diff --git a/.cargo/config.toml b/.cargo/config.toml index d971518eb..42a4adb55 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -12,3 +12,5 @@ rustflags = [ #rustflags = [ # "-C", "link-args=-Wl,-Bstatic -lc -Wl,-Bdynamic" #] +[net] +git-fetch-with-cli = true diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index d8781bb0c..dbb8ed8a9 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -26,8 +26,8 @@ body: - type: input id: os attributes: - label: Operating system(s) on local side and remote side - description: What operating system(s) do you see this bug on? local side -> remote side. + label: Operating system(s) on local (controlling) side and remote (controlled) side + description: What operating system(s) do you see this bug on? local (controlling) side -> remote (controlled) side. placeholder: | Windows 10 -> osx validations: @@ -35,8 +35,8 @@ body: - type: input id: version attributes: - label: RustDesk Version(s) on local side and remote side - description: What RustDesk version(s) do you see this bug on? local side -> remote side. + label: RustDesk Version(s) on local (controlling) side and remote (controlled) side + description: What RustDesk version(s) do you see this bug on? local (controlling) side -> remote (controlled) side. placeholder: | 1.1.9 -> 1.1.8 validations: diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..56258e4e0 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +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/flutter_3.24.4_dropdown_menu_enableFilter.diff b/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff new file mode 100644 index 000000000..9b8ea2690 --- /dev/null +++ b/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff @@ -0,0 +1,42 @@ +diff --git a/packages/flutter/lib/src/material/dropdown_menu.dart b/packages/flutter/lib/src/material/dropdown_menu.dart +index 7e634cd2aa..c1e9acc295 100644 +--- a/packages/flutter/lib/src/material/dropdown_menu.dart ++++ b/packages/flutter/lib/src/material/dropdown_menu.dart +@@ -475,7 +475,7 @@ class _DropdownMenuState extends State> { + final GlobalKey _leadingKey = GlobalKey(); + late List buttonItemKeys; + final MenuController _controller = MenuController(); +- late bool _enableFilter; ++ bool _enableFilter = false; + late List> filteredEntries; + List? _initialMenu; + int? currentHighlight; +@@ -524,6 +524,11 @@ class _DropdownMenuState extends State> { + } + _localTextEditingController = widget.controller ?? TextEditingController(); + } ++ if (oldWidget.enableFilter != widget.enableFilter) { ++ if (!widget.enableFilter) { ++ _enableFilter = false; ++ } ++ } + if (oldWidget.enableSearch != widget.enableSearch) { + if (!widget.enableSearch) { + currentHighlight = null; +@@ -663,6 +668,7 @@ class _DropdownMenuState extends State> { + ); + currentHighlight = widget.enableSearch ? i : null; + widget.onSelected?.call(entry.value); ++ _enableFilter = false; + } + : null, + requestFocusOnHover: false, +@@ -735,6 +741,8 @@ class _DropdownMenuState extends State> { + if (_enableFilter) { + filteredEntries = widget.filterCallback?.call(filteredEntries, _localTextEditingController!.text) + ?? filter(widget.dropdownMenuEntries, _localTextEditingController!); ++ } else { ++ filteredEntries = widget.dropdownMenuEntries; + } + + if (widget.enableSearch) { diff --git a/.github/workflows/bridge.yml b/.github/workflows/bridge.yml index 54180ccdd..1913132e2 100644 --- a/.github/workflows/bridge.yml +++ b/.github/workflows/bridge.yml @@ -6,7 +6,8 @@ on: workflow_call: env: - FLUTTER_VERSION: "3.19.6" + CARGO_EXPAND_VERSION: "1.0.95" + FLUTTER_VERSION: "3.22.3" FLUTTER_RUST_BRIDGE_VERSION: "1.80.1" RUST_VERSION: "1.75" # https://github.com/rustdesk/rustdesk/discussions/7503 @@ -19,12 +20,14 @@ jobs: job: - { target: x86_64-unknown-linux-gnu, - os: ubuntu-20.04, + os: ubuntu-22.04, extra-build-args: "", } steps: - name: Checkout source code uses: actions/checkout@v4 + with: + submodules: recursive - name: Install prerequisites run: | @@ -37,9 +40,9 @@ jobs: gcc \ git \ g++ \ - libclang-10-dev \ + libclang-dev \ libgtk-3-dev \ - llvm-10-dev \ + llvm-dev \ nasm \ ninja-build \ pkg-config \ @@ -73,12 +76,14 @@ jobs: - name: Install flutter rust bridge deps shell: bash run: | - cargo install flutter_rust_bridge_codegen --version ${{ env.FLUTTER_RUST_BRIDGE_VERSION }} --features "uuid" - pushd flutter && flutter pub get && popd + cargo install cargo-expand --version ${{ env.CARGO_EXPAND_VERSION }} --locked + cargo install flutter_rust_bridge_codegen --version ${{ env.FLUTTER_RUST_BRIDGE_VERSION }} --features "uuid" --locked + pushd flutter && sed -i -e 's/extended_text: 14.0.0/extended_text: 13.0.0/g' pubspec.yaml && flutter pub get && popd - name: Run flutter rust bridge run: | - ~/.cargo/bin/flutter_rust_bridge_codegen --rust-input ./src/flutter_ffi.rs --dart-output ./flutter/lib/generated_bridge.dart + ~/.cargo/bin/flutter_rust_bridge_codegen --rust-input ./src/flutter_ffi.rs --dart-output ./flutter/lib/generated_bridge.dart --c-output ./flutter/macos/Runner/bridge_generated.h + cp ./flutter/macos/Runner/bridge_generated.h ./flutter/ios/Runner/bridge_generated.h - name: Upload Artifact uses: actions/upload-artifact@master @@ -89,3 +94,5 @@ jobs: ./src/bridge_generated.io.rs ./flutter/lib/generated_bridge.dart ./flutter/lib/generated_bridge.freezed.dart + ./flutter/macos/Runner/bridge_generated.h + ./flutter/ios/Runner/bridge_generated.h diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 90f312968..3a7d21d7e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,9 +4,8 @@ env: # MIN_SUPPORTED_RUST_VERSION: "1.46.0" # CICD_INTERMEDIATES_DIR: "_cicd-intermediates" VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite" - # vcpkg version: 2024.06.15 # for multiarch gcc compatibility - VCPKG_COMMIT_ID: "f7423ee180c4b7f40d43402c2feb3859161ef625" + VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b" on: workflow_dispatch: @@ -45,6 +44,8 @@ jobs: # steps: # - name: Checkout source code # uses: actions/checkout@v3 + # with: + # submodules: recursive # - name: Install rust toolchain (v${{ env.MIN_SUPPORTED_RUST_VERSION }}) # uses: actions-rs/toolchain@v1 @@ -80,9 +81,23 @@ jobs: # - { target: x86_64-apple-darwin , os: macos-10.15 } # - { target: x86_64-pc-windows-gnu , os: windows-2022 } # - { target: x86_64-pc-windows-msvc , os: windows-2022 } - - { target: x86_64-unknown-linux-gnu , os: ubuntu-20.04 } + - { target: x86_64-unknown-linux-gnu , os: ubuntu-24.04 } # - { target: x86_64-unknown-linux-musl , os: ubuntu-20.04, use-cross: true } steps: + - name: Free Disk Space (Ubuntu) + if: runner.os == 'Linux' + # jlumbroso/free-disk-space@main is used in .github\workflows\flutter-build.yml + # But pinning to a specific version to avoid unexpected issues is preferred. + uses: jlumbroso/free-disk-space@v1.3.1 + with: + tool-cache: false + android: true + dotnet: true + haskell: true + large-packages: false + docker-images: true + swap-storage: false + - name: Export GitHub Actions cache environment variables uses: actions/github-script@v6 with: @@ -92,6 +107,8 @@ jobs: - name: Checkout source code uses: actions/checkout@v4 + with: + submodules: recursive - name: Install prerequisites shell: bash @@ -108,6 +125,7 @@ jobs: g++ \ libpam0g-dev \ libasound2-dev \ + libunwind-dev \ libgstreamer1.0-dev \ libgstreamer-plugins-base1.0-dev \ libgtk-3-dev \ diff --git a/.github/workflows/flutter-build.yml b/.github/workflows/flutter-build.yml index dd21515ba..263bd67dc 100644 --- a/.github/workflows/flutter-build.yml +++ b/.github/workflows/flutter-build.yml @@ -20,32 +20,37 @@ on: env: SCITER_RUST_VERSION: "1.75" # https://github.com/rustdesk/rustdesk/discussions/7503, also 1.78 has ABI change which causes our sciter version not working, https://blog.rust-lang.org/2024/03/30/i128-layout-update.html RUST_VERSION: "1.75" # sciter failed on m1 with 1.78 because of https://blog.rust-lang.org/2024/03/30/i128-layout-update.html + MAC_RUST_VERSION: "1.81" # 1.81 is requred for macos, because of https://github.com/yury/cidre requires 1.81 CARGO_NDK_VERSION: "3.1.2" SCITER_ARMV7_CMAKE_VERSION: "3.29.7" - SCITER_NASM_DEBVERSION: "2.14-1" + SCITER_NASM_DEBVERSION: "2.15.05-1" LLVM_VERSION: "15.0.6" - FLUTTER_VERSION: "3.19.6" - ANDROID_FLUTTER_VERSION: "3.13.9" # >= 3.16 is very slow on my android phone, but work well on most of others. We may switch to new flutter after changing to texture rendering (I believe it can solve my problem). - FLUTTER_RUST_BRIDGE_VERSION: "1.80.1" + FLUTTER_VERSION: "3.24.5" + ANDROID_FLUTTER_VERSION: "3.24.5" # 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: 2024.07.12 - VCPKG_COMMIT_ID: "1de2026f28ead93ff1773e6e680387643e914ea1" - VERSION: "1.3.2" - NDK_VERSION: "r27b" + # vcpkg version: 2025.08.27 + # If we change the `VCPKG COMMIT_ID`, please remember: + # 1. Call `$VCPKG_ROOT/vcpkg x-update-baseline` to update the baseline in `vcpkg.json`. + # Or we may face build issue like + # 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" + ARMV7_VCPKG_COMMIT_ID: "6f29f12e82a8293156836ad81cc9bf5af41fe836" # 2025.01.13, got "/opt/artifacts/vcpkg/vcpkg: No such file or directory" with latest version + VERSION: "1.4.6" + NDK_VERSION: "r28c" #signing keys env variable checks ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}" MACOS_P12_BASE64: "${{ secrets.MACOS_P12_BASE64 }}" - # To make a custom build with your own servers set the below secret values - RS_PUB_KEY: "${{ secrets.RS_PUB_KEY }}" - RENDEZVOUS_SERVER: "${{ secrets.RENDEZVOUS_SERVER }}" - API_SERVER: "${{ secrets.API_SERVER }}" UPLOAD_ARTIFACT: "${{ inputs.upload-artifact }}" - SIGN_BASE_URL: "${{ secrets.SIGN_BASE_URL }}" + SIGN_BASE_URL: "${{ secrets.SIGN_BASE_URL }}-2" jobs: + generate-bridge: + uses: ./.github/workflows/bridge.yml + build-RustDeskTempTopMostWindow: uses: ./.github/workflows/third-party-RustDeskTempTopMostWindow.yml with: @@ -59,7 +64,7 @@ jobs: build-for-windows-flutter: name: ${{ matrix.job.target }} - needs: [build-RustDeskTempTopMostWindow] + needs: [build-RustDeskTempTopMostWindow, generate-bridge] runs-on: ${{ matrix.job.os }} strategy: fail-fast: false @@ -84,6 +89,14 @@ jobs: - name: Checkout source code uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Restore bridge files + uses: actions/download-artifact@master + with: + name: bridge-artifact + path: ./ - name: Install LLVM and Clang uses: KyleMayes/install-llvm-action@v1 @@ -95,7 +108,22 @@ jobs: with: channel: "stable" flutter-version: ${{ env.FLUTTER_VERSION }} - cache: true + + # https://github.com/flutter/flutter/issues/155685 + - name: Replace engine with rustdesk custom flutter engine + run: | + flutter doctor -v + flutter precache --windows + Invoke-WebRequest -Uri https://github.com/rustdesk/engine/releases/download/main/windows-x64-release.zip -OutFile windows-x64-release.zip + Expand-Archive -Path windows-x64-release.zip -DestinationPath windows-x64-release + mv -Force windows-x64-release/*  C:/hostedtoolcache/windows/flutter/stable-${{ env.FLUTTER_VERSION }}-x64/bin/cache/artifacts/engine/windows-x64-release/ + + - name: Patch flutter + shell: bash + 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 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@v1 @@ -108,13 +136,6 @@ jobs: with: prefix-key: ${{ matrix.job.os }} - - name: Install flutter rust bridge deps - run: | - git config --global core.longpaths true - cargo install flutter_rust_bridge_codegen --version ${{ env.FLUTTER_RUST_BRIDGE_VERSION }} --features "uuid" - Push-Location flutter ; flutter pub get ; Pop-Location - ~/.cargo/bin/flutter_rust_bridge_codegen --rust-input ./src/flutter_ffi.rs --dart-output ./flutter/lib/generated_bridge.dart - - name: Setup vcpkg with Github Actions binary cache uses: lukka/run-vcpkg@v11 with: @@ -139,18 +160,49 @@ jobs: done exit 1 fi + head -n 100 "${VCPKG_ROOT}/buildtrees/ffmpeg/build-${{ matrix.job.vcpkg-triplet }}-rel-out.log" || true shell: bash - name: Build rustdesk run: | + # Windows: build RustDesk + python3 .\build.py --portable --hwcodec --flutter --vram --skip-portable-pack + mv ./flutter/build/windows/x64/runner/Release ./rustdesk + + # Download usbmmidd_v2.zip and extract it to ./rustdesk Invoke-WebRequest -Uri https://github.com/rustdesk-org/rdev/releases/download/usbmmidd_v2/usbmmidd_v2.zip -OutFile usbmmidd_v2.zip Expand-Archive usbmmidd_v2.zip -DestinationPath . - python3 .\build.py --portable --hwcodec --flutter --vram --skip-portable-pack Remove-Item -Path usbmmidd_v2\Win32 -Recurse Remove-Item -Path "usbmmidd_v2\deviceinstaller64.exe", "usbmmidd_v2\deviceinstaller.exe", "usbmmidd_v2\usbmmidd.bat" - mv ./flutter/build/windows/x64/runner/Release ./rustdesk mv -Force .\usbmmidd_v2 ./rustdesk + # Download printer driver files and extract them to ./rustdesk + try { + Invoke-WebRequest -Uri https://github.com/rustdesk/hbb_common/releases/download/driver/rustdesk_printer_driver_v4-1.4.zip -OutFile rustdesk_printer_driver_v4-1.4.zip + Invoke-WebRequest -Uri https://github.com/rustdesk/hbb_common/releases/download/driver/printer_driver_adapter.zip -OutFile printer_driver_adapter.zip + Invoke-WebRequest -Uri https://github.com/rustdesk/hbb_common/releases/download/driver/sha256sums -OutFile sha256sums + + # Check and move the files + $checksum_driver = (Select-String -Path .\sha256sums -Pattern '^([a-fA-F0-9]{64}) \*rustdesk_printer_driver_v4-1.4\.zip$').Matches.Groups[1].Value + $downloadsum_driver = Get-FileHash -Path rustdesk_printer_driver_v4-1.4.zip -Algorithm SHA256 + $checksum_adapter = (Select-String -Path .\sha256sums -Pattern '^([a-fA-F0-9]{64}) \*printer_driver_adapter\.zip$').Matches.Groups[1].Value + $downloadsum_adapter = Get-FileHash -Path printer_driver_adapter.zip -Algorithm SHA256 + if ($checksum_driver -eq $downloadsum_driver.Hash -and $checksum_adapter -eq $downloadsum_adapter.Hash) { + Write-Output "rustdesk_printer_driver_v4-1.4, checksums match, extract the file." + Expand-Archive rustdesk_printer_driver_v4-1.4.zip -DestinationPath . + mkdir ./rustdesk/drivers + mv -Force .\rustdesk_printer_driver_v4-1.4 ./rustdesk/drivers/RustDeskPrinterDriver + Expand-Archive printer_driver_adapter.zip -DestinationPath . + mv -Force .\printer_driver_adapter.dll ./rustdesk + } elseif ($checksum_driver -ne $downloadsum_driver.Hash) { + Write-Output "rustdesk_printer_driver_v4-1.4, checksums do not match, ignore the file." + } else { + Write-Output "printer_driver_adapter.dll, checksums do not match, ignore the file." + } + } catch { + Write-Host "Ingore the printer driver error." + } + - name: find Runner.res # Windows: find Runner.res (compiled from ./flutter/windows/runner/Runner.rc), copy to ./Runner.res # Runner.rc does not contain actual version, but Runner.res does @@ -182,11 +234,11 @@ jobs: path: rustdesk - name: Sign rustdesk files - if: env.UPLOAD_ARTIFACT == 'true' && env.SIGN_BASE_URL != '' + if: env.UPLOAD_ARTIFACT == 'true' && env.SIGN_BASE_URL != '-2' shell: bash run: | pip3 install requests argparse - BASE_URL=${{ secrets.SIGN_BASE_URL }} SECRET_KEY=${{ secrets.SIGN_SECRET_KEY }} python3 res/job.py sign_files ./rustdesk/ + BASE_URL=${{ env.SIGN_BASE_URL }} SECRET_KEY=${{ secrets.SIGN_SECRET_KEY }} python3 res/job.py sign_files ./rustdesk/ - name: Build self-extracted executable shell: bash @@ -214,10 +266,10 @@ jobs: sha256sum ../../SignOutput/rustdesk-*.msi - name: Sign rustdesk self-extracted file - if: env.UPLOAD_ARTIFACT == 'true' && env.SIGN_BASE_URL != '' + if: env.UPLOAD_ARTIFACT == 'true' && env.SIGN_BASE_URL != '-2' shell: bash run: | - BASE_URL=${{ secrets.SIGN_BASE_URL }} SECRET_KEY=${{ secrets.SIGN_SECRET_KEY }} python3 res/job.py sign_files ./SignOutput + BASE_URL=${{ env.SIGN_BASE_URL }} SECRET_KEY=${{ secrets.SIGN_SECRET_KEY }} python3 res/job.py sign_files ./SignOutput - name: Publish Release uses: softprops/action-gh-release@v1 @@ -258,6 +310,8 @@ jobs: - name: Checkout source code uses: actions/checkout@v4 + with: + submodules: recursive - name: Install LLVM and Clang uses: rustdesk-org/install-llvm-action-32bit@master @@ -299,6 +353,7 @@ jobs: done exit 1 fi + head -n 100 "${VCPKG_ROOT}/buildtrees/ffmpeg/build-${{ matrix.job.vcpkg-triplet }}-rel-out.log" || true shell: bash - name: Build rustdesk @@ -337,12 +392,19 @@ jobs: ls -l ./libs/portable/Runner.res; fi + - name: Upload unsigned + if: env.UPLOAD_ARTIFACT == 'true' + uses: actions/upload-artifact@master + with: + name: rustdesk-unsigned-windows-${{ matrix.job.arch }} + path: Release + - name: Sign rustdesk files - if: env.UPLOAD_ARTIFACT == 'true' && env.SIGN_BASE_URL != '' + if: env.UPLOAD_ARTIFACT == 'true' && env.SIGN_BASE_URL != '-2' shell: bash run: | pip3 install requests argparse - BASE_URL=${{ secrets.SIGN_BASE_URL }} SECRET_KEY=${{ secrets.SIGN_SECRET_KEY }} python3 res/job.py sign_files ./Release/ + BASE_URL=${{ env.SIGN_BASE_URL }} SECRET_KEY=${{ secrets.SIGN_SECRET_KEY }} python3 res/job.py sign_files ./Release/ - name: Build self-extracted executable shell: bash @@ -356,10 +418,10 @@ jobs: mv ./target/release/rustdesk-portable-packer.exe ./SignOutput/rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}-sciter.exe - name: Sign rustdesk self-extracted file - if: env.UPLOAD_ARTIFACT == 'true' && env.SIGN_BASE_URL != '' + if: env.UPLOAD_ARTIFACT == 'true' && env.SIGN_BASE_URL != '-2' shell: bash run: | - BASE_URL=${{ secrets.SIGN_BASE_URL }} SECRET_KEY=${{ secrets.SIGN_SECRET_KEY }} python3 res/job.py sign_files ./SignOutput/ + BASE_URL=${{ env.SIGN_BASE_URL }} SECRET_KEY=${{ secrets.SIGN_SECRET_KEY }} python3 res/job.py sign_files ./SignOutput/ - name: Publish Release uses: softprops/action-gh-release@v1 @@ -370,82 +432,11 @@ jobs: files: | ./SignOutput/rustdesk-*.exe - build-for-macOS-arm64-selfhost: - # use build-for-macOS instead - if: false - runs-on: [self-hosted, macOS, ARM64] - steps: - - name: Export GitHub Actions cache environment variables - uses: actions/github-script@v6 - with: - script: | - core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || ''); - core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || ''); - - - name: Checkout source code - uses: actions/checkout@v4 - - - name: Install flutter rust bridge deps - shell: bash - run: | - cargo install flutter_rust_bridge_codegen --version ${{ env.FLUTTER_RUST_BRIDGE_VERSION }} --features "uuid" - pushd flutter && flutter pub get && popd - ~/.cargo/bin/flutter_rust_bridge_codegen --rust-input ./src/flutter_ffi.rs --dart-output ./flutter/lib/generated_bridge.dart --c-output ./flutter/macos/Runner/bridge_generated.h - - - name: Build rustdesk - run: | - ./build.py --flutter --hwcodec - - - name: create unsigned dmg - if: env.UPLOAD_ARTIFACT == 'true' - run: | - CREATE_DMG="$(command -v create-dmg)" - CREATE_DMG="$(readlink -f "$CREATE_DMG")" - sed -i -e 's/MAXIMUM_UNMOUNTING_ATTEMPTS=3/MAXIMUM_UNMOUNTING_ATTEMPTS=7/' "$CREATE_DMG" - create-dmg --icon "RustDesk.app" 200 190 --hide-extension "RustDesk.app" --window-size 800 400 --app-drop-link 600 185 rustdesk-${{ env.VERSION }}-arm64.dmg ./flutter/build/macos/Build/Products/Release/RustDesk.app - - - name: Upload unsigned macOS app - if: env.UPLOAD_ARTIFACT == 'true' - uses: actions/upload-artifact@master - with: - name: rustdesk-unsigned-macos-arm64 - path: rustdesk-${{ env.VERSION }}-arm64.dmg # can not upload the directory directly or tar.gz file, which destroy the link structure, causing the codesign failed - - - name: Codesign app and create signed dmg - if: env.MACOS_P12_BASE64 != null && env.UPLOAD_ARTIFACT == 'true' - run: | - # Patch create-dmg to give more attempts to unmount image - CREATE_DMG="$(command -v create-dmg)" - CREATE_DMG="$(readlink -f "$CREATE_DMG")" - sed -i -e 's/MAXIMUM_UNMOUNTING_ATTEMPTS=3/MAXIMUM_UNMOUNTING_ATTEMPTS=7/' "$CREATE_DMG" - # start sign the rustdesk.app and dmg - rm -rf *.dmg || true - codesign --force --options runtime -s ${{ secrets.MACOS_CODESIGN_IDENTITY }} --deep --strict ./flutter/build/macos/Build/Products/Release/RustDesk.app -vvv - create-dmg --icon "RustDesk.app" 200 190 --hide-extension "RustDesk.app" --window-size 800 400 --app-drop-link 600 185 rustdesk-${{ env.VERSION }}.dmg ./flutter/build/macos/Build/Products/Release/RustDesk.app - codesign --force --options runtime -s ${{ secrets.MACOS_CODESIGN_IDENTITY }} --deep --strict rustdesk-${{ env.VERSION }}.dmg -vvv - # notarize the rustdesk-${{ env.VERSION }}.dmg - rcodesign notary-submit --api-key-path ~/.p12/api-key.json --staple rustdesk-${{ env.VERSION }}.dmg - - - name: Rename rustdesk - if: env.UPLOAD_ARTIFACT == 'true' - run: | - for name in rustdesk*??.dmg; do - mv "$name" "${name%%.dmg}-aarch64.dmg" - done - - - name: Publish DMG package - if: env.UPLOAD_ARTIFACT == 'true' - uses: softprops/action-gh-release@v1 - with: - prerelease: true - tag_name: ${{ env.TAG_NAME }} - files: | - rustdesk*-aarch64.dmg - build-rustdesk-ios: if: ${{ inputs.upload-artifact }} name: build rustdesk ios ipa runs-on: ${{ matrix.job.os }} + needs: [generate-bridge] strategy: fail-fast: false matrix: @@ -453,7 +444,7 @@ jobs: - { arch: aarch64, target: aarch64-apple-ios, - os: macos-13, + os: macos-latest, vcpkg-triplet: arm64-ios, } steps: @@ -469,12 +460,20 @@ jobs: brew install nasm yasm - name: Checkout source code uses: actions/checkout@v4 + with: + submodules: recursive + - name: Install flutter uses: subosito/flutter-action@v2 with: channel: "stable" flutter-version: ${{ env.FLUTTER_VERSION }} + - 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 + - name: Setup vcpkg with Github Actions binary cache uses: lukka/run-vcpkg@v11 with: @@ -496,6 +495,7 @@ jobs: done exit 1 fi + head -n 100 "${VCPKG_ROOT}/buildtrees/ffmpeg/build-${{ matrix.job.vcpkg-triplet }}-rel-out.log" || true shell: bash - name: Install Rust toolchain @@ -510,75 +510,24 @@ jobs: prefix-key: rustdesk-lib-cache-ios key: ${{ matrix.job.target }} - - name: Install flutter rust bridge deps - shell: bash - run: | - cargo install flutter_rust_bridge_codegen --version ${{ env.FLUTTER_RUST_BRIDGE_VERSION }} --features "uuid" - pushd flutter && flutter pub get && popd - ~/.cargo/bin/flutter_rust_bridge_codegen --rust-input ./src/flutter_ffi.rs --dart-output ./flutter/lib/generated_bridge.dart --c-output ./flutter/ios/Runner/bridge_generated.h + - name: Restore bridge files + uses: actions/download-artifact@master + with: + name: bridge-artifact + path: ./ - name: Build rustdesk lib run: | rustup target add ${{ matrix.job.target }} cargo build --features flutter,hwcodec --release --target aarch64-apple-ios --lib - - - name: Build rustdesk - shell: bash - run: | - pushd flutter - # flutter build ipa --release --obfuscate --split-debug-info=./split-debug-info --no-codesign - # for easy debugging - flutter build ipa --release --no-codesign - - # - name: Upload Artifacts - # # if: env.ANDROID_SIGNING_KEY != null && env.UPLOAD_ARTIFACT == 'true' - # uses: actions/upload-artifact@master - # with: - # name: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.apk - # path: flutter/build/ios/ipa/*.ipa - - # - name: Publish ipa package - # # if: env.ANDROID_SIGNING_KEY != null && env.UPLOAD_ARTIFACT == 'true' - # uses: softprops/action-gh-release@v1 - # with: - # prerelease: true - # tag_name: ${{ env.TAG_NAME }} - # files: | - # flutter/build/ios/ipa/*.ipa - - build-rustdesk-ios-selfhost: - #if: ${{ inputs.upload-artifact }} - if: false - runs-on: [self-hosted, macOS, ARM64] - strategy: - fail-fast: false - steps: - - name: Export GitHub Actions cache environment variables - uses: actions/github-script@v6 + + - name: Upload liblibrustdesk.a Artifacts + uses: actions/upload-artifact@master with: - script: | - core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || ''); - core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || ''); - - - name: Checkout source code - uses: actions/checkout@v4 - - # $VCPKG_ROOT/vcpkg install --triplet arm64-ios --x-install-root="$VCPKG_ROOT/installed" - - - name: Install flutter rust bridge deps - shell: bash - run: | - cargo install flutter_rust_bridge_codegen --version ${{ env.FLUTTER_RUST_BRIDGE_VERSION }} --features "uuid" - pushd flutter && flutter pub get && popd - ~/.cargo/bin/flutter_rust_bridge_codegen --rust-input ./src/flutter_ffi.rs --dart-output ./flutter/lib/generated_bridge.dart --c-output ./flutter/ios/Runner/bridge_generated.h - - - name: Build rustdesk lib - run: | - cargo build --features flutter,hwcodec --release --target aarch64-apple-ios --lib + name: liblibrustdesk.a + path: target/aarch64-apple-ios/release/liblibrustdesk.a - name: Build rustdesk - # ios sdk not installed on this machine, I will install it later after I am back home - if: false shell: bash run: | pushd flutter @@ -602,25 +551,29 @@ jobs: # files: | # flutter/build/ios/ipa/*.ipa + build-for-macOS: name: ${{ matrix.job.target }} runs-on: ${{ matrix.job.os }} + needs: [generate-bridge] strategy: fail-fast: false matrix: job: - { target: x86_64-apple-darwin, - os: macos-13, #macos-latest or macos-14 use M1 now, https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners/about-github-hosted-runners#:~:text=14%20GB-,macos%2Dlatest%20or%20macos%2D14,-The%20macos%2Dlatestlabel + os: macos-15-intel, #macos-latest or macos-14 use M1 now, https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners/about-github-hosted-runners#:~:text=14%20GB-,macos%2Dlatest%20or%20macos%2D14,-The%20macos%2Dlatestlabel extra-build-args: "", arch: x86_64, + vcpkg-triplet: x64-osx, } - { target: aarch64-apple-darwin, - os: macos-latest, + os: macos-14, # extra-build-args: "--disable-flutter-texture-render", # disable this for mac, because we see a lot of users reporting flickering both on arm and x64, and we can not confirm if texture rendering has better performance if htere is no vram, https://github.com/rustdesk/rustdesk/issues/6296 - extra-build-args: "", + extra-build-args: "--screencapturekit", arch: aarch64, + vcpkg-triplet: arm64-osx, } steps: - name: Export GitHub Actions cache environment variables @@ -632,6 +585,8 @@ jobs: - name: Checkout source code uses: actions/checkout@v4 + with: + submodules: recursive - name: Import the codesign cert if: env.MACOS_P12_BASE64 != null @@ -668,7 +623,24 @@ jobs: - name: Install build runtime run: | - brew install llvm create-dmg nasm cmake gcc wget ninja pkg-config + brew install llvm create-dmg + # pkg-config is handled in a separate step, because it may be already installed by `macos-latest`(14.7.1) runner + if command -v pkg-config &>/dev/null; then + echo "pkg-config is already installed" + else + brew install pkg-config + fi + + - name: Install NASM + run: | + # Install NASM 2.16.x from official release. + # Do NOT use `brew install nasm` which installs NASM 3.x. + # NASM 3.x is a complete rewrite with incompatible CLI options and removed features. + # aom and other multimedia libraries require NASM 2.x for x86/x86_64 assembly. + wget https://www.nasm.us/pub/nasm/releasebuilds/2.16.03/macosx/nasm-2.16.03-macosx.zip + unzip nasm-2.16.03-macosx.zip + sudo cp nasm-2.16.03/nasm /usr/local/bin/nasm + nasm --version - name: Install flutter uses: subosito/flutter-action@v2 @@ -676,6 +648,11 @@ jobs: channel: "stable" flutter-version: ${{ env.FLUTTER_VERSION }} + - 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 + - name: Workaround for flutter issue shell: bash run: | @@ -687,7 +664,7 @@ jobs: - name: Install Rust toolchain uses: dtolnay/rust-toolchain@v1 with: - toolchain: ${{ env.RUST_VERSION }} + toolchain: ${{ env.MAC_RUST_VERSION }} targets: ${{ matrix.job.target }} components: "rustfmt" @@ -695,12 +672,11 @@ jobs: with: prefix-key: ${{ matrix.job.os }} - - name: Install flutter rust bridge deps - shell: bash - run: | - cargo install flutter_rust_bridge_codegen --version ${{ env.FLUTTER_RUST_BRIDGE_VERSION }} --features "uuid" - pushd flutter && flutter pub get && popd - ~/.cargo/bin/flutter_rust_bridge_codegen --rust-input ./src/flutter_ffi.rs --dart-output ./flutter/lib/generated_bridge.dart --c-output ./flutter/macos/Runner/bridge_generated.h + - name: Restore bridge files + uses: actions/download-artifact@master + with: + name: bridge-artifact + path: ./ - name: Setup vcpkg with Github Actions binary cache uses: lukka/run-vcpkg@v11 @@ -722,6 +698,7 @@ jobs: done exit 1 fi + head -n 100 "${VCPKG_ROOT}/buildtrees/ffmpeg/build-${{ matrix.job.vcpkg-triplet }}-rel-out.log" || true - name: Show version information (Rust, cargo, Clang) shell: bash @@ -735,7 +712,14 @@ jobs: - name: Build rustdesk run: | - ./build.py --flutter --hwcodec ${{ matrix.job.extra-build-args }} + if [ "${{ matrix.job.target }}" = "aarch64-apple-darwin" ]; then + MIN_MACOS_VERSION="12.3" + sed -i -e "s/MACOSX_DEPLOYMENT_TARGET\=[0-9]*.[0-9]*/MACOSX_DEPLOYMENT_TARGET=${MIN_MACOS_VERSION}/" build.py + sed -i -e "s/platform :osx, '.*'/platform :osx, '${MIN_MACOS_VERSION}'/" flutter/macos/Podfile + sed -i -e "s/osx_minimum_system_version = \"[0-9]*.[0-9]*\"/osx_minimum_system_version = \"${MIN_MACOS_VERSION}\"/" Cargo.toml + sed -i -e "s/MACOSX_DEPLOYMENT_TARGET = [0-9]*.[0-9]*;/MACOSX_DEPLOYMENT_TARGET = ${MIN_MACOS_VERSION};/" flutter/macos/Runner.xcodeproj/project.pbxproj + fi + ./build.py --flutter --hwcodec --unix-file-copy-paste ${{ matrix.job.extra-build-args }} - name: create unsigned dmg if: env.UPLOAD_ARTIFACT == 'true' @@ -790,6 +774,7 @@ jobs: needs: - build-for-macOS - build-for-windows-flutter + - build-for-windows-sciter runs-on: ubuntu-latest if: ${{ inputs.upload-artifact }} steps: @@ -811,9 +796,15 @@ jobs: name: rustdesk-unsigned-windows-x86_64 path: ./windows-x86_64/ + - name: Download Artifacts + uses: actions/download-artifact@master + with: + name: rustdesk-unsigned-windows-x86 + path: ./windows-x86/ + - name: Combine unsigned app run: | - tar czf rustdesk-${{ env.VERSION }}-unsigned.tar.gz *.dmg windows-x86_64 + tar czf rustdesk-${{ env.VERSION }}-unsigned.tar.gz *.dmg windows-x86_64 windows-x86 - name: Publish unsigned app uses: softprops/action-gh-release@v1 @@ -822,11 +813,8 @@ jobs: tag_name: ${{ env.TAG_NAME }} files: rustdesk-${{ env.VERSION }}-unsigned.tar.gz - generate-bridge-linux: - uses: ./.github/workflows/bridge.yml - build-rustdesk-android: - needs: [generate-bridge-linux] + needs: [generate-bridge] name: build rustdesk android apk ${{ matrix.job.target }} runs-on: ${{ matrix.job.os }} strategy: @@ -836,21 +824,21 @@ jobs: - { arch: aarch64, target: aarch64-linux-android, - os: ubuntu-20.04, + os: ubuntu-24.04, reltype: release, suffix: "", } - { arch: armv7, target: armv7-linux-androideabi, - os: ubuntu-20.04, + os: ubuntu-24.04, reltype: release, suffix: "", } - { arch: x86_64, target: x86_64-linux-android, - os: ubuntu-20.04, + os: ubuntu-24.04, reltype: release, suffix: "", } @@ -887,34 +875,43 @@ jobs: libayatana-appindicator3-dev \ libasound2-dev \ libc6-dev \ - libclang-10-dev \ + libclang-dev \ + libunwind-dev \ libgstreamer1.0-dev \ libgstreamer-plugins-base1.0-dev \ libgtk-3-dev \ libpam0g-dev \ libpulse-dev \ libva-dev \ - libvdpau-dev \ libxcb-randr0-dev \ libxcb-shape0-dev \ libxcb-xfixes0-dev \ libxdo-dev \ libxfixes-dev \ - llvm-10-dev \ + llvm-dev \ nasm \ ninja-build \ - openjdk-11-jdk-headless \ + openjdk-17-jdk-headless \ pkg-config \ tree \ wget - name: Checkout source code uses: actions/checkout@v4 + with: + submodules: recursive + - name: Install flutter uses: subosito/flutter-action@v2 with: channel: "stable" flutter-version: ${{ env.ANDROID_FLUTTER_VERSION }} + + - 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 + - uses: nttld/setup-ndk@v1 id: setup-ndk with: @@ -973,23 +970,13 @@ jobs: prefix-key: rustdesk-lib-cache-android # TODO: drop '-android' part after caches are invalidated key: ${{ matrix.job.target }} - - name: fix android for flutter 3.13 - if: $${{ env.ANDROID_FLUTTER_VERSION == '3.13.9' }} - run: | - cd flutter - sed -i 's/uni_links_desktop/#uni_links_desktop/g' pubspec.yaml - sed -i 's/extended_text: .*/extended_text: 11.1.0/' pubspec.yaml - flutter pub get - cd lib - find . | grep dart | xargs sed -i 's/textScaler: TextScaler.linear(\(.*\)),/textScaleFactor: \1,/g' - - name: Build rustdesk lib env: ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }} ANDROID_NDK_ROOT: ${{ steps.setup-ndk.outputs.ndk-path }} run: | rustup target add ${{ matrix.job.target }} - cargo install cargo-ndk --version ${{ env.CARGO_NDK_VERSION }} + cargo install cargo-ndk --version ${{ env.CARGO_NDK_VERSION }} --locked case ${{ matrix.job.target }} in aarch64-linux-android) ./flutter/ndk_arm64.sh @@ -1022,9 +1009,11 @@ 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 + # Increase Gradle JVM memory for CI builds + sed -i "s/org.gradle.jvmargs=-Xmx1024M/org.gradle.jvmargs=-Xmx2g/g" ./flutter/android/gradle.properties # temporary use debug sign config sed -i "s/signingConfigs.release/signingConfigs.debug/g" ./flutter/android/app/build.gradle case ${{ matrix.job.target }} in @@ -1069,6 +1058,14 @@ jobs: mkdir -p signed-apk; pushd signed-apk mv ../rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}${{ matrix.job.suffix }}.apk . + # https://github.com/r0adkll/sign-android-release/issues/84#issuecomment-1889636075 + - name: Setup sign tool version variable + shell: bash + run: | + BUILD_TOOL_VERSION=$(ls /usr/local/lib/android/sdk/build-tools/ | tail -n 1) + echo "ANDROID_SIGN_TOOL_VERSION=$BUILD_TOOL_VERSION" >> $GITHUB_ENV + echo Last build tool version is: $BUILD_TOOL_VERSION + - uses: r0adkll/sign-android-release@v1 name: Sign app APK if: env.ANDROID_SIGNING_KEY != null @@ -1080,8 +1077,8 @@ jobs: keyStorePassword: ${{ secrets.ANDROID_KEY_STORE_PASSWORD }} keyPassword: ${{ secrets.ANDROID_KEY_PASSWORD }} env: - # override default build-tools version (29.0.3) -- optional - BUILD_TOOLS_VERSION: "30.0.2" + # env.ANDROID_SIGN_TOOL_VERSION is set by Step "Setup sign tool version variable" + BUILD_TOOLS_VERSION: ${{ env.ANDROID_SIGN_TOOL_VERSION }} - name: Upload Artifacts if: env.ANDROID_SIGNING_KEY != null && env.UPLOAD_ARTIFACT == 'true' @@ -1112,7 +1109,7 @@ jobs: needs: [build-rustdesk-android] name: build rustdesk android universal apk if: ${{ inputs.upload-artifact }} - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 env: reltype: release x86_target: "" # can be ",android-x86" @@ -1150,35 +1147,43 @@ jobs: libayatana-appindicator3-dev \ libasound2-dev \ libc6-dev \ - libclang-10-dev \ + libclang-dev \ + libunwind-dev \ libgstreamer1.0-dev \ libgstreamer-plugins-base1.0-dev \ libgtk-3-dev \ libpam0g-dev \ libpulse-dev \ libva-dev \ - libvdpau-dev \ libxcb-randr0-dev \ libxcb-shape0-dev \ libxcb-xfixes0-dev \ libxdo-dev \ libxfixes-dev \ - llvm-10-dev \ + llvm-dev \ nasm \ ninja-build \ - openjdk-11-jdk-headless \ + openjdk-17-jdk-headless \ pkg-config \ tree \ wget - name: Checkout source code uses: actions/checkout@v4 + with: + submodules: recursive + - name: Install flutter uses: subosito/flutter-action@v2 with: channel: "stable" flutter-version: ${{ env.ANDROID_FLUTTER_VERSION }} + - 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 + - name: Restore bridge files uses: actions/download-artifact@master with: @@ -1210,22 +1215,14 @@ jobs: name: librustdesk.so.i686-linux-android path: ./flutter/android/app/src/main/jniLibs/x86 - - name: fix android for flutter 3.13 - if: $${{ env.ANDROID_FLUTTER_VERSION == '3.13.9' }} - run: | - cd flutter - sed -i 's/uni_links_desktop/#uni_links_desktop/g' pubspec.yaml - sed -i 's/extended_text: .*/extended_text: 11.1.0/' pubspec.yaml - flutter pub get - cd lib - find . | grep dart | xargs sed -i 's/textScaler: TextScaler.linear(\(.*\)),/textScaleFactor: \1,/g' - - 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 + # Increase Gradle JVM memory for CI builds + sed -i "s/org.gradle.jvmargs=-Xmx1024M/org.gradle.jvmargs=-Xmx2g/g" ./flutter/android/gradle.properties # temporary use debug sign config sed -i "s/signingConfigs.release/signingConfigs.debug/g" ./flutter/android/app/build.gradle mv ./flutter/android/app/src/main/jniLibs/arm64-v8a/liblibrustdesk.so ./flutter/android/app/src/main/jniLibs/arm64-v8a/librustdesk.so @@ -1245,6 +1242,14 @@ jobs: mkdir -p signed-apk mv ./flutter/build/app/outputs/flutter-apk/app-${{ env.reltype }}.apk signed-apk/rustdesk-${{ env.VERSION }}-universal${{ env.suffix }}.apk + # https://github.com/r0adkll/sign-android-release/issues/84#issuecomment-1889636075 + - name: Setup sign tool version variable + shell: bash + run: | + BUILD_TOOL_VERSION=$(ls /usr/local/lib/android/sdk/build-tools/ | tail -n 1) + echo "ANDROID_SIGN_TOOL_VERSION=$BUILD_TOOL_VERSION" >> $GITHUB_ENV + echo Last build tool version is: $BUILD_TOOL_VERSION + - uses: r0adkll/sign-android-release@v1 name: Sign app APK if: env.ANDROID_SIGNING_KEY != null @@ -1256,8 +1261,8 @@ jobs: keyStorePassword: ${{ secrets.ANDROID_KEY_STORE_PASSWORD }} keyPassword: ${{ secrets.ANDROID_KEY_PASSWORD }} env: - # override default build-tools version (29.0.3) -- optional - BUILD_TOOLS_VERSION: "30.0.2" + # env.ANDROID_SIGN_TOOL_VERSION is set by Step "Setup sign tool version variable" + BUILD_TOOLS_VERSION: ${{ env.ANDROID_SIGN_TOOL_VERSION }} - name: Upload Artifacts if: env.ANDROID_SIGNING_KEY != null && env.UPLOAD_ARTIFACT == 'true' @@ -1285,7 +1290,7 @@ jobs: signed-apk/rustdesk-${{ env.VERSION }}-universal${{ env.suffix }}.apk build-rustdesk-linux: - needs: [generate-bridge-linux] + needs: [generate-bridge] name: build rustdesk linux ${{ matrix.job.target }} runs-on: ${{ matrix.job.on }} strategy: @@ -1297,7 +1302,7 @@ jobs: arch: x86_64, target: x86_64-unknown-linux-gnu, distro: ubuntu18.04, - on: ubuntu-20.04, + on: ubuntu-22.04, deb_arch: amd64, vcpkg-triplet: x64-linux, } @@ -1305,7 +1310,7 @@ jobs: arch: aarch64, target: aarch64-unknown-linux-gnu, distro: ubuntu18.04, - on: [self-hosted, Linux, ARM64], + on: ubuntu-22.04-arm, deb_arch: arm64, vcpkg-triplet: arm64-linux, } @@ -1318,16 +1323,20 @@ jobs: core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || ''); - name: Maximize build space - if: ${{ matrix.job.arch == 'x86_64' }} 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 qemu-user-static + sudo apt-get install -y nasm + if [[ "${{ matrix.job.arch }}" == "x86_64" ]]; then + sudo apt-get install -y qemu-user-static + fi - name: Checkout source code uses: actions/checkout@v4 + with: + submodules: recursive - name: Set Swap Space if: ${{ matrix.job.arch == 'x86_64' }} @@ -1376,6 +1385,7 @@ jobs: - name: Install vcpkg dependencies if: matrix.job.arch == 'x86_64' || env.UPLOAD_ARTIFACT == 'true' run: | + sudo apt install -y libva-dev && apt show libva-dev if ! $VCPKG_ROOT/vcpkg \ install \ --triplet ${{ matrix.job.vcpkg-triplet }} \ @@ -1389,6 +1399,7 @@ jobs: done exit 1 fi + head -n 100 "${VCPKG_ROOT}/buildtrees/ffmpeg/build-${{ matrix.job.vcpkg-triplet }}-rel-out.log" || true shell: bash - name: Restore bridge files @@ -1433,7 +1444,6 @@ jobs: libpam0g-dev \ libpulse-dev \ libva-dev \ - libvdpau-dev \ libxcb-randr0-dev \ libxcb-shape0-dev \ libxcb-xfixes0-dev \ @@ -1448,7 +1458,8 @@ jobs: rpm \ unzip \ wget \ - xz-utils + xz-utils \ + libssl-dev # we have libopus compiled by us. apt-get remove -y libopus-dev || true # output devs @@ -1480,7 +1491,7 @@ jobs: export JOBS="" fi echo $JOBS - cargo build --lib $JOBS --features hwcodec,flutter --release + cargo build --lib $JOBS --features hwcodec,flutter,unix-file-copy-paste --release rm -rf target/release/deps target/release/build rm -rf ~/.cargo @@ -1520,6 +1531,19 @@ jobs: ;; esac + if [[ "3.24.5" == ${{ env.FLUTTER_VERSION }} ]]; then + case ${{ matrix.job.arch }} in + aarch64) + pushd /opt/flutter-elinux/flutter + ;; + x86_64) + pushd /opt/flutter + ;; + esac + git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff + popd + fi + # build flutter pushd /workspace export CARGO_INCREMENTAL=0 @@ -1604,7 +1628,6 @@ jobs: build-rustdesk-linux-sciter: if: ${{ inputs.upload-artifact }} - needs: build-rustdesk-linux # not for dep, just make it run later for parallelism runs-on: ${{ matrix.job.on }} name: build-rustdesk-linux-sciter ${{ matrix.job.target }} strategy: @@ -1615,22 +1638,22 @@ jobs: - { arch: x86_64, target: x86_64-unknown-linux-gnu, - on: ubuntu-20.04, + on: ubuntu-22.04, distro: ubuntu18.04, deb_arch: amd64, sciter_arch: x64, vcpkg-triplet: x64-linux, - extra_features: ",hwcodec", + extra_features: ",hwcodec,unix-file-copy-paste", } - { arch: armv7, target: armv7-unknown-linux-gnueabihf, - on: [self-hosted, Linux, ARM64], + on: ubuntu-22.04-arm, distro: ubuntu18.04-rustdesk, deb_arch: armhf, sciter_arch: arm32, vcpkg-triplet: arm-linux, - extra_features: "", + extra_features: ",unix-file-copy-paste", } steps: - name: Export GitHub Actions cache environment variables @@ -1642,6 +1665,16 @@ jobs: - name: Checkout source code uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Modify vcpkg.json for armv7 + if: matrix.job.vcpkg-triplet == 'arm-linux' + run: | + # Replace the baseline in vcpkg.json with ARMV7_VCPKG_COMMIT_ID for armv7 builds + sed -i 's/"baseline": ".*"/"baseline": "${{ env.ARMV7_VCPKG_COMMIT_ID }}"/' vcpkg.json + echo "Modified vcpkg.json for armv7 build:" + grep -A 2 -B 2 '"baseline"' vcpkg.json - name: Free Space run: | @@ -1693,7 +1726,6 @@ jobs: libpam0g-dev \ libpulse-dev \ libva-dev \ - libvdpau-dev \ libxcb-randr0-dev \ libxcb-shape0-dev \ libxcb-xfixes0-dev \ @@ -1707,12 +1739,13 @@ jobs: unzip \ wget \ xz-utils \ - zip + zip \ + libssl-dev # arm-linux needs CMake and vcokg built from source as there # are no prebuilts available from Kitware and Microsoft if [ "${{ matrix.job.vcpkg-triplet }}" = "arm-linux" ]; then # install gcc/g++ 8 for vcpkg and OpenSSL headers for CMake - apt-get install -y gcc-8 g++-8 libssl-dev + apt-get install -y gcc-8 g++-8 # bootstrap CMake amd add it to PATH git clone --depth 1 https://github.com/kitware/cmake -b "v${{ env.SCITER_ARMV7_CMAKE_VERSION }}" /tmp/cmake pushd /tmp/cmake @@ -1729,11 +1762,12 @@ jobs: rm -rf vcpkg git clone https://github.com/microsoft/vcpkg pushd vcpkg - git reset --hard ${{ env.VCPKG_COMMIT_ID }} # build vcpkg helper executable with gcc-8 for arm-linux but use prebuilt one on x64-linux if [ "${{ matrix.job.vcpkg-triplet }}" = "arm-linux" ]; then + git reset --hard ${{ env.ARMV7_VCPKG_COMMIT_ID }} CC=/usr/bin/gcc-8 CXX=/usr/bin/g++-8 sh bootstrap-vcpkg.sh -disableMetrics else + git reset --hard ${{ env.VCPKG_COMMIT_ID }} sh bootstrap-vcpkg.sh -disableMetrics fi popd @@ -1771,6 +1805,8 @@ jobs: cat ~/.cargo/config # install dependencies from vcpkg export VCPKG_ROOT=/opt/artifacts/vcpkg + # remove this when support higher version + export USE_AOM_391=1 if ! $VCPKG_ROOT/vcpkg install --triplet ${{ matrix.job.vcpkg-triplet }} --x-install-root="$VCPKG_ROOT/installed"; then find "${VCPKG_ROOT}/" -name "*.log" | while read -r _1; do echo "$_1:" @@ -1781,6 +1817,7 @@ jobs: done exit 1 fi + head -n 100 "${VCPKG_ROOT}/buildtrees/ffmpeg/build-${{ matrix.job.vcpkg-triplet }}-rel-out.log" || true # build rustdesk python3 ./res/inline-sciter.py export CARGO_INCREMENTAL=0 @@ -1819,7 +1856,7 @@ jobs: build-appimage: name: Build appimage ${{ matrix.job.target }} needs: [build-rustdesk-linux] - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 if: ${{ inputs.upload-artifact }} strategy: fail-fast: false @@ -1830,6 +1867,8 @@ jobs: steps: - name: Checkout source code uses: actions/checkout@v4 + with: + submodules: recursive - name: Download Binary uses: actions/download-artifact@master @@ -1846,13 +1885,11 @@ jobs: run: | # install libarchive-tools for bsdtar command used in AppImageBuilder.yml sudo apt-get update -y - sudo apt-get install -y libarchive-tools + # https://github.com/AppImage/AppImageKit/wiki/FUSE + sudo apt-get install -y libarchive-tools libfuse2 # set-up appimage-builder - pushd /tmp - wget -O appimage-builder-x86_64.AppImage https://github.com/AppImageCrafters/appimage-builder/releases/download/v1.1.0/appimage-builder-1.1.0-x86_64.AppImage - chmod +x appimage-builder-x86_64.AppImage - sudo mv appimage-builder-x86_64.AppImage /usr/local/bin/appimage-builder - popd + # https://github.com/AppImage/AppImageKit/issues/1395 + sudo pip3 install git+https://github.com/rustdesk-org/appimage-builder.git # run appimage-builder pushd appimage sudo appimage-builder --skip-tests --recipe ./AppImageBuilder-${{ matrix.job.arch }}.yml @@ -1879,15 +1916,16 @@ jobs: job: - { target: x86_64-unknown-linux-gnu, - distro: ubuntu18.04, - on: ubuntu-20.04, + # https://github.com/ostreedev/ostree/commit/4bac96a8c817beda37448f9b8c662162bb619981 + distro: ubuntu22.04, + on: ubuntu-22.04, arch: x86_64, suffix: "", } - { target: x86_64-unknown-linux-gnu, - distro: ubuntu18.04, - on: ubuntu-20.04, + distro: ubuntu22.04, + on: ubuntu-22.04, arch: x86_64, suffix: "-sciter", } @@ -1895,13 +1933,15 @@ jobs: target: aarch64-unknown-linux-gnu, # try out newer flatpak since error of "error: Nothing matches org.freedesktop.Platform in remote flathub" distro: ubuntu22.04, - on: [self-hosted, Linux, ARM64], + on: ubuntu-22.04-arm, arch: aarch64, suffix: "", } steps: - name: Checkout source code uses: actions/checkout@v4 + with: + submodules: recursive - name: Download Binary uses: actions/download-artifact@master @@ -1927,36 +1967,17 @@ jobs: shell: /bin/bash install: | apt-get update -y - apt-get install -y \ - curl \ - git \ - rpm \ - wget + apt-get install -y git flatpak flatpak-builder run: | # disable git safe.directory git config --global --add safe.directory "*" pushd /workspace - # install - apt-get update -y - apt-get install -y \ - cmake \ - curl \ - flatpak \ - flatpak-builder \ - gcc \ - git \ - g++ \ - libgtk-3-dev \ - nasm \ - wget # flatpak deps - flatpak --user remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo - flatpak --user install -y flathub org.freedesktop.Platform/${{ matrix.job.arch }}/23.08 - flatpak --user install -y flathub org.freedesktop.Sdk/${{ matrix.job.arch }}/23.08 + flatpak --user remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo # package pushd flatpak git clone https://github.com/flathub/shared-modules.git --depth=1 - flatpak-builder --user --force-clean --repo=repo ./build ./rustdesk.json + flatpak-builder --user --install-deps-from=flathub -y --force-clean --repo=repo ./build ./rustdesk.json flatpak build-bundle ./repo rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}${{ matrix.job.suffix }}.flatpak com.rustdesk.RustDesk - name: Publish flatpak package @@ -1970,7 +1991,9 @@ jobs: build-rustdesk-web: if: False name: build-rustdesk-web - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 + permissions: + contents: read strategy: fail-fast: false env: @@ -1978,6 +2001,8 @@ jobs: steps: - name: Checkout source code uses: actions/checkout@v4 + with: + submodules: recursive - name: Prepare env run: | @@ -1989,7 +2014,12 @@ jobs: with: channel: "stable" flutter-version: ${{ env.FLUTTER_VERSION }} - cache: true + + - name: Patch flutter + 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 # https://rustdesk.com/docs/en/dev/build/web/ - name: Build web diff --git a/.github/workflows/playground.yml b/.github/workflows/playground.yml index efd6974a9..110437e0f 100644 --- a/.github/workflows/playground.yml +++ b/.github/workflows/playground.yml @@ -16,9 +16,8 @@ env: FLUTTER_ELINUX_VERSION: "3.16.9" TAG_NAME: "nightly" VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite" - # vcpkg version: 2024.06.15 - VCPKG_COMMIT_ID: "f7423ee180c4b7f40d43402c2feb3859161ef625" - VERSION: "1.3.2" + VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b" + VERSION: "1.4.6" NDK_VERSION: "r26d" #signing keys env variable checks ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}" @@ -90,7 +89,8 @@ jobs: uses: actions/checkout@v3 with: ref: ${{ matrix.job.ref }} - + submodules: recursive + - name: Import the codesign cert if: env.MACOS_P12_BASE64 != null uses: apple-actions/import-codesign-certs@v1 @@ -126,7 +126,7 @@ jobs: - name: Install build runtime run: | - brew install llvm create-dmg nasm cmake gcc wget ninja pkg-config + brew install llvm create-dmg nasm pkg-config - name: Install flutter uses: subosito/flutter-action@v2 @@ -149,7 +149,7 @@ jobs: shell: bash run: | sed -i '' 's/3.1.0/2.17.0/g' flutter/pubspec.yaml; - cargo install flutter_rust_bridge_codegen --version ${{ matrix.job.bridge }} --features "uuid" + cargo install flutter_rust_bridge_codegen --version ${{ matrix.job.bridge }} --features "uuid" --locked # below works for mac to make buildable on 3.13.9 # pushd flutter/lib; find . -name "*.dart" | xargs -I{} sed -i '' 's/textScaler: TextScaler.linear(\(.*\)),/textScaleFactor: \1,/g' {}; popd; pushd flutter && flutter pub get && popd @@ -241,7 +241,7 @@ jobs: - { arch: aarch64, target: aarch64-linux-android, - os: ubuntu-20.04, + os: ubuntu-22.04, openssl-arch: android-arm64, ref: master, # latest } @@ -250,6 +250,7 @@ jobs: uses: actions/checkout@v3 with: ref: ${{ matrix.job.ref }} + submodules: recursive - name: Install dependencies run: | @@ -265,7 +266,8 @@ jobs: libayatana-appindicator3-dev\ libasound2-dev \ libc6-dev \ - libclang-10-dev \ + libclang-dev \ + libunwind-dev \ libgstreamer1.0-dev \ libgstreamer-plugins-base1.0-dev \ libgtk-3-dev \ @@ -278,7 +280,7 @@ jobs: libxcb-xfixes0-dev \ libxdo-dev \ libxfixes-dev \ - llvm-10-dev \ + llvm-dev \ nasm \ yasm \ ninja-build \ @@ -302,7 +304,7 @@ jobs: - name: Install flutter rust bridge deps run: | git config --global core.longpaths true - cargo install flutter_rust_bridge_codegen --version ${{ env.FLUTTER_RUST_BRIDGE_VERSION }} --features "uuid" + cargo install flutter_rust_bridge_codegen --version ${{ env.FLUTTER_RUST_BRIDGE_VERSION }} --features "uuid" --locked sed -i 's/uni_links_desktop/#uni_links_desktop/g' flutter/pubspec.yaml pushd flutter/lib; find . | grep dart | xargs sed -i 's/textScaler: TextScaler.linear(\(.*\)),/textScaleFactor: \1,/g'; popd; pushd flutter ; flutter pub get ; popd @@ -347,7 +349,7 @@ jobs: ANDROID_NDK_ROOT: ${{ steps.setup-ndk.outputs.ndk-path }} run: | rustup target add ${{ matrix.job.target }} - cargo install cargo-ndk --version ${{ env.CARGO_NDK_VERSION }} + cargo install cargo-ndk --version ${{ env.CARGO_NDK_VERSION }} --locked case ${{ matrix.job.target }} in aarch64-linux-android) ./flutter/ndk_arm64.sh diff --git a/.github/workflows/winget.yml b/.github/workflows/winget.yml deleted file mode 100644 index 52bb17e6f..000000000 --- a/.github/workflows/winget.yml +++ /dev/null @@ -1,13 +0,0 @@ -name: Publish to WinGet -on: - release: - types: [released] -jobs: - publish: - runs-on: ubuntu-latest - steps: - - uses: vedantmgoyal9/winget-releaser@main - with: - identifier: RustDesk.RustDesk - version: ${{ github.event.release.tag_name }} - token: ${{ secrets.WINGET_TOKEN }} diff --git a/.gitignore b/.gitignore index b4ea62660..d2e09a906 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ .vscode .idea .DS_Store +.env libsciter-gtk.so src/ui/inline.rs extractor diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..d80e69aa8 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "libs/hbb_common"] + path = libs/hbb_common + url = https://github.com/rustdesk/hbb_common diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..e36c65fab --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,62 @@ +# RustDesk Guide + +## Project Layout + +### Directory Structure +* `src/` Rust app +* `src/server/` audio / clipboard / input / video / network +* `src/platform/` platform-specific code +* `src/ui/` legacy Sciter UI (deprecated) +* `flutter/` current UI +* `libs/hbb_common/` config / proto / shared utils +* `libs/scrap/` screen capture +* `libs/enigo/` input control +* `libs/clipboard/` clipboard +* `libs/hbb_common/src/config.rs` all options + +### Key Components +- **Remote Desktop Protocol**: Custom protocol implemented in `src/rendezvous_mediator.rs` for communicating with rustdesk-server +- **Screen Capture**: Platform-specific screen capture in `libs/scrap/` +- **Input Handling**: Cross-platform input simulation in `libs/enigo/` +- **Audio/Video Services**: Real-time audio/video streaming in `src/server/` +- **File Transfer**: Secure file transfer implementation in `libs/hbb_common/` + +### UI Architecture +- **Legacy UI**: Sciter-based (deprecated) - files in `src/ui/` +- **Modern UI**: Flutter-based - files in `flutter/` + - Desktop: `flutter/lib/desktop/` + - Mobile: `flutter/lib/mobile/` + - Shared: `flutter/lib/common/` and `flutter/lib/models/` + +## Rust Rules + +* Avoid `unwrap()` / `expect()` in production code. +* Exceptions: + + * tests; + * lock acquisition where failure means poisoning, not normal control flow. +* Otherwise prefer `Result` + `?` or explicit handling. +* Do not ignore errors silently. +* Avoid unnecessary `.clone()`. +* Prefer borrowing when practical. +* Do not add dependencies unless needed. +* Keep code simple and idiomatic. + +## Tokio Rules + +* Assume a Tokio runtime already exists. +* Never create nested runtimes. +* Never call `Runtime::block_on()` inside Tokio / async code. +* Do not hide runtime creation inside helpers or libraries. +* Do not hold locks across `.await`. +* Prefer `.await`, `tokio::spawn`, channels. +* Use `spawn_blocking` or dedicated threads for blocking work. +* Do not use `std::thread::sleep()` in async code. + +## Editing Hygiene + +* Change only what is required. +* Prefer the smallest valid diff. +* Do not refactor unrelated code. +* Do not make formatting-only changes. +* Keep naming/style consistent with nearby code. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..c31706425 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md diff --git a/Cargo.lock b/Cargo.lock index 7565d34ee..fe1f67cc0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,22 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "ab_glyph" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e074464580a518d16a7126262fffaaa47af89d4099d4cb403f8ed938ba12ee7d" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + [[package]] name = "addr2line" version = "0.22.0" @@ -17,6 +33,22 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + [[package]] name = "aes" version = "0.8.4" @@ -28,27 +60,42 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "ahash" version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" dependencies = [ - "getrandom", + "getrandom 0.2.15", "once_cell", "version_check", ] [[package]] name = "ahash" -version = "0.8.11" +version = "0.8.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if 1.0.0", + "getrandom 0.3.2", "once_cell", "version_check", - "zerocopy 0.7.34", + "zerocopy 0.8.26", ] [[package]] @@ -87,12 +134,6 @@ dependencies = [ "alloc-no-stdlib", ] -[[package]] -name = "allocator-api2" -version = "0.2.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f" - [[package]] name = "alsa" version = "0.9.0" @@ -100,7 +141,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37fe60779335388a88c01ac6c3be40304d1e349de3ada3b15f7808bb90fa9dce" dependencies = [ "alsa-sys", - "bitflags 2.6.0", + "bitflags 2.9.1", "libc", ] @@ -114,6 +155,33 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "android-activity" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef6978589202a00cd7e118380c448a08b6ed394c3a8df3a430d0898e3a42d046" +dependencies = [ + "android-properties", + "bitflags 2.9.1", + "cc", + "cesu8", + "jni", + "jni-sys", + "libc", + "log", + "ndk 0.9.0", + "ndk-context", + "ndk-sys 0.6.0+11769913", + "num_enum 0.7.2", + "thiserror 1.0.61", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + [[package]] name = "android-tzdata" version = "0.1.1" @@ -125,7 +193,7 @@ name = "android-wakelock" version = "0.1.0" source = "git+https://github.com/rustdesk-org/android-wakelock#d0292e5a367e627c4fa6f1ca6bdfad005dca7d90" dependencies = [ - "jni 0.21.1", + "jni", "log", "ndk-context", ] @@ -183,9 +251,9 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.7" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "038dfcf04a5feb68e9c60b21c9625a54c2c0616e79b72b0fd87075a056ae1d1b" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" [[package]] name = "anstyle-parse" @@ -217,30 +285,100 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.86" +version = "1.0.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" +checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" [[package]] name = "arboard" version = "3.4.0" -source = "git+https://github.com/rustdesk-org/arboard#747ab2d9b40a5c9c5102051cf3b0bb38b4845e60" +source = "git+https://github.com/rustdesk-org/arboard#85be1218668ff218a7b170c9d424fde73e069914" dependencies = [ "clipboard-win", "core-graphics 0.23.2", "image 0.25.1", "log", "objc2 0.5.2", - "objc2-app-kit", - "objc2-foundation", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", "parking_lot", - "serde 1.0.203", + "percent-encoding", + "serde 1.0.228", "serde_derive", "windows-sys 0.48.0", "wl-clipboard-rs", "x11rb 0.13.1", ] +[[package]] +name = "arc-swap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits 0.2.19", + "rusticata-macros", + "thiserror 1.0.61", + "time 0.3.36", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2 1.0.93", + "quote 1.0.36", + "syn 2.0.98", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2 1.0.93", + "quote 1.0.36", + "syn 2.0.98", +] + +[[package]] +name = "associative-cache" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46016233fc1bb55c23b856fe556b7db6ccd05119a0a392e04f0b3b7c79058f16" + [[package]] name = "async-broadcast" version = "0.5.1" @@ -265,13 +403,13 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.11" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd066d0b4ef8ecb03a55319dc13aa6910616d0f44008a045bb1835af830abff5" +checksum = "5a89bce6054c720275ac2432fbba080a66a2106a44a1b804553930ca6909f4e0" dependencies = [ - "flate2", + "compression-codecs", + "compression-core", "futures-core", - "memchr", "pin-project-lite", "tokio", ] @@ -383,9 +521,9 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ - "proc-macro2 1.0.86", + "proc-macro2 1.0.93", "quote 1.0.36", - "syn 2.0.68", + "syn 2.0.98", ] [[package]] @@ -418,9 +556,9 @@ version = "0.1.80" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6fa2087f2753a7da8cc1c0dbfcf89579dd57458e36769de5ac750b4671737ca" dependencies = [ - "proc-macro2 1.0.86", + "proc-macro2 1.0.93", "quote 1.0.36", - "syn 2.0.68", + "syn 2.0.98", ] [[package]] @@ -469,6 +607,17 @@ dependencies = [ "winapi 0.3.9", ] +[[package]] +name = "auto_impl" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" +dependencies = [ + "proc-macro2 1.0.93", + "quote 1.0.36", + "syn 2.0.98", +] + [[package]] name = "autocfg" version = "0.1.8" @@ -494,11 +643,17 @@ dependencies = [ "cc", "cfg-if 1.0.0", "libc", - "miniz_oxide", + "miniz_oxide 0.7.4", "object", "rustc-demangle", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base32" version = "0.4.0" @@ -523,6 +678,15 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde 1.0.228", +] + [[package]] name = "bindgen" version = "0.59.2" @@ -538,10 +702,10 @@ dependencies = [ "lazycell", "log", "peeking_take_while", - "proc-macro2 1.0.86", + "proc-macro2 1.0.93", "quote 1.0.36", "regex", - "rustc-hash", + "rustc-hash 1.1.0", "shlex", "which", ] @@ -560,12 +724,12 @@ dependencies = [ "log", "peeking_take_while", "prettyplease", - "proc-macro2 1.0.86", + "proc-macro2 1.0.93", "quote 1.0.36", "regex", - "rustc-hash", + "rustc-hash 1.1.0", "shlex", - "syn 2.0.68", + "syn 2.0.98", "which", ] @@ -575,18 +739,36 @@ version = "0.69.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a00dc851838a2120612785d195287475a3ac45514741da670b735818822129a0" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.1", "cexpr", "clang-sys", "itertools 0.12.1", "lazy_static", "lazycell", - "proc-macro2 1.0.86", + "proc-macro2 1.0.93", "quote 1.0.36", "regex", - "rustc-hash", + "rustc-hash 1.1.0", "shlex", - "syn 2.0.68", + "syn 2.0.98", +] + +[[package]] +name = "bindgen" +version = "0.71.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" +dependencies = [ + "bitflags 2.9.1", + "cexpr", + "clang-sys", + "itertools 0.12.1", + "proc-macro2 1.0.93", + "quote 1.0.36", + "regex", + "rustc-hash 2.1.1", + "shlex", + "syn 2.0.98", ] [[package]] @@ -603,11 +785,11 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.6.0" +version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" +checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" dependencies = [ - "serde 1.0.203", + "serde 1.0.228", ] [[package]] @@ -617,7 +799,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "afb15541e888071f64592c0b4364fdff21b7cb0a247f984296699351963a8721" dependencies = [ "quote 1.0.36", - "syn 2.0.68", + "syn 2.0.98", ] [[package]] @@ -647,6 +829,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block-sys" version = "0.1.0-beta.1" @@ -675,6 +866,15 @@ dependencies = [ "objc2 0.5.2", ] +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.4", +] + [[package]] name = "blocking" version = "1.6.1" @@ -722,10 +922,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" [[package]] -name = "bytemuck" -version = "1.16.1" +name = "bytecodec" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b236fc92302c97ed75b38da1f4917b5cdda4984745740f153a5d3059e48d725e" +checksum = "adf4c9d0bbf32eea58d7c0f812058138ee8edaf0f2802b6d03561b504729a325" +dependencies = [ + "byteorder", + "trackable 0.2.24", +] + +[[package]] +name = "bytemuck" +version = "1.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3995eaeebcdf32f91f980d360f78732ddc061097ab4e39991ae7a6ace9194677" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f154e572231cb6ba2bd1176980827e3d5dc04cc183a75dea38109fbdd672d29" +dependencies = [ + "proc-macro2 1.0.93", + "quote 1.0.36", + "syn 2.0.98", +] [[package]] name = "byteorder" @@ -735,11 +959,11 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.6.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "514de17de45fdb8dc022b1a7975556c53c86f9f0aa5f534b98977b171857c2c9" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" dependencies = [ - "serde 1.0.203", + "serde 1.0.228", ] [[package]] @@ -787,12 +1011,12 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.1", "cairo-sys-rs", "glib 0.18.5", "libc", "once_cell", - "thiserror", + "thiserror 1.0.61", ] [[package]] @@ -807,14 +1031,86 @@ dependencies = [ ] [[package]] -name = "cc" -version = "1.0.102" +name = "calloop" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "779e6b7d17797c0b42023d417228c02889300190e700cb074c3438d9c541d332" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.9.1", + "log", + "polling 3.7.2", + "rustix 0.38.34", + "slab", + "thiserror 1.0.61", +] + +[[package]] +name = "calloop" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb9f6e1368bd4621d2c86baa7e37de77a938adf5221e5dd3d6133340101b309e" +dependencies = [ + "bitflags 2.9.1", + "polling 3.7.2", + "rustix 1.1.2", + "slab", + "tracing", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop 0.13.0", + "rustix 0.38.34", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop 0.14.3", + "rustix 1.1.2", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7777341816418c02e033934a09f20dc0ccaf65a5201ef8a450ae0105a573fda" dependencies = [ "jobserver", "libc", - "once_cell", + "shlex", +] + +[[package]] +name = "ccm" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae3c82e4355234767756212c570e29833699ab63e6ffd161887314cc5b43847" +dependencies = [ + "aead", + "cipher", + "ctr", + "subtle", ] [[package]] @@ -867,17 +1163,41 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] -name = "chrono" -version = "0.4.38" +name = "chacha20" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if 1.0.0", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "chrono" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" dependencies = [ "android-tzdata", "iana-time-zone", "js-sys", "num-traits 0.2.19", "wasm-bindgen", - "windows-targets 0.52.5", + "windows-link 0.1.1", ] [[package]] @@ -893,6 +1213,20 @@ dependencies = [ "regex", ] +[[package]] +name = "cidre" +version = "0.4.0" +source = "git+https://github.com/yury/cidre.git?rev=f05c428#f05c4288f9870c9fab53272ddafd6ec01c7b2dbf" +dependencies = [ + "cidre-macros", + "parking_lot", +] + +[[package]] +name = "cidre-macros" +version = "0.1.0" +source = "git+https://github.com/yury/cidre.git?rev=f05c428#f05c4288f9870c9fab53272ddafd6ec01c7b2dbf" + [[package]] name = "cipher" version = "0.4.4" @@ -901,6 +1235,7 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common", "inout", + "zeroize", ] [[package]] @@ -931,18 +1266,18 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.8" +version = "4.5.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84b3edb18336f4df585bc9aa31dd99c036dfa5dc5e9a2939a722a188f3a8970d" +checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" dependencies = [ "clap_builder", ] [[package]] name = "clap_builder" -version = "4.5.8" +version = "4.5.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1c09dd5ada6c6c78075d6fd0da3f90d8080651e2d6cc8eb2f1aaa4034ced708" +checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" dependencies = [ "anstream", "anstyle", @@ -952,9 +1287,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.1" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b82cf0babdbd58558212896d1a4272303a57bdb245c2bf1147185fb45640e70" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" [[package]] name = "clipboard" @@ -962,27 +1297,34 @@ version = "0.1.0" dependencies = [ "cacao", "cc", - "dashmap", + "dashmap 5.5.3", + "dirs 5.0.1", + "fsevent", "fuser", "hbb_common", "lazy_static", "libc", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", "once_cell", "parking_lot", "percent-encoding", "rand 0.8.5", - "serde 1.0.203", + "serde 1.0.228", "serde_derive", - "thiserror", + "thiserror 1.0.61", "utf16string", + "uuid", "x11-clipboard 0.8.1", "x11rb 0.12.0", + "xattr", ] [[package]] name = "clipboard-master" version = "4.0.0-beta.6" -source = "git+https://github.com/rustdesk-org/clipboard-master#4fb62e5b62fb6350d82b571ec7ba94b3cd466695" +source = "git+https://github.com/rustdesk-org/clipboard-master#ddc39f00a6211959489ae683aa6ae6eedf03a809" dependencies = [ "objc", "objc-foundation", @@ -1023,6 +1365,21 @@ dependencies = [ "cc", ] +[[package]] +name = "cocoa" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c49e86fc36d5704151f5996b7b3795385f50ce09e3be0f47a0cfde869681cf8" +dependencies = [ + "bitflags 1.3.2", + "block", + "core-foundation 0.7.0", + "core-graphics 0.19.2", + "foreign-types 0.3.2", + "libc", + "objc", +] + [[package]] name = "cocoa" version = "0.24.1" @@ -1091,6 +1448,23 @@ dependencies = [ "memchr", ] +[[package]] +name = "compression-codecs" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8a506ec4b81c460798f572caead636d57d3d7e940f998160f52bd254bf2d23" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e47641d3deaf41fb1538ac1f54735925e275eaf3bf4d55c81b137fba797e5cbb" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -1106,8 +1480,8 @@ version = "0.4.0-2" source = "git+https://github.com/rustdesk-org/confy#83db9ec19a2f97e9718aef69e4fc5611bb382479" dependencies = [ "directories-next", - "serde 1.0.203", - "thiserror", + "serde 1.0.228", + "thiserror 1.0.61", "toml 0.5.11", ] @@ -1121,6 +1495,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const_fn" version = "0.4.10" @@ -1142,7 +1522,7 @@ version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f6ff08fd20f4f299298a28e2dfa8a8ba1036e6cd2460ac1de7b425d76f2500" dependencies = [ - "proc-macro2 1.0.86", + "proc-macro2 1.0.93", "quote 1.0.36", "unicode-xid 0.2.4", ] @@ -1159,12 +1539,22 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "21a53c0a4d288377e7415b53dcfc3c04da5cdc2cc95c8d5ac178b58f0b861ad6" +[[package]] +name = "core-foundation" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d24c7a13c43e870e37c1556b74555437870a04514f7685f5b354e090567171" +dependencies = [ + "core-foundation-sys 0.7.0", + "libc", +] + [[package]] name = "core-foundation" version = "0.9.3" source = "git+https://github.com/madsmtm/core-foundation-rs.git?rev=7d593d016175755e492a92ef89edca68ac3bd5cd#7d593d016175755e492a92ef89edca68ac3bd5cd" dependencies = [ - "core-foundation-sys 0.8.6 (git+https://github.com/madsmtm/core-foundation-rs.git?rev=7d593d016175755e492a92ef89edca68ac3bd5cd)", + "core-foundation-sys 0.8.6", "libc", ] @@ -1174,15 +1564,25 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" dependencies = [ - "core-foundation-sys 0.8.6 (registry+https://github.com/rust-lang/crates.io-index)", + "core-foundation-sys 0.8.7", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys 0.8.7", "libc", ] [[package]] name = "core-foundation-sys" -version = "0.8.6" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" +checksum = "b3a71ab494c0b5b860bdc8407ae08978052417070c2ced38573a9157ad75b8ac" [[package]] name = "core-foundation-sys" @@ -1192,6 +1592,24 @@ dependencies = [ "objc2-encode 2.0.0-pre.2", ] +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3889374e6ea6ab25dba90bb5d96202f61108058361f6dc72e8b03e6f8bbe923" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.7.0", + "foreign-types 0.3.2", + "libc", +] + [[package]] name = "core-graphics" version = "0.22.3" @@ -1253,6 +1671,52 @@ dependencies = [ "libc", ] +[[package]] +name = "core-media-sys" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "273bf3fc5bf51fd06a7766a84788c1540b6527130a0bce39e00567d6ab9f31f1" +dependencies = [ + "cfg-if 0.1.10", + "core-foundation-sys 0.7.0", + "libc", +] + +[[package]] +name = "core-text" +version = "19.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d74ada66e07c1cefa18f8abfba765b486f250de2e4a999e5727fc0dd4b4a25" +dependencies = [ + "core-foundation 0.9.4", + "core-graphics 0.22.3", + "foreign-types 0.3.2", + "libc", +] + +[[package]] +name = "core-video-sys" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34ecad23610ad9757664d644e369246edde1803fcb43ed72876565098a5d3828" +dependencies = [ + "cfg-if 0.1.10", + "core-foundation-sys 0.7.0", + "core-graphics 0.19.2", + "libc", + "metal", + "objc", +] + +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + [[package]] name = "coreaudio-rs" version = "0.11.3" @@ -1260,7 +1724,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace" dependencies = [ "bitflags 1.3.2", - "core-foundation-sys 0.8.6 (registry+https://github.com/rust-lang/crates.io-index)", + "core-foundation-sys 0.8.7", "coreaudio-sys", ] @@ -1276,14 +1740,14 @@ dependencies = [ [[package]] name = "cpal" version = "0.15.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779" +source = "git+https://github.com/rustdesk-org/cpal?branch=osx-screencapturekit#6b374bcaed076750ca8fce6da518ab39b882e14a" dependencies = [ "alsa", - "core-foundation-sys 0.8.6 (registry+https://github.com/rust-lang/crates.io-index)", + "cidre", + "core-foundation-sys 0.8.7", "coreaudio-rs", "dasp_sample", - "jni 0.21.1", + "jni", "js-sys", "libc", "mach2", @@ -1305,6 +1769,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + [[package]] name = "crc32fast" version = "1.4.2" @@ -1363,6 +1842,18 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.6" @@ -1370,9 +1861,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] +[[package]] +name = "ctor-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f791803201ab277ace03903de1594460708d2d54df6053f2d9e82f592b19e3b" + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "ctrlc" version = "3.4.4" @@ -1383,6 +1890,38 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if 1.0.0", + "cpufeatures", + "curve25519-dalek-derive", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2 1.0.93", + "quote 1.0.36", + "syn 2.0.98", +] + [[package]] name = "dart-sys" version = "4.1.5" @@ -1405,6 +1944,20 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if 1.0.0", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "dasp" version = "0.11.0" @@ -1524,6 +2077,12 @@ dependencies = [ "dasp_sample", ] +[[package]] +name = "data-encoding" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" + [[package]] name = "dbus" version = "0.9.7" @@ -1567,6 +2126,41 @@ dependencies = [ "windows 0.32.0", ] +[[package]] +name = "default_net" +version = "0.1.0" +source = "git+https://github.com/rustdesk-org/default_net#78f8f70cd85151a3a2c4a3230d80d5272703c02e" +dependencies = [ + "anyhow", + "regex", + "winapi 0.3.9", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits 0.2.19", + "rusticata-macros", +] + [[package]] name = "deranged" version = "0.3.11" @@ -1582,7 +2176,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" dependencies = [ - "proc-macro2 1.0.86", + "proc-macro2 1.0.93", "quote 1.0.36", "syn 1.0.109", ] @@ -1594,6 +2188,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", "subtle", ] @@ -1618,6 +2213,15 @@ dependencies = [ "dirs-sys 0.3.7", ] +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys 0.3.7", +] + [[package]] name = "dirs" version = "5.0.1" @@ -1627,6 +2231,15 @@ dependencies = [ "dirs-sys 0.4.1", ] +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys 0.5.0", +] + [[package]] name = "dirs-next" version = "2.0.0" @@ -1644,7 +2257,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" dependencies = [ "libc", - "redox_users", + "redox_users 0.4.5", "winapi 0.3.9", ] @@ -1656,10 +2269,22 @@ checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.4.5", "windows-sys 0.48.0", ] +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.61.2", +] + [[package]] name = "dirs-sys-next" version = "0.1.2" @@ -1667,7 +2292,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" dependencies = [ "libc", - "redox_users", + "redox_users 0.4.5", "winapi 0.3.9", ] @@ -1677,6 +2302,27 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.9.1", + "objc2 0.6.4", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2 1.0.93", + "quote 1.0.36", + "syn 2.0.98", +] + [[package]] name = "dlib" version = "0.5.2" @@ -1716,7 +2362,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a09ac8bb8c16a282264c379dffba707b9c998afc7506009137f3c6136888078" dependencies = [ - "proc-macro2 1.0.86", + "proc-macro2 1.0.93", "quote 1.0.36", "syn 1.0.109", ] @@ -1746,7 +2392,7 @@ checksum = "7f3f119846c823f9eafcf953a8f6ffb6ed69bf6240883261a7f13b634579a51f" dependencies = [ "lazy_static", "regex", - "serde 1.0.203", + "serde 1.0.228", "strsim 0.10.0", ] @@ -1762,12 +2408,93 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f25c0e292a7ca6d6498557ff1df68f32c99850012b6ea401cf8daf771f22ff53" +[[package]] +name = "drm" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98888c4bbd601524c11a7ed63f814b8825f420514f78e96f752c437ae9cbb5d1" +dependencies = [ + "bitflags 2.9.1", + "bytemuck", + "drm-ffi", + "drm-fourcc", + "rustix 0.38.34", +] + +[[package]] +name = "drm-ffi" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97c98727e48b7ccb4f4aea8cfe881e5b07f702d17b7875991881b41af7278d53" +dependencies = [ + "drm-sys", + "rustix 0.38.34", +] + +[[package]] +name = "drm-fourcc" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aafbcdb8afc29c1a7ee5fbe53b5d62f4565b35a042a662ca9fecd0b54dae6f4" + +[[package]] +name = "drm-sys" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd39dde40b6e196c2e8763f23d119ddb1a8714534bf7d77fa97a65b0feda3986" +dependencies = [ + "libc", + "linux-raw-sys 0.6.5", +] + +[[package]] +name = "dtls" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f531dd7c181beaf3cebab3716afa4d0d41ab888be85232583f56bbaf07ca208a" +dependencies = [ + "aes", + "aes-gcm", + "async-trait", + "bincode", + "byteorder", + "cbc", + "ccm", + "chacha20poly1305", + "der-parser", + "hmac", + "log", + "p256", + "p384", + "portable-atomic", + "rand 0.9.2", + "rand_core 0.6.4", + "rcgen", + "ring", + "rustls", + "sec1", + "serde 1.0.228", + "sha1", + "sha2", + "thiserror 1.0.61", + "tokio", + "webrtc-util", + "x25519-dalek", + "x509-parser", +] + [[package]] name = "dtoa" version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56899898ce76aaf4a0f24d914c97ea6ed976d42fec6ad33fcbb0a1103e07b2b0" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "dylib_virtual_display" version = "0.1.0" @@ -1775,9 +2502,23 @@ dependencies = [ "cc", "hbb_common", "lazy_static", - "serde 1.0.203", + "serde 1.0.228", "serde_derive", - "thiserror", + "thiserror 1.0.61", +] + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature 2.2.0", + "spki", ] [[package]] @@ -1786,7 +2527,7 @@ version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91cff35c70bba8a626e3185d8cd48cc11b5437e1a5bcd15b9b5fa3c64b6dfee7" dependencies = [ - "signature", + "signature 1.6.4", ] [[package]] @@ -1796,12 +2537,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" [[package]] -name = "encoding_rs" -version = "0.8.34" +name = "elliptic-curve" +version = "0.13.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b45de904aa0b010bce2ab45264d0631681847fa7b6f2eaa7dab7619943bc4f59" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ - "cfg-if 1.0.0", + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", ] [[package]] @@ -1810,11 +2563,12 @@ version = "0.0.14" dependencies = [ "core-graphics 0.22.3", "hbb_common", + "libxdo-sys", "log", "objc", "pkg-config", "rdev", - "serde 1.0.203", + "serde 1.0.228", "serde_derive", "tfc", "unicode-segmentation", @@ -1827,7 +2581,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06c36cb11dbde389f4096111698d8b567c0720e3452fd5ac3e6b4e47e1939932" dependencies = [ - "thiserror", + "thiserror 1.0.61", ] [[package]] @@ -1845,9 +2599,9 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb" dependencies = [ - "proc-macro2 1.0.86", + "proc-macro2 1.0.93", "quote 1.0.36", - "syn 2.0.68", + "syn 2.0.98", ] [[package]] @@ -1857,7 +2611,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d232db7f5956f3f14313dc2f87985c58bd2c695ce124c8cdd984e08e15ac133d" dependencies = [ "enumflags2_derive", - "serde 1.0.203", + "serde 1.0.228", ] [[package]] @@ -1866,9 +2620,19 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de0d48a183585823424a4ce1aa132d174a6a81bd540895822eb4c8373a8e49e8" dependencies = [ - "proc-macro2 1.0.86", + "proc-macro2 1.0.93", "quote 1.0.36", - "syn 2.0.68", + "syn 2.0.98", +] + +[[package]] +name = "env_filter" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0" +dependencies = [ + "log", + "regex", ] [[package]] @@ -1890,11 +2654,21 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cd405aab171cb85d6735e5c8d9db038c17d3ca007a4d2c25f337935c3d90580" dependencies = [ - "humantime", - "is-terminal", "log", "regex", - "termcolor", +] + +[[package]] +name = "env_logger" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcaee3d8e3cfc3fd92428d477bc97fc29ec8716d180c0d74c643bb26166660e0" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "humantime", + "log", ] [[package]] @@ -1903,7 +2677,7 @@ version = "4.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74351c3392ea1ff6cd2628e0042d268ac2371cb613252ff383b6dfa50d22fa79" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.1", "libc", ] @@ -1915,9 +2689,9 @@ checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" [[package]] name = "errno" -version = "0.3.9" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", "windows-sys 0.52.0", @@ -1987,7 +2761,7 @@ dependencies = [ "flume", "half", "lebe", - "miniz_oxide", + "miniz_oxide 0.7.4", "rayon-core", "smallvec", "zune-inflate", @@ -2017,6 +2791,22 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "field-offset" version = "0.3.6" @@ -2027,6 +2817,16 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filedescriptor" +version = "0.8.2" +source = "git+https://github.com/rustdesk-org/wezterm?branch=rustdesk/pty_based_0.8.1#80174f8009f41565f0fa8c66dab90d4f9211ae16" +dependencies = [ + "libc", + "thiserror 1.0.61", + "winapi 0.3.9", +] + [[package]] name = "filetime" version = "0.2.23" @@ -2047,12 +2847,12 @@ checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" [[package]] name = "flate2" -version = "1.0.30" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f54427cfd1c7829e2a139fcefea601bf088ebca651d2bf53ebc600eac295dae" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", - "miniz_oxide", + "miniz_oxide 0.8.9", ] [[package]] @@ -2068,9 +2868,9 @@ dependencies = [ "is-terminal", "lazy_static", "log", - "nu-ansi-term", + "nu-ansi-term 0.49.0", "regex", - "thiserror", + "thiserror 1.0.61", ] [[package]] @@ -2079,6 +2879,9 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55ac459de2512911e4b674ce33cf20befaba382d05b62b008afc1c8b57cbf181" dependencies = [ + "futures-core", + "futures-sink", + "nanorand", "spin", ] @@ -2129,6 +2932,29 @@ dependencies = [ "libm", ] +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree", +] + +[[package]] +name = "fontdb" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser", +] + [[package]] name = "foreign-types" version = "0.3.2" @@ -2154,9 +2980,9 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ - "proc-macro2 1.0.86", + "proc-macro2 1.0.93", "quote 1.0.36", - "syn 2.0.68", + "syn 2.0.98", ] [[package]] @@ -2193,6 +3019,25 @@ dependencies = [ "time 0.1.45", ] +[[package]] +name = "fsevent" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8836d1f147a0a195bf517a5fd211ea7023d19ced903135faf6c4504f2cf8775f" +dependencies = [ + "bitflags 1.3.2", + "fsevent-sys", +] + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "fuchsia-cprng" version = "0.1.1" @@ -2207,17 +3052,17 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "fuser" -version = "0.13.0" +version = "0.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21370f84640642c8ea36dfb2a6bfc4c55941f476fcf431f6fef25a5ddcf0169b" +checksum = "53274f494609e77794b627b1a3cddfe45d675a6b2e9ba9c0fdc8d8eee2184369" dependencies = [ "libc", "log", "memchr", + "nix 0.29.0", "page_size", - "pkg-config", "smallvec", - "zerocopy 0.6.6", + "zerocopy 0.8.26", ] [[package]] @@ -2237,9 +3082,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", "futures-sink", @@ -2247,9 +3092,9 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" [[package]] name = "futures-executor" @@ -2264,9 +3109,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" [[package]] name = "futures-lite" @@ -2298,32 +3143,32 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ - "proc-macro2 1.0.86", + "proc-macro2 1.0.93", "quote 1.0.36", - "syn 2.0.68", + "syn 2.0.98", ] [[package]] name = "futures-sink" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" [[package]] name = "futures-task" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" [[package]] name = "futures-util" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ "futures-channel", "futures-core", @@ -2430,6 +3275,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -2459,8 +3305,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" dependencies = [ "cfg-if 1.0.0", + "js-sys", "libc", "wasi 0.11.0+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0" +dependencies = [ + "cfg-if 1.0.0", + "js-sys", + "libc", + "r-efi", + "wasi 0.14.2+wasi-0.2.4", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", ] [[package]] @@ -2495,7 +3367,7 @@ dependencies = [ "once_cell", "pin-project-lite", "smallvec", - "thiserror", + "thiserror 1.0.61", ] [[package]] @@ -2549,7 +3421,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.1", "futures-channel", "futures-core", "futures-executor", @@ -2563,7 +3435,7 @@ dependencies = [ "memchr", "once_cell", "smallvec", - "thiserror", + "thiserror 1.0.61", ] [[package]] @@ -2577,7 +3449,7 @@ dependencies = [ "itertools 0.9.0", "proc-macro-crate 0.1.5", "proc-macro-error", - "proc-macro2 1.0.86", + "proc-macro2 1.0.93", "quote 1.0.36", "syn 1.0.109", ] @@ -2591,9 +3463,9 @@ dependencies = [ "heck 0.4.1", "proc-macro-crate 2.0.2", "proc-macro-error", - "proc-macro2 1.0.86", + "proc-macro2 1.0.93", "quote 1.0.36", - "syn 2.0.68", + "syn 2.0.98", ] [[package]] @@ -2644,6 +3516,17 @@ dependencies = [ "system-deps 6.2.2", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "gstreamer" version = "0.16.7" @@ -2665,7 +3548,7 @@ dependencies = [ "once_cell", "paste", "pretty-hex", - "thiserror", + "thiserror 1.0.61", ] [[package]] @@ -2824,28 +3707,9 @@ checksum = "c6063efb63db582968fb7df72e1ae68aa6360dcfb0a75143f34fc7d616bad75e" dependencies = [ "proc-macro-crate 1.3.1", "proc-macro-error", - "proc-macro2 1.0.86", + "proc-macro2 1.0.93", "quote 1.0.36", - "syn 2.0.68", -] - -[[package]] -name = "h2" -version = "0.3.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8" -dependencies = [ - "bytes", - "fnv", - "futures-core", - "futures-sink", - "futures-util", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", + "syn 2.0.98", ] [[package]] @@ -2872,25 +3736,30 @@ name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" -dependencies = [ - "ahash 0.8.11", - "allocator-api2", -] + +[[package]] +name = "hashbrown" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" [[package]] name = "hbb_common" version = "0.1.0" dependencies = [ "anyhow", + "async-recursion", "backtrace", "base64 0.22.1", "bytes", "chrono", + "clap 4.5.53", "confy", + "default_net", "directories-next", "dirs-next", "dlopen", - "env_logger 0.10.2", + "env_logger 0.11.6", "filetime", "flexi_logger", "futures", @@ -2898,6 +3767,7 @@ dependencies = [ "httparse", "lazy_static", "libc", + "libloading 0.8.4", "log", "mac_address", "machine-uid", @@ -2906,24 +3776,34 @@ dependencies = [ "protobuf-codegen", "rand 0.8.5", "regex", + "rustls-native-certs", "rustls-pki-types", "rustls-platform-verifier", - "serde 1.0.203", + "serde 1.0.228", "serde_derive", "serde_json 1.0.118", + "sha2", + "smithay-client-toolkit 0.20.0", "socket2 0.3.19", "sodiumoxide", "sysinfo", - "thiserror", + "thiserror 1.0.61", "tokio", "tokio-native-tls", - "tokio-rustls 0.26.0", - "tokio-socks 0.5.2-1", + "tokio-rustls", + "tokio-socks", + "tokio-tungstenite", "tokio-util", "toml 0.7.8", + "tungstenite", "url", + "users 0.11.0", "uuid", + "webpki-roots 1.0.4", + "webrtc", + "whoami", "winapi 0.3.9", + "x11 2.21.0", "zstd 0.13.1", ] @@ -2969,12 +3849,27 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" +[[package]] +name = "hermit-abi" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbd780fe5cc30f81464441920d82ac8740e2e46b29a6fad543ddd075229ce37e" + [[package]] name = "hex" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + [[package]] name = "hmac" version = "0.12.1" @@ -3010,9 +3905,9 @@ dependencies = [ [[package]] name = "http" -version = "0.2.12" +version = "1.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" dependencies = [ "bytes", "fnv", @@ -3021,26 +3916,32 @@ dependencies = [ [[package]] name = "http-body" -version = "0.4.6" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", "pin-project-lite", ] [[package]] name = "httparse" -version = "1.9.4" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fcc0b4a115bf80b728eb8ea024ad5bd707b615bfed49e0665b6e0f86fd082d9" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "humantime" @@ -3050,66 +3951,94 @@ checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" [[package]] name = "hwcodec" -version = "0.7.0" -source = "git+https://github.com/rustdesk-org/hwcodec#8bbd05bb300ad07cc345356ad85570f9ea99fbfa" +version = "0.7.1" +source = "git+https://github.com/rustdesk-org/hwcodec#398e5a8938dd8768ade0fcdc27ea80e8b4b38738" dependencies = [ "bindgen 0.59.2", "cc", "log", - "serde 1.0.203", + "serde 1.0.228", "serde_derive", "serde_json 1.0.118", ] [[package]] name = "hyper" -version = "0.14.29" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f361cde2f109281a220d4307746cdfd5ee3f410da58a70377762396775634b33" +checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" dependencies = [ + "atomic-waker", "bytes", "futures-channel", "futures-core", - "futures-util", - "h2", "http", "http-body", "httparse", - "httpdate", "itoa 1.0.11", "pin-project-lite", - "socket2 0.5.7", + "pin-utils", + "smallvec", "tokio", - "tower-service", - "tracing", "want", ] [[package]] name = "hyper-rustls" -version = "0.24.2" +version = "0.27.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ - "futures-util", "http", "hyper", - "rustls 0.21.12", + "hyper-util", + "rustls", + "rustls-native-certs", + "rustls-pki-types", "tokio", - "tokio-rustls 0.24.1", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.4", ] [[package]] name = "hyper-tls" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", + "http-body-util", "hyper", + "hyper-util", "native-tls", "tokio", "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", ] [[package]] @@ -3119,7 +4048,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7ffbb5a1b541ea2561f8c41c087286cc091e21e556a4f09a8f6cbf17b69b141" dependencies = [ "android_system_properties", - "core-foundation-sys 0.8.6 (registry+https://github.com/rust-lang/crates.io-index)", + "core-foundation-sys 0.8.7", "iana-time-zone-haiku", "js-sys", "wasm-bindgen", @@ -3158,7 +4087,7 @@ dependencies = [ "gif", "jpeg-decoder", "num-traits 0.2.19", - "png", + "png 0.17.13", "qoi", "tiff", ] @@ -3172,7 +4101,7 @@ dependencies = [ "bytemuck", "byteorder", "num-traits 0.2.19", - "png", + "png 0.17.13", "tiff", ] @@ -3199,7 +4128,7 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" dependencies = [ - "proc-macro2 1.0.86", + "proc-macro2 1.0.93", "quote 1.0.36", ] @@ -3239,6 +4168,7 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5" dependencies = [ + "block-padding", "generic-array", ] @@ -3251,6 +4181,27 @@ dependencies = [ "cfg-if 1.0.0", ] +[[package]] +name = "interceptor" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea51375727680dc15f06e8ad90fa31df75d79dd030100e8ad60eef1c27fe2c98" +dependencies = [ + "async-trait", + "bytes", + "futures", + "log", + "portable-atomic", + "rand 0.9.2", + "rtcp", + "rtp", + "thiserror 1.0.61", + "tokio", + "waitgroup", + "webrtc-srtp", + "webrtc-util", +] + [[package]] name = "io-lifetimes" version = "1.0.11" @@ -3263,18 +4214,37 @@ dependencies = [ ] [[package]] -name = "ipnet" -version = "2.9.0" +name = "ioctl-rs" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" +checksum = "f7970510895cee30b3e9128319f2cefd4bde883a39f38baa279567ba3a7eb97d" +dependencies = [ + "libc", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" +dependencies = [ + "memchr", + "serde 1.0.228", +] [[package]] name = "is-terminal" -version = "0.4.12" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f23ff5ef2b80d608d61efee834934d862cd92461afc0560dedf493e4c033738b" +checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" dependencies = [ - "hermit-abi 0.3.9", + "hermit-abi 0.5.0", "libc", "windows-sys 0.52.0", ] @@ -3321,20 +4291,6 @@ version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" -[[package]] -name = "jni" -version = "0.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6df18c2e3db7e453d3c6ac5b3e9d5182664d28788126d39b91f2d1e22b017ec" -dependencies = [ - "cesu8", - "combine", - "jni-sys", - "log", - "thiserror", - "walkdir", -] - [[package]] name = "jni" version = "0.21.1" @@ -3346,7 +4302,7 @@ dependencies = [ "combine", "jni-sys", "log", - "thiserror", + "thiserror 1.0.61", "walkdir", "windows-sys 0.45.0", ] @@ -3377,13 +4333,37 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.69" +version = "0.3.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29c15563dc2726973df627357ce0c9ddddbea194836909d655df6a75d2cf296d" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" dependencies = [ + "once_cell", "wasm-bindgen", ] +[[package]] +name = "kcp-sys" +version = "0.1.0" +source = "git+https://github.com/rustdesk-org/kcp-sys#32a6c09fc6223f54aea83981a6aa8995931d29be" +dependencies = [ + "anyhow", + "auto_impl", + "bindgen 0.71.1", + "bitflags 2.9.1", + "bytes", + "cc", + "dashmap 6.1.0", + "log", + "parking_lot", + "rand 0.8.5", + "thiserror 2.0.17", + "tokio", + "tokio-util", + "tracing", + "tracing-subscriber", + "zerocopy 0.7.34", +] + [[package]] name = "keepawake" version = "0.4.3" @@ -3414,11 +4394,20 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" dependencies = [ - "bitflags 2.6.0", - "serde 1.0.203", + "bitflags 2.9.1", + "serde 1.0.228", "unicode-segmentation", ] +[[package]] +name = "kurbo" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd85a5776cd9500c2e2059c8c76c3b01528566b7fcbaf8098b55a33fc298849b" +dependencies = [ + "arrayvec", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -3463,9 +4452,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.155" +version = "0.2.171" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" +checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6" [[package]] name = "libdbus-sys" @@ -3505,7 +4494,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e310b3a6b5907f99202fcdb4960ff45b93735d7c7d96b760fcff8db2dc0e103d" dependencies = [ "cfg-if 1.0.0", - "windows-targets 0.52.5", + "windows-targets 0.52.6", ] [[package]] @@ -3568,8 +4557,9 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.1", "libc", + "redox_syscall 0.5.2", ] [[package]] @@ -3605,11 +4595,8 @@ dependencies = [ [[package]] name = "libxdo-sys" version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db23b9e7e2b7831bbd8aac0bbeeeb7b68cbebc162b227e7052e8e55829a09212" dependencies = [ - "libc", - "x11 2.21.0", + "hbb_common", ] [[package]] @@ -3642,6 +4629,18 @@ version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" +[[package]] +name = "linux-raw-sys" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a385b1be4e5c3e362ad2ffa73c392e53f031eaa5b7d648e64cd87f27f6063d7" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + [[package]] name = "lock_api" version = "0.4.12" @@ -3658,6 +4657,12 @@ version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "mac_address" version = "1.1.7" @@ -3706,6 +4711,22 @@ dependencies = [ "libc", ] +[[package]] +name = "matches" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if 1.0.0", + "digest", +] + [[package]] name = "md5" version = "0.7.0" @@ -3724,6 +4745,15 @@ version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +[[package]] +name = "memmap2" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843a98750cd611cc2965a8213b53b43e715f13c37a9e096c6408e69990961db7" +dependencies = [ + "libc", +] + [[package]] name = "memoffset" version = "0.6.5" @@ -3752,10 +4782,19 @@ dependencies = [ ] [[package]] -name = "mime" -version = "0.3.17" +name = "metal" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +checksum = "e198a0ee42bdbe9ef2c09d0b9426f3b2b47d90d93a4a9b0395c4cea605e92dc0" +dependencies = [ + "bitflags 1.3.2", + "block", + "cocoa 0.20.2", + "core-graphics 0.19.2", + "foreign-types 0.3.2", + "log", + "objc", +] [[package]] name = "minimal-lexical" @@ -3773,6 +4812,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "0.8.11" @@ -3786,22 +4835,60 @@ dependencies = [ ] [[package]] -name = "muda" -version = "0.13.5" +name = "mio" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86b959f97c97044e4c96e32e1db292a7d594449546a3c6b77ae613dc3a5b5145" +checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" +dependencies = [ + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys 0.52.0", +] + +[[package]] +name = "mozjpeg" +version = "0.10.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55571bce4f12d80ceb4296526e7614f796df72daaaac85f265ab732fa47b7bc9" +dependencies = [ + "arrayvec", + "bytemuck", + "libc", + "mozjpeg-sys", + "rgb", +] + +[[package]] +name = "mozjpeg-sys" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad3626d7942d5b56cc6d47b1c59724c0a976b786fca059c5aaa904aef6324d55" +dependencies = [ + "cc", + "dunce", + "libc", + "nasm-rs", +] + +[[package]] +name = "muda" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c1738382f66ed56b3b9c8119e794a2e23148ac8ea214eda86622d4cb9d415a" dependencies = [ - "cocoa 0.25.0", "crossbeam-channel", "dpi", "gtk", "keyboard-types", "libxdo", - "objc", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", "once_cell", - "png", - "thiserror", - "windows-sys 0.52.0", + "png 0.17.13", + "thiserror 2.0.17", + "windows-sys 0.60.2", ] [[package]] @@ -3810,6 +4897,24 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0419348c027fa7be448d2ae7ea0e4e04c2334c31dc4e74ab29f00a2a7ca69204" +[[package]] +name = "nanorand" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3" +dependencies = [ + "getrandom 0.2.15", +] + +[[package]] +name = "nasm-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12fcfa1bd49e0342ec1d07ed2be83b59963e7acbeb9310e1bb2c07b69dadd959" +dependencies = [ + "jobserver", +] + [[package]] name = "native-tls" version = "0.2.12" @@ -3822,7 +4927,7 @@ dependencies = [ "openssl-probe", "openssl-sys", "schannel", - "security-framework", + "security-framework 2.10.0", "security-framework-sys", "tempfile", ] @@ -3850,7 +4955,7 @@ dependencies = [ "ndk-sys 0.4.1+23.1.7779620", "num_enum 0.5.11", "raw-window-handle 0.5.2", - "thiserror", + "thiserror 1.0.61", ] [[package]] @@ -3859,12 +4964,27 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.1", "jni-sys", "log", "ndk-sys 0.5.0+25.2.9519653", "num_enum 0.7.2", - "thiserror", + "thiserror 1.0.61", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.9.1", + "jni-sys", + "log", + "ndk-sys 0.6.0+11769913", + "num_enum 0.7.2", + "raw-window-handle 0.6.2", + "thiserror 1.0.61", ] [[package]] @@ -3891,6 +5011,15 @@ dependencies = [ "jni-sys", ] +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys", +] + [[package]] name = "netlink-packet-core" version = "0.5.0" @@ -3926,7 +5055,7 @@ dependencies = [ "anyhow", "byteorder", "paste", - "thiserror", + "thiserror 1.0.61", ] [[package]] @@ -3953,6 +5082,20 @@ dependencies = [ "memoffset 0.6.5", ] +[[package]] +name = "nix" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f346ff70e7dbfd675fe90590b92d59ef2de15a8779ae305ebcbfd3f0caf59be4" +dependencies = [ + "autocfg 1.3.0", + "bitflags 1.3.2", + "cfg-if 1.0.0", + "libc", + "memoffset 0.6.5", + "pin-utils", +] + [[package]] name = "nix" version = "0.26.4" @@ -3963,6 +5106,7 @@ dependencies = [ "cfg-if 1.0.0", "libc", "memoffset 0.7.1", + "pin-utils", ] [[package]] @@ -3971,7 +5115,7 @@ version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.1", "cfg-if 1.0.0", "cfg_aliases 0.1.1", "libc", @@ -3984,12 +5128,75 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.1", "cfg-if 1.0.0", "cfg_aliases 0.2.1", "libc", ] +[[package]] +name = "nokhwa" +version = "0.10.7" +source = "git+https://github.com/rustdesk-org/nokhwa.git?branch=fix_from_raw_parts#c2f74662b6ce117f7f94301693fdfadc0b1ec91a" +dependencies = [ + "flume", + "image 0.25.1", + "nokhwa-bindings-linux", + "nokhwa-bindings-macos", + "nokhwa-bindings-windows", + "nokhwa-core", + "paste", + "thiserror 2.0.17", +] + +[[package]] +name = "nokhwa-bindings-linux" +version = "0.1.1" +source = "git+https://github.com/rustdesk-org/nokhwa.git?branch=fix_from_raw_parts#c2f74662b6ce117f7f94301693fdfadc0b1ec91a" +dependencies = [ + "nokhwa-core", + "v4l", +] + +[[package]] +name = "nokhwa-bindings-macos" +version = "0.2.2" +source = "git+https://github.com/rustdesk-org/nokhwa.git?branch=fix_from_raw_parts#c2f74662b6ce117f7f94301693fdfadc0b1ec91a" +dependencies = [ + "block", + "cocoa-foundation", + "core-foundation 0.9.4", + "core-media-sys", + "core-video-sys", + "flume", + "nokhwa-core", + "objc", + "once_cell", +] + +[[package]] +name = "nokhwa-bindings-windows" +version = "0.4.2" +source = "git+https://github.com/rustdesk-org/nokhwa.git?branch=fix_from_raw_parts#c2f74662b6ce117f7f94301693fdfadc0b1ec91a" +dependencies = [ + "dlopen", + "lazy_static", + "nokhwa-core", + "once_cell", + "windows 0.43.0", +] + +[[package]] +name = "nokhwa-core" +version = "0.1.5" +source = "git+https://github.com/rustdesk-org/nokhwa.git?branch=fix_from_raw_parts#c2f74662b6ce117f7f94301693fdfadc0b1ec91a" +dependencies = [ + "bytes", + "image 0.25.1", + "mozjpeg", + "thiserror 2.0.17", +] + [[package]] name = "nom" version = "7.1.3" @@ -4009,6 +5216,16 @@ dependencies = [ "winapi 0.3.9", ] +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi 0.3.9", +] + [[package]] name = "nu-ansi-term" version = "0.49.0" @@ -4049,7 +5266,7 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "876a53fff98e03a936a674b29568b0e605f06b29372c2489ff4de23f1949743d" dependencies = [ - "proc-macro2 1.0.86", + "proc-macro2 1.0.93", "quote 1.0.36", "syn 1.0.109", ] @@ -4060,9 +5277,9 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ - "proc-macro2 1.0.86", + "proc-macro2 1.0.93", "quote 1.0.36", - "syn 2.0.68", + "syn 2.0.98", ] [[package]] @@ -4138,7 +5355,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dcbff9bc912032c62bf65ef1d5aea88983b420f4f839db1e9b0c281a25c9c799" dependencies = [ "proc-macro-crate 1.3.1", - "proc-macro2 1.0.86", + "proc-macro2 1.0.93", "quote 1.0.36", "syn 1.0.109", ] @@ -4150,9 +5367,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "681030a937600a36906c185595136d26abfebb4aa9c65701cefcaf8578bb982b" dependencies = [ "proc-macro-crate 2.0.2", - "proc-macro2 1.0.86", + "proc-macro2 1.0.93", "quote 1.0.36", - "syn 2.0.68", + "syn 2.0.98", ] [[package]] @@ -4215,7 +5432,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" dependencies = [ "objc-sys 0.3.5", - "objc2-encode 4.0.3", + "objc2-encode 4.1.0", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode 4.1.0", ] [[package]] @@ -4224,26 +5450,83 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.1", "block2 0.5.1", "libc", "objc2 0.5.2", "objc2-core-data", "objc2-core-image", - "objc2-foundation", + "objc2-foundation 0.2.2", "objc2-quartz-core", ] +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.9.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.9.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + [[package]] name = "objc2-core-data" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.1", "block2 0.5.1", "objc2 0.5.2", - "objc2-foundation", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.9.1", + "dispatch2", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.9.1", + "objc2-core-foundation", ] [[package]] @@ -4254,10 +5537,22 @@ checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" dependencies = [ "block2 0.5.1", "objc2 0.5.2", - "objc2-foundation", + "objc2-foundation 0.2.2", "objc2-metal", ] +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + [[package]] name = "objc2-encode" version = "2.0.0-pre.2" @@ -4269,9 +5564,9 @@ dependencies = [ [[package]] name = "objc2-encode" -version = "4.0.3" +version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7891e71393cd1f227313c9379a26a584ff3d7e6e7159e988851f0934c993f0f8" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" [[package]] name = "objc2-foundation" @@ -4279,22 +5574,47 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.1", "block2 0.5.1", + "dispatch", "libc", "objc2 0.5.2", ] +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.9.1", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + [[package]] name = "objc2-metal" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.1", "block2 0.5.1", "objc2 0.5.2", - "objc2-foundation", + "objc2-foundation 0.2.2", ] [[package]] @@ -4303,13 +5623,68 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.1", "block2 0.5.1", "objc2 0.5.2", - "objc2-foundation", + "objc2-foundation 0.2.2", "objc2-metal", ] +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.9.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.9.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + [[package]] name = "objc_exception" version = "0.1.2" @@ -4343,7 +5718,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb" dependencies = [ - "jni 0.21.1", + "jni", "ndk 0.8.0", "ndk-context", "num-derive 0.4.2", @@ -4360,6 +5735,15 @@ dependencies = [ "cc", ] +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.19.0" @@ -4367,12 +5751,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" [[package]] -name = "openssl" -version = "0.10.64" +name = "opaque-debug" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95a0481286a310808298130d22dd1fef0fa571e05a8f44ec801801e84b216b1f" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl" +version = "0.10.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6174bc48f102d208783c2c84bf931bb75927a617866870de8a4ea85597f871f5" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.1", "cfg-if 1.0.0", "foreign-types 0.3.2", "libc", @@ -4387,9 +5777,9 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ - "proc-macro2 1.0.86", + "proc-macro2 1.0.93", "quote 1.0.36", - "syn 2.0.68", + "syn 2.0.98", ] [[package]] @@ -4399,13 +5789,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" [[package]] -name = "openssl-sys" -version = "0.9.102" +name = "openssl-src" +version = "300.5.3+3.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c597637d56fbc83893a35eb0dd04b2b8e7a50c91e64e9493e398b5df4fb45fa2" +checksum = "dc6bad8cd0233b63971e232cc9c5e83039375b8586d2312f31fda85db8f888c2" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45abf306cbf99debc8195b66b7346498d7b10c210de50418b5ccd7ceba08c741" dependencies = [ "cc", "libc", + "openssl-src", "pkg-config", "vcpkg", ] @@ -4416,6 +5816,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "orbclient" +version = "0.3.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba0b26cec2e24f08ed8bb31519a9333140a6599b867dac464bb150bdb796fd43" +dependencies = [ + "libredox", +] + [[package]] name = "ordered-multimap" version = "0.4.3" @@ -4455,7 +5864,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae99c7fa6dd38c7cafe1ec085e804f8f555a2f8659b0dbe03f1f9963a9b51092" dependencies = [ "log", - "serde 1.0.203", + "serde 1.0.228", "windows-sys 0.52.0", ] @@ -4475,16 +5884,55 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38731fa859ef679f1aec66ca9562165926b442f298467f76f5990f431efe87dc" dependencies = [ - "serde 1.0.203", + "serde 1.0.228", "serde_derive", "serde_json 1.0.118", ] [[package]] -name = "page_size" -version = "0.5.0" +name = "overload" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b7663cbd190cfd818d08efa8497f6cd383076688c49a391ef7c0d03cd12b561" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" dependencies = [ "libc", "winapi 0.3.9", @@ -4507,7 +5955,7 @@ version = "0.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c94f3b9b97df3c6d4e51a14916639b24e02c7d15d1dba686ce9b1118277cb811" dependencies = [ - "proc-macro2 1.0.86", + "proc-macro2 1.0.93", "quote 1.0.36", "syn 1.0.109", ] @@ -4548,8 +5996,8 @@ dependencies = [ [[package]] name = "parity-tokio-ipc" -version = "0.7.3-4" -source = "git+https://github.com/rustdesk-org/parity-tokio-ipc#3623ec9ebef50c9b118e03b03df831008a4d1441" +version = "0.7.3-6" +source = "git+https://github.com/rustdesk-org/parity-tokio-ipc#d0ae39bffe5d5a3e8d82a1b6bcb1ca5a9b2f1c01" dependencies = [ "futures", "libc", @@ -4585,7 +6033,7 @@ dependencies = [ "libc", "redox_syscall 0.5.2", "smallvec", - "windows-targets 0.52.5", + "windows-targets 0.52.6", ] [[package]] @@ -4623,6 +6071,25 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.1" @@ -4645,7 +6112,16 @@ version = "0.7.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3da44b85f8e8dfaec21adae67f95d93244b2ecf6ad2a692320598dcc8e6dd18" dependencies = [ - "phf_shared", + "phf_shared 0.7.24", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_shared 0.11.3", ] [[package]] @@ -4654,8 +6130,18 @@ version = "0.7.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b03e85129e324ad4166b06b2c7491ae27fe3ec353af72e72cd1654c7225d517e" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.7.24", + "phf_shared 0.7.24", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", ] [[package]] @@ -4664,17 +6150,61 @@ version = "0.7.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09364cc93c159b8b06b1f4dd8a4398984503483891b0c26b867cf431fb132662" dependencies = [ - "phf_shared", + "phf_shared 0.7.24", "rand 0.6.5", ] +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.5", +] + [[package]] name = "phf_shared" version = "0.7.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "234f71a15de2288bcb7e3b6515828d22af7ec8598ee6d24c3b526fa0a80b67a0" dependencies = [ - "siphasher", + "siphasher 0.2.3", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher 1.0.1", +] + +[[package]] +name = "piet" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e381186490a3e2017a506d62b759ea8eaf4be14666b13ed53973e8ae193451b1" +dependencies = [ + "kurbo", + "unic-bidi", +] + +[[package]] +name = "piet-coregraphics" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a819b41d2ddb1d8abf3e45e49422f866cba281b4abb5e2fb948bba06e2c3d3f7" +dependencies = [ + "associative-cache", + "core-foundation 0.9.4", + "core-foundation-sys 0.8.7", + "core-graphics 0.22.3", + "core-text", + "foreign-types 0.3.2", + "piet", ] [[package]] @@ -4692,9 +6222,9 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" dependencies = [ - "proc-macro2 1.0.86", + "proc-macro2 1.0.93", "quote 1.0.36", - "syn 2.0.68", + "syn 2.0.98", ] [[package]] @@ -4720,6 +6250,16 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.30" @@ -4736,7 +6276,7 @@ dependencies = [ "indexmap", "line-wrap", "quick-xml 0.31.0", - "serde 1.0.203", + "serde 1.0.228", "time 0.3.36", ] @@ -4750,7 +6290,20 @@ dependencies = [ "crc32fast", "fdeflate", "flate2", - "miniz_oxide", + "miniz_oxide 0.7.4", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.9.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", ] [[package]] @@ -4784,6 +6337,55 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if 1.0.0", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" + +[[package]] +name = "portable-pty" +version = "0.8.1" +source = "git+https://github.com/rustdesk-org/wezterm?branch=rustdesk/pty_based_0.8.1#80174f8009f41565f0fa8c66dab90d4f9211ae16" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix 0.25.1", + "serial", + "shared_library", + "shell-words", + "winapi 0.3.9", + "winreg 0.10.1", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -4808,8 +6410,8 @@ version = "0.2.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f12335488a2f3b0a83b14edad48dca9879ce89b2edd10e80237e4e852dd645e" dependencies = [ - "proc-macro2 1.0.86", - "syn 2.0.68", + "proc-macro2 1.0.93", + "syn 2.0.98", ] [[package]] @@ -4821,6 +6423,15 @@ dependencies = [ "num-integer", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro-crate" version = "0.1.5" @@ -4857,7 +6468,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" dependencies = [ "proc-macro-error-attr", - "proc-macro2 1.0.86", + "proc-macro2 1.0.93", "quote 1.0.36", "syn 1.0.109", "version_check", @@ -4869,7 +6480,7 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" dependencies = [ - "proc-macro2 1.0.86", + "proc-macro2 1.0.93", "quote 1.0.36", "version_check", ] @@ -4885,30 +6496,30 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.86" +version = "1.0.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" +checksum = "60946a68e5f9d28b0dc1c21bb8a97ee7d018a8b322fa57838ba31cc878e22d99" dependencies = [ "unicode-ident", ] [[package]] name = "protobuf" -version = "3.5.0" +version = "3.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df67496db1a89596beaced1579212e9b7c53c22dca1d9745de00ead76573d514" +checksum = "d65a1d4ddae7d8b5de68153b48f6aa3bba8cb002b243dbdbc55a5afbc98f99f4" dependencies = [ "bytes", "once_cell", "protobuf-support", - "thiserror", + "thiserror 1.0.61", ] [[package]] name = "protobuf-codegen" -version = "3.5.0" +version = "3.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eab09155fad2d39333d3796f67845d43e29b266eea74f7bc93f153f707f126dc" +checksum = "5d3976825c0014bbd2f3b34f0001876604fe87e0c86cd8fa54251530f1544ace" dependencies = [ "anyhow", "once_cell", @@ -4916,14 +6527,14 @@ dependencies = [ "protobuf-parse", "regex", "tempfile", - "thiserror", + "thiserror 1.0.61", ] [[package]] name = "protobuf-parse" -version = "3.5.0" +version = "3.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a16027030d4ec33e423385f73bb559821827e9ec18c50e7874e4d6de5a4e96f" +checksum = "b4aeaa1f2460f1d348eeaeed86aea999ce98c1bded6f089ff8514c9d9dbdc973" dependencies = [ "anyhow", "indexmap", @@ -4931,17 +6542,17 @@ dependencies = [ "protobuf", "protobuf-support", "tempfile", - "thiserror", + "thiserror 1.0.61", "which", ] [[package]] name = "protobuf-support" -version = "3.5.0" +version = "3.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e2d30ab1878b2e72d1e2fc23ff5517799c9929e2cf81a8516f9f4dcf2b9cf3" +checksum = "3e36c2f31e0a47f9280fb347ef5e461ffcd2c52dd520d8e216b52f93b0b0d7d6" dependencies = [ - "thiserror", + "thiserror 1.0.61", ] [[package]] @@ -4979,7 +6590,7 @@ dependencies = [ "cfg-if 0.1.10", "rpassword 2.1.0", "tempfile", - "termios", + "termios 0.3.3", "winapi 0.3.9", ] @@ -5003,13 +6614,68 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.34.0" +version = "0.37.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f24d770aeca0eacb81ac29dfbc55ebcc09312fdd1f8bbecdc7e4a84e000e3b4" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases 0.2.1", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.1", + "rustls", + "socket2 0.5.10", + "thiserror 2.0.17", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.2", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash 2.1.1", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.17", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases 0.2.1", + "libc", + "once_cell", + "socket2 0.5.10", + "tracing", + "windows-sys 0.52.0", +] + [[package]] name = "quote" version = "0.6.13" @@ -5025,9 +6691,15 @@ version = "1.0.36" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" dependencies = [ - "proc-macro2 1.0.86", + "proc-macro2 1.0.93", ] +[[package]] +name = "r-efi" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" + [[package]] name = "radium" version = "0.7.0" @@ -5064,6 +6736,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", +] + [[package]] name = "rand_chacha" version = "0.1.1" @@ -5084,6 +6766,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", +] + [[package]] name = "rand_core" version = "0.3.1" @@ -5105,7 +6797,16 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.15", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.2", ] [[package]] @@ -5202,14 +6903,28 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time 0.3.36", + "x509-parser", + "yasna", +] + [[package]] name = "rdev" version = "0.5.0-2" -source = "git+https://github.com/rustdesk-org/rdev#961d25cc00c6b3ef80f444e6a7bed9872e2c35ea" +source = "git+https://github.com/rustdesk-org/rdev#f9b60b1dd0f3300a1b797d7a74c116683cd232c8" dependencies = [ "cocoa 0.24.1", "core-foundation 0.9.4", - "core-foundation-sys 0.8.6 (registry+https://github.com/rust-lang/crates.io-index)", + "core-foundation-sys 0.8.7", "core-graphics 0.22.3", "dispatch", "enum-map", @@ -5218,7 +6933,7 @@ dependencies = [ "lazy_static", "libc", "log", - "mio", + "mio 0.8.11", "strum 0.24.1", "strum_macros 0.24.3", "widestring", @@ -5259,7 +6974,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c82cf8cff14456045f55ec4241383baeff27af886adb72ffb2162f99911de0fd" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.1", ] [[package]] @@ -5268,16 +6983,27 @@ version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd283d9651eeda4b2a83a43c1c91b266c40fd76ecd39a50a8c630ae69dc72891" dependencies = [ - "getrandom", + "getrandom 0.2.15", "libredox", - "thiserror", + "thiserror 1.0.61", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.15", + "libredox", + "thiserror 2.0.17", ] [[package]] name = "regex" -version = "1.10.5" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b91213439dad192326a0d7c6ee3955910425f441d7038e0d6933b0aec5c4517f" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" dependencies = [ "aho-corasick", "memchr", @@ -5287,9 +7013,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.7" +version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" dependencies = [ "aho-corasick", "memchr", @@ -5298,9 +7024,18 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "remote_printer" +version = "0.1.0" +dependencies = [ + "hbb_common", + "winapi 0.3.9", + "windows-strings 0.3.1", +] [[package]] name = "repng" @@ -5314,62 +7049,79 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.11.23" -source = "git+https://github.com/rustdesk-org/reqwest#9cb758c9fb2f4edc62eb790acfd45a6a3da21ed3" +version = "0.12.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" dependencies = [ "async-compression", - "base64 0.21.7", + "base64 0.22.1", "bytes", - "encoding_rs", + "futures-channel", "futures-core", "futures-util", - "h2", "http", "http-body", + "http-body-util", "hyper", "hyper-rustls", "hyper-tls", - "ipnet", + "hyper-util", "js-sys", "log", - "mime", "native-tls", - "once_cell", "percent-encoding", "pin-project-lite", - "rustls 0.21.12", - "rustls-native-certs 0.6.3", - "rustls-pemfile 1.0.4", - "serde 1.0.203", + "quinn", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde 1.0.228", "serde_json 1.0.118", "serde_urlencoded", "sync_wrapper", - "system-configuration", "tokio", "tokio-native-tls", - "tokio-rustls 0.24.1", - "tokio-socks 0.5.1", + "tokio-rustls", "tokio-util", + "tower", + "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots 0.25.4", - "winreg 0.50.0", + "webpki-roots 1.0.4", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "rgb" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57397d16646700483b67d2dd6511d79318f9d057fdbd21a4066aeac8b41d310a" +dependencies = [ + "bytemuck", ] [[package]] name = "ring" -version = "0.17.8" +version = "0.17.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if 1.0.0", - "getrandom", + "getrandom 0.2.15", "libc", - "spin", "untrusted", "windows-sys 0.52.0", ] @@ -5383,6 +7135,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + [[package]] name = "rpassword" version = "2.1.0" @@ -5405,6 +7163,17 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "rtcp" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81d30d1c4091644431c22acf9f8be6191b56805e0e977f15ca7104b4a6d6eaec" +dependencies = [ + "bytes", + "thiserror 1.0.61", + "webrtc-util", +] + [[package]] name = "rtoolbox" version = "0.0.2" @@ -5415,6 +7184,21 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "rtp" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f126f38ea84c02480e32e547c1459a939052f74fb92117ac3eef23fdac6b023" +dependencies = [ + "bytes", + "memchr", + "portable-atomic", + "rand 0.9.2", + "serde 1.0.228", + "thiserror 1.0.61", + "webrtc-util", +] + [[package]] name = "rubato" version = "0.12.0" @@ -5452,7 +7236,7 @@ dependencies = [ [[package]] name = "rust-pulsectl" version = "0.2.12" -source = "git+https://github.com/open-trade/pulsectl#5e68f4c2b7c644fa321984688602d71e8ad0bba3" +source = "git+https://github.com/rustdesk-org/pulsectl#aa34dde499aa912a3abc5289cc0b547bd07dd6e2" dependencies = [ "libpulse-binding", ] @@ -5469,6 +7253,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustc_version" version = "0.4.0" @@ -5480,19 +7270,20 @@ dependencies = [ [[package]] name = "rustdesk" -version = "1.3.2" +version = "1.4.6" dependencies = [ "android-wakelock", "android_logger", "arboard", "async-process", "async-trait", + "bytemuck", "bytes", "cc", "cfg-if 1.0.0", "chrono", "cidr-utils", - "clap 4.5.8", + "clap 4.5.53", "clipboard", "clipboard-master", "cocoa 0.24.1", @@ -5506,11 +7297,14 @@ dependencies = [ "dbus-crossroads", "default-net", "dispatch", + "docopt", "enigo", "errno", "evdev", "flutter_rust_bridge", "fon", + "fontdb", + "foreign-types 0.3.2", "fruitbasket", "gtk", "hbb_common", @@ -5519,12 +7313,13 @@ dependencies = [ "image 0.24.9", "impersonate_system", "include_dir", - "jni 0.21.1", + "jni", + "kcp-sys", "keepawake", "lazy_static", - "libloading 0.8.4", "libpulse-binding", "libpulse-simple-binding", + "libxdo-sys", "mac_address", "magnum-opus", "nix 0.29.0", @@ -5532,12 +7327,17 @@ dependencies = [ "objc", "objc_id", "once_cell", + "openssl", "os-version", "pam", "parity-tokio-ipc", "percent-encoding", + "piet", + "piet-coregraphics", + "portable-pty", "qrcode-generator", "rdev", + "remote_printer", "repng", "reqwest", "ringbuf", @@ -5548,28 +7348,33 @@ dependencies = [ "samplerate", "sciter-rs", "scrap", - "serde 1.0.203", + "serde 1.0.228", "serde_derive", "serde_json 1.0.118", "serde_repr", "sha2", "shared_memory", "shutdown_hooks", + "softbuffer", + "stunclient", "sys-locale", "system_shutdown", "tao", "tauri-winrt-notification", - "termios", + "terminfo", + "termios 0.3.3", + "tiny-skia", "totp-rs", "tray-icon", + "ttf-parser", "url", - "users 0.11.0", "uuid", "virtual_display", "wallpaper", - "whoami", "winapi 0.3.9", + "windows 0.61.1", "windows-service", + "winit", "winreg 0.11.0", "winres", "wol-rs", @@ -5580,13 +7385,14 @@ dependencies = [ [[package]] name = "rustdesk-portable-packer" -version = "1.3.2" +version = "1.4.6" dependencies = [ "brotli", "dirs 5.0.1", "md5", "native-windows-gui", "winapi 0.3.9", + "windows 0.61.1", "winres", ] @@ -5605,6 +7411,15 @@ dependencies = [ "version_check", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustix" version = "0.37.27" @@ -5625,7 +7440,7 @@ version = "0.38.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.1", "errno", "libc", "linux-raw-sys 0.4.14", @@ -5633,124 +7448,86 @@ dependencies = [ ] [[package]] -name = "rustls" -version = "0.21.12" +name = "rustix" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ - "log", - "ring", - "rustls-webpki 0.101.7", - "sct", + "bitflags 2.9.1", + "errno", + "libc", + "linux-raw-sys 0.11.0", + "windows-sys 0.52.0", ] [[package]] name = "rustls" -version = "0.23.10" +version = "0.23.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05cff451f60db80f490f3c182b77c35260baace73209e9cdbbe526bfe3a4d402" +checksum = "7160e3e10bf4535308537f3c4e1641468cd0e485175d6163087c0393c7d46643" dependencies = [ "log", "once_cell", "ring", "rustls-pki-types", - "rustls-webpki 0.102.4", + "rustls-webpki", "subtle", "zeroize", ] [[package]] name = "rustls-native-certs" -version = "0.6.3" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00" +checksum = "7fcff2dd52b58a8d98a70243663a0d234c4e2b79235637849d15913394a247d3" dependencies = [ "openssl-probe", - "rustls-pemfile 1.0.4", - "schannel", - "security-framework", -] - -[[package]] -name = "rustls-native-certs" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f1fb85efa936c42c6d5fc28d2629bb51e4b2f4b8a5211e297d599cc5a093792" -dependencies = [ - "openssl-probe", - "rustls-pemfile 2.1.2", "rustls-pki-types", "schannel", - "security-framework", -] - -[[package]] -name = "rustls-pemfile" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" -dependencies = [ - "base64 0.21.7", -] - -[[package]] -name = "rustls-pemfile" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29993a25686778eb88d4189742cd713c9bce943bc54251a33509dc63cbacf73d" -dependencies = [ - "base64 0.22.1", - "rustls-pki-types", + "security-framework 3.5.1", ] [[package]] name = "rustls-pki-types" -version = "1.7.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "976295e77ce332211c0d24d92c0e83e50f5c5f046d11082cea19f3df13a3562d" +checksum = "917ce264624a4b4db1c364dcc35bfca9ded014d0a958cd47ad3e960e988ea51c" +dependencies = [ + "web-time", +] [[package]] name = "rustls-platform-verifier" -version = "0.3.2" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e3beb939bcd33c269f4bf946cc829fcd336370267c4a927ac0399c84a3151a1" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" dependencies = [ - "core-foundation 0.9.4", - "core-foundation-sys 0.8.6 (registry+https://github.com/rust-lang/crates.io-index)", - "jni 0.19.0", + "core-foundation 0.10.1", + "core-foundation-sys 0.8.7", + "jni", "log", "once_cell", - "rustls 0.23.10", - "rustls-native-certs 0.7.0", + "rustls", + "rustls-native-certs", "rustls-platform-verifier-android", - "rustls-webpki 0.102.4", - "security-framework", + "rustls-webpki", + "security-framework 3.5.1", "security-framework-sys", - "webpki-roots 0.26.3", - "winapi 0.3.9", + "webpki-root-certs", + "windows-sys 0.52.0", ] [[package]] name = "rustls-platform-verifier-android" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84e217e7fdc8466b5b35d30f8c0a30febd29173df4a3a0c2115d306b9c4117ad" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.101.7" +version = "0.103.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" -dependencies = [ - "ring", - "untrusted", -] - -[[package]] -name = "rustls-webpki" -version = "0.102.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff448f7e92e913c4b7d4c6d8e4540a1724b319b4152b8aef6d4cf8339712b33e" +checksum = "e4a72fe2bcf7a6ac6fd7d0b9e5cb68aeb7d4c0a0271730218b3e92d43b4eb435" dependencies = [ "ring", "rustls-pki-types", @@ -5799,7 +7576,7 @@ dependencies = [ [[package]] name = "sciter-rs" version = "0.5.57" -source = "git+https://github.com/open-trade/rust-sciter?branch=dyn#5322f3a755a0e6bf999fbc60d1efc35246c0f821" +source = "git+https://github.com/rustdesk-org/rust-sciter?branch=dyn#5322f3a755a0e6bf999fbc60d1efc35246c0f821" dependencies = [ "lazy_static", "libc", @@ -5834,31 +7611,62 @@ dependencies = [ "gstreamer-video", "hbb_common", "hwcodec", - "jni 0.21.1", + "jni", "lazy_static", "log", "ndk 0.7.0", "ndk-context", + "nokhwa", "num_cpus", "pkg-config", "quest", "repng", - "serde 1.0.203", + "serde 1.0.228", "serde_json 1.0.118", "target_build_utils", "tracing", "webm", "winapi 0.3.9", + "zbus", ] [[package]] -name = "sct" -version = "0.7.1" +name = "sctk-adwaita" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" dependencies = [ - "ring", - "untrusted", + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit 0.19.2", + "tiny-skia", +] + +[[package]] +name = "sdp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32c374dceda16965d541c8800ce9cc4e1c14acfd661ddf7952feeedc3411e5c6" +dependencies = [ + "rand 0.9.2", + "substring", + "thiserror 1.0.61", + "url", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", ] [[package]] @@ -5869,19 +7677,31 @@ checksum = "770452e37cad93e0a50d5abc3990d2bc351c36d0328f86cefec2f2fb206eaef6" dependencies = [ "bitflags 1.3.2", "core-foundation 0.9.4", - "core-foundation-sys 0.8.6 (registry+https://github.com/rust-lang/crates.io-index)", + "core-foundation-sys 0.8.7", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +dependencies = [ + "bitflags 2.9.1", + "core-foundation 0.10.1", + "core-foundation-sys 0.8.7", "libc", - "num-bigint", "security-framework-sys", ] [[package]] name = "security-framework-sys" -version = "2.11.0" +version = "2.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "317936bbbd05227752583946b9e66d7ce3b489f84e11a94a510b4437fef407d7" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" dependencies = [ - "core-foundation-sys 0.8.6 (registry+https://github.com/rust-lang/crates.io-index)", + "core-foundation-sys 0.8.7", "libc", ] @@ -5899,22 +7719,32 @@ checksum = "34b623917345a631dc9608d5194cc206b3fe6c3554cd1c75b937e55e285254af" [[package]] name = "serde" -version = "1.0.203" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7253ab4de971e72fb7be983802300c30b5a7f0c2e56fab8abfc6a214307c0094" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.203" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "500cbc0ebeb6f46627f50f3f5811ccf6bf00643be300b4c3eabc0ef55dc5b5ba" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ - "proc-macro2 1.0.86", + "proc-macro2 1.0.93", "quote 1.0.36", - "syn 2.0.68", + "syn 2.0.98", ] [[package]] @@ -5937,7 +7767,7 @@ checksum = "d947f6b3163d8857ea16c4fa0dd4840d52f3041039a85decd46867eb1abef2e4" dependencies = [ "itoa 1.0.11", "ryu", - "serde 1.0.203", + "serde 1.0.228", ] [[package]] @@ -5946,9 +7776,9 @@ version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c64451ba24fc7a6a2d60fc75dd9c83c90903b19028d4eff35e88fc1e86564e9" dependencies = [ - "proc-macro2 1.0.86", + "proc-macro2 1.0.93", "quote 1.0.36", - "syn 2.0.68", + "syn 2.0.98", ] [[package]] @@ -5957,7 +7787,7 @@ version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "79e674e01f999af37c49f70a6ede167a8a60b2503e56c5599532a65baa5969a0" dependencies = [ - "serde 1.0.203", + "serde 1.0.228", ] [[package]] @@ -5969,7 +7799,49 @@ dependencies = [ "form_urlencoded", "itoa 1.0.11", "ryu", - "serde 1.0.203", + "serde 1.0.228", +] + +[[package]] +name = "serial" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1237a96570fc377c13baa1b88c7589ab66edced652e43ffb17088f003db3e86" +dependencies = [ + "serial-core", + "serial-unix", + "serial-windows", +] + +[[package]] +name = "serial-core" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f46209b345401737ae2125fe5b19a77acce90cd53e1658cda928e4fe9a64581" +dependencies = [ + "libc", +] + +[[package]] +name = "serial-unix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f03fbca4c9d866e24a459cbca71283f545a37f8e3e002ad8c70593871453cab7" +dependencies = [ + "ioctl-rs", + "libc", + "serial-core", + "termios 0.2.2", +] + +[[package]] +name = "serial-windows" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15c6d3b776267a75d31bbdfd5d36c0ca051251caafc285827052bc53bcdc8162" +dependencies = [ + "libc", + "serial-core", ] [[package]] @@ -6007,6 +7879,25 @@ dependencies = [ "tzdb 0.5.10", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + [[package]] name = "shared_memory" version = "0.12.4" @@ -6020,6 +7911,12 @@ dependencies = [ "win-sys", ] +[[package]] +name = "shell-words" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" + [[package]] name = "shlex" version = "1.3.0" @@ -6047,6 +7944,16 @@ version = "1.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c" +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.7" @@ -6059,6 +7966,12 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b8de496cf83d4ed58b6be86c3a275b8602f6ffe98d3024a869e124147a9a3ac" +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + [[package]] name = "slab" version = "0.4.9" @@ -6069,10 +7982,80 @@ dependencies = [ ] [[package]] -name = "smallvec" -version = "1.13.2" +name = "slotmap" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" +checksum = "dbff4acf519f630b3a3ddcfaea6c06b42174d9a44bc70c620e9ed1649d58b82a" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.9.1", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.34", + "thiserror 1.0.61", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-client-toolkit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +dependencies = [ + "bitflags 2.9.1", + "calloop 0.14.3", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.2", + "thiserror 2.0.17", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde 1.0.228", +] [[package]] name = "socket2" @@ -6097,9 +8080,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.5.7" +version = "0.5.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" dependencies = [ "libc", "windows-sys 0.52.0", @@ -6114,7 +8097,40 @@ dependencies = [ "ed25519", "libc", "libsodium-sys", - "serde 1.0.203", + "serde 1.0.228", +] + +[[package]] +name = "softbuffer" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d623bff5d06f60d738990980d782c8c866997d9194cfe79ecad00aa2f76826dd" +dependencies = [ + "as-raw-xcb-connection", + "bytemuck", + "cfg_aliases 0.2.1", + "core-graphics 0.23.2", + "drm", + "fastrand 2.1.0", + "foreign-types 0.5.0", + "js-sys", + "log", + "memmap2", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-quartz-core", + "raw-window-handle 0.6.2", + "redox_syscall 0.5.2", + "rustix 0.38.34", + "tiny-xlib", + "wasm-bindgen", + "wayland-backend", + "wayland-client", + "wayland-sys", + "web-sys", + "windows-sys 0.52.0", + "x11rb 0.13.1", ] [[package]] @@ -6126,6 +8142,16 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "static_assertions" version = "1.1.0" @@ -6138,6 +8164,12 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + [[package]] name = "strsim" version = "0.8.0" @@ -6175,7 +8207,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "87c85aa3f8ea653bfd3ddf25f7ee357ee4d204731f6aa9ad04002306f6e2774c" dependencies = [ "heck 0.3.3", - "proc-macro2 1.0.86", + "proc-macro2 1.0.93", "quote 1.0.36", "syn 1.0.109", ] @@ -6187,12 +8219,67 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e385be0d24f186b4ce2f9982191e7101bb737312ad61c1f2f984f34bcf85d59" dependencies = [ "heck 0.4.1", - "proc-macro2 1.0.86", + "proc-macro2 1.0.93", "quote 1.0.36", "rustversion", "syn 1.0.109", ] +[[package]] +name = "stun" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a512c5d501e3e3b5a4bb3e8e31462d56d54a66b95a28b8596e14422bf21c32b" +dependencies = [ + "base64 0.22.1", + "crc", + "lazy_static", + "md-5", + "rand 0.9.2", + "ring", + "subtle", + "thiserror 1.0.61", + "tokio", + "url", + "webrtc-util", +] + +[[package]] +name = "stun_codec" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "feed9dafe0bda84f2b6ca3ce726b0a1f1ac2e8b63c6ecfb89b08b32313247b5b" +dependencies = [ + "bytecodec", + "byteorder", + "crc", + "hmac", + "md5", + "sha1", + "trackable 1.3.0", +] + +[[package]] +name = "stunclient" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c969a14b4a4c09c320416ebf880b3d5a81ad1612065741eb10521951c06c8991" +dependencies = [ + "bytecodec", + "rand 0.8.5", + "stun_codec", + "tokio", +] + +[[package]] +name = "substring" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ee6433ecef213b2e72f587ef64a2f5943e7cd16fbd82dbe8bc07486c534c86" +dependencies = [ + "autocfg 1.3.0", +] + [[package]] name = "subtle" version = "2.6.1" @@ -6216,27 +8303,41 @@ version = "1.0.109" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ - "proc-macro2 1.0.86", + "proc-macro2 1.0.93", "quote 1.0.36", "unicode-ident", ] [[package]] name = "syn" -version = "2.0.68" +version = "2.0.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "901fa70d88b9d6c98022e23b4136f9f3e54e4662c3bc1bd1d84a42a9a0f0c1e9" +checksum = "36147f1a48ae0ec2b5b3bc5b537d267457555a10dc06f3dbc8cb11ba3006d3b1" dependencies = [ - "proc-macro2 1.0.86", + "proc-macro2 1.0.93", "quote 1.0.36", "unicode-ident", ] [[package]] name = "sync_wrapper" -version = "0.1.2" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2 1.0.93", + "quote 1.0.36", + "syn 2.0.98", +] [[package]] name = "sys-locale" @@ -6253,7 +8354,7 @@ version = "0.29.10" source = "git+https://github.com/rustdesk-org/sysinfo?branch=rlim_max#90b1705d909a4902dbbbdea37ee64db17841077d" dependencies = [ "cfg-if 1.0.0", - "core-foundation-sys 0.8.6 (registry+https://github.com/rust-lang/crates.io-index)", + "core-foundation-sys 0.8.7", "libc", "ntapi", "once_cell", @@ -6278,7 +8379,7 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" dependencies = [ - "core-foundation-sys 0.8.6 (registry+https://github.com/rust-lang/crates.io-index)", + "core-foundation-sys 0.8.7", "libc", ] @@ -6292,7 +8393,7 @@ dependencies = [ "pkg-config", "strum 0.18.0", "strum_macros 0.18.0", - "thiserror", + "thiserror 1.0.61", "toml 0.5.11", "version-compare 0.0.10", ] @@ -6337,7 +8438,7 @@ dependencies = [ "gtk", "image 0.24.9", "instant", - "jni 0.21.1", + "jni", "lazy_static", "libc", "log", @@ -6347,14 +8448,14 @@ dependencies = [ "objc", "once_cell", "parking_lot", - "png", + "png 0.17.13", "raw-window-handle 0.6.2", "scopeguard", "tao-macros", "unicode-segmentation", "url", "windows 0.52.0", - "windows-implement", + "windows-implement 0.52.0", "windows-version", "x11-dl", "zbus", @@ -6365,7 +8466,7 @@ name = "tao-macros" version = "0.1.2" source = "git+https://github.com/rustdesk-org/tao?branch=dev#288c219cb0527e509590c2b2d8e7072aa9feb2d3" dependencies = [ - "proc-macro2 1.0.86", + "proc-macro2 1.0.93", "quote 1.0.36", "syn 1.0.109", ] @@ -6388,8 +8489,8 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "013d134ae4a25ee744ad6129db589018558f620ddfa44043887cdd45fa08e75c" dependencies = [ - "phf", - "phf_codegen", + "phf 0.7.24", + "phf_codegen 0.7.24", "serde_json 0.9.10", ] @@ -6424,6 +8525,28 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "terminfo" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666cd3a6681775d22b200409aad3b089c5b99fb11ecdd8a204d9d62f8148498f" +dependencies = [ + "dirs 4.0.0", + "fnv", + "nom", + "phf 0.11.3", + "phf_codegen 0.11.3", +] + +[[package]] +name = "termios" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5d9cf598a6d7ce700a4e6a9199da127e6819a61e64b68609683cc9a01b5683a" +dependencies = [ + "libc", +] + [[package]] name = "termios" version = "0.3.3" @@ -6445,7 +8568,7 @@ dependencies = [ [[package]] name = "tfc" version = "0.7.0" -source = "git+https://github.com/rustdesk-org/The-Fat-Controller?branch=history/rebase_upstream_20240722#de9c8ba480f166a9fc90aaa47bb0e84b443ea9c6" +source = "git+https://github.com/rustdesk-org/The-Fat-Controller?branch=history/rebase_upstream_20240722#78bb80a8e596e4c14ae57c8448f5fca75f91f2b0" dependencies = [ "anyhow", "core-graphics 0.23.2", @@ -6460,7 +8583,16 @@ version = "1.0.61" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c546c80d6be4bc6a00c0f01730c08df82eaa7a7a61f11d656526506112cc1709" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.61", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl 2.0.17", ] [[package]] @@ -6469,9 +8601,30 @@ version = "1.0.61" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46c3384250002a6d5af4d114f2845d37b57521033f30d5c3f46c4d70e1197533" dependencies = [ - "proc-macro2 1.0.86", + "proc-macro2 1.0.93", "quote 1.0.36", - "syn 2.0.68", + "syn 2.0.98", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2 1.0.93", + "quote 1.0.36", + "syn 2.0.98", +] + +[[package]] +name = "thread_local" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +dependencies = [ + "cfg-if 1.0.0", + "once_cell", ] [[package]] @@ -6517,7 +8670,7 @@ dependencies = [ "num-conv", "num_threads", "powerfmt", - "serde 1.0.203", + "serde 1.0.228", "time-core", "time-macros", ] @@ -6538,6 +8691,45 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if 1.0.0", + "log", + "png 0.17.13", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tiny-xlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0324504befd01cab6e0c994f34b2ffa257849ee019d3fb3b64fb2c858887d89e" +dependencies = [ + "as-raw-xcb-connection", + "ctor-lite", + "libloading 0.8.4", + "pkg-config", + "tracing", +] + [[package]] name = "tinyvec" version = "1.6.1" @@ -6555,32 +8747,31 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.38.0" +version = "1.44.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba4f4a02a7a80d6f274636f0aa95c7e383b912d41fe721a31f29e29698585a4a" +checksum = "e6b88822cbe49de4185e3a4cbf8321dd487cf5fe0c5c65695fef6346371e9c48" dependencies = [ "backtrace", "bytes", "libc", - "mio", - "num_cpus", + "mio 1.0.3", "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.5.7", + "socket2 0.5.10", "tokio-macros", - "windows-sys 0.48.0", + "windows-sys 0.52.0", ] [[package]] name = "tokio-macros" -version = "2.3.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f5ae998a069d4b5aba8ee9dad856af7d520c3699e6159b185c2acd48155d39a" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" dependencies = [ - "proc-macro2 1.0.86", + "proc-macro2 1.0.93", "quote 1.0.36", - "syn 2.0.68", + "syn 2.0.98", ] [[package]] @@ -6593,43 +8784,21 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-rustls" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" -dependencies = [ - "rustls 0.21.12", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4" dependencies = [ - "rustls 0.23.10", + "rustls", "rustls-pki-types", "tokio", ] [[package]] name = "tokio-socks" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51165dfa029d2a65969413a6cc96f354b86b464498702f174a4efa13608fd8c0" -dependencies = [ - "either", - "futures-util", - "thiserror", - "tokio", -] - -[[package]] -name = "tokio-socks" -version = "0.5.2-1" -source = "git+https://github.com/rustdesk-org/tokio-socks#94e97c6d7c93b0bcbfa54f2dc397c1da0a6e43d3" +version = "0.5.2-3" +source = "git+https://github.com/rustdesk-org/tokio-socks#bdb9aa3de5bac41602d0742b8ef6bbc6bfebd127" dependencies = [ "bytes", "either", @@ -6637,23 +8806,42 @@ dependencies = [ "futures-sink", "futures-util", "pin-project", - "thiserror", + "thiserror 2.0.17", "tokio", "tokio-util", ] [[package]] -name = "tokio-util" -version = "0.7.11" +name = "tokio-tungstenite" +version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cf6b47b3771c49ac75ad09a6162f53ad4b8088b76ac60e8ec1455b31a189fe1" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +dependencies = [ + "futures-util", + "log", + "native-tls", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-native-tls", + "tokio-rustls", + "tungstenite", + "webpki-roots 0.26.9", +] + +[[package]] +name = "tokio-util" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df" dependencies = [ "bytes", "futures-core", "futures-io", "futures-sink", "futures-util", - "hashbrown 0.14.5", + "hashbrown 0.15.4", "pin-project-lite", "slab", "tokio", @@ -6665,7 +8853,7 @@ version = "0.5.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" dependencies = [ - "serde 1.0.203", + "serde 1.0.228", ] [[package]] @@ -6674,7 +8862,7 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd79e69d3b627db300ff956027cc6c3798cef26d22526befdfcd12feeb6d2257" dependencies = [ - "serde 1.0.203", + "serde 1.0.228", "serde_spanned", "toml_datetime", "toml_edit 0.19.15", @@ -6686,7 +8874,7 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" dependencies = [ - "serde 1.0.203", + "serde 1.0.228", "serde_spanned", "toml_datetime", "toml_edit 0.20.2", @@ -6698,7 +8886,7 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" dependencies = [ - "serde 1.0.203", + "serde 1.0.228", ] [[package]] @@ -6708,7 +8896,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ "indexmap", - "serde 1.0.203", + "serde 1.0.228", "serde_spanned", "toml_datetime", "winnow", @@ -6721,7 +8909,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" dependencies = [ "indexmap", - "serde 1.0.203", + "serde 1.0.228", "serde_spanned", "toml_datetime", "winnow", @@ -6744,17 +8932,57 @@ dependencies = [ ] [[package]] -name = "tower-service" -version = "0.3.2" +name = "tower" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +dependencies = [ + "bitflags 2.9.1", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.40" +version = "0.1.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -6762,22 +8990,77 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.27" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" +checksum = "1b1ffbcf9c6f6b99d386e7444eb608ba646ae452a36b39737deb9663b610f662" dependencies = [ - "proc-macro2 1.0.86", + "proc-macro2 1.0.93", "quote 1.0.36", - "syn 2.0.68", + "syn 2.0.98", ] [[package]] name = "tracing-core" -version = "0.1.32" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" +dependencies = [ + "nu-ansi-term 0.46.0", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "trackable" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98abb9e7300b9ac902cc04920945a874c1973e08c310627cc4458c04b70dd32" +dependencies = [ + "trackable 1.3.0", + "trackable_derive", +] + +[[package]] +name = "trackable" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15bd114abb99ef8cee977e517c8f37aee63f184f2d08e3e6ceca092373369ae" +dependencies = [ + "trackable_derive", +] + +[[package]] +name = "trackable_derive" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebeb235c5847e2f82cfe0f07eb971d1e5f6804b18dac2ae16349cc604380f82f" +dependencies = [ + "quote 1.0.36", + "syn 1.0.109", ] [[package]] @@ -6792,21 +9075,22 @@ dependencies = [ [[package]] name = "tray-icon" -version = "0.14.3" -source = "git+https://github.com/tauri-apps/tray-icon#d4078696edba67b0ab42cef67e6a421a0332c96f" +version = "0.21.3" +source = "git+https://github.com/tauri-apps/tray-icon#0a5835b0e6828e37a1f781de9c2d671ae7a939e6" dependencies = [ - "core-graphics 0.23.2", "crossbeam-channel", - "dirs 5.0.1", + "dirs 6.0.0", "libappindicator", "muda", - "objc2 0.5.2", - "objc2-app-kit", - "objc2-foundation", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", "once_cell", - "png", - "thiserror", - "windows-sys 0.52.0", + "png 0.18.1", + "thiserror 2.0.17", + "windows-sys 0.60.2", ] [[package]] @@ -6829,6 +9113,58 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] + +[[package]] +name = "tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "native-tls", + "rand 0.9.2", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "sha1", + "thiserror 2.0.17", + "utf-8", + "webpki-roots 0.26.9", +] + +[[package]] +name = "turn" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ed995882f66ab94238de77c62e5e778389698ab700afa4696f4754da8f457cb" +dependencies = [ + "async-trait", + "base64 0.22.1", + "futures", + "log", + "md-5", + "portable-atomic", + "rand 0.9.2", + "ring", + "stun", + "thiserror 1.0.61", + "tokio", + "tokio-util", + "webrtc-util", +] + [[package]] name = "typenum" version = "1.17.0" @@ -6895,6 +9231,63 @@ dependencies = [ "libc", ] +[[package]] +name = "unic-bidi" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1356b759fb6a82050666f11dce4b6fe3571781f1449f3ef78074e408d468ec09" +dependencies = [ + "matches", + "unic-ucd-bidi", +] + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-bidi" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1d568b51222484e1f8209ce48caa6b430bf352962b877d592c29ab31fb53d8c" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + [[package]] name = "unicode-bidi" version = "0.3.15" @@ -6940,6 +9333,16 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f962df74c8c05a667b5ee8bcf162993134c104e96440b663c8daa176dc772d8c" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -6955,7 +9358,7 @@ dependencies = [ "form_urlencoded", "idna", "percent-encoding", - "serde 1.0.203", + "serde 1.0.228", ] [[package]] @@ -6984,6 +9387,12 @@ dependencies = [ "log", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf16string" version = "0.2.0" @@ -7007,13 +9416,39 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.9.1" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5de17fd2f7da591098415cff336e12965a28061ddace43b59cb3c430179c9439" +checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9" dependencies = [ - "getrandom", + "getrandom 0.3.2", ] +[[package]] +name = "v4l" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8fbfea44a46799d62c55323f3c55d06df722fbe577851d848d328a1041c3403" +dependencies = [ + "bitflags 1.3.2", + "libc", + "v4l2-sys-mit", +] + +[[package]] +name = "v4l2-sys-mit" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6779878362b9bacadc7893eac76abe69612e8837ef746573c4a5239daf11990b" +dependencies = [ + "bindgen 0.65.1", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "vcpkg" version = "0.2.15" @@ -7052,6 +9487,15 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "waitgroup" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1f50000a783467e6c0200f9d10642f4bc424e39efc1b770203e88b488f79292" +dependencies = [ + "atomic-waker", +] + [[package]] name = "waker-fn" version = "1.2.0" @@ -7076,7 +9520,7 @@ dependencies = [ "dirs 5.0.1", "enquote", "rust-ini", - "thiserror", + "thiserror 1.0.61", "winapi 0.3.9", "winreg 0.11.0", ] @@ -7102,6 +9546,15 @@ version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +[[package]] +name = "wasi" +version = "0.14.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] + [[package]] name = "wasite" version = "0.1.0" @@ -7110,46 +9563,48 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.92" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" dependencies = [ "cfg-if 1.0.0", + "once_cell", + "rustversion", "wasm-bindgen-macro", ] [[package]] name = "wasm-bindgen-backend" -version = "0.2.92" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" dependencies = [ "bumpalo", "log", - "once_cell", - "proc-macro2 1.0.86", + "proc-macro2 1.0.93", "quote 1.0.36", - "syn 2.0.68", + "syn 2.0.98", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" -version = "0.4.42" +version = "0.4.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76bc14366121efc8dbb487ab05bcc9d346b3b5ec0eaa76e46594cabbe51762c0" +checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" dependencies = [ "cfg-if 1.0.0", "js-sys", + "once_cell", "wasm-bindgen", "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.92" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" dependencies = [ "quote 1.0.36", "wasm-bindgen-macro-support", @@ -7157,32 +9612,35 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.92" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" dependencies = [ - "proc-macro2 1.0.86", + "proc-macro2 1.0.93", "quote 1.0.36", - "syn 2.0.68", + "syn 2.0.98", "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.92" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] [[package]] name = "wayland-backend" -version = "0.3.6" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90e11ce2ca99c97b940ee83edbae9da2d56a08f9ea8158550fd77fa31722993" +checksum = "673a33c33048a5ade91a6b139580fa174e19fb0d23f396dca9fa15f2e1e49b35" dependencies = [ "cc", "downcast-rs", - "rustix 0.38.34", + "rustix 1.1.2", "scoped-tls", "smallvec", "wayland-sys", @@ -7190,35 +9648,96 @@ dependencies = [ [[package]] name = "wayland-client" -version = "0.31.5" +version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e321577a0a165911bdcfb39cf029302479d7527b517ee58ab0f6ad09edf0943" +checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d" dependencies = [ - "bitflags 2.6.0", - "rustix 0.38.34", + "bitflags 2.9.1", + "rustix 1.1.2", "wayland-backend", "wayland-scanner", ] [[package]] -name = "wayland-protocols" -version = "0.32.3" +name = "wayland-csd-frame" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62989625a776e827cc0f15d41444a3cea5205b963c3a25be48ae1b52d6b4daaa" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.1", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ef9489a8df197ebf3a8ce8a7a7f0a2320035c3743f3c1bd0bdbccf07ce64f95" +dependencies = [ + "rustix 0.38.34", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efa790ed75fbfd71283bd2521a1cfdc022aabcc28bdcff00851f9e4ae88d9901" +dependencies = [ + "bitflags 2.9.1", "wayland-backend", "wayland-client", "wayland-scanner", ] +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.9.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dfe33d551eb8bffd03ff067a8b44bb963919157841a99957151299a6307d19c" +dependencies = [ + "bitflags 2.9.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f79f2d57c7fcc6ab4d602adba364bf59a5c24de57bd194486bf9b8360e06bfc4" +dependencies = [ + "bitflags 2.9.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + [[package]] name = "wayland-protocols-wlr" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd993de54a40a40fbe5601d9f1fbcaef0aebcc5fda447d7dc8f6dcbaae4f8953" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.1", "wayland-backend", "wayland-client", "wayland-protocols", @@ -7227,31 +9746,42 @@ dependencies = [ [[package]] name = "wayland-scanner" -version = "0.31.4" +version = "0.31.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7b56f89937f1cf2ee1f1259cf2936a17a1f45d8f0aa1019fae6d470d304cfa6" +checksum = "54cb1e9dc49da91950bdfd8b848c49330536d9d1fb03d4bfec8cae50caa50ae3" dependencies = [ - "proc-macro2 1.0.86", - "quick-xml 0.34.0", + "proc-macro2 1.0.93", + "quick-xml 0.37.5", "quote 1.0.36", ] [[package]] name = "wayland-sys" -version = "0.31.4" +version = "0.31.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43676fe2daf68754ecf1d72026e4e6c15483198b5d24e888b74d3f22f887a148" +checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142" dependencies = [ "dlib", "log", + "once_cell", "pkg-config", ] [[package]] name = "web-sys" -version = "0.3.69" +version = "0.3.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77afa9a11836342370f4817622a2f0f418b134426d91a82dfb48f532d2ec13ef" +checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" dependencies = [ "js-sys", "wasm-bindgen", @@ -7274,20 +9804,201 @@ dependencies = [ ] [[package]] -name = "webpki-roots" -version = "0.25.4" +name = "webpki-root-certs" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" +checksum = "05d651ec480de84b762e7be71e6efa7461699c19d9e2c272c8d93455f567786e" +dependencies = [ + "rustls-pki-types", +] [[package]] name = "webpki-roots" -version = "0.26.3" +version = "0.26.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd7c23921eeb1713a4e851530e9b9756e4fb0e89978582942612524cf09f01cd" +checksum = "29aad86cec885cafd03e8305fd727c418e970a521322c91688414d5b8efba16b" dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webrtc" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08fd686c0920ac08f3a57eacc48e31f0e4ca1ffefba4478784606f78c14e83ad" +dependencies = [ + "arc-swap", + "async-trait", + "bytes", + "dtls", + "hex", + "interceptor", + "lazy_static", + "log", + "portable-atomic", + "rand 0.9.2", + "rcgen", + "regex", + "ring", + "rtcp", + "rtp", + "sdp", + "serde 1.0.228", + "serde_json 1.0.118", + "sha2", + "smol_str", + "stun", + "thiserror 1.0.61", + "tokio", + "turn", + "unicase", + "url", + "waitgroup", + "webrtc-data", + "webrtc-ice", + "webrtc-mdns", + "webrtc-media", + "webrtc-sctp", + "webrtc-srtp", + "webrtc-util", +] + +[[package]] +name = "webrtc-data" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "062a5438d63bb0756a221693d76cc0dd6119affee1dfdfe57abe3a2a8c8b3eea" +dependencies = [ + "bytes", + "log", + "portable-atomic", + "thiserror 1.0.61", + "tokio", + "webrtc-sctp", + "webrtc-util", +] + +[[package]] +name = "webrtc-ice" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cb13fd1a373e68addc4bba0c8ca058627518e54342583d024bdcbb8ae5d97d" +dependencies = [ + "arc-swap", + "async-trait", + "crc", + "log", + "portable-atomic", + "rand 0.9.2", + "serde 1.0.228", + "serde_json 1.0.118", + "stun", + "thiserror 1.0.61", + "tokio", + "turn", + "url", + "uuid", + "waitgroup", + "webrtc-mdns", + "webrtc-util", +] + +[[package]] +name = "webrtc-mdns" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a17279a067e75df72ce923fdeb7f04cd808f6f5aa4910dc6bcb4fbe66b396ace" +dependencies = [ + "log", + "socket2 0.5.10", + "thiserror 1.0.61", + "tokio", + "webrtc-util", +] + +[[package]] +name = "webrtc-media" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94a84c910fec0848fd5a0d8a5651e0ddbdedaf25a7d3ae3f0b15f71ac73a1773" +dependencies = [ + "byteorder", + "bytes", + "rand 0.9.2", + "rtp", + "thiserror 1.0.61", +] + +[[package]] +name = "webrtc-sctp" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f985465467d8910c1f8ac4382cd64f83b1f6a1a75021a82b221546f6fb3b856f" +dependencies = [ + "arc-swap", + "async-trait", + "bytes", + "crc", + "log", + "portable-atomic", + "rand 0.9.2", + "thiserror 1.0.61", + "tokio", + "webrtc-util", +] + +[[package]] +name = "webrtc-srtp" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66d8cdc33413f1d0192670a80ce93d17cb78d57fe3a2414be30d6f6dff121123" +dependencies = [ + "aead", + "aes", + "aes-gcm", + "byteorder", + "bytes", + "ctr", + "hmac", + "log", + "rtcp", + "rtp", + "sha1", + "subtle", + "thiserror 1.0.61", + "tokio", + "webrtc-util", +] + +[[package]] +name = "webrtc-util" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c0c7e0c8f280f2bbfae442701465777ac07adaf46ce0c5863cd58e13fe472a" +dependencies = [ + "async-trait", + "bitflags 1.3.2", + "bytes", + "ipnet", + "lazy_static", + "log", + "nix 0.26.4", + "portable-atomic", + "rand 0.9.2", + "thiserror 1.0.61", + "tokio", + "winapi 0.3.9", +] + [[package]] name = "weezl" version = "0.1.8" @@ -7308,11 +10019,11 @@ dependencies = [ [[package]] name = "whoami" -version = "1.5.1" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a44ab49fad634e88f55bf8f9bb3abd2f27d7204172a112c7c9987e01c1c94ea9" +checksum = "6994d13118ab492c3c80c1f81928718159254c53c472bf9ce36f8dae4add02a7" dependencies = [ - "redox_syscall 0.4.1", + "redox_syscall 0.5.2", "wasite", "web-sys", ] @@ -7410,6 +10121,21 @@ dependencies = [ "windows_x86_64_msvc 0.34.0", ] +[[package]] +name = "windows" +version = "0.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04662ed0e3e5630dfa9b26e4cb823b817f1a9addda855d973a9458c236556244" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + [[package]] name = "windows" version = "0.44.0" @@ -7445,9 +10171,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" dependencies = [ "windows-core 0.52.0", - "windows-implement", - "windows-interface", - "windows-targets 0.52.5", + "windows-implement 0.52.0", + "windows-interface 0.52.0", + "windows-targets 0.52.6", ] [[package]] @@ -7457,7 +10183,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" dependencies = [ "windows-core 0.54.0", - "windows-targets 0.52.5", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows" +version = "0.61.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5ee8f3d025738cb02bad7868bbb5f8a6327501e870bf51f1b455b0a2454a419" +dependencies = [ + "windows-collections", + "windows-core 0.61.0", + "windows-future", + "windows-link 0.1.1", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.0", ] [[package]] @@ -7475,7 +10223,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" dependencies = [ - "windows-targets 0.52.5", + "windows-targets 0.52.6", ] [[package]] @@ -7484,8 +10232,31 @@ version = "0.54.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" dependencies = [ - "windows-result", - "windows-targets 0.52.5", + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.61.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980" +dependencies = [ + "windows-implement 0.60.0", + "windows-interface 0.59.1", + "windows-link 0.1.1", + "windows-result 0.3.2", + "windows-strings 0.4.0", +] + +[[package]] +name = "windows-future" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a1d6bbefcb7b60acd19828e1bc965da6fcf18a7e39490c5f8be71e54a19ba32" +dependencies = [ + "windows-core 0.61.0", + "windows-link 0.1.1", ] [[package]] @@ -7494,9 +10265,20 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12168c33176773b86799be25e2a2ba07c7aab9968b37541f1094dbd7a60c8946" dependencies = [ - "proc-macro2 1.0.86", + "proc-macro2 1.0.93", "quote 1.0.36", - "syn 2.0.68", + "syn 2.0.98", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2 1.0.93", + "quote 1.0.36", + "syn 2.0.98", ] [[package]] @@ -7505,9 +10287,42 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d8dc32e0095a7eeccebd0e3f09e9509365ecb3fc6ac4d6f5f14a3f6392942d1" dependencies = [ - "proc-macro2 1.0.86", + "proc-macro2 1.0.93", "quote 1.0.36", - "syn 2.0.68", + "syn 2.0.98", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2 1.0.93", + "quote 1.0.36", + "syn 2.0.98", +] + +[[package]] +name = "windows-link" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.0", + "windows-link 0.1.1", ] [[package]] @@ -7516,7 +10331,16 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" dependencies = [ - "windows-targets 0.52.5", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fd11a4fd95df68efcfee5f44a294fe71b8bc6a91993e2791938abcc712252" +dependencies = [ + "windows-link 0.1.1", ] [[package]] @@ -7530,6 +10354,24 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "windows-strings" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319" +dependencies = [ + "windows-link 0.1.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ba9642430ee452d5a7aa78d72907ebe8cfda358e8cb7918a2050581322f97" +dependencies = [ + "windows-link 0.1.1", +] + [[package]] name = "windows-sys" version = "0.45.0" @@ -7554,7 +10396,25 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.5", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", ] [[package]] @@ -7589,18 +10449,35 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.52.5" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f0713a46559409d202e70e28227288446bf7841d3211583a4b53e3f6d96e7eb" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.5", - "windows_aarch64_msvc 0.52.5", - "windows_i686_gnu 0.52.5", - "windows_i686_gnullvm", - "windows_i686_msvc 0.52.5", - "windows_x86_64_gnu 0.52.5", - "windows_x86_64_gnullvm 0.52.5", - "windows_x86_64_msvc 0.52.5", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] [[package]] @@ -7609,7 +10486,7 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6998aa457c9ba8ff2fb9f13e9d2a930dabcea28f1d0ab94d687d8b3654844515" dependencies = [ - "windows-targets 0.52.5", + "windows-targets 0.52.6", ] [[package]] @@ -7635,9 +10512,15 @@ checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" [[package]] name = "windows_aarch64_gnullvm" -version = "0.52.5" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7088eed71e8b8dda258ecc8bac5fb1153c5cffaf2578fc8ff5d61e23578d3263" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" [[package]] name = "windows_aarch64_msvc" @@ -7665,9 +10548,15 @@ checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" [[package]] name = "windows_aarch64_msvc" -version = "0.52.5" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9985fd1504e250c615ca5f281c3f7a6da76213ebd5ccc9561496568a2752afb6" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" [[package]] name = "windows_i686_gnu" @@ -7695,15 +10584,27 @@ checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" [[package]] name = "windows_i686_gnu" -version = "0.52.5" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88ba073cf16d5372720ec942a8ccbf61626074c6d4dd2e745299726ce8b89670" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" [[package]] name = "windows_i686_gnullvm" -version = "0.52.5" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87f4261229030a858f36b459e748ae97545d6f1ec60e5e0d6a3d32e0dc232ee9" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" [[package]] name = "windows_i686_msvc" @@ -7731,9 +10632,15 @@ checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" [[package]] name = "windows_i686_msvc" -version = "0.52.5" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db3c2bf3d13d5b658be73463284eaf12830ac9a26a90c717b7f771dfe97487bf" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" [[package]] name = "windows_x86_64_gnu" @@ -7761,9 +10668,15 @@ checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" [[package]] name = "windows_x86_64_gnu" -version = "0.52.5" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e4246f76bdeff09eb48875a0fd3e2af6aada79d409d33011886d3e1581517d9" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" [[package]] name = "windows_x86_64_gnullvm" @@ -7779,9 +10692,15 @@ checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" [[package]] name = "windows_x86_64_gnullvm" -version = "0.52.5" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "852298e482cd67c356ddd9570386e2862b5673c85bd5f88df9ab6802b334c596" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" [[package]] name = "windows_x86_64_msvc" @@ -7809,9 +10728,67 @@ checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" [[package]] name = "windows_x86_64_msvc" -version = "0.52.5" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winit" +version = "0.30.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a809eacf18c8eca8b6635091543f02a5a06ddf3dad846398795460e6e0ae3cc0" +dependencies = [ + "ahash 0.8.12", + "android-activity", + "atomic-waker", + "bitflags 2.9.1", + "block2 0.5.1", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases 0.2.1", + "concurrent-queue", + "core-foundation 0.9.4", + "core-graphics 0.23.2", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk 0.9.0", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle 0.6.2", + "redox_syscall 0.4.1", + "rustix 0.38.34", + "sctk-adwaita", + "smithay-client-toolkit 0.19.2", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb 0.13.1", + "xkbcommon-dl", +] [[package]] name = "winnow" @@ -7822,6 +10799,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi 0.3.9", +] + [[package]] name = "winreg" version = "0.11.0" @@ -7832,16 +10818,6 @@ dependencies = [ "winapi 0.3.9", ] -[[package]] -name = "winreg" -version = "0.50.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" -dependencies = [ - "cfg-if 1.0.0", - "windows-sys 0.48.0", -] - [[package]] name = "winres" version = "0.1.12" @@ -7851,6 +10827,15 @@ dependencies = [ "toml 0.5.11", ] +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags 2.9.1", +] + [[package]] name = "wl-clipboard-rs" version = "0.9.0" @@ -7862,7 +10847,7 @@ dependencies = [ "os_pipe", "rustix 0.38.34", "tempfile", - "thiserror", + "thiserror 1.0.61", "tree_magic_mini", "wayland-backend", "wayland-client", @@ -7952,7 +10937,11 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d91ffca73ee7f68ce055750bf9f6eca0780b8c85eff9bc046a3b0da41755e12" dependencies = [ + "as-raw-xcb-connection", "gethostname 0.4.3", + "libc", + "libloading 0.8.4", + "once_cell", "rustix 0.38.34", "x11rb-protocol 0.13.1", ] @@ -7972,6 +10961,53 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec107c4503ea0b4a98ef47356329af139c0a4f7750e621cf2973cd3385ebcb3d" +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde 1.0.228", + "zeroize", +] + +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 1.0.61", + "time 0.3.36", +] + +[[package]] +name = "xattr" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e105d177a3871454f754b33bb0ee637ecaaac997446375fd3e5d43a2ed00c909" +dependencies = [ + "libc", + "linux-raw-sys 0.4.14", + "rustix 0.38.34", +] + +[[package]] +name = "xcursor" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" + [[package]] name = "xdg-home" version = "1.2.0" @@ -7982,6 +11018,34 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.9.1", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time 0.3.36", +] + [[package]] name = "zbus" version = "3.15.2" @@ -8010,7 +11074,7 @@ dependencies = [ "once_cell", "ordered-stream", "rand 0.8.5", - "serde 1.0.203", + "serde 1.0.228", "serde_repr", "sha1", "static_assertions", @@ -8030,7 +11094,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7131497b0f887e8061b430c530240063d33bf9455fa34438f388a245da69e0a5" dependencies = [ "proc-macro-crate 1.3.1", - "proc-macro2 1.0.86", + "proc-macro2 1.0.93", "quote 1.0.36", "regex", "syn 1.0.109", @@ -8043,39 +11107,28 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "437d738d3750bed6ca9b8d423ccc7a8eb284f6b1d6d4e225a0e4e6258d864c8d" dependencies = [ - "serde 1.0.203", + "serde 1.0.228", "static_assertions", "zvariant", ] -[[package]] -name = "zerocopy" -version = "0.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "854e949ac82d619ee9a14c66a1b674ac730422372ccb759ce0c39cabcf2bf8e6" -dependencies = [ - "byteorder", - "zerocopy-derive 0.6.6", -] - [[package]] name = "zerocopy" version = "0.7.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae87e3fcd617500e5d106f0380cf7b77f3c6092aae37191433159dda23cfb087" dependencies = [ + "byteorder", "zerocopy-derive 0.7.34", ] [[package]] -name = "zerocopy-derive" -version = "0.6.6" +name = "zerocopy" +version = "0.8.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "125139de3f6b9d625c39e2efdd73d41bdac468ccd556556440e322be0e1bbd91" +checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f" dependencies = [ - "proc-macro2 1.0.86", - "quote 1.0.36", - "syn 2.0.68", + "zerocopy-derive 0.8.26", ] [[package]] @@ -8084,9 +11137,20 @@ version = "0.7.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "15e934569e47891f7d9411f1a451d947a60e000ab3bd24fbb970f000387d1b3b" dependencies = [ - "proc-macro2 1.0.86", + "proc-macro2 1.0.93", "quote 1.0.36", - "syn 2.0.68", + "syn 2.0.98", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" +dependencies = [ + "proc-macro2 1.0.93", + "quote 1.0.36", + "syn 2.0.98", ] [[package]] @@ -8094,6 +11158,20 @@ name = "zeroize" version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +dependencies = [ + "proc-macro2 1.0.93", + "quote 1.0.36", + "syn 2.0.98", +] [[package]] name = "zip" @@ -8180,7 +11258,7 @@ dependencies = [ "byteorder", "enumflags2", "libc", - "serde 1.0.203", + "serde 1.0.228", "static_assertions", "zvariant_derive", ] @@ -8192,7 +11270,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37c24dc0bed72f5f90d1f8bb5b07228cbf63b3c6e9f82d82559d4bae666e7ed9" dependencies = [ "proc-macro-crate 1.3.1", - "proc-macro2 1.0.86", + "proc-macro2 1.0.93", "quote 1.0.36", "syn 1.0.109", "zvariant_utils", @@ -8204,7 +11282,7 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7234f0d811589db492d16893e3f21e8e2fd282e6d01b0cddee310322062cc200" dependencies = [ - "proc-macro2 1.0.86", + "proc-macro2 1.0.93", "quote 1.0.36", "syn 1.0.109", ] diff --git a/Cargo.toml b/Cargo.toml index 1ee749afb..fa22dcd7b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustdesk" -version = "1.3.2" +version = "1.4.6" authors = ["rustdesk "] edition = "2021" build= "build.rs" @@ -16,6 +16,10 @@ crate-type = ["cdylib", "staticlib", "rlib"] name = "naming" path = "src/naming.rs" +[[bin]] +name = "service" +path = "src/service.rs" + [features] inline = [] cli = [] @@ -36,12 +40,12 @@ unix-file-copy-paste = [ "dep:once_cell", "clipboard/unix-file-copy-paste", ] +screencapturekit = ["cpal/screencapturekit"] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] async-trait = "0.1" -whoami = "1.5.0" scrap = { path = "libs/scrap", features = ["wayland"] } hbb_common = { path = "libs/hbb_common" } serde_derive = "1.0" @@ -72,26 +76,30 @@ crossbeam-queue = "0.3" hex = "0.4" chrono = "0.4" cidr-utils = "0.5" -libloading = "0.8" fon = "0.6" zip = "0.6" shutdown_hooks = "0.1" totp-rs = { version = "5.4", default-features = false, features = ["gen_secret", "otpauth"] } +stunclient = "0.4" +kcp-sys= { git = "https://github.com/rustdesk-org/kcp-sys"} +reqwest = { version = "0.12", features = ["blocking", "socks", "json", "native-tls", "rustls-tls", "rustls-tls-native-roots", "gzip"], default-features=false } -[target.'cfg(not(any(target_os = "android", target_os = "linux")))'.dependencies] -cpal = "0.15" +[target.'cfg(not(target_os = "linux"))'.dependencies] +# https://github.com/rustdesk/rustdesk/discussions/10197, not use cpal on linux +cpal = { git = "https://github.com/rustdesk-org/cpal", branch = "osx-screencapturekit" } ringbuf = "0.3" [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] mac_address = "1.1" -sciter-rs = { git = "https://github.com/open-trade/rust-sciter", branch = "dyn" } +sciter-rs = { git = "https://github.com/rustdesk-org/rust-sciter", branch = "dyn" } sys-locale = "0.3" enigo = { path = "libs/enigo", features = [ "with_serde" ] } clipboard = { path = "libs/clipboard" } ctrlc = "3.2" -# arboard = { version = "3.4.0", features = ["wayland-data-control"] } +# arboard = { version = "3.4", features = ["wayland-data-control"] } arboard = { git = "https://github.com/rustdesk-org/arboard", features = ["wayland-data-control"] } clipboard-master = { git = "https://github.com/rustdesk-org/clipboard-master" } +portable-pty = { git = "https://github.com/rustdesk-org/wezterm", branch = "rustdesk/pty_based_0.8.1", package = "portable-pty" } system_shutdown = "4.0" qrcode-generator = "4.1" @@ -110,13 +118,31 @@ winapi = { version = "0.3", features = [ "cguid", "cfgmgr32", "ioapiset", + "winspool", +] } +windows = { version = "0.61", features = [ + "Win32", + "Win32_Foundation", + "Win32_Security", + "Win32_Security_Authorization", + "Win32_Storage_FileSystem", + "Win32_System", + "Win32_System_Diagnostics", + "Win32_System_Diagnostics_ToolHelp", + "Win32_System_Environment", + "Win32_System_IO", + "Win32_System_Memory", + "Win32_System_Pipes", + "Win32_System_Threading", + "Win32_UI_Shell", ] } winreg = "0.11" windows-service = "0.6" virtual_display = { path = "libs/virtual_display" } +remote_printer = { path = "libs/remote_printer" } impersonate_system = { git = "https://github.com/rustdesk-org/impersonate-system" } shared_memory = "0.12" -tauri-winrt-notification = "0.1.2" +tauri-winrt-notification = "0.1" runas = "1.2" [target.'cfg(target_os = "macos")'.dependencies] @@ -128,9 +154,13 @@ core-graphics = "0.22" include_dir = "0.7" fruitbasket = "0.10" objc_id = "0.1" +# If we use piet "0.7" here, we must also update core-graphics to "0.24". +piet = "0.6" +piet-coregraphics = "0.6" +foreign-types = "0.3" [target.'cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))'.dependencies] -tray-icon = { git = "https://github.com/tauri-apps/tray-icon" } +tray-icon = { git = "https://github.com/tauri-apps/tray-icon", version = "0.21.3" } tao = { git = "https://github.com/rustdesk-org/tao", branch = "dev" } image = "0.24" @@ -139,24 +169,22 @@ keepawake = { git = "https://github.com/rustdesk-org/keepawake-rs" } [target.'cfg(any(target_os = "windows", target_os = "linux"))'.dependencies] wallpaper = { git = "https://github.com/rustdesk-org/wallpaper.rs" } - -[target.'cfg(any(target_os = "macos", target_os = "windows"))'.dependencies] -# https://github.com/rustdesk/rustdesk-server-pro/issues/189, using native-tls for better tls support -reqwest = { git = "https://github.com/rustdesk-org/reqwest", features = ["blocking", "socks", "json", "native-tls", "gzip"], default-features=false } - -[target.'cfg(not(any(target_os = "macos", target_os = "windows")))'.dependencies] -reqwest = { git = "https://github.com/rustdesk-org/reqwest", features = ["blocking", "socks", "json", "rustls-tls", "rustls-tls-native-roots", "gzip"], default-features=false } +tiny-skia = "0.11" +softbuffer = "0.4" +fontdb = "0.23" +bytemuck = "1.23" +ttf-parser = "0.25" [target.'cfg(target_os = "linux")'.dependencies] +libxdo-sys = "0.11" psimple = { package = "libpulse-simple-binding", version = "2.27" } pulse = { package = "libpulse-binding", version = "2.27" } -rust-pulsectl = { git = "https://github.com/open-trade/pulsectl" } +rust-pulsectl = { git = "https://github.com/rustdesk-org/pulsectl" } 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" } -users = { version = "0.11" } 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} @@ -164,6 +192,11 @@ once_cell = {version = "1.18", optional = true} nix = { version = "0.29", features = ["term", "process"]} gtk = "0.18" termios = "0.3" +terminfo = "0.8" +winit = "0.30" + +[target.'cfg(any(target_os = "linux", target_os = "android"))'.dependencies] +openssl = { version = "0.10", features = ["vendored"] } [target.'cfg(target_os = "android")'.dependencies] android_logger = "0.13" @@ -171,11 +204,16 @@ jni = "0.21" 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"] +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"] +# 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) +[patch.crates-io] +libxdo-sys = { path = "libs/libxdo-sys-stub" } + [package.metadata.winres] -LegalCopyright = "Copyright © 2024 Purslane Ltd. All rights reserved." +LegalCopyright = "Copyright © 2025 Purslane Ltd. All rights reserved." ProductName = "RustDesk" FileDescription = "RustDesk Remote Desktop" OriginalFilename = "rustdesk.exe" @@ -191,6 +229,7 @@ os-version = "0.2" [dev-dependencies] hound = "3.5" +docopt = "1.1" [package.metadata.bundle] name = "RustDesk" @@ -208,5 +247,4 @@ strip = true rpath = true [profile.dev] -split-debuginfo = '...' # Platform-specific. -#strip = "debuginfo" +debug = 1 diff --git a/Dockerfile b/Dockerfile index 8544219c2..f0e4e4a4a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,6 +2,7 @@ FROM debian:bullseye-slim WORKDIR / ARG DEBIAN_FRONTEND=noninteractive +ENV VCPKG_FORCE_SYSTEM_BINARIES=1 RUN apt update -y && \ apt install --yes --no-install-recommends \ g++ \ @@ -21,7 +22,8 @@ RUN apt update -y && \ libpam0g-dev \ libpulse-dev \ make \ - cmake \ + wget \ + libssl-dev \ unzip \ zip \ sudo \ @@ -31,6 +33,13 @@ RUN apt update -y && \ ninja-build && \ rm -rf /var/lib/apt/lists/* +RUN wget https://github.com/Kitware/CMake/releases/download/v3.30.6/cmake-3.30.6.tar.gz --no-check-certificate && \ + tar xzf cmake-3.30.6.tar.gz && \ + cd cmake-3.30.6 && \ + ./configure --prefix=/usr/local && \ + make && \ + make install + RUN git clone --branch 2023.04.15 --depth=1 https://github.com/microsoft/vcpkg && \ /vcpkg/bootstrap-vcpkg.sh -disableMetrics && \ /vcpkg/vcpkg --disable-metrics install libvpx libyuv opus aom diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 000000000..c31706425 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1 @@ +AGENTS.md diff --git a/README.md b/README.md index c193967d0..ae5c8d37c 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,23 @@

RustDesk - Your remote desktop
- ServersBuildDockerStructureSnapshot
- [Українська] | [česky] | [中文] | [Magyar] | [Español] | [فارسی] | [Français] | [Deutsch] | [Polski] | [Indonesian] | [Suomi] | [മലയാളം] | [日本語] | [Nederlands] | [Italiano] | [Русский] | [Português (Brasil)] | [Esperanto] | [한국어] | [العربي] | [Tiếng Việt] | [Dansk] | [Ελληνικά] | [Türkçe]
+ [Українська] | [č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

-Chat with us: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) +> [!Caution] +> **Misuse Disclaimer:**
+> The developers of RustDesk do not condone or support any unethical or illegal use of this software. Misuse, such as unauthorized access, control or invasion of privacy, is strictly against our guidelines. The authors are not responsible for any misuse of the application. -[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/I2I04VU09) -Yet another remote desktop software, written in Rust. Works out of the box, no configuration required. You have full control of your data, with no concerns about security. You can use our rendezvous/relay server, [set up your own](https://rustdesk.com/server), or [write your own rendezvous/relay server](https://github.com/rustdesk/rustdesk-server-demo). +Chat with us: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk) + +[![RustDesk Server Pro](https://img.shields.io/badge/RustDesk%20Server%20Pro-Advanced%20Features-blue)](https://rustdesk.com/pricing.html) + +Yet another remote desktop solution, written in Rust. Works out of the box with no configuration required. You have full control of your data, with no concerns about security. You can use our rendezvous/relay server, [set up your own](https://rustdesk.com/server), or [write your own rendezvous/relay server](https://github.com/rustdesk/rustdesk-server-demo). ![image](https://user-images.githubusercontent.com/71636191/171661982-430285f0-2e12-4b1d-9957-4a58e375304d.png) @@ -25,9 +29,12 @@ RustDesk welcomes contribution from everyone. See [CONTRIBUTING.md](docs/CONTRIB [**NIGHTLY BUILD**](https://github.com/rustdesk/rustdesk/releases/tag/nightly) -[Get it on F-Droid](https://f-droid.org/en/packages/com.carriez.flutter_hbb) +[Get it on Flathub](https://flathub.org/apps/com.rustdesk.RustDesk) ## Dependencies @@ -39,7 +46,7 @@ Please download Sciter dynamic library yourself. [Linux](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so) | [macOS](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.osx/libsciter.dylib) -## Raw steps to build +## Raw Steps to build - Prepare your Rust development env and C++ build env @@ -52,7 +59,7 @@ Please download Sciter dynamic library yourself. ## [Build](https://rustdesk.com/docs/en/dev/build/) -## How to build on Linux +## How to Build on Linux ### Ubuntu 18 (Debian 10) @@ -110,7 +117,7 @@ cd ```sh curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh source $HOME/.cargo/env -git clone https://github.com/rustdesk/rustdesk +git clone --recurse-submodules https://github.com/rustdesk/rustdesk cd rustdesk mkdir -p target/debug wget https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so @@ -125,6 +132,7 @@ Begin by cloning the repository and building the Docker container: ```sh git clone https://github.com/rustdesk/rustdesk cd rustdesk +git submodule update --init --recursive docker build -t "rustdesk-builder" . ``` @@ -146,7 +154,7 @@ Or, if you're running a release executable: target/release/rustdesk ``` -Please ensure that you are running these commands from the root of the RustDesk repository, otherwise the application might not be able to find the required resources. Also note that other cargo subcommands such as `install` or `run` are not currently supported via this method as they would install or run the program inside the container instead of the host. +Please ensure that you run these commands from the root of the RustDesk repository, or the application may not find the required resources. Also note that other cargo subcommands such as `install` or `run` are not currently supported via this method as they would install or run the program inside the container instead of the host. ## File Structure @@ -160,7 +168,7 @@ Please ensure that you are running these commands from the root of the 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 desktop and mobile -- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: JavaScript for Flutter web client +- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/v1/js)**: JavaScript for Flutter web client ## Screenshots @@ -172,6 +180,3 @@ Please ensure that you are running these commands from the root of the RustDesk ![TCP Tunneling](https://github.com/rustdesk/rustdesk/assets/28412477/78e8708f-e87e-4570-8373-1360033ea6c5) -## [Public Servers](#public-servers) - -RustDesk is supported by a free EU server, graciously provided by [Codext GmbH](https://codext.link/rustdesk?utm_source=github) diff --git a/appimage/AppImageBuilder-aarch64.yml b/appimage/AppImageBuilder-aarch64.yml index 5ff9fc2a7..64d6c2cfa 100644 --- a/appimage/AppImageBuilder-aarch64.yml +++ b/appimage/AppImageBuilder-aarch64.yml @@ -18,8 +18,8 @@ AppDir: id: rustdesk name: rustdesk icon: rustdesk - version: 1.3.2 - exec: usr/lib/rustdesk/rustdesk + version: 1.4.6 + exec: usr/share/rustdesk/rustdesk exec_args: $@ apt: arch: @@ -47,9 +47,9 @@ AppDir: - libasound2 - libsystemd0 - curl + - libva2 - libva-drm2 - libva-x11-2 - - libvdpau1 - libgstreamer-plugins-base1.0-0 - gstreamer1.0-pipewire - libwayland-client0 @@ -77,7 +77,7 @@ 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 - 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/lib/rustdesk/lib:$APPDIR/lib/aarch64 + 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 test: @@ -99,3 +99,4 @@ AppDir: AppImage: arch: aarch64 update-information: guess + comp: gzip diff --git a/appimage/AppImageBuilder-x86_64.yml b/appimage/AppImageBuilder-x86_64.yml index d8f0991cf..933673cef 100644 --- a/appimage/AppImageBuilder-x86_64.yml +++ b/appimage/AppImageBuilder-x86_64.yml @@ -18,8 +18,8 @@ AppDir: id: rustdesk name: rustdesk icon: rustdesk - version: 1.3.2 - exec: usr/lib/rustdesk/rustdesk + version: 1.4.6 + exec: usr/share/rustdesk/rustdesk exec_args: $@ apt: arch: @@ -50,9 +50,9 @@ AppDir: - libasound2 - libsystemd0 - curl + - libva2 - libva-drm2 - libva-x11-2 - - libvdpau1 - libgstreamer-plugins-base1.0-0 - gstreamer1.0-pipewire - libwayland-client0 @@ -80,7 +80,7 @@ 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 - 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/lib/rustdesk/lib:$APPDIR/lib/x86_64 + 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 test: @@ -102,3 +102,4 @@ AppDir: AppImage: arch: x86_64 update-information: guess + comp: gzip diff --git a/build.py b/build.py index b7ea0a1ef..5c53e4fc8 100755 --- a/build.py +++ b/build.py @@ -9,6 +9,7 @@ import shutil import hashlib import argparse import sys +from pathlib import Path windows = platform.platform().startswith('Windows') osx = platform.platform().startswith( @@ -111,7 +112,7 @@ def make_parser(): '--hwcodec', action='store_true', help='Enable feature hwcodec' + ( - '' if windows or osx else ', need libva-dev, libvdpau-dev.') + '' if windows or osx else ', need libva-dev.') ) parser.add_argument( '--vram', @@ -143,6 +144,12 @@ def make_parser(): "--package", type=str ) + if osx: + parser.add_argument( + '--screencapturekit', + action='store_true', + help='Enable feature screencapturekit' + ) return parser @@ -274,6 +281,9 @@ def get_features(args): features.append('flutter') if args.unix_file_copy_paste: features.append('unix-file-copy-paste') + if osx: + if args.screencapturekit: + features.append('screencapturekit') print("features:", features) return features @@ -289,7 +299,7 @@ Version: %s Architecture: %s Maintainer: rustdesk Homepage: https://rustdesk.com -Depends: libgtk-3-0, libxcb-randr0, libxdo3, libxfixes3, libxcb-shape0, libxcb-xfixes0, libasound2, libsystemd0, curl, libva-drm2, libva-x11-2, libvdpau1, libgstreamer-plugins-base1.0-0, libpam0g, gstreamer1.0-pipewire%s +Depends: libgtk-3-0, libxcb-randr0, libxdo3 | libxdo4, libxfixes3, libxcb-shape0, libxcb-xfixes0, libasound2, libsystemd0, curl, libva2, libva-drm2, libva-x11-2, libgstreamer-plugins-base1.0-0, libpam0g, gstreamer1.0-pipewire%s Recommends: libayatana-appindicator3-1 Description: A remote control software. @@ -312,7 +322,7 @@ def build_flutter_deb(version, features): os.chdir('flutter') system2('flutter build linux --release') system2('mkdir -p tmpdeb/usr/bin/') - system2('mkdir -p tmpdeb/usr/lib/rustdesk') + 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/') @@ -322,7 +332,7 @@ def build_flutter_deb(version, features): system2('mkdir -p tmpdeb/usr/share/polkit-1/actions') system2('rm tmpdeb/usr/bin/rustdesk || true') system2( - f'cp -r {flutter_build_dir}/* tmpdeb/usr/lib/rustdesk/') + f'cp -r {flutter_build_dir}/* tmpdeb/usr/share/rustdesk/') system2( 'cp ../res/rustdesk.service tmpdeb/usr/share/rustdesk/files/systemd/') system2( @@ -345,7 +355,7 @@ def build_flutter_deb(version, features): system2('mkdir -p tmpdeb/DEBIAN') generate_control_file(version) system2('cp -a ../res/DEBIAN/* tmpdeb/DEBIAN/') - md5_file('usr/share/rustdesk/files/systemd/rustdesk.service') + md5_file_folder("tmpdeb/") system2('dpkg-deb -b tmpdeb rustdesk.deb;') system2('/bin/rm -rf tmpdeb/') @@ -357,7 +367,7 @@ def build_flutter_deb(version, features): def build_deb_from_folder(version, binary_folder): os.chdir('flutter') system2('mkdir -p tmpdeb/usr/bin/') - system2('mkdir -p tmpdeb/usr/lib/rustdesk') + system2('mkdir -p tmpdeb/usr/share/rustdesk') 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/') @@ -365,7 +375,7 @@ def build_deb_from_folder(version, binary_folder): system2('mkdir -p tmpdeb/usr/share/polkit-1/actions') system2('rm tmpdeb/usr/bin/rustdesk || true') system2( - f'cp -r ../{binary_folder}/* tmpdeb/usr/lib/rustdesk/') + f'cp -r ../{binary_folder}/* tmpdeb/usr/share/rustdesk/') system2( 'cp ../res/rustdesk.service tmpdeb/usr/share/rustdesk/files/systemd/') system2( @@ -382,7 +392,7 @@ def build_deb_from_folder(version, binary_folder): system2('mkdir -p tmpdeb/DEBIAN') generate_control_file(version) system2('cp -a ../res/DEBIAN/* tmpdeb/DEBIAN/') - md5_file('usr/share/rustdesk/files/systemd/rustdesk.service') + md5_file_folder("tmpdeb/") system2('dpkg-deb -b tmpdeb rustdesk.deb;') system2('/bin/rm -rf tmpdeb/') @@ -395,12 +405,13 @@ def build_flutter_dmg(version, features): if not skip_cargo: # set minimum osx build target, now is 10.14, which is the same as the flutter xcode project system2( - f'MACOSX_DEPLOYMENT_TARGET=10.14 cargo build --features {features} --lib --release') + f'MACOSX_DEPLOYMENT_TARGET=10.14 cargo build --features {features} --release') # copy dylib system2( "cp target/release/liblibrustdesk.dylib target/release/librustdesk.dylib") os.chdir('flutter') system2('flutter build macos --release') + system2('cp -rf ../target/release/service ./build/macos/Build/Products/Release/RustDesk.app/Contents/MacOS/') ''' system2( "create-dmg --volname \"RustDesk Installer\" --window-pos 200 120 --window-size 800 400 --icon-size 100 --app-drop-link 600 185 --icon RustDesk.app 200 190 --hide-extension RustDesk.app rustdesk.dmg ./build/macos/Build/Products/Release/RustDesk.app") @@ -501,7 +512,7 @@ def main(): system2('pip3 install -r requirements.txt') system2( f'python3 ./generate.py -f ../../{res_dir} -o . -e ../../{res_dir}/rustdesk-{version}-win7-install.exe') - system2('mv ../../{res_dir}/rustdesk-{version}-win7-install.exe ../..') + system2(f'mv ../../{res_dir}/rustdesk-{version}-win7-install.exe ../..') elif os.path.isfile('/usr/bin/pacman'): # pacman -S -needed base-devel system2("sed -i 's/pkgver=.*/pkgver=%s/g' res/PKGBUILD" % version) @@ -612,21 +623,24 @@ def main(): 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/lib/rustdesk') - system2('mv tmpdeb/usr/bin/rustdesk tmpdeb/usr/lib/rustdesk/') - system2('cp libsciter-gtk.so tmpdeb/usr/lib/rustdesk/') - md5_file('usr/share/rustdesk/files/systemd/rustdesk.service') - md5_file('etc/rustdesk/startwm.sh') - md5_file('etc/X11/rustdesk/xorg.conf') - md5_file('etc/pam.d/rustdesk') - md5_file('usr/lib/rustdesk/libsciter-gtk.so') + system2('mkdir -p tmpdeb/usr/share/rustdesk') + system2('mv tmpdeb/usr/bin/rustdesk tmpdeb/usr/share/rustdesk/') + system2('cp libsciter-gtk.so tmpdeb/usr/share/rustdesk/') + md5_file_folder("tmpdeb/") system2('dpkg-deb -b tmpdeb rustdesk.deb; /bin/rm -rf tmpdeb/') os.rename('rustdesk.deb', 'rustdesk-%s.deb' % version) def md5_file(fn): md5 = hashlib.md5(open('tmpdeb/' + fn, 'rb').read()).hexdigest() - system2('echo "%s %s" >> tmpdeb/DEBIAN/md5sums' % (md5, fn)) + system2('echo "%s /%s" >> tmpdeb/DEBIAN/md5sums' % (md5, fn)) + +def md5_file_folder(base_dir): + base_path = Path(base_dir) + for file in base_path.rglob('*'): + if file.is_file() and 'DEBIAN' not in file.parts: + relative_path = file.relative_to(base_path) + md5_file(str(relative_path)) if __name__ == "__main__": diff --git a/build.rs b/build.rs index d332a43a2..92fb1f4b4 100644 --- a/build.rs +++ b/build.rs @@ -1,7 +1,7 @@ #[cfg(windows)] fn build_windows() { let file = "src/platform/windows.cc"; - let file2 = "src/platform/windows_delete_test_cert.cc"; + let file2 = "src/platform/windows_delete_test_cert.cc"; cc::Build::new().file(file).file(file2).compile("windows"); println!("cargo:rustc-link-lib=WtsApi32"); println!("cargo:rerun-if-changed={}", file); @@ -18,7 +18,7 @@ fn build_mac() { b.flag("-DNO_InputMonitoringAuthStatus=1"); } } - b.file(file).compile("macos"); + b.flag("-std=c++17").file(file).compile("macos"); println!("cargo:rerun-if-changed={}", file); } @@ -61,18 +61,18 @@ fn install_android_deps() { let target = format!("{}-android", target_arch); let vcpkg_root = std::env::var("VCPKG_ROOT").unwrap(); let mut path: std::path::PathBuf = vcpkg_root.into(); - path.push("installed"); + if let Ok(vcpkg_root) = std::env::var("VCPKG_INSTALLED_ROOT") { + path = vcpkg_root.into(); + } else { + path.push("installed"); + } path.push(target); println!( - "{}", - format!( - "cargo:rustc-link-search={}", - path.join("lib").to_str().unwrap() - ) + "cargo:rustc-link-search={}", + path.join("lib").to_str().unwrap() ); println!("cargo:rustc-link-lib=ndk_compat"); println!("cargo:rustc-link-lib=oboe"); - println!("cargo:rustc-link-lib=oboe_wrapper"); println!("cargo:rustc-link-lib=c++"); println!("cargo:rustc-link-lib=OpenSLES"); } diff --git a/docs/CODE_OF_CONDUCT-DE.md b/docs/CODE_OF_CONDUCT-DE.md new file mode 100644 index 000000000..ea4254552 --- /dev/null +++ b/docs/CODE_OF_CONDUCT-DE.md @@ -0,0 +1,137 @@ + +# Verhaltenskodex (Code of Conduct) für Mitwirkende + +## Unsere Verpflichtung + +Wir als Mitglieder, Mitwirkende und Führungskräfte verpflichten uns, +die Teilnahme unserer Community zu einer Erfahrung zu machen, +die für alle frei von Belästigungen ist, unabhängig von Alter, Körpergröße, +sichtbarer oder unsichtbarer Behinderung, ethnischer Zugehörigkeit, +Geschlechtsmerkmalen, Geschlechtsidentität und -ausdruck, Erfahrungsniveau, +Bildung, sozioökonomischem Status, Nationalität, persönlichem Erscheinungsbild, +Rasse, Religion oder sexueller Identität und Orientierung. + +Wir verpflichten uns, so zu handeln und zu interagieren, dass wir zu einer offenen, +einladenden, vielfältigen, integrativen und lebendigen Gemeinschaft beitragen. + +## Unsere Standards + +Beispiele für Verhaltensweisen, die zu einem positiven Umfeld für unsere +Gemeinschaft beitragen, sind: + +* Empathie und Freundlichkeit gegenüber anderen Menschen zu zeigen +* Respektvoll gegenüber anderen Meinungen, Sichtweisen und Erfahrungen zu sein +* Das Vergeben von sowie das großzügige Empfangen von konstruktivem Feedback +* Verantwortung übernehmen, sich bei den Betroffenen entschuldigen + und aus den Erfahrungen lernen +* Nicht darauf zu achten, was das Beste für sich selbst, + sondern zu Achten, was das Beste für die gesamte Community ist + +Beispiele für nicht akzeptables Verhalten sind: + +* Die Verwendung sexualisierter bzw. anstößiger Sprache oder Bilder + sowie sexuelle Aufmerksamkeit oder Annäherungsversuche jeglicher Art +* Trolling, beleidigende oder herabwürdigende Kommentare + sowie persönliche oder politische Angriffe +* Öffentliche sowie private Belästigung +* Das Teilen privater Informationen anderer Leute ohne deren explizite Zustimmung, + wie bspw. die physische oder die E-Mail-Adresse +* Anderes Verhalten, das in einem professionellen Umfeld begründeter Weise als + unangemessen angesehen werden könnte + +## Durchsetzungsbefugnisse + +Die Leiter der Community sind dafür verantwortlich, unsere Standards für +akzeptables Verhalten zu klären und durchzusetzen und werden angemessene +und faire Korrekturmaßnahmen ergreifen, wenn sie ein Verhalten als unangemessen, +bedrohlich, beleidigend oder schädlich erachten. + +Die Leiter der Community haben das Recht und die Pflicht, Kommentare, Commits, +Code, Wiki-Bearbeitungen, Issues und andere Beiträge, die nicht mit dem +Verhaltenskodex vereinbar sind, zu entfernen, zu bearbeiten oder abzulehnen. +Sie werden, falls angebracht, die Gründe für Moderationsentscheidungen mitteilen. + +## Geltungsbereich + +Dieser Verhaltenskodex gilt in allen Community-Bereichen und auch dann, wenn +eine Person die Community offiziell in öffentlichen Bereichen vertritt. +Beispiele für die Vertretung unserer Community sind die Verwendung einer +offiziellen E-Mail-Adresse, das Posten über einen offiziellen +Social-Media-Account oder die Tätigkeit als ernannter +Vertreter bei einer Online- oder Präsenzveranstaltung. + +## Geltendmachung + +Fälle von missbräuchlichem, belästigendem oder anderweitig inakzeptablem Verhalten können +den für die Durchsetzung zuständigen Community-Leitern +unter [info@rustdesk.com](mailto:info@rustdesk.com) gemeldet werden. +Jeder Fall wird umgehend und fair geprüft und untersucht. + +## Richtlinien zur Geltendmachung + +Die Community-Leiter werden die folgenden Community-Auswirkungsrichtlinien befolgen, +um die Konsequenzen für jede Handlung zu bestimmen, die sie als Verstoß gegen diesen +Verhaltenskodex ansehen: + +### 1. Korrektur + +**Auswirkungen auf die Community**: Verwendung unangemessener Sprache oder anderes +Verhalten, welches als unprofessionell oder in der Community unerwünscht angesehen wird. + +**Konsequenz**: Eine private, schriftliche Verwarnung durch die Leiter der Community, +in der die Art des Verstoßes klar dargelegt und erklärt wird, warum das +Verhalten unangemessen war. Eine öffentliche Entschuldigung kann verlangt werden. + +### 2. Warnung + +**Auswirkungen auf die Community**: Ein Verstoß durch einen einzelnen Vorfall +oder eine Reihe von Handlungen. + +**Konsequenz**: Eine Verwarnung mit Konsequenzen für das weitere Verhalten. Keine +Interaktion mit den beteiligten Personen, einschließlich unaufgeforderter Interaktion mit +denjenigen, die den Verhaltenskodex durchsetzen, für einen bestimmten Zeitraum. Dies +schließt die Vermeidung von Interaktionen in Gemeinschaftsräumen sowie externen Kanälen +wie sozialen Medien ein. Ein Verstoß gegen diese Bedingungen kann zu einer vorübergehenden oder +dauerhaften Sperrung führen. + +### 3. Temporärer Sperrung + + +**Auswirkungen auf die Community**: Ein schwerwiegender Verstoß gegen die Community-Standards, +einschließlich anhaltend unangemessenem Verhalten. + +**Konsequenz**: Eine vorübergehende Sperrung jeglicher Art von Interaktion oder öffentlicher +Kommunikation mit der Community für einen bestimmten Zeitraum. Während dieses Zeitraums sind +keine öffentlichen oder privaten Interaktionen mit den betroffenen Personen, +einschließlich unaufgeforderter Interaktionen mit denjenigen, +die den Verhaltenskodex durchsetzen, erlaubt. +Ein Verstoß gegen diese Bedingungen kann zu einer dauerhaften Sperrung führen. + +### 4. Dauerhafte Sperrung + +**Auswirkungen auf die Community**: Wiederholte Verstöße gegen die Community-Standards, +einschließlich anhaltend unangemessenem Verhalten, Belästigung einer +Person oder Aggression gegenüber oder Herabwürdigung von Personengruppen. + +**Konsequenz**: Ein dauerhafter Ausschluss von jeglicher öffentlicher +Interaktion innerhalb der Community. + +## Quellenangabe + +Dieser Verhaltenskodex ist eine Adaption des [Contributor Covenant][homepage], +Version 2.0, verfügbar unter +[https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0]. + +Die Richtlinien zu den Auswirkungen auf die Gemeinschaft wurden inspiriert von +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +Für Antworten auf häufig gestellte Fragen zu diesem Verhaltenskodex siehe die +häufig gestellten Fragen (FAQ) unter +[https://www.contributor-covenant.org/faq][FAQ]. Übersetzungen sind verfügbar +unter [https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/docs/CODE_OF_CONDUCT-FR.md b/docs/CODE_OF_CONDUCT-FR.md new file mode 100644 index 000000000..dca61e0aa --- /dev/null +++ b/docs/CODE_OF_CONDUCT-FR.md @@ -0,0 +1,143 @@ + +# Code de conduite des contributeurs + +## Notre engagement + +En tant que membres, contributeurs et responsables, nous nous engageons à faire +de la participation à notre communauté une expérience exempte de harcèlement pour +tous, indépendamment de l'âge, de la taille corporelle, du handicap visible ou +invisible, de l'origine ethnique, des caractéristiques sexuelles, de l'identité +et de l'expression de genre, du niveau d'expérience, de l'éducation, du statut +socio-économique, de la nationalité, de l'apparence personnelle, de la race, de +la religion ou de l'identité et de l'orientation sexuelle. + +Nous nous engageons à agir et à interagir de manière à contribuer à une +communauté ouverte, accueillante, diversifiée, inclusive et saine. + +## Nos standards + +Exemples de comportements qui contribuent à un environnement positif pour notre +communauté : + +* Faire preuve d'empathie et de bienveillance envers les autres +* Respecter les opinions, les points de vue et les expériences différents +* Donner et accepter gracieusement les retours constructifs +* Assumer ses responsabilités, s'excuser auprès des personnes affectées par nos + erreurs et apprendre de l'expérience +* Se concentrer sur ce qui est le mieux non seulement pour nous en tant + qu'individus, mais pour l'ensemble de la communauté + +Exemples de comportements inacceptables : + +* L'utilisation de langage ou d'images à caractère sexuel, et les attentions ou + avances sexuelles de quelque nature que ce soit +* Le trolling, les commentaires insultants ou désobligeants, et les attaques + personnelles ou politiques +* Le harcèlement public ou privé +* La publication d'informations privées d'autrui, telles qu'une adresse physique + ou électronique, sans autorisation explicite +* Tout autre comportement qui pourrait raisonnablement être considéré comme + inapproprié dans un cadre professionnel + +## Responsabilités en matière d'application + +Les responsables de la communauté sont chargés de clarifier et d'appliquer nos +standards de comportement acceptable et prendront des mesures correctives +appropriées et équitables en réponse à tout comportement qu'ils jugent +inapproprié, menaçant, offensant ou nuisible. + +Les responsables de la communauté ont le droit et la responsabilité de +supprimer, modifier ou rejeter les commentaires, commits, code, modifications +du wiki, issues et autres contributions qui ne sont pas conformes à ce Code de +conduite, et communiqueront les raisons de leurs décisions de modération le cas +échéant. + +## Portée + +Ce Code de conduite s'applique dans tous les espaces communautaires, et +s'applique également lorsqu'une personne représente officiellement la communauté +dans les espaces publics. Les exemples de représentation de notre communauté +incluent l'utilisation d'une adresse e-mail officielle, la publication via un +compte de réseau social officiel, ou le fait d'agir en tant que représentant +désigné lors d'un événement en ligne ou hors ligne. + +## Application + +Les cas de comportements abusifs, harcelants ou autrement inacceptables peuvent +être signalés aux responsables de la communauté chargés de l'application à +[info@rustdesk.com](mailto:info@rustdesk.com). +Toutes les plaintes seront examinées et feront l'objet d'une enquête rapide et +équitable. + +Tous les responsables de la communauté sont tenus de respecter la vie privée et +la sécurité de la personne ayant signalé un incident. + +## Directives d'application + +Les responsables de la communauté suivront ces Directives d'impact communautaire +pour déterminer les conséquences de toute action qu'ils jugent en violation de ce +Code de conduite : + +### 1. Correction + +**Impact communautaire** : Utilisation d'un langage inapproprié ou autre +comportement jugé non professionnel ou indésirable dans la communauté. + +**Conséquence** : Un avertissement écrit et privé de la part des responsables de +la communauté, expliquant la nature de la violation et pourquoi le comportement +était inapproprié. Des excuses publiques peuvent être demandées. + +### 2. Avertissement + +**Impact communautaire** : Une violation par un incident isolé ou une série +d'actions. + +**Conséquence** : Un avertissement avec des conséquences en cas de comportement +répété. Aucune interaction avec les personnes impliquées, y compris les +interactions non sollicitées avec les personnes chargées d'appliquer le Code de +conduite, pendant une période déterminée. Cela inclut d'éviter les interactions +dans les espaces communautaires ainsi que dans les canaux externes comme les +réseaux sociaux. Le non-respect de ces conditions peut entraîner une exclusion +temporaire ou permanente. + +### 3. Exclusion temporaire + +**Impact communautaire** : Une violation grave des standards communautaires, y +compris un comportement inapproprié persistant. + +**Conséquence** : Une exclusion temporaire de toute interaction ou communication +publique avec la communauté pendant une période déterminée. Aucune interaction +publique ou privée avec les personnes impliquées, y compris les interactions non +sollicitées avec les personnes chargées d'appliquer le Code de conduite, n'est +autorisée pendant cette période. Le non-respect de ces conditions peut entraîner +une exclusion permanente. + +### 4. Exclusion permanente + +**Impact communautaire** : Démontrer un schéma de violation des standards +communautaires, y compris un comportement inapproprié persistant, le harcèlement +d'une personne, ou une agression envers des catégories de personnes ou leur +dénigrement. + +**Conséquence** : Une exclusion permanente de toute interaction publique au sein +de la communauté. + +## Attribution + +Ce Code de conduite est adapté du [Contributor Covenant][homepage], version 2.0, +disponible à l'adresse +[https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0]. + +Les Directives d'impact communautaire ont été inspirées par +[l'échelle d'application du code de conduite de Mozilla][Mozilla CoC]. + +Pour des réponses aux questions fréquentes sur ce code de conduite, consultez la +FAQ à l'adresse [https://www.contributor-covenant.org/faq][FAQ]. Des traductions +sont disponibles à l'adresse +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/docs/CODE_OF_CONDUCT-KR.md b/docs/CODE_OF_CONDUCT-KR.md new file mode 100644 index 000000000..40fea02eb --- /dev/null +++ b/docs/CODE_OF_CONDUCT-KR.md @@ -0,0 +1,133 @@ + +# 기여자 계약 행동 강령 + +## 우리의 서약 + +회원, 기여자, 리더로서 우리는 나이, 신체 크기, 눈에 +보이거나 보이지 않는 장애, 민족, 성 특성, 성 정체성 및 +표현, 경험 수준, 교육, 사회 경제적 지위, 국적, 외모, +인종, 종교, 성적 정체성 및 지향에 관계없이 모든 사람이 +괴롭힘 없이 커뮤니티에 참여할 수 있도록 할 것을 +서약합니다. + +우리는 개방적이고 환영하며 다양하고 포용적이며 건강한 커뮤니티에 +기여하는 방식으로 행동하고 교류할 것을 약속합니다. + +## 우리의 표준 + +커뮤니티의 긍정적인 환경에 기여하는 행동의 예는 +다음과 같습니다: + +* 다른 사람들에게 공감과 친절을 보여주기 +* 다양한 의견, 관점, 경험을 존중하기 +* 건설적인 피드백을 제공하고 우아하게 받아들이기 +* 우리의 실수로 인해 영향을 받은 사람들에게 책임을 받아들이고 사과하며 + 그 경험을 통해 배우기 +* 우리 개인뿐만 아니라 전체 커뮤니티에 가장 좋은 것이 무엇인지 + 집중하기 + +용납할 수 없는 행동의 예는 다음과 같습니다: + +* 성적인 언어 또는 이미지의 사용, 모든 종류의 성적 관심 또는 + 접근 행위 +* 트롤링, 모욕적이거나 경멸적인 댓글, 개인적 또는 정치적 공격 +* 공개적 또는 사적인 괴롭힘 +* 명시적인 허가 없이 타인의 실제 주소 또는 이메일 주소와 같은 + 개인정보를 게시하는 행위 +* 직업적 환경에서 합리적으로 부적절하다고 간주될 수 있는 + 기타 행위 + +## 시행 책임 + +커뮤니티 리더는 허용되는 행동의 기준을 명확히 하고 시행할 +책임이 있으며 부적절하거나 위협적이거나 모욕적이거나 +유해하다고 판단되는 행동에 대해 적절하고 공정한 시정 조치를 +취합니다. + +커뮤니티 리더는 본 행동 강령에 부합하지 않는 댓글, 커밋, +코드, 위키 편집, 이슈 및 기타 기여를 삭제, 편집 또는 거부할 +권한과 책임이 있으며, 적절한 경우 중재 결정의 이유를 +전달합니다. + +## 범위 + +본 행동 강령은 모든 커뮤니티 공간에서 적용되며, 개인이 공개 +공간에서 커뮤니티를 공식적으로 대표하는 경우에도 적용됩니다. +커뮤니티를 대표하는 예로는 공식 이메일 주소 사용, 공식 소셜 미디어 +계정을 통한 게시, 온라인 또는 오프라인 이벤트에서 지정된 대표자로 +활동하는 것 등이 있습니다. + +## 시행 + +모욕적, 괴롭힘 또는 기타 용납할 수 없는 행동은 + [info@rustdesk.com](mailto:info@rustdesk.com)으로 법 집행을 담당하는 커뮤니티 리더에게 +신고하실 수 있습니다. +모든 불만 사항은 신속하고 공정하게 검토 및 조사됩니다. + +모든 커뮤니티 리더는 모든 사건 신고자의 사생활과 보안을 존중할 의무가 +있습니다. + +## 시행 지침 + +커뮤니티 리더는 이 행동 강령을 위반한 것으로 간주되는 모든 행동에 대한 +결과를 결정할 때 다음 커뮤니티 영향 지침을 따릅니다: + +### 1. 수정 + +**커뮤니티 영향**: 커뮤니티에서 비전문적이거나 환영받지 못하는 +것으로 간주되는 부적절한 언어 사용이나 기타 행위입니다. + +**결과**: 커뮤니티 리더의 비공개 서면 경고. 위반 사항의 성격과 +해당 행동이 부적절했던 이유를 명확히 설명해야 합니다. +공개 사과를 요청할 수도 있습니다. + +### 2. 경고 + +**커뮤니티 영향**: 단일 사건 또는 일련의 행위를 통한 +위반입니다. + +**결과**: 지속적인 행동에 대한 경고 및 결과. 행동 강령 시행 담당자와의 +원치 않는 상호작용을 포함하여 관련자와의 상호작용은 일정 +기간 동안 금지됩니다. 여기에는 공동 공간 및 소셜 미디어와 +같은 외부 채널에서의 상호작용 금지가 포함됩니다. 이러한 +조건을 위반할 경우 일시적 또는 영구적으로 이용이 금지될 수 +있습니다. + +### 3. 일시 금지 + +**커뮤니티 영향**: 지속적인 부적절한 행동을 포함하여 +커뮤니티 기준을 심각하게 위반한 경우입니다. + +**결과**: 일정 기간 동안 커뮤니티와의 모든 상호작용이나 공개적인 소통이 +일시적으로 금지됩니다. 이 기간 동안에는 행동 강령을 시행하는 +사람들과의 원치 않는 상호작용을 포함하여 관련자들과의 공개적 또는 +사적인 상호작용이 허용되지 않습니다. +이러한 조건을 위반할 경우 영구적으로 이용이 금지될 수 있습니다. + +### 4. 영구 금지 + +**커뮤니티 영향**: 지속적인 부적절한 행동, 특정 개인에 대한 괴롭힘, +특정 계층에 대한 공격성 또는 비하 등 공동체 기준을 위반하는 +행동을 보이는 경우입니다. + +**결과**: 공동체 내 모든 종류의 공개적인 상호작용이 영구적으로 +금지됩니다. + +## 귀속 + +본 행동 강령은 [Contributor Covenant][homepage] 버전 2.0을 바탕으로 작성되었으며 +[https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0]에서 + 확인하실 수 있습니다. + +커뮤니티 영향 지침은 +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]에서 영감을 받았습니다. + +본 행동 강령에 대한 일반적인 질문은 [https://www.contributor-covenant.org/faq][FAQ]에서 FAQ를 +참조하세요. 번역은 [https://www.contributor-covenant.org/translations][translations]에서 +확인하실 수 있습니다. + +[homepage]: https://www.contributor-covenant.org +[v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/docs/CODE_OF_CONDUCT-NO.md b/docs/CODE_OF_CONDUCT-NO.md new file mode 100644 index 000000000..baefda051 --- /dev/null +++ b/docs/CODE_OF_CONDUCT-NO.md @@ -0,0 +1,125 @@ + +# Atferdskodeks for bidragsyterpaktern + +## Hva Vi Står For + +Vi som medlemer, bidragere, og ledere står for å skape ett hat-fritt felleskap, +uansett alder, kroppstørrelse, synlig eller usynlige funksjonsnedsettninger, +etnesitet, kjønns karaktertrekk, kjønnsidentitet, kunnskapsnivå, utdanning, +sosial-økonomisk status, nasjonalitet, utsende, rase, religion, eller seksual +identitet og orientasjon. + +Vi står for åpen, velkommende, mangfold, inklusiv og sunn oppførsel i vårt felleskap. + +## Våre Standarer + +Eksempler på oppførsel som hjelper ett positivt felleskap inkluderer: + +* Vise empati og vennlighet mot andre mennesker +* Være respektfull ovenfor ulike meninger, synspunkter og erfaringer +* Gi og ta konstruktiv kritikk i beste mening +* Akseptere ansvar og unskylde seg for de som er utsatt av våre feil, + og lære av disse +* Fokusere på det som er best ikke bare for individer, men for felleskapet + +Eksempler på uakseptabel oppførsel inkluderer: + +* Bruk av seksualisert språk eller bilder, og seksual oppmerksomhet. +* Troll-ene, fornermende og nedsettende kommentarer, og personlig eller politiske angrep +* Offentlig eller privat trakassering +* Publisering av andres private informasjon, sånn som bosteds- og epost-addresser, + uten deres godskjenning. +* Andre rettningslinjer som kan bli sett på som upassende i en profesjonell setting. + +## Håndhevingsansvar + +Felleskapets ledere har ansvar for å klarifisere og håndheve våre standarer av +akseptert oppførsel og vill ta rimelige og rettferdige handliger som respons på +oppførsel de anser som upassende, truende, fornermende eller skadelig. + +Felleskapets ledere har retten og ansvaret til å fjerne, redigere, eller avslå +kommentarer, commits, kode, wiki endringer, issues, og andre birag som ikke +samsvarer med disse etiske rettningslinjene, og vill kommunisere grunner for +moderatorenes valg når passende. + +## Omfang + +Disse etiske rettningslinjene gjelder innenfor alle platformene til felleskapet, og +de gjelder også når ett individ representerer felleskapet på offentlige medier. +Eksempler på representasjon av vårt felleskap inkluderer bruke av offisielle e-mail +addresser, publisering gjennom en offisiell sosial media bruker, eller oppførsel som en +utpekt representant på digitale og fysiske arrangsjemanger. + +## Håndheving + +Hendelser av misbruk, trakasserende eller på noen måte uakseptert oppførsel kann +bli raportert til felleskapets ledere med ansvar for håndheving på +[info@rustdesk.com](mailto:info@rustdesk.com). +All tilbakemelding vill bli sett gjennom og investigert rettferdig så fort som mulig. + +Alle felleskapets ledere er obligert til å respektere privatlivet og sikkerhetet ovenfor +den som raporterer en hendelse. + +## Håndhevings Guide + +Felleskapets ledere vill følge disse Rettningslinjene for sammfunspåvirkning med +tanke på konsekvenser for en handling de anser i brudd med disse etiske rettningslinjene: + +### 1. Korreksjon + +**Sammfunspåvirkning**: Bruk av upassende språk eller annen oppførsel ansett som +uprofesjonelt eller uvelkommen i dette felleskapet. + +**Konsekvens**: En privat, skrevet advarsel fra en leder av felleskapet, som +klarifiserer grunnlaget til hvorfor denne oppførselen var upassende. En offentlig +unskyldning kan bli forespurt. + +### 2. Advarsel + +**Sammfunspåvirkning**: Ett brudd på en singulær hendelse eller en serie handlinger. + +**Konsekvens**: En advarsel med konsekvenser for kontinuerende oppførsel. Ingen +interaksjon med individene involvert, inkluderer uoppfordret interaksjoner med +de som håndhever disse etiske rettningslinjene, er tillat for en spesifisert tidsperiode. +Dette inkluderer å unngå interaksjoner i felleskapets platformer, samt eksterne +kanaler, som f.eks sosial media. Brudd av disse vilkårene kan føre til midlertidig +eller permanent bannlysning. + +### 3. Midlertidig Bannlysning + +**Sammfunspåvirkning**: Ett særiøst brudd på felleskapets standarer, inkludert +vedvarende upassende oppførsel. + +**Konsekvens**: En midlertidig bannlysning fra noen som helst interaksjon eller +offentlig kommunikasjon med felleskapet for en spesifisert tidsperiode. Ingen +interaksjon med individene involvert, inkluderer uoppfordret interaksjoner med +de som håndhever disse etiske rettningslinjene, er tillat for denne perioden. +Brudd på disse vilkårene kan føre til permanent bannlysning. + +### 4. Permanent Bannlysning + +**Sammfunspåvirkning**: Demonstasjon av mønster i brudd på felleskapets standarer, +inklusivt vedvarende upassende oppførsel, trakassering av ett individ, eller +aggresjon mot eller nedsettelse av grupper individer. + +**Konsekvens**: En permanent bannlysning fra alle offentlige interaksjoner i +felleskapet + +## Attribusjon + +Disse etiske rettningslinjene er adaptert fra [Contributor Covenant][homepage], +versjon 2.0, tilgjengelig ved +[https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0]. + +Sammfunspåvirknings guid inspirert av +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For svar til vanlige spørsmål angående disse etiske rettningslinjene, se FAQ på +[https://www.contributor-covenant.org/faq][FAQ]. Oversettelse tilgjengelig +ved [https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/docs/CODE_OF_CONDUCT-RO.md b/docs/CODE_OF_CONDUCT-RO.md new file mode 100644 index 000000000..6fe8564a3 --- /dev/null +++ b/docs/CODE_OF_CONDUCT-RO.md @@ -0,0 +1,85 @@ +# Codul de Conduită al Contributorilor + +## Angajamentul Nostru + +Noi, ca membri, contribuitori și lideri, ne angajăm să facem ca participarea în comunitatea noastră să fie o experiență fără hărțuire pentru toată lumea, indiferent de vârstă, dimensiunea corpului, dizabilități vizibile sau invizibile, etnie, caracteristici sexuale, identitate și exprimare de gen, nivel de experiență, educație, statut socio-economic, naționalitate, aspect personal, rasă, religie sau identitate și orientare sexuală. + +Ne angajăm să acționăm și să interacționăm în moduri care contribuie la o comunitate deschisă, primitoare, diversă, incluzivă și sănătoasă. + +## Standardele Noastre + +Exemple de comportamente care contribuie la un mediu pozitiv pentru comunitatea noastră includ: + +* Demonstrarea empatiei și a bunătății față de ceilalți +* Respectarea opiniilor, punctelor de vedere și experiențelor diferite +* Oferirea și acceptarea cu grație a feedback-ului constructiv +* Asumarea responsabilității și cererea de scuze celor afectați de greșelile noastre și învățarea din experiență +* Concentrarea pe ceea ce este cel mai bun nu doar pentru noi ca indivizi, ci pentru întreaga comunitate + +Exemple de comportamente inacceptabile includ: + +* Utilizarea limbajului sau imaginilor sexualizate, precum și atenția sau avansurile sexuale de orice fel +* Trollare, insulte sau comentarii denigratoare și atacuri personale sau politice +* Hărțuire publică sau privată +* Publicarea informațiilor private ale altora, cum ar fi adresa fizică sau de e-mail, fără permisiunea explicită +* Alte comportamente care ar putea fi considerate inadecvate într-un cadru profesional + +## Responsabilități de Aplicare + +Liderii comunității sunt responsabili pentru clarificarea și aplicarea standardelor noastre de comportament acceptabil și vor lua măsuri corective adecvate și echitabile ca răspuns la orice comportament pe care îl consideră inadecvat, amenințător, ofensator sau dăunător. + +Liderii comunității au dreptul și responsabilitatea de a elimina, edita sau respinge comentarii, commit-uri, cod, editări wiki, probleme și alte contribuții care nu se aliniază acestui Cod de Conduită și vor comunica motivele pentru deciziile de moderare atunci când este cazul. + +## Domeniu de Aplicare + +Acest Cod de Conduită se aplică în toate spațiile comunității și se aplică și atunci când un individ reprezintă oficial comunitatea în spații publice. +Exemple de reprezentare a comunității includ utilizarea unei adrese de e-mail oficiale, postarea printr-un cont oficial de social media sau acționarea ca reprezentant desemnat la un eveniment online sau offline. + +## Aplicare + +Cazurile de comportament abuziv, hărțuitor sau altfel inacceptabil pot fi raportate liderilor comunității responsabili pentru aplicare la [info@rustdesk.com](mailto:info@rustdesk.com). +Toate plângerile vor fi revizuite și investigate prompt și corect. + +Toți liderii comunității sunt obligați să respecte confidențialitatea și securitatea persoanei care raportează orice incident. + +## Ghiduri de Aplicare + +Liderii comunității vor urma aceste Ghiduri privind Impactul Comunității pentru a stabili consecințele pentru orice acțiune pe care o consideră o încălcare a acestui Cod de Conduită: + +### 1. Corectare + +**Impact asupra comunității**: Utilizarea limbajului neadecvat sau alte comportamente considerate neprofesionale sau nedorite în comunitate. + +**Consecință**: O avertizare scrisă și privată din partea liderilor comunității, oferind claritate asupra naturii încălcării și o explicație despre motivul pentru care comportamentul a fost inadecvat. Poate fi cerută o scuză publică. + +### 2. Avertisment + +**Impact asupra comunității**: Încălcare printr-un incident singular sau o serie de acțiuni. + +**Consecință**: Un avertisment cu consecințe pentru continuarea comportamentului. Nicio interacțiune cu persoanele implicate, inclusiv interacțiuni nesolicitate cu cei care aplică Codul de Conduită, pentru o perioadă specificată. Aceasta include evitarea interacțiunilor în spațiile comunității, precum și pe canale externe, cum ar fi rețelele sociale. Încălcarea acestor termeni poate duce la o suspendare temporară sau permanentă. + +### 3. Suspendare Temporară + +**Impact asupra comunității**: O încălcare serioasă a standardelor comunității, inclusiv comportament neadecvat susținut. + +**Consecință**: Suspendare temporară de la orice tip de interacțiune sau comunicare publică cu comunitatea pentru o perioadă specificată. Nicio interacțiune publică sau privată cu persoanele implicate, inclusiv interacțiuni nesolicitate cu cei care aplică Codul de Conduită, nu este permisă în această perioadă. Încălcarea acestor termeni poate duce la o interdicție permanentă. + +### 4. Interdicție Permanentă + +**Impact asupra comunității**: Demonstrând un tipar de încălcare a standardelor comunității, inclusiv comportament neadecvat susținut, hărțuire a unei persoane sau agresiune față de sau denigrare a unor grupuri de persoane. + +**Consecință**: Interdicție permanentă de la orice tip de interacțiune publică în cadrul comunității. + +## Atribuire + +Acest Cod de Conduită este adaptat din [Contributor Covenant][homepage], versiunea 2.0, disponibil la [https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0]. + +Ghidurile privind Impactul Comunității au fost inspirate de [scara de aplicare a codului de conduită Mozilla][Mozilla CoC]. + +Pentru răspunsuri la întrebări frecvente despre acest cod de conduită, vezi FAQ la [https://www.contributor-covenant.org/faq][FAQ]. Traduceri sunt disponibile la [https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations \ No newline at end of file diff --git a/docs/CONTRIBUTING-DE.md b/docs/CONTRIBUTING-DE.md index 6258a9a7a..b45c23d50 100644 --- a/docs/CONTRIBUTING-DE.md +++ b/docs/CONTRIBUTING-DE.md @@ -1,42 +1,42 @@ -# Beitrge zu RustDesk +# Beiträge zu RustDesk -RustDesk begrt Beitrge von jedem. Hier sind die Richtlinien, wenn Sie uns -helfen mchten: +RustDesk begrüßt Beiträge von jedem. Hier sind die Richtlinien, wenn Sie uns +helfen möchten: -## Beitrge +## Beiträge -Beitrge zu RustDesk oder seinen Abhngigkeiten sollten in Form von Pull +Beiträge zu RustDesk oder seinen Abhängigkeiten sollten in Form von Pull Requests auf GitHub erfolgen. Jeder Pull Request wird von einem Hauptakteur -(jemand mit der Erlaubnis, Korrekturen einzubringen) geprft und entweder in den -Hauptbaum eingefgt oder Feedback fr notwendige nderungen gegeben. Alle -Beitrge sollten diesem Format folgen, auch die von Hauptakteuren. +(jemand mit der Erlaubnis, Korrekturen einzubringen) geprüft und entweder in den +Hauptbaum eingefügt oder Feedback für notwendige Änderungen gegeben. Alle +Beiträge sollten diesem Format folgen, auch die von Hauptakteuren. -Wenn Sie an einem Problem arbeiten mchten, melden Sie es bitte zuerst an, indem -Sie auf GitHub erklren, dass Sie daran arbeiten mchten. Damit soll verhindert -werden, dass Beitrge zum gleichen Thema doppelt bearbeitet werden. +Wenn Sie an einem Problem arbeiten möchten, melden Sie es bitte zuerst an, indem +Sie auf GitHub erklären, dass Sie daran arbeiten möchten. Damit soll verhindert +werden, dass Beiträge zum gleichen Thema doppelt bearbeitet werden. -## Checkliste fr Pull Requests +## Checkliste für Pull Requests -- Verzweigen Sie sich vom Master-Branch und, falls ntig, wechseln Sie zum +- Verzweigen Sie sich vom Master-Branch und, falls nötig, wechseln Sie zum aktuellen Master-Branch, bevor Sie Ihren Pull Request einreichen. Wenn das - Zusammenfhren mit dem Master nicht reibungslos funktioniert, werden Sie - mglicherweise aufgefordert, Ihre nderungen zu berarbeiten. + Zusammenführen mit dem Master nicht reibungslos funktioniert, werden Sie + möglicherweise aufgefordert, Ihre Änderungen zu überarbeiten. -- Commits sollten so klein wie mglich sein und gleichzeitig sicherstellen, dass - jeder Commit unabhngig voneinander korrekt ist (d. h., jeder Commit sollte - sich bersetzen lassen und Tests bestehen). +- Commits sollten so klein wie möglich sein und gleichzeitig sicherstellen, dass + jeder Commit unabhängig voneinander korrekt ist (d. h., jeder Commit sollte + sich übersetzen lassen und Tests bestehen). -- Commits sollten von einem "Herkunftszertifikat fr Entwickler" +- Commits sollten von einem "Herkunftszertifikat für Entwickler" (https://developercertificate.org) begleitet werden, das besagt, dass Sie (und ggf. Ihr Arbeitgeber) mit den Bedingungen der [Projektlizenz](../LICENCE) - einverstanden sind. In Git ist dies die Option `-s` fr `git commit`. + einverstanden sind. In Git ist dies die Option `-s` für `git commit`. - Wenn Ihr Patch nicht begutachtet wird oder Sie eine bestimmte Person zur - Begutachtung bentigen, knnen Sie einem Gutachter mit @ antworten und um eine - Begutachtung des Pull Requests oder einen Kommentar bitten. Sie knnen auch + Begutachtung benötigen, können Sie einem Gutachter mit @ antworten und um eine + Begutachtung des Pull Requests oder einen Kommentar bitten. Sie können auch per [E-Mail](mailto:info@rustdesk.com) um eine Begutachtung bitten. -- Fgen Sie Tests hinzu, die sich auf den behobenen Fehler oder die neue +- Fügen Sie Tests hinzu, die sich auf den behobenen Fehler oder die neue Funktion beziehen. Spezifische Git-Anweisungen finden Sie im [GitHub-Workflow](https://github.com/servo/servo/wiki/GitHub-workflow). @@ -47,4 +47,4 @@ https://github.com/rustdesk/rustdesk/blob/master/docs/CODE_OF_CONDUCT.md ## Kommunikation -RustDesk-Mitarbeiter arbeiten hufig im [Discord](https://discord.gg/nDceKgxnkV). +RustDesk-Mitarbeiter arbeiten häufig im [Discord](https://discord.gg/nDceKgxnkV). diff --git a/docs/CONTRIBUTING-FR.md b/docs/CONTRIBUTING-FR.md new file mode 100644 index 000000000..6f800de7d --- /dev/null +++ b/docs/CONTRIBUTING-FR.md @@ -0,0 +1,55 @@ + +# Contribuer à RustDesk + +RustDesk accueille les contributions de tous. Voici les directives si vous +envisagez de nous aider : + +## Contributions + +Les contributions à RustDesk ou à ses dépendances doivent être soumises sous +forme de pull requests GitHub. Chaque pull request sera examinée par un +contributeur principal (une personne ayant la permission d'intégrer des +correctifs) et sera soit intégrée dans la branche principale, soit accompagnée +de retours sur les modifications requises. Toutes les contributions doivent +suivre ce format, même celles des contributeurs principaux. + +Si vous souhaitez travailler sur une issue, veuillez d'abord la revendiquer en +commentant sur l'issue GitHub indiquant que vous souhaitez la traiter. Cela +permet d'éviter les efforts en double de la part des contributeurs sur la même +issue. + +## Liste de vérification pour les pull requests + +- Partez de la branche master et, si nécessaire, effectuez un rebase sur la + branche master actuelle avant de soumettre votre pull request. Si elle ne + fusionne pas proprement avec master, il vous sera peut-être demandé de + rebaser vos modifications. + +- Les commits doivent être aussi petits que possible, tout en s'assurant que + chaque commit est correct de manière indépendante (c.-à-d. que chaque commit + doit compiler et passer les tests). + +- Les commits doivent être accompagnés d'une signature Developer Certificate of + Origin (http://developercertificate.org), indiquant que vous (et votre + employeur le cas échéant) acceptez d'être liés par les termes de la + [licence du projet](../LICENCE). Dans git, il s'agit de l'option `-s` de + `git commit`. + +- Si votre correctif n'est pas examiné ou si vous avez besoin qu'une personne + spécifique l'examine, vous pouvez @-mentionner un relecteur pour demander une + revue dans la pull request ou un commentaire, ou vous pouvez demander une + revue par [e-mail](mailto:info@rustdesk.com). + +- Ajoutez des tests relatifs au bug corrigé ou à la nouvelle fonctionnalité. + +Pour des instructions git spécifiques, consultez le +[GitHub workflow 101](https://github.com/servo/servo/wiki/GitHub-workflow). + +## Conduite + +https://github.com/rustdesk/rustdesk/blob/master/docs/CODE_OF_CONDUCT.md + +## Communication + +Les contributeurs de RustDesk se retrouvent fréquemment sur +[Discord](https://discord.gg/nDceKgxnkV). diff --git a/docs/CONTRIBUTING-KR.md b/docs/CONTRIBUTING-KR.md new file mode 100644 index 000000000..5e432648e --- /dev/null +++ b/docs/CONTRIBUTING-KR.md @@ -0,0 +1,46 @@ +# RustDesk 기여하기 + +RustDesk는 모든 분들의 참여를 환영합니다. 저희를 도와주실 생각이 있으시다면 + 다음 지침을 따르세요: + +## 기여 + +RustDesk 또는 그 종속성에 대한 기여는 GitHub 풀 리퀘스트 형태로 +이루어져야 합니다. 각 풀 리퀘스트는 핵심 기여자 (패치 적용 권한이 +있는 사람)가 검토하여 메인 트리에 추가하거나 필요한 변경 사항에 +대한 피드백을 제공합니다. 핵심 기여자의 기여를 포함하여 모든 기여는 +이 형식을 따라야 합니다. + +이슈에 대해 작업하고 싶으시면 먼저 해당 이슈에 대해 작업하고 싶다는 +댓글을 달아 해당 이슈를 요청하세요. 이는 동일한 이슈에 대한 기여자의 +중복된 노력을 방지하기 위한 것입니다. + +## 풀 리퀘스트 체크리스트 + +- Master 브랜치에서 브랜치를 만들고, 필요한 경우 풀 리퀘스트를 제출하기 + 전에 현재 마스터 브랜치로 리베이스하세요. 마스터 브랜치와 깔끔하게 + 병합되지 않으면 변경 사항을 리베이스하라는 요청을 받을 수 있습니다. + +- 커밋은 가능한 한 작아야 하지만, 각 커밋이 독립적으로 올바른지 확인 + 해야 합니다 (즉, 각 커밋은 컴파일되어 테스트를 통과해야 함). + +- 커밋에는 개발자 출처 증명서 (http://developercertificate.org) + 서명이 첨부되어야 하며, 이는 귀하 (및 해당되는 경우 고용주)가 + [프로젝트 라이선스](../LICENCE). 조건에 구속되는 데 동의한다는 것을 나타냅니다. + git에서는 `git commit`에 `-s` 옵션입니다 + +- 패치가 검토되지 않거나 특정인이 검토해야 하는 경우, 풀 리퀘스트나 + 댓글에서 검토자에게 @-답글을 보내 검토를 요청하거나 + [이메일](mailto:info@rustdesk.com)을 통해 검토를 요청할 수 있습니다. + +- 수정된 버그 또는 새 기능과 관련된 테스트를 추가합니다. + +구체적인 git 지침은, [GitHub workflow 101](https://github.com/servo/servo/wiki/GitHub-workflow)을 참조하세요. + +## 행동 강령 + +https://github.com/rustdesk/rustdesk/blob/master/docs/CODE_OF_CONDUCT.md + +## 커뮤니케이션 + +RustDesk 기여자들은 [Discord](https://discord.gg/nDceKgxnkV)에서 활동하고 있습니다. diff --git a/docs/CONTRIBUTING-NO.md b/docs/CONTRIBUTING-NO.md new file mode 100644 index 000000000..89a574563 --- /dev/null +++ b/docs/CONTRIBUTING-NO.md @@ -0,0 +1,46 @@ +# Bidrag til RustDesk + +RustDesk er åpene for bidrag fra alle. Her er reglene for de som har lyst til å +hjelpe oss: + +## Bidrag + +Bidrag til RustDesk eller deres avhengigheter burde være i form av GitHub pull requests. +Hver pull request vill bli sett igjennom av en kjerne bidrager (noen med autoritet til +å godkjenne endringene) og enten bli sendt til main treet eller respondert med +tilbakemelding på endringer som er nødvendig. Alle bidrag burde følge dette formate +også de fra kjerne bidragere. + +Om du ønsker å jobbe på en issue må du huske å gjøre krav på den først. Dette +kann gjøres ved å kommentere på den GitHub issue-en du ønsker å jobbe på. +Dette er for å hindre duplikat innsats på samme problem. + +## Pull Request Sjekkliste + +- Lag en gren fra master grenen og, hvis det er nødvendig, rebase den til den nåværende + master grenen før du sender inn din pull request. Hvis ikke dette gjøres på rent + vis vill du bli spurt om å rebase dine endringer. + +- Commits burde være så små som mulig, samtidig som de må være korrekt uavhenging av hverandre + (hver commit burde kompilere og bestå tester). + +- Commits burde være akkopaniert med en Developer Certificate of Origin + (http://developercertificate.org), som indikerer att du (og din arbeidsgiver + i det tilfellet) godkjenner å bli knyttet til vilkårene av [prosjekt lisensen](../LICENCE). + Ved bruk av git er dette `-s` opsjonen til `git commit`. + +- Hvis dine endringer ikke blir sett eller hvis du trenger en spesefik person til + å se på dem kan du @-svare en med autoritet til å godkjenne dine endringer. + Dette kann gjøres i en pull request, en kommentar eller via epost på [email](mailto:info@rustdesk.com). + +- Legg til tester relevant til en fikset bug eller en ny tilgjengelighet. + +For spesefike git instruksjoner, se [GitHub workflow 101](https://github.com/servo/servo/wiki/GitHub-workflow). + +## Oppførsel + +https://github.com/rustdesk/rustdesk/blob/master/docs/CODE_OF_CONDUCT.md + +## Kommunikasjon + +RustDesk bidragere burker [Discord](https://discord.gg/nDceKgxnkV). diff --git a/docs/CONTRIBUTING-RO.md b/docs/CONTRIBUTING-RO.md new file mode 100644 index 000000000..8249fb80f --- /dev/null +++ b/docs/CONTRIBUTING-RO.md @@ -0,0 +1,31 @@ +# Contribuții la RustDesk + +RustDesk primește cu plăcere contribuții din partea tuturor. Iată ghidurile dacă te gândești să ne ajuți: + +## Contribuții + +Contribuțiile la RustDesk sau la dependențele sale ar trebui făcute sub forma de pull request-uri pe GitHub. Fiecare pull request va fi revizuit de un contributor principal (cineva cu permisiunea de a aplica patch-uri) și fie va fi integrat în arborele principal, fie vor fi oferite sugestii pentru modificările necesare. Toate contribuțiile trebuie să urmeze acest format, chiar și cele ale contributorilor principali. + +Dacă dorești să lucrezi la o problemă, te rugăm să o revendici mai întâi comentând pe GitHub issue-ul pe care vrei să lucrezi. Aceasta previne eforturi duplicate din partea contributorilor asupra aceleiași probleme. + +## Lista de verificare pentru Pull Request + +- Creează un branch din branch-ul `master` și, dacă este necesar, fă rebase la branch-ul `master` curent înainte de a trimite pull request-ul. Dacă nu se poate integra curat cu `master`, ți se poate cere să faci rebase la modificările tale. + +- Commit-urile ar trebui să fie cât mai mici posibil, asigurând totodată că fiecare commit este corect independent (adică fiecare commit ar trebui să compileze și să treacă testele). + +- Commit-urile trebuie să fie însoțite de un semnătura Developer Certificate of Origin (http://developercertificate.org), care indică faptul că tu (și angajatorul tău, dacă este cazul) ești de acord să respecți termenii [licenței proiectului](../LICENCE). În git, aceasta este opțiunea `-s` la `git commit`. + +- Dacă patch-ul tău nu este revizuit sau ai nevoie ca o anumită persoană să-l revizuiască, poți @-reply unui reviewer cerând o revizuire în pull request sau într-un comentariu, sau poți solicita o revizuire prin [email](mailto:info@rustdesk.com). + +- Adaugă teste relevante pentru bug-ul corectat sau pentru funcționalitatea nouă. + +Pentru instrucțiuni specifice git, vezi [GitHub workflow 101](https://github.com/servo/servo/wiki/GitHub-workflow). + +## Conduită + +[Codul de Conduită RustDesk](https://github.com/rustdesk/rustdesk/blob/master/docs/CODE_OF_CONDUCT.md) + +## Comunicare + +Contributorii RustDesk frecventează [Discord](https://discord.gg/nDceKgxnkV). diff --git a/docs/CONTRIBUTING-RU.md b/docs/CONTRIBUTING-RU.md index acc233d00..1cf9a472d 100644 --- a/docs/CONTRIBUTING-RU.md +++ b/docs/CONTRIBUTING-RU.md @@ -5,18 +5,14 @@ RustDesk приветствует вклад каждого. ## Вклад в развитие -Вклады в развитие RustDesk или его зависимости должны быть -сделаны в виде `pull request` на GitHub. Каждый такой -`pull request` будет рассмотрен основным участником -(кем-то, у кого есть разрешение на влив исправлений) -и либо помещен в основное дерево, либо Вам будет дан отзыв -о необходимых правках. Все материалы должны соответствовать -этому формату, даже те, которые поступают от основных авторов. +Вклады в развитие RustDesk или его зависимости должны быть сделаны в виде `pull request` на GitHub. +Каждый такой `pull request` будет рассмотрен основным участником (кем-то, у кого есть разрешение +на влив исправлений) и либо помещен в основное дерево, либо Вам будет дан отзыв о необходимых правках. +Все материалы должны соответствовать этому формату, даже те, которые поступают от основных авторов. -Если вы хотите поработать над какой-либо проблемой, то пожалуйста, -сначала напишите об этом, создав тикет на GitHub, и описав, -над чем вы хотите поработать. Это делается для того, чтобы -предотвратить дублирование усилий участников по одному и тому же вопросу. +Если вы хотите поработать над какой-либо проблемой, то пожалуйста, сначала напишите об этом, +создав `issue` на GitHub, и описав, над чем вы хотите поработать. Это делается для того, +чтобы предотвратить дублирование усилий участников по одному и тому же вопросу. ## Контрольный список для Ваших `pull request` @@ -24,13 +20,13 @@ RustDesk приветствует вклад каждого. ветку перед отправкой `pull request`. При наличии конфликтов слияния вам будет предложено их устранить, возможно при помощи того же `rebase`. -- Коммиты должны быть, по возможности, небольшим, при этом гарантируя, что каждаый +- Коммиты должны быть, по возможности, небольшими, при этом гарантируя, что каждый коммит является независимо правильным (т.е., каждый коммит должен компилироваться и проходить тесты). -- Коммиты должны сопровождаться `Developer Certificate of Origin` - (http://developercertificate.org) подписью, которая укажет на то, что вы (и - ваш работодатель, если это применимо) согласны соблюдать условия - [лицензии проекта](../LICENCE). В `git` это флаг `-s` при использовании `git commit` +- Коммиты должны сопровождаться подписью `Developer Certificate of Origin` + (http://developercertificate.org), которая укажет на то, что вы (и ваш работодатель, + если это применимо) согласны соблюдать условия [лицензии проекта](../LICENCE). + В `git` это флаг `-s` при использовании `git commit` - Если ваш патч не проходит рецензирование или вам нужно, чтобы его проверил конкретный человек, Вы можете ответить рецензенту через `@`, @@ -40,7 +36,7 @@ RustDesk приветствует вклад каждого. Для получения конкретных инструкций `git` см. [GitHub workflow 101](https://github.com/servo/servo/wiki/Github-workflow). -## Кодекс поведения участников и вкладчиков +## Правила поведения участников и вкладчиков Нормы поведения внутри сообщества подробно описаны [здесь](CODE_OF_CONDUCT-RU.md). diff --git a/docs/DEVCONTAINER-DE.md b/docs/DEVCONTAINER-DE.md deleted file mode 100644 index 2a0d73f17..000000000 --- a/docs/DEVCONTAINER-DE.md +++ /dev/null @@ -1,14 +0,0 @@ - -Nach dem Start von Dev-Container im Docker-Container wird ein Linux-Binrprogramm im Debug-Modus erstellt. - -Derzeit bietet Dev-Container Linux- und Android-Builds sowohl im Debug- als auch im Release-Modus an. - -Nachfolgend finden Sie eine Tabelle mit Befehlen, die im Stammverzeichnis des Projekts ausgefhrt werden mssen, um bestimmte Builds zu erstellen. - -Kommando|Build-Typ|Modus --|-|-| -`.devcontainer/build.sh --debug linux`|Linux|debug -`.devcontainer/build.sh --release linux`|Linux|release -`.devcontainer/build.sh --debug android`|android-arm64|debug -`.devcontainer/build.sh --release android`|android-arm64|release - diff --git a/docs/DEVCONTAINER-IT.md b/docs/DEVCONTAINER-IT.md deleted file mode 100644 index 713c6fc37..000000000 --- a/docs/DEVCONTAINER-IT.md +++ /dev/null @@ -1,14 +0,0 @@ - -Dopo l'avvio di devcontainer nel contenitore docker, viene creato un binario linux in modalità debug. - -Attualmente devcontainer consente creazione build Linux e Android sia in modalità debug che in modalità rilascio. - -Di seguito è riportata la tabella dei comandi da eseguire dalla root del progetto per la creazione di build specifiche. - -Comando|Tipo build|Modo --|-|-| -`.devcontainer/build.sh --debug linux`|Linux|debug -`.devcontainer/build.sh --release linux`|Linux|release -`.devcontainer/build.sh --debug android`|android-arm64|debug -`.devcontainer/build.sh --release android`|android-arm64|release - diff --git a/docs/DEVCONTAINER-JP.md b/docs/DEVCONTAINER-JP.md deleted file mode 100644 index d8a599bef..000000000 --- a/docs/DEVCONTAINER-JP.md +++ /dev/null @@ -1,14 +0,0 @@ - -docker コンテナで devcontainer を起動すると、デバッグモードの linux バイナリが作成されます。 - -現在 devcontainer では、Linux と android のビルドをデバッグモードとリリースモードの両方で提供しています。 - -以下は、特定のビルドを作成するためにプロジェクトのルートから実行するコマンドの表になります。 - -コマンド|ビルド タイプ|モード --|-|-| -`.devcontainer/build.sh --debug linux`|Linux|debug -`.devcontainer/build.sh --release linux`|Linux|release -`.devcontainer/build.sh --debug android`|android-arm64|debug -`.devcontainer/build.sh --release android`|android-arm64|release - diff --git a/docs/DEVCONTAINER-NL.md b/docs/DEVCONTAINER-NL.md deleted file mode 100644 index cd6ae456d..000000000 --- a/docs/DEVCONTAINER-NL.md +++ /dev/null @@ -1,15 +0,0 @@ - -Na de start van devcontainer in docker container wordt een linux binaire in foutmodus aangemaakt. - -Momenteel biedt devcontainer linux en android builds in zowel foutopsporing- als uitgave modus. - -Hieronder staat de tabel met commando's die vanuit de root van het project moeten worden -uitgevoerd om specifieke builds te maken. - -Commando|Build Type|Modus --|-|-| -`.devcontainer/build.sh --debug linux`|Linux|debug -`.devcontainer/build.sh --release linux`|Linux|release -`.devcontainer/build.sh --debug android`|android-arm64|debug -`.devcontainer/build.sh --release android`|android-arm64|debug - diff --git a/docs/DEVCONTAINER-PL.md b/docs/DEVCONTAINER-PL.md deleted file mode 100644 index 0aae2b975..000000000 --- a/docs/DEVCONTAINER-PL.md +++ /dev/null @@ -1,14 +0,0 @@ - -Po uruchomieniu devcontainer w kontenerze docker, tworzony jest plik binarny linux w trybue debugowania. - -Obecnie devcontainer oferuje kompilowanie wersji dla linux i android w obu trybach - debugowania i wersji finalnej. - -Poniżej tabela poleceń do uruchomienia z głównego folderu do tworzenia wybranych kompilacji. - -Polecenie|Typ kompilacji|Tryb --|-|-| -`.devcontainer/build.sh --debug linux`|Linux|debug -`.devcontainer/build.sh --release linux`|Linux|release -`.devcontainer/build.sh --debug android`|android-arm64|debug -`.devcontainer/build.sh --release android`|android-arm64|debug - diff --git a/docs/DEVCONTAINER-TR.md b/docs/DEVCONTAINER-TR.md deleted file mode 100644 index 7fc14ce5e..000000000 --- a/docs/DEVCONTAINER-TR.md +++ /dev/null @@ -1,12 +0,0 @@ -Docker konteynerinde devcontainer'ın başlatılmasından sonra, hata ayıklama modunda bir Linux ikili dosyası oluşturulur. - -Şu anda devcontainer, hata ayıklama ve sürüm modunda hem Linux hem de Android derlemeleri sunmaktadır. - -Aşağıda, belirli derlemeler oluşturmak için projenin kökünden çalıştırılması gereken komutlar yer almaktadır. - -Komut | Derleme Türü | Mod --|-|- -`.devcontainer/build.sh --debug linux` | Linux | hata ayıklama -`.devcontainer/build.sh --release linux` | Linux | sürüm -`.devcontainer/build.sh --debug android` | Android-arm64 | hata ayıklama -`.devcontainer/build.sh --release android` | Android-arm64 | sürüm diff --git a/docs/DEVCONTAINER.md b/docs/DEVCONTAINER.md deleted file mode 100644 index 3d04fd399..000000000 --- a/docs/DEVCONTAINER.md +++ /dev/null @@ -1,14 +0,0 @@ - -After the start of devcontainer in docker container, a linux binary in debug mode is created. - -Currently devcontainer offers linux and android builds in both debug and release mode. - -Below is the table on commands to run from root of the project for creating specific builds. - -Command|Build Type|Mode --|-|-| -`.devcontainer/build.sh --debug linux`|Linux|debug -`.devcontainer/build.sh --release linux`|Linux|release -`.devcontainer/build.sh --debug android`|android-arm64|debug -`.devcontainer/build.sh --release android`|android-arm64|release - diff --git a/docs/README-AR.md b/docs/README-AR.md index d6800dc6f..5aa09da88 100644 --- a/docs/README-AR.md +++ b/docs/README-AR.md @@ -9,9 +9,9 @@ لغتك الأم, Doc و RustDesk UI, README نحن بحاجة إلى مساعدتك لترجمة هذا

-[Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) :تواصل معنا عبر +[Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk) :تواصل معنا عبر -[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/I2I04VU09) +[![RustDesk Server Pro](https://img.shields.io/badge/RustDesk%20Server%20Pro-%D8%A7%D9%84%D9%85%D9%8A%D8%B2%D8%A7%D8%AA%20%D8%A7%D9%84%D9%85%D8%AA%D9%82%D8%AF%D9%85%D8%A9-blue)](https://rustdesk.com/pricing.html) .Rustبرنامج آخر لسطح المكتب عن بعد، مكتوب بـ يعمل خارج الصندوق، لا حاجة إلى إعدادات. لديك سيطرة كاملة على بياناتك، دون مخاوف بشأن الأمن. يمكنك استخدام خادم @@ -27,6 +27,7 @@ [**BINARY تنزيل**](https://github.com/rustdesk/rustdesk/releases) + ## التبعيات لواجهة المستخدم الرسومية [sciter](https://sciter.com/) نسخة سطح المكتب تستخدم diff --git a/docs/README-CS.md b/docs/README-CS.md index 2a89997ce..b208414fe 100644 --- a/docs/README-CS.md +++ b/docs/README-CS.md @@ -9,10 +9,10 @@ Potřebujeme Vaši pomoc s překladem tohoto README, uživatelského rozhraní aplikace RustDesk a dokumentace k ní do vašeho jazyka

-Popovídejte si s námi: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) +Popovídejte si s námi: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk) -[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/I2I04VU09) +[![RustDesk Server Pro](https://img.shields.io/badge/RustDesk%20Server%20Pro-Pokro%C4%8Dil%C3%A9%20Funkce-blue)](https://rustdesk.com/pricing.html) Zase další software pro přístup k ploše na dálku, naprogramovaný v jazyce Rust. Funguje hned tak, jak je – není třeba žádného nastavování. Svá data máte ve svých rukách, bez obav o zabezpečení. Je možné používat námi poskytovaný propojovací/předávací (relay) server, [vytvořit si svůj vlastní](https://rustdesk.com/server), nebo [si dokonce svůj vlastní naprogramovat](https://github.com/rustdesk/rustdesk-server-demo), budete-li chtít. diff --git a/docs/README-DA.md b/docs/README-DA.md index 5ea615909..9ad109dde 100644 --- a/docs/README-DA.md +++ b/docs/README-DA.md @@ -9,13 +9,13 @@ Vi har brug for din hjælp til at oversætte denne README, RustDesk UI og Dokument til dit modersmål

-Chat med os: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) +Chat med os: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk) -[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/I2I04VU09) +[![RustDesk Server Pro](https://img.shields.io/badge/RustDesk%20Server%20Pro-Avancerede%20Funktioner-blue)](https://rustdesk.com/pricing.html) Endnu en fjernskrivebordssoftware, skrevet i Rust. Fungerer ud af æsken, ingen konfiguration påkrævet. Du har fuld kontrol over dine data uden bekymringer om sikkerhed. Du kan bruge vores rendezvous/relay-server, [opsætte din egen](https://rustdesk.com/server), eller [skrive din egen rendezvous/relay-server](https://github.com/rustdesk/rustdesk- server-demo). -RustDesk hilser bidrag fra alle velkommen. Se [`docs/CONTRIBUTING.md`](docs/CONTRIBUTING.md) for at få hjælp til at komme i gang. +RustDesk hilser bidrag fra alle velkommen. Se [`docs/CONTRIBUTING.md`](CONTRIBUTING.md) for at få hjælp til at komme i gang. [**PROGRAM DOWNLOAD**](https://github.com/rustdesk/rustdesk/releases) diff --git a/docs/README-DE.md b/docs/README-DE.md index 28e0bc19a..ba8894411 100644 --- a/docs/README-DE.md +++ b/docs/README-DE.md @@ -1,17 +1,21 @@

- RustDesk - Your remote desktop
- Server • + RustDesk - Dein Remote-Desktop
KompilierenDockerDateistrukturScreenshots
- [English] | [Українська] | [česky] | [中文] | [Magyar] | [Español] | [فارسی] | [Français] | [Polski] | [Indonesian] | [Suomi] | [മലയാളം] | [日本語] | [Nederlands] | [Italiano] | [Русский] | [Português (Brasil)] | [Esperanto] | [한국어] | [العربي] | [Tiếng Việt] | [Dansk] | [Ελληνικά]
+ [Українська] | [č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ă]
Wir brauchen Ihre Hilfe, um dieses README, die RustDesk-Benutzeroberfläche und die Dokumentation in Ihre Muttersprache zu übersetzen.

-Reden Sie mit uns auf: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) +> [!Caution] +> **Haftungsausschluss bei Missbrauch::**
+> Die Entwickler von RustDesk billigen oder unterstützen keine unethische oder illegale Nutzung dieser Software. Missbrauch, wie unbefugter Zugriff, unbefugte Kontrolle oder Verletzung der Privatsphäre, verstößt strikt gegen unsere Richtlinien. Die Autoren sind nicht verantwortlich für jeglichen Missbrauch der Anwendung. -[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/I2I04VU09) + +Reden Sie mit uns auf: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk) + +[![RustDesk Server Pro](https://img.shields.io/badge/RustDesk%20Server%20Pro-Erweiterte%20Funktionen-blue)](https://rustdesk.com/pricing.html) RustDesk ist eine in Rust geschriebene Remote-Desktop-Software, die out of the box ohne besondere Konfiguration funktioniert. Sie haben die volle Kontrolle über Ihre Daten und müssen sich keine Sorgen um die Sicherheit machen. Sie können unseren Rendezvous/Relay-Server nutzen, [einen eigenen Server aufsetzen](https://rustdesk.com/server) oder [einen eigenen Server programmieren](https://github.com/rustdesk/rustdesk-server-demo). @@ -23,11 +27,14 @@ RustDesk heißt jegliche Mitarbeit willkommen. Schauen Sie sich [CONTRIBUTING-DE [**Programm herunterladen**](https://github.com/rustdesk/rustdesk/releases) -[**Nächtliche Erstellung**](https://github.com/rustdesk/rustdesk/releases/tag/nightly) +[**Nightly Builds**](https://github.com/rustdesk/rustdesk/releases/tag/nightly) -[Get it on F-Droid](https://f-droid.org/en/packages/com.carriez.flutter_hbb) +[Get it on Flathub](https://flathub.org/apps/com.rustdesk.RustDesk) ## Abhängigkeiten @@ -59,18 +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 + libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-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 +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 ``` + ### 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 +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 ``` ### Arch (Manjaro) @@ -109,7 +117,7 @@ cd ```sh curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh source $HOME/.cargo/env -git clone https://github.com/rustdesk/rustdesk +git clone --recurse-submodules https://github.com/rustdesk/rustdesk cd rustdesk mkdir -p target/debug wget https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so @@ -124,6 +132,7 @@ Beginnen Sie damit, das Repository zu klonen und den Docker-Container zu bauen: ```sh git clone https://github.com/rustdesk/rustdesk cd rustdesk +git submodule update --init --recursive docker build -t "rustdesk-builder" . ``` @@ -152,6 +161,7 @@ Bitte stellen Sie sicher, dass Sie diese Befehle im Stammverzeichnis des RustDes - **[libs/hbb_common](https://github.com/rustdesk/rustdesk/tree/master/libs/hbb_common)**: Video-Codec, Konfiguration, TCP/UDP-Wrapper, Protokoll-Puffer, fs-Funktionen für Dateitransfer und ein paar andere nützliche Funktionen - **[libs/scrap](https://github.com/rustdesk/rustdesk/tree/master/libs/scrap)**: Bildschirmaufnahme - **[libs/enigo](https://github.com/rustdesk/rustdesk/tree/master/libs/enigo)**: Plattformspezifische Maus- und Tastatursteuerung +- **[libs/clipboard](https://github.com/rustdesk/rustdesk/tree/master/libs/clipboard)**: Datei kopieren und einfügen Implementierung für Windows, Linux, macOS. - **[src/ui](https://github.com/rustdesk/rustdesk/tree/master/src/ui)**: GUI - **[src/server](https://github.com/rustdesk/rustdesk/tree/master/src/server)**: Audio/Zwischenablage/Eingabe/Videodienste und Netzwerkverbindungen - **[src/client.rs](https://github.com/rustdesk/rustdesk/tree/master/src/client.rs)**: Starten einer Peer-Verbindung @@ -162,10 +172,11 @@ Bitte stellen Sie sicher, dass Sie diese Befehle im Stammverzeichnis des RustDes ## Screenshots -![image](https://user-images.githubusercontent.com/71636191/113112362-ae4deb80-923b-11eb-957d-ff88daad4f06.png) +![Verbindungsmanager](https://github.com/rustdesk/rustdesk/assets/28412477/db82d4e7-c4bc-4823-8e6f-6af7eadf7651) -![image](https://user-images.githubusercontent.com/71636191/113112619-f705a480-923b-11eb-911d-97e984ef52b6.png) +![Verbunden zu einem Windows PC](https://github.com/rustdesk/rustdesk/assets/28412477/9baa91e9-3362-4d06-aa1a-7518edcbd7ea) -![image](https://user-images.githubusercontent.com/71636191/113112857-3fbd5d80-923c-11eb-9836-768325faf906.png) +![Dateiübertragung](https://github.com/rustdesk/rustdesk/assets/28412477/39511ad3-aa9a-4f8c-8947-1cce286a46ad) + +![TCP-Tunneling](https://github.com/rustdesk/rustdesk/assets/28412477/78e8708f-e87e-4570-8373-1360033ea6c5) -![image](https://user-images.githubusercontent.com/71636191/135385039-38fdbd72-379a-422d-b97f-33df71fb1cec.png) diff --git a/docs/README-EO.md b/docs/README-EO.md index de44d7a16..d2a9315ec 100644 --- a/docs/README-EO.md +++ b/docs/README-EO.md @@ -9,9 +9,9 @@ Ni bezonas helpon traduki tiun README kaj la interfacon al via denaska lingvo

-Babili kun ni: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) +Babili kun ni: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk) -[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/I2I04VU09) +[![RustDesk Server Pro](https://img.shields.io/badge/RustDesk%20Server%20Pro-Altnivela%20Funkcioj-blue)](https://rustdesk.com/pricing.html) Denove alia fora labortabla programo, skribita en Rust. Ĝi funkcias elskatole, ne bezonas konfiguraĵon. Vi havas la tutan kontrolon sur viaj datumoj, sen zorgo pri sekureco. Vi povas uzi nian servilon rendezvous/relajsan, [agordi vian propran](https://rustdesk.com/server), aŭ [skribi vian propran servilon rendezvous/relajsan](https://github.com/rustdesk/rustdesk-server-demo). diff --git a/docs/README-ES.md b/docs/README-ES.md index dea12c152..da939bd7b 100644 --- a/docs/README-ES.md +++ b/docs/README-ES.md @@ -9,12 +9,18 @@ Necesitamos tu ayuda para traducir este README a tu idioma

-Chatea con nosotros: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) +> [!Caution] +> **Descargo de responsabilidad por mal uso:**
+> Los desarrolladores de RustDesk no aprueban ni apoyan ningún uso no ético o ilegal de este software. El mal uso, como el acceso no autorizado, el control o la invasión de la privacidad, va estrictamente en contra de nuestras directrices. Los autores no se hacen responsables de ningún uso indebido de la aplicación. -[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/I2I04VU09) +Chatea con nosotros: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk) + +[![RustDesk Server Pro](https://img.shields.io/badge/RustDesk%20Server%20Pro-Caracter%C3%ADsticas%20Avanzadas-blue)](https://rustdesk.com/pricing.html) Otro software de escritorio remoto, escrito en Rust. Funciona de forma inmediata, sin necesidad de configuración. Tienes el control total de tus datos, sin preocupaciones sobre la seguridad. Puedes utilizar nuestro servidor de rendezvous/relay, [instalar el tuyo](https://rustdesk.com/server), o [escribir tu propio servidor rendezvous/relay](https://github.com/rustdesk/rustdesk-server-demo). +![image](https://user-images.githubusercontent.com/71636191/171661982-430285f0-2e12-4b1d-9957-4a58e375304d.png) + RustDesk agradece la contribución de todo el mundo. Lee [`docs/CONTRIBUTING.md`](CONTRIBUTING.md) para ayuda para empezar. [**¿Cómo funciona rustdesk?**](https://github.com/rustdesk/rustdesk/wiki/How-does-RustDesk-work%3F) @@ -24,12 +30,15 @@ RustDesk agradece la contribución de todo el mundo. Lee [`docs/CONTRIBUTING.md` [Get it on F-Droid](https://f-droid.org/en/packages/com.carriez.flutter_hbb) +[Get it on Flathub](https://flathub.org/apps/com.rustdesk.RustDesk) ## Dependencias -La versión Desktop usa [Sciter](https://sciter.com/) o Flutter para el GUI, este tutorial es solo para Sciter. +Las versiones de escritorio utilizan Flutter o Sciter (obsoleto) para GUI, este tutorial es sólo para Sciter, ya que es más fácil y más amigable para empezar. Echa un vistazo a nuestro [CI](https://github.com/rustdesk/rustdesk/blob/master/.github/workflows/flutter-build.yml) para la construcción de la versión Flutter. -Por favor descarga la librería dinámica de Sciter tu mismo. +Por favor descarga la librería dinámica de Sciter tú mismo. [Windows](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.win/x64/sciter.dll) | [Linux](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so) | @@ -51,13 +60,21 @@ Por favor descarga la librería dinámica de Sciter tu mismo. ### Ubuntu 18 (Debian 10) ```sh -sudo apt install -y 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 +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 +``` + +### 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 ``` ### 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 +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 ``` ### Arch (Manjaro) @@ -96,12 +113,12 @@ cd ```sh curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh source $HOME/.cargo/env -git clone https://github.com/rustdesk/rustdesk +git clone --recurse-submodules https://github.com/rustdesk/rustdesk cd rustdesk mkdir -p target/debug wget https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so mv libsciter-gtk.so target/debug -cargo run +VCPKG_ROOT=$HOME/vcpkg cargo run ``` ## Como compilar con Docker @@ -111,10 +128,11 @@ Empieza clonando el repositorio y compilando el contenedor de docker: ```sh git clone https://github.com/rustdesk/rustdesk cd rustdesk +git submodule update --init --recursive docker build -t "rustdesk-builder" . ``` -Entonces, cada vez que necesites compilar una modificación, ejecuta el siguiente comando: +Entonces, cada vez que necesites compilar la aplicación, ejecuta el siguiente comando: ```sh docker run --rm -it -v $PWD:/home/user/rustdesk -v rustdesk-git-cache:/home/user/.cargo/git -v rustdesk-registry-cache:/home/user/.cargo/registry -e PUID="$(id -u)" -e PGID="$(id -g)" rustdesk-builder @@ -147,12 +165,16 @@ Por favor, asegurate de que estás ejecutando estos comandos desde la raíz del - **[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:**
+> Los desarrolladores de RustDesk no aprueban ni apoyan ningún uso no ético o ilegal de este software. El uso indebido, como el acceso no autorizado, el control o la invasión de la privacidad, está estrictamente en contra de nuestras directrices. Los autores no son responsables de ningún uso indebido de la aplicación. + ## Capturas de pantalla -![image](https://user-images.githubusercontent.com/71636191/113112362-ae4deb80-923b-11eb-957d-ff88daad4f06.png) +![Connection Manager](https://github.com/rustdesk/rustdesk/assets/28412477/db82d4e7-c4bc-4823-8e6f-6af7eadf7651) -![image](https://user-images.githubusercontent.com/71636191/113112619-f705a480-923b-11eb-911d-97e984ef52b6.png) +![Connected to a Windows PC](https://github.com/rustdesk/rustdesk/assets/28412477/9baa91e9-3362-4d06-aa1a-7518edcbd7ea) -![image](https://user-images.githubusercontent.com/71636191/113112857-3fbd5d80-923c-11eb-9836-768325faf906.png) +![File Transfer](https://github.com/rustdesk/rustdesk/assets/28412477/39511ad3-aa9a-4f8c-8947-1cce286a46ad) -![image](https://user-images.githubusercontent.com/71636191/135385039-38fdbd72-379a-422d-b97f-33df71fb1cec.png) +![TCP Tunneling](https://github.com/rustdesk/rustdesk/assets/28412477/78e8708f-e87e-4570-8373-1360033ea6c5) diff --git a/docs/README-FA.md b/docs/README-FA.md index ca060011b..a0645e02b 100644 --- a/docs/README-FA.md +++ b/docs/README-FA.md @@ -9,10 +9,10 @@

[English] | [Українська] | [česky] | [中文] | [Magyar] | [Español] | [Français] | [Deutsch] | [Polski] | [Indonesian] | [Suomi] | [മലയാളം] | [日本語] | [Nederlands] | [Italiano] | [Русский] | [Português (Brasil)] | [Esperanto] | [한국어] | [العربي] | [Tiếng Việt] | [Ελληνικά]

برای ترجمه این سند (README)، رابط کاربری RustDesk، و مستندات آن به زبان مادری شما به کمکتان نیازمندیم.

-با ما گفتگو کنید: [Reddit](https://www.reddit.com/r/rustdesk) | [Twitter](https://twitter.com/rustdesk) | [Discord](https://discord.gg/nDceKgxnkV) +با ما گفتگو کنید: [Reddit](https://www.reddit.com/r/rustdesk) | [Twitter](https://twitter.com/rustdesk) | [Discord](https://discord.gg/nDceKgxnkV) | [YouTube](https://www.youtube.com/@rustdesk) -[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/I2I04VU09) +[![RustDesk Server Pro](https://img.shields.io/badge/RustDesk%20Server%20Pro-%D9%88%DB%8C%DA%98%DA%AF%DB%8C%E2%80%8C%D9%87%D8%A7%DB%8C%20%D9%BE%DB%8C%D8%B4%D8%B1%D9%81%D8%AA%D9%87-blue)](https://rustdesk.com/pricing.html) راست‌دسک (RustDesk) نرم‌افزاری برای کارکردن با رایانه‌ی رومیزی از راه دور است و با زبان برنامه‌نویسی Rust نوشته شده است. نیاز به تنظیمات چندانی ندارد و شما را قادر می سازد تا بدون نگرانی از امنیت اطلاعات خود بر آن‌ها کنترل کامل داشته باشید. diff --git a/docs/README-FI.md b/docs/README-FI.md index 2ba44394f..4c167978c 100644 --- a/docs/README-FI.md +++ b/docs/README-FI.md @@ -9,9 +9,9 @@ Tarvitsemme apua tämän README-tiedoston kääntämiseksi äidinkielellesi

-Juttele meidän kanssa: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) +Juttele meidän kanssa: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk) -[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/I2I04VU09) +[![RustDesk Server Pro](https://img.shields.io/badge/RustDesk%20Server%20Pro-Edistyneet%20Ominaisuudet-blue)](https://rustdesk.com/pricing.html) Vielä yksi etätyöpöytäohjelmisto, ohjelmoitu Rust-kielellä. Toimii suoraan pakkauksesta, ei tarvitse asetusta. Hallitset täysin tietojasi, ei tarvitse murehtia turvallisuutta. Voit käyttää meidän rendezvous/relay-palvelinta, [aseta omasi](https://rustdesk.com/server), tai [kirjoittaa oma rendezvous/relay-palvelin](https://github.com/rustdesk/rustdesk-server-demo). diff --git a/docs/README-FR.md b/docs/README-FR.md index 1ad38ebc2..345e53b58 100644 --- a/docs/README-FR.md +++ b/docs/README-FR.md @@ -9,9 +9,9 @@ Nous avons besoin de votre aide pour traduire ce README dans votre langue maternelle.

-Chattez avec nous : [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) +Chattez avec nous : [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk) -[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/I2I04VU09) +[![RustDesk Server Pro](https://img.shields.io/badge/RustDesk%20Server%20Pro-Fonctionnalit%C3%A9s%20Avanc%C3%A9es-blue)](https://rustdesk.com/pricing.html) Encore un autre logiciel de bureau à distance, écrit en Rust. Fonctionne directement, aucune configuration n'est nécessaire. Vous avez le contrôle total de vos données, sans aucun souci de sécurité. Vous pouvez utiliser notre serveur de rendez-vous/relais, [configurer le vôtre](https://rustdesk.com/server), ou [écrire votre propre serveur de rendez-vous/relais](https://github.com/rustdesk/rustdesk-server-demo). @@ -34,9 +34,9 @@ Les versions de bureau utilisent [sciter](https://sciter.com/) pour l'interface - Installez [vcpkg](https://github.com/microsoft/vcpkg), et définissez correctement la variable d'environnement `VCPKG_ROOT`. - Windows : vcpkg install libvpx:x64-windows-static libyuv:x64-windows-static opus:x64-windows-static aom:x64-windows-static - - Linux/Osx : vcpkg install libvpx libyuv opus aom + - Linux/macOS : vcpkg install libvpx libyuv opus aom -- Exécuter `cargo run` +- Exécutez `cargo run` ## Comment compiler/build sous Linux @@ -93,7 +93,7 @@ cd rustdesk mkdir -p target/debug wget https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so mv libsciter-gtk.so target/debug -Exécution du cargo +cargo run ``` ## Comment construire avec Docker @@ -137,6 +137,10 @@ Veuillez vous assurer que vous exécutez ces commandes à partir de la racine du - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)** : Communiquer avec [rustdesk-server](https://github.com/rustdesk/rustdesk-server), attendre une connexion distante directe (TCP hole punching) ou relayée. - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)** : code spécifique à la plateforme +> [!Attention] +> **Avertissement contre l'utilisation abusive:**
+> Les développeurs de RustDesk ne cautionnent ni ne soutiennent aucune utilisation non éthique ou illégale de ce logiciel. Toute utilisation abusive, telle que l'accès non autorisé, le contrôle ou l'invasion de la vie privée, est strictement contraire à nos directives. Les auteurs ne sont pas responsables de toute utilisation abusive de l'application. + ## Images ![image](https://user-images.githubusercontent.com/71636191/113112362-ae4deb80-923b-11eb-957d-ff88daad4f06.png) diff --git a/docs/README-GR.md b/docs/README-GR.md index 6fbb78fc8..8b0276bf8 100644 --- a/docs/README-GR.md +++ b/docs/README-GR.md @@ -9,15 +9,15 @@ Χρειαζόμαστε τη βοήθειά σας για να μεταφράσουμε αυτό το αρχείο README, το RustDesk UI και το Doc στη μητρική σας γλώσσα

-Επικοινωνήστε μαζί μας μέσω: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) +Επικοινωνήστε μαζί μας μέσω: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk) -[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/I2I04VU09) +[![RustDesk Server Pro](https://img.shields.io/badge/RustDesk%20Server%20Pro-%CE%A0%CF%81%CE%BF%CE%B7%CE%B3%CE%BC%CE%AD%CE%BD%CE%B5%CF%82%20%CE%94%CF%85%CE%BD%CE%B1%CF%84%CF%8C%CF%84%CE%B7%CF%84%CE%B5%CF%82-blue)](https://rustdesk.com/pricing.html) Ένα λογισμικό απομακρυσμένης επιφάνειας εργασίας, γραμμένο σε γλώσσα Rust. Δεν χρειάζεται κάποια παραμετροποίηση, λειτουργεί αμέσως μετά την εγκατάσταση. Έχετε τον πλήρη έλεγχο των δεδομένων σας, χωρίς να ανησυχείτε για την ασφάλειά τους. Μπορείτε να χρησιμοποιήσετε τους προκαθορισμένους διακομιστές rendezvous/αναμετάδοσης, [να εγκαταστήσετε τον δικό σας διακομιστή](https://rustdesk.com/server), ή [να αναπτύξετε ένα δικό σας διακομιστή rendezvous/αναμετάδοσης](https://github.com/rustdesk/rustdesk-server-demo). ![image](https://user-images.githubusercontent.com/71636191/171661982-430285f0-2e12-4b1d-9957-4a58e375304d.png) -Το RustDesk ενθαρρύνει τη συνεισφορά όλων. Διαβάστε το [`docs/CONTRIBUTING.md`](docs/CONTRIBUTING.md) για βοήθεια στο πως να ξεκινήσετε. +Το RustDesk ενθαρρύνει τη συνεισφορά όλων. Διαβάστε το [`docs/CONTRIBUTING.md`](CONTRIBUTING.md) για βοήθεια στο πως να ξεκινήσετε. [**Συχνές ερωτήσεις**](https://github.com/rustdesk/rustdesk/wiki/FAQ) diff --git a/docs/README-HU.md b/docs/README-HU.md index 07c0fdc1d..82d1d5550 100644 --- a/docs/README-HU.md +++ b/docs/README-HU.md @@ -9,9 +9,9 @@ Kell a segítséged, hogy lefordítsuk ezt a README-t, a RustDesk UI-t és a Dokumentációt az anyanyelvedre

-Beszélgess velünk: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) +Beszélgess velünk: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk) -[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/I2I04VU09) +[![RustDesk Server Pro](https://img.shields.io/badge/RustDesk%20Server%20Pro-Speci%C3%A1lis%20Funkci%C3%B3k-blue)](https://rustdesk.com/pricing.html) A RustDesk egy távoli elérésű asztali szoftver, Rust-ban írva. Működik mindenféle konfiguráció nélkül, feltelepítéssel, vagy anélkül. Az adataidat teljesen te kezeled, nincs szükség aggódásra a harmadik felek miatt. Használhatod a RustDesk punblikus randevú/relay szervereit, [hostolhatsz sajátot](https://rustdesk.com/server), vagy akár [írhatsz is egyet](https://github.com/rustdesk/rustdesk-server-demo). diff --git a/docs/README-ID.md b/docs/README-ID.md index ae4bbfb75..7b63d0e7e 100644 --- a/docs/README-ID.md +++ b/docs/README-ID.md @@ -9,9 +9,9 @@ Kami membutuhkan bantuanmu untuk menterjemahkan file README dan RustDesk UI ke Bahasa Indonesia

-Mari mengobrol bersama kami: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) +Mari mengobrol bersama kami: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk) -[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/I2I04VU09) +[![RustDesk Server Pro](https://img.shields.io/badge/RustDesk%20Server%20Pro-Fitur%20Lanjutan-blue)](https://rustdesk.com/pricing.html) [![Open Bounties](https://img.shields.io/endpoint?url=https%3A%2F%2Fconsole.algora.io%2Fapi%2Fshields%2Frustdesk%2Fbounties%3Fstatus%3Dopen)](https://console.algora.io/org/rustdesk/bounties?status=open) diff --git a/docs/README-IT.md b/docs/README-IT.md index 4459f2423..0393ee6c7 100644 --- a/docs/README-IT.md +++ b/docs/README-IT.md @@ -9,9 +9,9 @@ Abbiamo bisogno del tuo aiuto per tradurre questo file README e la UI RustDesk nella tua lingua nativa

-Chatta con noi su: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) +Chatta con noi su: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk) -[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/I2I04VU09) +[![RustDesk Server Pro](https://img.shields.io/badge/RustDesk%20Server%20Pro-Funzionalit%C3%A0%20Avanzate-blue)](https://rustdesk.com/pricing.html) [![Bounties aperti](https://img.shields.io/endpoint?url=https%3A%2F%2Fconsole.algora.io%2Fapi%2Fshields%2Frustdesk%2Fbounties%3Fstatus%3Dopen)](https://console.algora.io/org/rustdesk/bounties?status=open) @@ -164,6 +164,10 @@ Assicurati di eseguire questi comandi dalla radice del repository RustDesk, altr - **[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:**
+> Gli sviluppatori di RustDesk non approvano né supportano alcun uso non etico o illegale di questo software. L'uso improprio, come l'accesso non autorizzato, il controllo o l'invasione della privacy, è strettamente contro le nostre linee guida. Gli autori non sono responsabili per qualsiasi uso improprio dell'applicazione. + ## Schermate ![image](https://user-images.githubusercontent.com/71636191/113112362-ae4deb80-923b-11eb-957d-ff88daad4f06.png) diff --git a/docs/README-JP.md b/docs/README-JP.md index f1245fa05..c9f75640b 100644 --- a/docs/README-JP.md +++ b/docs/README-JP.md @@ -5,20 +5,20 @@ DockerStructureSnapshot
- [Українська] | [česky] | [中文] | [Magyar] | [Español] | [فارسی] | [Français] | [Deutsch] | [Polski] | [Indonesian] | [Suomi] | [മലയാളം] | [日本語] | [Nederlands] | [Italiano] | [Русский] | [Português (Brasil)] | [Esperanto] | [한국어] | [العربي] | [Tiếng Việt] | [Dansk] | [Ελληνικά] | [Türkçe]
+ [Українська] | [česky] | [中文] | [Magyar] | [Español] | [فارسی] | [Français] | [Deutsch] | [Polski] | [Indonesian] | [Suomi] | [മലയാളം] | [日本語] | [Nederlands] | [Italiano] | [Русский] | [Português (Brasil)] | [Esperanto] | [한국어] | [العربي] | [Tiếng Việt] | [Dansk] | [Ελληνικά] | [Türkçe]
READMEやRustDesk UIRustDesk Docの翻訳者を歓迎します!

-私たちと話す: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) +私たちと話す: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk) -[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/I2I04VU09) +[![RustDesk Server Pro](https://img.shields.io/badge/RustDesk%20Server%20Pro-%E9%AB%98%E5%BA%A6%E3%81%AA%E6%A9%9F%E8%83%BD-blue)](https://rustdesk.com/pricing.html) Rustで書かれた、設定不要ですぐに使えるリモートデスクトップソフトウェアです。自分のデータを完全にコントロールでき、セキュリティの心配もありません。私たちのランデブー/リレーサーバを使うことも、[自分でサーバーをセットアップする](https://rustdesk.com/server) ことも、 [自分でランデブー/リレーサーバを作成する](https://github.com/rustdesk/rustdesk-server-demo)こともできます。 ![image](https://user-images.githubusercontent.com/71636191/171661982-430285f0-2e12-4b1d-9957-4a58e375304d.png) RustDeskは皆さんの貢献を歓迎します。 -貢献の方法については[CONTRIBUTING.md](docs/CONTRIBUTING.md)をご確認ください。 +貢献の方法については[CONTRIBUTING.md](CONTRIBUTING.md)をご確認ください。 [**よくある質問**](https://github.com/rustdesk/rustdesk/wiki/FAQ) @@ -168,6 +168,10 @@ target/release/rustdesk - **[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 +> [!注意] +> **:不正使用に関する免責事項**
+> RustDeskの開発者は、このソフトウェアの非倫理的または違法な使用を容認または支持しません。不正アクセス、不正な制御、またはプライバシーの侵害などの不正使用は、当社のガイドラインに厳密に違反します。開発者は、アプリケーションの不正使用に対して一切の責任を負いません。 + ## スクリーンショット ![Connection Manager](https://github.com/rustdesk/rustdesk/assets/28412477/db82d4e7-c4bc-4823-8e6f-6af7eadf7651) diff --git a/docs/README-KR.md b/docs/README-KR.md index af3c66524..c301fde05 100644 --- a/docs/README-KR.md +++ b/docs/README-KR.md @@ -1,64 +1,84 @@

RustDesk - Your remote desktop
- Servers • - Build • - Docker • - Structure • - Snapshot
- [English] | [Українська] | [česky] | [中文] | [Magyar] | [Español] | [فارسی] | [Français] | [Deutsch] | [Polski] | [Indonesian] | [Suomi] | [മലയാളം] | [日本語] | [Nederlands] | [Italiano] | [Русский] | [Português (Brasil)] | [Esperanto] | [العربي] | [Tiếng Việt] | [Ελληνικά]
- README를 모국어로 번역하기 위한 당신의 도움의 필요합니다. + 빌드 • + Docker • + 구조 • + 스냇샷
+ [English] | [Українська] | [č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]
+ 이 README, RustDesk UIRustDesk 문서를 귀하의 모국어로 번역하는 데 도움이 필요합니다

-Chat with us: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) +> [!Caution] +> **오용 면책 조항:**
+> RustDesk의 개발자는 이 소프트웨어의 비윤리적 또는 불법적인 사용을 묵인하거나 지원하지 않습니다. 무단 액세스, 제어 또는 개인정보 침해와 같은 오용은 엄격하게 당사의 지침에 위배됩니다. 작성자는 응용 프로그램의 오용에 대해 책임을 지지 않습니다. -[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/I2I04VU09) +우리와 채팅: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk) -Rust로 작성되었고, 설정없이 바로 사용할 수 있는 원격 데스트탑 소프트웨어입니다. 자신의 데이터를 완전히 컨트롤할 수 있고, 보안의 염려도 없습니다. 우리의 rendezvous/relay 서버를 사용해도, [스스로 설정](https://rustdesk.com/server)하는 것도, [스스로 rendezvous/relay 서버를 작성할 수도 있습니다](https://github.com/rustdesk/rustdesk-server-demo). +[![RustDesk Server Pro](https://img.shields.io/badge/RustDesk%20Server%20Pro-%EA%B3%A0%EA%B8%89%20%EA%B8%B0%EB%8A%A5-blue)](https://rustdesk.com/pricing.html) + +또 하나의 원격 데스크톱 솔루션으로, Rust로 작성되었습니다. 별도의 설정 없이 바로 사용할 수 있습니다. 데이터에 대한 완전한 통제권을 가지며 보안에 대한 걱정이 없습니다. 저희 랑데부/릴레이 서버를 사용하거나, [직접 설정](https://rustdesk.com/server)하거나, [자신만의 랑데부/릴레이 서버를 작성](https://github.com/rustdesk/rustdesk-server-demo)할 수 있습니다. ![image](https://user-images.githubusercontent.com/71636191/171661982-430285f0-2e12-4b1d-9957-4a58e375304d.png) -RustDesk는 모든 기여를 환영합니다. 기여하고자 한다면 [`docs/CONTRIBUTING.md`](CONTRIBUTING.md)를 참조해주세요. +RustDesk는 모든 분들의 기여를 환영합니다. 시작하는 데 도움이 필요하면 [CONTRIBUTING-KR.md](CONTRIBUTING-KR.md)를 참조하세요. -[**RustDesk는 어떻게 작동하는가?**](https://github.com/rustdesk/rustdesk/wiki/How-does-RustDesk-work%3F) +[**자주 묻는 질문**](https://github.com/rustdesk/rustdesk/wiki/FAQ) -[**BINARY DOWNLOAD**](https://github.com/rustdesk/rustdesk/releases) +[**바이너리 다운로드**](https://github.com/rustdesk/rustdesk/releases) -## 의존관계 +[**개발자 빌드**](https://github.com/rustdesk/rustdesk/releases/tag/nightly) -데스크탑판에는 GUI에 [sciter](https://sciter.com/)가 사용되었습니다. sciter dynamic library 를 다운로드해주세요. +[Get it on F-Droid](https://f-droid.org/en/packages/com.carriez.flutter_hbb) +[Get it on Flathub](https://flathub.org/apps/com.rustdesk.RustDesk) + +## 종속성 + +데스크톱 버전은 GUI로 Flutter 또는 Sciter (더 이상 지원되지 않음)를 사용하며, 이 자습서는 시작하기 더 쉽고 친숙한 Sciter 전용입니다. Flutter 버전 빌드는 [CI](https://github.com/rustdesk/rustdesk/blob/master/.github/workflows/flutter-build.yml)을 확인하세요. + +Sciter 동적 라이브러리를 직접 다운로드하세요. [Windows](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.win/x64/sciter.dll) | [Linux](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so) | -[MacOS](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.osx/libsciter.dylib) +[macOS](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.osx/libsciter.dylib) -모바일 버전은 Flutter를 사용합니다. 데스크탑 또한 Sciter에서 Flutter로 마이그레이션할 예정입니다. +## 빌드를 위한 원시 단계 -## 빌드 순서 +- Rust 개발 환경과 C++ 빌드 환경을 준비합니다 -- Rust 개발환경, C++ 빌드 환경을 준비합니다. - -- [vcpkg](https://github.com/microsoft/vcpkg) 설치하고 `VCPKG_ROOT` 환경변수를 정확히 설정합니다. +- [vcpkg](https://github.com/microsoft/vcpkg)를 설치하고 `VCPKG_ROOT` 환경 변수를 올바르게 설정합니다 - Windows: vcpkg install libvpx:x64-windows-static libyuv:x64-windows-static opus:x64-windows-static aom:x64-windows-static - - Linux/MacOS: vcpkg install libvpx libyuv opus aom + - Linux/macOS: vcpkg install libvpx libyuv opus aom -- run `cargo run` +- `cargo run` 실행 -## [Build](https://rustdesk.com/docs/en/dev/build/) +## [빌드](https://rustdesk.com/docs/en/dev/build/) -## Linux에서 빌드 순서 +## Linux에서 빌드하는 방법 ### Ubuntu 18 (Debian 10) ```sh -sudo apt install -y 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 +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 +``` + +### 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 ``` ### 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 +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 ``` ### Arch (Manjaro) @@ -67,7 +87,7 @@ sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb- sudo pacman -Syu --needed unzip git cmake gcc curl wget yasm nasm zip make pkg-config clang gtk3 xdotool libxcb libxfixes alsa-lib pipewire ``` -### Install vcpkg +### vcpkg 설치 ```sh git clone https://github.com/microsoft/vcpkg @@ -79,7 +99,7 @@ export VCPKG_ROOT=$HOME/vcpkg vcpkg/vcpkg install libvpx libyuv opus aom ``` -### Fix libvpx (For Fedora) +### libvpx 수정 (Fedora용) ```sh cd vcpkg/buildtrees/libvpx/src @@ -92,12 +112,12 @@ cp libvpx.a $HOME/vcpkg/installed/x64-linux/lib/ cd ``` -### Build +### 빌드 ```sh curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh source $HOME/.cargo/env -git clone https://github.com/rustdesk/rustdesk +git clone --recurse-submodules https://github.com/rustdesk/rustdesk cd rustdesk mkdir -p target/debug wget https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so @@ -105,56 +125,58 @@ mv libsciter-gtk.so target/debug VCPKG_ROOT=$HOME/vcpkg cargo run ``` -## Docker에 빌드하는 방법 +## Docker로 빌드하는 방법 -레포지토리를 클론하고, Docker 컨테이너 구성하는 것으로 시작합니다. +먼저 리포지토리를 복제하고 Docker 컨테이너를 빌드합니다: ```sh git clone https://github.com/rustdesk/rustdesk cd rustdesk +git submodule update --init --recursive docker build -t "rustdesk-builder" . ``` -이후, 애플리케이션을 빌드할 필요가 있을 때마다, 이하의 커맨드를 실행합니다. +그런 다음 응용 프로그램을 빌드해야 할 때마다 다음 명령을 실행합니다: ```sh docker run --rm -it -v $PWD:/home/user/rustdesk -v rustdesk-git-cache:/home/user/.cargo/git -v rustdesk-registry-cache:/home/user/.cargo/registry -e PUID="$(id -u)" -e PGID="$(id -g)" rustdesk-builder ``` -첫 빌드에서는 의존관계가 캐시될 때까지 시간이 걸릴 수 있습니다만, 이후의 빌드때는 빨라집니다. 더불어 빌드 커맨드에 다른 인수를 지정할 필요가 있다면, 커맨드 끝에 있는 `` 에 지정할 수 있습니다. 예를 들어 최적화된 출시 버전을 빌드하고 싶다면 이렇게 상기한 커맨드 뒤에 `--release` 를 붙여 실행합니다. 성공했다면 실행파일은 시스템 타겟 폴더에 담겨지고, 다음 커맨드로 실행할 수 있습니다. +첫 번째 빌드는 종속성이 캐시되기까지 시간이 오래 걸릴 수 있으며, 이후 빌드는 더 빨라집니다. 또한 빌드 명령에 다른 인수를 지정해야 하는 경우 명령 끝의 `` 위치에 인수를 지정할 수 있습니다. 예를 들어 최적화된 릴리스 버전을 빌드하려면 위의 명령 뒤에 `--release`를 추가하면 됩니다. 결과 실행 파일은 시스템의 대상 폴더에서 사용할 수 있으며 실행할 수 있습니다:: ```sh target/debug/rustdesk ``` -혹은 출시용 실행 파일을 실행할 수도 있습니다. +또는 릴리스 실행 파일을 실행하는 경우: ```sh target/release/rustdesk ``` -커맨드를 RustDesk 리포지토리 루트에서 실행한다는 것을 확인해주세요. 그렇게 하지 않으면 애플리케이션이 필요한 리소스를 발견하지 못 할 가능성이 있습니다. 또한 `install`, `run` 같은 cargo 서브커맨드는 호스트가 아니라 컨테이너 프로그램을 설치, 실행을 위함이므로 현재 이 방법은 지원하지 않다는 점을 유념해주시길 바랍니다. +RustDesk 리포지토리의 루트에서 이러한 명령을 실행하고 있는지 확인하세요. 그렇지 않으면 응용 프로그램이 필요한 리소스를 찾지 못할 수 있습니다. 또한 `install` 또는 `run` 과 같은 다른 cargo 하위 명령은 호스트가 아닌 컨테이너 내부에 프로그램을 설치하거나 실행하므로 현재 이 방법을 통해 지원되지 않는다는 점에 유의하세요. -## File Structure +## 파일 구조 -- **[libs/hbb_common](https://github.com/rustdesk/rustdesk/tree/master/libs/hbb_common)**: 비디오 코덱, 설정, tcp/udp 랩퍼, protobuf, 파일 전송을 위한 fs 함수, 그 외 유틸리티 함수 -- **[libs/scrap](https://github.com/rustdesk/rustdesk/tree/master/libs/scrap)**: 화면 캡처 -- **[libs/enigo](https://github.com/rustdesk/rustdesk/tree/master/libs/enigo)**: 플랫폼 고유 키보드/마우스 컨트롤 -- **[src/ui](https://github.com/rustdesk/rustdesk/tree/master/src/ui)**: GUI -- **[src/server](https://github.com/rustdesk/rustdesk/tree/master/src/server)**: 오디오, 클립보드, 입력, 비디오 서비스 그리고 네트워크 연결 -- **[src/client.rs](https://github.com/rustdesk/rustdesk/tree/master/src/client.rs)**: 피어 접속 시작 -- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: [rustdesk-server](https://github.com/rustdesk/rustdesk-server)와 통신해서 리모트 다이렉트(TCP hole punching) 혹은 relayed 접속 -- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: 플랫폼 고유의 코드 -- **[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 +- **[libs/hbb_common](https://github.com/rustdesk/rustdesk/tree/master/libs/hbb_common)**: 비디오 코덱, 구성, tcp/udp wrapper, protobuf, 파일 전송을 위한 fs 함수 및 기타 유틸리티 함수 +- **[libs/scrap](https://github.com/rustdesk/rustdesk/tree/master/libs/scrap)**: 화면 캡쳐 +- **[libs/enigo](https://github.com/rustdesk/rustdesk/tree/master/libs/enigo)**: 플랫폼별 키보드/마우스 제어 +- **[libs/clipboard](https://github.com/rustdesk/rustdesk/tree/master/libs/clipboard)**: Windows, Linux, macOS용 파일 복사 및 붙여넣기 구현 +- **[src/ui](https://github.com/rustdesk/rustdesk/tree/master/src/ui)**: 더 이상 사용되지 않는 Sciter UI (지원 중단) +- **[src/server](https://github.com/rustdesk/rustdesk/tree/master/src/server)**: 오디오/클립보드/입력/비디오 서비스 및 네트워크 연결 +- **[src/client.rs](https://github.com/rustdesk/rustdesk/tree/master/src/client.rs)**: 피어 연결 시작 +- **[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 -## Snapshot +## 스크린샷 -![image](https://user-images.githubusercontent.com/71636191/113112362-ae4deb80-923b-11eb-957d-ff88daad4f06.png) +![Connection Manager](https://github.com/rustdesk/rustdesk/assets/28412477/db82d4e7-c4bc-4823-8e6f-6af7eadf7651) -![image](https://user-images.githubusercontent.com/71636191/113112619-f705a480-923b-11eb-911d-97e984ef52b6.png) +![Connected to a Windows PC](https://github.com/rustdesk/rustdesk/assets/28412477/9baa91e9-3362-4d06-aa1a-7518edcbd7ea) -![image](https://user-images.githubusercontent.com/71636191/113112857-3fbd5d80-923c-11eb-9836-768325faf906.png) +![File Transfer](https://github.com/rustdesk/rustdesk/assets/28412477/39511ad3-aa9a-4f8c-8947-1cce286a46ad) -![image](https://user-images.githubusercontent.com/71636191/135385039-38fdbd72-379a-422d-b97f-33df71fb1cec.png) +![TCP Tunneling](https://github.com/rustdesk/rustdesk/assets/28412477/78e8708f-e87e-4570-8373-1360033ea6c5) diff --git a/docs/README-ML.md b/docs/README-ML.md index 4e92bf736..225d7b952 100644 --- a/docs/README-ML.md +++ b/docs/README-ML.md @@ -9,9 +9,9 @@ ഈ README നിങ്ങളുടെ മാതൃഭാഷയിലേക്ക് വിവർത്തനം ചെയ്യാൻ ഞങ്ങൾക്ക് നിങ്ങളുടെ സഹായം ആവശ്യമാണ്

-ഞങ്ങളുമായി ചാറ്റ് ചെയ്യുക: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) +ഞങ്ങളുമായി ചാറ്റ് ചെയ്യുക: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk) -[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/I2I04VU09) +[![RustDesk Server Pro](https://img.shields.io/badge/RustDesk%20Server%20Pro-%E0%B4%B5%E0%B4%BF%E0%B4%95%E0%B4%B8%E0%B4%BF%E0%B4%A4%20%E0%B4%B8%E0%B4%B5%E0%B4%BF%E0%B4%B6%E0%B5%87%E0%B4%B7%E0%B4%A4%E0%B4%95%E0%B5%BE-blue)](https://rustdesk.com/pricing.html) റസ്റ്റിൽ എഴുതിയ മറ്റൊരു റിമോട്ട് ഡെസ്ക്ടോപ്പ് സോഫ്റ്റ്‌വെയർ. ബോക്‌സിന് പുറത്ത് പ്രവർത്തിക്കുന്നു, കോൺഫിഗറേഷൻ ആവശ്യമില്ല. സുരക്ഷയെക്കുറിച്ച് ആശങ്കകളൊന്നുമില്ലാതെ, നിങ്ങളുടെ ഡാറ്റയുടെ പൂർണ്ണ നിയന്ത്രണം നിങ്ങൾക്കുണ്ട്. നിങ്ങൾക്ക് ഞങ്ങളുടെ rendezvous/relay സെർവർ ഉപയോഗിക്കാം, [സ്വന്തമായി സജ്ജീകരിക്കുക](https://rustdesk.com/server), അല്ലെങ്കിൽ [നിങ്ങളുടെ സ്വന്തം rendezvous/relay സെർവർ എഴുതുക](https://github.com/rustdesk/rustdesk-server-demo). diff --git a/docs/README-NL.md b/docs/README-NL.md index 44fedd5af..45d68b20e 100644 --- a/docs/README-NL.md +++ b/docs/README-NL.md @@ -9,9 +9,9 @@ Wij hebben uw hulp nodig om dit README bestand te vertalen, RustDesk UI en Doc naar uw moedertaal

-Chat met ons: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) +Chat met ons: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk) -[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/I2I04VU09) +[![RustDesk Server Pro](https://img.shields.io/badge/RustDesk%20Server%20Pro-Geavanceerde%20Functies-blue)](https://rustdesk.com/pricing.html) Alweer een andere programma voor -bureaublad op afstand-, geschreven in Rust. Werkt -out of the box-, geen configuratie nodig. U heeft volledige controle over uw gegevens, en hoeft zich geen zorgen te maken over de beveiliging. U kunt onze rendez-vous/relay server gebruiken, [je eigen server opzetten](https://rustdesk.com/blog/id-relay-set), of [je eigen rendez-vous/relay-server schrijven](https://github.com/rustdesk/rustdesk-server-demo). diff --git a/docs/README-NO.md b/docs/README-NO.md new file mode 100644 index 000000000..1352e8aed --- /dev/null +++ b/docs/README-NO.md @@ -0,0 +1,177 @@ +

+ RustDesk - Your remote desktop
+ Servere • + Build • + Docker • + Struktur • + Snapshot
+ [Українська] | [č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
+ Vi trenger din hjelp til å oversette denne README-en, RustDesk UI og RustDesk Doc tid ditt morsmål +

+ +Snakk med oss: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk) + +[![RustDesk Server Pro](https://img.shields.io/badge/RustDesk%20Server%20Pro-Avanserte%20Funksjoner-blue)](https://rustdesk.com/pricing.html) + +Enda en annen fjernstyrt desktop programvare, skrevet i Rust. Virker rett ut av pakken, ingen konfigurasjon nødvendig. Du har full kontroll over din data, uten beskymring for sikkerhet. Du kan bruke vår rendezvous_mediator/relay server, [sett opp din egen](https://rustdesk.com/server), eller [skriv din egen rendezvous_mediator/relay server](https://github.com/rustdesk/rustdesk-server-demo). + +![image](https://user-images.githubusercontent.com/71636191/171661982-430285f0-2e12-4b1d-9957-4a58e375304d.png) + +RustDesk er velkommen for bidrag fra alle. Se [CONTRIBUTING.md](CONTRIBUTING-NO.md) for hjelp med oppstart. + +[**FAQ**](https://github.com/rustdesk/rustdesk/wiki/FAQ) + +[**BINARY NEDLASTING**](https://github.com/rustdesk/rustdesk/releases) + +[**NIGHTLY BUILD**](https://github.com/rustdesk/rustdesk/releases/tag/nightly) + +[Få det på F-Droid](https://f-droid.org/en/packages/com.carriez.flutter_hbb) +[Få det på Flathub](https://flathub.org/apps/com.rustdesk.RustDesk) + +## Avhengigheter + +Desktop versjoner bruker Flutter eller Sciter (avviklet) for GUI, denne veiledningen er bare for Sciter, grunnet att det er letter og en mer venlig start. Skjekk ut vår [CI](https://github.com/rustdesk/rustdesk/blob/master/.github/workflows/flutter-build.yml) for bygging av Flutter versjonen. + +Venligst last ned Sciters dynamiske bibliotek selv. + +[Windows](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.win/x64/sciter.dll) | +[Linux](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so) | +[macOS](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.osx/libsciter.dylib) + +## Rå steg for bygging + +- Klargjør ditt Rust development env og C++ build env + +- Installer [vcpkg](https://github.com/microsoft/vcpkg), og koriger `VCPKG_ROOT` env vaiabelen + + - Windows: vcpkg install libvpx:x64-windows-static libyuv:x64-windows-static opus:x64-windows-static aom:x64-windows-static + - Linux/macOS: vcpkg install libvpx libyuv opus aom + +- Kjør `cargo run` + +## [Bygg](https://rustdesk.com/docs/en/dev/build/) + +## Hvordan Bygge til Linux + +### 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 +``` + +### 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 +``` + +### 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 +``` + +### Arch (Manjaro) + +```sh +sudo pacman -Syu --needed unzip git cmake gcc curl wget yasm nasm zip make pkg-config clang gtk3 xdotool libxcb libxfixes alsa-lib pipewire +``` + +### Installer vcpkg + +```sh +git clone https://github.com/microsoft/vcpkg +cd vcpkg +git checkout 2023.04.15 +cd .. +vcpkg/bootstrap-vcpkg.sh +export VCPKG_ROOT=$HOME/vcpkg +vcpkg/vcpkg install libvpx libyuv opus aom +``` + +### Fiks libvpx (For Fedora) + +```sh +cd vcpkg/buildtrees/libvpx/src +cd * +./configure +sed -i 's/CFLAGS+=-I/CFLAGS+=-fPIC -I/g' Makefile +sed -i 's/CXXFLAGS+=-I/CXXFLAGS+=-fPIC -I/g' Makefile +make +cp libvpx.a $HOME/vcpkg/installed/x64-linux/lib/ +cd +``` + +### Bygg + +```sh +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +source $HOME/.cargo/env +git clone https://github.com/rustdesk/rustdesk +cd rustdesk +mkdir -p target/debug +wget https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so +mv libsciter-gtk.so target/debug +VCPKG_ROOT=$HOME/vcpkg cargo run +``` + +## Hvordan bygge med Docker + +Start med å klone repositoret og bygg Docker konteineren: + +```sh +git clone https://github.com/rustdesk/rustdesk +cd rustdesk +docker build -t "rustdesk-builder" . +``` + +Deretter, hver gang du trenger å bygge applikasjonen, kjør følgene kommando: + +```sh +docker run --rm -it -v $PWD:/home/user/rustdesk -v rustdesk-git-cache:/home/user/.cargo/git -v rustdesk-registry-cache:/home/user/.cargo/registry -e PUID="$(id -u)" -e PGID="$(id -g)" rustdesk-builder +``` + +Det kan ta lengere tid før avhengighetene blir bufret første gang du bygger, senere bygg er raskere. Hvis du trenger å spesifisere forkjellige argumenter til bygge kommandoen, kan du gjøre det på slutten av kommandoen ved `` feltet. For eksempel, hvis du ville bygge en optimalisert release versjon, ville du kjørt kommandoen over fulgt `--release`. Den kjørbare filen vill være tilgjengelig i mål direktive på ditt system, og kan bli kjørt med: + +```sh +target/debug/rustdesk +``` + +Eller, hvis du kjører ett release program: + +```sh +target/release/rustdesk +``` + +Venligst pass på att du kjører disse kommandoene fra roten av RustDesk repositoret, eller kan det hende att applikasjon ikke finner de riktige ressursene. Pass også på att andre cargo subkommandoer som for eksempel `install` eller `run` ikke støttes med denne metoden da de vill installere eller kjøre programmet i konteineren istedet for verten. + +## Fil Struktur + +- **[libs/hbb_common](https://github.com/rustdesk/rustdesk/tree/master/libs/hbb_common)**: video kodek, configurasjon, tcp/udp innpakning, protobuf, fs funksjon for fil overføring, og noen andre verktøy funksjoner +- **[libs/scrap](https://github.com/rustdesk/rustdesk/tree/master/libs/scrap)**: skjermfangst +- **[libs/enigo](https://github.com/rustdesk/rustdesk/tree/master/libs/enigo)**: platform spesefik keyboard/mus kontroll +- **[libs/clipboard](https://github.com/rustdesk/rustdesk/tree/master/libs/clipboard)**: fil kopi og innliming implementasjon for Windows, Linux, macOS. +- **[src/ui](https://github.com/rustdesk/rustdesk/tree/master/src/ui)**: foreldret Sciter UI (avviklet) +- **[src/server](https://github.com/rustdesk/rustdesk/tree/master/src/server)**: lyd/utklippstavle/input/video tjenester, og internett tilkobling +- **[src/client.rs](https://github.com/rustdesk/rustdesk/tree/master/src/client.rs)**: start en peer tilkobling +- **[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 + +![Tilkoblings Manager](https://github.com/rustdesk/rustdesk/assets/28412477/db82d4e7-c4bc-4823-8e6f-6af7eadf7651) + +![Koble til Windows PC](https://github.com/rustdesk/rustdesk/assets/28412477/9baa91e9-3362-4d06-aa1a-7518edcbd7ea) + +![Fil Overføring](https://github.com/rustdesk/rustdesk/assets/28412477/39511ad3-aa9a-4f8c-8947-1cce286a46ad) + +![TCP Tunneling](https://github.com/rustdesk/rustdesk/assets/28412477/78e8708f-e87e-4570-8373-1360033ea6c5) + diff --git a/docs/README-PL.md b/docs/README-PL.md index 4d3464d41..437682a9c 100644 --- a/docs/README-PL.md +++ b/docs/README-PL.md @@ -9,11 +9,13 @@ Potrzebujemy twojej pomocy w tłumaczeniu README na twój ojczysty język

-Porozmawiaj z nami na: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) +Porozmawiaj z nami na: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk) -[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/I2I04VU09) +[![RustDesk Server Pro](https://img.shields.io/badge/RustDesk%20Server%20Pro-Zaawansowane%20Funkcje-blue)](https://rustdesk.com/pricing.html) -Kolejny program do zdalnego pulpitu, napisany w Rust. Działa od samego początku, nie wymaga konfiguracji. Masz pełną kontrolę nad swoimi danymi, bez obaw o bezpieczeństwo. Możesz skorzystać z naszego darmowego serwera publicznego, [skonfigurować własny](https://rustdesk.com/server), lub [napisać własny serwer](https://github.com/rustdesk/rustdesk-server-demo). +## O projekcie + +RustDesk to wieloplatformowe oprogramowanie do zdalnego pulpitu, napisane w języku Rust, zaprojektowane z myślą o prostocie wdrożenia, bezpieczeństwie i pełnej kontroli użytkownika nad danymi. Aplikacja działa od razu po uruchomieniu i nie wymaga skomplikowanej konfiguracji. Możesz skorzystać z naszego darmowego serwera publicznego, [skonfigurować własny](https://rustdesk.com/server), lub [napisać własny serwer](https://github.com/rustdesk/rustdesk-server-demo). ![image](https://user-images.githubusercontent.com/71636191/171661982-430285f0-2e12-4b1d-9957-4a58e375304d.png) @@ -31,7 +33,7 @@ RustDesk zaprasza do współpracy każdego. Zobacz [`docs/CONTRIBUTING-PL.md`](C ## Zależności -Wersje desktopowe używają [sciter](https://sciter.com/) dla GUI, proszę pobrać samodzielnie bibliotekę sciter. +Wersje desktopowe korzystają z biblioteki [sciter](https://sciter.com/) jako silnika GUI. Bibliotekę Sciter należy pobrać i zainstalować samodzielnie. [Windows](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.win/x64/sciter.dll) | [Linux](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so) | @@ -164,3 +166,4 @@ Upewnij się, że uruchamiasz te polecenia z katalogu głównego repozytorium Ru ![image](https://user-images.githubusercontent.com/71636191/113112857-3fbd5d80-923c-11eb-9836-768325faf906.png) ![image](https://user-images.githubusercontent.com/71636191/135385039-38fdbd72-379a-422d-b97f-33df71fb1cec.png) + diff --git a/docs/README-PTBR.md b/docs/README-PTBR.md index 64c5ae001..6c3e6b99f 100644 --- a/docs/README-PTBR.md +++ b/docs/README-PTBR.md @@ -9,9 +9,9 @@ Precisamos de sua ajuda para traduzir este README e a UI do RustDesk para sua língua nativa

-Converse conosco: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) +Converse conosco: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk) -[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/I2I04VU09) +[![RustDesk Server Pro](https://img.shields.io/badge/RustDesk%20Server%20Pro-Recursos%20Avan%C3%A7ados-blue)](https://rustdesk.com/pricing.html) Mais um software de desktop remoto, escrito em Rust. Funciona por padrão, sem necessidade de configuração. Você tem completo controle de seus dados, sem se preocupar com segurança. Você pode usar nossos servidores de rendezvous/relay, [configurar seu próprio](https://rustdesk.com/server), ou [escrever seu próprio servidor de rendezvous/relay](https://github.com/rustdesk/rustdesk-server-demo). @@ -137,6 +137,10 @@ Por favor verifique que está executando estes comandos da raiz do repositório - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Comunicação com [rustdesk-server](https://github.com/rustdesk/rustdesk-server), aguardar pela conexão remota direta (TCP hole punching) ou conexão indireta (relayed) - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: código específico a cada plataforma +> [!Cuidadob] +> **Aviso de uso indevido:**
+> Os desenvolvedores do RustDesk não aprovam nem apoiam qualquer uso antiético ou ilegal deste software. O uso indevido, como acesso não autorizado, controle ou invasão de privacidade, é estritamente contra nossas diretrizes. Os autores não são responsáveis por qualquer uso indevido da aplicação. + ## Screenshots ![image](https://user-images.githubusercontent.com/71636191/113112362-ae4deb80-923b-11eb-957d-ff88daad4f06.png) diff --git a/docs/README-RO.md b/docs/README-RO.md new file mode 100644 index 000000000..be7ecf164 --- /dev/null +++ b/docs/README-RO.md @@ -0,0 +1,181 @@ +

+ RustDesk - desktopul tău la distanță
+ Construire • + Docker • + Structură • + Capturi
+ [Українська] | [č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ă]
+ Avem nevoie de ajutorul tău pentru a traduce acest README, RustDesk UI și RustDesk Doc în limba ta maternă +

+ +> [!Atenție] +> **Declinare de responsabilitate privind utilizarea abuzivă:**
+> Dezvoltatorii RustDesk nu susțin sau aprobă utilizarea neetică sau ilegală a acestui software. Utilizarea abuzivă, cum ar fi accesul neautorizat, controlul sau invadarea intimității, este strict împotriva regulilor noastre. Autorii nu sunt responsabili pentru utilizarea necorespunzătoare a aplicației. + + +Conversați cu noi: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk) + +[![RustDesk Server Pro](https://img.shields.io/badge/RustDesk%20Server%20Pro-Advanced%20Features-blue)](https://rustdesk.com/pricing.html) + +Încă o soluție de desktop la distanță scrisă în Rust. Funcționează imediat, fără configurare necesară. Ai control total asupra datelor tale, fără probleme de securitate. Poți folosi serverul nostru de rendezvous/relay, [să-ți configurezi propriul server](https://rustdesk.com/server) sau [să scrii propriul server de rendezvous/relay](https://github.com/rustdesk/rustdesk-server-demo). + +![imagine](https://user-images.githubusercontent.com/71636191/171661982-430285f0-2e12-4b1d-9957-4a58e375304d.png) + +RustDesk primește contribuții de la oricine. Vezi [CONTRIBUTING.md](../docs/CONTRIBUTING.md) pentru ajutor la început. + +[**ÎNTREBĂRI FRECVENTE (FAQ)**](https://github.com/rustdesk/rustdesk/wiki/FAQ) + +[**DESCĂRCARE BINARE**](https://github.com/rustdesk/rustdesk/releases) + +[**BUILD NIGHTLY**](https://github.com/rustdesk/rustdesk/releases/tag/nightly) + +[Get it on F-Droid](https://f-droid.org/en/packages/com.carriez.flutter_hbb) +[Get it on Flathub](https://flathub.org/apps/com.rustdesk.RustDesk) + +## Dependențe + +Versiunile desktop folosesc Flutter sau Sciter (depreciat) pentru interfață; acest ghid este pentru Sciter doar, deoarece este mai ușor și mai prietenos pentru început. Vezi [CI](https://github.com/rustdesk/rustdesk/blob/master/.github/workflows/flutter-build.yml) pentru construire cu Flutter. + +Te rugăm să descarci singur librăria dinamică Sciter. + +[Windows](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.win/x64/sciter.dll) | +[Linux](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so) | +[macOS](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.osx/libsciter.dylib) + +## Pași pentru construire (Raw Steps to build) + +- Pregătește mediul de dezvoltare Rust și mediul de construire C++ + +- Instalează [vcpkg](https://github.com/microsoft/vcpkg) și setează corect variabila de mediu `VCPKG_ROOT` + + - Windows: vcpkg install libvpx:x64-windows-static libyuv:x64-windows-static opus:x64-windows-static aom:x64-windows-static + - Linux/macOS: vcpkg install libvpx libyuv opus aom + +- rulează `cargo run` + +## [Construire](https://rustdesk.com/docs/en/dev/build/) + +## Cum se construiește pe Linux + +### 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 +``` + +### 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 +``` + +### 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 +``` + +### Arch (Manjaro) + +```sh +sudo pacman -Syu --needed unzip git cmake gcc curl wget yasm nasm zip make pkg-config clang gtk3 xdotool libxcb libxfixes alsa-lib pipewire +``` + +### Instalează vcpkg + +```sh +git clone https://github.com/microsoft/vcpkg +cd vcpkg +git checkout 2023.04.15 +cd .. +vcpkg/bootstrap-vcpkg.sh +export VCPKG_ROOT=$HOME/vcpkg +vcpkg/vcpkg install libvpx libyuv opus aom +``` + +### Repară libvpx (Pentru Fedora) + +```sh +cd vcpkg/buildtrees/libvpx/src +cd * +./configure +sed -i 's/CFLAGS+=-I/CFLAGS+=-fPIC -I/g' Makefile +sed -i 's/CXXFLAGS+=-I/CXXFLAGS+=-fPIC -I/g' Makefile +make +cp libvpx.a $HOME/vcpkg/installed/x64-linux/lib/ +cd +``` + +### Build + +```sh +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +source $HOME/.cargo/env +git clone --recurse-submodules https://github.com/rustdesk/rustdesk +cd rustdesk +mkdir -p target/debug +wget https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so +mv libsciter-gtk.so target/debug +VCPKG_ROOT=$HOME/vcpkg cargo run +``` + +## Cum să construiești cu Docker + +Începe prin clonarea repository-ului și construirea imaginii Docker: + +```sh +git clone https://github.com/rustdesk/rustdesk +cd rustdesk +git submodule update --init --recursive +docker build -t "rustdesk-builder" . +``` + +Apoi, de fiecare dată când trebuie să construiești aplicația, rulează comanda următoare: + +```sh +docker run --rm -it -v $PWD:/home/user/rustdesk -v rustdesk-git-cache:/home/user/.cargo/git -v rustdesk-registry-cache:/home/user/.cargo/registry -e PUID="$(id -u)" -e PGID="$(id -g)" rustdesk-builder +``` + +Reține că prima construire poate dura mai mult până când dependențele sunt în cache; construirile ulterioare vor fi mai rapide. De asemenea, dacă trebuie să specifici argumente diferite comenzii de build, le poți adăuga la finalul comenzii în poziția ``. De exemplu, pentru a construi o versiune optimizată de release, adaugă `--release`. Executabilul rezultat va fi disponibil în folderul `target` pe sistemul tău, și poate fi rulat cu: + +```sh +target/debug/rustdesk +``` + +Sau, dacă rulezi un executabil release: + +```sh +target/release/rustdesk +``` + +Asigură-te că rulezi aceste comenzi din rădăcina repository-ului RustDesk, altfel aplicația poate să nu găsească resursele necesare. De asemenea, reține că alte subcomenzi cargo, cum ar fi `install` sau `run`, nu sunt acceptate în prezent prin această metodă, deoarece ar instala sau rula programul în interiorul containerului în loc de gazdă. + +## Structura fișierelor + +- **[libs/hbb_common](https://github.com/rustdesk/rustdesk/tree/master/libs/hbb_common)**: codec video, config, wrapper tcp/udp, protobuf, funcții fs pentru transfer de fișiere și alte funcții utilitare +- **[libs/scrap](https://github.com/rustdesk/rustdesk/tree/master/libs/scrap)**: capturare ecran +- **[libs/enigo](https://github.com/rustdesk/rustdesk/tree/master/libs/enigo)**: control tastatură/mouse specific platformei +- **[libs/clipboard](https://github.com/rustdesk/rustdesk/tree/master/libs/clipboard)**: implementare copy/paste pentru fișiere pentru Windows, Linux, macOS. +- **[src/ui](https://github.com/rustdesk/rustdesk/tree/master/src/ui)**: interfață Sciter învechită (depreciată) +- **[src/server](https://github.com/rustdesk/rustdesk/tree/master/src/server)**: servicii audio/clipboard/input/video și conexiuni de rețea +- **[src/client.rs](https://github.com/rustdesk/rustdesk/tree/master/src/client.rs)**: inițiază o conexiune peer +- **[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 + +![Connection Manager](https://github.com/rustdesk/rustdesk/assets/28412477/db82d4e7-c4bc-4823-8e6f-6af7eadf7651) + +![Connected to a Windows PC](https://github.com/rustdesk/rustdesk/assets/28412477/9baa91e9-3362-4d06-aa1a-7518edcbd7ea) + +![File Transfer](https://github.com/rustdesk/rustdesk/assets/28412477/39511ad3-aa9a-4f8c-8947-1cce286a46ad) + +![TCP Tunneling](https://github.com/rustdesk/rustdesk/assets/28412477/78e8708f-e87e-4570-8373-1360033ea6c5) diff --git a/docs/README-RU.md b/docs/README-RU.md index 60972efd3..928faad07 100644 --- a/docs/README-RU.md +++ b/docs/README-RU.md @@ -1,42 +1,52 @@

RustDesk - Ваш удаленый рабочий стол
- Servers • - Build • - Docker • - Structure • - Snapshot
+ Первичные шаги для сборки • + Как собрать с помощью Docker • + Структура файлов • + Скриншоты
[English] | [Українська] | [česky] | [中文] | [Magyar] | [Español] | [فارسی] | [Français] | [Deutsch] | [Polski] | [Indonesian] | [Suomi] | [മലയാളം] | [日本語] | [Nederlands] | [Italiano] | [Português (Brasil)] | [Esperanto] | [한국어] | [العربي] | [Tiếng Việt] | [Ελληνικά]
- Нам нужна ваша помощь для перевода этого README RustDesk UI - и документацию RustDesk на ваш родной язык. RustDesk Doc + Нам нужна ваша помощь в переводе этого README, интерфейса RustDesk + и документации RustDesk на ваш родной язык.

-Общение с нами: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) +> [!Caution] +> **Отказ от ответственности за неправомерное использование**
+> Разработчики RustDesk не одобряют и не поддерживают какое-либо неэтичное или незаконное использование данного программного обеспечения. Неправомерное использование (несанкционированный доступ, контроль или вторжение в частную жизнь) строго противоречит нашим правилам. Авторы не несут ответственности за любое неправомерное использование приложения. -[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/I2I04VU09) +Общение с нами: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk) -Еще одно программное обеспечение для удаленного рабочего стола, написанное на Rust. Работает из коробки, не требует настройки. Вы полностью контролируете свои данные, не беспокоясь о безопасности. Вы можете использовать наш сервер ретрансляции, [настроить свой собственный](https://rustdesk.com/server), или [написать свой](https://github.com/rustdesk/rustdesk-server-demo). +[![RustDesk Server Pro](https://img.shields.io/badge/RustDesk%20Server%20Pro-%D0%A0%D0%B0%D1%81%D1%88%D0%B8%D1%80%D0%B5%D0%BD%D0%BD%D1%8B%D0%B5%20%D0%92%D0%BE%D0%B7%D0%BC%D0%BE%D0%B6%D0%BD%D0%BE%D1%81%D1%82%D0%B8-blue)](https://rustdesk.com/pricing.html) + +Ещё одно программное обеспечение для удаленного рабочего стола, написанное на Rust. Работает из коробки, настройки не требует. Вы полностью контролируете свои данные, не беспокоясь о безопасности. Вы можете использовать наш сервер ретрансляции, [настроить свой собственный](https://rustdesk.com/server), или [написать свой](https://github.com/rustdesk/rustdesk-server-demo). ![image](https://user-images.githubusercontent.com/71636191/171661982-430285f0-2e12-4b1d-9957-4a58e375304d.png) RustDesk приветствует вклад каждого. Ознакомьтесь с [`docs/CONTRIBUTING-RU.md`](CONTRIBUTING-RU.md) в начале работы для понимания. -[**Как работает RustDesk?**](https://github.com/rustdesk/rustdesk/wiki/How-does-RustDesk-work%3F) +[**Как работает RustDesk?**](https://github.com/rustdesk/rustdesk/wiki/How-does-RustDesk-work%3F) (Документация на английском языке) + +[**Часто задаваемые вопросы**](https://github.com/rustdesk/rustdesk/wiki/FAQ) (Страница на английском языке) [**СКАЧАТЬ ПРИЛОЖЕНИЕ**](https://github.com/rustdesk/rustdesk/releases) -[**ночные сборки (актуальные)**](https://github.com/rustdesk/rustdesk/releases/tag/nightly) +[**НОЧНЫЕ СБОРКИ (Актуальные)**](https://github.com/rustdesk/rustdesk/releases/tag/nightly) -[Get it on F-Droid](https://f-droid.org/en/packages/com.carriez.flutter_hbb) +[Get it on F-Droid](https://f-droid.org/en/packages/com.carriez.flutter_hbb) +[Get it on Flathub](https://flathub.org/apps/com.rustdesk.RustDesk) ## Зависимости -Настольные версии используют [sciter](https://sciter.com/) для графического интерфейса, загрузите динамическую библиотеку sciter самостоятельно. +Для ПК-версии используются библиотеки Flutter или Sciter (устаревшее) для графического интерфейса. Данное руководство подразумевает работу с Sciter, так как он более простой в использовании и с ним легче начать работу. Вы можете также посмотреть на механизм нашего [CI](https://github.com/rustdesk/rustdesk/blob/master/.github/workflows/flutter-build.yml) для сборок на Flutter. + +Загрузите динамическую библиотеку Flutter самостоятельно. [Windows](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.win/x64/sciter.dll) | [Linux](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so) | -[MacOS](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.osx/libsciter.dylib) - -Мобильные версии используют Flutter. В будущем мы перенесем настольную версию со Sciter на Flutter. +[macOS](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.osx/libsciter.dylib) ## Первичные шаги для сборки @@ -45,22 +55,32 @@ RustDesk приветствует вклад каждого. Ознакомьт - Установите [vcpkg](https://github.com/microsoft/vcpkg), и правильно установите переменную `VCPKG_ROOT` - Windows: vcpkg install libvpx:x64-windows-static libyuv:x64-windows-static opus:x64-windows-static aom:x64-windows-static - - Linux/MacOS: vcpkg install libvpx libyuv opus aom + - Linux/macOS: vcpkg install libvpx libyuv opus aom -- Запустите `cargo run` +- Выполните команду `cargo run` + +## [Сборка](https://rustdesk.com/docs/ru/dev/build/) ## Как собрать на Linux ### Ubuntu 18 (Debian 10) ```sh -sudo apt install -y 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 +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 +``` + +### 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 ``` ### 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 +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 ``` ### Arch (Manjaro) @@ -99,7 +119,7 @@ cd ```sh curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh source $HOME/.cargo/env -git clone https://github.com/rustdesk/rustdesk +git clone --recurse-submodules https://github.com/rustdesk/rustdesk cd rustdesk mkdir -p target/debug wget https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so @@ -114,16 +134,17 @@ VCPKG_ROOT=$HOME/vcpkg cargo run ```sh git clone https://github.com/rustdesk/rustdesk cd rustdesk +git submodule update --init --recursive docker build -t "rustdesk-builder" . ``` -Затем каждый раз, когда вам нужно собрать приложение, запускайте следующую команду: +Затем при каждой сборке приложения выполняйте следующую команду: ```sh docker run --rm -it -v $PWD:/home/user/rustdesk -v rustdesk-git-cache:/home/user/.cargo/git -v rustdesk-registry-cache:/home/user/.cargo/registry -e PUID="$(id -u)" -e PGID="$(id -g)" rustdesk-builder ``` -Обратите внимание, что первая сборка может занять больше времени, прежде чем зависимости будут кэшированы, но последующие сборки будут выполняться быстрее. Кроме того, если вам нужно указать другие аргументы для команды сборки, вы можете сделать это в конце команды в переменной ``. Например, если вы хотите создать оптимизированную версию, вы должны запустить приведенную выше команду и в конце строки добавить `--release`. Полученный исполняемый файл будет доступен в целевой папке вашей системы и может быть запущен с помощью: +Обратите внимание, что первая сборка может занять больше времени, прежде чем зависимости будут кэшированы, но последующие сборки будут выполняться быстрее. Кроме того, если вам нужно указать другие аргументы для команды сборки, вы можете сделать это в конце команды в переменной ``. Например, если вы хотите создать оптимизированную версию, вы должны выполнить приведенную выше команду и в конце строки добавить `--release`. Полученный исполняемый файл будет доступен в целевой папке вашей системы и может быть запущен с помощью следующей команды: ```sh target/debug/rustdesk @@ -135,25 +156,28 @@ target/debug/rustdesk target/release/rustdesk ``` -Пожалуйста, убедитесь, что вы запускаете эти команды из корня репозитория RustDesk, иначе приложение не сможет найти необходимые ресурсы. Также обратите внимание, что другие cargo подкоманды, такие как `install` или `run`, в настоящее время не поддерживаются этим методом, поскольку они будут устанавливать или запускать программу внутри контейнера, а не на хосте. +Пожалуйста, убедитесь, что вы запускаете эти команды из корня репозитория RustDesk, иначе приложение не сможет найти необходимые ресурсы. Также обратите внимание, что другие подкоманды Cargo, такие как `install` или `run`, в настоящее время не поддерживаются этим методом, поскольку они будут устанавливать или запускать программу внутри контейнера, а не на хосте. ## Структура файлов -- **[libs/hbb_common](https://github.com/rustdesk/rustdesk/tree/master/libs/hbb_common)**: видеокодек, конфиг, обертка tcp/udp, protobuf, функции fs для передачи файлов и некоторые другие служебные функции +- **[libs/hbb_common](https://github.com/rustdesk/rustdesk/tree/master/libs/hbb_common)**: видеокодек, конфигурация, враппер TCP/UDP, protobuf, функции файловой системы для передачи файлов и некоторые другие служебные функции - **[libs/scrap](https://github.com/rustdesk/rustdesk/tree/master/libs/scrap)**: захват экрана - **[libs/enigo](https://github.com/rustdesk/rustdesk/tree/master/libs/enigo)**: специфичное для платформы управление клавиатурой/мышью -- **[src/ui](https://github.com/rustdesk/rustdesk/tree/master/src/ui)**: графический пользовательский интерфейс -- **[src/server](https://github.com/rustdesk/rustdesk/tree/master/src/server)**: сервисы аудио/буфера обмена/ввода/видео и сетевых подключений +- **[libs/clipboard](https://github.com/rustdesk/rustdesk/tree/master/libs/clipboard)**: функционал буфера обмена файлами для Windows, Linux, и macOS +- **[src/ui](https://github.com/rustdesk/rustdesk/tree/master/src/ui)**: графический пользовательский интерфейс на Sciter (устаревшее) +- **[src/server](https://github.com/rustdesk/rustdesk/tree/master/src/server)**: сервисы аудио, буфера обмена, ввода, видео и сетевых подключений - **[src/client.rs](https://github.com/rustdesk/rustdesk/tree/master/src/client.rs)**: одноранговое соединение -- **[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/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 ## Скриншоты -![image](https://user-images.githubusercontent.com/71636191/113112362-ae4deb80-923b-11eb-957d-ff88daad4f06.png) +![Менеджер соединений](https://github.com/rustdesk/rustdesk/assets/28412477/db82d4e7-c4bc-4823-8e6f-6af7eadf7651) -![image](https://user-images.githubusercontent.com/71636191/113112619-f705a480-923b-11eb-911d-97e984ef52b6.png) +![Подключение к удалённому рабочему столу на Windows](https://github.com/rustdesk/rustdesk/assets/28412477/9baa91e9-3362-4d06-aa1a-7518edcbd7ea) -![image](https://user-images.githubusercontent.com/71636191/113112857-3fbd5d80-923c-11eb-9836-768325faf906.png) +![Передача файлов](https://github.com/rustdesk/rustdesk/assets/28412477/39511ad3-aa9a-4f8c-8947-1cce286a46ad) -![image](https://user-images.githubusercontent.com/71636191/135385039-38fdbd72-379a-422d-b97f-33df71fb1cec.png) +![TCP-туннелирование](https://github.com/rustdesk/rustdesk/assets/28412477/78e8708f-e87e-4570-8373-1360033ea6c5) \ No newline at end of file diff --git a/docs/README-TR.md b/docs/README-TR.md index 3b4b34edd..99c961e8b 100644 --- a/docs/README-TR.md +++ b/docs/README-TR.md @@ -7,34 +7,37 @@ Dosya YapısıEkran Görüntüleri
[Українська] | [česky] | [中文] | [Magyar] | [Español] | [فارسی] | [Français] | [Deutsch] | [Polski] | [Indonesian] | [Suomi] | [മലയാളം] | [日本語] | [Nederlands] | [Italiano] | [Русский] | [Português (Brasil)] | [Esperanto] | [한국어] | [العربي] | [Tiếng Việt] | [Dansk] | [Ελληνικά]
- README, RustDesk UI ve RustDesk Belge'sini ana dilinize çevirmemiz için yardımınıza ihtiyacımız var + README, RustDesk UI ve RustDesk Dökümantasyonu'nu ana dilinize çevirmemiz için yardımınıza ihtiyacımız var

-Bizimle sohbet edin: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) -[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/I2I04VU09) +> [!Dikkat] +> **Yanlış Kullanım Uyarısı:**
+> RustDesk geliştiricileri, bu yazılımın etik olmayan veya yasa dışı kullanımını onaylamaz veya desteklemez. Yetkisiz erişim, kontrol veya gizlilik ihlali gibi kötüye kullanımlar kesinlikle yönergelerimize aykırıdır. Yazarlar, uygulamanın herhangi bir yanlış kullanımından sorumlu değildir. -Başka bir uzak masaüstü yazılımı daha, Rust dilinde yazılmış. Hemen kullanıma hazır, hiçbir yapılandırma gerektirmez. Verilerinizin tam kontrolünü elinizde tutarsınız ve güvenlikle ilgili endişeleriniz olmaz. Kendi buluş/iletme sunucumuzu kullanabilirsiniz, [kendi sunucunuzu kurabilirsiniz](https://rustdesk.com/server) veya [kendi buluş/iletme sunucunuzu yazabilirsiniz](https://github.com/rustdesk/rustdesk-server-demo). +Bizimle sohbet edin: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk) + +[![RustDesk Server Pro](https://img.shields.io/badge/RustDesk%20Server%20Pro-Geli%C5%9Fmi%C5%9F%20%C3%96zellikler-blue)](https://rustdesk.com/pricing.html) + +Rust dilinde yazılmış, başka bir uzak masaüstü yazılımı daha. Hiçbir yapılandırma gerekmeksizin, hemen kullanıma hazır. Güvenlik konusunda hiçbir endişe duymadan, verileriniz üzerinde tam kontrole sahip olun. Kendi rendezvous/relay sunucumuzu kullanabilirsiniz, [kendi sunucunuzu kurabilirsiniz](https://rustdesk.com/server) veya [kendi rendezvous/relay sunucunuzu yazabilirsiniz](https://github.com/rustdesk/rustdesk-server-demo). ![image](https://user-images.githubusercontent.com/71636191/171661982-430285f0-2e12-4b1d-9957-4a58e375304d.png) -RustDesk, herkesten katkıyı kabul eder. Başlamak için [CONTRIBUTING.md](docs/CONTRIBUTING-TR.md) belgesine göz atın. +RustDesk, herkesin katkısına açıktır. Başlamak için [CONTRIBUTING.md](CONTRIBUTING-TR.md) belgesine göz atın. [**SSS**](https://github.com/rustdesk/rustdesk/wiki/FAQ) -[**BİNARİ İNDİR**](https://github.com/rustdesk/rustdesk/releases) +[**BINARY İNDİR**](https://github.com/rustdesk/rustdesk/releases) -[**NİGHTLY DERLEME**](https://github.com/rustdesk/rustdesk/releases/tag/nightly) +[**NIGHTLY DERLEME**](https://github.com/rustdesk/rustdesk/releases/tag/nightly) [F-Droid'de Alın](https://f-droid.org/en/packages/com.carriez.flutter_hbb) -## Bağımlılıklar +## Gereksinimler -Masaüstü sürümleri GUI için - - [Sciter](https://sciter.com/) veya Flutter kullanır, bu kılavuz sadece Sciter içindir. +Masaüstü sürümleri GUI için; [Sciter](https://sciter.com/)(kaldırılacak) veya Flutter kullanır. Sciter daha kolay ve başlamak için daha dostcanlısı, bundan dolayı bu kılavuz sadece Sciter içindir. Flutter sürümünü derlemek için [CI](https://github.com/rustdesk/rustdesk/blob/master/.github/workflows/flutter-build.yml)'ımıza bakın. Lütfen Sciter dinamik kütüphanesini kendiniz indirin. @@ -46,7 +49,7 @@ Lütfen Sciter dinamik kütüphanesini kendiniz indirin. - Rust geliştirme ortamınızı ve C++ derleme ortamınızı hazırlayın. -- [vcpkg](https://github.com/microsoft/vcpkg) yükleyin ve `VCPKG_ROOT` çevresel değişkenini doğru bir şekilde ayarlayın. +- [vcpkg](https://github.com/microsoft/vcpkg) yükleyin ve `VCPKG_ROOT` ortam değişkenini doğru bir şekilde ayarlayın. - Windows: vcpkg install libvpx:x64-windows-static libyuv:x64-windows-static opus:x64-windows-static aom:x64-windows-static - Linux/macOS: vcpkg install libvpx libyuv opus aom @@ -123,7 +126,7 @@ VCPKG_ROOT=$HOME/vcpkg cargo run ## Docker ile Derleme Nasıl Yapılır -Öncelikle deposunu klonlayın ve Docker konteynerini oluşturun: +Önce repository'i klonlayın ve Docker container'ını oluşturun. ```sh git clone https://github.com/rustdesk/rustdesk @@ -131,40 +134,40 @@ cd rustdesk docker build -t "rustdesk-builder" . ``` -Ardından, uygulamayı derlemek için her seferinde aşağıdaki komutu çalıştırın: +Ardından, uygulamayı her derlemeniz gerektiğinde aşağıdaki komutu çalıştırın: ```sh docker run --rm -it -v $PWD:/home/user/rustdesk -v rustdesk-git-cache:/home/user/.cargo/git -v rustdesk-registry-cache:/home/user/.cargo/registry -e PUID="$(id -u)" -e PGID="$(id -g)" rustdesk-builder ``` -İlk derleme, bağımlılıklar önbelleğe alınmadan önce daha uzun sürebilir, sonraki derlemeler daha hızlı olacaktır. Ayrıca, derleme komutuna isteğe bağlı argümanlar belirtmeniz gerekiyorsa, bunu - - komutun sonunda `<İSTEĞE BAĞLI-ARGÜMANLAR>` pozisyonunda yapabilirsiniz. Örneğin, optimize edilmiş bir sürümü derlemek isterseniz, yukarıdaki komutu çalıştırdıktan sonra `--release` ekleyebilirsiniz. Oluşan yürütülebilir dosya sisteminizdeki hedef klasöründe bulunacak ve şu komutla çalıştırılabilir: +Bilin ki ilk derlemeniz gereksinimlerin önbelleği yüklenmesinden ötürü uzun sürebilir, sonraki derlemeleriniz daha hızlı olacaktır. Ayrıca, derleme komutuna isteğe bağlı argümanlar belirtmeniz gerekiyorsa, bunu komutun sonunda ki `` yerine yazabilirsiniz. Örneğin, optimize edilmiş bir sürümü derlemek isterseniz, yukarıdaki komutu çalıştırdıktan sonra `--release` ekleyebilirsiniz. Oluşan çalıştırılabilir dosya sisteminizdeki hedef klasöründe bulunacak ve şu komutla çalıştırılabilir olacaktır: ```sh target/debug/rustdesk ``` -Veya, yayın yürütülebilir dosyası çalıştırılıyorsa: +Veya, yayım çalıştırılabilir dosyası için: ```sh target/release/rustdesk ``` -Lütfen bu komutları RustDesk deposunun kökünden çalıştırdığınızdan emin olun, aksi takdirde uygulama gereken kaynakları bulamayabilir. Ayrıca, `install` veya `run` gibi diğer cargo altkomutları şu anda bu yöntem aracılığıyla desteklenmemektedir, çünkü bunlar programı konteyner içinde kurar veya çalıştırır ve ana makinede değil. +Lütfen bu komutları RustDesk reposunun root klasöründe çalıştırdığınızdan emin olun, aksi takdirde uygulama gereken kaynakları bulamayabilir. Ayrıca, `install` veya `run` gibi diğer cargo altkomutları şu anda bu yöntem aracılığıyla desteklenmemektedir, çünkü bunlar programı konteyner içinde kurar veya çalıştırır, ana makinede değil. ## Dosya Yapısı -- **[libs/hbb_common](https://github.com/rustdesk/rustdesk/tree/master/libs/hbb_common)**: video kodlayıcı, yapılandırma, tcp/udp sarmalayıcı, protobuf, dosya transferi için fs işlevleri ve diğer bazı yardımcı işlevler +- **[libs/hbb_common](https://github.com/rustdesk/rustdesk/tree/master/libs/hbb_common)**: video codec, config, tcp/udp wrapper, protobuf, dosya transferi için fs fonksiyonları ve diğer bazı yardımcı işlevler - **[libs/scrap](https://github.com/rustdesk/rustdesk/tree/master/libs/scrap)**: ekran yakalama - **[libs/enigo](https://github.com/rustdesk/rustdesk/tree/master/libs/enigo)**: platforma özgü klavye/fare kontrolü -- **[src/ui](https://github.com/rustdesk/rustdesk/tree/master/src/ui)**: GUI -- **[src/server](https://github.com/rustdesk/rustdesk/tree/master/src/server)**: ses/pasta/klavye/video hizmetleri ve ağ bağlantıları -- **[src/client.rs](https://github.com/rustdesk/rustdesk/tree/master/src/client.rs)**: bir eş bağlantısı başlatır -- **[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şim kurar, uzak doğrudan (TCP delik vurma) veya iletme bağlantısını bekler +- **[libs/clipboard](https://github.com/rustdesk/rustdesk/tree/master/libs/clipboard)**: platforma özgü kopyala/yapıştır implementasyonları. +- **[src/ui](https://github.com/rustdesk/rustdesk/tree/master/src/ui)**: Eski Sciter UI (kaldırılacak) +- **[src/server](https://github.com/rustdesk/rustdesk/tree/master/src/server)**: ses/pano/input/video servisleri ve ağ bağlantıları +- **[src/client.rs](https://github.com/rustdesk/rustdesk/tree/master/src/client.rs)**: Eşli bağlantı başlat +- **[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)**: mobil için Flutter kodu -- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Flutter web istemcisi için JavaScript +- **[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 8f226914d..eb4c9edec 100644 --- a/docs/README-UA.md +++ b/docs/README-UA.md @@ -9,15 +9,15 @@ Нам потрібна ваша допомога для перекладу цього README, інтерфейсу та документації RustDesk вашою рідною мовою

-Спілкування з нами: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) +Спілкування з нами: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk) -[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/I2I04VU09) +[![RustDesk Server Pro](https://img.shields.io/badge/RustDesk%20Server%20Pro-%D0%A0%D0%BE%D0%B7%D1%88%D0%B8%D1%80%D0%B5%D0%BD%D1%96%20%D0%A4%D1%83%D0%BD%D0%BA%D1%86%D1%96%D1%97-blue)](https://rustdesk.com/pricing.html) Ще один застосунок для віддаленого керування стільницею, написаний на Rust. Працює з коробки, не потребує налаштування. Ви повністю контролюєте свої дані, не турбуючись про безпеку. Ви можете використовувати наш сервер ретрансляції, [налаштувати свій власний](https://rustdesk.com/server), або [написати свій власний сервер ретрансляції](https://github.com/rustdesk/rustdesk-server-demo). ![image](https://user-images.githubusercontent.com/71636191/171661982-430285f0-2e12-4b1d-9957-4a58e375304d.png) -RustDesk вітає внесок кожного. Ознайомтеся з [CONTRIBUTING.md](docs/CONTRIBUTING.md), щоб отримати допомогу на початковому етапі. +RustDesk вітає внесок кожного. Ознайомтеся з [CONTRIBUTING.md](CONTRIBUTING.md), щоб отримати допомогу на початковому етапі. [**ЧаПи**](https://github.com/rustdesk/rustdesk/wiki/FAQ) @@ -172,6 +172,3 @@ target/release/rustdesk ![Тунелювання TCP](https://github.com/rustdesk/rustdesk/assets/28412477/78e8708f-e87e-4570-8373-1360033ea6c5) -## [Публічні сервери](#публічні-сервери) - -RustDesk підтримується безкоштовним європейським сервером, любʼязно наданим [Codext GmbH](https://codext.link/rustdesk?utm_source=github) diff --git a/docs/README-VN.md b/docs/README-VN.md index 9c8ebcf23..38cdc10fb 100644 --- a/docs/README-VN.md +++ b/docs/README-VN.md @@ -11,9 +11,9 @@ Chúng tôi rất hoan nghênh sự hỗ trợ của bạn trong việc dịch trang README, trang giao diện người dùng của RustDesk - RustDesk UI và trang tài liệu của RustDesk - RustDesk Doc sang Tiếng Việt

-Hãy trao đổi với chúng tôi qua: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) +Hãy trao đổi với chúng tôi qua: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk) -[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/I2I04VU09) +[![RustDesk Server Pro](https://img.shields.io/badge/RustDesk%20Server%20Pro-T%C3%ADnh%20N%C4%83ng%20N%C3%A2ng%20Cao-blue)](https://rustdesk.com/pricing.html) RustDesk là một phần mềm điểu khiển máy tính từ xa mã nguồn mở, được viết bằng Rust. Nó hoạt động ngay sau khi cài đặt, không yêu cầu cấu hình phức tạp. Bạn có toàn quyền kiểm soát với dữ liệu của mình mà không cần phải lo lắng về vấn đề bảo mật. Bạn có thể sử dụng máy chủ rendezvous/relay của chúng tôi hoặc [tự cài đặt máy chủ của riêng mình](https://rustdesk.com/server) hay thậm chí [tự tạo máy chủ rendezvous/relay cho riêng bạn](https://github.com/rustdesk/rustdesk-server-demo). diff --git a/docs/README-ZH.md b/docs/README-ZH.md index 5a5f56b20..9328e52e9 100644 --- a/docs/README-ZH.md +++ b/docs/README-ZH.md @@ -8,9 +8,13 @@ [English] | [Українська] | [česky] | [Magyar] | [Español] | [فارسی] | [Français] | [Deutsch] | [Polski] | [Indonesian] | [Suomi] | [മലയാളം] | [日本語] | [Nederlands] | [Italiano] | [Русский] | [Português (Brasil)] | [Esperanto] | [한국어] | [العربي] | [Tiếng Việt] | [Ελληνικά]

-与我们交流: [知乎](https://www.zhihu.com/people/rustdesk) | [Discord](https://discord.gg/nDceKgxnkV) | [Reddit](https://www.reddit.com/r/rustdesk) +> [!CAUTION] +> **免责声明:**
+> RustDesk 的开发人员不纵容或支持任何不道德或非法的软件使用行为。滥用行为,例如未经授权的访问、控制或侵犯隐私,严格违反我们的准则。作者对应用程序的任何滥用行为概不负责。 -[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/I2I04VU09) +与我们交流: [知乎](https://www.zhihu.com/people/rustdesk) | [Discord](https://discord.gg/nDceKgxnkV) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk) + +[![RustDesk Server Pro](https://img.shields.io/badge/RustDesk%20Server%20Pro-%E9%AB%98%E7%BA%A7%E5%8A%9F%E8%83%BD-blue)](https://rustdesk.com/pricing.html) 远程桌面软件,开箱即用,无需任何配置。您完全掌控数据,不用担心安全问题。您可以使用我们的注册/中继服务器, 或者[自己设置](https://rustdesk.com/server), @@ -135,8 +139,8 @@ docker build -t "rustdesk-builder" . # 构建容器 ``` 在Dockerfile的RUN apt update之前插入两行: - RUN sed -i "s/deb.debian.org/mirrors.163.com/g" /etc/apt/sources.list - RUN sed -i "s/security.debian.org/mirrors.163.com/g" /etc/apt/sources.list + RUN sed -i "s|deb.debian.org|mirrors.aliyun.com|g" /etc/apt/sources.list && \ + sed -i "s|security.debian.org|mirrors.aliyun.com|g" /etc/apt/sources.list ``` 2. 修改容器系统中的 cargo 源,在`RUN ./rustup.sh -y`后插入下面代码: diff --git a/docs/SECURITY-FR.md b/docs/SECURITY-FR.md new file mode 100644 index 000000000..1cf2c6167 --- /dev/null +++ b/docs/SECURITY-FR.md @@ -0,0 +1,16 @@ + +# Politique de sécurité + +## Signaler une vulnérabilité + +Nous accordons une très grande importance à la sécurité du projet. Nous +encourageons tous les utilisateurs à nous signaler toute vulnérabilité qu'ils +découvrent. + +Si vous trouvez une vulnérabilité de sécurité dans le projet RustDesk, veuillez +la signaler de manière responsable en envoyant un e-mail à info@rustdesk.com. + +À ce stade, nous n'avons pas de programme de bug bounty. Nous sommes une petite +équipe qui s'attaque à un grand défi. Nous vous encourageons vivement à signaler +toute vulnérabilité de manière responsable afin que nous puissions continuer à +développer une application sécurisée pour l'ensemble de la communauté. diff --git a/docs/SECURITY-KR.md b/docs/SECURITY-KR.md new file mode 100644 index 000000000..94ce8f2ba --- /dev/null +++ b/docs/SECURITY-KR.md @@ -0,0 +1,7 @@ +# 보안 정책 + +## 취약점 보고 + +저희는 프로젝트의 보안을 매우 중요하게 생각합니다. 모든 사용자가 발견한 취약점을 저희에게 보고할 것을 권장합니다. RustDesk 프로젝트에서 보안 취약점이 발견되면 info@rustdesk.com으로 이메일을 보내 책임감 있게 보고해 주시기 바랍니다. + +현재로서는 버그 현상금 프로그램이 없습니다. 저희는 큰 문제를 해결하기 위해 노력하는 소규모 팀입니다. 전체 커뮤니티를 위한 안전한 응용 프로그램을 계속 구축할 수 있도록 취약점을 책임감 있게 신고해 주시기 바랍니다. diff --git a/docs/SECURITY-NO.md b/docs/SECURITY-NO.md new file mode 100644 index 000000000..1f8dcb411 --- /dev/null +++ b/docs/SECURITY-NO.md @@ -0,0 +1,9 @@ +# Sikkerhets Rettningslinjer + +## Reportering av en Sårbarhet + +Vi verdsetter pris på sikkerhet for prosjektet høyt. Og oppmunterer alle brukere til å rapportere sårbarheter de oppdager til oss. +Om du finner en sikkerhets sårbarhet i RustDesk prosjektet, venligst raportere det ansvarsfult ved å sende oss en email til info@rustdesk.com. + +På dette tidspunktet har vi ingen bug dusør program. Vi er ett lite team som prøver å løse ett stort problem. Vi trenger att du raporterer alle sårbarhetene +annsvarfult så vi kan fortsettte å bygge ett en sikker applikasjon for hele felleskapet. diff --git a/docs/SECURITY-RO.md b/docs/SECURITY-RO.md new file mode 100644 index 000000000..029e01d53 --- /dev/null +++ b/docs/SECURITY-RO.md @@ -0,0 +1,9 @@ +# Politica de Securitate + +## Raportarea unei Vulnerabilități + +Acordăm o mare importanță securității proiectului. Încurajăm toți utilizatorii să ne raporteze orice vulnerabilități pe care le descoperă. +Dacă găsești o vulnerabilitate de securitate în proiectul RustDesk, te rugăm să o raportezi responsabil trimițând un e-mail la info@rustdesk.com. + +În acest moment, nu avem un program de recompense pentru descoperirea de bug-uri. Suntem o echipă mică care încearcă să rezolve o problemă mare. +Te rugăm să raportezi orice vulnerabilitate în mod responsabil, astfel încât să putem continua să construim o aplicație sigură pentru întreaga comunitate. diff --git a/examples/custom_plugin/Cargo.toml b/examples/custom_plugin/Cargo.toml deleted file mode 100644 index b9ee06ae7..000000000 --- a/examples/custom_plugin/Cargo.toml +++ /dev/null @@ -1,28 +0,0 @@ -[package] -name = "custom_plugin" -version = "0.1.0" -edition = "2021" - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - -[lib] -name = "custom_plugin" -path = "src/lib.rs" -crate-type = ["cdylib"] - - -[features] -default = ["flutter"] -flutter = [] - -[dependencies] -lazy_static = "1.4.0" -rustdesk = { path = "../../", version = "1.2.0", features = ["flutter"]} - -[profile.release] -lto = true -codegen-units = 1 -panic = 'abort' -strip = true -#opt-level = 'z' # only have smaller size after strip -rpath = true \ No newline at end of file diff --git a/examples/custom_plugin/src/lib.rs b/examples/custom_plugin/src/lib.rs deleted file mode 100644 index 0b21f3fc8..000000000 --- a/examples/custom_plugin/src/lib.rs +++ /dev/null @@ -1,30 +0,0 @@ -use librustdesk::api::RustDeskApiTable; -/// This file demonstrates how to write a custom plugin for RustDesk. -use std::ffi::{c_char, c_int, CString}; - -lazy_static::lazy_static! { - pub static ref PLUGIN_NAME: CString = CString::new("A Template Rust Plugin").unwrap(); - pub static ref PLUGIN_ID: CString = CString::new("TemplatePlugin").unwrap(); - // Do your own logic based on the API provided by RustDesk. - pub static ref API: RustDeskApiTable = RustDeskApiTable::default(); -} - -#[no_mangle] -fn plugin_name() -> *const c_char { - return PLUGIN_NAME.as_ptr(); -} - -#[no_mangle] -fn plugin_id() -> *const c_char { - return PLUGIN_ID.as_ptr(); -} - -#[no_mangle] -fn plugin_init() -> c_int { - return 0 as _; -} - -#[no_mangle] -fn plugin_dispose() -> c_int { - return 0 as _; -} diff --git a/examples/ipc.rs b/examples/ipc.rs new file mode 100644 index 000000000..bca2321b1 --- /dev/null +++ b/examples/ipc.rs @@ -0,0 +1,90 @@ +use docopt::Docopt; +use hbb_common::{ + env_logger::{init_from_env, Env, DEFAULT_FILTER_ENV}, + log, tokio, +}; +use librustdesk::{ipc::Data, *}; + +const USAGE: &'static str = " +IPC test program. + +Usage: + ipc (-s | --server | -c | --client) [-p | --postfix=] + ipc (-h | --help) + +Options: + -h --help Show this screen. + -s --server Run as IPC server. + -c --client Run as IPC client. + -p --postfix= IPC path postfix [default: ]. +"; + +#[derive(Debug, serde::Deserialize)] +struct Args { + flag_server: bool, + flag_client: bool, + flag_postfix: String, +} + +#[tokio::main] +async fn main() { + init_from_env(Env::default().filter_or(DEFAULT_FILTER_ENV, "info")); + + let args: Args = Docopt::new(USAGE) + .and_then(|d| d.deserialize()) + .unwrap_or_else(|e| e.exit()); + + if args.flag_server { + if args.flag_postfix.is_empty() { + log::info!("Starting IPC server..."); + } else { + log::info!( + "Starting IPC server with postfix: '{}'...", + args.flag_postfix + ); + } + ipc_server(&args.flag_postfix).await; + } else if args.flag_client { + if args.flag_postfix.is_empty() { + log::info!("Starting IPC client..."); + } else { + log::info!( + "Starting IPC client with postfix: '{}'...", + args.flag_postfix + ); + } + ipc_client(&args.flag_postfix).await; + } +} + +async fn ipc_server(postfix: &str) { + let postfix = postfix.to_string(); + let postfix2 = postfix.clone(); + std::thread::spawn(move || { + if let Err(err) = crate::ipc::start(&postfix) { + log::error!("Failed to start ipc: {}", err); + std::process::exit(-1); + } + }); + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + ipc_client(&postfix2).await; +} + +async fn ipc_client(postfix: &str) { + loop { + match crate::ipc::connect(1000, postfix).await { + Ok(mut conn) => match conn.send(&Data::Empty).await { + Ok(_) => { + log::info!("send message to ipc server success"); + } + Err(e) => { + log::error!("Failed to send message to ipc server: {}", e); + } + }, + Err(e) => { + log::error!("Failed to connect to ipc server: {}", e); + } + } + tokio::time::sleep(std::time::Duration::from_secs(6)).await; + } +} diff --git a/fastlane/metadata/android/en-US/short_description.txt b/fastlane/metadata/android/en-US/short_description.txt index 357fb37ab..91e796ada 100644 --- a/fastlane/metadata/android/en-US/short_description.txt +++ b/fastlane/metadata/android/en-US/short_description.txt @@ -1 +1 @@ -An open-source remote desktop application, the open source TeamViewer alternative. +An open-source remote desktop application, the TeamViewer alternative diff --git a/flatpak/com.rustdesk.RustDesk.metainfo.xml b/flatpak/com.rustdesk.RustDesk.metainfo.xml new file mode 100644 index 000000000..90bdafcb5 --- /dev/null +++ b/flatpak/com.rustdesk.RustDesk.metainfo.xml @@ -0,0 +1,59 @@ + + + com.rustdesk.RustDesk + + RustDesk + + com.rustdesk.RustDesk.desktop + CC0-1.0 + AGPL-3.0-only + RustDesk + Secure remote desktop access + +

+ RustDesk is a full-featured open source remote control alternative for self-hosting and security with minimal configuration. +

+
    +
  • Works on Windows, macOS, Linux, iOS, Android, Web.
  • +
  • Supports VP8 / VP9 / AV1 software codecs, and H264 / H265 hardware codecs.
  • +
  • Own your data, easily set up self-hosting solution on your infrastructure.
  • +
  • P2P connection with end-to-end encryption based on NaCl.
  • +
  • No administrative privileges or installation needed for Windows, elevate privilege locally or from remote on demand.
  • +
  • We like to keep things simple and will strive to make simpler where possible.
  • +
+

+ For self-hosting setup instructions please go to our home page. +

+
+ + Utility + + + + Remote desktop session + https://user-images.githubusercontent.com/71636191/171661982-430285f0-2e12-4b1d-9957-4a58e375304d.png + + + + #d9eaf8 + #0160ee + + https://rustdesk.com + https://github.com/rustdesk/rustdesk/issues + https://github.com/rustdesk/rustdesk/wiki/FAQ + https://rustdesk.com/docs + https://ko-fi.com/rustdesk + https://github.com/rustdesk/rustdesk + https://github.com/rustdesk/rustdesk/tree/master/src/lang + https://github.com/rustdesk/rustdesk/blob/master/docs/CONTRIBUTING.md + https://rustdesk.com/docs/en/technical-support + + 600 + always + + + keyboard + pointing + + +
diff --git a/flatpak/rustdesk.json b/flatpak/rustdesk.json index 6d7acb5b8..2418ac2a6 100644 --- a/flatpak/rustdesk.json +++ b/flatpak/rustdesk.json @@ -1,19 +1,30 @@ { "id": "com.rustdesk.RustDesk", "runtime": "org.freedesktop.Platform", - "runtime-version": "23.08", + "runtime-version": "24.08", "sdk": "org.freedesktop.Sdk", "command": "rustdesk", - "icon": "share/icons/hicolor/scalable/apps/rustdesk.svg", + "cleanup": ["/include", "/lib/pkgconfig", "/share/gtk-doc"], + "rename-desktop-file": "rustdesk.desktop", + "rename-icon": "rustdesk", "modules": [ "shared-modules/libappindicator/libappindicator-gtk3-12.10.json", - "xdotool.json", { - "name": "pam", - "buildsystem": "simple", - "build-commands": [ - "./configure --disable-selinux --prefix=/app && make -j4 install" - ], + "name": "xdotool", + "no-autogen": true, + "make-install-args": ["PREFIX=${FLATPAK_DEST}"], + "sources": [ + { + "type": "archive", + "url": "https://github.com/jordansissel/xdotool/releases/download/v3.20211022.1/xdotool-3.20211022.1.tar.gz", + "sha256": "96f0facfde6d78eacad35b91b0f46fecd0b35e474c03e00e30da3fdd345f9ada" + } + ] + }, + { + "name": "pam", + "buildsystem": "autotools", + "config-opts": ["--disable-selinux"], "sources": [ { "type": "archive", @@ -26,38 +37,30 @@ "name": "rustdesk", "buildsystem": "simple", "build-commands": [ - "bsdtar -zxvf rustdesk.deb", - "tar -xvf ./data.tar.xz", - "cp -r ./usr/* /app/", - "mkdir -p /app/bin && ln -s /app/lib/rustdesk/rustdesk /app/bin/rustdesk", - "mv /app/share/applications/rustdesk.desktop /app/share/applications/com.rustdesk.RustDesk.desktop", - "mv /app/share/applications/rustdesk-link.desktop /app/share/applications/com.rustdesk.RustDesk-link.desktop", - "sed -i '/^Icon=/ c\\Icon=com.rustdesk.RustDesk' /app/share/applications/*.desktop", - "mv /app/share/icons/hicolor/scalable/apps/rustdesk.svg /app/share/icons/hicolor/scalable/apps/com.rustdesk.RustDesk.svg", - "for size in 16 24 32 48 64 128 256 512; do\n rsvg-convert -w $size -h $size -f png -o $size.png scalable.svg\n install -Dm644 $size.png /app/share/icons/hicolor/${size}x${size}/apps/com.rustdesk.RustDesk.png\n done" + "bsdtar -Oxf rustdesk.deb data.tar.xz | bsdtar -xf -", + "cp -r usr/* /app/", + "mkdir -p /app/bin && ln -s /app/share/rustdesk/rustdesk /app/bin/rustdesk" ], - "cleanup": ["/include", "/lib/pkgconfig", "/share/gtk-doc"], "sources": [ { "type": "file", - "path": "./rustdesk.deb" + "path": "rustdesk.deb" }, { "type": "file", - "path": "../res/scalable.svg" + "path": "com.rustdesk.RustDesk.metainfo.xml" } ] } ], "finish-args": [ "--share=ipc", - "--socket=x11", - "--socket=fallback-x11", "--socket=wayland", + "--socket=x11", "--share=network", "--filesystem=home", "--device=dri", "--socket=pulseaudio", "--talk-name=org.freedesktop.Flatpak" ] -} +} \ No newline at end of file diff --git a/flatpak/xdotool.json b/flatpak/xdotool.json deleted file mode 100644 index d7f41bf0e..000000000 --- a/flatpak/xdotool.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "xdotool", - "buildsystem": "simple", - "build-commands": [ - "make -j4 && PREFIX=./build make install", - "cp -r ./build/* /app/" - ], - "sources": [ - { - "type": "archive", - "url": "https://github.com/jordansissel/xdotool/releases/download/v3.20211022.1/xdotool-3.20211022.1.tar.gz", - "sha256": "96f0facfde6d78eacad35b91b0f46fecd0b35e474c03e00e30da3fdd345f9ada" - } - ] -} diff --git a/flutter/android/app/build.gradle b/flutter/android/app/build.gradle index 320eb3347..830cbc2dd 100644 --- a/flutter/android/app/build.gradle +++ b/flutter/android/app/build.gradle @@ -1,6 +1,11 @@ import com.google.protobuf.gradle.* +import groovy.json.JsonSlurper + plugins { id "com.google.protobuf" version "0.9.4" + id "com.android.application" + id "kotlin-android" + id "dev.flutter.flutter-gradle-plugin" } def keystoreProperties = new Properties() @@ -17,11 +22,6 @@ if (localPropertiesFile.exists()) { } } -def flutterRoot = localProperties.getProperty('flutter.sdk') -if (flutterRoot == null) { - throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") -} - def flutterVersionCode = localProperties.getProperty('flutter.versionCode') if (flutterVersionCode == null) { flutterVersionCode = '1' @@ -32,12 +32,37 @@ if (flutterVersionName == null) { flutterVersionName = '1.0' } -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' -apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" +// Add rustls-platform-verifier Android support +String findRustlsPlatformVerifierMavenDir() { + def dependencyText = providers.exec { + it.workingDir = new File("../..") + commandLine("cargo", "metadata", "--format-version", "1") + }.standardOutput.asText.get() -dependencies { - implementation 'com.google.protobuf:protobuf-javalite:3.20.1' + def dependencyJson = new JsonSlurper().parseText(dependencyText) + def pkg = dependencyJson.packages.find { it.name == "rustls-platform-verifier-android" } + + if (pkg == null) { + throw new GradleException("rustls-platform-verifier-android package not found in cargo metadata!") + } + + def manifestPath = file(pkg.manifest_path) + def mavenDir = new File(manifestPath.parentFile, "maven") + + if (!mavenDir.exists()) { + throw new GradleException("Maven directory not found at: ${mavenDir.path}") + } + + println("✓ Found rustls-platform-verifier maven repo at: ${mavenDir.path}") + return mavenDir.path +} + + +repositories { + maven { + url = findRustlsPlatformVerifierMavenDir() + metadataSources.artifact() + } } protobuf { @@ -57,7 +82,7 @@ protobuf { } android { - compileSdkVersion 33 + compileSdkVersion 34 sourceSets { main.java.srcDirs += 'src/main/kotlin' @@ -73,7 +98,7 @@ android { defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "com.carriez.flutter_hbb" - minSdkVersion 21 + minSdkVersion 22 targetSdkVersion 33 versionCode flutterVersionCode.toInteger() versionName flutterVersionName @@ -103,9 +128,10 @@ flutter { } dependencies { + implementation 'com.google.protobuf:protobuf-javalite:3.20.1' implementation "androidx.media:media:1.6.0" implementation 'com.github.getActivity:XXPermissions:18.5' - implementation("org.jetbrains.kotlin:kotlin-stdlib") { version { strictly("$kotlin_version") } } + implementation("org.jetbrains.kotlin:kotlin-stdlib") { version { strictly("1.9.10") } } implementation 'com.caverock:androidsvg-aar:1.4' + implementation "rustls:rustls-platform-verifier:0.1.1" } - diff --git a/flutter/android/app/proguard-rules b/flutter/android/app/proguard-rules index 0b12a6cda..517402567 100644 --- a/flutter/android/app/proguard-rules +++ b/flutter/android/app/proguard-rules @@ -1,4 +1,7 @@ # Keep class members from protobuf generated code. -keepclassmembers class * extends com.google.protobuf.GeneratedMessageLite { ; -} \ No newline at end of file +} + +# Keep rustls-platform-verifier classes for JNI +-keep, includedescriptorclasses class org.rustls.platformverifier.** { *; } \ No newline at end of file diff --git a/flutter/android/app/src/main/AndroidManifest.xml b/flutter/android/app/src/main/AndroidManifest.xml index 51015f74a..f4788af4c 100644 --- a/flutter/android/app/src/main/AndroidManifest.xml +++ b/flutter/android/app/src/main/AndroidManifest.xml @@ -15,7 +15,15 @@ + + + + + + + when (menuItem.itemId) { idShowRustDesk -> { openMainActivity() true } + idSyncClipboard -> { + syncClipboard() + true + } idStopService -> { stopMainService() true @@ -340,6 +353,10 @@ class FloatingWindowService : Service(), View.OnTouchListener { } } + private fun syncClipboard() { + MainActivity.rdClipboardManager?.syncClipboard(false) + } + private fun stopMainService() { MainActivity.flutterMethodChannel?.invokeMethod("stop_service", null) } @@ -375,4 +392,3 @@ class FloatingWindowService : Service(), View.OnTouchListener { return false } } - diff --git a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/InputService.kt b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/InputService.kt index 3fcc72df3..3ca83fbac 100644 --- a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/InputService.kt +++ b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/InputService.kt @@ -19,6 +19,7 @@ import android.view.accessibility.AccessibilityEvent import android.view.ViewGroup.LayoutParams import android.view.accessibility.AccessibilityNodeInfo import android.view.KeyEvent as KeyEventAndroid +import android.view.ViewConfiguration import android.graphics.Rect import android.media.AudioManager import android.accessibilityservice.AccessibilityServiceInfo @@ -34,10 +35,15 @@ import hbb.MessageOuterClass.KeyEvent import hbb.MessageOuterClass.KeyboardMode import hbb.KeyEventConverter -const val LIFT_DOWN = 9 -const val LIFT_MOVE = 8 -const val LIFT_UP = 10 +// const val BUTTON_UP = 2 +// const val BUTTON_BACK = 0x08 + +const val LEFT_DOWN = 9 +const val LEFT_MOVE = 8 +const val LEFT_UP = 10 const val RIGHT_UP = 18 +// (BUTTON_BACK << 3) | BUTTON_UP +const val BACK_UP = 66 const val WHEEL_BUTTON_DOWN = 33 const val WHEEL_BUTTON_UP = 34 const val WHEEL_DOWN = 523331 @@ -65,11 +71,14 @@ class InputService : AccessibilityService() { private val logTag = "input service" private var leftIsDown = false private var touchPath = Path() + private var stroke: GestureDescription.StrokeDescription? = null private var lastTouchGestureStartTime = 0L private var mouseX = 0 private var mouseY = 0 private var timer = Timer() private var recentActionTask: TimerTask? = null + // 100(tap timeout) + 400(long press timeout) + private val longPressDuration = ViewConfiguration.getTapTimeout().toLong() + ViewConfiguration.getLongPressTimeout().toLong() private val wheelActionsQueue = LinkedList() private var isWheelActionsPolling = false @@ -77,6 +86,9 @@ class InputService : AccessibilityService() { private var fakeEditTextForTextStateCalculation: EditText? = null + private var lastX = 0 + private var lastY = 0 + private val volumeController: VolumeController by lazy { VolumeController(applicationContext.getSystemService(AUDIO_SERVICE) as AudioManager) } @RequiresApi(Build.VERSION_CODES.N) @@ -84,7 +96,7 @@ class InputService : AccessibilityService() { val x = max(0, _x) val y = max(0, _y) - if (mask == 0 || mask == LIFT_MOVE) { + if (mask == 0 || mask == LEFT_MOVE) { val oldX = mouseX val oldY = mouseY mouseX = x * SCREEN_INFO.scale @@ -98,31 +110,30 @@ class InputService : AccessibilityService() { } } - // left button down ,was up - if (mask == LIFT_DOWN) { + // left button down, was up + if (mask == LEFT_DOWN) { isWaitingLongPress = true timer.schedule(object : TimerTask() { override fun run() { if (isWaitingLongPress) { isWaitingLongPress = false - leftIsDown = false - endGesture(mouseX, mouseY) + continueGesture(mouseX, mouseY) } } - }, LONG_TAP_DELAY * 4) + }, longPressDuration) leftIsDown = true startGesture(mouseX, mouseY) return } - // left down ,was down + // left down, was down if (leftIsDown) { continueGesture(mouseX, mouseY) } - // left up ,was down - if (mask == LIFT_UP) { + // left up, was down + if (mask == LEFT_UP) { if (leftIsDown) { leftIsDown = false isWaitingLongPress = false @@ -132,6 +143,11 @@ class InputService : AccessibilityService() { } if (mask == RIGHT_UP) { + longPress(mouseX, mouseY) + return + } + + if (mask == BACK_UP) { performGlobalAction(GLOBAL_ACTION_BACK) return } @@ -241,18 +257,100 @@ class InputService : AccessibilityService() { } } - private fun startGesture(x: Int, y: Int) { - touchPath = Path() - touchPath.moveTo(x.toFloat(), y.toFloat()) - lastTouchGestureStartTime = System.currentTimeMillis() - } - - private fun continueGesture(x: Int, y: Int) { - touchPath.lineTo(x.toFloat(), y.toFloat()) + @RequiresApi(Build.VERSION_CODES.N) + private fun performClick(x: Int, y: Int, duration: Long) { + val path = Path() + path.moveTo(x.toFloat(), y.toFloat()) + try { + val longPressStroke = GestureDescription.StrokeDescription(path, 0, duration) + val builder = GestureDescription.Builder() + builder.addStroke(longPressStroke) + Log.d(logTag, "performClick x:$x y:$y time:$duration") + dispatchGesture(builder.build(), null, null) + } catch (e: Exception) { + Log.e(logTag, "performClick, error:$e") + } } @RequiresApi(Build.VERSION_CODES.N) - private fun endGesture(x: Int, y: Int) { + private fun longPress(x: Int, y: Int) { + performClick(x, y, longPressDuration) + } + + private fun startGesture(x: Int, y: Int) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + touchPath.reset() + } else { + touchPath = Path() + } + touchPath.moveTo(x.toFloat(), y.toFloat()) + lastTouchGestureStartTime = System.currentTimeMillis() + lastX = x + lastY = y + } + + @RequiresApi(Build.VERSION_CODES.N) + private fun doDispatchGesture(x: Int, y: Int, willContinue: Boolean) { + touchPath.lineTo(x.toFloat(), y.toFloat()) + var duration = System.currentTimeMillis() - lastTouchGestureStartTime + if (duration <= 0) { + duration = 1 + } + try { + if (stroke == null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + stroke = GestureDescription.StrokeDescription( + touchPath, + 0, + duration, + willContinue + ) + } else { + stroke = GestureDescription.StrokeDescription( + touchPath, + 0, + duration + ) + } + } else { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + stroke = stroke?.continueStroke(touchPath, 0, duration, willContinue) + } else { + stroke = null + stroke = GestureDescription.StrokeDescription( + touchPath, + 0, + duration + ) + } + } + stroke?.let { + val builder = GestureDescription.Builder() + builder.addStroke(it) + Log.d(logTag, "doDispatchGesture x:$x y:$y time:$duration") + dispatchGesture(builder.build(), null, null) + } + } catch (e: Exception) { + Log.e(logTag, "doDispatchGesture, willContinue:$willContinue, error:$e") + } + } + + @RequiresApi(Build.VERSION_CODES.N) + private fun continueGesture(x: Int, y: Int) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + doDispatchGesture(x, y, true) + touchPath.reset() + touchPath.moveTo(x.toFloat(), y.toFloat()) + lastTouchGestureStartTime = System.currentTimeMillis() + lastX = x + lastY = y + } else { + touchPath.lineTo(x.toFloat(), y.toFloat()) + } + } + + @RequiresApi(Build.VERSION_CODES.N) + private fun endGestureBelowO(x: Int, y: Int) { try { touchPath.lineTo(x.toFloat(), y.toFloat()) var duration = System.currentTimeMillis() - lastTouchGestureStartTime @@ -273,6 +371,17 @@ class InputService : AccessibilityService() { } } + @RequiresApi(Build.VERSION_CODES.N) + private fun endGesture(x: Int, y: Int) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + doDispatchGesture(x, y, false) + touchPath.reset() + stroke = null + } else { + endGestureBelowO(x, y) + } + } + @RequiresApi(Build.VERSION_CODES.N) fun onKeyEvent(data: ByteArray) { val keyEvent = KeyEvent.parseFrom(data) @@ -280,20 +389,20 @@ class InputService : AccessibilityService() { var textToCommit: String? = null - if (keyboardMode == KeyboardMode.Legacy) { - if (keyEvent.hasChr() && keyEvent.getDown()) { + // [down] indicates the key's state(down or up). + // [press] indicates a click event(down and up). + // https://github.com/rustdesk/rustdesk/blob/3a7594755341f023f56fa4b6a43b60d6b47df88d/flutter/lib/models/input_model.dart#L688 + if (keyEvent.hasSeq()) { + textToCommit = keyEvent.getSeq() + } else if (keyboardMode == KeyboardMode.Legacy) { + if (keyEvent.hasChr() && (keyEvent.getDown() || keyEvent.getPress())) { val chr = keyEvent.getChr() if (chr != null) { textToCommit = String(Character.toChars(chr)) } } } else if (keyboardMode == KeyboardMode.Translate) { - if (keyEvent.hasSeq() && keyEvent.getDown()) { - val seq = keyEvent.getSeq() - if (seq != null) { - textToCommit = seq - } - } + } else { } Log.d(logTag, "onKeyEvent $keyEvent textToCommit:$textToCommit") @@ -320,6 +429,10 @@ class InputService : AccessibilityService() { } else { ke?.let { event -> inputConnection.sendKeyEvent(event) + if (keyEvent.getPress()) { + val actionUpEvent = KeyEventAndroid(KeyEventAndroid.ACTION_UP, event.keyCode) + inputConnection.sendKeyEvent(actionUpEvent) + } } } } @@ -333,6 +446,10 @@ class InputService : AccessibilityService() { for (item in possibleNodes) { val success = trySendKeyEvent(event, item, textToCommit) if (success) { + if (keyEvent.getPress()) { + val actionUpEvent = KeyEventAndroid(KeyEventAndroid.ACTION_UP, event.keyCode) + trySendKeyEvent(actionUpEvent, item, textToCommit) + } break } } diff --git a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/KeyboardKeyEventMapper.kt b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/KeyboardKeyEventMapper.kt index 1e63df405..ccb33195e 100644 --- a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/KeyboardKeyEventMapper.kt +++ b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/KeyboardKeyEventMapper.kt @@ -31,14 +31,12 @@ object KeyEventConverter { } var action = 0 - if (keyEventProto.getDown()) { + if (keyEventProto.getDown() || keyEventProto.getPress()) { action = KeyEvent.ACTION_DOWN } else { action = KeyEvent.ACTION_UP } - // FIXME: The last parameter is the repeat count, not modifiers ? - // https://developer.android.com/reference/android/view/KeyEvent#KeyEvent(long,%20long,%20int,%20int,%20int) return KeyEvent(0, 0, action, chrValue, 0, modifiers) } 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 10c3d7c2d..fea8e5519 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 @@ -13,6 +13,8 @@ import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.ServiceConnection +import android.content.ClipboardManager +import android.os.Bundle import android.os.Build import android.os.IBinder import android.util.Log @@ -36,6 +38,9 @@ import kotlin.concurrent.thread class MainActivity : FlutterActivity() { companion object { var flutterMethodChannel: MethodChannel? = null + private var _rdClipboardManager: RdClipboardManager? = null + val rdClipboardManager: RdClipboardManager? + get() = _rdClipboardManager; } private val channelTag = "mChannel" @@ -57,7 +62,13 @@ class MainActivity : FlutterActivity() { channelTag ) initFlutterChannel(flutterMethodChannel!!) - thread { setCodecInfo() } + thread { + try { + setCodecInfo() + } catch (e: Exception) { + Log.e("MainActivity", "Failed to setCodecInfo: ${e.message}", e) + } + } } override fun onResume() { @@ -85,6 +96,14 @@ class MainActivity : FlutterActivity() { } } + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (_rdClipboardManager == null) { + _rdClipboardManager = RdClipboardManager(getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager) + FFI.setClipboardManager(_rdClipboardManager!!) + } + } + override fun onDestroy() { Log.e(logTag, "onDestroy") mainService?.let { @@ -207,6 +226,10 @@ class MainActivity : FlutterActivity() { result.success(true) } + "try_sync_clipboard" -> { + rdClipboardManager?.syncClipboard(true) + result.success(true) + } GET_START_ON_BOOT_OPT -> { val prefs = getSharedPreferences(KEY_SHARED_PREFERENCES, MODE_PRIVATE) result.success(prefs.getBoolean(KEY_START_ON_BOOT_OPT, false)) @@ -299,7 +322,7 @@ class MainActivity : FlutterActivity() { codecObject.put("mime_type", mime_type) val caps = codec.getCapabilitiesForType(mime_type) if (codec.isEncoder) { - // Encoder‘s max_height and max_width are interchangeable + // Encoder's max_height and max_width are interchangeable if (!caps.videoCapabilities.isSizeSupported(w,h) && !caps.videoCapabilities.isSizeSupported(h,w)) { return@forEach } diff --git a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainApplication.kt b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainApplication.kt new file mode 100644 index 000000000..59a3b0fb4 --- /dev/null +++ b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainApplication.kt @@ -0,0 +1,17 @@ +package com.carriez.flutter_hbb + +import android.app.Application +import android.util.Log +import ffi.FFI + +class MainApplication : Application() { + companion object { + private const val TAG = "MainApplication" + } + + override fun onCreate() { + super.onCreate() + Log.d(TAG, "App start") + FFI.onAppStart(applicationContext) + } +} 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 1a709747e..7bb16a00a 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 @@ -65,8 +65,8 @@ class MainService : Service() { @Keep @RequiresApi(Build.VERSION_CODES.N) fun rustPointerInput(kind: Int, mask: Int, x: Int, y: Int) { - // turn on screen with LIFT_DOWN when screen off - if (!powerManager.isInteractive && (kind == 0 || mask == LIFT_DOWN)) { + // turn on screen with LEFT_DOWN when screen off + if (!powerManager.isInteractive && (kind == 0 || mask == LEFT_DOWN)) { if (wakeLock.isHeld) { Log.d(logTag, "Turn on Screen, WakeLock release") wakeLock.release() @@ -122,9 +122,9 @@ class MainService : Service() { val authorized = jsonObject["authorized"] as Boolean val isFileTransfer = jsonObject["is_file_transfer"] as Boolean val type = if (isFileTransfer) { - translate("File Connection") + translate("Transfer file") } else { - translate("Screen Connection") + translate("Share screen") } if (authorized) { if (!isFileTransfer && !isStart) { @@ -302,6 +302,8 @@ class MainService : Service() { stopCapture() FFI.refreshScreen() startCapture() + } else { + FFI.refreshScreen() } } @@ -431,6 +433,7 @@ class MainService : Service() { checkMediaPermission() _isStart = true FFI.setFrameRawEnable("video",true) + MainActivity.rdClipboardManager?.setCaptureStarted(_isStart) return true } @@ -439,6 +442,7 @@ class MainService : Service() { Log.d(logTag, "Stop Capture") FFI.setFrameRawEnable("video",false) _isStart = false + MainActivity.rdClipboardManager?.setCaptureStarted(_isStart) // release video if (reuseVirtualDisplay) { // The virtual display video projection can be paused by calling `setSurface(null)`. diff --git a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/RdClipboardManager.kt b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/RdClipboardManager.kt new file mode 100644 index 000000000..8c9d85028 --- /dev/null +++ b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/RdClipboardManager.kt @@ -0,0 +1,197 @@ +package com.carriez.flutter_hbb + +import java.nio.ByteBuffer +import java.util.Timer +import java.util.TimerTask + +import android.content.ClipData +import android.content.ClipDescription +import android.content.ClipboardManager +import android.util.Log +import androidx.annotation.Keep + +import hbb.MessageOuterClass.ClipboardFormat +import hbb.MessageOuterClass.Clipboard +import hbb.MessageOuterClass.MultiClipboards + +import ffi.FFI + +class RdClipboardManager(private val clipboardManager: ClipboardManager) { + private val logTag = "RdClipboardManager" + private val supportedMimeTypes = arrayOf( + ClipDescription.MIMETYPE_TEXT_PLAIN, + ClipDescription.MIMETYPE_TEXT_HTML + ) + + // 1. Avoid listening to the same clipboard data updated by `rustUpdateClipboard`. + // 2. Avoid sending the clipboard data before enabling client clipboard. + // 1) Disable clipboard + // 2) Copy text "a" + // 3) Enable clipboard + // 4) Switch to another app + // 5) Switch back to the app + // 6) "a" should not be sent to the client, because it's copied before enabling clipboard + // + // It's okay to that `rustEnableClientClipboard(false)` is called after `rustUpdateClipboard`, + // though the `lastUpdatedClipData` will be set to null once. + private var lastUpdatedClipData: ClipData? = null + private var isClientEnabled = true; + private var _isCaptureStarted = false; + + val isCaptureStarted: Boolean + get() = _isCaptureStarted + + fun checkPrimaryClip(isClient: Boolean) { + val clipData = clipboardManager.primaryClip + if (clipData != null && clipData.itemCount > 0) { + // Only handle the first item in the clipboard for now. + val clip = clipData.getItemAt(0) + // Ignore the `isClipboardDataEqual()` check if it's a host operation. + // Because it's an action manually triggered by the user. + if (isClient) { + if (lastUpdatedClipData != null && isClipboardDataEqual(clipData, lastUpdatedClipData!!)) { + Log.d(logTag, "Clipboard data is the same as last update, ignore") + return + } + } + val mimeTypeCount = clipData.description.getMimeTypeCount() + val mimeTypes = mutableListOf() + for (i in 0 until mimeTypeCount) { + mimeTypes.add(clipData.description.getMimeType(i)) + } + var text: CharSequence? = null; + var html: String? = null; + if (isSupportedMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN)) { + text = clip?.text + } + if (isSupportedMimeType(ClipDescription.MIMETYPE_TEXT_HTML)) { + text = clip?.text + html = clip?.htmlText + } + var count = 0 + val clips = MultiClipboards.newBuilder() + if (text != null) { + val content = com.google.protobuf.ByteString.copyFromUtf8(text.toString()) + clips.addClipboards(Clipboard.newBuilder().setFormat(ClipboardFormat.Text).setContent(content).build()) + count++ + } + if (html != null) { + val content = com.google.protobuf.ByteString.copyFromUtf8(html) + clips.addClipboards(Clipboard.newBuilder().setFormat(ClipboardFormat.Html).setContent(content).build()) + count++ + } + if (count > 0) { + val clipsBytes = clips.build().toByteArray() + val isClientFlag = if (isClient) 1 else 0 + val clipsBuf = ByteBuffer.allocateDirect(clipsBytes.size + 1).apply { + put(isClientFlag.toByte()) + put(clipsBytes) + } + clipsBuf.flip() + lastUpdatedClipData = clipData + Log.d(logTag, "${if (isClient) "client" else "host"}, send clipboard data to the remote") + FFI.onClipboardUpdate(clipsBuf) + } + } + } + + private fun isSupportedMimeType(mimeType: String): Boolean { + return supportedMimeTypes.contains(mimeType) + } + + private fun isClipboardDataEqual(left: ClipData, right: ClipData): Boolean { + if (left.description.getMimeTypeCount() != right.description.getMimeTypeCount()) { + return false + } + val mimeTypeCount = left.description.getMimeTypeCount() + for (i in 0 until mimeTypeCount) { + if (left.description.getMimeType(i) != right.description.getMimeType(i)) { + return false + } + } + + if (left.itemCount != right.itemCount) { + return false + } + for (i in 0 until left.itemCount) { + val mimeType = left.description.getMimeType(i) + if (!isSupportedMimeType(mimeType)) { + continue + } + val leftItem = left.getItemAt(i) + val rightItem = right.getItemAt(i) + if (mimeType == ClipDescription.MIMETYPE_TEXT_PLAIN || mimeType == ClipDescription.MIMETYPE_TEXT_HTML) { + if (leftItem.text != rightItem.text || leftItem.htmlText != rightItem.htmlText) { + return false + } + } + } + return true + } + + fun setCaptureStarted(started: Boolean) { + _isCaptureStarted = started + } + + @Keep + fun rustEnableClientClipboard(enable: Boolean) { + Log.d(logTag, "rustEnableClientClipboard: enable: $enable") + isClientEnabled = enable + lastUpdatedClipData = null + } + + fun syncClipboard(isClient: Boolean) { + Log.d(logTag, "syncClipboard: isClient: $isClient, isClientEnabled: $isClientEnabled") + if (isClient && !isClientEnabled) { + return + } + checkPrimaryClip(isClient) + } + + @Keep + fun rustUpdateClipboard(clips: ByteArray) { + val clips = MultiClipboards.parseFrom(clips) + var mimeTypes = mutableListOf() + var text: String? = null + var html: String? = null + for (clip in clips.getClipboardsList()) { + when (clip.format) { + ClipboardFormat.Text -> { + mimeTypes.add(ClipDescription.MIMETYPE_TEXT_PLAIN) + text = String(clip.content.toByteArray(), Charsets.UTF_8) + } + ClipboardFormat.Html -> { + mimeTypes.add(ClipDescription.MIMETYPE_TEXT_HTML) + html = String(clip.content.toByteArray(), Charsets.UTF_8) + } + ClipboardFormat.ImageRgba -> { + } + ClipboardFormat.ImagePng -> { + } + else -> { + Log.e(logTag, "Unsupported clipboard format: ${clip.format}") + } + } + } + + val clipDescription = ClipDescription("clipboard", mimeTypes.toTypedArray()) + var item: ClipData.Item? = null + if (text == null) { + Log.e(logTag, "No text content in clipboard") + return + } else { + if (html == null) { + item = ClipData.Item(text) + } else { + item = ClipData.Item(text, html) + } + } + if (item == null) { + Log.e(logTag, "No item in clipboard") + return + } + val clipData = ClipData(clipDescription, item) + lastUpdatedClipData = clipData + clipboardManager.setPrimaryClip(clipData) + } +} diff --git a/flutter/android/app/src/main/kotlin/ffi.kt b/flutter/android/app/src/main/kotlin/ffi.kt index a7573bbf9..e3c9d9830 100644 --- a/flutter/android/app/src/main/kotlin/ffi.kt +++ b/flutter/android/app/src/main/kotlin/ffi.kt @@ -5,12 +5,16 @@ package ffi import android.content.Context import java.nio.ByteBuffer +import com.carriez.flutter_hbb.RdClipboardManager + object FFI { init { System.loadLibrary("rustdesk") } 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 startService() external fun onVideoFrameUpdate(buf: ByteBuffer) @@ -20,4 +24,7 @@ object FFI { external fun setFrameRawEnable(name: String, value: Boolean) external fun setCodecInfo(info: String) external fun getLocalOption(key: String): String -} \ No newline at end of file + external fun getBuildinOption(key: String): String + external fun onClipboardUpdate(clips: ByteBuffer) + external fun isServiceClipboardEnabled(): Boolean +} diff --git a/flutter/android/build.gradle b/flutter/android/build.gradle index c6a77f36b..401bea009 100644 --- a/flutter/android/build.gradle +++ b/flutter/android/build.gradle @@ -1,18 +1,3 @@ -buildscript { - ext.kotlin_version = '1.9.10' - repositories { - google() - jcenter() - maven { url 'https://jitpack.io' } - } - - dependencies { - classpath 'com.android.tools.build:gradle:7.0.0' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - classpath 'com.google.gms:google-services:4.3.14' - } -} - allprojects { repositories { google() @@ -24,6 +9,8 @@ allprojects { rootProject.buildDir = '../build' subprojects { project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { project.evaluationDependsOn(':app') } diff --git a/flutter/android/gradle.properties b/flutter/android/gradle.properties index 94adc3a3f..804b29b30 100644 --- a/flutter/android/gradle.properties +++ b/flutter/android/gradle.properties @@ -1,3 +1,4 @@ -org.gradle.jvmargs=-Xmx1536M +org.gradle.jvmargs=-Xmx1024M android.useAndroidX=true android.enableJetifier=true +org.gradle.daemon=false diff --git a/flutter/android/gradle/wrapper/gradle-wrapper.properties b/flutter/android/gradle/wrapper/gradle-wrapper.properties index cc5527d78..cb576305f 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.4-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.4-all.zip diff --git a/flutter/android/settings.gradle b/flutter/android/settings.gradle index 44e62bcf0..ae32fa00e 100644 --- a/flutter/android/settings.gradle +++ b/flutter/android/settings.gradle @@ -1,11 +1,25 @@ -include ':app' +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + }() -def localPropertiesFile = new File(rootProject.projectDir, "local.properties") -def properties = new Properties() + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") -assert localPropertiesFile.exists() -localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} -def flutterSdkPath = properties.getProperty("flutter.sdk") -assert flutterSdkPath != null, "flutter.sdk not set in local.properties" -apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version "7.3.1" apply false + id "org.jetbrains.kotlin.android" version "2.1.21" apply false +} + +include ":app" diff --git a/flutter/assets/auth-microsoft.svg b/flutter/assets/auth-microsoft.svg new file mode 100644 index 000000000..c9ce5f9cf --- /dev/null +++ b/flutter/assets/auth-microsoft.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/flutter/assets/device_group.ttf b/flutter/assets/device_group.ttf new file mode 100644 index 000000000..a6e42704f Binary files /dev/null and b/flutter/assets/device_group.ttf differ diff --git a/flutter/assets/keyboard.svg b/flutter/assets/keyboard.svg deleted file mode 100644 index 0e94a5a62..000000000 --- a/flutter/assets/keyboard.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/flutter/assets/keyboard_mouse.svg b/flutter/assets/keyboard_mouse.svg new file mode 100644 index 000000000..f6a5b4b2b --- /dev/null +++ b/flutter/assets/keyboard_mouse.svg @@ -0,0 +1 @@ + diff --git a/flutter/assets/more.ttf b/flutter/assets/more.ttf new file mode 100644 index 000000000..3b01435df Binary files /dev/null and b/flutter/assets/more.ttf differ diff --git a/flutter/build_android_deps.sh b/flutter/build_android_deps.sh index e4210477e..64fb9dad2 100755 --- a/flutter/build_android_deps.sh +++ b/flutter/build_android_deps.sh @@ -68,6 +68,7 @@ function build { pushd "$SCRIPTDIR/.." $VCPKG_ROOT/vcpkg install --triplet $VCPKG_TARGET --x-install-root="$VCPKG_ROOT/installed" popd + head -n 100 "${VCPKG_ROOT}/buildtrees/ffmpeg/build-$VCPKG_TARGET-rel-out.log" || true echo "*** [$ANDROID_ABI][Finished] Build and install vcpkg dependencies" if [ -d "$VCPKG_ROOT/installed/arm-neon-android" ]; then diff --git a/flutter/build_fdroid.sh b/flutter/build_fdroid.sh index 7f3a9cc48..d50a6a6ce 100755 --- a/flutter/build_fdroid.sh +++ b/flutter/build_fdroid.sh @@ -1,7 +1,5 @@ #!/bin/bash -set -x - # # Script to build F-Droid release of RustDesk # @@ -9,7 +7,7 @@ set -x # 2024, Vasyl Gello # -# The script is invoked by F-Droid builder system ste-by-step. +# The script is invoked by F-Droid builder system step-by-step. # # It accepts the following arguments: # @@ -18,11 +16,47 @@ set -x # - Android architecture to build APK for: armeabi-v7a arm64-v8av x86 x86_64 # - The build step to execute: # -# + sudo-deps: as root, install needed Debian packages into builder VM # + prebuild: patch sources and do other stuff before the build # + build: perform actual build of APK file # +# Start of functions + +# Install Flutter of version `VERSION` from Github repository +# into directory `FLUTTER_DIR` and apply patches if needed + +prepare_flutter() { + VERSION="${1}" + FLUTTER_DIR="${2}" + + if [ ! -f "${FLUTTER_DIR}/bin/flutter" ]; then + git clone https://github.com/flutter/flutter "${FLUTTER_DIR}" + fi + + pushd "${FLUTTER_DIR}" + + git restore . + git checkout "${VERSION}" + + # Patch flutter + + if dpkg --compare-versions "${VERSION}" ge "3.24.4"; then + git apply "${ROOTDIR}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff" + fi + + flutter config --no-analytics + + popd # ${FLUTTER_DIR} +} + +# Start of script + +set -x + +# Note current working directory as root dir for patches + +ROOTDIR="${PWD}" + # Parse command-line arguments VERNAME="${1}" @@ -101,18 +135,31 @@ prebuild) .env.CARGO_NDK_VERSION \ .github/workflows/flutter-build.yml)" + # Flutter used to compile main Rustdesk library + FLUTTER_VERSION="$(yq -r \ .env.ANDROID_FLUTTER_VERSION \ .github/workflows/flutter-build.yml)" + if [ -z "${FLUTTER_VERSION}" ]; then FLUTTER_VERSION="$(yq -r \ .env.FLUTTER_VERSION \ .github/workflows/flutter-build.yml)" fi + # Flutter used to compile Flutter<->Rust bridge files + + CARGO_EXPAND_VERSION="$(yq -r \ + .env.CARGO_EXPAND_VERSION \ + .github/workflows/bridge.yml)" + + FLUTTER_BRIDGE_VERSION="$(yq -r \ + .env.FLUTTER_VERSION \ + .github/workflows/bridge.yml)" + FLUTTER_RUST_BRIDGE_VERSION="$(yq -r \ .env.FLUTTER_RUST_BRIDGE_VERSION \ - .github/workflows/flutter-build.yml)" + .github/workflows/bridge.yml)" NDK_VERSION="$(yq -r \ .env.NDK_VERSION \ @@ -127,6 +174,7 @@ prebuild) .github/workflows/flutter-build.yml)" if [ -z "${CARGO_NDK_VERSION}" ] || [ -z "${FLUTTER_VERSION}" ] || + [ -z "${FLUTTER_BRIDGE_VERSION}" ] || [ -z "${FLUTTER_RUST_BRIDGE_VERSION}" ] || [ -z "${NDK_VERSION}" ] || [ -z "${RUST_VERSION}" ] || [ -z "${VCPKG_COMMIT_ID}" ]; then @@ -135,13 +183,9 @@ prebuild) fi # Map NDK version to revision - - NDK_VERSION="$(wget \ - -qO- \ - -H "Accept: application/vnd.github+json" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - 'https://api.github.com/repos/android/ndk/releases' | - jq -r ".[] | select(.tag_name == \"${NDK_VERSION}\") | .body | match(\"ndkVersion \\\"(.*)\\\"\").captures[0].string")" + NDK_VERSION="$(curl https://gitlab.com/fdroid/android-sdk-transparency-log/-/raw/master/signed/checksums.json | + jq -r ".\"https://dl.google.com/android/repository/android-ndk-${NDK_VERSION}-linux.zip\"[0].\"source.properties\"" | + sed -n -E 's/.*Pkg.Revision = ([0-9.]+).*/\1/p')" if [ -z "${NDK_VERSION}" ]; then echo "ERROR: Can not map Android NDK codename to revision!" >&2 @@ -163,24 +207,6 @@ prebuild) sdkmanager --install "ndk;${NDK_VERSION}" fi - # Install Flutter - - if [ ! -f "${HOME}/flutter/bin/flutter" ]; then - pushd "${HOME}" - - git clone https://github.com/flutter/flutter - - pushd flutter - - git reset --hard "${FLUTTER_VERSION}" - - flutter config --no-analytics - - popd # flutter - - popd # ${HOME} - fi - # Install Rust if [ ! -f "${HOME}/rustup/rustup-init.sh" ]; then @@ -205,14 +231,19 @@ prebuild) cargo install \ cargo-ndk \ - --version "${CARGO_NDK_VERSION}" + --version "${CARGO_NDK_VERSION}" \ + --locked # Install rust bridge generator - cargo install cargo-expand + cargo install \ + cargo-expand \ + --version "${CARGO_EXPAND_VERSION}" \ + --locked cargo install flutter_rust_bridge_codegen \ --version "${FLUTTER_RUST_BRIDGE_VERSION}" \ - --features "uuid" + --features "uuid" \ + --locked # Populate native vcpkg dependencies @@ -275,12 +306,81 @@ prebuild) git apply res/fdroid/patches/*.patch + # If Flutter version used to generate bridge files differs from Flutter + # version used to compile Rustdesk library, generate bridge using the + # `FLUTTER_BRIDGE_VERSION` an restore the pubspec later + + if [ "${FLUTTER_VERSION}" != "${FLUTTER_BRIDGE_VERSION}" ]; then + # Find first libclang.so and set BRIDGE_LLVM_PATH + + BRIDGE_LLVM_PATH="$(find /usr/lib/ -name libclang.so | head -n1)" + + if [ -z "${BRIDGE_LLVM_PATH}" ]; then + echo 'ERROR: Can not find libclang.so for bridge generator!' >&2 + exit 1 + fi + + BRIDGE_LLVM_PATH="$(dirname "${BRIDGE_LLVM_PATH}")" + BRIDGE_LLVM_PATH="$(dirname "${BRIDGE_LLVM_PATH}")" + + # Install Flutter bridge version + + prepare_flutter "${FLUTTER_BRIDGE_VERSION}" "${HOME}/flutter" + + # Save changes + + git add . + + # Edit pubspec to make flutter bridge version work + + sed \ + -i \ + -e 's/extended_text: 14.0.0/extended_text: 13.0.0/g' \ + flutter/pubspec.yaml + + # Download Flutter dependencies + + pushd flutter + + flutter clean + flutter packages pub get + + popd # flutter + + # Generate FFI bindings + + flutter_rust_bridge_codegen \ + --rust-input ./src/flutter_ffi.rs \ + --dart-output ./flutter/lib/generated_bridge.dart \ + --llvm-path "${BRIDGE_LLVM_PATH}" + + # Add bridge files to save-list + + git add -f ./flutter/lib/generated_bridge.* ./src/bridge_generated.* + + # Restore everything + + git checkout '*' + git clean -dffx + git reset + + unset BRIDGE_LLVM_PATH + fi + + # Install Flutter version for RustDesk library build + + prepare_flutter "${FLUTTER_VERSION}" "${HOME}/flutter" + + # gms is not in these files now, but we still keep the following line for future reference(maybe). + sed \ -i \ -e '/gms/d' \ flutter/android/build.gradle \ flutter/android/app/build.gradle + # `firebase_analytics` is not in these files now, but we still keep the following lines. + sed \ -i \ -e '/firebase_analytics/d' \ @@ -296,34 +396,6 @@ prebuild) -e '/firebase/Id' \ flutter/lib/main.dart - if [ "${FLUTTER_VERSION}" = "3.13.9" ]; then - # Fix for android 3.13.9 - # https://github.com/rustdesk/rustdesk/blob/285e974d1a52c891d5fcc28e963d724e085558bc/.github/workflows/flutter-build.yml#L862 - - sed \ - -i \ - -e 's/extended_text: .*/extended_text: 11.1.0/' \ - -e 's/uni_links_desktop/#uni_links_desktop/g' \ - flutter/pubspec.yaml - - set -- - - while read -r _1; do - set -- "$@" "${_1}" - done 0<<.a -$(find flutter/lib/ -type f -name "*dart*") -.a - - sed \ - -i \ - -e 's/textScaler: TextScaler.linear(\(.*\)),/textScaleFactor: \1,/g' \ - "$@" - - set -- - fi - - sed -i "s/FLUTTER_VERSION_PLACEHOLDER/${FLUTTER_VERSION}/" flutter-sdk/.gclient - ;; build) # build: perform actual build of APK file @@ -335,9 +407,12 @@ build) # '.github/workflows/flutter-build.yml' # + # Flutter used to compile main Rustdesk library + FLUTTER_VERSION="$(yq -r \ .env.ANDROID_FLUTTER_VERSION \ .github/workflows/flutter-build.yml)" + if [ -z "${FLUTTER_VERSION}" ]; then FLUTTER_VERSION="$(yq -r \ .env.FLUTTER_VERSION \ @@ -349,13 +424,9 @@ build) .github/workflows/flutter-build.yml)" # Map NDK version to revision - - NDK_VERSION="$(wget \ - -qO- \ - -H "Accept: application/vnd.github+json" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - 'https://api.github.com/repos/android/ndk/releases' | - jq -r ".[] | select(.tag_name == \"${NDK_VERSION}\") | .body | match(\"ndkVersion \\\"(.*)\\\"\").captures[0].string")" + NDK_VERSION="$(curl https://gitlab.com/fdroid/android-sdk-transparency-log/-/raw/master/signed/checksums.json | + jq -r ".\"https://dl.google.com/android/repository/android-ndk-${NDK_VERSION}-linux.zip\"[0].\"source.properties\"" | + sed -n -E 's/.*Pkg.Revision = ([0-9.]+).*/\1/p')" if [ -z "${NDK_VERSION}" ]; then echo "ERROR: Can not map Android NDK codename to revision!" >&2 @@ -373,16 +444,11 @@ build) pushd flutter + flutter clean flutter packages pub get popd # flutter - # Generate FFI bindings - - flutter_rust_bridge_codegen \ - --rust-input ./src/flutter_ffi.rs \ - --dart-output ./flutter/lib/generated_bridge.dart - # Build host android deps bash flutter/build_android_deps.sh "${ANDROID_ABI}" diff --git a/flutter/build_ios.sh b/flutter/build_ios.sh index a6468a0a8..cd1262626 100755 --- a/flutter/build_ios.sh +++ b/flutter/build_ios.sh @@ -2,4 +2,7 @@ # https://docs.flutter.dev/deployment/ios # flutter build ipa --release --obfuscate --split-debug-info=./split-debug-info # no obfuscate, because no easy to check errors +cd $(dirname $(dirname $(which flutter))) +git apply ~/rustdesk/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff +cd - flutter build ipa --release diff --git a/flutter/deploy.sh b/flutter/deploy.sh deleted file mode 100755 index f6826fd87..000000000 --- a/flutter/deploy.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env bash -cd build/web/ -python3 -c 'x=open("./main.dart.js", "rt").read();import re;y=re.search("https://.*canvaskit-wasm@([\d\.]+)/bin/",x);dirname="canvaskit@"+y.groups()[0];z=x.replace(y.group(),"/"+dirname+"/");f=open("./main.dart.js", "wt");f.write(z);import os;os.system("ln -s canvaskit " + dirname);' -mv jds/dist/index.js ./ -mv jds/dist/vendor.js ./ -/bin/rm -rf js -python3 -c 'import hashlib;x=hashlib.sha1(open("./main.dart.js").read().encode()).hexdigest()[:10];y=open("index.html","rt").read().replace("main.dart.js", "main.dart.js?v="+x);open("index.html","wt").write(y)' -python3 -c 'import hashlib;x=hashlib.sha1(open("./index.js").read().encode()).hexdigest()[:10];y=open("index.html","rt").read().replace("js/dist/index.js", "index.js?v="+x);open("index.html","wt").write(y)' -python3 -c 'import hashlib;x=hashlib.sha1(open("./vendor.js").read().encode()).hexdigest()[:10];y=open("index.html","rt").read().replace("js/dist/vendor.js", "vendor.js?v="+x);open("index.html","wt").write(y)' -tar czf x * -scp x sg:/tmp/ -ssh sg "sudo tar xzf /tmp/x -C /var/www/html/web.rustdesk.com/ && /bin/rm /tmp/x && sudo chown www-data:www-data /var/www/html/web.rustdesk.com/ -R" -/bin/rm x -cd - diff --git a/flutter/ios/Flutter/AppFrameworkInfo.plist b/flutter/ios/Flutter/AppFrameworkInfo.plist index 7c5696400..1dc6cf765 100644 --- a/flutter/ios/Flutter/AppFrameworkInfo.plist +++ b/flutter/ios/Flutter/AppFrameworkInfo.plist @@ -21,6 +21,6 @@ CFBundleVersion 1.0 MinimumOSVersion - 12.0 + 13.0 diff --git a/flutter/ios/Podfile b/flutter/ios/Podfile index 321d7132c..b71c436f2 100644 --- a/flutter/ios/Podfile +++ b/flutter/ios/Podfile @@ -1,10 +1,7 @@ -# Uncomment this line to define a global platform for your project -# platform :ios, '12.0' - # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' -platform :ios, '12.0' +platform :ios, '13.0' project 'Runner', { 'Debug' => :debug, diff --git a/flutter/ios/Podfile.lock b/flutter/ios/Podfile.lock index 6cb5c9cff..c9e9f9a2f 100644 --- a/flutter/ios/Podfile.lock +++ b/flutter/ios/Podfile.lock @@ -133,10 +133,10 @@ SPEC CHECKSUMS: sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec SwiftyGif: 93a1cc87bf3a51916001cf8f3d63835fb64c819f uni_links: d97da20c7701486ba192624d99bffaaffcfc298a - url_launcher_ios: bbd758c6e7f9fd7b5b1d4cde34d2b95fcce5e812 + url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe video_player_avfoundation: 02011213dab73ae3687df27ce441fbbcc82b5579 wakelock_plus: 8b09852c8876491e4b6d179e17dfe2a0b5f60d47 -PODFILE CHECKSUM: d4cb12ad5d3bdb3352770b1d3db237584e155156 +PODFILE CHECKSUM: 83d1b0fb6fc8613d8312a03b8e1540d37cfc5d2c COCOAPODS: 1.15.2 diff --git a/flutter/ios/Runner.xcodeproj/project.pbxproj b/flutter/ios/Runner.xcodeproj/project.pbxproj index acc2a09e7..36dc89ea8 100644 --- a/flutter/ios/Runner.xcodeproj/project.pbxproj +++ b/flutter/ios/Runner.xcodeproj/project.pbxproj @@ -347,7 +347,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -491,7 +491,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -541,7 +541,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; diff --git a/flutter/ios/Runner/AppDelegate.swift b/flutter/ios/Runner/AppDelegate.swift index 89e443af6..d9333b706 100644 --- a/flutter/ios/Runner/AppDelegate.swift +++ b/flutter/ios/Runner/AppDelegate.swift @@ -1,7 +1,7 @@ import UIKit import Flutter -@UIApplicationMain +@main @objc class AppDelegate: FlutterAppDelegate { override func application( _ application: UIApplication, diff --git a/flutter/ios/Runner/Info.plist b/flutter/ios/Runner/Info.plist index 496fb17c2..9351dac53 100644 --- a/flutter/ios/Runner/Info.plist +++ b/flutter/ios/Runner/Info.plist @@ -43,6 +43,8 @@ UIApplicationSupportsIndirectInputEvents + UIFileSharingEnabled + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile @@ -60,6 +62,8 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + UISupportsDocumentBrowser + UIViewControllerBasedStatusBarAppearance ITSAppUsesNonExemptEncryption diff --git a/flutter/lib/common.dart b/flutter/lib/common.dart index a2ad96775..366a7b6ba 100644 --- a/flutter/lib/common.dart +++ b/flutter/lib/common.dart @@ -13,15 +13,18 @@ import 'package:flutter_hbb/desktop/widgets/refresh_wrapper.dart'; import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart'; import 'package:flutter_hbb/main.dart'; import 'package:flutter_hbb/models/peer_model.dart'; +import 'package:flutter_hbb/models/peer_tab_model.dart'; import 'package:flutter_hbb/models/state_model.dart'; import 'package:flutter_hbb/utils/multi_window_manager.dart'; import 'package:flutter_hbb/utils/platform_channel.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:get/get.dart'; +import 'package:get/get_rx/src/rx_workers/utils/debouncer.dart'; import 'package:provider/provider.dart'; import 'package:uni_links/uni_links.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:uuid/uuid.dart'; +import 'package:wakelock_plus/wakelock_plus.dart'; import 'package:window_manager/window_manager.dart'; import 'package:window_size/window_size.dart' as window_size; @@ -29,8 +32,11 @@ import '../consts.dart'; import 'common/widgets/overlay.dart'; import 'mobile/pages/file_manager_page.dart'; import 'mobile/pages/remote_page.dart'; +import 'mobile/pages/view_camera_page.dart'; +import 'mobile/pages/terminal_page.dart'; import 'desktop/pages/remote_page.dart' as desktop_remote; import 'desktop/pages/file_manager_page.dart' as desktop_file_manager; +import 'desktop/pages/view_camera_page.dart' as desktop_view_camera; import 'package:flutter_hbb/desktop/widgets/remote_toolbar.dart'; import 'models/model.dart'; import 'models/platform_model.dart'; @@ -39,6 +45,7 @@ import 'package:flutter_hbb/native/win32.dart' if (dart.library.html) 'package:flutter_hbb/web/win32.dart'; import 'package:flutter_hbb/native/common.dart' if (dart.library.html) 'package:flutter_hbb/web/common.dart'; +import 'package:flutter_hbb/utils/http_service.dart' as http; final globalKey = GlobalKey(); final navigationBarKey = GlobalKey(); @@ -64,10 +71,16 @@ int androidVersion = 0; // So we need to use this flag to enable/disable resizable. bool _linuxWindowResizable = true; +// Only used on Windows(window manager). +bool _ignoreDevicePixelRatio = true; + /// only available for Windows target int windowsBuildNumber = 0; DesktopType? desktopType; +// Tolerance used for floating-point position comparisons to avoid precision errors. +const double _kPositionEpsilon = 1e-6; + bool get isMainDesktopWindow => desktopType == DesktopType.main || desktopType == DesktopType.cm; @@ -93,13 +106,23 @@ enum DesktopType { main, remote, fileTransfer, + viewCamera, + terminal, cm, portForward, } +bool isDoubleEqual(double a, double b) { + return (a - b).abs() < _kPositionEpsilon; +} + class IconFont { static const _family1 = 'Tabbar'; static const _family2 = 'PeerSearchbar'; + static const _family3 = 'AddressBook'; + static const _family4 = 'DeviceGroup'; + static const _family5 = 'More'; + IconFont._(); static const IconData max = IconData(0xe606, fontFamily: _family1); @@ -110,8 +133,12 @@ class IconFont { static const IconData menu = IconData(0xe628, fontFamily: _family1); static const IconData search = IconData(0xe6a4, fontFamily: _family2); static const IconData roundClose = IconData(0xe6ed, fontFamily: _family2); - static const IconData addressBook = - IconData(0xe602, fontFamily: "AddressBook"); + static const IconData addressBook = IconData(0xe602, fontFamily: _family3); + static const IconData deviceGroupOutline = + IconData(0xe623, fontFamily: _family4); + static const IconData deviceGroupFill = + IconData(0xe748, fontFamily: _family4); + static const IconData more = IconData(0xe609, fontFamily: _family5); } class ColorThemeExtension extends ThemeExtension { @@ -689,6 +716,17 @@ closeConnection({String? id}) { stateGlobal.isInMainPage = true; } else { final controller = Get.find(); + if (controller.tabType == DesktopTabType.terminal && + controller.onCloseWindow != null) { + // Terminal windows are scoped to one peer. The optional id passed to + // closeConnection() is that peer id, not a terminal tab key + // (${peerId}_${terminalId}). Closing from terminal dialogs should close + // the peer's whole terminal window, including all terminal tabs. + unawaited(controller.onCloseWindow!().catchError((e, _) { + debugPrint('[closeConnection] Failed to close terminal window: $e'); + })); + return; + } controller.closeBy(id); } } @@ -814,7 +852,11 @@ class OverlayDialogManager { close([res]) { _dialogs.remove(dialogTag); - dialog.complete(res); + try { + dialog.complete(res); + } catch (e) { + debugPrint("Dialog complete catch error: $e"); + } BackButtonInterceptor.removeByName(dialogTag); } @@ -980,13 +1022,15 @@ makeMobileActionsOverlayEntry(VoidCallback? onHide, {FFI? ffi}) { }); } -void showToast(String text, {Duration timeout = const Duration(seconds: 3)}) { +void showToast(String text, + {Duration timeout = const Duration(seconds: 3), + Alignment alignment = const Alignment(0.0, 0.8)}) { final overlayState = globalKey.currentState?.overlay; if (overlayState == null) return; final entry = OverlayEntry(builder: (context) { return IgnorePointer( child: Align( - alignment: const Alignment(0.0, 0.8), + alignment: alignment, child: Container( decoration: BoxDecoration( color: MyTheme.color(context).toastBg, @@ -1091,18 +1135,23 @@ class CustomAlertDialog extends StatelessWidget { Widget createDialogContent(String text) { final RegExp linkRegExp = RegExp(r'(https?://[^\s]+)'); + bool hasLink = linkRegExp.hasMatch(text); + + // Early return: no link, use default theme color + if (!hasLink) { + return SelectableText(text, style: const TextStyle(fontSize: 15)); + } + final List spans = []; int start = 0; - bool hasLink = false; linkRegExp.allMatches(text).forEach((match) { - hasLink = true; if (match.start > start) { spans.add(TextSpan(text: text.substring(start, match.start))); } spans.add(TextSpan( text: match.group(0) ?? '', - style: TextStyle( + style: const TextStyle( color: Colors.blue, decoration: TextDecoration.underline, ), @@ -1120,13 +1169,9 @@ Widget createDialogContent(String text) { spans.add(TextSpan(text: text.substring(start))); } - if (!hasLink) { - return SelectableText(text, style: const TextStyle(fontSize: 15)); - } - return SelectableText.rich( TextSpan( - style: TextStyle(color: Colors.black, fontSize: 15), + style: const TextStyle(fontSize: 15), children: spans, ), ); @@ -1134,15 +1179,23 @@ Widget createDialogContent(String text) { void msgBox(SessionID sessionId, String type, String title, String text, String link, OverlayDialogManager dialogManager, - {bool? hasCancel, ReconnectHandle? reconnect, int? reconnectTimeout}) { + {bool? hasCancel, + ReconnectHandle? reconnect, + int? reconnectTimeout, + VoidCallback? onSubmit, + int? submitTimeout}) { dialogManager.dismissAll(); List buttons = []; bool hasOk = false; submit() { dialogManager.dismissAll(); - // https://github.com/rustdesk/rustdesk/blob/5e9a31340b899822090a3731769ae79c6bf5f3e5/src/ui/common.tis#L263 - if (!type.contains("custom") && desktopType != DesktopType.portForward) { - closeConnection(); + if (onSubmit != null) { + onSubmit.call(); + } else { + // https://github.com/rustdesk/rustdesk/blob/5e9a31340b899822090a3731769ae79c6bf5f3e5/src/ui/common.tis#L263 + if (!type.contains("custom") && desktopType != DesktopType.portForward) { + closeConnection(); + } } } @@ -1158,7 +1211,18 @@ void msgBox(SessionID sessionId, String type, String title, String text, if (type != "connecting" && type != "success" && !type.contains("nook")) { hasOk = true; - buttons.insert(0, dialogButton('OK', onPressed: submit)); + late final Widget btn; + if (submitTimeout != null) { + btn = _CountDownButton( + text: 'OK', + second: submitTimeout, + onPressed: submit, + submitOnTimeout: true, + ); + } else { + btn = dialogButton('OK', onPressed: submit); + } + buttons.insert(0, btn); } hasCancel ??= !type.contains("error") && !type.contains("nocancel") && @@ -1179,7 +1243,8 @@ void msgBox(SessionID sessionId, String type, String title, String text, reconnectTimeout != null) { // `enabled` is used to disable the dialog button once the button is clicked. final enabled = true.obs; - final button = Obx(() => _ReconnectCountDownButton( + final button = Obx(() => _CountDownButton( + text: 'Reconnect', second: reconnectTimeout, onPressed: enabled.isTrue ? () { @@ -1525,7 +1590,7 @@ bool option2bool(String option, String value) { option == kOptionForceAlwaysRelay) { res = value == "Y"; } else { - assert(false); + // "" is true res = value != "N"; } return res; @@ -1533,7 +1598,9 @@ bool option2bool(String option, String value) { String bool2option(String option, bool b) { String res; - if (option.startsWith('enable-')) { + if (option.startsWith('enable-') && + option != kOptionEnableUdpPunch && + option != kOptionEnableIpv6Punch) { res = b ? defaultOptionYes : 'N'; } else if (option.startsWith('allow-') || option == kOptionStopService || @@ -1541,7 +1608,6 @@ String bool2option(String option, bool b) { option == kOptionForceAlwaysRelay) { res = b ? 'Y' : defaultOptionNo; } else { - assert(false); res = b ? 'Y' : 'N'; } return res; @@ -1577,7 +1643,8 @@ bool mainGetPeerBoolOptionSync(String id, String key) { // Use `sessionGetToggleOption()` and `sessionToggleOption()` instead. // Because all session options use `Y` and `` as values. -Future matchPeer(String searchText, Peer peer) async { +Future matchPeer( + String searchText, Peer peer, PeerTabIndex peerTabIndex) async { if (searchText.isEmpty) { return true; } @@ -1588,11 +1655,14 @@ Future matchPeer(String searchText, Peer peer) async { peer.username.toLowerCase().contains(searchText)) { return true; } - final alias = peer.alias; - if (alias.isEmpty) { - return false; + if (peer.alias.toLowerCase().contains(searchText)) { + return true; } - return alias.toLowerCase().contains(searchText); + if (peerTabShowNote(peerTabIndex) && + peer.note.toLowerCase().contains(searchText)) { + return true; + } + return false; } /// Get the image for the current [platform]. @@ -1611,12 +1681,6 @@ Widget getPlatformImage(String platform, {double size = 50}) { return SvgPicture.asset('assets/$platform.svg', height: size, width: size); } -class OffsetDevicePixelRatio { - Offset offset; - final double devicePixelRatio; - OffsetDevicePixelRatio(this.offset, this.devicePixelRatio); -} - class LastWindowPosition { double? width; double? height; @@ -1628,6 +1692,15 @@ class LastWindowPosition { LastWindowPosition(this.width, this.height, this.offsetWidth, this.offsetHeight, this.isMaximized, this.isFullscreen); + bool equals(LastWindowPosition other) { + return ((width == other.width) && + (height == other.height) && + (offsetWidth == other.offsetWidth) && + (offsetHeight == other.offsetHeight) && + (isMaximized == other.isMaximized) && + (isFullscreen == other.isFullscreen)); + } + Map toJson() { return { "width": width, @@ -1667,24 +1740,36 @@ String get windowFramePrefix => ? "incoming_" : (bind.isOutgoingOnly() ? "outgoing_" : "")); +typedef WindowKey = ({WindowType type, int? windowId}); + +LastWindowPosition? _lastWindowPosition = null; +final Debouncer _saveWindowDebounce = Debouncer(delay: Duration(seconds: 1)); + /// Save window position and size on exit /// Note that windowId must be provided if it's subwindow -Future saveWindowPosition(WindowType type, {int? windowId}) async { +Future saveWindowPosition(WindowType type, + {int? windowId, bool? flush}) async { if (type != WindowType.Main && windowId == null) { debugPrint( "Error: windowId cannot be null when saving positions for sub window"); } - late Offset position; - late Size sz; + Offset? position; + Size? sz; late bool isMaximized; bool isFullscreen = stateGlobal.fullscreen.isTrue; + setPreFrame() { final pos = bind.getLocalFlutterOption(k: windowFramePrefix + type.name); var lpos = LastWindowPosition.loadFromString(pos); - position = Offset( - lpos?.offsetWidth ?? position.dx, lpos?.offsetHeight ?? position.dy); - sz = Size(lpos?.width ?? sz.width, lpos?.height ?? sz.height); + if (lpos != null) { + if (lpos.offsetWidth != null && lpos.offsetHeight != null) { + position = Offset(lpos.offsetWidth!, lpos.offsetHeight!); + } + if (lpos.width != null && lpos.height != null) { + sz = Size(lpos.width!, lpos.height!); + } + } } switch (type) { @@ -1699,8 +1784,10 @@ Future saveWindowPosition(WindowType type, {int? windowId}) async { if (isFullscreen || isMaximized) { setPreFrame(); } else { - position = await windowManager.getPosition(); - sz = await windowManager.getSize(); + position = await windowManager.getPosition( + ignoreDevicePixelRatio: _ignoreDevicePixelRatio); + sz = await windowManager.getSize( + ignoreDevicePixelRatio: _ignoreDevicePixelRatio); } break; default: @@ -1722,29 +1809,56 @@ Future saveWindowPosition(WindowType type, {int? windowId}) async { } break; } - if (isWindows) { + if (isWindows && position != null) { const kMinOffset = -10000; const kMaxOffset = 10000; - if (position.dx < kMinOffset || - position.dy < kMinOffset || - position.dx > kMaxOffset || - position.dy > kMaxOffset) { + if (position!.dx < kMinOffset || + position!.dy < kMinOffset || + position!.dx > kMaxOffset || + position!.dy > kMaxOffset) { debugPrint("Invalid position: $position, ignore saving position"); return; } } - final pos = LastWindowPosition( - sz.width, sz.height, position.dx, position.dy, isMaximized, isFullscreen); - debugPrint( - "Saving frame: $windowId: ${pos.width}/${pos.height}, offset:${pos.offsetWidth}/${pos.offsetHeight}, isMaximized:${pos.isMaximized}, isFullscreen:${pos.isFullscreen}"); + final pos = LastWindowPosition(sz?.width, sz?.height, position?.dx, + position?.dy, isMaximized, isFullscreen); - await bind.setLocalFlutterOption( - k: windowFramePrefix + type.name, v: pos.toString()); + final WindowKey key = (type: type, windowId: windowId); - if (type == WindowType.RemoteDesktop && windowId != null) { - await _saveSessionWindowPosition( - type, windowId, isMaximized, isFullscreen, pos); + final bool haveNewWindowPosition = + (_lastWindowPosition == null) || !pos.equals(_lastWindowPosition!); + final bool isPreviousNewWindowPositionPending = _saveWindowDebounce.isRunning; + + if (haveNewWindowPosition || isPreviousNewWindowPositionPending) { + _lastWindowPosition = pos; + + if (flush ?? false) { + // If a previous update is pending, replace it. + _saveWindowDebounce.cancel(); + await _saveWindowPositionActual(key); + } else if (haveNewWindowPosition) { + _saveWindowDebounce.call(() => _saveWindowPositionActual(key)); + } + } +} + +Future _saveWindowPositionActual(WindowKey key) async { + LastWindowPosition? pos = _lastWindowPosition; + + if (pos != null) { + debugPrint( + "Saving frame: ${key.windowId}: ${pos.width}/${pos.height}, offset:${pos.offsetWidth}/${pos.offsetHeight}, isMaximized:${pos.isMaximized}, isFullscreen:${pos.isFullscreen}"); + + await bind.setLocalFlutterOption( + k: windowFramePrefix + key.type.name, v: pos.toString()); + + if ((key.type == WindowType.RemoteDesktop || + key.type == WindowType.ViewCamera) && + key.windowId != null) { + await _saveSessionWindowPosition(key.type, key.windowId!, + pos.isMaximized ?? false, pos.isFullscreen ?? false, pos); + } } } @@ -1810,6 +1924,8 @@ Future _adjustRestoreMainWindowSize(double? width, double? height) async { return Size(restoreWidth, restoreHeight); } +// Consider using Rect.contains() instead, +// though the implementation is not exactly the same. bool isPointInRect(Offset point, Rect rect) { return point.dx >= rect.left && point.dx <= rect.right && @@ -1818,7 +1934,7 @@ bool isPointInRect(Offset point, Rect rect) { } /// return null means center -Future _adjustRestoreMainWindowOffset( +Future _adjustRestoreMainWindowOffset( double? left, double? top, double? width, @@ -1828,51 +1944,44 @@ Future _adjustRestoreMainWindowOffset( return null; } - double? frameLeft; - double? frameTop; - double? frameRight; - double? frameBottom; - double devicePixelRatio = 1.0; - if (isDesktop || isWebDesktop) { - for (final screen in await window_size.getScreenList()) { - if (isPointInRect(Offset(left, top), screen.visibleFrame)) { - devicePixelRatio = screen.scaleFactor; + final screens = await window_size.getScreenList(); + if (screens.isNotEmpty) { + final windowRect = Rect.fromLTWH(left, top, width, height); + bool isVisible = false; + for (final screen in screens) { + final intersection = windowRect.intersect(screen.visibleFrame); + if (intersection.width >= 10.0 && intersection.height >= 10.0) { + isVisible = true; + break; + } } - frameLeft = frameLeft == null - ? screen.visibleFrame.left - : min(screen.visibleFrame.left, frameLeft); - frameTop = frameTop == null - ? screen.visibleFrame.top - : min(screen.visibleFrame.top, frameTop); - frameRight = frameRight == null - ? screen.visibleFrame.right - : max(screen.visibleFrame.right, frameRight); - frameBottom = frameBottom == null - ? screen.visibleFrame.bottom - : max(screen.visibleFrame.bottom, frameBottom); + if (!isVisible) { + return null; + } + return Offset(left, top); } } - if (frameLeft == null) { - frameLeft = 0.0; - frameTop = 0.0; - frameRight = ((isDesktop || isWebDesktop) - ? kDesktopMaxDisplaySize - : kMobileMaxDisplaySize) - .toDouble(); - frameBottom = ((isDesktop || isWebDesktop) - ? kDesktopMaxDisplaySize - : kMobileMaxDisplaySize) - .toDouble(); - } + + double frameLeft = 0.0; + double frameTop = 0.0; + double frameRight = ((isDesktop || isWebDesktop) + ? kDesktopMaxDisplaySize + : kMobileMaxDisplaySize) + .toDouble(); + double frameBottom = ((isDesktop || isWebDesktop) + ? kDesktopMaxDisplaySize + : kMobileMaxDisplaySize) + .toDouble(); + final minWidth = 10.0; - if ((left + minWidth) > frameRight! || - (top + minWidth) > frameBottom! || + if ((left + minWidth) > frameRight || + (top + minWidth) > frameBottom || (left + width - minWidth) < frameLeft || - top < frameTop!) { + top < frameTop) { return null; } else { - return OffsetDevicePixelRatio(Offset(left, top), devicePixelRatio); + return Offset(left, top); } } @@ -1897,7 +2006,9 @@ Future restoreWindowPosition(WindowType type, String? pos; // No need to check mainGetLocalBoolOptionSync(kOptionOpenNewConnInTabs) // Though "open in tabs" is true and the new window restore peer position, it's ok. - if (type == WindowType.RemoteDesktop && windowId != null && peerId != null) { + if ((type == WindowType.RemoteDesktop || type == WindowType.ViewCamera) && + windowId != null && + peerId != null) { final peerPos = bind.mainGetPeerFlutterOptionSync( id: peerId, k: windowFramePrefix + type.name); if (peerPos.isNotEmpty) { @@ -1909,10 +2020,26 @@ Future restoreWindowPosition(WindowType type, var lpos = LastWindowPosition.loadFromString(pos); if (lpos == null) { - debugPrint("no window position saved, ignoring position restoration"); - return false; + debugPrint("No window position saved, trying to center the window."); + switch (type) { + case WindowType.Main: + // Center the main window only if no position is saved (on first run). + if (isWindows || isLinux) { + await windowManager.center(); + } + // For MacOS, the window is already centered by default. + // See https://github.com/rustdesk/rustdesk/blob/9b9276e7524523d7f667fefcd0694d981443df0e/flutter/macos/Runner/Base.lproj/MainMenu.xib#L333 + // If `` in `` is not set, the window will be centered. + break; + default: + // No need to change the position of a sub window if no position is saved, + // since the default position is already centered. + // https://github.com/rustdesk/rustdesk/blob/317639169359936f7f9f85ef445ec9774218772d/flutter/lib/utils/multi_window_manager.dart#L163 + break; + } + return true; } - if (type == WindowType.RemoteDesktop) { + if (type == WindowType.RemoteDesktop || type == WindowType.ViewCamera) { if (!isRemotePeerPos && windowId != null) { if (lpos.offsetWidth != null) { lpos.offsetWidth = lpos.offsetWidth! + windowId * kNewWindowOffset; @@ -1932,47 +2059,23 @@ Future restoreWindowPosition(WindowType type, } final size = await _adjustRestoreMainWindowSize(lpos.width, lpos.height); - final offsetDevicePixelRatio = await _adjustRestoreMainWindowOffset( + final offsetLeftTop = await _adjustRestoreMainWindowOffset( lpos.offsetWidth, lpos.offsetHeight, size.width, size.height, ); debugPrint( - "restore lpos: ${size.width}/${size.height}, offset:${offsetDevicePixelRatio?.offset.dx}/${offsetDevicePixelRatio?.offset.dy}, devicePixelRatio:${offsetDevicePixelRatio?.devicePixelRatio}, isMaximized: ${lpos.isMaximized}, isFullscreen: ${lpos.isFullscreen}"); + "restore lpos: ${size.width}/${size.height}, offset:${offsetLeftTop?.dx}/${offsetLeftTop?.dy}, isMaximized: ${lpos.isMaximized}, isFullscreen: ${lpos.isFullscreen}"); switch (type) { case WindowType.Main: - // https://github.com/rustdesk/rustdesk/issues/8038 - // `setBounds()` in `window_manager` will use the current devicePixelRatio. - // So we need to adjust the offset by the scale factor. - // https://github.com/rustdesk-org/window_manager/blob/f19acdb008645366339444a359a45c3257c8b32e/windows/window_manager.cpp#L701 - if (isWindows) { - double? curDevicePixelRatio; - Offset curPos = await windowManager.getPosition(); - for (final screen in await window_size.getScreenList()) { - if (isPointInRect(curPos, screen.visibleFrame)) { - curDevicePixelRatio = screen.scaleFactor; - } - } - if (curDevicePixelRatio != null && - curDevicePixelRatio != 0 && - offsetDevicePixelRatio != null) { - if (offsetDevicePixelRatio.devicePixelRatio != 0) { - final scale = - offsetDevicePixelRatio.devicePixelRatio / curDevicePixelRatio; - offsetDevicePixelRatio.offset = - offsetDevicePixelRatio.offset.scale(scale, scale); - debugPrint( - "restore new offset: ${offsetDevicePixelRatio.offset.dx}/${offsetDevicePixelRatio.offset.dy}, scale:$scale"); - } - } - } restorePos() async { - if (offsetDevicePixelRatio == null) { + if (offsetLeftTop == null) { await windowManager.center(); } else { - await windowManager.setPosition(offsetDevicePixelRatio.offset); + await windowManager.setPosition(offsetLeftTop, + ignoreDevicePixelRatio: _ignoreDevicePixelRatio); } } if (lpos.isMaximized == true) { @@ -1981,20 +2084,39 @@ Future restoreWindowPosition(WindowType type, await windowManager.maximize(); } } else { - if (!bind.isIncomingOnly() || bind.isOutgoingOnly()) { - await windowManager.setSize(size); + final storeSize = !bind.isIncomingOnly() || bind.isOutgoingOnly(); + if (isWindows) { + if (storeSize) { + // We need to set the window size first to avoid the incorrect size in some special cases. + // E.g. There are two monitors, the left one is 100% DPI and the right one is 175% DPI. + // The window belongs to the left monitor, but if it is moved a little to the right, it will belong to the right monitor. + // After restoring, the size will be incorrect. + // See known issue in https://github.com/rustdesk/rustdesk/pull/9840 + await windowManager.setSize(size, + ignoreDevicePixelRatio: _ignoreDevicePixelRatio); + } + await restorePos(); + if (storeSize) { + await windowManager.setSize(size, + ignoreDevicePixelRatio: _ignoreDevicePixelRatio); + } + } else { + if (storeSize) { + await windowManager.setSize(size, + ignoreDevicePixelRatio: _ignoreDevicePixelRatio); + } + await restorePos(); } - await restorePos(); } return true; default: final wc = WindowController.fromWindowId(windowId!); restoreFrame() async { - if (offsetDevicePixelRatio == null) { + if (offsetLeftTop == null) { await wc.center(); } else { - final frame = Rect.fromLTWH(offsetDevicePixelRatio.offset.dx, - offsetDevicePixelRatio.offset.dy, size.width, size.height); + final frame = Rect.fromLTWH( + offsetLeftTop.dx, offsetLeftTop.dy, size.width, size.height); await wc.setFrame(frame); } } @@ -2086,8 +2208,14 @@ StreamSubscription? listenUniLinks({handleByFlutter = true}) { enum UriLinkType { remoteDesktop, fileTransfer, + viewCamera, portForward, rdp, + terminal, +} + +setEnvTerminalAdmin() { + bind.mainSetEnv(key: 'IS_TERMINAL_ADMIN', value: 'Y'); } // uri link handler @@ -2137,6 +2265,11 @@ bool handleUriLink({List? cmdArgs, Uri? uri, String? uriString}) { id = args[i + 1]; i++; break; + case '--view-camera': + type = UriLinkType.viewCamera; + id = args[i + 1]; + i++; + break; case '--port-forward': type = UriLinkType.portForward; id = args[i + 1]; @@ -2147,6 +2280,17 @@ bool handleUriLink({List? cmdArgs, Uri? uri, String? uriString}) { id = args[i + 1]; i++; break; + case '--terminal': + type = UriLinkType.terminal; + id = args[i + 1]; + i++; + break; + case '--terminal-admin': + setEnvTerminalAdmin(); + type = UriLinkType.terminal; + id = args[i + 1]; + i++; + break; case '--password': password = args[i + 1]; i++; @@ -2178,6 +2322,12 @@ bool handleUriLink({List? cmdArgs, Uri? uri, String? uriString}) { password: password, forceRelay: forceRelay); }); break; + case UriLinkType.viewCamera: + Future.delayed(Duration.zero, () { + rustDeskWinManager.newViewCamera(id!, + password: password, forceRelay: forceRelay); + }); + break; case UriLinkType.portForward: Future.delayed(Duration.zero, () { rustDeskWinManager.newPortForward(id!, false, @@ -2190,6 +2340,12 @@ bool handleUriLink({List? cmdArgs, Uri? uri, String? uriString}) { password: password, forceRelay: forceRelay); }); break; + case UriLinkType.terminal: + Future.delayed(Duration.zero, () { + rustDeskWinManager.newTerminal(id!, + password: password, forceRelay: forceRelay); + }); + break; } return true; @@ -2201,7 +2357,16 @@ bool handleUriLink({List? cmdArgs, Uri? uri, String? uriString}) { List? urlLinkToCmdArgs(Uri uri) { String? command; String? id; - final options = ["connect", "play", "file-transfer", "port-forward", "rdp"]; + final options = [ + "connect", + "play", + "file-transfer", + "view-camera", + "port-forward", + "rdp", + "terminal", + "terminal-admin", + ]; if (uri.authority.isEmpty && uri.path.split('').every((char) => char == '/')) { return []; @@ -2211,6 +2376,19 @@ List? urlLinkToCmdArgs(Uri uri) { id = uri.path.substring("/new/".length); } else if (uri.authority == "config") { if (isAndroid || isIOS) { + final allowDeepLinkServerSettings = + bind.mainGetBuildinOption(key: kOptionAllowDeepLinkServerSettings) == + 'Y'; + if (!allowDeepLinkServerSettings) { + debugPrint( + "Ignore rustdesk://config because $kOptionAllowDeepLinkServerSettings is not enabled."); + // Keep the user-facing error generic; detailed rejection reason is in debug logs. + // Delay toast to avoid missing overlay during cold-start deeplink handling. + Timer(Duration(seconds: 1), () { + showToast(translate('Failed')); + }); + return null; + } final config = uri.path.substring("/".length); // add a timer to make showToast work Timer(Duration(seconds: 1), () { @@ -2220,28 +2398,32 @@ List? urlLinkToCmdArgs(Uri uri) { return null; } else if (uri.authority == "password") { if (isAndroid || isIOS) { + final allowDeepLinkPassword = + bind.mainGetBuildinOption(key: kOptionAllowDeepLinkPassword) == 'Y'; + if (!allowDeepLinkPassword) { + debugPrint( + "Ignore rustdesk://password because $kOptionAllowDeepLinkPassword is not enabled."); + // Keep the user-facing error generic; detailed rejection reason is in debug logs. + // Delay toast to avoid missing overlay during cold-start deeplink handling. + Timer(Duration(seconds: 1), () { + showToast(translate('Failed')); + }); + return null; + } final password = uri.path.substring("/".length); if (password.isNotEmpty) { Timer(Duration(seconds: 1), () async { - await bind.mainSetPermanentPassword(password: password); - showToast(translate('Successful')); + final ok = + await bind.mainSetPermanentPasswordWithResult(password: password); + showToast(translate(ok ? 'Successful' : 'Failed')); }); } } } else if (options.contains(uri.authority)) { - final optionIndex = options.indexOf(uri.authority); command = '--${uri.authority}'; if (uri.path.length > 1) { id = uri.path.substring(1); } - if (isMobile && id != null) { - if (optionIndex == 0 || optionIndex == 1) { - connect(Get.context!, id); - } else if (optionIndex == 2) { - connect(Get.context!, id, isFileTransfer: true); - } - return null; - } } else if (uri.authority.length > 2 && (uri.path.length <= 1 || (uri.path == '/r' || uri.path.startsWith('/r@')))) { @@ -2255,26 +2437,46 @@ List? urlLinkToCmdArgs(Uri uri) { } } - var key = uri.queryParameters["key"]; + var queryParameters = + uri.queryParameters.map((k, v) => MapEntry(k.toLowerCase(), v)); + + var key = queryParameters["key"]; if (id != null) { if (key != null) { id = "$id?key=$key"; } } - if (isMobile) { - if (id != null) { - final forceRelay = uri.queryParameters["relay"] != null; - connect(Get.context!, id, forceRelay: forceRelay); - return null; + if (isMobile && id != null) { + final forceRelay = queryParameters["relay"] != null; + final password = queryParameters["password"]; + + // Determine connection type based on command + if (command == '--file-transfer') { + connect(Get.context!, id, + isFileTransfer: true, forceRelay: forceRelay, password: password); + } else if (command == '--view-camera') { + connect(Get.context!, id, + isViewCamera: true, forceRelay: forceRelay, password: password); + } else if (command == '--terminal') { + connect(Get.context!, id, + isTerminal: true, forceRelay: forceRelay, password: password); + } else if (command == 'terminal-admin') { + setEnvTerminalAdmin(); + connect(Get.context!, id, + isTerminal: true, forceRelay: forceRelay, password: password); + } else { + // Default to remote desktop for '--connect', '--play', or direct connection + connect(Get.context!, id, forceRelay: forceRelay, password: password); } + return null; } List args = List.empty(growable: true); if (command != null && id != null) { args.add(command); args.add(id); - var param = uri.queryParameters; + var param = queryParameters; String? password = param["password"]; if (password != null) args.addAll(['--password', password]); String? switch_uuid = param["switch_uuid"]; @@ -2288,6 +2490,8 @@ List? urlLinkToCmdArgs(Uri uri) { connectMainDesktop(String id, {required bool isFileTransfer, + required bool isViewCamera, + required bool isTerminal, required bool isTcpTunneling, required bool isRDP, bool? forceRelay, @@ -2300,12 +2504,24 @@ connectMainDesktop(String id, isSharedPassword: isSharedPassword, connToken: connToken, forceRelay: forceRelay); + } else if (isViewCamera) { + await rustDeskWinManager.newViewCamera(id, + password: password, + isSharedPassword: isSharedPassword, + connToken: connToken, + forceRelay: forceRelay); } else if (isTcpTunneling || isRDP) { await rustDeskWinManager.newPortForward(id, isRDP, password: password, isSharedPassword: isSharedPassword, connToken: connToken, forceRelay: forceRelay); + } else if (isTerminal) { + await rustDeskWinManager.newTerminal(id, + password: password, + isSharedPassword: isSharedPassword, + connToken: connToken, + forceRelay: forceRelay); } else { await rustDeskWinManager.newRemoteDesktop(id, password: password, @@ -2316,10 +2532,13 @@ connectMainDesktop(String id, /// Connect to a peer with [id]. /// If [isFileTransfer], starts a session only for file transfer. +/// If [isViewCamera], starts a session only for view camera. /// If [isTcpTunneling], starts a session only for tcp tunneling. /// If [isRDP], starts a session only for rdp. connect(BuildContext context, String id, {bool isFileTransfer = false, + bool isViewCamera = false, + bool isTerminal = false, bool isTcpTunneling = false, bool isRDP = false, bool forceRelay = false, @@ -2342,7 +2561,7 @@ connect(BuildContext context, String id, id = id.replaceAll(' ', ''); final oldId = id; id = await bind.mainHandleRelayId(id: id); - final forceRelay2 = id != oldId || forceRelay; + forceRelay = id != oldId || forceRelay; assert(!(isFileTransfer && isTcpTunneling && isRDP), "more than one connect type"); @@ -2351,16 +2570,20 @@ connect(BuildContext context, String id, await connectMainDesktop( id, isFileTransfer: isFileTransfer, + isViewCamera: isViewCamera, + isTerminal: isTerminal, isTcpTunneling: isTcpTunneling, isRDP: isRDP, password: password, isSharedPassword: isSharedPassword, - forceRelay: forceRelay2, + forceRelay: forceRelay, ); } else { await rustDeskWinManager.call(WindowType.Main, kWindowConnect, { 'id': id, 'isFileTransfer': isFileTransfer, + 'isViewCamera': isViewCamera, + 'isTerminal': isTerminal, 'isTcpTunneling': isTcpTunneling, 'isRDP': isRDP, 'password': password, @@ -2394,10 +2617,52 @@ connect(BuildContext context, String id, context, MaterialPageRoute( builder: (BuildContext context) => FileManagerPage( - id: id, password: password, isSharedPassword: isSharedPassword), + id: id, + password: password, + isSharedPassword: isSharedPassword, + forceRelay: forceRelay), ), ); } + } else if (isViewCamera) { + if (isWeb) { + Navigator.push( + context, + MaterialPageRoute( + builder: (BuildContext context) => + desktop_view_camera.ViewCameraPage( + key: ValueKey(id), + id: id, + toolbarState: ToolbarState(), + password: password, + isSharedPassword: isSharedPassword, + ), + ), + ); + } else { + Navigator.push( + context, + MaterialPageRoute( + builder: (BuildContext context) => ViewCameraPage( + id: id, + password: password, + isSharedPassword: isSharedPassword, + forceRelay: forceRelay), + ), + ); + } + } else if (isTerminal) { + Navigator.push( + context, + MaterialPageRoute( + builder: (BuildContext context) => TerminalPage( + id: id, + password: password, + isSharedPassword: isSharedPassword, + forceRelay: forceRelay, + ), + ), + ); } else { if (isWeb) { Navigator.push( @@ -2408,7 +2673,6 @@ connect(BuildContext context, String id, id: id, toolbarState: ToolbarState(), password: password, - forceRelay: forceRelay, isSharedPassword: isSharedPassword, ), ), @@ -2418,7 +2682,10 @@ connect(BuildContext context, String id, context, MaterialPageRoute( builder: (BuildContext context) => RemotePage( - id: id, password: password, isSharedPassword: isSharedPassword), + id: id, + password: password, + isSharedPassword: isSharedPassword, + forceRelay: forceRelay), ), ); } @@ -2444,6 +2711,55 @@ class SimpleWrapper { SimpleWrapper(this.value); } +/// Wakelock manager with reference counting for desktop. +/// Ensures wakelock is only disabled when all sessions are closed/minimized. +/// +/// Note: Each isolate has its own WakelockPlus instance with independent assertion. +/// As long as one isolate has wakelock enabled, the screen stays awake. +/// This manager handles multiple tabs within the same isolate. +class WakelockManager { + static final Set _enabledKeys = {}; + // Don't use WakelockPlus.enabled, it causes error on Android: + // Unhandled Exception: FormatException: Message corrupted + // + // On Linux, multiple enable() calls create only one inhibit, but each disable() + // only releases if _cookie != null. So we need our own _enabled state to avoid + // calling disable() when not enabled. + // See: https://github.com/fluttercommunity/wakelock_plus/blob/0c74e5bbc6aefac57b6c96bb7ef987705ed559ec/wakelock_plus/lib/src/wakelock_plus_linux_plugin.dart#L48 + static bool _enabled = false; + + static void enable(UniqueKey key, {bool isServer = false}) { + // Check if we should keep awake during outgoing sessions + if (!isServer) { + final keepAwake = + mainGetLocalBoolOptionSync(kOptionKeepAwakeDuringOutgoingSessions); + if (!keepAwake) { + return; // Don't enable wakelock if user disabled keep awake + } + } + if (isDesktop) { + _enabledKeys.add(key); + } + if (!_enabled) { + _enabled = true; + WakelockPlus.enable(); + } + } + + static void disable(UniqueKey key) { + if (isDesktop) { + _enabledKeys.remove(key); + if (_enabledKeys.isNotEmpty) { + return; + } + } + if (_enabled) { + WakelockPlus.disable(); + _enabled = false; + } + } +} + /// call this to reload current window. /// /// [Note] @@ -2508,9 +2824,20 @@ Future onActiveWindowChanged() async { // But the app will not close. // // No idea why we need to delay here, `terminate()` itself is also an async function. - Future.delayed(Duration.zero, () { - RdPlatformChannel.instance.terminate(); - }); + // + // A quick workaround, use `Timer.periodic` to avoid the app not closing. + // Because `await windowManager.close()` and `RdPlatformChannel.instance.terminate()` + // may not work since `Flutter 3.24.4`, see the following logs. + // A delay will allow the app to close. + // + //``` + // embedder.cc (2725): 'FlutterPlatformMessageCreateResponseHandle' returned 'kInvalidArguments'. Engine handle was invalid. + // 2024-11-11 11:41:11.546 RustDesk[90272:2567686] Failed to create a FlutterPlatformMessageResponseHandle (2) + // embedder.cc (2672): 'FlutterEngineSendPlatformMessage' returned 'kInvalidArguments'. Invalid engine handle. + // 2024-11-11 11:41:11.565 RustDesk[90272:2567686] Failed to send message to Flutter engine on channel 'flutter/lifecycle' (2). + // ``` + periodic_immediate( + Duration(milliseconds: 30), RdPlatformChannel.instance.terminate); } } } @@ -2562,6 +2889,8 @@ bool get kUseCompatibleUiMode => isWindows && const [WindowsTarget.w7].contains(windowsBuildNumber.windowsVersion); +bool get isWin10 => windowsBuildNumber.windowsVersion == WindowsTarget.w10; + class ServerConfig { late String idServer; late String relayServer; @@ -2587,7 +2916,7 @@ class ServerConfig { } catch (err) { final input = msg.split('').reversed.join(''); final bytes = base64Decode(base64.normalize(input)); - json = jsonDecode(utf8.decode(bytes)); + json = jsonDecode(utf8.decode(bytes, allowMalformed: true)); } idServer = json['host'] ?? ''; relayServer = json['relay'] ?? ''; @@ -2603,7 +2932,7 @@ class ServerConfig { config['relay'] = relayServer.trim(); config['api'] = apiServer.trim(); config['key'] = key.trim(); - return base64Encode(Uint8List.fromList(jsonEncode(config).codeUnits)) + return base64UrlEncode(Uint8List.fromList(jsonEncode(config).codeUnits)) .split('') .reversed .join(); @@ -2671,6 +3000,8 @@ String getWindowName({WindowType? overrideType}) { return name; case WindowType.FileTransfer: return "File Transfer - $name"; + case WindowType.ViewCamera: + return "View Camera - $name"; case WindowType.PortForward: return "Port Forward - $name"; case WindowType.RemoteDesktop: @@ -2702,7 +3033,7 @@ Future updateSystemWindowTheme() async { /// /// Note: not found a general solution for rust based AVFoundation bingding. /// [AVFoundation] crate has compile error. -const kMacOSPermChannel = MethodChannel("org.rustdesk.rustdesk/macos"); +const kMacOSPermChannel = MethodChannel("org.rustdesk.rustdesk/host"); enum PermissionAuthorizeType { undetermined, @@ -2726,30 +3057,6 @@ Future osxRequestAudio() async { return await kMacOSPermChannel.invokeMethod("requestRecordAudio"); } -class DraggableNeverScrollableScrollPhysics extends ScrollPhysics { - /// Creates scroll physics that does not let the user scroll. - const DraggableNeverScrollableScrollPhysics({super.parent}); - - @override - DraggableNeverScrollableScrollPhysics applyTo(ScrollPhysics? ancestor) { - return DraggableNeverScrollableScrollPhysics(parent: buildParent(ancestor)); - } - - @override - bool shouldAcceptUserOffset(ScrollMetrics position) { - // TODO: find a better solution to check if the offset change is caused by the scrollbar. - // Workaround: when dragging with the scrollbar, it always triggers an [IdleScrollActivity]. - if (position is ScrollPositionWithSingleContext) { - // ignore: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member - return position.activity is IdleScrollActivity; - } - return false; - } - - @override - bool get allowImplicitScrolling => false; -} - Widget futureBuilder( {required Future? future, required Widget Function(dynamic data) hasData}) { return FutureBuilder( @@ -2793,12 +3100,29 @@ Future start_service(bool is_start) async { } Future canBeBlocked() async { - var access_mode = await bind.mainGetOption(key: kOptionAccessMode); + if (isWeb) { + // Web can only act as a controller, never as a controlled side, + // so it should never be blocked by a remote session. + return false; + } + // First check control permission + final controlPermission = await bind.mainGetCommon( + key: "is-remote-modify-enabled-by-control-permissions"); + if (controlPermission == "true") { + return false; + } else if (controlPermission == "false") { + return true; + } + + // Check local settings + var accessMode = await bind.mainGetOption(key: kOptionAccessMode); + var isCustomAccessMode = accessMode != 'full' && accessMode != 'view'; var option = option2bool(kOptionAllowRemoteConfigModification, await bind.mainGetOption(key: kOptionAllowRemoteConfigModification)); - return access_mode == 'view' || (access_mode.isEmpty && !option); + return accessMode == 'view' || (isCustomAccessMode && !option); } +// to-do: web not implemented Future shouldBeBlocked(RxBool block, WhetherUseRemoteBlock? use) async { if (use != null && !await use()) { block.value = false; @@ -2829,7 +3153,7 @@ Widget buildRemoteBlock( onExit: (event) => block.value = false, child: Stack(children: [ // scope block tab - FocusScope(child: child, canRequestFocus: !block.value), + preventMouseKeyBuilder(child: child, block: block.value), // mask block click, cm not block click and still use check_click_time to avoid block local click if (mask) Offstage( @@ -2841,6 +3165,11 @@ Widget buildRemoteBlock( )); } +Widget preventMouseKeyBuilder({required Widget child, required bool block}) { + return ExcludeFocus( + excluding: block, child: AbsorbPointer(child: child, absorbing: block)); +} + Widget unreadMessageCountBuilder(RxInt? count, {double? size, double? fontSize}) { return Obx(() => Offstage( @@ -3055,6 +3384,7 @@ openMonitorInNewTabOrWindow(int i, String peerId, PeerInfo pi, 'peer_id': peerId, 'display': i, 'display_count': pi.displays.length, + 'window_type': (kWindowType ?? WindowType.RemoteDesktop).index, }; if (screenRect != null) { args['screen_rect'] = { @@ -3069,12 +3399,12 @@ openMonitorInNewTabOrWindow(int i, String peerId, PeerInfo pi, } setNewConnectWindowFrame(int windowId, String peerId, int preSessionCount, - int? display, Rect? screenRect) async { + WindowType windowType, int? display, Rect? screenRect) async { if (screenRect == null) { // Do not restore window position to new connection if there's a pre-session. // https://github.com/rustdesk/rustdesk/discussions/8825 if (preSessionCount == 0) { - await restoreWindowPosition(WindowType.RemoteDesktop, + await restoreWindowPosition(windowType, windowId: windowId, display: display, peerId: peerId); } } else { @@ -3118,21 +3448,24 @@ parseParamScreenRect(Map params) { get isInputSourceFlutter => stateGlobal.getInputSource() == "Input source 2"; -class _ReconnectCountDownButton extends StatefulWidget { - _ReconnectCountDownButton({ +class _CountDownButton extends StatefulWidget { + _CountDownButton({ Key? key, + required this.text, required this.second, required this.onPressed, + this.submitOnTimeout = false, }) : super(key: key); + final String text; final VoidCallback? onPressed; final int second; + final bool submitOnTimeout; @override - State<_ReconnectCountDownButton> createState() => - _ReconnectCountDownButtonState(); + State<_CountDownButton> createState() => _CountDownButtonState(); } -class _ReconnectCountDownButtonState extends State<_ReconnectCountDownButton> { +class _CountDownButtonState extends State<_CountDownButton> { late int _countdownSeconds = widget.second; Timer? _timer; @@ -3153,6 +3486,9 @@ class _ReconnectCountDownButtonState extends State<_ReconnectCountDownButton> { _timer = Timer.periodic(Duration(seconds: 1), (timer) { if (_countdownSeconds <= 0) { timer.cancel(); + if (widget.submitOnTimeout) { + widget.onPressed?.call(); + } } else { setState(() { _countdownSeconds--; @@ -3164,7 +3500,7 @@ class _ReconnectCountDownButtonState extends State<_ReconnectCountDownButton> { @override Widget build(BuildContext context) { return dialogButton( - '${translate('Reconnect')} (${_countdownSeconds}s)', + '${translate(widget.text)} (${_countdownSeconds}s)', onPressed: widget.onPressed, isOutline: true, ); @@ -3354,6 +3690,9 @@ Color? disabledTextColor(BuildContext context, bool enabled) { } Widget loadPowered(BuildContext context) { + if (bind.mainGetBuildinOption(key: "hide-powered-by-me") == 'Y') { + return SizedBox.shrink(); + } return MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( @@ -3543,6 +3882,16 @@ setResizable(bool resizable) { isOptionFixed(String key) => bind.mainIsOptionFixed(key: key); +bool isChangePermanentPasswordDisabled() => + bind.mainGetBuildinOption(key: kOptionDisableChangePermanentPassword) == + 'Y'; + +bool isChangeIdDisabled() => + bind.mainGetBuildinOption(key: kOptionDisableChangeId) == 'Y'; + +bool isUnlockPinDisabled() => + bind.mainGetBuildinOption(key: kOptionDisableUnlockPin) == 'Y'; + bool? _isCustomClient; bool get isCustomClient { _isCustomClient ??= bind.isCustomClient(); @@ -3623,3 +3972,225 @@ List? get subWindowManagerEnableResizeEdges => isWindows void earlyAssert() { assert('\1' == '1'); } + +void checkUpdate() { + if (!isWeb) { + if (!bind.isCustomClient()) { + platformFFI.registerEventHandler( + kCheckSoftwareUpdateFinish, kCheckSoftwareUpdateFinish, + (Map evt) async { + if (evt['url'] is String) { + stateGlobal.updateUrl.value = evt['url']; + } + }); + Timer(const Duration(seconds: 1), () async { + bind.mainGetSoftwareUpdateUrl(); + }); + } + } +} + +// https://github.com/flutter/flutter/issues/153560#issuecomment-2497160535 +// For TextField, TextFormField +extension WorkaroundFreezeLinuxMint on Widget { + Widget workaroundFreezeLinuxMint() { + // No need to check if is Linux Mint, because this workaround is harmless on other platforms. + if (isLinux) { + return ExcludeSemantics(child: this); + } else { + return this; + } + } +} + +// Don't use `extension` here, the border looks weird if using `extension` in my test. +Widget workaroundWindowBorder(BuildContext context, Widget child) { + if (!isWin10) { + return child; + } + + final isLight = Theme.of(context).brightness == Brightness.light; + final borderColor = isLight ? Colors.black87 : Colors.grey; + final width = isLight ? 0.5 : 0.1; + + getBorderWidget(Widget child) { + return Obx(() => + (stateGlobal.isMaximized.isTrue || stateGlobal.fullscreen.isTrue) + ? Offstage() + : child); + } + + final List borders = [ + getBorderWidget(Container( + color: borderColor, + height: width + 0.1, + )) + ]; + if (kWindowType == WindowType.Main && !isLight) { + borders.addAll([ + getBorderWidget(Align( + alignment: Alignment.topLeft, + child: Container( + color: borderColor, + width: width, + ), + )), + getBorderWidget(Align( + alignment: Alignment.topRight, + child: Container( + color: borderColor, + width: width, + ), + )), + getBorderWidget(Align( + alignment: Alignment.bottomCenter, + child: Container( + color: borderColor, + height: width, + ), + )), + ]); + } + return Stack( + children: [ + child, + ...borders, + ], + ); +} + +void updateTextAndPreserveSelection( + TextEditingController controller, String text) { + // Only care about select all for now. + final isSelected = controller.selection.isValid && + controller.selection.end > controller.selection.start; + + // Set text will make the selection invalid. + controller.text = text; + + if (isSelected) { + controller.selection = TextSelection( + baseOffset: 0, extentOffset: controller.value.text.length); + } +} + +List getPrinterNames() { + final printerNamesJson = bind.mainGetPrinterNames(); + if (printerNamesJson.isEmpty) { + return []; + } + try { + final List printerNamesList = jsonDecode(printerNamesJson); + final appPrinterName = '$appName Printer'; + return printerNamesList + .map((e) => e.toString()) + .where((name) => name != appPrinterName) + .toList(); + } catch (e) { + debugPrint('failed to parse printer names, err: $e'); + return []; + } +} + +String _appName = ''; +String get appName { + if (_appName.isEmpty) { + _appName = bind.mainGetAppNameSync(); + } + return _appName; +} + +String getConnectionText(bool secure, bool direct, String streamType) { + String connectionText; + if (secure && direct) { + connectionText = translate("Direct and encrypted connection"); + } else if (secure && !direct) { + connectionText = translate("Relayed and encrypted connection"); + } else if (!secure && direct) { + connectionText = translate("Direct and unencrypted connection"); + } else { + connectionText = translate("Relayed and unencrypted connection"); + } + if (streamType == 'Relay') { + streamType = 'TCP'; + } + if (streamType.isEmpty) { + return connectionText; + } else { + return '$connectionText ($streamType)'; + } +} + +String decode_http_response(http.Response resp) { + try { + // https://github.com/rustdesk/rustdesk-server-pro/discussions/758 + return utf8.decode(resp.bodyBytes, allowMalformed: true); + } catch (e) { + debugPrint('Failed to decode response as UTF-8: $e'); + // Fallback to bodyString which handles encoding automatically + return resp.body; + } +} + +bool peerTabShowNote(PeerTabIndex peerTabIndex) { + return peerTabIndex == PeerTabIndex.ab || peerTabIndex == PeerTabIndex.group; +} + +// TODO: We should support individual bits combinations in the future. +// But for now, just keep it simple, because the old code only supports single button. +// No users have requested multi-button support yet. +String mouseButtonsToPeer(int buttons) { + switch (buttons) { + case kPrimaryMouseButton: + return 'left'; + case kSecondaryMouseButton: + return 'right'; + case kMiddleMouseButton: + return 'wheel'; + case kBackMouseButton: + return 'back'; + case kForwardMouseButton: + return 'forward'; + default: + return ''; + } +} + +/// Build an avatar widget from an avatar URL or data URI string. +/// Returns [fallback] if avatar is empty or cannot be decoded. +/// [borderRadius] defaults to [size]/2 (circle). +Widget? buildAvatarWidget({ + required String avatar, + required double size, + double? borderRadius, + Widget? fallback, +}) { + final trimmed = avatar.trim(); + if (trimmed.isEmpty) return fallback; + + ImageProvider? imageProvider; + if (trimmed.startsWith('data:image/')) { + final comma = trimmed.indexOf(','); + if (comma > 0) { + try { + imageProvider = MemoryImage(base64Decode(trimmed.substring(comma + 1))); + } catch (_) {} + } + } else if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { + imageProvider = NetworkImage(trimmed); + } + + if (imageProvider == null) return fallback; + + final radius = borderRadius ?? size / 2; + return ClipRRect( + borderRadius: BorderRadius.circular(radius), + child: Image( + image: imageProvider, + width: size, + height: size, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => fallback ?? SizedBox.shrink(), + ), + ); +} diff --git a/flutter/lib/common/hbbs/hbbs.dart b/flutter/lib/common/hbbs/hbbs.dart index e189cc7b2..0c729e4df 100644 --- a/flutter/lib/common/hbbs/hbbs.dart +++ b/flutter/lib/common/hbbs/hbbs.dart @@ -25,15 +25,21 @@ enum UserStatus { kDisabled, kNormal, kUnverified } // Is all the fields of the user needed? class UserPayload { String name = ''; + String displayName = ''; + String avatar = ''; String email = ''; String note = ''; + String? verifier; UserStatus status; bool isAdmin = false; UserPayload.fromJson(Map json) : name = json['name'] ?? '', + displayName = json['display_name'] ?? '', + avatar = json['avatar'] ?? '', email = json['email'] ?? '', note = json['note'] ?? '', + verifier = json['verifier'], status = json['status'] == 0 ? UserStatus.kDisabled : json['status'] == -1 @@ -44,6 +50,8 @@ class UserPayload { Map toJson() { final Map map = { 'name': name, + 'display_name': displayName, + 'avatar': avatar, 'status': status == UserStatus.kDisabled ? 0 : status == UserStatus.kUnverified @@ -56,9 +64,14 @@ class UserPayload { Map toGroupCacheJson() { final Map map = { 'name': name, + 'display_name': displayName, }; return map; } + + String get displayNameOrName { + return displayName.trim().isEmpty ? name : displayName; + } } class PeerPayload { @@ -67,6 +80,7 @@ class PeerPayload { int? status; String user = ''; String user_name = ''; + String? device_group_name; String note = ''; PeerPayload.fromJson(Map json) @@ -75,6 +89,7 @@ class PeerPayload { status = json['status'], user = json['user'] ?? '', user_name = json['user_name'] ?? '', + device_group_name = json['device_group_name'] ?? '', note = json['note'] ?? ''; static Peer toPeer(PeerPayload p) { @@ -84,6 +99,8 @@ class PeerPayload { "username": p.info['username'] ?? '', "platform": _platform(p.info['os']), "hostname": p.info['device_name'], + "device_group_name": p.device_group_name, + "note": p.note, }); } @@ -243,15 +260,17 @@ class AbProfile { String name; String owner; String? note; + dynamic info; int rule; - AbProfile(this.guid, this.name, this.owner, this.note, this.rule); + AbProfile(this.guid, this.name, this.owner, this.note, this.rule, this.info); AbProfile.fromJson(Map json) : guid = json['guid'] ?? '', name = json['name'] ?? '', owner = json['owner'] ?? '', note = json['note'] ?? '', + info = json['info'], rule = json['rule'] ?? 0; } @@ -265,3 +284,19 @@ class AbTag { : name = json['name'] ?? '', color = json['color'] ?? ''; } + +class DeviceGroupPayload { + String name; + + DeviceGroupPayload(this.name); + + DeviceGroupPayload.fromJson(Map json) + : name = json['name'] ?? ''; + + Map toGroupCacheJson() { + final Map map = { + 'name': name, + }; + return map; + } +} diff --git a/flutter/lib/common/shared_state.dart b/flutter/lib/common/shared_state.dart index 908c98a70..4f9373ccd 100644 --- a/flutter/lib/common/shared_state.dart +++ b/flutter/lib/common/shared_state.dart @@ -77,9 +77,11 @@ class CurrentDisplayState { class ConnectionType { final Rx _secure = kInvalidValueStr.obs; final Rx _direct = kInvalidValueStr.obs; + final Rx _stream_type = kInvalidValueStr.obs; Rx get secure => _secure; Rx get direct => _direct; + Rx get stream_type => _stream_type; static String get strSecure => 'secure'; static String get strInsecure => 'insecure'; @@ -94,9 +96,14 @@ class ConnectionType { _direct.value = v ? strDirect : strIndirect; } + void setStreamType(String v) { + _stream_type.value = v; + } + bool isValid() { return _secure.value != kInvalidValueStr && - _direct.value != kInvalidValueStr; + _direct.value != kInvalidValueStr && + _stream_type.value != kInvalidValueStr; } } diff --git a/flutter/lib/common/widgets/address_book.dart b/flutter/lib/common/widgets/address_book.dart index 78bd20ef0..054a1666c 100644 --- a/flutter/lib/common/widgets/address_book.dart +++ b/flutter/lib/common/widgets/address_book.dart @@ -1,5 +1,6 @@ import 'dart:math'; +import 'package:bot_toast/bot_toast.dart'; import 'package:dropdown_button2/dropdown_button2.dart'; import 'package:dynamic_layouts/dynamic_layouts.dart'; import 'package:flutter/material.dart'; @@ -53,9 +54,9 @@ class _AddressBookState extends State { const LinearProgressIndicator(), buildErrorBanner(context, loading: gFFI.abModel.currentAbLoading, - err: gFFI.abModel.currentAbPullError, + err: gFFI.abModel.abPullError, retry: null, - close: () => gFFI.abModel.currentAbPullError.value = ''), + close: gFFI.abModel.clearPullErrors), buildErrorBanner(context, loading: gFFI.abModel.currentAbLoading, err: gFFI.abModel.currentAbPushError, @@ -285,7 +286,7 @@ class _AddressBookState extends State { borderRadius: BorderRadius.circular(8), ), ), - ), + ).workaroundFreezeLinuxMint(), ), searchMatchFn: (item, searchValue) { return item.value @@ -316,13 +317,14 @@ class _AddressBookState extends State { Widget _buildTags() { return Obx(() { - final List tags; + List tags; if (gFFI.abModel.sortTags.value) { tags = gFFI.abModel.currentAbTags.toList(); tags.sort(); } else { - tags = gFFI.abModel.currentAbTags; + tags = gFFI.abModel.currentAbTags.toList(); } + tags = [kUntagged, ...tags].toList(); final editPermission = gFFI.abModel.current.canWrite(); tagBuilder(String e) { return AddressBookTag( @@ -464,6 +466,7 @@ class _AddressBookState extends State { IDTextEditingController idController = IDTextEditingController(text: ''); TextEditingController aliasController = TextEditingController(text: ''); TextEditingController passwordController = TextEditingController(text: ''); + TextEditingController noteController = TextEditingController(text: ''); final tags = List.of(gFFI.abModel.currentAbTags); var selectedTag = List.empty(growable: true).obs; final style = TextStyle(fontSize: 14.0); @@ -492,7 +495,11 @@ class _AddressBookState extends State { password = passwordController.text; } String? errMsg2 = await gFFI.abModel.addIdToCurrent( - id, aliasController.text.trim(), password, selectedTag); + id, + aliasController.text.trim(), + password, + selectedTag, + noteController.text); if (errMsg2 != null) { setState(() { isInProgress = false; @@ -507,13 +514,13 @@ class _AddressBookState extends State { double marginBottom = 4; - row({required Widget lable, required Widget input}) { + row({required Widget label, required Widget input}) { makeChild(bool isPortrait) => Row( children: [ !isPortrait ? ConstrainedBox( constraints: const BoxConstraints(minWidth: 100), - child: lable.marginOnly(right: 10)) + child: label.marginOnly(right: 10)) : SizedBox.shrink(), Expanded( child: ConstrainedBox( @@ -533,7 +540,7 @@ class _AddressBookState extends State { Column( children: [ row( - lable: Row( + label: Row( children: [ Text( '*', @@ -554,9 +561,9 @@ class _AddressBookState extends State { : translate('ID'), errorText: errorMsg, errorMaxLines: 5), - ))), + ).workaroundFreezeLinuxMint())), row( - lable: Text( + label: Text( translate('Alias'), style: style, ), @@ -567,11 +574,11 @@ class _AddressBookState extends State { ? null : translate('Alias'), ), - )), + ).workaroundFreezeLinuxMint()), ), if (isCurrentAbShared) row( - lable: Text( + label: Text( translate('Password'), style: style, ), @@ -596,8 +603,26 @@ class _AddressBookState extends State { }, ), ), - ), + ).workaroundFreezeLinuxMint(), )), + row( + label: Text( + translate('Note'), + style: style, + ), + input: Obx( + () => TextField( + controller: noteController, + maxLines: 3, + minLines: 1, + maxLength: 300, + decoration: InputDecoration( + labelText: stateGlobal.isPortrait.isFalse + ? null + : translate('Note'), + ), + ).workaroundFreezeLinuxMint(), + )), if (gFFI.abModel.currentAbTags.isNotEmpty) Align( alignment: Alignment.centerLeft, @@ -669,6 +694,14 @@ class _AddressBookState extends State { } else { final tags = field.trim().split(RegExp(r"[\s,;\n]+")); field = tags.join(','); + for (var t in [kUntagged, translate(kUntagged)]) { + if (tags.contains(t)) { + BotToast.showText( + contentColor: Colors.red, text: 'Tag name cannot be "$t"'); + isInProgress = false; + return; + } + } gFFI.abModel.addTags(tags); // final currentPeers } @@ -694,7 +727,7 @@ class _AddressBookState extends State { ), controller: controller, autofocus: true, - ), + ).workaroundFreezeLinuxMint(), ), ], ), @@ -741,12 +774,14 @@ class AddressBookTag extends StatelessWidget { } const double radius = 8; + final isUnTagged = name == kUntagged; + final showAction = showActionMenu && !isUnTagged; return GestureDetector( onTap: onTap, - onTapDown: showActionMenu ? setPosition : null, - onSecondaryTapDown: showActionMenu ? setPosition : null, - onSecondaryTap: showActionMenu ? () => _showMenu(context, pos) : null, - onLongPress: showActionMenu ? () => _showMenu(context, pos) : null, + onTapDown: showAction ? setPosition : null, + onSecondaryTapDown: showAction ? setPosition : null, + onSecondaryTap: showAction ? () => _showMenu(context, pos) : null, + onLongPress: showAction ? () => _showMenu(context, pos) : null, child: Obx(() => Container( decoration: BoxDecoration( color: tags.contains(name) @@ -758,17 +793,18 @@ class AddressBookTag extends StatelessWidget { child: IntrinsicWidth( child: Row( children: [ - Container( - width: radius, - height: radius, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: tags.contains(name) - ? Colors.white - : gFFI.abModel.getCurrentAbTagColor(name)), - ).marginOnly(right: radius / 2), + if (!isUnTagged) + Container( + width: radius, + height: radius, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: tags.contains(name) + ? Colors.white + : gFFI.abModel.getCurrentAbTagColor(name)), + ).marginOnly(right: radius / 2), Expanded( - child: Text(name, + child: Text(isUnTagged ? translate(name) : name, style: TextStyle( overflow: TextOverflow.ellipsis, color: tags.contains(name) ? Colors.white : null)), diff --git a/flutter/lib/common/widgets/audio_input.dart b/flutter/lib/common/widgets/audio_input.dart index 1db439127..1f8f1a8b9 100644 --- a/flutter/lib/common/widgets/audio_input.dart +++ b/flutter/lib/common/widgets/audio_input.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hbb/common.dart'; import 'package:flutter_hbb/models/platform_model.dart'; -const _kWindowsSystemSound = 'System Sound'; +const _kSystemSound = 'System Sound'; typedef AudioINputSetDevice = void Function(String device); typedef AudioInputBuilder = Widget Function( @@ -21,7 +21,7 @@ class AudioInput extends StatelessWidget { : super(key: key); static String getDefault() { - if (isWindows) return translate('System Sound'); + if (bind.mainAudioSupportLoopback()) return translate(_kSystemSound); return ''; } @@ -55,8 +55,8 @@ class AudioInput extends StatelessWidget { static Future> getDevicesInfo( bool isCm, bool isVoiceCall) async { List devices = (await bind.mainGetSoundInputs()).toList(); - if (isWindows) { - devices.insert(0, translate(_kWindowsSystemSound)); + if (bind.mainAudioSupportLoopback()) { + devices.insert(0, translate(_kSystemSound)); } String current = await getValue(isCm, isVoiceCall); return {'devices': devices, 'current': current}; diff --git a/flutter/lib/common/widgets/autocomplete.dart b/flutter/lib/common/widgets/autocomplete.dart index 978d053df..ec64cca18 100644 --- a/flutter/lib/common/widgets/autocomplete.dart +++ b/flutter/lib/common/widgets/autocomplete.dart @@ -1,4 +1,3 @@ -import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_hbb/common/formatter/id_formatter.dart'; import '../../../models/platform_model.dart'; @@ -6,56 +5,104 @@ import 'package:flutter_hbb/models/peer_model.dart'; import 'package:flutter_hbb/common.dart'; import 'package:flutter_hbb/common/widgets/peer_card.dart'; -Future> getAllPeers() async { - Map recentPeers = jsonDecode(bind.mainLoadRecentPeersSync()); - Map lanPeers = jsonDecode(bind.mainLoadLanPeersSync()); - Map combinedPeers = {}; +class AllPeersLoader { + List peers = []; - void mergePeers(Map peers) { - if (peers.containsKey("peers")) { - dynamic peerData = peers["peers"]; + bool _isPeersLoading = false; + bool _isPeersLoaded = false; - if (peerData is String) { - try { - peerData = jsonDecode(peerData); - } catch (e) { - print("Error decoding peers: $e"); - return; - } - } + final String _listenerKey = 'AllPeersLoader'; - if (peerData is List) { - for (var peer in peerData) { - if (peer is Map && peer.containsKey("id")) { - String id = peer["id"]; - if (!combinedPeers.containsKey(id)) { - combinedPeers[id] = peer; - } - } - } + late void Function(VoidCallback) setState; + + bool get needLoad => !_isPeersLoaded && !_isPeersLoading; + bool get isPeersLoaded => _isPeersLoaded; + + AllPeersLoader(); + + void init(void Function(VoidCallback) setState) { + this.setState = setState; + gFFI.recentPeersModel.addListener(_mergeAllPeers); + gFFI.lanPeersModel.addListener(_mergeAllPeers); + gFFI.abModel.addPeerUpdateListener(_listenerKey, _mergeAllPeers); + gFFI.groupModel.addPeerUpdateListener(_listenerKey, _mergeAllPeers); + } + + void clear() { + gFFI.recentPeersModel.removeListener(_mergeAllPeers); + gFFI.lanPeersModel.removeListener(_mergeAllPeers); + gFFI.abModel.removePeerUpdateListener(_listenerKey); + gFFI.groupModel.removePeerUpdateListener(_listenerKey); + } + + Future getAllPeers() async { + if (!needLoad) { + return; + } + _isPeersLoading = true; + + if (gFFI.recentPeersModel.peers.isEmpty) { + bind.mainLoadRecentPeers(); + } + if (gFFI.lanPeersModel.peers.isEmpty) { + bind.mainLoadLanPeers(); + } + // No need to care about peers from abModel, and group model. + // Because they will pull data in `refreshCurrentUser()` on startup. + + final startTime = DateTime.now(); + _mergeAllPeers(); + final diffTime = DateTime.now().difference(startTime).inMilliseconds; + if (diffTime < 100) { + await Future.delayed(Duration(milliseconds: diffTime)); + } + } + + void _mergeAllPeers() { + Map combinedPeers = {}; + for (var p in gFFI.abModel.allPeers()) { + if (!combinedPeers.containsKey(p.id)) { + combinedPeers[p.id] = p.toJson(); } } - } - - mergePeers(recentPeers); - mergePeers(lanPeers); - for (var p in gFFI.abModel.allPeers()) { - if (!combinedPeers.containsKey(p.id)) { - combinedPeers[p.id] = p.toJson(); + for (var p in gFFI.groupModel.peers.map((e) => Peer.copy(e)).toList()) { + if (!combinedPeers.containsKey(p.id)) { + combinedPeers[p.id] = p.toJson(); + } } - } - for (var p in gFFI.groupModel.peers.map((e) => Peer.copy(e)).toList()) { - if (!combinedPeers.containsKey(p.id)) { - combinedPeers[p.id] = p.toJson(); + + List parsedPeers = []; + for (var peer in combinedPeers.values) { + parsedPeers.add(Peer.fromJson(peer)); } - } - List parsedPeers = []; + Set peerIds = combinedPeers.keys.toSet(); + for (final peer in gFFI.lanPeersModel.peers) { + if (!peerIds.contains(peer.id)) { + parsedPeers.add(peer); + peerIds.add(peer.id); + } + } - for (var peer in combinedPeers.values) { - parsedPeers.add(Peer.fromJson(peer)); + for (final peer in gFFI.recentPeersModel.peers) { + if (!peerIds.contains(peer.id)) { + parsedPeers.add(peer); + peerIds.add(peer.id); + } + } + for (final id in gFFI.recentPeersModel.restPeerIds) { + if (!peerIds.contains(id)) { + parsedPeers.add(Peer.fromJson({'id': id})); + peerIds.add(id); + } + } + + peers = parsedPeers; + setState(() { + _isPeersLoading = false; + _isPeersLoaded = true; + }); } - return parsedPeers; } class AutocompletePeerTile extends StatefulWidget { diff --git a/flutter/lib/common/widgets/chat_page.dart b/flutter/lib/common/widgets/chat_page.dart index b6611d3ed..4b0954d40 100644 --- a/flutter/lib/common/widgets/chat_page.dart +++ b/flutter/lib/common/widgets/chat_page.dart @@ -167,7 +167,7 @@ class ChatPage extends StatelessWidget implements PageShape { ); }, ), - ); + ).workaroundFreezeLinuxMint(); return SelectionArea(child: chat); }), ], diff --git a/flutter/lib/common/widgets/custom_scale_base.dart b/flutter/lib/common/widgets/custom_scale_base.dart new file mode 100644 index 000000000..6eceef13f --- /dev/null +++ b/flutter/lib/common/widgets/custom_scale_base.dart @@ -0,0 +1,156 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:debounce_throttle/debounce_throttle.dart'; +import 'package:flutter_hbb/consts.dart'; +import 'package:flutter_hbb/models/model.dart'; +import 'package:flutter_hbb/models/platform_model.dart'; +import 'package:flutter_hbb/utils/scale.dart'; +import 'package:flutter_hbb/common.dart'; + +/// Base class providing shared custom scale control logic for both mobile and desktop widgets. +/// Implementations must provide [ffi] and [onScaleChanged] getters. +abstract class CustomScaleControls extends State { + /// FFI instance for session interaction + FFI get ffi; + + /// Callback invoked when scale value changes + ValueChanged? get onScaleChanged; + + late int _scaleValue; + late final Debouncer _debouncerScale; + // Normalized slider position in [0, 1]. We map it nonlinearly to percent. + double _scalePos = 0.0; + + int get scaleValue => _scaleValue; + double get scalePos => _scalePos; + + int mapPosToPercent(double p) => _mapPosToPercent(p); + + static const int minPercent = kScaleCustomMinPercent; + static const int pivotPercent = kScaleCustomPivotPercent; // 100% should be at 1/3 of track + static const int maxPercent = kScaleCustomMaxPercent; + static const double pivotPos = kScaleCustomPivotPos; // first 1/3 → up to 100% + static const double detentEpsilon = kScaleCustomDetentEpsilon; // snap range around pivot (~0.6%) + + // Clamp helper for local use + int _clampScale(int v) => clampCustomScalePercent(v); + + // Map normalized position [0,1] → percent [5,1000] with 100 at 1/3 width. + int _mapPosToPercent(double p) { + if (p <= 0.0) return minPercent; + if (p >= 1.0) return maxPercent; + if (p <= pivotPos) { + final q = p / pivotPos; // 0..1 + final v = minPercent + q * (pivotPercent - minPercent); + return _clampScale(v.round()); + } else { + final q = (p - pivotPos) / (1.0 - pivotPos); // 0..1 + final v = pivotPercent + q * (maxPercent - pivotPercent); + return _clampScale(v.round()); + } + } + + // Map percent [5,1000] → normalized position [0,1] + double _mapPercentToPos(int percent) { + final p = _clampScale(percent); + if (p <= pivotPercent) { + final q = (p - minPercent) / (pivotPercent - minPercent); + return q * pivotPos; + } else { + final q = (p - pivotPercent) / (maxPercent - pivotPercent); + return pivotPos + q * (1.0 - pivotPos); + } + } + + // Snap normalized position to the pivot when close to it + double _snapNormalizedPos(double p) { + if ((p - pivotPos).abs() <= detentEpsilon) return pivotPos; + if (p < 0.0) return 0.0; + if (p > 1.0) return 1.0; + return p; + } + + @override + void initState() { + super.initState(); + _scaleValue = 100; + _debouncerScale = Debouncer( + kDebounceCustomScaleDuration, + onChanged: (v) async { + await _applyScale(v); + }, + initialValue: _scaleValue, + ); + WidgetsBinding.instance.addPostFrameCallback((_) async { + try { + final v = await getSessionCustomScalePercent(ffi.sessionId); + if (mounted) { + setState(() { + _scaleValue = v; + _scalePos = _mapPercentToPos(v); + }); + } + } catch (e, st) { + debugPrint('[CustomScale] Failed to get initial value: $e'); + debugPrintStack(stackTrace: st); + } + }); + } + + Future _applyScale(int v) async { + v = clampCustomScalePercent(v); + setState(() { + _scaleValue = v; + }); + try { + await bind.sessionSetFlutterOption( + sessionId: ffi.sessionId, + k: kCustomScalePercentKey, + v: v.toString()); + final curStyle = await bind.sessionGetViewStyle(sessionId: ffi.sessionId); + if (curStyle != kRemoteViewStyleCustom) { + await bind.sessionSetViewStyle( + sessionId: ffi.sessionId, value: kRemoteViewStyleCustom); + } + await ffi.canvasModel.updateViewStyle(); + if (isMobile) { + HapticFeedback.selectionClick(); + } + onScaleChanged?.call(v); + } catch (e, st) { + debugPrint('[CustomScale] Apply failed: $e'); + debugPrintStack(stackTrace: st); + } + } + + void nudgeScale(int delta) { + final next = _clampScale(_scaleValue + delta); + setState(() { + _scaleValue = next; + _scalePos = _mapPercentToPos(next); + }); + onScaleChanged?.call(next); + _debouncerScale.value = next; + } + + @override + void dispose() { + _debouncerScale.cancel(); + super.dispose(); + } + + void onSliderChanged(double v) { + final snapped = _snapNormalizedPos(v); + final next = _mapPosToPercent(snapped); + if (next != _scaleValue || snapped != _scalePos) { + setState(() { + _scalePos = snapped; + _scaleValue = next; + }); + onScaleChanged?.call(next); + _debouncerScale.value = next; + } + } +} diff --git a/flutter/lib/common/widgets/dialog.dart b/flutter/lib/common/widgets/dialog.dart index cc3e06131..7534fb2a1 100644 --- a/flutter/lib/common/widgets/dialog.dart +++ b/flutter/lib/common/widgets/dialog.dart @@ -4,24 +4,32 @@ import 'dart:convert'; import 'package:bot_toast/bot_toast.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart'; import 'package:flutter_hbb/common/shared_state.dart'; import 'package:flutter_hbb/common/widgets/setting_widgets.dart'; import 'package:flutter_hbb/consts.dart'; +import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart'; import 'package:flutter_hbb/models/peer_model.dart'; import 'package:flutter_hbb/models/peer_tab_model.dart'; import 'package:flutter_hbb/models/state_model.dart'; import 'package:get/get.dart'; import 'package:qr_flutter/qr_flutter.dart'; +import 'package:flutter_hbb/utils/http_service.dart' as http; import '../../common.dart'; import '../../models/model.dart'; import '../../models/platform_model.dart'; import 'address_book.dart'; -void clientClose(SessionID sessionId, OverlayDialogManager dialogManager) { - msgBox(sessionId, 'info', 'Close', 'Are you sure to close the connection?', - '', dialogManager); +void clientClose(SessionID sessionId, FFI ffi) async { + if (allowAskForNoteAtEndOfConnection(ffi, true)) { + if (await showConnEndAuditDialogCloseCanceled(ffi: ffi)) { + return; + } + closeConnection(); + } else { + msgBox(sessionId, 'info', 'Close', 'Are you sure to close the connection?', + '', ffi.dialogManager); + } } abstract class ValidationRule { @@ -71,7 +79,7 @@ void changeIdDialog() { final rules = [ RegexValidationRule('starts with a letter', RegExp(r'^[a-zA-Z]')), LengthRangeValidationRule(6, 16), - RegexValidationRule('allowed characters', RegExp(r'^\w*$')) + RegexValidationRule('allowed characters', RegExp(r'^[\w-]*$')) ]; gFFI.dialogManager.show((setState, close, context) { @@ -140,7 +148,7 @@ void changeIdDialog() { msg = ''; }); }, - ), + ).workaroundFreezeLinuxMint(), const SizedBox( height: 8.0, ), @@ -201,13 +209,14 @@ void changeWhiteList({Function()? callback}) async { children: [ Expanded( child: TextField( - maxLines: null, - decoration: InputDecoration( - errorText: msg.isEmpty ? null : translate(msg), - ), - controller: controller, - enabled: !isOptFixed, - autofocus: true), + maxLines: null, + decoration: InputDecoration( + errorText: msg.isEmpty ? null : translate(msg), + ), + controller: controller, + enabled: !isOptFixed, + autofocus: true) + .workaroundFreezeLinuxMint(), ), ], ), @@ -287,22 +296,23 @@ Future changeDirectAccessPort( children: [ Expanded( child: TextField( - maxLines: null, - keyboardType: TextInputType.number, - decoration: InputDecoration( - hintText: '21118', - isCollapsed: true, - prefix: Text('$currentIP : '), - suffix: IconButton( - padding: EdgeInsets.zero, - icon: const Icon(Icons.clear, size: 16), - onPressed: () => controller.clear())), - inputFormatters: [ - FilteringTextInputFormatter.allow(RegExp( - r'^([0-9]|[1-9]\d|[1-9]\d{2}|[1-9]\d{3}|[1-5]\d{4}|6[0-4]\d{3}|65[0-4]\d{2}|655[0-2]\d|6553[0-5])$')), - ], - controller: controller, - autofocus: true), + maxLines: null, + keyboardType: TextInputType.number, + decoration: InputDecoration( + hintText: '21118', + isCollapsed: true, + prefix: Text('$currentIP : '), + suffix: IconButton( + padding: EdgeInsets.zero, + icon: const Icon(Icons.clear, size: 16), + onPressed: () => controller.clear())), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp( + r'^([0-9]|[1-9]\d|[1-9]\d{2}|[1-9]\d{3}|[1-5]\d{4}|6[0-4]\d{3}|65[0-4]\d{2}|655[0-2]\d|6553[0-5])$')), + ], + controller: controller, + autofocus: true) + .workaroundFreezeLinuxMint(), ), ], ), @@ -335,21 +345,22 @@ Future changeAutoDisconnectTimeout(String old) async { children: [ Expanded( child: TextField( - maxLines: null, - keyboardType: TextInputType.number, - decoration: InputDecoration( - hintText: '10', - isCollapsed: true, - suffix: IconButton( - padding: EdgeInsets.zero, - icon: const Icon(Icons.clear, size: 16), - onPressed: () => controller.clear())), - inputFormatters: [ - FilteringTextInputFormatter.allow(RegExp( - r'^([0-9]|[1-9]\d|[1-9]\d{2}|[1-9]\d{3}|[1-5]\d{4}|6[0-4]\d{3}|65[0-4]\d{2}|655[0-2]\d|6553[0-5])$')), - ], - controller: controller, - autofocus: true), + maxLines: null, + keyboardType: TextInputType.number, + decoration: InputDecoration( + hintText: '10', + isCollapsed: true, + suffix: IconButton( + padding: EdgeInsets.zero, + icon: const Icon(Icons.clear, size: 16), + onPressed: () => controller.clear())), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp( + r'^([0-9]|[1-9]\d|[1-9]\d{2}|[1-9]\d{3}|[1-5]\d{4}|6[0-4]\d{3}|65[0-4]\d{2}|655[0-2]\d|6553[0-5])$')), + ], + controller: controller, + autofocus: true) + .workaroundFreezeLinuxMint(), ), ], ), @@ -409,25 +420,39 @@ class DialogTextField extends StatelessWidget { return Row( children: [ Expanded( - child: TextField( - decoration: InputDecoration( - labelText: title, - hintText: hintText, - prefixIcon: prefixIcon, - suffixIcon: suffixIcon, - helperText: helperText, - helperMaxLines: 8, - errorText: errorText, - errorMaxLines: 8, - ), - controller: controller, - focusNode: focusNode, - autofocus: true, - obscureText: obscureText, - keyboardType: keyboardType, - inputFormatters: inputFormatters, - maxLength: maxLength, - ), + child: Column( + children: [ + TextField( + decoration: InputDecoration( + labelText: title, + hintText: hintText, + prefixIcon: prefixIcon, + suffixIcon: suffixIcon, + helperText: helperText, + helperMaxLines: 8, + ), + controller: controller, + focusNode: focusNode, + autofocus: true, + obscureText: obscureText, + keyboardType: keyboardType, + inputFormatters: inputFormatters, + maxLength: maxLength, + ), + if (errorText != null) + Align( + alignment: Alignment.centerLeft, + child: SelectableText( + errorText!, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + fontSize: 12, + ), + textAlign: TextAlign.left, + ).paddingOnly(top: 8, left: 12), + ), + ], + ).workaroundFreezeLinuxMint(), ), ], ).paddingSymmetric(vertical: 4.0); @@ -803,23 +828,33 @@ void enterPasswordDialog( } void enterUserLoginDialog( - SessionID sessionId, OverlayDialogManager dialogManager) async { + SessionID sessionId, + OverlayDialogManager dialogManager, + String osAccountDescTip, + bool canRememberAccount) async { await _connectDialog( sessionId, dialogManager, osUsernameController: TextEditingController(), osPasswordController: TextEditingController(), + osAccountDescTip: osAccountDescTip, + canRememberAccount: canRememberAccount, ); } void enterUserLoginAndPasswordDialog( - SessionID sessionId, OverlayDialogManager dialogManager) async { + SessionID sessionId, + OverlayDialogManager dialogManager, + String osAccountDescTip, + bool canRememberAccount) async { await _connectDialog( sessionId, dialogManager, osUsernameController: TextEditingController(), osPasswordController: TextEditingController(), passwordController: TextEditingController(), + osAccountDescTip: osAccountDescTip, + canRememberAccount: canRememberAccount, ); } @@ -829,17 +864,28 @@ _connectDialog( TextEditingController? osUsernameController, TextEditingController? osPasswordController, TextEditingController? passwordController, + String? osAccountDescTip, + bool canRememberAccount = true, }) async { + final errUsername = ''.obs; var rememberPassword = false; if (passwordController != null) { rememberPassword = await bind.sessionGetRemember(sessionId: sessionId) ?? false; } var rememberAccount = false; - if (osUsernameController != null) { + if (canRememberAccount && osUsernameController != null) { rememberAccount = await bind.sessionGetRemember(sessionId: sessionId) ?? false; } + if (osUsernameController != null) { + osUsernameController.addListener(() { + if (errUsername.value.isNotEmpty) { + errUsername.value = ''; + } + }); + } + dialogManager.dismissAll(); dialogManager.show((setState, close, context) { cancel() { @@ -848,6 +894,13 @@ _connectDialog( } submit() { + if (osUsernameController != null) { + if (osUsernameController.text.trim().isEmpty) { + errUsername.value = translate('Empty Username'); + setState(() {}); + return; + } + } final osUsername = osUsernameController?.text.trim() ?? ''; final osPassword = osPasswordController?.text.trim() ?? ''; final password = passwordController?.text.trim() ?? ''; @@ -911,26 +964,39 @@ _connectDialog( } return Column( children: [ - descWidget(translate('login_linux_tip')), + if (osAccountDescTip != null) descWidget(translate(osAccountDescTip)), DialogTextField( title: translate(DialogTextField.kUsernameTitle), controller: osUsernameController, prefixIcon: DialogTextField.kUsernameIcon, errorText: null, ), + if (errUsername.value.isNotEmpty) + Align( + alignment: Alignment.centerLeft, + child: SelectableText( + errUsername.value, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + fontSize: 12, + ), + textAlign: TextAlign.left, + ).paddingOnly(left: 12, bottom: 2), + ), PasswordWidget( controller: osPasswordController, autoFocus: false, ), - rememberWidget( - translate('remember_account_tip'), - rememberAccount, - (v) { - if (v != null) { - setState(() => rememberAccount = v); - } - }, - ), + if (canRememberAccount) + rememberWidget( + translate('remember_account_tip'), + rememberAccount, + (v) { + if (v != null) { + setState(() => rememberAccount = v); + } + }, + ), ], ); } @@ -1120,7 +1186,7 @@ void showRequestElevationDialog( DialogTextField( controller: userController, title: translate('Username'), - hintText: translate('eg: admin'), + hintText: translate('elevation_username_tip'), prefixIcon: DialogTextField.kUsernameIcon, errorText: errUser.isEmpty ? null : errUser.value, ), @@ -1452,55 +1518,70 @@ showSetOSAccount( }); } +Widget buildNoteTextField({ + required TextEditingController controller, + required VoidCallback onEscape, +}) { + final focusNode = FocusNode( + onKey: (FocusNode node, RawKeyEvent evt) { + if (evt.logicalKey.keyLabel == 'Enter') { + if (evt is RawKeyDownEvent) { + int pos = controller.selection.base.offset; + controller.text = + '${controller.text.substring(0, pos)}\n${controller.text.substring(pos)}'; + controller.selection = + TextSelection.fromPosition(TextPosition(offset: pos + 1)); + } + return KeyEventResult.handled; + } + if (evt.logicalKey.keyLabel == 'Esc') { + if (evt is RawKeyDownEvent) { + onEscape(); + } + return KeyEventResult.handled; + } else { + return KeyEventResult.ignored; + } + }, + ); + + return TextField( + autofocus: true, + keyboardType: TextInputType.multiline, + textInputAction: TextInputAction.newline, + decoration: InputDecoration( + hintText: translate('input note here'), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + contentPadding: EdgeInsets.all(12), + ), + minLines: 5, + maxLines: null, + maxLength: 256, + controller: controller, + focusNode: focusNode, + ).workaroundFreezeLinuxMint(); +} + showAuditDialog(FFI ffi) async { - final controller = TextEditingController(text: ffi.auditNote); + final controller = TextEditingController( + text: bind.sessionGetLastAuditNote(sessionId: ffi.sessionId)); ffi.dialogManager.show((setState, close, context) { submit() { var text = controller.text; bind.sessionSendNote(sessionId: ffi.sessionId, note: text); - ffi.auditNote = text; close(); } - late final focusNode = FocusNode( - onKey: (FocusNode node, RawKeyEvent evt) { - if (evt.logicalKey.keyLabel == 'Enter') { - if (evt is RawKeyDownEvent) { - int pos = controller.selection.base.offset; - controller.text = - '${controller.text.substring(0, pos)}\n${controller.text.substring(pos)}'; - controller.selection = - TextSelection.fromPosition(TextPosition(offset: pos + 1)); - } - return KeyEventResult.handled; - } - if (evt.logicalKey.keyLabel == 'Esc') { - if (evt is RawKeyDownEvent) { - close(); - } - return KeyEventResult.handled; - } else { - return KeyEventResult.ignored; - } - }, - ); - return CustomAlertDialog( title: Text(translate('Note')), content: SizedBox( width: 250, height: 120, - child: TextField( - autofocus: true, - keyboardType: TextInputType.multiline, - textInputAction: TextInputAction.newline, - decoration: const InputDecoration.collapsed( - hintText: 'input note here', - ), - maxLines: null, - maxLength: 256, + child: buildNoteTextField( controller: controller, - focusNode: focusNode, + onEscape: close, )), actions: [ dialogButton('Cancel', onPressed: close, isOutline: true), @@ -1512,6 +1593,223 @@ showAuditDialog(FFI ffi) async { }); } +bool allowAskForNoteAtEndOfConnection(FFI? ffi, bool closedByControlling) { + if (ffi == null) { + return false; + } + return mainGetLocalBoolOptionSync(kOptionAllowAskForNoteAtEndOfConnection) && + bind + .sessionGetAuditServerSync(sessionId: ffi.sessionId, typ: "conn") + .isNotEmpty && + bind.sessionGetAuditGuid(sessionId: ffi.sessionId).isNotEmpty && + bind.sessionGetLastAuditNote(sessionId: ffi.sessionId).isEmpty && + (!closedByControlling || + bind.willSessionCloseCloseSession(sessionId: ffi.sessionId)); +} + +// return value: close canceled +// true: return +// false: go on +Future desktopTryShowTabAuditDialogCloseCancelled( + {required String id, required DesktopTabController tabController}) async { + try { + final page = + tabController.state.value.tabs.firstWhere((tab) => tab.key == id).page; + final ffi = (page as dynamic).ffi; + final res = await showConnEndAuditDialogCloseCanceled(ffi: ffi); + return res; + } catch (e) { + debugPrint('Failed to show audit dialog: $e'); + return false; + } +} + +// return value: +// true: return +// false: go on +Future showConnEndAuditDialogCloseCanceled( + {required FFI ffi, String? type, String? title, String? text}) async { + final res = await _showConnEndAuditDialogCloseCanceled( + ffi: ffi, type: type, title: title, text: text); + if (res == true) { + return true; + } + return false; +} + +// return value: +// true: return +// false / null: go on +Future _showConnEndAuditDialogCloseCanceled({ + required FFI ffi, + String? type, + String? title, + String? text, +}) async { + final closedByControlling = type == null; + final showDialog = allowAskForNoteAtEndOfConnection(ffi, closedByControlling); + if (!showDialog) { + return false; + } + ffi.dialogManager.dismissAll(); + + Future updateAuditNoteByGuid(String auditGuid, String note) async { + debugPrint('Updating audit note for GUID: $auditGuid, note: $note'); + try { + final apiServer = await bind.mainGetApiServer(); + if (apiServer.isEmpty) { + debugPrint('API server is empty, cannot update audit note'); + return; + } + final url = '$apiServer/api/audit'; + var headers = getHttpHeaders(); + headers['Content-Type'] = "application/json"; + final body = jsonEncode({ + 'guid': auditGuid, + 'note': note, + }); + + final response = await http.put( + Uri.parse(url), + headers: headers, + body: body, + ); + + if (response.statusCode == 200) { + debugPrint('Successfully updated audit note for GUID: $auditGuid'); + } else { + debugPrint( + 'Failed to update audit note. Status: ${response.statusCode}, Body: ${response.body}'); + } + } catch (e) { + debugPrint('Error updating audit note: $e'); + } + } + + final controller = TextEditingController(); + bool askForNote = + mainGetLocalBoolOptionSync(kOptionAllowAskForNoteAtEndOfConnection); + final isOptFixed = isOptionFixed(kOptionAllowAskForNoteAtEndOfConnection); + bool isInProgress = false; + + return await ffi.dialogManager.show((setState, close, context) { + cancel() { + close(true); + } + + set() async { + if (isInProgress) return; + setState(() { + isInProgress = true; + }); + var text = controller.text; + if (text.isNotEmpty) { + await updateAuditNoteByGuid( + bind.sessionGetAuditGuid(sessionId: ffi.sessionId), text) + .timeout(const Duration(seconds: 6), onTimeout: () { + debugPrint('updateAuditNoteByGuid timeout after 6s'); + }); + } + // Save the "ask for note" preference + if (!isOptFixed) { + await mainSetLocalBoolOption( + kOptionAllowAskForNoteAtEndOfConnection, askForNote); + } + } + + submit() async { + await set(); + close(false); + } + + final buttons = [ + dialogButton('OK', onPressed: isInProgress ? null : submit) + ]; + if (type == 'relay-hint' || type == 'relay-hint2') { + buttons.add(dialogButton('Retry', onPressed: () async { + await set(); + close(true); + ffi.ffiModel.reconnect(ffi.dialogManager, ffi.sessionId, false); + })); + if (type == 'relay-hint2') { + buttons.add(dialogButton('Connect via relay', onPressed: () async { + await set(); + close(true); + ffi.ffiModel.reconnect(ffi.dialogManager, ffi.sessionId, true); + })); + } + } + if (closedByControlling) { + buttons.add(dialogButton('Cancel', + onPressed: isInProgress ? null : cancel, isOutline: true)); + } + + Widget content; + if (closedByControlling) { + content = SelectionArea( + child: msgboxContent( + 'info', 'Close', 'Are you sure to close the connection?')); + } else { + content = + SelectionArea(child: msgboxContent(type, title ?? '', text ?? '')); + } + + return CustomAlertDialog( + title: null, + content: SizedBox( + width: 350, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + content, + const SizedBox(height: 16), + SizedBox( + height: 120, + child: buildNoteTextField( + controller: controller, + onEscape: cancel, + ), + ), + if (!isOptFixed) ...[ + const SizedBox(height: 8), + InkWell( + onTap: () { + setState(() { + askForNote = !askForNote; + }); + }, + child: Row( + children: [ + Checkbox( + value: askForNote, + onChanged: (value) { + setState(() { + askForNote = value ?? false; + }); + }, + ), + Expanded( + child: Text( + translate('note-at-conn-end-tip'), + style: const TextStyle(fontSize: 13), + ), + ), + ], + ), + ), + ], + if (isInProgress) + const LinearProgressIndicator().marginOnly(top: 4), + ], + )), + actions: buttons, + onSubmit: submit, + onCancel: cancel, + ); + }); +} + void showConfirmSwitchSidesDialog( SessionID sessionId, String id, OverlayDialogManager dialogManager) async { dialogManager.show((setState, close, context) { @@ -1607,6 +1905,28 @@ 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; + 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(); + } + ffi.dialogManager.dismissAll(); + }); + msgBoxCommon( + ffi.dialogManager, + 'Trackpad speed', + TrackpadSpeedWidget( + value: curSpeed, + ), + [btnClose]); +} + void deleteConfirmDialog(Function onSubmit, String title) async { gFFI.dialogManager.show( (setState, close, context) { @@ -1704,6 +2024,49 @@ void editAbTagDialog( }); } +void editAbPeerNoteDialog(String id) { + var isInProgress = false; + final currentNote = gFFI.abModel.getPeerNote(id); + var controller = TextEditingController(text: currentNote); + + gFFI.dialogManager.show((setState, close, context) { + submit() async { + setState(() { + isInProgress = true; + }); + await gFFI.abModel.changeNote(id: id, note: controller.text); + close(); + } + + return CustomAlertDialog( + title: Text(translate("Edit note")), + content: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: controller, + autofocus: true, + maxLines: 3, + minLines: 1, + maxLength: 300, + decoration: InputDecoration( + labelText: translate('Note'), + ), + ).workaroundFreezeLinuxMint(), + // NOT use Offstage to wrap LinearProgressIndicator + if (isInProgress) const LinearProgressIndicator(), + ], + ), + actions: [ + dialogButton("Cancel", onPressed: close, isOutline: true), + dialogButton("OK", onPressed: submit), + ], + onSubmit: submit, + onCancel: close, + ); + }); +} + void renameDialog( {required String oldName, FormFieldValidator? validator, @@ -1748,7 +2111,7 @@ void renameDialog( autofocus: true, decoration: InputDecoration(labelText: translate('Name')), validator: validator, - ), + ).workaroundFreezeLinuxMint(), ), ), // NOT use Offstage to wrap LinearProgressIndicator @@ -1808,7 +2171,7 @@ void changeBot({Function()? callback}) async { decoration: InputDecoration( hintText: translate('Token'), ), - ); + ).workaroundFreezeLinuxMint(); return CustomAlertDialog( title: Text(translate("Telegram bot")), @@ -1999,15 +2362,20 @@ void showWindowsSessionsDialog( return CustomAlertDialog( title: null, - content: msgboxContent(type, title, text), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + msgboxContent(type, title, text).marginOnly(bottom: 12), + ComboBox( + keys: sids, + values: names, + initialKey: selectedUserValue, + onChanged: (value) { + selectedUserValue = value; + }), + ], + ), actions: [ - ComboBox( - keys: sids, - values: names, - initialKey: selectedUserValue, - onChanged: (value) { - selectedUserValue = value; - }), dialogButton('Connect', onPressed: submit, isOutline: false), ], ); @@ -2178,7 +2546,7 @@ void setSharedAbPasswordDialog(String abName, Peer peer) { }, ), ), - ), + ).workaroundFreezeLinuxMint(), if (!gFFI.abModel.current.isPersonal()) Row(children: [ Icon(Icons.info, color: Colors.amber).marginOnly(right: 4), diff --git a/flutter/lib/common/widgets/gestures.dart b/flutter/lib/common/widgets/gestures.dart index 6c2696567..0501ca453 100644 --- a/flutter/lib/common/widgets/gestures.dart +++ b/flutter/lib/common/widgets/gestures.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/gestures.dart'; import 'package:flutter/widgets.dart'; +import 'package:flutter_hbb/common/widgets/remote_input.dart'; enum GestureState { none, @@ -24,6 +25,7 @@ class CustomTouchGestureRecognizer extends ScaleGestureRecognizer { GestureDragStartCallback? onOneFingerPanStart; GestureDragUpdateCallback? onOneFingerPanUpdate; GestureDragEndCallback? onOneFingerPanEnd; + GestureDragCancelCallback? onOneFingerPanCancel; // twoFingerScale : scale + pan event GestureScaleStartCallback? onTwoFingerScaleStart; @@ -86,7 +88,7 @@ class CustomTouchGestureRecognizer extends ScaleGestureRecognizer { // end switch (_currentState) { case GestureState.oneFingerPan: - debugPrint("TwoFingerState.pan onEnd"); + debugPrint("OneFingerState.pan onEnd"); if (onOneFingerPanEnd != null) { onOneFingerPanEnd!(_getDragEndDetails(d)); } @@ -96,6 +98,12 @@ class CustomTouchGestureRecognizer extends ScaleGestureRecognizer { if (onTwoFingerScaleEnd != null) { onTwoFingerScaleEnd!(d); } + if (isSpecialHoldDragActive) { + // If we are in special drag mode, we need to reset the state. + // Otherwise, the next `onTwoFingerScaleUpdate()` will handle a wrong `focalPoint`. + _currentState = GestureState.none; + return; + } break; case GestureState.threeFingerVerticalDrag: debugPrint("ThreeFingerState.vertical onEnd"); @@ -162,6 +170,27 @@ class CustomTouchGestureRecognizer extends ScaleGestureRecognizer { DragEndDetails _getDragEndDetails(ScaleEndDetails d) => DragEndDetails(velocity: d.velocity); + + @override + void rejectGesture(int pointer) { + super.rejectGesture(pointer); + switch (_currentState) { + case GestureState.oneFingerPan: + if (onOneFingerPanCancel != null) { + onOneFingerPanCancel!(); + } + break; + case GestureState.twoFingerScale: + // Reset scale state if needed, currently self-contained + break; + case GestureState.threeFingerVerticalDrag: + // Reset drag state if needed, currently self-contained + break; + default: + break; + } + _currentState = GestureState.none; + } } class HoldTapMoveGestureRecognizer extends GestureRecognizer { @@ -710,6 +739,7 @@ RawGestureDetector getMixinGestureDetector({ GestureDragStartCallback? onOneFingerPanStart, GestureDragUpdateCallback? onOneFingerPanUpdate, GestureDragEndCallback? onOneFingerPanEnd, + GestureDragCancelCallback? onOneFingerPanCancel, GestureScaleUpdateCallback? onTwoFingerScaleUpdate, GestureScaleEndCallback? onTwoFingerScaleEnd, GestureDragUpdateCallback? onThreeFingerVerticalDragUpdate, @@ -758,6 +788,7 @@ RawGestureDetector getMixinGestureDetector({ ..onOneFingerPanStart = onOneFingerPanStart ..onOneFingerPanUpdate = onOneFingerPanUpdate ..onOneFingerPanEnd = onOneFingerPanEnd + ..onOneFingerPanCancel = onOneFingerPanCancel ..onTwoFingerScaleUpdate = onTwoFingerScaleUpdate ..onTwoFingerScaleEnd = onTwoFingerScaleEnd ..onThreeFingerVerticalDragUpdate = onThreeFingerVerticalDragUpdate; diff --git a/flutter/lib/common/widgets/login.dart b/flutter/lib/common/widgets/login.dart index 71f3dacc3..ee376de68 100644 --- a/flutter/lib/common/widgets/login.dart +++ b/flutter/lib/common/widgets/login.dart @@ -20,7 +20,8 @@ const kOpSvgList = [ 'okta', 'facebook', 'azure', - 'auth0' + 'auth0', + 'microsoft' ]; class _IconOP extends StatelessWidget { @@ -103,7 +104,7 @@ class ButtonOP extends StatelessWidget { child: FittedBox( fit: BoxFit.scaleDown, child: Center( - child: Text('${translate("Continue with")} $opLabel')), + child: Text(translate("Continue with {$opLabel}"))), ), ), ], @@ -166,10 +167,13 @@ class _WidgetOPState extends State { final String stateMsg = resultMap['state_msg']; String failedMsg = resultMap['failed_msg']; final String? url = resultMap['url']; + final bool urlLaunched = (resultMap['url_launched'] as bool?) ?? false; final authBody = resultMap['auth_body']; if (_stateMsg != stateMsg || _failedMsg != failedMsg) { if (_url.isEmpty && url != null && url.isNotEmpty) { - launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication); + if (!urlLaunched) { + launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication); + } _url = url; } if (authBody != null) { @@ -221,21 +225,59 @@ class _WidgetOPState extends State { return Offstage( offstage: _failedMsg.isEmpty && widget.curOP.value != widget.config.op, - child: RichText( - text: TextSpan( - text: '$_stateMsg ', - style: - DefaultTextStyle.of(context).style.copyWith(fontSize: 12), - children: [ - TextSpan( - text: _failedMsg, - style: DefaultTextStyle.of(context).style.copyWith( - fontSize: 14, - color: Colors.red, - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (_stateMsg.isNotEmpty && _failedMsg.isEmpty) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: SelectableText( + translate(_stateMsg), + style: DefaultTextStyle.of(context) + .style + .copyWith(fontSize: 12), + ), ), - ], - ), + if (_failedMsg.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Builder(builder: (context) { + final errorColor = + Theme.of(context).colorScheme.error; + final bgColor = Theme.of(context) + .colorScheme + .errorContainer + .withOpacity(0.3); + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 8.0, vertical: 6.0), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(4.0), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline, + color: errorColor, size: 16), + const SizedBox(width: 6), + Flexible( + child: SelectableText( + translate(_failedMsg), + style: DefaultTextStyle.of(context) + .style + .copyWith( + fontSize: 13, + color: errorColor, + ), + ), + ), + ], + ), + ); + }), + ), + ], ), ); }), @@ -397,6 +439,8 @@ Future loginDialog() async { String? passwordMsg; var isInProgress = false; final RxString curOP = ''.obs; + // Track hover state for the close icon + bool isCloseHovered = false; final loginOptions = [].obs; Future.delayed(Duration.zero, () async { @@ -455,10 +499,14 @@ Future loginDialog() async { resp.user, resp.secret, isEmailVerification); } else { setState(() => isInProgress = false); + // Workaround for web, close the dialog first, then show the verification code dialog. + // Otherwise, the text field will keep selecting the text and we can't input the code. + // Not sure why this happens. + if (isWeb && close != null) close(null); final res = await verificationCodeDialog( resp.user, resp.secret, isEmailVerification); if (res == true) { - if (close != null) close(false); + if (!isWeb && close != null) close(false); return; } } @@ -550,21 +598,27 @@ Future loginDialog() async { Text( translate('Login'), ).marginOnly(top: MyTheme.dialogPadding), - InkWell( - child: Icon( - Icons.close, - size: 25, - // No need to handle the branch of null. - // Because we can ensure the color is not null when debug. - color: Theme.of(context) - .textTheme - .titleLarge - ?.color - ?.withOpacity(0.55), + MouseRegion( + onEnter: (_) => setState(() => isCloseHovered = true), + onExit: (_) => setState(() => isCloseHovered = false), + child: InkWell( + child: Icon( + Icons.close, + size: 25, + // No need to handle the branch of null. + // Because we can ensure the color is not null when debug. + color: isCloseHovered + ? Colors.white + : Theme.of(context) + .textTheme + .titleLarge + ?.color + ?.withOpacity(0.55), + ), + onTap: onDialogCancel, + hoverColor: Colors.red, + borderRadius: BorderRadius.circular(5), ), - onTap: onDialogCancel, - hoverColor: Colors.red, - borderRadius: BorderRadius.circular(5), ).marginOnly(top: 10, right: 15), ], ); @@ -678,7 +732,7 @@ Future verificationCodeDialog( labelText: "Email", prefixIcon: Icon(Icons.email)), readOnly: true, controller: TextEditingController(text: user?.email), - )), + ).workaroundFreezeLinuxMint()), isEmailVerification ? const SizedBox(height: 8) : const Offstage(), codeField, /* diff --git a/flutter/lib/common/widgets/my_group.dart b/flutter/lib/common/widgets/my_group.dart index 867d71dff..74ce34e71 100644 --- a/flutter/lib/common/widgets/my_group.dart +++ b/flutter/lib/common/widgets/my_group.dart @@ -20,8 +20,11 @@ class MyGroup extends StatefulWidget { } class _MyGroupState extends State { - RxString get selectedUser => gFFI.groupModel.selectedUser; - RxString get searchUserText => gFFI.groupModel.searchUserText; + RxBool get isSelectedDeviceGroup => gFFI.groupModel.isSelectedDeviceGroup; + RxString get selectedAccessibleItemName => + gFFI.groupModel.selectedAccessibleItemName; + RxString get searchAccessibleItemNameText => + gFFI.groupModel.searchAccessibleItemNameText; static TextEditingController searchUserController = TextEditingController(); @override @@ -72,7 +75,7 @@ class _MyGroupState extends State { child: Container( width: double.infinity, height: double.infinity, - child: _buildUserContacts(), + child: _buildLeftList(), ), ) ], @@ -105,7 +108,7 @@ class _MyGroupState extends State { _buildLeftHeader(), Container( width: double.infinity, - child: _buildUserContacts(), + child: _buildLeftList(), ) ], ), @@ -130,7 +133,8 @@ class _MyGroupState extends State { child: TextField( controller: searchUserController, onChanged: (value) { - searchUserText.value = value; + searchAccessibleItemNameText.value = value; + selectedAccessibleItemName.value = ''; }, textAlignVertical: TextAlignVertical.center, style: TextStyle(fontSize: fontSize), @@ -145,25 +149,42 @@ class _MyGroupState extends State { border: InputBorder.none, isDense: true, ), - )), + ).workaroundFreezeLinuxMint()), ], ); } - Widget _buildUserContacts() { + Widget _buildLeftList() { return Obx(() { - final items = gFFI.groupModel.users.where((p0) { - if (searchUserText.isNotEmpty) { + final userItems = gFFI.groupModel.users.where((p0) { + if (searchAccessibleItemNameText.isNotEmpty) { + final search = searchAccessibleItemNameText.value.toLowerCase(); + return p0.name.toLowerCase().contains(search) || + p0.displayNameOrName.toLowerCase().contains(search); + } + return true; + }).toList(); + // Count occurrences of each displayNameOrName to detect duplicates + final displayNameCount = {}; + for (final u in userItems) { + final dn = u.displayNameOrName; + displayNameCount[dn] = (displayNameCount[dn] ?? 0) + 1; + } + final deviceGroupItems = gFFI.groupModel.deviceGroups.where((p0) { + if (searchAccessibleItemNameText.isNotEmpty) { return p0.name .toLowerCase() - .contains(searchUserText.value.toLowerCase()); + .contains(searchAccessibleItemNameText.value.toLowerCase()); } return true; }).toList(); listView(bool isPortrait) => ListView.builder( shrinkWrap: isPortrait, - itemCount: items.length, - itemBuilder: (context, index) => _buildUserItem(items[index])); + itemCount: deviceGroupItems.length + userItems.length, + itemBuilder: (context, index) => index < deviceGroupItems.length + ? _buildDeviceGroupItem(deviceGroupItems[index]) + : _buildUserItem(userItems[index - deviceGroupItems.length], + displayNameCount)); var maxHeight = max(MediaQuery.of(context).size.height / 6, 100.0); return Obx(() => stateGlobal.isPortrait.isFalse ? listView(false) @@ -171,17 +192,25 @@ class _MyGroupState extends State { }); } - Widget _buildUserItem(UserPayload user) { + Widget _buildUserItem(UserPayload user, Map displayNameCount) { final username = user.name; + final dn = user.displayNameOrName; + final isDuplicate = (displayNameCount[dn] ?? 0) > 1; + final displayName = + isDuplicate && user.displayName.trim().isNotEmpty + ? '${user.displayName} (@$username)' + : dn; return InkWell(onTap: () { - if (selectedUser.value != username) { - selectedUser.value = username; + isSelectedDeviceGroup.value = false; + if (selectedAccessibleItemName.value != username) { + selectedAccessibleItemName.value = username; } else { - selectedUser.value = ''; + selectedAccessibleItemName.value = ''; } }, child: Obx( () { - bool selected = selectedUser.value == username; + bool selected = !isSelectedDeviceGroup.value && + selectedAccessibleItemName.value == username; final isMe = username == gFFI.userModel.userName.value; final colorMe = MyTheme.color(context).me!; return Container( @@ -206,14 +235,14 @@ class _MyGroupState extends State { alignment: Alignment.center, child: Center( child: Text( - username.characters.first.toUpperCase(), + displayName.characters.first.toUpperCase(), style: TextStyle(color: Colors.white), textAlign: TextAlign.center, ), ), ), ).marginOnly(right: 4), - if (isMe) Flexible(child: Text(username)), + if (isMe) Flexible(child: Text(displayName)), if (isMe) Flexible( child: Container( @@ -230,7 +259,46 @@ class _MyGroupState extends State { ), ), ), - if (!isMe) Expanded(child: Text(username)), + if (!isMe) Expanded(child: Text(displayName)), + ], + ).paddingSymmetric(vertical: 4), + ), + ); + }, + )).marginSymmetric(horizontal: 12).marginOnly(bottom: 6); + } + + Widget _buildDeviceGroupItem(DeviceGroupPayload deviceGroup) { + final name = deviceGroup.name; + return InkWell(onTap: () { + isSelectedDeviceGroup.value = true; + if (selectedAccessibleItemName.value != name) { + selectedAccessibleItemName.value = name; + } else { + selectedAccessibleItemName.value = ''; + } + }, child: Obx( + () { + bool selected = isSelectedDeviceGroup.value && + selectedAccessibleItemName.value == name; + return Container( + decoration: BoxDecoration( + color: selected ? MyTheme.color(context).highlight : null, + border: Border( + bottom: BorderSide( + width: 0.7, + color: Theme.of(context).dividerColor.withOpacity(0.1))), + ), + child: Container( + child: Row( + children: [ + Container( + width: 20, + height: 20, + child: Icon(IconFont.deviceGroupOutline, + color: MyTheme.accent, size: 19), + ).marginOnly(right: 4), + Expanded(child: Text(name)), ], ).paddingSymmetric(vertical: 4), ), diff --git a/flutter/lib/common/widgets/overlay.dart b/flutter/lib/common/widgets/overlay.dart index 9b20136e1..3fb63616d 100644 --- a/flutter/lib/common/widgets/overlay.dart +++ b/flutter/lib/common/widgets/overlay.dart @@ -50,6 +50,7 @@ class DraggableChatWindow extends StatelessWidget { ) : Draggable( checkKeyboard: true, + checkScreenSize: true, position: draggablePositions.chatWindow, width: width, height: height, @@ -395,7 +396,10 @@ class _DraggableState extends State { _chatModel?.setChatWindowPosition(position); } - checkScreenSize() {} + checkScreenSize() { + // Ensure the draggable always stays within current screen bounds + widget.position.tryAdjust(widget.width, widget.height, 1); + } checkKeyboard() { final bottomHeight = MediaQuery.of(context).viewInsets.bottom; @@ -517,6 +521,12 @@ class IOSDraggableState extends State { _lastBottomHeight = bottomHeight; } + @override + void initState() { + super.initState(); + position.tryAdjust(_width, _height, 1); + } + @override Widget build(BuildContext context) { checkKeyboard(); diff --git a/flutter/lib/common/widgets/peer_card.dart b/flutter/lib/common/widgets/peer_card.dart index 0a15eb45b..1f9f3ed7f 100644 --- a/flutter/lib/common/widgets/peer_card.dart +++ b/flutter/lib/common/widgets/peer_card.dart @@ -127,6 +127,10 @@ class _PeerCardState extends State<_PeerCard> ); } + bool _showNote(Peer peer) { + return peerTabShowNote(widget.tab) && peer.note.isNotEmpty; + } + makeChild(bool isPortrait, Peer peer) { final name = hideUsernameOnCard == true ? peer.hostname @@ -134,6 +138,8 @@ class _PeerCardState extends State<_PeerCard> final greyStyle = TextStyle( fontSize: 11, color: Theme.of(context).textTheme.titleLarge?.color?.withOpacity(0.6)); + final showNote = _showNote(peer); + return Row( mainAxisSize: MainAxisSize.max, children: [ @@ -185,14 +191,44 @@ class _PeerCardState extends State<_PeerCard> style: Theme.of(context).textTheme.titleSmall, )), ]).marginOnly(top: isPortrait ? 0 : 2), - Align( - alignment: Alignment.centerLeft, - child: Text( - name, - style: isPortrait ? null : greyStyle, - textAlign: TextAlign.start, - overflow: TextOverflow.ellipsis, - ), + Row( + children: [ + Flexible( + child: Tooltip( + message: name, + waitDuration: const Duration(seconds: 1), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + name, + style: isPortrait ? null : greyStyle, + textAlign: TextAlign.start, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + if (showNote) + Expanded( + child: Tooltip( + message: peer.note, + waitDuration: const Duration(seconds: 1), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + peer.note, + style: isPortrait ? null : greyStyle, + textAlign: TextAlign.start, + overflow: TextOverflow.ellipsis, + ).marginOnly( + left: peerCardUiType.value == + PeerUiType.list + ? 32 + : 4), + ), + ), + ) + ], ), ], ).marginOnly(top: 2), @@ -278,7 +314,7 @@ class _PeerCardState extends State<_PeerCard> padding: const EdgeInsets.all(6), child: getPlatformImage(peer.platform, size: 60), - ).marginOnly(top: 4), + ), Row( children: [ Expanded( @@ -297,8 +333,26 @@ class _PeerCardState extends State<_PeerCard> ), ], ), + if (_showNote(peer)) + Row( + children: [ + Expanded( + child: Tooltip( + message: peer.note, + waitDuration: const Duration(seconds: 1), + child: Text( + peer.note, + style: const TextStyle( + color: Colors.white38, + fontSize: 10), + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + ), + )) + ], + ), ], - ).paddingAll(4.0), + ).paddingOnly(top: 4.0, left: 4.0, right: 4.0), ), ], ), @@ -488,8 +542,11 @@ abstract class BasePeerCard extends StatelessWidget { BuildContext context, String title, { bool isFileTransfer = false, + bool isViewCamera = false, bool isTcpTunneling = false, bool isRDP = false, + bool isTerminal = false, + bool isTerminalRunAsAdmin = false, }) { return MenuEntryButton( childBuilder: (TextStyle? style) => Text( @@ -497,13 +554,18 @@ abstract class BasePeerCard extends StatelessWidget { style: style, ), proc: () { + if (isTerminalRunAsAdmin) { + setEnvTerminalAdmin(); + } connectInPeerTab( context, peer, tab, isFileTransfer: isFileTransfer, + isViewCamera: isViewCamera, isTcpTunneling: isTcpTunneling, isRDP: isRDP, + isTerminal: isTerminal || isTerminalRunAsAdmin, ); }, padding: menuPadding, @@ -530,6 +592,33 @@ abstract class BasePeerCard extends StatelessWidget { ); } + @protected + MenuEntryBase _viewCameraAction(BuildContext context) { + return _connectCommonAction( + context, + translate('View camera'), + isViewCamera: true, + ); + } + + @protected + MenuEntryBase _terminalAction(BuildContext context) { + return _connectCommonAction( + context, + '${translate('Terminal')} (beta)', + isTerminal: true, + ); + } + + @protected + MenuEntryBase _terminalRunAsAdminAction(BuildContext context) { + return _connectCommonAction( + context, + '${translate('Terminal (Run as administrator)')} (beta)', + isTerminalRunAsAdmin: true, + ); + } + @protected MenuEntryBase _tcpTunnelingAction(BuildContext context) { return _connectCommonAction( @@ -716,18 +805,18 @@ abstract class BasePeerCard extends StatelessWidget { switch (tab) { case PeerTabIndex.recent: await bind.mainRemovePeer(id: id); - await bind.mainLoadRecentPeers(); + bind.mainLoadRecentPeers(); break; case PeerTabIndex.fav: final favs = (await bind.mainGetFav()).toList(); if (favs.remove(id)) { await bind.mainStoreFav(favs: favs); - await bind.mainLoadFavPeers(); + bind.mainLoadFavPeers(); } break; case PeerTabIndex.lan: await bind.mainRemoveDiscovered(id: id); - await bind.mainLoadLanPeers(); + bind.mainLoadLanPeers(); break; case PeerTabIndex.ab: await gFFI.abModel.deletePeers([id]); @@ -880,8 +969,14 @@ class RecentPeerCard extends BasePeerCard { final List> menuItems = [ _connectAction(context), _transferFileAction(context), + _viewCameraAction(context), + _terminalAction(context), ]; + if (peer.platform == kPeerPlatformWindows) { + menuItems.add(_terminalRunAsAdminAction(context)); + } + final List favs = (await bind.mainGetFav()).toList(); if (isDesktop && peer.platform != kPeerPlatformAndroid) { @@ -939,7 +1034,14 @@ class FavoritePeerCard extends BasePeerCard { final List> menuItems = [ _connectAction(context), _transferFileAction(context), + _viewCameraAction(context), + _terminalAction(context), ]; + + if (peer.platform == kPeerPlatformWindows) { + menuItems.add(_terminalRunAsAdminAction(context)); + } + if (isDesktop && peer.platform != kPeerPlatformAndroid) { menuItems.add(_tcpTunnelingAction(context)); } @@ -992,8 +1094,14 @@ class DiscoveredPeerCard extends BasePeerCard { final List> menuItems = [ _connectAction(context), _transferFileAction(context), + _viewCameraAction(context), + _terminalAction(context), ]; + if (peer.platform == kPeerPlatformWindows) { + menuItems.add(_terminalRunAsAdminAction(context)); + } + final List favs = (await bind.mainGetFav()).toList(); if (isDesktop && peer.platform != kPeerPlatformAndroid) { @@ -1045,12 +1153,21 @@ class AddressBookPeerCard extends BasePeerCard { final List> menuItems = [ _connectAction(context), _transferFileAction(context), + _viewCameraAction(context), + _terminalAction(context), ]; + + if (peer.platform == kPeerPlatformWindows) { + menuItems.add(_terminalRunAsAdminAction(context)); + } + if (isDesktop && peer.platform != kPeerPlatformAndroid) { menuItems.add(_tcpTunnelingAction(context)); } // menuItems.add(await _openNewConnInOptAction(peer.id)); - // menuItems.add(await _forceAlwaysRelayAction(peer.id)); + if (!isWeb) { + menuItems.add(await _forceAlwaysRelayAction(peer.id)); + } if (isWindows && peer.platform == kPeerPlatformWindows) { menuItems.add(_rdpAction(context, peer.id)); } @@ -1071,6 +1188,7 @@ class AddressBookPeerCard extends BasePeerCard { if (gFFI.abModel.currentAbTags.isNotEmpty) { menuItems.add(_editTagAction(peer.id)); } + menuItems.add(_editNoteAction(peer.id)); } final addressbooks = gFFI.abModel.addressBooksCanWrite(); if (gFFI.peerTabModel.currentTab == PeerTabIndex.ab.index) { @@ -1110,6 +1228,21 @@ class AddressBookPeerCard extends BasePeerCard { ); } + @protected + MenuEntryBase _editNoteAction(String id) { + return MenuEntryButton( + childBuilder: (TextStyle? style) => Text( + translate('Edit note'), + style: style, + ), + proc: () { + editAbPeerNoteDialog(id); + }, + padding: super.menuPadding, + dismissOnClicked: true, + ); + } + @protected @override Future _getAlias(String id) async => @@ -1177,12 +1310,21 @@ class MyGroupPeerCard extends BasePeerCard { final List> menuItems = [ _connectAction(context), _transferFileAction(context), + _viewCameraAction(context), + _terminalAction(context), ]; + + if (peer.platform == kPeerPlatformWindows) { + menuItems.add(_terminalRunAsAdminAction(context)); + } + if (isDesktop && peer.platform != kPeerPlatformAndroid) { menuItems.add(_tcpTunnelingAction(context)); } // menuItems.add(await _openNewConnInOptAction(peer.id)); - // menuItems.add(await _forceAlwaysRelayAction(peer.id)); + if (!isWeb) { + menuItems.add(await _forceAlwaysRelayAction(peer.id)); + } if (isWindows && peer.platform == kPeerPlatformWindows) { menuItems.add(_rdpAction(context, peer.id)); } @@ -1257,7 +1399,7 @@ void _rdpDialog(String id) async { hintText: '3389'), controller: portController, autofocus: true, - ), + ).workaroundFreezeLinuxMint(), ), ], ).marginOnly(bottom: isDesktop ? 8 : 0), @@ -1277,7 +1419,7 @@ void _rdpDialog(String id) async { labelText: isDesktop ? null : translate('Username')), controller: userController, - ), + ).workaroundFreezeLinuxMint(), ), ], ).marginOnly(bottom: stateGlobal.isPortrait.isFalse ? 8 : 0)), @@ -1305,7 +1447,7 @@ void _rdpDialog(String id) async { ? Icons.visibility_off : Icons.visibility))), controller: passwordController, - )), + ).workaroundFreezeLinuxMint()), ), ], )) @@ -1398,8 +1540,10 @@ class TagPainter extends CustomPainter { void connectInPeerTab(BuildContext context, Peer peer, PeerTabIndex tab, {bool isFileTransfer = false, + bool isViewCamera = false, bool isTcpTunneling = false, - bool isRDP = false}) async { + bool isRDP = false, + bool isTerminal = false}) async { var password = ''; bool isSharedPassword = false; if (tab == PeerTabIndex.ab) { @@ -1417,12 +1561,21 @@ void connectInPeerTab(BuildContext context, Peer peer, PeerTabIndex tab, password = peer.password; isSharedPassword = true; } + if (password.isEmpty) { + final abPassword = gFFI.abModel.getdefaultSharedPassword(); + if (abPassword != null) { + password = abPassword; + isSharedPassword = true; + } + } } } connect(context, peer.id, password: password, isSharedPassword: isSharedPassword, isFileTransfer: isFileTransfer, + isTerminal: isTerminal, + isViewCamera: isViewCamera, isTcpTunneling: isTcpTunneling, isRDP: isRDP); } diff --git a/flutter/lib/common/widgets/peer_tab_page.dart b/flutter/lib/common/widgets/peer_tab_page.dart index 359750788..4849f2783 100644 --- a/flutter/lib/common/widgets/peer_tab_page.dart +++ b/flutter/lib/common/widgets/peer_tab_page.dart @@ -33,8 +33,8 @@ class PeerTabPage extends StatefulWidget { class _TabEntry { final Widget widget; - final Function({dynamic hint}) load; - _TabEntry(this.widget, this.load); + final Function({dynamic hint})? load; + _TabEntry(this.widget, [this.load]); } EdgeInsets? _menuPadding() { @@ -44,21 +44,15 @@ EdgeInsets? _menuPadding() { class _PeerTabPageState extends State with SingleTickerProviderStateMixin { final List<_TabEntry> entries = [ - _TabEntry( - RecentPeersView( - menuPadding: _menuPadding(), - ), - bind.mainLoadRecentPeers), - _TabEntry( - FavoritePeersView( - menuPadding: _menuPadding(), - ), - bind.mainLoadFavPeers), - _TabEntry( - DiscoveredPeersView( - menuPadding: _menuPadding(), - ), - bind.mainDiscover), + _TabEntry(RecentPeersView( + menuPadding: _menuPadding(), + )), + _TabEntry(FavoritePeersView( + menuPadding: _menuPadding(), + )), + _TabEntry(DiscoveredPeersView( + menuPadding: _menuPadding(), + )), _TabEntry( AddressBook( menuPadding: _menuPadding(), @@ -100,7 +94,7 @@ class _PeerTabPageState extends State gFFI.peerTabModel.setCurrentTabCachedPeers([]); } gFFI.peerTabModel.setCurrentTab(tabIndex); - entries[tabIndex].load(hint: false); + entries[tabIndex].load?.call(hint: false); } } @@ -225,7 +219,7 @@ class _PeerTabPageState extends State child: RefreshWidget( onPressed: () { if (gFFI.peerTabModel.currentTab < entries.length) { - entries[gFFI.peerTabModel.currentTab].load(); + entries[gFFI.peerTabModel.currentTab].load?.call(); } }, spinning: loading, @@ -404,7 +398,7 @@ class _PeerTabPageState extends State for (var p in peers) { await bind.mainRemovePeer(id: p.id); } - await bind.mainLoadRecentPeers(); + bind.mainLoadRecentPeers(); break; case 1: final favs = (await bind.mainGetFav()).toList(); @@ -412,13 +406,13 @@ class _PeerTabPageState extends State favs.remove(p.id); }).toList(); await bind.mainStoreFav(favs: favs); - await bind.mainLoadFavPeers(); + bind.mainLoadFavPeers(); break; case 2: for (var p in peers) { await bind.mainRemoveDiscovered(id: p.id); } - await bind.mainLoadLanPeers(); + bind.mainLoadLanPeers(); break; case 3: await gFFI.abModel.deletePeers(peers.map((p) => p.id).toList()); @@ -743,7 +737,7 @@ class _PeerSearchBarState extends State { border: InputBorder.none, isDense: true, ), - ), + ).workaroundFreezeLinuxMint(), ), // Icon(Icons.close), IconButton( diff --git a/flutter/lib/common/widgets/peers_view.dart b/flutter/lib/common/widgets/peers_view.dart index 7f1685021..5be5af272 100644 --- a/flutter/lib/common/widgets/peers_view.dart +++ b/flutter/lib/common/widgets/peers_view.dart @@ -5,7 +5,7 @@ import 'package:dynamic_layouts/dynamic_layouts.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hbb/consts.dart'; -import 'package:flutter_hbb/desktop/widgets/scroll_wrapper.dart'; +import 'package:flutter_hbb/models/ab_model.dart'; import 'package:flutter_hbb/models/peer_tab_model.dart'; import 'package:flutter_hbb/models/state_model.dart'; import 'package:get/get.dart'; @@ -25,13 +25,13 @@ class PeerSortType { static const String remoteId = 'Remote ID'; static const String remoteHost = 'Remote Host'; static const String username = 'Username'; - // static const String status = 'Status'; + static const String status = 'Status'; static List values = [ PeerSortType.remoteId, PeerSortType.remoteHost, PeerSortType.username, - // PeerSortType.status + PeerSortType.status ]; } @@ -71,10 +71,12 @@ class _PeersView extends StatefulWidget { final Peers peers; final PeerFilter? peerFilter; final PeerCardBuilder peerCardBuilder; + final PeerTabIndex peerTabIndex; const _PeersView( {required this.peers, required this.peerCardBuilder, + required this.peerTabIndex, this.peerFilter, Key? key}) : super(key: key); @@ -270,33 +272,24 @@ class _PeersViewState extends State<_PeersView> }, ) : peerCardUiType.value == PeerUiType.list - ? DesktopScrollWrapper( - scrollController: _scrollController, - child: ListView.builder( - controller: _scrollController, - physics: DraggableNeverScrollableScrollPhysics(), - itemCount: peers.length, - itemBuilder: (BuildContext context, int index) { - return buildOnePeer(peers[index], false) - .marginOnly( - right: space, - top: index == 0 ? 0 : space / 2, - bottom: space / 2); - }), + ? ListView.builder( + controller: _scrollController, + itemCount: peers.length, + itemBuilder: (BuildContext context, int index) { + return buildOnePeer(peers[index], false).marginOnly( + right: space, + top: index == 0 ? 0 : space / 2, + bottom: space / 2); + }, ) - : DesktopScrollWrapper( - scrollController: _scrollController, - child: DynamicGridView.builder( - controller: _scrollController, - physics: DraggableNeverScrollableScrollPhysics(), - gridDelegate: SliverGridDelegateWithWrapping( - mainAxisSpacing: space / 2, - crossAxisSpacing: space), - itemCount: peers.length, - itemBuilder: (BuildContext context, int index) { - return buildOnePeer(peers[index], false); - }), - )); + : DynamicGridView.builder( + gridDelegate: SliverGridDelegateWithWrapping( + mainAxisSpacing: space / 2, + crossAxisSpacing: space), + itemCount: peers.length, + itemBuilder: (BuildContext context, int index) { + return buildOnePeer(peers[index], false); + })); if (updateEvent == UpdateEvent.load) { _curPeers.clear(); @@ -393,9 +386,9 @@ class _PeersViewState extends State<_PeersView> peers.sort((p1, p2) => p1.username.toLowerCase().compareTo(p2.username.toLowerCase())); break; - // case PeerSortType.status: - // peers.sort((p1, p2) => p1.online ? -1 : 1); - // break; + case PeerSortType.status: + peers.sort((p1, p2) => p1.online ? -1 : 1); + break; } } @@ -404,8 +397,8 @@ class _PeersViewState extends State<_PeersView> return peers; } searchText = searchText.toLowerCase(); - final matches = - await Future.wait(peers.map((peer) => matchPeer(searchText, peer))); + final matches = await Future.wait( + peers.map((peer) => matchPeer(searchText, peer, widget.peerTabIndex))); final filteredList = List.empty(growable: true); for (var i = 0; i < peers.length; i++) { if (matches[i]) { @@ -450,7 +443,10 @@ abstract class BasePeersView extends StatelessWidget { break; } return _PeersView( - peers: peers, peerFilter: peerFilter, peerCardBuilder: peerCardBuilder); + peers: peers, + peerFilter: peerFilter, + peerCardBuilder: peerCardBuilder, + peerTabIndex: peerTabIndex); } } @@ -510,6 +506,7 @@ class DiscoveredPeersView extends BasePeersView { Widget build(BuildContext context) { final widget = super.build(context); bind.mainLoadLanPeers(); + bind.mainDiscover(); return widget; } } @@ -532,15 +529,22 @@ class AddressBookPeersView extends BasePeersView { if (selectedTags.isEmpty) { return true; } + // The result of a no-tag union with normal tags, still allows normal tags to perform union or intersection operations. + final selectedNormalTags = + selectedTags.where((tag) => tag != kUntagged).toList(); + if (selectedTags.contains(kUntagged)) { + if (idents.isEmpty) return true; + if (selectedNormalTags.isEmpty) return false; + } if (gFFI.abModel.filterByIntersection.value) { - for (final tag in selectedTags) { + for (final tag in selectedNormalTags) { if (!idents.contains(tag)) { return false; } } return true; } else { - for (final tag in selectedTags) { + for (final tag in selectedNormalTags) { if (idents.contains(tag)) { return true; } @@ -564,14 +568,29 @@ class MyGroupPeerView extends BasePeersView { ); static bool filter(Peer peer) { - if (gFFI.groupModel.searchUserText.isNotEmpty) { - if (!peer.loginName.contains(gFFI.groupModel.searchUserText)) { + final model = gFFI.groupModel; + if (model.searchAccessibleItemNameText.isNotEmpty) { + final text = model.searchAccessibleItemNameText.value.toLowerCase(); + final searchPeersOfUser = model.users.any((user) => + user.name == peer.loginName && + (user.name.toLowerCase().contains(text) || + user.displayNameOrName.toLowerCase().contains(text))); + final searchPeersOfDeviceGroup = + peer.device_group_name.toLowerCase().contains(text) && + model.deviceGroups.any((g) => g.name == peer.device_group_name); + if (!searchPeersOfUser && !searchPeersOfDeviceGroup) { return false; } } - if (gFFI.groupModel.selectedUser.isNotEmpty) { - if (gFFI.groupModel.selectedUser.value != peer.loginName) { - return false; + if (model.selectedAccessibleItemName.isNotEmpty) { + if (model.isSelectedDeviceGroup.value) { + if (model.selectedAccessibleItemName.value != peer.device_group_name) { + return false; + } + } else { + if (model.selectedAccessibleItemName.value != peer.loginName) { + return false; + } } } return true; diff --git a/flutter/lib/common/widgets/remote_input.dart b/flutter/lib/common/widgets/remote_input.dart index a4d3caf29..9515ca759 100644 --- a/flutter/lib/common/widgets/remote_input.dart +++ b/flutter/lib/common/widgets/remote_input.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -30,7 +31,7 @@ class RawKeyFocusScope extends StatelessWidget { // https://github.com/flutter/flutter/issues/154053 final useRawKeyEvents = isLinux && !isWeb; // FIXME: On Windows, `AltGr` will generate `Alt` and `Control` key events, - // while `Alt` and `Control` are seperated key events for en-US input method. + // while `Alt` and `Control` are separated key events for en-US input method. return FocusScope( autofocus: true, child: Focus( @@ -50,16 +51,24 @@ class RawKeyFocusScope extends StatelessWidget { } } +// For virtual mouse when using the mouse mode on mobile. +// Special hold-drag mode: one finger holds a button (left/right button), another finger pans. +// This flag is to override the scale gesture to a pan gesture. +bool isSpecialHoldDragActive = false; +// Cache the last focal point to calculate deltas in special hold-drag mode. +Offset _lastSpecialHoldDragFocalPoint = Offset.zero; + class RawTouchGestureDetectorRegion extends StatefulWidget { final Widget child; final FFI ffi; - + final bool isCamera; late final InputModel inputModel = ffi.inputModel; late final FfiModel ffiModel = ffi.ffiModel; RawTouchGestureDetectorRegion({ required this.child, required this.ffi, + this.isCamera = false, }); @override @@ -84,8 +93,23 @@ class _RawTouchGestureDetectorRegionState double _mouseScrollIntegral = 0; // mouse scroll speed controller double _scale = 1; + // Workaround tap down event when two fingers are used to scale(mobile) + TapDownDetails? _lastTapDownDetails; + PointerDeviceKind? lastDeviceKind; + // For touch mode, onDoubleTap + // `onDoubleTap()` does not provide the position of the tap event. + Offset _lastPosOfDoubleTapDown = Offset.zero; + bool _touchModePanStarted = false; + Offset _doubleFinerTapPosition = Offset.zero; + + // For mouse mode, we need to block the events when the cursor is in a blocked area. + // So we need to cache the last tap down position. + Offset? _lastTapDownPositionForMouseMode; + // Cache global position for onTap (which lacks position info). + Offset? _lastTapDownGlobalPosition; + FFI get ffi => widget.ffi; FfiModel get ffiModel => widget.ffiModel; InputModel get inputModel => widget.inputModel; @@ -100,152 +124,257 @@ class _RawTouchGestureDetectorRegionState ); } - onTapDown(TapDownDetails d) { + bool isNotTouchBasedDevice() { + return !kTouchBasedDeviceKinds.contains(lastDeviceKind); + } + + // Mobile, mouse mode. + // Check if should block the mouse tap event (`_lastTapDownPositionForMouseMode`). + bool shouldBlockMouseModeEvent() { + return _lastTapDownPositionForMouseMode != null && + ffi.cursorModel.shouldBlock(_lastTapDownPositionForMouseMode!.dx, + _lastTapDownPositionForMouseMode!.dy); + } + + onTapDown(TapDownDetails d) async { lastDeviceKind = d.kind; - if (lastDeviceKind != PointerDeviceKind.touch) { + _lastTapDownGlobalPosition = d.globalPosition; + if (isNotTouchBasedDevice()) { return; } if (handleTouch) { + _lastPosOfDoubleTapDown = d.localPosition; // Desktop or mobile "Touch mode" - if (ffi.cursorModel.move(d.localPosition.dx, d.localPosition.dy)) { - inputModel.tapDown(MouseButtons.left); - } + _lastTapDownDetails = d; + } else { + _lastTapDownPositionForMouseMode = d.localPosition; } } - onTapUp(TapUpDetails d) { - if (lastDeviceKind != PointerDeviceKind.touch) { + onTapUp(TapUpDetails d) async { + final TapDownDetails? lastTapDownDetails = _lastTapDownDetails; + _lastTapDownDetails = null; + if (isNotTouchBasedDevice()) { + return; + } + // Filter duplicate touch tap events on iOS (Magic Mouse issue). + if (inputModel.shouldIgnoreTouchTap(d.globalPosition)) { return; } if (handleTouch) { - if (ffi.cursorModel.move(d.localPosition.dx, d.localPosition.dy)) { - inputModel.tapUp(MouseButtons.left); + final isMoved = + await ffi.cursorModel.move(d.localPosition.dx, d.localPosition.dy); + if (isMoved) { + // If pan already handled 'down', don't send it again. + if (lastTapDownDetails != null && !_touchModePanStarted) { + await inputModel.tapDown(MouseButtons.left); + } + await inputModel.tapUp(MouseButtons.left); } } } - onTap() { - if (lastDeviceKind != PointerDeviceKind.touch) { + onTap() async { + if (isNotTouchBasedDevice()) { + return; + } + // Filter duplicate touch tap events on iOS (Magic Mouse issue). + final lastPos = _lastTapDownGlobalPosition; + if (lastPos != null && inputModel.shouldIgnoreTouchTap(lastPos)) { return; } if (!handleTouch) { + // Cannot use `_lastTapDownDetails` because Flutter calls `onTapUp` before `onTap`, clearing the cached details. + // Using `_lastTapDownPositionForMouseMode` instead. + if (shouldBlockMouseModeEvent()) { + return; + } // Mobile, "Mouse mode" - inputModel.tap(MouseButtons.left); + await inputModel.tap(MouseButtons.left); } } - onDoubleTapDown(TapDownDetails d) { + onDoubleTapDown(TapDownDetails d) async { lastDeviceKind = d.kind; - if (lastDeviceKind != PointerDeviceKind.touch) { + if (isNotTouchBasedDevice()) { return; } if (handleTouch) { - ffi.cursorModel.move(d.localPosition.dx, d.localPosition.dy); + _lastPosOfDoubleTapDown = d.localPosition; + await ffi.cursorModel.move(d.localPosition.dx, d.localPosition.dy); + } else { + _lastTapDownPositionForMouseMode = d.localPosition; } } - onDoubleTap() { - if (lastDeviceKind != PointerDeviceKind.touch) { + onDoubleTap() async { + if (isNotTouchBasedDevice()) { return; } if (ffiModel.touchMode && ffi.cursorModel.lastIsBlocked) { return; } - inputModel.tap(MouseButtons.left); - inputModel.tap(MouseButtons.left); + if (handleTouch && + !ffi.cursorModel.isInRemoteRect(_lastPosOfDoubleTapDown)) { + return; + } + // Check if the position is in a blocked area when using the mouse mode. + if (!handleTouch) { + if (shouldBlockMouseModeEvent()) { + return; + } + } + await inputModel.tap(MouseButtons.left); + await inputModel.tap(MouseButtons.left); } - onLongPressDown(LongPressDownDetails d) { + onLongPressDown(LongPressDownDetails d) async { lastDeviceKind = d.kind; - if (lastDeviceKind != PointerDeviceKind.touch) { + if (isNotTouchBasedDevice()) { return; } if (handleTouch) { - ffi.cursorModel.move(d.localPosition.dx, d.localPosition.dy); + _lastPosOfDoubleTapDown = d.localPosition; _cacheLongPressPosition = d.localPosition; + if (!ffi.cursorModel.isInRemoteRect(d.localPosition)) { + return; + } _cacheLongPressPositionTs = DateTime.now().millisecondsSinceEpoch; + if (ffiModel.isPeerMobile) { + await ffi.cursorModel + .move(_cacheLongPressPosition.dx, _cacheLongPressPosition.dy); + await inputModel.tapDown(MouseButtons.left); + } + } else { + _lastTapDownPositionForMouseMode = d.localPosition; } } - onLongPressUp() { - if (lastDeviceKind != PointerDeviceKind.touch) { + onLongPressUp() async { + if (isNotTouchBasedDevice()) { return; } if (handleTouch) { - inputModel.tapUp(MouseButtons.left); + await inputModel.tapUp(MouseButtons.left); } } // for mobiles - onLongPress() { - if (lastDeviceKind != PointerDeviceKind.touch) { + onLongPress() async { + if (isNotTouchBasedDevice()) { + return; + } + if (!ffi.ffiModel.isPeerMobile) { + if (handleTouch) { + final isMoved = await ffi.cursorModel + .move(_cacheLongPressPosition.dx, _cacheLongPressPosition.dy); + if (!isMoved) { + return; + } + } else { + if (shouldBlockMouseModeEvent()) { + return; + } + } + await inputModel.tap(MouseButtons.right); + } else { + // It's better to send a message to tell the controlled device that the long press event is triggered. + // We're now using a `TimerTask` in `InputService.kt` to decide whether to trigger the long press event. + // It's not accurate and it's better to use the same detection logic in the controlling side. + } + } + + onLongPressMoveUpdate(LongPressMoveUpdateDetails d) async { + if (!ffiModel.isPeerMobile || isNotTouchBasedDevice()) { return; } if (handleTouch) { - ffi.cursorModel - .move(_cacheLongPressPosition.dx, _cacheLongPressPosition.dy); + if (!ffi.cursorModel.isInRemoteRect(d.localPosition)) { + return; + } + await ffi.cursorModel.move(d.localPosition.dx, d.localPosition.dy); } - inputModel.tap(MouseButtons.right); } - onDoubleFinerTapDown(TapDownDetails d) { + onDoubleFinerTapDown(TapDownDetails d) async { lastDeviceKind = d.kind; - if (lastDeviceKind != PointerDeviceKind.touch) { + if (isNotTouchBasedDevice()) { return; } + _doubleFinerTapPosition = d.localPosition; // ignore for desktop and mobile } - onDoubleFinerTap(TapDownDetails d) { + onDoubleFinerTap(TapDownDetails d) async { lastDeviceKind = d.kind; - if (lastDeviceKind != PointerDeviceKind.touch) { + if (isNotTouchBasedDevice()) { return; } - if ((isDesktop || isWebDesktop) || !ffiModel.touchMode) { - inputModel.tap(MouseButtons.right); + + // mobile mouse mode or desktop touch screen + final isMobileMouseMode = isMobile && !ffiModel.touchMode; + // We can't use `d.localPosition` here because it's always (0, 0) on desktop. + final isDesktopInRemoteRect = (isDesktop || isWebDesktop) && + ffi.cursorModel.isInRemoteRect(_doubleFinerTapPosition); + if (isMobileMouseMode || isDesktopInRemoteRect) { + await inputModel.tap(MouseButtons.right); } } - onHoldDragStart(DragStartDetails d) { + onHoldDragStart(DragStartDetails d) async { lastDeviceKind = d.kind; - if (lastDeviceKind != PointerDeviceKind.touch) { + if (isNotTouchBasedDevice()) { return; } if (!handleTouch) { - inputModel.sendMouse('down', MouseButtons.left); + if (isSpecialHoldDragActive) return; + await inputModel.sendMouse('down', MouseButtons.left); } } - onHoldDragUpdate(DragUpdateDetails d) { - if (lastDeviceKind != PointerDeviceKind.touch) { + onHoldDragUpdate(DragUpdateDetails d) async { + if (isNotTouchBasedDevice()) { return; } if (!handleTouch) { - ffi.cursorModel.updatePan(d.delta, d.localPosition, handleTouch); + if (isSpecialHoldDragActive) return; + await ffi.cursorModel.updatePan(d.delta, d.localPosition, handleTouch); } } - onHoldDragEnd(DragEndDetails d) { - if (lastDeviceKind != PointerDeviceKind.touch) { + onHoldDragEnd(DragEndDetails d) async { + if (isNotTouchBasedDevice()) { return; } if (!handleTouch) { - inputModel.sendMouse('up', MouseButtons.left); + await inputModel.sendMouse('up', MouseButtons.left); } } - onOneFingerPanStart(BuildContext context, DragStartDetails d) { + onOneFingerPanStart(BuildContext context, DragStartDetails d) async { + final TapDownDetails? lastTapDownDetails = _lastTapDownDetails; + _lastTapDownDetails = null; lastDeviceKind = d.kind ?? lastDeviceKind; - if (lastDeviceKind != PointerDeviceKind.touch) { + if (isNotTouchBasedDevice()) { return; } if (handleTouch) { + if (lastTapDownDetails != null) { + await ffi.cursorModel.move(lastTapDownDetails.localPosition.dx, + lastTapDownDetails.localPosition.dy); + } if (ffi.cursorModel.shouldBlock(d.localPosition.dx, d.localPosition.dy)) { return; } + if (!ffi.cursorModel.isInRemoteRect(d.localPosition)) { + return; + } + + _touchModePanStarted = true; if (isDesktop || isWebDesktop) { ffi.cursorModel.trySetRemoteWindowCoords(); } + // Workaround for the issue that the first pan event is sent a long time after the start event. // If the time interval between the start event and the first pan event is less than 500ms, // we consider to use the long press position as the start position. @@ -253,11 +382,14 @@ class _RawTouchGestureDetectorRegionState // TODO: We should find a better way to send the first pan event as soon as possible. if (DateTime.now().millisecondsSinceEpoch - _cacheLongPressPositionTs < 500) { - ffi.cursorModel + await ffi.cursorModel .move(_cacheLongPressPosition.dx, _cacheLongPressPosition.dy); } - inputModel.sendMouse('down', MouseButtons.left); - ffi.cursorModel.move(d.localPosition.dx, d.localPosition.dy); + // In relative mouse mode, skip mouse down - only send movement via sendMobileRelativeMouseMove + if (!inputModel.relativeMouseMode.value) { + await inputModel.sendMouse('down', MouseButtons.left); + } + await ffi.cursorModel.move(d.localPosition.dx, d.localPosition.dy); } else { final offset = ffi.cursorModel.offset; final cursorX = offset.dx; @@ -266,48 +398,86 @@ class _RawTouchGestureDetectorRegionState ffi.cursorModel.getVisibleRect().inflate(1); // extend edges final size = MediaQueryData.fromView(View.of(context)).size; if (!visible.contains(Offset(cursorX, cursorY))) { - ffi.cursorModel.move(size.width / 2, size.height / 2); + await ffi.cursorModel.move(size.width / 2, size.height / 2); } } } - onOneFingerPanUpdate(DragUpdateDetails d) { - if (lastDeviceKind != PointerDeviceKind.touch) { + onOneFingerPanUpdate(DragUpdateDetails d) async { + if (isNotTouchBasedDevice()) { return; } if (ffi.cursorModel.shouldBlock(d.localPosition.dx, d.localPosition.dy)) { return; } - ffi.cursorModel.updatePan(d.delta, d.localPosition, handleTouch); + if (handleTouch && !_touchModePanStarted) { + return; + } + // In relative mouse mode, send delta directly without position tracking. + if (inputModel.relativeMouseMode.value) { + await inputModel.sendMobileRelativeMouseMove(d.delta.dx, d.delta.dy); + } else { + await ffi.cursorModel.updatePan(d.delta, d.localPosition, handleTouch); + } } - onOneFingerPanEnd(DragEndDetails d) { - if (lastDeviceKind != PointerDeviceKind.touch) { + onOneFingerPanEnd(DragEndDetails d) async { + _touchModePanStarted = false; + if (isNotTouchBasedDevice()) { return; } if (isDesktop || isWebDesktop) { ffi.cursorModel.clearRemoteWindowCoords(); } - inputModel.sendMouse('up', MouseButtons.left); + if (handleTouch) { + // In relative mouse mode, skip mouse up - matches the skipped mouse down in onOneFingerPanStart + if (!inputModel.relativeMouseMode.value) { + await inputModel.sendMouse('up', MouseButtons.left); + } + } + } + + // Reset `_touchModePanStarted` if the one-finger pan gesture is cancelled + // or rejected by the gesture arena. Without this, the flag can remain + // stuck in the "started" state and cause issues such as the Magic Mouse + // double-click problem on iPad with magic mouse. + onOneFingerPanCancel() { + _touchModePanStarted = false; } // scale + pan event onTwoFingerScaleStart(ScaleStartDetails d) { - if (lastDeviceKind != PointerDeviceKind.touch) { + _lastTapDownDetails = null; + if (isNotTouchBasedDevice()) { return; } + if (isSpecialHoldDragActive) { + // Initialize the last focal point to calculate deltas manually. + _lastSpecialHoldDragFocalPoint = d.focalPoint; + } } - onTwoFingerScaleUpdate(ScaleUpdateDetails d) { - if (lastDeviceKind != PointerDeviceKind.touch) { + onTwoFingerScaleUpdate(ScaleUpdateDetails d) async { + if (isNotTouchBasedDevice()) { return; } + + // If in special drag mode, perform a pan instead of a scale. + if (isSpecialHoldDragActive) { + // Calculate delta manually to avoid the jumpy behavior. + final delta = d.focalPoint - _lastSpecialHoldDragFocalPoint; + _lastSpecialHoldDragFocalPoint = d.focalPoint; + await ffi.cursorModel.updatePan(delta * 2.0, d.focalPoint, handleTouch); + return; + } + if ((isDesktop || isWebDesktop)) { final scale = ((d.scale - _scale) * 1000).toInt(); _scale = d.scale; if (scale != 0) { - bind.sessionSendPointer( + if (widget.isCamera) return; + await bind.sessionSendPointer( sessionId: sessionId, msg: json.encode( PointerEventToRust(kPointerEventKindTouch, 'scale', scale) @@ -322,21 +492,25 @@ class _RawTouchGestureDetectorRegionState } } - onTwoFingerScaleEnd(ScaleEndDetails d) { - if (lastDeviceKind != PointerDeviceKind.touch) { + onTwoFingerScaleEnd(ScaleEndDetails d) async { + if (isNotTouchBasedDevice()) { return; } if ((isDesktop || isWebDesktop)) { - bind.sessionSendPointer( + if (widget.isCamera) return; + await bind.sessionSendPointer( sessionId: sessionId, msg: json.encode( PointerEventToRust(kPointerEventKindTouch, 'scale', 0).toJson())); } else { // mobile _scale = 1; - bind.sessionSetViewStyle(sessionId: sessionId, value: ""); + // No idea why we need to set the view style to "" here. + // bind.sessionSetViewStyle(sessionId: sessionId, value: ""); + } + if (!isSpecialHoldDragActive) { + await inputModel.sendMouse('up', MouseButtons.left); } - inputModel.sendMouse('up', MouseButtons.left); } get onHoldDragCancel => null; @@ -358,7 +532,9 @@ class _RawTouchGestureDetectorRegionState // Official TapGestureRecognizer: GestureRecognizerFactoryWithHandlers( - () => TapGestureRecognizer(), (instance) { + () => TapGestureRecognizer( + supportedDevices: kTouchBasedDeviceKinds, + ), (instance) { instance ..onTapDown = onTapDown ..onTapUp = onTapUp @@ -366,23 +542,30 @@ class _RawTouchGestureDetectorRegionState }), DoubleTapGestureRecognizer: GestureRecognizerFactoryWithHandlers( - () => DoubleTapGestureRecognizer(), (instance) { + () => DoubleTapGestureRecognizer( + supportedDevices: kTouchBasedDeviceKinds, + ), (instance) { instance ..onDoubleTapDown = onDoubleTapDown ..onDoubleTap = onDoubleTap; }), LongPressGestureRecognizer: GestureRecognizerFactoryWithHandlers( - () => LongPressGestureRecognizer(), (instance) { + () => LongPressGestureRecognizer( + supportedDevices: kTouchBasedDeviceKinds, + ), (instance) { instance ..onLongPressDown = onLongPressDown ..onLongPressUp = onLongPressUp - ..onLongPress = onLongPress; + ..onLongPress = onLongPress + ..onLongPressMoveUpdate = onLongPressMoveUpdate; }), // Customized HoldTapMoveGestureRecognizer: GestureRecognizerFactoryWithHandlers( - () => HoldTapMoveGestureRecognizer(), + () => HoldTapMoveGestureRecognizer( + supportedDevices: kTouchBasedDeviceKinds, + ), (instance) => instance ..onHoldDragStart = onHoldDragStart ..onHoldDragUpdate = onHoldDragUpdate @@ -390,19 +573,24 @@ class _RawTouchGestureDetectorRegionState ..onHoldDragEnd = onHoldDragEnd), DoubleFinerTapGestureRecognizer: GestureRecognizerFactoryWithHandlers( - () => DoubleFinerTapGestureRecognizer(), (instance) { + () => DoubleFinerTapGestureRecognizer( + supportedDevices: kTouchBasedDeviceKinds, + ), (instance) { instance ..onDoubleFinerTap = onDoubleFinerTap ..onDoubleFinerTapDown = onDoubleFinerTapDown; }), CustomTouchGestureRecognizer: GestureRecognizerFactoryWithHandlers( - () => CustomTouchGestureRecognizer(), (instance) { + () => CustomTouchGestureRecognizer( + supportedDevices: kTouchBasedDeviceKinds, + ), (instance) { instance.onOneFingerPanStart = (DragStartDetails d) => onOneFingerPanStart(context, d); instance ..onOneFingerPanUpdate = onOneFingerPanUpdate ..onOneFingerPanEnd = onOneFingerPanEnd + ..onOneFingerPanCancel = onOneFingerPanCancel ..onTwoFingerScaleStart = onTwoFingerScaleStart ..onTwoFingerScaleUpdate = onTwoFingerScaleUpdate ..onTwoFingerScaleEnd = onTwoFingerScaleEnd @@ -459,3 +647,46 @@ class RawPointerMouseRegion extends StatelessWidget { ); } } + +class CameraRawPointerMouseRegion extends StatelessWidget { + final InputModel inputModel; + final Widget child; + final PointerEnterEventListener? onEnter; + final PointerExitEventListener? onExit; + final PointerDownEventListener? onPointerDown; + final PointerUpEventListener? onPointerUp; + + CameraRawPointerMouseRegion({ + this.onEnter, + this.onExit, + this.onPointerDown, + this.onPointerUp, + required this.inputModel, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return Listener( + onPointerHover: (evt) { + final offset = evt.position; + double x = offset.dx; + double y = max(0.0, offset.dy); + inputModel.handlePointerDevicePos( + kPointerEventKindMouse, x, y, true, kMouseEventTypeDefault); + }, + onPointerDown: (evt) { + onPointerDown?.call(evt); + }, + onPointerUp: (evt) { + onPointerUp?.call(evt); + }, + child: MouseRegion( + cursor: MouseCursor.defer, + onEnter: onEnter, + onExit: onExit, + child: child, + ), + ); + } +} diff --git a/flutter/lib/common/widgets/setting_widgets.dart b/flutter/lib/common/widgets/setting_widgets.dart index 5bcb73a4c..f3be77003 100644 --- a/flutter/lib/common/widgets/setting_widgets.dart +++ b/flutter/lib/common/widgets/setting_widgets.dart @@ -230,7 +230,6 @@ List<(String, String)> otherDefaultSettings() { ('Disable clipboard', kOptionDisableClipboard), ('Lock after session end', kOptionLockAfterSessionEnd), ('Privacy mode', kOptionPrivacyMode), - if (isMobile) ('Touch mode', kOptionTouchMode), ('True color (4:4:4)', kOptionI444), ('Reverse mouse wheel', kKeyReverseMouseWheel), ('swap-left-right-mouse', kOptionSwapLeftRightMouse), @@ -243,8 +242,99 @@ List<(String, String)> otherDefaultSettings() { ( 'Use all my displays for the remote session', kKeyUseAllMyDisplaysForTheRemoteSession - ) + ), + ('Keep terminal sessions on disconnect', kOptionTerminalPersistent), ]; return v; } + +class TrackpadSpeedWidget extends StatefulWidget { + final SimpleWrapper value; + // If null, no debouncer will be applied. + final Function(int)? onDebouncer; + + TrackpadSpeedWidget({Key? key, required this.value, this.onDebouncer}); + + @override + TrackpadSpeedWidgetState createState() => TrackpadSpeedWidgetState(); +} + +class TrackpadSpeedWidgetState extends State { + final TextEditingController _controller = TextEditingController(); + late final Debouncer debouncerSpeed; + + set value(int v) => widget.value.value = v; + int get value => widget.value.value; + + void updateValue(int newValue) { + setState(() { + value = newValue.clamp(kMinTrackpadSpeed, kMaxTrackpadSpeed); + // Scale the trackpad speed value to a percentage for display purposes. + _controller.text = value.toString(); + if (widget.onDebouncer != null) { + debouncerSpeed.setValue(value); + } + }); + } + + @override + void initState() { + super.initState(); + debouncerSpeed = Debouncer( + Duration(milliseconds: 1000), + onChanged: widget.onDebouncer, + initialValue: widget.value.value, + ); + } + + @override + Widget build(BuildContext context) { + if (_controller.text.isEmpty) { + _controller.text = value.toString(); + } + return Row( + children: [ + Expanded( + flex: 3, + child: Slider( + value: value.toDouble(), + min: kMinTrackpadSpeed.toDouble(), + max: kMaxTrackpadSpeed.toDouble(), + divisions: ((kMaxTrackpadSpeed - kMinTrackpadSpeed) / 10).round(), + onChanged: (double v) => updateValue(v.round()), + ), + ), + Expanded( + flex: 1, + child: Row( + children: [ + SizedBox( + width: 56, + child: TextField( + controller: _controller, + keyboardType: TextInputType.number, + textAlign: TextAlign.center, + onSubmitted: (text) { + int? v = int.tryParse(text); + if (v != null) { + updateValue(v); + } + }, + style: const TextStyle(fontSize: 13), + decoration: InputDecoration( + contentPadding: + EdgeInsets.symmetric(vertical: 8.0, horizontal: 12.0), + ), + ), + ).marginOnly(right: 8.0), + Text( + '%', + style: const TextStyle(fontSize: 15), + ) + ], + )), + ], + ); + } +} diff --git a/flutter/lib/common/widgets/toolbar.dart b/flutter/lib/common/widgets/toolbar.dart index 153121057..537014246 100644 --- a/flutter/lib/common/widgets/toolbar.dart +++ b/flutter/lib/common/widgets/toolbar.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; @@ -5,17 +6,25 @@ import 'package:flutter/services.dart'; import 'package:flutter_hbb/common.dart'; import 'package:flutter_hbb/common/shared_state.dart'; import 'package:flutter_hbb/common/widgets/dialog.dart'; +import 'package:flutter_hbb/common/widgets/login.dart'; import 'package:flutter_hbb/consts.dart'; import 'package:flutter_hbb/desktop/widgets/remote_toolbar.dart'; import 'package:flutter_hbb/models/model.dart'; import 'package:flutter_hbb/models/platform_model.dart'; +import 'package:flutter_hbb/utils/multi_window_manager.dart'; import 'package:get/get.dart'; bool isEditOsPassword = false; +// macOS privacy mode blacks out all online displays, so switching the remote +// display does not weaken the local privacy protection. +bool allowDisplaySwitchInPrivacyMode(PeerInfo pi) { + return pi.platform == kPeerPlatformMacOS; +} + class TTextMenu { final Widget child; - final VoidCallback onPressed; + final VoidCallback? onPressed; Widget? trailingIcon; bool divider; TTextMenu( @@ -89,10 +98,13 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { final pi = ffiModel.pi; final perms = ffiModel.permissions; final sessionId = ffi.sessionId; + final isDefaultConn = ffi.connType == ConnType.defaultConn; List v = []; // elevation - if (perms['keyboard'] != false && ffi.elevationModel.showRequestMenu) { + if (isDefaultConn && + perms['keyboard'] != false && + ffi.elevationModel.showRequestMenu) { v.add( TTextMenu( child: Text(translate('Request Elevation')), @@ -101,7 +113,7 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { ); } // osAccount / osPassword - if (perms['keyboard'] != false) { + if (isDefaultConn && perms['keyboard'] != false) { v.add( TTextMenu( child: Row(children: [ @@ -130,7 +142,9 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { ); } // paste - if (pi.platform != kPeerPlatformAndroid && perms['keyboard'] != false) { + if (isDefaultConn && + pi.platform != kPeerPlatformAndroid && + perms['keyboard'] != false) { v.add(TTextMenu( child: Text(translate('Send clipboard keystrokes')), onPressed: () async { @@ -142,55 +156,80 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { })); } // reset canvas - if (isMobile) { + if (isDefaultConn && isMobile) { v.add(TTextMenu( child: Text(translate('Reset canvas')), onPressed: () => ffi.cursorModel.reset())); } + // https://github.com/rustdesk/rustdesk/pull/9731 + // Does not work for connection established by "accept". connectWithToken( - {required bool isFileTransfer, required bool isTcpTunneling}) { + {bool isFileTransfer = false, + bool isViewCamera = false, + bool isTcpTunneling = false, + bool isTerminal = false}) { final connToken = bind.sessionGetConnToken(sessionId: ffi.sessionId); connect(context, id, isFileTransfer: isFileTransfer, + isViewCamera: isViewCamera, + isTerminal: isTerminal, isTcpTunneling: isTcpTunneling, connToken: connToken); } - // transferFile - if (isDesktop) { + if (isDefaultConn && isDesktop) { v.add( TTextMenu( child: Text(translate('Transfer file')), - onPressed: () => - connectWithToken(isFileTransfer: true, isTcpTunneling: false)), + onPressed: () => connectWithToken(isFileTransfer: true)), + ); + v.add( + TTextMenu( + child: Text(translate('View camera')), + onPressed: () => connectWithToken(isViewCamera: true)), + ); + v.add( + TTextMenu( + child: Text('${translate('Terminal')} (beta)'), + onPressed: () => connectWithToken(isTerminal: true)), ); - } - // tcpTunneling - if (isDesktop) { v.add( TTextMenu( child: Text(translate('TCP tunneling')), - onPressed: () => - connectWithToken(isFileTransfer: false, isTcpTunneling: true)), + onPressed: () => connectWithToken(isTcpTunneling: true)), ); } // note - if (bind - .sessionGetAuditServerSync(sessionId: sessionId, typ: "conn") - .isNotEmpty) { + if (isDefaultConn && !bind.isDisableAccount()) { v.add( TTextMenu( child: Text(translate('Note')), - onPressed: () => showAuditDialog(ffi)), + onPressed: () async { + bool isLogin = + bind.mainGetLocalOption(key: 'access_token').isNotEmpty; + if (!isLogin) { + final res = await loginDialog(); + if (res != true) return; + // Desktop: send message to main window to refresh login status + // Web: login is required before connection, so no need to refresh + // Mobile: same isolate, no need to send message + if (isDesktop) { + rustDeskWinManager.call( + WindowType.Main, kWindowRefreshCurrentUser, ""); + } + } + showAuditDialog(ffi); + }), ); } // divider - if (isDesktop || isWebDesktop) { + if (isDefaultConn && (isDesktop || isWebDesktop)) { v.add(TTextMenu(child: Offstage(), onPressed: () {}, divider: true)); } // ctrlAltDel - if (!ffiModel.viewOnly && + if (isDefaultConn && + !ffiModel.viewOnly && ffiModel.keyboard && (pi.platform == kPeerPlatformLinux || pi.sasEnabled)) { v.add( @@ -200,7 +239,8 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { ); } // restart - if (perms['restart'] != false && + if (isDefaultConn && + perms['restart'] != false && (pi.platform == kPeerPlatformLinux || pi.platform == kPeerPlatformWindows || pi.platform == kPeerPlatformMacOS)) { @@ -212,7 +252,7 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { ); } // insertLock - if (!ffiModel.viewOnly && ffi.ffiModel.keyboard) { + if (isDefaultConn && !ffiModel.viewOnly && ffi.ffiModel.keyboard) { v.add( TTextMenu( child: Text(translate('Insert Lock')), @@ -220,7 +260,8 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { ); } // blockUserInput - if (ffi.ffiModel.keyboard && + if (isDefaultConn && + ffi.ffiModel.keyboard && ffi.ffiModel.permissions['block_input'] != false && pi.platform == kPeerPlatformWindows) // privacy-mode != true ?? { @@ -236,12 +277,12 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { })); } // switchSides - if (isDesktop && + if (isDefaultConn && + isDesktop && ffiModel.keyboard && pi.platform != kPeerPlatformAndroid && - pi.platform != kPeerPlatformMacOS && versionCmp(pi.version, '1.2.0') >= 0 && - bind.peerGetDefaultSessionsCount(id: id) == 1) { + bind.peerGetSessionsCount(id: id, connType: ffi.connType.index) == 1) { v.add(TTextMenu( child: Text(translate('Switch Sides')), onPressed: () => @@ -275,6 +316,41 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { ), onPressed: () => ffi.recordingModel.toggle())); } + + // to-do: + // 1. Web desktop + // 2. Mobile, copy the image to the clipboard + if (isDesktop) { + final isScreenshotSupported = bind.sessionGetCommonSync( + sessionId: sessionId, key: 'is_screenshot_supported', param: ''); + if ('true' == isScreenshotSupported) { + v.add(TTextMenu( + child: Text(ffi.ffiModel.timerScreenshot != null + ? '${translate('Taking screenshot')} ...' + : translate('Take screenshot')), + onPressed: ffi.ffiModel.timerScreenshot != null + ? null + : () { + if (pi.currentDisplay == kAllDisplayValue) { + msgBox( + sessionId, + 'custom-nook-nocancel-hasclose-info', + 'Take screenshot', + 'screenshot-merged-screen-not-supported-tip', + '', + ffi.dialogManager); + } else { + bind.sessionTakeScreenshot( + sessionId: sessionId, display: pi.currentDisplay); + ffi.ffiModel.timerScreenshot = + Timer(Duration(seconds: 30), () { + ffi.ffiModel.timerScreenshot = null; + }); + } + }, + )); + } + } // fingerprint if (!(isDesktop || isWebDesktop)) { v.add(TTextMenu( @@ -306,6 +382,11 @@ Future>> toolbarViewStyle( child: Text(translate('Scale adaptive')), value: kRemoteViewStyleAdaptive, groupValue: groupValue, + onChanged: onChanged), + TRadioMenu( + child: Text(translate('Scale custom')), + value: kRemoteViewStyleCustom, + groupValue: groupValue, onChanged: onChanged) ]; } @@ -523,6 +604,7 @@ Future> toolbarDisplayToggle( final pi = ffiModel.pi; final perms = ffiModel.permissions; final sessionId = ffi.sessionId; + final isDefaultConn = ffi.connType == ConnType.defaultConn; // show quality monitor final option = 'show-quality-monitor'; @@ -535,7 +617,7 @@ Future> toolbarDisplayToggle( }, child: Text(translate('Show quality monitor')))); // mute - if (perms['audio'] != false) { + if (isDefaultConn && perms['audio'] != false) { final option = 'disable-audio'; final value = bind.sessionGetToggleOptionSync(sessionId: sessionId, arg: option); @@ -556,7 +638,8 @@ Future> toolbarDisplayToggle( final isSupportIfPeer_1_2_4 = versionCmp(pi.version, '1.2.4') >= 0 && bind.mainHasFileClipboard() && pi.platformAdditions.containsKey(kPlatformAdditionsHasFileClipboard); - if (ffiModel.keyboard && + if (isDefaultConn && + ffiModel.keyboard && perms['file'] != false && (isSupportIfPeer_1_2_3 || isSupportIfPeer_1_2_4)) { final enabled = !ffiModel.viewOnly; @@ -574,7 +657,7 @@ Future> toolbarDisplayToggle( child: Text(translate('Enable file copy and paste')))); } // disable clipboard - if (ffiModel.keyboard && perms['clipboard'] != false) { + if (isDefaultConn && ffiModel.keyboard && perms['clipboard'] != false) { final enabled = !ffiModel.viewOnly; final option = 'disable-clipboard'; var value = @@ -591,7 +674,7 @@ Future> toolbarDisplayToggle( child: Text(translate('Disable clipboard')))); } // lock after session end - if (ffiModel.keyboard && !ffiModel.isPeerAndroid) { + if (isDefaultConn && ffiModel.keyboard && !ffiModel.isPeerAndroid) { final enabled = !ffiModel.viewOnly; final option = 'lock-after-session-end'; final value = @@ -607,8 +690,9 @@ Future> toolbarDisplayToggle( child: Text(translate('Lock after session end')))); } + final privacyModeState = PrivacyModeState.find(id); if (pi.isSupportMultiDisplay && - PrivacyModeState.find(id).isEmpty && + (privacyModeState.isEmpty || allowDisplaySwitchInPrivacyMode(pi)) && pi.displaysCount.value > 1 && bind.mainGetUserDefaultOption(key: kKeyShowMonitorsToolbar) == 'Y') { final value = @@ -656,12 +740,12 @@ Future> toolbarDisplayToggle( child: Text(translate('True color (4:4:4)')))); } - if (isMobile) { + if (isDefaultConn && isMobile) { v.addAll(toolbarKeyboardToggles(ffi)); } // view mode (mobile only, desktop is in keyboard menu) - if (isMobile && versionCmp(pi.version, '1.2.0') >= 0) { + if (isDefaultConn && isMobile && versionCmp(pi.version, '1.2.0') >= 0) { v.add(TToggleMenu( value: ffiModel.viewOnly, onChanged: (value) async { @@ -682,15 +766,25 @@ List toolbarPrivacyMode( final ffiModel = ffi.ffiModel; final pi = ffiModel.pi; final sessionId = ffi.sessionId; + final hasPrivacyModePermission = ffiModel.permissions['privacy_mode'] != false; + + // Backend revocation already attempts to turn privacy mode off. + // Still keep this menu when privacy mode is active, so users can turn it off + // if there is a sync delay, version mismatch, or off attempt failure. + if (!hasPrivacyModePermission && privacyModeState.isEmpty) { + return []; // No permission and not active, hide options. + } getDefaultMenu(Future Function(SessionID sid, String opt) toggleFunc) { - final enabled = !ffi.ffiModel.viewOnly; + final enabled = + !ffiModel.viewOnly && (hasPrivacyModePermission || privacyModeState.isNotEmpty); return TToggleMenu( value: privacyModeState.isNotEmpty, onChanged: enabled ? (value) { if (value == null) return; - if (ffiModel.pi.currentDisplay != 0 && + if (!allowDisplaySwitchInPrivacyMode(pi) && + ffiModel.pi.currentDisplay != 0 && ffiModel.pi.currentDisplay != kAllDisplayValue) { msgBox( sessionId, @@ -733,18 +827,29 @@ List toolbarPrivacyMode( }) ]; } else { - return privacyModeImpls.map((e) { + final visibleImpls = hasPrivacyModePermission + ? privacyModeImpls + : privacyModeImpls.where((e) { + final implKey = (e as List)[0] as String; + return privacyModeState.value == implKey; + }).toList(); + return visibleImpls.map((e) { final implKey = (e as List)[0] as String; final implName = (e)[1] as String; + final enabled = !ffiModel.viewOnly && + (hasPrivacyModePermission || privacyModeState.value == implKey); return TToggleMenu( child: Text(translate(implName)), value: privacyModeState.value == implKey, - onChanged: (value) { - if (value == null) return; - togglePrivacyModeTime = DateTime.now(); - bind.sessionTogglePrivacyMode( - sessionId: sessionId, implKey: implKey, on: value); - }); + onChanged: enabled + ? (value) { + if (value == null) return; + if (value && !hasPrivacyModePermission) return; + togglePrivacyModeTime = DateTime.now(); + bind.sessionTogglePrivacyMode( + sessionId: sessionId, implKey: implKey, on: value); + } + : null); }).toList(); } } @@ -753,6 +858,7 @@ List toolbarKeyboardToggles(FFI ffi) { final ffiModel = ffi.ffiModel; final pi = ffiModel.pi; final sessionId = ffi.sessionId; + final isDefaultConn = ffi.connType == ConnType.defaultConn; List v = []; // swap key @@ -774,6 +880,34 @@ List toolbarKeyboardToggles(FFI ffi) { child: Text(translate('Swap control-command key')))); } + // Relative mouse mode (gaming mode). + // Only show when server supports MOUSE_TYPE_MOVE_RELATIVE (version >= 1.4.5) + // Note: This feature is only available in Flutter client. Sciter client does not support this. + // Web client is not supported yet due to Pointer Lock API integration complexity with Flutter's input system. + // Wayland is not supported due to cursor warping limitations. + // Mobile: This option is now in GestureHelp widget, shown only when joystick is visible. + final isWayland = isDesktop && isLinux && bind.mainCurrentIsWayland(); + if (isDesktop && + isDefaultConn && + !isWeb && + !isWayland && + ffiModel.keyboard && + !ffiModel.viewOnly && + ffi.inputModel.isRelativeMouseModeSupported) { + v.add(TToggleMenu( + value: ffi.inputModel.relativeMouseMode.value, + onChanged: (value) { + if (value == null) return; + final previousValue = ffi.inputModel.relativeMouseMode.value; + final success = ffi.inputModel.setRelativeMouseMode(value); + if (!success) { + // Revert the observable toggle to reflect the actual state + ffi.inputModel.relativeMouseMode.value = previousValue; + } + }, + child: Text(translate('Relative mouse mode')))); + } + // reverse mouse wheel if (ffiModel.keyboard) { var optionValue = diff --git a/flutter/lib/consts.dart b/flutter/lib/consts.dart index 89306bb7a..adf7b1d45 100644 --- a/flutter/lib/consts.dart +++ b/flutter/lib/consts.dart @@ -1,3 +1,4 @@ +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hbb/common.dart'; import 'package:flutter_hbb/models/state_model.dart'; @@ -44,9 +45,12 @@ const String kAppTypeConnectionManager = "cm"; const String kAppTypeDesktopRemote = "remote"; const String kAppTypeDesktopFileTransfer = "file transfer"; +const String kAppTypeDesktopViewCamera = "view camera"; const String kAppTypeDesktopPortForward = "port forward"; +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. @@ -55,10 +59,14 @@ const String kWindowActionRebuild = "rebuild"; const String kWindowEventHide = "hide"; const String kWindowEventShow = "show"; const String kWindowConnect = "connect"; +const String kWindowBumpMouse = "bump_mouse"; const String kWindowEventNewRemoteDesktop = "new_remote_desktop"; const String kWindowEventNewFileTransfer = "new_file_transfer"; +const String kWindowEventNewViewCamera = "new_view_camera"; const String kWindowEventNewPortForward = "new_port_forward"; +const String kWindowEventNewTerminal = "new_terminal"; +const String kWindowEventRestoreTerminalSessions = "restore_terminal_sessions"; const String kWindowEventActiveSession = "active_session"; const String kWindowEventActiveDisplaySession = "active_display_session"; const String kWindowEventGetRemoteList = "get_remote_list"; @@ -72,9 +80,11 @@ const String kWindowEventOpenMonitorSession = "open_monitor_session"; const String kOptionViewStyle = "view_style"; const String kOptionScrollStyle = "scroll_style"; +const String kOptionEdgeScrollEdgeThickness = "edge-scroll-edge-thickness"; const String kOptionImageQuality = "image_quality"; const String kOptionOpenNewConnInTabs = "enable-open-new-connections-in-tabs"; const String kOptionTextureRender = "use-texture-render"; +const String kOptionD3DRender = "allow-d3d-render"; const String kOptionOpenInTabs = "allow-open-in-tabs"; const String kOptionOpenInWindows = "allow-open-in-windows"; const String kOptionForceAlwaysRelay = "force-always-relay"; @@ -94,17 +104,27 @@ const String kOptionVideoSaveDirectory = "video-save-directory"; const String kOptionAccessMode = "access-mode"; const String kOptionEnableKeyboard = "enable-keyboard"; // "Settings -> Security -> Permissions" +const String kOptionEnableRemotePrinter = "enable-remote-printer"; const String kOptionEnableClipboard = "enable-clipboard"; const String kOptionEnableFileTransfer = "enable-file-transfer"; const String kOptionEnableAudio = "enable-audio"; +const String kOptionEnableCamera = "enable-camera"; +const String kOptionEnableTerminal = "enable-terminal"; +const String kOptionTerminalPersistent = "terminal-persistent"; const String kOptionEnableTunnel = "enable-tunnel"; const String kOptionEnableRemoteRestart = "enable-remote-restart"; const String kOptionEnableBlockInput = "enable-block-input"; +const String kOptionEnablePrivacyMode = "enable-privacy-mode"; +const String kOptionEnablePermChangeInAcceptWindow = + "enable-perm-change-in-accept-window"; const String kOptionAllowRemoteConfigModification = "allow-remote-config-modification"; const String kOptionVerificationMethod = "verification-method"; const String kOptionApproveMode = "approve-mode"; +const String kOptionAllowNumericOneTimePassword = + "allow-numeric-one-time-password"; const String kOptionCollapseToolbar = "collapse_toolbar"; +const String kOptionHideToolbar = "hide-toolbar"; const String kOptionShowRemoteCursor = "show_remote_cursor"; const String kOptionFollowRemoteCursor = "follow_remote_cursor"; const String kOptionFollowRemoteWindow = "follow_remote_window"; @@ -122,6 +142,10 @@ const String kOptionSwapLeftRightMouse = "swap-left-right-mouse"; const String kOptionCodecPreference = "codec-preference"; const String kOptionRemoteMenubarDragLeft = "remote-menubar-drag-left"; const String kOptionRemoteMenubarDragRight = "remote-menubar-drag-right"; +const String kOptionRemoteMenubarEdge = "remote-menubar-edge"; +const String kOptionRemoteMenubarFraction = "remote-menubar-frac"; +const String kOptionAllowMultiEdgeToolbarDock = + "allow-multi-edge-toolbar-dock"; const String kOptionHideAbTagsPanel = "hideAbTagsPanel"; const String kOptionRemoteMenubarState = "remoteMenubarState"; const String kOptionPeerSorting = "peer-sorting"; @@ -133,29 +157,57 @@ const String kOptionCurrentAbName = "current-ab-name"; 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 kOptionEnableTrustedDevices = "enable-trusted-devices"; +const String kOptionShowVirtualMouse = "show-virtual-mouse"; +const String kOptionVirtualMouseScale = "virtual-mouse-scale"; +const String kOptionShowVirtualJoystick = "show-virtual-joystick"; +const String kOptionAllowAskForNoteAtEndOfConnection = "allow-ask-for-note"; +const String kOptionEnableShowTerminalExtraKeys = "enable-show-terminal-extra-keys"; -// buildin opitons +// network options +const String kOptionAllowWebSocket = "allow-websocket"; +const String kOptionAllowInsecureTLSFallback = "allow-insecure-tls-fallback"; +const String kOptionDisableUdp = "disable-udp"; +const String kOptionEnableFlutterHttpOnRust = "enable-flutter-http-on-rust"; + +// builtin options const String kOptionHideServerSetting = "hide-server-settings"; 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 kOptionHideSecuritySetting = "hide-security-settings"; const String kOptionHideNetworkSetting = "hide-network-settings"; const String kOptionRemovePresetPasswordWarning = "remove-preset-password-warning"; +const String kOptionDisableChangePermanentPassword = + "disable-change-permanent-password"; +const String kOptionDisableChangeId = "disable-change-id"; +const String kOptionDisableUnlockPin = "disable-unlock-pin"; const kHideUsernameOnCard = "hide-username-on-card"; const String kOptionHideHelpCards = "hide-help-cards"; +const String kOptionAllowDeepLinkPassword = "allow-deep-link-password"; +const String kOptionAllowDeepLinkServerSettings = + "allow-deep-link-server-settings"; const String kOptionToggleViewOnly = "view-only"; +const String kOptionToggleShowMyCursor = "show-my-cursor"; const String kOptionDisableFloatingWindow = "disable-floating-window"; const String kOptionKeepScreenOn = "keep-screen-on"; +const String kOptionKeepAwakeDuringIncomingSessions = "keep-awake-during-incoming-sessions"; +const String kOptionKeepAwakeDuringOutgoingSessions = "keep-awake-during-outgoing-sessions"; + const String kOptionShowMobileAction = "showMobileActions"; const String kUrlActionClose = "close"; @@ -169,6 +221,13 @@ const int kWindowMainId = 0; const String kPointerEventKindTouch = "touch"; const String kPointerEventKindMouse = "mouse"; +const String kMouseEventTypeDefault = ""; +const String kMouseEventTypePanStart = "pan_start"; +const String kMouseEventTypePanUpdate = "pan_update"; +const String kMouseEventTypePanEnd = "pan_end"; +const String kMouseEventTypeDown = "down"; +const String kMouseEventTypeUp = "up"; + const String kKeyFlutterKey = "flutter_key"; const String kKeyShowDisplaysAsIndividualWindows = @@ -202,11 +261,53 @@ const double kMinFps = 5; const double kDefaultFps = 30; const double kMaxFps = 120; -const double kMinQuality = 5; +const double kMinQuality = 10; const double kDefaultQuality = 50; const double kMaxQuality = 100; const double kMaxMoreQuality = 2000; +// trackpad speed +const String kKeyTrackpadSpeed = 'trackpad-speed'; +const int kMinTrackpadSpeed = 10; +const int kDefaultTrackpadSpeed = 100; +const int kMaxTrackpadSpeed = 1000; + +// relative mouse mode +/// Throttle duration (in milliseconds) for updating pointer lock center during +/// window move/resize events. Lower values provide more responsive updates but +/// may cause performance issues during rapid window operations. +const int kDefaultPointerLockCenterThrottleMs = 100; + +/// Minimum server version required for relative mouse mode (MOUSE_TYPE_MOVE_RELATIVE). +/// Servers older than this version will ignore relative mouse events. +/// +/// IMPORTANT: This value must be kept in sync with the Rust constant +/// `MIN_VERSION_RELATIVE_MOUSE_MODE` in `src/common.rs`. +const String kMinVersionForRelativeMouseMode = '1.4.5'; + +/// Maximum delta value for relative mouse movement. +/// Large values could cause issues with i32 overflow on server side, +/// and no reasonable mouse movement should exceed this bound. +/// +/// IMPORTANT: This value must be kept in sync with the Rust constant +/// `MAX_RELATIVE_MOUSE_DELTA` in `src/server/input_service.rs`. +const int kMaxRelativeMouseDelta = 10000; + +/// Debounce duration (in milliseconds) for relative mouse mode toggle. +/// This prevents double-toggle from race condition between Rust rdev grab loop +/// and Flutter keyboard handling. Value should be small enough to allow +/// intentional quick toggles but large enough to prevent accidental double-triggers. +const int kRelativeMouseModeToggleDebounceMs = 150; + +// incomming (should be incoming) is kept, because change it will break the previous setting. +const String kKeyPrinterIncomingJobAction = 'printer-incomming-job-action'; +const String kValuePrinterIncomingJobDismiss = 'dismiss'; +const String kValuePrinterIncomingJobDefault = ''; +const String kValuePrinterIncomingJobSelected = 'selected'; +const String kKeyPrinterSelected = 'printer-selected-name'; +const String kKeyPrinterSave = 'allow-printer-dialog-save'; +const String kKeyPrinterAllowAutoPrint = 'allow-printer-auto-print'; + double kNewWindowOffset = isWindows ? 56.0 : isLinux @@ -237,15 +338,11 @@ const double kDesktopIconButtonSplashRadius = 20; /// [kMinCursorSize] indicates min cursor (w, h) const int kMinCursorSize = 12; -/// [kDefaultScrollAmountMultiplier] indicates how many rows can be scrolled after a minimum scroll action of mouse -const kDefaultScrollAmountMultiplier = 5.0; -const kDefaultScrollDuration = Duration(milliseconds: 50); -const kDefaultMouseWheelThrottleDuration = Duration(milliseconds: 50); const kFullScreenEdgeSize = 0.0; const kMaximizeEdgeSize = 0.0; // Do not use kWindowResizeEdgeSize directly. Use `windowResizeEdgeSize` in `common.dart` instead. const kWindowResizeEdgeSize = 5.0; -const kWindowBorderWidth = 1.0; +final kWindowBorderWidth = isWindows ? 0.0 : 1.0; const kDesktopMenuPadding = EdgeInsets.only(left: 12.0, right: 3.0); const kFrameBorderRadius = 12.0; const kFrameClipRRectBorderRadius = 12.0; @@ -273,12 +370,18 @@ const kRemoteViewStyleOriginal = 'original'; /// [kRemoteViewStyleAdaptive] Show remote image scaling by ratio factor. const kRemoteViewStyleAdaptive = 'adaptive'; +/// [kRemoteViewStyleCustom] Show remote image at a user-defined scale percent. +const kRemoteViewStyleCustom = 'custom'; + /// [kRemoteScrollStyleAuto] Scroll image auto by position. const kRemoteScrollStyleAuto = 'scrollauto'; /// [kRemoteScrollStyleBar] Scroll image with scroll bar. const kRemoteScrollStyleBar = 'scrollbar'; +/// [kRemoteScrollStyleEdge] Scroll image auto at edges. +const kRemoteScrollStyleEdge = 'scrolledge'; + /// [kScrollModeDefault] Mouse or touchpad, the default scroll mode. const kScrollModeDefault = 'default'; @@ -299,6 +402,23 @@ const kRemoteImageQualityCustom = 'custom'; const kIgnoreDpi = true; +const Set kTouchBasedDeviceKinds = { + PointerDeviceKind.touch, + PointerDeviceKind.stylus, + PointerDeviceKind.invertedStylus, +}; + +// Scale custom related constants +const String kCustomScalePercentKey = + 'custom_scale_percent'; // Flutter option key for storing custom scale percent (integer 5-1000) +const int kScaleCustomMinPercent = 5; +const int kScaleCustomPivotPercent = 100; // 100% should be at 1/3 of track +const int kScaleCustomMaxPercent = 1000; +const double kScaleCustomPivotPos = 1.0 / 3.0; // first 1/3 → up to 100% +const double kScaleCustomDetentEpsilon = + 0.006; // snap range around pivot (~0.6%) +const Duration kDebounceCustomScaleDuration = Duration(milliseconds: 300); + // ================================ mobile ================================ // Magic numbers, maybe need to avoid it or use a better way to get them. diff --git a/flutter/lib/desktop/pages/connection_page.dart b/flutter/lib/desktop/pages/connection_page.dart index 744c05f9c..bdf3829e1 100644 --- a/flutter/lib/desktop/pages/connection_page.dart +++ b/flutter/lib/desktop/pages/connection_page.dart @@ -2,10 +2,12 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_hbb/common/widgets/connection_page_title.dart'; import 'package:flutter_hbb/consts.dart'; +import 'package:flutter_hbb/desktop/widgets/popup_menu.dart'; import 'package:flutter_hbb/models/state_model.dart'; import 'package:get/get.dart'; import 'package:url_launcher/url_launcher_string.dart'; @@ -17,7 +19,7 @@ import '../../common/formatter/id_formatter.dart'; import '../../common/widgets/peer_tab_page.dart'; import '../../common/widgets/autocomplete.dart'; import '../../models/platform_model.dart'; -import '../widgets/button.dart'; +import '../../desktop/widgets/material_mod_popup_menu.dart' as mod_menu; class OnlineStatusWidget extends StatefulWidget { const OnlineStatusWidget({Key? key, this.onSvcStatusChanged}) @@ -39,7 +41,7 @@ class _OnlineStatusWidgetState extends State { double? get height => bind.isIncomingOnly() ? null : em * 3; void onUsePublicServerGuide() { - const url = "https://rustdesk.com/pricing.html"; + const url = "https://rustdesk.com/pricing"; canLaunchUrlString(url).then((can) { if (can) { launchUrlString(url); @@ -179,6 +181,9 @@ class _OnlineStatusWidgetState extends State { stateGlobal.svcStatus.value = SvcStatus.notReady; } _svcIsUsingPublicServer.value = await bind.mainIsUsingPublicServer(); + try { + stateGlobal.videoConnCount.value = status['video_conn_count'] as int; + } catch (_) {} } } @@ -197,16 +202,25 @@ class _ConnectionPageState extends State final _idController = IDTextEditingController(); final RxBool _idInputFocused = false.obs; + final FocusNode _idFocusNode = FocusNode(); + final TextEditingController _idEditingController = TextEditingController(); + + String selectedConnectionType = 'Connect'; bool isWindowMinimized = false; - List peers = []; - bool isPeersLoading = false; - bool isPeersLoaded = false; + final AllPeersLoader _allPeersLoader = AllPeersLoader(); + + // https://github.com/flutter/flutter/issues/157244 + Iterable _autocompleteOpts = []; + + final _menuOpen = false.obs; @override void initState() { super.initState(); + _allPeersLoader.init(setState); + _idFocusNode.addListener(onFocusChanged); if (_idController.text.isEmpty) { WidgetsBinding.instance.addPostFrameCallback((_) async { final lastRemoteId = await bind.mainGetLastRemoteId(); @@ -217,6 +231,7 @@ class _ConnectionPageState extends State } }); } + Get.put(_idEditingController); Get.put(_idController); windowManager.addListener(this); } @@ -225,6 +240,10 @@ class _ConnectionPageState extends State void dispose() { _idController.dispose(); windowManager.removeListener(this); + _allPeersLoader.clear(); + _idFocusNode.removeListener(onFocusChanged); + _idFocusNode.dispose(); + _idEditingController.dispose(); if (Get.isRegistered()) { Get.delete(); } @@ -268,6 +287,20 @@ class _ConnectionPageState extends State bind.mainOnMainWindowClose(); } + void onFocusChanged() { + _idInputFocused.value = _idFocusNode.hasFocus; + if (_idFocusNode.hasFocus) { + if (_allPeersLoader.needLoad) { + _allPeersLoader.getAllPeers(); + } + + final textLength = _idEditingController.value.text.length; + // Select all to facilitate removing text, just following the behavior of address input of chrome. + _idEditingController.selection = + TextSelection(baseOffset: 0, extentOffset: textLength); + } + } + @override Widget build(BuildContext context) { final isOutgoingOnly = bind.isOutgoingOnly(); @@ -294,21 +327,15 @@ class _ConnectionPageState extends State /// Callback for the connect button. /// Connects to the selected peer. - void onConnect({bool isFileTransfer = false}) { + void onConnect( + {bool isFileTransfer = false, + bool isViewCamera = false, + bool isTerminal = false}) { var id = _idController.id; - connect(context, id, isFileTransfer: isFileTransfer); - } - - Future _fetchPeers() async { - setState(() { - isPeersLoading = true; - }); - await Future.delayed(Duration(milliseconds: 100)); - peers = await getAllPeers(); - setState(() { - isPeersLoading = false; - isPeersLoaded = true; - }); + connect(context, id, + isFileTransfer: isFileTransfer, + isViewCamera: isViewCamera, + isTerminal: isTerminal); } /// UI for the remote ID TextField. @@ -327,11 +354,12 @@ class _ConnectionPageState extends State Row( children: [ Expanded( - child: Autocomplete( + child: RawAutocomplete( optionsBuilder: (TextEditingValue textEditingValue) { if (textEditingValue.text == '') { - return const Iterable.empty(); - } else if (peers.isEmpty && !isPeersLoaded) { + _autocompleteOpts = const Iterable.empty(); + } else if (_allPeersLoader.peers.isEmpty && + !_allPeersLoader.isPeersLoaded) { Peer emptyPeer = Peer( id: '', username: '', @@ -345,8 +373,10 @@ class _ConnectionPageState extends State rdpPort: '', rdpUsername: '', loginName: '', + device_group_name: '', + note: '', ); - return [emptyPeer]; + _autocompleteOpts = [emptyPeer]; } else { String textWithoutSpaces = textEditingValue.text.replaceAll(" ", ""); @@ -357,8 +387,7 @@ class _ConnectionPageState extends State ); } String textToFind = textEditingValue.text.toLowerCase(); - - return peers + _autocompleteOpts = _allPeersLoader.peers .where((peer) => peer.id.toLowerCase().contains(textToFind) || peer.username @@ -370,26 +399,18 @@ class _ConnectionPageState extends State peer.alias.toLowerCase().contains(textToFind)) .toList(); } + return _autocompleteOpts; }, + focusNode: _idFocusNode, + textEditingController: _idEditingController, fieldViewBuilder: ( BuildContext context, TextEditingController fieldTextEditingController, FocusNode fieldFocusNode, VoidCallback onFieldSubmitted, ) { - fieldTextEditingController.text = _idController.text; - Get.put(fieldTextEditingController); - fieldFocusNode.addListener(() async { - _idInputFocused.value = fieldFocusNode.hasFocus; - if (fieldFocusNode.hasFocus && !isPeersLoading) { - _fetchPeers(); - } - }); - final textLength = - fieldTextEditingController.value.text.length; - // select all to facilitate removing text, just following the behavior of address input of chrome - fieldTextEditingController.selection = - TextSelection(baseOffset: 0, extentOffset: textLength); + updateTextAndPreserveSelection( + fieldTextEditingController, _idController.text); return Obx(() => TextField( autocorrect: false, enableSuggestions: false, @@ -419,7 +440,7 @@ class _ConnectionPageState extends State onSubmitted: (_) { onConnect(); }, - )); + ).workaroundFreezeLinuxMint()); }, onSelected: (option) { setState(() { @@ -430,6 +451,7 @@ class _ConnectionPageState extends State optionsViewBuilder: (BuildContext context, AutocompleteOnSelected onSelected, Iterable options) { + options = _autocompleteOpts; double maxHeight = options.length * 50; if (options.length == 1) { maxHeight = 52; @@ -461,7 +483,8 @@ class _ConnectionPageState extends State maxHeight: maxHeight, maxWidth: 319, ), - child: peers.isEmpty && isPeersLoading + child: _allPeersLoader.peers.isEmpty && + !_allPeersLoader.isPeersLoaded ? Container( height: 80, child: Center( @@ -491,21 +514,97 @@ class _ConnectionPageState extends State ), Padding( padding: const EdgeInsets.only(top: 13.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Button( - isOutline: true, - onTap: () => onConnect(isFileTransfer: true), - text: "Transfer file", + child: Row(mainAxisAlignment: MainAxisAlignment.end, children: [ + SizedBox( + height: 28.0, + child: ElevatedButton( + onPressed: () { + onConnect(); + }, + child: Text(translate("Connect")), ), - const SizedBox( - width: 17, + ), + const SizedBox(width: 8), + Container( + height: 28.0, + width: 28.0, + decoration: BoxDecoration( + border: Border.all(color: Theme.of(context).dividerColor), + borderRadius: BorderRadius.circular(8), ), - Button(onTap: onConnect, text: "Connect"), - ], - ), - ) + child: Center( + child: StatefulBuilder( + builder: (context, setState) { + var offset = Offset(0, 0); + return Obx(() => InkWell( + child: _menuOpen.value + ? Transform.rotate( + angle: pi, + child: Icon(IconFont.more, size: 14), + ) + : Icon(IconFont.more, size: 14), + onTapDown: (e) { + offset = e.globalPosition; + }, + onTap: () async { + _menuOpen.value = true; + final x = offset.dx; + final y = offset.dy; + await mod_menu + .showMenu( + context: context, + position: RelativeRect.fromLTRB(x, y, x, y), + items: [ + ( + 'Transfer file', + () => onConnect(isFileTransfer: true) + ), + ( + 'View camera', + () => onConnect(isViewCamera: true) + ), + ( + '${translate('Terminal')} (beta)', + () => onConnect(isTerminal: true) + ), + ] + .map((e) => MenuEntryButton( + childBuilder: (TextStyle? style) => + Text( + translate(e.$1), + style: style, + ), + proc: () => e.$2(), + padding: EdgeInsets.symmetric( + horizontal: + kDesktopMenuPadding.left), + dismissOnClicked: true, + )) + .map((e) => e.build( + context, + const MenuConfig( + commonColor: CustomPopupMenuTheme + .commonColor, + height: + CustomPopupMenuTheme.height, + dividerHeight: + CustomPopupMenuTheme + .dividerHeight))) + .expand((i) => i) + .toList(), + elevation: 8, + ) + .then((_) { + _menuOpen.value = false; + }); + }, + )); + }, + ), + ), + ), + ]), + ), ], ), ), diff --git a/flutter/lib/desktop/pages/desktop_home_page.dart b/flutter/lib/desktop/pages/desktop_home_page.dart index 493e4ca47..42ec10032 100644 --- a/flutter/lib/desktop/pages/desktop_home_page.dart +++ b/flutter/lib/desktop/pages/desktop_home_page.dart @@ -12,17 +12,18 @@ import 'package:flutter_hbb/consts.dart'; import 'package:flutter_hbb/desktop/pages/connection_page.dart'; import 'package:flutter_hbb/desktop/pages/desktop_setting_page.dart'; import 'package:flutter_hbb/desktop/pages/desktop_tab_page.dart'; -import 'package:flutter_hbb/desktop/widgets/scroll_wrapper.dart'; +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'; import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:window_manager/window_manager.dart'; import 'package:window_size/window_size.dart' as window_size; - import '../widgets/button.dart'; class DesktopHomePage extends StatefulWidget { @@ -35,12 +36,11 @@ class DesktopHomePage extends StatefulWidget { const borderColor = Color(0xFF2F65BA); class _DesktopHomePageState extends State - with AutomaticKeepAliveClientMixin { + with AutomaticKeepAliveClientMixin, WidgetsBindingObserver { final _leftPaneScrollController = ScrollController(); @override bool get wantKeepAlive => true; - var updateUrl = ''; var systemError = ''; StreamSubscription? _uniLinksSubscription; var svcStopped = false.obs; @@ -52,6 +52,7 @@ class _DesktopHomePageState extends State bool isCardClosed = false; final RxBool _editHover = false.obs; + final RxBool _block = false.obs; final GlobalKey _childKey = GlobalKey(); @@ -59,14 +60,20 @@ class _DesktopHomePageState extends State Widget build(BuildContext context) { super.build(context); final isIncomingOnly = bind.isIncomingOnly(); - return Row( + return _buildBlock( + child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ buildLeftPane(context), if (!isIncomingOnly) const VerticalDivider(width: 1), if (!isIncomingOnly) Expanded(child: buildRightPane(context)), ], - ); + )); + } + + Widget _buildBlock({required Widget child}) { + return buildRemoteBlock( + block: _block, mask: true, use: canBeBlocked, child: child); } Widget buildLeftPane(BuildContext context) { @@ -87,7 +94,8 @@ class _DesktopHomePageState extends State if (!isOutgoingOnly) buildIDBoard(context), if (!isOutgoingOnly) buildPasswordBoard(context), FutureBuilder( - future: buildHelpCards(), + future: Future.value( + Obx(() => buildHelpCards(stateGlobal.updateUrl.value))), builder: (_, data) { if (data.hasData) { if (isIncomingOnly) { @@ -125,47 +133,48 @@ class _DesktopHomePageState extends State child: Container( width: isIncomingOnly ? 280.0 : 200.0, color: Theme.of(context).colorScheme.background, - child: DesktopScrollWrapper( - scrollController: _leftPaneScrollController, - child: Stack( - children: [ - SingleChildScrollView( - controller: _leftPaneScrollController, - physics: DraggableNeverScrollableScrollPhysics(), - child: Column( - key: _childKey, - children: children, - ), - ), - if (isOutgoingOnly) - Positioned( - bottom: 6, - left: 12, - child: Align( - alignment: Alignment.centerLeft, - child: InkWell( - child: Obx( - () => Icon( - Icons.settings, - color: _editHover.value - ? textColor - : Colors.grey.withOpacity(0.5), - size: 22, - ), - ), - onTap: () => { - if (DesktopSettingPage.tabKeys.isNotEmpty) - { - DesktopSettingPage.switch2page( - DesktopSettingPage.tabKeys[0]) - } - }, - onHover: (value) => _editHover.value = value, - ), + child: Stack( + children: [ + Column( + children: [ + SingleChildScrollView( + controller: _leftPaneScrollController, + child: Column( + key: _childKey, + children: children, ), - ) - ], - ), + ), + Expanded(child: Container()) + ], + ), + if (isOutgoingOnly) + Positioned( + bottom: 6, + left: 12, + child: Align( + alignment: Alignment.centerLeft, + child: InkWell( + child: Obx( + () => Icon( + Icons.settings, + color: _editHover.value + ? textColor + : Colors.grey.withOpacity(0.5), + size: 22, + ), + ), + onTap: () => { + if (DesktopSettingPage.tabKeys.isNotEmpty) + { + DesktopSettingPage.switch2page( + DesktopSettingPage.tabKeys[0]) + } + }, + onHover: (value) => _editHover.value = value, + ), + ), + ) + ], ), ), ); @@ -234,7 +243,7 @@ class _DesktopHomePageState extends State style: TextStyle( fontSize: 22, ), - ), + ).workaroundFreezeLinuxMint(), ), ) ], @@ -272,10 +281,21 @@ class _DesktopHomePageState extends State } buildPasswordBoard(BuildContext context) { - final model = gFFI.serverModel; + return ChangeNotifierProvider.value( + value: gFFI.serverModel, + child: Consumer( + builder: (context, model, child) { + return buildPasswordBoard2(context, model); + }, + )); + } + + buildPasswordBoard2(BuildContext context, ServerModel model) { RxBool refreshHover = false.obs; RxBool editHover = false.obs; final textColor = Theme.of(context).textTheme.titleLarge?.color; + final showOneTime = model.approveMode != 'click' && + model.verificationMethod != kUsePermanentPassword; return Container( margin: EdgeInsets.only(left: 20.0, right: 16, top: 13, bottom: 13), child: Row( @@ -304,8 +324,7 @@ class _DesktopHomePageState extends State Expanded( child: GestureDetector( onDoubleTap: () { - if (model.verificationMethod != - kUsePermanentPassword) { + if (showOneTime) { Clipboard.setData( ClipboardData(text: model.serverPasswd.text)); showToast(translate("Copied")); @@ -320,25 +339,26 @@ class _DesktopHomePageState extends State EdgeInsets.only(top: 14, bottom: 10), ), style: TextStyle(fontSize: 15), - ), + ).workaroundFreezeLinuxMint(), ), ), - AnimatedRotationWidget( - onPressed: () => bind.mainUpdateTemporaryPassword(), - child: Tooltip( - message: translate('Refresh Password'), - child: Obx(() => RotatedBox( - quarterTurns: 2, - child: Icon( - Icons.refresh, - color: refreshHover.value - ? textColor - : Color(0xFFDDDDDD), - size: 22, - ))), - ), - onHover: (value) => refreshHover.value = value, - ).marginOnly(right: 8, top: 4), + if (showOneTime) + AnimatedRotationWidget( + onPressed: () => bind.mainUpdateTemporaryPassword(), + child: Tooltip( + message: translate('Refresh Password'), + child: Obx(() => RotatedBox( + quarterTurns: 2, + child: Icon( + Icons.refresh, + color: refreshHover.value + ? textColor + : Color(0xFFDDDDDD), + size: 22, + ))), + ), + onHover: (value) => refreshHover.value = value, + ).marginOnly(right: 8, top: 4), if (!bind.isDisableSettings()) InkWell( child: Tooltip( @@ -409,18 +429,32 @@ class _DesktopHomePageState extends State ); } - Future buildHelpCards() async { + Widget buildHelpCards(String updateUrl) { if (!bind.isCustomClient() && updateUrl.isNotEmpty && !isCardClosed && bind.mainUriPrefixSync().contains('rustdesk')) { - return buildInstallCard( - "Status", - "There is a newer version of ${bind.mainGetAppNameSync()} ${bind.mainGetNewVersion()} available.", - "Click to download", () async { + final isToUpdate = (isWindows || isMacOS) && bind.mainIsInstalled(); + String btnText = isToUpdate ? 'Update' : 'Download'; + GestureTapCallback onPressed = () async { final Uri url = Uri.parse('https://rustdesk.com/download'); await launchUrl(url); - }, closeButton: true); + }; + if (isToUpdate) { + onPressed = () { + handleUpdate(updateUrl); + }; + } + return buildInstallCard( + "Status", + "${translate("new-version-of-{${bind.mainGetAppNameSync()}}-tip")} (${bind.mainGetNewVersion()}).", + btnText, + onPressed, + closeButton: true, + help: isToUpdate ? 'Changelog' : null, + link: isToUpdate + ? 'https://github.com/rustdesk/rustdesk/releases/tag/${bind.mainGetNewVersion()}' + : null); } if (systemError.isNotEmpty) { return buildInstallCard("", systemError, "", () {}); @@ -663,20 +697,6 @@ class _DesktopHomePageState extends State @override void initState() { super.initState(); - if (!bind.isCustomClient()) { - platformFFI.registerEventHandler( - kCheckSoftwareUpdateFinish, kCheckSoftwareUpdateFinish, - (Map evt) async { - if (evt['url'] is String) { - setState(() { - updateUrl = evt['url']; - }); - } - }); - Timer(const Duration(seconds: 1), () async { - bind.mainGetSoftwareUpdateUrl(); - }); - } _updateTimer = periodic_immediate(const Duration(seconds: 1), () async { await gFFI.serverModel.fetchID(); final error = await bind.mainGetError(); @@ -745,11 +765,23 @@ class _DesktopHomePageState extends State 'scaleFactor': screen.scaleFactor, }; + bool isChattyMethod(String methodName) { + switch (methodName) { + case kWindowBumpMouse: return true; + } + + return false; + } + rustDeskWinManager.setMethodHandler((call, fromWindowId) async { - debugPrint( + if (!isChattyMethod(call.method)) { + debugPrint( "[Main] call ${call.method} with args ${call.arguments} from window $fromWindowId"); + } if (call.method == kWindowMainWindowOnTop) { 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) { @@ -770,12 +802,18 @@ class _DesktopHomePageState extends State await connectMainDesktop( call.arguments['id'], isFileTransfer: call.arguments['isFileTransfer'], + isViewCamera: call.arguments['isViewCamera'], + isTerminal: call.arguments['isTerminal'], isTcpTunneling: call.arguments['isTcpTunneling'], isRDP: call.arguments['isRDP'], password: call.arguments['password'], forceRelay: call.arguments['forceRelay'], connToken: call.arguments['connToken'], ); + } else if (call.method == kWindowBumpMouse) { + return RdPlatformChannel.instance.bumpMouse( + dx: call.arguments['dx'], + dy: call.arguments['dy']); } else if (call.method == kWindowEventMoveTabToNewWindow) { final args = call.arguments.split(','); int? windowId; @@ -784,9 +822,15 @@ class _DesktopHomePageState extends State } catch (e) { debugPrint("Failed to parse window id '${call.arguments}': $e"); } - if (windowId != null) { + WindowType? windowType; + try { + windowType = WindowType.values.byName(args[3]); + } catch (e) { + debugPrint("Failed to parse window type '${call.arguments}': $e"); + } + if (windowId != null && windowType != null) { await rustDeskWinManager.moveTabToNewWindow( - windowId, args[1], args[2]); + windowId, args[1], args[2], windowType); } } else if (call.method == kWindowEventOpenMonitorSession) { final args = jsonDecode(call.arguments); @@ -794,9 +838,10 @@ class _DesktopHomePageState extends State final peerId = args['peer_id'] as String; final display = args['display'] as int; final displayCount = args['display_count'] as int; + final windowType = args['window_type'] as int; final screenRect = parseParamScreenRect(args); await rustDeskWinManager.openMonitorSession( - windowId, peerId, display, displayCount, screenRect); + windowId, peerId, display, displayCount, screenRect, windowType); } else if (call.method == kWindowEventRemoteWindowCoords) { final windowId = int.tryParse(call.arguments); if (windowId != null) { @@ -812,6 +857,7 @@ class _DesktopHomePageState extends State _updateWindowSize(); }); } + WidgetsBinding.instance.addObserver(this); } _updateWindowSize() { @@ -833,13 +879,18 @@ class _DesktopHomePageState extends State _uniLinksSubscription?.cancel(); Get.delete(tag: 'stop-service'); _updateTimer?.cancel(); - if (!bind.isCustomClient()) { - platformFFI.unregisterEventHandler( - kCheckSoftwareUpdateFinish, kCheckSoftwareUpdateFinish); - } + WidgetsBinding.instance.removeObserver(this); super.dispose(); } + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + super.didChangeAppLifecycleState(state); + if (state == AppLifecycleState.resumed) { + shouldBeBlocked(_block, canBeBlocked); + } + } + Widget buildPluginEntry() { final entries = PluginUiManager.instance.entries.entries; return Offstage( @@ -857,12 +908,17 @@ class _DesktopHomePageState extends State } void setPasswordDialog({VoidCallback? notEmptyCallback}) async { - final pw = await bind.mainGetPermanentPassword(); - final p0 = TextEditingController(text: pw); - final p1 = TextEditingController(text: pw); + final p0 = TextEditingController(text: ""); + final p1 = TextEditingController(text: ""); var errMsg0 = ""; var errMsg1 = ""; - final RxString rxPass = pw.trim().obs; + final localPasswordSet = + (await bind.mainGetCommon(key: "local-permanent-password-set")) == "true"; + final permanentPasswordSet = + (await bind.mainGetCommon(key: "permanent-password-set")) == "true"; + final presetPassword = permanentPasswordSet && !localPasswordSet; + var canSubmit = false; + final RxString rxPass = "".obs; final rules = [ DigitValidationRule(), UppercaseValidationRule(), @@ -871,9 +927,21 @@ void setPasswordDialog({VoidCallback? notEmptyCallback}) async { MinCharactersValidationRule(8), ]; final maxLength = bind.mainMaxEncryptLen(); + final statusTip = localPasswordSet + ? translate('password-hidden-tip') + : (presetPassword ? translate('preset-password-in-use-tip') : ''); + final showStatusTipOnMobile = + statusTip.isNotEmpty && !isDesktop && !isWebDesktop; gFFI.dialogManager.show((setState, close, context) { - submit() { + updateCanSubmit() { + canSubmit = p0.text.trim().isNotEmpty || p1.text.trim().isNotEmpty; + } + + submit() async { + if (!canSubmit) { + return; + } setState(() { errMsg0 = ""; errMsg1 = ""; @@ -896,7 +964,13 @@ void setPasswordDialog({VoidCallback? notEmptyCallback}) async { }); return; } - bind.mainSetPermanentPassword(password: pass); + final ok = await bind.mainSetPermanentPasswordWithResult(password: pass); + if (!ok) { + setState(() { + errMsg0 = '${translate('Prompt')}: ${translate("Failed")}'; + }); + return; + } if (pass.isNotEmpty) { notEmptyCallback?.call(); } @@ -904,14 +978,20 @@ void setPasswordDialog({VoidCallback? notEmptyCallback}) async { } return CustomAlertDialog( - title: Text(translate("Set Password")), + title: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.key, color: MyTheme.accent), + Text(translate("Set Password")).paddingOnly(left: 10), + ], + ), content: ConstrainedBox( constraints: const BoxConstraints(minWidth: 500), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const SizedBox( - height: 8.0, + SizedBox( + height: showStatusTipOnMobile ? 0.0 : 6.0, ), Row( children: [ @@ -927,10 +1007,11 @@ void setPasswordDialog({VoidCallback? notEmptyCallback}) async { rxPass.value = value.trim(); setState(() { errMsg0 = ''; + updateCanSubmit(); }); }, maxLength: maxLength, - ), + ).workaroundFreezeLinuxMint(), ), ], ), @@ -938,9 +1019,9 @@ void setPasswordDialog({VoidCallback? notEmptyCallback}) async { children: [ Expanded(child: PasswordStrengthIndicator(password: rxPass)), ], - ).marginSymmetric(vertical: 8), - const SizedBox( - height: 8.0, + ).marginOnly(top: 2, bottom: showStatusTipOnMobile ? 2 : 8), + SizedBox( + height: showStatusTipOnMobile ? 0.0 : 8.0, ), Row( children: [ @@ -954,18 +1035,31 @@ void setPasswordDialog({VoidCallback? notEmptyCallback}) async { onChanged: (value) { setState(() { errMsg1 = ''; + updateCanSubmit(); }); }, maxLength: maxLength, - ), + ).workaroundFreezeLinuxMint(), ), ], ), - const SizedBox( - height: 8.0, + if (statusTip.isNotEmpty) + Row( + children: [ + Icon(Icons.info, color: Colors.amber, size: 18) + .marginOnly(right: 6), + Expanded( + child: Text( + statusTip, + style: const TextStyle(fontSize: 13, height: 1.1), + )) + ], + ).marginOnly(top: 6, bottom: 2), + SizedBox( + height: showStatusTipOnMobile ? 0.0 : 8.0, ), Obx(() => Wrap( - runSpacing: 8, + runSpacing: showStatusTipOnMobile ? 2.0 : 8.0, spacing: 4, children: rules.map((e) { var checked = e.validate(rxPass.value.trim()); @@ -985,11 +1079,67 @@ void setPasswordDialog({VoidCallback? notEmptyCallback}) async { ], ), ), - actions: [ - dialogButton("Cancel", onPressed: close, isOutline: true), - dialogButton("OK", onPressed: submit), - ], - onSubmit: submit, + actions: (() { + final cancelButton = dialogButton( + "Cancel", + icon: Icon(Icons.close_rounded), + onPressed: close, + isOutline: true, + ); + final removeButton = dialogButton( + "Remove", + icon: Icon(Icons.delete_outline_rounded), + onPressed: () async { + setState(() { + errMsg0 = ""; + errMsg1 = ""; + }); + final ok = + await bind.mainSetPermanentPasswordWithResult(password: ""); + if (!ok) { + setState(() { + errMsg0 = '${translate('Prompt')}: ${translate("Failed")}'; + }); + return; + } + close(); + }, + buttonStyle: ButtonStyle( + backgroundColor: MaterialStatePropertyAll(Colors.red)), + ); + final okButton = dialogButton( + "OK", + icon: Icon(Icons.done_rounded), + onPressed: canSubmit ? submit : null, + ); + if (!isDesktop && !isWebDesktop && localPasswordSet) { + return [ + Align( + alignment: Alignment.centerRight, + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerRight, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + cancelButton, + const SizedBox(width: 4), + removeButton, + const SizedBox(width: 4), + okButton, + ], + ), + ), + ), + ]; + } + return [ + cancelButton, + if (localPasswordSet) removeButton, + okButton, + ]; + })(), + onSubmit: canSubmit ? submit : null, onCancel: close, ); }); diff --git a/flutter/lib/desktop/pages/desktop_setting_page.dart b/flutter/lib/desktop/pages/desktop_setting_page.dart index 15cf2173b..d1d620014 100644 --- a/flutter/lib/desktop/pages/desktop_setting_page.dart +++ b/flutter/lib/desktop/pages/desktop_setting_page.dart @@ -11,15 +11,18 @@ import 'package:flutter_hbb/common/widgets/setting_widgets.dart'; import 'package:flutter_hbb/consts.dart'; import 'package:flutter_hbb/desktop/pages/desktop_home_page.dart'; import 'package:flutter_hbb/desktop/pages/desktop_tab_page.dart'; +import 'package:flutter_hbb/desktop/widgets/remote_toolbar.dart'; +import 'package:flutter_hbb/mobile/widgets/dialog.dart'; 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'; import 'package:url_launcher/url_launcher_string.dart'; -import 'package:flutter_hbb/desktop/widgets/scroll_wrapper.dart'; import '../../common/widgets/dialog.dart'; import '../../common/widgets/login.dart'; @@ -54,6 +57,7 @@ enum SettingsTabKey { display, plugin, account, + printer, about, } @@ -73,6 +77,9 @@ class DesktopSettingPage extends StatefulWidget { if (!isWeb && !bind.isIncomingOnly() && bind.pluginFeatureIsEnabled()) SettingsTabKey.plugin, if (!bind.isDisableAccount()) SettingsTabKey.account, + if (isWindows && + bind.mainGetBuildinOption(key: kOptionHideRemotePrinterSetting) != 'Y') + SettingsTabKey.printer, SettingsTabKey.about, ]; @@ -106,13 +113,20 @@ class DesktopSettingPage extends StatefulWidget { } class _DesktopSettingPageState extends State - with TickerProviderStateMixin, AutomaticKeepAliveClientMixin { + with + TickerProviderStateMixin, + AutomaticKeepAliveClientMixin, + WidgetsBindingObserver { late PageController controller; late Rx selectedTab; @override bool get wantKeepAlive => true; + final RxBool _block = false.obs; + final RxBool _canBeBlocked = false.obs; + Timer? _videoConnTimer; + _DesktopSettingPageState(SettingsTabKey initialTabkey) { var initialIndex = DesktopSettingPage.tabKeys.indexOf(initialTabkey); if (initialIndex == -1) { @@ -132,11 +146,34 @@ class _DesktopSettingPageState extends State }); } + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + super.didChangeAppLifecycleState(state); + if (state == AppLifecycleState.resumed) { + shouldBeBlocked(_block, canBeBlocked); + } + } + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _videoConnTimer = + periodic_immediate(Duration(milliseconds: 1000), () async { + if (!mounted) { + return; + } + _canBeBlocked.value = await canBeBlocked(); + }); + } + @override void dispose() { super.dispose(); Get.delete(tag: _kSettingPageControllerTag); Get.delete(tag: _kSettingPageTabKeyTag); + WidgetsBinding.instance.removeObserver(this); + _videoConnTimer?.cancel(); } List<_TabInfo> _settingTabs() { @@ -167,6 +204,10 @@ class _DesktopSettingPageState extends State settingTabs.add( _TabInfo(tab, 'Account', Icons.person_outline, Icons.person)); break; + case SettingsTabKey.printer: + settingTabs + .add(_TabInfo(tab, 'Printer', Icons.print_outlined, Icons.print)); + break; case SettingsTabKey.about: settingTabs .add(_TabInfo(tab, 'About', Icons.info_outline, Icons.info)); @@ -198,6 +239,9 @@ class _DesktopSettingPageState extends State case SettingsTabKey.account: children.add(const _Account()); break; + case SettingsTabKey.printer: + children.add(const _Printer()); + break; case SettingsTabKey.about: children.add(const _About()); break; @@ -206,12 +250,35 @@ class _DesktopSettingPageState extends State return children; } + Widget _buildBlock({required List children}) { + // check both mouseMoveTime and videoConnCount + return Obx(() { + final videoConnBlock = + _canBeBlocked.value && stateGlobal.videoConnCount > 0; + return Stack(children: [ + buildRemoteBlock( + block: _block, + mask: false, + use: canBeBlocked, + child: preventMouseKeyBuilder( + child: Row(children: children), + block: videoConnBlock, + ), + ), + if (videoConnBlock) + Container( + color: Colors.black.withOpacity(0.5), + ) + ]); + }); + } + @override Widget build(BuildContext context) { super.build(context); return Scaffold( backgroundColor: Theme.of(context).colorScheme.background, - body: Row( + body: _buildBlock( children: [ SizedBox( width: _kTabWidth, @@ -226,13 +293,11 @@ class _DesktopSettingPageState extends State Expanded( child: Container( color: Theme.of(context).scaffoldBackgroundColor, - child: DesktopScrollWrapper( - scrollController: controller, - child: PageView( - controller: controller, - physics: NeverScrollableScrollPhysics(), - children: _children(), - )), + child: PageView( + controller: controller, + physics: NeverScrollableScrollPhysics(), + children: _children(), + ), ), ) ], @@ -281,13 +346,10 @@ class _DesktopSettingPageState extends State Widget _listView({required List<_TabInfo> tabs}) { final scrollController = ScrollController(); - return DesktopScrollWrapper( - scrollController: scrollController, - child: ListView( - physics: DraggableNeverScrollableScrollPhysics(), - controller: scrollController, - children: tabs.map((tab) => _listItem(tab: tab)).toList(), - )); + return ListView( + controller: scrollController, + children: tabs.map((tab) => _listItem(tab: tab)).toList(), + ); } Widget _listItem({required _TabInfo tab}) { @@ -349,22 +411,19 @@ class _GeneralState extends State<_General> { @override Widget build(BuildContext context) { final scrollController = ScrollController(); - return DesktopScrollWrapper( - scrollController: scrollController, - child: ListView( - physics: DraggableNeverScrollableScrollPhysics(), - controller: scrollController, - children: [ - if (!isWeb) service(), - theme(), - _Card(title: 'Language', children: [language()]), - if (!isWeb) hwcodec(), - if (!isWeb) audio(context), - if (!isWeb) record(context), - if (!isWeb) WaylandCard(), - other() - ], - ).marginOnly(bottom: _kListViewBottomMargin)); + return ListView( + controller: scrollController, + children: [ + if (!isWeb) service(), + theme(), + _Card(title: 'Language', children: [language()]), + if (!isWeb) hwcodec(), + if (!isWeb) audio(context), + if (!isWeb) record(context), + if (!isWeb) WaylandCard(), + other() + ], + ).marginOnly(bottom: _kListViewBottomMargin); } Widget theme() { @@ -399,26 +458,46 @@ class _GeneralState extends State<_General> { return const Offstage(); } - return _Card(title: 'Service', children: [ - Obx(() => _Button(serviceStop.value ? 'Start' : 'Stop', () { - () async { - serviceBtnEnabled.value = false; - await start_service(serviceStop.value); - // enable the button after 1 second - Future.delayed(const Duration(seconds: 1), () { - serviceBtnEnabled.value = true; - }); - }(); - }, enabled: serviceBtnEnabled.value)) - ]); + final hideStopService = + bind.mainGetBuildinOption(key: kOptionHideStopService) == 'Y'; + + return Obx(() { + if (hideStopService && !serviceStop.value) { + return const Offstage(); + } + + return _Card(title: 'Service', children: [ + _Button(serviceStop.value ? 'Start' : 'Stop', () { + () async { + serviceBtnEnabled.value = false; + await start_service(serviceStop.value); + // enable the button after 1 second + Future.delayed(const Duration(seconds: 1), () { + serviceBtnEnabled.value = true; + }); + }(); + }, enabled: serviceBtnEnabled.value) + ]); + }); } Widget other() { + final showAutoUpdate = isWindows && bind.mainIsInstalled(); final children = [ if (!isWeb && !bind.isIncomingOnly()) _OptionCheckBox(context, 'Confirm before closing multiple tabs', kOptionEnableConfirmClosingTabs, isServer: false), + if (!bind.isIncomingOnly()) + _OptionCheckBox( + context, + 'allow-remote-toolbar-docking-any-edge', + kOptionAllowMultiEdgeToolbarDock, + isServer: false, + update: (_) { + reloadAllWindows(); + }, + ), _OptionCheckBox(context, 'Adaptive bitrate', kOptionEnableAbr), if (!isWeb) wallpaper(), if (!isWeb && !bind.isIncomingOnly()) ...[ @@ -450,6 +529,16 @@ class _GeneralState extends State<_General> { await bind.mainSetLocalOption(key: k, value: v ? 'Y' : 'N'), ), ), + if (isWindows) + Tooltip( + message: translate('d3d_render_tip'), + child: _OptionCheckBox( + context, + "Use D3D rendering", + kOptionD3DRender, + isServer: false, + ), + ), if (!isWeb && !bind.isCustomClient()) _OptionCheckBox( context, @@ -457,18 +546,65 @@ class _GeneralState extends State<_General> { kOptionEnableCheckUpdate, isServer: false, ), + if (showAutoUpdate) + _OptionCheckBox( + context, + 'Auto update', + kOptionAllowAutoUpdate, + isServer: true, + ), if (isWindows && !bind.isOutgoingOnly()) _OptionCheckBox( context, 'Capture screen using DirectX', kOptionDirectxCapture, - ) + ), + if (!bind.isIncomingOnly()) ...[ + _OptionCheckBox( + context, + 'Enable UDP hole punching', + kOptionEnableUdpPunch, + isServer: false, + ), + _OptionCheckBox( + context, + 'Enable IPv6 P2P connection', + kOptionEnableIpv6Punch, + isServer: false, + ), + ], ], ]; + + // Add client-side wakelock option for desktop platforms + if (!bind.isIncomingOnly()) { + children.add(_OptionCheckBox( + context, + 'keep-awake-during-outgoing-sessions-label', + kOptionKeepAwakeDuringOutgoingSessions, + isServer: false, + )); + } + if (!isWeb && bind.mainShowOption(key: kOptionAllowLinuxHeadless)) { children.add(_OptionCheckBox( context, 'Allow linux headless', kOptionAllowLinuxHeadless)); } + if (!bind.isDisableAccount()) { + children.add(_OptionCheckBox( + context, + 'note-at-conn-end-tip', + kOptionAllowAskForNoteAtEndOfConnection, + isServer: false, + optSetter: (key, value) async { + if (value && !gFFI.userModel.isLogin) { + final res = await loginDialog(); + if (res != true) return; + } + await mainSetLocalBoolOption(key, value); + }, + )); + } return _Card(title: 'Other', children: children); } @@ -561,7 +697,6 @@ class _GeneralState extends State<_General> { bool user_dir_exists = await Directory(user_dir).exists(); bool root_dir_exists = showRootDir ? await Directory(root_dir).exists() : false; - // canLaunchUrl blocked on windows portable, user SYSTEM return { 'user_dir': user_dir, 'root_dir': root_dir, @@ -705,29 +840,27 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin { @override Widget build(BuildContext context) { super.build(context); - return DesktopScrollWrapper( - scrollController: scrollController, - child: SingleChildScrollView( - physics: DraggableNeverScrollableScrollPhysics(), - controller: scrollController, - child: Column( - children: [ - _lock(locked, 'Unlock Security Settings', () { - locked = false; - setState(() => {}); - }), - AbsorbPointer( - absorbing: locked, - child: Column(children: [ - permissions(context), - password(context), - _Card(title: '2FA', children: [tfa()]), - _Card(title: 'ID', children: [changeId()]), - more(context), - ]), - ), - ], - )).marginOnly(bottom: _kListViewBottomMargin)); + return SingleChildScrollView( + controller: scrollController, + child: Column( + children: [ + _lock(locked, 'Unlock Security Settings', () { + locked = false; + setState(() => {}); + }), + preventMouseKeyBuilder( + block: locked, + child: Column(children: [ + permissions(context), + password(context), + _Card(title: '2FA', children: [tfa()]), + if (!isChangeIdDisabled()) + _Card(title: 'ID', children: [changeId()]), + more(context), + ]), + ), + ], + )).marginOnly(bottom: _kListViewBottomMargin); } Widget tfa() { @@ -911,6 +1044,10 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin { _OptionCheckBox( context, 'Enable keyboard/mouse', kOptionEnableKeyboard, enabled: enabled, fakeValue: fakeValue), + if (isWindows) + _OptionCheckBox( + context, 'Enable remote printer', kOptionEnableRemotePrinter, + enabled: enabled, fakeValue: fakeValue), _OptionCheckBox(context, 'Enable clipboard', kOptionEnableClipboard, enabled: enabled, fakeValue: fakeValue), _OptionCheckBox( @@ -918,6 +1055,10 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin { enabled: enabled, fakeValue: fakeValue), _OptionCheckBox(context, 'Enable audio', kOptionEnableAudio, enabled: enabled, fakeValue: fakeValue), + _OptionCheckBox(context, 'Enable camera', kOptionEnableCamera, + enabled: enabled, fakeValue: fakeValue), + _OptionCheckBox(context, 'Enable terminal', kOptionEnableTerminal, + enabled: enabled, fakeValue: fakeValue), _OptionCheckBox( context, 'Enable TCP tunneling', kOptionEnableTunnel, enabled: enabled, fakeValue: fakeValue), @@ -931,6 +1072,10 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin { _OptionCheckBox(context, 'Enable blocking user input', kOptionEnableBlockInput, enabled: enabled, fakeValue: fakeValue), + if (bind.mainSupportedPrivacyModeImpls() != '[]') + _OptionCheckBox( + context, 'Enable privacy mode', kOptionEnablePrivacyMode, + enabled: enabled, fakeValue: fakeValue), _OptionCheckBox(context, 'Enable remote configuration modification', kOptionAllowRemoteConfigModification, enabled: enabled, fakeValue: fakeValue), @@ -978,8 +1123,13 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin { if (value == passwordValues[passwordKeys .indexOf(kUsePermanentPassword)] && - (await bind.mainGetPermanentPassword()) - .isEmpty) { + (await bind.mainGetCommon( + key: "permanent-password-set")) != + "true") { + if (isChangePermanentPasswordDisabled()) { + await callback(); + return; + } setPasswordDialog(notEmptyCallback: callback); } else { await callback(); @@ -1018,6 +1168,34 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin { )) .toList(); + final isOptFixedNumOTP = + isOptionFixed(kOptionAllowNumericOneTimePassword); + final isNumOPTChangable = !isOptFixedNumOTP && tmpEnabled && !locked; + final numericOneTimePassword = GestureDetector( + child: InkWell( + child: Row( + children: [ + Checkbox( + value: model.allowNumericOneTimePassword, + onChanged: isNumOPTChangable + ? (bool? v) { + model.switchAllowNumericOneTimePassword(); + } + : null) + .marginOnly(right: 5), + Expanded( + child: Text( + translate('Numeric one-time password'), + style: TextStyle( + color: disabledTextColor(context, isNumOPTChangable)), + )) + ], + )), + onTap: isNumOPTChangable + ? () => model.switchAllowNumericOneTimePassword() + : null, + ).marginOnly(left: _kContentHSubMargin - 5); + final modeKeys = [ 'password', 'click', @@ -1029,7 +1207,9 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin { translate('Accept sessions via both'), ]; var modeInitialKey = model.approveMode; - if (!modeKeys.contains(modeInitialKey)) modeInitialKey = ''; + if (!modeKeys.contains(modeInitialKey)) { + modeInitialKey = defaultOptionApproveMode; + } final usePassword = model.approveMode != 'click'; final isApproveModeFixed = isOptionFixed(kOptionApproveMode); @@ -1052,8 +1232,9 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin { ], ), enabled: tmpEnabled && !locked), + if (usePassword) numericOneTimePassword, if (usePassword) radios[1], - if (usePassword) + if (usePassword && !isChangePermanentPasswordDisabled()) _SubButton('Set permanent password', setPasswordDialog, permEnabled && !locked), // if (usePassword) @@ -1072,11 +1253,14 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin { ...directIp(context), whitelist(), ...autoDisconnect(context), + _OptionCheckBox(context, 'keep-awake-during-incoming-sessions-label', + kOptionKeepAwakeDuringIncomingSessions, + reverse: false, enabled: enabled), if (bind.mainIsInstalled()) _OptionCheckBox(context, 'allow-only-conn-window-open-tip', 'allow-only-conn-window-open', reverse: false, enabled: enabled), - if (bind.mainIsInstalled()) unlockPin() + if (bind.mainIsInstalled() && !isUnlockPinDisabled()) unlockPin() ]); } @@ -1144,7 +1328,7 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin { contentPadding: EdgeInsets.symmetric(vertical: 12, horizontal: 12), ), - ).marginOnly(right: 15), + ).workaroundFreezeLinuxMint().marginOnly(right: 15), ), Obx(() => ElevatedButton( onPressed: applyEnabled.value && @@ -1301,7 +1485,7 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin { contentPadding: EdgeInsets.symmetric(vertical: 12, horizontal: 12), ), - ).marginOnly(right: 15), + ).workaroundFreezeLinuxMint().marginOnly(right: 15), ), Obx(() => ElevatedButton( onPressed: @@ -1372,112 +1556,190 @@ class _NetworkState extends State<_Network> with AutomaticKeepAliveClientMixin { bool get wantKeepAlive => true; bool locked = !isWeb && bind.mainIsInstalled(); + final scrollController = ScrollController(); + @override Widget build(BuildContext context) { super.build(context); - bool enabled = !locked; - final scrollController = ScrollController(); - final hideServer = - bind.mainGetBuildinOption(key: kOptionHideServerSetting) == 'Y'; - // TODO: support web proxy - final hideProxy = - isWeb || bind.mainGetBuildinOption(key: kOptionHideProxySetting) == 'Y'; - return DesktopScrollWrapper( - scrollController: scrollController, - child: ListView( - controller: scrollController, - physics: DraggableNeverScrollableScrollPhysics(), - children: [ - _lock(locked, 'Unlock Network Settings', () { - locked = false; - setState(() => {}); - }), - AbsorbPointer( - absorbing: locked, - child: Column(children: [ - if (!hideServer) server(enabled), - if (!hideProxy) - _Card(title: 'Proxy', children: [ - _Button('Socks5/Http(s) Proxy', changeSocks5Proxy, - enabled: enabled), - ]), - ]), - ), - ]).marginOnly(bottom: _kListViewBottomMargin)); + return ListView(controller: scrollController, children: [ + _lock(locked, 'Unlock Network Settings', () { + locked = false; + setState(() => {}); + }), + preventMouseKeyBuilder( + block: locked, + child: Column(children: [ + network(context), + ]), + ), + ]).marginOnly(bottom: _kListViewBottomMargin); } - server(bool enabled) { - // Simple temp wrapper for PR check - tmpWrapper() { - // Setting page is not modal, oldOptions should only be used when getting options, never when setting. - Map oldOptions = jsonDecode(bind.mainGetOptionsSync()); - old(String key) { - return (oldOptions[key] ?? '').trim(); - } + Widget network(BuildContext context) { + final hideServer = + bind.mainGetBuildinOption(key: kOptionHideServerSetting) == 'Y'; + final hideProxy = + isWeb || bind.mainGetBuildinOption(key: kOptionHideProxySetting) == 'Y'; + final hideWebSocket = isWeb || + bind.mainGetBuildinOption(key: kOptionHideWebSocketSetting) == 'Y'; - RxString idErrMsg = ''.obs; - RxString relayErrMsg = ''.obs; - RxString apiErrMsg = ''.obs; - var idController = - TextEditingController(text: old('custom-rendezvous-server')); - var relayController = TextEditingController(text: old('relay-server')); - var apiController = TextEditingController(text: old('api-server')); - var keyController = TextEditingController(text: old('key')); - final controllers = [ - idController, - relayController, - apiController, - keyController, - ]; - final errMsgs = [ - idErrMsg, - relayErrMsg, - apiErrMsg, - ]; - - submit() async { - bool result = await setServerConfig( - null, - errMsgs, - ServerConfig( - idServer: idController.text, - relayServer: relayController.text, - apiServer: apiController.text, - key: keyController.text)); - if (result) { - setState(() {}); - showToast(translate('Successful')); - } else { - showToast(translate('Failed')); - } - } - - bool secure = !enabled; - return _Card( - title: 'ID/Relay Server', - title_suffix: ServerConfigImportExportWidgets(controllers, errMsgs), - children: [ - Column( - children: [ - Obx(() => _LabeledTextField(context, 'ID Server', idController, - idErrMsg.value, enabled, secure)), - if (!isWeb) - Obx(() => _LabeledTextField(context, 'Relay Server', - relayController, relayErrMsg.value, enabled, secure)), - Obx(() => _LabeledTextField(context, 'API Server', - apiController, apiErrMsg.value, enabled, secure)), - _LabeledTextField( - context, 'Key', keyController, '', enabled, secure), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [_Button('Apply', submit, enabled: enabled)], - ).marginOnly(top: 10), - ], - ) - ]); + if (hideServer && hideProxy && hideWebSocket) { + return Offstage(); } - return tmpWrapper(); + // Helper function to create network setting ListTiles + Widget listTile({ + required IconData icon, + required String title, + VoidCallback? onTap, + Widget? trailing, + bool showTooltip = false, + String tooltipMessage = '', + }) { + final titleWidget = showTooltip + ? Row( + children: [ + Tooltip( + waitDuration: Duration(milliseconds: 1000), + message: translate(tooltipMessage), + child: Row( + children: [ + Text( + translate(title), + style: TextStyle(fontSize: _kContentFontSize), + ), + SizedBox(width: 5), + Icon( + Icons.help_outline, + size: 14, + color: Theme.of(context) + .textTheme + .titleLarge + ?.color + ?.withOpacity(0.7), + ), + ], + ), + ), + ], + ) + : Text( + translate(title), + style: TextStyle(fontSize: _kContentFontSize), + ); + + return ListTile( + leading: Icon(icon, color: _accentColor), + title: titleWidget, + enabled: !locked, + onTap: onTap, + trailing: trailing, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + contentPadding: EdgeInsets.symmetric(horizontal: 16), + minLeadingWidth: 0, + horizontalTitleGap: 10, + ); + } + + Widget switchWidget(IconData icon, String title, String tooltipMessage, + String optionKey) => + listTile( + icon: icon, + title: title, + showTooltip: true, + tooltipMessage: tooltipMessage, + trailing: Switch( + value: mainGetBoolOptionSync(optionKey), + onChanged: locked || isOptionFixed(optionKey) + ? null + : (value) { + mainSetBoolOption(optionKey, value); + setState(() {}); + }, + ), + ); + + final outgoingOnly = bind.isOutgoingOnly(); + + final divider = const Divider(height: 1, indent: 16, endIndent: 16); + return _Card( + title: 'Network', + children: [ + Container( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!hideServer) + listTile( + icon: Icons.dns_outlined, + title: 'ID/Relay Server', + onTap: () => showServerSettings(gFFI.dialogManager, setState), + ), + if (!hideProxy && !hideServer) divider, + if (!hideProxy) + listTile( + icon: Icons.network_ping_outlined, + title: 'Socks5/Http(s) Proxy', + onTap: changeSocks5Proxy, + ), + if (!hideWebSocket && (!hideServer || !hideProxy)) divider, + if (!hideWebSocket) + switchWidget( + Icons.web_asset_outlined, + 'Use WebSocket', + '${translate('websocket_tip')}\n\n${translate('server-oss-not-support-tip')}', + kOptionAllowWebSocket), + if (!isWeb) + futureBuilder( + future: bind.mainIsUsingPublicServer(), + hasData: (isUsingPublicServer) { + if (isUsingPublicServer) { + return Offstage(); + } else { + return Column( + children: [ + if (!hideServer || !hideProxy || !hideWebSocket) + divider, + switchWidget( + Icons.no_encryption_outlined, + 'Allow insecure TLS fallback', + 'allow-insecure-tls-fallback-tip', + kOptionAllowInsecureTLSFallback), + if (!outgoingOnly) divider, + if (!outgoingOnly) + listTile( + icon: Icons.lan_outlined, + title: 'Disable UDP', + showTooltip: true, + tooltipMessage: + '${translate('disable-udp-tip')}\n\n${translate('server-oss-not-support-tip')}', + trailing: Switch( + value: bind.mainGetOptionSync( + key: kOptionDisableUdp) == + 'Y', + onChanged: + locked || isOptionFixed(kOptionDisableUdp) + ? null + : (value) async { + await bind.mainSetOption( + key: kOptionDisableUdp, + value: value ? 'Y' : 'N'); + setState(() {}); + }, + ), + ), + ], + ); + } + }, + ), + ], + ), + ), + ], + ); } } @@ -1492,19 +1754,15 @@ class _DisplayState extends State<_Display> { @override Widget build(BuildContext context) { final scrollController = ScrollController(); - return DesktopScrollWrapper( - scrollController: scrollController, - child: ListView( - controller: scrollController, - physics: DraggableNeverScrollableScrollPhysics(), - children: [ - viewStyle(context), - scrollStyle(context), - imageQuality(context), - codec(context), - if (!isWeb) privacyModeImpl(context), - other(context), - ]).marginOnly(bottom: _kListViewBottomMargin)); + return ListView(controller: scrollController, children: [ + viewStyle(context), + scrollStyle(context), + imageQuality(context), + codec(context), + if (isDesktop) trackpadSpeed(context), + if (!isWeb) privacyModeImpl(context), + other(context), + ]).marginOnly(bottom: _kListViewBottomMargin); } Widget viewStyle(BuildContext context) { @@ -1538,6 +1796,13 @@ class _DisplayState extends State<_Display> { } final groupValue = bind.mainGetUserDefaultOption(key: kOptionScrollStyle); + + onEdgeScrollEdgeThicknessChanged(double value) async { + await bind.mainSetUserDefaultOption( + key: kOptionEdgeScrollEdgeThickness, value: value.round().toString()); + setState(() {}); + } + return _Card(title: 'Default Scroll Style', children: [ _Radio(context, value: kRemoteScrollStyleAuto, @@ -1549,6 +1814,23 @@ class _DisplayState extends State<_Display> { groupValue: groupValue, label: 'Scrollbar', onChanged: isOptFixed ? null : onChanged), + if (!isWeb) ...[ + _Radio(context, + value: kRemoteScrollStyleEdge, + groupValue: groupValue, + label: 'ScrollEdge', + onChanged: isOptFixed ? null : onChanged), + Offstage( + offstage: groupValue != kRemoteScrollStyleEdge, + child: EdgeThicknessControl( + value: double.tryParse(bind.mainGetUserDefaultOption( + key: kOptionEdgeScrollEdgeThickness)) ?? + 100.0, + onChanged: isOptionFixed(kOptionEdgeScrollEdgeThickness) + ? null + : onEdgeScrollEdgeThicknessChanged, + )), + ], ]); } @@ -1589,6 +1871,26 @@ class _DisplayState extends State<_Display> { ]); } + Widget trackpadSpeed(BuildContext context) { + final initSpeed = + (int.tryParse(bind.mainGetUserDefaultOption(key: kKeyTrackpadSpeed)) ?? + kDefaultTrackpadSpeed); + final curSpeed = SimpleWrapper(initSpeed); + void onDebouncer(int v) { + bind.mainSetUserDefaultOption( + key: kKeyTrackpadSpeed, value: v.toString()); + // It's better to notify all sessions that the default speed is changed. + // But it may also be ok to take effect in the next connection. + } + + return _Card(title: 'Default trackpad speed', children: [ + TrackpadSpeedWidget( + value: curSpeed, + onDebouncer: onDebouncer, + ), + ]); + } + Widget codec(BuildContext context) { onChanged(String value) async { await bind.mainSetUserDefaultOption( @@ -1727,20 +2029,19 @@ class _AccountState extends State<_Account> { @override Widget build(BuildContext context) { final scrollController = ScrollController(); - return DesktopScrollWrapper( - scrollController: scrollController, - child: ListView( - physics: DraggableNeverScrollableScrollPhysics(), - controller: scrollController, - children: [ - _Card(title: 'Account', children: [accountAction(), useInfo()]), - ], - ).marginOnly(bottom: _kListViewBottomMargin)); + return ListView( + controller: scrollController, + children: [ + _Card(title: 'Account', children: [accountAction(), useInfo()]), + ], + ).marginOnly(bottom: _kListViewBottomMargin); } Widget accountAction() { return Obx(() => _Button( - gFFI.userModel.userName.value.isEmpty ? 'Login' : 'Logout', + gFFI.userModel.userName.value.isEmpty + ? 'Login' + : '${translate('Logout')} (${gFFI.userModel.accountLabelWithHandle})', () => { gFFI.userModel.userName.value.isEmpty ? loginDialog() @@ -1749,24 +2050,65 @@ class _AccountState extends State<_Account> { } Widget useInfo() { - text(String key, String value) { - return Align( - alignment: Alignment.centerLeft, - child: SelectionArea(child: Text('${translate(key)}: $value')) - .marginSymmetric(vertical: 4), - ); - } - return Obx(() => Offstage( offstage: gFFI.userModel.userName.value.isEmpty, - child: Column( - children: [ - text('Username', gFFI.userModel.userName.value), - // text('Group', gFFI.groupModel.groupName.value), - ], + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(10), + ), + child: Builder(builder: (context) { + final avatarWidget = _buildUserAvatar(); + return Row( + children: [ + if (avatarWidget != null) avatarWidget, + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + gFFI.userModel.displayNameOrUserName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + SelectionArea( + child: Text( + '@${gFFI.userModel.userName.value}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13, + color: + Theme.of(context).textTheme.bodySmall?.color, + ), + ), + ), + ], + ), + ), + ], + ); + }), ), )).marginOnly(left: 18, top: 16); } + + Widget? _buildUserAvatar() { + // Resolve relative avatar path at display time + final avatar = + bind.mainResolveAvatarUrl(avatar: gFFI.userModel.avatar.value); + return buildAvatarWidget( + avatar: avatar, + size: 44, + ); + } } class _Checkbox extends StatefulWidget { @@ -1832,18 +2174,14 @@ class _PluginState extends State<_Plugin> { Widget build(BuildContext context) { bind.pluginListReload(); final scrollController = ScrollController(); - return DesktopScrollWrapper( - scrollController: scrollController, - child: ChangeNotifierProvider.value( - value: pluginManager, - child: Consumer(builder: (context, model, child) { - return ListView( - physics: DraggableNeverScrollableScrollPhysics(), - controller: scrollController, - children: model.plugins.map((entry) => pluginCard(entry)).toList(), - ).marginOnly(bottom: _kListViewBottomMargin); - }), - ), + 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); + }), ); } @@ -1858,7 +2196,9 @@ class _PluginState extends State<_Plugin> { Widget accountAction() { return Obx(() => _Button( - gFFI.userModel.userName.value.isEmpty ? 'Login' : 'Logout', + gFFI.userModel.userName.value.isEmpty + ? 'Login' + : '${translate('Logout')} (${gFFI.userModel.accountLabelWithHandle})', () => { gFFI.userModel.userName.value.isEmpty ? loginDialog() @@ -1867,6 +2207,153 @@ class _PluginState extends State<_Plugin> { } } +class _Printer extends StatefulWidget { + const _Printer({super.key}); + + @override + State<_Printer> createState() => __PrinterState(); +} + +class __PrinterState extends State<_Printer> { + @override + Widget build(BuildContext context) { + final scrollController = ScrollController(); + return ListView(controller: scrollController, children: [ + outgoing(context), + incoming(context), + ]).marginOnly(bottom: _kListViewBottomMargin); + } + + Widget outgoing(BuildContext context) { + final isSupportPrinterDriver = + bind.mainGetCommonSync(key: 'is-support-printer-driver') == 'true'; + + Widget tipOsNotSupported() { + return Align( + alignment: Alignment.topLeft, + child: Text(translate('printer-os-requirement-tip')), + ).marginOnly(left: _kCardLeftMargin); + } + + Widget tipClientNotInstalled() { + return Align( + alignment: Alignment.topLeft, + child: + Text(translate('printer-requires-installed-{$appName}-client-tip')), + ).marginOnly(left: _kCardLeftMargin); + } + + Widget tipPrinterNotInstalled() { + final failedMsg = ''.obs; + platformFFI.registerEventHandler( + 'install-printer-res', 'install-printer-res', (evt) async { + if (evt['success'] as bool) { + setState(() {}); + } else { + failedMsg.value = evt['msg'] as String; + } + }, replace: true); + return Column(children: [ + Obx( + () => failedMsg.value.isNotEmpty + ? Offstage() + : Align( + alignment: Alignment.topLeft, + child: Text(translate('printer-{$appName}-not-installed-tip')) + .marginOnly(bottom: 10.0), + ), + ), + Obx( + () => failedMsg.value.isEmpty + ? Offstage() + : Align( + alignment: Alignment.topLeft, + child: Text(failedMsg.value, + style: DefaultTextStyle.of(context) + .style + .copyWith(color: Colors.red)) + .marginOnly(bottom: 10.0)), + ), + _Button('Install {$appName} Printer', () { + failedMsg.value = ''; + bind.mainSetCommon(key: 'install-printer', value: ''); + }) + ]).marginOnly(left: _kCardLeftMargin, bottom: 2.0); + } + + Widget tipReady() { + return Align( + alignment: Alignment.topLeft, + child: Text(translate('printer-{$appName}-ready-tip')), + ).marginOnly(left: _kCardLeftMargin); + } + + final installed = bind.mainIsInstalled(); + // `is-printer-installed` may fail, but it's rare case. + // Add additional error message here if it's really needed. + final isPrinterInstalled = + bind.mainGetCommonSync(key: 'is-printer-installed') == 'true'; + + final List children = []; + if (!isSupportPrinterDriver) { + children.add(tipOsNotSupported()); + } else { + children.addAll([ + if (!installed) tipClientNotInstalled(), + if (installed && !isPrinterInstalled) tipPrinterNotInstalled(), + if (installed && isPrinterInstalled) tipReady() + ]); + } + return _Card(title: 'Outgoing Print Jobs', children: children); + } + + Widget incoming(BuildContext context) { + onRadioChanged(String value) async { + await bind.mainSetLocalOption( + key: kKeyPrinterIncomingJobAction, value: value); + setState(() {}); + } + + PrinterOptions printerOptions = PrinterOptions.load(); + return _Card(title: 'Incoming Print Jobs', children: [ + _Radio(context, + value: kValuePrinterIncomingJobDismiss, + groupValue: printerOptions.action, + label: 'Dismiss', + onChanged: onRadioChanged), + _Radio(context, + value: kValuePrinterIncomingJobDefault, + groupValue: printerOptions.action, + label: 'use-the-default-printer-tip', + onChanged: onRadioChanged), + _Radio(context, + value: kValuePrinterIncomingJobSelected, + groupValue: printerOptions.action, + label: 'use-the-selected-printer-tip', + onChanged: onRadioChanged), + if (printerOptions.printerNames.isNotEmpty) + ComboBox( + initialKey: printerOptions.printerName, + keys: printerOptions.printerNames, + values: printerOptions.printerNames, + enabled: printerOptions.action == kValuePrinterIncomingJobSelected, + onChanged: (value) async { + await bind.mainSetLocalOption( + key: kKeyPrinterSelected, value: value); + setState(() {}); + }, + ).marginOnly(left: 10), + _OptionCheckBox( + context, + 'auto-print-tip', + kKeyPrinterAllowAutoPrint, + isServer: false, + enabled: printerOptions.action != kValuePrinterIncomingJobDismiss, + ) + ]); + } +} + class _About extends StatefulWidget { const _About({Key? key}) : super(key: key); @@ -1895,75 +2382,72 @@ class _AboutState extends State<_About> { final fingerprint = data['fingerprint'].toString(); const linkStyle = TextStyle(decoration: TextDecoration.underline); final scrollController = ScrollController(); - return DesktopScrollWrapper( - scrollController: scrollController, - child: SingleChildScrollView( - controller: scrollController, - physics: DraggableNeverScrollableScrollPhysics(), - child: _Card(title: translate('About RustDesk'), children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox( - height: 8.0, - ), - SelectionArea( - child: Text('${translate('Version')}: $version') - .marginSymmetric(vertical: 4.0)), - SelectionArea( - child: Text('${translate('Build Date')}: $buildDate') - .marginSymmetric(vertical: 4.0)), - if (!isWeb) - SelectionArea( - child: Text('${translate('Fingerprint')}: $fingerprint') - .marginSymmetric(vertical: 4.0)), - InkWell( - onTap: () { - launchUrlString('https://rustdesk.com/privacy.html'); - }, - child: Text( - translate('Privacy Statement'), - style: linkStyle, - ).marginSymmetric(vertical: 4.0)), - InkWell( - onTap: () { - launchUrlString('https://rustdesk.com'); - }, - child: Text( - translate('Website'), - style: linkStyle, - ).marginSymmetric(vertical: 4.0)), - Container( - decoration: const BoxDecoration(color: Color(0xFF2c8cff)), - padding: - const EdgeInsets.symmetric(vertical: 24, horizontal: 8), - child: SelectionArea( - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Copyright © ${DateTime.now().toString().substring(0, 4)} Purslane Ltd.\n$license', - style: const TextStyle(color: Colors.white), - ), - Text( - translate('Slogan_tip'), - style: TextStyle( - fontWeight: FontWeight.w800, - color: Colors.white), - ) - ], + return SingleChildScrollView( + controller: scrollController, + child: _Card(title: translate('About RustDesk'), children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox( + height: 8.0, + ), + SelectionArea( + child: Text('${translate('Version')}: $version') + .marginSymmetric(vertical: 4.0)), + SelectionArea( + child: Text('${translate('Build Date')}: $buildDate') + .marginSymmetric(vertical: 4.0)), + if (!isWeb) + SelectionArea( + child: Text('${translate('Fingerprint')}: $fingerprint') + .marginSymmetric(vertical: 4.0)), + InkWell( + onTap: () { + launchUrlString('https://rustdesk.com/privacy.html'); + }, + child: Text( + translate('Privacy Statement'), + style: linkStyle, + ).marginSymmetric(vertical: 4.0)), + InkWell( + onTap: () { + launchUrlString('https://rustdesk.com'); + }, + child: Text( + translate('Website'), + style: linkStyle, + ).marginSymmetric(vertical: 4.0)), + Container( + decoration: const BoxDecoration(color: Color(0xFF2c8cff)), + padding: + const EdgeInsets.symmetric(vertical: 24, horizontal: 8), + child: SelectionArea( + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Copyright © ${DateTime.now().toString().substring(0, 4)} Purslane Ltd.\n$license', + style: const TextStyle(color: Colors.white), ), - ), - ], - )), - ).marginSymmetric(vertical: 4.0) - ], - ).marginOnly(left: _kContentHMargin) - ]), - )); + Text( + translate('Slogan_tip'), + style: TextStyle( + fontWeight: FontWeight.w800, + color: Colors.white), + ) + ], + ), + ), + ], + )), + ).marginSymmetric(vertical: 4.0) + ], + ).marginOnly(left: _kContentHMargin) + ]), + ); }); } } @@ -2122,6 +2606,49 @@ class WaylandCard extends StatefulWidget { class _WaylandCardState extends State { final restoreTokenKey = 'wayland-restore-token'; + static const _kClearShortcutsInhibitorEventKey = + 'clear-gnome-shortcuts-inhibitor-permission-res'; + final _clearShortcutsInhibitorFailedMsg = ''.obs; + // Don't show the shortcuts permission reset button for now. + // Users can change it manually: + // "Settings" -> "Apps" -> "RustDesk" -> "Permissions" -> "Inhibit Shortcuts". + // For resetting(clearing) the permission from the portal permission store, you can + // use (replace with the RustDesk desktop file ID): + // busctl --user call org.freedesktop.impl.portal.PermissionStore \ + // /org/freedesktop/impl/portal/PermissionStore org.freedesktop.impl.portal.PermissionStore \ + // DeletePermission sss "gnome" "shortcuts-inhibitor" "" + // On a native install this is typically "rustdesk.desktop"; on Flatpak it is usually + // the exported desktop ID derived from the Flatpak app-id (e.g. "com.rustdesk.RustDesk.desktop"). + // + // We may add it back in the future if needed. + final showResetInhibitorPermission = false; + + @override + void initState() { + super.initState(); + if (showResetInhibitorPermission) { + platformFFI.registerEventHandler( + _kClearShortcutsInhibitorEventKey, _kClearShortcutsInhibitorEventKey, + (evt) async { + if (!mounted) return; + if (evt['success'] == true) { + setState(() {}); + } else { + _clearShortcutsInhibitorFailedMsg.value = + evt['msg'] as String? ?? 'Unknown error'; + } + }); + } + } + + @override + void dispose() { + if (showResetInhibitorPermission) { + platformFFI.unregisterEventHandler( + _kClearShortcutsInhibitorEventKey, _kClearShortcutsInhibitorEventKey); + } + super.dispose(); + } @override Widget build(BuildContext context) { @@ -2129,9 +2656,16 @@ class _WaylandCardState extends State { future: bind.mainHandleWaylandScreencastRestoreToken( key: restoreTokenKey, value: "get"), hasData: (restoreToken) { + final hasShortcutsPermission = showResetInhibitorPermission && + bind.mainGetCommonSync( + key: "has-gnome-shortcuts-inhibitor-permission") == + "true"; + final children = [ if (restoreToken.isNotEmpty) _buildClearScreenSelection(context, restoreToken), + if (hasShortcutsPermission) + _buildClearShortcutsInhibitorPermission(context), ]; return Offstage( offstage: children.isEmpty, @@ -2176,6 +2710,50 @@ class _WaylandCardState extends State { ), ); } + + Widget _buildClearShortcutsInhibitorPermission(BuildContext context) { + onConfirm() { + _clearShortcutsInhibitorFailedMsg.value = ''; + bind.mainSetCommon( + key: "clear-gnome-shortcuts-inhibitor-permission", value: ""); + gFFI.dialogManager.dismissAll(); + } + + showConfirmMsgBox() => msgBoxCommon( + gFFI.dialogManager, + 'Confirmation', + Text( + translate('confirm-clear-shortcuts-inhibitor-permission-tip'), + ), + [ + dialogButton('OK', onPressed: onConfirm), + dialogButton('Cancel', + onPressed: () => gFFI.dialogManager.dismissAll()) + ]); + + return Column(children: [ + Obx( + () => _clearShortcutsInhibitorFailedMsg.value.isEmpty + ? Offstage() + : Align( + alignment: Alignment.topLeft, + child: Text(_clearShortcutsInhibitorFailedMsg.value, + style: DefaultTextStyle.of(context) + .style + .copyWith(color: Colors.red)) + .marginOnly(bottom: 10.0)), + ), + _Button( + 'Reset keyboard shortcuts permission', + showConfirmMsgBox, + tip: 'clear-shortcuts-inhibitor-permission-tip', + style: ButtonStyle( + backgroundColor: MaterialStateProperty.all( + Theme.of(context).colorScheme.error.withOpacity(0.75)), + ), + ), + ]); + } } // ignore: non_constant_identifier_names @@ -2257,7 +2835,7 @@ Widget _lock( ]).marginSymmetric(vertical: 2)), onPressed: () async { final unlockPin = bind.mainGetUnlockPin(); - if (unlockPin.isEmpty) { + if (unlockPin.isEmpty || isUnlockPinDisabled()) { bool checked = await callMainCheckSuperUserPermission(); if (checked) { onUnlock(); @@ -2281,26 +2859,39 @@ _LabeledTextField( String errorText, bool enabled, bool secure) { - return Row( + return Table( + columnWidths: const { + 0: FixedColumnWidth(150), + 1: FlexColumnWidth(), + }, + defaultVerticalAlignment: TableCellVerticalAlignment.middle, children: [ - ConstrainedBox( - constraints: const BoxConstraints(minWidth: 140), - child: Text( - '${translate(label)}:', - textAlign: TextAlign.right, - style: TextStyle( - fontSize: 16, color: disabledTextColor(context, enabled)), - ).marginOnly(right: 10)), - Expanded( - child: TextField( + TableRow( + children: [ + Padding( + padding: const EdgeInsets.only(right: 10), + child: Text( + '${translate(label)}:', + textAlign: TextAlign.right, + style: TextStyle( + fontSize: 16, + color: disabledTextColor(context, enabled), + ), + ), + ), + TextField( controller: controller, enabled: enabled, obscureText: secure, + autocorrect: false, decoration: InputDecoration( - errorText: errorText.isNotEmpty ? errorText : null), + errorText: errorText.isNotEmpty ? errorText : null, + ), style: TextStyle( color: disabledTextColor(context, enabled), - )), + ), + ).workaroundFreezeLinuxMint(), + ], ), ], ).marginOnly(bottom: 8); @@ -2478,7 +3069,7 @@ void changeSocks5Proxy() async { controller: proxyController, autofocus: true, enabled: !isOptFixed, - ), + ).workaroundFreezeLinuxMint(), ), ], ).marginOnly(bottom: 8), @@ -2498,7 +3089,7 @@ void changeSocks5Proxy() async { labelText: isMobile ? translate('Username') : null, ), enabled: !isOptFixed, - ), + ).workaroundFreezeLinuxMint(), ), ], ).marginOnly(bottom: 8), @@ -2524,7 +3115,7 @@ void changeSocks5Proxy() async { controller: pwdController, enabled: !isOptFixed, maxLength: bind.mainMaxEncryptLen(), - )), + ).workaroundFreezeLinuxMint()), ), ], ), diff --git a/flutter/lib/desktop/pages/desktop_tab_page.dart b/flutter/lib/desktop/pages/desktop_tab_page.dart index 7319f7a3c..6440e55a1 100644 --- a/flutter/lib/desktop/pages/desktop_tab_page.dart +++ b/flutter/lib/desktop/pages/desktop_tab_page.dart @@ -37,13 +37,9 @@ class DesktopTabPage extends StatefulWidget { } } -class _DesktopTabPageState extends State - with WidgetsBindingObserver { +class _DesktopTabPageState extends State { final tabController = DesktopTabController(tabType: DesktopTabType.main); - final RxBool _block = false.obs; - // bool mouseIn = false; - _DesktopTabPageState() { RemoteCountState.init(); Get.put(tabController); @@ -69,19 +65,10 @@ class _DesktopTabPageState extends State } } - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - super.didChangeAppLifecycleState(state); - if (state == AppLifecycleState.resumed) { - shouldBeBlocked(_block, canBeBlocked); - } else if (state == AppLifecycleState.inactive) {} - } - @override void initState() { super.initState(); // HardwareKeyboard.instance.addHandler(_handleKeyEvent); - WidgetsBinding.instance.addObserver(this); } /* @@ -97,7 +84,6 @@ class _DesktopTabPageState extends State @override void dispose() { // HardwareKeyboard.instance.removeHandler(_handleKeyEvent); - WidgetsBinding.instance.removeObserver(this); Get.delete(); super.dispose(); @@ -119,7 +105,6 @@ class _DesktopTabPageState extends State isClose: false, ), ), - blockTab: _block, ))); return isMacOS || kUseCompatibleUiMode ? tabWidget diff --git a/flutter/lib/desktop/pages/file_manager_page.dart b/flutter/lib/desktop/pages/file_manager_page.dart index 90b8d7dcb..cf97351b3 100644 --- a/flutter/lib/desktop/pages/file_manager_page.dart +++ b/flutter/lib/desktop/pages/file_manager_page.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'dart:math'; import 'package:extended_text/extended_text.dart'; +import 'package:flutter_hbb/common/widgets/dialog.dart'; import 'package:flutter_hbb/desktop/widgets/dragable_divider.dart'; import 'package:percent_indicator/percent_indicator.dart'; import 'package:desktop_drop/desktop_drop.dart'; @@ -16,7 +17,6 @@ import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart'; import 'package:flutter_hbb/models/file_model.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:get/get.dart'; -import 'package:wakelock_plus/wakelock_plus.dart'; import 'package:flutter_hbb/web/dummy.dart' if (dart.library.html) 'package:flutter_hbb/web/web_unique.dart'; @@ -52,7 +52,7 @@ enum MouseFocusScope { } class FileManagerPage extends StatefulWidget { - const FileManagerPage( + FileManagerPage( {Key? key, required this.id, required this.password, @@ -67,9 +67,16 @@ class FileManagerPage extends StatefulWidget { final bool? forceRelay; final String? connToken; final DesktopTabController? tabController; + final SimpleWrapper?> _lastState = SimpleWrapper(null); + + FFI get ffi => (_lastState.value! as _FileManagerPageState)._ffi; @override - State createState() => _FileManagerPageState(); + State createState() { + final state = _FileManagerPageState(); + _lastState.value = state; + return state; + } } class _FileManagerPageState extends State @@ -78,6 +85,7 @@ class _FileManagerPageState extends State final _dropMaskVisible = false.obs; // TODO impl drop mask final _overlayKeyState = OverlayKeyState(); + final _uniqueKey = UniqueKey(); late FFI _ffi; @@ -99,9 +107,7 @@ class _FileManagerPageState extends State .showLoading(translate('Connecting...'), onCancel: closeConnection); }); Get.put(_ffi, tag: 'ft_${widget.id}'); - if (!isLinux) { - WakelockPlus.enable(); - } + WakelockManager.enable(_uniqueKey); if (isWeb) { _ffi.ffiModel.updateEventListener(_ffi.sessionId, widget.id); } @@ -119,9 +125,7 @@ class _FileManagerPageState extends State model.close().whenComplete(() { _ffi.close(); _ffi.dialogManager.dismissAll(); - if (!isLinux) { - WakelockPlus.disable(); - } + WakelockManager.disable(_uniqueKey); Get.delete(tag: 'ft_${widget.id}'); }); WidgetsBinding.instance.removeObserver(this); @@ -139,12 +143,26 @@ class _FileManagerPageState extends State } } + Widget willPopScope(Widget child) { + if (isWeb) { + return WillPopScope( + onWillPop: () async { + clientClose(_ffi.sessionId, _ffi); + return false; + }, + child: child, + ); + } else { + return child; + } + } + @override Widget build(BuildContext context) { super.build(context); return Overlay(key: _overlayKeyState.key, initialEntries: [ OverlayEntry(builder: (_) { - return Scaffold( + return willPopScope(Scaffold( backgroundColor: Theme.of(context).scaffoldBackgroundColor, body: Row( children: [ @@ -160,7 +178,7 @@ class _FileManagerPageState extends State Flexible(flex: 2, child: statusList()) ], ), - ); + )); }) ]); } @@ -260,11 +278,9 @@ class _FileManagerPageState extends State item.state != JobState.inProgress, child: LinearPercentIndicator( animateFromLastPercent: true, - center: Text( - '${(item.finishedSize / item.totalSize * 100).toStringAsFixed(0)}%', - ), + center: Text(item.percentText), barRadius: Radius.circular(15), - percent: item.finishedSize / item.totalSize, + percent: item.percent, progressColor: MyTheme.accent, backgroundColor: Theme.of(context).hoverColor, lineHeight: kDesktopFileTransferRowHeight, @@ -768,7 +784,7 @@ class _FileManagerViewState extends State { ), controller: name, autofocus: true, - ), + ).workaroundFreezeLinuxMint(), ], ), actions: [ @@ -1657,7 +1673,7 @@ class _FileManagerViewState extends State { onChanged: _locationStatus.value == LocationStatus.fileSearchBar ? (searchText) => onSearchText(searchText, isLocal) : null, - ), + ).workaroundFreezeLinuxMint(), ) ], ); diff --git a/flutter/lib/desktop/pages/file_manager_tab_page.dart b/flutter/lib/desktop/pages/file_manager_tab_page.dart index cc77cdd95..ed3e9682d 100644 --- a/flutter/lib/desktop/pages/file_manager_tab_page.dart +++ b/flutter/lib/desktop/pages/file_manager_tab_page.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:desktop_multi_window/desktop_multi_window.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hbb/common.dart'; +import 'package:flutter_hbb/common/widgets/dialog.dart'; import 'package:flutter_hbb/consts.dart'; import 'package:flutter_hbb/models/state_model.dart'; import 'package:flutter_hbb/desktop/pages/file_manager_page.dart'; @@ -40,7 +41,15 @@ class _FileManagerTabPageState extends State { label: params['id'], selectedIcon: selectedIcon, unselectedIcon: unselectedIcon, - onTabCloseButton: () => tabController.closeBy(params['id']), + onTabCloseButton: () async { + if (await desktopTryShowTabAuditDialogCloseCancelled( + id: params['id'], + tabController: tabController, + )) { + return; + } + tabController.closeBy(params['id']); + }, page: FileManagerPage( key: ValueKey(params['id']), id: params['id'], @@ -69,7 +78,15 @@ class _FileManagerTabPageState extends State { label: id, selectedIcon: selectedIcon, unselectedIcon: unselectedIcon, - onTabCloseButton: () => tabController.closeBy(id), + onTabCloseButton: () async { + if (await desktopTryShowTabAuditDialogCloseCancelled( + id: id, + tabController: tabController, + )) { + return; + } + tabController.closeBy(id); + }, page: FileManagerPage( key: ValueKey(id), id: id, @@ -103,11 +120,13 @@ class _FileManagerTabPageState extends State { )); final tabWidget = isLinux ? buildVirtualWindowFrame(context, child) - : Container( - decoration: BoxDecoration( - border: Border.all(color: MyTheme.color(context).border!)), - child: child, - ); + : workaroundWindowBorder( + context, + Container( + decoration: BoxDecoration( + border: Border.all(color: MyTheme.color(context).border!)), + child: child, + )); return isMacOS || kUseCompatibleUiMode ? tabWidget : SubWindowDragToResizeArea( @@ -130,6 +149,14 @@ class _FileManagerTabPageState extends State { Future handleWindowCloseButton() async { final connLength = tabController.state.value.tabs.length; + if (connLength == 1) { + if (await desktopTryShowTabAuditDialogCloseCancelled( + id: tabController.state.value.tabs[0].key, + tabController: tabController, + )) { + return false; + } + } if (connLength <= 1) { tabController.clear(); return true; diff --git a/flutter/lib/desktop/pages/install_page.dart b/flutter/lib/desktop/pages/install_page.dart index 0ff04240b..5bf6bafee 100644 --- a/flutter/lib/desktop/pages/install_page.dart +++ b/flutter/lib/desktop/pages/install_page.dart @@ -65,6 +65,7 @@ class _InstallPageBodyState extends State<_InstallPageBody> late final TextEditingController controller; final RxBool startmenu = true.obs; final RxBool desktopicon = true.obs; + final RxBool printer = true.obs; final RxBool showProgress = false.obs; final RxBool btnEnabled = true.obs; @@ -79,6 +80,7 @@ class _InstallPageBodyState extends State<_InstallPageBody> final installOptions = jsonDecode(bind.installInstallOptions()); startmenu.value = installOptions['STARTMENUSHORTCUTS'] != '0'; desktopicon.value = installOptions['DESKTOPSHORTCUTS'] != '0'; + printer.value = installOptions['PRINTER'] != '0'; } @override @@ -147,7 +149,7 @@ class _InstallPageBodyState extends State<_InstallPageBody> decoration: InputDecoration( contentPadding: EdgeInsets.all(0.75 * em), ), - ).marginOnly(right: 10), + ).workaroundFreezeLinuxMint().marginOnly(right: 10), ), Obx( () => OutlinedButton.icon( @@ -161,7 +163,9 @@ class _InstallPageBodyState extends State<_InstallPageBody> ).marginSymmetric(vertical: 2 * em), Option(startmenu, label: 'Create start menu shortcuts') .marginOnly(bottom: 7), - Option(desktopicon, label: 'Create desktop icon'), + Option(desktopicon, label: 'Create desktop icon') + .marginOnly(bottom: 7), + Option(printer, label: 'Install {$appName} Printer'), Container( padding: EdgeInsets.all(12), decoration: BoxDecoration( @@ -253,6 +257,7 @@ class _InstallPageBodyState extends State<_InstallPageBody> String args = ''; if (startmenu.value) args += ' startmenu'; if (desktopicon.value) args += ' desktopicon'; + if (printer.value) args += ' printer'; bind.installInstallMe(options: args, path: controller.text); } diff --git a/flutter/lib/desktop/pages/port_forward_page.dart b/flutter/lib/desktop/pages/port_forward_page.dart index d6d243c50..13dca0eaf 100644 --- a/flutter/lib/desktop/pages/port_forward_page.dart +++ b/flutter/lib/desktop/pages/port_forward_page.dart @@ -25,7 +25,7 @@ class _PortForward { } class PortForwardPage extends StatefulWidget { - const PortForwardPage({ + PortForwardPage({ Key? key, required this.id, required this.password, @@ -42,9 +42,16 @@ class PortForwardPage extends StatefulWidget { final bool? forceRelay; final bool? isSharedPassword; final String? connToken; + final SimpleWrapper?> _lastState = SimpleWrapper(null); + + FFI get ffi => (_lastState.value! as _PortForwardPageState)._ffi; @override - State createState() => _PortForwardPageState(); + State createState() { + final state = _PortForwardPageState(); + _lastState.value = state; + return state; + } } class _PortForwardPageState extends State @@ -238,7 +245,7 @@ class _PortForwardPageState extends State inputFormatters: inputFormatters, decoration: InputDecoration( hintText: hint, - ))), + )).workaroundFreezeLinuxMint()), ); } diff --git a/flutter/lib/desktop/pages/port_forward_tab_page.dart b/flutter/lib/desktop/pages/port_forward_tab_page.dart index f399f7cab..9d366bcb0 100644 --- a/flutter/lib/desktop/pages/port_forward_tab_page.dart +++ b/flutter/lib/desktop/pages/port_forward_tab_page.dart @@ -118,11 +118,13 @@ class _PortForwardTabPageState extends State { backgroundColor: Theme.of(context).colorScheme.background, body: child), ) - : Container( - decoration: BoxDecoration( - border: Border.all(color: MyTheme.color(context).border!)), - child: child, - ); + : workaroundWindowBorder( + context, + Container( + decoration: BoxDecoration( + border: Border.all(color: MyTheme.color(context).border!)), + child: child, + )); return isMacOS || kUseCompatibleUiMode ? tabWidget : Obx( diff --git a/flutter/lib/desktop/pages/remote_page.dart b/flutter/lib/desktop/pages/remote_page.dart index cca2074a2..29e710bbc 100644 --- a/flutter/lib/desktop/pages/remote_page.dart +++ b/flutter/lib/desktop/pages/remote_page.dart @@ -3,10 +3,9 @@ import 'dart:async'; import 'package:desktop_multi_window/desktop_multi_window.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter/scheduler.dart'; import 'package:get/get.dart'; import 'package:provider/provider.dart'; -import 'package:wakelock_plus/wakelock_plus.dart'; -import 'package:flutter_improved_scrolling/flutter_improved_scrolling.dart'; import 'package:flutter_hbb/models/state_model.dart'; import '../../consts.dart'; @@ -16,6 +15,7 @@ import '../../common.dart'; import '../../common/widgets/dialog.dart'; import '../../common/widgets/toolbar.dart'; import '../../models/model.dart'; +import '../../models/input_model.dart'; import '../../models/platform_model.dart'; import '../../common/shared_state.dart'; import '../../utils/image.dart'; @@ -73,7 +73,10 @@ class RemotePage extends StatefulWidget { } class _RemotePageState extends State - with AutomaticKeepAliveClientMixin, MultiWindowListener { + with + AutomaticKeepAliveClientMixin, + MultiWindowListener, + TickerProviderStateMixin { Timer? _timer; String keyboardMode = "legacy"; bool _isWindowBlur = false; @@ -82,11 +85,16 @@ class _RemotePageState extends State late RxBool _zoomCursor; late RxBool _remoteCursorMoved; late RxBool _keyboardEnabled; + final _uniqueKey = UniqueKey(); var _blockableOverlayState = BlockableOverlayState(); final FocusNode _rawKeyFocusNode = FocusNode(debugLabel: "rawkeyFocusNode"); + // Debounce timer for pointer lock center updates during window events. + // Uses kDefaultPointerLockCenterThrottleMs from consts.dart for the duration. + Timer? _pointerLockCenterDebounceTimer; + // We need `_instanceIdOnEnterOrLeaveImage4Toolbar` together with `_onEnterOrLeaveImage4Toolbar` // to identify the toolbar instance and its callback function. int? _instanceIdOnEnterOrLeaveImage4Toolbar; @@ -113,11 +121,13 @@ class _RemotePageState extends State _ffi = FFI(widget.sessionId); Get.put(_ffi, tag: widget.id); _ffi.imageModel.addCallbackOnFirstImage((String peerId) { + _ffi.canvasModel.activateLocalCursor(); showKBLayoutTypeChooserIfNeeded( _ffi.ffiModel.pi.platform, _ffi.dialogManager); _ffi.recordingModel .updateStatus(bind.sessionGetIsRecording(sessionId: _ffi.sessionId)); }); + _ffi.canvasModel.initializeEdgeScrollFallback(this); _ffi.start( widget.id, password: widget.password, @@ -133,9 +143,7 @@ class _RemotePageState extends State _ffi.dialogManager .showLoading(translate('Connecting...'), onCancel: closeConnection); }); - if (!isLinux) { - WakelockPlus.enable(); - } + WakelockManager.enable(_uniqueKey); _ffi.ffiModel.updateEventListener(sessionId, widget.id); if (!isWeb) bind.pluginSyncUi(syncTo: kAppTypeDesktopRemote); @@ -166,6 +174,16 @@ class _RemotePageState extends State WidgetsBinding.instance.addPostFrameCallback((_) { widget.tabController?.onSelected?.call(widget.id); }); + + // Register callback to cancel debounce timer when relative mouse mode is disabled + _ffi.inputModel.onRelativeMouseModeDisabled = + _cancelPointerLockCenterDebounceTimer; + } + + /// Cancel the pointer lock center debounce timer + void _cancelPointerLockCenterDebounceTimer() { + _pointerLockCenterDebounceTimer?.cancel(); + _pointerLockCenterDebounceTimer = null; } @override @@ -181,6 +199,13 @@ class _RemotePageState extends State _rawKeyFocusNode.unfocus(); } stateGlobal.isFocused.value = false; + + // When window loses focus, temporarily release relative mouse mode constraints + // to allow user to interact with other applications normally. + // The cursor will be re-hidden and re-centered when window regains focus. + if (_ffi.inputModel.relativeMouseMode.value) { + _ffi.inputModel.onWindowBlur(); + } } @override @@ -191,6 +216,12 @@ class _RemotePageState extends State _isWindowBlur = false; } stateGlobal.isFocused.value = true; + + // Restore relative mouse mode constraints when window regains focus. + if (_ffi.inputModel.relativeMouseMode.value) { + _rawKeyFocusNode.requestFocus(); + _ffi.inputModel.onWindowFocus(); + } } @override @@ -201,25 +232,59 @@ class _RemotePageState extends State if (isWindows) { _isWindowBlur = false; } - if (!isLinux) { - WakelockPlus.enable(); - } + WakelockManager.enable(_uniqueKey); + // Update pointer lock center when window is restored + _updatePointerLockCenterIfNeeded(); } // When the window is unminimized, onWindowMaximize or onWindowRestore can be called when the old state was maximized or not. @override void onWindowMaximize() { super.onWindowMaximize(); - if (!isLinux) { - WakelockPlus.enable(); - } + WakelockManager.enable(_uniqueKey); + // Update pointer lock center when window is maximized + _updatePointerLockCenterIfNeeded(); + } + + @override + void onWindowResize() { + super.onWindowResize(); + // Update pointer lock center when window is resized + _updatePointerLockCenterIfNeeded(); + } + + @override + void onWindowMove() { + super.onWindowMove(); + // Update pointer lock center when window is moved + _updatePointerLockCenterIfNeeded(); + } + + /// Update pointer lock center with debouncing to avoid excessive updates + /// during rapid window move/resize events. + void _updatePointerLockCenterIfNeeded() { + if (!_ffi.inputModel.relativeMouseMode.value) return; + + // Cancel any pending update and schedule a new one (debounce pattern) + _pointerLockCenterDebounceTimer?.cancel(); + _pointerLockCenterDebounceTimer = Timer( + const Duration(milliseconds: kDefaultPointerLockCenterThrottleMs), + () { + if (!mounted) return; + if (_ffi.inputModel.relativeMouseMode.value) { + _ffi.inputModel.updatePointerLockCenter(); + } + }, + ); } @override void onWindowMinimize() { super.onWindowMinimize(); - if (!isLinux) { - WakelockPlus.disable(); + WakelockManager.disable(_uniqueKey); + // Release cursor constraints when minimized + if (_ffi.inputModel.relativeMouseMode.value) { + _ffi.inputModel.onWindowBlur(); } } @@ -246,6 +311,16 @@ class _RemotePageState extends State // https://github.com/flutter/flutter/issues/64935 super.dispose(); debugPrint("REMOTE PAGE dispose session $sessionId ${widget.id}"); + + // Defensive cleanup: ensure host system-key propagation is reset even if + // MouseRegion.onExit never fired (e.g., tab closed while cursor inside). + if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: true); + + _pointerLockCenterDebounceTimer?.cancel(); + _pointerLockCenterDebounceTimer = null; + // Clear callback reference to prevent memory leaks and stale references + _ffi.inputModel.onRelativeMouseModeDisabled = null; + // Relative mouse mode cleanup is centralized in FFI.close(closeSession: ...). _ffi.textureModel.onRemotePageDispose(closeSession); if (closeSession) { // ensure we leave this session, this is a double check @@ -263,9 +338,7 @@ class _RemotePageState extends State await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: SystemUiOverlay.values); } - if (!isLinux) { - await WakelockPlus.disable(); - } + WakelockManager.disable(_uniqueKey); await Get.delete(tag: widget.id); removeSharedStates(widget.id); } @@ -349,10 +422,15 @@ class _RemotePageState extends State } }(), // Use Overlay to enable rebuild every time on menu button click. - _ffi.ffiModel.pi.isSet.isTrue - ? Overlay( - initialEntries: [OverlayEntry(builder: remoteToolbar)]) - : remoteToolbar(context), + // Hide toolbar when relative mouse mode is active to prevent + // cursor from escaping to toolbar area. + Obx(() => _ffi.inputModel.relativeMouseMode.value + ? const Offstage() + : _ffi.ffiModel.pi.isSet.isTrue + ? Overlay(initialEntries: [ + OverlayEntry(builder: remoteToolbar) + ]) + : remoteToolbar(context)), _ffi.ffiModel.pi.isSet.isFalse ? emptyOverlay() : Offstage(), ], ), @@ -396,7 +474,7 @@ class _RemotePageState extends State super.build(context); return WillPopScope( onWillPop: () async { - clientClose(sessionId, _ffi.dialogManager); + clientClose(sessionId, _ffi); return false; }, child: MultiProvider(providers: [ @@ -409,6 +487,8 @@ class _RemotePageState extends State } void enterView(PointerEnterEvent evt) { + _ffi.canvasModel.rearmEdgeScroll(); + _cursorOverImage.value = true; _firstEnterImage.value = true; if (_onEnterOrLeaveImage4Toolbar != null) { @@ -418,6 +498,7 @@ class _RemotePageState extends State // } } + // See [onWindowBlur]. if (!isWindows) { if (!_rawKeyFocusNode.hasFocus) { @@ -428,6 +509,8 @@ class _RemotePageState extends State } void leaveView(PointerExitEvent evt) { + _ffi.canvasModel.disableEdgeScroll(); + if (_ffi.ffiModel.keyboard) { _ffi.inputModel.tryMoveEdgeOnExit(evt.position); } @@ -441,6 +524,7 @@ class _RemotePageState extends State // } } + // See [onWindowBlur]. if (!isWindows) { _ffi.inputModel.enterOrLeave(false); @@ -488,33 +572,39 @@ class _RemotePageState extends State Widget getBodyForDesktop(BuildContext context) { var paints = [ - MouseRegion(onEnter: (evt) { - if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: false); - }, onExit: (evt) { - if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: true); - }, child: LayoutBuilder(builder: (context, constraints) { - final c = Provider.of(context, listen: false); - Future.delayed(Duration.zero, () => c.updateViewStyle()); - final peerDisplay = CurrentDisplayState.find(widget.id); - return Obx( - () => _ffi.ffiModel.pi.isSet.isFalse - ? Container(color: Colors.transparent) - : Obx(() { - widget.toolbarState.initShow(sessionId); - _ffi.textureModel.updateCurrentDisplay(peerDisplay.value); - return ImagePaint( - id: widget.id, - zoomCursor: _zoomCursor, - cursorOverImage: _cursorOverImage, - keyboardEnabled: _keyboardEnabled, - remoteCursorMoved: _remoteCursorMoved, - listenerBuilder: (child) => _buildRawTouchAndPointerRegion( - child, enterView, leaveView), - ffi: _ffi, - ); - }), - ); - })) + MouseRegion( + onEnter: (evt) { + if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: false); + }, + onExit: (evt) { + if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: true); + }, + child: _ViewStyleUpdater( + canvasModel: _ffi.canvasModel, + inputModel: _ffi.inputModel, + child: Builder(builder: (context) { + final peerDisplay = CurrentDisplayState.find(widget.id); + return Obx( + () => _ffi.ffiModel.pi.isSet.isFalse + ? Container(color: Colors.transparent) + : Obx(() { + _ffi.textureModel.updateCurrentDisplay(peerDisplay.value); + return ImagePaint( + id: widget.id, + zoomCursor: _zoomCursor, + cursorOverImage: _cursorOverImage, + keyboardEnabled: _keyboardEnabled, + remoteCursorMoved: _remoteCursorMoved, + listenerBuilder: (child) => + _buildRawTouchAndPointerRegion( + child, enterView, leaveView), + ffi: _ffi, + ); + }), + ); + }), + ), + ) ]; if (!_ffi.canvasModel.cursorEmbedded) { @@ -543,6 +633,63 @@ class _RemotePageState extends State bool get wantKeepAlive => true; } +/// A widget that tracks the view size and updates CanvasModel.updateViewStyle() +/// and InputModel.updateImageWidgetSize() only when size actually changes. +/// This avoids scheduling post-frame callbacks on every LayoutBuilder rebuild. +class _ViewStyleUpdater extends StatefulWidget { + final CanvasModel canvasModel; + final InputModel inputModel; + final Widget child; + + const _ViewStyleUpdater({ + Key? key, + required this.canvasModel, + required this.inputModel, + required this.child, + }) : super(key: key); + + @override + State<_ViewStyleUpdater> createState() => _ViewStyleUpdaterState(); +} + +class _ViewStyleUpdaterState extends State<_ViewStyleUpdater> { + Size? _lastSize; + bool _callbackScheduled = false; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final maxWidth = constraints.maxWidth; + final maxHeight = constraints.maxHeight; + // Guard against infinite constraints (e.g., unconstrained ancestor). + if (!maxWidth.isFinite || !maxHeight.isFinite) { + return widget.child; + } + final newSize = Size(maxWidth, maxHeight); + if (_lastSize != newSize) { + _lastSize = newSize; + // Schedule the update for after the current frame to avoid setState during build. + // Use _callbackScheduled flag to prevent accumulating multiple callbacks + // when size changes rapidly before any callback executes. + if (!_callbackScheduled) { + _callbackScheduled = true; + SchedulerBinding.instance.addPostFrameCallback((_) { + _callbackScheduled = false; + final currentSize = _lastSize; + if (mounted && currentSize != null) { + widget.canvasModel.updateViewStyle(); + widget.inputModel.updateImageWidgetSize(currentSize); + } + }); + } + } + return widget.child; + }, + ); + } +} + class ImagePaint extends StatefulWidget { final FFI ffi; final String id; @@ -607,26 +754,29 @@ class _ImagePaintState extends State { cursor: cursorOverImage.isTrue ? c.cursorEmbedded ? SystemMouseCursors.none - : keyboardEnabled.isTrue - ? (() { - if (remoteCursorMoved.isTrue) { - _lastRemoteCursorMoved = true; - return SystemMouseCursors.none; - } else { - if (_lastRemoteCursorMoved) { - _lastRemoteCursorMoved = false; - _firstEnterImage.value = true; - } - return _buildCustomCursor( - context, getCursorScale()); - } - }()) - : _buildDisabledCursor(context, getCursorScale()) + // Hide cursor when relative mouse mode is active + : widget.ffi.inputModel.relativeMouseMode.value + ? SystemMouseCursors.none + : keyboardEnabled.isTrue + ? (() { + if (remoteCursorMoved.isTrue) { + _lastRemoteCursorMoved = true; + return SystemMouseCursors.none; + } else { + if (_lastRemoteCursorMoved) { + _lastRemoteCursorMoved = false; + _firstEnterImage.value = true; + } + return _buildCustomCursor( + context, getCursorScale()); + } + }()) + : _buildDisabledCursor(context, getCursorScale()) : MouseCursor.defer, onHover: (evt) {}, child: child); }); - if (c.imageOverflow.isTrue && c.scrollStyle == ScrollStyle.scrollbar) { + if (c.imageOverflow.isTrue && c.scrollStyle != ScrollStyle.scrollauto) { final paintWidth = c.getDisplayWidth() * s; final paintHeight = c.getDisplayHeight() * s; final paintSize = Size(paintWidth, paintHeight); @@ -681,9 +831,20 @@ class _ImagePaintState extends State { Widget _buildScrollAutoNonTextureRender( ImageModel m, CanvasModel c, double s) { + double sizeScale = s; + if (widget.ffi.ffiModel.isPeerLinux) { + final displays = widget.ffi.ffiModel.pi.getCurDisplays(); + if (displays.isNotEmpty) { + sizeScale = s / displays[0].scale; + } + } return CustomPaint( size: Size(c.size.width, c.size.height), - painter: ImagePainter(image: m.image, x: c.x / s, y: c.y / s, scale: s), + painter: ImagePainter( + image: m.image, + x: c.x / sizeScale, + y: c.y / sizeScale, + scale: sizeScale), ); } @@ -696,17 +857,19 @@ class _ImagePaintState extends State { if (rect == null) { return Container(); } + final isPeerLinux = ffiModel.isPeerLinux; final curDisplay = ffiModel.pi.currentDisplay; for (var i = 0; i < displays.length; i++) { final textureId = widget.ffi.textureModel .getTextureId(curDisplay == kAllDisplayValue ? i : curDisplay); if (true) { // both "textureId.value != -1" and "true" seems ok + final sizeScale = isPeerLinux ? s / displays[i].scale : s; children.add(Positioned( left: (displays[i].x - rect.left) * s + offset.dx, top: (displays[i].y - rect.top) * s + offset.dy, - width: displays[i].width * s, - height: displays[i].height * s, + width: displays[i].width * sizeScale, + height: displays[i].height * sizeScale, child: Obx(() => Texture( textureId: textureId.value, filterQuality: @@ -742,12 +905,6 @@ class _ImagePaintState extends State { ScrollController horizontal, ScrollController vertical, ) { - final scrollConfig = CustomMouseWheelScrollConfig( - scrollDuration: kDefaultScrollDuration, - scrollCurve: Curves.linearToEaseOut, - mouseWheelTurnsThrottleTimeMs: - kDefaultMouseWheelThrottleDuration.inMilliseconds, - scrollAmountMultiplier: kDefaultScrollAmountMultiplier); var widget = child; if (layoutSize.width < size.width) { widget = ScrollConfiguration( @@ -793,36 +950,26 @@ class _ImagePaintState extends State { ); } if (layoutSize.width < size.width) { - widget = ImprovedScrolling( - scrollController: horizontal, - enableCustomMouseWheelScrolling: cursorOverImage.isFalse, - customMouseWheelScrollConfig: scrollConfig, - child: RawScrollbar( - thickness: kScrollbarThickness, - thumbColor: Colors.grey, - controller: horizontal, - thumbVisibility: false, - trackVisibility: false, - notificationPredicate: layoutSize.height < size.height - ? (notification) => notification.depth == 1 - : defaultScrollNotificationPredicate, - child: widget, - ), + widget = RawScrollbar( + thickness: kScrollbarThickness, + thumbColor: Colors.grey, + controller: horizontal, + thumbVisibility: false, + trackVisibility: false, + notificationPredicate: layoutSize.height < size.height + ? (notification) => notification.depth == 1 + : defaultScrollNotificationPredicate, + child: widget, ); } if (layoutSize.height < size.height) { - widget = ImprovedScrolling( - scrollController: vertical, - enableCustomMouseWheelScrolling: cursorOverImage.isFalse, - customMouseWheelScrollConfig: scrollConfig, - child: RawScrollbar( - thickness: kScrollbarThickness, - thumbColor: Colors.grey, - controller: vertical, - thumbVisibility: false, - trackVisibility: false, - child: widget, - ), + widget = RawScrollbar( + thickness: kScrollbarThickness, + thumbColor: Colors.grey, + controller: vertical, + thumbVisibility: false, + trackVisibility: false, + child: widget, ); } diff --git a/flutter/lib/desktop/pages/remote_tab_page.dart b/flutter/lib/desktop/pages/remote_tab_page.dart index efd437e1f..ccd5935ce 100644 --- a/flutter/lib/desktop/pages/remote_tab_page.dart +++ b/flutter/lib/desktop/pages/remote_tab_page.dart @@ -80,7 +80,15 @@ class _ConnectionTabPageState extends State { label: peerId!, selectedIcon: selectedIcon, unselectedIcon: unselectedIcon, - onTabCloseButton: () => tabController.closeBy(peerId), + onTabCloseButton: () async { + if (await desktopTryShowTabAuditDialogCloseCancelled( + id: peerId!, + tabController: tabController, + )) { + return; + } + tabController.closeBy(peerId!); + }, page: RemotePage( key: ValueKey(peerId), id: peerId!, @@ -127,7 +135,13 @@ class _ConnectionTabPageState extends State { body: DesktopTab( controller: tabController, onWindowCloseButton: handleWindowCloseButton, - tail: const AddButton(), + tail: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _RelativeMouseModeHint(tabController: tabController), + const AddButton(), + ], + ), selectedBorderColor: MyTheme.accent, pageViewBuilder: (pageView) => pageView, labelGetter: DesktopTab.tablabelGetter, @@ -146,16 +160,8 @@ class _ConnectionTabPageState extends State { connectionType.secure.value == ConnectionType.strSecure; bool direct = connectionType.direct.value == ConnectionType.strDirect; - String msgConn; - if (secure && direct) { - msgConn = translate("Direct and encrypted connection"); - } else if (secure && !direct) { - msgConn = translate("Relayed and encrypted connection"); - } else if (!secure && direct) { - msgConn = translate("Direct and unencrypted connection"); - } else { - msgConn = translate("Relayed and unencrypted connection"); - } + String msgConn = getConnectionText( + secure, direct, connectionType.stream_type.value); var msgFingerprint = '${translate('Fingerprint')}:\n'; var fingerprint = FingerprintState.find(key).value; if (fingerprint.isEmpty) { @@ -212,14 +218,16 @@ class _ConnectionTabPageState extends State { ); final tabWidget = isLinux ? buildVirtualWindowFrame(context, child) - : Obx(() => Container( - decoration: BoxDecoration( - border: Border.all( - color: MyTheme.color(context).border!, - width: stateGlobal.windowBorderWidth.value), - ), - child: child, - )); + : workaroundWindowBorder( + context, + Obx(() => Container( + decoration: BoxDecoration( + border: Border.all( + color: MyTheme.color(context).border!, + width: stateGlobal.windowBorderWidth.value), + ), + child: child, + ))); return isMacOS || kUseCompatibleUiMode ? tabWidget : Obx(() => SubWindowDragToResizeArea( @@ -249,11 +257,11 @@ class _ConnectionTabPageState extends State { MenuEntryButton( childBuilder: (TextStyle? style) => Obx(() => Text( translate( - toolbarState.show.isTrue ? 'Hide Toolbar' : 'Show Toolbar'), + toolbarState.hide.isTrue ? 'Show Toolbar' : 'Hide Toolbar'), style: style, )), proc: () { - toolbarState.switchShow(sessionId); + toolbarState.switchHide(sessionId); cancelFunc(); }, padding: padding, @@ -267,8 +275,10 @@ class _ConnectionTabPageState extends State { style: style, ), proc: () async { - await DesktopMultiWindow.invokeMethod(kMainWindowId, - kWindowEventMoveTabToNewWindow, '${windowId()},$key,$sessionId'); + await DesktopMultiWindow.invokeMethod( + kMainWindowId, + kWindowEventMoveTabToNewWindow, + '${windowId()},$key,$sessionId,RemoteDesktop'); cancelFunc(); }, padding: padding, @@ -320,7 +330,13 @@ class _ConnectionTabPageState extends State { translate('Close'), style: style, ), - proc: () { + proc: () async { + if (await desktopTryShowTabAuditDialogCloseCancelled( + id: key, + tabController: tabController, + )) { + return; + } tabController.closeBy(key); cancelFunc(); }, @@ -364,6 +380,8 @@ class _ConnectionTabPageState extends State { loopCloseWindow(); } ConnectionTypeState.delete(id); + // Clean up relative mouse mode state for this peer. + stateGlobal.relativeMouseModeState.remove(id); _update_remote_count(); } @@ -373,6 +391,14 @@ class _ConnectionTabPageState extends State { Future handleWindowCloseButton() async { final connLength = tabController.length; + if (connLength == 1) { + if (await desktopTryShowTabAuditDialogCloseCancelled( + id: tabController.state.value.tabs[0].key, + tabController: tabController, + )) { + return false; + } + } if (connLength <= 1) { tabController.clear(); return true; @@ -415,8 +441,8 @@ class _ConnectionTabPageState extends State { await WindowController.fromWindowId(windowId()).setFullscreen(false); stateGlobal.setFullscreen(false, procWnd: false); } - await setNewConnectWindowFrame( - windowId(), id!, prePeerCount, display, screenRect); + await setNewConnectWindowFrame(windowId(), id!, prePeerCount, + WindowType.RemoteDesktop, display, screenRect); Future.delayed(Duration(milliseconds: isWindows ? 100 : 0), () async { await windowOnTop(windowId()); }); @@ -427,7 +453,15 @@ class _ConnectionTabPageState extends State { label: id, selectedIcon: selectedIcon, unselectedIcon: unselectedIcon, - onTabCloseButton: () => tabController.closeBy(id), + onTabCloseButton: () async { + if (await desktopTryShowTabAuditDialogCloseCancelled( + id: id, + tabController: tabController, + )) { + return; + } + tabController.closeBy(id); + }, page: RemotePage( key: ValueKey(id), id: id, @@ -522,3 +556,69 @@ class _ConnectionTabPageState extends State { return returnValue; } } + +/// A widget that displays a hint in the tab bar when relative mouse mode is active. +/// This helps users remember how to exit relative mouse mode. +class _RelativeMouseModeHint extends StatelessWidget { + final DesktopTabController tabController; + + const _RelativeMouseModeHint({Key? key, required this.tabController}) + : super(key: key); + + @override + Widget build(BuildContext context) { + return Obx(() { + // Check if there are any tabs + if (tabController.state.value.tabs.isEmpty) { + return const SizedBox.shrink(); + } + + // Get current selected tab's RemotePage + final selectedTabInfo = tabController.state.value.selectedTabInfo; + if (selectedTabInfo.page is! RemotePage) { + return const SizedBox.shrink(); + } + + final remotePage = selectedTabInfo.page as RemotePage; + final String peerId = remotePage.id; + + // Use global state to check relative mouse mode (synced from InputModel). + // This avoids timing issues with FFI registration. + final isRelativeMouseMode = + stateGlobal.relativeMouseModeState[peerId] ?? false; + + if (!isRelativeMouseMode) { + return const SizedBox.shrink(); + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + margin: const EdgeInsets.only(right: 8), + decoration: BoxDecoration( + color: Colors.orange.withOpacity(0.2), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Colors.orange.withOpacity(0.5)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.mouse, + size: 14, + color: Colors.orange[700], + ), + const SizedBox(width: 4), + Text( + translate( + 'rel-mouse-exit-{${isMacOS ? "Cmd+G" : "Ctrl+Alt"}}-tip'), + style: TextStyle( + fontSize: 11, + color: Colors.orange[700], + ), + ), + ], + ), + ); + }); + } +} diff --git a/flutter/lib/desktop/pages/server_page.dart b/flutter/lib/desktop/pages/server_page.dart index 53ba73c97..8bd7df08b 100644 --- a/flutter/lib/desktop/pages/server_page.dart +++ b/flutter/lib/desktop/pages/server_page.dart @@ -88,12 +88,14 @@ class _DesktopServerPageState extends State ); return isLinux ? buildVirtualWindowFrame(context, body) - : Container( - decoration: BoxDecoration( - border: - Border.all(color: MyTheme.color(context).border!)), - child: body, - ); + : workaroundWindowBorder( + context, + Container( + decoration: BoxDecoration( + border: + Border.all(color: MyTheme.color(context).border!)), + child: body, + )); }, ), ); @@ -110,7 +112,8 @@ class ConnectionManager extends StatefulWidget { class ConnectionManagerState extends State with WidgetsBindingObserver { - final RxBool _block = false.obs; + final RxBool _controlPageBlock = false.obs; + final RxBool _sidePageBlock = false.obs; ConnectionManagerState() { gFFI.serverModel.tabController.onSelected = (client_id_str) { @@ -139,7 +142,8 @@ class ConnectionManagerState extends State super.didChangeAppLifecycleState(state); if (state == AppLifecycleState.resumed) { if (!allowRemoteCMModification()) { - shouldBeBlocked(_block, null); + shouldBeBlocked(_controlPageBlock, null); + shouldBeBlocked(_sidePageBlock, null); } } } @@ -192,7 +196,6 @@ class ConnectionManagerState extends State selectedBorderColor: MyTheme.accent, maxLabelWidth: 100, tail: null, //buildScrollJumper(), - blockTab: allowRemoteCMModification() ? null : _block, tabBuilder: (key, icon, label, themeConf) { final client = serverModel.clients .firstWhereOrNull((client) => client.id.toString() == key); @@ -237,13 +240,20 @@ class ConnectionManagerState extends State ? buildSidePage() : buildRemoteBlock( child: buildSidePage(), - block: _block, + block: _sidePageBlock, mask: true), )), SizedBox( width: realClosedWidth, - child: - SizedBox(width: realClosedWidth, child: pageView)), + child: SizedBox( + width: realClosedWidth, + child: allowRemoteCMModification() + ? pageView + : buildRemoteBlock( + child: _buildKeyEventBlock(pageView), + block: _controlPageBlock, + mask: false, + ))), ]); return Container( color: Theme.of(context).scaffoldBackgroundColor, @@ -268,6 +278,10 @@ class ConnectionManagerState extends State } } + Widget _buildKeyEventBlock(Widget child) { + return ExcludeFocus(child: child, excluding: true); + } + Widget buildTitleBar() { return SizedBox( height: kDesktopRemoteTabBarHeight, @@ -339,7 +353,10 @@ Widget buildConnectionCard(Client client) { key: ValueKey(client.id), children: [ _CmHeader(client: client), - client.type_() != ClientType.remote || client.disconnected + client.type_() == ClientType.file || + client.type_() == ClientType.portForward || + client.type_() == ClientType.terminal || + client.disconnected ? Offstage() : _PrivilegeBoard(client: client), Expanded( @@ -445,23 +462,7 @@ class _CmHeaderState extends State<_CmHeader> child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - width: 70, - height: 70, - alignment: Alignment.center, - decoration: BoxDecoration( - color: str2color(client.name), - borderRadius: BorderRadius.circular(15.0), - ), - child: Text( - client.name[0], - style: TextStyle( - fontWeight: FontWeight.bold, - color: Colors.white, - fontSize: 55, - ), - ), - ).marginOnly(right: 10.0), + _buildClientAvatar().marginOnly(right: 10.0), Expanded( child: Column( mainAxisAlignment: MainAxisAlignment.start, @@ -483,7 +484,36 @@ class _CmHeaderState extends State<_CmHeader> "(${client.peerId})", style: TextStyle(color: Colors.white, fontSize: 14), ), - ).marginOnly(bottom: 10.0), + ), + if (client.type_() == ClientType.terminal) + FittedBox( + child: Text( + translate("Terminal"), + style: TextStyle(color: Colors.white70, fontSize: 12), + ), + ), + if (client.type_() == ClientType.file) + FittedBox( + child: Text( + translate("File Transfer"), + style: TextStyle(color: Colors.white70, fontSize: 12), + ), + ), + if (client.type_() == ClientType.camera) + FittedBox( + child: Text( + translate("View Camera"), + style: TextStyle(color: Colors.white70, fontSize: 12), + ), + ), + if (client.portForward.isNotEmpty) + FittedBox( + child: Text( + "Port Forward: ${client.portForward}", + style: TextStyle(color: Colors.white70, fontSize: 12), + ), + ), + SizedBox(height: 10.0), FittedBox( child: Row( children: [ @@ -512,7 +542,8 @@ class _CmHeaderState extends State<_CmHeader> Offstage( offstage: !client.authorized || (client.type_() != ClientType.remote && - client.type_() != ClientType.file), + client.type_() != ClientType.file && + client.type_() != ClientType.camera), child: IconButton( onPressed: () => checkClickTime(client.id, () { if (client.type_() == ClientType.file) { @@ -535,6 +566,36 @@ class _CmHeaderState extends State<_CmHeader> @override bool get wantKeepAlive => true; + + Widget _buildClientAvatar() { + return buildAvatarWidget( + avatar: client.avatar, + size: 70, + borderRadius: 15, + fallback: _buildInitialAvatar(), + ) ?? + _buildInitialAvatar(); + } + + Widget _buildInitialAvatar() { + return Container( + width: 70, + height: 70, + alignment: Alignment.center, + decoration: BoxDecoration( + color: str2color(client.name), + borderRadius: BorderRadius.circular(15.0), + ), + child: Text( + client.name.isNotEmpty ? client.name[0] : '?', + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.white, + fontSize: 55, + ), + ), + ); + } } class _PrivilegeBoard extends StatefulWidget { @@ -549,19 +610,24 @@ class _PrivilegeBoard extends StatefulWidget { class _PrivilegeBoardState extends State<_PrivilegeBoard> { late final client = widget.client; Widget buildPermissionIcon(bool enabled, IconData iconData, - Function(bool)? onTap, String tooltipText) { + Function(bool)? onTap, String tooltipText, + {required bool canModify}) { return Tooltip( message: "$tooltipText: ${enabled ? "ON" : "OFF"}", waitDuration: Duration.zero, child: Container( decoration: BoxDecoration( - color: enabled ? MyTheme.accent : Colors.grey[700], + color: enabled + ? (canModify ? MyTheme.accent : MyTheme.accent.withOpacity(0.6)) + : Colors.grey[700], borderRadius: BorderRadius.circular(10.0), ), padding: EdgeInsets.all(8.0), child: InkWell( - onTap: () => - checkClickTime(widget.client.id, () => onTap?.call(!enabled)), + onTap: canModify + ? () => + checkClickTime(widget.client.id, () => onTap?.call(!enabled)) + : null, child: Column( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ @@ -582,6 +648,9 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> { Widget build(BuildContext context) { final crossAxisCount = 4; final spacing = 10.0; + final canModifyPermission = + bind.mainGetBuildinOption(key: kOptionEnablePermChangeInAcceptWindow) != + 'N'; return Container( width: double.infinity, height: 160.0, @@ -613,96 +682,164 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> { padding: EdgeInsets.symmetric(horizontal: spacing), mainAxisSpacing: spacing, crossAxisSpacing: spacing, - children: [ - buildPermissionIcon( - client.keyboard, - Icons.keyboard, - (enabled) { - bind.cmSwitchPermission( - connId: client.id, name: "keyboard", enabled: enabled); - setState(() { - client.keyboard = enabled; - }); - }, - translate('Enable keyboard/mouse'), - ), - buildPermissionIcon( - client.clipboard, - Icons.assignment_rounded, - (enabled) { - bind.cmSwitchPermission( - connId: client.id, name: "clipboard", enabled: enabled); - setState(() { - client.clipboard = enabled; - }); - }, - translate('Enable clipboard'), - ), - buildPermissionIcon( - client.audio, - Icons.volume_up_rounded, - (enabled) { - bind.cmSwitchPermission( - connId: client.id, name: "audio", enabled: enabled); - setState(() { - client.audio = enabled; - }); - }, - translate('Enable audio'), - ), - buildPermissionIcon( - client.file, - Icons.upload_file_rounded, - (enabled) { - bind.cmSwitchPermission( - connId: client.id, name: "file", enabled: enabled); - setState(() { - client.file = enabled; - }); - }, - translate('Enable file copy and paste'), - ), - buildPermissionIcon( - client.restart, - Icons.restart_alt_rounded, - (enabled) { - bind.cmSwitchPermission( - connId: client.id, name: "restart", enabled: enabled); - setState(() { - client.restart = enabled; - }); - }, - translate('Enable remote restart'), - ), - buildPermissionIcon( - client.recording, - Icons.videocam_rounded, - (enabled) { - bind.cmSwitchPermission( - connId: client.id, name: "recording", enabled: enabled); - setState(() { - client.recording = enabled; - }); - }, - translate('Enable recording session'), - ), - // only windows support block input - if (isWindows) - buildPermissionIcon( - client.blockInput, - Icons.block, - (enabled) { - bind.cmSwitchPermission( - connId: client.id, - name: "block_input", - enabled: enabled); - setState(() { - client.blockInput = enabled; - }); - }, - translate('Enable blocking user input'), - ) - ], + children: client.type_() == ClientType.camera + ? [ + buildPermissionIcon( + client.audio, + Icons.volume_up_rounded, + (enabled) { + bind.cmSwitchPermission( + connId: client.id, + name: "audio", + enabled: enabled); + setState(() { + client.audio = enabled; + }); + }, + translate('Enable audio'), + canModify: canModifyPermission, + ), + buildPermissionIcon( + client.recording, + Icons.videocam_rounded, + (enabled) { + bind.cmSwitchPermission( + connId: client.id, + name: "recording", + enabled: enabled); + setState(() { + client.recording = enabled; + }); + }, + translate('Enable recording session'), + canModify: canModifyPermission, + ), + ] + : [ + buildPermissionIcon( + client.keyboard, + Icons.keyboard, + (enabled) { + bind.cmSwitchPermission( + connId: client.id, + name: "keyboard", + enabled: enabled); + setState(() { + client.keyboard = enabled; + }); + }, + translate('Enable keyboard/mouse'), + canModify: canModifyPermission, + ), + buildPermissionIcon( + client.clipboard, + Icons.assignment_rounded, + (enabled) { + bind.cmSwitchPermission( + connId: client.id, + name: "clipboard", + enabled: enabled); + setState(() { + client.clipboard = enabled; + }); + }, + translate('Enable clipboard'), + canModify: canModifyPermission, + ), + buildPermissionIcon( + client.audio, + Icons.volume_up_rounded, + (enabled) { + bind.cmSwitchPermission( + connId: client.id, + name: "audio", + enabled: enabled); + setState(() { + client.audio = enabled; + }); + }, + translate('Enable audio'), + canModify: canModifyPermission, + ), + buildPermissionIcon( + client.file, + Icons.upload_file_rounded, + (enabled) { + bind.cmSwitchPermission( + connId: client.id, + name: "file", + enabled: enabled); + setState(() { + client.file = enabled; + }); + }, + translate('Enable file copy and paste'), + canModify: canModifyPermission, + ), + buildPermissionIcon( + client.restart, + Icons.restart_alt_rounded, + (enabled) { + bind.cmSwitchPermission( + connId: client.id, + name: "restart", + enabled: enabled); + setState(() { + client.restart = enabled; + }); + }, + translate('Enable remote restart'), + canModify: canModifyPermission, + ), + buildPermissionIcon( + client.recording, + Icons.videocam_rounded, + (enabled) { + bind.cmSwitchPermission( + connId: client.id, + name: "recording", + enabled: enabled); + setState(() { + client.recording = enabled; + }); + }, + translate('Enable recording session'), + canModify: canModifyPermission, + ), + // only windows support block input + if (isWindows) + buildPermissionIcon( + client.blockInput, + Icons.block, + (enabled) { + bind.cmSwitchPermission( + connId: client.id, + name: "block_input", + enabled: enabled); + setState(() { + client.blockInput = enabled; + }); + }, + translate('Enable blocking user input'), + canModify: canModifyPermission, + ), + if (bind.mainSupportedPrivacyModeImpls() != '[]') + buildPermissionIcon( + client.privacyMode, + Icons.visibility_off, + (enabled) { + bind.cmSwitchPermission( + connId: client.id, + name: "privacy_mode", + enabled: enabled); + setState(() { + client.privacyMode = enabled; + }); + }, + translate('Enable privacy mode'), + canModify: canModifyPermission, + ) + ], ), ), ], diff --git a/flutter/lib/desktop/pages/terminal_connection_manager.dart b/flutter/lib/desktop/pages/terminal_connection_manager.dart new file mode 100644 index 000000000..91b8baa97 --- /dev/null +++ b/flutter/lib/desktop/pages/terminal_connection_manager.dart @@ -0,0 +1,98 @@ +import 'package:flutter/foundation.dart'; +import 'package:get/get.dart'; +import '../../models/model.dart'; + +/// Manages terminal connections to ensure one FFI instance per peer +class TerminalConnectionManager { + static final Map _connections = {}; + static final Map _connectionRefCount = {}; + + // Track service IDs per peer + static final Map _serviceIds = {}; + + /// Get or create an FFI instance for a peer + static FFI getConnection({ + required String peerId, + required String? password, + required bool? isSharedPassword, + required bool? forceRelay, + required String? connToken, + }) { + final existingFfi = _connections[peerId]; + if (existingFfi != null && !existingFfi.closed) { + // Increment reference count + _connectionRefCount[peerId] = (_connectionRefCount[peerId] ?? 0) + 1; + debugPrint('[TerminalConnectionManager] Reusing existing connection for peer $peerId. Reference count: ${_connectionRefCount[peerId]}'); + return existingFfi; + } + + // Create new FFI instance for first terminal + debugPrint('[TerminalConnectionManager] Creating new terminal connection for peer $peerId'); + final ffi = FFI(null); + ffi.start( + peerId, + password: password, + isSharedPassword: isSharedPassword, + forceRelay: forceRelay, + connToken: connToken, + isTerminal: true, + ); + + _connections[peerId] = ffi; + _connectionRefCount[peerId] = 1; + + // Register the FFI instance with Get for dependency injection + Get.put(ffi, tag: 'terminal_$peerId'); + + debugPrint('[TerminalConnectionManager] New connection created. Total connections: ${_connections.length}'); + return ffi; + } + + /// Release a connection reference + static void releaseConnection(String peerId) { + final refCount = _connectionRefCount[peerId] ?? 0; + debugPrint('[TerminalConnectionManager] Releasing connection for peer $peerId. Current ref count: $refCount'); + + if (refCount <= 1) { + // Last reference, close the connection + final ffi = _connections[peerId]; + if (ffi != null) { + debugPrint('[TerminalConnectionManager] Closing connection for peer $peerId (last reference)'); + ffi.close(); + _connections.remove(peerId); + _connectionRefCount.remove(peerId); + Get.delete(tag: 'terminal_$peerId'); + } + } else { + // Decrement reference count + _connectionRefCount[peerId] = refCount - 1; + debugPrint('[TerminalConnectionManager] Connection still in use. New ref count: ${_connectionRefCount[peerId]}'); + } + } + + /// Check if a connection exists for a peer + static bool hasConnection(String peerId) { + final ffi = _connections[peerId]; + return ffi != null && !ffi.closed; + } + + /// Get existing connection without creating new one + static FFI? getExistingConnection(String peerId) { + return _connections[peerId]; + } + + /// Get connection count for debugging + static int getConnectionCount() => _connections.length; + + /// Get terminal count for a peer + static int getTerminalCount(String peerId) => _connectionRefCount[peerId] ?? 0; + + /// Get service ID for a peer + static String? getServiceId(String peerId) => _serviceIds[peerId]; + + /// Set service ID for a peer + static void setServiceId(String peerId, String serviceId) { + _serviceIds[peerId] = serviceId; + debugPrint('[TerminalConnectionManager] Service ID for $peerId: $serviceId'); + } +} \ No newline at end of file diff --git a/flutter/lib/desktop/pages/terminal_page.dart b/flutter/lib/desktop/pages/terminal_page.dart new file mode 100644 index 000000000..d38dc4a8b --- /dev/null +++ b/flutter/lib/desktop/pages/terminal_page.dart @@ -0,0 +1,223 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +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 'terminal_connection_manager.dart'; + +class TerminalPage extends StatefulWidget { + TerminalPage({ + Key? key, + required this.id, + required this.password, + required this.tabController, + required this.isSharedPassword, + required this.terminalId, + required this.tabKey, + this.forceRelay, + this.connToken, + }) : super(key: key); + final String id; + final String? password; + final DesktopTabController tabController; + final bool? forceRelay; + final bool? isSharedPassword; + final String? connToken; + final int terminalId; + + /// Tab key for focus management, passed from parent to avoid duplicate construction + final String tabKey; + final SimpleWrapper?> _lastState = SimpleWrapper(null); + + FFI get ffi => (_lastState.value! as _TerminalPageState)._ffi; + + @override + State createState() { + final state = _TerminalPageState(); + _lastState.value = state; + return state; + } +} + +class _TerminalPageState extends State + with AutomaticKeepAliveClientMixin { + static const EdgeInsets _defaultTerminalPadding = + EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0); + + late FFI _ffi; + late TerminalModel _terminalModel; + double? _cellHeight; + final FocusNode _terminalFocusNode = FocusNode(canRequestFocus: false); + StreamSubscription? _tabStateSubscription; + + @override + void initState() { + super.initState(); + + // Listen for tab selection changes to request focus + _tabStateSubscription = widget.tabController.state.listen(_onTabStateChanged); + + // Use shared FFI instance from connection manager + _ffi = TerminalConnectionManager.getConnection( + peerId: widget.id, + password: widget.password, + isSharedPassword: widget.isSharedPassword, + forceRelay: widget.forceRelay, + connToken: widget.connToken, + ); + + // Create terminal model with specific terminal ID + _terminalModel = TerminalModel(_ffi, widget.terminalId); + debugPrint( + '[TerminalPage] Terminal model created for terminal ${widget.terminalId}'); + + _terminalModel.onResizeExternal = (w, h, pw, ph) { + _cellHeight = ph * 1.0; + + // Enable focus once terminal has valid dimensions (first valid resize) + if (!_terminalFocusNode.canRequestFocus && w > 0 && h > 0) { + _terminalFocusNode.canRequestFocus = true; + // Auto-focus if this tab is currently selected + _requestFocusIfSelected(); + } + + // Schedule the setState for the next frame + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() {}); + } + }); + }; + + // Register this terminal model with FFI for event routing + _ffi.registerTerminalModel(widget.terminalId, _terminalModel); + + // Initialize terminal connection + WidgetsBinding.instance.addPostFrameCallback((_) { + widget.tabController.onSelected?.call(widget.id); + + // Check if this is a new connection or additional terminal + // Note: When a connection exists, the ref count will be > 1 after this terminal is added + final isExistingConnection = + TerminalConnectionManager.hasConnection(widget.id) && + TerminalConnectionManager.getTerminalCount(widget.id) > 1; + + if (!isExistingConnection) { + // First terminal - show loading dialog, wait for onReady + _ffi.dialogManager + .showLoading(translate('Connecting...'), onCancel: closeConnection); + } else { + // Additional terminal - connection already established + // Open the terminal directly + _terminalModel.openTerminal(); + } + }); + } + + @override + void dispose() { + // Cancel tab state subscription to prevent memory leak + _tabStateSubscription?.cancel(); + // Unregister terminal model from FFI + _ffi.unregisterTerminalModel(widget.terminalId); + _terminalModel.dispose(); + _terminalFocusNode.dispose(); + // Release connection reference instead of closing directly + TerminalConnectionManager.releaseConnection(widget.id); + super.dispose(); + } + + void _onTabStateChanged(DesktopTabState state) { + // Check if this tab is now selected and request focus + if (state.selected >= 0 && state.selected < state.tabs.length) { + final selectedTab = state.tabs[state.selected]; + if (selectedTab.key == widget.tabKey && mounted) { + _requestFocusIfSelected(); + } + } + } + + void _requestFocusIfSelected() { + if (!mounted || !_terminalFocusNode.canRequestFocus) return; + // Use post-frame callback to ensure widget is fully laid out in focus tree + WidgetsBinding.instance.addPostFrameCallback((_) { + // Re-check conditions after frame: mounted, focusable, still selected, not already focused + if (!mounted || !_terminalFocusNode.canRequestFocus || _terminalFocusNode.hasFocus) return; + final state = widget.tabController.state.value; + if (state.selected >= 0 && state.selected < state.tabs.length) { + if (state.tabs[state.selected].key == widget.tabKey) { + _terminalFocusNode.requestFocus(); + } + } + }); + } + + // This method ensures that the number of visible rows is an integer by computing the + // extra space left after dividing the available height by the height of a single + // terminal row (`_cellHeight`) and distributing it evenly as top and bottom padding. + EdgeInsets _calculatePadding(double heightPx) { + final cellHeight = _cellHeight; + if (!heightPx.isFinite || + heightPx <= 0 || + cellHeight == null || + !cellHeight.isFinite || + cellHeight <= 0) { + return _defaultTerminalPadding; + } + final rows = (heightPx / cellHeight).floor(); + if (rows <= 0) { + return _defaultTerminalPadding; + } + final extraSpace = heightPx - rows * cellHeight; + if (!extraSpace.isFinite || extraSpace < 0) { + return _defaultTerminalPadding; + } + final topBottom = extraSpace / 2.0; + return EdgeInsets.symmetric( + horizontal: _defaultTerminalPadding.horizontal / 2, + vertical: topBottom, + ); + } + + @override + Widget build(BuildContext context) { + super.build(context); + return Scaffold( + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + body: LayoutBuilder( + builder: (context, constraints) { + final heightPx = constraints.maxHeight; + return TerminalView( + _terminalModel.terminal, + controller: _terminalModel.terminalController, + focusNode: _terminalFocusNode, + // Note: autofocus is not used here because focus is managed manually + // via _onTabStateChanged() to handle tab switching properly. + backgroundOpacity: 0.7, + padding: _calculatePadding(heightPx), + onSecondaryTapDown: (details, offset) async { + final selection = _terminalModel.terminalController.selection; + if (selection != null) { + final text = _terminalModel.terminal.buffer.getText(selection); + _terminalModel.terminalController.clearSelection(); + await Clipboard.setData(ClipboardData(text: text)); + } else { + final data = await Clipboard.getData('text/plain'); + final text = data?.text; + if (text != null) { + _terminalModel.terminal.paste(text); + } + } + }, + ); + }, + ), + ); + } + + @override + bool get wantKeepAlive => true; +} diff --git a/flutter/lib/desktop/pages/terminal_tab_page.dart b/flutter/lib/desktop/pages/terminal_tab_page.dart new file mode 100644 index 000000000..63289e94d --- /dev/null +++ b/flutter/lib/desktop/pages/terminal_tab_page.dart @@ -0,0 +1,625 @@ +import 'dart:convert'; + +import 'package:desktop_multi_window/desktop_multi_window.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hbb/common.dart'; +import 'package:flutter_hbb/common/widgets/dialog.dart'; +import 'package:flutter_hbb/consts.dart'; +import 'package:flutter_hbb/models/state_model.dart'; +import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart'; +import 'package:flutter_hbb/utils/multi_window_manager.dart'; +import 'package:flutter_hbb/models/model.dart'; +import 'package:get/get.dart'; + +import '../../models/platform_model.dart'; +import 'terminal_page.dart'; +import 'terminal_connection_manager.dart'; +import '../widgets/material_mod_popup_menu.dart' as mod_menu; +import '../widgets/popup_menu.dart'; +import 'package:bot_toast/bot_toast.dart'; + +class TerminalTabPage extends StatefulWidget { + final Map params; + + const TerminalTabPage({Key? key, required this.params}) : super(key: key); + + @override + State createState() => _TerminalTabPageState(params); +} + +class _TerminalTabPageState extends State { + DesktopTabController get tabController => Get.find(); + + static const IconData selectedIcon = Icons.terminal; + static const IconData unselectedIcon = Icons.terminal_outlined; + int _nextTerminalId = 1; + // Lightweight idempotency guard for async close operations + final Set _closingTabs = {}; + // When true, all session cleanup should persist (window-level close in progress) + bool _windowClosing = false; + + _TerminalTabPageState(Map params) { + Get.put(DesktopTabController(tabType: DesktopTabType.terminal)); + tabController.onSelected = (id) { + WindowController.fromWindowId(windowId()) + .setTitle(getWindowNameWithId(id)); + }; + tabController.onRemoved = (_, id) => onRemoveId(id); + tabController.onCloseWindow = _closeWindowFromConnection; + final terminalId = params['terminalId'] ?? _nextTerminalId++; + tabController.add(_createTerminalTab( + peerId: params['id'], + terminalId: terminalId, + password: params['password'], + isSharedPassword: params['isSharedPassword'], + forceRelay: params['forceRelay'], + connToken: params['connToken'], + )); + } + + TabInfo _createTerminalTab({ + required String peerId, + required int terminalId, + String? password, + bool? isSharedPassword, + bool? forceRelay, + String? connToken, + }) { + final tabKey = '${peerId}_$terminalId'; + final alias = bind.mainGetPeerOptionSync(id: peerId, key: 'alias'); + final tabLabel = + alias.isNotEmpty ? '$alias #$terminalId' : '$peerId #$terminalId'; + return TabInfo( + key: tabKey, + label: tabLabel, + selectedIcon: selectedIcon, + unselectedIcon: unselectedIcon, + onTabCloseButton: () => _closeTab(tabKey), + page: TerminalPage( + key: ValueKey(tabKey), + id: peerId, + terminalId: terminalId, + tabKey: tabKey, + password: password, + isSharedPassword: isSharedPassword, + tabController: tabController, + forceRelay: forceRelay, + connToken: connToken, + ), + ); + } + + /// Unified tab close handler for all close paths (button, shortcut, programmatic). + /// Shows audit dialog, cleans up session if not persistent, then removes the UI tab. + Future _closeTab(String tabKey) async { + // Idempotency guard: skip if already closing this tab + if (_closingTabs.contains(tabKey)) return; + _closingTabs.add(tabKey); + + try { + // Snapshot peerTabCount BEFORE any await to avoid race with concurrent + // _closeAllTabs clearing tabController (which would make the live count + // drop to 0 and incorrectly trigger session persistence). + // Note: the snapshot may become stale if other individual tabs are closed + // during the audit dialog, but this is an acceptable trade-off. + int? snapshotPeerTabCount; + final parsed = _parseTabKey(tabKey); + if (parsed != null) { + final (peerId, _) = parsed; + snapshotPeerTabCount = tabController.state.value.tabs.where((t) { + final p = _parseTabKey(t.key); + return p != null && p.$1 == peerId; + }).length; + } + + if (await desktopTryShowTabAuditDialogCloseCancelled( + id: tabKey, + tabController: tabController, + )) { + return; + } + + // Close terminal session if not in persistent mode. + // Wrapped separately so session cleanup failure never blocks UI tab removal. + try { + await _closeTerminalSessionIfNeeded(tabKey, + peerTabCount: snapshotPeerTabCount); + } catch (e) { + debugPrint('[TerminalTabPage] Session cleanup failed for $tabKey: $e'); + } + // Always close the tab from UI, regardless of session cleanup result + tabController.closeBy(tabKey); + } catch (e) { + debugPrint('[TerminalTabPage] Error closing tab $tabKey: $e'); + } finally { + _closingTabs.remove(tabKey); + } + } + + /// Close all tabs with session cleanup. + /// Used for window-level close operations (onDestroy, handleWindowCloseButton). + /// UI tabs are removed immediately; session cleanup runs in parallel with a + /// bounded timeout so window close is not blocked indefinitely. + Future _closeAllTabs() async { + _windowClosing = true; + final tabKeys = tabController.state.value.tabs.map((t) => t.key).toList(); + // Remove all UI tabs immediately (same instant behavior as the old tabController.clear()) + // Keep the cleanup target lookup below synchronous before its first await: + // it relies on the current frame still retaining each TerminalPage's FFI/model. + tabController.clear(); + // Run session cleanup in parallel with bounded timeout (closeTerminal() has internal 3s timeout). + // Skip tabs already being closed by a concurrent _closeTab() to avoid duplicate FFI calls. + final futures = tabKeys + .where((tabKey) => !_closingTabs.contains(tabKey)) + .map((tabKey) async { + try { + await _closeTerminalSessionIfNeeded(tabKey, persistAll: true); + } catch (e) { + debugPrint('[TerminalTabPage] Session cleanup failed for $tabKey: $e'); + } + }).toList(); + if (futures.isNotEmpty) { + await Future.wait(futures).timeout( + const Duration(seconds: 4), + onTimeout: () { + debugPrint( + '[TerminalTabPage] Session cleanup timed out for batch close'); + return []; + }, + ); + } + } + + /// Close the terminal session on server side based on persistent mode. + /// + /// [persistAll] controls behavior when persistent mode is enabled: + /// - `true` (window close): persist all sessions, don't close any. + /// - `false` (tab close): only persist the last session for the peer, + /// close others so only the most recent disconnected session survives. + /// + /// Note: if [_windowClosing] is true, persistAll is forced to true so that + /// in-flight _closeTab() calls don't accidentally close sessions that the + /// window-close flow intends to preserve. + Future _closeTerminalSessionIfNeeded(String tabKey, + {bool persistAll = false, int? peerTabCount}) async { + // If window close is in progress, override to persist all sessions + // even if this call originated from an individual tab close. + if (_windowClosing) { + persistAll = true; + } + final parsed = _parseTabKey(tabKey); + if (parsed == null) return; + final (peerId, terminalId) = parsed; + + final ffi = TerminalConnectionManager.getExistingConnection(peerId); + if (ffi == null) return; + + final isPersistent = bind.sessionGetToggleOptionSync( + sessionId: ffi.sessionId, + arg: kOptionTerminalPersistent, + ); + + if (isPersistent) { + if (persistAll) { + // Window close: persist all sessions + return; + } + // Tab close: only persist if this is the last tab for this peer. + // Use the snapshot value if provided (avoids race with concurrent tab removal). + final effectivePeerTabCount = peerTabCount ?? + tabController.state.value.tabs.where((t) { + final p = _parseTabKey(t.key); + return p != null && p.$1 == peerId; + }).length; + if (effectivePeerTabCount <= 1) { + // Last tab for this peer — persist the session + return; + } + // Not the last tab — fall through to close the session + } + + final terminalModel = ffi.terminalModels[terminalId]; + if (terminalModel != null) { + // closeTerminal() has internal 3s timeout, no need for external timeout + await terminalModel.closeTerminal(); + } + } + + /// Parse tabKey (format: "peerId_terminalId") into its components. + /// Note: peerId may contain underscores, so we use lastIndexOf('_'). + /// Returns null if tabKey format is invalid. + (String peerId, int terminalId)? _parseTabKey(String tabKey) { + final lastUnderscore = tabKey.lastIndexOf('_'); + if (lastUnderscore <= 0) { + debugPrint('[TerminalTabPage] Invalid tabKey format: $tabKey'); + return null; + } + final terminalIdStr = tabKey.substring(lastUnderscore + 1); + final terminalId = int.tryParse(terminalIdStr); + if (terminalId == null) { + debugPrint('[TerminalTabPage] Invalid terminalId in tabKey: $tabKey'); + return null; + } + final peerId = tabKey.substring(0, lastUnderscore); + return (peerId, terminalId); + } + + Widget _tabMenuBuilder(String peerId, CancelFunc cancelFunc) { + final List> menu = []; + const EdgeInsets padding = EdgeInsets.only(left: 8.0, right: 5.0); + + // New tab menu item + menu.add(MenuEntryButton( + childBuilder: (TextStyle? style) => Text( + translate('New tab'), + style: style, + ), + proc: () { + _addNewTerminal(peerId); + cancelFunc(); + // Also try to close any BotToast overlays + BotToast.cleanAll(); + }, + padding: padding, + )); + + menu.add(MenuEntryDivider()); + + menu.add(MenuEntrySwitch( + switchType: SwitchType.scheckbox, + text: translate('Keep terminal sessions on disconnect'), + getter: () async { + final ffi = Get.find(tag: 'terminal_$peerId'); + return bind.sessionGetToggleOptionSync( + sessionId: ffi.sessionId, + arg: kOptionTerminalPersistent, + ); + }, + setter: (bool v) async { + final ffi = Get.find(tag: 'terminal_$peerId'); + await bind.sessionToggleOption( + sessionId: ffi.sessionId, + value: kOptionTerminalPersistent, + ); + }, + padding: padding, + )); + + return mod_menu.PopupMenu( + items: menu + .map((e) => e.build( + context, + const MenuConfig( + commonColor: CustomPopupMenuTheme.commonColor, + height: CustomPopupMenuTheme.height, + dividerHeight: CustomPopupMenuTheme.dividerHeight, + ), + )) + .expand((i) => i) + .toList(), + ); + } + + @override + void initState() { + super.initState(); + + // Add keyboard shortcut handler + HardwareKeyboard.instance.addHandler(_handleKeyEvent); + + rustDeskWinManager.setMethodHandler((call, fromWindowId) async { + print( + "[Remote Terminal] call ${call.method} with args ${call.arguments} from window $fromWindowId"); + if (call.method == kWindowEventNewTerminal) { + final args = jsonDecode(call.arguments); + final id = args['id']; + windowOnTop(windowId()); + // Allow multiple terminals for the same connection + final terminalId = args['terminalId'] ?? _nextTerminalId++; + tabController.add(_createTerminalTab( + peerId: id, + terminalId: terminalId, + password: args['password'], + isSharedPassword: args['isSharedPassword'], + forceRelay: args['forceRelay'], + connToken: args['connToken'], + )); + } else if (call.method == kWindowEventRestoreTerminalSessions) { + _restoreSessions(call.arguments); + } else if (call.method == "onDestroy") { + // Clean up sessions before window destruction (bounded wait) + await _closeAllTabs(); + } else if (call.method == kWindowActionRebuild) { + reloadCurrentWindow(); + } else if (call.method == kWindowEventActiveSession) { + if (tabController.state.value.tabs.isEmpty) { + return false; + } + final currentTab = tabController.state.value.selectedTabInfo; + assert(call.arguments is String, + "Expected String arguments for kWindowEventActiveSession, got ${call.arguments.runtimeType}"); + // Use lastIndexOf to handle peerIds containing underscores + final lastUnderscore = currentTab.key.lastIndexOf('_'); + if (lastUnderscore > 0 && + currentTab.key.substring(0, lastUnderscore) == call.arguments) { + windowOnTop(windowId()); + return true; + } + return false; + } + }); + Future.delayed(Duration.zero, () { + restoreWindowPosition(WindowType.Terminal, windowId: windowId()); + }); + } + + @override + void dispose() { + HardwareKeyboard.instance.removeHandler(_handleKeyEvent); + super.dispose(); + } + + Future _restoreSessions(String arguments) async { + Map? args; + try { + args = jsonDecode(arguments) as Map; + } catch (e) { + debugPrint("Error parsing JSON arguments in _restoreSessions: $e"); + return; + } + final persistentSessions = + args['persistent_sessions'] as List? ?? []; + final sortedSessions = persistentSessions.whereType().toList()..sort(); + var peerId = args['peer_id'] as String? ?? ''; + if (peerId.isEmpty) { + if (tabController.state.value.tabs.isEmpty || + tabController.state.value.selected >= + tabController.state.value.tabs.length) { + debugPrint('[TerminalTabPage] Skip restore: no selected tab'); + return; + } + final currentTab = tabController.state.value.selectedTabInfo; + final parsed = _parseTabKey(currentTab.key); + if (parsed == null) return; + peerId = parsed.$1; + } + final existingTerminalIds = tabController.state.value.tabs + .map((tab) => _parseTabKey(tab.key)) + .where((parsed) => parsed != null && parsed.$1 == peerId) + .map((parsed) => parsed!.$2) + .toSet(); + if (existingTerminalIds.isEmpty) { + debugPrint( + '[TerminalTabPage] Skip restore: no seed tab for peer $peerId'); + return; + } + for (final terminalId in sortedSessions) { + if (!existingTerminalIds.add(terminalId)) { + continue; + } + _addNewTerminal(peerId, terminalId: terminalId); + // A delay is required to ensure the UI has sufficient time to update + // before adding the next terminal. Without this delay, `_TerminalPageState::dispose()` + // may be called prematurely while the tab widget is still in the tab controller. + // This behavior is likely due to a race condition between the UI rendering lifecycle + // and the addition of new tabs. Attempts to use `_TerminalPageState::addPostFrameCallback()` + // to wait for the previous page to be ready were unsuccessful, as the observed call sequence is: + // `initState() 2 -> dispose() 2 -> postFrameCallback() 2`, followed by `initState() 3`. + // The `Future.delayed` approach mitigates this issue by introducing a buffer period, + // allowing the UI to stabilize before proceeding. + await Future.delayed(const Duration(milliseconds: 300)); + } + } + + bool _handleKeyEvent(KeyEvent event) { + if (event is KeyDownEvent) { + // Use Cmd+T on macOS, Ctrl+Shift+T on other platforms + if (event.logicalKey == LogicalKeyboardKey.keyT) { + if (isMacOS && + HardwareKeyboard.instance.isMetaPressed && + !HardwareKeyboard.instance.isShiftPressed) { + // macOS: Cmd+T (standard for new tab) + _addNewTerminalForCurrentPeer(); + return true; + } else if (!isMacOS && + HardwareKeyboard.instance.isControlPressed && + HardwareKeyboard.instance.isShiftPressed) { + // Other platforms: Ctrl+Shift+T (to avoid conflict with Ctrl+T in terminal) + _addNewTerminalForCurrentPeer(); + return true; + } + } + + // Use Cmd+W on macOS, Ctrl+Shift+W on other platforms + if (event.logicalKey == LogicalKeyboardKey.keyW) { + if (isMacOS && + HardwareKeyboard.instance.isMetaPressed && + !HardwareKeyboard.instance.isShiftPressed) { + // macOS: Cmd+W (standard for close tab) + final currentTab = tabController.state.value.selectedTabInfo; + if (tabController.state.value.tabs.length > 1) { + _closeTab(currentTab.key); + return true; + } + } else if (!isMacOS && + HardwareKeyboard.instance.isControlPressed && + HardwareKeyboard.instance.isShiftPressed) { + // Other platforms: Ctrl+Shift+W (to avoid conflict with Ctrl+W word delete) + final currentTab = tabController.state.value.selectedTabInfo; + if (tabController.state.value.tabs.length > 1) { + _closeTab(currentTab.key); + return true; + } + } + } + + // Use Alt+Left/Right for tab navigation (avoids conflicts) + if (HardwareKeyboard.instance.isAltPressed) { + if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { + // Previous tab + final currentIndex = tabController.state.value.selected; + if (currentIndex > 0) { + tabController.jumpTo(currentIndex - 1); + } + return true; + } else if (event.logicalKey == LogicalKeyboardKey.arrowRight) { + // Next tab + final currentIndex = tabController.state.value.selected; + if (currentIndex < tabController.length - 1) { + tabController.jumpTo(currentIndex + 1); + } + return true; + } + } + + // Check for Cmd/Ctrl + Number (switch to specific tab) + final numberKeys = [ + LogicalKeyboardKey.digit1, + LogicalKeyboardKey.digit2, + LogicalKeyboardKey.digit3, + LogicalKeyboardKey.digit4, + LogicalKeyboardKey.digit5, + LogicalKeyboardKey.digit6, + LogicalKeyboardKey.digit7, + LogicalKeyboardKey.digit8, + LogicalKeyboardKey.digit9, + ]; + + for (int i = 0; i < numberKeys.length; i++) { + if (event.logicalKey == numberKeys[i] && + ((isMacOS && HardwareKeyboard.instance.isMetaPressed) || + (!isMacOS && HardwareKeyboard.instance.isControlPressed))) { + if (i < tabController.length) { + tabController.jumpTo(i); + return true; + } + } + } + } + return false; + } + + void _addNewTerminal(String peerId, {int? terminalId}) { + // Find first tab for this peer to get connection parameters + final firstTab = tabController.state.value.tabs.firstWhere( + (tab) { + final last = tab.key.lastIndexOf('_'); + return last > 0 && tab.key.substring(0, last) == peerId; + }, + ); + if (firstTab.page is TerminalPage) { + final page = firstTab.page as TerminalPage; + final newTerminalId = terminalId ?? _nextTerminalId++; + if (terminalId != null && terminalId >= _nextTerminalId) { + _nextTerminalId = terminalId + 1; + } + tabController.add(_createTerminalTab( + peerId: peerId, + terminalId: newTerminalId, + password: page.password, + isSharedPassword: page.isSharedPassword, + forceRelay: page.forceRelay, + connToken: page.connToken, + )); + } + } + + void _addNewTerminalForCurrentPeer({int? terminalId}) { + final currentTab = tabController.state.value.selectedTabInfo; + final parsed = _parseTabKey(currentTab.key); + if (parsed == null) return; + final (peerId, _) = parsed; + _addNewTerminal(peerId, terminalId: terminalId); + } + + @override + Widget build(BuildContext context) { + final child = Scaffold( + backgroundColor: Theme.of(context).cardColor, + body: DesktopTab( + controller: tabController, + onWindowCloseButton: handleWindowCloseButton, + tail: _buildAddButton(), + selectedBorderColor: MyTheme.accent, + labelGetter: DesktopTab.tablabelGetter, + tabMenuBuilder: (key) { + final parsed = _parseTabKey(key); + if (parsed == null) return Container(); + final (peerId, _) = parsed; + return _tabMenuBuilder(peerId, () {}); + }, + )); + final tabWidget = isLinux + ? buildVirtualWindowFrame(context, child) + : workaroundWindowBorder( + context, + Container( + decoration: BoxDecoration( + border: Border.all(color: MyTheme.color(context).border!)), + child: child, + )); + return isMacOS || kUseCompatibleUiMode + ? tabWidget + : SubWindowDragToResizeArea( + child: tabWidget, + resizeEdgeSize: stateGlobal.resizeEdgeSize.value, + enableResizeEdges: subWindowManagerEnableResizeEdges, + windowId: stateGlobal.windowId, + ); + } + + void onRemoveId(String id) { + if (tabController.state.value.tabs.isEmpty) { + WindowController.fromWindowId(windowId()).close(); + } + } + + Future _closeWindowFromConnection() async { + await _closeAllTabs(); + await WindowController.fromWindowId(windowId()).close(); + } + + int windowId() { + return widget.params["windowId"]; + } + + Widget _buildAddButton() { + return ActionIcon( + message: 'New tab', + icon: IconFont.add, + onTap: () { + _addNewTerminalForCurrentPeer(); + }, + isClose: false, + ); + } + + Future handleWindowCloseButton() async { + final connLength = tabController.state.value.tabs.length; + if (connLength == 1) { + if (await desktopTryShowTabAuditDialogCloseCancelled( + id: tabController.state.value.tabs[0].key, + tabController: tabController, + )) { + return false; + } + } + if (connLength <= 1) { + await _closeAllTabs(); + return true; + } else { + final bool res; + if (!option2bool(kOptionEnableConfirmClosingTabs, + bind.mainGetLocalOption(key: kOptionEnableConfirmClosingTabs))) { + res = true; + } else { + res = await closeConfirmDialog(); + } + if (res) { + await _closeAllTabs(); + } + return res; + } + } +} diff --git a/flutter/lib/desktop/pages/view_camera_page.dart b/flutter/lib/desktop/pages/view_camera_page.dart new file mode 100644 index 000000000..c45ec4d86 --- /dev/null +++ b/flutter/lib/desktop/pages/view_camera_page.dart @@ -0,0 +1,717 @@ +import 'dart:async'; + +import 'package:desktop_multi_window/desktop_multi_window.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hbb/common/widgets/remote_input.dart'; +import 'package:get/get.dart'; +import 'package:provider/provider.dart'; +import 'package:flutter_hbb/models/state_model.dart'; + +import '../../consts.dart'; +import '../../common/widgets/overlay.dart'; +import '../../common.dart'; +import '../../common/widgets/dialog.dart'; +import '../../common/widgets/toolbar.dart'; +import '../../models/model.dart'; +import '../../models/platform_model.dart'; +import '../../common/shared_state.dart'; +import '../../utils/image.dart'; +import '../widgets/remote_toolbar.dart'; +import '../widgets/kb_layout_type_chooser.dart'; +import '../widgets/tabbar_widget.dart'; + +import 'package:flutter_hbb/native/custom_cursor.dart' + if (dart.library.html) 'package:flutter_hbb/web/custom_cursor.dart'; + +final SimpleWrapper _firstEnterImage = SimpleWrapper(false); + +// Used to skip session close if "move to new window" is clicked. +final Map closeSessionOnDispose = {}; + +class ViewCameraPage extends StatefulWidget { + ViewCameraPage({ + Key? key, + required this.id, + required this.toolbarState, + this.sessionId, + this.tabWindowId, + this.password, + this.display, + this.displays, + this.tabController, + this.connToken, + this.forceRelay, + this.isSharedPassword, + }) : super(key: key) { + initSharedStates(id); + } + + final String id; + final SessionID? sessionId; + final int? tabWindowId; + final int? display; + final List? displays; + final String? password; + final ToolbarState toolbarState; + final bool? forceRelay; + final bool? isSharedPassword; + final String? connToken; + final SimpleWrapper?> _lastState = SimpleWrapper(null); + final DesktopTabController? tabController; + + FFI get ffi => (_lastState.value! as _ViewCameraPageState)._ffi; + + @override + State createState() { + final state = _ViewCameraPageState(id); + _lastState.value = state; + return state; + } +} + +class _ViewCameraPageState extends State + with AutomaticKeepAliveClientMixin, MultiWindowListener { + Timer? _timer; + String keyboardMode = "legacy"; + bool _isWindowBlur = false; + final _cursorOverImage = false.obs; + final _uniqueKey = UniqueKey(); + + var _blockableOverlayState = BlockableOverlayState(); + + final FocusNode _rawKeyFocusNode = FocusNode(debugLabel: "rawkeyFocusNode"); + + // We need `_instanceIdOnEnterOrLeaveImage4Toolbar` together with `_onEnterOrLeaveImage4Toolbar` + // to identify the toolbar instance and its callback function. + int? _instanceIdOnEnterOrLeaveImage4Toolbar; + Function(bool)? _onEnterOrLeaveImage4Toolbar; + + late FFI _ffi; + + SessionID get sessionId => _ffi.sessionId; + + _ViewCameraPageState(String id) { + _initStates(id); + } + + void _initStates(String id) {} + + @override + void initState() { + super.initState(); + _ffi = FFI(widget.sessionId); + Get.put(_ffi, tag: widget.id); + _ffi.imageModel.addCallbackOnFirstImage((String peerId) { + showKBLayoutTypeChooserIfNeeded( + _ffi.ffiModel.pi.platform, _ffi.dialogManager); + _ffi.recordingModel + .updateStatus(bind.sessionGetIsRecording(sessionId: _ffi.sessionId)); + }); + _ffi.start( + widget.id, + isViewCamera: true, + password: widget.password, + isSharedPassword: widget.isSharedPassword, + forceRelay: widget.forceRelay, + tabWindowId: widget.tabWindowId, + display: widget.display, + displays: widget.displays, + connToken: widget.connToken, + ); + WidgetsBinding.instance.addPostFrameCallback((_) { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: []); + _ffi.dialogManager + .showLoading(translate('Connecting...'), onCancel: closeConnection); + }); + 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); + // if (!_isCustomCursorInited) { + // customCursorController.registerNeedUpdateCursorCallback( + // (String? lastKey, String? currentKey) async { + // if (_firstEnterImage.value) { + // _firstEnterImage.value = false; + // return true; + // } + // return lastKey == null || lastKey != currentKey; + // }); + // _isCustomCursorInited = true; + // } + + _blockableOverlayState.applyFfi(_ffi); + // Call onSelected in post frame callback, since we cannot guarantee that the callback will not call setState. + WidgetsBinding.instance.addPostFrameCallback((_) { + widget.tabController?.onSelected?.call(widget.id); + }); + } + + @override + void onWindowBlur() { + super.onWindowBlur(); + // On windows, we use `focus` way to handle keyboard better. + // Now on Linux, there's some rdev issues which will break the input. + // We disable the `focus` way for non-Windows temporarily. + if (isWindows) { + _isWindowBlur = true; + // unfocus the primary-focus when the whole window is lost focus, + // and let OS to handle events instead. + _rawKeyFocusNode.unfocus(); + } + stateGlobal.isFocused.value = false; + } + + @override + void onWindowFocus() { + super.onWindowFocus(); + // See [onWindowBlur]. + if (isWindows) { + _isWindowBlur = false; + } + stateGlobal.isFocused.value = true; + } + + @override + void onWindowRestore() { + super.onWindowRestore(); + // On windows, we use `onWindowRestore` way to handle window restore from + // a minimized state. + if (isWindows) { + _isWindowBlur = false; + } + WakelockManager.enable(_uniqueKey); + } + + // When the window is unminimized, onWindowMaximize or onWindowRestore can be called when the old state was maximized or not. + @override + void onWindowMaximize() { + super.onWindowMaximize(); + WakelockManager.enable(_uniqueKey); + } + + @override + void onWindowMinimize() { + super.onWindowMinimize(); + WakelockManager.disable(_uniqueKey); + } + + @override + void onWindowEnterFullScreen() { + super.onWindowEnterFullScreen(); + if (isMacOS) { + stateGlobal.setFullscreen(true); + } + } + + @override + void onWindowLeaveFullScreen() { + super.onWindowLeaveFullScreen(); + if (isMacOS) { + stateGlobal.setFullscreen(false); + } + } + + @override + Future dispose() async { + final closeSession = closeSessionOnDispose.remove(widget.id) ?? true; + + // https://github.com/flutter/flutter/issues/64935 + super.dispose(); + debugPrint("VIEW CAMERA PAGE dispose session $sessionId ${widget.id}"); + _ffi.textureModel.onViewCameraPageDispose(closeSession); + if (closeSession) { + // ensure we leave this session, this is a double check + _ffi.inputModel.enterOrLeave(false); + } + DesktopMultiWindow.removeListener(this); + _ffi.dialogManager.hideMobileActionsOverlay(); + _ffi.imageModel.disposeImage(); + _ffi.cursorModel.disposeImages(); + _rawKeyFocusNode.dispose(); + await _ffi.close(closeSession: closeSession); + _timer?.cancel(); + _ffi.dialogManager.dismissAll(); + if (closeSession) { + await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, + overlays: SystemUiOverlay.values); + } + WakelockManager.disable(_uniqueKey); + await Get.delete(tag: widget.id); + removeSharedStates(widget.id); + } + + Widget emptyOverlay() => BlockableOverlay( + /// the Overlay key will be set with _blockableOverlayState in BlockableOverlay + /// see override build() in [BlockableOverlay] + state: _blockableOverlayState, + underlying: Container( + color: Colors.transparent, + ), + ); + + Widget buildBody(BuildContext context) { + remoteToolbar(BuildContext context) => RemoteToolbar( + id: widget.id, + ffi: _ffi, + state: widget.toolbarState, + onEnterOrLeaveImageSetter: (id, func) { + _instanceIdOnEnterOrLeaveImage4Toolbar = id; + _onEnterOrLeaveImage4Toolbar = func; + }, + onEnterOrLeaveImageCleaner: (id) { + // If _instanceIdOnEnterOrLeaveImage4Toolbar != id + // it means `_onEnterOrLeaveImage4Toolbar` is not set or it has been changed to another toolbar. + if (_instanceIdOnEnterOrLeaveImage4Toolbar == id) { + _instanceIdOnEnterOrLeaveImage4Toolbar = null; + _onEnterOrLeaveImage4Toolbar = null; + } + }, + setRemoteState: setState, + ); + + bodyWidget() { + return Stack( + children: [ + Container( + color: kColorCanvas, + child: getBodyForDesktop(context), + ), + Stack( + children: [ + _ffi.ffiModel.pi.isSet.isTrue && + _ffi.ffiModel.waitForFirstImage.isTrue + ? emptyOverlay() + : () { + if (!_ffi.ffiModel.isPeerAndroid) { + return Offstage(); + } else { + return Obx(() => Offstage( + offstage: _ffi.dialogManager + .mobileActionsOverlayVisible.isFalse, + child: Overlay(initialEntries: [ + makeMobileActionsOverlayEntry( + () => _ffi.dialogManager + .setMobileActionsOverlayVisible(false), + ffi: _ffi, + ) + ]), + )); + } + }(), + // Use Overlay to enable rebuild every time on menu button click. + _ffi.ffiModel.pi.isSet.isTrue + ? Overlay( + initialEntries: [OverlayEntry(builder: remoteToolbar)]) + : remoteToolbar(context), + _ffi.ffiModel.pi.isSet.isFalse ? emptyOverlay() : Offstage(), + ], + ), + ], + ); + } + + return Scaffold( + backgroundColor: Theme.of(context).colorScheme.background, + body: Obx(() { + final imageReady = _ffi.ffiModel.pi.isSet.isTrue && + _ffi.ffiModel.waitForFirstImage.isFalse; + if (imageReady) { + // If the privacy mode(disable physical displays) is switched, + // we should not dismiss the dialog immediately. + if (DateTime.now().difference(togglePrivacyModeTime) > + const Duration(milliseconds: 3000)) { + // `dismissAll()` is to ensure that the state is clean. + // It's ok to call dismissAll() here. + _ffi.dialogManager.dismissAll(); + // Recreate the block state to refresh the state. + _blockableOverlayState = BlockableOverlayState(); + _blockableOverlayState.applyFfi(_ffi); + } + // Block the whole `bodyWidget()` when dialog shows. + return BlockableOverlay( + underlying: bodyWidget(), + state: _blockableOverlayState, + ); + } else { + // `_blockableOverlayState` is not recreated here. + // The toolbar's block state won't work properly when reconnecting, but that's okay. + return bodyWidget(); + } + }), + ); + } + + @override + Widget build(BuildContext context) { + super.build(context); + return WillPopScope( + onWillPop: () async { + clientClose(sessionId, _ffi); + return false; + }, + child: MultiProvider(providers: [ + ChangeNotifierProvider.value(value: _ffi.ffiModel), + ChangeNotifierProvider.value(value: _ffi.imageModel), + ChangeNotifierProvider.value(value: _ffi.cursorModel), + ChangeNotifierProvider.value(value: _ffi.canvasModel), + ChangeNotifierProvider.value(value: _ffi.recordingModel), + ], child: buildBody(context))); + } + + void enterView(PointerEnterEvent evt) { + _cursorOverImage.value = true; + _firstEnterImage.value = true; + if (_onEnterOrLeaveImage4Toolbar != null) { + try { + _onEnterOrLeaveImage4Toolbar!(true); + } catch (e) { + // + } + } + // See [onWindowBlur]. + if (!isWindows) { + if (!_rawKeyFocusNode.hasFocus) { + _rawKeyFocusNode.requestFocus(); + } + _ffi.inputModel.enterOrLeave(true); + } + } + + void leaveView(PointerExitEvent evt) { + if (_ffi.ffiModel.keyboard) { + _ffi.inputModel.tryMoveEdgeOnExit(evt.position); + } + + _cursorOverImage.value = false; + _firstEnterImage.value = false; + if (_onEnterOrLeaveImage4Toolbar != null) { + try { + _onEnterOrLeaveImage4Toolbar!(false); + } catch (e) { + // + } + } + // See [onWindowBlur]. + if (!isWindows) { + _ffi.inputModel.enterOrLeave(false); + } + } + + Widget _buildRawTouchAndPointerRegion( + Widget child, + PointerEnterEventListener? onEnter, + PointerExitEventListener? onExit, + ) { + return RawTouchGestureDetectorRegion( + child: _buildRawPointerMouseRegion(child, onEnter, onExit), + ffi: _ffi, + isCamera: true, + ); + } + + Widget _buildRawPointerMouseRegion( + Widget child, + PointerEnterEventListener? onEnter, + PointerExitEventListener? onExit, + ) { + return CameraRawPointerMouseRegion( + onEnter: onEnter, + onExit: onExit, + onPointerDown: (event) { + // A double check for blur status. + // Note: If there's an `onPointerDown` event is triggered, `_isWindowBlur` is expected being false. + // Sometimes the system does not send the necessary focus event to flutter. We should manually + // handle this inconsistent status by setting `_isWindowBlur` to false. So we can + // ensure the grab-key thread is running when our users are clicking the remote canvas. + if (_isWindowBlur) { + debugPrint( + "Unexpected status: onPointerDown is triggered while the remote window is in blur status"); + _isWindowBlur = false; + } + if (!_rawKeyFocusNode.hasFocus) { + _rawKeyFocusNode.requestFocus(); + } + }, + inputModel: _ffi.inputModel, + child: child, + ); + } + + Widget getBodyForDesktop(BuildContext context) { + var paints = [ + MouseRegion(onEnter: (evt) { + if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: false); + }, onExit: (evt) { + if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: true); + }, child: LayoutBuilder(builder: (context, constraints) { + final c = Provider.of(context, listen: false); + Future.delayed(Duration.zero, () => c.updateViewStyle()); + final peerDisplay = CurrentDisplayState.find(widget.id); + return Obx( + () => _ffi.ffiModel.pi.isSet.isFalse + ? Container(color: Colors.transparent) + : Obx(() { + _ffi.textureModel.updateCurrentDisplay(peerDisplay.value); + return ImagePaint( + id: widget.id, + cursorOverImage: _cursorOverImage, + listenerBuilder: (child) => _buildRawTouchAndPointerRegion( + child, enterView, leaveView), + ffi: _ffi, + ); + }), + ); + })) + ]; + + paints.add( + Positioned( + top: 10, + right: 10, + child: _buildRawTouchAndPointerRegion( + QualityMonitor(_ffi.qualityMonitorModel), null, null), + ), + ); + return Stack( + children: paints, + ); + } + + @override + bool get wantKeepAlive => true; +} + +class ImagePaint extends StatefulWidget { + final FFI ffi; + final String id; + final RxBool cursorOverImage; + final Widget Function(Widget)? listenerBuilder; + + ImagePaint( + {Key? key, + required this.ffi, + required this.id, + required this.cursorOverImage, + this.listenerBuilder}) + : super(key: key); + + @override + State createState() => _ImagePaintState(); +} + +class _ImagePaintState extends State { + String get id => widget.id; + RxBool get cursorOverImage => widget.cursorOverImage; + Widget Function(Widget)? get listenerBuilder => widget.listenerBuilder; + + @override + Widget build(BuildContext context) { + final m = Provider.of(context); + var c = Provider.of(context); + final s = c.scale; + + bool isViewOriginal() => c.viewStyle.style == kRemoteViewStyleOriginal; + + if (c.imageOverflow.isTrue && c.scrollStyle != ScrollStyle.scrollauto) { + final paintWidth = c.getDisplayWidth() * s; + final paintHeight = c.getDisplayHeight() * s; + final paintSize = Size(paintWidth, paintHeight); + final paintWidget = + m.useTextureRender || widget.ffi.ffiModel.pi.forceTextureRender + ? _BuildPaintTextureRender( + c, s, Offset.zero, paintSize, isViewOriginal()) + : _buildScrollbarNonTextureRender(m, paintSize, s); + return NotificationListener( + onNotification: (notification) { + c.updateScrollPercent(); + return false; + }, + child: Container( + child: _buildCrossScrollbarFromLayout( + context, + _buildListener(paintWidget), + c.size, + paintSize, + c.scrollHorizontal, + c.scrollVertical, + )), + ); + } else { + if (c.size.width > 0 && c.size.height > 0) { + final paintWidget = + m.useTextureRender || widget.ffi.ffiModel.pi.forceTextureRender + ? _BuildPaintTextureRender( + c, + s, + Offset( + isLinux ? c.x.toInt().toDouble() : c.x, + isLinux ? c.y.toInt().toDouble() : c.y, + ), + c.size, + isViewOriginal()) + : _buildScrollAutoNonTextureRender(m, c, s); + return Container(child: _buildListener(paintWidget)); + } else { + return Container(); + } + } + } + + Widget _buildScrollbarNonTextureRender( + ImageModel m, Size imageSize, double s) { + return CustomPaint( + size: imageSize, + painter: ImagePainter(image: m.image, x: 0, y: 0, scale: s), + ); + } + + Widget _buildScrollAutoNonTextureRender( + ImageModel m, CanvasModel c, double s) { + return CustomPaint( + size: Size(c.size.width, c.size.height), + painter: ImagePainter(image: m.image, x: c.x / s, y: c.y / s, scale: s), + ); + } + + Widget _BuildPaintTextureRender( + CanvasModel c, double s, Offset offset, Size size, bool isViewOriginal) { + final ffiModel = c.parent.target!.ffiModel; + final displays = ffiModel.pi.getCurDisplays(); + final children = []; + final rect = ffiModel.rect; + if (rect == null) { + return Container(); + } + final curDisplay = ffiModel.pi.currentDisplay; + for (var i = 0; i < displays.length; i++) { + final textureId = widget.ffi.textureModel + .getTextureId(curDisplay == kAllDisplayValue ? i : curDisplay); + if (true) { + // both "textureId.value != -1" and "true" seems ok + children.add(Positioned( + left: (displays[i].x - rect.left) * s + offset.dx, + top: (displays[i].y - rect.top) * s + offset.dy, + width: displays[i].width * s, + height: displays[i].height * s, + child: Obx(() => Texture( + textureId: textureId.value, + filterQuality: + isViewOriginal ? FilterQuality.none : FilterQuality.low, + )), + )); + } + } + return SizedBox( + width: size.width, + height: size.height, + child: Stack(children: children), + ); + } + + MouseCursor _buildCustomCursor(BuildContext context, double scale) { + final cursor = Provider.of(context); + final cache = cursor.cache ?? preDefaultCursor.cache; + return buildCursorOfCache(cursor, scale, cache); + } + + MouseCursor _buildDisabledCursor(BuildContext context, double scale) { + final cursor = Provider.of(context); + final cache = preForbiddenCursor.cache; + return buildCursorOfCache(cursor, scale, cache); + } + + Widget _buildCrossScrollbarFromLayout( + BuildContext context, + Widget child, + Size layoutSize, + Size size, + ScrollController horizontal, + ScrollController vertical, + ) { + var widget = child; + if (layoutSize.width < size.width) { + widget = ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), + child: SingleChildScrollView( + controller: horizontal, + scrollDirection: Axis.horizontal, + physics: cursorOverImage.isTrue + ? const NeverScrollableScrollPhysics() + : null, + child: widget, + ), + ); + } else { + widget = Row( + children: [ + Container( + width: ((layoutSize.width - size.width) ~/ 2).toDouble(), + ), + widget, + ], + ); + } + if (layoutSize.height < size.height) { + widget = ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), + child: SingleChildScrollView( + controller: vertical, + physics: cursorOverImage.isTrue + ? const NeverScrollableScrollPhysics() + : null, + child: widget, + ), + ); + } else { + widget = Column( + children: [ + Container( + height: ((layoutSize.height - size.height) ~/ 2).toDouble(), + ), + widget, + ], + ); + } + if (layoutSize.width < size.width) { + widget = RawScrollbar( + thickness: kScrollbarThickness, + thumbColor: Colors.grey, + controller: horizontal, + thumbVisibility: false, + trackVisibility: false, + notificationPredicate: layoutSize.height < size.height + ? (notification) => notification.depth == 1 + : defaultScrollNotificationPredicate, + child: widget, + ); + } + if (layoutSize.height < size.height) { + widget = RawScrollbar( + thickness: kScrollbarThickness, + thumbColor: Colors.grey, + controller: vertical, + thumbVisibility: false, + trackVisibility: false, + child: widget, + ); + } + + return Container( + child: widget, + width: layoutSize.width, + height: layoutSize.height, + ); + } + + Widget _buildListener(Widget child) { + if (listenerBuilder != null) { + return listenerBuilder!(child); + } else { + return child; + } + } +} diff --git a/flutter/lib/desktop/pages/view_camera_tab_page.dart b/flutter/lib/desktop/pages/view_camera_tab_page.dart new file mode 100644 index 000000000..36fa623ff --- /dev/null +++ b/flutter/lib/desktop/pages/view_camera_tab_page.dart @@ -0,0 +1,522 @@ +import 'dart:convert'; +import 'dart:async'; +import 'dart:ui' as ui; + +import 'package:desktop_multi_window/desktop_multi_window.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hbb/common.dart'; +import 'package:flutter_hbb/common/shared_state.dart'; +import 'package:flutter_hbb/common/widgets/dialog.dart'; +import 'package:flutter_hbb/consts.dart'; +import 'package:flutter_hbb/models/input_model.dart'; +import 'package:flutter_hbb/models/state_model.dart'; +import 'package:flutter_hbb/desktop/pages/view_camera_page.dart'; +import 'package:flutter_hbb/desktop/widgets/remote_toolbar.dart'; +import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart'; +import 'package:flutter_hbb/desktop/widgets/material_mod_popup_menu.dart' + as mod_menu; +import 'package:flutter_hbb/desktop/widgets/popup_menu.dart'; +import 'package:flutter_hbb/utils/multi_window_manager.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:get/get.dart'; +import 'package:bot_toast/bot_toast.dart'; + +import '../../models/platform_model.dart'; + +class _MenuTheme { + static const Color blueColor = MyTheme.button; + // kMinInteractiveDimension + static const double height = 20.0; + static const double dividerHeight = 12.0; +} + +class ViewCameraTabPage extends StatefulWidget { + final Map params; + + const ViewCameraTabPage({Key? key, required this.params}) : super(key: key); + + @override + State createState() => _ViewCameraTabPageState(params); +} + +class _ViewCameraTabPageState extends State { + final tabController = + Get.put(DesktopTabController(tabType: DesktopTabType.viewCamera)); + final contentKey = UniqueKey(); + static const IconData selectedIcon = Icons.desktop_windows_sharp; + static const IconData unselectedIcon = Icons.desktop_windows_outlined; + + String? peerId; + bool _isScreenRectSet = false; + int? _display; + + var connectionMap = RxList.empty(growable: true); + + _ViewCameraTabPageState(Map params) { + RemoteCountState.init(); + peerId = params['id']; + final sessionId = params['session_id']; + final tabWindowId = params['tab_window_id']; + final display = params['display']; + final displays = params['displays']; + final screenRect = parseParamScreenRect(params); + _isScreenRectSet = screenRect != null; + _display = display as int?; + tryMoveToScreenAndSetFullscreen(screenRect); + if (peerId != null) { + ConnectionTypeState.init(peerId!); + tabController.onSelected = (id) { + final viewCameraPage = tabController.widget(id); + if (viewCameraPage is ViewCameraPage) { + final ffi = viewCameraPage.ffi; + bind.setCurSessionId(sessionId: ffi.sessionId); + } + WindowController.fromWindowId(params['windowId']) + .setTitle(getWindowNameWithId(id)); + UnreadChatCountState.find(id).value = 0; + }; + tabController.add(TabInfo( + key: peerId!, + label: peerId!, + selectedIcon: selectedIcon, + unselectedIcon: unselectedIcon, + onTabCloseButton: () async { + if (await desktopTryShowTabAuditDialogCloseCancelled( + id: peerId!, + tabController: tabController, + )) { + return; + } + tabController.closeBy(peerId!); + }, + page: ViewCameraPage( + key: ValueKey(peerId), + id: peerId!, + sessionId: sessionId == null ? null : SessionID(sessionId), + tabWindowId: tabWindowId, + display: display, + displays: displays?.cast(), + password: params['password'], + toolbarState: ToolbarState(), + tabController: tabController, + connToken: params['connToken'], + forceRelay: params['forceRelay'], + isSharedPassword: params['isSharedPassword'], + ), + )); + _update_remote_count(); + } + tabController.onRemoved = (_, id) => onRemoveId(id); + rustDeskWinManager.setMethodHandler(_remoteMethodHandler); + } + + @override + void initState() { + super.initState(); + + if (!_isScreenRectSet) { + Future.delayed(Duration.zero, () { + restoreWindowPosition( + WindowType.ViewCamera, + windowId: windowId(), + peerId: tabController.state.value.tabs.isEmpty + ? null + : tabController.state.value.tabs[0].key, + display: _display, + ); + }); + } + } + + @override + Widget build(BuildContext context) { + final child = Scaffold( + backgroundColor: Theme.of(context).colorScheme.background, + body: DesktopTab( + controller: tabController, + onWindowCloseButton: handleWindowCloseButton, + tail: const AddButton(), + selectedBorderColor: MyTheme.accent, + pageViewBuilder: (pageView) => pageView, + labelGetter: DesktopTab.tablabelGetter, + tabBuilder: (key, icon, label, themeConf) => Obx(() { + final connectionType = ConnectionTypeState.find(key); + if (!connectionType.isValid()) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + icon, + label, + ], + ); + } else { + bool secure = + connectionType.secure.value == ConnectionType.strSecure; + bool direct = + connectionType.direct.value == ConnectionType.strDirect; + String msgConn = getConnectionText( + secure, direct, connectionType.stream_type.value); + var msgFingerprint = '${translate('Fingerprint')}:\n'; + var fingerprint = FingerprintState.find(key).value; + if (fingerprint.isEmpty) { + fingerprint = 'N/A'; + } + if (fingerprint.length > 5 * 8) { + var first = fingerprint.substring(0, 39); + var second = fingerprint.substring(40); + msgFingerprint += '$first\n$second'; + } else { + msgFingerprint += fingerprint; + } + + final tab = Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + icon, + Tooltip( + message: '$msgConn\n$msgFingerprint', + child: SvgPicture.asset( + 'assets/${connectionType.secure.value}${connectionType.direct.value}.svg', + width: themeConf.iconSize, + height: themeConf.iconSize, + ).paddingOnly(right: 5), + ), + label, + unreadMessageCountBuilder(UnreadChatCountState.find(key)) + .marginOnly(left: 4), + ], + ); + + return Listener( + onPointerDown: (e) { + if (e.kind != ui.PointerDeviceKind.mouse) { + return; + } + final viewCameraPage = tabController.state.value.tabs + .firstWhere((tab) => tab.key == key) + .page as ViewCameraPage; + if (viewCameraPage.ffi.ffiModel.pi.isSet.isTrue && + e.buttons == 2) { + showRightMenu( + (CancelFunc cancelFunc) { + return _tabMenuBuilder(key, cancelFunc); + }, + target: e.position, + ); + } + }, + child: tab, + ); + } + }), + ), + ); + final tabWidget = isLinux + ? buildVirtualWindowFrame(context, child) + : workaroundWindowBorder( + context, + Obx(() => Container( + decoration: BoxDecoration( + border: Border.all( + color: MyTheme.color(context).border!, + width: stateGlobal.windowBorderWidth.value), + ), + child: child, + ))); + return isMacOS || kUseCompatibleUiMode + ? tabWidget + : Obx(() => SubWindowDragToResizeArea( + key: contentKey, + child: tabWidget, + // Specially configured for a better resize area and remote control. + childPadding: kDragToResizeAreaPadding, + resizeEdgeSize: stateGlobal.resizeEdgeSize.value, + enableResizeEdges: subWindowManagerEnableResizeEdges, + windowId: stateGlobal.windowId, + )); + } + + // Note: Some dup code to ../widgets/remote_toolbar + Widget _tabMenuBuilder(String key, CancelFunc cancelFunc) { + final List> menu = []; + const EdgeInsets padding = EdgeInsets.only(left: 8.0, right: 5.0); + final viewCameraPage = tabController.state.value.tabs + .firstWhere((tab) => tab.key == key) + .page as ViewCameraPage; + final ffi = viewCameraPage.ffi; + final sessionId = ffi.sessionId; + final toolbarState = viewCameraPage.toolbarState; + menu.addAll([ + MenuEntryButton( + childBuilder: (TextStyle? style) => Obx(() => Text( + translate( + toolbarState.hide.isTrue ? 'Show Toolbar' : 'Hide Toolbar'), + style: style, + )), + proc: () { + toolbarState.switchHide(sessionId); + cancelFunc(); + }, + padding: padding, + ), + ]); + + if (tabController.state.value.tabs.length > 1) { + final splitAction = MenuEntryButton( + childBuilder: (TextStyle? style) => Text( + translate('Move tab to new window'), + style: style, + ), + proc: () async { + await DesktopMultiWindow.invokeMethod( + kMainWindowId, + kWindowEventMoveTabToNewWindow, + '${windowId()},$key,$sessionId,ViewCamera'); + cancelFunc(); + }, + padding: padding, + ); + menu.insert(1, splitAction); + } + + menu.addAll([ + MenuEntryDivider(), + MenuEntryButton( + childBuilder: (TextStyle? style) => Text( + translate('Copy Fingerprint'), + style: style, + ), + proc: () => onCopyFingerprint(FingerprintState.find(key).value), + padding: padding, + dismissOnClicked: true, + dismissCallback: cancelFunc, + ), + MenuEntryButton( + childBuilder: (TextStyle? style) => Text( + translate('Close'), + style: style, + ), + proc: () async { + if (await desktopTryShowTabAuditDialogCloseCancelled( + id: key, + tabController: tabController, + )) { + return; + } + tabController.closeBy(key); + cancelFunc(); + }, + padding: padding, + ) + ]); + + return mod_menu.PopupMenu( + items: menu + .map((entry) => entry.build( + context, + const MenuConfig( + commonColor: _MenuTheme.blueColor, + height: _MenuTheme.height, + dividerHeight: _MenuTheme.dividerHeight, + ))) + .expand((i) => i) + .toList(), + ); + } + + void onRemoveId(String id) async { + if (tabController.state.value.tabs.isEmpty) { + // Keep calling until the window status is hidden. + // + // Workaround for Windows: + // If you click other buttons and close in msgbox within a very short period of time, the close may fail. + // `await WindowController.fromWindowId(windowId()).close();`. + Future loopCloseWindow() async { + int c = 0; + final windowController = WindowController.fromWindowId(windowId()); + while (c < 20 && + tabController.state.value.tabs.isEmpty && + (!await windowController.isHidden())) { + await windowController.close(); + await Future.delayed(Duration(milliseconds: 100)); + c++; + } + } + + loopCloseWindow(); + } + ConnectionTypeState.delete(id); + _update_remote_count(); + } + + int windowId() { + return widget.params["windowId"]; + } + + Future handleWindowCloseButton() async { + final connLength = tabController.length; + if (connLength == 1) { + if (await desktopTryShowTabAuditDialogCloseCancelled( + id: tabController.state.value.tabs[0].key, + tabController: tabController, + )) { + return false; + } + } + if (connLength <= 1) { + tabController.clear(); + return true; + } else { + final bool res; + if (!option2bool(kOptionEnableConfirmClosingTabs, + bind.mainGetLocalOption(key: kOptionEnableConfirmClosingTabs))) { + res = true; + } else { + res = await closeConfirmDialog(); + } + if (res) { + tabController.clear(); + } + return res; + } + } + + _update_remote_count() => + RemoteCountState.find().value = tabController.length; + + Future _remoteMethodHandler(call, fromWindowId) async { + debugPrint( + "[View Camera Page] call ${call.method} with args ${call.arguments} from window $fromWindowId"); + + dynamic returnValue; + // for simplify, just replace connectionId + if (call.method == kWindowEventNewViewCamera) { + final args = jsonDecode(call.arguments); + final id = args['id']; + final sessionId = args['session_id']; + final tabWindowId = args['tab_window_id']; + final display = args['display']; + final displays = args['displays']; + final screenRect = parseParamScreenRect(args); + final prePeerCount = tabController.length; + Future.delayed(Duration.zero, () async { + if (stateGlobal.fullscreen.isTrue) { + await WindowController.fromWindowId(windowId()).setFullscreen(false); + stateGlobal.setFullscreen(false, procWnd: false); + } + await setNewConnectWindowFrame(windowId(), id!, prePeerCount, + WindowType.ViewCamera, display, screenRect); + Future.delayed(Duration(milliseconds: isWindows ? 100 : 0), () async { + await windowOnTop(windowId()); + }); + }); + ConnectionTypeState.init(id); + tabController.add(TabInfo( + key: id, + label: id, + selectedIcon: selectedIcon, + unselectedIcon: unselectedIcon, + onTabCloseButton: () async { + if (await desktopTryShowTabAuditDialogCloseCancelled( + id: id, + tabController: tabController, + )) { + return; + } + tabController.closeBy(id); + }, + page: ViewCameraPage( + key: ValueKey(id), + id: id, + sessionId: sessionId == null ? null : SessionID(sessionId), + tabWindowId: tabWindowId, + display: display, + displays: displays?.cast(), + password: args['password'], + toolbarState: ToolbarState(), + tabController: tabController, + connToken: args['connToken'], + forceRelay: args['forceRelay'], + isSharedPassword: args['isSharedPassword'], + ), + )); + } else if (call.method == kWindowDisableGrabKeyboard) { + // ??? + } else if (call.method == "onDestroy") { + tabController.clear(); + } else if (call.method == kWindowActionRebuild) { + reloadCurrentWindow(); + } else if (call.method == kWindowEventActiveSession) { + final jumpOk = tabController.jumpToByKey(call.arguments); + if (jumpOk) { + windowOnTop(windowId()); + } + return jumpOk; + } else if (call.method == kWindowEventActiveDisplaySession) { + final args = jsonDecode(call.arguments); + final id = args['id']; + final display = args['display']; + final jumpOk = + tabController.jumpToByKeyAndDisplay(id, display, isCamera: true); + if (jumpOk) { + windowOnTop(windowId()); + } + return jumpOk; + } else if (call.method == kWindowEventGetRemoteList) { + return tabController.state.value.tabs + .map((e) => e.key) + .toList() + .join(','); + } else if (call.method == kWindowEventGetSessionIdList) { + return tabController.state.value.tabs + .map((e) => '${e.key},${(e.page as ViewCameraPage).ffi.sessionId}') + .toList() + .join(';'); + } else if (call.method == kWindowEventGetCachedSessionData) { + // Ready to show new window and close old tab. + final args = jsonDecode(call.arguments); + final id = args['id']; + final close = args['close']; + try { + final viewCameraPage = tabController.state.value.tabs + .firstWhere((tab) => tab.key == id) + .page as ViewCameraPage; + returnValue = viewCameraPage.ffi.ffiModel.cachedPeerData.toString(); + } catch (e) { + debugPrint('Failed to get cached session data: $e'); + } + if (close && returnValue != null) { + closeSessionOnDispose[id] = false; + tabController.closeBy(id); + } + } else if (call.method == kWindowEventRemoteWindowCoords) { + final viewCameraPage = + tabController.state.value.selectedTabInfo.page as ViewCameraPage; + final ffi = viewCameraPage.ffi; + final displayRect = ffi.ffiModel.displaysRect(); + if (displayRect != null) { + final wc = WindowController.fromWindowId(windowId()); + Rect? frame; + try { + frame = await wc.getFrame(); + } catch (e) { + debugPrint( + "Failed to get frame of window $windowId, it may be hidden"); + } + if (frame != null) { + ffi.cursorModel.moveLocal(0, 0); + final coords = RemoteWindowCoords( + frame, + CanvasCoords.fromCanvasModel(ffi.canvasModel), + CursorCoords.fromCursorModel(ffi.cursorModel), + displayRect); + returnValue = jsonEncode(coords.toJson()); + } + } + } else if (call.method == kWindowEventSetFullscreen) { + stateGlobal.setFullscreen(call.arguments == 'true'); + } + _update_remote_count(); + return returnValue; + } +} diff --git a/flutter/lib/desktop/screen/desktop_terminal_screen.dart b/flutter/lib/desktop/screen/desktop_terminal_screen.dart new file mode 100644 index 000000000..301489c86 --- /dev/null +++ b/flutter/lib/desktop/screen/desktop_terminal_screen.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hbb/common.dart'; +import 'package:provider/provider.dart'; + +import 'package:flutter_hbb/desktop/pages/terminal_tab_page.dart'; + +class DesktopTerminalScreen extends StatelessWidget { + final Map params; + + const DesktopTerminalScreen({Key? key, required this.params}) + : super(key: key); + + @override + Widget build(BuildContext context) { + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: gFFI.ffiModel), + ], + child: Scaffold( + backgroundColor: isLinux ? Colors.transparent : null, + body: TerminalTabPage( + params: params, + ), + ), + ); + } +} diff --git a/flutter/lib/desktop/screen/desktop_view_camera_screen.dart b/flutter/lib/desktop/screen/desktop_view_camera_screen.dart new file mode 100644 index 000000000..a845b89d0 --- /dev/null +++ b/flutter/lib/desktop/screen/desktop_view_camera_screen.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hbb/common.dart'; +import 'package:flutter_hbb/desktop/pages/view_camera_tab_page.dart'; +import 'package:flutter_hbb/models/platform_model.dart'; +import 'package:flutter_hbb/models/state_model.dart'; +import 'package:provider/provider.dart'; + +/// multi-tab desktop remote screen +class DesktopViewCameraScreen extends StatelessWidget { + final Map params; + + DesktopViewCameraScreen({Key? key, required this.params}) : super(key: key) { + bind.mainInitInputSource(); + stateGlobal.getInputSource(force: true); + } + + @override + Widget build(BuildContext context) { + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: gFFI.ffiModel), + ChangeNotifierProvider.value(value: gFFI.imageModel), + ChangeNotifierProvider.value(value: gFFI.cursorModel), + ChangeNotifierProvider.value(value: gFFI.canvasModel), + ], + child: Scaffold( + // Set transparent background for padding the resize area out of the flutter view. + // This allows the wallpaper goes through our resize area. (Linux only now). + backgroundColor: isLinux ? Colors.transparent : null, + body: ViewCameraTabPage( + params: params, + ), + )); + } +} diff --git a/flutter/lib/desktop/widgets/remote_toolbar.dart b/flutter/lib/desktop/widgets/remote_toolbar.dart index 839ea1a81..44a2dc1c7 100644 --- a/flutter/lib/desktop/widgets/remote_toolbar.dart +++ b/flutter/lib/desktop/widgets/remote_toolbar.dart @@ -4,6 +4,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hbb/common/widgets/audio_input.dart'; +import 'package:flutter_hbb/common/widgets/dialog.dart'; import 'package:flutter_hbb/common/widgets/toolbar.dart'; import 'package:flutter_hbb/models/chat_model.dart'; import 'package:flutter_hbb/models/state_model.dart'; @@ -24,12 +25,232 @@ import '../../models/platform_model.dart'; import '../../common/shared_state.dart'; import './popup_menu.dart'; import './kb_layout_type_chooser.dart'; +import 'package:flutter_hbb/utils/scale.dart'; +import 'package:flutter_hbb/common/widgets/custom_scale_base.dart'; + +enum _ToolbarEdge { top, right, bottom, left } + +_ToolbarEdge _parseToolbarEdge(String? s) { + switch (s) { + case 'right': + return _ToolbarEdge.right; + case 'bottom': + return _ToolbarEdge.bottom; + case 'left': + return _ToolbarEdge.left; + default: + return _ToolbarEdge.top; + } +} + +String _toolbarEdgeToString(_ToolbarEdge e) { + switch (e) { + case _ToolbarEdge.top: + return 'top'; + case _ToolbarEdge.right: + return 'right'; + case _ToolbarEdge.bottom: + return 'bottom'; + case _ToolbarEdge.left: + return 'left'; + } +} + +bool _isHorizontalEdge(_ToolbarEdge e) => + e == _ToolbarEdge.top || e == _ToolbarEdge.bottom; + +const _legacyRemoteMenubarDragX = 'remote-menubar-drag-x'; + +double _clampToolbarFraction(double fraction, double left, double right) { + if (fraction < left) fraction = left; + if (fraction > right) fraction = right; + return fraction; +} + +Size _toolbarSizeForEdge(_ToolbarEdge edge, Size? measured) { + final isHorizontal = _isHorizontalEdge(edge); + final fallback = isHorizontal ? const Size(360, 40) : const Size(40, 360); + final size = measured ?? fallback; + final long = size.longestSide; + final short = size.shortestSide; + return Size(isHorizontal ? long : short, isHorizontal ? short : long); +} + +Offset _toolbarOffsetForEdge({ + required _ToolbarEdge edge, + required double fraction, + required Size parentSize, + required Size toolbarSize, +}) { + final xTravel = parentSize.width - toolbarSize.width; + final yTravel = parentSize.height - toolbarSize.height; + switch (edge) { + case _ToolbarEdge.top: + return Offset(xTravel * fraction, 0); + case _ToolbarEdge.bottom: + return Offset(xTravel * fraction, yTravel); + case _ToolbarEdge.left: + return Offset(0, yTravel * fraction); + case _ToolbarEdge.right: + return Offset(xTravel, yTravel * fraction); + } +} + +double _fractionForAlignedDrag({ + required double cursor, + required double grabOffset, + required double parentExtent, + required double toolbarExtent, + required double left, + required double right, +}) { + final travelExtent = parentExtent - toolbarExtent; + if (travelExtent <= 0) { + return _clampToolbarFraction(0.5, left, right); + } + return _clampToolbarFraction( + (cursor - grabOffset) / travelExtent, left, right); +} + +({double left, double right}) _fractionBoundsForEdge( + _ToolbarEdge edge, + double left, + double right, +) { + return _isHorizontalEdge(edge) + ? (left: left, right: right) + : (left: 0, right: 1); +} + +String _toolbarRawFraction({ + required bool multiEdgeEnabled, + required _ToolbarEdge edge, + required String? savedFraction, + required String? legacyFraction, +}) { + if (!multiEdgeEnabled) { + return (legacyFraction != null && legacyFraction.isNotEmpty) + ? legacyFraction + : '0.5'; + } + if (savedFraction != null && savedFraction.isNotEmpty) { + return savedFraction; + } + if (edge == _ToolbarEdge.top && + legacyFraction != null && + legacyFraction.isNotEmpty) { + return legacyFraction; + } + return '0.5'; +} + +// Returns the alignment for the wrapper Align that positions the entire +// toolbar against the given edge at the given fraction along that edge. +// Alignment uses [-1, 1] coordinates (0 = center). +Alignment _alignmentForEdge(_ToolbarEdge edge, double fraction) { + final f = fraction * 2 - 1; + switch (edge) { + case _ToolbarEdge.top: + return Alignment(f, -1); + case _ToolbarEdge.bottom: + return Alignment(f, 1); + case _ToolbarEdge.left: + return Alignment(-1, f); + case _ToolbarEdge.right: + return Alignment(1, f); + } +} + +// The drag handle hangs off the side of the toolbar facing away from the +// docked edge, so the icons themselves sit flush against that edge. +BorderRadius _collapseHandleBorderRadius(_ToolbarEdge edge) { + const r = Radius.circular(5); + switch (edge) { + case _ToolbarEdge.top: + return const BorderRadius.vertical(bottom: r); + case _ToolbarEdge.bottom: + return const BorderRadius.vertical(top: r); + case _ToolbarEdge.left: + return const BorderRadius.horizontal(right: r); + case _ToolbarEdge.right: + return const BorderRadius.horizontal(left: r); + } +} + +int _monitorMenuQuarterTurns(_ToolbarEdge edge) { + switch (edge) { + case _ToolbarEdge.left: + return 1; + case _ToolbarEdge.right: + return 3; + case _ToolbarEdge.top: + case _ToolbarEdge.bottom: + return 0; + } +} + +IconData _toolbarCollapseIcon(_ToolbarEdge edge, bool isCollapsed) { + switch (edge) { + case _ToolbarEdge.top: + return isCollapsed ? Icons.expand_more : Icons.expand_less; + case _ToolbarEdge.bottom: + return isCollapsed ? Icons.expand_less : Icons.expand_more; + case _ToolbarEdge.left: + return isCollapsed ? Icons.chevron_right : Icons.chevron_left; + case _ToolbarEdge.right: + return isCollapsed ? Icons.chevron_left : Icons.chevron_right; + } +} + +class _ToolbarDockingOptions { + _ToolbarDockingOptions({ + required this.edge, + required this.fraction, + required this.multiEdgeEnabled, + }); + + _ToolbarEdge edge; + double fraction; + bool multiEdgeEnabled; +} + +final _toolbarDockingOptionsBySession = {}; + +String _toolbarDockingCacheKey(SessionID sessionId) => sessionId.toString(); + +_ToolbarDockingOptions? _cachedToolbarDockingOptions(SessionID sessionId) => + _toolbarDockingOptionsBySession[_toolbarDockingCacheKey(sessionId)]; + +void _cacheToolbarDockingOptions({ + required SessionID sessionId, + required _ToolbarEdge edge, + required double fraction, + required bool multiEdgeEnabled, +}) { + final key = _toolbarDockingCacheKey(sessionId); + final cached = _toolbarDockingOptionsBySession[key]; + if (cached == null) { + _toolbarDockingOptionsBySession[key] = _ToolbarDockingOptions( + edge: edge, + fraction: fraction, + multiEdgeEnabled: multiEdgeEnabled, + ); + return; + } + cached.edge = edge; + cached.fraction = fraction; + cached.multiEdgeEnabled = multiEdgeEnabled; +} class ToolbarState { late RxBool _pin; - bool isShowInited = false; - RxBool show = false.obs; + RxBool collapse = false.obs; + RxBool hide = false.obs; + + // Track initialization state to prevent flickering + final RxBool initialized = false.obs; + bool _isInitializing = false; ToolbarState() { _pin = RxBool(false); @@ -50,19 +271,39 @@ class ToolbarState { bool get pin => _pin.value; - switchShow(SessionID sessionId) async { - bind.sessionToggleOption( - sessionId: sessionId, value: kOptionCollapseToolbar); - show.value = !show.value; + /// Initialize all toolbar states from session options. + /// This should be called once when the toolbar is first created. + Future init(SessionID sessionId) async { + if (initialized.value || _isInitializing) return; + _isInitializing = true; + + try { + // Load both states in parallel for better performance + final results = await Future.wait([ + bind.sessionGetToggleOption( + sessionId: sessionId, arg: kOptionCollapseToolbar), + bind.sessionGetToggleOption( + sessionId: sessionId, arg: kOptionHideToolbar), + ]); + + collapse.value = results[0] ?? false; + hide.value = results[1] ?? false; + } finally { + _isInitializing = false; + initialized.value = true; + } } - initShow(SessionID sessionId) async { - if (!isShowInited) { - show.value = !(await bind.sessionGetToggleOption( - sessionId: sessionId, arg: kOptionCollapseToolbar) ?? - false); - isShowInited = true; - } + switchCollapse(SessionID sessionId) async { + bind.sessionToggleOption( + sessionId: sessionId, value: kOptionCollapseToolbar); + collapse.value = !collapse.value; + } + + // Switch hide state for entire toolbar visibility + switchHide(SessionID sessionId) async { + bind.sessionToggleOption(sessionId: sessionId, value: kOptionHideToolbar); + hide.value = !hide.value; } switchPin() async { @@ -151,129 +392,6 @@ class _ToolbarTheme { typedef DismissFunc = void Function(); class RemoteMenuEntry { - static MenuEntryRadios viewStyle( - String remoteId, - FFI ffi, - EdgeInsets padding, { - DismissFunc? dismissFunc, - DismissCallback? dismissCallback, - RxString? rxViewStyle, - }) { - return MenuEntryRadios( - text: translate('Ratio'), - optionsGetter: () => [ - MenuEntryRadioOption( - text: translate('Scale original'), - value: kRemoteViewStyleOriginal, - dismissOnClicked: true, - dismissCallback: dismissCallback, - ), - MenuEntryRadioOption( - text: translate('Scale adaptive'), - value: kRemoteViewStyleAdaptive, - dismissOnClicked: true, - dismissCallback: dismissCallback, - ), - ], - curOptionGetter: () async { - // null means peer id is not found, which there's no need to care about - final viewStyle = - await bind.sessionGetViewStyle(sessionId: ffi.sessionId) ?? ''; - if (rxViewStyle != null) { - rxViewStyle.value = viewStyle; - } - return viewStyle; - }, - optionSetter: (String oldValue, String newValue) async { - await bind.sessionSetViewStyle( - sessionId: ffi.sessionId, value: newValue); - if (rxViewStyle != null) { - rxViewStyle.value = newValue; - } - ffi.canvasModel.updateViewStyle(); - if (dismissFunc != null) { - dismissFunc(); - } - }, - padding: padding, - dismissOnClicked: true, - dismissCallback: dismissCallback, - ); - } - - static MenuEntrySwitch2 showRemoteCursor( - String remoteId, - SessionID sessionId, - EdgeInsets padding, { - DismissFunc? dismissFunc, - DismissCallback? dismissCallback, - }) { - final state = ShowRemoteCursorState.find(remoteId); - final optKey = 'show-remote-cursor'; - return MenuEntrySwitch2( - switchType: SwitchType.scheckbox, - text: translate('Show remote cursor'), - getter: () { - return state; - }, - setter: (bool v) async { - await bind.sessionToggleOption(sessionId: sessionId, value: optKey); - state.value = - bind.sessionGetToggleOptionSync(sessionId: sessionId, arg: optKey); - if (dismissFunc != null) { - dismissFunc(); - } - }, - padding: padding, - dismissOnClicked: true, - dismissCallback: dismissCallback, - ); - } - - static MenuEntrySwitch disableClipboard( - SessionID sessionId, - EdgeInsets? padding, { - DismissFunc? dismissFunc, - DismissCallback? dismissCallback, - }) { - return createSwitchMenuEntry( - sessionId, - 'Disable clipboard', - 'disable-clipboard', - padding, - true, - dismissCallback: dismissCallback, - ); - } - - static MenuEntrySwitch createSwitchMenuEntry( - SessionID sessionId, - String text, - String option, - EdgeInsets? padding, - bool dismissOnClicked, { - DismissFunc? dismissFunc, - DismissCallback? dismissCallback, - }) { - return MenuEntrySwitch( - switchType: SwitchType.scheckbox, - text: translate(text), - getter: () async { - return bind.sessionGetToggleOptionSync( - sessionId: sessionId, arg: option); - }, - setter: (bool v) async { - await bind.sessionToggleOption(sessionId: sessionId, value: option); - if (dismissFunc != null) { - dismissFunc(); - } - }, - padding: padding, - dismissOnClicked: dismissOnClicked, - dismissCallback: dismissCallback, - ); - } - static MenuEntryButton insertLock( SessionID sessionId, EdgeInsets? padding, { @@ -346,8 +464,26 @@ class RemoteToolbar extends StatefulWidget { class _RemoteToolbarState extends State { late Debouncer _debouncerHide; bool _isCursorOverImage = false; - final _fractionX = 0.5.obs; + final _fraction = 0.5.obs; + final _edge = _ToolbarEdge.top.obs; final _dragging = false.obs; + // Live drag preview: where the toolbar would dock if the user dropped now. + final _previewEdge = Rxn<_ToolbarEdge>(); + final _previewFraction = Rxn(); + // Measured size of the live toolbar, so the preview ghost matches reality + // (collapsed handle vs expanded toolbar). Updated after every layout pass. + final _toolbarSize = Rxn(); + final _toolbarKey = GlobalKey(debugLabel: 'remote_toolbar_root'); + // When false (default), the toolbar stays on the top edge and the drag + // handle just slides it horizontally — preserving long-standing UX while + // still fixing the bug where dragging only moved the handle. When true, + // the user has opted into multi-edge docking with nearest-edge snap. + // Kept in sync after settings-triggered rebuilds. + final _multiEdgeEnabled = false.obs; + final _dockingOptionsInitialized = false.obs; + bool _pendingDockingOptionSync = false; + int _dockingOptionSyncSerial = 0; + int _dragEpoch = 0; int get windowId => stateGlobal.windowId; @@ -357,7 +493,8 @@ class _RemoteToolbarState extends State { // setState(() {}); } - RxBool get show => widget.state.show; + RxBool get collapse => widget.state.collapse; + RxBool get hide => widget.state.hide; bool get pin => widget.state.pin; PeerInfo get pi => widget.ffi.ffiModel.pi; @@ -368,16 +505,146 @@ class _RemoteToolbarState extends State { void _minimize() async => await WindowController.fromWindowId(windowId).minimize(); + Future _syncDockingOptions({required bool force}) async { + final syncSerial = ++_dockingOptionSyncSerial; + if (_dragging.isTrue) { + _deferDockingOptionsSync(); + return; + } + final dragEpoch = _dragEpoch; + + // Use the canonical helper so the option's documented default semantics + // apply (allow-* prefix => default false). Keeping it raw-string would + // diverge from how _OptionCheckBox displays the same key. + final multiEdgeEnabled = + mainGetLocalBoolOptionSync(kOptionAllowMultiEdgeToolbarDock); + final cached = _cachedToolbarDockingOptions(widget.ffi.sessionId); + if (cached == null && pi.isSet.isFalse) { + return; + } + final hadDockingOptions = cached != null; + final wasMultiEdgeEnabled = + cached?.multiEdgeEnabled ?? _multiEdgeEnabled.value; + if (!force && + hadDockingOptions && + wasMultiEdgeEnabled == multiEdgeEnabled) { + _pendingDockingOptionSync = false; + return; + } + + final savedFraction = await bind.sessionGetOption( + sessionId: widget.ffi.sessionId, arg: kOptionRemoteMenubarFraction); + // Backward compat: legacy horizontal-only position. + final legacyFraction = await bind.sessionGetOption( + sessionId: widget.ffi.sessionId, arg: _legacyRemoteMenubarDragX); + if (!mounted || syncSerial != _dockingOptionSyncSerial) return; + + var nextEdge = _edge.value; + var savedFractionForNextEdge = savedFraction; + var keepCurrentPosition = false; + if (!multiEdgeEnabled) { + nextEdge = _ToolbarEdge.top; + } else if (force || wasMultiEdgeEnabled || cached == null) { + final edgeStr = await bind.sessionGetOption( + sessionId: widget.ffi.sessionId, arg: kOptionRemoteMenubarEdge); + if (!mounted || syncSerial != _dockingOptionSyncSerial) return; + nextEdge = _parseToolbarEdge(edgeStr); + } else { + // The setting changed from top-only to multi-edge while this toolbar is + // already visible. Keep its current position instead of jumping to the + // last saved multi-edge dock. + nextEdge = cached.edge; + savedFractionForNextEdge = cached.fraction.toString(); + keepCurrentPosition = true; + } + + final rawFraction = _toolbarRawFraction( + multiEdgeEnabled: multiEdgeEnabled, + edge: nextEdge, + savedFraction: savedFractionForNextEdge, + legacyFraction: legacyFraction, + ); + // Clamp to the saved drag-bound contract so a corrupted or out-of-range + // saved value can't bypass it until the user drags again. + final dragLeft = double.tryParse( + bind.mainGetLocalOption(key: kOptionRemoteMenubarDragLeft)) ?? + 0.0; + final dragRight = double.tryParse( + bind.mainGetLocalOption(key: kOptionRemoteMenubarDragRight)) ?? + 1.0; + final fractionBounds = + _fractionBoundsForEdge(nextEdge, dragLeft, dragRight); + final nextFraction = (double.tryParse(rawFraction) ?? 0.5) + .clamp(fractionBounds.left, fractionBounds.right) + .toDouble(); + if (!mounted || syncSerial != _dockingOptionSyncSerial) return; + if (_dragging.isTrue || dragEpoch != _dragEpoch) { + _deferDockingOptionsSync(); + return; + } + _edge.value = nextEdge; + _fraction.value = nextFraction; + _multiEdgeEnabled.value = multiEdgeEnabled; + _dockingOptionsInitialized.value = true; + _cacheToolbarDockingOptions( + sessionId: widget.ffi.sessionId, + edge: nextEdge, + fraction: nextFraction, + multiEdgeEnabled: multiEdgeEnabled, + ); + _pendingDockingOptionSync = false; + if (!multiEdgeEnabled || keepCurrentPosition) { + bind.sessionPeerOption( + sessionId: widget.ffi.sessionId, + name: kOptionRemoteMenubarEdge, + value: _toolbarEdgeToString(nextEdge), + ); + bind.sessionPeerOption( + sessionId: widget.ffi.sessionId, + name: kOptionRemoteMenubarFraction, + value: nextFraction.toString(), + ); + } + } + + void _deferDockingOptionsSync() { + _pendingDockingOptionSync = true; + if (_dragging.isFalse) { + _syncDockingOptionsAfterDragIfNeeded(); + } + } + + void _markToolbarDragEpoch() { + ++_dragEpoch; + } + + void _syncDockingOptionsAfterDragIfNeeded() { + if (!_pendingDockingOptionSync) return; + WidgetsBinding.instance.addPostFrameCallback((_) async { + await _syncDockingOptions(force: false); + }); + } + @override initState() { super.initState(); + final cached = _cachedToolbarDockingOptions(widget.ffi.sessionId); + final multiEdgeEnabled = + mainGetLocalBoolOptionSync(kOptionAllowMultiEdgeToolbarDock); + final shouldResetToTop = + cached != null && cached.multiEdgeEnabled && !multiEdgeEnabled; + if (cached != null && !shouldResetToTop) { + _edge.value = cached.edge; + _fraction.value = cached.fraction; + _multiEdgeEnabled.value = multiEdgeEnabled; + _dockingOptionsInitialized.value = true; + } + WidgetsBinding.instance.addPostFrameCallback((_) async { - _fractionX.value = double.tryParse(await bind.sessionGetOption( - sessionId: widget.ffi.sessionId, - arg: 'remote-menubar-drag-x') ?? - '0.5') ?? - 0.5; + await _syncDockingOptions(force: cached == null || shouldResetToTop); + // Initialize toolbar states (collapse, hide) from session options + widget.state.init(widget.ffi.sessionId); }); _debouncerHide = Debouncer( @@ -396,62 +663,146 @@ class _RemoteToolbarState extends State { }); } + @override + void didUpdateWidget(covariant RemoteToolbar oldWidget) { + super.didUpdateWidget(oldWidget); + WidgetsBinding.instance.addPostFrameCallback((_) async { + await _syncDockingOptions(force: false); + }); + } + _debouncerHideProc(int v) { - if (!pin && show.isTrue && _isCursorOverImage && _dragging.isFalse) { - show.value = false; + if (!pin && collapse.isFalse && _isCursorOverImage && _dragging.isFalse) { + collapse.value = true; } } @override dispose() { - super.dispose(); - + ++_dockingOptionSyncSerial; widget.onEnterOrLeaveImageCleaner(identityHashCode(this)); + super.dispose(); } @override Widget build(BuildContext context) { + return Obx(() { + // Wait for initialization to complete to prevent flickering + if (!widget.state.initialized.value || + !_dockingOptionsInitialized.value) { + return const SizedBox.shrink(); + } + // If toolbar is hidden, return empty widget + if (hide.value) { + return const SizedBox.shrink(); + } + final edge = _edge.value; + final isHorizontal = _isHorizontalEdge(edge); + + // Measure the live toolbar after every layout so the preview ghost can + // match its actual footprint (collapsed handle vs expanded toolbar). + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_dragging.isTrue) return; + final ro = _toolbarKey.currentContext?.findRenderObject(); + if (ro is RenderBox && ro.hasSize) { + final s = ro.size; + if (_toolbarSize.value != s) _toolbarSize.value = s; + } + }); + + final toolbar = Align( + alignment: _alignmentForEdge(edge, _fraction.value), + child: KeyedSubtree( + key: _toolbarKey, + child: collapse.isFalse + ? _buildToolbar(context, edge, isHorizontal) + : _buildDraggableCollapse(context, edge, isHorizontal), + ), + ); + + // Always return the Stack — even when not dragging — so the toolbar's + // position in the Element tree stays stable. Wrapping/unwrapping it + // mid-drag was killing the Draggable's gesture state. + return Stack( + fit: StackFit.expand, + children: [ + IgnorePointer( + child: Obx(() { + final pe = _previewEdge.value; + final pf = _previewFraction.value; + if (!_dragging.isTrue || pe == null || pf == null) { + return const SizedBox.shrink(); + } + return _buildDragPreview(context, pe, pf, _toolbarSize.value); + }), + ), + toolbar, + ], + ); + }); + } + + Widget _buildDragPreview(BuildContext context, _ToolbarEdge edge, + double fraction, Size? measured) { + final color = Theme.of(context).colorScheme.primary; + // Use the measured live toolbar size so collapsed vs expanded looks + // right. The current orientation may differ from the preview orientation + // (e.g. dragging a top-docked toolbar toward the left edge), so swap the + // long/short axes when previewing a different orientation. + final previewSize = _toolbarSizeForEdge(edge, measured); return Align( - alignment: Alignment.topCenter, - child: Obx(() => show.value - ? _buildToolbar(context) - : _buildDraggableShowHide(context)), + alignment: _alignmentForEdge(edge, fraction), + child: Container( + width: previewSize.width, + height: previewSize.height, + decoration: BoxDecoration( + color: color.withOpacity(0.10), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: color.withOpacity(0.55), width: 1.5), + ), + ), ); } - Widget _buildDraggableShowHide(BuildContext context) { + Widget _buildDraggableCollapse( + BuildContext context, _ToolbarEdge edge, bool isHorizontal) { return Obx(() { - if (show.isTrue && _dragging.isFalse) { + if (collapse.isFalse && _dragging.isFalse) { triggerAutoHide(); } - final borderRadius = BorderRadius.vertical( - bottom: Radius.circular(5), - ); - return Align( - alignment: FractionalOffset(_fractionX.value, 0), - child: Offstage( - offstage: _dragging.isTrue, - child: Material( - elevation: _ToolbarTheme.elevation, - shadowColor: MyTheme.color(context).shadow, + final borderRadius = _collapseHandleBorderRadius(edge); + return Offstage( + offstage: _dragging.isTrue, + child: Material( + elevation: _ToolbarTheme.elevation, + shadowColor: MyTheme.color(context).shadow, + borderRadius: borderRadius, + child: _DraggableShowHide( + id: widget.id, + sessionId: widget.ffi.sessionId, + dragging: _dragging, + fraction: _fraction, + edge: _edge, + previewEdge: _previewEdge, + previewFraction: _previewFraction, + toolbarSize: _toolbarSize, + markDragEpoch: _markToolbarDragEpoch, + syncDockingOptionsAfterDragIfNeeded: + _syncDockingOptionsAfterDragIfNeeded, + isHorizontal: isHorizontal, + multiEdgeEnabled: _multiEdgeEnabled.value, + toolbarState: widget.state, + setFullscreen: _setFullscreen, + setMinimize: _minimize, borderRadius: borderRadius, - child: _DraggableShowHide( - id: widget.id, - sessionId: widget.ffi.sessionId, - dragging: _dragging, - fractionX: _fractionX, - toolbarState: widget.state, - setFullscreen: _setFullscreen, - setMinimize: _minimize, - borderRadius: borderRadius, - ), ), ), ); }); } - Widget _buildToolbar(BuildContext context) { + Widget _buildToolbar( + BuildContext context, _ToolbarEdge edge, bool isHorizontal) { final List toolbarItems = []; toolbarItems.add(_PinMenu(state: widget.state)); if (!isWebDesktop) { @@ -459,11 +810,13 @@ class _RemoteToolbarState extends State { } toolbarItems.add(Obx(() { - if (PrivacyModeState.find(widget.id).isEmpty && + if ((PrivacyModeState.find(widget.id).isEmpty || + allowDisplaySwitchInPrivacyMode(pi)) && pi.displaysCount.value > 1) { return _MonitorMenu( id: widget.id, ffi: widget.ffi, + edge: edge, setRemoteState: widget.setRemoteState); } else { return Offstage(); @@ -478,7 +831,10 @@ class _RemoteToolbarState extends State { state: widget.state, setFullscreen: _setFullscreen, )); - toolbarItems.add(_KeyboardMenu(id: widget.id, ffi: widget.ffi)); + // Do not show keyboard for camera connection type. + if (widget.ffi.connType == ConnType.defaultConn) { + toolbarItems.add(_KeyboardMenu(id: widget.id, ffi: widget.ffi)); + } toolbarItems.add(_ChatMenu(id: widget.id, ffi: widget.ffi)); if (!isWeb) { toolbarItems.add(_VoiceCallMenu(id: widget.id, ffi: widget.ffi)); @@ -486,37 +842,53 @@ class _RemoteToolbarState extends State { if (!isWeb) toolbarItems.add(_RecordMenu()); toolbarItems.add(_CloseMenu(id: widget.id, ffi: widget.ffi)); final toolbarBorderRadius = BorderRadius.all(Radius.circular(4.0)); - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - Material( - elevation: _ToolbarTheme.elevation, - shadowColor: MyTheme.color(context).shadow, - borderRadius: toolbarBorderRadius, - color: Theme.of(context) - .menuBarTheme - .style - ?.backgroundColor - ?.resolve(MaterialState.values.toSet()), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Theme( - data: themeData(), - child: _ToolbarTheme.borderWrapper( - context, - Row( - children: [ - SizedBox(width: _ToolbarTheme.buttonHMargin * 2), - ...toolbarItems, - SizedBox(width: _ToolbarTheme.buttonHMargin * 2) - ], - ), - toolbarBorderRadius), - ), - ), + // innerAxis: how the toolbar icons themselves flow. + // outerAxis: how the toolbar block and the handle stack against each other + // (perpendicular to the dock edge, so the handle hangs off the interior face). + final innerAxis = isHorizontal ? Axis.horizontal : Axis.vertical; + final outerAxis = isHorizontal ? Axis.vertical : Axis.horizontal; + final spacer = isHorizontal + ? SizedBox(width: _ToolbarTheme.buttonHMargin * 2) + : SizedBox(height: _ToolbarTheme.buttonHMargin * 2); + final toolbarMaterial = Material( + elevation: _ToolbarTheme.elevation, + shadowColor: MyTheme.color(context).shadow, + borderRadius: toolbarBorderRadius, + color: Theme.of(context) + .menuBarTheme + .style + ?.backgroundColor + ?.resolve(MaterialState.values.toSet()), + child: SingleChildScrollView( + scrollDirection: innerAxis, + child: Theme( + data: themeData(), + child: _ToolbarTheme.borderWrapper( + context, + Flex( + direction: innerAxis, + mainAxisSize: MainAxisSize.min, + children: [ + spacer, + ...toolbarItems, + spacer, + ], + ), + toolbarBorderRadius), ), - _buildDraggableShowHide(context), - ], + ), + ); + final handle = _buildDraggableCollapse(context, edge, isHorizontal); + // The handle hangs off the interior face of the toolbar (away from the + // docked edge), centered along that face by the Flex's default cross-axis + // alignment, so the icons themselves sit flush against the docked edge. + final children = (edge == _ToolbarEdge.top || edge == _ToolbarEdge.left) + ? [toolbarMaterial, handle] + : [handle, toolbarMaterial]; + return Flex( + direction: outerAxis, + mainAxisSize: MainAxisSize.min, + children: children, ); } @@ -595,11 +967,13 @@ class _MobileActionMenu extends StatelessWidget { class _MonitorMenu extends StatelessWidget { final String id; final FFI ffi; + final _ToolbarEdge edge; final Function(VoidCallback) setRemoteState; const _MonitorMenu({ Key? key, required this.id, required this.ffi, + required this.edge, required this.setRemoteState, }) : super(key: key); @@ -610,9 +984,17 @@ class _MonitorMenu extends StatelessWidget { !isWeb && ffi.ffiModel.pi.isSupportMultiDisplay; @override - Widget build(BuildContext context) => showMonitorsToolbar - ? buildMultiMonitorMenu(context) - : Obx(() => buildMonitorMenu(context)); + Widget build(BuildContext context) { + final child = showMonitorsToolbar + ? buildMultiMonitorMenu(context) + : Obx(() => buildMonitorMenu(context)); + final quarterTurns = _monitorMenuQuarterTurns(edge); + if (quarterTurns == 0) return child; + return RotatedBox( + quarterTurns: quarterTurns, + child: child, + ); + } Widget buildMonitorMenu(BuildContext context) { final width = SimpleWrapper(0); @@ -628,7 +1010,7 @@ class _MonitorMenu extends StatelessWidget { menuStyle: MenuStyle( padding: MaterialStatePropertyAll(EdgeInsets.symmetric(horizontal: 6))), - menuChildrenGetter: () => [buildMonitorSubmenuWidget(context)]); + menuChildrenGetter: (_) => [buildMonitorSubmenuWidget(context)]); } Widget buildMultiMonitorMenu(BuildContext context) { @@ -744,7 +1126,8 @@ class _MonitorMenu extends StatelessWidget { } final scale = _ToolbarTheme.buttonSize / rect.height * 0.75; - final startY = (_ToolbarTheme.buttonSize - rect.height * scale) * 0.5; + final height = rect.height * scale; + final startY = (_ToolbarTheme.buttonSize - height) * 0.5; final startX = startY; final children = []; @@ -787,7 +1170,7 @@ class _MonitorMenu extends StatelessWidget { width.value = rect.width * scale + startX * 2; return SizedBox( width: width.value, - height: rect.height * scale + startY * 2, + height: height + startY * 2, child: Stack( children: children, ), @@ -839,7 +1222,7 @@ class _ControlMenu extends StatelessWidget { color: _ToolbarTheme.blueColor, hoverColor: _ToolbarTheme.hoverBlueColor, ffi: ffi, - menuChildrenGetter: () => toolbarControls(context, id, ffi).map((e) { + menuChildrenGetter: (_) => toolbarControls(context, id, ffi).map((e) { if (e.divider) { return Divider(); } else { @@ -1020,6 +1403,7 @@ class _DisplayMenu extends StatefulWidget { } class _DisplayMenuState extends State<_DisplayMenu> { + final RxInt _customPercent = 100.obs; late final ScreenAdjustor _screenAdjustor = ScreenAdjustor( id: widget.id, ffi: widget.ffi, @@ -1033,34 +1417,52 @@ class _DisplayMenuState extends State<_DisplayMenu> { FFI get ffi => widget.ffi; String get id => widget.id; + @override + void initState() { + super.initState(); + // Initialize custom percent from stored option once + WidgetsBinding.instance.addPostFrameCallback((_) async { + try { + final v = await getSessionCustomScalePercent(widget.ffi.sessionId); + if (_customPercent.value != v) { + _customPercent.value = v; + } + } catch (_) {} + }); + } + @override Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; _screenAdjustor.updateScreen(); - menuChildrenGetter() { + menuChildrenGetter(_IconSubmenuButtonState state) { final menuChildren = [ _screenAdjustor.adjustWindow(context), - viewStyle(), - scrollStyle(), + viewStyle(customPercent: _customPercent), + scrollStyle(state, colorScheme), imageQuality(), codec(), - _ResolutionsMenu( - id: widget.id, - ffi: widget.ffi, - screenAdjustor: _screenAdjustor, - ), - if (showVirtualDisplayMenu(ffi)) + if (ffi.connType == ConnType.defaultConn) + _ResolutionsMenu( + id: widget.id, + ffi: widget.ffi, + screenAdjustor: _screenAdjustor, + ), + if (showVirtualDisplayMenu(ffi) && ffi.connType == ConnType.defaultConn) _SubmenuButton( ffi: widget.ffi, menuChildren: getVirtualDisplayMenuChildren(ffi, id, null), child: Text(translate("Virtual display")), ), - cursorToggles(), + if (ffi.connType == ConnType.defaultConn) cursorToggles(), Divider(), toggles(), ]; // privacy mode - if (ffiModel.keyboard && pi.features.privacyMode) { - final privacyModeState = PrivacyModeState.find(id); + final privacyModeState = PrivacyModeState.find(id); + if (ffi.connType == ConnType.defaultConn && + (pi.features.privacyMode || privacyModeState.isNotEmpty) && + (ffiModel.keyboard || privacyModeState.isNotEmpty)) { final privacyModeList = toolbarPrivacyMode(privacyModeState, context, id, ffi); if (privacyModeList.length == 1) { @@ -1085,7 +1487,9 @@ class _DisplayMenuState extends State<_DisplayMenu> { ]); } } - menuChildren.add(widget.pluginItem); + if (ffi.connType == ConnType.defaultConn) { + menuChildren.add(widget.pluginItem); + } return menuChildren; } @@ -1099,62 +1503,146 @@ class _DisplayMenuState extends State<_DisplayMenu> { ); } - viewStyle() { + viewStyle({required RxInt customPercent}) { return futureBuilder( future: toolbarViewStyle(context, widget.id, widget.ffi), hasData: (data) { final v = data as List>; + final bool isCustomSelected = v.isNotEmpty + ? v.first.groupValue == kRemoteViewStyleCustom + : false; return Column(children: [ - ...v - .map((e) => RdoMenuButton( - value: e.value, - groupValue: e.groupValue, - onChanged: e.onChanged, - child: e.child, - ffi: ffi)) - .toList(), - Divider(), + ...v.map((e) { + final isCustom = e.value == kRemoteViewStyleCustom; + final child = + isCustom ? Text(translate('Scale custom')) : e.child; + // Whether the current selection is already custom + final bool isGroupCustomSelected = + e.groupValue == kRemoteViewStyleCustom; + // Keep menu open when switching INTO custom so the slider is visible immediately + final bool keepOpenForThisItem = + isCustom && !isGroupCustomSelected; + return RdoMenuButton( + value: e.value, + groupValue: e.groupValue, + onChanged: (value) { + // Perform the original change + e.onChanged?.call(value); + // Only force a rebuild when we keep the menu open to reveal the slider + if (keepOpenForThisItem) { + setState(() {}); + } + }, + child: child, + ffi: ffi, + // When entering custom, keep submenu open to show the slider controls + closeOnActivate: !keepOpenForThisItem); + }).toList(), + // Only show a divider when custom is NOT selected + if (!isCustomSelected) Divider(), + _customControlsIfCustomSelected( + onChanged: (v) => customPercent.value = v), ]); }); } - scrollStyle() { + Widget _customControlsIfCustomSelected({ValueChanged? onChanged}) { + return futureBuilder(future: () async { + final current = await bind.sessionGetViewStyle(sessionId: ffi.sessionId); + return current == kRemoteViewStyleCustom; + }(), hasData: (data) { + final isCustom = data as bool; + return AnimatedSwitcher( + duration: Duration(milliseconds: 220), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + child: isCustom + ? _CustomScaleMenuControls(ffi: ffi, onChanged: onChanged) + : SizedBox.shrink(), + ); + }); + } + + scrollStyle(_IconSubmenuButtonState state, ColorScheme colorScheme) { return futureBuilder(future: () async { final viewStyle = await bind.sessionGetViewStyle(sessionId: ffi.sessionId) ?? ''; - final visible = viewStyle == kRemoteViewStyleOriginal; + final visible = viewStyle == kRemoteViewStyleOriginal || + viewStyle == kRemoteViewStyleCustom; final scrollStyle = await bind.sessionGetScrollStyle(sessionId: ffi.sessionId) ?? ''; - return {'visible': visible, 'scrollStyle': scrollStyle}; + final edgeScrollEdgeThickness = await bind + .sessionGetEdgeScrollEdgeThickness(sessionId: ffi.sessionId); + return { + 'visible': visible, + 'scrollStyle': scrollStyle, + 'edgeScrollEdgeThickness': edgeScrollEdgeThickness, + }; }(), hasData: (data) { final visible = data['visible'] as bool; if (!visible) return Offstage(); final groupValue = data['scrollStyle'] as String; - onChange(String? value) async { + final edgeScrollEdgeThickness = data['edgeScrollEdgeThickness'] as int; + + onChangeScrollStyle(String? value) async { if (value == null) return; await bind.sessionSetScrollStyle( sessionId: ffi.sessionId, value: value); widget.ffi.canvasModel.updateScrollStyle(); + state.setState(() {}); } - final enabled = widget.ffi.canvasModel.imageOverflow.value; - return Column(children: [ - RdoMenuButton( - child: Text(translate('ScrollAuto')), - value: kRemoteScrollStyleAuto, - groupValue: groupValue, - onChanged: enabled ? (value) => onChange(value) : null, - ffi: widget.ffi, - ), - RdoMenuButton( - child: Text(translate('Scrollbar')), - value: kRemoteScrollStyleBar, - groupValue: groupValue, - onChanged: enabled ? (value) => onChange(value) : null, - ffi: widget.ffi, - ), - Divider(), - ]); + onChangeEdgeScrollEdgeThickness(double? value) async { + if (value == null) return; + final newThickness = value.round(); + await bind.sessionSetEdgeScrollEdgeThickness( + sessionId: ffi.sessionId, value: newThickness); + widget.ffi.canvasModel.updateEdgeScrollEdgeThickness(newThickness); + state.setState(() {}); + } + + return Obx(() => Column(children: [ + RdoMenuButton( + child: Text(translate('ScrollAuto')), + value: kRemoteScrollStyleAuto, + groupValue: groupValue, + onChanged: widget.ffi.canvasModel.imageOverflow.value + ? (value) => onChangeScrollStyle(value) + : null, + closeOnActivate: groupValue != kRemoteScrollStyleEdge, + ffi: widget.ffi, + ), + RdoMenuButton( + child: Text(translate('Scrollbar')), + value: kRemoteScrollStyleBar, + groupValue: groupValue, + onChanged: widget.ffi.canvasModel.imageOverflow.value + ? (value) => onChangeScrollStyle(value) + : null, + closeOnActivate: groupValue != kRemoteScrollStyleEdge, + ffi: widget.ffi, + ), + if (!isWeb) ...[ + RdoMenuButton( + child: Text(translate('ScrollEdge')), + value: kRemoteScrollStyleEdge, + groupValue: groupValue, + closeOnActivate: false, + onChanged: widget.ffi.canvasModel.imageOverflow.value + ? (value) => onChangeScrollStyle(value) + : null, + ffi: widget.ffi, + ), + Offstage( + offstage: groupValue != kRemoteScrollStyleEdge, + child: EdgeThicknessControl( + value: edgeScrollEdgeThickness.toDouble(), + onChanged: onChangeEdgeScrollEdgeThickness, + colorScheme: colorScheme, + )), + ], + Divider(), + ])); }); } @@ -1236,6 +1724,178 @@ class _DisplayMenuState extends State<_DisplayMenu> { } } +class _CustomScaleMenuControls extends StatefulWidget { + final FFI ffi; + final ValueChanged? onChanged; + const _CustomScaleMenuControls({Key? key, required this.ffi, this.onChanged}) + : super(key: key); + + @override + State<_CustomScaleMenuControls> createState() => + _CustomScaleMenuControlsState(); +} + +class _CustomScaleMenuControlsState + extends CustomScaleControls<_CustomScaleMenuControls> { + @override + FFI get ffi => widget.ffi; + + @override + ValueChanged? get onScaleChanged => widget.onChanged; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + const smallBtnConstraints = BoxConstraints(minWidth: 28, minHeight: 28); + + final sliderControl = Semantics( + label: translate('Custom scale slider'), + value: '$scaleValue%', + child: SliderTheme( + data: SliderTheme.of(context).copyWith( + activeTrackColor: colorScheme.primary, + thumbColor: colorScheme.primary, + overlayColor: colorScheme.primary.withOpacity(0.1), + showValueIndicator: ShowValueIndicator.never, + thumbShape: _RectValueThumbShape( + min: CustomScaleControls.minPercent.toDouble(), + max: CustomScaleControls.maxPercent.toDouble(), + width: 52, + height: 24, + radius: 4, + displayValueForNormalized: (t) => mapPosToPercent(t), + ), + ), + child: Slider( + value: scalePos, + min: 0.0, + max: 1.0, + // Use a wide range of divisions (calculated as (CustomScaleControls.maxPercent - CustomScaleControls.minPercent)) to provide ~1% precision increments. + // This allows users to set precise scale values. Lower values would require more fine-tuning via the +/- buttons, which is undesirable for big ranges. + divisions: + (CustomScaleControls.maxPercent - CustomScaleControls.minPercent) + .round(), + onChanged: onSliderChanged, + ), + ), + ); + + return Column(children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: Row(children: [ + Tooltip( + message: translate('Decrease'), + child: IconButton( + iconSize: 16, + padding: EdgeInsets.all(1), + constraints: smallBtnConstraints, + icon: const Icon(Icons.remove), + onPressed: () => nudgeScale(-1), + ), + ), + Expanded(child: sliderControl), + Tooltip( + message: translate('Increase'), + child: IconButton( + iconSize: 16, + padding: EdgeInsets.all(1), + constraints: smallBtnConstraints, + icon: const Icon(Icons.add), + onPressed: () => nudgeScale(1), + ), + ), + ]), + ), + Divider(), + ]); + } +} + +// Lightweight rectangular thumb that paints the current percentage. +// Stateless and uses only SliderTheme colors; avoids allocations beyond a TextPainter per frame. +class _RectValueThumbShape extends SliderComponentShape { + final double min; + final double max; + final double width; + final double height; + final double radius; + final String unit; + // Optional mapper to compute display value from normalized position [0,1] + // If null, falls back to linear interpolation between min and max. + final int Function(double normalized)? displayValueForNormalized; + + const _RectValueThumbShape({ + required this.min, + required this.max, + required this.width, + required this.height, + required this.radius, + this.displayValueForNormalized, + this.unit = '%', + }); + + @override + Size getPreferredSize(bool isEnabled, bool isDiscrete) { + return Size(width, height); + } + + @override + void paint( + PaintingContext context, + Offset center, { + required Animation activationAnimation, + required Animation enableAnimation, + required bool isDiscrete, + required TextPainter labelPainter, + required RenderBox parentBox, + required SliderThemeData sliderTheme, + required TextDirection textDirection, + required double value, + required double textScaleFactor, + required Size sizeWithOverflow, + }) { + final Canvas canvas = context.canvas; + + // Resolve color based on enabled/disabled animation, with safe fallbacks. + final ColorTween colorTween = ColorTween( + begin: sliderTheme.disabledThumbColor, + end: sliderTheme.thumbColor, + ); + final Color? evaluatedColor = colorTween.evaluate(enableAnimation); + final Color? thumbColor = sliderTheme.thumbColor; + final Color fillColor = evaluatedColor ?? thumbColor ?? Colors.blueAccent; + + final RRect rrect = RRect.fromRectAndRadius( + Rect.fromCenter(center: center, width: width, height: height), + Radius.circular(radius), + ); + final Paint paint = Paint()..color = fillColor; + canvas.drawRRect(rrect, paint); + + // Compute displayed value from normalized slider value. + final int displayValue = displayValueForNormalized != null + ? displayValueForNormalized!(value) + : (min + value * (max - min)).round(); + final TextSpan span = TextSpan( + text: '$displayValue$unit', + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ); + final TextPainter tp = TextPainter( + text: span, + textAlign: TextAlign.center, + textDirection: textDirection, + ); + tp.layout(maxWidth: width - 4); + tp.paint( + canvas, Offset(center.dx - tp.width / 2, center.dy - tp.height / 2)); + } +} + class _ResolutionsMenu extends StatefulWidget { final String id; final FFI ffi; @@ -1495,7 +2155,7 @@ class _ResolutionsMenuState extends State<_ResolutionsMenu> { ); } - TextField _resolutionInput(TextEditingController controller) { + Widget _resolutionInput(TextEditingController controller) { return TextField( decoration: InputDecoration( border: InputBorder.none, @@ -1509,7 +2169,7 @@ class _ResolutionsMenuState extends State<_ResolutionsMenu> { FilteringTextInputFormatter.allow(RegExp(r'[0-9]')), ], controller: controller, - ); + ).workaroundFreezeLinuxMint(); } List _supportedResolutionMenuButtons() => resolutions @@ -1568,28 +2228,59 @@ class _KeyboardMenu extends StatelessWidget { Widget build(BuildContext context) { var ffiModel = Provider.of(context); if (!ffiModel.keyboard) return Offstage(); - toolbarToggles() => toolbarKeyboardToggles(ffi) - .map((e) => CkbMenuButton( - value: e.value, onChanged: e.onChanged, child: e.child, ffi: ffi)) - .toList(); + toolbarToggles() { + final toggles = toolbarKeyboardToggles(ffi) + .map((e) => CkbMenuButton( + value: e.value, + onChanged: e.onChanged, + child: e.child, + ffi: ffi) as Widget) + .toList(); + if (toggles.isNotEmpty) { + toggles.add(Divider()); + } + return toggles; + } + return _IconSubmenuButton( tooltip: 'Keyboard Settings', - svg: "assets/keyboard.svg", + svg: "assets/keyboard_mouse.svg", ffi: ffi, color: _ToolbarTheme.blueColor, hoverColor: _ToolbarTheme.hoverBlueColor, - menuChildrenGetter: () => [ + menuChildrenGetter: (_) => [ keyboardMode(), localKeyboardType(), inputSource(), Divider(), viewMode(), + if ([kPeerPlatformWindows, kPeerPlatformMacOS, kPeerPlatformLinux] + .contains(pi.platform)) + showMyCursor(), Divider(), ...toolbarToggles(), + ...mouseSpeed(), ...mobileActions(), ]); } + mouseSpeed() { + final speedWidgets = []; + final sessionId = ffi.sessionId; + if (isDesktop) { + if (ffi.ffiModel.keyboard) { + final enabled = !ffi.ffiModel.viewOnly; + final trackpad = MenuButton( + child: Text(translate('Trackpad speed')).paddingOnly(left: 26.0), + onPressed: enabled ? () => trackpadSpeedDialog(sessionId, ffi) : null, + ffi: ffi, + ); + speedWidgets.add(trackpad); + } + } + return speedWidgets; + } + keyboardMode() { return futureBuilder(future: () async { return await bind.sessionGetKeyboardMode(sessionId: ffi.sessionId) ?? @@ -1633,8 +2324,18 @@ class _KeyboardMenu extends StatelessWidget { continue; } - if (pi.isWayland && mode.key != kKeyMapMode) { - continue; + if (pi.isWayland) { + // Legacy mode is hidden on desktop control side because dead keys + // don't work properly on Wayland. When the control side is mobile, + // Legacy mode is used automatically (mobile always sends Legacy events). + if (mode.key == kKeyLegacyMode) { + continue; + } + // Translate mode requires server >= 1.4.6. + if (mode.key == kKeyTranslateMode && + versionCmp(pi.version, '1.4.6') < 0) { + continue; + } } var text = translate(mode.menu); @@ -1722,12 +2423,43 @@ class _KeyboardMenu extends StatelessWidget { final viewOnly = await bind.sessionGetToggleOption( sessionId: ffi.sessionId, arg: kOptionToggleViewOnly); ffiModel.setViewOnly(id, viewOnly ?? value); + final showMyCursor = await bind.sessionGetToggleOption( + sessionId: ffi.sessionId, arg: kOptionToggleShowMyCursor); + ffiModel.setShowMyCursor(showMyCursor ?? value); } : null, ffi: ffi, child: Text(translate('View Mode'))); } + showMyCursor() { + final ffiModel = ffi.ffiModel; + return CkbMenuButton( + value: ffiModel.showMyCursor, + onChanged: (value) async { + if (value == null) return; + await bind.sessionToggleOption( + sessionId: ffi.sessionId, value: kOptionToggleShowMyCursor); + final showMyCursor = await bind.sessionGetToggleOption( + sessionId: ffi.sessionId, + arg: kOptionToggleShowMyCursor) ?? + value; + ffiModel.setShowMyCursor(showMyCursor); + + // Also set view only if showMyCursor is enabled and viewOnly is not enabled. + if (showMyCursor && !ffiModel.viewOnly) { + await bind.sessionToggleOption( + sessionId: ffi.sessionId, value: kOptionToggleViewOnly); + final viewOnly = await bind.sessionGetToggleOption( + sessionId: ffi.sessionId, arg: kOptionToggleViewOnly); + ffiModel.setViewOnly(id, viewOnly ?? value); + } + }, + ffi: ffi, + child: Text(translate('Show my cursor'))) + .paddingOnly(left: 26.0); + } + mobileActions() { if (pi.platform != kPeerPlatformAndroid) return []; final enabled = versionCmp(pi.version, '1.2.7') >= 0; @@ -1791,7 +2523,7 @@ class _ChatMenuState extends State<_ChatMenu> { ffi: widget.ffi, color: _ToolbarTheme.blueColor, hoverColor: _ToolbarTheme.hoverBlueColor, - menuChildrenGetter: () => [textChat(), voiceCall()]); + menuChildrenGetter: (_) => [textChat(), voiceCall()]); } } @@ -1847,7 +2579,7 @@ class _VoiceCallMenu extends StatelessWidget { @override Widget build(BuildContext context) { - menuChildrenGetter() { + menuChildrenGetter(_IconSubmenuButtonState state) { final audioInput = AudioInput( builder: (devices, currentDevice, setDevice) { return Column( @@ -1953,7 +2685,12 @@ class _CloseMenu extends StatelessWidget { return _IconMenuButton( assetName: 'assets/close.svg', tooltip: 'Close', - onPressed: () => closeConnection(id: id), + onPressed: () async { + if (await showConnEndAuditDialogCloseCanceled(ffi: ffi)) { + return; + } + closeConnection(id: id); + }, color: _ToolbarTheme.redColor, hoverColor: _ToolbarTheme.hoverRedColor, ); @@ -2047,7 +2784,7 @@ class _IconSubmenuButton extends StatefulWidget { final Widget? icon; final Color color; final Color hoverColor; - final List Function() menuChildrenGetter; + final List Function(_IconSubmenuButtonState state) menuChildrenGetter; final MenuStyle? menuStyle; final FFI? ffi; final double? width; @@ -2072,6 +2809,11 @@ class _IconSubmenuButton extends StatefulWidget { class _IconSubmenuButtonState extends State<_IconSubmenuButton> { bool hover = false; + @override // discard @protected + void setState(VoidCallback fn) { + super.setState(fn); + } + @override Widget build(BuildContext context) { assert(widget.svg != null || widget.icon != null); @@ -2104,7 +2846,7 @@ class _IconSubmenuButtonState extends State<_IconSubmenuButton> { ), child: icon))), menuChildren: widget - .menuChildrenGetter() + .menuChildrenGetter(this) .map((e) => _buildPointerTrackWidget(e, widget.ffi)) .toList())); return MenuBar(children: [ @@ -2205,6 +2947,8 @@ class RdoMenuButton extends StatelessWidget { final ValueChanged? onChanged; final Widget? child; final FFI? ffi; + // When true, submenu will be dismissed on activate; when false, it stays open. + final bool closeOnActivate; const RdoMenuButton({ Key? key, required this.value, @@ -2212,6 +2956,7 @@ class RdoMenuButton extends StatelessWidget { required this.child, this.ffi, this.onChanged, + this.closeOnActivate = true, }) : super(key: key); @override @@ -2220,9 +2965,10 @@ class RdoMenuButton extends StatelessWidget { value: value, groupValue: groupValue, child: child, + closeOnActivate: closeOnActivate, onChanged: onChanged != null ? (T? value) { - if (ffi != null) { + if (ffi != null && closeOnActivate) { _menuDismissCallback(ffi!); } onChanged?.call(value); @@ -2235,7 +2981,18 @@ class RdoMenuButton extends StatelessWidget { class _DraggableShowHide extends StatefulWidget { final String id; final SessionID sessionId; - final RxDouble fractionX; + final RxDouble fraction; + final Rx<_ToolbarEdge> edge; + final Rxn<_ToolbarEdge> previewEdge; + final Rxn previewFraction; + final Rxn toolbarSize; + final VoidCallback markDragEpoch; + final VoidCallback syncDockingOptionsAfterDragIfNeeded; + final bool isHorizontal; + // Whether multi-edge docking is enabled for this session (toggled in + // Settings -> Other). When false, the drag handle slides the toolbar + // horizontally on the top edge and never switches edges. + final bool multiEdgeEnabled; final RxBool dragging; final ToolbarState toolbarState; final BorderRadius borderRadius; @@ -2247,7 +3004,15 @@ class _DraggableShowHide extends StatefulWidget { Key? key, required this.id, required this.sessionId, - required this.fractionX, + required this.fraction, + required this.edge, + required this.previewEdge, + required this.previewFraction, + required this.toolbarSize, + required this.markDragEpoch, + required this.syncDockingOptionsAfterDragIfNeeded, + required this.isHorizontal, + required this.multiEdgeEnabled, required this.dragging, required this.toolbarState, required this.setFullscreen, @@ -2260,12 +3025,14 @@ class _DraggableShowHide extends StatefulWidget { } class _DraggableShowHideState extends State<_DraggableShowHide> { - Offset position = Offset.zero; - Size size = Size.zero; double left = 0.0; double right = 1.0; + Offset? _lastPointerDown; + Offset? _dragGrabOffset; + double? _dragLongAxisGrabOffset; + Size? _dragToolbarSize; - RxBool get show => widget.toolbarState.show; + RxBool get collapse => widget.toolbarState.collapse; @override initState() { @@ -2289,41 +3056,174 @@ class _DraggableShowHideState extends State<_DraggableShowHide> { } } + // Bias applied to the currently-previewed edge so a drag hovering between + // two edges doesn't flicker. Only relevant when multi-edge is enabled. + static const double _switchHysteresisPx = 50.0; + + _ToolbarEdge _nearestToolbarEdge(Offset cursor, Size mediaSize) { + if (!widget.multiEdgeEnabled) return widget.edge.value; + + double rawDist(_ToolbarEdge e) { + switch (e) { + case _ToolbarEdge.top: + return cursor.dy; + case _ToolbarEdge.bottom: + return mediaSize.height - cursor.dy; + case _ToolbarEdge.left: + return cursor.dx; + case _ToolbarEdge.right: + return mediaSize.width - cursor.dx; + } + } + + final previewed = widget.previewEdge.value; + var winner = widget.edge.value; + var best = double.infinity; + for (final e in _ToolbarEdge.values) { + final biased = + e == previewed ? rawDist(e) - _switchHysteresisPx : rawDist(e); + if (biased < best) { + best = biased; + winner = e; + } + } + return winner; + } + + void _ensureDragGrabOffset(Offset cursor) { + if (_dragGrabOffset != null) return; + final mediaSize = MediaQueryData.fromView(View.of(context)).size; + final toolbarSize = + _toolbarSizeForEdge(widget.edge.value, widget.toolbarSize.value); + _dragToolbarSize = toolbarSize; + final toolbarOffset = _toolbarOffsetForEdge( + edge: widget.edge.value, + fraction: widget.fraction.value, + parentSize: mediaSize, + toolbarSize: toolbarSize, + ); + _dragGrabOffset = cursor - toolbarOffset; + _dragLongAxisGrabOffset = _isHorizontalEdge(widget.edge.value) + ? _dragGrabOffset?.dx + : _dragGrabOffset?.dy; + } + + double _dragGrabOffsetForEdge(_ToolbarEdge edge, Size toolbarSize) { + final offset = _dragLongAxisGrabOffset ?? 0; + final extent = + _isHorizontalEdge(edge) ? toolbarSize.width : toolbarSize.height; + return _clampToolbarFraction(offset, 0, extent); + } + + void _updatePreview(Offset cursor) { + _ensureDragGrabOffset(cursor); + final mediaSize = MediaQueryData.fromView(View.of(context)).size; + final winner = _nearestToolbarEdge(cursor, mediaSize); + widget.previewEdge.value = winner; + + final toolbarSize = _toolbarSizeForEdge(winner, _dragToolbarSize); + final grabOffset = _dragGrabOffsetForEdge(winner, toolbarSize); + final double frac; + if (winner == _ToolbarEdge.top || winner == _ToolbarEdge.bottom) { + frac = _fractionForAlignedDrag( + cursor: cursor.dx, + grabOffset: grabOffset, + parentExtent: mediaSize.width, + toolbarExtent: toolbarSize.width, + left: left, + right: right, + ); + } else { + final fractionBounds = _fractionBoundsForEdge(winner, left, right); + frac = _fractionForAlignedDrag( + cursor: cursor.dy, + grabOffset: grabOffset, + parentExtent: mediaSize.height, + toolbarExtent: toolbarSize.height, + left: fractionBounds.left, + right: fractionBounds.right, + ); + } + widget.previewFraction.value = frac; + } + + void _resetDragTracking() { + _lastPointerDown = null; + _dragGrabOffset = null; + _dragLongAxisGrabOffset = null; + _dragToolbarSize = null; + } + + void _commitPreview() { + final newEdge = widget.previewEdge.value; + final frac = widget.previewFraction.value; + widget.previewEdge.value = null; + widget.previewFraction.value = null; + widget.dragging.value = false; + widget.markDragEpoch(); + _resetDragTracking(); + widget.syncDockingOptionsAfterDragIfNeeded(); + if (newEdge == null || frac == null) return; + widget.edge.value = newEdge; + widget.fraction.value = frac; + _cacheToolbarDockingOptions( + sessionId: widget.sessionId, + edge: newEdge, + fraction: frac, + multiEdgeEnabled: widget.multiEdgeEnabled, + ); + bind.sessionPeerOption( + sessionId: widget.sessionId, + name: kOptionRemoteMenubarEdge, + value: _toolbarEdgeToString(newEdge), + ); + bind.sessionPeerOption( + sessionId: widget.sessionId, + name: kOptionRemoteMenubarFraction, + value: frac.toString(), + ); + if (widget.multiEdgeEnabled) { + return; + } + bind.sessionPeerOption( + sessionId: widget.sessionId, + name: _legacyRemoteMenubarDragX, + value: frac.toString(), + ); + } + Widget _buildDraggable(BuildContext context) { - return Draggable( - axis: Axis.horizontal, - child: Icon( - Icons.drag_indicator, - size: 20, - color: MyTheme.color(context).drag_indicator, + return Listener( + onPointerDown: (event) => _lastPointerDown = event.position, + child: Draggable( + // When multi-edge docking is off the toolbar stays on the top edge, + // so lock the feedback to horizontal motion — otherwise the handle + // floats away from the top while dragging and the toolbar looks + // unmoored. When multi-edge is on we need 2D drag for snap-to-edge. + axis: widget.multiEdgeEnabled ? null : Axis.horizontal, + child: Icon( + widget.isHorizontal ? Icons.drag_indicator : Icons.drag_handle, + size: 20, + color: MyTheme.color(context).drag_indicator, + ), + feedback: widget, + onDragStarted: () { + widget.markDragEpoch(); + final pointerDown = _lastPointerDown; + if (pointerDown != null) { + _ensureDragGrabOffset(pointerDown); + } + widget.dragging.value = true; + // Seed the preview at the current docked edge/fraction so something + // shows the instant the drag begins, before the first onDragUpdate. + widget.previewEdge.value = widget.edge.value; + widget.previewFraction.value = widget.fraction.value; + }, + onDragUpdate: (details) { + _updatePreview(details.globalPosition); + }, + onDragEnd: (_) => _commitPreview(), ), - feedback: widget, - onDragStarted: (() { - final RenderObject? renderObj = context.findRenderObject(); - if (renderObj != null) { - final RenderBox renderBox = renderObj as RenderBox; - size = renderBox.size; - position = renderBox.localToGlobal(Offset.zero); - } - widget.dragging.value = true; - }), - onDragEnd: (details) { - final mediaSize = MediaQueryData.fromView(View.of(context)).size; - widget.fractionX.value += - (details.offset.dx - position.dx) / (mediaSize.width - size.width); - if (widget.fractionX.value < left) { - widget.fractionX.value = left; - } - if (widget.fractionX.value > right) { - widget.fractionX.value = right; - } - bind.sessionPeerOption( - sessionId: widget.sessionId, - name: 'remote-menubar-drag-x', - value: widget.fractionX.value.toString(), - ); - widget.dragging.value = false; - }, ); } @@ -2353,7 +3253,9 @@ class _DraggableShowHideState extends State<_DraggableShowHide> { ); } - final child = Row( + final axis = widget.isHorizontal ? Axis.horizontal : Axis.vertical; + final child = Flex( + direction: axis, mainAxisSize: MainAxisSize.min, children: [ _buildDraggable(context), @@ -2388,20 +3290,20 @@ class _DraggableShowHideState extends State<_DraggableShowHide> { )), buttonWrapper( () => setState(() { - widget.toolbarState.switchShow(widget.sessionId); + widget.toolbarState.switchCollapse(widget.sessionId); }), Obx((() => Tooltip( - message: - translate(show.isTrue ? 'Hide Toolbar' : 'Show Toolbar'), + message: translate( + collapse.isFalse ? 'Hide Toolbar' : 'Show Toolbar'), child: Icon( - show.isTrue ? Icons.expand_less : Icons.expand_more, + _toolbarCollapseIcon(widget.edge.value, collapse.isTrue), size: iconSize, ), ))), ), if (isWebDesktop) Obx(() { - if (show.isTrue) { + if (collapse.isFalse) { return Offstage(); } else { return buttonWrapper( @@ -2436,7 +3338,8 @@ class _DraggableShowHideState extends State<_DraggableShowHide> { borderRadius: widget.borderRadius, ), child: SizedBox( - height: 20, + height: widget.isHorizontal ? 20 : null, + width: widget.isHorizontal ? null : 20, child: child, ), ), @@ -2463,3 +3366,56 @@ Widget _buildPointerTrackWidget(Widget child, FFI? ffi) { ), ); } + +class EdgeThicknessControl extends StatelessWidget { + final double value; + final ValueChanged? onChanged; + final ColorScheme? colorScheme; + + const EdgeThicknessControl({ + Key? key, + required this.value, + this.onChanged, + this.colorScheme, + }) : super(key: key); + + static const double kMin = 20; + static const double kMax = 150; + + @override + Widget build(BuildContext context) { + final colorScheme = this.colorScheme ?? Theme.of(context).colorScheme; + + final slider = SliderTheme( + data: SliderTheme.of(context).copyWith( + activeTrackColor: colorScheme.primary, + thumbColor: colorScheme.primary, + overlayColor: colorScheme.primary.withOpacity(0.1), + showValueIndicator: ShowValueIndicator.never, + thumbShape: _RectValueThumbShape( + min: EdgeThicknessControl.kMin, + max: EdgeThicknessControl.kMax, + width: 52, + height: 24, + radius: 4, + unit: 'px', + ), + ), + child: Semantics( + value: value.toInt().toString(), + child: Slider( + value: value, + min: EdgeThicknessControl.kMin, + max: EdgeThicknessControl.kMax, + divisions: + (EdgeThicknessControl.kMax - EdgeThicknessControl.kMin).round(), + semanticFormatterCallback: (double newValue) => + "${newValue.round()}px", + onChanged: onChanged, + ), + ), + ); + + return slider; + } +} diff --git a/flutter/lib/desktop/widgets/scroll_wrapper.dart b/flutter/lib/desktop/widgets/scroll_wrapper.dart deleted file mode 100644 index c5bc3394b..000000000 --- a/flutter/lib/desktop/widgets/scroll_wrapper.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:flutter_hbb/consts.dart'; -import 'package:flutter_improved_scrolling/flutter_improved_scrolling.dart'; - -class DesktopScrollWrapper extends StatelessWidget { - final ScrollController scrollController; - final Widget child; - const DesktopScrollWrapper( - {Key? key, required this.scrollController, required this.child}) - : super(key: key); - - @override - Widget build(BuildContext context) { - return ImprovedScrolling( - scrollController: scrollController, - enableCustomMouseWheelScrolling: true, - // enableKeyboardScrolling: true, // strange behavior on mac - customMouseWheelScrollConfig: CustomMouseWheelScrollConfig( - scrollDuration: kDefaultScrollDuration, - scrollCurve: Curves.linearToEaseOut, - mouseWheelTurnsThrottleTimeMs: - kDefaultMouseWheelThrottleDuration.inMilliseconds, - scrollAmountMultiplier: kDefaultScrollAmountMultiplier), - child: child, - ); - } -} diff --git a/flutter/lib/desktop/widgets/tabbar_widget.dart b/flutter/lib/desktop/widgets/tabbar_widget.dart index 75ecacbfe..9ef7d38d9 100644 --- a/flutter/lib/desktop/widgets/tabbar_widget.dart +++ b/flutter/lib/desktop/widgets/tabbar_widget.dart @@ -9,6 +9,7 @@ import 'package:flutter/material.dart' hide TabBarTheme; import 'package:flutter_hbb/common.dart'; import 'package:flutter_hbb/consts.dart'; import 'package:flutter_hbb/desktop/pages/remote_page.dart'; +import 'package:flutter_hbb/desktop/pages/view_camera_page.dart'; import 'package:flutter_hbb/main.dart'; import 'package:flutter_hbb/models/platform_model.dart'; import 'package:flutter_hbb/models/state_model.dart'; @@ -51,7 +52,9 @@ enum DesktopTabType { cm, remoteScreen, fileTransfer, + viewCamera, portForward, + terminal, install, } @@ -96,6 +99,7 @@ class DesktopTabController { /// index, key Function(int, String)? onRemoved; Function(String)? onSelected; + Future Function()? onCloseWindow; DesktopTabController( {required this.tabType, this.onRemoved, this.onSelected}); @@ -179,11 +183,13 @@ class DesktopTabController { jumpTo(state.value.tabs.indexWhere((tab) => tab.key == key), callOnSelected: callOnSelected); - bool jumpToByKeyAndDisplay(String key, int display) { + bool jumpToByKeyAndDisplay(String key, int display, {bool isCamera = false}) { for (int i = 0; i < state.value.tabs.length; i++) { final tab = state.value.tabs[i]; if (tab.key == key) { - final ffi = (tab.page as RemotePage).ffi; + final ffi = isCamera + ? (tab.page as ViewCameraPage).ffi + : (tab.page as RemotePage).ffi; if (ffi.ffiModel.pi.currentDisplay == display) { return jumpTo(i, callOnSelected: true); } @@ -246,7 +252,6 @@ class DesktopTab extends StatefulWidget { final Color? selectedTabBackgroundColor; final Color? unSelectedTabBackgroundColor; final Color? selectedBorderColor; - final RxBool? blockTab; final DesktopTabController controller; @@ -272,7 +277,6 @@ class DesktopTab extends StatefulWidget { this.selectedTabBackgroundColor, this.unSelectedTabBackgroundColor, this.selectedBorderColor, - this.blockTab, }) : super(key: key); static RxString tablabelGetter(String peerId) { @@ -289,7 +293,6 @@ class DesktopTab extends StatefulWidget { // ignore: must_be_immutable class _DesktopTabState extends State with MultiWindowListener, WindowListener { - final _saveFrameDebounce = Debouncer(delay: Duration(seconds: 1)); Timer? _macOSCheckRestoreTimer; int _macOSCheckRestoreCounter = 0; @@ -311,7 +314,6 @@ class _DesktopTabState extends State Color? get unSelectedTabBackgroundColor => widget.unSelectedTabBackgroundColor; Color? get selectedBorderColor => widget.selectedBorderColor; - RxBool? get blockTab => widget.blockTab; DesktopTabController get controller => widget.controller; RxList get invisibleTabKeys => widget.invisibleTabKeys; Debouncer get _scrollDebounce => widget._scrollDebounce; @@ -368,7 +370,7 @@ class _DesktopTabState extends State void _setMaximized(bool maximize) { stateGlobal.setMaximized(maximize); - _saveFrameDebounce.call(_saveFrame); + _saveFrame(); setState(() {}); } @@ -403,24 +405,29 @@ class _DesktopTabState extends State super.onWindowUnmaximize(); } - _saveFrame() async { - if (tabType == DesktopTabType.main) { - await saveWindowPosition(WindowType.Main); - } else if (kWindowType != null && kWindowId != null) { - await saveWindowPosition(kWindowType!, windowId: kWindowId); + _saveFrame({bool? flush}) async { + try { + if (tabType == DesktopTabType.main) { + await saveWindowPosition(WindowType.Main, flush: flush); + } else if (kWindowType != null && kWindowId != null) { + await saveWindowPosition(kWindowType!, + windowId: kWindowId, flush: flush); + } + } catch (e) { + debugPrint('Error saving window position: $e'); } } @override void onWindowMoved() { - _saveFrameDebounce.call(_saveFrame); + _saveFrame(); super.onWindowMoved(); } @override void onWindowResized() { - _saveFrameDebounce.call(_saveFrame); - super.onWindowMoved(); + _saveFrame(); + super.onWindowResized(); } @override @@ -458,6 +465,8 @@ class _DesktopTabState extends State }); } + await _saveFrame(flush: true); + // hide window on close if (isMainWindow) { if (rustDeskWinManager.getActiveWindows().contains(kMainWindowId)) { @@ -533,21 +542,9 @@ class _DesktopTabState extends State ]); } - Widget _buildBlock({required Widget child}) { - if (blockTab != null) { - return buildRemoteBlock( - child: child, - block: blockTab!, - use: canBeBlocked, - mask: tabType == DesktopTabType.main); - } else { - return child; - } - } - List _tabWidgets = []; Widget _buildPageView() { - final child = _buildBlock( + final child = Container( child: Obx(() => PageView( controller: state.value.pageController, physics: NeverScrollableScrollPhysics(), @@ -596,14 +593,13 @@ class _DesktopTabState extends State } Widget _buildBar() { + final isIncomingHomePage = bind.isIncomingOnly() && isInHomePage(); return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded( child: GestureDetector( // custom double tap handler - onTap: !(bind.isIncomingOnly() && isInHomePage()) && - showMaximize + onTap: !isIncomingHomePage && showMaximize ? () { final current = DateTime.now().millisecondsSinceEpoch; final elapsed = current - _lastClickTime; @@ -614,7 +610,7 @@ class _DesktopTabState extends State .then((value) => stateGlobal.setMaximized(value)); } } - : null, + : (isIncomingHomePage ? () {} : null), // Keep tap recognizer for Windows touch. onPanStart: (_) => startDragging(isMainWindow), onPanCancel: () { // We want to disable dragging of the tab area in the tab bar. @@ -662,7 +658,9 @@ class _DesktopTabState extends State controller.state.value.scrollController; if (!sc.canScroll) return; _scrollDebounce.call(() { - sc.animateTo(sc.offset + e.scrollDelta.dy, + double adjust = 2.5; + sc.animateTo( + sc.offset + e.scrollDelta.dy * adjust, duration: Duration(milliseconds: 200), curve: Curves.ease); }); @@ -740,6 +738,7 @@ class WindowActionPanelState extends State { return widget.tabController.state.value.tabs.length > 1 && (widget.tabController.tabType == DesktopTabType.remoteScreen || widget.tabController.tabType == DesktopTabType.fileTransfer || + widget.tabController.tabType == DesktopTabType.viewCamera || widget.tabController.tabType == DesktopTabType.portForward || widget.tabController.tabType == DesktopTabType.cm); } @@ -1086,11 +1085,12 @@ class _TabState extends State<_Tab> with RestorationMixin { return ConstrainedBox( constraints: BoxConstraints(maxWidth: widget.maxLabelWidth ?? 200), child: Tooltip( - message: widget.tabType == DesktopTabType.main - ? '' - : translate(widget.label.value), + message: + widget.tabType == DesktopTabType.main ? '' : widget.label.value, child: Text( - translate(widget.label.value), + widget.tabType == DesktopTabType.main + ? translate(widget.label.value) + : widget.label.value, textAlign: TextAlign.center, style: TextStyle( color: isSelected diff --git a/flutter/lib/desktop/widgets/update_progress.dart b/flutter/lib/desktop/widgets/update_progress.dart new file mode 100644 index 000000000..93f661b7b --- /dev/null +++ b/flutter/lib/desktop/widgets/update_progress.dart @@ -0,0 +1,267 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_hbb/common.dart'; +import 'package:flutter_hbb/models/platform_model.dart'; +import 'package:get/get.dart'; +import 'package:url_launcher/url_launcher.dart'; + +final _isExtracting = false.obs; + +void handleUpdate(String releasePageUrl) { + _isExtracting.value = false; + String downloadUrl = releasePageUrl.replaceAll('tag', 'download'); + String version = downloadUrl.substring(downloadUrl.lastIndexOf('/') + 1); + final String downloadFile = + bind.mainGetCommonSync(key: 'download-file-$version'); + if (downloadFile.startsWith('error:')) { + final error = downloadFile.replaceFirst('error:', ''); + msgBox(gFFI.sessionId, 'custom-nocancel-nook-hasclose', 'Error', error, + releasePageUrl, gFFI.dialogManager); + return; + } + downloadUrl = '$downloadUrl/$downloadFile'; + + SimpleWrapper downloadId = SimpleWrapper(''); + SimpleWrapper onCanceled = SimpleWrapper(() {}); + gFFI.dialogManager.dismissAll(); + gFFI.dialogManager.show((setState, close, context) { + return CustomAlertDialog( + title: Obx(() => Text(translate(_isExtracting.isTrue + ? 'Preparing for installation ...' + : 'Downloading {$appName}'))), + content: + UpdateProgress(releasePageUrl, downloadUrl, downloadId, onCanceled) + .marginSymmetric(horizontal: 8) + .paddingOnly(top: 12), + actions: [ + if (_isExtracting.isFalse) dialogButton(translate('Cancel'), onPressed: () async { + onCanceled.value(); + await bind.mainSetCommon( + key: 'cancel-downloader', value: downloadId.value); + // Wait for the downloader to be removed. + for (int i = 0; i < 10; i++) { + await Future.delayed(const Duration(milliseconds: 300)); + final isCanceled = 'error:Downloader not found' == + await bind.mainGetCommon( + key: 'download-data-${downloadId.value}'); + if (isCanceled) { + break; + } + } + close(); + }, isOutline: true), + ]); + }); +} + +class UpdateProgress extends StatefulWidget { + final String releasePageUrl; + final String downloadUrl; + final SimpleWrapper downloadId; + final SimpleWrapper onCanceled; + UpdateProgress( + this.releasePageUrl, this.downloadUrl, this.downloadId, this.onCanceled, + {Key? key}) + : super(key: key); + + @override + State createState() => UpdateProgressState(); +} + +class UpdateProgressState extends State { + Timer? _timer; + int? _totalSize; + int _downloadedSize = 0; + int _getDataFailedCount = 0; + final String _eventKeyDownloadNewVersion = 'download-new-version'; + final String _eventKeyExtractUpdateDmg = 'extract-update-dmg'; + + @override + void initState() { + super.initState(); + widget.onCanceled.value = () { + cancelQueryTimer(); + }; + platformFFI.registerEventHandler(_eventKeyDownloadNewVersion, + _eventKeyDownloadNewVersion, handleDownloadNewVersion, + replace: true); + bind.mainSetCommon(key: 'download-new-version', value: widget.downloadUrl); + if (isMacOS) { + platformFFI.registerEventHandler(_eventKeyExtractUpdateDmg, + _eventKeyExtractUpdateDmg, handleExtractUpdateDmg, + replace: true); + } + } + + @override + void dispose() { + cancelQueryTimer(); + platformFFI.unregisterEventHandler( + _eventKeyDownloadNewVersion, _eventKeyDownloadNewVersion); + if (isMacOS) { + platformFFI.unregisterEventHandler( + _eventKeyExtractUpdateDmg, _eventKeyExtractUpdateDmg); + } + super.dispose(); + } + + void cancelQueryTimer() { + _timer?.cancel(); + _timer = null; + } + + Future handleDownloadNewVersion(Map evt) async { + if (evt.containsKey('id')) { + widget.downloadId.value = evt['id'] as String; + _timer = Timer.periodic(const Duration(milliseconds: 300), (timer) { + _updateDownloadData(); + }); + } else { + if (evt.containsKey('error')) { + _onError(evt['error'] as String); + } else { + // unreachable + _onError('$evt'); + } + } + } + + // `isExtractDmg` is true when handling extract-update-dmg event. + // It's a rare case that the dmg file is corrupted and cannot be extracted. + void _onError(String error, {bool isExtractDmg = false}) { + cancelQueryTimer(); + + debugPrint( + '${isExtractDmg ? "Extract" : "Download"} new version error: $error'); + final msgBoxType = 'custom-nocancel-nook-hasclose'; + final msgBoxTitle = 'Error'; + final msgBoxText = 'download-new-version-failed-tip'; + final dialogManager = gFFI.dialogManager; + + close() { + dialogManager.dismissAll(); + } + + jumplink() { + launchUrl(Uri.parse(widget.releasePageUrl)); + dialogManager.dismissAll(); + } + + retry() { + dialogManager.dismissAll(); + handleUpdate(widget.releasePageUrl); + } + + final List buttons = [ + dialogButton('Download', onPressed: jumplink), + if (!isExtractDmg) dialogButton('Retry', onPressed: retry), + dialogButton('Close', onPressed: close), + ]; + dialogManager.dismissAll(); + dialogManager.show( + (setState, close, context) => CustomAlertDialog( + title: null, + content: SelectionArea( + child: msgboxContent(msgBoxType, msgBoxTitle, msgBoxText)), + actions: buttons, + ), + tag: '$msgBoxType-$msgBoxTitle-$msgBoxTitle', + ); + } + + void _updateDownloadData() { + String err = ''; + String downloadData = + bind.mainGetCommonSync(key: 'download-data-${widget.downloadId.value}'); + if (downloadData.startsWith('error:')) { + err = downloadData.substring('error:'.length); + } else { + try { + jsonDecode(downloadData).forEach((key, value) { + if (key == 'total_size') { + if (value != null && value is int) { + _totalSize = value; + } + } else if (key == 'downloaded_size') { + _downloadedSize = value as int; + } else if (key == 'error') { + if (value != null) { + err = value.toString(); + } + } + }); + } catch (e) { + _getDataFailedCount += 1; + debugPrint( + 'Failed to get download data ${widget.downloadUrl}, error $e'); + if (_getDataFailedCount > 3) { + err = e.toString(); + } + } + } + if (err != '') { + _onError(err); + } else { + if (_totalSize != null && _downloadedSize >= _totalSize!) { + cancelQueryTimer(); + bind.mainSetCommon( + key: 'remove-downloader', value: widget.downloadId.value); + if (_totalSize == 0) { + _onError('The download file size is 0.'); + } else { + setState(() {}); + if (isMacOS) { + bind.mainSetCommon( + key: 'extract-update-dmg', value: widget.downloadUrl); + _isExtracting.value = true; + } else { + updateMsgBox(); + } + } + } else { + setState(() {}); + } + } + } + + void updateMsgBox() { + msgBox( + gFFI.sessionId, + 'custom-nocancel', + '{$appName} Update', + '{$appName}-to-update-tip', + '', + gFFI.dialogManager, + onSubmit: () { + debugPrint('Downloaded, update to new version now'); + bind.mainSetCommon(key: 'update-me', value: widget.downloadUrl); + }, + submitTimeout: 5, + ); + } + + Future handleExtractUpdateDmg(Map evt) async { + _isExtracting.value = false; + if (evt.containsKey('err') && (evt['err'] as String).isNotEmpty) { + _onError(evt['err'] as String, isExtractDmg: true); + } else { + updateMsgBox(); + } + } + + @override + Widget build(BuildContext context) { + getValue() => _totalSize == null + ? 0.0 + : (_totalSize == 0 ? 1.0 : _downloadedSize / _totalSize!); + return LinearProgressIndicator( + value: _isExtracting.isTrue ? null : getValue(), + minHeight: 20, + borderRadius: BorderRadius.circular(5), + backgroundColor: Colors.grey[300], + valueColor: const AlwaysStoppedAnimation(Colors.blue), + ); + } +} diff --git a/flutter/lib/main.dart b/flutter/lib/main.dart index 00afbb001..91773afe7 100644 --- a/flutter/lib/main.dart +++ b/flutter/lib/main.dart @@ -11,8 +11,10 @@ import 'package:flutter_hbb/desktop/pages/desktop_tab_page.dart'; import 'package:flutter_hbb/desktop/pages/install_page.dart'; import 'package:flutter_hbb/desktop/pages/server_page.dart'; import 'package:flutter_hbb/desktop/screen/desktop_file_transfer_screen.dart'; +import 'package:flutter_hbb/desktop/screen/desktop_view_camera_screen.dart'; import 'package:flutter_hbb/desktop/screen/desktop_port_forward_screen.dart'; import 'package:flutter_hbb/desktop/screen/desktop_remote_screen.dart'; +import 'package:flutter_hbb/desktop/screen/desktop_terminal_screen.dart'; import 'package:flutter_hbb/desktop/widgets/refresh_wrapper.dart'; import 'package:flutter_hbb/models/state_model.dart'; import 'package:flutter_hbb/utils/multi_window_manager.dart'; @@ -76,6 +78,13 @@ Future main(List args) async { kAppTypeDesktopFileTransfer, ); break; + case WindowType.ViewCamera: + desktopType = DesktopType.viewCamera; + runMultiWindow( + argument, + kAppTypeDesktopViewCamera, + ); + break; case WindowType.PortForward: desktopType = DesktopType.portForward; runMultiWindow( @@ -83,6 +92,12 @@ Future main(List args) async { kAppTypeDesktopPortForward, ); break; + case WindowType.Terminal: + desktopType = DesktopType.terminal; + runMultiWindow( + argument, + kAppTypeDesktopTerminal, + ); default: break; } @@ -120,6 +135,7 @@ Future initEnv(String appType) async { void runMainApp(bool startService) async { // register uni links await initEnv(kAppTypeMain); + checkUpdate(); // trigger connection status updater await bind.mainCheckConnectStatus(); if (startService) { @@ -131,8 +147,15 @@ void runMainApp(bool startService) async { gFFI.userModel.refreshCurrentUser(); runApp(App()); + bool? alwaysOnTop; + if (isDesktop) { + alwaysOnTop = + bind.mainGetBuildinOption(key: "main-window-always-on-top") == 'Y'; + } + // Set window option. - WindowOptions windowOptions = getHiddenTitleBarWindowOptions(); + WindowOptions windowOptions = getHiddenTitleBarWindowOptions( + isMainWindow: true, alwaysOnTop: alwaysOnTop); windowManager.waitUntilReadyToShow(windowOptions, () async { // Restore the location of the main window before window hide or show. await restoreWindowPosition(WindowType.Main); @@ -156,6 +179,7 @@ void runMainApp(bool startService) async { void runMobileApp() async { await initEnv(kAppTypeMain); + checkUpdate(); if (isAndroid) androidChannelInit(); if (isAndroid) platformFFI.syncAndroidServiceAppDirConfigPath(); draggablePositions.load(); @@ -189,11 +213,22 @@ void runMultiWindow( params: argument, ); break; + case kAppTypeDesktopViewCamera: + draggablePositions.load(); + widget = DesktopViewCameraScreen( + params: argument, + ); + break; case kAppTypeDesktopPortForward: widget = DesktopPortForwardScreen( params: argument, ); break; + case kAppTypeDesktopTerminal: + widget = DesktopTerminalScreen( + params: argument, + ); + break; default: // no such appType exit(0); @@ -224,9 +259,25 @@ void runMultiWindow( await restoreWindowPosition(WindowType.FileTransfer, windowId: kWindowId!); break; + case kAppTypeDesktopViewCamera: + // If screen rect is set, the window will be moved to the target screen and then set fullscreen. + if (argument['screen_rect'] == null) { + // display can be used to control the offset of the window. + await restoreWindowPosition( + WindowType.ViewCamera, + windowId: kWindowId!, + peerId: argument['id'] as String?, + // FIXME: fix display index. + display: argument['display'] as int?, + ); + } + break; case kAppTypeDesktopPortForward: await restoreWindowPosition(WindowType.PortForward, windowId: kWindowId!); break; + case kAppTypeDesktopTerminal: + await restoreWindowPosition(WindowType.Terminal, windowId: kWindowId!); + break; default: // no such appType exit(0); @@ -352,7 +403,10 @@ void runInstallPage() async { } WindowOptions getHiddenTitleBarWindowOptions( - {Size? size, bool center = false, bool? alwaysOnTop}) { + {bool isMainWindow = false, + Size? size, + bool center = false, + bool? alwaysOnTop}) { var defaultTitleBarStyle = TitleBarStyle.hidden; // we do not hide titlebar on win7 because of the frame overflow. if (kUseCompatibleUiMode) { @@ -361,7 +415,7 @@ WindowOptions getHiddenTitleBarWindowOptions( return WindowOptions( size: size, center: center, - backgroundColor: Colors.transparent, + backgroundColor: (isMacOS && isMainWindow) ? null : Colors.transparent, skipTaskbar: false, titleBarStyle: defaultTitleBarStyle, alwaysOnTop: alwaysOnTop, @@ -483,9 +537,10 @@ class _AppState extends State with WidgetsBindingObserver { child = keyListenerBuilder(context, child); } if (isLinux) { - child = buildVirtualWindowFrame(context, child); + return buildVirtualWindowFrame(context, child); + } else { + return workaroundWindowBorder(context, child); } - return child; }, ), ); diff --git a/flutter/lib/mobile/pages/connection_page.dart b/flutter/lib/mobile/pages/connection_page.dart index 89b71c177..0e7e0a480 100644 --- a/flutter/lib/mobile/pages/connection_page.dart +++ b/flutter/lib/mobile/pages/connection_page.dart @@ -4,6 +4,7 @@ import 'package:auto_size_text_field/auto_size_text_field.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hbb/common/formatter/id_formatter.dart'; import 'package:flutter_hbb/common/widgets/connection_page_title.dart'; +import 'package:flutter_hbb/models/state_model.dart'; import 'package:get/get.dart'; import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -40,14 +41,16 @@ class _ConnectionPageState extends State { final _idController = IDTextEditingController(); final RxBool _idEmpty = true.obs; - /// Update url. If it's not null, means an update is available. - var _updateUrl = ''; - List peers = []; + final FocusNode _idFocusNode = FocusNode(); + final TextEditingController _idEditingController = TextEditingController(); + + final AllPeersLoader _allPeersLoader = AllPeersLoader(); - bool isPeersLoading = false; - bool isPeersLoaded = false; StreamSubscription? _uniLinksSubscription; + // https://github.com/flutter/flutter/issues/157244 + Iterable _autocompleteOpts = []; + _ConnectionPageState() { if (!isWeb) _uniLinksSubscription = listenUniLinks(); _idController.addListener(() { @@ -59,6 +62,8 @@ class _ConnectionPageState extends State { @override void initState() { super.initState(); + _allPeersLoader.init(setState); + _idFocusNode.addListener(onFocusChanged); if (_idController.text.isEmpty) { WidgetsBinding.instance.addPostFrameCallback((_) async { final lastRemoteId = await bind.mainGetLastRemoteId(); @@ -69,22 +74,7 @@ class _ConnectionPageState extends State { } }); } - if (isAndroid) { - if (!bind.isCustomClient()) { - platformFFI.registerEventHandler( - kCheckSoftwareUpdateFinish, kCheckSoftwareUpdateFinish, - (Map evt) async { - if (evt['url'] is String) { - setState(() { - _updateUrl = evt['url']; - }); - } - }); - Timer(const Duration(seconds: 1), () async { - bind.mainGetSoftwareUpdateUrl(); - }); - } - } + Get.put(_idEditingController); } @override @@ -94,7 +84,8 @@ class _ConnectionPageState extends State { slivers: [ SliverList( delegate: SliverChildListDelegate([ - if (!bind.isCustomClient()) _buildUpdateUI(), + if (!bind.isCustomClient() && !isIOS) + Obx(() => _buildUpdateUI(stateGlobal.updateUrl.value)), _buildRemoteIDTextField(), ])), SliverFillRemaining( @@ -112,17 +103,37 @@ class _ConnectionPageState extends State { connect(context, id); } + void onFocusChanged() { + _idEmpty.value = _idEditingController.text.isEmpty; + if (_idFocusNode.hasFocus) { + if (_allPeersLoader.needLoad) { + _allPeersLoader.getAllPeers(); + } + + final textLength = _idEditingController.value.text.length; + // Select all to facilitate removing text, just following the behavior of address input of chrome. + _idEditingController.selection = + TextSelection(baseOffset: 0, extentOffset: textLength); + } + } + /// UI for software update. - /// If [_updateUrl] is not empty, shows a button to update the software. - Widget _buildUpdateUI() { - return _updateUrl.isEmpty + /// If _updateUrl] is not empty, shows a button to update the software. + Widget _buildUpdateUI(String updateUrl) { + return updateUrl.isEmpty ? const SizedBox(height: 0) : InkWell( onTap: () async { final url = 'https://rustdesk.com/download'; - if (await canLaunchUrl(Uri.parse(url))) { - await launchUrl(Uri.parse(url)); - } + // https://pub.dev/packages/url_launcher#configuration + // https://developer.android.com/training/package-visibility/use-cases#open-urls-custom-tabs + // + // `await launchUrl(Uri.parse(url))` can also run if skip + // 1. The following check + // 2. `` in AndroidManifest.xml + // + // But it is better to add the check. + await launchUrl(Uri.parse(url)); }, child: Container( alignment: AlignmentDirectional.center, @@ -134,18 +145,6 @@ class _ConnectionPageState extends State { color: Colors.white, fontWeight: FontWeight.bold)))); } - Future _fetchPeers() async { - setState(() { - isPeersLoading = true; - }); - await Future.delayed(Duration(milliseconds: 100)); - peers = await getAllPeers(); - setState(() { - isPeersLoading = false; - isPeersLoaded = true; - }); - } - /// UI for the remote ID TextField. /// Search for a peer and connect to it if the id exists. Widget _buildRemoteIDTextField() { @@ -163,11 +162,12 @@ class _ConnectionPageState extends State { Expanded( child: Container( padding: const EdgeInsets.only(left: 16, right: 16), - child: Autocomplete( + child: RawAutocomplete( optionsBuilder: (TextEditingValue textEditingValue) { if (textEditingValue.text == '') { - return const Iterable.empty(); - } else if (peers.isEmpty && !isPeersLoaded) { + _autocompleteOpts = const Iterable.empty(); + } else if (_allPeersLoader.peers.isEmpty && + !_allPeersLoader.isPeersLoaded) { Peer emptyPeer = Peer( id: '', username: '', @@ -181,8 +181,10 @@ class _ConnectionPageState extends State { rdpPort: '', rdpUsername: '', loginName: '', + device_group_name: '', + note: '', ); - return [emptyPeer]; + _autocompleteOpts = [emptyPeer]; } else { String textWithoutSpaces = textEditingValue.text.replaceAll(" ", ""); @@ -194,7 +196,7 @@ class _ConnectionPageState extends State { } String textToFind = textEditingValue.text.toLowerCase(); - return peers + _autocompleteOpts = _allPeersLoader.peers .where((peer) => peer.id.toLowerCase().contains(textToFind) || peer.username @@ -206,26 +208,16 @@ class _ConnectionPageState extends State { peer.alias.toLowerCase().contains(textToFind)) .toList(); } + return _autocompleteOpts; }, + focusNode: _idFocusNode, + textEditingController: _idEditingController, fieldViewBuilder: (BuildContext context, TextEditingController fieldTextEditingController, FocusNode fieldFocusNode, VoidCallback onFieldSubmitted) { - fieldTextEditingController.text = _idController.text; - Get.put( - fieldTextEditingController); - fieldFocusNode.addListener(() async { - _idEmpty.value = - fieldTextEditingController.text.isEmpty; - if (fieldFocusNode.hasFocus && !isPeersLoading) { - _fetchPeers(); - } - }); - final textLength = - fieldTextEditingController.value.text.length; - // select all to facilitate removing text, just following the behavior of address input of chrome - fieldTextEditingController.selection = TextSelection( - baseOffset: 0, extentOffset: textLength); + updateTextAndPreserveSelection( + fieldTextEditingController, _idController.text); return AutoSizeTextField( controller: fieldTextEditingController, focusNode: fieldFocusNode, @@ -274,6 +266,7 @@ class _ConnectionPageState extends State { optionsViewBuilder: (BuildContext context, AutocompleteOnSelected onSelected, Iterable options) { + options = _autocompleteOpts; double maxHeight = options.length * 50; if (options.length == 1) { maxHeight = 52; @@ -304,7 +297,9 @@ class _ConnectionPageState extends State { maxHeight: maxHeight, maxWidth: 320, ), - child: peers.isEmpty && isPeersLoading + child: _allPeersLoader + .peers.isEmpty && + !_allPeersLoader.isPeersLoaded ? Container( height: 80, child: Center( @@ -367,16 +362,16 @@ class _ConnectionPageState extends State { void dispose() { _uniLinksSubscription?.cancel(); _idController.dispose(); + _idFocusNode.removeListener(onFocusChanged); + _allPeersLoader.clear(); + _idFocusNode.dispose(); + _idEditingController.dispose(); if (Get.isRegistered()) { Get.delete(); } if (Get.isRegistered()) { Get.delete(); } - if (!bind.isCustomClient()) { - platformFFI.unregisterEventHandler( - kCheckSoftwareUpdateFinish, kCheckSoftwareUpdateFinish); - } super.dispose(); } } diff --git a/flutter/lib/mobile/pages/file_manager_page.dart b/flutter/lib/mobile/pages/file_manager_page.dart index e017b5b6f..1e793bca7 100644 --- a/flutter/lib/mobile/pages/file_manager_page.dart +++ b/flutter/lib/mobile/pages/file_manager_page.dart @@ -5,18 +5,22 @@ import 'package:flutter_breadcrumb/flutter_breadcrumb.dart'; import 'package:flutter_hbb/models/file_model.dart'; import 'package:get/get.dart'; import 'package:toggle_switch/toggle_switch.dart'; -import 'package:wakelock_plus/wakelock_plus.dart'; import '../../common.dart'; import '../../common/widgets/dialog.dart'; class FileManagerPage extends StatefulWidget { FileManagerPage( - {Key? key, required this.id, this.password, this.isSharedPassword}) + {Key? key, + required this.id, + this.password, + this.isSharedPassword, + this.forceRelay}) : super(key: key); final String id; final String? password; final bool? isSharedPassword; + final bool? forceRelay; @override State createState() => _FileManagerPageState(); @@ -67,6 +71,7 @@ class _FileManagerPageState extends State { showLocal ? model.localController : model.remoteController; FileDirectory get currentDir => currentFileController.directory.value; DirectoryOptions get currentOptions => currentFileController.options.value; + final _uniqueKey = UniqueKey(); @override void initState() { @@ -74,13 +79,14 @@ class _FileManagerPageState extends State { gFFI.start(widget.id, isFileTransfer: true, password: widget.password, - isSharedPassword: widget.isSharedPassword); + isSharedPassword: widget.isSharedPassword, + forceRelay: widget.forceRelay); WidgetsBinding.instance.addPostFrameCallback((_) { gFFI.dialogManager .showLoading(translate('Connecting...'), onCancel: closeConnection); }); gFFI.ffiModel.updateEventListener(gFFI.sessionId, widget.id); - WakelockPlus.enable(); + WakelockManager.enable(_uniqueKey); } @override @@ -88,8 +94,9 @@ class _FileManagerPageState extends State { model.close().whenComplete(() { gFFI.close(); gFFI.dialogManager.dismissAll(); - WakelockPlus.disable(); + WakelockManager.disable(_uniqueKey); }); + model.jobController.clear(); super.dispose(); } @@ -110,8 +117,7 @@ class _FileManagerPageState extends State { leading: Row(children: [ IconButton( icon: Icon(Icons.close), - onPressed: () => - clientClose(gFFI.sessionId, gFFI.dialogManager)), + onPressed: () => clientClose(gFFI.sessionId, gFFI)), ]), centerTitle: true, title: ToggleSwitch( @@ -225,7 +231,7 @@ class _FileManagerPageState extends State { errorText: errorText, ), controller: name, - ), + ).workaroundFreezeLinuxMint(), ], ), actions: [ @@ -349,15 +355,21 @@ class _FileManagerPageState extends State { return Offstage(); } - switch (jobTable.last.state) { + // Find the first job that is in progress (the one actually transferring data) + // Rust backend processes jobs sequentially, so the first inProgress job is the active one + final activeJob = jobTable + .firstWhereOrNull((job) => job.state == JobState.inProgress) ?? + jobTable.last; + + switch (activeJob.state) { case JobState.inProgress: return BottomSheetBody( leading: CircularProgressIndicator(), title: translate("Waiting"), text: - "${translate("Speed")}: ${readableFileSize(jobTable.last.speed)}/s", + "${translate("Speed")}: ${readableFileSize(activeJob.speed)}/s", onCanceled: () { - model.jobController.cancelJob(jobTable.last.id); + model.jobController.cancelJob(activeJob.id); jobTable.clear(); }, ); @@ -365,7 +377,7 @@ class _FileManagerPageState extends State { return BottomSheetBody( leading: Icon(Icons.check), title: "${translate("Successful")}!", - text: jobTable.last.display(), + text: activeJob.display(), onCanceled: () => jobTable.clear(), ); case JobState.error: @@ -422,6 +434,7 @@ class FileManagerView extends StatefulWidget { class _FileManagerViewState extends State { final _listScrollController = ScrollController(); final _breadCrumbScroller = ScrollController(); + late final ascending = Rx(controller.sortAscending); bool get isLocal => widget.controller.isLocal; FileController get controller => widget.controller; @@ -633,7 +646,17 @@ class _FileManagerViewState extends State { )) .toList(); }, - onSelected: controller.changeSortStyle), + onSelected: (sortBy) { + // If selecting the same sort option, flip the order + // If selecting a different sort option, use ascending order + if (controller.sortBy.value == sortBy) { + ascending.value = !controller.sortAscending; + } else { + ascending.value = true; + } + controller.changeSortStyle(sortBy, + ascending: ascending.value); + }), ], ) ], diff --git a/flutter/lib/mobile/pages/home_page.dart b/flutter/lib/mobile/pages/home_page.dart index efccc5de6..651ec4f17 100644 --- a/flutter/lib/mobile/pages/home_page.dart +++ b/flutter/lib/mobile/pages/home_page.dart @@ -204,13 +204,14 @@ class WebHomePage extends StatelessWidget { return; } bool isFileTransfer = false; + bool isViewCamera = false; + bool isTerminal = false; String? id; String? password; for (int i = 0; i < args.length; i++) { switch (args[i]) { case '--connect': case '--play': - isFileTransfer = false; id = args[i + 1]; i++; break; @@ -219,6 +220,22 @@ class WebHomePage extends StatelessWidget { id = args[i + 1]; i++; break; + case '--view-camera': + isViewCamera = true; + id = args[i + 1]; + i++; + break; + case '--terminal': + isTerminal = true; + id = args[i + 1]; + i++; + break; + case '--terminal-admin': + setEnvTerminalAdmin(); + isTerminal = true; + id = args[i + 1]; + i++; + break; case '--password': password = args[i + 1]; i++; @@ -228,7 +245,11 @@ class WebHomePage extends StatelessWidget { } } if (id != null) { - connect(context, id, isFileTransfer: isFileTransfer, password: password); + connect(context, id, + isFileTransfer: isFileTransfer, + isViewCamera: isViewCamera, + isTerminal: isTerminal, + password: password); } } } diff --git a/flutter/lib/mobile/pages/remote_page.dart b/flutter/lib/mobile/pages/remote_page.dart index 40890f228..74a5af45c 100644 --- a/flutter/lib/mobile/pages/remote_page.dart +++ b/flutter/lib/mobile/pages/remote_page.dart @@ -5,13 +5,14 @@ import 'package:flutter/services.dart'; import 'package:flutter_hbb/common/shared_state.dart'; import 'package:flutter_hbb/common/widgets/toolbar.dart'; import 'package:flutter_hbb/consts.dart'; +import 'package:flutter_hbb/mobile/widgets/floating_mouse.dart'; +import 'package:flutter_hbb/mobile/widgets/floating_mouse_widgets.dart'; import 'package:flutter_hbb/mobile/widgets/gesture_help.dart'; import 'package:flutter_hbb/models/chat_model.dart'; import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart'; import 'package:flutter_svg/svg.dart'; import 'package:get/get.dart'; import 'package:provider/provider.dart'; -import 'package:wakelock_plus/wakelock_plus.dart'; import '../../common.dart'; import '../../common/widgets/overlay.dart'; @@ -22,27 +23,49 @@ import '../../models/model.dart'; import '../../models/platform_model.dart'; import '../../utils/image.dart'; import '../widgets/dialog.dart'; +import '../widgets/custom_scale_widget.dart'; final initText = '1' * 1024; +// Workaround for Android (default input method, Microsoft SwiftKey keyboard) when using physical keyboard. +// When connecting a physical keyboard, `KeyEvent.physicalKey.usbHidUsage` are wrong is using Microsoft SwiftKey keyboard. +// https://github.com/flutter/flutter/issues/159384 +// https://github.com/flutter/flutter/issues/159383 +void _disableAndroidSoftKeyboard({bool? isKeyboardVisible}) { + if (isAndroid) { + if (isKeyboardVisible != true) { + // `enable_soft_keyboard` will be set to `true` when clicking the keyboard icon, in `openKeyboard()`. + gFFI.invokeMethod("enable_soft_keyboard", false); + } + } +} + class RemotePage extends StatefulWidget { - RemotePage({Key? key, required this.id, this.password, this.isSharedPassword}) + RemotePage( + {Key? key, + required this.id, + this.password, + this.isSharedPassword, + this.forceRelay}) : super(key: key); final String id; final String? password; final bool? isSharedPassword; + final bool? forceRelay; @override State createState() => _RemotePageState(id); } -class _RemotePageState extends State { +class _RemotePageState extends State with WidgetsBindingObserver { Timer? _timer; bool _showBar = !isWebDesktop; bool _showGestureHelp = false; String _value = ''; Orientation? _currentOrientation; + final _uniqueKey = UniqueKey(); + Timer? _iosKeyboardWorkaroundTimer; final _blockableOverlayState = BlockableOverlayState(); @@ -57,9 +80,6 @@ class _RemotePageState extends State { final TextEditingController _textController = TextEditingController(text: initText); - // This timer is used to check the composing status of the soft keyboard. - // It is used for Android, Korean(and other similar) input method. - Timer? _composingTimer; _RemotePageState(String id) { initSharedStates(id); @@ -75,15 +95,14 @@ class _RemotePageState extends State { widget.id, password: widget.password, isSharedPassword: widget.isSharedPassword, + forceRelay: widget.forceRelay, ); WidgetsBinding.instance.addPostFrameCallback((_) { SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: []); gFFI.dialogManager .showLoading(translate('Connecting...'), onCancel: closeConnection); }); - if (!isWeb) { - WakelockPlus.enable(); - } + WakelockManager.enable(_uniqueKey); _physicalFocusNode.requestFocus(); gFFI.inputModel.listenToMouse(true); gFFI.qualityMonitorModel.checkShowQualityMonitor(sessionId); @@ -98,11 +117,15 @@ class _RemotePageState extends State { if (gFFI.recordingModel.start) { showToast(translate('Automatically record outgoing sessions')); } + _disableAndroidSoftKeyboard( + isKeyboardVisible: keyboardVisibilityController.isVisible); }); + WidgetsBinding.instance.addObserver(this); } @override Future dispose() async { + WidgetsBinding.instance.removeObserver(this); // https://github.com/flutter/flutter/issues/64935 super.dispose(); gFFI.dialogManager.hideMobileActionsOverlay(store: false); @@ -114,13 +137,11 @@ class _RemotePageState extends State { _physicalFocusNode.dispose(); await gFFI.close(); _timer?.cancel(); - _composingTimer?.cancel(); + _iosKeyboardWorkaroundTimer?.cancel(); gFFI.dialogManager.dismissAll(); await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: SystemUiOverlay.values); - if (!isWeb) { - await WakelockPlus.disable(); - } + WakelockManager.disable(_uniqueKey); await keyboardSubscription.cancel(); removeSharedStates(widget.id); // `on_voice_call_closed` should be called when the connection is ended. @@ -129,6 +150,19 @@ class _RemotePageState extends State { gFFI.chatModel.onVoiceCallClosed("End connetion"); } + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + trySyncClipboard(); + } + } + + // For client side + // When swithing from other app to this app, try to sync clipboard. + void trySyncClipboard() { + gFFI.invokeMethod("try_sync_clipboard"); + } + // to-do: It should be better to use transparent color instead of the bgColor. // But for now, the transparent color will cause the canvas to be white. // I'm sure that the white color is caused by the Overlay widget in BlockableOverlay. @@ -150,8 +184,24 @@ class _RemotePageState extends State { gFFI.ffiModel.pi.version.isNotEmpty) { gFFI.invokeMethod("enable_soft_keyboard", false); } - _composingTimer?.cancel(); + + // Workaround for iOS: physical keyboard input fails after virtual keyboard is hidden + // https://github.com/flutter/flutter/issues/39900 + // https://github.com/rustdesk/rustdesk/discussions/11843#discussioncomment-13499698 - Virtual keyboard issue + if (isIOS) { + _iosKeyboardWorkaroundTimer?.cancel(); + _iosKeyboardWorkaroundTimer = Timer(Duration(milliseconds: 100), () { + if (!mounted) return; + _physicalFocusNode.unfocus(); + _iosKeyboardWorkaroundTimer = Timer(Duration(milliseconds: 50), () { + if (!mounted) return; + _physicalFocusNode.requestFocus(); + }); + }); + } } else { + _iosKeyboardWorkaroundTimer?.cancel(); + _iosKeyboardWorkaroundTimer = null; _timer?.cancel(); _timer = Timer(kMobileDelaySoftKeyboardFocus, () { SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, @@ -214,13 +264,6 @@ class _RemotePageState extends State { } void _handleNonIOSSoftKeyboardInput(String newValue) { - _composingTimer?.cancel(); - if (_textController.value.isComposingRangeValid) { - _composingTimer = Timer(Duration(milliseconds: 25), () { - _handleNonIOSSoftKeyboardInput(_textController.value.text); - }); - return; - } var oldValue = _value; _value = newValue; if (oldValue.isNotEmpty && @@ -261,14 +304,10 @@ class _RemotePageState extends State { } } - Future handleSoftKeyboardInput(String newValue) async { + // handle mobile virtual keyboard + void handleSoftKeyboardInput(String newValue) { if (isIOS) { - // fix: TextFormField onChanged event triggered multiple times when Korean input - // https://github.com/rustdesk/rustdesk/pull/9644 - await Future.delayed(const Duration(milliseconds: 10)); - - if (newValue != _textController.text) return; - _handleIOSSoftKeyboardInput(_textController.text); + _handleIOSSoftKeyboardInput(newValue); } else { _handleNonIOSSoftKeyboardInput(newValue); } @@ -317,7 +356,7 @@ class _RemotePageState extends State { return WillPopScope( onWillPop: () async { - clientClose(sessionId, gFFI.dialogManager); + clientClose(sessionId, gFFI); return false; }, child: Scaffold( @@ -387,12 +426,10 @@ class _RemotePageState extends State { } return Container( color: MyTheme.canvasColor, - child: inputModel.isPhysicalMouse.value - ? getBodyForMobile() - : RawTouchGestureDetectorRegion( - child: getBodyForMobile(), - ffi: gFFI, - ), + child: RawTouchGestureDetectorRegion( + child: getBodyForMobile(), + ffi: gFFI, + ), ); }), ), @@ -435,7 +472,7 @@ class _RemotePageState extends State { color: Colors.white, icon: Icon(Icons.clear), onPressed: () { - clientClose(sessionId, gFFI.dialogManager); + clientClose(sessionId, gFFI); }, ), IconButton( @@ -520,7 +557,9 @@ class _RemotePageState extends State { } bool get showCursorPaint => - !gFFI.ffiModel.isPeerAndroid && !gFFI.canvasModel.cursorEmbedded; + !gFFI.ffiModel.isPeerAndroid && + !gFFI.canvasModel.cursorEmbedded && + !gFFI.inputModel.relativeMouseMode.value; Widget getBodyForMobile() { final keyboardIsVisible = keyboardVisibilityController.isVisible; @@ -528,13 +567,15 @@ class _RemotePageState extends State { color: MyTheme.canvasColor, child: Stack(children: () { final paints = [ - ImagePaint(), + ImagePaint(ffiModel: gFFI.ffiModel), Positioned( top: 10, right: 10, child: QualityMonitor(gFFI.qualityMonitorModel), ), - KeyHelpTools(requestShow: (keyboardIsVisible || _showGestureHelp)), + KeyHelpTools( + keyboardIsVisible: keyboardIsVisible, + showGestureHelp: _showGestureHelp), SizedBox( width: 0, height: 0, @@ -554,20 +595,37 @@ class _RemotePageState extends State { controller: _textController, // trick way to make backspace work always keyboardType: TextInputType.multiline, + // `onChanged` may be called depending on the input method if this widget is wrapped in + // `Focus(onKeyEvent: ..., child: ...)` + // For `Backspace` button in the soft keyboard: + // en/fr input method: + // 1. The button will not trigger `onKeyEvent` if the text field is not empty. + // 2. The button will trigger `onKeyEvent` if the text field is empty. + // ko/zh/ja input method: the button will trigger `onKeyEvent` + // and the event will not popup if `KeyEventResult.handled` is returned. onChanged: handleSoftKeyboardInput, - ), + ).workaroundFreezeLinuxMint(), ), ]; if (showCursorPaint) { paints.add(CursorPaint(widget.id)); } + if (gFFI.ffiModel.touchMode) { + paints.add(FloatingMouse( + ffi: gFFI, + )); + } else { + paints.add(FloatingMouseWidgets( + ffi: gFFI, + )); + } return paints; }())); } Widget getBodyForDesktopWithListener() { final ffiModel = Provider.of(context); - var paints = [ImagePaint()]; + var paints = [ImagePaint(ffiModel: ffiModel)]; if (showCursorPaint) { final cursor = bind.sessionGetToggleOptionSync( sessionId: sessionId, arg: 'show-remote-cursor'); @@ -646,9 +704,9 @@ class _RemotePageState extends State { ); if (index != null) { if (index < mobileActionMenus.length) { - mobileActionMenus[index].onPressed.call(); + mobileActionMenus[index].onPressed?.call(); } else if (index < mobileActionMenus.length + more.length) { - menus[index - mobileActionMenus.length].onPressed.call(); + menus[index - mobileActionMenus.length].onPressed?.call(); } } }(); @@ -721,7 +779,7 @@ class _RemotePageState extends State { elevation: 8, ); if (index != null && index < menus.length) { - menus[index].onPressed.call(); + menus[index].onPressed?.call(); } }); } @@ -733,13 +791,15 @@ class _RemotePageState extends State { controller: ScrollController(), padding: EdgeInsets.symmetric(vertical: 10), child: GestureHelp( - touchMode: gFFI.ffiModel.touchMode, - onTouchModeChange: (t) { - gFFI.ffiModel.toggleTouchMode(); - final v = gFFI.ffiModel.touchMode ? 'Y' : ''; - bind.sessionPeerOption( - sessionId: sessionId, name: kOptionTouchMode, value: v); - }))); + touchMode: gFFI.ffiModel.touchMode, + onTouchModeChange: (t) { + gFFI.ffiModel.toggleTouchMode(); + final v = gFFI.ffiModel.touchMode ? 'Y' : 'N'; + bind.mainSetLocalOption(key: kOptionTouchMode, value: v); + }, + virtualMouseMode: gFFI.ffiModel.virtualMouseMode, + inputModel: gFFI.inputModel, + ))); } // * Currently mobile does not enable map mode @@ -763,10 +823,14 @@ class _RemotePageState extends State { } class KeyHelpTools extends StatefulWidget { - /// need to show by external request, etc [keyboardIsVisible] or [changeTouchMode] - final bool requestShow; + final bool keyboardIsVisible; + final bool showGestureHelp; - KeyHelpTools({required this.requestShow}); + /// need to show by external request, etc [keyboardIsVisible] or [changeTouchMode] + bool get requestShow => keyboardIsVisible || showGestureHelp; + + KeyHelpTools( + {required this.keyboardIsVisible, required this.showGestureHelp}); @override State createState() => _KeyHelpToolsState(); @@ -811,7 +875,8 @@ class _KeyHelpToolsState extends State { final size = renderObject.size; Offset pos = renderObject.localToGlobal(Offset.zero); gFFI.cursorModel.keyHelpToolsVisibilityChanged( - Rect.fromLTWH(pos.dx, pos.dy, size.width, size.height)); + Rect.fromLTWH(pos.dx, pos.dy, size.width, size.height), + widget.keyboardIsVisible); } } @@ -823,13 +888,16 @@ class _KeyHelpToolsState extends State { inputModel.command; if (!_pin && !hasModifierOn && !widget.requestShow) { - gFFI.cursorModel.keyHelpToolsVisibilityChanged(null); + gFFI.cursorModel + .keyHelpToolsVisibilityChanged(null, widget.keyboardIsVisible); return Offstage(); } final size = MediaQuery.of(context).size; final pi = gFFI.ffiModel.pi; final isMac = pi.platform == kPeerPlatformMacOS; + final isWin = pi.platform == kPeerPlatformWindows; + final isLinux = pi.platform == kPeerPlatformLinux; final modifiers = [ wrap('Ctrl ', () { setState(() => inputModel.ctrl = !inputModel.ctrl); @@ -910,6 +978,28 @@ class _KeyHelpToolsState extends State { wrap('PgDn', () { inputModel.inputKey('VK_NEXT'); }), + // to-do: support PrtScr on Mac + if (isWin || isLinux) + wrap('PrtScr', () { + inputModel.inputKey('VK_SNAPSHOT'); + }), + if (isWin || isLinux) + wrap('ScrollLock', () { + inputModel.inputKey('VK_SCROLL'); + }), + if (isWin || isLinux) + wrap('Pause', () { + inputModel.inputKey('VK_PAUSE'); + }), + if (isWin || isLinux) + // Maybe it's better to call it "Menu" + // https://en.wikipedia.org/wiki/Menu_key + wrap('Menu', () { + inputModel.inputKey('Apps'); + }), + wrap('Enter', () { + inputModel.inputKey('VK_ENTER'); + }), SizedBox(width: 9999), wrap('', () { inputModel.inputKey('VK_LEFT'); @@ -956,15 +1046,24 @@ class _KeyHelpToolsState extends State { } class ImagePaint extends StatelessWidget { + final FfiModel ffiModel; + ImagePaint({Key? key, required this.ffiModel}) : super(key: key); + @override Widget build(BuildContext context) { final m = Provider.of(context); final c = Provider.of(context); - final adjust = gFFI.cursorModel.adjustForKeyboard(); var s = c.scale; + if (ffiModel.isPeerLinux) { + final displays = ffiModel.pi.getCurDisplays(); + if (displays.isNotEmpty) { + s = s / displays[0].scale; + } + } + final adjust = c.getAdjustY(); return CustomPaint( painter: ImagePainter( - image: m.image, x: c.x / s, y: (c.y - adjust) / s, scale: s), + image: m.image, x: c.x / s, y: (c.y + adjust) / s, scale: s), ); } } @@ -978,7 +1077,6 @@ class CursorPaint extends StatelessWidget { final m = Provider.of(context); final c = Provider.of(context); final ffiModel = Provider.of(context); - final adjust = gFFI.cursorModel.adjustForKeyboard(); final s = c.scale; double hotx = m.hotx; double hoty = m.hoty; @@ -1010,11 +1108,12 @@ class CursorPaint extends StatelessWidget { factor = s / mins; } final s2 = s < mins ? mins : s; + final adjust = c.getAdjustY(); return CustomPaint( painter: ImagePainter( image: image, x: (m.x - hotx) * factor + c.x / s2, - y: (m.y - hoty) * factor + (c.y - adjust) / s2, + y: (m.y - hoty) * factor + (c.y + adjust) / s2, scale: s2), ); } @@ -1024,13 +1123,21 @@ void showOptions( BuildContext context, String id, OverlayDialogManager dialogManager) async { var displays = []; final pi = gFFI.ffiModel.pi; - final image = gFFI.ffiModel.getConnectionImage(); + final image = gFFI.ffiModel.getConnectionImageText(); if (image != null) { displays.add(Padding(padding: const EdgeInsets.only(top: 8), child: image)); } if (pi.displays.length > 1 && pi.currentDisplay != kAllDisplayValue) { final cur = pi.currentDisplay; final children = []; + final isDarkTheme = MyTheme.currentThemeMode() == ThemeMode.dark; + final numColorSelected = Colors.white; + final numColorUnselected = isDarkTheme ? Colors.grey : Colors.black87; + // We can't use `Theme.of(context).primaryColor` here, the color is: + // - light theme: 0xff2196f3 (Colors.blue) + // - dark theme: 0xff212121 (the canvas color?) + final numBgSelected = + Theme.of(context).colorScheme.primary.withOpacity(0.6); for (var i = 0; i < pi.displays.length; ++i) { children.add(InkWell( onTap: () { @@ -1044,13 +1151,12 @@ void showOptions( decoration: BoxDecoration( border: Border.all(color: Theme.of(context).hintColor), borderRadius: BorderRadius.circular(2), - color: i == cur - ? Theme.of(context).primaryColor.withOpacity(0.6) - : null), + color: i == cur ? numBgSelected : null), child: Center( child: Text((i + 1).toString(), style: TextStyle( - color: i == cur ? Colors.white : Colors.black87, + color: + i == cur ? numColorSelected : numColorUnselected, fontWeight: FontWeight.bold)))))); } displays.add(Padding( @@ -1077,7 +1183,8 @@ void showOptions( List privacyModeList = []; // privacy mode final privacyModeState = PrivacyModeState.find(id); - if (gFFI.ffiModel.keyboard && gFFI.ffiModel.pi.features.privacyMode) { + if ((gFFI.ffiModel.pi.features.privacyMode && gFFI.ffiModel.keyboard) || + privacyModeState.isNotEmpty) { privacyModeList = toolbarPrivacyMode(privacyModeState, context, id, gFFI); if (privacyModeList.length == 1) { displayToggles.add(privacyModeList[0]); @@ -1103,6 +1210,10 @@ void showOptions( if (v != null) viewStyle.value = v; } : null)), + // Show custom scale controls when custom view style is selected + Obx(() => viewStyle.value == kRemoteViewStyleCustom + ? MobileCustomScaleControls(ffi: gFFI) + : const SizedBox.shrink()), const Divider(color: MyTheme.border), for (var e in imageQualityRadios) Obx(() => getRadio( @@ -1188,7 +1299,7 @@ void showOptions( title: resolution.child, onTap: () { close(); - resolution.onPressed(); + resolution.onPressed?.call(); }, )); } @@ -1200,7 +1311,7 @@ void showOptions( title: virtualDisplayMenu.child, onTap: () { close(); - virtualDisplayMenu.onPressed(); + virtualDisplayMenu.onPressed?.call(); }, )); } @@ -1217,7 +1328,9 @@ void showOptions( toggles + [privacyModeWidget]), ); - }, clickMaskDismiss: true, backDismiss: true); + }, clickMaskDismiss: true, backDismiss: true).then((value) { + _disableAndroidSoftKeyboard(); + }); } TTextMenu? getVirtualDisplayMenu(FFI ffi, String id) { @@ -1236,7 +1349,9 @@ TTextMenu? getVirtualDisplayMenu(FFI ffi, String id) { children: children, ), ); - }, clickMaskDismiss: true, backDismiss: true); + }, clickMaskDismiss: true, backDismiss: true).then((value) { + _disableAndroidSoftKeyboard(); + }); }, ); } @@ -1278,7 +1393,9 @@ TTextMenu? getResolutionMenu(FFI ffi, String id) { children: children, ), ); - }, clickMaskDismiss: true, backDismiss: true); + }, clickMaskDismiss: true, backDismiss: true).then((value) { + _disableAndroidSoftKeyboard(); + }); }, ); } diff --git a/flutter/lib/mobile/pages/scan_page.dart b/flutter/lib/mobile/pages/scan_page.dart index e92400dba..5bc033565 100644 --- a/flutter/lib/mobile/pages/scan_page.dart +++ b/flutter/lib/mobile/pages/scan_page.dart @@ -156,7 +156,7 @@ class _ScanPageState extends State { try { final sc = ServerConfig.decode(data.substring(7)); Timer(Duration(milliseconds: 60), () { - showServerSettingsWithValue(sc, gFFI.dialogManager); + showServerSettingsWithValue(sc, gFFI.dialogManager, null); }); } catch (e) { showToast('Invalid QR code'); diff --git a/flutter/lib/mobile/pages/server_page.dart b/flutter/lib/mobile/pages/server_page.dart index 06258e5a5..cd3f97a53 100644 --- a/flutter/lib/mobile/pages/server_page.dart +++ b/flutter/lib/mobile/pages/server_page.dart @@ -17,7 +17,7 @@ import 'home_page.dart'; class ServerPage extends StatefulWidget implements PageShape { @override - final title = translate("Share Screen"); + final title = translate("Share screen"); @override final icon = const Icon(Icons.mobile_screen_share); @@ -56,13 +56,18 @@ class _DropDownAction extends StatelessWidget { final verificationMethod = gFFI.serverModel.verificationMethod; final showPasswordOption = approveMode != 'click'; final isApproveModeFixed = isOptionFixed(kOptionApproveMode); + final isNumericOneTimePasswordFixed = + isOptionFixed(kOptionAllowNumericOneTimePassword); + final isAllowNumericOneTimePassword = + gFFI.serverModel.allowNumericOneTimePassword; return [ - PopupMenuItem( - enabled: gFFI.serverModel.connectStatus > 0, - value: "changeID", - child: Text(translate("Change ID")), - ), - const PopupMenuDivider(), + if (!isChangeIdDisabled()) + PopupMenuItem( + enabled: gFFI.serverModel.connectStatus > 0, + value: "changeID", + child: Text(translate("Change ID")), + ), + if (!isChangeIdDisabled()) const PopupMenuDivider(), PopupMenuItem( value: 'AcceptSessionsViaPassword', child: listTile( @@ -83,7 +88,8 @@ class _DropDownAction extends StatelessWidget { ), if (showPasswordOption) const PopupMenuDivider(), if (showPasswordOption && - verificationMethod != kUseTemporaryPassword) + verificationMethod != kUseTemporaryPassword && + !isChangePermanentPasswordDisabled()) PopupMenuItem( value: "setPermanentPassword", child: Text(translate("Set permanent password")), @@ -94,6 +100,14 @@ class _DropDownAction extends StatelessWidget { value: "setTemporaryPasswordLength", child: Text(translate("One-time password length")), ), + if (showPasswordOption && + verificationMethod != kUsePermanentPassword) + PopupMenuItem( + value: "allowNumericOneTimePassword", + child: listTile(translate("Numeric one-time password"), + isAllowNumericOneTimePassword), + enabled: !isNumericOneTimePasswordFixed, + ), if (showPasswordOption) const PopupMenuDivider(), if (showPasswordOption) PopupMenuItem( @@ -124,6 +138,9 @@ class _DropDownAction extends StatelessWidget { setPasswordDialog(); } else if (value == "setTemporaryPasswordLength") { setTemporaryPasswordLengthDialog(gFFI.dialogManager); + } else if (value == "allowNumericOneTimePassword") { + gFFI.serverModel.switchAllowNumericOneTimePassword(); + gFFI.serverModel.updatePasswordModel(); } else if (value == kUsePermanentPassword || value == kUseTemporaryPassword || value == kUseBothPasswords) { @@ -133,7 +150,12 @@ class _DropDownAction extends StatelessWidget { } if (value == kUsePermanentPassword && - (await bind.mainGetPermanentPassword()).isEmpty) { + (await bind.mainGetCommon(key: "permanent-password-set")) != + "true") { + if (isChangePermanentPasswordDisabled()) { + callback(); + return; + } setPasswordDialog(notEmptyCallback: callback); } else { callback(); @@ -446,7 +468,6 @@ class ServerInfo extends StatelessWidget { @override Widget build(BuildContext context) { - final isPermanent = model.verificationMethod == kUsePermanentPassword; final serverModel = Provider.of(context); const Color colorPositive = Colors.green; @@ -486,6 +507,8 @@ class ServerInfo extends StatelessWidget { } } + final showOneTime = serverModel.approveMode != 'click' && + serverModel.verificationMethod != kUsePermanentPassword; return PaddingCard( title: translate('Your Device'), child: Column( @@ -523,10 +546,10 @@ class ServerInfo extends StatelessWidget { ]), Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - isPermanent ? '-' : model.serverPasswd.value.text, + !showOneTime ? '-' : model.serverPasswd.value.text, style: textStyleValue, ), - isPermanent + !showOneTime ? SizedBox.shrink() : Row(children: [ IconButton( @@ -560,10 +583,20 @@ class _PermissionCheckerState extends State { Widget build(BuildContext context) { final serverModel = Provider.of(context); final hasAudioPermission = androidVersion >= 30; + final hideStopService = isAndroid && + bind.mainGetBuildinOption(key: kOptionHideStopService) == 'Y'; + final allowPermChangeInAcceptWindow = option2bool( + kOptionEnablePermChangeInAcceptWindow, + bind.mainGetBuildinOption( + key: kOptionEnablePermChangeInAcceptWindow, + )); + final permissionChangeLocked = isAndroid && + serverModel.clients.any((c) => !c.disconnected) && + !allowPermChangeInAcceptWindow; return PaddingCard( title: translate("Permissions"), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - serverModel.mediaOk + serverModel.mediaOk && !hideStopService ? ElevatedButton.icon( style: ButtonStyle( backgroundColor: @@ -573,21 +606,30 @@ class _PermissionCheckerState extends State { label: Text(translate("Stop service"))) .marginOnly(bottom: 8) : SizedBox.shrink(), + if (!hideStopService || !serverModel.mediaOk) + PermissionRow( + translate("Screen Capture"), + serverModel.mediaOk, + !serverModel.mediaOk && + gFFI.userModel.userName.value.isEmpty && + bind.mainGetLocalOption(key: "show-scam-warning") != "N" + ? () => showScamWarning(context, serverModel) + : serverModel.toggleService), PermissionRow( - translate("Screen Capture"), - serverModel.mediaOk, - !serverModel.mediaOk && - gFFI.userModel.userName.value.isEmpty && - bind.mainGetLocalOption(key: "show-scam-warning") != "N" - ? () => showScamWarning(context, serverModel) - : serverModel.toggleService), - PermissionRow(translate("Input Control"), serverModel.inputOk, - serverModel.toggleInput), - PermissionRow(translate("Transfer file"), serverModel.fileOk, - serverModel.toggleFile), + translate("Input Control"), + serverModel.inputOk, + serverModel.toggleInput, + ), + PermissionRow( + translate("Transfer file"), + serverModel.fileOk, + serverModel.toggleFile, + enabled: !permissionChangeLocked, + ), hasAudioPermission ? PermissionRow(translate("Audio Capture"), serverModel.audioOk, - serverModel.toggleAudio) + serverModel.toggleAudio, + enabled: !permissionChangeLocked) : Row(children: [ Icon(Icons.info_outline).marginOnly(right: 15), Expanded( @@ -595,18 +637,26 @@ class _PermissionCheckerState extends State { translate("android_version_audio_tip"), style: const TextStyle(color: MyTheme.darkGray), )) - ]) + ]), + PermissionRow( + translate("Enable clipboard"), + serverModel.clipboardOk, + serverModel.toggleClipboard, + enabled: !permissionChangeLocked, + ), ])); } } class PermissionRow extends StatelessWidget { - const PermissionRow(this.name, this.isOk, this.onPressed, {Key? key}) + const PermissionRow(this.name, this.isOk, this.onPressed, + {Key? key, this.enabled = true}) : super(key: key); final String name; final bool isOk; final VoidCallback onPressed; + final bool enabled; @override Widget build(BuildContext context) { @@ -615,9 +665,11 @@ class PermissionRow extends StatelessWidget { contentPadding: EdgeInsets.all(0), title: Text(name), value: isOk, - onChanged: (bool value) { - onPressed(); - }); + onChanged: enabled + ? (bool value) { + onPressed(); + } + : null); } } @@ -630,9 +682,8 @@ class ConnectionManager extends StatelessWidget { return Column( children: serverModel.clients .map((client) => PaddingCard( - title: translate(client.isFileTransfer - ? "File Connection" - : "Screen Connection"), + title: translate( + client.isFileTransfer ? "Transfer file" : "Share screen"), titleIcon: client.isFileTransfer ? Icon(Icons.folder_outlined) : Icon(Icons.mobile_screen_share), @@ -818,13 +869,7 @@ class ClientInfo extends StatelessWidget { flex: -1, child: Padding( padding: const EdgeInsets.only(right: 12), - child: CircleAvatar( - backgroundColor: str2color( - client.name, - Theme.of(context).brightness == Brightness.light - ? 255 - : 150), - child: Text(client.name[0])))), + child: _buildAvatar(context))), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -837,6 +882,20 @@ class ClientInfo extends StatelessWidget { ), ])); } + + Widget _buildAvatar(BuildContext context) { + final fallback = CircleAvatar( + backgroundColor: str2color(client.name, + Theme.of(context).brightness == Brightness.light ? 255 : 150), + child: Text(client.name.isNotEmpty ? client.name[0] : '?'), + ); + return buildAvatarWidget( + avatar: client.avatar, + size: 40, + fallback: fallback, + ) ?? + fallback; + } } void androidChannelInit() { diff --git a/flutter/lib/mobile/pages/settings_page.dart b/flutter/lib/mobile/pages/settings_page.dart index eb3865933..509260636 100644 --- a/flutter/lib/mobile/pages/settings_page.dart +++ b/flutter/lib/mobile/pages/settings_page.dart @@ -70,6 +70,8 @@ class _SettingsState extends State with WidgetsBindingObserver { false; //androidVersion >= 26; // remove because not work on every device var _ignoreBatteryOpt = false; var _enableStartOnBoot = false; + var _checkUpdateOnStartup = false; + var _showTerminalExtraKeys = false; var _floatingWindowDisabled = false; var _keepScreenOn = KeepScreenOn.duringControlled; // relay on floating window var _enableAbr = false; @@ -78,6 +80,7 @@ class _SettingsState extends State with WidgetsBindingObserver { var _enableDirectIPAccess = false; var _enableRecordSession = false; var _enableHardwareCodec = false; + var _allowWebSocket = false; var _autoRecordIncomingSession = false; var _autoRecordOutgoingSession = false; var _allowAutoDisconnect = false; @@ -89,7 +92,15 @@ class _SettingsState extends State with WidgetsBindingObserver { var _hideServer = false; var _hideProxy = false; var _hideNetwork = false; + var _hideWebSocket = false; var _enableTrustedDevices = false; + var _enableUdpPunch = false; + var _allowInsecureTlsFallback = false; + var _disableUdp = false; + var _enableIpv6Punch = false; + var _isUsingPublicServer = false; + var _allowAskForNoteAtEndOfConnection = false; + var _preventSleepWhileConnected = true; _SettingsState() { _enableAbr = option2bool( @@ -103,6 +114,10 @@ class _SettingsState extends State with WidgetsBindingObserver { bind.mainGetOptionSync(key: kOptionEnableRecordSession)); _enableHardwareCodec = option2bool(kOptionEnableHwcodec, bind.mainGetOptionSync(key: kOptionEnableHwcodec)); + _allowWebSocket = mainGetBoolOptionSync(kOptionAllowWebSocket); + _allowInsecureTlsFallback = + mainGetBoolOptionSync(kOptionAllowInsecureTLSFallback); + _disableUdp = bind.mainGetOptionSync(key: kOptionDisableUdp) == 'Y'; _autoRecordIncomingSession = option2bool(kOptionAllowAutoRecordIncoming, bind.mainGetOptionSync(key: kOptionAllowAutoRecordIncoming)); _autoRecordOutgoingSession = option2bool(kOptionAllowAutoRecordOutgoing, @@ -118,7 +133,18 @@ class _SettingsState extends State with WidgetsBindingObserver { _hideProxy = bind.mainGetBuildinOption(key: kOptionHideProxySetting) == 'Y'; _hideNetwork = bind.mainGetBuildinOption(key: kOptionHideNetworkSetting) == 'Y'; + _hideWebSocket = + bind.mainGetBuildinOption(key: kOptionHideWebSocketSetting) == 'Y' || + isWeb; _enableTrustedDevices = mainGetBoolOptionSync(kOptionEnableTrustedDevices); + _enableUdpPunch = mainGetLocalBoolOptionSync(kOptionEnableUdpPunch); + _enableIpv6Punch = mainGetLocalBoolOptionSync(kOptionEnableIpv6Punch); + _allowAskForNoteAtEndOfConnection = + mainGetLocalBoolOptionSync(kOptionAllowAskForNoteAtEndOfConnection); + _preventSleepWhileConnected = + mainGetLocalBoolOptionSync(kOptionKeepAwakeDuringOutgoingSessions); + _showTerminalExtraKeys = + mainGetLocalBoolOptionSync(kOptionEnableShowTerminalExtraKeys); } @override @@ -154,6 +180,13 @@ class _SettingsState extends State with WidgetsBindingObserver { _enableStartOnBoot = enableStartOnBoot; } + var checkUpdateOnStartup = + mainGetLocalBoolOptionSync(kOptionEnableCheckUpdate); + if (checkUpdateOnStartup != _checkUpdateOnStartup) { + update = true; + _checkUpdateOnStartup = checkUpdateOnStartup; + } + var floatingWindowDisabled = bind.mainGetLocalOption(key: kOptionDisableFloatingWindow) == "Y" || !await AndroidPermissionManager.check(kSystemAlertWindow); @@ -182,6 +215,13 @@ class _SettingsState extends State with WidgetsBindingObserver { update = true; _buildDate = buildDate; } + + final isUsingPublicServer = await bind.mainIsUsingPublicServer(); + if (_isUsingPublicServer != isUsingPublicServer) { + update = true; + _isUsingPublicServer = isUsingPublicServer; + } + if (update) { setState(() {}); } @@ -234,7 +274,7 @@ class _SettingsState extends State with WidgetsBindingObserver { Widget build(BuildContext context) { Provider.of(context); final outgoingOnly = bind.isOutgoingOnly(); - final incommingOnly = bind.isIncomingOnly(); + final incomingOnly = bind.isIncomingOnly(); final customClientSection = CustomSettingsSection( child: Column( children: [ @@ -360,7 +400,7 @@ class _SettingsState extends State with WidgetsBindingObserver { }, ), SettingsTile.switchTile( - title: Text('${translate('Adaptive bitrate')} (beta)'), + title: Text(translate('Adaptive bitrate')), initialValue: _enableAbr, onToggle: isOptionFixed(kOptionEnableAbr) ? null @@ -522,7 +562,7 @@ class _SettingsState extends State with WidgetsBindingObserver { enhancementsTiles.add(SettingsTile.switchTile( initialValue: _enableStartOnBoot, title: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text("${translate('Start on boot')} (beta)"), + Text(translate('Start on boot')), Text( '* ${translate('Start the screen sharing service on boot, requires special permissions')}', style: Theme.of(context).textTheme.bodySmall), @@ -552,6 +592,39 @@ class _SettingsState extends State with WidgetsBindingObserver { gFFI.invokeMethod(AndroidChannel.kSetStartOnBootOpt, toValue); })); + if (!bind.isCustomClient()) { + enhancementsTiles.add( + SettingsTile.switchTile( + initialValue: _checkUpdateOnStartup, + title: + Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(translate('Check for software update on startup')), + ]), + onToggle: (bool toValue) async { + await mainSetLocalBoolOption(kOptionEnableCheckUpdate, toValue); + setState(() => _checkUpdateOnStartup = toValue); + }, + ), + ); + } + + enhancementsTiles.add( + SettingsTile.switchTile( + initialValue: _showTerminalExtraKeys, + title: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(translate('Show terminal extra keys')), + ]), + onToggle: (bool v) async { + await mainSetLocalBoolOption(kOptionEnableShowTerminalExtraKeys, v); + final newValue = + mainGetLocalBoolOptionSync(kOptionEnableShowTerminalExtraKeys); + setState(() { + _showTerminalExtraKeys = newValue; + }); + }, + ), + ); + onFloatingWindowChanged(bool toValue) async { if (toValue) { if (!await AndroidPermissionManager.check(kSystemAlertWindow)) { @@ -615,8 +688,18 @@ class _SettingsState extends State with WidgetsBindingObserver { SettingsTile( title: Obx(() => Text(gFFI.userModel.userName.value.isEmpty ? translate('Login') - : '${translate('Logout')} (${gFFI.userModel.userName.value})')), - leading: Icon(Icons.person), + : '${translate('Logout')} (${gFFI.userModel.accountLabelWithHandle})')), + leading: Obx(() { + final avatar = bind.mainResolveAvatarUrl( + avatar: gFFI.userModel.avatar.value); + return buildAvatarWidget( + avatar: avatar, + size: 28, + borderRadius: null, + fallback: Icon(Icons.person), + ) ?? + Icon(Icons.person); + }), onPressed: (context) { if (gFFI.userModel.userName.value.isEmpty) { loginDialog(); @@ -633,15 +716,91 @@ class _SettingsState extends State with WidgetsBindingObserver { title: Text(translate('ID/Relay Server')), leading: Icon(Icons.cloud), onPressed: (context) { - showServerSettings(gFFI.dialogManager); + showServerSettings(gFFI.dialogManager, (callback) async { + _isUsingPublicServer = await bind.mainIsUsingPublicServer(); + setState(callback); + }); }), - if (!isIOS && !_hideNetwork && !_hideProxy) + if (!_hideNetwork && !_hideProxy) SettingsTile( title: Text(translate('Socks5/Http(s) Proxy')), leading: Icon(Icons.network_ping), onPressed: (context) { changeSocks5Proxy(); }), + if (!disabledSettings && !_hideNetwork && !_hideWebSocket) + SettingsTile.switchTile( + title: Text(translate('Use WebSocket')), + initialValue: _allowWebSocket, + onToggle: isOptionFixed(kOptionAllowWebSocket) + ? null + : (v) async { + await mainSetBoolOption(kOptionAllowWebSocket, v); + final newValue = + await mainGetBoolOption(kOptionAllowWebSocket); + setState(() { + _allowWebSocket = newValue; + }); + }, + ), + if (!_isUsingPublicServer) + SettingsTile.switchTile( + title: Text(translate('Allow insecure TLS fallback')), + initialValue: _allowInsecureTlsFallback, + onToggle: isOptionFixed(kOptionAllowInsecureTLSFallback) + ? null + : (v) async { + await mainSetBoolOption( + kOptionAllowInsecureTLSFallback, v); + final newValue = mainGetBoolOptionSync( + kOptionAllowInsecureTLSFallback); + setState(() { + _allowInsecureTlsFallback = newValue; + }); + }, + ), + if (isAndroid && !outgoingOnly && !_isUsingPublicServer) + SettingsTile.switchTile( + title: Text(translate('Disable UDP')), + initialValue: _disableUdp, + onToggle: isOptionFixed(kOptionDisableUdp) + ? null + : (v) async { + await bind.mainSetOption( + key: kOptionDisableUdp, value: v ? 'Y' : 'N'); + final newValue = + bind.mainGetOptionSync(key: kOptionDisableUdp) == 'Y'; + setState(() { + _disableUdp = newValue; + }); + }, + ), + if (!incomingOnly) + SettingsTile.switchTile( + title: Text(translate('Enable UDP hole punching')), + initialValue: _enableUdpPunch, + onToggle: (v) async { + await mainSetLocalBoolOption(kOptionEnableUdpPunch, v); + final newValue = + mainGetLocalBoolOptionSync(kOptionEnableUdpPunch); + setState(() { + _enableUdpPunch = newValue; + }); + }, + ), + if (!incomingOnly) + SettingsTile.switchTile( + title: Text(translate('Enable IPv6 P2P connection')), + initialValue: _enableIpv6Punch, + onToggle: (v) async { + await mainSetLocalBoolOption(kOptionEnableIpv6Punch, v); + final newValue = + mainGetLocalBoolOptionSync(kOptionEnableIpv6Punch); + setState(() { + _enableIpv6Punch = newValue; + }); + }, + ), SettingsTile( title: Text(translate('Language')), leading: Icon(Icons.translate), @@ -659,7 +818,38 @@ class _SettingsState extends State with WidgetsBindingObserver { onPressed: (context) { showThemeSettings(gFFI.dialogManager); }, - ) + ), + if (!bind.isDisableAccount()) + SettingsTile.switchTile( + title: Text(translate('note-at-conn-end-tip')), + initialValue: _allowAskForNoteAtEndOfConnection, + onToggle: (v) async { + if (v && !gFFI.userModel.isLogin) { + final res = await loginDialog(); + if (res != true) return; + } + await mainSetLocalBoolOption( + kOptionAllowAskForNoteAtEndOfConnection, v); + final newValue = mainGetLocalBoolOptionSync( + kOptionAllowAskForNoteAtEndOfConnection); + setState(() { + _allowAskForNoteAtEndOfConnection = newValue; + }); + }, + ), + if (!incomingOnly) + SettingsTile.switchTile( + title: + Text(translate('keep-awake-during-outgoing-sessions-label')), + initialValue: _preventSleepWhileConnected, + onToggle: (v) async { + await mainSetLocalBoolOption( + kOptionKeepAwakeDuringOutgoingSessions, v); + setState(() { + _preventSleepWhileConnected = v; + }); + }, + ), ]), if (isAndroid) SettingsSection(title: Text(translate('Hardware Codec')), tiles: [ @@ -703,7 +893,7 @@ class _SettingsState extends State with WidgetsBindingObserver { }); }, ), - if (!incommingOnly) + if (!incomingOnly) SettingsTile.switchTile( title: Text(translate('Automatically record outgoing sessions')), @@ -740,7 +930,7 @@ class _SettingsState extends State with WidgetsBindingObserver { !outgoingOnly && !hideSecuritySettings) SettingsSection( - title: Text(translate("Share Screen")), + title: Text(translate("Share screen")), tiles: shareScreenTiles, ), if (!bind.isIncomingOnly()) defaultDisplaySection(), @@ -757,9 +947,7 @@ class _SettingsState extends State with WidgetsBindingObserver { tiles: [ SettingsTile( onPressed: (context) async { - if (await canLaunchUrl(Uri.parse(url))) { - await launchUrl(Uri.parse(url)); - } + await launchUrl(Uri.parse(url)); }, title: Text(translate("Version: ") + version), value: Padding( @@ -828,11 +1016,6 @@ class _SettingsState extends State with WidgetsBindingObserver { } } -void showServerSettings(OverlayDialogManager dialogManager) async { - Map options = jsonDecode(await bind.mainGetOptions()); - showServerSettingsWithValue(ServerConfig.fromOptions(options), dialogManager); -} - void showLanguageSettings(OverlayDialogManager dialogManager) async { try { final langs = json.decode(await bind.mainGetLangs()) as List; @@ -908,9 +1091,7 @@ void showAbout(OverlayDialogManager dialogManager) { InkWell( onTap: () async { const url = 'https://rustdesk.com/'; - if (await canLaunchUrl(Uri.parse(url))) { - await launchUrl(Uri.parse(url)); - } + await launchUrl(Uri.parse(url)); }, child: Padding( padding: EdgeInsets.symmetric(vertical: 8), diff --git a/flutter/lib/mobile/pages/terminal_page.dart b/flutter/lib/mobile/pages/terminal_page.dart new file mode 100644 index 000000000..aff85b40c --- /dev/null +++ b/flutter/lib/mobile/pages/terminal_page.dart @@ -0,0 +1,441 @@ +import 'dart:async'; +import 'dart:math'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hbb/common.dart'; +import 'package:flutter_hbb/common/widgets/dialog.dart'; +import 'package:flutter_hbb/models/model.dart'; +import 'package:flutter_hbb/models/terminal_model.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:xterm/xterm.dart'; +import '../../desktop/pages/terminal_connection_manager.dart'; +import '../../consts.dart'; + +class TerminalPage extends StatefulWidget { + const TerminalPage({ + Key? key, + required this.id, + required this.password, + required this.isSharedPassword, + this.forceRelay, + this.connToken, + }) : super(key: key); + final String id; + final String? password; + final bool? forceRelay; + final bool? isSharedPassword; + final String? connToken; + final terminalId = 0; + + @override + State createState() => _TerminalPageState(); +} + +class _TerminalPageState extends State + with AutomaticKeepAliveClientMixin, WidgetsBindingObserver { + late FFI _ffi; + late TerminalModel _terminalModel; + double? _cellHeight; + double _sysKeyboardHeight = 0; + Timer? _keyboardDebounce; + final GlobalKey _keyboardKey = GlobalKey(); + double _keyboardHeight = 0; + late bool _showTerminalExtraKeys; + // For iOS edge swipe gesture + double _swipeStartX = 0; + double _swipeCurrentX = 0; + + // For web only. + // 'monospace' does not work on web, use Google Fonts, `??` is only for null safety. + final String _robotoMonoFontFamily = isWeb + ? (GoogleFonts.robotoMono().fontFamily ?? 'monospace') + : 'monospace'; + + SessionID get sessionId => _ffi.sessionId; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + + debugPrint( + '[TerminalPage] Initializing terminal ${widget.terminalId} for peer ${widget.id}'); + + // Use shared FFI instance from connection manager + _ffi = TerminalConnectionManager.getConnection( + peerId: widget.id, + password: widget.password, + isSharedPassword: widget.isSharedPassword, + forceRelay: widget.forceRelay, + connToken: widget.connToken, + ); + + // Create terminal model with specific terminal ID + _terminalModel = TerminalModel(_ffi, widget.terminalId); + debugPrint( + '[TerminalPage] Terminal model created for terminal ${widget.terminalId}'); + + _terminalModel.onResizeExternal = (w, h, pw, ph) { + _cellHeight = ph * 1.0; + }; + + // Register this terminal model with FFI for event routing + _ffi.registerTerminalModel(widget.terminalId, _terminalModel); + + // Web desktop users have full hardware keyboard access, so the on-screen + // terminal extra keys bar is unnecessary and disabled. + _showTerminalExtraKeys = !isWebDesktop && + mainGetLocalBoolOptionSync(kOptionEnableShowTerminalExtraKeys); + // Initialize terminal connection + WidgetsBinding.instance.addPostFrameCallback((_) { + _ffi.dialogManager + .showLoading(translate('Connecting...'), onCancel: closeConnection); + + if (_showTerminalExtraKeys) { + _updateKeyboardHeight(); + } + }); + _ffi.ffiModel.updateEventListener(_ffi.sessionId, widget.id); + } + + @override + void dispose() { + // Unregister terminal model from FFI + _ffi.unregisterTerminalModel(widget.terminalId); + _terminalModel.dispose(); + _keyboardDebounce?.cancel(); + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + TerminalConnectionManager.releaseConnection(widget.id); + } + + @override + void didChangeMetrics() { + super.didChangeMetrics(); + + _keyboardDebounce?.cancel(); + _keyboardDebounce = Timer(const Duration(milliseconds: 20), () { + final bottomInset = MediaQuery.of(context).viewInsets.bottom; + setState(() { + _sysKeyboardHeight = bottomInset; + }); + }); + } + + void _updateKeyboardHeight() { + if (_keyboardKey.currentContext != null) { + final renderBox = _keyboardKey.currentContext!.findRenderObject() as RenderBox; + _keyboardHeight = renderBox.size.height; + } + } + + EdgeInsets _calculatePadding(double heightPx) { + if (_cellHeight == null) { + return const EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0); + } + final realHeight = heightPx - _sysKeyboardHeight - _keyboardHeight; + final rows = (realHeight / _cellHeight!).floor(); + final extraSpace = realHeight - rows * _cellHeight!; + final topBottom = max(0.0, extraSpace / 2.0); + return EdgeInsets.only(left: 5.0, right: 5.0, top: topBottom, bottom: topBottom + _sysKeyboardHeight + _keyboardHeight); + } + + @override + Widget build(BuildContext context) { + super.build(context); + return WillPopScope( + onWillPop: () async { + clientClose(sessionId, _ffi); + return false; // Prevent default back behavior + }, + child: buildBody(), + ); + } + + Widget buildBody() { + final scaffold = Scaffold( + resizeToAvoidBottomInset: false, // Disable automatic layout adjustment; manually control UI updates to prevent flickering when the keyboard shows/hides + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + body: Stack( + children: [ + Positioned.fill( + child: SafeArea( + top: true, + child: LayoutBuilder( + builder: (context, constraints) { + final heightPx = constraints.maxHeight; + return TerminalView( + _terminalModel.terminal, + controller: _terminalModel.terminalController, + autofocus: true, + textStyle: _getTerminalStyle(), + backgroundOpacity: 0.7, + // The following comment is from xterm.dart source code: + // Workaround to detect delete key for platforms and IMEs that do not + // emit a hardware delete event. Preferred on mobile platforms. [false] by + // default. + // + // Android works fine without this workaround. + deleteDetection: isIOS, + padding: _calculatePadding(heightPx), + onSecondaryTapDown: (details, offset) async { + final selection = _terminalModel.terminalController.selection; + if (selection != null) { + final text = _terminalModel.terminal.buffer.getText(selection); + _terminalModel.terminalController.clearSelection(); + await Clipboard.setData(ClipboardData(text: text)); + } else { + final data = await Clipboard.getData('text/plain'); + final text = data?.text; + if (text != null) { + _terminalModel.terminal.paste(text); + } + } + }, + ); + }, + ), + ), + ), + if (_showTerminalExtraKeys) _buildFloatingKeyboard(), + // iOS-style circular close button in top-right corner + if (isIOS) _buildCloseButton(), + ], + ), + ); + + // Add iOS edge swipe gesture to exit (similar to Android back button) + if (isIOS) { + return LayoutBuilder( + builder: (context, constraints) { + final screenWidth = constraints.maxWidth; + // Base thresholds on screen width but clamp to reasonable logical pixel ranges + // Edge detection region: ~10% of width, clamped between 20 and 80 logical pixels + final edgeThreshold = (screenWidth * 0.1).clamp(20.0, 80.0); + // Required horizontal movement: ~25% of width, clamped between 80 and 300 logical pixels + final swipeThreshold = (screenWidth * 0.25).clamp(80.0, 300.0); + + return RawGestureDetector( + behavior: HitTestBehavior.translucent, + gestures: { + HorizontalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers( + () => HorizontalDragGestureRecognizer( + debugOwner: this, + // Only respond to touch input, exclude mouse/trackpad + supportedDevices: kTouchBasedDeviceKinds, + ), + (HorizontalDragGestureRecognizer instance) { + instance + // Capture initial touch-down position (before touch slop) + ..onDown = (details) { + _swipeStartX = details.localPosition.dx; + _swipeCurrentX = details.localPosition.dx; + } + ..onUpdate = (details) { + _swipeCurrentX = details.localPosition.dx; + } + ..onEnd = (details) { + // Check if swipe started from left edge and moved right + if (_swipeStartX < edgeThreshold && (_swipeCurrentX - _swipeStartX) > swipeThreshold) { + clientClose(sessionId, _ffi); + } + _swipeStartX = 0; + _swipeCurrentX = 0; + } + ..onCancel = () { + _swipeStartX = 0; + _swipeCurrentX = 0; + }; + }, + ), + }, + child: scaffold, + ); + }, + ); + } + + return scaffold; + } + + Widget _buildCloseButton() { + return Positioned( + top: 0, + right: 0, + child: SafeArea( + minimum: const EdgeInsets.only( + top: 16, // iOS standard margin + right: 16, // iOS standard margin + ), + child: Semantics( + button: true, + label: translate('Close'), + child: Container( + width: 44, // iOS standard tap target size + height: 44, + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.5), // Half transparency + shape: BoxShape.circle, + ), + child: Material( + color: Colors.transparent, + shape: const CircleBorder(), + clipBehavior: Clip.antiAlias, + child: InkWell( + customBorder: const CircleBorder(), + onTap: () { + clientClose(sessionId, _ffi); + }, + child: Tooltip( + message: translate('Close'), + child: const Icon( + Icons.chevron_left, // iOS-style back arrow + color: Colors.white, + size: 28, + ), + ), + ), + ), + ), + ), + ), + ); + } + + Widget _buildFloatingKeyboard() { + return AnimatedPositioned( + duration: const Duration(milliseconds: 200), + left: 0, + right: 0, + bottom: _sysKeyboardHeight, + child: Container( + key: _keyboardKey, + color: Theme.of(context).scaffoldBackgroundColor, + padding: EdgeInsets.zero, + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildKeyButton('Esc'), + const SizedBox(width: 2), + _buildKeyButton('/'), + const SizedBox(width: 2), + _buildKeyButton('|'), + const SizedBox(width: 2), + _buildKeyButton('Home'), + const SizedBox(width: 2), + _buildKeyButton('↑'), + const SizedBox(width: 2), + _buildKeyButton('End'), + const SizedBox(width: 2), + _buildKeyButton('PgUp'), + ], + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildKeyButton('Tab'), + const SizedBox(width: 2), + _buildKeyButton('Ctrl+C'), + const SizedBox(width: 2), + _buildKeyButton('~'), + const SizedBox(width: 2), + _buildKeyButton('←'), + const SizedBox(width: 2), + _buildKeyButton('↓'), + const SizedBox(width: 2), + _buildKeyButton('→'), + const SizedBox(width: 2), + _buildKeyButton('PgDn'), + ], + ), + ], + ), + ), + ); + } + + Widget _buildKeyButton(String label) { + return ElevatedButton( + onPressed: () { + _sendKeyToTerminal(label); + }, + child: Text(label), + style: ElevatedButton.styleFrom( + minimumSize: const Size(48, 32), + padding: EdgeInsets.zero, + textStyle: const TextStyle(fontSize: 12), + backgroundColor: Theme.of(context).colorScheme.surfaceVariant, + foregroundColor: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ); + } + + void _sendKeyToTerminal(String key) { + String? send; + + switch (key) { + case 'Esc': + send = '\x1B'; + break; + case 'Tab': + send = '\t'; + break; + case 'Ctrl+C': + send = '\x03'; + break; + + case '↑': + send = '\x1B[A'; + break; + case '↓': + send = '\x1B[B'; + break; + case '→': + send = '\x1B[C'; + break; + case '←': + send = '\x1B[D'; + break; + + case 'Home': + send = '\x1B[H'; + break; + case 'End': + send = '\x1B[F'; + break; + case 'PgUp': + send = '\x1B[5~'; + break; + case 'PgDn': + send = '\x1B[6~'; + break; + + default: + send = key; + break; + } + + if (send != null) { + _terminalModel.sendVirtualKey(send); + } + } + + // https://github.com/TerminalStudio/xterm.dart/issues/42#issuecomment-877495472 + // https://github.com/TerminalStudio/xterm.dart/issues/198#issuecomment-2526548458 + TerminalStyle _getTerminalStyle() { + return isWeb + ? TerminalStyle( + fontFamily: _robotoMonoFontFamily, + fontSize: 14, + ) + : const TerminalStyle(); + } + + @override + bool get wantKeepAlive => true; +} diff --git a/flutter/lib/mobile/pages/view_camera_page.dart b/flutter/lib/mobile/pages/view_camera_page.dart new file mode 100644 index 000000000..08c8cda1a --- /dev/null +++ b/flutter/lib/mobile/pages/view_camera_page.dart @@ -0,0 +1,727 @@ +import 'dart:async'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hbb/common/shared_state.dart'; +import 'package:flutter_hbb/common/widgets/toolbar.dart'; +import 'package:flutter_hbb/consts.dart'; +import 'package:flutter_hbb/models/chat_model.dart'; +import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:get/get.dart'; +import 'package:provider/provider.dart'; + +import '../../common.dart'; +import '../../common/widgets/overlay.dart'; +import '../../common/widgets/dialog.dart'; +import '../../common/widgets/remote_input.dart'; +import '../../models/input_model.dart'; +import '../../models/model.dart'; +import '../../models/platform_model.dart'; +import '../../utils/image.dart'; + +final initText = '1' * 1024; + +// Workaround for Android (default input method, Microsoft SwiftKey keyboard) when using physical keyboard. +// When connecting a physical keyboard, `KeyEvent.physicalKey.usbHidUsage` are wrong is using Microsoft SwiftKey keyboard. +// https://github.com/flutter/flutter/issues/159384 +// https://github.com/flutter/flutter/issues/159383 +void _disableAndroidSoftKeyboard({bool? isKeyboardVisible}) { + if (isAndroid) { + if (isKeyboardVisible != true) { + // `enable_soft_keyboard` will be set to `true` when clicking the keyboard icon, in `openKeyboard()`. + gFFI.invokeMethod("enable_soft_keyboard", false); + } + } +} + +class ViewCameraPage extends StatefulWidget { + ViewCameraPage( + {Key? key, + required this.id, + this.password, + this.isSharedPassword, + this.forceRelay}) + : super(key: key); + + final String id; + final String? password; + final bool? isSharedPassword; + final bool? forceRelay; + + @override + State createState() => _ViewCameraPageState(id); +} + +class _ViewCameraPageState extends State + with WidgetsBindingObserver { + Timer? _timer; + bool _showBar = !isWebDesktop; + bool _showGestureHelp = false; + Orientation? _currentOrientation; + double _viewInsetsBottom = 0; + final _uniqueKey = UniqueKey(); + Timer? _timerDidChangeMetrics; + + final _blockableOverlayState = BlockableOverlayState(); + + final keyboardVisibilityController = KeyboardVisibilityController(); + final FocusNode _mobileFocusNode = FocusNode(); + final FocusNode _physicalFocusNode = FocusNode(); + var _showEdit = false; // use soft keyboard + + InputModel get inputModel => gFFI.inputModel; + SessionID get sessionId => gFFI.sessionId; + + final TextEditingController _textController = + TextEditingController(text: initText); + + _ViewCameraPageState(String id) { + initSharedStates(id); + gFFI.chatModel.voiceCallStatus.value = VoiceCallStatus.notStarted; + gFFI.dialogManager.loadMobileActionsOverlayVisible(); + } + + @override + void initState() { + super.initState(); + gFFI.ffiModel.updateEventListener(sessionId, widget.id); + gFFI.start( + widget.id, + isViewCamera: true, + password: widget.password, + isSharedPassword: widget.isSharedPassword, + forceRelay: widget.forceRelay, + ); + WidgetsBinding.instance.addPostFrameCallback((_) { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: []); + gFFI.dialogManager + .showLoading(translate('Connecting...'), onCancel: closeConnection); + }); + WakelockManager.enable(_uniqueKey); + _physicalFocusNode.requestFocus(); + gFFI.inputModel.listenToMouse(true); + gFFI.qualityMonitorModel.checkShowQualityMonitor(sessionId); + gFFI.chatModel + .changeCurrentKey(MessageKey(widget.id, ChatModel.clientModeID)); + _blockableOverlayState.applyFfi(gFFI); + gFFI.imageModel.addCallbackOnFirstImage((String peerId) { + gFFI.recordingModel + .updateStatus(bind.sessionGetIsRecording(sessionId: gFFI.sessionId)); + if (gFFI.recordingModel.start) { + showToast(translate('Automatically record outgoing sessions')); + } + _disableAndroidSoftKeyboard( + isKeyboardVisible: keyboardVisibilityController.isVisible); + }); + WidgetsBinding.instance.addObserver(this); + } + + @override + Future dispose() async { + WidgetsBinding.instance.removeObserver(this); + // https://github.com/flutter/flutter/issues/64935 + super.dispose(); + gFFI.dialogManager.hideMobileActionsOverlay(store: false); + gFFI.inputModel.listenToMouse(false); + gFFI.imageModel.disposeImage(); + gFFI.cursorModel.disposeImages(); + await gFFI.invokeMethod("enable_soft_keyboard", true); + _mobileFocusNode.dispose(); + _physicalFocusNode.dispose(); + await gFFI.close(); + _timer?.cancel(); + _timerDidChangeMetrics?.cancel(); + gFFI.dialogManager.dismissAll(); + await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, + overlays: SystemUiOverlay.values); + WakelockManager.disable(_uniqueKey); + removeSharedStates(widget.id); + // `on_voice_call_closed` should be called when the connection is ended. + // The inner logic of `on_voice_call_closed` will check if the voice call is active. + // Only one client is considered here for now. + gFFI.chatModel.onVoiceCallClosed("End connetion"); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) {} + + @override + void didChangeMetrics() { + // If the soft keyboard is visible and the canvas has been changed(panned or scaled) + // Don't try reset the view style and focus the cursor. + if (gFFI.cursorModel.lastKeyboardIsVisible && + gFFI.canvasModel.isMobileCanvasChanged) { + return; + } + + final newBottom = MediaQueryData.fromView(ui.window).viewInsets.bottom; + _timerDidChangeMetrics?.cancel(); + _timerDidChangeMetrics = Timer(Duration(milliseconds: 100), () async { + // We need this comparation because poping up the floating action will also trigger `didChangeMetrics()`. + if (newBottom != _viewInsetsBottom) { + gFFI.canvasModel.mobileFocusCanvasCursor(); + _viewInsetsBottom = newBottom; + } + }); + } + + // to-do: It should be better to use transparent color instead of the bgColor. + // But for now, the transparent color will cause the canvas to be white. + // I'm sure that the white color is caused by the Overlay widget in BlockableOverlay. + // But I don't know why and how to fix it. + Widget emptyOverlay(Color bgColor) => BlockableOverlay( + /// the Overlay key will be set with _blockableOverlayState in BlockableOverlay + /// see override build() in [BlockableOverlay] + state: _blockableOverlayState, + underlying: Container( + color: bgColor, + ), + ); + + Widget _bottomWidget() => (_showBar && gFFI.ffiModel.pi.displays.isNotEmpty + ? getBottomAppBar() + : Offstage()); + + @override + Widget build(BuildContext context) { + final keyboardIsVisible = + keyboardVisibilityController.isVisible && _showEdit; + final showActionButton = !_showBar || keyboardIsVisible || _showGestureHelp; + + return WillPopScope( + onWillPop: () async { + clientClose(sessionId, gFFI); + return false; + }, + child: Scaffold( + // workaround for https://github.com/rustdesk/rustdesk/issues/3131 + floatingActionButtonLocation: keyboardIsVisible + ? FABLocation(FloatingActionButtonLocation.endFloat, 0, -35) + : null, + floatingActionButton: !showActionButton + ? null + : FloatingActionButton( + mini: !keyboardIsVisible, + child: Icon( + (keyboardIsVisible || _showGestureHelp) + ? Icons.expand_more + : Icons.expand_less, + color: Colors.white, + ), + backgroundColor: MyTheme.accent, + onPressed: () { + setState(() { + if (keyboardIsVisible) { + _showEdit = false; + gFFI.invokeMethod("enable_soft_keyboard", false); + _mobileFocusNode.unfocus(); + _physicalFocusNode.requestFocus(); + } else if (_showGestureHelp) { + _showGestureHelp = false; + } else { + _showBar = !_showBar; + } + }); + }), + bottomNavigationBar: Obx(() => Stack( + alignment: Alignment.bottomCenter, + children: [ + gFFI.ffiModel.pi.isSet.isTrue && + gFFI.ffiModel.waitForFirstImage.isTrue + ? emptyOverlay(MyTheme.canvasColor) + : () { + gFFI.ffiModel.tryShowAndroidActionsOverlay(); + return Offstage(); + }(), + _bottomWidget(), + gFFI.ffiModel.pi.isSet.isFalse + ? emptyOverlay(MyTheme.canvasColor) + : Offstage(), + ], + )), + body: Obx( + () => getRawPointerAndKeyBody(Overlay( + initialEntries: [ + OverlayEntry(builder: (context) { + return Container( + color: kColorCanvas, + child: SafeArea( + child: OrientationBuilder(builder: (ctx, orientation) { + if (_currentOrientation != orientation) { + Timer(const Duration(milliseconds: 200), () { + gFFI.dialogManager + .resetMobileActionsOverlay(ffi: gFFI); + _currentOrientation = orientation; + gFFI.canvasModel.updateViewStyle(); + }); + } + return Container( + color: MyTheme.canvasColor, + child: RawTouchGestureDetectorRegion( + child: getBodyForMobile(), + ffi: gFFI, + isCamera: true, + ), + ); + }), + ), + ); + }) + ], + )), + )), + ); + } + + Widget getRawPointerAndKeyBody(Widget child) { + return CameraRawPointerMouseRegion( + inputModel: inputModel, + // Disable RawKeyFocusScope before the connecting is established. + // The "Delete" key on the soft keyboard may be grabbed when inputting the password dialog. + child: gFFI.ffiModel.pi.isSet.isTrue + ? RawKeyFocusScope( + focusNode: _physicalFocusNode, + inputModel: inputModel, + child: child) + : child, + ); + } + + Widget getBottomAppBar() { + return BottomAppBar( + elevation: 10, + color: MyTheme.accent, + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + IconButton( + color: Colors.white, + icon: Icon(Icons.clear), + onPressed: () { + clientClose(sessionId, gFFI); + }, + ), + IconButton( + color: Colors.white, + icon: Icon(Icons.tv), + onPressed: () { + setState(() => _showEdit = false); + showOptions(context, widget.id, gFFI.dialogManager); + }, + ) + ] + + (isWeb + ? [] + : [ + futureBuilder( + future: gFFI.invokeMethod( + "get_value", "KEY_IS_SUPPORT_VOICE_CALL"), + hasData: (isSupportVoiceCall) => IconButton( + color: Colors.white, + icon: isAndroid && isSupportVoiceCall + ? SvgPicture.asset('assets/chat.svg', + colorFilter: ColorFilter.mode( + Colors.white, BlendMode.srcIn)) + : Icon(Icons.message), + onPressed: () => + isAndroid && isSupportVoiceCall + ? showChatOptions(widget.id) + : onPressedTextChat(widget.id), + )) + ]) + + [ + IconButton( + color: Colors.white, + icon: Icon(Icons.more_vert), + onPressed: () { + setState(() => _showEdit = false); + showActions(widget.id); + }, + ), + ]), + Obx(() => IconButton( + color: Colors.white, + icon: Icon(Icons.expand_more), + onPressed: gFFI.ffiModel.waitForFirstImage.isTrue + ? null + : () { + setState(() => _showBar = !_showBar); + }, + )), + ], + ), + ); + } + + Widget getBodyForMobile() { + return Container( + color: MyTheme.canvasColor, + child: Stack(children: () { + final paints = [ + ImagePaint(), + Positioned( + top: 10, + right: 10, + child: QualityMonitor(gFFI.qualityMonitorModel), + ), + SizedBox( + width: 0, + height: 0, + child: !_showEdit + ? Container() + : TextFormField( + textInputAction: TextInputAction.newline, + autocorrect: false, + // Flutter 3.16.9 Android. + // `enableSuggestions` causes secure keyboard to be shown. + // https://github.com/flutter/flutter/issues/139143 + // https://github.com/flutter/flutter/issues/146540 + // enableSuggestions: false, + autofocus: true, + focusNode: _mobileFocusNode, + maxLines: null, + controller: _textController, + // trick way to make backspace work always + keyboardType: TextInputType.multiline, + // `onChanged` may be called depending on the input method if this widget is wrapped in + // `Focus(onKeyEvent: ..., child: ...)` + // For `Backspace` button in the soft keyboard: + // en/fr input method: + // 1. The button will not trigger `onKeyEvent` if the text field is not empty. + // 2. The button will trigger `onKeyEvent` if the text field is empty. + // ko/zh/ja input method: the button will trigger `onKeyEvent` + // and the event will not popup if `KeyEventResult.handled` is returned. + onChanged: null, + ).workaroundFreezeLinuxMint(), + ), + ]; + return paints; + }())); + } + + Widget getBodyForDesktopWithListener() { + var paints = [ImagePaint()]; + return Container( + color: MyTheme.canvasColor, child: Stack(children: paints)); + } + + List _getMobileActionMenus() { + if (gFFI.ffiModel.pi.platform != kPeerPlatformAndroid || + !gFFI.ffiModel.keyboard) { + return []; + } + final enabled = versionCmp(gFFI.ffiModel.pi.version, '1.2.7') >= 0; + if (!enabled) return []; + return [ + TTextMenu( + child: Text(translate('Back')), + onPressed: () => gFFI.inputModel.onMobileBack(), + ), + TTextMenu( + child: Text(translate('Home')), + onPressed: () => gFFI.inputModel.onMobileHome(), + ), + TTextMenu( + child: Text(translate('Apps')), + onPressed: () => gFFI.inputModel.onMobileApps(), + ), + TTextMenu( + child: Text(translate('Volume up')), + onPressed: () => gFFI.inputModel.onMobileVolumeUp(), + ), + TTextMenu( + child: Text(translate('Volume down')), + onPressed: () => gFFI.inputModel.onMobileVolumeDown(), + ), + TTextMenu( + child: Text(translate('Power')), + onPressed: () => gFFI.inputModel.onMobilePower(), + ), + ]; + } + + void showActions(String id) async { + final size = MediaQuery.of(context).size; + final x = 120.0; + final y = size.height; + final mobileActionMenus = _getMobileActionMenus(); + final menus = toolbarControls(context, id, gFFI); + + final List> more = [ + ...mobileActionMenus + .asMap() + .entries + .map((e) => + PopupMenuItem(child: e.value.getChild(), value: e.key)) + .toList(), + if (mobileActionMenus.isNotEmpty) PopupMenuDivider(), + ...menus + .asMap() + .entries + .map((e) => PopupMenuItem( + child: e.value.getChild(), + value: e.key + mobileActionMenus.length)) + .toList(), + ]; + () async { + var index = await showMenu( + context: context, + position: RelativeRect.fromLTRB(x, y, x, y), + items: more, + elevation: 8, + ); + if (index != null) { + if (index < mobileActionMenus.length) { + mobileActionMenus[index].onPressed?.call(); + } else if (index < mobileActionMenus.length + more.length) { + menus[index - mobileActionMenus.length].onPressed?.call(); + } + } + }(); + } + + onPressedTextChat(String id) { + gFFI.chatModel.changeCurrentKey(MessageKey(id, ChatModel.clientModeID)); + gFFI.chatModel.toggleChatOverlay(); + } + + showChatOptions(String id) async { + onPressVoiceCall() => bind.sessionRequestVoiceCall(sessionId: sessionId); + onPressEndVoiceCall() => bind.sessionCloseVoiceCall(sessionId: sessionId); + + makeTextMenu(String label, Widget icon, VoidCallback onPressed, + {TextStyle? labelStyle}) => + TTextMenu( + child: Text(translate(label), style: labelStyle), + trailingIcon: Transform.scale( + scale: (isDesktop || isWebDesktop) ? 0.8 : 1, + child: IgnorePointer( + child: IconButton( + onPressed: null, + icon: icon, + ), + ), + ), + onPressed: onPressed, + ); + + final isInVoice = [ + VoiceCallStatus.waitingForResponse, + VoiceCallStatus.connected + ].contains(gFFI.chatModel.voiceCallStatus.value); + final menus = [ + makeTextMenu('Text chat', Icon(Icons.message, color: MyTheme.accent), + () => onPressedTextChat(widget.id)), + isInVoice + ? makeTextMenu( + 'End voice call', + SvgPicture.asset( + 'assets/call_wait.svg', + colorFilter: + ColorFilter.mode(Colors.redAccent, BlendMode.srcIn), + ), + onPressEndVoiceCall, + labelStyle: TextStyle(color: Colors.redAccent)) + : makeTextMenu( + 'Voice call', + SvgPicture.asset( + 'assets/call_wait.svg', + colorFilter: ColorFilter.mode(MyTheme.accent, BlendMode.srcIn), + ), + onPressVoiceCall), + ]; + + final menuItems = menus + .asMap() + .entries + .map((e) => PopupMenuItem(child: e.value.getChild(), value: e.key)) + .toList(); + Future.delayed(Duration.zero, () async { + final size = MediaQuery.of(context).size; + final x = 120.0; + final y = size.height; + var index = await showMenu( + context: context, + position: RelativeRect.fromLTRB(x, y, x, y), + items: menuItems, + elevation: 8, + ); + if (index != null && index < menus.length) { + menus[index].onPressed?.call(); + } + }); + } +} + +class ImagePaint extends StatelessWidget { + @override + Widget build(BuildContext context) { + final m = Provider.of(context); + final c = Provider.of(context); + var s = c.scale; + final adjust = c.getAdjustY(); + return CustomPaint( + painter: ImagePainter( + image: m.image, x: c.x / s, y: (c.y + adjust) / s, scale: s), + ); + } +} + +void showOptions( + BuildContext context, String id, OverlayDialogManager dialogManager) async { + var displays = []; + final pi = gFFI.ffiModel.pi; + final image = gFFI.ffiModel.getConnectionImageText(); + if (image != null) { + displays.add(Padding(padding: const EdgeInsets.only(top: 8), child: image)); + } + if (pi.displays.length > 1 && pi.currentDisplay != kAllDisplayValue) { + final cur = pi.currentDisplay; + final children = []; + final isDarkTheme = MyTheme.currentThemeMode() == ThemeMode.dark; + final numColorSelected = Colors.white; + final numColorUnselected = isDarkTheme ? Colors.grey : Colors.black87; + // We can't use `Theme.of(context).primaryColor` here, the color is: + // - light theme: 0xff2196f3 (Colors.blue) + // - dark theme: 0xff212121 (the canvas color?) + final numBgSelected = + Theme.of(context).colorScheme.primary.withOpacity(0.6); + for (var i = 0; i < pi.displays.length; ++i) { + children.add(InkWell( + onTap: () { + if (i == cur) return; + openMonitorInTheSameTab(i, gFFI, pi); + gFFI.dialogManager.dismissAll(); + }, + child: Ink( + width: 40, + height: 40, + decoration: BoxDecoration( + border: Border.all(color: Theme.of(context).hintColor), + borderRadius: BorderRadius.circular(2), + color: i == cur ? numBgSelected : null), + child: Center( + child: Text((i + 1).toString(), + style: TextStyle( + color: + i == cur ? numColorSelected : numColorUnselected, + fontWeight: FontWeight.bold)))))); + } + displays.add(Padding( + padding: const EdgeInsets.only(top: 8), + child: Wrap( + alignment: WrapAlignment.center, + spacing: 8, + children: children, + ))); + } + if (displays.isNotEmpty) { + displays.add(const Divider(color: MyTheme.border)); + } + + List> viewStyleRadios = + await toolbarViewStyle(context, id, gFFI); + List> imageQualityRadios = + await toolbarImageQuality(context, id, gFFI); + List> codecRadios = await toolbarCodec(context, id, gFFI); + List displayToggles = + await toolbarDisplayToggle(context, id, gFFI); + + dialogManager.show((setState, close, context) { + var viewStyle = + (viewStyleRadios.isNotEmpty ? viewStyleRadios[0].groupValue : '').obs; + var imageQuality = + (imageQualityRadios.isNotEmpty ? imageQualityRadios[0].groupValue : '') + .obs; + var codec = (codecRadios.isNotEmpty ? codecRadios[0].groupValue : '').obs; + final radios = [ + for (var e in viewStyleRadios) + Obx(() => getRadio( + e.child, + e.value, + viewStyle.value, + e.onChanged != null + ? (v) { + e.onChanged?.call(v); + if (v != null) viewStyle.value = v; + } + : null)), + const Divider(color: MyTheme.border), + for (var e in imageQualityRadios) + Obx(() => getRadio( + e.child, + e.value, + imageQuality.value, + e.onChanged != null + ? (v) { + e.onChanged?.call(v); + if (v != null) imageQuality.value = v; + } + : null)), + const Divider(color: MyTheme.border), + for (var e in codecRadios) + Obx(() => getRadio( + e.child, + e.value, + codec.value, + e.onChanged != null + ? (v) { + e.onChanged?.call(v); + if (v != null) codec.value = v; + } + : null)), + if (codecRadios.isNotEmpty) const Divider(color: MyTheme.border), + ]; + + final rxToggleValues = displayToggles.map((e) => e.value.obs).toList(); + final displayTogglesList = displayToggles + .asMap() + .entries + .map((e) => Obx(() => CheckboxListTile( + contentPadding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + value: rxToggleValues[e.key].value, + onChanged: e.value.onChanged != null + ? (v) { + e.value.onChanged?.call(v); + if (v != null) rxToggleValues[e.key].value = v; + } + : null, + title: e.value.child))) + .toList(); + final toggles = [ + ...displayTogglesList, + ]; + + var popupDialogMenus = List.empty(growable: true); + if (popupDialogMenus.isNotEmpty) { + popupDialogMenus.add(const Divider(color: MyTheme.border)); + } + + return CustomAlertDialog( + content: Column( + mainAxisSize: MainAxisSize.min, + children: displays + radios + popupDialogMenus + toggles), + ); + }, clickMaskDismiss: true, backDismiss: true).then((value) { + _disableAndroidSoftKeyboard(); + }); +} + +class FABLocation extends FloatingActionButtonLocation { + FloatingActionButtonLocation location; + double offsetX; + double offsetY; + FABLocation(this.location, this.offsetX, this.offsetY); + + @override + Offset getOffset(ScaffoldPrelayoutGeometry scaffoldGeometry) { + final offset = location.getOffset(scaffoldGeometry); + return Offset(offset.dx + offsetX, offset.dy + offsetY); + } +} diff --git a/flutter/lib/mobile/widgets/custom_scale_widget.dart b/flutter/lib/mobile/widgets/custom_scale_widget.dart new file mode 100644 index 000000000..91d538b2c --- /dev/null +++ b/flutter/lib/mobile/widgets/custom_scale_widget.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hbb/models/model.dart'; +import 'package:flutter_hbb/common.dart'; +import 'package:flutter_hbb/common/widgets/custom_scale_base.dart'; + +class MobileCustomScaleControls extends StatefulWidget { + final FFI ffi; + final ValueChanged? onChanged; + const MobileCustomScaleControls({super.key, required this.ffi, this.onChanged}); + + @override + State createState() => _MobileCustomScaleControlsState(); +} + +class _MobileCustomScaleControlsState extends CustomScaleControls { + @override + FFI get ffi => widget.ffi; + + @override + ValueChanged? get onScaleChanged => widget.onChanged; + + @override + Widget build(BuildContext context) { + // Smaller button size for mobile + const smallBtnConstraints = BoxConstraints(minWidth: 32, minHeight: 32); + + final sliderControl = Slider( + value: scalePos, + min: 0.0, + max: 1.0, + divisions: (CustomScaleControls.maxPercent - CustomScaleControls.minPercent).round(), + label: '$scaleValue%', + onChanged: onSliderChanged, + ); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${translate("Scale custom")}: $scaleValue%', + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + ), + const SizedBox(height: 8), + Row( + children: [ + IconButton( + iconSize: 20, + padding: const EdgeInsets.all(4), + constraints: smallBtnConstraints, + icon: const Icon(Icons.remove), + tooltip: translate('Decrease'), + onPressed: () => nudgeScale(-1), + ), + Expanded(child: sliderControl), + IconButton( + iconSize: 20, + padding: const EdgeInsets.all(4), + constraints: smallBtnConstraints, + icon: const Icon(Icons.add), + tooltip: translate('Increase'), + onPressed: () => nudgeScale(1), + ), + ], + ), + ], + ), + ); + } +} diff --git a/flutter/lib/mobile/widgets/dialog.dart b/flutter/lib/mobile/widgets/dialog.dart index 2d17f3b54..8b645bb88 100644 --- a/flutter/lib/mobile/widgets/dialog.dart +++ b/flutter/lib/mobile/widgets/dialog.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_hbb/common/widgets/setting_widgets.dart'; import 'package:flutter_hbb/common/widgets/toolbar.dart'; @@ -11,100 +12,6 @@ void _showSuccess() { showToast(translate("Successful")); } -void _showError() { - showToast(translate("Error")); -} - -void setPermanentPasswordDialog(OverlayDialogManager dialogManager) async { - final pw = await bind.mainGetPermanentPassword(); - final p0 = TextEditingController(text: pw); - final p1 = TextEditingController(text: pw); - var validateLength = false; - var validateSame = false; - dialogManager.show((setState, close, context) { - submit() async { - close(); - dialogManager.showLoading(translate("Waiting")); - if (await gFFI.serverModel.setPermanentPassword(p0.text)) { - dialogManager.dismissAll(); - _showSuccess(); - } else { - dialogManager.dismissAll(); - _showError(); - } - } - - return CustomAlertDialog( - title: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.password_rounded, color: MyTheme.accent), - Text(translate('Set your own password')).paddingOnly(left: 10), - ], - ), - content: Form( - autovalidateMode: AutovalidateMode.onUserInteraction, - child: Column(mainAxisSize: MainAxisSize.min, children: [ - TextFormField( - autofocus: true, - obscureText: true, - keyboardType: TextInputType.visiblePassword, - decoration: InputDecoration( - labelText: translate('Password'), - ), - controller: p0, - validator: (v) { - if (v == null) return null; - final val = v.trim().length > 5; - if (validateLength != val) { - // use delay to make setState success - Future.delayed(Duration(microseconds: 1), - () => setState(() => validateLength = val)); - } - return val - ? null - : translate('Too short, at least 6 characters.'); - }, - ), - TextFormField( - obscureText: true, - keyboardType: TextInputType.visiblePassword, - decoration: InputDecoration( - labelText: translate('Confirmation'), - ), - controller: p1, - validator: (v) { - if (v == null) return null; - final val = p0.text == v; - if (validateSame != val) { - Future.delayed(Duration(microseconds: 1), - () => setState(() => validateSame = val)); - } - return val - ? null - : translate('The confirmation is not identical.'); - }, - ), - ])), - onCancel: close, - onSubmit: (validateLength && validateSame) ? submit : null, - actions: [ - dialogButton( - 'Cancel', - icon: Icon(Icons.close_rounded), - onPressed: close, - isOutline: true, - ), - dialogButton( - 'OK', - icon: Icon(Icons.done_rounded), - onPressed: (validateLength && validateSame) ? submit : null, - ), - ], - ); - }); -} - void setTemporaryPasswordLengthDialog( OverlayDialogManager dialogManager) async { List lengths = ['6', '8', '10']; @@ -146,8 +53,22 @@ void setTemporaryPasswordLengthDialog( }, backDismiss: true, clickMaskDismiss: true); } +void showServerSettings(OverlayDialogManager dialogManager, + void Function(VoidCallback) setState) async { + Map options = {}; + try { + options = jsonDecode(await bind.mainGetOptions()); + } catch (e) { + print("Invalid server config: $e"); + } + showServerSettingsWithValue( + ServerConfig.fromOptions(options), dialogManager, setState); +} + void showServerSettingsWithValue( - ServerConfig serverConfig, OverlayDialogManager dialogManager) async { + ServerConfig serverConfig, + OverlayDialogManager dialogManager, + void Function(VoidCallback)? upSetState) async { var isInProgress = false; final idCtrl = TextEditingController(text: serverConfig.idServer); final relayCtrl = TextEditingController(text: serverConfig.relayServer); @@ -184,6 +105,43 @@ void showServerSettingsWithValue( return ret; } + Widget buildField( + String label, TextEditingController controller, String errorMsg, + {String? Function(String?)? validator, bool autofocus = false}) { + if (isDesktop || isWeb) { + return Row( + children: [ + SizedBox( + width: 120, + child: Text(label), + ), + SizedBox(width: 8), + Expanded( + child: TextFormField( + controller: controller, + decoration: InputDecoration( + errorText: errorMsg.isEmpty ? null : errorMsg, + contentPadding: + EdgeInsets.symmetric(horizontal: 8, vertical: 12), + ), + validator: validator, + autofocus: autofocus, + ).workaroundFreezeLinuxMint(), + ), + ], + ); + } + + return TextFormField( + controller: controller, + decoration: InputDecoration( + labelText: label, + errorText: errorMsg.isEmpty ? null : errorMsg, + ), + validator: validator, + ).workaroundFreezeLinuxMint(); + } + return CustomAlertDialog( title: Row( children: [ @@ -191,56 +149,45 @@ void showServerSettingsWithValue( ...ServerConfigImportExportWidgets(controllers, errMsgs), ], ), - content: Form( + content: ConstrainedBox( + constraints: const BoxConstraints(minWidth: 500), + child: Form( child: Obx(() => Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextFormField( - controller: idCtrl, - decoration: InputDecoration( - labelText: translate('ID Server'), - errorText: idServerMsg.value.isEmpty - ? null - : idServerMsg.value), - ) - ] + - [ - if (isAndroid) - TextFormField( - controller: relayCtrl, - decoration: InputDecoration( - labelText: translate('Relay Server'), - errorText: relayServerMsg.value.isEmpty - ? null - : relayServerMsg.value), - ) - ] + - [ - TextFormField( - controller: apiCtrl, - decoration: InputDecoration( - labelText: translate('API Server'), - ), - autovalidateMode: AutovalidateMode.onUserInteraction, - validator: (v) { - if (v != null && v.isNotEmpty) { - if (!(v.startsWith('http://') || - v.startsWith("https://"))) { - return translate("invalid_http"); - } + mainAxisSize: MainAxisSize.min, + children: [ + buildField(translate('ID Server'), idCtrl, idServerMsg.value, + autofocus: true), + SizedBox(height: 8), + if (!isIOS && !isWeb) ...[ + buildField(translate('Relay Server'), relayCtrl, + relayServerMsg.value), + SizedBox(height: 8), + ], + buildField( + translate('API Server'), + apiCtrl, + apiServerMsg.value, + validator: (v) { + if (v != null && v.isNotEmpty) { + if (!(v.startsWith('http://') || + v.startsWith("https://"))) { + return translate("invalid_http"); } - return null; - }, + } + return null; + }, + ), + SizedBox(height: 8), + buildField('Key', keyCtrl, ''), + if (isInProgress) + Padding( + padding: EdgeInsets.only(top: 8), + child: LinearProgressIndicator(), ), - TextFormField( - controller: keyCtrl, - decoration: InputDecoration( - labelText: 'Key', - ), - ), - // NOT use Offstage to wrap LinearProgressIndicator - if (isInProgress) const LinearProgressIndicator(), - ]))), + ], + )), + ), + ), actions: [ dialogButton('Cancel', onPressed: () { close(); @@ -251,6 +198,7 @@ void showServerSettingsWithValue( if (await submit()) { close(); showToast(translate('Successful')); + upSetState?.call(() {}); } else { showToast(translate('Failed')); } diff --git a/flutter/lib/mobile/widgets/floating_mouse.dart b/flutter/lib/mobile/widgets/floating_mouse.dart new file mode 100644 index 000000000..d18011c63 --- /dev/null +++ b/flutter/lib/mobile/widgets/floating_mouse.dart @@ -0,0 +1,1209 @@ +// This floating mouse widget simulates a physical mouse when connecting from mobile to desktop in touch mode. + +import 'dart:async'; +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:flutter_hbb/common.dart'; +import 'package:flutter_hbb/models/input_model.dart'; +import 'package:flutter_hbb/models/model.dart'; +import 'package:flutter_hbb/utils/image.dart'; +import 'package:provider/provider.dart'; + +const int _kDotCount = 60; +const double _kDotAngle = 2 * pi / _kDotCount; +final Color _kDefaultColor = Colors.grey.withOpacity(0.7); +final Color _kDefaultHighlightColor = Colors.white24.withOpacity(0.7); +final Color _kTapDownColor = Colors.blue.withOpacity(0.7); +const double _baseMouseWidth = 112.0; +const double _baseMouseHeight = 138.0; +const double _kShowPressedScale = 1.2; +const double kScaleMax = 1.8; +const double kScaleMin = 0.8; + +double? _tryParseCoordinateFromEvt(Map? evt, String key) { + if (evt == null) return null; + final coord = evt[key]; + if (coord == null) return null; + return double.tryParse(coord); +} + +class FloatingMouse extends StatefulWidget { + final FFI ffi; + const FloatingMouse({ + super.key, + required this.ffi, + }); + + @override + State createState() => _FloatingMouseState(); +} + +class _CanvasScrollState { + static const double speedPressed = 3.0; + final InputModel inputModel; + final CanvasModel canvasModel; + final int _intervalMillis = 30; + Timer? _timer; + double _dx = 0; + double _dy = 0; + double _speed = 1.0; + Rect _displayRect = Rect.zero; + Offset _mouseGlobalPosition = Offset.zero; + + _CanvasScrollState({required this.inputModel, required this.canvasModel}); + + double get step => 5.0 * canvasModel.scale; + + set scrollX(double speed) { + _dx = step; + setSpeed(speed); + } + + set scrollY(double speed) { + _dy = step; + setSpeed(speed); + } + + void tryCancel() { + _dx = 0; + _dy = 0; + if (_timer == null) return; + _timer?.cancel(); + _timer = null; + } + + void setPressedSpeed() { + setSpeed(_speed > 0 + ? _CanvasScrollState.speedPressed + : -_CanvasScrollState.speedPressed); + } + + void setReleasedSpeed() { + setSpeed(_speed > 0 ? 1.0 : -1.0); + } + + void setSpeed(double newSpeed) { + _speed = newSpeed; + if (_speed > 0) { + _speed = _speed.clamp(0.1, 10.0); + } else { + _speed = _speed.clamp(-10.0, -0.1); + } + if (_dx != 0) { + _dx = step * _speed; + } else if (_dy != 0) { + _dy = step * _speed; + } + } + + void tryStart(Rect displayRect, Offset mouseGlobalPosition) { + _displayRect = displayRect; + _mouseGlobalPosition = mouseGlobalPosition; + if (_timer != null) return; + _timer = Timer.periodic(Duration(milliseconds: _intervalMillis), (timer) { + if (_dx == 0 && _dy == 0) { + tryCancel(); + } else { + if (_dx != 0) { + canvasModel.panX(_dx); + } + if (_dy != 0) { + canvasModel.panY(_dy); + } + final evt = inputModel.processEventToPeer( + InputModel.getMouseEventMove(), _mouseGlobalPosition, + moveCanvas: false); + if (shouldCancelScrollTimer(evt)) { + tryCancel(); + } + } + }); + } + + bool shouldCancelScrollTimer(Map? evt) { + if (evt == null) { + return true; + } + double s = canvasModel.scale; + assert(s > 0, 'canvasModel.scale should always be positive'); + if (s <= 0) { + return true; + } + if (_dx != 0) { + final x = _tryParseCoordinateFromEvt(evt, 'x'); + if (x == null) { + return true; + } else { + if (_dx < 0) { + if (isDoubleEqual(_displayRect.right - 1, x)) { + return true; + } else { + final dxDisplay = _dx / s; + if ((x - dxDisplay) > (_displayRect.right - 1)) { + canvasModel.panX((x - _displayRect.right + 1) * s); + return true; + } + } + } else { + if (isDoubleEqual(x, _displayRect.left)) { + return true; + } else { + final dxDisplay = _dx / s; + if ((x - dxDisplay) < _displayRect.left) { + canvasModel.panX((x - _displayRect.left) * s); + return true; + } + } + } + } + } + if (_dy != 0) { + final y = _tryParseCoordinateFromEvt(evt, 'y'); + if (y == null) { + return true; + } else { + if (_dy < 0) { + if (isDoubleEqual(_displayRect.bottom - 1, y)) { + return true; + } else { + final dyDisplay = _dy / s; + if ((y - dyDisplay) > (_displayRect.bottom - 1)) { + canvasModel.panY((y - _displayRect.bottom + 1) * s); + return true; + } + } + } else { + if (isDoubleEqual(y, _displayRect.top)) { + return true; + } else { + final dyDisplay = _dy / s; + if ((y - dyDisplay) < _displayRect.top) { + canvasModel.panY((y - _displayRect.top) * s); + return true; + } + } + } + } + } + return false; + } +} + +class _FloatingMouseState extends State { + Rect? _lastBlockedRect; + final GlobalKey _scrollWheelUpKey = GlobalKey(); + final GlobalKey _scrollWheelDownKey = GlobalKey(); + final GlobalKey _mouseWidgetKey = GlobalKey(); + final GlobalKey _cursorPaintKey = GlobalKey(); + + Offset _position = Offset.zero; + bool _isInitialized = false; + double _baseMouseScale = 1.0; + double _mouseScale = 1.0; + bool _isExpanded = true; + bool _isScrolling = false; + Offset? _scrollCenter; + double _snappedPointerAngle = 0.0; + double? _lastSnappedAngle; + late final _CanvasScrollState _canvasScrollState; + Orientation? _previousOrientation; + Timer? _collapseTimer; + late final VirtualMouseMode _virtualMouseMode; + + void _resetCollapseTimer() { + _collapseTimer?.cancel(); + if (_isExpanded) { + _collapseTimer = Timer(const Duration(seconds: 7), () { + if (mounted && _isExpanded) { + final minMouseScale = (_baseMouseScale * 0.3); + setState(() { + _mouseScale = minMouseScale; + _isExpanded = false; + _position += _expandOffset; + }); + } + }); + } + } + + double get mouseWidth => _baseMouseWidth * _mouseScale; + double get mouseHeight => _baseMouseHeight * _mouseScale; + + InputModel get _inputModel => widget.ffi.inputModel; + CursorModel get _cursorModel => widget.ffi.cursorModel; + CanvasModel get _canvasModel => widget.ffi.canvasModel; + + Offset get _expandOffset => + Offset(84 * _baseMouseScale, 12 * _baseMouseScale); + + @override + void initState() { + super.initState(); + _virtualMouseMode = widget.ffi.ffiModel.virtualMouseMode; + _virtualMouseMode.addListener(_onVirtualMouseModeChanged); + _canvasScrollState = + _CanvasScrollState(inputModel: _inputModel, canvasModel: _canvasModel); + _cursorModel.blockEvents = false; + WidgetsBinding.instance.addPostFrameCallback((_) { + _resetPosition(); + _resetCollapseTimer(); + }); + } + + void _onVirtualMouseModeChanged() { + if (mounted) { + setState(() { + if (_virtualMouseMode.showVirtualMouse) { + _isExpanded = true; + _resetCollapseTimer(); + } + }); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final currentOrientation = MediaQuery.of(context).orientation; + if (_previousOrientation != null && + _previousOrientation != currentOrientation) { + _resetPosition(); + } + _previousOrientation = currentOrientation; + } + + void _resetPosition() { + setState(() { + final size = MediaQuery.of(context).size; + _position = Offset( + (size.width - _baseMouseWidth * _mouseScale) / 2, + (size.height - _baseMouseHeight * _mouseScale) / 2, + ); + _isInitialized = true; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _updateBlockedRect(); + }); + } + + @override + void dispose() { + if (_lastBlockedRect != null) { + _cursorModel.removeBlockedRect(_lastBlockedRect!); + } + _virtualMouseMode.removeListener(_onVirtualMouseModeChanged); + _canvasScrollState.tryCancel(); + _cursorModel.blockEvents = false; + _collapseTimer?.cancel(); + super.dispose(); + } + + void _updateBlockedRect() { + final context = _mouseWidgetKey.currentContext; + if (context == null) return; + final renderBox = context.findRenderObject() as RenderBox?; + if (renderBox == null || !renderBox.attached) return; + + final newRect = renderBox.localToGlobal(Offset.zero) & renderBox.size; + + if (_lastBlockedRect != null) { + _cursorModel.removeBlockedRect(_lastBlockedRect!); + } + _cursorModel.addBlockedRect(newRect); + _lastBlockedRect = newRect; + } + + Offset _getMouseGlobalPosition() { + final RenderBox? renderBox = + _cursorPaintKey.currentContext?.findRenderObject() as RenderBox?; + if (renderBox != null) { + return renderBox.localToGlobal(Offset.zero); + } else { + return _position; + } + } + + static Offset? _getPositionFromMouseRetEvt(Map? evt) { + final x = _tryParseCoordinateFromEvt(evt, 'x'); + final y = _tryParseCoordinateFromEvt(evt, 'y'); + if (x == null || y == null) { + return null; + } + return Offset(x, y); + } + + // Returns true if [value] is within 2.01 pixels of [edge]. + // We need this near check because it can make the auto scroll easier to trigger and control. + bool _isValueNearEdge(double edge, double value) { + return (value - edge).abs() < 2.01; + } + + bool _isValueAtEdge(double edge, double value) { + return (value - edge).abs() < 0.01; + } + + bool _isValueAtOrOutsideEdge(double edge, double? value) { + // If value is null, then consider it outside the edge. + return value == null || isDoubleEqual(value, edge); + } + + // If the mouse is very close to the edge of the display, + // we can only start auto scroll when the mouse is at the edge of the screen. + bool _shouldAutoScrollIfCursorNearRemoteEdge(double remoteEdge, + double remoteValue, double localEdge, double localValue) { + if ((remoteEdge - remoteValue).abs() < 100.0) { + if (!_isValueAtEdge(localEdge, localValue)) { + return false; + } + } + return true; + } + + void _onMoveUpdateDelta(Offset delta) { + _resetCollapseTimer(); + final context = this.context; + final size = MediaQuery.of(context).size; + Offset newPosition = _position + delta; + double minX = 0; + double minY = 0; + double maxX = size.width - mouseWidth; + double maxY = size.height - mouseHeight; + newPosition = Offset( + newPosition.dx.clamp(minX, maxX), + newPosition.dy.clamp(minY, maxY), + ); + setState(() { + final isPositionChanged = !(isDoubleEqual(newPosition.dx, _position.dx) && + isDoubleEqual(newPosition.dy, _position.dy)); + _position = newPosition; + if (!_isExpanded) { + return; + } + + Offset? mouseGlobalPosition; + Offset? positionInRemoteDisplay; + if (isPositionChanged) { + mouseGlobalPosition = _getMouseGlobalPosition(); + final evt = _inputModel.handleMouse( + InputModel.getMouseEventMove(), mouseGlobalPosition, + moveCanvas: false); + positionInRemoteDisplay = _getPositionFromMouseRetEvt(evt); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _updateBlockedRect(); + }); + } + + // Get the display rect + final displayRect = widget.ffi.ffiModel.displaysRect(); + if (displayRect == null) { + _canvasScrollState.tryCancel(); + return; + } + + // Get the mouse global position and position in remote display + mouseGlobalPosition ??= _getMouseGlobalPosition(); + if (positionInRemoteDisplay == null) { + final evt = _inputModel.processEventToPeer( + InputModel.getMouseEventMove(), mouseGlobalPosition, + moveCanvas: false); + positionInRemoteDisplay = _getPositionFromMouseRetEvt(evt); + } + + // Check if need to start auto canvas scroll + // If: + // 1. The mouse is near the edge of the screen. + // 2. The position in remote display is in the rect of the display. + // 3. If the remote cursor is near the edge of the remote display, + // then the local mouse must be at the edge of the screen. + // Then start auto canvas scroll. + if (_isValueNearEdge(minX, _position.dx)) { + bool shouldStartScroll = true; + if (_isValueAtOrOutsideEdge( + displayRect.left, positionInRemoteDisplay?.dx)) { + shouldStartScroll = false; + } + if (positionInRemoteDisplay != null) { + if (!_shouldAutoScrollIfCursorNearRemoteEdge(displayRect.left, + positionInRemoteDisplay.dx, minX, _position.dx)) { + shouldStartScroll = false; + } + } + if (!shouldStartScroll) { + _canvasScrollState.tryCancel(); + return; + } + _canvasScrollState.scrollX = 1.0 * _CanvasScrollState.speedPressed; + } else if (_isValueNearEdge(minY, _position.dy)) { + bool shouldStartScroll = true; + if (_isValueAtOrOutsideEdge( + displayRect.top, positionInRemoteDisplay?.dy)) { + shouldStartScroll = false; + } + if (positionInRemoteDisplay != null) { + if (!_shouldAutoScrollIfCursorNearRemoteEdge(displayRect.top, + positionInRemoteDisplay.dy, minY, _position.dy)) { + shouldStartScroll = false; + } + } + if (!shouldStartScroll) { + _canvasScrollState.tryCancel(); + return; + } + _canvasScrollState.scrollY = 1.0 * _CanvasScrollState.speedPressed; + } else if (_isValueNearEdge(maxX, _position.dx)) { + bool shouldStartScroll = true; + if (_isValueAtOrOutsideEdge( + displayRect.right - 1, positionInRemoteDisplay?.dx)) { + shouldStartScroll = false; + } + if (positionInRemoteDisplay != null) { + if (!_shouldAutoScrollIfCursorNearRemoteEdge(displayRect.right - 1, + positionInRemoteDisplay.dx, maxX, _position.dx)) { + shouldStartScroll = false; + } + } + if (!shouldStartScroll) { + _canvasScrollState.tryCancel(); + return; + } + _canvasScrollState.scrollX = -1.0 * _CanvasScrollState.speedPressed; + } else if (_isValueNearEdge(maxY, _position.dy)) { + bool shouldStartScroll = true; + if (_isValueAtOrOutsideEdge( + displayRect.bottom - 1, positionInRemoteDisplay?.dy)) { + shouldStartScroll = false; + } + if (positionInRemoteDisplay != null) { + if (!_shouldAutoScrollIfCursorNearRemoteEdge(displayRect.bottom - 1, + positionInRemoteDisplay.dy, maxY, _position.dy)) { + shouldStartScroll = false; + } + } + if (!shouldStartScroll) { + _canvasScrollState.tryCancel(); + return; + } + _canvasScrollState.scrollY = -1.0 * _CanvasScrollState.speedPressed; + } else { + _canvasScrollState.tryCancel(); + return; + } + _canvasScrollState.tryStart(displayRect, mouseGlobalPosition); + }); + } + + void _onDragHandleUpdate(DragUpdateDetails details) => + _onMoveUpdateDelta(details.delta); + + void _onBodyPointerMoveUpdate(PointerMoveEvent event) => + _onMoveUpdateDelta(event.delta); + + bool _containsPosition(GlobalKey key, Offset pos) { + final contextScroll = key.currentContext; + if (contextScroll == null) return false; + final RenderBox? scrollWheelBox = + contextScroll.findRenderObject() as RenderBox?; + if (scrollWheelBox == null || !scrollWheelBox.attached) return false; + Rect rect = scrollWheelBox.localToGlobal(Offset.zero) & scrollWheelBox.size; + return rect.contains(pos); + } + + void _handlePointerDown(PointerDownEvent event) { + _resetCollapseTimer(); + if (_isScrolling) return; + if (_containsPosition(_scrollWheelUpKey, event.position) || + _containsPosition(_scrollWheelDownKey, event.position)) { + final contextMouse = _mouseWidgetKey.currentContext; + if (contextMouse == null) return; + final RenderBox? mouseBox = contextMouse.findRenderObject() as RenderBox?; + if (mouseBox == null || !mouseBox.attached) return; + + // Only enter scroll mode when all RenderObjects are available. + final Offset mouseTopLeft = mouseBox.localToGlobal(Offset.zero); + final Size mouseSize = mouseBox.size; + final Offset center = + mouseTopLeft + Offset(mouseSize.width / 2, mouseSize.height / 2); + + final vector = event.position - center; + final rawAngle = atan2(vector.dy, vector.dx); + + final closestDotIndex = (rawAngle / _kDotAngle).round(); + _lastSnappedAngle = closestDotIndex * _kDotAngle; + + setState(() { + _isScrolling = true; + _cursorModel.blockEvents = true; + _scrollCenter = center; + _snappedPointerAngle = _lastSnappedAngle!; + }); + } + } + + void _handlePointerMove(PointerMoveEvent event) { + _resetCollapseTimer(); + if (!_isScrolling || _scrollCenter == null || _lastSnappedAngle == null) { + return; + } + + final touchPosition = event.position; + final vector = touchPosition - _scrollCenter!; + final rawCurrentAngle = atan2(vector.dy, vector.dx); + + final closestDotIndex = (rawCurrentAngle / _kDotAngle).round(); + final snappedCurrentAngle = closestDotIndex * _kDotAngle; + + if (snappedCurrentAngle == _lastSnappedAngle) return; + + double deltaAngle = snappedCurrentAngle - _lastSnappedAngle!; + + if (deltaAngle.abs() > pi) { + deltaAngle = (deltaAngle > 0) ? deltaAngle - 2 * pi : deltaAngle + 2 * pi; + } + + _lastSnappedAngle = snappedCurrentAngle; + + setState(() { + _snappedPointerAngle = snappedCurrentAngle; + _inputModel.scroll(deltaAngle > 0 ? -1 : 1); + }); + } + + void _tryCancelScrolling() { + _resetCollapseTimer(); + if (!_isScrolling) return; + setState(() { + _isScrolling = false; + _cursorModel.blockEvents = false; + _lastSnappedAngle = null; + _scrollCenter = null; + }); + } + + void _handlePointerUp(PointerUpEvent event) => _tryCancelScrolling(); + void _handlePointerCancel(PointerCancelEvent event) => _tryCancelScrolling(); + + @override + Widget build(BuildContext context) { + if (!_isInitialized) { + return const Offstage(); + } + final virtualMouseMode = _virtualMouseMode; + if (!virtualMouseMode.showVirtualMouse) { + return const Offstage(); + } + _baseMouseScale = virtualMouseMode.virtualMouseScale; + if (_isExpanded) { + _mouseScale = _baseMouseScale; + } else { + final minMouseScale = (_baseMouseScale * 0.3); + _mouseScale = minMouseScale; + } + return Listener( + onPointerDown: _isExpanded ? _handlePointerDown : null, + onPointerMove: _handlePointerMove, + onPointerUp: _handlePointerUp, + onPointerCancel: _handlePointerCancel, + behavior: HitTestBehavior.translucent, + child: Stack( + children: [ + if (!_isScrolling) + Positioned( + left: _position.dx, + top: _position.dy, + child: _buildMouseWithHide(), + ), + if (_isScrolling && _scrollCenter != null) + Positioned.fill( + child: Builder( + builder: (context) { + final RenderBox? customPaintBox = + context.findRenderObject() as RenderBox?; + if (customPaintBox == null || !customPaintBox.attached) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _isScrolling) setState(() {}); + }); + return const SizedBox.expand(); + } + final Offset customPaintTopLeft = + customPaintBox.localToGlobal(Offset.zero); + final Offset localCenter = + _scrollCenter! - customPaintTopLeft; + return CustomPaint( + painter: DottedCirclePainter( + center: localCenter, + pointerAngle: _snappedPointerAngle, + scale: _mouseScale, + ), + ); + }, + ), + ), + ], + ), + ); + } + + Widget _buildMouseWithHide() { + double minMouseScale = (_baseMouseScale * 0.3); + if (!_isExpanded) { + return SizedBox( + width: mouseWidth, + height: mouseHeight, + child: GestureDetector( + onPanUpdate: _onDragHandleUpdate, + onTap: () { + setState(() { + _mouseScale = _baseMouseScale; + _isExpanded = true; + _position -= _expandOffset; + }); + _resetCollapseTimer(); + }, + child: MouseBody( + scrollWheelUpKey: _scrollWheelUpKey, + scrollWheelDownKey: _scrollWheelDownKey, + mouseWidgetKey: _mouseWidgetKey, + inputModel: _isExpanded ? _inputModel : null, + scale: _mouseScale, + resetCollapseTimer: _resetCollapseTimer, + ), + )); + } else { + return SizedBox( + width: mouseWidth, + height: mouseHeight, + child: Column( + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CursorPaint( + key: _cursorPaintKey, + scale: _mouseScale, + ), + const Spacer(), + GestureDetector( + onTap: () { + _collapseTimer?.cancel(); + setState(() { + _mouseScale = minMouseScale; + _isExpanded = false; + _position += _expandOffset; + }); + }, + child: Container( + width: 18 * _mouseScale, + height: 18 * _mouseScale, + child: Center( + child: Container( + width: 14 * _mouseScale, + height: 14 * _mouseScale, + decoration: const BoxDecoration( + color: Colors.grey, + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: Icon(Icons.close, + color: Colors.white, size: 12 * _mouseScale), + ), + ), + ), + ), + ], + ), + Padding( + padding: EdgeInsets.only(left: 14 * _mouseScale), + child: MouseBody( + scrollWheelUpKey: _scrollWheelUpKey, + scrollWheelDownKey: _scrollWheelDownKey, + mouseWidgetKey: _mouseWidgetKey, + onPointerMoveUpdate: _onBodyPointerMoveUpdate, + cancelCanvasScroll: _canvasScrollState.tryCancel, + setCanvasScrollPressed: _canvasScrollState.setPressedSpeed, + setCanvasScrollReleased: _canvasScrollState.setReleasedSpeed, + inputModel: _isExpanded ? _inputModel : null, + scale: _mouseScale, + resetCollapseTimer: _resetCollapseTimer, + )), + ], + ), + ); + } + } +} + +class MouseBody extends StatefulWidget { + final GlobalKey scrollWheelUpKey; + final GlobalKey scrollWheelDownKey; + final GlobalKey mouseWidgetKey; + final Function(PointerMoveEvent)? onPointerMoveUpdate; + final Function()? cancelCanvasScroll; + final Function()? setCanvasScrollPressed; + final Function()? setCanvasScrollReleased; + final InputModel? inputModel; + final double scale; + final Function()? resetCollapseTimer; + const MouseBody({ + super.key, + required this.scrollWheelUpKey, + required this.scrollWheelDownKey, + required this.mouseWidgetKey, + required this.scale, + this.inputModel, + this.onPointerMoveUpdate, + this.cancelCanvasScroll, + this.setCanvasScrollPressed, + this.setCanvasScrollReleased, + this.resetCollapseTimer, + }); + + @override + State createState() => _MouseBodyState(); +} + +class WidgetScale { + final double scale; + final double translateScale; + + const WidgetScale({required this.scale, required this.translateScale}); + + static WidgetScale getScale(bool down, double s) { + if (down) { + return WidgetScale( + scale: s * _kShowPressedScale, + translateScale: s * (_kShowPressedScale - 1.0) * 0.5); + } else { + return WidgetScale(scale: s, translateScale: 0.0); + } + } +} + +class _MouseBodyState extends State { + bool _leftDown = false; + bool _rightDown = false; + bool _midDown = false; + bool _dragDown = false; + + Widget _buildScrollUpDown(GlobalKey key, IconData iconData, double s) { + return Container( + key: key, + height: 17 * s, + child: Icon( + iconData, + color: _kDefaultHighlightColor, + size: 14 * s, + ), + ); + } + + Widget _buildScrollMidButton(double s) { + return Listener( + onPointerDown: widget.inputModel != null + ? (event) { + widget.resetCollapseTimer?.call(); + setState(() { + _midDown = true; + widget.inputModel?.tapDown(MouseButtons.wheel); + }); + } + : null, + onPointerUp: widget.inputModel != null + ? (event) { + setState(() { + _midDown = false; + widget.inputModel?.tapUp(MouseButtons.wheel); + widget.cancelCanvasScroll?.call(); + }); + } + : null, + onPointerCancel: widget.inputModel != null + ? (event) { + setState(() { + _midDown = false; + widget.inputModel?.tapUp(MouseButtons.wheel); + widget.cancelCanvasScroll?.call(); + }); + } + : null, + onPointerMove: widget.onPointerMoveUpdate, + behavior: HitTestBehavior.opaque, + child: Container( + height: 28 * s, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 6 * s, + height: 2 * s, + color: _kDefaultHighlightColor, + ), + SizedBox(height: 3 * s), + Container( + width: 8 * s, + height: 2 * s, + color: _kDefaultHighlightColor, + ), + SizedBox(height: 3 * s), + Container( + width: 6 * s, + height: 2 * s, + color: _kDefaultHighlightColor, + ), + ], + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final s = widget.scale; + final leftScale = WidgetScale.getScale(_leftDown, s); + final rightScale = WidgetScale.getScale(_rightDown, s); + final midScale = WidgetScale.getScale(_midDown, s); + return Row( + children: [ + SizedBox( + key: widget.mouseWidgetKey, + width: 80 * s, + height: 120 * s, + child: Column( + children: [ + SizedBox( + height: 55 * s, + child: Stack( + clipBehavior: Clip.none, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + // Left button + Transform.translate( + offset: Offset( + -(80 - 24) * 0.5 * leftScale.translateScale, + -32 * leftScale.translateScale), + child: SizedBox( + width: (80 - 24) * 0.5 * leftScale.scale, + child: Listener( + onPointerMove: widget.onPointerMoveUpdate, + onPointerDown: widget.inputModel != null + ? (event) { + widget.resetCollapseTimer?.call(); + setState(() { + _leftDown = true; + widget.inputModel + ?.tapDown(MouseButtons.left); + }); + } + : null, + onPointerUp: widget.inputModel != null + ? (event) => setState(() { + _leftDown = false; + widget.inputModel + ?.tapUp(MouseButtons.left); + widget.cancelCanvasScroll?.call(); + }) + : null, + onPointerCancel: widget.inputModel != null + ? (event) => setState(() { + _leftDown = false; + widget.inputModel + ?.tapUp(MouseButtons.left); + widget.cancelCanvasScroll?.call(); + }) + : null, + child: Container( + decoration: BoxDecoration( + color: _leftDown + ? _kTapDownColor + : _kDefaultColor, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(22 * s)), + ), + margin: EdgeInsets.only(right: 0.5 * s), + ), + ), + ), + ), + const Spacer(), + Transform.translate( + offset: Offset( + (80 - 24) * 0.5 * rightScale.translateScale, + -32 * rightScale.translateScale), + child: SizedBox( + width: (80 - 24) * 0.5 * rightScale.scale, + child: Listener( + onPointerMove: widget.onPointerMoveUpdate, + onPointerDown: widget.inputModel != null + ? (event) { + widget.resetCollapseTimer?.call(); + setState(() { + _rightDown = true; + widget.inputModel + ?.tapDown(MouseButtons.right); + }); + } + : null, + onPointerUp: widget.inputModel != null + ? (event) => setState(() { + _rightDown = false; + widget.inputModel + ?.tapUp(MouseButtons.right); + widget.cancelCanvasScroll?.call(); + }) + : null, + onPointerCancel: widget.inputModel != null + ? (event) => setState(() { + _rightDown = false; + widget.inputModel + ?.tapUp(MouseButtons.right); + widget.cancelCanvasScroll?.call(); + }) + : null, + child: Container( + decoration: BoxDecoration( + color: _rightDown + ? _kTapDownColor + : _kDefaultColor, + borderRadius: BorderRadius.only( + topRight: Radius.circular(22 * s)), + ), + margin: EdgeInsets.only(left: 0.5 * s), + ), + ), + ), + ), + ], + ), + // Middle function area overflows Row bottom + Positioned( + left: (80 * s - 22 * s) / 2, + top: 0, + child: Transform.translate( + offset: Offset(0, -2 * s), + child: Container( + width: 22 * s, + height: 67 * s, + decoration: BoxDecoration( + color: Colors.grey.withOpacity(0.7), + borderRadius: BorderRadius.vertical( + top: Radius.circular(12 * s), + bottom: Radius.circular(16 * s), + ), + ), + padding: EdgeInsets.symmetric(vertical: 2 * s), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _buildScrollUpDown(widget.scrollWheelUpKey, + Icons.keyboard_arrow_up, midScale.scale), + _buildScrollMidButton(midScale.scale), + _buildScrollUpDown(widget.scrollWheelDownKey, + Icons.keyboard_arrow_down, midScale.scale), + ], + ), + ), + ), + ), + ], + ), + ), + // Thin gap separates upper and lower parts + SizedBox(height: 1 * s), + // Bottom part: drag area (top middle indentation) + Expanded( + child: Listener( + onPointerMove: widget.onPointerMoveUpdate, + onPointerDown: widget.inputModel != null + ? (event) { + widget.resetCollapseTimer?.call(); + setState(() { + _dragDown = true; + }); + widget.setCanvasScrollPressed?.call(); + } + : null, + onPointerUp: widget.inputModel != null + ? (event) { + setState(() { + _dragDown = false; + }); + widget.setCanvasScrollReleased?.call(); + } + : null, + onPointerCancel: widget.inputModel != null + ? (event) { + setState(() { + _dragDown = false; + }); + widget.setCanvasScrollReleased?.call(); + } + : null, + behavior: HitTestBehavior.opaque, + child: CustomPaint( + painter: DragAreaTopIndentPainter( + color: _dragDown ? _kTapDownColor : _kDefaultColor, + scale: widget.scale), + child: Container( + width: 80 * s, + alignment: Alignment.center, + child: Transform.rotate( + angle: pi / 2, + child: Icon(Icons.drag_indicator, + color: _kDefaultHighlightColor, size: 18 * s), + ), + ), + ), + ), + ), + ], + ), + ), + const Spacer() + ], + ); + } +} + +class DottedCirclePainter extends CustomPainter { + final Offset center; + final double pointerAngle; + final double scale; + final Offset? scrollWheelCenter; + + DottedCirclePainter( + {required this.center, + required this.pointerAngle, + required this.scale, + this.scrollWheelCenter}); + + @override + void paint(Canvas canvas, Size size) { + final radius = 48.0 * scale; + final circlePaint = Paint() + ..color = Colors.grey.shade400 + ..style = PaintingStyle.fill; + final pointerPaint = Paint() + ..color = Colors.blue + ..style = PaintingStyle.fill; + + const dotRadius = 2.5; + for (int i = 0; i < _kDotCount; i += 3) { + final angle = i * _kDotAngle; + final dotX = center.dx + radius * cos(angle); + final dotY = center.dy + radius * sin(angle); + canvas.drawCircle(Offset(dotX, dotY), dotRadius, circlePaint); + } + + final pointerX = center.dx + radius * cos(pointerAngle); + final pointerY = center.dy + radius * sin(pointerAngle); + final pointerPosition = Offset(pointerX, pointerY); + canvas.drawCircle(pointerPosition, 8.0, pointerPaint); + } + + @override + bool shouldRepaint(covariant DottedCirclePainter oldDelegate) { + return oldDelegate.pointerAngle != pointerAngle || + oldDelegate.center != center || + oldDelegate.scrollWheelCenter != scrollWheelCenter; + } +} + +// Painter for the bottom center indentation of the drag area +class BottomIndentPainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = Colors.grey.withOpacity(0.7) + ..style = PaintingStyle.fill; + // Draw bottom semicircle + final center = Offset(size.width / 2, size.height); + canvas.drawArc( + Rect.fromCenter(center: center, width: size.width, height: size.height), + pi, + pi, + false, + paint, + ); + // Use background color to carve a circular notch in the middle + final clearPaint = Paint()..blendMode = BlendMode.clear; + canvas.drawCircle(Offset(size.width / 2, size.height - 10), 10, clearPaint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + +// Painter for the top center indentation of the drag area +class DragAreaTopIndentPainter extends CustomPainter { + final double scale; + final Color color; + DragAreaTopIndentPainter({required this.color, required this.scale}); + + @override + void paint(Canvas canvas, Size size) { + // Use saveLayer to make the hollow part transparent + final paint = Paint() + ..color = color + ..style = PaintingStyle.fill; + canvas.saveLayer(Offset.zero & size, Paint()); + // Draw drag area main body (rectangle + bottom rounded corners) + final rect = Rect.fromLTWH(0, 0, size.width, size.height); + final rrect = RRect.fromRectAndCorners( + rect, + bottomLeft: Radius.circular(40 * scale), + bottomRight: Radius.circular(40 * scale), + ); + canvas.drawRRect(rrect, paint); + // Use BlendMode.dstOut to carve a smaller semicircular notch at the top center + final clearPaint = Paint()..blendMode = BlendMode.dstOut; + canvas.drawArc( + Rect.fromCenter( + center: Offset(size.width / 2, 0), + width: 25 * scale, + height: 20 * scale), + 0, + pi, + false, + clearPaint, + ); + canvas.restore(); + } + + @override + bool shouldRepaint(covariant DragAreaTopIndentPainter oldDelegate) { + return oldDelegate.color != color || oldDelegate.scale != scale; + } +} + +class CursorPaint extends StatelessWidget { + final double scale; + CursorPaint({super.key, required this.scale}); + + @override + Widget build(BuildContext context) { + final cursorModel = Provider.of(context); + double hotx = cursorModel.hotx; + double hoty = cursorModel.hoty; + var image = cursorModel.image; + if (image == null) { + if (preDefaultCursor.image != null) { + image = preDefaultCursor.image; + hotx = preDefaultCursor.image!.width / 2; + hoty = preDefaultCursor.image!.height / 2; + } + } + if (image == null) { + return const Offstage(); + } + assert(scale > 0, 'scale should always be positive'); + if (scale <= 0) { + return const Offstage(); + } + return CustomPaint( + painter: ImagePainter(image: image, x: -hotx, y: -hoty, scale: scale), + ); + } +} diff --git a/flutter/lib/mobile/widgets/floating_mouse_widgets.dart b/flutter/lib/mobile/widgets/floating_mouse_widgets.dart new file mode 100644 index 000000000..dbcc606af --- /dev/null +++ b/flutter/lib/mobile/widgets/floating_mouse_widgets.dart @@ -0,0 +1,905 @@ +// These floating mouse widgets are used to simulate a physical mouse +// when "mobile" -> "desktop" in mouse mode. +// This file does not contain whole mouse widgets, it only contains +// parts that help to control, such as wheel scroll and wheel button. + +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; + +import 'package:flutter_hbb/common.dart'; +import 'package:flutter_hbb/common/widgets/remote_input.dart'; +import 'package:flutter_hbb/models/input_model.dart'; +import 'package:flutter_hbb/models/model.dart'; +import 'package:flutter_hbb/models/platform_model.dart'; + +// Used for the wheel button and wheel scroll widgets +const double _kSpaceToHorizontalEdge = 25; +const double _wheelWidth = 50; +const double _wheelHeight = 162; +// Used for the left/right button widgets +const double _kSpaceToVerticalEdge = 15; +const double _kSpaceBetweenLeftRightButtons = 40; +const double _kLeftRightButtonWidth = 55; +const double _kLeftRightButtonHeight = 40; +const double _kBorderWidth = 1; +final Color _kDefaultBorderColor = Colors.white.withOpacity(0.7); +final Color _kDefaultColor = Colors.black.withOpacity(0.4); +final Color _kTapDownColor = Colors.blue.withOpacity(0.7); +final Color _kWidgetHighlightColor = Colors.white.withOpacity(0.9); +const int _kInputTimerIntervalMillis = 100; + +class FloatingMouseWidgets extends StatefulWidget { + final FFI ffi; + const FloatingMouseWidgets({ + super.key, + required this.ffi, + }); + + @override + State createState() => _FloatingMouseWidgetsState(); +} + +class _FloatingMouseWidgetsState extends State { + InputModel get _inputModel => widget.ffi.inputModel; + CursorModel get _cursorModel => widget.ffi.cursorModel; + late final VirtualMouseMode _virtualMouseMode; + + @override + void initState() { + super.initState(); + _virtualMouseMode = widget.ffi.ffiModel.virtualMouseMode; + _virtualMouseMode.addListener(_onVirtualMouseModeChanged); + _cursorModel.blockEvents = false; + isSpecialHoldDragActive = false; + } + + void _onVirtualMouseModeChanged() { + if (mounted) { + setState(() {}); + } + } + + @override + void dispose() { + _virtualMouseMode.removeListener(_onVirtualMouseModeChanged); + super.dispose(); + _cursorModel.blockEvents = false; + isSpecialHoldDragActive = false; + } + + @override + Widget build(BuildContext context) { + final virtualMouseMode = _virtualMouseMode; + if (!virtualMouseMode.showVirtualMouse) { + return const Offstage(); + } + return Stack( + children: [ + FloatingWheel( + inputModel: _inputModel, + cursorModel: _cursorModel, + ), + if (virtualMouseMode.showVirtualJoystick) + VirtualJoystick( + cursorModel: _cursorModel, + inputModel: _inputModel, + ), + FloatingLeftRightButton( + isLeft: true, + inputModel: _inputModel, + cursorModel: _cursorModel, + ), + FloatingLeftRightButton( + isLeft: false, + inputModel: _inputModel, + cursorModel: _cursorModel, + ), + ], + ); + } +} + +class FloatingWheel extends StatefulWidget { + final InputModel inputModel; + final CursorModel cursorModel; + const FloatingWheel( + {super.key, required this.inputModel, required this.cursorModel}); + + @override + State createState() => _FloatingWheelState(); +} + +class _FloatingWheelState extends State { + Offset _position = Offset.zero; + bool _isInitialized = false; + Rect? _lastBlockedRect; + + bool _isUpDown = false; + bool _isMidDown = false; + bool _isDownDown = false; + + Orientation? _previousOrientation; + + Timer? _scrollTimer; + + InputModel get _inputModel => widget.inputModel; + CursorModel get _cursorModel => widget.cursorModel; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _resetPosition(); + }); + } + + void _resetPosition() { + final size = MediaQuery.of(context).size; + setState(() { + _position = Offset( + size.width - _wheelWidth - _kSpaceToHorizontalEdge, + (size.height - _wheelHeight) / 2, + ); + _isInitialized = true; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _updateBlockedRect(); + }); + } + + void _updateBlockedRect() { + if (_lastBlockedRect != null) { + _cursorModel.removeBlockedRect(_lastBlockedRect!); + } + final newRect = + Rect.fromLTWH(_position.dx, _position.dy, _wheelWidth, _wheelHeight); + _cursorModel.addBlockedRect(newRect); + _lastBlockedRect = newRect; + } + + @override + void dispose() { + _scrollTimer?.cancel(); + if (_lastBlockedRect != null) { + _cursorModel.removeBlockedRect(_lastBlockedRect!); + } + super.dispose(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final currentOrientation = MediaQuery.of(context).orientation; + if (_previousOrientation != null && + _previousOrientation != currentOrientation) { + _resetPosition(); + } + _previousOrientation = currentOrientation; + } + + Widget _buildUpDownButton( + void Function(PointerDownEvent) onPointerDown, + void Function(PointerUpEvent) onPointerUp, + void Function(PointerCancelEvent) onPointerCancel, + bool Function() flagGetter, + BorderRadiusGeometry borderRadius, + IconData iconData) { + return Listener( + onPointerDown: onPointerDown, + onPointerUp: onPointerUp, + onPointerCancel: onPointerCancel, + child: Container( + width: _wheelWidth, + height: 55, + alignment: Alignment.center, + decoration: BoxDecoration( + color: _kDefaultColor, + border: Border.all( + color: flagGetter() ? _kTapDownColor : _kDefaultBorderColor, + width: 1), + borderRadius: borderRadius, + ), + child: Icon(iconData, color: _kDefaultBorderColor, size: 32), + ), + ); + } + + @override + Widget build(BuildContext context) { + if (!_isInitialized) { + return Positioned(child: Offstage()); + } + return Positioned( + left: _position.dx, + top: _position.dy, + child: _buildWidget(context), + ); + } + + Widget _buildWidget(BuildContext context) { + return Container( + width: _wheelWidth, + height: _wheelHeight, + child: Column( + children: [ + _buildUpDownButton( + (event) { + setState(() { + _isUpDown = true; + }); + _startScrollTimer(1); + }, + (event) { + setState(() { + _isUpDown = false; + }); + _stopScrollTimer(); + }, + (event) { + setState(() { + _isUpDown = false; + }); + _stopScrollTimer(); + }, + () => _isUpDown, + BorderRadius.vertical(top: Radius.circular(_wheelWidth * 0.5)), + Icons.keyboard_arrow_up, + ), + Listener( + onPointerDown: (event) { + setState(() { + _isMidDown = true; + }); + _inputModel.tapDown(MouseButtons.wheel); + }, + onPointerUp: (event) { + setState(() { + _isMidDown = false; + }); + _inputModel.tapUp(MouseButtons.wheel); + }, + onPointerCancel: (event) { + setState(() { + _isMidDown = false; + }); + _inputModel.tapUp(MouseButtons.wheel); + }, + child: Container( + width: _wheelWidth, + height: 52, + decoration: BoxDecoration( + color: _kDefaultColor, + border: Border.symmetric( + vertical: BorderSide( + color: + _isMidDown ? _kTapDownColor : _kDefaultBorderColor, + width: _kBorderWidth)), + ), + child: Center( + child: Container( + width: _wheelWidth - 10, + height: _wheelWidth - 10, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 18, + height: 2, + color: _kDefaultBorderColor, + ), + SizedBox(height: 6), + Container( + width: 24, + height: 2, + color: _kDefaultBorderColor, + ), + SizedBox(height: 6), + Container( + width: 18, + height: 2, + color: _kDefaultBorderColor, + ), + ], + ), + ), + ), + ), + ), + ), + _buildUpDownButton( + (event) { + setState(() { + _isDownDown = true; + }); + _startScrollTimer(-1); + }, + (event) { + setState(() { + _isDownDown = false; + }); + _stopScrollTimer(); + }, + (event) { + setState(() { + _isDownDown = false; + }); + _stopScrollTimer(); + }, + () => _isDownDown, + BorderRadius.vertical(bottom: Radius.circular(_wheelWidth * 0.5)), + Icons.keyboard_arrow_down, + ), + ], + ), + ); + } + + void _startScrollTimer(int direction) { + _scrollTimer?.cancel(); + _inputModel.scroll(direction); + _scrollTimer = Timer.periodic( + Duration(milliseconds: _kInputTimerIntervalMillis), (timer) { + _inputModel.scroll(direction); + }); + } + + void _stopScrollTimer() { + _scrollTimer?.cancel(); + _scrollTimer = null; + } +} + +class FloatingLeftRightButton extends StatefulWidget { + final bool isLeft; + final InputModel inputModel; + final CursorModel cursorModel; + const FloatingLeftRightButton( + {super.key, + required this.isLeft, + required this.inputModel, + required this.cursorModel}); + + @override + State createState() => + _FloatingLeftRightButtonState(); +} + +class _FloatingLeftRightButtonState extends State { + Offset _position = Offset.zero; + bool _isInitialized = false; + bool _isDown = false; + Rect? _lastBlockedRect; + + Orientation? _previousOrientation; + Offset _preSavedPos = Offset.zero; + + // Gesture ambiguity resolution + Timer? _tapDownTimer; + final Duration _pressTimeout = const Duration(milliseconds: 200); + bool _isDragging = false; + + bool get _isLeft => widget.isLeft; + InputModel get _inputModel => widget.inputModel; + CursorModel get _cursorModel => widget.cursorModel; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + final currentOrientation = MediaQuery.of(context).orientation; + _previousOrientation = currentOrientation; + _resetPosition(currentOrientation); + }); + } + + @override + void dispose() { + if (_lastBlockedRect != null) { + _cursorModel.removeBlockedRect(_lastBlockedRect!); + } + _tapDownTimer?.cancel(); + _trySavePosition(); + super.dispose(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final currentOrientation = MediaQuery.of(context).orientation; + if (_previousOrientation == null || + _previousOrientation != currentOrientation) { + _resetPosition(currentOrientation); + } + _previousOrientation = currentOrientation; + } + + double _getOffsetX(double w) { + if (_isLeft) { + return (w - _kLeftRightButtonWidth * 2 - _kSpaceBetweenLeftRightButtons) * + 0.5; + } else { + return (w + _kSpaceBetweenLeftRightButtons) * 0.5; + } + } + + String _getPositionKey(Orientation ori) { + final strLeftRight = _isLeft ? 'l' : 'r'; + final strOri = ori == Orientation.landscape ? 'l' : 'p'; + return '$strLeftRight$strOri-mouse-btn-pos'; + } + + static Offset? _loadPositionFromString(String s) { + if (s.isEmpty) { + return null; + } + try { + final m = jsonDecode(s); + return Offset(m['x'], m['y']); + } catch (e) { + debugPrintStack(label: 'Failed to load position "$s" $e'); + return null; + } + } + + void _trySavePosition() { + if (_previousOrientation == null) return; + if (((_position - _preSavedPos)).distanceSquared < 0.1) return; + final pos = jsonEncode({ + 'x': _position.dx, + 'y': _position.dy, + }); + bind.setLocalFlutterOption( + k: _getPositionKey(_previousOrientation!), v: pos); + _preSavedPos = _position; + } + + void _restorePosition(Orientation ori) { + final ps = bind.getLocalFlutterOption(k: _getPositionKey(ori)); + final pos = _loadPositionFromString(ps); + if (pos == null) { + final size = MediaQuery.of(context).size; + _position = Offset(_getOffsetX(size.width), + size.height - _kSpaceToVerticalEdge - _kLeftRightButtonHeight); + } else { + _position = pos; + _preSavedPos = pos; + } + } + + void _resetPosition(Orientation ori) { + setState(() { + _restorePosition(ori); + _isInitialized = true; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _updateBlockedRect(); + }); + } + + void _updateBlockedRect() { + if (_lastBlockedRect != null) { + _cursorModel.removeBlockedRect(_lastBlockedRect!); + } + final newRect = Rect.fromLTWH(_position.dx, _position.dy, + _kLeftRightButtonWidth, _kLeftRightButtonHeight); + _cursorModel.addBlockedRect(newRect); + _lastBlockedRect = newRect; + } + + void _onMoveUpdateDelta(Offset delta) { + final context = this.context; + final size = MediaQuery.of(context).size; + Offset newPosition = _position + delta; + double minX = _kSpaceToHorizontalEdge; + double minY = _kSpaceToVerticalEdge; + double maxX = size.width - _kLeftRightButtonWidth - _kSpaceToHorizontalEdge; + double maxY = size.height - _kLeftRightButtonHeight - _kSpaceToVerticalEdge; + newPosition = Offset( + newPosition.dx.clamp(minX, maxX), + newPosition.dy.clamp(minY, maxY), + ); + final isPositionChanged = !(isDoubleEqual(newPosition.dx, _position.dx) && + isDoubleEqual(newPosition.dy, _position.dy)); + setState(() { + _position = newPosition; + }); + if (isPositionChanged) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _updateBlockedRect(); + }); + } + } + + void _onBodyPointerMoveUpdate(PointerMoveEvent event) { + _cursorModel.blockEvents = true; + // If move, it's a drag, not a tap. + _isDragging = true; + // Cancel the timer to prevent it from being recognized as a tap/hold. + _tapDownTimer?.cancel(); + _tapDownTimer = null; + _onMoveUpdateDelta(event.delta); + } + + Widget _buildButtonIcon() { + final double w = _kLeftRightButtonWidth * 0.45; + final double h = _kLeftRightButtonHeight * 0.75; + final double borderRadius = w * 0.5; + final double quarterCircleRadius = borderRadius * 0.9; + return Stack( + children: [ + Container( + width: w, + height: h, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(_kLeftRightButtonWidth * 0.225), + color: Colors.white, + ), + ), + Positioned( + left: _isLeft ? quarterCircleRadius * 0.25 : null, + right: _isLeft ? null : quarterCircleRadius * 0.25, + top: quarterCircleRadius * 0.25, + child: CustomPaint( + size: Size(quarterCircleRadius * 2, quarterCircleRadius * 2), + painter: _QuarterCirclePainter( + color: _kDefaultColor, + isLeft: _isLeft, + radius: quarterCircleRadius, + ), + ), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + if (!_isInitialized) { + return Positioned(child: Offstage()); + } + return Positioned( + left: _position.dx, + top: _position.dy, + // We can't use the GestureDetector here, because `onTapDown` may be + // triggered sometimes when dragging. + child: Listener( + onPointerMove: _onBodyPointerMoveUpdate, + onPointerDown: (event) async { + _isDragging = false; + setState(() { + _isDown = true; + }); + // Start a timer. If it fires, it's a hold. + _tapDownTimer?.cancel(); + _tapDownTimer = Timer(_pressTimeout, () { + isSpecialHoldDragActive = true; + () async { + await _cursorModel.syncCursorPosition(); + await _inputModel + .tapDown(_isLeft ? MouseButtons.left : MouseButtons.right); + }(); + _tapDownTimer = null; + }); + }, + onPointerUp: (event) { + _cursorModel.blockEvents = false; + setState(() { + _isDown = false; + }); + // If timer is active, it's a quick tap. + if (_tapDownTimer != null) { + _tapDownTimer!.cancel(); + _tapDownTimer = null; + // Fire tap down and up quickly. + _inputModel + .tapDown(_isLeft ? MouseButtons.left : MouseButtons.right) + .then( + (_) => Future.delayed(const Duration(milliseconds: 50), () { + _inputModel.tapUp( + _isLeft ? MouseButtons.left : MouseButtons.right); + })); + } else { + // If it's not a quick tap, it could be a hold or drag. + // If it was a hold, isSpecialHoldDragActive is true. + if (isSpecialHoldDragActive) { + _inputModel + .tapUp(_isLeft ? MouseButtons.left : MouseButtons.right); + } + } + + if (_isDragging) { + _trySavePosition(); + } + isSpecialHoldDragActive = false; + }, + onPointerCancel: (event) { + _cursorModel.blockEvents = false; + setState(() { + _isDown = false; + }); + _tapDownTimer?.cancel(); + _tapDownTimer = null; + if (isSpecialHoldDragActive) { + _inputModel.tapUp(_isLeft ? MouseButtons.left : MouseButtons.right); + } + isSpecialHoldDragActive = false; + if (_isDragging) { + _trySavePosition(); + } + }, + child: Container( + width: _kLeftRightButtonWidth, + height: _kLeftRightButtonHeight, + alignment: Alignment.center, + decoration: BoxDecoration( + color: _kDefaultColor, + border: Border.all( + color: _isDown ? _kTapDownColor : _kDefaultBorderColor, + width: _kBorderWidth), + borderRadius: _isLeft + ? BorderRadius.horizontal( + left: Radius.circular(_kLeftRightButtonHeight * 0.5)) + : BorderRadius.horizontal( + right: Radius.circular(_kLeftRightButtonHeight * 0.5)), + ), + child: _buildButtonIcon(), + ), + ), + ); + } +} + +class _QuarterCirclePainter extends CustomPainter { + final Color color; + final bool isLeft; + final double radius; + _QuarterCirclePainter( + {required this.color, required this.isLeft, required this.radius}); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..style = PaintingStyle.fill; + final rect = Rect.fromLTWH(0, 0, radius * 2, radius * 2); + if (isLeft) { + canvas.drawArc(rect, -pi, pi / 2, true, paint); + } else { + canvas.drawArc(rect, -pi / 2, pi / 2, true, paint); + } + } + + @override + bool shouldRepaint(CustomPainter oldDelegate) => false; +} + +// Virtual joystick can send either absolute movement (via updatePan) +// or relative movement (via sendMobileRelativeMouseMove) depending on the +// InputModel.relativeMouseMode setting. +class VirtualJoystick extends StatefulWidget { + final CursorModel cursorModel; + final InputModel inputModel; + + const VirtualJoystick({ + super.key, + required this.cursorModel, + required this.inputModel, + }); + + @override + State createState() => _VirtualJoystickState(); +} + +class _VirtualJoystickState extends State { + Offset _position = Offset.zero; + bool _isInitialized = false; + Offset _offset = Offset.zero; + final double _joystickRadius = 50.0; + final double _thumbRadius = 20.0; + final double _moveStep = 3.0; + final double _speed = 1.0; + + /// Scale factor for relative mouse movement sensitivity. + /// Higher values result in faster cursor movement on the remote machine. + static const double _kRelativeMouseScale = 3.0; + + // One-shot timer to detect a drag gesture + Timer? _dragStartTimer; + // Periodic timer for continuous movement + Timer? _continuousMoveTimer; + Size? _lastScreenSize; + bool _isPressed = false; + + /// Check if relative mouse mode is enabled. + bool get _useRelativeMouse => widget.inputModel.relativeMouseMode.value; + + @override + void initState() { + super.initState(); + widget.cursorModel.blockEvents = false; + WidgetsBinding.instance.addPostFrameCallback((_) { + _lastScreenSize = MediaQuery.of(context).size; + _resetPosition(); + }); + } + + @override + void dispose() { + _stopSendEventTimer(); + widget.cursorModel.blockEvents = false; + super.dispose(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final currentScreenSize = MediaQuery.of(context).size; + if (_lastScreenSize != null && _lastScreenSize != currentScreenSize) { + _resetPosition(); + } + _lastScreenSize = currentScreenSize; + } + + void _resetPosition() { + final size = MediaQuery.of(context).size; + setState(() { + _position = Offset( + _kSpaceToHorizontalEdge + _joystickRadius, + size.height * 0.5 + _joystickRadius * 1.5, + ); + _isInitialized = true; + }); + } + + Offset _offsetToPanDelta(Offset offset) { + return Offset( + offset.dx / _joystickRadius, + offset.dy / _joystickRadius, + ); + } + + /// Send movement delta to remote machine. + /// Uses relative mouse mode if enabled, otherwise uses absolute updatePan. + void _sendMovement(Offset delta) { + if (_useRelativeMouse) { + widget.inputModel.sendMobileRelativeMouseMove( + delta.dx * _kRelativeMouseScale, delta.dy * _kRelativeMouseScale); + } else { + // In absolute mode, use cursorModel.updatePan which tracks position. + widget.cursorModel.updatePan(delta, Offset.zero, false); + } + } + + void _stopSendEventTimer() { + _dragStartTimer?.cancel(); + _continuousMoveTimer?.cancel(); + _dragStartTimer = null; + _continuousMoveTimer = null; + } + + @override + Widget build(BuildContext context) { + if (!_isInitialized) { + return Positioned(child: Offstage()); + } + return Positioned( + left: _position.dx - _joystickRadius, + top: _position.dy - _joystickRadius, + child: GestureDetector( + onPanStart: (details) { + setState(() { + _isPressed = true; + }); + widget.cursorModel.blockEvents = true; + _updateOffset(details.localPosition); + + // 1. Send a single, small pan event immediately for responsiveness. + // The movement is small for a gentle start. + final initialDelta = _offsetToPanDelta(_offset); + if (initialDelta.distance > 0) { + _sendMovement(initialDelta); + } + + // 2. Start a one-shot timer to check if the user is holding for a drag. + _dragStartTimer?.cancel(); + _dragStartTimer = Timer(const Duration(milliseconds: 120), () { + // 3. If the timer fires, it's a drag. Start the continuous movement timer. + _continuousMoveTimer?.cancel(); + _continuousMoveTimer = + periodic_immediate(const Duration(milliseconds: 20), () async { + if (_offset != Offset.zero) { + _sendMovement(_offsetToPanDelta(_offset) * _moveStep * _speed); + } + }); + }); + }, + onPanUpdate: (details) { + _updateOffset(details.localPosition); + }, + onPanEnd: (details) { + setState(() { + _offset = Offset.zero; + _isPressed = false; + }); + widget.cursorModel.blockEvents = false; + + // 4. Critical step: On pan end, cancel all timers. + // If it was a flick, this cancels the drag detection before it fires. + // If it was a drag, this stops the continuous movement. + _stopSendEventTimer(); + }, + child: CustomPaint( + size: Size(_joystickRadius * 2, _joystickRadius * 2), + painter: _JoystickPainter( + _offset, _joystickRadius, _thumbRadius, _isPressed), + ), + ), + ); + } + + void _updateOffset(Offset localPosition) { + final center = Offset(_joystickRadius, _joystickRadius); + final offset = localPosition - center; + final distance = offset.distance; + + if (distance <= _joystickRadius) { + setState(() { + _offset = offset; + }); + } else { + final clampedOffset = offset / distance * _joystickRadius; + setState(() { + _offset = clampedOffset; + }); + } + } +} + +class _JoystickPainter extends CustomPainter { + final Offset _offset; + final double _joystickRadius; + final double _thumbRadius; + final bool _isPressed; + + _JoystickPainter( + this._offset, this._joystickRadius, this._thumbRadius, this._isPressed); + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final joystickColor = _kDefaultColor; + final borderColor = _isPressed ? _kTapDownColor : _kDefaultBorderColor; + final thumbColor = _kWidgetHighlightColor; + + final joystickPaint = Paint() + ..color = joystickColor + ..style = PaintingStyle.fill; + + final borderPaint = Paint() + ..color = borderColor + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5; + + final thumbPaint = Paint() + ..color = thumbColor + ..style = PaintingStyle.fill; + + // Draw joystick base and border + canvas.drawCircle(center, _joystickRadius, joystickPaint); + canvas.drawCircle(center, _joystickRadius, borderPaint); + + // Draw thumb + final thumbCenter = center + _offset; + canvas.drawCircle(thumbCenter, _thumbRadius, thumbPaint); + } + + @override + bool shouldRepaint(covariant _JoystickPainter oldDelegate) { + return oldDelegate._offset != _offset || + oldDelegate._isPressed != _isPressed; + } +} diff --git a/flutter/lib/mobile/widgets/gesture_help.dart b/flutter/lib/mobile/widgets/gesture_help.dart index 5ba696489..8e86681b4 100644 --- a/flutter/lib/mobile/widgets/gesture_help.dart +++ b/flutter/lib/mobile/widgets/gesture_help.dart @@ -1,5 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_hbb/common.dart'; +import 'package:flutter_hbb/models/input_model.dart'; +import 'package:flutter_hbb/models/model.dart'; +import 'package:get/get.dart'; import 'package:toggle_switch/toggle_switch.dart'; class GestureIcons { @@ -35,24 +38,41 @@ typedef OnTouchModeChange = void Function(bool); class GestureHelp extends StatefulWidget { GestureHelp( - {Key? key, required this.touchMode, required this.onTouchModeChange}) + {Key? key, + required this.touchMode, + required this.onTouchModeChange, + required this.virtualMouseMode, + this.inputModel}) : super(key: key); final bool touchMode; final OnTouchModeChange onTouchModeChange; + final VirtualMouseMode virtualMouseMode; + final InputModel? inputModel; @override - State createState() => _GestureHelpState(touchMode); + State createState() => + _GestureHelpState(touchMode, virtualMouseMode); } class _GestureHelpState extends State { late int _selectedIndex; late bool _touchMode; + final VirtualMouseMode _virtualMouseMode; - _GestureHelpState(bool touchMode) { + _GestureHelpState(bool touchMode, VirtualMouseMode virtualMouseMode) + : _virtualMouseMode = virtualMouseMode { _touchMode = touchMode; _selectedIndex = _touchMode ? 1 : 0; } + /// Helper to exit relative mouse mode when certain conditions are met. + /// This reduces code duplication across multiple UI callbacks. + void _exitRelativeMouseModeIf(bool condition) { + if (condition) { + widget.inputModel?.setRelativeMouseMode(false); + } + } + @override Widget build(BuildContext context) { final size = MediaQuery.of(context).size; @@ -68,31 +88,193 @@ class _GestureHelpState extends State { padding: const EdgeInsets.symmetric(vertical: 12.0), child: Column( mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - ToggleSwitch( - initialLabelIndex: _selectedIndex, - activeFgColor: Colors.white, - inactiveFgColor: Colors.white60, - activeBgColor: [MyTheme.accent], - inactiveBgColor: Theme.of(context).hintColor, - totalSwitches: 2, - minWidth: 150, - fontSize: 15, - iconSize: 18, - labels: [translate("Mouse mode"), translate("Touch mode")], - icons: [Icons.mouse, Icons.touch_app], - onToggle: (index) { - setState(() { - if (_selectedIndex != index) { - _selectedIndex = index ?? 0; - _touchMode = index == 0 ? false : true; - widget.onTouchModeChange(_touchMode); - } - }); - }, + Center( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ToggleSwitch( + initialLabelIndex: _selectedIndex, + activeFgColor: Colors.white, + inactiveFgColor: Colors.white60, + activeBgColor: [MyTheme.accent], + inactiveBgColor: Theme.of(context).hintColor, + totalSwitches: 2, + minWidth: 150, + fontSize: 15, + iconSize: 18, + labels: [ + translate("Mouse mode"), + translate("Touch mode") + ], + icons: [Icons.mouse, Icons.touch_app], + onToggle: (index) { + setState(() { + if (_selectedIndex != index) { + _selectedIndex = index ?? 0; + _touchMode = index == 0 ? false : true; + widget.onTouchModeChange(_touchMode); + // Exit relative mouse mode when switching to touch mode + _exitRelativeMouseModeIf(_touchMode); + } + }); + }, + ), + Transform.translate( + offset: const Offset(-10.0, 0.0), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Checkbox( + value: _virtualMouseMode.showVirtualMouse, + onChanged: (value) async { + if (value == null) return; + await _virtualMouseMode.toggleVirtualMouse(); + // Exit relative mouse mode when virtual mouse is hidden + _exitRelativeMouseModeIf( + !_virtualMouseMode.showVirtualMouse); + setState(() {}); + }, + ), + InkWell( + onTap: () async { + await _virtualMouseMode.toggleVirtualMouse(); + // Exit relative mouse mode when virtual mouse is hidden + _exitRelativeMouseModeIf( + !_virtualMouseMode.showVirtualMouse); + setState(() {}); + }, + child: Text(translate('Show virtual mouse')), + ), + ], + ), + ), + if (_touchMode && _virtualMouseMode.showVirtualMouse) + Padding( + // Indent "Virtual mouse size" + padding: const EdgeInsets.only(left: 24.0), + child: SizedBox( + width: 260, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.only( + top: 0.0, bottom: 0), + child: Text(translate('Virtual mouse size')), + ), + Transform.translate( + offset: Offset(-0.0, -6.0), + child: Row( + children: [ + Padding( + padding: + const EdgeInsets.only(left: 0.0), + child: Text(translate('Small')), + ), + Expanded( + child: Slider( + value: _virtualMouseMode + .virtualMouseScale, + min: 0.8, + max: 1.8, + divisions: 10, + onChanged: (value) { + _virtualMouseMode + .setVirtualMouseScale(value); + setState(() {}); + }, + ), + ), + Padding( + padding: + const EdgeInsets.only(right: 16.0), + child: Text(translate('Large')), + ), + ], + ), + ), + ], + ), + ), + ), + if (!_touchMode && _virtualMouseMode.showVirtualMouse) + Transform.translate( + offset: const Offset(-10.0, -12.0), + child: Padding( + // Indent "Show virtual joystick" + padding: const EdgeInsets.only(left: 24.0), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Checkbox( + value: + _virtualMouseMode.showVirtualJoystick, + onChanged: (value) async { + if (value == null) return; + await _virtualMouseMode + .toggleVirtualJoystick(); + // Exit relative mouse mode when joystick is hidden + _exitRelativeMouseModeIf( + !_virtualMouseMode + .showVirtualJoystick); + setState(() {}); + }, + ), + InkWell( + onTap: () async { + await _virtualMouseMode + .toggleVirtualJoystick(); + // Exit relative mouse mode when joystick is hidden + _exitRelativeMouseModeIf( + !_virtualMouseMode + .showVirtualJoystick); + setState(() {}); + }, + child: Text( + translate("Show virtual joystick")), + ), + ], + )), + ), + // Relative mouse mode option - only visible when joystick is shown + if (!_touchMode && + _virtualMouseMode.showVirtualMouse && + _virtualMouseMode.showVirtualJoystick && + widget.inputModel != null) + Obx(() => Transform.translate( + offset: const Offset(-10.0, -24.0), + child: Padding( + // Indent further for 'Relative mouse mode' + padding: const EdgeInsets.only(left: 48.0), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Checkbox( + value: widget.inputModel! + .relativeMouseMode.value, + onChanged: (value) { + if (value == null) return; + widget.inputModel! + .setRelativeMouseMode(value); + }, + ), + InkWell( + onTap: () { + widget.inputModel! + .toggleRelativeMouseMode(); + }, + child: Text( + translate('Relative mouse mode')), + ), + ], + )), + )), + ], + ), ), - const SizedBox(height: 30), Container( child: Wrap( spacing: space, diff --git a/flutter/lib/models/ab_model.dart b/flutter/lib/models/ab_model.dart index 0da84e0f2..001887c0c 100644 --- a/flutter/lib/models/ab_model.dart +++ b/flutter/lib/models/ab_model.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_hbb/common/hbbs/hbbs.dart'; @@ -33,6 +32,8 @@ bool filterAbTagByIntersection() { const _personalAddressBookName = "My address book"; const _legacyAddressBookName = "Legacy address book"; +const kUntagged = "Untagged"; + enum ForcePullAb { listAndCurrent, current, @@ -51,11 +52,16 @@ class AbModel { RxBool get currentAbLoading => current.abLoading; bool get currentAbEmpty => current.peers.isEmpty && current.tags.isEmpty; - RxString get currentAbPullError => current.pullError; + final _listPullError = ''.obs; + RxString get abPullError => + _listPullError.value.isNotEmpty ? _listPullError : current.pullError; RxString get currentAbPushError => current.pushError; String? _personalAbGuid; RxBool legacyMode = false.obs; + // Only handles peers add/remove + final Map _peerIdUpdateListeners = {}; + final sortTags = shouldSortTags().obs; final filterByIntersection = filterAbTagByIntersection().obs; @@ -63,6 +69,7 @@ class AbModel { var _syncFromRecentLock = false; var _timerCounter = 0; var _cacheLoadOnceFlag = false; + var _pulledOnce = false; var listInitialized = false; var _maxPeerOneAb = 0; @@ -92,10 +99,17 @@ class AbModel { print("reset ab model"); addressbooks.clear(); _currentName.value = ''; + _listPullError.value = ''; + _pulledOnce = false; await bind.mainClearAb(); listInitialized = false; } + void clearPullErrors() { + _listPullError.value = ''; + current.pullError.value = ''; + } + // #region ab /// Pulls the address book data from the server. /// @@ -105,39 +119,49 @@ class AbModel { var _pulling = false; Future pullAb( {required ForcePullAb? force, required bool quiet}) async { + if (bind.isDisableAb()) return; + if (!gFFI.userModel.isLogin) return; + if (gFFI.userModel.networkError.isNotEmpty) return; if (_pulling) return; + if (force == null && _pulledOnce) { + return; + } _pulling = true; + if (!quiet) { + _listPullError.value = ''; + current.pullError.value = ''; + } try { await _pullAb(force: force, quiet: quiet); _refreshTab(); } catch (_) {} _pulling = false; + _pulledOnce = true; } Future _pullAb( {required ForcePullAb? force, required bool quiet}) async { - if (bind.isDisableAb()) return; - if (!gFFI.userModel.isLogin) return; - if (gFFI.userModel.networkError.isNotEmpty) return; if (force == null && listInitialized && current.initialized) return; debugPrint("pullAb, force: $force, quiet: $quiet"); if (!listInitialized || force == ForcePullAb.listAndCurrent) { try { // Read personal guid every time to avoid upgrading the server without closing the main window _personalAbGuid = null; - await _getPersonalAbGuid(); - // Determine legacy mode based on whether _personalAbGuid is null + // `true`: continue init. `false`: stop, error already recorded. + if (!await _getPersonalAbGuid(quiet: quiet)) { + return; + } legacyMode.value = _personalAbGuid == null; if (!legacyMode.value && _maxPeerOneAb == 0) { - await _getAbSettings(); + await _getAbSettings(quiet: quiet); } if (_personalAbGuid != null) { debugPrint("pull ab list"); List abProfiles = List.empty(growable: true); abProfiles.add(AbProfile(_personalAbGuid!, _personalAddressBookName, - gFFI.userModel.userName.value, null, ShareRule.read.value)); + gFFI.userModel.userName.value, null, ShareRule.read.value, null)); // get all address book name - await _getSharedAbProfiles(abProfiles); + await _getSharedAbProfiles(abProfiles, quiet: quiet); addressbooks.removeWhere((key, value) => abProfiles.firstWhereOrNull((e) => e.name == key) == null); for (int i = 0; i < abProfiles.length; i++) { @@ -177,6 +201,7 @@ class AbModel { } } catch (e) { debugPrint("pull ab list error: $e"); + _setListPullError(e, quiet: quiet); } } else if (listInitialized && (!current.initialized || force == ForcePullAb.current)) { @@ -186,65 +211,91 @@ class AbModel { debugPrint("pull current Ab error: $e"); } } + _callbackPeerUpdate(); if (listInitialized && current.initialized) { _saveCache(); } } - Future _getAbSettings() async { + void _setListPullError(Object err, {required bool quiet, int? statusCode}) { + if (!quiet) { + _listPullError.value = + '${translate('pull_ab_failed_tip')}: ${translate(err.toString())}'; + } + if (statusCode == 401) { + gFFI.userModel.reset(resetOther: true); + } + } + + Future _getAbSettings({required bool quiet}) async { + int? statusCode; try { final api = "${await bind.mainGetApiServer()}/api/ab/settings"; var headers = getHttpHeaders(); headers['Content-Type'] = "application/json"; + _setEmptyBody(headers); final resp = await http.post(Uri.parse(api), headers: headers); - if (resp.statusCode == 404) { + statusCode = resp.statusCode; + if (statusCode == 404) { debugPrint("HTTP 404, api server doesn't support shared address book"); return false; } Map json = - _jsonDecodeRespMap(utf8.decode(resp.bodyBytes), resp.statusCode); + _jsonDecodeRespMap(decode_http_response(resp), resp.statusCode); if (json.containsKey('error')) { throw json['error']; } - if (resp.statusCode != 200) { - throw 'HTTP ${resp.statusCode}'; + if (statusCode != 200) { + throw 'HTTP $statusCode'; } _maxPeerOneAb = json['max_peer_one_ab'] ?? 0; return true; } catch (err) { debugPrint('get ab settings err: ${err.toString()}'); + _setListPullError(err, quiet: quiet, statusCode: statusCode); } return false; } - Future _getPersonalAbGuid() async { + /// Loads `/api/ab/personal`. + /// Returns `true` to continue init, `false` to stop after a real error. + Future _getPersonalAbGuid({required bool quiet}) async { + int? statusCode; try { final api = "${await bind.mainGetApiServer()}/api/ab/personal"; var headers = getHttpHeaders(); headers['Content-Type'] = "application/json"; + _setEmptyBody(headers); final resp = await http.post(Uri.parse(api), headers: headers); - if (resp.statusCode == 404) { + statusCode = resp.statusCode; + if (statusCode == 404) { debugPrint("HTTP 404, current api server is legacy mode"); - return false; + // Old server: keep `_personalAbGuid` null and continue in legacy mode. + return true; } Map json = - _jsonDecodeRespMap(utf8.decode(resp.bodyBytes), resp.statusCode); + _jsonDecodeRespMap(decode_http_response(resp), resp.statusCode); if (json.containsKey('error')) { throw json['error']; } - if (resp.statusCode != 200) { - throw 'HTTP ${resp.statusCode}'; + if (statusCode != 200) { + throw 'HTTP $statusCode'; } _personalAbGuid = json['guid']; + // New server: guid is available, continue in non-legacy mode. return true; } catch (err) { debugPrint('get personal ab err: ${err.toString()}'); + _setListPullError(err, quiet: quiet, statusCode: statusCode); } + // Real error: stop the current pull. return false; } - Future _getSharedAbProfiles(List profiles) async { + Future _getSharedAbProfiles(List profiles, + {required bool quiet}) async { final api = "${await bind.mainGetApiServer()}/api/ab/shared/profiles"; + int? statusCode; try { var uri0 = Uri.parse(api); final pageSize = 100; @@ -263,14 +314,21 @@ class AbModel { }); var headers = getHttpHeaders(); headers['Content-Type'] = "application/json"; + _setEmptyBody(headers); final resp = await http.post(uri, headers: headers); + statusCode = resp.statusCode; + if (statusCode == 404) { + debugPrint( + "HTTP 404, api server doesn't support shared address book"); + return false; + } Map json = - _jsonDecodeRespMap(utf8.decode(resp.bodyBytes), resp.statusCode); + _jsonDecodeRespMap(decode_http_response(resp), resp.statusCode); if (json.containsKey('error')) { throw json['error']; } - if (resp.statusCode != 200) { - throw 'HTTP ${resp.statusCode}'; + if (statusCode != 200) { + throw 'HTTP $statusCode'; } if (json.containsKey('total')) { if (total == 0) total = json['total']; @@ -293,6 +351,7 @@ class AbModel { return true; } catch (err) { debugPrint('_getSharedAbProfiles err: ${err.toString()}'); + _setListPullError(err, quiet: quiet, statusCode: statusCode); } return false; } @@ -313,8 +372,8 @@ class AbModel { // #endregion // #region peer - Future addIdToCurrent( - String id, String alias, String password, List tags) async { + Future addIdToCurrent(String id, String alias, String password, + List tags, String note) async { if (currentAbPeers.where((element) => element.id == id).isNotEmpty) { return "$id already exists in address book $_currentName"; } @@ -327,6 +386,9 @@ class AbModel { if (password.isNotEmpty) { peer['password'] = password; } + if (note.isNotEmpty) { + peer['note'] = note; + } final ret = await addPeersTo([peer], _currentName.value); _syncAllFromRecent = true; return ret; @@ -341,6 +403,9 @@ class AbModel { if (ab == null) { return 'no such addressbook: $name'; } + for (var p in ps) { + ab.removeNonExistentTags(p); + } String? errMsg = await ab.addPeers(ps); await pullNonLegacyAfterChange(name: name); if (name == _currentName.value) { @@ -367,6 +432,14 @@ class AbModel { return res; } + Future changeNote({required String id, required String note}) async { + bool res = await current.changeNote(id: id, note: note); + await pullNonLegacyAfterChange(); + currentAbPeers.refresh(); + // no need to save cache + return res; + } + Future changePersonalHashPassword(String id, String hash) async { var ret = false; final personalAb = addressbooks[_personalAddressBookName]; @@ -417,6 +490,7 @@ class AbModel { } }); } + _callbackPeerUpdate(); return ret; } @@ -424,6 +498,7 @@ class AbModel { // #region tags Future addTags(List tagList) async { + tagList.removeWhere((e) => e == kUntagged); final ret = await current.addTags(tagList, {}); await pullNonLegacyAfterChange(); _saveCache(); @@ -598,7 +673,7 @@ class AbModel { if (name == null || guid == null) { continue; } - ab = Ab(AbProfile(guid, name, '', '', ShareRule.read.value), + ab = Ab(AbProfile(guid, name, '', '', ShareRule.read.value, null), name == _personalAddressBookName); } addressbooks[name] = ab; @@ -617,6 +692,9 @@ class AbModel { } } } + if (abEntries.isNotEmpty) { + _callbackPeerUpdate(); + } } } @@ -644,7 +722,19 @@ class AbModel { } } + String getPeerNote(String id) { + final it = currentAbPeers.where((p0) => p0.id == id); + if (it.isEmpty) { + return ''; + } else { + return it.first.note; + } + } + Color getCurrentAbTagColor(String tag) { + if (tag == kUntagged) { + return MyTheme.accent; + } int? colorValue = current.tagColors[tag]; if (colorValue != null) { return Color(colorValue); @@ -736,6 +826,42 @@ class AbModel { } } + void _callbackPeerUpdate() { + for (var listener in _peerIdUpdateListeners.values) { + listener(); + } + } + + void addPeerUpdateListener(String key, VoidCallback listener) { + _peerIdUpdateListeners[key] = listener; + } + + void removePeerUpdateListener(String key) { + _peerIdUpdateListeners.remove(key); + } + + String? getdefaultSharedPassword() { + if (current.isPersonal()) { + return null; + } + final profile = current.sharedProfile(); + if (profile == null) { + return null; + } + try { + if (profile.info is Map) { + final password = (profile.info as Map)['password']; + if (password is String && password.isNotEmpty) { + return password; + } + } + return null; + } catch (e) { + debugPrint("getdefaultSharedPassword: $e"); + return null; + } + } + // #endregion } @@ -747,7 +873,10 @@ abstract class BaseAb { final pullError = "".obs; final pushError = "".obs; - final abLoading = false.obs; + final abLoading = false + .obs; // Indicates whether the UI should show a loading state for the address book. + var abPulling = + false; // Tracks whether a pull operation is currently in progress to prevent concurrent pulls. Unlike abLoading, this is not tied to UI updates. bool initialized = false; String name(); @@ -762,17 +891,22 @@ abstract class BaseAb { } Future pullAb({quiet = false}) async { - debugPrint("pull ab \"${name()}\""); - if (abLoading.value) return; + if (abPulling) return; + abPulling = true; if (!quiet) { abLoading.value = true; pullError.value = ""; } initialized = false; + debugPrint("pull ab \"${name()}\""); try { initialized = await pullAbImpl(quiet: quiet); - } catch (_) {} - abLoading.value = false; + } catch (e) { + debugPrint("Error occurred while pulling address book: $e"); + } finally { + abLoading.value = false; + abPulling = false; + } } Future pullAbImpl({quiet = false}); @@ -786,10 +920,24 @@ abstract class BaseAb { p.remove('password'); } + removeNonExistentTags(Map p) { + try { + final oldTags = p.remove('tags'); + if (oldTags is List) { + final newTags = oldTags.where((e) => tagContainBy(e)).toList(); + p['tags'] = newTags; + } + } catch (e) { + print("removeNonExistentTags: $e"); + } + } + Future changeTagForPeers(List ids, List tags); Future changeAlias({required String id, required String alias}); + Future changeNote({required String id, required String note}); + Future changePersonalHashPassword(String id, String hash); Future changeSharedPassword(String id, String password); @@ -874,7 +1022,7 @@ class LegacyAb extends BaseAb { peers.clear(); } else if (resp.body.isNotEmpty) { Map json = - _jsonDecodeRespMap(utf8.decode(resp.bodyBytes), resp.statusCode); + _jsonDecodeRespMap(decode_http_response(resp), resp.statusCode); if (json.containsKey('error')) { throw json['error']; } else if (json.containsKey('data')) { @@ -917,22 +1065,14 @@ class LegacyAb extends BaseAb { var authHeaders = getHttpHeaders(); authHeaders['Content-Type'] = "application/json"; final body = jsonEncode({"data": jsonEncode(_serialize())}); - http.Response resp; - // support compression - if (licensedDevices > 0 && body.length > 1024) { - authHeaders['Content-Encoding'] = "gzip"; - resp = await http.post(Uri.parse(api), - headers: authHeaders, body: GZipCodec().encode(utf8.encode(body))); - } else { - resp = - await http.post(Uri.parse(api), headers: authHeaders, body: body); - } + http.Response resp = + await http.post(Uri.parse(api), headers: authHeaders, body: body); if (resp.statusCode == 200 && (resp.body.isEmpty || resp.body.toLowerCase() == 'null')) { ret = true; } else { Map json = - _jsonDecodeRespMap(utf8.decode(resp.bodyBytes), resp.statusCode); + _jsonDecodeRespMap(decode_http_response(resp), resp.statusCode); if (json.containsKey('error')) { throw json['error']; } else if (resp.statusCode == 200) { @@ -1017,6 +1157,12 @@ class LegacyAb extends BaseAb { return await pushAb(); } + @override + Future changeNote({required String id, required String note}) async { + // no need to implement + return false; + } + @override Future changeSharedPassword(String id, String password) async { // no need to implement @@ -1305,10 +1451,11 @@ class Ab extends BaseAb { }); var headers = getHttpHeaders(); headers['Content-Type'] = "application/json"; + _setEmptyBody(headers); final resp = await http.post(uri, headers: headers); statusCode = resp.statusCode; Map json = - _jsonDecodeRespMap(utf8.decode(resp.bodyBytes), resp.statusCode); + _jsonDecodeRespMap(decode_http_response(resp), resp.statusCode); if (json.containsKey('error')) { throw json['error']; } @@ -1362,10 +1509,11 @@ class Ab extends BaseAb { ); var headers = getHttpHeaders(); headers['Content-Type'] = "application/json"; + _setEmptyBody(headers); final resp = await http.post(uri, headers: headers); statusCode = resp.statusCode; List json = - _jsonDecodeRespList(utf8.decode(resp.bodyBytes), resp.statusCode); + _jsonDecodeRespList(decode_http_response(resp), resp.statusCode); if (resp.statusCode != 200) { throw 'HTTP ${resp.statusCode}'; } @@ -1476,6 +1624,27 @@ class Ab extends BaseAb { } } + @override + Future changeNote({required String id, required String note}) async { + try { + final api = + "${await bind.mainGetApiServer()}/api/ab/peer/update/${profile.guid}"; + var headers = getHttpHeaders(); + headers['Content-Type'] = "application/json"; + final body = jsonEncode({"id": id, "note": note}); + final resp = await http.put(Uri.parse(api), headers: headers, body: body); + final errMsg = _jsonDecodeActionResp(resp); + if (errMsg.isNotEmpty) { + BotToast.showText(contentColor: Colors.red, text: errMsg); + return false; + } + return true; + } catch (err) { + debugPrint('changeNote err: ${err.toString()}'); + return false; + } + } + Future _setPassword(Object bodyContent) async { try { final api = @@ -1742,6 +1911,11 @@ class DummyAb extends BaseAb { return false; } + @override + Future changeNote({required String id, required String note}) async { + return false; + } + @override Future changePersonalHashPassword(String id, String hash) async { return false; @@ -1850,3 +2024,8 @@ String _jsonDecodeActionResp(http.Response resp) { } return errMsg; } + +// https://github.com/seanmonstar/reqwest/issues/838 +void _setEmptyBody(Map headers) { + headers['Content-Length'] = '0'; +} diff --git a/flutter/lib/models/cm_file_model.dart b/flutter/lib/models/cm_file_model.dart index 6609f1191..46935c188 100644 --- a/flutter/lib/models/cm_file_model.dart +++ b/flutter/lib/models/cm_file_model.dart @@ -275,7 +275,7 @@ class TransferJobSerdeData { : this( connId: d['connId'] ?? 0, id: int.tryParse(d['id'].toString()) ?? 0, - path: d['path'] ?? '', + path: d['dataSource'] ?? '', isRemote: d['isRemote'] ?? false, totalSize: d['totalSize'] ?? 0, finishedSize: d['finishedSize'] ?? 0, diff --git a/flutter/lib/models/desktop_render_texture.dart b/flutter/lib/models/desktop_render_texture.dart index c6cf55256..a96049134 100644 --- a/flutter/lib/models/desktop_render_texture.dart +++ b/flutter/lib/models/desktop_render_texture.dart @@ -235,6 +235,17 @@ class TextureModel { } } + onViewCameraPageDispose(bool closeSession) async { + final ffi = parent.target; + if (ffi == null) return; + for (final texture in _pixelbufferRenderTextures.values) { + await texture.destroy(closeSession, ffi); + } + for (final texture in _gpuRenderTextures.values) { + await texture.destroy(closeSession, ffi); + } + } + ensureControl(int display) { var ctl = _control[display]; if (ctl == null) { diff --git a/flutter/lib/models/file_model.dart b/flutter/lib/models/file_model.dart index 05c79ae86..7d91b03b3 100644 --- a/flutter/lib/models/file_model.dart +++ b/flutter/lib/models/file_model.dart @@ -30,6 +30,15 @@ enum SortBy { class JobID { int _count = 0; int next() { + try { + if (!isWeb) { + String v = bind.mainGetCommonSync(key: 'transfer-job-id'); + return int.parse(v); + } + } catch (e) { + debugPrint("Failed to get transfer job id: $e"); + } + // Finally increase the count if on the web or if failed to get the id. _count++; return _count; } @@ -100,6 +109,38 @@ class FileModel { fileFetcher.tryCompleteTask(evt['value'], evt['is_local']); } + void receiveEmptyDirs(Map evt) { + fileFetcher.tryCompleteEmptyDirsTask(evt['value'], evt['is_local']); + } + + // This method fixes a deadlock that occurred when the previous code directly + // called jobController.jobError(evt) in the job_error event handler. + // + // The problem with directly calling jobController.jobError(): + // 1. fetchDirectoryRecursiveToRemove(jobID) registers readRecursiveTasks[jobID] + // and waits for completion + // 2. If the remote has no permission (or some other errors), it returns a FileTransferError + // 3. The error triggers job_error event, which called jobController.jobError() + // 4. jobController.jobError() calls getJob(jobID) to find the job in jobTable + // 5. But addDeleteDirJob() is called AFTER fetchDirectoryRecursiveToRemove(), + // so the job doesn't exist yet in jobTable + // 6. Result: jobController.jobError() does nothing useful, and + // readRecursiveTasks[jobID] never completes, causing a 2s timeout + // + // Solution: Before calling jobController.jobError(), we first check if there's + // a pending readRecursiveTasks with this ID and complete it with the error. + void handleJobError(Map evt) { + final id = int.tryParse(evt['id']?.toString() ?? ''); + if (id != null) { + final err = evt['err']?.toString() ?? 'Unknown error'; + 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 + // or logging of unexpected errors. + jobController.jobError(evt); + } + Future postOverrideFileConfirm(Map evt) async { evtLoop.pushEvent( _FileDialogEvent(WeakReference(this), FileDialogType.overwrite, evt)); @@ -257,6 +298,27 @@ class FileModel { debugPrint("Failed to decode onSelectedFiles: $e"); } } + + void sendEmptyDirs(dynamic obj) { + late final List emptyDirs; + try { + emptyDirs = jsonDecode(obj['dirs'] as String); + } catch (e) { + debugPrint("Failed to decode sendEmptyDirs: $e"); + } + final otherSideData = remoteController.directoryData(); + final toPath = otherSideData.directory.path; + final isPeerWindows = otherSideData.options.isWindows; + + final isLocalWindows = isWindows || isWebOnWindows; + for (var dir in emptyDirs) { + if (isLocalWindows != isPeerWindows) { + dir = PathUtil.convert(dir, isLocalWindows, isPeerWindows); + } + var peerPath = PathUtil.join(toPath, dir, isPeerWindows); + remoteController.createDirWithRemote(peerPath, true); + } + } } class DirectoryData { @@ -329,14 +391,30 @@ class FileController { await Future.delayed(Duration(milliseconds: 100)); - final dir = (await bind.sessionGetPeerOption( + final savedDir = (await bind.sessionGetPeerOption( sessionId: sessionId, name: isLocal ? "local_dir" : "remote_dir")); - openDirectory(dir.isEmpty ? options.value.home : dir); + Future tryOpenReadyDirs() async { + final dirs = { + if (directory.value.path.isNotEmpty) directory.value.path, + if (savedDir.isNotEmpty) savedDir, + options.value.home, + }; + for (final dir in dirs) { + if (await _openDirectoryPath(dir, isBack: true)) { + return true; + } + } + return false; + } + + var opened = await tryOpenReadyDirs(); await Future.delayed(Duration(seconds: 1)); - if (directory.value.path.isEmpty) { - openDirectory(options.value.home); + if (!opened) { + // The peer may become ready during the reconnect delay, so retry the + // same candidates instead of only retrying the default home directory. + await tryOpenReadyDirs(); } } @@ -367,19 +445,23 @@ class FileController { }); } - Future refresh() async { - await openDirectory(directory.value.path); + Future refresh() async { + // "." can be both a refresh command and a real remote directory path. + // Refresh must bypass openDirectory's command dispatch to avoid recursion. + return await _openDirectoryPath(directory.value.path, isBack: true); } - Future openDirectory(String path, {bool isBack = false}) async { - if (path == ".") { - refresh(); - return; + Future openDirectory(String path, {bool isBack = false}) async { + if (!isBack && path == ".") { + return await refresh(); } - if (path == "..") { - goToParentDirectory(); - return; + if (!isBack && path == "..") { + return await _goToParentDirectory(isBack: isBack); } + return await _openDirectoryPath(path, isBack: isBack); + } + + Future _openDirectoryPath(String path, {bool isBack = false}) async { if (!isBack) { pushHistory(); } @@ -396,8 +478,10 @@ class FileController { final fd = await fileFetcher.fetchDirectory(path, isLocal, showHidden); fd.format(isWindows, sort: sortBy.value); directory.value = fd; + return true; } catch (e) { debugPrint("Failed to openDirectory $path: $e"); + return false; } } @@ -425,19 +509,22 @@ class FileController { goBack(); return; } - openDirectory(path, isBack: true); + unawaited(_openDirectoryPath(path, isBack: true).then((_) {})); } void goToParentDirectory() { + unawaited(_goToParentDirectory().then((_) {})); + } + + Future _goToParentDirectory({bool isBack = false}) async { final isWindows = options.value.isWindows; final dirPath = directory.value.path; var parent = PathUtil.dirname(dirPath, isWindows); // specially for C:\, D:\, goto '/' if (parent == dirPath && isWindows) { - openDirectory('/'); - return; + return await _openDirectoryPath('/', isBack: isBack); } - openDirectory(parent); + return await _openDirectoryPath(parent, isBack: isBack); } // TODO deprecated this @@ -470,7 +557,8 @@ class FileController { } /// sendFiles from current side (FileController.isLocal) to other side (SelectedItems). - void sendFiles(SelectedItems items, DirectoryData otherSideData) { + Future sendFiles( + SelectedItems items, DirectoryData otherSideData) async { /// ignore wrong items side status if (items.isLocal != isLocal) { return; @@ -496,6 +584,43 @@ class FileController { debugPrint( "path: ${from.path}, toPath: $toPath, to: ${PathUtil.join(toPath, from.name, isWindows)}"); } + + if (isWeb || + (!isLocal && + versionCmp(rootState.target!.ffiModel.pi.version, '1.3.3') < 0)) { + return; + } + + final List entrys = items.items.toList(); + var isRemote = isLocal == true ? true : false; + + await Future.forEach(entrys, (Entry item) async { + if (!item.isDirectory) { + return; + } + + final List paths = []; + + final emptyDirs = + await fileFetcher.readEmptyDirs(item.path, isLocal, showHidden); + + if (emptyDirs.isEmpty) { + return; + } else { + for (var dir in emptyDirs) { + paths.add(dir.path); + } + } + + final dirs = paths.map((path) { + return PathUtil.getOtherSidePath(directory.value.path, path, + options.value.isWindows, toPath, isWindows); + }); + + for (var dir in dirs) { + createDirWithRemote(dir, isRemote); + } + }); } bool _removeCheckboxRemember = false; @@ -519,8 +644,21 @@ class FileController { } else if (item.isDirectory) { title = translate("Not an empty directory"); dialogManager?.showLoading(translate("Waiting")); - final fd = await fileFetcher.fetchDirectoryRecursiveToRemove( - jobID, item.path, items.isLocal, true); + final FileDirectory fd; + try { + fd = await fileFetcher.fetchDirectoryRecursiveToRemove( + jobID, item.path, items.isLocal, true); + } catch (e) { + dialogManager?.dismissAll(); + final dm = dialogManager; + if (dm != null) { + msgBox(sessionId, 'custom-error-nook-nocancel-hasclose', + translate("Error"), e.toString(), '', dm); + } else { + debugPrint("removeAction error msgbox failed: $e"); + } + return; + } if (fd.path.isEmpty) { fd.path = item.path; } @@ -534,7 +672,7 @@ class FileController { item.name, false); if (confirm == true) { - sendRemoveEmptyDir( + await sendRemoveEmptyDir( item.path, 0, deleteJobId, @@ -575,7 +713,7 @@ class FileController { // handle remove res; if (item.isDirectory && res['file_num'] == (entries.length - 1).toString()) { - sendRemoveEmptyDir(item.path, i, deleteJobId); + await sendRemoveEmptyDir(item.path, i, deleteJobId); } } else { jobController.updateJobStatus(deleteJobId, @@ -588,7 +726,7 @@ class FileController { final res = await jobController.jobResultListener.start(); if (item.isDirectory && res['file_num'] == (entries.length - 1).toString()) { - sendRemoveEmptyDir(item.path, i, deleteJobId); + await sendRemoveEmptyDir(item.path, i, deleteJobId); } } } else { @@ -683,18 +821,22 @@ class FileController { fileNum: fileNum); } - void sendRemoveEmptyDir(String path, int fileNum, int actId) { + Future sendRemoveEmptyDir(String path, int fileNum, int actId) async { history.removeWhere((element) => element.contains(path)); - bind.sessionRemoveAllEmptyDirs( + await bind.sessionRemoveAllEmptyDirs( sessionId: sessionId, actId: actId, path: path, isRemote: !isLocal); } - Future createDir(String path) async { + Future createDirWithRemote(String path, bool isRemote) async { bind.sessionCreateDir( sessionId: sessionId, actId: JobController.jobID.next(), path: path, - isRemote: !isLocal); + isRemote: isRemote); + } + + Future createDir(String path) async { + await createDirWithRemote(path, !isLocal); } Future renameAction(Entry item, bool isLocal) async { @@ -957,30 +1099,54 @@ class JobController { await bind.sessionCancelJob(sessionId: sessionId, actId: id); } - void loadLastJob(Map evt) { + Future loadLastJob(Map evt) async { debugPrint("load last job: $evt"); Map jobDetail = json.decode(evt['value']); - // int id = int.parse(jobDetail['id']); String remote = jobDetail['remote']; String to = jobDetail['to']; bool showHidden = jobDetail['show_hidden']; int fileNum = jobDetail['file_num']; bool isRemote = jobDetail['is_remote']; - final currJobId = JobController.jobID.next(); - String fileName = path.basename(isRemote ? remote : to); - var jobProgress = JobProgress() - ..type = JobType.transfer - ..fileName = fileName - ..jobName = isRemote ? remote : to - ..id = currJobId - ..isRemoteToLocal = isRemote - ..fileNum = fileNum - ..remote = remote - ..to = to - ..showHidden = showHidden - ..state = JobState.paused; - jobTable.add(jobProgress); - bind.sessionAddJob( + bool isAutoStart = jobDetail['auto_start'] == true; + int currJobId = -1; + if (isAutoStart) { + // Ensure jobDetail['id'] exists and is an int + if (jobDetail.containsKey('id') && + jobDetail['id'] != null && + jobDetail['id'] is int) { + currJobId = jobDetail['id']; + } + } + if (currJobId < 0) { + // If id is missing or invalid, disable auto-start and assign a new job id + isAutoStart = false; + currJobId = JobController.jobID.next(); + } + + if (!isAutoStart) { + if (!(isDesktop || isWebDesktop)) { + // Don't add to job table if not auto start on mobile. + // Because mobile does not support job list view now. + return; + } + + // Add to job table if not auto start on desktop. + String fileName = path.basename(isRemote ? remote : to); + final jobProgress = JobProgress() + ..type = JobType.transfer + ..fileName = fileName + ..jobName = isRemote ? remote : to + ..id = currJobId + ..isRemoteToLocal = isRemote + ..fileNum = fileNum + ..remote = remote + ..to = to + ..showHidden = showHidden + ..state = JobState.paused; + jobTable.add(jobProgress); + } + + await bind.sessionAddJob( sessionId: sessionId, isRemote: isRemote, includeHidden: showHidden, @@ -989,6 +1155,11 @@ class JobController { to: isRemote ? to : remote, fileNum: fileNum, ); + + if (isAutoStart) { + await bind.sessionResumeJob( + sessionId: sessionId, actId: currJobId, isRemote: isRemote); + } } void resumeJob(int jobId) { @@ -1019,6 +1190,11 @@ class JobController { } debugPrint("update folder files: $info"); } + + void clear() { + jobTable.clear(); + jobResultListener.clear(); + } } class JobResultListener { @@ -1064,6 +1240,7 @@ class JobResultListener { class FileFetcher { // Map> localTasks = {}; // now we only use read local dir sync Map> remoteTasks = {}; + Map>> remoteEmptyDirsTasks = {}; Map> readRecursiveTasks = {}; final GetSessionID getSessionID; @@ -1071,6 +1248,24 @@ class FileFetcher { FileFetcher(this.getSessionID); + Future> registerReadEmptyDirsTask( + bool isLocal, String path) { + // final jobs = isLocal?localJobs:remoteJobs; // maybe we will use read local dir async later + final tasks = remoteEmptyDirsTasks; // bypass now + if (tasks.containsKey(path)) { + throw "Failed to registerReadEmptyDirsTask, 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 empty dirs, timeout"); + }); + 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 @@ -1104,6 +1299,25 @@ class FileFetcher { return c.future; } + tryCompleteEmptyDirsTask(String? msg, String? isLocalStr) { + if (msg == null || isLocalStr == null) return; + late final Map>> tasks; + try { + final map = jsonDecode(msg); + final String path = map["path"]; + final List fdJsons = map["empty_dirs"]; + final List fds = + fdJsons.map((fdJson) => FileDirectory.fromJson(fdJson)).toList(); + + tasks = remoteEmptyDirsTasks; + final completer = tasks.remove(path); + + completer?.complete(fds); + } catch (e) { + debugPrint("tryCompleteJob err: $e"); + } + } + tryCompleteTask(String? msg, String? isLocalStr) { if (msg == null || isLocalStr == null) return; late final Map> tasks; @@ -1127,6 +1341,37 @@ class FileFetcher { } } + // Complete a pending recursive read task with an error. + // See FileModel.handleJobError() for why this is necessary. + void tryCompleteRecursiveTaskWithError(int id, String error) { + final completer = readRecursiveTasks.remove(id); + if (completer != null && !completer.isCompleted) { + completer.completeError(error); + } + } + + Future> readEmptyDirs( + String path, bool isLocal, bool showHidden) async { + try { + if (isLocal) { + final res = await bind.sessionReadLocalEmptyDirsRecursiveSync( + sessionId: sessionId, path: path, includeHidden: showHidden); + + final List fdJsons = jsonDecode(res); + + final List fds = + fdJsons.map((fdJson) => FileDirectory.fromJson(fdJson)).toList(); + return fds; + } else { + await bind.sessionReadRemoteEmptyDirsRecursiveSync( + sessionId: sessionId, path: path, includeHidden: showHidden); + return registerReadEmptyDirsTask(isLocal, path); + } + } catch (e) { + return Future.error(e); + } + } + Future fetchDirectory( String path, bool isLocal, bool showHidden) async { try { @@ -1268,6 +1513,10 @@ class JobProgress { var err = ""; int lastTransferredSize = 0; + double get percent => + totalSize > 0 ? (finishedSize.toDouble() / totalSize) : 0.0; + String get percentText => '${(percent * 100).toStringAsFixed(0)}%'; + clear() { type = JobType.none; state = JobState.none; @@ -1373,6 +1622,24 @@ class PathUtil { static final windowsContext = path.Context(style: path.Style.windows); static final posixContext = path.Context(style: path.Style.posix); + static String getOtherSidePath(String mainRootPath, String mainPath, + bool isMainWindows, String otherRootPath, bool isOtherWindows) { + final mainPathUtil = isMainWindows ? windowsContext : posixContext; + final relativePath = mainPathUtil.relative(mainPath, from: mainRootPath); + + final names = mainPathUtil.split(relativePath); + + final otherPathUtil = isOtherWindows ? windowsContext : posixContext; + + String path = otherRootPath; + + for (var name in names) { + path = otherPathUtil.join(path, name); + } + + return path; + } + static String join(String path1, String path2, bool isWindows) { final pathUtil = isWindows ? windowsContext : posixContext; return pathUtil.join(path1, path2); @@ -1383,6 +1650,12 @@ class PathUtil { return pathUtil.split(path); } + static String convert(String path, bool isMainWindows, bool isOtherWindows) { + final mainPathUtil = isMainWindows ? windowsContext : posixContext; + final otherPathUtil = isOtherWindows ? windowsContext : posixContext; + return otherPathUtil.joinAll(mainPathUtil.split(path)); + } + static String dirname(String path, bool isWindows) { final pathUtil = isWindows ? windowsContext : posixContext; return pathUtil.dirname(path); diff --git a/flutter/lib/models/group_model.dart b/flutter/lib/models/group_model.dart index b14ccd46b..d55cff453 100644 --- a/flutter/lib/models/group_model.dart +++ b/flutter/lib/models/group_model.dart @@ -12,16 +12,20 @@ import '../utils/http_service.dart' as http; class GroupModel { final RxBool groupLoading = false.obs; final RxString groupLoadError = "".obs; + final RxList deviceGroups = RxList.empty(growable: true); final RxList users = RxList.empty(growable: true); final RxList peers = RxList.empty(growable: true); - final RxString selectedUser = ''.obs; - final RxString searchUserText = ''.obs; + final RxBool isSelectedDeviceGroup = false.obs; + final RxString selectedAccessibleItemName = ''.obs; + final RxString searchAccessibleItemNameText = ''.obs; WeakReference parent; var initialized = false; var _cacheLoadOnceFlag = false; var _statusCode = 200; - bool get emtpy => users.isEmpty && peers.isEmpty; + final Map _peerIdUpdateListeners = {}; + + bool get emtpy => deviceGroups.isEmpty && users.isEmpty && peers.isEmpty; late final Peers peersModel; @@ -43,7 +47,10 @@ class GroupModel { } try { await _pull(); - } catch (_) {} + _tryHandlePullError(); + } catch (e) { + print("pull accessibles error: $e"); + } groupLoading.value = false; initialized = true; platformFFI.tryHandle({'name': LoadEvent.group}); @@ -55,6 +62,12 @@ class GroupModel { } Future _pull() async { + List tmpDeviceGroups = List.empty(growable: true); + if (!await _getDeviceGroups(tmpDeviceGroups)) { + // old hbbs doesn't support this api + // return; + } + tmpDeviceGroups.sort((a, b) => a.name.compareTo(b.name)); List tmpUsers = List.empty(growable: true); if (!await _getUsers(tmpUsers)) { return; @@ -63,6 +76,7 @@ class GroupModel { if (!await _getPeers(tmpPeers)) { return; } + deviceGroups.value = tmpDeviceGroups; // me first var index = tmpUsers .indexWhere((user) => user.name == gFFI.userModel.userName.value); @@ -71,8 +85,9 @@ class GroupModel { tmpUsers.insert(0, user); } users.value = tmpUsers; - if (!users.any((u) => u.name == selectedUser.value)) { - selectedUser.value = ''; + if (!users.any((u) => u.name == selectedAccessibleItemName.value) && + !deviceGroups.any((d) => d.name == selectedAccessibleItemName.value)) { + selectedAccessibleItemName.value = ''; } // recover online final oldOnlineIDs = peers.where((e) => e.online).map((e) => e.id).toList(); @@ -82,6 +97,64 @@ class GroupModel { .map((e) => e.online = true) .toList(); groupLoadError.value = ''; + _callbackPeerUpdate(); + } + + Future _getDeviceGroups( + List tmpDeviceGroups) async { + final api = "${await bind.mainGetApiServer()}/api/device-group/accessible"; + try { + var uri0 = Uri.parse(api); + final pageSize = 100; + var total = 0; + int current = 0; + do { + current += 1; + var uri = Uri( + scheme: uri0.scheme, + host: uri0.host, + path: uri0.path, + port: uri0.port, + queryParameters: { + 'current': current.toString(), + 'pageSize': pageSize.toString(), + }); + final resp = await http.get(uri, headers: getHttpHeaders()); + _statusCode = resp.statusCode; + Map json = + _jsonDecodeResp(decode_http_response(resp), resp.statusCode); + if (json.containsKey('error')) { + throw json['error']; + } + if (resp.statusCode != 200) { + throw 'HTTP ${resp.statusCode}'; + } + if (json.containsKey('total')) { + if (total == 0) total = json['total']; + if (json.containsKey('data')) { + final data = json['data']; + if (data is List) { + for (final user in data) { + final u = DeviceGroupPayload.fromJson(user); + int index = tmpDeviceGroups.indexWhere((e) => e.name == u.name); + if (index < 0) { + tmpDeviceGroups.add(u); + } else { + tmpDeviceGroups[index] = u; + } + } + } + } + } + } while (current * pageSize < total); + return true; + } catch (err) { + debugPrint('get accessible device groups: $err'); + // old hbbs doesn't support this api + // groupLoadError.value = + // '${translate('pull_group_failed_tip')}: ${translate(err.toString())}'; + } + return false; } Future _getUsers(List tmpUsers) async { @@ -107,7 +180,7 @@ class GroupModel { final resp = await http.get(uri, headers: getHttpHeaders()); _statusCode = resp.statusCode; Map json = - _jsonDecodeResp(utf8.decode(resp.bodyBytes), resp.statusCode); + _jsonDecodeResp(decode_http_response(resp), resp.statusCode); if (json.containsKey('error')) { if (json['error'] == 'Admin required!' || json['error'] @@ -173,7 +246,7 @@ class GroupModel { _statusCode = resp.statusCode; Map json = - _jsonDecodeResp(utf8.decode(resp.bodyBytes), resp.statusCode); + _jsonDecodeResp(decode_http_response(resp), resp.statusCode); if (json.containsKey('error')) { throw json['error']; } @@ -225,6 +298,7 @@ class GroupModel { try { final map = ({ "access_token": bind.mainGetLocalOption(key: 'access_token'), + "device_groups": deviceGroups.map((e) => e.toGroupCacheJson()).toList(), "users": users.map((e) => e.toGroupCacheJson()).toList(), 'peers': peers.map((e) => e.toGroupCacheJson()).toList() }); @@ -244,8 +318,14 @@ class GroupModel { if (groupLoading.value) return; final data = jsonDecode(cache); if (data == null || data['access_token'] != access_token) return; + deviceGroups.clear(); users.clear(); peers.clear(); + if (data['device_groups'] is List) { + for (var u in data['device_groups']) { + deviceGroups.add(DeviceGroupPayload.fromJson(u)); + } + } if (data['users'] is List) { for (var u in data['users']) { users.add(UserPayload.fromJson(u)); @@ -255,6 +335,7 @@ class GroupModel { for (final peer in data['peers']) { peers.add(Peer.fromJson(peer)); } + _callbackPeerUpdate(); } } catch (e) { debugPrint("load group cache: $e"); @@ -262,10 +343,36 @@ class GroupModel { } reset() async { + initialized = false; groupLoadError.value = ''; + deviceGroups.clear(); users.clear(); peers.clear(); - selectedUser.value = ''; + selectedAccessibleItemName.value = ''; await bind.mainClearGroup(); } + + void _callbackPeerUpdate() { + for (var listener in _peerIdUpdateListeners.values) { + listener(); + } + } + + void addPeerUpdateListener(String key, VoidCallback listener) { + _peerIdUpdateListeners[key] = listener; + } + + void removePeerUpdateListener(String key) { + _peerIdUpdateListeners.remove(key); + } + + void _tryHandlePullError() { + String errorMessage = groupLoadError.value; + // The error message is "Retrieving accessible devices is disabled." + if (errorMessage.toLowerCase().contains('disabled')) { + users.clear(); + peers.clear(); + deviceGroups.clear(); + } + } } diff --git a/flutter/lib/models/input_model.dart b/flutter/lib/models/input_model.dart index c7e1e6131..984d6a25c 100644 --- a/flutter/lib/models/input_model.dart +++ b/flutter/lib/models/input_model.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:math'; +import 'package:flutter/foundation.dart'; import 'dart:ui' as ui; import 'package:desktop_multi_window/desktop_multi_window.dart'; @@ -14,11 +15,14 @@ import 'package:get/get.dart'; import '../../models/model.dart'; import '../../models/platform_model.dart'; +import '../../models/state_model.dart'; +import 'input_modifier_utils.dart'; +import 'relative_mouse_model.dart'; import '../common.dart'; import '../consts.dart'; /// Mouse button enum. -enum MouseButtons { left, right, wheel } +enum MouseButtons { left, right, wheel, back, forward } const _kMouseEventDown = 'mousedown'; const _kMouseEventUp = 'mouseup'; @@ -42,8 +46,7 @@ class CanvasCoords { 'scale': scale, 'scrollX': scrollX, 'scrollY': scrollY, - 'scrollStyle': - scrollStyle == ScrollStyle.scrollauto ? 'scrollauto' : 'scrollbar', + 'scrollStyle': scrollStyle.toJson(), 'size': { 'w': size.width, 'h': size.height, @@ -58,9 +61,8 @@ class CanvasCoords { model.scale = json['scale']; model.scrollX = json['scrollX']; model.scrollY = json['scrollY']; - model.scrollStyle = json['scrollStyle'] == 'scrollauto' - ? ScrollStyle.scrollauto - : ScrollStyle.scrollbar; + model.scrollStyle = + ScrollStyle.fromJson(json['scrollStyle'], ScrollStyle.scrollauto); model.size = Size(json['size']['w'], json['size']['h']); return model; } @@ -155,6 +157,10 @@ extension ToString on MouseButtons { return 'right'; case MouseButtons.wheel: return 'wheel'; + case MouseButtons.back: + return 'back'; + case MouseButtons.forward: + return 'forward'; } } } @@ -325,6 +331,80 @@ class ToReleaseKeys { } class InputModel { + // Side mouse button support for Linux. + // Flutter's Linux embedder drops X11 button 8/9 events, so we capture them + // natively via GDK and forward through the platform channel. + static InputModel? _activeSideButtonModel; + // Tracks per-button which model received a side button down event, so the + // matching up event is routed there even if the pointer has left the view + // or a different button was pressed in between. + static final Map _sideButtonDownModels = {}; + static bool _sideButtonChannelInitialized = false; + + /// Each Flutter engine (main window + sub-windows from desktop_multi_window) + /// runs its own Dart isolate with its own statics. Called from initEnv() + /// which runs per-engine, so each isolate registers its own handler tied + /// to its own set of InputModels. + static void initSideButtonChannel() { + if (!isLinux) return; + if (_sideButtonChannelInitialized) return; + _sideButtonChannelInitialized = true; + + const channel = MethodChannel('org.rustdesk.rustdesk/side_buttons'); + channel.setMethodCallHandler((call) async { + if (call.method == 'onSideMouseButton') { + final args = call.arguments as Map; + final button = args['button'] as String; + final type = args['type'] as String; + final mb = button == 'back' ? MouseButtons.back : MouseButtons.forward; + + if (type == 'down') { + final model = _activeSideButtonModel; + if (model != null && + !(model.isViewOnly && !model.showMyCursor) && + model.keyboardPerm && + !model.isViewCamera) { + _sideButtonDownModels[mb] = model; + // Fire-and-forget to avoid blocking the platform channel handler. + unawaited(model._sendMouseUnchecked(type, mb).catchError((Object e) { + debugPrint('[InputModel] failed to send side button $type for $mb: $e'); + })); + } + } else { + // Only route 'up' when we recorded the matching 'down'; + // dropping avoids sending unpaired 'up' to an unrelated session. + // Use _sendMouseUnchecked to bypass permission checks so the + // release always goes through even if permissions changed. + final model = _sideButtonDownModels.remove(mb); + if (model != null) { + unawaited(model._sendMouseUnchecked(type, mb).catchError((Object e) { + debugPrint('[InputModel] failed to send side button $type for $mb: $e'); + })); + } + } + } + return null; + }); + } + + /// Clear any static references to this model (prevents stale routing). + /// Releases any held side buttons on the peer so closing a session + /// mid-press does not leave a stuck button. + void disposeSideButtonTracking() { + if (_activeSideButtonModel == this) _activeSideButtonModel = null; + final held = _sideButtonDownModels.entries + .where((e) => e.value == this) + .map((e) => e.key) + .toList(); + for (final mb in held) { + _sideButtonDownModels.remove(mb); + // Best-effort release; session may already be tearing down. + unawaited(_sendMouseUnchecked('up', mb).catchError((Object e) { + debugPrint('[InputModel] failed to release side button $mb: $e'); + })); + } + } + final WeakReference parent; String keyboardMode = ''; @@ -343,18 +423,50 @@ class InputModel { var _fling = false; Timer? _flingTimer; final _flingBaseDelay = 30; - // trackpad, peer linux - final _trackpadSpeed = 0.06; + final _trackpadAdjustPeerLinux = 0.06; + // This is an experience value. + final _trackpadAdjustMacToWin = 2.50; + // Ignore directional locking for very small deltas on both axes (including + // tiny single-axis movement) to avoid over-filtering near zero. + static const double _trackpadAxisNoiseThreshold = 0.2; + // Lock to dominant axis only when one axis is clearly stronger. + // 1.6 means the dominant axis must be >= 60% larger than the other. + static const double _trackpadAxisLockRatio = 1.6; + int _trackpadSpeed = kDefaultTrackpadSpeed; + double _trackpadSpeedInner = kDefaultTrackpadSpeed / 100.0; var _trackpadScrollUnsent = Offset.zero; + // Mobile relative mouse delta accumulators (for slow/fine movements). + double _mobileDeltaRemainderX = 0.0; + double _mobileDeltaRemainderY = 0.0; + var _lastScale = 1.0; bool _pointerMovedAfterEnter = false; + bool _pointerInsideImage = false; // mouse final isPhysicalMouse = false.obs; int _lastButtons = 0; Offset lastMousePos = Offset.zero; + int _lastWheelTsUs = 0; + + // Wheel acceleration thresholds. + static const int _wheelAccelFastThresholdUs = 40000; // 40ms + static const int _wheelAccelMediumThresholdUs = 80000; // 80ms + static const double _wheelBurstVelocityThreshold = + 0.002; // delta units per microsecond + // Wheel burst acceleration (empirical tuning). + // Applies only to fast, non-smooth bursts to preserve single-step scrolling. + // Flutter uses microseconds for dt, so velocity is in delta/us. + + // Relative mouse mode (for games/3D apps). + final relativeMouseMode = false.obs; + late final RelativeMouseModel _relativeMouse; + // Callback to cancel external throttle timer when relative mouse mode is disabled. + VoidCallback? onRelativeMouseModeDisabled; + // Disposer for the relativeMouseMode observer (to prevent memory leaks). + Worker? _relativeMouseModeDisposer; bool _queryOtherWindowCoords = false; Rect? _windowRect; @@ -365,11 +477,109 @@ class InputModel { bool get keyboardPerm => parent.target!.ffiModel.keyboard; String get id => parent.target?.id ?? ''; String? get peerPlatform => parent.target?.ffiModel.pi.platform; + String get peerVersion => parent.target?.ffiModel.pi.version ?? ''; bool get isViewOnly => parent.target!.ffiModel.viewOnly; + bool get showMyCursor => parent.target!.ffiModel.showMyCursor; double get devicePixelRatio => parent.target!.canvasModel.devicePixelRatio; + bool get isViewCamera => parent.target!.connType == ConnType.viewCamera; + int get trackpadSpeed => _trackpadSpeed; + bool get useEdgeScroll => + parent.target!.canvasModel.scrollStyle == ScrollStyle.scrolledge; + + /// Check if the connected server supports relative mouse mode. + bool get isRelativeMouseModeSupported => _relativeMouse.isSupported; InputModel(this.parent) { + initSideButtonChannel(); sessionId = parent.target!.sessionId; + _relativeMouse = RelativeMouseModel( + sessionId: sessionId, + enabled: relativeMouseMode, + keyboardPerm: () => keyboardPerm, + isViewCamera: () => isViewCamera, + peerVersion: () => peerVersion, + peerPlatform: () => peerPlatform, + modify: (msg) => modify(msg), + getPointerInsideImage: () => _pointerInsideImage, + setPointerInsideImage: (inside) => _pointerInsideImage = inside, + ); + _relativeMouse.onDisabled = () => onRelativeMouseModeDisabled?.call(); + + // Sync relative mouse mode state to global state for UI components (e.g., tab bar hint). + _relativeMouseModeDisposer = ever(relativeMouseMode, (bool value) { + final peerId = id; + if (peerId.isNotEmpty) { + stateGlobal.relativeMouseModeState[peerId] = value; + } + }); + } + + // https://github.com/flutter/flutter/issues/157241 + // Infer CapsLock state from the character output. + // This is needed because Flutter's HardwareKeyboard.lockModesEnabled may report + // incorrect CapsLock state on iOS. + bool _getIosCapsFromCharacter(KeyEvent e) { + if (!isIOS) return false; + final ch = e.character; + return _getIosCapsFromCharacterImpl( + ch, HardwareKeyboard.instance.isShiftPressed); + } + + // RawKeyEvent version of _getIosCapsFromCharacter. + bool _getIosCapsFromRawCharacter(RawKeyEvent e) { + if (!isIOS) return false; + final ch = e.character; + return _getIosCapsFromCharacterImpl(ch, e.isShiftPressed); + } + + // Shared implementation for inferring CapsLock state from character. + // Uses Unicode-aware case detection to support non-ASCII letters (e.g., ü/Ü, é/É). + // + // Limitations: + // 1. This inference assumes the client and server use the same keyboard layout. + // If layouts differ (e.g., client uses EN, server uses DE), the character output + // may not match expectations. For example, ';' on EN layout maps to 'ö' on DE + // layout, making it impossible to correctly infer CapsLock state from the + // character alone. + // 2. On iOS, CapsLock+Shift produces uppercase letters (unlike desktop where it + // produces lowercase). This method cannot handle that case correctly. + bool _getIosCapsFromCharacterImpl(String? ch, bool shiftPressed) { + if (ch == null || ch.length != 1) return false; + // Use Dart's built-in Unicode-aware case detection + final upper = ch.toUpperCase(); + final lower = ch.toLowerCase(); + final isUpper = upper == ch && lower != ch; + final isLower = lower == ch && upper != ch; + // Skip non-letter characters (e.g., numbers, symbols, CJK characters without case) + if (!isUpper && !isLower) return false; + return isUpper != shiftPressed; + } + + int _buildLockModes(bool iosCapsLock) { + const capslock = 1; + const numlock = 2; + const scrolllock = 3; + int lockModes = 0; + if (isIOS) { + if (iosCapsLock) { + lockModes |= (1 << capslock); + } + // Ignore "NumLock/ScrollLock" on iOS for now. + } else { + if (HardwareKeyboard.instance.lockModesEnabled + .contains(KeyboardLockMode.capsLock)) { + lockModes |= (1 << capslock); + } + if (HardwareKeyboard.instance.lockModesEnabled + .contains(KeyboardLockMode.numLock)) { + lockModes |= (1 << numlock); + } + if (HardwareKeyboard.instance.lockModesEnabled + .contains(KeyboardLockMode.scrollLock)) { + lockModes |= (1 << scrolllock); + } + } + return lockModes; } // This function must be called after the peer info is received. @@ -382,6 +592,28 @@ class InputModel { } } + /// Updates the trackpad speed based on the session value. + /// + /// The expected format of the retrieved value is a string that can be parsed into a double. + /// If parsing fails or the value is out of bounds (less than `kMinTrackpadSpeed` or greater + /// than `kMaxTrackpadSpeed`), the trackpad speed is reset to the default + /// value (`kDefaultTrackpadSpeed`). + /// + /// Bounds: + /// - Minimum: `kMinTrackpadSpeed` + /// - Maximum: `kMaxTrackpadSpeed` + /// - Default: `kDefaultTrackpadSpeed` + Future updateTrackpadSpeed() async { + _trackpadSpeed = + (await bind.sessionGetTrackpadSpeed(sessionId: sessionId) ?? + kDefaultTrackpadSpeed); + if (_trackpadSpeed < kMinTrackpadSpeed || + _trackpadSpeed > kMaxTrackpadSpeed) { + _trackpadSpeed = kDefaultTrackpadSpeed; + } + _trackpadSpeedInner = _trackpadSpeed / 100.0; + } + void handleKeyDownEventModifiers(KeyEvent e) { KeyUpEvent upEvent(e) => KeyUpEvent( physicalKey: e.physicalKey, @@ -467,8 +699,41 @@ class InputModel { } } + // Safe: this only re-dispatches synthesized Shift key-up events. + // The key-up path clears the tracked Shift state so this does not loop. + void _releaseTrackedShiftKeyEventIfNeeded() { + final leftShift = toReleaseKeys.lastLShiftKeyEvent; + final rightShift = toReleaseKeys.lastRShiftKeyEvent; + if (leftShift != null) { + handleKeyEvent(leftShift); + } + if (rightShift != null) { + handleKeyEvent(rightShift); + } + } + + // Safe: this only re-dispatches synthesized Shift key-up events. + // The raw key-up path clears the tracked Shift state so this does not loop. + void _releaseTrackedRawShiftKeyEventIfNeeded() { + final leftShift = toReleaseRawKeys.lastLShiftKeyEvent; + final rightShift = toReleaseRawKeys.lastRShiftKeyEvent; + if (leftShift != null) { + handleRawKeyEvent(RawKeyUpEvent( + data: leftShift.data, + character: leftShift.character, + )); + } + if (rightShift != null) { + handleRawKeyEvent(RawKeyUpEvent( + data: rightShift.data, + character: rightShift.character, + )); + } + } + KeyEventResult handleRawKeyEvent(RawKeyEvent e) { if (isViewOnly) return KeyEventResult.handled; + if (isViewCamera) return KeyEventResult.handled; if (!isInputSourceFlutter) { if (isDesktop) { return KeyEventResult.handled; @@ -477,6 +742,15 @@ class InputModel { } } + if (_relativeMouse.handleRawKeyEvent(e)) { + return KeyEventResult.handled; + } + + bool iosCapsLock = false; + if (isIOS && e is RawKeyDownEvent) { + iosCapsLock = _getIosCapsFromRawCharacter(e); + } + final key = e.logicalKey; if (e is RawKeyDownEvent) { if (!e.repeat) { @@ -511,9 +785,30 @@ class InputModel { toReleaseRawKeys.updateKeyUp(key, e); } + // On some mobile soft-keyboard paths, Flutter may leave cached Shift state + // set even though the current raw key event is not shifted anymore. + if (e is RawKeyDownEvent && + shouldReleaseStaleMobileShift( + isMobile: isMobile, + cachedShiftPressed: shift, + actualShiftPressed: e.isShiftPressed, + logicalKey: e.logicalKey, + hasTrackedShiftKeyDown: toReleaseRawKeys.lastLShiftKeyEvent != null || + toReleaseRawKeys.lastRShiftKeyEvent != null, + )) { + if (kDebugMode) { + debugPrint( + 'input: releasing stale mobile Shift before replaying tracked raw ' + 'key-up (logicalKey=${e.logicalKey.keyLabel}, ' + 'actualShiftPressed=${e.isShiftPressed}, cachedShiftPressed=$shift)', + ); + } + _releaseTrackedRawShiftKeyEventIfNeeded(); + } + // * Currently mobile does not enable map mode if ((isDesktop || isWebDesktop) && keyboardMode == kKeyMapMode) { - mapKeyboardModeRaw(e); + mapKeyboardModeRaw(e, iosCapsLock); } else { legacyKeyboardModeRaw(e); } @@ -523,6 +818,7 @@ class InputModel { KeyEventResult handleKeyEvent(KeyEvent e) { if (isViewOnly) return KeyEventResult.handled; + if (isViewCamera) return KeyEventResult.handled; if (!isInputSourceFlutter) { if (isDesktop) { return KeyEventResult.handled; @@ -538,19 +834,85 @@ class InputModel { } } + if (_relativeMouse.handleKeyEvent( + e, + ctrlPressed: ctrl, + shiftPressed: shift, + altPressed: alt, + commandPressed: command, + )) { + return KeyEventResult.handled; + } + + bool iosCapsLock = false; + if (isIOS && (e is KeyDownEvent || e is KeyRepeatEvent)) { + iosCapsLock = _getIosCapsFromCharacter(e); + } + + // Update cached modifier state before sending the event. The stale mobile + // Shift release check below relies on this cached state. if (e is KeyUpEvent) { handleKeyUpEventModifiers(e); } else if (e is KeyDownEvent) { handleKeyDownEventModifiers(e); } - if (isMobile || (isDesktop || isWebDesktop) && keyboardMode == kKeyMapMode) { + bool isMobileAndMapMode = false; + if (isMobile) { + // Do not use map mode if mobile -> Android. Android does not support map mode for now. + // Because simulating the physical key events(uhid) which requires root permission is not supported. + if (peerPlatform != kPeerPlatformAndroid) { + if (isIOS) { + isMobileAndMapMode = true; + } else { + // The physicalKey.usbHidUsage may be not correct for soft keyboard on Android. + // iOS does not have this issue. + // 1. Open the soft keyboard on Android + // 2. Switch to input method like zh/ko/ja + // 3. Click Backspace and Enter on the soft keyboard or physical keyboard + // 4. The physicalKey.usbHidUsage is not correct. + // PhysicalKeyboardKey#8ac83(usbHidUsage: "0x1100000042", debugName: "Key with ID 0x1100000042") + // LogicalKeyboardKey#2604c(keyId: "0x10000000d", keyLabel: "Enter", debugName: "Enter") + // + // The correct PhysicalKeyboardKey should be + // PhysicalKeyboardKey#e14a9(usbHidUsage: "0x00070028", debugName: "Enter") + // https://github.com/flutter/flutter/issues/157771 + // We cannot use the debugName to determine the key is correct or not, because it's null in release mode. + // The normal `usbHidUsage` for keyboard shoud be between [0x00000010, 0x000c029f] + // https://github.com/flutter/flutter/blob/c051b69e2a2224300e20d93dbd15f4b91e8844d1/packages/flutter/lib/src/services/keyboard_key.g.dart#L5332 - 5600 + final isNormalHsbHidUsage = (e.physicalKey.usbHidUsage >> 20) == 0; + isMobileAndMapMode = isNormalHsbHidUsage && + // No need to check `!['Backspace', 'Enter'].contains(e.logicalKey.keyLabel)` + // But we still add it for more reliability. + !['Backspace', 'Enter'].contains(e.logicalKey.keyLabel); + } + } + } + + // On some mobile soft-keyboard paths, Flutter may leave cached Shift state + // set even though the current key event is not shifted anymore. + if (e is KeyDownEvent && + shouldReleaseStaleMobileShift( + isMobile: isMobile, + cachedShiftPressed: shift, + actualShiftPressed: HardwareKeyboard.instance.isShiftPressed, + logicalKey: e.logicalKey, + hasTrackedShiftKeyDown: toReleaseKeys.lastLShiftKeyEvent != null || + toReleaseKeys.lastRShiftKeyEvent != null, + )) { + _releaseTrackedShiftKeyEventIfNeeded(); + } + + final isDesktopAndMapMode = + isDesktop || (isWebDesktop && keyboardMode == kKeyMapMode); + if (isMobileAndMapMode || isDesktopAndMapMode) { // FIXME: e.character is wrong for dead keys, eg: ^ in de newKeyboardMode( e.character ?? '', e.physicalKey.usbHidUsage & 0xFFFF, // Show repeat event be converted to "release+press" events? - e is KeyDownEvent || e is KeyRepeatEvent); + e is KeyDownEvent || e is KeyRepeatEvent, + iosCapsLock); } else { legacyKeyboardMode(e); } @@ -559,23 +921,9 @@ class InputModel { } /// Send Key Event - void newKeyboardMode(String character, int usbHid, bool down) { - const capslock = 1; - const numlock = 2; - const scrolllock = 3; - int lockModes = 0; - if (HardwareKeyboard.instance.lockModesEnabled - .contains(KeyboardLockMode.capsLock)) { - lockModes |= (1 << capslock); - } - if (HardwareKeyboard.instance.lockModesEnabled - .contains(KeyboardLockMode.numLock)) { - lockModes |= (1 << numlock); - } - if (HardwareKeyboard.instance.lockModesEnabled - .contains(KeyboardLockMode.scrollLock)) { - lockModes |= (1 << scrolllock); - } + void newKeyboardMode( + String character, int usbHid, bool down, bool iosCapsLock) { + final lockModes = _buildLockModes(iosCapsLock); bind.sessionHandleFlutterKeyEvent( sessionId: sessionId, character: character, @@ -584,7 +932,7 @@ class InputModel { downOrUp: down); } - void mapKeyboardModeRaw(RawKeyEvent e) { + void mapKeyboardModeRaw(RawKeyEvent e, bool iosCapsLock) { int positionCode = -1; int platformCode = -1; bool down; @@ -615,27 +963,14 @@ class InputModel { } else { down = false; } - inputRawKey(e.character ?? '', platformCode, positionCode, down); + inputRawKey( + e.character ?? '', platformCode, positionCode, down, iosCapsLock); } /// Send raw Key Event - void inputRawKey(String name, int platformCode, int positionCode, bool down) { - const capslock = 1; - const numlock = 2; - const scrolllock = 3; - int lockModes = 0; - if (HardwareKeyboard.instance.lockModesEnabled - .contains(KeyboardLockMode.capsLock)) { - lockModes |= (1 << capslock); - } - if (HardwareKeyboard.instance.lockModesEnabled - .contains(KeyboardLockMode.numLock)) { - lockModes |= (1 << numlock); - } - if (HardwareKeyboard.instance.lockModesEnabled - .contains(KeyboardLockMode.scrollLock)) { - lockModes |= (1 << scrolllock); - } + void inputRawKey(String name, int platformCode, int positionCode, bool down, + bool iosCapsLock) { + final lockModes = _buildLockModes(iosCapsLock); bind.sessionHandleFlutterRawKeyEvent( sessionId: sessionId, name: name, @@ -689,6 +1024,7 @@ class InputModel { /// [press] indicates a click event(down and up). void inputKey(String name, {bool? down, bool? press}) { if (!keyboardPerm) return; + if (isViewCamera) return; bind.sessionInputKey( sessionId: sessionId, name: name, @@ -700,9 +1036,17 @@ class InputModel { command: command); } + static Map getMouseEventMove() => { + 'type': _kMouseEventMove, + 'buttons': 0, + }; + Map _getMouseEvent(PointerEvent evt, String type) { final Map out = {}; + bool hasStaleButtonsOnMouseUp = + type == _kMouseEventUp && evt.buttons == _lastButtons; + // Check update event type and set buttons to be sent. int buttons = _lastButtons; if (type == _kMouseEventMove) { @@ -727,7 +1071,7 @@ class InputModel { buttons = evt.buttons; } } - _lastButtons = evt.buttons; + _lastButtons = hasStaleButtonsOnMouseUp ? 0 : evt.buttons; out['buttons'] = buttons; out['type'] = type; @@ -735,22 +1079,23 @@ class InputModel { } /// Send a mouse tap event(down and up). - void tap(MouseButtons button) { - sendMouse('down', button); - sendMouse('up', button); + Future tap(MouseButtons button) async { + await sendMouse('down', button); + await sendMouse('up', button); } - void tapDown(MouseButtons button) { - sendMouse('down', button); + Future tapDown(MouseButtons button) async { + await sendMouse('down', button); } - void tapUp(MouseButtons button) { - sendMouse('up', button); + Future tapUp(MouseButtons button) async { + await sendMouse('up', button); } /// Send scroll event with scroll distance [y]. - void scroll(int y) { - bind.sessionSendMouse( + Future scroll(int y) async { + if (isViewCamera) return; + await bind.sessionSendMouse( sessionId: sessionId, msg: json .encode(modify({'id': id, 'type': 'wheel', 'y': y.toString()}))); @@ -770,23 +1115,41 @@ class InputModel { return evt; } - /// Send mouse press event. - void sendMouse(String type, MouseButtons button) { - if (!keyboardPerm) return; - bind.sessionSendMouse( + /// Send mouse event unconditionally (no permission checks). + /// Used for side button releases that must go through even if permissions + /// changed after the matching down was sent. + Future _sendMouseUnchecked(String type, MouseButtons button) async { + await bind.sessionSendMouse( sessionId: sessionId, msg: json.encode(modify({'type': type, 'buttons': button.value}))); } + /// Send mouse press event. + Future sendMouse(String type, MouseButtons button) async { + if (!keyboardPerm) return; + if (isViewCamera) return; + await _sendMouseUnchecked(type, button); + } + void enterOrLeave(bool enter) { toReleaseKeys.release(handleKeyEvent); toReleaseRawKeys.release(handleRawKeyEvent); _pointerMovedAfterEnter = false; + _pointerInsideImage = enter; + _lastWheelTsUs = 0; + + // Track active model for side button events (Linux). + if (enter) { + _activeSideButtonModel = this; + } else if (_activeSideButtonModel == this) { + _activeSideButtonModel = null; + } // Fix status if (!enter) { resetModifiers(); } + _relativeMouse.onEnterOrLeaveImage(enter); _flingTimer?.cancel(); if (!isInputSourceFlutter) { bind.sessionEnterOrLeave(sessionId: sessionId, enter: enter); @@ -797,24 +1160,152 @@ class InputModel { } /// Send mouse movement event with distance in [x] and [y]. - void moveMouse(double x, double y) { + Future moveMouse(double x, double y) async { if (!keyboardPerm) return; + if (isViewCamera) return; var x2 = x.toInt(); var y2 = y.toInt(); - bind.sessionSendMouse( + await bind.sessionSendMouse( sessionId: sessionId, msg: json.encode(modify({'x': '$x2', 'y': '$y2'}))); } + /// Send relative mouse movement for mobile clients (virtual joystick). + /// This method is for touch-based controls that want to send delta values. + /// Uses the 'move_relative' type which bypasses absolute position tracking. + /// + /// Accumulates fractional deltas to avoid losing slow/fine movements. + /// Only sends events when relative mouse mode is enabled and supported. + Future sendMobileRelativeMouseMove(double dx, double dy) async { + if (!keyboardPerm) return; + if (isViewCamera) return; + // Only send relative mouse events when relative mode is enabled and supported. + if (!isRelativeMouseModeSupported || !relativeMouseMode.value) return; + _mobileDeltaRemainderX += dx; + _mobileDeltaRemainderY += dy; + final x = _mobileDeltaRemainderX.truncate(); + final y = _mobileDeltaRemainderY.truncate(); + _mobileDeltaRemainderX -= x; + _mobileDeltaRemainderY -= y; + if (x == 0 && y == 0) return; + await bind.sessionSendMouse( + sessionId: sessionId, + msg: json.encode(modify({ + 'type': 'move_relative', + 'x': '$x', + 'y': '$y', + }))); + } + + /// Update the pointer lock center position based on current window frame. + Future updatePointerLockCenter({Offset? localCenter}) { + return _relativeMouse.updatePointerLockCenter(localCenter: localCenter); + } + + /// Get the current image widget size (for comparison to avoid unnecessary updates). + Size? get imageWidgetSize => _relativeMouse.imageWidgetSize; + + /// Update the image widget size for center calculation. + void updateImageWidgetSize(Size size) { + _relativeMouse.updateImageWidgetSize(size); + } + + void toggleRelativeMouseMode() { + _relativeMouse.toggleRelativeMouseMode(); + } + + bool setRelativeMouseMode(bool enabled) { + return _relativeMouse.setRelativeMouseMode(enabled); + } + + /// Exit relative mouse mode and release all modifier keys to the remote. + /// This is called when the user presses the exit shortcut (Ctrl+Alt on Win/Linux, Cmd+G on macOS). + /// We need to send key-up events for all modifiers because the shortcut itself may have + /// blocked some key events, leaving the remote in a state where modifiers are stuck. + void exitRelativeMouseModeWithKeyRelease() { + if (!_relativeMouse.enabled.value) return; + + // First, send release events for all modifier keys to the remote. + // This ensures the remote doesn't have stuck modifier keys after exiting. + // Use press: false, down: false to send key-up events without modifiers attached. + final modifiersToRelease = [ + 'Control_L', + 'Control_R', + 'Alt_L', + 'Alt_R', + 'Shift_L', + 'Shift_R', + 'Meta_L', // Command/Super left + 'Meta_R', // Command/Super right + ]; + + for (final key in modifiersToRelease) { + bind.sessionInputKey( + sessionId: sessionId, + name: key, + down: false, + press: false, + alt: false, + ctrl: false, + shift: false, + command: false, + ); + } + + // Reset local modifier state + resetModifiers(); + + // Now exit relative mouse mode + _relativeMouse.setRelativeMouseMode(false); + } + + void disposeRelativeMouseMode() { + _relativeMouse.dispose(); + onRelativeMouseModeDisabled = null; + // Cancel the relative mouse mode observer and clean up global state. + _relativeMouseModeDisposer?.dispose(); + _relativeMouseModeDisposer = null; + final peerId = id; + if (peerId.isNotEmpty) { + stateGlobal.relativeMouseModeState.remove(peerId); + } + } + + void onWindowBlur() { + _relativeMouse.onWindowBlur(); + } + + void onWindowFocus() { + _relativeMouse.onWindowFocus(); + } + void onPointHoverImage(PointerHoverEvent e) { _stopFling = true; - if (isViewOnly) return; + if (isViewOnly && !showMyCursor) return; if (e.kind != ui.PointerDeviceKind.mouse) return; + + // May fix https://github.com/rustdesk/rustdesk/issues/13009 + if (isIOS && e.synthesized && e.position == Offset.zero && e.buttons == 0) { + // iOS may emit a synthesized hover event at (0,0) when the mouse is disconnected. + // Ignore this event to prevent cursor jumping. + debugPrint('Ignored synthesized hover at (0,0) on iOS'); + return; + } + + // Only update pointer region when relative mouse mode is enabled. + // This avoids unnecessary tracking when not in relative mode. + if (_relativeMouse.enabled.value) { + _relativeMouse.updatePointerRegionTopLeftGlobal(e); + } + if (!isPhysicalMouse.value) { isPhysicalMouse.value = true; } if (isPhysicalMouse.value) { - handleMouse(_getMouseEvent(e, _kMouseEventMove), e.position); + if (!_relativeMouse.handleRelativeMouseMove(e.localPosition)) { + handleMouse(_getMouseEvent(e, _kMouseEventMove), e.position, + edgeScroll: useEdgeScroll); + } } } @@ -822,14 +1313,16 @@ class InputModel { _lastScale = 1.0; _stopFling = true; if (isViewOnly) return; + if (isViewCamera) return; if (peerPlatform == kPeerPlatformAndroid) { - handlePointerEvent('touch', 'pan_start', e.position); + handlePointerEvent('touch', kMouseEventTypePanStart, e.position); } } // https://docs.flutter.dev/release/breaking-changes/trackpad-gestures void onPointerPanZoomUpdate(PointerPanZoomUpdateEvent e) { if (isViewOnly) return; + if (isViewCamera) return; if (peerPlatform != kPeerPlatformAndroid) { final scale = ((e.scale - _lastScale) * 1000).toInt(); _lastScale = e.scale; @@ -844,13 +1337,17 @@ class InputModel { } } - final delta = e.panDelta; + var delta = e.panDelta * _trackpadSpeedInner; + if (isMacOS && peerPlatform == kPeerPlatformWindows) { + delta *= _trackpadAdjustMacToWin; + } + delta = _filterTrackpadDeltaAxis(delta); _trackpadLastDelta = delta; var x = delta.dx.toInt(); var y = delta.dy.toInt(); if (peerPlatform == kPeerPlatformLinux) { - _trackpadScrollUnsent += (delta * _trackpadSpeed); + _trackpadScrollUnsent += (delta * _trackpadAdjustPeerLinux); x = _trackpadScrollUnsent.dx.truncate(); y = _trackpadScrollUnsent.dy.truncate(); _trackpadScrollUnsent -= Offset(x.toDouble(), y.toDouble()); @@ -866,9 +1363,10 @@ class InputModel { } if (x != 0 || y != 0) { if (peerPlatform == kPeerPlatformAndroid) { - handlePointerEvent( - 'touch', 'pan_update', Offset(x.toDouble(), y.toDouble())); + handlePointerEvent('touch', kMouseEventTypePanUpdate, + Offset(x.toDouble(), y.toDouble())); } else { + if (isViewCamera) return; bind.sessionSendMouse( sessionId: sessionId, msg: '{"type": "trackpad", "x": "$x", "y": "$y"}'); @@ -876,7 +1374,26 @@ class InputModel { } } + Offset _filterTrackpadDeltaAxis(Offset delta) { + final absDx = delta.dx.abs(); + final absDy = delta.dy.abs(); + // Keep diagonal intent when movement is tiny on both axes. + if (absDx < _trackpadAxisNoiseThreshold && + absDy < _trackpadAxisNoiseThreshold) { + return delta; + } + // Dominant-axis lock to reduce accidental cross-axis scrolling noise. + if (absDy >= absDx * _trackpadAxisLockRatio) { + return Offset(0, delta.dy); + } + if (absDx >= absDy * _trackpadAxisLockRatio) { + return Offset(delta.dx, 0); + } + return delta; + } + void _scheduleFling(double x, double y, int delay) { + if (isViewCamera) return; if ((x == 0 && y == 0) || _stopFling) { _fling = false; return; @@ -896,8 +1413,8 @@ class InputModel { var dx = x.toInt(); var dy = y.toInt(); if (parent.target?.ffiModel.pi.platform == kPeerPlatformLinux) { - dx = (x * _trackpadSpeed).toInt(); - dy = (y * _trackpadSpeed).toInt(); + dx = (x * _trackpadAdjustPeerLinux).toInt(); + dy = (y * _trackpadAdjustPeerLinux).toInt(); } var delay = _flingBaseDelay; @@ -928,8 +1445,9 @@ class InputModel { } void onPointerPanZoomEnd(PointerPanZoomEndEvent e) { + if (isViewCamera) return; if (peerPlatform == kPeerPlatformAndroid) { - handlePointerEvent('touch', 'pan_end', e.position); + handlePointerEvent('touch', kMouseEventTypePanEnd, e.position); return; } @@ -942,7 +1460,10 @@ class InputModel { _stopFling = false; // 2.0 is an experience value - double minFlingValue = 2.0; + double minFlingValue = 2.0 * _trackpadSpeedInner; + if (isMacOS && peerPlatform == kPeerPlatformWindows) { + minFlingValue *= _trackpadAdjustMacToWin; + } if (_trackpadLastDelta.dx.abs() > minFlingValue || _trackpadLastDelta.dy.abs() > minFlingValue) { _fling = true; @@ -952,35 +1473,113 @@ class InputModel { _trackpadLastDelta = Offset.zero; } + // iOS Magic Mouse duplicate event detection. + // When using Magic Mouse on iPad, iOS may emit both mouse and touch events + // for the same click in certain areas (like top-left corner). + int _lastMouseDownTimeMs = 0; + ui.Offset _lastMouseDownPos = ui.Offset.zero; + + /// Check if a touch tap event should be ignored because it's a duplicate + /// of a recent mouse event (iOS Magic Mouse issue). + bool shouldIgnoreTouchTap(ui.Offset pos) { + if (!isIOS) return false; + final nowMs = DateTime.now().millisecondsSinceEpoch; + final dt = nowMs - _lastMouseDownTimeMs; + final distance = (_lastMouseDownPos - pos).distance; + // If touch tap is within 2000ms and 80px of the last mouse down, + // it's likely a duplicate event from the same Magic Mouse click. + if (dt >= 0 && dt < 2000 && distance < 80.0) { + debugPrint("shouldIgnoreTouchTap: IGNORED (dt=$dt, dist=$distance)"); + return true; + } + return false; + } + + /// iOS may emit a synthesized touch event after a real mouse click. + /// This helper ignores touch-down events that arrive shortly after a mouse down, + /// even when the position is far (e.g., near the top edge). + bool _shouldIgnoreTouchAfterMouse(int nowMs) { + if (!isIOS) return false; + const int kTouchAfterMouseWindowMs = 700; + final dt = nowMs - _lastMouseDownTimeMs; + return dt >= 0 && dt < kTouchAfterMouseWindowMs; + } + void onPointDownImage(PointerDownEvent e) { debugPrint("onPointDownImage ${e.kind}"); _stopFling = true; if (isDesktop) _queryOtherWindowCoords = true; _remoteWindowCoords = []; _windowRect = null; - if (isViewOnly) return; + if (isViewOnly && !showMyCursor) return; + if (isViewCamera) return; + + // Track mouse down events for duplicate detection on iOS. + final nowMs = DateTime.now().millisecondsSinceEpoch; + if (e.kind == ui.PointerDeviceKind.mouse) { + if (!isPhysicalMouse.value) { + isPhysicalMouse.value = true; + } + _lastMouseDownTimeMs = nowMs; + _lastMouseDownPos = e.position; + } + + if (_relativeMouse.enabled.value) { + _relativeMouse.updatePointerRegionTopLeftGlobal(e); + } + if (e.kind != ui.PointerDeviceKind.mouse) { + // Ignore duplicate touch events that follow a recent mouse click (iOS Magic Mouse issue). + if (isPhysicalMouse.value && _shouldIgnoreTouchAfterMouse(nowMs)) { + return; + } if (isPhysicalMouse.value) { isPhysicalMouse.value = false; } } if (isPhysicalMouse.value) { - handleMouse(_getMouseEvent(e, _kMouseEventDown), e.position); + // In relative mouse mode, send button events without position. + // Use _relativeMouse.enabled.value consistently with the guard above. + if (_relativeMouse.enabled.value) { + _relativeMouse + .sendRelativeMouseButton(_getMouseEvent(e, _kMouseEventDown)); + } else { + handleMouse(_getMouseEvent(e, _kMouseEventDown), e.position); + } } } void onPointUpImage(PointerUpEvent e) { if (isDesktop) _queryOtherWindowCoords = false; - if (isViewOnly) return; + if (isViewOnly && !showMyCursor) return; + if (isViewCamera) return; + + if (_relativeMouse.enabled.value) { + _relativeMouse.updatePointerRegionTopLeftGlobal(e); + } + if (e.kind != ui.PointerDeviceKind.mouse) return; if (isPhysicalMouse.value) { - handleMouse(_getMouseEvent(e, _kMouseEventUp), e.position); + // In relative mouse mode, send button events without position. + // Use _relativeMouse.enabled.value consistently with the guard above. + if (_relativeMouse.enabled.value) { + _relativeMouse + .sendRelativeMouseButton(_getMouseEvent(e, _kMouseEventUp)); + } else { + handleMouse(_getMouseEvent(e, _kMouseEventUp), e.position); + } } } void onPointMoveImage(PointerMoveEvent e) { - if (isViewOnly) return; + if (isViewOnly && !showMyCursor) return; + if (isViewCamera) return; if (e.kind != ui.PointerDeviceKind.mouse) return; + + if (_relativeMouse.enabled.value) { + _relativeMouse.updatePointerRegionTopLeftGlobal(e); + } + if (_queryOtherWindowCoords) { Future.delayed(Duration.zero, () async { _windowRect = await fillRemoteCoordsAndGetCurFrame(_remoteWindowCoords); @@ -988,7 +1587,10 @@ class InputModel { _queryOtherWindowCoords = false; } if (isPhysicalMouse.value) { - handleMouse(_getMouseEvent(e, _kMouseEventMove), e.position); + if (!_relativeMouse.handleRelativeMouseMove(e.localPosition)) { + handleMouse(_getMouseEvent(e, _kMouseEventMove), e.position, + edgeScroll: useEdgeScroll); + } } } @@ -1012,20 +1614,53 @@ class InputModel { return null; } + /// Handle scroll/wheel events. + /// Note: Scroll events intentionally use absolute positioning even in relative mouse mode. + /// This is because scroll events don't need relative positioning - they represent + /// scroll deltas that are independent of cursor position. Games and 3D applications + /// handle scroll events the same way regardless of mouse mode. void onPointerSignalImage(PointerSignalEvent e) { if (isViewOnly) return; + if (isViewCamera) return; if (e is PointerScrollEvent) { - var dx = e.scrollDelta.dx.toInt(); - var dy = e.scrollDelta.dy.toInt(); + final rawDx = e.scrollDelta.dx; + final rawDy = e.scrollDelta.dy; + final dominantDelta = rawDx.abs() > rawDy.abs() ? rawDx.abs() : rawDy.abs(); + final isSmooth = dominantDelta < 1; + final nowUs = DateTime.now().microsecondsSinceEpoch; + final dtUs = _lastWheelTsUs == 0 ? 0 : nowUs - _lastWheelTsUs; + _lastWheelTsUs = nowUs; + int accel = 1; + if (!isSmooth && + dtUs > 0 && + dtUs <= _wheelAccelMediumThresholdUs && + (isWindows || isLinux) && + peerPlatform == kPeerPlatformMacOS) { + final velocity = dominantDelta / dtUs; + if (velocity >= _wheelBurstVelocityThreshold) { + if (dtUs < _wheelAccelFastThresholdUs) { + accel = 3; + } else { + accel = 2; + } + } + } + var dx = rawDx.toInt(); + var dy = rawDy.toInt(); + if (rawDx.abs() > rawDy.abs()) { + dy = 0; + } else { + dx = 0; + } if (dx > 0) { - dx = -1; + dx = -accel; } else if (dx < 0) { - dx = 1; + dx = accel; } if (dy > 0) { - dy = -1; + dy = -accel; } else if (dy < 0) { - dy = 1; + dy = accel; } bind.sessionSendMouse( sessionId: sessionId, @@ -1036,7 +1671,7 @@ class InputModel { void refreshMousePos() => handleMouse({ 'buttons': 0, 'type': _kMouseEventMove, - }, lastMousePos); + }, lastMousePos, edgeScroll: useEdgeScroll); void tryMoveEdgeOnExit(Offset pos) => handleMouse( { @@ -1047,7 +1682,7 @@ class InputModel { onExit: true, ); - int trySetNearestRange(int v, int min, int max, int n) { + static double tryGetNearestRange(double v, double min, double max, double n) { if (v < min && v >= min - n) { v = min; } @@ -1087,13 +1722,13 @@ class InputModel { // to-do: handle mouse events late final dynamic evtValue; - if (type == 'pan_update') { + if (type == kMouseEventTypePanUpdate) { evtValue = { 'x': x.toInt(), 'y': y.toInt(), }; } else { - final isMoveTypes = ['pan_start', 'pan_end']; + final isMoveTypes = [kMouseEventTypePanStart, kMouseEventTypePanEnd]; final pos = handlePointerDevicePos( kPointerEventKindTouch, x, @@ -1105,12 +1740,13 @@ class InputModel { return; } evtValue = { - 'x': pos.x, - 'y': pos.y, + 'x': pos.x.toInt(), + 'y': pos.y.toInt(), }; } final evt = PointerEventToRust(kind, type, evtValue).toJson(); + if (isViewCamera) return; bind.sessionSendPointer( sessionId: sessionId, msg: json.encode(modify(evt))); } @@ -1137,36 +1773,39 @@ class InputModel { return false; } - void handleMouse( + Map? processEventToPeer( Map evt, Offset offset, { bool onExit = false, + bool moveCanvas = true, + bool edgeScroll = false, }) { + if (isViewCamera) return null; double x = offset.dx; double y = max(0.0, offset.dy); if (_checkPeerControlProtected(x, y)) { - return; + return null; } - var type = ''; + var type = kMouseEventTypeDefault; var isMove = false; switch (evt['type']) { case _kMouseEventDown: - type = 'down'; + type = kMouseEventTypeDown; break; case _kMouseEventUp: - type = 'up'; + type = kMouseEventTypeUp; break; case _kMouseEventMove: _pointerMovedAfterEnter = true; isMove = true; break; default: - return; + return null; } evt['type'] = type; - if (type == 'down' && !_pointerMovedAfterEnter) { + if (type == kMouseEventTypeDown && !_pointerMovedAfterEnter) { // Move mouse to the position of the down event first. lastMousePos = ui.Offset(x, y); refreshMousePos(); @@ -1180,27 +1819,49 @@ class InputModel { type, onExit: onExit, buttons: evt['buttons'], + moveCanvas: moveCanvas, + edgeScroll: edgeScroll, ); if (pos == null) { - return; + return null; } if (type != '') { evt['x'] = '0'; evt['y'] = '0'; } else { - evt['x'] = '${pos.x}'; - evt['y'] = '${pos.y}'; + evt['x'] = '${pos.x.toInt()}'; + evt['y'] = '${pos.y.toInt()}'; } - Map mapButtons = { - kPrimaryMouseButton: 'left', - kSecondaryMouseButton: 'right', - kMiddleMouseButton: 'wheel', - kBackMouseButton: 'back', - kForwardMouseButton: 'forward' - }; - evt['buttons'] = mapButtons[evt['buttons']] ?? ''; - bind.sessionSendMouse(sessionId: sessionId, msg: json.encode(modify(evt))); + final buttons = evt['buttons']; + if (buttons is int) { + evt['buttons'] = mouseButtonsToPeer(buttons); + } else { + // Log warning if buttons exists but is not an int (unexpected caller). + // Keep empty string fallback for missing buttons to preserve move/hover behavior. + if (buttons != null) { + debugPrint( + '[InputModel] processEventToPeer: unexpected buttons type: ${buttons.runtimeType}, value: $buttons'); + } + evt['buttons'] = ''; + } + return evt; + } + + Map? handleMouse( + Map evt, + Offset offset, { + bool onExit = false, + bool moveCanvas = true, + bool edgeScroll = false, + }) { + final evtToPeer = processEventToPeer(evt, offset, + onExit: onExit, moveCanvas: moveCanvas, edgeScroll: edgeScroll); + if (evtToPeer != null) { + bind.sessionSendMouse( + sessionId: sessionId, msg: json.encode(modify(evtToPeer))); + } + return evtToPeer; } Point? handlePointerDevicePos( @@ -1211,6 +1872,8 @@ class InputModel { String evtType, { bool onExit = false, int buttons = kPrimaryMouseButton, + bool moveCanvas = true, + bool edgeScroll = false, }) { final ffiModel = parent.target!.ffiModel; CanvasCoords canvas = @@ -1227,8 +1890,12 @@ class InputModel { isMove = false; canvas = coords.canvas; rect = coords.remoteRect; - x -= coords.relativeOffset.dx / devicePixelRatio; - y -= coords.relativeOffset.dy / devicePixelRatio; + x -= isWindows + ? coords.relativeOffset.dx / devicePixelRatio + : coords.relativeOffset.dx; + y -= isWindows + ? coords.relativeOffset.dy / devicePixelRatio + : coords.relativeOffset.dy; } } } @@ -1236,7 +1903,15 @@ class InputModel { y -= CanvasModel.topToEdge; x -= CanvasModel.leftToEdge; if (isMove) { - parent.target!.canvasModel.moveDesktopMouse(x, y); + final canvasModel = parent.target!.canvasModel; + + if (edgeScroll) { + canvasModel.edgeScrollMouse(x, y); + } else if (moveCanvas) { + canvasModel.moveDesktopMouse(x, y); + } + + canvasModel.updateLocalCursor(x, y); } return _handlePointerDevicePos( @@ -1253,15 +1928,21 @@ class InputModel { } bool _isInCurrentWindow(double x, double y) { - final w = _windowRect!.width / devicePixelRatio; - final h = _windowRect!.width / devicePixelRatio; + var w = _windowRect!.width; + var h = _windowRect!.height; + if (isWindows) { + w /= devicePixelRatio; + h /= devicePixelRatio; + } return x >= 0 && y >= 0 && x <= w && y <= h; } static RemoteWindowCoords? findRemoteCoords(double x, double y, List remoteWindowCoords, double devicePixelRatio) { - x *= devicePixelRatio; - y *= devicePixelRatio; + if (isWindows) { + x *= devicePixelRatio; + y *= devicePixelRatio; + } for (final c in remoteWindowCoords) { if (x >= c.relativeOffset.dx && y >= c.relativeOffset.dy && @@ -1293,7 +1974,7 @@ class InputModel { var nearBottom = (canvas.size.height - y) < nearThr; final imageWidth = rect.width * canvas.scale; final imageHeight = rect.height * canvas.scale; - if (canvas.scrollStyle == ScrollStyle.scrollbar) { + if (canvas.scrollStyle != ScrollStyle.scrollauto) { x += imageWidth * canvas.scrollX; y += imageHeight * canvas.scrollY; @@ -1329,33 +2010,56 @@ class InputModel { y = pos.dy; } - var evtX = 0; - var evtY = 0; - try { - evtX = x.round(); - evtY = y.round(); - } catch (e) { - debugPrintStack(label: 'canvas.scale value ${canvas.scale}, $e'); - return null; - } + return InputModel.getPointInRemoteRect( + true, peerPlatform, kind, evtType, x, y, rect, + buttons: buttons); + } - int minX = rect.left.toInt(); + static Point? getPointInRemoteRect( + bool isLocalDesktop, + String? peerPlatform, + String kind, + String evtType, + double evtX, + double evtY, + Rect rect, + {int buttons = kPrimaryMouseButton}) { + double minX = rect.left; // https://github.com/rustdesk/rustdesk/issues/6678 // For Windows, [0,maxX], [0,maxY] should be set to enable window snapping. - int maxX = (rect.left + rect.width).toInt() - + double maxX = (rect.left + rect.width) - (peerPlatform == kPeerPlatformWindows ? 0 : 1); - int minY = rect.top.toInt(); - int maxY = (rect.top + rect.height).toInt() - + double minY = rect.top; + double maxY = (rect.top + rect.height) - (peerPlatform == kPeerPlatformWindows ? 0 : 1); - evtX = trySetNearestRange(evtX, minX, maxX, 5); - evtY = trySetNearestRange(evtY, minY, maxY, 5); - if (kind == kPointerEventKindMouse) { - if (evtX < minX || evtY < minY || evtX > maxX || evtY > maxY) { - // If left mouse up, no early return. - if (!(buttons == kPrimaryMouseButton && evtType == 'up')) { - return null; + evtX = InputModel.tryGetNearestRange(evtX, minX, maxX, 5); + evtY = InputModel.tryGetNearestRange(evtY, minY, maxY, 5); + if (isLocalDesktop) { + if (kind == kPointerEventKindMouse) { + if (evtX < minX || evtY < minY || evtX > maxX || evtY > maxY) { + // If left mouse up, no early return. + if (!(buttons == kPrimaryMouseButton && + evtType == kMouseEventTypeUp)) { + return null; + } } } + } else { + bool evtXInRange = evtX >= minX && evtX <= maxX; + bool evtYInRange = evtY >= minY && evtY <= maxY; + if (!(evtXInRange || evtYInRange)) { + return null; + } + if (evtX < minX) { + evtX = minX; + } else if (evtX > maxX) { + evtX = maxX; + } + if (evtY < minY) { + evtY = minY; + } else if (evtY > maxY) { + evtY = maxY; + } } return Point(evtX, evtY); @@ -1370,7 +2074,18 @@ class InputModel { } } - void onMobileBack() => tap(MouseButtons.right); + void onMobileBack() { + final minBackButtonVersion = "1.3.8"; + final peerVersion = + parent.target?.ffiModel.pi.version ?? minBackButtonVersion; + var btn = MouseButtons.back; + // For compatibility with old versions + if (versionCmp(peerVersion, minBackButtonVersion) < 0) { + btn = MouseButtons.right; + } + tap(btn); + } + void onMobileHome() => tap(MouseButtons.wheel); Future onMobileApps() async { sendMouse('down', MouseButtons.wheel); @@ -1381,9 +2096,9 @@ class InputModel { // Simulate a key press event. // `usbHidUsage` is the USB HID usage code of the key. Future tapHidKey(int usbHidUsage) async { - newKeyboardMode(kKeyFlutterKey, usbHidUsage, true); + newKeyboardMode(kKeyFlutterKey, usbHidUsage, true, false); await Future.delayed(Duration(milliseconds: 100)); - newKeyboardMode(kKeyFlutterKey, usbHidUsage, false); + newKeyboardMode(kKeyFlutterKey, usbHidUsage, false, false); } Future onMobileVolumeUp() async => diff --git a/flutter/lib/models/input_modifier_utils.dart b/flutter/lib/models/input_modifier_utils.dart new file mode 100644 index 000000000..e65c32790 --- /dev/null +++ b/flutter/lib/models/input_modifier_utils.dart @@ -0,0 +1,38 @@ +import 'package:flutter/services.dart'; + +/// Returns true when a stale mobile one-shot Shift state should be released +/// by replaying a tracked Shift key-down as a synthesized key-up. +/// +/// This is only valid on mobile when Flutter's cached Shift state is still on +/// (`cachedShiftPressed == true`) but the current hardware/raw event reports +/// Shift as off (`actualShiftPressed == false`). +/// +/// A tracked Shift key-down is required so the caller can safely synthesize the +/// matching key-up. Both `shiftLeft` and `shiftRight` are excluded because the +/// Shift key event itself must be processed first; otherwise we could release +/// the tracked key while still handling the original Shift press/release. +/// Callers should evaluate this only after their cached modifier state has been +/// updated for the current event. +/// +/// When this returns true, the caller logs a line like: +/// `input: releasing stale mobile Shift before replaying tracked raw key-up` +/// immediately before calling `_releaseTrackedRawShiftKeyEventIfNeeded()`. +bool shouldReleaseStaleMobileShift({ + required bool isMobile, + required bool cachedShiftPressed, + required bool actualShiftPressed, + required LogicalKeyboardKey logicalKey, + required bool hasTrackedShiftKeyDown, +}) { + if (!isMobile || !cachedShiftPressed || actualShiftPressed) { + return false; + } + if (!hasTrackedShiftKeyDown) { + return false; + } + if (logicalKey == LogicalKeyboardKey.shiftLeft || + logicalKey == LogicalKeyboardKey.shiftRight) { + return false; + } + return true; +} diff --git a/flutter/lib/models/model.dart b/flutter/lib/models/model.dart index ecbfd6fa4..e94834a2b 100644 --- a/flutter/lib/models/model.dart +++ b/flutter/lib/models/model.dart @@ -6,8 +6,10 @@ import 'dart:ui' as ui; import 'package:bot_toast/bot_toast.dart'; import 'package:desktop_multi_window/desktop_multi_window.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter/scheduler.dart'; import 'package:flutter_hbb/common/widgets/peers_view.dart'; import 'package:flutter_hbb/consts.dart'; import 'package:flutter_hbb/models/ab_model.dart'; @@ -17,27 +19,33 @@ import 'package:flutter_hbb/models/file_model.dart'; import 'package:flutter_hbb/models/group_model.dart'; import 'package:flutter_hbb/models/peer_model.dart'; import 'package:flutter_hbb/models/peer_tab_model.dart'; +import 'package:flutter_hbb/models/printer_model.dart'; import 'package:flutter_hbb/models/server_model.dart'; 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; import 'package:tuple/tuple.dart'; import 'package:image/image.dart' as img2; import 'package:flutter_svg/flutter_svg.dart'; import 'package:get/get.dart'; import 'package:uuid/uuid.dart'; import 'package:window_manager/window_manager.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:vector_math/vector_math.dart' show Vector2; import '../common.dart'; import '../utils/image.dart' as img; import '../common/widgets/dialog.dart'; import 'input_model.dart'; import 'platform_model.dart'; +import 'package:flutter_hbb/utils/scale.dart'; import 'package:flutter_hbb/generated_bridge.dart' if (dart.library.html) 'package:flutter_hbb/web/bridge.dart'; @@ -57,6 +65,7 @@ class CachedPeerData { bool secure = false; bool direct = false; + String streamType = ''; CachedPeerData(); @@ -70,6 +79,7 @@ class CachedPeerData { 'permissions': permissions, 'secure': secure, 'direct': direct, + 'streamType': streamType, }); } @@ -88,6 +98,7 @@ class CachedPeerData { }); data.secure = map['secure']; data.direct = map['direct']; + data.streamType = map['streamType']; return data; } catch (e) { debugPrint('Failed to parse CachedPeerData: $e'); @@ -106,9 +117,12 @@ class FfiModel with ChangeNotifier { bool? _secure; bool? _direct; bool _touchMode = false; + late VirtualMouseMode virtualMouseMode; Timer? _timer; var _reconnects = 1; + DateTime? _offlineReconnectStartTime; bool _viewOnly = false; + bool _showMyCursor = false; WeakReference parent; late final SessionID sessionId; @@ -117,6 +131,8 @@ class FfiModel with ChangeNotifier { RxBool waitForFirstImage = true.obs; bool isRefreshing = false; + Timer? timerScreenshot; + Rect? get rect => _rect; bool get isOriginalResolutionSet => _pi.tryGetDisplayIfNotAllDisplay()?.isOriginalResolutionSet ?? false; @@ -142,8 +158,12 @@ class FfiModel with ChangeNotifier { bool get touchMode => _touchMode; bool get isPeerAndroid => _pi.platform == kPeerPlatformAndroid; + bool get isPeerMobile => isPeerAndroid; + + bool get isPeerLinux => _pi.platform == kPeerPlatformLinux; bool get viewOnly => _viewOnly; + bool get showMyCursor => _showMyCursor; set inputBlocked(v) { _inputBlocked = v; @@ -153,6 +173,7 @@ class FfiModel with ChangeNotifier { clear(); sessionId = parent.target!.sessionId; cachedPeerData.permissions = _permissions; + virtualMouseMode = VirtualMouseMode(this); } Rect? globalDisplaysRect() => _getDisplaysRect(_pi.displays, true); @@ -161,6 +182,9 @@ class FfiModel with ChangeNotifier { if (displays.isEmpty) { return null; } + if (isPeerLinux) { + useDisplayScale = true; + } int scale(int len, double s) { if (useDisplayScale) { return len.toDouble() ~/ s; @@ -190,6 +214,9 @@ class FfiModel with ChangeNotifier { } updatePermission(Map evt, String id) { + // Track previous keyboard permission to detect revocation. + final hadKeyboardPerm = _permissions['keyboard'] != false; + evt.forEach((k, v) { if (k == 'name' || k.isEmpty) return; _permissions[k] = v == 'true'; @@ -198,6 +225,18 @@ class FfiModel with ChangeNotifier { if (parent.target?.connType == ConnType.defaultConn) { KeyboardEnabledState.find(id).value = _permissions['keyboard'] != false; } + + // If keyboard permission was revoked while relative mouse mode is active, + // forcefully disable relative mouse mode to prevent the user from being trapped. + final hasKeyboardPerm = _permissions['keyboard'] != false; + if (hadKeyboardPerm && !hasKeyboardPerm) { + final inputModel = parent.target?.inputModel; + if (inputModel != null && inputModel.relativeMouseMode.value) { + inputModel.setRelativeMouseMode(false); + showToast(translate('rel-mouse-permission-lost-tip')); + } + } + debugPrint('updatePermission: $_permissions'); notifyListeners(); } @@ -213,29 +252,48 @@ class FfiModel with ChangeNotifier { _timer = null; clearPermissions(); waitForImageTimer?.cancel(); + timerScreenshot?.cancel(); } - setConnectionType(String peerId, bool secure, bool direct) { + setConnectionType( + String peerId, bool secure, bool direct, String streamType) { cachedPeerData.secure = secure; cachedPeerData.direct = direct; + cachedPeerData.streamType = streamType; _secure = secure; _direct = direct; try { var connectionType = ConnectionTypeState.find(peerId); connectionType.setSecure(secure); connectionType.setDirect(direct); + connectionType.setStreamType(streamType); } catch (e) { // } } - Widget? getConnectionImage() { + Widget? getConnectionImageText() { if (secure == null || direct == null) { return null; } else { final icon = '${secure == true ? 'secure' : 'insecure'}${direct == true ? '' : '_relay'}'; - return SvgPicture.asset('assets/$icon.svg', width: 48, height: 48); + final iconWidget = + SvgPicture.asset('assets/$icon.svg', width: 48, height: 48); + String connectionText = + getConnectionText(secure!, direct!, cachedPeerData.streamType); + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + iconWidget, + SizedBox(height: 4), + Text( + connectionText, + style: TextStyle(fontSize: 12), + textAlign: TextAlign.center, + ), + ], + ); } } @@ -252,7 +310,7 @@ class FfiModel with ChangeNotifier { 'link': '', }, sessionId, peerId); updatePrivacyMode(data.updatePrivacyMode, sessionId, peerId); - setConnectionType(peerId, data.secure, data.direct); + setConnectionType(peerId, data.secure, data.direct, data.streamType); await handlePeerInfo(data.peerInfo, peerId, true); for (final element in data.cursorDataList) { updateLastCursorId(element); @@ -281,8 +339,8 @@ class FfiModel with ChangeNotifier { } else if (name == 'sync_platform_additions') { handlePlatformAdditions(evt, sessionId, peerId); } else if (name == 'connection_ready') { - setConnectionType( - peerId, evt['secure'] == 'true', evt['direct'] == 'true'); + setConnectionType(peerId, evt['secure'] == 'true', + evt['direct'] == 'true', evt['stream_type'] ?? ''); } else if (name == 'switch_display') { // switch display is kept for backward compatibility handleSwitchDisplay(evt, sessionId, peerId); @@ -304,8 +362,12 @@ class FfiModel with ChangeNotifier { } else if (name == 'chat_server_mode') { parent.target?.chatModel .receive(int.parse(evt['id'] as String), evt['text'] ?? ''); + } else if (name == 'terminal_response') { + parent.target?.routeTerminalResponse(evt); } else if (name == 'file_dir') { parent.target?.fileModel.receiveFileDir(evt); + } else if (name == 'empty_dirs') { + parent.target?.fileModel.receiveEmptyDirs(evt); } else if (name == 'job_progress') { parent.target?.fileModel.jobController.tryUpdateJobProgress(evt); } else if (name == 'job_done') { @@ -317,7 +379,7 @@ class FfiModel with ChangeNotifier { parent.target?.fileModel.refreshAll(); } } else if (name == 'job_error') { - parent.target?.fileModel.jobController.jobError(evt); + parent.target?.fileModel.handleJobError(evt); } else if (name == 'override_file_confirm') { parent.target?.fileModel.postOverrideFileConfirm(evt); } else if (name == 'load_last_job') { @@ -397,16 +459,269 @@ class FfiModel with ChangeNotifier { if (isWeb) { parent.target?.fileModel.onSelectedFiles(evt); } + } else if (name == "send_emptry_dirs") { + if (isWeb) { + parent.target?.fileModel.sendEmptyDirs(evt); + } } else if (name == "record_status") { - if (desktopType == DesktopType.remote || isMobile) { + if (desktopType == DesktopType.remote || + desktopType == DesktopType.viewCamera || + isMobile) { parent.target?.recordingModel.updateStatus(evt['start'] == 'true'); } + } else if (name == "printer_request") { + _handlePrinterRequest(evt, sessionId, peerId); + } else if (name == 'screenshot') { + _handleScreenshot(evt, sessionId, peerId); + } else if (name == 'exit_relative_mouse_mode') { + // Handle exit shortcut from rdev grab loop (Ctrl+Alt on Win/Linux, Cmd+G on macOS) + parent.target?.inputModel.exitRelativeMouseModeWithKeyRelease(); } else { debugPrint('Event is not handled in the fixed branch: $name'); } }; } + _handleScreenshot( + Map evt, SessionID sessionId, String peerId) { + timerScreenshot?.cancel(); + timerScreenshot = null; + final msg = evt['msg'] ?? ''; + final msgBoxType = 'custom-nook-nocancel-hasclose'; + final msgBoxTitle = 'Take screenshot'; + final dialogManager = parent.target!.dialogManager; + if (msg.isNotEmpty) { + msgBox(sessionId, msgBoxType, msgBoxTitle, msg, '', dialogManager); + } else { + final msgBoxText = 'screenshot-action-tip'; + + close() { + dialogManager.dismissAll(); + } + + saveAs() { + close(); + Future.delayed(Duration.zero, () async { + final ts = DateTime.now().millisecondsSinceEpoch ~/ 1000; + String? outputFile = await FilePicker.platform.saveFile( + dialogTitle: '${translate('Save as')}...', + fileName: 'screenshot_$ts.png', + allowedExtensions: ['png'], + type: FileType.custom, + ); + if (outputFile == null) { + bind.sessionHandleScreenshot(sessionId: sessionId, action: '2'); + } else { + final res = await bind.sessionHandleScreenshot( + sessionId: sessionId, action: '0:$outputFile'); + if (res.isNotEmpty) { + msgBox(sessionId, 'custom-nook-nocancel-hasclose-error', + 'Take screenshot', res, '', dialogManager); + } + } + }); + } + + copyToClipboard() { + bind.sessionHandleScreenshot(sessionId: sessionId, action: '1'); + close(); + } + + cancel() { + bind.sessionHandleScreenshot(sessionId: sessionId, action: '2'); + close(); + } + + final List buttons = [ + dialogButton('${translate('Save as')}...', onPressed: saveAs), + dialogButton('Copy to clipboard', onPressed: copyToClipboard), + dialogButton('Cancel', onPressed: cancel), + ]; + dialogManager.dismissAll(); + dialogManager.show( + (setState, close, context) => CustomAlertDialog( + title: null, + content: SelectionArea( + child: msgboxContent(msgBoxType, msgBoxTitle, msgBoxText)), + actions: buttons, + ), + tag: '$msgBoxType-$msgBoxTitle-$msgBoxTitle', + ); + } + } + + _handlePrinterRequest( + Map evt, SessionID sessionId, String peerId) { + final id = evt['id']; + final path = evt['path']; + final dialogManager = parent.target!.dialogManager; + dialogManager.show((setState, close, context) { + PrinterOptions printerOptions = PrinterOptions.load(); + final saveSettings = mainGetLocalBoolOptionSync(kKeyPrinterSave).obs; + final dontShowAgain = false.obs; + final Rx selectedPrinterName = printerOptions.printerName.obs; + final printerNames = printerOptions.printerNames; + final defaultOrSelectedGroupValue = + (printerOptions.action == kValuePrinterIncomingJobDismiss + ? kValuePrinterIncomingJobDefault + : printerOptions.action) + .obs; + + onRatioChanged(String? value) { + defaultOrSelectedGroupValue.value = + value ?? kValuePrinterIncomingJobDefault; + } + + onSubmit() { + final printerName = defaultOrSelectedGroupValue.isEmpty + ? '' + : selectedPrinterName.value; + bind.sessionPrinterResponse( + sessionId: sessionId, id: id, path: path, printerName: printerName); + if (saveSettings.value || dontShowAgain.value) { + bind.mainSetLocalOption(key: kKeyPrinterSelected, value: printerName); + bind.mainSetLocalOption( + key: kKeyPrinterIncomingJobAction, + value: defaultOrSelectedGroupValue.value); + } + if (dontShowAgain.value) { + mainSetLocalBoolOption(kKeyPrinterAllowAutoPrint, true); + } + close(); + } + + onCancel() { + if (dontShowAgain.value) { + bind.mainSetLocalOption( + key: kKeyPrinterIncomingJobAction, + value: kValuePrinterIncomingJobDismiss); + } + close(); + } + + final printerItemHeight = 30.0; + final selectionAreaHeight = + printerItemHeight * min(8.0, max(printerNames.length, 3.0)); + final content = Column( + children: [ + Text(translate('print-incoming-job-confirm-tip')), + Row( + children: [ + Obx(() => Radio( + value: kValuePrinterIncomingJobDefault, + groupValue: defaultOrSelectedGroupValue.value, + onChanged: onRatioChanged)), + GestureDetector( + child: Text(translate('use-the-default-printer-tip')), + onTap: () => onRatioChanged(kValuePrinterIncomingJobDefault)), + ], + ), + Column( + children: [ + Row(children: [ + Obx(() => Radio( + value: kValuePrinterIncomingJobSelected, + groupValue: defaultOrSelectedGroupValue.value, + onChanged: onRatioChanged)), + GestureDetector( + child: Text(translate('use-the-selected-printer-tip')), + onTap: () => + onRatioChanged(kValuePrinterIncomingJobSelected)), + ]), + SizedBox( + height: selectionAreaHeight, + width: 500, + child: ListView.builder( + itemBuilder: (context, index) { + return Obx(() => GestureDetector( + child: Container( + decoration: BoxDecoration( + color: selectedPrinterName.value == + printerNames[index] + ? (defaultOrSelectedGroupValue.value == + kValuePrinterIncomingJobSelected + ? MyTheme.button + : MyTheme.button.withOpacity(0.5)) + : Theme.of(context).cardColor, + borderRadius: BorderRadius.all( + Radius.circular(5.0), + ), + ), + key: ValueKey(printerNames[index]), + height: printerItemHeight, + child: Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.only(left: 10.0), + child: Text( + printerNames[index], + style: TextStyle(fontSize: 14), + ), + ), + ), + ), + onTap: defaultOrSelectedGroupValue.value == + kValuePrinterIncomingJobSelected + ? () { + selectedPrinterName.value = + printerNames[index]; + } + : null, + )); + }, + itemCount: printerNames.length), + ), + ], + ), + Row( + children: [ + Obx(() => Checkbox( + value: saveSettings.value, + onChanged: (value) { + if (value != null) { + saveSettings.value = value; + mainSetLocalBoolOption(kKeyPrinterSave, value); + } + })), + GestureDetector( + child: Text(translate('save-settings-tip')), + onTap: () { + saveSettings.value = !saveSettings.value; + mainSetLocalBoolOption(kKeyPrinterSave, saveSettings.value); + }), + ], + ), + Row( + children: [ + Obx(() => Checkbox( + value: dontShowAgain.value, + onChanged: (value) { + if (value != null) { + dontShowAgain.value = value; + } + })), + GestureDetector( + child: Text(translate('dont-show-again-tip')), + onTap: () { + dontShowAgain.value = !dontShowAgain.value; + }), + ], + ), + ], + ); + return CustomAlertDialog( + title: Text(translate('Incoming Print Job')), + content: content, + actions: [ + dialogButton('OK', onPressed: onSubmit), + dialogButton('Cancel', onPressed: onCancel), + ], + onSubmit: onSubmit, + onCancel: onCancel, + ); + }); + } + _handleUseTextureRender( Map evt, SessionID sessionId, String peerId) { parent.target?.imageModel.setUseTextureRender(evt['v'] == 'Y'); @@ -469,7 +784,8 @@ class FfiModel with ChangeNotifier { } } - updateCurDisplay(SessionID sessionId, {updateCursorPos = false}) { + Future updateCurDisplay(SessionID sessionId, + {updateCursorPos = false}) async { final newRect = displaysRect(); if (newRect == null) { return; @@ -481,9 +797,19 @@ class FfiModel with ChangeNotifier { updateCursorPos: updateCursorPos); } _rect = newRect; - parent.target?.canvasModel + // Await updateViewStyle to ensure view geometry is fully updated before + // updating pointer lock center. This prevents stale center calculations. + await parent.target?.canvasModel .updateViewStyle(refreshMousePos: updateCursorPos); _updateSessionWidthHeight(sessionId); + + // Keep pointer lock center in sync when using relative mouse mode. + // Note: updatePointerLockCenter is async-safe (handles errors internally), + // so we fire-and-forget here. + final inputModel = parent.target?.inputModel; + if (inputModel != null && inputModel.relativeMouseMode.value) { + inputModel.updatePointerLockCenter(); + } } } @@ -492,7 +818,9 @@ class FfiModel with ChangeNotifier { final display = int.parse(evt['display']); if (_pi.currentDisplay != kAllDisplayValue) { - if (bind.peerGetDefaultSessionsCount(id: peerId) > 1) { + if (bind.peerGetSessionsCount( + id: peerId, connType: parent.target!.connType.index) > + 1) { if (display != _pi.currentDisplay) { return; } @@ -565,6 +893,17 @@ class FfiModel with ChangeNotifier { final title = evt['title']; final text = evt['text']; final link = evt['link']; + + // 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. + if (title == 'Connection Error' || + type == 'error' || + type == 'restarting' || + (type is String && type.contains('error'))) { + parent.target?.inputModel.setRelativeMouseMode(false); + } + if (type == 're-input-password') { wrongPasswordDialog(sessionId, dialogManager, type, title, text); } else if (type == 'input-2fa') { @@ -572,10 +911,16 @@ class FfiModel with ChangeNotifier { } else if (type == 'input-password') { enterPasswordDialog(sessionId, dialogManager); } else if (type == 'session-login' || type == 'session-re-login') { - enterUserLoginDialog(sessionId, dialogManager); - } else if (type == 'session-login-password' || - type == 'session-login-password') { - enterUserLoginAndPasswordDialog(sessionId, dialogManager); + 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); + } else if (type == 'terminal-admin-login-password') { + enterUserLoginAndPasswordDialog( + sessionId, dialogManager, 'terminal-admin-login-tip', false); } else if (type == 'restarting') { showMsgBox(sessionId, type, title, text, link, false, dialogManager, hasCancel: false); @@ -596,11 +941,46 @@ class FfiModel with ChangeNotifier { showPrivacyFailedDialog( sessionId, type, title, text, link, hasRetry, dialogManager); } else { - final hasRetry = evt['hasRetry'] == 'true'; + var hasRetry = evt['hasRetry'] == 'true'; + if (!hasRetry) { + hasRetry = shouldAutoRetryOnOffline(type, title, text); + } showMsgBox(sessionId, type, title, text, link, hasRetry, dialogManager); } } + /// Auto-retry check for "Remote desktop is offline" error. + /// returns true to auto-retry, false otherwise. + bool shouldAutoRetryOnOffline( + String type, + String title, + String text, + ) { + if (type == 'error' && + title == 'Connection Error' && + text == 'Remote desktop is offline' && + _pi.isSet.isTrue) { + // Auto retry for ~30s (server's peer offline threshold) when controlled peer's account changes + // (e.g., signout, switch user, login into OS) causes temporary offline via websocket/tcp connection. + // The actual wait may exceed 30s (e.g., 20s elapsed + 16s next retry = 36s), which is acceptable + // since the controlled side reconnects quickly after account changes. + // Uses time-based check instead of _reconnects count because user can manually retry. + // https://github.com/rustdesk/rustdesk/discussions/14048 + if (_offlineReconnectStartTime == null) { + // First offline, record time and start retry + _offlineReconnectStartTime = DateTime.now(); + return true; + } else { + final elapsed = + DateTime.now().difference(_offlineReconnectStartTime!).inSeconds; + if (elapsed < 30) { + return true; + } + } + } + return false; + } + handleToast(Map evt, SessionID sessionId, String peerId) { final type = evt['type'] ?? 'info'; final text = evt['text'] ?? ''; @@ -635,11 +1015,33 @@ class FfiModel with ChangeNotifier { /// Show a message box with [type], [title] and [text]. showMsgBox(SessionID sessionId, String type, String title, String text, String link, bool hasRetry, OverlayDialogManager dialogManager, - {bool? hasCancel}) { - msgBox(sessionId, type, title, text, link, dialogManager, - hasCancel: hasCancel, - reconnect: hasRetry ? reconnect : null, - reconnectTimeout: hasRetry ? _reconnects : null); + {bool? hasCancel}) async { + final noteAllowed = parent.target != null && + allowAskForNoteAtEndOfConnection(parent.target, false) && + (title == "Connection Error" || type == "restarting"); + final showNoteEdit = noteAllowed && !hasRetry; + if (showNoteEdit) { + await showConnEndAuditDialogCloseCanceled( + ffi: parent.target!, type: type, title: title, text: text); + closeConnection(); + } else { + VoidCallback? onSubmit; + if (noteAllowed && hasRetry) { + final ffi = parent.target!; + onSubmit = () async { + _timer?.cancel(); + _timer = null; + await showConnEndAuditDialogCloseCanceled( + ffi: ffi, type: type, title: title, text: text); + closeConnection(); + }; + } + msgBox(sessionId, type, title, text, link, dialogManager, + hasCancel: hasCancel, + reconnect: hasRetry ? reconnect : null, + reconnectTimeout: hasRetry ? _reconnects : null, + onSubmit: onSubmit); + } _timer?.cancel(); if (hasRetry) { _timer = Timer(Duration(seconds: _reconnects), () { @@ -648,11 +1050,14 @@ class FfiModel with ChangeNotifier { _reconnects *= 2; } else { _reconnects = 1; + _offlineReconnectStartTime = null; } } void reconnect(OverlayDialogManager dialogManager, SessionID sessionId, bool forceRelay) { + // Disable relative mouse mode before reconnecting to ensure cursor is released. + parent.target?.inputModel.setRelativeMouseMode(false); bind.sessionReconnect(sessionId: sessionId, forceRelay: forceRelay); clearPermissions(); dialogManager.dismissAll(); @@ -660,8 +1065,30 @@ class FfiModel with ChangeNotifier { onCancel: closeConnection); } - void showRelayHintDialog(SessionID sessionId, String type, String title, - String text, OverlayDialogManager dialogManager, String peerId) { + Future showRelayHintDialog( + SessionID sessionId, + String type, + String title, + String text, + OverlayDialogManager dialogManager, + String peerId) async { + var hint = "\n\n${translate('relay_hint_tip')}"; + if (text.contains("10054") || text.contains("104")) { + hint = ""; + } + final text2 = "${translate(text)}$hint"; + + if (parent.target != null && + allowAskForNoteAtEndOfConnection(parent.target, false) && + pi.isSet.isTrue) { + if (await showConnEndAuditDialogCloseCanceled( + ffi: parent.target!, type: type, title: title, text: text2)) { + return; + } + closeConnection(); + return; + } + dialogManager.show(tag: '$sessionId-$type', (setState, close, context) { onClose() { closeConnection(); @@ -670,13 +1097,10 @@ class FfiModel with ChangeNotifier { final style = ElevatedButton.styleFrom(backgroundColor: Colors.green[700]); - var hint = "\n\n${translate('relay_hint_tip')}"; - if (text.contains("10054") || text.contains("104")) { - hint = ""; - } + return CustomAlertDialog( title: null, - content: msgboxContent(type, title, "${translate(text)}$hint"), + content: msgboxContent(type, title, text2), actions: [ dialogButton('Close', onPressed: onClose, isOutline: true), if (type == 'relay-hint') @@ -730,17 +1154,12 @@ class FfiModel with ChangeNotifier { String link, bool hasRetry, OverlayDialogManager dialogManager) { - if (text == 'no_need_privacy_mode_no_physical_displays_tip' || - text == 'Enter privacy mode') { - // There are display changes on the remote side, - // which will cause some messages to refresh the canvas and dismiss dialogs. - // So we add a delay here to ensure the dialog is displayed. - Future.delayed(Duration(milliseconds: 3000), () { - showMsgBox(sessionId, type, title, text, link, hasRetry, dialogManager); - }); - } else { + // There are display changes on the remote side, + // which will cause some messages to refresh the canvas and dismiss dialogs. + // So we add a delay here to ensure the dialog is displayed. + Future.delayed(Duration(milliseconds: 3000), () { showMsgBox(sessionId, type, title, text, link, hasRetry, dialogManager); - } + }); } _updateSessionWidthHeight(SessionID sessionId) { @@ -755,28 +1174,114 @@ class FfiModel with ChangeNotifier { sessionId: sessionId, display: pi.currentDisplay == kAllDisplayValue ? 0 : pi.currentDisplay, - width: _rect!.width.toInt(), - height: _rect!.height.toInt(), + width: displays[0].width, + height: displays[0].height, ); } else { for (int i = 0; i < displays.length; ++i) { bind.sessionSetSize( sessionId: sessionId, display: i, - width: displays[i].width.toInt(), - height: displays[i].height.toInt(), + width: displays[i].width, + height: displays[i].height, ); } } } } + void _queryAuditGuid(String peerId) async { + try { + if (bind.isDisableAccount()) { + return; + } + if (bind + .sessionGetAuditServerSync(sessionId: sessionId, typ: "conn/active") + .isEmpty) { + return; + } + if (!mainGetLocalBoolOptionSync( + kOptionAllowAskForNoteAtEndOfConnection)) { + return; + } + if (bind.sessionGetAuditGuid(sessionId: sessionId).isNotEmpty) { + debugPrint('Get cached audit GUID'); + return; + } + final url = bind.sessionGetAuditServerSync( + sessionId: sessionId, typ: "conn/active"); + if (url.isEmpty) { + return; + } + final initialConnSessionId = + bind.sessionGetConnSessionId(sessionId: sessionId); + final connType = switch (parent.target?.connType) { + ConnType.defaultConn => 0, + ConnType.fileTransfer => 1, + ConnType.portForward => 2, + ConnType.rdp => 2, + ConnType.viewCamera => 3, + ConnType.terminal => 4, + _ => 0, + }; + + const retryIntervals = [1, 1, 2, 2, 3, 3]; + + for (int attempt = 1; attempt <= retryIntervals.length; attempt++) { + final currentConnSessionId = + bind.sessionGetConnSessionId(sessionId: sessionId); + if (currentConnSessionId != initialConnSessionId) { + debugPrint('connSessionId changed, stopping audit GUID query'); + return; + } + + final fullUrl = + '$url?id=$peerId&session_id=$currentConnSessionId&conn_type=$connType'; + + debugPrint( + 'Querying audit GUID, attempt $attempt/${retryIntervals.length}'); + try { + var headers = getHttpHeaders(); + headers['Content-Type'] = "application/json"; + + final response = await http.get( + Uri.parse(fullUrl), + headers: headers, + ); + + if (response.statusCode == 200) { + final guid = jsonDecode(response.body) as String?; + if (guid != null && guid.isNotEmpty) { + bind.sessionSetAuditGuid(sessionId: sessionId, guid: guid); + debugPrint('Successfully retrieved audit GUID'); + return; + } + } else { + debugPrint( + 'Failed to query audit GUID. Status: ${response.statusCode}, Body: ${response.body}'); + return; + } + } catch (e) { + debugPrint('Error querying audit GUID (attempt $attempt): $e'); + } + + if (attempt < retryIntervals.length) { + await Future.delayed(Duration(seconds: retryIntervals[attempt - 1])); + } + } + + debugPrint( + 'Failed to retrieve audit GUID after ${retryIntervals.length} attempts'); + } catch (e) { + debugPrint('Error in _queryAuditGuid: $e'); + } + } + /// Handle the peer info event based on [evt]. handlePeerInfo(Map evt, String peerId, bool isCache) async { parent.target?.chatModel.voiceCallStatus.value = VoiceCallStatus.notStarted; - // This call is to ensuer the keyboard mode is updated depending on the peer version. - parent.target?.inputModel.updateKeyboardMode(); + _queryAuditGuid(peerId); // Map clone is required here, otherwise "evt" may be changed by other threads through the reference. // Because this function is asynchronous, there's an "await" in this function. @@ -789,6 +1294,17 @@ class FfiModel with ChangeNotifier { parent.target?.dialogManager.dismissAll(); _pi.version = evt['version']; + // Note: Relative mouse mode is NOT auto-enabled on connect. + // Users must manually enable it via toolbar or keyboard shortcut (Ctrl+Alt+Shift+M). + // + // For desktop/webDesktop, keyboard mode initialization is handled later by + // checkDesktopKeyboardMode() which may change the mode if not supported, + // followed by updateKeyboardMode() to sync InputModel.keyboardMode. + // For mobile, updateKeyboardMode() is currently a no-op (only executes on desktop/web), + // but we call it here for consistency and future-proofing. + if (isMobile) { + parent.target?.inputModel.updateKeyboardMode(); + } _pi.isSupportMultiUiSession = bind.isSupportMultiUiSession(version: _pi.version); _pi.username = evt['username']; @@ -800,7 +1316,9 @@ class FfiModel with ChangeNotifier { _pi.primaryDisplay = currentDisplay; } - if (bind.peerGetDefaultSessionsCount(id: peerId) <= 1) { + if (bind.peerGetSessionsCount( + id: peerId, connType: parent.target!.connType.index) <= + 1) { _pi.currentDisplay = currentDisplay; } @@ -814,13 +1332,34 @@ class FfiModel with ChangeNotifier { if (isPeerAndroid) { _touchMode = true; } else { - _touchMode = await bind.sessionGetOption( - sessionId: sessionId, arg: kOptionTouchMode) != - ''; + // `kOptionTouchMode` is originally peer option, but it is moved to local option later. + // We check local option first, if not set, then check peer option. + // Because if local option is not empty: + // 1. User has set the touch mode explicitly. + // 2. The advanced option (custom client) is set. + // Then we choose to use the local option. + final optLocal = bind.mainGetLocalOption(key: kOptionTouchMode); + if (optLocal != '') { + _touchMode = optLocal == 'Y'; + } else { + final optSession = await bind.sessionGetOption( + sessionId: sessionId, arg: kOptionTouchMode); + _touchMode = optSession != ''; + } + } + if (isMobile) { + virtualMouseMode.loadOptions(); } if (connType == ConnType.fileTransfer) { parent.target?.fileModel.onReady(); - } else if (connType == ConnType.defaultConn) { + } else if (connType == ConnType.terminal) { + // Call onReady on all registered terminal models + final models = parent.target?._terminalModels.values ?? []; + for (final model in models) { + model.onReady(); + } + } else if (connType == ConnType.defaultConn || + connType == ConnType.viewCamera) { List newDisplays = []; List displays = json.decode(evt['displays']); for (int i = 0; i < displays.length; ++i) { @@ -834,6 +1373,7 @@ class FfiModel with ChangeNotifier { } if (displays.isNotEmpty) { _reconnects = 1; + _offlineReconnectStartTime = null; waitForFirstImage.value = true; isRefreshing = false; } @@ -849,8 +1389,10 @@ class FfiModel with ChangeNotifier { peerId, bind.sessionGetToggleOptionSync( sessionId: sessionId, arg: kOptionToggleViewOnly)); + setShowMyCursor(bind.sessionGetToggleOptionSync( + sessionId: sessionId, arg: kOptionToggleShowMyCursor)); } - if (connType == ConnType.defaultConn) { + if (connType == ConnType.defaultConn || connType == ConnType.viewCamera) { final platformAdditions = evt['platform_additions']; if (platformAdditions != null && platformAdditions != '') { try { @@ -865,7 +1407,11 @@ class FfiModel with ChangeNotifier { stateGlobal.resetLastResolutionGroupValues(peerId); if (isDesktop || isWebDesktop) { - checkDesktopKeyboardMode(); + // checkDesktopKeyboardMode may change the keyboard mode if the current + // mode is not supported. Re-sync InputModel.keyboardMode afterwards. + // Note: updateKeyboardMode() is a no-op on mobile (early-returns). + await checkDesktopKeyboardMode(); + await parent.target?.inputModel.updateKeyboardMode(); } notifyListeners(); @@ -1007,8 +1553,17 @@ class FfiModel with ChangeNotifier { d.cursorEmbedded = evt['cursor_embedded'] == 1; d.originalWidth = evt['original_width'] ?? kInvalidResolutionValue; d.originalHeight = evt['original_height'] ?? kInvalidResolutionValue; - double v = (evt['scale']?.toDouble() ?? 100.0) / 100; - d._scale = v > 1.0 ? v : 1.0; + d._scale = 1.0; + final scaledWidth = evt['scaled_width']; + if (scaledWidth != null) { + final sw = int.tryParse(scaledWidth.toString()); + if (sw != null && sw > 0 && d.width > 0) { + d._scale = max(d.width.toDouble() / sw, 1.0); + } else { + debugPrint( + "Invalid scaled_width ($scaledWidth) or width (${d.width}), using default scale 1.0"); + } + } return d; } @@ -1199,6 +1754,79 @@ class FfiModel with ChangeNotifier { notifyListeners(); } } + + void setShowMyCursor(bool value) { + if (_showMyCursor != value) { + _showMyCursor = value; + notifyListeners(); + } + } +} + +class VirtualMouseMode with ChangeNotifier { + bool _showVirtualMouse = false; + double _virtualMouseScale = 1.0; + bool _showVirtualJoystick = false; + + bool get showVirtualMouse => _showVirtualMouse; + double get virtualMouseScale => _virtualMouseScale; + bool get showVirtualJoystick => _showVirtualJoystick; + + FfiModel ffiModel; + + VirtualMouseMode(this.ffiModel); + + bool _shouldShow() => !ffiModel.isPeerAndroid; + + setShowVirtualMouse(bool b) { + if (b == _showVirtualMouse) return; + if (_shouldShow()) { + _showVirtualMouse = b; + notifyListeners(); + } + } + + setVirtualMouseScale(double s) { + if (s <= 0) return; + if (s == _virtualMouseScale) return; + _virtualMouseScale = s; + bind.mainSetLocalOption(key: kOptionVirtualMouseScale, value: s.toString()); + notifyListeners(); + } + + setShowVirtualJoystick(bool b) { + if (b == _showVirtualJoystick) return; + if (_shouldShow()) { + _showVirtualJoystick = b; + notifyListeners(); + } + } + + void loadOptions() { + _showVirtualMouse = + bind.mainGetLocalOption(key: kOptionShowVirtualMouse) == 'Y'; + _virtualMouseScale = double.tryParse( + bind.mainGetLocalOption(key: kOptionVirtualMouseScale)) ?? + 1.0; + _showVirtualJoystick = + bind.mainGetLocalOption(key: kOptionShowVirtualJoystick) == 'Y'; + notifyListeners(); + } + + Future toggleVirtualMouse() async { + await bind.mainSetLocalOption( + key: kOptionShowVirtualMouse, value: showVirtualMouse ? 'N' : 'Y'); + setShowVirtualMouse( + bind.mainGetLocalOption(key: kOptionShowVirtualMouse) == 'Y'); + } + + Future toggleVirtualJoystick() async { + await bind.mainSetLocalOption( + key: kOptionShowVirtualJoystick, + value: showVirtualJoystick ? 'N' : 'Y'); + setShowVirtualJoystick( + bind.mainGetLocalOption(key: kOptionShowVirtualJoystick) == 'Y'); + } } class ImageModel with ChangeNotifier { @@ -1263,7 +1891,9 @@ class ImageModel with ChangeNotifier { rgba, rect?.width.toInt() ?? 0, rect?.height.toInt() ?? 0, - isWeb ? ui.PixelFormat.rgba8888 : ui.PixelFormat.bgra8888, + isWeb | isWindows | isLinux + ? ui.PixelFormat.rgba8888 + : ui.PixelFormat.bgra8888, ); if (parent.target?.id != pid) return; await update(image); @@ -1274,13 +1904,7 @@ class ImageModel with ChangeNotifier { if (isDesktop || isWebDesktop) { await parent.target?.canvasModel.updateViewStyle(); await parent.target?.canvasModel.updateScrollStyle(); - } else { - final size = MediaQueryData.fromWindow(ui.window).size; - final canvasWidth = size.width; - final canvasHeight = size.height; - final xscale = canvasWidth / image.width; - final yscale = canvasHeight / image.height; - parent.target?.canvasModel.scale = min(xscale, yscale); + await parent.target?.canvasModel.initializeEdgeScrollEdgeThickness(); } if (parent.target != null) { await initializeCursorAndCanvas(parent.target!); @@ -1292,20 +1916,18 @@ class ImageModel with ChangeNotifier { } // mobile only - // for desktop, height should minus tabbar height double get maxScale { if (_image == null) return 1.5; - final size = MediaQueryData.fromWindow(ui.window).size; + final size = parent.target!.canvasModel.getSize(); final xscale = size.width / _image!.width; final yscale = size.height / _image!.height; return max(1.5, max(xscale, yscale)); } // mobile only - // for desktop, height should minus tabbar height double get minScale { if (_image == null) return 1.5; - final size = MediaQueryData.fromWindow(ui.window).size; + final size = parent.target!.canvasModel.getSize(); final xscale = size.width / _image!.width; final yscale = size.height / _image!.height; return min(xscale, yscale) / 1.5; @@ -1331,8 +1953,56 @@ class ImageModel with ChangeNotifier { } enum ScrollStyle { - scrollbar, - scrollauto, + scrollbar(kRemoteScrollStyleBar), + scrollauto(kRemoteScrollStyleAuto), + scrolledge(kRemoteScrollStyleEdge); + + const ScrollStyle(this.stringValue); + + final String stringValue; + + String toJson() { + return name; + } + + static ScrollStyle fromJson(String json, [ScrollStyle? fallbackValue]) { + switch (json) { + case 'scrollbar': + return scrollbar; + case 'scrollauto': + return scrollauto; + case 'scrolledge': + return scrolledge; + } + + if (fallbackValue != null) { + return fallbackValue; + } + + throw ArgumentError("Unknown ScrollStyle JSON value: '$json'"); + } + + @override + String toString() { + return stringValue; + } + + static ScrollStyle fromString(String string, [ScrollStyle? fallbackValue]) { + switch (string) { + case kRemoteScrollStyleBar: + return scrollbar; + case kRemoteScrollStyleAuto: + return scrollauto; + case kRemoteScrollStyleEdge: + return scrolledge; + } + + if (fallbackValue != null) { + return fallbackValue; + } + + throw ArgumentError("Unknown ScrollStyle string value: '$string'"); + } } class ViewStyle { @@ -1400,11 +2070,67 @@ class ViewStyle { final s2 = height / displayHeight; s = s1 < s2 ? s1 : s2; } + } else if (style == kRemoteViewStyleCustom) { + // Custom scale is session-scoped and applied in CanvasModel.updateViewStyle() } return s; } } +enum EdgeScrollState { + inactive, + armed, + active, +} + +class EdgeScrollFallbackState { + final CanvasModel _owner; + + late Ticker _ticker; + + Duration _lastTotalElapsed = Duration.zero; + bool _nextEventIsFirst = true; + Vector2 _encroachment = Vector2.zero(); + + EdgeScrollFallbackState(this._owner, TickerProvider tickerProvider) { + _ticker = tickerProvider.createTicker(emitTick); + } + + void setEncroachment(Vector2 encroachment) { + _encroachment = encroachment; + } + + void emitTick(Duration totalElapsed) { + if (_nextEventIsFirst) { + _lastTotalElapsed = totalElapsed; + _nextEventIsFirst = false; + } else { + final thisTickElapsed = totalElapsed - _lastTotalElapsed; + + const double kFrameTime = 1000.0 / 60.0; + const double kSpeedFactor = 0.1; + + var delta = _encroachment * + (kSpeedFactor * thisTickElapsed.inMilliseconds / kFrameTime); + + _owner.performEdgeScroll(delta); + + _lastTotalElapsed = totalElapsed; + } + } + + void start() { + if (!_ticker.isActive) { + _nextEventIsFirst = true; + _ticker.start(); + } + } + + void stop() { + _ticker.stop(); + } +} + class CanvasModel with ChangeNotifier { // image offset of canvas double _x = 0; @@ -1426,8 +2152,26 @@ class CanvasModel with ChangeNotifier { // scroll offset y percent double _scrollY = 0.0; ScrollStyle _scrollStyle = ScrollStyle.scrollauto; + // edge scroll mode: trigger scrolling when the cursor is close to the edge of the view + int _edgeScrollEdgeThickness = 100; + // tracks whether edge scroll should be active, prevents spurious + // scrolling when the cursor enters the view from outside + EdgeScrollState _edgeScrollState = EdgeScrollState.inactive; + // fallback strategy for when Bump Mouse isn't available + late EdgeScrollFallbackState _edgeScrollFallbackState; + // to avoid hammering a non-functional Bump Mouse + bool _bumpMouseIsWorking = true; ViewStyle _lastViewStyle = ViewStyle.defaultViewStyle(); + Timer? _timerMobileFocusCanvasCursor; + Timer? _timerMobileRestoreCanvasOffset; + Offset? _offsetBeforeMobileSoftKeyboard; + double? _scaleBeforeMobileSoftKeyboard; + + // `isMobileCanvasChanged` is used to avoid canvas reset when changing the input method + // after showing the soft keyboard. + bool isMobileCanvasChanged = false; + final ScrollController _horizontal = ScrollController(); final ScrollController _vertical = ScrollController(); @@ -1450,9 +2194,18 @@ class CanvasModel with ChangeNotifier { _resetScroll() => setScrollPercent(0.0, 0.0); - setScrollPercent(double x, double y) { - _scrollX = x; - _scrollY = y; + void setScrollPercent(double x, double y) { + _scrollX = x.isFinite ? x : 0.0; + _scrollY = y.isFinite ? y : 0.0; + } + + void pushScrollPositionToUI(double scrollPixelX, double scrollPixelY) { + if (_horizontal.hasClients) { + _horizontal.jumpTo(scrollPixelX); + } + if (_vertical.hasClients) { + _vertical.jumpTo(scrollPixelY); + } } ScrollController get scrollHorizontal => _horizontal; @@ -1470,21 +2223,59 @@ class CanvasModel with ChangeNotifier { static double get bottomToEdge => isDesktop ? windowBorderWidth + kDragToResizeAreaPadding.bottom : 0; - updateViewStyle({refreshMousePos = true}) async { - Size getSize() { - final size = MediaQueryData.fromWindow(ui.window).size; - // If minimized, w or h may be negative here. - double w = size.width - leftToEdge - rightToEdge; - double h = size.height - topToEdge - bottomToEdge; - return Size(w < 0 ? 0 : w, h < 0 ? 0 : h); + Size getSize() { + final mediaData = MediaQueryData.fromView(ui.window); + final size = mediaData.size; + // If minimized, w or h may be negative here. + double w = size.width - leftToEdge - rightToEdge; + double h = size.height - topToEdge - bottomToEdge; + if (isMobile) { + // Account for horizontal safe area insets on both orientations. + w = w - mediaData.padding.left - mediaData.padding.right; + // Vertically, subtract the bottom keyboard inset (viewInsets.bottom) and any + // bottom overlay (e.g. key-help tools) so the canvas is not covered. + h = h - + mediaData.viewInsets.bottom - + (parent.target?.cursorModel.keyHelpToolsRectToAdjustCanvas?.bottom ?? + 0); + // Orientation-specific handling: + // - Portrait: additionally subtract top padding (e.g. status bar / notch) + // - Landscape: does not subtract mediaData.padding.top/bottom (home indicator auto-hides) + final isPortrait = size.height > size.width; + if (isPortrait) { + // In portrait mode, subtract the top safe-area padding (e.g. status bar / notch) + // so the remote image is not truncated, while keeping the bottom inset to avoid + // introducing unnecessary blank space around the canvas. + // + // iOS -> Android, portrait, adjust mode: + // h = h (no padding subtracted): top and bottom are truncated + // https://github.com/user-attachments/assets/30ed4559-c27e-432b-847f-8fec23c9f998 + // h = h - top - bottom: extra blank spaces appear + // https://github.com/user-attachments/assets/12a98817-3b4e-43aa-be0f-4b03cf364b7e + // h = h - top (current): works fine + // https://github.com/user-attachments/assets/95f047f2-7f47-4a36-8113-5023989a0c81 + h = h - mediaData.padding.top; + } } + return Size(w < 0 ? 0 : w, h < 0 ? 0 : h); + } + // mobile only + double getAdjustY() { + final bottom = + parent.target?.cursorModel.keyHelpToolsRectToAdjustCanvas?.bottom ?? 0; + return max(bottom - MediaQueryData.fromView(ui.window).padding.top, 0); + } + + updateSize() => _size = getSize(); + + updateViewStyle({refreshMousePos = true, notify = true}) async { final style = await bind.sessionGetViewStyle(sessionId: sessionId); if (style == null) { return; } - _size = getSize(); + updateSize(); final displayWidth = getDisplayWidth(); final displayHeight = getDisplayHeight(); final viewStyle = ViewStyle( @@ -1494,7 +2285,13 @@ class CanvasModel with ChangeNotifier { displayWidth: displayWidth, displayHeight: displayHeight, ); - if (_lastViewStyle == viewStyle) { + // If only the Custom scale percent changed, proceed to update even if + // the basic ViewStyle fields are equal. + // In Custom scale mode, the scale percent can change independently of the other + // ViewStyle fields and is not captured by the equality check. Therefore, we must + // allow updates to proceed when style == kRemoteViewStyleCustom, even if the + // rest of the ViewStyle fields are unchanged. + if (_lastViewStyle == viewStyle && style != kRemoteViewStyleCustom) { return; } if (_lastViewStyle.style != viewStyle.style) { @@ -1503,45 +2300,89 @@ class CanvasModel with ChangeNotifier { _lastViewStyle = viewStyle; _scale = viewStyle.scale; - _devicePixelRatio = ui.window.devicePixelRatio; - if (kIgnoreDpi && style == kRemoteViewStyleOriginal) { - _scale = 1.0 / _devicePixelRatio; + // Apply custom scale percent when in Custom mode + if (style == kRemoteViewStyleCustom) { + try { + _scale = await getSessionCustomScale(sessionId); + } catch (e, stack) { + debugPrint('Error in getSessionCustomScale: $e'); + debugPrintStack(stackTrace: stack); + _scale = 1.0; + } } - _x = (size.width - displayWidth * _scale) / 2; - _y = (size.height - displayHeight * _scale) / 2; - _imageOverflow.value = _x < 0 || y < 0; - notifyListeners(); - if (refreshMousePos) { + + _devicePixelRatio = ui.window.devicePixelRatio; + if (kIgnoreDpi) { + if (style == kRemoteViewStyleOriginal) { + _scale = 1.0 / _devicePixelRatio; + } else if (_scale != 0 && style == kRemoteViewStyleCustom) { + _scale /= _devicePixelRatio; + } + } + _resetCanvasOffset(displayWidth, displayHeight); + final overflow = _x < 0 || y < 0; + if (_imageOverflow.value != overflow) { + _imageOverflow.value = overflow; + } + if (notify) { + notifyListeners(); + } + if (!isMobile && refreshMousePos) { parent.target?.inputModel.refreshMousePos(); } tryUpdateScrollStyle(Duration.zero, style); } + _resetCanvasOffset(int displayWidth, int displayHeight) { + _x = (size.width - displayWidth * _scale) / 2; + _y = (size.height - displayHeight * _scale) / 2; + if (isMobile) { + _moveToCenterCursor(); + } + } + tryUpdateScrollStyle(Duration duration, String? style) async { - if (_scrollStyle != ScrollStyle.scrollbar) return; + if (_scrollStyle == ScrollStyle.scrollauto) return; style ??= await bind.sessionGetViewStyle(sessionId: sessionId); - if (style != kRemoteViewStyleOriginal) { + if (style != kRemoteViewStyleOriginal && style != kRemoteViewStyleCustom) { return; } _resetScroll(); + Future.delayed(duration, () async { updateScrollPercent(); }); } - updateScrollStyle() async { + Future updateScrollStyle() async { final style = await bind.sessionGetScrollStyle(sessionId: sessionId); - if (style == kRemoteScrollStyleBar) { - _scrollStyle = ScrollStyle.scrollbar; + + _scrollStyle = + style != null ? ScrollStyle.fromString(style) : ScrollStyle.scrollauto; + + if (_scrollStyle != ScrollStyle.scrollauto) { _resetScroll(); - } else { - _scrollStyle = ScrollStyle.scrollauto; } + notifyListeners(); } - update(double x, double y, double scale) { + Future initializeEdgeScrollEdgeThickness() async { + final savedValue = + await bind.sessionGetEdgeScrollEdgeThickness(sessionId: sessionId); + + if (savedValue != null) { + _edgeScrollEdgeThickness = savedValue; + } + } + + void updateEdgeScrollEdgeThickness(int newThickness) { + _edgeScrollEdgeThickness = newThickness; + notifyListeners(); + } + + void update(double x, double y, double scale) { _x = x; _y = y; _scale = scale; @@ -1568,7 +2409,33 @@ class CanvasModel with ChangeNotifier { static double get windowBorderWidth => stateGlobal.windowBorderWidth.value; static double get tabBarHeight => stateGlobal.tabBarHeight; - moveDesktopMouse(double x, double y) { + void activateLocalCursor() { + if (isDesktop || isWebDesktop) { + try { + RemoteCursorMovedState.find(id).value = false; + } catch (e) { + // + } + } + } + + void updateLocalCursor(double x, double y) { + // 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. + if (!(isDesktop || isWebDesktop)) { + parent.target!.cursorModel.moveLocal(x, y); + } else { + try { + RemoteCursorMovedState.find(id).value = false; + } catch (e) { + // + } + } + } + } + + void moveDesktopMouse(double x, double y) { if (size.width == 0 || size.height == 0) { return; } @@ -1597,29 +2464,136 @@ class CanvasModel with ChangeNotifier { if (dxOffset != 0 || dyOffset != 0) { notifyListeners(); } + } - // 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. - if (!(isDesktop || isWebDesktop)) { - parent.target!.cursorModel.moveLocal(x, y); + void initializeEdgeScrollFallback(TickerProvider tickerProvider) { + _edgeScrollFallbackState = EdgeScrollFallbackState(this, tickerProvider); + } + + void disableEdgeScroll() { + _edgeScrollState = EdgeScrollState.inactive; + cancelEdgeScroll(); + } + + void rearmEdgeScroll() { + _edgeScrollState = EdgeScrollState.armed; + } + + void cancelEdgeScroll() { + _edgeScrollFallbackState.stop(); + } + + (Vector2, Vector2) getScrollInfo() { + final scrollPixel = Vector2( + _horizontal.hasClients ? _horizontal.position.pixels : 0, + _vertical.hasClients ? _vertical.position.pixels : 0); + + final max = Vector2( + _horizontal.hasClients ? _horizontal.position.maxScrollExtent : 0, + _vertical.hasClients ? _vertical.position.maxScrollExtent : 0); + + return (scrollPixel, max); + } + + void edgeScrollMouse(double x, double y) async { + if ((_edgeScrollState == EdgeScrollState.inactive) || + (size.width == 0 || size.height == 0) || + !(_horizontal.hasClients || _vertical.hasClients)) { + return; + } + + if (_edgeScrollState == EdgeScrollState.armed) { + // Edge scroll is armed to become active once the cursor + // is observed within the rectangle interior to the + // edge scroll regions. If the user has just moved the + // cursor in from outside of the window, edge scrolling + // doesn't happen yet. + final clientArea = Rect.fromLTWH(0, 0, size.width, size.height); + + final innerZone = clientArea.deflate(_edgeScrollEdgeThickness.toDouble()); + + if (innerZone.contains(Offset(x, y))) { + _edgeScrollState = EdgeScrollState.active; } else { - try { - RemoteCursorMovedState.find(id).value = false; - } catch (e) { - // - } + // Not yet. + return; + } + } + + var dxOffset = 0.0; + var dyOffset = 0.0; + + if (x < _edgeScrollEdgeThickness) { + dxOffset = x - _edgeScrollEdgeThickness; + } else if (x >= size.width - _edgeScrollEdgeThickness) { + dxOffset = x - (size.width - _edgeScrollEdgeThickness); + } + + if (y < _edgeScrollEdgeThickness) { + dyOffset = y - _edgeScrollEdgeThickness; + } else if (y >= size.height - _edgeScrollEdgeThickness) { + dyOffset = y - (size.height - _edgeScrollEdgeThickness); + } + + var encroachment = Vector2(dxOffset, dyOffset); + + var (scrollPixel, max) = getScrollInfo(); + + encroachment.clamp(-scrollPixel, max - scrollPixel); + + if (encroachment.length2 == 0) { + _edgeScrollFallbackState.stop(); + } else { + var bumpAmount = -encroachment; + + // Round away from 0: this ensures that the mouse will be bumped clear of + // whichever edge scroll zone(s) it is in + bumpAmount.x += bumpAmount.x.sign * 0.5; + bumpAmount.y += bumpAmount.y.sign * 0.5; + + var bumpMouseSucceeded = _bumpMouseIsWorking && + (await rustDeskWinManager.call(WindowType.Main, kWindowBumpMouse, + {"dx": bumpAmount.x.round(), "dy": bumpAmount.y.round()})) + .result; + + if (bumpMouseSucceeded) { + performEdgeScroll(encroachment); + } else { + // If we can't BumpMouse, then we switch to slower scrolling with autorepeat + + // Don't keep hammering BumpMouse if it's not working. + _bumpMouseIsWorking = false; + + // Keep scrolling as long as the user is overtop of an edge. + _edgeScrollFallbackState.setEncroachment(encroachment); + _edgeScrollFallbackState.start(); } } } - set scale(v) { - _scale = v; + void performEdgeScroll(Vector2 delta) { + var (scrollPixel, max) = getScrollInfo(); + + scrollPixel += delta; + + scrollPixel.clamp(Vector2.zero(), max); + + var scrollPixelPercent = scrollPixel.clone(); + + scrollPixelPercent.divide(max); + scrollPixelPercent.scale(100.0); + + setScrollPercent(scrollPixelPercent.x, scrollPixelPercent.y); + pushScrollPositionToUI(scrollPixel.x, scrollPixel.y); + notifyListeners(); } panX(double dx) { _x += dx; + if (isMobile) { + isMobileCanvasChanged = true; + } notifyListeners(); } @@ -1627,17 +2601,20 @@ class CanvasModel with ChangeNotifier { if (isWebDesktop) { updateViewStyle(); } else { - _x = (size.width - getDisplayWidth() * _scale) / 2; - _y = (size.height - getDisplayHeight() * _scale) / 2; + _resetCanvasOffset(getDisplayWidth(), getDisplayHeight()); } notifyListeners(); } panY(double dy) { _y += dy; + if (isMobile) { + isMobileCanvasChanged = true; + } notifyListeners(); } + // mobile only updateScale(double v, Offset focalPoint) { if (parent.target?.imageModel.image == null) return; final s = _scale; @@ -1649,13 +2626,13 @@ class CanvasModel with ChangeNotifier { // (focalPoint.dx - _x_1) / s1 + displayOriginX = (focalPoint.dx - _x_2) / s2 + displayOriginX // _x_2 = focalPoint.dx - (focalPoint.dx - _x_1) / s1 * s2 _x = focalPoint.dx - (focalPoint.dx - _x) / s * _scale; - final adjustForKeyboard = - parent.target?.cursorModel.adjustForKeyboard() ?? 0.0; - // (focalPoint.dy - _y_1 + adjust) / s1 + displayOriginY = (focalPoint.dy - _y_2 + adjust) / s2 + displayOriginY - // _y_2 = focalPoint.dy + adjust - (focalPoint.dy - _y_1 + adjust) / s1 * s2 - _y = focalPoint.dy + - adjustForKeyboard - - (focalPoint.dy - _y + adjustForKeyboard) / s * _scale; + final adjust = getAdjustY(); + // (focalPoint.dy - _y_1 - adjust) / s1 + displayOriginY = (focalPoint.dy - _y_2 - adjust) / s2 + displayOriginY + // _y_2 = focalPoint.dy - adjust - (focalPoint.dy - _y_1 - adjust) / s1 * s2 + _y = focalPoint.dy - adjust - (focalPoint.dy - _y - adjust) / s * _scale; + if (isMobile) { + isMobileCanvasChanged = true; + } notifyListeners(); } @@ -1666,10 +2643,7 @@ class CanvasModel with ChangeNotifier { if (kIgnoreDpi && _lastViewStyle.style == kRemoteViewStyleOriginal) { _scale = 1.0 / _devicePixelRatio; } - final displayWidth = getDisplayWidth(); - final displayHeight = getDisplayHeight(); - _x = (size.width - displayWidth * _scale) / 2; - _y = (size.height - displayHeight * _scale) / 2; + _resetCanvasOffset(getDisplayWidth(), getDisplayHeight()); bind.sessionSetViewStyle(sessionId: sessionId, value: _lastViewStyle.style); notifyListeners(); } @@ -1678,6 +2652,11 @@ class CanvasModel with ChangeNotifier { _x = 0; _y = 0; _scale = 1.0; + _lastViewStyle = ViewStyle.defaultViewStyle(); + _timerMobileFocusCanvasCursor?.cancel(); + _timerMobileRestoreCanvasOffset?.cancel(); + _offsetBeforeMobileSoftKeyboard = null; + _scaleBeforeMobileSoftKeyboard = null; } updateScrollPercent() { @@ -1695,6 +2674,68 @@ class CanvasModel with ChangeNotifier { : 0.0; setScrollPercent(percentX, percentY); } + + void mobileFocusCanvasCursor() { + _timerMobileFocusCanvasCursor?.cancel(); + _timerMobileFocusCanvasCursor = + Timer(Duration(milliseconds: 100), () async { + updateSize(); + _resetCanvasOffset(getDisplayWidth(), getDisplayHeight()); + notifyListeners(); + }); + } + + void saveMobileOffsetBeforeSoftKeyboard() { + _timerMobileRestoreCanvasOffset?.cancel(); + _offsetBeforeMobileSoftKeyboard = Offset(_x, _y); + _scaleBeforeMobileSoftKeyboard = _scale; + } + + void restoreMobileOffsetAfterSoftKeyboard() { + _timerMobileRestoreCanvasOffset?.cancel(); + _timerMobileFocusCanvasCursor?.cancel(); + final targetOffset = _offsetBeforeMobileSoftKeyboard; + final targetScale = _scaleBeforeMobileSoftKeyboard; + if (targetOffset == null || targetScale == null) { + return; + } + _timerMobileRestoreCanvasOffset = Timer(Duration(milliseconds: 100), () { + updateSize(); + _x = targetOffset.dx; + _y = targetOffset.dy; + _scale = targetScale; + _offsetBeforeMobileSoftKeyboard = null; + _scaleBeforeMobileSoftKeyboard = null; + notifyListeners(); + }); + } + + // mobile only + // Move the canvas to make the cursor visible(center) on the screen. + void _moveToCenterCursor() { + Rect? imageRect = parent.target?.ffiModel.rect; + if (imageRect == null) { + // unreachable + return; + } + final maxX = 0.0; + final minX = _size.width + (imageRect.left - imageRect.right) * _scale; + final maxY = 0.0; + final minY = _size.height + (imageRect.top - imageRect.bottom) * _scale; + Offset offsetToCenter = + parent.target?.cursorModel.getCanvasOffsetToCenterCursor() ?? + Offset.zero; + if (minX < 0) { + _x = min(max(offsetToCenter.dx, minX), maxX); + } else { + // _size.width > (imageRect.right, imageRect.left) * _scale, we should not change _x + } + if (minY < 0) { + _y = min(max(offsetToCenter.dy, minY), maxY); + } else { + // _size.height > (imageRect.bottom - imageRect.top) * _scale, , we should not change _y + } + } } // data for cursor @@ -1888,11 +2929,31 @@ class CursorModel with ChangeNotifier { // `lastIsBlocked` is only used in common/widgets/remote_input.dart -> _RawTouchGestureDetectorRegionState -> onDoubleTap() // Because onDoubleTap() doesn't have the `event` parameter, we can't get the touch event's position. bool _lastIsBlocked = false; - double _yForKeyboardAdjust = 0; + bool _lastKeyboardIsVisible = false; - keyHelpToolsVisibilityChanged(Rect? r) { - _keyHelpToolsRect = r; - if (r == null) { + bool get lastKeyboardIsVisible => _lastKeyboardIsVisible; + + Rect? get keyHelpToolsRectToAdjustCanvas => + _lastKeyboardIsVisible ? _keyHelpToolsRect : null; + // The blocked rect is used to block the pointer/touch events in the remote page. + final List _blockedRects = []; + // Used in shouldBlock(). + // _blockEvents is a flag to block pointer/touch events on the remote image. + // It is set to true to prevent accidental touch events in the following scenarios: + // 1. In floating mouse mode, when the scroll circle is shown. + // 2. In floating mouse widgets mode, when the left/right buttons are moving. + // 3. In floating mouse widgets mode, when using the virtual joystick. + // When _blockEvents is true, all pointer/touch events are blocked regardless of the contents of _blockedRects. + // _blockedRects contains specific rectangular regions where events are blocked; these are checked when _blockEvents is false. + // In summary: _blockEvents acts as a global block, while _blockedRects provides fine-grained blocking. + bool _blockEvents = false; + List get blockedRects => List.unmodifiable(_blockedRects); + + set blockEvents(bool v) => _blockEvents = v; + + keyHelpToolsVisibilityChanged(Rect? rect, bool keyboardIsVisible) { + _keyHelpToolsRect = rect; + if (rect == null) { _lastIsBlocked = false; } else { // Block the touch event is safe here. @@ -1900,7 +2961,24 @@ class CursorModel with ChangeNotifier { // `lastIsBlocked` will be set when the cursor is moving or touch somewhere else. _lastIsBlocked = true; } - _yForKeyboardAdjust = _y; + if (isMobile && _lastKeyboardIsVisible != keyboardIsVisible) { + if (keyboardIsVisible) { + parent.target?.canvasModel.saveMobileOffsetBeforeSoftKeyboard(); + parent.target?.canvasModel.mobileFocusCanvasCursor(); + parent.target?.canvasModel.isMobileCanvasChanged = false; + } else { + parent.target?.canvasModel.restoreMobileOffsetAfterSoftKeyboard(); + } + } + _lastKeyboardIsVisible = keyboardIsVisible; + } + + addBlockedRect(Rect rect) { + _blockedRects.add(rect); + } + + removeBlockedRect(Rect rect) { + _blockedRects.remove(rect); } get lastIsBlocked => _lastIsBlocked; @@ -1939,8 +3017,10 @@ class CursorModel with ChangeNotifier { addKey(String key) => _cacheKeys.add(key); // remote physical display coordinate + // For update pan (mobile), onOneFingerPanStart, onOneFingerPanUpdate, onHoldDragUpdate Rect getVisibleRect() { - final size = MediaQueryData.fromWindow(ui.window).size; + final size = parent.target?.canvasModel.getSize() ?? + MediaQueryData.fromView(ui.window).size; final xoffset = parent.target?.canvasModel.x ?? 0; final yoffset = parent.target?.canvasModel.y ?? 0; final scale = parent.target?.canvasModel.scale ?? 1; @@ -1949,52 +3029,106 @@ class CursorModel with ChangeNotifier { return Rect.fromLTWH(x0, y0, size.width / scale, size.height / scale); } - get keyboardHeight => MediaQueryData.fromWindow(ui.window).viewInsets.bottom; - get scale => parent.target?.canvasModel.scale ?? 1.0; - - double adjustForKeyboard() { - if (keyboardHeight < 100) { - return 0.0; - } - - final m = MediaQueryData.fromWindow(ui.window); - final size = m.size; - final thresh = (size.height - keyboardHeight) / 2; - final h = (_yForKeyboardAdjust - getVisibleRect().top) * - scale; // local physical display height - return h - thresh; + Offset getCanvasOffsetToCenterCursor() { + // Cursor should be at the center of the visible rect. + // _x = rect.left + rect.width / 2 + // _y = rect.right + rect.height / 2 + // See `getVisibleRect()` + // _x = _displayOriginX - xoffset / scale + size.width / scale * 0.5; + // _y = _displayOriginY - yoffset / scale + size.height / scale * 0.5; + final size = parent.target?.canvasModel.getSize() ?? + MediaQueryData.fromView(ui.window).size; + final xoffset = (_displayOriginX - _x) * scale + size.width * 0.5; + final yoffset = (_displayOriginY - _y) * scale + size.height * 0.5; + return Offset(xoffset, yoffset); } + get scale => parent.target?.canvasModel.scale ?? 1.0; + // mobile Soft keyboard, block touch event from the KeyHelpTools shouldBlock(double x, double y) { + if (_blockEvents) { + return true; + } + final offset = Offset(x, y); + for (final rect in _blockedRects) { + if (isPointInRect(offset, rect)) { + return true; + } + } + + // For help tools rectangle, only block touch event when in touch mode. if (!(parent.target?.ffiModel.touchMode ?? false)) { return false; } - if (_keyHelpToolsRect == null) { - return false; - } - if (isPointInRect(Offset(x, y), _keyHelpToolsRect!)) { + if (_keyHelpToolsRect != null && + isPointInRect(offset, _keyHelpToolsRect!)) { return true; } return false; } - move(double x, double y) { + // For touch mode + Future move(double x, double y) async { if (shouldBlock(x, y)) { _lastIsBlocked = true; return false; } _lastIsBlocked = false; - moveLocal(x, y, adjust: adjustForKeyboard()); - parent.target?.inputModel.moveMouse(_x, _y); + if (!_moveLocalIfInRemoteRect(x, y)) { + return false; + } + await parent.target?.inputModel.moveMouse(_x, _y); + return true; + } + + Future syncCursorPosition() async { + await parent.target?.inputModel.moveMouse(_x, _y); + } + + bool isInRemoteRect(Offset offset) { + return getRemotePosInRect(offset) != null; + } + + Offset? getRemotePosInRect(Offset offset) { + final adjust = parent.target?.canvasModel.getAdjustY() ?? 0; + final newPos = _getNewPos(offset.dx, offset.dy, adjust); + final visibleRect = getVisibleRect(); + if (!isPointInRect(newPos, visibleRect)) { + return null; + } + final rect = parent.target?.ffiModel.rect; + if (rect != null) { + if (!isPointInRect(newPos, rect)) { + return null; + } + } + return newPos; + } + + Offset _getNewPos(double x, double y, double adjust) { + final xoffset = parent.target?.canvasModel.x ?? 0; + final yoffset = parent.target?.canvasModel.y ?? 0; + final newX = (x - xoffset) / scale + _displayOriginX; + final newY = (y - yoffset - adjust) / scale + _displayOriginY; + return Offset(newX, newY); + } + + bool _moveLocalIfInRemoteRect(double x, double y) { + final newPos = getRemotePosInRect(Offset(x, y)); + if (newPos == null) { + return false; + } + _x = newPos.dx; + _y = newPos.dy; + notifyListeners(); return true; } moveLocal(double x, double y, {double adjust = 0}) { - final xoffset = parent.target?.canvasModel.x ?? 0; - final yoffset = parent.target?.canvasModel.y ?? 0; - _x = (x - xoffset) / scale + _displayOriginX; - _y = (y - yoffset + adjust) / scale + _displayOriginY; + final newPos = _getNewPos(x, y, adjust); + _x = newPos.dx; + _y = newPos.dy; notifyListeners(); } @@ -2006,9 +3140,9 @@ class CursorModel with ChangeNotifier { notifyListeners(); } - updatePan(Offset delta, Offset localPosition, bool touchMode) { + updatePan(Offset delta, Offset localPosition, bool touchMode) async { if (touchMode) { - _handleTouchMode(delta, localPosition); + await _handleTouchMode(delta, localPosition); return; } double dx = delta.dx; @@ -2021,9 +3155,10 @@ class CursorModel with ChangeNotifier { var cx = r.center.dx; var cy = r.center.dy; var tryMoveCanvasX = false; + final displayRect = parent.target?.ffiModel.rect; if (dx > 0) { final maxCanvasCanMove = _displayOriginX + - (parent.target?.imageModel.image!.width ?? 1280) - + (displayRect?.width ?? 1280) - r.right.roundToDouble(); tryMoveCanvasX = _x + dx > cx && maxCanvasCanMove > 0; if (tryMoveCanvasX) { @@ -2045,7 +3180,7 @@ class CursorModel with ChangeNotifier { var tryMoveCanvasY = false; if (dy > 0) { final mayCanvasCanMove = _displayOriginY + - (parent.target?.imageModel.image!.height ?? 720) - + (displayRect?.height ?? 720) - r.bottom.roundToDouble(); tryMoveCanvasY = _y + dy > cy && mayCanvasCanMove > 0; if (tryMoveCanvasY) { @@ -2066,13 +3201,34 @@ class CursorModel with ChangeNotifier { } if (dx == 0 && dy == 0) return; - _x += dx; - _y += dy; + + Point? newPos; + final rect = parent.target?.ffiModel.rect; + if (rect == null) { + // unreachable + return; + } + newPos = InputModel.getPointInRemoteRect( + false, + parent.target?.ffiModel.pi.platform, + kPointerEventKindMouse, + kMouseEventTypeDefault, + _x + dx, + _y + dy, + rect, + buttons: kPrimaryButton); + if (newPos == null) { + return; + } + dx = newPos.x - _x; + dy = newPos.y - _y; + _x = newPos.x; + _y = newPos.y; if (tryMoveCanvasX && dx != 0) { - parent.target?.canvasModel.panX(-dx); + parent.target?.canvasModel.panX(-dx * scale); } if (tryMoveCanvasY && dy != 0) { - parent.target?.canvasModel.panY(-dy); + parent.target?.canvasModel.panY(-dy * scale); } parent.target?.inputModel.moveMouse(_x, _y); @@ -2085,7 +3241,7 @@ class CursorModel with ChangeNotifier { return x >= 0 && y >= 0 && x <= w && y <= h; } - _handleTouchMode(Offset delta, Offset localPosition) { + _handleTouchMode(Offset delta, Offset localPosition) async { bool isMoved = false; if (_remoteWindowCoords.isNotEmpty && _windowRect != null && @@ -2101,15 +3257,50 @@ class CursorModel with ChangeNotifier { coords.canvas.scale; x2 += coords.cursor.offset.dx; y2 += coords.cursor.offset.dy; - parent.target?.inputModel.moveMouse(x2, y2); + await parent.target?.inputModel.moveMouse(x2, y2); isMoved = true; } } if (!isMoved) { + final rect = parent.target?.ffiModel.rect; + if (rect == null) { + // unreachable + return; + } + + Offset? movementInRect(double x, double y, Rect r) { + final isXInRect = x >= r.left && x <= r.right; + final isYInRect = y >= r.top && y <= r.bottom; + if (!(isXInRect || isYInRect)) { + return null; + } + if (x < r.left) { + x = r.left; + } else if (x > r.right) { + x = r.right; + } + if (y < r.top) { + y = r.top; + } else if (y > r.bottom) { + y = r.bottom; + } + return Offset(x, y); + } + final scale = parent.target?.canvasModel.scale ?? 1.0; - _x += delta.dx / scale; - _y += delta.dy / scale; - parent.target?.inputModel.moveMouse(_x, _y); + var movement = + movementInRect(_x + delta.dx / scale, _y + delta.dy / scale, rect); + if (movement == null) { + return; + } + movement = movementInRect(movement.dx, movement.dy, getVisibleRect()); + if (movement == null) { + return; + } + + _x = movement.dx; + _y = movement.dy; + await parent.target?.inputModel.moveMouse(_x, _y); } notifyListeners(); } @@ -2248,6 +3439,8 @@ class CursorModel with ChangeNotifier { _x = -10000; _x = -10000; _image = null; + _firstUpdateMouseTime = null; + gotMouseControl = true; disposeImages(); _clearCache(); @@ -2392,7 +3585,15 @@ class ElevationModel with ChangeNotifier { onPortableServiceRunning(bool running) => _running = running; } -enum ConnType { defaultConn, fileTransfer, portForward, rdp } +// The index values of `ConnType` are same as rust protobuf. +enum ConnType { + defaultConn, + fileTransfer, + portForward, + rdp, + viewCamera, + terminal +} /// Flutter state manager and data communication with the Rust core. class FFI { @@ -2400,7 +3601,6 @@ class FFI { var version = ''; var connType = ConnType.defaultConn; var closed = false; - var auditNote = ''; /// dialogManager use late to ensure init after main page binding [globalKey] late final dialogManager = OverlayDialogManager(); @@ -2427,6 +3627,12 @@ class FFI { late final Peers favoritePeersModel; // global late final Peers lanPeersModel; // global + // Terminal model registry for multiple terminals + final Map _terminalModels = {}; + + // Getter for terminal models + Map get terminalModels => _terminalModels; + FFI(SessionID? sId) { sessionId = sId ?? (isDesktop ? Uuid().v4obj() : _constSessionId); imageModel = ImageModel(WeakReference(this)); @@ -2467,12 +3673,14 @@ class FFI { ffiModel.waitForImageTimer = null; } - /// Start with the given [id]. Only transfer file if [isFileTransfer], only port forward if [isPortForward]. + /// Start with the given [id]. Only transfer file if [isFileTransfer], only view camera if [isViewCamera], only port forward if [isPortForward]. void start( String id, { bool isFileTransfer = false, + bool isViewCamera = false, bool isPortForward = false, bool isRdp = false, + bool isTerminal = false, String? switchUuid, String? password, bool? isSharedPassword, @@ -2483,13 +3691,23 @@ class FFI { List? displays, }) { closed = false; - auditNote = ''; if (isMobile) mobileReset(); - assert(!(isFileTransfer && isPortForward), 'more than one connect type'); + assert( + (!(isPortForward && isViewCamera)) && + (!(isViewCamera && isPortForward)) && + (!(isPortForward && isFileTransfer)) && + (!(isTerminal && isFileTransfer)) && + (!(isTerminal && isViewCamera)) && + (!(isTerminal && isPortForward)), + 'more than one connect type'); if (isFileTransfer) { connType = ConnType.fileTransfer; + } else if (isViewCamera) { + connType = ConnType.viewCamera; } else if (isPortForward) { connType = ConnType.portForward; + } else if (isTerminal) { + connType = ConnType.terminal; } else { chatModel.resetClientMode(); connType = ConnType.defaultConn; @@ -2507,8 +3725,10 @@ class FFI { sessionId: sessionId, id: id, isFileTransfer: isFileTransfer, + isViewCamera: isViewCamera, isPortForward: isPortForward, isRdp: isRdp, + isTerminal: isTerminal, switchUuid: switchUuid ?? '', forceRelay: forceRelay ?? false, password: password ?? '', @@ -2522,7 +3742,10 @@ class FFI { return; } final addRes = bind.sessionAddExistedSync( - id: id, sessionId: sessionId, displays: Int32List.fromList(displays)); + id: id, + sessionId: sessionId, + displays: Int32List.fromList(displays), + isViewCamera: isViewCamera); if (addRes != '') { debugPrint( 'Unreachable, failed to add existed session to $id, $addRes'); @@ -2533,6 +3756,15 @@ class FFI { if (isDesktop && connType == ConnType.defaultConn) { textureModel.updateCurrentDisplay(display ?? 0); } + // FIXME: separate cameras displays or shift all indices. + if (isDesktop && connType == ConnType.viewCamera) { + // FIXME: currently the default 0 is not used. + textureModel.updateCurrentDisplay(display ?? 0); + } + + if (isDesktop) { + inputModel.updateTrackpadSpeed(); + } // CAUTION: `sessionStart()` and `sessionStartWithDisplays()` are an async functions. // Though the stream is returned immediately, the stream may not be ready. @@ -2650,6 +3882,7 @@ class FFI { dialogManager.dismissAll(); await canvasModel.updateViewStyle(); await canvasModel.updateScrollStyle(); + await canvasModel.initializeEdgeScrollEdgeThickness(); for (final cb in imageModel.callbacksOnFirstImage) { cb(id); } @@ -2676,6 +3909,11 @@ class FFI { Future close({bool closeSession = true}) async { closed = true; chatModel.close(); + // Close all terminal models + for (final model in _terminalModels.values) { + model.dispose(); + } + _terminalModels.clear(); if (imageModel.image != null && !isWebDesktop) { await setCanvasConfig( sessionId, @@ -2686,11 +3924,15 @@ class FFI { canvasModel.scale, ffiModel.pi.currentDisplay); } + imageModel.callbacksOnFirstImage.clear(); await imageModel.update(null); cursorModel.clear(); ffiModel.clear(); canvasModel.clear(); inputModel.resetModifiers(); + // Dispose relative mouse mode resources to ensure cursor is restored + inputModel.disposeRelativeMouseMode(); + inputModel.disposeSideButtonTracking(); if (closeSession) { await bind.sessionClose(sessionId: sessionId); } @@ -2705,6 +3947,27 @@ class FFI { Future invokeMethod(String method, [dynamic arguments]) async { return await platformFFI.invokeMethod(method, arguments); } + + // Terminal model management + void registerTerminalModel(int terminalId, TerminalModel model) { + debugPrint('[FFI] Registering terminal model for terminal $terminalId'); + _terminalModels[terminalId] = model; + } + + void unregisterTerminalModel(int terminalId) { + debugPrint('[FFI] Unregistering terminal model for terminal $terminalId'); + _terminalModels.remove(terminalId); + } + + void routeTerminalResponse(Map evt) { + final int terminalId = TerminalModel.getTerminalIdFromEvt(evt); + + // Route to specific terminal model if it exists + final model = _terminalModels[terminalId]; + if (model != null) { + model.handleTerminalResponse(evt); + } + } } const kInvalidResolutionValue = -1; @@ -2750,7 +4013,8 @@ class Display { originalWidth == kVirtualDisplayResolutionValue && originalHeight == kVirtualDisplayResolutionValue; bool get isOriginalResolution => - width == originalWidth && height == originalHeight; + width == (originalWidth * scale).round() && + height == (originalHeight * scale).round(); } class Resolution { diff --git a/flutter/lib/models/native_model.dart b/flutter/lib/models/native_model.dart index c8d5085e8..b57867838 100644 --- a/flutter/lib/models/native_model.dart +++ b/flutter/lib/models/native_model.dart @@ -60,14 +60,14 @@ class PlatformFFI { } bool registerEventHandler( - String eventName, String handlerName, HandleEvent handler) { + String eventName, String handlerName, HandleEvent handler, {bool replace = false}) { debugPrint('registerEventHandler $eventName $handlerName'); var handlers = _eventHandlers[eventName]; if (handlers == null) { _eventHandlers[eventName] = {handlerName: handler}; return true; } else { - if (handlers.containsKey(handlerName)) { + if (!replace && handlers.containsKey(handlerName)) { return false; } else { handlers[handlerName] = handler; @@ -156,7 +156,10 @@ class PlatformFFI { // only support for android _homeDir = (await ExternalPath.getExternalStorageDirectories())[0]; } else if (isIOS) { - _homeDir = _ffiBind.mainGetDataDirIos(); + // The previous code was `_homeDir = (await getDownloadsDirectory())?.path ?? '';`, + // which provided the `downloads` path in the sandbox. + // It is unclear why we now use the `data` directory in the sandbox instead. + _homeDir = _ffiBind.mainGetDataDirIos(appDir: _dir); } else { // no need to set home dir } diff --git a/flutter/lib/models/peer_model.dart b/flutter/lib/models/peer_model.dart index 7ab5a2b80..59acdd591 100644 --- a/flutter/lib/models/peer_model.dart +++ b/flutter/lib/models/peer_model.dart @@ -19,6 +19,8 @@ class Peer { String rdpUsername; bool online = false; String loginName; //login username + String device_group_name; + String note; bool? sameServer; String getId() { @@ -41,6 +43,8 @@ class Peer { rdpPort = json['rdpPort'] ?? '', rdpUsername = json['rdpUsername'] ?? '', loginName = json['loginName'] ?? '', + device_group_name = json['device_group_name'] ?? '', + note = json['note'] is String ? json['note'] : '', sameServer = json['same_server']; Map toJson() { @@ -57,6 +61,8 @@ class Peer { "rdpPort": rdpPort, "rdpUsername": rdpUsername, 'loginName': loginName, + 'device_group_name': device_group_name, + 'note': note, 'same_server': sameServer, }; } @@ -83,6 +89,7 @@ class Peer { "hostname": hostname, "platform": platform, "login_name": loginName, + "device_group_name": device_group_name, }; } @@ -99,6 +106,8 @@ class Peer { required this.rdpPort, required this.rdpUsername, required this.loginName, + required this.device_group_name, + required this.note, this.sameServer, }); @@ -116,6 +125,8 @@ class Peer { rdpPort: '', rdpUsername: '', loginName: '', + device_group_name: '', + note: '', ); bool equal(Peer other) { return id == other.id && @@ -129,7 +140,9 @@ class Peer { forceAlwaysRelay == other.forceAlwaysRelay && rdpPort == other.rdpPort && rdpUsername == other.rdpUsername && - loginName == other.loginName; + device_group_name == other.device_group_name && + loginName == other.loginName && + note == other.note; } Peer.copy(Peer other) @@ -146,6 +159,8 @@ class Peer { rdpPort: other.rdpPort, rdpUsername: other.rdpUsername, loginName: other.loginName, + device_group_name: other.device_group_name, + note: other.note, sameServer: other.sameServer); } @@ -157,6 +172,11 @@ class Peers extends ChangeNotifier { final String name; final String loadEvent; List peers = List.empty(growable: true); + // Part of the peers that are not in the rest peers list. + // When there're too many peers, we may want to load the front 100 peers first, + // so we can see peers in UI quickly. `restPeerIds` is the rest peers' ids. + // And then load all peers later. + List restPeerIds = List.empty(growable: true); final GetInitPeers? getInitPeers; UpdateEvent event = UpdateEvent.load; static const _cbQueryOnlines = 'callback_query_onlines'; @@ -230,6 +250,12 @@ class Peers extends ChangeNotifier { } else { peers = _decodePeers(evt['peers']); } + + restPeerIds = []; + if (evt['ids'] != null) { + restPeerIds = (evt['ids'] as String).split(','); + } + for (var peer in peers) { final state = onlineStates[peer.id]; peer.online = state != null && state != false; diff --git a/flutter/lib/models/peer_tab_model.dart b/flutter/lib/models/peer_tab_model.dart index 83df1f05d..e5fa7fb03 100644 --- a/flutter/lib/models/peer_tab_model.dart +++ b/flutter/lib/models/peer_tab_model.dart @@ -28,19 +28,19 @@ class PeerTabModel with ChangeNotifier { 'Favorites', 'Discovered', 'Address book', - 'Group', + 'Accessible devices', ]; static const List icons = [ Icons.access_time_filled, Icons.star, Icons.explore, IconFont.addressBook, - Icons.group, + IconFont.deviceGroupFill, ]; List isEnabled = List.from([ true, true, - !isWeb, + !isWeb && bind.mainGetLocalOption(key: "disable-discovery-panel") != "Y", !(bind.isDisableAb() || bind.isDisableAccount()), !(bind.isDisableGroupPanel() || bind.isDisableAccount()), ]); diff --git a/flutter/lib/models/printer_model.dart b/flutter/lib/models/printer_model.dart new file mode 100644 index 000000000..8d0b37932 --- /dev/null +++ b/flutter/lib/models/printer_model.dart @@ -0,0 +1,48 @@ +import 'package:flutter_hbb/common.dart'; +import 'package:flutter_hbb/consts.dart'; +import 'package:flutter_hbb/models/platform_model.dart'; + +class PrinterOptions { + String action; + List printerNames; + String printerName; + + PrinterOptions( + {required this.action, + required this.printerNames, + required this.printerName}); + + static PrinterOptions load() { + var action = bind.mainGetLocalOption(key: kKeyPrinterIncomingJobAction); + if (![ + kValuePrinterIncomingJobDismiss, + kValuePrinterIncomingJobDefault, + kValuePrinterIncomingJobSelected + ].contains(action)) { + action = kValuePrinterIncomingJobDefault; + } + + final printerNames = getPrinterNames(); + var selectedPrinterName = bind.mainGetLocalOption(key: kKeyPrinterSelected); + if (!printerNames.contains(selectedPrinterName)) { + if (action == kValuePrinterIncomingJobSelected) { + action = kValuePrinterIncomingJobDefault; + bind.mainSetLocalOption( + key: kKeyPrinterIncomingJobAction, + value: kValuePrinterIncomingJobDefault); + if (printerNames.isEmpty) { + selectedPrinterName = ''; + } else { + selectedPrinterName = printerNames.first; + } + bind.mainSetLocalOption( + key: kKeyPrinterSelected, value: selectedPrinterName); + } + } + + return PrinterOptions( + action: action, + printerNames: printerNames, + printerName: selectedPrinterName); + } +} diff --git a/flutter/lib/models/relative_mouse_model.dart b/flutter/lib/models/relative_mouse_model.dart new file mode 100644 index 000000000..2673cb8ae --- /dev/null +++ b/flutter/lib/models/relative_mouse_model.dart @@ -0,0 +1,1061 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math' as math; +import 'dart:ui' as ui; + +import 'package:desktop_multi_window/desktop_multi_window.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_hbb/main.dart'; +import 'package:flutter_hbb/utils/relative_mouse_accumulator.dart'; +import 'package:get/get.dart'; + +import '../common.dart'; +import '../consts.dart'; +import 'platform_model.dart'; + +class RelativeMouseModel { + final SessionID sessionId; + final RxBool enabled; + + final bool Function() keyboardPerm; + final bool Function() isViewCamera; + final String Function() peerVersion; + final String? Function() peerPlatform; + + final Map Function(Map msg) modify; + + final bool Function() getPointerInsideImage; + final void Function(bool inside) setPointerInsideImage; + + RelativeMouseModel({ + required this.sessionId, + required this.enabled, + required this.keyboardPerm, + required this.isViewCamera, + required this.peerVersion, + required this.peerPlatform, + required this.modify, + required this.getPointerInsideImage, + required this.setPointerInsideImage, + }); + + final RelativeMouseAccumulator _accumulator = RelativeMouseAccumulator(); + + // Native relative mouse mode support (macOS only) + // Uses CGAssociateMouseAndMouseCursorPosition to lock cursor and NSEvent monitor for raw delta. + static MethodChannel? _hostChannel; + // The currently active model receiving native mouse delta events. + // Note: Race condition between multiple sessions is not a concern here because + // when relative mouse mode is active, the cursor is locked and the user cannot + // switch to another session window. The user must first exit relative mouse mode + // (via Cmd+G on macOS or Ctrl+Alt on Windows/Linux) before they can interact + // with a different session. + static RelativeMouseModel? _activeNativeModel; + static bool _hostChannelInitialized = false; + + /// Initialize the host channel for native relative mouse mode. + /// This should be called once when the app starts on macOS. + static void initHostChannel() { + if (!isMacOS) return; + if (_hostChannelInitialized) return; + _hostChannelInitialized = true; + + _hostChannel = const MethodChannel('org.rustdesk.rustdesk/host'); + _hostChannel!.setMethodCallHandler((call) async { + if (call.method == 'onMouseDelta') { + final args = call.arguments as Map; + final dx = args['dx'] as int; + final dy = args['dy'] as int; + _activeNativeModel?._onNativeMouseDelta(dx, dy); + } + return null; + }); + } + + // TODO(perf): Consider routing native delta through RelativeMouseAccumulator/throttle + // if high-polling mice (e.g. 1000Hz+) cause message flooding on the network. + void _onNativeMouseDelta(int dx, int dy) { + if (!enabled.value) return; + // Send directly to remote without accumulator (native already provides integer deltas) + _sendMouseMessageToSession({ + 'type': 'move_relative', + 'x': '$dx', + 'y': '$dy', + }); + } + + Future _enableNativeRelativeMouseMode() async { + if (!isMacOS) return false; + if (_hostChannel == null) { + initHostChannel(); + if (_hostChannel == null) return false; + } + + // Defensive guard: prevent overwriting an already-active native session. + // In practice, this should not happen because when relative mouse mode is active, + // the cursor is locked and the user cannot switch to another session window. + // The user must first exit relative mouse mode (via Cmd+G on macOS or Ctrl+Alt on + // Windows/Linux) before interacting with a different session. + if (_activeNativeModel != null && _activeNativeModel != this) { + debugPrint( + '[RelMouse] Another model already has native relative mouse mode active'); + return false; + } + + try { + final result = + await _hostChannel!.invokeMethod('enableNativeRelativeMouseMode'); + if (result == true) { + _activeNativeModel = this; + return true; + } + } catch (e) { + debugPrint('[RelMouse] Failed to enable native relative mouse mode: $e'); + } + return false; + } + + Future _disableNativeRelativeMouseMode() async { + if (!isMacOS) return; + if (_hostChannel == null) return; + + // Only the owning model should disable native mode to avoid + // one session inadvertently disrupting another's native relative mouse state. + if (_activeNativeModel != this) { + return; + } + + try { + await _hostChannel!.invokeMethod('disableNativeRelativeMouseMode'); + } catch (e) { + debugPrint('[RelMouse] Failed to disable native relative mouse mode: $e'); + } finally { + if (_activeNativeModel == this) { + _activeNativeModel = null; + } + } + } + + // Whether native relative mouse mode is currently active for this model + bool get _isNativeRelativeMouseModeActive => + isMacOS && _activeNativeModel == this; + + // Pointer lock center in LOCAL widget coordinates (for delta calculation) + Offset? _pointerLockCenterLocal; + // Pointer lock center in SCREEN coordinates (for OS cursor re-centering) + Offset? _pointerLockCenterScreen; + // Pointer region top-left in Flutter view coordinates. + // Computed from PointerEvent.position - PointerEvent.localPosition. + Offset? _pointerRegionTopLeftGlobal; + // Last pointer position in LOCAL widget coordinates (fallback when center is not ready). + Offset? _lastPointerLocalPos; + + // Track whether we currently have an OS-level cursor clip active (Windows only). + // TODO(accuracy): Revisit window/client/border clipping math if users report misaligned + // clipping on custom or maximized window decorations. Consider using platform APIs + // (e.g. GetClientRect on Windows) instead of Flutter's window coordinates. + bool _cursorClipApplied = false; + + // Track whether a recenter operation is in progress to prevent overlapping calls. + bool _recenterInProgress = false; + + // Request token for async enable operation to prevent stale callbacks. + // Incremented on each enable attempt, callbacks check if token still matches. + int _enableRequestId = 0; + + // Throttle buffer for batching mouse move messages (reduces network flooding). + int _pendingDeltaX = 0; + int _pendingDeltaY = 0; + Timer? _throttleTimer; + static const Duration _throttleInterval = Duration(milliseconds: 16); + + // Size of the remote image widget (for center calculation) + Size? _imageWidgetSize; + + // Debounce timestamp for relative mouse mode toggle to prevent race conditions + // between Rust rdev grab loop and Flutter keyboard handling. + DateTime? _lastToggle; + + // Track key down state for exit shortcut. + // macOS: Cmd+G - track G key + // Windows/Linux: Ctrl+Alt - track whichever modifier was pressed last + // When key down is blocked (shortcut triggered), we also need to block + // the corresponding key up to avoid orphan key up events being sent to remote. + bool _exitShortcutKeyDown = false; + + // Callback to cancel external throttle timer when relative mouse mode is disabled. + VoidCallback? onDisabled; + + bool get isSupported { + // On Linux/Wayland, cursor warping is not supported, hide the option entirely. + if (isDesktop && isLinux && bind.mainCurrentIsWayland()) { + return false; + } + // Relative mouse mode is unsupported on remote Linux: + // 1. Long-press key events are unsupported. + // 2. The Wayland display server lacks cursor warping support. + final platform = peerPlatform(); + if (platform == kPeerPlatformLinux) { + return false; + } + final v = peerVersion(); + if (v.isEmpty) return false; + return versionCmp(v, kMinVersionForRelativeMouseMode) >= 0; + } + + Size? get imageWidgetSize => _imageWidgetSize; + + void updateImageWidgetSize(Size size) { + _imageWidgetSize = size; + if (enabled.value) { + _pointerLockCenterLocal = Offset(size.width / 2, size.height / 2); + } + } + + void updatePointerRegionTopLeftGlobal(PointerEvent e) { + _pointerRegionTopLeftGlobal = e.position - e.localPosition; + } + + /// Shared helper for handling exit shortcut for relative mouse mode. + /// Returns true if the event was handled and should not be forwarded. + /// + /// Exit shortcuts (only work when relative mouse mode is active): + /// - macOS: Cmd+G + /// - Windows/Linux: Ctrl+Alt (any order - triggered when both are pressed) + /// + /// [logicalKey] - the logical key of the event + /// [isKeyUp] - whether the event is a key up event + /// [isKeyDown] - whether the event is a key down event + /// [ctrlPressed], [altPressed], [commandPressed] - modifier states + bool _handleExitShortcut({ + required LogicalKeyboardKey logicalKey, + required bool isKeyUp, + required bool isKeyDown, + required bool ctrlPressed, + required bool altPressed, + required bool commandPressed, + }) { + if (!isDesktop || !keyboardPerm() || isViewCamera()) return false; + + // Only handle exit shortcuts when relative mouse mode is active + if (!enabled.value) return false; + + // Block key up if key down was blocked (to avoid orphan key up event on remote). + if (isKeyUp && _exitShortcutKeyDown) { + _exitShortcutKeyDown = false; + return true; + } + + if (!isKeyDown) return false; + + // macOS: Cmd+G to exit + if (isMacOS) { + final isGKey = logicalKey == LogicalKeyboardKey.keyG; + if (isGKey && commandPressed) { + _exitShortcutKeyDown = true; + setRelativeMouseMode(false); + return true; + } + return false; + } + + // Windows/Linux: Ctrl+Alt to exit + // Triggered when both modifiers are pressed (check on either Ctrl or Alt key down) + final isCtrlKey = logicalKey == LogicalKeyboardKey.controlLeft || + logicalKey == LogicalKeyboardKey.controlRight; + final isAltKey = logicalKey == LogicalKeyboardKey.altLeft || + logicalKey == LogicalKeyboardKey.altRight; + + // When Ctrl is pressed and Alt is already down, or vice versa + if ((isCtrlKey && altPressed) || (isAltKey && ctrlPressed)) { + _exitShortcutKeyDown = true; + setRelativeMouseMode(false); + return true; + } + + return false; + } + + bool handleKeyEvent( + KeyEvent e, { + required bool ctrlPressed, + required bool shiftPressed, + required bool altPressed, + required bool commandPressed, + }) { + return _handleExitShortcut( + logicalKey: e.logicalKey, + isKeyUp: e is KeyUpEvent, + isKeyDown: e is KeyDownEvent, + ctrlPressed: ctrlPressed, + altPressed: altPressed, + commandPressed: commandPressed, + ); + } + + /// Handle raw key events for relative mouse mode. + /// Returns true if the event was handled and should not be forwarded. + bool handleRawKeyEvent(RawKeyEvent e) { + final modifiers = e.data; + return _handleExitShortcut( + logicalKey: e.logicalKey, + isKeyUp: e is RawKeyUpEvent, + isKeyDown: e is RawKeyDownEvent, + ctrlPressed: modifiers.isControlPressed, + altPressed: modifiers.isAltPressed, + commandPressed: modifiers.isMetaPressed, + ); + } + + void onEnterOrLeaveImage(bool enter) { + if (!enabled.value) return; + + // Keep the shared pointer-in-image flag in sync. + setPointerInsideImage(enter); + + // macOS native mode: cursor is locked by CGAssociateMouseAndMouseCursorPosition, + // no need for recenter logic. + if (_isNativeRelativeMouseModeActive) { + return; + } + + if (!enter) { + _releaseCursorClip(); + return; + } + + // Windows: clip cursor to window rect + // Linux: use recenter method + updatePointerLockCenter().then((_) { + _recenterMouse(); + }); + } + + void onWindowBlur() { + if (!enabled.value) return; + + // Focus can change while the pointer is outside the window (e.g. taskbar activation). + // Do not rely on the previous "pointer inside" state across focus boundaries. + setPointerInsideImage(false); + // macOS native mode: don't call _releaseCursorClip as it would break CGAssociateMouseAndMouseCursorPosition + if (!_isNativeRelativeMouseModeActive) { + _releaseCursorClip(); + } + } + + void onWindowFocus() { + if (!enabled.value) return; + + // macOS native mode: cursor is already locked + if (_isNativeRelativeMouseModeActive) { + setPointerInsideImage(false); + return; + } + + // Guard: image widget size must be available for proper center calculation. + if (_imageWidgetSize == null) { + _disableWithCleanup(); + return; + } + + // Fail-safe: keep cursor usable on focus gain. Pointer lock will be re-engaged + // on the next pointer enter/move/hover inside the remote image. + setPointerInsideImage(false); + _releaseCursorClip(); + + // Best-effort: refresh center so the next engage is immediate. + updatePointerLockCenter(); + } + + void toggleRelativeMouseMode() { + final now = DateTime.now(); + if (_lastToggle != null && + now.difference(_lastToggle!).inMilliseconds < + kRelativeMouseModeToggleDebounceMs) { + return; + } + _lastToggle = now; + setRelativeMouseMode(!enabled.value); + } + + bool setRelativeMouseMode(bool value) { + // Web is not supported due to Pointer Lock API integration complexity with Flutter's input system + if (isWeb) { + return false; + } + + if (value) { + if (!keyboardPerm() || isViewCamera()) { + return false; + } + + if (isDesktop && _imageWidgetSize == null) { + // Desktop only: Ensure image widget size is available for proper center calculation. + showToast(translate('rel-mouse-not-ready-tip')); + return false; + } + + if (!isSupported) { + // Check server version support before enabling. + showToast(translate('rel-mouse-not-supported-peer-tip')); + return false; + } + } + + if (value) { + try { + if (isDesktop) { + final requestId = ++_enableRequestId; + if (isMacOS) { + // macOS: Use native relative mouse mode with CGAssociateMouseAndMouseCursorPosition + // This locks the cursor in place and provides raw delta via NSEvent monitor. + _enableNativeRelativeMouseMode().then((success) { + // Guard against stale callback: user may have toggled off relative mode + // while the async enable was in progress. + if (_enableRequestId != requestId) { + return; + } + if (success) { + _completeEnableRelativeMouseMode(); + } + // Note: _enableNativeRelativeMouseMode already handles its own cleanup on failure + }); + } else { + // Windows/Linux: Use Flutter-based cursor recenter approach + if (!getPointerInsideImage()) { + _releaseCursorClip(); + } + + updatePointerLockCenter().then((_) => _recenterMouse()).then((_) { + if (_enableRequestId != requestId) { + return; + } + _completeEnableRelativeMouseMode(); + }).catchError((e) { + if (_enableRequestId != requestId) { + return; + } + debugPrint('[RelMouse] Platform setup failed: $e'); + _resetState(); + }); + } + } else { + // Mobile: enable immediately (no platform-specific setup needed) + _completeEnableRelativeMouseMode(); + } + } catch (e) { + _disableWithCleanup(); + return false; + } + } else { + // Best-effort marker for Rust rdev grab loop (ESC behavior). + // Bypass keyboardPerm check to ensure Rust state is always synced, + // even if permission was revoked while relative mode was active. + _sendMouseMessageToSession( + { + 'relative_mouse_mode': '0', + }, + disableRelativeOnError: false, + bypassKeyboardPerm: true, + ); + + // Desktop only: cursor manipulation + if (isDesktop) { + if (isMacOS) { + // macOS: Disable native relative mouse mode + // This already calls CGAssociateMouseAndMouseCursorPosition(1) to re-associate mouse + _disableNativeRelativeMouseMode(); + } else { + _releaseCursorClip(); + } + } + enabled.value = false; + _resetState(); + onDisabled?.call(); + } + + return true; + } + + /// Called when platform setup completes successfully to finalize enabling relative mouse mode. + void _completeEnableRelativeMouseMode() { + enabled.value = true; + + // Show toast notification so user knows how to exit relative mouse mode (desktop only). + if (isDesktop) { + showToast( + translate('rel-mouse-exit-{${isMacOS ? "Cmd+G" : "Ctrl+Alt"}}-tip'), + alignment: Alignment.center); + } + + // Best-effort marker for Rust rdev grab loop (ESC behavior) and peer/server state. + // This uses a no-op delta so it does not move the remote cursor. + // Intentionally fire-and-forget: we don't block enabling on this marker message. + // Failures are logged but do not disable relative mouse mode. + _sendMouseMessageToSession( + { + 'relative_mouse_mode': '1', + 'type': 'move_relative', + 'x': '0', + 'y': '0', + }, + disableRelativeOnError: false, + ).catchError((e) { + debugPrint('[RelMouse] Failed to send enable marker: $e'); + return false; + }); + } + + // Flag to skip the first mouse move event after recenter (it's the recenter itself). + bool _skipNextMouseMove = false; + + /// Handle relative mouse movement based on current local pointer position. + /// Returns true if the event was handled in relative mode, false otherwise. + bool handleRelativeMouseMove(Offset localPosition) { + if (!enabled.value) return false; + + // macOS: Native mode handles delta via callback, skip Flutter-based handling. + if (_isNativeRelativeMouseModeActive) { + return true; + } + + // Pointer move/hover implies we're inside the remote image. + _ensurePointerLockEngaged(); + + // Skip the mouse move event triggered by recenter operation itself. + if (_skipNextMouseMove) { + _skipNextMouseMove = false; + _lastPointerLocalPos = localPosition; + return true; + } + + final lastLocal = _lastPointerLocalPos; + _lastPointerLocalPos = localPosition; + + // Linux-specific: Proactive recenter check before processing delta. + // On Linux, we don't have clip_cursor, so if the cursor moves too fast + // it may escape the window before _recenterIfNearEdge can catch it. + // Check now and recenter immediately if needed. + if (isLinux) { + _recenterIfNearEdgeLinux(localPosition); + } + + // Calculate delta from last position (not from center). + // This avoids issues with CGWarpMouseCursorPosition integer rounding. + if (lastLocal != null) { + final delta = localPosition - lastLocal; + if (delta.dx != 0 || delta.dy != 0) { + sendRelativeMouseMove(delta.dx, delta.dy); + } + } + + return true; + } + + /// Linux-specific: More aggressive recenter check to prevent cursor escape. + /// Called synchronously before processing mouse delta to ensure cursor stays within bounds. + void _recenterIfNearEdgeLinux(Offset localPosition) { + final size = _imageWidgetSize; + if (size == null) return; + + final edgeThreshold = _calculateEdgeThreshold(size); + + final nearLeft = localPosition.dx < edgeThreshold; + final nearRight = localPosition.dx > size.width - edgeThreshold; + final nearTop = localPosition.dy < edgeThreshold; + final nearBottom = localPosition.dy > size.height - edgeThreshold; + + if (nearLeft || nearRight || nearTop || nearBottom) { + _recenterMouse(); + } + } + + void sendRelativeMouseMove(double dx, double dy) { + if (!isDesktop) return; + + final delta = _accumulator.add(dx, dy, maxDelta: kMaxRelativeMouseDelta); + if (delta == null) return; + + // Buffer the delta for throttled sending. + _pendingDeltaX += delta.x; + _pendingDeltaY += delta.y; + + // Start or refresh the throttle timer. + if (_throttleTimer == null || !_throttleTimer!.isActive) { + _throttleTimer = Timer(_throttleInterval, () => _flushPendingDelta()); + } + } + + Future _flushPendingDelta() async { + if (!isDesktop) return; + if (_pendingDeltaX == 0 && _pendingDeltaY == 0) return; + + final x = _pendingDeltaX; + final y = _pendingDeltaY; + _pendingDeltaX = 0; + _pendingDeltaY = 0; + + final ok = await _sendMouseMessageToSession({ + 'type': 'move_relative', + 'x': '$x', + 'y': '$y', + }); + if (!ok) return; + + // Only recenter when mouse is near the edge of the image widget. + // This allows smooth mouse movement without constant recentering. + _recenterIfNearEdge(); + } + + // Edge threshold parameters for recenter detection. + // Threshold is calculated as: min(maxThreshold, min(width, height) * fraction) + static const double _edgeThresholdFraction = 0.1; // 10% of smaller dimension + static const double _edgeThresholdMax = + 100.0; // Maximum threshold in logical pixels + static const double _edgeThresholdMin = + 20.0; // Minimum threshold for very small widgets + + // Linux-specific edge threshold parameters (more aggressive to prevent cursor escape). + // On Linux, we don't have clip_cursor capability, so we need to recenter earlier + // to prevent the cursor from escaping the window when moving fast. + static const double _edgeThresholdFractionLinux = + 0.25; // 25% of smaller dimension + static const double _edgeThresholdMaxLinux = + 200.0; // Larger maximum threshold for Linux + static const double _edgeThresholdMinLinux = + 50.0; // Larger minimum threshold for Linux + + /// Calculate dynamic edge threshold based on widget size. + double _calculateEdgeThreshold(Size size) { + final smallerDimension = math.min(size.width, size.height); + if (isLinux) { + // Use more aggressive thresholds on Linux to prevent cursor escape. + final dynamicThreshold = smallerDimension * _edgeThresholdFractionLinux; + return dynamicThreshold.clamp( + _edgeThresholdMinLinux, _edgeThresholdMaxLinux); + } + final dynamicThreshold = smallerDimension * _edgeThresholdFraction; + // Clamp between min and max thresholds + return dynamicThreshold.clamp(_edgeThresholdMin, _edgeThresholdMax); + } + + /// Recenter the cursor only if it's near the edge of the image widget. + void _recenterIfNearEdge() { + final lastPos = _lastPointerLocalPos; + final size = _imageWidgetSize; + if (lastPos == null || size == null) return; + + // Dynamic threshold based on widget size + final edgeThreshold = _calculateEdgeThreshold(size); + + final nearLeft = lastPos.dx < edgeThreshold; + final nearRight = lastPos.dx > size.width - edgeThreshold; + final nearTop = lastPos.dy < edgeThreshold; + final nearBottom = lastPos.dy > size.height - edgeThreshold; + + if (nearLeft || nearRight || nearTop || nearBottom) { + _recenterMouse(); + } + } + + /// Send mouse button event without position (for relative mouse mode). + Future sendRelativeMouseButton(Map evt) async { + if (!enabled.value) return; + _ensurePointerLockEngaged(); + + final rawType = evt['type']; + final rawButtons = evt['buttons']; + if (rawType is! String || rawButtons is! int) return; + + final type = _mouseEventTypeToPeer(rawType); + if (type.isEmpty) return; + + final buttons = mouseButtonsToPeer(rawButtons); + if (buttons.isEmpty) return; + + await _sendMouseMessageToSession({ + 'type': type, + 'buttons': buttons, + }); + } + + static String _mouseEventTypeToPeer(String type) { + switch (type) { + case 'mousedown': + return kMouseEventTypeDown; + case 'mouseup': + return kMouseEventTypeUp; + default: + return ''; + } + } + + Future _sendMouseMessageToSession( + Map msg, { + bool disableRelativeOnError = true, + bool bypassKeyboardPerm = false, + }) async { + if (!bypassKeyboardPerm && !keyboardPerm()) return false; + if (isViewCamera()) return false; + + try { + await bind.sessionSendMouse( + sessionId: sessionId, + msg: json.encode(modify(msg)), + ); + return true; + } catch (e) { + debugPrint('[RelMouse] Error sending mouse message: $e'); + if (disableRelativeOnError && enabled.value) { + _disableWithCleanup(); + } + return false; + } + } + + /// Retry parameters for cursor re-centering. + static const int _recenterMaxRetries = 3; + static const Duration _recenterRetryDelay = Duration(milliseconds: 100); + + /// Recenter the cursor to the pointer lock center. + /// Fire-and-forget safe: prevents overlapping calls and catches errors internally. + Future _recenterMouse() async { + // Prevent overlapping recenter operations under high-frequency mouse moves. + if (_recenterInProgress) return; + _recenterInProgress = true; + + try { + if (!enabled.value) return; + if (!getPointerInsideImage()) return; + + final center = _pointerLockCenterScreen; + if (center == null) { + return; + } + + for (int attempt = 0; attempt < _recenterMaxRetries; attempt++) { + // Check preconditions before each attempt. + if (!enabled.value || !getPointerInsideImage()) return; + + final ok = bind.mainSetCursorPosition( + x: center.dx.toInt(), + y: center.dy.toInt(), + ); + if (ok) { + // Skip the next mouse move event - it's triggered by the recenter itself. + _skipNextMouseMove = true; + return; + } + + // Wait before retrying (except on the last attempt). + if (attempt < _recenterMaxRetries - 1) { + await Future.delayed(_recenterRetryDelay); + } + } + + // All attempts failed. + _disableWithCleanup(); + showToast(translate('rel-mouse-lock-failed-tip')); + } catch (e, st) { + debugPrint('[RelMouse] Unexpected error in _recenterMouse: $e\n$st'); + } finally { + _recenterInProgress = false; + } + } + + Future updatePointerLockCenter({Offset? localCenter}) async { + if (!isDesktop) return; + + // Null safety check for kWindowId. + if (kWindowId == null) { + if (enabled.value) { + _disableWithCleanup(); + } + return; + } + + try { + final wc = WindowController.fromWindowId(kWindowId!); + final frame = await wc.getFrame(); + + if (frame.width <= 0 || frame.height <= 0) { + if (enabled.value) { + _disableWithCleanup(); + } + return; + } + + if (localCenter != null) { + _pointerLockCenterLocal = localCenter; + } else if (_imageWidgetSize != null) { + _pointerLockCenterLocal = Offset( + _imageWidgetSize!.width / 2, + _imageWidgetSize!.height / 2, + ); + } else { + if (enabled.value) { + _disableWithCleanup(); + } + return; + } + + // Calculate screen coordinates for OS cursor positioning. + // Use PlatformDispatcher instead of deprecated ui.window. + final view = ui.PlatformDispatcher.instance.views.firstOrNull; + if (view == null) { + debugPrint('[RelMouse] No view available for coordinate calculation'); + if (enabled.value) { + _disableWithCleanup(); + } + return; + } + final scale = view.devicePixelRatio; + + if (_pointerRegionTopLeftGlobal != null && scale > 0) { + // On macOS, window frame and CGWarpMouseCursorPosition use points (not pixels). + // On Windows, they use pixels. + // Flutter's logical coordinates are in points on macOS. + final centerInView = + _pointerRegionTopLeftGlobal! + _pointerLockCenterLocal!; + + // Calculate client area offset (excluding title bar and borders) + final clientPhysical = view.physicalSize; + + // macOS: Window frame and CGWarpMouseCursorPosition both use points (not pixels). + // We convert clientPhysical (pixels) to points via `/ scale` to compute titleBarHeight, + // which is the difference between the total window height and the Flutter view height. + if (isMacOS) { + final clientHeightPoints = clientPhysical.height / scale; + final titleBarHeight = frame.height - clientHeightPoints; + + _pointerLockCenterScreen = Offset( + frame.left + centerInView.dx, + frame.top + titleBarHeight + centerInView.dy, + ); + } else { + // Windows/Linux: Use pixel coordinates. We estimate the client-area offset using + // a heuristic based on the difference between frame size and client physical size. + // This assumes symmetric horizontal borders (extraW / 2) and that the remaining + // vertical space (extraH - borderBottom) is the title bar height. + // Limitation: This heuristic may be inaccurate for maximized windows, custom window + // decorations, or when the OS uses different border styles. + // TODO: Replace this heuristic with platform API calls (e.g., GetClientRect on Windows) + // if precise client-area offsets are required. + final extraW = frame.width - clientPhysical.width; + final extraH = frame.height - clientPhysical.height; + final borderX = extraW > 0 ? extraW / 2 : 0.0; + final borderBottom = borderX; + final borderTop = extraH > borderBottom ? extraH - borderBottom : 0.0; + final clientTopLeftScreen = + Offset(frame.left + borderX, frame.top + borderTop); + + // Calculate tentative center, then validate it's within frame bounds. + // This guards against heuristic inaccuracies (e.g., maximized windows). + final tentativeCenter = Offset( + clientTopLeftScreen.dx + centerInView.dx * scale, + clientTopLeftScreen.dy + centerInView.dy * scale, + ); + final withinFrame = tentativeCenter.dx >= frame.left && + tentativeCenter.dx <= frame.left + frame.width && + tentativeCenter.dy >= frame.top && + tentativeCenter.dy <= frame.top + frame.height; + _pointerLockCenterScreen = withinFrame + ? tentativeCenter + : Offset( + frame.left + frame.width / 2, frame.top + frame.height / 2); + } + } else { + _pointerLockCenterScreen = Offset( + frame.left + frame.width / 2, + frame.top + frame.height / 2, + ); + } + + if (enabled.value && isWindows && getPointerInsideImage()) { + _applyCursorClipForFrame(frame); + } else if (enabled.value && isWindows && _cursorClipApplied) { + // Only release if we actually have a clip applied to avoid redundant FFI calls. + _releaseCursorClip(); + } + // macOS: no clip_cursor (CGAssociateMouseAndMouseCursorPosition stops mouse events) + // Instead, we use recenter method like other platforms. + } catch (e) { + if (enabled.value) { + _disableWithCleanup(); + } else { + _pointerLockCenterLocal = null; + _pointerLockCenterScreen = null; + } + } + } + + void _ensurePointerLockEngaged() { + if (!enabled.value) return; + if (!isDesktop) return; + + setPointerInsideImage(true); + + final needsCenter = + _pointerLockCenterLocal == null || _pointerLockCenterScreen == null; + // Windows only: cursor clip + final needsClip = isWindows && !_cursorClipApplied; + if (needsCenter || needsClip) { + updatePointerLockCenter() + .then((_) => _recenterMouse()) + .catchError((Object e, StackTrace st) { + debugPrint('[RelMouse] updatePointerLockCenter failed: $e\n$st'); + _disableWithCleanup(); + }); + } + } + + void _applyCursorClipForFrame(Rect frame) { + if (!isWindows) return; + + // Use PlatformDispatcher to get the device pixel ratio for proper scaling. + final view = ui.PlatformDispatcher.instance.views.firstOrNull; + final scale = view?.devicePixelRatio ?? 1.0; + + // Get the Flutter view's physical size (client area in pixels). + final clientPhysical = view?.physicalSize ?? ui.Size.zero; + + // Calculate the non-client area (OS window title bar, borders). + // frame includes the entire window (title bar + borders + client area). + final extraW = frame.width - clientPhysical.width; + final extraH = frame.height - clientPhysical.height; + + // Assume symmetric horizontal borders. + final borderX = extraW > 0 ? extraW / 2 : 0.0; + // Bottom border is typically the same as side borders. + final borderBottom = borderX; + // OS window title bar height is the remaining vertical non-client space. + final borderTop = extraH > borderBottom ? extraH - borderBottom : 0.0; + + // Calculate client area top-left in screen coordinates. + final clientTopLeftScreen = + Offset(frame.left + borderX, frame.top + borderTop); + + int left, top, right, bottom; + + // If we have precise image widget info, clip to the remote image area. + // This excludes the Flutter app's internal title bar and toolbar. + if (_pointerRegionTopLeftGlobal != null && + _imageWidgetSize != null && + scale > 0) { + // _pointerRegionTopLeftGlobal is in Flutter logical coordinates (relative to client area). + // Convert to screen physical coordinates. + left = (clientTopLeftScreen.dx + _pointerRegionTopLeftGlobal!.dx * scale) + .toInt(); + top = (clientTopLeftScreen.dy + _pointerRegionTopLeftGlobal!.dy * scale) + .toInt(); + right = (left + _imageWidgetSize!.width * scale).toInt(); + bottom = (top + _imageWidgetSize!.height * scale).toInt(); + } else { + // Fallback: clip to client area (excluding OS window decorations). + left = clientTopLeftScreen.dx.toInt(); + top = clientTopLeftScreen.dy.toInt(); + right = (frame.left + frame.width - borderX).toInt(); + bottom = (frame.top + frame.height - borderBottom).toInt(); + } + + _cursorClipApplied = bind.mainClipCursor( + left: left, + top: top, + right: right, + bottom: bottom, + enable: true, + ); + } + + void _releaseCursorClip() { + if (!_cursorClipApplied) return; + _cursorClipApplied = false; + if (!isWindows) return; + + bind.mainClipCursor( + left: 0, + top: 0, + right: 0, + bottom: 0, + enable: false, + ); + } + + void _resetState() { + // Flush any pending delta before clearing state. + // This ensures the last buffered movement is sent before values are zeroed. + // Fire-and-forget: we don't wait for the async send to complete. + if (_throttleTimer != null || _pendingDeltaX != 0 || _pendingDeltaY != 0) { + _throttleTimer?.cancel(); + _throttleTimer = null; + if (_pendingDeltaX != 0 || _pendingDeltaY != 0) { + final x = _pendingDeltaX; + final y = _pendingDeltaY; + _pendingDeltaX = 0; + _pendingDeltaY = 0; + // Send without awaiting; skip recenter since we're disabling. + _sendMouseMessageToSession({ + 'type': 'move_relative', + 'x': '$x', + 'y': '$y', + }, disableRelativeOnError: false); + } + } + _accumulator.reset(); + _pointerLockCenterLocal = null; + _pointerLockCenterScreen = null; + _pointerRegionTopLeftGlobal = null; + _lastPointerLocalPos = null; + _skipNextMouseMove = false; + setPointerInsideImage(false); + _cursorClipApplied = false; + _exitShortcutKeyDown = false; + } + + /// Core cleanup logic shared by [_disableWithCleanup] and [dispose]. + /// Sends disable message to Rust, releases platform resources, and resets state. + void _performCleanupCore() { + // Best-effort marker for Rust rdev grab loop (ESC behavior). + // Bypass keyboardPerm check to ensure Rust state is always synced. + _sendMouseMessageToSession( + { + 'relative_mouse_mode': '0', + }, + disableRelativeOnError: false, + bypassKeyboardPerm: true, + ); + + // macOS: Disable native relative mouse mode + // This already calls CGAssociateMouseAndMouseCursorPosition(1) to re-associate mouse + if (isMacOS) { + _disableNativeRelativeMouseMode(); + } else { + _releaseCursorClip(); + } + + _resetState(); + } + + void _disableWithCleanup() { + _performCleanupCore(); + enabled.value = false; + onDisabled?.call(); + } + + bool _disposed = false; + + void dispose() { + if (_disposed) return; + _disposed = true; + + _performCleanupCore(); + _imageWidgetSize = null; + _lastToggle = null; + // Set enabled to false BEFORE calling onDisabled, consistent with _disableWithCleanup(). + enabled.value = false; + // Trigger callback before clearing it, so external cleanup can run. + onDisabled?.call(); + onDisabled = null; + } +} diff --git a/flutter/lib/models/server_model.dart b/flutter/lib/models/server_model.dart index 1d800ef69..40c94fcf5 100644 --- a/flutter/lib/models/server_model.dart +++ b/flutter/lib/models/server_model.dart @@ -8,7 +8,6 @@ import 'package:flutter_hbb/mobile/pages/settings_page.dart'; import 'package:flutter_hbb/models/chat_model.dart'; import 'package:flutter_hbb/models/platform_model.dart'; import 'package:get/get.dart'; -import 'package:wakelock_plus/wakelock_plus.dart'; import 'package:window_manager/window_manager.dart'; import '../common.dart'; @@ -30,11 +29,13 @@ class ServerModel with ChangeNotifier { bool _inputOk = false; bool _audioOk = false; bool _fileOk = false; + bool _clipboardOk = false; bool _showElevation = false; bool hideCm = false; int _connectStatus = 0; // Rendezvous Server status String _verificationMethod = ""; String _temporaryPasswordLength = ""; + bool _allowNumericOneTimePassword = false; String _approveMode = ""; int _zeroClientLengthCounter = 0; @@ -49,6 +50,8 @@ class ServerModel with ChangeNotifier { Timer? cmHiddenTimer; + final _wakelockKey = UniqueKey(); + bool get isStart => _isStart; bool get mediaOk => _mediaOk; @@ -59,6 +62,8 @@ class ServerModel with ChangeNotifier { bool get fileOk => _fileOk; + bool get clipboardOk => _clipboardOk; + bool get showElevation => _showElevation; int get connectStatus => _connectStatus; @@ -109,6 +114,12 @@ class ServerModel with ChangeNotifier { */ } + bool get allowNumericOneTimePassword => _allowNumericOneTimePassword; + switchAllowNumericOneTimePassword() async { + await mainSetBoolOption( + kOptionAllowNumericOneTimePassword, !_allowNumericOneTimePassword); + } + TextEditingController get serverId => _serverId; TextEditingController get serverPasswd => _serverPasswd; @@ -209,6 +220,10 @@ class ServerModel with ChangeNotifier { _fileOk = fileOption != 'N'; } + // clipboard + final clipOption = await bind.mainGetOption(key: kOptionEnableClipboard); + _clipboardOk = clipOption != 'N'; + notifyListeners(); } @@ -220,6 +235,8 @@ class ServerModel with ChangeNotifier { final temporaryPasswordLength = await bind.mainGetOption(key: "temporary-password-length"); final approveMode = await bind.mainGetOption(key: kOptionApproveMode); + final numericOneTimePassword = + await mainGetBoolOption(kOptionAllowNumericOneTimePassword); /* var hideCm = option2bool( 'allow-hide-cm', await bind.mainGetOption(key: 'allow-hide-cm')); @@ -258,6 +275,10 @@ class ServerModel with ChangeNotifier { _temporaryPasswordLength = temporaryPasswordLength; update = true; } + if (_allowNumericOneTimePassword != numericOneTimePassword) { + _allowNumericOneTimePassword = numericOneTimePassword; + update = true; + } /* if (_hideCm != hideCm) { _hideCm = hideCm; @@ -277,7 +298,7 @@ class ServerModel with ChangeNotifier { } toggleAudio() async { - if (clients.isNotEmpty) { + if (clients.any((c) => !c.disconnected)) { await showClientsMayNotBeChangedAlert(parent.target); } if (!_audioOk && !await AndroidPermissionManager.check(kRecordAudio)) { @@ -295,7 +316,7 @@ class ServerModel with ChangeNotifier { } toggleFile() async { - if (clients.isNotEmpty) { + if (clients.any((c) => !c.disconnected)) { await showClientsMayNotBeChangedAlert(parent.target); } if (!_fileOk && @@ -315,8 +336,16 @@ class ServerModel with ChangeNotifier { notifyListeners(); } + toggleClipboard() async { + _clipboardOk = !clipboardOk; + bind.mainSetOption( + key: kOptionEnableClipboard, + value: clipboardOk ? defaultOptionYes : 'N'); + notifyListeners(); + } + toggleInput() async { - if (clients.isNotEmpty) { + if (clients.any((c) => !c.disconnected)) { await showClientsMayNotBeChangedAlert(parent.target); } if (_inputOk) { @@ -438,21 +467,8 @@ class ServerModel with ChangeNotifier { await parent.target?.invokeMethod("stop_service"); await bind.mainStopService(); notifyListeners(); - if (!isLinux) { - // current linux is not supported - WakelockPlus.disable(); - } - } - - Future setPermanentPassword(String newPW) async { - await bind.mainSetPermanentPassword(password: newPW); - await Future.delayed(Duration(milliseconds: 500)); - final pw = await bind.mainGetPermanentPassword(); - if (newPW == pw) { - return true; - } else { - return false; - } + // for androidUpdatekeepScreenOn only + WakelockManager.disable(_wakelockKey); } fetchID() async { @@ -533,10 +549,19 @@ class ServerModel with ChangeNotifier { if (index < 0) { _clients.add(client); } else { + if (_clients[index].authorized) { + _clients[index].privacyMode = client.privacyMode; + notifyListeners(); + return; + } _clients[index].authorized = true; + _clients[index].privacyMode = client.privacyMode; } } else { - if (_clients.any((c) => c.id == client.id)) { + final index = _clients.indexWhere((c) => c.id == client.id); + if (index >= 0) { + _clients[index].privacyMode = client.privacyMode; + notifyListeners(); return; } _clients.add(client); @@ -585,7 +610,13 @@ class ServerModel with ChangeNotifier { void showLoginDialog(Client client) { showClientDialog( client, - client.isFileTransfer ? "File Connection" : "Screen Connection", + client.isFileTransfer + ? "Transfer file" + : client.isViewCamera + ? "View camera" + : client.isTerminal + ? "Terminal" + : "Share screen", 'Do you accept?', 'android_new_connection_tip', () => sendLoginResponse(client, false), @@ -664,7 +695,7 @@ class ServerModel with ChangeNotifier { void sendLoginResponse(Client client, bool res) async { if (res) { bind.cmLoginRes(connId: client.id, res: res); - if (!client.isFileTransfer) { + if (!client.isFileTransfer && !client.isTerminal) { parent.target?.invokeMethod("start_capture"); } parent.target?.invokeMethod("cancel_notification", client.id); @@ -763,12 +794,10 @@ class ServerModel with ChangeNotifier { final on = ((keepScreenOn == KeepScreenOn.serviceOn) && _isStart) || (keepScreenOn == KeepScreenOn.duringControlled && _clients.map((e) => !e.disconnected).isNotEmpty); - if (on != await WakelockPlus.enabled) { - if (on) { - WakelockPlus.enable(); - } else { - WakelockPlus.disable(); - } + if (on) { + WakelockManager.enable(_wakelockKey, isServer: true); + } else { + WakelockManager.disable(_wakelockKey); } } } @@ -776,15 +805,20 @@ class ServerModel with ChangeNotifier { enum ClientType { remote, file, + camera, portForward, + terminal, } class Client { int id = 0; // client connections inner count id bool authorized = false; bool isFileTransfer = false; + bool isViewCamera = false; + bool isTerminal = false; String portForward = ""; String name = ""; + String avatar = ""; String peerId = ""; // peer user's id,show at app bool keyboard = false; bool clipboard = false; @@ -793,6 +827,7 @@ class Client { bool restart = false; bool recording = false; bool blockInput = false; + bool privacyMode = false; bool disconnected = false; bool fromSwitch = false; bool inVoiceCall = false; @@ -800,15 +835,19 @@ class Client { RxInt unreadChatMessageCount = 0.obs; - Client(this.id, this.authorized, this.isFileTransfer, this.name, this.peerId, - this.keyboard, this.clipboard, this.audio); + Client(this.id, this.authorized, this.isFileTransfer, this.isViewCamera, + this.name, this.peerId, this.keyboard, this.clipboard, this.audio); Client.fromJson(Map json) { id = json['id']; authorized = json['authorized']; isFileTransfer = json['is_file_transfer']; + // TODO: no entry then default. + isViewCamera = json['is_view_camera']; + isTerminal = json['is_terminal'] ?? false; portForward = json['port_forward']; name = json['name']; + avatar = json['avatar'] ?? ''; peerId = json['peer_id']; keyboard = json['keyboard']; clipboard = json['clipboard']; @@ -817,6 +856,7 @@ class Client { restart = json['restart']; recording = json['recording']; blockInput = json['block_input']; + privacyMode = json['privacy_mode'] ?? privacyMode; disconnected = json['disconnected']; fromSwitch = json['from_switch']; inVoiceCall = json['in_voice_call']; @@ -828,8 +868,11 @@ class Client { data['id'] = id; data['authorized'] = authorized; data['is_file_transfer'] = isFileTransfer; + data['is_view_camera'] = isViewCamera; + data['is_terminal'] = isTerminal; data['port_forward'] = portForward; data['name'] = name; + data['avatar'] = avatar; data['peer_id'] = peerId; data['keyboard'] = keyboard; data['clipboard'] = clipboard; @@ -838,6 +881,7 @@ class Client { data['restart'] = restart; data['recording'] = recording; data['block_input'] = blockInput; + data['privacy_mode'] = privacyMode; data['disconnected'] = disconnected; data['from_switch'] = fromSwitch; data['in_voice_call'] = inVoiceCall; @@ -848,6 +892,10 @@ class Client { ClientType type_() { if (isFileTransfer) { return ClientType.file; + } else if (isViewCamera) { + return ClientType.camera; + } else if (isTerminal) { + return ClientType.terminal; } else if (portForward.isNotEmpty) { return ClientType.portForward; } else { diff --git a/flutter/lib/models/state_model.dart b/flutter/lib/models/state_model.dart index f8f06cc3f..77195d662 100644 --- a/flutter/lib/models/state_model.dart +++ b/flutter/lib/models/state_model.dart @@ -1,5 +1,4 @@ import 'package:desktop_multi_window/desktop_multi_window.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_hbb/common.dart'; import 'package:get/get.dart'; @@ -18,6 +17,7 @@ class StateGlobal { final RxDouble _windowBorderWidth = RxDouble(kWindowBorderWidth); final RxBool showRemoteToolBar = false.obs; final svcStatus = SvcStatus.notReady.obs; + final RxInt videoConnCount = 0.obs; final RxBool isFocused = false.obs; // for mobile and web bool isInMainPage = true; @@ -25,8 +25,15 @@ class StateGlobal { final isPortrait = false.obs; + final updateUrl = ''.obs; + String _inputSource = ''; + // Track relative mouse mode state for each peer connection. + // Key: peerId, Value: true if relative mouse mode is active. + // Note: This is session-only runtime state, NOT persisted to config. + final RxMap relativeMouseModeState = {}.obs; + // Use for desktop -> remote toolbar -> resolution final Map> _lastResolutionGroupValues = {}; diff --git a/flutter/lib/models/terminal_model.dart b/flutter/lib/models/terminal_model.dart new file mode 100644 index 000000000..8961d2dd8 --- /dev/null +++ b/flutter/lib/models/terminal_model.dart @@ -0,0 +1,497 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:desktop_multi_window/desktop_multi_window.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hbb/common.dart'; +import 'package:flutter_hbb/consts.dart'; +import 'package:flutter_hbb/main.dart'; +import 'package:xterm/xterm.dart'; + +import 'model.dart'; +import 'platform_model.dart'; + +class TerminalModel with ChangeNotifier { + final String id; // peer id + final FFI parent; + final int terminalId; + late final Terminal terminal; + late final TerminalController terminalController; + + bool _terminalOpened = false; + bool get terminalOpened => _terminalOpened; + + bool _disposed = false; + + final _inputBuffer = []; + // Buffer for output data received before terminal view has valid dimensions. + // This prevents NaN errors when writing to terminal before layout is complete. + final _pendingOutputChunks = []; + final _pendingOutputSuppressFlags = []; + int _pendingOutputSize = 0; + static const int _kMaxOutputBufferChars = 8 * 1024; + // View ready state: true when terminal has valid dimensions, safe to write + bool _terminalViewReady = false; + bool _markViewReadyScheduled = false; + bool _suppressTerminalOutput = false; + bool _suppressNextTerminalDataOutput = false; + + void Function(int w, int h, int pw, int ph)? onResizeExternal; + + Future _handleInput(String data) async { + // Soft keyboards (notably iOS) emit '\n' when Enter is pressed, while a + // real keyboard's Enter sends '\r'. Some Android keyboards also emit '\n'. + // - Peer Windows: '\r' works, '\n' is just a newline. + // - Peer Linux: canonical-mode shells accept both, but raw-mode apps + // (readline, prompt_toolkit, vim, TUI frameworks) expect '\r'. + // - Peer macOS: same as Linux, raw-mode apps expect '\r' + // (https://github.com/rustdesk/rustdesk/issues/14907). + // So on mobile / web-mobile, always normalize a lone '\n' to '\r'. + // We deliberately do not touch multi-character payloads (e.g. pasted text) + // so embedded newlines in pasted content are preserved. + final isMobileOrWebMobile = (isMobile || (isWeb && !isWebDesktop)); + if (isMobileOrWebMobile && data == '\n') { + data = '\r'; + } + if (_terminalOpened) { + // Send user input to remote terminal + try { + await bind.sessionSendTerminalInput( + sessionId: parent.sessionId, + terminalId: terminalId, + data: data, + ); + } catch (e) { + debugPrint('[TerminalModel] Error sending terminal input: $e'); + } + } else { + debugPrint('[TerminalModel] Terminal not opened yet, buffering input'); + _inputBuffer.add(data); + } + } + + TerminalModel(this.parent, [this.terminalId = 0]) : id = parent.id { + terminal = Terminal(maxLines: 10000); + terminalController = TerminalController(); + + // Setup terminal callbacks + terminal.onOutput = (data) { + if (_suppressTerminalOutput) return; + _handleInput(data); + }; + + terminal.onResize = (w, h, pw, ph) async { + // Validate all dimensions before using them + if (w > 0 && h > 0 && pw > 0 && ph > 0) { + debugPrint( + '[TerminalModel] Terminal resized to ${w}x$h (pixel: ${pw}x$ph)'); + + // This piece of code must be placed before the conditional check in order to initialize properly. + onResizeExternal?.call(w, h, pw, ph); + + // Mark terminal view as ready and flush any buffered output on first valid resize. + // Must be after onResizeExternal so the view layer has valid dimensions before flushing. + if (!_terminalViewReady) { + _scheduleMarkViewReady(); + } + + if (_terminalOpened) { + // Notify remote terminal of resize + try { + await bind.sessionResizeTerminal( + sessionId: parent.sessionId, + terminalId: terminalId, + rows: h, + cols: w, + ); + } catch (e) { + debugPrint('[TerminalModel] Error resizing terminal: $e'); + } + } + } else { + debugPrint( + '[TerminalModel] Invalid terminal dimensions: ${w}x$h (pixel: ${pw}x$ph)'); + } + }; + } + + void onReady() { + parent.dialogManager.dismissAll(); + + // Fire and forget - don't block onReady. If the transport reconnects while + // this model is still open, re-send OpenTerminal so the remote service marks + // the persistent session active again and resumes output streaming. + openTerminal(force: _terminalOpened).catchError((e) { + debugPrint('[TerminalModel] Error opening terminal: $e'); + }); + } + + Future openTerminal({bool force = false}) async { + if (_terminalOpened && !force) return; + // Request the remote side to open a terminal with default shell + // The remote side will decide which shell to use based on its OS + + // Get terminal dimensions, ensuring they are valid + int rows = 24; + int cols = 80; + + if (terminal.viewHeight > 0) { + rows = terminal.viewHeight; + } + if (terminal.viewWidth > 0) { + cols = terminal.viewWidth; + } + + debugPrint( + '[TerminalModel] Opening terminal $terminalId, sessionId: ${parent.sessionId}, size: ${cols}x$rows'); + try { + await bind + .sessionOpenTerminal( + sessionId: parent.sessionId, + terminalId: terminalId, + rows: rows, + cols: cols, + ) + .timeout( + const Duration(seconds: 5), + onTimeout: () { + throw TimeoutException( + 'sessionOpenTerminal timed out after 5 seconds'); + }, + ); + debugPrint('[TerminalModel] sessionOpenTerminal called successfully'); + } catch (e) { + debugPrint('[TerminalModel] Error calling sessionOpenTerminal: $e'); + // Optionally show error to user + if (e is TimeoutException) { + _writeToTerminal('Failed to open terminal: Connection timeout\r\n'); + } + } + } + + Future sendVirtualKey(String data) async { + return _handleInput(data); + } + + Future closeTerminal() async { + if (_terminalOpened) { + try { + await bind + .sessionCloseTerminal( + sessionId: parent.sessionId, + terminalId: terminalId, + ) + .timeout( + const Duration(seconds: 3), + onTimeout: () { + throw TimeoutException( + 'sessionCloseTerminal timed out after 3 seconds'); + }, + ); + debugPrint('[TerminalModel] sessionCloseTerminal called successfully'); + } catch (e) { + debugPrint('[TerminalModel] Error calling sessionCloseTerminal: $e'); + // Continue with cleanup even if close fails + } + _terminalOpened = false; + notifyListeners(); + } + } + + static int getTerminalIdFromEvt(Map evt) { + if (evt.containsKey('terminal_id')) { + final v = evt['terminal_id']; + if (v is int) { + // Desktop and mobile send terminal_id as an int + return v; + } else if (v is String) { + // Web sends terminal_id as a string + final parsed = int.tryParse(v); + if (parsed != null) { + return parsed; + } else { + debugPrint( + '[TerminalModel] Failed to parse terminal_id as integer: $v. Expected a numeric string.'); + return 0; + } + } else { + // Unexpected type, log and handle gracefully + debugPrint( + '[TerminalModel] Unexpected terminal_id type: ${v.runtimeType}, value: $v. Expected int or String.'); + return 0; + } + } else { + debugPrint('[TerminalModel] Event does not contain terminal_id'); + return 0; + } + } + + static bool getSuccessFromEvt(Map evt) { + if (evt.containsKey('success')) { + final v = evt['success']; + if (v is bool) { + // Desktop and mobile + return v; + } else if (v is String) { + // Web + return v.toLowerCase() == 'true'; + } else { + // Unexpected type, log and handle gracefully + debugPrint( + '[TerminalModel] Unexpected success type: ${v.runtimeType}, value: $v. Expected bool or String.'); + return false; + } + } else { + debugPrint('[TerminalModel] Event does not contain success'); + return false; + } + } + + void handleTerminalResponse(Map evt) { + final String? type = evt['type']; + final int evtTerminalId = getTerminalIdFromEvt(evt); + + // Only handle events for this terminal + if (evtTerminalId != terminalId) { + debugPrint( + '[TerminalModel] Ignoring event for terminal $evtTerminalId (not mine)'); + return; + } + + switch (type) { + case 'opened': + _handleTerminalOpened(evt); + break; + case 'data': + _handleTerminalData(evt); + break; + case 'closed': + _handleTerminalClosed(evt); + break; + case 'error': + _handleTerminalError(evt); + break; + } + } + + void _handleTerminalOpened(Map evt) { + final bool success = getSuccessFromEvt(evt); + final String message = evt['message']?.toString() ?? ''; + final String? serviceId = evt['service_id']?.toString(); + + debugPrint( + '[TerminalModel] Terminal opened response: success=$success, message=$message, service_id=$serviceId'); + + if (success) { + _terminalOpened = true; + + // On reconnect, the server may replay recent output. That replay can include + // terminal queries like DSR/DA; xterm answers them through onOutput as + // "^[[1;1R^[[2;2R^[[>0;0;0c", which must not be sent back to the peer. + final replayTerminalOutput = evt['replay_terminal_output']; + _suppressNextTerminalDataOutput = replayTerminalOutput == true || + message == 'Reconnected to existing terminal with pending output'; + + // Fallback: if terminal view is not yet ready but already has valid + // dimensions (e.g. layout completed before open response arrived), + // mark view ready now to avoid output stuck in buffer indefinitely. + if (!_terminalViewReady && + terminal.viewWidth > 0 && + terminal.viewHeight > 0) { + _scheduleMarkViewReady(); + } + + // Process any buffered input + _processBufferedInputAsync().then((_) { + notifyListeners(); + }).catchError((e) { + debugPrint('[TerminalModel] Error processing buffered input: $e'); + notifyListeners(); + }); + + final persistentSessions = + (evt['persistent_sessions'] as List? ?? []) + .whereType() + .where((id) => !parent.terminalModels.containsKey(id)) + .toList(); + if (kWindowId != null && persistentSessions.isNotEmpty) { + DesktopMultiWindow.invokeMethod( + kWindowId!, + kWindowEventRestoreTerminalSessions, + jsonEncode({ + 'peer_id': id, + 'persistent_sessions': persistentSessions, + })); + } + } else { + _writeToTerminal('Failed to open terminal: $message\r\n'); + } + } + + Future _processBufferedInputAsync() async { + final buffer = List.from(_inputBuffer); + _inputBuffer.clear(); + + for (final data in buffer) { + try { + await bind.sessionSendTerminalInput( + sessionId: parent.sessionId, + terminalId: terminalId, + data: data, + ); + } catch (e) { + debugPrint('[TerminalModel] Error sending buffered input: $e'); + } + } + } + + void _handleTerminalData(Map evt) { + final data = evt['data']; + + if (data != null) { + final suppressTerminalOutput = _suppressNextTerminalDataOutput; + _suppressNextTerminalDataOutput = false; + try { + String text = ''; + if (data is String) { + // Try to decode as base64 first + try { + final bytes = base64Decode(data); + text = utf8.decode(bytes, allowMalformed: true); + } catch (e) { + // If base64 decode fails, treat as plain text + text = data; + } + } else if (data is List) { + // Handle if data comes as byte array + text = utf8.decode(List.from(data), allowMalformed: true); + } else { + debugPrint('[TerminalModel] Unknown data type: ${data.runtimeType}'); + return; + } + + _writeToTerminal(text, suppressTerminalOutput: suppressTerminalOutput); + } catch (e) { + debugPrint('[TerminalModel] Failed to process terminal data: $e'); + } + } + } + + /// Write text to terminal, buffering if the view is not yet ready. + /// All terminal output should go through this method to avoid NaN errors + /// from writing before the terminal view has valid layout dimensions. + void _writeToTerminal( + String text, { + bool suppressTerminalOutput = false, + }) { + if (!_terminalViewReady) { + // If a single chunk exceeds the cap, keep only its tail. + // Note: truncation may split a multi-byte ANSI escape sequence, + // which can cause a brief visual glitch on flush. This is acceptable + // because it only affects the pre-layout buffering window and the + // terminal will self-correct on subsequent output. + if (text.length >= _kMaxOutputBufferChars) { + final truncated = text.substring(text.length - _kMaxOutputBufferChars); + _pendingOutputChunks + ..clear() + ..add(truncated); + _pendingOutputSuppressFlags + ..clear() + ..add(suppressTerminalOutput); + _pendingOutputSize = truncated.length; + } else { + _pendingOutputChunks.add(text); + _pendingOutputSuppressFlags.add(suppressTerminalOutput); + _pendingOutputSize += text.length; + // Drop oldest chunks if exceeds limit (whole chunks to preserve ANSI sequences) + while (_pendingOutputSize > _kMaxOutputBufferChars && + _pendingOutputChunks.length > 1) { + final removed = _pendingOutputChunks.removeAt(0); + _pendingOutputSuppressFlags.removeAt(0); + _pendingOutputSize -= removed.length; + } + } + return; + } + _writeTerminalChunk(text, suppressTerminalOutput: suppressTerminalOutput); + } + + void _flushOutputBuffer() { + if (_pendingOutputChunks.isEmpty) return; + debugPrint( + '[TerminalModel] Flushing $_pendingOutputSize buffered chars (${_pendingOutputChunks.length} chunks)'); + for (var i = 0; i < _pendingOutputChunks.length; i++) { + _writeTerminalChunk( + _pendingOutputChunks[i], + suppressTerminalOutput: _pendingOutputSuppressFlags[i], + ); + } + _pendingOutputChunks.clear(); + _pendingOutputSuppressFlags.clear(); + _pendingOutputSize = 0; + } + + void _writeTerminalChunk( + String text, { + required bool suppressTerminalOutput, + }) { + if (!suppressTerminalOutput) { + terminal.write(text); + return; + } + final previous = _suppressTerminalOutput; + _suppressTerminalOutput = true; + try { + terminal.write(text); + } finally { + _suppressTerminalOutput = previous; + } + } + + /// Mark terminal view as ready and flush buffered output. + void _scheduleMarkViewReady() { + if (_disposed || _terminalViewReady || _markViewReadyScheduled) return; + _markViewReadyScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _markViewReadyScheduled = false; + if (_disposed || _terminalViewReady) return; + if (terminal.viewWidth > 0 && terminal.viewHeight > 0) { + _markViewReady(); + } + }); + WidgetsBinding.instance.ensureVisualUpdate(); + } + + void _markViewReady() { + if (_terminalViewReady) return; + _terminalViewReady = true; + _flushOutputBuffer(); + } + + void _handleTerminalClosed(Map evt) { + final int exitCode = evt['exit_code'] ?? 0; + _writeToTerminal('\r\nTerminal closed with exit code: $exitCode\r\n'); + _terminalOpened = false; + notifyListeners(); + } + + void _handleTerminalError(Map evt) { + final String message = evt['message'] ?? 'Unknown error'; + _writeToTerminal('\r\nTerminal error: $message\r\n'); + } + + @override + void dispose() { + if (_disposed) return; + _disposed = true; + // Clear buffers to free memory + _inputBuffer.clear(); + _pendingOutputChunks.clear(); + _pendingOutputSuppressFlags.clear(); + _pendingOutputSize = 0; + _markViewReadyScheduled = false; + _suppressNextTerminalDataOutput = false; + // Terminal cleanup is handled server-side when service closes + super.dispose(); + } +} diff --git a/flutter/lib/models/user_model.dart b/flutter/lib/models/user_model.dart index 9d9c762d9..cecb58eaa 100644 --- a/flutter/lib/models/user_model.dart +++ b/flutter/lib/models/user_model.dart @@ -16,9 +16,25 @@ bool refreshingUser = false; class UserModel { final RxString userName = ''.obs; + final RxString displayName = ''.obs; + final RxString avatar = ''.obs; final RxBool isAdmin = false.obs; final RxString networkError = ''.obs; bool get isLogin => userName.isNotEmpty; + String get displayNameOrUserName => + displayName.value.trim().isEmpty ? userName.value : displayName.value; + String get accountLabelWithHandle { + final username = userName.value.trim(); + if (username.isEmpty) { + return ''; + } + final preferred = displayName.value.trim(); + if (preferred.isEmpty || preferred == username) { + return username; + } + return '$preferred (@$username)'; + } + WeakReference parent; UserModel(this.parent) { @@ -66,7 +82,7 @@ class UserModel { reset(resetOther: status == 401); return; } - final data = json.decode(utf8.decode(response.bodyBytes)); + final data = json.decode(decode_http_response(response)); final error = data['error']; if (error != null) { throw error; @@ -98,7 +114,9 @@ class UserModel { _updateLocalUserInfo() { final userInfo = getLocalUserInfo(); if (userInfo != null) { - userName.value = userInfo['name']; + userName.value = (userInfo['name'] ?? '').toString(); + displayName.value = (userInfo['display_name'] ?? '').toString(); + avatar.value = (userInfo['avatar'] ?? '').toString(); } } @@ -110,12 +128,20 @@ class UserModel { await gFFI.groupModel.reset(); } userName.value = ''; + displayName.value = ''; + avatar.value = ''; } _parseAndUpdateUser(UserPayload user) { userName.value = user.name; + displayName.value = user.displayName; + avatar.value = user.avatar; isAdmin.value = user.isAdmin; bind.mainSetLocalOption(key: 'user_info', value: jsonEncode(user)); + if (isWeb) { + // ugly here, tmp solution + bind.mainSetLocalOption(key: 'verifier', value: user.verifier ?? ''); + } } // update ab and group status @@ -156,7 +182,7 @@ class UserModel { final Map body; try { - body = jsonDecode(utf8.decode(resp.bodyBytes)); + body = jsonDecode(decode_http_response(resp)); } catch (e) { debugPrint("login: jsonDecode resp body failed: ${e.toString()}"); if (resp.statusCode != 200) { @@ -184,7 +210,9 @@ class UserModel { rethrow; } - if (loginResponse.user != null) { + final isLogInDone = loginResponse.type == HttpType.kAuthResTypeToken && + loginResponse.access_token != null; + if (isLogInDone && loginResponse.user != null) { _parseAndUpdateUser(loginResponse.user!); } diff --git a/flutter/lib/models/web_model.dart b/flutter/lib/models/web_model.dart index a4312d959..5241c3974 100644 --- a/flutter/lib/models/web_model.dart +++ b/flutter/lib/models/web_model.dart @@ -8,10 +8,12 @@ import 'dart:html'; import 'dart:async'; 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/web/bridge.dart'; import 'package:flutter_hbb/common.dart'; +import 'package:uuid/uuid.dart'; final List> mouseListeners = []; final List> keyListeners = []; @@ -49,14 +51,15 @@ class PlatformFFI { } bool registerEventHandler( - String eventName, String handlerName, HandleEvent handler) { + String eventName, String handlerName, HandleEvent handler, + {bool replace = false}) { debugPrint('registerEventHandler $eventName $handlerName'); var handlers = _eventHandlers[eventName]; if (handlers == null) { _eventHandlers[eventName] = {handlerName: handler}; return true; } else { - if (handlers.containsKey(handlerName)) { + if (!replace && handlers.containsKey(handlerName)) { return false; } else { handlers[handlerName] = handler; @@ -112,6 +115,17 @@ class PlatformFFI { context["onInitFinished"] = () { completer.complete(); }; + context['dialog'] = (type, title, text) { + final uuid = Uuid(); + msgBox(SessionID(uuid.v4()), type, title, text, '', gFFI.dialogManager); + }; + context['loginDialog'] = () { + loginDialog(); + }; + context['closeConnection'] = () { + gFFI.dialogManager.dismissAll(); + closeConnection(); + }; context.callMethod('init'); version = getByName('version'); window.onContextMenu.listen((event) { diff --git a/flutter/lib/plugin/utils/dialogs.dart b/flutter/lib/plugin/utils/dialogs.dart index f30248f7a..6fdb86ab4 100644 --- a/flutter/lib/plugin/utils/dialogs.dart +++ b/flutter/lib/plugin/utils/dialogs.dart @@ -2,16 +2,18 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_hbb/common.dart'; -import 'package:flutter_hbb/models/platform_model.dart'; void showPeerSelectionDialog( {bool singleSelection = false, - required Function(List) onPeersCallback}) { - final peers = bind.mainLoadRecentPeersSync(); + 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 sync failed."); + // debugPrint("load recent peers failed."); return; } + Map map = jsonDecode(peers); List peersList = map['peers'] ?? []; final selected = List.empty(growable: true); diff --git a/flutter/lib/utils/http_service.dart b/flutter/lib/utils/http_service.dart index 49855017b..1618e25ff 100644 --- a/flutter/lib/utils/http_service.dart +++ b/flutter/lib/utils/http_service.dart @@ -1,7 +1,9 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; +import 'package:flutter_hbb/consts.dart'; import 'package:http/http.dart' as http; import '../models/platform_model.dart'; +import 'package:flutter_hbb/common.dart'; export 'package:http/http.dart' show Response; enum HttpMethod { get, post, put, delete } @@ -15,11 +17,19 @@ class HttpService { }) async { headers ??= {'Content-Type': 'application/json'}; - // Determine if there is currently a proxy setting, and if so, use FFI to call the Rust HTTP method. - final isProxy = await bind.mainGetProxyStatus(); + // Use Rust HTTP implementation for non-web platforms for consistency. + var useFlutterHttp = (isWeb || kIsWeb); + if (!useFlutterHttp) { + final enableFlutterHttpOnRust = + mainGetLocalBoolOptionSync(kOptionEnableFlutterHttpOnRust); + // Use flutter http if: + // Not `enableFlutterHttpOnRust` and no proxy is set + useFlutterHttp = + !(enableFlutterHttpOnRust || await bind.mainGetProxyStatus()); + } - if (!isProxy) { - return await _pollFultterHttp(url, method, headers: headers, body: body); + if (useFlutterHttp) { + return await _pollFlutterHttp(url, method, headers: headers, body: body); } String headersJson = jsonEncode(headers); @@ -34,7 +44,7 @@ class HttpService { return _parseHttpResponse(resJson); } - Future _pollFultterHttp( + Future _pollFlutterHttp( Uri url, HttpMethod method, { Map? headers, @@ -87,7 +97,8 @@ class HttpService { int statusCode = parsedJson['status_code']; return http.Response(body, statusCode, headers: headers); } catch (e) { - throw Exception('Failed to parse response: $e'); + print('Failed to parse response\n$responseJson\nError:\n$e'); + throw Exception('Failed to parse response.\n$responseJson'); } } } diff --git a/flutter/lib/utils/multi_window_manager.dart b/flutter/lib/utils/multi_window_manager.dart index 70001ffdf..9e26f8cf9 100644 --- a/flutter/lib/utils/multi_window_manager.dart +++ b/flutter/lib/utils/multi_window_manager.dart @@ -11,7 +11,15 @@ import 'package:flutter_hbb/models/input_model.dart'; /// must keep the order // ignore: constant_identifier_names -enum WindowType { Main, RemoteDesktop, FileTransfer, PortForward, Unknown } +enum WindowType { + Main, + RemoteDesktop, + FileTransfer, + ViewCamera, + PortForward, + Terminal, + Unknown +} extension Index on int { WindowType get windowType { @@ -23,7 +31,11 @@ extension Index on int { case 2: return WindowType.FileTransfer; case 3: + return WindowType.ViewCamera; + case 4: return WindowType.PortForward; + case 5: + return WindowType.Terminal; default: return WindowType.Unknown; } @@ -50,31 +62,47 @@ class RustDeskMultiWindowManager { final List _windowActiveCallbacks = List.empty(growable: true); final List _remoteDesktopWindows = List.empty(growable: true); final List _fileTransferWindows = List.empty(growable: true); + final List _viewCameraWindows = List.empty(growable: true); final List _portForwardWindows = List.empty(growable: true); + final List _terminalWindows = List.empty(growable: true); - moveTabToNewWindow(int windowId, String peerId, String sessionId) async { + moveTabToNewWindow(int windowId, String peerId, String sessionId, + WindowType windowType) async { var params = { - 'type': WindowType.RemoteDesktop.index, + 'type': windowType.index, 'id': peerId, 'tab_window_id': windowId, 'session_id': sessionId, }; - await _newSession( - false, - WindowType.RemoteDesktop, - kWindowEventNewRemoteDesktop, - peerId, - _remoteDesktopWindows, - jsonEncode(params), - ); + if (windowType == WindowType.RemoteDesktop) { + await _newSession( + false, + WindowType.RemoteDesktop, + kWindowEventNewRemoteDesktop, + peerId, + _remoteDesktopWindows, + jsonEncode(params), + ); + } else if (windowType == WindowType.ViewCamera) { + await _newSession( + false, + WindowType.ViewCamera, + kWindowEventNewViewCamera, + peerId, + _viewCameraWindows, + jsonEncode(params), + ); + } } // This function must be called in the main window thread. // Because the _remoteDesktopWindows is managed in that thread. openMonitorSession(int windowId, String peerId, int display, int displayCount, - Rect? screenRect) async { - if (_remoteDesktopWindows.length > 1) { - for (final windowId in _remoteDesktopWindows) { + Rect? screenRect, int windowType) async { + final isCamera = windowType == WindowType.ViewCamera.index; + final windowIDs = isCamera ? _viewCameraWindows : _remoteDesktopWindows; + if (windowIDs.length > 1) { + for (final windowId in windowIDs) { if (await DesktopMultiWindow.invokeMethod( windowId, kWindowEventActiveDisplaySession, @@ -91,7 +119,7 @@ class RustDeskMultiWindowManager { ? List.generate(displayCount, (index) => index) : [display]; var params = { - 'type': WindowType.RemoteDesktop.index, + 'type': windowType, 'id': peerId, 'tab_window_id': windowId, 'display': display, @@ -107,10 +135,10 @@ class RustDeskMultiWindowManager { } await _newSession( false, - WindowType.RemoteDesktop, - kWindowEventNewRemoteDesktop, + windowType.windowType, + isCamera ? kWindowEventNewViewCamera : kWindowEventNewRemoteDesktop, peerId, - _remoteDesktopWindows, + windowIDs, jsonEncode(params), screenRect: screenRect, ); @@ -277,6 +305,27 @@ class RustDeskMultiWindowManager { ); } + Future newViewCamera( + String remoteId, { + String? password, + bool? isSharedPassword, + String? switchUuid, + bool? forceRelay, + String? connToken, + }) async { + return await newSession( + WindowType.ViewCamera, + kWindowEventNewViewCamera, + remoteId, + _viewCameraWindows, + password: password, + forceRelay: forceRelay, + switchUuid: switchUuid, + isSharedPassword: isSharedPassword, + connToken: connToken, + ); + } + Future newPortForward( String remoteId, bool isRDP, { @@ -298,6 +347,42 @@ class RustDeskMultiWindowManager { ); } + Future newTerminal( + String remoteId, { + String? password, + bool? isSharedPassword, + bool? forceRelay, + String? connToken, + }) async { + // Iterate through terminal windows in reverse order to prioritize + // the most recently added or used windows, as they are more likely + // to have an active session. + for (final windowId in _terminalWindows.reversed) { + if (await DesktopMultiWindow.invokeMethod( + windowId, kWindowEventActiveSession, remoteId)) { + return MultiWindowCallResult(windowId, null); + } + } + + // Terminal windows should always create new windows, not reuse + // This avoids the MissingPluginException when trying to invoke + // new_terminal on an inactive window + var params = { + "type": WindowType.Terminal.index, + "id": remoteId, + "password": password, + "forceRelay": forceRelay, + "isSharedPassword": isSharedPassword, + "connToken": connToken, + }; + final msg = jsonEncode(params); + + // Always create a new window for terminal + final windowId = await newSessionWindow( + WindowType.Terminal, remoteId, msg, _terminalWindows, false); + return MultiWindowCallResult(windowId, null); + } + Future call( WindowType type, String methodName, dynamic args) async { final wnds = _findWindowsByType(type); @@ -324,8 +409,12 @@ class RustDeskMultiWindowManager { return _remoteDesktopWindows; case WindowType.FileTransfer: return _fileTransferWindows; + case WindowType.ViewCamera: + return _viewCameraWindows; case WindowType.PortForward: return _portForwardWindows; + case WindowType.Terminal: + return _terminalWindows; case WindowType.Unknown: break; } @@ -342,9 +431,14 @@ class RustDeskMultiWindowManager { case WindowType.FileTransfer: _fileTransferWindows.clear(); break; + case WindowType.ViewCamera: + _viewCameraWindows.clear(); + break; case WindowType.PortForward: _portForwardWindows.clear(); break; + case WindowType.Terminal: + _terminalWindows.clear(); case WindowType.Unknown: break; } @@ -376,9 +470,17 @@ class RustDeskMultiWindowManager { if (windows.isEmpty) { return; } - for (final wId in windows) { - debugPrint("closing multi window, type: ${type.toString()} id: $wId"); - await saveWindowPosition(type, windowId: wId); + for (int i = 0; i < windows.length; i++) { + final wId = windows[i]; + final shouldSavePos = type != WindowType.Terminal || i == windows.length - 1; + if (shouldSavePos) { + debugPrint("closing multi window, type: ${type.toString()} id: $wId"); + try { + await saveWindowPosition(type, windowId: wId); + } catch (e) { + debugPrint('Failed to save window position of $wId, $e'); + } + } try { await WindowController.fromWindowId(wId).setPreventClose(false); await WindowController.fromWindowId(wId).close(); diff --git a/flutter/lib/utils/platform_channel.dart b/flutter/lib/utils/platform_channel.dart index eaea4e79f..9e53ca076 100644 --- a/flutter/lib/utils/platform_channel.dart +++ b/flutter/lib/utils/platform_channel.dart @@ -13,8 +13,18 @@ class RdPlatformChannel { static RdPlatformChannel get instance => _windowUtil; - final MethodChannel _osxMethodChannel = - MethodChannel("org.rustdesk.rustdesk/macos"); + final MethodChannel _hostMethodChannel = + MethodChannel("org.rustdesk.rustdesk/host"); + + /// Bump the position of the mouse cursor, if applicable + Future bumpMouse({required int dx, required int dy}) async { + // No debug output; this call is too chatty. + + bool? result = await _hostMethodChannel + .invokeMethod("bumpMouse", {"dx": dx, "dy": dy}); + + return result ?? false; + } /// Change the theme of the system window Future changeSystemWindowTheme(SystemWindowTheme theme) { @@ -23,13 +33,13 @@ class RdPlatformChannel { print( "[Window ${kWindowId ?? 'Main'}] change system window theme to ${theme.name}"); } - return _osxMethodChannel + return _hostMethodChannel .invokeMethod("setWindowTheme", {"themeName": theme.name}); } /// Terminate .app manually. Future terminate() { assert(isMacOS); - return _osxMethodChannel.invokeMethod("terminate"); + return _hostMethodChannel.invokeMethod("terminate"); } } diff --git a/flutter/lib/utils/relative_mouse_accumulator.dart b/flutter/lib/utils/relative_mouse_accumulator.dart new file mode 100644 index 000000000..0b1426449 --- /dev/null +++ b/flutter/lib/utils/relative_mouse_accumulator.dart @@ -0,0 +1,58 @@ +/// A small helper for accumulating fractional mouse deltas and emitting integer deltas. +/// +/// Relative mouse mode uses integer deltas on the wire, but Flutter pointer deltas +/// are doubles. This accumulator preserves sub-pixel movement by carrying the +/// fractional remainder across events. +class RelativeMouseDelta { + final int x; + final int y; + + const RelativeMouseDelta(this.x, this.y); +} + +/// Accumulates fractional mouse deltas and returns integer deltas when available. +class RelativeMouseAccumulator { + double _fracX = 0.0; + double _fracY = 0.0; + + /// Adds a delta and returns an integer delta when at least one axis reaches a + /// magnitude of 1px (after truncation towards zero). + /// + /// If [maxDelta] is > 0, the returned integer delta is clamped to + /// [-maxDelta, maxDelta] on each axis. + RelativeMouseDelta? add( + double dx, + double dy, { + required int maxDelta, + }) { + // Guard against misuse: negative maxDelta would silently disable clamping. + assert(maxDelta >= 0, 'maxDelta must be non-negative'); + + _fracX += dx; + _fracY += dy; + + int intX = _fracX.truncate(); + int intY = _fracY.truncate(); + + if (intX == 0 && intY == 0) { + return null; + } + + // Clamp before subtracting so excess movement is preserved in the accumulator + // rather than being permanently discarded during spikes. + if (maxDelta > 0) { + intX = intX.clamp(-maxDelta, maxDelta); + intY = intY.clamp(-maxDelta, maxDelta); + } + + _fracX -= intX; + _fracY -= intY; + + return RelativeMouseDelta(intX, intY); + } + + void reset() { + _fracX = 0.0; + _fracY = 0.0; + } +} diff --git a/flutter/lib/utils/scale.dart b/flutter/lib/utils/scale.dart new file mode 100644 index 000000000..d1f380a4c --- /dev/null +++ b/flutter/lib/utils/scale.dart @@ -0,0 +1,34 @@ +import 'package:flutter_hbb/consts.dart'; +import 'package:flutter_hbb/models/platform_model.dart'; +import 'package:uuid/uuid.dart'; + +/// Clamp custom scale percent to supported bounds. +/// Keep this in sync with the slider's minimum in the desktop toolbar UI. +/// +/// This function exists to ensure consistent clamping behavior across the app +/// and to provide a single point of reference for the valid scale range. +int clampCustomScalePercent(int percent) { + return percent.clamp(kScaleCustomMinPercent, kScaleCustomMaxPercent); +} + +/// Parse a string percent and clamp. Defaults to 100 when invalid. +int parseCustomScalePercent(String? s, {int defaultPercent = 100}) { + final parsed = int.tryParse(s ?? '') ?? defaultPercent; + return clampCustomScalePercent(parsed); +} + +/// Convert a percent value to scale factor after clamping. +double percentToScale(int percent) => clampCustomScalePercent(percent) / 100.0; + +/// Fetch, parse and clamp the custom scale percent for a session. +Future getSessionCustomScalePercent(UuidValue sessionId) async { + final opt = await bind.sessionGetFlutterOption( + sessionId: sessionId, k: kCustomScalePercentKey); + return parseCustomScalePercent(opt); +} + +/// Fetch and compute the custom scale factor for a session. +Future getSessionCustomScale(UuidValue sessionId) async { + final p = await getSessionCustomScalePercent(sessionId); + return percentToScale(p); +} diff --git a/flutter/lib/web/bridge.dart b/flutter/lib/web/bridge.dart index 208912814..54e6a9a9b 100644 --- a/flutter/lib/web/bridge.dart +++ b/flutter/lib/web/bridge.dart @@ -60,7 +60,8 @@ class RustdeskImpl { throw UnimplementedError("hostStopSystemKeyPropagate"); } - int peerGetDefaultSessionsCount({required String id, dynamic hint}) { + int peerGetSessionsCount( + {required String id, required int connType, dynamic hint}) { return 0; } @@ -68,6 +69,7 @@ class RustdeskImpl { {required String id, required UuidValue sessionId, required Int32List displays, + required bool isViewCamera, dynamic hint}) { return ''; } @@ -76,8 +78,10 @@ class RustdeskImpl { {required UuidValue sessionId, required String id, required bool isFileTransfer, + required bool isViewCamera, required bool isPortForward, required bool isRdp, + required bool isTerminal, required String switchUuid, required bool forceRelay, required String password, @@ -90,7 +94,9 @@ class RustdeskImpl { 'id': id, 'password': password, 'is_shared_password': isSharedPassword, - 'isFileTransfer': isFileTransfer + 'isFileTransfer': isFileTransfer, + 'isViewCamera': isViewCamera, + 'isTerminal': isTerminal }) ]); } @@ -263,6 +269,16 @@ class RustdeskImpl { ])); } + Future sessionGetTrackpadSpeed( + {required UuidValue sessionId, dynamic hint}) { + throw UnimplementedError("sessionGetTrackpadSpeed"); + } + + Future sessionSetTrackpadSpeed( + {required UuidValue sessionId, required int value, dynamic hint}) { + throw UnimplementedError("sessionSetTrackpadSpeed"); + } + Future sessionGetScrollStyle( {required UuidValue sessionId, dynamic hint}) { return Future(() => @@ -796,7 +812,7 @@ class RustdeskImpl { } String mainGetAppNameSync({dynamic hint}) { - return 'RustDesk'; + return js.context.callMethod('getByName', ['app-name']); } String mainUriPrefixSync({dynamic hint}) { @@ -892,8 +908,18 @@ class RustdeskImpl { return js.context.callMethod('getByName', ['option:local', key]); } + // Do not return the real environment variables. + // Use the global variable as the environment variable in web. String mainGetEnv({required String key, dynamic hint}) { - throw UnimplementedError("mainGetEnv"); + return js.context.callMethod('getByName', ['envvar', key]); + } + + // Use the global variable as the environment variable in web. + void mainSetEnv({required String key, String? value, dynamic hint}) { + js.context.callMethod('setByName', [ + 'envvar', + jsonEncode({'name': key, 'value': value}) + ]); } Future mainSetLocalOption( @@ -1133,10 +1159,6 @@ class RustdeskImpl { return Future.value(''); } - Future mainGetPermanentPassword({dynamic hint}) { - return Future.value(''); - } - Future mainGetFingerprint({dynamic hint}) { return Future.value(''); } @@ -1320,9 +1342,9 @@ class RustdeskImpl { throw UnimplementedError("mainUpdateTemporaryPassword"); } - Future mainSetPermanentPassword( + Future mainSetPermanentPasswordWithResult( {required String password, dynamic hint}) { - throw UnimplementedError("mainSetPermanentPassword"); + throw UnimplementedError("mainSetPermanentPasswordWithResult"); } Future mainCheckSuperUserPermission({dynamic hint}) { @@ -1516,15 +1538,23 @@ class RustdeskImpl { Future mainAccountAuth( {required String op, required bool rememberMe, dynamic hint}) { - throw UnimplementedError("mainAccountAuth"); + // Safari only allows auth popups while handling the original user gesture. + // Use Future.sync so the JS call runs synchronously (pre-opening the OIDC + // window) while any interop error still surfaces as a Future error. + return Future.sync(() => js.context.callMethod('setByName', [ + 'account_auth', + jsonEncode({'op': op, 'remember': rememberMe}) + ])); } Future mainAccountAuthCancel({dynamic hint}) { - throw UnimplementedError("mainAccountAuthCancel"); + return Future( + () => js.context.callMethod('setByName', ['account_auth_cancel'])); } Future mainAccountAuthResult({dynamic hint}) { - throw UnimplementedError("mainAccountAuthResult"); + return Future( + () => js.context.callMethod('getByName', ['account_auth_result'])); } Future mainOnMainWindowClose({dynamic hint}) { @@ -1578,23 +1608,28 @@ class RustdeskImpl { } bool isCustomClient({dynamic hint}) { - return false; + // is_custom_client() checks if app name is not "RustDesk" + return mainGetAppNameSync(hint: hint) != "RustDesk"; } bool isDisableSettings({dynamic hint}) { - return false; + // Checks HARD_SETTINGS["disable-settings"] == "Y" + return mainGetHardOption(key: "disable-settings", hint: hint) == "Y"; } bool isDisableAb({dynamic hint}) { - return false; + // Checks HARD_SETTINGS["disable-ab"] == "Y" + return mainGetHardOption(key: "disable-ab", hint: hint) == "Y"; } bool isDisableGroupPanel({dynamic hint}) { - return false; + // Checks LocalConfig::get_option("disable-group-panel") == "Y" + return mainGetLocalOption(key: "disable-group-panel", hint: hint) == "Y"; } bool isDisableAccount({dynamic hint}) { - return false; + // Checks HARD_SETTINGS["disable-account"] == "Y" + return mainGetHardOption(key: "disable-account", hint: hint) == "Y"; } bool isDisableInstallation({dynamic hint}) { @@ -1694,7 +1729,7 @@ class RustdeskImpl { } String mainSupportedPrivacyModeImpls({dynamic hint}) { - throw UnimplementedError("mainSupportedPrivacyModeImpls"); + return '[]'; } String mainSupportedInputSource({dynamic hint}) { @@ -1717,7 +1752,7 @@ class RustdeskImpl { } String mainGetHardOption({required String key, dynamic hint}) { - throw UnimplementedError("mainGetHardOption"); + return mainGetLocalOption(key: key, hint: hint); } Future mainCheckHwcodec({dynamic hint}) { @@ -1790,7 +1825,7 @@ class RustdeskImpl { } String mainGetBuildinOption({required String key, dynamic hint}) { - return ''; + return mainGetLocalOption(key: key, hint: hint); } String installInstallOptions({dynamic hint}) { @@ -1801,6 +1836,26 @@ class RustdeskImpl { throw UnimplementedError("mainMaxEncryptLen"); } + bool mainAudioSupportLoopback({dynamic hint}) { + return false; + } + + Future sessionReadLocalEmptyDirsRecursiveSync( + {required UuidValue sessionId, + required String path, + required bool includeHidden, + dynamic hint}) { + throw UnimplementedError("sessionReadLocalEmptyDirsRecursiveSync"); + } + + Future sessionReadRemoteEmptyDirsRecursiveSync( + {required UuidValue sessionId, + required String path, + required bool includeHidden, + dynamic hint}) { + throw UnimplementedError("sessionReadRemoteEmptyDirsRecursiveSync"); + } + Future sessionRenameFile( {required UuidValue sessionId, required int actId, @@ -1828,5 +1883,159 @@ class RustdeskImpl { throw UnimplementedError("sessionGetConnToken"); } + String mainGetPrinterNames({dynamic hint}) { + return ''; + } + + Future sessionPrinterResponse( + {required UuidValue sessionId, + required int id, + required String path, + required String printerName, + dynamic hint}) { + throw UnimplementedError("sessionPrinterResponse"); + } + + Future mainGetCommon({required String key, dynamic hint}) { + throw UnimplementedError("mainGetCommon"); + } + + String mainGetCommonSync({required String key, dynamic hint}) { + throw UnimplementedError("mainGetCommonSync"); + } + + Future mainSetCommon( + {required String key, required String value, dynamic hint}) { + throw UnimplementedError("mainSetCommon"); + } + + Future sessionHandleScreenshot( + {required UuidValue sessionId, required String action, dynamic hint}) { + throw UnimplementedError("sessionHandleScreenshot"); + } + + String? sessionGetCommonSync( + {required UuidValue sessionId, + required String key, + required String param, + dynamic hint}) { + throw UnimplementedError("sessionGetCommonSync"); + } + + Future sessionTakeScreenshot( + {required UuidValue sessionId, required int display, dynamic hint}) { + throw UnimplementedError("sessionTakeScreenshot"); + } + + Future sessionOpenTerminal( + {required UuidValue sessionId, + required int terminalId, + required int rows, + required int cols, + dynamic hint}) { + return Future(() => js.context.callMethod('setByName', [ + 'open_terminal', + jsonEncode({ + 'terminal_id': terminalId, + 'rows': rows, + 'cols': cols, + }) + ])); + } + + Future sessionSendTerminalInput( + {required UuidValue sessionId, + required int terminalId, + required String data, + dynamic hint}) { + return Future(() => js.context.callMethod('setByName', [ + 'send_terminal_input', + jsonEncode({ + 'terminal_id': terminalId, + 'data': data, + }) + ])); + } + + Future sessionResizeTerminal( + {required UuidValue sessionId, + required int terminalId, + required int rows, + required int cols, + dynamic hint}) { + return Future(() => js.context.callMethod('setByName', [ + 'resize_terminal', + jsonEncode({ + 'terminal_id': terminalId, + 'rows': rows, + 'cols': cols, + }) + ])); + } + + Future sessionCloseTerminal( + {required UuidValue sessionId, required int terminalId, dynamic hint}) { + return Future(() => js.context.callMethod('setByName', [ + 'close_terminal', + jsonEncode({ + 'terminal_id': terminalId, + }) + ])); + } + + Future sessionGetEdgeScrollEdgeThickness( + {required UuidValue sessionId, dynamic hint}) { + final thickness = js.context.callMethod( + 'getByName', ['option:session', 'edge-scroll-edge-thickness']); + return Future(() => int.tryParse(thickness) ?? 100); + } + + Future sessionSetEdgeScrollEdgeThickness( + {required UuidValue sessionId, required int value, dynamic hint}) { + return Future(() => js.context.callMethod('setByName', + ['option:session', 'edge-scroll-edge-thickness', value.toString()])); + } + + String sessionGetConnSessionId({required UuidValue sessionId, dynamic hint}) { + return js.context.callMethod('getByName', ['conn_session_id']); + } + + bool willSessionCloseCloseSession( + {required UuidValue sessionId, dynamic hint}) { + return true; + } + + String sessionGetLastAuditNote({required UuidValue sessionId, dynamic hint}) { + return js.context.callMethod('getByName', ['last_audit_note']); + } + + Future sessionSetAuditGuid( + {required UuidValue sessionId, required String guid, dynamic hint}) { + return Future( + () => js.context.callMethod('setByName', ['audit_guid', guid])); + } + + String sessionGetAuditGuid({required UuidValue sessionId, dynamic hint}) { + return js.context.callMethod('getByName', ['audit_guid']); + } + + bool mainSetCursorPosition({required int x, required int y, dynamic hint}) { + return false; + } + + bool mainClipCursor( + {required int left, + required int top, + required int right, + required int bottom, + required bool enable, + dynamic hint}) { + return false; + } + + String mainResolveAvatarUrl({required String avatar, dynamic hint}) { + return js.context.callMethod('getByName', ['resolve_avatar_url', avatar])?.toString() ?? avatar; + } + void dispose() {} } diff --git a/flutter/linux/CMakeLists.txt b/flutter/linux/CMakeLists.txt index a9fd84088..56a8dbb70 100644 --- a/flutter/linux/CMakeLists.txt +++ b/flutter/linux/CMakeLists.txt @@ -1,6 +1,6 @@ # Project-level configuration. cmake_minimum_required(VERSION 3.10) -project(runner LANGUAGES CXX) +project(runner LANGUAGES C CXX) # The name of the executable created for the application. Change this to change # the on-disk name of your application. @@ -54,6 +54,55 @@ add_subdirectory(${FLUTTER_MANAGED_DIR}) find_package(PkgConfig REQUIRED) pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +# Wayland protocol for keyboard shortcuts inhibit +pkg_check_modules(WAYLAND_CLIENT IMPORTED_TARGET wayland-client) +pkg_check_modules(WAYLAND_PROTOCOLS_PKG QUIET wayland-protocols) +pkg_check_modules(WAYLAND_SCANNER_PKG QUIET wayland-scanner) + +if(WAYLAND_PROTOCOLS_PKG_FOUND) + pkg_get_variable(WAYLAND_PROTOCOLS_DIR wayland-protocols pkgdatadir) +endif() +if(WAYLAND_SCANNER_PKG_FOUND) + pkg_get_variable(WAYLAND_SCANNER wayland-scanner wayland_scanner) +endif() + +if(WAYLAND_CLIENT_FOUND AND WAYLAND_PROTOCOLS_DIR AND WAYLAND_SCANNER) + set(KEYBOARD_SHORTCUTS_INHIBIT_PROTOCOL + "${WAYLAND_PROTOCOLS_DIR}/unstable/keyboard-shortcuts-inhibit/keyboard-shortcuts-inhibit-unstable-v1.xml") + + if(EXISTS ${KEYBOARD_SHORTCUTS_INHIBIT_PROTOCOL}) + set(WAYLAND_GENERATED_DIR "${CMAKE_CURRENT_BINARY_DIR}/wayland-protocols") + file(MAKE_DIRECTORY ${WAYLAND_GENERATED_DIR}) + + # Generate client header + add_custom_command( + OUTPUT "${WAYLAND_GENERATED_DIR}/keyboard-shortcuts-inhibit-unstable-v1-client-protocol.h" + COMMAND ${WAYLAND_SCANNER} client-header + ${KEYBOARD_SHORTCUTS_INHIBIT_PROTOCOL} + "${WAYLAND_GENERATED_DIR}/keyboard-shortcuts-inhibit-unstable-v1-client-protocol.h" + DEPENDS ${KEYBOARD_SHORTCUTS_INHIBIT_PROTOCOL} + VERBATIM + ) + + # Generate protocol code + add_custom_command( + OUTPUT "${WAYLAND_GENERATED_DIR}/keyboard-shortcuts-inhibit-unstable-v1-protocol.c" + COMMAND ${WAYLAND_SCANNER} private-code + ${KEYBOARD_SHORTCUTS_INHIBIT_PROTOCOL} + "${WAYLAND_GENERATED_DIR}/keyboard-shortcuts-inhibit-unstable-v1-protocol.c" + DEPENDS ${KEYBOARD_SHORTCUTS_INHIBIT_PROTOCOL} + VERBATIM + ) + + set(WAYLAND_PROTOCOL_SOURCES + "${WAYLAND_GENERATED_DIR}/keyboard-shortcuts-inhibit-unstable-v1-client-protocol.h" + "${WAYLAND_GENERATED_DIR}/keyboard-shortcuts-inhibit-unstable-v1-protocol.c" + ) + + set(HAS_KEYBOARD_SHORTCUTS_INHIBIT TRUE) + endif() +endif() + add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") # Define the application target. To change its name, change BINARY_NAME above, @@ -63,7 +112,11 @@ add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") add_executable(${BINARY_NAME} "main.cc" "my_application.cc" + "wayland_shortcuts_inhibit.cc" + "bump_mouse.cc" + "bump_mouse_x11.cc" "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + ${WAYLAND_PROTOCOL_SOURCES} ) # Apply the standard set of build settings. This can be removed for applications @@ -76,6 +129,13 @@ target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) target_link_libraries(${BINARY_NAME} PRIVATE ${CMAKE_DL_LIBS}) # target_link_libraries(${BINARY_NAME} PRIVATE librustdesk) +# Wayland support for keyboard shortcuts inhibit +if(HAS_KEYBOARD_SHORTCUTS_INHIBIT) + target_compile_definitions(${BINARY_NAME} PRIVATE HAS_KEYBOARD_SHORTCUTS_INHIBIT) + target_include_directories(${BINARY_NAME} PRIVATE ${WAYLAND_GENERATED_DIR}) + target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::WAYLAND_CLIENT) +endif() + # Run the Flutter tool portions of the build. This must not be removed. add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/flutter/linux/bump_mouse.cc b/flutter/linux/bump_mouse.cc new file mode 100644 index 000000000..985aa6e81 --- /dev/null +++ b/flutter/linux/bump_mouse.cc @@ -0,0 +1,18 @@ +#include "bump_mouse.h" + +#include "bump_mouse_x11.h" + +#include + +bool bump_mouse(int dx, int dy) +{ + GdkDisplay *display = gdk_display_get_default(); + + if (GDK_IS_X11_DISPLAY(display)) { + return bump_mouse_x11(dx, dy); + } + else { + // Don't know how to support this. + return false; + } +} diff --git a/flutter/linux/bump_mouse.h b/flutter/linux/bump_mouse.h new file mode 100644 index 000000000..0861e44e8 --- /dev/null +++ b/flutter/linux/bump_mouse.h @@ -0,0 +1,3 @@ +#pragma once + +bool bump_mouse(int dx, int dy); diff --git a/flutter/linux/bump_mouse_x11.cc b/flutter/linux/bump_mouse_x11.cc new file mode 100644 index 000000000..7889ea302 --- /dev/null +++ b/flutter/linux/bump_mouse_x11.cc @@ -0,0 +1,30 @@ +#include "bump_mouse.h" + +#include + +#include + +#include + +bool bump_mouse_x11(int dx, int dy) +{ + GdkDevice *mouse_device; + +#if GTK_CHECK_VERSION(3, 20, 0) + auto seat = gdk_display_get_default_seat(gdk_display_get_default()); + + mouse_device = gdk_seat_get_pointer(seat); +#else + auto devman = gdk_display_get_device_manager(gdk_display_get_default()); + + mouse_device = gdk_device_manager_get_client_pointer(devman); +#endif + + GdkScreen *screen; + gint x, y; + + gdk_device_get_position(mouse_device, &screen, &x, &y); + gdk_device_warp(mouse_device, screen, x + dx, y + dy); + + return true; +} diff --git a/flutter/linux/bump_mouse_x11.h b/flutter/linux/bump_mouse_x11.h new file mode 100644 index 000000000..00bbaaad9 --- /dev/null +++ b/flutter/linux/bump_mouse_x11.h @@ -0,0 +1,3 @@ +#pragma once + +bool bump_mouse_x11(int dx, int dy); diff --git a/flutter/linux/my_application.cc b/flutter/linux/my_application.cc index 56b85ccae..210adba96 100644 --- a/flutter/linux/my_application.cc +++ b/flutter/linux/my_application.cc @@ -1,28 +1,118 @@ #include "my_application.h" +#include "bump_mouse.h" + #include #ifdef GDK_WINDOWING_X11 #include #endif +#if defined(GDK_WINDOWING_WAYLAND) && defined(HAS_KEYBOARD_SHORTCUTS_INHIBIT) +#include "wayland_shortcuts_inhibit.h" +#endif + +#include #include "flutter/generated_plugin_registrant.h" struct _MyApplication { GtkApplication parent_instance; char** dart_entrypoint_arguments; + FlMethodChannel* host_channel; }; G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) +void host_channel_call_handler(FlMethodChannel* channel, FlMethodCall* method_call, gpointer user_data); + +GtkWidget *find_gl_area(GtkWidget *widget); +void try_set_transparent(GtkWindow* window, GdkScreen* screen, FlView* view); + extern bool gIsConnectionManager; +// --- Side mouse button support (back/forward) --- +// Flutter's Linux embedder doesn't deliver X11 button 8/9 events to Dart. +// We intercept them via GDK and forward through a dedicated platform channel. + +static const char* kSideButtonChannelName = "org.rustdesk.rustdesk/side_buttons"; + +static gboolean on_side_button_event(GtkWidget* widget, GdkEventButton* event, gpointer user_data) { + if (event->button != 8 && event->button != 9) { + return FALSE; + } + // Ignore GDK_2BUTTON_PRESS / GDK_3BUTTON_PRESS (double/triple-click synthetic + // events) - only handle real press and release. + if (event->type != GDK_BUTTON_PRESS && event->type != GDK_BUTTON_RELEASE) { + return FALSE; + } + + FlMethodChannel* channel = FL_METHOD_CHANNEL(user_data); + if (channel == NULL) return FALSE; + + g_autoptr(FlValue) args = fl_value_new_map(); + fl_value_set_string_take(args, "button", + fl_value_new_string(event->button == 8 ? "back" : "forward")); + fl_value_set_string_take(args, "type", + fl_value_new_string(event->type == GDK_BUTTON_PRESS ? "down" : "up")); + + fl_method_channel_invoke_method(channel, "onSideMouseButton", args, + NULL, NULL, NULL); + + return TRUE; +} + +static FlMethodChannel* side_buttons_create_channel(FlEngine* engine) { + g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); + return fl_method_channel_new( + fl_engine_get_binary_messenger(engine), + kSideButtonChannelName, + FL_METHOD_CODEC(codec)); +} + +static void side_buttons_channel_destroy(gpointer data) { + g_object_unref(data); +} + +static void side_buttons_init_for_window(GtkWindow* window, FlMethodChannel* channel) { + // Guard against double-initialization (would leave dangling signal user_data). + if (g_object_get_data(G_OBJECT(window), "side-buttons-channel") != NULL) return; + + gtk_widget_add_events(GTK_WIDGET(window), + GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK); + // Store channel on the window so it stays alive and is freed with the window. + g_object_set_data_full(G_OBJECT(window), "side-buttons-channel", + g_object_ref(channel), side_buttons_channel_destroy); + g_signal_connect(window, "button-press-event", + G_CALLBACK(on_side_button_event), channel); + g_signal_connect(window, "button-release-event", + G_CALLBACK(on_side_button_event), channel); +} + +static void on_subwindow_created(FlPluginRegistry* registry) { +#if defined(GDK_WINDOWING_WAYLAND) && defined(HAS_KEYBOARD_SHORTCUTS_INHIBIT) + wayland_shortcuts_inhibit_init_for_subwindow(registry); +#endif + // Set up side button forwarding for sub-windows. + if (registry == NULL || !FL_IS_VIEW(registry)) return; + FlView* view = FL_VIEW(registry); + GtkWidget* toplevel = gtk_widget_get_toplevel(GTK_WIDGET(view)); + if (toplevel != NULL && GTK_IS_WINDOW(toplevel)) { + FlMethodChannel* channel = side_buttons_create_channel(fl_view_get_engine(view)); + if (channel == NULL) return; + side_buttons_init_for_window(GTK_WINDOW(toplevel), channel); + g_object_unref(channel); // window now owns a ref via g_object_set_data_full + } +} + +GtkWidget *find_gl_area(GtkWidget *widget); + // Implements GApplication::activate. static void my_application_activate(GApplication* application) { MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); gtk_window_set_decorated(window, FALSE); - // try setting icon for rustdesk, which uses the system cache + // try setting icon for rustdesk, which uses the system cache GtkIconTheme* theme = gtk_icon_theme_get_default(); gint icons[4] = {256, 128, 64, 32}; for (int i = 0; i < 4; i++) { @@ -39,9 +129,10 @@ static void my_application_activate(GApplication* application) { // If running on Wayland assume the header bar will work (may need changing // if future cases occur). gboolean use_header_bar = TRUE; + GdkScreen* screen = NULL; #ifdef GDK_WINDOWING_X11 - GdkScreen* screen = gtk_window_get_screen(window); - if (GDK_IS_X11_SCREEN(screen)) { + screen = gtk_window_get_screen(window); + if (screen != NULL && GDK_IS_X11_SCREEN(screen)) { const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); if (g_strcmp0(wm_name, "GNOME Shell") != 0) { use_header_bar = FALSE; @@ -66,18 +157,44 @@ static void my_application_activate(GApplication* application) { height = 490; } gtk_window_set_default_size(window, width, height); // <-- comment this line - gtk_widget_show(GTK_WIDGET(window)); + // gtk_widget_show(GTK_WIDGET(window)); gtk_widget_set_opacity(GTK_WIDGET(window), 0); g_autoptr(FlDartProject) project = fl_dart_project_new(); fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); FlView* view = fl_view_new(project); - gtk_widget_show(GTK_WIDGET(view)); gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + try_set_transparent(window, gtk_window_get_screen(window), view); + gtk_widget_show(GTK_WIDGET(window)); + gtk_widget_show(GTK_WIDGET(view)); + + // Register callback for sub-windows created by desktop_multi_window plugin. + // Handles both Wayland shortcuts inhibition (guarded inside) and side button + // forwarding. Safe to call on X11-only builds - the plugin just stores the + // callback pointer regardless of windowing system. + desktop_multi_window_plugin_set_window_created_callback( + (WindowCreatedCallback)on_subwindow_created); + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); + self->host_channel = fl_method_channel_new( + fl_engine_get_binary_messenger(fl_view_get_engine(view)), + "org.rustdesk.rustdesk/host", + FL_METHOD_CODEC(codec)); + fl_method_channel_set_method_call_handler( + self->host_channel, + host_channel_call_handler, + self, + nullptr); + + // Forward side mouse button events (back/forward) to Dart on the main window. + FlMethodChannel* side_channel = side_buttons_create_channel(fl_view_get_engine(view)); + side_buttons_init_for_window(window, side_channel); + g_object_unref(side_channel); + gtk_widget_grab_focus(GTK_WIDGET(view)); } @@ -104,6 +221,7 @@ static gboolean my_application_local_command_line(GApplication* application, gch static void my_application_dispose(GObject* object) { MyApplication* self = MY_APPLICATION(object); g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + g_clear_object(&self->host_channel); G_OBJECT_CLASS(my_application_parent_class)->dispose(object); } @@ -121,3 +239,103 @@ MyApplication* my_application_new() { "flags", G_APPLICATION_NON_UNIQUE, nullptr)); } + +void host_channel_call_handler(FlMethodChannel* channel, FlMethodCall* method_call, gpointer user_data) +{ + if (strcmp(fl_method_call_get_name(method_call), "bumpMouse") == 0) { + FlValue *args = fl_method_call_get_args(method_call); + + FlValue *dxValue = nullptr; + FlValue *dyValue = nullptr; + + switch (fl_value_get_type(args)) + { + case FL_VALUE_TYPE_MAP: + { + dxValue = fl_value_lookup_string(args, "dx"); + dyValue = fl_value_lookup_string(args, "dy"); + + break; + } + case FL_VALUE_TYPE_LIST: + { + int listSize = fl_value_get_length(args); + + dxValue = (listSize >= 1) ? fl_value_get_list_value(args, 0) : nullptr; + dyValue = (listSize >= 2) ? fl_value_get_list_value(args, 1) : nullptr; + + break; + } + + default: break; + } + + int dx = 0, dy = 0; + + if (dxValue && (fl_value_get_type(dxValue) == FL_VALUE_TYPE_INT)) { + dx = fl_value_get_int(dxValue); + } + + if (dyValue && (fl_value_get_type(dyValue) == FL_VALUE_TYPE_INT)) { + dy = fl_value_get_int(dyValue); + } + + bool result = bump_mouse(dx, dy); + + FlValue *result_value = fl_value_new_bool(result); + + GError *error = nullptr; + + if (!fl_method_call_respond_success(method_call, result_value, &error)) { + g_warning("Failed to send Flutter Platform Channel response: %s", error->message); + g_error_free(error); + } + + fl_value_unref(result_value); + } +} + +GtkWidget *find_gl_area(GtkWidget *widget) +{ + if (GTK_IS_GL_AREA(widget)) { + return widget; + } + + if (GTK_IS_CONTAINER(widget)) { + GList *children = gtk_container_get_children(GTK_CONTAINER(widget)); + for (GList *iter = children; iter != NULL; iter = g_list_next(iter)) { + GtkWidget *child = GTK_WIDGET(iter->data); + GtkWidget *gl_area = find_gl_area(child); + if (gl_area != NULL) { + g_list_free(children); + return gl_area; + } + } + g_list_free(children); + } + + return NULL; +} + +// https://github.com/flutter/flutter/issues/152154 +// Remove this workaround when flutter version is updated. +void try_set_transparent(GtkWindow* window, GdkScreen* screen, FlView* view) +{ + GtkWidget *gl_area = NULL; + + printf("Try setting transparent\n"); + + gl_area = find_gl_area(GTK_WIDGET(view)); + if (gl_area != NULL) { + gtk_gl_area_set_has_alpha(GTK_GL_AREA(gl_area), TRUE); + } + + if (screen != NULL) { + GdkVisual *visual = NULL; + gtk_widget_set_app_paintable(GTK_WIDGET(window), TRUE); + visual = gdk_screen_get_rgba_visual(screen); + if (visual != NULL && gdk_screen_is_composited(screen)) { + gtk_widget_set_visual(GTK_WIDGET(window), visual); + } + } +} diff --git a/flutter/linux/wayland_shortcuts_inhibit.cc b/flutter/linux/wayland_shortcuts_inhibit.cc new file mode 100644 index 000000000..76c45be4d --- /dev/null +++ b/flutter/linux/wayland_shortcuts_inhibit.cc @@ -0,0 +1,244 @@ +// Wayland keyboard shortcuts inhibit implementation +// Uses the zwp_keyboard_shortcuts_inhibit_manager_v1 protocol to request +// the compositor to disable system shortcuts for specific windows. + +#include "wayland_shortcuts_inhibit.h" + +#if defined(GDK_WINDOWING_WAYLAND) && defined(HAS_KEYBOARD_SHORTCUTS_INHIBIT) + +#include +#include +#include +#include "keyboard-shortcuts-inhibit-unstable-v1-client-protocol.h" + +// Data structure to hold inhibitor state for each window +typedef struct { + struct zwp_keyboard_shortcuts_inhibit_manager_v1* manager; + struct zwp_keyboard_shortcuts_inhibitor_v1* inhibitor; +} ShortcutsInhibitData; + +// Cleanup function for ShortcutsInhibitData +static void shortcuts_inhibit_data_free(gpointer data) { + ShortcutsInhibitData* inhibit_data = static_cast(data); + if (inhibit_data->inhibitor != NULL) { + zwp_keyboard_shortcuts_inhibitor_v1_destroy(inhibit_data->inhibitor); + } + if (inhibit_data->manager != NULL) { + zwp_keyboard_shortcuts_inhibit_manager_v1_destroy(inhibit_data->manager); + } + g_free(inhibit_data); +} + +// Wayland registry handler to find the shortcuts inhibit manager +static void registry_handle_global(void* data, struct wl_registry* registry, + uint32_t name, const char* interface, + uint32_t /*version*/) { + ShortcutsInhibitData* inhibit_data = static_cast(data); + if (strcmp(interface, + zwp_keyboard_shortcuts_inhibit_manager_v1_interface.name) == 0) { + inhibit_data->manager = + static_cast(wl_registry_bind( + registry, name, &zwp_keyboard_shortcuts_inhibit_manager_v1_interface, + 1)); + } +} + +static void registry_handle_global_remove(void* /*data*/, struct wl_registry* /*registry*/, + uint32_t /*name*/) { + // Not needed for this use case +} + +static const struct wl_registry_listener registry_listener = { + registry_handle_global, + registry_handle_global_remove, +}; + +// Inhibitor event handlers +static void inhibitor_active(void* /*data*/, + struct zwp_keyboard_shortcuts_inhibitor_v1* /*inhibitor*/) { + // Inhibitor is now active, shortcuts are being captured +} + +static void inhibitor_inactive(void* /*data*/, + struct zwp_keyboard_shortcuts_inhibitor_v1* /*inhibitor*/) { + // Inhibitor is now inactive, shortcuts restored to compositor +} + +static const struct zwp_keyboard_shortcuts_inhibitor_v1_listener inhibitor_listener = { + inhibitor_active, + inhibitor_inactive, +}; + +// Forward declaration +static void uninhibit_keyboard_shortcuts(GtkWindow* window); + +// Inhibit keyboard shortcuts on Wayland for a specific window +static void inhibit_keyboard_shortcuts(GtkWindow* window) { + GdkDisplay* display = gtk_widget_get_display(GTK_WIDGET(window)); + if (!GDK_IS_WAYLAND_DISPLAY(display)) { + return; + } + + // Check if already inhibited for this window + if (g_object_get_data(G_OBJECT(window), "shortcuts-inhibit-data") != NULL) { + return; + } + + ShortcutsInhibitData* inhibit_data = g_new0(ShortcutsInhibitData, 1); + + struct wl_display* wl_display = gdk_wayland_display_get_wl_display(display); + if (wl_display == NULL) { + shortcuts_inhibit_data_free(inhibit_data); + return; + } + + struct wl_registry* registry = wl_display_get_registry(wl_display); + if (registry == NULL) { + shortcuts_inhibit_data_free(inhibit_data); + return; + } + + wl_registry_add_listener(registry, ®istry_listener, inhibit_data); + wl_display_roundtrip(wl_display); + + if (inhibit_data->manager == NULL) { + wl_registry_destroy(registry); + shortcuts_inhibit_data_free(inhibit_data); + return; + } + + GdkWindow* gdk_window = gtk_widget_get_window(GTK_WIDGET(window)); + if (gdk_window == NULL) { + wl_registry_destroy(registry); + shortcuts_inhibit_data_free(inhibit_data); + return; + } + + struct wl_surface* surface = gdk_wayland_window_get_wl_surface(gdk_window); + if (surface == NULL) { + wl_registry_destroy(registry); + shortcuts_inhibit_data_free(inhibit_data); + return; + } + + GdkSeat* gdk_seat = gdk_display_get_default_seat(display); + if (gdk_seat == NULL) { + wl_registry_destroy(registry); + shortcuts_inhibit_data_free(inhibit_data); + return; + } + + struct wl_seat* seat = gdk_wayland_seat_get_wl_seat(gdk_seat); + if (seat == NULL) { + wl_registry_destroy(registry); + shortcuts_inhibit_data_free(inhibit_data); + return; + } + + inhibit_data->inhibitor = + zwp_keyboard_shortcuts_inhibit_manager_v1_inhibit_shortcuts( + inhibit_data->manager, surface, seat); + + if (inhibit_data->inhibitor == NULL) { + wl_registry_destroy(registry); + shortcuts_inhibit_data_free(inhibit_data); + return; + } + + // Add listener to monitor active/inactive state + zwp_keyboard_shortcuts_inhibitor_v1_add_listener( + inhibit_data->inhibitor, &inhibitor_listener, window); + + wl_display_roundtrip(wl_display); + wl_registry_destroy(registry); + + // Associate the inhibit data with the window for cleanup on destroy + g_object_set_data_full(G_OBJECT(window), "shortcuts-inhibit-data", + inhibit_data, shortcuts_inhibit_data_free); +} + +// Remove keyboard shortcuts inhibitor from a window +static void uninhibit_keyboard_shortcuts(GtkWindow* window) { + ShortcutsInhibitData* inhibit_data = static_cast( + g_object_get_data(G_OBJECT(window), "shortcuts-inhibit-data")); + + if (inhibit_data == NULL) { + return; + } + + // This will trigger shortcuts_inhibit_data_free via g_object_set_data + g_object_set_data(G_OBJECT(window), "shortcuts-inhibit-data", NULL); +} + +// Focus event handlers for dynamic inhibitor management +static gboolean on_window_focus_in(GtkWidget* widget, GdkEventFocus* /*event*/, gpointer /*user_data*/) { + if (GTK_IS_WINDOW(widget)) { + inhibit_keyboard_shortcuts(GTK_WINDOW(widget)); + } + return FALSE; // Continue event propagation +} + +static gboolean on_window_focus_out(GtkWidget* widget, GdkEventFocus* /*event*/, gpointer /*user_data*/) { + if (GTK_IS_WINDOW(widget)) { + uninhibit_keyboard_shortcuts(GTK_WINDOW(widget)); + } + return FALSE; // Continue event propagation +} + +// Key for marking window as having focus handlers connected +static const char* const kFocusHandlersConnectedKey = "shortcuts-inhibit-focus-handlers-connected"; +// Key for marking window as having a pending realize handler +static const char* const kRealizeHandlerConnectedKey = "shortcuts-inhibit-realize-handler-connected"; + +// Callback when window is realized (mapped to screen) +// Sets up focus-based inhibitor management +static void on_window_realize(GtkWidget* widget, gpointer /*user_data*/) { + if (GTK_IS_WINDOW(widget)) { + // Check if focus handlers are already connected to avoid duplicates + if (g_object_get_data(G_OBJECT(widget), kFocusHandlersConnectedKey) != NULL) { + return; + } + + // Connect focus events for dynamic inhibitor management + g_signal_connect(widget, "focus-in-event", + G_CALLBACK(on_window_focus_in), NULL); + g_signal_connect(widget, "focus-out-event", + G_CALLBACK(on_window_focus_out), NULL); + + // Mark as connected to prevent duplicate connections + g_object_set_data(G_OBJECT(widget), kFocusHandlersConnectedKey, GINT_TO_POINTER(1)); + + // If window already has focus, create inhibitor now + if (gtk_window_has_toplevel_focus(GTK_WINDOW(widget))) { + inhibit_keyboard_shortcuts(GTK_WINDOW(widget)); + } + } +} + +// Public API: Initialize shortcuts inhibit for a sub-window +void wayland_shortcuts_inhibit_init_for_subwindow(void* view) { + GtkWidget* widget = GTK_WIDGET(view); + GtkWidget* toplevel = gtk_widget_get_toplevel(widget); + + if (toplevel != NULL && GTK_IS_WINDOW(toplevel)) { + // Check if already initialized to avoid duplicate realize handlers + if (g_object_get_data(G_OBJECT(toplevel), kFocusHandlersConnectedKey) != NULL || + g_object_get_data(G_OBJECT(toplevel), kRealizeHandlerConnectedKey) != NULL) { + return; + } + + if (gtk_widget_get_realized(toplevel)) { + // Window is already realized, set up focus handlers now + on_window_realize(toplevel, NULL); + } else { + // Mark realize handler as connected to prevent duplicate connections + // if called again before window is realized + g_object_set_data(G_OBJECT(toplevel), kRealizeHandlerConnectedKey, GINT_TO_POINTER(1)); + // Wait for window to be realized + g_signal_connect(toplevel, "realize", + G_CALLBACK(on_window_realize), NULL); + } + } +} + +#endif // defined(GDK_WINDOWING_WAYLAND) && defined(HAS_KEYBOARD_SHORTCUTS_INHIBIT) diff --git a/flutter/linux/wayland_shortcuts_inhibit.h b/flutter/linux/wayland_shortcuts_inhibit.h new file mode 100644 index 000000000..c0996931a --- /dev/null +++ b/flutter/linux/wayland_shortcuts_inhibit.h @@ -0,0 +1,22 @@ +// Wayland keyboard shortcuts inhibit support +// This module provides functionality to inhibit system keyboard shortcuts +// on Wayland compositors, allowing remote desktop windows to capture all +// key events including Super, Alt+Tab, etc. + +#ifndef WAYLAND_SHORTCUTS_INHIBIT_H_ +#define WAYLAND_SHORTCUTS_INHIBIT_H_ + +#include + +#if defined(GDK_WINDOWING_WAYLAND) && defined(HAS_KEYBOARD_SHORTCUTS_INHIBIT) + +// Initialize shortcuts inhibit for a sub-window created by desktop_multi_window plugin. +// This sets up focus-based inhibitor management: inhibitor is created when +// the window gains focus and destroyed when it loses focus. +// +// @param view The FlView of the sub-window +void wayland_shortcuts_inhibit_init_for_subwindow(void* view); + +#endif // defined(GDK_WINDOWING_WAYLAND) && defined(HAS_KEYBOARD_SHORTCUTS_INHIBIT) + +#endif // WAYLAND_SHORTCUTS_INHIBIT_H_ diff --git a/flutter/macos/Podfile.lock b/flutter/macos/Podfile.lock index a29674fec..008344842 100644 --- a/flutter/macos/Podfile.lock +++ b/flutter/macos/Podfile.lock @@ -10,6 +10,11 @@ PODS: - flutter_custom_cursor (0.0.1): - FlutterMacOS - FlutterMacOS (1.0.0) + - FMDB (2.7.12): + - FMDB/standard (= 2.7.12) + - FMDB/Core (2.7.12) + - FMDB/standard (2.7.12): + - FMDB/Core - package_info_plus (0.0.1): - FlutterMacOS - path_provider_foundation (0.0.1): @@ -17,9 +22,9 @@ PODS: - FlutterMacOS - screen_retriever (0.0.1): - FlutterMacOS - - sqflite (0.0.3): - - Flutter + - sqflite (0.0.2): - FlutterMacOS + - FMDB (>= 2.7.5) - texture_rgba_renderer (0.0.1): - FlutterMacOS - uni_links_desktop (0.0.1): @@ -46,7 +51,7 @@ DEPENDENCIES: - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) - screen_retriever (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever/macos`) - - sqflite (from `Flutter/ephemeral/.symlinks/plugins/sqflite/darwin`) + - sqflite (from `Flutter/ephemeral/.symlinks/plugins/sqflite/macos`) - texture_rgba_renderer (from `Flutter/ephemeral/.symlinks/plugins/texture_rgba_renderer/macos`) - uni_links_desktop (from `Flutter/ephemeral/.symlinks/plugins/uni_links_desktop/macos`) - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) @@ -55,6 +60,10 @@ DEPENDENCIES: - window_manager (from `Flutter/ephemeral/.symlinks/plugins/window_manager/macos`) - window_size (from `Flutter/ephemeral/.symlinks/plugins/window_size/macos`) +SPEC REPOS: + trunk: + - FMDB + EXTERNAL SOURCES: desktop_drop: :path: Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos @@ -75,7 +84,7 @@ EXTERNAL SOURCES: screen_retriever: :path: Flutter/ephemeral/.symlinks/plugins/screen_retriever/macos sqflite: - :path: Flutter/ephemeral/.symlinks/plugins/sqflite/darwin + :path: Flutter/ephemeral/.symlinks/plugins/sqflite/macos texture_rgba_renderer: :path: Flutter/ephemeral/.symlinks/plugins/texture_rgba_renderer/macos uni_links_desktop: @@ -92,24 +101,25 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/window_size/macos SPEC CHECKSUMS: - desktop_drop: 69eeff437544aa619c8db7f4481b3a65f7696898 - desktop_multi_window: 566489c048b501134f9d7fb6a2354c60a9126486 - device_info_plus: 5401765fde0b8d062a2f8eb65510fb17e77cf07f - file_selector_macos: 54fdab7caa3ac3fc43c9fac4d7d8d231277f8cf2 - flutter_custom_cursor: 629957115075c672287bd0fa979d863ccf6024f7 + desktop_drop: e0b672a7d84c0a6cbc378595e82cdb15f2970a43 + desktop_multi_window: 93667594ccc4b88d91a97972fd3b1b89667fa80a + device_info_plus: b0fafc687fb901e2af612763340f1b0d4352f8e5 + file_selector_macos: 6280b52b459ae6c590af5d78fc35c7267a3c4b31 + flutter_custom_cursor: 37e588711a2746f5cf48adb58b582cacff11c0c6 FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 - package_info_plus: 02d7a575e80f194102bef286361c6c326e4c29ce - path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 - screen_retriever: 59634572a57080243dd1bf715e55b6c54f241a38 - sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec - texture_rgba_renderer: cbed959a3c127122194a364e14b8577bd62dc8f2 - uni_links_desktop: 45900fb319df48fcdea2df0756e9c2626696b026 - url_launcher_macos: 5f437abeda8c85500ceb03f5c1938a8c5a705399 - video_player_avfoundation: 7c6c11d8470e1675df7397027218274b6d2360b3 - wakelock_plus: 4783562c9a43d209c458cb9b30692134af456269 - window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8 - window_size: 339dafa0b27a95a62a843042038fa6c3c48de195 + FMDB: 728731dd336af3936ce00f91d9d8495f5718a0e6 + package_info_plus: 122abb51244f66eead59ce7c9c200d6b53111779 + path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 + screen_retriever: 4f97c103641aab8ce183fa5af3b87029df167936 + sqflite: c73556b2499b92f0b6e6946abe4a4084510cdf90 + texture_rgba_renderer: 6661f577ea5d4990e964c7e3840e544ac798e6da + uni_links_desktop: 34322c2646e4c9abc69b62e1865f9782d2850ba2 + url_launcher_macos: 0fba8ddabfc33ce0a9afe7c5fef5aab3d8d2d673 + video_player_avfoundation: 2cef49524dd1f16c5300b9cd6efd9611ce03639b + wakelock_plus: 21ddc249ac4b8d018838dbdabd65c5976c308497 + window_manager: 1d01fa7ac65a6e6f83b965471b1a7fdd3f06166c + window_size: 4bd15034e6e3d0720fd77928a7c42e5492cfece9 PODFILE CHECKSUM: 353c8bcc5d5b0994e508d035b5431cfe18c1dea7 -COCOAPODS: 1.15.2 +COCOAPODS: 1.16.2 diff --git a/flutter/macos/Runner.xcodeproj/project.pbxproj b/flutter/macos/Runner.xcodeproj/project.pbxproj index f38badcbb..c41bfa117 100644 --- a/flutter/macos/Runner.xcodeproj/project.pbxproj +++ b/flutter/macos/Runner.xcodeproj/project.pbxproj @@ -433,7 +433,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = HZF9JMC8YN; ENABLE_HARDENED_RUNTIME = YES; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -579,7 +579,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = HZF9JMC8YN; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -609,7 +609,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = HZF9JMC8YN; ENABLE_HARDENED_RUNTIME = YES; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( diff --git a/flutter/macos/Runner/AppDelegate.swift b/flutter/macos/Runner/AppDelegate.swift index 3498decd3..46372a582 100644 --- a/flutter/macos/Runner/AppDelegate.swift +++ b/flutter/macos/Runner/AppDelegate.swift @@ -1,7 +1,7 @@ import Cocoa import FlutterMacOS -@NSApplicationMain +@main class AppDelegate: FlutterAppDelegate { var launched = false; override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { diff --git a/flutter/macos/Runner/Configs/AppInfo.xcconfig b/flutter/macos/Runner/Configs/AppInfo.xcconfig index f095b674e..eabc428e5 100644 --- a/flutter/macos/Runner/Configs/AppInfo.xcconfig +++ b/flutter/macos/Runner/Configs/AppInfo.xcconfig @@ -11,4 +11,4 @@ PRODUCT_NAME = RustDesk PRODUCT_BUNDLE_IDENTIFIER = com.carriez.flutterHbb // The copyright displayed in application information -PRODUCT_COPYRIGHT = Copyright © 2024 Purslane Ltd. All rights reserved. +PRODUCT_COPYRIGHT = Copyright © 2025 Purslane Ltd. All rights reserved. diff --git a/flutter/macos/Runner/MainFlutterWindow.swift b/flutter/macos/Runner/MainFlutterWindow.swift index b0e20d6ae..1cc72419b 100644 --- a/flutter/macos/Runner/MainFlutterWindow.swift +++ b/flutter/macos/Runner/MainFlutterWindow.swift @@ -19,6 +19,22 @@ import window_manager import window_size import texture_rgba_renderer +// Global state for relative mouse mode +// All properties and methods must be accessed on the main thread since they +// interact with NSEvent monitors, CoreGraphics APIs, and Flutter channels. +// Note: We avoid @MainActor to maintain macOS 10.14 compatibility. +class RelativeMouseState { + static let shared = RelativeMouseState() + + var enabled = false + var eventMonitor: Any? + var deltaChannel: FlutterMethodChannel? + var accumulatedDeltaX: CGFloat = 0 + var accumulatedDeltaY: CGFloat = 0 + + private init() {} +} + class MainFlutterWindow: NSWindow { override func awakeFromNib() { rustdesk_core_main(); @@ -29,7 +45,7 @@ class MainFlutterWindow: NSWindow { // register self method handler let registrar = flutterViewController.registrar(forPlugin: "RustDeskPlugin") setMethodHandler(registrar: registrar) - + RegisterGeneratedPlugins(registry: flutterViewController) FlutterMultiWindowPlugin.setOnWindowCreatedCallback { controller in @@ -50,22 +66,120 @@ class MainFlutterWindow: NSWindow { WindowSizePlugin.register(with: controller.registrar(forPlugin: "WindowSizePlugin")) TextureRgbaRendererPlugin.register(with: controller.registrar(forPlugin: "TextureRgbaRendererPlugin")) } - + super.awakeFromNib() } - + override public func order(_ place: NSWindow.OrderingMode, relativeTo otherWin: Int) { super.order(place, relativeTo: otherWin) hiddenWindowAtLaunch() } - + /// Override window theme. public func setWindowInterfaceMode(window: NSWindow, themeName: String) { window.appearance = NSAppearance(named: themeName == "light" ? .aqua : .darkAqua) } - + + private func enableNativeRelativeMouseMode(channel: FlutterMethodChannel) -> Bool { + assert(Thread.isMainThread, "enableNativeRelativeMouseMode must be called on the main thread") + let state = RelativeMouseState.shared + if state.enabled { + // Already enabled: update the channel so this caller receives deltas. + state.deltaChannel = channel + return true + } + + // Dissociate mouse from cursor position - this locks the cursor in place + // Do this FIRST before setting any state + let result = CGAssociateMouseAndMouseCursorPosition(0) + if result != CGError.success { + NSLog("[RustDesk] Failed to dissociate mouse from cursor position: %d", result.rawValue) + return false + } + + // Only set state after CG call succeeds + state.deltaChannel = channel + state.accumulatedDeltaX = 0 + state.accumulatedDeltaY = 0 + + // Add local event monitor to capture mouse delta. + // Note: Local event monitors are always called on the main thread, + // so accessing main-thread-only state is safe here. + state.eventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.mouseMoved, .leftMouseDragged, .rightMouseDragged, .otherMouseDragged]) { [weak state] event in + guard let state = state else { return event } + // Guard against race: mode may be disabled between weak capture and this check. + guard state.enabled else { return event } + let deltaX = event.deltaX + let deltaY = event.deltaY + + if deltaX != 0 || deltaY != 0 { + // Accumulate delta (main thread only - NSEvent local monitors always run on main thread) + state.accumulatedDeltaX += deltaX + state.accumulatedDeltaY += deltaY + + // Only send if we have integer movement + let intX = Int(state.accumulatedDeltaX) + let intY = Int(state.accumulatedDeltaY) + + if intX != 0 || intY != 0 { + state.accumulatedDeltaX -= CGFloat(intX) + state.accumulatedDeltaY -= CGFloat(intY) + + // Send delta to Flutter (already on main thread) + state.deltaChannel?.invokeMethod("onMouseDelta", arguments: ["dx": intX, "dy": intY]) + } + } + + return event + } + + // Check if monitor was created successfully + if state.eventMonitor == nil { + NSLog("[RustDesk] Failed to create event monitor for relative mouse mode") + // Re-associate mouse since we failed + CGAssociateMouseAndMouseCursorPosition(1) + state.deltaChannel = nil + return false + } + + // Set enabled LAST after everything succeeds + state.enabled = true + return true + } + + private func disableNativeRelativeMouseMode() { + assert(Thread.isMainThread, "disableNativeRelativeMouseMode must be called on the main thread") + let state = RelativeMouseState.shared + if !state.enabled { return } + + state.enabled = false + + // Remove event monitor + if let monitor = state.eventMonitor { + NSEvent.removeMonitor(monitor) + state.eventMonitor = nil + } + + state.deltaChannel = nil + state.accumulatedDeltaX = 0 + state.accumulatedDeltaY = 0 + + // Re-associate mouse with cursor position (non-blocking with async retry) + let result = CGAssociateMouseAndMouseCursorPosition(1) + if result != CGError.success { + NSLog("[RustDesk] Failed to re-associate mouse with cursor position: %d, scheduling retry...", result.rawValue) + // Non-blocking retry after 50ms + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + let retryResult = CGAssociateMouseAndMouseCursorPosition(1) + if retryResult != CGError.success { + NSLog("[RustDesk] Retry failed to re-associate mouse: %d. Cursor may remain locked.", retryResult.rawValue) + } + } + } + } + public func setMethodHandler(registrar: FlutterPluginRegistrar) { - let channel = FlutterMethodChannel(name: "org.rustdesk.rustdesk/macos", binaryMessenger: registrar.messenger) + let channel = FlutterMethodChannel(name: "org.rustdesk.rustdesk/host", binaryMessenger: registrar.messenger) channel.setMethodCallHandler({ (call, result) -> Void in switch call.method { @@ -96,9 +210,74 @@ class MainFlutterWindow: NSWindow { } case "requestRecordAudio": AVCaptureDevice.requestAccess(for: .audio, completionHandler: { granted in - result(granted) + DispatchQueue.main.async { + result(granted) + } }) break + case "bumpMouse": + var dx = 0 + var dy = 0 + + if let argMap = call.arguments as? [String: Any] { + dx = (argMap["dx"] as? Int) ?? 0 + dy = (argMap["dy"] as? Int) ?? 0 + } + else if let argList = call.arguments as? [Any] { + dx = argList.count >= 1 ? (argList[0] as? Int) ?? 0 : 0 + dy = argList.count >= 2 ? (argList[1] as? Int) ?? 0 : 0 + } + + var mouseLoc: CGPoint + + if let dummyEvent = CGEvent(source: nil) { // can this ever fail? + mouseLoc = dummyEvent.location + } + else if let screenFrame = NSScreen.screens.first?.frame { + // NeXTStep: Origin is lower-left of primary screen, positive is up + // Cocoa Core Graphics: Origin is upper-left of primary screen, positive is down + let nsMouseLoc = NSEvent.mouseLocation + + mouseLoc = CGPoint( + x: nsMouseLoc.x, + y: NSHeight(screenFrame) - nsMouseLoc.y) + } + else { + result(false) + break + } + + let newLoc = CGPoint(x: mouseLoc.x + CGFloat(dx), y: mouseLoc.y + CGFloat(dy)) + + CGDisplayMoveCursorToPoint(0, newLoc) + + // By default, Cocoa suppresses mouse events briefly after a call to warp the + // cursor to a new location. This is good if you want to draw the user's + // attention to the fact that the mouse is now in a particular location, but + // it's bad in this case; we get called as part of the handling of edge + // scrolling, which means the mouse is typically still in motion, and we want + // the cursor to keep moving smoothly uninterrupted. + // + // This function's main action is to toggle whether the mouse cursor is + // associated with the mouse position, but setting it to true when it's + // already true has the side-effect of cancelling this motion suppression. + // + // However, we must NOT call this when relative mouse mode is active, + // as it would break the pointer lock established by enableNativeRelativeMouseMode. + if !RelativeMouseState.shared.enabled { + CGAssociateMouseAndMouseCursorPosition(1 /* true */) + } + + result(true) + + case "enableNativeRelativeMouseMode": + let success = self.enableNativeRelativeMouseMode(channel: channel) + result(success) + + case "disableNativeRelativeMouseMode": + self.disableNativeRelativeMouseMode() + result(true) + default: result(FlutterMethodNotImplemented) } diff --git a/flutter/ndk_x86.sh b/flutter/ndk_x86.sh index 617c25f65..57e121274 100755 --- a/flutter/ndk_x86.sh +++ b/flutter/ndk_x86.sh @@ -1,2 +1,10 @@ #!/usr/bin/env bash + +# +# Fix OpenSSL build with Android NDK clang on 32-bit architectures +# + +export CFLAGS="-DBROKEN_CLANG_ATOMICS" +export CXXFLAGS="-DBROKEN_CLANG_ATOMICS" + cargo ndk --platform 21 --target i686-linux-android build --release --features flutter diff --git a/flutter/pubspec.lock b/flutter/pubspec.lock index 6551bbb37..c6f8aa1c2 100644 --- a/flutter/pubspec.lock +++ b/flutter/pubspec.lock @@ -5,10 +5,15 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: eb376e9acf6938204f90eb3b1f00b578640d3188b4c8a8ec054f9f479af8d051 + sha256: f256b0c0ba6c7577c15e2e4e114755640a875e885099367bf6e012b19314c834 url: "https://pub.dev" source: hosted - version: "64.0.0" + version: "72.0.0" + _macros: + dependency: transitive + description: dart + source: sdk + version: "0.3.2" after_layout: dependency: transitive description: @@ -21,10 +26,10 @@ packages: dependency: transitive description: name: analyzer - sha256: "69f54f967773f6c26c7dcb13e93d7ccee8b17a641689da39e878d5cf13b06893" + sha256: b652861553cd3990d8ed361f7979dc6d7053a9ac8843fa73820ab68ce5410139 url: "https://pub.dev" source: hosted - version: "6.2.0" + version: "6.7.0" animations: dependency: transitive description: @@ -37,26 +42,26 @@ packages: dependency: transitive description: name: archive - sha256: "22600aa1e926be775fa5fe7e6894e7fb3df9efda8891c73f70fb3262399a432d" + sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d url: "https://pub.dev" source: hosted - version: "3.4.10" + version: "3.6.1" args: dependency: transitive description: name: args - sha256: eef6c46b622e0494a36c5a12d10d77fb4e855501a91c1b9ef9339326e58f0596 + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.7.0" async: dependency: transitive description: name: async - sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" url: "https://pub.dev" source: hosted - version: "2.11.0" + version: "2.13.0" auto_size_text: dependency: "direct main" description: @@ -69,10 +74,10 @@ packages: dependency: "direct main" description: name: auto_size_text_field - sha256: d47c81ffa9b61d219f6c50492dc03ea28fa9346561b2ec33b46ccdc000ddb0aa + sha256: "41c90b2270e38edc6ce5c02e5a17737a863e65e246bdfc94565a38f3ec399144" url: "https://pub.dev" source: hosted - version: "2.2.2" + version: "2.2.4" back_button_interceptor: dependency: "direct main" description: @@ -85,10 +90,10 @@ packages: dependency: transitive description: name: boolean_selector - sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" bot_toast: dependency: "direct main" description: @@ -125,10 +130,10 @@ packages: dependency: transitive description: name: build_daemon - sha256: "0343061a33da9c5810b2d6cee51945127d8f4c060b7fbdd9d54917f0a3feaaa1" + sha256: "79b2aef6ac2ed00046867ed354c88778c9c0f029df8a20fe10b5436826721ef9" url: "https://pub.dev" source: hosted - version: "4.0.1" + version: "4.0.2" build_resolvers: dependency: transitive description: @@ -141,18 +146,18 @@ packages: dependency: "direct dev" description: name: build_runner - sha256: "581bacf68f89ec8792f5e5a0b2c4decd1c948e97ce659dc783688c8a88fbec21" + sha256: "028819cfb90051c6b5440c7e574d1896f8037e3c96cf17aaeb054c9311cfbf4d" url: "https://pub.dev" source: hosted - version: "2.4.8" + version: "2.4.13" build_runner_core: dependency: transitive description: name: build_runner_core - sha256: "4ae8ffe5ac758da294ecf1802f2aff01558d8b1b00616aa7538ea9a8a5d50799" + sha256: f8126682b87a7282a339b871298cc12009cb67109cfa1614d6436fb0289193e0 url: "https://pub.dev" source: hosted - version: "7.3.0" + version: "7.3.2" built_collection: dependency: transitive description: @@ -165,10 +170,10 @@ packages: dependency: transitive description: name: built_value - sha256: a3ec2e0f967bc47f69f95009bb93db936288d61d5343b9436e378b28a2f830c6 + sha256: "082001b5c3dc495d4a42f1d5789990505df20d8547d42507c29050af6933ee27" url: "https://pub.dev" source: hosted - version: "8.9.0" + version: "8.10.1" cached_network_image: dependency: transitive description: @@ -189,10 +194,10 @@ packages: dependency: transitive description: name: cached_network_image_web - sha256: "42a835caa27c220d1294311ac409a43361088625a4f23c820b006dd9bffb3316" + sha256: "205d6a9f1862de34b93184f22b9d2d94586b2f05c581d546695e3d8f6a805cd7" url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.2.0" characters: dependency: transitive description: @@ -205,10 +210,10 @@ packages: dependency: transitive description: name: charcode - sha256: fb98c0f6d12c920a02ee2d998da788bca066ca5f148492b7085ee23372b12306 + sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a url: "https://pub.dev" source: hosted - version: "1.3.1" + version: "1.4.0" checked_yaml: dependency: transitive description: @@ -221,10 +226,10 @@ packages: dependency: transitive description: name: cli_util - sha256: c05b7406fdabc7a49a3929d4af76bcaccbbffcbcdcf185b082e1ae07da323d19 + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c url: "https://pub.dev" source: hosted - version: "0.4.1" + version: "0.4.2" clock: dependency: transitive description: @@ -237,10 +242,10 @@ packages: dependency: transitive description: name: code_builder - sha256: f692079e25e7869c14132d39f223f8eec9830eb76131925143b2129c4bb01b37 + sha256: "0ec10bf4a89e4c613960bf1e8b42c64127021740fb21640c29c909826a5eea3e" url: "https://pub.dev" source: hosted - version: "4.10.0" + version: "4.10.1" collection: dependency: transitive description: @@ -261,42 +266,42 @@ packages: dependency: transitive description: name: convert - sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 url: "https://pub.dev" source: hosted - version: "3.1.1" + version: "3.1.2" cross_file: dependency: transitive description: name: cross_file - sha256: fedaadfa3a6996f75211d835aaeb8fede285dae94262485698afd832371b9a5e + sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670" url: "https://pub.dev" source: hosted - version: "0.3.3+8" + version: "0.3.4+2" crypto: dependency: transitive description: name: crypto - sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab + sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.0.6" csslib: dependency: transitive description: name: csslib - sha256: "706b5707578e0c1b4b7550f64078f0a0f19dec3f50a178ffae7006b0a9ca58fb" + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "1.0.2" dart_style: dependency: transitive description: name: dart_style - sha256: "40ae61a5d43feea6d24bd22c0537a6629db858963b99b4bc1c3db80676f32368" + sha256: "7856d364b589d1f08986e140938578ed36ed948581fbc3bc9aef1805039ac5ab" url: "https://pub.dev" source: hosted - version: "2.3.4" + version: "2.3.7" dash_chat_2: dependency: "direct main" description: @@ -310,10 +315,10 @@ packages: dependency: transitive description: name: dbus - sha256: "365c771ac3b0e58845f39ec6deebc76e3276aa9922b0cc60840712094d9047ac" + sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.11" debounce_throttle: dependency: "direct main" description: @@ -335,7 +340,7 @@ packages: description: path: "." ref: HEAD - resolved-ref: "519350f1f40746798299e94786197d058353bac9" + resolved-ref: b47e8385e5a75d38319ad706a64b0ead3108b093 url: "https://github.com/rustdesk-org/rustdesk_desktop_multi_window" source: git version: "0.1.0" @@ -351,10 +356,10 @@ packages: dependency: transitive description: name: device_info_plus_platform_interface - sha256: d3b01d5868b50ae571cd1dc6e502fc94d956b665756180f7b16ead09e836fd64 + sha256: "0b04e02b30791224b31969eb1b50d723498f402971bff3630bca2ba839bd1ed2" url: "https://pub.dev" source: hosted - version: "7.0.0" + version: "7.0.2" draggable_float_widget: dependency: "direct main" description: @@ -380,22 +385,30 @@ packages: url: "https://github.com/rustdesk-org/dynamic_layouts.git" source: git version: "0.0.1+1" + equatable: + dependency: transitive + description: + name: equatable + sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" + url: "https://pub.dev" + source: hosted + version: "2.0.7" extended_text: dependency: "direct main" description: name: extended_text - sha256: "7f382de3af12992e34bd72ddd36becf90c4720900af126cb9859f0189af71ffe" + sha256: "38c1cac571d6eaf406f4b80040c1f88561e7617ad90795aac6a1be0a8d0bb676" url: "https://pub.dev" source: hosted - version: "13.0.0" + version: "14.0.0" extended_text_library: dependency: transitive description: name: extended_text_library - sha256: "55d09098ec56fab0d9a8a68950ca0bbf2efa1327937f7cec6af6dfa066234829" + sha256: "13d99f8a10ead472d5e2cf4770d3d047203fe5054b152e9eb5dc692a71befbba" url: "https://pub.dev" source: hosted - version: "12.0.0" + version: "12.0.1" external_path: dependency: "direct main" description: @@ -408,10 +421,10 @@ packages: dependency: "direct main" description: name: ffi - sha256: "7bf0adc28a23d395f19f3f1eb21dd7cfd1dd9f8e1c50051c069122e6853bc878" + sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.3" ffigen: dependency: "direct dev" description: @@ -440,18 +453,18 @@ packages: dependency: transitive description: name: file_selector_linux - sha256: "045d372bf19b02aeb69cacf8b4009555fb5f6f0b7ad8016e5f46dd1387ddd492" + sha256: "54cbbd957e1156d29548c7d9b9ec0c0ebb6de0a90452198683a7d23aed617a33" url: "https://pub.dev" source: hosted - version: "0.9.2+1" + version: "0.9.3+2" file_selector_macos: dependency: transitive description: name: file_selector_macos - sha256: b15c3da8bd4908b9918111fa486903f5808e388b8d1c559949f584725a6594d6 + sha256: "271ab9986df0c135d45c3cdb6bd0faa5db6f4976d3e4b437cf7d0f258d941bfc" url: "https://pub.dev" source: hosted - version: "0.9.3+3" + version: "0.9.4+2" file_selector_platform_interface: dependency: transitive description: @@ -464,34 +477,34 @@ packages: dependency: transitive description: name: file_selector_windows - sha256: d3547240c20cabf205c7c7f01a50ecdbc413755814d6677f3cb366f04abcead0 + sha256: "320fcfb6f33caa90f0b58380489fc5ac05d99ee94b61aa96ec2bff0ba81d3c2b" url: "https://pub.dev" source: hosted - version: "0.9.3+1" + version: "0.9.3+4" fixnum: dependency: transitive description: name: fixnum - sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1" + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.1" flex_color_picker: dependency: "direct main" description: name: flex_color_picker - sha256: "0871edc170153cfc3de316d30625f40a85daecfa76ce541641f3cc0ec7757cbf" + sha256: "12dc855ae8ef5491f529b1fc52c655f06dcdf4114f1f7fdecafa41eec2ec8d79" url: "https://pub.dev" source: hosted - version: "3.3.1" + version: "3.6.0" flex_seed_scheme: dependency: transitive description: name: flex_seed_scheme - sha256: "29c12aba221eb8a368a119685371381f8035011d18de5ba277ad11d7dfb8657f" + sha256: "7639d2c86268eff84a909026eb169f008064af0fb3696a651b24b0fa24a40334" url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "3.4.1" flutter: dependency: "direct main" description: flutter @@ -525,20 +538,11 @@ packages: dependency: "direct main" description: path: "." - ref: "2ded7f146437a761ffe6981e2f742038f85ca68d" - resolved-ref: "2ded7f146437a761ffe6981e2f742038f85ca68d" + ref: "08a471bb8ceccdd50483c81cdfa8b81b07b14b87" + resolved-ref: "08a471bb8ceccdd50483c81cdfa8b81b07b14b87" url: "https://github.com/rustdesk-org/flutter_gpu_texture_renderer" source: git version: "0.0.1" - flutter_improved_scrolling: - dependency: "direct main" - description: - path: "." - ref: HEAD - resolved-ref: "62f09545149f320616467c306c8c5f71714a18e6" - url: "https://github.com/rustdesk-org/flutter_improved_scrolling" - source: git - version: "0.0.3" flutter_keyboard_visibility: dependency: "direct main" description: @@ -617,7 +621,7 @@ packages: source: hosted version: "2.2.1" flutter_plugin_android_lifecycle: - dependency: transitive + dependency: "direct overridden" description: name: flutter_plugin_android_lifecycle sha256: b068ffc46f82a55844acfa4fdbb61fad72fa2aef0905548419d97f0f95c456da @@ -636,10 +640,10 @@ packages: dependency: "direct main" description: name: flutter_svg - sha256: d39e7f95621fc84376bc0f7d504f05c3a41488c562f4a8ad410569127507402c + sha256: d44bf546b13025ec7353091516f6881f1d4c633993cb109c3916c3a0159dadf1 url: "https://pub.dev" source: hosted - version: "2.0.9" + version: "2.1.0" flutter_web_plugins: dependency: transitive description: flutter @@ -649,74 +653,82 @@ packages: dependency: "direct dev" description: name: freezed - sha256: "57247f692f35f068cae297549a46a9a097100685c6780fe67177503eea5ed4e5" + sha256: "44c19278dd9d89292cf46e97dc0c1e52ce03275f40a97c5a348e802a924bf40e" url: "https://pub.dev" source: hosted - version: "2.4.7" + version: "2.5.7" freezed_annotation: dependency: "direct main" description: name: freezed_annotation - sha256: c3fd9336eb55a38cc1bbd79ab17573113a8deccd0ecbbf926cca3c62803b5c2d + sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2 url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.4" frontend_server_client: dependency: transitive description: name: frontend_server_client - sha256: "408e3ca148b31c20282ad6f37ebfa6f4bdc8fede5b74bc2f08d9d92b55db3612" + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 url: "https://pub.dev" source: hosted - version: "3.2.0" + version: "4.0.0" get: dependency: "direct main" description: name: get - sha256: e4e7335ede17452b391ed3b2ede016545706c01a02292a6c97619705e7d2a85e + sha256: c79eeb4339f1f3deffd9ec912f8a923834bec55f7b49c9e882b8fef2c139d425 url: "https://pub.dev" source: hosted - version: "4.6.6" + version: "4.7.2" glob: dependency: transitive description: name: glob - sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.3" + google_fonts: + dependency: "direct main" + description: + name: google_fonts + sha256: b1ac0fe2832c9cc95e5e88b57d627c5e68c223b9657f4b96e1487aa9098c7b82 + url: "https://pub.dev" + source: hosted + version: "6.2.1" graphs: dependency: transitive description: name: graphs - sha256: aedc5a15e78fc65a6e23bcd927f24c64dd995062bcd1ca6eda65a3cff92a4d19 + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.3.2" html: dependency: transitive description: name: html - sha256: "3a7812d5bcd2894edf53dfaf8cd640876cf6cef50a8f238745c8b8120ea74d3a" + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" url: "https://pub.dev" source: hosted - version: "0.15.4" + version: "0.15.6" http: dependency: "direct main" description: name: http - sha256: a2bbf9d017fcced29139daa8ed2bba4ece450ab222871df93ca9eec6f80c34ba + sha256: "2c11f3f94c687ee9bad77c171151672986360b2b001d109814ee7140b2cf261b" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.4.0" http_multi_server: dependency: transitive description: name: http_multi_server - sha256: "97486f20f9c2f7be8f514851703d0119c3596d14ea63227af6f7a481ef2b2f8b" + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 url: "https://pub.dev" source: hosted - version: "3.2.1" + version: "3.2.2" http_parser: dependency: transitive description: @@ -737,66 +749,66 @@ packages: dependency: "direct main" description: name: image - sha256: "4c68bfd5ae83e700b5204c1e74451e7bf3cf750e6843c6e158289cf56bda018e" + sha256: f31d52537dc417fdcde36088fdf11d191026fd5e4fae742491ebd40e5a8bea7d url: "https://pub.dev" source: hosted - version: "4.1.7" + version: "4.3.0" image_picker: dependency: "direct main" description: name: image_picker - sha256: b6951e25b795d053a6ba03af5f710069c99349de9341af95155d52665cb4607c + sha256: "021834d9c0c3de46bf0fe40341fa07168407f694d9b2bb18d532dc1261867f7a" url: "https://pub.dev" source: hosted - version: "0.8.9" + version: "1.1.2" image_picker_android: dependency: transitive description: name: image_picker_android - sha256: "39f2bfe497e495450c81abcd44b62f56c2a36a37a175da7d137b4454977b51b1" + sha256: "82652a75e3dd667a91187769a6a2cc81bd8c111bbead698d8e938d2b63e5e89a" url: "https://pub.dev" source: hosted - version: "0.8.9+3" + version: "0.8.12+21" image_picker_for_web: dependency: transitive description: name: image_picker_for_web - sha256: "869fe8a64771b7afbc99fc433a5f7be2fea4d1cb3d7c11a48b6b579eb9c797f0" + sha256: "717eb042ab08c40767684327be06a5d8dbb341fe791d514e4b92c7bbe1b7bb83" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "3.0.6" image_picker_ios: dependency: transitive description: name: image_picker_ios - sha256: fadafce49e8569257a0cad56d24438a6fa1f0cbd7ee0af9b631f7492818a4ca3 + sha256: "05da758e67bc7839e886b3959848aa6b44ff123ab4b28f67891008afe8ef9100" url: "https://pub.dev" source: hosted - version: "0.8.9+1" + version: "0.8.12+2" image_picker_linux: dependency: transitive description: name: image_picker_linux - sha256: "4ed1d9bb36f7cd60aa6e6cd479779cc56a4cb4e4de8f49d487b1aaad831300fa" + sha256: "34a65f6740df08bbbeb0a1abd8e6d32107941fd4868f67a507b25601651022c9" url: "https://pub.dev" source: hosted - version: "0.2.1+1" + version: "0.2.1+2" image_picker_macos: dependency: transitive description: name: image_picker_macos - sha256: "3f5ad1e8112a9a6111c46d0b57a7be2286a9a07fc6e1976fdf5be2bd31d4ff62" + sha256: "1b90ebbd9dcf98fb6c1d01427e49a55bd96b5d67b8c67cf955d60a5de74207c1" url: "https://pub.dev" source: hosted - version: "0.2.1+1" + version: "0.2.1+2" image_picker_platform_interface: dependency: transitive description: name: image_picker_platform_interface - sha256: fa4e815e6fcada50e35718727d83ba1c92f1edf95c0b4436554cec301b56233b + sha256: "886d57f0be73c4b140004e78b9f28a8914a09e50c2d816bdd0520051a71236a0" url: "https://pub.dev" source: hosted - version: "2.9.3" + version: "2.10.1" image_picker_windows: dependency: transitive description: @@ -817,10 +829,10 @@ packages: dependency: transitive description: name: io - sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "1.0.5" js: dependency: transitive description: @@ -833,10 +845,10 @@ packages: dependency: transitive description: name: json_annotation - sha256: b10a7b2ff83d83c777edba3c6a0f97045ddadd56c944e1a23a3fdf43a1bf4467 + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" url: "https://pub.dev" source: hosted - version: "4.8.1" + version: "4.9.0" lints: dependency: transitive description: @@ -849,42 +861,50 @@ packages: dependency: transitive description: name: logging - sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340" + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.3.0" + macros: + dependency: transitive + description: + name: macros + sha256: "0acaed5d6b7eab89f63350bccd82119e6c602df0f391260d0e32b5e23db79536" + url: "https://pub.dev" + source: hosted + version: "0.1.2-main.4" matcher: dependency: transitive description: name: matcher - sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.16+1" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.8.0" + version: "0.11.1" meta: dependency: transitive description: name: meta - sha256: d584fa6707a52763a52446f02cc621b077888fb63b93bbcb1143a7be5a0c0c04 + sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 url: "https://pub.dev" source: hosted - version: "1.11.0" + version: "1.15.0" mime: dependency: transitive description: name: mime - sha256: "2e123074287cc9fd6c09de8336dae606d1ddb88d9ac47358826db698c176a1f2" + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" url: "https://pub.dev" source: hosted - version: "1.0.5" + version: "2.0.0" nested: dependency: transitive description: @@ -897,18 +917,18 @@ packages: dependency: transitive description: name: octo_image - sha256: "45b40f99622f11901238e18d48f5f12ea36426d8eced9f4cbf58479c7aa2430d" + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.1.0" package_config: dependency: transitive description: name: package_config - sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.2.0" package_info_plus: dependency: "direct main" description: @@ -945,34 +965,34 @@ packages: dependency: transitive description: name: path_parsing - sha256: e3e67b1629e6f7e8100b367d3db6ba6af4b1f0bb80f64db18ef1fbabd2fa9ccf + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "1.1.0" path_provider: dependency: "direct main" description: name: path_provider - sha256: b27217933eeeba8ff24845c34003b003b2b22151de3c908d0e679e8fe1aa078b + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.5" path_provider_android: dependency: transitive description: name: path_provider_android - sha256: "477184d672607c0a3bf68fbbf601805f92ef79c82b64b4d6eb318cbca4c48668" + sha256: "4adf4fd5423ec60a29506c76581bc05854c55e3a0b72d35bb28d661c9686edf2" url: "https://pub.dev" source: hosted - version: "2.2.2" + version: "2.2.15" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: "5a7999be66e000916500be4f15a3633ebceb8302719b47b9cc49ce924125350f" + sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.4.1" path_provider_linux: dependency: transitive description: @@ -993,10 +1013,10 @@ packages: dependency: transitive description: name: path_provider_windows - sha256: "8bc9f22eee8690981c22aa7fc602f5c85b497a6fb2ceb35ee5a5e5ed85ad8170" + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.3.0" pedantic: dependency: transitive description: @@ -1009,10 +1029,10 @@ packages: dependency: "direct main" description: name: percent_indicator - sha256: c37099ad833a883c9d71782321cb65c3a848c21b6939b6185f0ff6640d05814c + sha256: "157d29133bbc6ecb11f923d36e7960a96a3f28837549a20b65e5135729f0f9fd" url: "https://pub.dev" source: hosted - version: "4.2.3" + version: "4.2.5" petitparser: dependency: transitive description: @@ -1025,10 +1045,10 @@ packages: dependency: transitive description: name: platform - sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec" + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" url: "https://pub.dev" source: hosted - version: "3.1.4" + version: "3.1.6" plugin_platform_interface: dependency: transitive description: @@ -1037,14 +1057,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" - pointycastle: - dependency: transitive - description: - name: pointycastle - sha256: "43ac87de6e10afabc85c445745a7b799e04de84cebaa4fd7bf55a5e1e9604d29" - url: "https://pub.dev" - source: hosted - version: "3.7.4" pool: dependency: transitive description: @@ -1057,50 +1069,50 @@ packages: dependency: "direct main" description: name: provider - sha256: "9a96a0a19b594dbc5bf0f1f27d2bc67d5f95957359b461cd9feb44ed6ae75096" + sha256: "4abbd070a04e9ddc287673bf5a030c7ca8b685ff70218720abab8b092f53dd84" url: "https://pub.dev" source: hosted - version: "6.1.1" + version: "6.1.5" pub_semver: dependency: transitive description: name: pub_semver - sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" pubspec_parse: dependency: transitive description: name: pubspec_parse - sha256: c63b2876e58e194e4b0828fcb080ad0e06d051cb607a6be51a9e084f47cb9367 + sha256: "81876843eb50dc2e1e5b151792c9a985c5ed2536914115ed04e9c8528f6647b0" url: "https://pub.dev" source: hosted - version: "1.2.3" + version: "1.4.0" pull_down_button: dependency: "direct main" description: name: pull_down_button - sha256: "235b302701ce029fd9e9470975069376a6700935bb47a5f1b3ec8a5efba07e6f" + sha256: "48b928203afdeafa4a8be5dc96980523bc8a2ddbd04569f766071a722be22379" url: "https://pub.dev" source: hosted - version: "0.9.3" + version: "0.9.4" puppeteer: dependency: transitive description: name: puppeteer - sha256: eedeaae6ec5d2e54f9ae22ab4d6b3dda2e8791c356cc783046d06c287ffe11d8 + sha256: "7a990c68d33882b642214c351f66492d9a738afa4226a098ab70642357337fa2" url: "https://pub.dev" source: hosted - version: "3.6.0" + version: "3.16.0" qr: dependency: transitive description: name: qr - sha256: "64957a3930367bf97cc211a5af99551d630f2f4625e38af10edd6b19131b64b3" + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" qr_code_scanner: dependency: "direct main" description: @@ -1121,10 +1133,10 @@ packages: dependency: transitive description: name: quiver - sha256: b1c1ac5ce6688d77f65f3375a9abb9319b3cb32486bdc7a1e0fdf004d7ba4e47 + sha256: ea0b925899e64ecdfbf9c7becb60d5b50e706ade44a85b2363be2a22d88117d2 url: "https://pub.dev" source: hosted - version: "3.2.1" + version: "3.2.2" rxdart: dependency: transitive description: @@ -1169,10 +1181,10 @@ packages: dependency: transitive description: name: shelf_static - sha256: a41d3f53c4adf0f57480578c1d61d90342cd617de7fc8077b1304643c2d85c1e + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 url: "https://pub.dev" source: hosted - version: "1.1.2" + version: "1.1.3" shelf_web_socket: dependency: transitive description: @@ -1206,106 +1218,107 @@ packages: dependency: transitive description: name: source_span - sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" url: "https://pub.dev" source: hosted - version: "1.10.0" + version: "1.10.1" sqflite: - dependency: transitive + dependency: "direct main" description: name: sqflite - sha256: a9016f495c927cb90557c909ff26a6d92d9bd54fc42ba92e19d4e79d61e798c6 + sha256: a9a8c6dfdf315f87f2a23a7bad2b60c8d5af0f88a5fde92cf9205202770c2753 url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.2.0" sqflite_common: dependency: transitive description: name: sqflite_common - sha256: "28d8c66baee4968519fb8bd6cdbedad982d6e53359091f0b74544a9f32ec72d5" + sha256: "761b9740ecbd4d3e66b8916d784e581861fd3c3553eda85e167bc49fdb68f709" url: "https://pub.dev" source: hosted - version: "2.5.3" + version: "2.5.4+6" stack_trace: dependency: transitive description: name: stack_trace - sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" url: "https://pub.dev" source: hosted - version: "1.11.1" + version: "1.12.1" stream_channel: dependency: transitive description: name: stream_channel - sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.4" stream_transform: dependency: transitive description: name: stream_transform - sha256: "14a00e794c7c11aa145a170587321aedce29769c08d7f58b1d141da75e3b1c6f" + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.1" string_scanner: dependency: transitive description: name: string_scanner - sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.4.1" synchronized: dependency: transitive description: name: synchronized - sha256: "539ef412b170d65ecdafd780f924e5be3f60032a1128df156adad6c5b373d558" + sha256: "69fe30f3a8b04a0be0c15ae6490fc859a78ef4c43ae2dd5e8a623d45bfcf9225" url: "https://pub.dev" source: hosted - version: "3.1.0+1" + version: "3.3.0+3" term_glyph: dependency: transitive description: name: term_glyph - sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "1.2.2" test_api: dependency: transitive description: name: test_api - sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" url: "https://pub.dev" source: hosted - version: "0.7.0" + version: "0.7.6" texture_rgba_renderer: dependency: "direct main" description: - name: texture_rgba_renderer - sha256: cb048abdd800468ca40749ca10d1db9d1e6a055d1cde6234c05191293f0c7d61 - url: "https://pub.dev" - source: hosted + path: "." + ref: "42797e0f03141dc2b585f76c64a13974508058b4" + resolved-ref: "42797e0f03141dc2b585f76c64a13974508058b4" + url: "https://github.com/rustdesk-org/flutter_texture_rgba_renderer" + source: git version: "0.0.16" timing: dependency: transitive description: name: timing - sha256: "70a3b636575d4163c477e6de42f247a23b315ae20e86442bebe32d3cabf61c32" + sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "1.0.2" toggle_switch: dependency: "direct main" description: name: toggle_switch - sha256: "9e6af1f0c5a97d9de41109dc7b9e1b3bbe73417f89b10e0e44dc834fb493d4cb" + sha256: dca04512d7c23ed320d6c5ede1211a404f177d54d353bf785b07d15546a86ce5 url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.3.0" tuple: dependency: "direct main" description: @@ -1318,17 +1331,18 @@ packages: dependency: transitive description: name: typed_data - sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 url: "https://pub.dev" source: hosted - version: "1.3.2" + version: "1.4.0" uni_links: dependency: "direct main" description: - name: uni_links - sha256: "051098acfc9e26a9fde03b487bef5d3d228ca8f67693480c6f33fd4fbb8e2b6e" - url: "https://pub.dev" - source: hosted + path: uni_links + ref: f416118d843a7e9ed117c7bb7bdc2deda5a9e86f + resolved-ref: f416118d843a7e9ed117c7bb7bdc2deda5a9e86f + url: "https://github.com/rustdesk-org/uni_links" + source: git version: "0.5.1" uni_links_desktop: dependency: "direct main" @@ -1366,66 +1380,66 @@ packages: dependency: "direct main" description: name: url_launcher - sha256: c512655380d241a337521703af62d2c122bf7b77a46ff7dd750092aa9433499c + sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603" url: "https://pub.dev" source: hosted - version: "6.2.4" + version: "6.3.1" url_launcher_android: dependency: transitive description: name: url_launcher_android - sha256: "507dc655b1d9cb5ebc756032eb785f114e415f91557b73bf60b7e201dfedeb2f" + sha256: "6fc2f56536ee873eeb867ad176ae15f304ccccc357848b351f6f0d8d4a40d193" url: "https://pub.dev" source: hosted - version: "6.2.2" + version: "6.3.14" url_launcher_ios: - dependency: transitive + dependency: "direct main" description: name: url_launcher_ios - sha256: "75bb6fe3f60070407704282a2d295630cab232991eb52542b18347a8a941df03" + sha256: "7f2022359d4c099eea7df3fdf739f7d3d3b9faf3166fb1dd390775176e0b76cb" url: "https://pub.dev" source: hosted - version: "6.2.4" + version: "6.3.3" url_launcher_linux: dependency: transitive description: name: url_launcher_linux - sha256: ab360eb661f8879369acac07b6bb3ff09d9471155357da8443fd5d3cf7363811 + sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" url: "https://pub.dev" source: hosted - version: "3.1.1" + version: "3.2.1" url_launcher_macos: dependency: transitive description: name: url_launcher_macos - sha256: b7244901ea3cf489c5335bdacda07264a6e960b1c1b1a9f91e4bc371d9e68234 + sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2" url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "3.2.2" url_launcher_platform_interface: dependency: transitive description: name: url_launcher_platform_interface - sha256: a932c3a8082e118f80a475ce692fde89dc20fddb24c57360b96bc56f7035de1f + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.3.2" url_launcher_web: dependency: transitive description: name: url_launcher_web - sha256: fff0932192afeedf63cdd50ecbb1bc825d31aed259f02bb8dba0f3b729a5e88b + sha256: "772638d3b34c779ede05ba3d38af34657a05ac55b06279ea6edd409e323dca8e" url: "https://pub.dev" source: hosted - version: "2.2.3" + version: "2.3.3" url_launcher_windows: dependency: transitive description: name: url_launcher_windows - sha256: ecf9725510600aa2bb6d7ddabe16357691b6d2805f66216a97d1b881e21beff7 + sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" url: "https://pub.dev" source: hosted - version: "3.1.1" + version: "3.1.4" uuid: dependency: "direct main" description: @@ -1438,26 +1452,26 @@ packages: dependency: transitive description: name: vector_graphics - sha256: "4ac59808bbfca6da38c99f415ff2d3a5d7ca0a6b4809c71d9cf30fba5daf9752" + sha256: "44cc7104ff32563122a929e4620cf3efd584194eec6d1d913eb5ba593dbcf6de" url: "https://pub.dev" source: hosted - version: "1.1.10+1" + version: "1.1.18" vector_graphics_codec: dependency: transitive description: name: vector_graphics_codec - sha256: f3247e7ab0ec77dc759263e68394990edc608fb2b480b80db8aa86ed09279e33 + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" url: "https://pub.dev" source: hosted - version: "1.1.10+1" + version: "1.1.13" vector_graphics_compiler: dependency: transitive description: name: vector_graphics_compiler - sha256: "18489bdd8850de3dd7ca8a34e0c446f719ec63e2bab2e7a8cc66a9028dd76c5a" + sha256: "1b4b9e706a10294258727674a340ae0d6e64a7231980f9f9a3d12e4b42407aad" url: "https://pub.dev" source: hosted - version: "1.1.10+1" + version: "1.1.16" vector_math: dependency: transitive description: @@ -1470,42 +1484,42 @@ packages: dependency: transitive description: name: video_player - sha256: fbf28ce8bcfe709ad91b5789166c832cb7a684d14f571a81891858fefb5bb1c2 + sha256: "7d78f0cfaddc8c19d4cb2d3bebe1bfef11f2103b0a03e5398b303a1bf65eeb14" url: "https://pub.dev" source: hosted - version: "2.8.2" + version: "2.9.5" video_player_android: dependency: transitive description: name: video_player_android - sha256: "7f8f25d7ad56819a82b2948357f3c3af071f6a678db33833b26ec36bbc221316" + sha256: "391e092ba4abe2f93b3e625bd6b6a6ec7d7414279462c1c0ee42b5ab8d0a0898" url: "https://pub.dev" source: hosted - version: "2.4.11" + version: "2.7.16" video_player_avfoundation: dependency: transitive description: name: video_player_avfoundation - sha256: "309e3962795e761be010869bae65c0b0e45b5230c5cee1bec72197ca7db040ed" + sha256: "9ee764e5cd2fc1e10911ae8ad588e1a19db3b6aa9a6eb53c127c42d3a3c3f22f" url: "https://pub.dev" source: hosted - version: "2.5.6" + version: "2.7.1" video_player_platform_interface: dependency: transitive description: name: video_player_platform_interface - sha256: "236454725fafcacf98f0f39af0d7c7ab2ce84762e3b63f2cbb3ef9a7e0550bc6" + sha256: df534476c341ab2c6a835078066fc681b8265048addd853a1e3c78740316a844 url: "https://pub.dev" source: hosted - version: "6.2.2" + version: "6.3.0" video_player_web: dependency: transitive description: name: video_player_web - sha256: "34beb3a07d4331a24f7e7b2f75b8e2b103289038e07e65529699a671b6a6e2cb" + sha256: e8bba2e5d1e159d5048c9a491bb2a7b29c535c612bb7d10c1e21107f5bd365ba url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.3.5" visibility_detector: dependency: "direct main" description: @@ -1518,64 +1532,64 @@ packages: dependency: "direct main" description: name: wakelock_plus - sha256: f268ca2116db22e57577fb99d52515a24bdc1d570f12ac18bb762361d43b043d + sha256: "104d94837bb28c735894dcd592877e990149c380e6358b00c04398ca1426eed4" url: "https://pub.dev" source: hosted - version: "1.1.4" + version: "1.2.1" wakelock_plus_platform_interface: dependency: transitive description: name: wakelock_plus_platform_interface - sha256: "40fabed5da06caff0796dc638e1f07ee395fb18801fbff3255a2372db2d80385" + sha256: e10444072e50dbc4999d7316fd303f7ea53d31c824aa5eb05d7ccbdd98985207 url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.2.3" watcher: dependency: transitive description: name: watcher - sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" + sha256: "0b7fd4a0bbc4b92641dbf20adfd7e3fd1398fe17102d94b674234563e110088a" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.2" web: dependency: transitive description: name: web - sha256: "4188706108906f002b3a293509234588823c8c979dc83304e229ff400c996b05" + sha256: "97da13628db363c635202ad97068d47c5b8aa555808e7a9411963c533b449b27" url: "https://pub.dev" source: hosted - version: "0.4.2" + version: "0.5.1" web_socket_channel: dependency: transitive description: name: web_socket_channel - sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b + sha256: "58c6666b342a38816b2e7e50ed0f1e261959630becd4c879c4f26bfa14aa5a42" url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.5" win32: dependency: "direct main" description: name: win32 - sha256: "464f5674532865248444b4c3daca12bd9bf2d7c47f759ce2617986e7229494a8" + sha256: daf97c9d80197ed7b619040e86c8ab9a9dad285e7671ee7390f9180cc828a51e url: "https://pub.dev" source: hosted - version: "5.2.0" + version: "5.10.1" win32_registry: dependency: transitive description: name: win32_registry - sha256: "41fd8a189940d8696b1b810efb9abcf60827b6cbfab90b0c43e8439e3a39d85a" + sha256: "21ec76dfc731550fd3e2ce7a33a9ea90b828fdf19a5c3bcf556fa992cfa99852" url: "https://pub.dev" source: hosted - version: "1.1.2" + version: "1.1.5" window_manager: dependency: "direct main" description: path: "." ref: HEAD - resolved-ref: f19acdb008645366339444a359a45c3257c8b32e + resolved-ref: "85789bfe6e4cfaf4ecc00c52857467fdb7f26879" url: "https://github.com/rustdesk-org/window_manager" source: git version: "0.3.6" @@ -1583,8 +1597,8 @@ packages: dependency: "direct main" description: path: "plugins/window_size" - ref: a738913c8ce2c9f47515382d40827e794a334274 - resolved-ref: a738913c8ce2c9f47515382d40827e794a334274 + ref: eb3964990cf19629c89ff8cb4a37640c7b3d5601 + resolved-ref: eb3964990cf19629c89ff8cb4a37640c7b3d5601 url: "https://github.com/google/flutter-desktop-embedding.git" source: git version: "0.1.0" @@ -1592,10 +1606,10 @@ packages: dependency: transitive description: name: xdg_directories - sha256: faea9dee56b520b55a566385b84f2e8de55e7496104adada9962e0bd11bcff1d + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "1.1.0" xml: dependency: transitive description: @@ -1604,30 +1618,46 @@ packages: url: "https://pub.dev" source: hosted version: "6.5.0" + xterm: + dependency: "direct main" + description: + name: xterm + sha256: "168dfedca77cba33fdb6f52e2cd001e9fde216e398e89335c19b524bb22da3a2" + url: "https://pub.dev" + source: hosted + version: "4.0.0" yaml: dependency: transitive description: name: yaml - sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce url: "https://pub.dev" source: hosted - version: "3.1.2" + version: "3.1.3" yaml_edit: dependency: transitive description: name: yaml_edit - sha256: "1579d4a0340a83cf9e4d580ea51a16329c916973bffd5bd4b45e911b25d46bfd" + sha256: fb38626579fb345ad00e674e2af3a5c9b0cc4b9bfb8fd7f7ff322c7c9e62aef5 url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.2.2" + zmodem: + dependency: transitive + description: + name: zmodem + sha256: "3b7e5b29f3a7d8aee472029b05165a68438eff2f3f7766edf13daba1e297adbf" + url: "https://pub.dev" + source: hosted + version: "0.0.6" zxing2: dependency: "direct main" description: name: zxing2 - sha256: a042961441bd400f59595f9125ef5fca4c888daf0ea59c17f41e0e151f8a12b5 + sha256: "2677c49a3b9ca9457cb1d294fd4bd5041cac6aab8cdb07b216ba4e98945c684f" url: "https://pub.dev" source: hosted - version: "0.2.1" + version: "0.2.4" sdks: - dart: ">=3.3.0 <4.0.0" - flutter: ">=3.19.0" + dart: ">=3.5.0 <4.0.0" + flutter: ">=3.24.0" diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index cc3a2e6c5..eddf5a19d 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.3.2+51 +version: 1.4.6+64 environment: sdk: '^3.1.0' @@ -35,7 +35,8 @@ dependencies: wakelock_plus: ^1.1.3 #firebase_analytics: ^9.1.5 package_info_plus: ^4.2.0 - url_launcher: ^6.2.1 + url_launcher: ^6.3.1 + url_launcher_ios: ^6.3.2 toggle_switch: ^2.1.0 dash_chat_2: git: @@ -46,7 +47,7 @@ dependencies: http: ^1.1.0 qr_code_scanner: ^1.0.0 zxing2: ^0.2.0 - image_picker: ^0.8.5 + image_picker: ^1.1.2 image: ^4.0.17 back_button_interceptor: ^6.0.1 flutter_rust_bridge: "1.80.1" @@ -62,7 +63,7 @@ dependencies: git: url: https://github.com/google/flutter-desktop-embedding.git path: plugins/window_size - ref: a738913c8ce2c9f47515382d40827e794a334274 + ref: eb3964990cf19629c89ff8cb4a37640c7b3d5601 get: ^4.6.5 visibility_detector: ^0.4.0+2 contextmenu: ^3.0.0 @@ -71,14 +72,11 @@ dependencies: debounce_throttle: ^2.0.0 file_picker: ^5.1.0 flutter_svg: ^2.0.5 - flutter_improved_scrolling: - # currently, we use flutter 3.10.0+. - # - # for flutter 3.0.5, please use official version(just comment code below). - # if build rustdesk by flutter >=3.3, please use our custom pub below (uncomment code below). + uni_links: git: - url: https://github.com/rustdesk-org/flutter_improved_scrolling - uni_links: ^0.5.1 + url: https://github.com/rustdesk-org/uni_links + path: uni_links + ref: f416118d843a7e9ed117c7bb7bdc2deda5a9e86f uni_links_desktop: ^0.1.6 # use 0.1.6 to make flutter 3.13 works path: ^1.8.1 auto_size_text: ^3.0.0 @@ -87,13 +85,16 @@ dependencies: password_strength: ^0.2.0 flutter_launcher_icons: ^0.13.1 flutter_keyboard_visibility: ^5.4.0 - texture_rgba_renderer: ^0.0.16 + texture_rgba_renderer: + git: + url: https://github.com/rustdesk-org/flutter_texture_rgba_renderer + ref: 42797e0f03141dc2b585f76c64a13974508058b4 percent_indicator: ^4.2.2 dropdown_button2: ^2.0.0 flutter_gpu_texture_renderer: git: url: https://github.com/rustdesk-org/flutter_gpu_texture_renderer - ref: 2ded7f146437a761ffe6981e2f742038f85ca68d + ref: 08a471bb8ceccdd50483c81cdfa8b81b07b14b87 uuid: ^3.0.7 auto_size_text_field: ^2.2.1 flex_color_picker: ^3.3.0 @@ -104,12 +105,16 @@ dependencies: pull_down_button: ^0.9.3 device_info_plus: ^9.1.0 qr_flutter: ^4.1.0 - extended_text: 13.0.0 + extended_text: 14.0.0 + xterm: 4.0.0 + sqflite: 2.2.0 + google_fonts: ^6.2.1 + vector_math: ^2.1.4 dev_dependencies: icons_launcher: ^2.0.4 - #flutter_test: - #sdk: flutter + flutter_test: + sdk: flutter build_runner: ^2.4.6 freezed: ^2.4.2 flutter_lints: ^2.0.2 @@ -117,7 +122,8 @@ dev_dependencies: dependency_overrides: intl: ^0.19.0 - + flutter_plugin_android_lifecycle: 2.0.17 + # rerun: flutter pub run flutter_launcher_icons flutter_icons: image_path: "../res/icon.png" @@ -160,6 +166,12 @@ flutter: - family: AddressBook fonts: - asset: assets/address_book.ttf + - family: DeviceGroup + fonts: + - asset: assets/device_group.ttf + - family: More + fonts: + - asset: assets/more.ttf # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/assets-and-images/#resolution-aware. diff --git a/flutter/test/cm_test.dart b/flutter/test/cm_test.dart index 21b87b848..342764b4a 100644 --- a/flutter/test/cm_test.dart +++ b/flutter/test/cm_test.dart @@ -10,10 +10,10 @@ import 'package:get/get.dart'; import 'package:window_manager/window_manager.dart'; final testClients = [ - Client(0, false, false, "UserAAAAAA", "123123123", true, false, false), - Client(1, false, false, "UserBBBBB", "221123123", true, false, false), - Client(2, false, false, "UserC", "331123123", true, false, false), - Client(3, false, false, "UserDDDDDDDDDDDd", "441123123", true, false, false) + Client(0, false, false, false, "UserAAAAAA", "123123123", true, false, false, false), + Client(1, false, false, false, "UserBBBBB", "221123123", true, false, false, false), + Client(2, false, false, false, "UserC", "331123123", true, false, false, false), + Client(3, false, false, false, "UserDDDDDDDDDDDd", "441123123", true, false, false, false) ]; /// flutter run -d {platform} -t test/cm_test.dart to test cm diff --git a/flutter/test/input_modifier_utils_test.dart b/flutter/test/input_modifier_utils_test.dart new file mode 100644 index 000000000..2e1971753 --- /dev/null +++ b/flutter/test/input_modifier_utils_test.dart @@ -0,0 +1,125 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_hbb/models/input_modifier_utils.dart'; + +void main() { + group('shouldReleaseStaleMobileShift', () { + test('does not release when cached shift is already false', () { + expect( + shouldReleaseStaleMobileShift( + isMobile: true, + cachedShiftPressed: false, + actualShiftPressed: false, + logicalKey: LogicalKeyboardKey.keyD, + hasTrackedShiftKeyDown: true, + ), + isFalse, + ); + }); + + test('releases one-shot mobile shift after a text key', () { + expect( + shouldReleaseStaleMobileShift( + isMobile: true, + cachedShiftPressed: true, + actualShiftPressed: false, + logicalKey: LogicalKeyboardKey.keyD, + hasTrackedShiftKeyDown: true, + ), + isTrue, + ); + }); + + test('does not release manually toggled shift without tracked key down', + () { + expect( + shouldReleaseStaleMobileShift( + isMobile: true, + cachedShiftPressed: true, + actualShiftPressed: false, + logicalKey: LogicalKeyboardKey.keyD, + hasTrackedShiftKeyDown: false, + ), + isFalse, + ); + }); + + test('does not release when shift is still physically pressed', () { + expect( + shouldReleaseStaleMobileShift( + isMobile: true, + cachedShiftPressed: true, + actualShiftPressed: true, + logicalKey: LogicalKeyboardKey.keyD, + hasTrackedShiftKeyDown: true, + ), + isFalse, + ); + }); + + test('does not release on non-mobile platforms', () { + expect( + shouldReleaseStaleMobileShift( + isMobile: false, + cachedShiftPressed: true, + actualShiftPressed: false, + logicalKey: LogicalKeyboardKey.keyD, + hasTrackedShiftKeyDown: true, + ), + isFalse, + ); + }); + + test('releases on enter key', () { + expect( + shouldReleaseStaleMobileShift( + isMobile: true, + cachedShiftPressed: true, + actualShiftPressed: false, + logicalKey: LogicalKeyboardKey.enter, + hasTrackedShiftKeyDown: true, + ), + isTrue, + ); + }); + + test('releases on arrow key', () { + expect( + shouldReleaseStaleMobileShift( + isMobile: true, + cachedShiftPressed: true, + actualShiftPressed: false, + logicalKey: LogicalKeyboardKey.arrowLeft, + hasTrackedShiftKeyDown: true, + ), + isTrue, + ); + }); + + test('does not release on modifier events', () { + expect( + shouldReleaseStaleMobileShift( + isMobile: true, + cachedShiftPressed: true, + actualShiftPressed: false, + logicalKey: LogicalKeyboardKey.shiftLeft, + hasTrackedShiftKeyDown: true, + ), + isFalse, + ); + }); + + test('does not release on shiftRight modifier events', () { + expect( + shouldReleaseStaleMobileShift( + isMobile: true, + cachedShiftPressed: true, + actualShiftPressed: false, + logicalKey: LogicalKeyboardKey.shiftRight, + hasTrackedShiftKeyDown: true, + ), + isFalse, + ); + }); + }); +} diff --git a/flutter/web/v1/.gitignore b/flutter/web/v1/.gitignore deleted file mode 100644 index 6290a3f63..000000000 --- a/flutter/web/v1/.gitignore +++ /dev/null @@ -1,9 +0,0 @@ -assets -js/src/gen_js_from_hbb.ts -js/src/message.ts -js/src/rendezvous.ts -ogvjs* -libopus.js -libopus.wasm -yuv-canvas* -node_modules diff --git a/flutter/web/v1/README.md b/flutter/web/v1/README.md deleted file mode 100644 index b9e2fc5c0..000000000 --- a/flutter/web/v1/README.md +++ /dev/null @@ -1 +0,0 @@ -v1 is not compatible with current Flutter source code. \ No newline at end of file diff --git a/flutter/web/v1/index.html b/flutter/web/v1/index.html deleted file mode 100644 index b466df662..000000000 --- a/flutter/web/v1/index.html +++ /dev/null @@ -1,183 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - RustDesk - - - - - - - - - - -
-
-
- - - - - - - - - diff --git a/flutter/web/v1/js/.gitattributes b/flutter/web/v1/js/.gitattributes deleted file mode 100644 index 176a458f9..000000000 --- a/flutter/web/v1/js/.gitattributes +++ /dev/null @@ -1 +0,0 @@ -* text=auto diff --git a/flutter/web/v1/js/.gitignore b/flutter/web/v1/js/.gitignore deleted file mode 100644 index 8737dbba5..000000000 --- a/flutter/web/v1/js/.gitignore +++ /dev/null @@ -1,9 +0,0 @@ -node_modules -.DS_Store -dist -dist-ssr -*.local -*log -ogvjs -.vscode -.yarn diff --git a/flutter/web/v1/js/.yarnrc.yml b/flutter/web/v1/js/.yarnrc.yml deleted file mode 100644 index 3186f3f07..000000000 --- a/flutter/web/v1/js/.yarnrc.yml +++ /dev/null @@ -1 +0,0 @@ -nodeLinker: node-modules diff --git a/flutter/web/v1/js/gen_js_from_hbb.py b/flutter/web/v1/js/gen_js_from_hbb.py deleted file mode 100755 index cfa95ffe0..000000000 --- a/flutter/web/v1/js/gen_js_from_hbb.py +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/env python3 - -import re -import os -import glob -from tabnanny import check - -def pad_start(s, n, c = ' '): - if len(s) >= n: - return s - return c * (n - len(s)) + s - -def safe_unicode(s): - res = "" - for c in s: - res += r"\u{}".format(pad_start(hex(ord(c))[2:], 4, '0')) - return res - -def main(): - print('export const LANGS = {') - for fn in glob.glob('../../../src/lang/*'): - lang = os.path.basename(fn)[:-3] - if lang == 'template': continue - print(' %s: {'%lang) - for ln in open(fn, encoding='utf-8'): - ln = ln.strip() - if ln.startswith('("'): - toks = ln.split('", "') - assert(len(toks) == 2) - a = toks[0][2:] - b = toks[1][:-3] - print(' "%s": "%s",'%(safe_unicode(a), safe_unicode(b))) - print(' },') - print('}') - check_if_retry = ['', False] - KEY_MAP = ['', False] - for ln in open('../../../src/client.rs', encoding='utf-8'): - ln = ln.strip() - if 'check_if_retry' in ln: - check_if_retry[1] = True - continue - if ln.startswith('}') and check_if_retry[1]: - check_if_retry[1] = False - continue - if check_if_retry[1]: - ln = removeComment(ln) - check_if_retry[0] += ln + '\n' - if 'KEY_MAP' in ln: - KEY_MAP[1] = True - continue - if '.collect' in ln and KEY_MAP[1]: - KEY_MAP[1] = False - continue - if KEY_MAP[1] and ln.startswith('('): - ln = removeComment(ln) - toks = ln.split('", Key::') - assert(len(toks) == 2) - a = toks[0][2:] - b = toks[1].replace('ControlKey(ControlKey::', '').replace("Chr('", '').replace("' as _)),", '').replace(')),', '') - KEY_MAP[0] += ' "%s": "%s",\n'%(a, b) - print() - print('export function checkIfRetry(msgtype: string, title: string, text: string, retry_for_relay: boolean) {') - print(' return %s'%check_if_retry[0].replace('to_lowercase', 'toLowerCase').replace('contains', 'indexOf').replace('!', '').replace('")', '") < 0')) - print(';}') - print() - print('export const KEY_MAP: any = {') - print(KEY_MAP[0]) - print('}') - for ln in open('../../../Cargo.toml', encoding='utf-8'): - if ln.startswith('version ='): - print('export const ' + ln) - - -def removeComment(ln): - return re.sub('\s+\/\/.*$', '', ln) - -main() diff --git a/flutter/web/v1/js/index.html b/flutter/web/v1/js/index.html deleted file mode 100644 index 0ae0a2410..000000000 --- a/flutter/web/v1/js/index.html +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - Vite App - - -
- - - diff --git a/flutter/web/v1/js/package.json b/flutter/web/v1/js/package.json deleted file mode 100644 index 15e0e75b8..000000000 --- a/flutter/web/v1/js/package.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "web_hbb", - "version": "1.0.0", - "scripts": { - "dev": "vite", - "build": "./gen_js_from_hbb.py > src/gen_js_from_hbb.ts && ./ts_proto.py && tsc && vite build", - "preview": "vite preview" - }, - "devDependencies": { - "typescript": "^4.4.4", - "vite": "^2.7.2" - }, - "dependencies": { - "fast-sha256": "^1.3.0", - "libsodium": "^0.7.9", - "libsodium-wrappers": "^0.7.9", - "pcm-player": "^0.0.11", - "ts-proto": "^1.101.0", - "wasm-feature-detect": "^1.2.11", - "zstddec": "^0.0.2" - } -} diff --git a/flutter/web/v1/js/src/codec.js b/flutter/web/v1/js/src/codec.js deleted file mode 100644 index 27c9565ec..000000000 --- a/flutter/web/v1/js/src/codec.js +++ /dev/null @@ -1,43 +0,0 @@ -// example: https://github.com/rgov/js-theora-decoder/blob/main/index.html -// https://github.com/brion/ogv.js/releases, yarn add has no simd -// dev: copy decoder files from node/ogv/dist/* to project dir -// dist: .... to dist -/* - OGVDemuxerOggW: 'ogv-demuxer-ogg-wasm.js', - OGVDemuxerWebMW: 'ogv-demuxer-webm-wasm.js', - OGVDecoderAudioOpusW: 'ogv-decoder-audio-opus-wasm.js', - OGVDecoderAudioVorbisW: 'ogv-decoder-audio-vorbis-wasm.js', - OGVDecoderVideoTheoraW: 'ogv-decoder-video-theora-wasm.js', - OGVDecoderVideoVP8W: 'ogv-decoder-video-vp8-wasm.js', - OGVDecoderVideoVP8MTW: 'ogv-decoder-video-vp8-mt-wasm.js', - OGVDecoderVideoVP9W: 'ogv-decoder-video-vp9-wasm.js', - OGVDecoderVideoVP9SIMDW: 'ogv-decoder-video-vp9-simd-wasm.js', - OGVDecoderVideoVP9MTW: 'ogv-decoder-video-vp9-mt-wasm.js', - OGVDecoderVideoVP9SIMDMTW: 'ogv-decoder-video-vp9-simd-mt-wasm.js', - OGVDecoderVideoAV1W: 'ogv-decoder-video-av1-wasm.js', - OGVDecoderVideoAV1SIMDW: 'ogv-decoder-video-av1-simd-wasm.js', - OGVDecoderVideoAV1MTW: 'ogv-decoder-video-av1-mt-wasm.js', - OGVDecoderVideoAV1SIMDMTW: 'ogv-decoder-video-av1-simd-mt-wasm.js', -*/ -import { simd } from "wasm-feature-detect"; - -export async function loadVp9(callback) { - // Multithreading is used only if `options.threading` is true. - // This requires browser support for the new `SharedArrayBuffer` and `Atomics` APIs, - // currently available in Firefox and Chrome with experimental flags enabled. - // 所有主流浏览器均默认于2018年1月5日禁用SharedArrayBuffer - const isSIMD = await simd(); - console.log('isSIMD: ' + isSIMD); - window.OGVLoader.loadClass( - isSIMD ? "OGVDecoderVideoVP9SIMDW" : "OGVDecoderVideoVP9W", - (videoCodecClass) => { - window.videoCodecClass = videoCodecClass; - videoCodecClass({ videoFormat: {} }).then((decoder) => { - decoder.init(() => { - callback(decoder); - }) - }) - }, - { worker: true, threading: true } - ); -} \ No newline at end of file diff --git a/flutter/web/v1/js/src/common.ts b/flutter/web/v1/js/src/common.ts deleted file mode 100644 index 8da049a4d..000000000 --- a/flutter/web/v1/js/src/common.ts +++ /dev/null @@ -1,77 +0,0 @@ -import * as zstd from "zstddec"; -import { KeyEvent, controlKeyFromJSON, ControlKey } from "./message"; -import { KEY_MAP, LANGS } from "./gen_js_from_hbb"; - -let decompressor: zstd.ZSTDDecoder; - -export async function initZstd() { - const tmp = new zstd.ZSTDDecoder(); - await tmp.init(); - console.log("zstd ready"); - decompressor = tmp; -} - -export async function decompress(compressedArray: Uint8Array) { - const MAX = 1024 * 1024 * 64; - const MIN = 1024 * 1024; - let n = 30 * compressedArray.length; - if (n > MAX) { - n = MAX; - } - if (n < MIN) { - n = MIN; - } - try { - if (!decompressor) { - await initZstd(); - } - return decompressor.decode(compressedArray, n); - } catch (e) { - console.error("decompress failed: " + e); - return undefined; - } -} - -const LANG = getLang(); - -export function translate(locale: string, text: string): string { - const lang = LANG || locale.substring(locale.length - 2).toLowerCase(); - let en = LANGS.en as any; - let dict = (LANGS as any)[lang]; - if (!dict) dict = en; - let res = dict[text]; - if (!res && lang != "en") res = en[text]; - return res || text; -} - -const zCode = "z".charCodeAt(0); -const aCode = "a".charCodeAt(0); - -export function mapKey(name: string, isDesktop: Boolean) { - const tmp = KEY_MAP[name] || name; - if (tmp.length == 1) { - const chr = tmp.charCodeAt(0); - if (!isDesktop && (chr > zCode || chr < aCode)) - return KeyEvent.fromPartial({ unicode: chr }); - else return KeyEvent.fromPartial({ chr }); - } - const control_key = controlKeyFromJSON(tmp); - if (control_key == ControlKey.UNRECOGNIZED) { - console.error("Unknown control key " + tmp); - } - return KeyEvent.fromPartial({ control_key }); -} - -export async function sleep(ms: number) { - await new Promise((r) => setTimeout(r, ms)); -} - -function getLang(): string { - try { - const queryString = window.location.search; - const urlParams = new URLSearchParams(queryString); - return urlParams.get("lang") || ""; - } catch (e) { - return ""; - } -} diff --git a/flutter/web/v1/js/src/connection.ts b/flutter/web/v1/js/src/connection.ts deleted file mode 100644 index b0c479c90..000000000 --- a/flutter/web/v1/js/src/connection.ts +++ /dev/null @@ -1,773 +0,0 @@ -import Websock from "./websock"; -import * as message from "./message.js"; -import * as rendezvous from "./rendezvous.js"; -import { loadVp9 } from "./codec"; -import * as sha256 from "fast-sha256"; -import * as globals from "./globals"; -import { decompress, mapKey, sleep } from "./common"; - -const PORT = 21116; -const HOSTS = [ - "rs-sg.rustdesk.com", - "rs-cn.rustdesk.com", - "rs-us.rustdesk.com", -]; -let HOST = localStorage.getItem("rendezvous-server") || HOSTS[0]; -const SCHEMA = "ws://"; - -type MsgboxCallback = (type: string, title: string, text: string) => void; -type DrawCallback = (data: Uint8Array) => void; -//const cursorCanvas = document.createElement("canvas"); - -export default class Connection { - _msgs: any[]; - _ws: Websock | undefined; - _interval: any; - _id: string; - _hash: message.Hash | undefined; - _msgbox: MsgboxCallback; - _draw: DrawCallback; - _peerInfo: message.PeerInfo | undefined; - _firstFrame: Boolean | undefined; - _videoDecoder: any; - _password: Uint8Array | undefined; - _options: any; - _videoTestSpeed: number[]; - //_cursors: { [name: number]: any }; - - constructor() { - this._msgbox = globals.msgbox; - this._draw = globals.draw; - this._msgs = []; - this._id = ""; - this._videoTestSpeed = [0, 0]; - //this._cursors = {}; - } - - async start(id: string) { - try { - await this._start(id); - } catch (e: any) { - this.msgbox( - "error", - "Connection Error", - e.type == "close" ? "Reset by the peer" : String(e) - ); - } - } - - async _start(id: string) { - if (!this._options) { - this._options = globals.getPeers()[id] || {}; - } - if (!this._password) { - const p = this.getOption("password"); - if (p) { - try { - this._password = Uint8Array.from(JSON.parse("[" + p + "]")); - } catch (e) { - console.error(e); - } - } - } - this._interval = setInterval(() => { - while (this._msgs.length) { - this._ws?.sendMessage(this._msgs[0]); - this._msgs.splice(0, 1); - } - }, 1); - this.loadVideoDecoder(); - const uri = getDefaultUri(); - const ws = new Websock(uri, true); - this._ws = ws; - this._id = id; - console.log( - new Date() + ": Connecting to rendezvous server: " + uri + ", for " + id - ); - await ws.open(); - console.log(new Date() + ": Connected to rendezvous server"); - const conn_type = rendezvous.ConnType.DEFAULT_CONN; - const nat_type = rendezvous.NatType.SYMMETRIC; - const punch_hole_request = rendezvous.PunchHoleRequest.fromPartial({ - id, - licence_key: localStorage.getItem("key") || undefined, - conn_type, - nat_type, - token: localStorage.getItem("access_token") || undefined, - }); - ws.sendRendezvous({ punch_hole_request }); - const msg = (await ws.next()) as rendezvous.RendezvousMessage; - ws.close(); - console.log(new Date() + ": Got relay response"); - const phr = msg.punch_hole_response; - const rr = msg.relay_response; - if (phr) { - if (phr?.other_failure) { - this.msgbox("error", "Error", phr?.other_failure); - return; - } - if (phr.failure != rendezvous.PunchHoleResponse_Failure.UNRECOGNIZED) { - switch (phr?.failure) { - case rendezvous.PunchHoleResponse_Failure.ID_NOT_EXIST: - this.msgbox("error", "Error", "ID does not exist"); - break; - case rendezvous.PunchHoleResponse_Failure.OFFLINE: - this.msgbox("error", "Error", "Remote desktop is offline"); - break; - case rendezvous.PunchHoleResponse_Failure.LICENSE_MISMATCH: - this.msgbox("error", "Error", "Key mismatch"); - break; - case rendezvous.PunchHoleResponse_Failure.LICENSE_OVERUSE: - this.msgbox("error", "Error", "Key overuse"); - break; - } - } - } else if (rr) { - if (!rr.version) { - this.msgbox("error", "Error", "Remote version is low, not support web"); - return; - } - await this.connectRelay(rr); - } - } - - async connectRelay(rr: rendezvous.RelayResponse) { - const pk = rr.pk; - let uri = rr.relay_server; - if (uri) { - uri = getrUriFromRs(uri, true, 2); - } else { - uri = getDefaultUri(true); - } - const uuid = rr.uuid; - console.log(new Date() + ": Connecting to relay server: " + uri); - const ws = new Websock(uri, false); - await ws.open(); - console.log(new Date() + ": Connected to relay server"); - this._ws = ws; - const request_relay = rendezvous.RequestRelay.fromPartial({ - licence_key: localStorage.getItem("key") || undefined, - uuid, - }); - ws.sendRendezvous({ request_relay }); - const secure = (await this.secure(pk)) || false; - globals.pushEvent("connection_ready", { secure, direct: false }); - await this.msgLoop(); - } - - async secure(pk: Uint8Array | undefined) { - if (pk) { - const RS_PK = "OeVuKk5nlHiXp+APNn0Y3pC1Iwpwn44JGqrQCsWqmBw="; - try { - pk = await globals.verify(pk, localStorage.getItem("key") || RS_PK); - if (pk) { - const idpk = message.IdPk.decode(pk); - if (idpk.id == this._id) { - pk = idpk.pk; - } - } - if (pk?.length != 32) { - pk = undefined; - } - } catch (e) { - console.error(e); - pk = undefined; - } - if (!pk) - console.error( - "Handshake failed: invalid public key from rendezvous server" - ); - } - if (!pk) { - // send an empty message out in case server is setting up secure and waiting for first message - const public_key = message.PublicKey.fromPartial({}); - this._ws?.sendMessage({ public_key }); - return; - } - const msg = (await this._ws?.next()) as message.Message; - let signedId: any = msg?.signed_id; - if (!signedId) { - console.error("Handshake failed: invalid message type"); - const public_key = message.PublicKey.fromPartial({}); - this._ws?.sendMessage({ public_key }); - return; - } - try { - signedId = await globals.verify(signedId.id, Uint8Array.from(pk!)); - } catch (e) { - console.error(e); - // fall back to non-secure connection in case pk mismatch - console.error("pk mismatch, fall back to non-secure"); - const public_key = message.PublicKey.fromPartial({}); - this._ws?.sendMessage({ public_key }); - return; - } - const idpk = message.IdPk.decode(signedId); - const id = idpk.id; - const theirPk = idpk.pk; - if (id != this._id!) { - console.error("Handshake failed: sign failure"); - const public_key = message.PublicKey.fromPartial({}); - this._ws?.sendMessage({ public_key }); - return; - } - if (theirPk.length != 32) { - console.error( - "Handshake failed: invalid public box key length from peer" - ); - const public_key = message.PublicKey.fromPartial({}); - this._ws?.sendMessage({ public_key }); - return; - } - const [mySk, asymmetric_value] = globals.genBoxKeyPair(); - const secret_key = globals.genSecretKey(); - const symmetric_value = globals.seal(secret_key, theirPk, mySk); - const public_key = message.PublicKey.fromPartial({ - asymmetric_value, - symmetric_value, - }); - this._ws?.sendMessage({ public_key }); - this._ws?.setSecretKey(secret_key); - console.log("secured"); - return true; - } - - async msgLoop() { - while (true) { - const msg = (await this._ws?.next()) as message.Message; - if (msg?.hash) { - this._hash = msg?.hash; - if (!this._password) - this.msgbox("input-password", "Password Required", ""); - this.login(); - } else if (msg?.test_delay) { - const test_delay = msg?.test_delay; - console.log(test_delay); - if (!test_delay.from_client) { - this._ws?.sendMessage({ test_delay }); - } - } else if (msg?.login_response) { - const r = msg?.login_response; - if (r.error) { - if (r.error == "Wrong Password") { - this._password = undefined; - this.msgbox( - "re-input-password", - r.error, - "Do you want to enter again?" - ); - } else { - this.msgbox("error", "Login Error", r.error); - } - } else if (r.peer_info) { - this.handlePeerInfo(r.peer_info); - } - } else if (msg?.video_frame) { - this.handleVideoFrame(msg?.video_frame!); - } else if (msg?.clipboard) { - const cb = msg?.clipboard; - if (cb.compress) { - const c = await decompress(cb.content); - if (!c) continue; - cb.content = c; - } - try { - globals.copyToClipboard(new TextDecoder().decode(cb.content)); - } catch (e) { - console.error(e); - } - // globals.pushEvent("clipboard", cb); - } else if (msg?.cursor_data) { - const cd = msg?.cursor_data; - const c = await decompress(cd.colors); - if (!c) continue; - cd.colors = c; - globals.pushEvent("cursor_data", cd); - /* - let ctx = cursorCanvas.getContext("2d"); - cursorCanvas.width = cd.width; - cursorCanvas.height = cd.height; - let imgData = new ImageData( - new Uint8ClampedArray(c), - cd.width, - cd.height - ); - ctx?.clearRect(0, 0, cd.width, cd.height); - ctx?.putImageData(imgData, 0, 0); - let url = cursorCanvas.toDataURL(); - const img = document.createElement("img"); - img.src = url; - this._cursors[cd.id] = img; - //cursorCanvas.width /= 2.; - //cursorCanvas.height /= 2.; - //ctx?.drawImage(img, cursorCanvas.width, cursorCanvas.height); - url = cursorCanvas.toDataURL(); - document.body.style.cursor = - "url(" + url + ")" + cd.hotx + " " + cd.hoty + ", default"; - console.log(document.body.style.cursor); - */ - } else if (msg?.cursor_id) { - globals.pushEvent("cursor_id", { id: msg?.cursor_id }); - } else if (msg?.cursor_position) { - globals.pushEvent("cursor_position", msg?.cursor_position); - } else if (msg?.misc) { - if (!this.handleMisc(msg?.misc)) break; - } else if (msg?.audio_frame) { - globals.playAudio(msg?.audio_frame.data); - } - } - } - - msgbox(type_: string, title: string, text: string) { - this._msgbox?.(type_, title, text); - } - - draw(frame: any) { - this._draw?.(frame); - globals.draw(frame); - } - - close() { - this._msgs = []; - clearInterval(this._interval); - this._ws?.close(); - this._videoDecoder?.close(); - } - - refresh() { - const misc = message.Misc.fromPartial({ refresh_video: true }); - this._ws?.sendMessage({ misc }); - } - - setMsgbox(callback: MsgboxCallback) { - this._msgbox = callback; - } - - setDraw(callback: DrawCallback) { - this._draw = callback; - } - - login(password: string | undefined = undefined) { - if (password) { - const salt = this._hash?.salt; - let p = hash([password, salt!]); - this._password = p; - const challenge = this._hash?.challenge; - p = hash([p, challenge!]); - this.msgbox("connecting", "Connecting...", "Logging in..."); - this._sendLoginMessage(p); - } else { - let p = this._password; - if (p) { - const challenge = this._hash?.challenge; - p = hash([p, challenge!]); - } - this._sendLoginMessage(p); - } - } - - async reconnect() { - this.close(); - await this.start(this._id); - } - - _sendLoginMessage(password: Uint8Array | undefined = undefined) { - const login_request = message.LoginRequest.fromPartial({ - username: this._id!, - my_id: "web", // to-do - my_name: "web", // to-do - password, - option: this.getOptionMessage(), - video_ack_required: true, - }); - this._ws?.sendMessage({ login_request }); - } - - getOptionMessage(): message.OptionMessage | undefined { - let n = 0; - const msg = message.OptionMessage.fromPartial({}); - const q = this.getImageQualityEnum(this.getImageQuality(), true); - const yes = message.OptionMessage_BoolOption.Yes; - if (q != undefined) { - msg.image_quality = q; - n += 1; - } - if (this._options["show-remote-cursor"]) { - msg.show_remote_cursor = yes; - n += 1; - } - if (this._options["lock-after-session-end"]) { - msg.lock_after_session_end = yes; - n += 1; - } - if (this._options["privacy-mode"]) { - msg.privacy_mode = yes; - n += 1; - } - if (this._options["disable-audio"]) { - msg.disable_audio = yes; - n += 1; - } - if (this._options["disable-clipboard"]) { - msg.disable_clipboard = yes; - n += 1; - } - return n > 0 ? msg : undefined; - } - - sendVideoReceived() { - const misc = message.Misc.fromPartial({ video_received: true }); - this._ws?.sendMessage({ misc }); - } - - handleVideoFrame(vf: message.VideoFrame) { - if (!this._firstFrame) { - this.msgbox("", "", ""); - this._firstFrame = true; - } - if (vf.vp9s) { - const dec = this._videoDecoder; - var tm = new Date().getTime(); - var i = 0; - const n = vf.vp9s?.frames.length; - vf.vp9s.frames.forEach((f) => { - dec.processFrame(f.data.slice(0).buffer, (ok: any) => { - i++; - if (i == n) this.sendVideoReceived(); - if (ok && dec.frameBuffer && n == i) { - this.draw(dec.frameBuffer); - const now = new Date().getTime(); - var elapsed = now - tm; - this._videoTestSpeed[1] += elapsed; - this._videoTestSpeed[0] += 1; - if (this._videoTestSpeed[0] >= 30) { - console.log( - "video decoder: " + - parseInt( - "" + this._videoTestSpeed[1] / this._videoTestSpeed[0] - ) - ); - this._videoTestSpeed = [0, 0]; - } - } - }); - }); - } - } - - handlePeerInfo(pi: message.PeerInfo) { - this._peerInfo = pi; - if (pi.displays.length == 0) { - this.msgbox("error", "Remote Error", "No Display"); - return; - } - this.msgbox("success", "Successful", "Connected, waiting for image..."); - globals.pushEvent("peer_info", pi); - const p = this.shouldAutoLogin(); - if (p) this.inputOsPassword(p); - const username = this.getOption("info")?.username; - if (username && !pi.username) pi.username = username; - this.setOption("info", pi); - if (this.getRemember()) { - if (this._password?.length) { - const p = this._password.toString(); - if (p != this.getOption("password")) { - this.setOption("password", p); - console.log("remember password of " + this._id); - } - } - } else { - this.setOption("password", undefined); - } - } - - shouldAutoLogin(): string { - const l = this.getOption("lock-after-session-end"); - const a = !!this.getOption("auto-login"); - const p = this.getOption("os-password"); - if (p && l && a) { - return p; - } - return ""; - } - - handleMisc(misc: message.Misc) { - if (misc.audio_format) { - globals.initAudio( - misc.audio_format.channels, - misc.audio_format.sample_rate - ); - } else if (misc.chat_message) { - globals.pushEvent("chat", { text: misc.chat_message.text }); - } else if (misc.permission_info) { - const p = misc.permission_info; - console.info("Change permission " + p.permission + " -> " + p.enabled); - let name; - switch (p.permission) { - case message.PermissionInfo_Permission.Keyboard: - name = "keyboard"; - break; - case message.PermissionInfo_Permission.Clipboard: - name = "clipboard"; - break; - case message.PermissionInfo_Permission.Audio: - name = "audio"; - break; - default: - return; - } - globals.pushEvent("permission", { [name]: p.enabled }); - } else if (misc.switch_display) { - this.loadVideoDecoder(); - globals.pushEvent("switch_display", misc.switch_display); - } else if (misc.close_reason) { - this.msgbox("error", "Connection Error", misc.close_reason); - this.close(); - return false; - } - return true; - } - - getRemember(): Boolean { - return this._options["remember"] || false; - } - - setRemember(v: Boolean) { - this.setOption("remember", v); - } - - getOption(name: string): any { - return this._options[name]; - } - - setOption(name: string, value: any) { - if (value == undefined) { - delete this._options[name]; - } else { - this._options[name] = value; - } - this._options["tm"] = new Date().getTime(); - const peers = globals.getPeers(); - peers[this._id] = this._options; - localStorage.setItem("peers", JSON.stringify(peers)); - } - - inputKey( - name: string, - down: boolean, - press: boolean, - alt: Boolean, - ctrl: Boolean, - shift: Boolean, - command: Boolean - ) { - const key_event = mapKey(name, globals.isDesktop()); - if (!key_event) return; - if (alt && (name == "VK_MENU" || name == "RAlt")) { - alt = false; - } - if (ctrl && (name == "VK_CONTROL" || name == "RControl")) { - ctrl = false; - } - if (shift && (name == "VK_SHIFT" || name == "RShift")) { - shift = false; - } - if (command && (name == "Meta" || name == "RWin")) { - command = false; - } - key_event.down = down; - key_event.press = press; - key_event.modifiers = this.getMod(alt, ctrl, shift, command); - this._ws?.sendMessage({ key_event }); - } - - ctrlAltDel() { - const key_event = message.KeyEvent.fromPartial({ down: true }); - if (this._peerInfo?.platform == "Windows") { - key_event.control_key = message.ControlKey.CtrlAltDel; - } else { - key_event.control_key = message.ControlKey.Delete; - key_event.modifiers = this.getMod(true, true, false, false); - } - this._ws?.sendMessage({ key_event }); - } - - inputString(seq: string) { - const key_event = message.KeyEvent.fromPartial({ seq }); - this._ws?.sendMessage({ key_event }); - } - - switchDisplay(display: number) { - const switch_display = message.SwitchDisplay.fromPartial({ display }); - const misc = message.Misc.fromPartial({ switch_display }); - this._ws?.sendMessage({ misc }); - } - - async inputOsPassword(seq: string) { - this.inputMouse(); - await sleep(50); - this.inputMouse(0, 3, 3); - await sleep(50); - this.inputMouse(1 | (1 << 3)); - this.inputMouse(2 | (1 << 3)); - await sleep(1200); - const key_event = message.KeyEvent.fromPartial({ press: true, seq }); - this._ws?.sendMessage({ key_event }); - } - - lockScreen() { - const key_event = message.KeyEvent.fromPartial({ - down: true, - control_key: message.ControlKey.LockScreen, - }); - this._ws?.sendMessage({ key_event }); - } - - getMod(alt: Boolean, ctrl: Boolean, shift: Boolean, command: Boolean) { - const mod: message.ControlKey[] = []; - if (alt) mod.push(message.ControlKey.Alt); - if (ctrl) mod.push(message.ControlKey.Control); - if (shift) mod.push(message.ControlKey.Shift); - if (command) mod.push(message.ControlKey.Meta); - return mod; - } - - inputMouse( - mask: number = 0, - x: number = 0, - y: number = 0, - alt: Boolean = false, - ctrl: Boolean = false, - shift: Boolean = false, - command: Boolean = false - ) { - const mouse_event = message.MouseEvent.fromPartial({ - mask, - x, - y, - modifiers: this.getMod(alt, ctrl, shift, command), - }); - this._ws?.sendMessage({ mouse_event }); - } - - toggleOption(name: string) { - const v = !this._options[name]; - const option = message.OptionMessage.fromPartial({}); - const v2 = v - ? message.OptionMessage_BoolOption.Yes - : message.OptionMessage_BoolOption.No; - switch (name) { - case "show-remote-cursor": - option.show_remote_cursor = v2; - break; - case "disable-audio": - option.disable_audio = v2; - break; - case "disable-clipboard": - option.disable_clipboard = v2; - break; - case "lock-after-session-end": - option.lock_after_session_end = v2; - break; - case "privacy-mode": - option.privacy_mode = v2; - break; - case "block-input": - option.block_input = message.OptionMessage_BoolOption.Yes; - break; - case "unblock-input": - option.block_input = message.OptionMessage_BoolOption.No; - break; - default: - return; - } - if (name.indexOf("block-input") < 0) this.setOption(name, v); - const misc = message.Misc.fromPartial({ option }); - this._ws?.sendMessage({ misc }); - } - - getImageQuality() { - return this.getOption("image-quality"); - } - - getImageQualityEnum( - value: string, - ignoreDefault: Boolean - ): message.ImageQuality | undefined { - switch (value) { - case "low": - return message.ImageQuality.Low; - case "best": - return message.ImageQuality.Best; - case "balanced": - return ignoreDefault ? undefined : message.ImageQuality.Balanced; - default: - return undefined; - } - } - - setImageQuality(value: string) { - this.setOption("image-quality", value); - const image_quality = this.getImageQualityEnum(value, false); - if (image_quality == undefined) return; - const option = message.OptionMessage.fromPartial({ image_quality }); - const misc = message.Misc.fromPartial({ option }); - this._ws?.sendMessage({ misc }); - } - - loadVideoDecoder() { - this._videoDecoder?.close(); - loadVp9((decoder: any) => { - this._videoDecoder = decoder; - console.log("vp9 loaded"); - console.log(decoder); - }); - } -} - -function testDelay() { - var nearest = ""; - HOSTS.forEach((host) => { - const now = new Date().getTime(); - new Websock(getrUriFromRs(host), true).open().then(() => { - console.log("latency of " + host + ": " + (new Date().getTime() - now)); - if (!nearest) { - HOST = host; - localStorage.setItem("rendezvous-server", host); - } - }); - }); -} - -testDelay(); - -function getDefaultUri(isRelay: Boolean = false): string { - const host = localStorage.getItem("custom-rendezvous-server"); - return getrUriFromRs(host || HOST, isRelay); -} - -function getrUriFromRs( - uri: string, - isRelay: Boolean = false, - roffset: number = 0 -): string { - if (uri.indexOf(":") > 0) { - const tmp = uri.split(":"); - const port = parseInt(tmp[1]); - uri = tmp[0] + ":" + (port + (isRelay ? roffset || 3 : 2)); - } else { - uri += ":" + (PORT + (isRelay ? 3 : 2)); - } - return SCHEMA + uri; -} - -function hash(datas: (string | Uint8Array)[]): Uint8Array { - const hasher = new sha256.Hash(); - datas.forEach((data) => { - if (typeof data == "string") { - data = new TextEncoder().encode(data); - } - return hasher.update(data); - }); - return hasher.digest(); -} diff --git a/flutter/web/v1/js/src/globals.js b/flutter/web/v1/js/src/globals.js deleted file mode 100644 index 953add18d..000000000 --- a/flutter/web/v1/js/src/globals.js +++ /dev/null @@ -1,383 +0,0 @@ -import Connection from "./connection"; -import _sodium from "libsodium-wrappers"; -import { CursorData } from "./message"; -import { loadVp9 } from "./codec"; -import { checkIfRetry, version } from "./gen_js_from_hbb"; -import { initZstd, translate } from "./common"; -import PCMPlayer from "pcm-player"; - -window.curConn = undefined; -window.isMobile = () => { - return /(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|ipad|iris|kindle|Android|Silk|lge |maemo|midp|mmp|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows (ce|phone)|xda|xiino/i.test(navigator.userAgent) - || /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(navigator.userAgent.substr(0, 4)); -} - -export function isDesktop() { - return !isMobile(); -} - -export function msgbox(type, title, text) { - if (!type || (type == 'error' && !text)) return; - const text2 = text.toLowerCase(); - var hasRetry = checkIfRetry(type, title, text) ? 'true' : ''; - onGlobalEvent(JSON.stringify({ name: 'msgbox', type, title, text, hasRetry })); -} - -function jsonfyForDart(payload) { - var tmp = {}; - for (const [key, value] of Object.entries(payload)) { - if (!key) continue; - tmp[key] = value instanceof Uint8Array ? '[' + value.toString() + ']' : JSON.stringify(value); - } - return tmp; -} - -export function pushEvent(name, payload) { - payload = jsonfyForDart(payload); - payload.name = name; - onGlobalEvent(JSON.stringify(payload)); -} - -let yuvWorker; -let yuvCanvas; -let gl; -let pixels; -let flipPixels; -let oldSize; -if (YUVCanvas.WebGLFrameSink.isAvailable()) { - var canvas = document.createElement('canvas'); - yuvCanvas = YUVCanvas.attach(canvas, { webGL: true }); - gl = canvas.getContext("webgl"); -} else { - yuvWorker = new Worker("./yuv.js"); -} -let testSpeed = [0, 0]; - -export function draw(frame) { - if (yuvWorker) { - // frame's (y/u/v).bytes already detached, can not transferrable any more. - yuvWorker.postMessage(frame); - } else { - var tm0 = new Date().getTime(); - yuvCanvas.drawFrame(frame); - var width = canvas.width; - var height = canvas.height; - var size = width * height * 4; - if (size != oldSize) { - pixels = new Uint8Array(size); - flipPixels = new Uint8Array(size); - oldSize = size; - } - gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, pixels); - const row = width * 4; - const end = (height - 1) * row; - for (let i = 0; i < size; i += row) { - flipPixels.set(pixels.subarray(i, i + row), end - i); - } - onRgba(flipPixels); - testSpeed[1] += new Date().getTime() - tm0; - testSpeed[0] += 1; - if (testSpeed[0] > 30) { - console.log('gl: ' + parseInt('' + testSpeed[1] / testSpeed[0])); - testSpeed = [0, 0]; - } - } - /* - var testCanvas = document.getElementById("test-yuv-decoder-canvas"); - if (testCanvas && currentFrame) { - var ctx = testCanvas.getContext("2d"); - testCanvas.width = frame.format.displayWidth; - testCanvas.height = frame.format.displayHeight; - var img = ctx.createImageData(testCanvas.width, testCanvas.height); - img.data.set(currentFrame); - ctx.putImageData(img, 0, 0); - } - */ -} - -export function sendOffCanvas(c) { - let canvas = c.transferControlToOffscreen(); - yuvWorker.postMessage({ canvas }, [canvas]); -} - -export function setConn(conn) { - window.curConn = conn; -} - -export function getConn() { - return window.curConn; -} - -export async function startConn(id) { - setByName('remote_id', id); - await curConn.start(id); -} - -export function close() { - getConn()?.close(); - setConn(undefined); -} - -export function newConn() { - window.curConn?.close(); - const conn = new Connection(); - setConn(conn); - return conn; -} - -let sodium; -export async function verify(signed, pk) { - if (!sodium) { - await _sodium.ready; - sodium = _sodium; - } - if (typeof pk == 'string') { - pk = decodeBase64(pk); - } - return sodium.crypto_sign_open(signed, pk); -} - -export function decodeBase64(pk) { - return sodium.from_base64(pk, sodium.base64_variants.ORIGINAL); -} - -export function genBoxKeyPair() { - const pair = sodium.crypto_box_keypair(); - const sk = pair.privateKey; - const pk = pair.publicKey; - return [sk, pk]; -} - -export function genSecretKey() { - return sodium.crypto_secretbox_keygen(); -} - -export function seal(unsigned, theirPk, ourSk) { - const nonce = Uint8Array.from(Array(24).fill(0)); - return sodium.crypto_box_easy(unsigned, nonce, theirPk, ourSk); -} - -function makeOnce(value) { - var byteArray = Array(24).fill(0); - - for (var index = 0; index < byteArray.length && value > 0; index++) { - var byte = value & 0xff; - byteArray[index] = byte; - value = (value - byte) / 256; - } - - return Uint8Array.from(byteArray); -}; - -export function encrypt(unsigned, nonce, key) { - return sodium.crypto_secretbox_easy(unsigned, makeOnce(nonce), key); -} - -export function decrypt(signed, nonce, key) { - return sodium.crypto_secretbox_open_easy(signed, makeOnce(nonce), key); -} - -window.setByName = (name, value) => { - switch (name) { - case 'remote_id': - localStorage.setItem('remote-id', value); - break; - case 'connect': - newConn(); - startConn(value); - break; - case 'login': - value = JSON.parse(value); - curConn.setRemember(value.remember == 'true'); - curConn.login(value.password); - break; - case 'close': - close(); - break; - case 'refresh': - curConn.refresh(); - break; - case 'reconnect': - curConn.reconnect(); - break; - case 'toggle_option': - curConn.toggleOption(value); - break; - case 'image_quality': - curConn.setImageQuality(value); - break; - case 'lock_screen': - curConn.lockScreen(); - break; - case 'ctrl_alt_del': - curConn.ctrlAltDel(); - break; - case 'switch_display': - curConn.switchDisplay(value); - break; - case 'remove': - const peers = getPeers(); - delete peers[value]; - localStorage.setItem('peers', JSON.stringify(peers)); - break; - case 'input_key': - value = JSON.parse(value); - curConn.inputKey(value.name, value.down == 'true', value.press == 'true', value.alt == 'true', value.ctrl == 'true', value.shift == 'true', value.command == 'true'); - break; - case 'input_string': - curConn.inputString(value); - break; - case 'send_mouse': - let mask = 0; - value = JSON.parse(value); - switch (value.type) { - case 'down': - mask = 1; - break; - case 'up': - mask = 2; - break; - case 'wheel': - mask = 3; - break; - } - switch (value.buttons) { - case 'left': - mask |= 1 << 3; - break; - case 'right': - mask |= 2 << 3; - break; - case 'wheel': - mask |= 4 << 3; - } - curConn.inputMouse(mask, parseInt(value.x || '0'), parseInt(value.y || '0'), value.alt == 'true', value.ctrl == 'true', value.shift == 'true', value.command == 'true'); - break; - case 'option': - value = JSON.parse(value); - localStorage.setItem(value.name, value.value); - break; - case 'peer_option': - value = JSON.parse(value); - curConn.setOption(value.name, value.value); - break; - case 'input_os_password': - curConn.inputOsPassword(value); - break; - default: - break; - } -} - -window.getByName = (name, arg) => { - let v = _getByName(name, arg); - if (typeof v == 'string' || v instanceof String) return v; - if (v == undefined || v == null) return ''; - return JSON.stringify(v); -} - -function getPeersForDart() { - const peers = []; - for (const [id, value] of Object.entries(getPeers())) { - if (!id) continue; - const tm = value['tm']; - const info = value['info']; - if (!tm || !info) continue; - peers.push([tm, id, info]); - } - return peers.sort().reverse().map(x => x.slice(1)); -} - -function _getByName(name, arg) { - switch (name) { - case 'peers': - return getPeersForDart(); - case 'remote_id': - return localStorage.getItem('remote-id'); - case 'remember': - return curConn.getRemember(); - case 'toggle_option': - return curConn.getOption(arg) || false; - case 'option': - return localStorage.getItem(arg); - case 'image_quality': - return curConn.getImageQuality(); - case 'translate': - arg = JSON.parse(arg); - return translate(arg.locale, arg.text); - case 'peer_option': - return curConn.getOption(arg); - case 'test_if_valid_server': - break; - case 'version': - return version; - } - return ''; -} - -let opusWorker = new Worker("./libopus.js"); -let pcmPlayer; - -export function initAudio(channels, sampleRate) { - pcmPlayer = newAudioPlayer(channels, sampleRate); - opusWorker.postMessage({ channels, sampleRate }); -} - -export function playAudio(packet) { - opusWorker.postMessage(packet, [packet.buffer]); -} - -window.init = async () => { - if (yuvWorker) { - yuvWorker.onmessage = (e) => { - onRgba(e.data); - } - } - opusWorker.onmessage = (e) => { - pcmPlayer.feed(e.data); - } - loadVp9(() => { }); - await initZstd(); - console.log('init done'); -} - -export function getPeers() { - try { - return JSON.parse(localStorage.getItem('peers')) || {}; - } catch (e) { - return {}; - } -} - -function newAudioPlayer(channels, sampleRate) { - return new PCMPlayer({ - channels, - sampleRate, - flushingTime: 2000 - }); -} - -export function copyToClipboard(text) { - if (window.clipboardData && window.clipboardData.setData) { - // Internet Explorer-specific code path to prevent textarea being shown while dialog is visible. - return window.clipboardData.setData("Text", text); - - } - else if (document.queryCommandSupported && document.queryCommandSupported("copy")) { - var textarea = document.createElement("textarea"); - textarea.textContent = text; - textarea.style.position = "fixed"; // Prevent scrolling to bottom of page in Microsoft Edge. - document.body.appendChild(textarea); - textarea.select(); - try { - return document.execCommand("copy"); // Security exception may be thrown by some browsers. - } - catch (ex) { - console.warn("Copy to clipboard failed.", ex); - // return prompt("Copy to clipboard: Ctrl+C, Enter", text); - } - finally { - document.body.removeChild(textarea); - } - } -} \ No newline at end of file diff --git a/flutter/web/v1/js/src/main.ts b/flutter/web/v1/js/src/main.ts deleted file mode 100644 index 2be877f58..000000000 --- a/flutter/web/v1/js/src/main.ts +++ /dev/null @@ -1,2 +0,0 @@ -import "./globals"; -import "./ui"; \ No newline at end of file diff --git a/flutter/web/v1/js/src/style.css b/flutter/web/v1/js/src/style.css deleted file mode 100644 index 852de7aa2..000000000 --- a/flutter/web/v1/js/src/style.css +++ /dev/null @@ -1,8 +0,0 @@ -#app { - font-family: Avenir, Helvetica, Arial, sans-serif; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - text-align: center; - color: #2c3e50; - margin-top: 60px; -} diff --git a/flutter/web/v1/js/src/ui.js b/flutter/web/v1/js/src/ui.js deleted file mode 100644 index 446334022..000000000 --- a/flutter/web/v1/js/src/ui.js +++ /dev/null @@ -1,108 +0,0 @@ -import "./style.css"; -import "./connection"; -import * as globals from "./globals"; - -const app = document.querySelector('#app'); - -if (app) { - app.innerHTML = ` -
- - - - -
Host:
Key:
Id:
- - - -`; - - let player; - window.init(); - - document.body.onload = () => { - const host = document.querySelector('#host'); - host.value = localStorage.getItem('custom-rendezvous-server'); - const id = document.querySelector('#id'); - id.value = localStorage.getItem('id'); - const key = document.querySelector('#key'); - key.value = localStorage.getItem('key'); - player = YUVCanvas.attach(document.getElementById('player')); - // globals.sendOffCanvas(document.getElementById('player')); - }; - - window.connect = () => { - const host = document.querySelector('#host'); - localStorage.setItem('custom-rendezvous-server', host.value); - const id = document.querySelector('#id'); - localStorage.setItem('id', id.value); - const key = document.querySelector('#key'); - localStorage.setItem('key', key.value); - const func = async () => { - const conn = globals.newConn(); - conn.setMsgbox(msgbox); - conn.setDraw((f) => { - /* - if (!(document.getElementById('player').width > 0)) { - document.getElementById('player').width = f.format.displayWidth; - document.getElementById('player').height = f.format.displayHeight; - } - */ - globals.draw(f); - player.drawFrame(f); - }); - document.querySelector('div#status').style.display = 'block'; - document.querySelector('div#connect').style.display = 'none'; - document.querySelector('div#text').innerHTML = 'Connecting ...'; - await conn.start(id.value); - }; - func(); - } - - function msgbox(type, title, text) { - if (!globals.getConn()) return; - if (type == 'input-password') { - document.querySelector('div#status').style.display = 'none'; - document.querySelector('div#password').style.display = 'block'; - } else if (!type) { - document.querySelector('div#canvas').style.display = 'block'; - document.querySelector('div#password').style.display = 'none'; - document.querySelector('div#status').style.display = 'none'; - } else if (type == 'error') { - document.querySelector('div#status').style.display = 'block'; - document.querySelector('div#canvas').style.display = 'none'; - document.querySelector('div#text').innerHTML = '
' + text + '
'; - } else { - document.querySelector('div#password').style.display = 'none'; - document.querySelector('div#status').style.display = 'block'; - document.querySelector('div#text').innerHTML = '
' + text + '
'; - } - } - - window.cancel = () => { - globals.close(); - document.querySelector('div#connect').style.display = 'block'; - document.querySelector('div#password').style.display = 'none'; - document.querySelector('div#status').style.display = 'none'; - document.querySelector('div#canvas').style.display = 'none'; - } - - window.confirm = () => { - const password = document.querySelector('input#password').value; - if (password) { - document.querySelector('div#password').style.display = 'none'; - globals.getConn().login(password); - } - } -} \ No newline at end of file diff --git a/flutter/web/v1/js/src/vite-env.d.ts b/flutter/web/v1/js/src/vite-env.d.ts deleted file mode 100644 index 151aa6856..000000000 --- a/flutter/web/v1/js/src/vite-env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// \ No newline at end of file diff --git a/flutter/web/v1/js/src/websock.ts b/flutter/web/v1/js/src/websock.ts deleted file mode 100644 index 6f05e6f6b..000000000 --- a/flutter/web/v1/js/src/websock.ts +++ /dev/null @@ -1,183 +0,0 @@ -import * as message from "./message.js"; -import * as rendezvous from "./rendezvous.js"; -import * as globals from "./globals"; - -type Keys = "message" | "open" | "close" | "error"; - -export default class Websock { - _websocket: WebSocket; - _eventHandlers: { [key in Keys]: Function }; - _buf: (rendezvous.RendezvousMessage | message.Message)[]; - _status: any; - _latency: number; - _secretKey: [Uint8Array, number, number] | undefined; - _uri: string; - _isRendezvous: boolean; - - constructor(uri: string, isRendezvous: boolean = true) { - this._eventHandlers = { - message: (_: any) => {}, - open: () => {}, - close: () => {}, - error: () => {}, - }; - this._uri = uri; - this._status = ""; - this._buf = []; - this._websocket = new WebSocket(uri); - this._websocket.onmessage = this._recv_message.bind(this); - this._websocket.binaryType = "arraybuffer"; - this._latency = new Date().getTime(); - this._isRendezvous = isRendezvous; - } - - latency(): number { - return this._latency; - } - - setSecretKey(key: Uint8Array) { - this._secretKey = [key, 0, 0]; - } - - sendMessage(json: message.DeepPartial) { - let data = message.Message.encode( - message.Message.fromPartial(json) - ).finish(); - let k = this._secretKey; - if (k) { - k[1] += 1; - data = globals.encrypt(data, k[1], k[0]); - } - this._websocket.send(data); - } - - sendRendezvous(data: rendezvous.DeepPartial) { - this._websocket.send( - rendezvous.RendezvousMessage.encode( - rendezvous.RendezvousMessage.fromPartial(data) - ).finish() - ); - } - - parseMessage(data: Uint8Array) { - return message.Message.decode(data); - } - - parseRendezvous(data: Uint8Array) { - return rendezvous.RendezvousMessage.decode(data); - } - - // Event Handlers - off(evt: Keys) { - this._eventHandlers[evt] = () => {}; - } - - on(evt: Keys, handler: Function) { - this._eventHandlers[evt] = handler; - } - - async open(timeout: number = 12000): Promise { - return new Promise((resolve, reject) => { - setTimeout(() => { - if (this._status != "open") { - reject(this._status || "Timeout"); - } - }, timeout); - this._websocket.onopen = () => { - this._latency = new Date().getTime() - this._latency; - this._status = "open"; - console.debug(">> WebSock.onopen"); - if (this._websocket?.protocol) { - console.info( - "Server choose sub-protocol: " + this._websocket.protocol - ); - } - - this._eventHandlers.open(); - console.info("WebSock.onopen"); - resolve(this); - }; - this._websocket.onclose = (e) => { - if (this._status == "open") { - // e.code 1000 means that the connection was closed normally. - // - } - this._status = e; - console.error("WebSock.onclose: "); - console.error(e); - this._eventHandlers.close(e); - reject("Reset by the peer"); - }; - this._websocket.onerror = (e: any) => { - if (!this._status) { - reject("Failed to connect to " + (this._isRendezvous ? "rendezvous" : "relay") + " server"); - return; - } - this._status = e; - console.error("WebSock.onerror: ") - console.error(e); - this._eventHandlers.error(e); - }; - }); - } - - async next( - timeout = 12000 - ): Promise { - const func = ( - resolve: (value: rendezvous.RendezvousMessage | message.Message) => void, - reject: (reason: any) => void, - tm0: number - ) => { - if (this._buf.length) { - resolve(this._buf[0]); - this._buf.splice(0, 1); - } else { - if (this._status != "open") { - reject(this._status); - return; - } - if (new Date().getTime() > tm0 + timeout) { - reject("Timeout"); - } else { - setTimeout(() => func(resolve, reject, tm0), 1); - } - } - }; - return new Promise((resolve, reject) => { - func(resolve, reject, new Date().getTime()); - }); - } - - close() { - this._status = ""; - if (this._websocket) { - if ( - this._websocket.readyState === WebSocket.OPEN || - this._websocket.readyState === WebSocket.CONNECTING - ) { - console.info("Closing WebSocket connection"); - this._websocket.close(); - } - - this._websocket.onmessage = () => {}; - } - } - - _recv_message(e: any) { - if (e.data instanceof window.ArrayBuffer) { - let bytes = new Uint8Array(e.data); - const k = this._secretKey; - if (k) { - k[2] += 1; - bytes = globals.decrypt(bytes, k[2], k[0]); - } - this._buf.push( - this._isRendezvous - ? this.parseRendezvous(bytes) - : this.parseMessage(bytes) - ); - } - this._eventHandlers.message(e.data); - } -} diff --git a/flutter/web/v1/js/ts_proto.py b/flutter/web/v1/js/ts_proto.py deleted file mode 100755 index 62a73fe7c..000000000 --- a/flutter/web/v1/js/ts_proto.py +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env python - -import os - -path = os.path.abspath(os.path.join(os.getcwd(), '..', '..', '..', 'libs', 'hbb_common', 'protos')) - -if os.name == 'nt': - cmd = r'protoc --ts_proto_opt=esModuleInterop=true --ts_proto_opt=snakeToCamel=false --plugin=protoc-gen-ts_proto=.\node_modules\.bin\protoc-gen-ts_proto.cmd -I "%s" --ts_proto_out=./src/ rendezvous.proto'%path - print(cmd) - os.system(cmd) - cmd = r'protoc --ts_proto_opt=esModuleInterop=true --ts_proto_opt=snakeToCamel=false --plugin=protoc-gen-ts_proto=.\node_modules\.bin\protoc-gen-ts_proto.cmd -I "%s" --ts_proto_out=./src/ message.proto'%path - print(cmd) - os.system(cmd) -else: - cmd = r'protoc --ts_proto_opt=esModuleInterop=true --ts_proto_opt=snakeToCamel=false --plugin=./node_modules/.bin/protoc-gen-ts_proto -I "%s" --ts_proto_out=./src/ rendezvous.proto'%path - print(cmd) - os.system(cmd) - cmd = r'protoc --ts_proto_opt=esModuleInterop=true --ts_proto_opt=snakeToCamel=false --plugin=./node_modules/.bin/protoc-gen-ts_proto -I "%s" --ts_proto_out=./src/ message.proto'%path - print(cmd) - os.system(cmd) diff --git a/flutter/web/v1/js/tsconfig.json b/flutter/web/v1/js/tsconfig.json deleted file mode 100644 index ca949de6a..000000000 --- a/flutter/web/v1/js/tsconfig.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "compilerOptions": { - "target": "ESNext", - "useDefineForClassFields": true, - "module": "ESNext", - "allowJs": true, - "lib": [ - "ESNext", - "DOM" - ], - "moduleResolution": "Node", - "strict": true, - "sourceMap": true, - "resolveJsonModule": true, - "esModuleInterop": true, - "noEmit": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noImplicitReturns": true - }, - "include": [ - "./src" - ] -} \ No newline at end of file diff --git a/flutter/web/v1/js/vite.config.js b/flutter/web/v1/js/vite.config.js deleted file mode 100644 index 22c51fa54..000000000 --- a/flutter/web/v1/js/vite.config.js +++ /dev/null @@ -1,14 +0,0 @@ -import { defineConfig } from 'vite'; - -export default defineConfig({ - build: { - manifest: false, - rollupOptions: { - output: { - entryFileNames: `[name].js`, - chunkFileNames: `[name].js`, - assetFileNames: `[name].[ext]`, - } - } - }, -}) \ No newline at end of file diff --git a/flutter/web/v1/js/yarn.lock b/flutter/web/v1/js/yarn.lock deleted file mode 100644 index d55664879..000000000 --- a/flutter/web/v1/js/yarn.lock +++ /dev/null @@ -1,1494 +0,0 @@ -# This file is generated by running "yarn install" inside your project. -# Manual changes might be lost - proceed with caution! - -__metadata: - version: 6 - cacheKey: 8 - -"@gar/promisify@npm:^1.1.3": - version: 1.1.3 - resolution: "@gar/promisify@npm:1.1.3" - checksum: 4059f790e2d07bf3c3ff3e0fec0daa8144fe35c1f6e0111c9921bd32106adaa97a4ab096ad7dab1e28ee6a9060083c4d1a4ada42a7f5f3f7a96b8812e2b757c1 - languageName: node - linkType: hard - -"@npmcli/fs@npm:^2.1.0": - version: 2.1.0 - resolution: "@npmcli/fs@npm:2.1.0" - dependencies: - "@gar/promisify": ^1.1.3 - semver: ^7.3.5 - checksum: 6ec6d678af6da49f9dac50cd882d7f661934dd278972ffbaacde40d9eaa2871292d634000a0cca9510f6fc29855fbd4af433e1adbff90a524ec3eaf140f1219b - languageName: node - linkType: hard - -"@npmcli/move-file@npm:^2.0.0": - version: 2.0.0 - resolution: "@npmcli/move-file@npm:2.0.0" - dependencies: - mkdirp: ^1.0.4 - rimraf: ^3.0.2 - checksum: 1388777b507b0c592d53f41b9d182e1a8de7763bc625fc07999b8edbc22325f074e5b3ec90af79c89d6987fdb2325bc66d59f483258543c14a43661621f841b0 - languageName: node - linkType: hard - -"@protobufjs/aspromise@npm:^1.1.1, @protobufjs/aspromise@npm:^1.1.2": - version: 1.1.2 - resolution: "@protobufjs/aspromise@npm:1.1.2" - checksum: 011fe7ef0826b0fd1a95935a033a3c0fd08483903e1aa8f8b4e0704e3233406abb9ee25350ec0c20bbecb2aad8da0dcea58b392bbd77d6690736f02c143865d2 - languageName: node - linkType: hard - -"@protobufjs/base64@npm:^1.1.2": - version: 1.1.2 - resolution: "@protobufjs/base64@npm:1.1.2" - checksum: 67173ac34de1e242c55da52c2f5bdc65505d82453893f9b51dc74af9fe4c065cf4a657a4538e91b0d4a1a1e0a0642215e31894c31650ff6e3831471061e1ee9e - languageName: node - linkType: hard - -"@protobufjs/codegen@npm:^2.0.4": - version: 2.0.4 - resolution: "@protobufjs/codegen@npm:2.0.4" - checksum: 59240c850b1d3d0b56d8f8098dd04787dcaec5c5bd8de186fa548de86b86076e1c50e80144b90335e705a044edf5bc8b0998548474c2a10a98c7e004a1547e4b - languageName: node - linkType: hard - -"@protobufjs/eventemitter@npm:^1.1.0": - version: 1.1.0 - resolution: "@protobufjs/eventemitter@npm:1.1.0" - checksum: 0369163a3d226851682f855f81413cbf166cd98f131edb94a0f67f79e75342d86e89df9d7a1df08ac28be2bc77e0a7f0200526bb6c2a407abbfee1f0262d5fd7 - languageName: node - linkType: hard - -"@protobufjs/fetch@npm:^1.1.0": - version: 1.1.0 - resolution: "@protobufjs/fetch@npm:1.1.0" - dependencies: - "@protobufjs/aspromise": ^1.1.1 - "@protobufjs/inquire": ^1.1.0 - checksum: 3fce7e09eb3f1171dd55a192066450f65324fd5f7cc01a431df01bb00d0a895e6bfb5b0c5561ce157ee1d886349c90703d10a4e11a1a256418ff591b969b3477 - languageName: node - linkType: hard - -"@protobufjs/float@npm:^1.0.2": - version: 1.0.2 - resolution: "@protobufjs/float@npm:1.0.2" - checksum: 5781e1241270b8bd1591d324ca9e3a3128d2f768077a446187a049e36505e91bc4156ed5ac3159c3ce3d2ba3743dbc757b051b2d723eea9cd367bfd54ab29b2f - languageName: node - linkType: hard - -"@protobufjs/inquire@npm:^1.1.0": - version: 1.1.0 - resolution: "@protobufjs/inquire@npm:1.1.0" - checksum: ca06f02eaf65ca36fb7498fc3492b7fc087bfcc85c702bac5b86fad34b692bdce4990e0ef444c1e2aea8c034227bd1f0484be02810d5d7e931c55445555646f4 - languageName: node - linkType: hard - -"@protobufjs/path@npm:^1.1.2": - version: 1.1.2 - resolution: "@protobufjs/path@npm:1.1.2" - checksum: 856eeb532b16a7aac071cacde5c5620df800db4c80cee6dbc56380524736205aae21e5ae47739114bf669ab5e8ba0e767a282ad894f3b5e124197cb9224445ee - languageName: node - linkType: hard - -"@protobufjs/pool@npm:^1.1.0": - version: 1.1.0 - resolution: "@protobufjs/pool@npm:1.1.0" - checksum: d6a34fbbd24f729e2a10ee915b74e1d77d52214de626b921b2d77288bd8f2386808da2315080f2905761527cceffe7ec34c7647bd21a5ae41a25e8212ff79451 - languageName: node - linkType: hard - -"@protobufjs/utf8@npm:^1.1.0": - version: 1.1.0 - resolution: "@protobufjs/utf8@npm:1.1.0" - checksum: f9bf3163d13aaa3b6f5e6fbf37a116e094ea021c0e1f2a7ccd0e12a29e2ce08dafba4e8b36e13f8ed7397e1591610ce880ed1289af4d66cf4ace8a36a9557278 - languageName: node - linkType: hard - -"@tootallnate/once@npm:2": - version: 2.0.0 - resolution: "@tootallnate/once@npm:2.0.0" - checksum: ad87447820dd3f24825d2d947ebc03072b20a42bfc96cbafec16bff8bbda6c1a81fcb0be56d5b21968560c5359a0af4038a68ba150c3e1694fe4c109a063bed8 - languageName: node - linkType: hard - -"@types/long@npm:^4.0.1": - version: 4.0.1 - resolution: "@types/long@npm:4.0.1" - checksum: ff9653c33f5000d0f131fd98a950a0343e2e33107dd067a97ac4a3b9678e1a2e39ea44772ad920f54ef6e8f107f76bc92c2584ba905a0dc4253282a4101166d0 - languageName: node - linkType: hard - -"@types/node@npm:>=13.7.0": - version: 17.0.8 - resolution: "@types/node@npm:17.0.8" - checksum: f4cadeb9e602027520abc88c77142697e33cf6ac98bb02f8b595a398603cbd33df1f94d01c055c9f13cde0c8eaafc5e396ca72645458d42b4318b845bc7f1d0f - languageName: node - linkType: hard - -"@types/object-hash@npm:^1.3.0": - version: 1.3.4 - resolution: "@types/object-hash@npm:1.3.4" - checksum: fe4aa041427f3c69cbcf63434af6e788329b40d7208b30aa845cfc1aa6bf9b0c11b23fa33a567d85ba7f2574a95c3b4a227f4b9b9b55da1eaea68ab94b4058d9 - languageName: node - linkType: hard - -"abbrev@npm:1": - version: 1.1.1 - resolution: "abbrev@npm:1.1.1" - checksum: a4a97ec07d7ea112c517036882b2ac22f3109b7b19077dc656316d07d308438aac28e4d9746dc4d84bf6b1e75b4a7b0a5f3cb30592419f128ca9a8cee3bcfa17 - languageName: node - linkType: hard - -"agent-base@npm:6, agent-base@npm:^6.0.2": - version: 6.0.2 - resolution: "agent-base@npm:6.0.2" - dependencies: - debug: 4 - checksum: f52b6872cc96fd5f622071b71ef200e01c7c4c454ee68bc9accca90c98cfb39f2810e3e9aa330435835eedc8c23f4f8a15267f67c6e245d2b33757575bdac49d - languageName: node - linkType: hard - -"agentkeepalive@npm:^4.2.1": - version: 4.2.1 - resolution: "agentkeepalive@npm:4.2.1" - dependencies: - debug: ^4.1.0 - depd: ^1.1.2 - humanize-ms: ^1.2.1 - checksum: 39cb49ed8cf217fd6da058a92828a0a84e0b74c35550f82ee0a10e1ee403c4b78ade7948be2279b188b7a7303f5d396ea2738b134731e464bf28de00a4f72a18 - languageName: node - linkType: hard - -"aggregate-error@npm:^3.0.0": - version: 3.1.0 - resolution: "aggregate-error@npm:3.1.0" - dependencies: - clean-stack: ^2.0.0 - indent-string: ^4.0.0 - checksum: 1101a33f21baa27a2fa8e04b698271e64616b886795fd43c31068c07533c7b3facfcaf4e9e0cab3624bd88f729a592f1c901a1a229c9e490eafce411a8644b79 - languageName: node - linkType: hard - -"ansi-regex@npm:^5.0.1": - version: 5.0.1 - resolution: "ansi-regex@npm:5.0.1" - checksum: 2aa4bb54caf2d622f1afdad09441695af2a83aa3fe8b8afa581d205e57ed4261c183c4d3877cee25794443fde5876417d859c108078ab788d6af7e4fe52eb66b - languageName: node - linkType: hard - -"aproba@npm:^1.0.3 || ^2.0.0": - version: 2.0.0 - resolution: "aproba@npm:2.0.0" - checksum: 5615cadcfb45289eea63f8afd064ab656006361020e1735112e346593856f87435e02d8dcc7ff0d11928bc7d425f27bc7c2a84f6c0b35ab0ff659c814c138a24 - languageName: node - linkType: hard - -"are-we-there-yet@npm:^3.0.0": - version: 3.0.0 - resolution: "are-we-there-yet@npm:3.0.0" - dependencies: - delegates: ^1.0.0 - readable-stream: ^3.6.0 - checksum: 348edfdd931b0b50868b55402c01c3f64df1d4c229ab6f063539a5025fd6c5f5bb8a0cab409bbed8d75d34762d22aa91b7c20b4204eb8177063158d9ba792981 - languageName: node - linkType: hard - -"balanced-match@npm:^1.0.0": - version: 1.0.2 - resolution: "balanced-match@npm:1.0.2" - checksum: 9706c088a283058a8a99e0bf91b0a2f75497f185980d9ffa8b304de1d9e58ebda7c72c07ebf01dadedaac5b2907b2c6f566f660d62bd336c3468e960403b9d65 - languageName: node - linkType: hard - -"brace-expansion@npm:^1.1.7": - version: 1.1.11 - resolution: "brace-expansion@npm:1.1.11" - dependencies: - balanced-match: ^1.0.0 - concat-map: 0.0.1 - checksum: faf34a7bb0c3fcf4b59c7808bc5d2a96a40988addf2e7e09dfbb67a2251800e0d14cd2bfc1aa79174f2f5095c54ff27f46fb1289fe2d77dac755b5eb3434cc07 - languageName: node - linkType: hard - -"brace-expansion@npm:^2.0.1": - version: 2.0.1 - resolution: "brace-expansion@npm:2.0.1" - dependencies: - balanced-match: ^1.0.0 - checksum: a61e7cd2e8a8505e9f0036b3b6108ba5e926b4b55089eeb5550cd04a471fe216c96d4fe7e4c7f995c728c554ae20ddfc4244cad10aef255e72b62930afd233d1 - languageName: node - linkType: hard - -"cacache@npm:^16.0.2": - version: 16.0.7 - resolution: "cacache@npm:16.0.7" - dependencies: - "@npmcli/fs": ^2.1.0 - "@npmcli/move-file": ^2.0.0 - chownr: ^2.0.0 - fs-minipass: ^2.1.0 - glob: ^8.0.1 - infer-owner: ^1.0.4 - lru-cache: ^7.7.1 - minipass: ^3.1.6 - minipass-collect: ^1.0.2 - minipass-flush: ^1.0.5 - minipass-pipeline: ^1.2.4 - mkdirp: ^1.0.4 - p-map: ^4.0.0 - promise-inflight: ^1.0.1 - rimraf: ^3.0.2 - ssri: ^9.0.0 - tar: ^6.1.11 - unique-filename: ^1.1.1 - checksum: 2155b099b7e0f0369fb1155ca4673532ca7efe2ebdbec63acca8743580b8446b5d4fd7184626b1cb059001af77b981cdc67035c7855544d365d4f048eafca2ca - languageName: node - linkType: hard - -"chownr@npm:^2.0.0": - version: 2.0.0 - resolution: "chownr@npm:2.0.0" - checksum: c57cf9dd0791e2f18a5ee9c1a299ae6e801ff58fee96dc8bfd0dcb4738a6ce58dd252a3605b1c93c6418fe4f9d5093b28ffbf4d66648cb2a9c67eaef9679be2f - languageName: node - linkType: hard - -"clean-stack@npm:^2.0.0": - version: 2.2.0 - resolution: "clean-stack@npm:2.2.0" - checksum: 2ac8cd2b2f5ec986a3c743935ec85b07bc174d5421a5efc8017e1f146a1cf5f781ae962618f416352103b32c9cd7e203276e8c28241bbe946160cab16149fb68 - languageName: node - linkType: hard - -"color-support@npm:^1.1.3": - version: 1.1.3 - resolution: "color-support@npm:1.1.3" - bin: - color-support: bin.js - checksum: 9b7356817670b9a13a26ca5af1c21615463b500783b739b7634a0c2047c16cef4b2865d7576875c31c3cddf9dd621fa19285e628f20198b233a5cfdda6d0793b - languageName: node - linkType: hard - -"concat-map@npm:0.0.1": - version: 0.0.1 - resolution: "concat-map@npm:0.0.1" - checksum: 902a9f5d8967a3e2faf138d5cb784b9979bad2e6db5357c5b21c568df4ebe62bcb15108af1b2253744844eb964fc023fbd9afbbbb6ddd0bcc204c6fb5b7bf3af - languageName: node - linkType: hard - -"console-control-strings@npm:^1.1.0": - version: 1.1.0 - resolution: "console-control-strings@npm:1.1.0" - checksum: 8755d76787f94e6cf79ce4666f0c5519906d7f5b02d4b884cf41e11dcd759ed69c57da0670afd9236d229a46e0f9cf519db0cd829c6dca820bb5a5c3def584ed - languageName: node - linkType: hard - -"dataloader@npm:^1.4.0": - version: 1.4.0 - resolution: "dataloader@npm:1.4.0" - checksum: e2c93d43afde68980efc0cd9ff48e9851116e27a9687f863e02b56d46f7e7868cc762cd6dcbaf4197e1ca850a03651510c165c2ae24b8e9843fd894002ad0e20 - languageName: node - linkType: hard - -"debug@npm:4, debug@npm:^4.1.0, debug@npm:^4.3.3": - version: 4.3.4 - resolution: "debug@npm:4.3.4" - dependencies: - ms: 2.1.2 - peerDependenciesMeta: - supports-color: - optional: true - checksum: 3dbad3f94ea64f34431a9cbf0bafb61853eda57bff2880036153438f50fb5a84f27683ba0d8e5426bf41a8c6ff03879488120cf5b3a761e77953169c0600a708 - languageName: node - linkType: hard - -"delegates@npm:^1.0.0": - version: 1.0.0 - resolution: "delegates@npm:1.0.0" - checksum: a51744d9b53c164ba9c0492471a1a2ffa0b6727451bdc89e31627fdf4adda9d51277cfcbfb20f0a6f08ccb3c436f341df3e92631a3440226d93a8971724771fd - languageName: node - linkType: hard - -"depd@npm:^1.1.2": - version: 1.1.2 - resolution: "depd@npm:1.1.2" - checksum: 6b406620d269619852885ce15965272b829df6f409724415e0002c8632ab6a8c0a08ec1f0bd2add05dc7bd7507606f7e2cc034fa24224ab829580040b835ecd9 - languageName: node - linkType: hard - -"emoji-regex@npm:^8.0.0": - version: 8.0.0 - resolution: "emoji-regex@npm:8.0.0" - checksum: d4c5c39d5a9868b5fa152f00cada8a936868fd3367f33f71be515ecee4c803132d11b31a6222b2571b1e5f7e13890156a94880345594d0ce7e3c9895f560f192 - languageName: node - linkType: hard - -"encoding@npm:^0.1.13": - version: 0.1.13 - resolution: "encoding@npm:0.1.13" - dependencies: - iconv-lite: ^0.6.2 - checksum: bb98632f8ffa823996e508ce6a58ffcf5856330fde839ae42c9e1f436cc3b5cc651d4aeae72222916545428e54fd0f6aa8862fd8d25bdbcc4589f1e3f3715e7f - languageName: node - linkType: hard - -"env-paths@npm:^2.2.0": - version: 2.2.1 - resolution: "env-paths@npm:2.2.1" - checksum: 65b5df55a8bab92229ab2b40dad3b387fad24613263d103a97f91c9fe43ceb21965cd3392b1ccb5d77088021e525c4e0481adb309625d0cb94ade1d1fb8dc17e - languageName: node - linkType: hard - -"err-code@npm:^2.0.2": - version: 2.0.3 - resolution: "err-code@npm:2.0.3" - checksum: 8b7b1be20d2de12d2255c0bc2ca638b7af5171142693299416e6a9339bd7d88fc8d7707d913d78e0993176005405a236b066b45666b27b797252c771156ace54 - languageName: node - linkType: hard - -"esbuild-android-arm64@npm:0.13.15": - version: 0.13.15 - resolution: "esbuild-android-arm64@npm:0.13.15" - conditions: os=android & cpu=arm64 - languageName: node - linkType: hard - -"esbuild-darwin-64@npm:0.13.15": - version: 0.13.15 - resolution: "esbuild-darwin-64@npm:0.13.15" - conditions: os=darwin & cpu=x64 - languageName: node - linkType: hard - -"esbuild-darwin-arm64@npm:0.13.15": - version: 0.13.15 - resolution: "esbuild-darwin-arm64@npm:0.13.15" - conditions: os=darwin & cpu=arm64 - languageName: node - linkType: hard - -"esbuild-freebsd-64@npm:0.13.15": - version: 0.13.15 - resolution: "esbuild-freebsd-64@npm:0.13.15" - conditions: os=freebsd & cpu=x64 - languageName: node - linkType: hard - -"esbuild-freebsd-arm64@npm:0.13.15": - version: 0.13.15 - resolution: "esbuild-freebsd-arm64@npm:0.13.15" - conditions: os=freebsd & cpu=arm64 - languageName: node - linkType: hard - -"esbuild-linux-32@npm:0.13.15": - version: 0.13.15 - resolution: "esbuild-linux-32@npm:0.13.15" - conditions: os=linux & cpu=ia32 - languageName: node - linkType: hard - -"esbuild-linux-64@npm:0.13.15": - version: 0.13.15 - resolution: "esbuild-linux-64@npm:0.13.15" - conditions: os=linux & cpu=x64 - languageName: node - linkType: hard - -"esbuild-linux-arm64@npm:0.13.15": - version: 0.13.15 - resolution: "esbuild-linux-arm64@npm:0.13.15" - conditions: os=linux & cpu=arm64 - languageName: node - linkType: hard - -"esbuild-linux-arm@npm:0.13.15": - version: 0.13.15 - resolution: "esbuild-linux-arm@npm:0.13.15" - conditions: os=linux & cpu=arm - languageName: node - linkType: hard - -"esbuild-linux-mips64le@npm:0.13.15": - version: 0.13.15 - resolution: "esbuild-linux-mips64le@npm:0.13.15" - conditions: os=linux & cpu=mips64el - languageName: node - linkType: hard - -"esbuild-linux-ppc64le@npm:0.13.15": - version: 0.13.15 - resolution: "esbuild-linux-ppc64le@npm:0.13.15" - conditions: os=linux & cpu=ppc64 - languageName: node - linkType: hard - -"esbuild-netbsd-64@npm:0.13.15": - version: 0.13.15 - resolution: "esbuild-netbsd-64@npm:0.13.15" - conditions: os=netbsd & cpu=x64 - languageName: node - linkType: hard - -"esbuild-openbsd-64@npm:0.13.15": - version: 0.13.15 - resolution: "esbuild-openbsd-64@npm:0.13.15" - conditions: os=openbsd & cpu=x64 - languageName: node - linkType: hard - -"esbuild-sunos-64@npm:0.13.15": - version: 0.13.15 - resolution: "esbuild-sunos-64@npm:0.13.15" - conditions: os=sunos & cpu=x64 - languageName: node - linkType: hard - -"esbuild-windows-32@npm:0.13.15": - version: 0.13.15 - resolution: "esbuild-windows-32@npm:0.13.15" - conditions: os=win32 & cpu=ia32 - languageName: node - linkType: hard - -"esbuild-windows-64@npm:0.13.15": - version: 0.13.15 - resolution: "esbuild-windows-64@npm:0.13.15" - conditions: os=win32 & cpu=x64 - languageName: node - linkType: hard - -"esbuild-windows-arm64@npm:0.13.15": - version: 0.13.15 - resolution: "esbuild-windows-arm64@npm:0.13.15" - conditions: os=win32 & cpu=arm64 - languageName: node - linkType: hard - -"esbuild@npm:^0.13.12": - version: 0.13.15 - resolution: "esbuild@npm:0.13.15" - dependencies: - esbuild-android-arm64: 0.13.15 - esbuild-darwin-64: 0.13.15 - esbuild-darwin-arm64: 0.13.15 - esbuild-freebsd-64: 0.13.15 - esbuild-freebsd-arm64: 0.13.15 - esbuild-linux-32: 0.13.15 - esbuild-linux-64: 0.13.15 - esbuild-linux-arm: 0.13.15 - esbuild-linux-arm64: 0.13.15 - esbuild-linux-mips64le: 0.13.15 - esbuild-linux-ppc64le: 0.13.15 - esbuild-netbsd-64: 0.13.15 - esbuild-openbsd-64: 0.13.15 - esbuild-sunos-64: 0.13.15 - esbuild-windows-32: 0.13.15 - esbuild-windows-64: 0.13.15 - esbuild-windows-arm64: 0.13.15 - dependenciesMeta: - esbuild-android-arm64: - optional: true - esbuild-darwin-64: - optional: true - esbuild-darwin-arm64: - optional: true - esbuild-freebsd-64: - optional: true - esbuild-freebsd-arm64: - optional: true - esbuild-linux-32: - optional: true - esbuild-linux-64: - optional: true - esbuild-linux-arm: - optional: true - esbuild-linux-arm64: - optional: true - esbuild-linux-mips64le: - optional: true - esbuild-linux-ppc64le: - optional: true - esbuild-netbsd-64: - optional: true - esbuild-openbsd-64: - optional: true - esbuild-sunos-64: - optional: true - esbuild-windows-32: - optional: true - esbuild-windows-64: - optional: true - esbuild-windows-arm64: - optional: true - bin: - esbuild: bin/esbuild - checksum: d5fac8f28a6328592e45f9d49a7e98420cf2c2a3ff5a753bbf011ab79bcb5c062209ef862d3ae0875d8f2a50d40c112b0224e8b419a7cbffc6e2b02cee11ef7e - languageName: node - linkType: hard - -"fast-sha256@npm:^1.3.0": - version: 1.3.0 - resolution: "fast-sha256@npm:1.3.0" - checksum: 2b0bea7d3a9955e67abd2d3fbef4ce57f7dbb75708fc206d14973bd1d97aaf35b5c0a59c1d65be6f755df43d73b7657b9eac4fb3c2d58e6849966db1ef1fa186 - languageName: node - linkType: hard - -"fs-minipass@npm:^2.0.0, fs-minipass@npm:^2.1.0": - version: 2.1.0 - resolution: "fs-minipass@npm:2.1.0" - dependencies: - minipass: ^3.0.0 - checksum: 1b8d128dae2ac6cc94230cc5ead341ba3e0efaef82dab46a33d171c044caaa6ca001364178d42069b2809c35a1c3c35079a32107c770e9ffab3901b59af8c8b1 - languageName: node - linkType: hard - -"fs.realpath@npm:^1.0.0": - version: 1.0.0 - resolution: "fs.realpath@npm:1.0.0" - checksum: 99ddea01a7e75aa276c250a04eedeffe5662bce66c65c07164ad6264f9de18fb21be9433ead460e54cff20e31721c811f4fb5d70591799df5f85dce6d6746fd0 - languageName: node - linkType: hard - -"fsevents@npm:~2.3.2": - version: 2.3.2 - resolution: "fsevents@npm:2.3.2" - dependencies: - node-gyp: latest - checksum: 97ade64e75091afee5265e6956cb72ba34db7819b4c3e94c431d4be2b19b8bb7a2d4116da417950c3425f17c8fe693d25e20212cac583ac1521ad066b77ae31f - conditions: os=darwin - languageName: node - linkType: hard - -"fsevents@patch:fsevents@~2.3.2#~builtin": - version: 2.3.2 - resolution: "fsevents@patch:fsevents@npm%3A2.3.2#~builtin::version=2.3.2&hash=18f3a7" - dependencies: - node-gyp: latest - conditions: os=darwin - languageName: node - linkType: hard - -"function-bind@npm:^1.1.1": - version: 1.1.1 - resolution: "function-bind@npm:1.1.1" - checksum: b32fbaebb3f8ec4969f033073b43f5c8befbb58f1a79e12f1d7490358150359ebd92f49e72ff0144f65f2c48ea2a605bff2d07965f548f6474fd8efd95bf361a - languageName: node - linkType: hard - -"gauge@npm:^4.0.3": - version: 4.0.4 - resolution: "gauge@npm:4.0.4" - dependencies: - aproba: ^1.0.3 || ^2.0.0 - color-support: ^1.1.3 - console-control-strings: ^1.1.0 - has-unicode: ^2.0.1 - signal-exit: ^3.0.7 - string-width: ^4.2.3 - strip-ansi: ^6.0.1 - wide-align: ^1.1.5 - checksum: 788b6bfe52f1dd8e263cda800c26ac0ca2ff6de0b6eee2fe0d9e3abf15e149b651bd27bf5226be10e6e3edb5c4e5d5985a5a1a98137e7a892f75eff76467ad2d - languageName: node - linkType: hard - -"glob@npm:^7.1.3, glob@npm:^7.1.4": - version: 7.2.3 - resolution: "glob@npm:7.2.3" - dependencies: - fs.realpath: ^1.0.0 - inflight: ^1.0.4 - inherits: 2 - minimatch: ^3.1.1 - once: ^1.3.0 - path-is-absolute: ^1.0.0 - checksum: 29452e97b38fa704dabb1d1045350fb2467cf0277e155aa9ff7077e90ad81d1ea9d53d3ee63bd37c05b09a065e90f16aec4a65f5b8de401d1dac40bc5605d133 - languageName: node - linkType: hard - -"glob@npm:^8.0.1": - version: 8.0.3 - resolution: "glob@npm:8.0.3" - dependencies: - fs.realpath: ^1.0.0 - inflight: ^1.0.4 - inherits: 2 - minimatch: ^5.0.1 - once: ^1.3.0 - checksum: 50bcdea19d8e79d8de5f460b1939ffc2b3299eac28deb502093fdca22a78efebc03e66bf54f0abc3d3d07d8134d19a32850288b7440d77e072aa55f9d33b18c5 - languageName: node - linkType: hard - -"graceful-fs@npm:^4.2.6": - version: 4.2.10 - resolution: "graceful-fs@npm:4.2.10" - checksum: 3f109d70ae123951905d85032ebeae3c2a5a7a997430df00ea30df0e3a6c60cf6689b109654d6fdacd28810a053348c4d14642da1d075049e6be1ba5216218da - languageName: node - linkType: hard - -"has-unicode@npm:^2.0.1": - version: 2.0.1 - resolution: "has-unicode@npm:2.0.1" - checksum: 1eab07a7436512db0be40a710b29b5dc21fa04880b7f63c9980b706683127e3c1b57cb80ea96d47991bdae2dfe479604f6a1ba410106ee1046a41d1bd0814400 - languageName: node - linkType: hard - -"has@npm:^1.0.3": - version: 1.0.3 - resolution: "has@npm:1.0.3" - dependencies: - function-bind: ^1.1.1 - checksum: b9ad53d53be4af90ce5d1c38331e712522417d017d5ef1ebd0507e07c2fbad8686fffb8e12ddecd4c39ca9b9b47431afbb975b8abf7f3c3b82c98e9aad052792 - languageName: node - linkType: hard - -"http-cache-semantics@npm:^4.1.0": - version: 4.1.0 - resolution: "http-cache-semantics@npm:4.1.0" - checksum: 974de94a81c5474be07f269f9fd8383e92ebb5a448208223bfb39e172a9dbc26feff250192ecc23b9593b3f92098e010406b0f24bd4d588d631f80214648ed42 - languageName: node - linkType: hard - -"http-proxy-agent@npm:^5.0.0": - version: 5.0.0 - resolution: "http-proxy-agent@npm:5.0.0" - dependencies: - "@tootallnate/once": 2 - agent-base: 6 - debug: 4 - checksum: e2ee1ff1656a131953839b2a19cd1f3a52d97c25ba87bd2559af6ae87114abf60971e498021f9b73f9fd78aea8876d1fb0d4656aac8a03c6caa9fc175f22b786 - languageName: node - linkType: hard - -"https-proxy-agent@npm:^5.0.0": - version: 5.0.1 - resolution: "https-proxy-agent@npm:5.0.1" - dependencies: - agent-base: 6 - debug: 4 - checksum: 571fccdf38184f05943e12d37d6ce38197becdd69e58d03f43637f7fa1269cf303a7d228aa27e5b27bbd3af8f09fd938e1c91dcfefff2df7ba77c20ed8dfc765 - languageName: node - linkType: hard - -"humanize-ms@npm:^1.2.1": - version: 1.2.1 - resolution: "humanize-ms@npm:1.2.1" - dependencies: - ms: ^2.0.0 - checksum: 9c7a74a2827f9294c009266c82031030eae811ca87b0da3dceb8d6071b9bde22c9f3daef0469c3c533cc67a97d8a167cd9fc0389350e5f415f61a79b171ded16 - languageName: node - linkType: hard - -"iconv-lite@npm:^0.6.2": - version: 0.6.3 - resolution: "iconv-lite@npm:0.6.3" - dependencies: - safer-buffer: ">= 2.1.2 < 3.0.0" - checksum: 3f60d47a5c8fc3313317edfd29a00a692cc87a19cac0159e2ce711d0ebc9019064108323b5e493625e25594f11c6236647d8e256fbe7a58f4a3b33b89e6d30bf - languageName: node - linkType: hard - -"imurmurhash@npm:^0.1.4": - version: 0.1.4 - resolution: "imurmurhash@npm:0.1.4" - checksum: 7cae75c8cd9a50f57dadd77482359f659eaebac0319dd9368bcd1714f55e65badd6929ca58569da2b6494ef13fdd5598cd700b1eba23f8b79c5f19d195a3ecf7 - languageName: node - linkType: hard - -"indent-string@npm:^4.0.0": - version: 4.0.0 - resolution: "indent-string@npm:4.0.0" - checksum: 824cfb9929d031dabf059bebfe08cf3137365e112019086ed3dcff6a0a7b698cb80cf67ccccde0e25b9e2d7527aa6cc1fed1ac490c752162496caba3e6699612 - languageName: node - linkType: hard - -"infer-owner@npm:^1.0.4": - version: 1.0.4 - resolution: "infer-owner@npm:1.0.4" - checksum: 181e732764e4a0611576466b4b87dac338972b839920b2a8cde43642e4ed6bd54dc1fb0b40874728f2a2df9a1b097b8ff83b56d5f8f8e3927f837fdcb47d8a89 - languageName: node - linkType: hard - -"inflight@npm:^1.0.4": - version: 1.0.6 - resolution: "inflight@npm:1.0.6" - dependencies: - once: ^1.3.0 - wrappy: 1 - checksum: f4f76aa072ce19fae87ce1ef7d221e709afb59d445e05d47fba710e85470923a75de35bfae47da6de1b18afc3ce83d70facf44cfb0aff89f0a3f45c0a0244dfd - languageName: node - linkType: hard - -"inherits@npm:2, inherits@npm:^2.0.3": - version: 2.0.4 - resolution: "inherits@npm:2.0.4" - checksum: 4a48a733847879d6cf6691860a6b1e3f0f4754176e4d71494c41f3475553768b10f84b5ce1d40fbd0e34e6bfbb864ee35858ad4dd2cf31e02fc4a154b724d7f1 - languageName: node - linkType: hard - -"ip@npm:^1.1.5": - version: 1.1.8 - resolution: "ip@npm:1.1.8" - checksum: a2ade53eb339fb0cbe9e69a44caab10d6e3784662285eb5d2677117ee4facc33a64679051c35e0dfdb1a3983a51ce2f5d2cb36446d52e10d01881789b76e28fb - languageName: node - linkType: hard - -"is-core-module@npm:^2.8.0": - version: 2.8.1 - resolution: "is-core-module@npm:2.8.1" - dependencies: - has: ^1.0.3 - checksum: 418b7bc10768a73c41c7ef497e293719604007f88934a6ffc5f7c78702791b8528102fb4c9e56d006d69361549b3d9519440214a74aefc7e0b79e5e4411d377f - languageName: node - linkType: hard - -"is-fullwidth-code-point@npm:^3.0.0": - version: 3.0.0 - resolution: "is-fullwidth-code-point@npm:3.0.0" - checksum: 44a30c29457c7fb8f00297bce733f0a64cd22eca270f83e58c105e0d015e45c019491a4ab2faef91ab51d4738c670daff901c799f6a700e27f7314029e99e348 - languageName: node - linkType: hard - -"is-lambda@npm:^1.0.1": - version: 1.0.1 - resolution: "is-lambda@npm:1.0.1" - checksum: 93a32f01940220532e5948538699ad610d5924ac86093fcee83022252b363eb0cc99ba53ab084a04e4fb62bf7b5731f55496257a4c38adf87af9c4d352c71c35 - languageName: node - linkType: hard - -"isexe@npm:^2.0.0": - version: 2.0.0 - resolution: "isexe@npm:2.0.0" - checksum: 26bf6c5480dda5161c820c5b5c751ae1e766c587b1f951ea3fcfc973bafb7831ae5b54a31a69bd670220e42e99ec154475025a468eae58ea262f813fdc8d1c62 - languageName: node - linkType: hard - -"libsodium-wrappers@npm:^0.7.9": - version: 0.7.9 - resolution: "libsodium-wrappers@npm:0.7.9" - dependencies: - libsodium: ^0.7.0 - checksum: b5b1b9e1b4aa5662e07df244934125f9e3cd2ba7fe0ec45191a5ffc822d22f4d2f6e09e42d91c30c4f48ca0c7f810a176fdf5e32eed6722d7d82a2a719459f56 - languageName: node - linkType: hard - -"libsodium@npm:^0.7.0, libsodium@npm:^0.7.9": - version: 0.7.9 - resolution: "libsodium@npm:0.7.9" - checksum: 1c922c9972cf97ddb7207ee4f810dd291e0610dd57ea0e47f2343968392546aaa629945a2fb39ae5f19d067f6ed0bb7330f32cc9a680a847a662e9a210ce7bfb - languageName: node - linkType: hard - -"lodash@npm:^4.17.15": - version: 4.17.21 - resolution: "lodash@npm:4.17.21" - checksum: eb835a2e51d381e561e508ce932ea50a8e5a68f4ebdd771ea240d3048244a8d13658acbd502cd4829768c56f2e16bdd4340b9ea141297d472517b83868e677f7 - languageName: node - linkType: hard - -"long@npm:^4.0.0": - version: 4.0.0 - resolution: "long@npm:4.0.0" - checksum: 16afbe8f749c7c849db1f4de4e2e6a31ac6e617cead3bdc4f9605cb703cd20e1e9fc1a7baba674ffcca57d660a6e5b53a9e236d7b25a295d3855cca79cc06744 - languageName: node - linkType: hard - -"lru-cache@npm:^6.0.0": - version: 6.0.0 - resolution: "lru-cache@npm:6.0.0" - dependencies: - yallist: ^4.0.0 - checksum: f97f499f898f23e4585742138a22f22526254fdba6d75d41a1c2526b3b6cc5747ef59c5612ba7375f42aca4f8461950e925ba08c991ead0651b4918b7c978297 - languageName: node - linkType: hard - -"lru-cache@npm:^7.7.1": - version: 7.10.1 - resolution: "lru-cache@npm:7.10.1" - checksum: e8b190d71ed0fcd7b29c71a3e9b01f851c92d1ef8865ff06b5581ca991db1e5e006920ed4da8b56da1910664ed51abfd76c46fb55e82ac252ff6c970ff910d72 - languageName: node - linkType: hard - -"make-fetch-happen@npm:^10.0.3": - version: 10.1.3 - resolution: "make-fetch-happen@npm:10.1.3" - dependencies: - agentkeepalive: ^4.2.1 - cacache: ^16.0.2 - http-cache-semantics: ^4.1.0 - http-proxy-agent: ^5.0.0 - https-proxy-agent: ^5.0.0 - is-lambda: ^1.0.1 - lru-cache: ^7.7.1 - minipass: ^3.1.6 - minipass-collect: ^1.0.2 - minipass-fetch: ^2.0.3 - minipass-flush: ^1.0.5 - minipass-pipeline: ^1.2.4 - negotiator: ^0.6.3 - promise-retry: ^2.0.1 - socks-proxy-agent: ^6.1.1 - ssri: ^9.0.0 - checksum: 14b9bc5fb65a1a1f53b4579c947d1ebdb18db71eb0b35a2eab612e9642a14127917528fe4ffb2c37aaa0d27dfd7507e4044e6e2e47b43985e8fa18722f535b8f - languageName: node - linkType: hard - -"minimatch@npm:^3.1.1": - version: 3.1.2 - resolution: "minimatch@npm:3.1.2" - dependencies: - brace-expansion: ^1.1.7 - checksum: c154e566406683e7bcb746e000b84d74465b3a832c45d59912b9b55cd50dee66e5c4b1e5566dba26154040e51672f9aa450a9aef0c97cfc7336b78b7afb9540a - languageName: node - linkType: hard - -"minimatch@npm:^5.0.1": - version: 5.1.0 - resolution: "minimatch@npm:5.1.0" - dependencies: - brace-expansion: ^2.0.1 - checksum: 15ce53d31a06361e8b7a629501b5c75491bc2b59712d53e802b1987121d91b433d73fcc5be92974fde66b2b51d8fb28d75a9ae900d249feb792bb1ba2a4f0a90 - languageName: node - linkType: hard - -"minipass-collect@npm:^1.0.2": - version: 1.0.2 - resolution: "minipass-collect@npm:1.0.2" - dependencies: - minipass: ^3.0.0 - checksum: 14df761028f3e47293aee72888f2657695ec66bd7d09cae7ad558da30415fdc4752bbfee66287dcc6fd5e6a2fa3466d6c484dc1cbd986525d9393b9523d97f10 - languageName: node - linkType: hard - -"minipass-fetch@npm:^2.0.3": - version: 2.1.0 - resolution: "minipass-fetch@npm:2.1.0" - dependencies: - encoding: ^0.1.13 - minipass: ^3.1.6 - minipass-sized: ^1.0.3 - minizlib: ^2.1.2 - dependenciesMeta: - encoding: - optional: true - checksum: 1334732859a3f7959ed22589bafd9c40384b885aebb5932328071c33f86b3eb181d54c86919675d1825ab5f1c8e4f328878c863873258d113c29d79a4b0c9c9f - languageName: node - linkType: hard - -"minipass-flush@npm:^1.0.5": - version: 1.0.5 - resolution: "minipass-flush@npm:1.0.5" - dependencies: - minipass: ^3.0.0 - checksum: 56269a0b22bad756a08a94b1ffc36b7c9c5de0735a4dd1ab2b06c066d795cfd1f0ac44a0fcae13eece5589b908ecddc867f04c745c7009be0b566421ea0944cf - languageName: node - linkType: hard - -"minipass-pipeline@npm:^1.2.4": - version: 1.2.4 - resolution: "minipass-pipeline@npm:1.2.4" - dependencies: - minipass: ^3.0.0 - checksum: b14240dac0d29823c3d5911c286069e36d0b81173d7bdf07a7e4a91ecdef92cdff4baaf31ea3746f1c61e0957f652e641223970870e2353593f382112257971b - languageName: node - linkType: hard - -"minipass-sized@npm:^1.0.3": - version: 1.0.3 - resolution: "minipass-sized@npm:1.0.3" - dependencies: - minipass: ^3.0.0 - checksum: 79076749fcacf21b5d16dd596d32c3b6bf4d6e62abb43868fac21674078505c8b15eaca4e47ed844985a4514854f917d78f588fcd029693709417d8f98b2bd60 - languageName: node - linkType: hard - -"minipass@npm:^3.0.0, minipass@npm:^3.1.1, minipass@npm:^3.1.6": - version: 3.1.6 - resolution: "minipass@npm:3.1.6" - dependencies: - yallist: ^4.0.0 - checksum: 57a04041413a3531a65062452cb5175f93383ef245d6f4a2961d34386eb9aa8ac11ac7f16f791f5e8bbaf1dfb1ef01596870c88e8822215db57aa591a5bb0a77 - languageName: node - linkType: hard - -"minizlib@npm:^2.1.1, minizlib@npm:^2.1.2": - version: 2.1.2 - resolution: "minizlib@npm:2.1.2" - dependencies: - minipass: ^3.0.0 - yallist: ^4.0.0 - checksum: f1fdeac0b07cf8f30fcf12f4b586795b97be856edea22b5e9072707be51fc95d41487faec3f265b42973a304fe3a64acd91a44a3826a963e37b37bafde0212c3 - languageName: node - linkType: hard - -"mkdirp@npm:^1.0.3, mkdirp@npm:^1.0.4": - version: 1.0.4 - resolution: "mkdirp@npm:1.0.4" - bin: - mkdirp: bin/cmd.js - checksum: a96865108c6c3b1b8e1d5e9f11843de1e077e57737602de1b82030815f311be11f96f09cce59bd5b903d0b29834733e5313f9301e3ed6d6f6fba2eae0df4298f - languageName: node - linkType: hard - -"ms@npm:2.1.2": - version: 2.1.2 - resolution: "ms@npm:2.1.2" - checksum: 673cdb2c3133eb050c745908d8ce632ed2c02d85640e2edb3ace856a2266a813b30c613569bf3354fdf4ea7d1a1494add3bfa95e2713baa27d0c2c71fc44f58f - languageName: node - linkType: hard - -"ms@npm:^2.0.0": - version: 2.1.3 - resolution: "ms@npm:2.1.3" - checksum: aa92de608021b242401676e35cfa5aa42dd70cbdc082b916da7fb925c542173e36bce97ea3e804923fe92c0ad991434e4a38327e15a1b5b5f945d66df615ae6d - languageName: node - linkType: hard - -"nanoid@npm:^3.1.30": - version: 3.2.0 - resolution: "nanoid@npm:3.2.0" - bin: - nanoid: bin/nanoid.cjs - checksum: 3d1d5a69fea84e538057cf64106e713931c4ef32af344068ecff153ff91252f39b0f2b472e09b0dfff43ac3cf520c92938d90e6455121fe93976e23660f4fccc - languageName: node - linkType: hard - -"negotiator@npm:^0.6.3": - version: 0.6.3 - resolution: "negotiator@npm:0.6.3" - checksum: b8ffeb1e262eff7968fc90a2b6767b04cfd9842582a9d0ece0af7049537266e7b2506dfb1d107a32f06dd849ab2aea834d5830f7f4d0e5cb7d36e1ae55d021d9 - languageName: node - linkType: hard - -"node-gyp@npm:latest": - version: 9.0.0 - resolution: "node-gyp@npm:9.0.0" - dependencies: - env-paths: ^2.2.0 - glob: ^7.1.4 - graceful-fs: ^4.2.6 - make-fetch-happen: ^10.0.3 - nopt: ^5.0.0 - npmlog: ^6.0.0 - rimraf: ^3.0.2 - semver: ^7.3.5 - tar: ^6.1.2 - which: ^2.0.2 - bin: - node-gyp: bin/node-gyp.js - checksum: 4d8ef8860f7e4f4d86c91db3f519d26ed5cc23b48fe54543e2afd86162b4acbd14f21de42a5db344525efb69a991e021b96a68c70c6e2d5f4a5cb770793da6d3 - languageName: node - linkType: hard - -"nopt@npm:^5.0.0": - version: 5.0.0 - resolution: "nopt@npm:5.0.0" - dependencies: - abbrev: 1 - bin: - nopt: bin/nopt.js - checksum: d35fdec187269503843924e0114c0c6533fb54bbf1620d0f28b4b60ba01712d6687f62565c55cc20a504eff0fbe5c63e22340c3fad549ad40469ffb611b04f2f - languageName: node - linkType: hard - -"npmlog@npm:^6.0.0": - version: 6.0.2 - resolution: "npmlog@npm:6.0.2" - dependencies: - are-we-there-yet: ^3.0.0 - console-control-strings: ^1.1.0 - gauge: ^4.0.3 - set-blocking: ^2.0.0 - checksum: ae238cd264a1c3f22091cdd9e2b106f684297d3c184f1146984ecbe18aaa86343953f26b9520dedd1b1372bc0316905b736c1932d778dbeb1fcf5a1001390e2a - languageName: node - linkType: hard - -"object-hash@npm:^1.3.1": - version: 1.3.1 - resolution: "object-hash@npm:1.3.1" - checksum: fdcb957a2f15a9060e30655a9f683ba1fc25dfb8809a73d32e9634bec385a2f1d686c707ac1e5f69fb773bc12df03fb64c77ce3faeed83e35f4eb1946cb1989e - languageName: node - linkType: hard - -"once@npm:^1.3.0": - version: 1.4.0 - resolution: "once@npm:1.4.0" - dependencies: - wrappy: 1 - checksum: cd0a88501333edd640d95f0d2700fbde6bff20b3d4d9bdc521bdd31af0656b5706570d6c6afe532045a20bb8dc0849f8332d6f2a416e0ba6d3d3b98806c7db68 - languageName: node - linkType: hard - -"p-map@npm:^4.0.0": - version: 4.0.0 - resolution: "p-map@npm:4.0.0" - dependencies: - aggregate-error: ^3.0.0 - checksum: cb0ab21ec0f32ddffd31dfc250e3afa61e103ef43d957cc45497afe37513634589316de4eb88abdfd969fe6410c22c0b93ab24328833b8eb1ccc087fc0442a1c - languageName: node - linkType: hard - -"path-is-absolute@npm:^1.0.0": - version: 1.0.1 - resolution: "path-is-absolute@npm:1.0.1" - checksum: 060840f92cf8effa293bcc1bea81281bd7d363731d214cbe5c227df207c34cd727430f70c6037b5159c8a870b9157cba65e775446b0ab06fd5ecc7e54615a3b8 - languageName: node - linkType: hard - -"path-parse@npm:^1.0.7": - version: 1.0.7 - resolution: "path-parse@npm:1.0.7" - checksum: 49abf3d81115642938a8700ec580da6e830dde670be21893c62f4e10bd7dd4c3742ddc603fe24f898cba7eb0c6bc1777f8d9ac14185d34540c6d4d80cd9cae8a - languageName: node - linkType: hard - -"pcm-player@npm:^0.0.11": - version: 0.0.11 - resolution: "pcm-player@npm:0.0.11" - checksum: 8b02471e5788f23dbc965e577656a36c77d8a92f9481ddacac2d46c52755653e61bdb7a41698cee1a29e9df0cf8d853495b3968551b025c601ac4a7bf8139d81 - languageName: node - linkType: hard - -"picocolors@npm:^1.0.0": - version: 1.0.0 - resolution: "picocolors@npm:1.0.0" - checksum: a2e8092dd86c8396bdba9f2b5481032848525b3dc295ce9b57896f931e63fc16f79805144321f72976383fc249584672a75cc18d6777c6b757603f372f745981 - languageName: node - linkType: hard - -"postcss@npm:^8.4.5": - version: 8.4.5 - resolution: "postcss@npm:8.4.5" - dependencies: - nanoid: ^3.1.30 - picocolors: ^1.0.0 - source-map-js: ^1.0.1 - checksum: b78abdd89c10f7b48f4bdcd376104a19d6e9c7495ab521729bdb3df315af6c211360e9f06887ad3bc0ab0f61a04b91d68ea11462997c79cced58b9ccd66fac07 - languageName: node - linkType: hard - -"prettier@npm:^2.5.1": - version: 2.5.1 - resolution: "prettier@npm:2.5.1" - bin: - prettier: bin-prettier.js - checksum: 21b9408476ea1c544b0e45d51ceb94a84789ff92095abb710942d780c862d0daebdb29972d47f6b4d0f7ebbfb0ffbf56cc2cfa3e3e9d1cca54864af185b15b66 - languageName: node - linkType: hard - -"promise-inflight@npm:^1.0.1": - version: 1.0.1 - resolution: "promise-inflight@npm:1.0.1" - checksum: 22749483091d2c594261517f4f80e05226d4d5ecc1fc917e1886929da56e22b5718b7f2a75f3807e7a7d471bc3be2907fe92e6e8f373ddf5c64bae35b5af3981 - languageName: node - linkType: hard - -"promise-retry@npm:^2.0.1": - version: 2.0.1 - resolution: "promise-retry@npm:2.0.1" - dependencies: - err-code: ^2.0.2 - retry: ^0.12.0 - checksum: f96a3f6d90b92b568a26f71e966cbbc0f63ab85ea6ff6c81284dc869b41510e6cdef99b6b65f9030f0db422bf7c96652a3fff9f2e8fb4a0f069d8f4430359429 - languageName: node - linkType: hard - -"protobufjs@npm:^6.8.8": - version: 6.11.2 - resolution: "protobufjs@npm:6.11.2" - dependencies: - "@protobufjs/aspromise": ^1.1.2 - "@protobufjs/base64": ^1.1.2 - "@protobufjs/codegen": ^2.0.4 - "@protobufjs/eventemitter": ^1.1.0 - "@protobufjs/fetch": ^1.1.0 - "@protobufjs/float": ^1.0.2 - "@protobufjs/inquire": ^1.1.0 - "@protobufjs/path": ^1.1.2 - "@protobufjs/pool": ^1.1.0 - "@protobufjs/utf8": ^1.1.0 - "@types/long": ^4.0.1 - "@types/node": ">=13.7.0" - long: ^4.0.0 - bin: - pbjs: bin/pbjs - pbts: bin/pbts - checksum: 80e9d9610c3eb66f9eae4e44a1ae30381cedb721b7d5f635d781fe4c507e2c77bb7c879addcd1dda79733d3ae589d9e66fd18d42baf99b35df7382a0f9920795 - languageName: node - linkType: hard - -"readable-stream@npm:^3.6.0": - version: 3.6.0 - resolution: "readable-stream@npm:3.6.0" - dependencies: - inherits: ^2.0.3 - string_decoder: ^1.1.1 - util-deprecate: ^1.0.1 - checksum: d4ea81502d3799439bb955a3a5d1d808592cf3133350ed352aeaa499647858b27b1c4013984900238b0873ec8d0d8defce72469fb7a83e61d53f5ad61cb80dc8 - languageName: node - linkType: hard - -"resolve@npm:^1.20.0": - version: 1.21.0 - resolution: "resolve@npm:1.21.0" - dependencies: - is-core-module: ^2.8.0 - path-parse: ^1.0.7 - supports-preserve-symlinks-flag: ^1.0.0 - bin: - resolve: bin/resolve - checksum: d7d9092a5c04a048bea16c7e5a2eb605ac3e8363a0cc5644de1fde17d5028e8d5f4343aab1d99bd327b98e91a66ea83e242718150c64dfedcb96e5e7aad6c4f5 - languageName: node - linkType: hard - -"resolve@patch:resolve@^1.20.0#~builtin": - version: 1.21.0 - resolution: "resolve@patch:resolve@npm%3A1.21.0#~builtin::version=1.21.0&hash=07638b" - dependencies: - is-core-module: ^2.8.0 - path-parse: ^1.0.7 - supports-preserve-symlinks-flag: ^1.0.0 - bin: - resolve: bin/resolve - checksum: a0a4d1f7409e73190f31f901f8a619960bb3bd4ae38ba3a54c7ea7e1c87758d28a73256bb8d6a35996a903d1bf14f53883f0dcac6c571c063cb8162d813ad26e - languageName: node - linkType: hard - -"retry@npm:^0.12.0": - version: 0.12.0 - resolution: "retry@npm:0.12.0" - checksum: 623bd7d2e5119467ba66202d733ec3c2e2e26568074923bc0585b6b99db14f357e79bdedb63cab56cec47491c4a0da7e6021a7465ca6dc4f481d3898fdd3158c - languageName: node - linkType: hard - -"rimraf@npm:^3.0.2": - version: 3.0.2 - resolution: "rimraf@npm:3.0.2" - dependencies: - glob: ^7.1.3 - bin: - rimraf: bin.js - checksum: 87f4164e396f0171b0a3386cc1877a817f572148ee13a7e113b238e48e8a9f2f31d009a92ec38a591ff1567d9662c6b67fd8818a2dbbaed74bc26a87a2a4a9a0 - languageName: node - linkType: hard - -"rollup@npm:^2.59.0": - version: 2.64.0 - resolution: "rollup@npm:2.64.0" - dependencies: - fsevents: ~2.3.2 - dependenciesMeta: - fsevents: - optional: true - bin: - rollup: dist/bin/rollup - checksum: dc5b28538002ed635ea54af4c2ced05c52146322c61dbe0e84f294ee62e4f232a15760fdcef9bbeb742883edf9bf093ace5389bbdd816d18b9f5740555135180 - languageName: node - linkType: hard - -"safe-buffer@npm:~5.2.0": - version: 5.2.1 - resolution: "safe-buffer@npm:5.2.1" - checksum: b99c4b41fdd67a6aaf280fcd05e9ffb0813654894223afb78a31f14a19ad220bba8aba1cb14eddce1fcfb037155fe6de4e861784eb434f7d11ed58d1e70dd491 - languageName: node - linkType: hard - -"safer-buffer@npm:>= 2.1.2 < 3.0.0": - version: 2.1.2 - resolution: "safer-buffer@npm:2.1.2" - checksum: cab8f25ae6f1434abee8d80023d7e72b598cf1327164ddab31003c51215526801e40b66c5e65d658a0af1e9d6478cadcb4c745f4bd6751f97d8644786c0978b0 - languageName: node - linkType: hard - -"semver@npm:^7.3.5": - version: 7.3.7 - resolution: "semver@npm:7.3.7" - dependencies: - lru-cache: ^6.0.0 - bin: - semver: bin/semver.js - checksum: 2fa3e877568cd6ce769c75c211beaed1f9fce80b28338cadd9d0b6c40f2e2862bafd62c19a6cff42f3d54292b7c623277bcab8816a2b5521cf15210d43e75232 - languageName: node - linkType: hard - -"set-blocking@npm:^2.0.0": - version: 2.0.0 - resolution: "set-blocking@npm:2.0.0" - checksum: 6e65a05f7cf7ebdf8b7c75b101e18c0b7e3dff4940d480efed8aad3a36a4005140b660fa1d804cb8bce911cac290441dc728084a30504d3516ac2ff7ad607b02 - languageName: node - linkType: hard - -"signal-exit@npm:^3.0.7": - version: 3.0.7 - resolution: "signal-exit@npm:3.0.7" - checksum: a2f098f247adc367dffc27845853e9959b9e88b01cb301658cfe4194352d8d2bb32e18467c786a7fe15f1d44b233ea35633d076d5e737870b7139949d1ab6318 - languageName: node - linkType: hard - -"smart-buffer@npm:^4.2.0": - version: 4.2.0 - resolution: "smart-buffer@npm:4.2.0" - checksum: b5167a7142c1da704c0e3af85c402002b597081dd9575031a90b4f229ca5678e9a36e8a374f1814c8156a725d17008ae3bde63b92f9cfd132526379e580bec8b - languageName: node - linkType: hard - -"socks-proxy-agent@npm:^6.1.1": - version: 6.2.0 - resolution: "socks-proxy-agent@npm:6.2.0" - dependencies: - agent-base: ^6.0.2 - debug: ^4.3.3 - socks: ^2.6.2 - checksum: 6723fd64fb50334e2b340fd0a80fd8488ffc5bc43d85b7cf1d25612044f814dd7d6ea417fd47602159941236f7f4bd15669fa5d7e1f852598a31288e1a43967b - languageName: node - linkType: hard - -"socks@npm:^2.6.2": - version: 2.6.2 - resolution: "socks@npm:2.6.2" - dependencies: - ip: ^1.1.5 - smart-buffer: ^4.2.0 - checksum: dd9194293059d737759d5c69273850ad4149f448426249325c4bea0e340d1cf3d266c3b022694b0dcf5d31f759de23657244c481fc1e8322add80b7985c36b5e - languageName: node - linkType: hard - -"source-map-js@npm:^1.0.1": - version: 1.0.1 - resolution: "source-map-js@npm:1.0.1" - checksum: 22606113d62bbd468712b0cb0c46e9a8629de7eb081049c62a04d977a211abafd7d61455617f8b78daba0b6c0c7e7c88f8c6b5aaeacffac0a6676ecf5caac5ce - languageName: node - linkType: hard - -"ssri@npm:^9.0.0": - version: 9.0.0 - resolution: "ssri@npm:9.0.0" - dependencies: - minipass: ^3.1.1 - checksum: bf33174232d07cc64e77ab1c51b55d28352273380c503d35642a19627e88a2c5f160039bb0a28608a353485075dda084dbf0390c7070f9f284559eb71d01b84b - languageName: node - linkType: hard - -"string-width@npm:^1.0.2 || 2 || 3 || 4, string-width@npm:^4.2.3": - version: 4.2.3 - resolution: "string-width@npm:4.2.3" - dependencies: - emoji-regex: ^8.0.0 - is-fullwidth-code-point: ^3.0.0 - strip-ansi: ^6.0.1 - checksum: e52c10dc3fbfcd6c3a15f159f54a90024241d0f149cf8aed2982a2d801d2e64df0bf1dc351cf8e95c3319323f9f220c16e740b06faecd53e2462df1d2b5443fb - languageName: node - linkType: hard - -"string_decoder@npm:^1.1.1": - version: 1.3.0 - resolution: "string_decoder@npm:1.3.0" - dependencies: - safe-buffer: ~5.2.0 - checksum: 8417646695a66e73aefc4420eb3b84cc9ffd89572861fe004e6aeb13c7bc00e2f616247505d2dbbef24247c372f70268f594af7126f43548565c68c117bdeb56 - languageName: node - linkType: hard - -"strip-ansi@npm:^6.0.1": - version: 6.0.1 - resolution: "strip-ansi@npm:6.0.1" - dependencies: - ansi-regex: ^5.0.1 - checksum: f3cd25890aef3ba6e1a74e20896c21a46f482e93df4a06567cebf2b57edabb15133f1f94e57434e0a958d61186087b1008e89c94875d019910a213181a14fc8c - languageName: node - linkType: hard - -"supports-preserve-symlinks-flag@npm:^1.0.0": - version: 1.0.0 - resolution: "supports-preserve-symlinks-flag@npm:1.0.0" - checksum: 53b1e247e68e05db7b3808b99b892bd36fb096e6fba213a06da7fab22045e97597db425c724f2bbd6c99a3c295e1e73f3e4de78592289f38431049e1277ca0ae - languageName: node - linkType: hard - -"tar@npm:^6.1.11, tar@npm:^6.1.2": - version: 6.1.11 - resolution: "tar@npm:6.1.11" - dependencies: - chownr: ^2.0.0 - fs-minipass: ^2.0.0 - minipass: ^3.0.0 - minizlib: ^2.1.1 - mkdirp: ^1.0.3 - yallist: ^4.0.0 - checksum: a04c07bb9e2d8f46776517d4618f2406fb977a74d914ad98b264fc3db0fe8224da5bec11e5f8902c5b9bcb8ace22d95fbe3c7b36b8593b7dfc8391a25898f32f - languageName: node - linkType: hard - -"ts-poet@npm:^4.5.0": - version: 4.10.0 - resolution: "ts-poet@npm:4.10.0" - dependencies: - lodash: ^4.17.15 - prettier: ^2.5.1 - checksum: ffb3890a429f7ab59d96a7a17d9cc161bce786695af0fd77156e5779cfaeda92eaae4f15995a8c71a83cfb528d61bd2518bae19c4adec147bff8dce6f27c57d3 - languageName: node - linkType: hard - -"ts-proto-descriptors@npm:^1.2.1": - version: 1.3.1 - resolution: "ts-proto-descriptors@npm:1.3.1" - dependencies: - long: ^4.0.0 - protobufjs: ^6.8.8 - checksum: ef8acf9231375dd00cfa667c688746ae24fb8012a3875d1447cb6a6e9e0311150681719072716f58a24b1df801bcc35e56faca11ea4bac1f8146038b524b93c4 - languageName: node - linkType: hard - -"ts-proto@npm:^1.101.0": - version: 1.101.0 - resolution: "ts-proto@npm:1.101.0" - dependencies: - "@types/object-hash": ^1.3.0 - dataloader: ^1.4.0 - object-hash: ^1.3.1 - protobufjs: ^6.8.8 - ts-poet: ^4.5.0 - ts-proto-descriptors: ^1.2.1 - bin: - protoc-gen-ts_proto: protoc-gen-ts_proto - checksum: d404e34cad4fc5fb19271f7f257ff177d0ebac22ceca3b287927566a3ecda2f350b8592851d27415f6ec645525eae4ab40291ce3a6a3e151bb004478a1fe634a - languageName: node - linkType: hard - -"typescript@npm:^4.4.4": - version: 4.5.4 - resolution: "typescript@npm:4.5.4" - bin: - tsc: bin/tsc - tsserver: bin/tsserver - checksum: 59f3243f9cd6fe3161e6150ff6bf795fc843b4234a655dbd938a310515e0d61afd1ac942799e7415e4334255e41c2c49b7dd5d9fd38a17acd25a6779ca7e0961 - languageName: node - linkType: hard - -"typescript@patch:typescript@^4.4.4#~builtin": - version: 4.5.4 - resolution: "typescript@patch:typescript@npm%3A4.5.4#~builtin::version=4.5.4&hash=bda367" - bin: - tsc: bin/tsc - tsserver: bin/tsserver - checksum: eda87927f9cfb94aca9b5e47842daf37347ad3073133e17f556fbb6c18f3493c5b551eedab0f4b26774235ddb7acbe0087250d5285f72ce6819a0891dd5a74ed - languageName: node - linkType: hard - -"unique-filename@npm:^1.1.1": - version: 1.1.1 - resolution: "unique-filename@npm:1.1.1" - dependencies: - unique-slug: ^2.0.0 - checksum: cf4998c9228cc7647ba7814e255dec51be43673903897b1786eff2ac2d670f54d4d733357eb08dea969aa5e6875d0e1bd391d668fbdb5a179744e7c7551a6f80 - languageName: node - linkType: hard - -"unique-slug@npm:^2.0.0": - version: 2.0.2 - resolution: "unique-slug@npm:2.0.2" - dependencies: - imurmurhash: ^0.1.4 - checksum: 5b6876a645da08d505dedb970d1571f6cebdf87044cb6b740c8dbb24f0d6e1dc8bdbf46825fd09f994d7cf50760e6f6e063cfa197d51c5902c00a861702eb75a - languageName: node - linkType: hard - -"util-deprecate@npm:^1.0.1": - version: 1.0.2 - resolution: "util-deprecate@npm:1.0.2" - checksum: 474acf1146cb2701fe3b074892217553dfcf9a031280919ba1b8d651a068c9b15d863b7303cb15bd00a862b498e6cf4ad7b4a08fb134edd5a6f7641681cb54a2 - languageName: node - linkType: hard - -"vite@npm:^2.7.2": - version: 2.7.12 - resolution: "vite@npm:2.7.12" - dependencies: - esbuild: ^0.13.12 - fsevents: ~2.3.2 - postcss: ^8.4.5 - resolve: ^1.20.0 - rollup: ^2.59.0 - peerDependencies: - less: "*" - sass: "*" - stylus: "*" - dependenciesMeta: - fsevents: - optional: true - peerDependenciesMeta: - less: - optional: true - sass: - optional: true - stylus: - optional: true - bin: - vite: bin/vite.js - checksum: 56d62ae8131b02891f2dbd81f26a3ca28a02bfe390f9cb4e0c2d8dc831c2e2f8264dd3c45b14c7dd48e79d83d323a35148f92729e1f3385fae04fcd691f3f985 - languageName: node - linkType: hard - -"wasm-feature-detect@npm:^1.2.11": - version: 1.2.11 - resolution: "wasm-feature-detect@npm:1.2.11" - checksum: e7f28f5e6ca0722ba059e200c47a944ebd7570027a3ac5600b7178ee9bf950fe5280a68b5e3b5f29930407cc1214695ca10ea36a3d995d3445f4e34db58a8505 - languageName: node - linkType: hard - -"web_hbb@workspace:.": - version: 0.0.0-use.local - resolution: "web_hbb@workspace:." - dependencies: - fast-sha256: ^1.3.0 - libsodium: ^0.7.9 - libsodium-wrappers: ^0.7.9 - pcm-player: ^0.0.11 - ts-proto: ^1.101.0 - typescript: ^4.4.4 - vite: ^2.7.2 - wasm-feature-detect: ^1.2.11 - zstddec: ^0.0.2 - languageName: unknown - linkType: soft - -"which@npm:^2.0.2": - version: 2.0.2 - resolution: "which@npm:2.0.2" - dependencies: - isexe: ^2.0.0 - bin: - node-which: ./bin/node-which - checksum: 1a5c563d3c1b52d5f893c8b61afe11abc3bab4afac492e8da5bde69d550de701cf9806235f20a47b5c8fa8a1d6a9135841de2596535e998027a54589000e66d1 - languageName: node - linkType: hard - -"wide-align@npm:^1.1.5": - version: 1.1.5 - resolution: "wide-align@npm:1.1.5" - dependencies: - string-width: ^1.0.2 || 2 || 3 || 4 - checksum: d5fc37cd561f9daee3c80e03b92ed3e84d80dde3365a8767263d03dacfc8fa06b065ffe1df00d8c2a09f731482fcacae745abfbb478d4af36d0a891fad4834d3 - languageName: node - linkType: hard - -"wrappy@npm:1": - version: 1.0.2 - resolution: "wrappy@npm:1.0.2" - checksum: 159da4805f7e84a3d003d8841557196034155008f817172d4e986bd591f74aa82aa7db55929a54222309e01079a65a92a9e6414da5a6aa4b01ee44a511ac3ee5 - languageName: node - linkType: hard - -"yallist@npm:^4.0.0": - version: 4.0.0 - resolution: "yallist@npm:4.0.0" - checksum: 343617202af32df2a15a3be36a5a8c0c8545208f3d3dfbc6bb7c3e3b7e8c6f8e7485432e4f3b88da3031a6e20afa7c711eded32ddfb122896ac5d914e75848d5 - languageName: node - linkType: hard - -"zstddec@npm:^0.0.2": - version: 0.0.2 - resolution: "zstddec@npm:0.0.2" - checksum: 107334442a34590173cda03614006337712658fd043fa79f72bd486de527e2a16da474d7b20d4a171f086b334c2ad8a72afb634776d79bc2c36aee065babe31b - languageName: node - linkType: hard diff --git a/flutter/web/v1/libs/firebase-analytics.js b/flutter/web/v1/libs/firebase-analytics.js deleted file mode 100644 index 9b9a02b09..000000000 --- a/flutter/web/v1/libs/firebase-analytics.js +++ /dev/null @@ -1,2 +0,0 @@ -!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(require("@firebase/app")):"function"==typeof define&&define.amd?define(["@firebase/app"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).firebase)}(this,function(mt){"use strict";try{!function(){function e(e){return e&&"object"==typeof e&&"default"in e?e:{default:e}}var t=e(mt),r=function(e,t){return(r=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(e,t){e.__proto__=t}||function(e,t){for(var n in t)Object.prototype.hasOwnProperty.call(t,n)&&(e[n]=t[n])})(e,t)};var s=function(){return(s=Object.assign||function(e){for(var t,n=1,r=arguments.length;na[0]&&t[1]=e.length?void 0:e)&&e[r++],done:!e}}};throw new TypeError(t?"Object is not iterable.":"Symbol.iterator is not defined.")}function u(e,t){for(var n=0,r=t.length,i=e.length;n"})):"Error",e=this.serviceName+": "+e+" ("+o+").";return new f(o,e,i)},n);function n(e,t,n){this.service=e,this.serviceName=t,this.errors=n}var y=/\{\$([^}]+)}/g,b=1e3,w=2,I=144e5,_=.5;function E(e,t,n){void 0===n&&(n=w);n=(t=void 0===t?b:t)*Math.pow(n,e),e=Math.round(_*n*(Math.random()-.5)*2);return Math.min(I,n+e)}var T=(S.prototype.setInstantiationMode=function(e){return this.instantiationMode=e,this},S.prototype.setMultipleInstances=function(e){return this.multipleInstances=e,this},S.prototype.setServiceProps=function(e){return this.serviceProps=e,this},S.prototype.setInstanceCreatedCallback=function(e){return this.onInstanceCreated=e,this},S);function S(e,t,n){this.name=e,this.instanceFactory=t,this.type=n,this.multipleInstances=!1,this.serviceProps={},this.instantiationMode="LAZY",this.onInstanceCreated=null}function C(n){return new Promise(function(e,t){n.onsuccess=function(){e(n.result)},n.onerror=function(){t(n.error)}})}function O(n,r,i){var o,e=new Promise(function(e,t){C(o=n[r].apply(n,i)).then(e,t)});return e.request=o,e}function N(e,n,t){t.forEach(function(t){Object.defineProperty(e.prototype,t,{get:function(){return this[n][t]},set:function(e){this[n][t]=e}})})}function D(t,n,r,e){e.forEach(function(e){e in r.prototype&&(t.prototype[e]=function(){return O(this[n],e,arguments)})})}function P(t,n,r,e){e.forEach(function(e){e in r.prototype&&(t.prototype[e]=function(){return this[n][e].apply(this[n],arguments)})})}function A(e,r,t,n){n.forEach(function(n){n in t.prototype&&(e.prototype[n]=function(){return e=this[r],(t=O(e,n,arguments)).then(function(e){if(e)return new k(e,t.request)});var e,t})})}function x(e){this._index=e}function k(e,t){this._cursor=e,this._request=t}function j(e){this._store=e}function L(n){this._tx=n,this.complete=new Promise(function(e,t){n.oncomplete=function(){e()},n.onerror=function(){t(n.error)},n.onabort=function(){t(n.error)}})}function R(e,t,n){this._db=e,this.oldVersion=t,this.transaction=new L(n)}function F(e){this._db=e}N(x,"_index",["name","keyPath","multiEntry","unique"]),D(x,"_index",IDBIndex,["get","getKey","getAll","getAllKeys","count"]),A(x,"_index",IDBIndex,["openCursor","openKeyCursor"]),N(k,"_cursor",["direction","key","primaryKey","value"]),D(k,"_cursor",IDBCursor,["update","delete"]),["advance","continue","continuePrimaryKey"].forEach(function(n){n in IDBCursor.prototype&&(k.prototype[n]=function(){var t=this,e=arguments;return Promise.resolve().then(function(){return t._cursor[n].apply(t._cursor,e),C(t._request).then(function(e){if(e)return new k(e,t._request)})})})}),j.prototype.createIndex=function(){return new x(this._store.createIndex.apply(this._store,arguments))},j.prototype.index=function(){return new x(this._store.index.apply(this._store,arguments))},N(j,"_store",["name","keyPath","indexNames","autoIncrement"]),D(j,"_store",IDBObjectStore,["put","add","delete","clear","get","getAll","getKey","getAllKeys","count"]),A(j,"_store",IDBObjectStore,["openCursor","openKeyCursor"]),P(j,"_store",IDBObjectStore,["deleteIndex"]),L.prototype.objectStore=function(){return new j(this._tx.objectStore.apply(this._tx,arguments))},N(L,"_tx",["objectStoreNames","mode"]),P(L,"_tx",IDBTransaction,["abort"]),R.prototype.createObjectStore=function(){return new j(this._db.createObjectStore.apply(this._db,arguments))},N(R,"_db",["name","version","objectStoreNames"]),P(R,"_db",IDBDatabase,["deleteObjectStore","close"]),F.prototype.transaction=function(){return new L(this._db.transaction.apply(this._db,arguments))},N(F,"_db",["name","version","objectStoreNames"]),P(F,"_db",IDBDatabase,["close"]),["openCursor","openKeyCursor"].forEach(function(i){[j,x].forEach(function(e){i in e.prototype&&(e.prototype[i.replace("open","iterate")]=function(){var e=(n=arguments,Array.prototype.slice.call(n)),t=e[e.length-1],n=this._store||this._index,r=n[i].apply(n,e.slice(0,-1));r.onsuccess=function(){t(r.result)}})})}),[x,j].forEach(function(e){e.prototype.getAll||(e.prototype.getAll=function(e,n){var r=this,i=[];return new Promise(function(t){r.iterateCursor(e,function(e){e?(i.push(e.value),void 0===n||i.length!=n?e.continue():t(i)):t(i)})})})});var M="0.4.32",B=1e4,H="w:"+M,q="FIS_v2",V="https://firebaseinstallations.googleapis.com/v1",G=36e5,K=((Re={})["missing-app-config-values"]='Missing App configuration value: "{$valueName}"',Re["not-registered"]="Firebase Installation is not registered.",Re["installation-not-found"]="Firebase Installation not found.",Re["request-failed"]='{$requestName} request failed with error "{$serverCode} {$serverStatus}: {$serverMessage}"',Re["app-offline"]="Could not process request. Application offline.",Re["delete-pending-registration"]="Can't delete installation while there is a pending registration request.",Re),U=new m("installations","Installations",K);function W(e){return e instanceof f&&e.code.includes("request-failed")}function $(e){e=e.projectId;return V+"/projects/"+e+"/installations"}function z(e){return{token:e.token,requestStatus:2,expiresIn:(e=e.expiresIn,Number(e.replace("s","000"))),creationTime:Date.now()}}function J(n,r){return p(this,void 0,void 0,function(){var t;return h(this,function(e){switch(e.label){case 0:return[4,r.json()];case 1:return t=e.sent(),t=t.error,[2,U.create("request-failed",{requestName:n,serverCode:t.code,serverMessage:t.message,serverStatus:t.status})]}})})}function Y(e){e=e.apiKey;return new Headers({"Content-Type":"application/json",Accept:"application/json","x-goog-api-key":e})}function X(e,t){t=t.refreshToken,e=Y(e);return e.append("Authorization",q+" "+t),e}function Z(n){return p(this,void 0,void 0,function(){var t;return h(this,function(e){switch(e.label){case 0:return[4,n()];case 1:return 500<=(t=e.sent()).status&&t.status<600?[2,n()]:[2,t]}})})}function Q(t){return new Promise(function(e){setTimeout(e,t)})}function ee(e){return btoa(String.fromCharCode.apply(String,u([],function(e,t){var n="function"==typeof Symbol&&e[Symbol.iterator];if(!n)return e;var r,i,o=n.call(e),a=[];try{for(;(void 0===t||0a[0]&&t[1]=e.length?void 0:e)&&e[r++],done:!e}}};throw new TypeError(t?"Object is not iterable.":"Symbol.iterator is not defined.")}function f(e,t){var n="function"==typeof Symbol&&e[Symbol.iterator];if(!n)return e;var r,i,o=n.call(e),a=[];try{for(;(void 0===t||0"})):"Error",e=this.serviceName+": "+e+" ("+o+").";return new c(o,e,i)},v);function v(e,t,n){this.service=e,this.serviceName=t,this.errors=n}var m=/\{\$([^}]+)}/g;function y(e,t){return Object.prototype.hasOwnProperty.call(e,t)}function g(e,t){t=new b(e,t);return t.subscribe.bind(t)}var b=(I.prototype.next=function(t){this.forEachObserver(function(e){e.next(t)})},I.prototype.error=function(t){this.forEachObserver(function(e){e.error(t)}),this.close(t)},I.prototype.complete=function(){this.forEachObserver(function(e){e.complete()}),this.close()},I.prototype.subscribe=function(e,t,n){var r,i=this;if(void 0===e&&void 0===t&&void 0===n)throw new Error("Missing Observer.");void 0===(r=function(e,t){if("object"!=typeof e||null===e)return!1;for(var n=0,r=t;n=(null!=o?o:e.logLevel)&&a({level:R[t].toLowerCase(),message:i,args:n,type:e.name})}}(n[e])}var H=((H={})["no-app"]="No Firebase App '{$appName}' has been created - call Firebase App.initializeApp()",H["bad-app-name"]="Illegal App name: '{$appName}",H["duplicate-app"]="Firebase App named '{$appName}' already exists",H["app-deleted"]="Firebase App named '{$appName}' already deleted",H["invalid-app-argument"]="firebase.{$appName}() takes either no argument or a Firebase App instance.",H["invalid-log-argument"]="First argument to `onLog` must be null or a function.",H),V=new d("app","Firebase",H),B="@firebase/app",M="[DEFAULT]",U=((H={})[B]="fire-core",H["@firebase/analytics"]="fire-analytics",H["@firebase/app-check"]="fire-app-check",H["@firebase/auth"]="fire-auth",H["@firebase/database"]="fire-rtdb",H["@firebase/functions"]="fire-fn",H["@firebase/installations"]="fire-iid",H["@firebase/messaging"]="fire-fcm",H["@firebase/performance"]="fire-perf",H["@firebase/remote-config"]="fire-rc",H["@firebase/storage"]="fire-gcs",H["@firebase/firestore"]="fire-fst",H["fire-js"]="fire-js",H["firebase-wrapper"]="fire-js-all",H),W=new z("@firebase/app"),G=(Object.defineProperty($.prototype,"automaticDataCollectionEnabled",{get:function(){return this.checkDestroyed_(),this.automaticDataCollectionEnabled_},set:function(e){this.checkDestroyed_(),this.automaticDataCollectionEnabled_=e},enumerable:!1,configurable:!0}),Object.defineProperty($.prototype,"name",{get:function(){return this.checkDestroyed_(),this.name_},enumerable:!1,configurable:!0}),Object.defineProperty($.prototype,"options",{get:function(){return this.checkDestroyed_(),this.options_},enumerable:!1,configurable:!0}),$.prototype.delete=function(){var t=this;return new Promise(function(e){t.checkDestroyed_(),e()}).then(function(){return t.firebase_.INTERNAL.removeApp(t.name_),Promise.all(t.container.getProviders().map(function(e){return e.delete()}))}).then(function(){t.isDeleted_=!0})},$.prototype._getService=function(e,t){void 0===t&&(t=M),this.checkDestroyed_();var n=this.container.getProvider(e);return n.isInitialized()||"EXPLICIT"!==(null===(e=n.getComponent())||void 0===e?void 0:e.instantiationMode)||n.initialize(),n.getImmediate({identifier:t})},$.prototype._removeServiceInstance=function(e,t){void 0===t&&(t=M),this.container.getProvider(e).clearInstance(t)},$.prototype._addComponent=function(t){try{this.container.addComponent(t)}catch(e){W.debug("Component "+t.name+" failed to register with FirebaseApp "+this.name,e)}},$.prototype._addOrOverwriteComponent=function(e){this.container.addOrOverwriteComponent(e)},$.prototype.toJSON=function(){return{name:this.name,automaticDataCollectionEnabled:this.automaticDataCollectionEnabled,options:this.options}},$.prototype.checkDestroyed_=function(){if(this.isDeleted_)throw V.create("app-deleted",{appName:this.name_})},$);function $(e,t,n){var r=this;this.firebase_=n,this.isDeleted_=!1,this.name_=t.name,this.automaticDataCollectionEnabled_=t.automaticDataCollectionEnabled||!1,this.options_=h(void 0,e),this.container=new S(t.name),this._addComponent(new O("app",function(){return r},"PUBLIC")),this.firebase_.INTERNAL.components.forEach(function(e){return r._addComponent(e)})}G.prototype.name&&G.prototype.options||G.prototype.delete||console.log("dc");var K="8.10.1";function Y(a){var s={},l=new Map,c={__esModule:!0,initializeApp:function(e,t){void 0===t&&(t={});"object"==typeof t&&null!==t||(t={name:t});var n=t;void 0===n.name&&(n.name=M);t=n.name;if("string"!=typeof t||!t)throw V.create("bad-app-name",{appName:String(t)});if(y(s,t))throw V.create("duplicate-app",{appName:t});n=new a(e,n,c);return s[t]=n},app:u,registerVersion:function(e,t,n){var r=null!==(i=U[e])&&void 0!==i?i:e;n&&(r+="-"+n);var i=r.match(/\s|\//),e=t.match(/\s|\//);i||e?(n=['Unable to register library "'+r+'" with version "'+t+'":'],i&&n.push('library name "'+r+'" contains illegal characters (whitespace or "/")'),i&&e&&n.push("and"),e&&n.push('version name "'+t+'" contains illegal characters (whitespace or "/")'),W.warn(n.join(" "))):o(new O(r+"-version",function(){return{library:r,version:t}},"VERSION"))},setLogLevel:T,onLog:function(e,t){if(null!==e&&"function"!=typeof e)throw V.create("invalid-log-argument");x(e,t)},apps:null,SDK_VERSION:K,INTERNAL:{registerComponent:o,removeApp:function(e){delete s[e]},components:l,useAsService:function(e,t){return"serverAuth"!==t?t:null}}};function u(e){if(!y(s,e=e||M))throw V.create("no-app",{appName:e});return s[e]}function o(n){var e,r=n.name;if(l.has(r))return W.debug("There were multiple attempts to register component "+r+"."),"PUBLIC"===n.type?c[r]:null;l.set(r,n),"PUBLIC"===n.type&&(e=function(e){if("function"!=typeof(e=void 0===e?u():e)[r])throw V.create("invalid-app-argument",{appName:r});return e[r]()},void 0!==n.serviceProps&&h(e,n.serviceProps),c[r]=e,a.prototype[r]=function(){for(var e=[],t=0;t 30) { - console.log('yuv: ' + parseInt('' + testSpeed[1] / testSpeed[0])); - testSpeed = [0, 0]; - } - return out; -} - -var currentFrame; -self.addEventListener('message', (e) => { - currentFrame = e.data; -}); - -function run() { - if (currentFrame) { - self.postMessage(I420ToARGB(currentFrame)); - currentFrame = undefined; - } - setTimeout(run, 1); -} - -run(); \ No newline at end of file diff --git a/flutter/web/v1/yuv.wasm b/flutter/web/v1/yuv.wasm deleted file mode 100644 index f203c685c..000000000 Binary files a/flutter/web/v1/yuv.wasm and /dev/null differ diff --git a/flutter/web/v2/README.md b/flutter/web/v2/README.md deleted file mode 100644 index 7c128776c..000000000 --- a/flutter/web/v2/README.md +++ /dev/null @@ -1 +0,0 @@ -Under dev. \ No newline at end of file diff --git a/flutter/windows/runner/CMakeLists.txt b/flutter/windows/runner/CMakeLists.txt index 17411a8ab..2dbf0a973 100644 --- a/flutter/windows/runner/CMakeLists.txt +++ b/flutter/windows/runner/CMakeLists.txt @@ -10,6 +10,7 @@ add_executable(${BINARY_NAME} WIN32 "flutter_window.cpp" "main.cpp" "utils.cpp" + "win32_desktop.cpp" "win32_window.cpp" "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" "Runner.rc" diff --git a/flutter/windows/runner/Runner.rc b/flutter/windows/runner/Runner.rc index 7e0fe9b31..ab1b7e06f 100644 --- a/flutter/windows/runner/Runner.rc +++ b/flutter/windows/runner/Runner.rc @@ -93,7 +93,7 @@ BEGIN VALUE "FileDescription", "RustDesk Remote Desktop" "\0" VALUE "FileVersion", VERSION_AS_STRING "\0" VALUE "InternalName", "rustdesk" "\0" - VALUE "LegalCopyright", "Copyright © 2024 Purslane Ltd. All rights reserved." "\0" + VALUE "LegalCopyright", "Copyright © 2025 Purslane Ltd. All rights reserved." "\0" VALUE "OriginalFilename", "rustdesk.exe" "\0" VALUE "ProductName", "RustDesk" "\0" VALUE "ProductVersion", VERSION_AS_STRING "\0" diff --git a/flutter/windows/runner/flutter_window.cpp b/flutter/windows/runner/flutter_window.cpp index 3ccbdd4f4..903fb2fa0 100644 --- a/flutter/windows/runner/flutter_window.cpp +++ b/flutter/windows/runner/flutter_window.cpp @@ -1,13 +1,24 @@ #include "flutter_window.h" -#include - #include #include #include #include "flutter/generated_plugin_registrant.h" +#include +#include +#include +#include +#include + +#include + +#include +#include + +#include "win32_desktop.h" + FlutterWindow::FlutterWindow(const flutter::DartProject& project) : project_(project) {} @@ -29,6 +40,48 @@ bool FlutterWindow::OnCreate() { return false; } RegisterPlugins(flutter_controller_->engine()); + + flutter::MethodChannel<> channel( + flutter_controller_->engine()->messenger(), + "org.rustdesk.rustdesk/host", + &flutter::StandardMethodCodec::GetInstance()); + + channel.SetMethodCallHandler( + [](const flutter::MethodCall<>& call, std::unique_ptr> result) { + if (call.method_name() == "bumpMouse") { + auto arguments = call.arguments(); + + int dx = 0, dy = 0; + + if (std::holds_alternative(*arguments)) { + auto argsMap = std::get(*arguments); + + auto dxIt = argsMap.find(flutter::EncodableValue("dx")); + auto dyIt = argsMap.find(flutter::EncodableValue("dy")); + + if ((dxIt != argsMap.end()) && std::holds_alternative(dxIt->second)) { + dx = std::get(dxIt->second); + } + if ((dyIt != argsMap.end()) && std::holds_alternative(dyIt->second)) { + dy = std::get(dyIt->second); + } + } else if (std::holds_alternative(*arguments)) { + auto argsList = std::get(*arguments); + + if ((argsList.size() >= 1) && std::holds_alternative(argsList[0])) { + dx = std::get(argsList[0]); + } + if ((argsList.size() >= 2) && std::holds_alternative(argsList[1])) { + dy = std::get(argsList[1]); + } + } + + bool succeeded = Win32Desktop::BumpMouse(dx, dy); + + result->Success(succeeded); + } + }); + DesktopMultiWindowSetWindowCreatedCallback([](void *controller) { auto *flutter_view_controller = reinterpret_cast(controller); diff --git a/flutter/windows/runner/main.cpp b/flutter/windows/runner/main.cpp index 5c55d1c28..cd9f386b1 100644 --- a/flutter/windows/runner/main.cpp +++ b/flutter/windows/runner/main.cpp @@ -7,6 +7,7 @@ #include #include +#include "win32_desktop.h" #include "flutter_window.h" #include "utils.h" @@ -126,8 +127,22 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); FlutterWindow window(project); - Win32Window::Point origin(10, 10); - Win32Window::Size size(800, 600); + + // Get primary monitor's work area. + Win32Window::Point workarea_origin(0, 0); + Win32Window::Size workarea_size(0, 0); + + Win32Desktop::GetWorkArea(workarea_origin, workarea_size); + + // Compute window bounds for default main window position: (10, 10) x(800, 600) + Win32Window::Point relative_origin(10, 10); + + Win32Window::Point origin(workarea_origin.x + relative_origin.x, workarea_origin.y + relative_origin.y); + Win32Window::Size size(800u, 600u); + + // Fit the window to the monitor's work area. + Win32Desktop::FitToWorkArea(origin, size); + std::wstring window_title; if (is_cm_page) { window_title = app_name + L" - Connection Manager"; diff --git a/flutter/windows/runner/win32_desktop.cpp b/flutter/windows/runner/win32_desktop.cpp new file mode 100644 index 000000000..4274f6ec5 --- /dev/null +++ b/flutter/windows/runner/win32_desktop.cpp @@ -0,0 +1,82 @@ +#include "win32_desktop.h" + +#include + +#include + +namespace Win32Desktop +{ + void GetWorkArea(Win32Window::Point& origin, Win32Window::Size& size) + { + RECT windowRect; + + windowRect.left = origin.x; + windowRect.top = origin.y; + windowRect.right = origin.x + size.width; + windowRect.bottom = origin.y + size.height; + + HMONITOR hMonitor = MonitorFromRect(&windowRect, MONITOR_DEFAULTTONEAREST); + + if (hMonitor == NULL) + hMonitor = MonitorFromWindow(NULL, MONITOR_DEFAULTTOPRIMARY); + + RECT workAreaRect; + workAreaRect.left = 0; + workAreaRect.top = 0; + workAreaRect.right = 1280; + workAreaRect.bottom = 1024 - 40; // default Windows 10 task bar height + + if (hMonitor != NULL) + { + MONITORINFO monitorInfo = {0}; + + monitorInfo.cbSize = sizeof(monitorInfo); + + if (GetMonitorInfoW(hMonitor, &monitorInfo)) + { + workAreaRect = monitorInfo.rcWork; + } + } + + origin.x = workAreaRect.left; + origin.y = workAreaRect.top; + + size.width = workAreaRect.right - workAreaRect.left; + size.height = workAreaRect.bottom - workAreaRect.top; + } + + void FitToWorkArea(Win32Window::Point& origin, Win32Window::Size& size) + { + // Retrieve the work area of the monitor that contains or + // is closed to the supplied window bounds. + Win32Window::Point workarea_origin = origin; + Win32Window::Size workarea_size = size; + + GetWorkArea(workarea_origin, workarea_size); + + // Translate the window so that its top/left is inside the work area. + origin.x = std::max(origin.x, workarea_origin.x); + origin.y = std::max(origin.y, workarea_origin.y); + + // Crop the window if it extends past the bottom/right of the work area. + Win32Window::Point workarea_bottom_right( + workarea_origin.x + workarea_size.width, + workarea_origin.y + workarea_size.height); + + size.width = std::min(size.width, workarea_bottom_right.x - origin.x); + size.height = std::min(size.height, workarea_bottom_right.y - origin.y); + } + + bool BumpMouse(int dx, int dy) + { + POINT pos; + + if (GetCursorPos(&pos)) + { + SetCursorPos(pos.x + dx, pos.y + dy); + return true; + } + + return false; + } +} diff --git a/flutter/windows/runner/win32_desktop.h b/flutter/windows/runner/win32_desktop.h new file mode 100644 index 000000000..8a07478e5 --- /dev/null +++ b/flutter/windows/runner/win32_desktop.h @@ -0,0 +1,13 @@ +#ifndef RUNNER_WIN32_DESKTOP_H_ +#define RUNNER_WIN32_DESKTOP_H_ + +#include "win32_window.h" + +namespace Win32Desktop +{ + void GetWorkArea(Win32Window::Point& origin, Win32Window::Size& size); + void FitToWorkArea(Win32Window::Point& origin, Win32Window::Size& size); + bool BumpMouse(int dx, int dy); +} + +#endif // RUNNER_WIN32_DESKTOP_H_ diff --git a/flutter/windows/runner/win32_window.cpp b/flutter/windows/runner/win32_window.cpp index 2c25f00dd..606ef0aa3 100644 --- a/flutter/windows/runner/win32_window.cpp +++ b/flutter/windows/runner/win32_window.cpp @@ -7,6 +7,7 @@ #include // for getenv and _putenv #include // for strcmp +#include // for std::wstring namespace { @@ -15,6 +16,43 @@ constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; // The number of Win32Window objects that currently exist. static int g_active_window_count = 0; +// Static variable to hold the custom icon (needs cleanup on exit) +static HICON g_custom_icon_ = nullptr; + +// Try to load icon from data\flutter_assets\assets\icon.ico if it exists. +// Returns nullptr if the file doesn't exist or can't be loaded. +HICON LoadCustomIcon() { + if (g_custom_icon_ != nullptr) { + return g_custom_icon_; + } + wchar_t exe_path[MAX_PATH]; + if (!GetModuleFileNameW(nullptr, exe_path, MAX_PATH)) { + return nullptr; + } + + std::wstring icon_path = exe_path; + size_t last_slash = icon_path.find_last_of(L"\\/"); + if (last_slash == std::wstring::npos) { + return nullptr; + } + + icon_path = icon_path.substr(0, last_slash + 1); + icon_path += L"data\\flutter_assets\\assets\\icon.ico"; + + // Check file attributes - reject if missing, directory, or reparse point (symlink/junction) + DWORD file_attr = GetFileAttributesW(icon_path.c_str()); + if (file_attr == INVALID_FILE_ATTRIBUTES || + (file_attr & FILE_ATTRIBUTE_DIRECTORY) || + (file_attr & FILE_ATTRIBUTE_REPARSE_POINT)) { + return nullptr; + } + + g_custom_icon_ = (HICON)LoadImageW( + nullptr, icon_path.c_str(), IMAGE_ICON, 0, 0, + LR_LOADFROMFILE | LR_DEFAULTSIZE); + return g_custom_icon_; +} + using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); // Scale helper to convert logical scaler values to physical using passed in @@ -81,8 +119,16 @@ const wchar_t* WindowClassRegistrar::GetWindowClass() { window_class.cbClsExtra = 0; window_class.cbWndExtra = 0; window_class.hInstance = GetModuleHandle(nullptr); - window_class.hIcon = - LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + + // Try to load icon from data\flutter_assets\assets\icon.ico if it exists + HICON custom_icon = LoadCustomIcon(); + if (custom_icon != nullptr) { + window_class.hIcon = custom_icon; + } else { + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + } + window_class.hbrBackground = 0; window_class.lpszMenuName = nullptr; window_class.lpfnWndProc = Win32Window::WndProc; @@ -95,6 +141,12 @@ const wchar_t* WindowClassRegistrar::GetWindowClass() { void WindowClassRegistrar::UnregisterWindowClass() { UnregisterClass(kWindowClassName, nullptr); class_registered_ = false; + + // Clean up the custom icon if it was loaded + if (g_custom_icon_ != nullptr) { + DestroyIcon(g_custom_icon_); + g_custom_icon_ = nullptr; + } } Win32Window::Win32Window() { diff --git a/libs/clipboard/Cargo.toml b/libs/clipboard/Cargo.toml index c3673a9bd..afe2f2f31 100644 --- a/libs/clipboard/Cargo.toml +++ b/libs/clipboard/Cargo.toml @@ -34,7 +34,6 @@ parking_lot = {version = "0.12"} [target.'cfg(any(target_os = "linux", target_os = "macos"))'.dependencies] rand = {version = "0.8", optional = true} -fuser = {version = "0.13", optional = true} libc = {version = "0.2", optional = true} dashmap = {version ="5.5", optional = true} utf16string = {version = "0.2", optional = true} @@ -44,6 +43,15 @@ 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} [target.'cfg(target_os = "macos")'.dependencies] cacao = {git="https://github.com/clslaid/cacao", branch = "feat/set-file-urls", optional = true} +# Use `relax-void-encoding`, as that allows us to pass `c_void` instead of implementing `Encode` correctly for `&CGImageRef` +objc2 = { version = "0.5.1", features = ["relax-void-encoding"] } +objc2-foundation = { version = "0.2.0", features = ["NSArray", "NSString", "NSEnumerator", "NSGeometry", "NSProgress"] } +objc2-app-kit = { version = "0.2.0", features = ["NSPasteboard", "NSPasteboardItem", "NSImage", "NSFilePromiseProvider"] } +uuid = { version = "1.3", features = ["v4"] } +fsevent = "2.1.2" +dirs = "5.0" +xattr = "1.4.0" diff --git a/libs/clipboard/README.md b/libs/clipboard/README.md index 6333a0644..ec08cbf04 100644 --- a/libs/clipboard/README.md +++ b/libs/clipboard/README.md @@ -10,7 +10,7 @@ TODO: Move this lib to a separate project. ## How it works -Terminalogies: +Terminologies: - cliprdr: this module - local: the endpoint which initiates a file copy events @@ -50,7 +50,7 @@ sequenceDiagram r ->> l: Format List Response (notified) r ->> l: Format Data Request (requests file list) activate l - note left of l: Retrive file list from system clipboard + note left of l: Retrieve file list from system clipboard l ->> r: Format Data Response (containing file list) deactivate l note over r: Update system clipboard with received file list @@ -84,10 +84,10 @@ and copy files to remote. The protocol was originally designed as an extension of the Windows RDP, so the specific message packages fits windows well. -When starting cliprdr, a thread is spawn to create a invisible window +When starting cliprdr, a thread is spawned to create an invisible window and to subscribe to OLE clipboard events. The window's callback (see `cliprdr_proc` in `src/windows/wf_cliprdr.c`) was -set to handle a variaty of events. +set to handle a variety of events. Detailed implementation is shown in pictures above. @@ -108,18 +108,18 @@ after filtering out those pointing to our FUSE directory or duplicated, send format list directly to remote. The cliprdr server also uses clipboard client for setting clipboard, -or retrive paths from system. +or retrieve paths from system. #### Local File List -The local file list is a temperary list of file metadata. +The local file list is a temporary list of file metadata. When receiving file contents PDU from peer, the server picks out the file requested and open it for reading if necessary. Also when receiving Format Data Request PDU from remote asking for file list, the local file list should be rebuilt from file list retrieved from Clipboard Client. -Some caching and preloading could done on it since applications are likely to read +Some caching and preloading could be done on it since applications are likely to read on the list sequentially. #### FUSE server diff --git a/libs/clipboard/src/cliprdr.h b/libs/clipboard/src/cliprdr.h index 8b9cecef0..33e3d522a 100644 --- a/libs/clipboard/src/cliprdr.h +++ b/libs/clipboard/src/cliprdr.h @@ -170,6 +170,8 @@ extern "C" typedef UINT (*pcNotifyClipboardMsg)(UINT32 connID, const NOTIFICATION_MESSAGE *msg); + typedef UINT (*pcHandleClipboardFiles)(UINT32 connID, size_t nFiles, WCHAR **fileNames); + typedef UINT (*pcCliprdrClientFormatList)(CliprdrClientContext *context, const CLIPRDR_FORMAT_LIST *formatList); typedef UINT (*pcCliprdrServerFormatList)(CliprdrClientContext *context, @@ -217,6 +219,7 @@ extern "C" pcCliprdrMonitorReady MonitorReady; pcCliprdrTempDirectory TempDirectory; pcNotifyClipboardMsg NotifyClipboardMsg; + pcHandleClipboardFiles HandleClipboardFiles; pcCliprdrClientFormatList ClientFormatList; pcCliprdrServerFormatList ServerFormatList; pcCliprdrClientFormatListResponse ClientFormatListResponse; diff --git a/libs/clipboard/src/context_send.rs b/libs/clipboard/src/context_send.rs index f3606509f..caa9d4a48 100644 --- a/libs/clipboard/src/context_send.rs +++ b/libs/clipboard/src/context_send.rs @@ -1,22 +1,29 @@ use hbb_common::{log, ResultType}; -use std::sync::Mutex; +use std::{ops::Deref, sync::Mutex}; use crate::CliprdrServiceContext; const CLIPBOARD_RESPONSE_WAIT_TIMEOUT_SECS: u32 = 30; lazy_static::lazy_static! { - static ref CONTEXT_SEND: ContextSend = ContextSend{addr: Mutex::new(None)}; + static ref CONTEXT_SEND: ContextSend = ContextSend::default(); } -pub struct ContextSend { - addr: Mutex>>, +#[derive(Default)] +pub struct ContextSend(Mutex>>); + +impl Deref for ContextSend { + type Target = Mutex>>; + + fn deref(&self) -> &Self::Target { + &self.0 + } } impl ContextSend { #[inline] pub fn is_enabled() -> bool { - CONTEXT_SEND.addr.lock().unwrap().is_some() + CONTEXT_SEND.lock().unwrap().is_some() } pub fn set_is_stopped() { @@ -24,7 +31,7 @@ impl ContextSend { } pub fn enable(enabled: bool) { - let mut lock = CONTEXT_SEND.addr.lock().unwrap(); + let mut lock = CONTEXT_SEND.lock().unwrap(); if enabled { if lock.is_some() { return; @@ -49,7 +56,7 @@ impl ContextSend { /// make sure the clipboard context is enabled. pub fn make_sure_enabled() -> ResultType<()> { - let mut lock = CONTEXT_SEND.addr.lock().unwrap(); + let mut lock = CONTEXT_SEND.lock().unwrap(); if lock.is_some() { return Ok(()); } @@ -63,7 +70,7 @@ impl ContextSend { pub fn proc) -> ResultType<()>>( f: F, ) -> ResultType<()> { - let mut lock = CONTEXT_SEND.addr.lock().unwrap(); + let mut lock = CONTEXT_SEND.lock().unwrap(); match lock.as_mut() { Some(context) => f(context), None => Ok(()), diff --git a/libs/clipboard/src/lib.rs b/libs/clipboard/src/lib.rs index 30055740e..5ce9afe28 100644 --- a/libs/clipboard/src/lib.rs +++ b/libs/clipboard/src/lib.rs @@ -1,24 +1,32 @@ -#[allow(dead_code)] -use std::{ - path::PathBuf, - sync::{Arc, Mutex, RwLock}, -}; +use std::sync::{Arc, Mutex, RwLock}; -#[cfg(any(target_os = "windows", feature = "unix-file-copy-paste",))] -use hbb_common::{allow_err, bail}; +#[cfg(any( + target_os = "windows", + all(target_os = "macos", feature = "unix-file-copy-paste") +))] +use hbb_common::ResultType; +#[cfg(any(target_os = "windows", feature = "unix-file-copy-paste"))] +use hbb_common::{allow_err, log}; use hbb_common::{ lazy_static, tokio::sync::{ mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}, Mutex as TokioMutex, }, - ResultType, }; use serde_derive::{Deserialize, Serialize}; use thiserror::Error; +#[cfg(any( + target_os = "windows", + all(target_os = "macos", feature = "unix-file-copy-paste") +))] pub mod context_send; pub mod platform; +#[cfg(any( + target_os = "windows", + all(target_os = "macos", feature = "unix-file-copy-paste") +))] pub use context_send::*; #[cfg(target_os = "windows")] @@ -28,8 +36,19 @@ const ERR_CODE_INVALID_PARAMETER: u32 = 0x00000002; #[cfg(target_os = "windows")] const ERR_CODE_SEND_MSG: u32 = 0x00000003; +#[cfg(any( + target_os = "windows", + all(target_os = "macos", feature = "unix-file-copy-paste") +))] pub(crate) use platform::create_cliprdr_context; +pub struct ProgressPercent { + pub percent: f64, + pub is_canceled: bool, + pub is_failed: bool, +} + +// to-do: This trait may be removed, because unix file copy paste does not need it. /// Ability to handle Clipboard File from remote rustdesk client /// /// # Note @@ -41,9 +60,12 @@ pub trait CliprdrServiceContext: Send + Sync { fn set_is_stopped(&mut self) -> Result<(), CliprdrError>; /// clear the content on clipboard fn empty_clipboard(&mut self, conn_id: i32) -> Result; - /// run as a server for clipboard RPC fn server_clip_file(&mut self, conn_id: i32, msg: ClipboardFile) -> Result<(), CliprdrError>; + /// get the progress of the paste task. + fn get_progress_percent(&self) -> Option; + /// cancel the paste task. + fn cancel(&mut self); } #[derive(Error, Debug)] @@ -62,10 +84,12 @@ pub enum CliprdrError { ConversionFailure, #[error("failure to read clipboard")] OpenClipboard, - #[error("failure to read file metadata or content")] - FileError { path: PathBuf, err: std::io::Error }, - #[error("invalid request")] + #[error("failure to read file metadata or content, path: {path}, err: {err}")] + FileError { path: String, err: std::io::Error }, + #[error("invalid request: {description}")] InvalidRequest { description: String }, + #[error("common request: {description}")] + CommonError { description: String }, #[error("unknown cliprdr error")] Unknown(u32), } @@ -107,6 +131,10 @@ pub enum ClipboardFile { stream_id: i32, requested_data: Vec, }, + TryEmpty, + Files { + files: Vec<(String, u64)>, + }, } struct MsgChannel { @@ -198,42 +226,67 @@ pub fn get_rx_cliprdr_server(conn_id: i32) -> Arc ResultType<()> { +pub fn send_data(conn_id: i32, data: ClipboardFile) -> Result<(), CliprdrError> { #[cfg(target_os = "windows")] return send_data_to_channel(conn_id, data); #[cfg(not(target_os = "windows"))] if conn_id == 0 { - send_data_to_all(data); + let _ = send_data_to_all(data); + Ok(()) } else { - send_data_to_channel(conn_id, data); + send_data_to_channel(conn_id, data) } } -#[cfg(any(target_os = "windows", feature = "unix-file-copy-paste",))] + #[inline] -fn send_data_to_channel(conn_id: i32, data: ClipboardFile) -> ResultType<()> { +#[cfg(any(target_os = "windows", feature = "unix-file-copy-paste"))] +fn send_data_to_channel(conn_id: i32, data: ClipboardFile) -> Result<(), CliprdrError> { if let Some(msg_channel) = VEC_MSG_CHANNEL .read() .unwrap() .iter() .find(|x| x.conn_id == conn_id) { - msg_channel.sender.send(data)?; - Ok(()) + msg_channel + .sender + .send(data) + .map_err(|e| CliprdrError::CommonError { + description: e.to_string(), + }) } else { - bail!("conn_id not found"); + Err(CliprdrError::InvalidRequest { + description: "conn_id not found".to_string(), + }) } } -#[cfg(feature = "unix-file-copy-paste")] #[inline] -fn send_data_to_all(data: ClipboardFile) -> ResultType<()> { +#[cfg(target_os = "windows")] +pub fn send_data_exclude(conn_id: i32, data: ClipboardFile) { + // Need more tests to see if it's necessary to handle the error. + for msg_channel in VEC_MSG_CHANNEL.read().unwrap().iter() { + if msg_channel.conn_id != conn_id { + allow_err!(msg_channel.sender.send(data.clone())); + } + } +} + +#[inline] +#[cfg(feature = "unix-file-copy-paste")] +fn send_data_to_all(data: ClipboardFile) { // Need more tests to see if it's necessary to handle the error. for msg_channel in VEC_MSG_CHANNEL.read().unwrap().iter() { allow_err!(msg_channel.sender.send(data.clone())); } - Ok(()) } #[cfg(test)] diff --git a/libs/clipboard/src/platform/mod.rs b/libs/clipboard/src/platform/mod.rs index 5db271129..f54f4021b 100644 --- a/libs/clipboard/src/platform/mod.rs +++ b/libs/clipboard/src/platform/mod.rs @@ -1,6 +1,3 @@ -#[cfg(any(target_os = "linux", target_os = "macos"))] -use crate::{CliprdrError, CliprdrServiceContext}; - #[cfg(target_os = "windows")] pub mod windows; #[cfg(target_os = "windows")] @@ -16,76 +13,14 @@ pub fn create_cliprdr_context( } #[cfg(feature = "unix-file-copy-paste")] -#[cfg(any(target_os = "linux", target_os = "macos"))] -/// use FUSE for file pasting on these platforms -pub mod fuse; -#[cfg(feature = "unix-file-copy-paste")] -#[cfg(any(target_os = "linux", target_os = "macos"))] pub mod unix; -#[cfg(any(target_os = "linux", target_os = "macos"))] + +#[cfg(all(feature = "unix-file-copy-paste", target_os = "macos"))] pub fn create_cliprdr_context( _enable_files: bool, _enable_others: bool, _response_wait_timeout_secs: u32, ) -> crate::ResultType> { - #[cfg(feature = "unix-file-copy-paste")] - { - use std::{fs::Permissions, os::unix::prelude::PermissionsExt}; - - use hbb_common::{config::APP_NAME, log}; - - if !_enable_files { - return Ok(Box::new(DummyCliprdrContext {}) as Box<_>); - } - - let timeout = std::time::Duration::from_secs(_response_wait_timeout_secs as u64); - - let app_name = APP_NAME.read().unwrap().clone(); - - let mnt_path = format!("/tmp/{}/{}", app_name, "cliprdr"); - - // this function must be called after the main IPC is up - std::fs::create_dir(&mnt_path).ok(); - std::fs::set_permissions(&mnt_path, Permissions::from_mode(0o777)).ok(); - - log::info!("clear previously mounted cliprdr FUSE"); - if let Err(e) = std::process::Command::new("umount").arg(&mnt_path).status() { - log::warn!("umount {:?} may fail: {:?}", mnt_path, e); - } - - let unix_ctx = unix::ClipboardContext::new(timeout, mnt_path.parse()?)?; - log::debug!("start cliprdr FUSE"); - unix_ctx.run()?; - - Ok(Box::new(unix_ctx) as Box<_>) - } - - #[cfg(not(feature = "unix-file-copy-paste"))] - return Ok(Box::new(DummyCliprdrContext {}) as Box<_>); + let boxed = unix::macos::pasteboard_context::create_pasteboard_context()? as Box<_>; + Ok(boxed) } - -#[cfg(any(target_os = "linux", target_os = "macos"))] -struct DummyCliprdrContext {} - -#[cfg(any(target_os = "linux", target_os = "macos"))] -impl CliprdrServiceContext for DummyCliprdrContext { - fn set_is_stopped(&mut self) -> Result<(), CliprdrError> { - Ok(()) - } - fn empty_clipboard(&mut self, _conn_id: i32) -> Result { - Ok(true) - } - fn server_clip_file( - &mut self, - _conn_id: i32, - _msg: crate::ClipboardFile, - ) -> Result<(), crate::CliprdrError> { - Ok(()) - } -} - -#[cfg(feature = "unix-file-copy-paste")] -#[cfg(any(target_os = "linux", target_os = "macos"))] -// begin of epoch used by microsoft -// 1601-01-01 00:00:00 + LDAP_EPOCH_DELTA*(100 ns) = 1970-01-01 00:00:00 -const LDAP_EPOCH_DELTA: u64 = 116444772610000000; diff --git a/libs/clipboard/src/platform/unix/filetype.rs b/libs/clipboard/src/platform/unix/filetype.rs new file mode 100644 index 000000000..8436ba05e --- /dev/null +++ b/libs/clipboard/src/platform/unix/filetype.rs @@ -0,0 +1,188 @@ +use super::{FLAGS_FD_ATTRIBUTES, FLAGS_FD_LAST_WRITE, FLAGS_FD_UNIX_MODE, LDAP_EPOCH_DELTA}; +use crate::CliprdrError; +use hbb_common::{ + bytes::{Buf, Bytes}, + log, +}; +use serde_derive::{Deserialize, Serialize}; +use std::{ + path::PathBuf, + time::{Duration, SystemTime}, +}; +use utf16string::WStr; + +#[cfg(target_os = "linux")] +pub type Inode = u64; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum FileType { + File, + Directory, + // todo: support symlink + Symlink, +} + +/// read only permission +pub const PERM_READ: u16 = 0o444; +/// read and write permission +pub const PERM_RW: u16 = 0o644; +/// only self can read and readonly +pub const PERM_SELF_RO: u16 = 0o400; +/// rwx +pub const PERM_RWX: u16 = 0o755; +#[allow(dead_code)] +/// max length of file name +pub const MAX_NAME_LEN: usize = 255; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FileDescription { + pub conn_id: i32, + pub name: PathBuf, + pub kind: FileType, + pub atime: SystemTime, + pub last_modified: SystemTime, + pub last_metadata_changed: SystemTime, + pub creation_time: SystemTime, + pub size: u64, + pub perm: u16, +} + +impl FileDescription { + fn parse_file_descriptor( + bytes: &mut Bytes, + conn_id: i32, + ) -> Result { + let flags = bytes.get_u32_le(); + // skip reserved 32 bytes + bytes.advance(32); + let attributes = bytes.get_u32_le(); + + // in original specification, this is 16 bytes reserved + // we use the last 4 bytes to store the file mode + // skip reserved 12 bytes + bytes.advance(12); + let perm = bytes.get_u32_le() as u16; + + // last write time from 1601-01-01 00:00:00, in 100ns + let last_write_time = bytes.get_u64_le(); + // file size + let file_size_high = bytes.get_u32_le(); + let file_size_low = bytes.get_u32_le(); + // utf16 file name, double \0 terminated, in 520 bytes block + // read with another pointer, and advance the main pointer + let block = bytes.clone(); + bytes.advance(520); + + let block = &block[..520]; + let wstr = WStr::from_utf16le(block).map_err(|e| { + log::error!("cannot convert file descriptor path: {:?}", e); + CliprdrError::ConversionFailure + })?; + + let from_unix = flags & FLAGS_FD_UNIX_MODE != 0; + + let valid_attributes = flags & FLAGS_FD_ATTRIBUTES != 0; + if !valid_attributes { + return Err(CliprdrError::InvalidRequest { + description: "file description must have valid attributes".to_string(), + }); + } + + // todo: check normal, hidden, system, readonly, archive... + let directory = attributes & 0x10 != 0; + let normal = attributes == 0x80; + let hidden = attributes & 0x02 != 0; + let readonly = attributes & 0x01 != 0; + + let perm = if from_unix { + // as is + perm + // cannot set as is... + } else if normal { + PERM_RWX + } else if readonly { + PERM_READ + } else if hidden { + PERM_SELF_RO + } else if directory { + PERM_RWX + } else { + PERM_RW + }; + + let kind = if directory { + FileType::Directory + } else { + FileType::File + }; + + // to-do: use `let valid_size = flags & FLAGS_FD_SIZE != 0;` + // We use `true` to for compatibility with Windows. + // let valid_size = flags & FLAGS_FD_SIZE != 0; + let valid_size = true; + let size = if valid_size { + ((file_size_high as u64) << 32) + file_size_low as u64 + } else { + 0 + }; + + let valid_write_time = flags & FLAGS_FD_LAST_WRITE != 0; + let last_modified = if valid_write_time && last_write_time >= LDAP_EPOCH_DELTA { + let last_write_time = (last_write_time - LDAP_EPOCH_DELTA) * 100; + let last_write_time = Duration::from_nanos(last_write_time); + SystemTime::UNIX_EPOCH + last_write_time + } else { + SystemTime::UNIX_EPOCH + }; + + let name = wstr.to_utf8().replace('\\', "/"); + let name = PathBuf::from(name.trim_end_matches('\0')); + + let desc = FileDescription { + conn_id, + name, + kind, + atime: last_modified, + last_modified, + last_metadata_changed: last_modified, + creation_time: last_modified, + size, + perm, + }; + + Ok(desc) + } + + /// parse file descriptions from a format data response PDU + /// which containing a CSPTR_FILEDESCRIPTORW indicated format data + pub fn parse_file_descriptors( + file_descriptor_pdu: Vec, + conn_id: i32, + ) -> Result, CliprdrError> { + let mut data = Bytes::from(file_descriptor_pdu); + if data.remaining() < 4 { + return Err(CliprdrError::InvalidRequest { + description: "file descriptor request with infficient length".to_string(), + }); + } + + let count = data.get_u32_le() as usize; + if data.remaining() == 0 && count == 0 { + return Ok(Vec::new()); + } + + if data.remaining() != 592 * count { + return Err(CliprdrError::InvalidRequest { + description: "file descriptor request with invalid length".to_string(), + }); + } + + let mut files = Vec::with_capacity(count); + for _ in 0..count { + let desc = Self::parse_file_descriptor(&mut data, conn_id)?; + files.push(desc); + } + + Ok(files) + } +} diff --git a/libs/clipboard/src/platform/fuse.rs b/libs/clipboard/src/platform/unix/fuse/cs.rs similarity index 82% rename from libs/clipboard/src/platform/fuse.rs rename to libs/clipboard/src/platform/unix/fuse/cs.rs index c5fe60f56..fa1dea71d 100644 --- a/libs/clipboard/src/platform/fuse.rs +++ b/libs/clipboard/src/platform/unix/fuse/cs.rs @@ -12,7 +12,7 @@ //! //! For now, we transfer all file names with windows separators, UTF-16 encoded. //! *Need a way to transfer file names with '\' safely*. -//! Maybe we can use URL encoded file names and '/' seperators as a new standard, while keep the support to old schemes. +//! Maybe we can use URL encoded file names and '/' separators as a new standard, while keep the support to old schemes. //! //! # Note //! - all files on FS should be read only, and mark the owner to be the current user @@ -31,33 +31,29 @@ use std::{ }; use fuser::{ReplyDirectory, FUSE_ROOT_ID}; -use hbb_common::{ - bytes::{Buf, Bytes}, - log, -}; +use hbb_common::log; use parking_lot::{Condvar, Mutex}; -use utf16string::WStr; -use crate::{send_data, ClipboardFile, CliprdrError}; - -use super::LDAP_EPOCH_DELTA; +use crate::{ + platform::unix::{ + filetype::{FileDescription, FileType, Inode, MAX_NAME_LEN, PERM_RWX}, + BLOCK_SIZE, + }, + send_data, ClipboardFile, CliprdrError, +}; /// fuse server ready retry max times const READ_RETRY: i32 = 3; -/// block size for fuse, align to our asynchronic request size over FileContentsRequest. -pub const BLOCK_SIZE: u32 = 4 * 1024 * 1024; - -/// read only permission -const PERM_READ: u16 = 0o444; -/// read and write permission -const PERM_RW: u16 = 0o644; -/// only self can read and readonly -const PERM_SELF_RO: u16 = 0o400; -/// rwx -const PERM_RWX: u16 = 0o755; -/// max length of file name -const MAX_NAME_LEN: usize = 255; +impl From for fuser::FileType { + fn from(value: FileType) -> Self { + match value { + FileType::File => Self::RegularFile, + FileType::Directory => Self::Directory, + FileType::Symlink => Self::Symlink, + } + } +} /// fuse client /// this is a proxy to the fuse server @@ -150,9 +146,15 @@ impl fuser::Filesystem for FuseClient { server.release(req, ino, fh, _flags, _lock_owner, _flush, reply) } - fn getattr(&mut self, req: &fuser::Request<'_>, ino: u64, reply: fuser::ReplyAttr) { + fn getattr( + &mut self, + req: &fuser::Request<'_>, + ino: u64, + fh: Option, + reply: fuser::ReplyAttr, + ) { let mut server = self.server.lock(); - server.getattr(req, ino, reply) + server.getattr(req, ino, fh, reply) } fn statfs(&mut self, req: &fuser::Request<'_>, ino: u64, reply: fuser::ReplyStatfs) { @@ -247,7 +249,6 @@ impl fuser::Filesystem for FuseServer { if parent_entry.attributes.kind != FileType::Directory { log::error!("fuse: parent is not a directory"); - reply.error(libc::ENOTDIR); return; } @@ -480,7 +481,13 @@ impl fuser::Filesystem for FuseServer { reply.ok(); } - fn getattr(&mut self, _req: &fuser::Request<'_>, ino: u64, reply: fuser::ReplyAttr) { + fn getattr( + &mut self, + _req: &fuser::Request<'_>, + ino: u64, + _fh: Option, + reply: fuser::ReplyAttr, + ) { let files = &self.files; let Some(entry) = files.get(ino as usize - 1) else { reply.error(libc::ENOENT); @@ -527,14 +534,6 @@ impl FuseServer { size: u32, ) -> Result, std::io::Error> { // todo: async and concurrent read, generate stream_id per request - log::debug!( - "reading {:?} offset {} size {} on stream: {}", - node.name, - offset, - size, - node.stream_id - ); - let cb_requested = unsafe { // convert `size` from u32 to i32 // yet with same bit representation @@ -554,16 +553,14 @@ impl FuseServer { clip_data_id: 0, }; - send_data(node.conn_id, request.clone()); - - log::debug!( - "waiting for read reply for {:?} on stream: {}", - node.name, - node.stream_id - ); + send_data(node.conn_id, request.clone()).map_err(|e| { + log::error!("failed to send file list to channel: {:?}", e); + std::io::Error::new(std::io::ErrorKind::Other, e) + })?; let mut retry_times = 0; + // to-do: more tests needed loop { let reply = self.rx.recv_timeout(self.timeout).map_err(|e| { log::error!("failed to receive file list from channel: {:?}", e); @@ -590,7 +587,10 @@ impl FuseServer { )); } - send_data(node.conn_id, request.clone()); + send_data(node.conn_id, request.clone()).map_err(|e| { + log::error!("failed to send file list to channel: {:?}", e); + std::io::Error::new(std::io::ErrorKind::Other, e) + })?; continue; } return Ok(requested_data); @@ -605,160 +605,6 @@ impl FuseServer { } } } - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct FileDescription { - pub conn_id: i32, - pub name: PathBuf, - pub kind: FileType, - pub atime: SystemTime, - pub last_modified: SystemTime, - pub last_metadata_changed: SystemTime, - pub creation_time: SystemTime, - - pub size: u64, - - pub perm: u16, -} - -impl FileDescription { - fn parse_file_descriptor( - bytes: &mut Bytes, - conn_id: i32, - ) -> Result { - let flags = bytes.get_u32_le(); - // skip reserved 32 bytes - bytes.advance(32); - let attributes = bytes.get_u32_le(); - - // in original specification, this is 16 bytes reserved - // we use the last 4 bytes to store the file mode - // skip reserved 12 bytes - bytes.advance(12); - let perm = bytes.get_u32_le() as u16; - - // last write time from 1601-01-01 00:00:00, in 100ns - let last_write_time = bytes.get_u64_le(); - // file size - let file_size_high = bytes.get_u32_le(); - let file_size_low = bytes.get_u32_le(); - // utf16 file name, double \0 terminated, in 520 bytes block - // read with another pointer, and advance the main pointer - let block = bytes.clone(); - bytes.advance(520); - - let block = &block[..520]; - let wstr = WStr::from_utf16le(block).map_err(|e| { - log::error!("cannot convert file descriptor path: {:?}", e); - CliprdrError::ConversionFailure - })?; - - let from_unix = flags & 0x08 != 0; - - let valid_attributes = flags & 0x04 != 0; - if !valid_attributes { - return Err(CliprdrError::InvalidRequest { - description: "file description must have valid attributes".to_string(), - }); - } - - // todo: check normal, hidden, system, readonly, archive... - let directory = attributes & 0x10 != 0; - let normal = attributes == 0x80; - let hidden = attributes & 0x02 != 0; - let readonly = attributes & 0x01 != 0; - - let perm = if from_unix { - // as is - perm - // cannot set as is... - } else if normal { - PERM_RWX - } else if readonly { - PERM_READ - } else if hidden { - PERM_SELF_RO - } else if directory { - PERM_RWX - } else { - PERM_RW - }; - - let kind = if directory { - FileType::Directory - } else { - FileType::File - }; - - let valid_size = flags & 0x40 != 0; - let size = if valid_size { - ((file_size_high as u64) << 32) + file_size_low as u64 - } else { - 0 - }; - - let valid_write_time = flags & 0x20 != 0; - let last_modified = if valid_write_time && last_write_time >= LDAP_EPOCH_DELTA { - let last_write_time = (last_write_time - LDAP_EPOCH_DELTA) * 100; - let last_write_time = Duration::from_nanos(last_write_time); - SystemTime::UNIX_EPOCH + last_write_time - } else { - SystemTime::UNIX_EPOCH - }; - - let name = wstr.to_utf8().replace('\\', "/"); - let name = PathBuf::from(name.trim_end_matches('\0')); - - let desc = FileDescription { - conn_id, - name, - kind, - atime: last_modified, - last_modified, - last_metadata_changed: last_modified, - - creation_time: last_modified, - size, - perm, - }; - - Ok(desc) - } - - /// parse file descriptions from a format data response PDU - /// which containing a CSPTR_FILEDESCRIPTORW indicated format data - pub fn parse_file_descriptors( - file_descriptor_pdu: Vec, - conn_id: i32, - ) -> Result, CliprdrError> { - let mut data = Bytes::from(file_descriptor_pdu); - if data.remaining() < 4 { - return Err(CliprdrError::InvalidRequest { - description: "file descriptor request with infficient length".to_string(), - }); - } - - let count = data.get_u32_le() as usize; - if data.remaining() == 0 && count == 0 { - return Ok(Vec::new()); - } - - if data.remaining() != 592 * count { - return Err(CliprdrError::InvalidRequest { - description: "file descriptor request with invalid length".to_string(), - }); - } - - let mut files = Vec::with_capacity(count); - for _ in 0..count { - let desc = Self::parse_file_descriptor(&mut data, conn_id)?; - files.push(desc); - } - - Ok(files) - } -} - /// a node in the FUSE file tree #[derive(Debug)] struct FuseNode { @@ -881,7 +727,7 @@ impl FuseNode { format!("invalid file name {}", file.name.display()), ); CliprdrError::FileError { - path: file.name.clone(), + path: file.name.to_string_lossy().to_string(), err, } })?; @@ -902,26 +748,6 @@ impl FuseNode { } } -pub type Inode = u64; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum FileType { - File, - Directory, - // todo: support symlink - Symlink, -} - -impl From for fuser::FileType { - fn from(value: FileType) -> Self { - match value { - FileType::File => Self::RegularFile, - FileType::Directory => Self::Directory, - FileType::Symlink => Self::Symlink, - } - } -} - #[derive(Debug, Clone)] pub struct InodeAttributes { inode: Inode, @@ -1064,8 +890,6 @@ impl FileHandles { #[cfg(test)] mod fuse_test { - use std::str::FromStr; - use super::*; // todo: more tests needed! diff --git a/libs/clipboard/src/platform/unix/fuse/mod.rs b/libs/clipboard/src/platform/unix/fuse/mod.rs new file mode 100644 index 000000000..df743004f --- /dev/null +++ b/libs/clipboard/src/platform/unix/fuse/mod.rs @@ -0,0 +1,225 @@ +mod cs; + +use super::filetype::FileDescription; +use crate::{ClipboardFile, CliprdrError}; +use cs::FuseServer; +use fuser::MountOption; +use hbb_common::{config::APP_NAME, log}; +use parking_lot::Mutex; +use std::{ + path::PathBuf, + sync::{mpsc::Sender, Arc}, + time::Duration, +}; + +lazy_static::lazy_static! { + static ref FUSE_MOUNT_POINT_CLIENT: Arc = { + let mnt_path = format!("/tmp/{}/{}", APP_NAME.read().unwrap(), "cliprdr-client"); + // No need to run `canonicalize()` here. + Arc::new(mnt_path) + }; + + static ref FUSE_MOUNT_POINT_SERVER: Arc = { + let mnt_path = format!("/tmp/{}/{}", APP_NAME.read().unwrap(), "cliprdr-server"); + // No need to run `canonicalize()` here. + Arc::new(mnt_path) + }; + + static ref FUSE_CONTEXT_CLIENT: Arc>> = Arc::new(Mutex::new(None)); + static ref FUSE_CONTEXT_SERVER: Arc>> = Arc::new(Mutex::new(None)); +} + +static FUSE_TIMEOUT: Duration = Duration::from_secs(3); + +pub fn get_exclude_paths(is_client: bool) -> Arc { + if is_client { + FUSE_MOUNT_POINT_CLIENT.clone() + } else { + FUSE_MOUNT_POINT_SERVER.clone() + } +} + +pub fn is_fuse_context_inited(is_client: bool) -> bool { + if is_client { + FUSE_CONTEXT_CLIENT.lock().is_some() + } else { + FUSE_CONTEXT_SERVER.lock().is_some() + } +} + +pub fn init_fuse_context(is_client: bool) -> Result<(), CliprdrError> { + let mut fuse_context_lock = if is_client { + FUSE_CONTEXT_CLIENT.lock() + } else { + FUSE_CONTEXT_SERVER.lock() + }; + if fuse_context_lock.is_some() { + return Ok(()); + } + let mount_point = if is_client { + FUSE_MOUNT_POINT_CLIENT.clone() + } else { + FUSE_MOUNT_POINT_SERVER.clone() + }; + + let mount_point = std::path::PathBuf::from(&*mount_point); + let (server, tx) = FuseServer::new(FUSE_TIMEOUT); + let server = Arc::new(Mutex::new(server)); + + prepare_fuse_mount_point(&mount_point); + let mnt_opts = [ + MountOption::FSName("rustdesk-cliprdr-fs".to_string()), + MountOption::NoAtime, + MountOption::RO, + ]; + log::info!("mounting clipboard FUSE to {}", mount_point.display()); + // to-do: ignore the error if the mount point is already mounted + // Because the sciter version uses separate processes as the controlling side. + let session = fuser::spawn_mount2( + FuseServer::client(server.clone()), + mount_point.clone(), + &mnt_opts, + ) + .map_err(|e| { + log::error!("failed to mount cliprdr fuse: {:?}", e); + CliprdrError::CliprdrInit + })?; + let session = Mutex::new(Some(session)); + + let ctx = FuseContext { + server, + tx, + mount_point, + session, + conn_id: 0, + }; + *fuse_context_lock = Some(ctx); + Ok(()) +} + +pub fn uninit_fuse_context(is_client: bool) { + uninit_fuse_context_(is_client) +} + +pub fn format_data_response_to_urls( + is_client: bool, + format_data: Vec, + conn_id: i32, +) -> Result, CliprdrError> { + let mut ctx = if is_client { + FUSE_CONTEXT_CLIENT.lock() + } else { + FUSE_CONTEXT_SERVER.lock() + }; + ctx.as_mut() + .ok_or(CliprdrError::CliprdrInit)? + .format_data_response_to_urls(format_data, conn_id) +} + +pub fn handle_file_content_response( + is_client: bool, + clip: ClipboardFile, +) -> Result<(), CliprdrError> { + // we don't know its corresponding request, no resend can be performed + let ctx = if is_client { + FUSE_CONTEXT_CLIENT.lock() + } else { + FUSE_CONTEXT_SERVER.lock() + }; + ctx.as_ref() + .ok_or(CliprdrError::CliprdrInit)? + .tx + .send(clip) + .map_err(|e| { + log::error!("failed to send file contents response to fuse: {:?}", e); + CliprdrError::ClipboardInternalError + })?; + Ok(()) +} + +pub fn empty_local_files(is_client: bool, conn_id: i32) -> bool { + let ctx = if is_client { + FUSE_CONTEXT_CLIENT.lock() + } else { + FUSE_CONTEXT_SERVER.lock() + }; + ctx.as_ref() + .map(|c| c.empty_local_files(conn_id)) + .unwrap_or(false) +} + +struct FuseContext { + server: Arc>, + tx: Sender, + mount_point: PathBuf, + // stores fuse background session handle + session: Mutex>, + // Indicates the connection ID of that set the clipboard content + conn_id: i32, +} + +// this function must be called after the main IPC is up +fn prepare_fuse_mount_point(mount_point: &PathBuf) { + use std::{ + fs::{self, Permissions}, + os::unix::prelude::PermissionsExt, + }; + + fs::create_dir(mount_point).ok(); + fs::set_permissions(mount_point, Permissions::from_mode(0o777)).ok(); + + if let Err(e) = std::process::Command::new("umount") + .arg(mount_point) + .status() + { + log::warn!("umount {:?} may fail: {:?}", mount_point, e); + } +} + +fn uninit_fuse_context_(is_client: bool) { + if is_client { + let _ = FUSE_CONTEXT_CLIENT.lock().take(); + } else { + let _ = FUSE_CONTEXT_SERVER.lock().take(); + } +} + +impl Drop for FuseContext { + fn drop(&mut self) { + self.session.lock().take().map(|s| s.join()); + log::info!("unmounting clipboard FUSE from {}", self.mount_point.display()); + } +} + +impl FuseContext { + pub fn empty_local_files(&self, conn_id: i32) -> bool { + if conn_id != 0 && self.conn_id != conn_id { + return false; + } + let mut fuse_guard = self.server.lock(); + let _ = fuse_guard.load_file_list(vec![]); + true + } + + pub fn format_data_response_to_urls( + &mut self, + format_data: Vec, + conn_id: i32, + ) -> Result, CliprdrError> { + let files = FileDescription::parse_file_descriptors(format_data, conn_id)?; + + let paths = { + let mut fuse_guard = self.server.lock(); + fuse_guard.load_file_list(files)?; + self.conn_id = conn_id; + + fuse_guard.list_root() + }; + + let prefix = self.mount_point.clone(); + Ok(paths + .into_iter() + .map(|p| prefix.join(p).to_string_lossy().to_string()) + .collect()) + } +} diff --git a/libs/clipboard/src/platform/unix/local_file.rs b/libs/clipboard/src/platform/unix/local_file.rs index e24712efa..11d62cad8 100644 --- a/libs/clipboard/src/platform/unix/local_file.rs +++ b/libs/clipboard/src/platform/unix/local_file.rs @@ -1,38 +1,29 @@ +use super::{BLOCK_SIZE, LDAP_EPOCH_DELTA}; +use crate::{ + platform::unix::{ + FLAGS_FD_ATTRIBUTES, FLAGS_FD_LAST_WRITE, FLAGS_FD_PROGRESSUI, FLAGS_FD_SIZE, + FLAGS_FD_UNIX_MODE, + }, + CliprdrError, +}; +use hbb_common::{ + bytes::{BufMut, BytesMut}, + log, +}; use std::{ collections::HashSet, fs::File, io::{BufRead, BufReader, Read, Seek}, os::unix::prelude::PermissionsExt, - path::PathBuf, + path::{Path, PathBuf}, sync::atomic::{AtomicU64, Ordering}, time::SystemTime, }; - -use hbb_common::{ - bytes::{BufMut, BytesMut}, - log, -}; use utf16string::WString; -use crate::{ - platform::{fuse::BLOCK_SIZE, LDAP_EPOCH_DELTA}, - CliprdrError, -}; - -/// has valid file attributes -const FLAGS_FD_ATTRIBUTES: u32 = 0x04; -/// has valid file size -const FLAGS_FD_SIZE: u32 = 0x40; -/// has valid last write time -const FLAGS_FD_LAST_WRITE: u32 = 0x20; -/// show progress -const FLAGS_FD_PROGRESSUI: u32 = 0x4000; -/// transferred from unix, contains file mode -/// P.S. this flag is not used in windows -const FLAGS_FD_UNIX_MODE: u32 = 0x08; - #[derive(Debug)] pub(super) struct LocalFile { + pub relative_root: PathBuf, pub path: PathBuf, pub handle: Option>, @@ -51,9 +42,9 @@ pub(super) struct LocalFile { } impl LocalFile { - pub fn try_open(path: &PathBuf) -> Result { + pub fn try_open(relative_root: &Path, path: &Path) -> Result { let mt = std::fs::metadata(path).map_err(|e| CliprdrError::FileError { - path: path.clone(), + path: path.to_string_lossy().to_string(), err: e, })?; let size = mt.len() as u64; @@ -79,7 +70,8 @@ impl LocalFile { Ok(Self { name, - path: path.clone(), + relative_root: relative_root.to_path_buf(), + path: path.to_path_buf(), handle, offset, size, @@ -121,7 +113,12 @@ impl LocalFile { let size_high = (self.size >> 32) as u32; let size_low = (self.size & (u32::MAX as u64)) as u32; - let path = self.path.to_string_lossy().to_string(); + let path = self + .path + .strip_prefix(&self.relative_root) + .unwrap_or(&self.path) + .to_string_lossy() + .into_owned(); let wstr: WString = WString::from(&path); let name = wstr.as_bytes(); @@ -172,12 +169,12 @@ impl LocalFile { pub fn load_handle(&mut self) -> Result<(), CliprdrError> { if !self.is_dir && self.handle.is_none() { let handle = std::fs::File::open(&self.path).map_err(|e| CliprdrError::FileError { - path: self.path.clone(), + path: self.path.to_string_lossy().to_string(), err: e, })?; let mut reader = BufReader::with_capacity(BLOCK_SIZE as usize * 2, handle); reader.fill_buf().map_err(|e| CliprdrError::FileError { - path: self.path.clone(), + path: self.path.to_string_lossy().to_string(), err: e, })?; self.handle = Some(reader); @@ -188,20 +185,25 @@ impl LocalFile { pub fn read_exact_at(&mut self, buf: &mut [u8], offset: u64) -> Result<(), CliprdrError> { self.load_handle()?; - let handle = self.handle.as_mut()?; + let Some(handle) = self.handle.as_mut() else { + return Err(CliprdrError::FileError { + path: self.path.to_string_lossy().to_string(), + err: std::io::Error::new(std::io::ErrorKind::NotFound, "file handle not found"), + }); + }; if offset != self.offset.load(Ordering::Relaxed) { handle .seek(std::io::SeekFrom::Start(offset)) .map_err(|e| CliprdrError::FileError { - path: self.path.clone(), + path: self.path.to_string_lossy().to_string(), err: e, })?; } handle .read_exact(buf) .map_err(|e| CliprdrError::FileError { - path: self.path.clone(), + path: self.path.to_string_lossy().to_string(), err: e, })?; let new_offset = offset + (buf.len() as u64); @@ -219,7 +221,8 @@ impl LocalFile { pub(super) fn construct_file_list(paths: &[PathBuf]) -> Result, CliprdrError> { fn constr_file_lst( - path: &PathBuf, + relative_root: &Path, + path: &Path, file_list: &mut Vec, visited: &mut HashSet, ) -> Result<(), CliprdrError> { @@ -227,22 +230,28 @@ pub(super) fn construct_file_list(paths: &[PathBuf]) -> Result, C if visited.contains(path) { return Ok(()); } - visited.insert(path.clone()); + visited.insert(path.to_path_buf()); - let local_file = LocalFile::try_open(path)?; + let local_file = LocalFile::try_open(relative_root, path)?; file_list.push(local_file); let mt = std::fs::metadata(path).map_err(|e| CliprdrError::FileError { - path: path.clone(), + path: path.to_string_lossy().to_string(), err: e, })?; if mt.is_dir() { - let dir = std::fs::read_dir(path)?; + let dir = std::fs::read_dir(path).map_err(|e| CliprdrError::FileError { + path: path.to_string_lossy().to_string(), + err: e, + })?; for entry in dir { - let entry = entry?; + let entry = entry.map_err(|e| CliprdrError::FileError { + path: path.to_string_lossy().to_string(), + err: e, + })?; let path = entry.path(); - constr_file_lst(&path, file_list, visited)?; + constr_file_lst(relative_root, &path, file_list, visited)?; } } Ok(()) @@ -251,8 +260,18 @@ pub(super) fn construct_file_list(paths: &[PathBuf]) -> Result, C let mut file_list = Vec::new(); let mut visited = HashSet::new(); + let relative_root = paths + .first() + .ok_or(CliprdrError::InvalidRequest { + description: "empty file list".to_string(), + })? + .parent() + .ok_or(CliprdrError::InvalidRequest { + description: "empty parent".to_string(), + })? + .to_path_buf(); for path in paths { - constr_file_lst(path, &mut file_list, &mut visited)?; + constr_file_lst(&relative_root, path, &mut file_list, &mut visited)?; } Ok(file_list) } @@ -263,7 +282,7 @@ mod file_list_test { use hbb_common::bytes::{BufMut, BytesMut}; - use crate::{platform::fuse::FileDescription, CliprdrError}; + use crate::{platform::unix::filetype::FileDescription, CliprdrError}; use super::LocalFile; @@ -277,6 +296,7 @@ mod file_list_test { #[inline] fn generate_file(path: &str, name: &str, is_dir: bool) -> LocalFile { LocalFile { + relative_root: PathBuf::from("."), path: PathBuf::from(path), handle: None, name: name.to_string(), diff --git a/libs/clipboard/src/platform/unix/macos/README.md b/libs/clipboard/src/platform/unix/macos/README.md new file mode 100644 index 000000000..5c1cc5c90 --- /dev/null +++ b/libs/clipboard/src/platform/unix/macos/README.md @@ -0,0 +1,25 @@ +# File pate on macOS + +MacOS cannot use `fuse` because of [macfuse is not supported by default](https://github.com/macfuse/macfuse/wiki/Getting-Started#enabling-support-for-third-party-kernel-extensions-apple-silicon-macs). + +1. Use a temporary file `/tmp/rustdesk_` as a placeholder in the pasteboard. +2. Uses `fsevent` to observe files paste operation. Then perform pasting files. + +## Files + +### `pasteboard_context.rs` + +The context manager of the paste operations. + +### `item_data_provider.rs` + +1. Set pasteboard item. +2. Create temp file in `/tmp/.rustdesk_*`. + +### `paste_observer.rs` + +Use `fsevent` to observe the paste operation with the source file `/tmp/.rustdesk_*`. + +### `paste_task.rs` + +Perform the paste. diff --git a/libs/clipboard/src/platform/unix/macos/item_data_provider.rs b/libs/clipboard/src/platform/unix/macos/item_data_provider.rs new file mode 100644 index 000000000..95036312e --- /dev/null +++ b/libs/clipboard/src/platform/unix/macos/item_data_provider.rs @@ -0,0 +1,77 @@ +use super::pasteboard_context::{PasteObserverInfo, TEMP_FILE_PREFIX}; +use objc2::{ + declare_class, msg_send_id, mutability, + rc::Id, + runtime::{NSObject, NSObjectProtocol}, + ClassType, DeclaredClass, +}; +use objc2_app_kit::{ + NSPasteboard, NSPasteboardItem, NSPasteboardItemDataProvider, NSPasteboardType, + NSPasteboardTypeFileURL, +}; +use objc2_foundation::NSString; +use std::{io::Result, sync::mpsc::Sender}; + +pub(super) struct Ivars { + task_info: PasteObserverInfo, + tx: Sender>, +} + +declare_class!( + pub(super) struct PasteboardFileUrlProvider; + + unsafe impl ClassType for PasteboardFileUrlProvider { + type Super = NSObject; + type Mutability = mutability::InteriorMutable; + const NAME: &'static str = "PasteboardFileUrlProvider"; + } + + impl DeclaredClass for PasteboardFileUrlProvider { + type Ivars = Ivars; + } + + unsafe impl NSObjectProtocol for PasteboardFileUrlProvider {} + + unsafe impl NSPasteboardItemDataProvider for PasteboardFileUrlProvider { + #[method(pasteboard:item:provideDataForType:)] + #[allow(non_snake_case)] + unsafe fn pasteboard_item_provideDataForType( + &self, + _pasteboard: Option<&NSPasteboard>, + item: &NSPasteboardItem, + r#type: &NSPasteboardType, + ) { + if r#type == NSPasteboardTypeFileURL { + let path = format!("/tmp/{}{}", TEMP_FILE_PREFIX, uuid::Uuid::new_v4().to_string()); + match std::fs::File::create(&path) { + Ok(_) => { + let url = format!("file:///{}", &path); + item.setString_forType(&NSString::from_str(&url), &NSPasteboardTypeFileURL); + let mut task_info = self.ivars().task_info.clone(); + task_info.source_path = path; + self.ivars().tx.send(Ok(task_info)).ok(); + } + Err(e) => { + self.ivars().tx.send(Err(e)).ok(); + } + } + } + } + + // #[method(pasteboardFinishedWithDataProvider:)] + // unsafe fn pasteboardFinishedWithDataProvider(&self, pasteboard: &NSPasteboard) { + // } + } + + unsafe impl PasteboardFileUrlProvider {} +); + +pub(super) fn create_pasteboard_file_url_provider( + task_info: PasteObserverInfo, + tx: Sender>, +) -> Id { + let provider = PasteboardFileUrlProvider::alloc(); + let provider = provider.set_ivars(Ivars { task_info, tx }); + let provider: Id = unsafe { msg_send_id![super(provider), init] }; + provider +} diff --git a/libs/clipboard/src/platform/unix/macos/mod.rs b/libs/clipboard/src/platform/unix/macos/mod.rs new file mode 100644 index 000000000..8b114aa17 --- /dev/null +++ b/libs/clipboard/src/platform/unix/macos/mod.rs @@ -0,0 +1,14 @@ +mod item_data_provider; +mod paste_observer; +mod paste_task; +pub mod pasteboard_context; + +pub fn should_handle_msg(msg: &crate::ClipboardFile) -> bool { + matches!( + msg, + crate::ClipboardFile::FormatList { .. } + | crate::ClipboardFile::FormatDataResponse { .. } + | crate::ClipboardFile::FileContentsResponse { .. } + | crate::ClipboardFile::TryEmpty + ) +} diff --git a/libs/clipboard/src/platform/unix/macos/paste-files-macos.png b/libs/clipboard/src/platform/unix/macos/paste-files-macos.png new file mode 100644 index 000000000..73e4e3f0b Binary files /dev/null and b/libs/clipboard/src/platform/unix/macos/paste-files-macos.png differ diff --git a/libs/clipboard/src/platform/unix/macos/paste_observer.rs b/libs/clipboard/src/platform/unix/macos/paste_observer.rs new file mode 100644 index 000000000..01e8b6c10 --- /dev/null +++ b/libs/clipboard/src/platform/unix/macos/paste_observer.rs @@ -0,0 +1,179 @@ +use super::pasteboard_context::PasteObserverInfo; +use fsevent::{self, StreamFlags}; +use hbb_common::{bail, log, ResultType}; +use std::{ + sync::{ + mpsc::{channel, Receiver, RecvTimeoutError, Sender}, + Arc, Mutex, + }, + thread, + time::Duration, +}; + +enum FseventControl { + Start, + Stop, + Exit, +} + +struct FseventThreadInfo { + tx: Sender, + handle: thread::JoinHandle<()>, +} + +pub struct PasteObserver { + exit: Arc>, + observer_info: Arc>>, + tx_handle_fsevent_thread: Option, + handle_observer_thread: Option>, +} + +impl Drop for PasteObserver { + fn drop(&mut self) { + *self.exit.lock().unwrap() = true; + if let Some(handle_observer_thread) = self.handle_observer_thread.take() { + handle_observer_thread.join().ok(); + } + if let Some(tx_handle_fsevent_thread) = self.tx_handle_fsevent_thread.take() { + tx_handle_fsevent_thread.tx.send(FseventControl::Exit).ok(); + tx_handle_fsevent_thread.handle.join().ok(); + } + } +} + +impl PasteObserver { + const OBSERVE_TIMEOUT: Duration = Duration::from_secs(30); + + pub fn new() -> Self { + Self { + exit: Arc::new(Mutex::new(false)), + observer_info: Default::default(), + tx_handle_fsevent_thread: None, + handle_observer_thread: None, + } + } + + pub fn init(&mut self, cb_pasted: fn(&PasteObserverInfo) -> ()) -> ResultType<()> { + let Some(home_dir) = dirs::home_dir() else { + bail!("No home dir is set, do not observe."); + }; + + let (tx_observer, rx_observer) = channel::(); + let handle_observer = Self::init_thread_observer( + self.exit.clone(), + self.observer_info.clone(), + rx_observer, + cb_pasted, + ); + self.handle_observer_thread = Some(handle_observer); + let (tx_control, rx_control) = channel::(); + let handle_fsevent = Self::init_thread_fsevent( + home_dir.to_string_lossy().to_string(), + tx_observer, + rx_control, + ); + self.tx_handle_fsevent_thread = Some(FseventThreadInfo { + tx: tx_control, + handle: handle_fsevent, + }); + Ok(()) + } + + #[inline] + fn get_file_from_path(path: &String) -> String { + let last_slash = path.rfind('/').or_else(|| path.rfind('\\')); + match last_slash { + Some(index) => path[index + 1..].to_string(), + None => path.clone(), + } + } + + fn init_thread_observer( + exit: Arc>, + observer_info: Arc>>, + rx_observer: Receiver, + cb_pasted: fn(&PasteObserverInfo) -> (), + ) -> thread::JoinHandle<()> { + thread::spawn(move || loop { + match rx_observer.recv_timeout(Duration::from_millis(300)) { + Ok(event) => { + if (event.flag & StreamFlags::ITEM_CREATED) != StreamFlags::NONE + && (event.flag & StreamFlags::ITEM_REMOVED) == StreamFlags::NONE + && (event.flag & StreamFlags::IS_FILE) != StreamFlags::NONE + { + let source_file = observer_info + .lock() + .unwrap() + .as_ref() + .map(|x| Self::get_file_from_path(&x.source_path)); + if let Some(source_file) = source_file { + let file = Self::get_file_from_path(&event.path); + if source_file == file { + if let Some(observer_info) = observer_info.lock().unwrap().as_mut() + { + observer_info.target_path = event.path.clone(); + cb_pasted(observer_info); + } + } + } + } + } + Err(_) => { + if *(exit.lock().unwrap()) { + break; + } + } + } + }) + } + + fn new_fsevent(home_dir: String, tx_observer: Sender) -> fsevent::FsEvent { + let mut evt = fsevent::FsEvent::new(vec![home_dir.to_string()]); + evt.observe_async(tx_observer).ok(); + evt + } + + fn init_thread_fsevent( + home_dir: String, + tx_observer: Sender, + rx_control: Receiver, + ) -> thread::JoinHandle<()> { + log::debug!("fsevent observe dir: {}", &home_dir); + thread::spawn(move || { + let mut fsevent = None; + loop { + match rx_control.recv_timeout(Self::OBSERVE_TIMEOUT) { + Ok(FseventControl::Start) => { + if fsevent.is_none() { + fsevent = + Some(Self::new_fsevent(home_dir.clone(), tx_observer.clone())); + } + } + Ok(FseventControl::Stop) | Err(RecvTimeoutError::Timeout) => { + let _ = fsevent.as_mut().map(|e| e.shutdown_observe()); + fsevent = None; + } + Ok(FseventControl::Exit) | Err(RecvTimeoutError::Disconnected) => { + break; + } + } + } + log::info!("fsevent thread exit"); + let _ = fsevent.as_mut().map(|e| e.shutdown_observe()); + }) + } + + pub fn start(&mut self, observer_info: PasteObserverInfo) { + if let Some(tx_handle_fsevent_thread) = self.tx_handle_fsevent_thread.as_ref() { + self.observer_info.lock().unwrap().replace(observer_info); + tx_handle_fsevent_thread.tx.send(FseventControl::Start).ok(); + } + } + + pub fn stop(&mut self) { + if let Some(tx_handle_fsevent_thread) = &self.tx_handle_fsevent_thread { + self.observer_info = Default::default(); + tx_handle_fsevent_thread.tx.send(FseventControl::Stop).ok(); + } + } +} diff --git a/libs/clipboard/src/platform/unix/macos/paste_task.rs b/libs/clipboard/src/platform/unix/macos/paste_task.rs new file mode 100644 index 000000000..33a11ed6c --- /dev/null +++ b/libs/clipboard/src/platform/unix/macos/paste_task.rs @@ -0,0 +1,639 @@ +use crate::{ + platform::unix::{FileDescription, FileType, BLOCK_SIZE}, + send_data, ClipboardFile, CliprdrError, ProgressPercent, +}; +use hbb_common::{allow_err, log, tokio::time::Instant}; +use std::{ + cmp::min, + fs::{File, FileTimes}, + io::{BufWriter, Write}, + os::macos::fs::FileTimesExt, + path::{Path, PathBuf}, + sync::{ + mpsc::{Receiver, RecvTimeoutError}, + Arc, Mutex, + }, + thread, + time::{Duration, SystemTime}, +}; + +const RECV_RETRY_TIMES: usize = 3; + +const DOWNLOAD_EXTENSION: &str = "rddownload"; +const RECEIVE_WAIT_TIMEOUT: Duration = Duration::from_millis(5_000); + +// https://stackoverflow.com/a/15112784/1926020 +// "1984-01-24 08:00:00 +0000" +const TIMESTAMP_FOR_FILE_PROGRESS_COMPLETED: u64 = 443779200; +const ATTR_PROGRESS_FRACTION_COMPLETED: &str = "com.apple.progress.fractionCompleted"; + +pub struct FileContentsResponse { + pub conn_id: i32, + pub msg_flags: i32, + pub stream_id: i32, + pub requested_data: Vec, +} + +#[derive(Debug)] +struct PasteTaskProgress { + // Use list index to identify the file + // `list_index` is also used as the stream id + list_index: i32, + offset: u64, + total_size: u64, + current_size: u64, + last_sent_time: Instant, + download_file_index: i32, + download_file_size: u64, + download_file_path: String, + download_file_current_size: u64, + file_handle: Option>, + error: Option, + is_canceled: bool, +} + +struct PasteTaskHandle { + progress: PasteTaskProgress, + target_dir: PathBuf, + files: Vec, +} + +pub struct PasteTask { + exit: Arc>, + handle: Arc>>, + handle_worker: Option>, +} + +impl Drop for PasteTask { + fn drop(&mut self) { + *self.exit.lock().unwrap() = true; + if let Some(handle_worker) = self.handle_worker.take() { + handle_worker.join().ok(); + } + } +} + +impl PasteTask { + const INVALID_FILE_INDEX: i32 = -1; + + pub fn new(rx_file_contents: Receiver) -> Self { + let exit = Arc::new(Mutex::new(false)); + let handle = Arc::new(Mutex::new(None)); + let handle_worker = + Self::init_worker_thread(exit.clone(), handle.clone(), rx_file_contents); + Self { + handle, + exit, + handle_worker: Some(handle_worker), + } + } + + pub fn start(&mut self, target_dir: PathBuf, files: Vec) { + let mut task_lock = self.handle.lock().unwrap(); + if task_lock + .as_ref() + .map(|x| !x.is_finished()) + .unwrap_or(false) + { + log::error!("Previous paste task is not finished, ignore new request."); + return; + } + let total_size = files.iter().map(|f| f.size).sum(); + let mut task_handle = PasteTaskHandle { + progress: PasteTaskProgress { + list_index: -1, + offset: 0, + total_size, + current_size: 0, + last_sent_time: Instant::now(), + download_file_index: Self::INVALID_FILE_INDEX, + download_file_size: 0, + download_file_path: "".to_owned(), + download_file_current_size: 0, + file_handle: None, + error: None, + is_canceled: false, + }, + target_dir, + files, + }; + task_handle.update_next(0).ok(); + if task_handle.is_finished() { + task_handle.on_finished(); + } else { + if let Err(e) = task_handle.send_file_contents_request() { + log::error!("Failed to send file contents request, error: {}", &e); + task_handle.on_error(e); + } + } + *task_lock = Some(task_handle); + } + + pub fn cancel(&self) { + let mut task_handle = self.handle.lock().unwrap(); + if let Some(task_handle) = task_handle.as_mut() { + task_handle.progress.is_canceled = true; + task_handle.on_cancelled(); + } + } + + fn init_worker_thread( + exit: Arc>, + handle: Arc>>, + rx_file_contents: Receiver, + ) -> thread::JoinHandle<()> { + thread::spawn(move || { + let mut retry_count = 0; + loop { + if *exit.lock().unwrap() { + break; + } + + match rx_file_contents.recv_timeout(Duration::from_millis(300)) { + Ok(file_contents) => { + let mut task_lock = handle.lock().unwrap(); + let Some(task_handle) = task_lock.as_mut() else { + continue; + }; + if task_handle.is_finished() { + continue; + } + + if file_contents.stream_id != task_handle.progress.list_index { + // ignore invalid stream id + continue; + } else if file_contents.msg_flags != 0x01 { + retry_count += 1; + if retry_count > RECV_RETRY_TIMES { + task_handle.progress.error = Some(CliprdrError::InvalidRequest { + description: format!( + "Failed to read file contents, stream id: {}, msg_flags: {}", + file_contents.stream_id, + file_contents.msg_flags + ), + }); + } + } else { + let resp_list_index = file_contents.stream_id; + let Some(file) = &task_handle.files.get(resp_list_index as usize) + else { + // unreachable + // Because `task_handle.progress.list_index >= task_handle.files.len()` should always be false + log::warn!( + "Invalid response list index: {}, file length: {}", + resp_list_index, + task_handle.files.len() + ); + continue; + }; + if file.conn_id != file_contents.conn_id { + // unreachable + // We still add log here to make sure we can see the error message when it happens. + log::error!( + "Invalid response conn id: {}, expected: {}", + file_contents.conn_id, + file.conn_id + ); + continue; + } + + if let Err(e) = task_handle.handle_file_contents_response(file_contents) + { + log::error!("Failed to handle file contents response: {}", &e); + task_handle.on_error(e); + } + } + + if !task_handle.is_finished() { + if let Err(e) = task_handle.send_file_contents_request() { + log::error!("Failed to send file contents request: {}", &e); + task_handle.on_error(e); + } + } else { + retry_count = 0; + task_handle.on_finished(); + } + } + Err(RecvTimeoutError::Timeout) => { + let mut task_lock = handle.lock().unwrap(); + if let Some(task_handle) = task_lock.as_mut() { + if task_handle.check_receive_timemout() { + retry_count = 0; + task_handle.on_finished(); + } + } + } + Err(RecvTimeoutError::Disconnected) => { + break; + } + } + } + }) + } + + pub fn is_finished(&self) -> bool { + self.handle + .lock() + .unwrap() + .as_ref() + .map(|handle| handle.is_finished()) + .unwrap_or(true) + } + + pub fn progress_percent(&self) -> Option { + self.handle + .lock() + .unwrap() + .as_ref() + .map(|handle| handle.progress_percent()) + } +} + +impl PasteTaskHandle { + fn update_next(&mut self, size: u64) -> Result<(), CliprdrError> { + if self.is_finished() { + return Ok(()); + } + self.progress.current_size += size; + + let is_start = self.progress.list_index == -1; + if is_start || (self.progress.offset + size) >= self.progress.download_file_size { + if !is_start { + self.on_done(); + } + for i in (self.progress.list_index + 1)..self.files.len() as i32 { + let Some(file_desc) = self.files.get(i as usize) else { + return Err(CliprdrError::InvalidRequest { + description: format!("Invalid file index: {}", i), + }); + }; + match file_desc.kind { + FileType::File => { + if file_desc.size == 0 { + if let Some(new_file_path) = + Self::get_new_filename(&self.target_dir, file_desc) + { + if let Ok(f) = std::fs::File::create(&new_file_path) { + f.set_len(0).ok(); + Self::set_file_metadata(&f, file_desc); + } + }; + } else { + self.progress.list_index = i; + self.progress.offset = 0; + self.open_new_writer()?; + break; + } + } + FileType::Directory => { + let path = self.target_dir.join(&file_desc.name); + if !path.exists() { + std::fs::create_dir_all(path).ok(); + } + } + FileType::Symlink => { + // to-do: handle symlink + } + } + } + } else { + self.progress.offset += size; + self.progress.download_file_current_size += size; + self.update_progress_completed(None); + } + if self.progress.file_handle.is_none() { + self.progress.list_index = self.files.len() as i32; + self.progress.offset = 0; + self.progress.download_file_size = 0; + self.progress.download_file_current_size = 0; + } + Ok(()) + } + + fn start_progress_completed(&self) { + if let Some(file) = self.progress.file_handle.as_ref() { + let creation_time = + SystemTime::UNIX_EPOCH + Duration::from_secs(TIMESTAMP_FOR_FILE_PROGRESS_COMPLETED); + file.get_ref() + .set_times(FileTimes::new().set_created(creation_time)) + .ok(); + xattr::set( + &self.progress.download_file_path, + ATTR_PROGRESS_FRACTION_COMPLETED, + "0.0".as_bytes(), + ) + .ok(); + } + } + + fn update_progress_completed(&mut self, fraction_completed: Option) { + let fraction_completed = fraction_completed.unwrap_or_else(|| { + let current_size = self.progress.download_file_current_size as f64; + let total_size = self.progress.download_file_size as f64; + if total_size > 0.0 { + current_size / total_size + } else { + 1.0 + } + }); + xattr::set( + &self.progress.download_file_path, + ATTR_PROGRESS_FRACTION_COMPLETED, + &fraction_completed.to_string().as_bytes(), + ) + .ok(); + } + + #[inline] + fn remove_progress_completed(path: &str) { + if !path.is_empty() { + xattr::remove(path, ATTR_PROGRESS_FRACTION_COMPLETED).ok(); + } + } + + fn open_new_writer(&mut self) -> Result<(), CliprdrError> { + let Some(file) = &self.files.get(self.progress.list_index as usize) else { + return Err(CliprdrError::InvalidRequest { + description: format!( + "Invalid file index: {}, file count: {}", + self.progress.list_index, + self.files.len() + ), + }); + }; + + let original_file_path = self + .target_dir + .join(&file.name) + .to_string_lossy() + .to_string(); + let Some(download_file_path) = Self::get_first_filename( + format!("{}.{}", original_file_path, DOWNLOAD_EXTENSION), + file.kind, + ) else { + return Err(CliprdrError::CommonError { + description: format!("Failed to get download file path: {}", original_file_path), + }); + }; + let Some(download_path_parent) = Path::new(&download_file_path).parent() else { + return Err(CliprdrError::CommonError { + description: format!( + "Failed to get parent of the download file path: {}", + original_file_path + ), + }); + }; + if !download_path_parent.exists() { + if let Err(e) = std::fs::create_dir_all(download_path_parent) { + return Err(CliprdrError::FileError { + path: download_path_parent.to_string_lossy().to_string(), + err: e, + }); + } + } + match std::fs::File::create(&download_file_path) { + Ok(handle) => { + let writer = BufWriter::with_capacity(BLOCK_SIZE as usize * 2, handle); + self.progress.download_file_index = self.progress.list_index; + self.progress.download_file_size = file.size; + self.progress.download_file_path = download_file_path; + self.progress.download_file_current_size = 0; + self.progress.file_handle = Some(writer); + self.start_progress_completed(); + } + Err(e) => { + self.progress.error = Some(CliprdrError::FileError { + path: download_file_path, + err: e, + }); + } + }; + Ok(()) + } + + fn get_first_filename(path: String, r#type: FileType) -> Option { + let p = Path::new(&path); + if !p.exists() { + return Some(path); + } else { + for i in 1..9999999 { + let new_path = match r#type { + FileType::File => { + if let Some(ext) = p.extension() { + let new_name = format!( + "{}-{}.{}", + p.file_stem().unwrap_or_default().to_string_lossy(), + i, + ext.to_string_lossy() + ); + p.with_file_name(new_name).to_string_lossy().to_string() + } else { + format!("{} ({})", path, i) + } + } + FileType::Directory => format!("{} ({})", path, i), + FileType::Symlink => { + // to-do: handle symlink + return None; + } + }; + if !Path::new(&new_path).exists() { + return Some(new_path); + } + } + } + // unreachable + None + } + + fn progress_percent(&self) -> ProgressPercent { + let percent = self.progress.current_size as f64 / self.progress.total_size as f64; + ProgressPercent { + percent, + is_canceled: self.progress.is_canceled, + is_failed: self.progress.error.is_some(), + } + } + + fn is_finished(&self) -> bool { + self.progress.is_canceled + || self.progress.error.is_some() + || self.progress.list_index >= self.files.len() as i32 + } + + fn check_receive_timemout(&mut self) -> bool { + if !self.is_finished() { + if self.progress.last_sent_time.elapsed() > RECEIVE_WAIT_TIMEOUT { + self.progress.error = Some(CliprdrError::InvalidRequest { + description: "Failed to read file contents".to_string(), + }); + return true; + } + } + false + } + + fn on_finished(&mut self) { + if self.progress.error.is_some() { + self.on_cancelled(); + } else { + self.on_done(); + } + if self.progress.current_size != self.progress.total_size { + self.progress.error = Some(CliprdrError::InvalidRequest { + description: "Failed to download all files".to_string(), + }); + } + } + + fn on_error(&mut self, error: CliprdrError) { + self.progress.error = Some(error); + self.on_cancelled(); + } + + fn on_cancelled(&mut self) { + self.progress.file_handle = None; + std::fs::remove_file(&self.progress.download_file_path).ok(); + } + + fn on_done(&mut self) { + self.update_progress_completed(Some(1.0)); + Self::remove_progress_completed(&self.progress.download_file_path); + + let Some(file) = self.progress.file_handle.as_mut() else { + return; + }; + if self.progress.download_file_index == PasteTask::INVALID_FILE_INDEX { + return; + } + + if let Err(e) = file.flush() { + log::error!("Failed to flush file: {:?}", e); + } + self.progress.file_handle = None; + + let Some(file_desc) = self.files.get(self.progress.download_file_index as usize) else { + // unreachable + log::error!( + "Failed to get file description: {}", + self.progress.download_file_index + ); + return; + }; + let Some(rename_to_path) = Self::get_new_filename(&self.target_dir, file_desc) else { + return; + }; + match std::fs::rename(&self.progress.download_file_path, &rename_to_path) { + Ok(_) => Self::set_file_metadata2(&rename_to_path, file_desc), + Err(e) => { + log::error!("Failed to rename file: {:?}", e); + } + } + self.progress.download_file_path = "".to_owned(); + self.progress.download_file_index = PasteTask::INVALID_FILE_INDEX; + } + + fn get_new_filename(target_dir: &PathBuf, file_desc: &FileDescription) -> Option { + let mut rename_to_path = target_dir + .join(&file_desc.name) + .to_string_lossy() + .to_string(); + if Path::new(&rename_to_path).exists() { + let Some(new_path) = Self::get_first_filename(rename_to_path.clone(), file_desc.kind) + else { + log::error!("Failed to get new file name: {}", &rename_to_path); + return None; + }; + rename_to_path = new_path; + } + Some(rename_to_path) + } + + #[inline] + fn set_file_metadata(f: &File, file_desc: &FileDescription) { + let times = FileTimes::new() + .set_accessed(file_desc.atime) + .set_modified(file_desc.last_modified) + .set_created(file_desc.creation_time); + f.set_times(times).ok(); + } + + #[inline] + fn set_file_metadata2(path: &str, file_desc: &FileDescription) { + let times = FileTimes::new() + .set_accessed(file_desc.atime) + .set_modified(file_desc.last_modified) + .set_created(file_desc.creation_time); + File::options() + .write(true) + .open(path) + .map(|f| f.set_times(times)) + .ok(); + } + + fn send_file_contents_request(&mut self) -> Result<(), CliprdrError> { + if self.is_finished() { + return Ok(()); + } + + let stream_id = self.progress.list_index; + let list_index = self.progress.list_index; + let Some(file) = &self.files.get(list_index as usize) else { + // unreachable + return Err(CliprdrError::InvalidRequest { + description: format!("Invalid file index: {}", list_index), + }); + }; + let cb_requested = min(BLOCK_SIZE as u64, file.size - self.progress.offset); + let conn_id = file.conn_id; + + let (n_position_high, n_position_low) = ( + (self.progress.offset >> 32) as i32, + (self.progress.offset & (u32::MAX as u64)) as i32, + ); + let request = ClipboardFile::FileContentsRequest { + stream_id, + list_index, + dw_flags: 2, + n_position_low, + n_position_high, + cb_requested: cb_requested as _, + have_clip_data_id: false, + clip_data_id: 0, + }; + allow_err!(send_data(conn_id, request)); + self.progress.last_sent_time = Instant::now(); + + Ok(()) + } + + fn handle_file_contents_response( + &mut self, + file_contents: FileContentsResponse, + ) -> Result<(), CliprdrError> { + if let Some(file) = self.progress.file_handle.as_mut() { + let data = file_contents.requested_data.as_slice(); + let mut write_len = 0; + while write_len < data.len() { + match file.write(&data[write_len..]) { + Ok(len) => { + write_len += len; + } + Err(e) => { + return Err(CliprdrError::FileError { + path: self.progress.download_file_path.clone(), + err: e, + }); + } + } + } + self.update_next(write_len as _)?; + } else { + return Err(CliprdrError::FileError { + path: self.progress.download_file_path.clone(), + err: std::io::Error::new(std::io::ErrorKind::NotFound, "file handle is not opened"), + }); + } + Ok(()) + } +} diff --git a/libs/clipboard/src/platform/unix/macos/pasteboard_context.rs b/libs/clipboard/src/platform/unix/macos/pasteboard_context.rs new file mode 100644 index 000000000..4c7474093 --- /dev/null +++ b/libs/clipboard/src/platform/unix/macos/pasteboard_context.rs @@ -0,0 +1,460 @@ +use super::{ + item_data_provider::create_pasteboard_file_url_provider, + paste_observer::PasteObserver, + paste_task::{FileContentsResponse, PasteTask}, +}; +use crate::{ + platform::unix::{ + filetype::FileDescription, FILECONTENTS_FORMAT_NAME, FILEDESCRIPTORW_FORMAT_NAME, + }, + send_data, ClipboardFile, CliprdrError, CliprdrServiceContext, ProgressPercent, +}; +use hbb_common::{allow_err, bail, log, ResultType}; +use objc2::{msg_send_id, rc::autoreleasepool, rc::Id, runtime::ProtocolObject, ClassType}; +use objc2_app_kit::{NSPasteboard, NSPasteboardTypeFileURL}; +use objc2_foundation::{NSArray, NSString}; +use std::{ + io, + path::Path, + sync::{ + mpsc::{channel, Receiver, RecvTimeoutError, Sender}, + Arc, Mutex, + }, + thread, + time::Duration, +}; + +lazy_static::lazy_static! { + static ref PASTE_OBSERVER_INFO: Arc>> = Default::default(); +} + +pub const TEMP_FILE_PREFIX: &str = ".rustdesk_"; + +#[derive(Default, Debug, Clone, PartialEq)] +pub(super) struct PasteObserverInfo { + pub file_descriptor_id: i32, + pub conn_id: i32, + pub source_path: String, + pub target_path: String, +} + +impl PasteObserverInfo { + fn exit_msg() -> Self { + Self::default() + } +} + +struct ContextInfo { + tx: Sender>, + handle: thread::JoinHandle<()>, +} + +pub struct PasteboardContext { + pasteboard: Id, + observer: Arc>, + tx_handle: Option, + tx_remove_file: Option>, + remove_file_handle: Option>, + tx_paste_task: Sender, + paste_task: Arc>, +} + +unsafe impl Send for PasteboardContext {} +unsafe impl Sync for PasteboardContext {} + +impl Drop for PasteboardContext { + fn drop(&mut self) { + self.observer.lock().unwrap().stop(); + if let Some(tx_handle) = self.tx_handle.take() { + if tx_handle.tx.send(Ok(PasteObserverInfo::exit_msg())).is_ok() { + tx_handle.handle.join().ok(); + } + } + } +} + +impl CliprdrServiceContext for PasteboardContext { + fn set_is_stopped(&mut self) -> Result<(), CliprdrError> { + Ok(()) + } + + fn empty_clipboard(&mut self, conn_id: i32) -> Result { + Ok(self.empty_clipboard_(conn_id)) + } + + fn server_clip_file(&mut self, conn_id: i32, msg: ClipboardFile) -> Result<(), CliprdrError> { + self.server_clip_file_(conn_id, msg) + } + + fn get_progress_percent(&self) -> Option { + self.paste_task.lock().unwrap().progress_percent() + } + + fn cancel(&mut self) { + self.paste_task.lock().unwrap().cancel(); + } +} + +impl PasteboardContext { + fn init(&mut self) { + let (tx_remove_file, rx_remove_file) = channel(); + let handle_remove_file = Self::init_thread_remove_file(rx_remove_file); + self.tx_remove_file = Some(tx_remove_file.clone()); + self.remove_file_handle = Some(handle_remove_file); + + let (tx, rx) = channel(); + let observer: Arc> = self.observer.clone(); + let handle = Self::init_thread_observer(tx_remove_file, rx, observer); + self.tx_handle = Some(ContextInfo { tx, handle }); + } + + fn init_thread_observer( + tx_remove_file: Sender, + rx: Receiver>, + observer: Arc>, + ) -> thread::JoinHandle<()> { + let exit_msg = PasteObserverInfo::exit_msg(); + thread::spawn(move || loop { + match rx.recv() { + Ok(Ok(task_info)) => { + if task_info == exit_msg { + log::debug!("pasteboard item data provider: exit"); + break; + } + tx_remove_file.send(task_info.source_path.clone()).ok(); + observer.lock().unwrap().start(task_info); + } + Ok(Err(e)) => { + log::error!("pasteboard item data provider, inner error: {e}"); + } + Err(e) => { + log::error!("pasteboard item data provider, error: {e}"); + break; + } + } + }) + } + + fn init_thread_remove_file(rx: Receiver) -> thread::JoinHandle<()> { + thread::spawn(move || { + let mut cur_file: Option = None; + loop { + match rx.recv_timeout(Duration::from_secs(30)) { + Ok(path) => { + if let Some(file) = cur_file.take() { + if !file.is_empty() { + std::fs::remove_file(&file).ok(); + } + } + if !path.is_empty() { + cur_file = Some(path); + } + } + Err(e) => { + if let Some(file) = cur_file.take() { + if !file.is_empty() { + std::fs::remove_file(&file).ok(); + } + } + if e == RecvTimeoutError::Disconnected { + break; + } + } + } + } + }) + } + + // Just removing the file can also make paste option in the context menu disappear. + fn empty_clipboard_(&mut self, _conn_id: i32) -> bool { + self.tx_remove_file + .as_ref() + .map(|tx| tx.send("".to_string()).ok()); + true + } + + fn temp_files_count() -> usize { + let mut count = 0; + if let Ok(entries) = std::fs::read_dir("/tmp") { + for entry in entries { + if let Ok(entry) = entry { + let path = entry.path(); + if path.is_file() { + if let Some(file_name) = path.file_name() { + if let Some(file_name_str) = file_name.to_str() { + if file_name_str.starts_with(TEMP_FILE_PREFIX) { + count += 1; + } + } + } + } + } + } + } + count + } + + fn server_clip_file_(&mut self, conn_id: i32, msg: ClipboardFile) -> Result<(), CliprdrError> { + match msg { + ClipboardFile::FormatList { format_list } => { + let temp_files = Self::temp_files_count(); + if temp_files >= 3 { + // The temp files should be 0 or 1 in normal case. + // We should not continue to paste files if there are more than 3 temp files. + return Err(CliprdrError::CommonError { + description: format!( + "too many temp files, current: {}, limit: {}", + temp_files, 3 + ), + }); + } + + let task_lock = self.paste_task.lock().unwrap(); + if !task_lock.is_finished() { + return Err(CliprdrError::CommonError { + description: "previous file paste task is not finished".to_string(), + }); + } + self.handle_format_list(conn_id, format_list)?; + } + ClipboardFile::FormatDataResponse { + msg_flags, + format_data, + } => { + self.handle_format_data_response(conn_id, msg_flags, format_data)?; + } + ClipboardFile::FileContentsResponse { + msg_flags, + stream_id, + requested_data, + } => { + self.handle_file_contents_response(conn_id, msg_flags, stream_id, requested_data)?; + } + ClipboardFile::TryEmpty => self.handle_try_empty(conn_id), + _ => {} + } + Ok(()) + } + + fn handle_format_list( + &self, + conn_id: i32, + format_list: Vec<(i32, String)>, + ) -> Result<(), CliprdrError> { + if let Some(tx_handle) = self.tx_handle.as_ref() { + if !format_list + .iter() + .find(|(_, name)| name == FILECONTENTS_FORMAT_NAME) + .map(|(id, _)| *id) + .is_some() + { + return Err(CliprdrError::CommonError { + description: "no file contents format found".to_string(), + }); + }; + let Some(file_descriptor_id) = format_list + .iter() + .find(|(_, name)| name == FILEDESCRIPTORW_FORMAT_NAME) + .map(|(id, _)| *id) + else { + return Err(CliprdrError::CommonError { + description: "no file descriptor format found".to_string(), + }); + }; + + autoreleasepool(|_| self.set_clipboard_item(tx_handle, conn_id, file_descriptor_id))?; + } else { + return Err(CliprdrError::CommonError { + description: "pasteboard context is not inited".to_string(), + }); + } + Ok(()) + } + + fn set_clipboard_item( + &self, + tx_handle: &ContextInfo, + conn_id: i32, + file_descriptor_id: i32, + ) -> Result<(), CliprdrError> { + let tx = tx_handle.tx.clone(); + let provider = create_pasteboard_file_url_provider( + PasteObserverInfo { + file_descriptor_id, + conn_id, + source_path: "".to_string(), + target_path: "".to_string(), + }, + tx, + ); + unsafe { + let types = NSArray::from_vec(vec![NSString::from_str( + &NSPasteboardTypeFileURL.to_string(), + )]); + let item = objc2_app_kit::NSPasteboardItem::new(); + item.setDataProvider_forTypes(&ProtocolObject::from_id(provider), &types); + self.pasteboard.clearContents(); + if !self + .pasteboard + .writeObjects(&Id::cast(NSArray::from_vec(vec![item]))) + { + return Err(CliprdrError::CommonError { + description: "failed to write objects".to_string(), + }); + } + } + Ok(()) + } + + fn handle_format_data_response( + &self, + conn_id: i32, + msg_flags: i32, + format_data: Vec, + ) -> Result<(), CliprdrError> { + log::debug!("handle format data response, msg_flags: {msg_flags}"); + if msg_flags != 0x1 { + // return failure message? + } + + let mut task_lock = self.paste_task.lock().unwrap(); + let target_dir = PASTE_OBSERVER_INFO + .lock() + .unwrap() + .as_ref() + .map(|task| task.target_path.clone()); + // unreachable in normal case + let Some(target_dir) = target_dir.as_ref().map(|d| Path::new(d).parent()).flatten() else { + return Err(CliprdrError::CommonError { + description: "failed to get parent path".to_string(), + }); + }; + // unreachable in normal case + if !target_dir.exists() { + return Err(CliprdrError::CommonError { + description: "target path does not exist".to_string(), + }); + } + let target_dir = target_dir.to_owned(); + match FileDescription::parse_file_descriptors(format_data, conn_id) { + Ok(files) => { + task_lock.start(target_dir, files); + Ok(()) + } + Err(e) => { + PASTE_OBSERVER_INFO + .lock() + .unwrap() + .replace(PasteObserverInfo::default()); + Err(e) + } + } + } + + fn handle_file_contents_response( + &self, + conn_id: i32, + msg_flags: i32, + stream_id: i32, + requested_data: Vec, + ) -> Result<(), CliprdrError> { + log::debug!("handle file contents response"); + self.tx_paste_task + .send(FileContentsResponse { + conn_id, + msg_flags, + stream_id, + requested_data, + }) + .ok(); + Ok(()) + } + + fn handle_try_empty(&mut self, conn_id: i32) { + log::debug!("empty_clipboard called"); + let ret = self.empty_clipboard_(conn_id); + log::debug!( + "empty_clipboard called, conn_id {}, return {}", + conn_id, + ret + ); + } +} + +fn handle_paste_result(task_info: &PasteObserverInfo) { + log::info!( + "file {} is pasted to {}", + &task_info.source_path, + &task_info.target_path + ); + if Path::new(&task_info.target_path).parent().is_none() { + log::error!( + "failed to get parent path of {}, no need to perform pasting", + &task_info.target_path + ); + return; + } + + PASTE_OBSERVER_INFO + .lock() + .unwrap() + .replace(task_info.clone()); + // to-do: add a timeout to clear data in `PASTE_OBSERVER_INFO`. + std::fs::remove_file(&task_info.source_path).ok(); + std::fs::remove_file(&task_info.target_path).ok(); + let data = ClipboardFile::FormatDataRequest { + requested_format_id: task_info.file_descriptor_id, + }; + allow_err!(send_data(task_info.conn_id as _, data)); +} + +#[inline] +pub fn create_pasteboard_context() -> ResultType> { + let pasteboard: Option> = + unsafe { msg_send_id![NSPasteboard::class(), generalPasteboard] }; + let Some(pasteboard) = pasteboard else { + bail!("failed to get general pasteboard"); + }; + let mut observer = PasteObserver::new(); + observer.init(handle_paste_result)?; + let (tx, rx) = channel(); + let mut context = Box::new(PasteboardContext { + pasteboard, + observer: Arc::new(Mutex::new(observer)), + tx_handle: None, + tx_remove_file: None, + remove_file_handle: None, + tx_paste_task: tx, + paste_task: Arc::new(Mutex::new(PasteTask::new(rx))), + }); + context.init(); + Ok(context) +} + +#[cfg(test)] +mod tests { + #[test] + fn test_temp_files_count() { + let mut c = super::PasteboardContext::temp_files_count(); + + let mut created_files = vec![]; + for _ in 0..10 { + let path = format!( + "/tmp/{}{}", + super::TEMP_FILE_PREFIX, + uuid::Uuid::new_v4().to_string() + ); + if std::fs::File::create(&path).is_ok() { + created_files.push(path); + c += 1; + } + } + + assert_eq!(c, super::PasteboardContext::temp_files_count()); + + // Clean up the created files. + for file in created_files { + std::fs::remove_file(&file).ok(); + } + } +} diff --git a/libs/clipboard/src/platform/unix/mod.rs b/libs/clipboard/src/platform/unix/mod.rs index 9a0861094..de5917f49 100644 --- a/libs/clipboard/src/platform/unix/mod.rs +++ b/libs/clipboard/src/platform/unix/mod.rs @@ -1,48 +1,42 @@ -use std::{ - path::PathBuf, - sync::{mpsc::Sender, Arc}, - time::Duration, -}; - use dashmap::DashMap; -use fuser::MountOption; -use hbb_common::{ - bytes::{BufMut, BytesMut}, - log, -}; use lazy_static::lazy_static; -use parking_lot::Mutex; -use crate::{ - platform::{fuse::FileDescription, unix::local_file::construct_file_list}, - send_data, ClipboardFile, CliprdrError, CliprdrServiceContext, -}; - -use self::local_file::LocalFile; +mod filetype; +pub use filetype::{FileDescription, FileType}; +/// use FUSE for file pasting on these platforms #[cfg(target_os = "linux")] -use self::url::{encode_path_to_uri, parse_plain_uri_list}; - -use super::fuse::FuseServer; - -#[cfg(target_os = "linux")] -/// clipboard implementation of x11 -pub mod x11; - +pub mod fuse; #[cfg(target_os = "macos")] -/// clipboard implementation of macos -pub mod ns_clipboard; +pub mod macos; pub mod local_file; +pub mod serv_files; -#[cfg(target_os = "linux")] -pub mod url; +/// has valid file attributes +pub const FLAGS_FD_ATTRIBUTES: u32 = 0x04; +/// has valid file size +pub const FLAGS_FD_SIZE: u32 = 0x40; +/// has valid last write time +pub const FLAGS_FD_LAST_WRITE: u32 = 0x20; +/// show progress +pub const FLAGS_FD_PROGRESSUI: u32 = 0x4000; +/// transferred from unix, contains file mode +/// P.S. this flag is not used in windows +pub const FLAGS_FD_UNIX_MODE: u32 = 0x08; // not actual format id, just a placeholder -const FILEDESCRIPTOR_FORMAT_ID: i32 = 49334; -const FILEDESCRIPTORW_FORMAT_NAME: &str = "FileGroupDescriptorW"; +pub const FILEDESCRIPTOR_FORMAT_ID: i32 = 49334; +pub const FILEDESCRIPTORW_FORMAT_NAME: &str = "FileGroupDescriptorW"; // not actual format id, just a placeholder -const FILECONTENTS_FORMAT_ID: i32 = 49267; -const FILECONTENTS_FORMAT_NAME: &str = "FileContents"; +pub const FILECONTENTS_FORMAT_ID: i32 = 49267; +pub const FILECONTENTS_FORMAT_NAME: &str = "FileContents"; + +/// block size for fuse, align to our asynchronic request size over FileContentsRequest. +pub(crate) const BLOCK_SIZE: u32 = 4 * 1024 * 1024; + +// begin of epoch used by microsoft +// 1601-01-01 00:00:00 + LDAP_EPOCH_DELTA*(100 ns) = 1970-01-01 00:00:00 +const LDAP_EPOCH_DELTA: u64 = 116444772610000000; lazy_static! { static ref REMOTE_FORMAT_MAP: DashMap = DashMap::from_iter( @@ -58,541 +52,7 @@ lazy_static! { ); } -fn get_local_format(remote_id: i32) -> Option { +#[inline] +pub fn get_local_format(remote_id: i32) -> Option { REMOTE_FORMAT_MAP.get(&remote_id).map(|s| s.clone()) } - -fn add_remote_format(local_name: &str, remote_id: i32) { - REMOTE_FORMAT_MAP.insert(remote_id, local_name.to_string()); -} - -trait SysClipboard: Send + Sync { - fn start(&self); - - fn set_file_list(&self, paths: &[PathBuf]) -> Result<(), CliprdrError>; - fn get_file_list(&self) -> Vec; -} - -#[cfg(target_os = "linux")] -fn get_sys_clipboard(ignore_path: &PathBuf) -> Result, CliprdrError> { - #[cfg(feature = "wayland")] - { - unimplemented!() - } - #[cfg(not(feature = "wayland"))] - { - use x11::*; - let x11_clip = X11Clipboard::new(ignore_path)?; - Ok(Box::new(x11_clip) as Box<_>) - } -} - -#[cfg(target_os = "macos")] -fn get_sys_clipboard(ignore_path: &PathBuf) -> Result, CliprdrError> { - use ns_clipboard::*; - let ns_pb = NsPasteboard::new(ignore_path)?; - Ok(Box::new(ns_pb) as Box<_>) -} - -#[derive(Debug)] -enum FileContentsRequest { - Size { - stream_id: i32, - file_idx: usize, - }, - - Range { - stream_id: i32, - file_idx: usize, - offset: u64, - length: u64, - }, -} - -pub struct ClipboardContext { - pub fuse_mount_point: PathBuf, - /// stores fuse background session handle - fuse_handle: Mutex>, - - /// a sender of clipboard file contents pdu to fuse server - fuse_tx: Sender, - fuse_server: Arc>, - - clipboard: Arc, - local_files: Mutex>, -} - -impl ClipboardContext { - pub fn new(timeout: Duration, mount_path: PathBuf) -> Result { - // assert mount path exists - let fuse_mount_point = mount_path.canonicalize().map_err(|e| { - log::error!("failed to canonicalize mount path: {:?}", e); - CliprdrError::CliprdrInit - })?; - - let (fuse_server, fuse_tx) = FuseServer::new(timeout); - - let fuse_server = Arc::new(Mutex::new(fuse_server)); - - let clipboard = get_sys_clipboard(&fuse_mount_point)?; - let clipboard = Arc::from(clipboard) as Arc<_>; - let local_files = Mutex::new(vec![]); - - Ok(Self { - fuse_mount_point, - fuse_server, - fuse_tx, - fuse_handle: Mutex::new(None), - clipboard, - local_files, - }) - } - - pub fn run(&self) -> Result<(), CliprdrError> { - if !self.is_stopped() { - return Ok(()); - } - - let mut fuse_handle = self.fuse_handle.lock(); - - let mount_path = &self.fuse_mount_point; - - let mnt_opts = [ - MountOption::FSName("rustdesk-cliprdr-fs".to_string()), - MountOption::NoAtime, - MountOption::RO, - ]; - log::info!( - "mounting clipboard FUSE to {}", - self.fuse_mount_point.display() - ); - - let new_handle = fuser::spawn_mount2( - FuseServer::client(self.fuse_server.clone()), - mount_path, - &mnt_opts, - ) - .map_err(|e| { - log::error!("failed to mount cliprdr fuse: {:?}", e); - CliprdrError::CliprdrInit - })?; - *fuse_handle = Some(new_handle); - - let clipboard = self.clipboard.clone(); - - std::thread::spawn(move || { - log::debug!("start listening clipboard"); - clipboard.start(); - }); - - Ok(()) - } - - /// set clipboard data from file list - pub fn set_clipboard(&self, paths: &[PathBuf]) -> Result<(), CliprdrError> { - let prefix = self.fuse_mount_point.clone(); - let paths: Vec = paths.iter().cloned().map(|p| prefix.join(p)).collect(); - log::debug!("setting clipboard with paths: {:?}", paths); - self.clipboard.set_file_list(&paths)?; - log::debug!("clipboard set, paths: {:?}", paths); - Ok(()) - } - - fn serve_file_contents( - &self, - conn_id: i32, - request: FileContentsRequest, - ) -> Result<(), CliprdrError> { - let mut file_list = self.local_files.lock(); - - let (file_idx, file_contents_resp) = match request { - FileContentsRequest::Size { - stream_id, - file_idx, - } => { - log::debug!("file contents (size) requested from conn: {}", conn_id); - let Some(file) = file_list.get(file_idx) else { - log::error!( - "invalid file index {} requested from conn: {}", - file_idx, - conn_id - ); - resp_file_contents_fail(conn_id, stream_id); - - return Err(CliprdrError::InvalidRequest { - description: format!( - "invalid file index {} requested from conn: {}", - file_idx, conn_id - ), - }); - }; - - log::debug!( - "conn {} requested file-{}: {}", - conn_id, - file_idx, - file.name - ); - - let size = file.size; - ( - file_idx, - ClipboardFile::FileContentsResponse { - msg_flags: 0x1, - stream_id, - requested_data: size.to_le_bytes().to_vec(), - }, - ) - } - FileContentsRequest::Range { - stream_id, - file_idx, - offset, - length, - } => { - log::debug!( - "file contents (range from {} length {}) request from conn: {}", - offset, - length, - conn_id - ); - let Some(file) = file_list.get_mut(file_idx) else { - log::error!( - "invalid file index {} requested from conn: {}", - file_idx, - conn_id - ); - resp_file_contents_fail(conn_id, stream_id); - return Err(CliprdrError::InvalidRequest { - description: format!( - "invalid file index {} requested from conn: {}", - file_idx, conn_id - ), - }); - }; - log::debug!( - "conn {} requested file-{}: {}", - conn_id, - file_idx, - file.name - ); - - if offset > file.size { - log::error!("invalid reading offset requested from conn: {}", conn_id); - resp_file_contents_fail(conn_id, stream_id); - - return Err(CliprdrError::InvalidRequest { - description: format!( - "invalid reading offset requested from conn: {}", - conn_id - ), - }); - } - let read_size = if offset + length > file.size { - file.size - offset - } else { - length - }; - - let mut buf = vec![0u8; read_size as usize]; - - file.read_exact_at(&mut buf, offset)?; - - ( - file_idx, - ClipboardFile::FileContentsResponse { - msg_flags: 0x1, - stream_id, - requested_data: buf, - }, - ) - } - }; - - send_data(conn_id, file_contents_resp); - log::debug!("file contents sent to conn: {}", conn_id); - // hot reload next file - for next_file in file_list.iter_mut().skip(file_idx + 1) { - if !next_file.is_dir { - next_file.load_handle()?; - break; - } - } - Ok(()) - } -} - -fn resp_file_contents_fail(conn_id: i32, stream_id: i32) { - let resp = ClipboardFile::FileContentsResponse { - msg_flags: 0x2, - stream_id, - requested_data: vec![], - }; - send_data(conn_id, resp) -} - -impl ClipboardContext { - pub fn is_stopped(&self) -> bool { - self.fuse_handle.lock().is_none() - } - - pub fn sync_local_files(&self) -> Result<(), CliprdrError> { - let mut local_files = self.local_files.lock(); - let clipboard_files = self.clipboard.get_file_list(); - let local_file_list: Vec = local_files.iter().map(|f| f.path.clone()).collect(); - if local_file_list == clipboard_files { - return Ok(()); - } - let new_files = construct_file_list(&clipboard_files)?; - *local_files = new_files; - Ok(()) - } - - pub fn serve(&self, conn_id: i32, msg: ClipboardFile) -> Result<(), CliprdrError> { - log::debug!("serve clipboard file from conn: {}", conn_id); - if self.is_stopped() { - log::debug!("cliprdr stopped, restart it"); - self.run()?; - } - match msg { - ClipboardFile::NotifyCallback { .. } => { - unreachable!() - } - ClipboardFile::MonitorReady => { - log::debug!("server_monitor_ready called"); - - self.send_file_list(conn_id)?; - - Ok(()) - } - - ClipboardFile::FormatList { format_list } => { - log::debug!("server_format_list called"); - // filter out "FileGroupDescriptorW" and "FileContents" - let fmt_lst: Vec<(i32, String)> = format_list - .into_iter() - .filter(|(_, name)| { - name == FILEDESCRIPTORW_FORMAT_NAME || name == FILECONTENTS_FORMAT_NAME - }) - .collect(); - if fmt_lst.len() != 2 { - log::debug!("no supported formats"); - return Ok(()); - } - log::debug!("supported formats: {:?}", fmt_lst); - let file_contents_id = fmt_lst - .iter() - .find(|(_, name)| name == FILECONTENTS_FORMAT_NAME) - .map(|(id, _)| *id)?; - let file_descriptor_id = fmt_lst - .iter() - .find(|(_, name)| name == FILEDESCRIPTORW_FORMAT_NAME) - .map(|(id, _)| *id)?; - - add_remote_format(FILECONTENTS_FORMAT_NAME, file_contents_id); - add_remote_format(FILEDESCRIPTORW_FORMAT_NAME, file_descriptor_id); - - // sync file system from peer - let data = ClipboardFile::FormatDataRequest { - requested_format_id: file_descriptor_id, - }; - send_data(conn_id, data); - - Ok(()) - } - ClipboardFile::FormatListResponse { msg_flags } => { - log::debug!("server_format_list_response called"); - if msg_flags != 0x1 { - send_format_list(conn_id) - } else { - Ok(()) - } - } - ClipboardFile::FormatDataRequest { - requested_format_id, - } => { - log::debug!("server_format_data_request called"); - let Some(format) = get_local_format(requested_format_id) else { - log::error!( - "got unsupported format data request: id={} from conn={}", - requested_format_id, - conn_id - ); - resp_format_data_failure(conn_id); - return Ok(()); - }; - - if format == FILEDESCRIPTORW_FORMAT_NAME { - self.send_file_list(conn_id)?; - } else if format == FILECONTENTS_FORMAT_NAME { - log::error!( - "try to read file contents with FormatDataRequest from conn={}", - conn_id - ); - resp_format_data_failure(conn_id); - } else { - log::error!( - "got unsupported format data request: id={} from conn={}", - requested_format_id, - conn_id - ); - resp_format_data_failure(conn_id); - } - Ok(()) - } - ClipboardFile::FormatDataResponse { - msg_flags, - format_data, - } => { - log::debug!( - "server_format_data_response called, msg_flags={}", - msg_flags - ); - - if msg_flags != 0x1 { - resp_format_data_failure(conn_id); - return Ok(()); - } - - log::debug!("parsing file descriptors"); - // this must be a file descriptor format data - let files = FileDescription::parse_file_descriptors(format_data, conn_id)?; - - let paths = { - let mut fuse_guard = self.fuse_server.lock(); - fuse_guard.load_file_list(files)?; - - fuse_guard.list_root() - }; - - log::debug!("load file list: {:?}", paths); - self.set_clipboard(&paths)?; - Ok(()) - } - ClipboardFile::FileContentsResponse { .. } => { - log::debug!("server_file_contents_response called"); - // we don't know its corresponding request, no resend can be performed - self.fuse_tx.send(msg).map_err(|e| { - log::error!("failed to send file contents response to fuse: {:?}", e); - CliprdrError::ClipboardInternalError - })?; - Ok(()) - } - ClipboardFile::FileContentsRequest { - stream_id, - list_index, - dw_flags, - n_position_low, - n_position_high, - cb_requested, - .. - } => { - log::debug!("server_file_contents_request called"); - let fcr = if dw_flags == 0x1 { - FileContentsRequest::Size { - stream_id, - file_idx: list_index as usize, - } - } else if dw_flags == 0x2 { - let offset = (n_position_high as u64) << 32 | n_position_low as u64; - let length = cb_requested as u64; - - FileContentsRequest::Range { - stream_id, - file_idx: list_index as usize, - offset, - length, - } - } else { - log::error!("got invalid FileContentsRequest from conn={}", conn_id); - resp_file_contents_fail(conn_id, stream_id); - return Ok(()); - }; - - self.serve_file_contents(conn_id, fcr) - } - } - } - - fn send_file_list(&self, conn_id: i32) -> Result<(), CliprdrError> { - self.sync_local_files()?; - - let file_list = self.local_files.lock(); - send_file_list(&*file_list, conn_id) - } -} - -impl CliprdrServiceContext for ClipboardContext { - fn set_is_stopped(&mut self) -> Result<(), CliprdrError> { - // unmount the fuse - if let Some(fuse_handle) = self.fuse_handle.lock().take() { - fuse_handle.join(); - } - // we don't stop the clipboard, keep listening in case of restart - Ok(()) - } - - fn empty_clipboard(&mut self, _conn_id: i32) -> Result { - self.clipboard.set_file_list(&[])?; - Ok(true) - } - - fn server_clip_file(&mut self, conn_id: i32, msg: ClipboardFile) -> Result<(), CliprdrError> { - self.serve(conn_id, msg) - } -} - -fn resp_format_data_failure(conn_id: i32) { - let data = ClipboardFile::FormatDataResponse { - msg_flags: 0x2, - format_data: vec![], - }; - send_data(conn_id, data) -} - -fn send_format_list(conn_id: i32) -> Result<(), CliprdrError> { - log::debug!("send format list to remote, conn={}", conn_id); - let fd_format_name = get_local_format(FILEDESCRIPTOR_FORMAT_ID) - .unwrap_or(FILEDESCRIPTORW_FORMAT_NAME.to_string()); - let fc_format_name = - get_local_format(FILECONTENTS_FORMAT_ID).unwrap_or(FILECONTENTS_FORMAT_NAME.to_string()); - let format_list = ClipboardFile::FormatList { - format_list: vec![ - (FILEDESCRIPTOR_FORMAT_ID, fd_format_name), - (FILECONTENTS_FORMAT_ID, fc_format_name), - ], - }; - - send_data(conn_id, format_list); - log::debug!("format list to remote dispatched, conn={}", conn_id); - Ok(()) -} - -fn build_file_list_pdu(files: &[LocalFile]) -> Vec { - let mut data = BytesMut::with_capacity(4 + 592 * files.len()); - data.put_u32_le(files.len() as u32); - for file in files.iter() { - data.put(file.as_bin().as_slice()); - } - - data.to_vec() -} - -fn send_file_list(files: &[LocalFile], conn_id: i32) -> Result<(), CliprdrError> { - log::debug!( - "send file list to remote, conn={}, list={:?}", - conn_id, - files.iter().map(|f| f.path.display()).collect::>() - ); - - let format_data = build_file_list_pdu(files); - - send_data( - conn_id, - ClipboardFile::FormatDataResponse { - msg_flags: 1, - format_data, - }, - ); - Ok(()) -} diff --git a/libs/clipboard/src/platform/unix/ns_clipboard.rs b/libs/clipboard/src/platform/unix/ns_clipboard.rs deleted file mode 100644 index 32c60a464..000000000 --- a/libs/clipboard/src/platform/unix/ns_clipboard.rs +++ /dev/null @@ -1,97 +0,0 @@ -use std::{collections::BTreeSet, path::PathBuf}; - -use cacao::pasteboard::{Pasteboard, PasteboardName}; -use hbb_common::log; -use parking_lot::Mutex; - -use crate::{platform::unix::send_format_list, CliprdrError}; - -use super::SysClipboard; - -#[inline] -fn wait_file_list() -> Option> { - let pb = Pasteboard::named(PasteboardName::General); - pb.get_file_urls() - .ok() - .map(|v| v.into_iter().map(|nsurl| nsurl.pathbuf()).collect()) -} - -#[inline] -fn set_file_list(file_list: &[PathBuf]) -> Result<(), CliprdrError> { - let pb = Pasteboard::named(PasteboardName::General); - pb.set_files(file_list.to_vec()) - .map_err(|_| CliprdrError::ClipboardInternalError) -} - -pub struct NsPasteboard { - ignore_path: PathBuf, - - former_file_list: Mutex>, -} - -impl NsPasteboard { - pub fn new(ignore_path: &PathBuf) -> Result { - Ok(Self { - ignore_path: ignore_path.to_owned(), - former_file_list: Mutex::new(vec![]), - }) - } -} - -impl SysClipboard for NsPasteboard { - fn set_file_list(&self, paths: &[PathBuf]) -> Result<(), CliprdrError> { - *self.former_file_list.lock() = paths.to_vec(); - set_file_list(paths) - } - - fn start(&self) { - { - *self.former_file_list.lock() = vec![]; - } - - loop { - let file_list = match wait_file_list() { - Some(v) => v, - None => { - std::thread::sleep(std::time::Duration::from_millis(100)); - continue; - } - }; - - let filtered = file_list - .into_iter() - .filter(|pb| !pb.starts_with(&self.ignore_path)) - .collect::>(); - - if filtered.is_empty() { - std::thread::sleep(std::time::Duration::from_millis(100)); - continue; - } - - { - let mut former = self.former_file_list.lock(); - - let filtered_st: BTreeSet<_> = filtered.iter().collect(); - let former_st = former.iter().collect::>(); - if filtered_st == former_st { - std::thread::sleep(std::time::Duration::from_millis(100)); - continue; - } - - *former = filtered; - } - - if let Err(e) = send_format_list(0) { - log::warn!("failed to send format list: {}", e); - break; - } - - std::thread::sleep(std::time::Duration::from_millis(100)); - } - log::debug!("stop listening file related atoms on clipboard"); - } - - fn get_file_list(&self) -> Vec { - self.former_file_list.lock().clone() - } -} diff --git a/libs/clipboard/src/platform/unix/serv_files.rs b/libs/clipboard/src/platform/unix/serv_files.rs new file mode 100644 index 000000000..6f4fb54a4 --- /dev/null +++ b/libs/clipboard/src/platform/unix/serv_files.rs @@ -0,0 +1,271 @@ +use super::local_file::LocalFile; +use crate::{platform::unix::local_file::construct_file_list, ClipboardFile, CliprdrError}; +use hbb_common::{ + bytes::{BufMut, BytesMut}, + log, +}; +use parking_lot::Mutex; +use std::{path::PathBuf, sync::Arc, usize}; + +lazy_static::lazy_static! { + // local files are cached, this value should not be changed when copying files + // Because `CliprdrFileContentsRequest` only contains the index of the file in the list. + // We need to keep the file list in the same order as the remote side. + // We may add a `FileId` field to `CliprdrFileContentsRequest` in the future. + static ref CLIP_FILES: Arc> = Default::default(); +} + +#[derive(Debug)] +enum FileContentsRequest { + Size { + stream_id: i32, + file_idx: usize, + }, + + Range { + stream_id: i32, + file_idx: usize, + offset: u64, + length: u64, + }, +} + +#[derive(Default)] +struct ClipFiles { + files: Vec, + file_list: Vec, + first_file_index: usize, + files_pdu: Vec, +} + +impl ClipFiles { + fn clear(&mut self) { + self.files.clear(); + self.file_list.clear(); + self.first_file_index = usize::MAX; + self.files_pdu.clear(); + } + + fn sync_files(&mut self, clipboard_files: &[String]) -> Result<(), CliprdrError> { + let clipboard_paths = clipboard_files + .iter() + .map(|s| PathBuf::from(s)) + .collect::>(); + self.file_list = construct_file_list(&clipboard_paths)?; + self.first_file_index = self + .file_list + .iter() + .position(|f| !f.path.is_dir()) + .unwrap_or(usize::MAX); + self.files = clipboard_files.to_vec(); + Ok(()) + } + + fn build_file_list_pdu(&mut self) { + let mut data = BytesMut::with_capacity(4 + 592 * self.file_list.len()); + data.put_u32_le(self.file_list.len() as u32); + for file in self.file_list.iter() { + data.put(file.as_bin().as_slice()); + } + self.files_pdu = data.to_vec() + } + + fn get_files_for_audit(&self, request: &FileContentsRequest) -> Option { + if let FileContentsRequest::Range { + file_idx, offset, .. + } = request + { + if *file_idx == self.first_file_index && *offset == 0 { + let files: Vec<(String, u64)> = self + .file_list + .iter() + .filter_map(|f| { + if f.path.is_file() { + Some((f.path.to_string_lossy().to_string(), f.size)) + } else { + None + } + }) + .collect::<_>(); + if files.is_empty() { + return None; + } else { + return Some(ClipboardFile::Files { files }); + } + } + } + None + } + + fn serve_file_contents( + &mut self, + conn_id: i32, + request: FileContentsRequest, + ) -> Result { + let (file_idx, file_contents_resp) = match request { + FileContentsRequest::Size { + stream_id, + file_idx, + } => { + log::debug!("file contents (size) requested from conn: {}", conn_id); + let Some(file) = self.file_list.get(file_idx) else { + log::error!( + "invalid file index {} requested from conn: {}", + file_idx, + conn_id + ); + return Err(CliprdrError::InvalidRequest { + description: format!( + "invalid file index {} requested from conn: {}", + file_idx, conn_id + ), + }); + }; + + log::debug!( + "conn {} requested file-{}: {}", + conn_id, + file_idx, + file.name + ); + + let size = file.size; + ( + file_idx, + ClipboardFile::FileContentsResponse { + msg_flags: 0x1, + stream_id, + requested_data: size.to_le_bytes().to_vec(), + }, + ) + } + FileContentsRequest::Range { + stream_id, + file_idx, + offset, + length, + } => { + log::debug!( + "file contents (range from {} length {}) request from conn: {}", + offset, + length, + conn_id + ); + let Some(file) = self.file_list.get_mut(file_idx) else { + log::error!( + "invalid file index {} requested from conn: {}", + file_idx, + conn_id + ); + return Err(CliprdrError::InvalidRequest { + description: format!( + "invalid file index {} requested from conn: {}", + file_idx, conn_id + ), + }); + }; + log::debug!( + "conn {} requested file-{}: {}", + conn_id, + file_idx, + file.name + ); + + if offset > file.size { + log::error!("invalid reading offset requested from conn: {}", conn_id); + return Err(CliprdrError::InvalidRequest { + description: format!( + "invalid reading offset requested from conn: {}", + conn_id + ), + }); + } + let read_size = if offset + length > file.size { + file.size - offset + } else { + length + }; + + let mut buf = vec![0u8; read_size as usize]; + + file.read_exact_at(&mut buf, offset)?; + + ( + file_idx, + ClipboardFile::FileContentsResponse { + msg_flags: 0x1, + stream_id, + requested_data: buf, + }, + ) + } + }; + + log::debug!("file contents sent to conn: {}", conn_id); + // hot reload next file + for next_file in self.file_list.iter_mut().skip(file_idx + 1) { + if !next_file.is_dir { + next_file.load_handle()?; + break; + } + } + Ok(file_contents_resp) + } +} + +#[inline] +pub fn clear_files() { + CLIP_FILES.lock().clear(); +} + +pub fn read_file_contents( + conn_id: i32, + stream_id: i32, + list_index: i32, + dw_flags: i32, + n_position_low: i32, + n_position_high: i32, + cb_requested: i32, +) -> Vec> { + let fcr = if dw_flags == 0x1 { + FileContentsRequest::Size { + stream_id, + file_idx: list_index as usize, + } + } else if dw_flags == 0x2 { + let offset = (n_position_high as u64) << 32 | n_position_low as u64; + let length = cb_requested as u64; + + FileContentsRequest::Range { + stream_id, + file_idx: list_index as usize, + offset, + length, + } + } else { + return vec![Err(CliprdrError::InvalidRequest { + description: format!("got invalid FileContentsRequest, dw_flats: {dw_flags}"), + })]; + }; + + let mut clip_files = CLIP_FILES.lock(); + let mut res = vec![]; + if let Some(files_res) = clip_files.get_files_for_audit(&fcr) { + res.push(Ok(files_res)); + } + res.push(clip_files.serve_file_contents(conn_id, fcr)); + res +} + +pub fn sync_files(files: &[String]) -> Result<(), CliprdrError> { + let mut files_lock = CLIP_FILES.lock(); + if files_lock.files == files { + return Ok(()); + } + files_lock.sync_files(files)?; + Ok(files_lock.build_file_list_pdu()) +} + +pub fn get_file_list_pdu() -> Vec { + CLIP_FILES.lock().files_pdu.clone() +} diff --git a/libs/clipboard/src/platform/unix/url.rs b/libs/clipboard/src/platform/unix/url.rs deleted file mode 100644 index 2ae520f4d..000000000 --- a/libs/clipboard/src/platform/unix/url.rs +++ /dev/null @@ -1,75 +0,0 @@ -use std::path::{Path, PathBuf}; - -use crate::CliprdrError; - -// on x11, path will be encode as -// "/home/rustdesk/pictures/🖼️.png" -> "file:///home/rustdesk/pictures/%F0%9F%96%BC%EF%B8%8F.png" -// url encode and decode is needed -const ENCODE_SET: percent_encoding::AsciiSet = percent_encoding::CONTROLS.add(b' ').remove(b'/'); - -pub(super) fn encode_path_to_uri(path: &PathBuf) -> io::Result { - let encoded = - percent_encoding::percent_encode(path.to_str()?.as_bytes(), &ENCODE_SET).to_string(); - format!("file://{}", encoded) -} - -pub(super) fn parse_uri_to_path(encoded_uri: &str) -> Result { - let encoded_path = encoded_uri.trim_start_matches("file://"); - let path_str = percent_encoding::percent_decode_str(encoded_path) - .decode_utf8() - .map_err(|_| CliprdrError::ConversionFailure)?; - let path_str = path_str.to_string(); - - Ok(Path::new(&path_str).to_path_buf()) -} - -// helper parse function -// convert 'text/uri-list' data to a list of valid Paths -// # Note -// - none utf8 data will lead to error -pub(super) fn parse_plain_uri_list(v: Vec) -> Result, CliprdrError> { - let text = String::from_utf8(v).map_err(|_| CliprdrError::ConversionFailure)?; - parse_uri_list(&text) -} - -// helper parse function -// convert 'text/uri-list' data to a list of valid Paths -// # Note -// - none utf8 data will lead to error -pub(super) fn parse_uri_list(text: &str) -> Result, CliprdrError> { - let mut list = Vec::new(); - - for line in text.lines() { - if !line.starts_with("file://") { - continue; - } - let decoded = parse_uri_to_path(line)?; - list.push(decoded) - } - Ok(list) -} - -#[cfg(test)] -mod uri_test { - #[test] - fn test_conversion() { - let path = std::path::PathBuf::from("/home/rustdesk/pictures/🖼️.png"); - let uri = super::encode_path_to_uri(&path).unwrap(); - assert_eq!( - uri, - "file:///home/rustdesk/pictures/%F0%9F%96%BC%EF%B8%8F.png" - ); - let convert_back = super::parse_uri_to_path(&uri).unwrap(); - assert_eq!(path, convert_back); - } - - #[test] - fn parse_list() { - let uri_list = r#"file:///home/rustdesk/pictures/%F0%9F%96%BC%EF%B8%8F.png -file:///home/rustdesk/pictures/%F0%9F%96%BC%EF%B8%8F.png -"#; - let list = super::parse_uri_list(uri_list.into()).unwrap(); - assert!(list.len() == 2); - assert_eq!(list[0], list[1]); - } -} diff --git a/libs/clipboard/src/platform/unix/x11.rs b/libs/clipboard/src/platform/unix/x11.rs deleted file mode 100644 index 41b642640..000000000 --- a/libs/clipboard/src/platform/unix/x11.rs +++ /dev/null @@ -1,168 +0,0 @@ -use std::{collections::BTreeSet, path::PathBuf}; - -use hbb_common::log; -use once_cell::sync::OnceCell; -use parking_lot::Mutex; -use x11_clipboard::Clipboard; -use x11rb::protocol::xproto::Atom; - -use crate::{platform::unix::send_format_list, CliprdrError}; - -use super::{encode_path_to_uri, parse_plain_uri_list, SysClipboard}; - -static X11_CLIPBOARD: OnceCell = OnceCell::new(); - -fn get_clip() -> Result<&'static Clipboard, CliprdrError> { - X11_CLIPBOARD.get_or_try_init(|| Clipboard::new().map_err(|_| CliprdrError::CliprdrInit)) -} - -pub struct X11Clipboard { - ignore_path: PathBuf, - text_uri_list: Atom, - gnome_copied_files: Atom, - nautilus_clipboard: Atom, - - former_file_list: Mutex>, -} - -impl X11Clipboard { - pub fn new(ignore_path: &PathBuf) -> Result { - let clipboard = get_clip()?; - let text_uri_list = clipboard - .setter - .get_atom("text/uri-list") - .map_err(|_| CliprdrError::CliprdrInit)?; - let gnome_copied_files = clipboard - .setter - .get_atom("x-special/gnome-copied-files") - .map_err(|_| CliprdrError::CliprdrInit)?; - let nautilus_clipboard = clipboard - .setter - .get_atom("x-special/nautilus-clipboard") - .map_err(|_| CliprdrError::CliprdrInit)?; - Ok(Self { - ignore_path: ignore_path.to_owned(), - text_uri_list, - gnome_copied_files, - nautilus_clipboard, - former_file_list: Mutex::new(vec![]), - }) - } - - fn load(&self, target: Atom) -> Result, CliprdrError> { - let clip = get_clip()?.setter.atoms.clipboard; - let prop = get_clip()?.setter.atoms.property; - // NOTE: - // # why not use `load_wait` - // load_wait is likely to wait forever, which is not what we want - let res = get_clip()?.load_wait(clip, target, prop); - match res { - Ok(res) => Ok(res), - Err(x11_clipboard::error::Error::UnexpectedType(_)) => Ok(vec![]), - Err(x11_clipboard::error::Error::Timeout) => { - log::debug!("x11 clipboard get content timeout."); - Err(CliprdrError::ClipboardInternalError) - } - Err(e) => { - log::debug!("x11 clipboard get content fail: {:?}", e); - Err(CliprdrError::ClipboardInternalError) - } - } - } - - fn store_batch(&self, batch: Vec<(Atom, Vec)>) -> Result<(), CliprdrError> { - let clip = get_clip()?.setter.atoms.clipboard; - log::debug!("try to store clipboard content"); - get_clip()? - .store_batch(clip, batch) - .map_err(|_| CliprdrError::ClipboardInternalError) - } - - fn wait_file_list(&self) -> Result>, CliprdrError> { - let v = self.load(self.text_uri_list)?; - let p = parse_plain_uri_list(v)?; - Ok(Some(p)) - } -} - -impl SysClipboard for X11Clipboard { - fn set_file_list(&self, paths: &[PathBuf]) -> Result<(), CliprdrError> { - *self.former_file_list.lock() = paths.to_vec(); - - let uri_list: Vec = { - let mut v = Vec::new(); - for path in paths { - v.push(encode_path_to_uri(path)?); - } - v - }; - let uri_list = uri_list.join("\n"); - let text_uri_list_data = uri_list.as_bytes().to_vec(); - let gnome_copied_files_data = ["copy\n".as_bytes(), uri_list.as_bytes()].concat(); - let batch = vec![ - (self.text_uri_list, text_uri_list_data), - (self.gnome_copied_files, gnome_copied_files_data.clone()), - (self.nautilus_clipboard, gnome_copied_files_data), - ]; - self.store_batch(batch) - .map_err(|_| CliprdrError::ClipboardInternalError) - } - - fn start(&self) { - { - // clear cached file list - *self.former_file_list.lock() = vec![]; - } - loop { - let sth = match self.wait_file_list() { - Ok(sth) => sth, - Err(e) => { - log::warn!("failed to get file list from clipboard: {}", e); - std::thread::sleep(std::time::Duration::from_millis(100)); - continue; - } - }; - - let Some(paths) = sth else { - // just sleep - std::thread::sleep(std::time::Duration::from_millis(100)); - continue; - }; - - let filtered = paths - .into_iter() - .filter(|pb| !pb.starts_with(&self.ignore_path)) - .collect::>(); - - if filtered.is_empty() { - std::thread::sleep(std::time::Duration::from_millis(100)); - continue; - } - - { - let mut former = self.former_file_list.lock(); - - let filtered_st: BTreeSet<_> = filtered.iter().collect(); - let former_st = former.iter().collect::>(); - if filtered_st == former_st { - std::thread::sleep(std::time::Duration::from_millis(100)); - continue; - } - - *former = filtered; - } - - if let Err(e) = send_format_list(0) { - log::warn!("failed to send format list: {}", e); - break; - } - - std::thread::sleep(std::time::Duration::from_millis(100)); - } - log::debug!("stop listening file related atoms on clipboard"); - } - - fn get_file_list(&self) -> Vec { - self.former_file_list.lock().clone() - } -} diff --git a/libs/clipboard/src/platform/windows.rs b/libs/clipboard/src/platform/windows.rs index 5d1aa086d..cdeb3e4b0 100644 --- a/libs/clipboard/src/platform/windows.rs +++ b/libs/clipboard/src/platform/windows.rs @@ -6,10 +6,11 @@ #![allow(deref_nullptr)] use crate::{ - allow_err, send_data, ClipboardFile, CliprdrError, CliprdrServiceContext, ResultType, - ERR_CODE_INVALID_PARAMETER, ERR_CODE_SEND_MSG, ERR_CODE_SERVER_FUNCTION_NONE, VEC_MSG_CHANNEL, + send_data, send_data_exclude, ClipboardFile, CliprdrError, CliprdrServiceContext, + ProgressPercent, ResultType, ERR_CODE_INVALID_PARAMETER, ERR_CODE_SEND_MSG, + ERR_CODE_SERVER_FUNCTION_NONE, VEC_MSG_CHANNEL, }; -use hbb_common::log; +use hbb_common::{allow_err, log}; use std::{ boxed::Box, ffi::{CStr, CString}, @@ -380,6 +381,9 @@ pub type pcCliprdrTempDirectory = ::std::option::Option< pub type pcNotifyClipboardMsg = ::std::option::Option< unsafe extern "C" fn(connID: UINT32, msg: *const NOTIFICATION_MESSAGE) -> UINT, >; +pub type pcHandleClipboardFiles = ::std::option::Option< + unsafe extern "C" fn(connID: UINT32, nFiles: size_t, fileNames: *mut *mut WCHAR) -> UINT, +>; pub type pcCliprdrClientFormatList = ::std::option::Option< unsafe extern "C" fn( context: *mut CliprdrClientContext, @@ -491,6 +495,7 @@ pub struct _cliprdr_client_context { pub MonitorReady: pcCliprdrMonitorReady, pub TempDirectory: pcCliprdrTempDirectory, pub NotifyClipboardMsg: pcNotifyClipboardMsg, + pub HandleClipboardFiles: pcHandleClipboardFiles, pub ClientFormatList: pcCliprdrClientFormatList, pub ServerFormatList: pcCliprdrServerFormatList, pub ClientFormatListResponse: pcCliprdrClientFormatListResponse, @@ -528,6 +533,7 @@ impl CliprdrClientContext { enable_others: bool, response_wait_timeout_secs: u32, notify_callback: pcNotifyClipboardMsg, + handle_clipboard_files: pcHandleClipboardFiles, client_format_list: pcCliprdrClientFormatList, client_format_list_response: pcCliprdrClientFormatListResponse, client_format_data_request: pcCliprdrClientFormatDataRequest, @@ -546,6 +552,7 @@ impl CliprdrClientContext { MonitorReady: None, TempDirectory: None, NotifyClipboardMsg: notify_callback, + HandleClipboardFiles: handle_clipboard_files, ClientFormatList: client_format_list, ServerFormatList: None, ClientFormatListResponse: client_format_list_response, @@ -602,6 +609,12 @@ impl CliprdrServiceContext for CliprdrClientContext { let ret = server_clip_file(self, conn_id, msg); ret_to_result(ret) } + + fn get_progress_percent(&self) -> Option { + None + } + + fn cancel(&mut self) {} } fn ret_to_result(ret: u32) -> Result<(), CliprdrError> { @@ -614,6 +627,7 @@ fn ret_to_result(ret: u32) -> Result<(), CliprdrError> { e => Err(CliprdrError::Unknown(e)), } } + pub fn empty_clipboard(context: &mut CliprdrClientContext, conn_id: i32) -> bool { unsafe { TRUE == empty_cliprdr(context, conn_id as u32) } } @@ -643,6 +657,7 @@ pub fn server_clip_file( conn_id, &format_list ); + send_data_exclude(conn_id as _, ClipboardFile::TryEmpty); ret = server_format_list(context, conn_id, format_list); log::debug!( "server_format_list called, conn_id {}, return {}", @@ -740,6 +755,18 @@ pub fn server_clip_file( ret ); } + ClipboardFile::TryEmpty => { + log::debug!("empty_clipboard called"); + let ret = empty_clipboard(context, conn_id); + log::debug!( + "empty_clipboard called, conn_id {}, return {}", + conn_id, + ret + ); + } + ClipboardFile::Files { .. } => { + // unreachable + } } ret } @@ -949,6 +976,7 @@ pub fn create_cliprdr_context( enable_others, response_wait_timeout_secs, Some(notify_callback), + Some(handle_clipboard_files), Some(client_format_list), Some(client_format_list_response), Some(client_format_data_request), @@ -1003,6 +1031,61 @@ extern "C" fn notify_callback(conn_id: UINT32, msg: *const NOTIFICATION_MESSAGE) 0 } +extern "C" fn handle_clipboard_files( + conn_id: UINT32, + n_files: size_t, + file_names: *mut *mut WCHAR, +) -> UINT { + if n_files == 0 { + return 0; + } + + let data = unsafe { + let mut files = Vec::new(); + use std::ffi::OsString; + use std::os::windows::ffi::OsStringExt; + for i in 0..n_files { + let file_name_ptr = *file_names.offset(i as isize); + if !file_name_ptr.is_null() { + let mut len = 0; + while *file_name_ptr.offset(len) != 0 { + len += 1; + } + let slice = std::slice::from_raw_parts(file_name_ptr, len as usize); + let os_string = OsString::from_wide(slice); + match os_string.to_str() { + Some(n) => match std::fs::metadata(n) { + Ok(meta) => { + if meta.is_file() { + files.push((n.to_owned(), meta.len())); + } + } + Err(e) => { + log::warn!( + "handle_clipboard_files: Failed to get metadata for file '{}': {}", + n, + e + ); + } + }, + None => { + log::warn!("handle_clipboard_files: Failed to convert file name to UTF-8"); + } + }; + } + } + if files.is_empty() { + return 0; + } + + ClipboardFile::Files { files } + }; + // no need to handle result here + allow_err!(send_data(conn_id as _, data)); + + 0 +} + extern "C" fn client_format_list( _context: *mut CliprdrClientContext, clip_format_list: *const CLIPRDR_FORMAT_LIST, diff --git a/libs/clipboard/src/windows/wf_cliprdr.c b/libs/clipboard/src/windows/wf_cliprdr.c index c2b7556a4..95d1d1a5c 100644 --- a/libs/clipboard/src/windows/wf_cliprdr.c +++ b/libs/clipboard/src/windows/wf_cliprdr.c @@ -211,6 +211,11 @@ struct wf_clipboard BOOL sync; UINT32 capabilities; + // This flag is not really needed, + // but we can use it to double confirm that files can only be pasted after `Ctrl+C`. + // Not sure `is_file_descriptor_from_remote()` is engough to check all cases on all Windows. + BOOL copied; + size_t map_size; size_t map_capacity; formatMapping *format_mappings; @@ -234,6 +239,7 @@ struct wf_clipboard size_t nFiles; size_t file_array_size; WCHAR **file_names; + size_t first_file_index; FILEDESCRIPTORW **fileDescriptor; BOOL legacyApi; @@ -263,6 +269,9 @@ static UINT cliprdr_send_request_filecontents(wfClipboard *clipboard, UINT32 con ULONG index, UINT32 flag, DWORD positionhigh, DWORD positionlow, ULONG request); +static BOOL is_file_descriptor_from_remote(); +static BOOL is_set_by_instance(wfClipboard *clipboard); + static void CliprdrDataObject_Delete(CliprdrDataObject *instance); static CliprdrEnumFORMATETC *CliprdrEnumFORMATETC_New(ULONG nFormats, FORMATETC *pFormatEtc); @@ -593,8 +602,11 @@ static CliprdrStream *CliprdrStream_New(UINT32 connID, ULONG index, void *pData, clipboard->req_fdata = NULL; } } - else + else { + instance->m_lSize.QuadPart = + ((UINT64)instance->m_Dsc.nFileSizeHigh << 32) | instance->m_Dsc.nFileSizeLow; success = TRUE; + } } } @@ -612,6 +624,7 @@ void CliprdrStream_Delete(CliprdrStream *instance) if (instance) { free(instance->iStream.lpVtbl); + instance->iStream.lpVtbl = NULL; free(instance); } } @@ -712,6 +725,15 @@ static HRESULT STDMETHODCALLTYPE CliprdrDataObject_GetData(IDataObject *This, FO if (!clipboard) return E_INVALIDARG; + // If `Ctrl+C` is not pressed yet, do not handle the file paste, and empty the clipboard. + if (!clipboard->copied) { + if (try_open_clipboard(clipboard->hwnd)) { + EmptyClipboard(); + CloseClipboard(); + } + return E_UNEXPECTED; + } + if ((idx = cliprdr_lookup_format(instance, pFormatEtc)) == -1) { // empty clipboard here? @@ -1479,6 +1501,8 @@ static UINT cliprdr_send_format_list(wfClipboard *clipboard, UINT32 connID) // send rc = clipboard->context->ClientFormatList(clipboard->context, &formatList); + // No need to check `rc`, `copied` is only used to indicate `Ctrl+C` is pressed. + clipboard->copied = TRUE; for (index = 0; index < numFormats; index++) { @@ -1727,8 +1751,7 @@ static LRESULT CALLBACK cliprdr_proc(HWND hWnd, UINT Msg, WPARAM wParam, LPARAM DEBUG_CLIPRDR("info: WM_CLIPBOARDUPDATE"); // if (clipboard->sync) { - if ((GetClipboardOwner() != clipboard->hwnd) && - (S_FALSE == OleIsCurrentClipboard(clipboard->data_obj))) + if (!is_set_by_instance(clipboard)) { if (clipboard->hmem) { @@ -2003,6 +2026,7 @@ static void clear_file_array(wfClipboard *clipboard) clipboard->file_array_size = 0; clipboard->nFiles = 0; + clipboard->first_file_index = (size_t)-1; } static BOOL wf_cliprdr_get_file_contents(WCHAR *file_name, BYTE *buffer, LONG positionLow, @@ -2068,6 +2092,8 @@ static FILEDESCRIPTORW *wf_cliprdr_get_file_descriptor(WCHAR *file_name, size_t return NULL; } + // to-do: use `fd->dwFlags = FD_ATTRIBUTES | FD_FILESIZE | FD_WRITESTIME | FD_PROGRESSUI`. + // We keep `fd->dwFlags = FD_ATTRIBUTES | FD_WRITESTIME | FD_PROGRESSUI` for compatibility. // fd->dwFlags = FD_ATTRIBUTES | FD_FILESIZE | FD_WRITESTIME | FD_PROGRESSUI; fd->dwFlags = FD_ATTRIBUTES | FD_WRITESTIME | FD_PROGRESSUI; fd->dwFileAttributes = GetFileAttributesW(file_name); @@ -2135,7 +2161,7 @@ static BOOL wf_cliprdr_add_to_file_arrays(wfClipboard *clipboard, WCHAR *full_fi return FALSE; /* add to name array */ - clipboard->file_names[clipboard->nFiles] = (LPWSTR)malloc(MAX_PATH * 2); + clipboard->file_names[clipboard->nFiles] = (LPWSTR)malloc((size_t)MAX_PATH * sizeof(WCHAR)); if (!clipboard->file_names[clipboard->nFiles]) return FALSE; @@ -2156,6 +2182,11 @@ static BOOL wf_cliprdr_add_to_file_arrays(wfClipboard *clipboard, WCHAR *full_fi return FALSE; } + if ((clipboard->fileDescriptor[clipboard->nFiles]->dwFileAttributes & + FILE_ATTRIBUTE_DIRECTORY) == 0) { + clipboard->first_file_index = clipboard->nFiles; + } + clipboard->nFiles++; return TRUE; } @@ -2274,7 +2305,9 @@ static UINT wf_cliprdr_monitor_ready(CliprdrClientContext *context, if (rc != CHANNEL_RC_OK) return rc; - return cliprdr_send_format_list(clipboard, monitorReady->connID); + return rc; + // Don't send format list here, because we don't want to paste files copied before the connection. + // return cliprdr_send_format_list(clipboard, monitorReady->connID); } /** @@ -2321,11 +2354,20 @@ static UINT wf_cliprdr_server_format_list(CliprdrClientContext *context, UINT32 i; formatMapping *mapping; CLIPRDR_FORMAT *format; - wfClipboard *clipboard = (wfClipboard *)context->Custom; + wfClipboard *clipboard = NULL; + + if (!context || !formatList) + return ERROR_INTERNAL_ERROR; + + clipboard = (wfClipboard *)context->Custom; + if (!clipboard) + return ERROR_INTERNAL_ERROR; if (!clear_format_map(clipboard)) return ERROR_INTERNAL_ERROR; + clipboard->copied = TRUE; + for (i = 0; i < formatList->numFormats; i++) { format = &(formatList->formats[i]); @@ -2820,6 +2862,31 @@ wf_cliprdr_server_file_contents_request(CliprdrClientContext *context, goto exit; } + // If the clipboard is set by the instance, or the file descriptor is from remote, + // we should not process the request. + // Because this may be the following cases: + // 1. `A` -> `B`, `C` + // 2. Copy in `A` + // 3. Copy in `B` + // 4. Paste in `C` + // In this case, `C` should not get the file content from `A`. The clipboard is set by `B`. + // + // Or + // 1. `B` -> `A` -> `C` + // 2. Copy in `A` + // 2. Copy in `B` + // 3. Paste in `C` + // In this case, `C` should not get the file content from `A`. The clipboard is set by `B`. + // + // We can simply notify `C` to clear the clipboard when `A` received copy message from `B`, + // if connections are in the same process. + // But if connections are in different processes, it is not easy to notify the other process. + // So we just ignore the request from `C` in this case. + if (is_set_by_instance(clipboard) || is_file_descriptor_from_remote()) { + rc = ERROR_INTERNAL_ERROR; + goto exit; + } + cbRequested = fileContentsRequest->cbRequested; if (fileContentsRequest->dwFlags == FILECONTENTS_SIZE) cbRequested = sizeof(UINT64); @@ -2909,6 +2976,14 @@ wf_cliprdr_server_file_contents_request(CliprdrClientContext *context, { LARGE_INTEGER dlibMove; ULARGE_INTEGER dlibNewPosition; + + if (clipboard->nFiles > 0 && + fileContentsRequest->listIndex == (UINT32)clipboard->first_file_index && + fileContentsRequest->nPositionLow == 0 && + fileContentsRequest->nPositionHigh == 0) { + clipboard->context->HandleClipboardFiles(fileContentsRequest->connID, clipboard->nFiles, clipboard->file_names); + } + dlibMove.HighPart = fileContentsRequest->nPositionHigh; dlibMove.LowPart = fileContentsRequest->nPositionLow; hRet = IStream_Seek(pStreamStc, dlibMove, STREAM_SEEK_SET, &dlibNewPosition); @@ -2940,6 +3015,13 @@ wf_cliprdr_server_file_contents_request(CliprdrClientContext *context, rc = ERROR_INTERNAL_ERROR; goto exit; } + + if (clipboard->nFiles > 0 && + fileContentsRequest->listIndex == (UINT32)clipboard->first_file_index && + fileContentsRequest->nPositionLow == 0 && + fileContentsRequest->nPositionHigh == 0) { + clipboard->context->HandleClipboardFiles(fileContentsRequest->connID, clipboard->nFiles, clipboard->file_names); + } bRet = wf_cliprdr_get_file_contents( clipboard->file_names[fileContentsRequest->listIndex], pData, fileContentsRequest->nPositionLow, fileContentsRequest->nPositionHigh, cbRequested, @@ -3060,6 +3142,27 @@ wf_cliprdr_server_file_contents_response(CliprdrClientContext *context, return rc; } +BOOL is_set_by_instance(wfClipboard *clipboard) +{ + if (GetClipboardOwner() == clipboard->hwnd || S_OK == OleIsCurrentClipboard(clipboard->data_obj)) { + return TRUE; + } + return FALSE; +} + +BOOL is_file_descriptor_from_remote() +{ + UINT fsid = 0; + if (IsClipboardFormatAvailable(CF_HDROP)) { + return FALSE; + } + fsid = RegisterClipboardFormat(CFSTR_FILEDESCRIPTORW); + if (IsClipboardFormatAvailable(fsid)) { + return TRUE; + } + return FALSE; +} + BOOL wf_cliprdr_init(wfClipboard *clipboard, CliprdrClientContext *cliprdr) { if (!clipboard || !cliprdr) @@ -3071,6 +3174,7 @@ BOOL wf_cliprdr_init(wfClipboard *clipboard, CliprdrClientContext *cliprdr) clipboard->map_size = 0; clipboard->hUser32 = LoadLibraryA("user32.dll"); clipboard->data_obj = NULL; + clipboard->copied = FALSE; if (clipboard->hUser32) { @@ -3126,14 +3230,18 @@ BOOL wf_cliprdr_uninit(wfClipboard *clipboard, CliprdrClientContext *cliprdr) if (!clipboard || !cliprdr) return FALSE; + clipboard->copied = FALSE; cliprdr->Custom = NULL; /* discard all contexts in clipboard */ if (try_open_clipboard(clipboard->hwnd)) { - if (!EmptyClipboard()) + if (is_set_by_instance(clipboard) || is_file_descriptor_from_remote()) { - DEBUG_CLIPRDR("EmptyClipboard failed with 0x%x", GetLastError()); + if (!EmptyClipboard()) + { + DEBUG_CLIPRDR("EmptyClipboard failed with 0x%x", GetLastError()); + } } if (!CloseClipboard()) { @@ -3227,6 +3335,8 @@ BOOL wf_do_empty_cliprdr(wfClipboard *clipboard) return FALSE; } + clipboard->copied = FALSE; + if (WaitForSingleObject(clipboard->data_obj_mutex, INFINITE) != WAIT_OBJECT_0) { return FALSE; @@ -3248,10 +3358,14 @@ BOOL wf_do_empty_cliprdr(wfClipboard *clipboard) break; } - if (!EmptyClipboard()) + if (is_file_descriptor_from_remote()) { - rc = FALSE; + if (!EmptyClipboard()) + { + rc = FALSE; + } } + if (!CloseClipboard()) { // critical error!!! diff --git a/libs/enigo/Cargo.toml b/libs/enigo/Cargo.toml index a5b6d5622..6468eeedd 100644 --- a/libs/enigo/Cargo.toml +++ b/libs/enigo/Cargo.toml @@ -37,5 +37,8 @@ core-graphics = "0.22" objc = "0.2" unicode-segmentation = "1.10" +[target.'cfg(target_os = "linux")'.dependencies] +libxdo-sys = "0.11" + [build-dependencies] pkg-config = "0.3" diff --git a/libs/enigo/src/linux/nix_impl.rs b/libs/enigo/src/linux/nix_impl.rs index c082236e3..c16be3469 100644 --- a/libs/enigo/src/linux/nix_impl.rs +++ b/libs/enigo/src/linux/nix_impl.rs @@ -261,6 +261,8 @@ impl KeyboardControllable for Enigo { } else { if let Some(keyboard) = &mut self.custom_keyboard { keyboard.key_sequence(sequence) + } else { + log::warn!("Enigo::key_sequence: no custom_keyboard set for Wayland!"); } } } @@ -277,6 +279,7 @@ impl KeyboardControllable for Enigo { if let Some(keyboard) = &mut self.custom_keyboard { keyboard.key_down(key) } else { + log::warn!("Enigo::key_down: no custom_keyboard set for Wayland!"); Ok(()) } } @@ -290,13 +293,24 @@ impl KeyboardControllable for Enigo { } else { if let Some(keyboard) = &mut self.custom_keyboard { keyboard.key_up(key) + } else { + log::warn!("Enigo::key_up: no custom_keyboard set for Wayland!"); } } } fn key_click(&mut self, key: Key) { - if self.tfc_key_click(key).is_err() { - self.key_down(key).ok(); - self.key_up(key); + if self.is_x11 { + // X11: try tfc first, then fallback to key_down/key_up + if self.tfc_key_click(key).is_err() { + self.key_down(key).ok(); + self.key_up(key); + } + } else { + if let Some(keyboard) = &mut self.custom_keyboard { + keyboard.key_click(key); + } else { + log::warn!("Enigo::key_click: no custom_keyboard set for Wayland!"); + } } } } @@ -345,7 +359,7 @@ fn convert_to_tfc_key(key: Key) -> Option { Key::Numpad9 => TFC_Key::N9, Key::Decimal => TFC_Key::NumpadDecimal, Key::Clear => TFC_Key::NumpadClear, - Key::Pause => TFC_Key::PlayPause, + Key::Pause => TFC_Key::Pause, Key::Print => TFC_Key::Print, Key::Snapshot => TFC_Key::PrintScreen, Key::Insert => TFC_Key::Insert, diff --git a/libs/enigo/src/linux/xdo.rs b/libs/enigo/src/linux/xdo.rs index f0f7d49af..7796904f9 100644 --- a/libs/enigo/src/linux/xdo.rs +++ b/libs/enigo/src/linux/xdo.rs @@ -1,50 +1,23 @@ +//! XDO-based input emulation for Linux. +//! +//! This module uses libxdo-sys (patched to use dynamic loading stub) for input emulation. +//! The stub handles dynamic loading of libxdo, so we just call the functions directly. +//! +//! If libxdo is not available at runtime, all operations become no-ops. + use crate::{Key, KeyboardControllable, MouseButton, MouseControllable}; -use hbb_common::libc::{c_char, c_int, c_void, useconds_t}; -use std::{borrow::Cow, ffi::CString, ptr}; +use hbb_common::libc::c_int; +use hbb_common::x11::xlib::{Display, XCloseDisplay, XGetPointerMapping, XOpenDisplay}; +use libxdo_sys::{self, xdo_t, CURRENTWINDOW}; +use std::{borrow::Cow, ffi::CString}; -const CURRENT_WINDOW: c_int = 0; +/// Default delay per keypress in microseconds. +/// This value is passed to libxdo functions and must fit in `useconds_t` (u32). const DEFAULT_DELAY: u64 = 12000; -type Window = c_int; -type Xdo = *const c_void; -#[link(name = "xdo")] -extern "C" { - fn xdo_free(xdo: Xdo); - fn xdo_new(display: *const c_char) -> Xdo; - - fn xdo_click_window(xdo: Xdo, window: Window, button: c_int) -> c_int; - fn xdo_mouse_down(xdo: Xdo, window: Window, button: c_int) -> c_int; - fn xdo_mouse_up(xdo: Xdo, window: Window, button: c_int) -> c_int; - fn xdo_move_mouse(xdo: Xdo, x: c_int, y: c_int, screen: c_int) -> c_int; - fn xdo_move_mouse_relative(xdo: Xdo, x: c_int, y: c_int) -> c_int; - - fn xdo_enter_text_window( - xdo: Xdo, - window: Window, - string: *const c_char, - delay: useconds_t, - ) -> c_int; - fn xdo_send_keysequence_window( - xdo: Xdo, - window: Window, - string: *const c_char, - delay: useconds_t, - ) -> c_int; - fn xdo_send_keysequence_window_down( - xdo: Xdo, - window: Window, - string: *const c_char, - delay: useconds_t, - ) -> c_int; - fn xdo_send_keysequence_window_up( - xdo: Xdo, - window: Window, - string: *const c_char, - delay: useconds_t, - ) -> c_int; - fn xdo_get_input_state(xdo: Xdo) -> u32; -} +/// Maximum allowed delay value (u32::MAX as u64). +const MAX_DELAY: u64 = u32::MAX as u64; fn mousebutton(button: MouseButton) -> c_int { match button { @@ -60,9 +33,54 @@ fn mousebutton(button: MouseButton) -> c_int { } } +/// Minimum number of buttons the X11 core pointer must support. +/// Buttons 8 (Back) and 9 (Forward) are needed for mouse side buttons. +const MIN_POINTER_BUTTONS: usize = 9; + +/// Check that the X11 core pointer's button map includes at least 9 buttons +/// so that `XTestFakeButtonEvent` can simulate Back (8) and Forward (9). +/// +/// RustDesk's uinput "Mouse passthrough" device normally provides enough +/// buttons, but we log a warning if the map is too small so the issue is +/// diagnosable. `XSetPointerMapping` cannot extend the button count (its +/// length must match `XGetPointerMapping`), so we only diagnose here. +fn check_x11_button_map() { + // Skip on non-X11 sessions to avoid noisy "XOpenDisplay failed" warnings + // on pure Wayland or headless environments without $DISPLAY. + if std::env::var_os("DISPLAY").is_none() { + return; + } + + let display: *mut Display = unsafe { XOpenDisplay(std::ptr::null()) }; + if display.is_null() { + log::warn!("XOpenDisplay failed, cannot check button map"); + return; + } + + let mut current_map = [0u8; 32]; + let nbuttons = + unsafe { XGetPointerMapping(display, current_map.as_mut_ptr(), current_map.len() as i32) }; + unsafe { XCloseDisplay(display) }; + + if nbuttons < 0 { + log::warn!("XGetPointerMapping failed (returned {nbuttons})"); + return; + } + + let nbuttons = nbuttons as usize; + if nbuttons >= MIN_POINTER_BUTTONS { + log::info!("X11 pointer has {nbuttons} buttons, side buttons supported"); + } else { + log::warn!( + "X11 pointer has only {nbuttons} buttons (need {MIN_POINTER_BUTTONS}); \ + back/forward side buttons may not work until a device with more buttons is added" + ); + } +} + /// The main struct for handling the event emitting pub(super) struct EnigoXdo { - xdo: Xdo, + xdo: *mut xdo_t, delay: u64, } // This is safe, we have a unique pointer. @@ -70,37 +88,62 @@ pub(super) struct EnigoXdo { unsafe impl Send for EnigoXdo {} impl Default for EnigoXdo { - /// Create a new EnigoXdo instance + /// Create a new EnigoXdo instance. + /// + /// If libxdo is not available, the xdo pointer will be null and all + /// input operations will be no-ops. fn default() -> Self { + let xdo = unsafe { libxdo_sys::xdo_new(std::ptr::null()) }; + if xdo.is_null() { + log::warn!("Failed to create xdo context, xdo functions will be disabled"); + } else { + log::info!("xdo context created successfully"); + check_x11_button_map(); + } Self { - xdo: unsafe { xdo_new(ptr::null()) }, + xdo, delay: DEFAULT_DELAY, } } } + impl EnigoXdo { - /// Get the delay per keypress. - /// Default value is 12000. - /// This is Linux-specific. + /// Get the delay per keypress in microseconds. + /// + /// Default value is 12000 (12ms). This is Linux-specific. pub fn delay(&self) -> u64 { self.delay } - /// Set the delay per keypress. - /// This is Linux-specific. + + /// Set the delay per keypress in microseconds. + /// + /// This is Linux-specific. The value is clamped to `u32::MAX` (approximately + /// 4295 seconds) because libxdo uses `useconds_t` which is typically `u32`. + /// + /// # Arguments + /// * `delay` - Delay in microseconds. Values exceeding `u32::MAX` will be clamped. pub fn set_delay(&mut self, delay: u64) { - self.delay = delay; + self.delay = delay.min(MAX_DELAY); + if delay > MAX_DELAY { + log::warn!( + "delay value {} exceeds maximum {}, clamped", + delay, + MAX_DELAY + ); + } } } + impl Drop for EnigoXdo { fn drop(&mut self) { - if self.xdo.is_null() { - return; - } - unsafe { - xdo_free(self.xdo); + if !self.xdo.is_null() { + unsafe { + libxdo_sys::xdo_free(self.xdo); + } } } } + impl MouseControllable for EnigoXdo { fn as_any(&self) -> &dyn std::any::Any { self @@ -115,42 +158,47 @@ impl MouseControllable for EnigoXdo { return; } unsafe { - xdo_move_mouse(self.xdo, x as c_int, y as c_int, 0); + libxdo_sys::xdo_move_mouse(self.xdo as *const _, x, y, 0); } } + fn mouse_move_relative(&mut self, x: i32, y: i32) { if self.xdo.is_null() { return; } unsafe { - xdo_move_mouse_relative(self.xdo, x as c_int, y as c_int); + libxdo_sys::xdo_move_mouse_relative(self.xdo as *const _, x, y); } } + fn mouse_down(&mut self, button: MouseButton) -> crate::ResultType { if self.xdo.is_null() { return Ok(()); } unsafe { - xdo_mouse_down(self.xdo, CURRENT_WINDOW, mousebutton(button)); + libxdo_sys::xdo_mouse_down(self.xdo as *const _, CURRENTWINDOW, mousebutton(button)); } Ok(()) } + fn mouse_up(&mut self, button: MouseButton) { if self.xdo.is_null() { return; } unsafe { - xdo_mouse_up(self.xdo, CURRENT_WINDOW, mousebutton(button)); + libxdo_sys::xdo_mouse_up(self.xdo as *const _, CURRENTWINDOW, mousebutton(button)); } } + fn mouse_click(&mut self, button: MouseButton) { if self.xdo.is_null() { return; } unsafe { - xdo_click_window(self.xdo, CURRENT_WINDOW, mousebutton(button)); + libxdo_sys::xdo_click_window(self.xdo as *const _, CURRENTWINDOW, mousebutton(button)); } } + fn mouse_scroll_x(&mut self, length: i32) { let button; let mut length = length; @@ -169,6 +217,7 @@ impl MouseControllable for EnigoXdo { self.mouse_click(button); } } + fn mouse_scroll_y(&mut self, length: i32) { let button; let mut length = length; @@ -188,6 +237,7 @@ impl MouseControllable for EnigoXdo { } } } + fn keysequence<'a>(key: Key) -> Cow<'a, str> { if let Key::Layout(c) = key { return Cow::Owned(format!("U{:X}", c as u32)); @@ -284,6 +334,7 @@ fn keysequence<'a>(key: Key) -> Cow<'a, str> { _ => "", }) } + impl KeyboardControllable for EnigoXdo { fn as_any(&self) -> &dyn std::any::Any { self @@ -314,7 +365,7 @@ impl KeyboardControllable for EnigoXdo { let mod_alt = 1 << 3; let mod_numlock = 1 << 4; let mod_meta = 1 << 6; - let mask = unsafe { xdo_get_input_state(self.xdo) }; + let mask = unsafe { libxdo_sys::xdo_get_input_state(self.xdo as *const _) }; match key { Key::Shift => mask & mod_shift != 0, Key::CapsLock => mask & mod_lock != 0, @@ -332,56 +383,59 @@ impl KeyboardControllable for EnigoXdo { } if let Ok(string) = CString::new(sequence) { unsafe { - xdo_enter_text_window( - self.xdo, - CURRENT_WINDOW, + libxdo_sys::xdo_enter_text_window( + self.xdo as *const _, + CURRENTWINDOW, string.as_ptr(), - self.delay as useconds_t, + self.delay as libxdo_sys::useconds_t, ); } } } + fn key_down(&mut self, key: Key) -> crate::ResultType { if self.xdo.is_null() { return Ok(()); } let string = CString::new(&*keysequence(key))?; unsafe { - xdo_send_keysequence_window_down( - self.xdo, - CURRENT_WINDOW, + libxdo_sys::xdo_send_keysequence_window_down( + self.xdo as *const _, + CURRENTWINDOW, string.as_ptr(), - self.delay as useconds_t, + self.delay as libxdo_sys::useconds_t, ); } Ok(()) } + fn key_up(&mut self, key: Key) { if self.xdo.is_null() { return; } if let Ok(string) = CString::new(&*keysequence(key)) { unsafe { - xdo_send_keysequence_window_up( - self.xdo, - CURRENT_WINDOW, + libxdo_sys::xdo_send_keysequence_window_up( + self.xdo as *const _, + CURRENTWINDOW, string.as_ptr(), - self.delay as useconds_t, + self.delay as libxdo_sys::useconds_t, ); } } } + fn key_click(&mut self, key: Key) { if self.xdo.is_null() { return; } if let Ok(string) = CString::new(&*keysequence(key)) { unsafe { - xdo_send_keysequence_window( - self.xdo, - CURRENT_WINDOW, + libxdo_sys::xdo_send_keysequence_window( + self.xdo as *const _, + CURRENTWINDOW, string.as_ptr(), - self.delay as useconds_t, + self.delay as libxdo_sys::useconds_t, ); } } diff --git a/libs/enigo/src/macos/macos_impl.rs b/libs/enigo/src/macos/macos_impl.rs index e7d7d9e8d..20f5d0cbf 100644 --- a/libs/enigo/src/macos/macos_impl.rs +++ b/libs/enigo/src/macos/macos_impl.rs @@ -141,8 +141,27 @@ impl Enigo { self.flags |= flag; } - fn post(&self, event: CGEvent) { - if !self.ignore_flags { + // Just check F11 for minimal changes. + // Since enigo (legacy mode) is deprecated, it is currently in maintenance only. + fn post(&self, event: CGEvent, keycode: Option) { + if keycode == Some(kVK_F11) { + // Some key events require the flags to work. + // We can't simply set the flag to `CGEventFlags::CGEventFlagNull`. + // eg. `F11` requires flags `CGEventFlags::CGEventFlagSecondaryFn | 0x20000000` to work. + self.post_event(event, false); + } else { + // macOS system may use the previous event flag to generate the next event. + // Only found this issue when locking the screen. + // When we use enigo to lock the screen, the next mouse event will have the flag + // `CGEventFlagControl | CGEventFlagCommand | 0x20000000`. + // The key event will also have the flag `CGEventFlagControl | CGEventFlagCommand | 0x20000000`. + // Therefore, we need to set the flag to `event.set_flags(self.flags)` to avoid this. + self.post_event(event, true); + } + } + + fn post_event(&self, event: CGEvent, force_flags: bool) { + if !self.ignore_flags && (force_flags || self.flags != CGEventFlags::CGEventFlagNull) { event.set_flags(self.flags); } event.set_integer_value_field(EventField::EVENT_SOURCE_USER_DATA, ENIGO_INPUT_EXTRA_VALUE); @@ -189,42 +208,56 @@ impl MouseControllable for Enigo { } fn mouse_move_to(&mut self, x: i32, y: i32) { - let pressed = Self::pressed_buttons(); - - let event_type = if pressed & 1 > 0 { - CGEventType::LeftMouseDragged - } else if pressed & 2 > 0 { - CGEventType::RightMouseDragged - } else { - CGEventType::MouseMoved - }; - - let dest = CGPoint::new(x as f64, y as f64); - if let Some(src) = self.event_source.as_ref() { - if let Ok(event) = - CGEvent::new_mouse_event(src.clone(), event_type, dest, CGMouseButton::Left) - { - self.post(event); - } - } + // For absolute movement, we don't set delta values + // This maintains backward compatibility + self.mouse_move_to_impl(x, y, None); } fn mouse_move_relative(&mut self, x: i32, y: i32) { let (display_width, display_height) = Self::main_display_size(); let (current_x, y_inv) = Self::mouse_location_raw_coords(); let current_y = (display_height as i32) - y_inv; - let new_x = current_x + x; - let new_y = current_y + y; + // Use saturating arithmetic to prevent overflow/wraparound + let mut new_x = current_x.saturating_add(x); + let mut new_y = current_y.saturating_add(y); - if new_x < 0 - || new_x as usize > display_width - || new_y < 0 - || new_y as usize > display_height - { - return; + // Define screen center and edge margins for cursor reset + let center_x = (display_width / 2) as i32; + let center_y = (display_height / 2) as i32; + // Margin calculation: 5% of the smaller screen dimension with a minimum of 50px. + // This provides a comfortable buffer zone to detect when the cursor is approaching + // screen edges, allowing us to reset it to center before it hits the boundary. + // This ensures continuous relative mouse movement without getting stuck at edges. + let margin = (display_width.min(display_height) / 20).max(50) as i32; + + // Check if cursor is approaching screen boundaries + // Use saturating_sub to prevent negative thresholds on very small displays + let right = (display_width as i32).saturating_sub(margin); + let bottom = (display_height as i32).saturating_sub(margin); + let near_edge = new_x < margin + || new_x > right + || new_y < margin + || new_y > bottom; + + if near_edge { + // Reset cursor to screen center to allow continuous movement + // The delta values are still passed correctly for games/apps + new_x = center_x; + new_y = center_y; } - self.mouse_move_to(new_x, new_y); + // Clamp to screen bounds as a safety measure. + // Use saturating_sub(1) to ensure coordinates don't exceed the last valid pixel. + let max_x = (display_width as i32).saturating_sub(1).max(0); + let max_y = (display_height as i32).saturating_sub(1).max(0); + new_x = new_x.clamp(0, max_x); + new_y = new_y.clamp(0, max_y); + + // Pass delta values for relative movement + // This is critical for browser Pointer Lock API support + // The delta fields (MOUSE_EVENT_DELTA_X/Y) are used by browsers + // to calculate movementX/Y in Pointer Lock mode + self.mouse_move_to_impl(new_x, new_y, Some((x, y))); } fn mouse_down(&mut self, button: MouseButton) -> crate::ResultType { @@ -269,7 +302,7 @@ impl MouseControllable for Enigo { if let Some(v) = btn_value { event.set_integer_value_field(EventField::MOUSE_EVENT_BUTTON_NUMBER, v); } - self.post(event); + self.post(event, None); } } Ok(()) @@ -308,7 +341,7 @@ impl MouseControllable for Enigo { if let Some(v) = btn_value { event.set_integer_value_field(EventField::MOUSE_EVENT_BUTTON_NUMBER, v); } - self.post(event); + self.post(event, None); } } } @@ -394,7 +427,7 @@ impl KeyboardControllable for Enigo { if let Some(src) = self.event_source.as_ref() { if let Ok(event) = CGEvent::new_keyboard_event(src.clone(), 0, true) { event.set_string(cluster); - self.post(event); + self.post(event, None); } } } @@ -408,11 +441,11 @@ impl KeyboardControllable for Enigo { if let Some(src) = self.event_source.as_ref() { if let Ok(event) = CGEvent::new_keyboard_event(src.clone(), keycode, true) { - self.post(event); + self.post(event, Some(keycode)); } if let Ok(event) = CGEvent::new_keyboard_event(src.clone(), keycode, false) { - self.post(event); + self.post(event, Some(keycode)); } } } @@ -424,18 +457,17 @@ impl KeyboardControllable for Enigo { } if let Some(src) = self.event_source.as_ref() { if let Ok(event) = CGEvent::new_keyboard_event(src.clone(), code, true) { - self.post(event); + self.post(event, Some(code)); } } Ok(()) } fn key_up(&mut self, key: Key) { + let code = self.key_to_keycode(key); if let Some(src) = self.event_source.as_ref() { - if let Ok(event) = - CGEvent::new_keyboard_event(src.clone(), self.key_to_keycode(key), false) - { - self.post(event); + if let Ok(event) = CGEvent::new_keyboard_event(src.clone(), code, false) { + self.post(event, Some(code)); } } } @@ -455,6 +487,43 @@ impl Enigo { } } + /// Internal implementation for mouse movement with optional delta values. + /// + /// The `delta` parameter is crucial for browser Pointer Lock API support. + /// When a browser enters Pointer Lock mode, it reads mouse delta values + /// (MOUSE_EVENT_DELTA_X/Y) directly from CGEvent to calculate movementX/Y. + /// Without setting these fields, the browser sees zero movement. + fn mouse_move_to_impl(&mut self, x: i32, y: i32, delta: Option<(i32, i32)>) { + let pressed = Self::pressed_buttons(); + + // Determine event type and corresponding mouse button based on pressed buttons. + // The CGMouseButton must match the event type for drag events. + let (event_type, button) = if pressed & 1 > 0 { + (CGEventType::LeftMouseDragged, CGMouseButton::Left) + } else if pressed & 2 > 0 { + (CGEventType::RightMouseDragged, CGMouseButton::Right) + } else if pressed & 4 > 0 { + (CGEventType::OtherMouseDragged, CGMouseButton::Center) + } else { + (CGEventType::MouseMoved, CGMouseButton::Left) // Button doesn't matter for MouseMoved + }; + + let dest = CGPoint::new(x as f64, y as f64); + if let Some(src) = self.event_source.as_ref() { + if let Ok(event) = + CGEvent::new_mouse_event(src.clone(), event_type, dest, button) + { + // Set delta fields for relative mouse movement + // This is essential for Pointer Lock API in browsers + if let Some((dx, dy)) = delta { + event.set_integer_value_field(EventField::MOUSE_EVENT_DELTA_X, dx as i64); + event.set_integer_value_field(EventField::MOUSE_EVENT_DELTA_Y, dy as i64); + } + self.post(event, None); + } + } + } + /// Fetches the `(width, height)` in pixels of the main display pub fn main_display_size() -> (usize, usize) { let display_id = unsafe { CGMainDisplayID() }; diff --git a/libs/enigo/src/win/win_impl.rs b/libs/enigo/src/win/win_impl.rs index 882dba126..a6b465ea1 100644 --- a/libs/enigo/src/win/win_impl.rs +++ b/libs/enigo/src/win/win_impl.rs @@ -269,7 +269,7 @@ impl KeyboardControllable for Enigo { for pos in 0..mod_len { let rpos = mod_len - 1 - pos; if flag & (0x0001 << rpos) != 0 { - self.key_up(modifiers[pos]); + self.key_up(modifiers[rpos]); } } @@ -298,7 +298,18 @@ impl KeyboardControllable for Enigo { } fn key_up(&mut self, key: Key) { - keybd_event(KEYEVENTF_KEYUP, self.key_to_keycode(key), 0); + match key { + Key::Layout(c) => { + let code = self.get_layoutdependent_keycode(c); + if code as u16 != 0xFFFF { + let vk = code & 0x00FF; + keybd_event(KEYEVENTF_KEYUP, vk, 0); + } + } + _ => { + keybd_event(KEYEVENTF_KEYUP, self.key_to_keycode(key), 0); + } + } } fn get_key_state(&mut self, key: Key) -> bool { diff --git a/libs/hbb_common b/libs/hbb_common new file mode 160000 index 000000000..9043c15ac --- /dev/null +++ b/libs/hbb_common @@ -0,0 +1 @@ +Subproject commit 9043c15acc6d5b42b6c12ad284c16c1ec172f1f0 diff --git a/libs/hbb_common/.gitignore b/libs/hbb_common/.gitignore deleted file mode 100644 index 693699042..000000000 --- a/libs/hbb_common/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -/target -**/*.rs.bk -Cargo.lock diff --git a/libs/hbb_common/Cargo.toml b/libs/hbb_common/Cargo.toml deleted file mode 100644 index 259d01e9d..000000000 --- a/libs/hbb_common/Cargo.toml +++ /dev/null @@ -1,65 +0,0 @@ -[package] -name = "hbb_common" -version = "0.1.0" -authors = ["open-trade "] -edition = "2018" - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - -[dependencies] -flexi_logger = { version = "0.27", features = ["async"] } -protobuf = { version = "3.4", features = ["with-bytes"] } -tokio = { version = "1.38", features = ["full"] } -tokio-util = { version = "0.7", features = ["full"] } -futures = "0.3" -bytes = { version = "1.6", features = ["serde"] } -log = "0.4" -env_logger = "0.10" -socket2 = { version = "0.3", features = ["reuseport"] } -zstd = "0.13" -anyhow = "1.0" -futures-util = "0.3" -directories-next = "2.0" -rand = "0.8" -serde_derive = "1.0" -serde = "1.0" -serde_json = "1.0" -lazy_static = "1.4" -confy = { git = "https://github.com/rustdesk-org/confy" } -dirs-next = "2.0" -filetime = "0.2" -sodiumoxide = "0.2" -regex = "1.8" -tokio-socks = { git = "https://github.com/rustdesk-org/tokio-socks" } -chrono = "0.4" -backtrace = "0.3" -libc = "0.2" -dlopen = "0.1" -toml = "0.7" -uuid = { version = "1.3", features = ["v4"] } -# new sysinfo issue: https://github.com/rustdesk/rustdesk/pull/6330#issuecomment-2270871442 -sysinfo = { git = "https://github.com/rustdesk-org/sysinfo", branch = "rlim_max" } -thiserror = "1.0" -httparse = "1.5" -base64 = "0.22" -url = "2.2" - -[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] -mac_address = "1.1" -machine-uid = { git = "https://github.com/rustdesk-org/machine-uid" } -[target.'cfg(not(any(target_os = "macos", target_os = "windows")))'.dependencies] -tokio-rustls = { version = "0.26", features = ["logging", "tls12", "ring"], default-features = false } -rustls-platform-verifier = "0.3.1" -rustls-pki-types = "1.4" -[target.'cfg(any(target_os = "macos", target_os = "windows"))'.dependencies] -tokio-native-tls ="0.3" - -[build-dependencies] -protobuf-codegen = { version = "3.4" } - -[target.'cfg(target_os = "windows")'.dependencies] -winapi = { version = "0.3", features = ["winuser", "synchapi", "pdh", "memoryapi", "sysinfoapi"] } - -[target.'cfg(target_os = "macos")'.dependencies] -osascript = "0.3" - diff --git a/libs/hbb_common/build.rs b/libs/hbb_common/build.rs deleted file mode 100644 index 5ebc3a287..000000000 --- a/libs/hbb_common/build.rs +++ /dev/null @@ -1,14 +0,0 @@ -fn main() { - let out_dir = format!("{}/protos", std::env::var("OUT_DIR").unwrap()); - - std::fs::create_dir_all(&out_dir).unwrap(); - - protobuf_codegen::Codegen::new() - .pure() - .out_dir(out_dir) - .inputs(["protos/rendezvous.proto", "protos/message.proto"]) - .include("protos") - .customize(protobuf_codegen::Customize::default().tokio_bytes(true)) - .run() - .expect("Codegen failed."); -} diff --git a/libs/hbb_common/examples/config.rs b/libs/hbb_common/examples/config.rs deleted file mode 100644 index 95169df8e..000000000 --- a/libs/hbb_common/examples/config.rs +++ /dev/null @@ -1,5 +0,0 @@ -extern crate hbb_common; - -fn main() { - println!("{:?}", hbb_common::config::PeerConfig::load("455058072")); -} diff --git a/libs/hbb_common/examples/system_message.rs b/libs/hbb_common/examples/system_message.rs deleted file mode 100644 index 0be788428..000000000 --- a/libs/hbb_common/examples/system_message.rs +++ /dev/null @@ -1,20 +0,0 @@ -extern crate hbb_common; -#[cfg(target_os = "linux")] -use hbb_common::platform::linux; -#[cfg(target_os = "macos")] -use hbb_common::platform::macos; - -fn main() { - #[cfg(target_os = "linux")] - let res = linux::system_message("test title", "test message", true); - #[cfg(target_os = "macos")] - let res = macos::alert( - "System Preferences".to_owned(), - "warning".to_owned(), - "test title".to_owned(), - "test message".to_owned(), - ["Ok".to_owned()].to_vec(), - ); - #[cfg(any(target_os = "linux", target_os = "macos"))] - println!("result {:?}", &res); -} diff --git a/libs/hbb_common/protos/message.proto b/libs/hbb_common/protos/message.proto deleted file mode 100644 index 21f9e7aea..000000000 --- a/libs/hbb_common/protos/message.proto +++ /dev/null @@ -1,847 +0,0 @@ -syntax = "proto3"; -package hbb; - -message EncodedVideoFrame { - bytes data = 1; - bool key = 2; - int64 pts = 3; -} - -message EncodedVideoFrames { repeated EncodedVideoFrame frames = 1; } - -message RGB { bool compress = 1; } - -// planes data send directly in binary for better use arraybuffer on web -message YUV { - bool compress = 1; - int32 stride = 2; -} - -enum Chroma { - I420 = 0; - I444 = 1; -} - -message VideoFrame { - oneof union { - EncodedVideoFrames vp9s = 6; - RGB rgb = 7; - YUV yuv = 8; - EncodedVideoFrames h264s = 10; - EncodedVideoFrames h265s = 11; - EncodedVideoFrames vp8s = 12; - EncodedVideoFrames av1s = 13; - } - int32 display = 14; -} - -message IdPk { - string id = 1; - bytes pk = 2; -} - -message DisplayInfo { - sint32 x = 1; - sint32 y = 2; - int32 width = 3; - int32 height = 4; - string name = 5; - bool online = 6; - bool cursor_embedded = 7; - Resolution original_resolution = 8; - double scale = 9; -} - -message PortForward { - string host = 1; - int32 port = 2; -} - -message FileTransfer { - string dir = 1; - bool show_hidden = 2; -} - -message OSLogin { - string username = 1; - string password = 2; -} - -message LoginRequest { - string username = 1; - bytes password = 2; - string my_id = 4; - string my_name = 5; - OptionMessage option = 6; - oneof union { - FileTransfer file_transfer = 7; - PortForward port_forward = 8; - } - bool video_ack_required = 9; - uint64 session_id = 10; - string version = 11; - OSLogin os_login = 12; - string my_platform = 13; - bytes hwid = 14; -} - -message Auth2FA { - string code = 1; - bytes hwid = 2; -} - -message ChatMessage { string text = 1; } - -message Features { - bool privacy_mode = 1; -} - -message CodecAbility { - bool vp8 = 1; - bool vp9 = 2; - bool av1 = 3; - bool h264 = 4; - bool h265 = 5; -} - -message SupportedEncoding { - bool h264 = 1; - bool h265 = 2; - bool vp8 = 3; - bool av1 = 4; - CodecAbility i444 = 5; -} - -message PeerInfo { - string username = 1; - string hostname = 2; - string platform = 3; - repeated DisplayInfo displays = 4; - int32 current_display = 5; - bool sas_enabled = 6; - string version = 7; - Features features = 9; - SupportedEncoding encoding = 10; - SupportedResolutions resolutions = 11; - // Use JSON's key-value format which is friendly for peer to handle. - // NOTE: Only support one-level dictionaries (for peer to update), and the key is of type string. - string platform_additions = 12; - WindowsSessions windows_sessions = 13; -} - -message WindowsSession { - uint32 sid = 1; - string name = 2; -} - -message LoginResponse { - oneof union { - string error = 1; - PeerInfo peer_info = 2; - } - bool enable_trusted_devices = 3; -} - -message TouchScaleUpdate { - // The delta scale factor relative to the previous scale. - // delta * 1000 - // 0 means scale end - int32 scale = 1; -} - -message TouchPanStart { - int32 x = 1; - int32 y = 2; -} - -message TouchPanUpdate { - // The delta x position relative to the previous position. - int32 x = 1; - // The delta y position relative to the previous position. - int32 y = 2; -} - -message TouchPanEnd { - int32 x = 1; - int32 y = 2; -} - -message TouchEvent { - oneof union { - TouchScaleUpdate scale_update = 1; - TouchPanStart pan_start = 2; - TouchPanUpdate pan_update = 3; - TouchPanEnd pan_end = 4; - } -} - -message PointerDeviceEvent { - oneof union { - TouchEvent touch_event = 1; - } - repeated ControlKey modifiers = 2; -} - -message MouseEvent { - int32 mask = 1; - sint32 x = 2; - sint32 y = 3; - repeated ControlKey modifiers = 4; -} - -enum KeyboardMode{ - Legacy = 0; - Map = 1; - Translate = 2; - Auto = 3; -} - -enum ControlKey { - Unknown = 0; - Alt = 1; - Backspace = 2; - CapsLock = 3; - Control = 4; - Delete = 5; - DownArrow = 6; - End = 7; - Escape = 8; - F1 = 9; - F10 = 10; - F11 = 11; - F12 = 12; - F2 = 13; - F3 = 14; - F4 = 15; - F5 = 16; - F6 = 17; - F7 = 18; - F8 = 19; - F9 = 20; - Home = 21; - LeftArrow = 22; - /// meta key (also known as "windows"; "super"; and "command") - Meta = 23; - /// option key on macOS (alt key on Linux and Windows) - Option = 24; // deprecated, use Alt instead - PageDown = 25; - PageUp = 26; - Return = 27; - RightArrow = 28; - Shift = 29; - Space = 30; - Tab = 31; - UpArrow = 32; - Numpad0 = 33; - Numpad1 = 34; - Numpad2 = 35; - Numpad3 = 36; - Numpad4 = 37; - Numpad5 = 38; - Numpad6 = 39; - Numpad7 = 40; - Numpad8 = 41; - Numpad9 = 42; - Cancel = 43; - Clear = 44; - Menu = 45; // deprecated, use Alt instead - Pause = 46; - Kana = 47; - Hangul = 48; - Junja = 49; - Final = 50; - Hanja = 51; - Kanji = 52; - Convert = 53; - Select = 54; - Print = 55; - Execute = 56; - Snapshot = 57; - Insert = 58; - Help = 59; - Sleep = 60; - Separator = 61; - Scroll = 62; - NumLock = 63; - RWin = 64; - Apps = 65; - Multiply = 66; - Add = 67; - Subtract = 68; - Decimal = 69; - Divide = 70; - Equals = 71; - NumpadEnter = 72; - RShift = 73; - RControl = 74; - RAlt = 75; - VolumeMute = 76; // mainly used on mobile devices as controlled side - VolumeUp = 77; - VolumeDown = 78; - Power = 79; // mainly used on mobile devices as controlled side - CtrlAltDel = 100; - LockScreen = 101; -} - -message KeyEvent { - bool down = 1; - bool press = 2; - oneof union { - ControlKey control_key = 3; - // position key code. win: scancode, linux: key code, macos: key code - uint32 chr = 4; - uint32 unicode = 5; - string seq = 6; - // high word. virtual keycode - // low word. unicode - uint32 win2win_hotkey = 7; - } - repeated ControlKey modifiers = 8; - KeyboardMode mode = 9; -} - -message CursorData { - uint64 id = 1; - sint32 hotx = 2; - sint32 hoty = 3; - int32 width = 4; - int32 height = 5; - bytes colors = 6; -} - -message CursorPosition { - sint32 x = 1; - sint32 y = 2; -} - -message Hash { - string salt = 1; - string challenge = 2; -} - -enum ClipboardFormat { - Text = 0; - Rtf = 1; - Html = 2; - ImageRgba = 21; - ImagePng = 22; - ImageSvg = 23; - Special = 31; -} - -message Clipboard { - bool compress = 1; - bytes content = 2; - int32 width = 3; - int32 height = 4; - ClipboardFormat format = 5; - // Special format name, only used when format is Special. - string special_name = 6; -} - -message MultiClipboards { repeated Clipboard clipboards = 1; } - -enum FileType { - Dir = 0; - DirLink = 2; - DirDrive = 3; - File = 4; - FileLink = 5; -} - -message FileEntry { - FileType entry_type = 1; - string name = 2; - bool is_hidden = 3; - uint64 size = 4; - uint64 modified_time = 5; -} - -message FileDirectory { - int32 id = 1; - string path = 2; - repeated FileEntry entries = 3; -} - -message ReadDir { - string path = 1; - bool include_hidden = 2; -} - -message ReadAllFiles { - int32 id = 1; - string path = 2; - bool include_hidden = 3; -} - -message FileRename { - int32 id = 1; - string path = 2; - string new_name = 3; -} - -message FileAction { - oneof union { - ReadDir read_dir = 1; - FileTransferSendRequest send = 2; - FileTransferReceiveRequest receive = 3; - FileDirCreate create = 4; - FileRemoveDir remove_dir = 5; - FileRemoveFile remove_file = 6; - ReadAllFiles all_files = 7; - FileTransferCancel cancel = 8; - FileTransferSendConfirmRequest send_confirm = 9; - FileRename rename = 10; - } -} - -message FileTransferCancel { int32 id = 1; } - -message FileResponse { - oneof union { - FileDirectory dir = 1; - FileTransferBlock block = 2; - FileTransferError error = 3; - FileTransferDone done = 4; - FileTransferDigest digest = 5; - } -} - -message FileTransferDigest { - int32 id = 1; - sint32 file_num = 2; - uint64 last_modified = 3; - uint64 file_size = 4; - bool is_upload = 5; - bool is_identical = 6; -} - -message FileTransferBlock { - int32 id = 1; - sint32 file_num = 2; - bytes data = 3; - bool compressed = 4; - uint32 blk_id = 5; -} - -message FileTransferError { - int32 id = 1; - string error = 2; - sint32 file_num = 3; -} - -message FileTransferSendRequest { - int32 id = 1; - string path = 2; - bool include_hidden = 3; - int32 file_num = 4; -} - -message FileTransferSendConfirmRequest { - int32 id = 1; - sint32 file_num = 2; - oneof union { - bool skip = 3; - uint32 offset_blk = 4; - } -} - -message FileTransferDone { - int32 id = 1; - sint32 file_num = 2; -} - -message FileTransferReceiveRequest { - int32 id = 1; - string path = 2; // path written to - repeated FileEntry files = 3; - int32 file_num = 4; - uint64 total_size = 5; -} - -message FileRemoveDir { - int32 id = 1; - string path = 2; - bool recursive = 3; -} - -message FileRemoveFile { - int32 id = 1; - string path = 2; - sint32 file_num = 3; -} - -message FileDirCreate { - int32 id = 1; - string path = 2; -} - -// main logic from freeRDP -message CliprdrMonitorReady { -} - -message CliprdrFormat { - int32 id = 2; - string format = 3; -} - -message CliprdrServerFormatList { - repeated CliprdrFormat formats = 2; -} - -message CliprdrServerFormatListResponse { - int32 msg_flags = 2; -} - -message CliprdrServerFormatDataRequest { - int32 requested_format_id = 2; -} - -message CliprdrServerFormatDataResponse { - int32 msg_flags = 2; - bytes format_data = 3; -} - -message CliprdrFileContentsRequest { - int32 stream_id = 2; - int32 list_index = 3; - int32 dw_flags = 4; - int32 n_position_low = 5; - int32 n_position_high = 6; - int32 cb_requested = 7; - bool have_clip_data_id = 8; - int32 clip_data_id = 9; -} - -message CliprdrFileContentsResponse { - int32 msg_flags = 3; - int32 stream_id = 4; - bytes requested_data = 5; -} - -message Cliprdr { - oneof union { - CliprdrMonitorReady ready = 1; - CliprdrServerFormatList format_list = 2; - CliprdrServerFormatListResponse format_list_response = 3; - CliprdrServerFormatDataRequest format_data_request = 4; - CliprdrServerFormatDataResponse format_data_response = 5; - CliprdrFileContentsRequest file_contents_request = 6; - CliprdrFileContentsResponse file_contents_response = 7; - } -} - -message Resolution { - int32 width = 1; - int32 height = 2; -} - -message DisplayResolution { - int32 display = 1; - Resolution resolution = 2; -} - -message SupportedResolutions { repeated Resolution resolutions = 1; } - -message SwitchDisplay { - int32 display = 1; - sint32 x = 2; - sint32 y = 3; - int32 width = 4; - int32 height = 5; - bool cursor_embedded = 6; - SupportedResolutions resolutions = 7; - // Do not care about the origin point for now. - Resolution original_resolution = 8; -} - -message CaptureDisplays { - repeated int32 add = 1; - repeated int32 sub = 2; - repeated int32 set = 3; -} - -message ToggleVirtualDisplay { - int32 display = 1; - bool on = 2; -} - -message TogglePrivacyMode { - string impl_key = 1; - bool on = 2; -} - -message PermissionInfo { - enum Permission { - Keyboard = 0; - Clipboard = 2; - Audio = 3; - File = 4; - Restart = 5; - Recording = 6; - BlockInput = 7; - } - - Permission permission = 1; - bool enabled = 2; -} - -enum ImageQuality { - NotSet = 0; - Low = 2; - Balanced = 3; - Best = 4; -} - -message SupportedDecoding { - enum PreferCodec { - Auto = 0; - VP9 = 1; - H264 = 2; - H265 = 3; - VP8 = 4; - AV1 = 5; - } - - int32 ability_vp9 = 1; - int32 ability_h264 = 2; - int32 ability_h265 = 3; - PreferCodec prefer = 4; - int32 ability_vp8 = 5; - int32 ability_av1 = 6; - CodecAbility i444 = 7; - Chroma prefer_chroma = 8; -} - -message OptionMessage { - enum BoolOption { - NotSet = 0; - No = 1; - Yes = 2; - } - ImageQuality image_quality = 1; - BoolOption lock_after_session_end = 2; - BoolOption show_remote_cursor = 3; - BoolOption privacy_mode = 4; - BoolOption block_input = 5; - int32 custom_image_quality = 6; - BoolOption disable_audio = 7; - BoolOption disable_clipboard = 8; - BoolOption enable_file_transfer = 9; - SupportedDecoding supported_decoding = 10; - int32 custom_fps = 11; - BoolOption disable_keyboard = 12; -// Position 13 is used for Resolution. Remove later. -// Resolution custom_resolution = 13; -// BoolOption support_windows_specific_session = 14; - // starting from 15 please, do not use removed fields - BoolOption follow_remote_cursor = 15; - BoolOption follow_remote_window = 16; -} - -message TestDelay { - int64 time = 1; - bool from_client = 2; - uint32 last_delay = 3; - uint32 target_bitrate = 4; -} - -message PublicKey { - bytes asymmetric_value = 1; - bytes symmetric_value = 2; -} - -message SignedId { bytes id = 1; } - -message AudioFormat { - uint32 sample_rate = 1; - uint32 channels = 2; -} - -message AudioFrame { - bytes data = 1; -} - -// Notify peer to show message box. -message MessageBox { - // Message type. Refer to flutter/lib/common.dart/msgBox(). - string msgtype = 1; - string title = 2; - // English - string text = 3; - // If not empty, msgbox provides a button to following the link. - // The link here can't be directly http url. - // It must be the key of http url configed in peer side or "rustdesk://*" (jump in app). - string link = 4; -} - -message BackNotification { - // no need to consider block input by someone else - enum BlockInputState { - BlkStateUnknown = 0; - BlkOnSucceeded = 2; - BlkOnFailed = 3; - BlkOffSucceeded = 4; - BlkOffFailed = 5; - } - enum PrivacyModeState { - PrvStateUnknown = 0; - // Privacy mode on by someone else - PrvOnByOther = 2; - // Privacy mode is not supported on the remote side - PrvNotSupported = 3; - // Privacy mode on by self - PrvOnSucceeded = 4; - // Privacy mode on by self, but denied - PrvOnFailedDenied = 5; - // Some plugins are not found - PrvOnFailedPlugin = 6; - // Privacy mode on by self, but failed - PrvOnFailed = 7; - // Privacy mode off by self - PrvOffSucceeded = 8; - // Ctrl + P - PrvOffByPeer = 9; - // Privacy mode off by self, but failed - PrvOffFailed = 10; - PrvOffUnknown = 11; - } - - oneof union { - PrivacyModeState privacy_mode_state = 1; - BlockInputState block_input_state = 2; - } - // Supplementary message, for "PrvOnFailed" and "PrvOffFailed" - string details = 3; - // The key of the implementation - string impl_key = 4; -} - -message ElevationRequestWithLogon { - string username = 1; - string password = 2; -} - -message ElevationRequest { - oneof union { - bool direct = 1; - ElevationRequestWithLogon logon = 2; - } -} - -message SwitchSidesRequest { - bytes uuid = 1; -} - -message SwitchSidesResponse { - bytes uuid = 1; - LoginRequest lr = 2; -} - -message SwitchBack {} - -message PluginRequest { - string id = 1; - bytes content = 2; -} - -message PluginFailure { - string id = 1; - string name = 2; - string msg = 3; -} - -message WindowsSessions { - repeated WindowsSession sessions = 1; - uint32 current_sid = 2; -} - -// Query messages from peer. -message MessageQuery { - // The SwitchDisplay message of the target display. - // If the target display is not found, the message will be ignored. - int32 switch_display = 1; -} - -message Misc { - oneof union { - ChatMessage chat_message = 4; - SwitchDisplay switch_display = 5; - PermissionInfo permission_info = 6; - OptionMessage option = 7; - AudioFormat audio_format = 8; - string close_reason = 9; - bool refresh_video = 10; - bool video_received = 12; - BackNotification back_notification = 13; - bool restart_remote_device = 14; - bool uac = 15; - bool foreground_window_elevated = 16; - bool stop_service = 17; - ElevationRequest elevation_request = 18; - string elevation_response = 19; - bool portable_service_running = 20; - SwitchSidesRequest switch_sides_request = 21; - SwitchBack switch_back = 22; - // Deprecated since 1.2.4, use `change_display_resolution` (36) instead. - // But we must keep it for compatibility when peer version < 1.2.4. - Resolution change_resolution = 24; - PluginRequest plugin_request = 25; - PluginFailure plugin_failure = 26; - uint32 full_speed_fps = 27; // deprecated - uint32 auto_adjust_fps = 28; - bool client_record_status = 29; - CaptureDisplays capture_displays = 30; - int32 refresh_video_display = 31; - ToggleVirtualDisplay toggle_virtual_display = 32; - TogglePrivacyMode toggle_privacy_mode = 33; - SupportedEncoding supported_encoding = 34; - uint32 selected_sid = 35; - DisplayResolution change_display_resolution = 36; - MessageQuery message_query = 37; - int32 follow_current_display = 38; - } -} - -message VoiceCallRequest { - int64 req_timestamp = 1; - // Indicates whether the request is a connect action or a disconnect action. - bool is_connect = 2; -} - -message VoiceCallResponse { - bool accepted = 1; - int64 req_timestamp = 2; // Should copy from [VoiceCallRequest::req_timestamp]. - int64 ack_timestamp = 3; -} - -message Message { - oneof union { - SignedId signed_id = 3; - PublicKey public_key = 4; - TestDelay test_delay = 5; - VideoFrame video_frame = 6; - LoginRequest login_request = 7; - LoginResponse login_response = 8; - Hash hash = 9; - MouseEvent mouse_event = 10; - AudioFrame audio_frame = 11; - CursorData cursor_data = 12; - CursorPosition cursor_position = 13; - uint64 cursor_id = 14; - KeyEvent key_event = 15; - Clipboard clipboard = 16; - FileAction file_action = 17; - FileResponse file_response = 18; - Misc misc = 19; - Cliprdr cliprdr = 20; - MessageBox message_box = 21; - SwitchSidesResponse switch_sides_response = 22; - VoiceCallRequest voice_call_request = 23; - VoiceCallResponse voice_call_response = 24; - PeerInfo peer_info = 25; - PointerDeviceEvent pointer_device_event = 26; - Auth2FA auth_2fa = 27; - MultiClipboards multi_clipboards = 28; - } -} diff --git a/libs/hbb_common/protos/rendezvous.proto b/libs/hbb_common/protos/rendezvous.proto deleted file mode 100644 index 2fc0d9040..000000000 --- a/libs/hbb_common/protos/rendezvous.proto +++ /dev/null @@ -1,196 +0,0 @@ -syntax = "proto3"; -package hbb; - -message RegisterPeer { - string id = 1; - int32 serial = 2; -} - -enum ConnType { - DEFAULT_CONN = 0; - FILE_TRANSFER = 1; - PORT_FORWARD = 2; - RDP = 3; -} - -message RegisterPeerResponse { bool request_pk = 2; } - -message PunchHoleRequest { - string id = 1; - NatType nat_type = 2; - string licence_key = 3; - ConnType conn_type = 4; - string token = 5; - string version = 6; -} - -message PunchHole { - bytes socket_addr = 1; - string relay_server = 2; - NatType nat_type = 3; -} - -message TestNatRequest { - int32 serial = 1; -} - -// per my test, uint/int has no difference in encoding, int not good for negative, use sint for negative -message TestNatResponse { - int32 port = 1; - ConfigUpdate cu = 2; // for mobile -} - -enum NatType { - UNKNOWN_NAT = 0; - ASYMMETRIC = 1; - SYMMETRIC = 2; -} - -message PunchHoleSent { - bytes socket_addr = 1; - string id = 2; - string relay_server = 3; - NatType nat_type = 4; - string version = 5; -} - -message RegisterPk { - string id = 1; - bytes uuid = 2; - bytes pk = 3; - string old_id = 4; -} - -message RegisterPkResponse { - enum Result { - OK = 0; - UUID_MISMATCH = 2; - ID_EXISTS = 3; - TOO_FREQUENT = 4; - INVALID_ID_FORMAT = 5; - NOT_SUPPORT = 6; - SERVER_ERROR = 7; - } - Result result = 1; - int32 keep_alive = 2; -} - -message PunchHoleResponse { - bytes socket_addr = 1; - bytes pk = 2; - enum Failure { - ID_NOT_EXIST = 0; - OFFLINE = 2; - LICENSE_MISMATCH = 3; - LICENSE_OVERUSE = 4; - } - Failure failure = 3; - string relay_server = 4; - oneof union { - NatType nat_type = 5; - bool is_local = 6; - } - string other_failure = 7; - int32 feedback = 8; -} - -message ConfigUpdate { - int32 serial = 1; - repeated string rendezvous_servers = 2; -} - -message RequestRelay { - string id = 1; - string uuid = 2; - bytes socket_addr = 3; - string relay_server = 4; - bool secure = 5; - string licence_key = 6; - ConnType conn_type = 7; - string token = 8; -} - -message RelayResponse { - bytes socket_addr = 1; - string uuid = 2; - string relay_server = 3; - oneof union { - string id = 4; - bytes pk = 5; - } - string refuse_reason = 6; - string version = 7; - int32 feedback = 9; -} - -message SoftwareUpdate { string url = 1; } - -// if in same intranet, punch hole won't work both for udp and tcp, -// even some router has below connection error if we connect itself, -// { kind: Other, error: "could not resolve to any address" }, -// so we request local address to connect. -message FetchLocalAddr { - bytes socket_addr = 1; - string relay_server = 2; -} - -message LocalAddr { - bytes socket_addr = 1; - bytes local_addr = 2; - string relay_server = 3; - string id = 4; - string version = 5; -} - -message PeerDiscovery { - string cmd = 1; - string mac = 2; - string id = 3; - string username = 4; - string hostname = 5; - string platform = 6; - string misc = 7; -} - -message OnlineRequest { - string id = 1; - repeated string peers = 2; -} - -message OnlineResponse { - bytes states = 1; -} - -message KeyExchange { - repeated bytes keys = 1; -} - -message HealthCheck { - string token = 1; -} - -message RendezvousMessage { - oneof union { - RegisterPeer register_peer = 6; - RegisterPeerResponse register_peer_response = 7; - PunchHoleRequest punch_hole_request = 8; - PunchHole punch_hole = 9; - PunchHoleSent punch_hole_sent = 10; - PunchHoleResponse punch_hole_response = 11; - FetchLocalAddr fetch_local_addr = 12; - LocalAddr local_addr = 13; - ConfigUpdate configure_update = 14; - RegisterPk register_pk = 15; - RegisterPkResponse register_pk_response = 16; - SoftwareUpdate software_update = 17; - RequestRelay request_relay = 18; - RelayResponse relay_response = 19; - TestNatRequest test_nat_request = 20; - TestNatResponse test_nat_response = 21; - PeerDiscovery peer_discovery = 22; - OnlineRequest online_request = 23; - OnlineResponse online_response = 24; - KeyExchange key_exchange = 25; - HealthCheck hc = 26; - } -} diff --git a/libs/hbb_common/src/bytes_codec.rs b/libs/hbb_common/src/bytes_codec.rs deleted file mode 100644 index bfc798715..000000000 --- a/libs/hbb_common/src/bytes_codec.rs +++ /dev/null @@ -1,280 +0,0 @@ -use bytes::{Buf, BufMut, Bytes, BytesMut}; -use std::io; -use tokio_util::codec::{Decoder, Encoder}; - -#[derive(Debug, Clone, Copy)] -pub struct BytesCodec { - state: DecodeState, - raw: bool, - max_packet_length: usize, -} - -#[derive(Debug, Clone, Copy)] -enum DecodeState { - Head, - Data(usize), -} - -impl Default for BytesCodec { - fn default() -> Self { - Self::new() - } -} - -impl BytesCodec { - pub fn new() -> Self { - Self { - state: DecodeState::Head, - raw: false, - max_packet_length: usize::MAX, - } - } - - pub fn set_raw(&mut self) { - self.raw = true; - } - - pub fn set_max_packet_length(&mut self, n: usize) { - self.max_packet_length = n; - } - - fn decode_head(&mut self, src: &mut BytesMut) -> io::Result> { - if src.is_empty() { - return Ok(None); - } - let head_len = ((src[0] & 0x3) + 1) as usize; - if src.len() < head_len { - return Ok(None); - } - let mut n = src[0] as usize; - if head_len > 1 { - n |= (src[1] as usize) << 8; - } - if head_len > 2 { - n |= (src[2] as usize) << 16; - } - if head_len > 3 { - n |= (src[3] as usize) << 24; - } - n >>= 2; - if n > self.max_packet_length { - return Err(io::Error::new(io::ErrorKind::InvalidData, "Too big packet")); - } - src.advance(head_len); - src.reserve(n); - Ok(Some(n)) - } - - fn decode_data(&self, n: usize, src: &mut BytesMut) -> io::Result> { - if src.len() < n { - return Ok(None); - } - Ok(Some(src.split_to(n))) - } -} - -impl Decoder for BytesCodec { - type Item = BytesMut; - type Error = io::Error; - - fn decode(&mut self, src: &mut BytesMut) -> Result, io::Error> { - if self.raw { - if !src.is_empty() { - let len = src.len(); - return Ok(Some(src.split_to(len))); - } else { - return Ok(None); - } - } - let n = match self.state { - DecodeState::Head => match self.decode_head(src)? { - Some(n) => { - self.state = DecodeState::Data(n); - n - } - None => return Ok(None), - }, - DecodeState::Data(n) => n, - }; - - match self.decode_data(n, src)? { - Some(data) => { - self.state = DecodeState::Head; - Ok(Some(data)) - } - None => Ok(None), - } - } -} - -impl Encoder for BytesCodec { - type Error = io::Error; - - fn encode(&mut self, data: Bytes, buf: &mut BytesMut) -> Result<(), io::Error> { - if self.raw { - buf.reserve(data.len()); - buf.put(data); - return Ok(()); - } - if data.len() <= 0x3F { - buf.put_u8((data.len() << 2) as u8); - } else if data.len() <= 0x3FFF { - buf.put_u16_le((data.len() << 2) as u16 | 0x1); - } else if data.len() <= 0x3FFFFF { - let h = (data.len() << 2) as u32 | 0x2; - buf.put_u16_le((h & 0xFFFF) as u16); - buf.put_u8((h >> 16) as u8); - } else if data.len() <= 0x3FFFFFFF { - buf.put_u32_le((data.len() << 2) as u32 | 0x3); - } else { - return Err(io::Error::new(io::ErrorKind::InvalidInput, "Overflow")); - } - buf.extend(data); - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn test_codec1() { - let mut codec = BytesCodec::new(); - let mut buf = BytesMut::new(); - let mut bytes: Vec = Vec::new(); - bytes.resize(0x3F, 1); - assert!(codec.encode(bytes.into(), &mut buf).is_ok()); - let buf_saved = buf.clone(); - assert_eq!(buf.len(), 0x3F + 1); - if let Ok(Some(res)) = codec.decode(&mut buf) { - assert_eq!(res.len(), 0x3F); - assert_eq!(res[0], 1); - } else { - panic!(); - } - let mut codec2 = BytesCodec::new(); - let mut buf2 = BytesMut::new(); - if let Ok(None) = codec2.decode(&mut buf2) { - } else { - panic!(); - } - buf2.extend(&buf_saved[0..1]); - if let Ok(None) = codec2.decode(&mut buf2) { - } else { - panic!(); - } - buf2.extend(&buf_saved[1..]); - if let Ok(Some(res)) = codec2.decode(&mut buf2) { - assert_eq!(res.len(), 0x3F); - assert_eq!(res[0], 1); - } else { - panic!(); - } - } - - #[test] - fn test_codec2() { - let mut codec = BytesCodec::new(); - let mut buf = BytesMut::new(); - let mut bytes: Vec = Vec::new(); - assert!(codec.encode("".into(), &mut buf).is_ok()); - assert_eq!(buf.len(), 1); - bytes.resize(0x3F + 1, 2); - assert!(codec.encode(bytes.into(), &mut buf).is_ok()); - assert_eq!(buf.len(), 0x3F + 2 + 2); - if let Ok(Some(res)) = codec.decode(&mut buf) { - assert_eq!(res.len(), 0); - } else { - panic!(); - } - if let Ok(Some(res)) = codec.decode(&mut buf) { - assert_eq!(res.len(), 0x3F + 1); - assert_eq!(res[0], 2); - } else { - panic!(); - } - } - - #[test] - fn test_codec3() { - let mut codec = BytesCodec::new(); - let mut buf = BytesMut::new(); - let mut bytes: Vec = Vec::new(); - bytes.resize(0x3F - 1, 3); - assert!(codec.encode(bytes.into(), &mut buf).is_ok()); - assert_eq!(buf.len(), 0x3F + 1 - 1); - if let Ok(Some(res)) = codec.decode(&mut buf) { - assert_eq!(res.len(), 0x3F - 1); - assert_eq!(res[0], 3); - } else { - panic!(); - } - } - #[test] - fn test_codec4() { - let mut codec = BytesCodec::new(); - let mut buf = BytesMut::new(); - let mut bytes: Vec = Vec::new(); - bytes.resize(0x3FFF, 4); - assert!(codec.encode(bytes.into(), &mut buf).is_ok()); - assert_eq!(buf.len(), 0x3FFF + 2); - if let Ok(Some(res)) = codec.decode(&mut buf) { - assert_eq!(res.len(), 0x3FFF); - assert_eq!(res[0], 4); - } else { - panic!(); - } - } - - #[test] - fn test_codec5() { - let mut codec = BytesCodec::new(); - let mut buf = BytesMut::new(); - let mut bytes: Vec = Vec::new(); - bytes.resize(0x3FFFFF, 5); - assert!(codec.encode(bytes.into(), &mut buf).is_ok()); - assert_eq!(buf.len(), 0x3FFFFF + 3); - if let Ok(Some(res)) = codec.decode(&mut buf) { - assert_eq!(res.len(), 0x3FFFFF); - assert_eq!(res[0], 5); - } else { - panic!(); - } - } - - #[test] - fn test_codec6() { - let mut codec = BytesCodec::new(); - let mut buf = BytesMut::new(); - let mut bytes: Vec = Vec::new(); - bytes.resize(0x3FFFFF + 1, 6); - assert!(codec.encode(bytes.into(), &mut buf).is_ok()); - let buf_saved = buf.clone(); - assert_eq!(buf.len(), 0x3FFFFF + 4 + 1); - if let Ok(Some(res)) = codec.decode(&mut buf) { - assert_eq!(res.len(), 0x3FFFFF + 1); - assert_eq!(res[0], 6); - } else { - panic!(); - } - let mut codec2 = BytesCodec::new(); - let mut buf2 = BytesMut::new(); - buf2.extend(&buf_saved[0..1]); - if let Ok(None) = codec2.decode(&mut buf2) { - } else { - panic!(); - } - buf2.extend(&buf_saved[1..6]); - if let Ok(None) = codec2.decode(&mut buf2) { - } else { - panic!(); - } - buf2.extend(&buf_saved[6..]); - if let Ok(Some(res)) = codec2.decode(&mut buf2) { - assert_eq!(res.len(), 0x3FFFFF + 1); - assert_eq!(res[0], 6); - } else { - panic!(); - } - } -} diff --git a/libs/hbb_common/src/compress.rs b/libs/hbb_common/src/compress.rs deleted file mode 100644 index 761d916e4..000000000 --- a/libs/hbb_common/src/compress.rs +++ /dev/null @@ -1,34 +0,0 @@ -use std::{cell::RefCell, io}; -use zstd::bulk::Compressor; - -// The library supports regular compression levels from 1 up to ZSTD_maxCLevel(), -// which is currently 22. Levels >= 20 -// Default level is ZSTD_CLEVEL_DEFAULT==3. -// value 0 means default, which is controlled by ZSTD_CLEVEL_DEFAULT -thread_local! { - static COMPRESSOR: RefCell>> = RefCell::new(Compressor::new(crate::config::COMPRESS_LEVEL)); -} - -pub fn compress(data: &[u8]) -> Vec { - let mut out = Vec::new(); - COMPRESSOR.with(|c| { - if let Ok(mut c) = c.try_borrow_mut() { - match &mut *c { - Ok(c) => match c.compress(data) { - Ok(res) => out = res, - Err(err) => { - crate::log::debug!("Failed to compress: {}", err); - } - }, - Err(err) => { - crate::log::debug!("Failed to get compressor: {}", err); - } - } - } - }); - out -} - -pub fn decompress(data: &[u8]) -> Vec { - zstd::decode_all(data).unwrap_or_default() -} diff --git a/libs/hbb_common/src/config.rs b/libs/hbb_common/src/config.rs deleted file mode 100644 index 0cb370cd8..000000000 --- a/libs/hbb_common/src/config.rs +++ /dev/null @@ -1,2686 +0,0 @@ -use std::{ - collections::{HashMap, HashSet}, - fs, - io::{Read, Write}, - net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, - ops::{Deref, DerefMut}, - path::{Path, PathBuf}, - sync::{Mutex, RwLock}, - time::{Duration, Instant, SystemTime}, -}; - -use anyhow::Result; -use bytes::Bytes; -use rand::Rng; -use regex::Regex; -use serde as de; -use serde_derive::{Deserialize, Serialize}; -use serde_json; -use sodiumoxide::base64; -use sodiumoxide::crypto::sign; - -use crate::{ - compress::{compress, decompress}, - log, - password_security::{ - decrypt_str_or_original, decrypt_vec_or_original, encrypt_str_or_original, - encrypt_vec_or_original, symmetric_crypt, - }, -}; - -pub const RENDEZVOUS_TIMEOUT: u64 = 12_000; -pub const CONNECT_TIMEOUT: u64 = 18_000; -pub const READ_TIMEOUT: u64 = 18_000; -// https://github.com/quic-go/quic-go/issues/525#issuecomment-294531351 -// https://datatracker.ietf.org/doc/html/draft-hamilton-early-deployment-quic-00#section-6.10 -// 15 seconds is recommended by quic, though oneSIP recommend 25 seconds, -// https://www.onsip.com/voip-resources/voip-fundamentals/what-is-nat-keepalive -pub const REG_INTERVAL: i64 = 15_000; -pub const COMPRESS_LEVEL: i32 = 3; -const SERIAL: i32 = 3; -const PASSWORD_ENC_VERSION: &str = "00"; -pub const ENCRYPT_MAX_LEN: usize = 128; // used for password, pin, etc, not for all - -#[cfg(target_os = "macos")] -lazy_static::lazy_static! { - pub static ref ORG: RwLock = RwLock::new("com.carriez".to_owned()); -} - -type Size = (i32, i32, i32, i32); -type KeyPair = (Vec, Vec); - -lazy_static::lazy_static! { - static ref CONFIG: RwLock = RwLock::new(Config::load()); - static ref CONFIG2: RwLock = RwLock::new(Config2::load()); - static ref LOCAL_CONFIG: RwLock = RwLock::new(LocalConfig::load()); - static ref TRUSTED_DEVICES: RwLock<(Vec, bool)> = Default::default(); - static ref ONLINE: Mutex> = Default::default(); - pub static ref PROD_RENDEZVOUS_SERVER: RwLock = RwLock::new(match option_env!("RENDEZVOUS_SERVER") { - Some(key) if !key.is_empty() => key, - _ => "", - }.to_owned()); - pub static ref EXE_RENDEZVOUS_SERVER: RwLock = Default::default(); - pub static ref APP_NAME: RwLock = RwLock::new("RustDesk".to_owned()); - static ref KEY_PAIR: Mutex> = Default::default(); - static ref USER_DEFAULT_CONFIG: RwLock<(UserDefaultConfig, Instant)> = RwLock::new((UserDefaultConfig::load(), Instant::now())); - pub static ref NEW_STORED_PEER_CONFIG: Mutex> = Default::default(); - pub static ref DEFAULT_SETTINGS: RwLock> = Default::default(); - pub static ref OVERWRITE_SETTINGS: RwLock> = Default::default(); - pub static ref DEFAULT_DISPLAY_SETTINGS: RwLock> = Default::default(); - pub static ref OVERWRITE_DISPLAY_SETTINGS: RwLock> = Default::default(); - pub static ref DEFAULT_LOCAL_SETTINGS: RwLock> = Default::default(); - pub static ref OVERWRITE_LOCAL_SETTINGS: RwLock> = Default::default(); - pub static ref HARD_SETTINGS: RwLock> = Default::default(); - pub static ref BUILTIN_SETTINGS: RwLock> = Default::default(); -} - -lazy_static::lazy_static! { - pub static ref APP_DIR: RwLock = Default::default(); -} - -#[cfg(any(target_os = "android", target_os = "ios"))] -lazy_static::lazy_static! { - pub static ref APP_HOME_DIR: RwLock = Default::default(); -} - -pub const LINK_DOCS_HOME: &str = "https://rustdesk.com/docs/en/"; -pub const LINK_DOCS_X11_REQUIRED: &str = "https://rustdesk.com/docs/en/manual/linux/#x11-required"; -pub const LINK_HEADLESS_LINUX_SUPPORT: &str = - "https://github.com/rustdesk/rustdesk/wiki/Headless-Linux-Support"; -lazy_static::lazy_static! { - pub static ref HELPER_URL: HashMap<&'static str, &'static str> = HashMap::from([ - ("rustdesk docs home", LINK_DOCS_HOME), - ("rustdesk docs x11-required", LINK_DOCS_X11_REQUIRED), - ("rustdesk x11 headless", LINK_HEADLESS_LINUX_SUPPORT), - ]); -} - -const CHARS: &[char] = &[ - '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', - 'm', 'n', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', -]; - -pub const RENDEZVOUS_SERVERS: &[&str] = &["rs-ny.rustdesk.com"]; -pub const PUBLIC_RS_PUB_KEY: &str = "OeVuKk5nlHiXp+APNn0Y3pC1Iwpwn44JGqrQCsWqmBw="; - -pub const RS_PUB_KEY: &str = match option_env!("RS_PUB_KEY") { - Some(key) if !key.is_empty() => key, - _ => PUBLIC_RS_PUB_KEY, -}; - -pub const RENDEZVOUS_PORT: i32 = 21116; -pub const RELAY_PORT: i32 = 21117; - -macro_rules! serde_field_string { - ($default_func:ident, $de_func:ident, $default_expr:expr) => { - fn $default_func() -> String { - $default_expr - } - - fn $de_func<'de, D>(deserializer: D) -> Result - where - D: de::Deserializer<'de>, - { - let s: String = - de::Deserialize::deserialize(deserializer).unwrap_or(Self::$default_func()); - if s.is_empty() { - return Ok(Self::$default_func()); - } - Ok(s) - } - }; -} - -macro_rules! serde_field_bool { - ($struct_name: ident, $field_name: literal, $func: ident, $default: literal) => { - #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] - pub struct $struct_name { - #[serde(default = $default, rename = $field_name, deserialize_with = "deserialize_bool")] - pub v: bool, - } - impl Default for $struct_name { - fn default() -> Self { - Self { v: Self::$func() } - } - } - impl $struct_name { - pub fn $func() -> bool { - UserDefaultConfig::read($field_name) == "Y" - } - } - impl Deref for $struct_name { - type Target = bool; - - fn deref(&self) -> &Self::Target { - &self.v - } - } - impl DerefMut for $struct_name { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.v - } - } - }; -} - -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum NetworkType { - Direct, - ProxySocks, -} - -#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq)] -pub struct Config { - #[serde( - default, - skip_serializing_if = "String::is_empty", - deserialize_with = "deserialize_string" - )] - pub id: String, // use - #[serde(default, deserialize_with = "deserialize_string")] - enc_id: String, // store - #[serde(default, deserialize_with = "deserialize_string")] - password: String, - #[serde(default, deserialize_with = "deserialize_string")] - salt: String, - #[serde(default, deserialize_with = "deserialize_keypair")] - key_pair: KeyPair, // sk, pk - #[serde(default, deserialize_with = "deserialize_bool")] - key_confirmed: bool, - #[serde(default, deserialize_with = "deserialize_hashmap_string_bool")] - keys_confirmed: HashMap, -} - -#[derive(Debug, Default, PartialEq, Serialize, Deserialize, Clone)] -pub struct Socks5Server { - #[serde(default, deserialize_with = "deserialize_string")] - pub proxy: String, - #[serde(default, deserialize_with = "deserialize_string")] - pub username: String, - #[serde(default, deserialize_with = "deserialize_string")] - pub password: String, -} - -// more variable configs -#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq)] -pub struct Config2 { - #[serde(default, deserialize_with = "deserialize_string")] - rendezvous_server: String, - #[serde(default, deserialize_with = "deserialize_i32")] - nat_type: i32, - #[serde(default, deserialize_with = "deserialize_i32")] - serial: i32, - #[serde(default, deserialize_with = "deserialize_string")] - unlock_pin: String, - #[serde(default, deserialize_with = "deserialize_string")] - trusted_devices: String, - - #[serde(default)] - socks: Option, - - // the other scalar value must before this - #[serde(default, deserialize_with = "deserialize_hashmap_string_string")] - pub options: HashMap, -} - -#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq)] -pub struct Resolution { - pub w: i32, - pub h: i32, -} - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -pub struct PeerConfig { - #[serde(default, deserialize_with = "deserialize_vec_u8")] - pub password: Vec, - #[serde(default, deserialize_with = "deserialize_size")] - pub size: Size, - #[serde(default, deserialize_with = "deserialize_size")] - pub size_ft: Size, - #[serde(default, deserialize_with = "deserialize_size")] - pub size_pf: Size, - #[serde( - default = "PeerConfig::default_view_style", - deserialize_with = "PeerConfig::deserialize_view_style", - skip_serializing_if = "String::is_empty" - )] - pub view_style: String, - // Image scroll style, scrollbar or scroll auto - #[serde( - default = "PeerConfig::default_scroll_style", - deserialize_with = "PeerConfig::deserialize_scroll_style", - skip_serializing_if = "String::is_empty" - )] - pub scroll_style: String, - #[serde( - default = "PeerConfig::default_image_quality", - deserialize_with = "PeerConfig::deserialize_image_quality", - skip_serializing_if = "String::is_empty" - )] - pub image_quality: String, - #[serde( - default = "PeerConfig::default_custom_image_quality", - deserialize_with = "PeerConfig::deserialize_custom_image_quality", - skip_serializing_if = "Vec::is_empty" - )] - pub custom_image_quality: Vec, - #[serde(flatten)] - pub show_remote_cursor: ShowRemoteCursor, - #[serde(flatten)] - pub lock_after_session_end: LockAfterSessionEnd, - #[serde(flatten)] - pub privacy_mode: PrivacyMode, - #[serde(flatten)] - pub allow_swap_key: AllowSwapKey, - #[serde(default, deserialize_with = "deserialize_vec_i32_string_i32")] - pub port_forwards: Vec<(i32, String, i32)>, - #[serde(default, deserialize_with = "deserialize_i32")] - pub direct_failures: i32, - #[serde(flatten)] - pub disable_audio: DisableAudio, - #[serde(flatten)] - pub disable_clipboard: DisableClipboard, - #[serde(flatten)] - pub enable_file_copy_paste: EnableFileCopyPaste, - #[serde(flatten)] - pub show_quality_monitor: ShowQualityMonitor, - #[serde(flatten)] - pub follow_remote_cursor: FollowRemoteCursor, - #[serde(flatten)] - pub follow_remote_window: FollowRemoteWindow, - #[serde( - default, - deserialize_with = "deserialize_string", - skip_serializing_if = "String::is_empty" - )] - pub keyboard_mode: String, - #[serde(flatten)] - pub view_only: ViewOnly, - #[serde(flatten)] - pub sync_init_clipboard: SyncInitClipboard, - // Mouse wheel or touchpad scroll mode - #[serde( - default = "PeerConfig::default_reverse_mouse_wheel", - deserialize_with = "PeerConfig::deserialize_reverse_mouse_wheel", - skip_serializing_if = "String::is_empty" - )] - pub reverse_mouse_wheel: String, - #[serde( - default = "PeerConfig::default_displays_as_individual_windows", - deserialize_with = "PeerConfig::deserialize_displays_as_individual_windows", - skip_serializing_if = "String::is_empty" - )] - pub displays_as_individual_windows: String, - #[serde( - default = "PeerConfig::default_use_all_my_displays_for_the_remote_session", - deserialize_with = "PeerConfig::deserialize_use_all_my_displays_for_the_remote_session", - skip_serializing_if = "String::is_empty" - )] - pub use_all_my_displays_for_the_remote_session: String, - - #[serde( - default, - deserialize_with = "deserialize_hashmap_resolutions", - skip_serializing_if = "HashMap::is_empty" - )] - pub custom_resolutions: HashMap, - - // The other scalar value must before this - #[serde( - default, - deserialize_with = "deserialize_hashmap_string_string", - skip_serializing_if = "HashMap::is_empty" - )] - pub options: HashMap, // not use delete to represent default values - // Various data for flutter ui - #[serde(default, deserialize_with = "deserialize_hashmap_string_string")] - pub ui_flutter: HashMap, - #[serde(default)] - pub info: PeerInfoSerde, - #[serde(default)] - pub transfer: TransferSerde, -} - -impl Default for PeerConfig { - fn default() -> Self { - Self { - password: Default::default(), - size: Default::default(), - size_ft: Default::default(), - size_pf: Default::default(), - view_style: Self::default_view_style(), - scroll_style: Self::default_scroll_style(), - image_quality: Self::default_image_quality(), - custom_image_quality: Self::default_custom_image_quality(), - show_remote_cursor: Default::default(), - lock_after_session_end: Default::default(), - privacy_mode: Default::default(), - allow_swap_key: Default::default(), - port_forwards: Default::default(), - direct_failures: Default::default(), - disable_audio: Default::default(), - disable_clipboard: Default::default(), - enable_file_copy_paste: Default::default(), - show_quality_monitor: Default::default(), - follow_remote_cursor: Default::default(), - follow_remote_window: Default::default(), - keyboard_mode: Default::default(), - view_only: Default::default(), - reverse_mouse_wheel: Self::default_reverse_mouse_wheel(), - displays_as_individual_windows: Self::default_displays_as_individual_windows(), - use_all_my_displays_for_the_remote_session: - Self::default_use_all_my_displays_for_the_remote_session(), - custom_resolutions: Default::default(), - options: Self::default_options(), - ui_flutter: Default::default(), - info: Default::default(), - transfer: Default::default(), - sync_init_clipboard: Default::default(), - } - } -} - -#[derive(Debug, PartialEq, Default, Serialize, Deserialize, Clone)] -pub struct PeerInfoSerde { - #[serde(default, deserialize_with = "deserialize_string")] - pub username: String, - #[serde(default, deserialize_with = "deserialize_string")] - pub hostname: String, - #[serde(default, deserialize_with = "deserialize_string")] - pub platform: String, -} - -#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq)] -pub struct TransferSerde { - #[serde(default, deserialize_with = "deserialize_vec_string")] - pub write_jobs: Vec, - #[serde(default, deserialize_with = "deserialize_vec_string")] - pub read_jobs: Vec, -} - -#[inline] -pub fn get_online_state() -> i64 { - *ONLINE.lock().unwrap().values().max().unwrap_or(&0) -} - -#[cfg(not(any(target_os = "android", target_os = "ios")))] -fn patch(path: PathBuf) -> PathBuf { - if let Some(_tmp) = path.to_str() { - #[cfg(windows)] - return _tmp - .replace( - "system32\\config\\systemprofile", - "ServiceProfiles\\LocalService", - ) - .into(); - #[cfg(target_os = "macos")] - return _tmp.replace("Application Support", "Preferences").into(); - #[cfg(target_os = "linux")] - { - if _tmp == "/root" { - if let Ok(user) = crate::platform::linux::run_cmds_trim_newline("whoami") { - if user != "root" { - let cmd = format!("getent passwd '{}' | awk -F':' '{{print $6}}'", user); - if let Ok(output) = crate::platform::linux::run_cmds_trim_newline(&cmd) { - return output.into(); - } - return format!("/home/{user}").into(); - } - } - } - } - } - path -} - -impl Config2 { - fn load() -> Config2 { - let mut config = Config::load_::("2"); - let mut store = false; - if let Some(mut socks) = config.socks { - let (password, _, store2) = - decrypt_str_or_original(&socks.password, PASSWORD_ENC_VERSION); - socks.password = password; - config.socks = Some(socks); - store |= store2; - } - let (unlock_pin, _, store2) = - decrypt_str_or_original(&config.unlock_pin, PASSWORD_ENC_VERSION); - config.unlock_pin = unlock_pin; - store |= store2; - if store { - config.store(); - } - config - } - - pub fn file() -> PathBuf { - Config::file_("2") - } - - fn store(&self) { - let mut config = self.clone(); - if let Some(mut socks) = config.socks { - socks.password = - encrypt_str_or_original(&socks.password, PASSWORD_ENC_VERSION, ENCRYPT_MAX_LEN); - config.socks = Some(socks); - } - config.unlock_pin = - encrypt_str_or_original(&config.unlock_pin, PASSWORD_ENC_VERSION, ENCRYPT_MAX_LEN); - Config::store_(&config, "2"); - } - - pub fn get() -> Config2 { - return CONFIG2.read().unwrap().clone(); - } - - pub fn set(cfg: Config2) -> bool { - let mut lock = CONFIG2.write().unwrap(); - if *lock == cfg { - return false; - } - *lock = cfg; - lock.store(); - true - } -} - -pub fn load_path( - file: PathBuf, -) -> T { - let cfg = match confy::load_path(&file) { - Ok(config) => config, - Err(err) => { - if let confy::ConfyError::GeneralLoadError(err) = &err { - if err.kind() == std::io::ErrorKind::NotFound { - return T::default(); - } - } - log::error!("Failed to load config '{}': {}", file.display(), err); - T::default() - } - }; - cfg -} - -#[inline] -pub fn store_path(path: PathBuf, cfg: T) -> crate::ResultType<()> { - #[cfg(not(windows))] - { - use std::os::unix::fs::PermissionsExt; - Ok(confy::store_path_perms( - path, - cfg, - fs::Permissions::from_mode(0o600), - )?) - } - #[cfg(windows)] - { - Ok(confy::store_path(path, cfg)?) - } -} - -impl Config { - fn load_( - suffix: &str, - ) -> T { - let file = Self::file_(suffix); - let cfg = load_path(file); - if suffix.is_empty() { - log::trace!("{:?}", cfg); - } - cfg - } - - fn store_(config: &T, suffix: &str) { - let file = Self::file_(suffix); - if let Err(err) = store_path(file, config) { - log::error!("Failed to store {suffix} config: {err}"); - } - } - - fn load() -> Config { - let mut config = Config::load_::(""); - let mut store = false; - let (password, _, store1) = decrypt_str_or_original(&config.password, PASSWORD_ENC_VERSION); - config.password = password; - store |= store1; - let mut id_valid = false; - let (id, encrypted, store2) = decrypt_str_or_original(&config.enc_id, PASSWORD_ENC_VERSION); - if encrypted { - config.id = id; - id_valid = true; - store |= store2; - } else if - // Comment out for forward compatible - // crate::get_modified_time(&Self::file_("")) - // .checked_sub(std::time::Duration::from_secs(30)) // allow modification during installation - // .unwrap_or_else(crate::get_exe_time) - // < crate::get_exe_time() - // && - !config.id.is_empty() - && config.enc_id.is_empty() - && !decrypt_str_or_original(&config.id, PASSWORD_ENC_VERSION).1 - { - id_valid = true; - store = true; - } - if !id_valid { - for _ in 0..3 { - if let Some(id) = Config::get_auto_id() { - config.id = id; - store = true; - break; - } else { - log::error!("Failed to generate new id"); - } - } - } - if store { - config.store(); - } - config - } - - fn store(&self) { - let mut config = self.clone(); - config.password = - encrypt_str_or_original(&config.password, PASSWORD_ENC_VERSION, ENCRYPT_MAX_LEN); - config.enc_id = encrypt_str_or_original(&config.id, PASSWORD_ENC_VERSION, ENCRYPT_MAX_LEN); - config.id = "".to_owned(); - Config::store_(&config, ""); - } - - pub fn file() -> PathBuf { - Self::file_("") - } - - fn file_(suffix: &str) -> PathBuf { - let name = format!("{}{}", *APP_NAME.read().unwrap(), suffix); - Config::with_extension(Self::path(name)) - } - - pub fn is_empty(&self) -> bool { - (self.id.is_empty() && self.enc_id.is_empty()) || self.key_pair.0.is_empty() - } - - pub fn get_home() -> PathBuf { - #[cfg(any(target_os = "android", target_os = "ios"))] - return PathBuf::from(APP_HOME_DIR.read().unwrap().as_str()); - #[cfg(not(any(target_os = "android", target_os = "ios")))] - { - if let Some(path) = dirs_next::home_dir() { - patch(path) - } else if let Ok(path) = std::env::current_dir() { - path - } else { - std::env::temp_dir() - } - } - } - - pub fn path>(p: P) -> PathBuf { - #[cfg(any(target_os = "android", target_os = "ios"))] - { - let mut path: PathBuf = APP_DIR.read().unwrap().clone().into(); - path.push(p); - return path; - } - #[cfg(not(any(target_os = "android", target_os = "ios")))] - { - #[cfg(not(target_os = "macos"))] - let org = "".to_owned(); - #[cfg(target_os = "macos")] - let org = ORG.read().unwrap().clone(); - // /var/root for root - if let Some(project) = - directories_next::ProjectDirs::from("", &org, &APP_NAME.read().unwrap()) - { - let mut path = patch(project.config_dir().to_path_buf()); - path.push(p); - return path; - } - "".into() - } - } - - #[allow(unreachable_code)] - pub fn log_path() -> PathBuf { - #[cfg(target_os = "macos")] - { - if let Some(path) = dirs_next::home_dir().as_mut() { - path.push(format!("Library/Logs/{}", *APP_NAME.read().unwrap())); - return path.clone(); - } - } - #[cfg(target_os = "linux")] - { - let mut path = Self::get_home(); - path.push(format!(".local/share/logs/{}", *APP_NAME.read().unwrap())); - std::fs::create_dir_all(&path).ok(); - return path; - } - #[cfg(target_os = "android")] - { - let mut path = Self::get_home(); - path.push(format!("{}/Logs", *APP_NAME.read().unwrap())); - std::fs::create_dir_all(&path).ok(); - return path; - } - if let Some(path) = Self::path("").parent() { - let mut path: PathBuf = path.into(); - path.push("log"); - return path; - } - "".into() - } - - pub fn ipc_path(postfix: &str) -> String { - #[cfg(windows)] - { - // \\ServerName\pipe\PipeName - // where ServerName is either the name of a remote computer or a period, to specify the local computer. - // https://docs.microsoft.com/en-us/windows/win32/ipc/pipe-names - format!( - "\\\\.\\pipe\\{}\\query{}", - *APP_NAME.read().unwrap(), - postfix - ) - } - #[cfg(not(windows))] - { - use std::os::unix::fs::PermissionsExt; - #[cfg(target_os = "android")] - let mut path: PathBuf = - format!("{}/{}", *APP_DIR.read().unwrap(), *APP_NAME.read().unwrap()).into(); - #[cfg(not(target_os = "android"))] - let mut path: PathBuf = format!("/tmp/{}", *APP_NAME.read().unwrap()).into(); - fs::create_dir(&path).ok(); - fs::set_permissions(&path, fs::Permissions::from_mode(0o0777)).ok(); - path.push(format!("ipc{postfix}")); - path.to_str().unwrap_or("").to_owned() - } - } - - pub fn icon_path() -> PathBuf { - let mut path = Self::path("icons"); - if fs::create_dir_all(&path).is_err() { - path = std::env::temp_dir(); - } - path - } - - #[inline] - pub fn get_any_listen_addr(is_ipv4: bool) -> SocketAddr { - if is_ipv4 { - SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0) - } else { - SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0) - } - } - - pub fn get_rendezvous_server() -> String { - let mut rendezvous_server = EXE_RENDEZVOUS_SERVER.read().unwrap().clone(); - if rendezvous_server.is_empty() { - rendezvous_server = Self::get_option("custom-rendezvous-server"); - } - if rendezvous_server.is_empty() { - rendezvous_server = PROD_RENDEZVOUS_SERVER.read().unwrap().clone(); - } - if rendezvous_server.is_empty() { - rendezvous_server = CONFIG2.read().unwrap().rendezvous_server.clone(); - } - if rendezvous_server.is_empty() { - rendezvous_server = Self::get_rendezvous_servers() - .drain(..) - .next() - .unwrap_or_default(); - } - if !rendezvous_server.contains(':') { - rendezvous_server = format!("{rendezvous_server}:{RENDEZVOUS_PORT}"); - } - rendezvous_server - } - - pub fn get_rendezvous_servers() -> Vec { - let s = EXE_RENDEZVOUS_SERVER.read().unwrap().clone(); - if !s.is_empty() { - return vec![s]; - } - let s = Self::get_option("custom-rendezvous-server"); - if !s.is_empty() { - return vec![s]; - } - let s = PROD_RENDEZVOUS_SERVER.read().unwrap().clone(); - if !s.is_empty() { - return vec![s]; - } - let serial_obsolute = CONFIG2.read().unwrap().serial > SERIAL; - if serial_obsolute { - let ss: Vec = Self::get_option("rendezvous-servers") - .split(',') - .filter(|x| x.contains('.')) - .map(|x| x.to_owned()) - .collect(); - if !ss.is_empty() { - return ss; - } - } - return RENDEZVOUS_SERVERS.iter().map(|x| x.to_string()).collect(); - } - - pub fn reset_online() { - *ONLINE.lock().unwrap() = Default::default(); - } - - pub fn update_latency(host: &str, latency: i64) { - ONLINE.lock().unwrap().insert(host.to_owned(), latency); - let mut host = "".to_owned(); - let mut delay = i64::MAX; - for (tmp_host, tmp_delay) in ONLINE.lock().unwrap().iter() { - if tmp_delay > &0 && tmp_delay < &delay { - delay = *tmp_delay; - host = tmp_host.to_string(); - } - } - if !host.is_empty() { - let mut config = CONFIG2.write().unwrap(); - if host != config.rendezvous_server { - log::debug!("Update rendezvous_server in config to {}", host); - log::debug!("{:?}", *ONLINE.lock().unwrap()); - config.rendezvous_server = host; - config.store(); - } - } - } - - pub fn set_id(id: &str) { - let mut config = CONFIG.write().unwrap(); - if id == config.id { - return; - } - config.id = id.into(); - config.store(); - } - - pub fn set_nat_type(nat_type: i32) { - let mut config = CONFIG2.write().unwrap(); - if nat_type == config.nat_type { - return; - } - config.nat_type = nat_type; - config.store(); - } - - pub fn get_nat_type() -> i32 { - CONFIG2.read().unwrap().nat_type - } - - pub fn set_serial(serial: i32) { - let mut config = CONFIG2.write().unwrap(); - if serial == config.serial { - return; - } - config.serial = serial; - config.store(); - } - - pub fn get_serial() -> i32 { - std::cmp::max(CONFIG2.read().unwrap().serial, SERIAL) - } - - fn get_auto_id() -> Option { - #[cfg(any(target_os = "android", target_os = "ios"))] - { - return Some( - rand::thread_rng() - .gen_range(1_000_000_000..2_000_000_000) - .to_string(), - ); - } - - #[cfg(not(any(target_os = "android", target_os = "ios")))] - { - let mut id = 0u32; - if let Ok(Some(ma)) = mac_address::get_mac_address() { - for x in &ma.bytes()[2..] { - id = (id << 8) | (*x as u32); - } - id &= 0x1FFFFFFF; - Some(id.to_string()) - } else { - None - } - } - } - - pub fn get_auto_password(length: usize) -> String { - let mut rng = rand::thread_rng(); - (0..length) - .map(|_| CHARS[rng.gen::() % CHARS.len()]) - .collect() - } - - pub fn get_key_confirmed() -> bool { - CONFIG.read().unwrap().key_confirmed - } - - pub fn set_key_confirmed(v: bool) { - let mut config = CONFIG.write().unwrap(); - if config.key_confirmed == v { - return; - } - config.key_confirmed = v; - if !v { - config.keys_confirmed = Default::default(); - } - config.store(); - } - - pub fn get_host_key_confirmed(host: &str) -> bool { - matches!(CONFIG.read().unwrap().keys_confirmed.get(host), Some(true)) - } - - pub fn set_host_key_confirmed(host: &str, v: bool) { - if Self::get_host_key_confirmed(host) == v { - return; - } - let mut config = CONFIG.write().unwrap(); - config.keys_confirmed.insert(host.to_owned(), v); - config.store(); - } - - pub fn get_key_pair() -> KeyPair { - // lock here to make sure no gen_keypair more than once - // no use of CONFIG directly here to ensure no recursive calling in Config::load because of password dec which calling this function - let mut lock = KEY_PAIR.lock().unwrap(); - if let Some(p) = lock.as_ref() { - return p.clone(); - } - let mut config = Config::load_::(""); - if config.key_pair.0.is_empty() { - log::info!("Generated new keypair for id: {}", config.id); - let (pk, sk) = sign::gen_keypair(); - let key_pair = (sk.0.to_vec(), pk.0.into()); - config.key_pair = key_pair.clone(); - std::thread::spawn(|| { - let mut config = CONFIG.write().unwrap(); - config.key_pair = key_pair; - config.store(); - }); - } - *lock = Some(config.key_pair.clone()); - config.key_pair - } - - pub fn get_id() -> String { - let mut id = CONFIG.read().unwrap().id.clone(); - if id.is_empty() { - if let Some(tmp) = Config::get_auto_id() { - id = tmp; - Config::set_id(&id); - } - } - id - } - - pub fn get_id_or(b: String) -> String { - let a = CONFIG.read().unwrap().id.clone(); - if a.is_empty() { - b - } else { - a - } - } - - pub fn get_options() -> HashMap { - let mut res = DEFAULT_SETTINGS.read().unwrap().clone(); - res.extend(CONFIG2.read().unwrap().options.clone()); - res.extend(OVERWRITE_SETTINGS.read().unwrap().clone()); - res - } - - #[inline] - fn purify_options(v: &mut HashMap) { - v.retain(|k, v| is_option_can_save(&OVERWRITE_SETTINGS, k, &DEFAULT_SETTINGS, v)); - } - - pub fn set_options(mut v: HashMap) { - Self::purify_options(&mut v); - let mut config = CONFIG2.write().unwrap(); - if config.options == v { - return; - } - config.options = v; - config.store(); - } - - pub fn get_option(k: &str) -> String { - get_or( - &OVERWRITE_SETTINGS, - &CONFIG2.read().unwrap().options, - &DEFAULT_SETTINGS, - k, - ) - .unwrap_or_default() - } - - pub fn get_bool_option(k: &str) -> bool { - option2bool(k, &Self::get_option(k)) - } - - pub fn set_option(k: String, v: String) { - if !is_option_can_save(&OVERWRITE_SETTINGS, &k, &DEFAULT_SETTINGS, &v) { - return; - } - let mut config = CONFIG2.write().unwrap(); - let v2 = if v.is_empty() { None } else { Some(&v) }; - if v2 != config.options.get(&k) { - if v2.is_none() { - config.options.remove(&k); - } else { - config.options.insert(k, v); - } - config.store(); - } - } - - pub fn update_id() { - // to-do: how about if one ip register a lot of ids? - let id = Self::get_id(); - let mut rng = rand::thread_rng(); - let new_id = rng.gen_range(1_000_000_000..2_000_000_000).to_string(); - Config::set_id(&new_id); - log::info!("id updated from {} to {}", id, new_id); - } - - pub fn set_permanent_password(password: &str) { - if HARD_SETTINGS - .read() - .unwrap() - .get("password") - .map_or(false, |v| v == password) - { - return; - } - let mut config = CONFIG.write().unwrap(); - if password == config.password { - return; - } - config.password = password.into(); - config.store(); - Self::clear_trusted_devices(); - } - - pub fn get_permanent_password() -> String { - let mut password = CONFIG.read().unwrap().password.clone(); - if password.is_empty() { - if let Some(v) = HARD_SETTINGS.read().unwrap().get("password") { - password = v.to_owned(); - } - } - password - } - - pub fn set_salt(salt: &str) { - let mut config = CONFIG.write().unwrap(); - if salt == config.salt { - return; - } - config.salt = salt.into(); - config.store(); - } - - pub fn get_salt() -> String { - let mut salt = CONFIG.read().unwrap().salt.clone(); - if salt.is_empty() { - salt = Config::get_auto_password(6); - Config::set_salt(&salt); - } - salt - } - - pub fn set_socks(socks: Option) { - let mut config = CONFIG2.write().unwrap(); - if config.socks == socks { - return; - } - config.socks = socks; - config.store(); - } - - #[inline] - fn get_socks_from_custom_client_advanced_settings( - settings: &HashMap, - ) -> Option { - let url = settings.get(keys::OPTION_PROXY_URL)?; - Some(Socks5Server { - proxy: url.to_owned(), - username: settings - .get(keys::OPTION_PROXY_USERNAME) - .map(|x| x.to_string()) - .unwrap_or_default(), - password: settings - .get(keys::OPTION_PROXY_PASSWORD) - .map(|x| x.to_string()) - .unwrap_or_default(), - }) - } - - pub fn get_socks() -> Option { - Self::get_socks_from_custom_client_advanced_settings(&OVERWRITE_SETTINGS.read().unwrap()) - .or(CONFIG2.read().unwrap().socks.clone()) - .or(Self::get_socks_from_custom_client_advanced_settings( - &DEFAULT_SETTINGS.read().unwrap(), - )) - } - - #[inline] - pub fn is_proxy() -> bool { - Self::get_network_type() != NetworkType::Direct - } - - pub fn get_network_type() -> NetworkType { - if OVERWRITE_SETTINGS - .read() - .unwrap() - .get(keys::OPTION_PROXY_URL) - .is_some() - { - return NetworkType::ProxySocks; - } - if CONFIG2.read().unwrap().socks.is_some() { - return NetworkType::ProxySocks; - } - if DEFAULT_SETTINGS - .read() - .unwrap() - .get(keys::OPTION_PROXY_URL) - .is_some() - { - return NetworkType::ProxySocks; - } - NetworkType::Direct - } - - pub fn get_unlock_pin() -> String { - CONFIG2.read().unwrap().unlock_pin.clone() - } - - pub fn set_unlock_pin(pin: &str) { - let mut config = CONFIG2.write().unwrap(); - if pin == config.unlock_pin { - return; - } - config.unlock_pin = pin.to_string(); - config.store(); - } - - pub fn get_trusted_devices_json() -> String { - serde_json::to_string(&Self::get_trusted_devices()).unwrap_or_default() - } - - pub fn get_trusted_devices() -> Vec { - let (devices, synced) = TRUSTED_DEVICES.read().unwrap().clone(); - if synced { - return devices; - } - let devices = CONFIG2.read().unwrap().trusted_devices.clone(); - let (devices, succ, store) = decrypt_str_or_original(&devices, PASSWORD_ENC_VERSION); - if succ { - let mut devices: Vec = - serde_json::from_str(&devices).unwrap_or_default(); - let len = devices.len(); - devices.retain(|d| !d.outdate()); - if store || devices.len() != len { - Self::set_trusted_devices(devices.clone()); - } - *TRUSTED_DEVICES.write().unwrap() = (devices.clone(), true); - devices - } else { - Default::default() - } - } - - fn set_trusted_devices(mut trusted_devices: Vec) { - trusted_devices.retain(|d| !d.outdate()); - let devices = serde_json::to_string(&trusted_devices).unwrap_or_default(); - let max_len = 1024 * 1024; - if devices.bytes().len() > max_len { - log::error!("Trusted devices too large: {}", devices.bytes().len()); - return; - } - let devices = encrypt_str_or_original(&devices, PASSWORD_ENC_VERSION, max_len); - let mut config = CONFIG2.write().unwrap(); - config.trusted_devices = devices; - config.store(); - *TRUSTED_DEVICES.write().unwrap() = (trusted_devices, true); - } - - pub fn add_trusted_device(device: TrustedDevice) { - let mut devices = Self::get_trusted_devices(); - devices.retain(|d| d.hwid != device.hwid); - devices.push(device); - Self::set_trusted_devices(devices); - } - - pub fn remove_trusted_devices(hwids: &Vec) { - let mut devices = Self::get_trusted_devices(); - devices.retain(|d| !hwids.contains(&d.hwid)); - Self::set_trusted_devices(devices); - } - - pub fn clear_trusted_devices() { - Self::set_trusted_devices(Default::default()); - } - - pub fn get() -> Config { - return CONFIG.read().unwrap().clone(); - } - - pub fn set(cfg: Config) -> bool { - let mut lock = CONFIG.write().unwrap(); - if *lock == cfg { - return false; - } - *lock = cfg; - lock.store(); - true - } - - fn with_extension(path: PathBuf) -> PathBuf { - let ext = path.extension(); - if let Some(ext) = ext { - let ext = format!("{}.toml", ext.to_string_lossy()); - path.with_extension(ext) - } else { - path.with_extension("toml") - } - } -} - -const PEERS: &str = "peers"; - -impl PeerConfig { - pub fn load(id: &str) -> PeerConfig { - let _lock = CONFIG.read().unwrap(); - match confy::load_path(Self::path(id)) { - Ok(config) => { - let mut config: PeerConfig = config; - let mut store = false; - let (password, _, store2) = - decrypt_vec_or_original(&config.password, PASSWORD_ENC_VERSION); - config.password = password; - store = store || store2; - for opt in ["rdp_password", "os-username", "os-password"] { - if let Some(v) = config.options.get_mut(opt) { - let (encrypted, _, store2) = - decrypt_str_or_original(v, PASSWORD_ENC_VERSION); - *v = encrypted; - store = store || store2; - } - } - if store { - config.store(id); - } - config - } - Err(err) => { - if let confy::ConfyError::GeneralLoadError(err) = &err { - if err.kind() == std::io::ErrorKind::NotFound { - return Default::default(); - } - } - log::error!("Failed to load peer config '{}': {}", id, err); - Default::default() - } - } - } - - pub fn store(&self, id: &str) { - let _lock = CONFIG.read().unwrap(); - let mut config = self.clone(); - config.password = - encrypt_vec_or_original(&config.password, PASSWORD_ENC_VERSION, ENCRYPT_MAX_LEN); - for opt in ["rdp_password", "os-username", "os-password"] { - if let Some(v) = config.options.get_mut(opt) { - *v = encrypt_str_or_original(v, PASSWORD_ENC_VERSION, ENCRYPT_MAX_LEN) - } - } - if let Err(err) = store_path(Self::path(id), config) { - log::error!("Failed to store config: {}", err); - } - NEW_STORED_PEER_CONFIG.lock().unwrap().insert(id.to_owned()); - } - - pub fn remove(id: &str) { - fs::remove_file(Self::path(id)).ok(); - } - - fn path(id: &str) -> PathBuf { - //If the id contains invalid chars, encode it - let forbidden_paths = Regex::new(r".*[<>:/\\|\?\*].*"); - let path: PathBuf; - if let Ok(forbidden_paths) = forbidden_paths { - let id_encoded = if forbidden_paths.is_match(id) { - "base64_".to_string() + base64::encode(id, base64::Variant::Original).as_str() - } else { - id.to_string() - }; - path = [PEERS, id_encoded.as_str()].iter().collect(); - } else { - log::warn!("Regex create failed: {:?}", forbidden_paths.err()); - // fallback for failing to create this regex. - path = [PEERS, id.replace(":", "_").as_str()].iter().collect(); - } - Config::with_extension(Config::path(path)) - } - - pub fn peers(id_filters: Option>) -> Vec<(String, SystemTime, PeerConfig)> { - if let Ok(peers) = Config::path(PEERS).read_dir() { - if let Ok(peers) = peers - .map(|res| res.map(|e| e.path())) - .collect::, _>>() - { - let mut peers: Vec<_> = peers - .iter() - .filter(|p| { - p.is_file() - && p.extension().map(|p| p.to_str().unwrap_or("")) == Some("toml") - }) - .map(|p| { - let id = p - .file_stem() - .map(|p| p.to_str().unwrap_or("")) - .unwrap_or("") - .to_owned(); - - let id_decoded_string = if id.starts_with("base64_") && id.len() != 7 { - let id_decoded = base64::decode(&id[7..], base64::Variant::Original) - .unwrap_or_default(); - String::from_utf8_lossy(&id_decoded).as_ref().to_owned() - } else { - id - }; - (id_decoded_string, p) - }) - .filter(|(id, _)| { - let Some(filters) = &id_filters else { - return true; - }; - filters.contains(id) - }) - .map(|(id, p)| { - let t = crate::get_modified_time(p); - let c = PeerConfig::load(&id); - if c.info.platform.is_empty() { - fs::remove_file(p).ok(); - } - (id, t, c) - }) - .filter(|p| !p.2.info.platform.is_empty()) - .collect(); - peers.sort_unstable_by(|a, b| b.1.cmp(&a.1)); - return peers; - } - } - Default::default() - } - - pub fn exists(id: &str) -> bool { - Self::path(id).exists() - } - - serde_field_string!( - default_view_style, - deserialize_view_style, - UserDefaultConfig::read(keys::OPTION_VIEW_STYLE) - ); - serde_field_string!( - default_scroll_style, - deserialize_scroll_style, - UserDefaultConfig::read(keys::OPTION_SCROLL_STYLE) - ); - serde_field_string!( - default_image_quality, - deserialize_image_quality, - UserDefaultConfig::read(keys::OPTION_IMAGE_QUALITY) - ); - serde_field_string!( - default_reverse_mouse_wheel, - deserialize_reverse_mouse_wheel, - UserDefaultConfig::read(keys::OPTION_REVERSE_MOUSE_WHEEL) - ); - serde_field_string!( - default_displays_as_individual_windows, - deserialize_displays_as_individual_windows, - UserDefaultConfig::read(keys::OPTION_DISPLAYS_AS_INDIVIDUAL_WINDOWS) - ); - serde_field_string!( - default_use_all_my_displays_for_the_remote_session, - deserialize_use_all_my_displays_for_the_remote_session, - UserDefaultConfig::read(keys::OPTION_USE_ALL_MY_DISPLAYS_FOR_THE_REMOTE_SESSION) - ); - - fn default_custom_image_quality() -> Vec { - let f: f64 = UserDefaultConfig::read(keys::OPTION_CUSTOM_IMAGE_QUALITY) - .parse() - .unwrap_or(50.0); - vec![f as _] - } - - fn deserialize_custom_image_quality<'de, D>(deserializer: D) -> Result, D::Error> - where - D: de::Deserializer<'de>, - { - let v: Vec = de::Deserialize::deserialize(deserializer)?; - if v.len() == 1 && v[0] >= 10 && v[0] <= 0xFFF { - Ok(v) - } else { - Ok(Self::default_custom_image_quality()) - } - } - - fn default_options() -> HashMap { - let mut mp: HashMap = Default::default(); - [ - keys::OPTION_CODEC_PREFERENCE, - keys::OPTION_CUSTOM_FPS, - keys::OPTION_ZOOM_CURSOR, - keys::OPTION_TOUCH_MODE, - keys::OPTION_I444, - keys::OPTION_SWAP_LEFT_RIGHT_MOUSE, - keys::OPTION_COLLAPSE_TOOLBAR, - ] - .map(|key| { - mp.insert(key.to_owned(), UserDefaultConfig::read(key)); - }); - mp - } -} - -serde_field_bool!( - ShowRemoteCursor, - "show_remote_cursor", - default_show_remote_cursor, - "ShowRemoteCursor::default_show_remote_cursor" -); -serde_field_bool!( - FollowRemoteCursor, - "follow_remote_cursor", - default_follow_remote_cursor, - "FollowRemoteCursor::default_follow_remote_cursor" -); - -serde_field_bool!( - FollowRemoteWindow, - "follow_remote_window", - default_follow_remote_window, - "FollowRemoteWindow::default_follow_remote_window" -); -serde_field_bool!( - ShowQualityMonitor, - "show_quality_monitor", - default_show_quality_monitor, - "ShowQualityMonitor::default_show_quality_monitor" -); -serde_field_bool!( - DisableAudio, - "disable_audio", - default_disable_audio, - "DisableAudio::default_disable_audio" -); -serde_field_bool!( - EnableFileCopyPaste, - "enable-file-copy-paste", - default_enable_file_copy_paste, - "EnableFileCopyPaste::default_enable_file_copy_paste" -); -serde_field_bool!( - DisableClipboard, - "disable_clipboard", - default_disable_clipboard, - "DisableClipboard::default_disable_clipboard" -); -serde_field_bool!( - LockAfterSessionEnd, - "lock_after_session_end", - default_lock_after_session_end, - "LockAfterSessionEnd::default_lock_after_session_end" -); -serde_field_bool!( - PrivacyMode, - "privacy_mode", - default_privacy_mode, - "PrivacyMode::default_privacy_mode" -); - -serde_field_bool!( - AllowSwapKey, - "allow_swap_key", - default_allow_swap_key, - "AllowSwapKey::default_allow_swap_key" -); - -serde_field_bool!( - ViewOnly, - "view_only", - default_view_only, - "ViewOnly::default_view_only" -); - -serde_field_bool!( - SyncInitClipboard, - "sync-init-clipboard", - default_sync_init_clipboard, - "SyncInitClipboard::default_sync_init_clipboard" -); - -#[derive(Debug, Default, Serialize, Deserialize, Clone)] -pub struct LocalConfig { - #[serde(default, deserialize_with = "deserialize_string")] - remote_id: String, // latest used one - #[serde(default, deserialize_with = "deserialize_string")] - kb_layout_type: String, - #[serde(default, deserialize_with = "deserialize_size")] - size: Size, - #[serde(default, deserialize_with = "deserialize_vec_string")] - pub fav: Vec, - #[serde(default, deserialize_with = "deserialize_hashmap_string_string")] - options: HashMap, - // Various data for flutter ui - #[serde(default, deserialize_with = "deserialize_hashmap_string_string")] - ui_flutter: HashMap, -} - -impl LocalConfig { - fn load() -> LocalConfig { - Config::load_::("_local") - } - - fn store(&self) { - Config::store_(self, "_local"); - } - - pub fn get_kb_layout_type() -> String { - LOCAL_CONFIG.read().unwrap().kb_layout_type.clone() - } - - pub fn set_kb_layout_type(kb_layout_type: String) { - let mut config = LOCAL_CONFIG.write().unwrap(); - config.kb_layout_type = kb_layout_type; - config.store(); - } - - pub fn get_size() -> Size { - LOCAL_CONFIG.read().unwrap().size - } - - pub fn set_size(x: i32, y: i32, w: i32, h: i32) { - let mut config = LOCAL_CONFIG.write().unwrap(); - let size = (x, y, w, h); - if size == config.size || size.2 < 300 || size.3 < 300 { - return; - } - config.size = size; - config.store(); - } - - pub fn set_remote_id(remote_id: &str) { - let mut config = LOCAL_CONFIG.write().unwrap(); - if remote_id == config.remote_id { - return; - } - config.remote_id = remote_id.into(); - config.store(); - } - - pub fn get_remote_id() -> String { - LOCAL_CONFIG.read().unwrap().remote_id.clone() - } - - pub fn set_fav(fav: Vec) { - let mut lock = LOCAL_CONFIG.write().unwrap(); - if lock.fav == fav { - return; - } - lock.fav = fav; - lock.store(); - } - - pub fn get_fav() -> Vec { - LOCAL_CONFIG.read().unwrap().fav.clone() - } - - pub fn get_option(k: &str) -> String { - get_or( - &OVERWRITE_LOCAL_SETTINGS, - &LOCAL_CONFIG.read().unwrap().options, - &DEFAULT_LOCAL_SETTINGS, - k, - ) - .unwrap_or_default() - } - - // Usually get_option should be used. - pub fn get_option_from_file(k: &str) -> String { - get_or( - &OVERWRITE_LOCAL_SETTINGS, - &Self::load().options, - &DEFAULT_LOCAL_SETTINGS, - k, - ) - .unwrap_or_default() - } - - pub fn get_bool_option(k: &str) -> bool { - option2bool(k, &Self::get_option(k)) - } - - pub fn set_option(k: String, v: String) { - if !is_option_can_save(&OVERWRITE_LOCAL_SETTINGS, &k, &DEFAULT_LOCAL_SETTINGS, &v) { - return; - } - let mut config = LOCAL_CONFIG.write().unwrap(); - // The custom client will explictly set "default" as the default language. - let is_custom_client_default_lang = k == keys::OPTION_LANGUAGE && v == "default"; - if is_custom_client_default_lang { - config.options.insert(k, "".to_owned()); - config.store(); - return; - } - let v2 = if v.is_empty() { None } else { Some(&v) }; - if v2 != config.options.get(&k) { - if v2.is_none() { - config.options.remove(&k); - } else { - config.options.insert(k, v); - } - config.store(); - } - } - - pub fn get_flutter_option(k: &str) -> String { - get_or( - &OVERWRITE_LOCAL_SETTINGS, - &LOCAL_CONFIG.read().unwrap().ui_flutter, - &DEFAULT_LOCAL_SETTINGS, - k, - ) - .unwrap_or_default() - } - - pub fn set_flutter_option(k: String, v: String) { - let mut config = LOCAL_CONFIG.write().unwrap(); - let v2 = if v.is_empty() { None } else { Some(&v) }; - if v2 != config.ui_flutter.get(&k) { - if v2.is_none() { - config.ui_flutter.remove(&k); - } else { - config.ui_flutter.insert(k, v); - } - config.store(); - } - } -} - -#[derive(Debug, Default, Serialize, Deserialize, Clone)] -pub struct DiscoveryPeer { - #[serde(default, deserialize_with = "deserialize_string")] - pub id: String, - #[serde(default, deserialize_with = "deserialize_string")] - pub username: String, - #[serde(default, deserialize_with = "deserialize_string")] - pub hostname: String, - #[serde(default, deserialize_with = "deserialize_string")] - pub platform: String, - #[serde(default, deserialize_with = "deserialize_bool")] - pub online: bool, - #[serde(default, deserialize_with = "deserialize_hashmap_string_string")] - pub ip_mac: HashMap, -} - -impl DiscoveryPeer { - pub fn is_same_peer(&self, other: &DiscoveryPeer) -> bool { - self.id == other.id && self.username == other.username - } -} - -#[derive(Debug, Default, Serialize, Deserialize, Clone)] -pub struct LanPeers { - #[serde(default, deserialize_with = "deserialize_vec_discoverypeer")] - pub peers: Vec, -} - -impl LanPeers { - pub fn load() -> LanPeers { - let _lock = CONFIG.read().unwrap(); - match confy::load_path(Config::file_("_lan_peers")) { - Ok(peers) => peers, - Err(err) => { - log::error!("Failed to load lan peers: {}", err); - Default::default() - } - } - } - - pub fn store(peers: &[DiscoveryPeer]) { - let f = LanPeers { - peers: peers.to_owned(), - }; - if let Err(err) = store_path(Config::file_("_lan_peers"), f) { - log::error!("Failed to store lan peers: {}", err); - } - } - - pub fn modify_time() -> crate::ResultType { - let p = Config::file_("_lan_peers"); - Ok(fs::metadata(p)? - .modified()? - .duration_since(SystemTime::UNIX_EPOCH)? - .as_millis() as _) - } -} - -#[derive(Debug, Default, Serialize, Deserialize, Clone)] -pub struct UserDefaultConfig { - #[serde(default, deserialize_with = "deserialize_hashmap_string_string")] - options: HashMap, -} - -impl UserDefaultConfig { - fn read(key: &str) -> String { - let mut cfg = USER_DEFAULT_CONFIG.write().unwrap(); - // we do so, because default config may changed in another process, but we don't sync it - // but no need to read every time, give a small interval to avoid too many redundant read waste - if cfg.1.elapsed() > Duration::from_secs(1) { - *cfg = (Self::load(), Instant::now()); - } - cfg.0.get(key) - } - - pub fn load() -> UserDefaultConfig { - Config::load_::("_default") - } - - #[inline] - fn store(&self) { - Config::store_(self, "_default"); - } - - pub fn get(&self, key: &str) -> String { - match key { - keys::OPTION_VIEW_STYLE => self.get_string(key, "original", vec!["adaptive"]), - keys::OPTION_SCROLL_STYLE => self.get_string(key, "scrollauto", vec!["scrollbar"]), - keys::OPTION_IMAGE_QUALITY => { - self.get_string(key, "balanced", vec!["best", "low", "custom"]) - } - keys::OPTION_CODEC_PREFERENCE => { - self.get_string(key, "auto", vec!["vp8", "vp9", "av1", "h264", "h265"]) - } - keys::OPTION_CUSTOM_IMAGE_QUALITY => { - self.get_double_string(key, 50.0, 10.0, 0xFFF as f64) - } - keys::OPTION_CUSTOM_FPS => self.get_double_string(key, 30.0, 5.0, 120.0), - keys::OPTION_ENABLE_FILE_COPY_PASTE => self.get_string(key, "Y", vec!["", "N"]), - _ => self - .get_after(key) - .map(|v| v.to_string()) - .unwrap_or_default(), - } - } - - pub fn set(&mut self, key: String, value: String) { - if !is_option_can_save( - &OVERWRITE_DISPLAY_SETTINGS, - &key, - &DEFAULT_DISPLAY_SETTINGS, - &value, - ) { - return; - } - if value.is_empty() { - self.options.remove(&key); - } else { - self.options.insert(key, value); - } - self.store(); - } - - #[inline] - fn get_string(&self, key: &str, default: &str, others: Vec<&str>) -> String { - match self.get_after(key) { - Some(option) => { - if others.contains(&option.as_str()) { - option.to_owned() - } else { - default.to_owned() - } - } - None => default.to_owned(), - } - } - - #[inline] - fn get_double_string(&self, key: &str, default: f64, min: f64, max: f64) -> String { - match self.get_after(key) { - Some(option) => { - let v: f64 = option.parse().unwrap_or(default); - if v >= min && v <= max { - v.to_string() - } else { - default.to_string() - } - } - None => default.to_string(), - } - } - - fn get_after(&self, k: &str) -> Option { - get_or( - &OVERWRITE_DISPLAY_SETTINGS, - &self.options, - &DEFAULT_DISPLAY_SETTINGS, - k, - ) - } -} - -#[derive(Debug, Default, Serialize, Deserialize, Clone)] -pub struct AbPeer { - #[serde( - default, - deserialize_with = "deserialize_string", - skip_serializing_if = "String::is_empty" - )] - pub id: String, - #[serde( - default, - deserialize_with = "deserialize_string", - skip_serializing_if = "String::is_empty" - )] - pub hash: String, - #[serde( - default, - deserialize_with = "deserialize_string", - skip_serializing_if = "String::is_empty" - )] - pub username: String, - #[serde( - default, - deserialize_with = "deserialize_string", - skip_serializing_if = "String::is_empty" - )] - pub hostname: String, - #[serde( - default, - deserialize_with = "deserialize_string", - skip_serializing_if = "String::is_empty" - )] - pub platform: String, - #[serde( - default, - deserialize_with = "deserialize_string", - skip_serializing_if = "String::is_empty" - )] - pub alias: String, - #[serde(default, deserialize_with = "deserialize_vec_string")] - pub tags: Vec, -} - -#[derive(Debug, Default, Serialize, Deserialize, Clone)] -pub struct AbEntry { - #[serde( - default, - deserialize_with = "deserialize_string", - skip_serializing_if = "String::is_empty" - )] - pub guid: String, - #[serde( - default, - deserialize_with = "deserialize_string", - skip_serializing_if = "String::is_empty" - )] - pub name: String, - #[serde(default, deserialize_with = "deserialize_vec_abpeer")] - pub peers: Vec, - #[serde(default, deserialize_with = "deserialize_vec_string")] - pub tags: Vec, - #[serde( - default, - deserialize_with = "deserialize_string", - skip_serializing_if = "String::is_empty" - )] - pub tag_colors: String, -} - -impl AbEntry { - pub fn personal(&self) -> bool { - self.name == "My address book" || self.name == "Legacy address book" - } -} - -#[derive(Debug, Default, Serialize, Deserialize, Clone)] -pub struct Ab { - #[serde( - default, - deserialize_with = "deserialize_string", - skip_serializing_if = "String::is_empty" - )] - pub access_token: String, - #[serde(default, deserialize_with = "deserialize_vec_abentry")] - pub ab_entries: Vec, -} - -impl Ab { - fn path() -> PathBuf { - let filename = format!("{}_ab", APP_NAME.read().unwrap().clone()); - Config::path(filename) - } - - pub fn store(json: String) { - if let Ok(mut file) = std::fs::File::create(Self::path()) { - let data = compress(json.as_bytes()); - let max_len = 64 * 1024 * 1024; - if data.len() > max_len { - // maxlen of function decompress - log::error!("ab data too large, {} > {}", data.len(), max_len); - return; - } - if let Ok(data) = symmetric_crypt(&data, true) { - file.write_all(&data).ok(); - } - }; - } - - pub fn load() -> Ab { - if let Ok(mut file) = std::fs::File::open(Self::path()) { - let mut data = vec![]; - if file.read_to_end(&mut data).is_ok() { - if let Ok(data) = symmetric_crypt(&data, false) { - let data = decompress(&data); - if let Ok(ab) = serde_json::from_str::(&String::from_utf8_lossy(&data)) { - return ab; - } - } - } - }; - Self::remove(); - Ab::default() - } - - pub fn remove() { - std::fs::remove_file(Self::path()).ok(); - } -} - -// use default value when field type is wrong -macro_rules! deserialize_default { - ($func_name:ident, $return_type:ty) => { - fn $func_name<'de, D>(deserializer: D) -> Result<$return_type, D::Error> - where - D: de::Deserializer<'de>, - { - Ok(de::Deserialize::deserialize(deserializer).unwrap_or_default()) - } - }; -} - -#[derive(Debug, Default, Serialize, Deserialize, Clone)] -pub struct GroupPeer { - #[serde( - default, - deserialize_with = "deserialize_string", - skip_serializing_if = "String::is_empty" - )] - pub id: String, - #[serde( - default, - deserialize_with = "deserialize_string", - skip_serializing_if = "String::is_empty" - )] - pub username: String, - #[serde( - default, - deserialize_with = "deserialize_string", - skip_serializing_if = "String::is_empty" - )] - pub hostname: String, - #[serde( - default, - deserialize_with = "deserialize_string", - skip_serializing_if = "String::is_empty" - )] - pub platform: String, - #[serde( - default, - deserialize_with = "deserialize_string", - skip_serializing_if = "String::is_empty" - )] - pub login_name: String, -} - -#[derive(Debug, Default, Serialize, Deserialize, Clone)] -pub struct GroupUser { - #[serde( - default, - deserialize_with = "deserialize_string", - skip_serializing_if = "String::is_empty" - )] - pub name: String, -} - -#[derive(Debug, Default, Serialize, Deserialize, Clone)] -pub struct Group { - #[serde( - default, - deserialize_with = "deserialize_string", - skip_serializing_if = "String::is_empty" - )] - pub access_token: String, - #[serde(default, deserialize_with = "deserialize_vec_groupuser")] - pub users: Vec, - #[serde(default, deserialize_with = "deserialize_vec_grouppeer")] - pub peers: Vec, -} - -impl Group { - fn path() -> PathBuf { - let filename = format!("{}_group", APP_NAME.read().unwrap().clone()); - Config::path(filename) - } - - pub fn store(json: String) { - if let Ok(mut file) = std::fs::File::create(Self::path()) { - let data = compress(json.as_bytes()); - let max_len = 64 * 1024 * 1024; - if data.len() > max_len { - // maxlen of function decompress - return; - } - if let Ok(data) = symmetric_crypt(&data, true) { - file.write_all(&data).ok(); - } - }; - } - - pub fn load() -> Self { - if let Ok(mut file) = std::fs::File::open(Self::path()) { - let mut data = vec![]; - if file.read_to_end(&mut data).is_ok() { - if let Ok(data) = symmetric_crypt(&data, false) { - let data = decompress(&data); - if let Ok(group) = serde_json::from_str::(&String::from_utf8_lossy(&data)) - { - return group; - } - } - } - }; - Self::remove(); - Self::default() - } - - pub fn remove() { - std::fs::remove_file(Self::path()).ok(); - } -} - -#[derive(Debug, Default, Serialize, Deserialize, Clone)] -pub struct TrustedDevice { - pub hwid: Bytes, - pub time: i64, - pub id: String, - pub name: String, - pub platform: String, -} - -impl TrustedDevice { - pub fn outdate(&self) -> bool { - const DAYS_90: i64 = 90 * 24 * 60 * 60 * 1000; - self.time + DAYS_90 < crate::get_time() - } -} - -deserialize_default!(deserialize_string, String); -deserialize_default!(deserialize_bool, bool); -deserialize_default!(deserialize_i32, i32); -deserialize_default!(deserialize_vec_u8, Vec); -deserialize_default!(deserialize_vec_string, Vec); -deserialize_default!(deserialize_vec_i32_string_i32, Vec<(i32, String, i32)>); -deserialize_default!(deserialize_vec_discoverypeer, Vec); -deserialize_default!(deserialize_vec_abpeer, Vec); -deserialize_default!(deserialize_vec_abentry, Vec); -deserialize_default!(deserialize_vec_groupuser, Vec); -deserialize_default!(deserialize_vec_grouppeer, Vec); -deserialize_default!(deserialize_keypair, KeyPair); -deserialize_default!(deserialize_size, Size); -deserialize_default!(deserialize_hashmap_string_string, HashMap); -deserialize_default!(deserialize_hashmap_string_bool, HashMap); -deserialize_default!(deserialize_hashmap_resolutions, HashMap); - -#[inline] -fn get_or( - a: &RwLock>, - b: &HashMap, - c: &RwLock>, - k: &str, -) -> Option { - a.read() - .unwrap() - .get(k) - .or(b.get(k)) - .or(c.read().unwrap().get(k)) - .cloned() -} - -#[inline] -fn is_option_can_save( - overwrite: &RwLock>, - k: &str, - defaults: &RwLock>, - v: &str, -) -> bool { - if overwrite.read().unwrap().contains_key(k) - || defaults.read().unwrap().get(k).map_or(false, |x| x == v) - { - return false; - } - true -} - -#[inline] -pub fn is_incoming_only() -> bool { - HARD_SETTINGS - .read() - .unwrap() - .get("conn-type") - .map_or(false, |x| x == ("incoming")) -} - -#[inline] -pub fn is_outgoing_only() -> bool { - HARD_SETTINGS - .read() - .unwrap() - .get("conn-type") - .map_or(false, |x| x == ("outgoing")) -} - -#[inline] -fn is_some_hard_opton(name: &str) -> bool { - HARD_SETTINGS - .read() - .unwrap() - .get(name) - .map_or(false, |x| x == ("Y")) -} - -#[inline] -pub fn is_disable_tcp_listen() -> bool { - is_some_hard_opton("disable-tcp-listen") -} - -#[inline] -pub fn is_disable_settings() -> bool { - is_some_hard_opton("disable-settings") -} - -#[inline] -pub fn is_disable_ab() -> bool { - is_some_hard_opton("disable-ab") -} - -#[inline] -pub fn is_disable_account() -> bool { - is_some_hard_opton("disable-account") -} - -#[inline] -pub fn is_disable_installation() -> bool { - is_some_hard_opton("disable-installation") -} - -// This function must be kept the same as the one in flutter and sciter code. -// flutter: flutter/lib/common.dart -> option2bool() -// sciter: Does not have the function, but it should be kept the same. -pub fn option2bool(option: &str, value: &str) -> bool { - if option.starts_with("enable-") { - value != "N" - } else if option.starts_with("allow-") - || option == "stop-service" - || option == keys::OPTION_DIRECT_SERVER - || option == "force-always-relay" - { - value == "Y" - } else { - value != "N" - } -} - -pub mod keys { - pub const OPTION_VIEW_ONLY: &str = "view_only"; - pub const OPTION_SHOW_MONITORS_TOOLBAR: &str = "show_monitors_toolbar"; - pub const OPTION_COLLAPSE_TOOLBAR: &str = "collapse_toolbar"; - pub const OPTION_SHOW_REMOTE_CURSOR: &str = "show_remote_cursor"; - pub const OPTION_FOLLOW_REMOTE_CURSOR: &str = "follow_remote_cursor"; - pub const OPTION_FOLLOW_REMOTE_WINDOW: &str = "follow_remote_window"; - pub const OPTION_ZOOM_CURSOR: &str = "zoom-cursor"; - pub const OPTION_SHOW_QUALITY_MONITOR: &str = "show_quality_monitor"; - pub const OPTION_DISABLE_AUDIO: &str = "disable_audio"; - pub const OPTION_ENABLE_FILE_COPY_PASTE: &str = "enable-file-copy-paste"; - pub const OPTION_DISABLE_CLIPBOARD: &str = "disable_clipboard"; - pub const OPTION_LOCK_AFTER_SESSION_END: &str = "lock_after_session_end"; - pub const OPTION_PRIVACY_MODE: &str = "privacy_mode"; - pub const OPTION_TOUCH_MODE: &str = "touch-mode"; - pub const OPTION_I444: &str = "i444"; - pub const OPTION_REVERSE_MOUSE_WHEEL: &str = "reverse_mouse_wheel"; - pub const OPTION_SWAP_LEFT_RIGHT_MOUSE: &str = "swap-left-right-mouse"; - pub const OPTION_DISPLAYS_AS_INDIVIDUAL_WINDOWS: &str = "displays_as_individual_windows"; - pub const OPTION_USE_ALL_MY_DISPLAYS_FOR_THE_REMOTE_SESSION: &str = - "use_all_my_displays_for_the_remote_session"; - pub const OPTION_VIEW_STYLE: &str = "view_style"; - pub const OPTION_SCROLL_STYLE: &str = "scroll_style"; - pub const OPTION_IMAGE_QUALITY: &str = "image_quality"; - pub const OPTION_CUSTOM_IMAGE_QUALITY: &str = "custom_image_quality"; - pub const OPTION_CUSTOM_FPS: &str = "custom-fps"; - pub const OPTION_CODEC_PREFERENCE: &str = "codec-preference"; - pub const OPTION_SYNC_INIT_CLIPBOARD: &str = "sync-init-clipboard"; - pub const OPTION_THEME: &str = "theme"; - pub const OPTION_LANGUAGE: &str = "lang"; - pub const OPTION_REMOTE_MENUBAR_DRAG_LEFT: &str = "remote-menubar-drag-left"; - pub const OPTION_REMOTE_MENUBAR_DRAG_RIGHT: &str = "remote-menubar-drag-right"; - pub const OPTION_HIDE_AB_TAGS_PANEL: &str = "hideAbTagsPanel"; - pub const OPTION_ENABLE_CONFIRM_CLOSING_TABS: &str = "enable-confirm-closing-tabs"; - pub const OPTION_ENABLE_OPEN_NEW_CONNECTIONS_IN_TABS: &str = - "enable-open-new-connections-in-tabs"; - pub const OPTION_TEXTURE_RENDER: &str = "use-texture-render"; - pub const OPTION_ENABLE_CHECK_UPDATE: &str = "enable-check-update"; - pub const OPTION_SYNC_AB_WITH_RECENT_SESSIONS: &str = "sync-ab-with-recent-sessions"; - pub const OPTION_SYNC_AB_TAGS: &str = "sync-ab-tags"; - pub const OPTION_FILTER_AB_BY_INTERSECTION: &str = "filter-ab-by-intersection"; - pub const OPTION_ACCESS_MODE: &str = "access-mode"; - pub const OPTION_ENABLE_KEYBOARD: &str = "enable-keyboard"; - pub const OPTION_ENABLE_CLIPBOARD: &str = "enable-clipboard"; - pub const OPTION_ENABLE_FILE_TRANSFER: &str = "enable-file-transfer"; - pub const OPTION_ENABLE_AUDIO: &str = "enable-audio"; - pub const OPTION_ENABLE_TUNNEL: &str = "enable-tunnel"; - pub const OPTION_ENABLE_REMOTE_RESTART: &str = "enable-remote-restart"; - pub const OPTION_ENABLE_RECORD_SESSION: &str = "enable-record-session"; - pub const OPTION_ENABLE_BLOCK_INPUT: &str = "enable-block-input"; - pub const OPTION_ALLOW_REMOTE_CONFIG_MODIFICATION: &str = "allow-remote-config-modification"; - pub const OPTION_ENABLE_LAN_DISCOVERY: &str = "enable-lan-discovery"; - pub const OPTION_DIRECT_SERVER: &str = "direct-server"; - pub const OPTION_DIRECT_ACCESS_PORT: &str = "direct-access-port"; - pub const OPTION_WHITELIST: &str = "whitelist"; - pub const OPTION_ALLOW_AUTO_DISCONNECT: &str = "allow-auto-disconnect"; - pub const OPTION_AUTO_DISCONNECT_TIMEOUT: &str = "auto-disconnect-timeout"; - pub const OPTION_ALLOW_ONLY_CONN_WINDOW_OPEN: &str = "allow-only-conn-window-open"; - pub const OPTION_ALLOW_AUTO_RECORD_INCOMING: &str = "allow-auto-record-incoming"; - pub const OPTION_ALLOW_AUTO_RECORD_OUTGOING: &str = "allow-auto-record-outgoing"; - pub const OPTION_VIDEO_SAVE_DIRECTORY: &str = "video-save-directory"; - pub const OPTION_ENABLE_ABR: &str = "enable-abr"; - pub const OPTION_ALLOW_REMOVE_WALLPAPER: &str = "allow-remove-wallpaper"; - pub const OPTION_ALLOW_ALWAYS_SOFTWARE_RENDER: &str = "allow-always-software-render"; - pub const OPTION_ALLOW_LINUX_HEADLESS: &str = "allow-linux-headless"; - pub const OPTION_ENABLE_HWCODEC: &str = "enable-hwcodec"; - pub const OPTION_APPROVE_MODE: &str = "approve-mode"; - pub const OPTION_CUSTOM_RENDEZVOUS_SERVER: &str = "custom-rendezvous-server"; - pub const OPTION_API_SERVER: &str = "api-server"; - pub const OPTION_KEY: &str = "key"; - pub const OPTION_PRESET_ADDRESS_BOOK_NAME: &str = "preset-address-book-name"; - pub const OPTION_PRESET_ADDRESS_BOOK_TAG: &str = "preset-address-book-tag"; - pub const OPTION_ENABLE_DIRECTX_CAPTURE: &str = "enable-directx-capture"; - pub const OPTION_ENABLE_ANDROID_SOFTWARE_ENCODING_HALF_SCALE: &str = - "enable-android-software-encoding-half-scale"; - pub const OPTION_ENABLE_TRUSTED_DEVICES: &str = "enable-trusted-devices"; - - // buildin options - pub const OPTION_DISPLAY_NAME: &str = "display-name"; - pub const OPTION_DISABLE_UDP: &str = "disable-udp"; - pub const OPTION_PRESET_USERNAME: &str = "preset-user-name"; - pub const OPTION_PRESET_STRATEGY_NAME: &str = "preset-strategy-name"; - pub const OPTION_REMOVE_PRESET_PASSWORD_WARNING: &str = "remove-preset-password-warning"; - pub const OPTION_HIDE_SECURITY_SETTINGS: &str = "hide-security-settings"; - pub const OPTION_HIDE_NETWORK_SETTINGS: &str = "hide-network-settings"; - pub const OPTION_HIDE_SERVER_SETTINGS: &str = "hide-server-settings"; - pub const OPTION_HIDE_PROXY_SETTINGS: &str = "hide-proxy-settings"; - pub const OPTION_HIDE_USERNAME_ON_CARD: &str = "hide-username-on-card"; - pub const OPTION_HIDE_HELP_CARDS: &str = "hide-help-cards"; - pub const OPTION_DEFAULT_CONNECT_PASSWORD: &str = "default-connect-password"; - pub const OPTION_HIDE_TRAY: &str = "hide-tray"; - pub const OPTION_ONE_WAY_CLIPBOARD_REDIRECTION: &str = "one-way-clipboard-redirection"; - pub const OPTION_ALLOW_LOGON_SCREEN_PASSWORD: &str = "allow-logon-screen-password"; - pub const OPTION_ONE_WAY_FILE_TRANSFER: &str = "one-way-file-transfer"; - - // flutter local options - pub const OPTION_FLUTTER_REMOTE_MENUBAR_STATE: &str = "remoteMenubarState"; - pub const OPTION_FLUTTER_PEER_SORTING: &str = "peer-sorting"; - pub const OPTION_FLUTTER_PEER_TAB_INDEX: &str = "peer-tab-index"; - pub const OPTION_FLUTTER_PEER_TAB_ORDER: &str = "peer-tab-order"; - pub const OPTION_FLUTTER_PEER_TAB_VISIBLE: &str = "peer-tab-visible"; - pub const OPTION_FLUTTER_PEER_CARD_UI_TYLE: &str = "peer-card-ui-type"; - pub const OPTION_FLUTTER_CURRENT_AB_NAME: &str = "current-ab-name"; - pub const OPTION_ALLOW_REMOTE_CM_MODIFICATION: &str = "allow-remote-cm-modification"; - - // android floating window options - pub const OPTION_DISABLE_FLOATING_WINDOW: &str = "disable-floating-window"; - pub const OPTION_FLOATING_WINDOW_SIZE: &str = "floating-window-size"; - pub const OPTION_FLOATING_WINDOW_UNTOUCHABLE: &str = "floating-window-untouchable"; - pub const OPTION_FLOATING_WINDOW_TRANSPARENCY: &str = "floating-window-transparency"; - pub const OPTION_FLOATING_WINDOW_SVG: &str = "floating-window-svg"; - - // android keep screen on - pub const OPTION_KEEP_SCREEN_ON: &str = "keep-screen-on"; - - pub const OPTION_DISABLE_GROUP_PANEL: &str = "disable-group-panel"; - pub const OPTION_PRE_ELEVATE_SERVICE: &str = "pre-elevate-service"; - - // proxy settings - // The following options are not real keys, they are just used for custom client advanced settings. - // The real keys are in Config2::socks. - pub const OPTION_PROXY_URL: &str = "proxy-url"; - pub const OPTION_PROXY_USERNAME: &str = "proxy-username"; - pub const OPTION_PROXY_PASSWORD: &str = "proxy-password"; - - // DEFAULT_DISPLAY_SETTINGS, OVERWRITE_DISPLAY_SETTINGS - pub const KEYS_DISPLAY_SETTINGS: &[&str] = &[ - OPTION_VIEW_ONLY, - OPTION_SHOW_MONITORS_TOOLBAR, - OPTION_COLLAPSE_TOOLBAR, - OPTION_SHOW_REMOTE_CURSOR, - OPTION_FOLLOW_REMOTE_CURSOR, - OPTION_FOLLOW_REMOTE_WINDOW, - OPTION_ZOOM_CURSOR, - OPTION_SHOW_QUALITY_MONITOR, - OPTION_DISABLE_AUDIO, - OPTION_ENABLE_FILE_COPY_PASTE, - OPTION_DISABLE_CLIPBOARD, - OPTION_LOCK_AFTER_SESSION_END, - OPTION_PRIVACY_MODE, - OPTION_TOUCH_MODE, - OPTION_I444, - OPTION_REVERSE_MOUSE_WHEEL, - OPTION_SWAP_LEFT_RIGHT_MOUSE, - OPTION_DISPLAYS_AS_INDIVIDUAL_WINDOWS, - OPTION_USE_ALL_MY_DISPLAYS_FOR_THE_REMOTE_SESSION, - OPTION_VIEW_STYLE, - OPTION_SCROLL_STYLE, - OPTION_IMAGE_QUALITY, - OPTION_CUSTOM_IMAGE_QUALITY, - OPTION_CUSTOM_FPS, - OPTION_CODEC_PREFERENCE, - OPTION_SYNC_INIT_CLIPBOARD, - ]; - // DEFAULT_LOCAL_SETTINGS, OVERWRITE_LOCAL_SETTINGS - pub const KEYS_LOCAL_SETTINGS: &[&str] = &[ - OPTION_THEME, - OPTION_LANGUAGE, - OPTION_ENABLE_CONFIRM_CLOSING_TABS, - OPTION_ENABLE_OPEN_NEW_CONNECTIONS_IN_TABS, - OPTION_TEXTURE_RENDER, - OPTION_SYNC_AB_WITH_RECENT_SESSIONS, - OPTION_SYNC_AB_TAGS, - OPTION_FILTER_AB_BY_INTERSECTION, - OPTION_REMOTE_MENUBAR_DRAG_LEFT, - OPTION_REMOTE_MENUBAR_DRAG_RIGHT, - OPTION_HIDE_AB_TAGS_PANEL, - OPTION_FLUTTER_REMOTE_MENUBAR_STATE, - OPTION_FLUTTER_PEER_SORTING, - OPTION_FLUTTER_PEER_TAB_INDEX, - OPTION_FLUTTER_PEER_TAB_ORDER, - OPTION_FLUTTER_PEER_TAB_VISIBLE, - OPTION_FLUTTER_PEER_CARD_UI_TYLE, - OPTION_FLUTTER_CURRENT_AB_NAME, - OPTION_DISABLE_FLOATING_WINDOW, - OPTION_FLOATING_WINDOW_SIZE, - OPTION_FLOATING_WINDOW_UNTOUCHABLE, - OPTION_FLOATING_WINDOW_TRANSPARENCY, - OPTION_FLOATING_WINDOW_SVG, - OPTION_KEEP_SCREEN_ON, - OPTION_DISABLE_GROUP_PANEL, - OPTION_PRE_ELEVATE_SERVICE, - OPTION_ALLOW_REMOTE_CM_MODIFICATION, - OPTION_ALLOW_AUTO_RECORD_OUTGOING, - OPTION_VIDEO_SAVE_DIRECTORY, - ]; - // DEFAULT_SETTINGS, OVERWRITE_SETTINGS - pub const KEYS_SETTINGS: &[&str] = &[ - OPTION_ACCESS_MODE, - OPTION_ENABLE_KEYBOARD, - OPTION_ENABLE_CLIPBOARD, - OPTION_ENABLE_FILE_TRANSFER, - OPTION_ENABLE_AUDIO, - OPTION_ENABLE_TUNNEL, - OPTION_ENABLE_REMOTE_RESTART, - OPTION_ENABLE_RECORD_SESSION, - OPTION_ENABLE_BLOCK_INPUT, - OPTION_ALLOW_REMOTE_CONFIG_MODIFICATION, - OPTION_ENABLE_LAN_DISCOVERY, - OPTION_DIRECT_SERVER, - OPTION_DIRECT_ACCESS_PORT, - OPTION_WHITELIST, - OPTION_ALLOW_AUTO_DISCONNECT, - OPTION_AUTO_DISCONNECT_TIMEOUT, - OPTION_ALLOW_ONLY_CONN_WINDOW_OPEN, - OPTION_ALLOW_AUTO_RECORD_INCOMING, - OPTION_ENABLE_ABR, - OPTION_ALLOW_REMOVE_WALLPAPER, - OPTION_ALLOW_ALWAYS_SOFTWARE_RENDER, - OPTION_ALLOW_LINUX_HEADLESS, - OPTION_ENABLE_HWCODEC, - OPTION_APPROVE_MODE, - OPTION_PROXY_URL, - OPTION_PROXY_USERNAME, - OPTION_PROXY_PASSWORD, - OPTION_CUSTOM_RENDEZVOUS_SERVER, - OPTION_API_SERVER, - OPTION_KEY, - OPTION_PRESET_ADDRESS_BOOK_NAME, - OPTION_PRESET_ADDRESS_BOOK_TAG, - OPTION_ENABLE_DIRECTX_CAPTURE, - OPTION_ENABLE_ANDROID_SOFTWARE_ENCODING_HALF_SCALE, - OPTION_ENABLE_TRUSTED_DEVICES, - ]; - - // BUILDIN_SETTINGS - pub const KEYS_BUILDIN_SETTINGS: &[&str] = &[ - OPTION_DISPLAY_NAME, - OPTION_DISABLE_UDP, - OPTION_PRESET_USERNAME, - OPTION_PRESET_STRATEGY_NAME, - OPTION_REMOVE_PRESET_PASSWORD_WARNING, - OPTION_HIDE_SECURITY_SETTINGS, - OPTION_HIDE_NETWORK_SETTINGS, - OPTION_HIDE_SERVER_SETTINGS, - OPTION_HIDE_PROXY_SETTINGS, - OPTION_HIDE_USERNAME_ON_CARD, - OPTION_HIDE_HELP_CARDS, - OPTION_DEFAULT_CONNECT_PASSWORD, - OPTION_HIDE_TRAY, - OPTION_ONE_WAY_CLIPBOARD_REDIRECTION, - OPTION_ALLOW_LOGON_SCREEN_PASSWORD, - OPTION_ONE_WAY_FILE_TRANSFER, - ]; -} - -pub fn common_load< - T: serde::Serialize + serde::de::DeserializeOwned + Default + std::fmt::Debug, ->( - suffix: &str, -) -> T { - Config::load_::(suffix) -} - -pub fn common_store(config: &T, suffix: &str) { - Config::store_(config, suffix); -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_serialize() { - let cfg: Config = Default::default(); - let res = toml::to_string_pretty(&cfg); - assert!(res.is_ok()); - let cfg: PeerConfig = Default::default(); - let res = toml::to_string_pretty(&cfg); - assert!(res.is_ok()); - } - - #[test] - fn test_overwrite_settings() { - DEFAULT_SETTINGS - .write() - .unwrap() - .insert("b".to_string(), "a".to_string()); - DEFAULT_SETTINGS - .write() - .unwrap() - .insert("c".to_string(), "a".to_string()); - CONFIG2 - .write() - .unwrap() - .options - .insert("a".to_string(), "b".to_string()); - CONFIG2 - .write() - .unwrap() - .options - .insert("b".to_string(), "b".to_string()); - OVERWRITE_SETTINGS - .write() - .unwrap() - .insert("b".to_string(), "c".to_string()); - OVERWRITE_SETTINGS - .write() - .unwrap() - .insert("c".to_string(), "f".to_string()); - OVERWRITE_SETTINGS - .write() - .unwrap() - .insert("d".to_string(), "c".to_string()); - let mut res: HashMap = Default::default(); - res.insert("b".to_owned(), "c".to_string()); - res.insert("d".to_owned(), "c".to_string()); - res.insert("c".to_owned(), "a".to_string()); - Config::purify_options(&mut res); - assert!(res.len() == 0); - res.insert("b".to_owned(), "c".to_string()); - res.insert("d".to_owned(), "c".to_string()); - res.insert("c".to_owned(), "a".to_string()); - res.insert("f".to_owned(), "a".to_string()); - Config::purify_options(&mut res); - assert!(res.len() == 1); - res.insert("b".to_owned(), "c".to_string()); - res.insert("d".to_owned(), "c".to_string()); - res.insert("c".to_owned(), "a".to_string()); - res.insert("f".to_owned(), "a".to_string()); - res.insert("e".to_owned(), "d".to_string()); - Config::purify_options(&mut res); - assert!(res.len() == 2); - res.insert("b".to_owned(), "c".to_string()); - res.insert("d".to_owned(), "c".to_string()); - res.insert("c".to_owned(), "a".to_string()); - res.insert("f".to_owned(), "a".to_string()); - res.insert("c".to_owned(), "d".to_string()); - res.insert("d".to_owned(), "cc".to_string()); - Config::purify_options(&mut res); - DEFAULT_SETTINGS - .write() - .unwrap() - .insert("f".to_string(), "c".to_string()); - Config::purify_options(&mut res); - assert!(res.len() == 2); - DEFAULT_SETTINGS - .write() - .unwrap() - .insert("f".to_string(), "a".to_string()); - Config::purify_options(&mut res); - assert!(res.len() == 1); - let res = Config::get_options(); - assert!(res["a"] == "b"); - assert!(res["c"] == "f"); - assert!(res["b"] == "c"); - assert!(res["d"] == "c"); - assert!(Config::get_option("a") == "b"); - assert!(Config::get_option("c") == "f"); - assert!(Config::get_option("b") == "c"); - assert!(Config::get_option("d") == "c"); - DEFAULT_SETTINGS.write().unwrap().clear(); - OVERWRITE_SETTINGS.write().unwrap().clear(); - CONFIG2.write().unwrap().options.clear(); - - DEFAULT_LOCAL_SETTINGS - .write() - .unwrap() - .insert("b".to_string(), "a".to_string()); - DEFAULT_LOCAL_SETTINGS - .write() - .unwrap() - .insert("c".to_string(), "a".to_string()); - LOCAL_CONFIG - .write() - .unwrap() - .options - .insert("a".to_string(), "b".to_string()); - LOCAL_CONFIG - .write() - .unwrap() - .options - .insert("b".to_string(), "b".to_string()); - OVERWRITE_LOCAL_SETTINGS - .write() - .unwrap() - .insert("b".to_string(), "c".to_string()); - OVERWRITE_LOCAL_SETTINGS - .write() - .unwrap() - .insert("d".to_string(), "c".to_string()); - assert!(LocalConfig::get_option("a") == "b"); - assert!(LocalConfig::get_option("c") == "a"); - assert!(LocalConfig::get_option("b") == "c"); - assert!(LocalConfig::get_option("d") == "c"); - DEFAULT_LOCAL_SETTINGS.write().unwrap().clear(); - OVERWRITE_LOCAL_SETTINGS.write().unwrap().clear(); - LOCAL_CONFIG.write().unwrap().options.clear(); - - DEFAULT_DISPLAY_SETTINGS - .write() - .unwrap() - .insert("b".to_string(), "a".to_string()); - DEFAULT_DISPLAY_SETTINGS - .write() - .unwrap() - .insert("c".to_string(), "a".to_string()); - USER_DEFAULT_CONFIG - .write() - .unwrap() - .0 - .options - .insert("a".to_string(), "b".to_string()); - USER_DEFAULT_CONFIG - .write() - .unwrap() - .0 - .options - .insert("b".to_string(), "b".to_string()); - OVERWRITE_DISPLAY_SETTINGS - .write() - .unwrap() - .insert("b".to_string(), "c".to_string()); - OVERWRITE_DISPLAY_SETTINGS - .write() - .unwrap() - .insert("d".to_string(), "c".to_string()); - assert!(UserDefaultConfig::read("a") == "b"); - assert!(UserDefaultConfig::read("c") == "a"); - assert!(UserDefaultConfig::read("b") == "c"); - assert!(UserDefaultConfig::read("d") == "c"); - DEFAULT_DISPLAY_SETTINGS.write().unwrap().clear(); - OVERWRITE_DISPLAY_SETTINGS.write().unwrap().clear(); - LOCAL_CONFIG.write().unwrap().options.clear(); - } - - #[test] - fn test_config_deserialize() { - let wrong_type_str = r#" - id = true - enc_id = [] - password = 1 - salt = "123456" - key_pair = {} - key_confirmed = "1" - keys_confirmed = 1 - "#; - let cfg = toml::from_str::(wrong_type_str); - assert_eq!( - cfg, - Ok(Config { - salt: "123456".to_string(), - ..Default::default() - }) - ); - - let wrong_field_str = r#" - hello = "world" - key_confirmed = true - "#; - let cfg = toml::from_str::(wrong_field_str); - assert_eq!( - cfg, - Ok(Config { - key_confirmed: true, - ..Default::default() - }) - ); - } - - #[test] - fn test_peer_config_deserialize() { - let default_peer_config = toml::from_str::("").unwrap(); - // test custom_resolution - { - let wrong_type_str = r#" - view_style = "adaptive" - scroll_style = "scrollbar" - custom_resolutions = true - "#; - let mut cfg_to_compare = default_peer_config.clone(); - cfg_to_compare.view_style = "adaptive".to_string(); - cfg_to_compare.scroll_style = "scrollbar".to_string(); - let cfg = toml::from_str::(wrong_type_str); - assert_eq!(cfg, Ok(cfg_to_compare), "Failed to test wrong_type_str"); - - let wrong_type_str = r#" - view_style = "adaptive" - scroll_style = "scrollbar" - [custom_resolutions.0] - w = "1920" - h = 1080 - "#; - let mut cfg_to_compare = default_peer_config.clone(); - cfg_to_compare.view_style = "adaptive".to_string(); - cfg_to_compare.scroll_style = "scrollbar".to_string(); - let cfg = toml::from_str::(wrong_type_str); - assert_eq!(cfg, Ok(cfg_to_compare), "Failed to test wrong_type_str"); - - let wrong_field_str = r#" - [custom_resolutions.0] - w = 1920 - h = 1080 - hello = "world" - [ui_flutter] - "#; - let mut cfg_to_compare = default_peer_config.clone(); - cfg_to_compare.custom_resolutions = - HashMap::from([("0".to_string(), Resolution { w: 1920, h: 1080 })]); - let cfg = toml::from_str::(wrong_field_str); - assert_eq!(cfg, Ok(cfg_to_compare), "Failed to test wrong_field_str"); - } - } - - #[test] - fn test_store_load() { - let peerconfig_id = "123456789"; - let cfg: PeerConfig = Default::default(); - cfg.store(&peerconfig_id); - assert_eq!(PeerConfig::load(&peerconfig_id), cfg); - - #[cfg(not(windows))] - { - use std::os::unix::fs::PermissionsExt; - assert_eq!( - // ignore file type information by masking with 0o777 (see https://stackoverflow.com/a/50045872) - fs::metadata(PeerConfig::path(&peerconfig_id)) - .expect("reading metadata failed") - .permissions() - .mode() - & 0o777, - 0o600 - ); - } - } -} diff --git a/libs/hbb_common/src/fs.rs b/libs/hbb_common/src/fs.rs deleted file mode 100644 index 3f236fd3a..000000000 --- a/libs/hbb_common/src/fs.rs +++ /dev/null @@ -1,915 +0,0 @@ -#[cfg(windows)] -use std::os::windows::prelude::*; -use std::path::{Path, PathBuf}; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; - -use serde_derive::{Deserialize, Serialize}; -use serde_json::json; -use tokio::{fs::File, io::*}; - -use crate::{anyhow::anyhow, bail, get_version_number, message_proto::*, ResultType, Stream}; -// https://doc.rust-lang.org/std/os/windows/fs/trait.MetadataExt.html -use crate::{ - compress::{compress, decompress}, - config::Config, -}; - -pub fn read_dir(path: &Path, include_hidden: bool) -> ResultType { - let mut dir = FileDirectory { - path: get_string(path), - ..Default::default() - }; - #[cfg(windows)] - if "/" == &get_string(path) { - let drives = unsafe { winapi::um::fileapi::GetLogicalDrives() }; - for i in 0..32 { - if drives & (1 << i) != 0 { - let name = format!( - "{}:", - std::char::from_u32('A' as u32 + i as u32).unwrap_or('A') - ); - dir.entries.push(FileEntry { - name, - entry_type: FileType::DirDrive.into(), - ..Default::default() - }); - } - } - return Ok(dir); - } - for entry in path.read_dir()?.flatten() { - let p = entry.path(); - let name = p - .file_name() - .map(|p| p.to_str().unwrap_or("")) - .unwrap_or("") - .to_owned(); - if name.is_empty() { - continue; - } - let mut is_hidden = false; - let meta; - if let Ok(tmp) = std::fs::symlink_metadata(&p) { - meta = tmp; - } else { - continue; - } - // docs.microsoft.com/en-us/windows/win32/fileio/file-attribute-constants - #[cfg(windows)] - if meta.file_attributes() & 0x2 != 0 { - is_hidden = true; - } - #[cfg(not(windows))] - if name.find('.').unwrap_or(usize::MAX) == 0 { - is_hidden = true; - } - if is_hidden && !include_hidden { - continue; - } - let (entry_type, size) = { - if p.is_dir() { - if meta.file_type().is_symlink() { - (FileType::DirLink.into(), 0) - } else { - (FileType::Dir.into(), 0) - } - } else if meta.file_type().is_symlink() { - (FileType::FileLink.into(), 0) - } else { - (FileType::File.into(), meta.len()) - } - }; - let modified_time = meta - .modified() - .map(|x| { - x.duration_since(std::time::SystemTime::UNIX_EPOCH) - .map(|x| x.as_secs()) - .unwrap_or(0) - }) - .unwrap_or(0); - dir.entries.push(FileEntry { - name: get_file_name(&p), - entry_type, - is_hidden, - size, - modified_time, - ..Default::default() - }); - } - Ok(dir) -} - -#[inline] -pub fn get_file_name(p: &Path) -> String { - p.file_name() - .map(|p| p.to_str().unwrap_or("")) - .unwrap_or("") - .to_owned() -} - -#[inline] -pub fn get_string(path: &Path) -> String { - path.to_str().unwrap_or("").to_owned() -} - -#[inline] -pub fn get_path(path: &str) -> PathBuf { - Path::new(path).to_path_buf() -} - -#[inline] -pub fn get_home_as_string() -> String { - get_string(&Config::get_home()) -} - -fn read_dir_recursive( - path: &PathBuf, - prefix: &Path, - include_hidden: bool, -) -> ResultType> { - let mut files = Vec::new(); - if path.is_dir() { - // to-do: symbol link handling, cp the link rather than the content - // to-do: file mode, for unix - let fd = read_dir(path, include_hidden)?; - for entry in fd.entries.iter() { - match entry.entry_type.enum_value() { - Ok(FileType::File) => { - let mut entry = entry.clone(); - entry.name = get_string(&prefix.join(entry.name)); - files.push(entry); - } - Ok(FileType::Dir) => { - if let Ok(mut tmp) = read_dir_recursive( - &path.join(&entry.name), - &prefix.join(&entry.name), - include_hidden, - ) { - for entry in tmp.drain(0..) { - files.push(entry); - } - } - } - _ => {} - } - } - Ok(files) - } else if path.is_file() { - let (size, modified_time) = if let Ok(meta) = std::fs::metadata(path) { - ( - meta.len(), - meta.modified() - .map(|x| { - x.duration_since(std::time::SystemTime::UNIX_EPOCH) - .map(|x| x.as_secs()) - .unwrap_or(0) - }) - .unwrap_or(0), - ) - } else { - (0, 0) - }; - files.push(FileEntry { - entry_type: FileType::File.into(), - size, - modified_time, - ..Default::default() - }); - Ok(files) - } else { - bail!("Not exists"); - } -} - -pub fn get_recursive_files(path: &str, include_hidden: bool) -> ResultType> { - read_dir_recursive(&get_path(path), &get_path(""), include_hidden) -} - -#[inline] -pub fn is_file_exists(file_path: &str) -> bool { - return Path::new(file_path).exists(); -} - -#[inline] -pub fn can_enable_overwrite_detection(version: i64) -> bool { - version >= get_version_number("1.1.10") -} - -#[derive(Default, Serialize, Debug)] -#[serde(rename_all = "camelCase")] -pub struct TransferJob { - pub id: i32, - pub remote: String, - pub path: PathBuf, - pub show_hidden: bool, - pub is_remote: bool, - pub is_last_job: bool, - pub file_num: i32, - #[serde(skip_serializing)] - pub files: Vec, - pub conn_id: i32, // server only - - #[serde(skip_serializing)] - file: Option, - pub total_size: u64, - finished_size: u64, - transferred: u64, - enable_overwrite_detection: bool, - file_confirmed: bool, - // indicating the last file is skipped - file_skipped: bool, - file_is_waiting: bool, - default_overwrite_strategy: Option, -} - -#[derive(Debug, Default, Serialize, Deserialize, Clone)] -pub struct TransferJobMeta { - #[serde(default)] - pub id: i32, - #[serde(default)] - pub remote: String, - #[serde(default)] - pub to: String, - #[serde(default)] - pub show_hidden: bool, - #[serde(default)] - pub file_num: i32, - #[serde(default)] - pub is_remote: bool, -} - -#[derive(Debug, Default, Serialize, Deserialize, Clone)] -pub struct RemoveJobMeta { - #[serde(default)] - pub path: String, - #[serde(default)] - pub is_remote: bool, - #[serde(default)] - pub no_confirm: bool, -} - -#[inline] -fn get_ext(name: &str) -> &str { - if let Some(i) = name.rfind('.') { - return &name[i + 1..]; - } - "" -} - -#[inline] -fn is_compressed_file(name: &str) -> bool { - let ext = get_ext(name); - ext == "xz" - || ext == "gz" - || ext == "zip" - || ext == "7z" - || ext == "rar" - || ext == "bz2" - || ext == "tgz" - || ext == "png" - || ext == "jpg" -} - -impl TransferJob { - #[allow(clippy::too_many_arguments)] - pub fn new_write( - id: i32, - remote: String, - path: String, - file_num: i32, - show_hidden: bool, - is_remote: bool, - files: Vec, - enable_overwrite_detection: bool, - ) -> Self { - log::info!("new write {}", path); - let total_size = files.iter().map(|x| x.size).sum(); - Self { - id, - remote, - path: get_path(&path), - file_num, - show_hidden, - is_remote, - files, - total_size, - enable_overwrite_detection, - ..Default::default() - } - } - - pub fn new_read( - id: i32, - remote: String, - path: String, - file_num: i32, - show_hidden: bool, - is_remote: bool, - enable_overwrite_detection: bool, - ) -> ResultType { - log::info!("new read {}", path); - let files = get_recursive_files(&path, show_hidden)?; - let total_size = files.iter().map(|x| x.size).sum(); - Ok(Self { - id, - remote, - path: get_path(&path), - file_num, - show_hidden, - is_remote, - files, - total_size, - enable_overwrite_detection, - ..Default::default() - }) - } - - #[inline] - pub fn files(&self) -> &Vec { - &self.files - } - - #[inline] - pub fn set_files(&mut self, files: Vec) { - self.files = files; - } - - #[inline] - pub fn id(&self) -> i32 { - self.id - } - - #[inline] - pub fn total_size(&self) -> u64 { - self.total_size - } - - #[inline] - pub fn finished_size(&self) -> u64 { - self.finished_size - } - - #[inline] - pub fn transferred(&self) -> u64 { - self.transferred - } - - #[inline] - pub fn file_num(&self) -> i32 { - self.file_num - } - - pub fn modify_time(&self) { - let file_num = self.file_num as usize; - if file_num < self.files.len() { - let entry = &self.files[file_num]; - let path = self.join(&entry.name); - let download_path = format!("{}.download", get_string(&path)); - std::fs::rename(download_path, &path).ok(); - filetime::set_file_mtime( - &path, - filetime::FileTime::from_unix_time(entry.modified_time as _, 0), - ) - .ok(); - } - } - - pub fn remove_download_file(&self) { - let file_num = self.file_num as usize; - if file_num < self.files.len() { - let entry = &self.files[file_num]; - let path = self.join(&entry.name); - let download_path = format!("{}.download", get_string(&path)); - std::fs::remove_file(download_path).ok(); - } - } - - pub async fn write(&mut self, block: FileTransferBlock) -> ResultType<()> { - if block.id != self.id { - bail!("Wrong id"); - } - let file_num = block.file_num as usize; - if file_num >= self.files.len() { - bail!("Wrong file number"); - } - if file_num != self.file_num as usize || self.file.is_none() { - self.modify_time(); - if let Some(file) = self.file.as_mut() { - file.sync_all().await?; - } - self.file_num = block.file_num; - let entry = &self.files[file_num]; - let path = self.join(&entry.name); - if let Some(p) = path.parent() { - std::fs::create_dir_all(p).ok(); - } - let path = format!("{}.download", get_string(&path)); - self.file = Some(File::create(&path).await?); - } - if block.compressed { - let tmp = decompress(&block.data); - self.file - .as_mut() - .ok_or(anyhow!("file is None"))? - .write_all(&tmp) - .await?; - self.finished_size += tmp.len() as u64; - } else { - self.file - .as_mut() - .ok_or(anyhow!("file is None"))? - .write_all(&block.data) - .await?; - self.finished_size += block.data.len() as u64; - } - self.transferred += block.data.len() as u64; - Ok(()) - } - - #[inline] - pub fn join(&self, name: &str) -> PathBuf { - if name.is_empty() { - self.path.clone() - } else { - self.path.join(name) - } - } - - pub async fn read(&mut self, stream: &mut Stream) -> ResultType> { - let file_num = self.file_num as usize; - if file_num >= self.files.len() { - self.file.take(); - return Ok(None); - } - let name = &self.files[file_num].name; - if self.file.is_none() { - match File::open(self.join(name)).await { - Ok(file) => { - self.file = Some(file); - self.file_confirmed = false; - self.file_is_waiting = false; - } - Err(err) => { - self.file_num += 1; - self.file_confirmed = false; - self.file_is_waiting = false; - return Err(err.into()); - } - } - } - if self.enable_overwrite_detection && !self.file_confirmed() { - if !self.file_is_waiting() { - self.send_current_digest(stream).await?; - self.set_file_is_waiting(true); - } - return Ok(None); - } - const BUF_SIZE: usize = 128 * 1024; - let mut buf: Vec = vec![0; BUF_SIZE]; - let mut compressed = false; - let mut offset: usize = 0; - loop { - match self - .file - .as_mut() - .ok_or(anyhow!("file is None"))? - .read(&mut buf[offset..]) - .await - { - Err(err) => { - self.file_num += 1; - self.file = None; - self.file_confirmed = false; - self.file_is_waiting = false; - return Err(err.into()); - } - Ok(n) => { - offset += n; - if n == 0 || offset == BUF_SIZE { - break; - } - } - } - } - unsafe { buf.set_len(offset) }; - if offset == 0 { - self.file_num += 1; - self.file = None; - self.file_confirmed = false; - self.file_is_waiting = false; - } else { - self.finished_size += offset as u64; - if !is_compressed_file(name) { - let tmp = compress(&buf); - if tmp.len() < buf.len() { - buf = tmp; - compressed = true; - } - } - self.transferred += buf.len() as u64; - } - Ok(Some(FileTransferBlock { - id: self.id, - file_num: file_num as _, - data: buf.into(), - compressed, - ..Default::default() - })) - } - - async fn send_current_digest(&mut self, stream: &mut Stream) -> ResultType<()> { - let mut msg = Message::new(); - let mut resp = FileResponse::new(); - let meta = self - .file - .as_ref() - .ok_or(anyhow!("file is None"))? - .metadata() - .await?; - let last_modified = meta - .modified()? - .duration_since(SystemTime::UNIX_EPOCH)? - .as_secs(); - resp.set_digest(FileTransferDigest { - id: self.id, - file_num: self.file_num, - last_modified, - file_size: meta.len(), - ..Default::default() - }); - msg.set_file_response(resp); - stream.send(&msg).await?; - log::info!( - "id: {}, file_num: {}, digest message is sent. waiting for confirm. msg: {:?}", - self.id, - self.file_num, - msg - ); - Ok(()) - } - - pub fn set_overwrite_strategy(&mut self, overwrite_strategy: Option) { - self.default_overwrite_strategy = overwrite_strategy; - } - - pub fn default_overwrite_strategy(&self) -> Option { - self.default_overwrite_strategy - } - - pub fn set_file_confirmed(&mut self, file_confirmed: bool) { - log::info!("id: {}, file_confirmed: {}", self.id, file_confirmed); - self.file_confirmed = file_confirmed; - self.file_skipped = false; - } - - pub fn set_file_is_waiting(&mut self, file_is_waiting: bool) { - self.file_is_waiting = file_is_waiting; - } - - #[inline] - pub fn file_is_waiting(&self) -> bool { - self.file_is_waiting - } - - #[inline] - pub fn file_confirmed(&self) -> bool { - self.file_confirmed - } - - /// Indicating whether the last file is skipped - #[inline] - pub fn file_skipped(&self) -> bool { - self.file_skipped - } - - /// Indicating whether the whole task is skipped - #[inline] - pub fn job_skipped(&self) -> bool { - self.file_skipped() && self.files.len() == 1 - } - - /// Check whether the job is completed after `read` returns `None` - /// This is a helper function which gives additional lifecycle when the job reads `None`. - /// If returns `true`, it means we can delete the job automatically. `False` otherwise. - /// - /// [`Note`] - /// Conditions: - /// 1. Files are not waiting for confirmation by peers. - #[inline] - pub fn job_completed(&self) -> bool { - // has no error, Condition 2 - !self.enable_overwrite_detection || (!self.file_confirmed && !self.file_is_waiting) - } - - /// Get job error message, useful for getting status when job had finished - pub fn job_error(&self) -> Option { - if self.job_skipped() { - return Some("skipped".to_string()); - } - None - } - - pub fn set_file_skipped(&mut self) -> bool { - log::debug!("skip file {} in job {}", self.file_num, self.id); - self.file.take(); - self.set_file_confirmed(false); - self.set_file_is_waiting(false); - self.file_num += 1; - self.file_skipped = true; - true - } - - pub fn confirm(&mut self, r: &FileTransferSendConfirmRequest) -> bool { - if self.file_num() != r.file_num { - log::info!("file num truncated, ignoring"); - } else { - match r.union { - Some(file_transfer_send_confirm_request::Union::Skip(s)) => { - if s { - self.set_file_skipped(); - } else { - self.set_file_confirmed(true); - } - } - Some(file_transfer_send_confirm_request::Union::OffsetBlk(_offset)) => { - self.set_file_confirmed(true); - } - _ => {} - } - } - true - } - - #[inline] - pub fn gen_meta(&self) -> TransferJobMeta { - TransferJobMeta { - id: self.id, - remote: self.remote.to_string(), - to: self.path.to_string_lossy().to_string(), - file_num: self.file_num, - show_hidden: self.show_hidden, - is_remote: self.is_remote, - } - } -} - -#[inline] -pub fn new_error(id: i32, err: T, file_num: i32) -> Message { - let mut resp = FileResponse::new(); - resp.set_error(FileTransferError { - id, - error: err.to_string(), - file_num, - ..Default::default() - }); - let mut msg_out = Message::new(); - msg_out.set_file_response(resp); - msg_out -} - -#[inline] -pub fn new_dir(id: i32, path: String, files: Vec) -> Message { - let mut resp = FileResponse::new(); - resp.set_dir(FileDirectory { - id, - path, - entries: files, - ..Default::default() - }); - let mut msg_out = Message::new(); - msg_out.set_file_response(resp); - msg_out -} - -#[inline] -pub fn new_block(block: FileTransferBlock) -> Message { - let mut resp = FileResponse::new(); - resp.set_block(block); - let mut msg_out = Message::new(); - msg_out.set_file_response(resp); - msg_out -} - -#[inline] -pub fn new_send_confirm(r: FileTransferSendConfirmRequest) -> Message { - let mut msg_out = Message::new(); - let mut action = FileAction::new(); - action.set_send_confirm(r); - msg_out.set_file_action(action); - msg_out -} - -#[inline] -pub fn new_receive( - id: i32, - path: String, - file_num: i32, - files: Vec, - total_size: u64, -) -> Message { - let mut action = FileAction::new(); - action.set_receive(FileTransferReceiveRequest { - id, - path, - files, - file_num, - total_size, - ..Default::default() - }); - let mut msg_out = Message::new(); - msg_out.set_file_action(action); - msg_out -} - -#[inline] -pub fn new_send(id: i32, path: String, file_num: i32, include_hidden: bool) -> Message { - log::info!("new send: {}, id: {}", path, id); - let mut action = FileAction::new(); - action.set_send(FileTransferSendRequest { - id, - path, - include_hidden, - file_num, - ..Default::default() - }); - let mut msg_out = Message::new(); - msg_out.set_file_action(action); - msg_out -} - -#[inline] -pub fn new_done(id: i32, file_num: i32) -> Message { - let mut resp = FileResponse::new(); - resp.set_done(FileTransferDone { - id, - file_num, - ..Default::default() - }); - let mut msg_out = Message::new(); - msg_out.set_file_response(resp); - msg_out -} - -#[inline] -pub fn remove_job(id: i32, jobs: &mut Vec) { - *jobs = jobs.drain(0..).filter(|x| x.id() != id).collect(); -} - -#[inline] -pub fn get_job(id: i32, jobs: &mut [TransferJob]) -> Option<&mut TransferJob> { - jobs.iter_mut().find(|x| x.id() == id) -} - -#[inline] -pub fn get_job_immutable(id: i32, jobs: &[TransferJob]) -> Option<&TransferJob> { - jobs.iter().find(|x| x.id() == id) -} - -pub async fn handle_read_jobs( - jobs: &mut Vec, - stream: &mut crate::Stream, -) -> ResultType { - let mut job_log = Default::default(); - let mut finished = Vec::new(); - for job in jobs.iter_mut() { - if job.is_last_job { - continue; - } - match job.read(stream).await { - Err(err) => { - stream - .send(&new_error(job.id(), err, job.file_num())) - .await?; - } - Ok(Some(block)) => { - stream.send(&new_block(block)).await?; - } - Ok(None) => { - if job.job_completed() { - job_log = serialize_transfer_job(job, true, false, ""); - finished.push(job.id()); - match job.job_error() { - Some(err) => { - job_log = serialize_transfer_job(job, false, false, &err); - stream - .send(&new_error(job.id(), err, job.file_num())) - .await? - } - None => stream.send(&new_done(job.id(), job.file_num())).await?, - } - } else { - // waiting confirmation. - } - } - } - } - for id in finished { - remove_job(id, jobs); - } - Ok(job_log) -} - -pub fn remove_all_empty_dir(path: &PathBuf) -> ResultType<()> { - let fd = read_dir(path, true)?; - for entry in fd.entries.iter() { - match entry.entry_type.enum_value() { - Ok(FileType::Dir) => { - remove_all_empty_dir(&path.join(&entry.name)).ok(); - } - Ok(FileType::DirLink) | Ok(FileType::FileLink) => { - std::fs::remove_file(path.join(&entry.name)).ok(); - } - _ => {} - } - } - std::fs::remove_dir(path).ok(); - Ok(()) -} - -#[inline] -pub fn remove_file(file: &str) -> ResultType<()> { - std::fs::remove_file(get_path(file))?; - Ok(()) -} - -#[inline] -pub fn create_dir(dir: &str) -> ResultType<()> { - std::fs::create_dir_all(get_path(dir))?; - Ok(()) -} - -#[inline] -pub fn rename_file(path: &str, new_name: &str) -> ResultType<()> { - let path = std::path::Path::new(&path); - if path.exists() { - let dir = path - .parent() - .ok_or(anyhow!("Parent directoy of {path:?} not exists"))?; - let new_path = dir.join(&new_name); - std::fs::rename(&path, &new_path)?; - Ok(()) - } else { - bail!("{path:?} not exists"); - } -} - -#[inline] -pub fn transform_windows_path(entries: &mut Vec) { - for entry in entries { - entry.name = entry.name.replace('\\', "/"); - } -} - -pub enum DigestCheckResult { - IsSame, - NeedConfirm(FileTransferDigest), - NoSuchFile, -} - -#[inline] -pub fn is_write_need_confirmation( - file_path: &str, - digest: &FileTransferDigest, -) -> ResultType { - let path = Path::new(file_path); - if path.exists() && path.is_file() { - let metadata = std::fs::metadata(path)?; - let modified_time = metadata.modified()?; - let remote_mt = Duration::from_secs(digest.last_modified); - let local_mt = modified_time.duration_since(UNIX_EPOCH)?; - // [Note] - // We decide to give the decision whether to override the existing file to users, - // which obey the behavior of the file manager in our system. - let mut is_identical = false; - if remote_mt == local_mt && digest.file_size == metadata.len() { - is_identical = true; - } - Ok(DigestCheckResult::NeedConfirm(FileTransferDigest { - id: digest.id, - file_num: digest.file_num, - last_modified: local_mt.as_secs(), - file_size: metadata.len(), - is_identical, - ..Default::default() - })) - } else { - Ok(DigestCheckResult::NoSuchFile) - } -} - -pub fn serialize_transfer_jobs(jobs: &[TransferJob]) -> String { - let mut v = vec![]; - for job in jobs { - let value = serde_json::to_value(job).unwrap_or_default(); - v.push(value); - } - serde_json::to_string(&v).unwrap_or_default() -} - -pub fn serialize_transfer_job(job: &TransferJob, done: bool, cancel: bool, error: &str) -> String { - let mut value = serde_json::to_value(job).unwrap_or_default(); - value["done"] = json!(done); - value["cancel"] = json!(cancel); - value["error"] = json!(error); - serde_json::to_string(&value).unwrap_or_default() -} diff --git a/libs/hbb_common/src/keyboard.rs b/libs/hbb_common/src/keyboard.rs deleted file mode 100644 index 10979f520..000000000 --- a/libs/hbb_common/src/keyboard.rs +++ /dev/null @@ -1,39 +0,0 @@ -use std::{fmt, slice::Iter, str::FromStr}; - -use crate::protos::message::KeyboardMode; - -impl fmt::Display for KeyboardMode { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - match self { - KeyboardMode::Legacy => write!(f, "legacy"), - KeyboardMode::Map => write!(f, "map"), - KeyboardMode::Translate => write!(f, "translate"), - KeyboardMode::Auto => write!(f, "auto"), - } - } -} - -impl FromStr for KeyboardMode { - type Err = (); - fn from_str(s: &str) -> Result { - match s { - "legacy" => Ok(KeyboardMode::Legacy), - "map" => Ok(KeyboardMode::Map), - "translate" => Ok(KeyboardMode::Translate), - "auto" => Ok(KeyboardMode::Auto), - _ => Err(()), - } - } -} - -impl KeyboardMode { - pub fn iter() -> Iter<'static, KeyboardMode> { - static KEYBOARD_MODES: [KeyboardMode; 4] = [ - KeyboardMode::Legacy, - KeyboardMode::Map, - KeyboardMode::Translate, - KeyboardMode::Auto, - ]; - KEYBOARD_MODES.iter() - } -} diff --git a/libs/hbb_common/src/lib.rs b/libs/hbb_common/src/lib.rs deleted file mode 100644 index 15ef31022..000000000 --- a/libs/hbb_common/src/lib.rs +++ /dev/null @@ -1,499 +0,0 @@ -pub mod compress; -pub mod platform; -pub mod protos; -pub use bytes; -use config::Config; -pub use futures; -pub use protobuf; -pub use protos::message as message_proto; -pub use protos::rendezvous as rendezvous_proto; -use std::{ - fs::File, - io::{self, BufRead}, - net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4}, - path::Path, - time::{self, SystemTime, UNIX_EPOCH}, -}; -pub use tokio; -pub use tokio_util; -pub mod proxy; -pub mod socket_client; -pub mod tcp; -pub mod udp; -pub use env_logger; -pub use log; -pub mod bytes_codec; -pub use anyhow::{self, bail}; -pub use futures_util; -pub mod config; -pub mod fs; -pub use lazy_static; -#[cfg(not(any(target_os = "android", target_os = "ios")))] -pub use mac_address; -pub use rand; -pub use regex; -pub use sodiumoxide; -pub use tokio_socks; -pub use tokio_socks::IntoTargetAddr; -pub use tokio_socks::TargetAddr; -pub mod password_security; -pub use chrono; -pub use directories_next; -pub use libc; -pub mod keyboard; -pub use base64; -#[cfg(not(any(target_os = "android", target_os = "ios")))] -pub use dlopen; -#[cfg(not(any(target_os = "android", target_os = "ios")))] -pub use machine_uid; -pub use serde_derive; -pub use serde_json; -pub use sysinfo; -pub use thiserror; -pub use toml; -pub use uuid; - -pub type Stream = tcp::FramedStream; -pub type SessionID = uuid::Uuid; - -#[inline] -pub async fn sleep(sec: f32) { - tokio::time::sleep(time::Duration::from_secs_f32(sec)).await; -} - -#[macro_export] -macro_rules! allow_err { - ($e:expr) => { - if let Err(err) = $e { - log::debug!( - "{:?}, {}:{}:{}:{}", - err, - module_path!(), - file!(), - line!(), - column!() - ); - } else { - } - }; - - ($e:expr, $($arg:tt)*) => { - if let Err(err) = $e { - log::debug!( - "{:?}, {}, {}:{}:{}:{}", - err, - format_args!($($arg)*), - module_path!(), - file!(), - line!(), - column!() - ); - } else { - } - }; -} - -#[inline] -pub fn timeout(ms: u64, future: T) -> tokio::time::Timeout { - tokio::time::timeout(std::time::Duration::from_millis(ms), future) -} - -pub type ResultType = anyhow::Result; - -/// Certain router and firewalls scan the packet and if they -/// find an IP address belonging to their pool that they use to do the NAT mapping/translation, so here we mangle the ip address - -pub struct AddrMangle(); - -#[inline] -pub fn try_into_v4(addr: SocketAddr) -> SocketAddr { - match addr { - SocketAddr::V6(v6) if !addr.ip().is_loopback() => { - if let Some(v4) = v6.ip().to_ipv4() { - SocketAddr::new(IpAddr::V4(v4), addr.port()) - } else { - addr - } - } - _ => addr, - } -} - -impl AddrMangle { - pub fn encode(addr: SocketAddr) -> Vec { - // not work with [:1]: - let addr = try_into_v4(addr); - match addr { - SocketAddr::V4(addr_v4) => { - let tm = (SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or(std::time::Duration::ZERO) - .as_micros() as u32) as u128; - let ip = u32::from_le_bytes(addr_v4.ip().octets()) as u128; - let port = addr.port() as u128; - let v = ((ip + tm) << 49) | (tm << 17) | (port + (tm & 0xFFFF)); - let bytes = v.to_le_bytes(); - let mut n_padding = 0; - for i in bytes.iter().rev() { - if i == &0u8 { - n_padding += 1; - } else { - break; - } - } - bytes[..(16 - n_padding)].to_vec() - } - SocketAddr::V6(addr_v6) => { - let mut x = addr_v6.ip().octets().to_vec(); - let port: [u8; 2] = addr_v6.port().to_le_bytes(); - x.push(port[0]); - x.push(port[1]); - x - } - } - } - - pub fn decode(bytes: &[u8]) -> SocketAddr { - use std::convert::TryInto; - - if bytes.len() > 16 { - if bytes.len() != 18 { - return Config::get_any_listen_addr(false); - } - let tmp: [u8; 2] = bytes[16..].try_into().unwrap_or_default(); - let port = u16::from_le_bytes(tmp); - let tmp: [u8; 16] = bytes[..16].try_into().unwrap_or_default(); - let ip = std::net::Ipv6Addr::from(tmp); - return SocketAddr::new(IpAddr::V6(ip), port); - } - let mut padded = [0u8; 16]; - padded[..bytes.len()].copy_from_slice(bytes); - let number = u128::from_le_bytes(padded); - let tm = (number >> 17) & (u32::max_value() as u128); - let ip = (((number >> 49) - tm) as u32).to_le_bytes(); - let port = (number & 0xFFFFFF) - (tm & 0xFFFF); - SocketAddr::V4(SocketAddrV4::new( - Ipv4Addr::new(ip[0], ip[1], ip[2], ip[3]), - port as u16, - )) - } -} - -pub fn get_version_from_url(url: &str) -> String { - let n = url.chars().count(); - let a = url.chars().rev().position(|x| x == '-'); - if let Some(a) = a { - let b = url.chars().rev().position(|x| x == '.'); - if let Some(b) = b { - if a > b { - if url - .chars() - .skip(n - b) - .collect::() - .parse::() - .is_ok() - { - return url.chars().skip(n - a).collect(); - } else { - return url.chars().skip(n - a).take(a - b - 1).collect(); - } - } else { - return url.chars().skip(n - a).collect(); - } - } - } - "".to_owned() -} - -pub fn gen_version() { - println!("cargo:rerun-if-changed=Cargo.toml"); - use std::io::prelude::*; - let mut file = File::create("./src/version.rs").unwrap(); - for line in read_lines("Cargo.toml").unwrap().flatten() { - let ab: Vec<&str> = line.split('=').map(|x| x.trim()).collect(); - if ab.len() == 2 && ab[0] == "version" { - file.write_all(format!("pub const VERSION: &str = {};\n", ab[1]).as_bytes()) - .ok(); - break; - } - } - // generate build date - let build_date = format!("{}", chrono::Local::now().format("%Y-%m-%d %H:%M")); - file.write_all( - format!("#[allow(dead_code)]\npub const BUILD_DATE: &str = \"{build_date}\";\n").as_bytes(), - ) - .ok(); - file.sync_all().ok(); -} - -fn read_lines

(filename: P) -> io::Result>> -where - P: AsRef, -{ - let file = File::open(filename)?; - Ok(io::BufReader::new(file).lines()) -} - -pub fn is_valid_custom_id(id: &str) -> bool { - regex::Regex::new(r"^[a-zA-Z]\w{5,15}$") - .unwrap() - .is_match(id) -} - -// Support 1.1.10-1, the number after - is a patch version. -pub fn get_version_number(v: &str) -> i64 { - let mut versions = v.split('-'); - - let mut n = 0; - - // The first part is the version number. - // 1.1.10 -> 1001100, 1.2.3 -> 1001030, multiple the last number by 10 - // to leave space for patch version. - if let Some(v) = versions.next() { - let mut last = 0; - for x in v.split('.') { - last = x.parse::().unwrap_or(0); - n = n * 1000 + last; - } - n -= last; - n += last * 10; - } - - if let Some(v) = versions.next() { - n += v.parse::().unwrap_or(0); - } - - // Ignore the rest - - n -} - -pub fn get_modified_time(path: &std::path::Path) -> SystemTime { - std::fs::metadata(path) - .map(|m| m.modified().unwrap_or(UNIX_EPOCH)) - .unwrap_or(UNIX_EPOCH) -} - -pub fn get_created_time(path: &std::path::Path) -> SystemTime { - std::fs::metadata(path) - .map(|m| m.created().unwrap_or(UNIX_EPOCH)) - .unwrap_or(UNIX_EPOCH) -} - -pub fn get_exe_time() -> SystemTime { - std::env::current_exe().map_or(UNIX_EPOCH, |path| { - let m = get_modified_time(&path); - let c = get_created_time(&path); - if m > c { - m - } else { - c - } - }) -} - -pub fn get_uuid() -> Vec { - #[cfg(not(any(target_os = "android", target_os = "ios")))] - if let Ok(id) = machine_uid::get() { - return id.into(); - } - Config::get_key_pair().1 -} - -#[inline] -pub fn get_time() -> i64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis()) - .unwrap_or(0) as _ -} - -#[inline] -pub fn is_ipv4_str(id: &str) -> bool { - if let Ok(reg) = regex::Regex::new( - r"^(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(:\d+)?$", - ) { - reg.is_match(id) - } else { - false - } -} - -#[inline] -pub fn is_ipv6_str(id: &str) -> bool { - if let Ok(reg) = regex::Regex::new( - r"^((([a-fA-F0-9]{1,4}:{1,2})+[a-fA-F0-9]{1,4})|(\[([a-fA-F0-9]{1,4}:{1,2})+[a-fA-F0-9]{1,4}\]:\d+))$", - ) { - reg.is_match(id) - } else { - false - } -} - -#[inline] -pub fn is_ip_str(id: &str) -> bool { - is_ipv4_str(id) || is_ipv6_str(id) -} - -#[inline] -pub fn is_domain_port_str(id: &str) -> bool { - // modified regex for RFC1123 hostname. check https://stackoverflow.com/a/106223 for original version for hostname. - // according to [TLD List](https://data.iana.org/TLD/tlds-alpha-by-domain.txt) version 2023011700, - // there is no digits in TLD, and length is 2~63. - if let Ok(reg) = regex::Regex::new( - r"(?i)^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z-]{0,61}[a-z]:\d{1,5}$", - ) { - reg.is_match(id) - } else { - false - } -} - -pub fn init_log(_is_async: bool, _name: &str) -> Option { - static INIT: std::sync::Once = std::sync::Once::new(); - #[allow(unused_mut)] - let mut logger_holder: Option = None; - INIT.call_once(|| { - #[cfg(debug_assertions)] - { - use env_logger::*; - init_from_env(Env::default().filter_or(DEFAULT_FILTER_ENV, "info")); - } - #[cfg(not(debug_assertions))] - { - // https://docs.rs/flexi_logger/latest/flexi_logger/error_info/index.html#write - // though async logger more efficient, but it also causes more problems, disable it for now - let mut path = config::Config::log_path(); - #[cfg(target_os = "android")] - if !config::Config::get_home().exists() { - return; - } - if !_name.is_empty() { - path.push(_name); - } - use flexi_logger::*; - if let Ok(x) = Logger::try_with_env_or_str("debug") { - logger_holder = x - .log_to_file(FileSpec::default().directory(path)) - .write_mode(if _is_async { - WriteMode::Async - } else { - WriteMode::Direct - }) - .format(opt_format) - .rotate( - Criterion::Age(Age::Day), - Naming::Timestamps, - Cleanup::KeepLogFiles(31), - ) - .start() - .ok(); - } - } - }); - logger_holder -} - -#[cfg(test)] -mod test { - use super::*; - - #[test] - fn test_mangle() { - let addr = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::new(192, 168, 16, 32), 21116)); - assert_eq!(addr, AddrMangle::decode(&AddrMangle::encode(addr))); - - let addr = "[2001:db8::1]:8080".parse::().unwrap(); - assert_eq!(addr, AddrMangle::decode(&AddrMangle::encode(addr))); - - let addr = "[2001:db8:ff::1111]:80".parse::().unwrap(); - assert_eq!(addr, AddrMangle::decode(&AddrMangle::encode(addr))); - } - - #[test] - fn test_allow_err() { - allow_err!(Err("test err") as Result<(), &str>); - allow_err!( - Err("test err with msg") as Result<(), &str>, - "prompt {}", - "failed" - ); - } - - #[test] - fn test_ipv6() { - assert!(is_ipv6_str("1:2:3")); - assert!(is_ipv6_str("[ab:2:3]:12")); - assert!(is_ipv6_str("[ABEF:2a:3]:12")); - assert!(!is_ipv6_str("[ABEG:2a:3]:12")); - assert!(!is_ipv6_str("1[ab:2:3]:12")); - assert!(!is_ipv6_str("1.1.1.1")); - assert!(is_ip_str("1.1.1.1")); - assert!(!is_ipv6_str("1:2:")); - assert!(is_ipv6_str("1:2::0")); - assert!(is_ipv6_str("[1:2::0]:1")); - assert!(!is_ipv6_str("[1:2::0]:")); - assert!(!is_ipv6_str("1:2::0]:1")); - } - - #[test] - fn test_ipv4() { - assert!(is_ipv4_str("1.2.3.4")); - assert!(is_ipv4_str("1.2.3.4:90")); - assert!(is_ipv4_str("192.168.0.1")); - assert!(is_ipv4_str("0.0.0.0")); - assert!(is_ipv4_str("255.255.255.255")); - assert!(!is_ipv4_str("256.0.0.0")); - assert!(!is_ipv4_str("256.256.256.256")); - assert!(!is_ipv4_str("1:2:")); - assert!(!is_ipv4_str("192.168.0.256")); - assert!(!is_ipv4_str("192.168.0.1/24")); - assert!(!is_ipv4_str("192.168.0.")); - assert!(!is_ipv4_str("192.168..1")); - } - - #[test] - fn test_hostname_port() { - assert!(!is_domain_port_str("a:12")); - assert!(!is_domain_port_str("a.b.c:12")); - assert!(is_domain_port_str("test.com:12")); - assert!(is_domain_port_str("test-UPPER.com:12")); - assert!(is_domain_port_str("some-other.domain.com:12")); - assert!(!is_domain_port_str("under_score:12")); - assert!(!is_domain_port_str("a@bc:12")); - assert!(!is_domain_port_str("1.1.1.1:12")); - assert!(!is_domain_port_str("1.2.3:12")); - assert!(!is_domain_port_str("1.2.3.45:12")); - assert!(!is_domain_port_str("a.b.c:123456")); - assert!(!is_domain_port_str("---:12")); - assert!(!is_domain_port_str(".:12")); - // todo: should we also check for these edge cases? - // out-of-range port - assert!(is_domain_port_str("test.com:0")); - assert!(is_domain_port_str("test.com:98989")); - } - - #[test] - fn test_mangle2() { - let addr = "[::ffff:127.0.0.1]:8080".parse().unwrap(); - let addr_v4 = "127.0.0.1:8080".parse().unwrap(); - assert_eq!(AddrMangle::decode(&AddrMangle::encode(addr)), addr_v4); - assert_eq!( - AddrMangle::decode(&AddrMangle::encode("[::127.0.0.1]:8080".parse().unwrap())), - addr_v4 - ); - assert_eq!(AddrMangle::decode(&AddrMangle::encode(addr_v4)), addr_v4); - let addr_v6 = "[ef::fe]:8080".parse().unwrap(); - assert_eq!(AddrMangle::decode(&AddrMangle::encode(addr_v6)), addr_v6); - let addr_v6 = "[::1]:8080".parse().unwrap(); - assert_eq!(AddrMangle::decode(&AddrMangle::encode(addr_v6)), addr_v6); - } - - #[test] - fn test_get_version_number() { - assert_eq!(get_version_number("1.1.10"), 1001100); - assert_eq!(get_version_number("1.1.10-1"), 1001101); - assert_eq!(get_version_number("1.1.11-1"), 1001111); - assert_eq!(get_version_number("1.2.3"), 1002030); - } -} diff --git a/libs/hbb_common/src/password_security.rs b/libs/hbb_common/src/password_security.rs deleted file mode 100644 index 5c04cc97b..000000000 --- a/libs/hbb_common/src/password_security.rs +++ /dev/null @@ -1,295 +0,0 @@ -use crate::config::Config; -use sodiumoxide::base64; -use std::sync::{Arc, RwLock}; - -lazy_static::lazy_static! { - pub static ref TEMPORARY_PASSWORD:Arc> = Arc::new(RwLock::new(Config::get_auto_password(temporary_password_length()))); -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum VerificationMethod { - OnlyUseTemporaryPassword, - OnlyUsePermanentPassword, - UseBothPasswords, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ApproveMode { - Both, - Password, - Click, -} - -// Should only be called in server -pub fn update_temporary_password() { - *TEMPORARY_PASSWORD.write().unwrap() = Config::get_auto_password(temporary_password_length()); -} - -// Should only be called in server -pub fn temporary_password() -> String { - TEMPORARY_PASSWORD.read().unwrap().clone() -} - -fn verification_method() -> VerificationMethod { - let method = Config::get_option("verification-method"); - if method == "use-temporary-password" { - VerificationMethod::OnlyUseTemporaryPassword - } else if method == "use-permanent-password" { - VerificationMethod::OnlyUsePermanentPassword - } else { - VerificationMethod::UseBothPasswords // default - } -} - -pub fn temporary_password_length() -> usize { - let length = Config::get_option("temporary-password-length"); - if length == "8" { - 8 - } else if length == "10" { - 10 - } else { - 6 // default - } -} - -pub fn temporary_enabled() -> bool { - verification_method() != VerificationMethod::OnlyUsePermanentPassword -} - -pub fn permanent_enabled() -> bool { - verification_method() != VerificationMethod::OnlyUseTemporaryPassword -} - -pub fn has_valid_password() -> bool { - temporary_enabled() && !temporary_password().is_empty() - || permanent_enabled() && !Config::get_permanent_password().is_empty() -} - -pub fn approve_mode() -> ApproveMode { - let mode = Config::get_option("approve-mode"); - if mode == "password" { - ApproveMode::Password - } else if mode == "click" { - ApproveMode::Click - } else { - ApproveMode::Both - } -} - -pub fn hide_cm() -> bool { - approve_mode() == ApproveMode::Password - && verification_method() == VerificationMethod::OnlyUsePermanentPassword - && crate::config::option2bool("allow-hide-cm", &Config::get_option("allow-hide-cm")) -} - -const VERSION_LEN: usize = 2; - -pub fn encrypt_str_or_original(s: &str, version: &str, max_len: usize) -> String { - if decrypt_str_or_original(s, version).1 { - log::error!("Duplicate encryption!"); - return s.to_owned(); - } - if s.chars().count() > max_len { - return String::default(); - } - if version == "00" { - if let Ok(s) = encrypt(s.as_bytes()) { - return version.to_owned() + &s; - } - } - s.to_owned() -} - -// String: password -// bool: whether decryption is successful -// bool: whether should store to re-encrypt when load -// note: s.len() return length in bytes, s.chars().count() return char count -// &[..2] return the left 2 bytes, s.chars().take(2) return the left 2 chars -pub fn decrypt_str_or_original(s: &str, current_version: &str) -> (String, bool, bool) { - if s.len() > VERSION_LEN { - if s.starts_with("00") { - if let Ok(v) = decrypt(s[VERSION_LEN..].as_bytes()) { - return ( - String::from_utf8_lossy(&v).to_string(), - true, - "00" != current_version, - ); - } - } - } - - (s.to_owned(), false, !s.is_empty()) -} - -pub fn encrypt_vec_or_original(v: &[u8], version: &str, max_len: usize) -> Vec { - if decrypt_vec_or_original(v, version).1 { - log::error!("Duplicate encryption!"); - return v.to_owned(); - } - if v.len() > max_len { - return vec![]; - } - if version == "00" { - if let Ok(s) = encrypt(v) { - let mut version = version.to_owned().into_bytes(); - version.append(&mut s.into_bytes()); - return version; - } - } - v.to_owned() -} - -// Vec: password -// bool: whether decryption is successful -// bool: whether should store to re-encrypt when load -pub fn decrypt_vec_or_original(v: &[u8], current_version: &str) -> (Vec, bool, bool) { - if v.len() > VERSION_LEN { - let version = String::from_utf8_lossy(&v[..VERSION_LEN]); - if version == "00" { - if let Ok(v) = decrypt(&v[VERSION_LEN..]) { - return (v, true, version != current_version); - } - } - } - - (v.to_owned(), false, !v.is_empty()) -} - -fn encrypt(v: &[u8]) -> Result { - if !v.is_empty() { - symmetric_crypt(v, true).map(|v| base64::encode(v, base64::Variant::Original)) - } else { - Err(()) - } -} - -fn decrypt(v: &[u8]) -> Result, ()> { - if !v.is_empty() { - base64::decode(v, base64::Variant::Original).and_then(|v| symmetric_crypt(&v, false)) - } else { - Err(()) - } -} - -pub fn symmetric_crypt(data: &[u8], encrypt: bool) -> Result, ()> { - use sodiumoxide::crypto::secretbox; - use std::convert::TryInto; - - let mut keybuf = crate::get_uuid(); - keybuf.resize(secretbox::KEYBYTES, 0); - let key = secretbox::Key(keybuf.try_into().map_err(|_| ())?); - let nonce = secretbox::Nonce([0; secretbox::NONCEBYTES]); - - if encrypt { - Ok(secretbox::seal(data, &nonce, &key)) - } else { - secretbox::open(data, &nonce, &key) - } -} - -mod test { - - #[test] - fn test() { - use super::*; - use rand::{thread_rng, Rng}; - use std::time::Instant; - - let version = "00"; - let max_len = 128; - - println!("test str"); - let data = "1ü1111"; - let encrypted = encrypt_str_or_original(data, version, max_len); - let (decrypted, succ, store) = decrypt_str_or_original(&encrypted, version); - println!("data: {data}"); - println!("encrypted: {encrypted}"); - println!("decrypted: {decrypted}"); - assert_eq!(data, decrypted); - assert_eq!(version, &encrypted[..2]); - assert!(succ); - assert!(!store); - let (_, _, store) = decrypt_str_or_original(&encrypted, "99"); - assert!(store); - assert!(!decrypt_str_or_original(&decrypted, version).1); - assert_eq!( - encrypt_str_or_original(&encrypted, version, max_len), - encrypted - ); - - println!("test vec"); - let data: Vec = "1ü1111".as_bytes().to_vec(); - let encrypted = encrypt_vec_or_original(&data, version, max_len); - let (decrypted, succ, store) = decrypt_vec_or_original(&encrypted, version); - println!("data: {data:?}"); - println!("encrypted: {encrypted:?}"); - println!("decrypted: {decrypted:?}"); - assert_eq!(data, decrypted); - assert_eq!(version.as_bytes(), &encrypted[..2]); - assert!(!store); - assert!(succ); - let (_, _, store) = decrypt_vec_or_original(&encrypted, "99"); - assert!(store); - assert!(!decrypt_vec_or_original(&decrypted, version).1); - assert_eq!( - encrypt_vec_or_original(&encrypted, version, max_len), - encrypted - ); - - println!("test original"); - let data = version.to_string() + "Hello World"; - let (decrypted, succ, store) = decrypt_str_or_original(&data, version); - assert_eq!(data, decrypted); - assert!(store); - assert!(!succ); - let verbytes = version.as_bytes(); - let data: Vec = vec![verbytes[0], verbytes[1], 1, 2, 3, 4, 5, 6]; - let (decrypted, succ, store) = decrypt_vec_or_original(&data, version); - assert_eq!(data, decrypted); - assert!(store); - assert!(!succ); - let (_, succ, store) = decrypt_str_or_original("", version); - assert!(!store); - assert!(!succ); - let (_, succ, store) = decrypt_vec_or_original(&[], version); - assert!(!store); - assert!(!succ); - let data = "1ü1111"; - assert_eq!(decrypt_str_or_original(data, version).0, data); - let data: Vec = "1ü1111".as_bytes().to_vec(); - assert_eq!(decrypt_vec_or_original(&data, version).0, data); - - println!("test speed"); - let test_speed = |len: usize, name: &str| { - let mut data: Vec = vec![]; - let mut rng = thread_rng(); - for _ in 0..len { - data.push(rng.gen_range(0..255)); - } - let start: Instant = Instant::now(); - let encrypted = encrypt_vec_or_original(&data, version, len); - assert_ne!(data, decrypted); - let t1 = start.elapsed(); - let start = Instant::now(); - let (decrypted, _, _) = decrypt_vec_or_original(&encrypted, version); - let t2 = start.elapsed(); - assert_eq!(data, decrypted); - println!("{name}"); - println!("encrypt:{:?}, decrypt:{:?}", t1, t2); - - let start: Instant = Instant::now(); - let encrypted = base64::encode(&data, base64::Variant::Original); - let t1 = start.elapsed(); - let start = Instant::now(); - let decrypted = base64::decode(&encrypted, base64::Variant::Original).unwrap(); - let t2 = start.elapsed(); - assert_eq!(data, decrypted); - println!("base64, encrypt:{:?}, decrypt:{:?}", t1, t2,); - }; - test_speed(128, "128"); - test_speed(1024, "1k"); - test_speed(1024 * 1024, "1M"); - test_speed(10 * 1024 * 1024, "10M"); - test_speed(100 * 1024 * 1024, "100M"); - } -} diff --git a/libs/hbb_common/src/platform/linux.rs b/libs/hbb_common/src/platform/linux.rs deleted file mode 100644 index 60c8714d8..000000000 --- a/libs/hbb_common/src/platform/linux.rs +++ /dev/null @@ -1,300 +0,0 @@ -use crate::ResultType; -use std::{collections::HashMap, process::Command}; - -lazy_static::lazy_static! { - pub static ref DISTRO: Distro = Distro::new(); -} - -pub const DISPLAY_SERVER_WAYLAND: &str = "wayland"; -pub const DISPLAY_SERVER_X11: &str = "x11"; -pub const DISPLAY_DESKTOP_KDE: &str = "KDE"; - -pub const XDG_CURRENT_DESKTOP: &str = "XDG_CURRENT_DESKTOP"; - -pub struct Distro { - pub name: String, - pub version_id: String, -} - -impl Distro { - fn new() -> Self { - let name = run_cmds("awk -F'=' '/^NAME=/ {print $2}' /etc/os-release") - .unwrap_or_default() - .trim() - .trim_matches('"') - .to_string(); - let version_id = run_cmds("awk -F'=' '/^VERSION_ID=/ {print $2}' /etc/os-release") - .unwrap_or_default() - .trim() - .trim_matches('"') - .to_string(); - Self { name, version_id } - } -} - -#[inline] -pub fn is_kde() -> bool { - if let Ok(env) = std::env::var(XDG_CURRENT_DESKTOP) { - env == DISPLAY_DESKTOP_KDE - } else { - false - } -} - -#[inline] -pub fn is_gdm_user(username: &str) -> bool { - username == "gdm" - // || username == "lightgdm" -} - -#[inline] -pub fn is_desktop_wayland() -> bool { - get_display_server() == DISPLAY_SERVER_WAYLAND -} - -#[inline] -pub fn is_x11_or_headless() -> bool { - !is_desktop_wayland() -} - -// -1 -const INVALID_SESSION: &str = "4294967295"; - -pub fn get_display_server() -> String { - // Check for forced display server environment variable first - if let Ok(forced_display) = std::env::var("RUSTDESK_FORCED_DISPLAY_SERVER") { - return forced_display; - } - - // Check if `loginctl` can be called successfully - if run_loginctl(None).is_err() { - return DISPLAY_SERVER_X11.to_owned(); - } - - let mut session = get_values_of_seat0(&[0])[0].clone(); - if session.is_empty() { - // loginctl has not given the expected output. try something else. - if let Ok(sid) = std::env::var("XDG_SESSION_ID") { - // could also execute "cat /proc/self/sessionid" - session = sid; - } - if session.is_empty() { - session = run_cmds("cat /proc/self/sessionid").unwrap_or_default(); - if session == INVALID_SESSION { - session = "".to_owned(); - } - } - } - if session.is_empty() { - std::env::var("XDG_SESSION_TYPE").unwrap_or("x11".to_owned()) - } else { - get_display_server_of_session(&session) - } -} - -pub fn get_display_server_of_session(session: &str) -> String { - let mut display_server = if let Ok(output) = - run_loginctl(Some(vec!["show-session", "-p", "Type", session])) - // Check session type of the session - { - String::from_utf8_lossy(&output.stdout) - .replace("Type=", "") - .trim_end() - .into() - } else { - "".to_owned() - }; - if display_server.is_empty() || display_server == "tty" { - if let Ok(sestype) = std::env::var("XDG_SESSION_TYPE") { - if !sestype.is_empty() { - return sestype.to_lowercase(); - } - } - display_server = "x11".to_owned(); - } - display_server.to_lowercase() -} - -#[inline] -fn line_values(indices: &[usize], line: &str) -> Vec { - indices - .into_iter() - .map(|idx| line.split_whitespace().nth(*idx).unwrap_or("").to_owned()) - .collect::>() -} - -#[inline] -pub fn get_values_of_seat0(indices: &[usize]) -> Vec { - _get_values_of_seat0(indices, true) -} - -#[inline] -pub fn get_values_of_seat0_with_gdm_wayland(indices: &[usize]) -> Vec { - _get_values_of_seat0(indices, false) -} - -// Ignore "3 sessions listed." -fn ignore_loginctl_line(line: &str) -> bool { - line.contains("sessions") || line.split(" ").count() < 4 -} - -fn _get_values_of_seat0(indices: &[usize], ignore_gdm_wayland: bool) -> Vec { - if let Ok(output) = run_loginctl(None) { - for line in String::from_utf8_lossy(&output.stdout).lines() { - if ignore_loginctl_line(line) { - continue; - } - if line.contains("seat0") { - if let Some(sid) = line.split_whitespace().next() { - if is_active(sid) { - if ignore_gdm_wayland { - if is_gdm_user(line.split_whitespace().nth(2).unwrap_or("")) - && get_display_server_of_session(sid) == DISPLAY_SERVER_WAYLAND - { - continue; - } - } - return line_values(indices, line); - } - } - } - } - - // some case, there is no seat0 https://github.com/rustdesk/rustdesk/issues/73 - for line in String::from_utf8_lossy(&output.stdout).lines() { - if ignore_loginctl_line(line) { - continue; - } - if let Some(sid) = line.split_whitespace().next() { - if is_active(sid) { - let d = get_display_server_of_session(sid); - if ignore_gdm_wayland { - if is_gdm_user(line.split_whitespace().nth(2).unwrap_or("")) - && d == DISPLAY_SERVER_WAYLAND - { - continue; - } - } - if d == "tty" { - continue; - } - return line_values(indices, line); - } - } - } - } - - line_values(indices, "") -} - -pub fn is_active(sid: &str) -> bool { - if let Ok(output) = run_loginctl(Some(vec!["show-session", "-p", "State", sid])) { - String::from_utf8_lossy(&output.stdout).contains("active") - } else { - false - } -} - -pub fn is_active_and_seat0(sid: &str) -> bool { - if let Ok(output) = run_loginctl(Some(vec!["show-session", sid])) { - String::from_utf8_lossy(&output.stdout).contains("State=active") - && String::from_utf8_lossy(&output.stdout).contains("Seat=seat0") - } else { - false - } -} - -// **Note** that the return value here, the last character is '\n'. -// Use `run_cmds_trim_newline()` if you want to remove '\n' at the end. -pub fn run_cmds(cmds: &str) -> ResultType { - let output = std::process::Command::new("sh") - .args(vec!["-c", cmds]) - .output()?; - Ok(String::from_utf8_lossy(&output.stdout).to_string()) -} - -pub fn run_cmds_trim_newline(cmds: &str) -> ResultType { - let output = std::process::Command::new("sh") - .args(vec!["-c", cmds]) - .output()?; - let out = String::from_utf8_lossy(&output.stdout); - Ok(if out.ends_with('\n') { - out[..out.len() - 1].to_string() - } else { - out.to_string() - }) -} - -fn run_loginctl(args: Option>) -> std::io::Result { - if std::env::var("FLATPAK_ID").is_ok() { - let mut l_args = String::from("loginctl"); - if let Some(a) = args.as_ref() { - l_args = format!("{} {}", l_args, a.join(" ")); - } - let res = std::process::Command::new("flatpak-spawn") - .args(vec![String::from("--host"), l_args]) - .output(); - if res.is_ok() { - return res; - } - } - let mut cmd = std::process::Command::new("loginctl"); - if let Some(a) = args { - return cmd.args(a).output(); - } - cmd.output() -} - -/// forever: may not work -#[cfg(target_os = "linux")] -pub fn system_message(title: &str, msg: &str, forever: bool) -> ResultType<()> { - let cmds: HashMap<&str, Vec<&str>> = HashMap::from([ - ("notify-send", [title, msg].to_vec()), - ( - "zenity", - [ - "--info", - "--timeout", - if forever { "0" } else { "3" }, - "--title", - title, - "--text", - msg, - ] - .to_vec(), - ), - ("kdialog", ["--title", title, "--msgbox", msg].to_vec()), - ( - "xmessage", - [ - "-center", - "-timeout", - if forever { "0" } else { "3" }, - title, - msg, - ] - .to_vec(), - ), - ]); - for (k, v) in cmds { - if Command::new(k).args(v).spawn().is_ok() { - return Ok(()); - } - } - crate::bail!("failed to post system message"); -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_run_cmds_trim_newline() { - assert_eq!(run_cmds_trim_newline("echo -n 123").unwrap(), "123"); - assert_eq!(run_cmds_trim_newline("echo 123").unwrap(), "123"); - assert_eq!( - run_cmds_trim_newline("whoami").unwrap() + "\n", - run_cmds("whoami").unwrap() - ); - } -} diff --git a/libs/hbb_common/src/platform/macos.rs b/libs/hbb_common/src/platform/macos.rs deleted file mode 100644 index dd83a8738..000000000 --- a/libs/hbb_common/src/platform/macos.rs +++ /dev/null @@ -1,55 +0,0 @@ -use crate::ResultType; -use osascript; -use serde_derive::{Deserialize, Serialize}; - -#[derive(Serialize)] -struct AlertParams { - title: String, - message: String, - alert_type: String, - buttons: Vec, -} - -#[derive(Deserialize)] -struct AlertResult { - #[serde(rename = "buttonReturned")] - button: String, -} - -/// Firstly run the specified app, then alert a dialog. Return the clicked button value. -/// -/// # Arguments -/// -/// * `app` - The app to execute the script. -/// * `alert_type` - Alert type. . informational, warning, critical -/// * `title` - The alert title. -/// * `message` - The alert message. -/// * `buttons` - The buttons to show. -pub fn alert( - app: String, - alert_type: String, - title: String, - message: String, - buttons: Vec, -) -> ResultType { - let script = osascript::JavaScript::new(&format!( - " - var App = Application('{}'); - App.includeStandardAdditions = true; - return App.displayAlert($params.title, {{ - message: $params.message, - 'as': $params.alert_type, - buttons: $params.buttons, - }}); - ", - app - )); - - let result: AlertResult = script.execute_with_params(AlertParams { - title, - message, - alert_type, - buttons, - })?; - Ok(result.button) -} diff --git a/libs/hbb_common/src/platform/mod.rs b/libs/hbb_common/src/platform/mod.rs deleted file mode 100644 index 5dc004a81..000000000 --- a/libs/hbb_common/src/platform/mod.rs +++ /dev/null @@ -1,81 +0,0 @@ -#[cfg(target_os = "linux")] -pub mod linux; - -#[cfg(target_os = "macos")] -pub mod macos; - -#[cfg(target_os = "windows")] -pub mod windows; - -#[cfg(not(debug_assertions))] -use crate::{config::Config, log}; -#[cfg(not(debug_assertions))] -use std::process::exit; - -#[cfg(not(debug_assertions))] -static mut GLOBAL_CALLBACK: Option> = None; - -#[cfg(not(debug_assertions))] -extern "C" fn breakdown_signal_handler(sig: i32) { - let mut stack = vec![]; - backtrace::trace(|frame| { - backtrace::resolve_frame(frame, |symbol| { - if let Some(name) = symbol.name() { - stack.push(name.to_string()); - } - }); - true // keep going to the next frame - }); - let mut info = String::default(); - if stack.iter().any(|s| { - s.contains(&"nouveau_pushbuf_kick") - || s.to_lowercase().contains("nvidia") - || s.contains("gdk_window_end_draw_frame") - || s.contains("glGetString") - }) { - Config::set_option("allow-always-software-render".to_string(), "Y".to_string()); - info = "Always use software rendering will be set.".to_string(); - log::info!("{}", info); - } - if stack.iter().any(|s| { - s.to_lowercase().contains("nvidia") - || s.to_lowercase().contains("amf") - || s.to_lowercase().contains("mfx") - || s.contains("cuProfilerStop") - }) { - Config::set_option("enable-hwcodec".to_string(), "N".to_string()); - info = "Perhaps hwcodec causing the crash, disable it first".to_string(); - log::info!("{}", info); - } - log::error!( - "Got signal {} and exit. stack:\n{}", - sig, - stack.join("\n").to_string() - ); - if !info.is_empty() { - #[cfg(target_os = "linux")] - linux::system_message( - "RustDesk", - &format!("Got signal {} and exit.{}", sig, info), - true, - ) - .ok(); - } - unsafe { - if let Some(callback) = &GLOBAL_CALLBACK { - callback() - } - } - exit(0); -} - -#[cfg(not(debug_assertions))] -pub fn register_breakdown_handler(callback: T) -where - T: Fn() + 'static, -{ - unsafe { - GLOBAL_CALLBACK = Some(Box::new(callback)); - libc::signal(libc::SIGSEGV, breakdown_signal_handler as _); - } -} diff --git a/libs/hbb_common/src/platform/windows.rs b/libs/hbb_common/src/platform/windows.rs deleted file mode 100644 index 7481631ac..000000000 --- a/libs/hbb_common/src/platform/windows.rs +++ /dev/null @@ -1,198 +0,0 @@ -use std::{ - collections::VecDeque, - sync::{Arc, Mutex}, - time::Instant, -}; -use winapi::{ - shared::minwindef::{DWORD, FALSE, TRUE}, - um::{ - handleapi::CloseHandle, - pdh::{ - PdhAddEnglishCounterA, PdhCloseQuery, PdhCollectQueryData, PdhCollectQueryDataEx, - PdhGetFormattedCounterValue, PdhOpenQueryA, PDH_FMT_COUNTERVALUE, PDH_FMT_DOUBLE, - PDH_HCOUNTER, PDH_HQUERY, - }, - synchapi::{CreateEventA, WaitForSingleObject}, - sysinfoapi::VerSetConditionMask, - winbase::{VerifyVersionInfoW, INFINITE, WAIT_OBJECT_0}, - winnt::{ - HANDLE, OSVERSIONINFOEXW, VER_BUILDNUMBER, VER_GREATER_EQUAL, VER_MAJORVERSION, - VER_MINORVERSION, VER_SERVICEPACKMAJOR, VER_SERVICEPACKMINOR, - }, - }, -}; - -lazy_static::lazy_static! { - static ref CPU_USAGE_ONE_MINUTE: Arc>> = Arc::new(Mutex::new(None)); -} - -// https://github.com/mgostIH/process_list/blob/master/src/windows/mod.rs -#[repr(transparent)] -pub struct RAIIHandle(pub HANDLE); - -impl Drop for RAIIHandle { - fn drop(&mut self) { - // This never gives problem except when running under a debugger. - unsafe { CloseHandle(self.0) }; - } -} - -#[repr(transparent)] -pub(self) struct RAIIPDHQuery(pub PDH_HQUERY); - -impl Drop for RAIIPDHQuery { - fn drop(&mut self) { - unsafe { PdhCloseQuery(self.0) }; - } -} - -pub fn start_cpu_performance_monitor() { - // Code from: - // https://learn.microsoft.com/en-us/windows/win32/perfctrs/collecting-performance-data - // https://learn.microsoft.com/en-us/windows/win32/api/pdh/nf-pdh-pdhcollectquerydataex - // Why value lower than taskManager: - // https://aaron-margosis.medium.com/task-managers-cpu-numbers-are-all-but-meaningless-2d165b421e43 - // Therefore we should compare with Precess Explorer rather than taskManager - - let f = || unsafe { - // load avg or cpu usage, test with prime95. - // Prefer cpu usage because we can get accurate value from Precess Explorer. - // const COUNTER_PATH: &'static str = "\\System\\Processor Queue Length\0"; - const COUNTER_PATH: &'static str = "\\Processor(_total)\\% Processor Time\0"; - const SAMPLE_INTERVAL: DWORD = 2; // 2 second - - let mut ret; - let mut query: PDH_HQUERY = std::mem::zeroed(); - ret = PdhOpenQueryA(std::ptr::null() as _, 0, &mut query); - if ret != 0 { - log::error!("PdhOpenQueryA failed: 0x{:X}", ret); - return; - } - let _query = RAIIPDHQuery(query); - let mut counter: PDH_HCOUNTER = std::mem::zeroed(); - ret = PdhAddEnglishCounterA(query, COUNTER_PATH.as_ptr() as _, 0, &mut counter); - if ret != 0 { - log::error!("PdhAddEnglishCounterA failed: 0x{:X}", ret); - return; - } - ret = PdhCollectQueryData(query); - if ret != 0 { - log::error!("PdhCollectQueryData failed: 0x{:X}", ret); - return; - } - let mut _counter_type: DWORD = 0; - let mut counter_value: PDH_FMT_COUNTERVALUE = std::mem::zeroed(); - let event = CreateEventA(std::ptr::null_mut(), FALSE, FALSE, std::ptr::null() as _); - if event.is_null() { - log::error!("CreateEventA failed"); - return; - } - let _event: RAIIHandle = RAIIHandle(event); - ret = PdhCollectQueryDataEx(query, SAMPLE_INTERVAL, event); - if ret != 0 { - log::error!("PdhCollectQueryDataEx failed: 0x{:X}", ret); - return; - } - - let mut queue: VecDeque = VecDeque::new(); - let mut recent_valid: VecDeque = VecDeque::new(); - loop { - // latest one minute - if queue.len() == 31 { - queue.pop_front(); - } - if recent_valid.len() == 31 { - recent_valid.pop_front(); - } - // allow get value within one minute - if queue.len() > 0 && recent_valid.iter().filter(|v| **v).count() > queue.len() / 2 { - let sum: f64 = queue.iter().map(|f| f.to_owned()).sum(); - let avg = sum / (queue.len() as f64); - *CPU_USAGE_ONE_MINUTE.lock().unwrap() = Some((avg, Instant::now())); - } else { - *CPU_USAGE_ONE_MINUTE.lock().unwrap() = None; - } - if WAIT_OBJECT_0 != WaitForSingleObject(event, INFINITE) { - recent_valid.push_back(false); - continue; - } - if PdhGetFormattedCounterValue( - counter, - PDH_FMT_DOUBLE, - &mut _counter_type, - &mut counter_value, - ) != 0 - || counter_value.CStatus != 0 - { - recent_valid.push_back(false); - continue; - } - queue.push_back(counter_value.u.doubleValue().clone()); - recent_valid.push_back(true); - } - }; - use std::sync::Once; - static ONCE: Once = Once::new(); - ONCE.call_once(|| { - std::thread::spawn(f); - }); -} - -pub fn cpu_uage_one_minute() -> Option { - let v = CPU_USAGE_ONE_MINUTE.lock().unwrap().clone(); - if let Some((v, instant)) = v { - if instant.elapsed().as_secs() < 30 { - return Some(v); - } - } - None -} - -pub fn sync_cpu_usage(cpu_usage: Option) { - let v = match cpu_usage { - Some(cpu_usage) => Some((cpu_usage, Instant::now())), - None => None, - }; - *CPU_USAGE_ONE_MINUTE.lock().unwrap() = v; - log::info!("cpu usage synced: {:?}", cpu_usage); -} - -// https://learn.microsoft.com/en-us/windows/win32/sysinfo/targeting-your-application-at-windows-8-1 -// https://github.com/nodejs/node-convergence-archive/blob/e11fe0c2777561827cdb7207d46b0917ef3c42a7/deps/uv/src/win/util.c#L780 -pub fn is_windows_version_or_greater( - os_major: u32, - os_minor: u32, - build_number: u32, - service_pack_major: u32, - service_pack_minor: u32, -) -> bool { - let mut osvi: OSVERSIONINFOEXW = unsafe { std::mem::zeroed() }; - osvi.dwOSVersionInfoSize = std::mem::size_of::() as DWORD; - osvi.dwMajorVersion = os_major as _; - osvi.dwMinorVersion = os_minor as _; - osvi.dwBuildNumber = build_number as _; - osvi.wServicePackMajor = service_pack_major as _; - osvi.wServicePackMinor = service_pack_minor as _; - - let result = unsafe { - let mut condition_mask = 0; - let op = VER_GREATER_EQUAL; - condition_mask = VerSetConditionMask(condition_mask, VER_MAJORVERSION, op); - condition_mask = VerSetConditionMask(condition_mask, VER_MINORVERSION, op); - condition_mask = VerSetConditionMask(condition_mask, VER_BUILDNUMBER, op); - condition_mask = VerSetConditionMask(condition_mask, VER_SERVICEPACKMAJOR, op); - condition_mask = VerSetConditionMask(condition_mask, VER_SERVICEPACKMINOR, op); - - VerifyVersionInfoW( - &mut osvi as *mut OSVERSIONINFOEXW, - VER_MAJORVERSION - | VER_MINORVERSION - | VER_BUILDNUMBER - | VER_SERVICEPACKMAJOR - | VER_SERVICEPACKMINOR, - condition_mask, - ) - }; - - result == TRUE -} diff --git a/libs/hbb_common/src/protos/mod.rs b/libs/hbb_common/src/protos/mod.rs deleted file mode 100644 index 57d9b68fe..000000000 --- a/libs/hbb_common/src/protos/mod.rs +++ /dev/null @@ -1 +0,0 @@ -include!(concat!(env!("OUT_DIR"), "/protos/mod.rs")); diff --git a/libs/hbb_common/src/proxy.rs b/libs/hbb_common/src/proxy.rs deleted file mode 100644 index 34d2c5109..000000000 --- a/libs/hbb_common/src/proxy.rs +++ /dev/null @@ -1,561 +0,0 @@ -use std::{ - io::Error as IoError, - net::{SocketAddr, ToSocketAddrs}, -}; - -use base64::{engine::general_purpose, Engine}; -use httparse::{Error as HttpParseError, Response, EMPTY_HEADER}; -use log::info; -use thiserror::Error as ThisError; -use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt, BufStream}; -#[cfg(any(target_os = "windows", target_os = "macos"))] -use tokio_native_tls::{native_tls, TlsConnector, TlsStream}; -#[cfg(not(any(target_os = "windows", target_os = "macos")))] -use tokio_rustls::{client::TlsStream, TlsConnector}; -use tokio_socks::{tcp::Socks5Stream, IntoTargetAddr}; -use tokio_util::codec::Framed; -use url::Url; - -use crate::{ - bytes_codec::BytesCodec, - config::Socks5Server, - tcp::{DynTcpStream, FramedStream}, - ResultType, -}; - -#[derive(Debug, ThisError)] -pub enum ProxyError { - #[error("IO Error: {0}")] - IoError(#[from] IoError), - #[error("Target parse error: {0}")] - TargetParseError(String), - #[error("HTTP parse error: {0}")] - HttpParseError(#[from] HttpParseError), - #[error("The maximum response header length is exceeded: {0}")] - MaximumResponseHeaderLengthExceeded(usize), - #[error("The end of file is reached")] - EndOfFile, - #[error("The url is error: {0}")] - UrlBadScheme(String), - #[error("The url parse error: {0}")] - UrlParseScheme(#[from] url::ParseError), - #[error("No HTTP code was found in the response")] - NoHttpCode, - #[error("The HTTP code is not equal 200: {0}")] - HttpCode200(u16), - #[error("The proxy address resolution failed: {0}")] - AddressResolutionFailed(String), - #[cfg(any(target_os = "windows", target_os = "macos"))] - #[error("The native tls error: {0}")] - NativeTlsError(#[from] tokio_native_tls::native_tls::Error), -} - -const MAXIMUM_RESPONSE_HEADER_LENGTH: usize = 4096; -/// The maximum HTTP Headers, which can be parsed. -const MAXIMUM_RESPONSE_HEADERS: usize = 16; -const DEFINE_TIME_OUT: u64 = 600; - -pub trait IntoUrl { - - // Besides parsing as a valid `Url`, the `Url` must be a valid - // `http::Uri`, in that it makes sense to use in a network request. - fn into_url(self) -> Result; - - fn as_str(&self) -> &str; -} - -impl IntoUrl for Url { - fn into_url(self) -> Result { - if self.has_host() { - Ok(self) - } else { - Err(ProxyError::UrlBadScheme(self.to_string())) - } - } - - fn as_str(&self) -> &str { - self.as_ref() - } -} - -impl<'a> IntoUrl for &'a str { - fn into_url(self) -> Result { - Url::parse(self) - .map_err(ProxyError::UrlParseScheme)? - .into_url() - } - - fn as_str(&self) -> &str { - self - } -} - -impl<'a> IntoUrl for &'a String { - fn into_url(self) -> Result { - (&**self).into_url() - } - - fn as_str(&self) -> &str { - self.as_ref() - } -} - -impl<'a> IntoUrl for String { - fn into_url(self) -> Result { - (&*self).into_url() - } - - fn as_str(&self) -> &str { - self.as_ref() - } -} - -#[derive(Clone)] -pub struct Auth { - user_name: String, - password: String, -} - -impl Auth { - fn get_proxy_authorization(&self) -> String { - format!( - "Proxy-Authorization: Basic {}\r\n", - self.get_basic_authorization() - ) - } - - pub fn get_basic_authorization(&self) -> String { - let authorization = format!("{}:{}", &self.user_name, &self.password); - general_purpose::STANDARD.encode(authorization.as_bytes()) - } -} - -#[derive(Clone)] -pub enum ProxyScheme { - Http { - auth: Option, - host: String, - }, - Https { - auth: Option, - host: String, - }, - Socks5 { - addr: SocketAddr, - auth: Option, - remote_dns: bool, - }, -} - -impl ProxyScheme { - pub fn maybe_auth(&self) -> Option<&Auth> { - match self { - ProxyScheme::Http { auth, .. } - | ProxyScheme::Https { auth, .. } - | ProxyScheme::Socks5 { auth, .. } => auth.as_ref(), - } - } - - fn socks5(addr: SocketAddr) -> Result { - Ok(ProxyScheme::Socks5 { - addr, - auth: None, - remote_dns: false, - }) - } - - fn http(host: &str) -> Result { - Ok(ProxyScheme::Http { - auth: None, - host: host.to_string(), - }) - } - fn https(host: &str) -> Result { - Ok(ProxyScheme::Https { - auth: None, - host: host.to_string(), - }) - } - - fn set_basic_auth, U: Into>(&mut self, username: T, password: U) { - let auth = Auth { - user_name: username.into(), - password: password.into(), - }; - match self { - ProxyScheme::Http { auth: a, .. } => *a = Some(auth), - ProxyScheme::Https { auth: a, .. } => *a = Some(auth), - ProxyScheme::Socks5 { auth: a, .. } => *a = Some(auth), - } - } - - fn parse(url: Url) -> Result { - use url::Position; - - // Resolve URL to a host and port - let to_addr = || { - let addrs = url.socket_addrs(|| match url.scheme() { - "socks5" => Some(1080), - _ => None, - })?; - addrs - .into_iter() - .next() - .ok_or_else(|| ProxyError::UrlParseScheme(url::ParseError::EmptyHost)) - }; - - let mut scheme: Self = match url.scheme() { - "http" => Self::http(&url[Position::BeforeHost..Position::AfterPort])?, - "https" => Self::https(&url[Position::BeforeHost..Position::AfterPort])?, - "socks5" => Self::socks5(to_addr()?)?, - e => return Err(ProxyError::UrlBadScheme(e.to_string())), - }; - - if let Some(pwd) = url.password() { - let username = url.username(); - scheme.set_basic_auth(username, pwd); - } - - Ok(scheme) - } - pub async fn socket_addrs(&self) -> Result { - info!("Resolving socket address"); - match self { - ProxyScheme::Http { host, .. } => self.resolve_host(host, 80).await, - ProxyScheme::Https { host, .. } => self.resolve_host(host, 443).await, - ProxyScheme::Socks5 { addr, .. } => Ok(addr.clone()), - } - } - - async fn resolve_host(&self, host: &str, default_port: u16) -> Result { - let (host_str, port) = match host.split_once(':') { - Some((h, p)) => (h, p.parse::().ok()), - None => (host, None), - }; - let addr = (host_str, port.unwrap_or(default_port)) - .to_socket_addrs()? - .next() - .ok_or_else(|| ProxyError::AddressResolutionFailed(host.to_string()))?; - Ok(addr) - } - - pub fn get_domain(&self) -> Result { - match self { - ProxyScheme::Http { host, .. } | ProxyScheme::Https { host, .. } => { - let domain = host - .split(':') - .next() - .ok_or_else(|| ProxyError::AddressResolutionFailed(host.clone()))?; - Ok(domain.to_string()) - } - ProxyScheme::Socks5 { addr, .. } => match addr { - SocketAddr::V4(addr_v4) => Ok(addr_v4.ip().to_string()), - SocketAddr::V6(addr_v6) => Ok(addr_v6.ip().to_string()), - }, - } - } - pub fn get_host_and_port(&self) -> Result { - match self { - ProxyScheme::Http { host, .. } => Ok(self.append_default_port(host, 80)), - ProxyScheme::Https { host, .. } => Ok(self.append_default_port(host, 443)), - ProxyScheme::Socks5 { addr, .. } => Ok(format!("{}", addr)), - } - } - fn append_default_port(&self, host: &str, default_port: u16) -> String { - if host.contains(':') { - host.to_string() - } else { - format!("{}:{}", host, default_port) - } - } -} - -pub trait IntoProxyScheme { - fn into_proxy_scheme(self) -> Result; -} - -impl IntoProxyScheme for S { - fn into_proxy_scheme(self) -> Result { - // validate the URL - let url = match self.as_str().into_url() { - Ok(ok) => ok, - Err(e) => { - match e { - // If the string does not contain protocol headers, try to parse it using the socks5 protocol - ProxyError::UrlParseScheme(_source) => { - let try_this = format!("socks5://{}", self.as_str()); - try_this.into_url()? - } - _ => { - return Err(e); - } - } - } - }; - ProxyScheme::parse(url) - } -} - -impl IntoProxyScheme for ProxyScheme { - fn into_proxy_scheme(self) -> Result { - Ok(self) - } -} - -#[derive(Clone)] -pub struct Proxy { - pub intercept: ProxyScheme, - ms_timeout: u64, -} - -impl Proxy { - pub fn new(proxy_scheme: U, ms_timeout: u64) -> Result { - Ok(Self { - intercept: proxy_scheme.into_proxy_scheme()?, - ms_timeout, - }) - } - - pub fn is_http_or_https(&self) -> bool { - return match self.intercept { - ProxyScheme::Socks5 { .. } => false, - _ => true, - }; - } - - pub fn from_conf(conf: &Socks5Server, ms_timeout: Option) -> Result { - let mut proxy; - match ms_timeout { - None => { - proxy = Self::new(&conf.proxy, DEFINE_TIME_OUT)?; - } - Some(time_out) => { - proxy = Self::new(&conf.proxy, time_out)?; - } - } - - if !conf.password.is_empty() && !conf.username.is_empty() { - proxy = proxy.basic_auth(&conf.username, &conf.password); - } - Ok(proxy) - } - - pub async fn proxy_addrs(&self) -> Result { - self.intercept.socket_addrs().await - } - - fn basic_auth(mut self, username: &str, password: &str) -> Proxy { - self.intercept.set_basic_auth(username, password); - self - } - - pub async fn connect<'t, T>( - self, - target: T, - local_addr: Option, - ) -> ResultType - where - T: IntoTargetAddr<'t>, - { - info!("Connect to proxy server"); - let proxy = self.proxy_addrs().await?; - - let local = if let Some(addr) = local_addr { - addr - } else { - crate::config::Config::get_any_listen_addr(proxy.is_ipv4()) - }; - - let stream = super::timeout( - self.ms_timeout, - crate::tcp::new_socket(local, true)?.connect(proxy), - ) - .await??; - stream.set_nodelay(true).ok(); - - let addr = stream.local_addr()?; - - return match self.intercept { - ProxyScheme::Http { .. } => { - info!("Connect to remote http proxy server: {}", proxy); - let stream = - super::timeout(self.ms_timeout, self.http_connect(stream, target)).await??; - Ok(FramedStream( - Framed::new(DynTcpStream(Box::new(stream)), BytesCodec::new()), - addr, - None, - 0, - )) - } - ProxyScheme::Https { .. } => { - info!("Connect to remote https proxy server: {}", proxy); - let stream = - super::timeout(self.ms_timeout, self.https_connect(stream, target)).await??; - Ok(FramedStream( - Framed::new(DynTcpStream(Box::new(stream)), BytesCodec::new()), - addr, - None, - 0, - )) - } - ProxyScheme::Socks5 { .. } => { - info!("Connect to remote socket5 proxy server: {}", proxy); - let stream = if let Some(auth) = self.intercept.maybe_auth() { - super::timeout( - self.ms_timeout, - Socks5Stream::connect_with_password_and_socket( - stream, - target, - &auth.user_name, - &auth.password, - ), - ) - .await?? - } else { - super::timeout( - self.ms_timeout, - Socks5Stream::connect_with_socket(stream, target), - ) - .await?? - }; - Ok(FramedStream( - Framed::new(DynTcpStream(Box::new(stream)), BytesCodec::new()), - addr, - None, - 0, - )) - } - }; - } - - #[cfg(any(target_os = "windows", target_os = "macos"))] - pub async fn https_connect<'a, Input, T>( - self, - io: Input, - target: T, - ) -> Result>, ProxyError> - where - Input: AsyncRead + AsyncWrite + Unpin, - T: IntoTargetAddr<'a>, - { - let tls_connector = TlsConnector::from(native_tls::TlsConnector::new()?); - let stream = tls_connector - .connect(&self.intercept.get_domain()?, io) - .await?; - self.http_connect(stream, target).await - } - - #[cfg(not(any(target_os = "windows", target_os = "macos")))] - pub async fn https_connect<'a, Input, T>( - self, - io: Input, - target: T, - ) -> Result>, ProxyError> - where - Input: AsyncRead + AsyncWrite + Unpin, - T: IntoTargetAddr<'a>, - { - use std::convert::TryFrom; - let verifier = rustls_platform_verifier::tls_config(); - let url_domain = self.intercept.get_domain()?; - - let domain = rustls_pki_types::ServerName::try_from(url_domain.as_str()) - .map_err(|e| ProxyError::AddressResolutionFailed(e.to_string()))? - .to_owned(); - - let tls_connector = TlsConnector::from(std::sync::Arc::new(verifier)); - let stream = tls_connector.connect(domain, io).await?; - self.http_connect(stream, target).await - } - - pub async fn http_connect<'a, Input, T>( - self, - io: Input, - target: T, - ) -> Result, ProxyError> - where - Input: AsyncRead + AsyncWrite + Unpin, - T: IntoTargetAddr<'a>, - { - let mut stream = BufStream::new(io); - let (domain, port) = get_domain_and_port(target)?; - - let request = self.make_request(&domain, port); - stream.write_all(request.as_bytes()).await?; - stream.flush().await?; - recv_and_check_response(&mut stream).await?; - Ok(stream) - } - - fn make_request(&self, host: &str, port: u16) -> String { - let mut request = format!( - "CONNECT {host}:{port} HTTP/1.1\r\nHost: {host}:{port}\r\n", - host = host, - port = port - ); - - if let Some(auth) = self.intercept.maybe_auth() { - request = format!("{}{}", request, auth.get_proxy_authorization()); - } - - request.push_str("\r\n"); - request - } -} - -fn get_domain_and_port<'a, T: IntoTargetAddr<'a>>(target: T) -> Result<(String, u16), ProxyError> { - let target_addr = target - .into_target_addr() - .map_err(|e| ProxyError::TargetParseError(e.to_string()))?; - match target_addr { - tokio_socks::TargetAddr::Ip(addr) => Ok((addr.ip().to_string(), addr.port())), - tokio_socks::TargetAddr::Domain(name, port) => Ok((name.to_string(), port)), - } -} - -async fn get_response(stream: &mut BufStream) -> Result -where - IO: AsyncRead + AsyncWrite + Unpin, -{ - use tokio::io::AsyncBufReadExt; - let mut response = String::new(); - - loop { - if stream.read_line(&mut response).await? == 0 { - return Err(ProxyError::EndOfFile); - } - - if MAXIMUM_RESPONSE_HEADER_LENGTH < response.len() { - return Err(ProxyError::MaximumResponseHeaderLengthExceeded( - response.len(), - )); - } - - if response.ends_with("\r\n\r\n") { - return Ok(response); - } - } -} - -async fn recv_and_check_response(stream: &mut BufStream) -> Result<(), ProxyError> -where - IO: AsyncRead + AsyncWrite + Unpin, -{ - let response_string = get_response(stream).await?; - - let mut response_headers = [EMPTY_HEADER; MAXIMUM_RESPONSE_HEADERS]; - let mut response = Response::new(&mut response_headers); - let response_bytes = response_string.into_bytes(); - response.parse(&response_bytes)?; - - return match response.code { - Some(code) => { - if code == 200 { - Ok(()) - } else { - Err(ProxyError::HttpCode200(code)) - } - } - None => Err(ProxyError::NoHttpCode), - }; -} diff --git a/libs/hbb_common/src/socket_client.rs b/libs/hbb_common/src/socket_client.rs deleted file mode 100644 index 4cb0bf204..000000000 --- a/libs/hbb_common/src/socket_client.rs +++ /dev/null @@ -1,291 +0,0 @@ -use crate::{ - config::{Config, NetworkType}, - tcp::FramedStream, - udp::FramedSocket, - ResultType, -}; -use anyhow::Context; -use std::net::SocketAddr; -use tokio::net::ToSocketAddrs; -use tokio_socks::{IntoTargetAddr, TargetAddr}; - -#[inline] -pub fn check_port(host: T, port: i32) -> String { - let host = host.to_string(); - if crate::is_ipv6_str(&host) { - if host.starts_with('[') { - return host; - } - return format!("[{host}]:{port}"); - } - if !host.contains(':') { - return format!("{host}:{port}"); - } - host -} - -#[inline] -pub fn increase_port(host: T, offset: i32) -> String { - let host = host.to_string(); - if crate::is_ipv6_str(&host) { - if host.starts_with('[') { - let tmp: Vec<&str> = host.split("]:").collect(); - if tmp.len() == 2 { - let port: i32 = tmp[1].parse().unwrap_or(0); - if port > 0 { - return format!("{}]:{}", tmp[0], port + offset); - } - } - } - } else if host.contains(':') { - let tmp: Vec<&str> = host.split(':').collect(); - if tmp.len() == 2 { - let port: i32 = tmp[1].parse().unwrap_or(0); - if port > 0 { - return format!("{}:{}", tmp[0], port + offset); - } - } - } - host -} - -pub fn test_if_valid_server(host: &str, test_with_proxy: bool) -> String { - let host = check_port(host, 0); - use std::net::ToSocketAddrs; - - if test_with_proxy && NetworkType::ProxySocks == Config::get_network_type() { - test_if_valid_server_for_proxy_(&host) - } else { - match host.to_socket_addrs() { - Err(err) => err.to_string(), - Ok(_) => "".to_owned(), - } - } -} - -#[inline] -pub fn test_if_valid_server_for_proxy_(host: &str) -> String { - // `&host.into_target_addr()` is defined in `tokio-socs`, but is a common pattern for testing, - // it can be used for both `socks` and `http` proxy. - match &host.into_target_addr() { - Err(err) => err.to_string(), - Ok(_) => "".to_owned(), - } -} - -pub trait IsResolvedSocketAddr { - fn resolve(&self) -> Option<&SocketAddr>; -} - -impl IsResolvedSocketAddr for SocketAddr { - fn resolve(&self) -> Option<&SocketAddr> { - Some(self) - } -} - -impl IsResolvedSocketAddr for String { - fn resolve(&self) -> Option<&SocketAddr> { - None - } -} - -impl IsResolvedSocketAddr for &str { - fn resolve(&self) -> Option<&SocketAddr> { - None - } -} - -#[inline] -pub async fn connect_tcp< - 't, - T: IntoTargetAddr<'t> + ToSocketAddrs + IsResolvedSocketAddr + std::fmt::Display, ->( - target: T, - ms_timeout: u64, -) -> ResultType { - connect_tcp_local(target, None, ms_timeout).await -} - -pub async fn connect_tcp_local< - 't, - T: IntoTargetAddr<'t> + ToSocketAddrs + IsResolvedSocketAddr + std::fmt::Display, ->( - target: T, - local: Option, - ms_timeout: u64, -) -> ResultType { - if let Some(conf) = Config::get_socks() { - return FramedStream::connect(target, local, &conf, ms_timeout).await; - } - if let Some(target) = target.resolve() { - if let Some(local) = local { - if local.is_ipv6() && target.is_ipv4() { - let target = query_nip_io(target).await?; - return FramedStream::new(target, Some(local), ms_timeout).await; - } - } - } - FramedStream::new(target, local, ms_timeout).await -} - -#[inline] -pub fn is_ipv4(target: &TargetAddr<'_>) -> bool { - match target { - TargetAddr::Ip(addr) => addr.is_ipv4(), - _ => true, - } -} - -#[inline] -pub async fn query_nip_io(addr: &SocketAddr) -> ResultType { - tokio::net::lookup_host(format!("{}.nip.io:{}", addr.ip(), addr.port())) - .await? - .find(|x| x.is_ipv6()) - .context("Failed to get ipv6 from nip.io") -} - -#[inline] -pub fn ipv4_to_ipv6(addr: String, ipv4: bool) -> String { - if !ipv4 && crate::is_ipv4_str(&addr) { - if let Some(ip) = addr.split(':').next() { - return addr.replace(ip, &format!("{ip}.nip.io")); - } - } - addr -} - -async fn test_target(target: &str) -> ResultType { - if let Ok(Ok(s)) = super::timeout(1000, tokio::net::TcpStream::connect(target)).await { - if let Ok(addr) = s.peer_addr() { - return Ok(addr); - } - } - tokio::net::lookup_host(target) - .await? - .next() - .context(format!("Failed to look up host for {target}")) -} - -#[inline] -pub async fn new_udp_for( - target: &str, - ms_timeout: u64, -) -> ResultType<(FramedSocket, TargetAddr<'static>)> { - let (ipv4, target) = if NetworkType::Direct == Config::get_network_type() { - let addr = test_target(target).await?; - (addr.is_ipv4(), addr.into_target_addr()?) - } else { - (true, target.into_target_addr()?) - }; - Ok(( - new_udp(Config::get_any_listen_addr(ipv4), ms_timeout).await?, - target.to_owned(), - )) -} - -async fn new_udp(local: T, ms_timeout: u64) -> ResultType { - match Config::get_socks() { - None => Ok(FramedSocket::new(local).await?), - Some(conf) => { - let socket = FramedSocket::new_proxy( - conf.proxy.as_str(), - local, - conf.username.as_str(), - conf.password.as_str(), - ms_timeout, - ) - .await?; - Ok(socket) - } - } -} - -pub async fn rebind_udp_for( - target: &str, -) -> ResultType)>> { - if Config::get_network_type() != NetworkType::Direct { - return Ok(None); - } - let addr = test_target(target).await?; - let v4 = addr.is_ipv4(); - Ok(Some(( - FramedSocket::new(Config::get_any_listen_addr(v4)).await?, - addr.into_target_addr()?.to_owned(), - ))) -} - -#[cfg(test)] -mod tests { - use std::net::ToSocketAddrs; - - use super::*; - - #[test] - fn test_nat64() { - test_nat64_async(); - } - - #[tokio::main(flavor = "current_thread")] - async fn test_nat64_async() { - assert_eq!(ipv4_to_ipv6("1.1.1.1".to_owned(), true), "1.1.1.1"); - assert_eq!(ipv4_to_ipv6("1.1.1.1".to_owned(), false), "1.1.1.1.nip.io"); - assert_eq!( - ipv4_to_ipv6("1.1.1.1:8080".to_owned(), false), - "1.1.1.1.nip.io:8080" - ); - assert_eq!( - ipv4_to_ipv6("rustdesk.com".to_owned(), false), - "rustdesk.com" - ); - if ("rustdesk.com:80") - .to_socket_addrs() - .unwrap() - .next() - .unwrap() - .is_ipv6() - { - assert!(query_nip_io(&"1.1.1.1:80".parse().unwrap()) - .await - .unwrap() - .is_ipv6()); - return; - } - assert!(query_nip_io(&"1.1.1.1:80".parse().unwrap()).await.is_err()); - } - - #[test] - fn test_test_if_valid_server() { - assert!(!test_if_valid_server("a", false).is_empty()); - // on Linux, "1" is resolved to "0.0.0.1" - assert!(test_if_valid_server("1.1.1.1", false).is_empty()); - assert!(test_if_valid_server("1.1.1.1:1", false).is_empty()); - assert!(test_if_valid_server("microsoft.com", false).is_empty()); - assert!(test_if_valid_server("microsoft.com:1", false).is_empty()); - - // with proxy - // `:0` indicates `let host = check_port(host, 0);` is called. - assert!(test_if_valid_server_for_proxy_("a:0").is_empty()); - assert!(test_if_valid_server_for_proxy_("1.1.1.1:0").is_empty()); - assert!(test_if_valid_server_for_proxy_("1.1.1.1:1").is_empty()); - assert!(test_if_valid_server_for_proxy_("abc.com:0").is_empty()); - assert!(test_if_valid_server_for_proxy_("abcd.com:1").is_empty()); - } - - #[test] - fn test_check_port() { - assert_eq!(check_port("[1:2]:12", 32), "[1:2]:12"); - assert_eq!(check_port("1:2", 32), "[1:2]:32"); - assert_eq!(check_port("z1:2", 32), "z1:2"); - assert_eq!(check_port("1.1.1.1", 32), "1.1.1.1:32"); - assert_eq!(check_port("1.1.1.1:32", 32), "1.1.1.1:32"); - assert_eq!(check_port("test.com:32", 0), "test.com:32"); - assert_eq!(increase_port("[1:2]:12", 1), "[1:2]:13"); - assert_eq!(increase_port("1.2.2.4:12", 1), "1.2.2.4:13"); - assert_eq!(increase_port("1.2.2.4", 1), "1.2.2.4"); - assert_eq!(increase_port("test.com", 1), "test.com"); - assert_eq!(increase_port("test.com:13", 4), "test.com:17"); - assert_eq!(increase_port("1:13", 4), "1:13"); - assert_eq!(increase_port("22:1:13", 4), "22:1:13"); - assert_eq!(increase_port("z1:2", 1), "z1:3"); - } -} diff --git a/libs/hbb_common/src/tcp.rs b/libs/hbb_common/src/tcp.rs deleted file mode 100644 index 17f360ff9..000000000 --- a/libs/hbb_common/src/tcp.rs +++ /dev/null @@ -1,341 +0,0 @@ -use crate::{bail, bytes_codec::BytesCodec, ResultType, config::Socks5Server, proxy::Proxy}; -use anyhow::Context as AnyhowCtx; -use bytes::{BufMut, Bytes, BytesMut}; -use futures::{SinkExt, StreamExt}; -use protobuf::Message; -use sodiumoxide::crypto::{ - box_, - secretbox::{self, Key, Nonce}, -}; -use std::{ - io::{self, Error, ErrorKind}, - net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, - ops::{Deref, DerefMut}, - pin::Pin, - task::{Context, Poll}, -}; -use tokio::{ - io::{AsyncRead, AsyncWrite, ReadBuf}, - net::{lookup_host, TcpListener, TcpSocket, ToSocketAddrs}, -}; -use tokio_socks::IntoTargetAddr; -use tokio_util::codec::Framed; - -pub trait TcpStreamTrait: AsyncRead + AsyncWrite + Unpin {} -pub struct DynTcpStream(pub(crate) Box); - -#[derive(Clone)] -pub struct Encrypt(Key, u64, u64); - -pub struct FramedStream( - pub(crate) Framed, - pub(crate) SocketAddr, - pub(crate) Option, - pub(crate) u64, -); - -impl Deref for FramedStream { - type Target = Framed; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl DerefMut for FramedStream { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0 - } -} - -impl Deref for DynTcpStream { - type Target = Box; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl DerefMut for DynTcpStream { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0 - } -} - -pub(crate) fn new_socket(addr: std::net::SocketAddr, reuse: bool) -> Result { - let socket = match addr { - std::net::SocketAddr::V4(..) => TcpSocket::new_v4()?, - std::net::SocketAddr::V6(..) => TcpSocket::new_v6()?, - }; - if reuse { - // windows has no reuse_port, but it's reuse_address - // almost equals to unix's reuse_port + reuse_address, - // though may introduce nondeterministic behavior - #[cfg(unix)] - socket.set_reuseport(true).ok(); - socket.set_reuseaddr(true).ok(); - } - socket.bind(addr)?; - Ok(socket) -} - -impl FramedStream { - pub async fn new( - remote_addr: T, - local_addr: Option, - ms_timeout: u64, - ) -> ResultType { - for remote_addr in lookup_host(&remote_addr).await? { - let local = if let Some(addr) = local_addr { - addr - } else { - crate::config::Config::get_any_listen_addr(remote_addr.is_ipv4()) - }; - if let Ok(socket) = new_socket(local, true) { - if let Ok(Ok(stream)) = - super::timeout(ms_timeout, socket.connect(remote_addr)).await - { - stream.set_nodelay(true).ok(); - let addr = stream.local_addr()?; - return Ok(Self( - Framed::new(DynTcpStream(Box::new(stream)), BytesCodec::new()), - addr, - None, - 0, - )); - } - } - } - bail!(format!("Failed to connect to {remote_addr}")); - } - - pub async fn connect<'t, T>( - target: T, - local_addr: Option, - proxy_conf: &Socks5Server, - ms_timeout: u64, - ) -> ResultType - where - T: IntoTargetAddr<'t>, - { - let proxy = Proxy::from_conf(proxy_conf, Some(ms_timeout))?; - proxy.connect::(target, local_addr).await - } - - pub fn local_addr(&self) -> SocketAddr { - self.1 - } - - pub fn set_send_timeout(&mut self, ms: u64) { - self.3 = ms; - } - - pub fn from(stream: impl TcpStreamTrait + Send + Sync + 'static, addr: SocketAddr) -> Self { - Self( - Framed::new(DynTcpStream(Box::new(stream)), BytesCodec::new()), - addr, - None, - 0, - ) - } - - pub fn set_raw(&mut self) { - self.0.codec_mut().set_raw(); - self.2 = None; - } - - pub fn is_secured(&self) -> bool { - self.2.is_some() - } - - #[inline] - pub async fn send(&mut self, msg: &impl Message) -> ResultType<()> { - self.send_raw(msg.write_to_bytes()?).await - } - - #[inline] - pub async fn send_raw(&mut self, msg: Vec) -> ResultType<()> { - let mut msg = msg; - if let Some(key) = self.2.as_mut() { - msg = key.enc(&msg); - } - self.send_bytes(bytes::Bytes::from(msg)).await?; - Ok(()) - } - - #[inline] - pub async fn send_bytes(&mut self, bytes: Bytes) -> ResultType<()> { - if self.3 > 0 { - super::timeout(self.3, self.0.send(bytes)).await??; - } else { - self.0.send(bytes).await?; - } - Ok(()) - } - - #[inline] - pub async fn next(&mut self) -> Option> { - let mut res = self.0.next().await; - if let Some(Ok(bytes)) = res.as_mut() { - if let Some(key) = self.2.as_mut() { - if let Err(err) = key.dec(bytes) { - return Some(Err(err)); - } - } - } - res - } - - #[inline] - pub async fn next_timeout(&mut self, ms: u64) -> Option> { - if let Ok(res) = super::timeout(ms, self.next()).await { - res - } else { - None - } - } - - pub fn set_key(&mut self, key: Key) { - self.2 = Some(Encrypt::new(key)); - } - - fn get_nonce(seqnum: u64) -> Nonce { - let mut nonce = Nonce([0u8; secretbox::NONCEBYTES]); - nonce.0[..std::mem::size_of_val(&seqnum)].copy_from_slice(&seqnum.to_le_bytes()); - nonce - } -} - -const DEFAULT_BACKLOG: u32 = 128; - -pub async fn new_listener(addr: T, reuse: bool) -> ResultType { - if !reuse { - Ok(TcpListener::bind(addr).await?) - } else { - let addr = lookup_host(&addr) - .await? - .next() - .context("could not resolve to any address")?; - new_socket(addr, true)? - .listen(DEFAULT_BACKLOG) - .map_err(anyhow::Error::msg) - } -} - -pub async fn listen_any(port: u16) -> ResultType { - if let Ok(mut socket) = TcpSocket::new_v6() { - #[cfg(unix)] - { - socket.set_reuseport(true).ok(); - socket.set_reuseaddr(true).ok(); - use std::os::unix::io::{FromRawFd, IntoRawFd}; - let raw_fd = socket.into_raw_fd(); - let sock2 = unsafe { socket2::Socket::from_raw_fd(raw_fd) }; - sock2.set_only_v6(false).ok(); - socket = unsafe { TcpSocket::from_raw_fd(sock2.into_raw_fd()) }; - } - #[cfg(windows)] - { - use std::os::windows::prelude::{FromRawSocket, IntoRawSocket}; - let raw_socket = socket.into_raw_socket(); - let sock2 = unsafe { socket2::Socket::from_raw_socket(raw_socket) }; - sock2.set_only_v6(false).ok(); - socket = unsafe { TcpSocket::from_raw_socket(sock2.into_raw_socket()) }; - } - if socket - .bind(SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), port)) - .is_ok() - { - if let Ok(l) = socket.listen(DEFAULT_BACKLOG) { - return Ok(l); - } - } - } - Ok(new_socket( - SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), port), - true, - )? - .listen(DEFAULT_BACKLOG)?) -} - -impl Unpin for DynTcpStream {} - -impl AsyncRead for DynTcpStream { - fn poll_read( - mut self: Pin<&mut Self>, - cx: &mut Context<'_>, - buf: &mut ReadBuf<'_>, - ) -> Poll> { - AsyncRead::poll_read(Pin::new(&mut self.0), cx, buf) - } -} - -impl AsyncWrite for DynTcpStream { - fn poll_write( - mut self: Pin<&mut Self>, - cx: &mut Context<'_>, - buf: &[u8], - ) -> Poll> { - AsyncWrite::poll_write(Pin::new(&mut self.0), cx, buf) - } - - fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - AsyncWrite::poll_flush(Pin::new(&mut self.0), cx) - } - - fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - AsyncWrite::poll_shutdown(Pin::new(&mut self.0), cx) - } -} - -impl TcpStreamTrait for R {} - -impl Encrypt { - pub fn new(key: Key) -> Self { - Self(key, 0, 0) - } - - pub fn dec(&mut self, bytes: &mut BytesMut) -> Result<(), Error> { - if bytes.len() <= 1 { - return Ok(()); - } - self.2 += 1; - let nonce = FramedStream::get_nonce(self.2); - match secretbox::open(bytes, &nonce, &self.0) { - Ok(res) => { - bytes.clear(); - bytes.put_slice(&res); - Ok(()) - } - Err(()) => Err(Error::new(ErrorKind::Other, "decryption error")), - } - } - - pub fn enc(&mut self, data: &[u8]) -> Vec { - self.1 += 1; - let nonce = FramedStream::get_nonce(self.1); - secretbox::seal(&data, &nonce, &self.0) - } - - pub fn decode( - symmetric_data: &[u8], - their_pk_b: &[u8], - our_sk_b: &box_::SecretKey, - ) -> ResultType { - if their_pk_b.len() != box_::PUBLICKEYBYTES { - anyhow::bail!("Handshake failed: pk length {}", their_pk_b.len()); - } - let nonce = box_::Nonce([0u8; box_::NONCEBYTES]); - let mut pk_ = [0u8; box_::PUBLICKEYBYTES]; - pk_[..].copy_from_slice(their_pk_b); - let their_pk_b = box_::PublicKey(pk_); - let symmetric_key = box_::open(symmetric_data, &nonce, &their_pk_b, &our_sk_b) - .map_err(|_| anyhow::anyhow!("Handshake failed: box decryption failure"))?; - if symmetric_key.len() != secretbox::KEYBYTES { - anyhow::bail!("Handshake failed: invalid secret key length from peer"); - } - let mut key = [0u8; secretbox::KEYBYTES]; - key[..].copy_from_slice(&symmetric_key); - Ok(Key(key)) - } -} diff --git a/libs/hbb_common/src/udp.rs b/libs/hbb_common/src/udp.rs deleted file mode 100644 index 68abd42df..000000000 --- a/libs/hbb_common/src/udp.rs +++ /dev/null @@ -1,170 +0,0 @@ -use crate::ResultType; -use anyhow::{anyhow, Context}; -use bytes::{Bytes, BytesMut}; -use futures::{SinkExt, StreamExt}; -use protobuf::Message; -use socket2::{Domain, Socket, Type}; -use std::net::SocketAddr; -use tokio::net::{lookup_host, ToSocketAddrs, UdpSocket}; -use tokio_socks::{udp::Socks5UdpFramed, IntoTargetAddr, TargetAddr, ToProxyAddrs}; -use tokio_util::{codec::BytesCodec, udp::UdpFramed}; - -pub enum FramedSocket { - Direct(UdpFramed), - ProxySocks(Socks5UdpFramed), -} - -fn new_socket(addr: SocketAddr, reuse: bool, buf_size: usize) -> Result { - let socket = match addr { - SocketAddr::V4(..) => Socket::new(Domain::ipv4(), Type::dgram(), None), - SocketAddr::V6(..) => Socket::new(Domain::ipv6(), Type::dgram(), None), - }?; - if reuse { - // windows has no reuse_port, but it's reuse_address - // almost equals to unix's reuse_port + reuse_address, - // though may introduce nondeterministic behavior - #[cfg(unix)] - socket.set_reuse_port(true).ok(); - socket.set_reuse_address(true).ok(); - } - // only nonblocking work with tokio, https://stackoverflow.com/questions/64649405/receiver-on-tokiompscchannel-only-receives-messages-when-buffer-is-full - socket.set_nonblocking(true)?; - if buf_size > 0 { - socket.set_recv_buffer_size(buf_size).ok(); - } - log::debug!( - "Receive buf size of udp {}: {:?}", - addr, - socket.recv_buffer_size() - ); - if addr.is_ipv6() && addr.ip().is_unspecified() && addr.port() > 0 { - socket.set_only_v6(false).ok(); - } - socket.bind(&addr.into())?; - Ok(socket) -} - -impl FramedSocket { - pub async fn new(addr: T) -> ResultType { - Self::new_reuse(addr, false, 0).await - } - - pub async fn new_reuse( - addr: T, - reuse: bool, - buf_size: usize, - ) -> ResultType { - let addr = lookup_host(&addr) - .await? - .next() - .context("could not resolve to any address")?; - Ok(Self::Direct(UdpFramed::new( - UdpSocket::from_std(new_socket(addr, reuse, buf_size)?.into_udp_socket())?, - BytesCodec::new(), - ))) - } - - pub async fn new_proxy<'a, 't, P: ToProxyAddrs, T: ToSocketAddrs>( - proxy: P, - local: T, - username: &'a str, - password: &'a str, - ms_timeout: u64, - ) -> ResultType { - let framed = if username.trim().is_empty() { - super::timeout(ms_timeout, Socks5UdpFramed::connect(proxy, Some(local))).await?? - } else { - super::timeout( - ms_timeout, - Socks5UdpFramed::connect_with_password(proxy, Some(local), username, password), - ) - .await?? - }; - log::trace!( - "Socks5 udp connected, local addr: {:?}, target addr: {}", - framed.local_addr(), - framed.socks_addr() - ); - Ok(Self::ProxySocks(framed)) - } - - #[inline] - pub async fn send( - &mut self, - msg: &impl Message, - addr: impl IntoTargetAddr<'_>, - ) -> ResultType<()> { - let addr = addr.into_target_addr()?.to_owned(); - let send_data = Bytes::from(msg.write_to_bytes()?); - match self { - Self::Direct(f) => { - if let TargetAddr::Ip(addr) = addr { - f.send((send_data, addr)).await? - } - } - Self::ProxySocks(f) => f.send((send_data, addr)).await?, - }; - Ok(()) - } - - // https://stackoverflow.com/a/68733302/1926020 - #[inline] - pub async fn send_raw( - &mut self, - msg: &'static [u8], - addr: impl IntoTargetAddr<'static>, - ) -> ResultType<()> { - let addr = addr.into_target_addr()?.to_owned(); - - match self { - Self::Direct(f) => { - if let TargetAddr::Ip(addr) = addr { - f.send((Bytes::from(msg), addr)).await? - } - } - Self::ProxySocks(f) => f.send((Bytes::from(msg), addr)).await?, - }; - Ok(()) - } - - #[inline] - pub async fn next(&mut self) -> Option)>> { - match self { - Self::Direct(f) => match f.next().await { - Some(Ok((data, addr))) => { - Some(Ok((data, addr.into_target_addr().ok()?.to_owned()))) - } - Some(Err(e)) => Some(Err(anyhow!(e))), - None => None, - }, - Self::ProxySocks(f) => match f.next().await { - Some(Ok((data, _))) => Some(Ok((data.data, data.dst_addr))), - Some(Err(e)) => Some(Err(anyhow!(e))), - None => None, - }, - } - } - - #[inline] - pub async fn next_timeout( - &mut self, - ms: u64, - ) -> Option)>> { - if let Ok(res) = - tokio::time::timeout(std::time::Duration::from_millis(ms), self.next()).await - { - res - } else { - None - } - } - - pub fn local_addr(&self) -> Option { - if let FramedSocket::Direct(x) = self { - if let Ok(v) = x.get_ref().local_addr() { - return Some(v); - } - } - None - } -} diff --git a/libs/libxdo-sys-stub/Cargo.toml b/libs/libxdo-sys-stub/Cargo.toml new file mode 100644 index 000000000..0b52cfb63 --- /dev/null +++ b/libs/libxdo-sys-stub/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "libxdo-sys" +version = "0.11.0" +edition = "2021" +publish = false +description = "Dynamic loading wrapper for libxdo-sys that doesn't require libxdo at compile/link time" + +[dependencies] +hbb_common = { path = "../hbb_common" } diff --git a/libs/libxdo-sys-stub/src/lib.rs b/libs/libxdo-sys-stub/src/lib.rs new file mode 100644 index 000000000..53d0e099c --- /dev/null +++ b/libs/libxdo-sys-stub/src/lib.rs @@ -0,0 +1,505 @@ +//! Dynamic loading wrapper for libxdo. +//! +//! Provides the same API as libxdo-sys but loads libxdo at runtime, +//! allowing the program to run on systems without libxdo installed +//! (e.g., Wayland-only environments). + +use hbb_common::{ + libc::{c_char, c_int, c_uint}, + libloading::{Library, Symbol}, + log, +}; +use std::sync::OnceLock; + +pub use hbb_common::x11::xlib::{Display, Screen, Window}; + +#[repr(C)] +pub struct xdo_t { + _private: [u8; 0], +} + +#[repr(C)] +pub struct charcodemap_t { + _private: [u8; 0], +} + +#[repr(C)] +pub struct xdo_search_t { + _private: [u8; 0], +} + +pub type useconds_t = c_uint; + +pub const CURRENTWINDOW: Window = 0; + +type FnXdoNew = unsafe extern "C" fn(*const c_char) -> *mut xdo_t; +type FnXdoNewWithOpenedDisplay = + unsafe extern "C" fn(*mut Display, *const c_char, c_int) -> *mut xdo_t; +type FnXdoFree = unsafe extern "C" fn(*mut xdo_t); +type FnXdoSendKeysequenceWindow = + unsafe extern "C" fn(*const xdo_t, Window, *const c_char, useconds_t) -> c_int; +type FnXdoSendKeysequenceWindowDown = + unsafe extern "C" fn(*const xdo_t, Window, *const c_char, useconds_t) -> c_int; +type FnXdoSendKeysequenceWindowUp = + unsafe extern "C" fn(*const xdo_t, Window, *const c_char, useconds_t) -> c_int; +type FnXdoEnterTextWindow = + unsafe extern "C" fn(*const xdo_t, Window, *const c_char, useconds_t) -> c_int; +type FnXdoClickWindow = unsafe extern "C" fn(*const xdo_t, Window, c_int) -> c_int; +type FnXdoMouseDown = unsafe extern "C" fn(*const xdo_t, Window, c_int) -> c_int; +type FnXdoMouseUp = unsafe extern "C" fn(*const xdo_t, Window, c_int) -> c_int; +type FnXdoMoveMouse = unsafe extern "C" fn(*const xdo_t, c_int, c_int, c_int) -> c_int; +type FnXdoMoveMouseRelative = unsafe extern "C" fn(*const xdo_t, c_int, c_int) -> c_int; +type FnXdoMoveMouseRelativeToWindow = + unsafe extern "C" fn(*const xdo_t, Window, c_int, c_int) -> c_int; +type FnXdoGetMouseLocation = + unsafe extern "C" fn(*const xdo_t, *mut c_int, *mut c_int, *mut c_int) -> c_int; +type FnXdoGetMouseLocation2 = + unsafe extern "C" fn(*const xdo_t, *mut c_int, *mut c_int, *mut c_int, *mut Window) -> c_int; +type FnXdoGetActiveWindow = unsafe extern "C" fn(*const xdo_t, *mut Window) -> c_int; +type FnXdoGetFocusedWindow = unsafe extern "C" fn(*const xdo_t, *mut Window) -> c_int; +type FnXdoGetFocusedWindowSane = unsafe extern "C" fn(*const xdo_t, *mut Window) -> c_int; +type FnXdoGetWindowLocation = + unsafe extern "C" fn(*const xdo_t, Window, *mut c_int, *mut c_int, *mut *mut Screen) -> c_int; +type FnXdoGetWindowSize = + unsafe extern "C" fn(*const xdo_t, Window, *mut c_uint, *mut c_uint) -> c_int; +type FnXdoGetInputState = unsafe extern "C" fn(*const xdo_t) -> c_uint; +type FnXdoActivateWindow = unsafe extern "C" fn(*const xdo_t, Window) -> c_int; +type FnXdoWaitForMouseMoveFrom = unsafe extern "C" fn(*const xdo_t, c_int, c_int) -> c_int; +type FnXdoWaitForMouseMoveTo = unsafe extern "C" fn(*const xdo_t, c_int, c_int) -> c_int; +type FnXdoSetWindowClass = + unsafe extern "C" fn(*const xdo_t, Window, *const c_char, *const c_char) -> c_int; +type FnXdoSearchWindows = + unsafe extern "C" fn(*const xdo_t, *const xdo_search_t, *mut *mut Window, *mut c_uint) -> c_int; + +struct XdoLib { + _lib: Library, + xdo_new: FnXdoNew, + xdo_new_with_opened_display: Option, + xdo_free: FnXdoFree, + xdo_send_keysequence_window: FnXdoSendKeysequenceWindow, + xdo_send_keysequence_window_down: Option, + xdo_send_keysequence_window_up: Option, + xdo_enter_text_window: Option, + xdo_click_window: Option, + xdo_mouse_down: Option, + xdo_mouse_up: Option, + xdo_move_mouse: Option, + xdo_move_mouse_relative: Option, + xdo_move_mouse_relative_to_window: Option, + xdo_get_mouse_location: Option, + xdo_get_mouse_location2: Option, + xdo_get_active_window: Option, + xdo_get_focused_window: Option, + xdo_get_focused_window_sane: Option, + xdo_get_window_location: Option, + xdo_get_window_size: Option, + xdo_get_input_state: Option, + xdo_activate_window: Option, + xdo_wait_for_mouse_move_from: Option, + xdo_wait_for_mouse_move_to: Option, + xdo_set_window_class: Option, + xdo_search_windows: Option, +} + +impl XdoLib { + fn load() -> Option { + // https://github.com/rustdesk/rustdesk/issues/13711 + const LIB_NAMES: [&str; 3] = ["libxdo.so.4", "libxdo.so.3", "libxdo.so"]; + + unsafe { + let (lib, lib_name) = LIB_NAMES + .iter() + .find_map(|name| Library::new(name).ok().map(|lib| (lib, *name)))?; + + log::info!("libxdo-sys Loaded {}", lib_name); + + let xdo_new: FnXdoNew = *lib.get(b"xdo_new").ok()?; + let xdo_free: FnXdoFree = *lib.get(b"xdo_free").ok()?; + let xdo_send_keysequence_window: FnXdoSendKeysequenceWindow = + *lib.get(b"xdo_send_keysequence_window").ok()?; + + let xdo_new_with_opened_display = lib + .get(b"xdo_new_with_opened_display") + .ok() + .map(|s: Symbol| *s); + let xdo_send_keysequence_window_down = lib + .get(b"xdo_send_keysequence_window_down") + .ok() + .map(|s: Symbol| *s); + let xdo_send_keysequence_window_up = lib + .get(b"xdo_send_keysequence_window_up") + .ok() + .map(|s: Symbol| *s); + let xdo_enter_text_window = lib + .get(b"xdo_enter_text_window") + .ok() + .map(|s: Symbol| *s); + let xdo_click_window = lib + .get(b"xdo_click_window") + .ok() + .map(|s: Symbol| *s); + let xdo_mouse_down = lib + .get(b"xdo_mouse_down") + .ok() + .map(|s: Symbol| *s); + let xdo_mouse_up = lib + .get(b"xdo_mouse_up") + .ok() + .map(|s: Symbol| *s); + let xdo_move_mouse = lib + .get(b"xdo_move_mouse") + .ok() + .map(|s: Symbol| *s); + let xdo_move_mouse_relative = lib + .get(b"xdo_move_mouse_relative") + .ok() + .map(|s: Symbol| *s); + let xdo_move_mouse_relative_to_window = lib + .get(b"xdo_move_mouse_relative_to_window") + .ok() + .map(|s: Symbol| *s); + let xdo_get_mouse_location = lib + .get(b"xdo_get_mouse_location") + .ok() + .map(|s: Symbol| *s); + let xdo_get_mouse_location2 = lib + .get(b"xdo_get_mouse_location2") + .ok() + .map(|s: Symbol| *s); + let xdo_get_active_window = lib + .get(b"xdo_get_active_window") + .ok() + .map(|s: Symbol| *s); + let xdo_get_focused_window = lib + .get(b"xdo_get_focused_window") + .ok() + .map(|s: Symbol| *s); + let xdo_get_focused_window_sane = lib + .get(b"xdo_get_focused_window_sane") + .ok() + .map(|s: Symbol| *s); + let xdo_get_window_location = lib + .get(b"xdo_get_window_location") + .ok() + .map(|s: Symbol| *s); + let xdo_get_window_size = lib + .get(b"xdo_get_window_size") + .ok() + .map(|s: Symbol| *s); + let xdo_get_input_state = lib + .get(b"xdo_get_input_state") + .ok() + .map(|s: Symbol| *s); + let xdo_activate_window = lib + .get(b"xdo_activate_window") + .ok() + .map(|s: Symbol| *s); + let xdo_wait_for_mouse_move_from = lib + .get(b"xdo_wait_for_mouse_move_from") + .ok() + .map(|s: Symbol| *s); + let xdo_wait_for_mouse_move_to = lib + .get(b"xdo_wait_for_mouse_move_to") + .ok() + .map(|s: Symbol| *s); + let xdo_set_window_class = lib + .get(b"xdo_set_window_class") + .ok() + .map(|s: Symbol| *s); + let xdo_search_windows = lib + .get(b"xdo_search_windows") + .ok() + .map(|s: Symbol| *s); + + Some(Self { + _lib: lib, + xdo_new, + xdo_new_with_opened_display, + xdo_free, + xdo_send_keysequence_window, + xdo_send_keysequence_window_down, + xdo_send_keysequence_window_up, + xdo_enter_text_window, + xdo_click_window, + xdo_mouse_down, + xdo_mouse_up, + xdo_move_mouse, + xdo_move_mouse_relative, + xdo_move_mouse_relative_to_window, + xdo_get_mouse_location, + xdo_get_mouse_location2, + xdo_get_active_window, + xdo_get_focused_window, + xdo_get_focused_window_sane, + xdo_get_window_location, + xdo_get_window_size, + xdo_get_input_state, + xdo_activate_window, + xdo_wait_for_mouse_move_from, + xdo_wait_for_mouse_move_to, + xdo_set_window_class, + xdo_search_windows, + }) + } + } +} + +static XDO_LIB: OnceLock> = OnceLock::new(); + +fn get_lib() -> Option<&'static XdoLib> { + XDO_LIB + .get_or_init(|| { + let lib = XdoLib::load(); + if lib.is_none() { + log::info!("libxdo-sys libxdo not found, xdo functions will be disabled"); + } + lib + }) + .as_ref() +} + +pub unsafe extern "C" fn xdo_new(display: *const c_char) -> *mut xdo_t { + get_lib().map_or(std::ptr::null_mut(), |lib| (lib.xdo_new)(display)) +} + +pub unsafe extern "C" fn xdo_new_with_opened_display( + xdpy: *mut Display, + display: *const c_char, + close_display_when_freed: c_int, +) -> *mut xdo_t { + get_lib() + .and_then(|lib| lib.xdo_new_with_opened_display) + .map_or(std::ptr::null_mut(), |f| { + f(xdpy, display, close_display_when_freed) + }) +} + +pub unsafe extern "C" fn xdo_free(xdo: *mut xdo_t) { + if xdo.is_null() { + return; + } + if let Some(lib) = get_lib() { + (lib.xdo_free)(xdo); + } +} + +pub unsafe extern "C" fn xdo_send_keysequence_window( + xdo: *const xdo_t, + window: Window, + keysequence: *const c_char, + delay: useconds_t, +) -> c_int { + get_lib().map_or(1, |lib| { + (lib.xdo_send_keysequence_window)(xdo, window, keysequence, delay) + }) +} + +pub unsafe extern "C" fn xdo_send_keysequence_window_down( + xdo: *const xdo_t, + window: Window, + keysequence: *const c_char, + delay: useconds_t, +) -> c_int { + get_lib() + .and_then(|lib| lib.xdo_send_keysequence_window_down) + .map_or(1, |f| f(xdo, window, keysequence, delay)) +} + +pub unsafe extern "C" fn xdo_send_keysequence_window_up( + xdo: *const xdo_t, + window: Window, + keysequence: *const c_char, + delay: useconds_t, +) -> c_int { + get_lib() + .and_then(|lib| lib.xdo_send_keysequence_window_up) + .map_or(1, |f| f(xdo, window, keysequence, delay)) +} + +pub unsafe extern "C" fn xdo_enter_text_window( + xdo: *const xdo_t, + window: Window, + string: *const c_char, + delay: useconds_t, +) -> c_int { + get_lib() + .and_then(|lib| lib.xdo_enter_text_window) + .map_or(1, |f| f(xdo, window, string, delay)) +} + +pub unsafe extern "C" fn xdo_click_window( + xdo: *const xdo_t, + window: Window, + button: c_int, +) -> c_int { + get_lib() + .and_then(|lib| lib.xdo_click_window) + .map_or(1, |f| f(xdo, window, button)) +} + +pub unsafe extern "C" fn xdo_mouse_down(xdo: *const xdo_t, window: Window, button: c_int) -> c_int { + get_lib() + .and_then(|lib| lib.xdo_mouse_down) + .map_or(1, |f| f(xdo, window, button)) +} + +pub unsafe extern "C" fn xdo_mouse_up(xdo: *const xdo_t, window: Window, button: c_int) -> c_int { + get_lib() + .and_then(|lib| lib.xdo_mouse_up) + .map_or(1, |f| f(xdo, window, button)) +} + +pub unsafe extern "C" fn xdo_move_mouse( + xdo: *const xdo_t, + x: c_int, + y: c_int, + screen: c_int, +) -> c_int { + get_lib() + .and_then(|lib| lib.xdo_move_mouse) + .map_or(1, |f| f(xdo, x, y, screen)) +} + +pub unsafe extern "C" fn xdo_move_mouse_relative(xdo: *const xdo_t, x: c_int, y: c_int) -> c_int { + get_lib() + .and_then(|lib| lib.xdo_move_mouse_relative) + .map_or(1, |f| f(xdo, x, y)) +} + +pub unsafe extern "C" fn xdo_move_mouse_relative_to_window( + xdo: *const xdo_t, + window: Window, + x: c_int, + y: c_int, +) -> c_int { + get_lib() + .and_then(|lib| lib.xdo_move_mouse_relative_to_window) + .map_or(1, |f| f(xdo, window, x, y)) +} + +pub unsafe extern "C" fn xdo_get_mouse_location( + xdo: *const xdo_t, + x: *mut c_int, + y: *mut c_int, + screen_num: *mut c_int, +) -> c_int { + get_lib() + .and_then(|lib| lib.xdo_get_mouse_location) + .map_or(1, |f| f(xdo, x, y, screen_num)) +} + +pub unsafe extern "C" fn xdo_get_mouse_location2( + xdo: *const xdo_t, + x: *mut c_int, + y: *mut c_int, + screen_num: *mut c_int, + window: *mut Window, +) -> c_int { + get_lib() + .and_then(|lib| lib.xdo_get_mouse_location2) + .map_or(1, |f| f(xdo, x, y, screen_num, window)) +} + +pub unsafe extern "C" fn xdo_get_active_window( + xdo: *const xdo_t, + window_ret: *mut Window, +) -> c_int { + get_lib() + .and_then(|lib| lib.xdo_get_active_window) + .map_or(1, |f| f(xdo, window_ret)) +} + +pub unsafe extern "C" fn xdo_get_focused_window( + xdo: *const xdo_t, + window_ret: *mut Window, +) -> c_int { + get_lib() + .and_then(|lib| lib.xdo_get_focused_window) + .map_or(1, |f| f(xdo, window_ret)) +} + +pub unsafe extern "C" fn xdo_get_focused_window_sane( + xdo: *const xdo_t, + window_ret: *mut Window, +) -> c_int { + get_lib() + .and_then(|lib| lib.xdo_get_focused_window_sane) + .map_or(1, |f| f(xdo, window_ret)) +} + +pub unsafe extern "C" fn xdo_get_window_location( + xdo: *const xdo_t, + window: Window, + x: *mut c_int, + y: *mut c_int, + screen_ret: *mut *mut Screen, +) -> c_int { + get_lib() + .and_then(|lib| lib.xdo_get_window_location) + .map_or(1, |f| f(xdo, window, x, y, screen_ret)) +} + +pub unsafe extern "C" fn xdo_get_window_size( + xdo: *const xdo_t, + window: Window, + width: *mut c_uint, + height: *mut c_uint, +) -> c_int { + get_lib() + .and_then(|lib| lib.xdo_get_window_size) + .map_or(1, |f| f(xdo, window, width, height)) +} + +pub unsafe extern "C" fn xdo_get_input_state(xdo: *const xdo_t) -> c_uint { + get_lib() + .and_then(|lib| lib.xdo_get_input_state) + .map_or(0, |f| f(xdo)) +} + +pub unsafe extern "C" fn xdo_activate_window(xdo: *const xdo_t, wid: Window) -> c_int { + get_lib() + .and_then(|lib| lib.xdo_activate_window) + .map_or(1, |f| f(xdo, wid)) +} + +pub unsafe extern "C" fn xdo_wait_for_mouse_move_from( + xdo: *const xdo_t, + origin_x: c_int, + origin_y: c_int, +) -> c_int { + get_lib() + .and_then(|lib| lib.xdo_wait_for_mouse_move_from) + .map_or(1, |f| f(xdo, origin_x, origin_y)) +} + +pub unsafe extern "C" fn xdo_wait_for_mouse_move_to( + xdo: *const xdo_t, + dest_x: c_int, + dest_y: c_int, +) -> c_int { + get_lib() + .and_then(|lib| lib.xdo_wait_for_mouse_move_to) + .map_or(1, |f| f(xdo, dest_x, dest_y)) +} + +pub unsafe extern "C" fn xdo_set_window_class( + xdo: *const xdo_t, + wid: Window, + name: *const c_char, + class: *const c_char, +) -> c_int { + get_lib() + .and_then(|lib| lib.xdo_set_window_class) + .map_or(1, |f| f(xdo, wid, name, class)) +} + +pub unsafe extern "C" fn xdo_search_windows( + xdo: *const xdo_t, + search: *const xdo_search_t, + windowlist_ret: *mut *mut Window, + nwindows_ret: *mut c_uint, +) -> c_int { + get_lib() + .and_then(|lib| lib.xdo_search_windows) + .map_or(1, |f| f(xdo, search, windowlist_ret, nwindows_ret)) +} diff --git a/libs/portable/Cargo.toml b/libs/portable/Cargo.toml index 7e60f7d1f..184079be8 100644 --- a/libs/portable/Cargo.toml +++ b/libs/portable/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustdesk-portable-packer" -version = "1.3.2" +version = "1.4.6" edition = "2021" description = "RustDesk Remote Desktop" @@ -15,10 +15,18 @@ md5 = "0.7" winapi = { version = "0.3", features = ["winbase"] } [target.'cfg(target_os = "windows")'.dependencies] +windows = { version = "0.61", features = [ + "Wdk", + "Wdk_System", + "Wdk_System_SystemServices", + "Win32", + "Win32_System", + "Win32_System_SystemInformation", +] } native-windows-gui = {version = "1.0", default-features = false, features = ["animation-timer", "image-decoder"]} [package.metadata.winres] -LegalCopyright = "Copyright © 2024 Purslane Ltd. All rights reserved." +LegalCopyright = "Copyright © 2025 Purslane Ltd. All rights reserved." ProductName = "RustDesk" OriginalFilename = "rustdesk.exe" FileDescription = "RustDesk Remote Desktop" diff --git a/libs/portable/src/bin_reader.rs b/libs/portable/src/bin_reader.rs index ced5baf32..9effbc589 100644 --- a/libs/portable/src/bin_reader.rs +++ b/libs/portable/src/bin_reader.rs @@ -1,7 +1,7 @@ use std::{ fs::{self}, io::{Cursor, Read}, - path::PathBuf, + path::Path, }; #[cfg(windows)] @@ -42,7 +42,7 @@ impl BinaryData { buf } - pub fn write_to_file(&self, prefix: &PathBuf) { + pub fn write_to_file(&self, prefix: &Path) { let p = prefix.join(&self.path); if let Some(parent) = p.parent() { if !parent.exists() { @@ -122,7 +122,7 @@ impl BinaryReader { } #[cfg(linux)] - pub fn configure_permission(&self, prefix: &PathBuf) { + pub fn configure_permission(&self, prefix: &Path) { use std::os::unix::prelude::PermissionsExt; let exe_path = prefix.join(&self.exe); diff --git a/libs/portable/src/main.rs b/libs/portable/src/main.rs index 7b68d821c..b7ff44ec5 100644 --- a/libs/portable/src/main.rs +++ b/libs/portable/src/main.rs @@ -1,7 +1,7 @@ #![windows_subsystem = "windows"] use std::{ - path::PathBuf, + path::{Path, PathBuf}, process::{Command, Stdio}, }; @@ -22,7 +22,7 @@ const APPNAME_RUNTIME_ENV_KEY: &str = "RUSTDESK_APPNAME"; #[cfg(windows)] const SET_FOREGROUND_WINDOW_ENV_KEY: &str = "SET_FOREGROUND_WINDOW"; -fn is_timestamp_matches(dir: &PathBuf, ts: &mut u64) -> bool { +fn is_timestamp_matches(dir: &Path, ts: &mut u64) -> bool { let Ok(app_metadata) = std::str::from_utf8(APP_METADATA) else { return true; }; @@ -50,7 +50,7 @@ fn is_timestamp_matches(dir: &PathBuf, ts: &mut u64) -> bool { false } -fn write_meta(dir: &PathBuf, ts: u64) { +fn write_meta(dir: &Path, ts: u64) { let meta_file = dir.join(APP_METADATA_CONFIG); if ts != 0 { let content = format!("{}{}", META_LINE_PREFIX_TIMESTAMP, ts); @@ -92,12 +92,46 @@ fn setup( } write_meta(&dir, ts); #[cfg(windows)] - windows::copy_runtime_broker(&dir); + win::copy_runtime_broker(&dir); #[cfg(linux)] reader.configure_permission(&dir); Some(dir.join(&reader.exe)) } +fn use_null_stdio() -> bool { + #[cfg(windows)] + { + // When running in CMD on Windows 7, using Stdio::inherit() with spawn returns an "invalid handle" error. + // Since using Stdio::null() didn’t cause any issues, and determining whether the program is launched from CMD or by double-clicking would require calling more APIs during startup, we also use Stdio::null() when launched by double-clicking on Windows 7. + let is_windows_7 = is_windows_7(); + println!("is windows7: {}", is_windows_7); + return is_windows_7; + } + #[cfg(not(windows))] + false +} + +#[cfg(windows)] +fn is_windows_7() -> bool { + use windows::Wdk::System::SystemServices::RtlGetVersion; + use windows::Win32::System::SystemInformation::OSVERSIONINFOW; + + unsafe { + let mut version_info = OSVERSIONINFOW::default(); + version_info.dwOSVersionInfoSize = std::mem::size_of::() as u32; + + if RtlGetVersion(&mut version_info).is_ok() { + // Windows 7 is version 6.1 + println!( + "Windows version: {}.{}", + version_info.dwMajorVersion, version_info.dwMinorVersion + ); + return version_info.dwMajorVersion == 6 && version_info.dwMinorVersion == 1; + } + } + false +} + fn execute(path: PathBuf, args: Vec, _ui: bool) { println!("executing {}", path.display()); // setup env @@ -114,12 +148,18 @@ fn execute(path: PathBuf, args: Vec, _ui: bool) { cmd.env(SET_FOREGROUND_WINDOW_ENV_KEY, "1"); } } - let _child = cmd - .env(APPNAME_RUNTIME_ENV_KEY, exe_name) - .stdin(Stdio::inherit()) - .stdout(Stdio::inherit()) - .stderr(Stdio::inherit()) - .spawn(); + + cmd.env(APPNAME_RUNTIME_ENV_KEY, exe_name); + if use_null_stdio() { + cmd.stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + } else { + cmd.stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()); + } + let _child = cmd.spawn(); #[cfg(windows)] if _ui { @@ -147,7 +187,10 @@ fn main() { i += 1; } let click_setup = args.is_empty() && arg_exe.to_lowercase().ends_with("install.exe"); - let quick_support = args.is_empty() && arg_exe.to_lowercase().ends_with("qs.exe"); + #[cfg(windows)] + let quick_support = args.is_empty() && win::is_quick_support_exe(&arg_exe); + #[cfg(not(windows))] + let quick_support = false; let mut ui = false; let reader = BinaryReader::default(); @@ -168,14 +211,14 @@ fn main() { } #[cfg(windows)] -mod windows { - use std::{fs, os::windows::process::CommandExt, path::PathBuf, process::Command}; +mod win { + use std::{fs, os::windows::process::CommandExt, path::Path, process::Command}; // Used for privacy mode(magnifier impl). pub const RUNTIME_BROKER_EXE: &'static str = "C:\\Windows\\System32\\RuntimeBroker.exe"; pub const WIN_TOPMOST_INJECTED_PROCESS_EXE: &'static str = "RuntimeBroker_rustdesk.exe"; - pub(super) fn copy_runtime_broker(dir: &PathBuf) { + pub(super) fn copy_runtime_broker(dir: &Path) { let src = RUNTIME_BROKER_EXE; let tgt = WIN_TOPMOST_INJECTED_PROCESS_EXE; let target_file = dir.join(tgt); @@ -194,4 +237,12 @@ mod windows { .output(); let _allow_err = std::fs::copy(src, &format!("{}\\{}", dir.to_string_lossy(), tgt)); } + + /// Check if the executable is a Quick Support version. + /// Note: This function must be kept in sync with `src/core_main.rs`. + #[inline] + pub(super) fn is_quick_support_exe(exe: &str) -> bool { + let exe = exe.to_lowercase(); + exe.contains("-qs-") || exe.contains("-qs.exe") || exe.contains("_qs.exe") + } } diff --git a/libs/remote_printer/Cargo.toml b/libs/remote_printer/Cargo.toml new file mode 100644 index 000000000..30f8aff33 --- /dev/null +++ b/libs/remote_printer/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "remote_printer" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[target.'cfg(target_os = "windows")'.dependencies] +hbb_common = { version = "0.1.0", path = "../hbb_common" } +winapi = { version = "0.3" } +windows-strings = "0.3.1" diff --git a/libs/remote_printer/src/lib.rs b/libs/remote_printer/src/lib.rs new file mode 100644 index 000000000..51ee3721a --- /dev/null +++ b/libs/remote_printer/src/lib.rs @@ -0,0 +1,34 @@ +#[cfg(target_os = "windows")] +mod setup; +#[cfg(target_os = "windows")] +pub use setup::{ + is_rd_printer_installed, + setup::{install_update_printer, uninstall_printer}, +}; + +#[cfg(target_os = "windows")] +const RD_DRIVER_INF_PATH: &str = "drivers/RustDeskPrinterDriver/RustDeskPrinterDriver.inf"; + +#[cfg(target_os = "windows")] +fn get_printer_name(app_name: &str) -> Vec { + format!("{} Printer", app_name) + .encode_utf16() + .chain(Some(0)) + .collect() +} + +#[cfg(target_os = "windows")] +fn get_driver_name() -> Vec { + "RustDesk v4 Printer Driver" + .encode_utf16() + .chain(Some(0)) + .collect() +} + +#[cfg(target_os = "windows")] +fn get_port_name(app_name: &str) -> Vec { + format!("{} Printer", app_name) + .encode_utf16() + .chain(Some(0)) + .collect() +} diff --git a/libs/remote_printer/src/setup/driver.rs b/libs/remote_printer/src/setup/driver.rs new file mode 100644 index 000000000..81226c5cc --- /dev/null +++ b/libs/remote_printer/src/setup/driver.rs @@ -0,0 +1,202 @@ +use super::{common_enum, get_wstr_bytes, is_name_equal}; +use hbb_common::{bail, log, ResultType}; +use std::{io, ptr::null_mut, time::Duration}; +use winapi::{ + shared::{ + minwindef::{BOOL, DWORD, FALSE, LPBYTE, LPDWORD, MAX_PATH}, + ntdef::{DWORDLONG, LPCWSTR}, + winerror::{ERROR_UNKNOWN_PRINTER_DRIVER, S_OK}, + }, + um::{ + winspool::{ + DeletePrinterDriverExW, DeletePrinterDriverPackageW, EnumPrinterDriversW, + InstallPrinterDriverFromPackageW, UploadPrinterDriverPackageW, DPD_DELETE_ALL_FILES, + DRIVER_INFO_6W, DRIVER_INFO_8W, IPDFP_COPY_ALL_FILES, UPDP_SILENT_UPLOAD, + UPDP_UPLOAD_ALWAYS, + }, + winuser::GetForegroundWindow, + }, +}; +use windows_strings::PCWSTR; + +const HRESULT_ERR_ELEMENT_NOT_FOUND: u32 = 0x80070490; + +fn enum_printer_driver( + level: DWORD, + p_driver_info: LPBYTE, + cb_buf: DWORD, + pcb_needed: LPDWORD, + pc_returned: LPDWORD, +) -> BOOL { + unsafe { + // https://learn.microsoft.com/en-us/windows/win32/printdocs/enumprinterdrivers + // This is a blocking or synchronous function and might not return immediately. + // How quickly this function returns depends on run-time factors + // such as network status, print server configuration, and printer driver implementation factors that are difficult to predict when writing an application. + // Calling this function from a thread that manages interaction with the user interface could make the application appear to be unresponsive. + EnumPrinterDriversW( + null_mut(), + null_mut(), + level, + p_driver_info, + cb_buf, + pcb_needed, + pc_returned, + ) + } +} + +pub fn get_installed_driver_version(name: &PCWSTR) -> ResultType> { + common_enum( + "EnumPrinterDriversW", + enum_printer_driver, + 6, + |info: &DRIVER_INFO_6W| { + if is_name_equal(name, info.pName) { + Some(info.dwlDriverVersion) + } else { + None + } + }, + || None, + ) +} + +fn find_inf(name: &PCWSTR) -> ResultType> { + let r = common_enum( + "EnumPrinterDriversW", + enum_printer_driver, + 8, + |info: &DRIVER_INFO_8W| { + if is_name_equal(name, info.pName) { + Some(get_wstr_bytes(info.pszInfPath)) + } else { + None + } + }, + || None, + )?; + Ok(r.unwrap_or(vec![])) +} + +fn delete_printer_driver(name: &PCWSTR) -> ResultType<()> { + unsafe { + // If the printer is used after the spooler service is started. E.g., printing a document through RustDesk Printer. + // `DeletePrinterDriverExW()` may fail with `ERROR_PRINTER_DRIVER_IN_USE`(3001, 0xBB9). + // We can only ignore this error for now. + // Though restarting the spooler service is a solution, it's not a good idea to restart the service. + // + // Deleting the printer driver after deleting the printer is a common practice. + // No idea why `DeletePrinterDriverExW()` fails with `ERROR_UNKNOWN_PRINTER_DRIVER` after using the printer once. + // https://github.com/ChromiumWebApps/chromium/blob/c7361d39be8abd1574e6ce8957c8dbddd4c6ccf7/cloud_print/virtual_driver/win/install/setup.cc#L422 + // AnyDesk printer driver and the simplest printer driver also have the same issue. + if FALSE + == DeletePrinterDriverExW( + null_mut(), + null_mut(), + name.as_ptr() as _, + DPD_DELETE_ALL_FILES, + 0, + ) + { + let err = io::Error::last_os_error(); + if err.raw_os_error() == Some(ERROR_UNKNOWN_PRINTER_DRIVER as _) { + return Ok(()); + } else { + bail!("Failed to delete the printer driver, {}", err) + } + } + } + Ok(()) +} + +// https://github.com/dvalter/chromium-android-ext-dev/blob/dab74f7d5bc5a8adf303090ee25c611b4d54e2db/cloud_print/virtual_driver/win/install/setup.cc#L190 +fn delete_printer_driver_package(inf: Vec) -> ResultType<()> { + if inf.is_empty() { + return Ok(()); + } + let slen = if inf[inf.len() - 1] == 0 { + inf.len() - 1 + } else { + inf.len() + }; + let inf_path = String::from_utf16_lossy(&inf[..slen]); + if !std::path::Path::new(&inf_path).exists() { + return Ok(()); + } + + let mut retries = 3; + loop { + unsafe { + let res = DeletePrinterDriverPackageW(null_mut(), inf.as_ptr(), null_mut()); + if res == S_OK || res == HRESULT_ERR_ELEMENT_NOT_FOUND as i32 { + return Ok(()); + } + log::error!("Failed to delete the printer driver, result: {}", res); + } + retries -= 1; + if retries <= 0 { + bail!("Failed to delete the printer driver"); + } + std::thread::sleep(Duration::from_secs(2)); + } +} + +pub fn uninstall_driver(name: &PCWSTR) -> ResultType<()> { + // Note: inf must be found before `delete_printer_driver()`. + let inf = find_inf(name)?; + delete_printer_driver(name)?; + delete_printer_driver_package(inf) +} + +pub fn install_driver(name: &PCWSTR, inf: LPCWSTR) -> ResultType<()> { + let mut size = (MAX_PATH * 10) as u32; + let mut package_path = [0u16; MAX_PATH * 10]; + unsafe { + let mut res = UploadPrinterDriverPackageW( + null_mut(), + inf, + null_mut(), + UPDP_SILENT_UPLOAD | UPDP_UPLOAD_ALWAYS, + null_mut(), + package_path.as_mut_ptr(), + &mut size as _, + ); + if res != S_OK { + log::error!( + "Failed to upload the printer driver package to the driver cache silently, {}. Will try with user UI.", + res + ); + + res = UploadPrinterDriverPackageW( + null_mut(), + inf, + null_mut(), + UPDP_UPLOAD_ALWAYS, + GetForegroundWindow(), + package_path.as_mut_ptr(), + &mut size as _, + ); + if res != S_OK { + bail!( + "Failed to upload the printer driver package to the driver cache with UI, {}", + res + ); + } + } + + // https://learn.microsoft.com/en-us/windows/win32/printdocs/installprinterdriverfrompackage + res = InstallPrinterDriverFromPackageW( + null_mut(), + package_path.as_ptr(), + name.as_ptr(), + null_mut(), + IPDFP_COPY_ALL_FILES, + ); + if res != S_OK { + bail!("Failed to install the printer driver from package, {}", res); + } + } + + Ok(()) +} diff --git a/libs/remote_printer/src/setup/mod.rs b/libs/remote_printer/src/setup/mod.rs new file mode 100644 index 000000000..562a7300f --- /dev/null +++ b/libs/remote_printer/src/setup/mod.rs @@ -0,0 +1,101 @@ +#![allow(non_snake_case)] + +use hbb_common::{bail, ResultType}; +use std::{io, ptr::null_mut}; +use winapi::{ + shared::{ + minwindef::{BOOL, DWORD, FALSE, LPBYTE, LPDWORD}, + ntdef::{LPCWSTR, LPWSTR}, + }, + um::winbase::{lstrcmpiW, lstrlenW}, +}; +use windows_strings::PCWSTR; + +mod driver; +mod port; +pub(crate) mod printer; +pub(crate) mod setup; + +#[inline] +pub fn is_rd_printer_installed(app_name: &str) -> ResultType { + let printer_name = crate::get_printer_name(app_name); + let rd_printer_name = PCWSTR::from_raw(printer_name.as_ptr()); + printer::is_printer_added(&rd_printer_name) +} + +fn get_wstr_bytes(p: LPWSTR) -> Vec { + let mut vec_bytes = vec![]; + unsafe { + let len: isize = lstrlenW(p) as _; + if len > 0 { + for i in 0..len + 1 { + vec_bytes.push(*p.offset(i)); + } + } + } + vec_bytes +} + +fn is_name_equal(name: &PCWSTR, name_from_api: LPCWSTR) -> bool { + // https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-lstrcmpiw + // For some locales, the lstrcmpi function may be insufficient. + // If this occurs, use `CompareStringEx` to ensure proper comparison. + // For example, in Japan call with the NORM_IGNORECASE, NORM_IGNOREKANATYPE, and NORM_IGNOREWIDTH values to achieve the most appropriate non-exact string comparison. + // Note that specifying these values slows performance, so use them only when necessary. + // + // No need to consider `CompareStringEx` for now. + unsafe { lstrcmpiW(name.as_ptr(), name_from_api) == 0 } +} + +fn common_enum( + enum_name: &str, + enum_fn: fn( + Level: DWORD, + pDriverInfo: LPBYTE, + cbBuf: DWORD, + pcbNeeded: LPDWORD, + pcReturned: LPDWORD, + ) -> BOOL, + level: DWORD, + on_data: impl Fn(&T) -> Option, + on_no_data: impl Fn() -> Option, +) -> ResultType> { + let mut needed = 0; + let mut returned = 0; + enum_fn(level, null_mut(), 0, &mut needed, &mut returned); + if needed == 0 { + return Ok(on_no_data()); + } + + let mut buffer = vec![0u8; needed as usize]; + if FALSE + == enum_fn( + level, + buffer.as_mut_ptr(), + needed, + &mut needed, + &mut returned, + ) + { + bail!( + "Failed to call {}, error: {}", + enum_name, + io::Error::last_os_error() + ) + } + + // to-do: how to free the buffers in *const T? + + let p_enum_info = buffer.as_ptr() as *const T; + unsafe { + for i in 0..returned { + let enum_info = p_enum_info.offset(i as isize); + let r = on_data(&*enum_info); + if r.is_some() { + return Ok(r); + } + } + } + + Ok(on_no_data()) +} diff --git a/libs/remote_printer/src/setup/port.rs b/libs/remote_printer/src/setup/port.rs new file mode 100644 index 000000000..d5ab0bace --- /dev/null +++ b/libs/remote_printer/src/setup/port.rs @@ -0,0 +1,128 @@ +use super::{common_enum, is_name_equal, printer::get_printer_installed_on_port}; +use hbb_common::{bail, ResultType}; +use std::{io, ptr::null_mut}; +use winapi::{ + shared::minwindef::{BOOL, DWORD, FALSE, LPBYTE, LPDWORD}, + um::{ + winnt::HANDLE, + winspool::{ + ClosePrinter, EnumPortsW, OpenPrinterW, XcvDataW, PORT_INFO_2W, PRINTER_DEFAULTSW, + SERVER_WRITE, + }, + }, +}; +use windows_strings::{w, PCWSTR}; + +const XCV_MONITOR_LOCAL_PORT: PCWSTR = w!(",XcvMonitor Local Port"); + +fn enum_printer_port( + level: DWORD, + p_port_info: LPBYTE, + cb_buf: DWORD, + pcb_needed: LPDWORD, + pc_returned: LPDWORD, +) -> BOOL { + unsafe { + // https://learn.microsoft.com/en-us/windows/win32/printdocs/enumports + // This is a blocking or synchronous function and might not return immediately. + // How quickly this function returns depends on run-time factors + // such as network status, print server configuration, and printer driver implementation factors that are difficult to predict when writing an application. + // Calling this function from a thread that manages interaction with the user interface could make the application appear to be unresponsive. + EnumPortsW( + null_mut(), + level, + p_port_info, + cb_buf, + pcb_needed, + pc_returned, + ) + } +} + +fn is_port_exists(name: &PCWSTR) -> ResultType { + let r = common_enum( + "EnumPortsW", + enum_printer_port, + 2, + |info: &PORT_INFO_2W| { + if is_name_equal(name, info.pPortName) { + Some(true) + } else { + None + } + }, + || None, + )?; + Ok(r.unwrap_or(false)) +} + +unsafe fn execute_on_local_port(port: &PCWSTR, command: &PCWSTR) -> ResultType<()> { + let mut dft = PRINTER_DEFAULTSW { + pDataType: null_mut(), + pDevMode: null_mut(), + DesiredAccess: SERVER_WRITE, + }; + let mut h_monitor: HANDLE = null_mut(); + if FALSE + == OpenPrinterW( + XCV_MONITOR_LOCAL_PORT.as_ptr() as _, + &mut h_monitor, + &mut dft as *mut PRINTER_DEFAULTSW as _, + ) + { + bail!(format!( + "Failed to open Local Port monitor. Error: {}", + io::Error::last_os_error() + )) + } + + let mut output_needed: u32 = 0; + let mut status: u32 = 0; + if FALSE + == XcvDataW( + h_monitor, + command.as_ptr(), + port.as_ptr() as *mut u8, + (port.len() + 1) as u32 * 2, + null_mut(), + 0, + &mut output_needed, + &mut status, + ) + { + ClosePrinter(h_monitor); + bail!(format!( + "Failed to execute the command on the printer port, Error: {}", + io::Error::last_os_error() + )) + } + + ClosePrinter(h_monitor); + + Ok(()) +} + +fn add_local_port(port: &PCWSTR) -> ResultType<()> { + unsafe { execute_on_local_port(port, &w!("AddPort")) } +} + +fn delete_local_port(port: &PCWSTR) -> ResultType<()> { + unsafe { execute_on_local_port(port, &w!("DeletePort")) } +} + +pub fn check_add_local_port(port: &PCWSTR) -> ResultType<()> { + if !is_port_exists(port)? { + return add_local_port(port); + } + Ok(()) +} + +pub fn check_delete_local_port(port: &PCWSTR) -> ResultType<()> { + if is_port_exists(port)? { + if get_printer_installed_on_port(port)?.is_some() { + bail!("The printer is installed on the port. Please remove the printer first."); + } + return delete_local_port(port); + } + Ok(()) +} diff --git a/libs/remote_printer/src/setup/printer.rs b/libs/remote_printer/src/setup/printer.rs new file mode 100644 index 000000000..9882b8f38 --- /dev/null +++ b/libs/remote_printer/src/setup/printer.rs @@ -0,0 +1,161 @@ +use super::{common_enum, get_wstr_bytes, is_name_equal}; +use hbb_common::{bail, ResultType}; +use std::{io, ptr::null_mut}; +use winapi::{ + shared::{ + minwindef::{BOOL, DWORD, FALSE, LPBYTE, LPDWORD}, + ntdef::HANDLE, + winerror::ERROR_INVALID_PRINTER_NAME, + }, + um::winspool::{ + AddPrinterW, ClosePrinter, DeletePrinter, EnumPrintersW, OpenPrinterW, SetPrinterW, + PRINTER_ALL_ACCESS, PRINTER_ATTRIBUTE_LOCAL, PRINTER_CONTROL_PURGE, PRINTER_DEFAULTSW, + PRINTER_ENUM_LOCAL, PRINTER_INFO_1W, PRINTER_INFO_2W, + }, +}; +use windows_strings::{w, PCWSTR}; + +fn enum_local_printer( + level: DWORD, + p_printer_info: LPBYTE, + cb_buf: DWORD, + pcb_needed: LPDWORD, + pc_returned: LPDWORD, +) -> BOOL { + unsafe { + // https://learn.microsoft.com/en-us/windows/win32/printdocs/enumprinters + // This is a blocking or synchronous function and might not return immediately. + // How quickly this function returns depends on run-time factors + // such as network status, print server configuration, and printer driver implementation factors that are difficult to predict when writing an application. + // Calling this function from a thread that manages interaction with the user interface could make the application appear to be unresponsive. + EnumPrintersW( + PRINTER_ENUM_LOCAL, + null_mut(), + level, + p_printer_info, + cb_buf, + pcb_needed, + pc_returned, + ) + } +} + +#[inline] +pub fn is_printer_added(name: &PCWSTR) -> ResultType { + let r = common_enum( + "EnumPrintersW", + enum_local_printer, + 1, + |info: &PRINTER_INFO_1W| { + if is_name_equal(name, info.pName) { + Some(true) + } else { + None + } + }, + || None, + )?; + Ok(r.unwrap_or(false)) +} + +// Only return the first matched printer +pub fn get_printer_installed_on_port(port: &PCWSTR) -> ResultType>> { + common_enum( + "EnumPrintersW", + enum_local_printer, + 2, + |info: &PRINTER_INFO_2W| { + if is_name_equal(port, info.pPortName) { + Some(get_wstr_bytes(info.pPrinterName)) + } else { + None + } + }, + || None, + ) +} + +pub fn add_printer(name: &PCWSTR, driver: &PCWSTR, port: &PCWSTR) -> ResultType<()> { + let mut printer_info = PRINTER_INFO_2W { + pServerName: null_mut(), + pPrinterName: name.as_ptr() as _, + pShareName: null_mut(), + pPortName: port.as_ptr() as _, + pDriverName: driver.as_ptr() as _, + pComment: null_mut(), + pLocation: null_mut(), + pDevMode: null_mut(), + pSepFile: null_mut(), + pPrintProcessor: w!("WinPrint").as_ptr() as _, + pDatatype: w!("RAW").as_ptr() as _, + pParameters: null_mut(), + pSecurityDescriptor: null_mut(), + Attributes: PRINTER_ATTRIBUTE_LOCAL, + Priority: 0, + DefaultPriority: 0, + StartTime: 0, + UntilTime: 0, + Status: 0, + cJobs: 0, + AveragePPM: 0, + }; + unsafe { + let h_printer = AddPrinterW( + null_mut(), + 2, + &mut printer_info as *mut PRINTER_INFO_2W as _, + ); + if h_printer.is_null() { + bail!(format!( + "Failed to add printer. Error: {}", + io::Error::last_os_error() + )) + } + } + Ok(()) +} + +pub fn delete_printer(name: &PCWSTR) -> ResultType<()> { + let mut dft = PRINTER_DEFAULTSW { + pDataType: null_mut(), + pDevMode: null_mut(), + DesiredAccess: PRINTER_ALL_ACCESS, + }; + let mut h_printer: HANDLE = null_mut(); + unsafe { + if FALSE + == OpenPrinterW( + name.as_ptr() as _, + &mut h_printer, + &mut dft as *mut PRINTER_DEFAULTSW as _, + ) + { + let err = io::Error::last_os_error(); + if err.raw_os_error() == Some(ERROR_INVALID_PRINTER_NAME as _) { + return Ok(()); + } else { + bail!(format!("Failed to open printer. Error: {}", err)) + } + } + + if FALSE == SetPrinterW(h_printer, 0, null_mut(), PRINTER_CONTROL_PURGE) { + ClosePrinter(h_printer); + bail!(format!( + "Failed to purge printer queue. Error: {}", + io::Error::last_os_error() + )) + } + + if FALSE == DeletePrinter(h_printer) { + ClosePrinter(h_printer); + bail!(format!( + "Failed to delete printer. Error: {}", + io::Error::last_os_error() + )) + } + + ClosePrinter(h_printer); + } + + Ok(()) +} diff --git a/libs/remote_printer/src/setup/setup.rs b/libs/remote_printer/src/setup/setup.rs new file mode 100644 index 000000000..f461ab75c --- /dev/null +++ b/libs/remote_printer/src/setup/setup.rs @@ -0,0 +1,94 @@ +use super::{ + driver::{get_installed_driver_version, install_driver, uninstall_driver}, + port::{check_add_local_port, check_delete_local_port}, + printer::{add_printer, delete_printer}, +}; +use hbb_common::{allow_err, bail, lazy_static, log, ResultType}; +use std::{path::PathBuf, sync::Mutex}; +use windows_strings::PCWSTR; + +lazy_static::lazy_static!( + static ref SETUP_MTX: Mutex<()> = Mutex::new(()); +); + +fn get_driver_inf_abs_path() -> ResultType { + use crate::RD_DRIVER_INF_PATH; + + let exe_file = std::env::current_exe()?; + let abs_path = match exe_file.parent() { + Some(parent) => parent.join(RD_DRIVER_INF_PATH), + None => bail!( + "Invalid exe parent for {}", + exe_file.to_string_lossy().as_ref() + ), + }; + if !abs_path.exists() { + bail!( + "The driver inf file \"{}\" does not exists", + RD_DRIVER_INF_PATH + ) + } + Ok(abs_path) +} + +// Note: This function must be called in a separate thread. +// Because many functions in this module are blocking or synchronous. +// Calling this function from a thread that manages interaction with the user interface could make the application appear to be unresponsive. +// Steps: +// 1. Add the local port. +// 2. Check if the driver is installed. +// Uninstall the existing driver if it is installed. +// We should not check the driver version because the driver is deployed with the application. +// It's better to uninstall the existing driver and install the driver from the application. +// 3. Add the printer. +pub fn install_update_printer(app_name: &str) -> ResultType<()> { + let printer_name = crate::get_printer_name(app_name); + let driver_name = crate::get_driver_name(); + let port = crate::get_port_name(app_name); + let rd_printer_name = PCWSTR::from_raw(printer_name.as_ptr()); + let rd_printer_driver_name = PCWSTR::from_raw(driver_name.as_ptr()); + let rd_printer_port = PCWSTR::from_raw(port.as_ptr()); + + let inf_file = get_driver_inf_abs_path()?; + let inf_file: Vec = inf_file + .to_string_lossy() + .as_ref() + .encode_utf16() + .chain(Some(0).into_iter()) + .collect(); + let _lock = SETUP_MTX.lock().unwrap(); + + check_add_local_port(&rd_printer_port)?; + + let should_install_driver = match get_installed_driver_version(&rd_printer_driver_name)? { + Some(_version) => { + delete_printer(&rd_printer_name)?; + allow_err!(uninstall_driver(&rd_printer_driver_name)); + true + } + None => true, + }; + + if should_install_driver { + allow_err!(install_driver(&rd_printer_driver_name, inf_file.as_ptr())); + } + + add_printer(&rd_printer_name, &rd_printer_driver_name, &rd_printer_port)?; + + Ok(()) +} + +pub fn uninstall_printer(app_name: &str) { + let printer_name = crate::get_printer_name(app_name); + let driver_name = crate::get_driver_name(); + let port = crate::get_port_name(app_name); + let rd_printer_name = PCWSTR::from_raw(printer_name.as_ptr()); + let rd_printer_driver_name = PCWSTR::from_raw(driver_name.as_ptr()); + let rd_printer_port = PCWSTR::from_raw(port.as_ptr()); + + let _lock = SETUP_MTX.lock().unwrap(); + + allow_err!(delete_printer(&rd_printer_name)); + allow_err!(uninstall_driver(&rd_printer_driver_name)); + allow_err!(check_delete_local_port(&rd_printer_port)); +} diff --git a/libs/scrap/Cargo.toml b/libs/scrap/Cargo.toml index 529010f16..505eca2de 100644 --- a/libs/scrap/Cargo.toml +++ b/libs/scrap/Cargo.toml @@ -10,7 +10,7 @@ authors = ["Ram "] edition = "2018" [features] -wayland = ["gstreamer", "gstreamer-app", "gstreamer-video", "dbus", "tracing"] +wayland = ["gstreamer", "gstreamer-app", "gstreamer-video", "dbus", "tracing", "zbus"] mediacodec = ["ndk"] linux-pkg-config = ["dep:pkg-config"] hwcodec = ["dep:hwcodec"] @@ -57,8 +57,12 @@ tracing = { version = "0.1", optional = true } gstreamer = { version = "0.16", optional = true } gstreamer-app = { version = "0.16", features = ["v1_10"], optional = true } gstreamer-video = { version = "0.16", optional = true } +zbus = { version = "3.15", optional = true } [dependencies.hwcodec] git = "https://github.com/rustdesk-org/hwcodec" optional = true +[target.'cfg(any(target_os = "windows", target_os = "linux"))'.dependencies] +nokhwa = { git = "https://github.com/rustdesk-org/nokhwa.git", branch = "fix_from_raw_parts", features = ["input-native"] } + diff --git a/libs/scrap/build.rs b/libs/scrap/build.rs index 55a688633..73765055d 100644 --- a/libs/scrap/build.rs +++ b/libs/scrap/build.rs @@ -55,24 +55,22 @@ fn link_vcpkg(mut path: PathBuf, name: &str) -> PathBuf { target = target.replace("x64", "x86"); } println!("cargo:info={}", target); - path.push("installed"); + if let Ok(vcpkg_root) = std::env::var("VCPKG_INSTALLED_ROOT") { + path = vcpkg_root.into(); + } else { + path.push("installed"); + } path.push(target); println!( - "{}", - format!( - "cargo:rustc-link-lib=static={}", - name.trim_start_matches("lib") - ) + "cargo:rustc-link-lib=static={}", + name.trim_start_matches("lib") ); println!( - "{}", - format!( - "cargo:rustc-link-search={}", - path.join("lib").to_str().unwrap() - ) + "cargo:rustc-link-search={}", + path.join("lib").to_str().unwrap() ); let include = path.join("include"); - println!("{}", format!("cargo:include={}", include.to_str().unwrap())); + println!("cargo:include={}", include.to_str().unwrap()); include } @@ -107,23 +105,17 @@ fn link_homebrew_m1(name: &str) -> PathBuf { path.push(directories.pop().unwrap()); // Link the library. println!( - "{}", - format!( - "cargo:rustc-link-lib=static={}", - name.trim_start_matches("lib") - ) + "cargo:rustc-link-lib=static={}", + name.trim_start_matches("lib") ); // Add the library path. println!( - "{}", - format!( - "cargo:rustc-link-search={}", - path.join("lib").to_str().unwrap() - ) + "cargo:rustc-link-search={}", + path.join("lib").to_str().unwrap() ); // Add the include path. let include = path.join("include"); - println!("{}", format!("cargo:include={}", include.to_str().unwrap())); + println!("cargo:include={}", include.to_str().unwrap()); include } @@ -235,6 +227,12 @@ fn ffmpeg() { */ fn main() { + // in this crate, these are also valid configurations + println!("cargo:rustc-check-cfg=cfg(dxgi,quartz,x11)"); + + // there is problem with cfg(target_os) in build.rs, so use our workaround + let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap(); + // note: all link symbol names in x86 (32-bit) are prefixed wth "_". // run "rustup show" to show current default toolchain, if it is stable-x86-pc-windows-msvc, // please install x64 toolchain by "rustup toolchain install stable-x86_64-pc-windows-msvc", @@ -252,8 +250,6 @@ fn main() { gen_vcpkg_package("libyuv", "yuv_ffi.h", "yuv_ffi.rs", ".*"); // ffmpeg(); - // there is problem with cfg(target_os) in build.rs, so use our workaround - let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap(); if target_os == "ios" { // nothing } else if target_os == "android" { diff --git a/libs/scrap/examples/benchmark.rs b/libs/scrap/examples/benchmark.rs index 803a4343c..a867a2d3f 100644 --- a/libs/scrap/examples/benchmark.rs +++ b/libs/scrap/examples/benchmark.rs @@ -5,7 +5,7 @@ use hbb_common::{ }; use scrap::{ aom::{AomDecoder, AomEncoder, AomEncoderConfig}, - codec::{EncoderApi, EncoderCfg, Quality as Q}, + codec::{EncoderApi, EncoderCfg}, Capturer, Display, TraitCapturer, VpxDecoder, VpxDecoderConfig, VpxEncoder, VpxEncoderConfig, VpxVideoCodecId::{self, *}, STRIDE_ALIGN, @@ -27,25 +27,17 @@ Usage: Options: -h --help Show this screen. --count=COUNT Capture frame count [default: 100]. - --quality=QUALITY Video quality [default: Balanced]. - Valid values: Best, Balanced, Low. + --quality=QUALITY Video quality [default: 1.0]. --i444 I444. "; #[derive(Debug, serde::Deserialize, Clone, Copy)] struct Args { flag_count: usize, - flag_quality: Quality, + flag_quality: f32, flag_i444: bool, } -#[derive(Debug, serde::Deserialize, Clone, Copy)] -enum Quality { - Best, - Balanced, - Low, -} - fn main() { init_from_env(Env::default().filter_or(DEFAULT_FILTER_ENV, "info")); let args: Args = Docopt::new(USAGE) @@ -70,11 +62,6 @@ fn main() { "benchmark {}x{} quality:{:?}, i444:{:?}", width, height, quality, args.flag_i444 ); - let quality = match quality { - Quality::Best => Q::Best, - Quality::Balanced => Q::Balanced, - Quality::Low => Q::Low, - }; [VP8, VP9].map(|codec| { test_vpx( &mut c, @@ -98,7 +85,7 @@ fn test_vpx( codec_id: VpxVideoCodecId, width: usize, height: usize, - quality: Q, + quality: f32, yuv_count: usize, i444: bool, ) { @@ -177,7 +164,7 @@ fn test_av1( c: &mut Capturer, width: usize, height: usize, - quality: Q, + quality: f32, yuv_count: usize, i444: bool, ) { @@ -247,7 +234,7 @@ mod hw { use super::*; - pub fn test(c: &mut Capturer, width: usize, height: usize, quality: Q, yuv_count: usize) { + pub fn test(c: &mut Capturer, width: usize, height: usize, quality: f32, yuv_count: usize) { let mut h264s = Vec::new(); let mut h265s = Vec::new(); if let Some(info) = HwRamEncoder::try_get(CodecFormat::H264) { @@ -263,7 +250,7 @@ mod hw { fn test_encoder( width: usize, height: usize, - quality: Q, + quality: f32, info: CodecInfo, c: &mut Capturer, yuv_count: usize, diff --git a/libs/scrap/examples/record-screen.rs b/libs/scrap/examples/record-screen.rs index 6d68a7352..ca620608a 100644 --- a/libs/scrap/examples/record-screen.rs +++ b/libs/scrap/examples/record-screen.rs @@ -13,7 +13,7 @@ use std::time::{Duration, Instant}; use std::{io, thread}; use docopt::Docopt; -use scrap::codec::{EncoderApi, EncoderCfg, Quality as Q}; +use scrap::codec::{EncoderApi, EncoderCfg}; use webm::mux; use webm::mux::Track; @@ -31,8 +31,7 @@ Options: -h --help Show this screen. --time= Recording duration in seconds. --fps= Frames per second [default: 30]. - --quality= Video quality [default: Balanced]. - Valid values: Best, Balanced, Low. + --quality= Video quality [default: 1.0]. --ba= Audio bitrate in kilobits per second [default: 96]. --codec CODEC Configure the codec used. [default: vp9] Valid values: vp8, vp9. @@ -44,14 +43,7 @@ struct Args { flag_codec: Codec, flag_time: Option, flag_fps: u64, - flag_quality: Quality, -} - -#[derive(Debug, serde::Deserialize)] -enum Quality { - Best, - Balanced, - Low, + flag_quality: f32, } #[derive(Debug, serde::Deserialize)] @@ -105,11 +97,7 @@ fn main() -> io::Result<()> { let mut vt = webm.add_video_track(width, height, None, mux_codec); // Setup the encoder. - let quality = match args.flag_quality { - Quality::Best => Q::Best, - Quality::Balanced => Q::Balanced, - Quality::Low => Q::Low, - }; + let quality = args.flag_quality; let mut vpx = vpx_encode::VpxEncoder::new( EncoderCfg::VPX(vpx_encode::VpxEncoderConfig { width, diff --git a/libs/scrap/src/android/ffi.rs b/libs/scrap/src/android/ffi.rs index f5208a673..2c891a932 100644 --- a/libs/scrap/src/android/ffi.rs +++ b/libs/scrap/src/android/ffi.rs @@ -5,23 +5,31 @@ use jni::sys::jboolean; use jni::JNIEnv; use jni::{ objects::{GlobalRef, JClass, JObject}, + strings::JNIString, JavaVM, }; +use hbb_common::{message_proto::MultiClipboards, protobuf::Message}; use jni::errors::{Error as JniError, Result as JniResult}; use lazy_static::lazy_static; use serde::Deserialize; use std::ops::Not; +use std::os::raw::c_void; use std::sync::atomic::{AtomicPtr, Ordering::SeqCst}; use std::sync::{Mutex, RwLock}; use std::time::{Duration, Instant}; + lazy_static! { static ref JVM: RwLock> = RwLock::new(None); static ref MAIN_SERVICE_CTX: RwLock> = RwLock::new(None); // MainService -> video service / audio service / info + static ref APPLICATION_CONTEXT: RwLock> = RwLock::new(None); static ref VIDEO_RAW: Mutex = Mutex::new(FrameRaw::new("video", MAX_VIDEO_FRAME_TIMEOUT)); static ref AUDIO_RAW: Mutex = Mutex::new(FrameRaw::new("audio", MAX_AUDIO_FRAME_TIMEOUT)); static ref NDK_CONTEXT_INITED: Mutex = Default::default(); static ref MEDIA_CODEC_INFOS: RwLock> = RwLock::new(None); + static ref CLIPBOARD_MANAGER: RwLock> = RwLock::new(None); + static ref CLIPBOARDS_HOST: Mutex> = Mutex::new(None); + static ref CLIPBOARDS_CLIENT: Mutex> = Mutex::new(None); } const MAX_VIDEO_FRAME_TIMEOUT: Duration = Duration::from_millis(100); @@ -104,6 +112,14 @@ pub fn get_audio_raw<'a>(dst: &mut Vec, last: &mut Vec) -> Option<()> { AUDIO_RAW.lock().ok()?.take(dst, last) } +pub fn get_clipboards(client: bool) -> Option { + if client { + CLIPBOARDS_CLIENT.lock().ok()?.take() + } else { + CLIPBOARDS_HOST.lock().ok()?.take() + } +} + #[no_mangle] pub extern "system" fn Java_ffi_FFI_onVideoFrameUpdate( env: JNIEnv, @@ -132,6 +148,27 @@ pub extern "system" fn Java_ffi_FFI_onAudioFrameUpdate( } } +#[no_mangle] +pub extern "system" fn Java_ffi_FFI_onClipboardUpdate( + env: JNIEnv, + _class: JClass, + buffer: JByteBuffer, +) { + if let Ok(data) = env.get_direct_buffer_address(&buffer) { + if let Ok(len) = env.get_direct_buffer_capacity(&buffer) { + let data = unsafe { std::slice::from_raw_parts(data, len) }; + if let Ok(clips) = MultiClipboards::parse_from_bytes(&data[1..]) { + let is_client = data[0] == 1; + if is_client { + *CLIPBOARDS_CLIENT.lock().unwrap() = Some(clips); + } else { + *CLIPBOARDS_HOST.lock().unwrap() = Some(clips); + } + } + } + } +} + #[no_mangle] pub extern "system" fn Java_ffi_FFI_setFrameRawEnable( env: JNIEnv, @@ -155,10 +192,36 @@ pub extern "system" fn Java_ffi_FFI_setFrameRawEnable( pub extern "system" fn Java_ffi_FFI_init(env: JNIEnv, _class: JClass, ctx: JObject) { log::debug!("MainService init from java"); if let Ok(jvm) = env.get_java_vm() { - *JVM.write().unwrap() = Some(jvm); + let java_vm = jvm.get_java_vm_pointer() as *mut c_void; + let mut jvm_lock = JVM.write().unwrap(); + if jvm_lock.is_none() { + *jvm_lock = Some(jvm); + } + drop(jvm_lock); if let Ok(context) = env.new_global_ref(ctx) { + let context_jobject = context.as_obj().as_raw() as *mut c_void; *MAIN_SERVICE_CTX.write().unwrap() = Some(context); - init_ndk_context().ok(); + init_ndk_context(java_vm, context_jobject); + } + } +} + +#[no_mangle] +pub extern "system" fn Java_ffi_FFI_setClipboardManager( + env: JNIEnv, + _class: JClass, + clipboard_manager: JObject, +) { + log::debug!("ClipboardManager init from java"); + if let Ok(jvm) = env.get_java_vm() { + let java_vm = jvm.get_java_vm_pointer() as *mut c_void; + let mut jvm_lock = JVM.write().unwrap(); + if jvm_lock.is_none() { + *jvm_lock = Some(jvm); + } + drop(jvm_lock); + if let Ok(manager) = env.new_global_ref(clipboard_manager) { + *CLIPBOARD_MANAGER.write().unwrap() = Some(manager); } } } @@ -272,6 +335,51 @@ pub fn call_main_service_key_event(data: &[u8]) -> JniResult<()> { } } +fn _call_clipboard_manager(name: S, sig: T, args: &[JValue]) -> JniResult<()> +where + S: Into, + T: Into + AsRef, +{ + if let (Some(jvm), Some(cm)) = ( + JVM.read().unwrap().as_ref(), + CLIPBOARD_MANAGER.read().unwrap().as_ref(), + ) { + let mut env = jvm.attach_current_thread()?; + env.call_method(cm, name, sig, args)?; + return Ok(()); + } else { + return Err(JniError::ThrowFailed(-1)); + } +} + +pub fn call_clipboard_manager_update_clipboard(data: &[u8]) -> JniResult<()> { + if let (Some(jvm), Some(cm)) = ( + JVM.read().unwrap().as_ref(), + CLIPBOARD_MANAGER.read().unwrap().as_ref(), + ) { + let mut env = jvm.attach_current_thread()?; + let data = env.byte_array_from_slice(data)?; + + env.call_method( + cm, + "rustUpdateClipboard", + "([B)V", + &[JValue::Object(&JObject::from(data))], + )?; + return Ok(()); + } else { + return Err(JniError::ThrowFailed(-1)); + } +} + +pub fn call_clipboard_manager_enable_client_clipboard(enable: bool) -> JniResult<()> { + _call_clipboard_manager( + "rustEnableClientClipboard", + "(Z)V", + &[JValue::Bool(jboolean::from(enable))], + ) +} + pub fn call_main_service_get_by_name(name: &str) -> JniResult { if let (Some(jvm), Some(ctx)) = ( JVM.read().unwrap().as_ref(), @@ -332,7 +440,14 @@ pub fn call_main_service_set_by_name( } } -fn init_ndk_context() -> JniResult<()> { +// Difference between MainService, MainActivity, JNI_OnLoad: +// jvm is the same, ctx is differen and ctx of JNI_OnLoad is null. +// cpal: all three works +// Service(GetByName, ...): only ctx from MainService works, so use 2 init context functions +// On app start: JNI_OnLoad or MainActivity init context +// On service start first time: MainService replace the context + +fn init_ndk_context(java_vm: *mut c_void, context_jobject: *mut c_void) { let mut lock = NDK_CONTEXT_INITED.lock().unwrap(); if *lock { unsafe { @@ -340,22 +455,57 @@ fn init_ndk_context() -> JniResult<()> { } *lock = false; } - if let (Some(jvm), Some(ctx)) = ( - JVM.read().unwrap().as_ref(), - MAIN_SERVICE_CTX.read().unwrap().as_ref(), - ) { - unsafe { - ndk_context::initialize_android_context( - jvm.get_java_vm_pointer() as _, - ctx.as_obj().as_raw() as _, - ); - #[cfg(feature = "hwcodec")] - hwcodec::android::ffmpeg_set_java_vm( - jvm.get_java_vm_pointer() as _, - ); - } - *lock = true; - return Ok(()); + unsafe { + ndk_context::initialize_android_context(java_vm, context_jobject); + #[cfg(feature = "hwcodec")] + hwcodec::android::ffmpeg_set_java_vm(java_vm); + } + *lock = true; +} + +fn try_init_rustls_platform_verifier(env: &mut JNIEnv, context_jobject: *mut c_void) { + use hbb_common::config::ANDROID_RUSTLS_PLATFORM_VERIFIER_INITIALIZED as INITIALIZED; + use std::sync::atomic::Ordering; + let initialized = INITIALIZED.load(Ordering::Relaxed); + if !initialized { + let ctx_for_rustls = unsafe { JObject::from_raw(context_jobject as jni::sys::jobject) }; + if let Err(e) = + hbb_common::rustls_platform_verifier::android::init_hosted(env, ctx_for_rustls) + { + log::error!("Failed to initialize rustls-platform-verifier: {:?}", e); + } else { + INITIALIZED.store(true, Ordering::Relaxed); + log::info!("rustls-platform-verifier initialized successfully"); + } + } +} + +// https://cjycode.com/flutter_rust_bridge/guides/how-to/ndk-init +#[no_mangle] +pub extern "C" fn JNI_OnLoad(vm: jni::JavaVM, res: *mut std::os::raw::c_void) -> jni::sys::jint { + if let Ok(env) = vm.get_env() { + let vm = vm.get_java_vm_pointer() as *mut std::os::raw::c_void; + init_ndk_context(vm, res); + } + jni::JNIVersion::V6.into() +} + +#[no_mangle] +pub extern "system" fn Java_ffi_FFI_onAppStart(mut env: JNIEnv, _class: JClass, ctx: JObject) { + if ctx.is_null() { + log::error!("application context is null"); + return; + } + if APPLICATION_CONTEXT.read().unwrap().is_some() { + log::info!("application context already initialized"); + return; + } + if let Ok(jvm) = env.get_java_vm() { + if let Ok(context) = env.new_global_ref(ctx) { + let java_vm = jvm.get_java_vm_pointer() as *mut c_void; + let context_jobject = context.as_obj().as_raw() as *mut c_void; + *APPLICATION_CONTEXT.write().unwrap() = Some(context); + try_init_rustls_platform_verifier(&mut env, context_jobject); + } } - Err(JniError::ThrowFailed(-1)) } diff --git a/libs/scrap/src/common/aom.rs b/libs/scrap/src/common/aom.rs index 00d6fe506..e5093e54b 100644 --- a/libs/scrap/src/common/aom.rs +++ b/libs/scrap/src/common/aom.rs @@ -6,7 +6,7 @@ include!(concat!(env!("OUT_DIR"), "/aom_ffi.rs")); -use crate::codec::{base_bitrate, codec_thread_num, Quality}; +use crate::codec::{base_bitrate, codec_thread_num}; use crate::{codec::EncoderApi, EncodeFrame, STRIDE_ALIGN}; use crate::{common::GoogleImage, generate_call_macro, generate_call_ptr_macro, Error, Result}; use crate::{EncodeInput, EncodeYuvFormat, Pixfmt}; @@ -45,7 +45,7 @@ impl Default for aom_image_t { pub struct AomEncoderConfig { pub width: u32, pub height: u32, - pub quality: Quality, + pub quality: f32, pub keyframe_interval: Option, } @@ -62,15 +62,9 @@ mod webrtc { use super::*; const kUsageProfile: u32 = AOM_USAGE_REALTIME; - const kMinQindex: u32 = 145; // Min qindex threshold for QP scaling. - const kMaxQindex: u32 = 205; // Max qindex threshold for QP scaling. const kBitDepth: u32 = 8; const kLagInFrames: u32 = 0; // No look ahead. - const kRtpTicksPerSecond: i32 = 90000; - const kMinimumFrameRate: f64 = 1.0; - - pub const DEFAULT_Q_MAX: u32 = 56; // no more than 63 - pub const DEFAULT_Q_MIN: u32 = 12; // no more than 63, litter than q_max + pub(super) const kTimeBaseDen: i64 = 1000; // Only positive speeds, range for real-time coding currently is: 6 - 8. // Lower means slower/better quality, higher means fastest/lower quality. @@ -108,7 +102,7 @@ mod webrtc { c.g_h = cfg.height; c.g_threads = codec_thread_num(64) as _; c.g_timebase.num = 1; - c.g_timebase.den = kRtpTicksPerSecond; + c.g_timebase.den = kTimeBaseDen as _; c.g_input_bit_depth = kBitDepth; if let Some(keyframe_interval) = cfg.keyframe_interval { c.kf_min_dist = 0; @@ -116,21 +110,10 @@ mod webrtc { } else { c.kf_mode = aom_kf_mode::AOM_KF_DISABLED; } - let (q_min, q_max, b) = AomEncoder::convert_quality(cfg.quality); - if q_min > 0 && q_min < q_max && q_max < 64 { - c.rc_min_quantizer = q_min; - c.rc_max_quantizer = q_max; - } else { - c.rc_min_quantizer = DEFAULT_Q_MIN; - c.rc_max_quantizer = DEFAULT_Q_MAX; - } - let base_bitrate = base_bitrate(cfg.width as _, cfg.height as _); - let bitrate = base_bitrate * b / 100; - if bitrate > 0 { - c.rc_target_bitrate = bitrate; - } else { - c.rc_target_bitrate = base_bitrate; - } + let (q_min, q_max) = AomEncoder::calc_q_values(cfg.quality); + c.rc_min_quantizer = q_min; + c.rc_max_quantizer = q_max; + c.rc_target_bitrate = AomEncoder::bitrate(cfg.width as _, cfg.height as _, cfg.quality); c.rc_undershoot_pct = 50; c.rc_overshoot_pct = 50; c.rc_buf_initial_sz = 600; @@ -273,17 +256,12 @@ impl EncoderApi for AomEncoder { false } - fn set_quality(&mut self, quality: Quality) -> ResultType<()> { + fn set_quality(&mut self, ratio: f32) -> ResultType<()> { let mut c = unsafe { *self.ctx.config.enc.to_owned() }; - let (q_min, q_max, b) = Self::convert_quality(quality); - if q_min > 0 && q_min < q_max && q_max < 64 { - c.rc_min_quantizer = q_min; - c.rc_max_quantizer = q_max; - } - let bitrate = base_bitrate(self.width as _, self.height as _) * b / 100; - if bitrate > 0 { - c.rc_target_bitrate = bitrate; - } + let (q_min, q_max) = Self::calc_q_values(ratio); + c.rc_min_quantizer = q_min; + c.rc_max_quantizer = q_max; + c.rc_target_bitrate = Self::bitrate(self.width as _, self.height as _, ratio); call_aom!(aom_codec_enc_config_set(&mut self.ctx, &c)); Ok(()) } @@ -293,10 +271,6 @@ impl EncoderApi for AomEncoder { c.rc_target_bitrate } - fn support_abr(&self) -> bool { - true - } - fn support_changing_quality(&self) -> bool { true } @@ -313,7 +287,7 @@ impl EncoderApi for AomEncoder { } impl AomEncoder { - pub fn encode(&mut self, pts: i64, data: &[u8], stride_align: usize) -> Result { + pub fn encode<'a>(&'a mut self, ms: i64, data: &[u8], stride_align: usize) -> Result> { let bpp = if self.i444 { 24 } else { 12 }; if data.len() < self.width * self.height * bpp / 8 { return Err(Error::FailedCall("len not enough".to_string())); @@ -333,13 +307,14 @@ impl AomEncoder { stride_align as _, data.as_ptr() as _, )); - + let pts = webrtc::kTimeBaseDen / 1000 * ms; + let duration = webrtc::kTimeBaseDen / 1000; call_aom!(aom_codec_encode( &mut self.ctx, &image, pts as _, - 1, // Duration - 0, // Flags + duration as _, // Duration + 0, // Flags )); Ok(EncodeFrames { @@ -369,31 +344,27 @@ impl AomEncoder { } } - pub fn convert_quality(quality: Quality) -> (u32, u32, u32) { - // we can use lower bitrate for av1 - match quality { - Quality::Best => (12, 25, 100), - Quality::Balanced => (12, 35, 100 * 2 / 3), - Quality::Low => (18, 45, 50), - Quality::Custom(b) => { - let (q_min, q_max) = Self::calc_q_values(b); - (q_min, q_max, b) - } - } + fn bitrate(width: u32, height: u32, ratio: f32) -> u32 { + let bitrate = base_bitrate(width, height) as f32; + (bitrate * ratio) as u32 } #[inline] - fn calc_q_values(b: u32) -> (u32, u32) { + fn calc_q_values(ratio: f32) -> (u32, u32) { + let b = (ratio * 100.0) as u32; let b = std::cmp::min(b, 200); - let q_min1: i32 = 24; + let q_min1 = 24; let q_min2 = 5; let q_max1 = 45; let q_max2 = 25; let t = b as f32 / 200.0; - let q_min: u32 = ((1.0 - t) * q_min1 as f32 + t * q_min2 as f32).round() as u32; - let q_max = ((1.0 - t) * q_max1 as f32 + t * q_max2 as f32).round() as u32; + let mut q_min: u32 = ((1.0 - t) * q_min1 as f32 + t * q_min2 as f32).round() as u32; + let mut q_max = ((1.0 - t) * q_max1 as f32 + t * q_max2 as f32).round() as u32; + + q_min = q_min.clamp(q_min2, q_min1); + q_max = q_max.clamp(q_max2, q_max1); (q_min, q_max) } @@ -490,7 +461,7 @@ impl AomDecoder { Ok(Self { ctx }) } - pub fn decode(&mut self, data: &[u8]) -> Result { + pub fn decode<'a>(&'a mut self, data: &[u8]) -> Result> { call_aom!(aom_codec_decode( &mut self.ctx, data.as_ptr(), @@ -505,7 +476,7 @@ impl AomDecoder { } /// Notify the decoder to return any pending frame - pub fn flush(&mut self) -> Result { + pub fn flush<'a>(&'a mut self) -> Result> { call_aom!(aom_codec_decode( &mut self.ctx, ptr::null(), diff --git a/libs/scrap/src/common/camera.rs b/libs/scrap/src/common/camera.rs new file mode 100644 index 000000000..ea259bdc1 --- /dev/null +++ b/libs/scrap/src/common/camera.rs @@ -0,0 +1,286 @@ +use std::{ + io, + sync::{Arc, Mutex}, +}; + +#[cfg(any(target_os = "windows", target_os = "linux"))] +use nokhwa::{ + pixel_format::RgbAFormat, + query, + utils::{ApiBackend, CameraIndex, RequestedFormat, RequestedFormatType}, + Camera, +}; + +use hbb_common::message_proto::{DisplayInfo, Resolution}; + +#[cfg(feature = "vram")] +use crate::AdapterDevice; + +use crate::common::{bail, ResultType}; +use crate::{Frame, TraitCapturer}; +#[cfg(any(target_os = "windows", target_os = "linux"))] +use crate::{PixelBuffer, Pixfmt}; + +pub const PRIMARY_CAMERA_IDX: usize = 0; +lazy_static::lazy_static! { + static ref SYNC_CAMERA_DISPLAYS: Arc>> = Arc::new(Mutex::new(Vec::new())); +} + +#[cfg(not(any(target_os = "windows", target_os = "linux")))] +const CAMERA_NOT_SUPPORTED: &str = "This platform doesn't support camera yet"; + +pub struct Cameras; + +// pre-condition +pub fn primary_camera_exists() -> bool { + Cameras::exists(PRIMARY_CAMERA_IDX) +} + +#[cfg(any(target_os = "windows", target_os = "linux"))] +impl Cameras { + pub fn all_info() -> ResultType> { + match query(ApiBackend::Auto) { + Ok(cameras) => { + let mut camera_displays = SYNC_CAMERA_DISPLAYS.lock().unwrap(); + camera_displays.clear(); + // FIXME: nokhwa returns duplicate info for one physical camera on linux for now. + // issue: https://github.com/l1npengtul/nokhwa/issues/171 + // Use only one camera as a temporary hack. + cfg_if::cfg_if! { + if #[cfg(target_os = "linux")] { + let Some(info) = cameras.first() else { + bail!("No camera found") + }; + // Use index (0) camera as main camera, fallback to the first camera if index (0) is not available. + // But maybe we also need to check index (1) or the lowest index camera. + // + // https://askubuntu.com/questions/234362/how-to-fix-this-problem-where-sometimes-dev-video0-becomes-automatically-dev + // https://github.com/rustdesk/rustdesk/pull/12010#issue-3125329069 + let mut camera_index = info.index().clone(); + if !matches!(camera_index, CameraIndex::Index(0)) { + if cameras.iter().any(|cam| matches!(cam.index(), CameraIndex::Index(0))) { + camera_index = CameraIndex::Index(0); + } + } + let camera = Self::create_camera(&camera_index)?; + let resolution = camera.resolution(); + let (width, height) = (resolution.width() as i32, resolution.height() as i32); + camera_displays.push(DisplayInfo { + x: 0, + y: 0, + name: info.human_name().clone(), + width, + height, + online: true, + cursor_embedded: false, + scale:1.0, + original_resolution: Some(Resolution { + width, + height, + ..Default::default() + }).into(), + ..Default::default() + }); + } else { + let mut x = 0; + for info in &cameras { + let camera = Self::create_camera(info.index())?; + let resolution = camera.resolution(); + let (width, height) = (resolution.width() as i32, resolution.height() as i32); + camera_displays.push(DisplayInfo { + x, + y: 0, + name: info.human_name().clone(), + width, + height, + online: true, + cursor_embedded: false, + scale:1.0, + original_resolution: Some(Resolution { + width, + height, + ..Default::default() + }).into(), + ..Default::default() + }); + x += width; + } + } + } + Ok(camera_displays.clone()) + } + Err(e) => { + bail!("Query cameras error: {}", e) + } + } + } + + pub fn exists(index: usize) -> bool { + match query(ApiBackend::Auto) { + Ok(cameras) => index < cameras.len(), + _ => return false, + } + } + + fn create_camera(index: &CameraIndex) -> ResultType { + let format_type = if cfg!(target_os = "linux") { + RequestedFormatType::None + } else { + RequestedFormatType::AbsoluteHighestResolution + }; + let result = Camera::new( + index.clone(), + RequestedFormat::new::(format_type), + ); + match result { + Ok(camera) => Ok(camera), + Err(e) => bail!("create camera{} error: {}", index, e), + } + } + + pub fn get_camera_resolution(index: usize) -> ResultType { + let index = CameraIndex::Index(index as u32); + let camera = Self::create_camera(&index)?; + let resolution = camera.resolution(); + Ok(Resolution { + width: resolution.width() as i32, + height: resolution.height() as i32, + ..Default::default() + }) + } + + pub fn get_sync_cameras() -> Vec { + SYNC_CAMERA_DISPLAYS.lock().unwrap().clone() + } + + pub fn get_capturer(current: usize) -> ResultType> { + Ok(Box::new(CameraCapturer::new(current)?)) + } +} + +#[cfg(not(any(target_os = "windows", target_os = "linux")))] +impl Cameras { + pub fn all_info() -> ResultType> { + return Ok(Vec::new()); + } + + pub fn exists(_index: usize) -> bool { + false + } + + pub fn get_camera_resolution(_index: usize) -> ResultType { + bail!(CAMERA_NOT_SUPPORTED); + } + + pub fn get_sync_cameras() -> Vec { + vec![] + } + + pub fn get_capturer(_current: usize) -> ResultType> { + bail!(CAMERA_NOT_SUPPORTED); + } +} + +#[cfg(any(target_os = "windows", target_os = "linux"))] +pub struct CameraCapturer { + camera: Camera, + data: Vec, + last_data: Vec, // for faster compare and copy +} + +#[cfg(not(any(target_os = "windows", target_os = "linux")))] +pub struct CameraCapturer; + +impl CameraCapturer { + #[cfg(any(target_os = "windows", target_os = "linux"))] + fn new(current: usize) -> ResultType { + let index = CameraIndex::Index(current as u32); + let camera = Cameras::create_camera(&index)?; + Ok(CameraCapturer { + camera, + data: Vec::new(), + last_data: Vec::new(), + }) + } + + #[allow(dead_code)] + #[cfg(not(any(target_os = "windows", target_os = "linux")))] + fn new(_current: usize) -> ResultType { + bail!(CAMERA_NOT_SUPPORTED); + } +} + +impl TraitCapturer for CameraCapturer { + #[cfg(any(target_os = "windows", target_os = "linux"))] + fn frame<'a>(&'a mut self, _timeout: std::time::Duration) -> std::io::Result> { + // TODO: move this check outside `frame`. + if !self.camera.is_stream_open() { + if let Err(e) = self.camera.open_stream() { + return Err(io::Error::new( + io::ErrorKind::Other, + format!("Camera open stream error: {}", e), + )); + } + } + match self.camera.frame() { + Ok(buffer) => { + match buffer.decode_image::() { + Ok(decoded) => { + self.data = decoded.as_raw().to_vec(); + crate::would_block_if_equal(&mut self.last_data, &self.data)?; + // FIXME: macos's PixelBuffer cannot be directly created from bytes slice. + cfg_if::cfg_if! { + if #[cfg(any(target_os = "linux", target_os = "windows"))] { + Ok(Frame::PixelBuffer(PixelBuffer::new( + &self.data, + Pixfmt::RGBA, + decoded.width() as usize, + decoded.height() as usize, + ))) + } else { + Err(io::Error::new( + io::ErrorKind::Other, + format!("Camera is not supported on this platform yet"), + )) + } + } + } + Err(e) => Err(io::Error::new( + io::ErrorKind::Other, + format!("Camera frame decode error: {}", e), + )), + } + } + Err(e) => Err(io::Error::new( + io::ErrorKind::Other, + format!("Camera frame error: {}", e), + )), + } + } + + #[cfg(not(any(target_os = "windows", target_os = "linux")))] + fn frame<'a>(&'a mut self, _timeout: std::time::Duration) -> std::io::Result> { + Err(io::Error::new( + io::ErrorKind::Other, + CAMERA_NOT_SUPPORTED.to_string(), + )) + } + + #[cfg(windows)] + fn is_gdi(&self) -> bool { + true + } + + #[cfg(windows)] + fn set_gdi(&mut self) -> bool { + true + } + + #[cfg(feature = "vram")] + fn device(&self) -> AdapterDevice { + AdapterDevice::default() + } + + #[cfg(feature = "vram")] + fn set_output_texture(&mut self, _texture: bool) {} +} diff --git a/libs/scrap/src/common/codec.rs b/libs/scrap/src/common/codec.rs index dad924c86..9b072e1bd 100644 --- a/libs/scrap/src/common/codec.rs +++ b/libs/scrap/src/common/codec.rs @@ -1,8 +1,8 @@ use std::{ collections::HashMap, - ffi::c_void, ops::{Deref, DerefMut}, sync::{Arc, Mutex}, + time::Instant, }; #[cfg(feature = "hwcodec")] @@ -18,17 +18,23 @@ use crate::{ CodecFormat, EncodeInput, EncodeYuvFormat, ImageRgb, ImageTexture, }; +#[cfg(any( + feature = "hwcodec", + feature = "mediacodec", + feature = "vram", + target_os = "windows" +))] +use hbb_common::config::option2bool; use hbb_common::{ anyhow::anyhow, bail, - config::{option2bool, Config, PeerConfig}, + config::{Config, PeerConfig}, lazy_static, log, message_proto::{ supported_decoding::PreferCodec, video_frame, Chroma, CodecAbility, EncodedVideoFrames, SupportedDecoding, SupportedEncoding, VideoFrame, }, sysinfo::System, - tokio::time::Instant, ResultType, }; @@ -63,12 +69,10 @@ pub trait EncoderApi { #[cfg(feature = "vram")] fn input_texture(&self) -> bool; - fn set_quality(&mut self, quality: Quality) -> ResultType<()>; + fn set_quality(&mut self, ratio: f32) -> ResultType<()>; fn bitrate(&self) -> u32; - fn support_abr(&self) -> bool; - fn support_changing_quality(&self) -> bool; fn latency_free(&self) -> bool; @@ -264,15 +268,20 @@ impl Encoder { .unwrap_or((PreferCodec::Auto.into(), 0)); let preference = most_frequent.enum_value_or(PreferCodec::Auto); - // auto: h265 > h264 > vp9/vp8 - let mut auto_codec = CodecFormat::VP9; + // auto: h265 > h264 > av1/vp9/vp8 + let av1_test = Config::get_option(hbb_common::config::keys::OPTION_AV1_TEST) != "N"; + let mut auto_codec = if av1_useable && av1_test { + CodecFormat::AV1 + } else { + CodecFormat::VP9 + }; if h264_useable { auto_codec = CodecFormat::H264; } if h265_useable { auto_codec = CodecFormat::H265; } - if auto_codec == CodecFormat::VP9 { + if auto_codec == CodecFormat::VP9 || auto_codec == CodecFormat::AV1 { let mut system = System::new(); system.refresh_memory(); if vp8_useable && system.total_memory() <= 4 * 1024 * 1024 * 1024 { @@ -862,7 +871,7 @@ pub fn enable_vram_option(encode: bool) -> bool { if encode { enable && enable_directx_capture() } else { - enable + enable && allow_d3d_render() } } else { false @@ -872,18 +881,25 @@ pub fn enable_vram_option(encode: bool) -> bool { #[cfg(windows)] pub fn enable_directx_capture() -> bool { use hbb_common::config::keys::OPTION_ENABLE_DIRECTX_CAPTURE as OPTION; - option2bool( - OPTION, - &Config::get_option(hbb_common::config::keys::OPTION_ENABLE_DIRECTX_CAPTURE), - ) + option2bool(OPTION, &Config::get_option(OPTION)) } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg(windows)] +pub fn allow_d3d_render() -> bool { + use hbb_common::config::keys::OPTION_ALLOW_D3D_RENDER as OPTION; + option2bool(OPTION, &hbb_common::config::LocalConfig::get_option(OPTION)) +} + +pub const BR_BEST: f32 = 1.5; +pub const BR_BALANCED: f32 = 0.67; +pub const BR_SPEED: f32 = 0.5; + +#[derive(Debug, Clone, Copy, PartialEq)] pub enum Quality { Best, Balanced, Low, - Custom(u32), + Custom(f32), } impl Default for Quality { @@ -899,22 +915,59 @@ impl Quality { _ => false, } } + + pub fn ratio(&self) -> f32 { + match self { + Quality::Best => BR_BEST, + Quality::Balanced => BR_BALANCED, + Quality::Low => BR_SPEED, + Quality::Custom(v) => *v, + } + } } pub fn base_bitrate(width: u32, height: u32) -> u32 { - #[allow(unused_mut)] - let mut base_bitrate = ((width * height) / 1000) as u32; // same as 1.1.9 - if base_bitrate == 0 { - base_bitrate = 1920 * 1080 / 1000; - } + const RESOLUTION_PRESETS: &[(u32, u32, u32)] = &[ + (640, 480, 400), // VGA, 307k pixels + (800, 600, 500), // SVGA, 480k pixels + (1024, 768, 800), // XGA, 786k pixels + (1280, 720, 1000), // 720p, 921k pixels + (1366, 768, 1100), // HD, 1049k pixels + (1440, 900, 1300), // WXGA+, 1296k pixels + (1600, 900, 1500), // HD+, 1440k pixels + (1920, 1080, 2073), // 1080p, 2073k pixels + (2048, 1080, 2200), // 2K DCI, 2211k pixels + (2560, 1440, 3000), // 2K QHD, 3686k pixels + (3440, 1440, 4000), // UWQHD, 4953k pixels + (3840, 2160, 5000), // 4K UHD, 8294k pixels + (7680, 4320, 12000), // 8K UHD, 33177k pixels + ]; + let pixels = width * height; + + let (preset_pixels, preset_bitrate) = RESOLUTION_PRESETS + .iter() + .map(|(w, h, bitrate)| (w * h, bitrate)) + .min_by_key(|(preset_pixels, _)| { + if *preset_pixels >= pixels { + preset_pixels - pixels + } else { + pixels - preset_pixels + } + }) + .unwrap_or(((1920 * 1080) as u32, &2073)); // default 1080p + + let bitrate = (*preset_bitrate as f32 * (pixels as f32 / preset_pixels as f32)).round() as u32; + #[cfg(target_os = "android")] { - // fix when android screen shrinks let fix = crate::Display::fix_quality() as u32; log::debug!("Android screen, fix quality:{}", fix); - base_bitrate = base_bitrate * fix; + bitrate * fix + } + #[cfg(not(target_os = "android"))] + { + bitrate } - base_bitrate } pub fn codec_thread_num(limit: usize) -> usize { @@ -982,3 +1035,123 @@ fn disable_av1() -> bool { // disable it for all 32 bit platforms std::mem::size_of::() == 4 } + +#[cfg(not(target_os = "ios"))] +pub fn test_av1() { + use hbb_common::config::keys::OPTION_AV1_TEST; + use hbb_common::rand::Rng; + use std::{sync::Once, time::Duration}; + + if disable_av1() || !Config::get_option(OPTION_AV1_TEST).is_empty() { + log::info!("skip test av1"); + return; + } + + static ONCE: Once = Once::new(); + ONCE.call_once(|| { + let f = || { + let (width, height, quality, keyframe_interval, i444) = (1920, 1080, 1.0, None, false); + let frame_count = 10; + let block_size = 300; + let move_step = 50; + let generate_fake_data = + |frame_index: u32, dst_fmt: EncodeYuvFormat| -> ResultType> { + let mut rng = hbb_common::rand::thread_rng(); + let mut bgra = vec![0u8; (width * height * 4) as usize]; + let gradient = frame_index as f32 / frame_count as f32; + // floating block + let x0 = (frame_index * move_step) % (width - block_size); + let y0 = (frame_index * move_step) % (height - block_size); + // Fill the block with random colors + for y in 0..block_size { + for x in 0..block_size { + let index = (((y0 + y) * width + x0 + x) * 4) as usize; + if index + 3 < bgra.len() { + let noise = rng.gen_range(0..255) as f32 / 255.0; + let value = (255.0 * gradient + noise * 50.0) as u8; + bgra[index] = value; + bgra[index + 1] = value; + bgra[index + 2] = value; + bgra[index + 3] = 255; + } + } + } + let dst_stride_y = dst_fmt.stride[0]; + let dst_stride_uv = dst_fmt.stride[1]; + let mut dst = vec![0u8; (dst_fmt.h * dst_stride_y * 2) as usize]; + let dst_y = dst.as_mut_ptr(); + let dst_u = dst[dst_fmt.u..].as_mut_ptr(); + let dst_v = dst[dst_fmt.v..].as_mut_ptr(); + let res = unsafe { + crate::ARGBToI420( + bgra.as_ptr(), + (width * 4) as _, + dst_y, + dst_stride_y as _, + dst_u, + dst_stride_uv as _, + dst_v, + dst_stride_uv as _, + width as _, + height as _, + ) + }; + if res != 0 { + bail!("ARGBToI420 failed: {}", res); + } + Ok(dst) + }; + let Ok(mut av1) = AomEncoder::new( + EncoderCfg::AOM(AomEncoderConfig { + width, + height, + quality, + keyframe_interval, + }), + i444, + ) else { + return false; + }; + let mut key_frame_time = Duration::ZERO; + let mut non_key_frame_time_sum = Duration::ZERO; + let pts = Instant::now(); + let yuvfmt = av1.yuvfmt(); + for i in 0..frame_count { + let Ok(yuv) = generate_fake_data(i, yuvfmt.clone()) else { + return false; + }; + let start = Instant::now(); + if av1 + .encode(pts.elapsed().as_millis() as _, &yuv, super::STRIDE_ALIGN) + .is_err() + { + log::debug!("av1 encode failed"); + if i == 0 { + return false; + } + } + if i == 0 { + key_frame_time = start.elapsed(); + } else { + non_key_frame_time_sum += start.elapsed(); + } + } + let non_key_frame_time = non_key_frame_time_sum / (frame_count - 1); + log::info!( + "av1 time: key: {:?}, non-key: {:?}, consume: {:?}", + key_frame_time, + non_key_frame_time, + pts.elapsed() + ); + key_frame_time < Duration::from_millis(90) + && non_key_frame_time < Duration::from_millis(30) + }; + std::thread::spawn(move || { + let v = f(); + Config::set_option( + OPTION_AV1_TEST.to_string(), + if v { "Y" } else { "N" }.to_string(), + ); + }); + }); +} diff --git a/libs/scrap/src/common/convert.rs b/libs/scrap/src/common/convert.rs index d38831928..40c17e5ba 100644 --- a/libs/scrap/src/common/convert.rs +++ b/libs/scrap/src/common/convert.rs @@ -197,3 +197,40 @@ pub fn convert_to_yuv( } Ok(()) } + +#[cfg(not(target_os = "ios"))] +pub fn convert(captured: &PixelBuffer, pixfmt: crate::Pixfmt, dst: &mut Vec) -> ResultType<()> { + if captured.pixfmt() == pixfmt { + dst.extend_from_slice(captured.data()); + return Ok(()); + } + + let src = captured.data(); + let src_stride = captured.stride(); + let src_pixfmt = captured.pixfmt(); + let src_width = captured.width(); + let src_height = captured.height(); + + let unsupported = format!( + "unsupported pixfmt conversion: {src_pixfmt:?} -> {:?}", + pixfmt + ); + + match (src_pixfmt, pixfmt) { + (crate::Pixfmt::BGRA, crate::Pixfmt::RGBA) | (crate::Pixfmt::RGBA, crate::Pixfmt::BGRA) => { + dst.resize(src.len(), 0); + call_yuv!(ABGRToARGB( + src.as_ptr(), + src_stride[0] as _, + dst.as_mut_ptr(), + src_stride[0] as _, + src_width as _, + src_height as _, + )); + } + _ => { + bail!(unsupported); + } + } + Ok(()) +} diff --git a/libs/scrap/src/common/dxgi.rs b/libs/scrap/src/common/dxgi.rs index ae2f1130f..f7bf167d2 100644 --- a/libs/scrap/src/common/dxgi.rs +++ b/libs/scrap/src/common/dxgi.rs @@ -70,23 +70,30 @@ impl TraitCapturer for Capturer { pub struct PixelBuffer<'a> { data: &'a [u8], + pixfmt: Pixfmt, width: usize, height: usize, stride: Vec, } impl<'a> PixelBuffer<'a> { - pub fn new(data: &'a [u8], width: usize, height: usize) -> Self { + pub fn new(data: &'a [u8], pixfmt: Pixfmt, width: usize, height: usize) -> Self { let stride0 = data.len() / height; let mut stride = Vec::new(); stride.push(stride0); PixelBuffer { data, + pixfmt, width, height, stride, } } + + #[allow(non_snake_case)] + pub fn with_BGRA(data: &'a [u8], width: usize, height: usize) -> Self { + Self::new(data, Pixfmt::BGRA, width, height) + } } impl<'a> crate::TraitPixelBuffer for PixelBuffer<'a> { @@ -107,7 +114,7 @@ impl<'a> crate::TraitPixelBuffer for PixelBuffer<'a> { } fn pixfmt(&self) -> Pixfmt { - Pixfmt::BGRA + self.pixfmt } } @@ -232,7 +239,7 @@ impl CapturerMag { impl TraitCapturer for CapturerMag { fn frame<'a>(&'a mut self, _timeout_ms: Duration) -> io::Result> { self.inner.frame(&mut self.data)?; - Ok(Frame::PixelBuffer(PixelBuffer::new( + Ok(Frame::PixelBuffer(PixelBuffer::with_BGRA( &self.data, self.inner.get_rect().1, self.inner.get_rect().2, diff --git a/libs/scrap/src/common/hwcodec.rs b/libs/scrap/src/common/hwcodec.rs index a0e730c91..17eda7f3c 100644 --- a/libs/scrap/src/common/hwcodec.rs +++ b/libs/scrap/src/common/hwcodec.rs @@ -1,7 +1,5 @@ use crate::{ - codec::{ - base_bitrate, codec_thread_num, enable_hwcodec_option, EncoderApi, EncoderCfg, Quality as Q, - }, + codec::{base_bitrate, codec_thread_num, enable_hwcodec_option, EncoderApi, EncoderCfg}, convert::*, CodecFormat, EncodeInput, ImageFormat, ImageRgb, Pixfmt, HW_STRIDE_ALIGN, }; @@ -15,7 +13,7 @@ use hbb_common::{ }; use hwcodec::{ common::{ - DataFormat, + DataFormat, HwcodecErrno, Quality::{self, *}, RateControl::{self, *}, }, @@ -31,6 +29,7 @@ const DEFAULT_PIXFMT: AVPixelFormat = AVPixelFormat::AV_PIX_FMT_NV12; pub const DEFAULT_FPS: i32 = 30; const DEFAULT_GOP: i32 = i32::MAX; const DEFAULT_HW_QUALITY: Quality = Quality_Default; +pub const ERR_HEVC_POC: i32 = HwcodecErrno::HWCODEC_ERR_HEVC_COULD_NOT_FIND_POC as i32; crate::generate_call_macro!(call_yuv, false); @@ -46,7 +45,7 @@ pub struct HwRamEncoderConfig { pub mc_name: Option, pub width: usize, pub height: usize, - pub quality: Q, + pub quality: f32, pub keyframe_interval: Option, } @@ -66,12 +65,8 @@ impl EncoderApi for HwRamEncoder { match cfg { EncoderCfg::HWRAM(config) => { let rc = Self::rate_control(&config); - let b = Self::convert_quality(&config.name, config.quality); - let base_bitrate = base_bitrate(config.width as _, config.height as _); - let mut bitrate = base_bitrate * b / 100; - if base_bitrate <= 0 { - bitrate = base_bitrate; - } + let mut bitrate = + Self::bitrate(&config.name, config.width, config.height, config.quality); bitrate = Self::check_bitrate_range(&config, bitrate); let gop = config.keyframe_interval.unwrap_or(DEFAULT_GOP as _) as i32; let ctx = EncodeContext { @@ -175,15 +170,19 @@ impl EncoderApi for HwRamEncoder { false } - fn set_quality(&mut self, quality: crate::codec::Quality) -> ResultType<()> { - let b = Self::convert_quality(&self.config.name, quality); - let mut bitrate = base_bitrate(self.config.width as _, self.config.height as _) * b / 100; + fn set_quality(&mut self, ratio: f32) -> ResultType<()> { + let mut bitrate = Self::bitrate( + &self.config.name, + self.config.width, + self.config.height, + ratio, + ); if bitrate > 0 { - bitrate = Self::check_bitrate_range(&self.config, self.bitrate); + bitrate = Self::check_bitrate_range(&self.config, bitrate); self.encoder.set_bitrate(bitrate as _).ok(); self.bitrate = bitrate; } - self.config.quality = quality; + self.config.quality = ratio; Ok(()) } @@ -191,16 +190,8 @@ impl EncoderApi for HwRamEncoder { self.bitrate } - fn support_abr(&self) -> bool { - ["qsv", "vaapi", "mediacodec", "videotoolbox"] - .iter() - .all(|&x| !self.config.name.contains(x)) - } - fn support_changing_quality(&self) -> bool { - ["vaapi", "mediacodec", "videotoolbox"] - .iter() - .all(|&x| !self.config.name.contains(x)) + ["vaapi"].iter().all(|&x| !self.config.name.contains(x)) } fn latency_free(&self) -> bool { @@ -257,21 +248,35 @@ impl HwRamEncoder { RC_CBR } - pub fn convert_quality(name: &str, quality: crate::codec::Quality) -> u32 { - use crate::codec::Quality; - let quality = match quality { - Quality::Best => 150, - Quality::Balanced => 100, - Quality::Low => 50, - Quality::Custom(b) => b, - }; - let factor = if name.contains("mediacodec") { + pub fn bitrate(name: &str, width: usize, height: usize, ratio: f32) -> u32 { + Self::calc_bitrate(width, height, ratio, name.contains("h264")) + } + + pub fn calc_bitrate(width: usize, height: usize, ratio: f32, h264: bool) -> u32 { + let base = base_bitrate(width as _, height as _) as f32 * ratio; + let threshold = 2000.0; + let decay_rate = 0.001; // 1000 * 0.001 = 1 + let factor: f32 = if cfg!(target_os = "android") { // https://stackoverflow.com/questions/26110337/what-are-valid-bit-rates-to-set-for-mediacodec?rq=3 - 5 + if base > threshold { + 1.0 + 4.0 / (1.0 + (base - threshold) * decay_rate) + } else { + 5.0 + } + } else if h264 { + if base > threshold { + 1.0 + 1.0 / (1.0 + (base - threshold) * decay_rate) + } else { + 2.0 + } } else { - 1 + if base > threshold { + 1.0 + 0.5 / (1.0 + (base - threshold) * decay_rate) + } else { + 1.5 + } }; - quality * factor + (base * factor) as u32 } pub fn check_bitrate_range(_config: &HwRamEncoderConfig, bitrate: u32) -> u32 { @@ -359,7 +364,7 @@ impl HwRamDecoder { } } } - pub fn decode(&mut self, data: &[u8]) -> ResultType> { + pub fn decode<'a>(&'a mut self, data: &[u8]) -> ResultType>> { match self.decoder.decode(data) { Ok(v) => Ok(v.iter().map(|f| HwRamDecoderImage { frame: f }).collect()), Err(e) => Err(anyhow!(e)), @@ -673,6 +678,8 @@ impl HwCodecConfig { } pub fn check_available_hwcodec() -> String { + #[cfg(any(target_os = "linux", target_os = "macos"))] + hwcodec::common::setup_parent_death_signal(); let ctx = EncodeContext { name: String::from(""), mc_name: None, @@ -680,7 +687,7 @@ pub fn check_available_hwcodec() -> String { height: 720, pixfmt: DEFAULT_PIXFMT, align: HW_STRIDE_ALIGN as _, - kbs: 0, + kbs: 1000, fps: DEFAULT_FPS, gop: DEFAULT_GOP, quality: DEFAULT_HW_QUALITY, @@ -695,8 +702,8 @@ pub fn check_available_hwcodec() -> String { #[cfg(not(feature = "vram"))] let vram_string = "".to_owned(); let c = HwCodecConfig { - ram_encode: Encoder::available_encoders(ctx, Some(vram_string.clone())), - ram_decode: Decoder::available_decoders(Some(vram_string)), + ram_encode: Encoder::available_encoders(ctx, Some(vram_string)), + ram_decode: Decoder::available_decoders(), #[cfg(feature = "vram")] vram_encode: vram.0, #[cfg(feature = "vram")] @@ -719,6 +726,8 @@ pub fn start_check_process() { if let Some(_) = exe.file_name().to_owned() { let arg = "--check-hwcodec-config"; if let Ok(mut child) = std::process::Command::new(exe).arg(arg).spawn() { + #[cfg(windows)] + hwcodec::common::child_exit_when_parent_exit(child.id()); // wait up to 30 seconds, it maybe slow on windows startup for poorly performing machines for _ in 0..30 { std::thread::sleep(std::time::Duration::from_secs(1)); diff --git a/libs/scrap/src/common/linux.rs b/libs/scrap/src/common/linux.rs index 4e83e6e7c..ba5e8e7ff 100644 --- a/libs/scrap/src/common/linux.rs +++ b/libs/scrap/src/common/linux.rs @@ -88,6 +88,27 @@ impl Display { } } + pub fn scale(&self) -> f64 { + match self { + Display::X11(_d) => 1.0, + Display::WAYLAND(d) => d.scale(), + } + } + + pub fn logical_width(&self) -> usize { + match self { + Display::X11(d) => d.width(), + Display::WAYLAND(d) => d.logical_width(), + } + } + + pub fn logical_height(&self) -> usize { + match self { + Display::X11(d) => d.height(), + Display::WAYLAND(d) => d.logical_height(), + } + } + pub fn origin(&self) -> (i32, i32) { match self { Display::X11(d) => d.origin(), diff --git a/libs/scrap/src/common/mediacodec.rs b/libs/scrap/src/common/mediacodec.rs index bd3eace7b..8ec5e6b8f 100644 --- a/libs/scrap/src/common/mediacodec.rs +++ b/libs/scrap/src/common/mediacodec.rs @@ -151,7 +151,7 @@ fn create_media_codec(name: &str, direction: MediaCodecDirection) -> Option, pub w: usize, @@ -189,7 +192,7 @@ impl Frame<'_> { yuvfmt: EncodeYuvFormat, yuv: &'a mut Vec, mid_data: &mut Vec, - ) -> ResultType { + ) -> ResultType> { match self { Frame::PixelBuffer(pixelbuffer) => { convert_to_yuv(&pixelbuffer, yuvfmt, yuv, mid_data)?; diff --git a/libs/scrap/src/common/record.rs b/libs/scrap/src/common/record.rs index c53b77431..d121984f1 100644 --- a/libs/scrap/src/common/record.rs +++ b/libs/scrap/src/common/record.rs @@ -25,7 +25,8 @@ pub struct RecorderContext { pub server: bool, pub id: String, pub dir: String, - pub display: usize, + pub display_idx: usize, + pub camera: bool, pub tx: Option>, } @@ -46,7 +47,11 @@ impl RecorderContext2 { + "_" + &ctx.id.clone() + &chrono::Local::now().format("_%Y%m%d%H%M%S%3f_").to_string() - + &format!("display{}_", ctx.display) + + &format!( + "{}{}_", + if ctx.camera { "camera" } else { "display" }, + ctx.display_idx + ) + &self.format.to_string().to_lowercase() + if self.format == CodecFormat::VP9 || self.format == CodecFormat::VP8 @@ -158,6 +163,7 @@ impl Recorder { #[cfg(not(feature = "hwcodec"))] _ => bail!("unsupported codec type"), }; + // pts is None when new inner is created self.pts = None; self.send_state(RecordState::NewFile(ctx2.filename.clone())); } @@ -194,33 +200,33 @@ impl Recorder { match frame { video_frame::Union::Vp8s(vp8s) => { for f in vp8s.frames.iter() { - self.check_pts(f.pts, w, h, format)?; + self.check_pts(f.pts, f.key, w, h, format)?; self.as_mut().map(|x| x.write_video(f)); } } video_frame::Union::Vp9s(vp9s) => { for f in vp9s.frames.iter() { - self.check_pts(f.pts, w, h, format)?; + self.check_pts(f.pts, f.key, w, h, format)?; self.as_mut().map(|x| x.write_video(f)); } } video_frame::Union::Av1s(av1s) => { for f in av1s.frames.iter() { - self.check_pts(f.pts, w, h, format)?; + self.check_pts(f.pts, f.key, w, h, format)?; self.as_mut().map(|x| x.write_video(f)); } } #[cfg(feature = "hwcodec")] video_frame::Union::H264s(h264s) => { for f in h264s.frames.iter() { - self.check_pts(f.pts, w, h, format)?; + self.check_pts(f.pts, f.key, w, h, format)?; self.as_mut().map(|x| x.write_video(f)); } } #[cfg(feature = "hwcodec")] video_frame::Union::H265s(h265s) => { for f in h265s.frames.iter() { - self.check_pts(f.pts, w, h, format)?; + self.check_pts(f.pts, f.key, w, h, format)?; self.as_mut().map(|x| x.write_video(f)); } } @@ -230,8 +236,18 @@ impl Recorder { Ok(()) } - fn check_pts(&mut self, pts: i64, w: usize, h: usize, format: CodecFormat) -> ResultType<()> { + fn check_pts( + &mut self, + pts: i64, + key: bool, + w: usize, + h: usize, + format: CodecFormat, + ) -> ResultType<()> { // https://stackoverflow.com/questions/76379101/how-to-create-one-playable-webm-file-from-two-different-video-tracks-with-same-c + if self.pts.is_none() && !key { + bail!("first frame is not key frame"); + } let old_pts = self.pts; self.pts = Some(pts); if old_pts.clone().unwrap_or_default() > pts { @@ -342,7 +358,7 @@ impl Drop for WebmRecorder { #[cfg(feature = "hwcodec")] struct HwRecorder { - muxer: Muxer, + muxer: Option, ctx: RecorderContext, ctx2: RecorderContext2, written: bool, @@ -362,7 +378,7 @@ impl RecorderApi for HwRecorder { }) .map_err(|_| anyhow!("Failed to create hardware muxer"))?; Ok(HwRecorder { - muxer, + muxer: Some(muxer), ctx, ctx2, written: false, @@ -376,7 +392,11 @@ impl RecorderApi for HwRecorder { self.key = true; } if self.key { - let ok = self.muxer.write_video(&frame.data, frame.key).is_ok(); + let ok = self + .muxer + .as_mut() + .map(|m| m.write_video(&frame.data, frame.key).is_ok()) + .unwrap_or_default(); if ok { self.written = true; } @@ -390,9 +410,11 @@ impl RecorderApi for HwRecorder { #[cfg(feature = "hwcodec")] impl Drop for HwRecorder { fn drop(&mut self) { - self.muxer.write_tail().ok(); + self.muxer.as_mut().map(|m| m.write_tail().ok()); let mut state = RecordState::WriteTail; if !self.written || self.start.elapsed().as_secs() < MIN_SECS { + // The process cannot access the file because it is being used by another process + self.muxer = None; std::fs::remove_file(&self.ctx2.filename).ok(); state = RecordState::RemoveFile; } diff --git a/libs/scrap/src/common/vpx.rs b/libs/scrap/src/common/vpx.rs index eb655314b..d627dcf6c 100644 --- a/libs/scrap/src/common/vpx.rs +++ b/libs/scrap/src/common/vpx.rs @@ -3,6 +3,7 @@ #![allow(non_upper_case_globals)] #![allow(improper_ctypes)] #![allow(dead_code)] +#![allow(unused_imports)] impl Default for vpx_codec_enc_cfg { fn default() -> Self { diff --git a/libs/scrap/src/common/vpxcodec.rs b/libs/scrap/src/common/vpxcodec.rs index 11b497fb3..f41dfb134 100644 --- a/libs/scrap/src/common/vpxcodec.rs +++ b/libs/scrap/src/common/vpxcodec.rs @@ -1,13 +1,14 @@ // https://github.com/astraw/vpx-encode // https://github.com/astraw/env-libvpx-sys // https://github.com/rust-av/vpx-rs/blob/master/src/decoder.rs +// https://github.com/chromium/chromium/blob/e7b24573bc2e06fed4749dd6b6abfce67f29052f/media/video/vpx_video_encoder.cc#L522 use hbb_common::anyhow::{anyhow, Context}; use hbb_common::log; use hbb_common::message_proto::{Chroma, EncodedVideoFrame, EncodedVideoFrames, VideoFrame}; use hbb_common::ResultType; -use crate::codec::{base_bitrate, codec_thread_num, EncoderApi, Quality}; +use crate::codec::{base_bitrate, codec_thread_num, EncoderApi}; use crate::{EncodeInput, EncodeYuvFormat, GoogleImage, Pixfmt, STRIDE_ALIGN}; use super::vpx::{vp8e_enc_control_id::*, vpx_codec_err_t::*, *}; @@ -19,9 +20,6 @@ use std::{ptr, slice}; generate_call_macro!(call_vpx, false); generate_call_ptr_macro!(call_vpx_ptr); -const DEFAULT_QP_MAX: u32 = 56; // no more than 63 -const DEFAULT_QP_MIN: u32 = 12; // no more than 63 - #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] pub enum VpxVideoCodecId { VP8, @@ -85,21 +83,11 @@ impl EncoderApi for VpxEncoder { c.kf_mode = vpx_kf_mode::VPX_KF_DISABLED; // reduce bandwidth a lot } - let (q_min, q_max, b) = Self::convert_quality(config.quality); - if q_min > 0 && q_min < q_max && q_max < 64 { - c.rc_min_quantizer = q_min; - c.rc_max_quantizer = q_max; - } else { - c.rc_min_quantizer = DEFAULT_QP_MIN; - c.rc_max_quantizer = DEFAULT_QP_MAX; - } - let base_bitrate = base_bitrate(config.width as _, config.height as _); - let bitrate = base_bitrate * b / 100; - if bitrate > 0 { - c.rc_target_bitrate = bitrate; - } else { - c.rc_target_bitrate = base_bitrate; - } + let (q_min, q_max) = Self::calc_q_values(config.quality); + c.rc_min_quantizer = q_min; + c.rc_max_quantizer = q_max; + c.rc_target_bitrate = + Self::bitrate(config.width as _, config.height as _, config.quality); // https://chromium.googlesource.com/webm/libvpx/+/refs/heads/main/vp9/common/vp9_enums.h#29 // https://chromium.googlesource.com/webm/libvpx/+/refs/heads/main/vp8/vp8_cx_iface.c#282 c.g_profile = if i444 && config.codec == VpxVideoCodecId::VP9 { @@ -212,17 +200,12 @@ impl EncoderApi for VpxEncoder { false } - fn set_quality(&mut self, quality: Quality) -> ResultType<()> { + fn set_quality(&mut self, ratio: f32) -> ResultType<()> { let mut c = unsafe { *self.ctx.config.enc.to_owned() }; - let (q_min, q_max, b) = Self::convert_quality(quality); - if q_min > 0 && q_min < q_max && q_max < 64 { - c.rc_min_quantizer = q_min; - c.rc_max_quantizer = q_max; - } - let bitrate = base_bitrate(self.width as _, self.height as _) * b / 100; - if bitrate > 0 { - c.rc_target_bitrate = bitrate; - } + let (q_min, q_max) = Self::calc_q_values(ratio); + c.rc_min_quantizer = q_min; + c.rc_max_quantizer = q_max; + c.rc_target_bitrate = Self::bitrate(self.width as _, self.height as _, ratio); call_vpx!(vpx_codec_enc_config_set(&mut self.ctx, &c)); Ok(()) } @@ -232,9 +215,6 @@ impl EncoderApi for VpxEncoder { c.rc_target_bitrate } - fn support_abr(&self) -> bool { - true - } fn support_changing_quality(&self) -> bool { true } @@ -251,7 +231,7 @@ impl EncoderApi for VpxEncoder { } impl VpxEncoder { - pub fn encode(&mut self, pts: i64, data: &[u8], stride_align: usize) -> Result { + pub fn encode<'a>(&'a mut self, pts: i64, data: &[u8], stride_align: usize) -> Result> { let bpp = if self.i444 { 24 } else { 12 }; if data.len() < self.width * self.height * bpp / 8 { return Err(Error::FailedCall("len not enough".to_string())); @@ -288,7 +268,7 @@ impl VpxEncoder { } /// Notify the encoder to return any pending packets - pub fn flush(&mut self) -> Result { + pub fn flush<'a>(&'a mut self) -> Result> { call_vpx!(vpx_codec_encode( &mut self.ctx, ptr::null(), @@ -331,30 +311,27 @@ impl VpxEncoder { } } - fn convert_quality(quality: Quality) -> (u32, u32, u32) { - match quality { - Quality::Best => (6, 45, 150), - Quality::Balanced => (12, 56, 100 * 2 / 3), - Quality::Low => (18, 56, 50), - Quality::Custom(b) => { - let (q_min, q_max) = Self::calc_q_values(b); - (q_min, q_max, b) - } - } + fn bitrate(width: u32, height: u32, ratio: f32) -> u32 { + let bitrate = base_bitrate(width, height) as f32; + (bitrate * ratio) as u32 } #[inline] - fn calc_q_values(b: u32) -> (u32, u32) { + fn calc_q_values(ratio: f32) -> (u32, u32) { + let b = (ratio * 100.0) as u32; let b = std::cmp::min(b, 200); - let q_min1: i32 = 36; + let q_min1 = 36; let q_min2 = 0; let q_max1 = 56; let q_max2 = 37; let t = b as f32 / 200.0; - let q_min: u32 = ((1.0 - t) * q_min1 as f32 + t * q_min2 as f32).round() as u32; - let q_max = ((1.0 - t) * q_max1 as f32 + t * q_max2 as f32).round() as u32; + let mut q_min: u32 = ((1.0 - t) * q_min1 as f32 + t * q_min2 as f32).round() as u32; + let mut q_max = ((1.0 - t) * q_max1 as f32 + t * q_max2 as f32).round() as u32; + + q_min = q_min.clamp(q_min2, q_min1); + q_max = q_max.clamp(q_max2, q_max1); (q_min, q_max) } @@ -415,8 +392,8 @@ pub struct VpxEncoderConfig { pub width: c_uint, /// The height (in pixels). pub height: c_uint, - /// The image quality - pub quality: Quality, + /// The bitrate ratio + pub quality: f32, /// The codec pub codec: VpxVideoCodecId, /// keyframe interval @@ -496,7 +473,7 @@ impl VpxDecoder { /// The `data` slice is sent to the decoder /// /// It matches a call to `vpx_codec_decode`. - pub fn decode(&mut self, data: &[u8]) -> Result { + pub fn decode<'a>(&'a mut self, data: &[u8]) -> Result> { call_vpx!(vpx_codec_decode( &mut self.ctx, data.as_ptr(), @@ -512,7 +489,7 @@ impl VpxDecoder { } /// Notify the decoder to return any pending frame - pub fn flush(&mut self) -> Result { + pub fn flush<'a>(&'a mut self) -> Result> { call_vpx!(vpx_codec_decode( &mut self.ctx, ptr::null(), diff --git a/libs/scrap/src/common/vram.rs b/libs/scrap/src/common/vram.rs index aae961df6..22645d92b 100644 --- a/libs/scrap/src/common/vram.rs +++ b/libs/scrap/src/common/vram.rs @@ -5,7 +5,7 @@ use std::{ }; use crate::{ - codec::{base_bitrate, enable_vram_option, EncoderApi, EncoderCfg, Quality}, + codec::{enable_vram_option, EncoderApi, EncoderCfg}, hwcodec::HwCodecConfig, AdapterDevice, CodecFormat, EncodeInput, EncodeYuvFormat, Pixfmt, }; @@ -17,7 +17,7 @@ use hbb_common::{ ResultType, }; use hwcodec::{ - common::{AdapterVendor::*, DataFormat, Driver, MAX_GOP}, + common::{DataFormat, Driver, MAX_GOP}, vram::{ decode::{self, DecodeFrame, Decoder}, encode::{self, EncodeFrame, Encoder}, @@ -30,8 +30,8 @@ use hwcodec::{ // https://cybersided.com/two-monitors-two-gpus/ // https://learn.microsoft.com/en-us/windows/win32/api/d3d12/nf-d3d12-id3d12device-getadapterluid#remarks lazy_static::lazy_static! { - static ref ENOCDE_NOT_USE: Arc>> = Default::default(); - static ref FALLBACK_GDI_DISPLAYS: Arc>> = Default::default(); + static ref ENOCDE_NOT_USE: Arc>> = Default::default(); + static ref FALLBACK_GDI_DISPLAYS: Arc>> = Default::default(); } #[derive(Debug, Clone)] @@ -39,7 +39,7 @@ pub struct VRamEncoderConfig { pub device: AdapterDevice, pub width: usize, pub height: usize, - pub quality: Quality, + pub quality: f32, pub feature: FeatureContext, pub keyframe_interval: Option, } @@ -51,7 +51,6 @@ pub struct VRamEncoder { bitrate: u32, last_frame_len: usize, same_bad_len_counter: usize, - config: VRamEncoderConfig, } impl EncoderApi for VRamEncoder { @@ -61,12 +60,12 @@ impl EncoderApi for VRamEncoder { { match cfg { EncoderCfg::VRAM(config) => { - let b = Self::convert_quality(config.quality, &config.feature); - let base_bitrate = base_bitrate(config.width as _, config.height as _); - let mut bitrate = base_bitrate * b / 100; - if base_bitrate <= 0 { - bitrate = base_bitrate; - } + let bitrate = Self::bitrate( + config.feature.data_format, + config.width, + config.height, + config.quality, + ); let gop = config.keyframe_interval.unwrap_or(MAX_GOP as _) as i32; let ctx = EncodeContext { f: config.feature.clone(), @@ -87,7 +86,6 @@ impl EncoderApi for VRamEncoder { bitrate, last_frame_len: 0, same_bad_len_counter: 0, - config, }), Err(_) => Err(anyhow!(format!("Failed to create encoder"))), } @@ -172,9 +170,13 @@ impl EncoderApi for VRamEncoder { true } - fn set_quality(&mut self, quality: Quality) -> ResultType<()> { - let b = Self::convert_quality(quality, &self.ctx.f); - let bitrate = base_bitrate(self.ctx.d.width as _, self.ctx.d.height as _) * b / 100; + fn set_quality(&mut self, ratio: f32) -> ResultType<()> { + let bitrate = Self::bitrate( + self.ctx.f.data_format, + self.ctx.d.width as _, + self.ctx.d.height as _, + ratio, + ); if bitrate > 0 { if self.encoder.set_bitrate((bitrate) as _).is_ok() { self.bitrate = bitrate; @@ -187,10 +189,6 @@ impl EncoderApi for VRamEncoder { self.bitrate } - fn support_abr(&self) -> bool { - self.config.device.vendor_id != ADAPTER_VENDOR_INTEL as u32 - } - fn support_changing_quality(&self) -> bool { true } @@ -285,43 +283,29 @@ impl VRamEncoder { } } - pub fn convert_quality(quality: Quality, f: &FeatureContext) -> u32 { - match quality { - Quality::Best => { - if f.driver == Driver::MFX && f.data_format == DataFormat::H264 { - 200 - } else { - 150 - } - } - Quality::Balanced => { - if f.driver == Driver::MFX && f.data_format == DataFormat::H264 { - 150 - } else { - 100 - } - } - Quality::Low => { - if f.driver == Driver::MFX && f.data_format == DataFormat::H264 { - 75 - } else { - 50 - } - } - Quality::Custom(b) => b, - } + pub fn bitrate(fmt: DataFormat, width: usize, height: usize, ratio: f32) -> u32 { + crate::hwcodec::HwRamEncoder::calc_bitrate(width, height, ratio, fmt == DataFormat::H264) } - pub fn set_not_use(display: usize, not_use: bool) { - log::info!("set display#{display} not use vram encode to {not_use}"); - ENOCDE_NOT_USE.lock().unwrap().insert(display, not_use); + pub fn set_not_use(video_service_name: String, not_use: bool) { + log::info!("set {video_service_name} not use vram encode to {not_use}"); + ENOCDE_NOT_USE + .lock() + .unwrap() + .insert(video_service_name, not_use); } - pub fn set_fallback_gdi(display: usize, fallback: bool) { + pub fn set_fallback_gdi(video_service_name: String, fallback: bool) { if fallback { - FALLBACK_GDI_DISPLAYS.lock().unwrap().insert(display); + FALLBACK_GDI_DISPLAYS + .lock() + .unwrap() + .insert(video_service_name); } else { - FALLBACK_GDI_DISPLAYS.lock().unwrap().remove(&display); + FALLBACK_GDI_DISPLAYS + .lock() + .unwrap() + .remove(&video_service_name); } } } @@ -383,7 +367,7 @@ impl VRamDecoder { } } } - pub fn decode(&mut self, data: &[u8]) -> ResultType> { + pub fn decode<'a>(&'a mut self, data: &[u8]) -> ResultType>> { match self.decoder.decode(data) { Ok(v) => Ok(v.iter().map(|f| VRamDecoderImage { frame: f }).collect()), Err(e) => Err(anyhow!(e)), diff --git a/libs/scrap/src/common/wayland.rs b/libs/scrap/src/common/wayland.rs index afcfc4a53..30b5f4d54 100644 --- a/libs/scrap/src/common/wayland.rs +++ b/libs/scrap/src/common/wayland.rs @@ -8,7 +8,6 @@ use super::x11::PixelBuffer; pub struct Capturer(Display, Box, Vec); - lazy_static::lazy_static! { static ref MAP_ERR: RwLock io::Error>> = Default::default(); } @@ -61,7 +60,7 @@ impl TraitCapturer for Capturer { } } -pub struct Display(pipewire::PipeWireCapturable); +pub struct Display(pub(crate) pipewire::PipeWireCapturable); impl Display { pub fn primary() -> io::Result { @@ -81,11 +80,35 @@ impl Display { } pub fn width(&self) -> usize { - self.0.size.0 + self.physical_width() } pub fn height(&self) -> usize { - self.0.size.1 + self.physical_height() + } + + pub fn physical_width(&self) -> usize { + self.0.physical_size.0 + } + + pub fn physical_height(&self) -> usize { + self.0.physical_size.1 + } + + pub fn logical_width(&self) -> usize { + self.0.logical_size.0 + } + + pub fn logical_height(&self) -> usize { + self.0.logical_size.1 + } + + pub fn scale(&self) -> f64 { + if self.logical_width() == 0 { + 1.0 + } else { + self.physical_width() as f64 / self.logical_width() as f64 + } } pub fn origin(&self) -> (i32, i32) { @@ -97,7 +120,7 @@ impl Display { } pub fn is_primary(&self) -> bool { - false + self.0.primary } pub fn name(&self) -> String { diff --git a/libs/scrap/src/dxgi/mag.rs b/libs/scrap/src/dxgi/mag.rs index 923606a82..75fc892cf 100644 --- a/libs/scrap/src/dxgi/mag.rs +++ b/libs/scrap/src/dxgi/mag.rs @@ -1,4 +1,6 @@ // logic from webrtc -- https://github.com/shiguredo/libwebrtc/blob/main/modules/desktop_capture/win/screen_capturer_win_magnifier.cc +#![allow(non_snake_case)] + use lazy_static; use std::{ ffi::CString, @@ -133,7 +135,7 @@ impl MagInterface { s.lib_handle = LoadLibraryExA( lib_file_name_c.as_ptr() as _, NULL, - LOAD_WITH_ALTERED_SEARCH_PATH, + LOAD_LIBRARY_SEARCH_SYSTEM32, ); if s.lib_handle.is_null() { return Err(Error::new( diff --git a/libs/scrap/src/dxgi/mod.rs b/libs/scrap/src/dxgi/mod.rs index 33a60e7d9..1f5296954 100644 --- a/libs/scrap/src/dxgi/mod.rs +++ b/libs/scrap/src/dxgi/mod.rs @@ -10,7 +10,7 @@ use winapi::{ dxgitype::*, minwindef::{DWORD, FALSE, TRUE, UINT}, ntdef::LONG, - windef::HMONITOR, + windef::{HMONITOR, RECT}, winerror::*, // dxgiformat::{DXGI_FORMAT, DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_420_OPAQUE}, }, @@ -57,6 +57,7 @@ pub struct Capturer { saved_raw_data: Vec, // for faster compare and copy output_texture: bool, adapter_desc1: DXGI_ADAPTER_DESC1, + rotate: Rotate, } impl Capturer { @@ -116,6 +117,7 @@ impl Capturer { } else { hres } + // NVFBC(NVIDIA Capture SDK) which xpra used already deprecated, https://developer.nvidia.com/capture-sdk // also try high version DXGI for better performance, e.g. @@ -127,6 +129,8 @@ impl Capturer { // can help us update screen incrementally /* // not supported on my PC, try in the future + use winapi::shared::dxgiformat::DXGI_FORMAT_B8G8R8A8_UNORM; + let format : Vec = vec![DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_420_OPAQUE]; (*display.inner).DuplicateOutput1( device as *mut _, @@ -151,6 +155,7 @@ impl Capturer { (*duplication).GetDesc(&mut desc); } } + let rotate = Self::create_rotations(device.0, context.0, &display); Ok(Capturer { device, @@ -168,9 +173,143 @@ impl Capturer { saved_raw_data: Vec::new(), output_texture: false, adapter_desc1, + rotate, }) } + fn create_rotations( + device: *mut ID3D11Device, + context: *mut ID3D11DeviceContext, + display: &Display, + ) -> Rotate { + let mut video_context: *mut ID3D11VideoContext = ptr::null_mut(); + let mut video_device: *mut ID3D11VideoDevice = ptr::null_mut(); + let mut video_processor_enum: *mut ID3D11VideoProcessorEnumerator = ptr::null_mut(); + let mut video_processor: *mut ID3D11VideoProcessor = ptr::null_mut(); + let processor_rotation = match display.rotation() { + DXGI_MODE_ROTATION_ROTATE90 => Some(D3D11_VIDEO_PROCESSOR_ROTATION_90), + DXGI_MODE_ROTATION_ROTATE180 => Some(D3D11_VIDEO_PROCESSOR_ROTATION_180), + DXGI_MODE_ROTATION_ROTATE270 => Some(D3D11_VIDEO_PROCESSOR_ROTATION_270), + _ => None, + }; + if let Some(processor_rotation) = processor_rotation { + println!("create rotations"); + if !device.is_null() && !context.is_null() { + unsafe { + (*context).QueryInterface( + &IID_ID3D11VideoContext, + &mut video_context as *mut *mut _ as *mut *mut _, + ); + if !video_context.is_null() { + (*device).QueryInterface( + &IID_ID3D11VideoDevice, + &mut video_device as *mut *mut _ as *mut *mut _, + ); + if !video_device.is_null() { + let (input_width, input_height) = match display.rotation() { + DXGI_MODE_ROTATION_ROTATE90 | DXGI_MODE_ROTATION_ROTATE270 => { + (display.height(), display.width()) + } + _ => (display.width(), display.height()), + }; + let (output_width, output_height) = (display.width(), display.height()); + let content_desc = D3D11_VIDEO_PROCESSOR_CONTENT_DESC { + InputFrameFormat: D3D11_VIDEO_FRAME_FORMAT_PROGRESSIVE, + InputFrameRate: DXGI_RATIONAL { + Numerator: 30, + Denominator: 1, + }, + InputWidth: input_width as _, + InputHeight: input_height as _, + OutputFrameRate: DXGI_RATIONAL { + Numerator: 30, + Denominator: 1, + }, + OutputWidth: output_width as _, + OutputHeight: output_height as _, + Usage: D3D11_VIDEO_USAGE_PLAYBACK_NORMAL, + }; + (*video_device).CreateVideoProcessorEnumerator( + &content_desc, + &mut video_processor_enum, + ); + if !video_processor_enum.is_null() { + let mut caps: D3D11_VIDEO_PROCESSOR_CAPS = mem::zeroed(); + if S_OK == (*video_processor_enum).GetVideoProcessorCaps(&mut caps) + { + if caps.FeatureCaps + & D3D11_VIDEO_PROCESSOR_FEATURE_CAPS_ROTATION + != 0 + { + (*video_device).CreateVideoProcessor( + video_processor_enum, + 0, + &mut video_processor, + ); + if !video_processor.is_null() { + (*video_context).VideoProcessorSetStreamRotation( + video_processor, + 0, + TRUE, + processor_rotation, + ); + (*video_context) + .VideoProcessorSetStreamAutoProcessingMode( + video_processor, + 0, + FALSE, + ); + (*video_context).VideoProcessorSetStreamFrameFormat( + video_processor, + 0, + D3D11_VIDEO_FRAME_FORMAT_PROGRESSIVE, + ); + (*video_context).VideoProcessorSetStreamSourceRect( + video_processor, + 0, + TRUE, + &RECT { + left: 0, + top: 0, + right: input_width as _, + bottom: input_height as _, + }, + ); + (*video_context).VideoProcessorSetStreamDestRect( + video_processor, + 0, + TRUE, + &RECT { + left: 0, + top: 0, + right: output_width as _, + bottom: output_height as _, + }, + ); + } + } + } + } + } + } + } + } + } + + let video_context = ComPtr(video_context); + let video_device = ComPtr(video_device); + let video_processor_enum = ComPtr(video_processor_enum); + let video_processor = ComPtr(video_processor); + let rotated_texture = ComPtr(ptr::null_mut()); + Rotate { + video_context, + video_device, + video_processor_enum, + video_processor, + texture: (rotated_texture, false), + } + } + pub fn is_gdi(&self) -> bool { self.gdi_capturer.is_some() } @@ -253,21 +392,11 @@ impl Capturer { pub fn frame<'a>(&'a mut self, timeout: UINT) -> io::Result> { if self.output_texture { - let rotation = match self.display.rotation() { - DXGI_MODE_ROTATION_IDENTITY | DXGI_MODE_ROTATION_UNSPECIFIED => 0, - DXGI_MODE_ROTATION_ROTATE90 => 90, - DXGI_MODE_ROTATION_ROTATE180 => 180, - DXGI_MODE_ROTATION_ROTATE270 => 270, - _ => { - // Unsupported rotation, try anyway - 0 - } - }; - Ok(Frame::Texture((self.get_texture(timeout)?, rotation))) + Ok(Frame::Texture(self.get_texture(timeout)?)) } else { let width = self.width; let height = self.height; - Ok(Frame::PixelBuffer(PixelBuffer::new( + Ok(Frame::PixelBuffer(PixelBuffer::with_BGRA( self.get_pixelbuffer(timeout)?, width, height, @@ -338,7 +467,7 @@ impl Capturer { } } - fn get_texture(&mut self, timeout: UINT) -> io::Result<*mut c_void> { + fn get_texture(&mut self, timeout: UINT) -> io::Result<(*mut c_void, usize)> { unsafe { if self.duplication.0.is_null() { return Err(std::io::ErrorKind::AddrNotAvailable.into()); @@ -362,7 +491,86 @@ impl Capturer { ); let texture = ComPtr(texture); self.texture = texture; - Ok(self.texture.0 as *mut c_void) + + let mut final_texture = self.texture.0 as *mut c_void; + let mut rotation = match self.display.rotation() { + DXGI_MODE_ROTATION_ROTATE90 => 90, + DXGI_MODE_ROTATION_ROTATE180 => 180, + DXGI_MODE_ROTATION_ROTATE270 => 270, + _ => 0, + }; + if rotation != 0 + && !self.texture.is_null() + && !self.rotate.video_context.is_null() + && !self.rotate.video_device.is_null() + && !self.rotate.video_processor_enum.is_null() + && !self.rotate.video_processor.is_null() + { + let mut desc: D3D11_TEXTURE2D_DESC = mem::zeroed(); + (*self.texture.0).GetDesc(&mut desc); + if rotation == 90 || rotation == 270 { + let tmp = desc.Width; + desc.Width = desc.Height; + desc.Height = tmp; + } + if !self.rotate.texture.1 { + self.rotate.texture.1 = true; + let mut rotated_texture: *mut ID3D11Texture2D = ptr::null_mut(); + desc.MiscFlags = D3D11_RESOURCE_MISC_SHARED; + (*self.device.0).CreateTexture2D(&desc, ptr::null(), &mut rotated_texture); + self.rotate.texture.0 = ComPtr(rotated_texture); + } + if !self.rotate.texture.0.is_null() + && desc.Width == self.width as u32 + && desc.Height == self.height as u32 + { + let input_view_desc = D3D11_VIDEO_PROCESSOR_INPUT_VIEW_DESC { + FourCC: 0, + ViewDimension: D3D11_VPIV_DIMENSION_TEXTURE2D, + Texture2D: D3D11_TEX2D_VPIV { + ArraySlice: 0, + MipSlice: 0, + }, + }; + let mut input_view = ptr::null_mut(); + (*self.rotate.video_device.0).CreateVideoProcessorInputView( + self.texture.0 as *mut _, + self.rotate.video_processor_enum.0 as *mut _, + &input_view_desc, + &mut input_view, + ); + if !input_view.is_null() { + let input_view = ComPtr(input_view); + let mut output_view_desc: D3D11_VIDEO_PROCESSOR_OUTPUT_VIEW_DESC = + mem::zeroed(); + output_view_desc.ViewDimension = D3D11_VPOV_DIMENSION_TEXTURE2D; + output_view_desc.u.Texture2D_mut().MipSlice = 0; + let mut output_view = ptr::null_mut(); + (*self.rotate.video_device.0).CreateVideoProcessorOutputView( + self.rotate.texture.0 .0 as *mut _, + self.rotate.video_processor_enum.0 as *mut _, + &output_view_desc, + &mut output_view, + ); + if !output_view.is_null() { + let output_view = ComPtr(output_view); + let mut stream_data: D3D11_VIDEO_PROCESSOR_STREAM = mem::zeroed(); + stream_data.Enable = TRUE; + stream_data.pInputSurface = input_view.0; + (*self.rotate.video_context.0).VideoProcessorBlt( + self.rotate.video_processor.0, + output_view.0, + 0, + 1, + &stream_data, + ); + final_texture = self.rotate.texture.0 .0 as *mut c_void; + rotation = 0; + } + } + } + } + Ok((final_texture, rotation)) } } @@ -666,3 +874,11 @@ fn wrap_hresult(x: HRESULT) -> io::Result<()> { }) .into()) } + +struct Rotate { + video_context: ComPtr, + video_device: ComPtr, + video_processor_enum: ComPtr, + video_processor: ComPtr, + texture: (ComPtr, bool), +} diff --git a/libs/scrap/src/wayland.rs b/libs/scrap/src/wayland.rs index 501fec859..341f2b800 100644 --- a/libs/scrap/src/wayland.rs +++ b/libs/scrap/src/wayland.rs @@ -1,5 +1,6 @@ pub mod capturable; pub mod pipewire; +pub mod display; mod screencast_portal; mod request_portal; pub mod remote_desktop_portal; diff --git a/libs/scrap/src/wayland/capturable.rs b/libs/scrap/src/wayland/capturable.rs index 61f80ecbf..070b66799 100644 --- a/libs/scrap/src/wayland/capturable.rs +++ b/libs/scrap/src/wayland/capturable.rs @@ -24,7 +24,7 @@ impl<'a> PixelProvider<'a> { } pub trait Recorder { - fn capture(&mut self, timeout_ms: u64) -> Result>; + fn capture(&mut self, timeout_ms: u64) -> Result, Box>; } pub trait BoxCloneCapturable { diff --git a/libs/scrap/src/wayland/display.rs b/libs/scrap/src/wayland/display.rs new file mode 100644 index 000000000..a5c937491 --- /dev/null +++ b/libs/scrap/src/wayland/display.rs @@ -0,0 +1,256 @@ +use hbb_common::regex::Regex; +use lazy_static::lazy_static; +use std::sync::Mutex; +use std::{ + process::{Command, Output, Stdio}, + sync::Arc, + time::{Duration, Instant}, +}; +use tracing::warn; + +use hbb_common::platform::linux::{get_wayland_displays, WaylandDisplayInfo}; + +lazy_static! { + static ref DISPLAYS: Mutex>> = Mutex::new(None); +} + +const COMMAND_TIMEOUT: Duration = Duration::from_millis(1000); + +pub struct Displays { + pub primary: usize, + pub displays: Vec, +} + +// We need this helper to run commands with a timeout, as some commands may hang. +// `kscreen-doctor -o` is known to hang when: +// 1. On Archlinux, Both GNOME and KDE Plasma are installed. +// 2. Run this command in a GNOME session. +fn run_with_timeout( + program: &str, + args: &[&str], + timeout: Duration, + label: &str, +) -> Option { + let mut child = Command::new(program) + .args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .ok()?; + + let start = Instant::now(); + loop { + if let Ok(Some(_)) = child.try_wait() { + break; + } + if start.elapsed() >= timeout { + warn!("{} command timed out after {:?}", label, timeout); + if let Err(e) = child.kill() { + warn!("Failed to kill child process for '{}': {}", label, e); + } + if let Err(e) = child.wait() { + warn!("Failed to wait for child process for '{}': {}", label, e); + } + return None; + } + std::thread::sleep(Duration::from_millis(30)); + } + + match child.wait_with_output() { + Ok(output) => { + if !output.status.success() { + warn!("{} command failed with status: {}", label, output.status); + return None; + } + Some(output) + } + Err(_) => None, + } +} + +// There are some limitations with xrandr method: +// 1. It only works when XWayland is running. +// 2. The distro may not have xrandr installed by default. +// 3. xrandr may not report "primary" in its output. eg. openSUSE Leap 15.6 KDE Plasma. +fn try_xrandr_primary() -> Option { + let output = Command::new("xrandr").output().ok()?; + if !output.status.success() { + return None; + } + + let text = String::from_utf8_lossy(&output.stdout); + for line in text.lines() { + if line.contains("primary") && line.contains("connected") { + if let Some(name) = line.split_whitespace().next() { + return Some(name.to_string()); + } + } + } + None +} + +fn try_kscreen_primary() -> Option { + if !hbb_common::platform::linux::is_kde_session() { + return None; + } + + let output = run_with_timeout( + "kscreen-doctor", + &["-o"], + COMMAND_TIMEOUT, + "kscreen-doctor -o", + )?; + if !output.status.success() { + return None; + } + + let text = String::from_utf8_lossy(&output.stdout); + + // Remove ANSI color codes + let re_ansi = Regex::new(r"\x1b\[[0-9;]*m").ok()?; + let clean_text = re_ansi.replace_all(&text, ""); + + // Split the text into blocks, each starting with "Output:". + // The first element of the split will be empty, so we skip it. + for block in clean_text.split("Output:").skip(1) { + // Check if this block describes the primary monitor. + if block.contains("priority 1") { + // The monitor name is the second piece of text in the block, after the ID. + // e.g., " 1 eDP-1 enabled..." -> "eDP-1" + if let Some(name) = block.split_whitespace().nth(1) { + return Some(name.to_string()); + } + } + } + + None +} + +fn try_gdbus_primary() -> Option { + let output = run_with_timeout( + "gdbus", + &[ + "call", + "--session", + "--dest", + "org.gnome.Mutter.DisplayConfig", + "--object-path", + "/org/gnome/Mutter/DisplayConfig", + "--method", + "org.gnome.Mutter.DisplayConfig.GetCurrentState", + ], + COMMAND_TIMEOUT, + "gdbus DisplayConfig.GetCurrentState", + )?; + + if !output.status.success() { + return None; + } + + let text = String::from_utf8_lossy(&output.stdout); + + // Match logical monitor entries with primary=true + // Pattern: (x, y, scale, transform, true, [('connector-name', ...), ...], ...) + // Use regex to find entries where 5th field is true, then extract connector name + // Example matched text: "(0, 0, 1.5, 0, true, [('HDMI-1', 'MHH', 'Monitor', '0x00000000')], ...)" + let re = Regex::new(r"\([^()]*,\s*true,\s*\[\('([^']+)'").ok()?; + + if let Some(captures) = re.captures(&text) { + return captures.get(1).map(|m| m.as_str().to_string()); + } + + None +} + +fn get_primary_monitor() -> Option { + try_xrandr_primary() + .or_else(try_kscreen_primary) + .or_else(try_gdbus_primary) +} + +pub fn get_displays() -> Arc { + let mut lock = DISPLAYS.lock().unwrap(); + match lock.as_ref() { + Some(displays) => displays.clone(), + None => match get_wayland_displays() { + Ok(displays) => { + let mut primary_index = None; + if let Some(name) = get_primary_monitor() { + for (i, display) in displays.iter().enumerate() { + if display.name == name { + primary_index = Some(i); + break; + } + } + }; + if primary_index.is_none() { + for (i, display) in displays.iter().enumerate() { + if display.x == 0 && display.y == 0 { + primary_index = Some(i); + break; + } + } + } + let displays = Arc::new(Displays { + primary: primary_index.unwrap_or(0), + displays, + }); + *lock = Some(displays.clone()); + displays + } + Err(err) => { + warn!("Failed to get wayland displays: {}", err); + Arc::new(Displays { + primary: 0, + displays: Vec::new(), + }) + } + }, + } +} + +#[inline] +pub fn clear_wayland_displays_cache() { + let _ = DISPLAYS.lock().unwrap().take(); +} + +// Return (min_x, max_x, min_y, max_y) +pub fn get_desktop_rect_for_uinput() -> Option<(i32, i32, i32, i32)> { + let wayland_displays = get_displays(); + let displays = &wayland_displays.displays; + if displays.is_empty() { + return None; + } + + // For compatibility, if only one display, we use the physical size for `uinput`. + // Otherwise, we use the logical size for `uinput`. + if displays.len() == 1 { + let d = &displays[0]; + return Some((d.x, d.x + d.width, d.y, d.y + d.height)); + } + + let mut min_x = i32::MAX; + let mut min_y = i32::MAX; + let mut max_x = i32::MIN; + let mut max_y = i32::MIN; + for d in displays.iter() { + min_x = min_x.min(d.x); + min_y = min_y.min(d.y); + let size = if let Some(logical_size) = d.logical_size { + logical_size + } else { + // When `logical_size` is None, we cannot obtain the correct desktop rectangle. + // This may occur if the Wayland compositor does not provide logical size information, + // or if display information is incomplete. We fall back to physical size, which provides + // usable dimensions, but may not always be correct depending on compositor behavior. + warn!( + "Display at ({}, {}) is missing logical_size; falling back to physical size ({}, {}).", + d.x, d.y, d.width, d.height + ); + (d.width, d.height) + }; + max_x = max_x.max(d.x + size.0); + max_y = max_y.max(d.y + size.1); + } + Some((min_x, max_x, min_y, max_y)) +} diff --git a/libs/scrap/src/wayland/pipewire.rs b/libs/scrap/src/wayland/pipewire.rs index 2f1e2a852..8859d0d3b 100644 --- a/libs/scrap/src/wayland/pipewire.rs +++ b/libs/scrap/src/wayland/pipewire.rs @@ -2,9 +2,12 @@ use std::collections::HashMap; use std::error::Error; use std::os::unix::io::AsRawFd; use std::process::Command; -use std::sync::{atomic::AtomicBool, Arc, Mutex}; +use std::sync::{ + atomic::{AtomicBool, AtomicU8, Ordering}, + Arc, Mutex, +}; use std::time::Duration; -use tracing::{debug, trace, warn}; +use tracing::{debug, error, trace, warn}; use dbus::{ arg::{OwnedFd, PropMap, RefArg, Variant}, @@ -17,22 +20,58 @@ use gstreamer as gst; use gstreamer::prelude::*; use gstreamer_app::AppSink; -use hbb_common::config; +use lazy_static::lazy_static; +use serde::{Deserialize, Serialize}; + +use hbb_common::{bail, config, platform::linux::CMD_SH, serde_json, tokio, ResultType}; use super::capturable::PixelProvider; use super::capturable::{Capturable, Recorder}; +use super::display::{clear_wayland_displays_cache, get_displays, Displays}; use super::remote_desktop_portal::OrgFreedesktopPortalRemoteDesktop as remote_desktop_portal; use super::request_portal::OrgFreedesktopPortalRequestResponse; use super::screencast_portal::OrgFreedesktopPortalScreenCast as screencast_portal; -use lazy_static::lazy_static; lazy_static! { pub static ref RDP_SESSION_INFO: Mutex> = Mutex::new(None); } +#[derive(Serialize, Deserialize)] +// For KDE Plasma only, because GNOME provides position info. +struct PipewireDisplayOffsetCache { + // We need to compare the displays, because: + // 1. On Archlinux KDE Plasma + // 2. One display, and connect, remember share choice. + // 3. Plug in another monitor. + // 4. The portal will reuse the restore token, no new share choice dialog, but the share screen is different. + // The controlling side will see the new monitor. + // All displays as one string for easy comparison + // name1-x1-y1-width1-height1;name2-x2-y2-width2-height2;... + display_key: String, + restore_token: String, + offsets: Vec<(i32, i32)>, +} + +// KDE Plasma may not provide position info +static HAS_POSITION_ATTR: AtomicBool = AtomicBool::new(false); +static IS_SERVER_RUNNING: AtomicU8 = AtomicU8::new(0); // 0: uninitialized, 1:true, 2: false + +impl PipewireDisplayOffsetCache { + fn displays_to_key(displays: &Arc) -> String { + displays + .displays + .iter() + .map(|d| format!("{}-{}-{}-{}-{}", d.name, d.x, d.y, d.width, d.height)) + .collect::>() + .join(";") + } +} + #[inline] pub fn close_session() { let _ = RDP_SESSION_INFO.lock().unwrap().take(); + clear_wayland_displays_cache(); + HAS_POSITION_ATTR.store(false, Ordering::SeqCst); } #[inline] @@ -51,6 +90,8 @@ pub fn try_close_session() { } if close { *rdp_info = None; + clear_wayland_displays_cache(); + HAS_POSITION_ATTR.store(false, Ordering::SeqCst); } } @@ -74,6 +115,10 @@ impl PwStreamInfo { pub fn get_size(&self) -> (usize, usize) { self.size } + + pub fn get_position(&self) -> (i32, i32) { + self.position + } } #[derive(Debug)] @@ -107,8 +152,10 @@ pub struct PipeWireCapturable { fd: OwnedFd, path: u64, source_type: u64, + pub primary: bool, pub position: (i32, i32), - pub size: (usize, usize), + pub logical_size: (usize, usize), + pub physical_size: (usize, usize), } impl PipeWireCapturable { @@ -116,27 +163,31 @@ impl PipeWireCapturable { conn: Arc, fd: OwnedFd, resolution: Arc>>, - stream: PwStreamInfo, + stream: &PwStreamInfo, ) -> Self { // alternative to get screen resolution as stream.size is not always correct ex: on fractional scaling // https://github.com/rustdesk/rustdesk/issues/6116#issuecomment-1817724244 - let size = get_res(Self { + let physical_size = get_res(Self { dbus_conn: conn.clone(), fd: fd.clone(), path: stream.path, source_type: stream.source_type, + primary: false, position: stream.position, - size: stream.size, + logical_size: stream.size, + physical_size: (0, 0), }) .unwrap_or(stream.size); - *resolution.lock().unwrap() = Some(size); + *resolution.lock().unwrap() = Some(physical_size); Self { dbus_conn: conn, fd, path: stream.path, source_type: stream.source_type, + primary: false, position: stream.position, - size, + logical_size: stream.size, + physical_size, } } } @@ -213,7 +264,7 @@ pub struct PipeWireRecorder { } impl PipeWireRecorder { - pub fn new(capturable: PipeWireCapturable) -> Result> { + pub fn new(capturable: PipeWireCapturable) -> ResultType { let pipeline = gst::Pipeline::new(None); let src = gst::ElementFactory::make("pipewiresrc", None)?; @@ -225,12 +276,21 @@ impl PipeWireRecorder { // see: https://gitlab.freedesktop.org/pipewire/pipewire/-/issues/982 src.set_property("always-copy", &true)?; + // COSMIC/Wayland fix: insert videoconvert between pipewiresrc and appsink. + // xdg-desktop-portal-cosmic's modifier negotiation fails when the downstream + // format set is too narrow (appsink only accepts BGRx/RGBx), producing + // "no more output formats" / not-negotiated (-4). videoconvert accepts any + // system-memory video/x-raw format, widening negotiation so the portal can + // settle on a format it can deliver via its SHM path. + let convert = gst::ElementFactory::make("videoconvert", None)?; + let sink = gst::ElementFactory::make("appsink", None)?; sink.set_property("drop", &true)?; sink.set_property("max-buffers", &1u32)?; - pipeline.add_many(&[&src, &sink])?; - src.link(&sink)?; + pipeline.add_many(&[&src, &convert, &sink])?; + src.link(&convert)?; + convert.link(&sink)?; let appsink = sink .dynamic_cast::() @@ -246,7 +306,40 @@ impl PipeWireRecorder { )); appsink.set_caps(Some(&caps)); + // [Workaround] + // Crash may occur if there are multiple pipelines started at the same time. + // `pipeline.get_state()` can significantly reduce the probability of crashes, + // but cannot completely resolve this issue. + // Adding a short sleep period can also reduce the probability of crashes. + debug!( + "[gstreamer] Setting pipeline {} to PLAYING state...", + capturable.fd.as_raw_fd() + ); pipeline.set_state(gst::State::Playing)?; + + // If `is_server_running()` is false, it means using remote_desktop_portal, + // which does not use multiple streams, so no need to wait for state change. + if is_server_running() { + // Wait for the state change to actually complete before proceeding. + // The 2000ms timeout for pipeline state change was chosen based on empirical testing. + let state_change = pipeline.get_state(gst::ClockTime::from_mseconds(2000)); + match state_change { + (Ok(_), gst::State::Playing, _) => { + debug!( + "[gstreamer] Pipeline {} state confirmed as PLAYING.", + capturable.fd.as_raw_fd() + ); + } + (result, state, pending) => { + warn!( + "[gstreamer] Pipeline {} state change incomplete: result={:?}, state={:?}, pending={:?}", + capturable.fd.as_raw_fd(), result, state, pending + ); + } + } + std::thread::sleep(std::time::Duration::from_millis(150)); + } + Ok(Self { pipeline, appsink, @@ -262,7 +355,7 @@ impl PipeWireRecorder { } impl Recorder for PipeWireRecorder { - fn capture(&mut self, timeout_ms: u64) -> Result> { + fn capture(&mut self, timeout_ms: u64) -> Result, Box> { if let Some(sample) = self .appsink .try_pull_sample(gst::ClockTime::from_mseconds(timeout_ms)) @@ -365,6 +458,8 @@ impl Drop for PipeWireRecorder { if let Err(err) = self.pipeline.set_state(gst::State::Null) { warn!("Failed to stop GStreamer pipeline: {}.", err); } + // Wait for state change to complete to avoid races during PipeWire teardown. + let _ = self.pipeline.get_state(gst::ClockTime::from_mseconds(2000)); } } @@ -395,18 +490,18 @@ where 0 => {} 1 => { warn!("DBus response: User cancelled interaction."); - failure_out.store(true, std::sync::atomic::Ordering::Relaxed); + failure_out.store(true, Ordering::SeqCst); return true; } c => { warn!("DBus response: Unknown error, code: {}.", c); - failure_out.store(true, std::sync::atomic::Ordering::Relaxed); + failure_out.store(true, Ordering::SeqCst); return true; } } if let Err(err) = f(r, c, m) { warn!("Error requesting screen capture via dbus: {}", err); - failure_out.store(true, std::sync::atomic::Ordering::Relaxed); + failure_out.store(true, Ordering::SeqCst); } true }) @@ -487,6 +582,7 @@ fn streams_from_response(response: OrgFreedesktopPortalRequestResponse) -> Vec

Vec

Result { let conn = SyncConnection::new_session()?; @@ -509,16 +606,15 @@ pub fn get_available_cursor_modes() -> Result { } // mostly inspired by https://gitlab.gnome.org/-/snippets/39 -pub fn request_remote_desktop() -> Result< - ( - SyncConnection, - OwnedFd, - Vec, - dbus::Path<'static>, - bool, - ), - Box, -> { +pub fn request_remote_desktop( + capture_cursor: bool, +) -> ResultType<( + SyncConnection, + OwnedFd, + Vec, + dbus::Path<'static>, + bool, +)> { unsafe { if !INIT { gstreamer::init()?; @@ -573,6 +669,7 @@ pub fn request_remote_desktop() -> Result< session.clone(), failure.clone(), is_support_restore_token, + capture_cursor, ), failure_res.clone(), )?; @@ -585,7 +682,7 @@ pub fn request_remote_desktop() -> Result< break; } - if failure_res.load(std::sync::atomic::Ordering::Relaxed) { + if failure_res.load(Ordering::SeqCst) { break; } } @@ -606,9 +703,7 @@ pub fn request_remote_desktop() -> Result< } } } - Err(Box::new(DBusError( -"Failed to obtain screen capture. You may need to upgrade the PipeWire library for better compatibility. Please check https://github.com/rustdesk/rustdesk/issues/8600#issuecomment-2254720954 for more details.".into() - ))) + bail!("Failed to obtain screen capture. You may need to upgrade the PipeWire library for better compatibility. Please check https://github.com/rustdesk/rustdesk/issues/8600#issuecomment-2254720954 for more details.") } fn on_create_session_response( @@ -617,6 +712,7 @@ fn on_create_session_response( session: Arc>>>, failure: Arc, is_support_restore_token: bool, + capture_cursor: bool, ) -> impl Fn( OrgFreedesktopPortalRequestResponse, &SyncConnection, @@ -660,9 +756,19 @@ fn on_create_session_response( Variant(Box::new("u3".to_string())), ); // https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.ScreenCast.html - // args.insert("multiple".into(), Variant(Box::new(true))); + if is_server_running() { + args.insert("multiple".into(), Variant(Box::new(true))); + } args.insert("types".into(), Variant(Box::new(1u32))); //| 2u32))); + if capture_cursor { + get_available_cursor_modes().ok().map(|modes| { + if modes & 0x2 != 0 { + args.insert("cursor_mode".to_string(), Variant(Box::new(2u32))); + } + }); + } + let path = portal.select_sources(ses.clone(), args)?; handle_response( c, @@ -724,7 +830,9 @@ fn on_select_devices_response( Variant(Box::new("u3".to_string())), ); // https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.ScreenCast.html - // args.insert("multiple".into(), Variant(Box::new(true))); + if is_server_running() { + args.insert("multiple".into(), Variant(Box::new(true))); + } args.insert("types".into(), Variant(Box::new(1u32))); //| 2u32))); let session = session.clone(); @@ -833,7 +941,7 @@ pub fn get_capturables() -> Result, Box> { }; if rdp_connection.is_none() { - let (conn, fd, streams, session, is_support_restore_token) = request_remote_desktop()?; + let (conn, fd, streams, session, is_support_restore_token) = request_remote_desktop(false)?; let conn = Arc::new(conn); let rdp_info = RdpSessionInfo { @@ -847,7 +955,7 @@ pub fn get_capturables() -> Result, Box> { *rdp_connection = Some(rdp_info); } - let rdp_info = match rdp_connection.as_ref() { + let rdp_info = match rdp_connection.as_mut() { Some(res) => res, None => { return Err(Box::new(DBusError("RDP response is None.".into()))); @@ -856,8 +964,7 @@ pub fn get_capturables() -> Result, Box> { Ok(rdp_info .streams - .clone() - .into_iter() + .iter() .map(|s| { PipeWireCapturable::new( rdp_info.conn.clone(), @@ -878,9 +985,14 @@ pub fn get_capturables() -> Result, Box> { // // `screencast_portal` supports restore_token and persist_mode if the version is greater than or equal to 4. // `remote_desktop_portal` does not support restore_token and persist_mode. -fn is_server_running() -> bool { +pub(crate) fn is_server_running() -> bool { + let v = IS_SERVER_RUNNING.load(Ordering::SeqCst); + if v > 0 { + return v == 1; + } + let app_name = config::APP_NAME.read().unwrap().clone().to_lowercase(); - let output = match Command::new("sh") + let output = match Command::new(CMD_SH.as_str()) .arg("-c") .arg(&format!("ps aux | grep {}", app_name)) .output() @@ -893,5 +1005,533 @@ fn is_server_running() -> bool { let output_str = String::from_utf8_lossy(&output.stdout); let is_running = output_str.contains(&format!("{} --server", app_name)); + IS_SERVER_RUNNING.store(if is_running { 1 } else { 2 }, Ordering::SeqCst); is_running } + +// The logical size reported by portal may be different from the size reported by `get_displays()`. +// So we need to use the workaround here. +// 1. openSUSE, KDE Plasma +// 2. Kubuntu 24.04 TLS, after running `sudo apt install plasma-workspace-wayland` +// Maybe it's a bug, and we can remove this workaround in the future. +pub fn try_fix_logical_size(shared_displays: &mut Vec) { + if !is_server_running() { + return; + } + + let wayland_displays = get_displays(); + if wayland_displays.displays.is_empty() { + return; + } + + for sd in shared_displays.iter_mut() { + if let crate::Display::WAYLAND(d) = sd { + let capturable = &mut d.0; + for wd in wayland_displays.displays.iter() { + if capturable.position.0 == wd.x && capturable.position.1 == wd.y { + if let Some(logical_size) = wd.logical_size { + if capturable.physical_size.0 != wd.width as usize + || capturable.physical_size.1 != wd.height as usize + { + // If "Full Workspace" is selected in the portal dialog, + // the physical size reported by portal may not match the display info. + debug!( + "Physical size of capturable ({:?}) does not match display info: ({:?}) - ({:?}). Skipping logical size fix.", + capturable.position, + capturable.physical_size, + (wd.width as usize, wd.height as usize) + ); + break; + } + + if capturable.logical_size.0 != logical_size.0 as usize + || capturable.logical_size.1 != logical_size.1 as usize + { + warn!( + "Fixing logical size of capturable from {:?} to {:?} based on display info {:?}.", + capturable.logical_size, + logical_size, + wd + ); + capturable.logical_size = + (logical_size.0 as usize, logical_size.1 as usize); + } + } + break; + } + } + } + } +} + +pub fn fill_displays( + mouse_move_to: impl Fn(i32, i32), + get_cursor_pos: fn() -> Option<(i32, i32)>, + shared_displays: &mut Vec, +) -> ResultType<()> { + if !is_server_running() { + return Ok(()); + } + + let mut rdp_connection = RDP_SESSION_INFO.lock().unwrap(); + let rdp_info = match rdp_connection.as_mut() { + Some(res) => res, + None => { + // Unreachable + bail!("RDP session info is None when filling display positions."); + } + }; + + let all_displays = get_displays(); + if !HAS_POSITION_ATTR.load(Ordering::SeqCst) { + if all_displays.displays.len() > 1 { + debug!("Multiple Wayland displays detected, adjusting stream positions accordingly."); + try_fill_positions( + mouse_move_to, + get_cursor_pos, + &all_displays, + shared_displays, + &mut rdp_info.streams, + )?; + } + HAS_POSITION_ATTR.store(true, Ordering::SeqCst); + } + + if all_displays.displays.len() > 1 { + sort_streams(&all_displays, shared_displays, &mut rdp_info.streams); + } + + shared_displays.iter_mut().next().map(|d| { + if let crate::Display::WAYLAND(d) = d { + d.0.primary = true; + } + }); + + Ok(()) +} + +fn try_fill_positions( + mouse_move_to: impl Fn(i32, i32), + get_cursor_pos: fn() -> Option<(i32, i32)>, + displays: &Arc, + shared_displays: &mut Vec, + streams: &mut Vec, +) -> ResultType<()> { + let pipewire_display_offset = config::LocalConfig::get_option(PIPEWIRE_DISPLAY_OFFSET_CONF_KEY); + if !pipewire_display_offset.is_empty() { + if try_fill_positions_from_cache( + pipewire_display_offset, + displays, + shared_displays, + streams, + ) { + return Ok(()); + } + config::LocalConfig::set_option(PIPEWIRE_DISPLAY_OFFSET_CONF_KEY.to_owned(), "".to_owned()); + } + + let mut multi_matched_indices = Vec::new(); + for (i, sd) in shared_displays.iter_mut().enumerate() { + if let crate::Display::WAYLAND(d) = sd { + let capturable = &mut d.0; + let mut match_count = 0; + for wd in displays.displays.iter() { + if capturable.physical_size.0 == wd.width as usize + && capturable.physical_size.1 == wd.height as usize + { + capturable.position = (wd.x, wd.y); + if let Some(pw_stream) = streams.get_mut(i) { + pw_stream.position = (wd.x, wd.y); + } + match_count += 1; + } + } + if match_count == 0 { + warn!( + "No matching display found for capturable with size {:?}.", + capturable.physical_size + ); + } else if match_count > 1 { + multi_matched_indices.push(i); + } + } + } + + if !multi_matched_indices.is_empty() { + fill_multi_matched_positions( + mouse_move_to, + get_cursor_pos, + displays, + shared_displays, + streams, + multi_matched_indices, + )?; + } + + save_positions_to_cache(displays, shared_displays); + Ok(()) +} + +fn try_fill_positions_from_cache( + cache_str: String, + displays: &Arc, + shared_displays: &mut Vec, + streams: &mut Vec, +) -> bool { + let Ok(cache) = serde_json::from_str::(&cache_str) else { + return false; + }; + + if cache.offsets.len() != shared_displays.len() { + return false; + } + + let display_key = PipewireDisplayOffsetCache::displays_to_key(displays); + if cache.display_key != display_key { + return false; + } + + let restore_token = config::LocalConfig::get_option(RESTORE_TOKEN_CONF_KEY); + if cache.restore_token != restore_token { + return false; + } + + for (i, sd) in shared_displays.iter_mut().enumerate() { + if let crate::Display::WAYLAND(d) = sd { + let capturable = &mut d.0; + if let Some((x_off, y_off)) = cache.offsets.get(i) { + capturable.position = (*x_off, *y_off); + if let Some(pw_stream) = streams.get_mut(i) { + pw_stream.position = (*x_off, *y_off); + } + } + } + } + true +} + +fn save_positions_to_cache(displays: &Arc, shared_displays: &Vec) { + let restore_token = config::LocalConfig::get_option(RESTORE_TOKEN_CONF_KEY); + if restore_token.is_empty() { + return; + } + + let mut offsets = Vec::new(); + for sd in shared_displays.iter() { + if let crate::Display::WAYLAND(d) = sd { + let capturable = &d.0; + offsets.push((capturable.position.0, capturable.position.1)); + } + } + + let display_key = PipewireDisplayOffsetCache::displays_to_key(displays); + let cache = PipewireDisplayOffsetCache { + display_key, + restore_token, + offsets, + }; + + if let Ok(s) = serde_json::to_string(&cache) { + config::LocalConfig::set_option(PIPEWIRE_DISPLAY_OFFSET_CONF_KEY.to_owned(), s); + } +} + +fn compare_left_up_corner(w: usize, d1: &[u8], d2: &[u8]) -> bool { + if w == 0 { + return false; + } + if d1.len() != d2.len() { + return false; + } + let bpp = 4; // BGR0/RGB0 + let stride = w.saturating_mul(bpp); + if stride == 0 || d1.len() < stride || d2.len() < stride { + return false; + } + let h = d1.len() / stride; + if h == 0 { + return false; + } + + let roi_w = std::cmp::min(36, w); + let roi_h = std::cmp::min(36, h); + let mut diff_px = 0usize; + let total_px = roi_w * roi_h; + // Minimum number of differing pixels required to consider images different. + const MIN_DIFF_PIXELS: usize = 8; + // Divisor for threshold calculation: allows up to 1/8 of ROI pixels to differ before returning true. + const DIFF_THRESHOLD_DIVISOR: usize = 8; + let threshold = std::cmp::max(MIN_DIFF_PIXELS, total_px / DIFF_THRESHOLD_DIVISOR); + + for y in 0..roi_h { + let row_off = y * stride; + for x in 0..roi_w { + let i = row_off + x * bpp; + let a = &d1[i..i + bpp]; + let b = &d2[i..i + bpp]; + if a != b { + diff_px += 1; + if diff_px >= threshold { + return true; + } + } + } + } + false +} + +fn fill_multi_matched_positions( + mouse_move_to: impl Fn(i32, i32), + get_cursor_pos: fn() -> Option<(i32, i32)>, + displays: &Arc, + shared_displays: &mut Vec, + streams: &mut Vec, + multi_matched_indices: Vec, +) -> ResultType<()> { + debug!( + "Multiple capturables ({:?}) match the same display size, attempting to disambiguate positions.", + &multi_matched_indices); + if multi_matched_indices.is_empty() { + return Ok(()); + } + + let is_support_embeded_cursor = get_available_cursor_modes() + .ok() + .map(|modes| modes & 0x2 != 0) + .unwrap_or(false); + if is_support_embeded_cursor { + fill_multi_matched_positions_cursor( + mouse_move_to, + get_cursor_pos, + displays, + shared_displays, + streams, + multi_matched_indices, + )?; + } + + Ok(()) +} + +fn mouse_move_to_( + mouse_move_to: &impl Fn(i32, i32), + get_cursor_pos: fn() -> Option<(i32, i32)>, + x: i32, + y: i32, +) { + const MOVE_MOUSE_TIMEOUT: Duration = Duration::from_millis(150); + let start = std::time::Instant::now(); + while start.elapsed() < MOVE_MOUSE_TIMEOUT { + mouse_move_to(x, y); + std::thread::sleep(Duration::from_millis(20)); + if let Some((x1, y1)) = get_cursor_pos() { + if x1 == x && y1 == y { + return; + } + } + } + warn!( + "Failed to move mouse to ({}, {}) within timeout: {:?}.", + x, y, &MOVE_MOUSE_TIMEOUT + ); +} + +fn fill_multi_matched_positions_cursor( + mouse_move_to: impl Fn(i32, i32), + get_cursor_pos: fn() -> Option<(i32, i32)>, + displays: &Arc, + shared_displays: &mut Vec, + streams: &mut Vec, + multi_matched_indices: Vec, +) -> ResultType<()> { + // This creates a new remote desktop session for cursor-based position detection. + // The session is temporary, used only for disambiguation, and is dropped after detection completes. + let (conn, fd, streams_with_cursor, _session, _is_support_restore_token) = + request_remote_desktop(true)?; + let conn = Arc::new(conn); + + let mut matched_indices = Vec::new(); + const CAPTURE_TIMEOUT_MS: u64 = 1_000; + for idx in multi_matched_indices { + match ( + shared_displays.get_mut(idx), + streams.get_mut(idx), + streams_with_cursor.get(idx), + ) { + (Some(crate::Display::WAYLAND(d)), Some(pw_stream), Some(pw_stream_with_cursor)) => { + // Check if only one display matches the size + let mut match_count = 0; + for (i, wd) in displays.displays.iter().enumerate() { + if matched_indices.contains(&i) { + continue; + } + if d.0.physical_size.0 == wd.width as usize + && d.0.physical_size.1 == wd.height as usize + { + match_count += 1; + } + } + if match_count == 0 { + error!( + "No matching display found for capturable with size {:?}.", + d.0.physical_size + ); + continue; + } + if match_count == 1 { + for (i, wd) in displays.displays.iter().enumerate() { + if matched_indices.contains(&i) { + continue; + } + if d.0.physical_size.0 == wd.width as usize + && d.0.physical_size.1 == wd.height as usize + { + d.0.position = (wd.x, wd.y); + pw_stream.position = (wd.x, wd.y); + matched_indices.push(i); + debug!( + "Disambiguated position for capturable with size {:?} to ({}, {}).", + d.0.physical_size, wd.x, wd.y + ); + break; + } + } + continue; + } + + // Move the mouse to a neutral position first, + // to avoid interference from previous position. + mouse_move_to_(&mouse_move_to, get_cursor_pos, 300, 300); + + let mut rec = PipeWireRecorder::new(PipeWireCapturable { + dbus_conn: conn.clone(), + fd: fd.clone(), + path: pw_stream_with_cursor.path, + source_type: pw_stream_with_cursor.source_type, + primary: false, + position: pw_stream_with_cursor.position, + logical_size: pw_stream_with_cursor.size, + physical_size: (0, 0), + })?; + // Take first frame and copy owned buffer to avoid borrow across second capture + let (is_bgr, w, first_buf): (bool, usize, Vec) = + match rec.capture(CAPTURE_TIMEOUT_MS) { + Ok(PixelProvider::BGR0(w, _, data1)) => (true, w, data1.to_vec()), + Ok(PixelProvider::RGB0(w, _, data1)) => (false, w, data1.to_vec()), + Ok(_) => { + error!("Unexpected pixel format on first capture."); + continue; + } + Err(e) => { + error!( + "Failed to capture screen for position disambiguation: {}", + e + ); + continue; + } + }; + + let matched_len = matched_indices.len(); + for (i, wd) in displays.displays.iter().enumerate() { + if matched_indices.contains(&i) { + continue; + } + + if wd.width as usize == d.0.physical_size.0 + && wd.height as usize == d.0.physical_size.1 + { + mouse_move_to_(&mouse_move_to, get_cursor_pos, wd.x + 8, wd.y + 8); + rec.saved_raw_data.clear(); + match rec.capture(CAPTURE_TIMEOUT_MS) { + Ok(PixelProvider::BGR0(_, _, data2)) if is_bgr => { + if compare_left_up_corner(w, &first_buf, data2) { + d.0.position = (wd.x, wd.y); + pw_stream.position = (wd.x, wd.y); + matched_indices.push(i); + debug!( + "Disambiguated position for capturable with size {:?} to ({}, {}).", + d.0.physical_size, wd.x, wd.y + ); + break; + } + } + Ok(PixelProvider::RGB0(_, _, data2)) if !is_bgr => { + if compare_left_up_corner(w, &first_buf, data2) { + d.0.position = (wd.x, wd.y); + pw_stream.position = (wd.x, wd.y); + matched_indices.push(i); + debug!( + "Disambiguated position for capturable with size {:?} to ({}, {}).", + d.0.physical_size, wd.x, wd.y + ); + break; + } + } + Ok(_) => { + // unreachable + error!("Pixel format changed between captures, cannot disambiguate position."); + } + Err(e) => { + error!( + "Failed to capture screen for position disambiguation: {}", + e + ); + } + } + } + } + if matched_len == matched_indices.len() { + error!( + "Failed to disambiguate position for capturable with size {:?}.", + d.0.physical_size + ); + } + } + _ => {} + } + } + + Ok(()) +} + +fn sort_streams( + displays: &Arc, + shared_displays: &mut Vec, + streams: &mut Vec, +) { + if streams.is_empty() { + // unreachable + error!("No streams available to sort."); + return; + } + + // put the main display first, then the rest by the order of displays + let mut display_order: Vec<(i32, i32)> = Vec::new(); + if let Some(d) = displays.displays.get(displays.primary) { + display_order.push((d.x, d.y)); + } + for (i, d) in displays.displays.iter().enumerate() { + if i != displays.primary { + display_order.push((d.x, d.y)); + } + } + + let mut sorted_streams = Vec::new(); + let mut sorted_shared_displays = Vec::new(); + // Move matching items in order without cloning + for (x, y) in display_order.into_iter() { + for i in 0..streams.len() { + if streams[i].position.0 == x && streams[i].position.1 == y { + sorted_streams.push(streams.remove(i)); + // shared_displays.len() must be equal to streams.len() + // But we still check the length to avoid panic + if shared_displays.len() > i { + sorted_shared_displays.push(shared_displays.remove(i)); + } + break; + } + } + } + *streams = sorted_streams; + *shared_displays = sorted_shared_displays; +} diff --git a/libs/scrap/src/x11/server.rs b/libs/scrap/src/x11/server.rs index f9983f7cf..7ae145d40 100644 --- a/libs/scrap/src/x11/server.rs +++ b/libs/scrap/src/x11/server.rs @@ -98,7 +98,7 @@ unsafe fn check_x11_shm_available(c: *mut xcb_connection_t) -> Result<(), Error> let mut e: *mut xcb_generic_error_t = std::ptr::null_mut(); let reply = xcb_shm_query_version_reply(c, cookie, &mut e as _); if reply.is_null() { - // TODO: Should seperate SHM disabled from SHM not supported? + // TODO: Should separate SHM disabled from SHM not supported? return Err(Error::UnsupportedExtension); } else { // https://github.com/FFmpeg/FFmpeg/blob/6229e4ac425b4566446edefb67d5c225eb397b58/libavdevice/xcbgrab.c#L229 diff --git a/libs/virtual_display/dylib/README.md b/libs/virtual_display/dylib/README.md index 30fa588f1..fb71c3c56 100644 --- a/libs/virtual_display/dylib/README.md +++ b/libs/virtual_display/dylib/README.md @@ -29,4 +29,4 @@ TODO ## X11 -## OSX +## macOS diff --git a/libs/virtual_display/src/lib.rs b/libs/virtual_display/src/lib.rs index c9243d822..6d602aa2e 100644 --- a/libs/virtual_display/src/lib.rs +++ b/libs/virtual_display/src/lib.rs @@ -48,7 +48,6 @@ macro_rules! make_lib_wrapper { $(let $field = if let Some(lib) = &lib { match unsafe { lib.symbol::<$tp>(stringify!($field)) } { Ok(m) => { - log::info!("method found {}", stringify!($field)); Some(*m) }, Err(e) => { diff --git a/res/.devcontainer/Dockerfile b/res/.devcontainer/Dockerfile deleted file mode 100644 index 93fd92ecb..000000000 --- a/res/.devcontainer/Dockerfile +++ /dev/null @@ -1,50 +0,0 @@ -FROM mcr.microsoft.com/devcontainers/base:ubuntu-22.04 -ENV HOME=/home/vscode -ENV WORKDIR=$HOME/rustdesk - -WORKDIR $HOME -RUN sudo apt update -y && sudo apt install -y 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 unzip zip sudo libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev -WORKDIR / - -RUN git clone https://github.com/microsoft/vcpkg -WORKDIR vcpkg -RUN git checkout 2023.04.15 -RUN /vcpkg/bootstrap-vcpkg.sh -disableMetrics -ENV VCPKG_ROOT=/vcpkg -RUN $VCPKG_ROOT/vcpkg --disable-metrics install libvpx libyuv opus aom - -WORKDIR / -RUN wget https://github.com/rustdesk/doc.rustdesk.com/releases/download/console/dep.tar.gz && tar xzf dep.tar.gz - - -USER vscode -WORKDIR $HOME -RUN wget https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so -RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs > rustup.sh -RUN chmod +x rustup.sh -RUN $HOME/rustup.sh -y -RUN $HOME/.cargo/bin/rustup target add aarch64-linux-android -RUN $HOME/.cargo/bin/cargo install cargo-ndk - -# Install Flutter -RUN wget https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.10.1-stable.tar.xz -RUN tar xf flutter_linux_3.10.1-stable.tar.xz && rm flutter_linux_3.10.1-stable.tar.xz -ENV PATH="$PATH:$HOME/flutter/bin" -RUN dart pub global activate ffigen 5.0.1 - - -# Install packages -RUN sudo apt-get install -y libclang-dev -RUN sudo apt install -y gcc-multilib - -WORKDIR $WORKDIR -ENV ANDROID_NDK_HOME=/opt/android/ndk/22.1.7171670 - -# Somehow try to automate flutter pub get -# https://rustdesk.com/docs/en/dev/build/android/ -# Put below steps in entrypoint.sh -# cd flutter -# wget https://github.com/rustdesk/doc.rustdesk.com/releases/download/console/so.tar.gz -# tar xzf so.tar.gz - -# own /opt/android diff --git a/res/.devcontainer/build.sh b/res/.devcontainer/build.sh deleted file mode 100755 index df87aace7..000000000 --- a/res/.devcontainer/build.sh +++ /dev/null @@ -1,75 +0,0 @@ -#!/bin/bash - -set -e - -MODE=${1:---debug} -TYPE=${2:-linux} -MODE=${MODE/*-/} - - -build(){ - pwd - $WORKDIR/entrypoint $1 -} - -build_arm64(){ - CWD=$(pwd) - cd $WORKDIR/flutter - flutter pub get - cd $WORKDIR - $WORKDIR/flutter/ndk_arm64.sh - cp $WORKDIR/target/aarch64-linux-android/release/liblibrustdesk.so $WORKDIR/flutter/android/app/src/main/jniLibs/arm64-v8a/librustdesk.so - cd $CWD -} - -build_apk(){ - cd $WORKDIR/flutter - MODE=$1 $WORKDIR/flutter/build_android.sh - cd $WORKDIR -} - -key_gen(){ - if [ ! -f $WORKDIR/flutter/android/key.properties ] - then - if [ ! -f $HOME/upload-keystore.jks ] - then - $WORKDIR/.devcontainer/setup.sh key - fi - read -r -p "enter the password used to generate $HOME/upload-keystore.jks\n" password - echo -e "storePassword=${password}\nkeyPassword=${password}\nkeyAlias=upload\nstoreFile=$HOME/upload-keystore.jks" > $WORKDIR/flutter/android/key.properties - else - echo "Believing storeFile is created ref: $WORKDIR/flutter/android/key.properties" - fi -} - -android_build(){ - if [ ! -d $WORKDIR/flutter/android/app/src/main/jniLibs/arm64-v8a ] - then - $WORKDIR/.devcontainer/setup.sh android - fi - build_arm64 - case $1 in - debug) - build_apk debug - ;; - release) - key_gen - build_apk release - ;; - esac -} - -case "$MODE:$TYPE" in - "debug:linux") - build - ;; - "release:linux") - build --release - ;; - "debug:android") - android_build debug - ;; - "release:android") - android_build release - ;; -esac diff --git a/res/.devcontainer/devcontainer.json b/res/.devcontainer/devcontainer.json deleted file mode 100644 index 953196eb3..000000000 --- a/res/.devcontainer/devcontainer.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "name": "rustdesk", - "build": { - "dockerfile": "./Dockerfile", - "context": "." - }, - "workspaceMount": "source=${localWorkspaceFolder},target=/home/vscode/rustdesk,type=bind,consistency=cache", - "workspaceFolder": "/home/vscode/rustdesk", - "postStartCommand": ".devcontainer/build.sh", - "features": { - "ghcr.io/devcontainers/features/java:1": {}, - "ghcr.io/akhildevelops/devcontainer-features/android-cli:latest": { - "PACKAGES": "platform-tools,ndk;23.2.8568313" - } - }, - "customizations": { - "vscode": { - "extensions": [ - "vadimcn.vscode-lldb", - "mutantdino.resourcemonitor", - "rust-lang.rust-analyzer", - "tamasfe.even-better-toml", - "serayuzgur.crates", - "mhutchie.git-graph", - "eamodio.gitlens" - ], - "settings": { - "files.watcherExclude": { - "**/target/**": true - } - } - } - } -} diff --git a/res/.devcontainer/setup.sh b/res/.devcontainer/setup.sh deleted file mode 100755 index c972f47b2..000000000 --- a/res/.devcontainer/setup.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/bin/bash -set -e -case $1 in - android) - # install deps - cd $WORKDIR/flutter - flutter pub get - wget https://github.com/rustdesk/doc.rustdesk.com/releases/download/console/so.tar.gz - tar xzf so.tar.gz - rm so.tar.gz - sudo chown -R $(whoami) $ANDROID_HOME - echo "Setup is Done." - ;; - linux) - echo "Linux Setup" - ;; - key) - echo -e "\n$HOME/upload-keystore.jks is not created.\nLet's create it.\nRemember the password you enter in keytool!" - keytool -genkey -v -keystore $HOME/upload-keystore.jks -keyalg RSA -keysize 2048 -validity 10000 -alias upload - ;; -esac - - \ No newline at end of file diff --git a/res/DEBIAN/postinst b/res/DEBIAN/postinst index eeeccaaec..57bb30d61 100755 --- a/res/DEBIAN/postinst +++ b/res/DEBIAN/postinst @@ -5,16 +5,14 @@ set -e if [ "$1" = configure ]; then INITSYS=$(ls -al /proc/1/exe | awk -F' ' '{print $NF}' | awk -F'/' '{print $NF}') - ln -s /usr/lib/rustdesk/rustdesk /usr/bin/rustdesk - + ln -f -s /usr/share/rustdesk/rustdesk /usr/bin/rustdesk + if [ "systemd" == "$INITSYS" ]; then if [ -e /etc/systemd/system/rustdesk.service ]; then rm /etc/systemd/system/rustdesk.service /usr/lib/systemd/system/rustdesk.service /usr/lib/systemd/user/rustdesk.service >/dev/null 2>&1 fi - version=$(python3 -V 2>&1 | grep -Po '(?<=Python )(.+)') - parsedVersion=$(echo "${version//./}") - mkdir -p /usr/lib/systemd/system/ + mkdir -p /usr/lib/systemd/system/ cp /usr/share/rustdesk/files/systemd/rustdesk.service /usr/lib/systemd/system/rustdesk.service # try fix error in Ubuntu 18.04 # Failed to reload rustdesk.service: Unit rustdesk.service is not loaded properly: Exec format error. diff --git a/res/DEBIAN/prerm b/res/DEBIAN/prerm index baef2e2e2..133ff11de 100755 --- a/res/DEBIAN/prerm +++ b/res/DEBIAN/prerm @@ -5,7 +5,7 @@ set -e case $1 in remove|upgrade) INITSYS=$(ls -al /proc/1/exe | awk -F' ' '{print $NF}' | awk -F'/' '{print $NF}') - rm /usr/bin/rustdesk + rm -f /usr/bin/rustdesk if [ "systemd" == "${INITSYS}" ]; then diff --git a/res/PKGBUILD b/res/PKGBUILD index 616682e8f..dd266eb2a 100644 --- a/res/PKGBUILD +++ b/res/PKGBUILD @@ -1,5 +1,5 @@ pkgname=rustdesk -pkgver=1.3.2 +pkgver=1.4.6 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' 'libvdpau' 'libappindicator-gtk3' 'pam' 'gst-plugins-base' 'gst-plugin-pipewire') +depends=('gtk3' 'xdotool' 'libxcb' 'libxfixes' 'alsa-lib' 'libva' 'libappindicator-gtk3' 'pam' 'gst-plugins-base' 'gst-plugin-pipewire') makedepends=() checkdepends=() optdepends=() @@ -23,10 +23,10 @@ md5sums=() #generate with 'makepkg -g' package() { if [[ ${FLUTTER} ]]; then - mkdir -p "${pkgdir}/usr/lib/rustdesk" && cp -r ${HBB}/flutter/build/linux/x64/release/bundle/* -t "${pkgdir}/usr/lib/rustdesk" + mkdir -p "${pkgdir}/usr/share/rustdesk" && cp -r ${HBB}/flutter/build/linux/x64/release/bundle/* -t "${pkgdir}/usr/share/rustdesk" fi mkdir -p "${pkgdir}/usr/bin" - pushd ${pkgdir} && ln -s /usr/lib/rustdesk/rustdesk usr/bin/rustdesk && popd + pushd ${pkgdir} && ln -s /usr/share/rustdesk/rustdesk usr/bin/rustdesk && popd install -Dm 644 $HBB/res/rustdesk.service -t "${pkgdir}/usr/share/rustdesk/files" install -Dm 644 $HBB/res/rustdesk.desktop -t "${pkgdir}/usr/share/rustdesk/files" install -Dm 644 $HBB/res/rustdesk-link.desktop -t "${pkgdir}/usr/share/rustdesk/files" diff --git a/res/ab.py b/res/ab.py new file mode 100644 index 000000000..11f52282b --- /dev/null +++ b/res/ab.py @@ -0,0 +1,791 @@ +#!/usr/bin/env python3 + +import requests +import argparse +import json +from datetime import datetime, timedelta + + +def get_personal_ab(url, token): + """Get personal address book GUID""" + headers = {"Authorization": f"Bearer {token}"} + + response = requests.get(f"{url}/api/ab/personal", headers=headers) + + if response.status_code != 200: + return f"Error: {response.status_code} - {response.text}" + + return response.json() + + +def view_shared_abs(url, token, name=None): + """View all shared address books (excluding personal ones)""" + headers = {"Authorization": f"Bearer {token}"} + pageSize = 30 + params = { + "name": name, + } + + filtered_params = { + k: "%" + v + "%" if (v != "-" and "%" not in v and k != "name") else v + for k, v in params.items() + if v is not None + } + filtered_params["pageSize"] = pageSize + + abs = [] + current = 0 + + while True: + current += 1 + filtered_params["current"] = current + response = requests.get(f"{url}/api/ab/shared/profiles", headers=headers, params=filtered_params) + if response.status_code != 200: + print(f"Error: HTTP {response.status_code} - {response.text}") + exit(1) + + response_json = response.json() + if "error" in response_json: + print(f"Error: {response_json['error']}") + exit(1) + + data = response_json.get("data", []) + abs.extend(data) + + total = response_json.get("total", 0) + if len(data) < pageSize or current * pageSize >= total: + break + + return abs + + +def get_ab_by_name(url, token, ab_name): + """Get address book by name""" + abs = view_shared_abs(url, token, ab_name) + for ab in abs: + if ab["name"] == ab_name: + return ab + return None + + +def view_ab_peers(url, token, ab_guid, peer_id=None, alias=None): + """View peers in an address book""" + headers = {"Authorization": f"Bearer {token}"} + pageSize = 30 + params = { + "ab": ab_guid, + "id": peer_id, + "alias": alias, + } + + filtered_params = { + k: "%" + v + "%" if (v != "-" and "%" not in v and k not in ["ab"]) else v + for k, v in params.items() + if v is not None + } + filtered_params["pageSize"] = pageSize + + peers = [] + current = 0 + + while True: + current += 1 + filtered_params["current"] = current + response = requests.get(f"{url}/api/ab/peers", headers=headers, params=filtered_params) + if response.status_code != 200: + print(f"Error: HTTP {response.status_code} - {response.text}") + exit(1) + + response_json = response.json() + if "error" in response_json: + print(f"Error: {response_json['error']}") + exit(1) + + data = response_json.get("data", []) + peers.extend(data) + + total = response_json.get("total", 0) + if len(data) < pageSize or current * pageSize >= total: + break + + return peers + + +def view_ab_tags(url, token, ab_guid): + """View tags in an address book""" + headers = {"Authorization": f"Bearer {token}"} + response = requests.get(f"{url}/api/ab/tags/{ab_guid}", headers=headers) + response_json = check_response(response) + + # Format color values as hex + if response_json: + for tag in response_json: + if "color" in tag and tag["color"] is not None: + # Convert color to hex format + color_value = tag["color"] + if isinstance(color_value, int): + tag["color"] = f"0x{color_value:08X}" + + return response_json if response_json else [] + + +def check_response(response): + """Check API response and return result""" + if response.status_code != 200: + print(f"Error: HTTP {response.status_code} - {response.text}") + exit(1) + + try: + response_json = response.json() + if "error" in response_json: + print(f"Error: {response_json['error']}") + exit(1) + return response_json + except ValueError: + return response.text or "Success" + + +def add_peer(url, token, ab_guid, peer_id, alias=None, note=None, tags=None, password=None): + """Add a peer to address book""" + print(f"Adding peer {peer_id} to address book") + headers = {"Authorization": f"Bearer {token}"} + + payload = { + "id": peer_id, + "note": note, + } + + # Add peer info if provided + info = {} + if alias: + info["alias"] = alias + if tags: + info["tags"] = tags if isinstance(tags, list) else [tags] + if password: + info["password"] = password + + if info: + payload.update(info) + + response = requests.post(f"{url}/api/ab/peer/add/{ab_guid}", headers=headers, json=payload) + return check_response(response) + + +def delete_peer(url, token, ab_guid, peer_ids): + """Delete peers from address book by IDs""" + if isinstance(peer_ids, str): + peer_ids = [peer_ids] + + print(f"Deleting peers {peer_ids} from address book") + headers = {"Authorization": f"Bearer {token}"} + response = requests.delete(f"{url}/api/ab/peer/{ab_guid}", headers=headers, json=peer_ids) + return check_response(response) + +def update_peer(url, token, ab_guid, peer_id, alias=None, note=None, tags=None, password=None): + """Update a peer in address book""" + print(f"Updating peer {peer_id} in address book") + headers = {"Authorization": f"Bearer {token}"} + + # Check if at least one parameter is provided for update + update_params = [alias, note, tags, password] + if all(param is None for param in update_params): + return "Error: At least one parameter must be specified for update" + + payload = { + "id": peer_id, + } + + # Add fields to update + info = {} + if alias is not None: + info["alias"] = alias + if tags is not None: + info["tags"] = tags if isinstance(tags, list) else [tags] + if password is not None: + info["password"] = password + + if info: + payload.update(info) + + if note is not None: + payload["note"] = note + + response = requests.put(f"{url}/api/ab/peer/update/{ab_guid}", headers=headers, json=payload) + return check_response(response) + + +def str2color(tag_name, existing_colors=None): + """Generate color for tag name similar to str2color2 function""" + if existing_colors is None: + existing_colors = [] + + color_map = { + "red": 0xFFFF0000, + "green": 0xFF008000, + "blue": 0xFF0000FF, + "orange": 0xFFFF9800, + "purple": 0xFF9C27B0, + "grey": 0xFF9E9E9E, + "cyan": 0xFF00BCD4, + "lime": 0xFFCDDC39, + "teal": 0xFF009688, + "pink": 0xFFF48FB1, + "indigo": 0xFF3F51B5, + "brown": 0xFF795548, + } + + lower_name = tag_name.lower() + + # Check if tag name matches a predefined color + if lower_name in color_map: + return color_map[lower_name] + + # Special case for yellow + if lower_name == "yellow": + return 0xFFFFFF00 + + # Generate hash-based color + hash_value = 0 + for char in tag_name: + hash_value += ord(char) + + color_list = list(color_map.values()) + hash_value = hash_value % len(color_list) + result = color_list[hash_value] + + # If color is already used, try to find an unused one + if result in existing_colors: + for color in color_list: + if color not in existing_colors: + result = color + break + + return result + + +def add_tag(url, token, ab_guid, tag_name, color=None): + """Add a tag to address book""" + print(f"Adding tag '{tag_name}' to address book") + headers = {"Authorization": f"Bearer {token}"} + + # If no color specified, generate one based on tag name + if color is None: + # Get existing tags to avoid color conflicts + try: + existing_tags = view_ab_tags(url, token, ab_guid) + existing_colors = [tag.get("color", 0) for tag in existing_tags] + color = str2color(tag_name, existing_colors) + except: + # Fallback to default color if we can't get existing tags + color = str2color(tag_name) + + payload = { + "name": tag_name, + "color": color, + } + + response = requests.post(f"{url}/api/ab/tag/add/{ab_guid}", headers=headers, json=payload) + return check_response(response) + + +def update_tag(url, token, ab_guid, tag_name, color): + """Update a tag in address book""" + print(f"Updating tag '{tag_name}' in address book") + headers = {"Authorization": f"Bearer {token}"} + + payload = { + "name": tag_name, + "color": color, + } + + response = requests.put(f"{url}/api/ab/tag/update/{ab_guid}", headers=headers, json=payload) + return check_response(response) + + +def delete_tags(url, token, ab_guid, tag_names): + """Delete tags from address book""" + if isinstance(tag_names, str): + tag_names = [tag_names] + + print(f"Deleting tags {tag_names} from address book") + headers = {"Authorization": f"Bearer {token}"} + response = requests.delete(f"{url}/api/ab/tag/{ab_guid}", headers=headers, json=tag_names) + return check_response(response) + + +def add_shared_ab(url, token, name, note=None, password=None): + """Add a new shared address book""" + print(f"Adding shared address book '{name}'") + headers = {"Authorization": f"Bearer {token}"} + + payload = { + "name": name, + "note": note, + } + + # Add info if password is provided + if password: + payload["info"] = { + "password": password + } + + response = requests.post(f"{url}/api/ab/shared/add", headers=headers, json=payload) + return check_response(response) + + +def update_shared_ab(url, token, ab_guid, name=None, note=None, owner=None, password=None): + """Update a shared address book""" + print(f"Updating shared address book {ab_guid}") + headers = {"Authorization": f"Bearer {token}"} + + # Check if at least one parameter is provided for update + update_params = [name, note, owner, password] + if all(param is None for param in update_params): + return "Error: At least one parameter must be specified for update" + + payload = { + "guid": ab_guid, + } + + if name is not None: + payload["name"] = name + if note is not None: + payload["note"] = note + if owner is not None: + payload["owner"] = owner + if password is not None: + payload["info"] = { + "password": password + } + + response = requests.put(f"{url}/api/ab/shared/update/profile", headers=headers, json=payload) + return check_response(response) + + +def delete_shared_abs(url, token, ab_guids): + """Delete shared address books""" + if isinstance(ab_guids, str): + ab_guids = [ab_guids] + + print(f"Deleting shared address books {ab_guids}") + headers = {"Authorization": f"Bearer {token}"} + response = requests.delete(f"{url}/api/ab/shared", headers=headers, json=ab_guids) + return check_response(response) + + +def permission_to_string(permission): + """Convert numeric permission to string representation""" + permission_map = { + 1: "ro", # Read + 2: "rw", # ReadWrite + 3: "full" # FullControl + } + return permission_map.get(permission, str(permission)) + + +def string_to_permission(permission_str): + """Convert string permission to numeric representation""" + permission_map = { + "ro": 1, # Read + "rw": 2, # ReadWrite + "full": 3 # FullControl + } + return permission_map.get(permission_str.lower(), None) + + +def view_ab_rules(url, token, ab_guid): + """View rules in an address book""" + headers = {"Authorization": f"Bearer {token}"} + pageSize = 30 + params = { + "ab": ab_guid, + "pageSize": pageSize, + } + + rules = [] + current = 0 + + while True: + current += 1 + params["current"] = current + response = requests.get(f"{url}/api/ab/rules", headers=headers, params=params) + if response.status_code != 200: + print(f"Error: HTTP {response.status_code} - {response.text}") + exit(1) + + response_json = response.json() + if "error" in response_json: + print(f"Error: {response_json['error']}") + exit(1) + + data = response_json.get("data", []) + rules.extend(data) + + total = response_json.get("total", 0) + if len(data) < pageSize or current * pageSize >= total: + break + + # Convert numeric permissions to string format + for rule in rules: + if "rule" in rule: + rule["rule"] = permission_to_string(rule["rule"]) + + return rules + + +def add_ab_rule(url, token, ab_guid, rule_type, user=None, group=None, rule=1): + """Add a rule to address book""" + print(f"Adding {rule_type} rule to address book") + headers = {"Authorization": f"Bearer {token}"} + + payload = { + "guid": ab_guid, + "rule": rule, + } + + if rule_type == "user" and user: + payload["user"] = user + elif rule_type == "group" and group: + payload["group"] = group + elif rule_type == "everyone": + # For everyone, both user and group are None (not included in payload) + pass + + response = requests.post(f"{url}/api/ab/rule", headers=headers, json=payload) + return check_response(response) + + +def update_ab_rule(url, token, rule_guid, rule): + """Update an address book rule""" + print(f"Updating rule {rule_guid}") + headers = {"Authorization": f"Bearer {token}"} + + payload = { + "guid": rule_guid, + "rule": rule, + } + + response = requests.patch(f"{url}/api/ab/rule", headers=headers, json=payload) + return check_response(response) + + +def delete_ab_rules(url, token, rule_guids): + """Delete address book rules""" + if isinstance(rule_guids, str): + rule_guids = [rule_guids] + + print(f"Deleting rules {rule_guids}") + headers = {"Authorization": f"Bearer {token}"} + response = requests.delete(f"{url}/api/ab/rules", headers=headers, json=rule_guids) + return check_response(response) + + +def main(): + def parse_color(value): + """Parse color value - supports both hex (0xFF00FF00) and decimal""" + if value.startswith('0x') or value.startswith('0X'): + return int(value, 16) + else: + return int(value) + + def parse_permission(value): + """Parse permission value - supports both string (ro/rw/full) and numeric (1/2/3)""" + # Try to parse as string first + permission_num = string_to_permission(value) + if permission_num is not None: + return permission_num + + # Try to parse as integer for backward compatibility + try: + num_value = int(value) + if num_value in [1, 2, 3]: + return num_value + else: + raise argparse.ArgumentTypeError(f"Invalid permission value: {value}. Must be one of: ro, rw, full, 1, 2, 3") + except ValueError: + raise argparse.ArgumentTypeError(f"Invalid permission value: {value}. Must be one of: ro, rw, full, 1, 2, 3") + + parser = argparse.ArgumentParser(description="Address Book manager") + + # Required arguments + parser.add_argument( + "command", + choices=["view-ab", "add-ab", "update-ab", "delete-ab", "get-personal-ab", + "view-peer", "add-peer", "update-peer", "delete-peer", + "view-tag", "add-tag", "update-tag", "delete-tag", + "view-rule", "add-rule", "update-rule", "delete-rule"], + help="Command to execute", + ) + + # Global arguments (used by all commands) + parser.add_argument("--url", required=True, help="URL of the API") + parser.add_argument("--token", required=True, help="Bearer token for authentication") + + # Address book identification (used by most commands except get-personal-ab) + parser.add_argument("--ab-name", help="Address book name (for identification)") + parser.add_argument("--ab-guid", help="Address book GUID (alternative to ab-name)") + + # Address book management arguments + parser.add_argument("--ab-update-name", help="New address book name (for update)") + parser.add_argument("--note", help="Note field") + parser.add_argument("--password", help="Password field") + parser.add_argument("--owner", help="Address book owner (username)") + + # Peer management arguments + parser.add_argument("--peer-id", help="Peer ID") + parser.add_argument("--alias", help="Peer alias") + parser.add_argument("--tags", help="Peer tags (supports both 'tag1,tag2' and '[tag1,tag2]' formats, use '[]' to clear tags)") + + # Tag management arguments + parser.add_argument("--tag-name", help="Tag name") + parser.add_argument("--tag-color", type=parse_color, help="Tag color (hex number like 0xFF00FF00 or decimal, auto-generated if not specified)") + + # Rule management arguments + parser.add_argument("--rule-type", choices=["user", "group", "everyone"], help="Rule type (auto-detected if not specified)") + parser.add_argument("--rule-user", help="Rule target user name (auto-sets rule-type=user)") + parser.add_argument("--rule-group", help="Rule target group name (auto-sets rule-type=group)") + parser.add_argument("--rule-permission", type=parse_permission, help="Rule permission (ro=Read, rw=ReadWrite, full=FullControl, or numeric 1/2/3)") + parser.add_argument("--rule-guid", help="Rule GUID (for update/delete)") + + args = parser.parse_args() + + # Remove trailing slashes from URL + while args.url.endswith("/"): + args.url = args.url[:-1] + + if args.command == "view-ab": + # View all shared address books + abs = view_shared_abs(args.url, args.token, args.ab_name) + print(json.dumps(abs, indent=2)) + + elif args.command == "get-personal-ab": + # Get personal address book GUID + personal_ab = get_personal_ab(args.url, args.token) + print(json.dumps(personal_ab, indent=2)) + + elif args.command in ["add-ab", "update-ab", "delete-ab"]: + # Address book management commands + if args.command == "add-ab": + if not args.ab_name: + print("Error: --ab-name is required for add-ab command") + return + + result = add_shared_ab(args.url, args.token, args.ab_name, args.note, args.password) + print(f"Result: {result}") + + elif args.command in ["update-ab", "delete-ab"]: + # Commands that need ab-name or ab-guid + if not args.ab_name and not args.ab_guid: + print("Error: --ab-name or --ab-guid is required for this command") + return + + if args.ab_name and args.ab_guid: + print("Error: Cannot specify both --ab-name and --ab-guid") + return + + if args.ab_guid: + ab_guid = args.ab_guid + print(f"Working with address book GUID: {ab_guid}") + else: + # Get address book by name + ab = get_ab_by_name(args.url, args.token, args.ab_name) + if not ab: + print(f"Error: Address book '{args.ab_name}' not found") + return + ab_guid = ab["guid"] + print(f"Working with address book: {args.ab_name} (GUID: {ab_guid})") + + if args.command == "update-ab": + result = update_shared_ab(args.url, args.token, ab_guid, args.ab_update_name, args.note, args.owner, args.password) + print(f"Result: {result}") + + elif args.command == "delete-ab": + result = delete_shared_abs(args.url, args.token, ab_guid) + print(f"Result: {result}") + + elif args.command in ["view-peer", "add-peer", "update-peer", "delete-peer", "view-tag", "add-tag", "update-tag", "delete-tag", "view-rule", "add-rule", "update-rule", "delete-rule"]: + if not args.ab_name and not args.ab_guid: + print("Error: --ab-name or --ab-guid is required for this command") + return + + if args.ab_name and args.ab_guid: + print("Error: Cannot specify both --ab-name and --ab-guid") + return + + if args.ab_guid: + ab_guid = args.ab_guid + print(f"Working with address book GUID: {ab_guid}") + else: + # Get address book by name + ab = get_ab_by_name(args.url, args.token, args.ab_name) + if not ab: + print(f"Error: Address book '{args.ab_name}' not found") + return + + ab_guid = ab["guid"] + print(f"Working with address book: {args.ab_name} (GUID: {ab_guid})") + + if args.command == "view-peer": + peers = view_ab_peers(args.url, args.token, ab_guid, args.peer_id, args.alias) + print(json.dumps(peers, indent=2)) + + elif args.command == "add-peer": + if not args.peer_id: + print("Error: --peer-id is required for add-peer command") + return + + # Handle tags parsing - support both [tag1,tag2] and tag1,tag2 formats + tags = None + if args.tags is not None: + if args.tags == "[]": + tags = [] # Empty list to clear tags + else: + # Remove brackets if present and split by comma + tags_str = args.tags.strip() + if tags_str.startswith('[') and tags_str.endswith(']'): + tags_str = tags_str[1:-1] # Remove brackets + tags = [tag.strip() for tag in tags_str.split(",") if tag.strip()] + + result = add_peer( + args.url, + args.token, + ab_guid, + args.peer_id, + args.alias, + args.note, + tags, + args.password + ) + print(f"Result: {result}") + + elif args.command == "update-peer": + if not args.peer_id: + print("Error: --peer-id is required for update-peer command") + return + + # Handle tags parsing - support both [tag1,tag2] and tag1,tag2 formats + tags = None + if args.tags is not None: + if args.tags == "[]": + tags = [] # Empty list to clear tags + else: + # Remove brackets if present and split by comma + tags_str = args.tags.strip() + if tags_str.startswith('[') and tags_str.endswith(']'): + tags_str = tags_str[1:-1] # Remove brackets + tags = [tag.strip() for tag in tags_str.split(",") if tag.strip()] + + result = update_peer( + args.url, + args.token, + ab_guid, + args.peer_id, + args.alias, + args.note, + tags, + args.password + ) + print(f"Result: {result}") + + elif args.command == "delete-peer": + if not args.peer_id: + print("Error: --peer-id is required for delete-peer command") + return + + result = delete_peer(args.url, args.token, ab_guid, args.peer_id) + print(f"Result: {result}") + + elif args.command == "view-tag": + tags = view_ab_tags(args.url, args.token, ab_guid) + print(json.dumps(tags, indent=2)) + + elif args.command == "add-tag": + if not args.tag_name: + print("Error: --tag-name is required for add-tag command") + return + + result = add_tag(args.url, args.token, ab_guid, args.tag_name, args.tag_color) + print(f"Result: {result}") + + elif args.command == "update-tag": + if not args.tag_name: + print("Error: --tag-name is required for update-tag command") + return + + result = update_tag(args.url, args.token, ab_guid, args.tag_name, args.tag_color) + print(f"Result: {result}") + + elif args.command == "delete-tag": + if not args.tag_name: + print("Error: --tag-name is required for delete-tag command") + return + + result = delete_tags(args.url, args.token, ab_guid, args.tag_name) + print(f"Result: {result}") + + elif args.command == "view-rule": + rules = view_ab_rules(args.url, args.token, ab_guid) + print(json.dumps(rules, indent=2)) + + elif args.command == "add-rule": + if not args.rule_permission: + print("Error: --rule-permission is required for add-rule command") + return + + # Auto-detect rule type if not explicitly specified + if not args.rule_type: + if args.rule_user and args.rule_group: + print("Error: Cannot specify both --rule-user and --rule-group") + return + elif args.rule_user: + rule_type = "user" + elif args.rule_group: + rule_type = "group" + else: + print("Error: Must specify --rule-type=everyone, --rule-user, or --rule-group") + return + else: + rule_type = args.rule_type + + # Validate explicit rule type with parameters + if rule_type == "user" and not args.rule_user: + print("Error: --rule-user is required when rule-type=user") + return + elif rule_type == "group" and not args.rule_group: + print("Error: --rule-group is required when rule-type=group") + return + elif rule_type == "user" and args.rule_group: + print("Error: Cannot specify --rule-group when rule-type=user") + return + elif rule_type == "group" and args.rule_user: + print("Error: Cannot specify --rule-user when rule-type=group") + return + elif rule_type == "everyone" and (args.rule_user or args.rule_group): + print("Error: Cannot specify --rule-user or --rule-group when rule-type=everyone") + return + + result = add_ab_rule(args.url, args.token, ab_guid, rule_type, args.rule_user, args.rule_group, args.rule_permission) + print(f"Result: {result}") + + elif args.command == "update-rule": + if not args.rule_guid: + print("Error: --rule-guid is required for update-rule command") + return + if not args.rule_permission: + print("Error: --rule-permission is required for update-rule command") + return + + result = update_ab_rule(args.url, args.token, args.rule_guid, args.rule_permission) + print(f"Result: {result}") + + elif args.command == "delete-rule": + if not args.rule_guid: + print("Error: --rule-guid is required for delete-rule command") + return + + result = delete_ab_rules(args.url, args.token, args.rule_guid) + print(f"Result: {result}") + + +if __name__ == "__main__": + main() diff --git a/res/audits.py b/res/audits.py new file mode 100755 index 000000000..77b0fd120 --- /dev/null +++ b/res/audits.py @@ -0,0 +1,374 @@ +#!/usr/bin/env python3 + +import requests +import argparse +import json +from datetime import datetime, timedelta, timezone + + +def format_timestamp(timestamp): + """Convert Unix timestamp to readable local datetime""" + if timestamp is None: + return None + try: + # Convert to local time + local_dt = datetime.fromtimestamp(timestamp) + return local_dt.strftime("%Y-%m-%d %H:%M:%S") + except (ValueError, TypeError): + return timestamp + + +def parse_local_time_to_utc_string(time_str): + """Parse local time string to UTC time string for API filtering""" + try: + # Parse the local time string + local_dt = datetime.strptime(time_str, "%Y-%m-%d %H:%M:%S.%f") + # Make the datetime object timezone-aware using system's local timezone + local_dt = local_dt.replace(tzinfo=datetime.now().astimezone().tzinfo) + utc_dt = local_dt.astimezone(timezone.utc) + return utc_dt.strftime("%Y-%m-%d %H:%M:%S.000") + except ValueError: + try: + # Try without microseconds + local_dt = datetime.strptime(time_str, "%Y-%m-%d %H:%M:%S") + # Make the datetime object timezone-aware using system's local timezone + local_dt = local_dt.replace(tzinfo=datetime.now().astimezone().tzinfo) + utc_dt = local_dt.astimezone(timezone.utc) + return utc_dt.strftime("%Y-%m-%d %H:%M:%S.000") + except ValueError: + return None + + +def get_connection_type_name(conn_type): + """Convert connection type number to readable name""" + type_map = { + 0: "Remote Desktop", + 1: "File Transfer", + 2: "Port Transfer", + 3: "View Camera", + 4: "Terminal" + } + return type_map.get(conn_type, f"Unknown ({conn_type})") + + +def get_console_type_name(console_type): + """Convert console audit type number to readable name""" + type_map = { + 0: "Group Management", + 1: "User Management", + 2: "Device Management", + 3: "Address Book Management" + } + return type_map.get(console_type, f"Unknown ({console_type})") + + +def get_console_operation_name(operation_code): + """Convert console operation code to readable name""" + operation_map = { + 0: "User Login", + 1: "Add Group", + 2: "Add User", + 3: "Add Device", + 4: "Delete Groups", + 5: "Disconnect Device", + 6: "Enable Users", + 7: "Disable Users", + 8: "Enable Devices", + 9: "Disable Devices", + 10: "Update Group", + 11: "Update User", + 12: "Update Device", + 13: "Delete User", + 14: "Delete Device", + 15: "Add Address Book", + 16: "Delete Address Book", + 17: "Change Address Book Name", + 18: "Delete Devices in the Address Book Recycle Bin", + 19: "Empty Address Book Recycle Bin", + 20: "Add Address Book Permission", + 21: "Delete Address Book Permission", + 22: "Update Address Book Permission" + } + return operation_map.get(operation_code, f"Unknown ({operation_code})") + + +def get_alarm_type_name(alarm_type): + """Convert alarm type number to readable name""" + type_map = { + 0: "Access attempt outside the IP whitelist", + 1: "Over 30 consecutive access attempts", + 2: "Multiple access attempts within one minute", + 3: "Over 30 consecutive login attempts", + 4: "Multiple login attempts within one minute", + 5: "Multiple login attempts within one hour" + } + return type_map.get(alarm_type, f"Unknown ({alarm_type})") + + +def enhance_audit_data(data, audit_type): + """Enhance audit data with readable formats""" + if not data: + return data + + enhanced_data = [] + for item in data: + enhanced_item = item.copy() + + # Convert timestamps - replace original values + if 'created_at' in enhanced_item: + enhanced_item['created_at'] = format_timestamp(enhanced_item['created_at']) + if 'end_time' in enhanced_item: + enhanced_item['end_time'] = format_timestamp(enhanced_item['end_time']) + + # Add type-specific enhancements - replace original values + if audit_type == 'conn': + if 'conn_type' in enhanced_item: + enhanced_item['conn_type'] = get_connection_type_name(enhanced_item['conn_type']) + else: + enhanced_item['conn_type'] = "Not Logged In" + + elif audit_type == 'console': + if 'typ' in enhanced_item: + # Replace typ field with type and convert to readable name + enhanced_item['type'] = get_console_type_name(enhanced_item['typ']) + del enhanced_item['typ'] + if 'iop' in enhanced_item: + # Replace iop field with operation and convert to readable name + enhanced_item['operation'] = get_console_operation_name(enhanced_item['iop']) + del enhanced_item['iop'] + + elif audit_type == 'alarm' and 'typ' in enhanced_item: + # Replace typ field with type and convert to readable name + enhanced_item['type'] = get_alarm_type_name(enhanced_item['typ']) + del enhanced_item['typ'] + + enhanced_data.append(enhanced_item) + + return enhanced_data + + +def check_response(response): + """Check API response and return result""" + if response.status_code != 200: + print(f"Error: HTTP {response.status_code} - {response.text}") + exit(1) + + try: + response_json = response.json() + if "error" in response_json: + print(f"Error: {response_json['error']}") + exit(1) + return response_json + except ValueError: + return response.text or "Success" + + +def view_audits_common(url, token, endpoint, filters=None, page_size=None, current=None, + created_at=None, days_ago=None, non_wildcard_fields=None): + """Common function for viewing audits""" + headers = {"Authorization": f"Bearer {token}"} + + # Set default page size and current page + if page_size is None: + page_size = 10 + if current is None: + current = 1 + + params = { + "pageSize": page_size, + "current": current + } + + # Add filter parameters if provided + if filters: + for key, value in filters.items(): + if value is not None: + params[key] = value + + # Handle time filters + if days_ago is not None: + # Calculate datetime from days ago + target_time = datetime.now() - timedelta(days=days_ago) + # Convert to UTC time string using system timezone + utc_timestamp = target_time.timestamp() + utc_dt = datetime.fromtimestamp(utc_timestamp, timezone.utc) + params["created_at"] = utc_dt.strftime("%Y-%m-%d %H:%M:%S.000") + elif created_at: + # Parse local time string and convert to UTC time string + utc_time_str = parse_local_time_to_utc_string(created_at) + if utc_time_str is not None: + params["created_at"] = utc_time_str + else: + # If parsing fails, pass the original value + params["created_at"] = created_at + + # Apply wildcard patterns for string fields (excluding specific fields) + if non_wildcard_fields is None: + non_wildcard_fields = set() + + # Always exclude these fields from wildcard treatment + non_wildcard_fields.update(["created_at", "pageSize", "current"]) + + string_params = {} + for k, v in params.items(): + if isinstance(v, str) and k not in non_wildcard_fields: + if v != "-" and "%" not in v: + string_params[k] = "%" + v + "%" + else: + string_params[k] = v + else: + string_params[k] = v + + response = requests.get(f"{url}/api/audits/{endpoint}", headers=headers, params=string_params) + response_json = check_response(response) + + # Enhance the data with readable formats + data = enhance_audit_data(response_json.get("data", []), endpoint) + + return { + "data": data, + "total": response_json.get("total", 0), + "current": current, + "pageSize": page_size + } + + +def view_conn_audits(url, token, remote=None, conn_type=None, + page_size=None, current=None, created_at=None, days_ago=None): + """View connection audits""" + filters = { + "remote": remote, + "conn_type": conn_type + } + non_wildcard_fields = {"conn_type"} + + return view_audits_common( + url, token, "conn", filters, page_size, current, created_at, days_ago, non_wildcard_fields + ) + + +def view_file_audits(url, token, remote=None, + page_size=None, current=None, created_at=None, days_ago=None): + """View file audits""" + filters = { + "remote": remote + } + non_wildcard_fields = set() + + return view_audits_common( + url, token, "file", filters, page_size, current, created_at, days_ago, non_wildcard_fields + ) + + +def view_alarm_audits(url, token, device=None, + page_size=None, current=None, created_at=None, days_ago=None): + """View alarm audits""" + filters = { + "device": device + } + non_wildcard_fields = set() + + return view_audits_common( + url, token, "alarm", filters, page_size, current, created_at, days_ago, non_wildcard_fields + ) + + +def view_console_audits(url, token, operator=None, + page_size=None, current=None, created_at=None, days_ago=None): + """View console audits""" + filters = { + "operator": operator + } + non_wildcard_fields = set() + + return view_audits_common( + url, token, "console", filters, page_size, current, created_at, days_ago, non_wildcard_fields + ) + + +def main(): + parser = argparse.ArgumentParser(description="Audits manager") + parser.add_argument( + "command", + choices=["view-conn", "view-file", "view-alarm", "view-console"], + help="Command to execute", + ) + parser.add_argument("--url", required=True, help="URL of the API") + parser.add_argument("--token", required=True, help="Bearer token for authentication") + + # Pagination parameters + parser.add_argument("--page-size", type=int, default=10, help="Number of records per page (default: 10)") + parser.add_argument("--current", type=int, default=1, help="Current page number (default: 1)") + + # Time filtering parameters + parser.add_argument("--created-at", help="Filter by creation time in local time (format: 2025-09-16 14:15:57 or 2025-09-16 14:15:57.000)") + parser.add_argument("--days-ago", type=int, help="Filter by days ago (e.g., 7 for last 7 days)") + + # Audit filters (simplified) + parser.add_argument("--remote", help="Remote peer ID filter (for conn/file audits)") + parser.add_argument("--device", help="Device ID filter (for alarm audits)") + parser.add_argument("--conn-type", type=int, help="Connection type filter (for conn audits only): 0=Remote Desktop, 1=File Transfer, 2=Port Transfer, 3=View Camera, 4=Terminal") + parser.add_argument("--operator", help="Operator filter (for console audits only)") + + args = parser.parse_args() + + # Remove trailing slashes from URL + while args.url.endswith("/"): + args.url = args.url[:-1] + + if args.command == "view-conn": + # View connection audits + result = view_conn_audits( + args.url, + args.token, + args.remote, + args.conn_type, + args.page_size, + args.current, + args.created_at, + args.days_ago + ) + print(json.dumps(result, indent=2)) + + elif args.command == "view-file": + # View file audits + result = view_file_audits( + args.url, + args.token, + args.remote, + args.page_size, + args.current, + args.created_at, + args.days_ago + ) + print(json.dumps(result, indent=2)) + + elif args.command == "view-alarm": + # View alarm audits + result = view_alarm_audits( + args.url, + args.token, + args.device, + args.page_size, + args.current, + args.created_at, + args.days_ago + ) + print(json.dumps(result, indent=2)) + + elif args.command == "view-console": + # View console audits + result = view_console_audits( + args.url, + args.token, + args.operator, + args.page_size, + args.current, + args.created_at, + args.days_ago + ) + print(json.dumps(result, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/res/device-groups.py b/res/device-groups.py new file mode 100755 index 000000000..dd861aefc --- /dev/null +++ b/res/device-groups.py @@ -0,0 +1,274 @@ +#!/usr/bin/env python3 + +import requests +import argparse +import json + + +def check_response(response): + """ + Check API response and handle errors. + + Two error cases: + 1. Status code is not 200 -> exit with error + 2. Response contains {"error": "xxx"} -> exit with error + """ + if response.status_code != 200: + print(f"Error: HTTP {response.status_code}: {response.text}") + exit(1) + + # Check for {"error": "xxx"} in response + if response.text and response.text.strip(): + try: + json_data = response.json() + if isinstance(json_data, dict) and "error" in json_data: + print(f"Error: {json_data['error']}") + exit(1) + return json_data + except ValueError: + return response.text + + return None + + +def headers_with(token): + return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + + +# ---------- Device Group APIs ---------- + +def list_groups(url, token, name=None, page_size=50): + headers = headers_with(token) + params = {"pageSize": page_size} + if name: + params["name"] = name + data, current = [], 0 + while True: + current += 1 + params["current"] = current + r = requests.get(f"{url}/api/device-groups", headers=headers, params=params) + if r.status_code != 200: + print(f"Error: HTTP {r.status_code} - {r.text}") + exit(1) + res = r.json() + if "error" in res: + print(f"Error: {res['error']}") + exit(1) + rows = res.get("data", []) + data.extend(rows) + total = res.get("total", 0) + if len(rows) < page_size or current * page_size >= total: + break + return data + + +def get_group_by_name(url, token, name): + groups = list_groups(url, token, name) + for g in groups: + if str(g.get("name")) == name: + return g + return None + + +def create_group(url, token, name, note=None, accessed_from=None): + headers = headers_with(token) + payload = {"name": name} + if note: + payload["note"] = note + if accessed_from: + payload["allowed_incomings"] = accessed_from + r = requests.post(f"{url}/api/device-groups", headers=headers, json=payload) + return check_response(r) + + +def update_group(url, token, name, new_name=None, note=None, accessed_from=None): + headers = headers_with(token) + g = get_group_by_name(url, token, name) + if not g: + print(f"Error: Group '{name}' not found") + exit(1) + guid = g.get("guid") + payload = {} + if new_name is not None: + payload["name"] = new_name + if note is not None: + payload["note"] = note + if accessed_from is not None: + payload["allowed_incomings"] = accessed_from + r = requests.patch(f"{url}/api/device-groups/{guid}", headers=headers, json=payload) + check_response(r) + return "Success" + + +def delete_groups(url, token, names): + headers = headers_with(token) + if isinstance(names, str): + names = [names] + for n in names: + g = get_group_by_name(url, token, n) + if not g: + print(f"Error: Group '{n}' not found") + exit(1) + guid = g.get("guid") + r = requests.delete(f"{url}/api/device-groups/{guid}", headers=headers) + check_response(r) + return "Success" + + +# ---------- Device group assign APIs (name -> guid) ---------- + +def view_devices(url, token, group_name=None, id=None, device_name=None, + user_name=None, device_username=None, page_size=50): + """View devices in a device group with filters""" + headers = headers_with(token) + + # Separate exact match and fuzzy match params + params = {} + fuzzy_params = { + "id": id, + "device_name": device_name, + "user_name": user_name, + "device_username": device_username, + } + + # Add device_group_name without wildcard (exact match) + if group_name: + params["device_group_name"] = group_name + + # Add wildcard for fuzzy search to other params + for k, v in fuzzy_params.items(): + if v is not None: + params[k] = "%" + v + "%" if (v != "-" and "%" not in v) else v + + params["pageSize"] = page_size + + data, current = [], 0 + while True: + current += 1 + params["current"] = current + r = requests.get(f"{url}/api/devices", headers=headers, params=params) + if r.status_code != 200: + return check_response(r) + res = r.json() + rows = res.get("data", []) + data.extend(rows) + total = res.get("total", 0) + if len(rows) < page_size or current * page_size >= total: + break + return data + + +def add_devices(url, token, group_name, device_ids): + headers = headers_with(token) + g = get_group_by_name(url, token, group_name) + if not g: + return f"Group '{group_name}' not found" + guid = g.get("guid") + payload = device_ids if isinstance(device_ids, list) else [device_ids] + r = requests.post(f"{url}/api/device-groups/{guid}", headers=headers, json=payload) + return check_response(r) + + +def remove_devices(url, token, group_name, device_ids): + headers = headers_with(token) + g = get_group_by_name(url, token, group_name) + if not g: + return f"Group '{group_name}' not found" + guid = g.get("guid") + payload = device_ids if isinstance(device_ids, list) else [device_ids] + r = requests.delete(f"{url}/api/device-groups/{guid}/devices", headers=headers, json=payload) + return check_response(r) + + +def parse_rules(s): + if not s: + return None + try: + v = json.loads(s) + if isinstance(v, list): + # expect list of {"type": number, "name": string} + return v + except Exception: + pass + return None + + +def main(): + parser = argparse.ArgumentParser(description="Device Group manager") + parser.add_argument("command", choices=[ + "view", "add", "update", "delete", + "view-devices", "add-devices", "remove-devices" + ], help=( + "Command to execute. " + "[view/add/update/delete/add-devices/remove-devices: require Device Group Permission] " + "[view-devices: require Device Permission]" + )) + parser.add_argument("--url", required=True) + parser.add_argument("--token", required=True) + + parser.add_argument("--name", help="Device group name (exact match)") + parser.add_argument("--new-name", help="New device group name (for update)") + parser.add_argument("--note", help="Note") + + parser.add_argument("--accessed-from", help="JSON array: '[{\"type\":0|2,\"name\":\"...\"}]' (0=User Group, 2=User)") + + parser.add_argument("--ids", help="Comma separated device IDs for add-devices/remove-devices") + + # Filters for view-devices command + parser.add_argument("--id", help="Device ID filter (for view-devices)") + parser.add_argument("--device-name", help="Device name filter (for view-devices)") + parser.add_argument("--user-name", help="User name filter (owner of device, for view-devices)") + parser.add_argument("--device-username", help="Device username filter (logged in user on device, for view-devices)") + + args = parser.parse_args() + while args.url.endswith("/"): args.url = args.url[:-1] + + if args.command == "view": + res = list_groups(args.url, args.token, args.name) + print(json.dumps(res, indent=2)) + elif args.command == "add": + if not args.name: + print("Error: --name is required") + exit(1) + print(create_group( + args.url, args.token, args.name, args.note, + parse_rules(args.accessed_from) + )) + elif args.command == "update": + if not args.name: + print("Error: --name is required") + exit(1) + print(update_group( + args.url, args.token, args.name, args.new_name, args.note, + parse_rules(args.accessed_from) + )) + elif args.command == "delete": + if not args.name: + print("Error: --name is required (supports comma separated)") + exit(1) + names = [x.strip() for x in args.name.split(",") if x.strip()] + print(delete_groups(args.url, args.token, names)) + elif args.command == "view-devices": + res = view_devices( + args.url, + args.token, + group_name=args.name, + id=args.id, + device_name=args.device_name, + user_name=args.user_name, + device_username=args.device_username + ) + print(json.dumps(res, indent=2)) + elif args.command in ("add-devices", "remove-devices"): + if not args.name or not args.ids: + print("Error: --name and --ids are required for add/remove devices") + exit(1) + ids = [x.strip() for x in args.ids.split(",") if x.strip()] + if args.command == "add-devices": + print(add_devices(args.url, args.token, args.name, ids)) + else: + print(remove_devices(args.url, args.token, args.name, ids)) + + +if __name__ == "__main__": + main() diff --git a/res/devices.py b/res/devices.py index f9bf27352..832f0509b 100755 --- a/res/devices.py +++ b/res/devices.py @@ -12,6 +12,7 @@ def view( device_name=None, user_name=None, group_name=None, + device_group_name=None, offline_days=None, ): headers = {"Authorization": f"Bearer {token}"} @@ -21,6 +22,7 @@ def view( "device_name": device_name, "user_name": user_name, "group_name": group_name, + "device_group_name": device_group_name, } params = { @@ -32,12 +34,20 @@ def view( devices = [] - current = 1 + current = 0 while True: + current += 1 params["current"] = current response = requests.get(f"{url}/api/devices", headers=headers, params=params) + if response.status_code != 200: + print(f"Error: HTTP {response.status_code} - {response.text}") + exit(1) + response_json = response.json() + if "error" in response_json: + print(f"Error: {response_json['error']}") + exit(1) data = response_json.get("data", []) @@ -52,22 +62,25 @@ def view( devices.append(device) total = response_json.get("total", 0) - current += pageSize - if len(data) < pageSize or current > total: + if len(data) < pageSize or current * pageSize >= total: break return devices def check(response): - if response.status_code == 200: - try: - response_json = response.json() - return response_json - except ValueError: - return response.text or "Success" - else: - return "Failed", response.status_code, response.text + if response.status_code != 200: + print(f"Error: HTTP {response.status_code} - {response.text}") + exit(1) + + try: + response_json = response.json() + if "error" in response_json: + print(f"Error: {response_json['error']}") + exit(1) + return response_json + except ValueError: + return response.text or "Success" def disable(url, token, guid, id): @@ -93,8 +106,17 @@ def delete(url, token, guid, id): def assign(url, token, guid, id, type, value): print("assign", id, type, value) - if type != "ab" and type != "strategy_name" and type != "user_name": - print("Invalid type, it must be 'ab', 'strategy_name' or 'user_name'") + valid_types = [ + "ab", + "strategy_name", + "user_name", + "device_group_name", + "note", + "device_username", + "device_name", + ] + if type not in valid_types: + print(f"Invalid type, it must be one of: {', '.join(valid_types)}") return data = {"type": type, "value": value} headers = {"Authorization": f"Bearer {token}"} @@ -118,10 +140,11 @@ def main(): parser.add_argument("--id", help="Device ID") parser.add_argument("--device_name", help="Device name") parser.add_argument("--user_name", help="User name") - parser.add_argument("--group_name", help="Group name") + parser.add_argument("--group_name", help="User group name") + parser.add_argument("--device_group_name", help="Device group name") parser.add_argument( "--assign_to", - help="=, e.g. user_name=mike, strategy_name=test, ab=ab1, ab=ab1,tag1", + help="=, e.g. user_name=mike, strategy_name=test, device_group_name=group1, note=note1, device_username=username1, device_name=name1, ab=ab1, ab=ab1,tag1,alias1,password1,note1" ) parser.add_argument( "--offline_days", type=int, help="Offline duration in days, e.g., 7" @@ -138,34 +161,44 @@ def main(): args.device_name, args.user_name, args.group_name, + args.device_group_name, args.offline_days, ) if args.command == "view": for device in devices: print(device) - elif args.command == "disable": - for device in devices: - response = disable(args.url, args.token, device["guid"], device["id"]) - print(response) - elif args.command == "enable": - for device in devices: - response = enable(args.url, args.token, device["guid"], device["id"]) - print(response) - elif args.command == "delete": - for device in devices: - response = delete(args.url, args.token, device["guid"], device["id"]) - print(response) - elif args.command == "assign": - if "=" not in args.assign_to: - print("Invalid assign_to format, it must be =") - return - type, value = args.assign_to.split("=", 1) - for device in devices: - response = assign( - args.url, args.token, device["guid"], device["id"], type, value - ) - print(response) + elif args.command in ["disable", "enable", "delete", "assign"]: + # Check if we need user confirmation for multiple devices + if len(devices) > 1: + print(f"Found {len(devices)} devices. Do you want to proceed with {args.command} operation on the devices? (Y/N)") + confirmation = input("Type 'Y' to confirm: ").strip() + if confirmation.upper() != 'Y': + print("Operation cancelled.") + return + + if args.command == "disable": + for device in devices: + response = disable(args.url, args.token, device["guid"], device["id"]) + print(response) + elif args.command == "enable": + for device in devices: + response = enable(args.url, args.token, device["guid"], device["id"]) + print(response) + elif args.command == "delete": + for device in devices: + response = delete(args.url, args.token, device["guid"], device["id"]) + print(response) + elif args.command == "assign": + if "=" not in args.assign_to: + print("Invalid assign_to format, it must be =") + return + type, value = args.assign_to.split("=", 1) + for device in devices: + response = assign( + args.url, args.token, device["guid"], device["id"], type, value + ) + print(response) if __name__ == "__main__": diff --git a/res/inline-sciter.py b/res/inline-sciter.py index 26cf1a754..4c81bd621 100644 --- a/res/inline-sciter.py +++ b/res/inline-sciter.py @@ -23,7 +23,8 @@ remote = open('src/ui/remote.html').read() \ .replace('include "grid.tis";', open('src/ui/grid.tis').read()) \ .replace('include "header.tis";', open('src/ui/header.tis').read()) \ .replace('include "file_transfer.tis";', open('src/ui/file_transfer.tis').read()) \ - .replace('include "port_forward.tis";', open('src/ui/port_forward.tis').read()) + .replace('include "port_forward.tis";', open('src/ui/port_forward.tis').read()) \ + .replace('include "printer.tis";', open('src/ui/printer.tis').read()) chatbox = open('src/ui/chatbox.html').read() install = open('src/ui/install.html').read().replace('include "install.tis";', open('src/ui/install.tis').read()) diff --git a/res/job.py b/res/job.py index 13ea9e81d..e53105fd3 100755 --- a/res/job.py +++ b/res/job.py @@ -205,9 +205,13 @@ def sign_files(dir_path, only_ext=None): if not only_ext[i].startswith("."): only_ext[i] = "." + only_ext[i] for root, dirs, files in os.walk(dir_path): + is_signed_dir = "RustDeskPrinterDriver" in root or "usbmmidd_v2" in root for file in files: file_path = os.path.join(root, file) _, ext = os.path.splitext(file_path) + # only sign the exe files in signed dirs + if is_signed_dir and ext not in [".exe"]: + continue if only_ext and ext not in only_ext: continue if ext in SIGN_EXTENSIONS: diff --git a/res/msi/CustomActions/Common.h b/res/msi/CustomActions/Common.h index 5dcd529c0..08302d98c 100644 --- a/res/msi/CustomActions/Common.h +++ b/res/msi/CustomActions/Common.h @@ -15,3 +15,9 @@ bool MyStopServiceW(LPCWSTR serviceName); std::wstring ReadConfig(const std::wstring& filename, const std::wstring& key); void UninstallDriver(LPCWSTR hardwareId, BOOL &rebootRequired); + +namespace RemotePrinter +{ + VOID installUpdatePrinter(const std::wstring& installFolder); + VOID uninstallPrinter(); +} diff --git a/res/msi/CustomActions/CustomActions.cpp b/res/msi/CustomActions/CustomActions.cpp index afe06fdbb..f4780dd87 100644 --- a/res/msi/CustomActions/CustomActions.cpp +++ b/res/msi/CustomActions/CustomActions.cpp @@ -31,24 +31,161 @@ LExit: return WcaFinalize(er); } -// CAUTION: We can't simply remove the install folder here, because silent repair/upgrade will fail. -// `RemoveInstallFolder()` is a deferred custom action, it will be executed after the files are copied. -// `msiexec /i package.msi /qn` +// Helper function to safely delete a file using handle-based deletion. +// Directories are refused after opening the handle. +BOOL SafeDeleteItem(LPCWSTR fullPath) +{ + // Open the file/directory with delete and attribute-read access plus FILE_FLAG_OPEN_REPARSE_POINT + // to prevent following symlinks. + // Use shared access to allow deletion even when other processes have the file open. + DWORD flags = FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT; + HANDLE hFile = CreateFileW( + fullPath, + DELETE | FILE_READ_ATTRIBUTES, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, // Allow shared access + NULL, + OPEN_EXISTING, + flags, + NULL + ); + + if (hFile == INVALID_HANDLE_VALUE) + { + WcaLog(LOGMSG_STANDARD, "SafeDeleteItem: Failed to open '%ls'. Error: %lu", fullPath, GetLastError()); + return FALSE; + } + + BY_HANDLE_FILE_INFORMATION fileInfo; + if (FALSE == GetFileInformationByHandle(hFile, &fileInfo)) + { + WcaLog(LOGMSG_STANDARD, "SafeDeleteItem: Failed to inspect '%ls'. Error: %lu", fullPath, GetLastError()); + CloseHandle(hFile); + return FALSE; + } + + if (fileInfo.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) + { + WcaLog(LOGMSG_STANDARD, "SafeDeleteItem: Refusing to delete directory '%ls'.", fullPath); + CloseHandle(hFile); + return FALSE; + } + + // Use SetFileInformationByHandle to mark for deletion. + // The file will be deleted when the handle is closed. + FILE_DISPOSITION_INFO dispInfo; + dispInfo.DeleteFile = TRUE; + + BOOL result = SetFileInformationByHandle( + hFile, + FileDispositionInfo, + &dispInfo, + sizeof(dispInfo) + ); + + if (!result) + { + DWORD error = GetLastError(); + WcaLog(LOGMSG_STANDARD, "SafeDeleteItem: Failed to mark '%ls' for deletion. Error: %lu", fullPath, error); + } + + CloseHandle(hFile); + return result; +} + +BOOL PathEndsWithSlash(LPCWSTR path) +{ + size_t length = 0; + HRESULT hr = StringCchLengthW(path, MAX_PATH, &length); + if (FAILED(hr) || length == 0) + { + return FALSE; + } + + WCHAR last = path[length - 1]; + return last == L'\\' || last == L'/'; +} + +void ClearReadOnlyAttribute(LPCWSTR fullPath, DWORD attributes) +{ + if (!(attributes & FILE_ATTRIBUTE_READONLY)) + { + return; + } + + DWORD writableAttributes = attributes & ~FILE_ATTRIBUTE_READONLY; + if (writableAttributes == 0) + { + writableAttributes = FILE_ATTRIBUTE_NORMAL; + } + + if (SetFileAttributesW(fullPath, writableAttributes)) + { + WcaLog(LOGMSG_STANDARD, "Runtime cleanup cleared read-only attribute for '%ls'.", fullPath); + return; + } + + WcaLog(LOGMSG_STANDARD, "Runtime cleanup failed to clear read-only attribute for '%ls'. Error: %lu", fullPath, GetLastError()); +} + +BOOL DeleteRuntimeGeneratedFile(LPCWSTR installFolder, LPCWSTR fileName) +{ + WCHAR fullPath[MAX_PATH]; + LPCWSTR separator = PathEndsWithSlash(installFolder) ? L"" : L"\\"; + HRESULT hr = StringCchPrintfW(fullPath, MAX_PATH, L"%s%s%s", installFolder, separator, fileName); + if (FAILED(hr)) + { + WcaLog(LOGMSG_STANDARD, "Runtime cleanup path is too long for '%ls'.", fileName); + return FALSE; + } + + DWORD attributes = GetFileAttributesW(fullPath); + if (attributes == INVALID_FILE_ATTRIBUTES) + { + DWORD error = GetLastError(); + if (error == ERROR_FILE_NOT_FOUND || error == ERROR_PATH_NOT_FOUND) + { + return TRUE; + } + + WcaLog(LOGMSG_STANDARD, "Runtime cleanup cannot stat '%ls'. Error: %lu", fullPath, error); + return FALSE; + } + + if (attributes & FILE_ATTRIBUTE_DIRECTORY) + { + WcaLog(LOGMSG_STANDARD, "Runtime cleanup skipped directory '%ls'.", fullPath); + return FALSE; + } + + ClearReadOnlyAttribute(fullPath, attributes); + WcaLog(LOGMSG_STANDARD, "Runtime cleanup deleting '%ls'.", fullPath); + return SafeDeleteItem(fullPath); +} + +// See `Package.wxs` for the sequence of this custom action. // -// So we need to delete the files separately in install folder. -UINT __stdcall RemoveInstallFolder( +// Upgrade/uninstall sequence: +// 1. InstallInitialize +// 2. RemoveExistingProducts +// ├─ TerminateProcesses +// ├─ TryStopDeleteService +// ├─ RemoveRuntimeGeneratedFiles - <-- Here +// └─ RemoveFiles +// 3. InstallValidate +// 4. InstallFiles +// 5. InstallExecute +// 6. InstallFinalize +UINT __stdcall RemoveRuntimeGeneratedFiles( __in MSIHANDLE hInstall) { HRESULT hr = S_OK; DWORD er = ERROR_SUCCESS; - int nResult = 0; LPWSTR installFolder = NULL; LPWSTR pwz = NULL; LPWSTR pwzData = NULL; - WCHAR runtimeBroker[1024] = { 0, }; - hr = WcaInitialize(hInstall, "RemoveInstallFolder"); + hr = WcaInitialize(hInstall, "RemoveRuntimeGeneratedFiles"); ExitOnFailure(hr, "Failed to initialize"); hr = WcaGetProperty(L"CustomActionData", &pwzData); @@ -56,26 +193,21 @@ UINT __stdcall RemoveInstallFolder( pwz = pwzData; hr = WcaReadStringFromCaData(&pwz, &installFolder); - ExitOnFailure(hr, "failed to read database key from custom action data: %ls", pwz); + ExitOnFailure(hr, "failed to read install folder from custom action data: %ls", pwz); - StringCchPrintfW(runtimeBroker, sizeof(runtimeBroker) / sizeof(runtimeBroker[0]), L"%ls\\RuntimeBroker_rustdesk.exe", installFolder); - - SHFILEOPSTRUCTW fileOp; - ZeroMemory(&fileOp, sizeof(SHFILEOPSTRUCT)); - fileOp.wFunc = FO_DELETE; - fileOp.pFrom = runtimeBroker; - fileOp.fFlags = FOF_NOCONFIRMATION | FOF_SILENT; - - nResult = SHFileOperationW(&fileOp); - if (nResult == 0) - { - WcaLog(LOGMSG_STANDARD, "The external file \"%ls\" has been deleted.", runtimeBroker); + if (installFolder == NULL || installFolder[0] == L'\0') { + WcaLog(LOGMSG_STANDARD, "Install folder path is empty, skipping runtime cleanup."); + goto LExit; } - else - { - WcaLog(LOGMSG_STANDARD, "The external file \"%ls\" has not been deleted, error code: 0x%02X. Please refer to https://learn.microsoft.com/en-us/windows/win32/api/shellapi/nf-shellapi-shfileoperationa for the error codes.", runtimeBroker, nResult); + + if (PathIsRootW(installFolder)) { + WcaLog(LOGMSG_STANDARD, "Refusing runtime cleanup in root folder '%ls'.", installFolder); + goto LExit; } + WcaLog(LOGMSG_STANDARD, "Removing runtime-generated files from install folder: %ls", installFolder); + DeleteRuntimeGeneratedFile(installFolder, L"RuntimeBroker_rustdesk.exe"); + LExit: ReleaseStr(pwzData); @@ -109,9 +241,12 @@ bool TerminateProcessIfNotContainsParam(pfnNtQueryInformationProcess NtQueryInfo { if (pebUpp.CommandLine.Length > 0) { - WCHAR *commandLine = (WCHAR *)malloc(pebUpp.CommandLine.Length); + // Allocate extra space for null terminator + WCHAR *commandLine = (WCHAR *)malloc(pebUpp.CommandLine.Length + sizeof(WCHAR)); if (commandLine != NULL) { + // Initialize all bytes to zero for safety + memset(commandLine, 0, pebUpp.CommandLine.Length + sizeof(WCHAR)); if (ReadProcessMemory(process, pebUpp.CommandLine.Buffer, commandLine, pebUpp.CommandLine.Length, &dwBytesRead)) { @@ -468,10 +603,10 @@ UINT __stdcall TryStopDeleteService(__in MSIHANDLE hInstall) } if (IsServiceRunningW(svcName)) { - WcaLog(LOGMSG_STANDARD, "Service \"%ls\" is not stoped after 1000 ms.", svcName); + WcaLog(LOGMSG_STANDARD, "Service \"%ls\" is not stopped after 1000 ms.", svcName); } else { - WcaLog(LOGMSG_STANDARD, "Service \"%ls\" is stoped.", svcName); + WcaLog(LOGMSG_STANDARD, "Service \"%ls\" is stopped.", svcName); } if (MyDeleteServiceW(svcName)) { @@ -497,7 +632,7 @@ UINT __stdcall TryStopDeleteService(__in MSIHANDLE hInstall) } // It's really strange that we need sleep here. - // But the upgrading may be stucked at "copying new files" because the file is in using. + // But the upgrading may be stuck at "copying new files" because the file is in using. // Steps to reproduce: Install -> stop service in tray --> start service -> upgrade // Sleep(300); @@ -610,7 +745,7 @@ UINT __stdcall AddRegSoftwareSASGeneration(__in MSIHANDLE hInstall) } // Why RegSetValueExW always return 998? - // + // result = RegCreateKeyExW(HKEY_LOCAL_MACHINE, subKey, 0, NULL, REG_OPTION_NON_VOLATILE, KEY_WRITE, NULL, &hKey, NULL); if (result != ERROR_SUCCESS) { WcaLog(LOGMSG_STANDARD, "Failed to create or open registry key: %d", result); @@ -726,7 +861,7 @@ void TryCreateStartServiceByShell(LPWSTR svcName, LPWSTR svcBinary, LPWSTR szSvc i = 0; j = 0; // svcBinary is a string with double quotes, we need to escape it for shell arguments. - // It is orignal used for `CreateServiceW`. + // It is original used for `CreateServiceW`. // eg. "C:\Program Files\MyApp\MyApp.exe" --service -> \"C:\Program Files\MyApp\MyApp.exe\" --service while (true) { if (svcBinary[j] == L'"') { @@ -878,3 +1013,55 @@ void TryStopDeleteServiceByShell(LPWSTR svcName) WcaLog(LOGMSG_STANDARD, "Failed to delete service: \"%ls\" with shell, current status: %d.", svcName, svcStatus.dwCurrentState); } } + +UINT __stdcall InstallPrinter( + __in MSIHANDLE hInstall) +{ + HRESULT hr = S_OK; + DWORD er = ERROR_SUCCESS; + + int nResult = 0; + LPWSTR installFolder = NULL; + LPWSTR pwz = NULL; + LPWSTR pwzData = NULL; + + hr = WcaInitialize(hInstall, "InstallPrinter"); + ExitOnFailure(hr, "Failed to initialize"); + + hr = WcaGetProperty(L"CustomActionData", &pwzData); + ExitOnFailure(hr, "failed to get CustomActionData"); + + pwz = pwzData; + hr = WcaReadStringFromCaData(&pwz, &installFolder); + ExitOnFailure(hr, "failed to read database key from custom action data: %ls", pwz); + + WcaLog(LOGMSG_STANDARD, "Try to install RD printer in : %ls", installFolder); + RemotePrinter::installUpdatePrinter(installFolder); + WcaLog(LOGMSG_STANDARD, "Install RD printer done"); + +LExit: + if (pwzData) { + ReleaseStr(pwzData); + } + + er = SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE; + return WcaFinalize(er); +} + +UINT __stdcall UninstallPrinter( + __in MSIHANDLE hInstall) +{ + HRESULT hr = S_OK; + DWORD er = ERROR_SUCCESS; + + hr = WcaInitialize(hInstall, "UninstallPrinter"); + ExitOnFailure(hr, "Failed to initialize"); + + WcaLog(LOGMSG_STANDARD, "Try to uninstall RD printer"); + RemotePrinter::uninstallPrinter(); + WcaLog(LOGMSG_STANDARD, "Uninstall RD printer done"); + +LExit: + er = SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE; + return WcaFinalize(er); +} diff --git a/res/msi/CustomActions/CustomActions.def b/res/msi/CustomActions/CustomActions.def index 557bfaf18..d50fbf59b 100644 --- a/res/msi/CustomActions/CustomActions.def +++ b/res/msi/CustomActions/CustomActions.def @@ -2,7 +2,7 @@ LIBRARY "CustomActions" EXPORTS CustomActionHello - RemoveInstallFolder + RemoveRuntimeGeneratedFiles TerminateProcesses AddFirewallRules SetPropertyIsServiceRunning @@ -12,3 +12,5 @@ EXPORTS SetPropertyFromConfig AddRegSoftwareSASGeneration RemoveAmyuniIdd + InstallPrinter + UninstallPrinter diff --git a/res/msi/CustomActions/CustomActions.vcxproj b/res/msi/CustomActions/CustomActions.vcxproj index 1bff7b154..2e704fbb5 100644 --- a/res/msi/CustomActions/CustomActions.vcxproj +++ b/res/msi/CustomActions/CustomActions.vcxproj @@ -67,6 +67,7 @@ Create + diff --git a/res/msi/CustomActions/RemotePrinter.cpp b/res/msi/CustomActions/RemotePrinter.cpp new file mode 100644 index 000000000..767c8c82c --- /dev/null +++ b/res/msi/CustomActions/RemotePrinter.cpp @@ -0,0 +1,517 @@ +#include "pch.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "Common.h" + +#pragma comment(lib, "setupapi.lib") +#pragma comment(lib, "winspool.lib") + +namespace RemotePrinter +{ +#define HRESULT_ERR_ELEMENT_NOT_FOUND 0x80070490 + + LPCWCH RD_DRIVER_INF_PATH = L"drivers\\RustDeskPrinterDriver\\RustDeskPrinterDriver.inf"; + LPCWCH RD_PRINTER_PORT = L"RustDesk Printer"; + LPCWCH RD_PRINTER_NAME = L"RustDesk Printer"; + LPCWCH RD_PRINTER_DRIVER_NAME = L"RustDesk v4 Printer Driver"; + LPCWCH XCV_MONITOR_LOCAL_PORT = L",XcvMonitor Local Port"; + + using FuncEnum = std::function; + template + using FuncOnData = std::function(const T &)>; + template + using FuncOnNoData = std::function()>; + + template + std::shared_ptr commonEnum(std::wstring funcName, FuncEnum func, DWORD level, FuncOnData onData, FuncOnNoData onNoData) + { + DWORD needed = 0; + DWORD returned = 0; + func(level, NULL, 0, &needed, &returned); + if (needed == 0) + { + return onNoData(); + } + + std::vector buffer(needed); + if (!func(level, buffer.data(), needed, &needed, &returned)) + { + return nullptr; + } + + T *pPortInfo = reinterpret_cast(buffer.data()); + for (DWORD i = 0; i < returned; i++) + { + auto r = onData(pPortInfo[i]); + if (r) + { + return r; + } + } + return onNoData(); + } + + BOOL isNameEqual(LPCWSTR lhs, LPCWSTR rhs) + { + // https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-lstrcmpiw + // For some locales, the lstrcmpi function may be insufficient. + // If this occurs, use `CompareStringEx` to ensure proper comparison. + // For example, in Japan call with the NORM_IGNORECASE, NORM_IGNOREKANATYPE, and NORM_IGNOREWIDTH values to achieve the most appropriate non-exact string comparison. + // Note that specifying these values slows performance, so use them only when necessary. + // + // No need to consider `CompareStringEx` for now. + return lstrcmpiW(lhs, rhs) == 0 ? TRUE : FALSE; + } + + BOOL enumPrinterPort( + DWORD level, + LPBYTE pPortInfo, + DWORD cbBuf, + LPDWORD pcbNeeded, + LPDWORD pcReturned) + { + // https://learn.microsoft.com/en-us/windows/win32/printdocs/enumports + // This is a blocking or synchronous function and might not return immediately. + // How quickly this function returns depends on run-time factors + // such as network status, print server configuration, and printer driver implementation factors that are difficult to predict when writing an application. + // Calling this function from a thread that manages interaction with the user interface could make the application appear to be unresponsive. + return EnumPortsW(NULL, level, pPortInfo, cbBuf, pcbNeeded, pcReturned); + } + + BOOL isPortExists(LPCWSTR port) + { + auto onData = [port](const PORT_INFO_2 &info) + { + if (isNameEqual(info.pPortName, port) == TRUE) { + return std::shared_ptr(new BOOL(TRUE)); + } + else { + return std::shared_ptr(nullptr); + } }; + auto onNoData = []() + { return nullptr; }; + auto res = commonEnum(L"EnumPortsW", enumPrinterPort, 2, onData, onNoData); + if (res == nullptr) + { + return false; + } + else + { + return *res; + } + } + + BOOL executeOnLocalPort(LPCWSTR port, LPCWSTR command) + { + PRINTER_DEFAULTSW dft = {0}; + dft.DesiredAccess = SERVER_WRITE; + HANDLE hMonitor = NULL; + if (OpenPrinterW(const_cast(XCV_MONITOR_LOCAL_PORT), &hMonitor, &dft) == FALSE) + { + return FALSE; + } + + DWORD outputNeeded = 0; + DWORD status = 0; + if (XcvDataW(hMonitor, command, (LPBYTE)port, (lstrlenW(port) + 1) * 2, NULL, 0, &outputNeeded, &status) == FALSE) + { + ClosePrinter(hMonitor); + return FALSE; + } + + ClosePrinter(hMonitor); + return TRUE; + } + + BOOL addLocalPort(LPCWSTR port) + { + return executeOnLocalPort(port, L"AddPort"); + } + + BOOL deleteLocalPort(LPCWSTR port) + { + return executeOnLocalPort(port, L"DeletePort"); + } + + BOOL checkAddLocalPort(LPCWSTR port) + { + if (!isPortExists(port)) + { + return addLocalPort(port); + } + return TRUE; + } + + std::wstring getPrinterInstalledOnPort(LPCWSTR port); + + BOOL checkDeleteLocalPort(LPCWSTR port) + { + if (isPortExists(port)) + { + if (getPrinterInstalledOnPort(port) != L"") + { + WcaLog(LOGMSG_STANDARD, "The printer is installed on the port. Please remove the printer first.\n"); + return FALSE; + } + return deleteLocalPort(port); + } + return TRUE; + } + + BOOL enumPrinterDriver( + DWORD level, + LPBYTE pDriverInfo, + DWORD cbBuf, + LPDWORD pcbNeeded, + LPDWORD pcReturned) + { + // https://learn.microsoft.com/en-us/windows/win32/printdocs/enumprinterdrivers + // This is a blocking or synchronous function and might not return immediately. + // How quickly this function returns depends on run-time factors + // such as network status, print server configuration, and printer driver implementation factors that are difficult to predict when writing an application. + // Calling this function from a thread that manages interaction with the user interface could make the application appear to be unresponsive. + return EnumPrinterDriversW( + NULL, + NULL, + level, + pDriverInfo, + cbBuf, + pcbNeeded, + pcReturned); + } + + DWORDLONG getInstalledDriverVersion(LPCWSTR name) + { + auto onData = [name](const DRIVER_INFO_6W &info) + { + if (isNameEqual(name, info.pName) == TRUE) + { + return std::shared_ptr(new DWORDLONG(info.dwlDriverVersion)); + } + else + { + return std::shared_ptr(nullptr); + } }; + auto onNoData = []() + { return nullptr; }; + auto res = commonEnum(L"EnumPrinterDriversW", enumPrinterDriver, 6, onData, onNoData); + if (res == nullptr) + { + return 0; + } + else + { + return *res; + } + } + + std::wstring findInf(LPCWSTR name) + { + auto onData = [name](const DRIVER_INFO_8W &info) + { + if (isNameEqual(name, info.pName) == TRUE) + { + return std::shared_ptr(new std::wstring(info.pszInfPath)); + } + else + { + return std::shared_ptr(nullptr); + } }; + auto onNoData = []() + { return nullptr; }; + auto res = commonEnum(L"EnumPrinterDriversW", enumPrinterDriver, 8, onData, onNoData); + if (res == nullptr) + { + return L""; + } + else + { + return *res; + } + } + + BOOL deletePrinterDriver(LPCWSTR name) + { + // If the printer is used after the spooler service is started. E.g., printing a document through RustDesk Printer. + // `DeletePrinterDriverExW()` may fail with `ERROR_PRINTER_DRIVER_IN_USE`(3001, 0xBB9). + // We can only ignore this error for now. + // Though restarting the spooler service is a solution, it's not a good idea to restart the service. + // + // Deleting the printer driver after deleting the printer is a common practice. + // No idea why `DeletePrinterDriverExW()` fails with `ERROR_UNKNOWN_PRINTER_DRIVER` after using the printer once. + // https://github.com/ChromiumWebApps/chromium/blob/c7361d39be8abd1574e6ce8957c8dbddd4c6ccf7/cloud_print/virtual_driver/win/install/setup.cc#L422 + // AnyDesk printer driver and the simplest printer driver also have the same issue. + BOOL res = DeletePrinterDriverExW(NULL, NULL, const_cast(name), DPD_DELETE_ALL_FILES, 0); + if (res == FALSE) + { + DWORD error = GetLastError(); + if (error == ERROR_UNKNOWN_PRINTER_DRIVER) + { + return TRUE; + } + else + { + WcaLog(LOGMSG_STANDARD, "Failed to delete printer driver. Error (%d)\n", error); + } + } + return res; + } + + BOOL deletePrinterDriverPackage(const std::wstring &inf) + { + // https://learn.microsoft.com/en-us/windows/win32/printdocs/deleteprinterdriverpackage + // This function is a blocking or synchronous function and might not return immediately. + // How quickly this function returns depends on run-time factors such as network status, print server configuration, and printer driver implementation factors that are difficult to predict when writing an application. + // Calling this function from a thread that manages interaction with the user interface could make the application appear to be unresponsive. + int tries = 3; + HRESULT result = S_FALSE; + while ((result = DeletePrinterDriverPackage(NULL, inf.c_str(), NULL)) != S_OK) + { + if (result == HRESULT_ERR_ELEMENT_NOT_FOUND) + { + return TRUE; + } + + WcaLog(LOGMSG_STANDARD, "Failed to delete printer driver package. HRESULT (%d)\n", result); + tries--; + if (tries <= 0) + { + return FALSE; + } + Sleep(2000); + } + return S_OK; + } + + BOOL uninstallDriver(LPCWSTR name) + { + auto infFile = findInf(name); + if (!deletePrinterDriver(name)) + { + return FALSE; + } + if (infFile != L"" && !deletePrinterDriverPackage(infFile)) + { + return FALSE; + } + return TRUE; + } + + BOOL installDriver(LPCWSTR name, LPCWSTR inf) + { + DWORD size = MAX_PATH * 10; + wchar_t package_path[MAX_PATH * 10] = {0}; + HRESULT result = UploadPrinterDriverPackage( + NULL, inf, NULL, + UPDP_SILENT_UPLOAD | UPDP_UPLOAD_ALWAYS, NULL, package_path, &size); + if (result != S_OK) + { + WcaLog(LOGMSG_STANDARD, "Uploading the printer driver package to the driver cache silently, failed. Will retry with user UI. HRESULT (%d)\n", result); + result = UploadPrinterDriverPackage( + NULL, inf, NULL, UPDP_UPLOAD_ALWAYS, + GetForegroundWindow(), package_path, &size); + if (result != S_OK) + { + WcaLog(LOGMSG_STANDARD, "Uploading the printer driver package to the driver cache failed with user UI. Aborting...\n"); + return FALSE; + } + } + + result = InstallPrinterDriverFromPackage( + NULL, package_path, name, NULL, IPDFP_COPY_ALL_FILES); + if (result != S_OK) + { + WcaLog(LOGMSG_STANDARD, "Installing the printer driver failed. HRESULT (%d)\n", result); + } + return result == S_OK; + } + + BOOL enumLocalPrinter( + DWORD level, + LPBYTE pPrinterInfo, + DWORD cbBuf, + LPDWORD pcbNeeded, + LPDWORD pcReturned) + { + // https://learn.microsoft.com/en-us/windows/win32/printdocs/enumprinters + // This is a blocking or synchronous function and might not return immediately. + // How quickly this function returns depends on run-time factors + // such as network status, print server configuration, and printer driver implementation factors that are difficult to predict when writing an application. + // Calling this function from a thread that manages interaction with the user interface could make the application appear to be unresponsive. + return EnumPrintersW(PRINTER_ENUM_LOCAL, NULL, level, pPrinterInfo, cbBuf, pcbNeeded, pcReturned); + } + + BOOL isPrinterAdded(LPCWSTR name) + { + auto onData = [name](const PRINTER_INFO_1W &info) + { + if (isNameEqual(name, info.pName) == TRUE) + { + return std::shared_ptr(new BOOL(TRUE)); + } + else + { + return std::shared_ptr(nullptr); + } }; + auto onNoData = []() + { return nullptr; }; + auto res = commonEnum(L"EnumPrintersW", enumLocalPrinter, 1, onData, onNoData); + if (res == nullptr) + { + return FALSE; + } + else + { + return *res; + } + } + + std::wstring getPrinterInstalledOnPort(LPCWSTR port) + { + auto onData = [port](const PRINTER_INFO_2W &info) + { + if (isNameEqual(port, info.pPortName) == TRUE) + { + return std::shared_ptr(new std::wstring(info.pPrinterName)); + } + else + { + return std::shared_ptr(nullptr); + } }; + auto onNoData = []() + { return nullptr; }; + auto res = commonEnum(L"EnumPrintersW", enumLocalPrinter, 2, onData, onNoData); + if (res == nullptr) + { + return L""; + } + else + { + return *res; + } + } + + BOOL addPrinter(LPCWSTR name, LPCWSTR driver, LPCWSTR port) + { + PRINTER_INFO_2W printerInfo = {0}; + printerInfo.pPrinterName = const_cast(name); + printerInfo.pPortName = const_cast(port); + printerInfo.pDriverName = const_cast(driver); + printerInfo.pPrintProcessor = const_cast(L"WinPrint"); + printerInfo.pDatatype = const_cast(L"RAW"); + printerInfo.Attributes = PRINTER_ATTRIBUTE_LOCAL; + HANDLE hPrinter = AddPrinterW(NULL, 2, (LPBYTE)&printerInfo); + return hPrinter == NULL ? FALSE : TRUE; + } + + VOID deletePrinter(LPCWSTR name) + { + PRINTER_DEFAULTSW dft = {0}; + dft.DesiredAccess = PRINTER_ALL_ACCESS; + HANDLE hPrinter = NULL; + if (OpenPrinterW(const_cast(name), &hPrinter, &dft) == FALSE) + { + DWORD error = GetLastError(); + if (error == ERROR_INVALID_PRINTER_NAME) + { + return; + } + WcaLog(LOGMSG_STANDARD, "Failed to open printer. error (%d)\n", error); + return; + } + + if (SetPrinterW(hPrinter, 0, NULL, PRINTER_CONTROL_PURGE) == FALSE) + { + ClosePrinter(hPrinter); + WcaLog(LOGMSG_STANDARD, "Failed to purge printer queue. error (%d)\n", GetLastError()); + return; + } + + if (DeletePrinter(hPrinter) == FALSE) + { + ClosePrinter(hPrinter); + WcaLog(LOGMSG_STANDARD, "Failed to delete printer. error (%d)\n", GetLastError()); + return; + } + + ClosePrinter(hPrinter); + } + + bool FileExists(const std::wstring &filePath) + { + DWORD fileAttributes = GetFileAttributes(filePath.c_str()); + return (fileAttributes != INVALID_FILE_ATTRIBUTES && !(fileAttributes & FILE_ATTRIBUTE_DIRECTORY)); + } + + // Steps: + // 1. Add the local port. + // 2. Check if the driver is installed. + // Uninstall the existing driver if it is installed. + // We should not check the driver version because the driver is deployed with the application. + // It's better to uninstall the existing driver and install the driver from the application. + // 3. Add the printer. + VOID installUpdatePrinter(const std::wstring &installFolder) + { + const std::wstring infFile = installFolder + L"\\" + RemotePrinter::RD_DRIVER_INF_PATH; + if (!FileExists(infFile)) + { + WcaLog(LOGMSG_STANDARD, "Printer driver INF file not found, aborting...\n"); + return; + } + + if (!checkAddLocalPort(RD_PRINTER_PORT)) + { + WcaLog(LOGMSG_STANDARD, "Failed to check add local port, error (%d)\n", GetLastError()); + return; + } + else + { + WcaLog(LOGMSG_STANDARD, "Local port added successfully\n"); + } + + if (getInstalledDriverVersion(RD_PRINTER_DRIVER_NAME) > 0) + { + deletePrinter(RD_PRINTER_NAME); + if (FALSE == uninstallDriver(RD_PRINTER_DRIVER_NAME)) + { + WcaLog(LOGMSG_STANDARD, "Failed to uninstall previous printer driver, error (%d)\n", GetLastError()); + } + } + + if (FALSE == installDriver(RD_PRINTER_DRIVER_NAME, infFile.c_str())) + { + WcaLog(LOGMSG_STANDARD, "Driver installation failed, still try to add the printer\n"); + } + else + { + WcaLog(LOGMSG_STANDARD, "Driver installed successfully\n"); + } + + if (FALSE == addPrinter(RD_PRINTER_NAME, RD_PRINTER_DRIVER_NAME, RD_PRINTER_PORT)) + { + WcaLog(LOGMSG_STANDARD, "Failed to add printer, error (%d)\n", GetLastError()); + } + else + { + WcaLog(LOGMSG_STANDARD, "Printer installed successfully\n"); + } + } + + VOID uninstallPrinter() + { + deletePrinter(RD_PRINTER_NAME); + WcaLog(LOGMSG_STANDARD, "Deleted the printer\n"); + uninstallDriver(RD_PRINTER_DRIVER_NAME); + WcaLog(LOGMSG_STANDARD, "Uninstalled the printer driver\n"); + checkDeleteLocalPort(RD_PRINTER_PORT); + WcaLog(LOGMSG_STANDARD, "Deleted the local port\n"); + } +} diff --git a/res/msi/Package/Components/Folders.wxs b/res/msi/Package/Components/Folders.wxs index de9edb7f3..6911600e9 100644 --- a/res/msi/Package/Components/Folders.wxs +++ b/res/msi/Package/Components/Folders.wxs @@ -16,8 +16,15 @@ - - + + + + + + + + + diff --git a/res/msi/Package/Components/RustDesk.wxs b/res/msi/Package/Components/RustDesk.wxs index c17d60edb..952172bdc 100644 --- a/res/msi/Package/Components/RustDesk.wxs +++ b/res/msi/Package/Components/RustDesk.wxs @@ -12,7 +12,7 @@ - + @@ -30,6 +30,7 @@ + @@ -53,8 +54,21 @@ - - + + + + + + + + + + @@ -63,19 +77,21 @@ - - - + + + - + + + - + - + diff --git a/res/msi/Package/Fragments/AddRemoveProperties.wxs b/res/msi/Package/Fragments/AddRemoveProperties.wxs index a7139fab2..ac1d85a86 100644 --- a/res/msi/Package/Fragments/AddRemoveProperties.wxs +++ b/res/msi/Package/Fragments/AddRemoveProperties.wxs @@ -6,9 +6,11 @@ - + + + @@ -23,6 +24,9 @@ + + + @@ -46,6 +50,16 @@ + + + + + + + + + + + + + diff --git a/res/msi/Package/Language/Package.en-us.wxl b/res/msi/Package/Language/Package.en-us.wxl index 1bd3986dd..c65a5126d 100644 --- a/res/msi/Package/Language/Package.en-us.wxl +++ b/res/msi/Package/Language/Package.en-us.wxl @@ -51,5 +51,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 bdd8471cf..e11756a65 100644 --- a/res/msi/Package/Package.wxs +++ b/res/msi/Package/Package.wxs @@ -51,6 +51,8 @@ + + diff --git a/res/msi/Package/UI/MyInstallDirDlg.wxs b/res/msi/Package/UI/MyInstallDirDlg.wxs index 6e27e2b28..e4bad9197 100644 --- a/res/msi/Package/UI/MyInstallDirDlg.wxs +++ b/res/msi/Package/UI/MyInstallDirDlg.wxs @@ -25,6 +25,7 @@ + diff --git a/res/msi/Package/UI/MyInstallDlg.wxs b/res/msi/Package/UI/MyInstallDlg.wxs index bf59d569c..06c37097c 100644 --- a/res/msi/Package/UI/MyInstallDlg.wxs +++ b/res/msi/Package/UI/MyInstallDlg.wxs @@ -23,12 +23,13 @@ Patch dialog sequence: --> + - + @@ -64,9 +65,16 @@ Patch dialog sequence: - - - + + + + + + + + + + diff --git a/res/msi/preprocess.py b/res/msi/preprocess.py index 9a43e9da6..c590549f4 100644 --- a/res/msi/preprocess.py +++ b/res/msi/preprocess.py @@ -8,7 +8,9 @@ import argparse import datetime import subprocess import re +import platform from pathlib import Path +from itertools import chain import shutil g_indent_unit = "\t" @@ -47,7 +49,7 @@ def make_parser(): "--dist-dir", type=str, default="../../rustdesk", - help="The dist direcotry to install.", + help="The dist directory to install.", ) parser.add_argument( "--arp", @@ -187,6 +189,17 @@ def replace_app_name_in_langs(app_name): with open(file_path, "w", encoding="utf-8") as f: f.writelines(lines) +def replace_app_name_in_custom_actions(app_name): + custion_actions_dir = Path(sys.argv[0]).parent.joinpath("CustomActions") + for file_path in chain(custion_actions_dir.glob("*.cpp"), custion_actions_dir.glob("*.h")): + with open(file_path, "r", encoding="utf-8") as f: + lines = f.readlines() + for i, line in enumerate(lines): + line = re.sub(r"\bRustDesk\b", app_name, line) + line = line.replace(f"{app_name} v4 Printer Driver", "RustDesk v4 Printer Driver") + lines[i] = line + with open(file_path, "w", encoding="utf-8") as f: + f.writelines(lines) def gen_upgrade_info(): def func(lines, index_start): @@ -323,7 +336,9 @@ def gen_custom_ARPSYSTEMCOMPONENT_True(args, dist_dir): f'{indent}\n' ) - estimated_size = get_folder_size(dist_dir) + # 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' ) @@ -542,3 +557,4 @@ if __name__ == "__main__": sys.exit(-1) replace_app_name_in_langs(args.app_name) + replace_app_name_in_custom_actions(args.app_name) diff --git a/res/rpm-flutter-suse.spec b/res/rpm-flutter-suse.spec index 768b04c28..bb2b56af6 100644 --- a/res/rpm-flutter-suse.spec +++ b/res/rpm-flutter-suse.spec @@ -1,12 +1,16 @@ Name: rustdesk -Version: 1.3.2 +Version: 1.4.6 Release: 0 Summary: RPM package License: GPL-3.0 -Requires: gtk3 libxcb1 xdotool libXfixes3 alsa-utils libXtst6 libvdpau1 libva2 pam gstreamer-plugins-base gstreamer-plugin-pipewire -Recommends: libayatana-appindicator3-1 +URL: https://rustdesk.com +Vendor: rustdesk +Requires: gtk3 libxcb1 libXfixes3 alsa-utils libXtst6 libva2 pam 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) +# https://docs.fedoraproject.org/en-US/packaging-guidelines/Scriptlets/ + %description The best open-source remote desktop client software, written in Rust. @@ -20,7 +24,7 @@ The best open-source remote desktop client software, written in Rust. %install -mkdir -p "%{buildroot}/usr/lib/rustdesk" && cp -r ${HBB}/flutter/build/linux/x64/release/bundle/* -t "%{buildroot}/usr/lib/rustdesk" +mkdir -p "%{buildroot}/usr/share/rustdesk" && cp -r ${HBB}/flutter/build/linux/x64/release/bundle/* -t "%{buildroot}/usr/share/rustdesk" mkdir -p "%{buildroot}/usr/bin" install -Dm 644 $HBB/res/rustdesk.service -t "%{buildroot}/usr/share/rustdesk/files" install -Dm 644 $HBB/res/rustdesk.desktop -t "%{buildroot}/usr/share/rustdesk/files" @@ -29,7 +33,7 @@ install -Dm 644 $HBB/res/128x128@2x.png "%{buildroot}/usr/share/icons/hicolor/25 install -Dm 644 $HBB/res/scalable.svg "%{buildroot}/usr/share/icons/hicolor/scalable/apps/rustdesk.svg" %files -/usr/lib/rustdesk/* +/usr/share/rustdesk/* /usr/share/rustdesk/files/rustdesk.service /usr/share/icons/hicolor/256x256/apps/rustdesk.png /usr/share/icons/hicolor/scalable/apps/rustdesk.svg @@ -39,7 +43,6 @@ install -Dm 644 $HBB/res/scalable.svg "%{buildroot}/usr/share/icons/hicolor/scal %changelog # let's skip this for now -# https://www.cnblogs.com/xingmuxin/p/8990255.html %pre # can do something for centos7 case "$1" in @@ -56,7 +59,7 @@ esac cp /usr/share/rustdesk/files/rustdesk.service /etc/systemd/system/rustdesk.service cp /usr/share/rustdesk/files/rustdesk.desktop /usr/share/applications/ cp /usr/share/rustdesk/files/rustdesk-link.desktop /usr/share/applications/ -ln -s /usr/lib/rustdesk/rustdesk /usr/bin/rustdesk +ln -sf /usr/share/rustdesk/rustdesk /usr/bin/rustdesk systemctl daemon-reload systemctl enable rustdesk systemctl start rustdesk @@ -79,12 +82,17 @@ esac case "$1" in 0) # for uninstall + rm /usr/bin/rustdesk || true + rmdir /usr/lib/rustdesk || true + rmdir /usr/local/rustdesk || true + rmdir /usr/share/rustdesk || true rm /usr/share/applications/rustdesk.desktop || true rm /usr/share/applications/rustdesk-link.desktop || true - rm /usr/bin/rustdesk || true update-desktop-database ;; 1) # for upgrade + rmdir /usr/lib/rustdesk || true + rmdir /usr/local/rustdesk || true ;; esac diff --git a/res/rpm-flutter.spec b/res/rpm-flutter.spec index b62c18b3b..1a077ee7e 100644 --- a/res/rpm-flutter.spec +++ b/res/rpm-flutter.spec @@ -1,12 +1,16 @@ Name: rustdesk -Version: 1.3.2 +Version: 1.4.6 Release: 0 Summary: RPM package License: GPL-3.0 -Requires: gtk3 libxcb libxdo libXfixes alsa-lib libvdpau libva pam gstreamer1-plugins-base -Recommends: libayatana-appindicator-gtk3 +URL: https://rustdesk.com +Vendor: rustdesk +Requires: gtk3 libxcb libXfixes alsa-lib libva pam 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) +# https://docs.fedoraproject.org/en-US/packaging-guidelines/Scriptlets/ + %description The best open-source remote desktop client software, written in Rust. @@ -20,7 +24,7 @@ The best open-source remote desktop client software, written in Rust. %install -mkdir -p "%{buildroot}/usr/lib/rustdesk" && cp -r ${HBB}/flutter/build/linux/x64/release/bundle/* -t "%{buildroot}/usr/lib/rustdesk" +mkdir -p "%{buildroot}/usr/share/rustdesk" && cp -r ${HBB}/flutter/build/linux/x64/release/bundle/* -t "%{buildroot}/usr/share/rustdesk" mkdir -p "%{buildroot}/usr/bin" install -Dm 644 $HBB/res/rustdesk.service -t "%{buildroot}/usr/share/rustdesk/files" install -Dm 644 $HBB/res/rustdesk.desktop -t "%{buildroot}/usr/share/rustdesk/files" @@ -29,7 +33,7 @@ install -Dm 644 $HBB/res/128x128@2x.png "%{buildroot}/usr/share/icons/hicolor/25 install -Dm 644 $HBB/res/scalable.svg "%{buildroot}/usr/share/icons/hicolor/scalable/apps/rustdesk.svg" %files -/usr/lib/rustdesk/* +/usr/share/rustdesk/* /usr/share/rustdesk/files/rustdesk.service /usr/share/icons/hicolor/256x256/apps/rustdesk.png /usr/share/icons/hicolor/scalable/apps/rustdesk.svg @@ -39,7 +43,6 @@ install -Dm 644 $HBB/res/scalable.svg "%{buildroot}/usr/share/icons/hicolor/scal %changelog # let's skip this for now -# https://www.cnblogs.com/xingmuxin/p/8990255.html %pre # can do something for centos7 case "$1" in @@ -56,7 +59,7 @@ esac cp /usr/share/rustdesk/files/rustdesk.service /etc/systemd/system/rustdesk.service cp /usr/share/rustdesk/files/rustdesk.desktop /usr/share/applications/ cp /usr/share/rustdesk/files/rustdesk-link.desktop /usr/share/applications/ -ln -s /usr/lib/rustdesk/rustdesk /usr/bin/rustdesk +ln -sf /usr/share/rustdesk/rustdesk /usr/bin/rustdesk systemctl daemon-reload systemctl enable rustdesk systemctl start rustdesk @@ -79,12 +82,17 @@ esac case "$1" in 0) # for uninstall + rm /usr/bin/rustdesk || true + rmdir /usr/lib/rustdesk || true + rmdir /usr/local/rustdesk || true + rmdir /usr/share/rustdesk || true rm /usr/share/applications/rustdesk.desktop || true rm /usr/share/applications/rustdesk-link.desktop || true - rm /usr/bin/rustdesk || true update-desktop-database ;; 1) # for upgrade + rmdir /usr/lib/rustdesk || true + rmdir /usr/local/rustdesk || true ;; esac diff --git a/res/rpm-suse.spec b/res/rpm-suse.spec index 1d6a94b13..14364eb77 100644 --- a/res/rpm-suse.spec +++ b/res/rpm-suse.spec @@ -3,8 +3,10 @@ Version: 1.1.9 Release: 0 Summary: RPM package License: GPL-3.0 -Requires: gtk3 libxcb1 xdotool libXfixes3 alsa-utils libXtst6 libvdpau1 libva2 pam gstreamer-plugins-base gstreamer-plugin-pipewire -Recommends: libayatana-appindicator3-1 +Requires: gtk3 libxcb1 libXfixes3 alsa-utils libXtst6 libva2 pam gstreamer-plugins-base gstreamer-plugin-pipewire +Recommends: libayatana-appindicator3-1 xdotool + +# https://docs.fedoraproject.org/en-US/packaging-guidelines/Scriptlets/ %description The best open-source remote desktop client software, written in Rust. @@ -19,12 +21,12 @@ The best open-source remote desktop client software, written in Rust. %install mkdir -p %{buildroot}/usr/bin/ -mkdir -p %{buildroot}/usr/lib/rustdesk/ +mkdir -p %{buildroot}/usr/share/rustdesk/ mkdir -p %{buildroot}/usr/share/rustdesk/files/ mkdir -p %{buildroot}/usr/share/icons/hicolor/256x256/apps/ mkdir -p %{buildroot}/usr/share/icons/hicolor/scalable/apps/ install -m 755 $HBB/target/release/rustdesk %{buildroot}/usr/bin/rustdesk -install $HBB/libsciter-gtk.so %{buildroot}/usr/lib/rustdesk/libsciter-gtk.so +install $HBB/libsciter-gtk.so %{buildroot}/usr/share/rustdesk/libsciter-gtk.so install $HBB/res/rustdesk.service %{buildroot}/usr/share/rustdesk/files/ install $HBB/res/128x128@2x.png %{buildroot}/usr/share/icons/hicolor/256x256/apps/rustdesk.png install $HBB/res/scalable.svg %{buildroot}/usr/share/icons/hicolor/scalable/apps/rustdesk.svg @@ -33,7 +35,7 @@ install $HBB/res/rustdesk-link.desktop %{buildroot}/usr/share/rustdesk/files/ %files /usr/bin/rustdesk -/usr/lib/rustdesk/libsciter-gtk.so +/usr/share/rustdesk/libsciter-gtk.so /usr/share/rustdesk/files/rustdesk.service /usr/share/icons/hicolor/256x256/apps/rustdesk.png /usr/share/icons/hicolor/scalable/apps/rustdesk.svg @@ -43,7 +45,6 @@ install $HBB/res/rustdesk-link.desktop %{buildroot}/usr/share/rustdesk/files/ %changelog # let's skip this for now -# https://www.cnblogs.com/xingmuxin/p/8990255.html %pre # can do something for centos7 case "$1" in diff --git a/res/rpm.spec b/res/rpm.spec index 033e95937..6a7377b8b 100644 --- a/res/rpm.spec +++ b/res/rpm.spec @@ -1,10 +1,14 @@ Name: rustdesk -Version: 1.3.2 +Version: 1.4.6 Release: 0 Summary: RPM package License: GPL-3.0 -Requires: gtk3 libxcb libxdo libXfixes alsa-lib libvdpau1 libva2 pam gstreamer1-plugins-base -Recommends: libayatana-appindicator-gtk3 +URL: https://rustdesk.com +Vendor: rustdesk +Requires: gtk3 libxcb libXfixes alsa-lib libva2 pam gstreamer1-plugins-base +Recommends: libayatana-appindicator-gtk3 libxdo + +# https://docs.fedoraproject.org/en-US/packaging-guidelines/Scriptlets/ %description The best open-source remote desktop client software, written in Rust. @@ -19,12 +23,12 @@ The best open-source remote desktop client software, written in Rust. %install mkdir -p %{buildroot}/usr/bin/ -mkdir -p %{buildroot}/usr/lib/rustdesk/ +mkdir -p %{buildroot}/usr/share/rustdesk/ mkdir -p %{buildroot}/usr/share/rustdesk/files/ mkdir -p %{buildroot}/usr/share/icons/hicolor/256x256/apps/ mkdir -p %{buildroot}/usr/share/icons/hicolor/scalable/apps/ install -m 755 $HBB/target/release/rustdesk %{buildroot}/usr/bin/rustdesk -install $HBB/libsciter-gtk.so %{buildroot}/usr/lib/rustdesk/libsciter-gtk.so +install $HBB/libsciter-gtk.so %{buildroot}/usr/share/rustdesk/libsciter-gtk.so install $HBB/res/rustdesk.service %{buildroot}/usr/share/rustdesk/files/ install $HBB/res/128x128@2x.png %{buildroot}/usr/share/icons/hicolor/256x256/apps/rustdesk.png install $HBB/res/scalable.svg %{buildroot}/usr/share/icons/hicolor/scalable/apps/rustdesk.svg @@ -33,7 +37,7 @@ install $HBB/res/rustdesk-link.desktop %{buildroot}/usr/share/rustdesk/files/ %files /usr/bin/rustdesk -/usr/lib/rustdesk/libsciter-gtk.so +/usr/share/rustdesk/libsciter-gtk.so /usr/share/rustdesk/files/rustdesk.service /usr/share/icons/hicolor/256x256/apps/rustdesk.png /usr/share/icons/hicolor/scalable/apps/rustdesk.svg @@ -44,7 +48,6 @@ install $HBB/res/rustdesk-link.desktop %{buildroot}/usr/share/rustdesk/files/ %changelog # let's skip this for now -# https://www.cnblogs.com/xingmuxin/p/8990255.html %pre # can do something for centos7 case "$1" in diff --git a/res/rustdesk-link.desktop b/res/rustdesk-link.desktop index c64781aeb..c7a9bd5cb 100644 --- a/res/rustdesk-link.desktop +++ b/res/rustdesk-link.desktop @@ -1,5 +1,5 @@ [Desktop Entry] -Name=RustDeskURL Scheme Handler +Name=RustDesk NoDisplay=true MimeType=x-scheme-handler/rustdesk; TryExec=rustdesk @@ -8,3 +8,4 @@ Icon=rustdesk Terminal=false Type=Application StartupNotify=false +StartupWMClass=rustdesk diff --git a/res/rustdesk.desktop b/res/rustdesk.desktop index cc72ff449..4e7d14fe5 100644 --- a/res/rustdesk.desktop +++ b/res/rustdesk.desktop @@ -8,8 +8,9 @@ Terminal=false Type=Application StartupNotify=true Categories=Network;RemoteAccess;GTK; -Keywords=internet; +Keywords=internet;linux;dart;rust;remote-control;p2p;teamviewer;rust-lang;rdp;remote-desktop;vnc; Actions=new-window; +StartupWMClass=rustdesk X-Desktop-File-Install-Version=0.23 diff --git a/res/rustdesk.service b/res/rustdesk.service index 6ec806845..1b3feb194 100644 --- a/res/rustdesk.service +++ b/res/rustdesk.service @@ -16,6 +16,7 @@ KillMode=mixed TimeoutStopSec=30 User=root LimitNOFILE=100000 +Environment="PULSE_LATENCY_MSEC=60" "PIPEWIRE_LATENCY=1024/48000" [Install] WantedBy=multi-user.target diff --git a/res/setup.nsi b/res/setup.nsi deleted file mode 100644 index 21cee15c8..000000000 --- a/res/setup.nsi +++ /dev/null @@ -1,178 +0,0 @@ -Unicode true - -#################################################################### -# Includes - -!include nsDialogs.nsh -!include MUI2.nsh -!include x64.nsh -!include LogicLib.nsh - -#################################################################### -# File Info - -!define PRODUCT_NAME "RustDesk" -!define PRODUCT_DESCRIPTION "Installer for ${PRODUCT_NAME}" -!define COPYRIGHT "Copyright © 2021" -!define VERSION "1.1.6" - -VIProductVersion "${VERSION}.0" -VIAddVersionKey "ProductName" "${PRODUCT_NAME}" -VIAddVersionKey "ProductVersion" "${VERSION}" -VIAddVersionKey "FileDescription" "${PRODUCT_DESCRIPTION}" -VIAddVersionKey "LegalCopyright" "${COPYRIGHT}" -VIAddVersionKey "FileVersion" "${VERSION}.0" - -#################################################################### -# Installer Attributes - -Name "${PRODUCT_NAME}" -Outfile "rustdesk-${VERSION}-setup.exe" -Caption "Setup - ${PRODUCT_NAME}" -BrandingText "${PRODUCT_NAME}" - -ShowInstDetails show -RequestExecutionLevel admin -SetOverwrite on - -InstallDir "$PROGRAMFILES64\${PRODUCT_NAME}" - -#################################################################### -# Pages - -!define MUI_ICON "icon.ico" -!define MUI_ABORTWARNING -!define MUI_LANGDLL_ALLLANGUAGES -!define MUI_FINISHPAGE_SHOWREADME "" -!define MUI_FINISHPAGE_SHOWREADME_NOTCHECKED -!define MUI_FINISHPAGE_SHOWREADME_TEXT "Create desktop shortcut" -!define MUI_FINISHPAGE_SHOWREADME_FUNCTION CreateDesktopShortcut -!define MUI_FINISHPAGE_RUN "$INSTDIR\${PRODUCT_NAME}.exe" - -!insertmacro MUI_PAGE_DIRECTORY -!insertmacro MUI_PAGE_INSTFILES -!insertmacro MUI_PAGE_FINISH - -#################################################################### -# Language - -!insertmacro MUI_LANGUAGE "English" ; The first language is the default language -!insertmacro MUI_LANGUAGE "French" -!insertmacro MUI_LANGUAGE "German" -!insertmacro MUI_LANGUAGE "Spanish" -!insertmacro MUI_LANGUAGE "SpanishInternational" -!insertmacro MUI_LANGUAGE "SimpChinese" -!insertmacro MUI_LANGUAGE "TradChinese" -!insertmacro MUI_LANGUAGE "Japanese" -!insertmacro MUI_LANGUAGE "Korean" -!insertmacro MUI_LANGUAGE "Italian" -!insertmacro MUI_LANGUAGE "Dutch" -!insertmacro MUI_LANGUAGE "Danish" -!insertmacro MUI_LANGUAGE "Swedish" -!insertmacro MUI_LANGUAGE "Norwegian" -!insertmacro MUI_LANGUAGE "NorwegianNynorsk" -!insertmacro MUI_LANGUAGE "Finnish" -!insertmacro MUI_LANGUAGE "Greek" -!insertmacro MUI_LANGUAGE "Russian" -!insertmacro MUI_LANGUAGE "Portuguese" -!insertmacro MUI_LANGUAGE "PortugueseBR" -!insertmacro MUI_LANGUAGE "Polish" -!insertmacro MUI_LANGUAGE "Ukrainian" -!insertmacro MUI_LANGUAGE "Czech" -!insertmacro MUI_LANGUAGE "Slovak" -!insertmacro MUI_LANGUAGE "Croatian" -!insertmacro MUI_LANGUAGE "Bulgarian" -!insertmacro MUI_LANGUAGE "Hungarian" -!insertmacro MUI_LANGUAGE "Thai" -!insertmacro MUI_LANGUAGE "Romanian" -!insertmacro MUI_LANGUAGE "Latvian" -!insertmacro MUI_LANGUAGE "Macedonian" -!insertmacro MUI_LANGUAGE "Estonian" -!insertmacro MUI_LANGUAGE "Turkish" -!insertmacro MUI_LANGUAGE "Lithuanian" -!insertmacro MUI_LANGUAGE "Slovenian" -!insertmacro MUI_LANGUAGE "Serbian" -!insertmacro MUI_LANGUAGE "SerbianLatin" -!insertmacro MUI_LANGUAGE "Arabic" -!insertmacro MUI_LANGUAGE "Farsi" -!insertmacro MUI_LANGUAGE "Hebrew" -!insertmacro MUI_LANGUAGE "Indonesian" -!insertmacro MUI_LANGUAGE "Mongolian" -!insertmacro MUI_LANGUAGE "Luxembourgish" -!insertmacro MUI_LANGUAGE "Albanian" -!insertmacro MUI_LANGUAGE "Breton" -!insertmacro MUI_LANGUAGE "Belarusian" -!insertmacro MUI_LANGUAGE "Icelandic" -!insertmacro MUI_LANGUAGE "Malay" -!insertmacro MUI_LANGUAGE "Bosnian" -!insertmacro MUI_LANGUAGE "Kurdish" -!insertmacro MUI_LANGUAGE "Irish" -!insertmacro MUI_LANGUAGE "Uzbek" -!insertmacro MUI_LANGUAGE "Galician" -!insertmacro MUI_LANGUAGE "Afrikaans" -!insertmacro MUI_LANGUAGE "Catalan" -!insertmacro MUI_LANGUAGE "Esperanto" -!insertmacro MUI_LANGUAGE "Asturian" -!insertmacro MUI_LANGUAGE "Basque" -!insertmacro MUI_LANGUAGE "Pashto" -!insertmacro MUI_LANGUAGE "ScotsGaelic" -!insertmacro MUI_LANGUAGE "Georgian" -!insertmacro MUI_LANGUAGE "Vietnamese" -!insertmacro MUI_LANGUAGE "Welsh" -!insertmacro MUI_LANGUAGE "Armenian" -!insertmacro MUI_LANGUAGE "Corsican" -!insertmacro MUI_LANGUAGE "Tatar" -!insertmacro MUI_LANGUAGE "Hindi" - - -#################################################################### -# Sections - -Section "Install" - SetOutPath $INSTDIR - - # Regkeys - WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" "DisplayIcon" "$INSTDIR\${PRODUCT_NAME}.exe" - WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" "DisplayName" "${PRODUCT_NAME} (x64)" - WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" "DisplayVersion" "${VERSION}" - WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" "UninstallString" '"$INSTDIR\${PRODUCT_NAME}.exe" --uninstall' - WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" "InstallLocation" "$INSTDIR" - WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" "Publisher" "Purslane Ltd." - WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" "HelpLink" "https://www.rustdesk.com/" - WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" "URLInfoAbout" "https://www.rustdesk.com/" - WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" "URLUpdateInfo" "https://www.rustdesk.com/" - - nsExec::Exec "taskkill /F /IM ${PRODUCT_NAME}.exe" - Sleep 500 ; Give time for process to be completely killed - File "${PRODUCT_NAME}.exe" - - SetShellVarContext all - CreateShortCut "$INSTDIR\Uninstall ${PRODUCT_NAME}.lnk" "$INSTDIR\${PRODUCT_NAME}.exe" "--uninstall" "msiexec.exe" - CreateDirectory "$SMPROGRAMS\${PRODUCT_NAME}" - CreateShortCut "$SMPROGRAMS\${PRODUCT_NAME}\${PRODUCT_NAME}.lnk" "$INSTDIR\${PRODUCT_NAME}.exe" - CreateShortCut "$SMPROGRAMS\${PRODUCT_NAME}\Uninstall ${PRODUCT_NAME}.lnk" "$INSTDIR\${PRODUCT_NAME}.exe" "--uninstall" "msiexec.exe" - CreateShortCut "$SMSTARTUP\${PRODUCT_NAME} Tray.lnk" "$INSTDIR\${PRODUCT_NAME}.exe" "--tray" - - nsExec::Exec 'sc create ${PRODUCT_NAME} start=auto DisplayName="${PRODUCT_NAME} Service" binPath= "\"$INSTDIR\${PRODUCT_NAME}.exe\" --service"' - nsExec::Exec 'netsh advfirewall firewall add rule name="${PRODUCT_NAME} Service" dir=in action=allow program="$INSTDIR\${PRODUCT_NAME}.exe" enable=yes' - nsExec::Exec 'sc start ${PRODUCT_NAME}' -SectionEnd - -#################################################################### -# Functions - -Function .onInit - # RustDesk is 64-bit only - ${IfNot} ${RunningX64} - MessageBox MB_ICONSTOP "${PRODUCT_NAME} is 64-bit only!" - Quit - ${EndIf} - ${DisableX64FSRedirection} - SetRegView 64 - - !insertmacro MUI_LANGDLL_DISPLAY -FunctionEnd - -Function CreateDesktopShortcut - CreateShortCut "$DESKTOP\${PRODUCT_NAME}.lnk" "$INSTDIR\${PRODUCT_NAME}.exe" -FunctionEnd diff --git a/res/strategies.py b/res/strategies.py new file mode 100755 index 000000000..178d8d9e7 --- /dev/null +++ b/res/strategies.py @@ -0,0 +1,301 @@ +#!/usr/bin/env python3 + +import requests +import argparse +import json + + +def check_response(response): + """ + Check API response and handle errors. + + Two error cases: + 1. Status code is not 200 -> exit with error + 2. Response contains {"error": "xxx"} -> exit with error + """ + if response.status_code != 200: + print(f"Error: HTTP {response.status_code}: {response.text}") + exit(1) + + # Check for {"error": "xxx"} in response + if response.text and response.text.strip(): + try: + json_data = response.json() + if isinstance(json_data, dict) and "error" in json_data: + print(f"Error: {json_data['error']}") + exit(1) + return json_data + except ValueError: + return response.text + + return None + + +def headers_with(token): + return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + + +# ---------- Strategies APIs ---------- + +def list_strategies(url, token): + """List all strategies""" + headers = headers_with(token) + r = requests.get(f"{url}/api/strategies", headers=headers) + return check_response(r) + + +def get_strategy_by_guid(url, token, guid): + """Get strategy by GUID""" + headers = headers_with(token) + r = requests.get(f"{url}/api/strategies/{guid}", headers=headers) + return check_response(r) + + +def get_strategy_by_name(url, token, name): + """Get strategy by name""" + strategies = list_strategies(url, token) + if not strategies: + return None + for s in strategies: + if str(s.get("name")) == name: + return s + return None + + +def enable_strategy(url, token, name): + """Enable a strategy""" + headers = headers_with(token) + strategy = get_strategy_by_name(url, token, name) + if not strategy: + print(f"Error: Strategy '{name}' not found") + exit(1) + guid = strategy.get("guid") + r = requests.put(f"{url}/api/strategies/{guid}/status", headers=headers, json=True) + check_response(r) + return "Success" + + +def disable_strategy(url, token, name): + """Disable a strategy""" + headers = headers_with(token) + strategy = get_strategy_by_name(url, token, name) + if not strategy: + print(f"Error: Strategy '{name}' not found") + exit(1) + guid = strategy.get("guid") + r = requests.put(f"{url}/api/strategies/{guid}/status", headers=headers, json=False) + check_response(r) + return "Success" + + +def get_device_guid_by_id(url, token, device_id): + """Get device GUID by device ID (exact match)""" + headers = headers_with(token) + params = {"id": device_id, "pageSize": 50} + r = requests.get(f"{url}/api/devices", headers=headers, params=params) + res = check_response(r) + if not res: + return None + + devices_data = res.get("data", []) if isinstance(res, dict) else res + for d in devices_data: + if d.get("id") == device_id: + return d.get("guid") + return None + + +def get_user_guid_by_name(url, token, name): + """Get user GUID by exact name match""" + headers = headers_with(token) + params = {"name": name, "pageSize": 50} + r = requests.get(f"{url}/api/users", headers=headers, params=params) + res = check_response(r) + if not res: + return None + + users_data = res.get("data", []) if isinstance(res, dict) else res + for u in users_data: + if u.get("name") == name: + return u.get("guid") + return None + + +def get_device_group_guid_by_name(url, token, name): + """Get device group GUID by exact name match""" + headers = headers_with(token) + params = {"pageSize": 50, "name": name} + r = requests.get(f"{url}/api/device-groups", headers=headers, params=params) + res = check_response(r) + if not res: + return None + + groups_data = res.get("data", []) if isinstance(res, dict) else res + for g in groups_data: + if g.get("name") == name: + return g.get("guid") + return None + + +def assign_strategy(url, token, strategy_name, peers=None, users=None, device_groups=None): + """ + Assign strategy to peers, users, or device groups + + Args: + strategy_name: Name of the strategy (or None to unassign) + peers: List of device IDs or GUIDs + users: List of user names or GUIDs + device_groups: List of device group names or GUIDs + """ + headers = headers_with(token) + + # Get strategy GUID if strategy_name is provided + strategy_guid = None + if strategy_name: + strategy = get_strategy_by_name(url, token, strategy_name) + if not strategy: + print(f"Error: Strategy '{strategy_name}' not found") + exit(1) + strategy_guid = strategy.get("guid") + + # Convert device IDs to GUIDs + peer_guids = [] + if peers: + for peer in peers: + # Check if it's already a GUID format + if len(peer) == 36 and peer.count('-') == 4: + peer_guids.append(peer) + else: + # Treat as device ID, look it up + guid = get_device_guid_by_id(url, token, peer) + if not guid: + print(f"Error: Device '{peer}' not found") + exit(1) + peer_guids.append(guid) + + # Convert user names to GUIDs + user_guids = [] + if users: + for user in users: + # Check if it's already a GUID format + if len(user) == 36 and user.count('-') == 4: + user_guids.append(user) + else: + # Treat as username, look it up + guid = get_user_guid_by_name(url, token, user) + if not guid: + print(f"Error: User '{user}' not found") + exit(1) + user_guids.append(guid) + + # Convert device group names to GUIDs + device_group_guids = [] + if device_groups: + for dg in device_groups: + # Check if it's already a GUID format + if len(dg) == 36 and dg.count('-') == 4: + device_group_guids.append(dg) + else: + # Treat as device group name, look it up + guid = get_device_group_guid_by_name(url, token, dg) + if not guid: + print(f"Error: Device group '{dg}' not found") + exit(1) + device_group_guids.append(guid) + + # Build payload + payload = {} + if strategy_guid: + payload["strategy"] = strategy_guid + + payload["peers"] = peer_guids + payload["users"] = user_guids + payload["groups"] = device_group_guids + + r = requests.post(f"{url}/api/strategies/assign", headers=headers, json=payload) + check_response(r) + + +def main(): + parser = argparse.ArgumentParser(description="Strategy manager") + parser.add_argument("command", choices=[ + "list", "view", "enable", "disable", "assign", "unassign" + ]) + parser.add_argument("--url", required=True, help="Server URL") + parser.add_argument("--token", required=True, help="API token") + + parser.add_argument("--name", help="Strategy name (for view/enable/disable/assign commands)") + parser.add_argument("--guid", help="Strategy GUID (for view command, alternative to --name)") + + # For assign/unassign commands + parser.add_argument("--peers", help="Comma separated device IDs or GUIDs (requires Device Permission:r)") + parser.add_argument("--users", help="Comma separated user names or GUIDs (requires User Permission:r)") + parser.add_argument("--device-groups", help="Comma separated device group names or GUIDs (requires Device Group Permission:r)") + + args = parser.parse_args() + while args.url.endswith("/"): args.url = args.url[:-1] + + if args.command == "list": + res = list_strategies(args.url, args.token) + print(json.dumps(res, indent=2)) + + elif args.command == "view": + if args.guid: + res = get_strategy_by_guid(args.url, args.token, args.guid) + print(json.dumps(res, indent=2)) + elif args.name: + strategy = get_strategy_by_name(args.url, args.token, args.name) + if not strategy: + print(f"Error: Strategy '{args.name}' not found") + exit(1) + # Get full details by GUID + guid = strategy.get("guid") + res = get_strategy_by_guid(args.url, args.token, guid) + print(json.dumps(res, indent=2)) + else: + print("Error: --name or --guid is required for view command") + exit(1) + + elif args.command == "enable": + if not args.name: + print("Error: --name is required") + exit(1) + print(enable_strategy(args.url, args.token, args.name)) + + elif args.command == "disable": + if not args.name: + print("Error: --name is required") + exit(1) + print(disable_strategy(args.url, args.token, args.name)) + + elif args.command == "assign": + if not args.name: + print("Error: --name is required") + exit(1) + if not args.peers and not args.users and not args.device_groups: + print("Error: at least one of --peers, --users, or --device-groups is required") + exit(1) + + peers = [x.strip() for x in args.peers.split(",") if x.strip()] if args.peers else None + users = [x.strip() for x in args.users.split(",") if x.strip()] if args.users else None + device_groups = [x.strip() for x in args.device_groups.split(",") if x.strip()] if args.device_groups else None + + assign_strategy(args.url, args.token, args.name, peers=peers, users=users, device_groups=device_groups) + count = (len(peers) if peers else 0) + (len(users) if users else 0) + (len(device_groups) if device_groups else 0) + print(f"Success: Assigned strategy '{args.name}' to {count} target(s)") + + elif args.command == "unassign": + if not args.peers and not args.users and not args.device_groups: + print("Error: at least one of --peers, --users, or --device-groups is required") + exit(1) + + peers = [x.strip() for x in args.peers.split(",") if x.strip()] if args.peers else None + users = [x.strip() for x in args.users.split(",") if x.strip()] if args.users else None + device_groups = [x.strip() for x in args.device_groups.split(",") if x.strip()] if args.device_groups else None + + assign_strategy(args.url, args.token, None, peers=peers, users=users, device_groups=device_groups) + count = (len(peers) if peers else 0) + (len(users) if users else 0) + (len(device_groups) if device_groups else 0) + print(f"Success: Unassigned strategy from {count} target(s)") + + +if __name__ == "__main__": + main() diff --git a/res/user-groups.py b/res/user-groups.py new file mode 100755 index 000000000..5df16c3b6 --- /dev/null +++ b/res/user-groups.py @@ -0,0 +1,302 @@ +#!/usr/bin/env python3 + +import requests +import argparse +import json + + +def check_response(response): + """ + Check API response and handle errors. + + Two error cases: + 1. Status code is not 200 -> exit with error + 2. Response contains {"error": "xxx"} -> exit with error + """ + if response.status_code != 200: + print(f"Error: HTTP {response.status_code}: {response.text}") + exit(1) + + # Check for {"error": "xxx"} in response + if response.text and response.text.strip(): + try: + json_data = response.json() + if isinstance(json_data, dict) and "error" in json_data: + print(f"Error: {json_data['error']}") + exit(1) + return json_data + except ValueError: + return response.text + + return None + + +def headers_with(token): + return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + + +# ---------- User Group APIs ---------- + +def list_groups(url, token, name=None, page_size=50): + headers = headers_with(token) + params = {"pageSize": page_size} + if name: + params["name"] = name + data, current = [], 0 + while True: + current += 1 + params["current"] = current + r = requests.get(f"{url}/api/user-groups", headers=headers, params=params) + if r.status_code != 200: + print(f"Error: HTTP {r.status_code} - {r.text}") + exit(1) + res = r.json() + if "error" in res: + print(f"Error: {res['error']}") + exit(1) + rows = res.get("data", []) + data.extend(rows) + total = res.get("total", 0) + if len(rows) < page_size or current * page_size >= total: + break + return data + + +def get_group_by_name(url, token, name): + groups = list_groups(url, token, name) + for g in groups: + if str(g.get("name")) == name: + return g + return None + + +def create_group(url, token, name, note=None, accessed_from=None, access_to=None): + headers = headers_with(token) + payload = {"name": name} + if note: + payload["note"] = note + if accessed_from: + payload["allowed_incomings"] = accessed_from + if access_to: + payload["allowed_outgoings"] = access_to + r = requests.post(f"{url}/api/user-groups", headers=headers, json=payload) + return check_response(r) + + +def update_group(url, token, name, new_name=None, note=None, accessed_from=None, access_to=None): + headers = headers_with(token) + g = get_group_by_name(url, token, name) + if not g: + print(f"Error: Group '{name}' not found") + exit(1) + guid = g.get("guid") + payload = {} + if new_name is not None: + payload["name"] = new_name + if note is not None: + payload["note"] = note + if accessed_from is not None: + payload["allowed_incomings"] = accessed_from + if access_to is not None: + payload["allowed_outgoings"] = access_to + r = requests.patch(f"{url}/api/user-groups/{guid}", headers=headers, json=payload) + check_response(r) + return "Success" + + +def delete_groups(url, token, names): + headers = headers_with(token) + if isinstance(names, str): + names = [names] + for n in names: + g = get_group_by_name(url, token, n) + if not g: + print(f"Error: Group '{n}' not found") + exit(1) + guid = g.get("guid") + r = requests.delete(f"{url}/api/user-groups/{guid}", headers=headers) + check_response(r) + return "Success" + + +# ---------- User management in group ---------- + +def view_users(url, token, group_name=None, name=None, page_size=50): + """View users in a user group with filters""" + headers = headers_with(token) + + # Separate exact match and fuzzy match params + params = {} + fuzzy_params = { + "name": name, + } + + # Add group_name without wildcard (exact match) + if group_name: + params["group_name"] = group_name + + # Add wildcard for fuzzy search to other params + for k, v in fuzzy_params.items(): + if v is not None: + params[k] = "%" + v + "%" if (v != "-" and "%" not in v) else v + + params["pageSize"] = page_size + + data, current = [], 0 + while True: + current += 1 + params["current"] = current + r = requests.get(f"{url}/api/users", headers=headers, params=params) + if r.status_code != 200: + return check_response(r) + res = r.json() + rows = res.get("data", []) + data.extend(rows) + total = res.get("total", 0) + if len(rows) < page_size or current * page_size >= total: + break + return data + + +def add_users(url, token, group_name, user_names): + """Add users to a user group""" + headers = headers_with(token) + if isinstance(user_names, str): + user_names = [user_names] + + # Get the user group guid + g = get_group_by_name(url, token, group_name) + if not g: + print(f"Error: Group '{group_name}' not found") + exit(1) + guid = g.get("guid") + + # Get user GUIDs + user_guids = [] + errors = [] + + for user_name in user_names: + # Get user by exact name match + params = {"name": user_name, "pageSize": 50} + r = requests.get(f"{url}/api/users", headers=headers, params=params) + if r.status_code != 200: + errors.append(f"{user_name}: HTTP {r.status_code}") + continue + + users_data = r.json() + users_list = users_data.get("data", []) + user = None + for u in users_list: + if u.get("name") == user_name: + user = u + break + + if not user: + errors.append(f"{user_name}: User not found") + continue + + user_guids.append(user["guid"]) + + if not user_guids: + msg = "Error: No valid users found" + if errors: + msg += ". " + "; ".join(errors) + print(msg) + exit(1) + + # Add users to group using POST /api/user-groups/:guid + r = requests.post(f"{url}/api/user-groups/{guid}", headers=headers, json=user_guids) + check_response(r) + + success_msg = f"Success: Added {len(user_guids)} user(s) to group '{group_name}'" + if errors: + return success_msg + " (with errors: " + "; ".join(errors) + ")" + return success_msg + + +def parse_rules(s): + if not s: + return None + try: + v = json.loads(s) + if isinstance(v, list): + # expect list of {"type": number, "name": string} + return v + except Exception: + pass + return None + + +def main(): + parser = argparse.ArgumentParser(description="User Group manager") + parser.add_argument("command", choices=[ + "view", "add", "update", "delete", + "view-users", "add-users" + ], help=( + "Command to execute. " + "[view/add/update/delete/add-users: require User Group Permission] " + "[view-users: require User Permission]" + )) + parser.add_argument("--url", required=True) + parser.add_argument("--token", required=True) + + parser.add_argument("--name", help="User group name (exact match)") + parser.add_argument("--new-name", help="New user group name (for update)") + parser.add_argument("--note", help="Note") + + parser.add_argument("--accessed-from", help="JSON array: '[{\"type\":0|2,\"name\":\"...\"}]' (0=User Group, 2=User)") + parser.add_argument("--access-to", help="JSON array: '[{\"type\":0|1,\"name\":\"...\"}]' (0=User Group, 1=Device Group)") + + parser.add_argument("--users", help="Comma separated usernames for add-users") + + # Filters for view-users command + parser.add_argument("--user-name", help="User name filter (for view-users, supports fuzzy search)") + + args = parser.parse_args() + while args.url.endswith("/"): args.url = args.url[:-1] + + if args.command == "view": + res = list_groups(args.url, args.token, args.name) + print(json.dumps(res, indent=2)) + elif args.command == "add": + if not args.name: + print("Error: --name is required") + exit(1) + print(create_group( + args.url, args.token, args.name, args.note, + parse_rules(args.accessed_from), + parse_rules(args.access_to) + )) + elif args.command == "update": + if not args.name: + print("Error: --name is required") + exit(1) + print(update_group( + args.url, args.token, args.name, args.new_name, args.note, + parse_rules(args.accessed_from), + parse_rules(args.access_to) + )) + elif args.command == "delete": + if not args.name: + print("Error: --name is required (supports comma separated)") + exit(1) + names = [x.strip() for x in args.name.split(",") if x.strip()] + print(delete_groups(args.url, args.token, names)) + elif args.command == "view-users": + res = view_users( + args.url, + args.token, + group_name=args.name, + name=args.user_name + ) + print(json.dumps(res, indent=2)) + elif args.command == "add-users": + if not args.name or not args.users: + print("Error: --name and --users are required") + exit(1) + users = [x.strip() for x in args.users.split(",") if x.strip()] + print(add_users(args.url, args.token, args.name, users)) + + +if __name__ == "__main__": + main() diff --git a/res/users.py b/res/users.py index 54297f06a..02b114715 100755 --- a/res/users.py +++ b/res/users.py @@ -5,6 +5,28 @@ import argparse from datetime import datetime, timedelta +def check_response(response): + """ + Check API response and handle errors properly. + Exit with code 1 if there's an error. + """ + if response.status_code != 200: + print(f"Error: HTTP {response.status_code}: {response.text}") + exit(1) + + if response.text and response.text.strip(): + try: + json_data = response.json() + if isinstance(json_data, dict) and "error" in json_data: + print(f"Error: {json_data['error']}") + exit(1) + return json_data + except ValueError: + return response.text + + return None + + def view( url, token, @@ -27,61 +49,147 @@ def view( users = [] - current = 1 + current = 0 while True: + current += 1 params["current"] = current response = requests.get(f"{url}/api/users", headers=headers, params=params) + if response.status_code != 200: + print(f"Error: HTTP {response.status_code} - {response.text}") + exit(1) + response_json = response.json() + if "error" in response_json: + print(f"Error: {response_json['error']}") + exit(1) data = response_json.get("data", []) users.extend(data) total = response_json.get("total", 0) - current += pageSize - if len(data) < pageSize or current > total: + if len(data) < pageSize or current * pageSize >= total: break return users -def check(response): - if response.status_code == 200: - try: - response_json = response.json() - return response_json - except ValueError: - return response.text or "Success" - else: - return "Failed", response.status_code, response.text - - def disable(url, token, guid, name): print("Disable", name) headers = {"Authorization": f"Bearer {token}"} response = requests.post(f"{url}/api/users/{guid}/disable", headers=headers) - return check(response) + check_response(response) def enable(url, token, guid, name): print("Enable", name) headers = {"Authorization": f"Bearer {token}"} response = requests.post(f"{url}/api/users/{guid}/enable", headers=headers) - return check(response) + check_response(response) -def delete(url, token, guid, name): +def delete_user(url, token, guid, name): print("Delete", name) headers = {"Authorization": f"Bearer {token}"} response = requests.delete(f"{url}/api/users/{guid}", headers=headers) - return check(response) + check_response(response) + + +def new_user(url, token, name, password, group_name=None, email=None, note=None): + """Create a new user""" + headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + payload = { + "name": name, + "password": password, + } + if group_name: + payload["group_name"] = group_name + if email: + payload["email"] = email + if note: + payload["note"] = note + response = requests.post(f"{url}/api/users", headers=headers, json=payload) + check_response(response) + + +def invite_user(url, token, email, name, group_name=None, note=None): + """Invite a user by email""" + headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + payload = { + "email": email, + "name": name, + } + if group_name: + payload["group_name"] = group_name + if note: + payload["note"] = note + response = requests.post(f"{url}/api/users/invite", headers=headers, json=payload) + check_response(response) + + +def enable_2fa_enforce(url, token, user_guids, base_url): + """Enable 2FA enforcement for users""" + headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + payload = { + "user_guids": user_guids if isinstance(user_guids, list) else [user_guids], + "enforce": True, + "url": base_url + } + response = requests.put(f"{url}/api/users/tfa/totp/enforce", headers=headers, json=payload) + check_response(response) + + +def disable_2fa_enforce(url, token, user_guids, base_url=""): + """Disable 2FA enforcement for users""" + headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + payload = { + "user_guids": user_guids if isinstance(user_guids, list) else [user_guids], + "enforce": False, + "url": base_url + } + response = requests.put(f"{url}/api/users/tfa/totp/enforce", headers=headers, json=payload) + check_response(response) + + +def disable_email_verification(url, token, user_guids): + """Disable email login verification for users""" + headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + payload = { + "user_guids": user_guids if isinstance(user_guids, list) else [user_guids], + "type": "email" + } + response = requests.put(f"{url}/api/users/disable_login_verification", headers=headers, json=payload) + check_response(response) + + +def reset_2fa(url, token, user_guids): + """Reset 2FA for users""" + headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + payload = { + "user_guids": user_guids if isinstance(user_guids, list) else [user_guids], + "type": "2fa" + } + response = requests.put(f"{url}/api/users/disable_login_verification", headers=headers, json=payload) + check_response(response) + + +def force_logout(url, token, user_guids): + """Force logout users""" + headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + payload = { + "user_guids": user_guids if isinstance(user_guids, list) else [user_guids], + } + response = requests.post(f"{url}/api/users/force-logout", headers=headers, json=payload) + check_response(response) def main(): parser = argparse.ArgumentParser(description="User manager") parser.add_argument( "command", - choices=["view", "disable", "enable", "delete"], + choices=["view", "disable", "enable", "delete", "new", "invite", + "enable-2fa-enforce", "disable-2fa-enforce", + "disable-email-verification", "reset-2fa", "force-logout"], help="Command to execute", ) parser.add_argument("--url", required=True, help="URL of the API") @@ -89,12 +197,32 @@ def main(): "--token", required=True, help="Bearer token for authentication" ) parser.add_argument("--name", help="User name") - parser.add_argument("--group_name", help="Group name") + parser.add_argument("--group_name", help="Group name (for filtering in view, or for new/invite command)") + parser.add_argument("--password", help="User password (for new command)") + parser.add_argument("--email", help="User email (for invite command)") + parser.add_argument("--note", help="User note (for new/invite command)") + parser.add_argument("--web-console-url", help="Web console URL (for 2FA enforce commands)") args = parser.parse_args() while args.url.endswith("/"): args.url = args.url[:-1] + if args.command == "new": + if not args.name or not args.password or not args.group_name: + print("Error: --name and --password and --group_name are required for new command") + exit(1) + new_user(args.url, args.token, args.name, args.password, args.group_name, args.email, args.note) + print("Success: User created") + return + + if args.command == "invite": + if not args.email or not args.name or not args.group_name: + print("Error: --email and --name and --group_name are required for invite command") + exit(1) + invite_user(args.url, args.token, args.email, args.name, args.group_name, args.note) + print("Success: Invitation sent") + return + users = view( args.url, args.token, @@ -103,20 +231,61 @@ def main(): ) if args.command == "view": - for user in users: - print(user) - elif args.command == "disable": - for user in users: - response = disable(args.url, args.token, user["guid"], user["name"]) - print(response) - elif args.command == "enable": - for user in users: - response = enable(args.url, args.token, user["guid"], user["name"]) - print(response) - elif args.command == "delete": - for user in users: - response = delete(args.url, args.token, user["guid"], user["name"]) - print(response) + if len(users) == 0: + print("Found 0 users") + else: + for user in users: + print(user) + elif args.command in ["disable", "enable", "delete", "enable-2fa-enforce", + "disable-2fa-enforce", "disable-email-verification", "reset-2fa", "force-logout"]: + if len(users) == 0: + print("Found 0 users") + return + + # Check if we need user confirmation for multiple users + if len(users) > 1: + print(f"Found {len(users)} users. Do you want to proceed with {args.command} operation on the users? (Y/N)") + confirmation = input("Type 'Y' to confirm: ").strip() + if confirmation.upper() != 'Y': + print("Operation cancelled.") + return + + if args.command == "disable": + for user in users: + disable(args.url, args.token, user["guid"], user["name"]) + print("Success") + elif args.command == "enable": + for user in users: + enable(args.url, args.token, user["guid"], user["name"]) + print("Success") + elif args.command == "delete": + for user in users: + delete_user(args.url, args.token, user["guid"], user["name"]) + print("Success") + elif args.command == "enable-2fa-enforce": + if not args.web_console_url: + print("Error: --web-console-url is required for enable-2fa-enforce") + exit(1) + user_guids = [user["guid"] for user in users] + enable_2fa_enforce(args.url, args.token, user_guids, args.web_console_url) + print(f"Success: Enabled 2FA enforcement for {len(users)} user(s)") + elif args.command == "disable-2fa-enforce": + user_guids = [user["guid"] for user in users] + web_url = args.web_console_url or "" + disable_2fa_enforce(args.url, args.token, user_guids, web_url) + print(f"Success: Disabled 2FA enforcement for {len(users)} user(s)") + elif args.command == "disable-email-verification": + user_guids = [user["guid"] for user in users] + disable_email_verification(args.url, args.token, user_guids) + print(f"Success: Disabled email verification for {len(users)} user(s)") + elif args.command == "reset-2fa": + user_guids = [user["guid"] for user in users] + reset_2fa(args.url, args.token, user_guids) + print(f"Success: Reset 2FA for {len(users)} user(s)") + elif args.command == "force-logout": + user_guids = [user["guid"] for user in users] + force_logout(args.url, args.token, user_guids) + print(f"Success: Force logout for {len(users)} user(s)") if __name__ == "__main__": diff --git a/res/vcpkg/aom/portfile.cmake b/res/vcpkg/aom/portfile.cmake index 2df452a64..f7b1e3c43 100644 --- a/res/vcpkg/aom/portfile.cmake +++ b/res/vcpkg/aom/portfile.cmake @@ -8,16 +8,28 @@ vcpkg_find_acquire_program(PERL) get_filename_component(PERL_PATH ${PERL} DIRECTORY) vcpkg_add_to_path(${PERL_PATH}) -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-avx2.diff - # Can be dropped when https://bugs.chromium.org/p/aomedia/issues/detail?id=3029 is merged into the upstream - aom-install.diff -) +if(DEFINED ENV{USE_AOM_391}) + 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-avx2.diff + aom-install.diff + ) +else() + vcpkg_from_git( + OUT_SOURCE_PATH SOURCE_PATH + URL "https://aomedia.googlesource.com/aom" + REF 10aece4157eb79315da205f39e19bf6ab3ee30d0 # 3.12.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() set(aom_target_cpu "") if(VCPKG_TARGET_IS_UWP OR (VCPKG_TARGET_IS_WINDOWS AND VCPKG_TARGET_ARCHITECTURE MATCHES "^arm")) diff --git a/res/vcpkg/aom/vcpkg.json b/res/vcpkg/aom/vcpkg.json index 78ccc8989..70a12d83e 100644 --- a/res/vcpkg/aom/vcpkg.json +++ b/res/vcpkg/aom/vcpkg.json @@ -1,6 +1,6 @@ { "name": "aom", - "version-semver": "3.9.1", + "version-semver": "3.12.1", "port-version": 0, "description": "AV1 codec library", "homepage": "https://aomedia.googlesource.com/aom", diff --git a/res/vcpkg/ffmpeg/0001-create-lib-libraries.patch b/res/vcpkg/ffmpeg/0001-create-lib-libraries.patch new file mode 100644 index 000000000..ced7ba86b --- /dev/null +++ b/res/vcpkg/ffmpeg/0001-create-lib-libraries.patch @@ -0,0 +1,27 @@ +diff --git a/configure b/configure +index 1f0b9497cb..3243e23021 100644 +--- a/configure ++++ b/configure +@@ -5697,17 +5697,19 @@ case $target_os in + ;; + win32|win64) + disable symver +- if enabled shared; then ++# if enabled shared; then + # Link to the import library instead of the normal static library + # for shared libs. + LD_LIB='%.lib' + # Cannot build both shared and static libs with MSVC or icl. +- disable static +- fi ++# disable static ++# fi + ! enabled small && test_cmd $windres --version && enable gnu_windres + enabled x86_32 && check_ldflags -LARGEADDRESSAWARE + add_cppflags -DWIN32_LEAN_AND_MEAN + shlibdir_default="$bindir_default" ++ LIBPREF="" ++ LIBSUF=".lib" + SLIBPREF="" + SLIBSUF=".dll" + SLIBNAME_WITH_VERSION='$(SLIBPREF)$(FULLNAME)-$(LIBVERSION)$(SLIBSUF)' diff --git a/res/vcpkg/ffmpeg/0004-dependencies.patch b/res/vcpkg/ffmpeg/0004-dependencies.patch new file mode 100644 index 000000000..f1f6e72be --- /dev/null +++ b/res/vcpkg/ffmpeg/0004-dependencies.patch @@ -0,0 +1,65 @@ +diff --git a/configure b/configure +index a8b74e0..c99f41c 100755 +--- a/configure ++++ b/configure +@@ -6633,7 +6633,7 @@ fi + + enabled zlib && { check_pkg_config zlib zlib "zlib.h" zlibVersion || + check_lib zlib zlib.h zlibVersion -lz; } +-enabled bzlib && check_lib bzlib bzlib.h BZ2_bzlibVersion -lbz2 ++enabled bzlib && require_pkg_config bzlib bzip2 bzlib.h BZ2_bzlibVersion + enabled lzma && check_lib lzma lzma.h lzma_version_number -llzma + + enabled zlib && test_exec $zlib_extralibs <= 3.98.3" lame/lame.h lame_set_VBR_quality -lmp3lame $libm_extralibs ++enabled libmp3lame && { check_lib libmp3lame lame/lame.h lame_set_VBR_quality -lmp3lame $libm_extralibs || ++ require libmp3lame lame/lame.h lame_set_VBR_quality -llibmp3lame-static -llibmpghip-static $libm_extralibs; } + enabled libmysofa && { check_pkg_config libmysofa libmysofa mysofa.h mysofa_neighborhood_init_withstepdefine || + require libmysofa mysofa.h mysofa_neighborhood_init_withstepdefine -lmysofa $zlib_extralibs; } + enabled libnpp && { check_lib libnpp npp.h nppGetLibVersion -lnppig -lnppicc -lnppc -lnppidei -lnppif || +@@ -6772,7 +6773,7 @@ require_pkg_config libopencv opencv opencv/cxcore.h cvCreateImageHeader; } + enabled libopenh264 && require_pkg_config libopenh264 "openh264 >= 1.3.0" wels/codec_api.h WelsGetCodecVersion + enabled libopenjpeg && { check_pkg_config libopenjpeg "libopenjp2 >= 2.1.0" openjpeg.h opj_version || + { require_pkg_config libopenjpeg "libopenjp2 >= 2.1.0" openjpeg.h opj_version -DOPJ_STATIC && add_cppflags -DOPJ_STATIC; } } +-enabled libopenmpt && require_pkg_config libopenmpt "libopenmpt >= 0.2.6557" libopenmpt/libopenmpt.h openmpt_module_create -lstdc++ && append libopenmpt_extralibs "-lstdc++" ++enabled libopenmpt && require_pkg_config libopenmpt "libopenmpt >= 0.2.6557" libopenmpt/libopenmpt.h openmpt_module_create + enabled libopenvino && { { check_pkg_config libopenvino openvino openvino/c/openvino.h ov_core_create && enable openvino2; } || + { check_pkg_config libopenvino openvino c_api/ie_c_api.h ie_c_api_version || + require libopenvino c_api/ie_c_api.h ie_c_api_version -linference_engine_c_api; } } +@@ -6796,8 +6797,8 @@ enabled libshaderc && require_pkg_config spirv_compiler "shaderc >= 2019. + enabled libshine && require_pkg_config libshine shine shine/layer3.h shine_encode_buffer + enabled libsmbclient && { check_pkg_config libsmbclient smbclient libsmbclient.h smbc_init || + require libsmbclient libsmbclient.h smbc_init -lsmbclient; } +-enabled libsnappy && require libsnappy snappy-c.h snappy_compress -lsnappy -lstdc++ +-enabled libsoxr && require libsoxr soxr.h soxr_create -lsoxr ++enabled libsnappy && require_pkg_config libsnappy snappy snappy-c.h snappy_compress ++enabled libsoxr && require libsoxr soxr.h soxr_create -lsoxr $libm_extralibs + enabled libssh && require_pkg_config libssh "libssh >= 0.6.0" libssh/sftp.h sftp_init + enabled libspeex && require_pkg_config libspeex speex speex/speex.h speex_decoder_init + enabled libsrt && require_pkg_config libsrt "srt >= 1.3.0" srt/srt.h srt_socket +@@ -6880,6 +6881,8 @@ enabled openal && { check_pkg_config openal "openal >= 1.1" "AL/al.h" + enabled opencl && { check_pkg_config opencl OpenCL CL/cl.h clEnqueueNDRangeKernel || + check_lib opencl OpenCL/cl.h clEnqueueNDRangeKernel "-framework OpenCL" || + check_lib opencl CL/cl.h clEnqueueNDRangeKernel -lOpenCL || ++ check_lib opencl CL/cl.h clEnqueueNDRangeKernel -lOpenCL -lAdvapi32 -lOle32 -lCfgmgr32|| ++ check_lib opencl CL/cl.h clEnqueueNDRangeKernel -lOpenCL -pthread -ldl || + die "ERROR: opencl not found"; } && + { test_cpp_condition "OpenCL/cl.h" "defined(CL_VERSION_1_2)" || + test_cpp_condition "CL/cl.h" "defined(CL_VERSION_1_2)" || +@@ -7204,10 +7207,10 @@ enabled amf && + "(AMF_VERSION_MAJOR << 48 | AMF_VERSION_MINOR << 32 | AMF_VERSION_RELEASE << 16 | AMF_VERSION_BUILD_NUM) >= 0x0001000400210000" + + # Funny iconv installations are not unusual, so check it after all flags have been set +-if enabled libc_iconv; then ++if enabled libc_iconv && disabled iconv; then + check_func_headers iconv.h iconv + elif enabled iconv; then +- check_func_headers iconv.h iconv || check_lib iconv iconv.h iconv -liconv ++ check_func_headers iconv.h iconv || check_lib iconv iconv.h iconv -liconv || check_lib iconv iconv.h iconv -liconv -lcharset + fi + + enabled debug && add_cflags -g"$debuglevel" && add_asflags -g"$debuglevel" diff --git a/res/vcpkg/ffmpeg/0005-fix-nasm.patch b/res/vcpkg/ffmpeg/0005-fix-nasm.patch index 9308e714a..68b7503b2 100644 --- a/res/vcpkg/ffmpeg/0005-fix-nasm.patch +++ b/res/vcpkg/ffmpeg/0005-fix-nasm.patch @@ -1,55 +1,78 @@ -diff --git a/libavcodec/x86/Makefile b/libavcodec/x86/Makefile ---- a/libavcodec/x86/Makefile -+++ b/libavcodec/x86/Makefile -@@ -158,6 +158,8 @@ X86ASM-OBJS-$(CONFIG_ALAC_DECODER) += x86/alacdsp.o - X86ASM-OBJS-$(CONFIG_APNG_DECODER) += x86/pngdsp.o - X86ASM-OBJS-$(CONFIG_CAVS_DECODER) += x86/cavsidct.o -+ifdef ARCH_X86_64 - X86ASM-OBJS-$(CONFIG_CFHD_ENCODER) += x86/cfhdencdsp.o -+endif - X86ASM-OBJS-$(CONFIG_CFHD_DECODER) += x86/cfhddsp.o - X86ASM-OBJS-$(CONFIG_DCA_DECODER) += x86/dcadsp.o x86/synth_filter.o - X86ASM-OBJS-$(CONFIG_DIRAC_DECODER) += x86/diracdsp.o \ -@@ -175,15 +177,21 @@ x86/hevc_sao_10bit.o - X86ASM-OBJS-$(CONFIG_JPEG2000_DECODER) += x86/jpeg2000dsp.o - X86ASM-OBJS-$(CONFIG_LSCR_DECODER) += x86/pngdsp.o -+ifdef ARCH_X86_64 - X86ASM-OBJS-$(CONFIG_MLP_DECODER) += x86/mlpdsp.o -+endif - X86ASM-OBJS-$(CONFIG_MPEG4_DECODER) += x86/xvididct.o - X86ASM-OBJS-$(CONFIG_PNG_DECODER) += x86/pngdsp.o -+ifdef ARCH_X86_64 - X86ASM-OBJS-$(CONFIG_PRORES_DECODER) += x86/proresdsp.o - X86ASM-OBJS-$(CONFIG_PRORES_LGPL_DECODER) += x86/proresdsp.o -+endif - X86ASM-OBJS-$(CONFIG_RV40_DECODER) += x86/rv40dsp.o - X86ASM-OBJS-$(CONFIG_SBC_ENCODER) += x86/sbcdsp.o - X86ASM-OBJS-$(CONFIG_SVQ1_ENCODER) += x86/svq1enc.o - X86ASM-OBJS-$(CONFIG_TAK_DECODER) += x86/takdsp.o -+ifdef ARCH_X86_64 - X86ASM-OBJS-$(CONFIG_TRUEHD_DECODER) += x86/mlpdsp.o -+endif - X86ASM-OBJS-$(CONFIG_TTA_DECODER) += x86/ttadsp.o - X86ASM-OBJS-$(CONFIG_TTA_ENCODER) += x86/ttaencdsp.o - X86ASM-OBJS-$(CONFIG_UTVIDEO_DECODER) += x86/utvideodsp.o -diff --git a/libavfilter/x86/Makefile b/libavfilter/x86/Makefile ---- a/libavfilter/x86/Makefile -+++ b/libavfilter/x86/Makefile -@@ -44,6 +44,8 @@ - X86ASM-OBJS-$(CONFIG_AFIR_FILTER) += x86/af_afir.o - X86ASM-OBJS-$(CONFIG_ANLMDN_FILTER) += x86/af_anlmdn.o -+ifdef ARCH_X86_64 - X86ASM-OBJS-$(CONFIG_ATADENOISE_FILTER) += x86/vf_atadenoise.o -+endif - X86ASM-OBJS-$(CONFIG_BLEND_FILTER) += x86/vf_blend.o - X86ASM-OBJS-$(CONFIG_BWDIF_FILTER) += x86/vf_bwdif.o - X86ASM-OBJS-$(CONFIG_COLORSPACE_FILTER) += x86/colorspacedsp.o -@@ -62,6 +62,8 @@ X86ASM-OBJS-$(CONFIG_LUT3D_FILTER) += x86/vf_lut3d.o - X86ASM-OBJS-$(CONFIG_MASKEDCLAMP_FILTER) += x86/vf_maskedclamp.o - X86ASM-OBJS-$(CONFIG_MASKEDMERGE_FILTER) += x86/vf_maskedmerge.o -+ifdef ARCH_X86_64 - X86ASM-OBJS-$(CONFIG_NLMEANS_FILTER) += x86/vf_nlmeans.o -+endif - X86ASM-OBJS-$(CONFIG_OVERLAY_FILTER) += x86/vf_overlay.o - X86ASM-OBJS-$(CONFIG_PP7_FILTER) += x86/vf_pp7.o - X86ASM-OBJS-$(CONFIG_PSNR_FILTER) += x86/vf_psnr.o +diff --git a/libavcodec/x86/mlpdsp.asm b/libavcodec/x86/mlpdsp.asm +index 3dc641e..609b834 100644 +--- a/libavcodec/x86/mlpdsp.asm ++++ b/libavcodec/x86/mlpdsp.asm +@@ -23,7 +23,9 @@ + + SECTION .text + +-%if ARCH_X86_64 ++%ifn ARCH_X86_64 ++mlpdsp_placeholder: times 4 db 0 ++%else + + %macro SHLX 2 + %if cpuflag(bmi2) +diff --git a/libavcodec/x86/proresdsp.asm b/libavcodec/x86/proresdsp.asm +index 65c9fad..5ad73f3 100644 +--- a/libavcodec/x86/proresdsp.asm ++++ b/libavcodec/x86/proresdsp.asm +@@ -24,7 +24,10 @@ + + %include "libavutil/x86/x86util.asm" + +-%if ARCH_X86_64 ++%ifn ARCH_X86_64 ++SECTION .rdata ++proresdsp_placeholder: times 4 db 0 ++%else + + SECTION_RODATA + +diff --git a/libavcodec/x86/vvc/vvc_mc.asm b/libavcodec/x86/vvc/vvc_mc.asm +index 30aa97c..3975f98 100644 +--- a/libavcodec/x86/vvc/vvc_mc.asm ++++ b/libavcodec/x86/vvc/vvc_mc.asm +@@ -31,7 +31,9 @@ + + SECTION_RODATA 32 + +-%if ARCH_X86_64 ++%ifn ARCH_X86_64 ++vvc_mc_placeholder: times 4 db 0 ++%else + + %if HAVE_AVX2_EXTERNAL + +diff --git a/libavfilter/x86/vf_atadenoise.asm b/libavfilter/x86/vf_atadenoise.asm +index 4945ad3..748b65a 100644 +--- a/libavfilter/x86/vf_atadenoise.asm ++++ b/libavfilter/x86/vf_atadenoise.asm +@@ -20,7 +20,10 @@ + ;* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + ;****************************************************************************** + +-%if ARCH_X86_64 ++%ifn ARCH_X86_64 ++SECTION .rdata ++vf_atadenoise_placeholder: times 4 db 0 ++%else + + %include "libavutil/x86/x86util.asm" + +diff --git a/libavfilter/x86/vf_nlmeans.asm b/libavfilter/x86/vf_nlmeans.asm +index 8f57801..9aef3a4 100644 +--- a/libavfilter/x86/vf_nlmeans.asm ++++ b/libavfilter/x86/vf_nlmeans.asm +@@ -21,7 +21,10 @@ + + %include "libavutil/x86/x86util.asm" + +-%if HAVE_AVX2_EXTERNAL && ARCH_X86_64 ++%ifn HAVE_AVX2_EXTERNAL && ARCH_X86_64 ++SECTION .rdata ++vf_nlmeans_placeholder: times 4 db 0 ++%else + + SECTION_RODATA 32 + diff --git a/res/vcpkg/ffmpeg/0007-fix-lib-naming.patch b/res/vcpkg/ffmpeg/0007-fix-lib-naming.patch new file mode 100644 index 000000000..c22f9c199 --- /dev/null +++ b/res/vcpkg/ffmpeg/0007-fix-lib-naming.patch @@ -0,0 +1,12 @@ +diff --git a/configure b/configure +index d6c4388..75b96c3 100644 +--- a/configure ++++ b/configure +@@ -4781,6 +4781,7 @@ msvc_common_flags(){ + -mfp16-format=*) ;; + -lz) echo zlib.lib ;; + -lx264) echo libx264.lib ;; ++ -lmp3lame) echo libmp3lame.lib ;; + -lstdc++) ;; + -l*) echo ${flag#-l}.lib ;; + -LARGEADDRESSAWARE) echo $flag ;; diff --git a/res/vcpkg/ffmpeg/0012-Fix-ssl-110-detection.patch b/res/vcpkg/ffmpeg/0012-Fix-ssl-110-detection.patch deleted file mode 100644 index b2e5501a1..000000000 --- a/res/vcpkg/ffmpeg/0012-Fix-ssl-110-detection.patch +++ /dev/null @@ -1,14 +0,0 @@ -diff --git a/configure b/configure -index 2be953f7e7..e075949ffc 100755 ---- a/configure -+++ b/configure -@@ -6497,6 +6497,7 @@ enabled openssl && { { check_pkg_config openssl "openssl >= 3.0.0 - { enabled gplv3 || ! enabled gpl || enabled nonfree || die "ERROR: OpenSSL >=3.0.0 requires --enable-version3"; }; } || - { enabled gpl && ! enabled nonfree && die "ERROR: OpenSSL <3.0.0 is incompatible with the gpl"; } || - check_pkg_config openssl openssl openssl/ssl.h OPENSSL_init_ssl || - check_pkg_config openssl openssl openssl/ssl.h SSL_library_init || -+ check_lib openssl openssl/ssl.h OPENSSL_init_ssl -lssl -lcrypto $pthreads_extralibs -ldl || - check_lib openssl openssl/ssl.h OPENSSL_init_ssl -lssl -lcrypto || - check_lib openssl openssl/ssl.h SSL_library_init -lssl -lcrypto || - check_lib openssl openssl/ssl.h SSL_library_init -lssl32 -leay32 || - diff --git a/res/vcpkg/ffmpeg/0020-fix-aarch64-libswscale.patch b/res/vcpkg/ffmpeg/0020-fix-aarch64-libswscale.patch new file mode 100644 index 000000000..f47e82ed8 --- /dev/null +++ b/res/vcpkg/ffmpeg/0020-fix-aarch64-libswscale.patch @@ -0,0 +1,28 @@ +diff --git a/libswscale/aarch64/yuv2rgb_neon.S b/libswscale/aarch64/yuv2rgb_neon.S +index 89d69e7f6c..4bc1607a7a 100644 +--- a/libswscale/aarch64/yuv2rgb_neon.S ++++ b/libswscale/aarch64/yuv2rgb_neon.S +@@ -169,19 +169,19 @@ function ff_\ifmt\()_to_\ofmt\()_neon, export=1 + sqdmulh v26.8h, v26.8h, v0.8h // ((Y1*(1<<3) - y_offset) * y_coeff) >> 15 + sqdmulh v27.8h, v27.8h, v0.8h // ((Y2*(1<<3) - y_offset) * y_coeff) >> 15 + +-.ifc \ofmt,argb // 1 2 3 0 ++.ifc \ofmt,argb + compute_rgba v5.8b,v6.8b,v7.8b,v4.8b, v17.8b,v18.8b,v19.8b,v16.8b + .endif + +-.ifc \ofmt,rgba // 0 1 2 3 ++.ifc \ofmt,rgba + compute_rgba v4.8b,v5.8b,v6.8b,v7.8b, v16.8b,v17.8b,v18.8b,v19.8b + .endif + +-.ifc \ofmt,abgr // 3 2 1 0 ++.ifc \ofmt,abgr + compute_rgba v7.8b,v6.8b,v5.8b,v4.8b, v19.8b,v18.8b,v17.8b,v16.8b + .endif + +-.ifc \ofmt,bgra // 2 1 0 3 ++.ifc \ofmt,bgra + compute_rgba v6.8b,v5.8b,v4.8b,v7.8b, v18.8b,v17.8b,v16.8b,v19.8b + .endif + diff --git a/res/vcpkg/ffmpeg/0024-fix-osx-host-c11.patch b/res/vcpkg/ffmpeg/0024-fix-osx-host-c11.patch new file mode 100644 index 000000000..dbce2f53b --- /dev/null +++ b/res/vcpkg/ffmpeg/0024-fix-osx-host-c11.patch @@ -0,0 +1,15 @@ +diff --git a/configure b/configure +index 4f5353f84b..dd9147c677 100755 +--- a/configure ++++ b/configure +@@ -5607,8 +5607,8 @@ check_cppflags -D_FILE_OFFSET_BITS=64 + check_cppflags -D_LARGEFILE_SOURCE + + add_host_cppflags -D_ISOC11_SOURCE + check_host_cflags_cc -std=$stdc ctype.h "__STDC_VERSION__ >= 201112L" || +- check_host_cflags_cc -std=c11 ctype.h "__STDC_VERSION__ >= 201112L" || die "Host compiler lacks C11 support" ++ check_host_cflags_cc -std=c11 ctype.h "__STDC_VERSION__ >= 201112L" + + check_host_cflags -Wall + check_host_cflags $host_cflags_speed + diff --git a/res/vcpkg/ffmpeg/0040-ffmpeg-add-av_stream_get_first_dts-for-chromium.patch b/res/vcpkg/ffmpeg/0040-ffmpeg-add-av_stream_get_first_dts-for-chromium.patch new file mode 100644 index 000000000..c2e1d8ff0 --- /dev/null +++ b/res/vcpkg/ffmpeg/0040-ffmpeg-add-av_stream_get_first_dts-for-chromium.patch @@ -0,0 +1,35 @@ +diff --git a/libavformat/avformat.h b/libavformat/avformat.h +index cd7b0d941c..b4a6dce885 100644 +--- a/libavformat/avformat.h ++++ b/libavformat/avformat.h +@@ -1169,7 +1169,11 @@ typedef struct AVStreamGroup { + } AVStreamGroup; + + struct AVCodecParserContext *av_stream_get_parser(const AVStream *s); + ++// Chromium: We use the internal field first_dts vvv ++int64_t av_stream_get_first_dts(const AVStream *st); ++// Chromium: We use the internal field first_dts ^^^ ++ + #define AV_PROGRAM_RUNNING 1 + + /** +diff --git a/libavformat/mux_utils.c b/libavformat/mux_utils.c +index de7580c32d..0ef0fe530e 100644 +--- a/libavformat/mux_utils.c ++++ b/libavformat/mux_utils.c +@@ -29,7 +29,14 @@ #include "avformat.h" + #include "avio.h" + #include "internal.h" + #include "mux.h" + ++// Chromium: We use the internal field first_dts vvv ++int64_t av_stream_get_first_dts(const AVStream *st) ++{ ++ return cffstream(st)->first_dts; ++} ++// Chromium: We use the internal field first_dts ^^^ ++ + int avformat_query_codec(const AVOutputFormat *ofmt, enum AVCodecID codec_id, + int std_compliance) + { diff --git a/res/vcpkg/ffmpeg/0041-add-const-for-opengl-definition.patch b/res/vcpkg/ffmpeg/0041-add-const-for-opengl-definition.patch new file mode 100644 index 000000000..b22b40d1f --- /dev/null +++ b/res/vcpkg/ffmpeg/0041-add-const-for-opengl-definition.patch @@ -0,0 +1,13 @@ +diff --git a/libavdevice/opengl_enc.c b/libavdevice/opengl_enc.c +index b2ac6eb..6351614 100644 +--- a/libavdevice/opengl_enc.c ++++ b/libavdevice/opengl_enc.c +@@ -116,7 +116,7 @@ typedef void (APIENTRY *FF_PFNGLATTACHSHADERPROC) (GLuint program, GLuint shad + typedef GLuint (APIENTRY *FF_PFNGLCREATESHADERPROC) (GLenum type); + typedef void (APIENTRY *FF_PFNGLDELETESHADERPROC) (GLuint shader); + typedef void (APIENTRY *FF_PFNGLCOMPILESHADERPROC) (GLuint shader); +-typedef void (APIENTRY *FF_PFNGLSHADERSOURCEPROC) (GLuint shader, GLsizei count, const char* *string, const GLint *length); ++typedef void (APIENTRY *FF_PFNGLSHADERSOURCEPROC) (GLuint shader, GLsizei count, const char* const *string, const GLint *length); + typedef void (APIENTRY *FF_PFNGLGETSHADERIVPROC) (GLuint shader, GLenum pname, GLint *params); + typedef void (APIENTRY *FF_PFNGLGETSHADERINFOLOGPROC) (GLuint shader, GLsizei bufSize, GLsizei *length, char *infoLog); + diff --git a/res/vcpkg/ffmpeg/0042-fix-arm64-linux.patch b/res/vcpkg/ffmpeg/0042-fix-arm64-linux.patch new file mode 100644 index 000000000..6ff63c371 --- /dev/null +++ b/res/vcpkg/ffmpeg/0042-fix-arm64-linux.patch @@ -0,0 +1,9 @@ +diff --git a/ffbuild/libversion.sh b/ffbuild/libversion.sh +index a94ab58..ecaa90c 100644 +--- a/ffbuild/libversion.sh ++++ b/ffbuild/libversion.sh +@@ -1,3 +1,4 @@ ++#!/bin/sh + toupper(){ + echo "$@" | tr abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ + } diff --git a/res/vcpkg/ffmpeg/0043-fix-miss-head.patch b/res/vcpkg/ffmpeg/0043-fix-miss-head.patch new file mode 100644 index 000000000..bad42798c --- /dev/null +++ b/res/vcpkg/ffmpeg/0043-fix-miss-head.patch @@ -0,0 +1,12 @@ +diff --git a/libavfilter/textutils.c b/libavfilter/textutils.c +index ef658d0..c61b0ad 100644 +--- a/libavfilter/textutils.c ++++ b/libavfilter/textutils.c +@@ -31,6 +31,7 @@ + #include "libavutil/file.h" + #include "libavutil/mem.h" + #include "libavutil/time.h" ++#include "libavutil/time_internal.h" + + static int ff_expand_text_function_internal(FFExpandTextContext *expand_text, AVBPrint *bp, + char *name, unsigned argc, char **argv) diff --git a/res/vcpkg/ffmpeg/patch/0001-avcodec-amfenc-add-query_timeout-option-for-h264-hev.patch b/res/vcpkg/ffmpeg/patch/0001-avcodec-amfenc-add-query_timeout-option-for-h264-hev.patch index 5431b3edd..4fbce0d48 100644 --- a/res/vcpkg/ffmpeg/patch/0001-avcodec-amfenc-add-query_timeout-option-for-h264-hev.patch +++ b/res/vcpkg/ffmpeg/patch/0001-avcodec-amfenc-add-query_timeout-option-for-h264-hev.patch @@ -1,7 +1,7 @@ -From f6988e5424e041ff6f6e241f4d8fa69a04c05e64 Mon Sep 17 00:00:00 2001 +From da6921d5bcb50961193526f47aa2dbe71ee5fe81 Mon Sep 17 00:00:00 2001 From: 21pages -Date: Thu, 5 Sep 2024 16:26:20 +0800 -Subject: [PATCH 1/3] avcodec/amfenc: add query_timeout option for h264/hevc +Date: Tue, 10 Dec 2024 13:40:46 +0800 +Subject: [PATCH 1/5] avcodec/amfenc: add query_timeout option for h264/hevc Signed-off-by: 21pages --- @@ -11,10 +11,10 @@ Signed-off-by: 21pages 3 files changed, 9 insertions(+) diff --git a/libavcodec/amfenc.h b/libavcodec/amfenc.h -index 2dbd378ef8..d636673a9d 100644 +index d985d01bb1..320c66919e 100644 --- a/libavcodec/amfenc.h +++ b/libavcodec/amfenc.h -@@ -89,6 +89,7 @@ typedef struct AmfContext { +@@ -91,6 +91,7 @@ typedef struct AmfContext { int quality; int b_frame_delta_qp; int ref_b_frame_delta_qp; @@ -23,40 +23,40 @@ index 2dbd378ef8..d636673a9d 100644 // Dynamic options, can be set after Init() call diff --git a/libavcodec/amfenc_h264.c b/libavcodec/amfenc_h264.c -index c1d5f4054e..415828f005 100644 +index 8edd39c633..6ad4961b2f 100644 --- a/libavcodec/amfenc_h264.c +++ b/libavcodec/amfenc_h264.c -@@ -135,6 +135,7 @@ static const AVOption options[] = { - { "aud", "Inserts AU Delimiter NAL unit", OFFSET(aud) ,AV_OPT_TYPE_BOOL, { .i64 = 0 }, 0, 1, VE }, +@@ -137,6 +137,7 @@ static const AVOption options[] = { + { "log_to_dbg", "Enable AMF logging to debug output", OFFSET(log_to_dbg) , AV_OPT_TYPE_BOOL, { .i64 = 0 }, 0, 1, VE }, + { "query_timeout", "Timeout for QueryOutput call in ms", OFFSET(query_timeout), AV_OPT_TYPE_INT64, { .i64 = -1 }, -1, 1000, VE }, //Pre Analysis options { "preanalysis", "Enable preanalysis", OFFSET(preanalysis), AV_OPT_TYPE_BOOL, {.i64 = -1 }, -1, 1, VE }, -@@ -222,6 +223,9 @@ FF_ENABLE_DEPRECATION_WARNINGS +@@ -228,6 +229,9 @@ FF_ENABLE_DEPRECATION_WARNINGS AMF_ASSIGN_PROPERTY_RATE(res, ctx->encoder, AMF_VIDEO_ENCODER_FRAMERATE, framerate); + if (ctx->query_timeout >= 0) -+ AMF_ASSIGN_PROPERTY_INT64(res, ctx->encoder, AMF_VIDEO_ENCODER_QUERY_TIMEOUT, ctx->query_timeout); ++ AMF_ASSIGN_PROPERTY_INT64(res, ctx->encoder, AMF_VIDEO_ENCODER_QUERY_TIMEOUT, ctx->query_timeout); + switch (avctx->profile) { case AV_PROFILE_H264_BASELINE: profile = AMF_VIDEO_ENCODER_PROFILE_BASELINE; diff --git a/libavcodec/amfenc_hevc.c b/libavcodec/amfenc_hevc.c -index 33a167aa52..65259d7153 100644 +index 4898824f3a..22cb95c7ce 100644 --- a/libavcodec/amfenc_hevc.c +++ b/libavcodec/amfenc_hevc.c -@@ -98,6 +98,7 @@ static const AVOption options[] = { - { "aud", "Inserts AU Delimiter NAL unit", OFFSET(aud) ,AV_OPT_TYPE_BOOL,{ .i64 = 0 }, 0, 1, VE }, +@@ -104,6 +104,7 @@ static const AVOption options[] = { + { "log_to_dbg", "Enable AMF logging to debug output", OFFSET(log_to_dbg), AV_OPT_TYPE_BOOL,{ .i64 = 0 }, 0, 1, VE }, + { "query_timeout", "Timeout for QueryOutput call in ms", OFFSET(query_timeout), AV_OPT_TYPE_INT64, { .i64 = -1 }, -1, 1000, VE }, //Pre Analysis options { "preanalysis", "Enable preanalysis", OFFSET(preanalysis), AV_OPT_TYPE_BOOL, {.i64 = -1 }, -1, 1, VE }, -@@ -183,6 +184,9 @@ FF_ENABLE_DEPRECATION_WARNINGS +@@ -194,6 +195,9 @@ FF_ENABLE_DEPRECATION_WARNINGS AMF_ASSIGN_PROPERTY_RATE(res, ctx->encoder, AMF_VIDEO_ENCODER_HEVC_FRAMERATE, framerate); diff --git a/res/vcpkg/ffmpeg/patch/0002-libavcodec-amfenc-reconfig-when-bitrate-change.patch b/res/vcpkg/ffmpeg/patch/0002-libavcodec-amfenc-reconfig-when-bitrate-change.patch index 62b86d08b..f2ec5df32 100644 --- a/res/vcpkg/ffmpeg/patch/0002-libavcodec-amfenc-reconfig-when-bitrate-change.patch +++ b/res/vcpkg/ffmpeg/patch/0002-libavcodec-amfenc-reconfig-when-bitrate-change.patch @@ -1,7 +1,7 @@ -From 6e76c57cf2c0e790228f19c88089eef110fd74aa Mon Sep 17 00:00:00 2001 +From 8d061adb7b00fc765b8001307c025437ef1cad88 Mon Sep 17 00:00:00 2001 From: 21pages Date: Thu, 5 Sep 2024 16:32:16 +0800 -Subject: [PATCH 2/3] libavcodec/amfenc: reconfig when bitrate change +Subject: [PATCH 2/5] libavcodec/amfenc: reconfig when bitrate change Signed-off-by: 21pages --- @@ -10,10 +10,10 @@ Signed-off-by: 21pages 2 files changed, 21 insertions(+) diff --git a/libavcodec/amfenc.c b/libavcodec/amfenc.c -index 061859f85c..97587fe66b 100644 +index a47aea6108..f70f0109f6 100644 --- a/libavcodec/amfenc.c +++ b/libavcodec/amfenc.c -@@ -222,6 +222,7 @@ static int amf_init_context(AVCodecContext *avctx) +@@ -275,6 +275,7 @@ static int amf_init_context(AVCodecContext *avctx) ctx->hwsurfaces_in_queue = 0; ctx->hwsurfaces_in_queue_max = 16; @@ -21,7 +21,7 @@ index 061859f85c..97587fe66b 100644 // configure AMF logger // the return of these functions indicates old state and do not affect behaviour -@@ -583,6 +584,23 @@ static void amf_release_buffer_with_frame_ref(AMFBuffer *frame_ref_storage_buffe +@@ -640,6 +641,23 @@ static void amf_release_buffer_with_frame_ref(AMFBuffer *frame_ref_storage_buffe frame_ref_storage_buffer->pVtbl->Release(frame_ref_storage_buffer); } @@ -45,7 +45,7 @@ index 061859f85c..97587fe66b 100644 int ff_amf_receive_packet(AVCodecContext *avctx, AVPacket *avpkt) { AmfContext *ctx = avctx->priv_data; -@@ -596,6 +614,8 @@ int ff_amf_receive_packet(AVCodecContext *avctx, AVPacket *avpkt) +@@ -653,6 +671,8 @@ int ff_amf_receive_packet(AVCodecContext *avctx, AVPacket *avpkt) int query_output_data_flag = 0; AMF_RESULT res_resubmit; @@ -55,10 +55,10 @@ index 061859f85c..97587fe66b 100644 return AVERROR(EINVAL); diff --git a/libavcodec/amfenc.h b/libavcodec/amfenc.h -index d636673a9d..09506ee2e0 100644 +index 320c66919e..481e0fb75d 100644 --- a/libavcodec/amfenc.h +++ b/libavcodec/amfenc.h -@@ -113,6 +113,7 @@ typedef struct AmfContext { +@@ -115,6 +115,7 @@ typedef struct AmfContext { int max_b_frames; int qvbr_quality_level; int hw_high_motion_quality_boost; diff --git a/res/vcpkg/ffmpeg/patch/0003-amf-colorspace.patch b/res/vcpkg/ffmpeg/patch/0003-amf-colorspace.patch deleted file mode 100644 index 9bcb6e692..000000000 --- a/res/vcpkg/ffmpeg/patch/0003-amf-colorspace.patch +++ /dev/null @@ -1,161 +0,0 @@ -From 14b77216106eaaff9cf701528039ae4264eaf420 Mon Sep 17 00:00:00 2001 -From: 21pages -Date: Thu, 5 Sep 2024 16:41:59 +0800 -Subject: [PATCH 3/3] amf colorspace - -Signed-off-by: 21pages ---- - libavcodec/amfenc.h | 1 + - libavcodec/amfenc_h264.c | 40 ++++++++++++++++++++++++++++++++++ - libavcodec/amfenc_hevc.c | 47 ++++++++++++++++++++++++++++++++++++++++ - 3 files changed, 88 insertions(+) - -diff --git a/libavcodec/amfenc.h b/libavcodec/amfenc.h -index 09506ee2e0..7f458b14f7 100644 ---- a/libavcodec/amfenc.h -+++ b/libavcodec/amfenc.h -@@ -24,6 +24,7 @@ - #include - #include - #include -+#include - - #include "libavutil/fifo.h" - -diff --git a/libavcodec/amfenc_h264.c b/libavcodec/amfenc_h264.c -index 415828f005..7da5a96c71 100644 ---- a/libavcodec/amfenc_h264.c -+++ b/libavcodec/amfenc_h264.c -@@ -200,6 +200,9 @@ static av_cold int amf_encode_init_h264(AVCodecContext *avctx) - AMFRate framerate; - AMFSize framesize = AMFConstructSize(avctx->width, avctx->height); - int deblocking_filter = (avctx->flags & AV_CODEC_FLAG_LOOP_FILTER) ? 1 : 0; -+ amf_int64 color_depth; -+ amf_int64 color_profile; -+ enum AVPixelFormat pix_fmt; - - if (avctx->framerate.num > 0 && avctx->framerate.den > 0) { - framerate = AMFConstructRate(avctx->framerate.num, avctx->framerate.den); -@@ -266,10 +269,47 @@ FF_ENABLE_DEPRECATION_WARNINGS - AMF_ASSIGN_PROPERTY_RATIO(res, ctx->encoder, AMF_VIDEO_ENCODER_ASPECT_RATIO, ratio); - } - -+ color_profile = AMF_VIDEO_CONVERTER_COLOR_PROFILE_UNKNOWN; - /// Color Range (Partial/TV/MPEG or Full/PC/JPEG) - if (avctx->color_range == AVCOL_RANGE_JPEG) { - AMF_ASSIGN_PROPERTY_BOOL(res, ctx->encoder, AMF_VIDEO_ENCODER_FULL_RANGE_COLOR, 1); -+ switch (avctx->colorspace) { -+ case AVCOL_SPC_SMPTE170M: -+ color_profile = AMF_VIDEO_CONVERTER_COLOR_PROFILE_FULL_601; -+ break; -+ case AVCOL_SPC_BT709: -+ color_profile = AMF_VIDEO_CONVERTER_COLOR_PROFILE_FULL_709; -+ break; -+ case AVCOL_SPC_BT2020_NCL: -+ case AVCOL_SPC_BT2020_CL: -+ color_profile = AMF_VIDEO_CONVERTER_COLOR_PROFILE_FULL_2020; -+ break; -+ } -+ } else { -+ AMF_ASSIGN_PROPERTY_BOOL(res, ctx->encoder, AMF_VIDEO_ENCODER_FULL_RANGE_COLOR, 0); -+ switch (avctx->colorspace) { -+ case AVCOL_SPC_SMPTE170M: -+ color_profile = AMF_VIDEO_CONVERTER_COLOR_PROFILE_601; -+ break; -+ case AVCOL_SPC_BT709: -+ color_profile = AMF_VIDEO_CONVERTER_COLOR_PROFILE_709; -+ break; -+ case AVCOL_SPC_BT2020_NCL: -+ case AVCOL_SPC_BT2020_CL: -+ color_profile = AMF_VIDEO_CONVERTER_COLOR_PROFILE_2020; -+ break; -+ } - } -+ pix_fmt = avctx->hw_frames_ctx ? ((AVHWFramesContext*)avctx->hw_frames_ctx->data)->sw_format : avctx->pix_fmt; -+ color_depth = AMF_COLOR_BIT_DEPTH_8; -+ if (pix_fmt == AV_PIX_FMT_P010) { -+ color_depth = AMF_COLOR_BIT_DEPTH_10; -+ } -+ -+ AMF_ASSIGN_PROPERTY_INT64(res, ctx->encoder, AMF_VIDEO_ENCODER_COLOR_BIT_DEPTH, color_depth); -+ AMF_ASSIGN_PROPERTY_INT64(res, ctx->encoder, AMF_VIDEO_ENCODER_OUTPUT_COLOR_PROFILE, color_profile); -+ AMF_ASSIGN_PROPERTY_INT64(res, ctx->encoder, AMF_VIDEO_ENCODER_OUTPUT_TRANSFER_CHARACTERISTIC, (amf_int64)avctx->color_trc); -+ AMF_ASSIGN_PROPERTY_INT64(res, ctx->encoder, AMF_VIDEO_ENCODER_OUTPUT_COLOR_PRIMARIES, (amf_int64)avctx->color_primaries); - - // autodetect rate control method - if (ctx->rate_control_mode == AMF_VIDEO_ENCODER_RATE_CONTROL_METHOD_UNKNOWN) { -diff --git a/libavcodec/amfenc_hevc.c b/libavcodec/amfenc_hevc.c -index 65259d7153..7c930d3ccc 100644 ---- a/libavcodec/amfenc_hevc.c -+++ b/libavcodec/amfenc_hevc.c -@@ -161,6 +161,9 @@ static av_cold int amf_encode_init_hevc(AVCodecContext *avctx) - AMFRate framerate; - AMFSize framesize = AMFConstructSize(avctx->width, avctx->height); - int deblocking_filter = (avctx->flags & AV_CODEC_FLAG_LOOP_FILTER) ? 1 : 0; -+ amf_int64 color_depth; -+ amf_int64 color_profile; -+ enum AVPixelFormat pix_fmt; - - if (avctx->framerate.num > 0 && avctx->framerate.den > 0) { - framerate = AMFConstructRate(avctx->framerate.num, avctx->framerate.den); -@@ -191,6 +194,9 @@ FF_ENABLE_DEPRECATION_WARNINGS - case AV_PROFILE_HEVC_MAIN: - profile = AMF_VIDEO_ENCODER_HEVC_PROFILE_MAIN; - break; -+ case AV_PROFILE_HEVC_MAIN_10: -+ profile = AMF_VIDEO_ENCODER_HEVC_PROFILE_MAIN_10; -+ break; - default: - break; - } -@@ -219,6 +225,47 @@ FF_ENABLE_DEPRECATION_WARNINGS - AMF_ASSIGN_PROPERTY_RATIO(res, ctx->encoder, AMF_VIDEO_ENCODER_HEVC_ASPECT_RATIO, ratio); - } - -+ color_profile = AMF_VIDEO_CONVERTER_COLOR_PROFILE_UNKNOWN; -+ if (avctx->color_range == AVCOL_RANGE_JPEG) { -+ AMF_ASSIGN_PROPERTY_BOOL(res, ctx->encoder, AMF_VIDEO_ENCODER_HEVC_NOMINAL_RANGE, 1); -+ switch (avctx->colorspace) { -+ case AVCOL_SPC_SMPTE170M: -+ color_profile = AMF_VIDEO_CONVERTER_COLOR_PROFILE_FULL_601; -+ break; -+ case AVCOL_SPC_BT709: -+ color_profile = AMF_VIDEO_CONVERTER_COLOR_PROFILE_FULL_709; -+ break; -+ case AVCOL_SPC_BT2020_NCL: -+ case AVCOL_SPC_BT2020_CL: -+ color_profile = AMF_VIDEO_CONVERTER_COLOR_PROFILE_FULL_2020; -+ break; -+ } -+ } else { -+ AMF_ASSIGN_PROPERTY_BOOL(res, ctx->encoder, AMF_VIDEO_ENCODER_HEVC_NOMINAL_RANGE, 0); -+ switch (avctx->colorspace) { -+ case AVCOL_SPC_SMPTE170M: -+ color_profile = AMF_VIDEO_CONVERTER_COLOR_PROFILE_601; -+ break; -+ case AVCOL_SPC_BT709: -+ color_profile = AMF_VIDEO_CONVERTER_COLOR_PROFILE_709; -+ break; -+ case AVCOL_SPC_BT2020_NCL: -+ case AVCOL_SPC_BT2020_CL: -+ color_profile = AMF_VIDEO_CONVERTER_COLOR_PROFILE_2020; -+ break; -+ } -+ } -+ pix_fmt = avctx->hw_frames_ctx ? ((AVHWFramesContext*)avctx->hw_frames_ctx->data)->sw_format : avctx->pix_fmt; -+ color_depth = AMF_COLOR_BIT_DEPTH_8; -+ if (pix_fmt == AV_PIX_FMT_P010) { -+ color_depth = AMF_COLOR_BIT_DEPTH_10; -+ } -+ -+ AMF_ASSIGN_PROPERTY_INT64(res, ctx->encoder, AMF_VIDEO_ENCODER_HEVC_COLOR_BIT_DEPTH, color_depth); -+ AMF_ASSIGN_PROPERTY_INT64(res, ctx->encoder, AMF_VIDEO_ENCODER_HEVC_OUTPUT_COLOR_PROFILE, color_profile); -+ AMF_ASSIGN_PROPERTY_INT64(res, ctx->encoder, AMF_VIDEO_ENCODER_HEVC_OUTPUT_TRANSFER_CHARACTERISTIC, (amf_int64)avctx->color_trc); -+ AMF_ASSIGN_PROPERTY_INT64(res, ctx->encoder, AMF_VIDEO_ENCODER_HEVC_OUTPUT_COLOR_PRIMARIES, (amf_int64)avctx->color_primaries); -+ - // Picture control properties - AMF_ASSIGN_PROPERTY_INT64(res, ctx->encoder, AMF_VIDEO_ENCODER_HEVC_NUM_GOPS_PER_IDR, ctx->gops_per_idr); - AMF_ASSIGN_PROPERTY_INT64(res, ctx->encoder, AMF_VIDEO_ENCODER_HEVC_GOP_SIZE, avctx->gop_size); --- -2.43.0.windows.1 - diff --git a/res/vcpkg/ffmpeg/patch/0004-videotoolbox-changing-bitrate.patch b/res/vcpkg/ffmpeg/patch/0004-videotoolbox-changing-bitrate.patch new file mode 100644 index 000000000..77b41a7ad --- /dev/null +++ b/res/vcpkg/ffmpeg/patch/0004-videotoolbox-changing-bitrate.patch @@ -0,0 +1,85 @@ +From d74de94b49efcf7a0b25673ace6016938d1b9272 Mon Sep 17 00:00:00 2001 +From: 21pages +Date: Tue, 10 Dec 2024 14:12:01 +0800 +Subject: [PATCH 3/5] videotoolbox changing bitrate + +Signed-off-by: 21pages +--- + libavcodec/videotoolboxenc.c | 40 ++++++++++++++++++++++++++++++++++++ + 1 file changed, 40 insertions(+) + +diff --git a/libavcodec/videotoolboxenc.c b/libavcodec/videotoolboxenc.c +index da7b291b03..3c866177f5 100644 +--- a/libavcodec/videotoolboxenc.c ++++ b/libavcodec/videotoolboxenc.c +@@ -279,6 +279,8 @@ typedef struct VTEncContext { + int max_slice_bytes; + int power_efficient; + int max_ref_frames; ++ ++ int last_bit_rate; + } VTEncContext; + + static void vtenc_free_buf_node(BufNode *info) +@@ -1180,6 +1182,7 @@ static int vtenc_create_encoder(AVCodecContext *avctx, + int64_t one_second_value = 0; + void *nums[2]; + ++ vtctx->last_bit_rate = bit_rate; + int status = VTCompressionSessionCreate(kCFAllocatorDefault, + avctx->width, + avctx->height, +@@ -2638,6 +2641,42 @@ out: + return status; + } + ++static void update_config(AVCodecContext *avctx) ++{ ++ VTEncContext *vtctx = avctx->priv_data; ++ ++ if (avctx->codec_id != AV_CODEC_ID_PRORES) { ++ if (avctx->bit_rate != vtctx->last_bit_rate) { ++ av_log(avctx, AV_LOG_INFO, "Setting bit rate to %d\n", avctx->bit_rate); ++ vtctx->last_bit_rate = avctx->bit_rate; ++ SInt32 bit_rate = avctx->bit_rate; ++ CFNumberRef bit_rate_num = CFNumberCreate(kCFAllocatorDefault, ++ kCFNumberSInt32Type, ++ &bit_rate); ++ if (!bit_rate_num) return; ++ ++ if (vtctx->constant_bit_rate) { ++ int status = VTSessionSetProperty(vtctx->session, ++ compat_keys.kVTCompressionPropertyKey_ConstantBitRate, ++ bit_rate_num); ++ if (status == kVTPropertyNotSupportedErr) { ++ av_log(avctx, AV_LOG_ERROR, "Error: -constant_bit_rate true is not supported by the encoder.\n"); ++ } ++ } else { ++ int status = VTSessionSetProperty(vtctx->session, ++ kVTCompressionPropertyKey_AverageBitRate, ++ bit_rate_num); ++ if (status) { ++ av_log(avctx, AV_LOG_ERROR, "Error: cannot set average bit rate: %d\n", status); ++ } ++ } ++ ++ CFRelease(bit_rate_num); ++ } ++ } ++} ++ ++ + static av_cold int vtenc_frame( + AVCodecContext *avctx, + AVPacket *pkt, +@@ -2650,6 +2689,7 @@ static av_cold int vtenc_frame( + CMSampleBufferRef buf = NULL; + ExtraSEI sei = {0}; + ++ update_config(avctx); + if (frame) { + status = vtenc_send_frame(avctx, vtctx, frame); + +-- +2.43.0.windows.1 + diff --git a/res/vcpkg/ffmpeg/patch/0005-mediacodec-changing-bitrate.patch b/res/vcpkg/ffmpeg/patch/0005-mediacodec-changing-bitrate.patch new file mode 100644 index 000000000..4a552dda0 --- /dev/null +++ b/res/vcpkg/ffmpeg/patch/0005-mediacodec-changing-bitrate.patch @@ -0,0 +1,246 @@ +From 7323bd68c1b34e9298ea557ff7a3e1883b653957 Mon Sep 17 00:00:00 2001 +From: 21pages +Date: Tue, 10 Dec 2024 14:28:16 +0800 +Subject: [PATCH 4/5] mediacodec changing bitrate + +Signed-off-by: 21pages +--- + libavcodec/mediacodec_wrapper.c | 98 +++++++++++++++++++++++++++++++++ + libavcodec/mediacodec_wrapper.h | 7 +++ + libavcodec/mediacodecenc.c | 18 ++++++ + 3 files changed, 123 insertions(+) + +diff --git a/libavcodec/mediacodec_wrapper.c b/libavcodec/mediacodec_wrapper.c +index 96c886666a..06b8504304 100644 +--- a/libavcodec/mediacodec_wrapper.c ++++ b/libavcodec/mediacodec_wrapper.c +@@ -35,6 +35,8 @@ + #include "ffjni.h" + #include "mediacodec_wrapper.h" + ++#define PARAMETER_KEY_VIDEO_BITRATE "video-bitrate" ++ + struct JNIAMediaCodecListFields { + + jclass mediacodec_list_class; +@@ -195,6 +197,8 @@ struct JNIAMediaCodecFields { + jmethodID set_input_surface_id; + jmethodID signal_end_of_input_stream_id; + ++ jmethodID set_parameters_id; ++ + jclass mediainfo_class; + + jmethodID init_id; +@@ -248,6 +252,8 @@ static const struct FFJniField jni_amediacodec_mapping[] = { + { "android/media/MediaCodec", "setInputSurface", "(Landroid/view/Surface;)V", FF_JNI_METHOD, OFFSET(set_input_surface_id), 0 }, + { "android/media/MediaCodec", "signalEndOfInputStream", "()V", FF_JNI_METHOD, OFFSET(signal_end_of_input_stream_id), 0 }, + ++ { "android/media/MediaCodec", "setParameters", "(Landroid/os/Bundle;)V", FF_JNI_METHOD, OFFSET(set_parameters_id), 0 }, ++ + { "android/media/MediaCodec$BufferInfo", NULL, NULL, FF_JNI_CLASS, OFFSET(mediainfo_class), 1 }, + + { "android/media/MediaCodec.BufferInfo", "", "()V", FF_JNI_METHOD, OFFSET(init_id), 1 }, +@@ -292,6 +298,24 @@ typedef struct FFAMediaCodecJni { + + static const FFAMediaCodec media_codec_jni; + ++struct JNIABundleFields ++{ ++ jclass bundle_class; ++ jmethodID init_id; ++ jmethodID put_int_id; ++}; ++ ++#define OFFSET(x) offsetof(struct JNIABundleFields, x) ++static const struct FFJniField jni_abundle_mapping[] = { ++ { "android/os/Bundle", NULL, NULL, FF_JNI_CLASS, OFFSET(bundle_class), 1 }, ++ ++ { "android/os/Bundle", "", "()V", FF_JNI_METHOD, OFFSET(init_id), 1 }, ++ { "android/os/Bundle", "putInt", "(Ljava/lang/String;I)V", FF_JNI_METHOD, OFFSET(put_int_id), 1 }, ++ ++ { NULL } ++}; ++#undef OFFSET ++ + #define JNI_GET_ENV_OR_RETURN(env, log_ctx, ret) do { \ + (env) = ff_jni_get_env(log_ctx); \ + if (!(env)) { \ +@@ -1762,6 +1786,70 @@ static int mediacodec_jni_signalEndOfInputStream(FFAMediaCodec *ctx) + return 0; + } + ++ ++static int mediacodec_jni_setParameter(FFAMediaCodec *ctx, const char* name, int value) ++{ ++ JNIEnv *env = NULL; ++ struct JNIABundleFields jfields = { 0 }; ++ jobject object = NULL; ++ jstring key = NULL; ++ FFAMediaCodecJni *codec = (FFAMediaCodecJni *)ctx; ++ void *log_ctx = codec; ++ int ret = -1; ++ ++ JNI_GET_ENV_OR_RETURN(env, codec, AVERROR_EXTERNAL); ++ ++ if (ff_jni_init_jfields(env, &jfields, jni_abundle_mapping, 0, log_ctx) < 0) { ++ av_log(log_ctx, AV_LOG_ERROR, "Failed to init jfields\n"); ++ goto fail; ++ } ++ ++ object = (*env)->NewObject(env, jfields.bundle_class, jfields.init_id); ++ if (!object) { ++ av_log(log_ctx, AV_LOG_ERROR, "Failed to create bundle object\n"); ++ goto fail; ++ } ++ ++ key = ff_jni_utf_chars_to_jstring(env, name, log_ctx); ++ if (!key) { ++ av_log(log_ctx, AV_LOG_ERROR, "Failed to convert key to jstring\n"); ++ goto fail; ++ } ++ ++ (*env)->CallVoidMethod(env, object, jfields.put_int_id, key, value); ++ if (ff_jni_exception_check(env, 1, log_ctx) < 0) { ++ goto fail; ++ } ++ ++ if (!codec->jfields.set_parameters_id) { ++ av_log(log_ctx, AV_LOG_ERROR, "System doesn't support setParameters\n"); ++ goto fail; ++ } ++ ++ (*env)->CallVoidMethod(env, codec->object, codec->jfields.set_parameters_id, object); ++ if (ff_jni_exception_check(env, 1, log_ctx) < 0) { ++ goto fail; ++ } ++ ++ ret = 0; ++ ++fail: ++ if (key) { ++ (*env)->DeleteLocalRef(env, key); ++ } ++ if (object) { ++ (*env)->DeleteLocalRef(env, object); ++ } ++ ff_jni_reset_jfields(env, &jfields, jni_abundle_mapping, 0, log_ctx); ++ ++ return ret; ++} ++ ++static int mediacodec_jni_setDynamicBitrate(FFAMediaCodec *ctx, int bitrate) ++{ ++ return mediacodec_jni_setParameter(ctx, PARAMETER_KEY_VIDEO_BITRATE, bitrate); ++} ++ + static const FFAMediaFormat media_format_jni = { + .class = &amediaformat_class, + +@@ -1821,6 +1909,8 @@ static const FFAMediaCodec media_codec_jni = { + .getConfigureFlagEncode = mediacodec_jni_getConfigureFlagEncode, + .cleanOutputBuffers = mediacodec_jni_cleanOutputBuffers, + .signalEndOfInputStream = mediacodec_jni_signalEndOfInputStream, ++ ++ .setDynamicBitrate = mediacodec_jni_setDynamicBitrate, + }; + + typedef struct FFAMediaFormatNdk { +@@ -2335,6 +2425,12 @@ static int mediacodec_ndk_signalEndOfInputStream(FFAMediaCodec *ctx) + return 0; + } + ++static int mediacodec_ndk_setDynamicBitrate(FFAMediaCodec *ctx, int bitrate) ++{ ++ av_log(ctx, AV_LOG_ERROR, "ndk setDynamicBitrate unavailable\n"); ++ return -1; ++} ++ + static const FFAMediaFormat media_format_ndk = { + .class = &amediaformat_ndk_class, + +@@ -2396,6 +2492,8 @@ static const FFAMediaCodec media_codec_ndk = { + .getConfigureFlagEncode = mediacodec_ndk_getConfigureFlagEncode, + .cleanOutputBuffers = mediacodec_ndk_cleanOutputBuffers, + .signalEndOfInputStream = mediacodec_ndk_signalEndOfInputStream, ++ ++ .setDynamicBitrate = mediacodec_ndk_setDynamicBitrate, + }; + + FFAMediaFormat *ff_AMediaFormat_new(int ndk) +diff --git a/libavcodec/mediacodec_wrapper.h b/libavcodec/mediacodec_wrapper.h +index 11a4260497..86c64556ad 100644 +--- a/libavcodec/mediacodec_wrapper.h ++++ b/libavcodec/mediacodec_wrapper.h +@@ -219,6 +219,8 @@ struct FFAMediaCodec { + + // For encoder with FFANativeWindow as input. + int (*signalEndOfInputStream)(FFAMediaCodec *); ++ ++ int (*setDynamicBitrate)(FFAMediaCodec *codec, int bitrate); + }; + + static inline char *ff_AMediaCodec_getName(FFAMediaCodec *codec) +@@ -343,6 +345,11 @@ static inline int ff_AMediaCodec_signalEndOfInputStream(FFAMediaCodec *codec) + return codec->signalEndOfInputStream(codec); + } + ++static inline int ff_AMediaCodec_setDynamicBitrate(FFAMediaCodec *codec, int bitrate) ++{ ++ return codec->setDynamicBitrate(codec, bitrate); ++} ++ + int ff_Build_SDK_INT(AVCodecContext *avctx); + + enum FFAMediaFormatColorRange { +diff --git a/libavcodec/mediacodecenc.c b/libavcodec/mediacodecenc.c +index 6ca3968a24..221f7360f4 100644 +--- a/libavcodec/mediacodecenc.c ++++ b/libavcodec/mediacodecenc.c +@@ -76,6 +76,8 @@ typedef struct MediaCodecEncContext { + int level; + int pts_as_dts; + int extract_extradata; ++ ++ int last_bit_rate; + } MediaCodecEncContext; + + enum { +@@ -193,6 +195,8 @@ static av_cold int mediacodec_init(AVCodecContext *avctx) + int ret; + int gop; + ++ s->last_bit_rate = avctx->bit_rate; ++ + if (s->use_ndk_codec < 0) + s->use_ndk_codec = !av_jni_get_java_vm(avctx); + +@@ -542,11 +546,25 @@ static int mediacodec_send(AVCodecContext *avctx, + return 0; + } + ++static void update_config(AVCodecContext *avctx) ++{ ++ MediaCodecEncContext *s = avctx->priv_data; ++ if (avctx->bit_rate != s->last_bit_rate) { ++ s->last_bit_rate = avctx->bit_rate; ++ if (0 != ff_AMediaCodec_setDynamicBitrate(s->codec, avctx->bit_rate)) { ++ av_log(avctx, AV_LOG_ERROR, "Failed to set bitrate to %d\n", avctx->bit_rate); ++ } else { ++ av_log(avctx, AV_LOG_INFO, "Set bitrate to %d\n", avctx->bit_rate); ++ } ++ } ++} ++ + static int mediacodec_encode(AVCodecContext *avctx, AVPacket *pkt) + { + MediaCodecEncContext *s = avctx->priv_data; + int ret; + ++ update_config(avctx); + // Return on three case: + // 1. Serious error + // 2. Got a packet success +-- +2.43.0.windows.1 + diff --git a/res/vcpkg/ffmpeg/patch/0006-dlopen-libva.patch b/res/vcpkg/ffmpeg/patch/0006-dlopen-libva.patch new file mode 100644 index 000000000..a62be5a81 --- /dev/null +++ b/res/vcpkg/ffmpeg/patch/0006-dlopen-libva.patch @@ -0,0 +1,1883 @@ +From 95ebc0ad912447ba83cacb197f506b881f82179e Mon Sep 17 00:00:00 2001 +From: 21pages +Date: Tue, 10 Dec 2024 15:29:21 +0800 +Subject: [PATCH 1/2] dlopen libva + +Signed-off-by: 21pages +--- + libavcodec/vaapi_decode.c | 96 ++++++----- + libavcodec/vaapi_encode.c | 173 ++++++++++--------- + libavcodec/vaapi_encode_h264.c | 3 +- + libavcodec/vaapi_encode_h265.c | 6 +- + libavutil/hwcontext_vaapi.c | 292 ++++++++++++++++++++++++--------- + libavutil/hwcontext_vaapi.h | 96 +++++++++++ + 6 files changed, 477 insertions(+), 189 deletions(-) + +diff --git a/libavcodec/vaapi_decode.c b/libavcodec/vaapi_decode.c +index a59194340f..e202b673f4 100644 +--- a/libavcodec/vaapi_decode.c ++++ b/libavcodec/vaapi_decode.c +@@ -38,17 +38,18 @@ int ff_vaapi_decode_make_param_buffer(AVCodecContext *avctx, + size_t size) + { + VAAPIDecodeContext *ctx = avctx->internal->hwaccel_priv_data; ++ VAAPIDynLoadFunctions *vaf = ctx->hwctx->funcs; + VAStatus vas; + VABufferID buffer; + + av_assert0(pic->nb_param_buffers + 1 <= MAX_PARAM_BUFFERS); + +- vas = vaCreateBuffer(ctx->hwctx->display, ctx->va_context, ++ vas = vaf->vaCreateBuffer(ctx->hwctx->display, ctx->va_context, + type, size, 1, (void*)data, &buffer); + if (vas != VA_STATUS_SUCCESS) { + av_log(avctx, AV_LOG_ERROR, "Failed to create parameter " + "buffer (type %d): %d (%s).\n", +- type, vas, vaErrorStr(vas)); ++ type, vas, vaf->vaErrorStr(vas)); + return AVERROR(EIO); + } + +@@ -69,6 +70,7 @@ int ff_vaapi_decode_make_slice_buffer(AVCodecContext *avctx, + size_t slice_size) + { + VAAPIDecodeContext *ctx = avctx->internal->hwaccel_priv_data; ++ VAAPIDynLoadFunctions *vaf = ctx->hwctx->funcs; + VAStatus vas; + int index; + +@@ -88,13 +90,13 @@ int ff_vaapi_decode_make_slice_buffer(AVCodecContext *avctx, + + index = 2 * pic->nb_slices; + +- vas = vaCreateBuffer(ctx->hwctx->display, ctx->va_context, ++ vas = vaf->vaCreateBuffer(ctx->hwctx->display, ctx->va_context, + VASliceParameterBufferType, + params_size, nb_params, (void*)params_data, + &pic->slice_buffers[index]); + if (vas != VA_STATUS_SUCCESS) { + av_log(avctx, AV_LOG_ERROR, "Failed to create slice " +- "parameter buffer: %d (%s).\n", vas, vaErrorStr(vas)); ++ "parameter buffer: %d (%s).\n", vas, vaf->vaErrorStr(vas)); + return AVERROR(EIO); + } + +@@ -102,15 +104,15 @@ int ff_vaapi_decode_make_slice_buffer(AVCodecContext *avctx, + "is %#x.\n", pic->nb_slices, params_size, + pic->slice_buffers[index]); + +- vas = vaCreateBuffer(ctx->hwctx->display, ctx->va_context, ++ vas = vaf->vaCreateBuffer(ctx->hwctx->display, ctx->va_context, + VASliceDataBufferType, + slice_size, 1, (void*)slice_data, + &pic->slice_buffers[index + 1]); + if (vas != VA_STATUS_SUCCESS) { + av_log(avctx, AV_LOG_ERROR, "Failed to create slice " + "data buffer (size %zu): %d (%s).\n", +- slice_size, vas, vaErrorStr(vas)); +- vaDestroyBuffer(ctx->hwctx->display, ++ slice_size, vas, vaf->vaErrorStr(vas)); ++ vaf->vaDestroyBuffer(ctx->hwctx->display, + pic->slice_buffers[index]); + return AVERROR(EIO); + } +@@ -127,26 +129,27 @@ static void ff_vaapi_decode_destroy_buffers(AVCodecContext *avctx, + VAAPIDecodePicture *pic) + { + VAAPIDecodeContext *ctx = avctx->internal->hwaccel_priv_data; ++ VAAPIDynLoadFunctions *vaf = ctx->hwctx->funcs; + VAStatus vas; + int i; + + for (i = 0; i < pic->nb_param_buffers; i++) { +- vas = vaDestroyBuffer(ctx->hwctx->display, ++ vas = vaf->vaDestroyBuffer(ctx->hwctx->display, + pic->param_buffers[i]); + if (vas != VA_STATUS_SUCCESS) { + av_log(avctx, AV_LOG_ERROR, "Failed to destroy " + "parameter buffer %#x: %d (%s).\n", +- pic->param_buffers[i], vas, vaErrorStr(vas)); ++ pic->param_buffers[i], vas, vaf->vaErrorStr(vas)); + } + } + + for (i = 0; i < 2 * pic->nb_slices; i++) { +- vas = vaDestroyBuffer(ctx->hwctx->display, ++ vas = vaf->vaDestroyBuffer(ctx->hwctx->display, + pic->slice_buffers[i]); + if (vas != VA_STATUS_SUCCESS) { + av_log(avctx, AV_LOG_ERROR, "Failed to destroy slice " + "slice buffer %#x: %d (%s).\n", +- pic->slice_buffers[i], vas, vaErrorStr(vas)); ++ pic->slice_buffers[i], vas, vaf->vaErrorStr(vas)); + } + } + } +@@ -155,6 +158,7 @@ int ff_vaapi_decode_issue(AVCodecContext *avctx, + VAAPIDecodePicture *pic) + { + VAAPIDecodeContext *ctx = avctx->internal->hwaccel_priv_data; ++ VAAPIDynLoadFunctions *vaf = ctx->hwctx->funcs; + VAStatus vas; + int err; + +@@ -166,37 +170,37 @@ int ff_vaapi_decode_issue(AVCodecContext *avctx, + av_log(avctx, AV_LOG_DEBUG, "Decode to surface %#x.\n", + pic->output_surface); + +- vas = vaBeginPicture(ctx->hwctx->display, ctx->va_context, ++ vas = vaf->vaBeginPicture(ctx->hwctx->display, ctx->va_context, + pic->output_surface); + if (vas != VA_STATUS_SUCCESS) { + av_log(avctx, AV_LOG_ERROR, "Failed to begin picture decode " +- "issue: %d (%s).\n", vas, vaErrorStr(vas)); ++ "issue: %d (%s).\n", vas, vaf->vaErrorStr(vas)); + err = AVERROR(EIO); + goto fail_with_picture; + } + +- vas = vaRenderPicture(ctx->hwctx->display, ctx->va_context, ++ vas = vaf->vaRenderPicture(ctx->hwctx->display, ctx->va_context, + pic->param_buffers, pic->nb_param_buffers); + if (vas != VA_STATUS_SUCCESS) { + av_log(avctx, AV_LOG_ERROR, "Failed to upload decode " +- "parameters: %d (%s).\n", vas, vaErrorStr(vas)); ++ "parameters: %d (%s).\n", vas, vaf->vaErrorStr(vas)); + err = AVERROR(EIO); + goto fail_with_picture; + } + +- vas = vaRenderPicture(ctx->hwctx->display, ctx->va_context, ++ vas = vaf->vaRenderPicture(ctx->hwctx->display, ctx->va_context, + pic->slice_buffers, 2 * pic->nb_slices); + if (vas != VA_STATUS_SUCCESS) { + av_log(avctx, AV_LOG_ERROR, "Failed to upload slices: " +- "%d (%s).\n", vas, vaErrorStr(vas)); ++ "%d (%s).\n", vas, vaf->vaErrorStr(vas)); + err = AVERROR(EIO); + goto fail_with_picture; + } + +- vas = vaEndPicture(ctx->hwctx->display, ctx->va_context); ++ vas = vaf->vaEndPicture(ctx->hwctx->display, ctx->va_context); + if (vas != VA_STATUS_SUCCESS) { + av_log(avctx, AV_LOG_ERROR, "Failed to end picture decode " +- "issue: %d (%s).\n", vas, vaErrorStr(vas)); ++ "issue: %d (%s).\n", vas, vaf->vaErrorStr(vas)); + err = AVERROR(EIO); + if (CONFIG_VAAPI_1 || ctx->hwctx->driver_quirks & + AV_VAAPI_DRIVER_QUIRK_RENDER_PARAM_BUFFERS) +@@ -213,10 +217,10 @@ int ff_vaapi_decode_issue(AVCodecContext *avctx, + goto exit; + + fail_with_picture: +- vas = vaEndPicture(ctx->hwctx->display, ctx->va_context); ++ vas = vaf->vaEndPicture(ctx->hwctx->display, ctx->va_context); + if (vas != VA_STATUS_SUCCESS) { + av_log(avctx, AV_LOG_ERROR, "Failed to end picture decode " +- "after error: %d (%s).\n", vas, vaErrorStr(vas)); ++ "after error: %d (%s).\n", vas, vaf->vaErrorStr(vas)); + } + fail: + ff_vaapi_decode_destroy_buffers(avctx, pic); +@@ -304,6 +308,7 @@ static int vaapi_decode_find_best_format(AVCodecContext *avctx, + AVHWFramesContext *frames) + { + AVVAAPIDeviceContext *hwctx = device->hwctx; ++ VAAPIDynLoadFunctions *vaf = hwctx->funcs; + VAStatus vas; + VASurfaceAttrib *attr; + enum AVPixelFormat source_format, best_format, format; +@@ -313,11 +318,11 @@ static int vaapi_decode_find_best_format(AVCodecContext *avctx, + source_format = avctx->sw_pix_fmt; + av_assert0(source_format != AV_PIX_FMT_NONE); + +- vas = vaQuerySurfaceAttributes(hwctx->display, config_id, ++ vas = vaf->vaQuerySurfaceAttributes(hwctx->display, config_id, + NULL, &nb_attr); + if (vas != VA_STATUS_SUCCESS) { + av_log(avctx, AV_LOG_ERROR, "Failed to query surface attributes: " +- "%d (%s).\n", vas, vaErrorStr(vas)); ++ "%d (%s).\n", vas, vaf->vaErrorStr(vas)); + return AVERROR(ENOSYS); + } + +@@ -325,11 +330,11 @@ static int vaapi_decode_find_best_format(AVCodecContext *avctx, + if (!attr) + return AVERROR(ENOMEM); + +- vas = vaQuerySurfaceAttributes(hwctx->display, config_id, ++ vas = vaf->vaQuerySurfaceAttributes(hwctx->display, config_id, + attr, &nb_attr); + if (vas != VA_STATUS_SUCCESS) { + av_log(avctx, AV_LOG_ERROR, "Failed to query surface attributes: " +- "%d (%s).\n", vas, vaErrorStr(vas)); ++ "%d (%s).\n", vas, vaf->vaErrorStr(vas)); + av_freep(&attr); + return AVERROR(ENOSYS); + } +@@ -471,6 +476,7 @@ static int vaapi_decode_make_config(AVCodecContext *avctx, + + AVHWDeviceContext *device = (AVHWDeviceContext*)device_ref->data; + AVVAAPIDeviceContext *hwctx = device->hwctx; ++ VAAPIDynLoadFunctions *vaf = hwctx->funcs; + + codec_desc = avcodec_descriptor_get(avctx->codec_id); + if (!codec_desc) { +@@ -478,7 +484,7 @@ static int vaapi_decode_make_config(AVCodecContext *avctx, + goto fail; + } + +- profile_count = vaMaxNumProfiles(hwctx->display); ++ profile_count = vaf->vaMaxNumProfiles(hwctx->display); + profile_list = av_malloc_array(profile_count, + sizeof(VAProfile)); + if (!profile_list) { +@@ -486,11 +492,11 @@ static int vaapi_decode_make_config(AVCodecContext *avctx, + goto fail; + } + +- vas = vaQueryConfigProfiles(hwctx->display, ++ vas = vaf->vaQueryConfigProfiles(hwctx->display, + profile_list, &profile_count); + if (vas != VA_STATUS_SUCCESS) { + av_log(avctx, AV_LOG_ERROR, "Failed to query profiles: " +- "%d (%s).\n", vas, vaErrorStr(vas)); ++ "%d (%s).\n", vas, vaf->vaErrorStr(vas)); + err = AVERROR(ENOSYS); + goto fail; + } +@@ -550,12 +556,12 @@ static int vaapi_decode_make_config(AVCodecContext *avctx, + } + } + +- vas = vaCreateConfig(hwctx->display, matched_va_profile, ++ vas = vaf->vaCreateConfig(hwctx->display, matched_va_profile, + VAEntrypointVLD, NULL, 0, + va_config); + if (vas != VA_STATUS_SUCCESS) { + av_log(avctx, AV_LOG_ERROR, "Failed to create decode " +- "configuration: %d (%s).\n", vas, vaErrorStr(vas)); ++ "configuration: %d (%s).\n", vas, vaf->vaErrorStr(vas)); + err = AVERROR(EIO); + goto fail; + } +@@ -638,7 +644,7 @@ fail: + av_hwframe_constraints_free(&constraints); + av_freep(&hwconfig); + if (*va_config != VA_INVALID_ID) { +- vaDestroyConfig(hwctx->display, *va_config); ++ vaf->vaDestroyConfig(hwctx->display, *va_config); + *va_config = VA_INVALID_ID; + } + av_freep(&profile_list); +@@ -651,12 +657,14 @@ int ff_vaapi_common_frame_params(AVCodecContext *avctx, + AVHWFramesContext *hw_frames = (AVHWFramesContext *)hw_frames_ctx->data; + AVHWDeviceContext *device_ctx = hw_frames->device_ctx; + AVVAAPIDeviceContext *hwctx; ++ VAAPIDynLoadFunctions *vaf; + VAConfigID va_config = VA_INVALID_ID; + int err; + + if (device_ctx->type != AV_HWDEVICE_TYPE_VAAPI) + return AVERROR(EINVAL); + hwctx = device_ctx->hwctx; ++ vaf = hwctx->funcs; + + err = vaapi_decode_make_config(avctx, hw_frames->device_ref, &va_config, + hw_frames_ctx); +@@ -664,7 +672,7 @@ int ff_vaapi_common_frame_params(AVCodecContext *avctx, + return err; + + if (va_config != VA_INVALID_ID) +- vaDestroyConfig(hwctx->display, va_config); ++ vaf->vaDestroyConfig(hwctx->display, va_config); + + return 0; + } +@@ -672,6 +680,7 @@ int ff_vaapi_common_frame_params(AVCodecContext *avctx, + int ff_vaapi_decode_init(AVCodecContext *avctx) + { + VAAPIDecodeContext *ctx = avctx->internal->hwaccel_priv_data; ++ VAAPIDynLoadFunctions *vaf; + VAStatus vas; + int err; + +@@ -686,13 +695,18 @@ int ff_vaapi_decode_init(AVCodecContext *avctx) + ctx->hwfc = ctx->frames->hwctx; + ctx->device = ctx->frames->device_ctx; + ctx->hwctx = ctx->device->hwctx; ++ if (!ctx->hwctx || !ctx->hwctx->funcs) { ++ err = AVERROR(EINVAL); ++ goto fail; ++ } ++ vaf = ctx->hwctx->funcs; + + err = vaapi_decode_make_config(avctx, ctx->frames->device_ref, + &ctx->va_config, NULL); + if (err) + goto fail; + +- vas = vaCreateContext(ctx->hwctx->display, ctx->va_config, ++ vas = vaf->vaCreateContext(ctx->hwctx->display, ctx->va_config, + avctx->coded_width, avctx->coded_height, + VA_PROGRESSIVE, + ctx->hwfc->surface_ids, +@@ -700,7 +714,7 @@ int ff_vaapi_decode_init(AVCodecContext *avctx) + &ctx->va_context); + if (vas != VA_STATUS_SUCCESS) { + av_log(avctx, AV_LOG_ERROR, "Failed to create decode " +- "context: %d (%s).\n", vas, vaErrorStr(vas)); ++ "context: %d (%s).\n", vas, vaf->vaErrorStr(vas)); + err = AVERROR(EIO); + goto fail; + } +@@ -718,22 +732,28 @@ fail: + int ff_vaapi_decode_uninit(AVCodecContext *avctx) + { + VAAPIDecodeContext *ctx = avctx->internal->hwaccel_priv_data; ++ VAAPIDynLoadFunctions *vaf = NULL; + VAStatus vas; + ++ if (ctx->hwctx && ctx->hwctx->funcs) ++ vaf = ctx->hwctx->funcs; ++ if (!vaf) ++ return 0; ++ + if (ctx->va_context != VA_INVALID_ID) { +- vas = vaDestroyContext(ctx->hwctx->display, ctx->va_context); ++ vas = vaf->vaDestroyContext(ctx->hwctx->display, ctx->va_context); + if (vas != VA_STATUS_SUCCESS) { + av_log(avctx, AV_LOG_ERROR, "Failed to destroy decode " + "context %#x: %d (%s).\n", +- ctx->va_context, vas, vaErrorStr(vas)); ++ ctx->va_context, vas, vaf->vaErrorStr(vas)); + } + } + if (ctx->va_config != VA_INVALID_ID) { +- vas = vaDestroyConfig(ctx->hwctx->display, ctx->va_config); ++ vas = vaf->vaDestroyConfig(ctx->hwctx->display, ctx->va_config); + if (vas != VA_STATUS_SUCCESS) { + av_log(avctx, AV_LOG_ERROR, "Failed to destroy decode " + "configuration %#x: %d (%s).\n", +- ctx->va_config, vas, vaErrorStr(vas)); ++ ctx->va_config, vas, vaf->vaErrorStr(vas)); + } + } + +diff --git a/libavcodec/vaapi_encode.c b/libavcodec/vaapi_encode.c +index 16a9a364f0..ccf6fa59d6 100644 +--- a/libavcodec/vaapi_encode.c ++++ b/libavcodec/vaapi_encode.c +@@ -43,6 +43,7 @@ static int vaapi_encode_make_packed_header(AVCodecContext *avctx, + int type, char *data, size_t bit_len) + { + VAAPIEncodeContext *ctx = avctx->priv_data; ++ VAAPIDynLoadFunctions *vaf = ctx->hwctx->funcs; + VAStatus vas; + VABufferID param_buffer, data_buffer; + VABufferID *tmp; +@@ -57,24 +58,24 @@ static int vaapi_encode_make_packed_header(AVCodecContext *avctx, + return AVERROR(ENOMEM); + pic->param_buffers = tmp; + +- vas = vaCreateBuffer(ctx->hwctx->display, ctx->va_context, ++ vas = vaf->vaCreateBuffer(ctx->hwctx->display, ctx->va_context, + VAEncPackedHeaderParameterBufferType, + sizeof(params), 1, ¶ms, ¶m_buffer); + if (vas != VA_STATUS_SUCCESS) { + av_log(avctx, AV_LOG_ERROR, "Failed to create parameter buffer " + "for packed header (type %d): %d (%s).\n", +- type, vas, vaErrorStr(vas)); ++ type, vas, vaf->vaErrorStr(vas)); + return AVERROR(EIO); + } + pic->param_buffers[pic->nb_param_buffers++] = param_buffer; + +- vas = vaCreateBuffer(ctx->hwctx->display, ctx->va_context, ++ vas = vaf->vaCreateBuffer(ctx->hwctx->display, ctx->va_context, + VAEncPackedHeaderDataBufferType, + (bit_len + 7) / 8, 1, data, &data_buffer); + if (vas != VA_STATUS_SUCCESS) { + av_log(avctx, AV_LOG_ERROR, "Failed to create data buffer " + "for packed header (type %d): %d (%s).\n", +- type, vas, vaErrorStr(vas)); ++ type, vas, vaf->vaErrorStr(vas)); + return AVERROR(EIO); + } + pic->param_buffers[pic->nb_param_buffers++] = data_buffer; +@@ -89,6 +90,7 @@ static int vaapi_encode_make_param_buffer(AVCodecContext *avctx, + int type, char *data, size_t len) + { + VAAPIEncodeContext *ctx = avctx->priv_data; ++ VAAPIDynLoadFunctions *vaf = ctx->hwctx->funcs; + VAStatus vas; + VABufferID *tmp; + VABufferID buffer; +@@ -98,11 +100,11 @@ static int vaapi_encode_make_param_buffer(AVCodecContext *avctx, + return AVERROR(ENOMEM); + pic->param_buffers = tmp; + +- vas = vaCreateBuffer(ctx->hwctx->display, ctx->va_context, ++ vas = vaf->vaCreateBuffer(ctx->hwctx->display, ctx->va_context, + type, len, 1, data, &buffer); + if (vas != VA_STATUS_SUCCESS) { + av_log(avctx, AV_LOG_ERROR, "Failed to create parameter buffer " +- "(type %d): %d (%s).\n", type, vas, vaErrorStr(vas)); ++ "(type %d): %d (%s).\n", type, vas, vaf->vaErrorStr(vas)); + return AVERROR(EIO); + } + pic->param_buffers[pic->nb_param_buffers++] = buffer; +@@ -141,6 +143,7 @@ static int vaapi_encode_wait(AVCodecContext *avctx, FFHWBaseEncodePicture *base_ + FFHWBaseEncodeContext *base_ctx = avctx->priv_data; + #endif + VAAPIEncodeContext *ctx = avctx->priv_data; ++ VAAPIDynLoadFunctions *vaf = ctx->hwctx->funcs; + VAAPIEncodePicture *pic = base_pic->priv; + VAStatus vas; + +@@ -156,22 +159,22 @@ static int vaapi_encode_wait(AVCodecContext *avctx, FFHWBaseEncodePicture *base_ + base_pic->encode_order, pic->input_surface); + + #if VA_CHECK_VERSION(1, 9, 0) +- if (base_ctx->async_encode) { +- vas = vaSyncBuffer(ctx->hwctx->display, ++ if (base_ctx->async_encode && vaf->vaSyncBuffer) { ++ vas = vaf->vaSyncBuffer(ctx->hwctx->display, + pic->output_buffer, + VA_TIMEOUT_INFINITE); + if (vas != VA_STATUS_SUCCESS) { + av_log(avctx, AV_LOG_ERROR, "Failed to sync to output buffer completion: " +- "%d (%s).\n", vas, vaErrorStr(vas)); ++ "%d (%s).\n", vas, vaf->vaErrorStr(vas)); + return AVERROR(EIO); + } + } else + #endif + { // If vaSyncBuffer is not implemented, try old version API. +- vas = vaSyncSurface(ctx->hwctx->display, pic->input_surface); ++ vas = vaf->vaSyncSurface(ctx->hwctx->display, pic->input_surface); + if (vas != VA_STATUS_SUCCESS) { + av_log(avctx, AV_LOG_ERROR, "Failed to sync to picture completion: " +- "%d (%s).\n", vas, vaErrorStr(vas)); ++ "%d (%s).\n", vas, vaf->vaErrorStr(vas)); + return AVERROR(EIO); + } + } +@@ -270,6 +273,7 @@ static int vaapi_encode_issue(AVCodecContext *avctx, + { + FFHWBaseEncodeContext *base_ctx = avctx->priv_data; + VAAPIEncodeContext *ctx = avctx->priv_data; ++ VAAPIDynLoadFunctions *vaf = ctx->hwctx->funcs; + VAAPIEncodePicture *pic = base_pic->priv; + VAAPIEncodeSlice *slice; + VAStatus vas; +@@ -587,28 +591,28 @@ static int vaapi_encode_issue(AVCodecContext *avctx, + } + #endif + +- vas = vaBeginPicture(ctx->hwctx->display, ctx->va_context, ++ vas = vaf->vaBeginPicture(ctx->hwctx->display, ctx->va_context, + pic->input_surface); + if (vas != VA_STATUS_SUCCESS) { + av_log(avctx, AV_LOG_ERROR, "Failed to begin picture encode issue: " +- "%d (%s).\n", vas, vaErrorStr(vas)); ++ "%d (%s).\n", vas, vaf->vaErrorStr(vas)); + err = AVERROR(EIO); + goto fail_with_picture; + } + +- vas = vaRenderPicture(ctx->hwctx->display, ctx->va_context, ++ vas = vaf->vaRenderPicture(ctx->hwctx->display, ctx->va_context, + pic->param_buffers, pic->nb_param_buffers); + if (vas != VA_STATUS_SUCCESS) { + av_log(avctx, AV_LOG_ERROR, "Failed to upload encode parameters: " +- "%d (%s).\n", vas, vaErrorStr(vas)); ++ "%d (%s).\n", vas, vaf->vaErrorStr(vas)); + err = AVERROR(EIO); + goto fail_with_picture; + } + +- vas = vaEndPicture(ctx->hwctx->display, ctx->va_context); ++ vas = vaf->vaEndPicture(ctx->hwctx->display, ctx->va_context); + if (vas != VA_STATUS_SUCCESS) { + av_log(avctx, AV_LOG_ERROR, "Failed to end picture encode issue: " +- "%d (%s).\n", vas, vaErrorStr(vas)); ++ "%d (%s).\n", vas, vaf->vaErrorStr(vas)); + err = AVERROR(EIO); + // vaRenderPicture() has been called here, so we should not destroy + // the parameter buffers unless separate destruction is required. +@@ -622,12 +626,12 @@ static int vaapi_encode_issue(AVCodecContext *avctx, + if (CONFIG_VAAPI_1 || ctx->hwctx->driver_quirks & + AV_VAAPI_DRIVER_QUIRK_RENDER_PARAM_BUFFERS) { + for (i = 0; i < pic->nb_param_buffers; i++) { +- vas = vaDestroyBuffer(ctx->hwctx->display, ++ vas = vaf->vaDestroyBuffer(ctx->hwctx->display, + pic->param_buffers[i]); + if (vas != VA_STATUS_SUCCESS) { + av_log(avctx, AV_LOG_ERROR, "Failed to destroy " + "param buffer %#x: %d (%s).\n", +- pic->param_buffers[i], vas, vaErrorStr(vas)); ++ pic->param_buffers[i], vas, vaf->vaErrorStr(vas)); + // And ignore. + } + } +@@ -636,10 +640,10 @@ static int vaapi_encode_issue(AVCodecContext *avctx, + return 0; + + fail_with_picture: +- vaEndPicture(ctx->hwctx->display, ctx->va_context); ++ vaf->vaEndPicture(ctx->hwctx->display, ctx->va_context); + fail: + for(i = 0; i < pic->nb_param_buffers; i++) +- vaDestroyBuffer(ctx->hwctx->display, pic->param_buffers[i]); ++ vaf->vaDestroyBuffer(ctx->hwctx->display, pic->param_buffers[i]); + if (pic->slices) { + for (i = 0; i < pic->nb_slices; i++) + av_freep(&pic->slices[i].codec_slice_params); +@@ -657,16 +661,17 @@ fail_at_end: + static int vaapi_encode_get_coded_buffer_size(AVCodecContext *avctx, VABufferID buf_id) + { + VAAPIEncodeContext *ctx = avctx->priv_data; ++ VAAPIDynLoadFunctions *vaf = ctx->hwctx->funcs; + VACodedBufferSegment *buf_list, *buf; + int size = 0; + VAStatus vas; + int err; + +- vas = vaMapBuffer(ctx->hwctx->display, buf_id, ++ vas = vaf->vaMapBuffer(ctx->hwctx->display, buf_id, + (void**)&buf_list); + if (vas != VA_STATUS_SUCCESS) { + av_log(avctx, AV_LOG_ERROR, "Failed to map output buffers: " +- "%d (%s).\n", vas, vaErrorStr(vas)); ++ "%d (%s).\n", vas, vaf->vaErrorStr(vas)); + err = AVERROR(EIO); + return err; + } +@@ -674,10 +679,10 @@ static int vaapi_encode_get_coded_buffer_size(AVCodecContext *avctx, VABufferID + for (buf = buf_list; buf; buf = buf->next) + size += buf->size; + +- vas = vaUnmapBuffer(ctx->hwctx->display, buf_id); ++ vas = vaf->vaUnmapBuffer(ctx->hwctx->display, buf_id); + if (vas != VA_STATUS_SUCCESS) { + av_log(avctx, AV_LOG_ERROR, "Failed to unmap output buffers: " +- "%d (%s).\n", vas, vaErrorStr(vas)); ++ "%d (%s).\n", vas, vaf->vaErrorStr(vas)); + err = AVERROR(EIO); + return err; + } +@@ -689,15 +694,16 @@ static int vaapi_encode_get_coded_buffer_data(AVCodecContext *avctx, + VABufferID buf_id, uint8_t **dst) + { + VAAPIEncodeContext *ctx = avctx->priv_data; ++ VAAPIDynLoadFunctions *vaf = ctx->hwctx->funcs; + VACodedBufferSegment *buf_list, *buf; + VAStatus vas; + int err; + +- vas = vaMapBuffer(ctx->hwctx->display, buf_id, ++ vas = vaf->vaMapBuffer(ctx->hwctx->display, buf_id, + (void**)&buf_list); + if (vas != VA_STATUS_SUCCESS) { + av_log(avctx, AV_LOG_ERROR, "Failed to map output buffers: " +- "%d (%s).\n", vas, vaErrorStr(vas)); ++ "%d (%s).\n", vas, vaf->vaErrorStr(vas)); + err = AVERROR(EIO); + return err; + } +@@ -710,10 +716,10 @@ static int vaapi_encode_get_coded_buffer_data(AVCodecContext *avctx, + *dst += buf->size; + } + +- vas = vaUnmapBuffer(ctx->hwctx->display, buf_id); ++ vas = vaf->vaUnmapBuffer(ctx->hwctx->display, buf_id); + if (vas != VA_STATUS_SUCCESS) { + av_log(avctx, AV_LOG_ERROR, "Failed to unmap output buffers: " +- "%d (%s).\n", vas, vaErrorStr(vas)); ++ "%d (%s).\n", vas, vaf->vaErrorStr(vas)); + err = AVERROR(EIO); + return err; + } +@@ -936,6 +942,7 @@ static av_cold int vaapi_encode_profile_entrypoint(AVCodecContext *avctx) + { + FFHWBaseEncodeContext *base_ctx = avctx->priv_data; + VAAPIEncodeContext *ctx = avctx->priv_data; ++ VAAPIDynLoadFunctions *vaf = ctx->hwctx->funcs; + VAProfile *va_profiles = NULL; + VAEntrypoint *va_entrypoints = NULL; + VAStatus vas; +@@ -977,16 +984,16 @@ static av_cold int vaapi_encode_profile_entrypoint(AVCodecContext *avctx) + av_log(avctx, AV_LOG_VERBOSE, "Input surface format is %s.\n", + desc->name); + +- n = vaMaxNumProfiles(ctx->hwctx->display); ++ n = vaf->vaMaxNumProfiles(ctx->hwctx->display); + va_profiles = av_malloc_array(n, sizeof(VAProfile)); + if (!va_profiles) { + err = AVERROR(ENOMEM); + goto fail; + } +- vas = vaQueryConfigProfiles(ctx->hwctx->display, va_profiles, &n); ++ vas = vaf->vaQueryConfigProfiles(ctx->hwctx->display, va_profiles, &n); + if (vas != VA_STATUS_SUCCESS) { + av_log(avctx, AV_LOG_ERROR, "Failed to query profiles: %d (%s).\n", +- vas, vaErrorStr(vas)); ++ vas, vaf->vaErrorStr(vas)); + err = AVERROR_EXTERNAL; + goto fail; + } +@@ -1007,7 +1014,7 @@ static av_cold int vaapi_encode_profile_entrypoint(AVCodecContext *avctx) + continue; + + #if VA_CHECK_VERSION(1, 0, 0) +- profile_string = vaProfileStr(profile->va_profile); ++ profile_string = vaf->vaProfileStr(profile->va_profile); + #else + profile_string = "(no profile names)"; + #endif +@@ -1037,18 +1044,18 @@ static av_cold int vaapi_encode_profile_entrypoint(AVCodecContext *avctx) + av_log(avctx, AV_LOG_VERBOSE, "Using VAAPI profile %s (%d).\n", + profile_string, ctx->va_profile); + +- n = vaMaxNumEntrypoints(ctx->hwctx->display); ++ n = vaf->vaMaxNumEntrypoints(ctx->hwctx->display); + va_entrypoints = av_malloc_array(n, sizeof(VAEntrypoint)); + if (!va_entrypoints) { + err = AVERROR(ENOMEM); + goto fail; + } +- vas = vaQueryConfigEntrypoints(ctx->hwctx->display, ctx->va_profile, ++ vas = vaf->vaQueryConfigEntrypoints(ctx->hwctx->display, ctx->va_profile, + va_entrypoints, &n); + if (vas != VA_STATUS_SUCCESS) { + av_log(avctx, AV_LOG_ERROR, "Failed to query entrypoints for " + "profile %s (%d): %d (%s).\n", profile_string, +- ctx->va_profile, vas, vaErrorStr(vas)); ++ ctx->va_profile, vas, vaf->vaErrorStr(vas)); + err = AVERROR_EXTERNAL; + goto fail; + } +@@ -1070,7 +1077,7 @@ static av_cold int vaapi_encode_profile_entrypoint(AVCodecContext *avctx) + + ctx->va_entrypoint = va_entrypoints[i]; + #if VA_CHECK_VERSION(1, 0, 0) +- entrypoint_string = vaEntrypointStr(ctx->va_entrypoint); ++ entrypoint_string = vaf->vaEntrypointStr(ctx->va_entrypoint); + #else + entrypoint_string = "(no entrypoint names)"; + #endif +@@ -1095,12 +1102,12 @@ static av_cold int vaapi_encode_profile_entrypoint(AVCodecContext *avctx) + } + + rt_format_attr = (VAConfigAttrib) { VAConfigAttribRTFormat }; +- vas = vaGetConfigAttributes(ctx->hwctx->display, ++ vas = vaf->vaGetConfigAttributes(ctx->hwctx->display, + ctx->va_profile, ctx->va_entrypoint, + &rt_format_attr, 1); + if (vas != VA_STATUS_SUCCESS) { + av_log(avctx, AV_LOG_ERROR, "Failed to query RT format " +- "config attribute: %d (%s).\n", vas, vaErrorStr(vas)); ++ "config attribute: %d (%s).\n", vas, vaf->vaErrorStr(vas)); + err = AVERROR_EXTERNAL; + goto fail; + } +@@ -1157,6 +1164,7 @@ static const VAAPIEncodeRCMode vaapi_encode_rc_modes[] = { + static av_cold int vaapi_encode_init_rate_control(AVCodecContext *avctx) + { + VAAPIEncodeContext *ctx = avctx->priv_data; ++ VAAPIDynLoadFunctions *vaf = ctx->hwctx->funcs; + uint32_t supported_va_rc_modes; + const VAAPIEncodeRCMode *rc_mode; + int64_t rc_bits_per_second; +@@ -1170,12 +1178,12 @@ static av_cold int vaapi_encode_init_rate_control(AVCodecContext *avctx) + VAStatus vas; + char supported_rc_modes_string[64]; + +- vas = vaGetConfigAttributes(ctx->hwctx->display, ++ vas = vaf->vaGetConfigAttributes(ctx->hwctx->display, + ctx->va_profile, ctx->va_entrypoint, + &rc_attr, 1); + if (vas != VA_STATUS_SUCCESS) { + av_log(avctx, AV_LOG_ERROR, "Failed to query rate control " +- "config attribute: %d (%s).\n", vas, vaErrorStr(vas)); ++ "config attribute: %d (%s).\n", vas, vaf->vaErrorStr(vas)); + return AVERROR_EXTERNAL; + } + if (rc_attr.value == VA_ATTRIB_NOT_SUPPORTED) { +@@ -1516,6 +1524,7 @@ static av_cold int vaapi_encode_init_max_frame_size(AVCodecContext *avctx) + { + #if VA_CHECK_VERSION(1, 5, 0) + VAAPIEncodeContext *ctx = avctx->priv_data; ++ VAAPIDynLoadFunctions *vaf = ctx->hwctx->funcs; + VAConfigAttrib attr = { VAConfigAttribMaxFrameSize }; + VAStatus vas; + +@@ -1526,14 +1535,14 @@ static av_cold int vaapi_encode_init_max_frame_size(AVCodecContext *avctx) + return AVERROR(EINVAL); + } + +- vas = vaGetConfigAttributes(ctx->hwctx->display, ++ vas = vaf->vaGetConfigAttributes(ctx->hwctx->display, + ctx->va_profile, + ctx->va_entrypoint, + &attr, 1); + if (vas != VA_STATUS_SUCCESS) { + ctx->max_frame_size = 0; + av_log(avctx, AV_LOG_ERROR, "Failed to query max frame size " +- "config attribute: %d (%s).\n", vas, vaErrorStr(vas)); ++ "config attribute: %d (%s).\n", vas, vaf->vaErrorStr(vas)); + return AVERROR_EXTERNAL; + } + +@@ -1573,18 +1582,19 @@ static av_cold int vaapi_encode_init_gop_structure(AVCodecContext *avctx) + { + FFHWBaseEncodeContext *base_ctx = avctx->priv_data; + VAAPIEncodeContext *ctx = avctx->priv_data; ++ VAAPIDynLoadFunctions *vaf = ctx->hwctx->funcs; + VAStatus vas; + VAConfigAttrib attr = { VAConfigAttribEncMaxRefFrames }; + uint32_t ref_l0, ref_l1; + int prediction_pre_only, err; + +- vas = vaGetConfigAttributes(ctx->hwctx->display, ++ vas = vaf->vaGetConfigAttributes(ctx->hwctx->display, + ctx->va_profile, + ctx->va_entrypoint, + &attr, 1); + if (vas != VA_STATUS_SUCCESS) { + av_log(avctx, AV_LOG_ERROR, "Failed to query reference frames " +- "attribute: %d (%s).\n", vas, vaErrorStr(vas)); ++ "attribute: %d (%s).\n", vas, vaf->vaErrorStr(vas)); + return AVERROR_EXTERNAL; + } + +@@ -1602,13 +1612,13 @@ static av_cold int vaapi_encode_init_gop_structure(AVCodecContext *avctx) + if (!(ctx->codec->flags & FF_HW_FLAG_INTRA_ONLY || + avctx->gop_size <= 1)) { + attr = (VAConfigAttrib) { VAConfigAttribPredictionDirection }; +- vas = vaGetConfigAttributes(ctx->hwctx->display, ++ vas = vaf->vaGetConfigAttributes(ctx->hwctx->display, + ctx->va_profile, + ctx->va_entrypoint, + &attr, 1); + if (vas != VA_STATUS_SUCCESS) { + av_log(avctx, AV_LOG_WARNING, "Failed to query prediction direction " +- "attribute: %d (%s).\n", vas, vaErrorStr(vas)); ++ "attribute: %d (%s).\n", vas, vaf->vaErrorStr(vas)); + return AVERROR_EXTERNAL; + } else if (attr.value == VA_ATTRIB_NOT_SUPPORTED) { + av_log(avctx, AV_LOG_VERBOSE, "Driver does not report any additional " +@@ -1758,6 +1768,7 @@ static av_cold int vaapi_encode_init_slice_structure(AVCodecContext *avctx) + { + FFHWBaseEncodeContext *base_ctx = avctx->priv_data; + VAAPIEncodeContext *ctx = avctx->priv_data; ++ VAAPIDynLoadFunctions *vaf = ctx->hwctx->funcs; + VAConfigAttrib attr[3] = { { VAConfigAttribEncMaxSlices }, + { VAConfigAttribEncSliceStructure }, + #if VA_CHECK_VERSION(1, 1, 0) +@@ -1789,13 +1800,13 @@ static av_cold int vaapi_encode_init_slice_structure(AVCodecContext *avctx) + return 0; + } + +- vas = vaGetConfigAttributes(ctx->hwctx->display, ++ vas = vaf->vaGetConfigAttributes(ctx->hwctx->display, + ctx->va_profile, + ctx->va_entrypoint, + attr, FF_ARRAY_ELEMS(attr)); + if (vas != VA_STATUS_SUCCESS) { + av_log(avctx, AV_LOG_ERROR, "Failed to query slice " +- "attributes: %d (%s).\n", vas, vaErrorStr(vas)); ++ "attributes: %d (%s).\n", vas, vaf->vaErrorStr(vas)); + return AVERROR_EXTERNAL; + } + max_slices = attr[0].value; +@@ -1849,16 +1860,17 @@ static av_cold int vaapi_encode_init_slice_structure(AVCodecContext *avctx) + static av_cold int vaapi_encode_init_packed_headers(AVCodecContext *avctx) + { + VAAPIEncodeContext *ctx = avctx->priv_data; ++ VAAPIDynLoadFunctions *vaf = ctx->hwctx->funcs; + VAStatus vas; + VAConfigAttrib attr = { VAConfigAttribEncPackedHeaders }; + +- vas = vaGetConfigAttributes(ctx->hwctx->display, ++ vas = vaf->vaGetConfigAttributes(ctx->hwctx->display, + ctx->va_profile, + ctx->va_entrypoint, + &attr, 1); + if (vas != VA_STATUS_SUCCESS) { + av_log(avctx, AV_LOG_ERROR, "Failed to query packed headers " +- "attribute: %d (%s).\n", vas, vaErrorStr(vas)); ++ "attribute: %d (%s).\n", vas, vaf->vaErrorStr(vas)); + return AVERROR_EXTERNAL; + } + +@@ -1910,17 +1922,18 @@ static av_cold int vaapi_encode_init_quality(AVCodecContext *avctx) + { + #if VA_CHECK_VERSION(0, 36, 0) + VAAPIEncodeContext *ctx = avctx->priv_data; ++ VAAPIDynLoadFunctions *vaf = ctx->hwctx->funcs; + VAStatus vas; + VAConfigAttrib attr = { VAConfigAttribEncQualityRange }; + int quality = avctx->compression_level; + +- vas = vaGetConfigAttributes(ctx->hwctx->display, ++ vas = vaf->vaGetConfigAttributes(ctx->hwctx->display, + ctx->va_profile, + ctx->va_entrypoint, + &attr, 1); + if (vas != VA_STATUS_SUCCESS) { + av_log(avctx, AV_LOG_ERROR, "Failed to query quality " +- "config attribute: %d (%s).\n", vas, vaErrorStr(vas)); ++ "config attribute: %d (%s).\n", vas, vaf->vaErrorStr(vas)); + return AVERROR_EXTERNAL; + } + +@@ -1958,16 +1971,17 @@ static av_cold int vaapi_encode_init_roi(AVCodecContext *avctx) + #if VA_CHECK_VERSION(1, 0, 0) + FFHWBaseEncodeContext *base_ctx = avctx->priv_data; + VAAPIEncodeContext *ctx = avctx->priv_data; ++ VAAPIDynLoadFunctions *vaf = ctx->hwctx->funcs; + VAStatus vas; + VAConfigAttrib attr = { VAConfigAttribEncROI }; + +- vas = vaGetConfigAttributes(ctx->hwctx->display, ++ vas = vaf->vaGetConfigAttributes(ctx->hwctx->display, + ctx->va_profile, + ctx->va_entrypoint, + &attr, 1); + if (vas != VA_STATUS_SUCCESS) { + av_log(avctx, AV_LOG_ERROR, "Failed to query ROI " +- "config attribute: %d (%s).\n", vas, vaErrorStr(vas)); ++ "config attribute: %d (%s).\n", vas, vaf->vaErrorStr(vas)); + return AVERROR_EXTERNAL; + } + +@@ -1992,10 +2006,11 @@ static void vaapi_encode_free_output_buffer(FFRefStructOpaque opaque, + { + AVCodecContext *avctx = opaque.nc; + VAAPIEncodeContext *ctx = avctx->priv_data; ++ VAAPIDynLoadFunctions *vaf = ctx->hwctx->funcs; + VABufferID *buffer_id_ref = obj; + VABufferID buffer_id = *buffer_id_ref; + +- vaDestroyBuffer(ctx->hwctx->display, buffer_id); ++ vaf->vaDestroyBuffer(ctx->hwctx->display, buffer_id); + + av_log(avctx, AV_LOG_DEBUG, "Freed output buffer %#x\n", buffer_id); + } +@@ -2005,6 +2020,7 @@ static int vaapi_encode_alloc_output_buffer(FFRefStructOpaque opaque, void *obj) + AVCodecContext *avctx = opaque.nc; + FFHWBaseEncodeContext *base_ctx = avctx->priv_data; + VAAPIEncodeContext *ctx = avctx->priv_data; ++ VAAPIDynLoadFunctions *vaf = ctx->hwctx->funcs; + VABufferID *buffer_id = obj; + VAStatus vas; + +@@ -2012,13 +2028,13 @@ static int vaapi_encode_alloc_output_buffer(FFRefStructOpaque opaque, void *obj) + // to hold the largest possible compressed frame. We assume here + // that the uncompressed frame plus some header data is an upper + // bound on that. +- vas = vaCreateBuffer(ctx->hwctx->display, ctx->va_context, ++ vas = vaf->vaCreateBuffer(ctx->hwctx->display, ctx->va_context, + VAEncCodedBufferType, + 3 * base_ctx->surface_width * base_ctx->surface_height + + (1 << 16), 1, 0, buffer_id); + if (vas != VA_STATUS_SUCCESS) { + av_log(avctx, AV_LOG_ERROR, "Failed to create bitstream " +- "output buffer: %d (%s).\n", vas, vaErrorStr(vas)); ++ "output buffer: %d (%s).\n", vas, vaf->vaErrorStr(vas)); + return AVERROR(ENOMEM); + } + +@@ -2092,6 +2108,7 @@ av_cold int ff_vaapi_encode_init(AVCodecContext *avctx) + { + FFHWBaseEncodeContext *base_ctx = avctx->priv_data; + VAAPIEncodeContext *ctx = avctx->priv_data; ++ VAAPIDynLoadFunctions *vaf = NULL; + AVVAAPIFramesContext *recon_hwctx = NULL; + VAStatus vas; + int err; +@@ -2107,6 +2124,12 @@ av_cold int ff_vaapi_encode_init(AVCodecContext *avctx) + + ctx->hwctx = base_ctx->device->hwctx; + ++ if (!ctx->hwctx || !ctx->hwctx->funcs) { ++ err = AVERROR(EINVAL); ++ goto fail; ++ } ++ vaf = ctx->hwctx->funcs; ++ + err = vaapi_encode_profile_entrypoint(avctx); + if (err < 0) + goto fail; +@@ -2157,13 +2180,13 @@ av_cold int ff_vaapi_encode_init(AVCodecContext *avctx) + goto fail; + } + +- vas = vaCreateConfig(ctx->hwctx->display, ++ vas = vaf->vaCreateConfig(ctx->hwctx->display, + ctx->va_profile, ctx->va_entrypoint, + ctx->config_attributes, ctx->nb_config_attributes, + &ctx->va_config); + if (vas != VA_STATUS_SUCCESS) { + av_log(avctx, AV_LOG_ERROR, "Failed to create encode pipeline " +- "configuration: %d (%s).\n", vas, vaErrorStr(vas)); ++ "configuration: %d (%s).\n", vas, vaf->vaErrorStr(vas)); + err = AVERROR(EIO); + goto fail; + } +@@ -2173,7 +2196,7 @@ av_cold int ff_vaapi_encode_init(AVCodecContext *avctx) + goto fail; + + recon_hwctx = base_ctx->recon_frames->hwctx; +- vas = vaCreateContext(ctx->hwctx->display, ctx->va_config, ++ vas = vaf->vaCreateContext(ctx->hwctx->display, ctx->va_config, + base_ctx->surface_width, base_ctx->surface_height, + VA_PROGRESSIVE, + recon_hwctx->surface_ids, +@@ -2181,7 +2204,7 @@ av_cold int ff_vaapi_encode_init(AVCodecContext *avctx) + &ctx->va_context); + if (vas != VA_STATUS_SUCCESS) { + av_log(avctx, AV_LOG_ERROR, "Failed to create encode pipeline " +- "context: %d (%s).\n", vas, vaErrorStr(vas)); ++ "context: %d (%s).\n", vas, vaf->vaErrorStr(vas)); + err = AVERROR(EIO); + goto fail; + } +@@ -2255,14 +2278,16 @@ av_cold int ff_vaapi_encode_init(AVCodecContext *avctx) + + #if VA_CHECK_VERSION(1, 9, 0) + // check vaSyncBuffer function +- vas = vaSyncBuffer(ctx->hwctx->display, VA_INVALID_ID, 0); +- if (vas != VA_STATUS_ERROR_UNIMPLEMENTED) { +- base_ctx->async_encode = 1; +- base_ctx->encode_fifo = av_fifo_alloc2(base_ctx->async_depth, +- sizeof(VAAPIEncodePicture*), +- 0); +- if (!base_ctx->encode_fifo) +- return AVERROR(ENOMEM); ++ if (vaf->vaSyncBuffer) { ++ vas = vaf->vaSyncBuffer(ctx->hwctx->display, VA_INVALID_ID, 0); ++ if (vas != VA_STATUS_ERROR_UNIMPLEMENTED) { ++ base_ctx->async_encode = 1; ++ base_ctx->encode_fifo = av_fifo_alloc2(base_ctx->async_depth, ++ sizeof(VAAPIEncodePicture*), ++ 0); ++ if (!base_ctx->encode_fifo) ++ return AVERROR(ENOMEM); ++ } + } + #endif + +@@ -2291,14 +2316,14 @@ av_cold int ff_vaapi_encode_close(AVCodecContext *avctx) + ff_refstruct_pool_uninit(&ctx->output_buffer_pool); + + if (ctx->va_context != VA_INVALID_ID) { +- if (ctx->hwctx) +- vaDestroyContext(ctx->hwctx->display, ctx->va_context); ++ if (ctx->hwctx && ctx->hwctx->funcs) ++ ctx->hwctx->funcs->vaDestroyContext(ctx->hwctx->display, ctx->va_context); + ctx->va_context = VA_INVALID_ID; + } + + if (ctx->va_config != VA_INVALID_ID) { +- if (ctx->hwctx) +- vaDestroyConfig(ctx->hwctx->display, ctx->va_config); ++ if (ctx->hwctx && ctx->hwctx->funcs) ++ ctx->hwctx->funcs->vaDestroyConfig(ctx->hwctx->display, ctx->va_config); + ctx->va_config = VA_INVALID_ID; + } + +diff --git a/libavcodec/vaapi_encode_h264.c b/libavcodec/vaapi_encode_h264.c +index fb87b68bec..6d4ce630ce 100644 +--- a/libavcodec/vaapi_encode_h264.c ++++ b/libavcodec/vaapi_encode_h264.c +@@ -868,6 +868,7 @@ static int vaapi_encode_h264_init_slice_params(AVCodecContext *avctx, + static av_cold int vaapi_encode_h264_configure(AVCodecContext *avctx) + { + VAAPIEncodeContext *ctx = avctx->priv_data; ++ VAAPIDynLoadFunctions *vaf = ctx->hwctx->funcs; + VAAPIEncodeH264Context *priv = avctx->priv_data; + int err; + +@@ -919,7 +920,7 @@ static av_cold int vaapi_encode_h264_configure(AVCodecContext *avctx) + vaapi_encode_h264_sei_identifier_uuid, + sizeof(priv->sei_identifier.uuid_iso_iec_11578)); + +- driver = vaQueryVendorString(ctx->hwctx->display); ++ driver = vaf->vaQueryVendorString(ctx->hwctx->display); + if (!driver) + driver = "unknown driver"; + +diff --git a/libavcodec/vaapi_encode_h265.c b/libavcodec/vaapi_encode_h265.c +index 2283bcc0b4..7c624f99a9 100644 +--- a/libavcodec/vaapi_encode_h265.c ++++ b/libavcodec/vaapi_encode_h265.c +@@ -899,6 +899,8 @@ static int vaapi_encode_h265_init_slice_params(AVCodecContext *avctx, + static av_cold int vaapi_encode_h265_get_encoder_caps(AVCodecContext *avctx) + { + FFHWBaseEncodeContext *base_ctx = avctx->priv_data; ++ VAAPIEncodeContext *ctx = avctx->priv_data; ++ VAAPIDynLoadFunctions *vaf = ctx->hwctx->funcs; + VAAPIEncodeH265Context *priv = avctx->priv_data; + + #if VA_CHECK_VERSION(1, 13, 0) +@@ -909,7 +911,7 @@ static av_cold int vaapi_encode_h265_get_encoder_caps(AVCodecContext *avctx) + VAStatus vas; + + attr.type = VAConfigAttribEncHEVCFeatures; +- vas = vaGetConfigAttributes(ctx->hwctx->display, ctx->va_profile, ++ vas = vaf->vaGetConfigAttributes(ctx->hwctx->display, ctx->va_profile, + ctx->va_entrypoint, &attr, 1); + if (vas != VA_STATUS_SUCCESS) { + av_log(avctx, AV_LOG_ERROR, "Failed to query encoder " +@@ -923,7 +925,7 @@ static av_cold int vaapi_encode_h265_get_encoder_caps(AVCodecContext *avctx) + } + + attr.type = VAConfigAttribEncHEVCBlockSizes; +- vas = vaGetConfigAttributes(ctx->hwctx->display, ctx->va_profile, ++ vas = vaf->vaGetConfigAttributes(ctx->hwctx->display, ctx->va_profile, + ctx->va_entrypoint, &attr, 1); + if (vas != VA_STATUS_SUCCESS) { + av_log(avctx, AV_LOG_ERROR, "Failed to query encoder " +diff --git a/libavutil/hwcontext_vaapi.c b/libavutil/hwcontext_vaapi.c +index 95aa38d9d2..13451e8ad7 100644 +--- a/libavutil/hwcontext_vaapi.c ++++ b/libavutil/hwcontext_vaapi.c +@@ -48,6 +48,7 @@ typedef HRESULT (WINAPI *PFN_CREATE_DXGI_FACTORY)(REFIID riid, void **ppFactory) + # include + #endif + ++#include + + #include "avassert.h" + #include "buffer.h" +@@ -60,6 +61,128 @@ typedef HRESULT (WINAPI *PFN_CREATE_DXGI_FACTORY)(REFIID riid, void **ppFactory) + #include "pixdesc.h" + #include "pixfmt.h" + ++//////////////////////////////////////////////////////////// ++/// dynamic load functions ++//////////////////////////////////////////////////////////// ++ ++#define LOAD_SYMBOL(name) do { \ ++ funcs->name = dlsym(funcs->handle_va, #name); \ ++ if (!funcs->name) { \ ++ av_log(NULL, AV_LOG_ERROR, "Failed to load %s\n", #name); \ ++ goto fail; \ ++ } \ ++} while(0) ++ ++static void vaapi_free_functions(VAAPIDynLoadFunctions *funcs) ++{ ++ if (!funcs) ++ return; ++ ++ if (funcs->handle_va_x11) ++ dlclose(funcs->handle_va_x11); ++ if (funcs->handle_va_drm) ++ dlclose(funcs->handle_va_drm); ++ if (funcs->handle_va) ++ dlclose(funcs->handle_va); ++ av_free(funcs); ++} ++ ++static VAAPIDynLoadFunctions *vaapi_load_functions(void) ++{ ++ VAAPIDynLoadFunctions *funcs = av_mallocz(sizeof(*funcs)); ++ if (!funcs) ++ return NULL; ++ ++ // Load libva.so ++ funcs->handle_va = dlopen("libva.so.2", RTLD_NOW | RTLD_LOCAL); ++ if (!funcs->handle_va) { ++ av_log(NULL, AV_LOG_ERROR, "Failed to load libva: %s\n", dlerror()); ++ goto fail; ++ } ++ ++ // Load core functions ++ LOAD_SYMBOL(vaInitialize); ++ LOAD_SYMBOL(vaTerminate); ++ LOAD_SYMBOL(vaCreateConfig); ++ LOAD_SYMBOL(vaDestroyConfig); ++ LOAD_SYMBOL(vaCreateContext); ++ LOAD_SYMBOL(vaDestroyContext); ++ LOAD_SYMBOL(vaCreateBuffer); ++ LOAD_SYMBOL(vaDestroyBuffer); ++ LOAD_SYMBOL(vaMapBuffer); ++ LOAD_SYMBOL(vaUnmapBuffer); ++ LOAD_SYMBOL(vaSyncSurface); ++ LOAD_SYMBOL(vaGetConfigAttributes); ++ LOAD_SYMBOL(vaCreateSurfaces); ++ LOAD_SYMBOL(vaDestroySurfaces); ++ LOAD_SYMBOL(vaBeginPicture); ++ LOAD_SYMBOL(vaRenderPicture); ++ LOAD_SYMBOL(vaEndPicture); ++ LOAD_SYMBOL(vaQueryConfigEntrypoints); ++ LOAD_SYMBOL(vaQueryConfigProfiles); ++ LOAD_SYMBOL(vaGetDisplayAttributes); ++ LOAD_SYMBOL(vaErrorStr); ++ LOAD_SYMBOL(vaMaxNumEntrypoints); ++ LOAD_SYMBOL(vaMaxNumProfiles); ++ LOAD_SYMBOL(vaQueryVendorString); ++ LOAD_SYMBOL(vaQuerySurfaceAttributes); ++ LOAD_SYMBOL(vaDestroyImage); ++ LOAD_SYMBOL(vaDeriveImage); ++ LOAD_SYMBOL(vaPutImage); ++ LOAD_SYMBOL(vaCreateImage); ++ LOAD_SYMBOL(vaGetImage); ++ LOAD_SYMBOL(vaExportSurfaceHandle); ++ LOAD_SYMBOL(vaReleaseBufferHandle); ++ LOAD_SYMBOL(vaAcquireBufferHandle); ++ LOAD_SYMBOL(vaSetErrorCallback); ++ LOAD_SYMBOL(vaSetInfoCallback); ++ LOAD_SYMBOL(vaSetDriverName); ++ LOAD_SYMBOL(vaEntrypointStr); ++ LOAD_SYMBOL(vaQueryImageFormats); ++ LOAD_SYMBOL(vaMaxNumImageFormats); ++ LOAD_SYMBOL(vaProfileStr); ++ ++ // Load libva-x11.so ++ funcs->handle_va_x11 = dlopen("libva-x11.so.2", RTLD_NOW | RTLD_LOCAL); ++ if (!funcs->handle_va_x11) { ++ av_log(NULL, AV_LOG_ERROR, "Failed to load libva-x11: %s\n", dlerror()); ++ goto fail; ++ } ++ ++ funcs->vaGetDisplay = dlsym(funcs->handle_va_x11, "vaGetDisplay"); ++ if (!funcs->vaGetDisplay) { ++ av_log(NULL, AV_LOG_ERROR, "Failed to load vaGetDisplay\n"); ++ goto fail; ++ } ++ ++ // Load libva-drm.so ++ funcs->handle_va_drm = dlopen("libva-drm.so.2", RTLD_NOW | RTLD_LOCAL); ++ if (!funcs->handle_va_drm) { ++ av_log(NULL, AV_LOG_ERROR, "Failed to load libva-drm: %s\n", dlerror()); ++ goto fail; ++ } ++ ++ funcs->vaGetDisplayDRM = dlsym(funcs->handle_va_drm, "vaGetDisplayDRM"); ++ if (!funcs->vaGetDisplayDRM) { ++ av_log(NULL, AV_LOG_ERROR, "Failed to load vaGetDisplayDRM\n"); ++ goto fail; ++ } ++ ++ // Optional functions ++ funcs->vaSyncBuffer = dlsym(funcs->handle_va, "vaSyncBuffer"); ++ av_log(NULL, AV_LOG_DEBUG, "vaSyncBuffer:%p.\n", funcs->vaSyncBuffer); ++ ++ return funcs; ++ ++fail: ++ vaapi_free_functions(funcs); ++ return NULL; ++} ++ ++//////////////////////////////////////////////////////////// ++/// VAAPI API end ++//////////////////////////////////////////////////////////// ++ + + typedef struct VAAPIDevicePriv { + #if HAVE_VAAPI_X11 +@@ -236,6 +359,7 @@ static int vaapi_frames_get_constraints(AVHWDeviceContext *hwdev, + { + VAAPIDeviceContext *ctx = hwdev->hwctx; + AVVAAPIDeviceContext *hwctx = &ctx->p; ++ VAAPIDynLoadFunctions *vaf = hwctx->funcs; + const AVVAAPIHWConfig *config = hwconfig; + VASurfaceAttrib *attr_list = NULL; + VAStatus vas; +@@ -246,11 +370,11 @@ static int vaapi_frames_get_constraints(AVHWDeviceContext *hwdev, + if (config && + !(hwctx->driver_quirks & AV_VAAPI_DRIVER_QUIRK_SURFACE_ATTRIBUTES)) { + attr_count = 0; +- vas = vaQuerySurfaceAttributes(hwctx->display, config->config_id, ++ vas = vaf->vaQuerySurfaceAttributes(hwctx->display, config->config_id, + 0, &attr_count); + if (vas != VA_STATUS_SUCCESS) { + av_log(hwdev, AV_LOG_ERROR, "Failed to query surface attributes: " +- "%d (%s).\n", vas, vaErrorStr(vas)); ++ "%d (%s).\n", vas, vaf->vaErrorStr(vas)); + err = AVERROR(ENOSYS); + goto fail; + } +@@ -261,11 +385,11 @@ static int vaapi_frames_get_constraints(AVHWDeviceContext *hwdev, + goto fail; + } + +- vas = vaQuerySurfaceAttributes(hwctx->display, config->config_id, ++ vas = vaf->vaQuerySurfaceAttributes(hwctx->display, config->config_id, + attr_list, &attr_count); + if (vas != VA_STATUS_SUCCESS) { + av_log(hwdev, AV_LOG_ERROR, "Failed to query surface attributes: " +- "%d (%s).\n", vas, vaErrorStr(vas)); ++ "%d (%s).\n", vas, vaf->vaErrorStr(vas)); + err = AVERROR(ENOSYS); + goto fail; + } +@@ -396,6 +520,7 @@ static int vaapi_device_init(AVHWDeviceContext *hwdev) + { + VAAPIDeviceContext *ctx = hwdev->hwctx; + AVVAAPIDeviceContext *hwctx = &ctx->p; ++ VAAPIDynLoadFunctions *vaf = hwctx->funcs; + VAImageFormat *image_list = NULL; + VAStatus vas; + const char *vendor_string; +@@ -403,7 +528,7 @@ static int vaapi_device_init(AVHWDeviceContext *hwdev) + enum AVPixelFormat pix_fmt; + unsigned int fourcc; + +- image_count = vaMaxNumImageFormats(hwctx->display); ++ image_count = vaf->vaMaxNumImageFormats(hwctx->display); + if (image_count <= 0) { + err = AVERROR(EIO); + goto fail; +@@ -413,7 +538,7 @@ static int vaapi_device_init(AVHWDeviceContext *hwdev) + err = AVERROR(ENOMEM); + goto fail; + } +- vas = vaQueryImageFormats(hwctx->display, image_list, &image_count); ++ vas = vaf->vaQueryImageFormats(hwctx->display, image_list, &image_count); + if (vas != VA_STATUS_SUCCESS) { + err = AVERROR(EIO); + goto fail; +@@ -440,7 +565,7 @@ static int vaapi_device_init(AVHWDeviceContext *hwdev) + } + } + +- vendor_string = vaQueryVendorString(hwctx->display); ++ vendor_string = vaf->vaQueryVendorString(hwctx->display); + if (vendor_string) + av_log(hwdev, AV_LOG_VERBOSE, "VAAPI driver: %s.\n", vendor_string); + +@@ -493,15 +618,16 @@ static void vaapi_buffer_free(void *opaque, uint8_t *data) + { + AVHWFramesContext *hwfc = opaque; + AVVAAPIDeviceContext *hwctx = hwfc->device_ctx->hwctx; ++ VAAPIDynLoadFunctions *vaf = hwctx->funcs; + VASurfaceID surface_id; + VAStatus vas; + + surface_id = (VASurfaceID)(uintptr_t)data; + +- vas = vaDestroySurfaces(hwctx->display, &surface_id, 1); ++ vas = vaf->vaDestroySurfaces(hwctx->display, &surface_id, 1); + if (vas != VA_STATUS_SUCCESS) { + av_log(hwfc, AV_LOG_ERROR, "Failed to destroy surface %#x: " +- "%d (%s).\n", surface_id, vas, vaErrorStr(vas)); ++ "%d (%s).\n", surface_id, vas, vaf->vaErrorStr(vas)); + } + } + +@@ -511,6 +637,7 @@ static AVBufferRef *vaapi_pool_alloc(void *opaque, size_t size) + VAAPIFramesContext *ctx = hwfc->hwctx; + AVVAAPIFramesContext *avfc = &ctx->p; + AVVAAPIDeviceContext *hwctx = hwfc->device_ctx->hwctx; ++ VAAPIDynLoadFunctions *vaf = hwctx->funcs; + VASurfaceID surface_id; + VAStatus vas; + AVBufferRef *ref; +@@ -519,13 +646,13 @@ static AVBufferRef *vaapi_pool_alloc(void *opaque, size_t size) + avfc->nb_surfaces >= hwfc->initial_pool_size) + return NULL; + +- vas = vaCreateSurfaces(hwctx->display, ctx->rt_format, ++ vas = vaf->vaCreateSurfaces(hwctx->display, ctx->rt_format, + hwfc->width, hwfc->height, + &surface_id, 1, + ctx->attributes, ctx->nb_attributes); + if (vas != VA_STATUS_SUCCESS) { + av_log(hwfc, AV_LOG_ERROR, "Failed to create surface: " +- "%d (%s).\n", vas, vaErrorStr(vas)); ++ "%d (%s).\n", vas, vaf->vaErrorStr(vas)); + return NULL; + } + av_log(hwfc, AV_LOG_DEBUG, "Created surface %#x.\n", surface_id); +@@ -534,7 +661,7 @@ static AVBufferRef *vaapi_pool_alloc(void *opaque, size_t size) + sizeof(surface_id), &vaapi_buffer_free, + hwfc, AV_BUFFER_FLAG_READONLY); + if (!ref) { +- vaDestroySurfaces(hwctx->display, &surface_id, 1); ++ vaf->vaDestroySurfaces(hwctx->display, &surface_id, 1); + return NULL; + } + +@@ -554,6 +681,7 @@ static int vaapi_frames_init(AVHWFramesContext *hwfc) + VAAPIFramesContext *ctx = hwfc->hwctx; + AVVAAPIFramesContext *avfc = &ctx->p; + AVVAAPIDeviceContext *hwctx = hwfc->device_ctx->hwctx; ++ VAAPIDynLoadFunctions *vaf = hwctx->funcs; + const VAAPIFormatDescriptor *desc; + VAImageFormat *expected_format; + AVBufferRef *test_surface = NULL; +@@ -669,7 +797,7 @@ static int vaapi_frames_init(AVHWFramesContext *hwfc) + err = vaapi_get_image_format(hwfc->device_ctx, + hwfc->sw_format, &expected_format); + if (err == 0) { +- vas = vaDeriveImage(hwctx->display, test_surface_id, &test_image); ++ vas = vaf->vaDeriveImage(hwctx->display, test_surface_id, &test_image); + if (vas == VA_STATUS_SUCCESS) { + if (expected_format->fourcc == test_image.format.fourcc) { + av_log(hwfc, AV_LOG_DEBUG, "Direct mapping possible.\n"); +@@ -680,11 +808,11 @@ static int vaapi_frames_init(AVHWFramesContext *hwfc) + "expected format %08x.\n", + expected_format->fourcc, test_image.format.fourcc); + } +- vaDestroyImage(hwctx->display, test_image.image_id); ++ vaf->vaDestroyImage(hwctx->display, test_image.image_id); + } else { + av_log(hwfc, AV_LOG_DEBUG, "Direct mapping disabled: " + "deriving image does not work: " +- "%d (%s).\n", vas, vaErrorStr(vas)); ++ "%d (%s).\n", vas, vaf->vaErrorStr(vas)); + } + } else { + av_log(hwfc, AV_LOG_DEBUG, "Direct mapping disabled: " +@@ -765,33 +893,34 @@ static void vaapi_unmap_frame(AVHWFramesContext *hwfc, + { + AVVAAPIDeviceContext *hwctx = hwfc->device_ctx->hwctx; + VAAPIMapping *map = hwmap->priv; ++ VAAPIDynLoadFunctions *vaf = hwctx->funcs; + VASurfaceID surface_id; + VAStatus vas; + + surface_id = (VASurfaceID)(uintptr_t)hwmap->source->data[3]; + av_log(hwfc, AV_LOG_DEBUG, "Unmap surface %#x.\n", surface_id); + +- vas = vaUnmapBuffer(hwctx->display, map->image.buf); ++ vas = vaf->vaUnmapBuffer(hwctx->display, map->image.buf); + if (vas != VA_STATUS_SUCCESS) { + av_log(hwfc, AV_LOG_ERROR, "Failed to unmap image from surface " +- "%#x: %d (%s).\n", surface_id, vas, vaErrorStr(vas)); ++ "%#x: %d (%s).\n", surface_id, vas, vaf->vaErrorStr(vas)); + } + + if ((map->flags & AV_HWFRAME_MAP_WRITE) && + !(map->flags & AV_HWFRAME_MAP_DIRECT)) { +- vas = vaPutImage(hwctx->display, surface_id, map->image.image_id, ++ vas = vaf->vaPutImage(hwctx->display, surface_id, map->image.image_id, + 0, 0, hwfc->width, hwfc->height, + 0, 0, hwfc->width, hwfc->height); + if (vas != VA_STATUS_SUCCESS) { + av_log(hwfc, AV_LOG_ERROR, "Failed to write image to surface " +- "%#x: %d (%s).\n", surface_id, vas, vaErrorStr(vas)); ++ "%#x: %d (%s).\n", surface_id, vas, vaf->vaErrorStr(vas)); + } + } + +- vas = vaDestroyImage(hwctx->display, map->image.image_id); ++ vas = vaf->vaDestroyImage(hwctx->display, map->image.image_id); + if (vas != VA_STATUS_SUCCESS) { + av_log(hwfc, AV_LOG_ERROR, "Failed to destroy image from surface " +- "%#x: %d (%s).\n", surface_id, vas, vaErrorStr(vas)); ++ "%#x: %d (%s).\n", surface_id, vas, vaf->vaErrorStr(vas)); + } + + av_free(map); +@@ -801,6 +930,7 @@ static int vaapi_map_frame(AVHWFramesContext *hwfc, + AVFrame *dst, const AVFrame *src, int flags) + { + AVVAAPIDeviceContext *hwctx = hwfc->device_ctx->hwctx; ++ VAAPIDynLoadFunctions *vaf = hwctx->funcs; + VAAPIFramesContext *ctx = hwfc->hwctx; + VASurfaceID surface_id; + const VAAPIFormatDescriptor *desc; +@@ -839,10 +969,10 @@ static int vaapi_map_frame(AVHWFramesContext *hwfc, + map->flags = flags; + map->image.image_id = VA_INVALID_ID; + +- vas = vaSyncSurface(hwctx->display, surface_id); ++ vas = vaf->vaSyncSurface(hwctx->display, surface_id); + if (vas != VA_STATUS_SUCCESS) { + av_log(hwfc, AV_LOG_ERROR, "Failed to sync surface " +- "%#x: %d (%s).\n", surface_id, vas, vaErrorStr(vas)); ++ "%#x: %d (%s).\n", surface_id, vas, vaf->vaErrorStr(vas)); + err = AVERROR(EIO); + goto fail; + } +@@ -856,11 +986,11 @@ static int vaapi_map_frame(AVHWFramesContext *hwfc, + // prefer not to be given direct-mapped memory if they request read access. + if (ctx->derive_works && dst->format == hwfc->sw_format && + ((flags & AV_HWFRAME_MAP_DIRECT) || !(flags & AV_HWFRAME_MAP_READ))) { +- vas = vaDeriveImage(hwctx->display, surface_id, &map->image); ++ vas = vaf->vaDeriveImage(hwctx->display, surface_id, &map->image); + if (vas != VA_STATUS_SUCCESS) { + av_log(hwfc, AV_LOG_ERROR, "Failed to derive image from " + "surface %#x: %d (%s).\n", +- surface_id, vas, vaErrorStr(vas)); ++ surface_id, vas, vaf->vaErrorStr(vas)); + err = AVERROR(EIO); + goto fail; + } +@@ -873,41 +1003,32 @@ static int vaapi_map_frame(AVHWFramesContext *hwfc, + } + map->flags |= AV_HWFRAME_MAP_DIRECT; + } else { +- vas = vaCreateImage(hwctx->display, image_format, ++ vas = vaf->vaCreateImage(hwctx->display, image_format, + hwfc->width, hwfc->height, &map->image); + if (vas != VA_STATUS_SUCCESS) { + av_log(hwfc, AV_LOG_ERROR, "Failed to create image for " + "surface %#x: %d (%s).\n", +- surface_id, vas, vaErrorStr(vas)); ++ surface_id, vas, vaf->vaErrorStr(vas)); + err = AVERROR(EIO); + goto fail; + } + if (!(flags & AV_HWFRAME_MAP_OVERWRITE)) { +- vas = vaGetImage(hwctx->display, surface_id, 0, 0, ++ vas = vaf->vaGetImage(hwctx->display, surface_id, 0, 0, + hwfc->width, hwfc->height, map->image.image_id); + if (vas != VA_STATUS_SUCCESS) { + av_log(hwfc, AV_LOG_ERROR, "Failed to read image from " + "surface %#x: %d (%s).\n", +- surface_id, vas, vaErrorStr(vas)); ++ surface_id, vas, vaf->vaErrorStr(vas)); + err = AVERROR(EIO); + goto fail; + } + } + } + +-#if VA_CHECK_VERSION(1, 21, 0) +- if (flags & AV_HWFRAME_MAP_READ) +- vaflags |= VA_MAPBUFFER_FLAG_READ; +- if (flags & AV_HWFRAME_MAP_WRITE) +- vaflags |= VA_MAPBUFFER_FLAG_WRITE; +- // On drivers not implementing vaMapBuffer2 libva calls vaMapBuffer instead. +- vas = vaMapBuffer2(hwctx->display, map->image.buf, &address, vaflags); +-#else +- vas = vaMapBuffer(hwctx->display, map->image.buf, &address); +-#endif ++ vas = vaf->vaMapBuffer(hwctx->display, map->image.buf, &address); + if (vas != VA_STATUS_SUCCESS) { + av_log(hwfc, AV_LOG_ERROR, "Failed to map image from surface " +- "%#x: %d (%s).\n", surface_id, vas, vaErrorStr(vas)); ++ "%#x: %d (%s).\n", surface_id, vas, vaf->vaErrorStr(vas)); + err = AVERROR(EIO); + goto fail; + } +@@ -936,9 +1057,9 @@ static int vaapi_map_frame(AVHWFramesContext *hwfc, + fail: + if (map) { + if (address) +- vaUnmapBuffer(hwctx->display, map->image.buf); ++ vaf->vaUnmapBuffer(hwctx->display, map->image.buf); + if (map->image.image_id != VA_INVALID_ID) +- vaDestroyImage(hwctx->display, map->image.image_id); ++ vaf->vaDestroyImage(hwctx->display, map->image.image_id); + av_free(map); + } + return err; +@@ -1080,12 +1201,12 @@ static void vaapi_unmap_from_drm(AVHWFramesContext *dst_fc, + HWMapDescriptor *hwmap) + { + AVVAAPIDeviceContext *dst_dev = dst_fc->device_ctx->hwctx; +- ++ VAAPIDynLoadFunctions *vaf = dst_dev->funcs; + VASurfaceID surface_id = (VASurfaceID)(uintptr_t)hwmap->priv; + + av_log(dst_fc, AV_LOG_DEBUG, "Destroy surface %#x.\n", surface_id); + +- vaDestroySurfaces(dst_dev->display, &surface_id, 1); ++ vaf->vaDestroySurfaces(dst_dev->display, &surface_id, 1); + } + + static int vaapi_map_from_drm(AVHWFramesContext *src_fc, AVFrame *dst, +@@ -1100,6 +1221,7 @@ static int vaapi_map_from_drm(AVHWFramesContext *src_fc, AVFrame *dst, + AVHWFramesContext *dst_fc = + (AVHWFramesContext*)dst->hw_frames_ctx->data; + AVVAAPIDeviceContext *dst_dev = dst_fc->device_ctx->hwctx; ++ VAAPIDynLoadFunctions *vaf = dst_dev->funcs; + const AVDRMFrameDescriptor *desc; + const VAAPIFormatDescriptor *format_desc; + VASurfaceID surface_id; +@@ -1216,7 +1338,7 @@ static int vaapi_map_from_drm(AVHWFramesContext *src_fc, AVFrame *dst, + * Gallium seem to do the correct error checks, so lets just try the + * PRIME_2 import first. + */ +- vas = vaCreateSurfaces(dst_dev->display, format_desc->rt_format, ++ vas = vaf->vaCreateSurfaces(dst_dev->display, format_desc->rt_format, + src->width, src->height, &surface_id, 1, + prime_attrs, FF_ARRAY_ELEMS(prime_attrs)); + if (vas != VA_STATUS_SUCCESS) +@@ -1267,7 +1389,7 @@ static int vaapi_map_from_drm(AVHWFramesContext *src_fc, AVFrame *dst, + FFSWAP(uint32_t, buffer_desc.offsets[1], buffer_desc.offsets[2]); + } + +- vas = vaCreateSurfaces(dst_dev->display, format_desc->rt_format, ++ vas = vaf->vaCreateSurfaces(dst_dev->display, format_desc->rt_format, + src->width, src->height, + &surface_id, 1, + buffer_attrs, FF_ARRAY_ELEMS(buffer_attrs)); +@@ -1298,14 +1420,14 @@ static int vaapi_map_from_drm(AVHWFramesContext *src_fc, AVFrame *dst, + FFSWAP(uint32_t, buffer_desc.offsets[1], buffer_desc.offsets[2]); + } + +- vas = vaCreateSurfaces(dst_dev->display, format_desc->rt_format, ++ vas = vaf->vaCreateSurfaces(dst_dev->display, format_desc->rt_format, + src->width, src->height, + &surface_id, 1, + attrs, FF_ARRAY_ELEMS(attrs)); + #endif + if (vas != VA_STATUS_SUCCESS) { + av_log(dst_fc, AV_LOG_ERROR, "Failed to create surface from DRM " +- "object: %d (%s).\n", vas, vaErrorStr(vas)); ++ "object: %d (%s).\n", vas, vaf->vaErrorStr(vas)); + return AVERROR(EIO); + } + av_log(dst_fc, AV_LOG_DEBUG, "Create surface %#x.\n", surface_id); +@@ -1343,6 +1465,7 @@ static int vaapi_map_to_drm_esh(AVHWFramesContext *hwfc, AVFrame *dst, + const AVFrame *src, int flags) + { + AVVAAPIDeviceContext *hwctx = hwfc->device_ctx->hwctx; ++ VAAPIDynLoadFunctions *vaf = hwctx->funcs; + VASurfaceID surface_id; + VAStatus vas; + VADRMPRIMESurfaceDescriptor va_desc; +@@ -1356,10 +1479,10 @@ static int vaapi_map_to_drm_esh(AVHWFramesContext *hwfc, AVFrame *dst, + if (flags & AV_HWFRAME_MAP_READ) { + export_flags |= VA_EXPORT_SURFACE_READ_ONLY; + +- vas = vaSyncSurface(hwctx->display, surface_id); ++ vas = vaf->vaSyncSurface(hwctx->display, surface_id); + if (vas != VA_STATUS_SUCCESS) { + av_log(hwfc, AV_LOG_ERROR, "Failed to sync surface " +- "%#x: %d (%s).\n", surface_id, vas, vaErrorStr(vas)); ++ "%#x: %d (%s).\n", surface_id, vas, vaf->vaErrorStr(vas)); + return AVERROR(EIO); + } + } +@@ -1367,14 +1490,14 @@ static int vaapi_map_to_drm_esh(AVHWFramesContext *hwfc, AVFrame *dst, + if (flags & AV_HWFRAME_MAP_WRITE) + export_flags |= VA_EXPORT_SURFACE_WRITE_ONLY; + +- vas = vaExportSurfaceHandle(hwctx->display, surface_id, ++ vas = vaf->vaExportSurfaceHandle(hwctx->display, surface_id, + VA_SURFACE_ATTRIB_MEM_TYPE_DRM_PRIME_2, + export_flags, &va_desc); + if (vas != VA_STATUS_SUCCESS) { + if (vas == VA_STATUS_ERROR_UNIMPLEMENTED) + return AVERROR(ENOSYS); + av_log(hwfc, AV_LOG_ERROR, "Failed to export surface %#x: " +- "%d (%s).\n", surface_id, vas, vaErrorStr(vas)); ++ "%d (%s).\n", surface_id, vas, vaf->vaErrorStr(vas)); + return AVERROR(EIO); + } + +@@ -1437,6 +1560,7 @@ static void vaapi_unmap_to_drm_abh(AVHWFramesContext *hwfc, + HWMapDescriptor *hwmap) + { + AVVAAPIDeviceContext *hwctx = hwfc->device_ctx->hwctx; ++ VAAPIDynLoadFunctions *vaf = hwctx->funcs; + VAAPIDRMImageBufferMapping *mapping = hwmap->priv; + VASurfaceID surface_id; + VAStatus vas; +@@ -1448,19 +1572,19 @@ static void vaapi_unmap_to_drm_abh(AVHWFramesContext *hwfc, + // DRM PRIME file descriptors are closed by vaReleaseBufferHandle(), + // so we shouldn't close them separately. + +- vas = vaReleaseBufferHandle(hwctx->display, mapping->image.buf); ++ vas = vaf->vaReleaseBufferHandle(hwctx->display, mapping->image.buf); + if (vas != VA_STATUS_SUCCESS) { + av_log(hwfc, AV_LOG_ERROR, "Failed to release buffer " + "handle of image %#x (derived from surface %#x): " + "%d (%s).\n", mapping->image.buf, surface_id, +- vas, vaErrorStr(vas)); ++ vas, vaf->vaErrorStr(vas)); + } + +- vas = vaDestroyImage(hwctx->display, mapping->image.image_id); ++ vas = vaf->vaDestroyImage(hwctx->display, mapping->image.image_id); + if (vas != VA_STATUS_SUCCESS) { + av_log(hwfc, AV_LOG_ERROR, "Failed to destroy image " + "derived from surface %#x: %d (%s).\n", +- surface_id, vas, vaErrorStr(vas)); ++ surface_id, vas, vaf->vaErrorStr(vas)); + } + + av_free(mapping); +@@ -1470,6 +1594,7 @@ static int vaapi_map_to_drm_abh(AVHWFramesContext *hwfc, AVFrame *dst, + const AVFrame *src, int flags) + { + AVVAAPIDeviceContext *hwctx = hwfc->device_ctx->hwctx; ++ VAAPIDynLoadFunctions *vaf = hwctx->funcs; + VAAPIDRMImageBufferMapping *mapping = NULL; + VASurfaceID surface_id; + VAStatus vas; +@@ -1483,12 +1608,12 @@ static int vaapi_map_to_drm_abh(AVHWFramesContext *hwfc, AVFrame *dst, + if (!mapping) + return AVERROR(ENOMEM); + +- vas = vaDeriveImage(hwctx->display, surface_id, ++ vas = vaf->vaDeriveImage(hwctx->display, surface_id, + &mapping->image); + if (vas != VA_STATUS_SUCCESS) { + av_log(hwfc, AV_LOG_ERROR, "Failed to derive image from " + "surface %#x: %d (%s).\n", +- surface_id, vas, vaErrorStr(vas)); ++ surface_id, vas, vaf->vaErrorStr(vas)); + err = AVERROR(EIO); + goto fail; + } +@@ -1543,13 +1668,13 @@ static int vaapi_map_to_drm_abh(AVHWFramesContext *hwfc, AVFrame *dst, + } + } + +- vas = vaAcquireBufferHandle(hwctx->display, mapping->image.buf, ++ vas = vaf->vaAcquireBufferHandle(hwctx->display, mapping->image.buf, + &mapping->buffer_info); + if (vas != VA_STATUS_SUCCESS) { + av_log(hwfc, AV_LOG_ERROR, "Failed to get buffer " + "handle from image %#x (derived from surface %#x): " + "%d (%s).\n", mapping->image.buf, surface_id, +- vas, vaErrorStr(vas)); ++ vas, vaf->vaErrorStr(vas)); + err = AVERROR(EIO); + goto fail_derived; + } +@@ -1578,9 +1703,9 @@ static int vaapi_map_to_drm_abh(AVHWFramesContext *hwfc, AVFrame *dst, + return 0; + + fail_mapped: +- vaReleaseBufferHandle(hwctx->display, mapping->image.buf); ++ vaf->vaReleaseBufferHandle(hwctx->display, mapping->image.buf); + fail_derived: +- vaDestroyImage(hwctx->display, mapping->image.image_id); ++ vaf->vaDestroyImage(hwctx->display, mapping->image.image_id); + fail: + av_freep(&mapping); + return err; +@@ -1634,9 +1759,15 @@ static void vaapi_device_free(AVHWDeviceContext *ctx) + { + AVVAAPIDeviceContext *hwctx = ctx->hwctx; + VAAPIDevicePriv *priv = ctx->user_opaque; ++ VAAPIDynLoadFunctions *vaf = hwctx->funcs; + +- if (hwctx->display) +- vaTerminate(hwctx->display); ++ if (hwctx && hwctx->display && vaf && vaf->vaTerminate) ++ vaf->vaTerminate(hwctx->display); ++ ++ if (hwctx && hwctx->funcs) { ++ vaapi_free_functions(hwctx->funcs); ++ hwctx->funcs = NULL; ++ } + + #if HAVE_VAAPI_X11 + if (priv->x11_display) +@@ -1669,20 +1800,21 @@ static int vaapi_device_connect(AVHWDeviceContext *ctx, + VADisplay display) + { + AVVAAPIDeviceContext *hwctx = ctx->hwctx; ++ VAAPIDynLoadFunctions *vaf = hwctx->funcs; + int major, minor; + VAStatus vas; + + #if CONFIG_VAAPI_1 +- vaSetErrorCallback(display, &vaapi_device_log_error, ctx); +- vaSetInfoCallback (display, &vaapi_device_log_info, ctx); ++ vaf->vaSetErrorCallback(display, &vaapi_device_log_error, ctx); ++ vaf->vaSetInfoCallback (display, &vaapi_device_log_info, ctx); + #endif + + hwctx->display = display; + +- vas = vaInitialize(display, &major, &minor); ++ vas = vaf->vaInitialize(display, &major, &minor); + if (vas != VA_STATUS_SUCCESS) { + av_log(ctx, AV_LOG_ERROR, "Failed to initialise VAAPI " +- "connection: %d (%s).\n", vas, vaErrorStr(vas)); ++ "connection: %d (%s).\n", vas, vaf->vaErrorStr(vas)); + return AVERROR(EIO); + } + av_log(ctx, AV_LOG_VERBOSE, "Initialised VAAPI connection: " +@@ -1698,6 +1830,16 @@ static int vaapi_device_create(AVHWDeviceContext *ctx, const char *device, + VADisplay display = NULL; + const AVDictionaryEntry *ent; + int try_drm, try_x11, try_win32, try_all; ++ VAAPIDeviceContext *hwctx = ctx->hwctx; ++ VAAPIDynLoadFunctions *vaf; ++ ++ hwctx->p.funcs = vaapi_load_functions(); ++ if (!hwctx->p.funcs) { ++ av_log(NULL, AV_LOG_ERROR, "Failed to load libva: %s\n", dlerror()); ++ return AVERROR_EXTERNAL; ++ } ++ ++ vaf = hwctx->p.funcs; + + priv = av_mallocz(sizeof(*priv)); + if (!priv) +@@ -1843,7 +1985,7 @@ static int vaapi_device_create(AVHWDeviceContext *ctx, const char *device, + break; + } + +- display = vaGetDisplayDRM(priv->drm_fd); ++ display = vaf->vaGetDisplayDRM(priv->drm_fd); + if (!display) { + av_log(ctx, AV_LOG_VERBOSE, "Cannot open a VA display " + "from DRM device %s.\n", device); +@@ -1861,7 +2003,7 @@ static int vaapi_device_create(AVHWDeviceContext *ctx, const char *device, + av_log(ctx, AV_LOG_VERBOSE, "Cannot open X11 display " + "%s.\n", XDisplayName(device)); + } else { +- display = vaGetDisplay(priv->x11_display); ++ display = vaf->vaGetDisplay(priv->x11_display); + if (!display) { + av_log(ctx, AV_LOG_ERROR, "Cannot open a VA display " + "from X11 display %s.\n", XDisplayName(device)); +@@ -1950,11 +2092,11 @@ static int vaapi_device_create(AVHWDeviceContext *ctx, const char *device, + if (ent) { + #if VA_CHECK_VERSION(0, 38, 0) + VAStatus vas; +- vas = vaSetDriverName(display, ent->value); ++ vas = vaf->vaSetDriverName(display, ent->value); + if (vas != VA_STATUS_SUCCESS) { + av_log(ctx, AV_LOG_ERROR, "Failed to set driver name to " +- "%s: %d (%s).\n", ent->value, vas, vaErrorStr(vas)); +- vaTerminate(display); ++ "%s: %d (%s).\n", ent->value, vas, vaf->vaErrorStr(vas)); ++ vaf->vaTerminate(display); + return AVERROR_EXTERNAL; + } + #else +@@ -1970,6 +2112,8 @@ static int vaapi_device_derive(AVHWDeviceContext *ctx, + AVHWDeviceContext *src_ctx, + AVDictionary *opts, int flags) + { ++ VAAPIDeviceContext *hwctx = ctx->hwctx; ++ VAAPIDynLoadFunctions *vaf = hwctx->p.funcs; + #if HAVE_VAAPI_DRM + if (src_ctx->type == AV_HWDEVICE_TYPE_DRM) { + AVDRMDeviceContext *src_hwctx = src_ctx->hwctx; +@@ -2041,7 +2185,7 @@ static int vaapi_device_derive(AVHWDeviceContext *ctx, + ctx->user_opaque = priv; + ctx->free = &vaapi_device_free; + +- display = vaGetDisplayDRM(fd); ++ display = vaf->vaGetDisplayDRM(fd); + if (!display) { + av_log(ctx, AV_LOG_ERROR, "Failed to open a VA display from " + "DRM device.\n"); +diff --git a/libavutil/hwcontext_vaapi.h b/libavutil/hwcontext_vaapi.h +index 0b2e071cb3..2c51223d45 100644 +--- a/libavutil/hwcontext_vaapi.h ++++ b/libavutil/hwcontext_vaapi.h +@@ -20,6 +20,100 @@ + #define AVUTIL_HWCONTEXT_VAAPI_H + + #include ++#include ++#include ++#include ++ ++ ++//////////////////////////////////////////////////////////// ++/// VAAPI dynamic load functions start ++//////////////////////////////////////////////////////////// ++ ++typedef struct VAAPIDynLoadFunctions { ++ // Core VA functions ++ VAStatus (*vaInitialize)(VADisplay dpy, int *major_version, int *minor_version); ++ VAStatus (*vaTerminate)(VADisplay dpy); ++ VAStatus (*vaCreateConfig)(VADisplay dpy, VAProfile profile, VAEntrypoint entrypoint, ++ VAConfigAttrib *attrib_list, int num_attribs, VAConfigID *config_id); ++ VAStatus (*vaDestroyConfig)(VADisplay dpy, VAConfigID config_id); ++ VAStatus (*vaCreateContext)(VADisplay dpy, VAConfigID config_id, int picture_width, ++ int picture_height, int flag, VASurfaceID *render_targets, ++ int num_render_targets, VAContextID *context); ++ VAStatus (*vaDestroyContext)(VADisplay dpy, VAContextID context); ++ VAStatus (*vaCreateBuffer)(VADisplay dpy, VAContextID context, VABufferType type, ++ unsigned int size, unsigned int num_elements, void *data, ++ VABufferID *buf_id); ++ VAStatus (*vaDestroyBuffer)(VADisplay dpy, VABufferID buf_id); ++ VAStatus (*vaMapBuffer)(VADisplay dpy, VABufferID buf_id, void **pbuf); ++ VAStatus (*vaUnmapBuffer)(VADisplay dpy, VABufferID buf_id); ++ VAStatus (*vaSyncSurface)(VADisplay dpy, VASurfaceID render_target); ++ VAStatus (*vaGetConfigAttributes)(VADisplay dpy, VAProfile profile, ++ VAEntrypoint entrypoint, VAConfigAttrib *attrib_list, ++ int num_attribs); ++ VAStatus (*vaCreateSurfaces)(VADisplay dpy, unsigned int format, ++ unsigned int width, unsigned int height, ++ VASurfaceID *surfaces, unsigned int num_surfaces, ++ VASurfaceAttrib *attrib_list, unsigned int num_attribs); ++ VAStatus (*vaDestroySurfaces)(VADisplay dpy, VASurfaceID *surfaces, int num_surfaces); ++ VAStatus (*vaBeginPicture)(VADisplay dpy, VAContextID context, VASurfaceID render_target); ++ VAStatus (*vaRenderPicture)(VADisplay dpy, VAContextID context, ++ VABufferID *buffers, int num_buffers); ++ VAStatus (*vaEndPicture)(VADisplay dpy, VAContextID context); ++ VAStatus (*vaQueryConfigEntrypoints)(VADisplay dpy, VAProfile profile, ++ VAEntrypoint *entrypoint_list, int *num_entrypoints); ++ VAStatus (*vaQueryConfigProfiles)(VADisplay dpy, VAProfile *profile_list, int *num_profiles); ++ VAStatus (*vaGetDisplayAttributes)(VADisplay dpy, VADisplayAttribute *attr_list, int num_attributes); ++ const char *(*vaErrorStr)(VAStatus error_status); ++ int (*vaMaxNumEntrypoints)(VADisplay dpy); ++ int (*vaMaxNumProfiles)(VADisplay dpy); ++ const char *(*vaQueryVendorString)(VADisplay dpy); ++ VAStatus (*vaQuerySurfaceAttributes)(VADisplay dpy, VAConfigID config_id, ++ VASurfaceAttrib *attrib_list, int *num_attribs); ++ VAStatus (*vaDestroyImage)(VADisplay dpy, VAImageID image); ++ VAStatus (*vaDeriveImage)(VADisplay dpy, VASurfaceID surface, VAImage *image); ++ VAStatus (*vaPutImage)(VADisplay dpy, VASurfaceID surface, VAImageID image, ++ int src_x, int src_y, unsigned int src_width, unsigned int src_height, ++ int dest_x, int dest_y, unsigned int dest_width, unsigned int dest_height); ++ VAStatus (*vaCreateImage)(VADisplay dpy, VAImageFormat *format, int width, int height, VAImage *image); ++ VAStatus (*vaGetImage)(VADisplay dpy, VASurfaceID surface, ++ int x, int y, unsigned int width, unsigned int height, ++ VAImageID image); ++ VAStatus (*vaExportSurfaceHandle)(VADisplay dpy, VASurfaceID surface_id, ++ uint32_t mem_type, uint32_t flags, ++ void *descriptor); ++ VAStatus (*vaReleaseBufferHandle)(VADisplay dpy, VABufferID buf_id); ++ VAStatus (*vaAcquireBufferHandle)(VADisplay dpy, VABufferID buf_id, ++ VABufferInfo *buf_info); ++ VAStatus (*vaSetErrorCallback)(VADisplay dpy, VAMessageCallback callback, void *user_context); ++ VAStatus (*vaSetInfoCallback)(VADisplay dpy, VAMessageCallback callback, void *user_context); ++ VAStatus (*vaSetDriverName)(VADisplay dpy, const char *driver_name); ++ const char *(*vaEntrypointStr)(VAEntrypoint entrypoint); ++ VAStatus (*vaQueryImageFormats)(VADisplay dpy, VAImageFormat *format_list, int *num_formats); ++ int (*vaMaxNumImageFormats)(VADisplay dpy); ++ const char *(*vaProfileStr)(VAProfile profile); ++ ++ ++ // Optional functions ++ VAStatus (*vaSyncBuffer)(VADisplay dpy, VABufferID buf_id, uint64_t timeout_ns); ++ ++ // X11 specific functions ++ VADisplay (*vaGetDisplay)(Display *dpy); ++ ++ // DRM specific functions ++ VADisplay (*vaGetDisplayDRM)(int fd); ++ ++ ++ ++ // Library handles ++ void *handle_va; ++ void *handle_va_drm; ++ void *handle_va_x11; ++} VAAPIDynLoadFunctions; ++ ++ ++//////////////////////////////////////////////////////////// ++/// VAAPI API end ++//////////////////////////////////////////////////////////// + + /** + * @file +@@ -78,6 +172,8 @@ typedef struct AVVAAPIDeviceContext { + * operations using VAAPI with the same VADisplay. + */ + unsigned int driver_quirks; ++ ++ VAAPIDynLoadFunctions *funcs; + } AVVAAPIDeviceContext; + + /** +-- +2.34.1 + diff --git a/res/vcpkg/ffmpeg/patch/0007-fix-linux-configure.patch b/res/vcpkg/ffmpeg/patch/0007-fix-linux-configure.patch new file mode 100644 index 000000000..21a1f4d4f --- /dev/null +++ b/res/vcpkg/ffmpeg/patch/0007-fix-linux-configure.patch @@ -0,0 +1,30 @@ +From 595f0468e127f204741b6c37a479d71daaf571eb Mon Sep 17 00:00:00 2001 +From: 21pages +Date: Tue, 10 Dec 2024 21:17:14 +0800 +Subject: [PATCH] fix linux configure + +Signed-off-by: 21pages +--- + configure | 6 ------ + 1 file changed, 6 deletions(-) + +diff --git a/configure b/configure +index d77a55b653..48ca90ac5e 100755 +--- a/configure ++++ b/configure +@@ -7071,12 +7071,6 @@ enabled mmal && { check_lib mmal interface/mmal/mmal.h mmal_port_co + check_lib mmal interface/mmal/mmal.h mmal_port_connect -lmmal_core -lmmal_util -lmmal_vc_client -lbcm_host; } || + die "ERROR: mmal not found" && + check_func_headers interface/mmal/mmal.h "MMAL_PARAMETER_VIDEO_MAX_NUM_CALLBACKS"; } +-enabled openal && { check_pkg_config openal "openal >= 1.1" "AL/al.h" alGetError || +- { for al_extralibs in "${OPENAL_LIBS}" "-lopenal" "-lOpenAL32"; do +- check_lib openal 'AL/al.h' alGetError "${al_extralibs}" && break; done } || +- die "ERROR: openal not found"; } && +- { test_cpp_condition "AL/al.h" "defined(AL_VERSION_1_1)" || +- die "ERROR: openal must be installed and version must be 1.1 or compatible"; } + enabled opencl && { check_pkg_config opencl OpenCL CL/cl.h clEnqueueNDRangeKernel || + check_lib opencl OpenCL/cl.h clEnqueueNDRangeKernel "-framework OpenCL" || + check_lib opencl CL/cl.h clEnqueueNDRangeKernel -lOpenCL || +-- +2.34.1 + diff --git a/res/vcpkg/ffmpeg/patch/0008-remove-amf-loop-query.patch b/res/vcpkg/ffmpeg/patch/0008-remove-amf-loop-query.patch new file mode 100644 index 000000000..fe08aebda --- /dev/null +++ b/res/vcpkg/ffmpeg/patch/0008-remove-amf-loop-query.patch @@ -0,0 +1,26 @@ +From 1440f556234d135ce58a2ef38916c6a63b05870e Mon Sep 17 00:00:00 2001 +From: 21pages +Date: Sat, 14 Dec 2024 21:39:44 +0800 +Subject: [PATCH] remove amf loop query + +Signed-off-by: 21pages +--- + libavcodec/amfenc.c | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + +diff --git a/libavcodec/amfenc.c b/libavcodec/amfenc.c +index f70f0109f6..a53a05b16b 100644 +--- a/libavcodec/amfenc.c ++++ b/libavcodec/amfenc.c +@@ -886,7 +886,7 @@ int ff_amf_receive_packet(AVCodecContext *avctx, AVPacket *avpkt) + av_usleep(1000); + } + } +- } while (block_and_wait); ++ } while (false); // already set query timeout + + if (res_query == AMF_EOF) { + ret = AVERROR_EOF; +-- +2.43.0.windows.1 + diff --git a/res/vcpkg/ffmpeg/patch/0009-fix-nvenc-reconfigure-blur.patch b/res/vcpkg/ffmpeg/patch/0009-fix-nvenc-reconfigure-blur.patch new file mode 100644 index 000000000..2e8aff64a --- /dev/null +++ b/res/vcpkg/ffmpeg/patch/0009-fix-nvenc-reconfigure-blur.patch @@ -0,0 +1,28 @@ +From bec8d49e75b37806e1cff39c75027860fde0bfa2 Mon Sep 17 00:00:00 2001 +From: 21pages +Date: Fri, 27 Dec 2024 08:43:12 +0800 +Subject: [PATCH] fix nvenc reconfigure blur + +Signed-off-by: 21pages +--- + libavcodec/nvenc.c | 4 ++-- + 1 file changed, 2 insertions(+), 2 deletions(-) + +diff --git a/libavcodec/nvenc.c b/libavcodec/nvenc.c +index 2cce478be0..f4c559b7ce 100644 +--- a/libavcodec/nvenc.c ++++ b/libavcodec/nvenc.c +@@ -2741,8 +2741,8 @@ static void reconfig_encoder(AVCodecContext *avctx, const AVFrame *frame) + } + + if (reconfig_bitrate) { +- params.resetEncoder = 1; +- params.forceIDR = 1; ++ params.resetEncoder = 0; ++ params.forceIDR = 0; + + needs_encode_config = 1; + needs_reconfig = 1; +-- +2.43.0.windows.1 + diff --git a/res/vcpkg/ffmpeg/patch/0010.disable-loading-DLLs-from-app-dir.patch b/res/vcpkg/ffmpeg/patch/0010.disable-loading-DLLs-from-app-dir.patch new file mode 100644 index 000000000..18da50b44 --- /dev/null +++ b/res/vcpkg/ffmpeg/patch/0010.disable-loading-DLLs-from-app-dir.patch @@ -0,0 +1,31 @@ +diff --git a/compat/w32dlfcn.h b/compat/w32dlfcn.h +index ac20e83..1e83aa6 100644 +--- a/compat/w32dlfcn.h ++++ b/compat/w32dlfcn.h +@@ -76,6 +76,7 @@ static inline HMODULE win32_dlopen(const char *name) + if (!name_w) + goto exit; + namelen = wcslen(name_w); ++ /* + // Try local directory first + path = get_module_filename(NULL); + if (!path) +@@ -91,6 +92,7 @@ static inline HMODULE win32_dlopen(const char *name) + path = new_path; + wcscpy(path + pathlen + 1, name_w); + module = LoadLibraryExW(path, NULL, LOAD_WITH_ALTERED_SEARCH_PATH); ++ */ + if (module == NULL) { + // Next try System32 directory + pathlen = GetSystemDirectoryW(path, pathsize); +@@ -131,7 +133,9 @@ exit: + return NULL; + module = LoadPackagedLibrary(name_w, 0); + #else +-#define LOAD_FLAGS (LOAD_LIBRARY_SEARCH_APPLICATION_DIR | LOAD_LIBRARY_SEARCH_SYSTEM32) ++// #define LOAD_FLAGS (LOAD_LIBRARY_SEARCH_APPLICATION_DIR | LOAD_LIBRARY_SEARCH_SYSTEM32) ++// Don't dynamic-link libraries from the application directory. ++ #define LOAD_FLAGS LOAD_LIBRARY_SEARCH_SYSTEM32 + /* filename may be be in CP_ACP */ + if (!name_w) + return LoadLibraryExA(name, NULL, LOAD_FLAGS); diff --git a/res/vcpkg/ffmpeg/patch/0011-android-mediacodec-encode-align-64.patch b/res/vcpkg/ffmpeg/patch/0011-android-mediacodec-encode-align-64.patch new file mode 100644 index 000000000..28661cb75 --- /dev/null +++ b/res/vcpkg/ffmpeg/patch/0011-android-mediacodec-encode-align-64.patch @@ -0,0 +1,42 @@ +From a609e1666c79ccce4faf7aa61d509bf202df9149 Mon Sep 17 00:00:00 2001 +From: 21pages +Date: Fri, 5 Sep 2025 21:35:37 +0800 +Subject: [PATCH] android mediacodec encode align 64 + +Signed-off-by: 21pages +--- + libavcodec/mediacodecenc.c | 11 ++++++----- + 1 file changed, 6 insertions(+), 5 deletions(-) + +diff --git a/libavcodec/mediacodecenc.c b/libavcodec/mediacodecenc.c +index 221f7360f4..768c8151df 100644 +--- a/libavcodec/mediacodecenc.c ++++ b/libavcodec/mediacodecenc.c +@@ -242,18 +242,19 @@ static av_cold int mediacodec_init(AVCodecContext *avctx) + ff_AMediaFormat_setString(format, "mime", codec_mime); + // Workaround the alignment requirement of mediacodec. We can't do it + // silently for AV_PIX_FMT_MEDIACODEC. ++ const int align = 64; + if (avctx->pix_fmt != AV_PIX_FMT_MEDIACODEC && + (avctx->codec_id == AV_CODEC_ID_H264 || + avctx->codec_id == AV_CODEC_ID_HEVC)) { +- s->width = FFALIGN(avctx->width, 16); +- s->height = FFALIGN(avctx->height, 16); ++ s->width = FFALIGN(avctx->width, align); ++ s->height = FFALIGN(avctx->height, align); + } else { + s->width = avctx->width; + s->height = avctx->height; +- if (s->width % 16 || s->height % 16) ++ if (s->width % align || s->height % align) + av_log(avctx, AV_LOG_WARNING, +- "Video size %dx%d isn't align to 16, it may have device compatibility issue\n", +- s->width, s->height); ++ "Video size %dx%d isn't align to %d, it may have device compatibility issue\n", ++ s->width, s->height, align); + } + ff_AMediaFormat_setInt32(format, "width", s->width); + ff_AMediaFormat_setInt32(format, "height", s->height); +-- +2.43.0.windows.1 + diff --git a/res/vcpkg/ffmpeg/patch/0012-fix-macos-big-sur-CVBufferCopyAttachments.patch b/res/vcpkg/ffmpeg/patch/0012-fix-macos-big-sur-CVBufferCopyAttachments.patch new file mode 100644 index 000000000..efcd58162 --- /dev/null +++ b/res/vcpkg/ffmpeg/patch/0012-fix-macos-big-sur-CVBufferCopyAttachments.patch @@ -0,0 +1,60 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: RustDesk +Date: Fri, 1 Nov 2025 08:00:00 +0000 +Subject: [PATCH] Fix CVBufferCopyAttachments crash on macOS Big Sur + +Use weak linking for CVBufferCopyAttachments to avoid symbol resolution +crash on macOS < 12. The function will be NULL on older systems and the +code will fall back to the deprecated CVBufferGetAttachments. + +This fixes a crash on macOS Big Sur (11.x) where CVBufferCopyAttachments +is not available. The runtime check with __builtin_available is not enough +because the symbol is still resolved at load time, causing a dyld error. + +Fixes: https://github.com/rustdesk/rustdesk/issues/13377 +--- + libavutil/hwcontext_videotoolbox.c | 21 ++++++++++++++++++++- + 1 file changed, 20 insertions(+), 1 deletion(-) + +diff --git a/libavutil/hwcontext_videotoolbox.c b/libavutil/hwcontext_videotoolbox.c +index 0000000000..1111111111 100644 +--- a/libavutil/hwcontext_videotoolbox.c ++++ b/libavutil/hwcontext_videotoolbox.c +@@ -33,6 +33,25 @@ + #include "pixfmt.h" + #include "pixdesc.h" + ++// Weak import CVBufferCopyAttachments to support macOS < 12 ++// The runtime check with __builtin_available is not enough because ++// the symbol is still resolved at load time, causing dyld errors on Big Sur. ++// With weak_import, the function pointer will be NULL on older systems. ++#if TARGET_OS_OSX && defined(__MAC_12_0) && __MAC_OS_X_VERSION_MAX_ALLOWED >= __MAC_12_0 ++extern CFDictionaryRef CVBufferCopyAttachments(CVBufferRef buffer, CVAttachmentMode mode) ++ __attribute__((weak_import)); ++#endif ++#if TARGET_OS_IOS && defined(__IPHONE_15_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_15_0 ++extern CFDictionaryRef CVBufferCopyAttachments(CVBufferRef buffer, CVAttachmentMode mode) ++ __attribute__((weak_import)); ++#endif ++#if TARGET_OS_TV && defined(__TVOS_15_0) && __TV_OS_VERSION_MAX_ALLOWED >= __TVOS_15_0 ++extern CFDictionaryRef CVBufferCopyAttachments(CVBufferRef buffer, CVAttachmentMode mode) ++ __attribute__((weak_import)); ++#endif ++ ++// End of weak import section ++ + typedef struct VTFramesContext { + /** + * The public AVVTFramesContext. See hwcontext_videotoolbox.h for it. +@@ -547,7 +566,7 @@ static CFDictionaryRef vt_cv_buffer_copy_attachments(CVBufferRef buffer, + (TARGET_OS_TV && defined(__TVOS_15_0) && __TV_OS_VERSION_MAX_ALLOWED >= __TVOS_15_0) + // On recent enough versions, just use the respective API + if (__builtin_available(macOS 12.0, iOS 15.0, tvOS 15.0, *)) +- return CVBufferCopyAttachments(buffer, attachment_mode); ++ if (CVBufferCopyAttachments != NULL) return CVBufferCopyAttachments(buffer, attachment_mode); + #endif + + // Check that the target is lower than macOS 12 / iOS 15 / tvOS 15 +-- +2.43.0 + diff --git a/res/vcpkg/ffmpeg/portfile.cmake b/res/vcpkg/ffmpeg/portfile.cmake index 3d4c10906..16cef8350 100644 --- a/res/vcpkg/ffmpeg/portfile.cmake +++ b/res/vcpkg/ffmpeg/portfile.cmake @@ -2,17 +2,32 @@ vcpkg_from_github( OUT_SOURCE_PATH SOURCE_PATH REPO ffmpeg/ffmpeg REF "n${VERSION}" - SHA512 3ba02e8b979c80bf61d55f414bdac2c756578bb36498ed7486151755c6ccf8bd8ff2b8c7afa3c5d1acd862ce48314886a86a105613c05e36601984c334f8f6bf + SHA512 3b273769ef1a1b63aed0691eef317a760f8c83b1d0e1c232b67bbee26db60b4864aafbc88df0e86d6bebf07185bbd057f33e2d5258fde6d97763b9994cd48b6f HEAD_REF master PATCHES - 0002-fix-msvc-link.patch # upstreamed in future version + 0001-create-lib-libraries.patch + 0002-fix-msvc-link.patch 0003-fix-windowsinclude.patch - 0005-fix-nasm.patch # upstreamed in future version - 0012-Fix-ssl-110-detection.patch + 0004-dependencies.patch + 0005-fix-nasm.patch + 0007-fix-lib-naming.patch 0013-define-WINVER.patch + 0020-fix-aarch64-libswscale.patch + 0024-fix-osx-host-c11.patch + 0040-ffmpeg-add-av_stream_get_first_dts-for-chromium.patch # Do not remove this patch. It is required by chromium + 0041-add-const-for-opengl-definition.patch + 0043-fix-miss-head.patch patch/0001-avcodec-amfenc-add-query_timeout-option-for-h264-hev.patch patch/0002-libavcodec-amfenc-reconfig-when-bitrate-change.patch - patch/0003-amf-colorspace.patch + patch/0004-videotoolbox-changing-bitrate.patch + patch/0005-mediacodec-changing-bitrate.patch + patch/0006-dlopen-libva.patch + patch/0007-fix-linux-configure.patch + patch/0008-remove-amf-loop-query.patch + patch/0009-fix-nvenc-reconfigure-blur.patch + patch/0010.disable-loading-DLLs-from-app-dir.patch + patch/0011-android-mediacodec-encode-align-64.patch + patch/0012-fix-macos-big-sur-CVBufferCopyAttachments.patch ) if(SOURCE_PATH MATCHES " ") @@ -48,6 +63,7 @@ set(OPTIONS "\ --disable-debug \ --disable-valgrind-backtrace \ --disable-large-tests \ +--disable-bzlib \ --disable-avdevice \ --enable-avcodec \ --enable-avformat \ @@ -77,13 +93,15 @@ else() endif() if(VCPKG_TARGET_IS_LINUX) - string(APPEND OPTIONS "\ + string(APPEND OPTIONS "\ --target-os=linux \ --enable-pthreads \ +--disable-vdpau \ ") + if(VCPKG_TARGET_ARCHITECTURE STREQUAL "arm") else() - string(APPEND OPTIONS "\ + string(APPEND OPTIONS "\ --enable-cuda \ --enable-ffnvcodec \ --enable-encoder=h264_nvenc \ @@ -98,8 +116,9 @@ if(VCPKG_TARGET_IS_LINUX) --enable-encoder=h264_vaapi \ --enable-encoder=hevc_vaapi \ ") + if(VCPKG_TARGET_ARCHITECTURE STREQUAL "x64") - string(APPEND OPTIONS "\ + string(APPEND OPTIONS "\ --enable-cuda_llvm \ ") endif() @@ -127,7 +146,8 @@ elseif(VCPKG_TARGET_IS_WINDOWS) --enable-libmfx \ --enable-encoder=h264_qsv \ --enable-encoder=hevc_qsv \ -") +") + if(VCPKG_TARGET_ARCHITECTURE STREQUAL "x86") set(LIB_MACHINE_ARG /machine:x86) string(APPEND OPTIONS " --arch=i686 --enable-cross-compile") @@ -158,6 +178,7 @@ elseif(VCPKG_CMAKE_SYSTEM_NAME STREQUAL "Android") string(APPEND OPTIONS "\ --target-os=android \ --disable-asm \ +--disable-iconv \ --enable-jni \ --enable-mediacodec \ --disable-hwaccels \ @@ -189,6 +210,7 @@ endif() string(APPEND VCPKG_COMBINED_C_FLAGS_DEBUG " -I \"${CURRENT_INSTALLED_DIR}/include\"") string(APPEND VCPKG_COMBINED_C_FLAGS_RELEASE " -I \"${CURRENT_INSTALLED_DIR}/include\"") + if(VCPKG_TARGET_IS_WINDOWS) string(APPEND VCPKG_COMBINED_C_FLAGS_DEBUG " -I \"${CURRENT_INSTALLED_DIR}/include/mfx\"") string(APPEND VCPKG_COMBINED_C_FLAGS_RELEASE " -I \"${CURRENT_INSTALLED_DIR}/include/mfx\"") @@ -202,9 +224,11 @@ if(VCPKG_DETECTED_CMAKE_C_COMPILER) get_filename_component(CC_filename "${VCPKG_DETECTED_CMAKE_C_COMPILER}" NAME) set(ENV{CC} "${CC_filename}") string(APPEND OPTIONS " --cc=${CC_filename}") + if(VCPKG_HOST_IS_WINDOWS) string(APPEND OPTIONS " --host_cc=${CC_filename}") endif() + list(APPEND prog_env "${CC_path}") endif() @@ -282,6 +306,7 @@ if(VCPKG_HOST_IS_WINDOWS) else() # find_program(SHELL bash) endif() + list(REMOVE_DUPLICATES prog_env) vcpkg_add_to_path(PREPEND ${prog_env}) diff --git a/res/vcpkg/ffmpeg/vcpkg.json b/res/vcpkg/ffmpeg/vcpkg.json index f7612d928..0346bb585 100644 --- a/res/vcpkg/ffmpeg/vcpkg.json +++ b/res/vcpkg/ffmpeg/vcpkg.json @@ -1,7 +1,7 @@ { "name": "ffmpeg", - "version": "7.0.2", - "port-version": 0, + "version": "7.1", + "port-version": 1, "description": [ "a library to decode, encode, transcode, mux, demux, stream, filter and play pretty much anything that humans and machines have created.", "FFmpeg is the leading multimedia framework, able to decode, encode, transcode, mux, demux, stream, filter and play pretty much anything that humans and machines have created. It supports the most obscure ancient formats up to the cutting edge. No matter if they were designed by some standards committee, the community or a corporation. It is also highly portable: FFmpeg compiles, runs, and passes our testing infrastructure FATE across Linux, Mac OS X, Microsoft Windows, the BSDs, Solaris, etc. under a wide variety of build environments, machine architectures, and configurations." diff --git a/res/vcpkg/libvpx/0002-Fix-nasm-debug-format-flag.patch b/res/vcpkg/libvpx/0002-Fix-nasm-debug-format-flag.patch deleted file mode 100644 index 5f4749ae0..000000000 --- a/res/vcpkg/libvpx/0002-Fix-nasm-debug-format-flag.patch +++ /dev/null @@ -1,21 +0,0 @@ -diff --git a/build/make/configure.sh b/build/make/configure.sh -index 81d30a1..325017e 100644 ---- a/build/make/configure.sh -+++ b/build/make/configure.sh -@@ -1370,12 +1370,14 @@ EOF - case ${tgt_os} in - win32) - add_asflags -f win32 -- enabled debug && add_asflags -g cv8 -+ enabled debug && [ "${AS}" = yasm ] && add_asflags -g cv8 -+ enabled debug && [ "${AS}" = nasm ] && add_asflags -gcv8 - EXE_SFX=.exe - ;; - win64) - add_asflags -f win64 -- enabled debug && add_asflags -g cv8 -+ enabled debug && [ "${AS}" = yasm ] && add_asflags -g cv8 -+ enabled debug && [ "${AS}" = nasm ] && add_asflags -gcv8 - EXE_SFX=.exe - ;; - linux*|solaris*|android*) diff --git a/res/vcpkg/libvpx/0003-add-uwp-v142-and-v143-support.patch b/res/vcpkg/libvpx/0003-add-uwp-v142-and-v143-support.patch index 32222238c..c9a01b744 100644 --- a/res/vcpkg/libvpx/0003-add-uwp-v142-and-v143-support.patch +++ b/res/vcpkg/libvpx/0003-add-uwp-v142-and-v143-support.patch @@ -1,8 +1,8 @@ diff --git a/build/make/configure.sh b/build/make/configure.sh -index 110f16e..c161d0e 100644 +index cc5bf6ce4..9380e87a7 100644 --- a/build/make/configure.sh +++ b/build/make/configure.sh -@@ -1038,7 +1038,7 @@ EOF +@@ -1092,7 +1092,7 @@ EOF # A number of ARM-based Windows platforms are constrained by their # respective SDKs' limitations. Fortunately, these are all 32-bit ABIs # and so can be selected as 'win32'. @@ -11,7 +11,7 @@ index 110f16e..c161d0e 100644 asm_conversion_cmd="${source_path_mk}/build/make/ads2armasm_ms.pl" AS_SFX=.S msvs_arch_dir=arm-msvs -@@ -1272,6 +1272,9 @@ EOF +@@ -1366,6 +1366,9 @@ EOF android) soft_enable realtime_only ;; @@ -21,12 +21,12 @@ index 110f16e..c161d0e 100644 win*) enabled gcc && add_cflags -fno-common ;; -@@ -1390,6 +1393,16 @@ EOF +@@ -1484,14 +1487,26 @@ EOF fi AS_SFX=.asm case ${tgt_os} in + uwp) -+ if [ {$tgt_isa} = "x86" ] || [ {$tgt_isa} = "armv7" ]; then ++ if [ ${tgt_isa} = "x86" ] || [ ${tgt_isa} = "armv7" ]; then + add_asflags -f win32 + else + add_asflags -f win64 @@ -37,8 +37,20 @@ index 110f16e..c161d0e 100644 + ;; win32) add_asflags -f win32 - enabled debug && [ "${AS}" = yasm ] && add_asflags -g cv8 -@@ -1519,6 +1532,8 @@ EOF +- enabled debug && add_asflags -g cv8 ++ enabled debug && [ "${AS}" = yasm ] && add_asflags -g cv8 ++ enabled debug && [ "${AS}" = nasm ] && add_asflags -gcv8 + EXE_SFX=.exe + ;; + win64) + add_asflags -f win64 +- enabled debug && add_asflags -g cv8 ++ enabled debug && [ "${AS}" = yasm ] && add_asflags -g cv8 ++ enabled debug && [ "${AS}" = nasm ] && add_asflags -gcv8 + EXE_SFX=.exe + ;; + linux*|solaris*|android*) +@@ -1622,6 +1637,8 @@ EOF # Almost every platform uses pthreads. if enabled multithread; then case ${toolchain} in @@ -48,10 +60,10 @@ index 110f16e..c161d0e 100644 ;; *-android-gcc) diff --git a/build/make/gen_msvs_vcxproj.sh b/build/make/gen_msvs_vcxproj.sh -index 58bb66b..b4cad6c 100644 +index 1e1db05bb..543eb37b2 100755 --- a/build/make/gen_msvs_vcxproj.sh +++ b/build/make/gen_msvs_vcxproj.sh -@@ -296,7 +296,22 @@ generate_vcxproj() { +@@ -310,7 +310,22 @@ generate_vcxproj() { tag_content ProjectGuid "{${guid}}" tag_content RootNamespace ${name} tag_content Keyword ManagedCProj @@ -75,7 +87,7 @@ index 58bb66b..b4cad6c 100644 tag_content AppContainerApplication true # The application type can be one of "Windows Store", # "Windows Phone" or "Windows Phone Silverlight". The -@@ -394,7 +409,7 @@ generate_vcxproj() { +@@ -412,7 +427,7 @@ generate_vcxproj() { Condition="'\$(Configuration)|\$(Platform)'=='$config|$plat'" if [ "$name" == "vpx" ]; then hostplat=$plat @@ -85,19 +97,19 @@ index 58bb66b..b4cad6c 100644 fi fi diff --git a/configure b/configure -index b212e07..1a9fa98 100755 +index 457bd6b38..fa4bce71b 100755 --- a/configure +++ b/configure -@@ -104,6 +104,8 @@ all_platforms="${all_platforms} arm64-darwin21-gcc" - all_platforms="${all_platforms} arm64-darwin22-gcc" +@@ -105,6 +105,8 @@ all_platforms="${all_platforms} arm64-darwin22-gcc" all_platforms="${all_platforms} arm64-darwin23-gcc" + all_platforms="${all_platforms} arm64-darwin24-gcc" all_platforms="${all_platforms} arm64-linux-gcc" +all_platforms="${all_platforms} arm64-uwp-vs16" +all_platforms="${all_platforms} arm64-uwp-vs17" all_platforms="${all_platforms} arm64-win64-gcc" all_platforms="${all_platforms} arm64-win64-vs15" all_platforms="${all_platforms} arm64-win64-vs16" -@@ -115,6 +117,8 @@ all_platforms="${all_platforms} armv7-darwin-gcc" #neon Cortex-A8 +@@ -116,6 +118,8 @@ all_platforms="${all_platforms} armv7-darwin-gcc" #neon Cortex-A8 all_platforms="${all_platforms} armv7-linux-rvct" #neon Cortex-A8 all_platforms="${all_platforms} armv7-linux-gcc" #neon Cortex-A8 all_platforms="${all_platforms} armv7-none-rvct" #neon Cortex-A8 @@ -106,7 +118,7 @@ index b212e07..1a9fa98 100755 all_platforms="${all_platforms} armv7-win32-gcc" all_platforms="${all_platforms} armv7-win32-vs14" all_platforms="${all_platforms} armv7-win32-vs15" -@@ -146,6 +150,8 @@ all_platforms="${all_platforms} x86-linux-gcc" +@@ -147,6 +151,8 @@ all_platforms="${all_platforms} x86-linux-gcc" all_platforms="${all_platforms} x86-linux-icc" all_platforms="${all_platforms} x86-os2-gcc" all_platforms="${all_platforms} x86-solaris-gcc" @@ -115,7 +127,7 @@ index b212e07..1a9fa98 100755 all_platforms="${all_platforms} x86-win32-gcc" all_platforms="${all_platforms} x86-win32-vs14" all_platforms="${all_platforms} x86-win32-vs15" -@@ -171,6 +177,8 @@ all_platforms="${all_platforms} x86_64-iphonesimulator-gcc" +@@ -173,6 +179,8 @@ all_platforms="${all_platforms} x86_64-iphonesimulator-gcc" all_platforms="${all_platforms} x86_64-linux-gcc" all_platforms="${all_platforms} x86_64-linux-icc" all_platforms="${all_platforms} x86_64-solaris-gcc" @@ -124,7 +136,7 @@ index b212e07..1a9fa98 100755 all_platforms="${all_platforms} x86_64-win64-gcc" all_platforms="${all_platforms} x86_64-win64-vs14" all_platforms="${all_platforms} x86_64-win64-vs15" -@@ -503,11 +511,10 @@ process_targets() { +@@ -507,11 +515,10 @@ process_targets() { ! enabled multithread && DIST_DIR="${DIST_DIR}-nomt" ! enabled install_docs && DIST_DIR="${DIST_DIR}-nodocs" DIST_DIR="${DIST_DIR}-${tgt_isa}-${tgt_os}" @@ -140,7 +152,7 @@ index b212e07..1a9fa98 100755 if [ -f "${source_path}/build/make/version.sh" ]; then ver=`"$source_path/build/make/version.sh" --bare "$source_path"` DIST_DIR="${DIST_DIR}-${ver}" -@@ -596,6 +603,10 @@ process_detect() { +@@ -600,6 +607,10 @@ process_detect() { # Specialize windows and POSIX environments. case $toolchain in @@ -151,3 +163,6 @@ index b212e07..1a9fa98 100755 *-win*-*) # Don't check for any headers in Windows builds. false +-- +2.49.0 + diff --git a/res/vcpkg/libvpx/portfile.cmake b/res/vcpkg/libvpx/portfile.cmake index 96eab8717..fbc60b9d8 100644 --- a/res/vcpkg/libvpx/portfile.cmake +++ b/res/vcpkg/libvpx/portfile.cmake @@ -4,10 +4,9 @@ vcpkg_from_github( OUT_SOURCE_PATH SOURCE_PATH REPO webmproject/libvpx REF "v${VERSION}" - SHA512 3e3bfad3d035c0bc3db7cb5a194d56d3c90f5963fb1ad527ae5252054e7c48ce2973de1346c97d94b59f7a95d4801bec44214cce10faf123f92b36fca79a8d1e + SHA512 824fe8719e4115ec359ae0642f5e1cea051d458f09eb8c24d60858cf082f66e411215e23228173ab154044bafbdfbb2d93b589bb726f55b233939b91f928aae0 HEAD_REF master PATCHES - 0002-Fix-nasm-debug-format-flag.patch 0003-add-uwp-v142-and-v143-support.patch 0004-remove-library-suffixes.patch ) @@ -226,6 +225,12 @@ else() set(LIBVPX_TARGET "generic-gnu") # use default target endif() + if (VCPKG_HOST_IS_OPENBSD OR VCPKG_HOST_IS_FREEBSD) + set(MAKE_BINARY "gmake") + else() + set(MAKE_BINARY "make") + endif() + message(STATUS "Build info. Target: ${LIBVPX_TARGET}; Options: ${OPTIONS}") if(NOT DEFINED VCPKG_BUILD_TYPE OR VCPKG_BUILD_TYPE STREQUAL "release") @@ -246,7 +251,7 @@ else() message(STATUS "Building libvpx for Release") vcpkg_execute_required_process( COMMAND - ${BASH} --noprofile --norc -c "make -j${VCPKG_CONCURRENCY}" + ${BASH} --noprofile --norc -c "${MAKE_BINARY} -j${VCPKG_CONCURRENCY}" WORKING_DIRECTORY "${CURRENT_BUILDTREES_DIR}/${TARGET_TRIPLET}-rel" LOGNAME build-${TARGET_TRIPLET}-rel ) @@ -254,7 +259,7 @@ else() message(STATUS "Installing libvpx for Release") vcpkg_execute_required_process( COMMAND - ${BASH} --noprofile --norc -c "make install" + ${BASH} --noprofile --norc -c "${MAKE_BINARY} install" WORKING_DIRECTORY "${CURRENT_BUILDTREES_DIR}/${TARGET_TRIPLET}-rel" LOGNAME install-${TARGET_TRIPLET}-rel ) @@ -280,7 +285,7 @@ else() message(STATUS "Building libvpx for Debug") vcpkg_execute_required_process( COMMAND - ${BASH} --noprofile --norc -c "make -j${VCPKG_CONCURRENCY}" + ${BASH} --noprofile --norc -c "${MAKE_BINARY} -j${VCPKG_CONCURRENCY}" WORKING_DIRECTORY "${CURRENT_BUILDTREES_DIR}/${TARGET_TRIPLET}-dbg" LOGNAME build-${TARGET_TRIPLET}-dbg ) @@ -288,7 +293,7 @@ else() message(STATUS "Installing libvpx for Debug") vcpkg_execute_required_process( COMMAND - ${BASH} --noprofile --norc -c "make install" + ${BASH} --noprofile --norc -c "${MAKE_BINARY} install" WORKING_DIRECTORY "${CURRENT_BUILDTREES_DIR}/${TARGET_TRIPLET}-dbg" LOGNAME install-${TARGET_TRIPLET}-dbg ) diff --git a/res/vcpkg/libvpx/vcpkg.json b/res/vcpkg/libvpx/vcpkg.json index ca4a47d30..ac9775ef6 100644 --- a/res/vcpkg/libvpx/vcpkg.json +++ b/res/vcpkg/libvpx/vcpkg.json @@ -1,7 +1,6 @@ { "name": "libvpx", - "version": "1.14.1", - "port-version": 0, + "version": "1.15.2", "description": "The reference software implementation for the video coding formats VP8 and VP9.", "homepage": "https://github.com/webmproject/libvpx", "license": "BSD-3-Clause", diff --git a/res/vcpkg/mfx-dispatch/0003-upgrade-cmake-3.14.patch b/res/vcpkg/mfx-dispatch/0003-upgrade-cmake-3.14.patch new file mode 100644 index 000000000..676c0dd7a --- /dev/null +++ b/res/vcpkg/mfx-dispatch/0003-upgrade-cmake-3.14.patch @@ -0,0 +1,10 @@ +diff --git a/CMakeLists.txt b/CMakeLists.txt +index a8a3288..7d01d97 100644 +--- a/CMakeLists.txt ++++ b/CMakeLists.txt +@@ -1,4 +1,4 @@ +-cmake_minimum_required(VERSION 2.6) ++cmake_minimum_required(VERSION 3.14) + + project( libmfx ) + diff --git a/res/vcpkg/mfx-dispatch/fix-pkgconf.patch b/res/vcpkg/mfx-dispatch/fix-pkgconf.patch new file mode 100644 index 000000000..c0310e12a --- /dev/null +++ b/res/vcpkg/mfx-dispatch/fix-pkgconf.patch @@ -0,0 +1,39 @@ +diff --git a/CMakeLists.txt b/CMakeLists.txt +index 9446bc4..a8a3288 100644 +--- a/CMakeLists.txt ++++ b/CMakeLists.txt +@@ -3,16 +3,7 @@ cmake_minimum_required(VERSION 2.6) + project( libmfx ) + + # FIXME Adds support for using system/other install of intel media sdk +-find_path ( INTELMEDIASDK_PATH mfx/mfxvideo.h +- HINTS "${CMAKE_SOURCE_DIR}" +-) +- +-if (INTELMEDIASDK_PATH_NOTFOUND) +- message( FATAL_ERROR "Intel MEDIA SDK include not found" ) +-else (INTELMEDIASDK_PATH_NOTFOUND) +- message(STATUS "Intel Media SDK is here: ${INTELMEDIASDK_PATH}") +-endif (INTELMEDIASDK_PATH_NOTFOUND) +- ++set(INTELMEDIASDK_PATH "${CMAKE_CURRENT_LIST_DIR}") + + set(SOURCES + src/main.cpp +diff --git a/libmfx.pc.cmake b/libmfx.pc.cmake +index fabb541..5d248fe 100644 +--- a/libmfx.pc.cmake ++++ b/libmfx.pc.cmake +@@ -6,9 +6,9 @@ Requires.private: + Name: libmfx + Description: Intel Media SDK Dispatched static library +-Version: 2013 ++Version: 1.35 + Requires: + Requires.private: + Conflicts: +-Libs: -L${libdir} -lsupc++ ${libdir}/libmfx.lib ++Libs: -L${libdir} -llibmfx + Libs.private: +-Cflags: -I${includedir} -I@INTELMEDIASDK_PATH@ ++Cflags: -I${includedir} diff --git a/res/vcpkg/mfx-dispatch/fix-unresolved-symbol.patch b/res/vcpkg/mfx-dispatch/fix-unresolved-symbol.patch new file mode 100644 index 000000000..96d9e6d90 --- /dev/null +++ b/res/vcpkg/mfx-dispatch/fix-unresolved-symbol.patch @@ -0,0 +1,66 @@ +Subject: [PATCH] fix for vcpkg +fix missing mfx_driver_store_loader related symbols +--- +Index: CMakeLists.txt +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/CMakeLists.txt b/CMakeLists.txt +--- a/CMakeLists.txt (revision 7e4d221c36c630c1250b23a5dfa15657bc04c10c) ++++ b/CMakeLists.txt (revision 5ebef171699530ca01594a5cef10a68811f4d105) +@@ -40,6 +39,7 @@ + src/mfx_load_plugin.cpp + src/mfx_plugin_hive.cpp + src/mfx_win_reg_key.cpp ++ src/mfx_driver_store_loader.cpp + ) + endif (CMAKE_SYSTEM_NAME MATCHES "Windows") + +@@ -56,6 +56,12 @@ + configure_file (${CMAKE_SOURCE_DIR}/libmfx.pc.cmake ${CMAKE_BINARY_DIR}/libmfx.pc @ONLY) + + add_library( mfx STATIC ${SOURCES} ) ++ ++if (CMAKE_SYSTEM_NAME MATCHES "Windows") ++ set_target_properties(mfx ++ PROPERTIES PREFIX lib) ++endif (CMAKE_SYSTEM_NAME MATCHES "Windows") ++ + install (DIRECTORY ${CMAKE_SOURCE_DIR}/mfx DESTINATION ${CMAKE_INSTALL_PREFIX}/include FILES_MATCHING PATTERN "*.h") + install (FILES ${CMAKE_BINARY_DIR}/libmfx.pc DESTINATION ${CMAKE_INSTALL_PREFIX}/lib/pkgconfig) + install (TARGETS mfx ARCHIVE DESTINATION ${CMAKE_INSTALL_PREFIX}/lib) +Index: libmfx.pc.cmake +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/libmfx.pc.cmake b/libmfx.pc.cmake +--- a/libmfx.pc.cmake (revision 7e4d221c36c630c1250b23a5dfa15657bc04c10c) ++++ b/libmfx.pc.cmake (revision 388559e9e8234eb0989e1598a9beea4035a04132) +@@ -9,6 +9,6 @@ + Requires: + Requires.private: + Conflicts: +-Libs: -L${libdir} -lsupc++ ${libdir}/libmfx.a ++Libs: -L${libdir} -lsupc++ ${libdir}/libmfx.lib + Libs.private: + Cflags: -I${includedir} -I@INTELMEDIASDK_PATH@ +Index: src/mfx_driver_store_loader.cpp +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +diff --git a/src/mfx_driver_store_loader.cpp b/src/mfx_driver_store_loader.cpp +--- a/src/mfx_driver_store_loader.cpp (revision 388559e9e8234eb0989e1598a9beea4035a04132) ++++ b/src/mfx_driver_store_loader.cpp (revision 5ebef171699530ca01594a5cef10a68811f4d105) +@@ -24,6 +24,9 @@ + #include "mfx_dispatcher_log.h" + #include "mfx_load_dll.h" + ++#pragma comment(lib, "Ole32.lib") ++#pragma comment(lib, "Advapi32.lib") ++ + namespace MFX + { + diff --git a/res/vcpkg/mfx-dispatch/portfile.cmake b/res/vcpkg/mfx-dispatch/portfile.cmake new file mode 100644 index 000000000..cb2ad7e7c --- /dev/null +++ b/res/vcpkg/mfx-dispatch/portfile.cmake @@ -0,0 +1,40 @@ +vcpkg_download_distfile( + MISSING_CSTDINT_IMPORT_PATCH + URLS https://github.com/lu-zero/mfx_dispatch/commit/d6241243f85a0d947bdfe813006686a930edef24.patch?full_index=1 + FILENAME fix-missing-cstdint-import-d6241243f85a0d947bdfe813006686a930edef24.patch + SHA512 5d2ffc4ec2ba0e5859d01d2e072f75436ebc3e62e0f6580b5bb8b9f82fe588e7558a46a1fdfa0297a782c0eeb8f50322258d0dd9e41d927cc9be496727b61e44 +) + +vcpkg_from_github( + OUT_SOURCE_PATH SOURCE_PATH + REPO lu-zero/mfx_dispatch + REF "${VERSION}" + SHA512 12517338342d3e653043a57e290eb9cffd190aede0c3a3948956f1c7f12f0ea859361cf3e534ab066b96b1c211f68409c67ef21fd6d76b68cc31daef541941b0 + HEAD_REF master + PATCHES + fix-unresolved-symbol.patch + fix-pkgconf.patch + 0003-upgrade-cmake-3.14.patch + ${MISSING_CSTDINT_IMPORT_PATCH} +) + +if(VCPKG_TARGET_IS_WINDOWS AND NOT VCPKG_TARGET_IS_MINGW) + vcpkg_cmake_configure( + SOURCE_PATH "${SOURCE_PATH}" + ) + vcpkg_cmake_install() + vcpkg_copy_pdbs() +else() + if(VCPKG_TARGET_IS_MINGW) + vcpkg_check_linkage(ONLY_STATIC_LIBRARY) + endif() + vcpkg_configure_make( + SOURCE_PATH "${SOURCE_PATH}" + AUTOCONFIG + ) + vcpkg_install_make() +endif() +vcpkg_fixup_pkgconfig() + +file(REMOVE_RECURSE "${CURRENT_PACKAGES_DIR}/debug/include") +vcpkg_install_copyright(FILE_LIST "${SOURCE_PATH}/LICENSE") diff --git a/res/vcpkg/mfx-dispatch/vcpkg.json b/res/vcpkg/mfx-dispatch/vcpkg.json new file mode 100644 index 000000000..e83747188 --- /dev/null +++ b/res/vcpkg/mfx-dispatch/vcpkg.json @@ -0,0 +1,16 @@ +{ + "name": "mfx-dispatch", + "version": "1.35.1", + "port-version": 5, + "description": "Open source Intel media sdk dispatcher", + "homepage": "https://github.com/lu-zero/mfx_dispatch", + "license": "BSD-3-Clause", + "supports": "((x86 | x64) & (android | linux)) | (windows & !uwp)", + "dependencies": [ + { + "name": "vcpkg-cmake", + "host": true, + "platform": "windows & !mingw" + } + ] +} diff --git a/res/vcpkg/oboe-wrapper/CMakeLists.txt b/res/vcpkg/oboe-wrapper/CMakeLists.txt deleted file mode 100644 index 9d50a9894..000000000 --- a/res/vcpkg/oboe-wrapper/CMakeLists.txt +++ /dev/null @@ -1,15 +0,0 @@ -cmake_minimum_required(VERSION 3.20) -project(oboe_wrapper CXX) - -include(GNUInstallDirs) - -add_library(oboe_wrapper STATIC - oboe.cc -) - -target_include_directories(oboe_wrapper PRIVATE "${CURRENT_INSTALLED_DIR}/include") - -install(TARGETS oboe_wrapper - ARCHIVE DESTINATION "${CMAKE_INSTALL_LIBDIR}" - LIBRARY DESTINATION "${CMAKE_INSTALL_LIBDIR}" - RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}") diff --git a/res/vcpkg/oboe-wrapper/oboe.cc b/res/vcpkg/oboe-wrapper/oboe.cc deleted file mode 100644 index a3c8238a7..000000000 --- a/res/vcpkg/oboe-wrapper/oboe.cc +++ /dev/null @@ -1,118 +0,0 @@ -#include -#include -#include -#include - -// I got link problem with std::mutex, so use pthread instead -class CThreadLock -{ -public: - CThreadLock(); - virtual ~CThreadLock(); - - void Lock(); - void Unlock(); - -private: - pthread_mutex_t mutexlock; -}; - -CThreadLock::CThreadLock() -{ - // init lock here - pthread_mutex_init(&mutexlock, 0); -} - -CThreadLock::~CThreadLock() -{ - // deinit lock here - pthread_mutex_destroy(&mutexlock); -} -void CThreadLock::Lock() -{ - // lock - pthread_mutex_lock(&mutexlock); -} -void CThreadLock::Unlock() -{ - // unlock - pthread_mutex_unlock(&mutexlock); -} - -class Player : public oboe::AudioStreamDataCallback -{ -public: - Player(int channels, int sample_rate) - { - this->channels = channels; - oboe::AudioStreamBuilder builder; - // The builder set methods can be chained for convenience. - builder.setSharingMode(oboe::SharingMode::Exclusive) - ->setPerformanceMode(oboe::PerformanceMode::LowLatency) - ->setChannelCount(channels) - ->setSampleRate(sample_rate) - ->setFormat(oboe::AudioFormat::Float) - ->setDataCallback(this) - ->openManagedStream(outStream); - // Typically, start the stream after querying some stream information, as well as some input from the user - outStream->requestStart(); - } - - ~Player() { - outStream->requestStop(); - } - - oboe::DataCallbackResult onAudioReady(oboe::AudioStream *oboeStream, void *audioData, int32_t numFrames) override - { - float *floatData = (float *)audioData; - int i = 0; - mtx.Lock(); - auto n = channels * numFrames; - for (; i < n && i < (int)buffer.size(); ++i, ++floatData) - { - *floatData = buffer.front(); - buffer.pop_front(); - } - mtx.Unlock(); - for (; i < n; ++i, ++floatData) - { - *floatData = 0; - } - return oboe::DataCallbackResult::Continue; - } - - void push(const float *v, int n) - { - mtx.Lock(); - for (auto i = 0; i < n; ++i, ++v) - buffer.push_back(*v); - // in case memory overuse - if (buffer.size() > 48 * 1024 * 120) - buffer.clear(); - mtx.Unlock(); - } - -private: - oboe::ManagedStream outStream; - int channels; - std::deque buffer; - CThreadLock mtx; -}; - -extern "C" -{ - void *create_oboe_player(int channels, int sample_rate) - { - return new Player(channels, sample_rate); - } - - void push_oboe_data(void *player, const float* v, int n) - { - static_cast(player)->push(v, n); - } - - void destroy_oboe_player(void *player) - { - delete static_cast(player); - } -} \ No newline at end of file diff --git a/res/vcpkg/oboe-wrapper/portfile.cmake b/res/vcpkg/oboe-wrapper/portfile.cmake deleted file mode 100644 index c83f5bcb1..000000000 --- a/res/vcpkg/oboe-wrapper/portfile.cmake +++ /dev/null @@ -1,8 +0,0 @@ -vcpkg_configure_cmake( - SOURCE_PATH "${CMAKE_CURRENT_LIST_DIR}" - OPTIONS - -DCURRENT_INSTALLED_DIR=${CURRENT_INSTALLED_DIR} - PREFER_NINJA -) - -vcpkg_cmake_install() diff --git a/res/vcpkg/oboe-wrapper/vcpkg.json b/res/vcpkg/oboe-wrapper/vcpkg.json deleted file mode 100644 index be497e1bb..000000000 --- a/res/vcpkg/oboe-wrapper/vcpkg.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "oboe-wrapper", - "version": "0", - "description": "None", - "dependencies": [ - { - "name": "vcpkg-cmake", - "host": true - }, - { - "name": "vcpkg-cmake-config", - "host": true - }, - { - "name": "oboe", - "host": false - } - ] -} diff --git a/src/cli.rs b/src/cli.rs index f61bfe92f..2f3b3550f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -25,7 +25,13 @@ impl Session { pub fn new(id: &str, sender: mpsc::UnboundedSender) -> Self { let mut password = "".to_owned(); if PeerConfig::load(id).password.is_empty() { - password = rpassword::prompt_password("Enter password: ").unwrap(); + match rpassword::prompt_password("Enter password: ") { + Ok(p) => password = p, + Err(e) => { + log::error!("Failed to read password: {:?}", e); + password = "".to_owned(); + } + } } let session = Self { id: id.to_owned(), diff --git a/src/client.rs b/src/client.rs index 0b5293d22..321a49ee6 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,32 +1,43 @@ +#[cfg(not(any(target_os = "android", target_os = "ios")))] +use crate::clipboard::clipboard_listener; use async_trait::async_trait; use bytes::Bytes; #[cfg(not(any(target_os = "android", target_os = "ios")))] -use clipboard_master::{CallbackResult, ClipboardHandler}; -#[cfg(not(any(target_os = "android", target_os = "linux")))] +use clipboard_master::CallbackResult; +#[cfg(not(target_os = "linux"))] use cpal::{ traits::{DeviceTrait, HostTrait, StreamTrait}, Device, Host, StreamConfig, }; use crossbeam_queue::ArrayQueue; use magnum_opus::{Channels::*, Decoder as AudioDecoder}; -#[cfg(not(any(target_os = "android", target_os = "linux")))] +#[cfg(not(target_os = "linux"))] use ringbuf::{ring_buffer::RbBase, Rb}; use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; use std::{ collections::HashMap, ffi::c_void, - io, net::SocketAddr, ops::Deref, str::FromStr, sync::{ - mpsc::{self, RecvTimeoutError, Sender}, + mpsc::{self, RecvTimeoutError}, Arc, Mutex, RwLock, }, }; use uuid::Uuid; +use crate::{ + check_port, + common::input::{MOUSE_BUTTON_LEFT, MOUSE_BUTTON_RIGHT, MOUSE_TYPE_DOWN, MOUSE_TYPE_UP}, + create_symmetric_key_msg, decode_id_pk, get_rs_pk, is_keyboard_mode_supported, + kcp_stream::KcpStream, + secure_tcp, + ui_interface::{get_builtin_option, resolve_avatar_url, use_texture_render}, + ui_session_interface::{InvokeUiSession, Session}, +}; +#[cfg(feature = "unix-file-copy-paste")] +use crate::{clipboard::check_clipboard_files, clipboard_file::unix_file_clip}; pub use file_trait::FileManager; #[cfg(not(feature = "flutter"))] #[cfg(not(any(target_os = "android", target_os = "ios")))] @@ -36,28 +47,31 @@ use hbb_common::{ anyhow::{anyhow, Context}, bail, config::{ - self, Config, LocalConfig, PeerConfig, PeerInfoSerde, Resolution, CONNECT_TIMEOUT, - PUBLIC_RS_PUB_KEY, READ_TIMEOUT, RELAY_PORT, RENDEZVOUS_PORT, RENDEZVOUS_SERVERS, + self, keys, use_ws, Config, LocalConfig, PeerConfig, PeerInfoSerde, Resolution, + CONNECT_TIMEOUT, READ_TIMEOUT, RELAY_PORT, RENDEZVOUS_PORT, RENDEZVOUS_SERVERS, }, + fs::JobType, + futures::future::{select_ok, FutureExt}, get_version_number, log, message_proto::{option_message::BoolOption, *}, protobuf::{Message as _, MessageField}, rand, rendezvous_proto::*, - socket_client::{connect_tcp, connect_tcp_local, ipv4_to_ipv6}, + sha2::{Digest, Sha256}, + socket_client::{connect_tcp, connect_tcp_local, ipv4_to_ipv6, new_direct_udp_for}, sodiumoxide::{base64, crypto::sign}, - tcp::FramedStream, timeout, tokio::{ self, + net::UdpSocket, + sync::{ + mpsc::{unbounded_channel, UnboundedReceiver}, + oneshot, + }, time::{interval, Duration, Instant}, }, AddrMangle, ResultType, Stream, }; -use hbb_common::{ - config::keys::OPTION_ALLOW_AUTO_RECORD_OUTGOING, - tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver}, -}; pub use helper::*; use scrap::{ codec::Decoder, @@ -65,16 +79,10 @@ use scrap::{ CodecFormat, ImageFormat, ImageRgb, ImageTexture, }; -use crate::{ - check_port, - common::input::{MOUSE_BUTTON_LEFT, MOUSE_BUTTON_RIGHT, MOUSE_TYPE_DOWN, MOUSE_TYPE_UP}, - create_symmetric_key_msg, decode_id_pk, get_rs_pk, is_keyboard_mode_supported, secure_tcp, - ui_interface::{get_builtin_option, use_texture_render}, - ui_session_interface::{InvokeUiSession, Session}, -}; - +#[cfg(not(target_os = "ios"))] +use crate::clipboard::CLIPBOARD_INTERVAL; #[cfg(not(any(target_os = "android", target_os = "ios")))] -use crate::clipboard::{check_clipboard, ClipboardSide, CLIPBOARD_INTERVAL}; +use crate::clipboard::{check_clipboard, ClipboardSide}; #[cfg(not(feature = "flutter"))] #[cfg(not(any(target_os = "android", target_os = "ios")))] use crate::ui_session_interface::SessionPermissionConfig; @@ -84,6 +92,7 @@ pub use super::lang::*; pub mod file_trait; pub mod helper; pub mod io_loop; +pub mod screenshot; pub const MILLI1: Duration = Duration::from_millis(1); pub const SEC30: Duration = Duration::from_secs(30); @@ -110,15 +119,18 @@ pub const LOGIN_MSG_NO_PASSWORD_ACCESS: &str = "No Password Access"; pub const LOGIN_MSG_OFFLINE: &str = "Offline"; pub const LOGIN_SCREEN_WAYLAND: &str = "Wayland login screen is not supported"; #[cfg(target_os = "linux")] -pub const SCRAP_UBUNTU_HIGHER_REQUIRED: &str = "Wayland requires Ubuntu 21.04 or higher version."; +pub const SCRAP_UBUNTU_HIGHER_REQUIRED: &str = "ubuntu-21-04-required"; #[cfg(target_os = "linux")] pub const SCRAP_OTHER_VERSION_OR_X11_REQUIRED: &str = - "Wayland requires higher version of linux distro. Please try X11 desktop or change your OS."; + "wayland-requires-higher-linux-version"; +#[cfg(target_os = "linux")] +pub const SCRAP_XDP_PORTAL_UNAVAILABLE: &str = + "xdp-portal-unavailable"; pub const SCRAP_X11_REQUIRED: &str = "x11 expected"; pub const SCRAP_X11_REF_URL: &str = "https://rustdesk.com/docs/en/manual/linux/#x11-required"; -#[cfg(not(any(target_os = "android", target_os = "linux")))] -pub const AUDIO_BUFFER_MS: usize = 150; +#[cfg(not(target_os = "linux"))] +pub const AUDIO_BUFFER_MS: usize = 3000; #[cfg(feature = "flutter")] #[cfg(not(any(target_os = "android", target_os = "ios")))] @@ -129,18 +141,23 @@ pub(crate) struct ClientClipboardContext; pub(crate) struct ClientClipboardContext { pub cfg: SessionPermissionConfig, pub tx: UnboundedSender, + #[cfg(feature = "unix-file-copy-paste")] + pub is_file_supported: bool, } /// Client of the remote desktop. pub struct Client; -#[cfg(not(any(target_os = "android", target_os = "ios")))] -struct TextClipboardState { - is_required: bool, +#[cfg(not(target_os = "ios"))] +struct ClipboardState { + #[cfg(feature = "flutter")] + is_text_required: bool, + #[cfg(all(feature = "flutter", feature = "unix-file-copy-paste"))] + is_file_required: bool, running: bool, } -#[cfg(not(any(target_os = "android", target_os = "linux")))] +#[cfg(not(target_os = "linux"))] lazy_static::lazy_static! { static ref AUDIO_HOST: Host = cpal::default_host(); } @@ -148,7 +165,11 @@ lazy_static::lazy_static! { #[cfg(not(any(target_os = "android", target_os = "ios")))] lazy_static::lazy_static! { static ref ENIGO: Arc> = Arc::new(Mutex::new(enigo::Enigo::new())); - static ref TEXT_CLIPBOARD_STATE: Arc> = Arc::new(Mutex::new(TextClipboardState::new())); +} + +#[cfg(not(target_os = "ios"))] +lazy_static::lazy_static! { + static ref CLIPBOARD_STATE: Arc> = Arc::new(Mutex::new(ClipboardState::new())); } const PUBLIC_SERVER: &str = "public"; @@ -163,67 +184,9 @@ pub fn get_key_state(key: enigo::Key) -> bool { ENIGO.lock().unwrap().get_key_state(key) } -cfg_if::cfg_if! { - if #[cfg(target_os = "android")] { - -use hbb_common::libc::{c_float, c_int}; -type Oboe = *mut c_void; -extern "C" { - fn create_oboe_player(channels: c_int, sample_rate: c_int) -> Oboe; - fn push_oboe_data(oboe: Oboe, d: *const c_float, n: c_int); - fn destroy_oboe_player(oboe: Oboe); -} - -struct OboePlayer { - raw: Oboe, -} - -impl Default for OboePlayer { - fn default() -> Self { - Self { - raw: std::ptr::null_mut(), - } - } -} - -impl OboePlayer { - fn new(channels: i32, sample_rate: i32) -> Self { - unsafe { - Self { - raw: create_oboe_player(channels, sample_rate), - } - } - } - - #[cfg(not(any(target_os = "android", target_os = "ios")))] - fn is_null(&self) -> bool { - self.raw.is_null() - } - - fn push(&mut self, d: &[f32]) { - if self.raw.is_null() { - return; - } - unsafe { - push_oboe_data(self.raw, d.as_ptr(), d.len() as _); - } - } -} - -impl Drop for OboePlayer { - fn drop(&mut self) { - unsafe { - if !self.raw.is_null() { - destroy_oboe_player(self.raw); - } - } - } -} - -} -} - impl Client { + const CLIENT_CLIPBOARD_NAME: &'static str = "client-clipboard"; + /// Start a new connection. pub async fn start( peer: &str, @@ -231,11 +194,20 @@ impl Client { token: &str, conn_type: ConnType, interface: impl Interface, - ) -> ResultType<((Stream, bool, Option>), (i32, String))> { + ) -> ResultType<( + ( + Stream, + bool, + Option>, + Option, + &'static str, + ), + (i32, String), + )> { debug_assert!(peer == interface.get_id()); interface.update_direct(None); interface.update_received(false); - match Self::_start(peer, key, token, conn_type, interface).await { + match Self::_start(peer, key, token, conn_type, interface.clone()).await { Err(err) => { let err_str = err.to_string(); if err_str.starts_with("Failed") { @@ -244,7 +216,19 @@ impl Client { return Err(err); } } - Ok(x) => Ok(x), + Ok(x) => { + // Set x.2 to true only in the connect() function to indicate that direct_failures needs to be updated; everywhere else it should be set to false. + if x.2 { + let direct_failures = interface.get_lch().read().unwrap().direct_failures; + let direct = x.0 .1; + if !interface.is_force_relay() && (direct_failures == 0) != direct { + let n = if direct { 0 } else { 1 }; + log::info!("direct_failures updated to {}", n); + interface.get_lch().write().unwrap().set_direct_failure(n); + } + } + Ok((x.0, x.1)) + } } } @@ -255,7 +239,17 @@ impl Client { token: &str, conn_type: ConnType, interface: impl Interface, - ) -> ResultType<((Stream, bool, Option>), (i32, String))> { + ) -> ResultType<( + ( + Stream, + bool, + Option>, + Option, + &'static str, + ), + (i32, String), + bool, + )> { if config::is_incoming_only() { bail!("Incoming only mode"); } @@ -263,18 +257,29 @@ impl Client { if hbb_common::is_ip_str(peer) { return Ok(( ( - connect_tcp(check_port(peer, RELAY_PORT + 1), CONNECT_TIMEOUT).await?, + connect_tcp_local(check_port(peer, RELAY_PORT + 1), None, CONNECT_TIMEOUT) + .await?, true, None, + None, + "TCP", ), (0, "".to_owned()), + false, )); } // Allow connect to {domain}:{port} if hbb_common::is_domain_port_str(peer) { return Ok(( - (connect_tcp(peer, CONNECT_TIMEOUT).await?, true, None), + ( + connect_tcp_local(peer, None, CONNECT_TIMEOUT).await?, + true, + None, + None, + "TCP", + ), (0, "".to_owned()), + false, )); } @@ -284,7 +289,7 @@ impl Client { } else { (peer, "", key, token) }; - let (mut rendezvous_server, servers, contained) = if other_server.is_empty() { + let (rendezvous_server, servers, contained) = if other_server.is_empty() { crate::get_rendezvous_server(1_000).await } else { if other_server == PUBLIC_SERVER { @@ -301,8 +306,93 @@ impl Client { } }; + if crate::get_ipv6_punch_enabled() { + crate::test_ipv6().await; + } + + let (stop_udp_tx, stop_udp_rx) = oneshot::channel::<()>(); + let udp = + // no need to care about multiple rendezvous servers case, since it is acutally not used any more. + // Shared state for UDP NAT test result + if crate::get_udp_punch_enabled() && !interface.is_force_relay() { + if let Ok((socket, addr)) = new_direct_udp_for(&rendezvous_server).await { + let udp_port = Arc::new(Mutex::new(0)); + let up_cloned = udp_port.clone(); + let socket_cloned = socket.clone(); + let func = async move { + allow_err!(test_udp_uat(socket_cloned, addr, up_cloned, stop_udp_rx).await); + }; + tokio::spawn(func); + (Some(socket), Some(udp_port)) + } else { + (None, None) + } + } else { + (None, None) + }; + let fut = Self::_start_inner( + peer.to_owned(), + key.to_owned(), + token.to_owned(), + conn_type, + interface.clone(), + udp.clone(), + Some(stop_udp_tx), + rendezvous_server.clone(), + servers.clone(), + contained, + ); + if udp.0.is_none() { + return fut.await; + } + let mut connect_futures = Vec::new(); + connect_futures.push(fut.boxed()); + let fut = Self::_start_inner( + peer.to_owned(), + key.to_owned(), + token.to_owned(), + conn_type, + interface, + (None, None), + None, + rendezvous_server, + servers, + contained, + ); + connect_futures.push(fut.boxed()); + match select_ok(connect_futures).await { + Ok(conn) => Ok((conn.0 .0, conn.0 .1, conn.0 .2)), + Err(e) => Err(e), + } + } + + async fn _start_inner( + peer: String, + key: String, + token: String, + conn_type: ConnType, + interface: impl Interface, + mut udp: (Option>, Option>>), + stop_udp_tx: Option>, + mut rendezvous_server: String, + servers: Vec, + contained: bool, + ) -> ResultType<( + ( + Stream, + bool, + Option>, + Option, + &'static str, + ), + (i32, String), + bool, + )> { + let mut start = Instant::now(); let mut socket = connect_tcp(&*rendezvous_server, CONNECT_TIMEOUT).await; debug_assert!(!servers.contains(&rendezvous_server)); + let rtt = start.elapsed(); + log::debug!("TCP connection establishment time used: {:?}", rtt); if socket.is_err() && !servers.is_empty() { log::info!("try the other servers: {:?}", servers); for server in servers { @@ -322,40 +412,75 @@ impl Client { let my_addr = socket.local_addr(); let mut signed_id_pk = Vec::new(); let mut relay_server = "".to_owned(); - - if !key.is_empty() && !token.is_empty() { - // mainly for the security of token - allow_err!(secure_tcp(&mut socket, key).await); - } - - let start = std::time::Instant::now(); let mut peer_addr = Config::get_any_listen_addr(true); let mut peer_nat_type = NatType::UNKNOWN_NAT; let my_nat_type = crate::get_nat_type(100).await; let mut is_local = false; let mut feedback = 0; - for i in 1..=3 { - log::info!("#{} punch attempt with {}, id: {}", i, my_addr, peer); - let mut msg_out = RendezvousMessage::new(); - use hbb_common::protobuf::Enum; - let nat_type = if interface.is_force_relay() { - NatType::SYMMETRIC + use hbb_common::protobuf::Enum; + let nat_type = if interface.is_force_relay() { + NatType::SYMMETRIC + } else { + NatType::from_i32(my_nat_type).unwrap_or(NatType::UNKNOWN_NAT) + }; + + if !key.is_empty() && !token.is_empty() { + // mainly for the security of token + secure_tcp(&mut socket, &key) + .await + .map_err(|e| anyhow!("Failed to secure tcp: {}", e))?; + } else if let Some(udp) = udp.1.as_ref() { + let tm = Instant::now(); + loop { + let port = *udp.lock().unwrap(); + if port > 0 { + break; + } + // await for 0.5 RTT + if tm.elapsed() > rtt / 2 { + break; + } + hbb_common::sleep(0.001).await; + } + } + // Stop UDP NAT test task if still running + stop_udp_tx.map(|tx| tx.send(())); + let mut msg_out = RendezvousMessage::new(); + let mut ipv6 = if crate::get_ipv6_punch_enabled() { + if let Some((socket, addr)) = crate::get_ipv6_socket().await { + (Some(socket), Some(addr)) } else { - NatType::from_i32(my_nat_type).unwrap_or(NatType::UNKNOWN_NAT) - }; - msg_out.set_punch_hole_request(PunchHoleRequest { - id: peer.to_owned(), - token: token.to_owned(), - nat_type: nat_type.into(), - licence_key: key.to_owned(), - conn_type: conn_type.into(), - version: crate::VERSION.to_owned(), - ..Default::default() - }); + (None, None) + } + } else { + (None, None) + }; + let udp_nat_port = udp.1.map(|x| *x.lock().unwrap()).unwrap_or(0); + let punch_type = if udp_nat_port > 0 { "UDP" } else { "TCP" }; + msg_out.set_punch_hole_request(PunchHoleRequest { + id: peer.to_owned(), + token: token.to_owned(), + nat_type: nat_type.into(), + licence_key: key.to_owned(), + conn_type: conn_type.into(), + version: crate::VERSION.to_owned(), + udp_port: udp_nat_port as _, + force_relay: interface.is_force_relay(), + socket_addr_v6: ipv6.1.unwrap_or_default(), + ..Default::default() + }); + for i in 1..=3 { + log::info!( + "#{} {} punch attempt with {}, id: {}", + i, + punch_type, + my_addr, + peer + ); socket.send(&msg_out).await?; // below timeout should not bigger than hbbs's connection timeout. if let Some(msg_in) = - crate::get_next_nonkeyexchange_msg(&mut socket, Some(i * 6000)).await + crate::get_next_nonkeyexchange_msg(&mut socket, Some(i * 3000)).await { match msg_in.union { Some(rendezvous_message::Union::PunchHoleResponse(ph)) => { @@ -385,7 +510,24 @@ impl Client { relay_server = ph.relay_server; peer_addr = AddrMangle::decode(&ph.socket_addr); feedback = ph.feedback; - log::info!("Hole Punched {} = {}", peer, peer_addr); + let s = udp.0.take(); + if ph.is_udp && s.is_some() { + if let Some(s) = s { + allow_err!(s.connect(peer_addr).await); + udp.0 = Some(s); + } + } + let s = ipv6.0.take(); + if !ph.socket_addr_v6.is_empty() && s.is_some() { + let addr = AddrMangle::decode(&ph.socket_addr_v6); + if addr.port() > 0 { + if let Some(s) = s { + allow_err!(s.connect(addr).await); + ipv6.0 = Some(s); + } + } + } + log::info!("{} Hole Punched {} = {}", punch_type, peer, peer_addr); break; } } @@ -395,20 +537,49 @@ impl Client { start.elapsed(), rr.relay_server ); + start = Instant::now(); + let mut connect_futures = Vec::new(); + if let Some(s) = ipv6.0 { + let addr = AddrMangle::decode(&rr.socket_addr_v6); + if addr.port() > 0 { + if s.connect(addr).await.is_ok() { + connect_futures + .push(udp_nat_connect(s, "IPv6", CONNECT_TIMEOUT).boxed()); + } + } + } signed_id_pk = rr.pk().into(); - let mut conn = Self::create_relay( - peer, + let fut = Self::create_relay( + &peer, rr.uuid, rr.relay_server, - key, + &key, conn_type, my_addr.is_ipv4(), - ) - .await?; + ); + connect_futures.push( + async move { + let conn = fut.await?; + Ok((conn, None, if use_ws() { "WebSocket" } else { "Relay" })) + } + .boxed(), + ); + // Run all connection attempts concurrently, return the first successful one + let (conn, kcp, typ) = match select_ok(connect_futures).await { + Ok(conn) => (Ok(conn.0 .0), conn.0 .1, conn.0 .2), + + Err(e) => (Err(e), None, ""), + }; + let mut conn = conn?; feedback = rr.feedback; + log::info!("{:?} used to establish {typ} connection", start.elapsed()); let pk = - Self::secure_connection(peer, signed_id_pk, key, &mut conn).await?; - return Ok(((conn, false, pk), (feedback, rendezvous_server))); + Self::secure_connection(&peer, signed_id_pk, &key, &mut conn).await?; + return Ok(( + (conn, typ == "IPv6", pk, kcp, typ), + (feedback, rendezvous_server), + false, + )); } _ => { log::error!("Unexpected protobuf msg received: {:?}", msg_in); @@ -422,8 +593,9 @@ impl Client { } let time_used = start.elapsed().as_millis() as u64; log::info!( - "{} ms used to punch hole, relay_server: {}, {}", + "{} ms used to {} punch hole, relay_server: {}, {}", time_used, + punch_type, relay_server, if is_local { "is_local: true".to_owned() @@ -435,7 +607,7 @@ impl Client { Self::connect( my_addr, peer_addr, - peer, + &peer, signed_id_pk, &relay_server, &rendezvous_server, @@ -443,13 +615,17 @@ impl Client { peer_nat_type, my_nat_type, is_local, - key, - token, + &key, + &token, conn_type, interface, + udp.0, + ipv6.0, + punch_type, ) .await?, (feedback, rendezvous_server), + true, )) } @@ -469,7 +645,16 @@ impl Client { token: &str, conn_type: ConnType, interface: impl Interface, - ) -> ResultType<(Stream, bool, Option>)> { + udp_socket_nat: Option>, + udp_socket_v6: Option>, + punch_type: &str, + ) -> ResultType<( + Stream, + bool, + Option>, + Option, + &'static str, + )> { let direct_failures = interface.get_lch().read().unwrap().direct_failures; let mut connect_timeout = 0; const MIN: u64 = 1000; @@ -504,10 +689,29 @@ impl Client { } log::info!("peer address: {}, timeout: {}", peer, connect_timeout); let start = std::time::Instant::now(); - // NOTICE: Socks5 is be used event in intranet. Which may be not a good way. - let mut conn = connect_tcp_local(peer, Some(local_addr), connect_timeout).await; + + let mut connect_futures = Vec::new(); + let fut = connect_tcp_local(peer, Some(local_addr), connect_timeout); + connect_futures.push( + async move { + let conn = fut.await?; + Ok((conn, None, "TCP")) + } + .boxed(), + ); + if let Some(udp_socket_nat) = udp_socket_nat { + connect_futures.push(udp_nat_connect(udp_socket_nat, "UDP", connect_timeout).boxed()); + } + if let Some(udp_socket_v6) = udp_socket_v6 { + connect_futures.push(udp_nat_connect(udp_socket_v6, "IPv6", connect_timeout).boxed()); + } + // Run all connection attempts concurrently, return the first successful one + let (mut conn, kcp, mut typ) = match select_ok(connect_futures).await { + Ok(conn) => (Ok(conn.0 .0), conn.0 .1, conn.0 .2), + Err(e) => (Err(e), None, ""), + }; + let mut direct = !conn.is_err(); - interface.update_direct(Some(direct)); if interface.is_force_relay() || conn.is_err() { if !relay_server.is_empty() { conn = Self::request_relay( @@ -520,24 +724,34 @@ impl Client { conn_type, ) .await; - interface.update_direct(Some(false)); if let Err(e) = conn { + // this direct is mainly used by on_establish_connection_error, so we update it here before bail + interface.update_direct(Some(false)); bail!("Failed to connect via relay server: {}", e); } + typ = "Relay"; direct = false; } else { bail!("Failed to make direct connection to remote desktop"); } } - if !relay_server.is_empty() && (direct_failures == 0) != direct { - let n = if direct { 0 } else { 1 }; - log::info!("direct_failures updated to {}", n); - interface.get_lch().write().unwrap().set_direct_failure(n); - } let mut conn = conn?; - log::info!("{:?} used to establish connection", start.elapsed()); - let pk = Self::secure_connection(peer_id, signed_id_pk, key, &mut conn).await?; - Ok((conn, direct, pk)) + log::info!( + "{:?} used to establish {typ} connection with {} punch", + start.elapsed(), + punch_type + ); + let res = Self::secure_connection(peer_id, signed_id_pk, key, &mut conn).await; + let pk: Option> = match res { + Ok(pk) => pk, + Err(e) => { + // this direct is mainly used by on_establish_connection_error, so we update it here before bail + interface.update_direct(Some(direct)); + bail!(e); + } + }; + log::debug!("{} punch secure_connection ok", punch_type); + Ok((conn, direct, pk, kcp, typ)) } /// Establish secure connection with the server. @@ -641,7 +855,7 @@ impl Client { if !key.is_empty() && !token.is_empty() { // mainly for the security of token - allow_err!(secure_tcp(&mut socket, key).await); + secure_tcp(&mut socket, key).await?; } ipv4 = socket.local_addr().is_ipv4(); @@ -712,12 +926,18 @@ impl Client { #[inline] #[cfg(feature = "flutter")] - #[cfg(not(any(target_os = "android", target_os = "ios")))] + #[cfg(not(target_os = "ios"))] pub fn set_is_text_clipboard_required(b: bool) { - TEXT_CLIPBOARD_STATE.lock().unwrap().is_required = b; + CLIPBOARD_STATE.lock().unwrap().is_text_required = b; } - #[cfg(not(any(target_os = "android", target_os = "ios")))] + #[inline] + #[cfg(all(feature = "flutter", feature = "unix-file-copy-paste"))] + pub fn set_is_file_clipboard_required(b: bool) { + CLIPBOARD_STATE.lock().unwrap().is_file_required = b; + } + + #[cfg(not(target_os = "ios"))] fn try_stop_clipboard() { // There's a bug here. // If session is closed by the peer, `has_sessions_running()` will always return true. @@ -730,68 +950,55 @@ impl Client { if crate::flutter::sessions::has_sessions_running(ConnType::DEFAULT_CONN) { return; } - TEXT_CLIPBOARD_STATE.lock().unwrap().running = false; + #[cfg(not(target_os = "android"))] + clipboard_listener::unsubscribe(Self::CLIENT_CLIPBOARD_NAME); + CLIPBOARD_STATE.lock().unwrap().running = false; + #[cfg(all(feature = "unix-file-copy-paste", target_os = "linux"))] + clipboard::platform::unix::fuse::uninit_fuse_context(true); } // `try_start_clipboard` is called by all session when connection is established. (When handling peer info). // This function only create one thread with a loop, the loop is shared by all sessions. // After all sessions are end, the loop exists. // - // If clipboard update is detected, the text will be sent to all sessions by `send_text_clipboard_msg`. + // If clipboard update is detected, the text will be sent to all sessions by `send_clipboard_msg`. #[cfg(not(any(target_os = "android", target_os = "ios")))] fn try_start_clipboard( _client_clip_ctx: Option, ) -> Option> { - let mut clipboard_lock = TEXT_CLIPBOARD_STATE.lock().unwrap(); + let mut clipboard_lock = CLIPBOARD_STATE.lock().unwrap(); if clipboard_lock.running { return None; } let (tx_cb_result, rx_cb_result) = mpsc::channel(); - let handler = ClientClipboardHandler { - ctx: None, - tx_cb_result, - #[cfg(not(feature = "flutter"))] - client_clip_ctx: _client_clip_ctx, - }; - - let (tx_start_res, rx_start_res) = mpsc::channel(); - let h = crate::clipboard::start_clipbard_master_thread(handler, tx_start_res); - let shutdown = match rx_start_res.recv() { - Ok((Some(s), _)) => s, - Ok((None, err)) => { - log::error!("{}", err); - return None; - } - Err(e) => { - log::error!("Failed to create clipboard listener: {}", e); - return None; - } - }; + if let Err(e) = + clipboard_listener::subscribe(Self::CLIENT_CLIPBOARD_NAME.to_owned(), tx_cb_result) + { + log::error!("Failed to subscribe clipboard listener: {}", e); + return None; + } clipboard_lock.running = true; - let (tx_started, rx_started) = unbounded_channel(); - log::info!("Start text clipboard loop"); + log::info!("Start client clipboard loop"); std::thread::spawn(move || { - let mut is_sent = false; + let mut handler = ClientClipboardHandler { + ctx: None, + #[cfg(not(feature = "flutter"))] + client_clip_ctx: _client_clip_ctx, + }; + tx_started.send(()).ok(); loop { - if !TEXT_CLIPBOARD_STATE.lock().unwrap().running { + if !CLIPBOARD_STATE.lock().unwrap().running { break; } - if !TEXT_CLIPBOARD_STATE.lock().unwrap().is_required { - std::thread::sleep(Duration::from_millis(CLIPBOARD_INTERVAL)); - continue; - } - - if !is_sent { - is_sent = true; - tx_started.send(()).ok(); - } - match rx_cb_result.recv_timeout(Duration::from_millis(CLIPBOARD_INTERVAL)) { + Ok(CallbackResult::Next) => { + handler.check_clipboard(); + } Ok(CallbackResult::Stop) => { log::debug!("Clipboard listener stopped"); break; @@ -801,24 +1008,60 @@ impl Client { break; } Err(RecvTimeoutError::Timeout) => {} - _ => {} + Err(RecvTimeoutError::Disconnected) => { + log::error!("Clipboard listener disconnected"); + break; + } } } - log::info!("Stop text clipboard loop"); - shutdown.signal(); - h.join().ok(); - TEXT_CLIPBOARD_STATE.lock().unwrap().running = false; + log::info!("Stop client clipboard loop"); + CLIPBOARD_STATE.lock().unwrap().running = false; }); Some(rx_started) } + + #[cfg(target_os = "android")] + fn try_start_clipboard(_p: Option<()>) -> Option> { + let mut clipboard_lock = CLIPBOARD_STATE.lock().unwrap(); + if clipboard_lock.running { + return None; + } + clipboard_lock.running = true; + + log::info!("Start client clipboard loop"); + std::thread::spawn(move || { + loop { + if !CLIPBOARD_STATE.lock().unwrap().running { + break; + } + if !CLIPBOARD_STATE.lock().unwrap().is_text_required { + std::thread::sleep(Duration::from_millis(CLIPBOARD_INTERVAL)); + continue; + } + + if let Some(msg) = crate::clipboard::get_clipboards_msg(true) { + crate::flutter::send_clipboard_msg(msg, false); + } + + std::thread::sleep(Duration::from_millis(CLIPBOARD_INTERVAL)); + } + log::info!("Stop client clipboard loop"); + CLIPBOARD_STATE.lock().unwrap().running = false; + }); + + None + } } -#[cfg(not(any(target_os = "android", target_os = "ios")))] -impl TextClipboardState { +#[cfg(not(target_os = "ios"))] +impl ClipboardState { fn new() -> Self { Self { - is_required: true, + #[cfg(feature = "flutter")] + is_text_required: true, + #[cfg(all(feature = "flutter", feature = "unix-file-copy-paste"))] + is_file_required: true, running: false, } } @@ -827,118 +1070,251 @@ impl TextClipboardState { #[cfg(not(any(target_os = "android", target_os = "ios")))] struct ClientClipboardHandler { ctx: Option, - tx_cb_result: Sender, #[cfg(not(feature = "flutter"))] client_clip_ctx: Option, } #[cfg(not(any(target_os = "android", target_os = "ios")))] impl ClientClipboardHandler { + fn is_text_required(&self) -> bool { + #[cfg(feature = "flutter")] + { + CLIPBOARD_STATE.lock().unwrap().is_text_required + } + #[cfg(not(feature = "flutter"))] + { + self.client_clip_ctx + .as_ref() + .map(|ctx| ctx.cfg.is_text_clipboard_required()) + .unwrap_or(false) + } + } + + #[cfg(feature = "unix-file-copy-paste")] + fn is_file_required(&self) -> bool { + #[cfg(feature = "flutter")] + { + CLIPBOARD_STATE.lock().unwrap().is_file_required + } + #[cfg(not(feature = "flutter"))] + { + self.client_clip_ctx + .as_ref() + .map(|ctx| ctx.cfg.is_file_clipboard_required()) + .unwrap_or(false) + } + } + + fn check_clipboard(&mut self) { + if CLIPBOARD_STATE.lock().unwrap().running { + #[cfg(feature = "unix-file-copy-paste")] + if let Some(urls) = check_clipboard_files(&mut self.ctx, ClipboardSide::Client, false) { + if !urls.is_empty() { + #[cfg(target_os = "macos")] + if crate::clipboard::is_file_url_set_by_rustdesk(&urls) { + return; + } + if self.is_file_required() { + match clipboard::platform::unix::serv_files::sync_files(&urls) { + Ok(()) => { + let msg = crate::clipboard_file::clip_2_msg( + unix_file_clip::get_format_list(), + ); + self.send_msg(msg, true); + } + Err(e) => { + log::error!("Failed to sync clipboard files: {}", e); + } + } + return; + } + } + } + + if let Some(msg) = check_clipboard(&mut self.ctx, ClipboardSide::Client, false) { + if self.is_text_required() { + self.send_msg(msg, false); + } + } + } + } + #[inline] #[cfg(feature = "flutter")] - fn send_msg(&self, msg: Message) { - crate::flutter::send_text_clipboard_msg(msg); + fn send_msg(&self, msg: Message, _is_file: bool) { + crate::flutter::send_clipboard_msg(msg, _is_file); } #[cfg(not(feature = "flutter"))] - fn send_msg(&self, msg: Message) { + fn send_msg(&self, msg: Message, _is_file: bool) { if let Some(ctx) = &self.client_clip_ctx { - if ctx.cfg.is_text_clipboard_required() { - if let Some(pi) = ctx.cfg.lc.read().unwrap().peer_info.as_ref() { - if let Some(message::Union::MultiClipboards(multi_clipboards)) = &msg.union { - if let Some(msg_out) = crate::clipboard::get_msg_if_not_support_multi_clip( - &pi.version, - &pi.platform, - multi_clipboards, - ) { - let _ = ctx.tx.send(Data::Message(msg_out)); - return; - } + #[cfg(feature = "unix-file-copy-paste")] + if _is_file { + if ctx.is_file_supported { + let _ = ctx.tx.send(Data::Message(msg)); + } + return; + } + + let pi = ctx.cfg.lc.read().unwrap().peer_info.clone(); + if let Some(pi) = pi.as_ref() { + if let Some(message::Union::MultiClipboards(multi_clipboards)) = &msg.union { + if let Some(msg_out) = crate::clipboard::get_msg_if_not_support_multi_clip( + &pi.version, + &pi.platform, + multi_clipboards, + ) { + let _ = ctx.tx.send(Data::Message(msg_out)); + return; } } - let _ = ctx.tx.send(Data::Message(msg)); } + let _ = ctx.tx.send(Data::Message(msg)); } } } -#[cfg(not(any(target_os = "android", target_os = "ios")))] -impl ClipboardHandler for ClientClipboardHandler { - fn on_clipboard_change(&mut self) -> CallbackResult { - if TEXT_CLIPBOARD_STATE.lock().unwrap().running - && TEXT_CLIPBOARD_STATE.lock().unwrap().is_required - { - if let Some(msg) = check_clipboard(&mut self.ctx, ClipboardSide::Client, false) { - self.send_msg(msg); - } - } - CallbackResult::Next - } - - fn on_clipboard_error(&mut self, error: io::Error) -> CallbackResult { - self.tx_cb_result - .send(CallbackResult::StopWithError(error)) - .ok(); - CallbackResult::Next - } -} - /// Audio handler for the [`Client`]. #[derive(Default)] pub struct AudioHandler { audio_decoder: Option<(AudioDecoder, Vec)>, - #[cfg(target_os = "android")] - oboe: Option, #[cfg(target_os = "linux")] simple: Option, - #[cfg(not(any(target_os = "android", target_os = "linux")))] + #[cfg(not(target_os = "linux"))] audio_buffer: AudioBuffer, sample_rate: (u32, u32), - #[cfg(not(any(target_os = "android", target_os = "linux")))] + #[cfg(not(target_os = "linux"))] audio_stream: Option>, channels: u16, - #[cfg(not(any(target_os = "android", target_os = "linux")))] + #[cfg(not(target_os = "linux"))] device_channel: u16, - #[cfg(not(any(target_os = "android", target_os = "linux")))] + #[cfg(not(target_os = "linux"))] ready: Arc>, } -#[cfg(not(any(target_os = "android", target_os = "linux")))] -struct AudioBuffer(pub Arc>>); +#[cfg(not(target_os = "linux"))] +struct AudioBuffer( + pub Arc>>, + usize, + [usize; 30], +); -#[cfg(not(any(target_os = "android", target_os = "linux")))] +#[cfg(not(target_os = "linux"))] impl Default for AudioBuffer { fn default() -> Self { - Self(Arc::new(std::sync::Mutex::new( - ringbuf::HeapRb::::new(48000 * 2 * AUDIO_BUFFER_MS / 1000), // 48000hz, 2 channel - ))) + Self( + Arc::new(std::sync::Mutex::new( + ringbuf::HeapRb::::new(48000 * 2 * AUDIO_BUFFER_MS / 1000), // 48000hz, 2 channel + )), + 48000 * 2, + [0; 30], + ) } } -#[cfg(not(any(target_os = "android", target_os = "linux")))] +#[cfg(not(target_os = "linux"))] impl AudioBuffer { - pub fn resize(&self, sample_rate: usize, channels: usize) { + pub fn resize(&mut self, sample_rate: usize, channels: usize) { let capacity = sample_rate * channels * AUDIO_BUFFER_MS / 1000; let old_capacity = self.0.lock().unwrap().capacity(); if capacity != old_capacity { *self.0.lock().unwrap() = ringbuf::HeapRb::::new(capacity); + self.1 = sample_rate * channels; log::info!("Audio buffer resized from {old_capacity} to {capacity}"); } } - // clear when full to avoid long time noise - #[inline] - pub fn clear_if_full(&self) { - let full = self.0.lock().unwrap().is_full(); - if full { - self.0.lock().unwrap().clear(); - log::trace!("Audio buffer cleared"); + fn try_shrink(&mut self, having: usize) { + extern crate chrono; + use chrono::prelude::*; + + let mut i = (having * 10) / self.1; + if i > 29 { + i = 29; } + self.2[i] += 1; + + #[allow(non_upper_case_globals)] + static mut tms: i64 = 0; + let dt = Local::now().timestamp_millis(); + unsafe { + if tms == 0 { + tms = dt; + return; + } else if dt < tms + 12000 { + return; + } + tms = dt; + } + + // the safer water mark to drop + let mut zero = 0; + // the water mark taking most of time + let mut max = 0; + for i in 0..30 { + if self.2[i] == 0 && zero == i { + zero += 1; + } + + if self.2[i] > self.2[max] { + self.2[max] = 0; + max = i; + } else { + self.2[i] = 0; + } + } + zero = zero * 2 / 3; + + // how many data can be dropped: + // 1. will not drop if buffered data is less than 600ms + // 2. choose based on min(zero, max) + const N: usize = 4; + self.2[max] = 0; + if max < 6 { + return; + } else if max > zero * N { + max = zero * N; + } + + let mut lock = self.0.lock().unwrap(); + let cap = lock.capacity(); + let having = lock.occupied_len(); + let skip = (cap * max / (30 * N) + 1) & (!1); + if (having > skip * 3) && (skip > 0) { + lock.skip(skip); + log::info!("skip {skip}, based {max} {zero}"); + } + } + + /// append pcm to audio buffer, if buffered data + /// exceeds AUDIO_BUFFER_MS, only AUDIO_BUFFER_MS + /// will be kept. + fn append_pcm2(&self, buffer: &[f32]) -> usize { + let mut lock = self.0.lock().unwrap(); + let cap = lock.capacity(); + if buffer.len() > cap { + lock.push_slice_overwrite(buffer); + return cap; + } + + let having = lock.occupied_len() + buffer.len(); + if having > cap { + lock.skip(having - cap); + } + lock.push_slice_overwrite(buffer); + lock.occupied_len() + } + + /// append pcm to audio buffer, trying to drop data + /// when data is too much (per 12 seconds) based + /// statistics. + pub fn append_pcm(&mut self, buffer: &[f32]) { + let having = self.append_pcm2(buffer); + self.try_shrink(having); } } impl AudioHandler { - /// Start the audio playback. #[cfg(target_os = "linux")] fn start_audio(&mut self, format0: AudioFormat) -> ResultType<()> { use psimple::Simple; @@ -969,18 +1345,7 @@ impl AudioHandler { } /// Start the audio playback. - #[cfg(target_os = "android")] - fn start_audio(&mut self, format0: AudioFormat) -> ResultType<()> { - self.oboe = Some(OboePlayer::new( - format0.channels as _, - format0.sample_rate as _, - )); - self.sample_rate = (format0.sample_rate, format0.sample_rate); - Ok(()) - } - - /// Start the audio playback. - #[cfg(not(any(target_os = "android", target_os = "linux")))] + #[cfg(not(target_os = "linux"))] fn start_audio(&mut self, format0: AudioFormat) -> ResultType<()> { let device = AUDIO_HOST .default_output_device() @@ -993,7 +1358,14 @@ impl AudioHandler { let sample_format = config.sample_format(); log::info!("Default output format: {:?}", config); log::info!("Remote input format: {:?}", format0); - let config: StreamConfig = config.into(); + #[allow(unused_mut)] + let mut config: StreamConfig = config.into(); + #[cfg(not(target_os = "ios"))] + { + // this makes ios audio output not work + config.buffer_size = cpal::BufferSize::Fixed(64); + } + self.sample_rate = (format0.sample_rate, config.sample_rate.0); let mut build_output_stream = |config: StreamConfig| match sample_format { cpal::SampleFormat::I8 => self.build_output_stream::(&config, &device), @@ -1041,7 +1413,7 @@ impl AudioHandler { /// Handle audio frame and play it. #[inline] pub fn handle_frame(&mut self, frame: AudioFrame) { - #[cfg(not(any(target_os = "android", target_os = "linux")))] + #[cfg(not(target_os = "linux"))] if self.audio_stream.is_none() || !self.ready.lock().unwrap().clone() { return; } @@ -1050,19 +1422,14 @@ impl AudioHandler { log::debug!("PulseAudio simple binding does not exists"); return; } - #[cfg(target_os = "android")] - if self.oboe.is_none() { - return; - } self.audio_decoder.as_mut().map(|(d, buffer)| { if let Ok(n) = d.decode_float(&frame.data, buffer, false) { let channels = self.channels; let n = n * (channels as usize); - #[cfg(not(any(target_os = "android", target_os = "linux")))] + #[cfg(not(target_os = "linux"))] { let sample_rate0 = self.sample_rate.0; let sample_rate = self.sample_rate.1; - let audio_buffer = self.audio_buffer.0.clone(); let mut buffer = buffer[0..n].to_owned(); if sample_rate != sample_rate0 { buffer = crate::audio_resample( @@ -1081,12 +1448,7 @@ impl AudioHandler { self.device_channel, ); } - self.audio_buffer.clear_if_full(); - audio_buffer.lock().unwrap().push_slice_overwrite(&buffer); - } - #[cfg(target_os = "android")] - { - self.oboe.as_mut().map(|x| x.push(&buffer[0..n])); + self.audio_buffer.append_pcm(&buffer); } #[cfg(target_os = "linux")] { @@ -1099,7 +1461,7 @@ impl AudioHandler { } /// Build audio output stream for current device. - #[cfg(not(any(target_os = "android", target_os = "linux")))] + #[cfg(not(target_os = "linux"))] fn build_output_stream>( &mut self, config: &StreamConfig, @@ -1117,18 +1479,46 @@ impl AudioHandler { let timeout = None; let stream = device.build_output_stream( config, - move |data: &mut [T], _: &_| { + move |data: &mut [T], info: &cpal::OutputCallbackInfo| { if !*ready.lock().unwrap() { *ready.lock().unwrap() = true; } - let mut lock = audio_buffer.lock().unwrap(); + let mut n = data.len(); - if lock.occupied_len() < n { - n = lock.occupied_len(); + let mut lock = audio_buffer.lock().unwrap(); + let mut having = lock.occupied_len(); + // android two timestamps, one from zero, another not + #[cfg(not(target_os = "android"))] + if having < n { + let tms = info.timestamp(); + let how_long = tms + .playback + .duration_since(&tms.callback) + .unwrap_or(Duration::from_millis(0)); + + // must long enough to fight back scheuler delay + if how_long > Duration::from_millis(6) && how_long < Duration::from_millis(3000) + { + drop(lock); + std::thread::sleep(how_long.div_f32(1.2)); + lock = audio_buffer.lock().unwrap(); + having = lock.occupied_len(); + } + + if having < n { + n = having; + } + } + #[cfg(target_os = "android")] + if having < n { + n = having; } let mut elems = vec![0.0f32; n]; - lock.pop_slice(&mut elems); + if n > 0 { + lock.pop_slice(&mut elems); + } drop(lock); + let mut input = elems.into_iter(); for sample in data.iter_mut() { *sample = match input.next() { @@ -1173,9 +1563,15 @@ impl VideoHandler { pub fn new(format: CodecFormat, _display: usize) -> Self { let luid = Self::get_adapter_luid(); log::info!("new video handler for display #{_display}, format: {format:?}, luid: {luid:?}"); + let rgba_format = + if cfg!(feature = "flutter") && (cfg!(windows) || cfg!(target_os = "linux")) { + ImageFormat::ABGR + } else { + ImageFormat::ARGB + }; VideoHandler { decoder: Decoder::new(format, luid), - rgb: ImageRgb::new(ImageFormat::ARGB, crate::get_dst_align_rgba()), + rgb: ImageRgb::new(rgba_format, crate::get_dst_align_rgba()), texture: Default::default(), recorder: Default::default(), record: false, @@ -1255,14 +1651,15 @@ impl VideoHandler { } /// Start or stop screen record. - pub fn record_screen(&mut self, start: bool, id: String, display: usize) { + pub fn record_screen(&mut self, start: bool, id: String, display_idx: usize, camera: bool) { self.record = false; if start { self.recorder = Recorder::new(RecorderContext { server: false, id, dir: crate::ui_interface::video_save_directory(false), - display, + display_idx, + camera, tx: None, }) .map_or(Default::default(), |r| Arc::new(Mutex::new(Some(r)))); @@ -1333,6 +1730,7 @@ struct ConnToken { pub struct LoginConfigHandler { id: String, pub conn_type: ConnType, + pub is_terminal_admin: bool, hash: Hash, password: Vec, // remember password for reconnect pub remember: bool, @@ -1347,6 +1745,9 @@ pub struct LoginConfigHandler { pub direct: Option, pub received: bool, switch_uuid: Option, + #[cfg(feature = "flutter")] + #[cfg(not(any(target_os = "android", target_os = "ios")))] + switch_back_allowed: bool, pub save_ab_password_to_recent: bool, // true: connected with ab password pub other_server: Option<(String, String, String)>, pub custom_fps: Arc>>, @@ -1358,7 +1759,8 @@ pub struct LoginConfigHandler { password_source: PasswordSource, // where the sent password comes from shared_password: Option, // Store the shared password pub enable_trusted_devices: bool, - pub record: bool, + pub record_state: bool, + pub record_permission: bool, } impl Deref for LoginConfigHandler { @@ -1394,18 +1796,18 @@ impl LoginConfigHandler { let server = server_key.next().unwrap_or_default(); let args = server_key.next().unwrap_or_default(); let key = if server == PUBLIC_SERVER { - PUBLIC_RS_PUB_KEY + config::RS_PUB_KEY.to_owned() } else { - let mut args_map: HashMap<&str, &str> = HashMap::new(); + let mut args_map: HashMap = HashMap::new(); for arg in args.split('&') { if let Some(kv) = arg.find('=') { - let k = &arg[0..kv]; + let k = arg[0..kv].to_lowercase(); let v = &arg[kv + 1..]; args_map.insert(k, v); } } let key = args_map.remove("key").unwrap_or_default(); - key + key.to_owned() }; // here we can check /r@server @@ -1413,7 +1815,7 @@ impl LoginConfigHandler { if real_id != raw_id { force_relay = true; } - self.other_server = Some((real_id.clone(), server.to_owned(), key.to_owned())); + self.other_server = Some((real_id.clone(), server.to_owned(), key)); id = format!("{real_id}@{server}"); } else { let real_id = crate::ui_interface::handle_relay_id(&id); @@ -1450,20 +1852,51 @@ impl LoginConfigHandler { self.restarting_remote_device = false; self.force_relay = config::option2bool("force-always-relay", &self.get_option("force-always-relay")) - || force_relay; + || force_relay + || use_ws() + || Config::is_proxy(); if let Some((real_id, server, key)) = &self.other_server { let other_server_key = self.get_option("other-server-key"); if !other_server_key.is_empty() && key.is_empty() { self.other_server = Some((real_id.to_owned(), server.to_owned(), other_server_key)); } } + self.direct = None; self.received = false; + #[cfg(feature = "flutter")] + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + self.switch_back_allowed = false; + } self.switch_uuid = switch_uuid; self.adapter_luid = adapter_luid; self.selected_windows_session_id = None; self.shared_password = shared_password; - self.record = LocalConfig::get_bool_option(OPTION_ALLOW_AUTO_RECORD_OUTGOING); + self.record_state = false; + self.record_permission = true; + + // `std::env::remove_var("IS_TERMINAL_ADMIN");` is called in `session_add_sync()` - `flutter_ffi.rs`. + let is_terminal_admin = conn_type == ConnType::TERMINAL + && std::env::var("IS_TERMINAL_ADMIN").map_or(false, |v| v == "Y"); + self.is_terminal_admin = is_terminal_admin; + } + + #[cfg(feature = "flutter")] + #[cfg(not(any(target_os = "android", target_os = "ios")))] + pub fn allow_switch_back_once(&mut self) { + self.switch_back_allowed = true; + } + + #[cfg(feature = "flutter")] + #[cfg(not(any(target_os = "android", target_os = "ios")))] + pub fn consume_switch_back_permission(&mut self) -> bool { + if self.switch_back_allowed { + self.switch_back_allowed = false; + true + } else { + false + } } /// Check if the client should auto login. @@ -1503,6 +1936,9 @@ impl LoginConfigHandler { /// * `v` - value of option pub fn set_option(&mut self, k: String, v: String) { let mut config = self.load_config(); + if v == self.get_option(&k) { + return; + } config.options.insert(k, v); self.save_config(config); } @@ -1568,13 +2004,24 @@ impl LoginConfigHandler { /// /// # Arguments /// - /// * `value` - The view style to be saved. + /// * `value` - The scroll style to be saved. pub fn save_scroll_style(&mut self, value: String) { let mut config = self.load_config(); config.scroll_style = value; self.save_config(config); } + /// Save edge scroll edge thickness to the current config. + /// + /// # Arguments + /// + /// * `value` - The edge thickness to be saved. + pub fn save_edge_scroll_edge_thickness(&mut self, value: i32) { + let mut config = self.load_config(); + config.edge_scroll_edge_thickness = value; + self.save_config(config); + } + /// Set a ui config of flutter for handler's [`PeerConfig`]. /// /// # Arguments @@ -1619,7 +2066,7 @@ impl LoginConfigHandler { /// // It's Ok to check the option empty in this function. // `toggle_option()` is only called in a session. - // Custom client advanced settings will not affact this function. + // Custom client advanced settings will not effect this function. pub fn toggle_option(&mut self, name: String) -> Option { let mut option = OptionMessage::default(); let mut config = self.load_config(); @@ -1671,6 +2118,14 @@ impl LoginConfigHandler { BoolOption::No }) .into(); + } else if name == keys::OPTION_TERMINAL_PERSISTENT { + config.terminal_persistent.v = !config.terminal_persistent.v; + option.terminal_persistent = (if config.terminal_persistent.v { + BoolOption::Yes + } else { + BoolOption::No + }) + .into(); } else if name == "privacy-mode" { // try toggle privacy mode option.privacy_mode = (if config.privacy_mode.v { @@ -1716,7 +2171,19 @@ impl LoginConfigHandler { option.show_remote_cursor = f(self.get_toggle_option("show-remote-cursor")); option.enable_file_transfer = f(self.config.enable_file_copy_paste.v); option.lock_after_session_end = f(self.config.lock_after_session_end.v); + if config.show_my_cursor.v { + config.show_my_cursor.v = false; + option.show_my_cursor = BoolOption::No.into(); + } } + } else if name == "show-my-cursor" { + config.show_my_cursor.v = !config.show_my_cursor.v; + option.show_my_cursor = if config.show_my_cursor.v { + BoolOption::Yes + } else { + BoolOption::No + } + .into(); } else { let is_set = self .options @@ -1731,6 +2198,12 @@ impl LoginConfigHandler { self.config.store(&self.id); return None; } + + #[cfg(feature = "unix-file-copy-paste")] + if option.enable_file_transfer.enum_value() == Ok(BoolOption::No) { + crate::clipboard::try_empty_clipboard_files(crate::clipboard::ClipboardSide::Client, 0); + } + if !name.contains("block-input") { self.save_config(config); } @@ -1762,6 +2235,14 @@ impl LoginConfigHandler { return None; } let mut msg = OptionMessage::new(); + if self.conn_type.eq(&ConnType::TERMINAL) { + if self.get_toggle_option(keys::OPTION_TERMINAL_PERSISTENT) { + msg.terminal_persistent = BoolOption::Yes.into(); + return Some(msg); + } else { + return None; + } + } let q = self.image_quality.clone(); if let Some(q) = self.get_image_quality_enum(&q, ignore_default) { msg.image_quality = q.into(); @@ -1795,6 +2276,9 @@ impl LoginConfigHandler { if view_only || self.get_toggle_option("show-remote-cursor") { msg.show_remote_cursor = BoolOption::Yes.into(); } + if view_only && self.get_toggle_option("show-my-cursor") { + msg.show_my_cursor = BoolOption::Yes.into(); + } if self.get_toggle_option("follow-remote-cursor") { msg.follow_remote_cursor = BoolOption::Yes.into(); } @@ -1807,7 +2291,7 @@ impl LoginConfigHandler { if self.get_toggle_option("disable-audio") { msg.disable_audio = BoolOption::Yes.into(); } - if !view_only && self.get_toggle_option(config::keys::OPTION_ENABLE_FILE_COPY_PASTE) { + if !view_only && self.get_toggle_option(keys::OPTION_ENABLE_FILE_COPY_PASTE) { msg.enable_file_transfer = BoolOption::Yes.into(); } if view_only || self.get_toggle_option("disable-clipboard") { @@ -1857,15 +2341,17 @@ impl LoginConfigHandler { /// // It's Ok to check the option empty in this function. // `get_toggle_option()` is only called in a session. - // Custom client advanced settings will not affact this function. + // Custom client advanced settings will not effect this function. pub fn get_toggle_option(&self, name: &str) -> bool { if name == "show-remote-cursor" { self.config.show_remote_cursor.v } else if name == "lock-after-session-end" { self.config.lock_after_session_end.v + } else if name == keys::OPTION_TERMINAL_PERSISTENT { + self.config.terminal_persistent.v } else if name == "privacy-mode" { self.config.privacy_mode.v - } else if name == config::keys::OPTION_ENABLE_FILE_COPY_PASTE { + } else if name == keys::OPTION_ENABLE_FILE_COPY_PASTE { self.config.enable_file_copy_paste.v } else if name == "disable-audio" { self.config.disable_audio.v @@ -1877,6 +2363,8 @@ impl LoginConfigHandler { self.config.allow_swap_key.v } else if name == "view-only" { self.config.view_only.v + } else if name == "show-my-cursor" { + self.config.show_my_cursor.v } else if name == "follow-remote-cursor" { self.config.follow_remote_cursor.v } else if name == "follow-remote-window" { @@ -1956,6 +2444,12 @@ impl LoginConfigHandler { res } + pub fn save_trackpad_speed(&mut self, speed: i32) { + let mut config = self.load_config(); + config.trackpad_speed = speed; + self.save_config(config); + } + /// Create a [`Message`] for saving custom fps. /// /// # Arguments @@ -2159,23 +2653,56 @@ impl LoginConfigHandler { } else { (my_id, self.id.clone()) }; - let mut display_name = get_builtin_option(config::keys::OPTION_DISPLAY_NAME); + let mut avatar = get_builtin_option(keys::OPTION_AVATAR); + if avatar.is_empty() { + avatar = serde_json::from_str::(&LocalConfig::get_option( + "user_info", + )) + .ok() + .and_then(|x| { + x.get("avatar") + .and_then(|x| x.as_str()) + .map(|x| x.trim().to_owned()) + }) + .unwrap_or_default(); + } + avatar = resolve_avatar_url(avatar); + let mut display_name = get_builtin_option(keys::OPTION_DISPLAY_NAME); if display_name.is_empty() { display_name = serde_json::from_str::(&LocalConfig::get_option("user_info")) .map(|x| { - x.get("name") - .map(|x| x.as_str().unwrap_or_default()) + x.get("display_name") + .and_then(|x| x.as_str()) + .map(|x| x.trim()) + .filter(|x| !x.is_empty()) + .or_else(|| x.get("name").and_then(|x| x.as_str())) + .map(|x| x.to_owned()) .unwrap_or_default() - .to_owned() }) .unwrap_or_default(); } if display_name.is_empty() { display_name = crate::username(); } + let display_name = display_name + .split_whitespace() + .map(|word| { + word.chars() + .enumerate() + .map(|(i, c)| { + if i == 0 { + c.to_uppercase().to_string() + } else { + c.to_string() + } + }) + .collect::() + }) + .collect::>() + .join(" "); #[cfg(not(target_os = "android"))] - let my_platform = whoami::platform().to_string(); + let my_platform = hbb_common::whoami::platform().to_string(); #[cfg(target_os = "android")] let my_platform = "Android".into(); let hwid = if self.get_option("trust-this-device") == "Y" { @@ -2199,6 +2726,7 @@ impl LoginConfigHandler { }) .into(), hwid, + avatar, ..Default::default() }; match self.conn_type { @@ -2207,11 +2735,17 @@ impl LoginConfigHandler { show_hidden: !self.get_option("remote_show_hidden").is_empty(), ..Default::default() }), + ConnType::VIEW_CAMERA => lr.set_view_camera(Default::default()), ConnType::PORT_FORWARD | ConnType::RDP => lr.set_port_forward(PortForward { host: self.port_forward.0.clone(), port: self.port_forward.1, ..Default::default() }), + ConnType::TERMINAL => { + let mut terminal = Terminal::new(); + terminal.service_id = self.get_option(self.get_key_terminal_service_id()); + lr.set_terminal(terminal); + } _ => {} } @@ -2256,78 +2790,77 @@ impl LoginConfigHandler { }) .ok() } + + pub fn get_id(&self) -> &str { + &self.id + } + + pub fn get_key_terminal_service_id(&self) -> &'static str { + if self.is_terminal_admin { + "terminal-admin-service-id" + } else { + "terminal-service-id" + } + } } /// Media data. pub enum MediaData { - VideoQueue(usize), + VideoQueue, VideoFrame(Box), AudioFrame(Box), AudioFormat(AudioFormat), - Reset(Option), + Reset, RecordScreen(bool), } pub type MediaSender = mpsc::Sender; -struct VideoHandlerController { - handler: VideoHandler, - skip_beginning: u32, -} - -/// Start video and audio thread. -/// Return two [`MediaSender`], they should be given to the media producer. +/// Start video thread. /// /// # Arguments /// /// * `video_callback` - The callback for video frame. Being called when a video frame is ready. -pub fn start_video_audio_threads( +pub fn start_video_thread( session: Session, + display: usize, + video_receiver: mpsc::Receiver, + video_queue: Arc>>, + fps: Arc>>, + chroma: Arc>>, + discard_queue: Arc>, video_callback: F, -) -> ( - MediaSender, - MediaSender, - Arc>>>, - Arc>>, - Arc>>, -) -where +) where F: 'static + FnMut(usize, &mut scrap::ImageRgb, *mut c_void, bool) + Send, T: InvokeUiSession, { - let (video_sender, video_receiver) = mpsc::channel::(); - let video_queue_map: Arc>>> = Default::default(); - let video_queue_map_cloned = video_queue_map.clone(); let mut video_callback = video_callback; - - let fps = Arc::new(RwLock::new(None)); - let decode_fps_map = fps.clone(); - let chroma = Arc::new(RwLock::new(None)); - let chroma_cloned = chroma.clone(); let mut last_chroma = None; + let is_view_camera = session.is_view_camera(); std::thread::spawn(move || { #[cfg(windows)] sync_cpu_usage(); get_hwcodec_config(); - let mut handler_controller_map = HashMap::new(); + let mut video_handler = None; let mut count = 0; let mut duration = std::time::Duration::ZERO; + let mut skip_beginning = 0; loop { if let Ok(data) = video_receiver.recv() { match data { - MediaData::VideoFrame(_) | MediaData::VideoQueue(_) => { + MediaData::VideoFrame(_) | MediaData::VideoQueue => { let vf = match data { - MediaData::VideoFrame(vf) => *vf, - MediaData::VideoQueue(display) => { - if let Some(video_queue) = - video_queue_map.read().unwrap().get(&display) - { - if let Some(vf) = video_queue.pop() { - vf - } else { + MediaData::VideoFrame(vf) => { + *discard_queue.write().unwrap() = false; + *vf + } + MediaData::VideoQueue => { + if let Some(vf) = video_queue.read().unwrap().pop() { + if discard_queue.read().unwrap().clone() { continue; } + vf } else { continue; } @@ -2340,36 +2873,26 @@ where let display = vf.display as usize; let start = std::time::Instant::now(); let format = CodecFormat::from(&vf); - if !handler_controller_map.contains_key(&display) { + if video_handler.is_none() { let mut handler = VideoHandler::new(format, display); - let record = session.lc.read().unwrap().record; + let record_state = session.lc.read().unwrap().record_state; + let record_permission = session.lc.read().unwrap().record_permission; let id = session.lc.read().unwrap().id.clone(); - if record { - handler.record_screen(record, id, display); + if record_state && record_permission { + handler.record_screen(true, id, display, is_view_camera); } - handler_controller_map.insert( - display, - VideoHandlerController { - handler, - skip_beginning: 0, - }, - ); + video_handler = Some(handler); } - if let Some(handler_controller) = handler_controller_map.get_mut(&display) { + if let Some(handler) = video_handler.as_mut() { let mut pixelbuffer = true; let mut tmp_chroma = None; - let format_changed = - handler_controller.handler.decoder.format() != format; - match handler_controller.handler.handle_frame( - vf, - &mut pixelbuffer, - &mut tmp_chroma, - ) { + let format_changed = handler.decoder.format() != format; + match handler.handle_frame(vf, &mut pixelbuffer, &mut tmp_chroma) { Ok(true) => { video_callback( display, - &mut handler_controller.handler.rgb, - handler_controller.handler.texture.texture, + &mut handler.rgb, + handler.texture.texture, pixelbuffer, ); @@ -2381,7 +2904,7 @@ where // fps calculation fps_calculate( - handler_controller, + &mut skip_beginning, &fps, format_changed, start.elapsed(), @@ -2410,53 +2933,34 @@ where // check invalid decoders let mut should_update_supported = false; - handler_controller_map - .iter() - .map(|(_, h)| { - if !h.handler.decoder.valid() || h.handler.fail_counter >= MAX_DECODE_FAIL_COUNTER { - let mut lc = session.lc.write().unwrap(); - let format = h.handler.decoder.format(); - if !lc.mark_unsupported.contains(&format) { - lc.mark_unsupported.push(format); - should_update_supported = true; - log::info!("mark {format:?} decoder as unsupported, valid:{}, fail_counter:{}, all unsupported:{:?}", h.handler.decoder.valid(), h.handler.fail_counter, lc.mark_unsupported); - } + if let Some(handler) = video_handler.as_mut() { + if !handler.decoder.valid() + || handler.fail_counter >= MAX_DECODE_FAIL_COUNTER + { + let mut lc = session.lc.write().unwrap(); + let format = handler.decoder.format(); + if !lc.mark_unsupported.contains(&format) { + lc.mark_unsupported.push(format); + should_update_supported = true; + log::info!("mark {format:?} decoder as unsupported, valid:{}, fail_counter:{}, all unsupported:{:?}", handler.decoder.valid(), handler.fail_counter, lc.mark_unsupported); } - }) - .count(); + } + } if should_update_supported { session.send(Data::Message( session.lc.read().unwrap().update_supported_decodings(), )); } } - MediaData::Reset(display) => { - if let Some(display) = display { - if let Some(handler_controler) = - handler_controller_map.get_mut(&display) - { - handler_controler.handler.reset(None); - } - } else { - for (_, handler_controler) in handler_controller_map.iter_mut() { - handler_controler.handler.reset(None); - } + MediaData::Reset => { + if let Some(handler) = video_handler.as_mut() { + handler.reset(None); } } MediaData::RecordScreen(start) => { - log::info!("record screen command: start: {start}"); - let record = session.lc.read().unwrap().record; - session.update_record_status(start); - if record != start { - session.lc.write().unwrap().record = start; - let id = session.lc.read().unwrap().id.clone(); - for (display, handler_controler) in handler_controller_map.iter_mut() { - handler_controler.handler.record_screen( - start, - id.clone(), - *display, - ); - } + let id = session.lc.read().unwrap().id.clone(); + if let Some(handler) = video_handler.as_mut() { + handler.record_screen(start, id, display, is_view_camera); } } _ => {} @@ -2467,14 +2971,6 @@ where } log::info!("Video decoder loop exits"); }); - let audio_sender = start_audio_thread(); - return ( - video_sender, - audio_sender, - video_queue_map_cloned, - decode_fps_map, - chroma_cloned, - ); } /// Start an audio thread @@ -2506,7 +3002,7 @@ pub fn start_audio_thread() -> MediaSender { #[inline] fn fps_calculate( - handler_controller: &mut VideoHandlerController, + skip_beginning: &mut usize, fps: &Arc>>, format_changed: bool, elapsed: std::time::Duration, @@ -2516,11 +3012,11 @@ fn fps_calculate( if format_changed { *count = 0; *duration = std::time::Duration::ZERO; - handler_controller.skip_beginning = 0; + *skip_beginning = 0; } // // The first frame will be very slow - if handler_controller.skip_beginning < 3 { - handler_controller.skip_beginning += 1; + if *skip_beginning < 3 { + *skip_beginning += 1; return; } *duration += elapsed; @@ -2777,6 +3273,7 @@ fn _input_os_password(p: String, activate: bool, interface: impl Interface) { return; } let mut key_event = KeyEvent::new(); + key_event.mode = KeyboardMode::Legacy.into(); key_event.press = true; let mut msg_out = Message::new(); key_event.set_seq(p); @@ -2905,6 +3402,36 @@ pub fn handle_login_error( } } +#[cfg(feature = "flutter")] +#[cfg(not(any(target_os = "android", target_os = "ios")))] +async fn consume_local_switch_sides_uuid(id: &str, uuid: &Uuid) -> bool { + let Ok(mut conn) = crate::ipc::connect(1000, "").await else { + return false; + }; + let uuid = uuid.to_string(); + if conn + .send(&crate::ipc::Data::SwitchSidesUuid( + uuid.clone(), + id.to_owned(), + None, + )) + .await + .is_err() + { + return false; + } + match conn.next_timeout(1000).await { + Ok(Some(crate::ipc::Data::SwitchSidesUuid( + returned_uuid, + returned_id, + Some(true), + ))) => { + returned_uuid == uuid && returned_id == id + } + _ => false, + } +} + /// Handle hash message sent by peer. /// Hash will be used for login. /// @@ -2925,12 +3452,22 @@ pub async fn handle_hash( // Take care of password application order // switch_uuid - let uuid = lc.write().unwrap().switch_uuid.take(); - if let Some(uuid) = uuid { - if let Ok(uuid) = uuid::Uuid::from_str(&uuid) { - send_switch_login_request(lc.clone(), peer, uuid).await; - lc.write().unwrap().password_source = Default::default(); - return; + #[cfg(feature = "flutter")] + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + let uuid = lc.write().unwrap().switch_uuid.take(); + 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 { + 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; + } + } } } // last password @@ -2972,8 +3509,7 @@ pub async fn handle_hash( } if password.is_empty() { - let p = - crate::ui_interface::get_builtin_option(config::keys::OPTION_DEFAULT_CONNECT_PASSWORD); + let p = crate::ui_interface::get_builtin_option(keys::OPTION_DEFAULT_CONNECT_PASSWORD); if !p.is_empty() { let mut hasher = Sha256::new(); hasher.update(p.clone()); @@ -2985,6 +3521,19 @@ pub async fn handle_hash( } lc.write().unwrap().password = password.clone(); + + let is_terminal_admin = lc.read().unwrap().is_terminal_admin; + let is_terminal = lc.read().unwrap().conn_type.eq(&ConnType::TERMINAL); + if is_terminal && is_terminal_admin { + if password.is_empty() { + interface.msgbox("terminal-admin-login-password", "", "", ""); + } else { + interface.msgbox("terminal-admin-login", "", "", ""); + } + lc.write().unwrap().hash = hash; + return; + } + let password = if password.is_empty() { // login without password, the remote side can click accept interface.msgbox("input-password", "Password Required", "", ""); @@ -2996,8 +3545,15 @@ pub async fn handle_hash( hasher.finalize()[..].into() }; - let os_username = lc.read().unwrap().get_option("os-username"); - let os_password = lc.read().unwrap().get_option("os-password"); + 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; lc.write().unwrap().hash = hash; @@ -3198,7 +3754,7 @@ pub enum Data { Close, Login((String, String, String, bool)), Message(Message), - SendFiles((i32, String, String, i32, bool, bool)), + SendFiles((i32, JobType, String, String, i32, bool, bool)), RemoveDirAll((i32, String, bool, bool)), ConfirmDeleteFiles((i32, i32)), SetNoConfirm(i32), @@ -3208,11 +3764,11 @@ pub enum Data { CancelJob(i32), RemovePortForward(i32), AddPortForward((i32, String, i32)), - #[cfg(not(feature = "flutter"))] + #[cfg(all(target_os = "windows", not(feature = "flutter")))] ToggleClipboardFile, NewRDP, SetConfirmOverrideFile((i32, i32, bool, bool, bool)), - AddJob((i32, String, String, i32, bool, bool)), + AddJob((i32, JobType, String, String, i32, bool, bool)), ResumeJob((i32, bool)), RecordScreen(bool), ElevateDirect, @@ -3221,6 +3777,7 @@ pub enum Data { CloseVoiceCall, ResetDecoder(Option), RenameFile((i32, String, String, bool)), + TakeScreenshot((i32, String)), } /// Keycode for key events. @@ -3328,6 +3885,7 @@ lazy_static::lazy_static! { ("VK_PRINT", Key::ControlKey(ControlKey::Print)), ("VK_EXECUTE", Key::ControlKey(ControlKey::Execute)), ("VK_SNAPSHOT", Key::ControlKey(ControlKey::Snapshot)), + ("VK_SCROLL", Key::ControlKey(ControlKey::Scroll)), ("VK_INSERT", Key::ControlKey(ControlKey::Insert)), ("VK_DELETE", Key::ControlKey(ControlKey::Delete)), ("VK_HELP", Key::ControlKey(ControlKey::Help)), @@ -3367,12 +3925,17 @@ pub fn check_if_retry(msgtype: &str, title: &str, text: &str, retry_for_relay: b && title == "Connection Error" && ((text.contains("10054") || text.contains("104")) && retry_for_relay || (!text.to_lowercase().contains("offline") - && !text.to_lowercase().contains("exist") - && !text.to_lowercase().contains("handshake") + && !text.to_lowercase().contains("not exist") + && (!text.to_lowercase().contains("handshake") + // https://github.com/snapview/tungstenite-rs/blob/e7e060a89a72cb08e31c25a6c7284dc1bd982e23/src/error.rs#L248 + || text + .to_lowercase() + .contains("connection reset without closing handshake") && use_ws()) && !text.to_lowercase().contains("failed") && !text.to_lowercase().contains("resolve") && !text.to_lowercase().contains("mismatch") && !text.to_lowercase().contains("manually") + && !text.to_lowercase().contains("restricted") && !text.to_lowercase().contains("not allowed"))) } @@ -3456,8 +4019,7 @@ pub mod peer_online { rendezvous_proto::*, sleep, socket_client::connect_tcp, - tcp::FramedStream, - ResultType, + ResultType, Stream, }; pub async fn query_online_states, Vec)>(ids: Vec, f: F) { @@ -3480,7 +4042,7 @@ pub mod peer_online { } } - async fn create_online_stream() -> ResultType { + async fn create_online_stream() -> ResultType { let (rendezvous_server, _servers, _contained) = crate::get_rendezvous_server(READ_TIMEOUT).await; let tmp: Vec<&str> = rendezvous_server.split(":").collect(); @@ -3523,11 +4085,9 @@ pub mod peer_online { } // Retry for 2 times to get the online response for _ in 0..2 { - if let Some(msg_in) = crate::common::get_next_nonkeyexchange_msg( - &mut socket, - Some(timeout.as_millis() as _), - ) - .await + if let Some(msg_in) = + crate::get_next_nonkeyexchange_msg(&mut socket, Some(timeout.as_millis() as _)) + .await { match msg_in.union { Some(rendezvous_message::Union::OnlineResponse(online_response)) => { @@ -3579,3 +4139,126 @@ pub mod peer_online { } } } + +async fn test_udp_uat( + udp_socket: Arc, + server_addr: SocketAddr, + udp_port: Arc>, + mut stop_udp_rx: oneshot::Receiver<()>, +) -> ResultType<()> { + let (tx, mut rx) = oneshot::channel::<_>(); + tokio::spawn(async { + if let Ok(v) = crate::test_nat_ipv4().await { + tx.send(v).ok(); + } + }); + + let start = Instant::now(); + let mut msg_out = RendezvousMessage::new(); + msg_out.set_test_nat_request(TestNatRequest { + ..Default::default() + }); + // Adaptive retry strategy that works within TCP RTT constraints + // Start with aggressive sending, then back off + let mut retry_interval = Duration::from_millis(20); // Start fast + const MAX_INTERVAL: Duration = Duration::from_millis(200); + let mut packets_sent = 0; + + // Send initial burst to improve reliability + let data = msg_out.write_to_bytes()?; + for _ in 0..2 { + if let Err(e) = udp_socket.send_to(&data, server_addr).await { + log::warn!("Failed to send initial UDP NAT test packet: {}", e); + } else { + packets_sent += 1; + } + } + let mut last_send_time = Instant::now(); + let mut buf = [0u8; 1500]; + + loop { + tokio::select! { + Ok((addr, server)) = &mut rx => { + *udp_port.lock().unwrap() = addr.port(); + log::debug!("UDP NAT test received response from {}: {}", addr, server); + break; + } + _ = &mut stop_udp_rx => { + log::debug!("UDP NAT test received stop signal after {} packets", packets_sent); + break; + } + _ = hbb_common::sleep(retry_interval.as_secs_f32()) => { + // Adaptive retry: send fewer packets as time goes on + let elapsed = last_send_time.elapsed(); + + if elapsed >= retry_interval { + // Send single packet (not double) to reduce network load + if let Err(e) = udp_socket.send_to(&data, server_addr).await { + log::warn!("Failed to send UDP NAT test retry packet: {}", e); + } else { + packets_sent += 1; + } + + // Exponentially increase interval to reduce network pressure + retry_interval = std::cmp::min( + Duration::from_millis((retry_interval.as_millis() as f64 * 1.5) as u64), + MAX_INTERVAL + ); + last_send_time = Instant::now(); + } + } + res = udp_socket.recv(&mut buf[..]) => { + match res { + Ok(n) => { + match RendezvousMessage::parse_from_bytes(&buf[0..n]) { + Ok(msg_in) => { + if let Some(rendezvous_message::Union::TestNatResponse(response)) = msg_in.union { + *udp_port.lock().unwrap() = response.port as u16; + break; + } + } + Err(e) => { + log::warn!("Failed to parse UDP NAT test response: {}", e); + } + } + } + Err(e) => { + log::warn!("UDP NAT test socket error: {}", e); + } + } + } + } + } + + let final_port = *udp_port.lock().unwrap(); + log::debug!( + "UDP NAT test to {:?} finished: time={:?}, port={}, packets_sent={}, success={}", + server_addr, + start.elapsed(), + final_port, + packets_sent, + final_port > 0 + ); + Ok(()) +} + +#[inline] +async fn udp_nat_connect( + socket: Arc, + typ: &'static str, + ms_timeout: u64, +) -> ResultType<(Stream, Option, &'static str)> { + crate::punch_udp(socket.clone(), false) + .await + .map_err(|err| { + log::debug!("{err}"); + anyhow!(err) + })?; + let res = KcpStream::connect(socket, Duration::from_millis(ms_timeout)) + .await + .map_err(|err| { + log::debug!("Failed to connect KCP stream: {}", err); + anyhow!(err) + })?; + Ok((res.1, Some(res.0), typ)) +} diff --git a/src/client/file_trait.rs b/src/client/file_trait.rs index 71ddfb09c..003767bcb 100644 --- a/src/client/file_trait.rs +++ b/src/client/file_trait.rs @@ -3,10 +3,36 @@ use hbb_common::{fs, log, message_proto::*}; use super::{Data, Interface}; pub trait FileManager: Interface { + #[cfg(not(any( + target_os = "android", + target_os = "ios", + feature = "cli", + feature = "flutter" + )))] fn get_home_dir(&self) -> String { fs::get_home_as_string() } + #[cfg(not(any( + target_os = "android", + target_os = "ios", + feature = "cli", + feature = "flutter" + )))] + fn get_next_job_id(&self) -> i32 { + fs::get_next_job_id() + } + + #[cfg(not(any( + target_os = "android", + target_os = "ios", + feature = "cli", + feature = "flutter" + )))] + fn update_next_job_id(&self, id: i32) { + fs::update_next_job_id(id); + } + #[cfg(not(any( target_os = "android", target_os = "ios", @@ -25,24 +51,22 @@ pub trait FileManager: Interface { } } - #[cfg(any( - target_os = "android", - target_os = "ios", - feature = "cli", - feature = "flutter" - ))] - fn read_dir(&self, path: &str, include_hidden: bool) -> String { - use crate::common::make_fd_to_json; - match fs::read_dir(&fs::get_path(path), include_hidden) { - Ok(fd) => make_fd_to_json(fd.id, fd.path, &fd.entries), - Err(_) => "".into(), - } - } - fn cancel_job(&self, id: i32) { self.send(Data::CancelJob(id)); } + fn read_empty_dirs(&self, path: String, include_hidden: bool) { + let mut msg_out = Message::new(); + let mut file_action = FileAction::new(); + file_action.set_read_empty_dirs(ReadEmptyDirs { + path, + include_hidden, + ..Default::default() + }); + msg_out.set_file_action(file_action); + self.send(Data::Message(msg_out)); + } + fn read_remote_dir(&self, path: String, include_hidden: bool) { let mut msg_out = Message::new(); let mut file_action = FileAction::new(); @@ -63,10 +87,22 @@ pub trait FileManager: Interface { self.send(Data::RemoveDirAll((id, path, is_remote, include_hidden))); } + #[cfg(not(any( + target_os = "android", + target_os = "ios", + feature = "cli", + feature = "flutter" + )))] fn confirm_delete_files(&self, id: i32, file_num: i32) { self.send(Data::ConfirmDeleteFiles((id, file_num))); } + #[cfg(not(any( + target_os = "android", + target_os = "ios", + feature = "cli", + feature = "flutter" + )))] fn set_no_confirm(&self, id: i32) { self.send(Data::SetNoConfirm(id)); } @@ -86,6 +122,7 @@ pub trait FileManager: Interface { fn send_files( &self, id: i32, + r#type: i32, path: String, to: String, file_num: i32, @@ -94,6 +131,7 @@ pub trait FileManager: Interface { ) { self.send(Data::SendFiles(( id, + r#type.into(), path, to, file_num, @@ -105,6 +143,7 @@ pub trait FileManager: Interface { fn add_job( &self, id: i32, + r#type: i32, path: String, to: String, file_num: i32, @@ -113,6 +152,7 @@ pub trait FileManager: Interface { ) { self.send(Data::AddJob(( id, + r#type.into(), path, to, file_num, diff --git a/src/client/io_loop.rs b/src/client/io_loop.rs index cc74c96ed..5eb7a273a 100644 --- a/src/client/io_loop.rs +++ b/src/client/io_loop.rs @@ -1,16 +1,7 @@ -use std::{ - collections::HashMap, - num::NonZeroI64, - sync::{ - atomic::{AtomicUsize, Ordering}, - Arc, RwLock, - }, -}; - #[cfg(not(any(target_os = "android", target_os = "ios")))] -use crate::clipboard::{update_clipboard, ClipboardSide, CLIPBOARD_INTERVAL}; +use crate::clipboard::{update_clipboard, ClipboardSide}; #[cfg(not(any(target_os = "ios")))] -use crate::{audio_service, ConnInner, CLIENT_SERVER}; +use crate::{audio_service, clipboard::CLIPBOARD_INTERVAL, ConnInner, CLIENT_SERVER}; use crate::{ client::{ self, new_voice_call_request, Client, Data, Interface, MediaData, MediaSender, @@ -19,14 +10,19 @@ use crate::{ common::get_default_sound_input, ui_session_interface::{InvokeUiSession, Session}, }; -#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] +#[cfg(feature = "unix-file-copy-paste")] +use crate::{clipboard::try_empty_clipboard_files, clipboard_file::unix_file_clip}; +#[cfg(any( + target_os = "windows", + all(target_os = "macos", feature = "unix-file-copy-paste") +))] use clipboard::ContextSend; use crossbeam_queue::ArrayQueue; #[cfg(not(target_os = "ios"))] use hbb_common::tokio::sync::mpsc::error::TryRecvError; use hbb_common::{ allow_err, - config::{self, PeerConfig, TransferSerde}, + config::{self, LocalConfig, PeerConfig, TransferSerde}, fs::{ self, can_enable_overwrite_detection, get_job, get_string, new_send_confirm, DigestCheckResult, RemoveJobMeta, @@ -43,14 +39,22 @@ use hbb_common::{ }, Stream, }; -#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] +#[cfg(any(target_os = "windows", feature = "unix-file-copy-paste"))] use hbb_common::{tokio::sync::Mutex as TokioMutex, ResultType}; use scrap::CodecFormat; +use std::{ + collections::HashMap, + ffi::c_void, + num::NonZeroI64, + path::PathBuf, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, RwLock, + }, +}; pub struct Remote { handler: Session, - video_queue_map: Arc>>>, - video_sender: MediaSender, audio_sender: MediaSender, receiver: mpsc::UnboundedReceiver, sender: mpsc::UnboundedSender, @@ -64,16 +68,16 @@ pub struct Remote { last_update_jobs_status: (Instant, HashMap), is_connected: bool, first_frame: bool, - #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] + #[cfg(any(target_os = "windows", feature = "unix-file-copy-paste"))] client_conn_id: i32, // used for file clipboard data_count: Arc, - frame_count_map: Arc>>, video_format: CodecFormat, elevation_requested: bool, - fps_control: FpsControl, - decode_fps: Arc>>, - chroma: Arc>>, peer_info: ParsedPeerInfo, + video_threads: HashMap, + chroma: Arc>>, + last_record_state: bool, + sent_close_reason: bool, } #[derive(Default)] @@ -81,6 +85,8 @@ struct ParsedPeerInfo { platform: String, is_installed: bool, idd_impl: String, + support_view_camera: bool, + support_terminal: bool, } impl ParsedPeerInfo { @@ -94,20 +100,12 @@ impl ParsedPeerInfo { impl Remote { pub fn new( handler: Session, - video_queue: Arc>>>, - video_sender: MediaSender, - audio_sender: MediaSender, receiver: mpsc::UnboundedReceiver, sender: mpsc::UnboundedSender, - frame_count_map: Arc>>, - decode_fps: Arc>>, - chroma: Arc>>, ) -> Self { Self { handler, - video_queue_map: video_queue, - video_sender, - audio_sender, + audio_sender: crate::client::start_audio_thread(), receiver, sender, read_jobs: Vec::new(), @@ -117,26 +115,52 @@ impl Remote { last_update_jobs_status: (Instant::now(), Default::default()), is_connected: false, first_frame: false, - #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] + #[cfg(any(target_os = "windows", feature = "unix-file-copy-paste"))] client_conn_id: 0, data_count: Arc::new(AtomicUsize::new(0)), - frame_count_map, video_format: CodecFormat::Unknown, stop_voice_call_sender: None, voice_call_request_timestamp: None, elevation_requested: false, - fps_control: Default::default(), - decode_fps, - chroma, peer_info: Default::default(), + video_threads: Default::default(), + chroma: Default::default(), + last_record_state: false, + sent_close_reason: false, } } pub async fn io_loop(&mut self, key: &str, token: &str, round: u32) { + #[cfg(target_os = "windows")] + let _file_clip_context_holder = { + // `is_port_forward()` will not reach here, but we still check it for clarity. + if self.handler.is_default() { + // It is ok to call this function multiple times. + ContextSend::enable(true); + Some(crate::SimpleCallOnReturn { + b: true, + f: Box::new(|| { + // No need to call `enable(false)` for sciter version, because each client of sciter version is a new process. + // It's better to check if the peers are windows(support file copy&paste), but it's not necessary. + #[cfg(feature = "flutter")] + if !crate::flutter::sessions::has_sessions_running(ConnType::DEFAULT_CONN) { + ContextSend::enable(false); + }; + }), + }) + } else { + None + } + }; + let mut last_recv_time = Instant::now(); let mut received = false; let conn_type = if self.handler.is_file_transfer() { ConnType::FILE_TRANSFER + } else if self.handler.is_view_camera() { + ConnType::VIEW_CAMERA + } else if self.handler.is_terminal() { + ConnType::TERMINAL } else { ConnType::default() }; @@ -150,40 +174,45 @@ impl Remote { ) .await { - Ok(((mut peer, direct, pk), (feedback, rendezvous_server))) => { + Ok(((mut peer, direct, pk, kcp, stream_type), (feedback, rendezvous_server))) => { self.handler .connection_round_state .lock() .unwrap() .set_connected(); - self.handler.set_connection_type(peer.is_secured(), direct); // flutter -> connection_ready + self.handler + .set_connection_type(peer.is_secured(), direct, stream_type); // flutter -> connection_ready self.handler.update_direct(Some(direct)); - if conn_type == ConnType::DEFAULT_CONN { + if conn_type == ConnType::DEFAULT_CONN || conn_type == ConnType::VIEW_CAMERA { self.handler .set_fingerprint(crate::common::pk_to_fingerprint(pk.unwrap_or_default())); } // just build for now - #[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))] + #[cfg(not(any(target_os = "windows", feature = "unix-file-copy-paste")))] let (_tx_holder, mut rx_clip_client) = mpsc::unbounded_channel::(); - #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] + #[cfg(any(target_os = "windows", feature = "unix-file-copy-paste"))] let (_tx_holder, rx) = mpsc::unbounded_channel(); - #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] - let mut rx_clip_client_lock = Arc::new(TokioMutex::new(rx)); - #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] + #[cfg(any(target_os = "windows", feature = "unix-file-copy-paste"))] + let mut rx_clip_client_holder = (Arc::new(TokioMutex::new(rx)), None); + #[cfg(any(target_os = "windows", feature = "unix-file-copy-paste"))] { - let is_conn_not_default = self.handler.is_file_transfer() - || self.handler.is_port_forward() - || self.handler.is_rdp(); - if !is_conn_not_default { - log::debug!("get cliprdr client for conn_id {}", self.client_conn_id); - (self.client_conn_id, rx_clip_client_lock) = + if self.handler.is_default() { + (self.client_conn_id, rx_clip_client_holder.0) = clipboard::get_rx_cliprdr_client(&self.handler.get_id()); + log::debug!("get cliprdr client for conn_id {}", self.client_conn_id); + let client_conn_id = self.client_conn_id; + rx_clip_client_holder.1 = Some(crate::SimpleCallOnReturn { + b: true, + f: Box::new(move || { + clipboard::remove_channel_by_conn_id(client_conn_id); + }), + }); }; } - #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] - let mut rx_clip_client = rx_clip_client_lock.lock().await; + #[cfg(any(target_os = "windows", feature = "unix-file-copy-paste"))] + let mut rx_clip_client = rx_clip_client_holder.0.lock().await; let mut status_timer = crate::rustdesk_interval(time::interval(Duration::new(1, 0))); @@ -231,8 +260,8 @@ impl Remote { } } _msg = rx_clip_client.recv() => { - #[cfg(any(target_os="windows", target_os="linux", target_os = "macos"))] - self.handle_local_clipboard_msg(&mut peer, _msg).await; + #[cfg(any(target_os = "windows", feature = "unix-file-copy-paste"))] + self.handle_local_clipboard_msg(&mut peer, _msg).await; } _ = self.timer.tick() => { if last_recv_time.elapsed() >= SEC30 { @@ -250,7 +279,6 @@ impl Remote { } } _ = status_timer.tick() => { - self.fps_control(direct); let elapsed = fps_instant.elapsed().as_millis(); if elapsed < 1000 { continue; @@ -260,14 +288,14 @@ impl Remote { speed = speed * 1000 / elapsed as usize; let speed = format!("{:.2}kB/s", speed as f32 / 1024 as f32); - let mut frame_count_map_write = self.frame_count_map.write().unwrap(); - let frame_count_map = frame_count_map_write.clone(); - frame_count_map_write.values_mut().for_each(|v| *v = 0); - drop(frame_count_map_write); - let fps = frame_count_map.iter().map(|(k, v)| { + let fps = self.video_threads.iter().map(|(k, v)| { // Correcting the inaccuracy of status_timer - (k.clone(), (*v as i32) * 1000 / elapsed as i32) + (k.clone(), (*v.frame_count.read().unwrap() as i32) * 1000 / elapsed as i32) }).collect::>(); + self.video_threads.iter().for_each(|(_, v)| { + *v.frame_count.write().unwrap() = 0; + }); + self.fps_control(direct, fps.clone()); let chroma = self.chroma.read().unwrap().clone(); let chroma = match chroma { Some(Chroma::I444) => "4:4:4", @@ -275,10 +303,16 @@ impl Remote { None => "-", }; let chroma = Some(chroma.to_string()); + let codec_format = if self.video_format == CodecFormat::Unknown { + None + } else { + Some(self.video_format.clone()) + }; self.handler.update_quality_status(QualityStatus { speed: Some(speed), fps, chroma, + codec_format, ..Default::default() }); } @@ -289,6 +323,13 @@ impl Remote { if let Some(s) = self.stop_voice_call_sender.take() { s.send(()).ok(); } + if kcp.is_some() { + // Send the close reason if it hasn't been sent yet, as KCP cannot detect the socket close event. + self.send_close_reason(&mut peer, "kcp").await; + // KCP does not send messages immediately, so wait to ensure the last message is sent. + // 1ms works in my test, but 30ms is more reliable. + tokio::time::sleep(Duration::from_millis(30)).await; + } } Err(err) => { self.handler.on_establish_connection_error(err.to_string()); @@ -302,26 +343,21 @@ impl Remote { .unwrap() .set_disconnected(round); - #[cfg(not(any(target_os = "android", target_os = "ios")))] - if _set_disconnected_ok { + #[cfg(not(target_os = "ios"))] + if self.handler.is_default() && _set_disconnected_ok { Client::try_stop_clipboard(); } - #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] - if _set_disconnected_ok { - let conn_id = self.client_conn_id; - log::debug!("try empty cliprdr for conn_id {}", conn_id); - let _ = ContextSend::proc(|context| -> ResultType<()> { - context.empty_clipboard(conn_id)?; - Ok(()) - }); + #[cfg(any(target_os = "windows", feature = "unix-file-copy-paste"))] + if self.handler.is_default() && _set_disconnected_ok { + crate::clipboard::try_empty_clipboard_files(ClipboardSide::Client, self.client_conn_id); } } - #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] + #[cfg(any(target_os = "windows", feature = "unix-file-copy-paste"))] async fn handle_local_clipboard_msg( &self, - peer: &mut crate::client::FramedStream, + peer: &mut Stream, msg: Option, ) { match msg { @@ -349,8 +385,12 @@ impl Remote { view_only, stop, is_stopping_allowed, server_file_transfer_enabled, file_transfer_enabled ); if stop { - ContextSend::set_is_stopped(); + #[cfg(target_os = "windows")] + { + ContextSend::set_is_stopped(); + } } else { + #[cfg(target_os = "windows")] if let Err(e) = ContextSend::make_sure_enabled() { log::error!("failed to restart clipboard context: {}", e); // to-do: Show msgbox with "Don't show again" option @@ -403,7 +443,10 @@ impl Remote { // Start a voice call recorder, records audio and send to remote fn start_voice_call(&mut self) -> Option> { - if self.handler.is_file_transfer() || self.handler.is_port_forward() { + if self.handler.is_file_transfer() + || self.handler.is_port_forward() + || self.handler.is_terminal() + { return None; } // iOS does not have this server. @@ -478,14 +521,22 @@ impl Remote { } } + async fn send_close_reason(&mut self, peer: &mut Stream, reason: &str) { + if self.sent_close_reason { + return; + } + let mut misc = Misc::new(); + misc.set_close_reason(reason.to_owned()); + let mut msg = Message::new(); + msg.set_misc(misc); + allow_err!(peer.send(&msg).await); + self.sent_close_reason = true; + } + async fn handle_msg_from_ui(&mut self, data: Data, peer: &mut Stream) -> bool { match data { Data::Close => { - let mut misc = Misc::new(); - misc.set_close_reason("".to_owned()); - let mut msg = Message::new(); - msg.set_misc(misc); - allow_err!(peer.send(&msg).await); + self.send_close_reason(peer, "").await; return false; } Data::Login((os_username, os_password, password, remember)) => { @@ -493,37 +544,60 @@ impl Remote { .handle_login_from_ui(os_username, os_password, password, remember, peer) .await; } - #[cfg(not(feature = "flutter"))] + #[cfg(all(target_os = "windows", not(feature = "flutter")))] Data::ToggleClipboardFile => { self.check_clipboard_file_context(); } Data::Message(msg) => { + match &msg.union { + Some(message::Union::Misc(misc)) => match misc.union { + Some(misc::Union::RefreshVideo(_)) => { + self.video_threads.iter().for_each(|(_, v)| { + *v.discard_queue.write().unwrap() = true; + }); + } + Some(misc::Union::RefreshVideoDisplay(display)) => { + if let Some(v) = self.video_threads.get_mut(&(display as usize)) { + *v.discard_queue.write().unwrap() = true; + } + } + _ => {} + }, + _ => {} + } allow_err!(peer.send(&msg).await); } - Data::SendFiles((id, path, to, file_num, include_hidden, is_remote)) => { + Data::SendFiles((id, r#type, path, to, file_num, include_hidden, is_remote)) => { log::info!("send files, is remote {}", is_remote); let od = can_enable_overwrite_detection(self.handler.lc.read().unwrap().version); if is_remote { log::debug!("New job {}, write to {} from remote {}", id, to, path); + let to = match r#type { + fs::JobType::Generic => fs::DataSource::FilePath(PathBuf::from(&to)), + fs::JobType::Printer => { + fs::DataSource::MemoryCursor(std::io::Cursor::new(Vec::new())) + } + }; self.write_jobs.push(fs::TransferJob::new_write( id, + r#type, path.clone(), to, file_num, include_hidden, is_remote, - Vec::new(), od, )); allow_err!( - peer.send(&fs::new_send(id, path, file_num, include_hidden)) + peer.send(&fs::new_send(id, r#type, path, file_num, include_hidden)) .await ); } else { match fs::TransferJob::new_read( id, + r#type, to.clone(), - path.clone(), + fs::DataSource::FilePath(PathBuf::from(&path)), file_num, include_hidden, is_remote, @@ -567,7 +641,7 @@ impl Remote { } } } - Data::AddJob((id, path, to, file_num, include_hidden, is_remote)) => { + Data::AddJob((id, r#type, path, to, file_num, include_hidden, is_remote)) => { let od = can_enable_overwrite_detection(self.handler.lc.read().unwrap().version); if is_remote { log::debug!( @@ -578,12 +652,12 @@ impl Remote { ); let mut job = fs::TransferJob::new_write( id, + r#type, path.clone(), - to, + fs::DataSource::FilePath(PathBuf::from(&to)), file_num, include_hidden, is_remote, - Vec::new(), od, ); job.is_last_job = true; @@ -591,8 +665,9 @@ impl Remote { } else { match fs::TransferJob::new_read( id, + r#type, to.clone(), - path.clone(), + fs::DataSource::FilePath(PathBuf::from(&path)), file_num, include_hidden, is_remote, @@ -627,9 +702,11 @@ impl Remote { if is_remote { if let Some(job) = get_job(id, &mut self.write_jobs) { job.is_last_job = false; + job.is_resume = true; allow_err!( peer.send(&fs::new_send( id, + fs::JobType::Generic, job.remote.clone(), job.file_num, job.show_hidden @@ -639,17 +716,36 @@ impl Remote { } } else { if let Some(job) = get_job(id, &mut self.read_jobs) { - job.is_last_job = false; - allow_err!( - peer.send(&fs::new_receive( - id, - job.path.to_string_lossy().to_string(), - job.file_num, - job.files.clone(), - job.total_size(), - )) - .await - ); + match &job.data_source { + fs::DataSource::FilePath(_p) => { + job.is_last_job = false; + job.is_resume = true; + job.set_finished_size_on_resume(); + #[cfg(not(windows))] + let files = job.files().clone(); + #[cfg(windows)] + let mut files = job.files().clone(); + #[cfg(windows)] + if self.handler.peer_platform() != "Windows" { + // peer is not windows, need transform \ to / + fs::transform_windows_path(&mut files); + } + allow_err!( + peer.send(&fs::new_receive( + id, + job.remote.clone(), + job.file_num, + files, + job.total_size(), + )) + .await + ); + } + fs::DataSource::MemoryCursor(_) => { + // unreachable!() + log::error!("Resume job with memory cursor"); + } + } } } } @@ -685,7 +781,8 @@ impl Remote { Some(file_transfer_send_confirm_request::Union::Skip(true)) }, ..Default::default() - }); + }) + .await; } } else { if let Some(job) = fs::get_job(id, &mut self.write_jobs) { @@ -704,7 +801,7 @@ impl Remote { }, ..Default::default() }; - job.confirm(&req); + job.confirm(&req).await; file_action.set_send_confirm(req); msg.set_file_action(file_action); allow_err!(peer.send(&msg).await); @@ -746,20 +843,7 @@ impl Remote { } } Data::CancelJob(id) => { - let mut msg_out = Message::new(); - let mut file_action = FileAction::new(); - file_action.set_cancel(FileTransferCancel { - id: id, - ..Default::default() - }); - msg_out.set_file_action(file_action); - allow_err!(peer.send(&msg_out).await); - if let Some(job) = fs::get_job(id, &mut self.write_jobs) { - job.remove_download_file(); - fs::remove_job(id, &mut self.write_jobs); - } - fs::remove_job(id, &mut self.read_jobs); - self.remove_jobs.remove(&id); + self.cancel_transfer_job(id, peer).await; } Data::RemoveDir((id, path)) => { let mut msg_out = Message::new(); @@ -838,7 +922,8 @@ impl Remote { } } Data::RecordScreen(start) => { - let _ = self.video_sender.send(MediaData::RecordScreen(start)); + self.handler.lc.write().unwrap().record_state = start; + self.update_record_state(); } Data::ElevateDirect => { let mut request = ElevationRequest::new(); @@ -881,8 +966,26 @@ impl Remote { .on_voice_call_closed("Closed manually by the peer"); allow_err!(peer.send(&msg).await); } - Data::ResetDecoder(display) => { - self.video_sender.send(MediaData::Reset(display)).ok(); + Data::ResetDecoder(display) => match display { + Some(display) => { + if let Some(v) = self.video_threads.get_mut(&display) { + v.video_sender.send(MediaData::Reset).ok(); + } + } + None => { + for (_, v) in self.video_threads.iter_mut() { + v.video_sender.send(MediaData::Reset).ok(); + } + } + }, + Data::TakeScreenshot((display, sid)) => { + let mut msg = Message::new(); + msg.set_screenshot_request(ScreenshotRequest { + display, + sid, + ..Default::default() + }); + allow_err!(peer.send(&msg).await); } _ => {} } @@ -936,8 +1039,26 @@ impl Remote { } } + async fn cancel_transfer_job(&mut self, id: i32, peer: &mut Stream) { + let mut msg_out = Message::new(); + let mut file_action = FileAction::new(); + file_action.set_cancel(FileTransferCancel { + id, + ..Default::default() + }); + msg_out.set_file_action(file_action); + allow_err!(peer.send(&msg_out).await); + if let Some(job) = fs::remove_job(id, &mut self.write_jobs) { + job.remove_download_file(); + } + let _ = fs::remove_job(id, &mut self.read_jobs); + self.remove_jobs.remove(&id); + } + pub async fn sync_jobs_status_to_local(&mut self) -> bool { - log::info!("sync transfer job status"); + if !self.is_connected { + return false; + } let mut config: PeerConfig = self.handler.load_config(); let mut transfer_metas = TransferSerde::default(); for job in self.read_jobs.iter() { @@ -1011,104 +1132,156 @@ impl Remote { } } + // Currently, this function only considers decoding speed and queue length, not network delay. + // The controlled end can consider auto fps as the maximum decoding fps. #[inline] - fn fps_control(&mut self, direct: bool) { + fn fps_control(&mut self, direct: bool, real_fps_map: HashMap) { + self.video_threads.iter_mut().for_each(|(k, v)| { + let real_fps = real_fps_map.get(k).cloned().unwrap_or_default(); + if real_fps == 0 { + v.fps_control.inactive_counter += 1; + } else { + v.fps_control.inactive_counter = 0; + } + }); let custom_fps = self.handler.lc.read().unwrap().custom_fps.clone(); let custom_fps = custom_fps.lock().unwrap().clone(); let mut custom_fps = custom_fps.unwrap_or(30); if custom_fps < 5 || custom_fps > 120 { custom_fps = 30; } - let ctl = &mut self.fps_control; - let len = self - .video_queue_map - .read() - .unwrap() + let inactive_threshold = 15; + let max_queue_len = self + .video_threads .iter() - .map(|v| v.1.len()) + .map(|v| v.1.video_queue.read().unwrap().len()) .max() .unwrap_or_default(); - let decode_fps = self.decode_fps.read().unwrap().clone(); - let Some(mut decode_fps) = decode_fps else { + let min_decode_fps = self + .video_threads + .iter() + .filter(|v| v.1.fps_control.inactive_counter < inactive_threshold) + .map(|v| *v.1.decode_fps.read().unwrap()) + .min() + .flatten(); + let Some(min_decode_fps) = min_decode_fps else { return; }; - if cfg!(feature = "flutter") { - let active_displays = ctl - .last_active_time - .iter() - .filter(|t| t.1.elapsed().as_secs() < 5) - .count(); - if active_displays > 1 { - decode_fps = decode_fps / active_displays; - } - } let mut limited_fps = if direct { - decode_fps * 9 / 10 // 30 got 27 + min_decode_fps * 9 / 10 // 30 got 27 } else { - decode_fps * 4 / 5 // 30 got 24 + min_decode_fps * 4 / 5 // 30 got 24 }; if limited_fps > custom_fps { limited_fps = custom_fps; } let last_auto_fps = self.handler.lc.read().unwrap().last_auto_fps.clone(); - let should_decrease = (len > 1 - && last_auto_fps.clone().unwrap_or(custom_fps as _) > limited_fps) - || len > std::cmp::max(1, limited_fps / 2); - - // increase judgement - if len <= 1 { - if ctl.idle_counter < usize::MAX { + let displays = self.video_threads.keys().cloned().collect::>(); + let mut fps_trending = |display: usize| { + let thread = self.video_threads.get_mut(&display)?; + let ctl = &mut thread.fps_control; + let len = thread.video_queue.read().unwrap().len(); + let decode_fps = thread.decode_fps.read().unwrap().clone()?; + let last_auto_fps = last_auto_fps.clone().unwrap_or(custom_fps as _); + if ctl.inactive_counter > inactive_threshold { + return None; + } + if len > 1 && last_auto_fps > limited_fps || len > std::cmp::max(1, decode_fps / 2) { + ctl.idle_counter = 0; + return Some(false); + } + if len <= 1 { ctl.idle_counter += 1; + if ctl.idle_counter > 3 && last_auto_fps + 3 <= limited_fps { + return Some(true); + } } - } else { - ctl.idle_counter = 0; - } - let mut should_increase = false; - if let Some(last_auto_fps) = last_auto_fps.clone() { - // ever set - if last_auto_fps + 3 <= limited_fps && ctl.idle_counter > 3 { - // limited_fps is 3 larger than last set, and idle time is more than 3 seconds - should_increase = true; + if len > 1 { + ctl.idle_counter = 0; } - } + None + }; + let trendings: Vec<_> = displays.iter().map(|k| fps_trending(*k)).collect(); + let should_decrease = trendings.iter().any(|v| *v == Some(false)); + let should_increase = !should_decrease && trendings.iter().any(|v| *v == Some(true)); if last_auto_fps.is_none() || should_decrease || should_increase { // limited_fps to ensure decoding is faster than encoding let mut auto_fps = limited_fps; - if should_decrease && limited_fps < len { + if should_decrease && limited_fps < max_queue_len { auto_fps = limited_fps / 2; } if auto_fps < 1 { auto_fps = 1; } - let mut misc = Misc::new(); - misc.set_option(OptionMessage { - custom_fps: auto_fps as _, - ..Default::default() - }); - let mut msg = Message::new(); - msg.set_misc(misc); - self.sender.send(Data::Message(msg)).ok(); - log::info!("Set fps to {}", auto_fps); - ctl.last_queue_size = len; - self.handler.lc.write().unwrap().last_auto_fps = Some(auto_fps); + if Some(auto_fps) != last_auto_fps { + let mut misc = Misc::new(); + misc.set_option(OptionMessage { + custom_fps: auto_fps as _, + ..Default::default() + }); + let mut msg = Message::new(); + msg.set_misc(misc); + self.sender.send(Data::Message(msg)).ok(); + log::info!("Set fps to {}", auto_fps); + self.handler.lc.write().unwrap().last_auto_fps = Some(auto_fps); + } } // send refresh - for (display, video_queue) in self.video_queue_map.read().unwrap().iter() { - let tolerable = std::cmp::min(decode_fps, video_queue.capacity() / 2); + for (display, thread) in self.video_threads.iter_mut() { + let ctl = &mut thread.fps_control; + let video_queue = thread.video_queue.read().unwrap(); + let tolerable = std::cmp::min(min_decode_fps, video_queue.capacity() / 2); if ctl.refresh_times < 20 // enough && (video_queue.len() > tolerable - && (ctl.refresh_times == 0 || ctl.last_refresh_instant.elapsed().as_secs() > 10)) + && (ctl.refresh_times == 0 || ctl.last_refresh_instant.map(|t|t.elapsed().as_secs() > 10).unwrap_or(false))) { // Refresh causes client set_display, left frames cause flickering. - while let Some(_) = video_queue.pop() {} + drop(video_queue); self.handler.refresh_video(*display as _); log::info!("Refresh display {} to reduce delay", display); ctl.refresh_times += 1; - ctl.last_refresh_instant = Instant::now(); + ctl.last_refresh_instant = Some(Instant::now()); } } } + fn check_view_camera_support(&self, peer_version: &str, peer_platform: &str) -> bool { + if self.peer_info.support_view_camera { + return true; + } + if hbb_common::get_version_number(&peer_version) < hbb_common::get_version_number("1.3.9") + && (peer_platform == "Windows" || peer_platform == "Linux") + { + self.handler.msgbox( + "error", + "Download new version", + "upgrade_remote_rustdesk_client_to_{1.3.9}_tip", + "", + ); + } else { + self.handler.on_error("view_camera_unsupported_tip"); + } + return false; + } + + fn check_terminal_support(&self, peer_version: &str) -> bool { + if self.peer_info.support_terminal { + return true; + } + if hbb_common::get_version_number(&peer_version) < hbb_common::get_version_number("1.4.1") { + self.handler.msgbox( + "error", + "Remote terminal not supported", + "Remote terminal is not supported by the remote side. Please upgrade to version 1.4.1 or higher.", + "", + ); + } else { + self.handler + .on_error("Remote terminal is not supported by the remote side"); + } + return false; + } + async fn handle_msg_from_peer(&mut self, data: &[u8], peer: &mut Stream) -> bool { if let Ok(msg_in) = Message::parse_from_bytes(&data) { match msg_in.union { @@ -1120,39 +1293,29 @@ impl Remote { self.send_toggle_virtual_display_msg(peer).await; self.send_toggle_privacy_mode_msg(peer).await; } - let incoming_format = CodecFormat::from(&vf); - if self.video_format != incoming_format { - self.video_format = incoming_format.clone(); - self.handler.update_quality_status(QualityStatus { - codec_format: Some(incoming_format), - ..Default::default() - }) - }; + self.video_format = CodecFormat::from(&vf); let display = vf.display as usize; - let mut video_queue_write = self.video_queue_map.write().unwrap(); - if !video_queue_write.contains_key(&display) { - video_queue_write.insert( - display, - ArrayQueue::::new(crate::client::VIDEO_QUEUE_SIZE), - ); + if !self.video_threads.contains_key(&display) { + self.new_video_thread(display); } + let Some(thread) = self.video_threads.get_mut(&display) else { + return true; + }; if Self::contains_key_frame(&vf) { - if let Some(video_queue) = video_queue_write.get_mut(&display) { - while let Some(_) = video_queue.pop() {} - } - self.video_sender + thread + .video_sender .send(MediaData::VideoFrame(Box::new(vf))) .ok(); } else { - if let Some(video_queue) = video_queue_write.get_mut(&display) { - video_queue.force_push(vf); + let video_queue = thread.video_queue.read().unwrap(); + if video_queue.force_push(vf).is_some() { + drop(video_queue); + self.handler.refresh_video(display as _); + } else { + thread.video_sender.send(MediaData::VideoQueue).ok(); } - self.video_sender.send(MediaData::VideoQueue(display)).ok(); } - self.fps_control - .last_active_time - .insert(display, Instant::now()); } Some(message::Union::Hash(hash)) => { self.handler @@ -1173,11 +1336,24 @@ impl Remote { let peer_version = pi.version.clone(); let peer_platform = pi.platform.clone(); self.set_peer_info(&pi); + if self.handler.is_view_camera() { + if !self.check_view_camera_support(&peer_version, &peer_platform) { + self.handler.lc.write().unwrap().handle_peer_info(&pi); + return false; + } + } + if self.handler.is_terminal() { + if !self.check_terminal_support(&peer_version) { + self.handler.lc.write().unwrap().handle_peer_info(&pi); + return false; + } + } self.handler.handle_peer_info(pi); + #[cfg(all(target_os = "windows", not(feature = "flutter")))] self.check_clipboard_file_context(); - if !(self.handler.is_file_transfer() || self.handler.is_port_forward()) { + if self.handler.is_default() { #[cfg(feature = "flutter")] - #[cfg(not(any(target_os = "android", target_os = "ios")))] + #[cfg(not(target_os = "ios"))] let rx = Client::try_start_clipboard(None); #[cfg(not(feature = "flutter"))] #[cfg(not(any(target_os = "android", target_os = "ios")))] @@ -1185,10 +1361,14 @@ impl Remote { crate::client::ClientClipboardContext { cfg: self.handler.get_permission_config(), tx: self.sender.clone(), + #[cfg(feature = "unix-file-copy-paste")] + is_file_supported: crate::is_support_file_copy_paste( + &peer_version, + ), }, )); // To make sure current text clipboard data is updated. - #[cfg(not(any(target_os = "android", target_os = "ios")))] + #[cfg(not(target_os = "ios"))] if let Some(mut rx) = rx { timeout(CLIPBOARD_INTERVAL, rx.recv()).await.ok(); } @@ -1209,6 +1389,15 @@ impl Remote { }); } } + // to-do: Android, is `sync_init_clipboard` really needed? + // https://github.com/rustdesk/rustdesk/discussions/9010 + + #[cfg(feature = "flutter")] + #[cfg(not(target_os = "ios"))] + crate::flutter::update_text_clipboard_required(); + + #[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"))] @@ -1240,7 +1429,7 @@ impl Remote { if !self.handler.lc.read().unwrap().disable_clipboard.v { #[cfg(not(any(target_os = "android", target_os = "ios")))] update_clipboard(vec![cb], ClipboardSide::Client); - #[cfg(any(target_os = "android", target_os = "ios"))] + #[cfg(target_os = "ios")] { let content = if cb.compress { hbb_common::compress::decompress(&cb.content) @@ -1251,20 +1440,44 @@ impl Remote { self.handler.clipboard(content); } } + #[cfg(target_os = "android")] + crate::clipboard::handle_msg_clipboard(cb); } } Some(message::Union::MultiClipboards(_mcb)) => { if !self.handler.lc.read().unwrap().disable_clipboard.v { #[cfg(not(any(target_os = "android", target_os = "ios")))] update_clipboard(_mcb.clipboards, ClipboardSide::Client); + #[cfg(target_os = "ios")] + { + if let Some(cb) = _mcb + .clipboards + .iter() + .find(|c| c.format.enum_value() == Ok(ClipboardFormat::Text)) + { + let content = if cb.compress { + hbb_common::compress::decompress(&cb.content) + } else { + cb.content.to_vec() + }; + if let Ok(content) = String::from_utf8(content) { + self.handler.clipboard(content); + } + } + } + #[cfg(target_os = "android")] + crate::clipboard::handle_msg_multi_clipboards(_mcb); } } - #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] + #[cfg(any(target_os = "windows", feature = "unix-file-copy-paste"))] Some(message::Union::Cliprdr(clip)) => { - self.handle_cliprdr_msg(clip); + self.handle_cliprdr_msg(clip, peer).await; } Some(message::Union::FileResponse(fr)) => { match fr.union { + Some(file_response::Union::EmptyDirs(res)) => { + self.handler.update_empty_dirs(res); + } Some(file_response::Union::Dir(fd)) => { #[cfg(windows)] let entries = fd.entries.to_vec(); @@ -1276,105 +1489,170 @@ impl Remote { fs::transform_windows_path(&mut entries); } } - self.handler - .update_folder_files(fd.id, &entries, fd.path, false, false); + // We cannot call cancel_transfer_job/handle_job_status while holding + // a mutable borrow from fs::get_job(&mut self.write_jobs), so defer + // the error handling until after the borrow scope ends. + let mut set_files_err = None; if let Some(job) = fs::get_job(fd.id, &mut self.write_jobs) { log::info!("job set_files: {:?}", entries); - job.set_files(entries); + if let Err(err) = job.set_files(entries) { + set_files_err = Some(err.to_string()); + } else { + job.set_finished_size_on_resume(); + self.handler.update_folder_files( + fd.id, + job.files(), + fd.path, + false, + false, + ); + } } else if let Some(job) = self.remove_jobs.get_mut(&fd.id) { + // Intentionally keep raw entries here: + // - remote remove flow executes deletions on peer side; + // - local remove flow is populated from local get_recursive_files(). job.files = entries; + self.handler + .update_folder_files(fd.id, &job.files, fd.path, false, false); + } else { + self.handler + .update_folder_files(fd.id, &entries, fd.path, false, false); + } + if let Some(err) = set_files_err { + log::warn!( + "Rejected unsafe file list from remote peer for job {}: {}", + fd.id, + err + ); + self.cancel_transfer_job(fd.id, peer).await; + self.handle_job_status(fd.id, -1, Some(err)); } } Some(file_response::Union::Digest(digest)) => { if digest.is_upload { if let Some(job) = fs::get_job(digest.id, &mut self.read_jobs) { if let Some(file) = job.files().get(digest.file_num as usize) { - let read_path = get_string(&job.join(&file.name)); - let overwrite_strategy = job.default_overwrite_strategy(); - if let Some(overwrite) = overwrite_strategy { - let req = FileTransferSendConfirmRequest { - id: digest.id, - file_num: digest.file_num, - union: Some(if overwrite { - file_transfer_send_confirm_request::Union::OffsetBlk(0) - } else { - file_transfer_send_confirm_request::Union::Skip( - true, - ) - }), - ..Default::default() - }; - job.confirm(&req); - let msg = new_send_confirm(req); - allow_err!(peer.send(&msg).await); - } else { - self.handler.override_file_confirm( - digest.id, - digest.file_num, - read_path, - true, - digest.is_identical, - ); + if let fs::DataSource::FilePath(p) = &job.data_source { + let read_path = + get_string(&fs::TransferJob::join(p, &file.name)); + let mut overwrite_strategy = + job.default_overwrite_strategy(); + let mut offset = 0; + if digest.is_identical && job.is_resume { + if digest.transferred_size > 0 { + overwrite_strategy = Some(true); + offset = digest.transferred_size as _; + } + } + if let Some(overwrite) = overwrite_strategy { + let req = FileTransferSendConfirmRequest { + id: digest.id, + file_num: digest.file_num, + union: Some(if overwrite { + file_transfer_send_confirm_request::Union::OffsetBlk(offset) + } else { + file_transfer_send_confirm_request::Union::Skip( + true, + ) + }), + ..Default::default() + }; + job.confirm(&req).await; + let msg = new_send_confirm(req); + allow_err!(peer.send(&msg).await); + } else { + self.handler.override_file_confirm( + digest.id, + digest.file_num, + read_path, + true, + digest.is_identical, + ); + } } } } } else { if let Some(job) = fs::get_job(digest.id, &mut self.write_jobs) { if let Some(file) = job.files().get(digest.file_num as usize) { - let write_path = get_string(&job.join(&file.name)); - let overwrite_strategy = job.default_overwrite_strategy(); - match fs::is_write_need_confirmation(&write_path, &digest) { - Ok(res) => match res { - DigestCheckResult::IsSame => { - let req = FileTransferSendConfirmRequest { - id: digest.id, - file_num: digest.file_num, - union: Some(file_transfer_send_confirm_request::Union::Skip(true)), - ..Default::default() - }; - job.confirm(&req); - let msg = new_send_confirm(req); - allow_err!(peer.send(&msg).await); - } - DigestCheckResult::NeedConfirm(digest) => { - if let Some(overwrite) = overwrite_strategy { + if let fs::DataSource::FilePath(p) = &job.data_source { + let write_path = + get_string(&fs::TransferJob::join(p, &file.name)); + job.set_digest(digest.file_size, digest.last_modified); + let peer_ver = self.handler.lc.read().unwrap().version; + let is_support_resume = + crate::is_support_file_transfer_resume_num( + peer_ver, + ); + match fs::is_write_need_confirmation( + is_support_resume && job.is_resume, + &write_path, + &digest, + ) { + Ok(res) => match res { + DigestCheckResult::IsSame => { let req = FileTransferSendConfirmRequest { id: digest.id, file_num: digest.file_num, - union: Some(if overwrite { - file_transfer_send_confirm_request::Union::OffsetBlk(0) - } else { - file_transfer_send_confirm_request::Union::Skip(true) - }), + union: Some(file_transfer_send_confirm_request::Union::Skip(true)), ..Default::default() }; - job.confirm(&req); + job.confirm(&req).await; let msg = new_send_confirm(req); allow_err!(peer.send(&msg).await); - } else { - self.handler.override_file_confirm( - digest.id, - digest.file_num, - write_path, - false, - digest.is_identical, - ); } - } - DigestCheckResult::NoSuchFile => { - let req = FileTransferSendConfirmRequest { + DigestCheckResult::NeedConfirm(digest) => { + let mut overwrite_strategy = + job.default_overwrite_strategy(); + let mut offset = 0; + if digest.is_identical + && job.is_resume + && digest.transferred_size > 0 + { + overwrite_strategy = Some(true); + offset = digest.transferred_size as _; + } + if let Some(overwrite) = overwrite_strategy + { + let req = + FileTransferSendConfirmRequest { + id: digest.id, + file_num: digest.file_num, + union: Some(if overwrite { + file_transfer_send_confirm_request::Union::OffsetBlk(offset) + } else { + file_transfer_send_confirm_request::Union::Skip(true) + }), + ..Default::default() + }; + job.confirm(&req).await; + let msg = new_send_confirm(req); + allow_err!(peer.send(&msg).await); + } else { + self.handler.override_file_confirm( + digest.id, + digest.file_num, + write_path, + false, + digest.is_identical, + ); + } + } + DigestCheckResult::NoSuchFile => { + let req = FileTransferSendConfirmRequest { id: digest.id, file_num: digest.file_num, union: Some(file_transfer_send_confirm_request::Union::OffsetBlk(0)), ..Default::default() }; - job.confirm(&req); - let msg = new_send_confirm(req); - allow_err!(peer.send(&msg).await); + job.confirm(&req).await; + let msg = new_send_confirm(req); + allow_err!(peer.send(&msg).await); + } + }, + Err(err) => { + println!("error receiving digest: {}", err); } - }, - Err(err) => { - println!("error receiving digest: {}", err); } } } @@ -1386,23 +1664,77 @@ impl Remote { if let Err(_err) = job.write(block).await { // to-do: add "skip" for writing job } - self.update_jobs_status(); + if job.r#type == fs::JobType::Generic { + self.update_jobs_status(); + } } } Some(file_response::Union::Done(d)) => { let mut err: Option = None; - if let Some(job) = fs::get_job(d.id, &mut self.write_jobs) { + let mut job_type = fs::JobType::Generic; + let mut printer_data = None; + if let Some(job) = fs::remove_job(d.id, &mut self.write_jobs) { job.modify_time(); err = job.job_error(); - fs::remove_job(d.id, &mut self.write_jobs); + job_type = job.r#type; + printer_data = match job.get_buf_data().await { + Ok(d) => d, + Err(e) => { + log::error!("Failed to get the printer data: {}", e); + None + } + }; + } + match job_type { + fs::JobType::Generic => { + self.handle_job_status(d.id, d.file_num, err); + } + fs::JobType::Printer => { + if let Some(err) = err { + log::error!("Receive print job failed, error {err}"); + } else { + log::info!( + "Receive print job done, data len: {:?}", + printer_data.as_ref().map(|d| d.len()).unwrap_or(0) + ); + #[cfg(target_os = "windows")] + if let Some(data) = printer_data { + let printer_name = self + .handler + .printer_names + .write() + .unwrap() + .remove(&d.id); + // Spawn a new thread to handle the print job. + // Or print job will block the ui thread. + std::thread::spawn(move || { + if let Err(e) = + crate::platform::send_raw_data_to_printer( + printer_name, + data, + ) + { + log::error!("Print job error: {}", e); + } + }); + } + } + } } - self.handle_job_status(d.id, d.file_num, err); } Some(file_response::Union::Error(e)) => { - if let Some(_job) = fs::get_job(e.id, &mut self.write_jobs) { - fs::remove_job(e.id, &mut self.write_jobs); + let job_type = fs::remove_job(e.id, &mut self.write_jobs) + .or_else(|| fs::remove_job(e.id, &mut self.read_jobs)) + .map(|j| j.r#type) + .unwrap_or(fs::JobType::Generic); + match job_type { + fs::JobType::Generic => { + self.handle_job_status(e.id, e.file_num, Some(e.error)); + } + fs::JobType::Printer => { + log::error!("Printer job error: {}", e.error); + } } - self.handle_job_status(e.id, e.file_num, Some(e.error)); } _ => {} } @@ -1421,14 +1753,16 @@ impl Remote { Ok(Permission::Keyboard) => { *self.handler.server_keyboard_enabled.write().unwrap() = p.enabled; #[cfg(feature = "flutter")] - #[cfg(not(any(target_os = "android", target_os = "ios")))] + #[cfg(not(target_os = "ios"))] crate::flutter::update_text_clipboard_required(); + #[cfg(all(feature = "flutter", feature = "unix-file-copy-paste"))] + crate::flutter::update_file_clipboard_required(); self.handler.set_permission("keyboard", p.enabled); } Ok(Permission::Clipboard) => { *self.handler.server_clipboard_enabled.write().unwrap() = p.enabled; #[cfg(feature = "flutter")] - #[cfg(not(any(target_os = "android", target_os = "ios")))] + #[cfg(not(target_os = "ios"))] crate::flutter::update_text_clipboard_required(); self.handler.set_permission("clipboard", p.enabled); } @@ -1441,25 +1775,47 @@ impl Remote { if !p.enabled && self.handler.is_file_transfer() { return true; } + #[cfg(all(feature = "flutter", feature = "unix-file-copy-paste"))] + crate::flutter::update_file_clipboard_required(); self.handler.set_permission("file", p.enabled); + #[cfg(feature = "unix-file-copy-paste")] + if !p.enabled { + try_empty_clipboard_files( + ClipboardSide::Client, + self.client_conn_id, + ); + } } Ok(Permission::Restart) => { self.handler.set_permission("restart", p.enabled); } Ok(Permission::Recording) => { + self.handler.lc.write().unwrap().record_permission = p.enabled; + self.update_record_state(); self.handler.set_permission("recording", p.enabled); } Ok(Permission::BlockInput) => { self.handler.set_permission("block_input", p.enabled); } + Ok(Permission::PrivacyMode) => { + self.handler.set_permission("privacy_mode", p.enabled); + } _ => {} } } Some(misc::Union::SwitchDisplay(s)) => { self.handler.handle_peer_switch_display(&s); - self.video_sender - .send(MediaData::Reset(Some(s.display as _))) - .ok(); + if let Some(thread) = self.video_threads.get_mut(&(s.display as usize)) { + thread.video_sender.send(MediaData::Reset).ok(); + } + + let mut scale = 1.0; + if let Some(pi) = &self.handler.lc.read().unwrap().peer_info { + if let Some(d) = pi.displays.get(s.display as usize) { + scale = d.scale; + } + } + if s.width > 0 && s.height > 0 { self.handler.set_display( s.x, @@ -1467,10 +1823,12 @@ impl Remote { s.width, s.height, s.cursor_embedded, + scale, ); } } Some(misc::Union::CloseReason(c)) => { + self.sent_close_reason = true; // The controlled end will close, no need to send close reason self.handler.msgbox("error", "Connection Error", &c, ""); return false; } @@ -1565,9 +1923,23 @@ impl Remote { ); } } + #[cfg(feature = "flutter")] + #[cfg(not(any(target_os = "android", target_os = "ios")))] Some(misc::Union::SwitchBack(_)) => { - #[cfg(feature = "flutter")] - self.handler.switch_back(&self.handler.get_id()); + let allow_switch_back = self + .handler + .lc + .write() + .unwrap() + .consume_switch_back_permission(); + if allow_switch_back { + self.handler.switch_back(&self.handler.get_id()); + } else { + log::warn!( + "Ignored unsolicited SwitchBack from {}", + self.handler.get_id() + ); + } } #[cfg(all(feature = "flutter", feature = "plugin_framework"))] #[cfg(not(any(target_os = "android", target_os = "ios")))] @@ -1609,9 +1981,44 @@ impl Remote { } } Some(message::Union::FileAction(action)) => match action.union { + Some(file_action::Union::Send(_s)) => match _s.file_type.enum_value() { + #[cfg(target_os = "windows")] + Ok(file_transfer_send_request::FileType::Printer) => { + #[cfg(feature = "flutter")] + let action = LocalConfig::get_option( + config::keys::OPTION_PRINTER_INCOMING_JOB_ACTION, + ); + #[cfg(not(feature = "flutter"))] + let action = ""; + if action == "dismiss" { + // Just ignore the incoming print job. + } else { + let id = fs::get_next_job_id(); + #[cfg(feature = "flutter")] + let allow_auto_print = LocalConfig::get_bool_option( + config::keys::OPTION_PRINTER_ALLOW_AUTO_PRINT, + ); + #[cfg(not(feature = "flutter"))] + let allow_auto_print = false; + if allow_auto_print { + let printer_name = if action == "" { + "".to_string() + } else { + LocalConfig::get_option( + config::keys::OPTION_PRINTER_SELECTED_NAME, + ) + }; + self.handler.printer_response(id, _s.path, printer_name); + } else { + self.handler.printer_request(id, _s.path); + } + } + } + _ => {} + }, Some(file_action::Union::SendConfirm(c)) => { if let Some(job) = fs::get_job(c.id, &mut self.read_jobs) { - job.confirm(&c); + job.confirm(&c).await; } } _ => {} @@ -1659,6 +2066,22 @@ impl Remote { self.handler.set_displays(&pi.displays); self.handler.set_platform_additions(&pi.platform_additions); } + Some(message::Union::ScreenshotResponse(response)) => { + crate::client::screenshot::set_screenshot(response.data); + self.handler + .handle_screenshot_resp(response.sid, response.msg); + } + Some(message::Union::TerminalResponse(response)) => { + use hbb_common::message_proto::terminal_response::Union; + if let Some(Union::Opened(opened)) = &response.union { + if opened.success && !opened.service_id.is_empty() { + let mut lc = self.handler.lc.write().unwrap(); + let key = lc.get_key_terminal_service_id().to_owned(); + lc.set_option(key, opened.service_id.clone()); + } + } + self.handler.handle_terminal_response(response); + } _ => {} } } @@ -1667,6 +2090,12 @@ impl Remote { fn set_peer_info(&mut self, pi: &PeerInfo) { self.peer_info.platform = pi.platform.clone(); + + // Check features field for terminal support + if let Some(features) = pi.features.as_ref() { + self.peer_info.support_terminal = features.terminal; + } + if let Ok(platform_additions) = serde_json::from_str::>(&pi.platform_additions) { @@ -1681,6 +2110,11 @@ impl Remote { .flatten() .unwrap_or_default() .to_string(); + self.peer_info.support_view_camera = platform_additions + .get("support_view_camera") + .map(|v| v.as_bool()) + .flatten() + .unwrap_or(false); } } @@ -1858,23 +2292,19 @@ impl Remote { true } + #[cfg(all(target_os = "windows", not(feature = "flutter")))] fn check_clipboard_file_context(&self) { - #[cfg(any( - target_os = "windows", - all( - feature = "unix-file-copy-paste", - any(target_os = "linux", target_os = "macos") - ) - ))] - { - let enabled = *self.handler.server_file_transfer_enabled.read().unwrap() - && self.handler.lc.read().unwrap().enable_file_copy_paste.v; - ContextSend::enable(enabled); - } + let enabled = *self.handler.server_file_transfer_enabled.read().unwrap() + && self.handler.lc.read().unwrap().enable_file_copy_paste.v; + ContextSend::enable(enabled); } - #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] - fn handle_cliprdr_msg(&self, clip: hbb_common::message_proto::Cliprdr) { + #[cfg(any(target_os = "windows", feature = "unix-file-copy-paste"))] + async fn handle_cliprdr_msg( + &mut self, + clip: hbb_common::message_proto::Cliprdr, + _peer: &mut Stream, + ) { log::debug!("handling cliprdr msg from server peer"); #[cfg(feature = "flutter")] if let Some(hbb_common::message_proto::cliprdr::Union::FormatList(_)) = &clip.union { @@ -1891,22 +2321,134 @@ impl Remote { }; let is_stopping_allowed = clip.is_beginning_message(); - let file_transfer_enabled = self.handler.lc.read().unwrap().enable_file_copy_paste.v; + let file_transfer_enabled = self.handler.is_file_clipboard_required(); let stop = is_stopping_allowed && !file_transfer_enabled; log::debug!( "Process clipboard message from server peer, stop: {}, is_stopping_allowed: {}, file_transfer_enabled: {}", stop, is_stopping_allowed, file_transfer_enabled); if !stop { + #[cfg(any( + target_os = "windows", + all(target_os = "macos", feature = "unix-file-copy-paste") + ))] if let Err(e) = ContextSend::make_sure_enabled() { log::error!("failed to restart clipboard context: {}", e); }; - let _ = ContextSend::proc(|context| -> ResultType<()> { - context - .server_clip_file(self.client_conn_id, clip) - .map_err(|e| e.into()) - }); + #[cfg(target_os = "windows")] + { + let _ = ContextSend::proc(|context| -> ResultType<()> { + context + .server_clip_file(self.client_conn_id, clip) + .map_err(|e| e.into()) + }); + } + #[cfg(feature = "unix-file-copy-paste")] + if crate::is_support_file_copy_paste_num(self.handler.lc.read().unwrap().version) { + let mut out_msgs = vec![]; + + #[cfg(target_os = "macos")] + if clipboard::platform::unix::macos::should_handle_msg(&clip) { + if let Err(e) = ContextSend::proc(|context| -> ResultType<()> { + context + .server_clip_file(self.client_conn_id, clip) + .map_err(|e| e.into()) + }) { + log::error!("failed to handle cliprdr msg: {}", e); + } + } else { + out_msgs = unix_file_clip::serve_clip_messages( + ClipboardSide::Client, + clip, + self.client_conn_id, + ); + } + + #[cfg(not(target_os = "macos"))] + { + out_msgs = unix_file_clip::serve_clip_messages( + ClipboardSide::Client, + clip, + self.client_conn_id, + ); + } + + for msg in out_msgs.into_iter() { + allow_err!(_peer.send(&msg).await); + } + } } } + + fn new_video_thread(&mut self, display: usize) { + let video_queue = Arc::new(RwLock::new(ArrayQueue::new(client::VIDEO_QUEUE_SIZE))); + let (video_sender, video_receiver) = std::sync::mpsc::channel::(); + let decode_fps = Arc::new(RwLock::new(None)); + let frame_count = Arc::new(RwLock::new(0)); + let discard_queue = Arc::new(RwLock::new(false)); + let video_thread = VideoThread { + video_queue: video_queue.clone(), + video_sender, + decode_fps: decode_fps.clone(), + frame_count: frame_count.clone(), + fps_control: Default::default(), + discard_queue: discard_queue.clone(), + }; + let handler = self.handler.ui_handler.clone(); + crate::client::start_video_thread( + self.handler.clone(), + display, + video_receiver, + video_queue, + decode_fps, + self.chroma.clone(), + discard_queue, + move |display: usize, + data: &mut scrap::ImageRgb, + _texture: *mut c_void, + pixelbuffer: bool| { + *frame_count.write().unwrap() += 1; + if pixelbuffer { + handler.on_rgba(display, data); + } else { + #[cfg(all(feature = "vram", feature = "flutter"))] + handler.on_texture(display, _texture); + } + }, + ); + self.video_threads.insert(display, video_thread); + if self.video_threads.len() == 1 { + let auto_record = + LocalConfig::get_bool_option(config::keys::OPTION_ALLOW_AUTO_RECORD_OUTGOING); + self.handler.lc.write().unwrap().record_state = auto_record; + self.update_record_state(); + } + } + + fn update_record_state(&mut self) { + // state + let permission = self.handler.lc.read().unwrap().record_permission; + if !permission { + self.handler.lc.write().unwrap().record_state = false; + } + let state = self.handler.lc.read().unwrap().record_state; + let start = state && permission; + if self.last_record_state == start { + return; + } + self.last_record_state = start; + log::info!("record screen start: {start}"); + // update local + for (_, v) in self.video_threads.iter_mut() { + v.video_sender.send(MediaData::RecordScreen(start)).ok(); + } + self.handler.update_record_status(start); + // update remote + let mut misc = Misc::new(); + misc.set_client_record_status(start); + let mut msg = Message::new(); + msg.set_misc(misc); + self.sender.send(Data::Message(msg)).ok(); + } } struct RemoveJob { @@ -1939,22 +2481,26 @@ impl RemoveJob { } } +#[derive(Debug, Default)] struct FpsControl { - last_queue_size: usize, refresh_times: usize, - last_refresh_instant: Instant, + last_refresh_instant: Option, idle_counter: usize, - last_active_time: HashMap, + inactive_counter: usize, } -impl Default for FpsControl { - fn default() -> Self { - Self { - last_queue_size: Default::default(), - refresh_times: Default::default(), - last_refresh_instant: Instant::now(), - idle_counter: 0, - last_active_time: Default::default(), - } +struct VideoThread { + video_queue: Arc>>, + video_sender: MediaSender, + decode_fps: Arc>>, + frame_count: Arc>, + discard_queue: Arc>, + fps_control: FpsControl, +} + +impl Drop for VideoThread { + fn drop(&mut self) { + // since channels are buffered, messages sent before the disconnect will still be properly received. + *self.discard_queue.write().unwrap() = true; } } diff --git a/src/client/screenshot.rs b/src/client/screenshot.rs new file mode 100644 index 000000000..82a95bee9 --- /dev/null +++ b/src/client/screenshot.rs @@ -0,0 +1,99 @@ +#[cfg(not(any(target_os = "android", target_os = "ios")))] +use crate::clipboard::{update_clipboard, ClipboardSide}; +use hbb_common::{message_proto::*, ResultType}; +use std::sync::Mutex; + +lazy_static::lazy_static! { + static ref SCREENSHOT: Mutex = Default::default(); +} + +pub enum ScreenshotAction { + SaveAs(String), + CopyToClipboard, + Discard, +} + +impl Default for ScreenshotAction { + fn default() -> Self { + Self::Discard + } +} + +impl From<&str> for ScreenshotAction { + fn from(value: &str) -> Self { + match value.chars().next() { + Some('0') => { + if let Some((pos, _)) = value.char_indices().nth(2) { + let substring = &value[pos..]; + Self::SaveAs(substring.to_string()) + } else { + Self::default() + } + } + Some('1') => Self::CopyToClipboard, + Some('2') => Self::default(), + _ => Self::default(), + } + } +} + +impl Into for ScreenshotAction { + fn into(self) -> String { + match self { + Self::SaveAs(p) => format!("0:{p}"), + Self::CopyToClipboard => "1".to_owned(), + Self::Discard => "2".to_owned(), + } + } +} + +#[derive(Default)] +pub struct Screenshot { + data: Option, +} + +impl Screenshot { + fn set_screenshot(&mut self, data: bytes::Bytes) { + self.data.replace(data); + } + + fn handle_screenshot(&mut self, action: String) -> String { + let Some(data) = self.data.take() else { + return "No cached screenshot".to_owned(); + }; + match Self::handle_screenshot_(data, action) { + Ok(()) => "".to_owned(), + Err(e) => e.to_string(), + } + } + + fn handle_screenshot_(data: bytes::Bytes, action: String) -> ResultType<()> { + match ScreenshotAction::from(&action as &str) { + ScreenshotAction::SaveAs(p) => { + std::fs::write(p, data)?; + } + ScreenshotAction::CopyToClipboard => { + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + let clips = vec![Clipboard { + compress: false, + content: data, + format: ClipboardFormat::ImagePng.into(), + ..Default::default() + }]; + update_clipboard(clips, ClipboardSide::Client); + } + } + ScreenshotAction::Discard => {} + } + Ok(()) + } +} + +pub fn set_screenshot(data: bytes::Bytes) { + SCREENSHOT.lock().unwrap().set_screenshot(data); +} + +pub fn handle_screenshot(action: String) -> String { + SCREENSHOT.lock().unwrap().handle_screenshot(action) +} diff --git a/src/clipboard.rs b/src/clipboard.rs index 329b392bb..cae7d03ac 100644 --- a/src/clipboard.rs +++ b/src/clipboard.rs @@ -1,13 +1,14 @@ +#[cfg(not(target_os = "android"))] use arboard::{ClipboardData, ClipboardFormat}; -use clipboard_master::{ClipboardHandler, Master, Shutdown}; use hbb_common::{bail, log, message_proto::*, ResultType}; use std::{ - sync::{mpsc::Sender, Arc, Mutex}, - thread::JoinHandle, + sync::{Arc, Mutex}, time::Duration, }; pub const CLIPBOARD_NAME: &'static str = "clipboard"; +#[cfg(feature = "unix-file-copy-paste")] +pub const FILE_CLIPBOARD_NAME: &'static str = "file-clipboard"; pub const CLIPBOARD_INTERVAL: u64 = 333; // This format is used to store the flag in the clipboard. @@ -16,6 +17,7 @@ const RUSTDESK_CLIPBOARD_OWNER_FORMAT: &'static str = "dyn.com.rustdesk.owner"; // Add special format for Excel XML Spreadsheet const CLIPBOARD_FORMAT_EXCEL_XML_SPREADSHEET: &'static str = "XML Spreadsheet"; +#[cfg(not(target_os = "android"))] lazy_static::lazy_static! { static ref ARBOARD_MTX: Arc> = Arc::new(Mutex::new(())); // cache the clipboard msg @@ -27,9 +29,12 @@ lazy_static::lazy_static! { static ref CLIPBOARD_CTX: Arc>> = Arc::new(Mutex::new(None)); } +#[cfg(not(target_os = "android"))] const CLIPBOARD_GET_MAX_RETRY: usize = 3; +#[cfg(not(target_os = "android"))] const CLIPBOARD_GET_RETRY_INTERVAL_DUR: Duration = Duration::from_millis(33); +#[cfg(not(target_os = "android"))] const SUPPORTED_FORMATS: &[ClipboardFormat] = &[ ClipboardFormat::Text, ClipboardFormat::Html, @@ -37,115 +42,13 @@ const SUPPORTED_FORMATS: &[ClipboardFormat] = &[ ClipboardFormat::ImageRgba, ClipboardFormat::ImagePng, ClipboardFormat::ImageSvg, + #[cfg(feature = "unix-file-copy-paste")] + ClipboardFormat::FileUrl, ClipboardFormat::Special(CLIPBOARD_FORMAT_EXCEL_XML_SPREADSHEET), ClipboardFormat::Special(RUSTDESK_CLIPBOARD_OWNER_FORMAT), ]; -#[cfg(all(target_os = "linux", feature = "unix-file-copy-paste"))] -static X11_CLIPBOARD: once_cell::sync::OnceCell = - once_cell::sync::OnceCell::new(); - -#[cfg(all(target_os = "linux", feature = "unix-file-copy-paste"))] -fn get_clipboard() -> Result<&'static x11_clipboard::Clipboard, String> { - X11_CLIPBOARD - .get_or_try_init(|| x11_clipboard::Clipboard::new()) - .map_err(|e| e.to_string()) -} - -#[cfg(all(target_os = "linux", feature = "unix-file-copy-paste"))] -pub struct ClipboardContext { - string_setter: x11rb::protocol::xproto::Atom, - string_getter: x11rb::protocol::xproto::Atom, - text_uri_list: x11rb::protocol::xproto::Atom, - - clip: x11rb::protocol::xproto::Atom, - prop: x11rb::protocol::xproto::Atom, -} - -#[cfg(all(target_os = "linux", feature = "unix-file-copy-paste"))] -fn parse_plain_uri_list(v: Vec) -> Result { - let text = String::from_utf8(v).map_err(|_| "ConversionFailure".to_owned())?; - let mut list = String::new(); - for line in text.lines() { - if !line.starts_with("file://") { - continue; - } - let decoded = percent_encoding::percent_decode_str(line) - .decode_utf8() - .map_err(|_| "ConversionFailure".to_owned())?; - list = list + "\n" + decoded.trim_start_matches("file://"); - } - list = list.trim().to_owned(); - Ok(list) -} - -#[cfg(all(target_os = "linux", feature = "unix-file-copy-paste"))] -impl ClipboardContext { - pub fn new() -> Result { - let clipboard = get_clipboard()?; - let string_getter = clipboard - .getter - .get_atom("UTF8_STRING") - .map_err(|e| e.to_string())?; - let string_setter = clipboard - .setter - .get_atom("UTF8_STRING") - .map_err(|e| e.to_string())?; - let text_uri_list = clipboard - .getter - .get_atom("text/uri-list") - .map_err(|e| e.to_string())?; - let prop = clipboard.getter.atoms.property; - let clip = clipboard.getter.atoms.clipboard; - Ok(Self { - text_uri_list, - string_setter, - string_getter, - clip, - prop, - }) - } - - pub fn get_text(&mut self) -> Result { - let clip = self.clip; - let prop = self.prop; - - const TIMEOUT: std::time::Duration = std::time::Duration::from_millis(120); - - let text_content = get_clipboard()? - .load(clip, self.string_getter, prop, TIMEOUT) - .map_err(|e| e.to_string())?; - - let file_urls = get_clipboard()?.load(clip, self.text_uri_list, prop, TIMEOUT)?; - - if file_urls.is_err() || file_urls.as_ref().is_empty() { - log::trace!("clipboard get text, no file urls"); - return String::from_utf8(text_content).map_err(|e| e.to_string()); - } - - let file_urls = parse_plain_uri_list(file_urls)?; - - let text_content = String::from_utf8(text_content).map_err(|e| e.to_string())?; - - if text_content.trim() == file_urls.trim() { - log::trace!("clipboard got text but polluted"); - return Err(String::from("polluted text")); - } - - Ok(text_content) - } - - pub fn set_text(&mut self, content: String) -> Result<(), String> { - let clip = self.clip; - - let value = content.clone().into_bytes(); - get_clipboard()? - .store(clip, self.string_setter, value) - .map_err(|e| e.to_string())?; - Ok(()) - } -} - +#[cfg(not(target_os = "android"))] pub fn check_clipboard( ctx: &mut Option, side: ClipboardSide, @@ -172,6 +75,104 @@ pub fn check_clipboard( None } +#[cfg(all(feature = "unix-file-copy-paste", target_os = "macos"))] +pub fn is_file_url_set_by_rustdesk(url: &Vec) -> bool { + if url.len() != 1 { + return false; + } + url.iter() + .next() + .map(|s| { + for prefix in &["file:///tmp/.rustdesk_", "//tmp/.rustdesk_"] { + if s.starts_with(prefix) { + return s[prefix.len()..].parse::().is_ok(); + } + } + false + }) + .unwrap_or(false) +} + +#[cfg(feature = "unix-file-copy-paste")] +pub fn check_clipboard_files( + ctx: &mut Option, + side: ClipboardSide, + force: bool, +) -> Option> { + if ctx.is_none() { + *ctx = ClipboardContext::new().ok(); + } + let ctx2 = ctx.as_mut()?; + match ctx2.get_files(side, force) { + Ok(Some(urls)) => { + if !urls.is_empty() { + return Some(urls); + } + } + Err(e) => { + log::error!("Failed to get clipboard file urls. {}", e); + } + _ => {} + } + None +} + +#[cfg(all(target_os = "linux", feature = "unix-file-copy-paste"))] +pub fn update_clipboard_files(files: Vec, side: ClipboardSide) { + if !files.is_empty() { + std::thread::spawn(move || { + do_update_clipboard_(vec![ClipboardData::FileUrl(files)], side); + }); + } +} + +#[cfg(feature = "unix-file-copy-paste")] +pub fn try_empty_clipboard_files(_side: ClipboardSide, _conn_id: i32) { + std::thread::spawn(move || { + let mut ctx = CLIPBOARD_CTX.lock().unwrap(); + if ctx.is_none() { + match ClipboardContext::new() { + Ok(x) => { + *ctx = Some(x); + } + Err(e) => { + log::error!("Failed to create clipboard context: {}", e); + return; + } + } + } + #[allow(unused_mut)] + if let Some(mut ctx) = ctx.as_mut() { + #[cfg(target_os = "linux")] + { + use clipboard::platform::unix; + if unix::fuse::empty_local_files(_side == ClipboardSide::Client, _conn_id) { + ctx.try_empty_clipboard_files(_side); + } + } + #[cfg(target_os = "macos")] + { + ctx.try_empty_clipboard_files(_side); + // No need to make sure the context is enabled. + clipboard::ContextSend::proc(|context| -> ResultType<()> { + context.empty_clipboard(_conn_id).ok(); + Ok(()) + }) + .ok(); + } + } + }); +} + +#[cfg(target_os = "windows")] +pub fn try_empty_clipboard_files(side: ClipboardSide, conn_id: i32) { + log::debug!("try to empty {} cliprdr for conn_id {}", side, conn_id); + let _ = clipboard::ContextSend::proc(|context| -> ResultType<()> { + context.empty_clipboard(conn_id)?; + Ok(()) + }); +} + #[cfg(target_os = "windows")] pub fn check_clipboard_cm() -> ResultType { let mut ctx = CLIPBOARD_CTX.lock().unwrap(); @@ -194,11 +195,17 @@ pub fn check_clipboard_cm() -> ResultType { } } +#[cfg(not(target_os = "android"))] fn update_clipboard_(multi_clipboards: Vec, side: ClipboardSide) { - let mut to_update_data = proto::from_multi_clipbards(multi_clipboards); + let to_update_data = proto::from_multi_clipboards(multi_clipboards); if to_update_data.is_empty() { return; } + do_update_clipboard_(to_update_data, side); +} + +#[cfg(not(target_os = "android"))] +fn do_update_clipboard_(mut to_update_data: Vec, side: ClipboardSide) { let mut ctx = CLIPBOARD_CTX.lock().unwrap(); if ctx.is_none() { match ClipboardContext::new() { @@ -224,18 +231,19 @@ fn update_clipboard_(multi_clipboards: Vec, side: ClipboardSide) { } } +#[cfg(not(target_os = "android"))] pub fn update_clipboard(multi_clipboards: Vec, side: ClipboardSide) { std::thread::spawn(move || { update_clipboard_(multi_clipboards, side); }); } -#[cfg(not(any(all(target_os = "linux", feature = "unix-file-copy-paste"))))] +#[cfg(not(target_os = "android"))] pub struct ClipboardContext { inner: arboard::Clipboard, } -#[cfg(not(any(all(target_os = "linux", feature = "unix-file-copy-paste"))))] +#[cfg(not(target_os = "android"))] #[allow(unreachable_code)] impl ClipboardContext { pub fn new() -> ResultType { @@ -249,7 +257,7 @@ impl ClipboardContext { let mut i = 1; loop { // Try 5 times to create clipboard - // Arboard::new() connect to X server or Wayland compositor, which shoud be ok at most time + // Arboard::new() connect to X server or Wayland compositor, which should be OK most times // But sometimes, the connection may fail, so we retry here. match arboard::Clipboard::new() { Ok(x) => { @@ -282,7 +290,7 @@ impl ClipboardContext { // https://github.com/rustdesk/rustdesk/issues/9263 // https://github.com/rustdesk/rustdesk/issues/9222#issuecomment-2329233175 for i in 0..CLIPBOARD_GET_MAX_RETRY { - match self.inner.get_formats(SUPPORTED_FORMATS) { + match self.inner.get_formats(formats) { Ok(data) => { return Ok(data .into_iter() @@ -305,8 +313,26 @@ impl ClipboardContext { } pub fn get(&mut self, side: ClipboardSide, force: bool) -> ResultType> { + let data = self.get_formats_filter(SUPPORTED_FORMATS, side, force)?; + // We have a separate service named `file-clipboard` to handle file copy-paste. + // We need to read the file urls because file copy may set the other clipboard formats such as text. + #[cfg(feature = "unix-file-copy-paste")] + { + if data.iter().any(|c| matches!(c, ClipboardData::FileUrl(_))) { + return Ok(vec![]); + } + } + Ok(data) + } + + fn get_formats_filter( + &mut self, + formats: &[ClipboardFormat], + side: ClipboardSide, + force: bool, + ) -> ResultType> { let _lock = ARBOARD_MTX.lock().unwrap(); - let data = self.get_formats(SUPPORTED_FORMATS)?; + let data = self.get_formats(formats)?; if data.is_empty() { return Ok(data); } @@ -323,24 +349,124 @@ impl ClipboardContext { .into_iter() .filter(|c| match c { ClipboardData::Special((s, _)) => s != RUSTDESK_CLIPBOARD_OWNER_FORMAT, + // Skip synchronizing empty text to the remote clipboard + ClipboardData::Text(text) => !text.is_empty(), _ => true, }) .collect()) } + #[cfg(feature = "unix-file-copy-paste")] + pub fn get_files( + &mut self, + side: ClipboardSide, + force: bool, + ) -> ResultType>> { + let data = self.get_formats_filter( + &[ + ClipboardFormat::FileUrl, + ClipboardFormat::Special(RUSTDESK_CLIPBOARD_OWNER_FORMAT), + ], + side, + force, + )?; + Ok(data.into_iter().find_map(|c| match c { + ClipboardData::FileUrl(urls) => Some(urls), + _ => None, + })) + } + fn set(&mut self, data: &[ClipboardData]) -> ResultType<()> { let _lock = ARBOARD_MTX.lock().unwrap(); self.inner.set_formats(data)?; Ok(()) } + + #[cfg(all(feature = "unix-file-copy-paste", target_os = "macos"))] + fn get_file_urls_set_by_rustdesk( + data: Vec, + _side: ClipboardSide, + ) -> Vec { + for item in data.into_iter() { + if let ClipboardData::FileUrl(urls) = item { + if is_file_url_set_by_rustdesk(&urls) { + return urls; + } + } + } + vec![] + } + + #[cfg(all(feature = "unix-file-copy-paste", target_os = "linux"))] + fn get_file_urls_set_by_rustdesk(data: Vec, side: ClipboardSide) -> Vec { + let exclude_path = + clipboard::platform::unix::fuse::get_exclude_paths(side == ClipboardSide::Client); + data.into_iter() + .filter_map(|c| match c { + ClipboardData::FileUrl(urls) => Some( + urls.into_iter() + .filter(|s| s.starts_with(&*exclude_path)) + .collect::>(), + ), + _ => None, + }) + .flatten() + .collect::>() + } + + #[cfg(feature = "unix-file-copy-paste")] + fn try_empty_clipboard_files(&mut self, side: ClipboardSide) { + let _lock = ARBOARD_MTX.lock().unwrap(); + if let Ok(data) = self.get_formats(&[ClipboardFormat::FileUrl]) { + let urls = Self::get_file_urls_set_by_rustdesk(data, side); + if !urls.is_empty() { + // FIXME: + // The host-side clear file clipboard `let _ = self.inner.clear();`, + // does not work on KDE Plasma for the installed version. + + // Don't use `hbb_common::platform::linux::is_kde()` here. + // It's not correct in the server process. + #[cfg(target_os = "linux")] + let is_kde_x11 = hbb_common::platform::linux::is_kde_session() + && crate::platform::linux::is_x11(); + #[cfg(target_os = "macos")] + let is_kde_x11 = false; + let clear_holder_text = if is_kde_x11 { + "RustDesk placeholder to clear the file clipboard" + } else { + "" + } + .to_string(); + self.inner + .set_formats(&[ + ClipboardData::Text(clear_holder_text), + ClipboardData::Special(( + RUSTDESK_CLIPBOARD_OWNER_FORMAT.to_owned(), + side.get_owner_data(), + )), + ]) + .ok(); + } + } + } } pub fn is_support_multi_clipboard(peer_version: &str, peer_platform: &str) -> bool { use hbb_common::get_version_number; - get_version_number(peer_version) >= get_version_number("1.3.0") - && !["", "Android", &whoami::Platform::Ios.to_string()].contains(&peer_platform) + if get_version_number(peer_version) < get_version_number("1.3.0") { + return false; + } + if ["", &hbb_common::whoami::Platform::Ios.to_string()].contains(&peer_platform) { + return false; + } + if "Android" == peer_platform && get_version_number(peer_version) < get_version_number("1.3.3") + { + return false; + } + true } +#[cfg(not(target_os = "android"))] pub fn get_current_clipboard_msg( peer_version: &str, peer_platform: &str, @@ -406,37 +532,9 @@ impl std::fmt::Display for ClipboardSide { } } -pub fn start_clipbard_master_thread( - handler: impl ClipboardHandler + Send + 'static, - tx_start_res: Sender<(Option, String)>, -) -> JoinHandle<()> { - // https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getmessage#:~:text=The%20window%20must%20belong%20to%20the%20current%20thread. - let h = std::thread::spawn(move || match Master::new(handler) { - Ok(mut master) => { - tx_start_res - .send((Some(master.shutdown_channel()), "".to_owned())) - .ok(); - log::debug!("Clipboard listener started"); - if let Err(err) = master.run() { - log::error!("Failed to run clipboard listener: {}", err); - } else { - log::debug!("Clipboard listener stopped"); - } - } - Err(err) => { - tx_start_res - .send(( - None, - format!("Failed to create clipboard listener: {}", err), - )) - .ok(); - } - }); - h -} - pub use proto::get_msg_if_not_support_multi_clip; mod proto { + #[cfg(not(target_os = "android"))] use arboard::ClipboardData; use hbb_common::{ compress::{compress as compress_func, decompress}, @@ -459,6 +557,7 @@ mod proto { } } + #[cfg(not(target_os = "android"))] fn image_to_proto(a: arboard::ImageData) -> Clipboard { match &a { arboard::ImageData::Rgba(rgba) => { @@ -519,6 +618,7 @@ mod proto { } } + #[cfg(not(target_os = "android"))] fn clipboard_data_to_proto(data: ClipboardData) -> Option { let d = match data { ClipboardData::Text(s) => plain_to_proto(s, ClipboardFormat::Text), @@ -531,6 +631,7 @@ mod proto { Some(d) } + #[cfg(not(target_os = "android"))] pub fn create_multi_clipboards(vec_data: Vec) -> MultiClipboards { MultiClipboards { clipboards: vec_data @@ -541,6 +642,7 @@ mod proto { } } + #[cfg(not(target_os = "android"))] fn from_clipboard(clipboard: Clipboard) -> Option { let data = if clipboard.compress { decompress(&clipboard.content) @@ -569,7 +671,8 @@ mod proto { } } - pub fn from_multi_clipbards(multi_clipboards: Vec) -> Vec { + #[cfg(not(target_os = "android"))] + pub fn from_multi_clipboards(multi_clipboards: Vec) -> Vec { multi_clipboards .into_iter() .filter_map(from_clipboard) @@ -597,3 +700,186 @@ mod proto { }) } } + +#[cfg(target_os = "android")] +pub fn handle_msg_clipboard(mut cb: Clipboard) { + use hbb_common::protobuf::Message; + + if cb.compress { + cb.content = bytes::Bytes::from(hbb_common::compress::decompress(&cb.content)); + } + let multi_clips = MultiClipboards { + clipboards: vec![cb], + ..Default::default() + }; + if let Ok(bytes) = multi_clips.write_to_bytes() { + let _ = scrap::android::ffi::call_clipboard_manager_update_clipboard(&bytes); + } +} + +#[cfg(target_os = "android")] +pub fn handle_msg_multi_clipboards(mut mcb: MultiClipboards) { + use hbb_common::protobuf::Message; + + for cb in mcb.clipboards.iter_mut() { + if cb.compress { + cb.content = bytes::Bytes::from(hbb_common::compress::decompress(&cb.content)); + } + } + if let Ok(bytes) = mcb.write_to_bytes() { + let _ = scrap::android::ffi::call_clipboard_manager_update_clipboard(&bytes); + } +} + +#[cfg(target_os = "android")] +pub fn get_clipboards_msg(client: bool) -> Option { + let mut clipboards = scrap::android::ffi::get_clipboards(client)?; + let mut msg = Message::new(); + for c in &mut clipboards.clipboards { + let compressed = hbb_common::compress::compress(&c.content); + let compress = compressed.len() < c.content.len(); + if compress { + c.content = compressed.into(); + } + c.compress = compress; + } + msg.set_multi_clipboards(clipboards); + Some(msg) +} + +// We need this mod to notify multiple subscribers when the clipboard changes. +// Because only one clipboard master(listener) can trigger the clipboard change event multiple listeners are created on Linux(x11). +// https://github.com/rustdesk-org/clipboard-master/blob/4fb62e5b62fb6350d82b571ec7ba94b3cd466695/src/master/x11.rs#L226 +#[cfg(not(target_os = "android"))] +pub mod clipboard_listener { + use clipboard_master::{CallbackResult, ClipboardHandler, Master, Shutdown}; + use hbb_common::{bail, log, ResultType}; + use std::{ + collections::HashMap, + io, + sync::mpsc::{channel, Sender}, + sync::{Arc, Mutex}, + thread::JoinHandle, + }; + + lazy_static::lazy_static! { + pub static ref CLIPBOARD_LISTENER: Arc> = Default::default(); + } + + struct Handler { + subscribers: Arc>>>, + } + + impl ClipboardHandler for Handler { + fn on_clipboard_change(&mut self) -> CallbackResult { + let sub_lock = self.subscribers.lock().unwrap(); + for tx in sub_lock.values() { + tx.send(CallbackResult::Next).ok(); + } + CallbackResult::Next + } + + fn on_clipboard_error(&mut self, error: io::Error) -> CallbackResult { + let msg = format!("Clipboard listener error: {}", error); + let sub_lock = self.subscribers.lock().unwrap(); + for tx in sub_lock.values() { + tx.send(CallbackResult::StopWithError(io::Error::new( + io::ErrorKind::Other, + msg.clone(), + ))) + .ok(); + } + CallbackResult::Next + } + } + + #[derive(Default)] + pub struct ClipboardListener { + subscribers: Arc>>>, + handle: Option<(Shutdown, JoinHandle<()>)>, + } + + pub fn subscribe(name: String, tx: Sender) -> ResultType<()> { + log::info!("Subscribe clipboard listener: {}", &name); + let mut listener_lock = CLIPBOARD_LISTENER.lock().unwrap(); + listener_lock + .subscribers + .lock() + .unwrap() + .insert(name.clone(), tx); + + if listener_lock.handle.is_none() { + log::info!("Start clipboard listener thread"); + let handler = Handler { + subscribers: listener_lock.subscribers.clone(), + }; + let (tx_start_res, rx_start_res) = channel(); + let h = start_clipboard_master_thread(handler, tx_start_res); + let shutdown = match rx_start_res.recv() { + Ok((Some(s), _)) => s, + Ok((None, err)) => { + bail!(err); + } + + Err(e) => { + bail!("Failed to create clipboard listener: {}", e); + } + }; + listener_lock.handle = Some((shutdown, h)); + log::info!("Clipboard listener thread started"); + } + + log::info!("Clipboard listener subscribed: {}", name); + Ok(()) + } + + pub fn unsubscribe(name: &str) { + log::info!("Unsubscribe clipboard listener: {}", name); + let mut listener_lock = CLIPBOARD_LISTENER.lock().unwrap(); + let is_empty = { + let mut sub_lock = listener_lock.subscribers.lock().unwrap(); + if let Some(tx) = sub_lock.remove(name) { + tx.send(CallbackResult::Stop).ok(); + } + sub_lock.is_empty() + }; + if is_empty { + if let Some((shutdown, h)) = listener_lock.handle.take() { + log::info!("Stop clipboard listener thread"); + shutdown.signal(); + h.join().ok(); + log::info!("Clipboard listener thread stopped"); + } + } + log::info!("Clipboard listener unsubscribed: {}", name); + } + + fn start_clipboard_master_thread( + handler: impl ClipboardHandler + Send + 'static, + tx_start_res: Sender<(Option, String)>, + ) -> JoinHandle<()> { + // https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getmessage#:~:text=The%20window%20must%20belong%20to%20the%20current%20thread. + let h = std::thread::spawn(move || match Master::new(handler) { + Ok(mut master) => { + tx_start_res + .send((Some(master.shutdown_channel()), "".to_owned())) + .ok(); + log::debug!("Clipboard listener started"); + if let Err(err) = master.run() { + log::error!("Failed to run clipboard listener: {}", err); + } else { + log::debug!("Clipboard listener stopped"); + } + } + Err(err) => { + tx_start_res + .send(( + None, + format!("Failed to create clipboard listener: {}", err), + )) + .ok(); + } + }); + h + } +} diff --git a/src/clipboard_file.rs b/src/clipboard_file.rs index a4bfc1aef..724d8aea9 100644 --- a/src/clipboard_file.rs +++ b/src/clipboard_file.rs @@ -134,6 +134,49 @@ pub fn clip_2_msg(clip: ClipboardFile) -> Message { })), ..Default::default() }, + ClipboardFile::TryEmpty => Message { + union: Some(message::Union::Cliprdr(Cliprdr { + union: Some(cliprdr::Union::TryEmpty(CliprdrTryEmpty { + ..Default::default() + })), + ..Default::default() + })), + ..Default::default() + }, + ClipboardFile::Files { files } => { + let files = files + .iter() + .filter_map(|(f, s)| { + if *s == 0 { + if let Ok(meta) = std::fs::metadata(f) { + Some(CliprdrFile { + name: f.to_owned(), + size: meta.len(), + ..Default::default() + }) + } else { + None + } + } else { + Some(CliprdrFile { + name: f.to_owned(), + size: *s, + ..Default::default() + }) + } + }) + .collect::>(); + Message { + union: Some(message::Union::Cliprdr(Cliprdr { + union: Some(cliprdr::Union::Files(CliprdrFiles { + files, + ..Default::default() + })), + ..Default::default() + })), + ..Default::default() + } + } } } @@ -176,6 +219,209 @@ pub fn msg_2_clip(msg: Cliprdr) -> Option { requested_data: data.requested_data.into(), }) } + Some(cliprdr::Union::TryEmpty(_)) => Some(ClipboardFile::TryEmpty), _ => None, } } + +#[cfg(feature = "unix-file-copy-paste")] +pub mod unix_file_clip { + use super::*; + #[cfg(target_os = "linux")] + use crate::clipboard::update_clipboard_files; + use crate::clipboard::{try_empty_clipboard_files, ClipboardSide}; + #[cfg(target_os = "linux")] + use clipboard::platform::unix::fuse; + use clipboard::platform::unix::{ + get_local_format, serv_files, FILECONTENTS_FORMAT_ID, FILECONTENTS_FORMAT_NAME, + FILEDESCRIPTORW_FORMAT_NAME, FILEDESCRIPTOR_FORMAT_ID, + }; + use hbb_common::log; + use std::sync::{Arc, Mutex}; + + lazy_static::lazy_static! { + static ref CLIPBOARD_CTX: Arc>> = Arc::new(Mutex::new(None)); + } + + pub fn get_format_list() -> ClipboardFile { + let fd_format_name = get_local_format(FILEDESCRIPTOR_FORMAT_ID) + .unwrap_or(FILEDESCRIPTORW_FORMAT_NAME.to_string()); + let fc_format_name = get_local_format(FILECONTENTS_FORMAT_ID) + .unwrap_or(FILECONTENTS_FORMAT_NAME.to_string()); + ClipboardFile::FormatList { + format_list: vec![ + (FILEDESCRIPTOR_FORMAT_ID, fd_format_name), + (FILECONTENTS_FORMAT_ID, fc_format_name), + ], + } + } + + #[inline] + fn msg_resp_format_data_failure() -> Message { + clip_2_msg(ClipboardFile::FormatDataResponse { + msg_flags: 0x2, + format_data: vec![], + }) + } + + #[inline] + fn resp_file_contents_fail(stream_id: i32) -> Message { + clip_2_msg(ClipboardFile::FileContentsResponse { + msg_flags: 0x2, + stream_id, + requested_data: vec![], + }) + } + + pub fn serve_clip_messages( + side: ClipboardSide, + clip: ClipboardFile, + conn_id: i32, + ) -> Vec { + log::debug!("got clipfile from client peer"); + match clip { + ClipboardFile::MonitorReady => { + log::debug!("client is ready for clipboard"); + } + ClipboardFile::FormatList { format_list } => { + if !format_list + .iter() + .find(|(_, name)| name == FILECONTENTS_FORMAT_NAME) + .map(|(id, _)| *id) + .is_some() + { + log::error!("no file contents format found"); + return vec![]; + }; + let Some(file_descriptor_id) = format_list + .iter() + .find(|(_, name)| name == FILEDESCRIPTORW_FORMAT_NAME) + .map(|(id, _)| *id) + else { + log::error!("no file descriptor format found"); + return vec![]; + }; + // sync file system from peer + let data = ClipboardFile::FormatDataRequest { + requested_format_id: file_descriptor_id, + }; + return vec![clip_2_msg(data)]; + } + ClipboardFile::FormatListResponse { + msg_flags: _msg_flags, + } => {} + ClipboardFile::FormatDataRequest { + requested_format_id: _requested_format_id, + } => { + log::debug!("requested format id: {}", _requested_format_id); + let format_data = serv_files::get_file_list_pdu(); + if !format_data.is_empty() { + return vec![clip_2_msg(ClipboardFile::FormatDataResponse { + msg_flags: 1, + format_data, + })]; + } + // empty file list, send failure message + return vec![msg_resp_format_data_failure()]; + } + #[cfg(target_os = "linux")] + ClipboardFile::FormatDataResponse { + msg_flags, + format_data, + } => { + log::debug!("format data response: msg_flags: {}", msg_flags); + + if msg_flags != 0x1 { + // return failure message? + } + + log::debug!("parsing file descriptors"); + if fuse::init_fuse_context(true).is_ok() { + match fuse::format_data_response_to_urls( + side == ClipboardSide::Client, + format_data, + conn_id, + ) { + Ok(files) => { + update_clipboard_files(files, side); + } + Err(e) => { + log::error!("failed to parse file descriptors: {:?}", e); + } + } + } else { + // send error message to server + } + } + ClipboardFile::FileContentsRequest { + stream_id, + list_index, + dw_flags, + n_position_low, + n_position_high, + cb_requested, + .. + } => { + log::debug!("file contents request: stream_id: {}, list_index: {}, dw_flags: {}, n_position_low: {}, n_position_high: {}, cb_requested: {}", stream_id, list_index, dw_flags, n_position_low, n_position_high, cb_requested); + return serv_files::read_file_contents( + conn_id, + stream_id, + list_index, + dw_flags, + n_position_low, + n_position_high, + cb_requested, + ) + .into_iter() + .map(|res| match res { + Ok(data) => clip_2_msg(data), + Err(e) => { + log::error!("failed to read file contents: {:?}", e); + resp_file_contents_fail(stream_id) + } + }) + .collect::<_>(); + } + #[cfg(target_os = "linux")] + ClipboardFile::FileContentsResponse { + msg_flags, + stream_id, + .. + } => { + log::debug!( + "file contents response: msg_flags: {}, stream_id: {}", + msg_flags, + stream_id, + ); + if fuse::init_fuse_context(true).is_ok() { + hbb_common::allow_err!(fuse::handle_file_content_response( + side == ClipboardSide::Client, + clip + )); + } else { + // send error message to server + } + } + ClipboardFile::NotifyCallback { + r#type, + title, + text, + } => { + // unreachable, but still log it + log::debug!( + "notify callback: type: {}, title: {}, text: {}", + r#type, + title, + text + ); + } + ClipboardFile::TryEmpty => { + try_empty_clipboard_files(side, conn_id); + } + _ => { + log::error!("unsupported clipboard file type"); + } + } + vec![] + } +} diff --git a/src/common.rs b/src/common.rs index f53dd703f..69e3ec304 100644 --- a/src/common.rs +++ b/src/common.rs @@ -1,18 +1,24 @@ use std::{ collections::HashMap, future::Future, + net::{SocketAddr, ToSocketAddrs}, sync::{Arc, Mutex, RwLock}, task::Poll, }; -use serde_json::Value; +use serde_json::{json, Map, Value}; +#[cfg(not(target_os = "ios"))] +use hbb_common::whoami; use hbb_common::{ allow_err, anyhow::{anyhow, Context}, + async_recursion::async_recursion, bail, base64, bytes::Bytes, - config::{self, Config, CONNECT_TIMEOUT, READ_TIMEOUT, RENDEZVOUS_PORT}, + config::{ + self, keys, use_ws, Config, LocalConfig, CONNECT_TIMEOUT, READ_TIMEOUT, RENDEZVOUS_PORT, + }, futures::future::join_all, futures_util::future::poll_fn, get_version_number, log, @@ -21,18 +27,19 @@ use hbb_common::{ rendezvous_proto::*, socket_client, sodiumoxide::crypto::{box_, secretbox, sign}, - tcp::FramedStream, timeout, + tls::{get_cached_tls_accept_invalid_cert, get_cached_tls_type, upsert_tls_cache, TlsType}, tokio::{ self, + net::UdpSocket, time::{Duration, Instant, Interval}, }, - ResultType, + ResultType, Stream, }; use crate::{ - hbbs_http::create_http_client_async, - ui_interface::{get_option, set_option}, + hbbs_http::{create_http_client_async, get_url_for_tls}, + ui_interface::{get_api_server as ui_get_api_server, get_option, is_installed, set_option}, }; #[derive(Debug, Eq, PartialEq)] @@ -64,6 +71,19 @@ pub mod input { pub const MOUSE_TYPE_UP: i32 = 2; pub const MOUSE_TYPE_WHEEL: i32 = 3; pub const MOUSE_TYPE_TRACKPAD: i32 = 4; + /// Relative mouse movement type for gaming/3D applications. + /// This type sends delta (dx, dy) values instead of absolute coordinates. + /// NOTE: This is only supported by the Flutter client. The Sciter client (deprecated) + /// does not support relative mouse mode due to: + /// 1. Fixed send_mouse() function signature that doesn't allow type differentiation + /// 2. Lack of pointer lock API in Sciter/TIS + /// 3. No OS cursor control (hide/show/clip) FFI bindings in Sciter UI + pub const MOUSE_TYPE_MOVE_RELATIVE: i32 = 5; + + /// Mask to extract the mouse event type from the mask field. + /// The lower 3 bits contain the event type (MOUSE_TYPE_*), giving a valid range of 0-7. + /// Currently defined types use values 0-5; values 6 and 7 are reserved for future use. + pub const MOUSE_TYPE_MASK: i32 = 0x7; pub const MOUSE_BUTTON_LEFT: i32 = 0x01; pub const MOUSE_BUTTON_RIGHT: i32 = 0x02; @@ -76,6 +96,7 @@ lazy_static::lazy_static! { pub static ref SOFTWARE_UPDATE_URL: Arc> = Default::default(); pub static ref DEVICE_ID: Arc> = Default::default(); pub static ref DEVICE_NAME: Arc> = Default::default(); + static ref PUBLIC_IPV6_ADDR: Arc, Option)>> = Default::default(); } lazy_static::lazy_static! { @@ -89,7 +110,7 @@ lazy_static::lazy_static! { pub struct SimpleCallOnReturn { pub b: bool, - pub f: Box, + pub f: Box, } impl Drop for SimpleCallOnReturn { @@ -127,12 +148,78 @@ pub fn is_support_multi_ui_session_num(ver: i64) -> bool { ver >= hbb_common::get_version_number(MIN_VER_MULTI_UI_SESSION) } +#[inline] +#[cfg(feature = "unix-file-copy-paste")] +pub fn is_support_file_copy_paste(ver: &str) -> bool { + is_support_file_copy_paste_num(hbb_common::get_version_number(ver)) +} + +#[inline] +#[cfg(feature = "unix-file-copy-paste")] +pub fn is_support_file_copy_paste_num(ver: i64) -> bool { + ver >= hbb_common::get_version_number("1.3.8") +} + +pub fn is_support_remote_print(ver: &str) -> bool { + hbb_common::get_version_number(ver) >= hbb_common::get_version_number("1.3.9") +} + +pub fn is_support_file_paste_if_macos(ver: &str) -> bool { + hbb_common::get_version_number(ver) >= hbb_common::get_version_number("1.3.9") +} + +#[inline] +pub fn is_support_screenshot(ver: &str) -> bool { + is_support_multi_ui_session_num(hbb_common::get_version_number(ver)) +} + +#[inline] +pub fn is_support_screenshot_num(ver: i64) -> bool { + ver >= hbb_common::get_version_number("1.4.0") +} + +#[inline] +pub fn is_support_file_transfer_resume(ver: &str) -> bool { + is_support_file_transfer_resume_num(hbb_common::get_version_number(ver)) +} + +#[inline] +pub fn is_support_file_transfer_resume_num(ver: i64) -> bool { + ver >= hbb_common::get_version_number("1.4.2") +} + +/// Minimum server version required for relative mouse mode support. +/// This constant must mirror Flutter's `kMinVersionForRelativeMouseMode` in `consts.dart`. +const MIN_VERSION_RELATIVE_MOUSE_MODE: &str = "1.4.5"; + +#[inline] +pub fn is_support_relative_mouse_mode(ver: &str) -> bool { + is_support_relative_mouse_mode_num(hbb_common::get_version_number(ver)) +} + +#[inline] +pub fn is_support_relative_mouse_mode_num(ver: i64) -> bool { + ver >= hbb_common::get_version_number(MIN_VERSION_RELATIVE_MOUSE_MODE) +} + // is server process, with "--server" args #[inline] pub fn is_server() -> bool { *IS_SERVER } +#[inline] +pub fn need_fs_cm_send_files() -> bool { + #[cfg(windows)] + { + is_server() + } + #[cfg(not(windows))] + { + false + } +} + #[inline] pub fn is_main() -> bool { *IS_MAIN @@ -472,41 +559,74 @@ audio_rechannel!(audio_rechannel_8_5, 8, 5); audio_rechannel!(audio_rechannel_8_6, 8, 6); audio_rechannel!(audio_rechannel_8_7, 8, 7); +pub struct CheckTestNatType { + is_direct: bool, +} + +impl CheckTestNatType { + pub fn new() -> Self { + Self { + is_direct: Config::get_socks().is_none() && !config::use_ws(), + } + } +} + +impl Drop for CheckTestNatType { + fn drop(&mut self) { + let is_direct = Config::get_socks().is_none() && !config::use_ws(); + if self.is_direct != is_direct { + test_nat_type(); + } + } +} + pub fn test_nat_type() { - let mut i = 0; - std::thread::spawn(move || loop { - match test_nat_type_() { - Ok(true) => break, - Err(err) => { - log::error!("test nat: {}", err); + test_ipv6_sync(); + use std::sync::atomic::{AtomicBool, Ordering}; + std::thread::spawn(move || { + static IS_RUNNING: AtomicBool = AtomicBool::new(false); + if IS_RUNNING.load(Ordering::SeqCst) { + return; + } + IS_RUNNING.store(true, Ordering::SeqCst); + + #[cfg(not(any(target_os = "android", target_os = "ios")))] + crate::ipc::get_socks_ws(); + let is_direct = Config::get_socks().is_none() && !config::use_ws(); + if !is_direct { + Config::set_nat_type(NatType::SYMMETRIC as _); + IS_RUNNING.store(false, Ordering::SeqCst); + return; + } + + let mut i = 0; + loop { + match test_nat_type_() { + Ok(true) => break, + Err(err) => { + log::error!("test nat: {}", err); + } + _ => {} } - _ => {} + if Config::get_nat_type() != 0 { + break; + } + i = i * 2 + 1; + if i > 300 { + i = 300; + } + std::thread::sleep(std::time::Duration::from_secs(i)); } - if Config::get_nat_type() != 0 { - break; - } - i = i * 2 + 1; - if i > 300 { - i = 300; - } - std::thread::sleep(std::time::Duration::from_secs(i)); + + IS_RUNNING.store(false, Ordering::SeqCst); }); } #[tokio::main(flavor = "current_thread")] async fn test_nat_type_() -> ResultType { log::info!("Testing nat ..."); - #[cfg(not(any(target_os = "android", target_os = "ios")))] - let is_direct = crate::ipc::get_socks_async(1_000).await.is_none(); // sync socks BTW - #[cfg(any(target_os = "android", target_os = "ios"))] - let is_direct = Config::get_socks().is_none(); // sync socks BTW - if !is_direct { - Config::set_nat_type(NatType::SYMMETRIC as _); - return Ok(true); - } let start = std::time::Instant::now(); - let (rendezvous_server, _, _) = get_rendezvous_server(1_000).await; - let server1 = rendezvous_server; + let server1 = Config::get_rendezvous_server(); let server2 = crate::increase_port(&server1, -1); let mut msg_out = RendezvousMessage::new(); let serial = Config::get_serial(); @@ -707,12 +827,22 @@ pub fn username() -> String { return DEVICE_NAME.lock().unwrap().clone(); } +// Exactly the implementation of "whoami::hostname()". +// This wrapper is to suppress warnings. +#[inline(always)] +#[cfg(not(target_os = "ios"))] +pub fn whoami_hostname() -> String { + let mut hostname = whoami::fallible::hostname().unwrap_or_else(|_| "localhost".to_string()); + hostname.make_ascii_lowercase(); + hostname +} + #[inline] pub fn hostname() -> String { #[cfg(not(any(target_os = "android", target_os = "ios")))] { #[allow(unused_mut)] - let mut name = whoami::hostname(); + let mut name = whoami_hostname(); // some time, there is .local, some time not, so remove it for osx #[cfg(target_os = "macos")] if name.ends_with(".local") { @@ -751,7 +881,6 @@ pub fn get_sysinfo() -> serde_json::Value { os = format!("{os} - {}", system.os_version().unwrap_or_default()); } let hostname = hostname(); // sys.hostname() return localhost on android in my test - use serde_json::json; #[cfg(any(target_os = "android", target_os = "ios"))] let out; #[cfg(not(any(target_os = "android", target_os = "ios")))] @@ -811,21 +940,48 @@ pub fn is_modifier(evt: &KeyEvent) -> bool { } pub fn check_software_update() { - std::thread::spawn(move || allow_err!(check_software_update_())); + if is_custom_client() { + return; + } + let opt = LocalConfig::get_option(keys::OPTION_ENABLE_CHECK_UPDATE); + if config::option2bool(keys::OPTION_ENABLE_CHECK_UPDATE, &opt) { + std::thread::spawn(move || allow_err!(do_check_software_update())); + } } +// No need to check `danger_accept_invalid_cert` for now. +// Because the url is always `https://api.rustdesk.com/version/latest`. #[tokio::main(flavor = "current_thread")] -async fn check_software_update_() -> hbb_common::ResultType<()> { - let url = "https://github.com/rustdesk/rustdesk/releases/latest"; - let latest_release_response = create_http_client_async().get(url).send().await?; - let latest_release_version = latest_release_response - .url() - .path() - .rsplit('/') - .next() - .unwrap_or_default(); - - let response_url = latest_release_response.url().to_string(); +pub async fn do_check_software_update() -> hbb_common::ResultType<()> { + let (request, url) = + hbb_common::version_check_request(hbb_common::VER_TYPE_RUSTDESK_CLIENT.to_string()); + let proxy_conf = Config::get_socks(); + let tls_url = get_url_for_tls(&url, &proxy_conf); + let tls_type = get_cached_tls_type(tls_url); + let is_tls_not_cached = tls_type.is_none(); + let tls_type = tls_type.unwrap_or(TlsType::Rustls); + let client = create_http_client_async(tls_type, false); + let latest_release_response = match client.post(&url).json(&request).send().await { + Ok(resp) => { + upsert_tls_cache(tls_url, tls_type, false); + resp + } + Err(err) => { + if is_tls_not_cached && err.is_request() { + let tls_type = TlsType::NativeTls; + let client = create_http_client_async(tls_type, false); + let resp = client.post(&url).json(&request).send().await?; + upsert_tls_cache(tls_url, tls_type, false); + resp + } else { + return Err(err.into()); + } + } + }; + let bytes = latest_release_response.bytes().await?; + let resp: hbb_common::VersionCheckResponse = serde_json::from_slice(&bytes)?; + let response_url = resp.url; + let latest_release_version = response_url.rsplit('/').next().unwrap_or_default(); if get_version_number(&latest_release_version) > get_version_number(crate::VERSION) { #[cfg(feature = "flutter")] @@ -837,7 +993,9 @@ async fn check_software_update_() -> hbb_common::ResultType<()> { let _ = crate::flutter::push_global_event(crate::flutter::APP_TYPE_MAIN, data); } } - *SOFTWARE_UPDATE_URL.lock().unwrap() = response_url; + *SOFTWARE_UPDATE_URL.lock().unwrap() = response_url; + } else { + *SOFTWARE_UPDATE_URL.lock().unwrap() = "".to_string(); } Ok(()) } @@ -886,7 +1044,25 @@ pub fn get_custom_rendezvous_server(custom: String) -> String { "".to_owned() } +#[inline] pub fn get_api_server(api: String, custom: String) -> String { + if Config::no_register_device() { + return "".to_owned(); + } + let mut res = get_api_server_(api, custom); + if res.ends_with('/') { + res.pop(); + } + if res.starts_with("https") + && res.ends_with(":21114") + && get_builtin_option(keys::OPTION_ALLOW_HTTPS_21114) != "Y" + { + return res.replace(":21114", ""); + } + res +} + +fn get_api_server_(api: String, custom: String) -> String { #[cfg(windows)] if let Ok(lic) = crate::platform::windows::get_license_from_exe_name() { if !lic.api.is_empty() { @@ -896,10 +1072,6 @@ pub fn get_api_server(api: String, custom: String) -> String { if !api.is_empty() { return api.to_owned(); } - let api = option_env!("API_SERVER").unwrap_or_default(); - if !api.is_empty() { - return api.into(); - } let s0 = get_custom_rendezvous_server(custom); if !s0.is_empty() { let s = crate::increase_port(&s0, -2); @@ -912,16 +1084,343 @@ pub fn get_api_server(api: String, custom: String) -> String { "https://admin.rustdesk.com".to_owned() } +#[inline] +pub fn is_public(url: &str) -> bool { + let url = url.to_ascii_lowercase(); + url.contains("rustdesk.com/") || url.ends_with("rustdesk.com") +} + +pub fn get_udp_punch_enabled() -> bool { + config::option2bool( + keys::OPTION_ENABLE_UDP_PUNCH, + &get_local_option(keys::OPTION_ENABLE_UDP_PUNCH), + ) +} + +pub fn get_ipv6_punch_enabled() -> bool { + config::option2bool( + keys::OPTION_ENABLE_IPV6_PUNCH, + &get_local_option(keys::OPTION_ENABLE_IPV6_PUNCH), + ) +} + +pub fn get_local_option(key: &str) -> String { + let v = LocalConfig::get_option(key); + if key == keys::OPTION_ENABLE_UDP_PUNCH || key == keys::OPTION_ENABLE_IPV6_PUNCH { + if v.is_empty() { + if !is_public(&Config::get_rendezvous_server()) { + return "N".to_owned(); + } + } + } + v +} + pub fn get_audit_server(api: String, custom: String, typ: String) -> String { let url = get_api_server(api, custom); - if url.is_empty() || url.contains("rustdesk.com") { + if url.is_empty() || is_public(&url) { return "".to_owned(); } format!("{}/api/audit/{}", url, typ) } +/// Check if we should use raw TCP proxy for API calls. +/// Returns true if USE_RAW_TCP_FOR_API builtin option is "Y", WebSocket is off, +/// and the target URL belongs to the configured non-public API host. +#[inline] +fn should_use_raw_tcp_for_api(url: &str) -> bool { + get_builtin_option(keys::OPTION_USE_RAW_TCP_FOR_API) == "Y" + && !use_ws() + && is_tcp_proxy_api_target(url) +} + +/// Check if we can attempt raw TCP proxy fallback for this target URL. +#[inline] +fn can_fallback_to_raw_tcp(url: &str) -> bool { + !use_ws() && is_tcp_proxy_api_target(url) +} + +#[inline] +fn should_use_tcp_proxy_for_api_url(url: &str, api_url: &str) -> bool { + if api_url.is_empty() || is_public(api_url) { + return false; + } + + let target_host = url::Url::parse(url) + .ok() + .and_then(|parsed| parsed.host_str().map(|host| host.to_ascii_lowercase())); + let api_host = url::Url::parse(api_url) + .ok() + .and_then(|parsed| parsed.host_str().map(|host| host.to_ascii_lowercase())); + + matches!((target_host, api_host), (Some(target), Some(api)) if target == api) +} + +#[inline] +fn is_tcp_proxy_api_target(url: &str) -> bool { + should_use_tcp_proxy_for_api_url(url, &ui_get_api_server()) +} + +fn tcp_proxy_log_target(url: &str) -> String { + url::Url::parse(url) + .ok() + .map(|parsed| { + let mut redacted = format!("{}://", parsed.scheme()); + let Some(host) = parsed.host() else { + return "".to_owned(); + }; + redacted.push_str(&host.to_string()); + if let Some(port) = parsed.port() { + redacted.push(':'); + redacted.push_str(&port.to_string()); + } + redacted.push_str(parsed.path()); + redacted + }) + .unwrap_or_else(|| "".to_owned()) +} + +#[inline] +fn get_tcp_proxy_addr() -> String { + check_port(Config::get_rendezvous_server(), RENDEZVOUS_PORT) +} + +/// Send an HTTP request via the rendezvous server's TCP proxy using protobuf. +/// Connects with `connect_tcp` + `secure_tcp`, sends `HttpProxyRequest`, +/// receives `HttpProxyResponse`. +/// +/// The entire operation (connect + handshake + send + receive) is wrapped in +/// an overall timeout of `CONNECT_TIMEOUT + READ_TIMEOUT` so that a stall at +/// any stage cannot block the caller indefinitely. +async fn tcp_proxy_request( + method: &str, + url: &str, + body: &[u8], + headers: Vec, +) -> ResultType { + let tcp_addr = get_tcp_proxy_addr(); + if tcp_addr.is_empty() { + bail!("No rendezvous server configured for TCP proxy"); + } + + let parsed = url::Url::parse(url)?; + let path = if let Some(query) = parsed.query() { + format!("{}?{}", parsed.path(), query) + } else { + parsed.path().to_string() + }; + + log::debug!( + "Sending {} {} via TCP proxy to {}", + method, + parsed.path(), + tcp_addr + ); + + let overall_timeout = CONNECT_TIMEOUT + READ_TIMEOUT; + timeout(overall_timeout, async { + let mut conn = socket_client::connect_tcp(&*tcp_addr, CONNECT_TIMEOUT).await?; + let key = crate::get_key(true).await; + secure_tcp_silent(&mut conn, &key).await?; + + let mut req = HttpProxyRequest::new(); + req.method = method.to_uppercase(); + req.path = path; + req.headers = headers.into(); + req.body = Bytes::from(body.to_vec()); + + let mut msg_out = RendezvousMessage::new(); + msg_out.set_http_proxy_request(req); + conn.send(&msg_out).await?; + + match conn.next().await { + Some(Ok(bytes)) => { + let msg_in = RendezvousMessage::parse_from_bytes(&bytes)?; + match msg_in.union { + Some(rendezvous_message::Union::HttpProxyResponse(resp)) => Ok(resp), + _ => bail!("Unexpected response from TCP proxy"), + } + } + Some(Err(e)) => bail!("TCP proxy read error: {}", e), + None => bail!("TCP proxy connection closed without response"), + } + }) + .await? +} + +/// Build HeaderEntry list from "Key: Value" style header string (used by post_request). +/// If the caller supplies a Content-Type header it overrides the default `application/json`. +fn parse_simple_header(header: &str) -> Vec { + let mut entries = Vec::new(); + let mut has_content_type = false; + if !header.is_empty() { + let tmp: Vec<&str> = header.splitn(2, ": ").collect(); + if tmp.len() == 2 { + if tmp[0].eq_ignore_ascii_case("Content-Type") { + has_content_type = true; + } + entries.push(HeaderEntry { + name: tmp[0].into(), + value: tmp[1].into(), + ..Default::default() + }); + } + } + if !has_content_type { + entries.insert( + 0, + HeaderEntry { + name: "Content-Type".into(), + value: "application/json".into(), + ..Default::default() + }, + ); + } + entries +} + +/// POST request via TCP proxy. +async fn post_request_via_tcp_proxy(url: &str, body: &str, header: &str) -> ResultType { + 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(String::from_utf8_lossy(&resp.body).to_string()) +} + +fn http_proxy_response_to_json(resp: HttpProxyResponse) -> ResultType { + if !resp.error.is_empty() { + bail!("TCP proxy error: {}", resp.error); + } + + let mut response_headers = Map::new(); + for entry in resp.headers.iter() { + response_headers.insert(entry.name.to_lowercase(), json!(entry.value)); + } + + let mut result = Map::new(); + result.insert("status_code".to_string(), json!(resp.status)); + result.insert("headers".to_string(), Value::Object(response_headers)); + result.insert( + "body".to_string(), + json!(String::from_utf8_lossy(&resp.body)), + ); + + serde_json::to_string(&result).map_err(|e| anyhow!("Failed to serialize response: {}", e)) +} + +fn parse_json_header_entries(header: &str) -> ResultType> { + let v: Value = serde_json::from_str(header)?; + if let Value::Object(obj) = v { + Ok(obj + .iter() + .map(|(key, value)| HeaderEntry { + name: key.clone(), + value: value.as_str().unwrap_or_default().into(), + ..Default::default() + }) + .collect()) + } else { + Err(anyhow!("HTTP header information parsing failed!")) + } +} + +/// Returns (status_code, body_text). Separating status so the wrapper can decide on fallback. +async fn post_request_http(url: &str, body: &str, header: &str) -> ResultType<(u16, String)> { + let proxy_conf = Config::get_socks(); + let tls_url = get_url_for_tls(url, &proxy_conf); + let tls_type = get_cached_tls_type(tls_url); + let danger_accept_invalid_cert = get_cached_tls_accept_invalid_cert(tls_url); + let response = post_request_( + url, + tls_url, + body.to_owned(), + header, + tls_type, + danger_accept_invalid_cert, + danger_accept_invalid_cert, + ) + .await?; + let status = response.status().as_u16(); + let text = response.text().await?; + Ok((status, text)) +} + +/// Try `http_fn` first; on connection failure or 5xx, fall back to `tcp_fn` +/// if the URL is eligible. 4xx responses are returned as-is. +async fn with_tcp_proxy_fallback( + url: &str, + method: &str, + http_fn: HttpFut, + tcp_fn: TcpFut, +) -> ResultType +where + HttpFut: Future>, + TcpFut: Future>, +{ + if should_use_raw_tcp_for_api(url) { + return tcp_fn.await; + } + + let http_result = http_fn.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 {} to {} failed or 5xx (result: {:?}), trying TCP proxy fallback", + method, + tcp_proxy_log_target(url), + http_result + .as_ref() + .map(|(s, _)| *s) + .map_err(|e| e.to_string()), + ); + match tcp_fn.await { + Ok(resp) => return Ok(resp), + Err(tcp_err) => { + log::warn!("TCP proxy fallback also failed: {:?}", tcp_err); + } + } + } + + http_result.map(|(_status, text)| text) +} + +/// POST request with raw TCP proxy support. +/// - If `USE_RAW_TCP_FOR_API` is "Y" and WS is off, goes directly through TCP proxy. +/// - Otherwise tries HTTP first; on connection failure or 5xx status, +/// falls back to TCP proxy if WS is off. +/// - 4xx responses are returned as-is (server is reachable, business logic error). +/// - If fallback also fails, returns the original HTTP result (text or error). pub async fn post_request(url: String, body: String, header: &str) -> ResultType { - let mut req = create_http_client_async().post(url); + with_tcp_proxy_fallback( + &url, + "POST", + post_request_http(&url, &body, header), + post_request_via_tcp_proxy(&url, &body, header), + ) + .await +} + +#[async_recursion] +async fn post_request_( + url: &str, + tls_url: &str, + body: String, + header: &str, + tls_type: Option, + danger_accept_invalid_cert: Option, + original_danger_accept_invalid_cert: Option, +) -> ResultType { + let mut req = create_http_client_async( + tls_type.unwrap_or(TlsType::Rustls), + danger_accept_invalid_cert.unwrap_or(false), + ) + .post(url); if !header.is_empty() { let tmp: Vec<&str> = header.split(": ").collect(); if tmp.len() == 2 { @@ -930,7 +1429,66 @@ pub async fn post_request(url: String, body: String, header: &str) -> ResultType } req = req.header("Content-Type", "application/json"); let to = std::time::Duration::from_secs(12); - Ok(req.body(body).timeout(to).send().await?.text().await?) + if tls_type.is_some() && danger_accept_invalid_cert.is_some() { + // This branch is used to reduce a `clone()` when both `tls_type` and + // `danger_accept_invalid_cert` are cached. + match req.body(body.clone()).timeout(to).send().await { + Ok(resp) => { + upsert_tls_cache( + tls_url, + tls_type.unwrap_or(TlsType::Rustls), + danger_accept_invalid_cert.unwrap_or(false), + ); + Ok(resp) + } + Err(e) => Err(anyhow!("{:?}", e)), + } + } else { + match req.body(body.clone()).timeout(to).send().await { + Ok(resp) => { + upsert_tls_cache( + tls_url, + tls_type.unwrap_or(TlsType::Rustls), + danger_accept_invalid_cert.unwrap_or(false), + ); + Ok(resp) + } + Err(e) => { + if (tls_type.is_none() || danger_accept_invalid_cert.is_none()) && e.is_request() { + if danger_accept_invalid_cert.is_none() { + log::warn!( + "HTTP request failed: {:?}, try again, danger accept invalid cert", + e + ); + post_request_( + url, + tls_url, + body, + header, + tls_type, + Some(true), + original_danger_accept_invalid_cert, + ) + .await + } else { + log::warn!("HTTP request failed: {:?}, try again with native-tls", e); + post_request_( + url, + tls_url, + body, + header, + Some(TlsType::NativeTls), + original_danger_accept_invalid_cert, + original_danger_accept_invalid_cert, + ) + .await + } + } else { + Err(anyhow!("{:?}", e)) + } + } + } + } } #[tokio::main(flavor = "current_thread")] @@ -938,6 +1496,155 @@ pub async fn post_request_sync(url: String, body: String, header: &str) -> Resul post_request(url, body, header).await } +#[async_recursion] +async fn get_http_response_async( + url: &str, + tls_url: &str, + method: &str, + body: Option, + header: &str, + tls_type: Option, + danger_accept_invalid_cert: Option, + original_danger_accept_invalid_cert: Option, +) -> ResultType { + let http_client = create_http_client_async( + tls_type.unwrap_or(TlsType::Rustls), + danger_accept_invalid_cert.unwrap_or(false), + ); + let normalized_method = method.to_ascii_lowercase(); + let mut http_client = match normalized_method.as_str() { + "get" => http_client.get(url), + "post" => http_client.post(url), + "put" => http_client.put(url), + "delete" => http_client.delete(url), + _ => return Err(anyhow!("The HTTP request method is not supported!")), + }; + for entry in parse_json_header_entries(header)? { + http_client = http_client.header(entry.name, entry.value); + } + + if tls_type.is_some() && danger_accept_invalid_cert.is_some() { + if let Some(b) = body { + http_client = http_client.body(b); + } + match http_client + .timeout(std::time::Duration::from_secs(12)) + .send() + .await + { + Ok(resp) => { + upsert_tls_cache( + tls_url, + tls_type.unwrap_or(TlsType::Rustls), + danger_accept_invalid_cert.unwrap_or(false), + ); + Ok(resp) + } + Err(e) => Err(anyhow!("{:?}", e)), + } + } else { + if let Some(b) = body.clone() { + http_client = http_client.body(b); + } + + match http_client + .timeout(std::time::Duration::from_secs(12)) + .send() + .await + { + Ok(resp) => { + upsert_tls_cache( + tls_url, + tls_type.unwrap_or(TlsType::Rustls), + danger_accept_invalid_cert.unwrap_or(false), + ); + Ok(resp) + } + Err(e) => { + if (tls_type.is_none() || danger_accept_invalid_cert.is_none()) && e.is_request() { + if danger_accept_invalid_cert.is_none() { + log::warn!( + "HTTP request failed: {:?}, try again, danger accept invalid cert", + e + ); + get_http_response_async( + url, + tls_url, + method, + body, + header, + tls_type, + Some(true), + original_danger_accept_invalid_cert, + ) + .await + } else { + log::warn!("HTTP request failed: {:?}, try again with native-tls", e); + get_http_response_async( + url, + tls_url, + method, + body, + header, + Some(TlsType::NativeTls), + original_danger_accept_invalid_cert, + original_danger_accept_invalid_cert, + ) + .await + } + } else { + Err(anyhow!("{:?}", e)) + } + } + } + } +} + +/// Returns (status_code, json_string) so the caller can inspect the status +/// without re-parsing the serialized JSON. +async fn http_request_http( + url: &str, + method: &str, + body: Option, + header: &str, +) -> ResultType<(u16, String)> { + let proxy_conf = Config::get_socks(); + let tls_url = get_url_for_tls(url, &proxy_conf); + let tls_type = get_cached_tls_type(tls_url); + let danger_accept_invalid_cert = get_cached_tls_accept_invalid_cert(tls_url); + let response = get_http_response_async( + url, + tls_url, + method, + body, + header, + tls_type, + danger_accept_invalid_cert, + danger_accept_invalid_cert, + ) + .await?; + // Serialize response headers + let mut response_headers = Map::new(); + for (key, value) in response.headers() { + response_headers.insert(key.to_string(), json!(value.to_str().unwrap_or(""))); + } + + let status_code = response.status().as_u16(); + let response_body = response.text().await?; + + // Construct the JSON object + let mut result = Map::new(); + result.insert("status_code".to_string(), json!(status_code)); + result.insert("headers".to_string(), Value::Object(response_headers)); + result.insert("body".to_string(), json!(response_body)); + + // Convert map to JSON string + let json_str = serde_json::to_string(&result) + .map_err(|e| anyhow!("Failed to serialize response: {}", e))?; + Ok((status_code, json_str)) +} + +/// HTTP request with raw TCP proxy support. #[tokio::main(flavor = "current_thread")] pub async fn http_request_sync( url: String, @@ -945,56 +1652,28 @@ pub async fn http_request_sync( body: Option, header: String, ) -> ResultType { - let http_client = create_http_client_async(); - let mut http_client = match method.as_str() { - "get" => http_client.get(url), - "post" => http_client.post(url), - "put" => http_client.put(url), - "delete" => http_client.delete(url), - _ => return Err(anyhow!("The HTTP request method is not supported!")), - }; - let v = serde_json::from_str(header.as_str())?; + with_tcp_proxy_fallback( + &url, + &method, + http_request_http(&url, &method, body.clone(), &header), + http_request_via_tcp_proxy(&url, &method, body.as_deref(), &header), + ) + .await +} - if let Value::Object(obj) = v { - for (key, value) in obj.iter() { - http_client = http_client.header(key, value.as_str().unwrap_or_default()); - } - } else { - return Err(anyhow!("HTTP header information parsing failed!")); - } +/// General HTTP request via TCP proxy. Header is a JSON string (used by http_request_sync). +/// Returns a JSON string with status_code, headers, body (same format as http_request_sync). +async fn http_request_via_tcp_proxy( + url: &str, + method: &str, + body: Option<&str>, + header: &str, +) -> ResultType { + let headers = parse_json_header_entries(header)?; + let body_bytes = body.unwrap_or("").as_bytes(); - if let Some(b) = body { - http_client = http_client.body(b); - } - - let response = http_client - .timeout(std::time::Duration::from_secs(12)) - .send() - .await?; - - // Serialize response headers - let mut response_headers = serde_json::map::Map::new(); - for (key, value) in response.headers() { - response_headers.insert( - key.to_string(), - serde_json::json!(value.to_str().unwrap_or("")), - ); - } - - let status_code = response.status().as_u16(); - let response_body = response.text().await?; - - // Construct the JSON object - let mut result = serde_json::map::Map::new(); - result.insert("status_code".to_string(), serde_json::json!(status_code)); - result.insert( - "headers".to_string(), - serde_json::Value::Object(response_headers), - ); - result.insert("body".to_string(), serde_json::json!(response_body)); - - // Convert map to JSON string - serde_json::to_string(&result).map_err(|e| anyhow!("Failed to serialize response: {}", e)) + let resp = tcp_proxy_request(method, url, body_bytes, headers).await?; + http_proxy_response_to_json(resp) } #[inline] @@ -1051,7 +1730,11 @@ pub fn get_supported_keyboard_modes(version: i64, peer_platform: &str) -> Vec) -> String { - use serde_json::json; + let fd_json = _make_fd_to_json(id, path, entries); + serde_json::to_string(&fd_json).unwrap_or("".into()) +} + +pub fn _make_fd_to_json(id: i32, path: String, entries: &Vec) -> Map { let mut fd_json = serde_json::Map::new(); fd_json.insert("id".into(), json!(id)); fd_json.insert("path".into(), json!(path)); @@ -1066,7 +1749,33 @@ pub fn make_fd_to_json(id: i32, path: String, entries: &Vec) -> Strin entries_out.push(entry_map); } fd_json.insert("entries".into(), json!(entries_out)); - serde_json::to_string(&fd_json).unwrap_or("".into()) + fd_json +} + +pub fn make_vec_fd_to_json(fds: &[FileDirectory]) -> String { + let mut fd_jsons = vec![]; + + for fd in fds.iter() { + let fd_json = _make_fd_to_json(fd.id, fd.path.clone(), &fd.entries); + fd_jsons.push(fd_json); + } + + serde_json::to_string(&fd_jsons).unwrap_or("".into()) +} + +pub fn make_empty_dirs_response_to_json(res: &ReadEmptyDirsResponse) -> String { + let mut map: Map = serde_json::Map::new(); + map.insert("path".into(), json!(res.path)); + + let mut fd_jsons = vec![]; + + for fd in res.empty_dirs.iter() { + let fd_json = _make_fd_to_json(fd.id, fd.path.clone(), &fd.entries); + fd_jsons.push(fd_json); + } + map.insert("empty_dirs".into(), fd_jsons.into()); + + serde_json::to_string(&map).unwrap_or("".into()) } /// The function to handle the url scheme sent by the system. @@ -1131,7 +1840,7 @@ pub fn pk_to_fingerprint(pk: Vec) -> String { #[inline] pub async fn get_next_nonkeyexchange_msg( - conn: &mut FramedStream, + conn: &mut Stream, timeout: Option, ) -> Option { let timeout = timeout.unwrap_or(READ_TIMEOUT); @@ -1153,7 +1862,34 @@ pub async fn get_next_nonkeyexchange_msg( None } +#[cfg(all(target_os = "windows", not(target_pointer_width = "64")))] +pub fn check_process(arg: &str, same_session_id: bool) -> bool { + let mut path = std::env::current_exe().unwrap_or_default(); + if let Ok(linked) = path.read_link() { + path = linked; + } + let Some(filename) = path.file_name() else { + return false; + }; + let filename = filename.to_string_lossy().to_string(); + match crate::platform::windows::get_pids_with_first_arg_check_session( + &filename, + arg, + same_session_id, + ) { + Ok(pids) => { + let self_pid = hbb_common::sysinfo::Pid::from_u32(std::process::id()); + pids.into_iter().filter(|pid| *pid != self_pid).count() > 0 + } + Err(e) => { + log::error!("Failed to check process with arg: \"{}\", {}", arg, e); + false + } + } +} + #[allow(unused_mut)] +#[cfg(not(all(target_os = "windows", not(target_pointer_width = "64"))))] #[cfg(not(any(target_os = "android", target_os = "ios")))] pub fn check_process(arg: &str, mut same_uid: bool) -> bool { #[cfg(target_os = "macos")] @@ -1200,7 +1936,14 @@ pub fn check_process(arg: &str, mut same_uid: bool) -> bool { false } -pub async fn secure_tcp(conn: &mut FramedStream, key: &str) -> ResultType<()> { +async fn secure_tcp_impl(conn: &mut Stream, key: &str, log_on_success: bool) -> ResultType<()> { + // Skip additional encryption when using WebSocket connections (wss://) + // as WebSocket Secure (wss://) already provides transport layer encryption. + // This doesn't affect the end-to-end encryption between clients, + // it only avoids redundant encryption between client and server. + if use_ws() { + return Ok(()); + } let rs_pk = get_rs_pk(key); let Some(rs_pk) = rs_pk else { bail!("Handshake failed: invalid public key from rendezvous server"); @@ -1226,7 +1969,9 @@ pub async fn secure_tcp(conn: &mut FramedStream, key: &str) -> ResultType<()> { }); timeout(CONNECT_TIMEOUT, conn.send(&msg_out)).await??; conn.set_key(key); - log::info!("Connection secured"); + if log_on_success { + log::info!("Connection secured"); + } } _ => {} } @@ -1237,6 +1982,14 @@ pub async fn secure_tcp(conn: &mut FramedStream, key: &str) -> ResultType<()> { Ok(()) } +pub async fn secure_tcp(conn: &mut Stream, key: &str) -> ResultType<()> { + secure_tcp_impl(conn, key, true).await +} + +async fn secure_tcp_silent(conn: &mut Stream, key: &str) -> ResultType<()> { + secure_tcp_impl(conn, key, false).await +} + #[inline] fn get_pk(pk: &[u8]) -> Option<[u8; 32]> { if pk.len() == 32 { @@ -1279,8 +2032,7 @@ pub fn create_symmetric_key_msg(their_pk_b: [u8; 32]) -> (Bytes, Bytes, secretbo #[inline] pub fn using_public_server() -> bool { - option_env!("RENDEZVOUS_SERVER").unwrap_or("").is_empty() - && crate::get_custom_rendezvous_server(get_option("custom-rendezvous-server")).is_empty() + crate::get_custom_rendezvous_server(get_option("custom-rendezvous-server")).is_empty() } pub struct ThrottledInterval { @@ -1455,19 +2207,19 @@ pub fn read_custom_client(config: &str) { } let mut map_display_settings = HashMap::new(); - for s in config::keys::KEYS_DISPLAY_SETTINGS { + for s in keys::KEYS_DISPLAY_SETTINGS { map_display_settings.insert(s.replace("_", "-"), s); } let mut map_local_settings = HashMap::new(); - for s in config::keys::KEYS_LOCAL_SETTINGS { + for s in keys::KEYS_LOCAL_SETTINGS { map_local_settings.insert(s.replace("_", "-"), s); } let mut map_settings = HashMap::new(); - for s in config::keys::KEYS_SETTINGS { + for s in keys::KEYS_SETTINGS { map_settings.insert(s.replace("_", "-"), s); } let mut buildin_settings = HashMap::new(); - for s in config::keys::KEYS_BUILDIN_SETTINGS { + for s in keys::KEYS_BUILDIN_SETTINGS { buildin_settings.insert(s.replace("_", "-"), s); } if let Some(default_settings) = data.remove("default-settings") { @@ -1510,7 +2262,7 @@ pub fn is_empty_uni_link(arg: &str) -> bool { } pub fn get_hwid() -> Bytes { - use sha2::{Digest, Sha256}; + use hbb_common::sha2::{Digest, Sha256}; let uuid = hbb_common::get_uuid(); let mut hasher = Sha256::new(); @@ -1518,6 +2270,356 @@ pub fn get_hwid() -> Bytes { Bytes::from(hasher.finalize().to_vec()) } +#[inline] +pub fn get_builtin_option(key: &str) -> String { + config::BUILTIN_SETTINGS + .read() + .unwrap() + .get(key) + .cloned() + .unwrap_or_default() +} + +#[inline] +pub fn is_custom_client() -> bool { + get_app_name() != "RustDesk" +} + +pub fn verify_login(_raw: &str, _id: &str) -> bool { + true + /* + if is_custom_client() { + return true; + } + #[cfg(debug_assertions)] + return true; + let Ok(pk) = crate::decode64("IycjQd4TmWvjjLnYd796Rd+XkK+KG+7GU1Ia7u4+vSw=") else { + return false; + }; + let Some(key) = get_pk(&pk).map(|x| sign::PublicKey(x)) else { + return false; + }; + let Ok(v) = crate::decode64(raw) else { + return false; + }; + let raw = sign::verify(&v, &key).unwrap_or_default(); + let v_str = std::str::from_utf8(&raw) + .unwrap_or_default() + .split(":") + .next() + .unwrap_or_default(); + v_str == id + */ +} + +#[inline] +pub fn is_udp_disabled() -> bool { + Config::get_option(keys::OPTION_DISABLE_UDP) == "Y" +} + +// this crate https://github.com/yoshd/stun-client supports nat type +async fn stun_ipv6_test(stun_server: &str) -> ResultType<(SocketAddr, String)> { + use std::net::ToSocketAddrs; + use stunclient::StunClient; + let local_addr = SocketAddr::from(([0u16; 8], 0)); // [::]:0 + let socket = UdpSocket::bind(&local_addr).await?; + let Some(stun_addr) = stun_server + .to_socket_addrs()? + .filter(|x| x.is_ipv6()) + .next() + else { + bail!( + "Failed to resolve STUN ipv6 server address: {}", + stun_server + ); + }; + let client = StunClient::new(stun_addr); + let addr = client.query_external_address_async(&socket).await?; + Ok(if addr.ip().is_ipv6() { + (addr, stun_server.to_owned()) + } else { + bail!("STUN server returned non-IPv6 address: {}", addr) + }) +} + +async fn stun_ipv4_test(stun_server: &str) -> ResultType<(SocketAddr, String)> { + use std::net::ToSocketAddrs; + use stunclient::StunClient; + let local_addr = SocketAddr::from(([0u8; 4], 0)); + let socket = UdpSocket::bind(&local_addr).await?; + let Some(stun_addr) = stun_server + .to_socket_addrs()? + .filter(|x| x.is_ipv4()) + .next() + else { + bail!( + "Failed to resolve STUN ipv4 server address: {}", + stun_server + ); + }; + let client = StunClient::new(stun_addr); + let addr = client.query_external_address_async(&socket).await?; + Ok(if addr.ip().is_ipv4() { + (addr, stun_server.to_owned()) + } else { + bail!("STUN server returned non-IPv6 address: {}", addr) + }) +} + +static STUNS_V4: [&str; 3] = [ + "stun.l.google.com:19302", + "stun.cloudflare.com:3478", + "stun.nextcloud.com:3478", +]; + +static STUNS_V6: [&str; 3] = [ + "stun.l.google.com:19302", + "stun.cloudflare.com:3478", + "stun.nextcloud.com:3478", +]; + +pub async fn test_nat_ipv4() -> ResultType<(SocketAddr, String)> { + use hbb_common::futures::future::{select_ok, FutureExt}; + let tests = STUNS_V4 + .iter() + .map(|&stun| stun_ipv4_test(stun).boxed()) + .collect::>(); + + match select_ok(tests).await { + Ok(res) => { + return Ok(res.0); + } + Err(e) => { + bail!( + "Failed to get public IPv4 address via public STUN servers: {}", + e + ); + } + }; +} + +async fn test_bind_ipv6() -> ResultType { + let local_addr = SocketAddr::from(([0u16; 8], 0)); // [::]:0 + let socket = UdpSocket::bind(local_addr).await?; + let addr = STUNS_V6[0] + .to_socket_addrs()? + .filter(|x| x.is_ipv6()) + .next() + .ok_or_else(|| { + anyhow!( + "Failed to resolve STUN ipv6 server address: {}", + STUNS_V6[0] + ) + })?; + socket.connect(addr).await?; + Ok(socket.local_addr()?) +} + +pub async fn test_ipv6() -> Option> { + if PUBLIC_IPV6_ADDR + .lock() + .unwrap() + .1 + .map(|x| x.elapsed().as_secs() < 60) + .unwrap_or(false) + { + return None; + } + PUBLIC_IPV6_ADDR.lock().unwrap().1 = Some(Instant::now()); + + match test_bind_ipv6().await { + Ok(mut addr) => { + if let std::net::IpAddr::V6(ip) = addr.ip() { + if !ip.is_loopback() + && !ip.is_unspecified() + && !ip.is_multicast() + && (ip.segments()[0] & 0xe000) == 0x2000 + { + addr.set_port(0); + PUBLIC_IPV6_ADDR.lock().unwrap().0 = Some(addr); + log::debug!("Found public IPv6 address locally: {}", addr); + } + } + } + Err(e) => { + log::warn!("Failed to bind IPv6 socket: {}", e); + } + } + // Interestingly, on my macOS, sometimes my ipv6 works, sometimes not (test with ping6 or https://test-ipv6.com/). + // I checked ifconfig, could not see any difference. Both secure ipv6 and temporary ipv6 are there. + // So we can not rely on the local ipv6 address queries with if_addrs. + // above test_bind_ipv6 is safer, because it can fail in this case. + /* + std::thread::spawn(|| { + if let Ok(ifaces) = if_addrs::get_if_addrs() { + for iface in ifaces { + if let if_addrs::IfAddr::V6(v6) = iface.addr { + let ip = v6.ip; + if !ip.is_loopback() + && !ip.is_unspecified() + && !ip.is_multicast() + && !ip.is_unique_local() + && !ip.is_unicast_link_local() + && (ip.segments()[0] & 0xe000) == 0x2000 + { + // only use the first one, on mac, the first one is the stable + // one, the last one is the temporary one. The middle ones are deperecated. + *PUBLIC_IPV6_ADDR.lock().unwrap() = + Some((SocketAddr::from((ip, 0)), Instant::now())); + log::debug!("Found public IPv6 address locally: {}", ip); + break; + } + } + } + } + }); + */ + + Some(tokio::spawn(async { + use hbb_common::futures::future::{select_ok, FutureExt}; + let tests = STUNS_V6 + .iter() + .map(|&stun| stun_ipv6_test(stun).boxed()) + .collect::>(); + + match select_ok(tests).await { + Ok(res) => { + let mut addr = res.0 .0; + addr.set_port(0); // Set port to 0 to avoid conflicts + PUBLIC_IPV6_ADDR.lock().unwrap().0 = Some(addr); + log::debug!( + "Found public IPv6 address via STUN server {}: {}", + res.0 .1, + addr + ); + } + Err(e) => { + log::error!("Failed to get public IPv6 address: {}", e); + } + }; + })) +} + +pub async fn punch_udp( + socket: Arc, + listen: bool, +) -> ResultType> { + let mut retry_interval = Duration::from_millis(20); + const MAX_INTERVAL: Duration = Duration::from_millis(200); + const MAX_TIME: Duration = Duration::from_secs(20); + let mut packets_sent = 0; + socket.send(&[]).await.ok(); + packets_sent += 1; + let mut last_send_time = Instant::now(); + let tm = Instant::now(); + let mut data = [0u8; 1500]; + + loop { + tokio::select! { + _ = hbb_common::sleep(retry_interval.as_secs_f32()) => { + if tm.elapsed() > MAX_TIME { + bail!("UDP punch is timed out, stop sending packets after {:?} packets", packets_sent); + } + let elapsed = last_send_time.elapsed(); + + if elapsed >= retry_interval { + socket.send(&[]).await.ok(); + packets_sent += 1; + + // Exponentially increase interval to reduce network pressure + retry_interval = std::cmp::min( + Duration::from_millis((retry_interval.as_millis() as f64 * 1.5) as u64), + MAX_INTERVAL + ); + last_send_time = Instant::now(); + } + } + res = socket.recv(&mut data) => match res { + Err(e) => bail!("UDP punch failed, {packets_sent} packets sent: {e}"), + Ok(n) => { + // log::debug!("UDP punch succeeded after sending {} packets after {:?}", packets_sent, tm.elapsed()); + if listen { + if n == 0 { + continue; + } + return Ok(Some(bytes::BytesMut::from(&data[..n]))); + } + return Ok(None); + } + } + } + } +} + +fn test_ipv6_sync() { + #[tokio::main(flavor = "current_thread")] + async fn func() { + if let Some(job) = test_ipv6().await { + job.await.ok(); + } + } + std::thread::spawn(func); +} + +pub async fn get_ipv6_socket() -> Option<(Arc, bytes::Bytes)> { + let Some(addr) = PUBLIC_IPV6_ADDR.lock().unwrap().0 else { + return None; + }; + + match UdpSocket::bind(addr).await { + Err(err) => { + log::warn!("Failed to create UDP socket for IPv6: {err}"); + } + Ok(socket) => { + if let Ok(local_addr_v6) = socket.local_addr() { + return Some(( + Arc::new(socket), + hbb_common::AddrMangle::encode(local_addr_v6).into(), + )); + } + } + } + None +} + +// The color is the same to `str2color()` in flutter. +pub fn str2color(s: &str, alpha: u8) -> u32 { + let bytes = s.as_bytes(); + // dart code `160 << 16 + 114 << 8 + 91` results `0`. + let mut hash: u32 = 0; + for &byte in bytes { + let code = byte as u32; + hash = code.wrapping_add((hash << 5).wrapping_sub(hash)); + } + + hash = hash % 16777216; + let rgb = hash & 0xFF7FFF; + + (alpha as u32) << 24 | rgb +} + +/// Check control permission state from a u64 bitmap. +/// Each permission uses 2 bits: 0 = not set, 1 = disable, 2 = enable, 3 = invalid (treated as not set) +/// Returns: Some(true) = enabled, Some(false) = disabled, None = not set or invalid +pub fn get_control_permission( + permissions: u64, + permission: hbb_common::rendezvous_proto::control_permissions::Permission, +) -> Option { + use hbb_common::protobuf::Enum; + let index = permission.value(); + if index >= 0 && index < 32 { + let shift = index * 2; + let value = (permissions >> shift) & 0b11; + match value { + 1 => Some(false), // disable + 2 => Some(true), // enable + _ => None, // 0 = not set, 3 = invalid + } + } else { + None + } +} + #[cfg(test)] mod tests { use super::*; @@ -1658,14 +2760,248 @@ mod tests { Duration::from_nanos(0) ); } -} -#[inline] -pub fn get_builtin_option(key: &str) -> String { - config::BUILTIN_SETTINGS - .read() - .unwrap() - .get(key) - .cloned() - .unwrap_or_default() + #[test] + fn test_is_public() { + // Test URLs containing "rustdesk.com/" + assert!(is_public("https://rustdesk.com/")); + assert!(is_public("https://www.rustdesk.com/")); + assert!(is_public("https://api.rustdesk.com/v1")); + assert!(is_public("https://API.RUSTDESK.COM/v1")); + assert!(is_public("https://rustdesk.com/path")); + + // Test URLs ending with "rustdesk.com" + assert!(is_public("rustdesk.com")); + assert!(is_public("https://rustdesk.com")); + assert!(is_public("https://RustDesk.com")); + assert!(is_public("http://www.rustdesk.com")); + assert!(is_public("https://api.rustdesk.com")); + + // Test non-public URLs + assert!(!is_public("https://example.com")); + assert!(!is_public("https://custom-server.com")); + assert!(!is_public("http://192.168.1.1")); + assert!(!is_public("localhost")); + assert!(!is_public("https://rustdesk.computer.com")); + assert!(!is_public("rustdesk.comhello.com")); + } + + #[test] + fn test_should_use_tcp_proxy_for_api_url() { + assert!(should_use_tcp_proxy_for_api_url( + "https://admin.example.com/api/login", + "https://admin.example.com" + )); + assert!(should_use_tcp_proxy_for_api_url( + "https://admin.example.com:21114/api/login", + "https://admin.example.com" + )); + assert!(!should_use_tcp_proxy_for_api_url( + "https://api.telegram.org/bot123/sendMessage", + "https://admin.example.com" + )); + assert!(!should_use_tcp_proxy_for_api_url( + "https://admin.rustdesk.com/api/login", + "https://admin.rustdesk.com" + )); + assert!(!should_use_tcp_proxy_for_api_url( + "https://admin.example.com/api/login", + "not a url" + )); + assert!(!should_use_tcp_proxy_for_api_url( + "not a url", + "https://admin.example.com" + )); + } + + #[test] + fn test_get_tcp_proxy_addr_normalizes_bare_ipv6_host() { + struct RestoreCustomRendezvousServer(String); + + impl Drop for RestoreCustomRendezvousServer { + fn drop(&mut self) { + Config::set_option( + keys::OPTION_CUSTOM_RENDEZVOUS_SERVER.to_string(), + self.0.clone(), + ); + } + } + + let _restore = RestoreCustomRendezvousServer(Config::get_option( + keys::OPTION_CUSTOM_RENDEZVOUS_SERVER, + )); + Config::set_option( + keys::OPTION_CUSTOM_RENDEZVOUS_SERVER.to_string(), + "1:2".to_string(), + ); + + assert_eq!(get_tcp_proxy_addr(), format!("[1:2]:{RENDEZVOUS_PORT}")); + } + + #[tokio::test] + async fn test_http_request_via_tcp_proxy_rejects_invalid_header_json() { + let result = http_request_via_tcp_proxy("not a url", "get", None, "{").await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_http_request_via_tcp_proxy_rejects_non_object_header_json() { + let err = http_request_via_tcp_proxy("not a url", "get", None, "[]") + .await + .unwrap_err() + .to_string(); + assert!(err.contains("HTTP header information parsing failed!")); + } + + #[test] + fn test_parse_json_header_entries_preserves_single_content_type() { + let headers = parse_json_header_entries( + r#"{"Content-Type":"text/plain","Authorization":"Bearer token"}"#, + ) + .unwrap(); + + assert_eq!( + headers + .iter() + .filter(|entry| entry.name.eq_ignore_ascii_case("Content-Type")) + .count(), + 1 + ); + assert_eq!( + headers + .iter() + .find(|entry| entry.name.eq_ignore_ascii_case("Content-Type")) + .map(|entry| entry.value.as_str()), + Some("text/plain") + ); + } + + #[test] + fn test_parse_json_header_entries_does_not_add_default_content_type() { + let headers = parse_json_header_entries(r#"{"Authorization":"Bearer token"}"#).unwrap(); + + assert!(!headers + .iter() + .any(|entry| entry.name.eq_ignore_ascii_case("Content-Type"))); + } + + #[test] + fn test_parse_simple_header_respects_custom_content_type() { + let headers = parse_simple_header("Content-Type: text/plain"); + + assert_eq!( + headers + .iter() + .filter(|entry| entry.name.eq_ignore_ascii_case("Content-Type")) + .count(), + 1 + ); + assert_eq!( + headers + .iter() + .find(|entry| entry.name.eq_ignore_ascii_case("Content-Type")) + .map(|entry| entry.value.as_str()), + Some("text/plain") + ); + } + + #[test] + fn test_parse_simple_header_preserves_non_content_type_header() { + let headers = parse_simple_header("Authorization: Bearer token"); + + assert!(headers.iter().any(|entry| { + entry.name.eq_ignore_ascii_case("Authorization") + && entry.value.as_str() == "Bearer token" + })); + assert_eq!( + headers + .iter() + .filter(|entry| entry.name.eq_ignore_ascii_case("Content-Type")) + .count(), + 1 + ); + assert_eq!( + headers + .iter() + .find(|entry| entry.name.eq_ignore_ascii_case("Content-Type")) + .map(|entry| entry.value.as_str()), + Some("application/json") + ); + } + + #[test] + fn test_tcp_proxy_log_target_redacts_query_only() { + assert_eq!( + tcp_proxy_log_target("https://example.com/api/heartbeat?token=secret"), + "https://example.com/api/heartbeat" + ); + } + + #[test] + fn test_tcp_proxy_log_target_brackets_ipv6_host_with_port() { + assert_eq!( + tcp_proxy_log_target("https://[2001:db8::1]:21114/api/heartbeat?token=secret"), + "https://[2001:db8::1]:21114/api/heartbeat" + ); + } + + #[test] + fn test_http_proxy_response_to_json() { + let mut resp = HttpProxyResponse { + status: 200, + body: br#"{"ok":true}"#.to_vec().into(), + ..Default::default() + }; + resp.headers.push(HeaderEntry { + name: "Content-Type".into(), + value: "application/json".into(), + ..Default::default() + }); + + let json = http_proxy_response_to_json(resp).unwrap(); + let value: Value = serde_json::from_str(&json).unwrap(); + assert_eq!(value["status_code"], 200); + assert_eq!(value["headers"]["content-type"], "application/json"); + assert_eq!(value["body"], r#"{"ok":true}"#); + + let err = http_proxy_response_to_json(HttpProxyResponse { + error: "dial failed".into(), + ..Default::default() + }) + .unwrap_err() + .to_string(); + assert!(err.contains("TCP proxy error: dial failed")); + } + + #[test] + fn test_mouse_event_constants_and_mask_layout() { + use super::input::*; + + // Verify MOUSE_TYPE constants are unique and within the mask range. + let types = [ + MOUSE_TYPE_MOVE, + MOUSE_TYPE_DOWN, + MOUSE_TYPE_UP, + MOUSE_TYPE_WHEEL, + MOUSE_TYPE_TRACKPAD, + MOUSE_TYPE_MOVE_RELATIVE, + ]; + + let mut seen = std::collections::HashSet::new(); + for t in types.iter() { + assert!(seen.insert(*t), "Duplicate mouse type: {}", t); + assert_eq!( + *t & MOUSE_TYPE_MASK, + *t, + "Mouse type {} exceeds mask {}", + t, + MOUSE_TYPE_MASK + ); + } + + // The mask layout is: lower 3 bits for type, upper bits for buttons (shifted by 3). + let combined_mask = MOUSE_TYPE_DOWN | ((MOUSE_BUTTON_LEFT | MOUSE_BUTTON_RIGHT) << 3); + assert_eq!(combined_mask & MOUSE_TYPE_MASK, MOUSE_TYPE_DOWN); + assert_eq!(combined_mask >> 3, MOUSE_BUTTON_LEFT | MOUSE_BUTTON_RIGHT); + } } diff --git a/src/core_main.rs b/src/core_main.rs index 23d7706d4..4515faa6b 100644 --- a/src/core_main.rs +++ b/src/core_main.rs @@ -1,4 +1,4 @@ -#[cfg(windows)] +#[cfg(any(target_os = "windows", target_os = "macos"))] use crate::client::translate; #[cfg(not(debug_assertions))] #[cfg(not(any(target_os = "android", target_os = "ios")))] @@ -29,9 +29,15 @@ macro_rules! my_println{ /// If it returns [`Some`], then the process will continue, and flutter gui will be started. #[cfg(not(any(target_os = "android", target_os = "ios")))] pub fn core_main() -> Option> { + if !crate::common::global_init() { + return None; + } crate::load_custom_client(); #[cfg(windows)] - crate::platform::windows::bootstrap(); + if !crate::platform::windows::bootstrap() { + // return None to terminate the process + return None; + } let mut args = Vec::new(); let mut flutter_args = Vec::new(); let mut i = 0; @@ -50,7 +56,9 @@ pub fn core_main() -> Option> { "--connect", "--play", "--file-transfer", + "--view-camera", "--port-forward", + "--terminal", "--rdp", ] .contains(&arg.as_str()) @@ -73,7 +81,15 @@ pub fn core_main() -> Option> { } #[cfg(any(target_os = "linux", target_os = "windows"))] if args.is_empty() { - if crate::check_process("--server", false) && !crate::check_process("--tray", true) { + #[cfg(target_os = "linux")] + let should_check_start_tray = crate::check_process("--server", false); + // We can use `crate::check_process("--server", false)` on Windows. + // Because `--server` process is the System user's process. We can't get the arguments in `check_process()`. + // We can assume that self service running means the server is also running on Windows. + #[cfg(target_os = "windows")] + let should_check_start_tray = crate::platform::is_self_service_running() + && crate::platform::is_cur_exe_the_installed(); + if should_check_start_tray && !crate::check_process("--tray", true) { #[cfg(target_os = "linux")] hbb_common::allow_err!(crate::platform::check_autostart_config()); hbb_common::allow_err!(crate::run_me(vec!["--tray"])); @@ -96,7 +112,7 @@ pub fn core_main() -> Option> { } } #[cfg(windows)] - if args.contains(&"--connect".to_string()) { + if args.contains(&"--connect".to_string()) || args.contains(&"--view-camera".to_string()) { hbb_common::platform::windows::start_cpu_performance_monitor(); } #[cfg(feature = "flutter")] @@ -124,20 +140,25 @@ pub fn core_main() -> Option> { { _is_quick_support |= !crate::platform::is_installed() && args.is_empty() - && (arg_exe.to_lowercase().contains("-qs-") + && (is_quick_support_exe(&arg_exe) || config::LocalConfig::get_option("pre-elevate-service") == "Y" || (!click_setup && crate::platform::is_elevated(None).unwrap_or(false))); crate::portable_service::client::set_quick_support(_is_quick_support); } let mut log_name = "".to_owned(); - if args.len() > 0 && args[0].starts_with("--") { + // Keep portable-service logs under a stable directory name. + let has_portable_service_shmem_arg = args + .iter() + .any(|arg| arg.starts_with("--portable-service-shmem-name=")); + if has_portable_service_shmem_arg { + log_name = "portable-service".to_owned(); + } else if args.len() > 0 && args[0].starts_with("--") { let name = args[0].replace("--", ""); if !name.is_empty() { log_name = name; } } hbb_common::init_log(false, &log_name); - log::info!("main start args: {:?}, env: {:?}", args, std::env::args()); // linux uni (url) go here. #[cfg(all(target_os = "linux", feature = "flutter"))] @@ -166,8 +187,32 @@ pub fn core_main() -> Option> { #[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")] + { + crate::platform::macos::try_remove_temp_update_dir(None); + } + + #[cfg(windows)] + { + crate::platform::try_remove_temp_update_files(); + hbb_common::config::PeerConfig::preload_peers(); + } std::thread::spawn(move || crate::start_server(false, no_server)); } else { + #[cfg(any(target_os = "linux", target_os = "macos"))] + // Root CLI management commands must talk to the user `--server` main IPC. + // Example: `sudo rustdesk --option custom-rendezvous-server` should query the + // user's IPC instead of root's `/tmp/-0/ipc`; `connect()` still limits this + // routing to empty-postfix main IPC only. + let _user_main_ipc_scope = if crate::platform::is_installed() + && is_root() + && is_user_main_ipc_scope_cli_command(&args) + { + Some(crate::ipc::UserMainIpcScope::new()) + } else { + None + }; + #[cfg(windows)] { use crate::platform; @@ -176,6 +221,33 @@ pub fn core_main() -> Option> { log::error!("Failed to uninstall: {}", err); } return None; + } else if args[0] == "--update" { + if config::is_disable_installation() { + return None; + } + + let text = match crate::platform::prepare_custom_client_update() { + Err(e) => { + log::error!("Error preparing custom client update: {}", e); + "Update failed!".to_string() + } + Ok(false) => "Update failed!".to_string(), + Ok(true) => match platform::update_me(false) { + Ok(_) => "Updated successfully!".to_string(), + Err(err) => { + log::error!("Failed with error: {err}"); + "Update failed!".to_string() + } + }, + }; + Toast::new(Toast::POWERSHELL_APP_ID) + .title(&config::APP_NAME.read().unwrap()) + .text1(&translate(text)) + .sound(Some(Sound::Default)) + .duration(Duration::Short) + .show() + .ok(); + return None; } else if args[0] == "--after-install" { if let Err(err) = platform::run_after_install() { log::error!("Failed to after-install: {}", err); @@ -190,12 +262,11 @@ pub fn core_main() -> Option> { if config::is_disable_installation() { return None; } - let res = platform::install_me( - "desktopicon startmenu", - "".to_owned(), - true, - args.len() > 1, - ); + #[cfg(not(windows))] + let options = "desktopicon startmenu"; + #[cfg(windows)] + let options = "desktopicon startmenu printer"; + let res = platform::install_me(options, "".to_owned(), true, args.len() > 1); let text = match res { Ok(_) => translate("Installation Successful!".to_string()), Err(err) => { @@ -236,6 +307,64 @@ pub fn core_main() -> Option> { crate::virtual_display_manager::amyuni_idd::uninstall_driver() ); return None; + } else if args[0] == "--install-remote-printer" { + #[cfg(windows)] + if crate::platform::is_win_10_or_greater() { + match remote_printer::install_update_printer(&crate::get_app_name()) { + Ok(_) => { + log::info!("Remote printer installed/updated successfully"); + } + Err(e) => { + log::error!("Failed to install/update the remote printer: {}", e); + } + } + } else { + log::error!("Win10 or greater required!"); + } + return None; + } else if args[0] == "--uninstall-remote-printer" { + #[cfg(windows)] + if crate::platform::is_win_10_or_greater() { + remote_printer::uninstall_printer(&crate::get_app_name()); + log::info!("Remote printer uninstalled"); + } + return None; + } + } + #[cfg(target_os = "macos")] + { + use crate::platform; + if args[0] == "--update" { + if args.len() > 1 && args[1].ends_with(".dmg") { + // Version check is unnecessary unless downgrading to an older version + // that lacks "update dmg" support. This is a special case since we cannot + // detect the version before extracting the DMG, so we skip the check. + let dmg_path = &args[1]; + println!("Updating from DMG: {}", dmg_path); + match platform::update_from_dmg(dmg_path) { + Ok(_) => { + println!("Update process from DMG started successfully."); + // The new process will handle the rest. We can exit. + } + Err(err) => { + eprintln!("Failed to start update from DMG: {}", err); + } + } + } else { + println!("Starting update process..."); + log::info!("Starting update process..."); + let _text = match platform::update_me() { + Ok(_) => { + println!("{}", translate("Updated successfully!".to_string())); + log::info!("Updated successfully!"); + } + Err(err) => { + eprintln!("Update failed with error: {}", err); + log::error!("Update failed with error: {err}"); + } + }; + } + return None; } } if args[0] == "--remove" { @@ -272,14 +401,10 @@ pub fn core_main() -> Option> { .arg(&format!("{} --tray", crate::get_app_name().to_lowercase())) .status() .ok(); - hbb_common::allow_err!(crate::platform::run_as_user( - vec!["--tray"], - None, - None::<(&str, &str)>, - )); + hbb_common::allow_err!(crate::run_me(vec!["--tray"])); } #[cfg(windows)] - crate::privacy_mode::restore_reg_connectivity(true); + crate::privacy_mode::restore_reg_connectivity(true, false); #[cfg(any(target_os = "linux", target_os = "windows"))] { crate::start_server(true, false); @@ -307,6 +432,14 @@ pub fn core_main() -> Option> { } return None; } else if args[0] == "--password" { + if config::is_disable_settings() { + println!("Settings are disabled!"); + return None; + } + if config::Config::is_disable_change_permanent_password() { + println!("Changing permanent password is disabled!"); + return None; + } if args.len() == 2 { if crate::platform::is_installed() && is_root() { if let Err(err) = crate::ipc::set_permanent_password(args[1].to_owned()) { @@ -320,6 +453,10 @@ pub fn core_main() -> Option> { } return None; } else if args[0] == "--set-unlock-pin" { + if config::Config::is_disable_unlock_pin() { + println!("Unlock PIN is disabled!"); + return None; + } #[cfg(feature = "flutter")] if args.len() == 2 { if crate::platform::is_installed() && is_root() { @@ -337,6 +474,14 @@ pub fn core_main() -> Option> { println!("{}", crate::ipc::get_id()); return None; } else if args[0] == "--set-id" { + if config::is_disable_settings() { + println!("Settings are disabled!"); + return None; + } + if config::Config::is_disable_change_id() { + println!("Changing ID is disabled!"); + return None; + } if args.len() == 2 { if crate::platform::is_installed() && is_root() { let old_id = crate::ipc::get_id(); @@ -376,6 +521,10 @@ pub fn core_main() -> Option> { } return None; } else if args[0] == "--option" { + if config::is_disable_settings() { + println!("Settings are disabled!"); + return None; + } if crate::platform::is_installed() && is_root() { if args.len() == 2 { let options = crate::ipc::get_options(); @@ -388,51 +537,56 @@ pub fn core_main() -> Option> { } return None; } else if args[0] == "--assign" { - if crate::platform::is_installed() && is_root() { + if config::Config::no_register_device() { + println!("Cannot assign an unregistrable device!"); + } else if crate::platform::is_installed() && is_root() { let max = args.len() - 1; let pos = args.iter().position(|x| x == "--token").unwrap_or(max); if pos < max { let token = args[pos + 1].to_owned(); let id = crate::ipc::get_id(); let uuid = crate::encode64(hbb_common::get_uuid()); - let mut user_name = None; - let pos = args.iter().position(|x| x == "--user_name").unwrap_or(max); - if pos < max { - user_name = Some(args[pos + 1].to_owned()); - } - let mut strategy_name = None; - let pos = args - .iter() - .position(|x| x == "--strategy_name") - .unwrap_or(max); - if pos < max { - strategy_name = Some(args[pos + 1].to_owned()); - } - let mut address_book_name = None; - let pos = args - .iter() - .position(|x| x == "--address_book_name") - .unwrap_or(max); - if pos < max { - address_book_name = Some(args[pos + 1].to_owned()); - } - let mut address_book_tag = None; - let pos = args - .iter() - .position(|x| x == "--address_book_tag") - .unwrap_or(max); - if pos < max { - address_book_tag = Some(args[pos + 1].to_owned()); - } + let get_value = |c: &str| { + let pos = args.iter().position(|x| x == c).unwrap_or(max); + if pos < max { + Some(args[pos + 1].to_owned()) + } else { + None + } + }; + let user_name = get_value("--user_name"); + let strategy_name = get_value("--strategy_name"); + let address_book_name = get_value("--address_book_name"); + let address_book_tag = get_value("--address_book_tag"); + let address_book_alias = get_value("--address_book_alias"); + let address_book_password = get_value("--address_book_password"); + let address_book_note = get_value("--address_book_note"); + let device_group_name = get_value("--device_group_name"); + let note = get_value("--note"); + let device_username = get_value("--device_username"); + let device_name = get_value("--device_name"); let mut body = serde_json::json!({ "id": id, "uuid": uuid, }); let header = "Authorization: Bearer ".to_owned() + &token; - if user_name.is_none() && strategy_name.is_none() && address_book_name.is_none() + if user_name.is_none() + && strategy_name.is_none() + && address_book_name.is_none() + && device_group_name.is_none() + && note.is_none() + && device_username.is_none() + && device_name.is_none() { println!( - "--user_name or --strategy_name or --address_book_name is required!" + r#"At least one of the following options is required: + --user_name + --strategy_name + --address_book_name + --device_group_name + --note + --device_username + --device_name"# ); } else { if let Some(name) = user_name { @@ -446,6 +600,27 @@ pub fn core_main() -> Option> { if let Some(name) = address_book_tag { body["address_book_tag"] = serde_json::json!(name); } + if let Some(name) = address_book_alias { + body["address_book_alias"] = serde_json::json!(name); + } + if let Some(name) = address_book_password { + body["address_book_password"] = serde_json::json!(name); + } + if let Some(name) = address_book_note { + body["address_book_note"] = serde_json::json!(name); + } + } + if let Some(name) = device_group_name { + body["device_group_name"] = serde_json::json!(name); + } + if let Some(name) = note { + body["note"] = serde_json::json!(name); + } + if let Some(name) = device_username { + body["device_username"] = serde_json::json!(name); + } + if let Some(name) = device_name { + body["device_name"] = serde_json::json!(name); } let url = crate::ui_interface::get_api_server() + "/api/devices/cli"; match crate::post_request_sync(url, body.to_string(), &header) { @@ -466,10 +641,113 @@ pub fn core_main() -> Option> { println!("Installation and administrative privileges required!"); } return None; + } else if args[0] == "--deploy" { + if config::Config::no_register_device() { + println!("Cannot deploy an unregistrable device!"); + } else if crate::platform::is_installed() && is_root() { + let max = args.len() - 1; + let pos = args.iter().position(|x| x == "--token").unwrap_or(max); + if pos >= max { + println!("--token is required!"); + return None; + } + let token = args[pos + 1].to_owned(); + let get_value = |c: &str| { + let pos = args.iter().position(|x| x == c).unwrap_or(max); + if pos < max { + Some(args[pos + 1].to_owned()) + } else { + None + } + }; + let new_id = get_value("--id"); + let local_id = crate::ipc::get_id(); + let id_to_deploy = new_id.clone().unwrap_or_else(|| local_id.clone()); + let uuid = crate::encode64(hbb_common::get_uuid()); + let pk = crate::encode64( + hbb_common::config::Config::get_key_pair().1, + ); + let body = serde_json::json!({ + "id": id_to_deploy, + "uuid": uuid, + "pk": pk, + }); + let header = "Authorization: Bearer ".to_owned() + &token; + let url = crate::ui_interface::get_api_server() + "/api/devices/deploy"; + match crate::post_request_sync(url, body.to_string(), &header) { + Err(err) => { + println!("Request failed: {}", err); + std::process::exit(1); + } + Ok(text) => { + let parsed: serde_json::Value = + serde_json::from_str(&text).unwrap_or(serde_json::Value::Null); + let result = parsed["result"].as_str().unwrap_or(""); + match result { + "OK" => { + if let Some(ref new_id) = new_id { + if *new_id != local_id { + if let Err(err) = + crate::ipc::set_config("id", new_id.clone()) + { + println!( + "Failed to persist deployed id locally: {}", + err + ); + std::process::exit(1); + } + } + } + if let Err(err) = crate::ipc::notify_deployed() { + log::warn!("Failed to notify deployed state: {}", err); + } + println!("Device deployed."); + } + "NOT_ENABLED" => { + println!("Server does not require deployment."); + std::process::exit(3); + } + "INVALID_INPUT" => { + println!("Invalid input."); + std::process::exit(5); + } + "ID_TAKEN" => { + println!( + "Id `{}` is already used by another machine on the server.", + id_to_deploy + ); + std::process::exit(6); + } + _ => { + if text.is_empty() { + println!("Unknown response."); + } else { + println!("{}", text); + } + std::process::exit(1); + } + } + } + } + } else { + println!("Installation and administrative privileges required!"); + } + return None; } else if args[0] == "--check-hwcodec-config" { #[cfg(feature = "hwcodec")] crate::ipc::hwcodec_process(); return None; + } else if args[0] == "--terminal-helper" { + // Terminal helper process - runs as user to create ConPTY + // This is needed because ConPTY has compatibility issues with CreateProcessAsUserW + #[cfg(target_os = "windows")] + { + let helper_args: Vec = args[1..].to_vec(); + if let Err(e) = crate::server::terminal_helper::run_terminal_helper(&helper_args) { + log::error!("Terminal helper failed: {}", e); + } + } + return None; } else if args[0] == "--cm" { // call connection manager to establish connections // meanwhile, return true to call flutter window to show control panel @@ -482,6 +760,12 @@ pub fn core_main() -> Option> { crate::flutter::connection_manager::start_cm_no_ui(); } return None; + } else if args[0] == "--whiteboard" { + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + crate::whiteboard::run(); + } + return None; } else if args[0] == "-gtk-sudo" { // rustdesk service kill `rustdesk --` processes #[cfg(target_os = "linux")] @@ -570,7 +854,8 @@ fn core_main_invoke_new_connection(mut args: std::env::Args) -> Option { + "--connect" | "--play" | "--file-transfer" | "--view-camera" | "--port-forward" + | "--terminal" | "--rdp" => { authority = Some((&arg.to_string()[2..]).to_owned()); id = args.next(); } @@ -666,3 +951,63 @@ fn is_root() -> bool { #[allow(unreachable_code)] crate::platform::is_root() } + +#[cfg(any(target_os = "linux", target_os = "macos", test))] +fn is_user_main_ipc_scope_cli_command(args: &[String]) -> bool { + matches!( + args.first().map(String::as_str), + Some("--password") + | Some("--set-unlock-pin") + | Some("--get-id") + | Some("--set-id") + | Some("--config") + | Some("--option") + | Some("--assign") + | Some("--deploy") + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn args(values: &[&str]) -> Vec { + values.iter().map(|value| value.to_string()).collect() + } + + #[test] + fn user_main_ipc_scope_cli_command_matches_management_commands_only() { + for command in [ + "--password", + "--set-unlock-pin", + "--get-id", + "--set-id", + "--config", + "--option", + "--assign", + "--deploy", + ] { + assert!(is_user_main_ipc_scope_cli_command(&args(&[command]))); + } + + for command in [ + "--service", + "--server", + "--tray", + "--cm", + "--check-hwcodec-config", + "--connect", + ] { + assert!(!is_user_main_ipc_scope_cli_command(&args(&[command]))); + } + } +} + +/// Check if the executable is a Quick Support version. +/// Note: This function must be kept in sync with `libs/portable/src/main.rs`. +#[cfg(windows)] +#[inline] +fn is_quick_support_exe(exe: &str) -> bool { + let exe = exe.to_lowercase(); + exe.contains("-qs-") || exe.contains("-qs.exe") || exe.contains("_qs.exe") +} diff --git a/src/custom_server.rs b/src/custom_server.rs index c2c5e7f63..18118788e 100644 --- a/src/custom_server.rs +++ b/src/custom_server.rs @@ -56,8 +56,8 @@ pub fn get_custom_server_from_string(s: &str) -> ResultType { * * This allows using a ',' (comma) symbol as a final delimiter. */ - if s.contains("host=") { - let stripped = &s[s.find("host=").unwrap_or(0)..s.len()]; + if s.to_lowercase().contains("host=") { + let stripped = &s[s.to_lowercase().find("host=").unwrap_or(0)..s.len()]; let strs: Vec<&str> = stripped.split(",").collect(); let mut host = String::default(); let mut key = String::default(); @@ -65,16 +65,17 @@ pub fn get_custom_server_from_string(s: &str) -> ResultType { let mut relay = String::default(); let strs_iter = strs.iter(); for el in strs_iter { - if el.starts_with("host=") { + let el_lower = el.to_lowercase(); + if el_lower.starts_with("host=") { host = el.chars().skip(5).collect(); } - if el.starts_with("key=") { + if el_lower.starts_with("key=") { key = el.chars().skip(4).collect(); } - if el.starts_with("api=") { + if el_lower.starts_with("api=") { api = el.chars().skip(4).collect(); } - if el.starts_with("relay=") { + if el_lower.starts_with("relay=") { relay = el.chars().skip(6).collect(); } } @@ -169,6 +170,18 @@ mod test { relay: "server.example.net".to_owned(), } ); + assert_eq!( + get_custom_server_from_string( + "rustdesk-Host=server.example.net,Key=Zm9vYmFyLiwyCg==,RELAY=server.example.net.exe" + ) + .unwrap(), + CustomServer { + host: "server.example.net".to_owned(), + key: "Zm9vYmFyLiwyCg==".to_owned(), + api: "".to_owned(), + relay: "server.example.net".to_owned(), + } + ); let lic = CustomServer { host: "1.1.1.1".to_owned(), key: "5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=".to_owned(), diff --git a/src/flutter.rs b/src/flutter.rs index a1c9c7e34..f8b04bf6c 100644 --- a/src/flutter.rs +++ b/src/flutter.rs @@ -15,14 +15,15 @@ use hbb_common::{ }; use serde::Serialize; use serde_json::json; - +#[cfg(target_os = "windows")] +use std::io::{Error as IoError, ErrorKind as IoErrorKind}; use std::{ collections::{HashMap, HashSet}, ffi::CString, os::raw::{c_char, c_int, c_void}, str::FromStr, sync::{ - atomic::{AtomicBool, Ordering}, + atomic::{AtomicBool, AtomicUsize, Ordering}, Arc, RwLock, }, }; @@ -50,7 +51,7 @@ lazy_static::lazy_static! { #[cfg(target_os = "windows")] lazy_static::lazy_static! { - pub static ref TEXTURE_RGBA_RENDERER_PLUGIN: Result = Library::open("texture_rgba_renderer_plugin.dll"); + pub static ref TEXTURE_RGBA_RENDERER_PLUGIN: Result = load_plugin_in_app_path("texture_rgba_renderer_plugin.dll"); } #[cfg(target_os = "linux")] @@ -65,7 +66,37 @@ lazy_static::lazy_static! { #[cfg(target_os = "windows")] lazy_static::lazy_static! { - pub static ref TEXTURE_GPU_RENDERER_PLUGIN: Result = Library::open("flutter_gpu_texture_renderer_plugin.dll"); + pub static ref TEXTURE_GPU_RENDERER_PLUGIN: Result = load_plugin_in_app_path("flutter_gpu_texture_renderer_plugin.dll"); +} + +// Move this function into `src/platform/windows.rs` if there're more calls to load plugins. +// Load dll with full path. +#[cfg(target_os = "windows")] +fn load_plugin_in_app_path(dll_name: &str) -> Result { + match std::env::current_exe() { + Ok(exe_file) => { + if let Some(cur_dir) = exe_file.parent() { + let full_path = cur_dir.join(dll_name); + if !full_path.exists() { + Err(LibError::OpeningLibraryError(IoError::new( + IoErrorKind::NotFound, + format!("{} not found", dll_name), + ))) + } else { + Library::open(full_path) + } + } else { + Err(LibError::OpeningLibraryError(IoError::new( + IoErrorKind::Other, + format!( + "Invalid exe parent for {}", + exe_file.to_string_lossy().as_ref() + ), + ))) + } + } + Err(e) => Err(LibError::OpeningLibraryError(e)), + } } /// FFI for rustdesk core's main entry. @@ -80,6 +111,7 @@ pub extern "C" fn rustdesk_core_main() -> bool { #[cfg(target_os = "macos")] std::process::exit(0); } + #[cfg(not(target_os = "macos"))] false } @@ -512,6 +544,25 @@ impl FlutterHandler { pub fn push_event(&self, name: &str, event: &[(&str, V)], excludes: &[&SessionID]) where V: Sized + Serialize + Clone, + { + self.push_event_(name, event, &[], excludes); + } + + pub fn push_event_to(&self, name: &str, event: &[(&str, V)], include: &[&SessionID]) + where + V: Sized + Serialize + Clone, + { + self.push_event_(name, event, include, &[]); + } + + pub fn push_event_( + &self, + name: &str, + event: &[(&str, V)], + includes: &[&SessionID], + excludes: &[&SessionID], + ) where + V: Sized + Serialize + Clone, { let mut h: HashMap<&str, serde_json::Value> = event.iter().map(|(k, v)| (*k, json!(*v))).collect(); @@ -519,11 +570,20 @@ impl FlutterHandler { h.insert("name", json!(name)); let out = serde_json::ser::to_string(&h).unwrap_or("".to_owned()); for (sid, session) in self.session_handlers.read().unwrap().iter() { - if excludes.contains(&sid) { - continue; + let mut push = false; + if includes.is_empty() { + if !excludes.contains(&sid) { + push = true; + } + } else { + if includes.contains(&sid) { + push = true; + } } - if let Some(stream) = &session.event_stream { - stream.add(EventToUI::Event(out.clone())); + if push { + if let Some(stream) = &session.event_stream { + stream.add(EventToUI::Event(out.clone())); + } } } } @@ -549,7 +609,22 @@ impl FlutterHandler { h.insert("original_width", original_resolution.width); h.insert("original_height", original_resolution.height); } - h.insert("scale", (d.scale * 100.0f64) as i32); + // Don't convert scale (x 100) to i32 directly. + // (d.scale * 100.0f64) as i32 may produces inaccuracies. + // + // Example: GNOME Wayland with Fractional Scaling enabled: + // - Physical resolution: 2560x1600 + // - Logical resolution: 1074x1065 + // - Scale factor: 150% + // Passing physical dimensions and scale factor prevents accurate logical resolution calculation + // since 2560/1.5 = 1706.666... (rounded to 1706.67) and 1600/1.5 = 1066.666... (rounded to 1066.67) + // h.insert("scale", (d.scale * 100.0f64) as i32); + + // Send scaled_width for accurate logical scale calculation. + if d.scale > 0.0 { + let scaled_width = (d.width as f64 / d.scale).round() as i32; + h.insert("scaled_width", scaled_width); + } msg_vec.push(h); } serde_json::ser::to_string(&msg_vec).unwrap_or("".to_owned()) @@ -619,7 +694,7 @@ impl InvokeUiSession for FlutterHandler { } /// unused in flutter, use switch_display or set_peer_info - fn set_display(&self, _x: i32, _y: i32, _w: i32, _h: i32, _cursor_embedded: bool) {} + fn set_display(&self, _x: i32, _y: i32, _w: i32, _h: i32, _cursor_embedded: bool, _scale: f64) {} fn update_privacy_mode(&self) { self.push_event::<&str>("update_privacy_mode", &[], &[]); @@ -657,12 +732,13 @@ impl InvokeUiSession for FlutterHandler { ); } - fn set_connection_type(&self, is_secured: bool, direct: bool) { + fn set_connection_type(&self, is_secured: bool, direct: bool, stream_type: &str) { self.push_event( "connection_ready", &[ ("secure", &is_secured.to_string()), ("direct", &direct.to_string()), + ("stream_type", &stream_type.to_string()), ], &[], ); @@ -695,7 +771,7 @@ impl InvokeUiSession for FlutterHandler { // unused in flutter fn clear_all_jobs(&self) {} - fn load_last_job(&self, _cnt: i32, job_json: &str) { + fn load_last_job(&self, _cnt: i32, job_json: &str, _auto_start: bool) { self.push_event("load_last_job", &[("value", job_json)], &[]); } @@ -726,6 +802,20 @@ impl InvokeUiSession for FlutterHandler { } } + fn update_empty_dirs(&self, res: ReadEmptyDirsResponse) { + self.push_event( + "empty_dirs", + &[ + ("is_local", "false"), + ( + "value", + &crate::common::make_empty_dirs_response_to_json(&res), + ), + ], + &[], + ); + } + // unused in flutter fn update_transfer_list(&self) {} @@ -1014,6 +1104,85 @@ impl InvokeUiSession for FlutterHandler { fn update_record_status(&self, start: bool) { self.push_event("record_status", &[("start", &start.to_string())], &[]); } + + fn printer_request(&self, id: i32, path: String) { + self.push_event( + "printer_request", + &[("id", json!(id)), ("path", json!(path))], + &[], + ); + } + + fn handle_screenshot_resp(&self, sid: String, msg: String) { + match SessionID::from_str(&sid) { + Ok(sid) => self.push_event_to("screenshot", &[("msg", json!(msg))], &[&sid]), + Err(e) => { + // Unreachable! + log::error!("Failed to parse sid \"{}\", {}", sid, e); + } + } + } + + fn handle_terminal_response(&self, response: TerminalResponse) { + use hbb_common::message_proto::terminal_response::Union; + + match response.union { + Some(Union::Opened(opened)) => { + let mut event_data: Vec<(&str, serde_json::Value)> = vec![ + ("type", json!("opened")), + ("terminal_id", json!(opened.terminal_id)), + ("success", json!(opened.success)), + ("message", json!(&opened.message)), + ("pid", json!(opened.pid)), + ("service_id", json!(&opened.service_id)), + ( + "replay_terminal_output", + json!(opened.replay_terminal_output), + ), + ]; + if !opened.persistent_sessions.is_empty() { + event_data.push(("persistent_sessions", json!(opened.persistent_sessions))); + } + self.push_event_("terminal_response", &event_data, &[], &[]); + } + Some(Union::Data(data)) => { + // Decompress data if needed + let output_data = if data.compressed { + hbb_common::compress::decompress(&data.data) + } else { + data.data.to_vec() + }; + + let encoded = crate::encode64(&output_data); + let event_data: Vec<(&str, serde_json::Value)> = vec![ + ("type", json!("data")), + ("terminal_id", json!(data.terminal_id)), + ("data", json!(&encoded)), + ]; + self.push_event_("terminal_response", &event_data, &[], &[]); + } + Some(Union::Closed(closed)) => { + let event_data: Vec<(&str, serde_json::Value)> = vec![ + ("type", json!("closed")), + ("terminal_id", json!(closed.terminal_id)), + ("exit_code", json!(closed.exit_code)), + ]; + self.push_event_("terminal_response", &event_data, &[], &[]); + } + Some(Union::Error(error)) => { + let event_data: Vec<(&str, serde_json::Value)> = vec![ + ("type", json!("error")), + ("terminal_id", json!(error.terminal_id)), + ("message", json!(&error.message)), + ]; + self.push_event_("terminal_response", &event_data, &[], &[]); + } + None => {} + Some(_) => { + log::warn!("Unhandled terminal response type"); + } + } + } } impl FlutterHandler { @@ -1104,8 +1273,14 @@ pub fn session_add_existed( peer_id: String, session_id: SessionID, displays: Vec, + is_view_camera: bool, ) -> ResultType<()> { - sessions::insert_peer_session_id(peer_id, ConnType::DEFAULT_CONN, session_id, displays); + let conn_type = if is_view_camera { + ConnType::VIEW_CAMERA + } else { + ConnType::DEFAULT_CONN + }; + sessions::insert_peer_session_id(peer_id, conn_type, session_id, displays); Ok(()) } @@ -1115,13 +1290,16 @@ pub fn session_add_existed( /// /// * `id` - The identifier of the remote session with prefix. Regex: [\w]*[\_]*[\d]+ /// * `is_file_transfer` - If the session is used for file transfer. +/// * `is_view_camera` - If the session is used for view camera. /// * `is_port_forward` - If the session is used for port forward. pub fn session_add( session_id: &SessionID, id: &str, is_file_transfer: bool, + is_view_camera: bool, is_port_forward: bool, is_rdp: bool, + is_terminal: bool, switch_uuid: &str, force_relay: bool, password: String, @@ -1130,6 +1308,10 @@ pub fn session_add( ) -> ResultType { let conn_type = if is_file_transfer { ConnType::FILE_TRANSFER + } else if is_view_camera { + ConnType::VIEW_CAMERA + } else if is_terminal { + ConnType::TERMINAL } else if is_port_forward { if is_rdp { ConnType::RDP @@ -1165,6 +1347,7 @@ pub fn session_add( server_keyboard_enabled: Arc::new(RwLock::new(true)), server_file_transfer_enabled: Arc::new(RwLock::new(true)), server_clipboard_enabled: Arc::new(RwLock::new(true)), + reconnect_count: Arc::new(AtomicUsize::new(0)), ..Default::default() }; @@ -1250,17 +1433,36 @@ fn try_send_close_event(event_stream: &Option>) { } } -#[cfg(not(any(target_os = "android", target_os = "ios")))] +#[cfg(not(target_os = "ios"))] pub fn update_text_clipboard_required() { let is_required = sessions::get_sessions() .iter() .any(|s| s.is_text_clipboard_required()); + #[cfg(target_os = "android")] + let _ = scrap::android::ffi::call_clipboard_manager_enable_client_clipboard(is_required); Client::set_is_text_clipboard_required(is_required); } -#[cfg(not(any(target_os = "android", target_os = "ios")))] -pub fn send_text_clipboard_msg(msg: Message) { +#[cfg(feature = "unix-file-copy-paste")] +pub fn update_file_clipboard_required() { + let is_required = sessions::get_sessions() + .iter() + .any(|s| s.is_file_clipboard_required()); + Client::set_is_file_clipboard_required(is_required); +} + +#[cfg(not(target_os = "ios"))] +pub fn send_clipboard_msg(msg: Message, _is_file: bool) { for s in sessions::get_sessions() { + #[cfg(feature = "unix-file-copy-paste")] + if _is_file { + if crate::is_support_file_copy_paste_num(s.lc.read().unwrap().version) + && s.is_file_clipboard_required() + { + s.send(Data::Message(msg.clone())); + } + continue; + } if s.is_text_clipboard_required() { // Check if the client supports multi clipboards if let Some(message::Union::MultiClipboards(multi_clipboards)) = &msg.union { @@ -1798,7 +2000,7 @@ pub(super) fn session_update_virtual_display(session: &FlutterSession, index: i3 let mut vdisplays = displays.split(',').collect::>(); let len = vdisplays.len(); if index == 0 { - // 0 means we cann't toggle the virtual display by index. + // 0 means we can't toggle the virtual display by index. vdisplays.remove(vdisplays.len() - 1); } else { if let Some(i) = vdisplays.iter().position(|&x| x == index.to_string()) { @@ -1914,7 +2116,30 @@ pub mod sessions { None => {} } } - SESSIONS.write().unwrap().remove(&remove_peer_key?) + let s = SESSIONS.write().unwrap().remove(&remove_peer_key?); + #[cfg(not(any(target_os = "android", target_os = "ios")))] + update_session_count_to_server(); + s + } + + /// Check if removing a session by session_id would result in removing the entire peer. + /// + /// Returns: + /// - `true`: The session exists and removing it would leave the peer with no other sessions, + /// so the entire peer would be removed (equivalent to `remove_session_by_session_id` returning `Some`) + /// - `false`: The session doesn't exist, or it exists but the peer has other sessions, + /// so the peer would not be removed (equivalent to `remove_session_by_session_id` returning `None`) + #[inline] + pub fn would_remove_peer_by_session_id(id: &SessionID) -> bool { + for (_peer_key, s) in SESSIONS.read().unwrap().iter() { + let read_lock = s.ui_handler.session_handlers.read().unwrap(); + if read_lock.contains_key(id) { + // Found the session, check if it's the only one for this peer + return read_lock.len() == 1; + } + } + // Session not found + false } fn check_remove_unused_displays( @@ -1962,6 +2187,8 @@ pub mod sessions { // This operation will also cause the peer to send a switch display message. // The switch display message will contain `SupportedResolutions`, which is useful when changing resolutions. s.switch_display(value[0]); + // Reset the valid flag of the display. + s.next_rgba(value[0] as usize); if !is_desktop { s.capture_displays(vec![], vec![], value); @@ -2014,6 +2241,14 @@ pub mod sessions { .write() .unwrap() .insert(session_id, Default::default()); + #[cfg(not(any(target_os = "android", target_os = "ios")))] + update_session_count_to_server(); + } + + #[inline] + #[cfg(not(any(target_os = "android", target_os = "ios")))] + fn update_session_count_to_server() { + crate::ipc::update_controlling_session_count(SESSIONS.read().unwrap().len()).ok(); } #[inline] @@ -2039,6 +2274,11 @@ pub mod sessions { .write() .unwrap() .insert(session_id, h); + // If the session is a single display session, it may be a software rgba rendered display. + // If this is the second time the display is opened, the old valid flag may be true. + if displays.len() == 1 { + s.ui_handler.next_rgba(displays[0] as usize); + } true } else { false @@ -2051,7 +2291,7 @@ pub mod sessions { } #[inline] - #[cfg(not(any(target_os = "android", target_os = "ios")))] + #[cfg(not(target_os = "ios"))] pub fn has_sessions_running(conn_type: ConnType) -> bool { SESSIONS.read().unwrap().iter().any(|((_, r#type), s)| { *r#type == conn_type && s.session_handlers.read().unwrap().len() != 0 @@ -2060,11 +2300,7 @@ pub mod sessions { } pub(super) mod async_tasks { - use hbb_common::{ - bail, - tokio::{self, select}, - ResultType, - }; + use hbb_common::{bail, tokio, ResultType}; use std::{ collections::HashMap, sync::{ diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs index 7a0c5e874..4b62b4fca 100644 --- a/src/flutter_ffi.rs +++ b/src/flutter_ffi.rs @@ -1,17 +1,16 @@ +#[cfg(not(any(target_os = "android", target_os = "ios")))] +use crate::keyboard::input_source::{change_input_source, get_cur_session_input_source}; +#[cfg(target_os = "linux")] +use crate::platform::linux::is_x11; use crate::{ client::file_trait::FileManager, - common::make_fd_to_json, + common::{make_fd_to_json, make_vec_fd_to_json}, flutter::{ self, session_add, session_add_existed, session_start_, sessions, try_sync_peer_option, }, input::*, ui_interface::{self, *}, }; -#[cfg(not(any(target_os = "android", target_os = "ios")))] -use crate::{ - common::get_default_sound_input, - keyboard::input_source::{change_input_source, get_cur_session_input_source}, -}; use flutter_rust_bridge::{StreamSink, SyncReturn}; #[cfg(feature = "plugin_framework")] #[cfg(not(any(target_os = "android", target_os = "ios")))] @@ -24,11 +23,12 @@ use hbb_common::{ }; use std::{ collections::HashMap, + path::PathBuf, sync::{ atomic::{AtomicI32, Ordering}, Arc, }, - time::SystemTime, + time::{Duration, SystemTime}, }; pub type SessionID = uuid::Uuid; @@ -39,7 +39,11 @@ lazy_static::lazy_static! { fn initialize(app_dir: &str, custom_client_config: &str) { flutter::async_tasks::start_flutter_async_runner(); - *config::APP_DIR.write().unwrap() = app_dir.to_owned(); + // `APP_DIR` is set in `main_get_data_dir_ios()` on iOS. + #[cfg(not(target_os = "ios"))] + { + *config::APP_DIR.write().unwrap() = app_dir.to_owned(); + } // core_main's load_custom_client does not work for flutter since it is only applied to its load_library in main.c if custom_client_config.is_empty() { crate::load_custom_client(); @@ -66,6 +70,11 @@ fn initialize(app_dir: &str, custom_client_config: &str) { { use hbb_common::env_logger::*; init_from_env(Env::default().filter_or(DEFAULT_FILTER_ENV, "debug")); + crate::common::test_nat_type(); + } + #[cfg(any(target_os = "android", target_os = "ios"))] + { + let _ = crate::common::global_init(); } #[cfg(not(any(target_os = "android", target_os = "ios")))] { @@ -95,16 +104,30 @@ pub fn host_stop_system_key_propagate(_stopped: bool) { } // This function is only used to count the number of control sessions. -pub fn peer_get_default_sessions_count(id: String) -> SyncReturn { - SyncReturn(sessions::get_session_count(id, ConnType::DEFAULT_CONN)) +pub fn peer_get_sessions_count(id: String, conn_type: i32) -> SyncReturn { + let conn_type = if conn_type == ConnType::VIEW_CAMERA as i32 { + ConnType::VIEW_CAMERA + } else if conn_type == ConnType::FILE_TRANSFER as i32 { + ConnType::FILE_TRANSFER + } else if conn_type == ConnType::PORT_FORWARD as i32 { + ConnType::PORT_FORWARD + } else if conn_type == ConnType::RDP as i32 { + ConnType::RDP + } else if conn_type == ConnType::TERMINAL as i32 { + ConnType::TERMINAL + } else { + ConnType::DEFAULT_CONN + }; + SyncReturn(sessions::get_session_count(id, conn_type)) } pub fn session_add_existed_sync( id: String, session_id: SessionID, displays: Vec, + is_view_camera: bool, ) -> SyncReturn { - if let Err(e) = session_add_existed(id.clone(), session_id, displays) { + if let Err(e) = session_add_existed(id.clone(), session_id, displays, is_view_camera) { SyncReturn(format!("Failed to add session with id {}, {}", &id, e)) } else { SyncReturn("".to_owned()) @@ -115,26 +138,37 @@ pub fn session_add_sync( session_id: SessionID, id: String, is_file_transfer: bool, + is_view_camera: bool, is_port_forward: bool, is_rdp: bool, + is_terminal: bool, switch_uuid: String, force_relay: bool, password: String, is_shared_password: bool, conn_token: Option, ) -> SyncReturn { - if let Err(e) = session_add( + let add_res = session_add( &session_id, &id, is_file_transfer, + is_view_camera, is_port_forward, is_rdp, + is_terminal, &switch_uuid, force_relay, password, is_shared_password, conn_token, - ) { + ); + // We can't put the remove call together with `std::env::var("IS_TERMINAL_ADMIN")`. + // Because there are some `bail!` in `session_add()`, we must make sure `IS_TERMINAL_ADMIN` is removed at last. + if is_terminal { + std::env::remove_var("IS_TERMINAL_ADMIN"); + } + + if let Err(e) = add_res { SyncReturn(format!("Failed to add session with id {}, {}", &id, e)) } else { SyncReturn("".to_owned()) @@ -222,6 +256,10 @@ pub fn session_get_enable_trusted_devices(session_id: SessionID) -> SyncReturn SyncReturn { + SyncReturn(sessions::would_remove_peer_by_session_id(&session_id)) +} + pub fn session_close(session_id: SessionID) { if let Some(session) = sessions::remove_session_by_session_id(&session_id) { // `release_remote_keys` is not required for mobile platforms in common cases. @@ -239,6 +277,19 @@ pub fn session_refresh(session_id: SessionID, display: usize) { } } +pub fn session_take_screenshot(session_id: SessionID, display: usize) { + if let Some(s) = sessions::get_session_by_session_id(&session_id) { + s.take_screenshot(display as _, session_id.to_string()); + } +} + +pub fn session_handle_screenshot( + #[allow(unused_variables)] session_id: SessionID, + action: String, +) -> String { + crate::client::screenshot::handle_screenshot(action) +} + pub fn session_is_multi_ui_session(session_id: SessionID) -> SyncReturn { if let Some(session) = sessions::get_session_by_session_id(&session_id) { SyncReturn(session.is_multi_ui_session()) @@ -274,10 +325,16 @@ pub fn session_toggle_option(session_id: SessionID, value: String) { session.toggle_option(value.clone()); try_sync_peer_option(&session, &session_id, &value, None); } - #[cfg(not(any(target_os = "android", target_os = "ios")))] + #[cfg(not(target_os = "ios"))] if sessions::get_session_by_session_id(&session_id).is_some() && value == "disable-clipboard" { crate::flutter::update_text_clipboard_required(); } + #[cfg(feature = "unix-file-copy-paste")] + if sessions::get_session_by_session_id(&session_id).is_some() + && value == config::keys::OPTION_ENABLE_FILE_COPY_PASTE + { + crate::flutter::update_file_clipboard_required(); + } } pub fn session_toggle_privacy_mode(session_id: SessionID, impl_key: String, on: bool) { @@ -349,6 +406,20 @@ pub fn session_set_scroll_style(session_id: SessionID, value: String) { } } +pub fn session_get_edge_scroll_edge_thickness(session_id: SessionID) -> Option { + if let Some(session) = sessions::get_session_by_session_id(&session_id) { + Some(session.get_edge_scroll_edge_thickness()) + } else { + None + } +} + +pub fn session_set_edge_scroll_edge_thickness(session_id: SessionID, value: i32) { + if let Some(session) = sessions::get_session_by_session_id(&session_id) { + session.save_edge_scroll_edge_thickness(value); + } +} + pub fn session_get_image_quality(session_id: SessionID) -> Option { if let Some(session) = sessions::get_session_by_session_id(&session_id) { Some(session.get_image_quality()) @@ -464,6 +535,20 @@ pub fn session_set_custom_fps(session_id: SessionID, fps: i32) { } } +pub fn session_get_trackpad_speed(session_id: SessionID) -> Option { + if let Some(session) = sessions::get_session_by_session_id(&session_id) { + Some(session.get_trackpad_speed()) + } else { + None + } +} + +pub fn session_set_trackpad_speed(session_id: SessionID, value: i32) { + if let Some(session) = sessions::get_session_by_session_id(&session_id) { + session.save_trackpad_speed(value); + } +} + pub fn session_lock_screen(session_id: SessionID) { if let Some(session) = sessions::get_session_by_session_id(&session_id) { session.lock_screen(); @@ -520,21 +605,30 @@ pub fn session_handle_flutter_raw_key_event( } } -// SyncReturn<()> is used to make sure enter() and leave() are executed in the sequence this function is called. -// // If the cursor jumps between remote page of two connections, leave view and enter view will be called. // session_enter_or_leave() will be called then. -// As rust is multi-thread, it is possible that enter() is called before leave(). -// This will cause the keyboard input to take no effect. +// As Rust is multi-threaded, enter() can be called before leave(). +// The Rust-side grab ownership state filters stale transitions. pub fn session_enter_or_leave(_session_id: SessionID, _enter: bool) -> SyncReturn<()> { #[cfg(not(any(target_os = "android", target_os = "ios")))] if let Some(session) = sessions::get_session_by_session_id(&_session_id) { let keyboard_mode = session.get_keyboard_mode(); + // Use the full per-window UUID (not lc.session_id which is per-connection) + // so that two windows viewing the same peer get distinct grab owners. + let window_id = _session_id.as_u128(); if _enter { set_cur_session_id_(_session_id, &keyboard_mode); - session.enter(keyboard_mode); + crate::keyboard::client::change_grab_status( + crate::common::GrabState::Run, + &keyboard_mode, + window_id, + ); } else { - session.leave(keyboard_mode); + crate::keyboard::client::change_grab_status( + crate::common::GrabState::Wait, + &keyboard_mode, + window_id, + ); } } SyncReturn(()) @@ -570,6 +664,36 @@ pub fn session_send_chat(session_id: SessionID, text: String) { } } +// Terminal functions +pub fn session_open_terminal(session_id: SessionID, terminal_id: i32, rows: u32, cols: u32) { + if let Some(session) = sessions::get_session_by_session_id(&session_id) { + session.open_terminal(terminal_id, rows, cols); + } else { + log::error!( + "[flutter_ffi] Session not found for session_id: {}", + session_id + ); + } +} + +pub fn session_send_terminal_input(session_id: SessionID, terminal_id: i32, data: String) { + if let Some(session) = sessions::get_session_by_session_id(&session_id) { + session.send_terminal_input(terminal_id, data); + } +} + +pub fn session_resize_terminal(session_id: SessionID, terminal_id: i32, rows: u32, cols: u32) { + if let Some(session) = sessions::get_session_by_session_id(&session_id) { + session.resize_terminal(terminal_id, rows, cols); + } +} + +pub fn session_close_terminal(session_id: SessionID, terminal_id: i32) { + if let Some(session) = sessions::get_session_by_session_id(&session_id) { + session.close_terminal(terminal_id); + } +} + pub fn session_peer_option(session_id: SessionID, name: String, value: String) { if let Some(session) = sessions::get_session_by_session_id(&session_id) { session.set_option(name, value); @@ -607,7 +731,15 @@ pub fn session_send_files( _is_dir: bool, ) { if let Some(session) = sessions::get_session_by_session_id(&session_id) { - session.send_files(act_id, path, to, file_num, include_hidden, is_remote); + session.send_files( + act_id, + fs::JobType::Generic.into(), + path, + to, + file_num, + include_hidden, + is_remote, + ); } } @@ -682,6 +814,27 @@ pub fn session_read_local_dir_sync( "".to_string() } +pub fn session_read_local_empty_dirs_recursive_sync( + _session_id: SessionID, + path: String, + include_hidden: bool, +) -> String { + if let Ok(fds) = fs::get_empty_dirs_recursive(&path, include_hidden) { + return make_vec_fd_to_json(&fds); + } + "".to_string() +} + +pub fn session_read_remote_empty_dirs_recursive_sync( + session_id: SessionID, + path: String, + include_hidden: bool, +) { + if let Some(session) = sessions::get_session_by_session_id(&session_id) { + session.read_empty_dirs(path, include_hidden); + } +} + pub fn session_get_platform(session_id: SessionID, is_remote: bool) -> String { if let Some(session) = sessions::get_session_by_session_id(&session_id) { return session.get_platform(is_remote); @@ -711,7 +864,15 @@ pub fn session_add_job( is_remote: bool, ) { if let Some(session) = sessions::get_session_by_session_id(&session_id) { - session.add_job(act_id, path, to, file_num, include_hidden, is_remote); + session.add_job( + act_id, + fs::JobType::Generic.into(), + path, + to, + file_num, + include_hidden, + is_remote, + ); } } @@ -774,13 +935,6 @@ pub fn main_get_sound_inputs() -> Vec { vec![String::from("")] } -pub fn main_get_default_sound_input() -> Option { - #[cfg(not(any(target_os = "android", target_os = "ios")))] - return get_default_sound_input(); - #[cfg(any(target_os = "android", target_os = "ios"))] - None -} - pub fn main_get_login_device_info() -> SyncReturn { SyncReturn(get_login_device_info_json()) } @@ -819,13 +973,54 @@ pub fn main_show_option(_key: String) -> SyncReturn { pub fn main_set_option(key: String, value: String) { #[cfg(target_os = "android")] - if key.eq(config::keys::OPTION_ENABLE_KEYBOARD) { - crate::ui_cm_interface::notify_input_control(config::option2bool( - config::keys::OPTION_ENABLE_KEYBOARD, - &value, - )); + { + let is_permission_option = key.eq(config::keys::OPTION_ENABLE_CLIPBOARD) + || key.eq(config::keys::OPTION_ENABLE_FILE_TRANSFER) + || key.eq(config::keys::OPTION_ENABLE_AUDIO); + let allow_perm_change_in_accept_window = config::option2bool( + config::keys::OPTION_ENABLE_PERM_CHANGE_IN_ACCEPT_WINDOW, + &crate::get_builtin_option(config::keys::OPTION_ENABLE_PERM_CHANGE_IN_ACCEPT_WINDOW), + ); + if is_permission_option + && !allow_perm_change_in_accept_window + && crate::ui_cm_interface::has_active_clients() + { + log::info!( + "blocked main_set_option by policy, key={}, value={}", + key, + value + ); + return; + } } - if key.eq("custom-rendezvous-server") { + #[cfg(target_os = "android")] + if key.eq(config::keys::OPTION_ENABLE_KEYBOARD) { + crate::ui_cm_interface::switch_permission_all( + "keyboard".to_owned(), + config::option2bool(&key, &value), + ); + } + #[cfg(target_os = "android")] + if key.eq(config::keys::OPTION_ENABLE_CLIPBOARD) { + crate::ui_cm_interface::switch_permission_all( + "clipboard".to_owned(), + config::option2bool(&key, &value), + ); + } + + // If `is_allow_tls_fallback` and https proxy is used, we need to restart rendezvous mediator. + // No need to check if https proxy is used, because this option does not change frequently + // and restarting mediator is safe even https proxy is not used. + let is_allow_tls_fallback = key.eq(config::keys::OPTION_ALLOW_INSECURE_TLS_FALLBACK); + if is_allow_tls_fallback + || key.eq("custom-rendezvous-server") + || key.eq(config::keys::OPTION_ALLOW_WEBSOCKET) + || key.eq(config::keys::OPTION_DISABLE_UDP) + || key.eq("api-server") + { + if is_allow_tls_fallback { + hbb_common::tls::reset_tls_cache(); + } set_option(key, value.clone()); #[cfg(target_os = "android")] crate::rendezvous_mediator::RendezvousMediator::restart(); @@ -845,7 +1040,29 @@ pub fn main_get_options_sync() -> SyncReturn { } pub fn main_set_options(json: String) { - let map: HashMap = serde_json::from_str(&json).unwrap_or(HashMap::new()); + let mut map: HashMap = serde_json::from_str(&json).unwrap_or(HashMap::new()); + #[cfg(target_os = "android")] + { + let allow_perm_change_in_accept_window = config::option2bool( + config::keys::OPTION_ENABLE_PERM_CHANGE_IN_ACCEPT_WINDOW, + &crate::get_builtin_option(config::keys::OPTION_ENABLE_PERM_CHANGE_IN_ACCEPT_WINDOW), + ); + if !allow_perm_change_in_accept_window && crate::ui_cm_interface::has_active_clients() { + for key in [ + config::keys::OPTION_ENABLE_CLIPBOARD, + config::keys::OPTION_ENABLE_FILE_TRANSFER, + config::keys::OPTION_ENABLE_AUDIO, + ] { + if let Some(value) = map.remove(key) { + log::info!( + "blocked main_set_options item by policy, key={}, value={}", + key, + value + ); + } + } + } + } if !map.is_empty() { set_options(map) } @@ -936,6 +1153,10 @@ pub fn main_get_api_server() -> String { get_api_server() } +pub fn main_resolve_avatar_url(avatar: String) -> SyncReturn { + SyncReturn(resolve_avatar_url(avatar)) +} + pub fn main_http_request(url: String, method: String, body: Option, header: String) { http_request(url, method, body, header) } @@ -952,8 +1173,38 @@ pub fn main_get_env(key: String) -> SyncReturn { SyncReturn(std::env::var(key).unwrap_or_default()) } +// Dart does not support changing environment variables. +// `Platform.environment['MY_VAR'] = 'VAR';` will throw an error +// `Unsupported operation: Cannot modify unmodifiable map`. +// +// And we need to share the environment variables between rust and dart isolates sometimes. +pub fn main_set_env(key: String, value: Option) -> SyncReturn<()> { + let is_valid_key = !key.is_empty() && !key.contains('=') && !key.contains('\0'); + debug_assert!(is_valid_key, "Invalid environment variable key: {}", key); + if !is_valid_key { + log::error!("Invalid environment variable key: {}", key); + return SyncReturn(()); + } + + match value { + Some(v) => { + let is_valid_value = !v.contains('\0'); + debug_assert!(is_valid_value, "Invalid environment variable value: {}", v); + if !is_valid_value { + log::error!("Invalid environment variable value: {}", v); + return SyncReturn(()); + } + std::env::set_var(key, v); + } + None => std::env::remove_var(key), + } + + SyncReturn(()) +} + pub fn main_set_local_option(key: String, value: String) { let is_texture_render_key = key.eq(config::keys::OPTION_TEXTURE_RENDER); + let is_d3d_render_key = key.eq(config::keys::OPTION_ALLOW_D3D_RENDER); set_local_option(key, value.clone()); if is_texture_render_key { let session_event = [("v", &value)]; @@ -963,6 +1214,11 @@ pub fn main_set_local_option(key: String, value: String) { session.ui_handler.update_use_texture_render(); } } + if is_d3d_render_key { + for session in sessions::get_sessions() { + session.update_supported_decodings(); + } + } } // We do use use `main_get_local_option` and `main_set_local_option`. @@ -1015,6 +1271,66 @@ pub fn main_set_input_source(session_id: SessionID, value: String) { } } +/// Set cursor position (for pointer lock re-centering). +/// +/// # Returns +/// - `true`: cursor position was successfully set +/// - `false`: operation failed or not supported +/// +/// # Platform behavior +/// - Windows/macOS/Linux: attempts to move the cursor to (x, y) +/// - Android/iOS: no-op, always returns `false` +pub fn main_set_cursor_position(x: i32, y: i32) -> SyncReturn { + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + SyncReturn(crate::set_cursor_pos(x, y)) + } + #[cfg(any(target_os = "android", target_os = "ios"))] + { + let _ = (x, y); + SyncReturn(false) + } +} + +/// Clip cursor to a rectangle (for pointer lock). +/// +/// When `enable` is true, the cursor is clipped to the rectangle defined by +/// `left`, `top`, `right`, `bottom`. When `enable` is false, the rectangle +/// values are ignored and the cursor is unclipped. +/// +/// # Returns +/// - `true`: operation succeeded or no-op completed +/// - `false`: operation failed +/// +/// # Platform behavior +/// - Windows: uses ClipCursor API to confine cursor to the specified rectangle +/// - macOS: uses CGAssociateMouseAndMouseCursorPosition for pointer lock effect; +/// the rect coordinates are ignored (only Some/None matters) +/// - Linux: no-op, always returns `true`; use pointer warping for similar effect +/// - Android/iOS: no-op, always returns `false` +pub fn main_clip_cursor( + left: i32, + top: i32, + right: i32, + bottom: i32, + enable: bool, +) -> SyncReturn { + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + let rect = if enable { + Some((left, top, right, bottom)) + } else { + None + }; + SyncReturn(crate::clip_cursor(rect)) + } + #[cfg(any(target_os = "android", target_os = "ios"))] + { + let _ = (left, top, right, bottom, enable); + SyncReturn(false) + } +} + pub fn main_get_my_id() -> String { get_id() } @@ -1076,55 +1392,76 @@ pub fn main_peer_exists(id: String) -> bool { peer_exists(&id) } -pub fn main_load_recent_peers() { - if !config::APP_DIR.read().unwrap().is_empty() { - let peers: Vec> = PeerConfig::peers(None) - .drain(..) - .map(|(id, _, p)| peer_to_map(id, p)) - .collect(); +fn load_recent_peers( + vec_id_modified_time_path: &Vec<(String, SystemTime, std::path::PathBuf)>, + to_end: bool, + all_peers: &mut Vec>, + from: usize, +) -> usize { + let to = if to_end { + Some(vec_id_modified_time_path.len()) + } else { + None + }; + let mut peers_next = PeerConfig::batch_peers(vec_id_modified_time_path, from, to); + // There may be less peers than the batch size. + // But no need to consider this case, because it is a rare case. + let peers = peers_next.0.drain(..).map(|(id, _, p)| peer_to_map(id, p)); + all_peers.extend(peers); + peers_next.1 +} - let data = HashMap::from([ - ("name", "load_recent_peers".to_owned()), - ( - "peers", - serde_json::ser::to_string(&peers).unwrap_or("".to_owned()), - ), - ]); +pub fn main_load_recent_peers() { + let push_to_flutter = |peers, ids| { + let mut data = HashMap::from([("name", "load_recent_peers".to_owned()), ("peers", peers)]); + if let Some(ids) = ids { + data.insert("ids", ids); + } let _res = flutter::push_global_event( flutter::APP_TYPE_MAIN, serde_json::ser::to_string(&data).unwrap_or("".to_owned()), ); - } -} + }; -pub fn main_load_recent_peers_sync() -> SyncReturn { if !config::APP_DIR.read().unwrap().is_empty() { - let peers: Vec> = PeerConfig::peers(None) - .drain(..) - .map(|(id, _, p)| peer_to_map(id, p)) - .collect(); + let vec_id_modified_time_path = PeerConfig::get_vec_id_modified_time_path(&None); + if vec_id_modified_time_path.is_empty() { + push_to_flutter("".to_owned(), None); + return; + } - let data = HashMap::from([ - ("name", "load_recent_peers".to_owned()), - ( - "peers", - serde_json::ser::to_string(&peers).unwrap_or("".to_owned()), - ), - ]); - return SyncReturn(serde_json::ser::to_string(&data).unwrap_or("".to_owned())); + let load_two_times = vec_id_modified_time_path.len() > PeerConfig::BATCH_LOADING_COUNT + && cfg!(target_os = "windows"); + let mut all_peers = vec![]; + if load_two_times { + let next_from = load_recent_peers(&vec_id_modified_time_path, false, &mut all_peers, 0); + let rest_ids = if next_from < vec_id_modified_time_path.len() { + Some( + vec_id_modified_time_path[next_from..] + .iter() + .map(|(id, _, _)| id.clone()) + .collect::>() + .join(", "), + ) + } else { + None + }; + push_to_flutter( + serde_json::ser::to_string(&all_peers).unwrap_or("".to_owned()), + rest_ids, + ); + let _ = load_recent_peers(&vec_id_modified_time_path, true, &mut all_peers, next_from); + } else { + let _ = load_recent_peers(&vec_id_modified_time_path, true, &mut all_peers, 0); + } + // Don't check if `all_peers` is empty, because we need this message to update the state in the flutter side. + push_to_flutter( + serde_json::ser::to_string(&all_peers).unwrap_or("".to_owned()), + None, + ); + } else { + push_to_flutter("".to_owned(), None) } - SyncReturn("".to_string()) -} - -pub fn main_load_lan_peers_sync() -> SyncReturn { - let data = HashMap::from([ - ("name", "load_lan_peers".to_owned()), - ( - "peers", - serde_json::to_string(&get_lan_peers()).unwrap_or_default(), - ), - ]); - return SyncReturn(serde_json::ser::to_string(&data).unwrap_or("".to_owned())); } pub fn main_load_recent_peers_for_ab(filter: String) -> String { @@ -1145,13 +1482,20 @@ pub fn main_load_recent_peers_for_ab(filter: String) -> String { } pub fn main_load_fav_peers() { + let push_to_flutter = |peers| { + let data = HashMap::from([("name", "load_fav_peers".to_owned()), ("peers", peers)]); + let _res = flutter::push_global_event( + flutter::APP_TYPE_MAIN, + serde_json::ser::to_string(&data).unwrap_or("".to_owned()), + ); + }; if !config::APP_DIR.read().unwrap().is_empty() { let favs = get_fav(); - let mut recent = PeerConfig::peers(None); + let mut recent = PeerConfig::peers(Some(favs.clone())); let mut lan = config::LanPeers::load() .peers .iter() - .filter(|d| recent.iter().all(|r| r.0 != d.id)) + .filter(|d| favs.contains(&d.id) && recent.iter().all(|r| r.0 != d.id)) .map(|d| { ( d.id.clone(), @@ -1170,26 +1514,12 @@ pub fn main_load_fav_peers() { recent.append(&mut lan); let peers: Vec> = recent .into_iter() - .filter_map(|(id, _, p)| { - if favs.contains(&id) { - Some(peer_to_map(id, p)) - } else { - None - } - }) + .map(|(id, _, p)| peer_to_map(id, p)) .collect(); - let data = HashMap::from([ - ("name", "load_fav_peers".to_owned()), - ( - "peers", - serde_json::ser::to_string(&peers).unwrap_or("".to_owned()), - ), - ]); - let _res = flutter::push_global_event( - flutter::APP_TYPE_MAIN, - serde_json::ser::to_string(&data).unwrap_or("".to_owned()), - ); + push_to_flutter(serde_json::ser::to_string(&peers).unwrap_or("".to_owned())); + } else { + push_to_flutter("".to_owned()); } } @@ -1250,20 +1580,7 @@ pub fn main_handle_relay_id(id: String) -> String { } pub fn main_is_option_fixed(key: String) -> SyncReturn { - SyncReturn( - config::OVERWRITE_DISPLAY_SETTINGS - .read() - .unwrap() - .contains_key(&key) - || config::OVERWRITE_LOCAL_SETTINGS - .read() - .unwrap() - .contains_key(&key) - || config::OVERWRITE_SETTINGS - .read() - .unwrap() - .contains_key(&key), - ) + SyncReturn(is_option_fixed(&key)) } pub fn main_get_main_display() -> SyncReturn { @@ -1272,19 +1589,45 @@ pub fn main_get_main_display() -> SyncReturn { #[cfg(not(target_os = "ios"))] let mut display_info = "".to_owned(); #[cfg(not(target_os = "ios"))] - if let Ok(displays) = crate::display_service::try_get_displays() { - // to-do: Need to detect current display index. - if let Some(display) = displays.iter().next() { - display_info = serde_json::to_string(&HashMap::from([ - ("w", display.width()), - ("h", display.height()), - ])) - .unwrap_or_default(); + { + #[cfg(not(target_os = "linux"))] + let is_linux_wayland = false; + #[cfg(target_os = "linux")] + let is_linux_wayland = !is_x11(); + + if !is_linux_wayland { + if let Ok(displays) = crate::display_service::try_get_displays() { + // to-do: Need to detect current display index. + if let Some(display) = displays.iter().next() { + display_info = serde_json::to_string(&HashMap::from([ + ("w", display.width()), + ("h", display.height()), + ])) + .unwrap_or_default(); + } + } + } + + #[cfg(target_os = "linux")] + if is_linux_wayland { + let displays = scrap::wayland::display::get_displays(); + if let Some(display) = displays.displays.get(displays.primary) { + let logical_size = display + .logical_size + .unwrap_or((display.width, display.height)); + display_info = serde_json::to_string(&HashMap::from([ + ("w", logical_size.0), + ("h", logical_size.1), + ])) + .unwrap_or_default(); + } } } SyncReturn(display_info) } +// No need to check if is on Wayland in this function. +// The Flutter side gets display information on Wayland using a different method. pub fn main_get_displays() -> SyncReturn { #[cfg(target_os = "ios")] let display_info = "".to_owned(); @@ -1387,9 +1730,7 @@ pub fn main_get_last_remote_id() -> String { } pub fn main_get_software_update_url() { - if get_local_option("enable-check-update".to_string()) != "N" { - crate::common::check_software_update(); - } + crate::common::check_software_update(); } pub fn main_get_home_dir() -> String { @@ -1404,8 +1745,8 @@ pub fn main_get_temporary_password() -> String { ui_interface::temporary_password() } -pub fn main_get_permanent_password() -> String { - ui_interface::permanent_password() +pub fn main_set_permanent_password_with_result(password: String) -> bool { + ui_interface::set_permanent_password_with_result(password) } pub fn main_get_fingerprint() -> String { @@ -1523,8 +1864,99 @@ pub fn session_send_pointer(session_id: SessionID, msg: String) { super::flutter::session_send_pointer(session_id, msg); } +/// Send mouse event from Flutter to the remote peer. +/// +/// # Relative Mouse Mode Message Contract +/// +/// When the message contains a `relative_mouse_mode` field, this function validates +/// and filters activation/deactivation markers. +/// +/// **Mode Authority:** +/// The Flutter InputModel is authoritative for relative mouse mode activation/deactivation. +/// The server (via `input_service.rs`) only consumes forwarded delta movements and tracks +/// relative movement processing state, but does NOT control mode activation/deactivation. +/// +/// **Deactivation Markers are Local-Only:** +/// Deactivation markers (`relative_mouse_mode: "0"`) are NEVER forwarded to the server. +/// They are handled entirely on the client side to reset local UI state (cursor visibility, +/// pointer lock, etc.). The server does not rely on deactivation markers and should not +/// expect to receive them. +/// +/// **Contract (Flutter side MUST adhere to):** +/// 1. `relative_mouse_mode` field is ONLY present on activation/deactivation marker messages, +/// NEVER on normal pointer events (move, button, scroll). +/// 2. Deactivation marker: `{"relative_mouse_mode": "0"}` - local-only, never forwarded. +/// 3. Activation marker: `{"relative_mouse_mode": "1", "type": "move_relative", "x": "0", "y": "0"}` +/// - MUST use `type="move_relative"` with `x="0"` and `y="0"` (safe no-op). +/// - Any other combination is dropped to prevent accidental cursor movement. +/// +/// If these assumptions are violated (e.g., `relative_mouse_mode` is added to normal events), +/// legitimate mouse events may be silently dropped by the early-return logic below. pub fn session_send_mouse(session_id: SessionID, msg: String) { if let Ok(m) = serde_json::from_str::>(&msg) { + // Relative mouse mode marker validation (Flutter-only). + // This only validates and filters markers; the server tracks per-connection + // relative-movement processing state but not mode activation/deactivation. + // See doc comment above for the message contract. + if let Some(v) = m.get("relative_mouse_mode") { + let active = matches!(v.as_str(), "1" | "Y" | "on"); + + // Disable marker: local-only, never forwarded to the server. + // The server does not track mode deactivation; it simply stops receiving + // relative move events when the client exits relative mouse mode. + if !active { + #[cfg(not(any(target_os = "android", target_os = "ios")))] + crate::keyboard::set_relative_mouse_mode_state(false); + return; + } + + // Enable marker: validate BEFORE setting state to avoid desync. + // This ensures we only mark as active if the marker will actually be forwarded. + + // Enable marker is allowed to go through only if it's a safe no-op relative move. + // This avoids accidentally moving the remote cursor (e.g. if type/x/y are missing). + let msg_type = m.get("type").map(|t| t.as_str()); + if msg_type != Some("move_relative") { + log::warn!( + "relative_mouse_mode activation marker has invalid type: {:?}, expected 'move_relative'. Dropping.", + msg_type + ); + return; + } + let x_marker = m + .get("x") + .map(|x| x.parse::().unwrap_or(0)) + .unwrap_or(0); + let y_marker = m + .get("y") + .map(|y| y.parse::().unwrap_or(0)) + .unwrap_or(0); + if x_marker != 0 || y_marker != 0 { + log::warn!( + "relative_mouse_mode activation marker has non-zero coordinates: x={}, y={}. Dropping.", + x_marker, y_marker + ); + return; + } + + // Guard against unexpected fields that could turn this no-op into a real event. + if m.contains_key("buttons") + || m.contains_key("alt") + || m.contains_key("ctrl") + || m.contains_key("shift") + || m.contains_key("command") + { + log::warn!( + "relative_mouse_mode activation marker contains unexpected fields (buttons/alt/ctrl/shift/command). Dropping." + ); + return; + } + + // All validation passed - marker will be forwarded as a no-op relative move. + #[cfg(not(any(target_os = "android", target_os = "ios")))] + crate::keyboard::set_relative_mouse_mode_state(true); + } + let alt = m.get("alt").is_some(); let ctrl = m.get("ctrl").is_some(); let shift = m.get("shift").is_some(); @@ -1544,6 +1976,7 @@ pub fn session_send_mouse(session_id: SessionID, msg: String) { "up" => MOUSE_TYPE_UP, "wheel" => MOUSE_TYPE_WHEEL, "trackpad" => MOUSE_TYPE_TRACKPAD, + "move_relative" => MOUSE_TYPE_MOVE_RELATIVE, _ => 0, }; } @@ -1584,6 +2017,36 @@ pub fn session_send_note(session_id: SessionID, note: String) { } } +pub fn session_get_last_audit_note(session_id: SessionID) -> SyncReturn { + if let Some(session) = sessions::get_session_by_session_id(&session_id) { + SyncReturn(session.last_audit_note.lock().unwrap().clone()) + } else { + SyncReturn("".to_owned()) + } +} + +pub fn session_set_audit_guid(session_id: SessionID, guid: String) { + if let Some(session) = sessions::get_session_by_session_id(&session_id) { + *session.audit_guid.lock().unwrap() = guid; + } +} + +pub fn session_get_audit_guid(session_id: SessionID) -> SyncReturn { + if let Some(session) = sessions::get_session_by_session_id(&session_id) { + SyncReturn(session.audit_guid.lock().unwrap().clone()) + } else { + SyncReturn("".to_owned()) + } +} + +pub fn session_get_conn_session_id(session_id: SessionID) -> SyncReturn { + if let Some(session) = sessions::get_session_by_session_id(&session_id) { + SyncReturn(session.lc.read().unwrap().session_id.to_string()) + } else { + SyncReturn("".to_owned()) + } +} + pub fn session_alternative_codecs(session_id: SessionID) -> String { if let Some(session) = sessions::get_session_by_session_id(&session_id) { let (vp8, av1, h264, h265) = session.alternative_codecs(); @@ -1596,7 +2059,7 @@ pub fn session_alternative_codecs(session_id: SessionID) -> String { pub fn session_change_prefer_codec(session_id: SessionID) { if let Some(session) = sessions::get_session_by_session_id(&session_id) { - session.change_prefer_codec(); + session.update_supported_decodings(); } } @@ -1611,6 +2074,17 @@ pub fn session_toggle_virtual_display(session_id: SessionID, index: i32, on: boo } } +pub fn session_printer_response( + session_id: SessionID, + id: i32, + path: String, + printer_name: String, +) { + if let Some(session) = sessions::get_session_by_session_id(&session_id) { + session.printer_response(id, path, printer_name); + } +} + pub fn main_set_home_dir(_home: String) { #[cfg(any(target_os = "android", target_os = "ios"))] { @@ -1619,7 +2093,8 @@ pub fn main_set_home_dir(_home: String) { } // This is a temporary method to get data dir for ios -pub fn main_get_data_dir_ios() -> SyncReturn { +pub fn main_get_data_dir_ios(app_dir: String) -> SyncReturn { + *config::APP_DIR.write().unwrap() = app_dir; let data_dir = config::Config::path("data"); if !data_dir.exists() { if let Err(e) = std::fs::create_dir_all(&data_dir) { @@ -1649,10 +2124,6 @@ pub fn main_update_temporary_password() { update_temporary_password(); } -pub fn main_set_permanent_password(password: String) { - set_permanent_password(password); -} - pub fn main_check_super_user_permission() -> bool { check_super_user_permission() } @@ -1742,7 +2213,7 @@ pub fn cm_elevate_portable(conn_id: i32) { } pub fn cm_switch_back(conn_id: i32) { - #[cfg(not(any(target_os = "ios")))] + #[cfg(not(any(target_os = "android", target_os = "ios")))] crate::ui_cm_interface::switch_back(conn_id); } @@ -1928,13 +2399,7 @@ pub fn main_hide_dock() -> SyncReturn { } pub fn main_has_file_clipboard() -> SyncReturn { - let ret = cfg!(any( - target_os = "windows", - all( - feature = "unix-file-copy-paste", - any(target_os = "linux", target_os = "macos") - ) - )); + let ret = cfg!(any(target_os = "windows", feature = "unix-file-copy-paste",)); SyncReturn(ret) } @@ -1981,7 +2446,7 @@ pub fn is_outgoing_only() -> SyncReturn { } pub fn is_custom_client() -> SyncReturn { - SyncReturn(get_app_name() != "RustDesk") + SyncReturn(crate::common::is_custom_client()) } pub fn is_disable_settings() -> SyncReturn { @@ -2006,16 +2471,23 @@ pub fn is_disable_installation() -> SyncReturn { } pub fn is_preset_password() -> bool { - config::HARD_SETTINGS + let hard = config::HARD_SETTINGS .read() .unwrap() .get("password") - .map_or(false, |p| { - #[cfg(not(any(target_os = "android", target_os = "ios")))] - return p == &crate::ipc::get_permanent_password(); - #[cfg(any(target_os = "android", target_os = "ios"))] - return p == &config::Config::get_permanent_password(); - }) + .cloned() + .unwrap_or_default(); + if hard.is_empty() { + return false; + } + + // On desktop, service owns the authoritative config; query it via IPC and return only a boolean. + #[cfg(not(any(target_os = "android", target_os = "ios")))] + return crate::ipc::is_permanent_password_preset(); + + // On mobile, we have no service IPC; verify against local storage. + #[cfg(any(target_os = "android", target_os = "ios"))] + return config::Config::matches_permanent_password_plain(&hard); } // Don't call this function for desktop version. @@ -2301,13 +2773,268 @@ pub fn session_request_new_display_init_msgs(session_id: SessionID, display: usi } } +pub fn main_audio_support_loopback() -> SyncReturn { + #[cfg(target_os = "windows")] + let is_surpport = true; + #[cfg(feature = "screencapturekit")] + let is_surpport = crate::audio_service::is_screen_capture_kit_available(); + #[cfg(not(any(target_os = "windows", feature = "screencapturekit")))] + let is_surpport = false; + SyncReturn(is_surpport) +} + +pub fn main_get_printer_names() -> SyncReturn { + #[cfg(target_os = "windows")] + return SyncReturn( + serde_json::to_string(&crate::platform::windows::get_printer_names().unwrap_or_default()) + .unwrap_or_default(), + ); + #[cfg(not(target_os = "windows"))] + return SyncReturn("".to_owned()); +} + +pub fn main_get_common(key: String) -> String { + if key == "is-printer-installed" { + #[cfg(target_os = "windows")] + { + return match remote_printer::is_rd_printer_installed(&get_app_name()) { + Ok(r) => r.to_string(), + Err(e) => e.to_string(), + }; + } + #[cfg(not(target_os = "windows"))] + return false.to_string(); + } else if key == "is-support-printer-driver" { + #[cfg(target_os = "windows")] + return crate::platform::is_win_10_or_greater().to_string(); + #[cfg(not(target_os = "windows"))] + return false.to_string(); + } else if key == "transfer-job-id" { + return hbb_common::fs::get_next_job_id().to_string(); + } else if key == "is-remote-modify-enabled-by-control-permissions" { + return match is_remote_modify_enabled_by_control_permissions() { + Some(true) => "true", + Some(false) => "false", + None => "", + } + .to_string(); + } else if key == "has-gnome-shortcuts-inhibitor-permission" { + #[cfg(target_os = "linux")] + return crate::platform::linux::has_gnome_shortcuts_inhibitor_permission().to_string(); + #[cfg(not(target_os = "linux"))] + return false.to_string(); + } else if key == "permanent-password-set" { + return ui_interface::is_permanent_password_set().to_string(); + } else if key == "local-permanent-password-set" { + return ui_interface::is_local_permanent_password_set().to_string(); + } else { + if key.starts_with("download-data-") { + let id = key.replace("download-data-", ""); + match crate::hbbs_http::downloader::get_download_data(&id) { + Ok(data) => serde_json::to_string(&data).unwrap_or_default(), + Err(e) => { + format!("error:{}", e) + } + } + } else if key.starts_with("download-file-") { + let _version = key.replace("download-file-", ""); + #[cfg(target_os = "windows")] + return match ( + crate::platform::windows::is_msi_installed(), + crate::common::is_custom_client(), + ) { + (Ok(true), false) => format!("rustdesk-{_version}-x86_64.msi"), + (Ok(true), true) | (Ok(false), _) => format!("rustdesk-{_version}-x86_64.exe"), + (Err(e), _) => { + log::error!("Failed to check if is msi: {}", e); + format!("error:update-failed-check-msi-tip") + } + }; + #[cfg(target_os = "macos")] + { + return if cfg!(target_arch = "x86_64") { + format!("rustdesk-{_version}-x86_64.dmg") + } else if cfg!(target_arch = "aarch64") { + format!("rustdesk-{_version}-aarch64.dmg") + } else { + "error:unsupported".to_owned() + }; + } + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + { + "error:unsupported".to_owned() + } + } else { + "".to_owned() + } + } +} + +pub fn main_get_common_sync(key: String) -> SyncReturn { + SyncReturn(main_get_common(key)) +} + +pub fn main_set_common(_key: String, _value: String) { + #[cfg(target_os = "windows")] + if _key == "install-printer" && crate::platform::is_win_10_or_greater() { + std::thread::spawn(move || { + let (success, msg) = match remote_printer::install_update_printer(&get_app_name()) { + Ok(_) => (true, "".to_owned()), + Err(e) => { + let err = e.to_string(); + log::error!("Failed to install/update rd printer: {}", &err); + (false, err) + } + }; + if success { + // Use `ipc` to notify the server process to update the install option in the registry. + // Because `install_update_printer()` may prompt for permissions, there is no need to prompt again here. + if let Err(e) = crate::ipc::set_install_option( + crate::platform::REG_NAME_INSTALL_PRINTER.to_string(), + "1".to_string(), + ) { + log::error!("Failed to set install printer option: {}", e); + } + } + let data = HashMap::from([ + ("name", serde_json::json!("install-printer-res")), + ("success", serde_json::json!(success)), + ("msg", serde_json::json!(msg)), + ]); + let _res = flutter::push_global_event( + flutter::APP_TYPE_MAIN, + serde_json::ser::to_string(&data).unwrap_or("".to_owned()), + ); + }); + } + #[cfg(any(target_os = "windows", target_os = "macos"))] + { + use crate::updater::get_download_file_from_url; + if _key == "download-new-version" { + let download_url = _value.clone(); + let event_key = "download-new-version".to_owned(); + let data = if let Some(download_file) = get_download_file_from_url(&download_url) { + std::fs::remove_file(&download_file).ok(); + match crate::hbbs_http::downloader::download_file( + download_url, + Some(PathBuf::from(download_file)), + Some(Duration::from_secs(3)), + ) { + Ok(id) => HashMap::from([("name", event_key), ("id", id)]), + Err(e) => HashMap::from([("name", event_key), ("error", e.to_string())]), + } + } else { + HashMap::from([ + ("name", event_key), + ("error", "Invalid download url".to_string()), + ]) + }; + let _res = flutter::push_global_event( + flutter::APP_TYPE_MAIN, + serde_json::ser::to_string(&data).unwrap_or("".to_owned()), + ); + } else if _key == "update-me" { + if let Some(new_version_file) = get_download_file_from_url(&_value) { + log::debug!( + "New version file is downloaded, update begin, {:?}", + new_version_file.to_str() + ); + if let Some(f) = new_version_file.to_str() { + // 1.4.0 does not support "--update" + // But we can assume that the new version supports it. + + #[cfg(any(target_os = "windows", target_os = "macos"))] + match crate::platform::update_to(f) { + Ok(_) => { + log::info!("Update process is launched successfully!"); + } + Err(e) => { + log::error!("Failed to update to new version, {}", e); + fs::remove_file(f).ok(); + } + } + } + } + } else if _key == "extract-update-dmg" { + #[cfg(target_os = "macos")] + { + if let Some(new_version_file) = get_download_file_from_url(&_value) { + if let Some(f) = new_version_file.to_str() { + crate::platform::macos::extract_update_dmg(f); + } else { + // unreachable!() + log::error!("Failed to get the new version file path"); + } + } else { + // unreachable!() + log::error!("Failed to get the new version file from url: {}", _value); + } + } + } + } + + if _key == "remove-downloader" { + crate::hbbs_http::downloader::remove(&_value); + } else if _key == "cancel-downloader" { + crate::hbbs_http::downloader::cancel(&_value); + } + + #[cfg(target_os = "linux")] + if _key == "clear-gnome-shortcuts-inhibitor-permission" { + std::thread::spawn(move || { + let (success, msg) = + match crate::platform::linux::clear_gnome_shortcuts_inhibitor_permission() { + Ok(_) => (true, "".to_owned()), + Err(e) => (false, e.to_string()), + }; + let data = HashMap::from([ + ( + "name", + serde_json::json!("clear-gnome-shortcuts-inhibitor-permission-res"), + ), + ("success", serde_json::json!(success)), + ("msg", serde_json::json!(msg)), + ]); + let _res = flutter::push_global_event( + flutter::APP_TYPE_MAIN, + serde_json::ser::to_string(&data).unwrap_or("".to_owned()), + ); + }); + } +} + +pub fn session_get_common_sync( + session_id: SessionID, + key: String, + param: String, +) -> SyncReturn> { + SyncReturn(session_get_common(session_id, key, param)) +} + +pub fn session_get_common( + session_id: SessionID, + key: String, + #[allow(unused_variables)] param: String, +) -> Option { + if let Some(s) = sessions::get_session_by_session_id(&session_id) { + let v = if key == "is_screenshot_supported" { + s.is_screenshot_supported().to_string() + } else { + "".to_owned() + }; + Some(v) + } else { + None + } +} + #[cfg(target_os = "android")] pub mod server_side { use hbb_common::{config, log}; use jni::{ errors::{Error as JniError, Result as JniResult}, objects::{JClass, JObject, JString}, - sys::jstring, + sys::{jboolean, jstring}, JNIEnv, }; @@ -2380,4 +3107,28 @@ pub mod server_side { }; return env.new_string(res).unwrap_or_default().into_raw(); } + + #[no_mangle] + pub unsafe extern "system" fn Java_ffi_FFI_getBuildinOption( + env: JNIEnv, + _class: JClass, + key: JString, + ) -> jstring { + let mut env = env; + let res = if let Ok(key) = env.get_string(&key) { + let key: String = key.into(); + super::get_builtin_option(&key) + } else { + "".into() + }; + return env.new_string(res).unwrap_or_default().into_raw(); + } + + #[no_mangle] + pub unsafe extern "system" fn Java_ffi_FFI_isServiceClipboardEnabled( + env: JNIEnv, + _class: JClass, + ) -> jboolean { + jboolean::from(crate::server::is_clipboard_service_ok()) + } } diff --git a/src/hbbs_http.rs b/src/hbbs_http.rs index 71fff7ca8..9e4538697 100644 --- a/src/hbbs_http.rs +++ b/src/hbbs_http.rs @@ -1,14 +1,17 @@ -use reqwest::blocking::Response; +use hbb_common::ResultType; use serde::de::DeserializeOwned; use serde_json::{Map, Value}; #[cfg(feature = "flutter")] pub mod account; +pub mod downloader; mod http_client; pub mod record_upload; pub mod sync; -pub use http_client::create_http_client; -pub use http_client::create_http_client_async; +pub use http_client::{ + create_http_client_async, create_http_client_async_with_url, create_http_client_with_url, + get_url_for_tls, +}; #[derive(Debug)] pub enum HbbHttpResponse { @@ -18,11 +21,9 @@ pub enum HbbHttpResponse { Data(T), } -impl TryFrom for HbbHttpResponse { - type Error = reqwest::Error; - - fn try_from(resp: Response) -> Result>::Error> { - let map = resp.json::>()?; +impl HbbHttpResponse { + pub fn parse(body: &str) -> ResultType { + let map = serde_json::from_str::>(body)?; if let Some(error) = map.get("error") { if let Some(err) = error.as_str() { Ok(Self::Error(err.to_owned())) diff --git a/src/hbbs_http/account.rs b/src/hbbs_http/account.rs index 8d4eb28b1..3f824113b 100644 --- a/src/hbbs_http/account.rs +++ b/src/hbbs_http/account.rs @@ -1,7 +1,6 @@ use super::HbbHttpResponse; -use crate::hbbs_http::create_http_client; +use crate::hbbs_http::create_http_client_with_url; use hbb_common::{config::LocalConfig, log, ResultType}; -use reqwest::blocking::Client; use serde_derive::{Deserialize, Serialize}; use serde_repr::{Deserialize_repr, Serialize_repr}; use std::{ @@ -17,6 +16,7 @@ lazy_static::lazy_static! { const QUERY_INTERVAL_SECS: f32 = 1.0; const QUERY_TIMEOUT_SECS: u64 = 60 * 3; + const REQUESTING_ACCOUNT_AUTH: &str = "Requesting account auth"; const WAITING_ACCOUNT_AUTH: &str = "Waiting account auth"; const LOGIN_ACCOUNT_AUTH: &str = "Login account auth"; @@ -80,6 +80,10 @@ pub enum UserStatus { pub struct UserPayload { pub name: String, #[serde(default)] + pub display_name: Option, + #[serde(default)] + pub avatar: Option, + #[serde(default)] pub email: Option, #[serde(default)] pub note: Option, @@ -104,7 +108,7 @@ pub struct AuthBody { } pub struct OidcSession { - client: Client, + warmed_api_server: Option, state_msg: &'static str, failed_msg: String, code_url: Option, @@ -131,7 +135,7 @@ impl Default for UserStatus { impl OidcSession { fn new() -> Self { Self { - client: create_http_client(), + warmed_api_server: None, state_msg: REQUESTING_ACCOUNT_AUTH, failed_msg: "".to_owned(), code_url: None, @@ -142,25 +146,33 @@ impl OidcSession { } } + fn ensure_client(api_server: &str) { + let mut write_guard = OIDC_SESSION.write().unwrap(); + if write_guard.warmed_api_server.as_deref() == Some(api_server) { + return; + } + // This URL is used to detect the appropriate TLS implementation for the server. + let login_option_url = format!("{}/api/login-options", api_server); + let _ = create_http_client_with_url(&login_option_url); + write_guard.warmed_api_server = Some(api_server.to_owned()); + } + fn auth( api_server: &str, op: &str, id: &str, uuid: &str, ) -> ResultType> { - Ok(OIDC_SESSION - .read() - .unwrap() - .client - .post(format!("{}/api/oidc/auth", api_server)) - .json(&serde_json::json!({ - "op": op, - "id": id, - "uuid": uuid, - "deviceInfo": crate::ui_interface::get_login_device_info(), - })) - .send()? - .try_into()?) + Self::ensure_client(api_server); + let body = serde_json::json!({ + "op": op, + "id": id, + "uuid": uuid, + "deviceInfo": crate::ui_interface::get_login_device_info(), + }) + .to_string(); + let resp = crate::post_request_sync(format!("{}/api/oidc/auth", api_server), body, "")?; + HbbHttpResponse::parse(&resp) } fn query( @@ -173,13 +185,20 @@ impl OidcSession { &format!("{}/api/oidc/auth-query", api_server), &[("code", code), ("id", id), ("uuid", uuid)], )?; - Ok(OIDC_SESSION - .read() - .unwrap() - .client - .get(url) - .send()? - .try_into()?) + Self::ensure_client(api_server); + #[derive(Deserialize)] + struct HttpResponseBody { + body: String, + } + + let resp = crate::http_request_sync( + url.to_string(), + "GET".to_owned(), + None, + "{}".to_owned(), + )?; + let resp = serde_json::from_str::(&resp)?; + HbbHttpResponse::parse(&resp.body) } fn reset(&mut self) { @@ -251,7 +270,13 @@ impl OidcSession { ); LocalConfig::set_option( "user_info".to_owned(), - serde_json::json!({ "name": auth_body.user.name, "status": auth_body.user.status }).to_string(), + serde_json::json!({ + "name": auth_body.user.name, + "display_name": auth_body.user.display_name, + "avatar": auth_body.user.avatar, + "status": auth_body.user.status + }) + .to_string(), ); } } diff --git a/src/hbbs_http/downloader.rs b/src/hbbs_http/downloader.rs new file mode 100644 index 000000000..573e7e77c --- /dev/null +++ b/src/hbbs_http/downloader.rs @@ -0,0 +1,309 @@ +use super::create_http_client_async_with_url; +use hbb_common::{ + bail, + lazy_static::lazy_static, + log, + tokio::{ + self, + fs::File, + io::AsyncWriteExt, + sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}, + }, + ResultType, +}; +use serde_derive::Serialize; +use std::{collections::HashMap, path::PathBuf, sync::Mutex, time::Duration}; + +lazy_static! { + static ref DOWNLOADERS: Mutex> = Default::default(); +} + +/// This struct is used to return the download data to the caller. +/// The caller should check if the file is downloaded successfully and remove the job from the map. +/// If the file is not downloaded successfully, the `data` field will be empty. +/// If the file is downloaded successfully, the `data` field will contain the downloaded data if `path` is None. +#[derive(Serialize, Debug)] +pub struct DownloadData { + #[serde(skip_serializing_if = "Vec::is_empty")] + pub data: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub total_size: Option, + pub downloaded_size: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +struct Downloader { + data: Vec, + path: Option, + // Some file may be empty, so we use Option to indicate if the size is known + total_size: Option, + downloaded_size: u64, + error: Option, + finished: bool, + tx_cancel: UnboundedSender<()>, +} + +// The caller should check if the file is downloaded successfully and remove the job from the map. +pub fn download_file( + url: String, + path: Option, + auto_del_dur: Option, +) -> ResultType { + let id = url.clone(); + // First pass: if a non-error downloader exists for this URL, reuse it. + // If an errored downloader exists, remove it so this call can retry. + let mut stale_path = None; + { + let mut downloaders = DOWNLOADERS.lock().unwrap(); + if let Some(downloader) = downloaders.get(&id) { + if downloader.error.is_none() { + return Ok(id); + } + stale_path = downloader.path.clone(); + downloaders.remove(&id); + } + } + if let Some(p) = stale_path { + if p.exists() { + if let Err(e) = std::fs::remove_file(&p) { + log::warn!("Failed to remove stale download file {}: {}", p.display(), e); + } + } + } + + if let Some(path) = path.as_ref() { + if path.exists() { + bail!("File {} already exists", path.display()); + } + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + } + let (tx, rx) = unbounded_channel(); + let downloader = Downloader { + data: Vec::new(), + path: path.clone(), + total_size: None, + downloaded_size: 0, + error: None, + tx_cancel: tx, + finished: false, + }; + // Second pass (atomic with insert) to avoid race with another concurrent caller. + let mut stale_path_after_check = None; + { + let mut downloaders = DOWNLOADERS.lock().unwrap(); + if let Some(existing) = downloaders.get(&id) { + if existing.error.is_none() { + return Ok(id); + } + stale_path_after_check = existing.path.clone(); + downloaders.remove(&id); + } + downloaders.insert(id.clone(), downloader); + } + if let Some(p) = stale_path_after_check { + if p.exists() { + if let Err(e) = std::fs::remove_file(&p) { + log::warn!("Failed to remove stale download file {}: {}", p.display(), e); + } + } + } + + let id2 = id.clone(); + std::thread::spawn( + move || match do_download(&id2, url, path, auto_del_dur, rx) { + Ok(is_all_downloaded) => { + let mut downloaded_size = 0; + let mut total_size = 0; + DOWNLOADERS.lock().unwrap().get_mut(&id2).map(|downloader| { + downloaded_size = downloader.downloaded_size; + total_size = downloader.total_size.unwrap_or(0); + }); + log::info!( + "Download {} end, {}/{}, {:.2} %", + &id2, + downloaded_size, + total_size, + if total_size == 0 { + 0.0 + } else { + downloaded_size as f64 / total_size as f64 * 100.0 + } + ); + + let is_canceled = !is_all_downloaded; + if is_canceled { + if let Some(downloader) = DOWNLOADERS.lock().unwrap().remove(&id2) { + if let Some(p) = downloader.path { + if p.exists() { + std::fs::remove_file(p).ok(); + } + } + } + } + } + Err(e) => { + let err = e.to_string(); + log::error!("Download {}, failed: {}", &id2, &err); + DOWNLOADERS.lock().unwrap().get_mut(&id2).map(|downloader| { + downloader.error = Some(err); + }); + } + }, + ); + + Ok(id) +} + +#[tokio::main(flavor = "current_thread")] +async fn do_download( + id: &str, + url: String, + path: Option, + auto_del_dur: Option, + mut rx_cancel: UnboundedReceiver<()>, +) -> ResultType { + let client = create_http_client_async_with_url(&url).await; + + let mut is_all_downloaded = false; + tokio::select! { + _ = rx_cancel.recv() => { + return Ok(is_all_downloaded); + } + head_resp = client.head(&url).send() => { + match head_resp { + Ok(resp) => { + if resp.status().is_success() { + let total_size = resp + .headers() + .get(reqwest::header::CONTENT_LENGTH) + .and_then(|ct_len| ct_len.to_str().ok()) + .and_then(|ct_len| ct_len.parse::().ok()); + let Some(total_size) = total_size else { + bail!("Failed to get content length"); + }; + DOWNLOADERS.lock().unwrap().get_mut(id).map(|downloader| { + downloader.total_size = Some(total_size); + }); + } else { + bail!("Failed to get content length: {}", resp.status()); + } + } + Err(e) => { + return Err(e.into()); + } + } + } + } + + let mut response; + tokio::select! { + _ = rx_cancel.recv() => { + return Ok(is_all_downloaded); + } + resp = client.get(url).send() => { + response = resp?; + } + } + + let mut dest: Option = None; + if let Some(p) = path { + dest = Some(File::create(p).await?); + } + + loop { + tokio::select! { + _ = rx_cancel.recv() => { + break; + } + chunk = response.chunk() => { + match chunk { + Ok(Some(chunk)) => { + match dest { + Some(ref mut f) => { + f.write_all(&chunk).await?; + f.flush().await?; + DOWNLOADERS.lock().unwrap().get_mut(id).map(|downloader| { + downloader.downloaded_size += chunk.len() as u64; + }); + } + None => { + DOWNLOADERS.lock().unwrap().get_mut(id).map(|downloader| { + downloader.data.extend_from_slice(&chunk); + downloader.downloaded_size += chunk.len() as u64; + }); + } + } + } + Ok(None) => { + is_all_downloaded = true; + break; + }, + Err(e) => { + log::error!("Download {} failed: {}", id, e); + return Err(e.into()); + } + } + } + } + } + + if let Some(mut f) = dest.take() { + f.flush().await?; + } + + if let Some(ref mut downloader) = DOWNLOADERS.lock().unwrap().get_mut(id) { + downloader.finished = true; + } + if is_all_downloaded { + let id_del = id.to_string(); + if let Some(dur) = auto_del_dur { + tokio::spawn(async move { + tokio::time::sleep(dur).await; + DOWNLOADERS.lock().unwrap().remove(&id_del); + }); + } + } + Ok(is_all_downloaded) +} + +pub fn get_download_data(id: &str) -> ResultType { + let downloaders = DOWNLOADERS.lock().unwrap(); + if let Some(downloader) = downloaders.get(id) { + let downloaded_size = downloader.downloaded_size; + let total_size = downloader.total_size.clone(); + let error = downloader.error.clone(); + let data = if total_size.unwrap_or(0) == downloaded_size && downloader.path.is_none() { + downloader.data.clone() + } else { + Vec::new() + }; + let path = downloader.path.clone(); + let download_data = DownloadData { + data, + path, + total_size, + downloaded_size, + error, + }; + Ok(download_data) + } else { + bail!("Downloader not found") + } +} + +pub fn cancel(id: &str) { + if let Some(downloader) = DOWNLOADERS.lock().unwrap().get(id) { + // downloader.is_canceled.store(true, Ordering::SeqCst); + // The receiver may not be able to receive the cancel signal, so we also set the atomic bool to true + let _ = downloader.tx_cancel.send(()); + } +} + +pub fn remove(id: &str) { + let _ = DOWNLOADERS.lock().unwrap().remove(id); +} diff --git a/src/hbbs_http/http_client.rs b/src/hbbs_http/http_client.rs index 944e84ae6..432e5fa38 100644 --- a/src/hbbs_http/http_client.rs +++ b/src/hbbs_http/http_client.rs @@ -1,40 +1,74 @@ -use hbb_common::config::Config; -use hbb_common::log::info; -use hbb_common::proxy::{Proxy, ProxyScheme}; -use reqwest::blocking::Client as SyncClient; -use reqwest::Client as AsyncClient; +use hbb_common::{ + async_recursion::async_recursion, + config::{Config, Socks5Server}, + log::{self, info}, + proxy::{Proxy, ProxyScheme}, + tls::{ + get_cached_tls_accept_invalid_cert, get_cached_tls_type, is_plain, upsert_tls_cache, + TlsType, + }, +}; +use reqwest::{blocking::Client as SyncClient, Client as AsyncClient}; macro_rules! configure_http_client { - ($builder:expr, $Client: ty) => {{ - let mut builder = $builder; + ($builder:expr, $tls_type:expr, $danger_accept_invalid_cert:expr, $Client: ty) => {{ + // https://github.com/rustdesk/rustdesk/issues/11569 + // https://docs.rs/reqwest/latest/reqwest/struct.ClientBuilder.html#method.no_proxy + let mut builder = $builder.no_proxy(); + + match $tls_type { + TlsType::Plain => {} + TlsType::NativeTls => { + builder = builder.use_native_tls(); + if $danger_accept_invalid_cert { + builder = builder.danger_accept_invalid_certs(true); + } + } + TlsType::Rustls => { + #[cfg(any(target_os = "android", target_os = "ios"))] + match hbb_common::verifier::client_config($danger_accept_invalid_cert) { + Ok(client_config) => { + builder = builder.use_preconfigured_tls(client_config); + } + Err(e) => { + hbb_common::log::error!("Failed to get client config: {}", e); + } + } + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + builder = builder.use_rustls_tls(); + if $danger_accept_invalid_cert { + builder = builder.danger_accept_invalid_certs(true); + } + } + } + } + let client = if let Some(conf) = Config::get_socks() { let proxy_result = Proxy::from_conf(&conf, None); match proxy_result { Ok(proxy) => { let proxy_setup = match &proxy.intercept { - ProxyScheme::Http { host, .. } =>{ reqwest::Proxy::http(format!("http://{}", host))}, - ProxyScheme::Https { host, .. } => {reqwest::Proxy::https(format!("https://{}", host))}, - ProxyScheme::Socks5 { addr, .. } => { reqwest::Proxy::all(&format!("socks5://{}", addr)) } + ProxyScheme::Http { host, .. } => { + reqwest::Proxy::all(format!("http://{}", host)) + } + ProxyScheme::Https { host, .. } => { + reqwest::Proxy::all(format!("https://{}", host)) + } + ProxyScheme::Socks5 { addr, .. } => { + reqwest::Proxy::all(&format!("socks5://{}", addr)) + } }; match proxy_setup { - Ok(p) => { - builder = builder.proxy(p); + Ok(mut p) => { if let Some(auth) = proxy.intercept.maybe_auth() { - let basic_auth = - format!("Basic {}", auth.get_basic_authorization()); - if let Ok(auth) = basic_auth.parse() { - builder = builder.default_headers( - vec![( - reqwest::header::PROXY_AUTHORIZATION, - auth, - )] - .into_iter() - .collect(), - ); + if !auth.username().is_empty() && !auth.password().is_empty() { + p = p.basic_auth(auth.username(), auth.password()); } } + builder = builder.proxy(p); builder.build().unwrap_or_else(|e| { info!("Failed to create a proxied client: {}", e); <$Client>::new() @@ -62,12 +96,241 @@ macro_rules! configure_http_client { }}; } -pub fn create_http_client() -> SyncClient { +pub fn create_http_client(tls_type: TlsType, danger_accept_invalid_cert: bool) -> SyncClient { let builder = SyncClient::builder(); - configure_http_client!(builder, SyncClient) + configure_http_client!(builder, tls_type, danger_accept_invalid_cert, SyncClient) } -pub fn create_http_client_async() -> AsyncClient { +pub fn create_http_client_async( + tls_type: TlsType, + danger_accept_invalid_cert: bool, +) -> AsyncClient { let builder = AsyncClient::builder(); - configure_http_client!(builder, AsyncClient) + configure_http_client!(builder, tls_type, danger_accept_invalid_cert, AsyncClient) +} + +pub fn get_url_for_tls<'a>(url: &'a str, proxy_conf: &'a Option) -> &'a str { + if is_plain(url) { + if let Some(conf) = proxy_conf { + if conf.proxy.starts_with("https://") { + return &conf.proxy; + } + } + } + url +} + +pub fn create_http_client_with_url(url: &str) -> SyncClient { + let proxy_conf = Config::get_socks(); + let tls_url = get_url_for_tls(url, &proxy_conf); + let tls_type = get_cached_tls_type(tls_url); + let is_tls_type_cached = tls_type.is_some(); + let tls_type = tls_type.unwrap_or(TlsType::Rustls); + let tls_danger_accept_invalid_cert = get_cached_tls_accept_invalid_cert(tls_url); + create_http_client_with_url_( + url, + tls_url, + tls_type, + is_tls_type_cached, + tls_danger_accept_invalid_cert, + tls_danger_accept_invalid_cert, + ) +} + +fn create_http_client_with_url_( + url: &str, + tls_url: &str, + tls_type: TlsType, + is_tls_type_cached: bool, + danger_accept_invalid_cert: Option, + original_danger_accept_invalid_cert: Option, +) -> SyncClient { + let mut client = create_http_client(tls_type, danger_accept_invalid_cert.unwrap_or(false)); + if is_tls_type_cached && original_danger_accept_invalid_cert.is_some() { + return client; + } + if let Err(e) = client.head(url).send() { + if e.is_request() { + match (tls_type, is_tls_type_cached, danger_accept_invalid_cert) { + (TlsType::Rustls, _, None) => { + log::warn!( + "Failed to connect to server {} with rustls-tls: {:?}, trying accept invalid cert", + tls_url, + e + ); + client = create_http_client_with_url_( + url, + tls_url, + tls_type, + is_tls_type_cached, + Some(true), + original_danger_accept_invalid_cert, + ); + } + (TlsType::Rustls, false, Some(_)) => { + log::warn!( + "Failed to connect to server {} with rustls-tls: {:?}, trying native-tls", + tls_url, + e + ); + client = create_http_client_with_url_( + url, + tls_url, + TlsType::NativeTls, + is_tls_type_cached, + original_danger_accept_invalid_cert, + original_danger_accept_invalid_cert, + ); + } + (TlsType::NativeTls, _, None) => { + log::warn!( + "Failed to connect to server {} with native-tls: {:?}, trying accept invalid cert", + tls_url, + e + ); + client = create_http_client_with_url_( + url, + tls_url, + tls_type, + is_tls_type_cached, + Some(true), + original_danger_accept_invalid_cert, + ); + } + _ => { + log::error!( + "Failed to connect to server {} with {:?}, err: {:?}.", + tls_url, + tls_type, + e + ); + } + } + } else { + log::warn!( + "Failed to connect to server {} with {:?}, err: {}.", + tls_url, + tls_type, + e + ); + } + } else { + log::info!( + "Successfully connected to server {} with {:?}", + tls_url, + tls_type + ); + upsert_tls_cache( + tls_url, + tls_type, + danger_accept_invalid_cert.unwrap_or(false), + ); + } + client +} + +pub async fn create_http_client_async_with_url(url: &str) -> AsyncClient { + let proxy_conf = Config::get_socks(); + let tls_url = get_url_for_tls(url, &proxy_conf); + let tls_type = get_cached_tls_type(tls_url); + let is_tls_type_cached = tls_type.is_some(); + let tls_type = tls_type.unwrap_or(TlsType::Rustls); + let danger_accept_invalid_cert = get_cached_tls_accept_invalid_cert(tls_url); + create_http_client_async_with_url_( + url, + tls_url, + tls_type, + is_tls_type_cached, + danger_accept_invalid_cert, + danger_accept_invalid_cert, + ) + .await +} + +#[async_recursion] +async fn create_http_client_async_with_url_( + url: &str, + tls_url: &str, + tls_type: TlsType, + is_tls_type_cached: bool, + danger_accept_invalid_cert: Option, + original_danger_accept_invalid_cert: Option, +) -> AsyncClient { + let mut client = + create_http_client_async(tls_type, danger_accept_invalid_cert.unwrap_or(false)); + if is_tls_type_cached && original_danger_accept_invalid_cert.is_some() { + return client; + } + if let Err(e) = client.head(url).send().await { + match (tls_type, is_tls_type_cached, danger_accept_invalid_cert) { + (TlsType::Rustls, _, None) => { + log::warn!( + "Failed to connect to server {} with rustls-tls: {:?}, trying accept invalid cert", + tls_url, + e + ); + client = create_http_client_async_with_url_( + url, + tls_url, + tls_type, + is_tls_type_cached, + Some(true), + original_danger_accept_invalid_cert, + ) + .await; + } + (TlsType::Rustls, false, Some(_)) => { + log::warn!( + "Failed to connect to server {} with rustls-tls: {:?}, trying native-tls", + tls_url, + e + ); + client = create_http_client_async_with_url_( + url, + tls_url, + TlsType::NativeTls, + is_tls_type_cached, + original_danger_accept_invalid_cert, + original_danger_accept_invalid_cert, + ) + .await; + } + (TlsType::NativeTls, _, None) => { + log::warn!( + "Failed to connect to server {} with native-tls: {:?}, trying accept invalid cert", + tls_url, + e + ); + client = create_http_client_async_with_url_( + url, + tls_url, + tls_type, + is_tls_type_cached, + Some(true), + original_danger_accept_invalid_cert, + ) + .await; + } + _ => { + log::error!( + "Failed to connect to server {} with {:?}, err: {:?}.", + tls_url, + tls_type, + e + ); + } + } + } else { + log::info!( + "Successfully connected to server {} with {:?}", + tls_url, + tls_type + ); + upsert_tls_cache( + tls_url, + tls_type, + danger_accept_invalid_cert.unwrap_or(false), + ); + } + client } diff --git a/src/hbbs_http/record_upload.rs b/src/hbbs_http/record_upload.rs index a25aae42d..ac51d5c32 100644 --- a/src/hbbs_http/record_upload.rs +++ b/src/hbbs_http/record_upload.rs @@ -1,4 +1,4 @@ -use crate::hbbs_http::create_http_client; +use crate::hbbs_http::create_http_client_with_url; use bytes::Bytes; use hbb_common::{bail, config::Config, lazy_static, log, ResultType}; use reqwest::blocking::{Body, Client}; @@ -25,51 +25,57 @@ pub fn is_enable() -> bool { } pub fn run(rx: Receiver) { - let mut uploader = RecordUploader { - client: create_http_client(), - api_server: crate::get_api_server( + std::thread::spawn(move || { + let api_server = crate::get_api_server( Config::get_option("api-server"), Config::get_option("custom-rendezvous-server"), - ), - filepath: Default::default(), - filename: Default::default(), - upload_size: Default::default(), - running: Default::default(), - last_send: Instant::now(), - }; - std::thread::spawn(move || loop { - if let Err(e) = match rx.recv() { - Ok(state) => match state { - RecordState::NewFile(filepath) => uploader.handle_new_file(filepath), - RecordState::NewFrame => { - if uploader.running { - uploader.handle_frame(false) - } else { - Ok(()) + ); + // This URL is used for TLS connectivity testing and fallback detection. + let login_option_url = format!("{}/api/login-options", &api_server); + let client = create_http_client_with_url(&login_option_url); + let mut uploader = RecordUploader { + client, + api_server, + filepath: Default::default(), + filename: Default::default(), + upload_size: Default::default(), + running: Default::default(), + last_send: Instant::now(), + }; + loop { + if let Err(e) = match rx.recv() { + Ok(state) => match state { + RecordState::NewFile(filepath) => uploader.handle_new_file(filepath), + RecordState::NewFrame => { + if uploader.running { + uploader.handle_frame(false) + } else { + Ok(()) + } } - } - RecordState::WriteTail => { - if uploader.running { - uploader.handle_tail() - } else { - Ok(()) + RecordState::WriteTail => { + if uploader.running { + uploader.handle_tail() + } else { + Ok(()) + } } - } - RecordState::RemoveFile => { - if uploader.running { - uploader.handle_remove() - } else { - Ok(()) + RecordState::RemoveFile => { + if uploader.running { + uploader.handle_remove() + } else { + Ok(()) + } } + }, + Err(e) => { + log::trace!("upload thread stop: {}", e); + break; } - }, - Err(e) => { - log::trace!("upload thread stop: {}", e); - break; + } { + uploader.running = false; + log::error!("upload stop: {}", e); } - } { - uploader.running = false; - log::error!("upload stop: {}", e); } }); } diff --git a/src/hbbs_http/sync.rs b/src/hbbs_http/sync.rs index 91c18c4b2..1bb61943f 100644 --- a/src/hbbs_http/sync.rs +++ b/src/hbbs_http/sync.rs @@ -7,7 +7,8 @@ use std::{ #[cfg(not(any(target_os = "ios")))] use crate::{ui_interface::get_builtin_option, Connection}; use hbb_common::{ - config::{keys, Config, LocalConfig}, + config::{self, keys, Config, LocalConfig}, + log, tokio::{self, sync::broadcast, time::Instant}, }; use serde::{Deserialize, Serialize}; @@ -48,6 +49,38 @@ pub struct StrategyOptions { pub extra: HashMap, } +struct InfoUploaded { + uploaded: bool, + url: String, + last_uploaded: Option, + id: String, + username: Option, +} + +impl Default for InfoUploaded { + fn default() -> Self { + Self { + uploaded: false, + url: "".to_owned(), + last_uploaded: None, + id: "".to_owned(), + username: None, + } + } +} + +impl InfoUploaded { + fn uploaded(url: String, id: String, username: String) -> Self { + Self { + uploaded: true, + url, + last_uploaded: None, + id, + username: Some(username), + } + } +} + #[cfg(not(any(target_os = "ios")))] #[tokio::main(flavor = "current_thread")] async fn start_hbbs_sync_async() { @@ -56,8 +89,8 @@ async fn start_hbbs_sync_async() { TIME_CONN, )); let mut last_sent: Option = None; - let mut info_uploaded: (bool, String, Option, String) = - (false, "".to_owned(), None, "".to_owned()); + let mut info_uploaded = InfoUploaded::default(); + let mut sysinfo_ver = "".to_owned(); loop { tokio::select! { _ = interval.tick() => { @@ -67,54 +100,132 @@ async fn start_hbbs_sync_async() { *PRO.lock().unwrap() = false; continue; } - if hbb_common::config::option2bool("stop-service", &Config::get_option("stop-service")) { + if config::option2bool("stop-service", &Config::get_option("stop-service")) { continue; } let conns = Connection::alive_conns(); - if info_uploaded.0 && (url != info_uploaded.1 || id != info_uploaded.3) { - info_uploaded.0 = false; + if info_uploaded.uploaded && (url != info_uploaded.url || id != info_uploaded.id) { + info_uploaded.uploaded = false; *PRO.lock().unwrap() = false; } - if !info_uploaded.0 && info_uploaded.2.map(|x| x.elapsed() >= UPLOAD_SYSINFO_TIMEOUT).unwrap_or(true) { - let mut v = crate::get_sysinfo(); - // username is empty in login screen of windows, but here we only upload sysinfo once, causing - // real user name not uploaded after login screen. https://github.com/rustdesk/rustdesk/discussions/8031 - if !cfg!(windows) || !v["username"].as_str().unwrap_or_default().is_empty() { - v["version"] = json!(crate::VERSION); - v["id"] = json!(id); - v["uuid"] = json!(crate::encode64(hbb_common::get_uuid())); - let ab_name = Config::get_option(keys::OPTION_PRESET_ADDRESS_BOOK_NAME); - if !ab_name.is_empty() { - v[keys::OPTION_PRESET_ADDRESS_BOOK_NAME] = json!(ab_name); - } - let ab_tag = Config::get_option(keys::OPTION_PRESET_ADDRESS_BOOK_TAG); - if !ab_tag.is_empty() { - v[keys::OPTION_PRESET_ADDRESS_BOOK_TAG] = json!(ab_tag); - } - let username = get_builtin_option(keys::OPTION_PRESET_USERNAME); - if !username.is_empty() { - v[keys::OPTION_PRESET_USERNAME] = json!(username); - } - let strategy_name = get_builtin_option(keys::OPTION_PRESET_STRATEGY_NAME); - if !strategy_name.is_empty() { - v[keys::OPTION_PRESET_STRATEGY_NAME] = json!(strategy_name); - } - match crate::post_request(url.replace("heartbeat", "sysinfo"), v.to_string(), "").await { - Ok(x) => { - if x == "SYSINFO_UPDATED" { - info_uploaded = (true, url.clone(), None, id.clone()); - hbb_common::log::info!("sysinfo updated"); + // For Windows: + // We can't skip uploading sysinfo when the username is empty, because the username may + // always be empty before login. We also need to upload the other sysinfo info. + // + // https://github.com/rustdesk/rustdesk/discussions/8031 + // We still need to check the username after uploading sysinfo, because + // 1. The username may be empty when logining in, and it can be fetched after a while. + // In this case, we need to upload sysinfo again. + // 2. The username may be changed after uploading sysinfo, and we need to upload sysinfo again. + // + // The Windows session will switch to the last user session before the restart, + // so it may be able to get the username before login. + // But strangely, sometimes we can get the username before login, + // we may not be able to get the username before login after the next restart. + let mut v = crate::get_sysinfo(); + let sys_username = v["username"].as_str().unwrap_or_default().to_string(); + // Though the username comparison is only necessary on Windows, + // we still keep the comparison on other platforms for consistency. + let need_upload = (!info_uploaded.uploaded || info_uploaded.username.as_ref() != Some(&sys_username)) && + info_uploaded.last_uploaded.map(|x| x.elapsed() >= UPLOAD_SYSINFO_TIMEOUT).unwrap_or(true); + if need_upload { + v["version"] = json!(crate::VERSION); + v["id"] = json!(id); + v["uuid"] = json!(crate::encode64(hbb_common::get_uuid())); + let ab_name = Config::get_option(keys::OPTION_PRESET_ADDRESS_BOOK_NAME); + if !ab_name.is_empty() { + v[keys::OPTION_PRESET_ADDRESS_BOOK_NAME] = json!(ab_name); + } + let ab_tag = Config::get_option(keys::OPTION_PRESET_ADDRESS_BOOK_TAG); + if !ab_tag.is_empty() { + v[keys::OPTION_PRESET_ADDRESS_BOOK_TAG] = json!(ab_tag); + } + let ab_alias = Config::get_option(keys::OPTION_PRESET_ADDRESS_BOOK_ALIAS); + if !ab_alias.is_empty() { + v[keys::OPTION_PRESET_ADDRESS_BOOK_ALIAS] = json!(ab_alias); + } + let ab_password = Config::get_option(keys::OPTION_PRESET_ADDRESS_BOOK_PASSWORD); + if !ab_password.is_empty() { + v[keys::OPTION_PRESET_ADDRESS_BOOK_PASSWORD] = json!(ab_password); + } + let ab_note = Config::get_option(keys::OPTION_PRESET_ADDRESS_BOOK_NOTE); + if !ab_note.is_empty() { + v[keys::OPTION_PRESET_ADDRESS_BOOK_NOTE] = json!(ab_note); + } + let username = get_builtin_option(keys::OPTION_PRESET_USERNAME); + if !username.is_empty() { + v[keys::OPTION_PRESET_USERNAME] = json!(username); + } + let strategy_name = get_builtin_option(keys::OPTION_PRESET_STRATEGY_NAME); + if !strategy_name.is_empty() { + v[keys::OPTION_PRESET_STRATEGY_NAME] = json!(strategy_name); + } + let device_group_name = get_builtin_option(keys::OPTION_PRESET_DEVICE_GROUP_NAME); + if !device_group_name.is_empty() { + v[keys::OPTION_PRESET_DEVICE_GROUP_NAME] = json!(device_group_name); + } + let device_username = Config::get_option(keys::OPTION_PRESET_DEVICE_USERNAME); + if !device_username.is_empty() { + v["username"] = json!(device_username); + } + let device_name = Config::get_option(keys::OPTION_PRESET_DEVICE_NAME); + if !device_name.is_empty() { + v["hostname"] = json!(device_name); + } + let note = Config::get_option(keys::OPTION_PRESET_NOTE); + if !note.is_empty() { + v[keys::OPTION_PRESET_NOTE] = json!(note); + } + let v = v.to_string(); + let mut hash = "".to_owned(); + if crate::is_public(&url) { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(url.as_bytes()); + hasher.update(&v.as_bytes()); + let res = hasher.finalize(); + hash = hbb_common::base64::encode(&res[..]); + let old_hash = config::Status::get("sysinfo_hash"); + let ver = config::Status::get("sysinfo_ver"); // sysinfo_ver is the version of sysinfo on server's side + if hash == old_hash { + // When the api doesn't exist, Ok("") will be returned in test. + let samever = match crate::post_request(url.replace("heartbeat", "sysinfo_ver"), "".to_owned(), "").await { + Ok(x) => { + sysinfo_ver = x.clone(); *PRO.lock().unwrap() = true; - } else if x == "ID_NOT_FOUND" { - info_uploaded.2 = None; // next heartbeat will upload sysinfo again - } else { - info_uploaded.2 = Some(Instant::now()); + x == ver } + _ => { + false // to make sure Pro can be assigned in below post for old + // hbbs pro not supporting sysinfo_ver, use false for ensuring + } + }; + if samever { + info_uploaded = InfoUploaded::uploaded(url.clone(), id.clone(), sys_username); + log::info!("sysinfo not changed, skip upload"); + continue; } - _ => { - info_uploaded.2 = Some(Instant::now()); + } + } + match crate::post_request(url.replace("heartbeat", "sysinfo"), v, "").await { + Ok(x) => { + if x == "SYSINFO_UPDATED" { + info_uploaded = InfoUploaded::uploaded(url.clone(), id.clone(), sys_username); + log::info!("sysinfo updated"); + if !hash.is_empty() { + config::Status::set("sysinfo_hash", hash); + config::Status::set("sysinfo_ver", sysinfo_ver.clone()); + } + *PRO.lock().unwrap() = true; + } else if x == "ID_NOT_FOUND" { + info_uploaded.last_uploaded = None; // next heartbeat will upload sysinfo again + } else { + info_uploaded.last_uploaded = Some(Instant::now()); } } + _ => { + info_uploaded.last_uploaded = Some(Instant::now()); + } } } if conns.is_empty() && last_sent.map(|x| x.elapsed() < TIME_HEARTBEAT).unwrap_or(false) { @@ -132,6 +243,11 @@ async fn start_hbbs_sync_async() { v["modified_at"] = json!(modified_at); if let Ok(s) = crate::post_request(url.clone(), v.to_string(), "").await { if let Ok(mut rsp) = serde_json::from_str::>(&s) { + if rsp.remove("sysinfo").is_some() { + info_uploaded.uploaded = false; + config::Status::set("sysinfo_hash", "".to_owned()); + log::info!("sysinfo required to forcely update"); + } if let Some(conns) = rsp.remove("disconnect") { if let Ok(conns) = serde_json::from_value::>(conns) { SENDER.lock().unwrap().send(conns).ok(); @@ -146,6 +262,7 @@ async fn start_hbbs_sync_async() { } if let Some(strategy) = rsp.remove("strategy") { if let Ok(strategy) = serde_json::from_value::(strategy) { + log::info!("strategy updated"); handle_config_options(strategy.config_options); } } @@ -161,7 +278,7 @@ fn heartbeat_url() -> String { Config::get_option("api-server"), Config::get_option("custom-rendezvous-server"), ); - if url.is_empty() || url.contains("rustdesk.com") { + if url.is_empty() || crate::is_public(&url) { return "".to_owned(); } format!("{}/api/heartbeat", url) @@ -169,10 +286,14 @@ fn heartbeat_url() -> String { fn handle_config_options(config_options: HashMap) { let mut options = Config::get_options(); + let default_settings = config::DEFAULT_SETTINGS.read().unwrap().clone(); config_options .iter() .map(|(k, v)| { - if v.is_empty() { + // Priority: user config > default advanced options. + // Only when default advanced options are also empty, remove user option (fallback to built-in default); + // otherwise insert an empty value so user config remains present. + if v.is_empty() && default_settings.get(k).map_or("", |v| v).is_empty() { options.remove(k); } else { options.insert(k.to_string(), v.to_string()); diff --git a/src/ipc.rs b/src/ipc.rs index 81693a735..ffe1b08a5 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -1,33 +1,31 @@ -use crate::{ - privacy_mode::PrivacyModeState, - ui_interface::{get_local_option, set_local_option}, -}; -use bytes::Bytes; -use parity_tokio_ipc::{ - Connection as Conn, ConnectionClient as ConnClient, Endpoint, Incoming, SecurityAttributes, -}; -use serde_derive::{Deserialize, Serialize}; -use std::{ - collections::HashMap, - sync::atomic::{AtomicBool, Ordering}, -}; -#[cfg(not(windows))] -use std::{fs::File, io::prelude::*}; +#[path = "ipc/auth.rs"] +mod ipc_auth; +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[path = "ipc/fs.rs"] +mod ipc_fs; #[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, + privacy_mode::PrivacyModeState, + rendezvous_mediator::RendezvousMediator, + ui_interface::{get_local_option, set_local_option}, +}; +use bytes::Bytes; #[cfg(not(any(target_os = "android", target_os = "ios")))] pub use clipboard::ClipboardFile; +#[cfg(target_os = "linux")] +use hbb_common::anyhow; use hbb_common::{ allow_err, bail, bytes, bytes_codec::BytesCodec, - config::{self, Config, Config2}, + config::{self, keys::OPTION_ALLOW_WEBSOCKET, Config, Config2}, futures::StreamExt as _, futures_util::sink::SinkExt, - log, password_security as password, - sodiumoxide::base64, - timeout, + log, password_security as password, timeout, tokio::{ self, io::{AsyncRead, AsyncWrite}, @@ -35,16 +33,99 @@ use hbb_common::{ tokio_util::codec::Framed, ResultType, }; - -use crate::{common::is_server, privacy_mode, rendezvous_mediator::RendezvousMediator}; +#[cfg(windows)] +pub(crate) use ipc_auth::authorize_windows_portable_service_ipc_connection; +#[cfg(windows)] +pub(crate) use ipc_auth::ensure_peer_executable_matches_current_by_pid_opt; +#[cfg(windows)] +pub(crate) use ipc_auth::log_rejected_windows_ipc_connection; +#[cfg(any(target_os = "linux", target_os = "macos"))] +use ipc_auth::{active_uid, authorize_service_scoped_ipc_connection}; +#[cfg(windows)] +use ipc_auth::{ + authorize_windows_main_ipc_connection, portable_service_listener_security_attributes, + should_allow_everyone_create_on_windows, +}; +#[cfg(target_os = "linux")] +pub(crate) use ipc_auth::{ + ensure_peer_executable_matches_current_by_fd, is_allowed_service_peer_uid, + log_rejected_uinput_connection, peer_uid_from_fd, +}; +#[cfg(target_os = "linux")] +use ipc_fs::terminal_count_candidate_uids; +#[cfg(any(target_os = "linux", target_os = "macos"))] +use ipc_fs::{ + check_pid, ensure_secure_ipc_parent_dir, scrub_secure_ipc_parent_dir, + should_scrub_parent_entries_after_check_pid, write_pid, +}; +use parity_tokio_ipc::{ + Connection as Conn, ConnectionClient as ConnClient, Endpoint, Incoming, SecurityAttributes, +}; +use serde_derive::{Deserialize, Serialize}; +#[cfg(any(target_os = "linux", target_os = "macos"))] +use std::cell::Cell; +#[cfg(any(target_os = "linux", target_os = "macos"))] +use std::os::unix::fs::PermissionsExt; +use std::{ + collections::HashMap, + sync::atomic::{AtomicBool, Ordering}, +}; // IPC actions here. pub const IPC_ACTION_CLOSE: &str = "close"; +#[cfg(target_os = "windows")] +const PORTABLE_SERVICE_IPC_HANDSHAKE_TIMEOUT_MS: u64 = 3_000; +#[cfg(target_os = "windows")] +pub(crate) const IPC_TOKEN_LEN: usize = 64; +#[cfg(target_os = "windows")] +const IPC_TOKEN_RANDOM_BYTES: usize = IPC_TOKEN_LEN / 2; +#[cfg(target_os = "windows")] +const _: () = assert!(IPC_TOKEN_LEN % 2 == 0); pub static EXIT_RECV_CLOSE: AtomicBool = AtomicBool::new(true); +#[cfg(any(target_os = "linux", target_os = "macos"))] +thread_local! { + static USE_USER_MAIN_IPC: Cell = Cell::new(false); +} + +#[must_use = "bind this guard to a local variable to keep the IPC scope active"] +/// Thread-local guard for routing root main IPC to the active user on Linux/macOS. +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub(crate) struct UserMainIpcScope { + previous: bool, +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +impl UserMainIpcScope { + pub(crate) fn new() -> Self { + let previous = USE_USER_MAIN_IPC.with(|use_user_main| { + let previous = use_user_main.get(); + use_user_main.set(true); + previous + }); + Self { previous } + } +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +impl Drop for UserMainIpcScope { + fn drop(&mut self) { + USE_USER_MAIN_IPC.with(|use_user_main| use_user_main.set(self.previous)); + } +} + +#[inline] +pub async fn connect_service(ms_timeout: u64) -> ResultType> { + connect(ms_timeout, crate::POSTFIX_SERVICE).await +} + #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(tag = "t", content = "c")] pub enum FS { + ReadEmptyDirs { + dir: String, + include_hidden: bool, + }, ReadDir { dir: String, include_hidden: bool, @@ -101,12 +182,41 @@ pub enum FS { file_size: u64, last_modified: u64, is_upload: bool, + is_resume: bool, }, + SendConfirm(Vec), Rename { id: i32, path: String, new_name: String, }, + // CM-side file reading operations (Windows only) + // These enable Connection Manager to read files and stream them back to Connection + ReadFile { + path: String, + id: i32, + file_num: i32, + include_hidden: bool, + conn_id: i32, + overwrite_detection: bool, + }, + CancelRead { + id: i32, + conn_id: i32, + }, + SendConfirmForRead { + id: i32, + file_num: i32, + skip: bool, + offset_blk: u32, + conn_id: i32, + }, + ReadAllFiles { + path: String, + id: i32, + include_hidden: bool, + conn_id: i32, + }, } #[cfg(target_os = "windows")] @@ -171,8 +281,10 @@ pub enum DataControl { pub enum DataPortableService { Ping, Pong, + AuthToken(String), + AuthResult(bool), ConnCount(Option), - Mouse((Vec, i32)), + Mouse((Vec, i32, String, u32, bool, bool)), Pointer((Vec, i32)), Key(Vec), RequestStart, @@ -186,8 +298,11 @@ pub enum Data { Login { id: i32, is_file_transfer: bool, + is_view_camera: bool, + is_terminal: bool, peer_id: String, name: String, + avatar: String, authorized: bool, port_forward: String, keyboard: bool, @@ -198,6 +313,7 @@ pub enum Data { restart: bool, recording: bool, block_input: bool, + privacy_mode: bool, from_switch: bool, }, ChatMessage { @@ -213,8 +329,6 @@ pub enum Data { MouseMoveTime(i64), Authorize, Close, - #[cfg(target_os = "android")] - InputControl(bool), #[cfg(windows)] SAS, UserSid(Option), @@ -228,13 +342,14 @@ pub enum Data { FS(FS), Test, SyncConfig(Option>), - #[cfg(not(any(target_os = "android", target_os = "ios")))] + #[cfg(target_os = "windows")] ClipboardFile(ClipboardFile), ClipboardFileEnabled(bool), #[cfg(target_os = "windows")] ClipboardNonFile(Option<(String, Vec)>), PrivacyModeState((i32, PrivacyModeState, String)), TestRendezvousServer, + Deployed, #[cfg(not(any(target_os = "android", target_os = "ios")))] Keyboard(DataKeyboard), #[cfg(not(any(target_os = "android", target_os = "ios")))] @@ -247,7 +362,14 @@ pub enum Data { Empty, Disconnected, DataPortableService(DataPortableService), + #[cfg(feature = "flutter")] + #[cfg(not(any(target_os = "android", target_os = "ios")))] SwitchSidesRequest(String), + #[cfg(feature = "flutter")] + #[cfg(not(any(target_os = "android", target_os = "ios")))] + SwitchSidesUuid(String, String, Option), + #[cfg(feature = "flutter")] + #[cfg(not(any(target_os = "android", target_os = "ios")))] SwitchSidesBack, UrlLink(String), VoiceCallIncoming, @@ -263,13 +385,98 @@ pub enum Data { #[cfg(windows)] ControlledSessionCount(usize), CmErr(String), + // CM-side file reading responses (Windows only) + // These are sent from CM back to Connection when CM handles file reading + /// Response to ReadFile: contains initial file list or error + ReadJobInitResult { + id: i32, + file_num: i32, + include_hidden: bool, + conn_id: i32, + /// Serialized protobuf bytes of FileDirectory, or error string + result: Result, String>, + }, + /// File data block read by CM. + /// + /// The actual data is sent separately via `send_raw()` after this message to avoid + /// JSON encoding overhead for large binary data. This mirrors the `WriteBlock` pattern. + /// + /// **Protocol:** + /// - Sender: `send(FileBlockFromCM{...})` then `send_raw(data)` + /// - Receiver: `next()` returns `FileBlockFromCM`, then `next_raw()` returns data bytes + /// + /// **Note on empty data (e.g., empty files):** + /// Empty data is supported. The IPC connection uses `BytesCodec` with `raw=false` (default), + /// which prefixes each frame with a length header. So `send_raw(Bytes::new())` sends a + /// 1-byte frame (length=0), and `next_raw()` correctly returns an empty `BytesMut`. + /// See `libs/hbb_common/src/bytes_codec.rs` test `test_codec2` for verification. + FileBlockFromCM { + id: i32, + file_num: i32, + /// Data is sent separately via `send_raw()` to avoid JSON encoding overhead. + /// This field is skipped during serialization; sender must call `send_raw()` after sending. + /// Receiver must call `next_raw()` and populate this field manually. + #[serde(skip)] + data: bytes::Bytes, + compressed: bool, + conn_id: i32, + }, + /// File read completed successfully + FileReadDone { + id: i32, + file_num: i32, + conn_id: i32, + }, + /// File read failed with error + FileReadError { + id: i32, + file_num: i32, + err: String, + conn_id: i32, + }, + /// Digest info from CM for overwrite detection + FileDigestFromCM { + id: i32, + file_num: i32, + last_modified: u64, + file_size: u64, + is_resume: bool, + conn_id: i32, + }, + /// Response to ReadAllFiles: recursive directory listing + AllFilesResult { + id: i32, + conn_id: i32, + path: String, + /// Serialized protobuf bytes of FileDirectory, or error string + result: Result, String>, + }, CheckHwcodec, + #[cfg(feature = "flutter")] VideoConnCount(Option), - // Although the key is not neccessary, it is used to avoid hardcoding the key. + // Although the key is not necessary, it is used to avoid hardcoding the key. WaylandScreencastRestoreToken((String, String)), HwCodecConfig(Option), RemoveTrustedDevices(Vec), ClearTrustedDevices, + #[cfg(all(target_os = "windows", feature = "flutter"))] + PrinterData(Vec), + InstallOption(Option<(String, String)>), + #[cfg(all( + feature = "flutter", + not(any(target_os = "android", target_os = "ios")) + ))] + ControllingSessionCount(usize), + #[cfg(target_os = "linux")] + TerminalSessionCount(usize), + #[cfg(target_os = "windows")] + PortForwardSessionCount(Option), + SocksWs(Option, String)>>), + #[cfg(not(any(target_os = "android", target_os = "ios")))] + Whiteboard((String, crate::whiteboard::CustomEvent)), + ControlPermissionsRemoteModify(Option), + #[cfg(target_os = "windows")] + FileTransferEnabledState(Option), } #[tokio::main(flavor = "current_thread")] @@ -281,6 +488,22 @@ pub async fn start(postfix: &str) -> ResultType<()> { Ok(stream) => { let mut stream = Connection::new(stream); let postfix = postfix.to_owned(); + #[cfg(any(target_os = "linux", target_os = "macos"))] + if config::is_service_ipc_postfix(&postfix) { + if !authorize_service_scoped_ipc_connection(&stream, &postfix) { + continue; + } + } + #[cfg(windows)] + if postfix.is_empty() { + // Windows main IPC (`postfix == ""`) is authorized here. + // Other security-sensitive channels use dedicated authorization paths: + // - `_portable_service`: portable-service listener + handshake policy + // - service-scoped postfixes: service-specific listener/authorization + if !authorize_windows_main_ipc_connection(&stream, &postfix) { + continue; + } + } tokio::spawn(async move { loop { match stream.next().await { @@ -289,9 +512,48 @@ pub async fn start(postfix: &str) -> ResultType<()> { break; } Ok(Some(data)) => { + // On Linux/macOS, the protected `_service` channel is used only for + // syncing config between root service and the active user process. + // + // NOTE: `is_service_ipc_postfix()` also includes `_uinput_*`, but those + // channels are handled by the dedicated uinput listener/protocol in + // `src/server/uinput.rs` and therefore do not share this Data enum + // allowlist. The SyncConfig allowlist here is intentionally scoped to the + // `_service` channel only. + // + // Keep this explicit branch to avoid policy drift between `_service` and + // uinput IPC paths while still minimizing exposed message surface here. + #[cfg(any(target_os = "linux", target_os = "macos"))] + if postfix == crate::POSTFIX_SERVICE { + if matches!(&data, Data::SyncConfig(_)) { + handle(data, &mut stream).await; + } else { + log::warn!( + "Rejected non-sync data on protected _service IPC channel: postfix={}, data_kind={:?}, peer_uid={:?}", + postfix, + std::mem::discriminant(&data), + stream.peer_uid() + ); + // Close the connection to avoid keeping a protected channel + // alive while repeatedly receiving invalid traffic. + break; + } + continue; + } handle(data, &mut stream).await; } - _ => {} + Ok(None) => { + // `Ok(None)` means a complete frame arrived but did not + // deserialize into `Data`. Peer close/reset is returned as + // `Err` by `ConnectionTmpl::next()`. Keep the historical + // ignore behavior except on the protected `_service` channel. + #[cfg(any(target_os = "linux", target_os = "macos"))] + { + if postfix == crate::POSTFIX_SERVICE { + break; + } + } + } } } }); @@ -306,20 +568,77 @@ pub async fn start(postfix: &str) -> ResultType<()> { pub async fn new_listener(postfix: &str) -> ResultType { let path = Config::ipc_path(postfix); - #[cfg(not(any(windows, target_os = "android", target_os = "ios")))] - check_pid(postfix).await; + #[cfg(any(target_os = "linux", target_os = "macos"))] + let should_scrub_parent_entries = ensure_secure_ipc_parent_dir(&path, postfix)?; + #[cfg(any(target_os = "linux", target_os = "macos"))] + let existing_listener_alive = check_pid(postfix).await; + #[cfg(any(target_os = "linux", target_os = "macos"))] + if should_scrub_parent_entries_after_check_pid( + should_scrub_parent_entries, + existing_listener_alive, + ) { + scrub_secure_ipc_parent_dir(&path, postfix)?; + } let mut endpoint = Endpoint::new(path.clone()); - match SecurityAttributes::allow_everyone_create() { + let security_attrs = { + #[cfg(windows)] + { + if postfix == "_portable_service" { + portable_service_listener_security_attributes() + } else if should_allow_everyone_create_on_windows(postfix) { + SecurityAttributes::allow_everyone_create() + } else { + Ok(SecurityAttributes::empty()) + } + } + #[cfg(not(windows))] + { + SecurityAttributes::allow_everyone_create() + } + }; + match security_attrs { Ok(attr) => endpoint.set_security_attributes(attr), - Err(err) => log::error!("Failed to set ipc{} security: {}", postfix, err), + Err(err) => { + log::error!("Failed to set ipc{} security: {}", postfix, err); + #[cfg(windows)] + if postfix == "_portable_service" { + // Fail closed for `_portable_service` when SDDL construction fails. + // This endpoint is security-critical and must not start with default ACLs. + return Err(err.into()); + } + } }; match endpoint.incoming() { Ok(incoming) => { - log::info!("Started ipc{} server at path: {}", postfix, &path); - #[cfg(not(windows))] + if postfix == crate::POSTFIX_SERVICE { + log::info!("Started protected ipc service server: postfix={}", postfix); + } else { + log::info!("Started ipc{} server at path: {}", postfix, &path); + } + #[cfg(any(target_os = "linux", target_os = "macos"))] { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o0777)).ok(); + // NOTE: On Linux/macOS, some IPC sockets are intentionally world-connectable + // (0666) so the active (non-root) user process can connect. Authorization is + // enforced at accept-time for these channels, and the protected `_service` + // channel is further restricted by an explicit message allowlist (SyncConfig + // only). + let socket_mode = if config::is_service_ipc_postfix(postfix) { + 0o0666 + } else { + 0o0600 + }; + if let Err(err) = + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(socket_mode)) + { + log::error!( + "Failed to set permissions on ipc{} socket at path {}: {}", + postfix, + &path, + err + ); + std::fs::remove_file(&path).ok(); + return Err(err.into()); + } write_pid(postfix); } Ok(incoming) @@ -336,29 +655,56 @@ pub async fn new_listener(postfix: &str) -> ResultType { } } -pub struct CheckIfRestart(String, Vec, String, String); +pub struct CheckIfRestart { + stop_service: String, + rendezvous_servers: Vec, + audio_input: String, + voice_call_input: String, + ws: String, + disable_udp: String, + allow_insecure_tls_fallback: String, + api_server: String, +} impl CheckIfRestart { pub fn new() -> CheckIfRestart { - CheckIfRestart( - Config::get_option("stop-service"), - Config::get_rendezvous_servers(), - Config::get_option("audio-input"), - Config::get_option("voice-call-input"), - ) + CheckIfRestart { + stop_service: Config::get_option("stop-service"), + rendezvous_servers: Config::get_rendezvous_servers(), + audio_input: Config::get_option("audio-input"), + voice_call_input: Config::get_option("voice-call-input"), + ws: Config::get_option(OPTION_ALLOW_WEBSOCKET), + disable_udp: Config::get_option(config::keys::OPTION_DISABLE_UDP), + allow_insecure_tls_fallback: Config::get_option( + config::keys::OPTION_ALLOW_INSECURE_TLS_FALLBACK, + ), + api_server: Config::get_option("api-server"), + } } } impl Drop for CheckIfRestart { fn drop(&mut self) { - if self.0 != Config::get_option("stop-service") - || self.1 != Config::get_rendezvous_servers() + // If https proxy is used, we need to restart rendezvous mediator. + // No need to check if https proxy is used, because this option does not change frequently + // and restarting mediator is safe even https proxy is not used. + let allow_insecure_tls_fallback_changed = self.allow_insecure_tls_fallback + != Config::get_option(config::keys::OPTION_ALLOW_INSECURE_TLS_FALLBACK); + if allow_insecure_tls_fallback_changed + || self.stop_service != Config::get_option("stop-service") + || self.rendezvous_servers != Config::get_rendezvous_servers() + || self.ws != Config::get_option(OPTION_ALLOW_WEBSOCKET) + || self.disable_udp != Config::get_option(config::keys::OPTION_DISABLE_UDP) + || self.api_server != Config::get_option("api-server") { + if allow_insecure_tls_fallback_changed { + hbb_common::tls::reset_tls_cache(); + } RendezvousMediator::restart(); } - if self.2 != Config::get_option("audio-input") { + if self.audio_input != Config::get_option("audio-input") { crate::audio_service::restart(); } - if self.3 != Config::get_option("voice-call-input") { + if self.voice_call_input != Config::get_option("voice-call-input") { crate::audio_service::set_voice_call_input_device( Some(Config::get_option("voice-call-input")), true, @@ -443,22 +789,36 @@ async fn handle(data: Data, stream: &mut Connection) { allow_err!(stream.send(&Data::Socks(Config::get_socks())).await); } Some(data) => { + let _nat = CheckTestNatType::new(); if data.proxy.is_empty() { Config::set_socks(None); } else { Config::set_socks(Some(data)); } - crate::common::test_nat_type(); RendezvousMediator::restart(); log::info!("socks updated"); } }, + Data::SocksWs(s) => match s { + None => { + allow_err!( + stream + .send(&Data::SocksWs(Some(Box::new(( + Config::get_socks(), + Config::get_option(OPTION_ALLOW_WEBSOCKET) + ))))) + .await + ); + } + _ => {} + }, + #[cfg(feature = "flutter")] Data::VideoConnCount(None) => { let n = crate::server::AUTHED_CONNS .lock() .unwrap() .iter() - .filter(|x| x.1 == crate::server::AuthConnType::Remote) + .filter(|x| x.conn_type == crate::server::AuthConnType::Remote) .count(); allow_err!(stream.send(&Data::VideoConnCount(Some(n))).await); } @@ -469,8 +829,29 @@ async fn handle(data: Data, stream: &mut Connection) { value = Some(Config::get_id()); } else if name == "temporary-password" { value = Some(password::temporary_password()); - } else if name == "permanent-password" { - value = Some(Config::get_permanent_password()); + } else if name == "permanent-password-storage-and-salt" { + let (storage, salt) = Config::get_local_permanent_password_storage_and_salt(); + value = Some(storage + "\n" + &salt); + } else if name == "permanent-password-set" { + value = Some(if Config::has_permanent_password() { + "Y".to_owned() + } else { + "N".to_owned() + }); + } else if name == "permanent-password-is-preset" { + let hard = config::HARD_SETTINGS + .read() + .unwrap() + .get("password") + .cloned() + .unwrap_or_default(); + let is_preset = + !hard.is_empty() && Config::matches_permanent_password_plain(&hard); + value = Some(if is_preset { + "Y".to_owned() + } else { + "N".to_owned() + }); } else if name == "salt" { value = Some(Config::get_salt()); } else if name == "rendezvous_server" { @@ -488,7 +869,8 @@ async fn handle(data: Data, stream: &mut Connection) { None }; } else if name == "hide_cm" { - value = if crate::hbbs_http::sync::is_pro() { + value = if crate::hbbs_http::sync::is_pro() || crate::common::is_custom_client() + { Some(hbb_common::password_security::hide_cm().to_string()) } else { None @@ -505,13 +887,24 @@ async fn handle(data: Data, stream: &mut Connection) { allow_err!(stream.send(&Data::Config((name, value))).await); } Some(value) => { + let mut updated = true; if name == "id" { Config::set_key_confirmed(false); Config::set_id(&value); } else if name == "temporary-password" { password::update_temporary_password(); } else if name == "permanent-password" { - Config::set_permanent_password(&value); + if Config::is_disable_change_permanent_password() { + log::warn!("Changing permanent password is disabled"); + updated = false; + } else { + Config::set_permanent_password(&value); + } + // Explicitly ACK/NACK permanent-password writes. This allows UIs/FFI to + // distinguish "accepted by daemon" vs "IPC send succeeded" without + // reading back any secret. + let ack = if updated { "Y" } else { "N" }.to_owned(); + allow_err!(stream.send(&Data::Config((name.clone(), Some(ack)))).await); } else if name == "salt" { Config::set_salt(&value); } else if name == "voice-call-input" { @@ -521,7 +914,9 @@ async fn handle(data: Data, stream: &mut Connection) { } else { return; } - log::info!("{} updated", name); + if updated { + log::info!("{} updated", name); + } } }, Data::Options(value) => match value { @@ -531,6 +926,7 @@ async fn handle(data: Data, stream: &mut Connection) { } Some(value) => { let _chk = CheckIfRestart::new(); + let _nat = CheckTestNatType::new(); if let Some(v) = value.get("privacy-mode-impl-key") { crate::privacy_mode::switch(v); } @@ -571,6 +967,12 @@ async fn handle(data: Data, stream: &mut Connection) { Data::TestRendezvousServer => { crate::test_rendezvous_server(); } + Data::Deployed => { + crate::rendezvous_mediator::NEEDS_DEPLOY.store(false, Ordering::SeqCst); + crate::rendezvous_mediator::RendezvousMediator::restart(); + } + #[cfg(feature = "flutter")] + #[cfg(not(any(target_os = "android", target_os = "ios")))] Data::SwitchSidesRequest(id) => { let uuid = uuid::Uuid::new_v4(); crate::server::insert_switch_sides_uuid(id, uuid.clone()); @@ -580,6 +982,19 @@ async fn handle(data: Data, stream: &mut Connection) { .await ); } + #[cfg(feature = "flutter")] + #[cfg(not(any(target_os = "android", target_os = "ios")))] + Data::SwitchSidesUuid(uuid, id, None) => { + let allowed = uuid + .parse::() + .map(|uuid| crate::server::remove_pending_switch_sides_uuid(&id, &uuid)) + .unwrap_or(false); + allow_err!( + stream + .send(&Data::SwitchSidesUuid(uuid, id, 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, @@ -593,6 +1008,18 @@ async fn handle(data: Data, stream: &mut Connection) { .await ); } + #[cfg(all( + feature = "flutter", + not(any(target_os = "android", target_os = "ios")) + ))] + Data::ControllingSessionCount(count) => { + crate::updater::update_controlling_session_count(count); + } + #[cfg(target_os = "linux")] + Data::TerminalSessionCount(_) => { + let count = crate::terminal_service::get_terminal_session_count(true); + allow_err!(stream.send(&Data::TerminalSessionCount(count)).await); + } #[cfg(feature = "hwcodec")] #[cfg(not(any(target_os = "android", target_os = "ios")))] Data::CheckHwcodec => { @@ -657,16 +1084,274 @@ async fn handle(data: Data, stream: &mut Connection) { Data::ClearTrustedDevices => { Config::clear_trusted_devices(); } + Data::InstallOption(opt) => match opt { + Some((_k, _v)) => { + #[cfg(target_os = "windows")] + if let Err(e) = crate::platform::windows::update_install_option(&_k, &_v) { + log::error!( + "Failed to update install option \"{}\" to \"{}\", error: {}", + &_k, + &_v, + e + ); + } + } + None => { + // `None` is usually used to get values. + // This branch is left blank for unification and further use. + } + }, + #[cfg(target_os = "windows")] + Data::PortForwardSessionCount(c) => match c { + None => { + let count = crate::server::AUTHED_CONNS + .lock() + .unwrap() + .iter() + .filter(|c| c.conn_type == crate::server::AuthConnType::PortForward) + .count(); + allow_err!( + stream + .send(&Data::PortForwardSessionCount(Some(count))) + .await + ); + } + _ => { + // Port forward session count is only a get value. + } + }, + Data::ControlPermissionsRemoteModify(_) => { + use hbb_common::rendezvous_proto::control_permissions::Permission; + let state = + crate::server::get_control_permission_state(Permission::remote_modify, true); + allow_err!( + stream + .send(&Data::ControlPermissionsRemoteModify(state)) + .await + ); + } + #[cfg(target_os = "windows")] + Data::FileTransferEnabledState(_) => { + use hbb_common::rendezvous_proto::control_permissions::Permission; + let state = crate::server::get_control_permission_state(Permission::file, false); + let enabled = state.unwrap_or_else(|| { + crate::server::Connection::is_permission_enabled_locally( + config::keys::OPTION_ENABLE_FILE_TRANSFER, + ) + }); + allow_err!( + stream + .send(&Data::FileTransferEnabledState(Some(enabled))) + .await + ); + } _ => {} + }; +} + +#[cfg(target_os = "windows")] +pub(crate) fn generate_one_time_ipc_token() -> ResultType { + use hbb_common::rand::{rngs::OsRng, RngCore as _}; + use std::fmt::Write as _; + + let mut random_bytes = [0u8; IPC_TOKEN_RANDOM_BYTES]; + let mut rng = OsRng; + rng.try_fill_bytes(&mut random_bytes).map_err(|err| { + hbb_common::anyhow::anyhow!( + "failed to generate portable service ipc token from OsRng: {}", + err + ) + })?; + + let mut token = String::with_capacity(IPC_TOKEN_LEN); + for byte in random_bytes { + let _ = write!(token, "{:02x}", byte); + } + Ok(token) +} + +#[cfg(target_os = "windows")] +pub(crate) fn constant_time_ipc_token_eq(expected: &str, candidate: &str) -> bool { + if expected.len() != IPC_TOKEN_LEN || candidate.len() != IPC_TOKEN_LEN { + return false; + } + expected + .as_bytes() + .iter() + .zip(candidate.as_bytes().iter()) + .fold(0u8, |diff, (left, right)| diff | (*left ^ *right)) + == 0 +} + +#[cfg(target_os = "windows")] +pub(crate) async fn portable_service_ipc_handshake_as_client( + stream: &mut ConnectionTmpl, + token: &str, +) -> ResultType<()> +where + T: AsyncRead + AsyncWrite + std::marker::Unpin, +{ + stream + .send(&Data::DataPortableService(DataPortableService::AuthToken( + token.to_owned(), + ))) + .await?; + match stream + .next_timeout(PORTABLE_SERVICE_IPC_HANDSHAKE_TIMEOUT_MS) + .await? + { + Some(Data::DataPortableService(DataPortableService::AuthResult(true))) => Ok(()), + Some(Data::DataPortableService(DataPortableService::AuthResult(false))) => { + bail!("portable service ipc handshake was rejected by server") + } + Some(_) | None => bail!("portable service ipc handshake returned an unexpected response"), } } -pub async fn connect(ms_timeout: u64, postfix: &str) -> ResultType> { - let path = Config::ipc_path(postfix); - let client = timeout(ms_timeout, Endpoint::connect(&path)).await??; +#[cfg(target_os = "windows")] +pub(crate) async fn portable_service_ipc_handshake_as_server( + stream: &mut ConnectionTmpl, + mut validate_token: F, +) -> ResultType<()> +where + T: AsyncRead + AsyncWrite + std::marker::Unpin, + // Token validators must use `constant_time_ipc_token_eq` or an equivalent + // fixed-length comparison; this handshake is part of the privilege boundary. + F: FnMut(&str) -> bool, +{ + let authorized = match stream + .next_timeout(PORTABLE_SERVICE_IPC_HANDSHAKE_TIMEOUT_MS) + .await? + { + Some(Data::DataPortableService(DataPortableService::AuthToken(token))) => { + validate_token(&token) + } + Some(_) | None => false, + }; + stream + .send(&Data::DataPortableService(DataPortableService::AuthResult( + authorized, + ))) + .await?; + if !authorized { + bail!("portable service ipc handshake failed") + } + Ok(()) +} + +#[inline] +async fn connect_with_path(ms_timeout: u64, path: &str) -> ResultType> { + let client = timeout(ms_timeout, Endpoint::connect(path)).await??; Ok(ConnectionTmpl::new(client)) } +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[inline] +fn select_server_uid_for_user_main_ipc( + server_uids: &[u32], + active_uid: Option, + prefer_root: bool, +) -> ResultType { + let mut server_uids = server_uids.to_vec(); + server_uids.sort_unstable(); + server_uids.dedup(); + + match server_uids.as_slice() { + [] => { + if let Some(uid) = active_uid { + // If no `--server` processes are found but the active user is identifiable, + // try the active user anyway because the main process may also listen on "" IPC. + return Ok(uid); + } else { + bail!("No --server process found for user main IPC") + } + } + [uid] => return Ok(*uid), + _ => {} + } + + if prefer_root && server_uids.contains(&0) { + return Ok(0); + } + if let Some(active_uid) = active_uid.filter(|uid| server_uids.contains(uid)) { + return Ok(active_uid); + } + bail!("Multiple --server processes found for user main IPC"); +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn running_server_uids_for_current_exe() -> ResultType> { + let current_exe = std::env::current_exe()?; + let current_exe_path = std::fs::canonicalize(¤t_exe)?; + let current_pid = hbb_common::sysinfo::Pid::from_u32(std::process::id()); + let mut sys = hbb_common::sysinfo::System::new(); + sys.refresh_processes(); + let mut server_uids = Vec::new(); + for process in sys.processes().values() { + if process.pid() == current_pid { + continue; + } + if process.cmd().get(1).map_or(true, |arg| arg != "--server") { + continue; + } + let Ok(process_path) = std::fs::canonicalize(process.exe()) else { + continue; + }; + if process_path != current_exe_path { + continue; + } + let Some(uid) = process.user_id().map(|uid| **uid as u32) else { + // Root CLI management commands need a stable matching `--server` target. + // If this key process races during enumeration, failing the command is clearer + // than silently skipping it; `--server` is not expected to exit frequently. + bail!("Failed to read --server process uid"); + }; + server_uids.push(uid); + } + Ok(server_uids) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn user_main_ipc_server_uid() -> ResultType { + let server_uids = running_server_uids_for_current_exe()?; + #[cfg(target_os = "linux")] + let prefer_root = crate::platform::linux::is_login_screen_wayland(); + #[cfg(target_os = "macos")] + let prefer_root = false; + select_server_uid_for_user_main_ipc(&server_uids, active_uid(), prefer_root) +} + +pub async fn connect(ms_timeout: u64, postfix: &str) -> ResultType> { + #[cfg(any(target_os = "linux", target_os = "macos"))] + { + let use_user_main_ipc = USE_USER_MAIN_IPC.with(|use_user_main| use_user_main.get()); + let is_root_main_ipc = + unsafe { hbb_common::libc::geteuid() == 0 } && postfix.is_empty() && use_user_main_ipc; + if is_root_main_ipc { + let uid = user_main_ipc_server_uid()?; + let path = Config::ipc_path_for_uid(uid, postfix); + return connect_with_path(ms_timeout, &path).await; + } + let path = Config::ipc_path(postfix); + return connect_with_path(ms_timeout, &path).await; + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + let path = Config::ipc_path(postfix); + connect_with_path(ms_timeout, &path).await + } +} + +#[cfg(target_os = "linux")] +pub async fn connect_for_uid( + ms_timeout: u64, + uid: u32, + postfix: &str, +) -> ResultType> { + let path = Config::ipc_path_for_uid(uid, postfix); + connect_with_path(ms_timeout, &path).await +} + #[cfg(target_os = "linux")] #[tokio::main(flavor = "current_thread")] pub async fn start_pa() { @@ -744,54 +1429,6 @@ pub async fn start_pa() { } } -#[inline] -#[cfg(not(windows))] -fn get_pid_file(postfix: &str) -> String { - let path = Config::ipc_path(postfix); - format!("{}.pid", path) -} - -#[cfg(not(any(windows, target_os = "android", target_os = "ios")))] -async fn check_pid(postfix: &str) { - let pid_file = get_pid_file(postfix); - if let Ok(mut file) = File::open(&pid_file) { - let mut content = String::new(); - file.read_to_string(&mut content).ok(); - let pid = content.parse::().unwrap_or(0); - if pid > 0 { - use hbb_common::sysinfo::System; - let mut sys = System::new(); - sys.refresh_processes(); - if let Some(p) = sys.process(pid.into()) { - if let Some(current) = sys.process((std::process::id() as usize).into()) { - if current.name() == p.name() { - // double check with connect - if connect(1000, postfix).await.is_ok() { - return; - } - } - } - } - } - } - // if not remove old ipc file, the new ipc creation will fail - // if we remove a ipc file, but the old ipc process is still running, - // new connection to the ipc will connect to new ipc, old connection to old ipc still keep alive - std::fs::remove_file(&Config::ipc_path(postfix)).ok(); -} - -#[inline] -#[cfg(not(windows))] -fn write_pid(postfix: &str) { - let path = get_pid_file(postfix); - if let Ok(mut file) = File::create(&path) { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o0777)).ok(); - file.write_all(&std::process::id().to_string().into_bytes()) - .ok(); - } -} - pub struct ConnectionTmpl { inner: Framed, } @@ -905,13 +1542,57 @@ pub fn update_temporary_password() -> ResultType<()> { set_config("temporary-password", "".to_owned()) } -pub fn get_permanent_password() -> String { - if let Ok(Some(v)) = get_config("permanent-password") { - Config::set_permanent_password(&v); - v - } else { - Config::get_permanent_password() +fn apply_permanent_password_storage_and_salt_payload(payload: Option<&str>) -> ResultType<()> { + let Some(payload) = payload else { + return Ok(()); + }; + let Some((storage, salt)) = payload.split_once('\n') else { + bail!("Invalid permanent-password-storage-and-salt payload"); + }; + + if storage.is_empty() { + Config::set_permanent_password_storage_for_sync("", "")?; + return Ok(()); } + + Config::set_permanent_password_storage_for_sync(storage, salt)?; + Ok(()) +} + +pub fn sync_permanent_password_storage_from_daemon() -> ResultType<()> { + let v = get_config("permanent-password-storage-and-salt")?; + apply_permanent_password_storage_and_salt_payload(v.as_deref()) +} + +async fn sync_permanent_password_storage_from_daemon_async() -> ResultType<()> { + let ms_timeout = 1_000; + let v = get_config_async("permanent-password-storage-and-salt", ms_timeout).await?; + apply_permanent_password_storage_and_salt_payload(v.as_deref()) +} + +pub fn is_permanent_password_set() -> bool { + match get_config("permanent-password-set") { + Ok(Some(v)) => { + let v = v.trim(); + return v == "Y"; + } + Ok(None) => { + // No response/value (timeout). + } + Err(_) => { + // Connection error. + } + } + log::warn!("Failed to query permanent password state from daemon"); + false +} + +pub fn is_permanent_password_preset() -> bool { + if let Ok(Some(v)) = get_config("permanent-password-is-preset") { + let v = v.trim(); + return v == "Y"; + } + false } pub fn get_fingerprint() -> String { @@ -921,8 +1602,41 @@ pub fn get_fingerprint() -> String { } pub fn set_permanent_password(v: String) -> ResultType<()> { - Config::set_permanent_password(&v); - set_config("permanent-password", v) + if Config::is_disable_change_permanent_password() { + bail!("Changing permanent password is disabled"); + } + if set_permanent_password_with_ack(v)? { + Ok(()) + } else { + bail!("Changing permanent password was rejected by daemon"); + } +} + +#[tokio::main(flavor = "current_thread")] +pub async fn set_permanent_password_with_ack(v: String) -> ResultType { + set_permanent_password_with_ack_async(v).await +} + +async fn set_permanent_password_with_ack_async(v: String) -> ResultType { + // The daemon ACK/NACK is expected quickly since it applies the config in-process. + let ms_timeout = 1_000; + let mut c = connect(ms_timeout, "").await?; + c.send_config("permanent-password", v).await?; + if let Some(Data::Config((name2, Some(v)))) = c.next_timeout(ms_timeout).await? { + if name2 == "permanent-password" { + let v = v.trim(); + let ok = v == "Y"; + if ok { + // Ensure the hashed permanent password storage is written to the user config file. + // This sync must not affect the daemon ACK outcome. + if let Err(err) = sync_permanent_password_storage_from_daemon_async().await { + log::warn!("Failed to sync permanent password storage from daemon: {err}"); + } + } + return Ok(ok); + } + } + Ok(false) } #[cfg(feature = "flutter")] @@ -1057,6 +1771,7 @@ pub fn set_option(key: &str, value: &str) { #[tokio::main(flavor = "current_thread")] pub async fn set_options(value: HashMap) -> ResultType<()> { + let _nat = CheckTestNatType::new(); if let Ok(mut c) = connect(1000, "").await { c.send(&Data::Options(Some(value.clone()))).await?; // do not put below before connect, because we need to check should_exit @@ -1114,6 +1829,7 @@ pub async fn get_socks() -> Option { #[tokio::main(flavor = "current_thread")] pub async fn set_socks(value: config::Socks5Server) -> ResultType<()> { + let _nat = CheckTestNatType::new(); Config::set_socks(if value.proxy.is_empty() { None } else { @@ -1126,6 +1842,29 @@ pub async fn set_socks(value: config::Socks5Server) -> ResultType<()> { Ok(()) } +async fn get_socks_ws_(ms_timeout: u64) -> ResultType<(Option, String)> { + let mut c = connect(ms_timeout, "").await?; + c.send(&Data::SocksWs(None)).await?; + if let Some(Data::SocksWs(Some(value))) = c.next_timeout(ms_timeout).await? { + Config::set_socks(value.0.clone()); + Config::set_option(OPTION_ALLOW_WEBSOCKET.to_string(), value.1.clone()); + Ok(*value) + } else { + Ok(( + Config::get_socks(), + Config::get_option(OPTION_ALLOW_WEBSOCKET), + )) + } +} + +#[tokio::main(flavor = "current_thread")] +pub async fn get_socks_ws() -> (Option, String) { + get_socks_ws_(1_000).await.unwrap_or(( + Config::get_socks(), + Config::get_option(OPTION_ALLOW_WEBSOCKET), + )) +} + pub fn get_proxy_status() -> bool { Config::get_socks().is_some() } @@ -1136,6 +1875,13 @@ pub async fn test_rendezvous_server() -> ResultType<()> { Ok(()) } +#[tokio::main(flavor = "current_thread")] +pub async fn notify_deployed() -> ResultType<()> { + let mut c = connect(1000, "").await?; + c.send(&Data::Deployed).await?; + Ok(()) +} + #[tokio::main(flavor = "current_thread")] pub async fn send_url_scheme(url: String) -> ResultType<()> { connect(1_000, "_url") @@ -1153,9 +1899,10 @@ pub fn close_all_instances() -> ResultType { } } +#[cfg(windows)] #[tokio::main(flavor = "current_thread")] pub async fn connect_to_user_session(usid: Option) -> ResultType<()> { - let mut stream = crate::ipc::connect(1000, crate::POSTFIX_SERVICE).await?; + let mut stream = crate::ipc::connect_service(1000).await?; timeout(1000, stream.send(&crate::ipc::Data::UserSid(usid))).await??; Ok(()) } @@ -1166,6 +1913,16 @@ pub async fn notify_server_to_check_hwcodec() -> ResultType<()> { Ok(()) } +#[cfg(target_os = "windows")] +pub async fn get_port_forward_session_count(ms_timeout: u64) -> ResultType { + let mut c = connect(ms_timeout, "").await?; + c.send(&Data::PortForwardSessionCount(None)).await?; + if let Some(Data::PortForwardSessionCount(Some(count))) = c.next_timeout(ms_timeout).await? { + return Ok(count); + } + bail!("Failed to get port forward session count"); +} + #[cfg(feature = "hwcodec")] #[cfg(not(any(target_os = "android", target_os = "ios")))] #[tokio::main(flavor = "current_thread")] @@ -1257,6 +2014,92 @@ pub async fn clear_wayland_screencast_restore_token(key: String) -> ResultType ResultType<()> { + let mut c = connect(1000, "").await?; + c.send(&Data::ControllingSessionCount(count)).await?; + Ok(()) +} + +#[cfg(target_os = "linux")] +#[tokio::main(flavor = "current_thread")] +pub async fn get_terminal_session_count() -> ResultType { + let timeout_ms = 1_000; + let effective_uid = unsafe { hbb_common::libc::geteuid() as u32 }; + let candidate_uids = terminal_count_candidate_uids(effective_uid); + let mut last_err: Option = None; + for candidate_uid in candidate_uids { + let socket_path = Config::ipc_path_for_uid(candidate_uid, ""); + let connect_result = timeout(timeout_ms, Endpoint::connect(&socket_path)) + .await + .map_err(|err| { + anyhow::anyhow!( + "Timeout connecting to terminal ipc at {}: {}", + socket_path, + err + ) + }); + let connection = match connect_result { + Ok(Ok(connection)) => connection, + Ok(Err(err)) => { + last_err = Some(anyhow::anyhow!( + "Failed to connect to terminal ipc at {}: {}", + socket_path, + err + )); + continue; + } + Err(err) => { + last_err = Some(err); + continue; + } + }; + let mut ipc_conn = ConnectionTmpl::new(connection); + if let Err(err) = ipc_conn.send(&Data::TerminalSessionCount(0)).await { + last_err = Some(anyhow::anyhow!( + "Failed to request terminal session count via ipc at {}: {}", + socket_path, + err + )); + continue; + } + match ipc_conn.next_timeout(timeout_ms).await { + Ok(Some(Data::TerminalSessionCount(session_count))) => { + return Ok(session_count); + } + Ok(None) => { + last_err = Some(anyhow::anyhow!( + "Invalid response when requesting terminal session count via ipc at {}", + socket_path + )); + } + Ok(other) => { + last_err = Some(anyhow::anyhow!( + "Unexpected response when requesting terminal session count via ipc at {}: {:?}", + socket_path, + other.map(|v| std::mem::discriminant(&v)) + )); + } + Err(err) => { + last_err = Some(anyhow::anyhow!( + "Failed to read terminal session count via ipc at {}: {}", + socket_path, + err + )); + } + } + } + if let Some(err) = last_err { + Err(err.into()) + } else { + Ok(0) + } +} + async fn handle_wayland_screencast_restore_token( key: String, value: String, @@ -1272,12 +2115,94 @@ async fn handle_wayland_screencast_restore_token( return Ok(None); } +#[tokio::main(flavor = "current_thread")] +pub async fn set_install_option(k: String, v: String) -> ResultType<()> { + if let Ok(mut c) = connect(1000, "").await { + c.send(&&Data::InstallOption(Some((k, v)))).await?; + // do not put below before connect, because we need to check should_exit + c.next_timeout(1000).await.ok(); + } + Ok(()) +} + #[cfg(test)] mod test { use super::*; + #[test] fn verify_ffi_enum_data_size() { println!("{}", std::mem::size_of::()); - assert!(std::mem::size_of::() < 96); + assert!(std::mem::size_of::() <= 120); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn test_service_ipc_path_is_shared_across_uids() { + assert_eq!( + Config::ipc_path_for_uid(0, crate::POSTFIX_SERVICE), + Config::ipc_path_for_uid(501, crate::POSTFIX_SERVICE) + ); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn test_ipc_path_differs_by_uid_for_cm() { + let effective_uid = unsafe { hbb_common::libc::geteuid() as u32 }; + let other_uid = effective_uid.saturating_add(1); + let postfix = "_cm"; + + // Default connect path targets the current effective uid. + assert_eq!( + Config::ipc_path(postfix), + Config::ipc_path_for_uid(effective_uid, postfix) + ); + // A different uid yields a different socket path - this is the root cause of the + // cross-user regression when root spawns a user process but still connects as uid 0. + assert_ne!( + Config::ipc_path(postfix), + Config::ipc_path_for_uid(other_uid, postfix) + ); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn test_select_server_uid_uses_active_uid_when_no_server_found() { + assert_eq!( + select_server_uid_for_user_main_ipc(&[], Some(501), false).unwrap(), + 501 + ); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn test_select_server_uid_uses_single_server_uid() { + assert_eq!( + select_server_uid_for_user_main_ipc(&[501], None, false).unwrap(), + 501 + ); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn test_select_server_uid_prefers_active_uid_with_multiple_servers() { + assert_eq!( + select_server_uid_for_user_main_ipc(&[0, 501], Some(501), false).unwrap(), + 501 + ); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn test_select_server_uid_prefers_root_on_wayland_login_screen() { + assert_eq!( + select_server_uid_for_user_main_ipc(&[0, 501], Some(501), true).unwrap(), + 0 + ); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn test_select_server_uid_fails_when_multiple_servers_are_ambiguous() { + assert!(select_server_uid_for_user_main_ipc(&[501, 502], None, false).is_err()); } } diff --git a/src/ipc/auth.rs b/src/ipc/auth.rs new file mode 100644 index 000000000..77fd148c6 --- /dev/null +++ b/src/ipc/auth.rs @@ -0,0 +1,1075 @@ +use crate::ipc::{Connection, ConnectionTmpl}; +#[cfg(all(windows, not(feature = "flutter")))] +use hbb_common::sha2::{Digest, Sha256}; +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] +use hbb_common::{anyhow, bail, log, ResultType}; +#[cfg(any(target_os = "linux", target_os = "macos"))] +use hbb_common::{ + libc, + tokio::io::{AsyncRead, AsyncWrite}, +}; +#[cfg(windows)] +use parity_tokio_ipc::SecurityAttributes; +#[cfg(windows)] +use std::io; +#[cfg(all(windows, not(feature = "flutter")))] +use std::io::Read; +#[cfg(target_os = "macos")] +use std::os::unix::fs::MetadataExt; +#[cfg(any(target_os = "linux", target_os = "macos"))] +use std::os::unix::io::RawFd; +#[cfg(windows)] +use std::os::windows::io::AsRawHandle; +#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] +use std::{ + fs, + path::{Path, PathBuf}, + sync::{Mutex, OnceLock}, +}; +#[cfg(windows)] +use windows::Win32::{Foundation::HANDLE, System::Pipes::GetNamedPipeClientProcessId}; + +#[cfg(windows)] +#[inline] +pub(crate) fn should_allow_everyone_create_on_windows(postfix: &str) -> bool { + postfix.is_empty() || hbb_common::config::is_service_ipc_postfix(postfix) +} + +#[cfg(windows)] +#[inline] +pub(crate) fn portable_service_listener_security_attributes() -> io::Result { + let user_sid = crate::platform::windows::current_process_user_sid_string().map_err(|err| { + io::Error::new( + io::ErrorKind::Other, + format!("failed to resolve current process SID: {}", err), + ) + })?; + debug_assert!( + user_sid.starts_with("S-1-") + && user_sid + .bytes() + .all(|byte| byte.is_ascii_digit() || byte == b'-'), + "current_process_user_sid_string returned a non-SDDL SID: {}", + user_sid + ); + // SDDL: + // - `D:P` => protected DACL (no inherited ACEs) + // - `(A;;GA;;;SY)` => allow GENERIC_ALL to LocalSystem + // - `(A;;GA;;;{user_sid})` => allow GENERIC_ALL to current process user SID + // References: + // - Security Descriptor String Format: https://learn.microsoft.com/en-us/windows/win32/secauthz/security-descriptor-string-format + // - ACE strings in SDDL: https://learn.microsoft.com/en-us/windows/win32/secauthz/ace-strings + let sddl = format!("D:P(A;;GA;;;SY)(A;;GA;;;{user_sid})"); + SecurityAttributes::from_sddl(&sddl).map_err(|err| { + io::Error::new( + io::ErrorKind::Other, + format!( + "failed to build portable service listener security attributes from SDDL '{}': {}", + sddl, err + ), + ) + }) +} + +#[cfg(target_os = "macos")] +#[inline] +fn macos_service_ipc_allows_gui_and_service_binaries( + peer_exe: &Path, + current_exe: &Path, + postfix: &str, +) -> bool { + if postfix != crate::POSTFIX_SERVICE { + return false; + } + let Some(peer_dir) = peer_exe.parent() else { + return false; + }; + let Some(current_dir) = current_exe.parent() else { + return false; + }; + if !executable_paths_match(peer_dir, current_dir) { + return false; + } + + // On installed macOS builds, `_service` is listened by the `service` binary while the GUI + // process connects from the app executable within the same app bundle. + let gui_exe_name = std::ffi::OsString::from(crate::get_app_name()); + let gui_exe = gui_exe_name.as_os_str(); + let service_exe = std::ffi::OsStr::new("service"); + let allowed_exe = [Some(gui_exe), Some(service_exe)]; + let peer_name = peer_exe.file_name(); + let current_name = current_exe.file_name(); + allowed_exe + .iter() + .any(|name| os_str_eq_ignore_ascii_case(peer_name, *name)) + && allowed_exe + .iter() + .any(|name| os_str_eq_ignore_ascii_case(current_name, *name)) +} + +#[cfg(target_os = "windows")] +#[inline] +fn windows_portable_service_ipc_allows_logon_helper_executable( + _peer_exe: &Path, + postfix: &str, +) -> bool { + if postfix != "_portable_service" { + return false; + } + #[cfg(feature = "flutter")] + { + false + } + #[cfg(not(feature = "flutter"))] + { + let Some((_, expected)) = crate::platform::windows::portable_service_logon_helper_paths() + else { + return false; + }; + let Ok(expected) = fs::canonicalize(expected) else { + return false; + }; + let Ok(current_exe) = current_exe_canonical_path() else { + return false; + }; + portable_service_helper_is_trusted(_peer_exe, &expected, ¤t_exe) + } +} + +#[cfg(windows)] +#[inline] +pub(crate) fn is_allowed_windows_session_scoped_peer( + client_is_system: bool, + client_session_id: Option, + expected_session_id: Option, +) -> bool { + client_is_system + || matches!( + (client_session_id, expected_session_id), + (Some(client), Some(expected)) if client == expected + ) +} + +#[cfg(windows)] +#[inline] +fn is_allowed_windows_portable_service_peer( + client_is_system: Option, + _client_session_id: Option, + _expected_session_id: Option, +) -> bool { + // Portable-service listener DACL includes SYSTEM and current-process SID. + // In the portable-service path, current process is expected to run as SYSTEM, + // and the higher-layer peer policy stays SYSTEM-only. + matches!(client_is_system, Some(true)) +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +#[inline] +pub(crate) fn is_allowed_service_peer_uid(peer_uid: u32, active_uid: Option) -> bool { + // Root is allowed at the UID gate because the service side may run as root. + // Callers still enforce executable matching before accepting service-scoped peers. + peer_uid == 0 || active_uid.is_some_and(|uid| uid == peer_uid) +} + +#[cfg(target_os = "macos")] +#[inline] +fn console_owner_uid() -> Option { + fs::metadata("/dev/console") + .ok() + .map(|metadata| metadata.uid()) +} + +#[cfg(target_os = "macos")] +#[inline] +fn active_uid_strict() -> Option { + // Prefer the filesystem metadata over parsing external command output. + console_owner_uid() +} + +#[cfg(target_os = "linux")] +#[inline] +fn active_uid_strict() -> Option { + let reported_uid_raw = crate::platform::linux::get_active_userid(); + let trimmed = reported_uid_raw.trim(); + if let Ok(uid) = trimmed.parse::() { + return Some(uid); + } + if trimmed.is_empty() { + log::debug!("Failed to resolve active user uid on linux: active uid is empty"); + } else { + log::warn!("Failed to parse active user uid on linux: '{}'", trimmed); + } + None +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[inline] +pub(crate) fn active_uid() -> Option { + active_uid_strict() +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[inline] +pub(crate) fn peer_uid_from_fd(fd: RawFd) -> Option { + #[cfg(target_os = "linux")] + { + return peer_cred_from_fd(fd).map(|cred| cred.uid as u32); + } + #[cfg(target_os = "macos")] + { + let mut uid = 0; + let mut gid = 0; + if unsafe { libc::getpeereid(fd, &mut uid, &mut gid) } == 0 { + Some(uid as u32) + } else { + None + } + } +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[inline] +fn peer_pid_from_fd(fd: RawFd) -> Option { + #[cfg(target_os = "linux")] + { + return peer_cred_from_fd(fd).and_then(|cred| (cred.pid > 0).then_some(cred.pid as u32)); + } + #[cfg(target_os = "macos")] + { + let mut pid = 0; + let mut len = std::mem::size_of::() as _; + let rc = unsafe { + libc::getsockopt( + fd, + libc::SOL_LOCAL, + libc::LOCAL_PEERPID, + &mut pid as *mut _ as *mut libc::c_void, + &mut len, + ) + }; + if rc == 0 && pid > 0 { + Some(pid as _) + } else { + None + } + } +} + +#[cfg(target_os = "linux")] +#[inline] +fn peer_cred_from_fd(fd: RawFd) -> Option { + let mut cred: libc::ucred = unsafe { std::mem::zeroed() }; + let mut len = std::mem::size_of::() as _; + let rc = unsafe { + libc::getsockopt( + fd, + libc::SOL_SOCKET, + libc::SO_PEERCRED, + &mut cred as *mut _ as *mut libc::c_void, + &mut len, + ) + }; + if rc == 0 { + Some(cred) + } else { + None + } +} + +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] +#[inline] +fn current_exe_canonical_path() -> ResultType { + let current = std::env::current_exe() + .map_err(|err| anyhow::anyhow!("Failed to resolve current executable path: {}", err))?; + fs::canonicalize(¤t).map_err(|err| { + anyhow::anyhow!( + "Failed to canonicalize current executable path '{}': {}", + current.display(), + err + ) + .into() + }) +} + +#[cfg(target_os = "linux")] +#[inline] +fn peer_exe_canonical_path_by_pid(peer_pid: u32) -> ResultType { + let proc_exe = PathBuf::from(format!("/proc/{peer_pid}/exe")); + let peer_exe = fs::read_link(&proc_exe).map_err(|err| { + anyhow::anyhow!( + "Failed to read peer executable link '{}': {}", + proc_exe.display(), + err + ) + })?; + fs::canonicalize(&peer_exe).map_err(|err| { + anyhow::anyhow!( + "Failed to canonicalize peer executable path '{}': {}", + peer_exe.display(), + err + ) + .into() + }) +} + +#[cfg(target_os = "macos")] +#[inline] +fn peer_exe_canonical_path_by_pid(peer_pid: u32) -> ResultType { + const PROC_PIDPATH_BUF_SIZE: usize = libc::PROC_PIDPATHINFO_MAXSIZE as _; + let mut buffer = vec![0u8; PROC_PIDPATH_BUF_SIZE]; + let length = unsafe { + libc::proc_pidpath( + peer_pid as _, + buffer.as_mut_ptr() as _, + PROC_PIDPATH_BUF_SIZE as _, + ) + }; + if length <= 0 { + bail!("Failed to query peer process path from pid {}", peer_pid); + } + buffer.truncate(length as _); + let path = PathBuf::from(String::from_utf8_lossy(&buffer).to_string()); + fs::canonicalize(&path).map_err(|err| { + anyhow::anyhow!( + "Failed to canonicalize peer executable path '{}': {}", + path.display(), + err + ) + .into() + }) +} + +#[cfg(target_os = "windows")] +#[inline] +fn peer_exe_canonical_path_by_pid(peer_pid: u32) -> ResultType { + let path = crate::platform::windows::get_process_executable_path(peer_pid)?; + fs::canonicalize(&path).map_err(|err| { + anyhow::anyhow!( + "Failed to canonicalize peer executable path '{}': {}", + path.display(), + err + ) + .into() + }) +} + +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] +#[inline] +pub(crate) fn executable_paths_match(left: &Path, right: &Path) -> bool { + #[cfg(target_os = "windows")] + { + // Callers pass paths resolved through fs::canonicalize() first, so NT + // namespace paths and 8.3 short names are expected to be resolved before + // this check. Keep this normalization limited to remaining Win32 spelling + // differences. + fn normalize(path: &Path) -> String { + let mut normalized = path.to_string_lossy().replace('/', "\\"); + if let Some(stripped) = normalized.strip_prefix(r"\\?\") { + normalized = stripped.to_owned(); + } + normalized.to_ascii_lowercase() + } + return normalize(left) == normalize(right); + } + #[cfg(target_os = "macos")] + { + return paths_refer_to_same_file(left, right); + } + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + { + left == right + } +} + +#[cfg(target_os = "macos")] +#[inline] +fn paths_refer_to_same_file(left: &Path, right: &Path) -> bool { + if left == right { + return true; + } + let (Ok(left), Ok(right)) = (fs::metadata(left), fs::metadata(right)) else { + return false; + }; + left.dev() == right.dev() && left.ino() == right.ino() +} + +#[cfg(target_os = "macos")] +#[inline] +fn os_str_eq_ignore_ascii_case( + left: Option<&std::ffi::OsStr>, + right: Option<&std::ffi::OsStr>, +) -> bool { + let (Some(left), Some(right)) = (left, right) else { + return false; + }; + left.to_string_lossy() + .eq_ignore_ascii_case(&right.to_string_lossy()) +} + +#[cfg(all(windows, not(feature = "flutter")))] +#[inline] +fn file_sha256(path: &Path) -> ResultType<[u8; 32]> { + let mut file = fs::File::open(path)?; + let mut hasher = Sha256::new(); + let mut buffer = [0u8; 8 * 1024]; + loop { + let read_bytes = file.read(&mut buffer)?; + if read_bytes == 0 { + break; + } + hasher.update(&buffer[..read_bytes]); + } + Ok(hasher.finalize().into()) +} + +#[cfg(all(windows, not(feature = "flutter")))] +#[inline] +fn portable_service_helper_is_trusted( + peer_exe: &Path, + expected_exe: &Path, + current_exe: &Path, +) -> bool { + if !executable_paths_match(peer_exe, expected_exe) { + return false; + } + let peer_hash = match file_sha256(peer_exe) { + Ok(hash) => hash, + Err(err) => { + log::warn!( + "Failed to hash peer portable helper executable '{}': {}", + peer_exe.display(), + err + ); + return false; + } + }; + let current_hash = match file_sha256(current_exe) { + Ok(hash) => hash, + Err(err) => { + log::warn!( + "Failed to hash current executable '{}' for portable helper trust check: {}", + current_exe.display(), + err + ); + return false; + } + }; + peer_hash == current_hash +} + +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] +#[inline] +fn ensure_peer_executable_matches_current_by_pid(peer_pid: u32, postfix: &str) -> ResultType<()> { + let peer_exe = peer_exe_canonical_path_by_pid(peer_pid)?; + let current_exe = current_exe_canonical_path()?; + if executable_paths_match(&peer_exe, ¤t_exe) { + return Ok(()); + } + #[cfg(target_os = "macos")] + if macos_service_ipc_allows_gui_and_service_binaries(&peer_exe, ¤t_exe, postfix) { + return Ok(()); + } + #[cfg(target_os = "windows")] + if windows_portable_service_ipc_allows_logon_helper_executable(&peer_exe, postfix) { + return Ok(()); + } + bail!( + "Peer executable path mismatch on ipc channel '{}': peer_pid={}, peer_exe='{}', current_exe='{}'", + postfix, + peer_pid, + peer_exe.display(), + current_exe.display() + ); +} + +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] +#[inline] +pub(crate) fn ensure_peer_executable_matches_current_by_pid_opt( + peer_pid: Option, + postfix: &str, +) -> ResultType<()> { + let peer_pid = peer_pid.ok_or_else(|| { + anyhow::anyhow!("Failed to resolve peer pid on ipc channel '{}'", postfix) + })?; + ensure_peer_executable_matches_current_by_pid(peer_pid, postfix) +} + +#[cfg(target_os = "linux")] +#[inline] +pub(crate) fn ensure_peer_executable_matches_current_by_fd( + fd: RawFd, + postfix: &str, +) -> ResultType<()> { + let peer_pid = peer_pid_from_fd(fd).ok_or_else(|| { + anyhow::anyhow!("Failed to resolve peer pid on ipc channel '{}'", postfix) + })?; + ensure_peer_executable_matches_current_by_pid(peer_pid, postfix) +} + +#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] +const UNAUTHORIZED_IPC_LOG_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5); + +#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] +#[derive(Default)] +struct UnauthorizedIpcLogThrottle { + last_log_at: Option, + suppressed: u64, +} + +#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] +impl UnauthorizedIpcLogThrottle { + #[inline] + fn on_reject(&mut self, now: std::time::Instant) -> Option { + if let Some(last) = self.last_log_at { + if now.saturating_duration_since(last) < UNAUTHORIZED_IPC_LOG_INTERVAL { + self.suppressed += 1; + return None; + } + } + self.last_log_at = Some(now); + Some(std::mem::take(&mut self.suppressed)) + } +} + +#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] +#[inline] +fn throttled_unauthorized_ipc_log( + throttle_cell: &OnceLock>, + emit: impl FnOnce(u64), +) { + let throttle = throttle_cell.get_or_init(|| Mutex::new(UnauthorizedIpcLogThrottle::default())); + let should_log = match throttle.lock() { + Ok(mut throttle) => throttle.on_reject(std::time::Instant::now()), + Err(_) => Some(0), + }; + if let Some(suppressed) = should_log { + emit(suppressed); + } +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[inline] +fn log_rejected_service_connection(postfix: &str, peer_uid: Option, active_uid: Option) { + static LOG_THROTTLE: OnceLock> = OnceLock::new(); + throttled_unauthorized_ipc_log(&LOG_THROTTLE, |suppressed| { + if suppressed > 0 { + log::warn!( + "Rejected unauthorized connection on protected service-scoped IPC channel: postfix={}, peer_uid={:?}, active_uid={:?} (suppressed {} similar events)", + postfix, + peer_uid, + active_uid, + suppressed + ); + } else { + log::warn!( + "Rejected unauthorized connection on protected service-scoped IPC channel: postfix={}, peer_uid={:?}, active_uid={:?}", + postfix, + peer_uid, + active_uid + ); + } + }); +} + +#[cfg(target_os = "linux")] +#[inline] +pub(crate) fn log_rejected_uinput_connection( + postfix: &str, + peer_uid: Option, + active_uid: Option, +) { + static LOG_THROTTLE: OnceLock> = OnceLock::new(); + throttled_unauthorized_ipc_log(&LOG_THROTTLE, |suppressed| { + if suppressed > 0 { + log::warn!( + "Rejected unauthorized connection on uinput ipc channel: postfix={}, peer_uid={:?}, active_uid={:?} (suppressed {} similar events)", + postfix, + peer_uid, + active_uid, + suppressed + ); + } else { + log::warn!( + "Rejected unauthorized connection on uinput ipc channel: postfix={}, peer_uid={:?}, active_uid={:?}", + postfix, + peer_uid, + active_uid + ); + } + }); +} + +#[cfg(windows)] +#[inline] +pub(crate) fn log_rejected_windows_ipc_connection( + postfix: &str, + peer_pid: Option, + peer_session_id: Option, + expected_session_id: Option, + peer_is_system: Option, + peer_is_elevated: Option, +) { + static LOG_THROTTLE: OnceLock> = OnceLock::new(); + throttled_unauthorized_ipc_log(&LOG_THROTTLE, |suppressed| { + if suppressed > 0 { + log::warn!( + "Rejected unauthorized connection on ipc channel: postfix={}, peer_pid={:?}, peer_session_id={:?}, expected_session_id={:?}, peer_is_system={:?}, peer_is_elevated={:?} (suppressed {} similar events)", + postfix, + peer_pid, + peer_session_id, + expected_session_id, + peer_is_system, + peer_is_elevated, + suppressed + ); + } else { + log::warn!( + "Rejected unauthorized connection on ipc channel: postfix={}, peer_pid={:?}, peer_session_id={:?}, expected_session_id={:?}, peer_is_system={:?}, peer_is_elevated={:?}", + postfix, + peer_pid, + peer_session_id, + expected_session_id, + peer_is_system, + peer_is_elevated + ); + } + }); +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub(crate) fn authorize_service_scoped_ipc_connection(stream: &Connection, postfix: &str) -> bool { + let peer_pid = stream.peer_pid(); + let (authorized, peer_uid, active_uid) = stream.service_authorization_status(); + if !authorized { + log_rejected_service_connection(postfix, peer_uid, active_uid); + return false; + } + if let Err(err) = ensure_peer_executable_matches_current_by_pid_opt(peer_pid, postfix) { + log::warn!( + "Rejected unauthorized connection on protected service-scoped IPC channel due to executable mismatch: postfix={}, peer_pid={:?}, err={}", + postfix, + peer_pid, + err + ); + return false; + } + true +} + +#[cfg(windows)] +pub(crate) fn authorize_windows_main_ipc_connection(stream: &Connection, postfix: &str) -> bool { + let ( + authorized, + peer_pid, + peer_session_id, + server_session_id, + peer_is_system, + peer_is_elevated, + ) = stream.server_authorization_status(); + if !authorized { + log_rejected_windows_ipc_connection( + postfix, + peer_pid, + peer_session_id, + server_session_id, + peer_is_system, + peer_is_elevated, + ); + return false; + } + if let Err(err) = ensure_peer_executable_matches_current_by_pid_opt(peer_pid, postfix) { + log::warn!( + "Rejected unauthorized connection on ipc channel due to executable mismatch: postfix={}, peer_pid={:?}, err={}", + postfix, + peer_pid, + err + ); + return false; + } + true +} + +#[cfg(windows)] +pub(crate) fn authorize_windows_portable_service_ipc_connection( + stream: &Connection, + postfix: &str, +) -> bool { + // Portable service IPC policy: + // - only SYSTEM peers are authorized by is_allowed_windows_portable_service_peer() + // - expected_session_id is still collected for diagnostics and identity checks + // - final privilege boundary is enforced by named-pipe ACL + one-time token handshake + // - when peer identity is unavailable on some hosts, executable verification remains + // best-effort telemetry (not fail-closed) to avoid breaking valid SYSTEM bootstrap + // flows that cannot be fully introspected + let expected_session_id = crate::platform::windows::get_current_process_session_id(); + let (authorized, peer_pid, peer_session_id, peer_is_system) = + stream.portable_service_authorization_status_for_session(expected_session_id); + if !authorized { + // Session lookup may succeed while SYSTEM identity lookup fails, so only the + // SYSTEM identity result determines whether peer identity is unavailable here. + // Don't use `peer_pid.is_some() && peer_session_id.is_none() && peer_is_system.is_none();` here. + let identity_unavailable = peer_pid.is_some() && peer_is_system.is_none(); + if identity_unavailable { + // In portable-service startup, resolving SYSTEM peer identity may fail on some hosts. + // `ProcessIdToSessionId` can still succeed while `OpenProcessToken(TOKEN_QUERY)` is + // denied by the peer token DACL or missing privileges. Treat that partial identity + // failure as unavailable and defer final authorization to pipe ACL + token handshake. + if let Err(err) = ensure_peer_executable_matches_current_by_pid_opt(peer_pid, postfix) { + log::warn!( + "Portable service ipc peer identity unavailable and executable verification failed; continue with ACL+token-gated flow: postfix={}, peer_pid={:?}, err={}", + postfix, + peer_pid, + err + ); + } else { + log::warn!( + "Portable service ipc peer identity unavailable; executable verification matched, continue with ACL+token-gated flow: postfix={}, peer_pid={:?}, expected_session_id={:?}", + postfix, + peer_pid, + expected_session_id + ); + } + return true; + } + log::warn!( + "Rejected unauthorized connection on portable service ipc channel: postfix={}, peer_pid={:?}, peer_session_id={:?}, expected_session_id={:?}, peer_is_system={:?}", + postfix, + peer_pid, + peer_session_id, + expected_session_id, + peer_is_system + ); + return false; + } + true +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +impl ConnectionTmpl +where + T: AsyncRead + AsyncWrite + std::marker::Unpin + std::os::unix::io::AsRawFd, +{ + pub(super) fn peer_uid(&self) -> Option { + peer_uid_from_fd(self.inner.get_ref().as_raw_fd()) + } + + fn service_authorization_status(&self) -> (bool, Option, Option) { + let peer_uid = self.peer_uid(); + // On Linux, `_service` can use the cached active UID from the service loop for + // stable config sync. Uinput does a fresh active-UID lookup in its own authorizer. + let active_uid = active_uid(); + let authorized = peer_uid.is_some_and(|uid| is_allowed_service_peer_uid(uid, active_uid)); + (authorized, peer_uid, active_uid) + } + + pub(super) fn peer_pid(&self) -> Option { + peer_pid_from_fd(self.inner.get_ref().as_raw_fd()) + } +} + +#[cfg(windows)] +impl ConnectionTmpl { + fn peer_pid(&self) -> Option { + let pipe_handle = self.inner.get_ref().as_raw_handle(); + if pipe_handle.is_null() { + return None; + } + let mut pid = 0u32; + let ok = unsafe { GetNamedPipeClientProcessId(HANDLE(pipe_handle), &mut pid as *mut u32) } + .is_ok(); + if ok && pid != 0 { + Some(pid) + } else { + None + } + } + + fn server_authorization_status( + &self, + ) -> ( + bool, + Option, + Option, + Option, + Option, + Option, + ) { + let peer_pid = self.peer_pid(); + let server_session_id = crate::platform::windows::get_current_process_session_id(); + let peer_session_id = + peer_pid.and_then(crate::platform::windows::get_session_id_of_process); + let peer_is_system_result = + peer_pid.map(crate::platform::windows::is_process_running_as_system); + let peer_is_system = peer_is_system_result + .as_ref() + .and_then(|r| r.as_ref().ok().copied()); + let session_authorized = is_allowed_windows_session_scoped_peer( + peer_is_system.unwrap_or(false), + peer_session_id, + server_session_id, + ); + let peer_is_elevated_result = if session_authorized { + None + } else { + peer_pid.map(|pid| crate::platform::windows::is_elevated(Some(pid))) + }; + let peer_is_elevated = peer_is_elevated_result + .as_ref() + .and_then(|r| r.as_ref().ok().copied()); + if server_session_id.is_none() + && !peer_is_system.unwrap_or(false) + && !peer_is_elevated.unwrap_or(false) + { + // When the server session id cannot be determined, the session-id allow-path is + // disabled and only privileged peers can be authorized. + log::debug!( + "IPC authorization: server session id unavailable; rejecting non-privileged peer, peer_pid={:?}, peer_session_id={:?}", + peer_pid, + peer_session_id + ); + } + // Main IPC trusts same-session peers, LocalSystem, and elevated administrators. + // Service-scoped IPC channels keep their own stricter authorization paths. + let authorized = session_authorized || peer_is_elevated.unwrap_or(false); + if !authorized { + if let (Some(pid), Some(Err(err))) = (peer_pid, peer_is_system_result.as_ref()) { + log::debug!( + "Failed to determine whether peer process is SYSTEM, pid={}, err={}", + pid, + err + ); + } + if let (Some(pid), Some(Err(err))) = (peer_pid, peer_is_elevated_result.as_ref()) { + log::debug!( + "Failed to determine whether peer process is elevated, pid={}, err={}", + pid, + err + ); + } + } + ( + authorized, + peer_pid, + peer_session_id, + server_session_id, + peer_is_system, + peer_is_elevated, + ) + } + + pub(crate) fn service_authorization_status_for_session( + &self, + expected_active_session_id: Option, + ) -> (bool, Option, Option, Option) { + let peer_pid = self.peer_pid(); + let peer_session_id = + peer_pid.and_then(crate::platform::windows::get_session_id_of_process); + let peer_is_system_result = + peer_pid.map(crate::platform::windows::is_process_running_as_system); + let peer_is_system = peer_is_system_result + .as_ref() + .and_then(|r| r.as_ref().ok().copied()); + let authorized = is_allowed_windows_session_scoped_peer( + peer_is_system.unwrap_or(false), + peer_session_id, + expected_active_session_id, + ); + if !authorized { + if let (Some(pid), Some(Err(err))) = (peer_pid, peer_is_system_result.as_ref()) { + log::debug!( + "Failed to determine whether peer process is SYSTEM, pid={}, err={}", + pid, + err + ); + } + } + (authorized, peer_pid, peer_session_id, peer_is_system) + } + + pub(crate) fn portable_service_authorization_status_for_session( + &self, + expected_active_session_id: Option, + ) -> (bool, Option, Option, Option) { + // Portable-service policy: + // only SYSTEM peers are allowed. + let (_service_authorized, peer_pid, peer_session_id, peer_is_system) = + self.service_authorization_status_for_session(expected_active_session_id); + ( + is_allowed_windows_portable_service_peer( + peer_is_system, + peer_session_id, + expected_active_session_id, + ), + peer_pid, + peer_session_id, + peer_is_system, + ) + } +} + +#[cfg(test)] +mod tests { + #[test] + #[cfg(any(target_os = "macos", target_os = "linux"))] + fn test_service_peer_uid_policy() { + assert!(super::is_allowed_service_peer_uid(0, None)); + assert!(super::is_allowed_service_peer_uid(501, Some(501))); + assert!(!super::is_allowed_service_peer_uid(502, Some(501))); + assert!(!super::is_allowed_service_peer_uid(501, None)); + } + + #[test] + #[cfg(windows)] + fn test_windows_server_peer_policy() { + assert!(super::is_allowed_windows_session_scoped_peer( + true, None, None + )); + assert!(super::is_allowed_windows_session_scoped_peer( + false, + Some(1), + Some(1) + )); + assert!(!super::is_allowed_windows_session_scoped_peer( + false, + Some(1), + Some(2) + )); + assert!(!super::is_allowed_windows_session_scoped_peer( + false, + None, + Some(1) + )); + } + + #[test] + #[cfg(windows)] + fn test_windows_portable_service_peer_policy() { + assert!(super::is_allowed_windows_portable_service_peer( + Some(true), + None, + None + )); + assert!(!super::is_allowed_windows_portable_service_peer( + Some(false), + Some(1), + Some(1) + )); + assert!(!super::is_allowed_windows_portable_service_peer( + Some(false), + Some(1), + Some(2) + )); + assert!(!super::is_allowed_windows_portable_service_peer( + None, + Some(1), + Some(1) + )); + } + + #[test] + #[cfg(windows)] + fn test_should_allow_everyone_create_on_windows_policy() { + assert!(super::should_allow_everyone_create_on_windows("")); + assert!(super::should_allow_everyone_create_on_windows("_service")); + assert!(!super::should_allow_everyone_create_on_windows( + "_portable_service" + )); + } + + #[test] + #[cfg(windows)] + fn test_executable_paths_match_windows_normalization() { + let left = std::path::PathBuf::from(r"\\?\C:\Program Files\RustDesk\RustDesk.exe"); + let right = std::path::PathBuf::from(r"c:\program files\rustdesk\rustdesk.exe"); + assert!(super::executable_paths_match(&left, &right)); + } + + #[test] + #[cfg(target_os = "macos")] + fn test_os_str_eq_ignore_ascii_case_for_process_names() { + assert!(super::os_str_eq_ignore_ascii_case( + Some(std::ffi::OsStr::new("RustDesk")), + Some(std::ffi::OsStr::new("rustdesk")) + )); + assert!(!super::os_str_eq_ignore_ascii_case( + Some(std::ffi::OsStr::new("RustDesk")), + Some(std::ffi::OsStr::new("service")) + )); + } + + #[cfg(all(windows, not(feature = "flutter")))] + struct TempDirGuard(std::path::PathBuf); + + #[cfg(all(windows, not(feature = "flutter")))] + impl Drop for TempDirGuard { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + #[test] + #[cfg(all(windows, not(feature = "flutter")))] + fn test_portable_service_helper_trust_requires_content_match() { + let unique = format!( + "rustdesk-portable-helper-trust-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 _cleanup = TempDirGuard(base.clone()); + + let current_exe = base.join("current.exe"); + let helper_exe = base.join("helper.exe"); + std::fs::write(¤t_exe, b"trusted-binary").unwrap(); + std::fs::write(&helper_exe, b"tampered-binary").unwrap(); + + assert!( + !super::portable_service_helper_is_trusted(&helper_exe, &helper_exe, ¤t_exe), + "helper trust check must reject path-match-only binaries with mismatched content" + ); + } + + #[test] + #[cfg(all(windows, not(feature = "flutter")))] + fn test_portable_service_helper_trust_accepts_matching_content() { + let unique = format!( + "rustdesk-portable-helper-trust-match-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 _cleanup = TempDirGuard(base.clone()); + + let current_exe = base.join("current.exe"); + let helper_exe = base.join("helper.exe"); + std::fs::write(¤t_exe, b"trusted-binary").unwrap(); + std::fs::write(&helper_exe, b"trusted-binary").unwrap(); + + assert!(super::portable_service_helper_is_trusted( + &helper_exe, + &helper_exe, + ¤t_exe + )); + } + + #[cfg(target_os = "macos")] + #[test] + fn test_console_owner_uid_matches_get_active_userid() { + let console_uid = + super::console_owner_uid().expect("/dev/console must have a resolvable uid"); + let raw_uid = crate::platform::macos::get_active_userid(); + let parsed_uid: u32 = raw_uid + .trim() + .parse() + .unwrap_or_else(|_| panic!("failed to parse get_active_userid() output: '{raw_uid}'")); + assert_eq!(parsed_uid, console_uid); + } +} diff --git a/src/ipc/fs.rs b/src/ipc/fs.rs new file mode 100644 index 000000000..e0157f3a9 --- /dev/null +++ b/src/ipc/fs.rs @@ -0,0 +1,951 @@ +#[cfg(target_os = "linux")] +use super::ipc_auth::active_uid; +use crate::ipc::{connect, Data}; +use hbb_common::{config, log, ResultType}; +use std::{ + ffi::CString, + io::{Error, ErrorKind}, + os::unix::ffi::OsStrExt, + path::Path, +}; + +struct FdGuard(i32); +impl Drop for FdGuard { + fn drop(&mut self) { + unsafe { + hbb_common::libc::close(self.0); + } + } +} + +#[cfg(target_os = "linux")] +#[inline] +pub(crate) fn terminal_count_candidate_uids(effective_uid: u32) -> Vec { + if effective_uid != 0 { + return vec![effective_uid]; + } + let mut candidates = Vec::with_capacity(2); + if let Some(uid) = active_uid().filter(|uid| *uid != 0) { + candidates.push(uid); + } + candidates.push(0); + candidates +} + +#[inline] +fn expected_ipc_parent_mode(postfix: &str) -> u32 { + if config::is_service_ipc_postfix(postfix) { + 0o0711 + } else { + 0o0700 + } +} + +fn open_ipc_parent_dir_fd(parent_c: &CString) -> std::io::Result { + let fd = unsafe { + hbb_common::libc::open( + parent_c.as_ptr(), + hbb_common::libc::O_RDONLY + | hbb_common::libc::O_DIRECTORY + | hbb_common::libc::O_CLOEXEC + | hbb_common::libc::O_NOFOLLOW, + ) + }; + if fd < 0 { + Err(std::io::Error::last_os_error()) + } else { + Ok(fd) + } +} + +// Remove one preexisting IPC artifact via an already-opened parent directory FD. +// +// Security intent: +// - Bind cleanup to the exact parent inode that passed O_NOFOLLOW + fstat checks. +// - Avoid path-based TOCTOU during scrub (e.g., parent path rename/swap race). +// +// Flow: +// 1) fstatat(..., AT_SYMLINK_NOFOLLOW) to inspect the target entry under parent_fd. +// 2) Decide file vs directory from st_mode. +// 3) unlinkat relative to parent_fd (AT_REMOVEDIR for directories). +// +// Error policy: +// - NotFound is treated as benign (already removed / raced away). +// - Other errors are surfaced explicitly. +fn remove_parent_entry_via_fd( + parent_fd: i32, + parent_dir: &Path, + entry_name: &str, +) -> ResultType<()> { + if entry_name.contains('/') { + return Err(Error::new( + ErrorKind::InvalidInput, + format!( + "invalid ipc parent entry name (contains '/'): parent={}, entry={}", + parent_dir.display(), + entry_name + ), + ) + .into()); + } + let entry_c = CString::new(entry_name.as_bytes().to_vec()).map_err(|err| { + Error::new( + ErrorKind::InvalidInput, + format!( + "invalid ipc parent entry name: parent={}, entry={}, err={}", + parent_dir.display(), + entry_name, + err + ), + ) + })?; + let mut stat: hbb_common::libc::stat = unsafe { std::mem::zeroed() }; + let stat_rc = unsafe { + hbb_common::libc::fstatat( + parent_fd, + entry_c.as_ptr(), + &mut stat, + hbb_common::libc::AT_SYMLINK_NOFOLLOW, + ) + }; + if stat_rc != 0 { + let err = std::io::Error::last_os_error(); + if err.kind() == ErrorKind::NotFound { + return Ok(()); + } + return Err(Error::new( + err.kind(), + format!( + "failed to stat preexisting ipc parent dir entry by fd: parent={}, entry={}, err={}", + parent_dir.display(), + entry_name, + err + ), + ) + .into()); + } + + let is_dir = (stat.st_mode & (hbb_common::libc::S_IFMT as hbb_common::libc::mode_t)) + == hbb_common::libc::S_IFDIR; + let unlink_flags = if is_dir { + hbb_common::libc::AT_REMOVEDIR + } else { + 0 + }; + let unlink_rc = + unsafe { hbb_common::libc::unlinkat(parent_fd, entry_c.as_ptr(), unlink_flags) }; + if unlink_rc != 0 { + let err = std::io::Error::last_os_error(); + if err.kind() == ErrorKind::NotFound { + return Ok(()); + } + return Err(Error::new( + err.kind(), + format!( + "failed to remove preexisting ipc parent dir entry by fd: parent={}, entry={}, err={}", + parent_dir.display(), + entry_name, + err + ), + ) + .into()); + } + Ok(()) +} + +fn scrub_preexisting_ipc_parent_entries( + parent_fd: i32, + parent_dir: &Path, + postfix: &str, +) -> ResultType<()> { + let ipc_basename = format!("ipc{}", postfix); + remove_parent_entry_via_fd(parent_fd, parent_dir, &ipc_basename)?; + remove_parent_entry_via_fd(parent_fd, parent_dir, &format!("{}.pid", ipc_basename))?; + 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) + .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())?; + let fd = match open_ipc_parent_dir_fd(&parent_c) { + Ok(fd) => fd, + Err(open_err) => { + if open_err.kind() == ErrorKind::NotFound { + return Ok(()); + } + return Err(Error::new( + open_err.kind(), + format!( + "failed to open ipc parent dir for stale socket cleanup (no-follow): postfix={}, parent={}, err={}", + postfix, + parent_dir.display(), + open_err + ), + ) + .into()); + } + }; + let _fd_guard = FdGuard(fd); + remove_parent_entry_via_fd(fd, parent_dir, &format!("ipc{}", postfix)) +} + +// Purpose: +// - Harden the IPC parent directory before creating/listening socket files. +// - Prevent symlink/path-race abuse and reject unsafe owner/mode. +// +// Approach: +// - Open parent dir with O_NOFOLLOW/O_DIRECTORY and operate on that fd. +// - Validate inode type/owner/mode via fstat. +// - For protected service postfix, optionally adopt owner (root only), then scrub stale +// rustdesk IPC artifacts when directory trust boundary changed. +// +// Main steps: +// 1) Resolve parent path and open/create directory securely. +// 2) Verify directory inode type and owner uid. +// 3) Enforce expected mode via fchmod on opened fd. +// 4) Scrub stale IPC artifacts when owner/mode was unsafe before hardening. +// +// References: +// - open(2): O_NOFOLLOW/O_DIRECTORY/O_CLOEXEC +// https://man7.org/linux/man-pages/man2/open.2.html +// - fstat(2): verify file type/metadata on opened fd +// https://man7.org/linux/man-pages/man2/fstat.2.html +// - fchown(2): adopt ownership when running as root +// https://man7.org/linux/man-pages/man2/chown.2.html +// - fchmod(2): enforce exact mode on opened fd +// https://man7.org/linux/man-pages/man2/fchmod.2.html +pub(crate) fn ensure_secure_ipc_parent_dir(path: &str, postfix: &str) -> ResultType { + let parent_dir = Path::new(path) + .parent() + .ok_or_else(|| Error::new(ErrorKind::InvalidInput, format!("invalid ipc path: {path}")))?; + // Harden against common TOCTOU by opening the parent directory with O_NOFOLLOW (so the parent + // itself cannot be a symlink) and then operating on its FD (fstat/fchown/fchmod). This ensures + // we mutate the inode we opened, though it does not protect against symlinks in ancestor path + // components. + let parent_c = CString::new(parent_dir.as_os_str().as_bytes().to_vec())?; + let fd = match open_ipc_parent_dir_fd(&parent_c) { + Ok(fd) => fd, + Err(open_err) => { + // If the directory doesn't exist yet, create it with the expected mode. The parent + // dir is intended to be a single-level /tmp path, so mkdir is sufficient here. + if open_err.raw_os_error() == Some(hbb_common::libc::ENOENT) { + let expected_mode = expected_ipc_parent_mode(postfix); + let rc = unsafe { + hbb_common::libc::mkdir( + parent_c.as_ptr(), + expected_mode as hbb_common::libc::mode_t, + ) + }; + if rc != 0 { + let mkdir_err = std::io::Error::last_os_error(); + // Handle a race where another process created the directory first. + if mkdir_err.raw_os_error() != Some(hbb_common::libc::EEXIST) { + return Err(Error::new( + mkdir_err.kind(), + format!( + "failed to mkdir ipc parent dir: postfix={}, parent={}, err={}", + postfix, + parent_dir.display(), + mkdir_err + ), + ) + .into()); + } + } + match open_ipc_parent_dir_fd(&parent_c) { + Ok(fd) => fd, + Err(err) => { + return Err(Error::new( + err.kind(), + format!( + "failed to open ipc parent dir (no-follow): postfix={}, parent={}, err={}", + postfix, + parent_dir.display(), + err + ), + ) + .into()); + } + } + } else { + return Err(Error::new( + open_err.kind(), + format!( + "failed to open ipc parent dir (no-follow): postfix={}, parent={}, err={}", + postfix, + parent_dir.display(), + open_err + ), + ) + .into()); + } + } + }; + let _fd_guard = FdGuard(fd); + + let mut st: hbb_common::libc::stat = unsafe { std::mem::zeroed() }; + if unsafe { hbb_common::libc::fstat(fd, &mut st as *mut _) } != 0 { + let os_err = std::io::Error::last_os_error(); + return Err(Error::new( + os_err.kind(), + format!( + "failed to stat ipc parent dir: postfix={}, parent={}, err={}", + postfix, + parent_dir.display(), + os_err + ), + ) + .into()); + } + let mode = st.st_mode as u32; + let is_dir = (mode & (hbb_common::libc::S_IFMT as u32)) == (hbb_common::libc::S_IFDIR as u32); + if !is_dir { + return Err(Error::new( + ErrorKind::PermissionDenied, + format!( + "ipc parent is not directory: postfix={}, parent={}", + postfix, + parent_dir.display() + ), + ) + .into()); + } + + let expected_uid = unsafe { hbb_common::libc::geteuid() as u32 }; + let mut owner_uid = st.st_uid as u32; + let mut adopted_foreign_service_parent = false; + // Service-scoped IPC may be created by different privilege contexts historically. + // If running as root on protected service postfix, try adopting ownership first. + if owner_uid != expected_uid && expected_uid == 0 && config::is_service_ipc_postfix(postfix) { + let rc = unsafe { + hbb_common::libc::fchown( + fd, + expected_uid as hbb_common::libc::uid_t, + hbb_common::libc::gid_t::MAX, + ) + }; + if rc == 0 { + let mut st2: hbb_common::libc::stat = unsafe { std::mem::zeroed() }; + if unsafe { hbb_common::libc::fstat(fd, &mut st2 as *mut _) } == 0 { + owner_uid = st2.st_uid as u32; + st = st2; + adopted_foreign_service_parent = true; + } + } else { + // Keep behavior unchanged; capture errno to ease diagnosing why chown failed. + let err = std::io::Error::last_os_error(); + log::warn!( + "Failed to chown ipc parent dir, parent={}, postfix={}, expected_uid={}, rc={}, err={:?}", + parent_dir.display(), + postfix, + expected_uid, + rc, + err + ); + } + } + if owner_uid != expected_uid { + return Err(Error::new( + ErrorKind::PermissionDenied, + format!( + "unsafe ipc parent owner, postfix={}, expected uid {expected_uid}, got {owner_uid}: {}", + postfix, + parent_dir.display() + ), + ) + .into()); + } + + let expected_mode = expected_ipc_parent_mode(postfix); + // Include special bits (setuid/setgid/sticky) to ensure the directory is hardened to the exact + // expected mode. + let current_mode = (st.st_mode as u32) & 0o7777; + let repaired_parent_mode = current_mode != expected_mode; + let had_untrusted_parent_mode = (current_mode & 0o022) != 0; + if repaired_parent_mode { + // Use fchmod on the opened fd to avoid path-race between check and chmod. + if unsafe { hbb_common::libc::fchmod(fd, expected_mode as hbb_common::libc::mode_t) } != 0 { + let os_err = std::io::Error::last_os_error(); + return Err(Error::new( + os_err.kind(), + format!( + "failed to chmod ipc parent dir: postfix={}, parent={}, err={}", + postfix, + parent_dir.display(), + os_err + ), + ) + .into()); + } + } + let should_scrub = + repaired_parent_mode || adopted_foreign_service_parent || had_untrusted_parent_mode; + Ok(should_scrub) +} + +pub(crate) fn scrub_secure_ipc_parent_dir(path: &str, postfix: &str) -> ResultType<()> { + 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())?; + let fd = open_ipc_parent_dir_fd(&parent_c).map_err(|err| { + Error::new( + err.kind(), + format!( + "failed to open ipc parent dir for scrub (no-follow): postfix={}, parent={}, err={}", + postfix, + parent_dir.display(), + err + ), + ) + })?; + let _fd_guard = FdGuard(fd); + scrub_preexisting_ipc_parent_entries(fd, parent_dir, postfix) +} + +#[inline] +pub(crate) fn get_pid_file(postfix: &str) -> String { + let path = config::Config::ipc_path(postfix); + format!("{}.pid", path) +} + +// Purpose: +// - Write current process pid to pid file without following attacker-controlled symlinks. +// - Ensure the pid file is a regular file owned by the opened inode path. +// +// Approach: +// - Use libc open/fstat/write syscalls (FFI) so flags and inode validation are explicit. +// - Open file with O_NOFOLLOW/O_CLOEXEC and verify S_IFREG with fstat before write. +// - Keep unsafe scopes minimal and check syscall return values immediately. +// +// Main steps: +// 1) Secure-open pid file (without truncation). +// 2) Validate opened inode is a regular file owned by current euid. +// 3) Enforce pid file mode to 0600 and truncate via ftruncate after validation. +// 4) Write process id bytes through fd. +// +// Why not plain std::fs::write? +// - std::fs helpers cannot enforce this exact open-time hardening sequence +// (especially "open with O_NOFOLLOW, then fstat the same opened inode"). +// +// References: +// - open(2): O_NOFOLLOW/O_CLOEXEC/O_NONBLOCK +// https://man7.org/linux/man-pages/man2/open.2.html +// - fstat(2): verify file type on opened fd +// https://man7.org/linux/man-pages/man2/fstat.2.html +// - fchmod(2): enforce secure mode on reused pid file +// https://man7.org/linux/man-pages/man2/fchmod.2.html +// - ftruncate(2): truncate after validation +// https://man7.org/linux/man-pages/man2/ftruncate.2.html +// - write(2): write bytes via fd +// https://man7.org/linux/man-pages/man2/write.2.html +fn write_pid_file(path: &Path) -> ResultType<()> { + let path_c = CString::new(path.as_os_str().as_bytes().to_vec()).map_err(|err| { + Error::new( + ErrorKind::InvalidInput, + format!("invalid pid file path '{}': {}", path.display(), err), + ) + })?; + let flags = hbb_common::libc::O_WRONLY + | hbb_common::libc::O_CREAT + | hbb_common::libc::O_CLOEXEC + | hbb_common::libc::O_NOFOLLOW + | hbb_common::libc::O_NONBLOCK; + let fd = unsafe { hbb_common::libc::open(path_c.as_ptr(), flags, 0o0600) }; + if fd < 0 { + let os_err = std::io::Error::last_os_error(); + return Err(Error::new( + os_err.kind(), + format!( + "failed to open pid file with no-follow '{}': {}", + path.display(), + os_err + ), + ) + .into()); + } + let _fd_guard = FdGuard(fd); + let mut stat: hbb_common::libc::stat = unsafe { std::mem::zeroed() }; + if unsafe { hbb_common::libc::fstat(fd, &mut stat) } != 0 { + let os_err = std::io::Error::last_os_error(); + return Err(Error::new( + os_err.kind(), + format!("failed to stat pid file '{}': {}", path.display(), os_err), + ) + .into()); + } + if (stat.st_mode & (hbb_common::libc::S_IFMT as hbb_common::libc::mode_t)) + != (hbb_common::libc::S_IFREG as hbb_common::libc::mode_t) + { + return Err(Error::new( + ErrorKind::PermissionDenied, + format!("pid file path is not a regular file: '{}'", path.display()), + ) + .into()); + } + let expected_uid = unsafe { hbb_common::libc::geteuid() as u32 }; + if stat.st_uid as u32 != expected_uid { + return Err(Error::new( + ErrorKind::PermissionDenied, + format!( + "pid file owner mismatch: expected uid {}, got {} for '{}'", + expected_uid, + stat.st_uid, + path.display() + ), + ) + .into()); + } + if unsafe { hbb_common::libc::fchmod(fd, 0o600) } != 0 { + let os_err = std::io::Error::last_os_error(); + return Err(Error::new( + os_err.kind(), + format!("failed to chmod pid file '{}': {}", path.display(), os_err), + ) + .into()); + } + if unsafe { hbb_common::libc::ftruncate(fd, 0) } != 0 { + let os_err = std::io::Error::last_os_error(); + return Err(Error::new( + os_err.kind(), + format!( + "failed to truncate pid file '{}': {}", + path.display(), + os_err + ), + ) + .into()); + } + + let bytes = std::process::id().to_string(); + let buf = bytes.as_bytes(); + // `write(2)` is allowed to return a short write even for regular files. + // PID content is tiny and usually written in one shot, but we still loop + // until all bytes are persisted so this path is semantically correct. + let mut written = 0usize; + while written < buf.len() { + let rc = unsafe { + hbb_common::libc::write( + fd, + buf[written..].as_ptr() as *const hbb_common::libc::c_void, + buf.len() - written, + ) + }; + if rc < 0 { + let os_err = std::io::Error::last_os_error(); + return Err(Error::new( + os_err.kind(), + format!("failed to write pid file '{}': {}", path.display(), os_err), + ) + .into()); + } + if rc == 0 { + return Err(Error::new( + ErrorKind::WriteZero, + format!( + "failed to write pid file '{}': write returned 0 bytes", + path.display() + ), + ) + .into()); + } + written += rc as usize; + } + Ok(()) +} + +#[inline] +pub(crate) fn write_pid(postfix: &str) { + let path = std::path::PathBuf::from(get_pid_file(postfix)); + if let Err(err) = write_pid_file(&path) { + log::warn!( + "Failed to write pid file for postfix '{}', path='{}', err={}", + postfix, + path.display(), + err + ); + } +} + +// Purpose: +// - Read pid file safely and avoid trusting symlink/non-regular files. +// +// Approach: +// - Use libc open/fstat/read syscalls (FFI) to control flags and inode checks. +// - Open path with O_NOFOLLOW, validate opened fd via fstat, then read and parse. +// - Keep unsafe scopes minimal and check syscall return values immediately. +// +// Main steps: +// 1) Secure-open pid file read-only. +// 2) Ensure fd points to regular file. +// 3) Read bytes and parse usize pid. +// +// References: +// - open(2): O_NOFOLLOW/O_CLOEXEC/O_NONBLOCK +// https://man7.org/linux/man-pages/man2/open.2.html +// - fstat(2): validate S_IFREG on opened fd +// https://man7.org/linux/man-pages/man2/fstat.2.html +// - read(2): read bytes via fd +// https://man7.org/linux/man-pages/man2/read.2.html +#[inline] +fn read_pid_file_secure(path: &Path) -> Option { + let path_c = CString::new(path.as_os_str().as_bytes().to_vec()).ok()?; + let flags = hbb_common::libc::O_RDONLY + | hbb_common::libc::O_CLOEXEC + | hbb_common::libc::O_NOFOLLOW + | hbb_common::libc::O_NONBLOCK; + let fd = unsafe { hbb_common::libc::open(path_c.as_ptr(), flags) }; + if fd < 0 { + return None; + } + let _fd_guard = FdGuard(fd); + + let mut stat: hbb_common::libc::stat = unsafe { std::mem::zeroed() }; + if unsafe { hbb_common::libc::fstat(fd, &mut stat) } != 0 { + return None; + } + if (stat.st_mode & (hbb_common::libc::S_IFMT as hbb_common::libc::mode_t)) + != (hbb_common::libc::S_IFREG as hbb_common::libc::mode_t) + { + return None; + } + + let mut buffer = [0u8; 64]; + let read_len = unsafe { + hbb_common::libc::read( + fd, + buffer.as_mut_ptr() as *mut hbb_common::libc::c_void, + buffer.len(), + ) + }; + if read_len <= 0 { + return None; + } + let content = String::from_utf8_lossy(&buffer[..read_len as usize]).to_string(); + content.trim().parse::().ok() +} + +#[inline] +async fn probe_existing_listener(postfix: &str) -> bool { + let Ok(mut stream) = connect(1000, postfix).await else { + return false; + }; + if postfix != crate::POSTFIX_SERVICE { + return true; + } + if stream.send(&Data::SyncConfig(None)).await.is_err() { + return false; + } + matches!( + stream.next_timeout(1000).await, + Ok(Some(Data::SyncConfig(Some(_)))) + ) +} + +pub(crate) async fn check_pid(postfix: &str) -> bool { + let pid_file = std::path::PathBuf::from(get_pid_file(postfix)); + if let Some(pid) = read_pid_file_secure(&pid_file) { + if pid > 0 { + let mut sys = hbb_common::sysinfo::System::new(); + sys.refresh_processes(); + if let Some(p) = sys.process(pid.into()) { + if let Some(current) = sys.process((std::process::id() as usize).into()) { + if current.name() == p.name() && probe_existing_listener(postfix).await { + return true; + } + } + } + } + } + if probe_existing_listener(postfix).await { + return true; + } + // if not remove old ipc file, the new ipc creation will fail + // if we remove a ipc file, but the old ipc process is still running, + // new connection to the ipc will connect to new ipc, old connection to old ipc still keep alive + if let Err(err) = remove_ipc_socket_via_secure_parent_fd(postfix) { + log::debug!( + "Failed to remove stale ipc socket via secure parent fd: postfix={}, err={}", + postfix, + err + ); + } + false +} + +#[inline] +pub(crate) fn should_scrub_parent_entries_after_check_pid( + should_scrub_parent_entries: bool, + existing_listener_alive: bool, +) -> bool { + should_scrub_parent_entries && !existing_listener_alive +} + +#[cfg(test)] +mod tests { + #[test] + fn test_write_pid_file_rejects_symlink() { + use std::os::unix::fs::symlink; + + let unique = format!( + "rustdesk-ipc-pid-file-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 target = base.join("target_pid"); + std::fs::write(&target, b"origin").unwrap(); + let link = base.join("pid_link"); + symlink(&target, &link).unwrap(); + + let res = super::write_pid_file(&link); + assert!(res.is_err()); + assert_eq!(std::fs::read_to_string(&target).unwrap(), "origin"); + + std::fs::remove_file(&link).ok(); + std::fs::remove_file(&target).ok(); + std::fs::remove_dir_all(&base).ok(); + } + + #[test] + fn test_ensure_secure_ipc_parent_dir_rejects_symlink_parent() { + use std::os::unix::fs::symlink; + + let unique = format!( + "rustdesk-ipc-secure-dir-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); + let real_dir = base.join("real"); + let link_dir = base.join("link"); + std::fs::create_dir_all(&real_dir).unwrap(); + symlink(&real_dir, &link_dir).unwrap(); + let ipc_path = link_dir.join("ipc_service"); + let res = + super::ensure_secure_ipc_parent_dir(ipc_path.to_string_lossy().as_ref(), "_service"); + assert!(res.is_err()); + std::fs::remove_file(&link_dir).ok(); + std::fs::remove_dir_all(&real_dir).ok(); + std::fs::remove_dir_all(&base).ok(); + } + + #[test] + fn test_ensure_secure_ipc_parent_dir_creates_parent_with_expected_mode() { + use std::os::unix::fs::PermissionsExt; + + let unique = format!( + "rustdesk-ipc-secure-dir-create-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(); + + // Intentionally choose a parent that does not exist to exercise the ENOENT -> mkdir branch. + let parent_dir = base.join("parent"); + assert!(!parent_dir.exists()); + let ipc_path = parent_dir.join("ipc"); + + let res = super::ensure_secure_ipc_parent_dir(ipc_path.to_string_lossy().as_ref(), ""); + // Restrictive umask can make mkdir create a stricter initial mode. In that case + // ensure_secure_ipc_parent_dir repairs it with fchmod and may request a scrub. + res.unwrap(); + + let md = std::fs::metadata(&parent_dir).unwrap(); + assert!(md.is_dir()); + let mode = md.permissions().mode() & 0o777; + assert_eq!(mode, 0o0700); + + std::fs::remove_dir_all(&base).ok(); + } + + #[test] + fn test_scrub_preexisting_ipc_parent_entries_only_removes_target_postfix_artifacts() { + use std::os::unix::ffi::OsStrExt; + + let unique = format!( + "rustdesk-ipc-scrub-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 ipc_file = base.join("ipc_service"); + let ipc_pid_file = base.join("ipc_service.pid"); + let ipc_other_postfix_file = base.join("ipc_uinput_1"); + let keep_file = base.join("keep.txt"); + let keep_dir = base.join("keep_dir"); + + std::fs::write(&ipc_file, b"socket-placeholder").unwrap(); + std::fs::write(&ipc_pid_file, b"1234").unwrap(); + std::fs::write(&ipc_other_postfix_file, b"other-postfix").unwrap(); + std::fs::write(&keep_file, b"keep").unwrap(); + std::fs::create_dir_all(&keep_dir).unwrap(); + + let base_c = std::ffi::CString::new(base.as_os_str().as_bytes().to_vec()).unwrap(); + let base_fd = super::open_ipc_parent_dir_fd(&base_c).unwrap(); + let _base_guard = super::FdGuard(base_fd); + super::scrub_preexisting_ipc_parent_entries(base_fd, &base, "_service").unwrap(); + + assert!(!ipc_file.exists()); + assert!(!ipc_pid_file.exists()); + assert!(ipc_other_postfix_file.exists()); + assert!(keep_file.exists()); + assert!(keep_dir.exists()); + + std::fs::remove_file(&ipc_other_postfix_file).ok(); + std::fs::remove_file(&keep_file).ok(); + std::fs::remove_dir_all(&keep_dir).ok(); + std::fs::remove_dir_all(&base).ok(); + } + + #[test] + fn test_scrub_preexisting_ipc_parent_entries_should_bind_to_opened_inode_not_path() { + use std::os::unix::ffi::OsStrExt; + + let unique = format!( + "rustdesk-ipc-scrub-fd-bind-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 trusted_parent = base.join("trusted_parent"); + let trusted_parent_moved = base.join("trusted_parent_moved"); + let attacker_parent = base.join("attacker_parent"); + std::fs::create_dir_all(&trusted_parent).unwrap(); + std::fs::create_dir_all(&attacker_parent).unwrap(); + + let trusted_ipc_file = trusted_parent.join("ipc_service"); + let attacker_ipc_file = attacker_parent.join("ipc_service"); + std::fs::write(&trusted_ipc_file, b"trusted").unwrap(); + std::fs::write(&attacker_ipc_file, b"attacker").unwrap(); + + let trusted_parent_c = + std::ffi::CString::new(trusted_parent.as_os_str().as_bytes().to_vec()).unwrap(); + let trusted_parent_fd = super::open_ipc_parent_dir_fd(&trusted_parent_c).unwrap(); + let _trusted_parent_guard = super::FdGuard(trusted_parent_fd); + + // Swap the path after the trusted inode has been opened. + std::fs::rename(&trusted_parent, &trusted_parent_moved).unwrap(); + std::fs::rename(&attacker_parent, &trusted_parent).unwrap(); + + super::scrub_preexisting_ipc_parent_entries(trusted_parent_fd, &trusted_parent, "_service") + .unwrap(); + + // Expected secure behavior: scrub should target the inode that was opened before path swap. + assert!( + !trusted_parent_moved.join("ipc_service").exists(), + "trusted inode artifact should be removed even after path swap" + ); + assert!( + trusted_parent.join("ipc_service").exists(), + "path-swapped attacker directory should not be scrubbed" + ); + + std::fs::remove_dir_all(&base).ok(); + } + + #[test] + fn test_ensure_secure_ipc_parent_dir_keeps_service_artifacts_before_liveness_probe() { + use std::os::unix::fs::PermissionsExt; + + let unique = format!( + "rustdesk-ipc-secure-dir-order-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 parent_dir = base.join("service_parent"); + std::fs::create_dir_all(&parent_dir).unwrap(); + // Trigger "had_untrusted_service_parent_mode". + std::fs::set_permissions(&parent_dir, std::fs::Permissions::from_mode(0o777)).unwrap(); + + let ipc_file = parent_dir.join("ipc_service"); + let ipc_pid_file = parent_dir.join("ipc_service.pid"); + std::fs::write(&ipc_file, b"socket-placeholder").unwrap(); + std::fs::write(&ipc_pid_file, b"1234").unwrap(); + + let res = + super::ensure_secure_ipc_parent_dir(ipc_file.to_string_lossy().as_ref(), "_service"); + assert_eq!(res.unwrap(), true); + + // Parent hardening should run first; artifacts should stay until liveness probe completes. + assert!(ipc_file.exists(), "ipc socket marker should be preserved"); + assert!(ipc_pid_file.exists(), "pid marker should be preserved"); + + std::fs::remove_dir_all(&base).ok(); + } + + #[test] + fn test_ensure_secure_ipc_parent_dir_marks_non_service_mode_repair_for_scrub() { + use std::os::unix::fs::PermissionsExt; + + let unique = format!( + "rustdesk-ipc-nonservice-mode-repair-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 parent_dir = base.join("non_service_parent"); + std::fs::create_dir_all(&parent_dir).unwrap(); + std::fs::set_permissions(&parent_dir, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let ipc_file = parent_dir.join("ipc"); + std::fs::write(&ipc_file, b"socket-placeholder").unwrap(); + + let res = super::ensure_secure_ipc_parent_dir(ipc_file.to_string_lossy().as_ref(), ""); + assert_eq!(res.unwrap(), true); + + std::fs::remove_dir_all(&base).ok(); + } + + #[test] + fn test_should_scrub_parent_entries_after_check_pid_only_when_requested_and_not_alive() { + assert!(!super::should_scrub_parent_entries_after_check_pid( + false, false + )); + assert!(!super::should_scrub_parent_entries_after_check_pid( + false, true + )); + assert!(super::should_scrub_parent_entries_after_check_pid( + true, false + )); + assert!(!super::should_scrub_parent_entries_after_check_pid( + true, true + )); + } +} diff --git a/src/kcp_stream.rs b/src/kcp_stream.rs new file mode 100644 index 000000000..74bd84130 --- /dev/null +++ b/src/kcp_stream.rs @@ -0,0 +1,151 @@ +use hbb_common::{ + anyhow, + bytes::{Bytes, BytesMut}, + bytes_codec::BytesCodec, + config, log, + tcp::{DynTcpStream, FramedStream}, + tokio::{self, net::UdpSocket, sync::mpsc, sync::oneshot}, + tokio_util, ResultType, Stream, +}; +use kcp_sys::{ + endpoint::KcpEndpoint, + packet_def::{KcpPacket, KcpPacketHeader}, + stream, +}; +use std::{net::SocketAddr, sync::Arc}; + +pub struct KcpStream { + _endpoint: KcpEndpoint, + stop_sender: Option>, +} + +impl KcpStream { + fn create_framed(stream: stream::KcpStream, local_addr: Option) -> Stream { + Stream::Tcp(FramedStream( + tokio_util::codec::Framed::new(DynTcpStream(Box::new(stream)), BytesCodec::new()), + local_addr.unwrap_or(config::Config::get_any_listen_addr(true)), + None, + 0, + )) + } + + pub async fn accept( + udp_socket: Arc, + timeout: std::time::Duration, + init_packet: Option, + ) -> ResultType<(Self, Stream)> { + let mut endpoint = KcpEndpoint::new(); + endpoint.run().await; + + let (input, output) = ( + endpoint.input_sender(), + endpoint + .output_receiver() + .ok_or_else(|| anyhow::anyhow!("Failed to get output receiver"))?, + ); + let (stop_sender, stop_receiver) = oneshot::channel(); + if let Some(packet) = init_packet { + if packet.len() >= std::mem::size_of::() { + input.send(packet.into()).await?; + } + } + Self::kcp_io(udp_socket.clone(), input, output, stop_receiver).await; + + let conn_id = tokio::time::timeout(timeout, endpoint.accept()).await??; + if let Some(stream) = stream::KcpStream::new(&endpoint, conn_id) { + Ok(( + Self { + _endpoint: endpoint, + stop_sender: Some(stop_sender), + }, + Self::create_framed(stream, udp_socket.local_addr().ok()), + )) + } else { + Err(anyhow::anyhow!("Failed to create KcpStream")) + } + } + + pub async fn connect( + udp_socket: Arc, + timeout: std::time::Duration, + ) -> ResultType<(Self, Stream)> { + let mut endpoint = KcpEndpoint::new(); + endpoint.run().await; + + let (input, output) = ( + endpoint.input_sender(), + endpoint + .output_receiver() + .ok_or_else(|| anyhow::anyhow!("Failed to get output receiver"))?, + ); + let (stop_sender, stop_receiver) = oneshot::channel(); + Self::kcp_io(udp_socket.clone(), input, output, stop_receiver).await; + + let conn_id = endpoint.connect(timeout, 0, 0, Bytes::new()).await?; + if let Some(stream) = stream::KcpStream::new(&endpoint, conn_id) { + Ok(( + Self { + _endpoint: endpoint, + stop_sender: Some(stop_sender), + }, + Self::create_framed(stream, udp_socket.local_addr().ok()), + )) + } else { + Err(anyhow::anyhow!("Failed to create KcpStream")) + } + } + + async fn kcp_io( + udp_socket: Arc, + input: mpsc::Sender, + mut output: mpsc::Receiver, + mut stop_receiver: oneshot::Receiver<()>, + ) { + let udp = udp_socket.clone(); + tokio::spawn(async move { + let mut buf = vec![0; 1500]; + loop { + tokio::select! { + _ = &mut stop_receiver => { + log::debug!("KCP io loop received stop signal"); + break; + } + Some(data) = output.recv() => { + if let Err(e) = udp.send(&data.inner()).await { + log::debug!("KCP send error: {:?}", e); + break; + } + } + result = udp.recv_from(&mut buf) => { + match result { + Ok((size, _)) => { + if size < std::mem::size_of::() { + continue; + } + input + .send(BytesMut::from(&buf[..size]).into()) + .await.ok(); + } + Err(e) => { + log::debug!("KCP recv_from error: {:?}", e); + break; + } + } + } + else => { + log::debug!("KCP endpoint input closed"); + break; + } + } + } + }); + } +} + +impl Drop for KcpStream { + fn drop(&mut self) { + if let Some(sender) = self.stop_sender.take() { + let _ = sender.send(()); + } + } +} diff --git a/src/keyboard.rs b/src/keyboard.rs index 6c68dfa10..b9cf4da2d 100644 --- a/src/keyboard.rs +++ b/src/keyboard.rs @@ -32,9 +32,33 @@ const OS_LOWER_MACOS: &str = "macos"; #[allow(dead_code)] const OS_LOWER_ANDROID: &str = "android"; -#[cfg(any(target_os = "windows", target_os = "macos"))] +#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] static KEYBOARD_HOOKED: AtomicBool = AtomicBool::new(false); +// Track key down state for relative mouse mode exit shortcut. +// macOS: Cmd+G (track G key) +// Windows/Linux: Ctrl+Alt (track whichever modifier was pressed last) +// This prevents the exit from retriggering on OS key-repeat. +#[cfg(all(feature = "flutter", any(target_os = "windows", target_os = "macos", target_os = "linux")))] +static EXIT_SHORTCUT_KEY_DOWN: AtomicBool = AtomicBool::new(false); + +// Track whether relative mouse mode is currently active. +// This is set by Flutter via set_relative_mouse_mode_state() and checked +// by the rdev grab loop to determine if exit shortcuts should be processed. +#[cfg(all(feature = "flutter", any(target_os = "windows", target_os = "macos", target_os = "linux")))] +static RELATIVE_MOUSE_MODE_ACTIVE: AtomicBool = AtomicBool::new(false); + +/// Set the relative mouse mode state from Flutter. +/// This is called when entering or exiting relative mouse mode. +#[cfg(all(feature = "flutter", any(target_os = "windows", target_os = "macos", target_os = "linux")))] +pub fn set_relative_mouse_mode_state(active: bool) { + RELATIVE_MOUSE_MODE_ACTIVE.store(active, Ordering::SeqCst); + // Reset exit shortcut state when mode changes to avoid stale state + if !active { + EXIT_SHORTCUT_KEY_DOWN.store(false, Ordering::SeqCst); + } +} + #[cfg(feature = "flutter")] #[cfg(not(any(target_os = "android", target_os = "ios")))] static IS_RDEV_ENABLED: AtomicBool = AtomicBool::new(false); @@ -58,8 +82,67 @@ lazy_static::lazy_static! { pub mod client { use super::*; + /// Tracks grab ownership and serializes transitions across threads. + /// + /// Multiple Flutter isolates (one per session window) call + /// `change_grab_status(Run/Wait)` concurrently. Without serialization a + /// stale `Wait` from session A can clobber session B's freshly acquired + /// grab on any desktop OS. + /// + /// Windows and macOS are less susceptible in practice because the Flutter + /// side triggers `enterView` only after a mouse click inside the window, + /// but we cannot rely on that. On Linux/X11, `XGrabKeyboard` can also + /// cause a focus-change feedback loop (~10 Hz), so `last_grab` debounces + /// spurious `Wait` events that arrive shortly after a `Run`. + #[derive(Default)] + struct GrabOwnerState { + owner: Option, + last_grab: Option, + /// True while a deferred-release thread is in flight. Prevents + /// spawning redundant threads during the X11 feedback loop. + deferred_pending: bool, + } + + /// How long after a grab acquisition we suppress Wait from the same session. + /// Must exceed one full X11 feedback cycle (~100 ms: 50 ms enable + 50 ms disable). + #[cfg(target_os = "linux")] + const GRAB_DEBOUNCE_MS: u128 = 300; + lazy_static::lazy_static! { static ref IS_GRAB_STARTED: Arc> = Arc::new(Mutex::new(false)); + static ref GRAB_STATE: Arc> = Arc::new(Mutex::new(GrabOwnerState::default())); + } + + #[cfg(target_os = "linux")] + lazy_static::lazy_static! { + static ref GRAB_OP_LOCK: Mutex<()> = Mutex::new(()); + } + + #[cfg(target_os = "linux")] + fn apply_run_grab_if_owner(session_id: u128, disable_first: bool) { + let _lock = GRAB_OP_LOCK.lock().unwrap(); + let gs = GRAB_STATE.lock().unwrap(); + if gs.owner != Some(session_id) { + return; + } + drop(gs); + if disable_first { + log::debug!("[grab] handoff: disable_grab before re-grab"); + rdev::disable_grab(); + } + rdev::enable_grab(); + } + + #[cfg(target_os = "linux")] + fn disable_grab_if_released() { + let _lock = GRAB_OP_LOCK.lock().unwrap(); + let should_disable = { + let gs = GRAB_STATE.lock().unwrap(); + gs.owner.is_none() && gs.last_grab.is_none() + }; + if should_disable { + rdev::disable_grab(); + } } pub fn start_grab_loop() { @@ -72,36 +155,167 @@ pub mod client { } #[cfg(not(any(target_os = "android", target_os = "ios")))] - pub fn change_grab_status(state: GrabState, keyboard_mode: &str) { + pub fn change_grab_status(state: GrabState, keyboard_mode: &str, session_id: u128) { #[cfg(feature = "flutter")] if !IS_RDEV_ENABLED.load(Ordering::SeqCst) { return; } + // Serialize transitions so a stale `Wait` from a previous owner cannot + // clobber a fresh `Run` from a different session window. + let mut release_after_unlock = None; + #[cfg(target_os = "linux")] + let mut run_grab_after_unlock = None; + #[cfg(target_os = "linux")] + let mut disable_after_unlock = false; + let mut gs = GRAB_STATE.lock().unwrap(); match state { GrabState::Ready => {} GrabState::Run => { #[cfg(windows)] update_grab_get_key_name(keyboard_mode); - #[cfg(any(target_os = "windows", target_os = "macos"))] - KEYBOARD_HOOKED.swap(true, Ordering::SeqCst); + + // Idempotent: if this session already owns the grab, just + // refresh the debounce timer (proves the session is still + // actively focused) and skip the actual grab call. + if gs.owner == Some(session_id) { + gs.last_grab = Some(std::time::Instant::now()); + // Reset so the next Wait can spawn a fresh deferred-release + // timer with an up-to-date snapshot of last_grab. + gs.deferred_pending = false; + log::debug!( + "[grab] Run(0x{:x}): already owner, refresh debounce", + session_id + ); + return; + } + + log::debug!( + "[grab] Run(0x{:x}): prev_owner={}, mode={}", + session_id, + gs.owner + .map_or("none".to_string(), |id| format!("0x{:x}", id)), + keyboard_mode, + ); + + #[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] + KEYBOARD_HOOKED.store(true, Ordering::SeqCst); #[cfg(target_os = "linux")] - rdev::enable_grab(); + let had_owner = gs.owner.is_some(); + gs.owner = Some(session_id); + gs.last_grab = Some(std::time::Instant::now()); + // Invalidate any in-flight deferred release from the previous + // owner so it cannot suppress a fresh timer for the new owner. + gs.deferred_pending = false; + #[cfg(target_os = "linux")] + { + run_grab_after_unlock = Some(had_owner); + } } GrabState::Wait => { + // Drop stale `Wait` events that do not correspond to the + // current grab owner. This prevents a late PointerExit from + // session A from releasing session B's freshly acquired grab. + if gs.owner != Some(session_id) { + log::debug!( + "[grab] Wait(0x{:x}): ignored, owner={}", + session_id, + gs.owner + .map_or("none".to_string(), |id| format!("0x{:x}", id)), + ); + return; + } + + // Debounce: on Linux/X11, XGrabKeyboard causes a focus-change + // feedback loop (grab -> PointerExit -> ungrab -> PointerEnter -> + // grab -> ...). Suppress Wait if the grab was acquired recently + // by this same session -- it is X11 feedback, not a real leave. + // A deferred release is scheduled so that a genuine leave within + // the debounce window is not permanently lost. + #[cfg(target_os = "linux")] + if let Some(t) = gs.last_grab { + let elapsed = t.elapsed().as_millis(); + if elapsed < GRAB_DEBOUNCE_MS { + if !gs.deferred_pending { + log::debug!( + "[grab] Wait(0x{:x}): debounced ({}ms < {}ms), scheduling deferred release", + session_id, elapsed, GRAB_DEBOUNCE_MS, + ); + gs.deferred_pending = true; + let remaining = (GRAB_DEBOUNCE_MS - elapsed) as u64 + 50; + let snapshot = gs.last_grab; + let mode = keyboard_mode.to_string(); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(remaining)); + let release_keys = { + let mut gs = GRAB_STATE.lock().unwrap(); + // Release only if no new Run has refreshed the grab since. + if gs.owner == Some(session_id) && gs.last_grab == snapshot { + let to_release = take_remote_keys(); + gs.deferred_pending = false; + log::debug!( + "[grab] Wait(0x{:x}): deferred release", + session_id + ); + KEYBOARD_HOOKED.store(false, Ordering::SeqCst); + gs.owner = None; + gs.last_grab = None; + Some(to_release) + } else { + log::debug!( + "[grab] Wait(0x{:x}): deferred release cancelled (grab refreshed)", + session_id, + ); + None + } + }; + if let Some(to_release) = release_keys { + disable_grab_if_released(); + release_remote_keys_for_events(&mode, to_release); + } + }); + } else { + log::debug!( + "[grab] Wait(0x{:x}): debounced, deferred release already pending", + session_id, + ); + } + return; + } + } + + log::debug!("[grab] Wait(0x{:x}): releasing grab", session_id); + #[cfg(windows)] rdev::set_get_key_unicode(false); - release_remote_keys(keyboard_mode); - - #[cfg(any(target_os = "windows", target_os = "macos"))] - KEYBOARD_HOOKED.swap(false, Ordering::SeqCst); + #[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] + KEYBOARD_HOOKED.store(false, Ordering::SeqCst); + gs.owner = None; + gs.last_grab = None; + gs.deferred_pending = false; + release_after_unlock = Some(take_remote_keys()); #[cfg(target_os = "linux")] - rdev::disable_grab(); + { + disable_after_unlock = true; + } } GrabState::Exit => {} } + drop(gs); + #[cfg(target_os = "linux")] + { + if disable_after_unlock { + disable_grab_if_released(); + } + if let Some(disable_first) = run_grab_after_unlock { + apply_run_grab_if_owner(session_id, disable_first); + } + } + if let Some(to_release) = release_after_unlock { + release_remote_keys_for_events(keyboard_mode, to_release); + } } pub fn process_event(keyboard_mode: &str, event: &Event, lock_modes: Option) { @@ -266,6 +480,135 @@ fn get_keyboard_mode() -> String { "legacy".to_string() } +/// Check if exit shortcut for relative mouse mode is active. +/// Exit shortcuts (only exits, not toggles): +/// - macOS: Cmd+G +/// - Windows/Linux: Ctrl+Alt (triggered when both are pressed) +/// Note: This shortcut is only available in Flutter client. Sciter client does not support relative mouse mode. +#[cfg(feature = "flutter")] +#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] +fn is_exit_relative_mouse_shortcut(key: Key) -> bool { + let modifiers = MODIFIERS_STATE.lock().unwrap(); + + #[cfg(target_os = "macos")] + { + // macOS: Cmd+G to exit + if key != Key::KeyG { + return false; + } + let meta = *modifiers.get(&Key::MetaLeft).unwrap_or(&false) + || *modifiers.get(&Key::MetaRight).unwrap_or(&false); + return meta; + } + + #[cfg(not(target_os = "macos"))] + { + // Windows/Linux: Ctrl+Alt to exit + // Triggered when Ctrl is pressed while Alt is down, or Alt is pressed while Ctrl is down + let is_ctrl_key = key == Key::ControlLeft || key == Key::ControlRight; + let is_alt_key = key == Key::Alt || key == Key::AltGr; + + if !is_ctrl_key && !is_alt_key { + return false; + } + + let ctrl = *modifiers.get(&Key::ControlLeft).unwrap_or(&false) + || *modifiers.get(&Key::ControlRight).unwrap_or(&false); + let alt = *modifiers.get(&Key::Alt).unwrap_or(&false) + || *modifiers.get(&Key::AltGr).unwrap_or(&false); + + // When Ctrl is pressed and Alt is already down, or vice versa + (is_ctrl_key && alt) || (is_alt_key && ctrl) + } +} + +/// Notify Flutter to exit relative mouse mode. +/// Note: This is Flutter-only. Sciter client does not support relative mouse mode. +#[cfg(feature = "flutter")] +#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] +fn notify_exit_relative_mouse_mode() { + let session_id = flutter::get_cur_session_id(); + flutter::push_session_event(&session_id, "exit_relative_mouse_mode", vec![]); +} + +/// Handle relative mouse mode shortcuts in the rdev grab loop. +/// Returns true if the event should be blocked from being sent to the peer. +#[cfg(feature = "flutter")] +#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] +#[inline] +fn can_exit_relative_mouse_mode_from_grab_loop() -> bool { + // Only process exit shortcuts when relative mouse mode is actually active. + // This prevents blocking Ctrl+Alt (or Cmd+G) when not in relative mouse mode. + if !RELATIVE_MOUSE_MODE_ACTIVE.load(Ordering::SeqCst) { + return false; + } + + let Some(session) = flutter::get_cur_session() else { + return false; + }; + + // Only for remote desktop sessions. + if !session.is_default() { + return false; + } + + // Must have keyboard permission and not be in view-only mode. + if !*session.server_keyboard_enabled.read().unwrap() { + return false; + } + let lc = session.lc.read().unwrap(); + if lc.view_only.v { + return false; + } + + // Peer must support relative mouse mode. + crate::common::is_support_relative_mouse_mode_num(lc.version) +} + +#[cfg(feature = "flutter")] +#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] +#[inline] +fn should_block_relative_mouse_shortcut(key: Key, is_press: bool) -> bool { + if !KEYBOARD_HOOKED.load(Ordering::SeqCst) { + return false; + } + + // Determine which key to track for key-up blocking based on platform + #[cfg(target_os = "macos")] + let is_tracked_key = key == Key::KeyG; + #[cfg(not(target_os = "macos"))] + let is_tracked_key = key == Key::ControlLeft + || key == Key::ControlRight + || key == Key::Alt + || key == Key::AltGr; + + // Block key up if key down was blocked (to avoid orphan key up event on remote). + // This must be checked before clearing the flag below. + if is_tracked_key && !is_press && EXIT_SHORTCUT_KEY_DOWN.swap(false, Ordering::SeqCst) { + return true; + } + + // Exit relative mouse mode shortcuts: + // - macOS: Cmd+G + // - Windows/Linux: Ctrl+Alt + // Guard it to supported/eligible sessions to avoid blocking the chord unexpectedly. + if is_exit_relative_mouse_shortcut(key) { + if !can_exit_relative_mouse_mode_from_grab_loop() { + return false; + } + if is_press { + // Only trigger exit on transition from "not pressed" to "pressed". + // This prevents retriggering on OS key-repeat. + if !EXIT_SHORTCUT_KEY_DOWN.swap(true, Ordering::SeqCst) { + notify_exit_relative_mouse_mode(); + } + } + return true; + } + + false +} + fn start_grab_loop() { std::env::set_var("KEYBOARD_ONLY", "y"); #[cfg(any(target_os = "windows", target_os = "macos"))] @@ -278,6 +621,12 @@ fn start_grab_loop() { let _scan_code = event.position_code; let _code = event.platform_code as KeyCode; + + #[cfg(feature = "flutter")] + if should_block_relative_mouse_shortcut(key, is_press) { + return None; + } + let res = if KEYBOARD_HOOKED.load(Ordering::SeqCst) { client::process_event(&get_keyboard_mode(), &event, None); if is_press { @@ -337,9 +686,14 @@ fn start_grab_loop() { #[cfg(target_os = "linux")] if let Err(err) = rdev::start_grab_listen(move |event: Event| match event.event_type { EventType::KeyPress(key) | EventType::KeyRelease(key) => { + let is_press = matches!(event.event_type, EventType::KeyPress(_)); if let Key::Unknown(keycode) = key { log::error!("rdev get unknown key, keycode is {:?}", keycode); } else { + #[cfg(feature = "flutter")] + if should_block_relative_mouse_shortcut(key, is_press) { + return None; + } client::process_event(&get_keyboard_mode(), &event, None); } None @@ -375,10 +729,12 @@ pub fn is_long_press(event: &Event) -> bool { return false; } -pub fn release_remote_keys(keyboard_mode: &str) { - // todo!: client quit suddenly, how to release keys? - let to_release = TO_RELEASE.lock().unwrap().clone(); - TO_RELEASE.lock().unwrap().clear(); +fn take_remote_keys() -> HashMap { + let mut to_release = TO_RELEASE.lock().unwrap(); + std::mem::take(&mut *to_release) +} + +fn release_remote_keys_for_events(keyboard_mode: &str, to_release: HashMap) { for (key, mut event) in to_release.into_iter() { event.event_type = EventType::KeyRelease(key); client::process_event(keyboard_mode, &event, None); @@ -393,6 +749,12 @@ pub fn release_remote_keys(keyboard_mode: &str) { } } +#[allow(dead_code)] +pub fn release_remote_keys(keyboard_mode: &str) { + // todo!: client quit suddenly, how to release keys? + release_remote_keys_for_events(keyboard_mode, take_remote_keys()); +} + pub fn get_keyboard_mode_enum(keyboard_mode: &str) -> KeyboardMode { match keyboard_mode { "map" => KeyboardMode::Map, @@ -417,6 +779,18 @@ pub fn is_modifier(key: &rdev::Key) -> bool { ) } +#[inline] +#[allow(dead_code)] +pub fn is_modifier_code(evt: &KeyEvent) -> bool { + match evt.union { + Some(key_event::Union::Chr(code)) => { + let key = rdev::linux_key_from_code(code); + is_modifier(&key) + } + _ => false, + } +} + #[inline] pub fn is_numpad_rdev_key(key: &rdev::Key) -> bool { matches!( @@ -571,7 +945,6 @@ pub fn event_to_key_events( ) -> Vec { peer.retain(|c| !c.is_whitespace()); - let mut key_event = KeyEvent::new(); update_modifiers_state(event); match event.event_type { @@ -584,6 +957,7 @@ pub fn event_to_key_events( _ => {} } + let mut key_event = KeyEvent::new(); key_event.mode = keyboard_mode.into(); let mut key_events = match keyboard_mode { @@ -869,24 +1243,11 @@ pub fn legacy_keyboard_mode(event: &Event, mut key_event: KeyEvent) -> Vec Vec { - match _map_keyboard_mode(_peer, event, key_event) { - Some(key_event) => { - if _peer == OS_LOWER_LINUX { - if let EventType::KeyPress(k) = &event.event_type { - #[cfg(target_os = "ios")] - let try_workaround = true; - #[cfg(not(target_os = "ios"))] - let try_workaround = !is_modifier(k); - if try_workaround { - return try_workaround_linux_long_press(key_event); - } - } - } - vec![key_event] - } - None => Vec::new(), - } + _map_keyboard_mode(_peer, event, key_event) + .map(|e| vec![e]) + .unwrap_or_default() } fn _map_keyboard_mode(_peer: &str, event: &Event, mut key_event: KeyEvent) -> Option { @@ -904,7 +1265,7 @@ fn _map_keyboard_mode(_peer: &str, event: &Event, mut key_event: KeyEvent) -> Op let keycode = match _peer { OS_LOWER_WINDOWS => { // https://github.com/rustdesk/rustdesk/issues/1371 - // Filter scancodes that are greater than 255 and the hight word is not 0xE0. + // Filter scancodes that are greater than 255 and the height word is not 0xE0. if event.position_code > 255 && (event.position_code >> 8) != 0xE0 { return None; } @@ -958,14 +1319,6 @@ fn _map_keyboard_mode(_peer: &str, event: &Event, mut key_event: KeyEvent) -> Op Some(key_event) } -// https://github.com/rustdesk/rustdesk/issues/6793 -#[inline] -fn try_workaround_linux_long_press(key_event: KeyEvent) -> Vec { - let mut key_event_up = key_event.clone(); - key_event_up.down = false; - vec![key_event, key_event_up] -} - #[cfg(not(any(target_os = "ios")))] fn try_fill_unicode(_peer: &str, event: &Event, key_event: &KeyEvent, events: &mut Vec) { match &event.unicode { diff --git a/src/lan.rs b/src/lan.rs index 7d3f4f05f..38c31adf9 100644 --- a/src/lan.rs +++ b/src/lan.rs @@ -1,7 +1,9 @@ -use hbb_common::config::Config; +#[cfg(not(target_os = "ios"))] +use hbb_common::whoami; use hbb_common::{ allow_err, anyhow::bail, + config::Config, config::{self, RENDEZVOUS_PORT}, log, protobuf::Message as _, @@ -45,7 +47,7 @@ pub(super) fn start_listening() -> ResultType<()> { } if let Some(self_addr) = get_ipaddr_by_peer(&addr) { let mut msg_out = Message::new(); - let mut hostname = whoami::hostname(); + let mut hostname = crate::whoami_hostname(); // The default hostname is "localhost" which is a bit confusing if hostname == "localhost" { hostname = "unknown".to_owned(); diff --git a/src/lang.rs b/src/lang.rs index 706822678..6302c2aed 100644 --- a/src/lang.rs +++ b/src/lang.rs @@ -16,8 +16,10 @@ mod es; mod et; mod eu; mod fa; +mod gu; mod fr; mod he; +mod hi; mod hr; mod hu; mod id; @@ -33,6 +35,7 @@ mod pl; mod ptbr; mod ro; mod ru; +mod sc; mod sk; mod sl; mod sq; @@ -42,7 +45,11 @@ mod th; mod tr; mod tw; mod uk; -mod vn; +mod vi; +mod ta; +mod ge; +mod fi; +mod ml; pub const LANGS: &[(&str, &str)] = &[ ("en", "English"), @@ -67,7 +74,7 @@ pub const LANGS: &[(&str, &str)] = &[ ("da", "Dansk"), ("eo", "Esperanto"), ("tr", "Türkçe"), - ("vn", "Tiếng Việt"), + ("vi", "Tiếng Việt"), ("pl", "Polski"), ("ja", "日本語"), ("ko", "한국어"), @@ -87,6 +94,13 @@ pub const LANGS: &[(&str, &str)] = &[ ("ar", "العربية"), ("he", "עברית"), ("hr", "Hrvatski"), + ("sc", "Sardu"), + ("ta", "தமிழ்"), + ("ge", "ქართული"), + ("fi", "Suomi"), + ("ml", "മലയാളം"), + ("hi", "हिंदी"), + ("gu", "ગુજરાતી"), ]; #[cfg(not(any(target_os = "android", target_os = "ios")))] @@ -139,13 +153,14 @@ pub fn translate_locale(name: String, locale: &str) -> String { "cs" => cs::T.deref(), "da" => da::T.deref(), "sk" => sk::T.deref(), - "vn" => vn::T.deref(), + "vi" => vi::T.deref(), "pl" => pl::T.deref(), "ja" => ja::T.deref(), "ko" => ko::T.deref(), "kz" => kz::T.deref(), "uk" => uk::T.deref(), "fa" => fa::T.deref(), + "fi" => fi::T.deref(), "ca" => ca::T.deref(), "el" => el::T.deref(), "sv" => sv::T.deref(), @@ -161,6 +176,12 @@ pub fn translate_locale(name: String, locale: &str) -> String { "be" => be::T.deref(), "he" => he::T.deref(), "hr" => hr::T.deref(), + "sc" => sc::T.deref(), + "ta" => ta::T.deref(), + "ge" => ge::T.deref(), + "ml" => ml::T.deref(), + "hi" => hi::T.deref(), + "gu" => gu::T.deref(), _ => en::T.deref(), }; let (name, placeholder_value) = extract_placeholder(&name); @@ -174,7 +195,26 @@ pub fn translate_locale(name: String, locale: &str) -> String { && !name.starts_with("upgrade_rustdesk_server_pro") && name != "powered_by_me" { - s = s.replace("RustDesk", &crate::get_app_name()); + let app_name = crate::get_app_name(); + if !app_name.contains("RustDesk") { + s = s.replace("RustDesk", &app_name); + } else { + // https://github.com/rustdesk/rustdesk-server-pro/issues/845 + // If app_name contains "RustDesk" (e.g., "RustDesk-Admin"), we need to avoid + // replacing "RustDesk" within the already-substituted app_name, which would + // cause duplication like "RustDesk-Admin" -> "RustDesk-Admin-Admin". + // + // app_name only contains alphanumeric and hyphen. + const PLACEHOLDER: &str = "#A-P-P-N-A-M-E#"; + if !s.contains(PLACEHOLDER) { + s = s.replace(&app_name, PLACEHOLDER); + s = s.replace("RustDesk", &app_name); + s = s.replace(PLACEHOLDER, &app_name); + } else { + // It's very unlikely to reach here. + // Skip replacement to avoid incorrect result. + } + } } } s diff --git a/src/lang/ar.rs b/src/lang/ar.rs index 31fd680fd..e13404802 100644 --- a/src/lang/ar.rs +++ b/src/lang/ar.rs @@ -41,7 +41,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("length %min% to %max%", "الطول من %min% الى %max%"), ("starts with a letter", "يبدأ بحرف"), ("allowed characters", "الحروف المسموح بها"), - ("id_change_tip", "فقط a-z, A-Z, 0-9 و _ مسموح بها. اول حرف يجب ان يكون a-z او A-Z. الطول بين 6 و 16."), + ("id_change_tip", "فقط a-z, A-Z, 0-9, - (dash) و _ مسموح بها. اول حرف يجب ان يكون a-z او A-Z. الطول بين 6 و 16."), ("Website", "الموقع"), ("About", "عن"), ("Slogan_tip", "صنع بحب في هذا العالم الفوضوي!"), @@ -147,8 +147,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("OS Password", "كلمة مرور نظام التشغيل"), ("install_tip", "بسبب صلاحيات تحكم حساب المستخدم. RustDesk قد لا يعمل بشكل صحيح في جهة البعيد في بعض الحالات. لتفادي ذلك. الرجاء الضغط على الزر ادناه لتثبيت RustDesk في جهازك."), ("Click to upgrade", "اضغط للارتقاء"), - ("Click to download", "اضغط للتنزيل"), - ("Click to update", "ضغط للتحديث"), ("Configure", "تهيئة"), ("config_acc", "لتتمكن من التحكم بسطح مكتبك البعيد, تحتاج الى منح RustDesk اذونات \"امكانية الوصول\"."), ("config_screen", "لتتمكن من الوصول الى سطح مكتبك البعيد, تحتاج الى منح RustDesk اذونات \"تسجيل الشاشة\"."), @@ -260,14 +258,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Three-Finger vertically", "ثلاث اصابع افقيا"), ("Mouse Wheel", "عجلة الفارة"), ("Two-Finger Move", "نقل الاصبعين"), - ("Canvas Move", ""), + ("Canvas Move", "تحريك اللوحة"), ("Pinch to Zoom", "قرصة للتكبير"), - ("Canvas Zoom", ""), - ("Reset canvas", ""), + ("Canvas Zoom", "تكبير اللوحة"), + ("Reset canvas", "إعادة تعيين اللوحة"), ("No permission of file transfer", "لا يوجد اذن نقل الملف"), ("Note", "ملاحظة"), ("Connection", "الاتصال"), - ("Share Screen", "مشاركة الشاشة"), + ("Share screen", "مشاركة الشاشة"), ("Chat", "محادثة"), ("Total", "الاجمالي"), ("items", "عناصر"), @@ -275,8 +273,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Screen Capture", "لقط الشاشة"), ("Input Control", "تحكم الادخال"), ("Audio Capture", "لقط الصوت"), - ("File Connection", "اتصال الملف"), - ("Screen Connection", "اتصال الشاشة"), ("Do you accept?", "هل تقبل؟"), ("Open System Setting", "فتح اعدادات النظام"), ("How to get Android input permission?", "كيف تحصل على اذن الادخال في اندرويد؟"), @@ -364,7 +360,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Recording", "التسجيل"), ("Directory", "المسار"), ("Automatically record incoming sessions", "تسجيل الجلسات القادمة تلقائيا"), - ("Automatically record outgoing sessions", ""), + ("Automatically record outgoing sessions", "تسجيل الجلسات الصادرة تلقائيا"), ("Change", "تغيير"), ("Start session recording", "بدء تسجيل الجلسة"), ("Stop session recording", "ايقاف تسجيل الجلسة"), @@ -372,7 +368,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Enable LAN discovery", "تفعيل اكتشاف الشبكة المحلية"), ("Deny LAN discovery", "رفض اكتشاف الشبكة المحلية"), ("Write a message", "اكتب رسالة"), - ("Prompt", ""), + ("Prompt", "موجه"), ("Please wait for confirmation of UAC...", "الرجاء انتظار تاكيد تحكم حساب المستخدم..."), ("elevated_foreground_window_tip", "النافذة الحالية لسطح المكتب البعيد تحتاج صلاحية اعلى لتعمل, لذلك لن تستطيع استخدام الفارة ولوحة المفاتيح مؤقتا. تستطيع انت تطلب من المستخدم البعيد تصغير النافذة الحالية, او ضفط زر الارتقاء في نافذة ادارة الاتصال. لتفادي هذة المشكلة من المستحسن تثبيت البرنامج في الجهاز البعيد."), ("Disconnected", "مفصول"), @@ -381,14 +377,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Keyboard Settings", "اعدادات لوحة المفاتيح"), ("Full Access", "وصول كامل"), ("Screen Share", "مشاركة الشاشة"), - ("Wayland requires Ubuntu 21.04 or higher version.", "Wayland يتطلب نسخة ابونتو 21.04 او اعلى."), - ("Wayland requires higher version of linux distro. Please try X11 desktop or change your OS.", "Wayland يتطلب نسخة اعلى من توزيعة لينكس. الرجاء تجربة سطح مكتب X11 او غير نظام تشغيلك."), + ("ubuntu-21-04-required", "Wayland يتطلب نسخة ابونتو 21.04 او اعلى."), + ("wayland-requires-higher-linux-version", "Wayland يتطلب نسخة اعلى من توزيعة لينكس. الرجاء تجربة سطح مكتب X11 او غير نظام تشغيلك."), + ("xdp-portal-unavailable", "لاقط شاشة Wayland فشل. بوابة سطح مكتب XDG ربما توقفت عن العمل او حدث خطأ بها. جرب اعادة تشغليها عن طريق 'systemctl --user restart xdg-desktop-portal'."), ("JumpLink", "رابط القفز"), ("Please Select the screen to be shared(Operate on the peer side).", "الرجاء اختيار شاشة لمشاركتها (تعمل على جانب القرين)."), ("Show RustDesk", "عرض RustDesk"), ("This PC", "هذا الحاسب"), ("or", "او"), - ("Continue with", "متابعة مع"), ("Elevate", "ارتقاء"), ("Zoom cursor", "تكبير المؤشر"), ("Accept sessions via password", "قبول الجلسات عبر كلمة المرور"), @@ -463,7 +459,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("empty_favorite_tip", "لا يوجد اقران مفضلين حتى الان؟\nحسنا لنبحث عن شخص للاتصال معه ومن ثم اضافته للمفضلة."), ("empty_lan_tip", "اه لا, يبدو انك لم تكتشف اي قرين بعد."), ("empty_address_book_tip", "يا عزيزي, يبدو انه لايوجد حاليا اي اقران في كتاب العناوين."), - ("eg: admin", "مثلا: admin"), ("Empty Username", "اسم مستخدم فارغ"), ("Empty Password", "كلمة مرور فارغة"), ("Me", "انا"), @@ -528,130 +523,227 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("pull_ab_failed_tip", "فشل تحديث كتاب العناوين"), ("push_ab_failed_tip", "فشل مزامنة كتاب العناوين مع الخادم"), ("synced_peer_readded_tip", "الاجهزة الموجودة في الجلسات الحديثة سيتم مزامنتها مع كتاب العناوين"), - ("Change Color", ""), - ("Primary Color", ""), - ("HSV Color", ""), - ("Installation Successful!", ""), - ("Installation failed!", ""), - ("Reverse mouse wheel", ""), - ("{} sessions", ""), - ("scam_title", ""), - ("scam_text1", ""), - ("scam_text2", ""), - ("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)", ""), - ("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", ""), - ("swap-left-right-mouse", ""), - ("2FA code", ""), - ("More", ""), - ("enable-2fa-title", ""), - ("enable-2fa-desc", ""), - ("wrong-2fa-code", ""), - ("enter-2fa-title", ""), - ("Email verification code must be 6 characters.", ""), - ("2FA code must be 6 digits.", ""), - ("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", ""), - ("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", ""), - ("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", ""), - ("Requires at least {} characters", ""), - ("Wrong PIN", ""), - ("Set 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", ""), + ("Change Color", "تغيير اللون"), + ("Primary Color", "اللون الأساسي"), + ("HSV Color", "اللون بنظام HSV"), + ("Installation Successful!", "تم التثبيت بنجاح!"), + ("Installation failed!", "فشل التثبيت!"), + ("Reverse mouse wheel", "عكس عجلة الماوس"), + ("{} sessions", "{} جلسات"), + ("scam_title", "عنوان الاحتيال"), + ("scam_text1", "تحذير! هذا قد يكون هجوم احتيالي."), + ("scam_text2", "يرجى توخي الحذر وعدم الموافقة على الاتصال إذا كنت غير متأكد."), + ("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", "ترقية خادم RustDesk Pro إلى {}"), + ("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", "يجب تكوين SELinux بشكل صحيح لضمان التشغيل السلس."), + ("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", "لا يدعم IDD في Windows 10 الإصدار 2004 أو أقدم."), + ("input_source_1_tip", "المصدر الأول للإدخال"), + ("input_source_2_tip", "المصدر الثاني للإدخال"), + ("Swap control-command key", "تبديل مفتاح التحكم-الأمر"), + ("swap-left-right-mouse", "تبديل زر الماوس الأيسر مع الأيمن"), + ("2FA code", "رمز التحقق الثنائي"), + ("More", "المزيد"), + ("enable-2fa-title", "تمكين التحقق الثنائي"), + ("enable-2fa-desc", "زيادة الأمان عن طريق التحقق الثنائي."), + ("wrong-2fa-code", "رمز التحقق الثنائي غير صحيح"), + ("enter-2fa-title", "إدخال رمز التحقق الثنائي"), + ("Email verification code must be 6 characters.", "يجب أن يتكون رمز التحقق بالبريد الإلكتروني من 6 أحرف."), + ("2FA code must be 6 digits.", "يجب أن يتكون رمز التحقق الثنائي من 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", "مسح اختيار الشاشة Wayland الحالي."), + ("confirm_clear_Wayland_screen_selection_tip", "هل أنت متأكد من مسح تحديد الشاشة Wayland؟"), + ("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", "تمكين البوت للتفاعل مع RustDesk"), + ("enable-bot-desc", "يمكنك استخدام بوت تيليجرام للتحكم في الجلسات."), + ("cancel-2fa-confirm-tip", "إلغاء تأكيد التحقق الثنائي."), + ("cancel-bot-confirm-tip", "إلغاء تأكيد بوت تيليجرام."), + ("About RustDesk", "حول RustDesk"), + ("Send clipboard keystrokes", "إرسال ضغطات المفاتيح من الحافظة"), + ("network_error_tip", "خطأ في الشبكة، يرجى المحاولة لاحقًا."), + ("Unlock with PIN", "فتح باستخدام الرقم السري"), + ("Requires at least {} characters", "يتطلب على الأقل {} حرفًا"), + ("Wrong PIN", "الرقم السري خاطئ"), + ("Set 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", "ترقية عميل RustDesk البعيد إلى {}"), + ("d3d_render_tip", "تمكين العرض باستخدام D3D"), + ("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", "يتم دعم الاتصالات عبر Relay فقط، WebSocket عند استخدام WebRelay."), + ("Use WebSocket", "استخدام WebSocket"), + ("Trackpad speed", "سرعة لوحة التتبع"), + ("Default trackpad speed", "سرعة لوحة التتبع الافتراضية"), + ("Numeric one-time password", "كلمة مرور رقمية لمرة واحدة"), + ("Enable IPv6 P2P connection", "تمكين اتصال نظير إلى نظير عبر IPv6"), + ("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", "يسمح باستخدام اتصال TLS غير آمن عند فشل الاتصال الآمن"), + ("Disable UDP", "تعطيل UDP"), + ("disable-udp-tip", "عند التفعيل لن يتم استخدام بروتوكول UDP"), + ("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 {}", "متابعة مع {}"), + ("Display Name", "اسم العرض"), + ("password-hidden-tip", "كلمة المرور مخفية"), + ("preset-password-in-use-tip", "كلمة المرور المحددة مسبقًا قيد الاستخدام"), + ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/be.rs b/src/lang/be.rs index fbe161535..9f6b69c8b 100644 --- a/src/lang/be.rs +++ b/src/lang/be.rs @@ -1,19 +1,19 @@ lazy_static::lazy_static! { pub static ref T: std::collections::HashMap<&'static str, &'static str> = [ - ("Status", "Статус"), + ("Status", "Стан"), ("Your Desktop", "Ваш працоўны стол"), ("desk_tip", "Ваш працоўны стол даступны з гэтым ID і паролем."), ("Password", "Пароль"), - ("Ready", "Гатовы"), + ("Ready", "Гатова"), ("Established", "Усталявана"), - ("connecting_status", "Падключэнне да сеткі RustDesk..."), + ("connecting_status", "Ідзе падключэнне да сеткі RustDesk..."), ("Enable service", "Уключыць службу"), ("Start service", "Запусціць службу"), ("Service is running", "Служба запушчана"), ("Service is not running", "Служба не запушчана"), - ("not_ready_status", "Не падключана. Праверце злучэнне."), - ("Control Remote Desktop", "Кіраванне выдаленым працоўным сталом"), + ("not_ready_status", "Не падключана. Праверце падключэнне."), + ("Control Remote Desktop", "Новае падключэнне"), ("Transfer file", "Перадаць файлы"), ("Connect", "Падключыцца"), ("Recent sessions", "Апошнія сеансы"), @@ -22,7 +22,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("TCP tunneling", "TCP-тунэляванне"), ("Remove", "Выдаліць"), ("Refresh random password", "Абнавіць выпадковы пароль"), - ("Set your own password", "Усталяваць свой пароль"), + ("Set your own password", "Задаць свой пароль"), ("Enable keyboard/mouse", "Выкарыстоўваць клавіятуру/мыш"), ("Enable clipboard", "Выкарыстоўваць буфер абмену"), ("Enable file transfer", "Выкарыстоўваць перадачу файлаў"), @@ -41,17 +41,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("length %min% to %max%", "даўжыня %min%...%max%"), ("starts with a letter", "пачынаецца з літары"), ("allowed characters", "дазволеныя сімвалы"), - ("id_change_tip", "Дапускаюцца толькі сімвалы a-z, A-Z, 0-9 і _ (падкрэсліванне). Першай павінна быць літара a-z, A-Z. Даўжыня ад 6 да 16."), + ("id_change_tip", "Дазволена выкарыстоўваць толькі сімвалы a-z, A-Z, 0-9, - (dash) і _ (падкрэсліванне). Першай павінна быць літара a-z, A-Z. Даўжыня ад 6 да 16."), ("Website", "Сайт"), ("About", "Пра праграму"), ("Slogan_tip", "Зроблена з душой у гэтым вар'яцкім свеце!"), - ("Privacy Statement", "Заява аб канфідэнцыяльнасці"), + ("Privacy Statement", "Заява аб канфідэнцыйнасці"), ("Mute", "Адключыць гук"), ("Build Date", "Дата зборкі"), ("Version", "Версія"), ("Home", "Галоўная"), - ("Audio Input", "Аўдыёўваход"), - ("Enhancements", "Палепшанні"), + ("Audio Input", "Аўдыяўваход"), + ("Enhancements", "Паляпшэнні"), ("Hardware Codec", "Апаратны кодэк"), ("Adaptive bitrate", "Адаптыўны бітрэйт"), ("ID Server", "Сервер ID"), @@ -63,10 +63,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("server_not_support", "Пакуль не падтрымліваецца серверам"), ("Not available", "Недаступна"), ("Too frequent", "Занадта часта"), - ("Cancel", "Адмяніць"), + ("Cancel", "Скасаваць"), ("Skip", "Прапусціць"), ("Close", "Закрыць"), - ("Retry", "Паўтор"), + ("Retry", "Паўтарыць спробу"), ("OK", "ОК"), ("Password Required", "Патрабуецца пароль"), ("Please enter your password", "Увядзіце пароль"), @@ -75,14 +75,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Do you want to enter again?", "Паўтарыць уваход?"), ("Connection Error", "Памылка падключэння"), ("Error", "Памылка"), - ("Reset by the peer", "Скінута выдаленым вузлом"), + ("Reset by the peer", "Скінута абанентам"), ("Connecting...", "Падключэнне..."), - ("Connection in progress. Please wait.", "Выконваецца падключэнне. Пачакайце."), + ("Connection in progress. Please wait.", "Ідзе падключэнне. Пачакайце."), ("Please try 1 minute later", "Паспрабуйце праз хвіліну"), ("Login Error", "Памылка ўваходу"), ("Successful", "Паспяхова"), - ("Connected, waiting for image...", "Падключана, чаканне выявы..."), - ("Name", "Імя"), + ("Connected, waiting for image...", "Падключана, чаканне відарыса..."), + ("Name", "Назва"), ("Type", "Тып"), ("Modified", "Зменена"), ("Size", "Памер"), @@ -91,80 +91,78 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Send", "Адправіць"), ("Refresh File", "Абнавіць файл"), ("Local", "Лакальны"), - ("Remote", "Выдалены"), - ("Remote Computer", "Выдалены камп'ютар"), + ("Remote", "Аддалены"), + ("Remote Computer", "Аддалены камп'ютар"), ("Local Computer", "Лакальны камп'ютар"), ("Confirm Delete", "Пацвердзіць выдаленне"), ("Delete", "Выдаліць"), ("Properties", "Уласцівасці"), ("Multi Select", "Шматлікі выбар"), - ("Select All", "Абраць усе"), - ("Unselect All", "Зняць усе"), - ("Empty Directory", "Пустая тэчка"), - ("Not an empty directory", "Тэчка не пустая"), + ("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?", "Выдаліць файл з гэтай тэчкі?"), + ("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", "Выдаленне"), + ("This is irreversible!", "Гэтага нельга адрабіць!"), + ("Deleting", "Ідзе выдаленне"), ("files", "файлы"), ("Waiting", "Чаканне"), ("Finished", "Завершана"), ("Speed", "Хуткасць"), - ("Custom Image Quality", "Якасць выявы па запыце"), - ("Privacy mode", "Рэжым прыватнасці"), - ("Block user input", "Забараніць ўвод на аддаленай прыладзе"), - ("Unblock user input", "Адблакіраваць ўвод на аддаленай прыладзе"), + ("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", "Зададзена карыстальнікам"), + ("Scrollbar", "Паласа прагортвання"), + ("ScrollAuto", "Аўта-прагортванне"), + ("Good image quality", "Добрая якасць відарыса"), + ("Balanced", "Баланс паміж якасцю і хуткасцю"), + ("Optimize reaction time", "Аптымізацыя хуткасці рэакцыі"), + ("Custom", "Карыстальніцкая"), ("Show remote cursor", "Паказваць аддалены курсор"), ("Show quality monitor", "Паказваць манітор якасці"), ("Disable clipboard", "Адключыць буфер абмену"), - ("Lock after session end", "Заблакаваць уліковы запіс пасля сеансу"), + ("Lock after session end", "Заблакіраваць уліковы запіс пасля сеанса"), ("Insert Ctrl + Alt + Del", "Уставіць Ctrl + Alt + Del"), - ("Insert Lock", "Заблакаваць уліковы запіс"), + ("Insert Lock", "Заблакіраваць уліковы запіс"), ("Refresh", "Абнавіць"), ("ID does not exist", "ID не існуе"), - ("Failed to connect to rendezvous server", "Немагчыма падключыцца да паседкавага сервера"), + ("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 rendezvous server", "Немагчыма падключыцца праз прамежкавы сервер"), ("Failed to connect via relay server", "Немагчыма падключыцца праз рэтранслятар"), - ("Failed to make direct connection to remote desktop", "Не ўдалося ўсталяваць прамое падключэнне да аддаленага працоўнага стала"), - ("Set Password", "Усталяваць пароль"), - ("OS Password", "Пароль ўваходу ў аперацыйную сістэму"), - ("install_tip", "У некаторых выпадках RustDesk можа працаваць няправільна на аддаленым вузле з-за UAC. Каб пазбегнуць магчымых праблем з UAC, націсніце кнопку ніжэй для ўстаноўкі RustDesk у сістэме."), + ("Failed to make direct connection to remote desktop", "Не ўдалося ўсталяваць прамога падключэння да аддаленай прылады"), + ("Set Password", "Задаць пароль"), + ("OS Password", "Пароль уваходу ў аперацыйную сістэму"), + ("install_tip", "У некаторых выпадках з-за UAC, RustDesk можа працаваць на баку абанента неадпаведным чынам. Каб пазбегнуць магчымых праблем з UAC, націсніце кнопку ніжэй для ўсталявання RustDesk у сістэме."), ("Click to upgrade", "Абнавіць"), - ("Click to download", "Спампаваць"), - ("Click to update", "Абнавіць"), ("Configure", "Наладзіць"), - ("config_acc", "Каб аддаленна кіраваць сваім працоўным сталом, вам неабходна дазволіць RustDesk правы доступу."), - ("config_screen", "Для аддаленага доступу да працоўнага сталу вам неабходна дазволіць RustDesk правы здымку экрана."), - ("Installing ...", "Ідзе ўстаноўка..."), + ("config_acc", "Каб аддаленна кіраваць сваім працоўным сталом, вам трэба дазволіць RustDesk правы \"доступу\""), + ("config_screen", "Для аддаленага доступу да працоўнага стала вам трэба даць RustDesk правы \"здымку экрана\"."), + ("Installing ...", "Ідзе ўсталёўванне..."), ("Install", "Усталяваць"), - ("Installation", "Устаноўка"), - ("Installation Path", "Шлях устаноўкі"), + ("Installation", "Усталёўванне"), + ("Installation Path", "Шлях усталёўвання"), ("Create start menu shortcuts", "Стварыць ярлыкі ў меню \"Пуск\""), ("Create desktop icon", "Стварыць значок на працоўным стале"), - ("agreement_tip", "Пачынаючы ўстаноўку, вы прымаеце ўмовы ліцэнзійнага ўгоды."), + ("agreement_tip", "Пачынаючы ўсталёўванне, вы прымаеце ўмовы ліцэнзійнага пагаднення."), ("Accept and Install", "Прыняць і ўсталяваць"), - ("End-user license agreement", "Ліцэнзійная ўгода з канчатковым карыстальнікам"), - ("Generating ...", "Генеруецца..."), - ("Your installation is lower version.", "Ваша ўстаноўка ніжэйшай версіі"), - ("not_close_tcp_tip", "Не зачыняць гэта акно пры выкарыстанні тунэлю."), - ("Listening ...", "Праслухоўванне..."), + ("End-user license agreement", "Ліцэнзійнае пагадненне з канчатковым карыстальнікам"), + ("Generating ...", "Ідзе генерыраванне..."), + ("Your installation is lower version.", "Усталявана ранейшая версія"), + ("not_close_tcp_tip", "Не закрываць гэтага акна пры выкарыстанні тунэлю."), + ("Listening ...", "Чаканне..."), ("Remote Host", "Аддалены хост"), ("Remote Port", "Аддалены порт"), ("Action", "Дзеянне"), @@ -172,122 +170,120 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Local Port", "Лакальны порт"), ("Local Address", "Лакальны адрас"), ("Change Local Port", "Змяніць лакальны порт"), - ("setup_server_tip", "Для хуткага падключэння наладзьце свой сервер."), + ("setup_server_tip", "Для хутчэйшага падключэння наладзьце ўласны сервер."), ("Too short, at least 6 characters.", "Занадта кароткі, мінімум 6 сімвалаў."), - ("The confirmation is not identical.", "Пацверджанне не супадае."), + ("The confirmation is not identical.", "Пацвярджэнне не супадае."), ("Permissions", "Дазволы"), ("Accept", "Прыняць"), ("Dismiss", "Адхіліць"), ("Disconnect", "Адключыць"), - ("Enable file copy and paste", "Дазволіць капіраванне і ўстаўку файлаў"), + ("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", "Увядзіце дыстанцыйны ID"), + ("Enter Remote ID", "Увядзіце ID абанента"), ("Enter your password", "Увядзіце пароль"), - ("Logging in...", "Уваход..."), - ("Enable RDP session sharing", "Дазволіць абмен сеансамі RDP"), - ("Auto Login", "Аўтаматычны ўваход у ўліковы запіс"), + ("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", "Калі ласка, увядзіце імя тэчкі"), + ("Create Folder", "Стварыць папку"), + ("Please enter the folder name", "Увядзіце імя папкі"), ("Fix it", "Выправіць"), ("Warning", "Папярэджанне"), ("Login screen using Wayland is not supported", "Уваход у сістэму з выкарыстаннем Wayland не падтрымліваецца"), ("Reboot required", "Патрабуецца перазагрузка"), - ("Unsupported display server", "Непадтрымліваемы сервер адлюстравання"), + ("Unsupported display server", "Сервер адлюстравання не падтрымліваецца"), ("x11 expected", "Чакаецца X11"), ("Port", "Порт"), ("Settings", "Налады"), ("Username", "Імя карыстальніка"), - ("Invalid port", "Няправільны порт"), - ("Closed manually by the peer", "Зачынена аддаленым вузлом уручную"), - ("Enable remote configuration modification", "Дазволіць змену канфігурацыі аддалена"), - ("Run without install", "Запусціць без ўстаноўкі"), + ("Invalid port", "Памылковы порт"), + ("Closed manually by the peer", "Закрыта абанентам уручную"), + ("Enable remote configuration modification", "Дазволіць аддаленае змяненне канфігурацыі"), + ("Run without install", "Запусціць без усталявання"), ("Connect via relay", "Падключыцца праз рэтранслятар"), ("Always connect via relay", "Заўсёды падключацца праз рэтранслятар"), - ("whitelist_tip", "Толькі IP-адрэсы з белага спісу могуць атрымаць доступ да маёй прылады."), + ("whitelist_tip", "Атрымліваць доступ да маёй прылады могуць толькі IP-адрасы з белага спісу."), ("Login", "Увайсці"), ("Verify", "Праверыць"), - ("Remember me", "Запомніць мяне"), - ("Trust this device", "Даверыць гэтую прыладу"), + ("Remember me", "Запомніць"), + ("Trust this device", "Давяраць гэтай прыладзе"), ("Verification code", "Праверачны код"), - ("verification_tip", "Выяўлена новая прылада, на зарэгістраваны адрас электроннай пошты адпраўлены праверачны код. Увядзіце яго, каб працягнуць уваход у сістэму."), + ("verification_tip", "Выяўлена новая прылада, на зарэгістраваны адрас электроннай пошты адпраўлены праверачны код. Увядзіце яго, каб працягнуць уваходжанне ў сістэму."), ("Logout", "Выйсці"), - ("Tags", "Тэгі"), + ("Tags", "Цэтлікі"), ("Search ID", "Пошук по ID"), - ("whitelist_sep", "Аддзяліць запятой, коскай з запятой, прабелам ці новым радком."), + ("whitelist_sep", "Падзяленне коскай, кропкай з коскай, прабелам або новым радком."), ("Add ID", "Дадаць ID"), - ("Add Tag", "Дадаць тэг"), - ("Unselect all tags", "Скасаваць выбар усіх тэгаў"), + ("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", "Забыць пароль"), + ("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", "Недапушчальнае імя тэчкі"), + ("Invalid folder name", "Недапушчальная назва папкі"), ("Socks5 Proxy", "Socks5-проксі"), ("Socks5/Http(s) Proxy", "Socks5/Http(s)-проксі"), ("Discovered", "Знойдзена"), - ("install_daemon_tip", "Для запуску пры загрузцы неабходна ўстанавіць сістэмную службу"), - ("Remote ID", "Аддалены ID"), + ("install_daemon_tip", "Для запуску пры загрузцы трэба ўсталяваць сістэмную службу"), + ("Remote ID", "ID абанента"), ("Paste", "Уставіць"), - ("Paste here?", "Уставіць тут?"), - ("Are you sure to close the connection?", "Ці ўпэўненыя, што жадаеце закрыць падключэнне?"), + ("Paste here?", "Уставіць сюды?"), + ("Are you sure to close the connection?", "Закрыць падключэнне?"), ("Download new version", "Спампаваць новую версію"), ("Touch mode", "Рэжым сэнсарнага экрана"), - ("Mouse mode", "Рэжым мышы/трэкпада"), - ("One-Finger Tap", "Націск адным пальцам"), + ("Mouse mode", "Рэжым мышы/сэнсарнай панэлі"), + ("One-Finger Tap", "Націсканне адным пальцам"), ("Left Mouse", "Левая кнопка мышы"), - ("One-Long Tap", "Доўгі націск адным пальцам"), - ("Two-Finger Tap", "Націск двума пальцамі"), + ("One-Long Tap", "Доўгае націсканне адным пальцам"), + ("Two-Finger Tap", "Націсканне двума пальцамі"), ("Right Mouse", "Правая кнопка мышы"), ("One-Finger Move", "Перамяшчэнне адным пальцам"), - ("Double Tap & Move", "Двайны націск і перамяшчэнне"), + ("Double Tap & Move", "Двайное націсканне і перамяшчэнне"), ("Mouse Drag", "Перацягванне мышшу"), ("Three-Finger vertically", "Трыма пальцамі па вертыкалі"), - ("Mouse Wheel", "Кола мышы"), + ("Mouse Wheel", "Колца мышы"), ("Two-Finger Move", "Перамяшчэнне двума пальцамі"), ("Canvas Move", "Перамяшчэнне палатна"), - ("Pinch to Zoom", "Маштабаванне сціскам"), - ("Canvas Zoom", "Маштаб палатна"), - ("Reset canvas", "Скінуць палатно"), + ("Pinch to Zoom", "Маштабаванне шчыпком"), + ("Canvas Zoom", "Маштабаванне палатна"), + ("Reset canvas", "Скінуць маштабаванне палатна"), ("No permission of file transfer", "Няма дазволу на перадачу файлаў"), ("Note", "Нататка"), ("Connection", "Падключэнне"), - ("Share Screen", "Дзяліцца экранам"), + ("Share screen", "Дэманстрацыя экрана"), ("Chat", "Чат"), ("Total", "Усяго"), ("items", "элементы"), ("Selected", "Выбрана"), ("Screen Capture", "Захоп экрана"), ("Input Control", "Кіраванне ўводам"), - ("Audio Capture", "Захоп аўдыё"), - ("File Connection", "Падлучэнне перадачы файлаў"), - ("Screen Connection", "Падлучэнне прагляду/кіравання экранам"), - ("Do you accept?", "Ці вы згодны?"), + ("Audio Capture", "Захоп аўдыя"), + ("Do you accept?", "Вы згодныя?"), ("Open System Setting", "Адкрыць налады сістэмы"), ("How to get Android input permission?", "Як атрымаць дазвол на ўвод Android?"), - ("android_input_permission_tip1", "Каб аддалёная прылада магла кіраваць вашай Android-прыладай з дапамогай мышы або націсканняў, неабходна дазволіць RustDesk выкарыстоўваць паслугу \"Асаблівыя магчымасці\"."), - ("android_input_permission_tip2", "Зайдзіце на адпаведную старонку сістэмных налад, знайдзіце і ўступіце ў \"Устаноўленыя паслугі\", уключыце паслугу \"RustDesk Input\"."), - ("android_new_connection_tip", "Атрыманы запыт на кіраванне вашай бягучай прыладай."), - ("android_service_will_start_tip", "Уключэнне захопу экрана аўтаматычна запускае службу, дазваляючы іншым прыладам запытаць падлучэнне да гэтай прылады."), - ("android_stop_service_tip", "Закрыццё службы аўтаматычна зачыніць усе ўстаноўленыя падлучэнні."), - ("android_version_audio_tip", "Бягучая версія Android не падтрымлівае захоп звуку, абнавіце яе да Android 10 ці вышэй."), + ("android_input_permission_tip1", "Каб аддаленая прылада магла кіраваць вашай Android-прыладай з дапамогай мышы або націсканняў, трэба дазволіць RustDesk выкарыстоўваць службу \"Спецыяльныя магчымасці\"."), + ("android_input_permission_tip2", "Зайдзіце на адпаведную старонку сістэмных налад, знайдзіце і перайдзіце ва \"Усталяваныя службы\", уключыце службу \"RustDesk Input\"."), + ("android_new_connection_tip", "Новы запыт на кіраванне вашай бягучай прыладай."), + ("android_service_will_start_tip", "Уключэнне захопу экрана аўтаматычна запускае службу, дазваляючы іншым прыладам запытаць падключэнне да гэтай прылады."), + ("android_stop_service_tip", "Закрыццё службы аўтаматычна закрые ўсе ўстаноўленыя падключэнні."), + ("android_version_audio_tip", "Бягучая версія Android не падтрымлівае захопу гуку, абнавіце яе да Android 10 ці вышэй."), ("android_start_service_tip", "Націсніце [Запусціць службу] або дазвольце [Захоп экрана], каб запусціць службу дэманстрацыі экрана."), - ("android_permission_may_not_change_tip", "Дазволы для ўстаноўленых падлучэнняў не могуць быць змененыя, неабходна перападключэнне."), + ("android_permission_may_not_change_tip", "Дазволы для ўстаноўленых падключэнняў не могуць быць зменены, патрабуецца перападключэнне."), ("Account", "Уліковы запіс"), ("Overwrite", "Перазапісаць"), ("This file exists, skip or overwrite this file?", "Файл існуе, прапусціць ці перазапісаць яго?"), @@ -295,47 +291,47 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Help", "Дапамога"), ("Failed", "Не ўдалося"), ("Succeeded", "Выканана"), - ("Someone turns on privacy mode, exit", "Хтосьці ўключыў рэжым прыватнасці, выхад"), + ("Someone turns on privacy mode, exit", "Хтосьці ўключыў рэжым канфідэнцыйнасці, выхад"), ("Unsupported", "Не падтрымліваецца"), - ("Peer denied", "Адмоўлена аддаленым вузлом"), - ("Please install plugins", "Усталюйце плагіны"), - ("Peer exit", "Аддалены вузел адключаны"), - ("Failed to turn off", "Немагчыма адключыць"), - ("Turned off", "Адключаны"), + ("Peer denied", "Забаронена абанентам"), + ("Please install plugins", "Усталюйце ўбудовы"), + ("Peer exit", "Абанент выйшаў"), + ("Failed to turn off", "Немагчыма выключыць"), + ("Turned off", "Выключаны"), ("Language", "Мова"), ("Keep RustDesk background service", "Захаваць фонавую службу RustDesk"), - ("Ignore Battery Optimizations", "Ігнараваць аптымізацыю патрэблення батарэі"), + ("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", "Стары рэжым"), + ("Legacy mode", "Састарэлы рэжым"), ("Map mode", "Рэжым супастаўлення"), ("Translate mode", "Рэжым перакладу"), ("Use permanent password", "Выкарыстоўваць пастаянны пароль"), ("Use both passwords", "Выкарыстоўваць абодва паролі"), - ("Set permanent password", "Устанавіць пастаянны пароль"), + ("Set permanent password", "Задаць пастаянны пароль"), ("Enable remote restart", "Дазволіць аддалены перазапуск"), ("Restart remote device", "Перазапусціць аддаленую прыладу"), - ("Are you sure you want to restart", "Вы ўпэўненыя, што хочаце перазагрузіць?"), - ("Restarting remote device", "Перазапуск аддаленай прылады"), - ("remote_restarting_tip", "Аддаленая прылада перазапускаецца. Закрыйце гэтае паведамленне і праз некаторы час перападключыцеся, выкарыстоўваючы пастаянны пароль."), - ("Copied", "Скапіравана"), + ("Are you sure you want to restart", "Вы ўпэўненыя, што хочаце зрабіць перазапуск?"), + ("Restarting remote device", "Ідзе перазапуск аддаленай прылады"), + ("remote_restarting_tip", "Аддаленая прылада перазапускаецца. Закрыйце гэта паведамленне і праз некаторы час перападключыцеся, выкарыстоўваючы пастаянны пароль."), + ("Copied", "Скапіявана"), ("Exit Fullscreen", "Выйсці з поўнаэкраннага рэжыму"), ("Fullscreen", "Поўнаэкранны рэжым"), ("Mobile Actions", "Мабільныя дзеянні"), - ("Select Monitor", "Выбраць манітор"), - ("Control Actions", "Дзеянні па кіраванню"), + ("Select Monitor", "Выберыце манітор"), + ("Control Actions", "Дзеянні па кіраванні"), ("Display Settings", "Налады адлюстравання"), ("Ratio", "Суадносіны"), - ("Image Quality", "Якасць выявы"), - ("Scroll Style", "Стыль пракруткі"), + ("Image Quality", "Якасць відарыса"), + ("Scroll Style", "Стыль прагортвання"), ("Show Toolbar", "Паказаць панэль інструментаў"), ("Hide Toolbar", "Схаваць панэль інструментаў"), - ("Direct Connection", "Прамаое злучэнне"), - ("Relay Connection", "Рэтрансляванае злучэнне"), - ("Secure Connection", "Бяспечнае злучэнне"), - ("Insecure Connection", "Нябяспечнае злучэнне"), + ("Direct Connection", "Прамое падключэнне"), + ("Relay Connection", "Рэтрансляванае падключэнне"), + ("Secure Connection", "Бяспечнае падключэнне"), + ("Insecure Connection", "Нябяспечнае падключэнне"), ("Scale original", "Арыгінальны маштаб"), ("Scale adaptive", "Адаптыўны маштаб"), ("General", "Агульныя"), @@ -343,13 +339,13 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Theme", "Тэма"), ("Dark Theme", "Цёмная тэма"), ("Light Theme", "Светлая тэма"), - ("Dark", "Цёмны"), - ("Light", "Светлы"), - ("Follow System", "Прытрымлівацца сістэмы"), + ("Dark", "Цёмная"), + ("Light", "Светлая"), + ("Follow System", "Сістэмная"), ("Enable hardware codec", "Уключыць апаратны кодэк"), - ("Unlock Security Settings", "Разблакаваць налады бяспекі"), + ("Unlock Security Settings", "Разблакіраваць налады бяспекі"), ("Enable audio", "Уключыць перадачу гуку"), - ("Unlock Network Settings", "Разблакаваць сеткавыя налады"), + ("Unlock Network Settings", "Разблакіраваць сеткавыя налады"), ("Server", "Сервер"), ("Direct IP Access", "Прамы IP-доступ"), ("Proxy", "Проксі"), @@ -362,7 +358,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Pin Toolbar", "Закрэпіць панэль інструментаў"), ("Unpin Toolbar", "Адкрэпіць панэль інструментаў"), ("Recording", "Запіс"), - ("Directory", "Тэчка"), + ("Directory", "Каталог"), ("Automatically record incoming sessions", "Аўтаматычна запісваць уваходныя сесіі"), ("Automatically record outgoing sessions", ""), ("Change", "Змяніць"), @@ -374,35 +370,35 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Write a message", "Напісаць паведамленне"), ("Prompt", "Падказка"), ("Please wait for confirmation of UAC...", "Дачакайцеся пацверджання UAC..."), - ("elevated_foreground_window_tip", "Бягучае акно аддаленага працоўнага стала патрабуе вышэйшых прывілегій для працы, таму часова немагчыма выкарыстоўваць мыш і клавіятуру. Можна папрасіць аддаленага карыстальніка згорнуць бягучае акно або націснуць кнопку павышэння правоў у акне кіравання падлучэннем. Каб прадухіліць гэтую праблему ў будучыні, рэкамендуецца ўстанавіць праграмнае забеспячэнне на аддаленай прыладзе."), + ("elevated_foreground_window_tip", "Бягучае акно аддаленага працоўнага стала патрабуе вышэйшых прывілегій для працы, таму часова немагчыма выкарыстоўваць мыш і клавіятуру. Можна папрасіць абанента згарнуць бягучае акно або націснуць кнопку павышэння правоў у акне кіравання падключэннем. Каб прадухіліць гэту праблему ў будучыні, рэкамендуецца ўсталяваць праграмнае забеспячэнне на аддаленай прыладзе."), ("Disconnected", "Адключана"), ("Other", "Іншае"), - ("Confirm before closing multiple tabs", "Пацвердзіць закрыццё некалькіх ўкладак"), + ("Confirm before closing multiple tabs", "Пацвердзіць закрыццё некалькіх укладак"), ("Keyboard Settings", "Налады клавіятуры"), ("Full Access", "Поўны доступ"), ("Screen Share", "Дэманстрацыя экрана"), - ("Wayland requires Ubuntu 21.04 or higher version.", "Wayland патрабуе Ubuntu версіі 21.04 або навейшай."), - ("Wayland requires higher version of linux distro. Please try X11 desktop or change your OS.", "Для Wayland патрабуецца вышэйшая версія дыстрыбутыву Linux. Карыстайцеся працоўным сталом X11 або зменіце сваю АС."), - ("JumpLink", "Перайсці па спасылцы"), - ("Please Select the screen to be shared(Operate on the peer side).", "Выберыце экран для дэманстрацыі (кіруецца аддаленай стараной)."), + ("ubuntu-21-04-required", "Wayland патрабуе Ubuntu версіі 21.04 або навейшай."), + ("wayland-requires-higher-linux-version", "Для Wayland патрабуецца вышэйшая версія дыстрыбутыва Linux. Карыстайцеся працоўным сталом X11 або зменіце сваю АС."), + ("xdp-portal-unavailable", ""), + ("JumpLink", "Прагляд"), + ("Please Select the screen to be shared(Operate on the peer side).", "Выберыце экран для дэманстрацыі (кіруецца на баку абанента)."), ("Show RustDesk", "Паказаць RustDesk"), - ("This PC", "Гэты кампутар"), + ("This PC", "Гэты камп’ютар"), ("or", "або"), - ("Continue with", "Працягнуць з"), ("Elevate", "Павысіць"), - ("Zoom cursor", "Павялічэнне курсора"), + ("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...", "Дачакайцеся, пакуль аддаленая старана прыме ваш запыт на сеанс..."), + ("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 connection management window", "Схаваць акно кіравання падключэннямі"), ("hide_cm_tip", "Дазваляць схаванне акна ў выпадку, калі прымаюцца сесіі па паролю або выкарыстоўваецца пастаянны пароль"), - ("wayland_experiment_tip", "Падтрымка Wayland знаходзіцца на эксперыментальнай стадыі, калі вам неабходны аўтаматычны доступ, выкарыстоўвайце X11."), - ("Right click to select tabs", "Правы клік для выбару ўкладак"), + ("wayland_experiment_tip", "Падтрымка Wayland знаходзіцца на эксперыментальнай стадыі, калі вам трэба аўтаматычны доступ, выкарыстоўвайце X11."), + ("Right click to select tabs", "Выбар укладак націсканнем правай кнопкі мышы"), ("Skipped", "Прапушчана"), ("Add to address book", "Дадаць у адрасную кнігу"), ("Group", "Група"), @@ -410,72 +406,71 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Closed manually by web console", "Закрыта ўручную праз вэб-кансоль"), ("Local keyboard type", "Тып лакальнай клавіятуры"), ("Select local keyboard type", "Выберыце тып лакальнай клавіятуры"), - ("software_render_tip", "Калі ў вас ёсць відэакарта Nvidia і аддаленае акно зачыняецца адразу пасля падлучэння, магчыма, дапаможа ўстаноўка драйвера Nouveau і выбар выкарыстання праграмнай візуалізацыі. Патрабуецца перазагрузка."), + ("software_render_tip", "Калі ў вас ёсць відэакарта Nvidia і аддаленае акно закрываецца адразу пасля падключэння, магчыма, дапаможа ўсталяванне драйвера Nouveau і выбар выкарыстання праграмнай візуалізацыі. Патрабуецца перазагрузка."), ("Always use software rendering", "Заўсёды выкарыстоўваць праграмную візуалізацыю"), - ("config_input", "Каб кіраваць аддаленым працоўным сталом праз клавіятуру, неабходна дазволіць RustDesk маніторынг уводу."), - ("config_microphone", "Каб размаўляць з аддаленай старонкай, неабходна дазволіць RustDesk запіс аўдыё."), - ("request_elevation_tip", "Таксама можна запытаць павышэнне правоў, калі хто-небудзь знаходзіцца на аддаленай старонцы."), + ("config_input", "Каб кіраваць аддаленым працоўным сталом праз клавіятуру, трэба дазволіць RustDesk \"Маніторынг уводу\"."), + ("config_microphone", "Каб размаўляць з абанентам, трэба дазволіць RustDesk запіс аўдыя."), + ("request_elevation_tip", "Таксама можна запытаць павышэння правоў, калі хто-небудзь знаходзіцца на баку абанента."), ("Wait", "Чакайце"), ("Elevation Error", "Памылка павышэння правоў"), - ("Ask the remote user for authentication", "Запытаць аўтэнтыфікацыю ў аддаленага карыстальніка"), - ("Choose this if the remote account is administrator", "Выберыце гэта, калі аддалены акаўнт з'яўляецца адміністратарам"), + ("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", "Дагэтуль патрэбна, каб аддалены карыстальнік націснуў \"OK\" ў акне UAC пры запуску RustDesk."), - ("Request Elevation", "Запыт на павышэнне"), - ("wait_accept_uac_tip", "Пачакайце, пакуль аддалены карыстальнік пацвердзіць запыт UAC."), - ("Elevate successfully", "Павышэнне паспяхова выканана"), - ("uppercase", "Вялікія літары"), - ("lowercase", "Малыя літары"), - ("digit", "Лічбы"), - ("special character", "Спецыяльныя сімвалы"), - ("length>=8", "Даўжыня >= 8 сімвалаў"), + ("still_click_uac_tip", "Дагэтуль патрэбна, каб абанент націснуў \"OK\" ў акне UAC пры запуску RustDesk."), + ("Request Elevation", "Запытаць павышэння"), + ("wait_accept_uac_tip", "Пачакайце, пакуль абанент пацвердзіць запыт UAC."), + ("Elevate successfully", "Правы павышаны"), + ("uppercase", "верхні рэгістр"), + ("lowercase", "ніжні рэгістр"), + ("digit", "лічбы"), + ("special character", "спецыяльныя сімвалы"), + ("length>=8", "8+ сімвалаў"), ("Weak", "Слабы"), ("Medium", "Сярэдні"), ("Strong", "Моцны"), ("Switch Sides", "Пераключыць бакі"), - ("Please confirm if you want to share your desktop?", "Пацвердзіце, калі хочаце дазволіць паказ вашага працоўнага стала?"), + ("Please confirm if you want to share your desktop?", "Вы сапраўды дазваляеце дэманстрацыю працоўнага стала?"), ("Display", "Адлюстраванне"), - ("Default View Style", "Стыль адлюстравання па змаўчанні"), - ("Default Scroll Style", "Стыль пракруткі па змаўчанні"), - ("Default Image Quality", "Якасць выявы па змаўчанні"), - ("Default Codec", "Кодэк па змаўчанні"), + ("Default View Style", "Стандартны стыль адлюстравання"), + ("Default Scroll Style", "Стандартны стыль прагортвання"), + ("Default Image Quality", "Стандартная якасць відарыса"), + ("Default Codec", "Стандартны кодэк"), ("Bitrate", "Бітрэйт"), ("FPS", "Колькасць кадраў у секунду"), ("Auto", "Аўта"), - ("Other Default Options", "Іншыя параметры па змаўчанні"), + ("Other Default Options", "Іншыя стандартныя параметры"), ("Voice call", "Галасавы выклік"), ("Text chat", "Тэкставы чат"), ("Stop voice call", "Спыніць галасавы выклік"), - ("relay_hint_tip", "Непасрэднае падключэнне можа быць немагчымым. У гэтым выпадку можна спрабаваць падключыцца праз рэлей.\nАкрамя таго, калі вы хочаце адразу выкарыстоўваць рэлей, можна дадаць да ідэнтыфікатара суфікс \"/r\" або ўключыць \"Заўсёды падключацца праз рэлей\" ў наладах аддаленага вузла."), + ("relay_hint_tip", "Непасрэднае падключэнне можа быць немагчымым. У гэтым выпадку можна спрабаваць падключыцца праз рэтранслятар.\nАкрамя таго, калі вы хочаце адразу выкарыстоўваць рэтранслятар, можна дадаць да ідэнтыфікатара суфікс \"/r\" або ўключыць \"Заўсёды падключацца праз рэтранслятар\" у наладах абанента."), ("Reconnect", "Перападключыць"), ("Codec", "Кодэк"), - ("Resolution", "Разрознасць"), + ("Resolution", "Раздзяляльнасць"), ("No transfers in progress", "Перадача не ажыццяўляецца"), ("Set one-time password length", "Усталяваць даўжыню аднаразовага пароля"), ("RDP Settings", "Налады RDP"), ("Sort by", "Сартаваць па"), - ("New Connection", "Новае злучэнне"), + ("New Connection", "Новае падключэнне"), ("Restore", "Аднавіць"), ("Minimize", "Згарнуць"), ("Maximize", "Разгарнуць"), ("Your Device", "Ваша прылада"), ("empty_recent_tip", "Няма апошніх сеансаў!\nЧас запланаваць новы."), - ("empty_favorite_tip", "Яшчэ няма выбраных аддаленых вузлоў?\nДавайце знойдзем, каго можна дадаць у выбранае."), - ("empty_lan_tip", "Не знойдзены аддаленыя вузлы."), - ("empty_address_book_tip", "У адраснай кнізе няма аддаленых вузлоў."), - ("eg: admin", "напрыклад: admin"), + ("empty_favorite_tip", "Яшчэ няма абраных абанентаў?\nДавайце знойдзем, каго можна дадаць у абранае."), + ("empty_lan_tip", "Абанентаў не знойдзена."), + ("empty_address_book_tip", "У адраснай кнізе няма абанентаў."), ("Empty Username", "Пустае імя карыстальніка"), ("Empty Password", "Пусты пароль"), ("Me", "Я"), - ("identical_file_tip", "Файл ідэнтычны файлу на аддаленым вузле"), + ("identical_file_tip", "Файл ідэнтычны файлу абанента"), ("show_monitors_tip", "Паказваць маніторы на панэлі інструментаў"), ("View Mode", "Рэжым прагляду"), - ("login_linux_tip", "Каб ўключыць сеанс працоўнага стала X, неабходна ўвайсці ў аддалены акаўнт Linux."), + ("login_linux_tip", "Каб уключыць сеанс працоўнага стала X, трэба ўвайсці ў аддалены ўліковы запіс Linux."), ("verify_rustdesk_password_tip", "Пацвердзіць пароль RustDesk"), - ("remember_account_tip", "Запомніць гэты акаўнт"), - ("os_account_desk_tip", "Гэты акаўнт выкарыстоўваецца для ўваходу ў аддаленую аперацыйную сістэму і ўключэння сеансу працоўнага сталу ў рэжыме headless."), + ("remember_account_tip", "Запомніць гэты ўліковы запіс"), + ("os_account_desk_tip", "Гэты ўліковы запіс выкарыстоўваецца для ўваходу ў аддаленую аперацыйную сістэму і ўключэння сеанса працоўнага стала ў рэжыме headless."), ("OS Account", "Акаўнт АС"), - ("another_user_login_title_tip", "Іншы карыстальнік ўжо ўвайшоў у сістэму"), + ("another_user_login_title_tip", "Іншы карыстальнік ужо ўвайшоў у сістэму"), ("another_user_login_text_tip", "Адключыць"), ("xorg_not_found_title_tip", "Xorg не знойдзены"), ("xorg_not_found_text_tip", "Усталюйце Xorg"), @@ -483,39 +478,39 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("no_desktop_text_tip", "Усталюйце GNOME Desktop"), ("No need to elevate", "Павышэнне правоў не патрабуецца"), ("System Sound", "Сістэмны гук"), - ("Default", "Па змаўчанні"), + ("Default", "Стандартна"), ("New RDP", "Новы RDP"), ("Fingerprint", "Адбітак"), - ("Copy Fingerprint", "Капіраваць адбітак"), + ("Copy Fingerprint", "Капіяваць адбітак"), ("no fingerprints", "адбіткі адсутнічаюць"), - ("Select a peer", "Выберыце аддалены ўзел"), - ("Select peers", "Выберыце аддаленыя ўзлы"), - ("Plugins", "Плагіны"), + ("Select a peer", "Выберыце абанента"), + ("Select peers", "Выберыце абанентаў"), + ("Plugins", "Убудовы"), ("Uninstall", "Выдаліць"), ("Update", "Абнавіць"), ("Enable", "Уключыць"), ("Disable", "Адключыць"), ("Options", "Параметры"), - ("resolution_original_tip", "Арыгінальнае разознасць"), - ("resolution_fit_local_tip", "Супадзенне з лакальнай разрознасцю"), - ("resolution_custom_tip", "Карыстацкая разрознасць"), + ("resolution_original_tip", "Арыгінальная раздзяляльнасць"), + ("resolution_fit_local_tip", "Супадзенне з лакальнай раздзяляльнасцю"), + ("resolution_custom_tip", "Карыстацкая раздзяляльнасць"), ("Collapse toolbar", "Згарнуць панэль інструментаў"), ("Accept and Elevate", "Прыняць і павысіць"), - ("accept_and_elevate_btn_tooltip", "Дазволіць падлучэнне і павысіць правы UAC."), - ("clipboard_wait_response_timeout_tip", "Час чакання адказу капіравання буфера абмену скончыўся"), - ("Incoming connection", "Уваходнае падлучэнне"), - ("Outgoing connection", "Выходнае падлучэнне"), - ("Exit", "Выхад"), + ("accept_and_elevate_btn_tooltip", "Дазволіць падключэнне і павысіць правы UAC."), + ("clipboard_wait_response_timeout_tip", "Час чакання адказу капіявання буфера абмену скончыўся"), + ("Incoming connection", "Уваходнае падключэнне"), + ("Outgoing connection", "Выходнае падключэнне"), + ("Exit", "Выйсці"), ("Open", "Адкрыць"), - ("logout_tip", "Вы сапраўды жадаеце выйсці?"), + ("logout_tip", "Вы сапраўды хочаце выйсці?"), ("Service", "Служба"), ("Start", "Запусціць"), ("Stop", "Спыніць"), - ("exceed_max_devices", "Дасягнута максімальная колькасць кіруемых прылад."), + ("exceed_max_devices", "Дасягнута максімальная колькасць кантраляваных прылад."), ("Sync with recent sessions", "Сінхранізацыя з апошнімі сеансамі"), - ("Sort tags", "Сартаваць тэгі"), - ("Open connection in new tab", "Адкрыць падлучэнне ў новай ўкладцы"), - ("Move tab to new window", "Перамясціць ўкладку ў новае акно"), + ("Sort tags", "Сартаваць цэтлікі"), + ("Open connection in new tab", "Адкрыць падключэнне ў новай укладцы"), + ("Move tab to new window", "Перамясціць укладку ў новае акно"), ("Can not be empty", "Ня можа быць пустым"), ("Already exists", "Ужо існуе"), ("Change Password", "Змяніць пароль"), @@ -524,134 +519,231 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Grid View", "Сетка"), ("List View", "Спіс"), ("Select", "Выбар"), - ("Toggle Tags", "Пераключыць тэгі"), + ("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", "Калі вы размаўляеце па тэлефоне з кімсці, каго вы НЕ ВЕДАЕЦЕ і каму НЕ ДАВЕРАЕЦЕ, і ён просіць вас выкарыстаць RustDesk і запусціць яго службу, не працягвайце і неадкладна адмяніце размову."), - ("scam_text2", "Магчыма, гэта аферыст, які паспрабуе ўкрасць вашыя грошы або іншую асабістую інфармацыю."), + ("Installation Successful!", "Усталяванне выканана!"), + ("Installation failed!", "Усталяванне не ўдалося."), + ("Reverse mouse wheel", "Адваротнае прагортванне мышшу"), + ("{} sessions", "Колькасць сеансаў: {}"), + ("scam_title", "Вас могуць ПАДМАНУЦЬ!"), + ("scam_text1", "Калі вы размаўляеце па тэлефоне з кімсьці НЕЗНАЁМЫМ і каму вы НЕ ДАВЕРАЕЦЕ, і гэта асоба просіць вас выкарыстаць RustDesk і запусціць яго службу, не працягвайце і неадкладна скончыце размову."), + ("scam_text2", "Магчыма, гэта аферыст, які спрабуе скрасці вашы грошы або іншую асабістую інфармацыю."), ("Don't show again", "Не паказваць больш"), - ("I Agree", "Я згодны"), + ("I Agree", "Згаджаюся"), ("Decline", "Адхіліць"), ("Timeout in minutes", "Час чакання (у хвілінах)"), - ("auto_disconnect_option_tip", "Аўтаматычна зачыняць уваходныя сеансы пры неактыўнасці карыстальніка"), - ("Connection failed due to inactivity", "Падлучэнне не ўдалося з-за неактыўнасці"), + ("auto_disconnect_option_tip", "Аўтаматычна закрываць уваходныя сеансы пры неактыўнасці карыстальніка"), + ("Connection failed due to inactivity", "Збой падключэння з-за неактыўнасці"), ("Check for software update on startup", "Праверка абнаўленняў праграмы пры запуску"), - ("upgrade_rustdesk_server_pro_to_{}_tip", "Абнавіце RustDesk Server Pro да версіі {} або новейшай!"), + ("upgrade_rustdesk_server_pro_to_{}_tip", "Абнавіце RustDesk Server Pro да версіі {} або навейшай!"), ("pull_group_failed_tip", "Немагчыма абнавіць групу"), ("Filter by intersection", "Фільтраваць па перасячэнні"), - ("Remove wallpaper during incoming sessions", "Схаваць фон працоўнага стала падчас ўваходнага сеансу"), + ("Remove wallpaper during incoming sessions", "Схаваць шпалеры працоўнага стала ў часе ўваходнага сеанса"), ("Test", "Тэст"), - ("display_is_plugged_out_msg", "Дысплей адключаны, пераключыцеся на першы дысплей."), - ("No displays", "Няма дысплеяў"), + ("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", "На вашай прыладзе ўключаны SELinux, што можа перашкаджаць правільнай працы RustDesk на кіруючым баку."), - ("Change view", "Змяніць выгляд"), + ("Show displays as individual windows", "Паказваць дысплэі ў асобных вокнах"), + ("Use all my displays for the remote session", "Выкарыстоўваць усе мае дысплэі для аддаленага сеанса"), + ("selinux_tip", "На вашай прыладзе ўключаны SELinux, што можа ствараць перашкоды ў працы RustDesk на баку абанента."), + ("Change view", "Рэжым"), ("Big tiles", "Вялікія пліткі"), ("Small tiles", "Маленькія пліткі"), ("List", "Спіс"), - ("Virtual display", "Віртуальны дысплей"), + ("Virtual display", "Віртуальны дысплэй"), ("Plug out all", "Адключыць усё"), ("True color (4:4:4)", "True color (4:4:4)"), - ("Enable blocking user input", "Дазволіць блакаванне ўводу карыстальніка на прыладзе"), - ("id_input_tip", "Можна ўвесці ідэнтыфікатар, просты IP-адрас або дамен з портам (<дамен>:<порт>).\nКаб атрымаць доступ да прылады на іншым серверы, дадайце адрас сервера (@<адрас_сервера>?key=<ключ_значэнне>), напрыклад:\n9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=.\nКалі неабходна атрымаць доступ да прылады на грамадскім серверы, увядзіце \"@public\", ключ для грамадскага сервера не патрабуецца."), + ("Enable blocking user input", "Дазволіць блакіраванне ўводу на прыладзе"), + ("id_input_tip", "Можна ўвесці ідэнтыфікатар, прамы IP-адрас або дамен з портам (<дамен>:<порт>).\nКаб атрымаць доступ да прылады на іншым серверы, дадайце адрас сервера (@<адрас_сервера>?key=<ключ_значэнне>), напрыклад:\n9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=.\nКалі трэба атрымаць доступ да прылады на агульнадаступным серверы, увядзіце \"@public\", ключ для публічнага сервера не патрабуецца."), ("privacy_mode_impl_mag_tip", "Рэжым 1"), ("privacy_mode_impl_virtual_display_tip", "Рэжым 2"), - ("Enter privacy mode", "Уключыць рэжым канфідэнцыяльнасці"), - ("Exit privacy mode", "Адключыць рэжым канфідэнцыяльнасці"), - ("idd_not_support_under_win10_2004_tip", "Драйвер непрамога адлюстравання не падтрымліваецца. Патрабуецца Windows 10 версіі 2004 ці навейшая."), + ("Enter privacy mode", "Уключыць рэжым канфідэнцыйнасці"), + ("Exit privacy mode", "Адключыць рэжым канфідэнцыйнасці"), + ("idd_not_support_under_win10_2004_tip", "Драйвер непрамога адлюстравання не падтрымліваецца. Патрабуецца Windows 10 версіі 2004 або навейшая."), ("input_source_1_tip", "Крыніца ўводу 1"), ("input_source_2_tip", "Крыніца ўводу 2"), ("Swap control-command key", "Памяняць месцамі значэнні кнопак Ctrl і Command"), ("swap-left-right-mouse", "Памяняць месцамі значэнні левай і правай кнопак мышы"), - ("2FA code", "Код двухфактарнай аўтэнтыфікацыі"), + ("2FA code", "Код двухфактарнай праверкі сапраўднасці"), ("More", "Яшчэ"), - ("enable-2fa-title", "Выкарыстоўваць двухфактарную аўтэнтыфікацыю"), - ("enable-2fa-desc", "Наладзьце праграму аўтэнтыфікацыі. Выкарыстоўвайце, напрыклад, Authy, Microsoft або Google Authenticator на тэлефоне ці кампутары.\n\nСкануйце QR-код з дапамогай праграмы аўтэнтыфікацыі і ўвядзіце код, які пакажа гэта праграма, каб уключыць двухфактарную аўтэнтыфікацыю."), + ("enable-2fa-title", "Выкарыстоўваць двухфактарную праверку сапраўднасці"), + ("enable-2fa-desc", "Наладзьце праграму праверкі сапраўднасці. Выкарыстоўвайце, напрыклад, Authy, Microsoft або Google Authenticator на тэлефоне ці камп’ютары.\n\nАдскануйце QR-код з дапамогай праграмы праверкі сапраўднасці і ўвядзіце код, які пакажа гэта праграма, каб уключыць двухфактарную праверку сапраўднасці."), ("wrong-2fa-code", "Немагчыма пацвердзіць код. Праверце код і налады мясцовага часу."), - ("enter-2fa-title", "Двухфактарная аутэнтыфікацыя"), - ("Email verification code must be 6 characters.", "Код верыфікацыі па электроннай пошце павінен складацца з 6 сімвалаў."), - ("2FA code must be 6 digits.", "Код двухфактарнай аутэнтыфікацыі павінен складацца з 6 лічбаў."), + ("enter-2fa-title", "Двухфактарная праверка сапраўднасці"), + ("Email verification code must be 6 characters.", "Код пацвярджэння па электроннай пошце павінен складацца з 6 сімвалаў."), + ("2FA code must be 6 digits.", "Код двухфактарнай праверкі сапраўднасці павінен складацца з 6 лічбаў."), ("Multiple Windows sessions found", "Знойдзена некалькі сеансаў Windows"), - ("Please select the session you want to connect to", "Выберыце сеанс, да якога вы жадаеце падключыцца"), - ("powered_by_me", "На аснове RustDesk"), + ("Please select the session you want to connect to", "Выберыце сеанс, да якога вы хочаце падключыцца"), + ("powered_by_me", "Заснавана на RustDesk"), ("outgoing_only_desk_tip", "Гэта спецыялізаваная версія.\nВы можаце падключацца да іншых прылад, але іншыя прылады не могуць падключацца да вашай."), - ("preset_password_warning", "Гэта спецыялізаваная версія з устаноўленым загадзя паролем. Любы, хто ведае гэты пароль, можа атрымаць поўны кантроль над вашай прыладай. Калі гэта для вас нечакана, адразу выдаліце гэта праграмнае забеспячэнне."), + ("preset_password_warning", "Гэта спецыялізаваная версія з прадвызначаным паролем. Любы, хто ведае гэты пароль, можа атрымаць поўны кантроль над вашай прыладай. Калі гэта для вас нечакана, адразу выдаліце гэта праграмнае забеспячэнне."), ("Security Alert", "Папярэджанне аб бяспецы"), ("My address book", "Мая адрасная кніга"), - ("Personal", "Асабісты"), + ("Personal", "Асабістая"), ("Owner", "Уладальнік"), - ("Set shared password", "Устанавіць агульны пароль"), + ("Set shared password", "Задаць агульны пароль"), ("Exist in", "Існуе ў"), ("Read-only", "Толькі для чытання"), ("Read/Write", "Чытанне і запіс"), - ("Full Control", "Поўны кантроль"), + ("Full Control", "Поўны доступ"), ("share_warning_tip", "Палі вышэй з'яўляюцца агульнымі і бачнымі іншым."), ("Everyone", "Усе"), ("ab_web_console_tip", "Больш у вэб-кансолі"), - ("allow-only-conn-window-open-tip", "Дазволіць толькі падключэнне пры адкрытым акне RustDesk"), - ("no_need_privacy_mode_no_physical_displays_tip", "Фізічныя дысплеі адсутнічаюць, няма патрэбы выкарыстоўваць рэжым канфідэнцыяльнасці."), - ("Follow remote cursor", "Сачыць за аддаленага курсарам"), - ("Follow remote window focus", "Сачыць за фокусам аддаленага акна"), - ("default_proxy_tip", "Пратакол і порт па змаўчанні: Socks5 і 1080"), + ("allow-only-conn-window-open-tip", "Дазволіць падключэнне толькі пры адкрытым акне RustDesk"), + ("no_need_privacy_mode_no_physical_displays_tip", "Фізічныя дысплэі адсутнічаюць, няма патрэбы выкарыстоўваць рэжым канфідэнцыйнасці."), + ("Follow remote cursor", "Прытрымлівацца аддаленага курсора"), + ("Follow remote window focus", "Прытрымлівацца фокуса аддаленага акна"), + ("default_proxy_tip", "Стандартныя пратакол і порт: Socks5 і 1080"), ("no_audio_input_device_tip", "Прылада ўваходнага аудыё не знойдзена."), ("Incoming", "Уваходныя"), ("Outgoing", "Выходныя"), - ("Clear Wayland screen selection", "Адмяніць выбар экрана Wayland"), - ("clear_Wayland_screen_selection_tip", "Пасля адмены можна зноў выбраць экран для дэманстрацыі."), - ("confirm_clear_Wayland_screen_selection_tip", "Адмяніць выбар экрана Wayland?"), - ("android_new_voice_call_tip", "Атрыман новы запыт на галасавы выклік. Калі вы прымеце яго, гук пераключыцца на галасавае злучэнне."), - ("texture_render_tip", "Выкарыстоўваць візуалізацыю тэкстураў для павышэння каб плаўнасці выявы."), - ("Use texture rendering", "Візуалізацыя тэкстураў"), - ("Floating window", "Плавучае акно"), + ("Clear Wayland screen selection", "Скасаваць выбар экрана Wayland"), + ("clear_Wayland_screen_selection_tip", "Пасля скасавання можна зноў выбраць экран для дэманстрацыі."), + ("confirm_clear_Wayland_screen_selection_tip", "Скасаваць выбар экрана Wayland?"), + ("android_new_voice_call_tip", "Прыйшоў новы запыт на галасавы выклік. Калі вы прымеце яго, гук пераключыцца на галасавае падключэнне."), + ("texture_render_tip", "Выкарыстоўваць візуалізацыю тэкстур, каб зрабіць відарысы больш плаўнымі."), + ("Use texture rendering", "Візуалізацыя тэкстур"), + ("Floating window", "Нефіксаванае акно"), ("floating_window_tip", "Дапамагае падтрымліваць фонавую службу RustDesk"), ("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", ""), - ("Requires at least {} characters", ""), - ("Wrong PIN", ""), - ("Set 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", ""), + ("Back", "Назад"), + ("Apps", "Праграмы"), + ("Volume up", "Гучнасць+"), + ("Volume down", "Гучнасць-"), + ("Power", "Сілкаванне"), + ("Telegram bot", "Telegram-бот"), + ("enable-bot-tip", "Калі ўключана, можна атрымліваць код двухфактарнай праверкі сапраўднасці ад бота. Таксама ён можа выконваць функцыю апавяшчэння пра падключэнне."), + ("enable-bot-desc", "1) Адкрыйце чат з @BotFather.\n2) Адпраўце каманду \"/newbot\". Пасля выканання гэтага кроку вы атрымаеце токен.\n3) Пачніце чат з вашым толькі што створаным ботам. Адпраўце паведамленне, якое пачынаецца з касой рысы (\"/\"), напрыклад, \"/hello\", каб яго актываваць.\n"), + ("cancel-2fa-confirm-tip", "Адключыць двухфактарную праверку сапраўднасці?"), + ("cancel-bot-confirm-tip", "Адключыць Telegram-бота"), + ("About RustDesk", "Пра 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", "Дазволіць давераным прыладам прапускаць праверку сапраўднасці 2FA"), + ("Parent directory", "Бацькоўскі каталог"), + ("Resume", "Працягнуць"), + ("Invalid file name", "Памылковая назва файла"), + ("one-way-file-transfer-tip", "На баку абанента ўключана аднабаковая перадача файлаў."), + ("Authentication Required", "Патрабуецца праверка сапраўднасці"), + ("Authenticate", "Прайсці праверку"), + ("web_id_input_tip", "Можна ўвесці ID на тым самым серверы, прамы доступ па IP у вэб-кліенце не падтрымліваецца.\nКалі вы хочаце атрымаць доступ да прылады на іншым серверы, дадайце адрас сервера (@<адрас_сервера>?key=<ключ>), напрыклад,\n9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=.\nКалі вы хочаце атрымаць доступ да прылады на публічным серверы, увядзіце \"@public\", для публічнага сервера ключ не патрэбны."), + ("Download", "Спампаваць"), + ("Upload folder", "Запампаваць папку"), + ("Upload files", "Запампаваць файлы"), + ("Clipboard is synchronized", "Буфер абмену сінхранізаваны"), + ("Update client clipboard", "Абнавіць буфер абмену кліента"), + ("Untagged", "Без цэтліка"), + ("new-version-of-{}-tip", "Даступна новая версія {}"), + ("Accessible devices", "Даступныя прылады"), + ("upgrade_remote_rustdesk_client_to_{}_tip", "Абнавіце кліент RustDesk да версіі {} або навейшай на баку абанента!"), + ("d3d_render_tip", "Пры ўключэнні візуалізацыі D3D на некаторых прыладах аддалены экран можа быць чорным."), + ("Use D3D rendering", "Выкарыстоўваць візуалізацыю D3D"), + ("Printer", "Прынтар"), + ("printer-os-requirement-tip", "Для работы функцыі выходнай сувязі з прынтарам патрабуецца Windows 10 або навейшай версіі."), + ("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", "WebSocket падтрымлівае толькі падключэнні да рэтранслятара."), + ("Use WebSocket", "Выкарыстоўваць WebSocket"), + ("Trackpad speed", "Хуткасць трэкпада"), + ("Default trackpad speed", "Стандартная хуткасць трэкпада"), + ("Numeric one-time password", "Лічбавы аднаразовы пароль"), + ("Enable IPv6 P2P connection", "Выкарыстоўваць падключэнне IPv6 P2P"), + ("Enable UDP hole punching", "Выкарыстоўваць UDP hole punching"), + ("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", "Стандартна RustDesk правярае сертыфікат сервера на наяўнасць пратаколаў, якія выкарыстоўваюць TLS.\nКалі гэта функцыя ўключана, RustDesk прапусціць дадзены этап і працягне працу ў выпадку няўдалай праверкі."), + ("Disable UDP", "Выключыць UDP"), + ("disable-udp-tip", "Вызначае, ці варта выкарыстоўваць толькі TCP.\nКалі ўключана, RustDesk не будзе выкарыстоўваць UDP 21116, замест чаго будзе выкарыстоўвацца TCP 21116."), + ("server-oss-not-support-tip", "ЗАЎВАГА! у OSS-серверы RustDesk гэта функцыя адсутнічае."), + ("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 {}", "Працягнуць з {}"), + ("Display Name", "Імя для адлюстравання"), + ("password-hidden-tip", "Зададзены пастаянны пароль (скрыты)."), + ("preset-password-in-use-tip", "Пададзены пароль цяпер выкарыстоўваецца"), + ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/bg.rs b/src/lang/bg.rs index 4f0131cc8..0aa61b1eb 100644 --- a/src/lang/bg.rs +++ b/src/lang/bg.rs @@ -1,7 +1,7 @@ lazy_static::lazy_static! { pub static ref T: std::collections::HashMap<&'static str, &'static str> = [ - ("Status", "Положение"), + ("Status", "Състояне"), ("Your Desktop", "Вашата работна среда"), ("desk_tip", "Вашата работна среда не може да бъде достъпена с този потребителски код и парола."), ("Password", "Парола"), @@ -20,53 +20,53 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Address book", "Адресник"), ("Confirmation", "Потвърждение"), ("TCP tunneling", "TCP тунел"), - ("Remove", "Премахване"), - ("Refresh random password", "Опресняване на произволна парола"), + ("Remove", "Премахни"), + ("Refresh random password", "Нова случйна парола"), ("Set your own password", "Задайте собствена парола"), ("Enable keyboard/mouse", "Позволяване на клавиатура/мишка"), ("Enable clipboard", "Позволяване достъп до клипборда"), - ("Enable file transfer", "Позволяване прехвърляне на файлове"), + ("Enable file transfer", "Позволяване на прехвърляне на файлове"), ("Enable TCP tunneling", "Позволяване на TCP тунели"), - ("IP Whitelisting", "Определяне на позволени IP по списък"), + ("IP Whitelisting", "Позволени IP"), ("ID/Relay Server", "ID/Препредаващ сървър"), - ("Import server config", "Внасяне сървър настройки за "), - ("Export Server Config", "Изнасяне настройки на сървър"), - ("Import server configuration successfully", "Успешно внасяне на сървърни настройки"), - ("Export server configuration successfully", "Успешно изнасяне на сървърни настройки"), - ("Invalid server configuration", "Недопустими сървърни настройки"), + ("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)"), + ("Stop service", "Спиране на услуга"), + ("Change ID", "Промяна идентификатор (ID)"), + ("Your new ID", "Вашият нов идентификатор (ID)"), ("length %min% to %max%", "дължина %min% до %max%"), ("starts with a letter", "започва с буква"), ("allowed characters", "разрешени знаци"), - ("id_change_tip", "Само a-z, A-Z, 0-9 и _ (долна черта) са сред позволени. Първа буква следва да е a-z, A-Z. С дължина мержу 6 и 16."), + ("id_change_tip", "Само a-z, A-Z, 0-9, - (тире) и _ (долна черта) са сред позволени. Първата буква следва да е a-z, A-Z. С дължина мержу 6 и 16."), ("Website", "Уебсайт"), - ("About", "Относно"), + ("About", "За програмата"), ("Slogan_tip", "Направено от сърце в този хаотичен свят!"), ("Privacy Statement", "Декларация за поверителност"), ("Mute", "Без звук"), - ("Build Date", "Дата на изграждане"), + ("Build Date", "Дата на създаване"), ("Version", "Версия"), ("Home", "Начало"), ("Audio Input", "Аудио вход"), ("Enhancements", "Подобрения"), ("Hardware Codec", "Хардуерен кодек"), - ("Adaptive bitrate", "Приспособяваще се скорост на предаване наданни"), + ("Adaptive bitrate", "Адаптивна скорост на предаване"), ("ID Server", "ID сървър"), ("Relay Server", "Препращащ сървър"), ("API Server", "API сървър"), ("invalid_http", "трябва да започва с http:// или https://"), - ("Invalid IP", "Недопустим IP"), - ("Invalid format", "Недопустим формат"), + ("Invalid IP", "Невалиден IP"), + ("Invalid format", "Невалиден формат"), ("server_not_support", "Все още не се поддържа от сървъра"), ("Not available", "Не е наличен"), ("Too frequent", "Твърде често"), - ("Cancel", "Отказ"), - ("Skip", "Пропускане"), - ("Close", "Затваряне"), - ("Retry", "Преповтори"), + ("Cancel", "Откажи"), + ("Skip", "Пропусни"), + ("Close", "Затвори"), + ("Retry", "Повтори"), ("OK", "Добре"), ("Password Required", "Изисква се парола"), ("Please enter your password", "Моля въведете парола"), @@ -77,10 +77,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Error", "Грешка"), ("Reset by the peer", "Нулирано от партньора"), ("Connecting...", "Свързване..."), - ("Connection in progress. Please wait.", "Връзката се извършва. Моля Изчакайте."), + ("Connection in progress. Please wait.", "Свързването се осъществява. Моля Изчакайте."), ("Please try 1 minute later", "Моля, опитайте 1 минута по-късно"), ("Login Error", "Грешка при вписване"), - ("Successful", "Успешен опит"), + ("Successful", "Успешно"), ("Connected, waiting for image...", "Свързано, чака се изображение..."), ("Name", "Име"), ("Type", "Тип"), @@ -88,7 +88,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Size", "Размер"), ("Show Hidden Files", "Показване на скрити файлове"), ("Receive", "Получаване"), - ("Send", "Пращане"), + ("Send", "Изпращане"), ("Refresh File", "Опресняване на файла"), ("Local", "Локално"), ("Remote", "Отдалечено"), @@ -105,7 +105,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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", "Разреши така всички конфликти"), + ("Do this for all conflicts", "Същото за всички конфликти"), ("This is irreversible!", "Това е необратимо!"), ("Deleting", "Изтриване"), ("files", "файлове"), @@ -114,54 +114,52 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Speed", "Скорост"), ("Custom Image Quality", "Качество на изображението по свой избор"), ("Privacy mode", "Режим на поверителност"), - ("Block user input", "Забрана за потребителски вход"), - ("Unblock user input", "Разрешаване на потребителски въвеждане"), + ("Block user input", "Забрана за потребителско въвеждане"), + ("Unblock user input", "Разрешаване на потребителско въвеждане"), ("Adjust Window", "Нагласи прозореца"), ("Original", "Оригинално"), ("Shrink", "Свиване"), - ("Stretch", "Разтегнат"), + ("Stretch", "Разтягане"), ("Scrollbar", "Плъзгач"), - ("ScrollAuto", "Автоматичено приплъзване"), + ("ScrollAuto", "Автоматично скролиране"), ("Good image quality", "Добро качество на изображението"), ("Balanced", "Уравновесен"), - ("Optimize reaction time", "С оглед времето на реакция"), - ("Custom", "По собствено желание"), + ("Optimize reaction time", "Оптимизирай времето за реакция"), + ("Custom", "По избор"), ("Show remote cursor", "Показвай отдалечения курсор"), ("Show quality monitor", "Показвай прозорец за качество"), - ("Disable clipboard", "Забрана за достъп до клипборд"), + ("Disable clipboard", "Забрана на клипборда"), ("Lock after session end", "Заключване след край на ползване"), - ("Insert Ctrl + Alt + Del", "Поставяне Ctrl + Alt + Del"), - ("Insert Lock", "Заявка за заключване"), + ("Insert Ctrl + Alt + Del", "Въведи Ctrl + Alt + Del"), + ("Insert Lock", "Въведи заключване"), ("Refresh", "Обновяване"), - ("ID does not exist", "Несъществуващ определител (ID)"), + ("ID does not exist", "Несъществуващ идентификатор (ID)"), ("Failed to connect to rendezvous server", "Неуспешно свързване към сървъра за среща (rendezvous)"), ("Please try later", "Моля опитайте по-късно"), ("Remote desktop is offline", "Отдалечената работна среда не е налична"), - ("Key mismatch", "Ключово несъответствие"), - ("Timeout", "Изтичане на времето"), - ("Failed to connect to relay server", "Провал при свързване към препредаващ сървър"), - ("Failed to connect via rendezvous server", "Провал при свързване към сървър за срещи (rendezvous)"), - ("Failed to connect via relay server", "Провал при свързване чрез препредаващ сървър"), - ("Failed to make direct connection to remote desktop", "Провал при установяване на пряка връзка с отдалечена работна среда"), + ("Key mismatch", "Несъответствие на ключове"), + ("Timeout", "Таймаут"), + ("Failed to connect to relay server", "Неуспешно свързване към препредаващ сървър"), + ("Failed to connect via rendezvous server", "Неуспешно свързване към сървър за срещи (rendezvous)"), + ("Failed to connect via relay server", "Неуспешно свързване чрез препредаващ сървър"), + ("Failed to make direct connection to remote desktop", "Неуспешно установяване на пряка връзка с отдалечена работна среда"), ("Set Password", "Задаване на парола"), ("OS Password", "Парола на Операционната система"), ("install_tip", "Поради UAC, RustDesk в някои случай не може да работи правилно за отдалечена достъп. За да заобиколите UAC, моля, натиснете копчето по-долу, за да поставите RustDesk като системна услуга."), ("Click to upgrade", "Натиснете, за да надстроите"), - ("Click to download", "Натиснете, за да изтеглите"), - ("Click to update", "Натиснете, за да обновите"), ("Configure", "Настройване"), ("config_acc", "За да управлявате вашия работна среда отдалечено, трябва да предоставите на RustDesk права от раздел \"Достъпност\"."), ("config_screen", "За да управлявате вашия работна среда отдалечено, трябва да предоставите на RustDesk права от раздел \"Запис на екрана\"."), - ("Installing ...", "Поставяне..."), - ("Install", "Постави"), - ("Installation", "Поставяне"), - ("Installation Path", "Път към място за поставяне"), - ("Create start menu shortcuts", "Бърз достъп от меню 'Старт'."), - ("Create desktop icon", "Създайте икона на работния плот"), - ("agreement_tip", "Започвайки поставянето, вие приемате лицензионното споразумение."), - ("Accept and Install", "Приемете и поставяте"), + ("Installing ...", "Инсталиране..."), + ("Install", "Инсталирай"), + ("Installation", "Инсталация"), + ("Installation Path", "Път за инсталация"), + ("Create start menu shortcuts", "Създай връзка от меню 'Старт'."), + ("Create desktop icon", "Създай иконка на работния плот"), + ("agreement_tip", "Започвайки инсталацията, вие приемате лицензионното споразумение."), + ("Accept and Install", "Приемам и инсталирам"), ("End-user license agreement", "Споразумение с потребителя"), - ("Generating ...", "Пораждане..."), + ("Generating ...", "Създаване..."), ("Your installation is lower version.", "Вашата инсталация е по-ниска версия."), ("not_close_tcp_tip", "Не затваряйте този прозорец, докато използвате тунела"), ("Listening ...", "Слушане..."), @@ -177,9 +175,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("The confirmation is not identical.", "Потвърждението не съвпада"), ("Permissions", "Разрешения"), ("Accept", "Приеми"), - ("Dismiss", "Отхвърляне"), - ("Disconnect", "Прекъсване"), - ("Enable file copy and paste", "Разрешаване прехвърляне на файлове"), + ("Dismiss", "Отхвърли"), + ("Disconnect", "Прекъсни"), + ("Enable file copy and paste", "Разрешаване копирането и поставяне на файлове"), ("Connected", "Свързан"), ("Direct and encrypted connection", "Пряка защитена връзка"), ("Relayed and encrypted connection", "Препредадена защитена връзка"), @@ -193,55 +191,55 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Enable direct IP access", "Разрешаване пряк IP достъп"), ("Rename", "Преименуване"), ("Space", "Пространство"), - ("Create desktop shortcut", "Създайте пряк път на работния плот"), + ("Create desktop shortcut", "Създайте връзка на работния плот"), ("Change Path", "Промяна на пътя"), ("Create Folder", "Създай папка"), ("Please enter the folder name", "Моля, въведете име на папката"), ("Fix it", "Оправи го"), ("Warning", "Внимание"), - ("Login screen using Wayland is not supported", "Екран за влизане чрез Wayland не се поддържа"), + ("Login screen using Wayland is not supported", "Екранът за влизане чрез Wayland не се поддържа"), ("Reboot required", "Нужно е презареждане на ОС"), ("Unsupported display server", "Неподдържан екранен сървър"), ("x11 expected", "Очаква се x11"), ("Port", "Порт"), ("Settings", "Настройки"), ("Username", "Потребителско име"), - ("Invalid port", "Недопустим порт"), + ("Invalid port", "Невалиден порт"), ("Closed manually by the peer", "Затворено ръчно от другата страна"), ("Enable remote configuration modification", "Разрешаване на отдалечена промяна на конфигурацията"), ("Run without install", "Стартирайте без инсталиране"), ("Connect via relay", "Свързване чрез препращане"), ("Always connect via relay", "Винаги чрез препращане"), ("whitelist_tip", "Само IP адресите от белия списък имат достъп до мен"), - ("Login", "Влизане"), + ("Login", "Вписване"), ("Verify", "Потвърди"), ("Remember me", "Запомни ме"), ("Trust this device", "Доверяване на това устройство"), ("Verification code", "Код за потвърждение"), - ("verification_tip", "На посочения имейл е изпратен код за потвърждение. Моля въведете го, за да продължите с влизането."), + ("verification_tip", "На посочения имейл е изпратен код за потвърждение. Моля въведете го, за да продължите с вписването."), ("Logout", "Отписване (Изход)"), - ("Tags", "Белези"), + ("Tags", "Етикети"), ("Search ID", "Търси ID"), ("whitelist_sep", "Разделени със запетая, точка и запетая, празни символи или нов ред"), ("Add ID", "Добави ID"), ("Add Tag", "Добави етикет"), - ("Unselect all tags", "Премахнете избора на всички белези (tags)"), + ("Unselect all tags", "Премахнете избора на всички етикети (tags)"), ("Network error", "Мрежова грешка"), - ("Username missed", "Липсващо потребителско име"), - ("Password missed", "Липсваща парола"), + ("Username missed", "Липсва потребителско име"), + ("Password missed", "Липсва парола"), ("Wrong credentials", "Грешни пълномощия"), - ("The verification code is incorrect or has expired", "Кодът за проверка е неправилен или с изтекла давност."), - ("Edit Tag", "Промени белег"), + ("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", "Socks5 посредник"), - ("Socks5/Http(s) Proxy", "Socks5/Http(s) посредник"), + ("Invalid folder name", "Невалидно име на папка"), + ("Socks5 Proxy", "Socks5 Прокси"), + ("Socks5/Http(s) Proxy", "Socks5/Http(s) прокси"), ("Discovered", "Открит"), - ("install_daemon_tip", "За зареждане при стартиране на ОС следва да поставите RustDesk като системна услуга."), + ("install_daemon_tip", "За зареждане при стартиране на ОС трябва да инсталирате RustDesk като системна услуга."), ("Remote ID", "Отдалечено ID"), ("Paste", "Постави"), ("Paste here?", "Постави тук?"), @@ -267,28 +265,26 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("No permission of file transfer", "Няма разрешение за прехвърляне на файлове"), ("Note", "Бележка"), ("Connection", "Връзка"), - ("Share Screen", "Сподели екран"), - ("Chat", "Говор"), + ("Share screen", "Сподели екран"), + ("Chat", "Чат"), ("Total", "Общо"), ("items", "неща"), ("Selected", "Избрано"), - ("Screen Capture", "Снемане на екрана"), - ("Input Control", "Управление на вход"), + ("Screen Capture", "Заснемане на екрана"), + ("Input Control", "Управление на въвеждане"), ("Audio Capture", "Аудиозапис"), - ("File Connection", "Файлова връзка"), - ("Screen Connection", "Екранна връзка"), ("Do you accept?", "Приемате ли?"), ("Open System Setting", "Отворете системните настройки"), - ("How to get Android input permission?", "Как да получим право за въвеждане под Андрид?"), + ("How to get Android input permission?", "Как да получим право за въвеждане при Андроид?"), ("android_input_permission_tip1", "За да може отдалечено устройство да управлява вашето Android устройство чрез мишка или допир, трябва да разрешите на RustDesk да използва услугата \"Достъпност\"."), - ("android_input_permission_tip2", "Моля, отидете на следващата страница с системни настройки, намерете и въведете [Installed Services], включете услугата [RustDesk Input]."), + ("android_input_permission_tip2", "Моля, отидете на следващата страница със системни настройки, намерете и въведете [Installed Services], включете услугата [RustDesk Input]."), ("android_new_connection_tip", "Получена е нова заявка за отдалечено управление на вашето текущо устройство."), - ("android_service_will_start_tip", "Включването на \"Снемане на екрана\" автоматично ще стартира услугата, позволявайки на други устройства да поискат връзка с вашето устройство."), + ("android_service_will_start_tip", "Включването на \"Заснемане на екрана\" автоматично ще стартира услугата, позволявайки на други устройства да поискат връзка с вашето устройство."), ("android_stop_service_tip", "Затварянето на услугата автоматично ще затвори всички установени връзки."), ("android_version_audio_tip", "Текущата версия на Android не поддържа аудиозапис. Моля, актуализирайте устройството с Android 10 или по-нов."), ("android_start_service_tip", "Докоснете [Start service] или позволете [Screen Capture], за да започне услугата по споделяне на екрана."), ("android_permission_may_not_change_tip", "Разрешенията за установени връзки може да не се променят незабавно, а ще изискват да се свържете отново."), - ("Account", "Сметка"), + ("Account", "Профил"), ("Overwrite", "Презаписване"), ("This file exists, skip or overwrite this file?", "Този файл съществува вече. Пропускане или презаписване?"), ("Quit", "Изход"), @@ -298,12 +294,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Someone turns on privacy mode, exit", "Някой включва режим на поверителност, изход"), ("Unsupported", "Неподдържан"), ("Peer denied", "Отказ от другата страна"), - ("Please install plugins", "Моля поставете приставки"), + ("Please install plugins", "Моля поставете плъгини"), ("Peer exit", "Изход от другата страна"), - ("Failed to turn off", "Провал при опит за изключване"), + ("Failed to turn off", "Неуспешен опит за изключване"), ("Turned off", "Изкключен"), ("Language", "Език"), - ("Keep RustDesk background service", "Запази работеща фонова услуга с RustDesk"), + ("Keep RustDesk background service", "Запази RustDesk фоновата услуга"), ("Ignore Battery Optimizations", "Игнорирай оптимизациите на батерията"), ("android_open_battery_optimizations_tip", "Ако искате да деактивирате тази функция, моля, отидете на следващата страница с настройки на приложението RustDesk, намерете и въведете [Battery], премахнете отметката от [Unrestricted]"), ("Start on boot", "Стартирайте при зареждане"), @@ -311,7 +307,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Connection not allowed", "Връзката непозволена"), ("Legacy mode", "По остарял начин"), ("Map mode", "По начин със съответствие (map)"), - ("Translate mode", "По нчаин с превод"), + ("Translate mode", "По начин с превод"), ("Use permanent password", "Използване на постоянна парола"), ("Use both passwords", "Използване и на двете пароли"), ("Set permanent password", "Задаване постоянна парола"), @@ -346,9 +342,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Dark", "Тъмна"), ("Light", "Светла"), ("Follow System", "Следвай система"), - ("Enable hardware codec", "Позволяване хардуерен кодек"), + ("Enable hardware codec", "Позволяване на хардуерен кодек"), ("Unlock Security Settings", "Отключи настройките за сигурност"), - ("Enable audio", "Разрешете аудиото"), + ("Enable audio", "Позволи звук"), ("Unlock Network Settings", "Отключи мрежовите настройки"), ("Server", "Сървър"), ("Direct IP Access", "Пряк IP достъп"), @@ -364,11 +360,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Recording", "Записване"), ("Directory", "Директория"), ("Automatically record incoming sessions", "Автоматичен запис на входящи сесии"), - ("Automatically record outgoing sessions", ""), - ("Change", "Промяна"), - ("Start session recording", "Започванена запис"), - ("Stop session recording", "Край на запис"), - ("Enable recording session", "Позволяване запис"), + ("Automatically record outgoing sessions", "Автоматичен запис на изходящи сесии"), + ("Change", "Промени"), + ("Start session recording", "Старт на запис на сесията"), + ("Stop session recording", "Стоип на запис на сесията"), + ("Enable recording session", "Позволяване на записване на сесията"), ("Enable LAN discovery", "Позволяване откриване във вътрешна мрежа"), ("Deny LAN discovery", "Забрана за откриване във вътрешна мрежа"), ("Write a message", "Напишете съобщение"), @@ -381,25 +377,25 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Keyboard Settings", "Настройки на клавиатурата"), ("Full Access", "Пълен достъп"), ("Screen Share", "Споделяне на екрана"), - ("Wayland requires Ubuntu 21.04 or higher version.", "Wayland изисква Ubuntu 21.04 или по-нов"), - ("Wayland requires higher version of linux distro. Please try X11 desktop or change your OS.", "Wayland изисква по-нов Linux. Моля, опитайте с X11 или сменете операционната система."), + ("ubuntu-21-04-required", "Wayland изисква Ubuntu 21.04 или по-нов"), + ("wayland-requires-higher-linux-version", "Wayland изисква по-нов Linux. Моля, опитайте с X11 или сменете операционната система."), + ("xdp-portal-unavailable", ""), ("JumpLink", "Препратка"), ("Please Select the screen to be shared(Operate on the peer side).", "Моля, изберете екрана, който да бъде споделен (спрямо отдалечената страна)."), ("Show RustDesk", "Покажи RustDesk"), ("This PC", "Този компютър"), ("or", "или"), - ("Continue with", "Продължи с"), ("Elevate", "Повишаване"), ("Zoom cursor", "Уголемяване курсор"), ("Accept sessions via password", "Приемане сесии чрез парола"), - ("Accept sessions via click", "Приемане сесии чрез цъкване"), + ("Accept sessions via click", "Приемане сесии чрез клик"), ("Accept sessions via both", "Приемане сесии и по двата начина"), - ("Please wait for the remote side to accept your session request...", "Моля, изчакайте докато другата страна приеме заявката за отдалечен достъп..."), + ("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 connection management window", "Скриване на прозореца за управление на връзка"), ("hide_cm_tip", "Разрешаване скриване само ако се приемат сесии чрез постоянна парола"), ("wayland_experiment_tip", "Поддръжката на Wayland е в експериментален стадий, моля, използвайте X11, ако се нуждаете от безконтролен достъп.."), ("Right click to select tabs", "Десен бутон за избор на раздел"), @@ -408,22 +404,22 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Group", "Група"), ("Search", "Търсене"), ("Closed manually by web console", "Затворен ръчно от уеб конзола"), - ("Local keyboard type", "Тип на тукашната клавиатура"), - ("Select local keyboard type", "Избор на тип на тукашната клавиатура"), + ("Local keyboard type", "Тип на локалната клавиатура"), + ("Select local keyboard type", "Избор на тип на локалната клавиатура"), ("software_render_tip", "Ако използвате графична карта Nvidia под Linux и отдалеченият прозорец се затваря веднага след свързване, превключването към драйвера Nouveau с отворен код и изборът да използвате софтуерно изобразяване може да помогне. Изисква се рестартиране на софтуера."), ("Always use software rendering", "Винаги ползвай софтуерно изграждане на картината"), ("config_input", "За да управлявате отдалечена среда с клавиатура, трябва да предоставите на RustDesk право за \"Input Monitoring\"."), ("config_microphone", "За да говорите отдалечено, трябва да предоставите на RustDesk право за \"Запис на звук\"."), ("request_elevation_tip", "Можете също така да поискате разширени права, ако има някой от отдалечената страна."), ("Wait", "Изчакване"), - ("Elevation Error", "Грешка при добвиане на разширени права"), + ("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", "Все още изисква отдалеченият потребител да щракне върху OK в прозореца на UAC при стартиран RustDesk."), - ("Request Elevation", "Поискайте разширени права"), + ("Transmit the username and password of administrator", "Предаване на потребителското име и паролата на администратор"), + ("still_click_uac_tip", "Все още изисква отдалеченият потребител да натисне върху OK в прозореца на UAC при стартиран RustDesk."), + ("Request Elevation", "Поискайте повишени права"), ("wait_accept_uac_tip", "Моля, изчакайте отдалеченият потребител да приеме диалоговия прозорец на UAC."), - ("Elevate successfully", "Успешно получаване на разширени права"), + ("Elevate successfully", "Успешно получаване на повишени права"), ("uppercase", "големи букви"), ("lowercase", "малки букви"), ("digit", "цифра"), @@ -433,7 +429,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Medium", "Средна"), ("Strong", "Силна"), ("Switch Sides", "Размяна на страните"), - ("Please confirm if you want to share your desktop?", "Моля, потвърдете дали искате да споделите работното си пространство"), + ("Please confirm if you want to share your desktop?", "Моля, потвърдете ако искате да споделите работното си пространство"), ("Display", "Екран"), ("Default View Style", "Стил на изглед по подразбиране"), ("Default Scroll Style", "Стил на превъртане по подразбиране"), @@ -444,44 +440,43 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Auto", "Автоматично"), ("Other Default Options", "Други опции по подразбиране"), ("Voice call", "Гласови обаждания"), - ("Text chat", "Текстов разговор"), - ("Stop voice call", "Прекратяване гласово обаждане"), - ("relay_hint_tip", "Може да не е възможно да се свържете директно; можете да опитате да се свържете чрез реле. Освен това, ако искате да използвате реле при първия си опит, добавете наставка \"/r\" към идентификатора или да изберете опцията \"Винаги свързване чрез реле\" в картата на последните сесии, ако съществува."), + ("Text chat", "Текстов чат"), + ("Stop voice call", "Прекратяване на гласово обаждане"), + ("relay_hint_tip", "Може да не е възможно да се свържете директно; можете да опитате да се свържете чрез препращаш сървър. Освен това, ако искате да използвате препращаш сървър при първия си опит, добавете наставка \"/r\" към идентификатора или да изберете опцията \"Винаги свързване чрез препращаш сървър\" в картата на последните сесии, ако съществува."), ("Reconnect", "Повторно свързане"), ("Codec", "Кодек"), ("Resolution", "Разделителна способност"), ("No transfers in progress", "Няма текущи прехвърляния"), - ("Set one-time password length", "Задаване дължаина на еднократна парола"), + ("Set one-time password length", "Задаване дължина на еднократна парола"), ("RDP Settings", "RDP настройки"), - ("Sort by", "Подредба по"), - ("New Connection", "Ново свързване"), - ("Restore", "Възстановяване"), - ("Minimize", "Смаляване"), - ("Maximize", "Уголемяване"), + ("Sort by", "Сортирай по"), + ("New Connection", "Нова Връзка"), + ("Restore", "Възстанови"), + ("Minimize", "Минимизирай"), + ("Maximize", "На цял екран"), ("Your Device", "Вашето устройство"), ("empty_recent_tip", "Ами сега, няма скорошни сесии!\nВреме е да планирате нова."), - ("empty_favorite_tip", "Все още нямате любими връстници?\nНека намерим някой, с когото да се свържете, и да го добавим към вашите любими!"), - ("empty_lan_tip", "О, не, изглежда, че все още не сме открили връстници."), - ("empty_address_book_tip", "Изглежда, че в момента няма изброени връстници във вашата адресна книга."), - ("eg: admin", "напр. admin"), + ("empty_favorite_tip", "Все още нямате любими връзки?\nНека намерим някой, с когото да се свържете, и да го добавим към вашите любими!"), + ("empty_lan_tip", "О, не, изглежда, че все още не сме открили връзки."), + ("empty_address_book_tip", "Изглежда, че в момента няма изброени връзки във вашата адресна книга."), ("Empty Username", "Празно потребителско име"), ("Empty Password", "Празна парола"), - ("Me", "Мен"), + ("Me", "Аз"), ("identical_file_tip", "Файлът съвпада с този от другата страна."), ("show_monitors_tip", "Показване на мониторите в лентата с инструменти"), - ("View Mode", "Режим на преглед"), + ("View Mode", "Режим на изглед"), ("login_linux_tip", "Трябва да влезете в отдалечен Linux акаунт, за да активирате X сесия на работния плот"), ("verify_rustdesk_password_tip", "Проверете RustDesk паролата"), ("remember_account_tip", "Запомнете този акаунт"), - ("os_account_desk_tip", "Този акаунт се използва за влизане в отдалечената операционна система и позволява на десктоп сесията без глава"), - ("OS Account", "Операционната система акаунт"), + ("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", ""), + ("No need to elevate", "Няма нужда за повишаване на права"), ("System Sound", "Системен звук"), ("Default", "По подразбиране"), ("New RDP", "Нов RDP"), @@ -490,7 +485,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("no fingerprints", "Няма пръстови отпечатъци"), ("Select a peer", "Избери отдалечена страна"), ("Select peers", "Избери отдалечени страни"), - ("Plugins", "Приставки"), + ("Plugins", "Плъгини"), ("Uninstall", "Премахни"), ("Update", "Обновяване"), ("Enable", "Позволяване"), @@ -513,17 +508,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Stop", "Спиране"), ("exceed_max_devices", "Достигнахте максималния брой управлявани устройства."), ("Sync with recent sessions", "Синхронизиране с последните сесии"), - ("Sort tags", "Подреди белези"), - ("Open connection in new tab", "Разкриване на връзка в нов раздел"), - ("Move tab to new window", "Отделяне на раздела в нов прозорец"), + ("Sort tags", "Подреди етикети"), + ("Open connection in new tab", "Отваряне на връзката в нов раздел"), + ("Move tab to new window", "Превместване на раздела в нов прозорец"), ("Can not be empty", "Не може да е празно"), ("Already exists", "Вече съществува"), ("Change Password", "Промяна на парола"), ("Refresh Password", "Обновяване парола"), - ("ID", "Определител (ID)"), - ("Grid View", "Мрежов изглед"), + ("ID", "Идентификатор (ID)"), + ("Grid View", "Табличен изглед"), ("List View", "Списъчен изглед"), - ("Select", "Избиране"), + ("Select", "Избор"), ("Toggle Tags", "Превключване на етикети"), ("pull_ab_failed_tip", "Неуспешно опресняване на адресната книга"), ("push_ab_failed_tip", "Неуспешно синхронизиране на адресната книга със сървъра"), @@ -531,20 +526,20 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Change Color", "Промяна на цвета"), ("Primary Color", "Основен цвят"), ("HSV Color", "HSV цвят"), - ("Installation Successful!", "Успешно поставяне!"), - ("Installation failed!", "Провал при поставяне"), + ("Installation Successful!", "Успешно инсталиране!"), + ("Installation failed!", "Неуспешно инсталиране"), ("Reverse mouse wheel", "Обърнато колелото на мишката"), ("{} sessions", "{} сесии"), ("scam_title", "Възможно е да сте ИЗМАМЕНИ!"), ("scam_text1", "Ако разговаряте по телефона с някой, когото НЕ ПОЗНАВАТЕ и НЯМАТЕ ДОВЕРИЕ, който ви е помолил да използвате RustDesk и да стартирате услугата, не продължавайте и затворете незабавно."), ("scam_text2", "Те вероятно са измамник, който се опитва да открадне вашите пари или друга лична информация."), ("Don't show again", "Не показвай отново"), - ("I Agree", "Съгласен"), + ("I Agree", "Съгласен съм"), ("Decline", "Отказвам"), ("Timeout in minutes", "Време за отговор в минути"), ("auto_disconnect_option_tip", "Автоматично затваряне на входящите сесии при неактивност на потребителя"), ("Connection failed due to inactivity", "Автоматично прекъсване на връзката поради неактивност"), - ("Check for software update on startup", ""), + ("Check for software update on startup", "Проверявай за обновления при стартиране"), ("upgrade_rustdesk_server_pro_to_{}_tip", "Моля обновете RustDesk Server Pro на версия {} или по-нова!"), ("pull_group_failed_tip", "Неуспешно опресняване на групата"), ("Filter by intersection", "Отсяване по пресичане"), @@ -554,14 +549,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("No displays", "Няма екрани"), ("Open in new window", "Отваряне в нов прозорец"), ("Show displays as individual windows", "Показване на екраните в отделни прозорци"), - ("Use all my displays for the remote session", "Използване на всички тукашни екрани за отдалечена работа"), + ("Use all my displays for the remote session", "Използвай всички мои екрани за отдалечена връзка"), ("selinux_tip", "SELinux е активиран на вашето устройство, което може да попречи на RustDesk да работи правилно като контролирана страна."), ("Change view", "Промяна изглед"), ("Big tiles", "Големи заглавия"), ("Small tiles", "Малки заглавия"), ("List", "Списък"), ("Virtual display", "Виртуален екран"), - ("Plug out all", "Изтръгване на всички"), + ("Plug out all", "Разкачане на всички"), ("True color (4:4:4)", ""), ("Enable blocking user input", "Разрешаване на блокиране на потребителско въвеждане"), ("id_input_tip", "Можете да въведете ID, директен IP адрес или домейн с порт (:).\nАко искате да получите достъп до устройство на друг сървър, моля, добавете адреса на сървъра (@?key=), например\n9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=.\nАко искате да получите достъп до устройство на обществен сървър, моля, въведете \"@public\" , ключът не е необходим за публичен сървър"), @@ -591,7 +586,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("My address book", "Моята адресна книга"), ("Personal", "Личен"), ("Owner", "Собственик"), - ("Set shared password", "Определяне споделена парола"), + ("Set shared password", "Задай споделена парола"), ("Exist in", "Съществува в"), ("Read-only", "Само четене"), ("Read/Write", "Писане/четене"), @@ -612,25 +607,25 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("confirm_clear_Wayland_screen_selection_tip", ""), ("android_new_voice_call_tip", ""), ("texture_render_tip", ""), - ("Use texture rendering", "Използвай текстово изграждане"), + ("Use texture rendering", "Използвай рендер на текстури"), ("Floating window", "Плаващ прозорец"), ("floating_window_tip", ""), ("Keep screen on", "Запази екранът включен"), ("Never", "Никога"), ("During controlled", "Докато е обект на управление"), ("During service is on", "Докато услугата е включена"), - ("Capture screen using DirectX", "Снемай екрана ползвайки DirectX"), + ("Capture screen using DirectX", "Заснемай екрана ползвайки DirectX"), ("Back", "Назад"), ("Apps", "Приложения"), ("Volume up", "Усилване звук"), - ("Volume down", "Намаляне звук"), + ("Volume down", "Намаляване звук"), ("Power", "Мощност"), ("Telegram bot", "Телеграм бот"), ("enable-bot-tip", ""), ("enable-bot-desc", ""), ("cancel-2fa-confirm-tip", ""), ("cancel-bot-confirm-tip", ""), - ("About RustDesk", "Относно RustDesk"), + ("About RustDesk", "За RustDesk"), ("Send clipboard keystrokes", ""), ("network_error_tip", ""), ("Unlock with PIN", "Отключване с PIN"), @@ -653,5 +648,102 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Upload folder", ""), ("Upload files", ""), ("Clipboard is synchronized", ""), + ("Update client clipboard", ""), + ("Untagged", ""), + ("new-version-of-{}-tip", ""), + ("Accessible devices", ""), + ("upgrade_remote_rustdesk_client_to_{}_tip", "Моля, надстройте клиента RustDesk до версия {} или по-нова от отдалечената страна!"), + ("d3d_render_tip", ""), + ("Use D3D rendering", ""), + ("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", ""), + ("Trackpad speed", ""), + ("Default trackpad speed", ""), + ("Numeric one-time password", ""), + ("Enable IPv6 P2P connection", ""), + ("Enable UDP hole punching", ""), + ("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", ""), + ("allow-insecure-tls-fallback-tip", ""), + ("Disable 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 {}", "Продължи с {}"), + ("Display Name", ""), + ("password-hidden-tip", ""), + ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ca.rs b/src/lang/ca.rs index 8e0ff1479..2f706cc89 100644 --- a/src/lang/ca.rs +++ b/src/lang/ca.rs @@ -41,7 +41,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("length %min% to %max%", "Entre %min% i %max% caràcters"), ("starts with a letter", "Comença amb una lletra"), ("allowed characters", "Caràcters admesos"), - ("id_change_tip", "Els caràcters admesos són: a-z, A-Z, 0-9, _ (guió baix). El primer caràcter ha de ser a-z/A-Z, i una mida de 6 a 16 caràcters."), + ("id_change_tip", "Els caràcters admesos són: a-z, A-Z, 0-9, - (dash), _ (guió baix). El primer caràcter ha de ser a-z/A-Z, i una mida de 6 a 16 caràcters."), ("Website", "Lloc web"), ("About", "Quant al RustDesk"), ("Slogan_tip", "Fet de tot cor dins d'aquest món caòtic!\nTraducció: Benet R. i Camps (BennyBeat)."), @@ -147,8 +147,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("OS Password", "Contrasenya del sistema"), ("install_tip", "En alguns casos és possible que el RustDesk no funcioni correctament per les restriccions UAC («User Account Control»; Control de comptes d'usuari). Per evitar aquest problema, instal·leu el RustDesk al vostre sistema."), ("Click to upgrade", "Feu clic per a actualitzar"), - ("Click to download", "Feu clic per a baixar"), - ("Click to update", "Feu clic per a actualitzar"), ("Configure", "Configura"), ("config_acc", "Per a poder controlar el dispositiu remotament, faciliteu al RustDesk els permisos d'accessibilitat."), ("config_screen", "Per a poder controlar el dispositiu remotament, faciliteu al RustDesk els permisos de gravació de pantalla."), @@ -267,7 +265,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("No permission of file transfer", "Cap permís per a transferència de fitxers"), ("Note", "Nota"), ("Connection", "Connexió"), - ("Share Screen", "Compartició de pantalla"), + ("Share screen", "Compartició de pantalla"), ("Chat", "Xat"), ("Total", "Total"), ("items", "elements"), @@ -275,8 +273,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Screen Capture", "Captura de pantalla"), ("Input Control", "Control d'entrada"), ("Audio Capture", "Captura d'àudio"), - ("File Connection", "Connexió de fitxer"), - ("Screen Connection", "Connexió de pantalla"), ("Do you accept?", "Voleu acceptar?"), ("Open System Setting", "Obre la configuració del sistema"), ("How to get Android input permission?", "Com modificar els permisos a Android?"), @@ -381,14 +377,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Keyboard Settings", "Configuració del teclat"), ("Full Access", "Accés complet"), ("Screen Share", "Compartició de pantalla"), - ("Wayland requires Ubuntu 21.04 or higher version.", "Wayland requereix Ubuntu 21.04 o superior"), - ("Wayland requires higher version of linux distro. Please try X11 desktop or change your OS.", "Wayland requereix una versió superior de sistema Linux per a funcionar. Proveu iniciant un entorn d'escriptori amb x11 o actualitzeu el vostre sistema operatiu."), + ("ubuntu-21-04-required", "Wayland requereix Ubuntu 21.04 o superior"), + ("wayland-requires-higher-linux-version", "Wayland requereix una versió superior de sistema Linux per a funcionar. Proveu iniciant un entorn d'escriptori amb x11 o actualitzeu el vostre sistema operatiu."), + ("xdp-portal-unavailable", ""), ("JumpLink", "Marcador"), ("Please Select the screen to be shared(Operate on the peer side).", "Seleccioneu la pantalla que compartireu (quina serà visible al client)"), ("Show RustDesk", "Mostra el RustDesk"), ("This PC", "Aquest equip"), ("or", "o"), - ("Continue with", "Continua amb"), ("Elevate", "Permisos ampliats"), ("Zoom cursor", "Escala del ratolí"), ("Accept sessions via password", "Accepta les sessions mitjançant una contrasenya"), @@ -463,7 +459,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("empty_favorite_tip", "No heu afegit cap dispositiu aquí!\nPodeu afegir dispositius favorits en qualsevol moment."), ("empty_lan_tip", "No s'ha trobat cap dispositiu proper."), ("empty_address_book_tip", "Sembla que no teniu cap dispositiu a la vostra llista d'adreces."), - ("eg: admin", "p. ex.:admin"), ("Empty Username", "Nom d'usuari buit"), ("Empty Password", "Contrasenya buida"), ("Me", "Vós"), @@ -649,9 +644,106 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Authentication Required", "Autenticació requerida"), ("Authenticate", "Autentica"), ("web_id_input_tip", "Podeu inserir el número ID al propi servidor; l'accés directe per IP no és compatible amb el client web.\nSi voleu accedir a un dispositiu d'un altre servidor, afegiu l'adreça del servidor, com ara @?key= (p. ex.\n9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=.\nSi voleu accedir a un dispositiu en un servidor públic, no cal que inseriu la clau pública «@» per al servidor públic."), - ("Download", ""), - ("Upload folder", ""), - ("Upload files", ""), - ("Clipboard is synchronized", ""), + ("Download", "Descarrega"), + ("Upload folder", "Puja una carpeta"), + ("Upload files", "Puja fitxers"), + ("Clipboard is synchronized", "El porta-retalls està sincronitzat"), + ("Update client clipboard", "Actualitza el porta-retalls del client"), + ("Untagged", "Sense etiquetar"), + ("new-version-of-{}-tip", ""), + ("Accessible devices", "Dispositius accessibles"), + ("upgrade_remote_rustdesk_client_to_{}_tip", ""), + ("d3d_render_tip", ""), + ("Use D3D rendering", "Utilitza renderització D3D"), + ("Printer", "Impressora"), + ("printer-os-requirement-tip", ""), + ("printer-requires-installed-{}-client-tip", ""), + ("printer-{}-not-installed-tip", ""), + ("printer-{}-ready-tip", ""), + ("Install {} Printer", "Instal·la {} impressora"), + ("Outgoing Print Jobs", "Treballs d'impressió sortints"), + ("Incoming Print Jobs", "Treballs d'impressió entrants"), + ("Incoming Print Job", "Treballs d'impressió entrant"), + ("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", "Fes una captura de pantalla"), + ("Taking screenshot", "Fent la captura de pantalla"), + ("screenshot-merged-screen-not-supported-tip", ""), + ("screenshot-action-tip", ""), + ("Save as", "Anomena i desa"), + ("Copy to clipboard", "Copia al porta-retalls"), + ("Enable remote printer", "Habilita l'impressora remota"), + ("Downloading {}", "Descarregant {}"), + ("{} Update", "{} Actualitza"), + ("{}-to-update-tip", ""), + ("download-new-version-failed-tip", ""), + ("Auto update", "Actualització automàtica"), + ("update-failed-check-msi-tip", ""), + ("websocket_tip", ""), + ("Use WebSocket", ""), + ("Trackpad speed", "Velocitat del trackpad"), + ("Default trackpad speed", "Velocitat per defecte del trackpad"), + ("Numeric one-time password", "Contrasenya numèrica d'un sol ús"), + ("Enable IPv6 P2P connection", "Habilita la connexió IPv6 P2P"), + ("Enable UDP hole punching", "Activa la perforació UDP"), + ("View camera", "Mostra la càmera"), + ("Enable camera", "Habilita la càmera"), + ("No cameras", "No hi ha càmeres"), + ("view_camera_unsupported_tip", ""), + ("Terminal", "Terminal"), + ("Enable terminal", "Habilita el terminal"), + ("New tab", "Nova finestra"), + ("Keep terminal sessions on disconnect", "Mantingues les sessions de terminal desconnectades"), + ("Terminal (Run as administrator)", "Terminal (executa com a administrador"), + ("terminal-admin-login-tip", ""), + ("Failed to get user token.", "No s'ha pogut obtenir el token d'usuari."), + ("Incorrect username or password.", "Nom d'usuari o contrasenya incorrecte"), + ("The user is not an administrator.", "Aquest usuari no és administrador"), + ("Failed to check if the user is an administrator.", "No s'ha pogut comprovar si l'usuari és administrador."), + ("Supported only in the installed version.", "Només compatible amb la versió instal·lada."), + ("elevation_username_tip", ""), + ("Preparing for installation ...", "Preparant per a l'instal·lació..."), + ("Show my cursor", "Mostra el meu punter"), + ("Scale custom", "Escala personalitzada"), + ("Custom scale slider", "Control lliscant d'escala personalitzada"), + ("Decrease", "Disminueix"), + ("Increase", "Augmenta"), + ("Show virtual mouse", "Mostra el ratolí virtual"), + ("Virtual mouse size", "Mida del ratolí virtual"), + ("Small", "Petita"), + ("Large", "Gran"), + ("Show virtual joystick", "Mostra el joystick virtual"), + ("Edit note", "Edita la nota"), + ("Alias", "Alias"), + ("ScrollEdge", ""), + ("Allow insecure TLS fallback", ""), + ("allow-insecure-tls-fallback-tip", ""), + ("Disable 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 {}", "Continua amb {}"), + ("Display Name", ""), + ("password-hidden-tip", ""), + ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/cn.rs b/src/lang/cn.rs index 8b1d3a5f9..a90e5e194 100644 --- a/src/lang/cn.rs +++ b/src/lang/cn.rs @@ -41,10 +41,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("length %min% to %max%", "长度在 %min% 与 %max% 之间"), ("starts with a letter", "以字母开头"), ("allowed characters", "使用允许的字符"), - ("id_change_tip", "只可以使用字母 a-z, A-Z, 0-9, _ (下划线)。首字母必须是 a-z, A-Z。长度在 6 与 16 之间。"), + ("id_change_tip", "只可以使用字母 a-z, A-Z, 0-9, - (dash), _ (下划线)。首字母必须是 a-z, A-Z。长度在 6 与 16 之间。"), ("Website", "网站"), ("About", "关于"), - ("Slogan_tip", ""), + ("Slogan_tip", "在这个混乱的世界中,用心制作!"), ("Privacy Statement", "隐私声明"), ("Mute", "静音"), ("Build Date", "构建日期"), @@ -147,8 +147,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("OS Password", "操作系统密码"), ("install_tip", "你正在运行未安装版本,由于 UAC 限制,作为被控端,会在某些情况下无法控制鼠标键盘,或者录制屏幕,请点击下面的按钮将 RustDesk 安装到系统,从而规避上述问题。"), ("Click to upgrade", "点击这里升级"), - ("Click to download", "点击这里下载"), - ("Click to update", "点击这里更新"), ("Configure", "配置"), ("config_acc", "为了能够远程控制你的桌面, 请给予 RustDesk \"辅助功能\" 权限。"), ("config_screen", "为了能够远程访问你的桌面, 请给予 RustDesk \"屏幕录制\" 权限。"), @@ -267,7 +265,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("No permission of file transfer", "没有文件传输权限"), ("Note", "备注"), ("Connection", "连接"), - ("Share Screen", "共享屏幕"), + ("Share screen", "共享屏幕"), ("Chat", "聊天消息"), ("Total", "总计"), ("items", "个项目"), @@ -275,8 +273,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Screen Capture", "屏幕录制"), ("Input Control", "输入控制"), ("Audio Capture", "音频录制"), - ("File Connection", "文件连接"), - ("Screen Connection", "屏幕连接"), ("Do you accept?", "是否接受?"), ("Open System Setting", "打开系统设置"), ("How to get Android input permission?", "如何获取安卓的输入权限?"), @@ -346,7 +342,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Dark", "黑暗"), ("Light", "明亮"), ("Follow System", "跟随系统"), - ("Enable hardware codec", "使能硬件编解码"), + ("Enable hardware codec", "启用硬件编解码"), ("Unlock Security Settings", "解锁安全设置"), ("Enable audio", "允许传输音频"), ("Unlock Network Settings", "解锁网络设置"), @@ -381,14 +377,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Keyboard Settings", "键盘设置"), ("Full Access", "完全访问"), ("Screen Share", "仅共享屏幕"), - ("Wayland requires Ubuntu 21.04 or higher version.", "Wayland 需要 Ubuntu 21.04 或更高版本。"), - ("Wayland requires higher version of linux distro. Please try X11 desktop or change your OS.", "Wayland 需要更高版本的 linux 发行版。 请尝试 X11 桌面或更改您的操作系统。"), + ("ubuntu-21-04-required", "Wayland 需要 Ubuntu 21.04 或更高版本。"), + ("wayland-requires-higher-linux-version", "Wayland 需要更高版本的 linux 发行版。 请尝试 X11 桌面或更改您的操作系统。"), + ("xdp-portal-unavailable", ""), ("JumpLink", "查看"), ("Please Select the screen to be shared(Operate on the peer side).", "请选择要分享的画面(对端操作)。"), ("Show RustDesk", "显示 RustDesk"), ("This PC", "此电脑"), ("or", "或"), - ("Continue with", "使用"), ("Elevate", "提权"), ("Zoom cursor", "缩放光标"), ("Accept sessions via password", "只允许密码访问"), @@ -463,7 +459,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("empty_favorite_tip", "还没有收藏的被控端?找一个人连接并将其添加到收藏吧!"), ("empty_lan_tip", "情况不妙,似乎未发现任何被控端!"), ("empty_address_book_tip", "似乎目前地址簿内无被控端"), - ("eg: admin", "例如:admin"), ("Empty Username", "空用户名"), ("Empty Password", "空密码"), ("Me", "我"), @@ -653,5 +648,102 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Upload folder", "上传文件夹"), ("Upload files", "上传文件"), ("Clipboard is synchronized", "剪贴板已同步"), + ("Update client clipboard", "更新客户端的剪贴板"), + ("Untagged", "无标签"), + ("new-version-of-{}-tip", "{} 版本更新"), + ("Accessible devices", "可访问的设备"), + ("upgrade_remote_rustdesk_client_to_{}_tip", "请在远程端将 RustDesk 客户端升级至版本 {} 或更新版本!"), + ("d3d_render_tip", "当启用 D3D 渲染时,某些机器可能无法显示远程画面。"), + ("Use D3D rendering", "使用 D3D 渲染"), + ("Printer", "打印机"), + ("printer-os-requirement-tip", "打印机的传出功能需要 Windows 10 或更高版本。"), + ("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", "使用 WebSocket 时,仅支持中继连接。"), + ("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", "默认情况下,对于使用 TLS 的协议,RustDesk 会验证服务器证书。\n启用此选项后,在验证失败时,RustDesk 将转为跳过验证步骤并继续连接。"), + ("Disable UDP", "禁用 UDP"), + ("disable-udp-tip", "控制是否仅使用 TCP。\n启用此选项后,RustDesk 将不再使用 UDP 21116,而是使用 TCP 21116。"), + ("server-oss-not-support-tip", "注意:RustDesk 开源服务器 (OSS server) 不包含此功能。"), + ("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 {}", "使用 {} 登录"), + ("Display Name", "显示名称"), + ("password-hidden-tip", "永久密码已设置(已隐藏)"), + ("preset-password-in-use-tip", "当前使用预设密码"), + ("Enable privacy mode", "允许隐私模式"), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/cs.rs b/src/lang/cs.rs index a9fb5b233..7f50d826f 100644 --- a/src/lang/cs.rs +++ b/src/lang/cs.rs @@ -41,7 +41,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("length %min% to %max%", "délka mezi %min% a %max%"), ("starts with a letter", "začíná písmenem"), ("allowed characters", "povolené znaky"), - ("id_change_tip", "Použít je možné pouze znaky a-z, A-Z, 0-9 a _ (podtržítko). Dále je třeba aby začínalo písmenem a-z, A-Z. Délka mezi 6 a 16 znaky."), + ("id_change_tip", "Použít je možné pouze znaky a-z, A-Z, 0-9, - (dash) a _ (podtržítko). Dále je třeba aby začínalo písmenem a-z, A-Z. Délka mezi 6 a 16 znaky."), ("Website", "Webové stránky"), ("About", "O aplikaci"), ("Slogan_tip", "Vytvořeno srdcem v tomto chaotickém světě!"), @@ -147,8 +147,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("OS Password", "Heslo do operačního systému"), ("install_tip", "Kvůli řízení oprávnění v systému (UAC), RustDesk v některých případech na protistraně nefunguje správně. Abyste se UAC vyhnuli, klikněte na níže uvedené tlačítko a nainstalujte tak RustDesk do systému."), ("Click to upgrade", "Aktualizovat"), - ("Click to download", "Stáhnout"), - ("Click to update", "Aktualizovat"), ("Configure", "Nastavit"), ("config_acc", "Aby bylo možné na dálku ovládat vaši plochu, je třeba aplikaci RustDesk udělit oprávnění pro \"Zpřístupnění pro hendikepované\"."), ("config_screen", "Aby bylo možné přistupovat k vaší ploše na dálku, je třeba aplikaci RustDesk udělit oprávnění pro \"Nahrávání obsahu obrazovky\"."), @@ -267,7 +265,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("No permission of file transfer", "Žádné oprávnění k přenosu souborů"), ("Note", "Poznámka"), ("Connection", "Připojení"), - ("Share Screen", "Sdílet obrazovku"), + ("Share screen", "Sdílet obrazovku"), ("Chat", "Chat"), ("Total", "Celkem"), ("items", "Položek"), @@ -275,8 +273,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Screen Capture", "Zachytávání obrazovky"), ("Input Control", "Ovládání vstupních zařízení"), ("Audio Capture", "Zachytávání zvuku"), - ("File Connection", "Souborové spojení"), - ("Screen Connection", "Spojení obrazovky"), ("Do you accept?", "Přijímáte?"), ("Open System Setting", "Otevřít nastavení systému"), ("How to get Android input permission?", "Jak v systému Android získat oprávnění pro vstupní zařízení?"), @@ -381,14 +377,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Keyboard Settings", "Nastavení klávesnice"), ("Full Access", "Úplný přístup"), ("Screen Share", "Sdílení obrazovky"), - ("Wayland requires Ubuntu 21.04 or higher version.", "Wayland vyžaduje Ubuntu 21.04, nebo vyšší verzi."), - ("Wayland requires higher version of linux distro. Please try X11 desktop or change your OS.", "Wayland vyžaduje vyšší verzi linuxové distribuce. Zkuste prosím X11 desktop, nebo změňte OS."), + ("ubuntu-21-04-required", "Wayland vyžaduje Ubuntu 21.04, nebo vyšší verzi."), + ("wayland-requires-higher-linux-version", "Wayland vyžaduje vyšší verzi linuxové distribuce. Zkuste prosím X11 desktop, nebo změňte OS."), + ("xdp-portal-unavailable", ""), ("JumpLink", "JumpLink"), ("Please Select the screen to be shared(Operate on the peer side).", "Vyberte prosím obrazovku, kterou chcete sdílet (Ovládejte na straně protistrany)."), ("Show RustDesk", "Zobrazit RustDesk"), ("This PC", "Tento počítač"), ("or", "nebo"), - ("Continue with", "Pokračovat s"), ("Elevate", "Zvýšit"), ("Zoom cursor", "Kurzor přiblížení"), ("Accept sessions via password", "Přijímat relace pomocí hesla"), @@ -463,7 +459,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("empty_favorite_tip", "Ještě nemáte oblíbené protistrany?\nNajděte někoho, s kým se můžete spojit, a přidejte si ho do oblíbených!"), ("empty_lan_tip", "Ale ne, vypadá, že jsme ještě neobjevili žádné protistrany."), ("empty_address_book_tip", "Ach bože, zdá se, že ve vašem adresáři nejsou v současné době uvedeni žádní kolegové."), - ("eg: admin", "např. admin"), ("Empty Username", "Prázdné uživatelské jméno"), ("Empty Password", "Prázdné heslo"), ("Me", "Já"), @@ -653,5 +648,102 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Upload folder", ""), ("Upload files", ""), ("Clipboard is synchronized", ""), + ("Update client clipboard", ""), + ("Untagged", ""), + ("new-version-of-{}-tip", ""), + ("Accessible devices", ""), + ("upgrade_remote_rustdesk_client_to_{}_tip", "Upgradujte prosím klienta RustDesk na verzi {} nebo novější na vzdálené straně!"), + ("d3d_render_tip", ""), + ("Use D3D rendering", ""), + ("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", ""), + ("Trackpad speed", ""), + ("Default trackpad speed", ""), + ("Numeric one-time password", ""), + ("Enable IPv6 P2P connection", ""), + ("Enable UDP hole punching", ""), + ("View camera", "Zobrazit kameru"), + ("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", ""), + ("allow-insecure-tls-fallback-tip", ""), + ("Disable 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 {}", "Pokračovat s {}"), + ("Display Name", ""), + ("password-hidden-tip", ""), + ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/da.rs b/src/lang/da.rs index 34e5433f5..c9d3b4eb0 100644 --- a/src/lang/da.rs +++ b/src/lang/da.rs @@ -41,7 +41,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("length %min% to %max%", "længde %min% til %max%"), ("starts with a letter", "starter med ét bogstav"), ("allowed characters", "tilladte tegn"), - ("id_change_tip", "Kun tegnene a-z, A-Z, 0-9 og _ (understregning) er tilladt. Det første bogstav skal være a-z, A-Z. Antal tegn skal være mellem 6 og 16."), + ("id_change_tip", "Kun tegnene a-z, A-Z, 0-9, - (dash) og _ (understregning) er tilladt. Det første bogstav skal være a-z, A-Z. Antal tegn skal være mellem 6 og 16."), ("Website", "Hjemmeside"), ("About", "Om"), ("Slogan_tip", "Lavet med kærlighed i denne kaotiske verden!"), @@ -147,8 +147,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("OS Password", "Operativsystemadgangskode"), ("install_tip", "På grund af UAC kan RustDesk ikke fungere korrekt i nogle tilfælde på fjernskrivebordet. For at undgå UAC skal du klikke på knappen nedenfor for at installere RustDesk på systemet"), ("Click to upgrade", "Klik for at opgradere"), - ("Click to download", "Klik for at downloade"), - ("Click to update", "Klik for at opdatere"), ("Configure", "Konfigurer"), ("config_acc", "For at kontrollere dit skrivebord på afstand skal du give RustDesk \"Access \" Rettigheder."), ("config_screen", "For at kunne få adgang til dit skrivebord langtfra, skal du give RustDesk \"skærmstøtte \" tilladelser."), @@ -267,7 +265,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("No permission of file transfer", "Ingen tilladelse til at overføre filen"), ("Note", "Note"), ("Connection", "Forbindelse"), - ("Share Screen", "Del skærmen"), + ("Share screen", "Del skærmen"), ("Chat", "Chat"), ("Total", "Total"), ("items", "artikel"), @@ -275,8 +273,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Screen Capture", "Skærmoptagelse"), ("Input Control", "Inputkontrol"), ("Audio Capture", "Lydoptagelse"), - ("File Connection", "Filforbindelse"), - ("Screen Connection", "Færdiggørelse"), ("Do you accept?", "Accepterer du?"), ("Open System Setting", "Åbn systemindstillingen"), ("How to get Android input permission?", "Hvordan får jeg en Android-input tilladelse?"), @@ -381,14 +377,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Keyboard Settings", "Tastaturindstillinger"), ("Full Access", "Fuld adgang"), ("Screen Share", "Skærmdeling"), - ("Wayland requires Ubuntu 21.04 or higher version.", "Wayland kræver Ubuntu version 21.04 eller nyere."), - ("Wayland requires higher version of linux distro. Please try X11 desktop or change your OS.", "Wayland kræver en højere version af Linux distro. Prøv venligst X11 desktop eller skift dit OS."), + ("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."), + ("xdp-portal-unavailable", ""), ("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)."), ("Show RustDesk", "Vis RustDesk"), ("This PC", "Denne PC"), ("or", "eller"), - ("Continue with", "Fortsæt med"), ("Elevate", "Elevér"), ("Zoom cursor", "Zoom markør"), ("Accept sessions via password", "Acceptér sessioner via adgangskode"), @@ -463,7 +459,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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."), - ("eg: admin", "fx: admin"), ("Empty Username", "Tom brugernavn"), ("Empty Password", "Tom adgangskode"), ("Me", "Mig"), @@ -653,5 +648,102 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Upload folder", ""), ("Upload files", ""), ("Clipboard is synchronized", ""), + ("Update client clipboard", ""), + ("Untagged", ""), + ("new-version-of-{}-tip", ""), + ("Accessible devices", ""), + ("upgrade_remote_rustdesk_client_to_{}_tip", "Opgrader venligst RustDesk-klienten til version {} eller nyere på fjernsiden!"), + ("d3d_render_tip", ""), + ("Use D3D rendering", ""), + ("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", ""), + ("Trackpad speed", ""), + ("Default trackpad speed", ""), + ("Numeric one-time password", ""), + ("Enable IPv6 P2P connection", ""), + ("Enable UDP hole punching", ""), + ("View camera", "Se kamera"), + ("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", ""), + ("allow-insecure-tls-fallback-tip", ""), + ("Disable 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 {}", "Fortsæt med {}"), + ("Display Name", ""), + ("password-hidden-tip", ""), + ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/de.rs b/src/lang/de.rs index a73221371..e6233e91e 100644 --- a/src/lang/de.rs +++ b/src/lang/de.rs @@ -41,7 +41,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("length %min% to %max%", "Länge %min% bis %max%"), ("starts with a letter", "Beginnt mit Buchstabe"), ("allowed characters", "Erlaubte Zeichen"), - ("id_change_tip", "Nur die Zeichen a-z, A-Z, 0-9 und _ (Unterstrich) sind erlaubt. Der erste Buchstabe muss a-z, A-Z sein und die Länge zwischen 6 und 16 Zeichen betragen."), + ("id_change_tip", "Nur die Zeichen a-z, A-Z, 0-9, - (Bindestrich) und _ (Unterstrich) sind erlaubt. Der erste Buchstabe muss a-z, A-Z sein und die Länge zwischen 6 und 16 Zeichen betragen."), ("Website", "Webseite"), ("About", "Über"), ("Slogan_tip", "Mit Herzblut programmiert - in einer Welt, die im Chaos versinkt!"), @@ -147,8 +147,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("OS Password", "Betriebssystem-Passwort"), ("install_tip", "Aufgrund der Benutzerkontensteuerung (UAC) kann RustDesk in manchen Fällen nicht ordnungsgemäß funktionieren. Um die Benutzerkontensteuerung zu umgehen, klicken Sie bitte auf die Schaltfläche unten und installieren RustDesk auf dem System."), ("Click to upgrade", "Zum Upgraden klicken"), - ("Click to download", "Zum Herunterladen klicken"), - ("Click to update", "Zum Aktualisieren klicken"), ("Configure", "Konfigurieren"), ("config_acc", "Um Ihren PC aus der Ferne zu steuern, müssen Sie RustDesk Zugriffsrechte erteilen."), ("config_screen", "Um aus der Ferne auf Ihren PC zugreifen zu können, müssen Sie RustDesk die Berechtigung \"Bildschirmaufnahme\" erteilen."), @@ -267,7 +265,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("No permission of file transfer", "Keine Berechtigung für die Dateiübertragung"), ("Note", "Hinweis"), ("Connection", "Verbindung"), - ("Share Screen", "Bildschirm freigeben"), + ("Share screen", "Bildschirm freigeben"), ("Chat", "Chat"), ("Total", "Gesamt"), ("items", "Einträge"), @@ -275,8 +273,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Screen Capture", "Bildschirmaufnahme"), ("Input Control", "Eingabesteuerung"), ("Audio Capture", "Audioaufnahme"), - ("File Connection", "Dateiverbindung"), - ("Screen Connection", "Bildschirmverbindung"), ("Do you accept?", "Verbindung zulassen?"), ("Open System Setting", "Systemeinstellung öffnen"), ("How to get Android input permission?", "Wie erhalte ich eine Android-Eingabeberechtigung?"), @@ -364,7 +360,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Recording", "Aufnahme"), ("Directory", "Verzeichnis"), ("Automatically record incoming sessions", "Eingehende Sitzungen automatisch aufzeichnen"), - ("Automatically record outgoing sessions", ""), + ("Automatically record outgoing sessions", "Ausgehende Sitzungen automatisch aufzeichnen"), ("Change", "Ändern"), ("Start session recording", "Sitzungsaufzeichnung starten"), ("Stop session recording", "Sitzungsaufzeichnung beenden"), @@ -381,14 +377,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Keyboard Settings", "Tastatureinstellungen"), ("Full Access", "Vollzugriff"), ("Screen Share", "Bildschirmfreigabe"), - ("Wayland requires Ubuntu 21.04 or higher version.", "Wayland erfordert Ubuntu 21.04 oder eine höhere Version."), - ("Wayland requires higher version of linux distro. Please try X11 desktop or change your OS.", "Wayland erfordert eine höhere Version der Linux-Distribution. Bitte versuchen Sie den X11-Desktop oder ändern Sie Ihr Betriebssystem."), - ("JumpLink", "View"), + ("ubuntu-21-04-required", "Wayland erfordert Ubuntu 21.04 oder eine höhere Version."), + ("wayland-requires-higher-linux-version", "Wayland erfordert eine höhere Version der Linux-Distribution. Bitte versuchen Sie den X11-Desktop oder ändern Sie Ihr Betriebssystem."), + ("xdp-portal-unavailable", "Die Bildschirmaufnahme mit Wayland ist fehlgeschlagen. Das XDG-Desktop-Portal ist möglicherweise abgestürzt oder nicht verfügbar. Versuchen Sie, es mit `systemctl --user restart xdg-desktop-portal` neu zu starten."), + ("JumpLink", "Anzeigen"), ("Please Select the screen to be shared(Operate on the peer side).", "Bitte wählen Sie den freizugebenden Bildschirm aus (Bedienung auf der Gegenseite)."), ("Show RustDesk", "RustDesk anzeigen"), ("This PC", "Dieser PC"), ("or", "oder"), - ("Continue with", "Fortfahren mit"), ("Elevate", "Zugriff gewähren"), ("Zoom cursor", "Cursor vergrößern"), ("Accept sessions via password", "Sitzung mit Passwort bestätigen"), @@ -463,7 +459,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("empty_favorite_tip", "Noch keine favorisierte Gegenstelle?\nLassen Sie uns jemanden finden, mit dem wir uns verbinden können und fügen Sie ihn zu Ihren Favoriten hinzu!"), ("empty_lan_tip", "Oh nein, es sieht so aus, als hätten wir noch keine Gegenstelle entdeckt."), ("empty_address_book_tip", "Oh je, es scheint, dass in Ihrem Adressbuch derzeit keine Gegenstellen aufgeführt sind."), - ("eg: admin", "z. B.: admin"), ("Empty Username", "Leerer Benutzername"), ("Empty Password", "Leeres Passwort"), ("Me", "Ich"), @@ -567,8 +562,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("id_input_tip", "Sie können eine ID, eine direkte IP oder eine Domäne mit einem Port (:) eingeben.\nWenn Sie auf ein Gerät auf einem anderen Server zugreifen wollen, fügen Sie bitte die Serveradresse (@?key=) hinzu, zum Beispiel\n9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=.\nWenn Sie auf ein Gerät auf einem öffentlichen Server zugreifen wollen, geben Sie bitte \"@public\" ein. Der Schlüssel wird für öffentliche Server nicht benötigt.\n\nWenn Sie bei der ersten Verbindung die Verwendung einer Relay-Verbindung erzwingen wollen, fügen Sie \"/r\" am Ende der ID hinzu, zum Beispiel \"9123456234/r\"."), ("privacy_mode_impl_mag_tip", "Modus 1"), ("privacy_mode_impl_virtual_display_tip", "Modus 2"), - ("Enter privacy mode", "Datenschutzmodus aktivieren"), - ("Exit privacy mode", "Datenschutzmodus beenden"), + ("Enter privacy mode", "Datenschutzmodus aktiviert"), + ("Exit privacy mode", "Datenschutzmodus beendet"), ("idd_not_support_under_win10_2004_tip", "Indirekter Grafiktreiber wird nicht unterstützt. Windows 10, Version 2004 oder neuer ist erforderlich."), ("input_source_1_tip", "Eingangsquelle 1"), ("input_source_2_tip", "Eingangsquelle 2"), @@ -653,5 +648,102 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Upload folder", "Ordner hochladen"), ("Upload files", "Dateien hochladen"), ("Clipboard is synchronized", "Zwischenablage ist synchronisiert"), + ("Update client clipboard", "Client-Zwischenablage aktualisieren"), + ("Untagged", "Unmarkiert"), + ("new-version-of-{}-tip", "Es ist eine neue Version von {} verfügbar"), + ("Accessible devices", "Erreichbare Geräte"), + ("upgrade_remote_rustdesk_client_to_{}_tip", "Bitte aktualisieren Sie den RustDesk-Client auf der Remote-Seite auf Version {} oder neuer!"), + ("d3d_render_tip", "Wenn das D3D-Rendering aktiviert ist, kann der entfernte Bildschirm auf manchen Rechnern schwarz sein."), + ("Use D3D rendering", "D3D-Rendering verwenden"), + ("Printer", "Drucker"), + ("printer-os-requirement-tip", "Für die Funktion des Druckerausgangs ist Windows 10 oder höher erforderlich."), + ("printer-requires-installed-{}-client-tip", "Um den entfernten Druck nutzen zu können, muss {} auf diesem Gerät installiert sein."), + ("printer-{}-not-installed-tip", "Der Drucker {} ist nicht installiert."), + ("printer-{}-ready-tip", "Der Drucker {} ist installiert und einsatzbereit."), + ("Install {} Printer", "Drucker {} installieren"), + ("Outgoing Print Jobs", "Ausgehende Druckaufträge"), + ("Incoming Print Jobs", "Eingehende Druckaufträge"), + ("Incoming Print Job", "Eingehender Druckauftrag"), + ("use-the-default-printer-tip", "Standarddrucker verwenden"), + ("use-the-selected-printer-tip", "Ausgewählten Drucker verwenden"), + ("auto-print-tip", "Automatisch mit dem ausgewählten Drucker drucken"), + ("print-incoming-job-confirm-tip", "Sie haben einen Druckauftrag aus der Ferne erhalten. Möchten Sie ihn bei sich selbst ausführen?"), + ("remote-printing-disallowed-tile-tip", "Entferntes Drucken nicht erlaubt"), + ("remote-printing-disallowed-text-tip", "Die Berechtigungseinstellungen der kontrollierten Seite verweigern den entfernten Druck."), + ("save-settings-tip", "Einstellungen speichern"), + ("dont-show-again-tip", "Nicht mehr anzeigen"), + ("Take screenshot", "Screenshot aufnehmen"), + ("Taking screenshot", "Screenshot aufnehmen …"), + ("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"), + ("Copy to clipboard", "In Zwischenablage kopieren"), + ("Enable remote printer", "Entfernten Drucker aktivieren"), + ("Downloading {}", "{} herunterladen"), + ("{} Update", "{} aktualisieren"), + ("{}-to-update-tip", "{} wird jetzt geschlossen und die neue Version installiert."), + ("download-new-version-failed-tip", "Download fehlgeschlagen. Sie können es erneut versuchen oder auf die Schaltfläche \"Herunterladen\" klicken, um von der Versionsseite herunterzuladen und manuell zu aktualisieren."), + ("Auto update", "Automatisch aktualisieren"), + ("update-failed-check-msi-tip", "Prüfung der Installationsmethode fehlgeschlagen. Bitte klicken Sie auf die Schaltfläche \"Herunterladen\", um von der Versionsseite herunterzuladen und manuell zu aktualisieren."), + ("websocket_tip", "Bei der Verwendung von WebSocket werden nur Relay-Verbindungen unterstützt."), + ("Use WebSocket", "WebSocket verwenden"), + ("Trackpad speed", "Geschwindigkeit des Trackpads"), + ("Default trackpad speed", "Standardgeschwindigkeit des Trackpads"), + ("Numeric one-time password", "Numerisches Einmalpasswort"), + ("Enable IPv6 P2P connection", "IPv6-P2P-Verbindung aktivieren"), + ("Enable UDP hole punching", "UDP-Hole-Punching aktivieren"), + ("View camera", "Kamera anzeigen"), + ("Enable camera", "Kamera zulassen"), + ("No cameras", "Keine Kameras"), + ("view_camera_unsupported_tip", "Das entfernte Gerät kann die Kamera nicht anzeigen."), + ("Terminal", "Terminal"), + ("Enable terminal", "Terminal zulassen"), + ("New tab", "Neuer Tab"), + ("Keep terminal sessions on disconnect", "Terminalsitzungen beim Trennen der Verbindung beibehalten"), + ("Terminal (Run as administrator)", "Terminal (als Administrator ausführen)"), + ("terminal-admin-login-tip", "Bitte geben Sie den Benutzernamen und das Passwort des Administrators der kontrollierten Seite ein."), + ("Failed to get user token.", "Benutzer-Token konnte nicht abgerufen werden."), + ("Incorrect username or password.", "Falscher Benutzername oder falsches Passwort."), + ("The user is not an administrator.", "Der Benutzer ist kein Administrator."), + ("Failed to check if the user is an administrator.", "Es konnte nicht geprüft werden, ob der Benutzer ein Administrator ist."), + ("Supported only in the installed version.", "Wird nur in der installierten Version unterstützt."), + ("elevation_username_tip", "Geben Sie Benutzername oder Domäne\\Benutzername ein"), + ("Preparing for installation ...", "Installation wird vorbereitet …"), + ("Show my cursor", "Meinen Cursor anzeigen"), + ("Scale custom", "Benutzerdefinierte Skalierung"), + ("Custom scale slider", "Schieberegler für benutzerdefinierte Skalierung"), + ("Decrease", "Verringern"), + ("Increase", "Erhöhen"), + ("Show virtual mouse", "Virtuelle Maus anzeigen"), + ("Virtual mouse size", "Virtuelle Mausgröße"), + ("Small", "Klein"), + ("Large", "Groß"), + ("Show virtual joystick", "Virtuellen Joystick anzeigen"), + ("Edit note", "Hinweis bearbeiten"), + ("Alias", "Alias"), + ("ScrollEdge", "Scrollen am Rand"), + ("Allow insecure TLS fallback", "Unsicheres TLS-Fallback zulassen"), + ("allow-insecure-tls-fallback-tip", "Standardmäßig überprüft RustDesk das Serverzertifikat für Protokolle, die TLS verwenden. Wenn diese Option aktiviert ist, überspringt RustDesk den Überprüfungsschritt und fährt im Falle eines Überprüfungsfehlers fort."), + ("Disable UDP", "UDP deaktivieren"), + ("disable-udp-tip", "Legt fest, ob nur TCP verwendet werden soll. Wenn diese Option aktiviert ist, verwendet RustDesk nicht mehr UDP 21116, sondern stattdessen TCP 21116."), + ("server-oss-not-support-tip", "HINWEIS: RustDesk Server OSS enthält diese Funktion nicht."), + ("input note here", "Hier eine Notiz eingeben"), + ("note-at-conn-end-tip", "Am Ende der Verbindung um eine Notiz bitten."), + ("Show terminal extra keys", "Zusätzliche Tasten des Terminals anzeigen"), + ("Relative mouse mode", "Relativer Mausmodus"), + ("rel-mouse-not-supported-peer-tip", "Der relative Mausmodus wird von der verbundenen Gegenstelle nicht unterstützt."), + ("rel-mouse-not-ready-tip", "Der relative Mausmodus ist noch nicht bereit. Bitte versuchen Sie es erneut."), + ("rel-mouse-lock-failed-tip", "Cursor konnte nicht gesperrt werden. Der relative Mausmodus wurde deaktiviert."), + ("rel-mouse-exit-{}-tip", "Drücken Sie {} zum Beenden."), + ("rel-mouse-permission-lost-tip", "Die Tastaturberechtigung wurde widerrufen. Der relative Mausmodus wurde deaktiviert."), + ("Changelog", "Änderungsprotokoll"), + ("keep-awake-during-outgoing-sessions-label", "Bildschirm während ausgehender Sitzungen aktiv halten"), + ("keep-awake-during-incoming-sessions-label", "Bildschirm während eingehender Sitzungen aktiv halten"), + ("Continue with {}", "Fortfahren mit {}"), + ("Display Name", "Anzeigename"), + ("password-hidden-tip", "Ein permanentes Passwort wurde festgelegt (ausgeblendet)."), + ("preset-password-in-use-tip", "Das voreingestellte Passwort wird derzeit verwendet."), + ("Enable privacy mode", "Datenschutzmodus aktivieren"), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/el.rs b/src/lang/el.rs index e6df3bc3d..d03bb069c 100644 --- a/src/lang/el.rs +++ b/src/lang/el.rs @@ -3,7 +3,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = [ ("Status", "Κατάσταση"), ("Your Desktop", "Ο σταθμός εργασίας σας"), - ("desk_tip", "Η πρόσβαση στον σταθμό εργασίας σας είναι δυνατή με αυτό το αναγνωριστικό και τον κωδικό πρόσβασης."), + ("desk_tip", "Η πρόσβαση στον σταθμό εργασίας σας είναι δυνατή με αυτό το ID και τον κωδικό πρόσβασης."), ("Password", "Κωδικός πρόσβασης"), ("Ready", "Έτοιμο"), ("Established", "Συνδέθηκε"), @@ -19,16 +19,16 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Recent sessions", "Πρόσφατες συνεδρίες"), ("Address book", "Βιβλίο διευθύνσεων"), ("Confirmation", "Επιβεβαίωση"), - ("TCP tunneling", "TCP tunneling"), + ("TCP tunneling", "Σήραγγα TCP"), ("Remove", "Κατάργηση"), - ("Refresh random password", "Νέος τυχαίος κωδικός πρόσβασης"), + ("Refresh random password", "Ανανέωση τυχαίου κωδικού πρόσβασης"), ("Set your own password", "Ορίστε τον δικό σας κωδικό πρόσβασης"), ("Enable keyboard/mouse", "Ενεργοποίηση πληκτρολογίου/ποντικιού"), ("Enable clipboard", "Ενεργοποίηση προχείρου"), ("Enable file transfer", "Ενεργοποίηση μεταφοράς αρχείων"), - ("Enable TCP tunneling", "Ενεργοποίηση TCP tunneling"), + ("Enable TCP tunneling", "Ενεργοποίηση σήραγγας TCP"), ("IP Whitelisting", "Λίστα επιτρεπόμενων IP"), - ("ID/Relay Server", "Διακομιστής ID/Αναμετάδοσης"), + ("ID/Relay Server", "ID/Διακομιστής Αναμετάδοσης"), ("Import server config", "Εισαγωγή διαμόρφωσης διακομιστή"), ("Export Server Config", "Εξαγωγή διαμόρφωσης διακομιστή"), ("Import server configuration successfully", "Επιτυχής εισαγωγή διαμόρφωσης διακομιστή"), @@ -36,14 +36,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Invalid server configuration", "Μη έγκυρη διαμόρφωση διακομιστή"), ("Clipboard is empty", "Το πρόχειρο είναι κενό"), ("Stop service", "Διακοπή υπηρεσίας"), - ("Change ID", "Αλλαγή αναγνωριστικού ID"), + ("Change ID", "Αλλαγή του ID σας"), ("Your new ID", "Το νέο σας ID"), ("length %min% to %max%", "μέγεθος από %min% έως %max%"), ("starts with a letter", "ξεκινά με γράμμα"), ("allowed characters", "επιτρεπόμενοι χαρακτήρες"), - ("id_change_tip", "Επιτρέπονται μόνο οι χαρακτήρες a-z, A-Z, 0-9 και _ (υπογράμμιση). Το πρώτο γράμμα πρέπει να είναι a-z, A-Z και το μήκος πρέπει να είναι μεταξύ 6 και 16 χαρακτήρων."), + ("id_change_tip", "Επιτρέπονται μόνο οι χαρακτήρες a-z, A-Z, 0-9, - (παύλα) και _ (κάτω παύλα). Το πρώτο γράμμα πρέπει να είναι a-z, A-Z και το μήκος πρέπει να είναι μεταξύ 6 και 16 χαρακτήρων."), ("Website", "Ιστότοπος"), - ("About", "Πληροφορίες"), + ("About", "Σχετικά"), ("Slogan_tip", "Φτιαγμένο με πάθος - σε έναν κόσμο που βυθίζεται στο χάος!"), ("Privacy Statement", "Πολιτική απορρήτου"), ("Mute", "Σίγαση"), @@ -53,7 +53,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Audio Input", "Είσοδος ήχου"), ("Enhancements", "Βελτιώσεις"), ("Hardware Codec", "Κωδικοποιητής υλικού"), - ("Adaptive bitrate", "Adaptive bitrate"), + ("Adaptive bitrate", "Προσαρμοστικός ρυθμός μετάδοσης bit"), ("ID Server", "Διακομιστής ID"), ("Relay Server", "Διακομιστής αναμετάδοσης"), ("API Server", "Διακομιστής API"), @@ -67,18 +67,18 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Skip", "Παράλειψη"), ("Close", "Κλείσιμο"), ("Retry", "Δοκίμασε ξανά"), - ("OK", "ΟΚ"), + ("OK", "Εντάξει"), ("Password Required", "Απαιτείται κωδικός πρόσβασης"), ("Please enter your password", "Παρακαλώ εισάγετε τον κωδικό πρόσβασης"), ("Remember password", "Απομνημόνευση κωδικού πρόσβασης"), ("Wrong Password", "Λάθος κωδικός πρόσβασης"), - ("Do you want to enter again?", "Επανασύνδεση;"), + ("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 λεπτό"), + ("Please try 1 minute later", "Παρακαλώ δοκιμάστε ξανά σε 1 λεπτό"), ("Login Error", "Σφάλμα εισόδου"), ("Successful", "Επιτυχής"), ("Connected, waiting for image...", "Συνδέθηκε, αναμονή για εικόνα..."), @@ -101,10 +101,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Select All", "Επιλογή όλων"), ("Unselect All", "Κατάργηση επιλογής όλων"), ("Empty Directory", "Κενός φάκελος"), - ("Not an 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?", "Είστε βέβαιοι ότι θέλετε να διαγράψετε το αρχείο αυτού του φακέλου;"), + ("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", "Διαγραφή"), @@ -133,8 +133,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Insert Ctrl + Alt + Del", "Εισαγωγή Ctrl + Alt + Del"), ("Insert Lock", "Κλείδωμα απομακρυσμένου σταθμού"), ("Refresh", "Ανανέωση"), - ("ID does not exist", "Το αναγνωριστικό ID δεν υπάρχει"), - ("Failed to connect to rendezvous server", "Αποτυχία σύνδεσης με διακομιστή"), + ("ID does not exist", "Το ID αυτό δεν υπάρχει"), + ("Failed to connect to rendezvous server", "Αποτυχία σύνδεσης με τον διακομιστή"), ("Please try later", "Παρακαλώ δοκιμάστε αργότερα"), ("Remote desktop is offline", "Ο απομακρυσμένος σταθμός εργασίας είναι εκτός σύνδεσης"), ("Key mismatch", "Μη έγκυρο κλειδί"), @@ -146,19 +146,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Set Password", "Ορίστε κωδικό πρόσβασης"), ("OS Password", "Κωδικός πρόσβασης λειτουργικού συστήματος"), ("install_tip", "Λόγω UAC, το RustDesk ενδέχεται να μην λειτουργεί σωστά σε ορισμένες περιπτώσεις. Για να αποφύγετε το UAC, κάντε κλικ στο κουμπί παρακάτω για να εγκαταστήσετε το RustDesk στο σύστημα"), - ("Click to upgrade", "Πιέστε για αναβάθμιση"), - ("Click to download", "Πιέστε για λήψη"), - ("Click to update", "Πιέστε για ενημέρωση"), + ("Click to upgrade", "Κάντε κλίκ για αναβάθμιση τώρα"), ("Configure", "Διαμόρφωση"), - ("config_acc", "Για τον απομακρυσμένο έλεγχο του υπολογιστή σας, πρέπει να εκχωρήσετε δικαιώματα πρόσβασης στο RustDesk."), - ("config_screen", "Για να αποκτήσετε απομακρυσμένη πρόσβαση στον υπολογιστή σας, πρέπει να εκχωρήσετε το δικαίωμα RustDesk \"Screen Capture\"."), + ("config_acc", "Για να ελέγξετε την επιφάνεια εργασίας σας από απόσταση, πρέπει να παραχωρήσετε στο RustDesk το δικαίωμα της \"Προσβασιμότητας\"."), + ("config_screen", "Για να αποκτήσετε απομακρυσμένη πρόσβαση στην επιφάνεια εργασίας σας, πρέπει να παραχωρήσετε στο RustDesk το δικαίωμα της \"Εγγραφή οθόνης\"."), ("Installing ...", "Γίνεται εγκατάσταση ..."), ("Install", "Εγκατάσταση"), ("Installation", "Η εγκατάσταση"), ("Installation Path", "Διαδρομή εγκατάστασης"), ("Create start menu shortcuts", "Δημιουργία συντομεύσεων μενού έναρξης"), ("Create desktop icon", "Δημιουργία εικονιδίου επιφάνειας εργασίας"), - ("agreement_tip", "Με την εγκατάσταση αποδέχεστε την άδεια χρήσης"), + ("agreement_tip", "Με την εγκατάσταση, αποδέχεστε την άδεια χρήσης"), ("Accept and Install", "Αποδοχή και εγκατάσταση"), ("End-user license agreement", "Σύμβαση άδειας χρήσης τελικού χρήστη"), ("Generating ...", "Δημιουργία ..."), @@ -172,8 +170,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Local Port", "Τοπική θύρα"), ("Local Address", "Τοπική διεύθυνση"), ("Change Local Port", "Αλλαγή τοπικής θύρας"), - ("setup_server_tip", "Για πιο γρήγορη σύνδεση, ρυθμίστε τον δικό σας διακομιστή σύνδεσης"), - ("Too short, at least 6 characters.", "Πολύ μικρό, τουλάχιστον 6 χαρακτήρες."), + ("setup_server_tip", "Για πιο γρήγορη σύνδεση, παρακαλούμε να ρυθμίστε τον δικό σας διακομιστή σύνδεσης"), + ("Too short, at least 6 characters.", "Πολύ μικρό, χρειάζεται τουλάχιστον 6 χαρακτήρες."), ("The confirmation is not identical.", "Η επιβεβαίωση δεν είναι πανομοιότυπη."), ("Permissions", "Άδειες"), ("Accept", "Αποδοχή"), @@ -185,7 +183,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relayed and encrypted connection", "Κρυπτογραφημένη σύνδεση με αναμετάδοση"), ("Direct and unencrypted connection", "Άμεση και μη κρυπτογραφημένη σύνδεση"), ("Relayed and unencrypted connection", "Μη κρυπτογραφημένη σύνδεση με αναμετάδοση"), - ("Enter Remote ID", "Εισαγωγή απομακρυσμένου ID"), + ("Enter Remote ID", "Εισαγωγή του απομακρυσμένου ID"), ("Enter your password", "Εισάγετε τον κωδικό σας"), ("Logging in...", "Γίνεται σύνδεση..."), ("Enable RDP session sharing", "Ενεργοποίηση κοινής χρήσης RDP"), @@ -202,35 +200,35 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Login screen using Wayland is not supported", "Η οθόνη εισόδου με χρήση του Wayland δεν υποστηρίζεται"), ("Reboot required", "Απαιτείται επανεκκίνηση"), ("Unsupported display server", "Μη υποστηριζόμενος διακομιστής εμφάνισης "), - ("x11 expected", "απαιτείται X11"), + ("x11 expected", "αναμένεται X11"), ("Port", "Θύρα"), ("Settings", "Ρυθμίσεις"), ("Username", "Όνομα χρήστη"), ("Invalid port", "Μη έγκυρη θύρα"), - ("Closed manually by the peer", "Έκλεισε από τον απομακρυσμένο σταθμό"), - ("Enable remote configuration modification", "Ενεργοποίηση απομακρυσμένης τροποποίησης ρυθμίσεων"), + ("Closed manually by the peer", "Τερματίστηκε από τον απομακρυσμένο σταθμό"), + ("Enable remote configuration modification", "Ενεργοποίηση απομακρυσμένης τροποποίησης διαμόρφωσης"), ("Run without install", "Εκτέλεση χωρίς εγκατάσταση"), - ("Connect via relay", "Πραγματοποίηση σύνδεση μέσω αναμεταδότη"), - ("Always connect via relay", "Σύνδεση πάντα μέσω αναμεταδότη"), - ("whitelist_tip", "Μόνο οι IP της λίστας επιτρεπόμενων έχουν πρόσβαση"), + ("Connect via relay", "Σύνδεση μέσω αναμεταδότη"), + ("Always connect via relay", "Να γίνεται σύνδεση πάντα μέσω αναμεταδότη"), + ("whitelist_tip", "Μόνο οι IP της λίστας επιτρεπόμενων να έχουν πρόσβαση σε εμένα"), ("Login", "Σύνδεση"), ("Verify", "Επαλήθευση"), ("Remember me", "Να με θυμάσαι"), - ("Trust this device", "Εμπιστεύομαι αυτή την συσκευή"), + ("Trust this device", "Να εμπιστεύομαι αυτή την συσκευή"), ("Verification code", "Κωδικός επαλήθευσης"), - ("verification_tip", "Εντοπίστηκε νέα συσκευή και εστάλη ένας κωδικός επαλήθευσης στην καταχωρισμένη διεύθυνση email. Εισαγάγετε τον κωδικό επαλήθευσης για να συνδεθείτε ξανά."), + ("verification_tip", "Ένας κωδικός επαλήθευσης έχει σταλεί στην καταχωρημένη διεύθυνση email. Εισαγάγετε τον κωδικό επαλήθευσης για να συνεχίσετε τη σύνδεση."), ("Logout", "Αποσύνδεση"), ("Tags", "Ετικέτες"), ("Search ID", "Αναζήτηση ID"), - ("whitelist_sep", "Διαχωρίζονται με κόμμα, ερωτηματικό, διάστημα ή νέα γραμμή"), - ("Add ID", "Προσθήκη αναγνωριστικού ID"), + ("whitelist_sep", "Διαχωρίζονται με κόμμα, ερωτηματικό, κενό ή νέα γραμμή"), + ("Add ID", "Προσθήκη ID"), ("Add Tag", "Προσθήκη ετικέτας"), - ("Unselect all tags", "Κατάργηση επιλογής όλων των ετικετών"), + ("Unselect all tags", "Αποεπιλογή όλων των ετικετών"), ("Network error", "Σφάλμα δικτύου"), ("Username missed", "Δεν συμπληρώσατε το όνομα χρήστη"), ("Password missed", "Δεν συμπληρώσατε τον κωδικό πρόσβασης"), ("Wrong credentials", "Λάθος διαπιστευτήρια"), - ("The verification code is incorrect or has expired", ""), + ("The verification code is incorrect or has expired", "Ο κωδικός επαλήθευσης είναι λανθασμένος ή έχει λήξει"), ("Edit Tag", "Επεξεργασία ετικέτας"), ("Forget Password", "Διαγραφή απομνημονευμένου κωδικού"), ("Favorites", "Αγαπημένα"), @@ -241,7 +239,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Socks5 Proxy", "Διαμεσολαβητής Socks5"), ("Socks5/Http(s) Proxy", "Διαμεσολαβητής Socks5/Http(s)"), ("Discovered", "Ανακαλύφθηκαν"), - ("install_daemon_tip", "Για να ξεκινά με την εκκίνηση του υπολογιστή, πρέπει να εγκαταστήσετε την υπηρεσία συστήματος"), + ("install_daemon_tip", "Για να ξεκινά με την εκκίνηση του υπολογιστή, πρέπει να εγκαταστήσετε την υπηρεσία συστήματος."), ("Remote ID", "Απομακρυσμένο ID"), ("Paste", "Επικόλληση"), ("Paste here?", "Επικόλληση εδώ;"), @@ -264,30 +262,28 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Pinch to Zoom", "Τσίμπημα για ζουμ"), ("Canvas Zoom", "Ζουμ σε καμβά"), ("Reset canvas", "Επαναφορά καμβά"), - ("No permission of file transfer", "Δεν υπάρχει άδεια για μεταφορά αρχείων"), + ("No permission of file transfer", "Δεν υπάρχει άδεια για την μεταφορά αρχείων"), ("Note", "Σημείωση"), ("Connection", "Σύνδεση"), - ("Share Screen", "Κοινή χρήση οθόνης"), + ("Share screen", "Κοινή χρήση οθόνης"), ("Chat", "Κουβέντα"), ("Total", "Σύνολο"), ("items", "στοιχεία"), - ("Selected", "Επιλεγμένο"), - ("Screen Capture", "Αποτύπωση οθόνης"), + ("Selected", "Επιλεγμένα"), + ("Screen Capture", "Καταγραφή οθόνης"), ("Input Control", "Έλεγχος εισόδου"), ("Audio Capture", "Εγγραφή ήχου"), - ("File Connection", "Σύνδεση αρχείου"), - ("Screen Connection", "Σύνδεση οθόνης"), ("Do you accept?", "Δέχεσαι;"), ("Open System Setting", "Άνοιγμα ρυθμίσεων συστήματος"), - ("How to get Android input permission?", "Πώς να αποκτήσω άδεια εισαγωγής Android;"), + ("How to get Android input permission?", "Πώς να αποκτήσω άδεια εισόδου για Android;"), ("android_input_permission_tip1", "Για να μπορεί μία απομακρυσμένη συσκευή να ελέγχει τη συσκευή σας Android, πρέπει να επιτρέψετε στο RustDesk να χρησιμοποιεί την υπηρεσία \"Προσβασιμότητα\"."), - ("android_input_permission_tip2", "Παρακαλώ μεταβείτε στην επόμενη σελίδα ρυθμίσεων συστήματος, βρείτε και πληκτρολογήστε [Εγκατεστημένες υπηρεσίες], ενεργοποιήστε την υπηρεσία [Είσοδος RustDesk]."), - ("android_new_connection_tip", "θέλω να ελέγξω τη συσκευή σου."), - ("android_service_will_start_tip", "Η ενεργοποίηση της κοινής χρήσης οθόνης θα ξεκινήσει αυτόματα την υπηρεσία, ώστε άλλες συσκευές να μπορούν να ελέγχουν αυτήν τη συσκευή Android."), - ("android_stop_service_tip", "Η απενεργοποίηση της υπηρεσίας θα αποσυνδέσει αυτόματα όλες τις εγκατεστημένες συνδέσεις."), - ("android_version_audio_tip", "Η έκδοση Android που διαθέτετε δεν υποστηρίζει εγγραφή ήχου, ενημερώστε το σε Android 10 ή νεότερη έκδοση, εάν είναι δυνατόν."), - ("android_start_service_tip", ""), - ("android_permission_may_not_change_tip", ""), + ("android_input_permission_tip2", "Παρακαλούμε να μεταβείτε στην επόμενη σελίδα ρυθμίσεων συστήματος, βρείτε και πληκτρολογήστε [Εγκατεστημένες υπηρεσίες], ενεργοποιήστε την υπηρεσία [Είσοδος RustDesk]."), + ("android_new_connection_tip", "Έχει ληφθεί νέο αίτημα ελέγχου, το οποίο θέλει να ελέγξει την τρέχουσα συσκευή σας."), + ("android_service_will_start_tip", "Η ενεργοποίηση της \"Καταγραφής οθόνης\" θα ξεκινήσει αυτόματα την υπηρεσία, επιτρέποντας σε άλλες συσκευές να ζητήσουν σύνδεση με τη συσκευή σας."), + ("android_stop_service_tip", "Το κλείσιμο της υπηρεσίας αυτής θα κλείσει αυτόματα όλες τις υπάρχουσες συνδέσεις."), + ("android_version_audio_tip", "Η τρέχουσα έκδοση Android δεν υποστηρίζει εγγραφή ήχου, αναβαθμίστε σε Android 10 ή νεότερη έκδοση."), + ("android_start_service_tip", "Πατήστε [Έναρξη υπηρεσίας] ή ενεργοποιήστε την άδεια [Καταγραφή οθόνης] για να ξεκινήσετε την υπηρεσία κοινής χρήσης οθόνης."), + ("android_permission_may_not_change_tip", "Τα δικαιώματα για τις καθιερωμένες συνδέσεις δεν μπορούν να αλλάξουν άμεσα μέχρι να επανασυνδεθούν."), ("Account", "Λογαριασμός"), ("Overwrite", "Αντικατάσταση"), ("This file exists, skip or overwrite this file?", "Αυτό το αρχείο υπάρχει, παράβλεψη ή αντικατάσταση αυτού του αρχείου;"), @@ -297,14 +293,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Succeeded", "Επιτυχής"), ("Someone turns on privacy mode, exit", "Κάποιος ενεργοποιεί τη λειτουργία απορρήτου, έξοδος"), ("Unsupported", "Δεν υποστηρίζεται"), - ("Peer denied", "Ο απομακρυσμένος σταθμός απέρριψε τη σύνδεση"), + ("Peer denied", "Ο απομακρυσμένος σταθμός έχει απορριφθεί"), ("Please install plugins", "Παρακαλώ εγκαταστήστε τα πρόσθετα"), ("Peer exit", "Ο απομακρυσμένος σταθμός έχει αποσυνδεθεί"), ("Failed to turn off", "Αποτυχία απενεργοποίησης"), ("Turned off", "Απενεργοποιημένο"), ("Language", "Γλώσσα"), - ("Keep RustDesk background service", "Εκτέλεση του RustDesk στο παρασκήνιο"), - ("Ignore Battery Optimizations", "Παράβλεψη βελτιστοποιήσεων μπαταρίας"), + ("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", "Η έναρξη της υπηρεσίας κοινής χρήσης οθόνης κατά την εκκίνηση, απαιτεί ειδικά δικαιώματα"), @@ -319,11 +315,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Restart remote device", "Επανεκκίνηση απομακρυσμένης συσκευής"), ("Are you sure you want to restart", "Είστε βέβαιοι ότι θέλετε να κάνετε επανεκκίνηση"), ("Restarting remote device", "Γίνεται επανεκκίνηση της απομακρυσμένης συσκευής"), - ("remote_restarting_tip", "Η απομακρυσμένη συσκευή επανεκκινείται, κλείστε αυτό το μήνυμα και επανασυνδεθείτε χρησιμοποιώντας τον μόνιμο κωδικό πρόσβασης."), + ("remote_restarting_tip", "Γίνεται επανεκκίνηση της απομακρυσμένης συσκευής. Κλείστε αυτό το πλαίσιο μηνύματος και επανασυνδεθείτε με τον μόνιμο κωδικό πρόσβασης μετά από λίγο."), ("Copied", "Αντιγράφηκε"), ("Exit Fullscreen", "Έξοδος από πλήρη οθόνη"), ("Fullscreen", "Πλήρης οθόνη"), - ("Mobile Actions", "Mobile Actions"), + ("Mobile Actions", "Ενέργειες για κινητά"), ("Select Monitor", "Επιλογή οθόνης"), ("Control Actions", "Ενέργειες ελέγχου"), ("Display Settings", "Ρυθμίσεις οθόνης"), @@ -351,7 +347,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Enable audio", "Ενεργοποίηση ήχου"), ("Unlock Network Settings", "Ξεκλείδωμα ρυθμίσεων δικτύου"), ("Server", "Διακομιστής"), - ("Direct IP Access", "Πρόσβαση με χρήση IP"), + ("Direct IP Access", "Άμεση πρόσβαση IP"), ("Proxy", "Διαμεσολαβητής"), ("Apply", "Εφαρμογή"), ("Disconnect all devices?", "Αποσύνδεση όλων των συσκευών;"), @@ -362,9 +358,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Pin Toolbar", "Καρφίτσωμα γραμμής εργαλείων"), ("Unpin Toolbar", "Ξεκαρφίτσωμα γραμμής εργαλείων"), ("Recording", "Εγγραφή"), - ("Directory", "Φάκελος εγγραφών"), + ("Directory", "Διαδρομή"), ("Automatically record incoming sessions", "Αυτόματη εγγραφή εισερχόμενων συνεδριών"), - ("Automatically record outgoing sessions", ""), + ("Automatically record outgoing sessions", "Αυτόματη εγγραφή εξερχόμενων συνεδριών"), ("Change", "Αλλαγή"), ("Start session recording", "Έναρξη εγγραφής συνεδρίας"), ("Stop session recording", "Διακοπή εγγραφής συνεδρίας"), @@ -377,24 +373,24 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("elevated_foreground_window_tip", "Το τρέχον παράθυρο της απομακρυσμένης επιφάνειας εργασίας απαιτεί υψηλότερα δικαιώματα για να λειτουργήσει, επομένως δεν μπορεί να χρησιμοποιήσει προσωρινά το ποντίκι και το πληκτρολόγιο. Μπορείτε να ζητήσετε από τον απομακρυσμένο χρήστη να ελαχιστοποιήσει το τρέχον παράθυρο ή να κάνετε κλικ στο κουμπί ανύψωσης στο παράθυρο διαχείρισης σύνδεσης. Για να αποφύγετε αυτό το πρόβλημα, συνιστάται η εγκατάσταση του λογισμικού στην απομακρυσμένη συσκευή."), ("Disconnected", "Αποσυνδέθηκε"), ("Other", "Άλλα"), - ("Confirm before closing multiple tabs", "Επιβεβαίωση πριν κλείσετε πολλές καρτέλες"), + ("Confirm before closing multiple tabs", "Επιβεβαίωση πριν κλείσουν πολλαπλές καρτέλες"), ("Keyboard Settings", "Ρυθμίσεις πληκτρολογίου"), ("Full Access", "Πλήρης πρόσβαση"), ("Screen Share", "Κοινή χρήση οθόνης"), - ("Wayland requires Ubuntu 21.04 or higher version.", "Το Wayland απαιτεί Ubuntu 21.04 ή νεότερη έκδοση."), - ("Wayland requires higher version of linux distro. Please try X11 desktop or change your OS.", "Το Wayland απαιτεί υψηλότερη έκδοση του linux distro. Δοκιμάστε την επιφάνεια εργασίας X11 ή αλλάξτε το λειτουργικό σας σύστημα."), - ("JumpLink", "Προβολή"), + ("ubuntu-21-04-required", "Το Wayland απαιτεί Ubuntu 21.04 ή νεότερη έκδοση."), + ("wayland-requires-higher-linux-version", "Το Wayland απαιτεί υψηλότερη έκδοση διανομής του linux. Δοκιμάστε την επιφάνεια εργασίας X11 ή αλλάξτε το λειτουργικό σας σύστημα."), + ("xdp-portal-unavailable", ""), + ("JumpLink", "Σύνδεσμος μετάβασης"), ("Please Select the screen to be shared(Operate on the peer side).", "Επιλέξτε την οθόνη που θέλετε να μοιραστείτε (Λειτουργία στην πλευρά του απομακρυσμένου σταθμού)."), - ("Show RustDesk", "Εμφάνιση RustDesk"), + ("Show RustDesk", "Εμφάνιση του RustDesk"), ("This PC", "Αυτός ο υπολογιστής"), ("or", "ή"), - ("Continue with", "Συνέχεια με"), ("Elevate", "Ανύψωση"), - ("Zoom cursor", "Kέρσορας μεγέθυνσης"), + ("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...", "Παρακαλώ περιμένετε μέχρι η απομακρυσμένη πλευρά να αποδεχτεί το αίτημα συνεδρίας σας..."), + ("Please wait for the remote side to accept your session request...", "Παρακαλώ περιμένετε μέχρι η απομακρυσμένη πλευρά να αποδεχτεί το αίτημα της συνεδρίας σας..."), ("One-time Password", "Κωδικός μίας χρήσης"), ("Use one-time password", "Χρήση κωδικού πρόσβασης μίας χρήσης"), ("One-time password length", "Μήκος κωδικού πρόσβασης μίας χρήσης"), @@ -403,27 +399,27 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("hide_cm_tip", "Να επιτρέπεται η απόκρυψη, μόνο εάν αποδέχεστε συνδέσεις μέσω κωδικού πρόσβασης και χρησιμοποιείτε μόνιμο κωδικό πρόσβασης"), ("wayland_experiment_tip", "Η υποστήριξη Wayland βρίσκεται σε πειραματικό στάδιο, χρησιμοποιήστε το X11 εάν χρειάζεστε πρόσβαση χωρίς επίβλεψη."), ("Right click to select tabs", "Κάντε δεξί κλικ για να επιλέξετε καρτέλες"), - ("Skipped", "Παράλειψη"), - ("Add to address book", "Προσθήκη στο Βιβλίο Διευθύνσεων"), + ("Skipped", "Παραλήφθηκε"), + ("Add to address book", "Προσθήκη στο βιβλίο διευθύνσεων"), ("Group", "Ομάδα"), ("Search", "Αναζήτηση"), - ("Closed manually by web console", "Κλειστό χειροκίνητα από την κονσόλα web"), + ("Closed manually by web console", "Κλείσιμο χειροκίνητα από την κονσόλα ιστού"), ("Local keyboard type", "Τύπος τοπικού πληκτρολογίου"), ("Select local keyboard type", "Επιλογή τύπου τοπικού πληκτρολογίου"), - ("software_render_tip", "Εάν έχετε κάρτα γραφικών Nvidia και το παράθυρο σύνδεσης κλείνει αμέσως μετά τη σύνδεση, η εγκατάσταση του προγράμματος οδήγησης nouveau και η επιλογή χρήσης της επιτάχυνσης γραφικών μέσω λογισμικού μπορεί να βοηθήσει. Απαιτείται επανεκκίνηση."), - ("Always use software rendering", "Επιτάχυνση γραφικών μέσω λογισμικού"), - ("config_input", "Για να ελέγξετε την απομακρυσμένη επιφάνεια εργασίας με πληκτρολόγιο, πρέπει να εκχωρήσετε δικαιώματα στο RustDesk"), - ("config_microphone", "Ρύθμιση μικροφώνου"), - ("request_elevation_tip", "αίτημα ανύψωσης δικαιωμάτων χρήστη"), + ("software_render_tip", "Εάν χρησιμοποιείτε κάρτα γραφικών της Nvidia σε Linux και το παράθυρο απομακρυσμένης πρόσβασης κλείνει αμέσως μετά τη σύνδεση, η μετάβαση στο πρόγραμμα οδήγησης της Nouveau ανοιχτού κώδικα και η επιλογή χρήσης απόδοσης λογισμικού μπορεί να βοηθήσει. Απαιτείται επανεκκίνηση του λογισμικού."), + ("Always use software rendering", "Να χρησιμοποιείτε πάντα η απόδοση λογισμικού"), + ("config_input", "Για να ελέγξετε την απομακρυσμένη επιφάνεια εργασίας με το πληκτρολόγιο, πρέπει να παραχωρήσετε στο RustDesk το δικαίωμα της \"Παρακολούθηση εισόδου\"."), + ("config_microphone", "Για να μιλήσετε εξ αποστάσεως, πρέπει να παραχωρήσετε στο RustDesk το δικαίωμα της \"Εγγραφή ήχου\"."), + ("request_elevation_tip", "Μπορείτε επίσης να ζητήσετε ανύψωση εάν υπάρχει κάποιος στην απομακρυσμένη πλευρά."), ("Wait", "Περιμένετε"), - ("Elevation Error", "Σφάλμα ανύψωσης δικαιωμάτων χρήστη"), + ("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", "Εξακολουθεί να απαιτεί από τον απομακρυσμένο χρήστη να κάνει κλικ στο OK στο παράθυρο UAC όπου εκτελείται το RustDesk."), - ("Request Elevation", "Αίτημα ανύψωσης δικαιωμάτων χρήστη"), - ("wait_accept_uac_tip", "Περιμένετε να αποδεχτεί ο απομακρυσμένος χρήστης το παράθυρο διαλόγου UAC."), - ("Elevate successfully", "Επιτυχής ανύψωση δικαιωμάτων χρήστη"), + ("Transmit the username and password of administrator", "Μεταδώστε το όνομα χρήστη και τον κωδικό πρόσβασης του διαχειριστή"), + ("still_click_uac_tip", "Εξακολουθεί να απαιτεί από τον απομακρυσμένο χρήστη να κάνει κλικ στο πλήκτρο Εντάξει στο παράθυρο UAC όπου εκτελείται το RustDesk."), + ("Request Elevation", "Αίτημα ανύψωσης"), + ("wait_accept_uac_tip", "Περιμένετε μέχρι ο απομακρυσμένος χρήστης να αποδεχτεί το παράθυρο διαλόγου UAC."), + ("Elevate successfully", "Επιτυχής ανύψωση"), ("uppercase", "κεφαλαία γράμματα"), ("lowercase", "πεζά γράμματα"), ("digit", "αριθμός"), @@ -432,7 +428,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Weak", "Αδύναμο"), ("Medium", "Μέτριο"), ("Strong", "Δυνατό"), - ("Switch Sides", "Εναλλαγή πλευράς"), + ("Switch Sides", "Αλλαγή πλευρών"), ("Please confirm if you want to share your desktop?", "Παρακαλώ επιβεβαιώστε αν επιθυμείτε την κοινή χρήση της επιφάνειας εργασίας;"), ("Display", "Εμφάνιση"), ("Default View Style", "Προκαθορισμένος τρόπος εμφάνισης"), @@ -446,11 +442,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Voice call", "Φωνητική κλήση"), ("Text chat", "Συνομιλία κειμένου"), ("Stop voice call", "Διακοπή φωνητικής κλήσης"), - ("relay_hint_tip", "Εάν δεν είναι δυνατή η απευθείας σύνδεση, μπορείτε να δοκιμάσετε να συνδεθείτε μέσω διακομιστή αναμετάδοσης"), + ("relay_hint_tip", "Ενδέχεται να μην είναι δυνατή η απευθείας σύνδεση: μπορείτε να δοκιμάσετε να συνδεθείτε μέσω αναμετάδοσης. Επιπλέον, εάν θέλετε να χρησιμοποιήσετε την αναμετάδοση στην πρώτη σας προσπάθεια, μπορείτε να προσθέσετε την \"/r\" κατάληξη στο ID ή να επιλέξετε την επιλογή \"Πάντα σύνδεση μέσω αναμετάδοσης\" στην κάρτα πρόσφατων συνεδριών, εάν υπάρχει."), ("Reconnect", "Επανασύνδεση"), ("Codec", "Κωδικοποίηση"), ("Resolution", "Ανάλυση"), - ("No transfers in progress", "Δεν υπάρχει μεταφορά σε εξέλιξη"), + ("No transfers in progress", "Δεν υπάρχουν μεταφορές σε εξέλιξη"), ("Set one-time password length", "Μέγεθος κωδικού μιας χρήσης"), ("RDP Settings", "Ρυθμίσεις RDP"), ("Sort by", "Ταξινόμηση κατά"), @@ -459,36 +455,35 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Minimize", "Ελαχιστοποίηση"), ("Maximize", "Μεγιστοποίηση"), ("Your Device", "Η συσκευή σας"), - ("empty_recent_tip", "Δεν υπάρχουν πρόσφατες συνεδρίες!\nΔοκιμάστε να ξεκινήσετε μια νέα."), - ("empty_favorite_tip", "Δεν υπάρχουν ακόμη αγαπημένες συνδέσεις;\nΑφού πραγματοποιήσετε σύνδεση με κάποιο απομακρυσμένο σταθμό, μπορείτε να τον προσθέσετε στα αγαπημένα σας!"), - ("empty_lan_tip", "Δεν έχουμε ανακαλυφθεί ακόμη απομακρυσμένοι σταθμοί."), - ("empty_address_book_tip", "Φαίνεται ότι αυτή τη στιγμή δεν υπάρχουν αγαπημένες συνδέσεις στο βιβλίο διευθύνσεών σας."), - ("eg: admin", "π.χ. admin"), + ("empty_recent_tip", "Ωχ, δεν υπάρχουν πρόσφατες συνεδρίες!\nΏρα να προγραμματίσετε μια νέα."), + ("empty_favorite_tip", "Δεν έχετε ακόμα αγαπημένους απομακρυσμένους σταθμούς;\nΑς βρούμε κάποιον για να συνδεθούμε και ας τον προσθέσουμε στα αγαπημένα σας!"), + ("empty_lan_tip", "Ωχ όχι, φαίνεται ότι δεν έχουμε ανακαλύψει ακόμη κανέναν απομακρυσμένο σταθμό."), + ("empty_address_book_tip", "Ω, Αγαπητέ/ή μου, φαίνεται ότι αυτήν τη στιγμή δεν υπάρχουν απομακρυσμένοι σταθμοί στο βιβλίο διευθύνσεών σας."), ("Empty Username", "Κενό όνομα χρήστη"), ("Empty Password", "Κενός κωδικός πρόσβασης"), ("Me", "Εγώ"), - ("identical_file_tip", "Το αρχείο είναι πανομοιότυπο με αυτό του άλλου υπολογιστή."), + ("identical_file_tip", "Αυτό το αρχείο είναι πανομοιότυπο με αυτό του απομακρυσμένου σταθμού."), ("show_monitors_tip", "Εμφάνιση οθονών στη γραμμή εργαλείων"), ("View Mode", "Λειτουργία προβολής"), - ("login_linux_tip", "Απαιτείται είσοδος σε απομακρυσμένο λογαριασμό Linux για την ενεργοποίηση του περιβάλλον εργασίας Χ."), + ("login_linux_tip", "Πρέπει να συνδεθείτε σε έναν απομακρυσμένο λογαριασμό Linux για να ενεργοποιήσετε μια συνεδρία επιφάνειας εργασίας X"), ("verify_rustdesk_password_tip", "Επιβεβαιώστε τον κωδικό του RustDesk"), ("remember_account_tip", "Απομνημόνευση αυτού του λογαριασμού"), - ("os_account_desk_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_title_tip", "Δεν υπάρχει διαθέσιμο περιβάλλον επιφάνειας εργασίας"), ("no_desktop_text_tip", "Παρακαλώ εγκαταστήστε το περιβάλλον GNOME"), - ("No need to elevate", "Δεν χρειάζονται αυξημένα δικαιώματα"), + ("No need to elevate", "Δεν χρειάζεται ανύψωση"), ("System Sound", "Ήχος συστήματος"), ("Default", "Προκαθορισμένο"), - ("New RDP", "Νέα απομακρυσμένη σύνδεση"), - ("Fingerprint", ""), - ("Copy Fingerprint", ""), - ("no fingerprints", ""), - ("Select a peer", "Επιλέξτε σταθμό"), + ("New RDP", "Νέα RDP"), + ("Fingerprint", "Δακτυλικό αποτύπωμα"), + ("Copy Fingerprint", "Αντιγραφή δακτυλικού αποτυπώματος"), + ("no fingerprints", "χωρίς δακτυλικά αποτυπώματα"), + ("Select a peer", "Επιλέξτε έναν σταθμό"), ("Select peers", "Επιλέξτε σταθμούς"), ("Plugins", "Επεκτάσεις"), ("Uninstall", "Κατάργηση εγκατάστασης"), @@ -499,10 +494,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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", "Έληξε ο χρόνος αναμονής για την ανταπόκριση της αντιγραφής"), + ("Collapse toolbar", "Σύμπτυξη γραμμής εργαλείων"), + ("Accept and Elevate", "Αποδοχή και ανύψωση"), + ("accept_and_elevate_btn_tooltip", "Αποδεχτείτε τη σύνδεση και ανυψώστε τα δικαιώματα UAC."), + ("clipboard_wait_response_timeout_tip", "Λήξη χρονικού ορίου αναμονής για απάντηση αντιγραφής."), ("Incoming connection", "Εισερχόμενη σύνδεση"), ("Outgoing connection", "Εξερχόμενη σύνδεση"), ("Exit", "Έξοδος"), @@ -511,7 +506,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Service", "Υπηρεσία"), ("Start", "Έναρξη"), ("Stop", "Διακοπή"), - ("exceed_max_devices", "Έχετε ξεπεράσει το μέγιστο όριο αποθηκευμένων συνδέσεων"), + ("exceed_max_devices", "Έχετε φτάσει τον μέγιστο αριθμό διαχειριζόμενων συσκευών."), ("Sync with recent sessions", "Συγχρονισμός των πρόσφατων συνεδριών"), ("Sort tags", "Ταξινόμηση ετικετών"), ("Open connection in new tab", "Άνοιγμα σύνδεσης σε νέα καρτέλα"), @@ -520,14 +515,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Already exists", "Υπάρχει ήδη"), ("Change Password", "Αλλαγή κωδικού"), ("Refresh Password", "Ανανέωση κωδικού"), - ("ID", ""), + ("ID", "ID"), ("Grid View", "Προβολή σε πλακίδια"), ("List View", "Προβολή σε λίστα"), ("Select", "Επιλογή"), ("Toggle Tags", "Εναλλαγή ετικετών"), - ("pull_ab_failed_tip", "Αποτυχία ανανέωσης βιβλίου διευθύνσεων"), - ("push_ab_failed_tip", "Αποτυχία συγχρονισμού βιβλίου διευθύνσεων"), - ("synced_peer_readded_tip", "Οι συσκευές των τρεχουσών συνεδριών θα συγχρονιστούν με το βιβλίο διευθύνσεων"), + ("pull_ab_failed_tip", "Η ανανέωση του βιβλίου διευθύνσεων απέτυχε"), + ("push_ab_failed_tip", "Αποτυχία συγχρονισμού του βιβλίου διευθύνσεων με τον διακομιστή"), + ("synced_peer_readded_tip", "Οι συσκευές που υπήρχαν στις πρόσφατες συνεδρίες θα συγχρονιστούν ξανά με το βιβλίο διευθύνσεων."), ("Change Color", "Αλλαγή χρώματος"), ("Primary Color", "Κυρίως χρώμα"), ("HSV Color", "Χρώμα HSV"), @@ -542,31 +537,31 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("I Agree", "Συμφωνώ"), ("Decline", "Διαφωνώ"), ("Timeout in minutes", "Τέλος χρόνου σε λεπτά"), - ("auto_disconnect_option_tip", "Αυτόματη αποσύνδεση απομακρυσμένης συνεδρίας έπειτα από την πάροδο του χρονικού ορίου αδράνειας "), + ("auto_disconnect_option_tip", "Αυτόματο κλείσιμο εισερχόμενων συνεδριών σε περίπτωση αδράνειας χρήστη"), ("Connection failed due to inactivity", "Η σύνδεση τερματίστηκε έπειτα από την πάροδο του χρόνου αδράνειας"), - ("Check for software update on startup", "Έλεγχος για ενημερώσεις κατα την εκκίνηση"), - ("upgrade_rustdesk_server_pro_to_{}_tip", "Παρακαλώ ενημερώστε τον RustDesk Server Pro στην έκδοση {} ή νεότερη!"), + ("Check for software update on startup", "Έλεγχος για ενημερώσεις κατά την εκκίνηση"), + ("upgrade_rustdesk_server_pro_to_{}_tip", "Παρακαλώ ενημερώστε το RustDesk Server Pro στην έκδοση {} ή νεότερη!"), ("pull_group_failed_tip", "Αποτυχία ανανέωσης της ομάδας"), - ("Filter by intersection", ""), + ("Filter by intersection", "Φιλτράρισμα κατά διασταύρωση"), ("Remove wallpaper during incoming sessions", "Αφαίρεση εικόνας φόντου στις εισερχόμενες συνδέσεις"), ("Test", "Δοκιμή"), - ("display_is_plugged_out_msg", "Η οθόνη έχει αποσυνδεθεί, επιστρέψτε στην κύρια οθόνη προβολής"), + ("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", "Έχετε ενεργοποιημένο το SELinux, το οποίο πιθανόν εμποδίζει την ορθή λειτουργία του RustDesk."), + ("selinux_tip", "Το SELinux είναι ενεργοποιημένο στη συσκευή σας, κάτι που ενδέχεται να εμποδίσει την σωστή λειτουργία του RustDesk ως ελεγχόμενης πλευράς."), ("Change view", "Αλλαγή απεικόνισης"), ("Big tiles", "Μεγάλα εικονίδια"), ("Small tiles", "Μικρά εικονίδια"), ("List", "Λίστα"), ("Virtual display", "Εινονική οθόνη"), ("Plug out all", "Αποσύνδεση όλων"), - ("True color (4:4:4)", ""), + ("True color (4:4:4)", "Αληθινό χρώμα (4:4:4)"), ("Enable blocking user input", "Ενεργοποίηση αποκλεισμού χειρισμού από τον χρήστη"), - ("id_input_tip", "Μπορείτε να εισάγετε ενα ID, μια διεύθυνση IP, ή ένα όνομα τομέα με την αντίστοιχη πόρτα (:).\nΑν θέλετε να συνδεθείτε σε μια συσκευή σε άλλο διακομιστή, παρακαλώ να προσθέσετε και την διεύθυνση του διακομιστή (@?key=), για παράδειγμα,\n9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=.\nΑν θέλετε να συνδεθείτε σε κάποιο δημόσιο διακομιστή, προσθέστε το όνομά του \"@public\", η παράμετρος key δεν απαιτείται για τους δημόσιους διακομιστές."), - ("privacy_mode_impl_mag_tip", "Προφύλαξη Οθόνης"), - ("privacy_mode_impl_virtual_display_tip", "Εικονική Οθόνη"), + ("id_input_tip", "Μπορείτε να εισάγετε ένα ID, μια διεύθυνση IP, ή ένα όνομα τομέα με την αντίστοιχη πόρτα (:).\nΑν θέλετε να συνδεθείτε σε μια συσκευή σε άλλο διακομιστή, παρακαλώ να προσθέσετε και την διεύθυνση του διακομιστή (@?key=), για παράδειγμα,\n9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=.\nΑν θέλετε να συνδεθείτε σε κάποιο δημόσιο διακομιστή, προσθέστε το όνομά του \"@public\", η παράμετρος key δεν απαιτείται για τους δημόσιους διακομιστές."), + ("privacy_mode_impl_mag_tip", "Λειτουργία 1"), + ("privacy_mode_impl_virtual_display_tip", "Λειτουργία 2"), ("Enter privacy mode", "Ενεργοποίηση λειτουργίας απορρήτου"), ("Exit privacy mode", "Διακοπή λειτουργίας απορρήτου"), ("idd_not_support_under_win10_2004_tip", "Το πρόγραμμα οδήγησης έμμεσης οθόνης δεν υποστηρίζεται. Απαιτείτε λειτουργικό σύστημα Windows 10 έκδοση 2004 ή νεότερο."), @@ -576,82 +571,179 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("swap-left-right-mouse", "Εναλλαγή αριστερό-δεξί κουμπί του ποντικιού"), ("2FA code", "κωδικός 2FA"), ("More", "Περισσότερα"), - ("enable-2fa-title", "Ενεργοποίηση Πιστοποίησης Δύο Παραγόντων"), - ("enable-2fa-desc", "Ρυθμίστε τον έλεγχο ταυτότητας τώρα. Μπορείτε να χρησιμοποιήσετε μια εφαρμογή ελέγχου ταυτότητας όπως Authy, Microsoft ή Google Authenticator στο τηλέφωνο ή στην επιφάνεια εργασίας σας.Σαρώστε τον κωδικό QR με την εφαρμογή σας και εισαγάγετε τον κωδικό που εμφανίζει η εφαρμογή σας για να ενεργοποιήσετε τον έλεγχο ταυτότητας δύο παραγόντων."), - ("wrong-2fa-code", "Δεν είναι δυνατή η επαλήθευση του κωδικού. Ελέγξτε ότι ο κωδικός και οι ρυθμίσεις τοπικής ώρας είναι σωστές"), + ("enable-2fa-title", "Ενεργοποίηση πιστοποίησης δύο παραγόντων"), + ("enable-2fa-desc", "Παρακαλούμε να ρυθμίστε τώρα τον έλεγχο ταυτότητας. Μπορείτε να χρησιμοποιήσετε μια εφαρμογή ελέγχου ταυτότητας όπως το Authy, το Microsoft ή το Google Authenticator στο τηλέφωνο ή τον υπολογιστή σας.\n\nΣαρώστε τον κωδικό QR με την εφαρμογή σας και εισαγάγετε τον κωδικό που εμφανίζει η εφαρμογή σας για να ενεργοποιήσετε τον έλεγχο ταυτότητας δύο παραγόντων."), + ("wrong-2fa-code", "Δεν είναι δυνατή η επαλήθευση του κωδικού. Ελέγξτε ότι οι ρυθμίσεις κωδικού και τοπικής ώρας είναι σωστές."), ("enter-2fa-title", "Έλεγχος ταυτότητας δύο παραγόντων"), - ("Email verification code must be 6 characters.", "Ο κωδικός επαλήθευσης email πρέπει να είναι εως 6 χαρακτήρες"), + ("Email verification code must be 6 characters.", "Ο κωδικός επαλήθευσης email πρέπει να είναι έως 6 χαρακτήρες"), ("2FA code must be 6 digits.", "Ο κωδικός 2FA πρέπει να είναι 6ψήφιος."), - ("Multiple Windows sessions found", ""), + ("Multiple Windows sessions found", "Βρέθηκαν πολλές συνεδρίες των Windows"), ("Please select the session you want to connect to", "Επιλέξτε τη συνεδρία στην οποία θέλετε να συνδεθείτε"), - ("powered_by_me", "Με την υποστήριξη της RustDesk"), - ("outgoing_only_desk_tip", ""), - ("preset_password_warning", "προειδοποίηση προκαθορισμένου κωδικού πρόσβασης"), + ("powered_by_me", "Με την υποστήριξη του RustDesk"), + ("outgoing_only_desk_tip", "Αυτή είναι μια προσαρμοσμένη έκδοση.\nΜπορείτε να συνδεθείτε με άλλες συσκευές, αλλά άλλες συσκευές δεν μπορούν να συνδεθούν με τη δική σας συσκευή."), + ("preset_password_warning", "Αυτή η προσαρμοσμένη έκδοση συνοδεύεται από έναν προκαθορισμένο κωδικό πρόσβασης. Όποιος γνωρίζει αυτόν τον κωδικό πρόσβασης θα μπορούσε να αποκτήσει τον πλήρη έλεγχο της συσκευής σας. Εάν δεν το περιμένατε αυτό, απεγκαταστήστε αμέσως το λογισμικό."), ("Security Alert", "Ειδοποίηση ασφαλείας"), ("My address book", "Το βιβλίο διευθύνσεών μου"), ("Personal", "Προσωπικό"), ("Owner", "Ιδιοκτήτης"), - ("Set shared password", "Ορίστε κοινόχρηστο κωδικό πρόσβασης"), - ("Exist in", ""), + ("Set shared password", "Ορίστε έναν κοινόχρηστο κωδικό πρόσβασης"), + ("Exist in", "Υπάρχει στο"), ("Read-only", "Μόνο για ανάγνωση"), - ("Read/Write", ""), - ("Full Control", "Πλήρης Έλεγχος"), + ("Read/Write", "Ανάγνωση/Εγγραφή"), + ("Full Control", "Πλήρης έλεγχος"), ("share_warning_tip", "Τα παραπάνω πεδία είναι κοινόχρηστα και ορατά σε άλλους."), - ("Everyone", ""), - ("ab_web_console_tip", ""), + ("Everyone", "Όλοι"), + ("ab_web_console_tip", "Περισσότερα στην κονσόλα web"), ("allow-only-conn-window-open-tip", "Να επιτρέπεται η σύνδεση μόνο εάν το παράθυρο RustDesk είναι ανοιχτό"), ("no_need_privacy_mode_no_physical_displays_tip", "Δεν υπάρχουν φυσικές οθόνες, δεν χρειάζεται να χρησιμοποιήσετε τη λειτουργία απορρήτου."), - ("Follow remote cursor", ""), - ("Follow remote window focus", ""), - ("default_proxy_tip", ""), - ("no_audio_input_device_tip", ""), + ("Follow remote cursor", "Παρακολούθηση απομακρυσμένου κέρσορα"), + ("Follow remote window focus", "Παρακολούθηση απομακρυσμένου ενεργού παραθύρου"), + ("default_proxy_tip", "Το προεπιλεγμένο πρωτόκολλο και η θύρα είναι Socks5 και 1080"), + ("no_audio_input_device_tip", "Δεν βρέθηκε συσκευή εισόδου ήχου."), ("Incoming", "Εισερχόμενη"), ("Outgoing", "Εξερχόμενη"), - ("Clear Wayland screen selection", ""), - ("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", ""), + ("Clear Wayland screen selection", "Εκκαθάριση επιλογής οθόνης Wayland"), + ("clear_Wayland_screen_selection_tip", "Αφού διαγράψετε την επιλογή οθόνης, μπορείτε να επιλέξετε ξανά την οθόνη για κοινή χρήση."), + ("confirm_clear_Wayland_screen_selection_tip", "Είστε βέβαιοι ότι θέλετε να διαγράψετε την επιλογή οθόνης Wayland;"), + ("android_new_voice_call_tip", "Ελήφθη ένα νέο αίτημα φωνητικής κλήσης. Εάν το αποδεχτείτε, ο ήχος θα μεταβεί σε φωνητική επικοινωνία."), + ("texture_render_tip", "Χρησιμοποιήστε την απόδοση υφής για να κάνετε τις εικόνες πιο ομαλές. Μπορείτε να δοκιμάσετε να απενεργοποιήσετε αυτήν την επιλογή εάν αντιμετωπίσετε προβλήματα απόδοσης."), + ("Use texture rendering", "Χρήση απόδοσης υφής"), + ("Floating window", "Πλωτό παράθυρο"), + ("floating_window_tip", "Βοηθά στη διατήρηση της υπηρεσίας παρασκηνίου RustDesk"), ("Keep screen on", "Διατήρηση οθόνης Ανοιχτή"), ("Never", "Ποτέ"), ("During controlled", "Κατα την διάρκεια απομακρυσμένου ελέγχου"), ("During service is on", "Κατα την εκκίνηση της υπηρεσίας Rustdesk"), - ("Capture screen using DirectX", ""), + ("Capture screen using DirectX", "Καταγραφή οθόνης με χρήση DirectX"), ("Back", "Πίσω"), ("Apps", "Εφαρμογές"), - ("Volume up", ""), - ("Volume down", ""), - ("Power", ""), - ("Telegram bot", ""), + ("Volume up", "Αύξηση έντασης"), + ("Volume down", "Μείωση έντασης"), + ("Power", "Ενέργεια"), + ("Telegram bot", "Telegram bot"), ("enable-bot-tip", "Εάν ενεργοποιήσετε αυτήν τη δυνατότητα, μπορείτε να λάβετε τον κωδικό 2FA από το bot σας. Μπορεί επίσης να λειτουργήσει ως ειδοποίηση σύνδεσης."), ("enable-bot-desc", "1, Ανοίξτε μια συνομιλία με τον @BotFather., Στείλτε την εντολή \"/newbot\". Θα λάβετε ένα διακριτικό αφού ολοκληρώσετε αυτό το βήμα.3, Ξεκινήστε μια συνομιλία με το bot που μόλις δημιουργήσατε. Στείλτε ένα μήνυμα που αρχίζει με κάθετο (\"/\") όπως \"/hello\" για να το ενεργοποιήσετε."), ("cancel-2fa-confirm-tip", "Είστε βέβαιοι ότι θέλετε να ακυρώσετε το 2FA;"), ("cancel-bot-confirm-tip", "Είστε βέβαιοι ότι θέλετε να ακυρώσετε το Telegram bot;"), - ("About RustDesk", ""), - ("Send clipboard keystrokes", ""), - ("network_error_tip", ""), - ("Unlock with PIN", ""), - ("Requires at least {} characters", ""), - ("Wrong PIN", ""), - ("Set 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", ""), + ("About RustDesk", "Πληροφορίες για το 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", "Παράβλεψη επαλήθευσης 2FA σε αξιόπιστες συσκευές."), + ("Parent directory", "Γονικός φάκελος"), + ("Resume", "Συνέχεια"), + ("Invalid file name", "Μη έγκυρο όνομα αρχείου"), + ("one-way-file-transfer-tip", "Η μονόδρομη μεταφορά αρχείων είναι ενεργοποιημένη στην ελεγχόμενη πλευρά."), + ("Authentication Required", "Απαιτείται έλεγχος ταυτότητας"), + ("Authenticate", "Πιστοποίηση"), + ("web_id_input_tip", "Μπορείτε να εισαγάγετε ένα ID στον ίδιο διακομιστή, η άμεση πρόσβαση IP δεν υποστηρίζεται στον web client.\nΕάν θέλετε να αποκτήσετε πρόσβαση σε μια συσκευή σε άλλον διακομιστή, παρακαλούμε να προσθέστε τη διεύθυνση διακομιστή (@?key=), για παράδειγμα,\n9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=.\nΕάν θέλετε να αποκτήσετε πρόσβαση σε μια συσκευή σε δημόσιο διακομιστή, παρακαλούμε να εισαγάγετε \"@public\". Το κλειδί δεν είναι απαραίτητο για δημόσιο διακομιστή."), + ("Download", "Λήψη"), + ("Upload folder", "Μεταφόρτωση φακέλου"), + ("Upload files", "Μεταφόρτωση αρχείων"), + ("Clipboard is synchronized", "Το πρόχειρο έχει συγχρονιστεί"), + ("Update client clipboard", "Ενημέρωση απομακρισμένου προχείρου"), + ("Untagged", "Χωρίς ετικέτα"), + ("new-version-of-{}-tip", "Υπάρχει διαθέσιμη νέα έκδοση του {}"), + ("Accessible devices", "Προσβάσιμες συσκευές"), + ("upgrade_remote_rustdesk_client_to_{}_tip", "Αναβαθμίστε τον πελάτη RustDesk στην έκδοση {} ή νεότερη στην απομακρυσμένη πλευρά!"), + ("d3d_render_tip", "Όταν είναι ενεργοποιημένη η απόδοση D3D, η οθόνη του τηλεχειριστηρίου ενδέχεται να είναι μαύρη σε ορισμένα μηχανήματα."), + ("Use D3D rendering", "Χρήση απόδοσης D3D"), + ("Printer", "Εκτυπωτής"), + ("printer-os-requirement-tip", "Η λειτουργία εξερχόμενης εκτύπωσης του εκτυπωτή απαιτεί Windows 10 ή νεότερη έκδοση."), + ("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", "Όταν χρησιμοποιείτε το WebSocket, υποστηρίζονται μόνο συνδέσεις αναμετάδοσης."), + ("Use WebSocket", "Χρήση WebSocket"), + ("Trackpad speed", "Ταχύτητα trackpad"), + ("Default trackpad speed", "Προεπιλεγμένη ταχύτητα trackpad"), + ("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", "Εμφάνιση εικονικού joystick"), + ("Edit note", "Επεξεργασία σημείωσης"), + ("Alias", "Ψευδώνυμο"), + ("ScrollEdge", "Άκρη κύλισης"), + ("Allow insecure TLS fallback", "Να επιτρέπεται η μη ασφαλής εφεδρική λειτουργία TLS"), + ("allow-insecure-tls-fallback-tip", "Από προεπιλογή, το RustDesk επαληθεύει το πιστοποιητικό διακομιστή για πρωτόκολλα που χρησιμοποιούν TLS.\nΜε ενεργοποιημένη αυτήν την επιλογή, το RustDesk θα παρακάμψει το βήμα επαλήθευσης και θα προχωρήσει σε περίπτωση αποτυχίας επαλήθευσης."), + ("Disable UDP", "Απενεργοποίηση UDP"), + ("disable-udp-tip", "Ελέγχει εάν θα χρησιμοποιείται μόνο TCP.\nΌταν είναι ενεργοποιημένη αυτή η επιλογή, το RustDesk δεν θα χρησιμοποιεί πλέον το UDP 21116, αλλά θα χρησιμοποιείται το TCP 21116."), + ("server-oss-not-support-tip", "ΣΗΜΕΙΩΣΗ: Το OSS του διακομιστή RustDesk δεν περιλαμβάνει αυτήν τη λειτουργία."), + ("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 {}", "Συνέχεια με {}"), + ("Display Name", "Εμφανιζόμενο όνομα"), + ("password-hidden-tip", ""), + ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/en.rs b/src/lang/en.rs index 7ed83a8fe..595169b8a 100644 --- a/src/lang/en.rs +++ b/src/lang/en.rs @@ -5,7 +5,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("connecting_status", "Connecting to the RustDesk network..."), ("not_ready_status", "Not ready. Please check your connection"), ("ID/Relay Server", "ID/Relay server"), - ("id_change_tip", "Only a-z, A-Z, 0-9 and _ (underscore) characters allowed. The first letter must be a-z, A-Z. Length between 6 and 16."), + ("id_change_tip", "Only a-z, A-Z, 0-9, - (dash) and _ (underscore) characters allowed. The first letter must be a-z, A-Z. Length between 6 and 16."), ("Slogan_tip", "Made with heart in this chaotic world!"), ("Build Date", "Build date"), ("Audio Input", "Audio input"), @@ -77,12 +77,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Canvas Move", "Canvas move"), ("Pinch to Zoom", "Pinch to zoom"), ("Canvas Zoom", "Canvas zoom"), - ("Share Screen", "Share screen"), ("Screen Capture", "Screen capture"), ("Input Control", "Input control"), ("Audio Capture", "Audio capture"), - ("File Connection", "File connection"), - ("Screen Connection", "Screen connection"), ("Open System Setting", "Open system setting"), ("android_input_permission_tip1", "In order for a remote device to control your Android device via mouse or touch, you need to allow RustDesk to use the \"Accessibility\" service."), ("android_input_permission_tip2", "Please go to the next system settings page, find and enter [Installed Services], turn on [RustDesk Input] service."), @@ -123,6 +120,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Keyboard Settings", "Keyboard settings"), ("Full Access", "Full access"), ("Screen Share", "Screen share"), + ("ubuntu-21-04-required", "Wayland requires Ubuntu 21.04 or higher version."), + ("wayland-requires-higher-linux-version", "Wayland requires higher version of linux distro. Please try X11 desktop or change your OS."), + ("xdp-portal-unavailable", "Wayland screen capture failed. The XDG Desktop Portal may have crashed or is unavailable. Try restarting it with `systemctl --user restart xdg-desktop-portal`."), ("JumpLink", "View"), ("Please Select the screen to be shared(Operate on the peer side).", "Please select the screen to be shared(Operate on the peer side)."), ("One-time Password", "One-time password"), @@ -223,7 +223,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("default_proxy_tip", "Default protocol and port are Socks5 and 1080"), ("no_audio_input_device_tip", "No audio input device found."), ("clear_Wayland_screen_selection_tip", "After clearing the screen selection, you can reselect the screen to share."), - ("confirm_clear_Wayland_screen_selection_tip", "Are you sure to clear the Wayland screen selection?"), + ("confirm_clear_Wayland_screen_selection_tip", "Are you sure you want to clear the Wayland screen selection?"), ("android_new_voice_call_tip", "A new voice call request was received. If you accept, the audio will switch to voice communication."), ("texture_render_tip", "Use texture rendering to make the pictures smoother. You could try disabling this option if you encounter rendering issues."), ("floating_window_tip", "It helps to keep RustDesk background service"), @@ -236,5 +236,44 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("enable-trusted-devices-tip", "Skip 2FA verification on trusted devices"), ("one-way-file-transfer-tip", "One-way file transfer is enabled on the controlled side."), ("web_id_input_tip", "You can input an ID in the same server, direct IP access is not supported in web client.\nIf you want to access a device on another server, please append the server address (@?key=), for example,\n9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=.\nIf you want to access a device on a public server, please input \"@public\", the key is not needed for public server."), + ("new-version-of-{}-tip", "There is a new version of {} available"), + ("View camera", "View camera"), + ("upgrade_remote_rustdesk_client_to_{}_tip", "Please upgrade the RustDesk client to version {} or newer on the remote side!"), + ("view_camera_unsupported_tip", "The remote device does not support viewing the camera."), + ("d3d_render_tip", "When D3D rendering is enabled, the remote control screen may be black on some machines."), + ("printer-requires-installed-{}-client-tip", "In order to use remote printing, {} needs to be installed on this device."), + ("printer-os-requirement-tip", "The printer outgoing function requires Windows 10 or higher."), + ("printer-{}-not-installed-tip", "The {} Printer is not installed."), + ("printer-{}-ready-tip", "The {} Printer is installed and ready to use."), + ("auto-print-tip", "Print automatically using the selected printer."), + ("print-incoming-job-confirm-tip", "You received a print job from remote. Do you want to execute it at your side?"), + ("use-the-default-printer-tip", "Use the default printer"), + ("use-the-selected-printer-tip", "Use the selected printer"), + ("remote-printing-disallowed-tile-tip", "Remote Printing disallowed"), + ("remote-printing-disallowed-text-tip", "The permission settings of the controlled side deny Remote Printing."), + ("save-settings-tip", "Save settings"), + ("dont-show-again-tip", "Don't show this again"), + ("screenshot-merged-screen-not-supported-tip", "Merging screenshots of multiple displays is currently not supported. Please switch to a single display and try again."), + ("screenshot-action-tip", "Please select how to continue with the screenshot."), + ("{}-to-update-tip", "{} will close now and install the new version."), + ("download-new-version-failed-tip", "Download failed. You can try again or click the \"Download\" button to download from the release page and upgrade manually."), + ("update-failed-check-msi-tip", "Installation method check failed. Please click the \"Download\" button to download from the release page and upgrade manually."), + ("websocket_tip", "When using WebSocket, only relay connections are supported."), + ("terminal-admin-login-tip", "Please input the administrator username and password of the controlled side."), + ("elevation_username_tip", "Input username or domain\\username"), + ("allow-insecure-tls-fallback-tip", "By default, RustDesk verifies the server certificate for protocols using TLS.\nWith this option enabled, RustDesk will fall back to skipping the verification step and proceed in case of verification failure."), + ("disable-udp-tip", "Controls whether to use TCP only.\nWhen this option enabled, RustDesk will not use UDP 21116 any more, TCP 21116 will be used instead."), + ("server-oss-not-support-tip", "NOTE: RustDesk server OSS doesn't include this feature."), + ("note-at-conn-end-tip", "Ask for note at end of connection"), + ("rel-mouse-not-supported-peer-tip", "Relative Mouse Mode is not supported by the connected peer."), + ("rel-mouse-not-ready-tip", "Relative Mouse Mode is not ready yet. Please try again."), + ("rel-mouse-lock-failed-tip", "Failed to lock cursor. Relative Mouse Mode has been disabled."), + ("rel-mouse-exit-{}-tip", "Press {} to exit."), + ("rel-mouse-permission-lost-tip", "Keyboard permission was revoked. Relative Mouse Mode has been disabled."), + ("keep-awake-during-outgoing-sessions-label", "Keep screen awake during outgoing sessions"), + ("keep-awake-during-incoming-sessions-label", "Keep screen awake during incoming sessions"), + ("password-hidden-tip", "Permanent password is set (hidden)."), + ("preset-password-in-use-tip", "Preset password is currently in use."), + ("allow-remote-toolbar-docking-any-edge", "Allow docking remote toolbar to any window edge"), ].iter().cloned().collect(); } diff --git a/src/lang/eo.rs b/src/lang/eo.rs index 876c901a4..131a85fbf 100644 --- a/src/lang/eo.rs +++ b/src/lang/eo.rs @@ -41,7 +41,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("length %min% to %max%", "longeco %min% al %max%"), ("starts with a letter", "komencas kun letero"), ("allowed characters", "permesitaj signoj"), - ("id_change_tip", "Nur la signoj a-z, A-Z, 0-9, _ (substreko) povas esti uzataj. La unua litero povas esti inter a-z, A-Z. La longeco devas esti inter 6 kaj 16."), + ("id_change_tip", "Nur la signoj a-z, A-Z, 0-9, - (dash), _ (substreko) povas esti uzataj. La unua litero povas esti inter a-z, A-Z. La longeco devas esti inter 6 kaj 16."), ("Website", "Retejo"), ("About", "Pri"), ("Slogan_tip", "Farita kun koro en ĉi tiu ĥaosa mondo!"), @@ -147,8 +147,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("OS Password", "Pasvorto de la operaciumo"), ("install_tip", "Vi ne uzas instalita versio. Pro limigoj pro UAC, kiel aparato kontrolata, en kelkaj kazoj, ne estos ebla kontroli la muson kaj klavaron aŭ registri la ekranon. Bonvolu alkliku la butonon malsupre por instali RustDesk sur la operaciumo por eviti la demando supre."), ("Click to upgrade", "Alklaki por plibonigi"), - ("Click to download", "Alklaki por elŝuti"), - ("Click to update", "Alklaki por ĝisdatigi"), ("Configure", "Konfiguri"), ("config_acc", "Por uzi vian foran aparaton, bonvolu doni la permeson \"alirebleco\" al RustDesk."), ("config_screen", "Por uzi vian foran aparaton, bonvolu doni la permeson \"ekranregistrado\" al RustDesk."), @@ -267,7 +265,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("No permission of file transfer", "Neniu permeso de dosiertransigo"), ("Note", "Notu"), ("Connection", "Konekto"), - ("Share Screen", "Kunhavigi Ekranon"), + ("Share screen", "Kunhavigi Ekranon"), ("Chat", "Babilo"), ("Total", "Sumo"), ("items", "eroj"), @@ -275,8 +273,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Screen Capture", "Ekrankapto"), ("Input Control", "Eniga Kontrolo"), ("Audio Capture", "Sonkontrolo"), - ("File Connection", "Dosiero Konekto"), - ("Screen Connection", "Ekrono konekto"), ("Do you accept?", "Ĉu vi akceptas?"), ("Open System Setting", "Malfermi Sistemajn Agordojn"), ("How to get Android input permission?", "Kiel akiri Android enigajn permesojn"), @@ -381,14 +377,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Keyboard Settings", ""), ("Full Access", ""), ("Screen Share", ""), - ("Wayland requires Ubuntu 21.04 or higher version.", "Wayland postulas Ubuntu 21.04 aŭ pli altan version."), - ("Wayland requires higher version of linux distro. Please try X11 desktop or change your OS.", "Wayland postulas pli altan version de linuksa distro. Bonvolu provi X11-labortablon aŭ ŝanĝi vian OS."), + ("ubuntu-21-04-required", "Wayland postulas Ubuntu 21.04 aŭ pli altan version."), + ("wayland-requires-higher-linux-version", "Wayland postulas pli altan version de linuksa distro. Bonvolu provi X11-labortablon aŭ ŝanĝi vian OS."), + ("xdp-portal-unavailable", ""), ("JumpLink", "View"), ("Please Select the screen to be shared(Operate on the peer side).", "Bonvolu Elekti la ekranon por esti dividita (Funkciu ĉe la sama flanko)."), ("Show RustDesk", ""), ("This PC", ""), ("or", ""), - ("Continue with", ""), ("Elevate", ""), ("Zoom cursor", ""), ("Accept sessions via password", ""), @@ -463,7 +459,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("empty_favorite_tip", ""), ("empty_lan_tip", ""), ("empty_address_book_tip", ""), - ("eg: admin", ""), ("Empty Username", ""), ("Empty Password", ""), ("Me", ""), @@ -653,5 +648,102 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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", ""), + ("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", ""), + ("Trackpad speed", ""), + ("Default trackpad speed", ""), + ("Numeric one-time password", ""), + ("Enable IPv6 P2P connection", ""), + ("Enable UDP hole punching", ""), + ("View camera", "Rigardi kameron"), + ("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", ""), + ("allow-insecure-tls-fallback-tip", ""), + ("Disable 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 {}", ""), + ("Display Name", ""), + ("password-hidden-tip", ""), + ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/es.rs b/src/lang/es.rs index ce77f620c..5e73b58a8 100644 --- a/src/lang/es.rs +++ b/src/lang/es.rs @@ -41,7 +41,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("length %min% to %max%", "de %min% a %max% de longitud"), ("starts with a letter", "comenzar con una letra"), ("allowed characters", "Caracteres permitidos"), - ("id_change_tip", "Solo puedes usar caracteres a-z, A-Z, 0-9 e _ (guion bajo). El primer carácter debe ser a-z o A-Z. La longitud debe estar entre 6 y 16 caracteres."), + ("id_change_tip", "Solo puedes usar caracteres a-z, A-Z, 0-9, - (dash) e _ (guion bajo). El primer carácter debe ser a-z o A-Z. La longitud debe estar entre 6 y 16 caracteres."), ("Website", "Sitio web"), ("About", "Acerca de"), ("Slogan_tip", "¡Hecho con corazón en este mundo caótico!"), @@ -147,8 +147,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("OS Password", "Contraseña del sistema operativo"), ("install_tip", "Debido al Control de cuentas de usuario, es posible que RustDesk no funcione correctamente como escritorio remoto. Para evitar este problema, haga clic en el botón de abajo para instalar RustDesk a nivel de sistema."), ("Click to upgrade", "Clic para actualizar"), - ("Click to download", "Clic para descargar"), - ("Click to update", "Clic para refrescar"), ("Configure", "Configurar"), ("config_acc", "Para controlar su escritorio desde el exterior, debe otorgar permiso a RustDesk de \"Accesibilidad\"."), ("config_screen", "Para controlar su escritorio desde el exterior, debe otorgar permiso a RustDesk de \"Grabación de pantalla\"."), @@ -210,7 +208,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Closed manually by the peer", "Cerrado manualmente por el par"), ("Enable remote configuration modification", "Habilitar modificación remota de configuración"), ("Run without install", "Ejecutar sin instalar"), - ("Connect via relay", ""), + ("Connect via relay", "Conectar a través de relay"), ("Always connect via relay", "Conéctese siempre a través de relay"), ("whitelist_tip", "Solo las direcciones IP autorizadas pueden conectarse a este escritorio"), ("Login", "Iniciar sesión"), @@ -230,7 +228,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Username missed", "Olvidó su nombre de usuario"), ("Password missed", "Olvidó su contraseña"), ("Wrong credentials", "Credenciales incorrectas"), - ("The verification code is incorrect or has expired", ""), + ("The verification code is incorrect or has expired", "El código de verificación es incorrecto o ha caducado"), ("Edit Tag", "Editar tag"), ("Forget Password", "Olvidar contraseña"), ("Favorites", "Favoritos"), @@ -267,7 +265,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("No permission of file transfer", "Sin permiso de transferencia de archivos"), ("Note", "Nota"), ("Connection", "Conexión"), - ("Share Screen", "Compartir pantalla"), + ("Share screen", "Compartir pantalla"), ("Chat", "Chat"), ("Total", "Total"), ("items", "items"), @@ -275,8 +273,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Screen Capture", "Captura de pantalla"), ("Input Control", "Control de entrada"), ("Audio Capture", "Captura de audio"), - ("File Connection", "Conexión de archivos"), - ("Screen Connection", "Conexión de pantalla"), ("Do you accept?", "¿Aceptas?"), ("Open System Setting", "Configuración del sistema abierto"), ("How to get Android input permission?", "¿Cómo obtener el permiso de entrada de Android?"), @@ -306,8 +302,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Keep RustDesk background service", "Dejar RustDesk como Servicio en 2do plano"), ("Ignore Battery Optimizations", "Ignorar optimizacioens de bateria"), ("android_open_battery_optimizations_tip", "Si deseas deshabilitar esta característica, por favor, ve a la página siguiente de ajustes, busca y entra en [Batería] y desmarca [Sin restricción]"), - ("Start on boot", ""), - ("Start the screen sharing service on boot, requires special permissions", ""), + ("Start on boot", "Iniciar al arrancar"), + ("Start the screen sharing service on boot, requires special permissions", "Iniciar el servicio de pantalla compartida al arrancar, requiere permisos especiales"), ("Connection not allowed", "Conexión no disponible"), ("Legacy mode", "Modo heredado"), ("Map mode", "Modo mapa"), @@ -330,8 +326,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Ratio", "Relación"), ("Image Quality", "Calidad de imagen"), ("Scroll Style", "Estilo de desplazamiento"), - ("Show Toolbar", ""), - ("Hide Toolbar", ""), + ("Show Toolbar", "Mostrar herramientas"), + ("Hide Toolbar", "Ocultar herramientas"), ("Direct Connection", "Conexión directa"), ("Relay Connection", "Conexión Relay"), ("Secure Connection", "Conexión segura"), @@ -342,7 +338,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Security", "Seguridad"), ("Theme", "Tema"), ("Dark Theme", "Tema Oscuro"), - ("Light Theme", ""), + ("Light Theme", "Tema claro"), ("Dark", "Oscuro"), ("Light", "Claro"), ("Follow System", "Tema del sistema"), @@ -359,12 +355,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Audio Input Device", "Dispositivo de entrada de audio"), ("Use IP Whitelisting", "Usar lista de IPs admitidas"), ("Network", "Red"), - ("Pin Toolbar", ""), - ("Unpin Toolbar", ""), + ("Pin Toolbar", "Anclar herramientas"), + ("Unpin Toolbar", "Desanclar herramientas"), ("Recording", "Grabando"), ("Directory", "Directorio"), ("Automatically record incoming sessions", "Grabación automática de sesiones entrantes"), - ("Automatically record outgoing sessions", ""), + ("Automatically record outgoing sessions", "Grabación automática de sesiones salientes"), ("Change", "Cambiar"), ("Start session recording", "Comenzar grabación de sesión"), ("Stop session recording", "Detener grabación de sesión"), @@ -372,7 +368,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Enable LAN discovery", "Habilitar descubrimiento de LAN"), ("Deny LAN discovery", "Denegar descubrimiento de LAN"), ("Write a message", "Escribir un mensaje"), - ("Prompt", ""), + ("Prompt", "Solicitud"), ("Please wait for confirmation of UAC...", "Por favor, espera confirmación de UAC"), ("elevated_foreground_window_tip", "La ventana actual del escritorio remoto necesita privilegios elevados para funcionar, así que no puedes usar ratón y teclado temporalmente. Puedes solicitar al usuario remoto que minimize la ventana actual o hacer clic en el botón de elevación de la ventana de gestión de conexión. Para evitar este problema, se recomienda instalar el programa en el dispositivo remto."), ("Disconnected", "Desconectado"), @@ -381,14 +377,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Keyboard Settings", "Ajustes de teclado"), ("Full Access", "Acceso completo"), ("Screen Share", "Compartir pantalla"), - ("Wayland requires Ubuntu 21.04 or higher version.", "Wayland requiere Ubuntu 21.04 o una versión superior."), - ("Wayland requires higher version of linux distro. Please try X11 desktop or change your OS.", "Wayland requiere una versión superior de la distribución de Linux. Pruebe el escritorio X11 o cambie su sistema operativo."), + ("ubuntu-21-04-required", "Wayland requiere Ubuntu 21.04 o una versión superior."), + ("wayland-requires-higher-linux-version", "Wayland requiere una versión superior de la distribución de Linux. Pruebe el escritorio X11 o cambie su sistema operativo."), + ("xdp-portal-unavailable", ""), ("JumpLink", "Ver"), ("Please Select the screen to be shared(Operate on the peer side).", "Seleccione la pantalla que se compartirá (Operar en el lado del par)."), ("Show RustDesk", "Mostrar RustDesk"), ("This PC", "Este PC"), ("or", "o"), - ("Continue with", "Continuar con"), ("Elevate", "Elevar privilegios"), ("Zoom cursor", "Ampliar cursor"), ("Accept sessions via password", "Aceptar sesiones a través de contraseña"), @@ -463,7 +459,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("empty_favorite_tip", "¿Sin pares favoritos aún?\nEncontremos uno al que conectarte y ¡añádelo a tus favoritos!"), ("empty_lan_tip", "Oh no, parece que aún no has descubierto ningún par."), ("empty_address_book_tip", "Parece que actualmente no hay pares en tu directorio."), - ("eg: admin", "ej.: admin"), ("Empty Username", "Nombre de usuario vacío"), ("Empty Password", "Contraseña vacía"), ("Me", "Yo"), @@ -621,9 +616,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("During service is on", "Mientras el servicio está activo"), ("Capture screen using DirectX", "Capturar pantalla con DirectX"), ("Back", "Atrás"), - ("Apps", ""), - ("Volume up", "Bajar volumen"), - ("Volume down", "Subir volumen"), + ("Apps", "Aplicaciones"), + ("Volume up", "Subir volumen"), + ("Volume down", "Bajar volumen"), ("Power", "Encendido"), ("Telegram bot", "Bot de Telegram"), ("enable-bot-tip", "Si activas esta característica puedes recibir código 2FA de tu bot. También puede funcionar como notificación de conexión."), @@ -653,5 +648,102 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Upload folder", "Subir carpeta"), ("Upload files", "Subir archivos"), ("Clipboard is synchronized", "Portapapeles sincronizado"), + ("Update client clipboard", "Actualizar portapapeles del cliente"), + ("Untagged", "Sin itiquetar"), + ("new-version-of-{}-tip", "Hay una nueva versión de {} disponible"), + ("Accessible devices", "Dispositivos accesibles"), + ("upgrade_remote_rustdesk_client_to_{}_tip", "Por favor, actualiza el cliente RustDesk a la versión {} o superior en el lado remoto"), + ("d3d_render_tip", "Al activar el renderizado D3D, la pantalla de control remoto puede verse negra en algunos equipos."), + ("Use D3D rendering", "Usar renderizado D3D"), + ("Printer", "Impresora"), + ("printer-os-requirement-tip", "La función de salida de impresora necesita Windows 10 o superior."), + ("printer-requires-installed-{}-client-tip", "Para usar la impresión remota, {} necesita estar instalado en tu dispositivo."), + ("printer-{}-not-installed-tip", "La impresora {} no está instalada."), + ("printer-{}-ready-tip", "La impresora {} está instalada y lista para usar."), + ("Install {} Printer", "Instalar la impresora {}"), + ("Outgoing Print Jobs", "Tareas salientes de impresión"), + ("Incoming Print Jobs", "Tareas entrantes de impresión"), + ("Incoming Print Job", "Trabajo entrante de impresión"), + ("use-the-default-printer-tip", "Usar la impresora predeterminada"), + ("use-the-selected-printer-tip", "Usar la impresora seleccionada"), + ("auto-print-tip", "Imprimir automáticamente usando la impresora seleccionada."), + ("print-incoming-job-confirm-tip", "Has recibido una tarea de impresión remota. ¿Deseas ejecutarla en tu lado?"), + ("remote-printing-disallowed-tile-tip", "Impresión remota inhabilitada"), + ("remote-printing-disallowed-text-tip", "Los ajustes de permisos del lado controlado no permiten la impresión remota."), + ("save-settings-tip", "Guardar ajustes"), + ("dont-show-again-tip", "No volver a mostrar"), + ("Take screenshot", "Tomar captura de pantalla"), + ("Taking screenshot", "Tomando captura de pantalla"), + ("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"), + ("Copy to clipboard", "Copiar al portapapeles"), + ("Enable remote printer", "Habilitar impresora remota"), + ("Downloading {}", "Descargando {}"), + ("{} Update", "{} Actualizar"), + ("{}-to-update-tip", "{} Se cerrará ahora e instalará la nueva versión."), + ("download-new-version-failed-tip", "Descarga fallida. Puedes volver a intentarlo o hacer clic en el botón \"Download\" para descargar desde la página de lanzamientos y actualizar manualmente."), + ("Auto update", "Auto actualizar"), + ("update-failed-check-msi-tip", "Comprobación de método de instalación fallida. Por favor, haz clic en el botón \"Download\" para descargar desde la página de lanzamientos y actualizar manualmente."), + ("websocket_tip", "Al usar WebSocket, solo se permiten conexiones relay."), + ("Use WebSocket", "Usar WebSocket"), + ("Trackpad speed", "Velocidad de trackpad"), + ("Default trackpad speed", "Velocidad predeterminada de trackpad"), + ("Numeric one-time password", "Contraseña numérica de un solo uso"), + ("Enable IPv6 P2P connection", "Habilitar conexión IPv6 P2P"), + ("Enable UDP hole punching", "Habilitar perforación de agujero UDP"), + ("View camera", "Ver cámara"), + ("Enable camera", "Habilitar cámara"), + ("No cameras", "No hay cámaras"), + ("view_camera_unsupported_tip", "El dispositivo remoto no soporta la visualización de la cámara."), + ("Terminal", ""), + ("Enable terminal", "Habilitar terminal"), + ("New tab", "Nueva pestaña"), + ("Keep terminal sessions on disconnect", "Mantener sesiones de terminal al desconectar"), + ("Terminal (Run as administrator)", "Terminal (Ejecutar como administrador)"), + ("terminal-admin-login-tip", "Por favor, introduzca el usuario y la contrasseña del administrador en el lado controlado."), + ("Failed to get user token.", "No se ha podido obtener el token de usuario"), + ("Incorrect username or password.", "Nombre y contraseña incorrectos"), + ("The user is not an administrator.", "El usuario no es un administrador."), + ("Failed to check if the user is an administrator.", "No se ha podido comprobar si el usuario es un administrador."), + ("Supported only in the installed version.", "Soportado solo en la versión instalada."), + ("elevation_username_tip", "Introduzca el nombre de usuario o dominio\\NombreDeUsuario"), + ("Preparing for installation ...", "Preparando instlación..."), + ("Show my cursor", "Mostrar mi cursor"), + ("Scale custom", "Escala personalizada"), + ("Custom scale slider", "Control deslizante de escala personalizada"), + ("Decrease", "Disminuir"), + ("Increase", "Aumentar"), + ("Show virtual mouse", "Mostrar ratón virtual"), + ("Virtual mouse size", "Tamaño del ratón virtual"), + ("Small", "Pequeño"), + ("Large", "Grande"), + ("Show virtual joystick", "Mostrar joystick virtual"), + ("Edit note", "Editar nota"), + ("Alias", ""), + ("ScrollEdge", "Desplazamiento de pantalla"), + ("Allow insecure TLS fallback", "Permitir conexión TLS insegura de respaldo"), + ("allow-insecure-tls-fallback-tip", "De forma predeterminada, RustDesk verifica el certificado de servidor para protocolos que usen TLS.\nCon esta opción habilitada, Rustdesk volverá al paso de omisión de verificación y procederá en caso de fallo de verificación."), + ("Disable UDP", "Inhabilitar UDP"), + ("disable-udp-tip", "Controla si se usa TCP solamente.\nCuando esta opción está activa, RustDesk no usará más el puerto UDP 21116, en su lugar se usará el TCP 21116."), + ("server-oss-not-support-tip", "NOTA: El servidor RustDesk OSS no incluye esta característica."), + ("input note here", "Introducir nota aquí"), + ("note-at-conn-end-tip", "Pedir nota al finalizar la conexión"), + ("Show terminal extra keys", "Mostrar teclas extra del terminal"), + ("Relative mouse mode", "Modo de ratón relativo"), + ("rel-mouse-not-supported-peer-tip", "El modo relativo de ratón no está soportado por el par."), + ("rel-mouse-not-ready-tip", "El modo relativo de ratón aún no está preparado. Por favor, inténtalo de nuevo."), + ("rel-mouse-lock-failed-tip", "Ha fallado el bloqueo del cursor. El modo relativo del ratón ha sido inhabilitado."), + ("rel-mouse-exit-{}-tip", "Pulsa {} para salir."), + ("rel-mouse-permission-lost-tip", "Permiso de teclado revocado. El modo relativo del ratón ha sido inhabilitado."), + ("Changelog", "Registro de cambios"), + ("keep-awake-during-outgoing-sessions-label", "Mantener la pantalla activa durante sesiones salientes"), + ("keep-awake-during-incoming-sessions-label", "Mantener la pantalla activa durante sesiones entrantes"), + ("Continue with {}", "Continuar con {}"), + ("Display Name", "Nombre de pantalla"), + ("password-hidden-tip", "La contraseña permanente está ajustada a (oculta)."), + ("preset-password-in-use-tip", "Se está usando la contraseña predeterminada."), + ("Enable privacy mode", "Habilitar modo privado"), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/et.rs b/src/lang/et.rs index 21de56c9e..76abc8563 100644 --- a/src/lang/et.rs +++ b/src/lang/et.rs @@ -1,254 +1,252 @@ lazy_static::lazy_static! { pub static ref T: std::collections::HashMap<&'static str, &'static str> = [ - ("Status", ""), - ("Your Desktop", ""), + ("Status", "Olek"), + ("Your Desktop", "Sinu töölaud"), ("desk_tip", "Sinu töölauale saab selle ID ja parooliga ligi pääseda."), - ("Password", ""), - ("Ready", ""), - ("Established", ""), + ("Password", "Parool"), + ("Ready", "Valmis"), + ("Established", "Ühendus loodud"), ("connecting_status", "RustDeski võrguga ühendumine..."), - ("Enable service", ""), - ("Start service", ""), - ("Service is running", ""), - ("Service is not running", ""), + ("Enable service", "Luba teenus"), + ("Start service", "Käivita teenus"), + ("Service is running", "Teenus töötab"), + ("Service is not running", "Teenus ei tööta"), ("not_ready_status", "Pole valmis. Palun kontrolli oma ühendust"), - ("Control Remote Desktop", ""), - ("Transfer file", ""), - ("Connect", ""), - ("Recent sessions", ""), - ("Address book", ""), - ("Confirmation", ""), - ("TCP tunneling", ""), - ("Remove", ""), - ("Refresh random password", ""), - ("Set your own password", ""), - ("Enable keyboard/mouse", ""), - ("Enable clipboard", ""), - ("Enable file transfer", ""), - ("Enable TCP tunneling", ""), - ("IP Whitelisting", ""), + ("Control Remote Desktop", "Juhi kaugtöölauda"), + ("Transfer file", "Edasta fail"), + ("Connect", "Ühenda"), + ("Recent sessions", "Viimatised seansid"), + ("Address book", "Aadressiraamat"), + ("Confirmation", "Kinnitus"), + ("TCP tunneling", "TCP-tunneldamine"), + ("Remove", "Eemalda"), + ("Refresh random password", "Värskenda juhuslik parool"), + ("Set your own password", "Määra oma parool"), + ("Enable keyboard/mouse", "Luba klaviatuur/hiir"), + ("Enable clipboard", "Luba lõikelaud"), + ("Enable file transfer", "Luba failiedastus"), + ("Enable TCP tunneling", "Luba TCP-tunneldamine"), + ("IP Whitelisting", "IP lubamisloend"), ("ID/Relay Server", "ID-/releeserver"), - ("Import server config", ""), - ("Export Server Config", ""), - ("Import server configuration successfully", ""), - ("Export server configuration successfully", ""), - ("Invalid server configuration", ""), - ("Clipboard is empty", ""), - ("Stop service", ""), - ("Change ID", ""), - ("Your new ID", ""), - ("length %min% to %max%", ""), - ("starts with a letter", ""), - ("allowed characters", ""), - ("id_change_tip", "Lubatud on vaid a-z, A-Z, 0-9 ja _ (alakriips) tähemärgid. Esimene täht peab olema a-z või A-Z. Pikkus vahemikus 6-16."), - ("Website", ""), - ("About", ""), + ("Import server config", "Impordi serveriseadistus"), + ("Export Server Config", "Ekspordi serveriseadistus"), + ("Import server configuration successfully", "Serveriseadistus edukalt imporditud"), + ("Export server configuration successfully", "Serveriseadistus edukalt eksporditud"), + ("Invalid server configuration", "Sobimatu serveriseadistus"), + ("Clipboard is empty", "Lõikelaud on tühi"), + ("Stop service", "Peata teenus"), + ("Change ID", "Muuda ID-d"), + ("Your new ID", "Sinu uus ID"), + ("length %min% to %max%", "pikkus %min%-%max%"), + ("starts with a letter", "algab tähega"), + ("allowed characters", "lubatud tähemärgid"), + ("id_change_tip", "Lubatud on vaid a-z, A-Z, 0-9, - (kriips) ja _ (alakriips) tähemärgid. Esimene täht peab olema a-z või A-Z. Pikkus vahemikus 6-16."), + ("Website", "Veebileht"), + ("About", "Meist"), ("Slogan_tip", "Loodud südamega selles kaootilises maailmas!"), - ("Privacy Statement", ""), - ("Mute", ""), + ("Privacy Statement", "Privaatsusteatis"), + ("Mute", "Hääletu"), ("Build Date", "Ehituskuupäev"), - ("Version", ""), - ("Home", ""), + ("Version", "Versioon"), + ("Home", "Kodu"), ("Audio Input", "Helisisend"), - ("Enhancements", ""), + ("Enhancements", "Täiendused"), ("Hardware Codec", "Riistvarakoodek"), - ("Adaptive bitrate", ""), + ("Adaptive bitrate", "Kohanduv bitikiirus"), ("ID Server", "ID-server"), ("Relay Server", "Releeserver"), ("API Server", "Rakendusliidese server"), ("invalid_http", "peab algama: http:// või https://"), - ("Invalid IP", ""), - ("Invalid format", ""), + ("Invalid IP", "Sobimatu IP"), + ("Invalid format", "Sobimatu vorming"), ("server_not_support", "Pole veel serveri poolt toetatud"), - ("Not available", ""), - ("Too frequent", ""), - ("Cancel", ""), - ("Skip", ""), - ("Close", ""), - ("Retry", ""), - ("OK", ""), + ("Not available", "Pole saadaval"), + ("Too frequent", "Liiga sagedane"), + ("Cancel", "Tühista"), + ("Skip", "Jäta vahele"), + ("Close", "Sulge"), + ("Retry", "Proovi uuesti"), + ("OK", "OK"), ("Password Required", "Parool on nõutud"), - ("Please enter your password", ""), - ("Remember password", ""), + ("Please enter your password", "Palun sisesta oma parool"), + ("Remember password", "Jäta parool meelde"), ("Wrong Password", "Vale parool"), - ("Do you want to enter again?", ""), + ("Do you want to enter again?", "Kas soovid uuesti sisestada?"), ("Connection Error", "Ühenduse viga"), - ("Error", ""), - ("Reset by the peer", ""), - ("Connecting...", ""), - ("Connection in progress. Please wait.", ""), - ("Please try 1 minute later", ""), + ("Error", "Viga"), + ("Reset by the peer", "Partneri poolt lähtestatud"), + ("Connecting...", "Ühendamine..."), + ("Connection in progress. Please wait.", "Ühendus on käimas. Palun oota."), + ("Please try 1 minute later", "Palun proovi 1 minuti pärast"), ("Login Error", "Sisselogimise viga"), - ("Successful", ""), - ("Connected, waiting for image...", ""), - ("Name", ""), - ("Type", ""), - ("Modified", ""), - ("Size", ""), + ("Successful", "Edukas"), + ("Connected, waiting for image...", "Ühendatud, pildi ootamine..."), + ("Name", "Nimi"), + ("Type", "Tüüp"), + ("Modified", "Muudetud"), + ("Size", "Suurus"), ("Show Hidden Files", "Kuva peidetud faile"), - ("Receive", ""), - ("Send", ""), + ("Receive", "Võta vastu"), + ("Send", "Saada"), ("Refresh File", "Värskenda faili"), - ("Local", ""), - ("Remote", ""), + ("Local", "Kohalik"), + ("Remote", "Kaugjuhitav"), ("Remote Computer", "Kaugarvuti"), ("Local Computer", "Kohalik arvuti"), ("Confirm Delete", "Kinnita kustutamist"), - ("Delete", ""), - ("Properties", ""), + ("Delete", "Kustuta"), + ("Properties", "Atribuudid"), ("Multi Select", "Mitmikvalik"), ("Select All", "Vali kõik"), ("Unselect All", "Tühista kõigi valik"), ("Empty Directory", "Tühi kaust"), - ("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", ""), + ("Not an empty directory", "Pole tühi kaust"), + ("Are you sure you want to delete this file?", "Kas soovid kindlasti selle faili eemaldada?"), + ("Are you sure you want to delete this empty directory?", "Kas soovid kindlasti selle tühja kausta eemaldada?"), + ("Are you sure you want to delete the file of this directory?", "Kas soovid kindlasti selle kausta faili eemaldada?"), + ("Do this for all conflicts", "Tee see kõigi konfliktide puhul"), + ("This is irreversible!", "See on pöördumatu!"), + ("Deleting", "Kustutamine"), + ("files", "failid"), + ("Waiting", "Ootamine"), + ("Finished", "Lõpetatud"), + ("Speed", "Kiirus"), ("Custom Image Quality", "Kohandatud pildikvaliteet"), - ("Privacy mode", ""), - ("Block user input", ""), - ("Unblock user input", ""), + ("Privacy mode", "Privaatsusrežiim"), + ("Block user input", "Blokeeri kasutajasisend"), + ("Unblock user input", "Eemalda kasutajasisendi blokeering"), ("Adjust Window", "Kohanda akent"), - ("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", ""), + ("Original", "Algne"), + ("Shrink", "Vähenda"), + ("Stretch", "Venita"), + ("Scrollbar", "Kerimisriba"), + ("ScrollAuto", "Automaatne kerimine"), + ("Good image quality", "Hea pildikvaliteet"), + ("Balanced", "Tasakaalustatud"), + ("Optimize reaction time", "Optimeeri reageerimisaeg"), + ("Custom", "Kohandatud"), + ("Show remote cursor", "Kuva kaugkursorit"), + ("Show quality monitor", "Kuva kvaliteedijälgija"), + ("Disable clipboard", "Keela lõikelaud"), + ("Lock after session end", "Lukusta pärast seansi lõppu"), + ("Insert Ctrl + Alt + Del", "Sisesta Ctrl + Alt + Del"), ("Insert Lock", "Sisesta lukk"), - ("Refresh", ""), - ("ID does not exist", ""), - ("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", ""), + ("Refresh", "Värskenda"), + ("ID does not exist", "ID-d ei eksisteeri"), + ("Failed to connect to rendezvous server", "Kohtumisserveriga ühendumine ebaõnnestus"), + ("Please try later", "Palun proovi hiljem"), + ("Remote desktop is offline", "Kaugtöölaud on väljas"), + ("Key mismatch", "Võtme sobimatus"), + ("Timeout", "Ajalõpp"), + ("Failed to connect to relay server", "Releeserveriga ühendumine ebaõnnestus"), + ("Failed to connect via rendezvous server", "Kohtumisserveri kaudu ühendumine ebaõnnestus"), + ("Failed to connect via relay server", "Releeserveri kaudu ühendumine ebaõnnestus"), + ("Failed to make direct connection to remote desktop", "Kaugtöölauaga otsese ühenduse loomine ebaõnnestus"), ("Set Password", "Määra parool"), ("OS Password", "Opsüsteemi parool"), ("install_tip", "Kasutajakonto kontrolli (UAC) tõttu ei saa RustDesk mõnel juhul korralikult kaugjuhtimispoolena töötada. Kontrolli vältimiseks palun klõpsa alloleval nupul, et RustDesk oma süsteemi paigaldada."), - ("Click to upgrade", ""), - ("Click to download", ""), - ("Click to update", ""), - ("Configure", ""), + ("Click to upgrade", "Vajuta täiendamiseks"), + ("Configure", "Seadista"), ("config_acc", "Töölaua kaugjuhtimiseks tuleb RustDeskile anda \"juurdepääsetavuse\" õigused."), ("config_screen", "Töölaua kaugjuhtimiseks tuleb RustDeskile anda \"ekraanisalvestuse\" õigused."), - ("Installing ...", ""), - ("Install", ""), - ("Installation", ""), + ("Installing ...", "Installimine..."), + ("Install", "Installi"), + ("Installation", "Paigaldus"), ("Installation Path", "Paigaldustee"), - ("Create start menu shortcuts", ""), - ("Create desktop icon", ""), + ("Create start menu shortcuts", "Loo Start-menüü otseteed"), + ("Create desktop icon", "Loo töölauaikoon"), ("agreement_tip", "Paigalduse alustamisel nõustud litsentsilepinguga."), ("Accept and Install", "Nõustu ja paigalda"), - ("End-user license agreement", ""), - ("Generating ...", ""), - ("Your installation is lower version.", ""), - ("not_close_tcp_tip", "Ara sulge seda akent, kuni kasutad tunnelit"), - ("Listening ...", ""), + ("End-user license agreement", "Lõppkasutaja litsentsileping"), + ("Generating ...", "Loomine..."), + ("Your installation is lower version.", "Sinu paigaldus kasutab vanemat versiooni."), + ("not_close_tcp_tip", "Ära sulge seda akent, kuni kasutad tunnelit"), + ("Listening ...", "Kuulamine..."), ("Remote Host", "Kaughost"), ("Remote Port", "Kaugport"), - ("Action", ""), - ("Add", ""), + ("Action", "Tegevus"), + ("Add", "Lisa"), ("Local Port", "Kohalik port"), ("Local Address", "Kohalik aadress"), ("Change Local Port", "Muuda kohalikku porti"), ("setup_server_tip", "Kiirema ühenduse jaoks palun seadista oma server"), - ("Too short, at least 6 characters.", ""), - ("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", ""), + ("Too short, at least 6 characters.", "Liiga lühike, peab olema vähemalt 6 tähemärki."), + ("The confirmation is not identical.", "Kinnitus ei ole identne."), + ("Permissions", "Õigused"), + ("Accept", "Nõustu"), + ("Dismiss", "Loobu"), + ("Disconnect", "Ühendu lahti"), + ("Enable file copy and paste", "Luba failide kopeerimine ja kleepimine"), + ("Connected", "Ühendatud"), + ("Direct and encrypted connection", "Otsene ja krüpteeritud ühendus"), + ("Relayed and encrypted connection", "Vahendatud ja krüpteeritud ühendus"), + ("Direct and unencrypted connection", "Otsene ja krüpteerimata ühendus"), + ("Relayed and unencrypted connection", "Vahendatud ja krüpteerimata ühendus"), ("Enter Remote ID", "Sisesta kaug-ID"), - ("Enter your password", ""), - ("Logging in...", ""), - ("Enable RDP session sharing", ""), + ("Enter your password", "Sisesta oma parool"), + ("Logging in...", "Sisselogimine..."), + ("Enable RDP session sharing", "Luba RDP-seansi jagamine"), ("Auto Login", "Logi automaatselt sisse (Kehtib vaid valiku \"lukusta pärast seansi lõppu\" lubamisel)"), - ("Enable direct IP access", ""), - ("Rename", ""), - ("Space", ""), - ("Create desktop shortcut", ""), + ("Enable direct IP access", "Luba otsene IP-juurdepääs"), + ("Rename", "Nimeta ümber"), + ("Space", "Ruum"), + ("Create desktop shortcut", "Loo töölauaotsetee"), ("Change Path", "Muuda failiteed"), ("Create Folder", "Loo kaust"), - ("Please enter the folder name", ""), - ("Fix it", ""), - ("Warning", ""), - ("Login screen using Wayland is not supported", ""), - ("Reboot required", ""), - ("Unsupported display server", ""), - ("x11 expected", ""), - ("Port", ""), - ("Settings", ""), - ("Username", ""), - ("Invalid port", ""), - ("Closed manually by the peer", ""), - ("Enable remote configuration modification", ""), - ("Run without install", ""), - ("Connect via relay", ""), - ("Always connect via relay", ""), + ("Please enter the folder name", "Palun sisesta kausta nimi"), + ("Fix it", "Paranda see"), + ("Warning", "Hoiatus"), + ("Login screen using Wayland is not supported", "Waylandi kasutav sisselogimisekraan ei ole toetatud"), + ("Reboot required", "Taaskäivitus nõutud"), + ("Unsupported display server", "Mittetoetatud kuvaserver"), + ("x11 expected", "Oodatakse x11"), + ("Port", "Port"), + ("Settings", "Seaded"), + ("Username", "Kasutajanimi"), + ("Invalid port", "Sobimatu port"), + ("Closed manually by the peer", "Partneri poolt käsitsi suletud"), + ("Enable remote configuration modification", "Luba kaugtöölaua seadistuse muutmine"), + ("Run without install", "Käivita paigaldamata"), + ("Connect via relay", "Ühenda relee kaudu"), + ("Always connect via relay", "Ühenda alati relee kaudu"), ("whitelist_tip", "Ainult lubamisloendis IP saab mulle ligi"), - ("Login", ""), - ("Verify", ""), - ("Remember me", ""), - ("Trust this device", ""), - ("Verification code", ""), + ("Login", "Logi sisse"), + ("Verify", "Kinnita"), + ("Remember me", "Jäta mind meelde"), + ("Trust this device", "Usalda seda seadet"), + ("Verification code", "Kinnituskood"), ("verification_tip", "Registreeritud e-posti aadressile on saadetud kinnituskood, sisselogimise jätkamiseks sisesta kinnituskood."), - ("Logout", ""), - ("Tags", ""), - ("Search ID", ""), + ("Logout", "Logi välja"), + ("Tags", "Sildid"), + ("Search ID", "Otsi ID-d"), ("whitelist_sep", "Eraldatud koma, semikooloni, tühikute või uue reaga"), - ("Add ID", ""), + ("Add ID", "Lisa ID"), ("Add Tag", "Lisa silt"), - ("Unselect all tags", ""), - ("Network error", ""), - ("Username missed", ""), - ("Password missed", ""), + ("Unselect all tags", "Tühista kõik sildid"), + ("Network error", "Võrgu viga"), + ("Username missed", "Kasutajanimi on puudu"), + ("Password missed", "Parool on puudu"), ("Wrong credentials", "Vale kasutajanimi või parool"), - ("The verification code is incorrect or has expired", ""), + ("The verification code is incorrect or has expired", "Kinnituskood on vale või aegunud"), ("Edit Tag", "Muuda silti"), ("Forget Password", "Unusta parool"), - ("Favorites", ""), + ("Favorites", "Lemmikud"), ("Add to Favorites", "Lisa lemmikutesse"), ("Remove from Favorites", "Eemalda lemmikutest"), - ("Empty", ""), - ("Invalid folder name", ""), + ("Empty", "Tühi"), + ("Invalid folder name", "Kehtetu kaustanimi"), ("Socks5 Proxy", "Socks5 proksi"), ("Socks5/Http(s) Proxy", "Socks5/Http(s) proksi"), - ("Discovered", ""), + ("Discovered", "Avastatud"), ("install_daemon_tip", "Süsteemikäivitusel käivitamiseks tuleb paigaldada süsteemiteenus."), - ("Remote ID", ""), - ("Paste", ""), - ("Paste here?", ""), + ("Remote ID", "Kaug-ID"), + ("Paste", "Kleebi"), + ("Paste here?", "Kleebid siia?"), ("Are you sure to close the connection?", "Kas soovid kindlasti ühenduse sulgeda?"), - ("Download new version", ""), - ("Touch mode", ""), - ("Mouse mode", ""), + ("Download new version", "Laadi alla uus versioon"), + ("Touch mode", "Puuterežiim"), + ("Mouse mode", "Hiirerežiim"), ("One-Finger Tap", "Ühe sõrme koputus"), ("Left Mouse", "Vasak hiireklahv"), ("One-Long Tap", "Üks pikk koputus"), @@ -263,23 +261,21 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Canvas Move", "Lõuendi liigutus"), ("Pinch to Zoom", "Näpistus-suum"), ("Canvas Zoom", "Lõuendi suum"), - ("Reset canvas", ""), - ("No permission of file transfer", ""), - ("Note", ""), - ("Connection", ""), - ("Share Screen", "Jaga ekraani"), - ("Chat", ""), - ("Total", ""), - ("items", ""), - ("Selected", ""), + ("Reset canvas", "Lähtesta lõuend"), + ("No permission of file transfer", "Failiülekande luba puudub"), + ("Note", "Märkus"), + ("Connection", "Ühendus"), + ("Share screen", "Jaga ekraani"), + ("Chat", "Vestlus"), + ("Total", "Kokku"), + ("items", "üksust"), + ("Selected", "Valitud"), ("Screen Capture", "Ekraanisalvestus"), ("Input Control", "Sisendjuhtimine"), ("Audio Capture", "Helisalvestus"), - ("File Connection", "Failiühendus"), - ("Screen Connection", "Kuvaühendus"), - ("Do you accept?", ""), + ("Do you accept?", "Kas nõustud?"), ("Open System Setting", "Ava süsteemisätted"), - ("How to get Android input permission?", ""), + ("How to get Android input permission?", "Kuidas saada Androidi sisendi luba?"), ("android_input_permission_tip1", "Selleks, et kaugseade saaks sinu Androidi seadet juhtida hiire või puute abil, pead andma RustDeskile \"juurdepääsetavuse\" loa."), ("android_input_permission_tip2", "Palun mine järgmisele süsteemiseadete lehele, leia ja sisesta [Paigaldatud teenused], lülita teenus [RustDesk Input] sisse."), ("android_new_connection_tip", "Saabunud on uus juhtimistaotlus, mis soovib sinu praegust seadet juhtida."), @@ -288,46 +284,46 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("android_version_audio_tip", "Kasutatav Androidi versioon ei toeta helisalvestust, palun täienda Android 10 või uuemale versioonile."), ("android_start_service_tip", "Koputa [Alusta teenust] või anna [Ekraanisalvestuse] luba, et ekraanijagamisteenust alustada."), ("android_permission_may_not_change_tip", "Loodud ühenduste õigused ei pruugi muutuda enne taasühendamist koheselt."), - ("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", ""), + ("Account", "Konto"), + ("Overwrite", "Ülekirjutamine"), + ("This file exists, skip or overwrite this file?", "See fail eksisteerib, kas soovid selle vahele jätta või ülekirjutada?"), + ("Quit", "Välju"), + ("Help", "Abi"), + ("Failed", "Ebaõnnestus"), + ("Succeeded", "Õnnestus"), + ("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"), + ("Language", "Keel"), + ("Keep RustDesk background service", "Säilita RustDeski taustateenus"), ("Ignore Battery Optimizations", "Ignoreeri akuoptimeerimisi"), ("android_open_battery_optimizations_tip", "Kui soovid selle funktsiooni keelata, palun mine järgmisele RustDeski rakenduse seadete lehele, leia ja sisesta [Aku], eemalda linnuke valikult [Piiramata]"), - ("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", ""), + ("Start on boot", "Käivita seadme käivitamisel"), + ("Start the screen sharing service on boot, requires special permissions", "Käivita ekraanijagamise teenus seadme käivitamisel, nõuab eriõigusi"), + ("Connection not allowed", "Ühendus ei ole lubatud"), + ("Legacy mode", "Pärandrežiim"), + ("Map mode", "Kaardirežiim"), + ("Translate mode", "Tõlkerežiim"), + ("Use permanent password", "Kasuta püsiparooli"), + ("Use both passwords", "Kasuta mõlemat parooli"), + ("Set permanent password", "Seadista püsiparool"), + ("Enable remote restart", "Luba kaugtaaskäivitamine"), + ("Restart remote device", "Taaskäivita kaugseade"), + ("Are you sure you want to restart", "Kas oled kindel, et soovid taaskäivitada"), + ("Restarting remote device", "Kaugseadme taaskäivitamine"), ("remote_restarting_tip", "Kaugseade taaskäivitub, palun sulge see sõnumikast ja ühendu mõne aja pärast uuesti püsiva parooliga."), - ("Copied", ""), - ("Exit Fullscreen", "Lahku täisekraanist"), - ("Fullscreen", ""), + ("Copied", "Kopeeritud"), + ("Exit Fullscreen", "Välju täisekraanist"), + ("Fullscreen", "Täisekraan"), ("Mobile Actions", "Mobiilitegevused"), ("Select Monitor", "Vali kuvar"), ("Control Actions", "Juhtimistegevused"), ("Display Settings", "Kuvasätted"), - ("Ratio", ""), + ("Ratio", "Kuvasuhe"), ("Image Quality", "Pildikvaliteet"), ("Scroll Style", "Kerimisstiil"), ("Show Toolbar", "Kuva tööriistariba"), @@ -336,142 +332,141 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Releeühendus"), ("Secure Connection", "Turvaline ühendus"), ("Insecure Connection", "Ebaturvaline ühendus"), - ("Scale original", ""), - ("Scale adaptive", ""), - ("General", ""), - ("Security", ""), - ("Theme", ""), + ("Scale original", "Originaalskaala"), + ("Scale adaptive", "Kohanduv skaala"), + ("General", "Üldine"), + ("Security", "Turvalisus"), + ("Theme", "Teema"), ("Dark Theme", "Tume teema"), ("Light Theme", "Hele teema"), - ("Dark", ""), - ("Light", ""), + ("Dark", "Tume"), + ("Light", "Hele"), ("Follow System", "Järgi süsteemi"), - ("Enable hardware codec", ""), + ("Enable hardware codec", "Luba riistvarakooder"), ("Unlock Security Settings", "Lukusta lahti turvasätted"), - ("Enable audio", ""), + ("Enable audio", "Luba heli"), ("Unlock Network Settings", "Lukusta lahti võrgusätted"), - ("Server", ""), + ("Server", "Server"), ("Direct IP Access", "Otsene IP-ligipääs"), - ("Proxy", ""), - ("Apply", ""), - ("Disconnect all devices?", ""), - ("Clear", ""), + ("Proxy", "Proksi"), + ("Apply", "Rakenda"), + ("Disconnect all devices?", "Ühendad kõik seadmed lahti?"), + ("Clear", "Tühjenda"), ("Audio Input Device", "Heli sisendseade"), ("Use IP Whitelisting", "Kasuta IP-lubamisloendit"), - ("Network", ""), + ("Network", "Võrk"), ("Pin Toolbar", "Kinnita tööriistariba"), ("Unpin Toolbar", "Eemalda tööriistariba kinnitus"), - ("Recording", ""), - ("Directory", ""), - ("Automatically record incoming sessions", ""), - ("Automatically record outgoing sessions", ""), - ("Change", ""), - ("Start session recording", ""), - ("Stop session recording", ""), - ("Enable recording session", ""), - ("Enable LAN discovery", ""), - ("Deny LAN discovery", ""), - ("Write a message", ""), - ("Prompt", ""), - ("Please wait for confirmation of UAC...", ""), + ("Recording", "Salvestamine"), + ("Directory", "Kaust"), + ("Automatically record incoming sessions", "Salvesta alati sisenevad ühendused"), + ("Automatically record outgoing sessions", "Salvesta alati väljuvad ühendused"), + ("Change", "Muuda"), + ("Start session recording", "Alusta seansisalvestust"), + ("Stop session recording", "Peata seansisalvestus"), + ("Enable recording session", "Luba seansisalvestus"), + ("Enable LAN discovery", "Luba LAN-avastamine"), + ("Deny LAN discovery", "Keela LAN-avastamine"), + ("Write a message", "Kirjuta sõnum"), + ("Prompt", "Küsi"), + ("Please wait for confirmation of UAC...", "Palun oota UAC (kasutajakonto kontroll) kinnitust..."), ("elevated_foreground_window_tip", "Kaugtöölaua praegune aken nõuab töötamiseks kõrgemaid õigusi, mistõttu ei saa see ajutiselt hiirt ja klaviatuuri kasutada. Võid kaugkasutajal paluda minimeerida praegune aken või klõpsata ühenduse haldamise aknas kõrgendatud loa nuppu. Selle probleemi vältimiseks on soovitatav kaugseadmesse tarkvara paigaldada."), - ("Disconnected", ""), - ("Other", ""), - ("Confirm before closing multiple tabs", ""), + ("Disconnected", "Ühendus katkestatud"), + ("Other", "Muu"), + ("Confirm before closing multiple tabs", "Kinnita enne mitme vahekaardi sulgemist"), ("Keyboard Settings", "Klaviatuurisätted"), ("Full Access", "Täielik ligipääs"), ("Screen Share", "Ekraanijagamine"), - ("Wayland requires Ubuntu 21.04 or higher version.", ""), - ("Wayland requires higher version of linux distro. Please try X11 desktop or change your OS.", ""), - ("JumpLink", "Kuva"), + ("ubuntu-21-04-required", "Wayland nõuab Ubuntu 21.04 või uuemat versiooni."), + ("wayland-requires-higher-linux-version", "Wayland nõuab Linuxi distributsiooni uuemat versiooni. Palun proovi X11 töölaual või muuda oma operatsioonisüsteemi."), + ("xdp-portal-unavailable", ""), + ("JumpLink", "JumpLink"), ("Please Select the screen to be shared(Operate on the peer side).", "Palun vali jagatav ekraan (tegutse partneri poolel)."), - ("Show RustDesk", ""), - ("This PC", ""), - ("or", ""), - ("Continue with", ""), - ("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...", ""), + ("Show RustDesk", "Kuva RustDesk"), + ("This PC", "See arvuti"), + ("or", "või"), + ("Elevate", "Tõsta"), + ("Zoom cursor", "Suumi kursorit"), + ("Accept sessions via password", "Aktsepteeri seansid parooli kaudu"), + ("Accept sessions via click", "Aktsepteeri seansid klõpsamise kaudu"), + ("Accept sessions via both", "Aktsepteeri seansid mõlema kaudu"), + ("Please wait for the remote side to accept your session request...", "Palun oota, kuni kaugpool aktsepteerib sinu seansitaotluse..."), ("One-time Password", "Ühekordne parool"), - ("Use one-time password", ""), - ("One-time password length", ""), - ("Request access to your device", ""), - ("Hide connection management window", ""), + ("Use one-time password", "Kasuta ühekordset parooli"), + ("One-time password length", "Ühekordse parooli pikkus"), + ("Request access to your device", "Taotle oma seadmele juurdepääsu"), + ("Hide connection management window", "Peida ühenduse haldamise aken"), ("hide_cm_tip", "Luba varjamine ainult siis, kui parooliga seansse võetakse vastu ning kasutatakse püsivat parooli."), ("wayland_experiment_tip", "Waylandi tugi on katsetusjärgus, järelvalveta juurdepääsu vajadusel palun kasuta X11."), - ("Right click to select tabs", ""), - ("Skipped", ""), - ("Add to address book", ""), - ("Group", ""), - ("Search", ""), - ("Closed manually by web console", ""), - ("Local keyboard type", ""), - ("Select local keyboard type", ""), + ("Right click to select tabs", "Paremklõpsa vahekaartide valimiseks"), + ("Skipped", "Vahelejäetud"), + ("Add to address book", "Lisa aadressiraamatusse"), + ("Group", "Grupeeri"), + ("Search", "Otsi"), + ("Closed manually by web console", "Veebikonsooli kaudu käsitsi suletud"), + ("Local keyboard type", "Kohalik klaviatuuritüüp"), + ("Select local keyboard type", "Vali kohalik klaviatuuritüüp"), ("software_render_tip", "Kui kasutad Linuxis Nvidia graafikakaarti ja kaugaken sulgub kohe pärast ühendamist, võib aidata üleminek avatud lähtekoodiga Nouveau draiverile ja valida tarkvaraline renderdamise. Vajalik on tarkvara taaskäivitamine."), - ("Always use software rendering", ""), + ("Always use software rendering", "Kasuta alati tarkvaralist renderdust"), ("config_input", "Kaugtöölaua klaviatuuriga juhtimiseks pead andma RustDeskile \"sisendi jälgimise\" õigused."), ("config_microphone", "Kaugelt rääkimiseks pead andma RustDeskile \"heli salvestamise\" õigused."), ("request_elevation_tip", "Sa võid kõrgendatud õigusi taotleda ka siis, kui keegi on kaugpoolel."), - ("Wait", ""), + ("Wait", "Oota"), ("Elevation Error", "Kõrgendatud õiguste viga"), - ("Ask the remote user for authentication", ""), - ("Choose this if the remote account is administrator", ""), - ("Transmit the username and password of administrator", ""), + ("Ask the remote user for authentication", "Küsi kaugkasutajalt autentimist"), + ("Choose this if the remote account is administrator", "Vali see, kui kaugkonto on administraator"), + ("Transmit the username and password of administrator", "Edasta administraatori kasutajanimi ja parool"), ("still_click_uac_tip", "Kaugkasutaja peab siiski ise vajutama käitatud RustDeski kasutajakonto kontrollis (UAC) OK-nuppu."), ("Request Elevation", "Taotle kõrgendatud õigusi"), ("wait_accept_uac_tip", "Palun oota, kuni kaugkasutaja nõustub UAC-dialoogiga (kasutajakonto kontroll)."), - ("Elevate successfully", ""), - ("uppercase", ""), - ("lowercase", ""), - ("digit", ""), - ("special character", ""), - ("length>=8", ""), - ("Weak", ""), - ("Medium", ""), - ("Strong", ""), + ("Elevate successfully", "Kõrgendamine õnnestus"), + ("uppercase", "suurtäht"), + ("lowercase", "väiketäht"), + ("digit", "number"), + ("special character", "erimärk"), + ("length>=8", "pikkus>=8"), + ("Weak", "Nõrk"), + ("Medium", "Keskmine"), + ("Strong", "Tugev"), ("Switch Sides", "Vaheta pooli"), - ("Please confirm if you want to share your desktop?", ""), - ("Display", ""), + ("Please confirm if you want to share your desktop?", "Palun kinnita, kas soovid oma töölauda jagada?"), + ("Display", "Kuva"), ("Default View Style", "Vaikimisi kuvastiil"), ("Default Scroll Style", "Vaikimisi kerimisstiil"), ("Default Image Quality", "Vaikimisi pildikvaliteet"), ("Default Codec", "Vaikimisi koodek"), - ("Bitrate", ""), - ("FPS", ""), - ("Auto", ""), + ("Bitrate", "Bitikiirus"), + ("FPS", "FPS"), + ("Auto", "Automaatne"), ("Other Default Options", "Teised vaikevalikud"), - ("Voice call", ""), - ("Text chat", ""), - ("Stop voice call", ""), + ("Voice call", "Häälkõne"), + ("Text chat", "Tekstivestlus"), + ("Stop voice call", "Peata häälkõne"), ("relay_hint_tip", "Otseühendust ei pruugi olla võimalik luua; võid proovida ühendust relee kaudu. Lisaks, kui soovid esimesel katsel kasutada releed, võid lisada ID-le järelliite \"/r\" või valida viimaste seansside kaardil - kui see on olemas - valiku \"Ühenda alati relee kaudu\"."), - ("Reconnect", ""), - ("Codec", ""), - ("Resolution", ""), - ("No transfers in progress", ""), - ("Set one-time password length", ""), + ("Reconnect", "Ühenda uuesti"), + ("Codec", "Koodek"), + ("Resolution", "Resolutsioon"), + ("No transfers in progress", "Ülekandeid ei toimu"), + ("Set one-time password length", "Seadista ühekordse parooli pikkus"), ("RDP Settings", "RDP seaded"), - ("Sort by", ""), + ("Sort by", "Sorteeri"), ("New Connection", "Uus ühendus"), - ("Restore", ""), - ("Minimize", ""), - ("Maximize", ""), + ("Restore", "Taasta"), + ("Minimize", "Minimeeri"), + ("Maximize", "Maksimeeri"), ("Your Device", "Sinu seade"), ("empty_recent_tip", "Ups, hiljutised seansid puuduvad!\nAeg uus planeerida."), ("empty_favorite_tip", "Ei ole veel ühtegi lemmikpartnerit?\nLeia keegi, kellega suhelda ja lisa ta oma lemmikute hulka!"), ("empty_lan_tip", "Oh ei, tundub, et me pole veel ühtegi partnerit avastanud."), ("empty_address_book_tip", "Oh ei, tundub et sinu aadressiraamatus ei ole hetkel ühtegi partnerit."), - ("eg: admin", ""), ("Empty Username", "Tühi kasutajanimi"), ("Empty Password", "Tühi parool"), - ("Me", ""), + ("Me", "Mina"), ("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 parooli"), + ("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"), @@ -481,49 +476,49 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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", ""), + ("No need to elevate", "Kõrgendamine pole vajalik"), ("System Sound", "Süsteemiheli"), - ("Default", ""), - ("New RDP", ""), - ("Fingerprint", ""), + ("Default", "Vaikimisi"), + ("New RDP", "Uus RDP"), + ("Fingerprint", "Sõrmejälg"), ("Copy Fingerprint", "Kopeeri sõrmejälg"), ("no fingerprints", "Sõrmejäljed puuduvad"), - ("Select a peer", ""), - ("Select peers", ""), - ("Plugins", ""), - ("Uninstall", ""), - ("Update", ""), - ("Enable", ""), - ("Disable", ""), - ("Options", ""), + ("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"), - ("Collapse toolbar", ""), - ("Accept and Elevate", "Luba kõrgemate õigustega"), + ("Collapse toolbar", "Kompaktne tööriistariba"), + ("Accept and Elevate", "Luba kõrgendatud õigustega"), ("accept_and_elevate_btn_tooltip", "Võta ühendus vastu ja anna kõrgemad UAC-õigused (kasutajakonto kontroll)."), ("clipboard_wait_response_timeout_tip", "Koopia vastuse ootamisel tekkis ajalõpp."), - ("Incoming connection", ""), - ("Outgoing connection", ""), - ("Exit", ""), - ("Open", ""), + ("Incoming connection", "Sissetulev ühendus"), + ("Outgoing connection", "Väljuv ühendus"), + ("Exit", "Välju"), + ("Open", "Ava"), ("logout_tip", "Kas soovid kindlasti välja logida?"), - ("Service", ""), - ("Start", ""), - ("Stop", ""), + ("Service", "Teenused"), + ("Start", "Käivita"), + ("Stop", "Peata"), ("exceed_max_devices", "Oled saavutanud hallatavate seadmete maksimaalse arvu."), - ("Sync with recent sessions", ""), - ("Sort tags", ""), - ("Open connection in new tab", ""), - ("Move tab to new window", ""), - ("Can not be empty", ""), - ("Already exists", ""), + ("Sync with recent sessions", "Sünkroniseeri viimaste seanssidega"), + ("Sort tags", "Sorteeri silte"), + ("Open connection in new tab", "Ava ühendus uuel vahekaardil"), + ("Move tab to new window", "Liiguta vahekaart uude aknasse"), + ("Can not be empty", "Ei tohi olla tühi"), + ("Already exists", "Juba eksisteerib"), ("Change Password", "Vaheta parooli"), ("Refresh Password", "Värskenda parool"), - ("ID", ""), + ("ID", "ID"), ("Grid View", "Ruudustikuvaade"), ("List View", "Loendivaade"), - ("Select", ""), + ("Select", "Vali"), ("Toggle Tags", "Lülita silte"), ("pull_ab_failed_tip", "Aadressiraamatu värskendamine ebaõnnestus"), ("push_ab_failed_tip", "Aadressiraamatu sünkroonimine serveriga ebaõnnestus"), @@ -532,126 +527,223 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Primary Color", "Põhivärv"), ("HSV Color", "HSV-värv"), ("Installation Successful!", "Paigaldus oli edukas!"), - ("Installation failed!", ""), - ("Reverse mouse wheel", ""), - ("{} sessions", ""), + ("Installation failed!", "Paigaldus ebaõnnestus!"), + ("Reverse mouse wheel", "Pööra hiireratas"), + ("{} sessions", "{} seanssi"), ("scam_title", "Võid olla KELMUSE ohver!"), ("scam_text1", "Kui räägid telefoniga kellegagi, keda EI TUNNE ja EI USALDA, kes on palunud sul RustDeski kasutada ja teenus käivitada, ära jätka ning lõpeta kõne koheselt."), ("scam_text2", "Tõenäoliselt on tegemist petturiga, kes üritab sinu raha või muid privaatseid andmeid varastada."), - ("Don't show again", ""), - ("I Agree", ""), - ("Decline", ""), - ("Timeout in minutes", ""), + ("Don't show again", "Ära kuva uuesti"), + ("I Agree", "Nõustun"), + ("Decline", "Keeldu"), + ("Timeout in minutes", "Ajalõpp minutites"), ("auto_disconnect_option_tip", "Sissetulevate seansside automaatne sulgemine kasutaja mitteaktiivsuse korral"), ("Connection failed due to inactivity", "Mitteaktiivsuse tõttu automaatselt lahti ühendatud"), - ("Check for software update on startup", ""), + ("Check for software update on startup", "Kontrolli käivitusel tarkvarauuendust"), ("upgrade_rustdesk_server_pro_to_{}_tip", "Palun täienda RustDesk Server Pro versioonile {} või uuem!"), ("pull_group_failed_tip", "Grupi värskendamine ebaõnnestus"), - ("Filter by intersection", ""), - ("Remove wallpaper during incoming sessions", ""), - ("Test", ""), + ("Filter by intersection", "Filtreeri ristumiste järgi"), + ("Remove wallpaper during incoming sessions", "Eemalda taustapilt sissetulevate seansside ajal"), + ("Test", "Test"), ("display_is_plugged_out_msg", "See kuvar on välja lülitatud, lülita esmasele kuvarile."), - ("No displays", ""), - ("Open in new window", ""), - ("Show displays as individual windows", ""), - ("Use all my displays for the remote session", ""), + ("No displays", "Kuvarid puuduvad"), + ("Open in new window", "Ava uues aknas"), + ("Show displays as individual windows", "Kuva kuvarid eraldi akendena"), + ("Use all my displays for the remote session", "Kasuta kõiki minu kuvarid kaugseansi jaoks"), ("selinux_tip", "SELinux on su seadmes lubatud, mis võib RustDeskil takistada juhitud poolel toimimist."), - ("Change view", ""), - ("Big tiles", ""), - ("Small tiles", ""), - ("List", ""), - ("Virtual display", ""), - ("Plug out all", ""), - ("True color (4:4:4)", ""), - ("Enable blocking user input", ""), + ("Change view", "Muuda vaadet"), + ("Big tiles", "Suured plaadid"), + ("Small tiles", "Väikesed plaadid"), + ("List", "Loend"), + ("Virtual display", "Virtuaalne kuvar"), + ("Plug out all", "Eemalda kõik"), + ("True color (4:4:4)", "Tõeline värv (4:4:4)"), + ("Enable blocking user input", "Luba kasutaja sisendi blokeerimine"), ("id_input_tip", "Võid sisestada ID, otsese IP või domeeni koos pordiga (:).\nKui soovid juurdepääsu seadmele mõnes teises serveris, lisa palun serveri aadress (@?key=), näiteks,\n9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=.\nKui soovid juurdepääsu seadmele avalikus serveris, sisesta \"@public\", avaliku serveri puhul ei ole võtit vaja."), ("privacy_mode_impl_mag_tip", "Režiim 1"), ("privacy_mode_impl_virtual_display_tip", "Režiim 2"), - ("Enter privacy mode", ""), - ("Exit privacy mode", ""), + ("Enter privacy mode", "Sisene privaatsusrežiimi"), + ("Exit privacy mode", "Välju privaatsusrežiimist"), ("idd_not_support_under_win10_2004_tip", "Kaudse kuvari draiver ei ole toetatud. Vajalik on Windows 10, versioon 2004 või uuem."), ("input_source_1_tip", "Sisendallikas 1"), ("input_source_2_tip", "Sisendallikas 2"), - ("Swap control-command key", ""), + ("Swap control-command key", "Vaheta klahvid Control ja Command"), ("swap-left-right-mouse", "Vaheta vasak ja parem hiirenupp"), - ("2FA code", ""), - ("More", ""), - ("enable-2fa-title", ""), - ("enable-2fa-desc", ""), - ("wrong-2fa-code", ""), - ("enter-2fa-title", ""), - ("Email verification code must be 6 characters.", ""), - ("2FA code must be 6 digits.", ""), - ("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", ""), - ("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", ""), - ("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", ""), - ("Requires at least {} characters", ""), - ("Wrong PIN", ""), - ("Set 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", ""), + ("2FA code", "2FA kood"), + ("More", "Rohkem"), + ("enable-2fa-title", "Luba kaheastmeline autentimine"), + ("enable-2fa-desc", "Palun seadista oma autentimisrakendus nüüd. Sa saad kasutada autentimisrakendust nagu Authy, Microsoft või Google Authenticator oma telefonis või lauaarvutis.\n\nSkaneeri QR-kood oma rakendusega ja sisesta kood, mida sinu rakendus näitab, et lubada kaheastmeline autentimine."), + ("wrong-2fa-code", "Koodi ei saa kinnitada. Kontrolli, et kood ja kohalikud ajaseaded oleksid õiged."), + ("enter-2fa-title", "Kaheastmeline autentimine"), + ("Email verification code must be 6 characters.", "E-posti kinnituskood peab olema 6 tähemärki."), + ("2FA code must be 6 digits.", "2FA kood peab olema 6 numbrit."), + ("Multiple Windows sessions found", "Leitud mitu Windowsi seanssi"), + ("Please select the session you want to connect to", "Palun vali seanss, millega soovid ühendada"), + ("powered_by_me", "Põhineb RustDeskil"), + ("outgoing_only_desk_tip", "See on kohandatud versioon.\nSa saad ühenduda teiste seadmetega, kuid teised seadmed ei saa sinu seadmega ühenduda."), + ("preset_password_warning", "See kohandatud versioon sisaldab eelmääratud parooli. Igaüks, kes seda parooli teab, võib saada täieliku kontrolli sinu seadme üle. Kui sa ei oodanud seda, desinstalli tarkvara kohe."), + ("Security Alert", "Turvahoiatus"), + ("My address book", "Minu aadressiraamat"), + ("Personal", "Isiklik"), + ("Owner", "Omanik"), + ("Set shared password", "Seadista jagatud parool"), + ("Exist in", "Eksisteerib"), + ("Read-only", "Ainult lugemiseks"), + ("Read/Write", "Lugemiseks/Kirjutamiseks"), + ("Full Control", "Täielik kontroll"), + ("share_warning_tip", "Ülalolevad väljad on teistele jagatud ja nähtavad."), + ("Everyone", "Igaüks"), + ("ab_web_console_tip", "Rohkem leiad veebikonsoolist"), + ("allow-only-conn-window-open-tip", "Luba ühendus ainult siis, kui RustDeski aken on avatud."), + ("no_need_privacy_mode_no_physical_displays_tip", "Füüsilisi ekraane pole, privaatsusrežiimi kasutamine pole vajalik."), + ("Follow remote cursor", "Jälgi kaugkursorit"), + ("Follow remote window focus", "Jälgi kaugakna fookust"), + ("default_proxy_tip", "Vaikimisi protokoll ja port on Socks5 ja 1080."), + ("no_audio_input_device_tip", "Heli sisendseadet ei leitud."), + ("Incoming", "Sissetulev"), + ("Outgoing", "Väljuv"), + ("Clear Wayland screen selection", "Tühjenda Waylandi ekraanivalik"), + ("clear_Wayland_screen_selection_tip", "Pärast ekraanivaliku tühistamist saad uuesti jagatava ekraani valida."), + ("confirm_clear_Wayland_screen_selection_tip", "Kas oled kindel, et soovid Waylandi ekraanivaliku tühistada?"), + ("android_new_voice_call_tip", "Uus häälkõne taotlus on saadud. Vastu võtmisel lülitub heli häälkommunikatsioonile."), + ("texture_render_tip", "Kasuta tekstuurirenderdust, et muuta pildid sujuvamaks. Renderdusprobleemide esinemisel võid proovida selle valiku keelata."), + ("Use texture rendering", "Kasuta tekstuurirenderdust"), + ("Floating window", "Ujuv aken"), + ("floating_window_tip", "See aitab säilitada RustDeski taustateenust."), + ("Keep screen on", "Hoia ekraan sees"), + ("Never", "Mitte kunagi"), + ("During controlled", "Juhtimise ajal"), + ("During service is on", "Teenuse käitamisel"), + ("Capture screen using DirectX", "Salvesta ekraani DirectX abil"), + ("Back", "Tagasi"), + ("Apps", "Rakendused"), + ("Volume up", "Heli üles"), + ("Volume down", "Heli alla"), + ("Power", "Toide"), + ("Telegram bot", "Telegrami bot"), + ("enable-bot-tip", "Kui lubad selle funktsiooni, saad 2FA koodi oma botilt. See võib töötada ka ühenduse teavitusena."), + ("enable-bot-desc", "1. Ava vestlus kasutajaga @BotFather.\n2. Saada käsklus \"/newbot\". Pärast selle sammu lõpetamist saad tokeni.\n3. Alusta vestlust oma uue loodud botiga. Saada sõnum, mis algab kaldkriipsuga (\"/\") nagu \"/hello\", et see aktiveerida.\n"), + ("cancel-2fa-confirm-tip", "Kas oled kindel, et soovid 2FA tühistada?"), + ("cancel-bot-confirm-tip", "Kas oled kindel, et soovid Telegrami boti tühistada?"), + ("About RustDesk", "RustDeski teave"), + ("Send clipboard keystrokes", "Saada lõikelaua klahvivajutused"), + ("network_error_tip", "Palun kontrolli oma võrguühendust ja seejärel klõpsa nuppu \"Proovi uuesti\"."), + ("Unlock with PIN", "Ava PIN-koodiga"), + ("Requires at least {} characters", "Nõuab vähemalt {} tähemärki"), + ("Wrong PIN", "Vale PIN"), + ("Set PIN", "Seadista PIN"), + ("Enable trusted devices", "Luba usaldusväärsed seadmed"), + ("Manage trusted devices", "Halda usaldusväärseid seadmeid"), + ("Platform", "Platvorm"), + ("Days remaining", "Päevi jäänud"), + ("enable-trusted-devices-tip", "Jäta usaldatud seadmetes 2FA kinnitamine vahele"), + ("Parent directory", "Ülemkaust"), + ("Resume", "Jätka"), + ("Invalid file name", "Kehtetu failinimi"), + ("one-way-file-transfer-tip", "Ühesuunaline failiedastus on lubatud juhitataval poolel."), + ("Authentication Required", "Autentimine nõutud"), + ("Authenticate", "Autendi"), + ("web_id_input_tip", "Saad sisestada sama serveri ID, otse IP-juurdepääs ei ole veebikliendis toetatud.\nKui soovid seadmele teises serveris ligi pääseda, palun lisa serveri aadress (@?key=), näiteks,\n9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=.\nKui soovid seadmele avalikus serveris ligi pääseda, palun sisesta \"@public\"; võti ei ole avaliku serveri jaoks vajalik."), + ("Download", "Laadi alla"), + ("Upload folder", "Laadi kaust üles"), + ("Upload files", "Laadi failid üles"), + ("Clipboard is synchronized", "Lõikelaud on sünkroonitud"), + ("Update client clipboard", "Uuenda kliendi lõikelauda"), + ("Untagged", "Sildistamata"), + ("new-version-of-{}-tip", "Saadaval on {} uus versioon"), + ("Accessible devices", "Ligipääsetavad seadmed"), + ("upgrade_remote_rustdesk_client_to_{}_tip", "Täiendage RustDeski klient kaugküljel versioonile {} või uuemale!"), + ("d3d_render_tip", ""), + ("Use D3D rendering", ""), + ("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", ""), + ("Trackpad speed", ""), + ("Default trackpad speed", ""), + ("Numeric one-time password", ""), + ("Enable IPv6 P2P connection", ""), + ("Enable UDP hole punching", ""), + ("View camera", "Vaata kaamerat"), + ("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", ""), + ("allow-insecure-tls-fallback-tip", ""), + ("Disable 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 {}", "Jätka koos {}"), + ("Display Name", ""), + ("password-hidden-tip", ""), + ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/eu.rs b/src/lang/eu.rs index ac958d79c..9e19d1fea 100644 --- a/src/lang/eu.rs +++ b/src/lang/eu.rs @@ -41,7 +41,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("length %min% to %max%", "%min%(e)tik %max% arteko luzera"), ("starts with a letter", "hizki batekin hasten da"), ("allowed characters", "onartutako karaktereak"), - ("id_change_tip", "Soilik a-z, A-Z, 0-9 eta _ (barra baxua) karaktereak daude onartuta. Lehen hizkia a-z, A-Z izan behar da. Luzera 6 eta 16 artekoa izan behar da."), + ("id_change_tip", "Soilik a-z, A-Z, 0-9, - (dash) eta _ (barra baxua) karaktereak daude onartuta. Lehen hizkia a-z, A-Z izan behar da. Luzera 6 eta 16 artekoa izan behar da."), ("Website", "Webgunea"), ("About", "Honi buruz"), ("Slogan_tip", "Bihotzez eginda mundu kaotiko honetan!"), @@ -147,8 +147,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("OS Password", "Sistema eragilearen pasahitza"), ("install_tip", "Erabiltzaile Kontuen Kontrolarengatik, RustDesk ezin du ondo funtzionatu urruneko mahaigainean. EKK saihesteko, mesedez, egin klik azpiko botoian RustDesk sistema mailan instalatzeko."), ("Click to upgrade", "Egin klik bertsio-berritzeko"), - ("Click to download", "Egin klik deskargatzeko"), - ("Click to update", "Egin klik eguneratzeko"), ("Configure", "Konfiguratu"), ("config_acc", "Zure mahaigaina urrunetik kontrolatzeko, RustDesk-i \"Irisgarritasuna\" baimenak eman behar dituzu."), ("config_screen", "Zure mahaigaina kanpotik kontrolatzeko, RustDesk-i \"Pantaila grabatu\" baimena eman behar duzu."), @@ -267,7 +265,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("No permission of file transfer", "Ez duzu baimenik fitxategiak transferitzeko"), ("Note", "Nota"), ("Connection", "Konexioa"), - ("Share Screen", "Partekatu pantaila"), + ("Share screen", "Partekatu pantaila"), ("Chat", "Txata"), ("Total", "Guztira"), ("items", "elementuak"), @@ -275,8 +273,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Screen Capture", "Pantaila-grabazioa"), ("Input Control", "Sarrera-kontrola"), ("Audio Capture", "Audio-grabazioa"), - ("File Connection", "Fitxategi-konexioa"), - ("Screen Connection", "Pantaila-konexioa"), ("Do you accept?", "Onartzen al duzu?"), ("Open System Setting", "Ireki sistemaren ezarpenak"), ("How to get Android input permission?", "Nola lortu dezaket Android sarrera-baimena?"), @@ -381,14 +377,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Keyboard Settings", "Teklatuaren ezarpenak"), ("Full Access", "Sarbide osoa"), ("Screen Share", "Pantailaren partekatzea"), - ("Wayland requires Ubuntu 21.04 or higher version.", "Wayland Ubuntu 21.04 edo bertsio berriagoa behar du."), - ("Wayland requires higher version of linux distro. Please try X11 desktop or change your OS.", "Wayland-ek linux banaketa berriago bat behar du. Saiatu X11 mahaigainarekin edo aldatu zure sistema eragilea."), + ("ubuntu-21-04-required", "Wayland Ubuntu 21.04 edo bertsio berriagoa behar du."), + ("wayland-requires-higher-linux-version", "Wayland-ek linux banaketa berriago bat behar du. Saiatu X11 mahaigainarekin edo aldatu zure sistema eragilea."), + ("xdp-portal-unavailable", ""), ("JumpLink", "Ikusi"), ("Please Select the screen to be shared(Operate on the peer side).", "Mesedez, hautatu partekatuko den pantaila (Kudeatu parekidearen aldean)"), ("Show RustDesk", "Erakutsi RustDesk"), ("This PC", "PC hau"), ("or", "edo"), - ("Continue with", "Jarraitu honekin"), ("Elevate", "Igo maila"), ("Zoom cursor", "Handitu kurtsorea"), ("Accept sessions via password", "Onartu saioak pasahitzaren bidez"), @@ -463,7 +459,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("empty_favorite_tip", "Parekide gogokorik gabe oraindik?\nBilatu norbait konektatzeko eta gehitu zure gogokoetara!"), ("empty_lan_tip", "Ai ez, badirudi ez duzula parekiderik aurkitu oraindik."), ("empty_address_book_tip", "Badirudi ez dagoela parekiderik zure helbide-liburuan."), - ("eg: admin", "adib. admin"), ("Empty Username", "Erabiltzaile-izena hutsik"), ("Empty Password", "Pasahitza hutsik"), ("Me", "Ni"), @@ -653,5 +648,102 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Upload folder", ""), ("Upload files", ""), ("Clipboard is synchronized", ""), + ("Update client clipboard", ""), + ("Untagged", ""), + ("new-version-of-{}-tip", ""), + ("Accessible devices", ""), + ("upgrade_remote_rustdesk_client_to_{}_tip", "Mesedez, eguneratu RustDesk bezeroa {} bertsiora edo berriagoa urruneko aldean!"), + ("d3d_render_tip", ""), + ("Use D3D rendering", ""), + ("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", ""), + ("Trackpad speed", ""), + ("Default trackpad speed", ""), + ("Numeric one-time password", ""), + ("Enable IPv6 P2P connection", ""), + ("Enable UDP hole punching", ""), + ("View camera", "Ikusi kamera"), + ("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", ""), + ("allow-insecure-tls-fallback-tip", ""), + ("Disable 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 {}", "{} honekin jarraitu"), + ("Display Name", ""), + ("password-hidden-tip", ""), + ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/fa.rs b/src/lang/fa.rs index ff4381573..9e01b7eb0 100644 --- a/src/lang/fa.rs +++ b/src/lang/fa.rs @@ -44,7 +44,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("id_change_tip", "شناسه باید طبق این شرایط باشد : حروف کوچک و بزرگ انگلیسی و اعداد از 0 تا 9، _ و همچنین حرف اول آن فقط حروف بزرگ یا کوچک انگلیسی و طول آن بین 6 الی 16 کاراکتر باشد"), ("Website", "وب سایت"), ("About", "درباره"), - ("Slogan_tip", "ساخته شده با قلب(عشق) در این دنیای پر هرج و مرج!"), + ("Slogan_tip", "ساخته شده با ❤️‍(عشق) در این دنیای پر هرج و مرج!"), ("Privacy Statement", "بیانیه حریم خصوصی"), ("Mute", "بستن صدا"), ("Build Date", "تاریخ ساخت"), @@ -147,8 +147,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("OS Password", "رمز عبور سیستم عامل"), ("install_tip", "لطفا برنامه را نصب کنید UAC و جلوگیری از خطای RustDesk برای راحتی در استفاده از نرم افزار"), ("Click to upgrade", "برای ارتقا کلیک کنید"), - ("Click to download", "برای دانلود کلیک کنید"), - ("Click to update", "برای به روز رسانی کلیک کنید"), ("Configure", "تنظیم"), ("config_acc", "بدهید \"access\" مجوز RustDesk برای کنترل از راه دور دسکتاپ باید به"), ("config_screen", "بدهید \"screenshot\" مجوز RustDesk برای کنترل از راه دور دسکتاپ باید به"), @@ -186,7 +184,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Direct and unencrypted connection", "اتصال مستقیم و بدون رمزگذاری"), ("Relayed and unencrypted connection", "و رمزگذاری نشده Relay اتصال از طریق"), ("Enter Remote ID", "شناسه از راه دور را وارد کنید"), - ("Enter your password", "زمر عبور خود را وارد کنید"), + ("Enter your password", "رمز عبور خود را وارد کنید"), ("Logging in...", "...در حال ورود"), ("Enable RDP session sharing", "را فعال کنید RDP اشتراک گذاری جلسه"), ("Auto Login", "ورود خودکار"), @@ -202,7 +200,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Login screen using Wayland is not supported", "پشتیبانی نمی شود Wayland ورود به سیستم با استفاده از "), ("Reboot required", "راه اندازی مجدد مورد نیاز است"), ("Unsupported display server", "سرور تصویر پشتیبانی نشده است"), - ("x11 expected", ""), + ("x11 expected", "X11 مورد انتظار است"), ("Port", "پورت"), ("Settings", "تنظیمات"), ("Username", "نام کاربری"), @@ -260,14 +258,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Three-Finger vertically", "سه انگشت عمودی"), ("Mouse Wheel", "چرخ ماوس"), ("Two-Finger Move", "با دو انگشت حرکت کنید"), - ("Canvas Move", ""), + ("Canvas Move", "حرکت دادن صفحه"), ("Pinch to Zoom", "با دو انگشت بکشید تا زوم شود"), - ("Canvas Zoom", ""), - ("Reset canvas", ""), + ("Canvas Zoom", "بزرگنمایی صفحه"), + ("Reset canvas", "بازنشانی صفحه"), ("No permission of file transfer", "مجوز انتقال فایل داده نشده"), ("Note", "یادداشت"), ("Connection", "ارتباط"), - ("Share Screen", "اشتراک گذاری صفحه"), + ("Share screen", "اشتراک گذاری صفحه"), ("Chat", "چت"), ("Total", "مجموع"), ("items", "آیتم ها"), @@ -275,8 +273,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Screen Capture", "ضبط صفحه"), ("Input Control", "کنترل ورودی"), ("Audio Capture", "ضبط صدا"), - ("File Connection", "ارتباط فایل"), - ("Screen Connection", "ارتباط صفحه"), ("Do you accept?", "آیا می پذیرید؟"), ("Open System Setting", "باز کردن تنظیمات سیستم"), ("How to get Android input permission?", "چگونه مجوز ورود به سیستم اندروید را دریافت کنیم؟"), @@ -364,7 +360,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Recording", "در حال ضبط"), ("Directory", "مسیر"), ("Automatically record incoming sessions", "ضبط خودکار جلسات ورودی"), - ("Automatically record outgoing sessions", ""), + ("Automatically record outgoing sessions", "ضبط خودکار جلسات خروجی"), ("Change", "تغییر"), ("Start session recording", "شروع ضبط جلسه"), ("Stop session recording", "توقف ضبط جلسه"), @@ -381,14 +377,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Keyboard Settings", "تنظیمات صفحه کلید"), ("Full Access", "دسترسی کامل"), ("Screen Share", "اشتراک گذاری صفحه"), - ("Wayland requires Ubuntu 21.04 or higher version.", "نیازمند اوبونتو نسخه 21.04 یا بالاتر است Wayland"), - ("Wayland requires higher version of linux distro. Please try X11 desktop or change your OS.", "استفاده کنید و یا سیستم عامل خود را تغییر دهید X11 نیازمند نسخه بالاتری از توزیع لینوکس است. لطفا از دسکتاپ با سیستم"), + ("ubuntu-21-04-required", "نیازمند اوبونتو نسخه 21.04 یا بالاتر است Wayland"), + ("wayland-requires-higher-linux-version", "استفاده کنید و یا سیستم عامل خود را تغییر دهید X11 نیازمند نسخه بالاتری از توزیع لینوکس است. لطفا از دسکتاپ با سیستم"), + ("xdp-portal-unavailable", ""), ("JumpLink", "چشم انداز"), ("Please Select the screen to be shared(Operate on the peer side).", "لطفاً صفحه‌ای را برای اشتراک‌گذاری انتخاب کنید (در سمت همتا به همتا کار کنید)."), ("Show RustDesk", "RustDesk نمایش"), ("This PC", "This PC"), ("or", "یا"), - ("Continue with", "ادامه با"), ("Elevate", "ارتقاء"), ("Zoom cursor", " بزرگنمایی نشانگر ماوس"), ("Accept sessions via password", "قبول درخواست با رمز عبور"), @@ -440,7 +436,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Default Image Quality", "کیفیت تصویر پیش فرض"), ("Default Codec", "کدک پیش فرض"), ("Bitrate", "میزان بیت صفحه نمایش"), - ("FPS", "FPS"), + ("FPS", "فریم در ثانیه"), ("Auto", "خودکار"), ("Other Default Options", "سایر گزینه های پیش فرض"), ("Voice call", "تماس صوتی"), @@ -463,7 +459,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("empty_favorite_tip", "هنوز همتای مورد علاقه‌ای ندارید؟\nبیایید فردی را برای ارتباط پیدا کنیم و آن را به موارد دلخواه خود اضافه کنیم!"), ("empty_lan_tip", "اوه نه، به نظر می رسد که ما هنوز همتای خود را پیدا نکرده ایم"), ("empty_address_book_tip", "اوه ، به نظر می رسد که در حال حاضر هیچ همتایی در دفترچه آدرس شما وجود ندارد"), - ("eg: admin", "مثال : admin"), ("Empty Username", "نام کاربری خالی است"), ("Empty Password", "رمز عبور خالی است"), ("Me", "من"), @@ -530,7 +525,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("synced_peer_readded_tip", "دستگاه هایی که در جلسات اخیر حضور داشتند با دفترچه آدرس همگام سازی می شوند"), ("Change Color", "تغییر رنگ"), ("Primary Color", "رنگ اولیه"), - ("HSV Color", "رنگ HDV"), + ("HSV Color", "رنگ HSV"), ("Installation Successful!", "نصب با موفقیت انجام شد!"), ("Installation failed!", "نصب انجام نشد!"), ("Reverse mouse wheel", "معکوس کردن چرخ موس"), @@ -547,7 +542,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Check for software update on startup", "در هنگلم شروع برنامه بروزرسانی را بررسی کن"), ("upgrade_rustdesk_server_pro_to_{}_tip", "را به نسخه {} یا جدیدتر ارتقا دهید RustDesk Server Pro لظفا"), ("pull_group_failed_tip", "گروه بازخوانی نشد"), - ("Filter by intersection", ""), + ("Filter by intersection", "فیلتر بر اساس اشتراک"), ("Remove wallpaper during incoming sessions", "را در جلسات ورودی حذف کنید Wallpaper"), ("Test", "تست"), ("display_is_plugged_out_msg", "صفحه نمایش قطع شده است، به صفحه نمایش اول بروید."), @@ -602,12 +597,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("allow-only-conn-window-open-tip", "باز است اتصال برقرار شود RustDesk زمانی که"), ("no_need_privacy_mode_no_physical_displays_tip", "بدون نمایشگر فیزیکی نیازی به استفاده از حالت خصوصی نیست"), ("Follow remote cursor", "مکان نما ریموت را دنبال کنید"), - ("Follow remote window focus", ""), + ("Follow remote window focus", "دنبال کردن فوکوس پنجره راه دور"), ("default_proxy_tip", "و پورت 1080 می باشد Sock5 پرونکل پیش فرض"), ("no_audio_input_device_tip", "دستگاه ورودی صوتی پیدا نشد"), ("Incoming", "ورودی"), ("Outgoing", "خروجی"), - ("Clear Wayland screen selection", ""), + ("Clear Wayland screen selection", "پاک کردن انتخاب صفحه Wayland"), ("clear_Wayland_screen_selection_tip", "پس از پاک کردن صفحه انتخابی، می توانید صفحه را برای اشتراک گذاری مجدد انتخاب کنید"), ("confirm_clear_Wayland_screen_selection_tip", "را پاک می کنید؟ Wayland آیا مطمئن هستید که انتخاب صفحه"), ("android_new_voice_call_tip", "یک درخواست تماس صوتی جدید دریافت شد. اگر بپذیرید، صدا به ارتباط صوتی تغییر خواهد کرد."), @@ -617,17 +612,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("floating_window_tip", "کمک می کند RustDesk این به حفظ سرویس پس زمینه"), ("Keep screen on", "صفحه نمایش را روشن نگه دارید"), ("Never", "هرگز"), - ("During controlled", ""), - ("During service is on", ""), + ("During controlled", "در حین کنترل"), + ("During service is on", "در حین سرویس روشن است"), ("Capture screen using DirectX", "DirectX تصویربرداری از صفحه نمایش با استفاده از"), ("Back", "برگشت"), ("Apps", "برنامه ها"), ("Volume up", "افزایش صدا"), ("Volume down", "کاهش صدا"), - ("Power", ""), + ("Power", "پاور"), ("Telegram bot", "ربات تلگرام"), ("enable-bot-tip", "اگر این ویژگی را فعال کنید، می توانید کد تائید دو مرحله ای را از ربات خود دریافت کنید. همچنین می تواند به عنوان یک اعلان اتصال عمل کند."), - ("enable-bot-desc", ""), + ("enable-bot-desc", "ربات، اعلان‌های اتصال و کدهای تأیید دو مرحله‌ای را برای شما ارسال می‌کند."), ("cancel-2fa-confirm-tip", "آیا مطمئن هستید که می خواهید تائید دو مرحله ای را لغو کنید؟"), ("cancel-bot-confirm-tip", "آیا مطمئن هستید که می خواهید ربات تلگرام را لغو کنید؟"), ("About RustDesk", "RustDesk درباره"), @@ -637,21 +632,118 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Requires at least {} characters", "حداقل به {} کاراکترها نیاز دارد"), ("Wrong PIN", "پین اشتباه است"), ("Set 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", ""), + ("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", "لطفاً RustDesk را به نسخه {} یا جدیدتر در سمت راه دور ارتقا دهید"), + ("d3d_render_tip", "فعال کردن رندر D3D برای عملکرد بهتر"), + ("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", "فقط اتصالات رله پشتیبانی می شوند ، WebSocket هنگام استفاده از ."), + ("Use WebSocket", "استفاده کنید WebSocket از"), + ("Trackpad speed", "سرعت ترک‌پد"), + ("Default trackpad speed", "سرعت پیش‌فرض ترک‌پد"), + ("Numeric one-time password", "رمز عبور یک‌بار مصرف عددی"), + ("Enable IPv6 P2P connection", "فعال‌سازی اتصال همتا‌به‌همتای IPv6"), + ("Enable UDP hole punching", "فعال‌سازی تکنیک UDP hole punching"), + ("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", "وارد نمایید domain\\username یا username نام کاربری را به صورت"), + ("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", "به‌طور پیش‌فرض، RustDesk گواهی سرور را برای پروتکل‌ها با استفاده از TLS تأیید می‌کند.\nبا فعال بودن این گزینه، RustDesk دوباره مرحله تأیید را رد می‌کند و در صورت عدم موفقیت تأیید ادامه می‌دهد."), + ("Disable UDP", "UDP غیر فعال کردن"), + ("disable-udp-tip", "کنترل می کند که آیا فقط از TCP استفاده شود یا خیر.\nوقتی این گزینه فعال باشد، RustDesk دیگر از UDP 21116 استفاده نمی کند، به جای آن از TCP 21116 استفاده می شود."), + ("server-oss-not-support-tip", "توجه: سرور RustDesk OSS این ویژگی را ندارد."), + ("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 {}", "ادامه با {}"), + ("Display Name", ""), + ("password-hidden-tip", ""), + ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/fi.rs b/src/lang/fi.rs new file mode 100644 index 000000000..f8283685b --- /dev/null +++ b/src/lang/fi.rs @@ -0,0 +1,749 @@ +lazy_static::lazy_static! { +pub static ref T: std::collections::HashMap<&'static str, &'static str> = + [ + ("Status", "Tila"), + ("Your Desktop", "Oma työpöytä"), + ("desk_tip", "Työpöytääsi voidaan käyttää tällä tunnuksella ja salasanalla."), + ("Password", "Salasana"), + ("Ready", "Valmis"), + ("Established", "Yhdistetty"), + ("connecting_status", "Yhdistetään RustDesk verkkoon..."), + ("Enable service", "Ota palvelu käyttöön"), + ("Start service", "Käynnistä palvelu"), + ("Service is running", "Palvelu on käynnissä"), + ("Service is not running", "Palvelu ei ole käynnissä"), + ("not_ready_status", "Ei valmis tarkista yhteys."), + ("Control Remote Desktop", "Hallitse etätyöpöytää"), + ("Transfer file", "Siirrä tiedosto"), + ("Connect", "Yhdistä"), + ("Recent sessions", "Viimeisimmät istunnot"), + ("Address book", "Osoitekirja"), + ("Confirmation", "Vahvistus"), + ("TCP tunneling", "TCP tunnelointi"), + ("Remove", "Poista"), + ("Refresh random password", "Päivitä satunnainen salasana"), + ("Set your own password", "Aseta oma salasana"), + ("Enable keyboard/mouse", "Salli näppäimistö ja hiiri"), + ("Enable clipboard", "Salli leikepöytä"), + ("Enable file transfer", "Salli tiedostonsiirto"), + ("Enable TCP tunneling", "Salli TCP tunnelointi"), + ("IP Whitelisting", "IP osoitteiden sallintalista"), + ("ID/Relay Server", "ID/Välityspalvelin"), + ("Import server config", "Tuo palvelimen asetukset"), + ("Export Server Config", "Vie palvelimen asetukset"), + ("Import server configuration successfully", "Palvelimen asetukset tuotu onnistuneesti"), + ("Export server configuration successfully", "Palvelimen asetukset viety onnistuneesti"), + ("Invalid server configuration", "Virheellinen palvelimen määritys"), + ("Clipboard is empty", "Leikepöytä on tyhjä"), + ("Stop service", "Pysäytä palvelu"), + ("Change ID", "Vaihda ID"), + ("Your new ID", "Uusi ID"), + ("length %min% to %max%", "pituus %min%–%max%"), + ("starts with a letter", "alkaa kirjaimella"), + ("allowed characters", "sallitut merkit"), + ("id_change_tip", "Sallitut merkit: a–z, A–Z, 0–9, - ja _. Ensimmäisen merkin on oltava kirjain. Pituus 6–16 merkkiä."), + ("Website", "Verkkosivusto"), + ("About", "Tietoa"), + ("Slogan_tip", "Tehty sydämellä tässä kaoottisessa maailmassa!"), + ("Privacy Statement", "Tietosuojaseloste"), + ("Mute", "Mykistä"), + ("Build Date", "Koontipäivä"), + ("Version", "Versio"), + ("Home", "Etusivu"), + ("Audio Input", "Äänitulo"), + ("Enhancements", "Parannukset"), + ("Hardware Codec", "Laitteistokoodekki"), + ("Adaptive bitrate", "Mukautuva bittinopeus"), + ("ID Server", "ID palvelin"), + ("Relay Server", "Välityspalvelin"), + ("API Server", "API palvelin"), + ("invalid_http", "Osoitteen on alettava http:// tai https://"), + ("Invalid IP", "Virheellinen IP osoite"), + ("Invalid format", "Virheellinen muoto"), + ("server_not_support", "Palvelin ei tue tätä ominaisuutta"), + ("Not available", "Ei saatavilla"), + ("Too frequent", "Liian tiheä pyyntö"), + ("Cancel", "Peruuta"), + ("Skip", "Ohita"), + ("Close", "Sulje"), + ("Retry", "Yritä uudelleen"), + ("OK", "OK"), + ("Password Required", "Salasana vaaditaan"), + ("Please enter your password", "Syötä salasanasi"), + ("Remember password", "Muista salasana"), + ("Wrong Password", "Väärä salasana"), + ("Do you want to enter again?", "Haluatko yrittää uudelleen?"), + ("Connection Error", "Yhteysvirhe"), + ("Error", "Virhe"), + ("Reset by the peer", "Yhteys katkaistu vastapuolen toimesta"), + ("Connecting...", "Yhdistetään..."), + ("Connection in progress. Please wait.", "Yhdistetään – odota hetki."), + ("Please try 1 minute later", "Yritä uudelleen minuutin kuluttua"), + ("Login Error", "Kirjautumisvirhe"), + ("Successful", "Onnistui"), + ("Connected, waiting for image...", "Yhdistetty, odotetaan kuvaa..."), + ("Name", "Nimi"), + ("Type", "Tyyppi"), + ("Modified", "Muokattu"), + ("Size", "Koko"), + ("Show Hidden Files", "Näytä piilotetut tiedostot"), + ("Receive", "Vastaanota"), + ("Send", "Lähetä"), + ("Refresh File", "Päivitä tiedosto"), + ("Local", "Paikallinen"), + ("Remote", "Etä"), + ("Remote Computer", "Etätietokone"), + ("Local Computer", "Paikallinen tietokone"), + ("Confirm Delete", "Vahvista poisto"), + ("Delete", "Poista"), + ("Properties", "Ominaisuudet"), + ("Multi Select", "Monivalinta"), + ("Select All", "Valitse kaikki"), + ("Unselect All", "Poista kaikki valinnat"), + ("Empty Directory", "Tyhjä kansio"), + ("Not an empty directory", "Hakemisto ei ole tyhjä"), + ("Are you sure you want to delete this file?", "Haluatko varmasti poistaa tämän tiedoston?"), + ("Are you sure you want to delete this empty directory?", "Haluatko varmasti poistaa tämän tyhjän hakemiston?"), + ("Are you sure you want to delete the file of this directory?", "Haluatko varmasti poistaa tämän hakemiston tiedoston?"), + ("Do this for all conflicts", "Tee sama kaikille ristiriidoille"), + ("This is irreversible!", "Tätä toimintoa ei voi perua!"), + ("Deleting", "Poistetaan"), + ("files", "tiedostoa"), + ("Waiting", "Odotetaan"), + ("Finished", "Valmis"), + ("Speed", "Nopeus"), + ("Custom Image Quality", "Mukautettu kuvanlaatu"), + ("Privacy mode", "Yksityisyystila"), + ("Block user input", "Estä käyttäjän toiminta"), + ("Unblock user input", "Salli käyttäjän toiminta"), + ("Adjust Window", "Sovita ikkuna"), + ("Original", "Alkuperäinen"), + ("Shrink", "Pienennä"), + ("Stretch", "Venytä"), + ("Scrollbar", "Vierityspalkki"), + ("ScrollAuto", "Automaattinen vieritys"), + ("Good image quality", "Hyvä kuvanlaatu"), + ("Balanced", "Tasapainotettu"), + ("Optimize reaction time", "Optimoi vasteaika"), + ("Custom", "Mukautettu"), + ("Show remote cursor", "Näytä etäkursori"), + ("Show quality monitor", "Näytä laadunvalvonta"), + ("Disable clipboard", "Poista leikepöytä käytöstä"), + ("Lock after session end", "Lukitse istunnon päätyttyä"), + ("Insert Ctrl + Alt + Del", "Lähetä Ctrl + Alt + Del"), + ("Insert Lock", "Aseta lukitse"), + ("Refresh", "Päivitä"), + ("ID does not exist", "Tunnusta ei ole olemassa"), + ("Failed to connect to rendezvous server", "Yhteys tapaamispalvelimeen epäonnistui"), + ("Please try later", "Yritä myöhemmin uudelleen"), + ("Remote desktop is offline", "Etätyöpöytä ei ole online tilassa"), + ("Key mismatch", "Avaimet eivät täsmää"), + ("Timeout", "Aikakatkaisu"), + ("Failed to connect to relay server", "Yhteys välityspalvelimeen epäonnistui"), + ("Failed to connect via rendezvous server", "Yhteys tapaamispalvelimen kautta epäonnistui"), + ("Failed to connect via relay server", "Yhteys välityspalvelimen kautta epäonnistui"), + ("Failed to make direct connection to remote desktop", "Suora yhteys etätyöpöytään epäonnistui"), + ("Set Password", "Aseta salasana"), + ("OS Password", "Käyttöjärjestelmän salasana"), + ("install_tip", "Joissain tapauksissa RustDesk ei toimi oikein etäpuolella UAC:n vuoksi. Välttääksesi tämän, napsauta alla olevaa painiketta asentaaksesi RustDeskin järjestelmään."), + ("Click to upgrade", "Päivitä napsauttamalla"), + ("Configure", "Määritä"), + ("config_acc", "Etätyöpöydän hallintaa varten sinun on annettava RustDeskille ”Esteettömyys”-oikeudet."), + ("config_screen", "Etätyöpöydän käyttöä varten sinun on annettava RustDeskille ”Näytön tallennus” oikeudet."), + ("Installing ...", "Asennetaan ..."), + ("Install", "Asenna"), + ("Installation", "Asennus"), + ("Installation Path", "Asennuspolku"), + ("Create start menu shortcuts", "Luo pikakuvakkeet Käynnistä valikkoon"), + ("Create desktop icon", "Luo kuvake työpöydälle"), + ("agreement_tip", "Aloittamalla asennuksen hyväksyt käyttöoikeussopimuksen."), + ("Accept and Install", "Hyväksy ja asenna"), + ("End-user license agreement", "Käyttöoikeussopimus"), + ("Generating ...", "Luodaan ..."), + ("Your installation is lower version.", "Asennettu versio on vanhempi."), + ("not_close_tcp_tip", "Älä sulje tätä ikkunaa tunnelin ollessa käytössä"), + ("Listening ...", "Kuunnellaan ..."), + ("Remote Host", "Etätietokone"), + ("Remote Port", "Etäportti"), + ("Action", "Toiminto"), + ("Add", "Lisää"), + ("Local Port", "Paikallinen portti"), + ("Local Address", "Paikallinen osoite"), + ("Change Local Port", "Vaihda paikallinen porttia"), + ("setup_server_tip", "Nopeampaa yhteyttä varten voit asettaa oman palvelimen"), + ("Too short, at least 6 characters.", "Liian lyhyt, vähintään 6 merkkiä."), + ("The confirmation is not identical.", "Vahvistus ei täsmää."), + ("Permissions", "Oikeudet"), + ("Accept", "Hyväksy"), + ("Dismiss", "Hylkää"), + ("Disconnect", "Katkaise yhteys"), + ("Enable file copy and paste", "Salli tiedostojen kopiointi ja liittäminen"), + ("Connected", "Yhdistetty"), + ("Direct and encrypted connection", "Suora ja salattu yhteys"), + ("Relayed and encrypted connection", "Välitetty ja salattu yhteys"), + ("Direct and unencrypted connection", "Suora ja salaamaton yhteys"), + ("Relayed and unencrypted connection", "Välitetty ja salaamaton yhteys"), + ("Enter Remote ID", "Anna ID"), + ("Enter your password", "Syötä salasanasi"), + ("Logging in...", "Kirjaudutaan sisään..."), + ("Enable RDP session sharing", "Salli RDP istunnon jakaminen"), + ("Auto Login", "Automaattinen kirjautuminen"), + ("Enable direct IP access", "Salli suora IP yhteys"), + ("Rename", "Nimeä uudelleen"), + ("Space", "Välilyönti"), + ("Create desktop shortcut", "Luo työpöydän pikakuvake"), + ("Change Path", "Vaihda polku"), + ("Create Folder", "Luo kansio"), + ("Please enter the folder name", "Anna kansion nimi"), + ("Fix it", "Korjaa"), + ("Warning", "Varoitus"), + ("Login screen using Wayland is not supported", "Kirjautumisnäyttö Waylandilla ei ole tuettu"), + ("Reboot required", "Uudelleenkäynnistys vaaditaan"), + ("Unsupported display server", "Näyttöpalvelin ei ole tuettu"), + ("x11 expected", "X11 odotettu"), + ("Port", "Portti"), + ("Settings", "Asetukset"), + ("Username", "Käyttäjänimi"), + ("Invalid port", "Virheellinen portti"), + ("Closed manually by the peer", "Suljettu vastapuolen toimesta"), + ("Enable remote configuration modification", "Salli etäasetusten muokkaus"), + ("Run without install", "Suorita ilman asennusta"), + ("Connect via relay", "Yhdistä välityspalvelimen kautta"), + ("Always connect via relay", "Yhdistä aina välityspalvelimen kautta"), + ("whitelist_tip", "Vain sallitut IP osoitteet voivat muodostaa yhteyden"), + ("Login", "Kirjaudu sisään"), + ("Verify", "Vahvista"), + ("Remember me", "Muista minut"), + ("Trust this device", "Luota tähän laitteeseen"), + ("Verification code", "Vahvistuskoodi"), + ("verification_tip", "Vahvistuskoodi on lähetetty rekisteröityyn sähköpostiosoitteeseen. Syötä koodi jatkaaksesi kirjautumista."), + ("Logout", "Kirjaudu ulos"), + ("Tags", "Tunnisteet"), + ("Search ID", "Hae ID"), + ("whitelist_sep", "Valkoisen listan erotin"), + ("Add ID", "Lisää ID"), + ("Add Tag", "Lisää tunniste"), + ("Unselect all tags", "Poista kaikki tunnistevalinnat"), + ("Network error", "Verkkovirhe"), + ("Username missed", "Käyttäjänimi puuttuu"), + ("Password missed", "Salasana puuttuu"), + ("Wrong credentials", "Virheelliset kirjautumistiedot"), + ("The verification code is incorrect or has expired", "Vahvistuskoodi on virheellinen tai vanhentunut"), + ("Edit Tag", "Muokkaa tunnistetta"), + ("Forget Password", "Unohditko salasanasi"), + ("Favorites", "Suosikit"), + ("Add to Favorites", "Lisää suosikkeihin"), + ("Remove from Favorites", "Poista suosikeista"), + ("Empty", "Tyhjä"), + ("Invalid folder name", "Virheellinen kansion nimi"), + ("Socks5 Proxy", "Socks5 välityspalvelin"), + ("Socks5/Http(s) Proxy", "Socks5/HTTP(s)-välityspalvelin"), + ("Discovered", "Löydetty"), + ("install_daemon_tip", "Palvelun automaattista käynnistystä varten RustDesk daemon on asennettava järjestelmään."), + ("Remote ID", "Etätunnus"), + ("Paste", "Liitä"), + ("Paste here?", "Liitä tähän?"), + ("Are you sure to close the connection?", "Haluatko varmasti katkaista yhteyden?"), + ("Download new version", "Lataa uusi versio"), + ("Touch mode", "Kosketustila"), + ("Mouse mode", "Hiiritila"), + ("One-Finger Tap", "Yksi sormipainallus"), + ("Left Mouse", "Vasen hiiren painike"), + ("One-Long Tap", "Pitkä painallus yhdellä sormella"), + ("Two-Finger Tap", "Kahden sormen napautus"), + ("Right Mouse", "Oikea hiiren painike"), + ("One-Finger Move", "Yhden sormen liike"), + ("Double Tap & Move", "Kaksoisnapautus ja liike"), + ("Mouse Drag", "Vedä hiirellä"), + ("Three-Finger vertically", "Kolmen sormen pystysuora liike"), + ("Mouse Wheel", "Hiiren rulla"), + ("Two-Finger Move", "Kahden sormen liike"), + ("Canvas Move", "Siirrä näkymää"), + ("Pinch to Zoom", "Lähennä tai loitonna"), + ("Canvas Zoom", "Suurennus"), + ("Reset canvas", "Palauta näkymä"), + ("No permission of file transfer", "Ei oikeutta tiedostonsiirtoon"), + ("Note", "Huomautus"), + ("Connection", "Yhteys"), + ("Share screen", "Jaa näyttö"), + ("Chat", "Keskustelu"), + ("Total", "Yhteensä"), + ("items", "kohdetta"), + ("Selected", "Valittu"), + ("Screen Capture", "Näytön kaappaus"), + ("Input Control", "Tulon hallinta"), + ("Audio Capture", "Äänen tallennus"), + ("Do you accept?", "Hyväksytkö?"), + ("Open System Setting", "Avaa järjestelmäasetukset"), + ("How to get Android input permission?", "Kuinka myöntää Androidin oikeudet?"), + ("android_input_permission_tip1", "Siirry Androidin asetuksiin ja ota RustDeskille käyttöön 'Syötteen ohjaus' oikeus."), + ("android_input_permission_tip2", "Jos et löydä asetusta, etsi 'Esteettömyys' ja salli RustDesk ohjelman käyttö."), + ("android_new_connection_tip", "Uusi yhteyspyyntö vastaanotettu."), + ("android_service_will_start_tip", "RustDesk palvelu käynnistyy taustalla."), + ("android_stop_service_tip", "Pysäytä taustapalvelu tarvittaessa RustDeskin asetuksista."), + ("android_version_audio_tip", "Äänensiirto vaatii Android 10:n tai uudemman."), + ("android_start_service_tip", "RustDesk palvelu käynnistetään..."), + ("android_permission_may_not_change_tip", "Oikeudet eivät ehkä päivity heti. Käynnistä sovellus uudelleen, jos muutokset eivät tule voimaan."), + ("Account", "Tili"), + ("Overwrite", "Korvaa"), + ("This file exists, skip or overwrite this file?", "Tämä tiedosto on jo olemassa, ohitetaanko vai korvataanko se?"), + ("Quit", "Poistu"), + ("Help", "Ohje"), + ("Failed", "Epäonnistui"), + ("Succeeded", "Onnistui"), + ("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"), + ("Language", "Kieli"), + ("Keep RustDesk background service", "Pidä RustDeskin taustapalvelu käynnissä"), + ("Ignore Battery Optimizations", "Ohita akun optimoinnit"), + ("android_open_battery_optimizations_tip", "Poista RustDeskin akkuoptimointi, jotta yhteys pysyy vakaana taustalla."), + ("Start on boot", "Käynnistä automaattisesti laitteen käynnistyessä"), + ("Start the screen sharing service on boot, requires special permissions", "Käynnistä näytönjakopalvelu laitteen käynnistyessä (vaatii erityisoikeudet)"), + ("Connection not allowed", "Yhteyttä ei sallita"), + ("Legacy mode", "Perinteinen tila"), + ("Map mode", "Karttatila"), + ("Translate mode", "Käännöstila"), + ("Use permanent password", "Käytä pysyvää salasanaa"), + ("Use both passwords", "Käytä molempia salasanoja"), + ("Set permanent password", "Aseta pysyvä salasana"), + ("Enable remote restart", "Salli etäuudelleenkäynnistys"), + ("Restart remote device", "Käynnistä etälaite uudelleen"), + ("Are you sure you want to restart", "Haluatko varmasti käynnistää laitteen uudelleen?"), + ("Restarting remote device", "Etälaitetta käynnistetään uudelleen"), + ("remote_restarting_tip", "Odota, kunnes etälaite käynnistyy uudelleen ja muodostaa yhteyden."), + ("Copied", "Kopioitu"), + ("Exit Fullscreen", "Poistu koko näytöstä"), + ("Fullscreen", "Koko näyttö"), + ("Mobile Actions", "Puhelin toiminnot"), + ("Select Monitor", "Valitse näyttö"), + ("Control Actions", "Ohjaustoiminnot"), + ("Display Settings", "Näyttöasetukset"), + ("Ratio", "Suhde"), + ("Image Quality", "Kuvanlaatu"), + ("Scroll Style", "Vieritystyyli"), + ("Show Toolbar", "Näytä työkalupalkki"), + ("Hide Toolbar", "Piilota työkalupalkki"), + ("Direct Connection", "Suora yhteys"), + ("Relay Connection", "Välitetty yhteys"), + ("Secure Connection", "Suojattu yhteys"), + ("Insecure Connection", "Suojaamaton yhteys"), + ("Scale original", "Skaalaa alkuperäinen"), + ("Scale adaptive", "Mukautuva skaalaus"), + ("General", "Yleiset"), + ("Security", "Turvallisuus"), + ("Theme", "Teema"), + ("Dark Theme", "Tumma teema"), + ("Light Theme", "Vaalea teema"), + ("Dark", "Tumma"), + ("Light", "Vaalea"), + ("Follow System", "Seuraa järjestelmän teemaa"), + ("Enable hardware codec", "Käytä laitteistokoodausta"), + ("Unlock Security Settings", "Avaa suojausasetukset"), + ("Enable audio", "Ota ääni käyttöön"), + ("Unlock Network Settings", "Avaa verkkoasetukset"), + ("Server", "Palvelin"), + ("Direct IP Access", "Suora IP yhteys"), + ("Proxy", "Välityspalvelin"), + ("Apply", "Käytä"), + ("Disconnect all devices?", "Katkaistaanko yhteys kaikkiin laitteisiin?"), + ("Clear", "Tyhjennä"), + ("Audio Input Device", "Äänitulolaite"), + ("Use IP Whitelisting", "Käytä IP sallitut listaa"), + ("Network", "Verkko"), + ("Pin Toolbar", "Kiinnitä työkalupalkki"), + ("Unpin Toolbar", "Irrota työkalupalkki"), + ("Recording", "Tallennus"), + ("Directory", "Hakemisto"), + ("Automatically record incoming sessions", "Tallenna saapuvat istunnot automaattisesti"), + ("Automatically record outgoing sessions", "Tallenna lähtevät istunnot automaattisesti"), + ("Change", "Vaihda"), + ("Start session recording", "Aloita istunnon tallennus"), + ("Stop session recording", "Lopeta istunnon tallennus"), + ("Enable recording session", "Ota istunnon tallennus käyttöön"), + ("Enable LAN discovery", "Ota LAN havaitseminen käyttöön"), + ("Deny LAN discovery", "Estä LAN havaitseminen"), + ("Write a message", "Kirjoita viesti"), + ("Prompt", "Kehote"), + ("Please wait for confirmation of UAC...", "Odota UAC hyväksyntää..."), + ("elevated_foreground_window_tip", "Käyttäjävalvontaikkuna on etualalla, hyväksy pyyntö etäkäytön jatkamiseksi."), + ("Disconnected", "Yhteys katkaistu"), + ("Other", "Muu"), + ("Confirm before closing multiple tabs", "Vahvista ennen useiden välilehtien sulkemista"), + ("Keyboard Settings", "Näppäimistöasetukset"), + ("Full Access", "Täysi käyttöoikeus"), + ("Screen Share", "Näytönjako"), + ("ubuntu-21-04-required", "Wayland vaatii Ubuntu 21.04:n tai uudemman version."), + ("wayland-requires-higher-linux-version", "Wayland vaatii uudemman Linux jakelun version. Kokeile X11 työpöytää tai vaihda käyttöjärjestelmää."), + ("xdp-portal-unavailable", ""), + ("JumpLink", "Pikalinkki"), + ("Please Select the screen to be shared(Operate on the peer side).", "Valitse jaettava näyttö (toiminto etäpäässä)."), + ("Show RustDesk", "Näytä RustDesk"), + ("This PC", "Tämä tietokone"), + ("or", "tai"), + ("Elevate", "Korota oikeudet"), + ("Zoom cursor", "Suurennusosoitin"), + ("Accept sessions via password", "Hyväksy istunnot salasanalla"), + ("Accept sessions via click", "Hyväksy istunnot napsauttamalla"), + ("Accept sessions via both", "Hyväksy istunnot kummallakin tavalla"), + ("Please wait for the remote side to accept your session request...", "Odota, että etäpää hyväksyy istuntopyyntösi..."), + ("One-time Password", "Kertakäyttösalasana"), + ("Use one-time password", "Käytä kertakäyttösalasanaa"), + ("One-time password length", "Kertakäyttösalasanan pituus"), + ("Request access to your device", "Pyydä pääsyä laitteeseesi"), + ("Hide connection management window", "Piilota yhteydenhallintaikkuna"), + ("hide_cm_tip", "Yhteydenhallintaikkuna voidaan piilottaa, jotta etäistunto ei keskeydy."), + ("wayland_experiment_tip", "Wayland tuki on kokeellinen ja saattaa aiheuttaa yhteysongelmia."), + ("Right click to select tabs", "Valitse välilehti hiiren oikealla painikkeella"), + ("Skipped", "Ohitettu"), + ("Add to address book", "Lisää osoitekirjaan"), + ("Group", "Ryhmä"), + ("Search", "Haku"), + ("Closed manually by web console", "Suljettu manuaalisesti verkkokonsolista"), + ("Local keyboard type", "Paikallinen näppäimistötyyppi"), + ("Select local keyboard type", "Valitse paikallinen näppäimistötyyppi"), + ("software_render_tip", "Jos laitteistokiihdytys ei toimi oikein, voit käyttää ohjelmistopohjaista renderöintiä."), + ("Always use software rendering", "Käytä aina ohjelmistopohjaista renderöintiä"), + ("config_input", "Syöteasetukset"), + ("config_microphone", "Mikrofoni"), + ("request_elevation_tip", "Etätoiminto vaatii järjestelmänvalvojan oikeudet."), + ("Wait", "Odota"), + ("Elevation Error", "Oikeuksien korotus epäonnistui"), + ("Ask the remote user for authentication", "Pyydä etäkäyttäjää vahvistamaan oikeudet"), + ("Choose this if the remote account is administrator", "Valitse tämä, jos etätili on järjestelmänvalvoja"), + ("Transmit the username and password of administrator", "Lähetä järjestelmänvalvojan käyttäjätunnus ja salasana"), + ("still_click_uac_tip", "Etäkäyttäjän on edelleen hyväksyttävä UAC kehote omalla koneellaan."), + ("Request Elevation", "Pyydä oikeuksien korotusta"), + ("wait_accept_uac_tip", "Odota, että etäkäyttäjä hyväksyy UAC pyynnön..."), + ("Elevate successfully", "Oikeuksien korotus onnistui"), + ("uppercase", "iso kirjain"), + ("lowercase", "pieni kirjain"), + ("digit", "numero"), + ("special character", "erikoismerkki"), + ("length>=8", "vähintään 8 merkkiä"), + ("Weak", "Heikko"), + ("Medium", "Keskitaso"), + ("Strong", "Vahva"), + ("Switch Sides", "Vaihda puolia"), + ("Please confirm if you want to share your desktop?", "Haluatko varmasti jakaa työpöytäsi?"), + ("Display", "Näyttö"), + ("Default View Style", "Oletusnäkymän tyyli"), + ("Default Scroll Style", "Oletusvieritys tyyli"), + ("Default Image Quality", "Oletuskuvanlaatu"), + ("Default Codec", "Oletuskoodekki"), + ("Bitrate", "Bittinopeus"), + ("FPS", "Kuvataajuus (FPS)"), + ("Auto", "Automaattinen"), + ("Other Default Options", "Muut oletusasetukset"), + ("Voice call", "Äänipuhelu"), + ("Text chat", "Tekstikeskustelu"), + ("Stop voice call", "Lopeta äänipuhelu"), + ("relay_hint_tip", "Jos suora yhteys ei toimi, käytetään automaattisesti välityspalvelinta."), + ("Reconnect", "Yhdistä uudelleen"), + ("Codec", "Koodekki"), + ("Resolution", "Resoluutio"), + ("No transfers in progress", "Ei käynnissä olevia siirtoja"), + ("Set one-time password length", "Aseta kertakäyttösalasanan pituus"), + ("RDP Settings", "RDP asetukset"), + ("Sort by", "Järjestä"), + ("New Connection", "Uusi yhteys"), + ("Restore", "Palauta"), + ("Minimize", "Pienennä"), + ("Maximize", "Suurenna"), + ("Your Device", "Sinun laitteesi"), + ("empty_recent_tip", "Ei äskettäisiä istuntoja"), + ("empty_favorite_tip", "Ei suosikkeja"), + ("empty_lan_tip", "LAN laitteita ei löytynyt"), + ("empty_address_book_tip", "Osoitekirja on tyhjä"), + ("Empty Username", "Tyhjä käyttäjänimi"), + ("Empty Password", "Tyhjä salasana"), + ("Me", "Minä"), + ("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"), + ("New RDP", "Uusi RDP yhteys"), + ("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"), + ("Collapse toolbar", "Tiivistä työkalupalkki"), + ("Accept and Elevate", "Hyväksy ja korota oikeudet"), + ("accept_and_elevate_btn_tooltip", "Hyväksy ja korota oikeudet järjestelmänvalvojaksi"), + ("clipboard_wait_response_timeout_tip", "Leikepöydän pyyntö aikakatkaistiin – ei vastausta etäpäästä."), + ("Incoming connection", "Saapuva yhteys"), + ("Outgoing connection", "Lähtevä yhteys"), + ("Exit", "Poistu"), + ("Open", "Avaa"), + ("logout_tip", "Haluatko varmasti kirjautua ulos?"), + ("Service", "Palvelu"), + ("Start", "Käynnistä"), + ("Stop", "Pysäytä"), + ("exceed_max_devices", "Olet saavuttanut hallittavien laitteiden enimmäismäärän."), + ("Sync with recent sessions", "Synkronoi viimeisimpiin istuntoihin"), + ("Sort tags", "Järjestä tunnisteet"), + ("Open connection in new tab", "Avaa yhteys uuteen välilehteen"), + ("Move tab to new window", "Siirrä välilehti uuteen ikkunaan"), + ("Can not be empty", "Ei voi olla tyhjä"), + ("Already exists", "On jo olemassa"), + ("Change Password", "Vaihda salasana"), + ("Refresh Password", "Päivitä salasana"), + ("ID", "Tunnus"), + ("Grid View", "Ruudukkonäkymä"), + ("List View", "Luettelonäkymä"), + ("Select", "Valitse"), + ("Toggle Tags", "Näytä/piilota tunnisteet"), + ("pull_ab_failed_tip", "Osoitekirjan lataus epäonnistui palvelimelta."), + ("push_ab_failed_tip", "Osoitekirjan lähetys palvelimelle epäonnistui."), + ("synced_peer_readded_tip", "Synkronoitu laite lisättiin uudelleen."), + ("Change Color", "Vaihda väri"), + ("Primary Color", "Pääväri"), + ("HSV Color", "HSV väriarvot"), + ("Installation Successful!", "Asennus onnistui!"), + ("Installation failed!", "Asennus epäonnistui!"), + ("Reverse mouse wheel", "Käänteinen hiiren rullaussuunta"), + ("{} sessions", "{} istuntoa"), + ("scam_title", "Huijausvaroitus"), + ("scam_text1", "Älä anna tuntemattomille henkilöille pääsyä tietokoneeseesi."), + ("scam_text2", "RustDesk ei koskaan pyydä maksua tai etäkäyttöä ilman lupaasi."), + ("Don't show again", "Älä näytä uudelleen"), + ("I Agree", "Hyväksyn"), + ("Decline", "Hylkää"), + ("Timeout in minutes", "Aikakatkaisu minuuteissa"), + ("auto_disconnect_option_tip", "Katkaise yhteys automaattisesti, jos ei aktiivisuutta määräaikaan mennessä."), + ("Connection failed due to inactivity", "Yhteys epäonnistui toimettomuuden vuoksi"), + ("Check for software update on startup", "Tarkista ohjelmistopäivitykset käynnistyksen yhteydessä"), + ("upgrade_rustdesk_server_pro_to_{}_tip", "Päivitä RustDesk Server Pro versioon {} jatkaaksesi."), + ("pull_group_failed_tip", "Ryhmäasetusten nouto epäonnistui."), + ("Filter by intersection", "Suodata leikkausten perusteella"), + ("Remove wallpaper during incoming sessions", "Poista taustakuva saapuvien istuntojen ajaksi"), + ("Test", "Testaa"), + ("display_is_plugged_out_msg", "Näyttö on irrotettu"), + ("No displays", "Ei näyttöjä"), + ("Open in new window", "Avaa uudessa ikkunassa"), + ("Show displays as individual windows", "Näytä näytöt erillisinä ikkunoina"), + ("Use all my displays for the remote session", "Käytä kaikkia näyttöjä etäistunnossa"), + ("selinux_tip", "SELinux saattaa estää etäyhteyden toiminnan. Tarkista asetukset."), + ("Change view", "Vaihda näkymä"), + ("Big tiles", "Suuret ruudut"), + ("Small tiles", "Pienet ruudut"), + ("List", "Lista"), + ("Virtual display", "Virtuaalinäyttö"), + ("Plug out all", "Irrota kaikki"), + ("True color (4:4:4)", "Tarkka väri (4:4:4)"), + ("Enable blocking user input", "Estä käyttäjän syöte etäpäässä"), + ("id_input_tip", "Anna etätunnus muodossa tunnus@palvelin"), + ("privacy_mode_impl_mag_tip", "Yksityisyystila käyttää suurennustekniikkaa piilottaakseen sisällön."), + ("privacy_mode_impl_virtual_display_tip", "Yksityisyystila käyttää virtuaalinäyttöä tietosuojan takaamiseksi."), + ("Enter privacy mode", "Siirry yksityisyystilaan"), + ("Exit privacy mode", "Poistu yksityisyystilasta"), + ("idd_not_support_under_win10_2004_tip", "Virtuaalinäyttöä ei tueta Windows 10 2004 versiota vanhemmissa järjestelmissä."), + ("input_source_1_tip", "Valitse syöte 1: fyysinen näppäimistö tai hiiri"), + ("input_source_2_tip", "Valitse syöte 2: virtuaalinen syöte"), + ("Swap control-command key", "Vaihda Ctrl ja Command näppäinten paikkaa"), + ("swap-left-right-mouse", "Vaihda hiiren vasen ja oikea painike"), + ("2FA code", "2FA koodi"), + ("More", "Lisää"), + ("enable-2fa-title", "Ota kaksivaiheinen todennus käyttöön"), + ("enable-2fa-desc", "Lisää turvallisuutta vahvistamalla kirjautumisesi 2FA koodilla."), + ("wrong-2fa-code", "Väärä 2FA koodi"), + ("enter-2fa-title", "Syötä 2FA koodi"), + ("Email verification code must be 6 characters.", "Sähköpostivarmennuskoodin on oltava 6 merkkiä pitkä."), + ("2FA code must be 6 digits.", "2FA koodin on oltava 6 numeroa."), + ("Multiple Windows sessions found", "Useita Windows istuntoja havaittu"), + ("Please select the session you want to connect to", "Valitse istunto, johon haluat muodostaa yhteyden"), + ("powered_by_me", "Ylpeästi kehitetty omavaraisesti"), + ("outgoing_only_desk_tip", "Tämä asennus tukee vain lähteviä yhteyksiä."), + ("preset_password_warning", "Esiasetettu salasana voi olla turvaton — vaihda se suojataksesi yhteytesi."), + ("Security Alert", "Turvailmoitus"), + ("My address book", "Oma osoitekirja"), + ("Personal", "Henkilökohtainen"), + ("Owner", "Omistaja"), + ("Set shared password", "Aseta jaettu salasana"), + ("Exist in", "Sisältyy kohteeseen"), + ("Read-only", "Vain luku"), + ("Read/Write", "Luku ja kirjoitus"), + ("Full Control", "Täysi hallinta"), + ("share_warning_tip", "Jakaminen antaa muille pääsyn laitteeseesi. Varmista, että luotat käyttäjään."), + ("Everyone", "Kaikki"), + ("ab_web_console_tip", "Osoitekirjaa voidaan hallita myös verkkokonsolin kautta."), + ("allow-only-conn-window-open-tip", "Salli vain yksi yhteyshallintaikkuna kerrallaan."), + ("no_need_privacy_mode_no_physical_displays_tip", "Yksityisyystilaa ei tarvita, koska fyysisiä näyttöjä ei ole."), + ("Follow remote cursor", "Seuraa etäosoitinta"), + ("Follow remote window focus", "Seuraa etäikkunan kohdistusta"), + ("default_proxy_tip", "Käytetään oletusarvoista välityspalvelinta, ellei muuta määritetty."), + ("no_audio_input_device_tip", "Äänitulolaitetta ei löydy."), + ("Incoming", "Saapuva"), + ("Outgoing", "Lähtevä"), + ("Clear Wayland screen selection", "Tyhjennä Wayland näyttövalinta"), + ("clear_Wayland_screen_selection_tip", "Tyhjentää nykyisen Wayland näytön valinnan."), + ("confirm_clear_Wayland_screen_selection_tip", "Haluatko varmasti tyhjentää Wayland näyttövalinnan?"), + ("android_new_voice_call_tip", "Uusi äänipuhelu aloitettu"), + ("texture_render_tip", "Käytä tekstuuripohjaista renderöintiä paremman suorituskyvyn saavuttamiseksi."), + ("Use texture rendering", "Käytä tekstuurirenderöintiä"), + ("Floating window", "Kelluva ikkuna"), + ("floating_window_tip", "Kelluva ikkuna pysyy muiden sovellusten päällä etäistunnon aikana."), + ("Keep screen on", "Pidä näyttö päällä"), + ("Never", "Ei koskaan"), + ("During controlled", "Kun etäohjattuna"), + ("During service is on", "Kun palvelu on käynnissä"), + ("Capture screen using DirectX", "Kaappaa näyttö käyttämällä DirectX"), + ("Back", "Takaisin"), + ("Apps", "Sovellukset"), + ("Volume up", "Lisää äänenvoimakkuutta"), + ("Volume down", "Vähennä äänenvoimakkuutta"), + ("Power", "Virta"), + ("Telegram bot", "Telegram-botti"), + ("enable-bot-tip", "Ota Telegram botti käyttöön etähallintaa varten."), + ("enable-bot-desc", "Mahdollistaa ilmoitukset ja etätoiminnot Telegramin kautta."), + ("cancel-2fa-confirm-tip", "Haluatko varmasti poistaa kaksivaiheisen todennuksen käytöstä?"), + ("cancel-bot-confirm-tip", "Haluatko varmasti poistaa Telegram-botin käytöstä?"), + ("About RustDesk", "Tietoa RustDeskistä"), + ("Send clipboard keystrokes", "Lähetä leikepöydän näppäinsyötteet"), + ("network_error_tip", "Verkkovirhe – tarkista yhteys ja yritä uudelleen."), + ("Unlock with PIN", "Avaa PIN-koodilla"), + ("Requires at least {} characters", "Vaatii vähintään {} merkkiä"), + ("Wrong PIN", "Väärä PIN-koodi"), + ("Set PIN", "Aseta PIN-koodi"), + ("Enable trusted devices", "Ota luotetut laitteet käyttöön"), + ("Manage trusted devices", "Hallitse luotettuja laitteita"), + ("Platform", "Alusta"), + ("Days remaining", "Päiviä jäljellä"), + ("enable-trusted-devices-tip", "Vain luotetut laitteet voivat muodostaa yhteyden ilman lisävahvistusta."), + ("Parent directory", "Ylähakemisto"), + ("Resume", "Jatka"), + ("Invalid file name", "Virheellinen tiedostonimi"), + ("one-way-file-transfer-tip", "Tiedostonsiirto on yksisuuntainen – vain lähetys tai vastaanotto."), + ("Authentication Required", "Tunnistautuminen vaaditaan"), + ("Authenticate", "Tunnistaudu"), + ("web_id_input_tip", "Anna etätunnus verkkoliittymässä muodossa tunnus@palvelin"), + ("Download", "Lataa"), + ("Upload folder", "Lataa kansio"), + ("Upload files", "Lataa tiedostoja"), + ("Clipboard is synchronized", "Leikepöytä on synkronoitu"), + ("Update client clipboard", "Päivitä asiakkaan leikepöytä"), + ("Untagged", "Tunnisteeton"), + ("new-version-of-{}-tip", "Uusi versio sovelluksesta {} on saatavilla"), + ("Accessible devices", "Käytettävissä olevat laitteet"), + ("upgrade_remote_rustdesk_client_to_{}_tip", "Päivitä etä-RustDesk-asiakasversioon {} yhteensopivuuden takaamiseksi"), + ("d3d_render_tip", "Käytä Direct3D-renderöintiä paremman suorituskyvyn saavuttamiseksi"), + ("Use D3D rendering", "Käytä D3D-renderöintiä"), + ("Printer", "Tulostin"), + ("printer-os-requirement-tip", "Tulostustoiminto vaatii yhteensopivan käyttöjärjestelmän"), + ("printer-requires-installed-{}-client-tip", "Tulostus vaatii, että {} asiakas on asennettu"), + ("printer-{}-not-installed-tip", "{} tulostinta ei ole asennettu"), + ("printer-{}-ready-tip", "{}-tulostin on valmis"), + ("Install {} Printer", "Asenna {} tulostin"), + ("Outgoing Print Jobs", "Lähtevät tulostustyöt"), + ("Incoming Print Jobs", "Saapuvat tulostustyöt"), + ("Incoming Print Job", "Saapuva tulostustyö"), + ("use-the-default-printer-tip", "Käytä oletustulostinta"), + ("use-the-selected-printer-tip", "Käytä valittua tulostinta"), + ("auto-print-tip", "Tulosta saapuvat työt automaattisesti"), + ("print-incoming-job-confirm-tip", "Hyväksytäänkö saapuvan tulostustyön tulostus?"), + ("remote-printing-disallowed-tile-tip", "Etätulostus estetty"), + ("remote-printing-disallowed-text-tip", "Etätulostus ei ole sallittu tässä laitteessa tai yhteydessä."), + ("save-settings-tip", "Tallenna asetukset"), + ("dont-show-again-tip", "Älä näytä uudelleen"), + ("Take screenshot", "Ota kuvakaappaus"), + ("Taking screenshot", "Otetaan kuvakaappausta"), + ("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ä"), + ("Copy to clipboard", "Kopioi leikepöydälle"), + ("Enable remote printer", "Ota etätulostin käyttöön"), + ("Downloading {}", "Ladataan {}"), + ("{} Update", "{} päivitys"), + ("{}-to-update-tip", "Päivitä sovellus {} jatkaaksesi"), + ("download-new-version-failed-tip", "Uuden version lataus epäonnistui"), + ("Auto update", "Automaattinen päivitys"), + ("update-failed-check-msi-tip", "Päivitys epäonnistui – tarkista MSI asennuspaketti"), + ("websocket_tip", "Käytä WebSocket protokollaa yhteyden muodostamiseen"), + ("Use WebSocket", "Käytä WebSocketia"), + ("Trackpad speed", "Kosketuslevyn nopeus"), + ("Default trackpad speed", "Oletusnopeus kosketuslevylle"), + ("Numeric one-time password", "Numeerinen kertakäyttösalasana"), + ("Enable IPv6 P2P connection", "Ota IPv6 P2P yhteys käyttöön"), + ("Enable UDP hole punching", "Ota käyttöön UDP hole punching tekniikka"), + ("View camera", "Näytä kamera"), + ("Enable camera", "Ota kamera käyttöön"), + ("No cameras", "Ei kameroita"), + ("view_camera_unsupported_tip", "Kameranäkymä ei ole tuettu tällä alustalla"), + ("Terminal", "Pääte"), + ("Enable terminal", "Ota pääte käyttöön"), + ("New tab", "Uusi välilehti"), + ("Keep terminal sessions on disconnect", "Säilytä pääteistunnot yhteyden katketessa"), + ("Terminal (Run as administrator)", "Pääte (Suorita järjestelmänvalvojana)"), + ("terminal-admin-login-tip", "Kirjaudu järjestelmänvalvojana käyttääksesi tätä päätettä"), + ("Failed to get user token.", "Käyttäjätunnuksen hakeminen epäonnistui."), + ("Incorrect username or password.", "Virheellinen käyttäjätunnus tai salasana."), + ("The user is not an administrator.", "Käyttäjä ei ole järjestelmänvalvoja."), + ("Failed to check if the user is an administrator.", "Järjestelmänvalvojan tarkistus epäonnistui."), + ("Supported only in the installed version.", "Tuettu vain asennetussa versiossa."), + ("elevation_username_tip", "Anna järjestelmänvalvojan käyttäjätunnus oikeuksien korotusta varten"), + ("Preparing for installation ...", "Valmistellaan asennusta..."), + ("Show my cursor", "Näytä osoittimeni"), + ("Scale custom", "Mukautettu skaalaus"), + ("Custom scale slider", "Mukautetun skaalauksen liukusäädin"), + ("Decrease", "Pienennä"), + ("Increase", "Suurenna"), + ("Show virtual mouse", "Näytä virtuaalinen hiiri"), + ("Virtual mouse size", "Virtuaalihiiren koko"), + ("Small", "Pieni"), + ("Large", "Suuri"), + ("Show virtual joystick", "Näytä virtuaalinen ohjain"), + ("Edit note", "Muokkaa muistiinpanoa"), + ("Alias", "Alias"), + ("ScrollEdge", ""), + ("Allow insecure TLS fallback", ""), + ("allow-insecure-tls-fallback-tip", ""), + ("Disable 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 {}", "Jatka käyttäen {}"), + ("Display Name", ""), + ("password-hidden-tip", ""), + ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), + ].iter().cloned().collect(); +} diff --git a/src/lang/fr.rs b/src/lang/fr.rs index 85b1354c3..f21d9b0df 100644 --- a/src/lang/fr.rs +++ b/src/lang/fr.rs @@ -1,50 +1,50 @@ lazy_static::lazy_static! { pub static ref T: std::collections::HashMap<&'static str, &'static str> = [ - ("Status", "Statut"), + ("Status", "État"), ("Your Desktop", "Votre bureau"), - ("desk_tip", "Votre bureau est accessible via l'identifiant et le mot de passe ci-dessous."), + ("desk_tip", "Votre bureau est accessible via l’identifiant et le mot de passe ci-dessous."), ("Password", "Mot de passe"), ("Ready", "Prêt"), - ("Established", "Établi"), - ("connecting_status", "Connexion au réseau RustDesk..."), - ("Enable service", "Autoriser le service"), + ("Established", "Établie"), + ("connecting_status", "Connexion au réseau RustDesk…"), + ("Enable service", "Activer le service"), ("Start service", "Démarrer le service"), - ("Service is running", "Le service est en cours d'exécution"), - ("Service is not running", "Le service ne fonctionne pas"), - ("not_ready_status", "Pas prêt, veuillez vérifier la connexion réseau"), - ("Control Remote Desktop", "Contrôler le bureau à distance"), - ("Transfer file", "Transfert de fichiers"), + ("Service is running", "Le service est en cours d’exécution"), + ("Service is not running", "Le service est inactif"), + ("not_ready_status", "Pas prêt ; veuillez vérifier la connexion"), + ("Control Remote Desktop", "Contrôler un bureau à distance"), + ("Transfer file", "Transférer des fichiers"), ("Connect", "Se connecter"), ("Recent sessions", "Sessions récentes"), - ("Address book", "Carnet d'adresses"), + ("Address book", "Carnet d’adresses"), ("Confirmation", "Confirmation"), ("TCP tunneling", "Tunnel TCP"), - ("Remove", "Supprimer"), - ("Refresh random password", "Actualiser le mot de passe aléatoire"), + ("Remove", "Retirer"), + ("Refresh random password", "Générer un nouveau mot de passe aléatoire"), ("Set your own password", "Définir votre propre mot de passe"), ("Enable keyboard/mouse", "Activer le contrôle clavier/souris"), ("Enable clipboard", "Activer la synchronisation du presse-papier"), ("Enable file transfer", "Activer le transfert de fichiers"), ("Enable TCP tunneling", "Activer le tunnel TCP"), - ("IP Whitelisting", "Liste blanche IP"), - ("ID/Relay Server", "ID/Serveur Relais"), + ("IP Whitelisting", "Liste blanche d’adresses IP"), + ("ID/Relay Server", "Serveur ID/relais"), ("Import server config", "Importer la configuration du serveur"), ("Export Server Config", "Exporter la configuration du serveur"), ("Import server configuration successfully", "Configuration du serveur importée avec succès"), ("Export server configuration successfully", "Configuration du serveur exportée avec succès"), ("Invalid server configuration", "Configuration du serveur non valide"), - ("Clipboard is empty", "Presse-papier vide"), + ("Clipboard is empty", "Le presse-papier est vide"), ("Stop service", "Arrêter le service"), - ("Change ID", "Changer d'ID"), + ("Change ID", "Modifier l’ID"), ("Your new ID", "Votre nouvel ID"), ("length %min% to %max%", "longueur de %min% à %max%"), ("starts with a letter", "commence par une lettre"), ("allowed characters", "caractères autorisés"), - ("id_change_tip", "Seules les lettres a-z, A-Z, 0-9, _ (trait de soulignement) peuvent être utilisées. La première lettre doit être a-z, A-Z. La longueur doit être comprise entre 6 et 16."), - ("Website", "Site Web"), - ("About", "À propos de"), - ("Slogan_tip", "Fait avec cœur dans ce monde chaotique !"), + ("id_change_tip", "Seuls les caractères a-z, A-Z, 0-9, - (trait d’union) et _ (tiret bas) sont autorisés. La première lettre doit être a-z ou A-Z. La longueur doit être comprise entre 6 et 16."), + ("Website", "Site web"), + ("About", "À propos"), + ("Slogan_tip", "Fait avec cœur dans ce monde chaotique !"), ("Privacy Statement", "Déclaration de confidentialité"), ("Mute", "Muet"), ("Build Date", "Date de compilation"), @@ -58,10 +58,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Server", "Serveur relais"), ("API Server", "Serveur API"), ("invalid_http", "Doit commencer par http:// ou https://"), - ("Invalid IP", "IP invalide"), - ("Invalid format", "Format invalide"), - ("server_not_support", "Pas encore supporté par le serveur"), - ("Not available", "Indisponible"), + ("Invalid IP", "IP non valide"), + ("Invalid format", "Format non valide"), + ("server_not_support", "Non encore pris en charge par le serveur"), + ("Not available", "Non disponible"), ("Too frequent", "Modifié trop fréquemment, veuillez réessayer plus tard"), ("Cancel", "Annuler"), ("Skip", "Ignorer"), @@ -72,16 +72,16 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Please enter your password", "Veuillez saisir votre mot de passe"), ("Remember password", "Mémoriser le mot de passe"), ("Wrong Password", "Mauvais mot de passe"), - ("Do you want to enter again?", "Voulez-vous participer à nouveau ?"), + ("Do you want to enter again?", "Voulez-vous ressaisir le mot de passe ?"), ("Connection Error", "Erreur de connexion"), ("Error", "Erreur"), - ("Reset by the peer", "La connexion a été fermée par l'appareil distant"), - ("Connecting...", "Connexion..."), - ("Connection in progress. Please wait.", "Connexion en cours. Veuillez patienter."), - ("Please try 1 minute later", "Réessayez dans une minute"), + ("Reset by the peer", "Terminée par l’appareil distant"), + ("Connecting...", "Connexion…"), + ("Connection in progress. Please wait.", "Connexion en cours ; veuillez patienter."), + ("Please try 1 minute later", "Veuillez réessayer dans une minute"), ("Login Error", "Erreur de connexion"), ("Successful", "Succès"), - ("Connected, waiting for image...", "Connecté, en attente de transmission d'image..."), + ("Connected, waiting for image...", "Connecté ; en attente de l’image…"), ("Name", "Nom"), ("Type", "Type"), ("Modified", "Modifié le"), @@ -101,70 +101,68 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Select All", "Tout sélectionner"), ("Unselect All", "Tout déselectionner"), ("Empty Directory", "Répertoire vide"), - ("Not an empty directory", "Pas un répertoire vide"), - ("Are you sure you want to delete this file?", "Voulez-vous vraiment supprimer ce fichier ?"), + ("Not an empty directory", "Répertoire non vide"), + ("Are you sure you want to delete this file?", "Voulez-vous vraiment supprimer ce fichier ?"), ("Are you sure you want to delete this empty directory?", "Voulez-vous vraiment supprimer ce répertoire vide ?"), ("Are you sure you want to delete the file of this directory?", "Voulez-vous vraiment supprimer le fichier de ce répertoire ?"), - ("Do this for all conflicts", "Appliquer à d'autres conflits"), - ("This is irreversible!", "C'est irréversible !"), + ("Do this for all conflicts", "Appliquer à tous les conflits"), + ("This is irreversible!", "Cette action est irréversible !"), ("Deleting", "Suppression"), - ("files", "fichier"), - ("Waiting", "En attente..."), + ("files", "fichiers"), + ("Waiting", "En attente"), ("Finished", "Terminé"), ("Speed", "Vitesse"), - ("Custom Image Quality", "Définir la qualité d'image"), - ("Privacy mode", "Mode privé"), - ("Block user input", "Bloquer la saisie de l'utilisateur"), - ("Unblock user input", "Débloquer l'entrée de l'utilisateur"), + ("Custom Image Quality", "Qualité d’image personnalisée"), + ("Privacy mode", "Mode de confidentialité"), + ("Block user input", "Bloquer la saisie de l’utilisateur"), + ("Unblock user input", "Débloquer la saisie de l’utilisateur"), ("Adjust Window", "Ajuster la fenêtre"), ("Original", "Ratio d'origine"), ("Shrink", "Rétrécir"), ("Stretch", "Étirer"), ("Scrollbar", "Barre de défilement"), ("ScrollAuto", "Défilement automatique"), - ("Good image quality", "Bonne qualité d'image"), - ("Balanced", "Qualité d'image normale"), + ("Good image quality", "Bonne qualité d’image"), + ("Balanced", "Équilibré"), ("Optimize reaction time", "Optimiser le temps de réaction"), ("Custom", "Personnalisé"), ("Show remote cursor", "Afficher le curseur distant"), ("Show quality monitor", "Afficher le moniteur de qualité"), ("Disable clipboard", "Désactiver le presse-papier"), - ("Lock after session end", "Verrouiller l'appareil distant après la déconnexion"), + ("Lock after session end", "Verrouiller l’appareil distant après la déconnexion"), ("Insert Ctrl + Alt + Del", "Envoyer Ctrl + Alt + Del"), - ("Insert Lock", "Verrouiller l'appareil distant"), - ("Refresh", "Rafraîchir l'écran"), - ("ID does not exist", "L'ID n'existe pas"), - ("Failed to connect to rendezvous server", "Échec de la connexion au serveur rendezvous"), + ("Insert Lock", "Verrouiller l’appareil distant"), + ("Refresh", "Rafraîchir l’écran"), + ("ID does not exist", "L’ID n’existe pas"), + ("Failed to connect to rendezvous server", "Échec de la connexion au serveur de rendez-vous"), ("Please try later", "Veuillez essayer plus tard"), - ("Remote desktop is offline", "Le bureau à distance est hors ligne"), - ("Key mismatch", "Discordance de clés"), + ("Remote desktop is offline", "Le bureau distant est hors ligne"), + ("Key mismatch", "Discordance des clés"), ("Timeout", "Connexion expirée"), ("Failed to connect to relay server", "Échec de la connexion au serveur relais"), - ("Failed to connect via rendezvous server", "Échec de l'établissement d'une connexion via le serveur rendezvous"), - ("Failed to connect via relay server", "Impossible d'établir une connexion via le serveur relais"), - ("Failed to make direct connection to remote desktop", "Impossible d'établir une connexion directe"), + ("Failed to connect via rendezvous server", "Échec de la connexion via le serveur de rendez-vous"), + ("Failed to connect via relay server", "Échec de la connexion via le serveur relais"), + ("Failed to make direct connection to remote desktop", "Échec de la connexion directe au bureau distant"), ("Set Password", "Définir le mot de passe"), - ("OS Password", "Mot de passe du système d'exploitation"), - ("install_tip", "Vous utilisez une version non installée. En raison des restrictions UAC, en tant que terminal contrôlé, dans certains cas, il ne sera pas en mesure de contrôler la souris et le clavier ou d'enregistrer l'écran. Veuillez cliquer sur le bouton ci-dessous pour installer RustDesk au système pour éviter la question ci-dessus."), - ("Click to upgrade", "Cliquer pour mettre à niveau"), - ("Click to download", "Cliquer pour télécharger"), - ("Click to update", "Cliquer pour mettre à jour"), + ("OS Password", "Mot de passe du système d’exploitation"), + ("install_tip", "RustDesk n’est pas installé, ce qui peut limiter son utilisation à cause de l’UAC. Cliquez ci-dessous pour l’installer."), + ("Click to upgrade", "Mettre à niveau"), ("Configure", "Configurer"), - ("config_acc", "Afin de pouvoir contrôler votre bureau à distance, veuillez donner l'autorisation \"accessibilité\" à RustDesk."), - ("config_screen", "Afin de pouvoir accéder à votre bureau à distance, veuillez donner à RustDesk l'autorisation \"enregistrement d'écran\"."), - ("Installing ...", "Installation..."), + ("config_acc", "L’autorisation « Accessibilité » est requise pour contrôler votre bureau à distance."), + ("config_screen", "L’autorisation « Enregistrement d’écran » est requise pour accéder à votre bureau à distance."), + ("Installing ...", "Installation…"), ("Install", "Installer"), ("Installation", "Installation"), - ("Installation Path", "Chemin d'installation"), + ("Installation Path", "Chemin d’installation"), ("Create start menu shortcuts", "Créer des raccourcis dans le menu démarrer"), ("Create desktop icon", "Créer une icône sur le bureau"), - ("agreement_tip", "Démarrer l'installation signifie accepter le contrat de licence."), + ("agreement_tip", "En lançant l’installation, vous acceptez le contrat de licence."), ("Accept and Install", "Accepter et installer"), - ("End-user license agreement", "Contrat d'utilisateur"), - ("Generating ...", "Génération..."), - ("Your installation is lower version.", "La version que vous avez installée est inférieure à la version en cours d'exécution."), - ("not_close_tcp_tip", "Veuillez ne pas fermer cette fenêtre lors de l'utilisation du tunnel"), - ("Listening ...", "En attente de connexion tunnel..."), + ("End-user license agreement", "Conditions générales d’utilisation"), + ("Generating ...", "Génération…"), + ("Your installation is lower version.", "La version installée est antérieure à la version en cours d’exécution."), + ("not_close_tcp_tip", "Veuillez ne pas fermer cette fenêtre lors de l’utilisation du tunnel"), + ("Listening ...", "En attente de connexion…"), ("Remote Host", "Hôte distant"), ("Remote Port", "Port distant"), ("Action", "Action"), @@ -172,90 +170,90 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Local Port", "Port local"), ("Local Address", "Adresse locale"), ("Change Local Port", "Changer le port local"), - ("setup_server_tip", "Si vous avez besoin d'une vitesse de connexion plus rapide, vous pouvez choisir de créer votre propre serveur"), - ("Too short, at least 6 characters.", "Trop court, au moins 6 caractères."), - ("The confirmation is not identical.", "Les deux entrées ne correspondent pas"), + ("setup_server_tip", "N’hésitez pas à mettre en place votre propre serveur afin d’améliorer la connexion"), + ("Too short, at least 6 characters.", "Trop court, 6 caractères minimum."), + ("The confirmation is not identical.", "Les deux entrées ne correspondent pas."), ("Permissions", "Autorisations"), ("Accept", "Accepter"), ("Dismiss", "Rejeter"), ("Disconnect", "Déconnecter"), - ("Enable file copy and paste", "Autoriser le copier-coller de fichiers"), + ("Enable file copy and paste", "Activer le copier-coller de fichiers"), ("Connected", "Connecté"), ("Direct and encrypted connection", "Connexion directe chiffrée"), - ("Relayed and encrypted connection", "Connexion relais chiffrée"), + ("Relayed and encrypted connection", "Connexion via relais chiffrée"), ("Direct and unencrypted connection", "Connexion directe non chiffrée"), - ("Relayed and unencrypted connection", "Connexion relais non chiffrée"), - ("Enter Remote ID", "Entrer l'ID de l'appareil distant"), - ("Enter your password", "Entrer votre mot de passe"), - ("Logging in...", "En cours de connexion ..."), + ("Relayed and unencrypted connection", "Connexion via relais non chiffrée"), + ("Enter Remote ID", "Saisissez l’ID de l’appareil distant"), + ("Enter your password", "Saisissez votre mot de passe"), + ("Logging in...", "En cours de connexion…"), ("Enable RDP session sharing", "Activer le partage de session RDP"), - ("Auto Login", "Connexion automatique (le verrouillage ne sera effectif qu'après la désactivation du premier paramètre)"), - ("Enable direct IP access", "Autoriser l'accès direct par IP"), + ("Auto Login", "Connexion automatique (Requiert l’activation de l’option « Verrouiller l’appareil distant après la déconnexion »)"), + ("Enable direct IP access", "Activer l’accès direct par adresse IP"), ("Rename", "Renommer"), ("Space", "Espace"), ("Create desktop shortcut", "Créer un raccourci sur le bureau"), - ("Change Path", "Changer de chemin"), + ("Change Path", "Modifier le chemin"), ("Create Folder", "Créer un dossier"), ("Please enter the folder name", "Veuillez saisir le nom du dossier"), ("Fix it", "Réparer"), ("Warning", "Avertissement"), - ("Login screen using Wayland is not supported", "L'écran de connexion utilisant Wayland n'est pas pris en charge"), + ("Login screen using Wayland is not supported", "L’écran de connexion n’est pas pris en charge sous Wayland"), ("Reboot required", "Redémarrage requis"), - ("Unsupported display server", "Le serveur d'affichage actuel n'est pas pris en charge"), - ("x11 expected", "x11 requis"), + ("Unsupported display server", "Le serveur d’affichage n’est pas pris en charge"), + ("x11 expected", "x11 attendu"), ("Port", "Port"), ("Settings", "Paramètres"), - ("Username", " Nom d'utilisateur"), - ("Invalid port", "Port invalide"), - ("Closed manually by the peer", "Fermé manuellement par l'appareil distant"), - ("Enable remote configuration modification", "Autoriser la modification de la configuration à distance"), + ("Username", " Nom d’utilisateur"), + ("Invalid port", "Port non valide"), + ("Closed manually by the peer", "Terminée manuellement par l’appareil distant"), + ("Enable remote configuration modification", "Activer la modification de la configuration à distance"), ("Run without install", "Exécuter sans installer"), - ("Connect via relay", "Connexion via relais"), - ("Always connect via relay", "Forcer la connexion relais"), - ("whitelist_tip", "Seule une IP de la liste blanche peut accéder à mon appareil"), + ("Connect via relay", "Connecter via relais"), + ("Always connect via relay", "Forcer la connexion via relais"), + ("whitelist_tip", "Seules les adresses IP incluses dans la liste blanche pourront accéder à mon appareil"), ("Login", "Connexion"), ("Verify", "Vérifier"), ("Remember me", "Se souvenir de moi"), ("Trust this device", "Faire confiance à cet appareil"), ("Verification code", "Code de vérification"), - ("verification_tip", "Un nouvel appareil a été détecté et un code de vérification a été envoyé à l'adresse e-mail enregistrée, entrez le code de vérification pour continuer la connexion."), + ("verification_tip", "Un code de vérification a été envoyé à l’adresse électronique enregistrée ; saisissez le code de vérification afin de poursuivre la connexion."), ("Logout", "Déconnexion"), ("Tags", "Étiquettes"), ("Search ID", "Rechercher un ID"), ("whitelist_sep", "Vous pouvez utiliser une virgule, un point-virgule, un espace ou une nouvelle ligne comme séparateur"), ("Add ID", "Ajouter un ID"), - ("Add Tag", "Ajout étiquette(s)"), + ("Add Tag", "Ajouter une étiquette"), ("Unselect all tags", "Désélectionner toutes les étiquettes"), ("Network error", "Erreur réseau"), - ("Username missed", "Nom d'utilisateur manquant"), + ("Username missed", "Nom d’utilisateur manquant"), ("Password missed", "Mot de passe manquant"), ("Wrong credentials", "Identifiant ou mot de passe erroné"), ("The verification code is incorrect or has expired", "Le code de vérification est incorrect ou a expiré"), - ("Edit Tag", "Gestion étiquettes"), - ("Forget Password", "Oublier le Mot de passe"), + ("Edit Tag", "Modifier l’étiquette"), + ("Forget Password", "Oublier le mot de passe"), ("Favorites", "Favoris"), - ("Add to Favorites", "Ajouter aux Favoris"), + ("Add to Favorites", "Ajouter aux favoris"), ("Remove from Favorites", "Retirer des favoris"), ("Empty", "Vide"), - ("Invalid folder name", "Nom de dossier invalide"), + ("Invalid folder name", "Nom de dossier non valide"), ("Socks5 Proxy", "Socks5 Agents"), - ("Socks5/Http(s) Proxy", "Socks5/Http(s) Agents"), - ("Discovered", "Découvert"), - ("install_daemon_tip", "Pour une exécution au démarrage du système, vous devez installer le service système."), - ("Remote ID", "ID de l'appareil distant"), + ("Socks5/Http(s) Proxy", "Proxy Socks5/Http(s)"), + ("Discovered", "Découverts"), + ("install_daemon_tip", "Le service système doit être installé avant de pouvoir activer l’exécution au démarrage du système."), + ("Remote ID", "ID de l’appareil distant"), ("Paste", "Coller"), - ("Paste here?", "Coller ici ?"), - ("Are you sure to close the connection?", "Êtes-vous sûr de fermer la connexion ?"), + ("Paste here?", "Coller ici ?"), + ("Are you sure to close the connection?", "Voulez-vous vraiment terminer la connexion ?"), ("Download new version", "Télécharger la nouvelle version"), ("Touch mode", "Mode tactile"), ("Mouse mode", "Mode souris"), - ("One-Finger Tap", "Tapez d'un doigt"), - ("Left Mouse", "Bouton gauche de la souris"), - ("One-Long Tap", "Un touché long"), - ("Two-Finger Tap", "Tapez à deux doigts"), - ("Right Mouse", "Bouton droit de la souris"), + ("One-Finger Tap", "Appui simple"), + ("Left Mouse", "Clic gauche"), + ("One-Long Tap", "Appui prolongé"), + ("Two-Finger Tap", "Appui à deux doigts"), + ("Right Mouse", "Clic droit"), ("One-Finger Move", "Mouvement à un doigt"), - ("Double Tap & Move", "Appuyez deux fois et déplacez"), + ("Double Tap & Move", "Mouvement après double appui"), ("Mouse Drag", "Glissement de la souris"), ("Three-Finger vertically", "Trois doigts verticalement"), ("Mouse Wheel", "Roulette de la souris"), @@ -264,80 +262,78 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Pinch to Zoom", "Pincer pour zoomer"), ("Canvas Zoom", "Zoom sur la vue"), ("Reset canvas", "Réinitialiser la vue"), - ("No permission of file transfer", "Aucune autorisation de transfert de fichiers"), - ("Note", "Noter"), + ("No permission of file transfer", "Absence de l’autorisation de transfert de fichiers"), + ("Note", "Note"), ("Connection", "Connexion"), - ("Share Screen", "Partager l'écran"), - ("Chat", "Discuter"), + ("Share screen", "Partage d’écran"), + ("Chat", "Discussion"), ("Total", "Total"), ("items", "éléments"), ("Selected", "Sélectionné(s)"), - ("Screen Capture", "Capture d'écran"), - ("Input Control", "Contrôle de saisie"), - ("Audio Capture", "Capture audio"), - ("File Connection", "Connexion de fichier"), - ("Screen Connection", "Connexion de l'écran"), - ("Do you accept?", "Acceptez-vous ?"), + ("Screen Capture", "Capture de l’écran"), + ("Input Control", "Contrôle de la saisie"), + ("Audio Capture", "Capture de l’audio"), + ("Do you accept?", "Acceptez-vous ?"), ("Open System Setting", "Ouvrir les paramètres système"), - ("How to get Android input permission?", "Comment obtenir l'autorisation d'entrée Android ?"), - ("android_input_permission_tip1", "Pour qu'un appareil distant puisse contrôler votre appareil Android via la souris ou le toucher, vous devez autoriser RustDesk à utiliser le service \"Accessibilité\"."), - ("android_input_permission_tip2", "Veuillez accéder à la page suivante des paramètres système, recherchez et entrez [Services installés], activez le service [RustDesk Input]."), + ("How to get Android input permission?", "Comment obtenir l’autorisation de contrôle de la saisie sur Android ?"), + ("android_input_permission_tip1", "Pour qu’un appareil distant puisse contrôler votre appareil Android via la souris ou le toucher d’écran, vous devez autoriser RustDesk à utiliser le service « Accessibilité »."), + ("android_input_permission_tip2", "Veuillez accéder à la page suivante des paramètres système, puis recherchez et accédez à la section [Services installés] ; activez ensuite le service [RustDesk Input]."), ("android_new_connection_tip", "Une nouvelle demande de contrôle a été reçue, elle souhaite contrôler votre appareil actuel."), - ("android_service_will_start_tip", "L'activation de la capture d'écran démarrera automatiquement le service, permettant à d'autres appareils de demander une connexion à partir de cet appareil."), - ("android_stop_service_tip", "La fermeture du service fermera automatiquement toutes les connexions établies."), - ("android_version_audio_tip", "La version actuelle d'Android ne prend pas en charge la capture audio, veuillez passer à Android 10 ou supérieur."), - ("android_start_service_tip", "Appuyez sur [Démarrer le service] ou activez l'autorisation [Capture d'écran] pour démarrer le service de partage d'écran."), - ("android_permission_may_not_change_tip", "Les autorisations pour les connexions établies peuvent ne pas être prisent en compte instantanément ou avant la reconnection."), + ("android_service_will_start_tip", "L’activation de la capture de l’écran démarrera automatiquement le service, ce qui permettra aux appareils distants d’initier une connexion vers cet appareil."), + ("android_stop_service_tip", "L’arrêt du service terminera automatiquement toutes les connexions établies."), + ("android_version_audio_tip", "La version actuelle d’Android ne prend pas en charge la capture de l’audio, veuillez passer à Android 10 ou supérieur."), + ("android_start_service_tip", "Appuyez sur [Démarrer le service] ou activez l’autorisation [Capture de l’écran] pour démarrer le service de partage d’écran."), + ("android_permission_may_not_change_tip", "Les modifications des autorisations peuvent requérir une reconnexion avant d’être prises en compte par les connexions déjà établies."), ("Account", "Compte"), ("Overwrite", "Écraser"), - ("This file exists, skip or overwrite this file?", "Ce fichier existe, ignorer ou écraser ce fichier ?"), + ("This file exists, skip or overwrite this file?", "Ce fichier existe déjà, ignorer ou écraser ce fichier ?"), ("Quit", "Quitter"), - ("Help", "Aider"), - ("Failed", "échouer"), + ("Help", "Aide"), + ("Failed", "Échec"), ("Succeeded", "Succès"), - ("Someone turns on privacy mode, exit", "Quelqu'un active le mode de confidentialité, quittez"), + ("Someone turns on privacy mode, exit", "Quelqu’un active le mode de confidentialité, désactiver"), ("Unsupported", "Non pris en charge"), - ("Peer denied", "Appareil distant refusé"), + ("Peer denied", "Refusé par l’appareil distant"), ("Please install plugins", "Veuillez installer les plugins"), - ("Peer exit", "Appareil distant déconnecté"), + ("Peer exit", "Désactivé par l’appareil distant"), ("Failed to turn off", "Échec de la désactivation"), ("Turned off", "Désactivé"), ("Language", "Langue"), - ("Keep RustDesk background service", "Gardez le service RustDesk en arrière plan"), - ("Ignore Battery Optimizations", "Ignorer les optimisations batterie"), - ("android_open_battery_optimizations_tip", "Conseil android d'optimisation de batterie"), + ("Keep RustDesk background service", "Garder le service RustDesk en arrière plan"), + ("Ignore Battery Optimizations", "Ignorer les optimisations de la batterie"), + ("android_open_battery_optimizations_tip", "Pour désactiver cette fonctionnalité, veuillez accéder à la page suivante des paramètres de l’application RustDesk, puis recherchez et accédez à la section [Batterie] ; décochez ensuite l’option [Sans restriction]."), ("Start on boot", "Lancer au démarrage"), - ("Start the screen sharing service on boot, requires special permissions", "Lancer le service de partage d'écran au démarrage, nécessite des autorisations spéciales"), + ("Start the screen sharing service on boot, requires special permissions", "Lancer le service de partage d’écran au démarrage, nécessite des autorisations spéciales"), ("Connection not allowed", "Connexion non autorisée"), ("Legacy mode", "Mode hérité"), ("Map mode", "Mode correspondance"), ("Translate mode", "Mode traduction"), ("Use permanent password", "Utiliser un mot de passe permanent"), - ("Use both passwords", "Utiliser les mots de passe unique et permanent"), + ("Use both passwords", "Utiliser les deux mots de passe"), ("Set permanent password", "Définir le mot de passe permanent"), ("Enable remote restart", "Activer le redémarrage à distance"), - ("Restart remote device", "Redémarrer l'appareil à distance"), - ("Are you sure you want to restart", "Êtes-vous sûr de vouloir redémarrer l'appareil ?"), - ("Restarting remote device", "Redémarrage de l'appareil distant"), - ("remote_restarting_tip", "L'appareil distant redémarre, veuillez fermer cette boîte de message et vous reconnecter avec un mot de passe permanent après un certain temps"), + ("Restart remote device", "Redémarrer l’appareil distant"), + ("Are you sure you want to restart", "Voulez-vous vraiment redémarrer"), + ("Restarting remote device", "Redémarrage de l’appareil distant"), + ("remote_restarting_tip", "L'appareil distant redémarre ; veuillez fermer cette boîte de dialogue et vous reconnecter en utilisant le mot de passe permanent dans quelques instants"), ("Copied", "Copié"), ("Exit Fullscreen", "Quitter le mode plein écran"), ("Fullscreen", "Plein écran"), ("Mobile Actions", "Actions mobiles"), - ("Select Monitor", "Sélection du Moniteur"), + ("Select Monitor", "Sélection du moniteur"), ("Control Actions", "Actions de contrôle"), - ("Display Settings", "Paramètres d'affichage"), + ("Display Settings", "Paramètres d’affichage"), ("Ratio", "Rapport"), - ("Image Quality", "Qualité d'image"), + ("Image Quality", "Qualité d’image"), ("Scroll Style", "Style de défilement"), - ("Show Toolbar", "Afficher la barre d'outils"), - ("Hide Toolbar", "Masquer la barre d'outils"), + ("Show Toolbar", "Afficher la barre d’outils"), + ("Hide Toolbar", "Cacher la barre d’outils"), ("Direct Connection", "Connexion directe"), - ("Relay Connection", "Connexion relais"), + ("Relay Connection", "Connexion via relais"), ("Secure Connection", "Connexion sécurisée"), ("Insecure Connection", "Connexion non sécurisée"), - ("Scale original", "Échelle 100%"), - ("Scale adaptive", "Mise à l'échelle Auto"), + ("Scale original", "Échelle originale"), + ("Scale adaptive", "Échelle adaptative"), ("General", "Général"), ("Security", "Sécurité"), ("Theme", "Thème"), @@ -347,311 +343,407 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Light", "Clair"), ("Follow System", "Suivi système"), ("Enable hardware codec", "Activer le transcodage matériel"), - ("Unlock Security Settings", "Déverrouiller les configurations de sécurité"), - ("Enable audio", "Activer l'audio"), - ("Unlock Network Settings", "Déverrouiller les configurations réseau"), + ("Unlock Security Settings", "Déverrouiller les paramètres de sécurité"), + ("Enable audio", "Activer l’audio"), + ("Unlock Network Settings", "Déverrouiller les paramètres réseau"), ("Server", "Serveur"), - ("Direct IP Access", "Accès IP direct"), + ("Direct IP Access", "Accès direct par adresse IP"), ("Proxy", "Proxy"), ("Apply", "Appliquer"), - ("Disconnect all devices?", "Déconnecter tous les appareils ?"), + ("Disconnect all devices?", "Déconnecter tous les appareils ?"), ("Clear", "Effacer"), ("Audio Input Device", "Périphérique source audio"), - ("Use IP Whitelisting", "Utiliser une liste blanche d'IP"), + ("Use IP Whitelisting", "Utiliser une liste blanche d’adresses IP"), ("Network", "Réseau"), - ("Pin Toolbar", "Épingler la barre d'outil"), - ("Unpin Toolbar", "Détacher la barre d'outil"), + ("Pin Toolbar", "Épingler la barre d’outils"), + ("Unpin Toolbar", "Détacher la barre d’outils"), ("Recording", "Enregistrement"), ("Directory", "Répertoire"), - ("Automatically record incoming sessions", "Enregistrement automatique des sessions entrantes"), - ("Automatically record outgoing sessions", ""), + ("Automatically record incoming sessions", "Enregistrer automatiquement les sessions entrantes"), + ("Automatically record outgoing sessions", "Enregistrer automatiquement les sessions sortantes"), ("Change", "Modifier"), - ("Start session recording", "Commencer l'enregistrement"), - ("Stop session recording", "Stopper l'enregistrement"), - ("Enable recording session", "Activer l'enregistrement de session"), + ("Start session recording", "Commencer l’enregistrement"), + ("Stop session recording", "Stopper l’enregistrement"), + ("Enable recording session", "Activer l’enregistrement de session"), ("Enable LAN discovery", "Activer la découverte sur réseau local"), - ("Deny LAN discovery", "Interdir la découverte sur réseau local"), - ("Write a message", "Ecrire un message"), + ("Deny LAN discovery", "Interdire la découverte sur réseau local"), + ("Write a message", "Écrire un message"), ("Prompt", "Annonce"), - ("Please wait for confirmation of UAC...", "Veuillez attendre la confirmation de l'UAC..."), - ("elevated_foreground_window_tip", "La fenêtre actuelle de l'appareil distant nécessite des privilèges plus élevés pour fonctionner, elle ne peut donc pas être atteinte par la souris et le clavier. Vous pouvez demander à l'utilisateur distant de réduire la fenêtre actuelle ou de cliquer sur le bouton d'élévation dans la fenêtre de gestion des connexions. Pour éviter ce problème, il est recommandé d'installer le logiciel sur l'appareil distant."), + ("Please wait for confirmation of UAC...", "Veuillez attendre la confirmation de l’UAC…"), + ("elevated_foreground_window_tip", "La fenêtre active du bureau distant nécessite des privilèges plus élevés pour fonctionner, la souris et le clavier ne peuvent donc pas l’atteindre actuellement. Vous pouvez demander à l’utilisateur distant de réduire la fenêtre active ou de cliquer sur le bouton d’élévation dans la fenêtre de gestion de la connexion. Il est conseillé d’installer le logiciel sur l’appareil distant afin d’éviter ce problème."), ("Disconnected", "Déconnecté"), ("Other", "Divers"), ("Confirm before closing multiple tabs", "Confirmer avant de fermer plusieurs onglets"), - ("Keyboard Settings", "Configuration clavier"), + ("Keyboard Settings", "Paramètres du clavier"), ("Full Access", "Accès total"), - ("Screen Share", "Partage d'écran"), - ("Wayland requires Ubuntu 21.04 or higher version.", "Wayland nécessite Ubuntu 21.04 ou une version supérieure."), - ("Wayland requires higher version of linux distro. Please try X11 desktop or change your OS.", "Wayland nécessite une version supérieure de la distribution Linux. Veuillez essayer le bureau X11 ou changer votre système d'exploitation."), + ("Screen Share", "Partage d’écran"), + ("ubuntu-21-04-required", "Wayland nécessite Ubuntu 21.04 ou une version ultérieure."), + ("wayland-requires-higher-linux-version", "Wayland nécessite une version ultérieure de votre distribution Linux. Veuillez essayer le bureau X11 ou changer de système d’exploitation."), + ("xdp-portal-unavailable", "Échec de la capture de l’écran Wayland. Le portail de bureau XDG a peut-être planté ou n’est pas disponible. Essayez de le redémarrer avec la commande `systemctl --user restart xdg-desktop-portal`."), ("JumpLink", "Afficher"), - ("Please Select the screen to be shared(Operate on the peer side).", "Veuillez sélectionner l'écran à partager (côté appareil distant)."), + ("Please Select the screen to be shared(Operate on the peer side).", "Veuillez sélectionner l’écran à partager (côté appareil distant)."), ("Show RustDesk", "Afficher RustDesk"), ("This PC", "Ce PC"), ("or", "ou"), - ("Continue with", "Continuer avec"), - ("Elevate", "Autoriser l'accès"), + ("Elevate", "Élever les privilèges"), ("Zoom cursor", "Augmenter la taille du curseur"), ("Accept sessions via password", "Accepter les sessions via mot de passe"), - ("Accept sessions via click", "Accepter les sessions via clique de confirmation"), - ("Accept sessions via both", "Accepter les sessions via mot de passe ou clique de confirmation"), - ("Please wait for the remote side to accept your session request...", "Veuillez attendre que votre demande de session distante soit accepter ..."), - ("One-time Password", "Mot de passe unique"), - ("Use one-time password", "Utiliser un mot de passe unique"), - ("One-time password length", "Longueur du mot de passe unique"), - ("Request access to your device", "Demande d'accès à votre appareil"), - ("Hide connection management window", "Masquer la fenêtre de gestion des connexions"), - ("hide_cm_tip", "Autoriser le masquage uniquement si vous acceptez des sessions via un mot de passe et utilisez un mot de passe permanent"), - ("wayland_experiment_tip", "Le support Wayland est en phase expérimentale, veuillez utiliser X11 si vous avez besoin d'un accès sans surveillance."), - ("Right click to select tabs", "Clique droit pour selectionner les onglets"), + ("Accept sessions via click", "Accepter les sessions via clic de confirmation"), + ("Accept sessions via both", "Accepter les sessions via mot de passe ou clic de confirmation"), + ("Please wait for the remote side to accept your session request...", "Veuillez attendre que votre demande de session distante soit acceptée…"), + ("One-time Password", "Mot de passe à usage unique"), + ("Use one-time password", "Utiliser un mot de passe à usage unique"), + ("One-time password length", "Longueur du mot de passe à usage unique"), + ("Request access to your device", "Demande l’accès à votre appareil"), + ("Hide connection management window", "Cacher la fenêtre de gestion de la connexion"), + ("hide_cm_tip", "Requiert d’accepter les sessions via mot de passe avec un mot de passe permanent"), + ("wayland_experiment_tip", "La prise en charge de Wayland est en phase expérimentale, veuillez utiliser X11 si vous avez besoin d’un accès non assisté."), + ("Right click to select tabs", "Clic droit pour sélectionner les onglets"), ("Skipped", "Ignoré"), - ("Add to address book", "Ajouter au carnet d'adresses"), + ("Add to address book", "Ajouter au carnet d’adresses"), ("Group", "Groupe"), ("Search", "Rechercher"), - ("Closed manually by web console", "Fermé manuellement par la console Web"), + ("Closed manually by web console", "Terminée manuellement par la console web"), ("Local keyboard type", "Disposition du clavier local"), - ("Select local keyboard type", "Selectionner la disposition du clavier local"), - ("software_render_tip", "Si vous avez une carte graphique NVIDIA et que la fenêtre distante se ferme immédiatement après la connexion, l'installation du pilote Nouveau et le choix d'utiliser le rendu du logiciel peuvent aider. Un redémarrage du logiciel est requis."), - ("Always use software rendering", "Utiliser toujours le rendu logiciel"), - ("config_input", "Afin de contrôler le bureau à distance avec le clavier, vous devez accorder à RustDesk l'autorisation \"Surveillance de l’entrée\"."), - ("config_microphone", "Pour discuter à distance, vous devez accorder à RustDesk les autorisations « Enregistrer l'audio »."), - ("request_elevation_tip", "Vous pouvez également demander une augmentation des privilèges s'il y a quelqu'un du côté distant."), - ("Wait", "En cours"), - ("Elevation Error", "Erreur d'augmentation des privilèges"), - ("Ask the remote user for authentication", "Demander à l'utilisateur distant de s'authentifier"), - ("Choose this if the remote account is administrator", "Choisissez ceci si le compte distant est le compte d'administrateur"), - ("Transmit the username and password of administrator", "Transmettre le nom d'utilisateur et le mot de passe de l'administrateur"), - ("still_click_uac_tip", "Nécessite toujours que l'utilisateur distant confirme par la fenêtre UAC de RustDesk en cours d'éxécution."), - ("Request Elevation", "Demande d'augmentation des privilèges"), - ("wait_accept_uac_tip", "Veuillez attendre que l'utilisateur distant accepte la boîte de dialogue UAC."), - ("Elevate successfully", "Augmentation des privilèges avec succès"), + ("Select local keyboard type", "Sélectionner la disposition du clavier local"), + ("software_render_tip", "Si vous utilisez une carte graphique Nvidia sous Linux et que la fenêtre distante se ferme immédiatement après la connexion, l’installation du pilote open-source Nouveau et l’utilisation du rendu du logiciel peuvent aider. Un redémarrage du logiciel est requis."), + ("Always use software rendering", "Toujours utiliser le rendu logiciel"), + ("config_input", "Vous devez accorder à RustDesk l’autorisation « Surveillance de l’entrée » pour contrôler le bureau distant avec le clavier."), + ("config_microphone", "Vous devez accorder à RustDesk l’autorisation « Enregistrer l’audio » pour discuter à distance."), + ("request_elevation_tip", "Vous pouvez également demander une élévation des privilèges si un utilisateur est présent côté distant."), + ("Wait", "Attendre"), + ("Elevation Error", "Erreur d’élévation des privilèges"), + ("Ask the remote user for authentication", "Demander à l’utilisateur distant de s’authentifier"), + ("Choose this if the remote account is administrator", "Sélectionnez cette option si le compte distant est administrateur"), + ("Transmit the username and password of administrator", "Transmettre le nom d’utilisateur et le mot de passe d’un compte administrateur"), + ("still_click_uac_tip", "L’utilisateur distant devra malgré tout confirmer l’UAC de l’instance RustDesk en cours d’éxécution."), + ("Request Elevation", "Demander l’élévation des privilèges"), + ("wait_accept_uac_tip", "Veuillez attendre l’acceptation de l’UAC par l’utilisateur distant."), + ("Elevate successfully", "Élévation des privilèges réussie"), ("uppercase", "majuscule"), ("lowercase", "minuscule"), ("digit", "chiffre"), ("special character", "caractère spécial"), - ("length>=8", "longueur>=8"), + ("length>=8", "longueur ≥ 8"), ("Weak", "Faible"), ("Medium", "Moyen"), ("Strong", "Fort"), ("Switch Sides", "Inverser la prise de contrôle"), - ("Please confirm if you want to share your desktop?", "Veuillez confirmer le partager de votre bureau ?"), + ("Please confirm if you want to share your desktop?", "Voulez-vous vraiment partager votre bureau ?"), ("Display", "Affichage"), ("Default View Style", "Style de vue par défaut"), ("Default Scroll Style", "Style de défilement par défaut"), - ("Default Image Quality", "Qualité d'image par défaut"), + ("Default Image Quality", "Qualité d’image par défaut"), ("Default Codec", "Codec par défaut"), ("Bitrate", "Débit"), ("FPS", "FPS"), ("Auto", "Auto"), ("Other Default Options", "Autres options par défaut"), - ("Voice call", "Appel voix"), + ("Voice call", "Appel vocal"), ("Text chat", "Conversation textuelle"), - ("Stop voice call", "Stopper l'appel voix"), - ("relay_hint_tip", "Il se peut qu'il ne doit pas possible de se connecter directement, vous pouvez essayer de vous connecter via un relais. \nEn outre, si vous souhaitez utiliser directement le relais, vous pouvez ajouter le suffixe \"/r\" à l'ID ou sélectionner l'option \"Toujours se connecter via le relais\" dans la fiche appareils distants."), + ("Stop voice call", "Terminer l’appel vocal"), + ("relay_hint_tip", "Il n’est pas toujours possible d’établir une connexion directe, mais une connexion via serveur relais est envisageable. En outre, si vous souhaitez utiliser un relais dès la première tentative, vous pouvez ajouter le suffixe « /r » à l’ID ou activer l’option « Forcer la connexion via relais » depuis la carte des sessions récentes, si elle s’y trouve."), ("Reconnect", "Se reconnecter"), ("Codec", "Codec"), ("Resolution", "Résolution"), - ("No transfers in progress", "Pas de transfert en cours"), + ("No transfers in progress", "Aucun transfert en cours"), ("Set one-time password length", "Définir la longueur du mot de passe à usage unique"), - ("RDP Settings", "Configuration RDP"), + ("RDP Settings", "Paramètres RDP"), ("Sort by", "Trier par"), ("New Connection", "Nouvelle connexion"), ("Restore", "Restaurer"), ("Minimize", "Minimiser"), ("Maximize", "Maximiser"), ("Your Device", "Votre appareil"), - ("empty_recent_tip", "Oups, pas de sessions récentes !\nIl est temps d'en prévoir une nouvelle."), - ("empty_favorite_tip", "Vous n'avez pas encore d'appareils distants favorits ?\nTrouvons quelqu'un avec qui vous connecter et ajoutez-les à vos favoris !"), - ("empty_lan_tip", "Oh non, il semble que nous n'ayons pas encore d'appareils réseau local découverts."), - ("empty_address_book_tip", "Ouh là là ! il semble qu'il n'y ait actuellement aucun appareil distant répertorié dans votre carnet d'adresses."), - ("eg: admin", "ex: admin"), - ("Empty Username", "Nom d'utilisation non spécifié"), - ("Empty Password", "Mot de passe non spécifié"), + ("empty_recent_tip", "Oups, aucune session récente !\nIl est l’heure d’en organiser une nouvelle."), + ("empty_favorite_tip", "Vous n’avez pas encore d’appareils distants favoris ?\nTrouvez quelqu’un avec qui vous connecter et ajoutez-le à vos favoris !"), + ("empty_lan_tip", "Oh non, il semble que nous n’avons pas encore découvert d’appareils sur le réseau local."), + ("empty_address_book_tip", "Mince, il n’y a actuellement aucun appareil distant répertorié dans votre carnet d’adresses."), + ("Empty Username", "Nom d’utilisation non renseigné"), + ("Empty Password", "Mot de passe non renseigné"), ("Me", "Moi"), - ("identical_file_tip", "Ce fichier est identique à celui de l'appareil distant."), - ("show_monitors_tip", "Afficher les moniteurs dans la barre d'outils"), + ("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", "Se connecter au compte Linux distant"), + ("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 système d'exploitation"), + ("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éconnexion"), + ("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 gestionaire de bureau n'est disponible"), - ("no_desktop_text_tip", "Veuillez installer le gestionaire de bureau GNOME"), - ("No need to elevate", "Pas besoin de permissions administrateur"), + ("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"), ("New RDP", "Nouvel RDP"), - ("Fingerprint", "Empreinte digitale"), - ("Copy Fingerprint", "Copier empreinte digitale"), - ("no fingerprints", "Pas d'empreintes digitales"), - ("Select a peer", "Sélectionnez l'appareil distant"), - ("Select peers", "Sélectionnez des appareils distants"), + ("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", "Mise à jour"), - ("Enable", "Activé"), - ("Disable", "Desactivé"), + ("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 local"), + ("resolution_original_tip", "Résolution d’origine"), + ("resolution_fit_local_tip", "Adapter à la résolution locale"), ("resolution_custom_tip", "Résolution personnalisée"), - ("Collapse toolbar", "Réduire la barre d'outils"), - ("Accept and Elevate", "Accepter et autoriser l'augmentation des privilèges"), - ("accept_and_elevate_btn_tooltip", "Accepter la connexion l'augmentation des privilèges UAC."), - ("clipboard_wait_response_timeout_tip", "Expiration du délai d'attente presse-papiers."), + ("Collapse toolbar", "Réduire la barre d’outils"), + ("Accept and Elevate", "Accepter et élever les privilèges"), + ("accept_and_elevate_btn_tooltip", "Accepter la connexion et élever les privilèges UAC."), + ("clipboard_wait_response_timeout_tip", "Expiration du délai d’attente du presse-papier."), ("Incoming connection", "Connexion entrante"), ("Outgoing connection", "Connexion sortante"), ("Exit", "Quitter"), ("Open", "Ouvrir"), - ("logout_tip", "Êtes-vous sûr de vouloir vous déconnecter ?"), + ("logout_tip", "Voulez-vous vraiment vous déconnecter ?"), ("Service", "Service"), - ("Start", "Lancer"), - ("Stop", "Stopper"), - ("exceed_max_devices", "Vous avez atteint le nombre maximal d'appareils gérés."), + ("Start", "Démarrer"), + ("Stop", "Arrêter"), + ("exceed_max_devices", "Vous avez atteint le nombre maximal d’appareils gérés."), ("Sync with recent sessions", "Synchroniser avec les sessions récentes"), ("Sort tags", "Trier les étiquettes"), - ("Open connection in new tab", "Ouvrir la connexion dans un nouvel onglet"), - ("Move tab to new window", "Déplacer l'onglet vers une nouvelle fenêtre"), - ("Can not be empty", "Ne peux pas être vide"), + ("Open connection in new tab", "Ouvrir les connexions dans un nouvel onglet"), + ("Move tab to new window", "Déplacer l’onglet vers une nouvelle fenêtre"), + ("Can not be empty", "Ne peut pas être vide"), ("Already exists", "Existe déjà"), - ("Change Password", "Changer le mot de passe"), + ("Change Password", "Modifier le mot de passe"), ("Refresh Password", "Actualiser le mot de passe"), ("ID", "ID"), ("Grid View", "Vue Grille"), ("List View", "Vue Liste"), ("Select", "Sélectionner"), - ("Toggle Tags", "Basculer vers les étiquettes"), - ("pull_ab_failed_tip", "Impossible d'actualiser le carnet d'adresses"), - ("push_ab_failed_tip", "Échec de la synchronisation du carnet d'adresses"), - ("synced_peer_readded_tip", "Les appareils qui étaient présents dans les sessions récentes seront synchronisés avec le carnet d'adresses."), + ("Toggle Tags", "Basculer les étiquettes"), + ("pull_ab_failed_tip", "Échec de l’actualisation du carnet d’adresses"), + ("push_ab_failed_tip", "Échec de la synchronisation du carnet d’adresses avec le serveur"), + ("synced_peer_readded_tip", "Les appareils qui étaient présents dans les sessions récentes seront synchronisés vers le carnet d’adresses."), ("Change Color", "Modifier la couleur"), - ("Primary Color", "Couleur primaire"), - ("HSV Color", "Couleur TSL"), - ("Installation Successful!", "Installation réussie !"), - ("Installation failed!", "Échec de l'installation !"), + ("Primary Color", "Couleur principale"), + ("HSV Color", "Couleur TSV"), + ("Installation Successful!", "Installation réussie !"), + ("Installation failed!", "Échec de l’installation !"), ("Reverse mouse wheel", "Inverser le sens de la molette de la souris"), - ("{} sessions", "{} sessions"), - ("scam_title", "Vous êtes peut-être victime d'une ESCROQUERIE !"), - ("scam_text1", "Si vous êtes au téléphone avec quelqu'un QUE VOUS NE CONNAISSEZ PAS et en qui VOUS N'AVEZ PAS CONFIANCE et qui vous a demandé d'utiliser RustDesk et de démarrer le service, ne le faites pas et raccrochez immédiatement."), - ("scam_text2", "Il s'agit probablement d'un escroc qui tente de vous voler de l'argent ou d'autres informations personnelles."), - ("Don't show again", "Ne plus montrer"), - ("I Agree", "J'accepte"), + ("{} sessions", "{} sessions"), + ("scam_title", "Vous êtes peut-être victime d’une ESCROQUERIE !"), + ("scam_text1", "Si vous êtes au téléphone avec quelqu’un QUE VOUS NE CONNAISSEZ PAS et en qui VOUS N’AVEZ PAS CONFIANCE et qui vous a demandé d’utiliser RustDesk et de démarrer le service, ne le faites pas et raccrochez immédiatement."), + ("scam_text2", "Il s’agit probablement d’un escroc qui tente de vous voler de l’argent ou d’autres informations personnelles."), + ("Don't show again", "Ne plus afficher"), + ("I Agree", "J’accepte"), ("Decline", "Refuser"), - ("Timeout in minutes", "Délai d'expiration en minutes"), - ("auto_disconnect_option_tip", "Fermer automatiquement les sessions entrantes en cas d'inactivité de l'utilisateur"), - ("Connection failed due to inactivity", "Déconnecté automatiquement pour cause d'inactivité"), + ("Timeout in minutes", "Délai d’expiration en minutes"), + ("auto_disconnect_option_tip", "Terminer automatiquement les sessions entrantes en cas d’inactivité de l’utilisateur"), + ("Connection failed due to inactivity", "Déconnecté automatiquement pour cause d’inactivité"), ("Check for software update on startup", "Vérifier la disponibilité des mises à jour au démarrage"), - ("upgrade_rustdesk_server_pro_to_{}_tip", "Veuillez mettre à jour RustDesk Server Pro avec la version {} ou une version plus récente !"), - ("pull_group_failed_tip", "Échec de l'actualisation du groupe"), + ("upgrade_rustdesk_server_pro_to_{}_tip", "Veuillez mettre à jour RustDesk Server Pro vers la version {} ou une version ultérieure !"), + ("pull_group_failed_tip", "Échec de l’actualisation du groupe"), ("Filter by intersection", "Filtrer par intersection"), - ("Remove wallpaper during incoming sessions", "Supprimer le fond d'écran lors des sessions entrantes"), - ("Test", ""), - ("display_is_plugged_out_msg", "L'écran est débranché, passez au premier écran."), + ("Remove wallpaper during incoming sessions", "Cacher le fond d’écran lors des sessions entrantes"), + ("Test", "Test"), + ("display_is_plugged_out_msg", "L’affichage est débranché, passez sur le premier affichage."), ("No displays", "Aucun affichage"), ("Open in new window", "Ouvrir dans une nouvelle fenêtre"), ("Show displays as individual windows", "Montrer les affichages sous forme de fenêtres individuelles"), - ("Use all my displays for the remote session", "Utiliser tous mes écrans pour la session à distance"), - ("selinux_tip", "SELinux est activé sur votre appareil, ce qui peut empêcher RustDesk de fonctionner correctement en tant que machine contrôlé."), - ("Change view", "Disposition d'affichage"), + ("Use all my displays for the remote session", "Utiliser tous mes affichages pour la session à distance"), + ("selinux_tip", "SELinux est activé sur votre appareil, ce qui peut empêcher RustDesk de fonctionner correctement sur la machine contrôlée."), + ("Change view", "Disposition"), ("Big tiles", "Grandes tuiles"), ("Small tiles", "Petites tuiles"), ("List", "Liste"), ("Virtual display", "Affichage virtuel"), - ("Plug out all", "Déconnecter tout"), + ("Plug out all", "Tout débrancher"), ("True color (4:4:4)", "Couleur réelle (4:4:4)"), - ("Enable blocking user input", "Activer le blocage des entrées utilisateur"), - ("id_input_tip", "Vous pouvez saisir un ID, une adresse IP directe ou un nom de domaine avec un port (:).\nSi vous souhaitez accéder à un appareil sur un autre serveur, veuillez ajouter l'adresse du serveur (?key=), par exemple,\n9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=.\nSi vous souhaitez accéder à un appareil sur un serveur public, veuillez saisir \"@public\" , la clé n'est pas nécessaire pour le serveur public"), - ("privacy_mode_impl_mag_tip", "Mode 1"), - ("privacy_mode_impl_virtual_display_tip", "Mode 2"), - ("Enter privacy mode", "Passer en mode confidentialité"), - ("Exit privacy mode", "Quitter le mode confidentialité"), - ("idd_not_support_under_win10_2004_tip", "Le pilote d'affichage indirect n'est pas pris en charge. Windows 10, version 2004 ou plus récente est requise."), - ("input_source_1_tip", "Source entrée 1"), - ("input_source_2_tip", "Source entrée 2"), - ("Swap control-command key", "Échanger la touche de controle-commande"), - ("swap-left-right-mouse", "Intervertir le bouton gauche et droit de la souris"), - ("2FA code", "code 2FA"), + ("Enable blocking user input", "Activer le blocage des entrées de l’utilisateur"), + ("id_input_tip", "Vous pouvez saisir un ID, une adresse IP ou un nom de domaine suivi d’un port (:).\nSi vous souhaitez accéder à un appareil sur un autre serveur, veuillez ajouter l’adresse du serveur (@?key=), par exemple :\n9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=.\nSi vous souhaitez accéder à un appareil sur un serveur public, veuillez saisir « @public » (la clé n’est pas nécessaire pour le serveur public).\n\nSi vous souhaitez forcer l’utilisation d’une connexion via relais dès la première tentative, ajoutez « /r » après l’ID, par exemple : « 9123456234/r »."), + ("privacy_mode_impl_mag_tip", "Mode 1"), + ("privacy_mode_impl_virtual_display_tip", "Mode 2"), + ("Enter privacy mode", "Entrer en mode de confidentialité"), + ("Exit privacy mode", "Quitter le mode de confidentialité"), + ("idd_not_support_under_win10_2004_tip", "Le pilote d’affichage indirect n’est pas pris en charge. Windows 10 version 2004 ou ultérieure est requis."), + ("input_source_1_tip", "Entrée source 1"), + ("input_source_2_tip", "Entrée source 2"), + ("Swap control-command key", "Intervertir la touche contrôle-commande"), + ("swap-left-right-mouse", "Intervertir les boutons gauche et droit de la souris"), + ("2FA code", "Code 2FA"), ("More", "Plus"), - ("enable-2fa-title", "Activer l'authentification à double facteur"), - ("enable-2fa-desc", "Veuillez configurer votre authentificateur maintenant. Vous pouvez utiliser une application d’authentification telle qu’Authy, Microsoft ou Google Authenticator sur votre téléphone ou votre ordinateur de bureau.nnScannez le code QR avec votre application et entrez le code affiché par votre application pour activer l’authentification à deux facteurs."), - ("wrong-2fa-code", "Impossible de vérifier le code. Vérifiez que le code et les paramètres d’heure locale sont corrects"), + ("enable-2fa-title", "Activer l’authentification à deux facteurs"), + ("enable-2fa-desc", "Veuillez maintenant configurer votre authentificateur. Vous pouvez utiliser une application d’authentification telle qu’Authy, Microsoft ou Google Authenticator sur votre téléphone ou votre ordinateur.\n\nScannez le code QR avec votre application puis saisissez le code affiché par votre application afin d’activer l’authentification à deux facteurs."), + ("wrong-2fa-code", "Impossible de vérifier le code. Vérifiez l’exactitude du code saisi ainsi que des paramètres d’heure locale"), ("enter-2fa-title", "Authentification à deux facteurs"), - ("Email verification code must be 6 characters.", "Le code de vérification email doit comporter 6 caractères"), - ("2FA code must be 6 digits.", "le code 2FA doit comporter 6 chiffres"), - ("Multiple Windows sessions found", "Plusieurs sessions Windows trouvées"), - ("Please select the session you want to connect to", "Merci de sélectionner la session Windows à laquelle vous voulez vous connecter"), - ("powered_by_me", ""), - ("outgoing_only_desk_tip", "Il s’agit d’une édition personnalisée.\nVous pouvez vous connecter à d’autres appareils, mais les autres appareils ne peuvent pas se connecter à votre appareil."), - ("preset_password_warning", "Cette édition personnalisée est livrée avec un mot de passe prédéfini. Toute personne connaissant ce mot de passe pourrait prendre le contrôle total de votre appareil. Si vous ne vous y attendiez pas, désinstallez immédiatement le logiciel."), + ("Email verification code must be 6 characters.", "Le code de vérification de l’adresse électronique doit être composé de 6 caractères."), + ("2FA code must be 6 digits.", "Le code 2FA doit être composé de 6 chiffres."), + ("Multiple Windows sessions found", "Plusieurs sessions Windows ont été trouvées"), + ("Please select the session you want to connect to", "Veuillez sélectionner la session à laquelle vous souhaitez vous connecter"), + ("powered_by_me", "Utilise la technologie RustDesk"), + ("outgoing_only_desk_tip", "Vous utilisez une version personnalisée.\nVous pouvez vous connecter à d’autres appareils, mais les autres appareils ne peuvent pas se connecter au vôtre."), + ("preset_password_warning", "Cette version personnalisée est livrée avec un mot de passe prédéfini. Toute personne connaissant ce mot de passe pourrait prendre le contrôle total de votre appareil. Si vous ne vous y attendiez pas, désinstallez immédiatement le logiciel."), ("Security Alert", "Alerte de sécurité"), - ("My address book", "Mon carnet d'adresse"), + ("My address book", "Mon carnet d’adresses"), ("Personal", "Personnel"), ("Owner", "Propriétaire"), ("Set shared password", "Définir le mot de passe partagé"), ("Exist in", "Existe dans"), - ("Read-only", "Lecture-seule"), + ("Read-only", "Lecture seule"), ("Read/Write", "Lecture/Écriture"), - ("Full Control", "Control Total"), + ("Full Control", "Contrôle complet"), ("share_warning_tip", "Les champs ci-dessus sont partagés et visibles par les autres."), ("Everyone", "Tout le monde"), - ("ab_web_console_tip", "Plus sur la console Web"), + ("ab_web_console_tip", "Plus sur la console web"), ("allow-only-conn-window-open-tip", "N’autoriser la connexion que si la fenêtre RustDesk est ouverte"), - ("no_need_privacy_mode_no_physical_displays_tip", "Pas d’affichage physique, pas besoin d’utiliser le mode confidentialité."), + ("no_need_privacy_mode_no_physical_displays_tip", "Aucun affichage physique ; l’utilisation du mode de confidentialité n’est pas nécessaire."), ("Follow remote cursor", "Suivre le curseur distant"), - ("Follow remote window focus", ""), + ("Follow remote window focus", "Suivre la focalisation de fenêtre distante"), ("default_proxy_tip", "Le protocole et le port par défaut sont Socks5 et 1080"), ("no_audio_input_device_tip", "Aucun périphérique d’entrée audio trouvé."), - ("Incoming", "Entrant"), - ("Outgoing", "Sortant"), - ("Clear Wayland screen selection", ""), - ("clear_Wayland_screen_selection_tip", ""), - ("confirm_clear_Wayland_screen_selection_tip", ""), - ("android_new_voice_call_tip", "Une nouvelle demande d’appel vocal a été reçue. Si vous acceptez, l’audio passera à la communication vocale."), - ("texture_render_tip", "Utilisez le rendu des textures pour rendre les images plus fluides."), + ("Incoming", "Entrantes"), + ("Outgoing", "Sortantes"), + ("Clear Wayland screen selection", "Effacer la sélection d’écran Wayland"), + ("clear_Wayland_screen_selection_tip", "Une fois la sélection d’écran effacée, vous pourrez resélectionner l’écran à partager."), + ("confirm_clear_Wayland_screen_selection_tip", "Voulez-vous vraiment effacer la sélection d’écran Wayland ?"), + ("android_new_voice_call_tip", "Une nouvelle demande d’appel vocal a été reçue. Si vous acceptez, l’audio passera sur la communication vocale."), + ("texture_render_tip", "Utiliser le rendu de texture afin de lisser les images. Désactiver cette option permet de résoudre certains problèmes de rendu."), ("Use texture rendering", "Utiliser le rendu de texture"), - ("Floating window", ""), - ("floating_window_tip", ""), - ("Keep screen on", ""), - ("Never", ""), - ("During controlled", ""), - ("During service is on", ""), - ("Capture screen using 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", ""), - ("Requires at least {} characters", ""), - ("Wrong PIN", ""), - ("Set 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", ""), + ("Floating window", "Fenêtre flottante"), + ("floating_window_tip", "Aide à maintenir le service en arrière-plan"), + ("Keep screen on", "Maintenir l’écran allumé"), + ("Never", "Jamais"), + ("During controlled", "Lorsque l’appareil est contrôlé"), + ("During service is on", "Lorsque le service est actif"), + ("Capture screen using DirectX", "Utiliser DirectX pour capturer l’écran"), + ("Back", "Retour"), + ("Apps", "Applis"), + ("Volume up", "Volume haut"), + ("Volume down", "Volume bas"), + ("Power", "Marche/Arrêt"), + ("Telegram bot", "Bot Telegram"), + ("enable-bot-tip", "Activer cette fonctionnalité vous permet de recevoir le code 2FA depuis votre bot. Peut également servir de notification de connexion."), + ("enable-bot-desc", "1. Entamez une discussion avec @BotFather.\n2. Envoyez-lui la commande « newbot ». Vous recevrez un jeton suite à cette étape.\n3. Entamez une discussion avec votre bot nouvellement créé. Envoyez-lui un message commençant par une barre oblique (« / ») tel que « /hello » afin de l’activer.\n"), + ("cancel-2fa-confirm-tip", "Voulez-vous vraiment désactiver l’authentication à deux facteurs ?"), + ("cancel-bot-confirm-tip", "Voulez-vous vraiment désactiver le bot Telegram ?"), + ("About RustDesk", "À propos de RustDesk"), + ("Send clipboard keystrokes", "Taper le contenu du presse-papier"), + ("network_error_tip", "Veuillez vérifier votre connexion réseau puis réessayer."), + ("Unlock with PIN", "Déverrouiller par code PIN"), + ("Requires at least {} characters", "Requiert un minimum de {} caractères"), + ("Wrong PIN", "Code PIN erroné"), + ("Set PIN", "Définir le code PIN"), + ("Enable trusted devices", "Activer les appareils de confiance"), + ("Manage trusted devices", "Gérer les appareils de confiance"), + ("Platform", "Plateforme"), + ("Days remaining", "Jours restants"), + ("enable-trusted-devices-tip", "Ne pas demander de code 2FA sur les appareils de confiance"), + ("Parent directory", "Répertoire parent"), + ("Resume", "Reprendre"), + ("Invalid file name", "Nom de fichier non valide"), + ("one-way-file-transfer-tip", "Le transfert de fichiers à sens unique est activé côté appareil contrôlé."), + ("Authentication Required", "Authentication requise"), + ("Authenticate", "Authentifier"), + ("web_id_input_tip", "Vous pouvez saisir un ID sur le même serveur ; le client web ne prend pas en charge l’accès par adresse IP.\nSi vous souhaitez accéder à un appareil sur un autre serveur, veuillez ajouter l’adresse du serveur (@?key=), par exemple :\n9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=.\nSi vous souhaitez accéder à un appareil sur un serveur public, veuillez saisir « @public » (la clé n’est pas nécessaire pour le serveur public)."), + ("Download", "Télécharger"), + ("Upload folder", "Téléverser le dossier"), + ("Upload files", "Téléverser les fichiers"), + ("Clipboard is synchronized", "Le presse-papier est synchronisé"), + ("Update client clipboard", "Actualiser le presse-papier du client"), + ("Untagged", "Sans étiquette"), + ("new-version-of-{}-tip", "Une nouvelle version de {} est disponible"), + ("Accessible devices", "Appareils accessibles"), + ("upgrade_remote_rustdesk_client_to_{}_tip", "Veuillez mettre le client RustDesk distant à jour vers la version {} ou ultérieure !"), + ("d3d_render_tip", "Sur certaines machines, l’écran du contrôle à distance peut rester noir lors de l’utilisation du rendu D3D."), + ("Use D3D rendering", "Utiliser le rendu D3D"), + ("Printer", "Imprimante"), + ("printer-os-requirement-tip", "La fonction d’impression sortante nécessite Windows 10 ou une version ultérieure."), + ("printer-requires-installed-{}-client-tip", "{} doit être installé sur cet appareil avant de pouvoir utiliser l’impression à distance."), + ("printer-{}-not-installed-tip", "L’imprimante {} n’est pas installée."), + ("printer-{}-ready-tip", "L’imprimante {} est installée et opérationnelle."), + ("Install {} Printer", "Installer l’imprimante {}"), + ("Outgoing Print Jobs", "Impressions sortantes"), + ("Incoming Print Jobs", "Impressions entrantes"), + ("Incoming Print Job", "Impression entrante"), + ("use-the-default-printer-tip", "Utiliser l’imprimante par défaut"), + ("use-the-selected-printer-tip", "Utiliser l’imprimante sélectionnée"), + ("auto-print-tip", "Imprimer automatiquement en utilisant l’imprimante sélectionnée."), + ("print-incoming-job-confirm-tip", "L’appareil distant vous a envoyé une impression ; voulez-vous l’exécuter de votre côté ?"), + ("remote-printing-disallowed-tile-tip", "Impression à distance non autorisée"), + ("remote-printing-disallowed-text-tip", "Les paramètres de l’appareil contrôlé n’autorisent pas l’impression à distance."), + ("save-settings-tip", "Enregistrer les paramètres"), + ("dont-show-again-tip", "Ne plus afficher"), + ("Take screenshot", "Prendre une capture d’écran"), + ("Taking screenshot", "Prise de capture d’écran"), + ("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"), + ("Copy to clipboard", "Copier dans le presse-papier"), + ("Enable remote printer", "Activer l’impression à distance"), + ("Downloading {}", "Téléchargement de {}"), + ("{} Update", "Mise à jour de {}"), + ("{}-to-update-tip", "{} va maintenant quitter afin d’installer la nouvelle version."), + ("download-new-version-failed-tip", "Le téléchargement a échoué. Vous pouvez réessayer, ou bien cliquer sur le bouton « Télécharger » pour vous rendre sur la page de publication afin de mettre à jour manuellement."), + ("Auto update", "Installer les mises à jour automatiquement"), + ("update-failed-check-msi-tip", "La vérification de la méthode d’installation a échoué. Veuillez cliquer sur le bouton « Télécharger » pour vous rendre sur la page de publication afin de mettre à jour manuellement."), + ("websocket_tip", "Seules les connexions via relais sont prises en charge lors de l’utilisation de WebSocket."), + ("Use WebSocket", "Utiliser WebSocket"), + ("Trackpad speed", "Vitesse du pavé tactile"), + ("Default trackpad speed", "Vitesse par défaut du pavé tactile"), + ("Numeric one-time password", "Mot de passe à usage unique numérique"), + ("Enable IPv6 P2P connection", "Activer la connexion P2P IPv6"), + ("Enable UDP hole punching", "Activer le « hole punching » UDP"), + ("View camera", "Afficher la caméra"), + ("Enable camera", "Activer la caméra"), + ("No cameras", "Aucune caméra"), + ("view_camera_unsupported_tip", "L’appareil distant ne prend pas en charge l’affichage de la caméra."), + ("Terminal", "Terminal"), + ("Enable terminal", "Activer le terminal"), + ("New tab", "Nouvel onglet"), + ("Keep terminal sessions on disconnect", "Maintenir les sessions du terminal lors de la déconnexion"), + ("Terminal (Run as administrator)", "Terminal (administrateur)"), + ("terminal-admin-login-tip", "Veuillez saisir le nom d’utilisateur et le mot de passe de l’administrateur de l’appareil contrôlé."), + ("Failed to get user token.", "Échec de l’obtention du jeton utilisateur."), + ("Incorrect username or password.", "Nom d’utilisateur ou mot de passe incorrect."), + ("The user is not an administrator.", "L’utilisateur n’est pas un administrateur."), + ("Failed to check if the user is an administrator.", "Échec de la vérification du statut d’administrateur de l’utilisateur."), + ("Supported only in the installed version.", "Uniquement pris en charge dans la version installée."), + ("elevation_username_tip", "Saisissez un nom d’utilisateur ou un domaine\\utilisateur"), + ("Preparing for installation ...", "Préparation de l’installation…"), + ("Show my cursor", "Afficher mon curseur"), + ("Scale custom", "Échelle personnalisée"), + ("Custom scale slider", "Curseur d’échelle personnalisée"), + ("Decrease", "Diminuer"), + ("Increase", "Augmenter"), + ("Show virtual mouse", "Afficher la souris virtuelle"), + ("Virtual mouse size", "Taille de la souris virtuelle"), + ("Small", "Petite"), + ("Large", "Grande"), + ("Show virtual joystick", "Afficher le joystick virtuel"), + ("Edit note", "Modifier la note"), + ("Alias", "Alias"), + ("ScrollEdge", "Défilement sur les bords"), + ("Allow insecure TLS fallback", "Utiliser une connexion TLS non sécurisée si nécessaire"), + ("allow-insecure-tls-fallback-tip", "Par défaut, RustDesk vérifie le certificat du serveur lors de l’utilisation de protocoles utilisant TLS.\nLorsque cette option est activée, RustDesk autorise les connexions même en cas d’échec de l’étape de vérification."), + ("Disable UDP", "Désactiver UDP"), + ("disable-udp-tip", "Contrôle l’utilisation exclusive du mode TCP.\nLorsque cette option est activée, RustDesk n’utilise plus le port UDP 21116 et utilise le port TCP 21116 à la place."), + ("server-oss-not-support-tip", "Note : Cette fonctionnalité n’est pas disponible sous la version open-source du serveur RustDesk."), + ("input note here", "saisir la note ici"), + ("note-at-conn-end-tip", "Proposer de rédiger une note une fois la connexion terminée"), + ("Show terminal extra keys", "Afficher les touches supplémentaires du terminal"), + ("Relative mouse mode", "Mode souris relative"), + ("rel-mouse-not-supported-peer-tip", "Le mode souris relative n’est pas pris en charge par l’appareil distant."), + ("rel-mouse-not-ready-tip", "Le mode souris relative n’est pas encore prêt ; veuillez réessayer."), + ("rel-mouse-lock-failed-tip", "Échec du verrouillage du curseur. Le mode souris relative a été désactivé."), + ("rel-mouse-exit-{}-tip", "Appuyez sur {} pour quitter."), + ("rel-mouse-permission-lost-tip", "L’autorisation de contrôle du clavier a été révoquée. Le mode souris relative a été désactivé."), + ("Changelog", "Journal des modifications"), + ("keep-awake-during-outgoing-sessions-label", "Maintenir l’écran allumé lors des sessions sortantes"), + ("keep-awake-during-incoming-sessions-label", "Maintenir l’écran allumé lors des sessions entrantes"), + ("Continue with {}", "Continuer avec {}"), + ("Display Name", "Nom d’affichage"), + ("password-hidden-tip", "Le mot de passe permanent est défini (masqué)."), + ("preset-password-in-use-tip", "Le mot de passe prédéfini est actuellement utilisé."), + ("Enable privacy mode", "Activer le mode de confidentialité"), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ge.rs b/src/lang/ge.rs new file mode 100644 index 000000000..2fc8f282d --- /dev/null +++ b/src/lang/ge.rs @@ -0,0 +1,749 @@ +lazy_static::lazy_static! { +pub static ref T: std::collections::HashMap<&'static str, &'static str> = + [ + ("Status", "სტატუსი"), + ("Your Desktop", "თქვენი სამუშაო მაგიდა"), + ("desk_tip", "თქვენი სამუშაო მაგიდა ხელმისაწვდომია ამ ID-ით და პაროლით."), + ("Password", "პაროლი"), + ("Ready", "მზადაა"), + ("Established", "დამყარებულია"), + ("connecting_status", "RustDesk ქსელთან დაკავშირება..."), + ("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", "დაშვებულია მხოლოდ a-z, A-Z, 0-9, - (დეფისი) და _ (ქვედა ტირე) სიმბოლოები. პირველი უნდა იყოს a-z, A-Z ასო. სიგრძე 6-დან 16-მდე."), + ("Website", "ვებგვერდი"), + ("About", "პროგრამის შესახებ"), + ("Slogan_tip", "შექმნილია გულით ამ შეშლილ სამყაროში!"), + ("Privacy Statement", "კონფიდენციალურობის განაცხადი"), + ("Mute", "ხმის გათიშვა"), + ("Build Date", "აგების თარიღი"), + ("Version", "ვერსია"), + ("Home", "მთავარი"), + ("Audio Input", "აუდიო შესავალი"), + ("Enhancements", "გაუმჯობესებები"), + ("Hardware Codec", "აპარატული კოდეკი"), + ("Adaptive bitrate", "ადაპტური ბიტრეიტი"), + ("ID Server", "ID სერვერი"), + ("Relay Server", "რეტრანსლატორი"), + ("API Server", "API სერვერი"), + ("invalid_http", "მისამართი უნდა იწყებოდეს http:// ან https://-ით"), + ("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", "სცადეთ ერთი წუთის შემდეგ"), + ("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", "ოპერაციული სისტემის პაროლი"), + ("install_tip", "ზოგიერთ შემთხვევაში UAC-ის გამო RustDesk შეიძლება არასწორად მუშაობდეს დაშორებულ კვანძზე. UAC-თან დაკავშირებული პრობლემების თავიდან ასაცილებლად დააჭირეთ ქვემოთ მოცემულ ღილაკს სისტემაში RustDesk-ის დასაყენებლად."), + ("Click to upgrade", "დააჭირეთ განახლებისთვის"), + ("Configure", "კონფიგურაცია"), + ("config_acc", "თქვენი სამუშაო მაგიდის დისტანციური მართვისთვის უნდა მიანიჭოთ RustDesk-ს \"წვდომის\" უფლებები"), + ("config_screen", "სამუშაო მაგიდაზე დისტანციური წვდომისთვის უნდა მიანიჭოთ RustDesk-ს \"ეკრანის ანაბეჭდის\" უფლებები"), + ("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.", "თქვენი ინსტალაცია უფრო ადრეული ვერსიაა."), + ("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", "შეიყვანეთ დაშორებული 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", "მხოლოდ თეთრ სიაში არსებულ IP მისამართებს შეუძლიათ ჩემს მოწყობილობაზე წვდომა."), + ("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", "SOCKS5-პროქსი"), + ("Socks5/Http(s) Proxy", ""), + ("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-მოწყობილობის მართვა თაგუნათი ან შეხებით, საჭიროა RustDesk-ისთვის \"სპეციალური შესაძლებლობების\" სერვისის გამოყენების უფლების მინიჭება."), + ("android_input_permission_tip2", "გადადით სისტემის პარამეტრების შესაბამის გვერდზე, იპოვეთ და შედით \"დაინსტალირებულ სერვისებში\", ჩართეთ \"RustDesk Input\" სერვისი."), + ("android_new_connection_tip", "მიღებულია ახალი მოთხოვნა თქვენი მიმდინარე მოწყობილობის მართვაზე."), + ("android_service_will_start_tip", "ეკრანის ჩაწერის ჩართვა ავტომატურად გაუშვებს სერვისს, რაც სხვა მოწყობილობებს საშუალებას აძლევს მოითხოვონ ამ მოწყობილობასთან დაკავშირება."), + ("android_stop_service_tip", "სერვისის დახურვა ავტომატურად დახურავს ყველა დამყარებულ კავშირს."), + ("android_version_audio_tip", "Android-ის მიმდინარე ვერსია არ უჭერს მხარს ხმის ჩაწერას, განაახლეთ Android 10-მდე ან უფრო ახალ ვერსიამდე."), + ("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", "Wayland საჭიროებს Ubuntu 21.04 ან უფრო ახალ ვერსიას."), + ("wayland-requires-higher-linux-version", "Wayland-ს სჭირდება Linux-ის დისტრიბუტივის უფრო ახალი ვერსია. გამოიყენეთ X11 სამუშაო მაგიდა ან შეცვალეთ ოპერაციული სისტემა."), + ("xdp-portal-unavailable", ""), + ("JumpLink", "ნახვა"), + ("Please Select the screen to be shared(Operate on the peer side).", "აირჩიეთ ეკრანი გასაზიარებლად (იმუშავეთ პარტნიორის მხარეს)."), + ("Show RustDesk", "RustDesk-ის ჩვენება"), + ("This 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", "დამალვის დაშვება, თუ სესიები მიიღება პაროლით ან გამოიყენება მუდმივი პაროლი"), + ("wayland_experiment_tip", "Wayland-ის მხარდაჭერა ექსპერიმენტულ ეტაპზეა, გამოიყენეთ X11, თუ გჭირდებათ ავტომატური წვდომა."), + ("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", "თუ გაქვთ Nvidia ვიდეობარათი და დისტანციური ფანჯარა იხურება დაკავშირებისთანავე, შეიძლება დაგეხმაროთ Nouveau დრაივერის დაყენება და პროგრამული ვიზუალიზაციის არჩევა. საჭირო იქნება გადატვირთვა."), + ("Always use software rendering", "ყოველთვის გამოიყენეთ პროგრამული ვიზუალიზაცია"), + ("config_input", "დისტანციური სამუშაო მაგიდის კლავიატურით სამართავად, საჭიროა RustDesk-ისთვის \"შეყვანის მონიტორინგის\" უფლების მინიჭება."), + ("config_microphone", "დისტანციურ მხარესთან სასაუბროდ, საჭიროა RustDesk-ისთვის \"აუდიოს ჩაწერის\" უფლების მინიჭება."), + ("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", "კვლავ საჭიროა, რომ დისტანციურმა მომხმარებელმა დააჭიროს \"OK\"-ს UAC ფანჯარაში RustDesk-ის გაშვებისას."), + ("Request Elevation", "უფლებების აწევის მოთხოვნა"), + ("wait_accept_uac_tip", "დაელოდეთ, სანამ დისტანციური მომხმარებელი დაადასტურებს UAC მოთხოვნას."), + ("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", "პირდაპირი კავშირი შეიძლება შეუძლებელი იყოს. ამ შემთხვევაში შეგიძლიათ სცადოთ რეტრანსლატორის გავლით დაკავშირება.\nასევე, თუ გსურთ პირდაპირ რეტრანსლატორის გამოყენება, შეგიძლიათ დაამატოთ ID-ს სუფიქსი \"/r\" ან ჩართოთ \"ყოველთვის დაუკავშირდით რეტრანსლატორის გავლით\" დისტანციური კვანძის პარამეტრებში."), + ("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", "არ არის ბოლო სესიები!\nდროა დაგეგმოთ ახალი."), + ("empty_favorite_tip", "ჯერ არ გაქვთ რჩეული დისტანციური კვანძები?\nმოდით, ვნახოთ, ვის შეიძლება დავამატოთ რჩეულებში!"), + ("empty_lan_tip", "დისტანციური კვანძები ვერ მოიძებნა."), + ("empty_address_book_tip", "მისამართების წიგნში არ არის დისტანციური კვანძები."), + ("Empty Username", "ცარიელი მომხმარებლის სახელი"), + ("Empty Password", "ცარიელი პაროლი"), + ("Me", "მე"), + ("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", "ნაგულისხმევი"), + ("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", "კავშირის დაშვება და UAC უფლებების აწევა."), + ("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", "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", "თუ ტელეფონით ესაუბრებით ვინმეს, ვისაც არ იცნობთ და არ ენდობით, და ის გთხოვთ გამოიყენოთ RustDesk და გაუშვათ მისი სერვისი, არ გააგრძელოთ და დაუყოვნებლივ შეწყვიტეთ საუბარი."), + ("scam_text2", "სავარაუდოდ, ეს არის თაღლითი, რომელიც ცდილობს მოიპაროს თქვენი ფული ან სხვა პირადი ინფორმაცია."), + ("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", "განაახლეთ RustDesk Server Pro ვერსიამდე {} ან უფრო ახალი!"), + ("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", "თქვენს მოწყობილობაზე ჩართულია SELinux, რამაც შეიძლება ხელი შეუშალოს RustDesk-ის სწორ მუშაობას მართულ მხარეზე."), + ("Change view", "ხედი"), + ("Big tiles", "დიდი ხატულები"), + ("Small tiles", "პატარა ხატულები"), + ("List", "სია"), + ("Virtual display", "ვირტუალური ეკრანი"), + ("Plug out all", "ყველას გამორთვა"), + ("True color (4:4:4)", "True color (4:4:4)"), + ("Enable blocking user input", "მომხმარებლის შეყვანის დაბლოკვის დაშვება"), + ("id_input_tip", "შეგიძლიათ შეიყვანოთ იდენტიფიკატორი, პირდაპირი IP მისამართი ან დომენი პორტით (<დომენი>:<პორტი>).\nთუ გჭირდებათ წვდომა მოწყობილობაზე სხვა სერვერზე, დაამატეთ სერვერის მისამართი (@<სერვერის_მისამართი>?key=<გასაღების_მნიშვნელობა>), მაგალითად:\n9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=.\nთუ გჭირდებათ წვდომა მოწყობილობაზე საჯარო სერვერზე, შეიყვანეთ \"@public\", გასაღები საჯარო სერვერისთვის არ არის საჭირო."), + ("privacy_mode_impl_mag_tip", "რეჟიმი 1"), + ("privacy_mode_impl_virtual_display_tip", "რეჟიმი 2"), + ("Enter privacy mode", "კონფიდენციალურობის რეჟიმის ჩართვა"), + ("Exit privacy mode", "კონფიდენციალურობის რეჟიმის გამორთვა"), + ("idd_not_support_under_win10_2004_tip", "არაპირდაპირი ჩვენების დრაივერი არ არის მხარდაჭერილი. საჭიროა Windows 10 ვერსია 2004 ან უფრო ახალი."), + ("input_source_1_tip", "შეყვანის წყარო 1"), + ("input_source_2_tip", "შეყვანის წყარო 2"), + ("Swap control-command key", "Ctrl და Command ღილაკების მნიშვნელობების გაცვლა"), + ("swap-left-right-mouse", "მაუსის მარცხენა და მარჯვენა ღილაკების მნიშვნელობების გაცვლა"), + ("2FA code", "ორფაქტორიანი ავთენტიფიკაციის კოდი"), + ("More", "მეტი"), + ("enable-2fa-title", "ორფაქტორიანი ავთენტიფიკაციის გამოყენება"), + ("enable-2fa-desc", "მოაწყვეთ ავთენტიფიკაციის აპლიკაცია. გამოიყენეთ, მაგალითად, Authy, Microsoft ან Google Authenticator ტელეფონზე ან კომპიუტერზე.\n\nდაასკანერეთ QR კოდი ავთენტიფიკაციის აპლიკაციით და შეიყვანეთ კოდი, რომელიც გამოჩნდება ამ აპლიკაციაში, ორფაქტორიანი ავთენტიფიკაციის ჩასართავად."), + ("wrong-2fa-code", "კოდის დადასტურება შეუძლებელია. შეამოწმეთ კოდი და ადგილობრივი დროის პარამეტრები."), + ("enter-2fa-title", "ორფაქტორიანი ავთენტიფიკაცია"), + ("Email verification code must be 6 characters.", "ელ-ფოსტის დადასტურების კოდი უნდა შედგებოდეს 6 სიმბოლოსგან."), + ("2FA code must be 6 digits.", "ორფაქტორიანი ავთენტიფიკაციის კოდი უნდა შედგებოდეს 6 ციფრისგან."), + ("Multiple Windows sessions found", "აღმოჩენილია Windows-ის რამდენიმე სესია"), + ("Please select the session you want to connect to", "აირჩიეთ სესია, რომელთანაც გსურთ დაკავშირება"), + ("powered_by_me", "RustDesk-ზე დაფუძნებული"), + ("outgoing_only_desk_tip", "ეს სპეციალიზებული ვერსიაა.\nშეგიძლიათ დაუკავშირდეთ სხვა მოწყობილობებს, მაგრამ სხვა მოწყობილობებს არ შეუძლიათ დაუკავშირდნენ თქვენსას."), + ("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", "დაშვება მხოლოდ მაშინ, როცა RustDesk-ის ფანჯარა გახსნილია"), + ("no_need_privacy_mode_no_physical_displays_tip", "ფიზიკური ეკრანები არ არის, არ არის საჭირო კონფიდენციალურობის რეჟიმის გამოყენება."), + ("Follow remote cursor", "დისტანციური კურსორის მიყოლა"), + ("Follow remote window focus", "დისტანციური ფანჯრის ფოკუსის მიყოლა"), + ("default_proxy_tip", "ნაგულისხმევი პროტოკოლი და პორტი: Socks5 და 1080"), + ("no_audio_input_device_tip", "აუდიო შეყვანის მოწყობილობა ვერ მოიძებნა."), + ("Incoming", "შემომავალი"), + ("Outgoing", "გამავალი"), + ("Clear Wayland screen selection", "Wayland ეკრანის არჩევანის გაუქმება"), + ("clear_Wayland_screen_selection_tip", "გაუქმების შემდეგ შეგიძლიათ ხელახლა აირჩიოთ ეკრანი გასაზიარებლად."), + ("confirm_clear_Wayland_screen_selection_tip", "გავაუქმოთ Wayland ეკრანის არჩევანი?"), + ("android_new_voice_call_tip", "მიღებულია ახალი ხმოვანი ზარის მოთხოვნა. თუ მიიღებთ, ხმა გადაირთვება ხმოვან კავშირზე."), + ("texture_render_tip", "გამოიყენეთ ტექსტურების ვიზუალიზაცია გამოსახულებების უფრო გლუვად გასაკეთებლად."), + ("Use texture rendering", "ტექსტურების ვიზუალიზაცია"), + ("Floating window", "მოტივტივე ფანჯარა"), + ("floating_window_tip", "ეხმარება RustDesk-ის ფონური სერვისის შენარჩუნებას"), + ("Keep screen on", "ეკრანის ჩართულად შენარჩუნება"), + ("Never", "არასდროს"), + ("During controlled", "მართვისას"), + ("During service is on", "სერვისის მუშაობისას"), + ("Capture screen using DirectX", "ეკრანის გადაღება DirectX-ის გამოყენებით"), + ("Back", "უკან"), + ("Apps", "აპლიკაციები"), + ("Volume up", "ხმის გაზრდა"), + ("Volume down", "ხმის შემცირება"), + ("Power", "კვება"), + ("Telegram bot", "Telegram ბოტი"), + ("enable-bot-tip", "თუ ჩართულია, შეგიძლიათ მიიღოთ ორფაქტორიანი ავთენტიფიკაციის კოდი ბოტისგან. მას ასევე შეუძლია შეასრულოს დაკავშირების შეტყობინების ფუნქცია."), + ("enable-bot-desc", "1) გახსენით ჩატი @BotFather-თან.\n2) გაგზავნეთ ბრძანება \"/newbot\". ამ ნაბიჯის შესრულების შემდეგ მიიღებთ ტოკენს.\n3) დაიწყეთ ჩატი თქვენს ახლად შექმნილ ბოტთან. გაგზავნეთ შეტყობინება, რომელიც იწყება დახრილი ხაზით (\"/\"), მაგალითად, \"/hello\", მის გასააქტიურებლად.\n"), + ("cancel-2fa-confirm-tip", "გამოვრთოთ ორფაქტორიანი ავთენტიფიკაცია?"), + ("cancel-bot-confirm-tip", "გამოვრთოთ Telegram ბოტი?"), + ("About RustDesk", "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", "სანდო მოწყობილობებს შეუძლიათ გამოტოვონ 2FA ავთენტიფიკაციის შემოწმება"), + ("Parent directory", "მშობელი საქაღალდე"), + ("Resume", "გაგრძელება"), + ("Invalid file name", "არასწორი ფაილის სახელი"), + ("one-way-file-transfer-tip", "მართულ მხარეზე ჩართულია ცალმხრივი ფაილების გადაცემა."), + ("Authentication Required", "საჭიროა ავთენტიფიკაცია"), + ("Authenticate", "ავთენტიფიკაცია"), + ("web_id_input_tip", "შეგიძლიათ შეიყვანოთ ID იმავე სერვერზე, პირდაპირი IP წვდომა ვებ-კლიენტში არ არის მხარდაჭერილი.\nთუ გსურთ წვდომა მოწყობილობაზე სხვა სერვერზე, დაამატეთ სერვერის მისამართი (@<სერვერის_მისამართი>?key=<გასაღები>), მაგალითად,\n9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=.\nთუ გსურთ წვდომა მოწყობილობაზე საჯარო სერვერზე, შეიყვანეთ \"@public\", საჯარო სერვერისთვის გასაღები არ არის საჭირო."), + ("Download", "ჩამოტვირთვა"), + ("Upload folder", "საქაღალდის ატვირთვა"), + ("Upload files", "ფაილების ატვირთვა"), + ("Clipboard is synchronized", "გაცვლის ბუფერი სინქრონიზებულია"), + ("Update client clipboard", "კლიენტის გაცვლის ბუფერის განახლება"), + ("Untagged", "უტეგო"), + ("new-version-of-{}-tip", "ხელმისაწვდომია ახალი ვერსია {}"), + ("Accessible devices", "ხელმისაწვდომი მოწყობილობები"), + ("upgrade_remote_rustdesk_client_to_{}_tip", "განაახლეთ RustDesk კლიენტი ვერსიამდე {} ან უფრო ახალი დისტანციურ მხარეზე!"), + ("d3d_render_tip", "D3D ვიზუალიზაციის ჩართვისას ზოგიერთ მოწყობილობაზე დისტანციური ეკრანი შეიძლება იყოს შავი."), + ("Use D3D rendering", "D3D ვიზუალიზაციის გამოყენება"), + ("Printer", "პრინტერი"), + ("printer-os-requirement-tip", "პრინტერთან გამავალი კავშირის ფუნქციისთვის საჭიროა Windows 10 ან უფრო ახალი ვერსია."), + ("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", ""), + ("Trackpad speed", ""), + ("Default trackpad speed", ""), + ("Numeric one-time password", ""), + ("Enable IPv6 P2P connection", ""), + ("Enable UDP hole punching", ""), + ("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", ""), + ("allow-insecure-tls-fallback-tip", ""), + ("Disable 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 {}", "{}-ით გაგრძელება"), + ("Display Name", ""), + ("password-hidden-tip", ""), + ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), + ].iter().cloned().collect(); +} diff --git a/src/lang/gu.rs b/src/lang/gu.rs new file mode 100644 index 000000000..ac0a588a8 --- /dev/null +++ b/src/lang/gu.rs @@ -0,0 +1,749 @@ +lazy_static::lazy_static! { +pub static ref T: std::collections::HashMap<&'static str, &'static str> = + [ + ("Status", "સ્થિતિ"), + ("Your Desktop", "તમારું ડેસ્કટોપ"), + ("desk_tip", "તમારું ડેસ્કટોપ આ ID અને પાસવર્ડ દ્વારા એક્સેસ કરી શકાય છે."), + ("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", "ID બદલ્યા પછી વર્તમાન કનેક્શન તૂટી જશે."), + ("Website", "વેબસાઇટ"), + ("About", "વિશે"), + ("Slogan_tip", "વધુ સારા અનુભવ માટે બનાવેલ રિમોટ ડેસ્કટોપ સોફ્ટવેર"), + ("Privacy Statement", "ગોપનીયતા નિવેદન"), + ("Mute", "મ્યૂટ કરો"), + ("Build Date", "બિલ્ડ તારીખ"), + ("Version", "સંસ્કરણ (Version)"), + ("Home", "હોમ"), + ("Audio Input", "ઓડિયો ઇનપુટ"), + ("Enhancements", "વધારાની સુવિધાઓ"), + ("Hardware Codec", "હાર્ડવેર કોડેક"), + ("Adaptive bitrate", "એડેપ્ટિવ બિટરેટ"), + ("ID Server", "ID સર્વર"), + ("Relay Server", "રિલે સર્વર"), + ("API Server", "API સર્વર"), + ("invalid_http", "અમાન્ય HTTP લિંક"), + ("Invalid IP", "અમાન્ય IP"), + ("Invalid format", "અમાન્ય ફોર્મેટ"), + ("server_not_support", "સર્વર દ્વારા સમર્થિત નથી"), + ("Not available", "ઉપલબ્ધ નથી"), + ("Too frequent", "ખૂબ વારંવાર"), + ("Cancel", "રદ કરો"), + ("Skip", "રહેવા દો (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", "કદ (Size)"), + ("Show Hidden Files", "છુપાયેલી ફાઇલો બતાવો"), + ("Receive", "મેળવો"), + ("Send", "મોકલો"), + ("Refresh File", "ફાઇલ રિફ્રેશ કરો"), + ("Local", "લોકલ"), + ("Remote", "રિમોટ"), + ("Remote Computer", "રિમોટ કોમ્પ્યુટર"), + ("Local Computer", "લોકલ કોમ્પ્યુટર"), + ("Confirm Delete", "કાઢી નાખવાની પુષ્ટિ કરો"), + ("Delete", "કાઢી નાખો"), + ("Properties", "ગુણધર્મો (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", "મૂળ (Original)"), + ("Shrink", "સંકોચો (Shrink)"), + ("Stretch", "ખેંચો (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", "Rendezvous સર્વર સાથે જોડવામાં નિષ્ફળ"), + ("Please try later", "કૃપા કરીને પછી પ્રયાસ કરો"), + ("Remote desktop is offline", "રિમોટ ડેસ્કટોપ ઓફલાઇન છે"), + ("Key mismatch", "કી મેળ ખાતી નથી"), + ("Timeout", "સમય સમાપ્ત"), + ("Failed to connect to relay server", "રિલે સર્વર સાથે જોડવામાં નિષ્ફળ"), + ("Failed to connect via rendezvous server", "Rendezvous સર્વર દ્વારા જોડવામાં નિષ્ફળ"), + ("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.", "તમારું ઇન્સ્ટોલેશન જૂનું સંસ્કરણ છે."), + ("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", "રિમોટ ID દાખલ કરો"), + ("Enter your password", "તમારો પાસવર્ડ દાખલ કરો"), + ("Logging in...", "લોગિન થઈ રહ્યું છે..."), + ("Enable RDP session sharing", "RDP સત્ર શેરિંગ સક્ષમ કરો"), + ("Auto Login", "ઓટો લોગિન"), + ("Enable direct IP access", "સીધું IP એક્સેસ સક્ષમ કરો"), + ("Rename", "નામ બદલો"), + ("Space", "જગ્યા (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", "માત્ર વ્હાઇટલિસ્ટ કરેલ IP જ મને એક્સેસ કરી શકે છે"), + ("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", "Socks5 પ્રોક્સી"), + ("Socks5/Http(s) Proxy", "Socks5/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", "કૃપા કરીને સેટિંગ્સમાં RustDesk શોધો અને તેને ચાલુ કરો."), + ("android_new_connection_tip", "નવો કંટ્રોલ વિનંતી પ્રાપ્ત થઈ છે."), + ("android_service_will_start_tip", "સ્ક્રીન કેપ્ચર ચાલુ કરવાથી સેવા આપમેળે શરૂ થશે."), + ("android_stop_service_tip", "સેવા બંધ કરવાથી તમામ કનેક્શન બંધ થઈ જશે."), + ("android_version_audio_tip", "ઓડિયો કેપ્ચર માત્ર Android 10 કે તેથી ઉપરના વર્ઝનમાં ઉપલબ્ધ છે."), + ("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", "રેશિયો (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 માટે ઉચ્ચ Linux વર્ઝન જરૂરી છે"), + ("xdp-portal-unavailable", "XDP પોર્ટલ અનુપલબ્ધ છે"), + ("JumpLink", "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", "વન-ટાઇમ પાસવર્ડ (OTP)"), + ("Use one-time password", "વન-ટાઇમ પાસવર્ડનો ઉપયોગ કરો"), + ("One-time password length", "OTP ની લંબાઈ"), + ("Request access to your device", "તમારા ઉપકરણના એક્સેસ માટે વિનંતી"), + ("Hide connection management window", "કનેક્શન મેનેજમેન્ટ વિન્ડો છુપાવો"), + ("hide_cm_tip", "જો પાસવર્ડ દ્વારા કનેક્શન હોય તો જ છુપાવો"), + ("wayland_experiment_tip", "Wayland સપોર્ટ હજુ પ્રાયોગિક ધોરણે છે"), + ("Right click to select tabs", "ટેબ્સ પસંદ કરવા રાઇટ ક્લિક કરો"), + ("Skipped", "રહેવા દીધું (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", "હંમેશા સોફ્ટવેર રેન્ડરિંગનો ઉપયોગ કરો"), + ("config_input", "ઇનપુટ કોન્ફિગર કરો"), + ("config_microphone", "માઇક્રોફોન કોન્ફિગર કરો"), + ("request_elevation_tip", "સામેથી ઉચ્ચ પરવાનગી (Elevation) માટે વિનંતી કરો"), + ("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", "રિમોટ યુઝરે હજુ પણ UAC વિન્ડોમાં 'હા' ક્લિક કરવું પડશે."), + ("Request Elevation", "એલિવેશન માટે વિનંતી કરો"), + ("wait_accept_uac_tip", "કૃપા કરીને સામેનો યુઝર UAC સ્વીકારે તેની રાહ જુઓ."), + ("Elevate successfully", "સફળતાપૂર્વક એલિવેટ થયું"), + ("uppercase", "મોટા અક્ષરો (Uppercase)"), + ("lowercase", "નાના અક્ષરો (Lowercase)"), + ("digit", "અંક (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", "FPS"), + ("Auto", "ઓટો"), + ("Other Default Options", "અન્ય ડિફોલ્ટ વિકલ્પો"), + ("Voice call", "વોઇસ કોલ"), + ("Text chat", "ટેક્સ્ટ ચેટ"), + ("Stop voice call", "વોઇસ કોલ બંધ કરો"), + ("relay_hint_tip", "સીધું કનેક્શન શક્ય નથી; તમે રિલે દ્વારા પ્રયાસ કરી શકો છો."), + ("Reconnect", "ફરી કનેક્ટ કરો"), + ("Codec", "કોડેક"), + ("Resolution", "રિઝોલ્યુશન"), + ("No transfers in progress", "કોઈ ટ્રાન્સફર ચાલુ નથી"), + ("Set one-time password length", "OTP લંબાઈ સેટ કરો"), + ("RDP Settings", "RDP સેટિંગ્સ"), + ("Sort by", "ક્રમબદ્ધ કરો"), + ("New Connection", "નવું કનેક્શન"), + ("Restore", "રીસ્ટોર"), + ("Minimize", "મિનિમાઇઝ"), + ("Maximize", "મેક્સિમાઇઝ"), + ("Your Device", "તમારું ઉપકરણ"), + ("empty_recent_tip", "તાજેતરના સત્રો અહીં દેખાશે."), + ("empty_favorite_tip", "પસંદગીના ઉપકરણો અહીં દેખાશે."), + ("empty_lan_tip", "નેટવર્ક પરના ઉપકરણો અહીં દેખાશે."), + ("empty_address_book_tip", "તમારી એડ્રેસ બુક ખાલી છે."), + ("Empty Username", "ખાલી યુઝરનેમ"), + ("Empty Password", "ખાલી પાસવર્ડ"), + ("Me", "હું"), + ("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", "ડિફોલ્ટ"), + ("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", "કનેક્શન સ્વીકારો અને UAC પરવાનગીઓ મેળવો."), + ("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", "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", "જો તમે અજાણી વ્યક્તિ સાથે વાત કરી રહ્યા હો અને તેણે RustDesk વાપરવા કહ્યું હોય, તો તરત ડિસ્કનેક્ટ કરો."), + ("scam_text2", "આ એક છેતરપિંડી હોઈ શકે છે. કોઈને પાસવર્ડ આપશો નહીં."), + ("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", "ગ્રુપ ખેંચવામાં (Pull) નિષ્ફળ"), + ("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", "SELinux ઉપકરણ પર સક્ષમ છે."), + ("Change view", "વ્યુ બદલો"), + ("Big tiles", "મોટી ટાઇલ્સ"), + ("Small tiles", "નાની ટાઇલ્સ"), + ("List", "લિસ્ટ"), + ("Virtual display", "વર્ચ્યુઅલ ડિસ્પ્લે"), + ("Plug out all", "બધું કાઢી નાખો (Plug out)"), + ("True color (4:4:4)", "ટ્રુ કલર (4:4:4)"), + ("Enable blocking user input", "યુઝર ઇનપુટ બ્લોકિંગ સક્ષમ કરો"), + ("id_input_tip", "તમે ID, Alias અથવા IP એડ્રેસ દાખલ કરી શકો છો."), + ("privacy_mode_impl_mag_tip", "મેગ્નિફાયર પ્રાઇવસી મોડ"), + ("privacy_mode_impl_virtual_display_tip", "વર્ચ્યુઅલ ડિસ્પ્લે પ્રાઇવસી મોડ"), + ("Enter privacy mode", "પ્રાઇવસી મોડમાં પ્રવેશ કરો"), + ("Exit privacy mode", "પ્રાઇવસી મોડમાંથી બહાર નીકળો"), + ("idd_not_support_under_win10_2004_tip", "વર્ચ્યુઅલ ડિસ્પ્લે Windows 10 (2004) કે તેથી ઉપર જ સક્ષમ છે."), + ("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", "તમારું ઓથેન્ટિકેટર એપ સેટ કરો."), + ("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", "બહુવિધ Windows સત્રો મળ્યા"), + ("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", "માત્ર RustDesk વિન્ડો ખુલ્લી હોય ત્યારે જ કનેક્શનની મંજૂરી આપો"), + ("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", "બેકગ્રાઉન્ડમાં હોય ત્યારે RustDesk બતાવો"), + ("Keep screen on", "સ્ક્રીન ચાલુ રાખો"), + ("Never", "ક્યારેય નહીં"), + ("During controlled", "નિયંત્રણ દરમિયાન"), + ("During service is on", "જ્યારે સેવા ચાલુ હોય ત્યારે"), + ("Capture screen using DirectX", "DirectX દ્વારા સ્ક્રીન કેપ્ચર કરો"), + ("Back", "પાછળ"), + ("Apps", "એપ્સ"), + ("Volume up", "અવાજ વધારો"), + ("Volume down", "અવાજ ઘટાડો"), + ("Power", "પાવર"), + ("Telegram bot", "Telegram બોટ"), + ("enable-bot-tip", "સૂચનાઓ માટે બોટ સક્ષમ કરો"), + ("enable-bot-desc", "સૂચનાઓ માટે ટેલિગ્રામ બોટ સેટ કરો."), + ("cancel-2fa-confirm-tip", "શું તમે 2FA રદ કરવા માંગો છો?"), + ("cancel-bot-confirm-tip", "શું તમે બોટ રદ કરવા માંગો છો?"), + ("About RustDesk", "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", "રિમોટ ID દાખલ કરો"), + ("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", "D3D રેન્ડરિંગ વાપરો"), + ("Use D3D rendering", ""), + ("Printer", "પ્રિન્ટર"), + ("printer-os-requirement-tip", "પ્રિન્ટિંગ માટે Windows જરૂરી છે."), + ("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", "અપડેટ નિષ્ફળ, MSI ફાઇલ તપાસો."), + ("websocket_tip", "જો પોર્ટ બ્લોક હોય તો WebSocket વાપરો."), + ("Use WebSocket", "WebSocket નો ઉપયોગ કરો"), + ("Trackpad speed", "ટ્રેકપેડ સ્પીડ"), + ("Default trackpad speed", "ડિફોલ્ટ ટ્રેકપેડ સ્પીડ"), + ("Numeric one-time password", "ન્યુમેરિક OTP"), + ("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", "Alias (ઉપનામ)"), + ("ScrollEdge", "સ્ક્રોલ એજ"), + ("Allow insecure TLS fallback", "અસુરક્ષિત TLS ફોલબેકની મંજૂરી આપો"), + ("allow-insecure-tls-fallback-tip", "જૂના સર્વર માટે વાપરો."), + ("Disable UDP", "UDP અક્ષમ કરો"), + ("disable-udp-tip", "કનેક્શન સમસ્યાઓ માટે UDP બંધ કરો."), + ("server-oss-not-support-tip", "OSS સર્વર આને સપોર્ટ કરતું નથી."), + ("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", "Changelog (ફેરફારો)"), + ("keep-awake-during-outgoing-sessions-label", "આઉટગોઇંગ સત્ર વખતે જાગૃત રાખો"), + ("keep-awake-during-incoming-sessions-label", "ઇનકમિંગ સત્ર વખતે જાગૃત રાખો"), + ("Continue with {}", "{} સાથે આગળ વધો"), + ("Display Name", "ડિસ્પ્લે નામ"), + ("password-hidden-tip", "સુરક્ષા માટે પાસવર્ડ છુપાવેલ છે."), + ("preset-password-in-use-tip", "પ્રીસેટ પાસવર્ડ વપરાશમાં છે."), + ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), + ].iter().cloned().collect(); +} diff --git a/src/lang/he.rs b/src/lang/he.rs index b63d42122..44b940784 100644 --- a/src/lang/he.rs +++ b/src/lang/he.rs @@ -1,254 +1,252 @@ lazy_static::lazy_static! { pub static ref T: std::collections::HashMap<&'static str, &'static str> = [ - ("Status", ""), - ("Your Desktop", ""), + ("Status", "מצב"), + ("Your Desktop", "שולחן העבודה שלך"), ("desk_tip", "ניתן לגשת לשולחן העבודה שלך עם מזהה וסיסמה זו."), - ("Password", ""), - ("Ready", ""), - ("Established", ""), + ("Password", "סיסמה"), + ("Ready", "מוכן"), + ("Established", "מחובר"), ("connecting_status", "מתחבר לרשת RustDesk..."), - ("Enable service", ""), - ("Start service", ""), - ("Service is running", ""), - ("Service is not running", ""), + ("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", ""), - ("Remove", ""), - ("Refresh random password", ""), - ("Set your own password", ""), - ("Enable keyboard/mouse", ""), - ("Enable clipboard", ""), - ("Enable file transfer", ""), - ("Enable TCP tunneling", ""), - ("IP Whitelisting", ""), - ("ID/Relay Server", "שרת מזהה/ריליי"), - ("Import server config", ""), - ("Export Server Config", ""), - ("Import server configuration successfully", ""), - ("Export server configuration successfully", ""), - ("Invalid server configuration", ""), - ("Clipboard is empty", ""), - ("Stop service", ""), - ("Change ID", ""), - ("Your new ID", ""), - ("length %min% to %max%", ""), - ("starts with a letter", ""), - ("allowed characters", ""), - ("id_change_tip", "מותרים רק תווים a-z, A-Z, 0-9 ו_ (קו תחתון). האות הראשונה חייבת להיות a-z, A-Z. אורך בין 6 ל-16."), - ("Website", ""), - ("About", ""), - ("Slogan_tip", "נוצר בלב בעולם הזה הכאוטי!"), - ("Privacy Statement", ""), - ("Mute", ""), + ("Control Remote Desktop", "שלוט בשולחן עבודה מרוחק"), + ("Transfer file", "העבר קובץ"), + ("Connect", "התחבר"), + ("Recent sessions", "הפעלות אחרונות"), + ("Address book", "ספר כתובות"), + ("Confirmation", "אישור"), + ("TCP tunneling", "TCP tunneling"), + ("Remove", "הסר"), + ("Refresh random password", "רענן סיסמה אקראית"), + ("Set your own password", "הגדר סיסמה משלך"), + ("Enable keyboard/mouse", "אפשר מקלדת/עכבר"), + ("Enable clipboard", "אפשר לוח גזירים"), + ("Enable file transfer", "אפשר העברת קבצים"), + ("Enable TCP tunneling", "אפשר TCP tunneling"), + ("IP Whitelisting", "רשימת IP מורשים"), + ("ID/Relay Server", "שרת ID/Relay"), + ("Import server config", "ייבוא הגדרות שרת"), + ("Export Server Config", "ייצוא הגדרות שרת"), + ("Import server configuration successfully", "ייבוא הגדרות שרת הושלם בהצלחה"), + ("Export server configuration successfully", "ייצוא הגדרות שרת הושלם בהצלחה"), + ("Invalid server configuration", "הגדרות שרת לא תקינות"), + ("Clipboard is empty", "לוח הגזירים ריק"), + ("Stop service", "עצור שירות"), + ("Change ID", "שנה מזהה"), + ("Your new ID", "המזהה החדש שלך"), + ("length %min% to %max%", "אורך בין %min% ל %max%"), + ("starts with a letter", "מתחיל באות"), + ("allowed characters", "תווים מותרים"), + ("id_change_tip", "מותרים רק תווים a-z, A-Z, 0-9, מקף (-) וקו תחתון (_). התו הראשון חייב להיות אות (a-z, A-Z). אורך בין 6 ל-16 תווים."), + ("Website", "דף הבית"), + ("About", "אודות"), + ("Slogan_tip", "נוצר באהבה בעולם הכאוטי הזה!"), + ("Privacy Statement", "הצהרת פרטיות"), + ("Mute", "השתק"), ("Build Date", "תאריך בנייה"), - ("Version", ""), - ("Home", ""), + ("Version", "גרסה"), + ("Home", "בית"), ("Audio Input", "קלט שמע"), - ("Enhancements", ""), - ("Hardware Codec", "קודק חומרה"), - ("Adaptive bitrate", ""), - ("ID Server", "שרת מזהה"), - ("Relay Server", "שרת ריליי"), + ("Enhancements", "שיפורים"), + ("Hardware Codec", "Hardware Codec"), + ("Adaptive bitrate", "Adaptive bitrate"), + ("ID Server", "שרת ID"), + ("Relay Server", "שרת Relay"), ("API Server", "שרת API"), ("invalid_http", "חייב להתחיל עם http:// או https://"), - ("Invalid IP", ""), - ("Invalid format", ""), - ("server_not_support", "עדיין לא נתמך על ידי השרת"), - ("Not available", ""), - ("Too frequent", ""), - ("Cancel", ""), - ("Skip", ""), - ("Close", ""), - ("Retry", ""), - ("OK", ""), + ("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", ""), + ("Please enter your password", "אנא הכנס סיסמה"), + ("Remember password", "זכור סיסמה"), ("Wrong Password", "סיסמה שגויה"), - ("Do you want to enter again?", ""), + ("Do you want to enter again?", "האם אתה רוצה לנסות שוב?"), ("Connection Error", "שגיאת חיבור"), - ("Error", ""), - ("Reset by the peer", ""), - ("Connecting...", ""), - ("Connection in progress. Please wait.", ""), - ("Please try 1 minute later", ""), + ("Error", "שגיאה"), + ("Reset by the peer", "אופס על-ידי הצד השני"), + ("Connecting...", "מתחבר..."), + ("Connection in progress. Please wait.", "מתחבר. אנא המתן."), + ("Please try 1 minute later", "אנא המתן דקה ונסה שוב"), ("Login Error", "שגיאת התחברות"), - ("Successful", ""), - ("Connected, waiting for image...", ""), - ("Name", ""), - ("Type", ""), - ("Modified", ""), - ("Size", ""), - ("Show Hidden Files", "הצג קבצים נסתרים"), - ("Receive", ""), - ("Send", ""), + ("Successful", "הצלחה"), + ("Connected, waiting for image...", "מחובר, מחכה לתמונה..."), + ("Name", "שם"), + ("Type", "סוג"), + ("Modified", "שונה"), + ("Size", "גודל"), + ("Show Hidden Files", "הצג קבצים מוסתרים"), + ("Receive", "קבל"), + ("Send", "שלח"), ("Refresh File", "רענן קובץ"), - ("Local", ""), - ("Remote", ""), + ("Local", "מקומי"), + ("Remote", "מרוחק"), ("Remote Computer", "מחשב מרוחק"), ("Local Computer", "מחשב מקומי"), ("Confirm Delete", "אשר מחיקה"), - ("Delete", ""), - ("Properties", ""), + ("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", ""), + ("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", ""), + ("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", ""), + ("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 + Delete"), ("Insert Lock", "הוסף נעילה"), - ("Refresh", ""), - ("ID does not exist", ""), - ("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", ""), + ("Refresh", "רענן"), + ("ID does not exist", "מזהה אינו קיים"), + ("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", "סיסמת מערכת הפעלה"), ("install_tip", "בגלל UAC, RustDesk לא יכול לפעול כראוי כצד מרוחק בחלק מהמקרים. כדי להימנע מ-UAC, אנא לחץ על הכפתור למטה כדי להתקין את RustDesk במערכת."), - ("Click to upgrade", ""), - ("Click to download", ""), - ("Click to update", ""), - ("Configure", ""), + ("Click to upgrade", "לחץ כדי לשדרג"), + ("Configure", "הגדר"), ("config_acc", "כדי לשלוט מרחוק בשולחן העבודה שלך, עליך להעניק ל-RustDesk הרשאות \"נגישות\"."), ("config_screen", "כדי לגשת מרחוק לשולחן העבודה שלך, עליך להעניק ל-RustDesk הרשאות \"הקלטת מסך\"."), - ("Installing ...", ""), - ("Install", ""), - ("Installation", ""), + ("Installing ...", "מתקין ..."), + ("Install", "התקן"), + ("Installation", "התקנה"), ("Installation Path", "נתיב התקנה"), - ("Create start menu shortcuts", ""), - ("Create desktop icon", ""), + ("Create start menu shortcuts", "צור קיצור-דרך לתפריט ההתחלה"), + ("Create desktop icon", "צור סמל בשולחן העבודה"), ("agreement_tip", "על ידי התחלת ההתקנה, אתה מקבל את הסכם הרישיון."), ("Accept and Install", "קבל והתקן"), - ("End-user license agreement", ""), - ("Generating ...", ""), - ("Your installation is lower version.", ""), - ("not_close_tcp_tip", "אל תסגור חלון זה בזמן שאתה משתמש במנהרה"), - ("Listening ...", ""), + ("End-user license agreement", "הסכם רישיון משתמש קצה"), + ("Generating ...", "יוצר ..."), + ("Your installation is lower version.", "מותקנת אצלך בגרסה ישנה יותר"), + ("not_close_tcp_tip", "אל תסגור חלון זה בזמן שאתה משתמש בtcp"), + ("Listening ...", "מאזין ..."), ("Remote Host", "מארח מרוחק"), ("Remote Port", "פורט מרוחק"), - ("Action", ""), - ("Add", ""), + ("Action", "פעולה"), + ("Add", "הוסף"), ("Local Port", "פורט מקומי"), ("Local Address", "כתובת מקומית"), ("Change Local Port", "שנה פורט מקומי"), - ("setup_server_tip", "לחיבור מהיר יותר, אנא הגדר שרת משלך"), - ("Too short, at least 6 characters.", ""), - ("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", ""), + ("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", ""), + ("Enter your password", "הכנס סיסמה"), + ("Logging in...", "מתחבר..."), + ("Enable RDP session sharing", "אפשר שיתוף סשן RDP"), ("Auto Login", "התחברות אוטומטית (תקפה רק אם הגדרת \"נעל לאחר סיום הסשן\")"), - ("Enable direct IP access", ""), - ("Rename", ""), - ("Space", ""), - ("Create desktop shortcut", ""), + ("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", ""), - ("Reboot required", ""), - ("Unsupported display server", ""), - ("x11 expected", ""), - ("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", "רק IP ברשימה הלבנה יכול לגשת אלי"), - ("Login", ""), - ("Verify", ""), - ("Remember me", ""), - ("Trust this device", ""), - ("Verification code", ""), - ("verification_tip", "קוד אימות נשלח לכתובת הדוא\"ל הרשומה, הזן את קוד האימות כדי להמשיך בהתחברות."), - ("Logout", ""), - ("Tags", ""), - ("Search ID", ""), + ("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", "רק כתובות IP מהרשימה הלבנה יכולות לגשת אלי"), + ("Login", "התחברות"), + ("Verify", "אמת"), + ("Remember me", "זכור אותי"), + ("Trust this device", "סמוך על מכשיר זה"), + ("Verification code", "קוד אימות"), + ("verification_tip", "קוד אימות נשלח לכתובת האימייל הרשומה, הזן את קוד האימות כדי להמשיך בהתחברות."), + ("Logout", "התנתק"), + ("Tags", "תגים"), + ("Search ID", "חפש מזהה"), ("whitelist_sep", "מופרד על ידי פסיק, נקודה פסיק, רווחים או שורה חדשה"), - ("Add ID", ""), + ("Add ID", "הוסף מזהה"), ("Add Tag", "הוסף תג"), - ("Unselect all tags", ""), - ("Network error", ""), - ("Username missed", ""), - ("Password missed", ""), - ("Wrong credentials", "שם משתמש או סיסמה שגויים"), - ("The verification code is incorrect or has expired", ""), + ("Unselect all tags", "בטל בחירת כל התגים"), + ("Network error", "שגיאת רשת"), + ("Username missed", "חסר שם משתמש"), + ("Password missed", "חסרה סיסמה"), + ("Wrong credentials", "פרטי התחברות שגויים"), + ("The verification code is incorrect or has expired", "קוד האימות שגוי או שפג תוקפו"), ("Edit Tag", "ערוך תג"), ("Forget Password", "שכחת סיסמה"), - ("Favorites", ""), + ("Favorites", "מועדפים"), ("Add to Favorites", "הוסף למועדפים"), ("Remove from Favorites", "הסר מהמועדפים"), - ("Empty", ""), - ("Invalid folder name", ""), + ("Empty", "ריק"), + ("Invalid folder name", "שם תיקייה אינו תקין"), ("Socks5 Proxy", "פרוקסי Socks5"), ("Socks5/Http(s) Proxy", "פרוקסי Socks5/Http(s)"), - ("Discovered", ""), + ("Discovered", "נמצא"), ("install_daemon_tip", "לצורך הפעלה בעת הפעלת המחשב, עליך להתקין שירות מערכת."), - ("Remote ID", ""), - ("Paste", ""), - ("Paste here?", ""), + ("Remote ID", "מזהה מרוחק"), + ("Paste", "הדבק"), + ("Paste here?", "להדביק כאן?"), ("Are you sure to close the connection?", "האם אתה בטוח שברצונך לסגור את החיבור?"), - ("Download new version", ""), - ("Touch mode", ""), - ("Mouse mode", ""), + ("Download new version", "הורד גרסה חדשה"), + ("Touch mode", "מצב מגע"), + ("Mouse mode", "מצב עכבר"), ("One-Finger Tap", "הקשה באצבע אחת"), ("Left Mouse", "עכבר שמאלי"), ("One-Long Tap", "הקשה ארוכה באצבע אחת"), @@ -257,223 +255,220 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("One-Finger Move", "הזזה באצבע אחת"), ("Double Tap & Move", "הקשה כפולה והזזה"), ("Mouse Drag", "גרירת עכבר"), - ("Three-Finger vertically", "שלוש אצבעות אנכית"), + ("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", ""), + ("Reset canvas", "אפס לוח ציור"), + ("No permission of file transfer", "אין הרשאת העברת קבצים"), + ("Note", "הערה"), + ("Connection", "התחברות"), + ("Share screen", "שיתוף מסך"), + ("Chat", "צ'אט"), + ("Total", "הכל"), + ("items", "פריטים"), + ("Selected", "נבחר"), ("Screen Capture", "לכידת מסך"), ("Input Control", "בקרת קלט"), ("Audio Capture", "לכידת שמע"), - ("File Connection", "חיבור קובץ"), - ("Screen Connection", "חיבור מסך"), - ("Do you accept?", ""), - ("Open System Setting", "פתח הגדרת מערכת"), - ("How to get Android input permission?", ""), + ("Do you accept?", "האם אתה מקבל?"), + ("Open System Setting", "פתח הגדרות מערכת"), + ("How to get Android input permission?", "כיצד לקבל הרשאת קלט באנדרואיד?"), ("android_input_permission_tip1", "כדי שמכשיר מרוחק יוכל לשלוט במכשיר האנדרואיד שלך באמצעות עכבר או מגע, עליך לאפשר ל-RustDesk להשתמש בשירות \"נגישות\"."), - ("android_input_permission_tip2", "אנא עבור לדף ההגדרות של המערכת הבא, מצא והכנס ל[שירותים מותקנים], הפעל את שירות [RustDesk Input]."), - ("android_new_connection_tip", "בקשת שליטה חדשה התקבלה, שרוצה לשלוט במכשירך הנוכחי."), - ("android_service_will_start_tip", "הפעלת \"לכידת מסך\" תתחיל אוטומטית את השירות, מאפשרת למכשירים אחרים לבקש חיבור למכשיר שלך."), - ("android_stop_service_tip", "סגירת השירות תסגור אוטומטית את כל החיבורים המוקמים."), - ("android_version_audio_tip", "גרסת האנדרואיד הנוכחית אינה תומכת בלכידת שמע, אנא שדרג לאנדרואיד 10 או גבוה יותר."), + ("android_input_permission_tip2", "אנא עבור לדף הגדרות המערכת הבא, מצא והכנס ל[שירותים מותקנים], הפעל את שירות [RustDesk Input]."), + ("android_new_connection_tip", "התקבלה בקשת שליטה חדשה, המבקשת לשלוט במכשירך הנוכחי."), + ("android_service_will_start_tip", "הפעלת \"לכידת מסך\" תפעיל את השירות באופן אוטומטי ותאפשר למכשירים אחרים לבקש חיבור למכשירך."), + ("android_stop_service_tip", "סגירת השירות תנתק באופן אוטומטי את כל החיבורים הקיימים."), + ("android_version_audio_tip", "גרסת האנדרואיד הנוכחית אינה תומכת בלכידת שמע. אנא שדרג לאנדרואיד 10 ומעלה."), ("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", ""), + ("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", "אם ברצונך לבטל תכונה זו, אנא עבור לדף ההגדרות של יישום RustDesk הבא, מצא והכנס ל[סוללה], הסר את הסימון מ-[לא מוגבל]"), - ("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", ""), + ("android_open_battery_optimizations_tip", "אם ברצונך לבטל תכונה זו, אנא עבור לדף ההגדרות של יישום RustDesk , מצא והיכנס ל[סוללה], ובטל את הסימון מ-[לא מוגבל]"), + ("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", ""), + ("Fullscreen", "מסך מלא"), ("Mobile Actions", "פעולות ניידות"), ("Select Monitor", "בחר מסך"), ("Control Actions", "פעולות בקרה"), ("Display Settings", "הגדרות תצוגה"), - ("Ratio", ""), + ("Ratio", "יחס"), ("Image Quality", "איכות תמונה"), ("Scroll Style", "סגנון גלילה"), ("Show Toolbar", "הצג סרגל כלים"), ("Hide Toolbar", "הסתר סרגל כלים"), ("Direct Connection", "חיבור ישיר"), - ("Relay Connection", "חיבור ריליי"), + ("Relay Connection", "חיבור באמצעות ממסר"), ("Secure Connection", "חיבור מאובטח"), ("Insecure Connection", "חיבור לא מאובטח"), - ("Scale original", ""), - ("Scale adaptive", ""), - ("General", ""), - ("Security", ""), - ("Theme", ""), + ("Scale original", "קנה מידה מקורי"), + ("Scale adaptive", "קנה מידה מותאם"), + ("General", "כללי"), + ("Security", "אבטחה"), + ("Theme", "ערכת נושא"), ("Dark Theme", "ערכת נושא כהה"), ("Light Theme", "ערכת נושא בהירה"), - ("Dark", ""), - ("Light", ""), - ("Follow System", "עקוב אחר המערכת"), - ("Enable hardware codec", ""), + ("Dark", "כהה"), + ("Light", "בהיר"), + ("Follow System", "זהה למערכת"), + ("Enable hardware codec", "אפשר מקודד חומרה"), ("Unlock Security Settings", "פתח הגדרות אבטחה"), - ("Enable audio", ""), + ("Enable audio", "הפעל שמע"), ("Unlock Network Settings", "פתח הגדרות רשת"), - ("Server", ""), + ("Server", "שרת"), ("Direct IP Access", "גישה ישירה ל-IP"), - ("Proxy", ""), - ("Apply", ""), - ("Disconnect all devices?", ""), - ("Clear", ""), + ("Proxy", "פרוקסי"), + ("Apply", "החל"), + ("Disconnect all devices?", "נתק את כל המכשירים?"), + ("Clear", "נקה"), ("Audio Input Device", "מכשיר קלט שמע"), ("Use IP Whitelisting", "השתמש ברשימת לבנה של IP"), - ("Network", ""), + ("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", ""), - ("Deny LAN discovery", ""), - ("Write a message", ""), - ("Prompt", ""), - ("Please wait for confirmation of UAC...", ""), - ("elevated_foreground_window_tip", "החלון הנוכחי של שולחן העבודה המרוחק דורש הרשאה גבוהה יותר לפעולה, לכן אי אפשר להשתמש בעכבר ובמקלדת באופן זמני. תוכל לבקש מהמשתמש המרוחק למזער את החלון הנוכחי, או ללחוץ על כפתור ההגבהה בחלון ניהול החיבור. כדי להימנע מבעיה זו, מומלץ להתקין את התוכנה במכשיר המרוחק."), - ("Disconnected", ""), - ("Other", ""), - ("Confirm before closing multiple tabs", ""), + ("Recording", "הקלטה"), + ("Directory", "תיקיה"), + ("Automatically record incoming sessions", "הקלט הפעלות נכנסות באופן אוטומטי"), + ("Automatically record outgoing sessions", "הקלט הפעלות יוצאות באופן אוטומטי"), + ("Change", "שנה"), + ("Start session recording", "התחל הקלטת הפעלה"), + ("Stop session recording", "הפסק הקלטת הפעלה"), + ("Enable recording session", "אפשר הקלטת הפעלה"), + ("Enable LAN discovery", "אפשר זיהוי ברשת מקומית"), + ("Deny LAN discovery", "חסום זיהוי ברשת מקומית"), + ("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", "שיתוף מסך"), - ("Wayland requires Ubuntu 21.04 or higher version.", ""), - ("Wayland requires higher version of linux distro. Please try X11 desktop or change your OS.", ""), - ("JumpLink", "הצג"), + ("ubuntu-21-04-required", "Wayland דורש Ubuntu 21.04 או גרסה גבוהה יותר"), + ("wayland-requires-higher-linux-version", "Wayland דורש גרסת הפצת לינוקס גבוהה יותר. אנא נסה שולחן עבודה מסוג X11 או החלף מערכת הפעלה"), + ("xdp-portal-unavailable", ""), + ("JumpLink", "קישור מהיר"), ("Please Select the screen to be shared(Operate on the peer side).", "אנא בחר את המסך לשיתוף (פעולה בצד העמית)."), - ("Show RustDesk", ""), - ("This PC", ""), - ("or", ""), - ("Continue with", ""), - ("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...", ""), + ("Show RustDesk", "הצג את RustDesk"), + ("This 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", "אפשר הסתרה רק אם מקבלים סשנים דרך סיסמה ומשתמשים בסיסמה קבועה"), - ("wayland_experiment_tip", "תמיכה ב-Wayland נמצאת בשלב ניסיוני, אנא השתמש ב-X11 אם אתה זקוק לגישה לא מלווה."), - ("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", "אם אתה משתמש בכרטיס גרפיקה של Nvidia תחת Linux וחלון המרחוק נסגר מיד לאחר החיבור, החלפה למנהל ההתקן הפתוח Nouveau ובחירה בשימוש בעיבוד תוכנה עשויה לעזור. נדרשת הפעלה מחדש של התוכנה."), - ("Always use software rendering", ""), - ("config_input", "כדי לשלוט בשולחן העבודה המרוחק באמצעות מקלדת, עליך להעניק ל-RustDesk הרשאות \"מעקב אחרי קלט\"."), + ("Use one-time password", "השתמש בסיסמה חד-פעמית"), + ("One-time password length", "אורך סיסמה חד-פעמית"), + ("Request access to your device", "בקשת גישה למכשיר שלך"), + ("Hide connection management window", "הסתר חלון ניהול חיבורים"), + ("hide_cm_tip", "אפשר הסתרה רק אם מקבלים הפעלות דרך סיסמה ומשתמשים בסיסמה קבועה"), + ("wayland_experiment_tip", "תמיכה ב-Wayland נמצאת בשלב ניסיוני, אנא השתמש ב-X11 אם אתה זקוק לגישה ללא ליווי מהצד המרוחק"), + ("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", "אם אתה משתמש בכרטיס גרפיקה של Nvidia תחת Linux וחלון המרוחק נסגר מיד לאחר החיבור, החלפה למנהל ההתקן הפתוח Nouveau ובחירה בשימוש בעיבוד תוכנה עשויה לעזור. נדרשת הפעלה מחדש של התוכנה."), + ("Always use software rendering", "השתמש תמיד בעיבוד תוכנה"), + ("config_input", "כדי לשלוט בשולחן העבודה המרוחק באמצעות מקלדת, עליך להעניק ל-RustDesk הרשאות \"מעקב קלט\"."), ("config_microphone", "כדי לדבר מרחוק, עליך להעניק ל-RustDesk הרשאות \"הקלטת שמע\"."), - ("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", "עדיין דורש מהמשתמש המרוחק ללחוץ OK בחלון ה-UAC של הרצת RustDesk."), - ("Request Elevation", "בקש הגבהה"), - ("wait_accept_uac_tip", "אנא המתן למשתמש המרוחק לקבל את דיאלוג ה-UAC."), - ("Elevate successfully", ""), - ("uppercase", ""), - ("lowercase", ""), - ("digit", ""), - ("special character", ""), - ("length>=8", ""), - ("Weak", ""), - ("Medium", ""), - ("Strong", ""), + ("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", "עדיין נדרש מהמשתמש המרוחק לאשר את חלון ה-UAC של RustDesk"), + ("Request Elevation", "בקש העלאת הרשאות"), + ("wait_accept_uac_tip", "אנא המתן שהמשתמש המרוחק יאשר את חלון ה-UAC"), + ("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", ""), + ("Please confirm if you want to share your desktop?", "האם לשתף את שולחן העבודה שלך?"), + ("Display", "תצוגה"), ("Default View Style", "סגנון תצוגה ברירת מחדל"), ("Default Scroll Style", "סגנון גלילה ברירת מחדל"), ("Default Image Quality", "איכות תמונה ברירת מחדל"), ("Default Codec", "קודק ברירת מחדל"), - ("Bitrate", ""), - ("FPS", ""), - ("Auto", ""), + ("Bitrate", "קצב סיביות"), + ("FPS", "FPS"), + ("Auto", "אוטומטי"), ("Other Default Options", "אפשרויות ברירת מחדל אחרות"), - ("Voice call", ""), - ("Text chat", ""), - ("Stop voice call", ""), - ("relay_hint_tip", "ייתכן שלא ניתן להתחבר ישירות; ניתן לנסות להתחבר דרך ריליי. בנוסף, אם ברצונך להשתמש בריליי בניסיון הראשון שלך, תוכל להוסיף את הסיומת \"/r\" למזהה או לבחור באפשרות \"התחבר תמיד דרך ריליי\" בכרטיס של הסשנים האחרונים אם קיים."), - ("Reconnect", ""), - ("Codec", ""), - ("Resolution", ""), - ("No transfers in progress", ""), - ("Set one-time password length", ""), + ("Voice call", "שיחה קולית"), + ("Text chat", "שיחת טקסט"), + ("Stop voice call", "הפסק שיחה קולית"), + ("relay_hint_tip", "ייתכן שלא ניתן להתחבר ישירות. נסה להתחבר דרך ממסר. כדי להשתמש בממסר כבר מהניסיון הראשון, הוסף את הסיומת /r למזהה או בחר \"התחבר תמיד דרך ממסר\" בכרטיס ההפעלות האחרונות, אם קיים."), + ("Reconnect", "התחברות מחדש"), + ("Codec", "קודק"), + ("Resolution", "רזולוציה"), + ("No transfers in progress", "אין העברות בתהליך"), + ("Set one-time password length", "הגדר אורך סיסמה חד-פעמית"), ("RDP Settings", "הגדרות RDP"), - ("Sort by", ""), + ("Sort by", "מיין לפי"), ("New Connection", "חיבור חדש"), - ("Restore", ""), - ("Minimize", ""), - ("Maximize", ""), + ("Restore", "שחזור"), + ("Minimize", "מזער"), + ("Maximize", "הגדל"), ("Your Device", "המכשיר שלך"), - ("empty_recent_tip", "אופס, אין סשנים אחרונים!\nהגיע הזמן לתכנן חדש."), - ("empty_favorite_tip", "עדיין אין עמיתים מועדפים?\nבוא נמצא מישהו להתחבר אליו ונוסיף אותו למועדפים!"), + ("empty_recent_tip", "אופס, אין הפעלות אחרונות!\nהגיע הזמן להתחבר למישהו חדש."), + ("empty_favorite_tip", "עדיין אין עמיתים מועדפים?\nבא נמצא מישהו להתחבר אליו ונוסיף אותו למועדפים!"), ("empty_lan_tip", "אוי לא, נראה שעדיין לא גילינו עמיתים."), - ("empty_address_book_tip", "אוי ואבוי, נראה שכרגע אין עמיתים בספר הכתובות שלך."), - ("eg: admin", ""), + ("empty_address_book_tip", "אבוי, נראה שכרגע אין עמיתים בספר הכתובות שלך."), ("Empty Username", "שם משתמש ריק"), ("Empty Password", "סיסמה ריקה"), - ("Me", ""), - ("identical_file_tip", "קובץ זה זהה לקובץ של העמית."), + ("Me", "אני"), + ("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_desk_tip", "חשבון זה משמש להתחברות למערכת ההפעלה המרוחקת ולהפעלת שולחן עבודה במצב לא מקוון"), ("OS Account", "חשבון מערכת הפעלה"), ("another_user_login_title_tip", "משתמש אחר כבר התחבר"), ("another_user_login_text_tip", "נתק"), @@ -481,177 +476,274 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("xorg_not_found_text_tip", "אנא התקן Xorg"), ("no_desktop_title_tip", "אין שולחן עבודה זמין"), ("no_desktop_text_tip", "אנא התקן שולחן עבודה GNOME"), - ("No need to elevate", ""), + ("No need to elevate", "אין צורך בהעלאת הרשאות"), ("System Sound", "צליל מערכת"), - ("Default", ""), - ("New RDP", ""), - ("Fingerprint", ""), + ("Default", "ברירת מחדל"), + ("New RDP", "RDP חדש"), + ("Fingerprint", "טביעת אצבע"), ("Copy Fingerprint", "העתק טביעת אצבע"), ("no fingerprints", "אין טביעות אצבע"), - ("Select a peer", ""), - ("Select peers", ""), - ("Plugins", ""), - ("Uninstall", ""), - ("Update", ""), - ("Enable", ""), - ("Disable", ""), - ("Options", ""), + ("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", "קבל את החיבור והגבה הרשאות UAC."), + ("Collapse toolbar", "מזער סרגל כלים"), + ("Accept and Elevate", "אשר והפעל הרשאות מורחבות"), + ("accept_and_elevate_btn_tooltip", "קבל את החיבור והפעל הרשאות מורחבות (UAC)"), ("clipboard_wait_response_timeout_tip", "המתנה לתגובת העתקה הסתיימה בזמן."), - ("Incoming connection", ""), - ("Outgoing connection", ""), - ("Exit", ""), - ("Open", ""), + ("Incoming connection", "חיבור נכנס"), + ("Outgoing connection", "חיבור יוצא"), + ("Exit", "צא"), + ("Open", "פתח"), ("logout_tip", "האם אתה בטוח שברצונך להתנתק?"), - ("Service", ""), - ("Start", ""), - ("Stop", ""), + ("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", ""), + ("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", ""), + ("ID", "מזהה"), ("Grid View", "תצוגת רשת"), ("List View", "תצוגת רשימה"), - ("Select", ""), + ("Select", "בחר"), ("Toggle Tags", "החלף תגיות"), ("pull_ab_failed_tip", "נכשל ברענון ספר הכתובות"), - ("push_ab_failed_tip", "נכשל בסנכרון ספר הכתובות לשרת"), - ("synced_peer_readded_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", "ייתכן שאתה נפלת להונאה!"), + ("Installation failed!", "התקנה נכשלה!"), + ("Reverse mouse wheel", "הפוך כיוון גלגלת העכבר"), + ("{} sessions", "{} הפעלות"), + ("scam_title", "ייתכן שנפלת להונאה!"), ("scam_text1", "אם אתה בשיחת טלפון עם מישהו שאינך מכיר ואינך סומך עליו שביקש ממך להשתמש ב-RustDesk ולהתחיל את השירות, אל תמשיך ונתק מיד."), ("scam_text2", "סביר להניח שמדובר בהונאה שמנסה לגנוב ממך כסף או מידע פרטי אחר."), - ("Don't show again", ""), - ("I Agree", ""), - ("Decline", ""), - ("Timeout in minutes", ""), - ("auto_disconnect_option_tip", "סגור באופן אוטומטי סשנים נכנסים במקרה של חוסר פעילות של המשתמש"), + ("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", ""), + ("Check for software update on startup", "בדוק עדכונים עם ההפעלה"), ("upgrade_rustdesk_server_pro_to_{}_tip", "אנא שדרג את RustDesk Server Pro לגרסה {} או חדשה יותר!"), - ("pull_group_failed_tip", "נכשל ברענון קבוצה"), - ("Filter by intersection", ""), - ("Remove wallpaper during incoming sessions", ""), - ("Test", ""), + ("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", ""), + ("No displays", "אין מסכים"), + ("Open in new window", "פתח בחלון חדש"), + ("Show displays as individual windows", "הצג מסכים כחלונות נפרדים"), + ("Use all my displays for the remote session", "השתמש בכל המסכים שלי עבור ההפעלה המרוחקת"), ("selinux_tip", "SELinux מופעל במכשיר שלך, מה שעלול למנוע מ-RustDesk לפעול כראוי כצד הנשלט."), - ("Change view", ""), - ("Big tiles", ""), - ("Small tiles", ""), - ("List", ""), - ("Virtual display", ""), - ("Plug out all", ""), - ("True color (4:4:4)", ""), - ("Enable blocking user input", ""), + ("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", "ניתן להזין מזהה, IP ישיר, או דומיין עם פורט (:).\nאם ברצונך לגשת למכשיר בשרת אחר, אנא הוסף את כתובת השרת (@?key=), לדוגמה,\n9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=.\nאם ברצונך לגשת למכשיר בשרת ציבורי, אנא הזן \"@public\", המפתח אינו נדרש לשרת ציבורי"), ("privacy_mode_impl_mag_tip", "מצב 1"), ("privacy_mode_impl_virtual_display_tip", "מצב 2"), - ("Enter privacy mode", ""), - ("Exit privacy mode", ""), - ("idd_not_support_under_win10_2004_tip", "נהג התצוגה העקיף אינו נתמך. נדרשת גרסת Windows 10, גרסה 2004 או חדשה יותר."), + ("Enter privacy mode", "הכנס למצב פרטיות"), + ("Exit privacy mode", "צא ממצב פרטיות"), + ("idd_not_support_under_win10_2004_tip", "מנהל התצוגה העקיף אינו נתמך. נדרשת גרסת Windows 10, גרסה 2004 או חדשה יותר."), ("input_source_1_tip", "מקור קלט 1"), ("input_source_2_tip", "מקור קלט 2"), - ("Swap control-command key", ""), - ("swap-left-right-mouse", "החלף בין כפתור העכבר השמאלי לימני"), + ("Swap control-command key", "החלף בין המקשים Control ו־Command"), + ("swap-left-right-mouse", "החלף בין לחצן שמאלי וימני בעכבר"), ("2FA code", "קוד אימות דו-שלבי"), - ("More", ""), + ("More", "עוד"), ("enable-2fa-title", "הפעל אימות דו-שלבי"), ("enable-2fa-desc", "אנא הגדר כעת את האפליקציה שלך לאימות. תוכל להשתמש באפליקציית אימות כגון Authy, Microsoft או Google Authenticator בטלפון או במחשב שלך.\n\nסרוק את קוד ה-QR עם האפליקציה שלך והזן את הקוד שהאפליקציה מציגה כדי להפעיל את אימות הדו-שלבי."), - ("wrong-2fa-code", "לא ניתן לאמת את הקוד. בדוק שהקוד והגדרות הזמן המקומיות נכונות"), + ("wrong-2fa-code", "קוד שגוי. בדוק את הקוד ואת הגדרות השעה במכשיר"), ("enter-2fa-title", "אימות דו-שלבי"), - ("Email verification code must be 6 characters.", ""), - ("2FA code must be 6 digits.", ""), - ("Multiple Windows sessions found", ""), - ("Please select the session you want to connect to", ""), - ("powered_by_me", ""), + ("Email verification code must be 6 characters.", "קוד אימות במייל חייב להיות באורך של 6 תווים."), + ("2FA code must be 6 digits.", "קוד אימות דו-שלבי חייב להיות באורך של 6 מספרים."), + ("Multiple Windows sessions found", "נמצאו מספר הפעלות Windows"), + ("Please select the session you want to connect to", "אנא בחר את ההפעלה שברצונך להתחבר אליה"), + ("powered_by_me", "מופעל דרכי"), ("outgoing_only_desk_tip", "זוהי מהדורה מותאמת אישית.\nניתן להתחבר למכשירים אחרים, אך מכשירים אחרים לא יכולים להתחבר אליך."), - ("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", ""), - ("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", ""), - ("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", ""), - ("Requires at least {} characters", ""), - ("Wrong PIN", ""), - ("Set 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", ""), + ("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", "ברירת מחדל זו תשתמש בהגדרות הproxy הכלליות של המערכת"), + ("no_audio_input_device_tip", "לא נמצא מכשיר קלט שמע"), + ("Incoming", "נכנס"), + ("Outgoing", "יוצא"), + ("Clear Wayland screen selection", "נקה את בחירת המסך ב־Wayland"), + ("clear_Wayland_screen_selection_tip", "נקה את בחירת המסך שנשמרה בעת שימוש ב־Wayland"), + ("confirm_clear_Wayland_screen_selection_tip", "האם אתה בטוח שברצונך לנקות את בחירת המסך עבור Wayland?"), + ("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", "אודות 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", "אנא שדרג את לקוח RustDesk לגרסה {} או חדשה יותר בצד המרוחק!"), + ("d3d_render_tip", "שימוש בעיבוד Direct3D עשוי לשפר ביצועים בחלק מהמקרים"), + ("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", "העדכון נכשל – בדוק אם קובץ ה־MSI מותקן או הוסר"), + ("websocket_tip", "אפשר שימוש בפרוטוקול WebSocket לחיבורים"), + ("Use WebSocket", "השתמש ב־WebSocket"), + ("Trackpad speed", "מהירות משטח מגע"), + ("Default trackpad speed", "מהירות ברירת מחדל של משטח מגע"), + ("Numeric one-time password", ""), + ("Enable IPv6 P2P connection", ""), + ("Enable UDP hole punching", ""), + ("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", ""), + ("allow-insecure-tls-fallback-tip", ""), + ("Disable 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 {}", "המשך עם {}"), + ("Display Name", ""), + ("password-hidden-tip", ""), + ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/hi.rs b/src/lang/hi.rs new file mode 100644 index 000000000..904d43118 --- /dev/null +++ b/src/lang/hi.rs @@ -0,0 +1,749 @@ +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", "यादृच्छिक (Random) पासवर्ड बदलें"), + ("Set your own password", "अपना पासवर्ड सेट करें"), + ("Enable keyboard/mouse", "कीबोर्ड/माउस सक्षम करें"), + ("Enable clipboard", "क्लिपबोर्ड सक्षम करें"), + ("Enable file transfer", "फ़ाइल स्थानांतरण सक्षम करें"), + ("Enable TCP tunneling", "TCP टनलिंग सक्षम करें"), + ("IP Whitelisting", "IP श्वेतसूची (Whitelisting)"), + ("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", "ID बदलने के बाद वर्तमान कनेक्शन टूट जाएगा।"), + ("Website", "वेबसाइट"), + ("About", "के बारे में"), + ("Slogan_tip", "बेहतर अनुभव के लिए बनाया गया रिमोट डेस्कटॉप सॉफ़्टवेयर"), + ("Privacy Statement", "गोपनीयता कथन"), + ("Mute", "म्यूट करें"), + ("Build Date", "निर्माण तिथि"), + ("Version", "संस्करण"), + ("Home", "होम"), + ("Audio Input", "ऑडियो इनपुट"), + ("Enhancements", "वृद्धि (Enhancements)"), + ("Hardware Codec", "हार्डवेयर कोडेक"), + ("Adaptive bitrate", "अनुकूली (Adaptive) बिटरेट"), + ("ID Server", "ID सर्वर"), + ("Relay Server", "रिले सर्वर"), + ("API Server", "API सर्वर"), + ("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", "स्थानीय (Local)"), + ("Remote", "रिमोट"), + ("Remote Computer", "रिमोट कंप्यूटर"), + ("Local Computer", "स्थानीय कंप्यूटर"), + ("Confirm Delete", "हटाने की पुष्टि करें"), + ("Delete", "हटाएं"), + ("Properties", "गुण (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", "मूल (Original)"), + ("Shrink", "सिकुड़ें"), + ("Stretch", "खिंचाव (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", "Rendezvous सर्वर से जुड़ने में विफल"), + ("Please try later", "कृपया बाद में प्रयास करें"), + ("Remote desktop is offline", "रिमोट डेस्कटॉप ऑफ़लाइन है"), + ("Key mismatch", "कुंजी बेमेल (Key mismatch)"), + ("Timeout", "समय समाप्त"), + ("Failed to connect to relay server", "रिले सर्वर से जुड़ने में विफल"), + ("Failed to connect via rendezvous server", "Rendezvous सर्वर के माध्यम से जुड़ने में विफल"), + ("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.", "आपका वर्तमान इंस्टॉलेशन पुराना संस्करण है।"), + ("not_close_tcp_tip", "टनल का उपयोग करते समय इस विंडो को बंद न करें।"), + ("Listening ...", "सुन रहा है (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", "रिमोट ID दर्ज करें"), + ("Enter your password", "अपना पासवर्ड दर्ज करें"), + ("Logging in...", "लॉग इन हो रहा है..."), + ("Enable RDP session sharing", "RDP सत्र साझाकरण सक्षम करें"), + ("Auto Login", "ऑटो लॉगिन"), + ("Enable direct IP access", "सीधी IP पहुंच सक्षम करें"), + ("Rename", "नाम बदलें"), + ("Space", "स्थान (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", "केवल श्वेतसूचीबद्ध IP ही मुझ तक पहुंच सकते हैं"), + ("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", "Socks5 प्रॉक्सी"), + ("Socks5/Http(s) Proxy", "Socks5/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", "कृपया सिस्टम सेटिंग में RustDesk खोजें और इसे चालू करें।"), + ("android_new_connection_tip", "एक नया नियंत्रण अनुरोध प्राप्त हुआ है।"), + ("android_service_will_start_tip", "स्क्रीन कैप्चर चालू करने से सेवा अपने आप शुरू हो जाएगी।"), + ("android_stop_service_tip", "सेवा बंद करने से सभी कनेक्शन टूट जाएंगे।"), + ("android_version_audio_tip", "ऑडियो कैप्चर केवल Android 10 या उच्चतर पर समर्थित है।"), + ("android_start_service_tip", "स्क्रीन शेयरिंग सेवा शुरू करने के लिए क्लिक करें।"), + ("android_permission_may_not_change_tip", "अनुमतियाँ बाद में नहीं बदली जा सकती हैं, कृपया ध्यान से चुनें।"), + ("Account", "खाता"), + ("Overwrite", "ओवरराइट (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", "लेगेसी (Legacy) मोड"), + ("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", "अनुपात (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", "पूर्ण पहुंच (Full Access)"), + ("Screen Share", "स्क्रीन शेयर"), + ("ubuntu-21-04-required", "Ubuntu 21.04 या उच्चतर आवश्यक है"), + ("wayland-requires-higher-linux-version", "Wayland के लिए उच्च Linux संस्करण आवश्यक है"), + ("xdp-portal-unavailable", "XDP पोर्टल अनुपलब्ध है"), + ("JumpLink", "JumpLink"), + ("Please Select the screen to be shared(Operate on the peer side).", "कृपया साझा की जाने वाली स्क्रीन चुनें (दूसरे सिस्टम पर संचालित करें)।"), + ("Show RustDesk", "RustDesk दिखाएं"), + ("This PC", "यह PC"), + ("or", "या"), + ("Elevate", "एलीवेट (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", "केवल तभी छुपाएं जब पासवर्ड से कनेक्शन की अनुमति हो"), + ("wayland_experiment_tip", "Wayland समर्थन अभी परीक्षण मोड में है"), + ("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", "हमेशा सॉफ़्टवेयर रेंडरिंग का उपयोग करें"), + ("config_input", "इनपुट कॉन्फ़िगर करें"), + ("config_microphone", "माइक्रोफ़ोन कॉन्फ़िगर करें"), + ("request_elevation_tip", "रिमोट साइड से उच्च अनुमतियों का अनुरोध करें"), + ("Wait", "प्रतीक्षा करें"), + ("Elevation Error", "एलीवेशन (Elevation) त्रुटि"), + ("Ask the remote user for authentication", "रिमोट उपयोगकर्ता से प्रमाणीकरण मांगें"), + ("Choose this if the remote account is administrator", "यदि रिमोट खाता व्यवस्थापक (Admin) है तो इसे चुनें"), + ("Transmit the username and password of administrator", "व्यवस्थापक का उपयोगकर्ता नाम और पासवर्ड भेजें"), + ("still_click_uac_tip", "रिमोट उपयोगकर्ता को अभी भी UAC विंडो पर 'हाँ' क्लिक करना होगा।"), + ("Request Elevation", "एलीवेशन का अनुरोध करें"), + ("wait_accept_uac_tip", "कृपया रिमोट उपयोगकर्ता द्वारा UAC स्वीकार करने की प्रतीक्षा करें।"), + ("Elevate successfully", "सफलतापूर्वक एलीवेट किया गया"), + ("uppercase", "बड़े अक्षर (Uppercase)"), + ("lowercase", "छोटे अक्षर (Lowercase)"), + ("digit", "अंक (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", "FPS"), + ("Auto", "ऑटो"), + ("Other Default Options", "अन्य डिफ़ॉल्ट विकल्प"), + ("Voice call", "वॉयस कॉल"), + ("Text chat", "टेक्स्ट चैट"), + ("Stop voice call", "वॉयस कॉल बंद करें"), + ("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", "खोजे गए डिवाइस यहाँ दिखाई देंगे।"), + ("empty_address_book_tip", "आपके पता पुस्तिका में वर्तमान में कोई डिवाइस नहीं है।"), + ("Empty Username", "खाली उपयोगकर्ता नाम"), + ("Empty Password", "खाली पासवर्ड"), + ("Me", "मैं"), + ("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", "डिफ़ॉल्ट"), + ("New RDP", "नया RDP"), + ("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", "कस्टम रिज़ॉल्यूशन"), + ("Collapse toolbar", "टूलबार समेटें"), + ("Accept and Elevate", "स्वीकार करें और एलीवेट करें"), + ("accept_and_elevate_btn_tooltip", "कनेक्शन स्वीकार करें और UAC अनुमतियाँ मांगें।"), + ("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", "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", "यदि आप किसी ऐसे व्यक्ति से बात कर रहे हैं जिसे आप नहीं जानते और जिसने आपसे RustDesk उपयोग करने को कहा है, तो तुरंत डिस्कनेक्ट कर दें।"), + ("scam_text2", "यह एक घोटाला हो सकता है। अपना आईडी या पासवर्ड किसी को न दें।"), + ("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", "RustDesk सर्वर प्रो को संस्करण {} में अपग्रेड करें"), + ("pull_group_failed_tip", "समूह खींचने (Pull) में विफल"), + ("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", "डिवाइस पर SELinux सक्षम है।"), + ("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", "आप ID, उपनाम (Alias) या IP पता दर्ज कर सकते हैं।"), + ("privacy_mode_impl_mag_tip", "मैग्निफायर (Magnifier) गोपनीयता मोड"), + ("privacy_mode_impl_virtual_display_tip", "वर्चुअल डिस्प्ले गोपनीयता मोड"), + ("Enter privacy mode", "गोपनीयता मोड में प्रवेश करें"), + ("Exit privacy mode", "गोपनीयता मोड से बाहर निकलें"), + ("idd_not_support_under_win10_2004_tip", "वर्चुअल डिस्प्ले Windows 10 संस्करण 2004 या उच्चतर पर समर्थित है।"), + ("input_source_1_tip", "इनपुट स्रोत 1"), + ("input_source_2_tip", "इनपुट स्रोत 2"), + ("Swap control-command key", "Control और Command कुंजियों को बदलें"), + ("swap-left-right-mouse", "बाएं और दाएं माउस बटन को बदलें"), + ("2FA code", "2FA कोड"), + ("More", "अधिक"), + ("enable-2fa-title", "द्वि-कारक प्रमाणीकरण (2FA) सक्षम करें"), + ("enable-2fa-desc", "कृपया अपना ऑथेंटिकेटर ऐप सेट करें।"), + ("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", "एकाधिक Windows सत्र मिले"), + ("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", "केवल तभी कनेक्शन की अनुमति दें जब RustDesk विंडो खुली हो"), + ("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", "Wayland के स्क्रीन चयन को रीसेट करें।"), + ("confirm_clear_Wayland_screen_selection_tip", "क्या आप वाकई स्क्रीन चयन साफ़ करना चाहते हैं?"), + ("android_new_voice_call_tip", "नया वॉयस कॉल अनुरोध"), + ("texture_render_tip", "टेक्सचर रेंडरिंग का उपयोग करें"), + ("Use texture rendering", "टेक्सचर रेंडरिंग का उपयोग करें"), + ("Floating window", "फ्लोटिंग विंडो"), + ("floating_window_tip", "बैकग्राउंड में रहने के दौरान RustDesk को दिखाएं"), + ("Keep screen on", "स्क्रीन चालू रखें"), + ("Never", "कभी नहीं"), + ("During controlled", "नियंत्रण के दौरान"), + ("During service is on", "जब सेवा चालू हो"), + ("Capture screen using DirectX", "DirectX का उपयोग करके स्क्रीन कैप्चर करें"), + ("Back", "पीछे"), + ("Apps", "ऐप्स"), + ("Volume up", "आवाज़ बढ़ाएं"), + ("Volume down", "आवाज़ कम करें"), + ("Power", "पावर"), + ("Telegram bot", "Telegram बॉट"), + ("enable-bot-tip", "सूचनाओं के लिए बोट सक्षम करें"), + ("enable-bot-desc", "निर्देशों के लिए हमारे टेलीग्राम बोट को देखें।"), + ("cancel-2fa-confirm-tip", "क्या आप वाकई 2FA रद्द करना चाहते हैं?"), + ("cancel-bot-confirm-tip", "क्या आप वाकई बोट रद्द करना चाहते हैं?"), + ("About RustDesk", "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", "रिमोट RustDesk क्लाइंट को संस्करण {} में अपग्रेड करें"), + ("d3d_render_tip", "D3D रेंडरिंग का उपयोग करें"), + ("Use D3D rendering", ""), + ("Printer", "प्रिंटर"), + ("printer-os-requirement-tip", "प्रिंटिंग के लिए Windows आवश्यक है।"), + ("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", "अपडेट विफल, कृपया MSI फ़ाइल की जांच करें।"), + ("websocket_tip", "यदि पोर्ट ब्लॉक हैं, तो WebSocket का उपयोग करें।"), + ("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", "उपनाम (Alias)"), + ("ScrollEdge", "किनारे से स्क्रॉल"), + ("Allow insecure TLS fallback", "असुरक्षित TLS फ़ालबैक की अनुमति दें"), + ("allow-insecure-tls-fallback-tip", "पुराने सर्वर कनेक्शन के लिए उपयोग करें।"), + ("Disable UDP", "UDP अक्षम करें"), + ("disable-udp-tip", "कनेक्शन समस्याओं के लिए UDP बंद करें।"), + ("server-oss-not-support-tip", "OSS सर्वर इसका समर्थन नहीं करता।"), + ("input note here", "यहाँ नोट दर्ज करें"), + ("note-at-conn-end-tip", "कनेक्शन के अंत में नोट दिखाएं"), + ("Show terminal extra keys", "टर्मिनल की अतिरिक्त कुंजियाँ दिखाएं"), + ("Relative mouse mode", "सापेक्ष (Relative) माउस मोड"), + ("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", "परिवर्तन सूची (Changelog)"), + ("keep-awake-during-outgoing-sessions-label", "आउटगोइंग सत्र के दौरान जागते रहें"), + ("keep-awake-during-incoming-sessions-label", "इनकमिंग सत्र के दौरान जागते रहें"), + ("Continue with {}", "{} के साथ जारी रखें"), + ("Display Name", "प्रदर्शित नाम"), + ("password-hidden-tip", "पासवर्ड सुरक्षा के लिए छिपा हुआ है।"), + ("preset-password-in-use-tip", "पूर्व-निर्धारित पासवर्ड उपयोग में है।"), + ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), + ].iter().cloned().collect(); +} diff --git a/src/lang/hr.rs b/src/lang/hr.rs index 4a3136c83..0593ff6b7 100644 --- a/src/lang/hr.rs +++ b/src/lang/hr.rs @@ -41,7 +41,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("length %min% to %max%", "duljina %min% do %max%"), ("starts with a letter", "Počinje slovom"), ("allowed characters", "Dopušteni znakovi"), - ("id_change_tip", "Dopušteni su samo a-z, A-Z, 0-9 i _ (donja crta) znakovi. Prvi znak mora biti slovo a-z, A-Z. Duljina je od 6 do 16."), + ("id_change_tip", "Dopušteni su samo a-z, A-Z, 0-9, - (dash) i _ (donja crta) znakovi. Prvi znak mora biti slovo a-z, A-Z. Duljina je od 6 do 16."), ("Website", "Web stranica"), ("About", "O programu"), ("Slogan_tip", "Stvoren srcem u ovom kaotičnom svijetu!"), @@ -147,8 +147,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("OS Password", "Lozinka OS-a"), ("install_tip", "Zbog UAC-a RustDesk ne može u nekim slučajevima raditi pravilno. Da biste prevazišli UAC, kliknite na tipku ispod da instalirate RustDesk na sustav."), ("Click to upgrade", "Klik za nadogradnju"), - ("Click to download", "Klik za preuzimanje"), - ("Click to update", "Klik za ažuriranje"), ("Configure", "Konfiguracija"), ("config_acc", "Da biste daljinski kontrolirali radnu površinu, RustDesk-u trebate dodijeliti prava za \"Pristupačnost\"."), ("config_screen", "Da biste daljinski pristupili radnoj površini, RustDesk-u trebate dodijeliti prava za \"Snimanje zaslona\"."), @@ -267,7 +265,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("No permission of file transfer", "Nemate pravo prijenosa datoteka"), ("Note", "Bilješka"), ("Connection", "Povezivanje"), - ("Share Screen", "Podijeli zaslon"), + ("Share screen", "Podijeli zaslon"), ("Chat", "Dopisivanje"), ("Total", "Ukupno"), ("items", "stavki"), @@ -275,8 +273,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Screen Capture", "Snimanje zaslona"), ("Input Control", "Kontrola unosa"), ("Audio Capture", "Snimanje zvuka"), - ("File Connection", "Spajanje preko datoteke"), - ("Screen Connection", "Podijelite vezu"), ("Do you accept?", "Prihvaćate li?"), ("Open System Setting", "Postavke otvorenog sustava"), ("How to get Android input permission?", "Kako dobiti pristup za unos na Androidu?"), @@ -381,14 +377,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Keyboard Settings", "Postavke tipkovnice"), ("Full Access", "Potpuni pristup"), ("Screen Share", "Dijeljenje zaslona"), - ("Wayland requires Ubuntu 21.04 or higher version.", "Wayland zahtijeva Ubuntu verziju 21.04 ili višu"), - ("Wayland requires higher version of linux distro. Please try X11 desktop or change your OS.", "Wayland zahtijeva višu verziju Linux distribucije. Molimo isprobjate X11 ili promijenite OS."), + ("ubuntu-21-04-required", "Wayland zahtijeva Ubuntu verziju 21.04 ili višu"), + ("wayland-requires-higher-linux-version", "Wayland zahtijeva višu verziju Linux distribucije. Molimo isprobjate X11 ili promijenite OS."), + ("xdp-portal-unavailable", ""), ("JumpLink", "Vidi"), ("Please Select the screen to be shared(Operate on the peer side).", "Molimo odaberite zaslon koji će biti podijeljen (Za rad na strani klijenta)"), ("Show RustDesk", "Prikaži RustDesk"), ("This PC", "Ovo računalo"), ("or", "ili"), - ("Continue with", "Nastavi sa"), ("Elevate", "Izdigni"), ("Zoom cursor", "Zumiraj kursor"), ("Accept sessions via password", "Prihvati sesije preko lozinke"), @@ -463,7 +459,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("empty_favorite_tip", "Još nemate nijednog omiljenog partnera?\nPronađite nekoga s kim se možete povezati i dodajte ga u svoje favorite!"), ("empty_lan_tip", "Ali ne, izgleda da još nismo otkrili niti jednu drugu stranu."), ("empty_address_book_tip", "Izgleda da trenutno nemate nijednog kolege navedenog u svom imeniku."), - ("eg: admin", "napr. admin"), ("Empty Username", "Prazno korisničko ime"), ("Empty Password", "Prazna lozinka"), ("Me", "Ja"), @@ -653,5 +648,102 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Upload folder", ""), ("Upload files", ""), ("Clipboard is synchronized", ""), + ("Update client clipboard", ""), + ("Untagged", ""), + ("new-version-of-{}-tip", ""), + ("Accessible devices", ""), + ("upgrade_remote_rustdesk_client_to_{}_tip", "Molimo ažurirajte RustDesk klijent na verziju {} ili noviju na udaljenoj strani!"), + ("d3d_render_tip", ""), + ("Use D3D rendering", ""), + ("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", ""), + ("Trackpad speed", ""), + ("Default trackpad speed", ""), + ("Numeric one-time password", ""), + ("Enable IPv6 P2P connection", ""), + ("Enable UDP hole punching", ""), + ("View camera", "Pregled kamere"), + ("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", ""), + ("allow-insecure-tls-fallback-tip", ""), + ("Disable 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 {}", "Nastavi sa {}"), + ("Display Name", ""), + ("password-hidden-tip", ""), + ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/hu.rs b/src/lang/hu.rs index f1f8ac1ae..3eb16890f 100644 --- a/src/lang/hu.rs +++ b/src/lang/hu.rs @@ -1,94 +1,94 @@ lazy_static::lazy_static! { pub static ref T: std::collections::HashMap<&'static str, &'static str> = [ - ("Status", "Státusz"), - ("Your Desktop", "Saját asztal"), + ("Status", "Állapot"), + ("Your Desktop", "Saját számítógép"), ("desk_tip", "A számítógép ezzel a jelszóval és azonosítóval érhető el távolról."), ("Password", "Jelszó"), ("Ready", "Kész"), ("Established", "Létrejött"), - ("connecting_status", "Csatlakozás folyamatban..."), + ("connecting_status", "Kapcsolódás folyamatban ..."), ("Enable service", "Szolgáltatás engedélyezése"), ("Start service", "Szolgáltatás indítása"), ("Service is running", "Szolgáltatás aktív"), ("Service is not running", "Szolgáltatás inaktív"), - ("not_ready_status", "Kapcsolódási hiba. Kérlek ellenőrizze a hálózati beállításokat."), + ("not_ready_status", "Kapcsolódási hiba. Ellenőrizze a hálózati beállításokat."), ("Control Remote Desktop", "Távoli számítógép vezérlése"), ("Transfer file", "Fájlátvitel"), - ("Connect", "Csatlakozás"), - ("Recent sessions", "Legutóbbi munkamanetek"), + ("Connect", "Kapcsolódás"), + ("Recent sessions", "Legutóbbi munkamenetek"), ("Address book", "Címjegyzék"), ("Confirmation", "Megerősítés"), - ("TCP tunneling", "TCP tunneling"), - ("Remove", "Eltávolít"), + ("TCP tunneling", "TCP-alagút"), + ("Remove", "Eltávolítás"), ("Refresh random password", "Új véletlenszerű jelszó"), ("Set your own password", "Saját jelszó beállítása"), ("Enable keyboard/mouse", "Billentyűzet/egér engedélyezése"), ("Enable clipboard", "Megosztott vágólap engedélyezése"), ("Enable file transfer", "Fájlátvitel engedélyezése"), - ("Enable TCP tunneling", "TCP tunneling engedélyezése"), + ("Enable TCP tunneling", "TCP-alagút engedélyezése"), ("IP Whitelisting", "IP engedélyezési lista"), - ("ID/Relay Server", "ID/Relay szerver"), - ("Import server config", "Szerver konfiguráció importálása"), - ("Export Server Config", "Szerver konfiguráció exportálása"), - ("Import server configuration successfully", "Szerver konfiguráció sikeresen importálva"), - ("Export server configuration successfully", "Szerver konfiguráció sikeresen exportálva"), - ("Invalid server configuration", "Érvénytelen szerver konfiguráció"), + ("ID/Relay Server", "ID/Továbbító-kiszolgáló"), + ("Import server config", "Kiszolgáló-konfiguráció importálása"), + ("Export Server Config", "Kiszolgáló-konfiguráció exportálása"), + ("Import server configuration successfully", "Kiszolgáló-konfiguráció sikeresen importálva"), + ("Export server configuration successfully", "Kiszolgáló-konfiguráció sikeresen exportálva"), + ("Invalid server configuration", "Érvénytelen kiszolgáló-konfiguráció"), ("Clipboard is empty", "A vágólap üres"), ("Stop service", "Szolgáltatás leállítása"), - ("Change ID", "Azonosító megváltoztatása"), - ("Your new ID", "Az új azonosítód"), - ("length %min% to %max%", ""), - ("starts with a letter", ""), - ("allowed characters", ""), - ("id_change_tip", "Csak a-z, A-Z, 0-9 csoportokba tartozó karakterek, illetve a _ karakter van engedélyezve. Az első karakternek mindenképpen a-z, A-Z csoportokba kell esnie. Az azonosító hosszúsága 6-tól, 16 karakter."), + ("Change ID", "Azonosító módosítása"), + ("Your new ID", "Új azonosító"), + ("length %min% to %max%", "hossz %min% és %max% között"), + ("starts with a letter", "betűvel kezdődik"), + ("allowed characters", "engedélyezett karakterek"), + ("id_change_tip", "Csak a-z, A-Z, 0-9, - (kötőjel) csoportokba tartozó karakterek, illetve a _ karakter van engedélyezve. Az első karakternek mindenképpen a-z, A-Z csoportokba kell esnie. Az azonosító hosszúsága 6-tól, 16 karakter."), ("Website", "Weboldal"), - ("About", "Rólunk"), - ("Slogan_tip", ""), + ("About", "Névjegy"), + ("Slogan_tip", "Szenvedéllyel programozva - egy káoszba süllyedő világban!"), ("Privacy Statement", "Adatvédelmi nyilatkozat"), ("Mute", "Némítás"), - ("Build Date", "Build ideje"), + ("Build Date", "Összeállítás ideje"), ("Version", "Verzió"), ("Home", "Kezdőképernyő"), - ("Audio Input", "Hangátvitel"), + ("Audio Input", "Hangbemenet"), ("Enhancements", "Fejlesztések"), - ("Hardware Codec", "Hardware kodek"), + ("Hardware Codec", "Hardveres kodek"), ("Adaptive bitrate", "Adaptív bitráta"), - ("ID Server", "ID szerver"), - ("Relay Server", "Továbbító szerver"), - ("API Server", "API szerver"), - ("invalid_http", "A címnek mindenképpen http(s)://-el kell kezdődnie."), - ("Invalid IP", "A megadott IP cím helytelen."), + ("ID Server", "ID-kiszolgáló"), + ("Relay Server", "Továbbító-kiszolgáló"), + ("API Server", "API-kiszolgáló"), + ("invalid_http", "A címnek mindenképpen http(s)://-rel kell kezdődnie."), + ("Invalid IP", "A megadott IP-cím érvénytelen"), ("Invalid format", "Érvénytelen formátum"), - ("server_not_support", "Nem támogatott a szerver által"), - ("Not available", "Nem elérhető"), + ("server_not_support", "A kiszolgáló nem támogatja"), + ("Not available", "Nem érhető el"), ("Too frequent", "Túl gyakori"), - ("Cancel", "Mégsem"), + ("Cancel", "Mégse"), ("Skip", "Kihagyás"), ("Close", "Bezárás"), ("Retry", "Újra"), ("OK", "OK"), - ("Password Required", "Jelszó megadása kötelező"), - ("Please enter your password", "Kérem írja be a jelszavát"), + ("Password Required", "A jelszó megadása kötelező"), + ("Please enter your password", "Adja meg a jelszavát"), ("Remember password", "Jelszó megjegyzése"), ("Wrong Password", "Hibás jelszó"), ("Do you want to enter again?", "Szeretne újra belépni?"), - ("Connection Error", "Csatlakozási hiba"), + ("Connection Error", "Kapcsolódási hiba"), ("Error", "Hiba"), - ("Reset by the peer", "A kapcsolatot alaphelyzetbe állt"), - ("Connecting...", "Csatlakozás..."), - ("Connection in progress. Please wait.", "Csatlakozás folyamatban. Kérem várjon."), - ("Please try 1 minute later", "Kérem próbálja meg 1 perc múlva"), + ("Reset by the peer", "A kapcsolatot a másik fél lezárta."), + ("Connecting...", "Kapcsolódás..."), + ("Connection in progress. Please wait.", "A kapcsolódás folyamatban van. Kis türelmet ..."), + ("Please try 1 minute later", "Próbálja meg 1 perc múlva"), ("Login Error", "Bejelentkezési hiba"), ("Successful", "Sikeres"), - ("Connected, waiting for image...", "Csatlakozva, várakozás a kép adatokra..."), + ("Connected, waiting for image...", "Kapcsolódva, várakozás a képadatokra..."), ("Name", "Név"), ("Type", "Típus"), ("Modified", "Módosított"), ("Size", "Méret"), - ("Show Hidden Files", "Rejtett fájlok mutatása"), - ("Receive", "Fogad"), - ("Send", "Küld"), + ("Show Hidden Files", "Rejtett fájlok megjelenítése"), + ("Receive", "Fogadás"), + ("Send", "Küldés"), ("Refresh File", "Fájl frissítése"), ("Local", "Helyi"), ("Remote", "Távoli"), @@ -99,21 +99,21 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Properties", "Tulajdonságok"), ("Multi Select", "Többszörös kijelölés"), ("Select All", "Összes kijelölése"), - ("Unselect All", "Kijelölések megszűntetése"), + ("Unselect All", "Kijelölések megszüntetése"), ("Empty Directory", "Üres könyvtár"), ("Not an empty directory", "Nem egy üres könyvtár"), ("Are you sure you want to delete this file?", "Biztosan törli ezt a fájlt?"), ("Are you sure you want to delete this empty directory?", "Biztosan törli ezt az üres könyvtárat?"), - ("Are you sure you want to delete the file of this directory?", "Biztos benne, hogy törölni szeretné a könyvtár tartalmát?"), - ("Do this for all conflicts", "Tegye ezt minden ütközéskor"), - ("This is irreversible!", "Ez a folyamat visszafordíthatatlan!"), + ("Are you sure you want to delete the file of this directory?", "Biztosan törli a könyvtár tartalmát?"), + ("Do this for all conflicts", "Tegye ezt minden ütközés esetén"), + ("This is irreversible!", "Ez a művelet nem vonható vissza!"), ("Deleting", "Törlés folyamatban"), - ("files", "fájlok"), + ("files", "fájl"), ("Waiting", "Várakozás"), ("Finished", "Befejezve"), ("Speed", "Sebesség"), - ("Custom Image Quality", "Egyedi képminőség"), - ("Privacy mode", "Inkognító mód"), + ("Custom Image Quality", "Egyéni képminőség"), + ("Privacy mode", "Inkognitó mód"), ("Block user input", "Felhasználói bevitel letiltása"), ("Unblock user input", "Felhasználói bevitel engedélyezése"), ("Adjust Window", "Ablakméret beállítása"), @@ -125,104 +125,102 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Good image quality", "Eredetihez hű"), ("Balanced", "Kiegyensúlyozott"), ("Optimize reaction time", "Gyorsan reagáló"), - ("Custom", "Egyedi"), + ("Custom", "Egyéni"), ("Show remote cursor", "Távoli kurzor megjelenítése"), - ("Show quality monitor", ""), + ("Show quality monitor", "Kijelző minőségének ellenőrzése"), ("Disable clipboard", "Közös vágólap kikapcsolása"), ("Lock after session end", "Távoli fiók zárolása a munkamenet végén"), - ("Insert Ctrl + Alt + Del", "Illessze be a Ctrl + Alt + Del"), + ("Insert Ctrl + Alt + Del", "Illessze be a Ctrl + Alt + Del billentyűzetkombinációt"), ("Insert Lock", "Távoli fiók zárolása"), ("Refresh", "Frissítés"), ("ID does not exist", "Az azonosító nem létezik"), - ("Failed to connect to rendezvous server", "Nem sikerült csatlakozni a kiszolgáló szerverhez"), - ("Please try later", "Kérjük, próbálja később"), + ("Failed to connect to rendezvous server", "Nem sikerült kapcsolódni a kiszolgálóhoz"), + ("Please try later", "Próbálja meg később"), ("Remote desktop is offline", "A távoli számítógép offline állapotban van"), - ("Key mismatch", "Eltérés a kulcsokban"), + ("Key mismatch", "Kulcseltérés"), ("Timeout", "Időtúllépés"), - ("Failed to connect to relay server", "Nem sikerült csatlakozni a közvetítő szerverhez"), - ("Failed to connect via rendezvous server", "Nem sikerült csatlakozni a kiszolgáló szerveren keresztül"), - ("Failed to connect via relay server", "Nem sikerült csatlakozni a közvetítő szerveren keresztül"), + ("Failed to connect to relay server", "Nem sikerült kapcsolódni a továbbító-kiszolgálóhoz"), + ("Failed to connect via rendezvous server", "Nem sikerült kapcsolódni a kiszolgálón keresztül"), + ("Failed to connect via relay server", "Nem sikerült kapcsolódni a továbbító-kiszolgálón keresztül"), ("Failed to make direct connection to remote desktop", "Nem sikerült közvetlen kapcsolatot létesíteni a távoli számítógéppel"), - ("Set Password", "Jelszó Beállítása"), + ("Set Password", "Jelszó beállítása"), ("OS Password", "Operációs rendszer jelszavának beállítása"), - ("install_tip", "Előfordul, hogy bizonyos esetekben hiba léphet fel a Portable verzió használata során. A megfelelő működés érdekében, kérem telepítse a RustDesk alkalmazást a számítógépre."), + ("install_tip", "Előfordul, hogy bizonyos esetekben hiba léphet fel a Portable verzió használatakor. A megfelelő működés érdekében, telepítse a RustDesk alkalmazást a számítógépére."), ("Click to upgrade", "Kattintson ide a frissítés telepítéséhez"), - ("Click to download", "Kattintson ide a letöltéshez"), - ("Click to update", "Kattintson ide a frissítés letöltéséhez"), ("Configure", "Beállítás"), - ("config_acc", "A távoli vezérléshez a RustDesk-nek \"Kisegítő lehetőség\" engedélyre van szüksége"), - ("config_screen", "A távoli vezérléshez szükséges a \"Képernyőfelvétel\" engedély megadása"), - ("Installing ...", "Telepítés..."), - ("Install", "Telepítés"), + ("config_acc", "A számítógép távoli vezérléséhez a RustDesknek hozzáférési jogokat kell adnia."), + ("config_screen", "Ahhoz, hogy távolról hozzáférhessen a számítógépéhez, meg kell adnia a RustDesknek a „Képernyőfelvétel” jogosultságot."), + ("Installing ...", "Telepítés ..."), + ("Install", "Telepítse"), ("Installation", "Telepítés"), ("Installation Path", "Telepítési útvonal"), ("Create start menu shortcuts", "Start menü parancsikonok létrehozása"), ("Create desktop icon", "Ikon létrehozása az asztalon"), - ("agreement_tip", "A telepítés folytatásával automatikusan elfogadásra kerül a licensz szerződés."), + ("agreement_tip", "A telepítés folytatásával automatikusan elfogadásra kerül a licenc szerződés."), ("Accept and Install", "Elfogadás és telepítés"), - ("End-user license agreement", "Felhasználói licensz szerződés"), - ("Generating ...", "Létrehozás..."), + ("End-user license agreement", "Végfelhasználói licenc szerződés"), + ("Generating ...", "Létrehozás ..."), ("Your installation is lower version.", "A telepített verzió alacsonyabb."), - ("not_close_tcp_tip", "Ne zárja be ezt az ablakot miközben a tunnelt használja"), - ("Listening ...", "Keresés..."), + ("not_close_tcp_tip", "Ne zárja be ezt az ablakot, amíg TCP-alagutat használ"), + ("Listening ...", "Figyelés ..."), ("Remote Host", "Távoli kiszolgáló"), ("Remote Port", "Távoli port"), ("Action", "Indítás"), ("Add", "Hozzáadás"), ("Local Port", "Helyi port"), ("Local Address", "Helyi cím"), - ("Change Local Port", "Helyi port megváltoztatása"), - ("setup_server_tip", "Gyorsabb kapcsolat érdekében, hozzon létre saját szervert"), + ("Change Local Port", "Helyi port módosítása"), + ("setup_server_tip", "Gyorsabb kapcsolat érdekében, hozzon létre saját kiszolgálót"), ("Too short, at least 6 characters.", "Túl rövid, legalább 6 karakter."), ("The confirmation is not identical.", "A megerősítés nem volt azonos"), ("Permissions", "Engedélyek"), ("Accept", "Elfogadás"), ("Dismiss", "Elutasítás"), ("Disconnect", "Kapcsolat bontása"), - ("Enable file copy and paste", "Fájlok másolásának és beillesztésének engedélyezése"), - ("Connected", "Csatlakozva"), + ("Enable file copy and paste", "Fájlmásolás és beillesztés engedélyezése"), + ("Connected", "Kapcsolódva"), ("Direct and encrypted connection", "Közvetlen, és titkosított kapcsolat"), ("Relayed and encrypted connection", "Továbbított, és titkosított kapcsolat"), ("Direct and unencrypted connection", "Közvetlen, és nem titkosított kapcsolat"), ("Relayed and unencrypted connection", "Továbbított, és nem titkosított kapcsolat"), ("Enter Remote ID", "Távoli számítógép azonosítója"), - ("Enter your password", "Írja be a jelszavát"), - ("Logging in...", "A belépés folyamatban..."), + ("Enter your password", "Adja meg a jelszavát"), + ("Logging in...", "Belépés folyamatban..."), ("Enable RDP session sharing", "RDP-munkamenet-megosztás engedélyezése"), ("Auto Login", "Automatikus bejelentkezés"), ("Enable direct IP access", "Közvetlen IP-elérés engedélyezése"), ("Rename", "Átnevezés"), - ("Space", ""), + ("Space", "Szóköz"), ("Create desktop shortcut", "Asztali parancsikon létrehozása"), ("Change Path", "Elérési út módosítása"), ("Create Folder", "Mappa létrehozás"), - ("Please enter the folder name", "Kérjük, adja meg a mappa nevét"), + ("Please enter the folder name", "Adja meg a mappa nevét"), ("Fix it", "Javítás"), ("Warning", "Figyelmeztetés"), - ("Login screen using Wayland is not supported", "Bejelentkezéskori Wayland használata nem támogatott"), + ("Login screen using Wayland is not supported", "A Wayland használatával történő bejelentkezési képernyő nem támogatott"), ("Reboot required", "Újraindítás szükséges"), - ("Unsupported display server", "Nem támogatott megjelenítő szerver"), - ("x11 expected", "x11-re számítottt"), + ("Unsupported display server", "Nem támogatott megjelenítő kiszolgáló"), + ("x11 expected", "x11-re számított"), ("Port", "Port"), ("Settings", "Beállítások"), ("Username", "Felhasználónév"), ("Invalid port", "Érvénytelen port"), - ("Closed manually by the peer", "A kapcsolatot a másik fél manuálisan bezárta"), - ("Enable remote configuration modification", "Távoli konfiguráció módosítás engedélyezése"), - ("Run without install", "Futtatás feltelepítés nélkül"), - ("Connect via relay", ""), - ("Always connect via relay", "Mindig közvetítőn keresztüli csatlakozás"), - ("whitelist_tip", "Csak az engedélyezési listán szereplő címek csatlakozhatnak"), + ("Closed manually by the peer", "A kapcsolatot a másik fél saját kezűleg bezárta"), + ("Enable remote configuration modification", "Távoli konfiguráció-módosítás engedélyezése"), + ("Run without install", "Futtatás telepítés nélkül"), + ("Connect via relay", "Kapcsolódás továbbító-kiszolgálón keresztül"), + ("Always connect via relay", "Kapcsolódás mindig továbbító-kiszolgálón keresztül"), + ("whitelist_tip", "Csak az engedélyezési listán szereplő címek kapcsolódhatnak"), ("Login", "Belépés"), - ("Verify", ""), - ("Remember me", ""), - ("Trust this device", ""), - ("Verification code", ""), - ("verification_tip", ""), + ("Verify", "Ellenőrzés"), + ("Remember me", "Emlékezzen rám"), + ("Trust this device", "Megbízom ebben az eszközben"), + ("Verification code", "Ellenőrző kód"), + ("verification_tip", "A regisztrált e-mail-címre egy ellenőrző kód lesz elküldve. Adja meg az ellenőrző kódot az újbóli bejelentkezéshez."), ("Logout", "Kilépés"), - ("Tags", "Tagok"), + ("Tags", "Címkék"), ("Search ID", "Azonosító keresése..."), - ("whitelist_sep", "A címeket veszővel, pontosvesszővel, szóközzel, vagy új sorral válassza el"), + ("whitelist_sep", "A címeket vesszővel, pontosvesszővel, szóközzel vagy új sorral kell elválasztani"), ("Add ID", "Azonosító hozzáadása"), ("Add Tag", "Címke hozzáadása"), ("Unselect all tags", "A címkék kijelölésének megszüntetése"), @@ -230,9 +228,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Username missed", "Üres felhasználónév"), ("Password missed", "Üres jelszó"), ("Wrong credentials", "Hibás felhasználónév vagy jelszó"), - ("The verification code is incorrect or has expired", ""), + ("The verification code is incorrect or has expired", "A hitelesítőkód érvénytelen vagy lejárt"), ("Edit Tag", "Címke szerkesztése"), - ("Forget Password", "A jelszó megjegyzésének törlése"), + ("Forget Password", "Jelszó elfelejtése"), ("Favorites", "Kedvencek"), ("Add to Favorites", "Hozzáadás a kedvencekhez"), ("Remove from Favorites", "Eltávolítás a kedvencekből"), @@ -241,12 +239,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Socks5 Proxy", "Socks5 Proxy"), ("Socks5/Http(s) Proxy", "Socks5/Http(s) Proxy"), ("Discovered", "Felfedezett"), - ("install_daemon_tip", "Az automatikus indításhoz szükséges a szolgáltatás telepítése"), + ("install_daemon_tip", "Automatikus indításhoz szükséges a szolgáltatás telepítése"), ("Remote ID", "Távoli azonosító"), ("Paste", "Beillesztés"), - ("Paste here?", "Beilleszti ide?"), - ("Are you sure to close the connection?", "Biztos, hogy bezárja a kapcsolatot?"), - ("Download new version", "Új verzó letöltése"), + ("Paste here?", "Beillesztés ide?"), + ("Are you sure to close the connection?", "Biztosan bezárja a kapcsolatot?"), + ("Download new version", "Új verzió letöltése"), ("Touch mode", "Érintési mód bekapcsolása"), ("Mouse mode", "Egérhasználati mód bekapcsolása"), ("One-Finger Tap", "Egyujjas érintés"), @@ -255,85 +253,83 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Two-Finger Tap", "Kétujjas érintés"), ("Right Mouse", "Jobb egér gomb"), ("One-Finger Move", "Egyujjas mozgatás"), - ("Double Tap & Move", "Dupla érintés, és mozgatás"), + ("Double Tap & Move", "Dupla érintés és mozgatás"), ("Mouse Drag", "Mozgatás egérrel"), ("Three-Finger vertically", "Három ujj függőlegesen"), ("Mouse Wheel", "Egérgörgő"), - ("Two-Finger Move", "Kátujjas mozgatás"), - ("Canvas Move", "Nézet mozgatása"), + ("Two-Finger Move", "Kétujjas mozgatás"), + ("Canvas Move", "Nézet módosítása"), ("Pinch to Zoom", "Kétujjas nagyítás"), ("Canvas Zoom", "Nézet nagyítása"), ("Reset canvas", "Nézet visszaállítása"), ("No permission of file transfer", "Nincs engedély a fájlátvitelre"), - ("Note", "Megyjegyzés"), + ("Note", "Megjegyzés"), ("Connection", "Kapcsolat"), - ("Share Screen", "Képernyőmegosztás"), - ("Chat", "Chat"), + ("Share screen", "Képernyőmegosztás"), + ("Chat", "Csevegés"), ("Total", "Összes"), - ("items", "elemek"), - ("Selected", "Kijelölt"), + ("items", "elem"), + ("Selected", "Kijelölve"), ("Screen Capture", "Képernyőrögzítés"), ("Input Control", "Távoli vezérlés"), ("Audio Capture", "Hangrögzítés"), - ("File Connection", "Fájlátvitel"), - ("Screen Connection", "Képátvitel"), - ("Do you accept?", "Elfogadja?"), + ("Do you accept?", "Elfogadás?"), ("Open System Setting", "Rendszerbeállítások megnyitása"), - ("How to get Android input permission?", "Hogyan állíthatok be Android beviteli engedélyt?"), - ("android_input_permission_tip1", "A távoli vezérléshez kérjük engedélyezze a \"Kisegítő lehetőség\" lehetőséget."), - ("android_input_permission_tip2", "A következő rendszerbeállítások oldalon a letöltött alkalmazások menüponton belül, kapcsolja be a [RustDesk Input] szolgáltatást."), - ("android_new_connection_tip", "Új kérés érkezett mely vezérelni szeretné az eszközét"), - ("android_service_will_start_tip", "A \"Képernyőrögzítés\" bekapcsolásával automatikus elindul a szolgáltatás, lehetővé téve, hogy más eszközök csatlakozási kérelmet küldhessenek"), + ("How to get Android input permission?", "Hogyan állítható be az Androidos beviteli engedély?"), + ("android_input_permission_tip1", "Ahhoz, hogy egy távoli eszköz vezérelhesse Android készülékét, engedélyeznie kell a RustDesk számára a „Hozzáférhetőség” szolgáltatás használatát."), + ("android_input_permission_tip2", "A következő rendszerbeállítások oldalon a letöltött alkalmazások menüponton belül, kapcsolja be a „RustDesk Input” szolgáltatást."), + ("android_new_connection_tip", "Új kérés érkezett, mely vezérelni szeretné az eszközét"), + ("android_service_will_start_tip", "A képernyőmegosztás aktiválása automatikusan elindítja a szolgáltatást, így más eszközök is vezérelhetik ezt az Android-eszközt."), ("android_stop_service_tip", "A szolgáltatás leállítása automatikusan szétkapcsol minden létező kapcsolatot."), ("android_version_audio_tip", "A jelenlegi Android verzió nem támogatja a hangrögzítést, frissítsen legalább Android 10-re, vagy egy újabb verzióra."), - ("android_start_service_tip", ""), - ("android_permission_may_not_change_tip", ""), + ("android_start_service_tip", "A képernyőmegosztó szolgáltatás elindításához koppintson a „Kapcsolási szolgáltatás indítása” gombra, vagy aktiválja a „Képernyőfelvétel” engedélyt."), + ("android_permission_may_not_change_tip", "A meglévő kapcsolatok engedélyei csak új kapcsolódás után módosulnak."), ("Account", "Fiók"), ("Overwrite", "Felülírás"), ("This file exists, skip or overwrite this file?", "Ez a fájl már létezik, kihagyja vagy felülírja ezt a fájlt?"), ("Quit", "Kilépés"), - ("Help", "Segítség"), + ("Help", "Súgó"), ("Failed", "Sikertelen"), ("Succeeded", "Sikeres"), ("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 álltal"), - ("Please install plugins", "Kérem telepítse a bővítményeket"), + ("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"), ("Language", "Nyelv"), ("Keep RustDesk background service", "RustDesk futtatása a háttérben"), - ("Ignore Battery Optimizations", "Akkumulátorkímélő figyelmen kívűl hagyása"), - ("android_open_battery_optimizations_tip", "Ha le szeretné tiltani ezt a funkciót, lépjen a RustDesk alkalmazás beállítási oldalára, keresse meg az [Akkumulátorkímélő] lehetőséget és válassza a nincs korlátozás lehetőséget."), - ("Start on boot", ""), - ("Start the screen sharing service on boot, requires special permissions", ""), - ("Connection not allowed", "A csatlakozás nem engedélyezett"), - ("Legacy mode", ""), - ("Map mode", ""), + ("Ignore Battery Optimizations", "Akkumulátorkímélő figyelmen kívül hagyása"), + ("android_open_battery_optimizations_tip", "Ha le szeretné tiltani ezt a funkciót, lépjen a RustDesk alkalmazás beállításaiba, keresse meg az [Akkumulátorkímélő] lehetőséget és válassza a nincs korlátozás lehetőséget."), + ("Start on boot", "Indítás bekapcsoláskor"), + ("Start the screen sharing service on boot, requires special permissions", "Indítsa el a képernyőmegosztó szolgáltatást rendszerindításkor, mely speciális engedélyeket is igényel"), + ("Connection not allowed", "A kapcsolódás nem engedélyezett"), + ("Legacy mode", "Kompatibilitási mód"), + ("Map mode", "Hozzárendelési mód"), ("Translate mode", "Fordító mód"), ("Use permanent password", "Állandó jelszó használata"), ("Use both passwords", "Mindkét jelszó használata"), ("Set permanent password", "Állandó jelszó beállítása"), ("Enable remote restart", "Távoli újraindítás engedélyezése"), ("Restart remote device", "Távoli eszköz újraindítása"), - ("Are you sure you want to restart", "Biztos szeretné újraindítani?"), + ("Are you sure you want to restart", "Biztosan újra szeretné indítani?"), ("Restarting remote device", "Távoli eszköz újraindítása..."), - ("remote_restarting_tip", "A távoli eszköz újraindul, zárja be ezt az üzenetet, csatlakozzon újra, állandó jelszavával"), + ("remote_restarting_tip", "A távoli eszköz újraindul, zárja be ezt az üzenetet, kapcsolódjon újra az állandó jelszavával"), ("Copied", "Másolva"), ("Exit Fullscreen", "Kilépés teljes képernyős módból"), ("Fullscreen", "Teljes képernyő"), - ("Mobile Actions", "mobil műveletek"), + ("Mobile Actions", "Mobil műveletek"), ("Select Monitor", "Válasszon képernyőt"), ("Control Actions", "Irányítási műveletek"), ("Display Settings", "Megjelenítési beállítások"), ("Ratio", "Arány"), ("Image Quality", "Képminőség"), ("Scroll Style", "Görgetési stílus"), - ("Show Toolbar", ""), - ("Hide Toolbar", ""), - ("Direct Connection", "Közvetlen kapcsolat"), - ("Relay Connection", "Közvetett csatlakozás"), + ("Show Toolbar", "Eszköztár megjelenítése"), + ("Hide Toolbar", "Eszköztár elrejtése"), + ("Direct Connection", "Kapcsolódás közvetlenül"), + ("Relay Connection", "Kapcsolódás továbbító-kiszolgálón keresztül"), ("Secure Connection", "Biztonságos kapcsolat"), ("Insecure Connection", "Nem biztonságos kapcsolat"), ("Scale original", "Eredeti méretarány"), @@ -342,316 +338,412 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Security", "Biztonság"), ("Theme", "Téma"), ("Dark Theme", "Sötét téma"), - ("Light Theme", ""), + ("Light Theme", "Világos téma"), ("Dark", "Sötét"), ("Light", "Világos"), - ("Follow System", "Rendszer téma követése"), + ("Follow System", "Rendszer beállításainak követése"), ("Enable hardware codec", "Hardveres kodek engedélyezése"), ("Unlock Security Settings", "Biztonsági beállítások feloldása"), ("Enable audio", "Hang engedélyezése"), ("Unlock Network Settings", "Hálózati beállítások feloldása"), - ("Server", "Szerver"), - ("Direct IP Access", "Közvetlen IP hozzáférés"), + ("Server", "Kiszolgáló"), + ("Direct IP Access", "Közvetlen IP-hozzáférés"), ("Proxy", "Proxy"), ("Apply", "Alkalmaz"), ("Disconnect all devices?", "Leválasztja az összes eszközt?"), ("Clear", "Tisztítás"), - ("Audio Input Device", "Audio bemeneti eszköz"), + ("Audio Input Device", "Hangbemeneti eszköz"), ("Use IP Whitelisting", "Engedélyezési lista használata"), ("Network", "Hálózat"), - ("Pin Toolbar", ""), - ("Unpin Toolbar", ""), + ("Pin Toolbar", "Eszköztár kitűzése"), + ("Unpin Toolbar", "Eszköztár kitűzésének feloldása"), ("Recording", "Felvétel"), ("Directory", "Könyvtár"), ("Automatically record incoming sessions", "A bejövő munkamenetek automatikus rögzítése"), - ("Automatically record outgoing sessions", ""), - ("Change", "Változtatás"), - ("Start session recording", "Munkamenet rögzítés indítása"), - ("Stop session recording", "Munkamenet rögzítés leállítása"), - ("Enable recording session", "Munkamenet rögzítés engedélyezése"), - ("Enable LAN discovery", "Felfedezés enegedélyezése"), + ("Automatically record outgoing sessions", "A kimenő munkamenetek automatikus rögzítése"), + ("Change", "Módosítás"), + ("Start session recording", "Munkamenet-rögzítés indítása"), + ("Stop session recording", "Munkamenet-rögzítés leállítása"), + ("Enable recording session", "Munkamenet-rögzítés engedélyezése"), + ("Enable LAN discovery", "Felfedezés engedélyezése"), ("Deny LAN discovery", "Felfedezés tiltása"), ("Write a message", "Üzenet írása"), - ("Prompt", ""), - ("Please wait for confirmation of UAC...", ""), - ("elevated_foreground_window_tip", ""), - ("Disconnected", "Szétkapcsolva"), + ("Prompt", "Kérés"), + ("Please wait for confirmation of UAC...", "Várjon az UAC megerősítésére..."), + ("elevated_foreground_window_tip", "A távvezérelt számítógép jelenleg nyitott ablakához magasabb szintű jogok szükségesek. Ezért jelenleg nem lehetséges az egér és a billentyűzet használata. Kérje meg azt a felhasználót, akinek a számítógépét távolról vezérli, hogy minimalizálja az ablakot, vagy növelje a jogokat. A jövőbeni probléma elkerülése érdekében ajánlott a szoftvert a távvezérelt számítógépre telepíteni."), + ("Disconnected", "Kapcsolat bontva"), ("Other", "Egyéb"), - ("Confirm before closing multiple tabs", "Biztos, hogy bezárja az összes lapot?"), - ("Keyboard Settings", "Billentyűzet beállítások"), + ("Confirm before closing multiple tabs", "Biztosan bezárja az összes lapot?"), + ("Keyboard Settings", "Billentyűzetbeállítások"), ("Full Access", "Teljes hozzáférés"), ("Screen Share", "Képernyőmegosztás"), - ("Wayland requires Ubuntu 21.04 or higher version.", "A Waylandhoz Ubuntu 21.04 vagy újabb verzió szükséges."), - ("Wayland requires higher version of linux distro. Please try X11 desktop or change your OS.", "A Wayland a Linux disztró magasabb verzióját igényli. Próbálja ki az X11 desktopot, vagy változtassa meg az operációs rendszert."), + ("ubuntu-21-04-required", "A Waylandhez Ubuntu 21.04 vagy újabb verzió szükséges."), + ("wayland-requires-higher-linux-version", "A Wayland a Linux disztribúció magasabb verzióját igényli. Próbálja ki az X11 asztali környezetet, vagy változtassa meg az operációs rendszert."), + ("xdp-portal-unavailable", ""), ("JumpLink", "Hiperhivatkozás"), - ("Please Select the screen to be shared(Operate on the peer side).", "Kérjük, válassza ki a megosztani kívánt képernyőt."), + ("Please Select the screen to be shared(Operate on the peer side).", "Válassza ki a megosztani kívánt képernyőt."), ("Show RustDesk", "A RustDesk megjelenítése"), ("This PC", "Ez a számítógép"), ("or", "vagy"), - ("Continue with", "Folytatás a következővel"), - ("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...", ""), + ("Elevate", "Hozzáférés engedélyezése"), + ("Zoom cursor", "Kurzor nagyítása"), + ("Accept sessions via password", "Munkamenetek elfogadása jelszóval"), + ("Accept sessions via click", "Munkamenetek elfogadása kattintással"), + ("Accept sessions via both", "Munkamenetek fogadása mindkettőn keresztül"), + ("Please wait for the remote side to accept your session request...", "Várjon, amíg a távoli oldal elfogadja a munkamenet-kérelmét..."), ("One-time Password", "Egyszer használatos jelszó"), - ("Use one-time password", "Használj ideiglenes jelszót"), + ("Use one-time password", "Használjon ideiglenes jelszót"), ("One-time password length", "Egyszer használatos jelszó hossza"), - ("Request access to your device", ""), - ("Hide connection management window", ""), - ("hide_cm_tip", ""), - ("wayland_experiment_tip", ""), - ("Right click to select tabs", ""), - ("Skipped", ""), - ("Add to address book", ""), + ("Request access to your device", "Hozzáférés kérése az eszközéhez"), + ("Hide connection management window", "Kapcsolatkezelő ablak elrejtése"), + ("hide_cm_tip", "Ez csak akkor lehetséges, ha a hozzáférés állandó jelszóval történik."), + ("wayland_experiment_tip", "A Wayland-támogatás csak kísérleti jellegű. Használja az X11-et, ha felügyelet nélküli hozzáférésre van szüksége."), + ("Right click to select tabs", "Jobb klikk a lapok kiválasztásához"), + ("Skipped", "Kihagyott"), + ("Add to address book", "Hozzáadás a címjegyzékhez"), ("Group", "Csoport"), ("Search", "Keresés"), - ("Closed manually by web console", ""), - ("Local keyboard type", ""), - ("Select local keyboard type", ""), - ("software_render_tip", ""), - ("Always use software rendering", ""), - ("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", ""), - ("Weak", ""), - ("Medium", ""), - ("Strong", ""), - ("Switch Sides", ""), - ("Please confirm if you want to share your desktop?", ""), - ("Display", ""), + ("Closed manually by web console", "Saját kezűleg bezárva a webkonzolon keresztül"), + ("Local keyboard type", "Helyi billentyűzet típusa"), + ("Select local keyboard type", "Helyi billentyűzet típusának kiválasztása"), + ("software_render_tip", "Ha Nvidia grafikus kártyát használ Linux alatt, és a távoli ablak a kapcsolat létrehozása után azonnal bezáródik, akkor a Nouveau nyílt forráskódú illesztőprogramra való váltás és a szoftveres leképezés alkalmazása segíthet. A szoftvert újra kell indítani."), + ("Always use software rendering", "Mindig szoftveres leképezést használjon"), + ("config_input", "Ahhoz, hogy a távoli asztalt a billentyűzettel vezérelhesse, a RustDesknek meg kell adnia a „Bemenet figyelése” jogosultságot."), + ("config_microphone", "Ahhoz, hogy távolról beszélhessen, meg kell adnia a RustDesknek a „Hangfelvétel” jogosultságot."), + ("request_elevation_tip", "Akkor is kérhet megnövelt jogokat, ha valaki a partneroldalon van."), + ("Wait", "Várjon"), + ("Elevation Error", "Emelt szintű hozzáférési hiba"), + ("Ask the remote user for authentication", "Hitelesítés kérése a távoli felhasználótól"), + ("Choose this if the remote account is administrator", "Akkor válassza ezt, ha a távoli fiók rendszergazda"), + ("Transmit the username and password of administrator", "Küldje el a rendszergazda felhasználónevét és jelszavát"), + ("still_click_uac_tip", "A távoli felhasználónak továbbra is az „Igen” gombra kell kattintania a RustDesk UAC ablakában. Kattintson!"), + ("Request Elevation", "Emelt szintű jogok igénylése"), + ("wait_accept_uac_tip", "Várjon, amíg a távoli felhasználó elfogadja az UAC párbeszédet."), + ("Elevate successfully", "Emelt szintű jogok megadva"), + ("uppercase", "NAGYBETŰS"), + ("lowercase", "kisbetűs"), + ("digit", "szám"), + ("special character", "különleges karakter"), + ("length>=8", "hossz>=8"), + ("Weak", "Gyenge"), + ("Medium", "Közepes"), + ("Strong", "Erős"), + ("Switch Sides", "Oldalváltás"), + ("Please confirm if you want to share your desktop?", "Erősítse meg, hogy meg akarja-e osztani az asztalát?"), + ("Display", "Képernyő"), ("Default View Style", "Alapértelmezett megjelenítés"), ("Default Scroll Style", "Alapértelmezett görgetés"), ("Default Image Quality", "Alapértelmezett képminőség"), - ("Default Codec", "Alapértelmezett kódek"), - ("Bitrate", ""), - ("FPS", ""), - ("Auto", ""), - ("Other Default Options", ""), - ("Voice call", ""), - ("Text chat", ""), - ("Stop voice call", ""), - ("relay_hint_tip", ""), - ("Reconnect", ""), - ("Codec", "Kódek"), + ("Default Codec", "Alapértelmezett kodek"), + ("Bitrate", "Bitsebesség"), + ("FPS", "FPS"), + ("Auto", "Automatikus"), + ("Other Default Options", "Egyéb alapértelmezett beállítások"), + ("Voice call", "Hanghívás"), + ("Text chat", "Szöveges csevegés"), + ("Stop voice call", "Hanghívás leállítása"), + ("relay_hint_tip", "Ha a közvetlen kapcsolat nem lehetséges, megpróbálhat kapcsolatot létesíteni egy továbbító-kiszolgálón keresztül.\nHa az első próbálkozáskor továbbító-kiszolgálón keresztüli kapcsolatot szeretne létrehozni, használhatja az „/r” utótagot. Az azonosítóhoz vagy a „Mindig továbbító-kiszolgálón keresztül kapcsolódom” opcióhoz a legutóbbi munkamenetek listájában, ha van ilyen."), + ("Reconnect", "Újrakapcsolódás"), + ("Codec", "Kodek"), ("Resolution", "Felbontás"), - ("No transfers in progress", ""), - ("Set one-time password length", ""), - ("RDP Settings", ""), - ("Sort by", ""), - ("New Connection", ""), - ("Restore", ""), - ("Minimize", ""), - ("Maximize", ""), - ("Your Device", ""), - ("empty_recent_tip", ""), - ("empty_favorite_tip", ""), - ("empty_lan_tip", ""), - ("empty_address_book_tip", ""), - ("eg: admin", ""), - ("Empty Username", ""), - ("Empty Password", ""), - ("Me", ""), - ("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", ""), - ("New 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", ""), + ("No transfers in progress", "Nincs folyamatban átvitel"), + ("Set one-time password length", "Állítsa be az egyszeri jelszó hosszát"), + ("RDP Settings", "RDP beállítások"), + ("Sort by", "Rendezés"), + ("New Connection", "Új kapcsolat"), + ("Restore", "Visszaállítás"), + ("Minimize", "Minimalizálás"), + ("Maximize", "Maximalizálás"), + ("Your Device", "Az én eszközöm"), + ("empty_recent_tip", "Nincsenek aktuális munkamenetek!\nIdeje ütemezni egy újat."), + ("empty_favorite_tip", "Még nincs kedvenc távoli állomása?\nHagyja, hogy találjunk valakit, akivel kapcsolatba tud lépni, és adja hozzá a kedvencekhez!"), + ("empty_lan_tip", "Úgy tűnik, még nem adott hozzá egyetlen távoli helyszínt sem."), + ("empty_address_book_tip", "Úgy tűnik, hogy jelenleg nincsenek távoli állomások a címjegyzékében."), + ("Empty Username", "Üres felhasználónév"), + ("Empty Password", "Üres jelszó"), + ("Me", "Ön"), + ("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"), + ("New RDP", "Új RDP"), + ("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"), + ("Collapse toolbar", "Eszköztár összecsukása"), + ("Accept and Elevate", "Elfogadás és magasabb szintű jogosultságra emelés"), + ("accept_and_elevate_btn_tooltip", "Fogadja el a kapcsolatot, és növelje az UAC-engedélyeket."), + ("clipboard_wait_response_timeout_tip", "Időtúllépés, amíg a másolat válaszára vár."), + ("Incoming connection", "Bejövő kapcsolat"), + ("Outgoing connection", "Kimenő kapcsolat"), + ("Exit", "Kilépés"), + ("Open", "Megnyitás"), + ("logout_tip", "Biztosan ki szeretne lépni?"), ("Service", "Szolgáltatás"), ("Start", "Indítás"), ("Stop", "Leállítás"), - ("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", ""), - ("Installation Successful!", ""), - ("Installation failed!", ""), - ("Reverse mouse wheel", ""), - ("{} sessions", ""), - ("scam_title", ""), - ("scam_text1", ""), - ("scam_text2", ""), - ("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)", ""), - ("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", ""), - ("swap-left-right-mouse", ""), - ("2FA code", ""), - ("More", ""), - ("enable-2fa-title", ""), - ("enable-2fa-desc", ""), - ("wrong-2fa-code", ""), - ("enter-2fa-title", ""), - ("Email verification code must be 6 characters.", ""), - ("2FA code must be 6 digits.", ""), - ("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", ""), - ("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", ""), - ("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", ""), - ("Requires at least {} characters", ""), - ("Wrong PIN", ""), - ("Set 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", ""), + ("exceed_max_devices", "Elérte a felügyelt eszközök maximális számát."), + ("Sync with recent sessions", "Szinkronizálás a legutóbbi munkamenetekkel"), + ("Sort tags", "Címkék rendezése"), + ("Open connection in new tab", "Kapcsolat megnyitása új lapon"), + ("Move tab to new window", "Lap áthelyezése új ablakba"), + ("Can not be empty", "Nem lehet üres"), + ("Already exists", "Már létezik"), + ("Change Password", "Jelszó módosítása"), + ("Refresh Password", "Jelszó frissítése"), + ("ID", "Azonosító"), + ("Grid View", "Mozaik nézet"), + ("List View", "Lista nézet"), + ("Select", "Kiválasztás"), + ("Toggle Tags", "Címkekapcsoló"), + ("pull_ab_failed_tip", "A címjegyzék frissítése nem sikerült"), + ("push_ab_failed_tip", "A címjegyzék szinkronizálása a kiszolgálóval nem sikerült"), + ("synced_peer_readded_tip", "A legutóbbi munkamenetekben jelen lévő eszközök ismét felkerülnek a címjegyzékbe."), + ("Change Color", "Szín módosítása"), + ("Primary Color", "Elsődleges szín"), + ("HSV Color", "HSV szín"), + ("Installation Successful!", "Sikeres telepítés!"), + ("Installation failed!", "A telepítés nem sikerült!"), + ("Reverse mouse wheel", "Fordított egérgörgő"), + ("{} sessions", "{} munkamenet"), + ("scam_title", "Lehet, hogy átverték!"), + ("scam_text1", "Ha olyan valakivel beszél telefonon, akit NEM ISMER, akiben NEM BÍZIK MEG, és aki arra kéri, hogy használja a RustDesket és indítsa el a szolgáltatást, ne folytassa, és azonnal tegye le a telefont."), + ("scam_text2", "Valószínűleg egy csaló próbálja ellopni a pénzét vagy más személyes adatait."), + ("Don't show again", "Ne jelenítse meg újra"), + ("I Agree", "Elfogadás"), + ("Decline", "Elutasítás"), + ("Timeout in minutes", "Időtúllépés percekben"), + ("auto_disconnect_option_tip", "A bejövő munkamenetek automatikus bezárása, ha a felhasználó inaktív"), + ("Connection failed due to inactivity", "A kapcsolat inaktivitás miatt megszakadt"), + ("Check for software update on startup", "Szoftverfrissítés keresése indításkor"), + ("upgrade_rustdesk_server_pro_to_{}_tip", "Frissítse a RustDesk Server Prot a(z) {} vagy újabb verzióra!"), + ("pull_group_failed_tip", "A csoport frissítése nem sikerült"), + ("Filter by intersection", "Szűrés metszéspontok szerint"), + ("Remove wallpaper during incoming sessions", "Háttérkép eltávolítása bejövő munkameneteknél"), + ("Test", "Teszt"), + ("display_is_plugged_out_msg", "A képernyő nincs csatlakoztatva, váltson az első képernyőre."), + ("No displays", "Nincsenek kijelzők"), + ("Open in new window", "Megnyitás új ablakban"), + ("Show displays as individual windows", "Kijelzők megjelenítése egyedi ablakokként"), + ("Use all my displays for the remote session", "Összes kijelző használata a távoli munkamenethez"), + ("selinux_tip", "A SELinux engedélyezve van az eszközén, ami azt okozhatja, hogy a RustDesk nem fut megfelelően, mint ellenőrzött."), + ("Change view", "Nézet módosítása"), + ("Big tiles", "Nagy csempék"), + ("Small tiles", "Kis csempék"), + ("List", "Lista"), + ("Virtual display", "Virtuális kijelző"), + ("Plug out all", "Kapcsolja ki az összeset"), + ("True color (4:4:4)", "Valódi szín (4:4:4)"), + ("Enable blocking user input", "Engedélyezze a felhasználói bevitel blokkolását"), + ("id_input_tip", "Megadhat egy azonosítót, egy közvetlen IP-címet vagy egy tartományt egy porttal (:).\nHa egy másik kiszolgálón lévő eszközhöz szeretne hozzáférni, adja meg a kiszolgáló címét (@?key=), például\n9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=.\nHa egy nyilvános kiszolgálón lévő eszközhöz szeretne hozzáférni, adja meg a „@public” lehetőséget. A kulcsra nincs szükség nyilvános kiszolgálók esetén.\n\nHa az első kapcsolathoz továbbító-kiszolgálón keresztüli kapcsolatot akar kényszeríteni, adja hozzá az „/r” az azonosítót a végén, például „9123456234/r”."), + ("privacy_mode_impl_mag_tip", "1. mód"), + ("privacy_mode_impl_virtual_display_tip", "2. mód"), + ("Enter privacy mode", "Lépjen be az adatvédelmi módba"), + ("Exit privacy mode", "Lépjen ki az adatvédelmi módból"), + ("idd_not_support_under_win10_2004_tip", "A közvetett grafikus illesztőprogram nem támogatott. Windows 10, 2004-es vagy újabb verzió szükséges."), + ("input_source_1_tip", "1. bemeneti forrás"), + ("input_source_2_tip", "2. bemeneti forrás"), + ("Swap control-command key", "Vezérlő- és parancsgombok cseréje"), + ("swap-left-right-mouse", "Bal és jobb egérgomb felcserélése"), + ("2FA code", "2FA kód"), + ("More", "Továbbiak"), + ("enable-2fa-title", "Kétfaktoros hitelesítés aktiválása"), + ("enable-2fa-desc", "Állítsa be a hitelesítőt. Használhat egy hitelesítő alkalmazást, például az Aegis, Authy, a Microsoft- vagy a Google Authenticator alkalmazást a telefonján vagy az asztali számítógépén.\n\nOlvassa be a QR-kódot az alkalmazással, és adja meg az alkalmazás által megjelenített kódot a kétfaktoros hitelesítés aktiválásához."), + ("wrong-2fa-code", "A kód nem ellenőrizhető. Ellenőrizze, hogy a kód és a helyi idő beállításai helyesek-e."), + ("enter-2fa-title", "Kétfaktoros hitelesítés"), + ("Email verification code must be 6 characters.", "Az e-mailben kapott ellenőrző-kódnak 6 karakterből kell állnia."), + ("2FA code must be 6 digits.", "A 2FA-kódnak 6 számjegyűnek kell lennie."), + ("Multiple Windows sessions found", "Több Windows-munkamenet található"), + ("Please select the session you want to connect to", "Válassza ki a munkamenetet, amelyhez kapcsolódni szeretne"), + ("powered_by_me", "Üzemeltető: RustDesk"), + ("outgoing_only_desk_tip", "Ez a RustDesk testre szabott kimenete.\nMás eszközökhöz kapcsolódhat, de más eszközök nem kapcsolódhatnak az Ön eszközéhez."), + ("preset_password_warning", "Ez egy testre szabott kimenet a RustDeskből egy előre beállított jelszóval. Bárki, aki ismeri ezt a jelszót, teljes irányítást szerezhet a készülék felett. Ha nem kívánja ezt megtenni, azonnal távolítsa el ezt a szoftvert."), + ("Security Alert", "Biztonsági riasztás"), + ("My address book", "Saját címjegyzék"), + ("Personal", "Személyes"), + ("Owner", "Tulajdonos"), + ("Set shared password", "Megosztott jelszó beállítása"), + ("Exist in", "Létezik"), + ("Read-only", "Csak olvasható"), + ("Read/Write", "Olvasás/Írás"), + ("Full Control", "Teljes ellenőrzés"), + ("share_warning_tip", "A fenti mezők megosztottak és mások számára is láthatóak."), + ("Everyone", "Mindenki"), + ("ab_web_console_tip", "További információk a webes konzolról"), + ("allow-only-conn-window-open-tip", "Csak akkor engedélyezze a kapcsolódást, ha a RustDesk ablaka nyitva van."), + ("no_need_privacy_mode_no_physical_displays_tip", "Nincsenek fizikai képernyők; Nincs szükség az adatvédelmi üzemmód használatára."), + ("Follow remote cursor", "Kövesse a távoli kurzort"), + ("Follow remote window focus", "Kövesse a távoli ablakfókuszt"), + ("default_proxy_tip", "A szabványos protokoll és port SOCKS5 és 1080"), + ("no_audio_input_device_tip", "Nem található hangbemeneti eszköz."), + ("Incoming", "Bejövő"), + ("Outgoing", "Kimenő"), + ("Clear Wayland screen selection", "Wayland képernyő kiválasztásának törlése"), + ("clear_Wayland_screen_selection_tip", "A képernyőválasztás törlése után újra kiválaszthatja a megosztandó képernyőt."), + ("confirm_clear_Wayland_screen_selection_tip", "Biztosan törölni szeretné a Wayland képernyő kiválasztását?"), + ("android_new_voice_call_tip", "Új hanghívás-kérés érkezett. Ha elfogadja a megkeresést, a hang átvált hangkommunikációra."), + ("texture_render_tip", "Használja a textúra leképezést a képek simábbá tételéhez. Ezt az opciót kikapcsolhatja, ha leképezési problémái vannak."), + ("Use texture rendering", "Textúra leképezés használata"), + ("Floating window", "Lebegő ablak"), + ("floating_window_tip", "Segít, ha a RustDesk a háttérben fut."), + ("Keep screen on", "Tartsa a képernyőt bekapcsolva"), + ("Never", "Soha"), + ("During controlled", "Amikor ellenőrzött"), + ("During service is on", "Amikor a szolgáltatás fut"), + ("Capture screen using DirectX", "Képernyő rögzítése DirectX használatával"), + ("Back", "Vissza"), + ("Apps", "Alkalmazások"), + ("Volume up", "Hangerő fel"), + ("Volume down", "Hangerő le"), + ("Power", "Főkapcsoló"), + ("Telegram bot", "Telegram bot"), + ("enable-bot-tip", "Ha aktiválja ezt a funkciót, akkor a 2FA-kódot a botjától kaphatja meg. Kapcsolati értesítésként is használható."), + ("enable-bot-desc", "1. Nyisson csevegést @BotFather.\n2. Küldje el a „/newbot” parancsot. Miután ezt a lépést elvégezte, kap egy tokent.\n3. Indítson csevegést az újonnan létrehozott botjával. Küldjön egy olyan üzenetet, amely egy perjel („/”) kezdetű, pl. „/hello” az aktiváláshoz.\n"), + ("cancel-2fa-confirm-tip", "Biztosan vissza akarja vonni a 2FA-hitelesítést?"), + ("cancel-bot-confirm-tip", "Biztosan le akarja mondani a Telegram botot?"), + ("About RustDesk", "A RustDesk névjegye"), + ("Send clipboard keystrokes", "Billentyűleütések küldése a vágólapra"), + ("network_error_tip", "Ellenőrizze a hálózati kapcsolatot, majd próbálja meg újra."), + ("Unlock with PIN", "Feloldás PIN-kóddal"), + ("Requires at least {} characters", "Legalább {} karakter szükséges"), + ("Wrong PIN", "Hibás PIN"), + ("Set PIN", "PIN-kód beállítása"), + ("Enable trusted devices", "Megbízható eszközök engedélyezése"), + ("Manage trusted devices", "Megbízható eszközök kezelése"), + ("Platform", "Platform"), + ("Days remaining", "Hátralévő napok"), + ("enable-trusted-devices-tip", "A 2FA-ellenőrzés kihagyása megbízható eszközökön"), + ("Parent directory", "Szülőkönyvtár"), + ("Resume", "Folytatás"), + ("Invalid file name", "Érvénytelen fájlnév"), + ("one-way-file-transfer-tip", "Az egyirányú fájlátvitel engedélyezve van a vezérelt oldalon."), + ("Authentication Required", "Hitelesítés szükséges"), + ("Authenticate", "Hitelesítés"), + ("web_id_input_tip", "Azonos kiszolgálón lévő azonosítót adhat meg, a közvetlen IP elérés nem támogatott a webkliensben.\nHa egy másik kiszolgálón lévő eszközhöz szeretne hozzáférni, adja meg a kiszolgáló címét (@?key=), például\n9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=.\nHa egy nyilvános kiszolgálón lévő eszközhöz szeretne hozzáférni, adja meg az „@public” kulcsot. A kulcsra nincs szükség a nyilvános kiszolgálók esetében."), + ("Download", "Letöltés"), + ("Upload folder", "Mappa feltöltése"), + ("Upload files", "Fájlok feltöltése"), + ("Clipboard is synchronized", "A vágólap szinkronizálva van"), + ("Update client clipboard", "Az ügyfél vágólapjának frissítése"), + ("Untagged", "Címkézetlen"), + ("new-version-of-{}-tip", "A(z) {} új verziója"), + ("Accessible devices", "Hozzáférhető eszközök"), + ("upgrade_remote_rustdesk_client_to_{}_tip", "Frissítse a RustDesk klienst {} vagy újabb verziójára a távoli oldalon!"), + ("d3d_render_tip", "D3D leképezés"), + ("Use D3D rendering", "D3D leképezés használata"), + ("Printer", "Nyomtató"), + ("printer-os-requirement-tip", "Nyomtató operációs rendszerének minimális rendszerkövetelménye"), + ("printer-requires-installed-{}-client-tip", "A nyomtatóhoz szükséges a(z) {} kliens telepítése"), + ("printer-{}-not-installed-tip", "A(z) {} nyomtató nincs telepítve"), + ("printer-{}-ready-tip", "A(z) {} nyomtató készen áll"), + ("Install {} Printer", "A(z) {} nyomtató telepítése"), + ("Outgoing Print Jobs", "Kimenő nyomtatási feladatok"), + ("Incoming Print Jobs", "Bejövő nyomtatási feladatok"), + ("Incoming Print Job", "Bejövő nyomtatási feladat"), + ("use-the-default-printer-tip", "Alapértelmezett nyomtató használata"), + ("use-the-selected-printer-tip", "Kiválasztott nyomtató használata"), + ("auto-print-tip", "Automatikus nyomtatás"), + ("print-incoming-job-confirm-tip", "Bejövő nyomtatási feladat megerősítése"), + ("remote-printing-disallowed-tile-tip", "A távoli nyomtatás nincs engedélyezve"), + ("remote-printing-disallowed-text-tip", "A távoli nyomtatás nincs engedélyezve"), + ("save-settings-tip", "Beállítások mentése"), + ("dont-show-again-tip", "Ne jelenítse meg újra"), + ("Take screenshot", "Képernyőkép készítése"), + ("Taking screenshot", "Képernyőkép készítése..."), + ("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"), + ("Copy to clipboard", "Másolás a vágólapra"), + ("Enable remote printer", "Távoli nyomtatók engedélyezése"), + ("Downloading {}", "{} letöltése"), + ("{} Update", "{} frissítés"), + ("{}-to-update-tip", "{} bezárása és az új verzió telepítése."), + ("download-new-version-failed-tip", "Ha a letöltés sikertelen, akkor vagy újrapróbálkozhat, vagy a „Letöltés” gombra kattintva letöltheti a kiadási oldalról, és manuálisan frissíthet."), + ("Auto update", "Automatikus frissítés"), + ("update-failed-check-msi-tip", "A telepítési módszer felismerése nem sikerült. Kattintson a „Letöltés” gombra, hogy letöltse a kiadási oldalról, és manuálisan frissítse."), + ("websocket_tip", "WebSocket használatakor csak a relé-kapcsolatok támogatottak."), + ("Use WebSocket", "WebSocket használata"), + ("Trackpad speed", "Érintőpad sebessége"), + ("Default trackpad speed", "Alapértelmezett érintőpad sebessége"), + ("Numeric one-time password", "Numerikus, egyszer használatos jelszó"), + ("Enable IPv6 P2P connection", "IPv6 P2P kapcsolat engedélyezése"), + ("Enable UDP hole punching", "UDP résszűrés engedélyezése"), + ("View camera", "Kamera nézet"), + ("Enable camera", "Kamera engedélyezése"), + ("No cameras", "Nincs kamera"), + ("view_camera_unsupported_tip", "A kameranézet nem támogatott"), + ("Terminal", "Terminál"), + ("Enable terminal", "Terminál engedélyezése"), + ("New tab", "Új lap"), + ("Keep terminal sessions on disconnect", "Terminál munkamenetek megtartása leválasztáskor"), + ("Terminal (Run as administrator)", "Terminál (rendszergazdaként futtatva)"), + ("terminal-admin-login-tip", "Adja meg a felügyelt terminál rendszergazdai fiókjának jelszavát."), + ("Failed to get user token.", "Hiba a felhasználói token lekérdezésekor."), + ("Incorrect username or password.", "A felhasználónév vagy a jelszó helytelen."), + ("The user is not an administrator.", "A felhasználó nem rendszergazda."), + ("Failed to check if the user is an administrator.", "Hiba merült fel annak ellenőrzése során, hogy a felhasználó rendszergazda-e."), + ("Supported only in the installed version.", "Csak a telepített változatban támogatott."), + ("elevation_username_tip", "Felhasználónév vagy tartománynév megadása"), + ("Preparing for installation ...", "Felkészülés a telepítésre ..."), + ("Show my cursor", "Kurzor megjelenítése"), + ("Scale custom", "Egyéni méretarány"), + ("Custom scale slider", "Egyéni méretarány-csúszka"), + ("Decrease", "Csökkentés"), + ("Increase", "Növelés"), + ("Show virtual mouse", "Virtuális egér megjelenítése"), + ("Virtual mouse size", "Virtuális egér mérete"), + ("Small", "Kicsi"), + ("Large", "Nagy"), + ("Show virtual joystick", "Virtuális vezérlő megjelenítése"), + ("Edit note", "Megjegyzés szerkesztése"), + ("Alias", "Álnév"), + ("ScrollEdge", "Görgetés az ablak szélein"), + ("Allow insecure TLS fallback", "Nem biztonságos TLS-tartalék engedélyezése"), + ("allow-insecure-tls-fallback-tip", "Alapértelmezés szerint a RustDesk ellenőrzi a kiszolgáló tanúsítványát a TLS-protokollok esetében. Ha ez a beállítás engedélyezve van, a RustDesk kihagyja az ellenőrzési lépést, és az ellenőrzés sikertelensége esetén folytatja a műveletet."), + ("Disable UDP", "UDP letiltása"), + ("disable-udp-tip", "Meghatározza, hogy csak TCP-t használjon-e. Ha ez az beállítás engedélyezve van, a RustDesk nem fogja többé használni a 21116-os UDP-portot, helyette a 21116-os TCP-portot fogja használni."), + ("server-oss-not-support-tip", "MEGJEGYZÉS: Az OSS RustDesk kiszolgáló nem támogatja ezt a funkciót."), + ("input note here", "Megjegyzés beírása"), + ("note-at-conn-end-tip", "Kérjen megjegyzést a kapcsolat végén"), + ("Show terminal extra keys", "További terminálgombok megjelenítése"), + ("Relative mouse mode", "Relatív egér mód"), + ("rel-mouse-not-supported-peer-tip", "A kapcsolódott partner nem támogatja a relatív egér módot."), + ("rel-mouse-not-ready-tip", "A relatív egér mód még nem elérhető. Próbálja meg újra."), + ("rel-mouse-lock-failed-tip", "Nem sikerült zárolni a kurzort. A relatív egér mód le lett tiltva."), + ("rel-mouse-exit-{}-tip", "A kilépéshez nyomja meg a következő gombot: {}"), + ("rel-mouse-permission-lost-tip", "A billentyűzet-hozzáférés vissza lett vonva. A relatív egér mód le lett tilva."), + ("Changelog", "Változáslista"), + ("keep-awake-during-outgoing-sessions-label", "Képernyő aktív állapotban tartása a kimenő munkamenetek során"), + ("keep-awake-during-incoming-sessions-label", "Képernyő aktív állapotban tartása a bejövő munkamenetek során"), + ("Continue with {}", "Folytatás ezzel: {}"), + ("Display Name", "Kijelző név"), + ("password-hidden-tip", "Állandó jelszó lett beállítva (rejtett)."), + ("preset-password-in-use-tip", "Jelenleg az alapértelmezett jelszót használja."), + ("Enable privacy mode", "Adatvédelmi mód aktiválása"), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/id.rs b/src/lang/id.rs index 066f2980c..bcda0a3a8 100644 --- a/src/lang/id.rs +++ b/src/lang/id.rs @@ -2,18 +2,18 @@ lazy_static::lazy_static! { pub static ref T: std::collections::HashMap<&'static str, &'static str> = [ ("Status", "Status"), - ("Your Desktop", "Layar Utama"), - ("desk_tip", "Layar kamu dapat diakses dengan ID dan kata sandi ini."), + ("Your Desktop", "Desktop Anda"), + ("desk_tip", "Akses desktop kamu dengan ID & Kata sandi ini"), ("Password", "Kata sandi"), ("Ready", "Sudah siap"), ("Established", "Didirikan"), - ("connecting_status", "Menghubungkan ke jaringan RustDesk..."), + ("connecting_status", "Menghubungkan ke RustDesk..."), ("Enable service", "Aktifkan Layanan"), ("Start service", "Mulai Layanan"), ("Service is running", "Layanan berjalan"), ("Service is not running", "Layanan tidak berjalan"), ("not_ready_status", "Belum siap digunakan. Silakan periksa koneksi"), - ("Control Remote Desktop", "Kontrol PC dari jarak jauh"), + ("Control Remote Desktop", "Lakukan Kontrol PC dari jarak jauh"), ("Transfer file", "Transfer File"), ("Connect", "Sambungkan"), ("Recent sessions", "Sesi Terkini"), @@ -41,7 +41,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("length %min% to %max%", "panjang %min% s/d %max%"), ("starts with a letter", "Dimulai dengan huruf"), ("allowed characters", "Karakter yang dapat digunakan"), - ("id_change_tip", "Hanya karakter a-z, A-Z, 0-9 dan _ (underscore) yang diperbolehkan. Huruf pertama harus a-z, A-Z. Panjang antara 6 dan 16."), + ("id_change_tip", "Hanya karakter a-z, A-Z, 0-9, - (dash) dan _ (underscore) yang diperbolehkan. Huruf pertama harus a-z, A-Z. Panjang antara 6 dan 16."), ("Website", "Situs Web"), ("About", "Tentang"), ("Slogan_tip", "Dibuat dengan penuh kasih sayang dalam dunia yang penuh kekacauan ini"), @@ -147,8 +147,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("OS Password", "Kata Sandi OS"), ("install_tip", "Karena UAC, RustDesk tidak dapat bekerja dengan baik sebagai sisi remote dalam beberapa kasus. Untuk menghindari UAC, silakan klik tombol di bawah ini untuk menginstal RustDesk ke sistem."), ("Click to upgrade", "Klik untuk upgrade"), - ("Click to download", "Klik untuk unduh"), - ("Click to update", "Klik untuk memperbarui"), ("Configure", "Konfigurasi"), ("config_acc", "Agar bisa mengontrol Desktopmu dari jarak jauh, Kamu harus memberikan izin \"Aksesibilitas\" untuk RustDesk."), ("config_screen", "Agar bisa mengakses Desktopmu dari jarak jauh, kamu harus memberikan izin \"Perekaman Layar\" untuk RustDesk."), @@ -167,7 +165,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Listening ...", "Menghubungkan..."), ("Remote Host", "Host Remote"), ("Remote Port", "Port Remote"), - ("Action", "Aksi"), + ("Action", "Tindakan"), ("Add", "Tambah"), ("Local Port", "Port Lokal"), ("Local Address", "Alamat lokal"), @@ -246,7 +244,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Paste", "Tempel"), ("Paste here?", "Tempel disini?"), ("Are you sure to close the connection?", "Apakah kamu yakin akan menutup koneksi?"), - ("Download new version", "Unduh versi baru"), + ("Download new version", "Download versi baru"), ("Touch mode", "Mode Layar Sentuh"), ("Mouse mode", "Mode Mouse"), ("One-Finger Tap", "Ketuk Satu Jari"), @@ -267,7 +265,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("No permission of file transfer", "Tidak ada izin untuk mengirim file"), ("Note", "Catatan"), ("Connection", "Koneksi"), - ("Share Screen", "Bagikan Layar"), + ("Share screen", "Bagikan Layar"), ("Chat", "Obrolan"), ("Total", "Total"), ("items", "item"), @@ -275,12 +273,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Screen Capture", "Tangkapan Layar"), ("Input Control", "Kontrol input"), ("Audio Capture", "Rekam Suara"), - ("File Connection", "Koneksi File"), - ("Screen Connection", "Koneksi layar"), - ("Do you accept?", "Apakah anda setuju?"), + ("Do you accept?", "Apakah kamu setuju?"), ("Open System Setting", "Buka Pengaturan Sistem"), ("How to get Android input permission?", "Bagaimana cara mendapatkan izin input dari Android?"), - ("android_input_permission_tip1", "Agar perangkat jarak jauh dapat mengontrol perangkat Android Anda melalui mouse atau sentuhan, Anda harus mengizinkan RustDesk untuk menggunakan layanan \"Aksesibilitas\"."), + ("android_input_permission_tip1", "Agar perangkat jarak jauh dapat mengontrol perangkat Android melalui mouse atau sentuhan, Kamu harus memberikan izin/permission kd RustDesk untuk menggunakan layanan \"Aksesibilitas\"."), ("android_input_permission_tip2", "Silakan buka halaman pengaturan sistem berikutnya, temukan dan masuk ke [Layanan Terinstal], aktifkan layanan [Input RustDesk]."), ("android_new_connection_tip", "Permintaan akses remote telah diterima"), ("android_service_will_start_tip", "Mengaktifkan \"Tangkapan Layar\" akan memulai secara otomatis, memungkinkan perangkat lain untuk meminta koneksi ke perangkat Anda."), @@ -381,14 +377,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Keyboard Settings", "Pengaturan Papan Ketik"), ("Full Access", "Akses penuh"), ("Screen Share", "Berbagi Layar"), - ("Wayland requires Ubuntu 21.04 or higher version.", "Wayland membutuhkan Ubuntu 21.04 atau versi yang lebih tinggi."), - ("Wayland requires higher version of linux distro. Please try X11 desktop or change your OS.", "Wayland membutuhkan versi distro linux yang lebih tinggi. Silakan coba desktop X11 atau ubah OS Anda."), + ("ubuntu-21-04-required", "Wayland membutuhkan Ubuntu 21.04 atau versi yang lebih tinggi."), + ("wayland-requires-higher-linux-version", "Wayland membutuhkan versi distro linux yang lebih tinggi. Silakan coba desktop X11 atau ubah OS Anda."), + ("xdp-portal-unavailable", ""), ("JumpLink", "Tautan Cepat"), ("Please Select the screen to be shared(Operate on the peer side).", "Silakan Pilih layar yang akan dibagikan kepada rekan anda."), ("Show RustDesk", "Tampilkan RustDesk"), ("This PC", "PC ini"), ("or", "atau"), - ("Continue with", "Lanjutkan dengan"), ("Elevate", "Elevasi"), ("Zoom cursor", "Perbersar Kursor"), ("Accept sessions via password", "Izinkan sesi dengan kata sandi"), @@ -463,7 +459,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("empty_favorite_tip", "Belum ada rekan favorit?\nTemukan seseorang untuk terhubung dan tambahkan ke favorit!"), ("empty_lan_tip", "Sepertinya kami belum memiliki rekan"), ("empty_address_book_tip", "Tampaknya saat ini tidak ada rekan yang terdaftar dalam buku alamat Anda"), - ("eg: admin", "contoh: admin"), ("Empty Username", "Nama pengguna kosong"), ("Empty Password", "Kata sandi kosong"), ("Me", "Saya"), @@ -625,7 +620,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Volume up", "Naikkan volume"), ("Volume down", "Turunkan volume"), ("Power", ""), - ("Telegram bot", ""), + ("Telegram bot", "Bot Telegram"), ("enable-bot-tip", "Jika fitur ini diaktifkan, Kamu dapat menerima kode 2FA dari bot, serta mendapatkan notifikasi tentang koneksi."), ("enable-bot-desc", "1. Buka chat dengan @BotFather.\n2. Kirim perintah \"/newbot\". Setelah menyelesaikan langkah ini, Kamu akan mendapatkan token\n3. Mulai percakapan dengan bot yang baru dibuat. Kirim pesan yang dimulai dengan garis miring (\"/\") seperti \"/hello\" untuk mengaktifkannya."), ("cancel-2fa-confirm-tip", "Apakah Kamu yakin ingin membatalkan 2FA?"), @@ -649,9 +644,106 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Authentication Required", "Diperlukan autentikasi"), ("Authenticate", "Autentikasi"), ("web_id_input_tip", "Kamu bisa memasukkan ID pada server yang sama, akses IP langsung tidak didukung di klien web.\nJika Anda ingin mengakses perangkat di server lain, silakan tambahkan alamat server (@?key=), contohnya:\n9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=.\nUntuk mengakses perangkat di server publik, cukup masukkan \"@public\", tanpa kunci/key."), - ("Download", ""), - ("Upload folder", ""), - ("Upload files", ""), - ("Clipboard is synchronized", ""), + ("Download", "Download"), + ("Upload folder", "Upload folder"), + ("Upload files", "Upload file"), + ("Clipboard is synchronized", "Clipboard disinkronisasi"), + ("Update client clipboard", ""), + ("Untagged", ""), + ("new-version-of-{}-tip", "Versi {} sudah tersedia."), + ("Accessible devices", "Perangkat yang tersedia"), + ("upgrade_remote_rustdesk_client_to_{}_tip", "Silahkan perbarui aplikasi RustDesk ke versi {} atau yang lebih baru pada komputer yang akan terhubung!"), + ("d3d_render_tip", "Ketika rendering D3D diaktifkan, layar kontrol jarak jauh bisa tampak hitam di beberapa komputer"), + ("Use D3D rendering", ""), + ("Printer", ""), + ("printer-os-requirement-tip", ""), + ("printer-requires-installed-{}-client-tip", ""), + ("printer-{}-not-installed-tip", "Printer {} tidak terinstal"), + ("printer-{}-ready-tip", "Printer {} sudah terinstal dan siap digunakan."), + ("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 tidak diizinkan"), + ("remote-printing-disallowed-text-tip", "Komputer yang diakses tidak mengizinkan Remote Printing."), + ("save-settings-tip", "Simpan pengaturan"), + ("dont-show-again-tip", "Jangan tampilkan lagi"), + ("Take screenshot", "Ambil tangkapan layar"), + ("Taking screenshot", "Mengambil tangkapan layar"), + ("screenshot-merged-screen-not-supported-tip", ""), + ("screenshot-action-tip", ""), + ("Save as", "Simpan sebagai"), + ("Copy to clipboard", "Salin ke papan klip"), + ("Enable remote printer", "Aktifkan printer jarak jauh"), + ("Downloading {}", "Mendownload {}"), + ("{} Update", "Perbarui {}"), + ("{}-to-update-tip", "{} akan ditutup dan menginstal versi baru"), + ("download-new-version-failed-tip", "Gagal mendownload. Kamu bisa mencoba lagi nanti atau klik tombol \"Download\" melakukan download dari halaman rilis dan meningkatkan versi secara manual."), + ("Auto update", "Pembaruan otomatis"), + ("update-failed-check-msi-tip", ""), + ("websocket_tip", ""), + ("Use WebSocket", "Gunakan WebSocket"), + ("Trackpad speed", "Kecepatan trackpad"), + ("Default trackpad speed", "Kecepatan default trackpad"), + ("Numeric one-time password", "Kata sandi sekali pakai numerik"), + ("Enable IPv6 P2P connection", "Aktifkan koneksi P2P IPv6"), + ("Enable UDP hole punching", "Aktifkan UDP hole punching"), + ("View camera", "Lihat Kamera"), + ("Enable camera", "Aktifkan kamera"), + ("No cameras", "Tidak ada kamera"), + ("view_camera_unsupported_tip", "Perangkat yang terhubung tidak mendukung tampilan kamera."), + ("Terminal", "Terminal"), + ("Enable terminal", "Aktifkan terminal"), + ("New tab", "Tab baru"), + ("Keep terminal sessions on disconnect", "Pertahankan sesi terminal saat terputus"), + ("Terminal (Run as administrator)", "Terminal (Jalankan sebagai administrator)"), + ("terminal-admin-login-tip", ""), + ("Failed to get user token.", "Gagal mendapatkan token pengguna."), + ("Incorrect username or password.", "Nama pengguna atau kata sandi salah."), + ("The user is not an administrator.", "Pengguna bukanlah administrator."), + ("Failed to check if the user is an administrator.", "Gagal memeriksa apakah pengguna adalah administrator."), + ("Supported only in the installed version.", "Hanya didukung pada versi yang terinstal."), + ("elevation_username_tip", "panduan_elevasi_nama_pengguna"), + ("Preparing for installation ...", "Mempersiapkan instalasi ..."), + ("Show my cursor", "Tampilkan kursor saya"), + ("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", ""), + ("allow-insecure-tls-fallback-tip", ""), + ("Disable 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 {}", "Lanjutkan dengan {}"), + ("Display Name", ""), + ("password-hidden-tip", ""), + ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/it.rs b/src/lang/it.rs index e0dd83db6..a5132e027 100644 --- a/src/lang/it.rs +++ b/src/lang/it.rs @@ -31,8 +31,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("ID/Relay Server", "Server ID/Relay"), ("Import server config", "Importa configurazione server dagli appunti"), ("Export Server Config", "Esporta configurazione server negli appunti"), - ("Import server configuration successfully", "Configurazione server importata completata"), - ("Export server configuration successfully", "Configurazione Server esportata completata"), + ("Import server configuration successfully", "Configurazione server importata con successo"), + ("Export server configuration successfully", "Configurazione Server esportata con successo"), ("Invalid server configuration", "Configurazione server non valida"), ("Clipboard is empty", "Gli appunti sono vuoti"), ("Stop service", "Arresta servizio"), @@ -41,7 +41,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("length %min% to %max%", "lunghezza da %min% a %max%"), ("starts with a letter", "inizia con una lettera"), ("allowed characters", "caratteri consentiti"), - ("id_change_tip", "Puoi usare solo i caratteri a-z, A-Z, 0-9 e _ (sottolineato).\nIl primo carattere deve essere a-z o A-Z.\nLa lunghezza deve essere fra 6 e 16 caratteri."), + ("id_change_tip", "Puoi usare solo i caratteri a-z, A-Z, 0-9, - (dash) e _ (sottolineato).\nIl primo carattere deve essere a-z o A-Z.\nLa lunghezza deve essere fra 6 e 16 caratteri."), ("Website", "Sito web programma"), ("About", "Info programma"), ("Slogan_tip", "Realizzato con il cuore in questo mondo caotico!"), @@ -102,10 +102,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Unselect All", "Deseleziona tutto"), ("Empty Directory", "Cartella vuota"), ("Not an empty directory", "Non è una cartella vuota"), - ("Are you sure you want to delete this file?", "Sei sicuro di voler eliminare questo file?"), - ("Are you sure you want to delete this empty directory?", "Sei sicuro di voler eliminare questa cartella vuota?"), - ("Are you sure you want to delete the file of this directory?", "Sei sicuro di voler eliminare il file di questa cartella?"), - ("Do this for all conflicts", "Ricorca questa scelta per tutti i conflitti"), + ("Are you sure you want to delete this file?", "Vuoi eliminare questo file?"), + ("Are you sure you want to delete this empty directory?", "Vuoi eliminare questa cartella vuota?"), + ("Are you sure you want to delete the file of this directory?", "Vuoi eliminare il file di questa cartella?"), + ("Do this for all conflicts", "Ricorda questa scelta per tutti i conflitti"), ("This is irreversible!", "Questo è irreversibile!"), ("Deleting", "Eliminazione di"), ("files", "file"), @@ -147,8 +147,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("OS Password", "Password sistema operativo"), ("install_tip", "A causa del Controllo Account Utente (UAC), RustDesk potrebbe non funzionare correttamente come desktop remoto.\nPer evitare questo problema, fai clic sul tasto qui sotto per installare RustDesk a livello di sistema."), ("Click to upgrade", "Aggiorna"), - ("Click to download", "Download"), - ("Click to update", "Aggiorna"), ("Configure", "Configura"), ("config_acc", "Per controllare il desktop dall'esterno, devi fornire a RustDesk il permesso 'Accessibilità'."), ("config_screen", "Per controllare il desktop dall'esterno, devi fornire a RustDesk il permesso 'Registrazione schermo'."), @@ -226,7 +224,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Add ID", "Aggiungi ID"), ("Add Tag", "Aggiungi etichetta"), ("Unselect all tags", "Deseleziona tutte le etichette"), - ("Network error", "Errore rete"), + ("Network error", "Errore di rete"), ("Username missed", "Nome utente mancante"), ("Password missed", "Password mancante"), ("Wrong credentials", "Credenziali errate"), @@ -245,7 +243,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Remote ID", "ID remoto"), ("Paste", "Incolla"), ("Paste here?", "Incollare qui?"), - ("Are you sure to close the connection?", "Sei sicuro di voler chiudere la connessione?"), + ("Are you sure to close the connection?", "Vuoi chiudere la connessione?"), ("Download new version", "Scarica nuova versione"), ("Touch mode", "Modalità tocco"), ("Mouse mode", "Modalità mouse"), @@ -267,7 +265,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("No permission of file transfer", "Nessun permesso per il trasferimento file"), ("Note", "Nota"), ("Connection", "Connessione"), - ("Share Screen", "Condividi schermo"), + ("Share screen", "Condividi schermo"), ("Chat", "Chat"), ("Total", "Totale"), ("items", "Oggetti"), @@ -275,8 +273,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Screen Capture", "Cattura schermo"), ("Input Control", "Controllo input"), ("Audio Capture", "Acquisizione audio"), - ("File Connection", "Connessione file"), - ("Screen Connection", "Connessione schermo"), ("Do you accept?", "Accetti?"), ("Open System Setting", "Apri impostazioni di sistema"), ("How to get Android input permission?", "Come ottenere l'autorizzazione input in Android?"), @@ -297,7 +293,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Succeeded", "Completato"), ("Someone turns on privacy mode, exit", "Qualcuno ha attivato la modalità privacy, uscita"), ("Unsupported", "Non supportato"), - ("Peer denied", "Acvesso negato al dispositivo remoto"), + ("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"), @@ -317,7 +313,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Set permanent password", "Imposta password permanente"), ("Enable remote restart", "Abilita riavvio da remoto"), ("Restart remote device", "Riavvia dispositivo remoto"), - ("Are you sure you want to restart", "Sei sicuro di voler riavviare?"), + ("Are you sure you want to restart", "Vuoi riavviare?"), ("Restarting remote device", "Il dispositivo remoto si sta riavviando"), ("remote_restarting_tip", "Riavvia il dispositivo remoto"), ("Copied", "Copiato"), @@ -381,14 +377,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Keyboard Settings", "Impostazioni tastiera"), ("Full Access", "Accesso completo"), ("Screen Share", "Condivisione schermo"), - ("Wayland requires Ubuntu 21.04 or higher version.", "Wayland richiede Ubuntu 21.04 o versione successiva."), - ("Wayland requires higher version of linux distro. Please try X11 desktop or change your OS.", "Wayland richiede una versione superiore della distribuzione Linux.\nProva X11 desktop o cambia il sistema operativo."), + ("ubuntu-21-04-required", "Wayland richiede Ubuntu 21.04 o versione successiva."), + ("wayland-requires-higher-linux-version", "Wayland richiede una versione superiore della distribuzione Linux.\nProva X11 desktop o cambia il sistema operativo."), + ("xdp-portal-unavailable", "Acquisizione dello schermo di Wayland non riuscita. Il portale desktop XDG potrebbe essersi bloccato o non essere disponibile. Prova a riavviarlo con `systemctl --user restart xdg-desktop-portal`."), ("JumpLink", "Vai a"), ("Please Select the screen to be shared(Operate on the peer side).", "Seleziona lo schermo da condividere (opera sul lato dispositivo remoto)."), ("Show RustDesk", "Visualizza RustDesk"), ("This PC", "Questo PC"), ("or", "O"), - ("Continue with", "Continua con"), ("Elevate", "Eleva"), ("Zoom cursor", "Cursore zoom"), ("Accept sessions via password", "Accetta sessioni via password"), @@ -463,7 +459,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("empty_favorite_tip", "Ancora nessuna connessione?\nTrova qualcuno con cui connetterti e aggiungilo ai preferiti!"), ("empty_lan_tip", "Sembra proprio che non sia stata rilevata nessuna connessione."), ("empty_address_book_tip", "Sembra che per ora nella rubrica non ci siano connessioni."), - ("eg: admin", "es: admin"), ("Empty Username", "Nome utente vuoto"), ("Empty Password", "Password vuota"), ("Me", "Io"), @@ -479,7 +474,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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 c'è nessun envorinment desktop disponibile."), + ("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"), @@ -507,7 +502,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Outgoing connection", "Connessioni in uscita"), ("Exit", "Esci da RustDesk"), ("Open", "Apri RustDesk"), - ("logout_tip", "Sei sicuro di voler uscire?"), + ("logout_tip", "Vuoi disconnetterti?"), ("Service", "Servizio"), ("Start", "Avvia"), ("Stop", "Ferma"), @@ -609,12 +604,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Outgoing", "In uscita"), ("Clear Wayland screen selection", "Annulla selezione schermata Wayland"), ("clear_Wayland_screen_selection_tip", "Dopo aver annullato la selezione schermo, è possibile selezionare nuovamente lo schermo da condividere."), - ("confirm_clear_Wayland_screen_selection_tip", "Sei sicuro di voler annullare la selezione schermo Wayland?"), + ("confirm_clear_Wayland_screen_selection_tip", "Vuoi annullare la selezione schermo Wayland?"), ("android_new_voice_call_tip", "È stata ricevuta una nuova richiesta di chiamata vocale. Se accetti, l'audio passerà alla comunicazione vocale."), ("texture_render_tip", "Usa il rendering texture per rendere le immagini più fluide. Se riscontri problemi di rendering prova a disabilitare questa opzione."), ("Use texture rendering", "Usa rendering texture"), ("Floating window", "Finestra galleggiante"), - ("floating_window_tip", "It helps to keep RustDesk background service"), + ("floating_window_tip", "Aiuta a mantenere il servizio Rustdesk in background."), ("Keep screen on", "Mantieni schermo acceso"), ("Never", "Mai"), ("During controlled", "Durante il controllo"), @@ -625,11 +620,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Volume up", "Volume +"), ("Volume down", "Volume -"), ("Power", "Alimentazione"), - ("Telegram bot", "Bot Telgram"), + ("Telegram bot", "Bot Telegram"), ("enable-bot-tip", "Se abiliti questa funzione, puoi ricevere il codice 2FA dal tuo bot.\nPuò anche funzionare come notifica di connessione."), ("enable-bot-desc", "1. apri una chat con @BotFather.\n2. Invia il comando \"/newbot\", dopo aver completato questo passaggio riceverai un token.\n3. Avvia una chat con il tuo bot appena creato. Per attivarlo Invia un messaggio che inizia con una barra (\"/\") tipo \"/hello\".\n"), - ("cancel-2fa-confirm-tip", "Sei sicuro di voler annullare 2FA?"), - ("cancel-bot-confirm-tip", "Sei sicuro di voler annullare Telegram?"), + ("cancel-2fa-confirm-tip", "Vuoi disabilitare 2FA?"), + ("cancel-bot-confirm-tip", "Vuoi disabilitare il bot Telegram?"), ("About RustDesk", "Info su RustDesk"), ("Send clipboard keystrokes", "Invia sequenze tasti appunti"), ("network_error_tip", "Controlla la connessione di rete, quindi seleziona 'Riprova'."), @@ -653,5 +648,102 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Upload folder", "Cartella upload"), ("Upload files", "File upload"), ("Clipboard is synchronized", "Gli appunti sono sincronizzati"), + ("Update client clipboard", "Aggiorna appunti client"), + ("Untagged", "Senza tag"), + ("new-version-of-{}-tip", "È disponibile una nuova versione di {}"), + ("Accessible devices", "Dispositivi accessibili"), + ("upgrade_remote_rustdesk_client_to_{}_tip", "Aggiorna il client RustDesk remoto alla versione {} o successiva!"), + ("d3d_render_tip", "Quando è abilitato il rendering D3D, in alcuni computer la schermata del telecomando potrebbe essere nera."), + ("Use D3D rendering", "Usa rendering D3D"), + ("Printer", "Stampante"), + ("printer-os-requirement-tip", "La funzione della stampante richiede Windows 10 o superiore."), + ("printer-requires-installed-{}-client-tip", "Per usare la stampa remota, {} è necessario installare il programma nel dispositivo."), + ("printer-{}-not-installed-tip", "La stampante {} non è installata."), + ("printer-{}-ready-tip", "La stampante {} è installata e pronta all'uso."), + ("Install {} Printer", "Installa la stampante {}"), + ("Outgoing Print Jobs", "Lavori di stampa in uscita"), + ("Incoming Print Jobs", "Lavori di stampa in entrata"), + ("Incoming Print Job", "Lavoro di stampa in entrata"), + ("use-the-default-printer-tip", "Usa la stampante predefinita"), + ("use-the-selected-printer-tip", "Usa la stampante selezionata"), + ("auto-print-tip", "Stampa usando automaticamente la stampante selezionata."), + ("print-incoming-job-confirm-tip", "Hai ricevuto un lavoro di stampa da remoto. Vuoi eseguirlo sul desktop?"), + ("remote-printing-disallowed-tile-tip", "Stampa remota disabilitata"), + ("remote-printing-disallowed-text-tip", "Le impostazioni di autorizzazione del lato controllato negano la stampa remota."), + ("save-settings-tip", "Salva impostazioni"), + ("dont-show-again-tip", "Non visualizzare più questo messaggio"), + ("Take screenshot", "Cattura schermata"), + ("Taking screenshot", "Cattura schermata"), + ("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"), + ("Copy to clipboard", "Copia negli appunti"), + ("Enable remote printer", "Abilita stampante remota"), + ("Downloading {}", "Download {}"), + ("{} Update", "Aggiorna {}"), + ("{}-to-update-tip", "{} si chiuderà e installerà la nuova versione"), + ("download-new-version-failed-tip", "Download non riuscito.\nÈ possibile riprovare o selezionare 'Download' per scaricare e aggiornarlo manualmente."), + ("Auto update", "Aggiornamento automatico"), + ("update-failed-check-msi-tip", "Controllo metodo installazione non riuscito.\nSeleziona 'Download' per scaricare il programma e aggiornarlo manualmente."), + ("websocket_tip", "Quando usi WebSocket, sono supportate solo le connessioni relay."), + ("Use WebSocket", "Usa WebSocket"), + ("Trackpad speed", "Velocità trackpad"), + ("Default trackpad speed", "Velocità predefinita trackpad"), + ("Numeric one-time password", "Password numerica monouso"), + ("Enable IPv6 P2P connection", "Abilita connessione P2P IPv6"), + ("Enable UDP hole punching", "Abilita hole punching UDP"), + ("View camera", "Visualizza telecamera"), + ("Enable camera", "Abilita camera"), + ("No cameras", "Nessuna camera"), + ("view_camera_unsupported_tip", "Il dispositivo remoto non supporta la visualizzazione della camera."), + ("Terminal", "Terminale"), + ("Enable terminal", "Abilita terminale"), + ("New tab", "Nuova scheda"), + ("Keep terminal sessions on disconnect", "Quando disconetti mantieni attiva sessione terminale"), + ("Terminal (Run as administrator)", "Terminale (esegui come amministratore)"), + ("terminal-admin-login-tip", "Inserisci il nome utente e la password dell'amministratore del lato controllato."), + ("Failed to get user token.", "Impossibile ottenere il token utente."), + ("Incorrect username or password.", "Nome utente o password non corretti."), + ("The user is not an administrator.", "L'utente non è un amministratore."), + ("Failed to check if the user is an administrator.", "Impossibile verificare se l'utente è un amministratore."), + ("Supported only in the installed version.", "Supportato solo nella versione installata."), + ("elevation_username_tip", "Inserisci Nome utente o dominio sorgente\\nome Utente"), + ("Preparing for installation ...", "Preparazione installazione..."), + ("Show my cursor", "Visualizza il mio cursore"), + ("Scale custom", "Scala personalizzata"), + ("Custom scale slider", "Cursore scala personalizzata"), + ("Decrease", "Diminuisci"), + ("Increase", "Aumenta"), + ("Show virtual mouse", "Visualizza mouse virtuale"), + ("Virtual mouse size", "Dimensione mouse virtuale"), + ("Small", "Piccola"), + ("Large", "Grande"), + ("Show virtual joystick", "Visualizza joystick virtuale"), + ("Edit note", "Modifica nota"), + ("Alias", "Alias"), + ("ScrollEdge", "Bordo scorrimento"), + ("Allow insecure TLS fallback", "Consenti fallback TLS non sicuro"), + ("allow-insecure-tls-fallback-tip", "Per impostazione predefinita, RustDesk verifica il certificato del server per i protocolli usando TLS.\nCon questa opzione abilitata, RustDesk salterà il passaggio di verifica e procederà in caso di errore di verifica."), + ("Disable UDP", "Disabilita UDP"), + ("disable-udp-tip", "Controlla se usare solo TCP.\nQuando questa opzione è abilitata, RustDesk non userà più UDP 21116, verrà invece usato TCP 21116."), + ("server-oss-not-support-tip", "Nota: il sistema operativo del server RustDesk non include questa funzionalità."), + ("input note here", "Inserisci nota qui"), + ("note-at-conn-end-tip", "Visualizza nota alla fine della connessione"), + ("Show terminal extra keys", "Visualizza tasti aggiuntivi terminale"), + ("Relative mouse mode", "Modalità relativa mouse"), + ("rel-mouse-not-supported-peer-tip", "La modalità mouse relativa non è supportata dal peer connesso."), + ("rel-mouse-not-ready-tip", "La modalità mouse relativa non è ancora pronta. Riprova."), + ("rel-mouse-lock-failed-tip", "Impossibile bloccare il cursore. La modalità mouse relativa è stata disabilitata."), + ("rel-mouse-exit-{}-tip", "Premi {} per uscire."), + ("rel-mouse-permission-lost-tip", "È stata revocato l'accesso alla tastiera. La modalità mouse relativa è stata disabilitata."), + ("Changelog", "Novità programma"), + ("keep-awake-during-outgoing-sessions-label", "Mantieni lo schermo attivo durante le sessioni in uscita"), + ("keep-awake-during-incoming-sessions-label", "Mantieni lo schermo attivo durante le sessioni in ingresso"), + ("Continue with {}", "Continua con {}"), + ("Display Name", "Visualizza nome"), + ("password-hidden-tip", "È impostata una password permanente (nascosta)."), + ("preset-password-in-use-tip", "È attualmente in uso la password preimpostata."), + ("Enable privacy mode", "Abilita modalità privacy"), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ja.rs b/src/lang/ja.rs index 5edd50572..2879e86bf 100644 --- a/src/lang/ja.rs +++ b/src/lang/ja.rs @@ -3,31 +3,31 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = [ ("Status", "状態"), ("Your Desktop", "あなたのコンピューター"), - ("desk_tip", "下記のIDとパスワードであなたのコンピューターにアクセスできます。"), + ("desk_tip", "下記の ID とパスワードでこのコンピューターにアクセスできます。"), ("Password", "パスワード"), ("Ready", "準備完了"), ("Established", "接続完了"), - ("connecting_status", "RuskDeskネットワークに接続中..."), - ("Enable service", "サービスを有効化"), + ("connecting_status", "RustDesk ネットワークに接続中..."), + ("Enable service", "サービスを有効化する"), ("Start service", "サービスを開始"), ("Service is running", "サービスが実行されています"), ("Service is not running", "サービスは停止しています"), ("not_ready_status", "接続できません。ネットワーク接続を確認してください"), - ("Control Remote Desktop", "リモートコンピューターを操作"), - ("Transfer file", "ファイル転送"), + ("Control Remote Desktop", "リモートデスクトップを操作"), + ("Transfer file", "ファイルを転送"), ("Connect", "接続"), ("Recent sessions", "最近のセッション"), ("Address book", "アドレス帳"), ("Confirmation", "確認"), - ("TCP tunneling", "TCPトンネリング"), + ("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ホワイトリスト"), + ("Enable keyboard/mouse", "キーボード/マウスを有効化する"), + ("Enable clipboard", "クリップボードを有効化する"), + ("Enable file transfer", "ファイル転送を有効化する"), + ("Enable TCP tunneling", "TCP トンネリングを有効化する"), + ("IP Whitelisting", "IP ホワイトリスト"), ("ID/Relay Server", "認証/中継サーバー"), ("Import server config", "サーバー設定をインポート"), ("Export Server Config", "サーバー設定をエクスポート"), @@ -36,30 +36,30 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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", "使用できるのは大文字・小文字のアルファベット、数字、アンダースコア(_)のみです。先頭の文字はアルファベット、長さは6文字から16文字である必要があります。"), + ("Change ID", "ID を変更"), + ("Your new ID", "新しい ID"), + ("length %min% to %max%", "%min%~%max% 文字の長さ"), + ("starts with a letter", "アルファベットで始まる"), + ("allowed characters", "使用可能な文字"), + ("id_change_tip", "使用できるのは大文字・小文字のアルファベット、数字、アンダースコア (_) のみです。先頭の文字はアルファベット、長さは 6 文字から 16 文字である必要があります。"), ("Website", "公式サイト"), - ("About", "RustDeskについて"), + ("About", "RustDesk について"), ("Slogan_tip", "この混沌とした世界から、愛をこめて!"), ("Privacy Statement", "プライバシーポリシー"), ("Mute", "ミュート"), ("Build Date", "ビルド日時"), ("Version", "バージョン"), ("Home", "ホーム"), - ("Audio Input", "音声入力"), + ("Audio Input", "オーディオ入力"), ("Enhancements", "拡張機能"), ("Hardware Codec", "ハードウェアコーデック"), - ("Adaptive bitrate", "可変ビットレート"), + ("Adaptive bitrate", "可変ビットレートを使用する"), ("ID Server", "認証サーバー"), ("Relay Server", "中継サーバー"), - ("API Server", "APIサーバー"), - ("invalid_http", "http://またはhttps://から始まる必要があります。"), - ("Invalid IP", "無効なIP"), - ("Invalid format", "無効なフォーマット"), + ("API Server", "API サーバー"), + ("invalid_http", "http:// または https:// から始まる必要があります。"), + ("Invalid IP", "無効な IP"), + ("Invalid format", "無効な形式"), ("server_not_support", "このサーバーには現在対応していません。"), ("Not available", "利用不可"), ("Too frequent", "接続の頻度が高すぎます!"), @@ -78,7 +78,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Reset by the peer", "リモートホストによって接続がリセットされました"), ("Connecting...", "接続中..."), ("Connection in progress. Please wait.", "接続中です。しばらくお待ちください。"), - ("Please try 1 minute later", "1分後にもう一度お試しください"), + ("Please try 1 minute later", "1 分後にもう一度お試しください"), ("Login Error", "ログインエラー"), ("Successful", "成功"), ("Connected, waiting for image...", "接続完了、映像を待機しています..."), @@ -86,7 +86,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Type", "種類"), ("Modified", "最終更新日"), ("Size", "サイズ"), - ("Show Hidden Files", "隠しファイルを表示"), + ("Show Hidden Files", "隠しファイルを表示する"), ("Receive", "受信"), ("Send", "送信"), ("Refresh File", "ファイルを更新"), @@ -112,7 +112,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Waiting", "待機中"), ("Finished", "完了"), ("Speed", "速度"), - ("Custom Image Quality", "画質をカスタムする"), + ("Custom Image Quality", "カスタム画質"), ("Privacy mode", "プライバシーモード"), ("Block user input", "ユーザーの入力をブロック"), ("Unblock user input", "ユーザーの入力を許可"), @@ -122,36 +122,34 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Stretch", "伸縮"), ("Scrollbar", "スクロールバー"), ("ScrollAuto", "自動スクロール"), - ("Good image quality", "画質優先"), + ("Good image quality", "画質を優先"), ("Balanced", "バランス"), - ("Optimize reaction time", "速度優先"), + ("Optimize reaction time", "速度を優先"), ("Custom", "カスタム"), - ("Show remote cursor", "リモートコンピューターのカーソルを表示"), - ("Show quality monitor", "品質モニターを表示"), - ("Disable clipboard", "クリップボードを無効化"), + ("Show remote cursor", "リモートコンピューターのカーソルを表示する"), + ("Show quality monitor", "ディスプレイの品質を表示する"), + ("Disable clipboard", "クリップボードを無効化する"), ("Lock after session end", "セッション終了後にロックする"), - ("Insert Ctrl + Alt + Del", "Ctrl + Alt + Del 送信"), + ("Insert Ctrl + Alt + Del", "Ctrl + Alt + Del を送信"), ("Insert Lock", "ロック命令を送信"), ("Refresh", "更新"), - ("ID does not exist", "IDが存在しません"), + ("ID does not exist", "ID が存在しません"), ("Failed to connect to rendezvous server", "ランデブーサーバーに接続できませんでした"), ("Please try later", "後でもう一度お試しください"), - ("Remote desktop is offline", "リモートコンピューターがオフラインです"), + ("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", "リモートコンピューターと直接接続できませんでした"), + ("Failed to make direct connection to remote desktop", "リモートデスクトップと直接接続できませんでした"), ("Set Password", "パスワードを設定"), - ("OS Password", "OSのパスワード"), - ("install_tip", "UACの影響により、RustDeskがリモートコンピューター上で正常に動作しない場合があります。UACを回避するには、下のボタンをクリックしてシステムにRustDeskをインストールしてください。"), + ("OS Password", "OS のパスワード"), + ("install_tip", "UAC の影響により、RustDesk がリモートデスクトップ上で正常に動作しない場合があります。UAC を回避するには、下のボタンをクリックしてシステムに RustDesk をインストールしてください。"), ("Click to upgrade", "アップグレード"), - ("Click to download", "ダウンロード"), - ("Click to update", "アップデート"), ("Configure", "設定"), - ("config_acc", "リモートからあなたのコンピューターを操作するには、RustDeskに「アクセシビリティ」権限を与える必要があります。"), - ("config_screen", "リモートからあなたのコンピューターにアクセスするには、RustDeskに「画面録画」の権限を与える必要があります。"), + ("config_acc", "リモートからあなたのコンピューターを操作するには、RustDesk に「アクセシビリティ」権限を与える必要があります。"), + ("config_screen", "リモートからあなたのコンピューターにアクセスするには、RustDesk に「画面録画」の権限を与える必要があります。"), ("Installing ...", "インストール中..."), ("Install", "インストール"), ("Installation", "インストール"), @@ -160,46 +158,46 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Create desktop icon", "デスクトップにアイコンを作成する"), ("agreement_tip", "インストールを開始することで、ライセンス条項に同意したとみなされます。"), ("Accept and Install", "同意してインストール"), - ("End-user license agreement", "エンドユーザー ライセンス条項"), + ("End-user license agreement", "エンドユーザーライセンス条項"), ("Generating ...", "生成中..."), ("Your installation is lower version.", "インストールされているバージョンが古くなっています。"), ("not_close_tcp_tip", "トンネルの使用中はこのウィンドウを閉じないでください"), - ("Listening ...", "リッスン中 ..."), + ("Listening ...", "リスニング中..."), ("Remote Host", "リモートホスト"), ("Remote Port", "リモートポート"), ("Action", "操作"), ("Add", "追加"), - ("Local Port", "ローカルのポート"), - ("Local Address", "ローカルポート"), + ("Local Port", "ローカルポート"), + ("Local Address", "ローカルアドレス"), ("Change Local Port", "ローカルポートを変更"), - ("setup_server_tip", "より高速に接続したい場合は、自分のサーバーをセットアップすることをおすすめします"), - ("Too short, at least 6 characters.", "文字数が短すぎます。最低文字数は6文字です。"), + ("setup_server_tip", "より高速に接続したい場合は、自分のサーバーをセットアップすることを推奨します。"), + ("Too short, at least 6 characters.", "文字数が短すぎます。最低文字数は 6 文字です。"), ("The confirmation is not identical.", "確認欄と入力が一致しません。"), ("Permissions", "権限"), ("Accept", "承諾"), - ("Dismiss", "無視"), + ("Dismiss", "却下"), ("Disconnect", "切断"), - ("Enable file copy and paste", "ファイルのコピーと貼り付けを許可"), + ("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", "リモートIDを入力"), + ("Direct and encrypted connection", "直接接続: 接続は暗号化されています"), + ("Relayed and encrypted connection", "中継接続: 接続は暗号化されています"), + ("Direct and unencrypted connection", "直接接続: 接続が暗号化されていません"), + ("Relayed and unencrypted connection", "中継接続: 接続が暗号化されていません"), + ("Enter Remote ID", "リモート ID を入力"), ("Enter your password", "パスワードを入力"), ("Logging in...", "ログイン中..."), - ("Enable RDP session sharing", "RDPセッション共有を有効化"), + ("Enable RDP session sharing", "RDP セッション共有を有効化する"), ("Auto Login", "自動ログイン"), - ("Enable direct IP access", "直接IPアクセスを有効化"), + ("Enable direct IP access", "直接 IP アクセスを有効化する"), ("Rename", "名前の変更"), ("Space", "スペース"), ("Create desktop shortcut", "デスクトップにショートカットを作成する"), ("Change Path", "パスを変更"), - ("Create Folder", "フォルダを作成"), - ("Please enter the folder name", "フォルダ名を入力してください"), + ("Create Folder", "フォルダーを作成"), + ("Please enter the folder name", "フォルダー名を入力してください"), ("Fix it", "修復する"), ("Warning", "警告"), - ("Login screen using Wayland is not supported", "Waylandを使用したログインスクリーンはサポートされていません"), + ("Login screen using Wayland is not supported", "Wayland を使用したログインスクリーンはサポートされていません"), ("Reboot required", "再起動が必要です"), ("Unsupported display server", "サポートされていないディスプレイサーバー"), ("x11 expected", "X11 が必要です"), @@ -208,11 +206,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Username", "ユーザー名"), ("Invalid port", "無効なポート"), ("Closed manually by the peer", "リモートホストによって切断されました"), - ("Enable remote configuration modification", "リモート設定の変更を有効化"), + ("Enable remote configuration modification", "リモート設定の変更を有効化する"), ("Run without install", "インストールせずに実行"), ("Connect via relay", "中継サーバー経由で接続"), ("Always connect via relay", "常に中継サーバー経由で接続"), - ("whitelist_tip", "ホワイトリストに登録されたIPからのみ接続を許可します"), + ("whitelist_tip", "ホワイトリストに登録された IP からのみ接続を許可します"), ("Login", "ログイン"), ("Verify", "認証"), ("Remember me", "入力内容を記憶する"), @@ -221,71 +219,69 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("verification_tip", "登録されたメールアドレスに認証コードが送信されました。認証コードを入力して、ログインを続行してください。"), ("Logout", "ログアウト"), ("Tags", "タグ"), - ("Search ID", "IDを検索"), - ("whitelist_sep", "カンマやセミコロン、空白、改行で区切ってください"), - ("Add ID", "IDを追加"), + ("Search ID", "ID を検索"), + ("whitelist_sep", "コンマやセミコロン、空白、改行で区切ってください"), + ("Add ID", "ID を追加"), ("Add Tag", "タグを追加"), - ("Unselect all tags", "全てのタグを選択解除"), + ("Unselect all tags", "すべてのタグの選択を解除"), ("Network error", "ネットワークエラー"), ("Username missed", "ユーザー名がありません"), ("Password missed", "パスワードがありません"), ("Wrong credentials", "資格情報が間違っています"), ("The verification code is incorrect or has expired", "認証コードが間違っているか、有効期限が切れています"), ("Edit Tag", "タグを編集"), - ("Forget Password", "パスワードを忘れる"), + ("Forget Password", "パスワードを忘れた"), ("Favorites", "お気に入り"), ("Add to Favorites", "お気に入りに追加"), ("Remove from Favorites", "お気に入りから削除"), ("Empty", "空"), - ("Invalid folder name", "無効なフォルダ名"), - ("Socks5 Proxy", "SOCKS5プロキシ"), - ("Socks5/Http(s) Proxy", "Socks5/Http(s)プロキシ"), + ("Invalid folder name", "無効なフォルダー名"), + ("Socks5 Proxy", "SOCKS5 プロキシ"), + ("Socks5/Http(s) Proxy", "Socks5/Http(s) プロキシ"), ("Discovered", "発見済み"), - ("install_daemon_tip", "起動時にRustDeskを開始するには、システムサービスをインストールする必要があります。"), - ("Remote ID", "リモートID"), + ("install_daemon_tip", "起動時に RustDesk を開始するには、システムサービスをインストールする必要があります。"), + ("Remote ID", "リモート ID"), ("Paste", "貼り付け"), ("Paste here?", "ここに貼り付けますか?"), ("Are you sure to close the connection?", "本当に切断しますか?"), ("Download new version", "新しいバージョンをダウンロード"), ("Touch mode", "タッチモード"), ("Mouse mode", "マウスモード"), - ("One-Finger Tap", "1本指でタップ"), + ("One-Finger Tap", "1 本指でタップ"), ("Left Mouse", "マウス左クリック"), - ("One-Long Tap", "1本指でロングタップ"), - ("Two-Finger Tap", "2本指でタップ"), + ("One-Long Tap", "1 本指でロングタップ"), + ("Two-Finger Tap", "2 本指でタップ"), ("Right Mouse", "マウス右クリック"), - ("One-Finger Move", "1本指でドラッグ"), - ("Double Tap & Move", "2本指でタップ&ドラッグ"), + ("One-Finger Move", "1 本指でドラッグ"), + ("Double Tap & Move", "2 本指でタップ&ドラッグ"), ("Mouse Drag", "マウスドラッグ"), - ("Three-Finger vertically", "3本指で縦方向"), + ("Three-Finger vertically", "3 本指で縦方向"), ("Mouse Wheel", "マウスホイール"), - ("Two-Finger Move", "2本指でドラッグ"), + ("Two-Finger Move", "2 本指でドラッグ"), ("Canvas Move", "キャンバスの移動"), - ("Pinch to Zoom", "ピンチしてズーム"), - ("Canvas Zoom", "キャンバスのズーム"), + ("Pinch to Zoom", "ピンチして拡大"), + ("Canvas Zoom", "キャンバスの拡大"), ("Reset canvas", "キャンバスのリセット"), ("No permission of file transfer", "ファイル転送の権限がありません"), ("Note", "ノート"), ("Connection", "接続"), - ("Share Screen", "画面を共有"), + ("Share screen", "画面を共有"), ("Chat", "チャット"), - ("Total", "計"), + ("Total", "合計"), ("items", "個のアイテム"), ("Selected", "選択済み"), ("Screen Capture", "画面キャプチャ"), ("Input Control", "入力操作"), ("Audio Capture", "音声キャプチャ"), - ("File Connection", "ファイルの接続"), - ("Screen Connection", "画面の接続"), ("Do you accept?", "許可しますか?"), ("Open System Setting", "システム設定を開く"), - ("How to get Android input permission?", "Androidの入力権限を取得するには?"), - ("android_input_permission_tip1", "このAndroid端末をリモートコンピューターからマウスやタッチで操作するには、RustDeskに「アクセシビリティ」サービスの使用を許可する必要があります。"), + ("How to get Android input permission?", "Android の入力権限を取得するには?"), + ("android_input_permission_tip1", "この Android デバイスをリモートコンピューターからマウスやタッチで操作するには、RustDesk に「ユーザー補助」からサービスの使用を許可する必要があります。"), ("android_input_permission_tip2", "次の端末設定ページに進み、「インストール済みアプリ」から「RustDesk Input」を有効にしてください。"), ("android_new_connection_tip", "新しい操作リクエストが届きました。この端末を操作しようとしています。"), ("android_service_will_start_tip", "「画面キャプチャ」を有効にするとサービスが自動的に開始され、他の端末がこの端末への接続をリクエストできるようになります。"), ("android_stop_service_tip", "サービスを停止すると、自動的に現在のセッションがすべて閉じられます。"), - ("android_version_audio_tip", "現在のAndroidバージョンでは音声キャプチャはサポートされていません。Android 10以降に更新してください。"), + ("android_version_audio_tip", "現在の Android バージョンでは音声キャプチャはサポートされていません。Android 10 以降に更新してください。"), ("android_start_service_tip", "「サービスを開始」をタップするか、「画面キャプチャ」の許可を有効にすると、画面共有サービスが開始されます。"), ("android_permission_may_not_change_tip", "権限の変更は現在のセッションには適用されません。再接続後に適用されます。"), ("Account", "アカウント"), @@ -305,39 +301,39 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Language", "言語"), ("Keep RustDesk background service", "RustDesk バックグラウンドサービスを維持"), ("Ignore Battery Optimizations", "バッテリーの最適化を無効にする"), - ("android_open_battery_optimizations_tip", "この機能を使わない場合は、RestDeskアプリの設定ページから「バッテリー」に進み、「制限なし」のチェックを外してください"), + ("android_open_battery_optimizations_tip", "この機能を使わない場合は、RustDesk アプリの設定ページから「バッテリー」に進み、「制限しない」を選択してください。"), ("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", "どちらのパスワードも使用"), + ("Use permanent password", "固定パスワードを使用する"), + ("Use both passwords", "両方のパスワードを使用する"), ("Set permanent password", "固定パスワードを設定"), - ("Enable remote restart", "リモートからの再起動を有効化"), + ("Enable remote restart", "リモートからの再起動を有効化する"), ("Restart remote device", "リモートの端末を再起動"), ("Are you sure you want to restart", "本当に再起動しますか"), - ("Restarting remote device", "リモートコンピューターを再起動中"), + ("Restarting remote device", "リモートデバイスを再起動中"), ("remote_restarting_tip", "リモートコンピューターは再起動中です。このメッセージボックスを閉じて、しばらくした後にパスワードを使用して再接続してください。"), ("Copied", "コピーしました"), ("Exit Fullscreen", "全画面表示を終了"), ("Fullscreen", "全画面表示"), - ("Mobile Actions", "モバイル アクション"), - ("Select Monitor", "モニターを選択"), - ("Control Actions", "コントロール アクション"), + ("Mobile Actions", "モバイルアクション"), + ("Select Monitor", "ディスプレイを選択"), + ("Control Actions", "コントロールアクション"), ("Display Settings", "ディスプレイの設定"), ("Ratio", "比率"), ("Image Quality", "画質"), - ("Scroll Style", "スクロール スタイル"), + ("Scroll Style", "スクロールスタイル"), ("Show Toolbar", "ツールバーを表示"), ("Hide Toolbar", "ツールバーを隠す"), ("Direct Connection", "直接接続"), ("Relay Connection", "中継接続"), ("Secure Connection", "安全な接続"), ("Insecure Connection", "安全でない接続"), - ("Scale original", "オリジナルサイズ"), - ("Scale adaptive", "フィットウィンドウ"), + ("Scale original", "オリジナルのサイズ"), + ("Scale adaptive", "ウィンドウに合わせる"), ("General", "一般"), ("Security", "セキュリティ"), ("Theme", "テーマ"), @@ -346,54 +342,54 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Dark", "ダーク"), ("Light", "ライト"), ("Follow System", "システム設定に従う"), - ("Enable hardware codec", "ハードウェアコーデックを有効化"), + ("Enable hardware codec", "ハードウェアコーデックを有効化する"), ("Unlock Security Settings", "セキュリティ設定のロックを解除"), - ("Enable audio", "オーディオを有効化"), + ("Enable audio", "オーディオを有効化する"), ("Unlock Network Settings", "ネットワーク設定のロックを解除"), ("Server", "サーバー"), - ("Direct IP Access", "直接IP接続"), + ("Direct IP Access", "直接 IP 接続"), ("Proxy", "プロキシ"), ("Apply", "適用"), ("Disconnect all devices?", "すべてのデバイスから切断しますか?"), ("Clear", "クリア"), ("Audio Input Device", "音声入力デバイス"), - ("Use IP Whitelisting", "IPホワイトリストを使用する"), + ("Use IP Whitelisting", "IP ホワイトリストを使用する"), ("Network", "ネットワーク"), - ("Pin Toolbar", "ツールバーをピン止め"), - ("Unpin Toolbar", "ツールバーのピン止めを解除"), + ("Pin Toolbar", "ツールバーをピン留め"), + ("Unpin Toolbar", "ツールバーのピン留めを解除"), ("Recording", "録画"), ("Directory", "ディレクトリ"), ("Automatically record incoming sessions", "受信したセッションを自動で記録する"), - ("Automatically record outgoing sessions", ""), + ("Automatically record outgoing sessions", "送信したセッションを自動で記録する"), ("Change", "変更"), ("Start session recording", "セッションの録画を開始"), ("Stop session recording", "セッションの録画を停止"), - ("Enable recording session", "セッションの録画を有効化"), - ("Enable LAN discovery", "LAN探索を有効化"), - ("Deny LAN discovery", "LAN探索を拒否"), + ("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", "リモートデスクトップでフォーカスされているウィンドウの操作にはより高い権限が必要なため、マウスとキーボードが一時的に使用できなくなっています。リモートユーザーにウィンドウを最小化、または接続管理画面から権限を昇格するよう要求してください。この問題を回避するには、リモートコンピューターにRustDeskをインストールしてください。"), + ("Please wait for confirmation of UAC...", "UAC の承認を待機しています..."), + ("elevated_foreground_window_tip", "リモートデスクトップでフォーカスされているウィンドウの操作にはより高い権限が必要なため、マウスとキーボードが一時的に使用できなくなっています。リモートユーザーにウィンドウを最小化、または接続管理画面から権限を昇格するよう要求してください。この問題を回避するには、リモートコンピューターに RustDesk をインストールしてください。"), ("Disconnected", "切断しました"), ("Other", "その他"), ("Confirm before closing multiple tabs", "複数のタブを閉じる前に確認する"), - ("Keyboard Settings", "キーボード設定"), + ("Keyboard Settings", "キーボードの設定"), ("Full Access", "フルアクセス"), ("Screen Share", "画面共有"), - ("Wayland requires Ubuntu 21.04 or higher version.", "Waylandを使用するには、Ubuntu 21.04 以降のバージョンが必要です。"), - ("Wayland requires higher version of linux distro. Please try X11 desktop or change your OS.", "Waylandを使用するには、より新しいLinuxディストリビューションが必要です。 X11デスクトップを試すか、OSを変更してください。"), - ("JumpLink", "View"), + ("ubuntu-21-04-required", "Wayland を使用するには、Ubuntu 21.04 以降のバージョンが必要です。"), + ("wayland-requires-higher-linux-version", "Wayland を使用するには、より新しい Linux ディストリビューションが必要です。 X11 デスクトップを試すか、OS を変更してください。"), + ("xdp-portal-unavailable", ""), + ("JumpLink", "表示"), ("Please Select the screen to be shared(Operate on the peer side).", "共有する画面を選択してください(リモートコンピューターが操作します)"), - ("Show RustDesk", "RustDeskを表示"), - ("This PC", "このPC"), + ("Show RustDesk", "RustDesk を表示"), + ("This PC", "この PC"), ("or", "または"), - ("Continue with", "で続行"), ("Elevate", "昇格"), - ("Zoom cursor", "拡大カーソル"), - ("Accept sessions via password", "パスワードによるセッションの許可"), - ("Accept sessions via click", "クリックによるセッションの承認"), - ("Accept sessions via both", "両方の方法でセッションを許可する"), + ("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", "ワンタイムパスワードを使用する"), @@ -401,48 +397,48 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Request access to your device", "デバイスへのアクセス要求"), ("Hide connection management window", "接続管理画面を隠す"), ("hide_cm_tip", "パスワードによるセッションを許可し、固定パスワードを使用する場合にのみ、管理画面の非表示を許可する。"), - ("wayland_experiment_tip", "Waylandのサポートは試験的なものです。無人アクセスを使用する場合はX11デスクトップをご利用ください。"), - ("Right click to select tabs", "右クリックでタフを選択"), + ("wayland_experiment_tip", "Wayland のサポートは試験的なものです。無人アクセスを使用する場合はX11デスクトップをご利用ください。"), + ("Right click to select tabs", "右クリックでタブを選択"), ("Skipped", "スキップ"), ("Add to address book", "アドレス帳に追加"), ("Group", "グループ"), ("Search", "検索"), - ("Closed manually by web console", "Webコンソールによって閉じられました"), + ("Closed manually by web console", "Web コンソールによって閉じられました"), ("Local keyboard type", "キーボードのタイプ"), ("Select local keyboard type", "キーボードのタイプを選択"), - ("software_render_tip", "LinuxでNvidia製のグラフィックカードを使用していると、接続後すぐにリモートウィンドウが閉じてしまう場合があります。オープンソースのNouveauドライバに切り替え、ソフトウェアレンダリングを使用するよう設定すると解決するかもしれません。(RustDeskの再起動が必要です)"), + ("software_render_tip", "Linux で NVIDIA 製のグラフィックカードを使用していると、接続後すぐにリモートウィンドウが閉じてしまう場合があります。オープンソースの Nouveau ドライバーに切り替えて、ソフトウェアレンダリングを使用するよう設定すると解決するかもしれません。(RustDesk の再起動が必要です)"), ("Always use software rendering", "常にソフトウェアレンダリングを使用する"), - ("config_input", "リモートコンピューターをキーボードで操作するには、RustDeskに「入力監視」権限を与える必要があります。"), - ("config_microphone", "リモートコンピューターと通話するには、RustDeskに「音声録音」権限を与える必要があります。"), + ("config_input", "リモートコンピューターをキーボードで操作するには、RustDesk に「入力監視」権限を与える必要があります。"), + ("config_microphone", "リモートコンピューターと通話するには、RustDesk に「音声録音」権限を与える必要があります。"), ("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", "リモートデスクトップ ユーザーがRustDeskを実行する際に、UACを許可する必要があります。"), + ("still_click_uac_tip", "リモートデスクトップユーザーが RustDesk を実行する際に、UACを許可する必要があります。"), ("Request Elevation", "権限の昇格をリクエストする"), - ("wait_accept_uac_tip", "リモートデスクトップ ユーザーがUACダイアログを許可するまでしばらくお待ちください。"), + ("wait_accept_uac_tip", "リモートデスクトップ ユーザーが UAC ダイアログを許可するまでしばらくお待ちください。"), ("Elevate successfully", "権限の昇格に成功しました"), ("uppercase", "大文字"), ("lowercase", "小文字"), ("digit", "桁数"), ("special character", "特殊文字"), - ("length>=8", "8文字以上"), + ("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", "デフォルトのコーデック"), + ("Default View Style", "既定の表示スタイル"), + ("Default Scroll Style", "既定のスクロールスタイル"), + ("Default Image Quality", "既定の画質"), + ("Default Codec", "既定のコーデック"), ("Bitrate", "ビットレート"), ("FPS", "FPS"), ("Auto", "自動"), - ("Other Default Options", "その他のデフォルト設定"), + ("Other Default Options", "その他の既定の設定"), ("Voice call", "音声通話"), ("Text chat", "テキストチャット"), ("Stop voice call", "音声通話を終了"), @@ -452,7 +448,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Resolution", "解像度"), ("No transfers in progress", "進行中の転送はありません"), ("Set one-time password length", "ワンタイムパスワードの長さを設定する"), - ("RDP Settings", "RDP設定"), + ("RDP Settings", "RDP 設定"), ("Sort by", "並べ替え"), ("New Connection", "新規接続"), ("Restore", "復元"), @@ -463,28 +459,27 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("empty_favorite_tip", "お気に入りのリモートコンピュータがないようですね?あなたの接続先を登録しましょう!"), ("empty_lan_tip", "あらら、まだ近くのコンピューターは発見できていないようです。"), ("empty_address_book_tip", "驚くべきことに、あなたのアドレス帳には現在コンピューターが登録されていません。"), - ("eg: admin", "例: 管理者"), ("Empty Username", "空のユーザー名"), ("Empty Password", "空のパスワード"), ("Me", "あなた"), ("identical_file_tip", "このファイルはリモートコンピューターと同一です。"), - ("show_monitors_tip", "ツールバーにモニターを表示します"), + ("show_monitors_tip", "ツールバーにディスプレイを表示する"), ("View Mode", "表示モード"), - ("login_linux_tip", "Xデスクトップのセッションにログインするには、リモートコンピューターのLinuxアカウントにログインする必要があります。"), - ("verify_rustdesk_password_tip", "RustDeskのパスワードを確認する"), + ("login_linux_tip", "X デスクトップのセッションにログインするには、リモートコンピューターのLinuxアカウントにログインする必要があります。"), + ("verify_rustdesk_password_tip", "RustDesk のパスワードを確認する"), ("remember_account_tip", "このアカウントを記憶する"), - ("os_account_desk_tip", "このアカウントは、リモートコンピューターのOSにログインし、ヘッドレスでセッションを有効化するために使用されます。"), - ("OS Account", "OSのアカウント"), + ("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をインストールしてください"), + ("xorg_not_found_title_tip", "Xorg サーバーが見つかりませんでした。"), + ("xorg_not_found_text_tip", "Xorg をインストールしてください"), ("no_desktop_title_tip", "デスクトップ環境が見つかりませんでした。"), - ("no_desktop_text_tip", "GNOMEデスクトップ環境をインストールしてください"), + ("no_desktop_text_tip", "GNOME デスクトップ環境をインストールしてください"), ("No need to elevate", "権限昇格の必要はありません"), ("System Sound", "システム音声"), - ("Default", "デフォルト"), - ("New RDP", "新しいRDP"), + ("Default", "既定"), + ("New RDP", "新しい RDP"), ("Fingerprint", "フィンガープリント"), ("Copy Fingerprint", "フィンガープリントをコピー"), ("no fingerprints", "フィンガープリントがありません"), @@ -501,7 +496,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("resolution_custom_tip", "カスタム解像度"), ("Collapse toolbar", "ツールバーを折りたたむ"), ("Accept and Elevate", "承認して権限を昇格する"), - ("accept_and_elevate_btn_tooltip", "接続を受け入れた上で、UAC権限を昇格します。"), + ("accept_and_elevate_btn_tooltip", "接続を受け入れた上で、UAC 権限を昇格します。"), ("clipboard_wait_response_timeout_tip", "クリップボードのコピーがタイムアウトしました。"), ("Incoming connection", "接続の受信"), ("Outgoing connection", "接続の送信"), @@ -519,74 +514,74 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Can not be empty", "空にすることはできません"), ("Already exists", "すでに存在します"), ("Change Password", "パスワードを変更"), - ("Refresh Password", "パスワードをリフレッシュ"), + ("Refresh Password", "パスワードを更新"), ("ID", "ID"), - ("Grid View", "グリッドビュー"), - ("List View", "リストビュー"), + ("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カラー"), + ("Primary Color", "プライマリカラー"), + ("HSV Color", "HSV カラー"), ("Installation Successful!", "インストールに成功しました!"), ("Installation failed!", "インストールに失敗しました。"), ("Reverse mouse wheel", "マウスホイールを反転する"), - ("{} sessions", "{}件のセッション"), + ("{} sessions", "{} 件のセッション"), ("scam_title", "あなたは詐欺にあっているかもしれません!"), - ("scam_text1", "もし、知らない相手から電話でRustDeskのインストールやサービスの開始を依頼された場合、作業を進めずに、すぐに電話を切ってください。"), + ("scam_text1", "もし、知らない相手から電話で RustDesk のインストールやサービスの開始を依頼された場合、作業を進めずに、すぐに電話を切ってください。"), ("scam_text2", "相手はあなたからお金や個人情報を盗もうとする詐欺師である可能性があります。"), ("Don't show again", "今後表示しない"), ("I Agree", "同意する"), ("Decline", "同意しない"), - ("Timeout in minutes", "タイムアウトまでの時間(分)"), + ("Timeout in minutes", "タイムアウトまでの時間 (分)"), ("auto_disconnect_option_tip", "ユーザーが非アクティブの場合、自動的に受信したセッションを閉じる"), - ("Connection failed due to inactivity", "リモートデスクトップ ユーザーが非アクティブなため、接続に失敗しました"), - ("Check for software update on startup", "起動時にソフトウェアの更新をチェック"), - ("upgrade_rustdesk_server_pro_to_{}_tip", "RustDesk Server Proをバージョン{}以上にアップグレードしてください!"), + ("Connection failed due to inactivity", "リモートデスクトップユーザーが非アクティブなため、接続に失敗しました"), + ("Check for software update on startup", "起動時にソフトウェアの更新を確認する"), + ("upgrade_rustdesk_server_pro_to_{}_tip", "RustDesk Server Pro をバージョン {} 以上にアップグレードしてください!"), ("pull_group_failed_tip", "グループの更新に失敗しました"), ("Filter by intersection", "交差位置でフィルター"), ("Remove wallpaper during incoming sessions", "セッションの受信中、デスクトップ背景を削除する"), ("Test", "テスト"), - ("display_is_plugged_out_msg", "モニターが接続されていません。最初のモニターを選択してください。"), - ("No displays", "モニターがありません"), + ("display_is_plugged_out_msg", "ディスプレイが接続されていません。最初のディスプレイを選択してください。"), + ("No displays", "ディスプレイがありません"), ("Open in new window", "新しいウィンドウで開く"), - ("Show displays as individual windows", "モニターを別々のウィンドウとして表示"), + ("Show displays as individual windows", "ディスプレイを個別のウィンドウとして表示する"), ("Use all my displays for the remote session", "すべてのディスプレイをセッションで使用する"), - ("selinux_tip", "SELinuxが有効になっているため、RustDeskが正常に動作しない可能性があります。"), - ("Change view", "表示変更"), + ("selinux_tip", "SELinuxが有効になっているため、RustDesk が正常に動作しない可能性があります。"), + ("Change view", "表示を変更"), ("Big tiles", "大きなタイル"), ("Small tiles", "小さなタイル"), ("List", "リスト"), - ("Virtual display", "仮想モニター"), - ("Plug out all", "すべて切断する"), - ("True color (4:4:4)", "True color (4:4:4)"), - ("Enable blocking user input", "ユーザー入力のブロックを有効化"), - ("id_input_tip", "ID、IPアドレス、またはドメインとポート番号(<ドメイン>:<ポート>)を使用できます。\n他のサーバーのデバイスにアクセスしたい場合は、サーバーアドレス(@<サーバーアドレス>?key=<キーの値>)を追加してください。 \n(例:9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=)\nパブリックサーバーのデバイスに接続したい場合は、「@public」のように入力してください。パブリックサーバーの場合、キーは不要です。\n\n初回接続で中継接続を行いたい場合は、「9123456234/r」のように末尾に「/r」を付けてください。"), + ("Virtual display", "仮想ディスプレイ"), + ("Plug out all", "すべて切断"), + ("True color (4:4:4)", "True Color (4:4:4)"), + ("Enable blocking user input", "ユーザー入力のブロックを有効化する"), + ("id_input_tip", "ID、IPアドレス、またはドメインとポート番号(<ドメイン>:<ポート>)を使用できます。\n他のサーバーのデバイスにアクセスしたい場合は、サーバーアドレス(@<サーバーアドレス>?key=<キーの値>)を追加してください。 \n(例: 9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=)\nパブリックサーバーのデバイスに接続したい場合は、「@public」のように入力してください。パブリックサーバーの場合、キーは不要です。\n\n初回接続で中継接続を行いたい場合は、「9123456234/r」のように末尾に「/r」を付けてください。"), ("privacy_mode_impl_mag_tip", "モード 1"), ("privacy_mode_impl_virtual_display_tip", "モード 2"), ("Enter privacy mode", "プライバシーモードを起動"), ("Exit privacy mode", "プライバシーモードを終了"), - ("idd_not_support_under_win10_2004_tip", "Indirect display driverには対応していません。Windows 10 バージョン2004以降が必要です。"), + ("idd_not_support_under_win10_2004_tip", "Indirect display driver には対応していません。Windows 10 バージョン 2004 以降が必要です。"), ("input_source_1_tip", "入力ソース 1"), ("input_source_2_tip", "入力ソース 2"), - ("Swap control-command key", "ctrlとcommandキーを入れ替える"), + ("Swap control-command key", "ctrl と command キーを入れ替える"), ("swap-left-right-mouse", "マウスのクリックを入れ替える"), ("2FA code", "二要素認証コード"), ("More", "詳細"), - ("enable-2fa-title", "二要素認証を有効化"), - ("enable-2fa-desc", "認証アプリをセットアップします。Authy、MicrosoftまたはGoogle AuthenticatorなどがPCまたはスマートフォンで利用できます。\n\nQRコードをスキャンし、アプリが表示するコードを入力することで二要素認証が有効になります。"), + ("enable-2fa-title", "二要素認証を有効化する"), + ("enable-2fa-desc", "認証アプリをセットアップします。Authy、Microsoft または Google 認証システムなどが PC またはスマートフォンで利用できます。\n\nQR コードをスキャンし、アプリが表示するコードを入力することで二要素認証が有効になります。"), ("wrong-2fa-code", "コードが違います。コードと端末の時刻設定が正しいかをご確認ください。"), ("enter-2fa-title", "二要素認証"), - ("Email verification code must be 6 characters.", "電子メール認証コードは6文字である必要があります。"), - ("2FA code must be 6 digits.", "二要素認証コードは6文字である必要があります。"), - ("Multiple Windows sessions found", "複数のWindowsセッションが見つかりました"), + ("Email verification code must be 6 characters.", "電子メール認証コードは 6 文字である必要があります。"), + ("2FA code must be 6 digits.", "二要素認証コードは 6 文字である必要があります。"), + ("Multiple Windows sessions found", "複数の Windows セッションが見つかりました"), ("Please select the session you want to connect to", "接続したいセッションを選択してください"), ("powered_by_me", "Powered by RustDesk"), ("outgoing_only_desk_tip", "カスタマイズされたエディションを使用しています。\n他のコンピューターに接続できますが、他のコンピューターからのリクエストは受信できません。"), - ("preset_password_warning", "このエディションには、デフォルトで固定パスワードが設定されています。このパスワードを知っているユーザーはあなたのデバイスを完全にコントロールできるため、そのような危険がある場合は直ちにRustDeskをアンインストールして下さい!"), + ("preset_password_warning", "このエディションには、既定で固定パスワードが設定されています。このパスワードを知っているユーザーはあなたのデバイスを完全にコントロールできるため、そのような危険がある場合は直ちに RustDesk をアンインストールして下さい!"), ("Security Alert", "セキュリティ警告"), ("My address book", "あなたのアドレス帳"), ("Personal", "個人"), @@ -598,60 +593,157 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Full Control", "フルアクセス"), ("share_warning_tip", "フィールドは共有され、他の人からも閲覧できます。"), ("Everyone", "全員"), - ("ab_web_console_tip", "webコンソールの詳細"), - ("allow-only-conn-window-open-tip", "RustDeskのウィンドウが開いている場合のみ接続を許可する"), - ("no_need_privacy_mode_no_physical_displays_tip", "物理モニターが存在しないため、プライバシーモードは不要です。"), - ("Follow remote cursor", "リモートカーソルに追従"), - ("Follow remote window focus", "リモートウィンドウのフォーカスに追従"), - ("default_proxy_tip", "デフォルトのプロトコルとポートはSocks5と1080です。"), + ("ab_web_console_tip", "Web コンソールの詳細"), + ("allow-only-conn-window-open-tip", "RustDesk のウィンドウが開いている場合のみ接続を許可する"), + ("no_need_privacy_mode_no_physical_displays_tip", "物理ディスプレイが存在しないため、プライバシーモードは不要です。"), + ("Follow remote cursor", "リモートカーソルに追従する"), + ("Follow remote window focus", "リモートウィンドウのフォーカスに追従する"), + ("default_proxy_tip", "既定のプロトコルとポートは Socks5 と 1080 です。"), ("no_audio_input_device_tip", "オーディオ入力デバイスが見つかりません。"), ("Incoming", "受信"), ("Outgoing", "発信"), - ("Clear Wayland screen selection", "Waylandの画面選択をクリア"), + ("Clear Wayland screen selection", "Wayland の画面選択をクリア"), ("clear_Wayland_screen_selection_tip", "画面選択をクリア後、共有画面を再び選択できます。"), - ("confirm_clear_Wayland_screen_selection_tip", "本当にWaylandの画面選択をクリアしますか?"), + ("confirm_clear_Wayland_screen_selection_tip", "本当に Wayland の画面選択をクリアしますか?"), ("android_new_voice_call_tip", "新しい音声通話リクエストを受信しました。承認すると音声通話に切り替わります。"), ("texture_render_tip", "テクスチャレンダリングを使用し、画像をより滑らかに描画します。レンダリングの問題が発生した場合は無効にしてみてください。"), - ("Use texture rendering", "テクスチャレンダリングを使用"), + ("Use texture rendering", "テクスチャレンダリングを使用する"), ("Floating window", "フローティングウィンドウ"), - ("floating_window_tip", "RustDeskのバックグラウンドサービスを維持するために使用されます。"), + ("floating_window_tip", "RustDesk のバックグラウンドサービスを維持するために使用されます。"), ("Keep screen on", "常に画面をオン"), ("Never", "画面をオンにしない"), ("During controlled", "操作中"), - ("During service is on", "サービスの動作中"), - ("Capture screen using DirectX", "DirectXを使用した画面キャプチャ"), + ("During service is on", "サービスが動作中"), + ("Capture screen using DirectX", "画面キャプチャに DirectX を使用する"), ("Back", "戻る"), ("Apps", "アプリ"), - ("Volume up", "音量アップ"), - ("Volume down", "音量ダウン"), + ("Volume up", "音量を上げる"), + ("Volume down", "音量を下げる"), ("Power", "電源"), - ("Telegram bot", "Telegram Bot"), + ("Telegram bot", "Telegram ボット"), ("enable-bot-tip", "この機能を有効にすると、ボットから二要素認証コードを受け取ることができます。また、接続時の通知としても機能します。"), - ("enable-bot-desc", "1. @BotFatherのチャットを開きます。\n2. 「/newbot」コマンドを送信します。送信後、トークンを取得できます。\n3. 新しく作成したbotとチャットを開始します。「/hello」のようにスラッシュで始まるメッセージを送信して起動します。\n"), + ("enable-bot-desc", "1. @BotFather のチャットを開きます。\n2. 「/newbot」コマンドを送信します。送信後、トークンを取得できます。\n3. 新しく作成したボットとチャットを開始します。「/hello」のようにスラッシュで始まるメッセージを送信して起動します。\n"), ("cancel-2fa-confirm-tip", "本当に二要素認証をキャンセルしますか?"), - ("cancel-bot-confirm-tip", "本当にTelegram Botをキャンセルしますか?"), - ("About RustDesk", "RustDeskについて"), + ("cancel-bot-confirm-tip", "本当に Telegram ボットをキャンセルしますか?"), + ("About RustDesk", "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", "承認済デバイスの管理"), + ("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", "承認済デバイスで2FAチェックをスキップします。"), + ("enable-trusted-devices-tip", "承認済みのデバイスで 2FA の確認をスキップします。"), ("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", ""), + ("one-way-file-transfer-tip", "コントロールをされる側では一方向のファイル転送が有効になります。"), + ("Authentication Required", "認証が必要です"), + ("Authenticate", "認証"), + ("web_id_input_tip", "同じサーバー内の ID を入力できます。Web クライアントでは直接 IP アドレスによるアクセスはサポートされていません。\n別のサーバー上のデバイスにアクセスする場合は、サーバーアドレス (@?key=) を入力してください。\n 例: 9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=\nパブリックサーバー上のデバイスにアクセスする場合は、「@public」と入力してください。パブリックサーバーはキーは不要です。"), + ("Download", "ダウンロード"), + ("Upload folder", "フォルダーをアップロード"), + ("Upload files", "ファイルをアップロード"), + ("Clipboard is synchronized", "クリップボードを同期しました"), + ("Update client clipboard", "クライアントのクリップボードを更新"), + ("Untagged", "タグ付けなし"), + ("new-version-of-{}-tip", "{} の新しいバージョンが利用可能です"), + ("Accessible devices", "アクセス可能なデバイス"), + ("upgrade_remote_rustdesk_client_to_{}_tip", "リモート側の RustDesk クライアントをバージョン {} 以上にアップグレードしてください!"), + ("d3d_render_tip", "D3D レンダリングを有効化すると、一部の環境ではリモートコントロール画面が黒くなる場合があります。"), + ("Use D3D rendering", "D3D レンダリングを使用する"), + ("Printer", "プリンター"), + ("printer-os-requirement-tip", "プリンター送信機能は Windows 10 以降が必要です。"), + ("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", "WebSocket を使用する場合、リレー接続のみがサポートされます。"), + ("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", "既定では RustDesk は TLS を使用するプロトコルのサーバー証明書を検証します。\nこのオプションを有効化すると RustDesk は検証の手順をスキップして、検証に失敗した場合の処理を続行します。"), + ("Disable UDP", "UDP を無効化する"), + ("disable-udp-tip", "TCP のみ使用するかどうかを制御します。\nこのオプションを有効化すると、RustDesk は UDP 21116 を使用せずに TCP 21116 を使用するようになります。"), + ("server-oss-not-support-tip", "注意: RustDesk Server OSS にはこの機能が含まれていません。"), + ("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 {}", "{} で続行する"), + ("Display Name", "表示名"), + ("password-hidden-tip", "永続的なパスワードが設定されています (非表示)"), + ("preset-password-in-use-tip", "プリセットパスワードが現在使用されています"), + ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ko.rs b/src/lang/ko.rs index 71bff5119..350d570b0 100644 --- a/src/lang/ko.rs +++ b/src/lang/ko.rs @@ -3,49 +3,49 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = [ ("Status", "상태"), ("Your Desktop", "내 데스크탑"), - ("desk_tip", "아래의 ID와 비밀번호로 연결할수 있습니다"), + ("desk_tip", "이 ID와 비밀번호로 데스크탑에 액세스할 수 있습니다."), ("Password", "비밀번호"), - ("Ready", "준비"), + ("Ready", "준비 완료"), ("Established", "연결됨"), - ("connecting_status", "RustDesk 네트워크로 연결중입니다..."), + ("connecting_status", "RustDesk 네트워크에 연결 중..."), ("Enable service", "서비스 활성화"), ("Start service", "서비스 시작"), - ("Service is running", "서비스가 실행되었습니다"), + ("Service is running", "서비스가 실행 중입니다"), ("Service is not running", "서비스가 실행되지 않았습니다"), - ("not_ready_status", "준비되지 않았습니다. 연결을 확인해주세요."), + ("not_ready_status", "준비되지 않았습니다. 연결을 확인해 주세요"), ("Control Remote Desktop", "원격 데스크탑 제어"), ("Transfer file", "파일 전송"), - ("Connect", "연결하기"), + ("Connect", "연결"), ("Recent sessions", "최근 세션"), - ("Address book", "세션 주소록"), + ("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 터널링 활성화"), + ("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", "잘못된 서버 설정"), + ("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"), + ("Your new ID", "새 ID"), ("length %min% to %max%", "길이 %min% ~ %max%"), ("starts with a letter", "문자로 시작해야 합니다"), ("allowed characters", "허용되는 문자"), - ("id_change_tip", "a-z, A-Z, 0-9, _(언더바)만 입력 가능합니다. 첫 문자는 a-z 혹은 A-Z로 시작해야 합니다. 길이는 6~16글자가 요구됩니다."), + ("id_change_tip", "a-z, A-Z, 0-9, -(대시) 및 _(밑줄) 문자만 허용됩니다. 첫 글자는 a-z, A-Z여야 합니다. 길이는 6에서 16 사이여야 합니다."), ("Website", "웹사이트"), ("About", "정보"), - ("Slogan_tip", "이 혼란스러운 세상에서 마음을 담아 만들었습니다!"), - ("Privacy Statement", "개인 정보 보호 정책"), + ("Slogan_tip", "이 혼란스러운 세상에서 마음을 담아 만들었습니다! - 한국어 번역: 비너스걸"), + ("Privacy Statement", "개인정보 보호정책"), ("Mute", "음소거"), ("Build Date", "빌드 날짜"), ("Version", "버전"), @@ -53,43 +53,43 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Audio Input", "오디오 입력"), ("Enhancements", "향상된 기능"), ("Hardware Codec", "하드웨어 코덱"), - ("Adaptive bitrate", "가변 비트레이트"), + ("Adaptive bitrate", "적응형 비트레이트"), ("ID Server", "ID 서버"), ("Relay Server", "릴레이 서버"), ("API Server", "API 서버"), - ("invalid_http", "http:// 또는 https:// 로 시작해야합니다"), - ("Invalid IP", "유효하지 않은 IP"), - ("Invalid format", "유효하지 않은 형식"), + ("invalid_http", "http:// 또는 https://로 시작해야 합니다"), + ("Invalid IP", "유효하지 않은 IP 주소입니다"), + ("Invalid format", "유효하지 않은 형식입니다"), ("server_not_support", "아직 서버에서 지원되지 않습니다"), - ("Not available", "불가능"), - ("Too frequent", "수정이 너무 자주 발생합니다. 나중에 재시도해 주세요."), + ("Not available", "사용할 수 없음"), + ("Too frequent", "너무 빈번합니다"), ("Cancel", "취소"), - ("Skip", "넘기기"), + ("Skip", "건너뛰기"), ("Close", "닫기"), ("Retry", "재시도"), ("OK", "확인"), - ("Password Required", "비밀번호 입력"), - ("Please enter your password", "비밀번호를 입력해주세요"), - ("Remember password", "비밀번호 기억하기"), - ("Wrong Password", "비밀번호가 다릅니다"), - ("Do you want to enter again?", "다시 연결하시겠습니까?"), + ("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분 후에 재시도하세요"), + ("Reset by the peer", "피어에 의해 초기화"), + ("Connecting...", "연결 중..."), + ("Connection in progress. Please wait.", "연결이 진행 중입니다. 기다려 주세요."), + ("Please try 1 minute later", "1분 후에 다시 시도하세요"), ("Login Error", "로그인 오류"), ("Successful", "성공"), - ("Connected, waiting for image...", "연결됨. 화면 전송 대기 중..."), + ("Connected, waiting for image...", "연결됨, 화면을 기다리는 중..."), ("Name", "이름"), ("Type", "유형"), - ("Modified", "수정됨"), + ("Modified", "수정 날짜"), ("Size", "크기"), - ("Show Hidden Files", "숨겨진 파일 표시"), + ("Show Hidden Files", "숨김 파일 표시"), ("Receive", "받기"), ("Send", "보내기"), - ("Refresh File", "파일 새로고침"), + ("Refresh File", "파일 새로 고침"), ("Local", "로컬"), ("Remote", "원격"), ("Remote Computer", "원격 컴퓨터"), @@ -98,341 +98,337 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Delete", "삭제"), ("Properties", "속성"), ("Multi Select", "다중 선택"), - ("Select All", "전체 선택"), - ("Unselect All", "전체 선택 해제"), - ("Empty Directory", "폴더가 비어있습니다"), - ("Not an empty directory", "폴더가 비어있지 않습니다"), + ("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", "삭제중"), + ("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", "완료됨"), + ("Waiting", "대기 중"), + ("Finished", "완료되었습니다"), ("Speed", "속도"), - ("Custom Image Quality", "화질 설정"), + ("Custom Image Quality", "사용자 지정 이미지 품질"), ("Privacy mode", "개인정보 보호 모드"), ("Block user input", "사용자 입력 차단"), ("Unblock user input", "사용자 입력 차단 해제"), - ("Adjust Window", "화면 조정"), + ("Adjust Window", "창 크기 조정"), ("Original", "원본"), ("Shrink", "축소"), - ("Stretch", "확대"), - ("Scrollbar", "스크롤바"), - ("ScrollAuto", "자동스크롤"), - ("Good image quality", "이미지 품질 최적화"), - ("Balanced", "균형"), + ("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", "새로고침"), + ("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", "키가 일치하지 않습니다."), + ("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", "원격 데스크탑으로의 직접 연결 생성에 실패하였습니다"), + ("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", "UAC로 인해, RustDesk가 원격지일 때 일부 기능이 동작하지 않을 수 있습니다. UAC 문제를 방지하려면, 아래 버튼을 클릭하여 RustDesk를 시스템에 설치해주세요."), + ("install_tip", "UAC로 인해 경우에 따라 RustDesk가 원격 쪽에서 제대로 작동하지 않을 수 있습니다. UAC를 피하려면 아래 버튼을 클릭하여 시스템에 RustDesk를 설치하세요."), ("Click to upgrade", "업그레이드"), - ("Click to download", "다운로드"), - ("Click to update", "업데이트"), ("Configure", "구성"), - ("config_acc", "내 데스크탑을 원격제어하기 전에, RustDesk에게 \"Accessibility (접근성)\" 권한을 부여해야 합니다."), - ("config_screen", "내 데스크탑을 원격제어하기 전에, RustDesk에게 \"Screen Recording (화면 녹화)\" 권한을 부여해야 합니다."), - ("Installing ...", "설치중 ..."), + ("config_acc", "데스크탑을 원격으로 제어하려면 RustDesk에 \"접근성\" 권한을 부여해야 합니다."), + ("config_screen", "데스크탑에 원격으로 액세스하려면 RustDesk에 \"화면 녹화\" 권한을 부여해야 합니다."), + ("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.", "설치한 버전이 현재 실행 중인 버전보다 낮습니다."), - ("not_close_tcp_tip", "연결중에는 이 창을 끄지 마세요"), - ("Listening ...", "연결 대기중 ..."), + ("Create start menu shortcuts", "시작 메뉴에 바로가기 만들기"), + ("Create desktop icon", "바탕 화면 아이콘 만들기"), + ("agreement_tip", "설치를 시작하면 라이선스 계약을 수락하는 것입니다."), + ("Accept and Install", "수락하고 설치"), + ("End-user license agreement", "최종 사용자 라이선스 계약"), + ("Generating ...", "생성 중 ..."), + ("Your installation is lower version.", "설치된 버전이 낮습니다."), + ("not_close_tcp_tip", "터널을 사용하는 동안에는 이 창을 닫지 마세요"), + ("Listening ...", "수신 대기 중 ..."), ("Remote Host", "원격 호스트"), ("Remote Port", "원격 포트"), - ("Action", "액션"), + ("Action", "동작"), ("Add", "추가"), ("Local Port", "로컬 포트"), - ("Local Address", "현재 주소"), + ("Local Address", "로컬 주소"), ("Change Local Port", "로컬 포트 변경"), - ("setup_server_tip", "자체 서버를 구축하면 더 빠른 속도로 사용할수 있습니다"), - ("Too short, at least 6 characters.", "너무 짧습니다, 최소 6글자 이상 입력해주세요."), - ("The confirmation is not identical.", "두 입력이 일치하지 않습니다."), + ("setup_server_tip", "더 빠른 연결을 위해, 자신만의 서버를 설정해 주세요."), + ("Too short, at least 6 characters.", "너무 짧습니다. 최소 6자 이상입니다."), + ("The confirmation is not identical.", "확인이 동일하지 않습니다."), ("Permissions", "권한"), ("Accept", "수락"), ("Dismiss", "거부"), - ("Disconnect", "연결 종료"), + ("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", "원격 ID를 입력하세요"), - ("Enter your password", "비밀번호를 입력하세요"), + ("Direct and encrypted connection", "직접 및 암호화된 연결"), + ("Relayed and encrypted connection", "릴레이 및 암호화된 연결"), + ("Direct and unencrypted connection", "직접 및 암호화되지 않은 연결"), + ("Relayed and unencrypted connection", "릴레이 및 암호화되지 않은 연결"), + ("Enter Remote ID", "원격 ID 입력"), + ("Enter your password", "비밀번호 입력"), ("Logging in...", "로그인 중..."), - ("Enable RDP session sharing", "RDP 세션 공유 활성화"), + ("Enable RDP session sharing", "RDP 세션 공유 허용"), ("Auto Login", "자동 로그인"), - ("Enable direct IP access", "다이렉트 IP 연결 활성화"), - ("Rename", "이름 변경"), - ("Space", "공간"), - ("Create desktop shortcut", "데스크탑 바로가기 생성"), + ("Enable direct IP access", "직접 IP 액세스 허용"), + ("Rename", "이름 바꾸기"), + ("Space", "공백"), + ("Create desktop shortcut", "바탕 화면 바로가기 만들기"), ("Change Path", "경로 변경"), - ("Create Folder", "폴더 생성"), - ("Please enter the folder name", "폴더명을 입력해주세요"), + ("Create Folder", "폴더 만들기"), + ("Please enter the folder name", "폴더 이름을 입력해주세요"), ("Fix it", "문제 해결"), ("Warning", "경고"), - ("Login screen using Wayland is not supported", "Wayland를 사용한 로그인 화면이 지원되지 않습니다"), + ("Login screen using Wayland is not supported", "Wayland를 사용한 로그인 화면은 지원되지 않습니다"), ("Reboot required", "재부팅이 필요합니다"), ("Unsupported display server", "지원하지 않는 디스플레이 서버"), - ("x11 expected", "x11로 전환해주세요"), + ("x11 expected", "x11 환경이 필요합니다"), ("Port", "포트"), ("Settings", "설정"), - ("Username", "사용자명"), - ("Invalid port", "포트가 유효하지않습니다"), - ("Closed manually by the peer", "다른 사용자에 의해 종료됨"), - ("Enable remote configuration modification", "원격 구성 변경 활성화"), + ("Username", "사용자 이름"), + ("Invalid port", "유효하지 않은 포트입니다"), + ("Closed manually by the peer", "피어가 수동으로 닫았습니다"), + ("Enable remote configuration modification", "원격 구성 수정 허용"), ("Run without install", "설치 없이 실행"), ("Connect via relay", "릴레이를 통해 연결"), ("Always connect via relay", "항상 릴레이를 통해 연결"), - ("whitelist_tip", "화이트리스트에 있는 IP만 나에게 연결할수 있습니다"), + ("whitelist_tip", "화이트리스트에 있는 IP만 나에게 액세스할 수 있음"), ("Login", "로그인"), ("Verify", "확인"), ("Remember me", "기억하기"), - ("Trust this device", "이 장치 신뢰"), - ("Verification code", "확인 코드"), - ("verification_tip", "등록된 이메일 주소로 인증번호가 발송되었습니다. 인증번호를 입력하시면 계속 로그인하실 수 있습니다"), + ("Trust this device", "이 장치를 신뢰"), + ("Verification code", "인증 코드"), + ("verification_tip", "등록한 이메일 주소로 인증 코드가 전송되었으니 인증 코드를 입력하여 로그인을 계속하세요."), ("Logout", "로그아웃"), ("Tags", "태그"), ("Search ID", "ID 검색"), - ("whitelist_sep", "다음 글자로 구분합니다. ',(콤마) ;(세미콜론) 띄어쓰기 혹은 줄바꿈'"), + ("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", "패스워드 기억하지 않기"), + ("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", "유효하지 않은 폴더명"), + ("Invalid folder name", "유효하지 않은 폴더 이름"), ("Socks5 Proxy", "Socks5 프록시"), ("Socks5/Http(s) Proxy", "Socks5/Http(s) 프록시"), - ("Discovered", "찾음"), - ("install_daemon_tip", "부팅된 이후 시스템 서비스에 설치해야 합니다."), + ("Discovered", "발견됨"), + ("install_daemon_tip", "부팅할 때 시작하려면 시스템 서비스를 설치해야 합니다."), ("Remote ID", "원격 ID"), ("Paste", "붙여넣기"), - ("Paste here?", "여기에 붙여넣기를 실핼할까요?"), + ("Paste here?", "여기에 붙여넣으시겠습니까?"), ("Are you sure to close the connection?", "연결을 종료하시겠습니까?"), - ("Download new version", "최신 버전 다운로드"), + ("Download new version", "새 버전 다운로드"), ("Touch mode", "터치 모드"), ("Mouse mode", "마우스 모드"), ("One-Finger Tap", "한 손가락 탭"), ("Left Mouse", "왼쪽 마우스"), - ("One-Long Tap", "길게 누르기"), + ("One-Long Tap", "한 번 길게 탭"), ("Two-Finger Tap", "두 손가락 탭"), ("Right Mouse", "오른쪽 마우스"), - ("One-Finger Move", "한 손가락 이동"), - ("Double Tap & Move", "두 번 탭 하고 이동"), - ("Mouse Drag", "마우스 드래그"), - ("Three-Finger vertically", "세 손가락 세로로"), + ("One-Finger Move", "한 손가락으로 이동"), + ("Double Tap & Move", "두 번 탭하고 이동"), + ("Mouse Drag", "마우스 끌기"), + ("Three-Finger vertically", "세 손가락으로 수직"), ("Mouse Wheel", "마우스 휠"), - ("Two-Finger Move", "두 손가락 이동"), + ("Two-Finger Move", "두 손가락으로 이동"), ("Canvas Move", "캔버스 이동"), - ("Pinch to Zoom", "확대/축소"), - ("Canvas Zoom", "캔버스 확대"), + ("Pinch to Zoom", "찝어서 확대/축소"), + ("Canvas Zoom", "캔버스 확대/축소"), ("Reset canvas", "캔버스 초기화"), ("No permission of file transfer", "파일 전송 권한이 없습니다"), ("Note", "노트"), ("Connection", "연결"), - ("Share Screen", "화면 공유"), + ("Share screen", "화면 공유"), ("Chat", "채팅"), - ("Total", "총합"), - ("items", "개체"), + ("Total", "전체"), + ("items", "항목"), ("Selected", "선택됨"), ("Screen Capture", "화면 캡처"), ("Input Control", "입력 제어"), ("Audio Capture", "오디오 캡처"), - ("File Connection", "파일 전송"), - ("Screen Connection", "화면 전송"), - ("Do you accept?", "동의하십니까?"), + ("Do you accept?", "수락하시겠습니까?"), ("Open System Setting", "시스템 설정 열기"), - ("How to get Android input permission?", "안드로이드 입력 권한에 어떻게 접근합니까?"), - ("android_input_permission_tip1", "원격지로서 마우스나 터치를 통해 Android 장치를 제어하려면 RustDesk에서 \"Accessibility (접근성)\" 서비스 사용을 허용해야 합니다."), - ("android_input_permission_tip2", "시스템 설정 페이지로 이동하여 [설치된 서비스]에서 [RustDesk Input] 서비스를 켜십시오."), - ("android_new_connection_tip", "현재 장치의 새로운 제어 요청이 수신되었습니다."), - ("android_service_will_start_tip", "\"화면 캡처\"를 켜면 서비스가 자동으로 시작되어 다른 장치에서 사용자 장치에 대한 연결을 요청할 수 있습니다."), - ("android_stop_service_tip", "서비스를 종료하면 모든 연결이 자동으로 닫힙니다."), - ("android_version_audio_tip", "현재 Android 버전은 오디오 캡처를 지원하지 않습니다. Android 10 이상으로 업그레이드하십시오."), - ("android_start_service_tip", "서비스 시작을 클릭하거나 화면 캡처 권한을 활성화하여 화면 공유 서비스를 시작하세요."), - ("android_permission_may_not_change_tip", "설정된 연결의 경우 연결이 재설정되지 않는 한 권한이 즉시 변경되지 않을 수 있습니다."), + ("How to get Android input permission?", "Android 입력 권한을 얻는 방법은?"), + ("android_input_permission_tip1", "원격 장치에서 마우스나 터치로 Android 장치를 제어하려면 RustDesk가 \"접근성\" 서비스를 사용하도록 허용해야 합니다."), + ("android_input_permission_tip2", "다음 시스템 설정 페이지로 이동하여 [설치된 서비스]를 찾아 들어가서 [RustDesk 입력] 서비스를 켜세요."), + ("android_new_connection_tip", "현재 장치를 제어하려는 새로운 제어 요청이 수신되었습니다."), + ("android_service_will_start_tip", "\"화면 캡처\"를 켜면 자동으로 서비스가 시작되어 다른 장치가 내 장치에 연결을 요청할 수 있습니다."), + ("android_stop_service_tip", "서비스를 닫으면 설정된 모든 연결이 자동으로 닫힙니다."), + ("android_version_audio_tip", "현재 Android 버전은 오디오 캡처를 지원하지 않으므로 Android 10 이상으로 업그레이드하세요."), + ("android_start_service_tip", "[서비스 시작]을 탭하거나 [화면 캡처] 권한을 활성화하여 화면 공유 서비스를 시작합니다."), + ("android_permission_may_not_change_tip", "설정된 연결에 대한 권한은 다시 연결할 때까지 즉시 변경되지 않을 수 있습니다."), ("Account", "계정"), ("Overwrite", "덮어쓰기"), - ("This file exists, skip or overwrite this file?", "해당 파일이 이미 존재합니다, 건너뛰거나 덮어쓰시겠습니까?"), + ("This file exists, skip or overwrite this file?", "이 파일이 이미 존재합니다, 건너뛰거나 덮어쓰시겠습니까?"), ("Quit", "종료"), - ("Help", "지원"), + ("Help", "도움말"), ("Failed", "실패"), ("Succeeded", "성공"), - ("Someone turns on privacy mode, exit", "개인정보 보호 모드가 활성화되어 종료됩니다"), + ("Someone turns on privacy mode, exit", "누군가 개인정보 보호 모드를 켰습니다, 연결을 종료합니다"), ("Unsupported", "지원되지 않음"), ("Peer denied", "연결 거부됨"), ("Please install plugins", "플러그인을 설치해주세요"), - ("Peer exit", "다른 사용자가 종료함"), - ("Failed to turn off", "종료 실패"), - ("Turned off", "종료됨"), + ("Peer exit", "피어 종료"), + ("Failed to turn off", "끄기 실패"), + ("Turned off", "꺼짐"), ("Language", "언어"), - ("Keep RustDesk background service", "RustDesk 백그라운드 서비스로 유지하기"), - ("Ignore Battery Optimizations", "배터리 최적화 무시하기"), - ("android_open_battery_optimizations_tip", "해당 기능을 비활성화하려면 RustDesk 응용 프로그램 설정 페이지로 이동하여 [배터리]에서 [제한 없음] 선택을 해제하십시오."), - ("Start on boot", "부팅시 시작"), - ("Start the screen sharing service on boot, requires special permissions", "부팅 시 화면 공유 서비스를 시작하려면 특별한 권한이 필요합니다."), + ("Keep RustDesk background service", "RustDesk 백그라운드 서비스 유지"), + ("Ignore Battery Optimizations", "배터리 최적화 무시"), + ("android_open_battery_optimizations_tip", "이 기능을 비활성화하려면 다음 RustDesk 응용 프로그램 설정 페이지로 이동하여 [배터리]를 찾아서 입력하고 [제한 없음]을 선택 취소하세요"), + ("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", "(임시/영구) 비밀번호 모두 사용"), + ("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", "복사됨"), + ("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", "모바일 액션"), + ("Mobile Actions", "모바일 작업"), ("Select Monitor", "모니터 선택"), ("Control Actions", "제어 작업"), - ("Display Settings", "화면 설정"), + ("Display Settings", "디스플레이 설정"), ("Ratio", "비율"), ("Image Quality", "이미지 품질"), ("Scroll Style", "스크롤 스타일"), - ("Show Toolbar", "툴바 보기"), - ("Hide Toolbar", "툴바 숨기기"), - ("Direct Connection", "다이렉트 연결"), + ("Show Toolbar", "도구 모음 표시"), + ("Hide Toolbar", "도구 모음 숨기기"), + ("Direct Connection", "직접 연결"), ("Relay Connection", "릴레이 연결"), ("Secure Connection", "보안 연결"), ("Insecure Connection", "보안되지 않은 연결"), - ("Scale original", "원래 크기"), - ("Scale adaptive", "창에 맞게"), + ("Scale original", "원본 크기 조정"), + ("Scale adaptive", "크기 조정 가능"), ("General", "일반"), ("Security", "보안"), ("Theme", "테마"), ("Dark Theme", "어두운 테마"), ("Light Theme", "밝은 테마"), - ("Dark", "어둡게"), - ("Light", "밝게"), - ("Follow System", "시스템 기본값"), + ("Dark", "어두운"), + ("Light", "밝은"), + ("Follow System", "시스템 설정 따름"), ("Enable hardware codec", "하드웨어 코덱 활성화"), ("Unlock Security Settings", "보안 설정 잠금 해제"), - ("Enable audio", "오디오 활성화"), + ("Enable audio", "오디오 허용"), ("Unlock Network Settings", "네트워크 설정 잠금 해제"), ("Server", "서버"), - ("Direct IP Access", "다이렉트 IP 연결"), + ("Direct IP Access", "직접 IP 연결"), ("Proxy", "프록시"), ("Apply", "적용"), - ("Disconnect all devices?", "모든 기기의 연결을 해제하시겠습니까?"), + ("Disconnect all devices?", "모든 장치의 연결을 해제하시겠습니까?"), ("Clear", "지우기"), ("Audio Input Device", "오디오 입력 장치"), ("Use IP Whitelisting", "IP 화이트리스트 사용"), ("Network", "네트워크"), - ("Pin Toolbar", "툴바 고정"), - ("Unpin Toolbar", "툴바 고정 해제"), + ("Pin Toolbar", "도구 모음 고정"), + ("Unpin Toolbar", "도구 모음 고정 해제"), ("Recording", "녹화"), - ("Directory", "경로"), - ("Automatically record incoming sessions", "들어오는 세션을 자동으로 녹화"), - ("Automatically record outgoing sessions", ""), + ("Directory", "디렉터리"), + ("Automatically record incoming sessions", "수신 세션 자동 녹화"), + ("Automatically record outgoing sessions", "발신 세션 자동 녹화"), ("Change", "변경"), ("Start session recording", "세션 녹화 시작"), ("Stop session recording", "세션 녹화 중지"), - ("Enable recording session", "세션 녹화 활성화"), - ("Enable LAN discovery", "LAN 검색 활성화"), + ("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", "연결이 끊김"), + ("Please wait for confirmation of UAC...", "UAC 확인을 기다려주세요..."), + ("elevated_foreground_window_tip", "원격 데스크탑의 현재 창을 작동하려면 더 높은 권한이 필요하므로 일시적으로 마우스와 키보드를 사용할 수 없습니다. 원격 사용자에게 현재 창을 최소화하도록 요청하거나 연결 관리 창에서 권한 상승 버튼을 클릭할 수 있습니다. 이 문제를 방지하려면 원격 장치에 소프트웨어를 설치하는 것이 좋습니다."), + ("Disconnected", "연결 끊김"), ("Other", "기타"), ("Confirm before closing multiple tabs", "여러 탭을 닫기 전에 확인"), ("Keyboard Settings", "키보드 설정"), - ("Full Access", "전체 권한"), + ("Full Access", "전체 액세스"), ("Screen Share", "화면 공유"), - ("Wayland requires Ubuntu 21.04 or higher version.", "Wayland는 Ubuntu 21.04 이상 버전이 필요합니다."), - ("Wayland requires higher version of linux distro. Please try X11 desktop or change your OS.", "Wayland에는 더 높은 버전의 Linux 배포판이 필요합니다. X11 데스크탑을 시도하거나 OS를 변경하십시오."), - ("JumpLink", "링크연결"), - ("Please Select the screen to be shared(Operate on the peer side).", "공유할 화면을 선택하십시오(피어 측에서 작동)."), + ("ubuntu-21-04-required", "Wayland는 Ubuntu 21.04 이상 버전이 필요합니다."), + ("wayland-requires-higher-linux-version", "Wayland는 상위 버전의 Linux 배포판이 필요합니다. X11 데스크탑을 사용하거나 OS를 변경하세요."), + ("xdp-portal-unavailable", ""), + ("JumpLink", "점프 링크"), + ("Please Select the screen to be shared(Operate on the peer side).", "공유할 화면을 선택하세요 (피어 측에서 작동)"), ("Show RustDesk", "RustDesk 표시"), ("This PC", "이 PC"), ("or", "또는"), - ("Continue with", "계속"), ("Elevate", "권한 상승"), - ("Zoom cursor", "커서 줌"), + ("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...", "원격 측에서 세션 요청을 수락할 때까지 기다리십시오..."), + ("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", "접근권한의 허용여부를 요청합니다"), + ("Request access to your device", "장치에 대한 액세스 권한을 요청"), ("Hide connection management window", "연결 관리 창 숨기기"), - ("hide_cm_tip", "숨기기는 비밀번호 연결만 허용되고 고정 비밀번호만 사용되는 경우에만 허용됩니다"), - ("wayland_experiment_tip", "Wayland 지원은 실험적입니다. 무인 접근이 필요한 경우 X11을 사용하십시오"), - ("Right click to select tabs", "마우스 오른쪽 버튼을 클릭하고 탭을 선택하세요"), - ("Skipped", "건너뛰기"), + ("hide_cm_tip", "비밀번호를 통해 세션을 수락하고 영구 비밀번호를 사용하는 경우에만 숨기기 허용"), + ("wayland_experiment_tip", "Wayland 지원은 실험 단계에 있으며, 무인 접근이 필요한 경우 X11을 사용해 주세요."), + ("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", "Nvidia 그래픽 카드를 사용하고 세션이 설정된 후 원격 창이 즉시 닫히는 경우 nouveau 드라이버를 설치하고 소프트웨어 렌더링을 사용하도록 선택하는 것이 도움이 될 수 있습니다. 소프트웨어를 재시작하면 적용됩니다."), + ("software_render_tip", "Linux에서 Nvidia 그래픽 카드를 사용 중인데 원격 창이 연결 즉시 닫히는 경우 오픈 소스 Nouveau 드라이버로 전환하고 소프트웨어 렌더링을 사용하기로 선택하는 것이 도움이 될 수 있습니다. 소프트웨어를 재시작해야 합니다."), ("Always use software rendering", "항상 소프트웨어 렌더링 사용"), - ("config_input", "키보드를 통해 원격 데스크톱을 제어할 수 있으려면 RustDesk의 \"입력 모니터링\" 권한을 부여해 주세요"), - ("config_microphone", "마이크를 통한 오디오 전송을 지원하려면 RustDesk에 \"녹음\" 권한을 부여해 주세요"), - ("request_elevation_tip", "상대방이 권한 상승을 요청할 수도 있습니다"), + ("config_input", "키보드로 원격 데스크탑을 제어하려면 RustDesk에 \"입력 모니터링\" 권한을 부여해야 합니다."), + ("config_microphone", "원격으로 통화하려면 RustDesk에 \"오디오 녹음\" 권한을 부여해야 합니다."), + ("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", "통제된 사용자는 여전히 RustDesk를 실행하는 UAC 창에서 확인을 클릭해야 합니다"), + ("Choose this if the remote account is administrator", "원격 계정이 관리자인 경우 이 옵션을 선택합니다"), + ("Transmit the username and password of administrator", "관리자의 사용자 이름과 비밀번호 전송"), + ("still_click_uac_tip", "여전히 원격 사용자가 RustDesk를 실행하는 UAC 창에서 확인을 클릭해야 합니다."), ("Request Elevation", "권한 상승 요청"), - ("wait_accept_uac_tip", "원격 사용자가 UAC 대화 상자를 확인할 때까지 기다리십시오"), - ("Elevate successfully", "권한 상승이 완료되었습니다"), + ("wait_accept_uac_tip", "원격 사용자가 UAC 대화 상자를 수락할 때까지 기다리세요."), + ("Elevate successfully", "권한 상승이 성공하였습니다"), ("uppercase", "대문자"), ("lowercase", "소문자"), ("digit", "숫자"), - ("special character", "특수문자"), + ("special character", "특수 문자"), ("length>=8", "8자 이상"), ("Weak", "약함"), ("Medium", "보통"), ("Strong", "강력"), - ("Switch Sides", "제어 방향 반전"), + ("Switch Sides", "역할 전환"), ("Please confirm if you want to share your desktop?", "데스크탑을 공유하시겠습니까?"), ("Display", "디스플레이"), ("Default View Style", "기본 보기 스타일"), @@ -443,10 +439,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("FPS", "FPS"), ("Auto", "자동"), ("Other Default Options", "기타 기본 옵션"), - ("Voice call", "음성통화"), - ("Text chat", "채팅"), - ("Stop voice call", "음성통화 종료"), - ("relay_hint_tip", "다이렉트 연결이 안될 수도 있으니 릴레이 연결을 시도해보세요. \n또한 릴레이 연결을 바로 사용하고 싶다면 ID 뒤에 /r을 추가하면 되고, 최근 방문에 해당 카드가 존재한다면 카드 옵션에서 릴레이 연결을 강제하도록 선택할 수도 있습니다."), + ("Voice call", "음성 통화"), + ("Text chat", "텍스트 채팅"), + ("Stop voice call", "음성 통화 종료"), + ("relay_hint_tip", "직접 연결이 불가능할 수 있으며 릴레이를 통해 연결을 시도할 수 있습니다. 또한 첫 번째 시도에서 릴레이를 사용하려면 아이디에 \"/r\" 접미사를 추가하거나 최근 세션 카드에 \"항상 릴레이를 통해 연결\" 옵션이 있는 경우 이 옵션을 선택하면 됩니다."), ("Reconnect", "다시 연결"), ("Codec", "코덱"), ("Resolution", "해상도"), @@ -454,17 +450,16 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Set one-time password length", "일회용 비밀번호 길이 설정"), ("RDP Settings", "RDP 설정"), ("Sort by", "정렬 기준"), - ("New Connection", "새로운 연결"), + ("New Connection", "새 연결"), ("Restore", "복원"), ("Minimize", "최소화"), ("Maximize", "최대화"), ("Your Device", "내 장치"), - ("empty_recent_tip", "최근 세션이 없습니다. 새 세션을 시작해보세요"), - ("empty_favorite_tip", "장치 즐겨찾기가 없습니다. 새 즐겨찾기를 추가해보세요"), - ("empty_lan_tip", "제어되는 장치가 발견되지 않았습니다."), - ("empty_address_book_tip", "현재 주소록에 제어되는 클라이언트가 없습니다"), - ("eg: admin", "예: 관리자"), - ("Empty Username", "사용자명이 비어있습니다"), + ("empty_recent_tip", "어머나, 최근 세션이 없네요!\n새로운 것을 계획할 시간입니다."), + ("empty_favorite_tip", "아직 즐겨찾는 피어가 없나요?\n연결하고 싶은 피어를 찾아 즐겨찾기에 추가해 보세요!"), + ("empty_lan_tip", "오 아니요, 아직 피어를 발견하지 못한 것 같습니다."), + ("empty_address_book_tip", "오, 이게 무슨 일인지 주소록에 현재 나열된 피어가 없는 것 같습니다."), + ("Empty Username", "사용자 이름이 비어있습니다"), ("Empty Password", "비밀번호가 비어있습니다"), ("Me", "나"), ("identical_file_tip", "이 파일은 상대방의 파일과 일치합니다."), @@ -472,186 +467,283 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("View Mode", "보기 모드"), ("login_linux_tip", "X 데스크탑을 활성화하려면 제어되는 터미널의 Linux 계정에 로그인하세요"), ("verify_rustdesk_password_tip", "RustDesk 비밀번호 확인"), - ("remember_account_tip", "이 계정을 기억하세요"), - ("os_account_desk_tip", "모니터가 없는 환경에서 이 계정은 제어되는 시스템에 로그인하고 데스크탑을 활성화하는 데 사용됩니다"), + ("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", "시스템 사운드"), + ("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", "기본"), - ("New RDP", "새로운 RDP"), + ("New RDP", "새 RDP"), ("Fingerprint", "지문"), ("Copy Fingerprint", "지문 복사"), ("no fingerprints", "지문이 없습니다"), - ("Select a peer", "동료를 선택하세요"), - ("Select peers", "동료 선택"), + ("Select a peer", "피어 선택"), + ("Select peers", "피어 선택"), ("Plugins", "플러그인"), - ("Uninstall", "제거"), + ("Uninstall", "설치 제거"), ("Update", "업데이트"), - ("Enable", "활성화"), - ("Disable", "비활성화"), + ("Enable", "허용"), + ("Disable", "사용 안 함"), ("Options", "옵션"), - ("resolution_original_tip", "기본 해상도"), - ("resolution_fit_local_tip", "로컬 해상도로 변경"), - ("resolution_custom_tip", "맞춤 해상도"), - ("Collapse toolbar", "툴바 접기"), - ("Accept and Elevate", "권한 상승 승인"), - ("accept_and_elevate_btn_tooltip", "UAC 권한 상승 및 연결 승인"), - ("clipboard_wait_response_timeout_tip", "복사 응답 시간이 초과되었습니다."), - ("Incoming connection", "연결이 요청되었습니다"), - ("Outgoing connection", "나가는 연결"), - ("Exit", "나가기"), + ("resolution_original_tip", "원본 해상도"), + ("resolution_fit_local_tip", "로컬 화면에 맞춤"), + ("resolution_custom_tip", "사용자 지정 해상도"), + ("Collapse toolbar", "도구 모음 접기"), + ("Accept and Elevate", "수락 및 권한 상승"), + ("accept_and_elevate_btn_tooltip", "연결을 수락하고 UAC 권한을 높입니다."), + ("clipboard_wait_response_timeout_tip", "복사 응답을 기다리는 동안 시간이 초과되었습니다."), + ("Incoming connection", "수신 연결"), + ("Outgoing connection", "발신 연결"), + ("Exit", "종료"), ("Open", "열기"), - ("logout_tip", "정말 로그아웃하시겠습니까?"), + ("logout_tip", "로그아웃하시겠습니까?"), ("Service", "서비스"), ("Start", "시작"), ("Stop", "중지"), - ("exceed_max_devices", "관리되는 장치가 최대치에 도달했습니다."), + ("exceed_max_devices", "관리되는 장치의 최대 수에 도달했습니다."), ("Sync with recent sessions", "최근 세션과 동기화"), ("Sort tags", "태그 정렬"), ("Open connection in new tab", "새 탭에서 연결 열기"), - ("Move tab to new window", "탭을 새 창으로 이동"), + ("Move tab to new window", "새 창으로 탭 이동"), ("Can not be empty", "비워둘 수 없습니다"), - ("Already exists", "이미 존재 합니다"), + ("Already exists", "이미 존재합니다"), ("Change Password", "비밀번호 변경"), - ("Refresh Password", "비밀번호 새로고침"), + ("Refresh Password", "비밀번호 새로 고침"), ("ID", "ID"), - ("Grid View", "그리드 보기"), - ("List View", "리스트 보기"), + ("Grid View", "격자 보기"), + ("List View", "목록 보기"), ("Select", "선택"), ("Toggle Tags", "태그 전환"), - ("pull_ab_failed_tip", "주소록을 가져오지 못했습니다."), - ("push_ab_failed_tip", "주소록 업로드 실패"), - ("synced_peer_readded_tip", "최근 세션에 있는 장치는 주소록에 다시 동기화됩니다."), + ("pull_ab_failed_tip", "주소록을 새로 고치지 못했습니다"), + ("push_ab_failed_tip", "주소록을 서버에 동기화하지 못했습니다"), + ("synced_peer_readded_tip", "최근 세션에 있던 장치들이 주소록으로 다시 동기화될 것입니다."), ("Change Color", "색상 변경"), ("Primary Color", "기본 색상"), ("HSV Color", "HSV 색상"), - ("Installation Successful!", "설치 성공!"), - ("Installation failed!", "설치 실패!"), + ("Installation Successful!", "설치에 성공했습니다!"), + ("Installation failed!", "설치에 실패했습니다!"), ("Reverse mouse wheel", "마우스 휠 반전"), ("{} sessions", "{} 세션"), - ("scam_title", "당신은 사기를 당했을 수도 있습니다"), - ("scam_text1", "모르는 사람과 통화 중이고 그들이 RustDesk를 사용하여 서비스를 시작하라고 요청하는 경우, 계속하지 말고 즉시 전화를 끊으세요"), - ("scam_text2", "그들은 귀하의 돈이나 기타 개인 정보를 훔치려는 사기꾼일 가능성이 높습니다"), + ("scam_title", "사기를 당하고 있을 수 있습니다!"), + ("scam_text1", "알지 못하고 신뢰할 수 없는 사람이 전화를 걸어 RustDesk를 사용하고 서비스를 시작하라고 요청하는 경우 계속 진행하지 말고 즉시 전화를 끊으세요."), + ("scam_text2", "사기꾼이 귀하의 돈이나 기타 개인 정보를 훔치려 할 가능성이 높습니다."), ("Don't show again", "다시 표시하지 않음"), ("I Agree", "동의"), ("Decline", "거절"), - ("Timeout in minutes", "시간 초과(분)"), - ("auto_disconnect_option_tip", "비활성 세션 자동 종료"), - ("Connection failed due to inactivity", "장시간 활동이 없어 연결이 자동으로 종료되었습니다"), + ("Timeout in minutes", "시간 초과 (분)"), + ("auto_disconnect_option_tip", "사용자가 비활성 상태일 때 수신 세션 자동 종료"), + ("Connection failed due to inactivity", "활동이 없어 자동으로 연결이 끊어졌습니다"), ("Check for software update on startup", "시작 시 소프트웨어 업데이트 확인"), - ("upgrade_rustdesk_server_pro_to_{}_tip", "RustDesk Server Pro를 {} 버전 이상으로 업그레이드하십시오!"), - ("pull_group_failed_tip", "그룹 정보를 가져오지 못했습니다"), - ("Filter by intersection", "교차로로 필터링"), - ("Remove wallpaper during incoming sessions", "세션 수락시 배경화면 제거"), + ("upgrade_rustdesk_server_pro_to_{}_tip", "RustDesk Server Pro를 {} 버전 이상으로 업그레이드하세요!"), + ("pull_group_failed_tip", "그룹 새로 고침에 실패했습니다"), + ("Filter by intersection", "교차해서 필터링"), + ("Remove wallpaper during incoming sessions", "수신 세션 동안 배경화면 제거"), ("Test", "테스트"), - ("display_is_plugged_out_msg", "디스플레이가 연결되어 있지 않습니다. 첫 번째 디스플레이로 전환하세요."), + ("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", "SELinux를 활성화하면 RustDesk가 호스트로 제대로 실행되지 않을 수 있습니다"), + ("Use all my displays for the remote session", "원격 세션에 내 모든 디스플레이 사용"), + ("selinux_tip", "SELinux가 장치에서 활성화되어 있어 RustDesk가 제어된 상태로 제대로 작동하지 않을 수 있습니다."), ("Change view", "보기 변경"), ("Big tiles", "큰 타일"), ("Small tiles", "작은 타일"), - ("List", "리스트"), + ("List", "목록"), ("Virtual display", "가상 디스플레이"), - ("Plug out all", "전원 모두 끄기"), + ("Plug out all", "모든 플러그를 뽑으세요"), ("True color (4:4:4)", "트루컬러 (4:4:4)"), ("Enable blocking user input", "사용자 입력 차단 허용"), - ("id_input_tip", "입력된 ID, IP, 도메인과 포트(:)를 입력할 수 있습니다.\n다른 서버에 있는 장치에 연결하려면 서버 주소(@?key=)를 추가하세요"), + ("id_input_tip", "ID, 직접 IP 또는 포트가 있는 도메인 (:)을 입력할 수 있습니다.\n다른 서버에 있는 장치에 액세스하려면 서버 주소 (@?key=)를 추가하세요. 예를들어 \n9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=.\n공용 서버의 장치에 액세스하려면 \"@public\"을 입력하세요. 공용 서버에서는 키가 필요하지 않습니다.\n\n첫 번째 연결에서 릴레이 연결을 강제로 사용하려면 ID 끝에 \"/r\"을 추가합니다, 예를들면 \"9123456234/r\"."), ("privacy_mode_impl_mag_tip", "모드 1"), ("privacy_mode_impl_virtual_display_tip", "모드 2"), - ("Enter privacy mode", "개인정보 보호 모드 사용"), + ("Enter privacy mode", "개인정보 보호 모드 시작"), ("Exit privacy mode", "개인정보 보호 모드 종료"), ("idd_not_support_under_win10_2004_tip", "간접 디스플레이 드라이버는 지원되지 않습니다. Windows 10 버전 2004 이상이 필요합니다."), - ("input_source_1_tip", "입력소스 1"), - ("input_source_2_tip", "입력소스 2"), + ("input_source_1_tip", "입력 소스 1"), + ("input_source_2_tip", "입력 소스 2"), ("Swap control-command key", "Control 및 Command 키 교체"), - ("swap-left-right-mouse", "마우스 왼쪽 버튼과 오른쪽 버튼 바꾸기"), - ("2FA code", "2단계 인증 코드"), - ("More", "더보기"), - ("enable-2fa-title", "2단계 인증 활성화"), - ("enable-2fa-desc", "지금 인증자를 설정하세요. 휴대폰이나 데스크톱 컴퓨터에서 Authy, Microsoft 또는 Google Authenticator와 같은 인증기를 사용할 수 있습니다. 인증기로 QR 코드를 스캔하고 표시된 코드를 입력하면 2단계 인증이 활성화됩니다. "), - ("wrong-2fa-code", "이 코드는 확인할 수 없습니다. 인증 코드와 현지 시간 설정이 올바른지 확인하세요."), - ("enter-2fa-title", "2단계 인증"), + ("swap-left-right-mouse", "마우스 왼쪽 버튼과 오른쪽 버튼 교체"), + ("2FA code", "이중 인증 코드"), + ("More", "더 많은"), + ("enable-2fa-title", "이중 인증 허용"), + ("enable-2fa-desc", "지금 인증앱을 설정해 주세요. 휴대폰이나 데스크탑에서 Authy, Microsoft 또는 Google 인증기와 같은 인증기 앱을 사용할 수 있습니다.\n\n앱으로 QR 코드를 스캔하고 앱에 표시된 코드를 입력하면 이중 인증이 가능합니다."), + ("wrong-2fa-code", "코드를 확인할 수 없습니다. 코드와 현지 시간 설정이 올바른지 확인합니다"), + ("enter-2fa-title", "이중 인증"), ("Email verification code must be 6 characters.", "이메일 인증 코드는 6자여야 합니다."), - ("2FA code must be 6 digits.", "2단계 인증 코드는 6자리여야 합니다."), - ("Multiple Windows sessions found", "여러 Windows 세션이 발견되었습니다."), - ("Please select the session you want to connect to", "연결하려는 세션을 선택하세요."), + ("2FA code must be 6 digits.", "이중 인증 코드는 6자리여야 합니다."), + ("Multiple Windows sessions found", "여러 Windows 세션이 발견되었습니다"), + ("Please select the session you want to connect to", "연결할 세션을 선택해 주세요"), ("powered_by_me", "RustDesk 제공"), - ("outgoing_only_desk_tip", "이것은 맞춤형 버전입니다.\n다른 장치에 연결할 수 있지만 다른 장치는 귀하의 장치에 연결할 수 없습니다."), - ("preset_password_warning", "이 맞춤형 에디션은 미리 설정된 비밀번호가 포함되어 있습니다. 이 비밀번호를 아는 사람은 누구나 귀하의 장치를 완전히 제어할 수 있습니다. 이 상황을 예상하지 못했다면, 즉시 소프트웨어를 삭제하시기 바랍니다."), + ("outgoing_only_desk_tip", "이것은 맞춤형 에디션입니다.\n다른 장치에 연결할 수는 있지만 귀하의 기기에 연결할 수 없습니다."), + ("preset_password_warning", "이 맞춤형 에디션에는 미리 설정된 비밀번호가 함께 제공됩니다. 이 비밀번호를 아는 사람이라면 누구나 기기를 완전히 제어할 수 있습니다. 예상치 못한 경우 즉시 소프트웨어를 제거하세요."), ("Security Alert", "보안 경고"), ("My address book", "내 주소록"), ("Personal", "개인"), ("Owner", "소유자"), - ("Set shared password", "공유 암호 설정"), - ("Exist in", "존재함"), - ("Read-only", "읽기전용"), + ("Set shared password", "공유 비밀번호 설정"), + ("Exist in", "다음 위치 존재"), + ("Read-only", "읽기 전용"), ("Read/Write", "읽기/쓰기"), ("Full Control", "전체 제어"), - ("share_warning_tip", "위의 항목들은 다른 사람들과 공유되며, 다른 사람들이 볼 수 있습니다."), + ("share_warning_tip", "위의 필드는 공유되고 다른 사람들에게 보입니다."), ("Everyone", "모두"), ("ab_web_console_tip", "웹 콘솔에 대해 더 알아보기"), - ("allow-only-conn-window-open-tip", "RustDesk 창이 열려 있는 경우에만 연결 허용"), - ("no_need_privacy_mode_no_physical_displays_tip", "물리적 디스플레이가 없으므로 프라이버시 모드를 사용할 필요가 없습니다."), - ("Follow remote cursor", "원격 커서 따르기"), - ("Follow remote window focus", "원격 창 포커스 따르기"), - ("default_proxy_tip", "기본 프로토콜과 포트는 Socks5와 1080입니다."), + ("allow-only-conn-window-open-tip", "RustDesk 창이 열려 있을 때만 연결 허용"), + ("no_need_privacy_mode_no_physical_displays_tip", "실제 디스플레이가 없으므로 개인 정보 보호 모드를 사용할 필요가 없습니다."), + ("Follow remote cursor", "원격 커서 따라가기"), + ("Follow remote window focus", "원격 창 초점 따라가기"), + ("default_proxy_tip", "기본 프로토콜 및 포트는 Socks5 및 1080입니다"), ("no_audio_input_device_tip", "오디오 입력 장치를 찾을 수 없습니다."), - ("Incoming", "수신중"), - ("Outgoing", "발신중"), - ("Clear Wayland screen selection", "Wayland 화면 선택 취소"), - ("clear_Wayland_screen_selection_tip", "화면 선택을 취소한 후 다시 공유할 화면을 선택할 수 있습니다."), + ("Incoming", "수신"), + ("Outgoing", "발신"), + ("Clear Wayland screen selection", "Wayland 화면 선택 지우기"), + ("clear_Wayland_screen_selection_tip", "화면 선택을 지운 후, 공유할 화면을 다시 선택할 수 있습니다."), ("confirm_clear_Wayland_screen_selection_tip", "Wayland 화면 선택을 정말 취소하시겠습니까?"), - ("android_new_voice_call_tip", "새로운 음성 통화 요청이 있습니다. 수락하면 오디오가 음성 통신으로 전환됩니다."), - ("texture_render_tip", "텍스처 렌더링을 사용하면 이미지가 더 부드러워집니다. 렌더링 문제가 발생하면 이 옵션을 비활성화해 보세요."), + ("android_new_voice_call_tip", "새 음성 통화 요청이 수신되었습니다. 수락하면 오디오가 음성 통신으로 전환됩니다."), + ("texture_render_tip", "텍스처 렌더링을 사용하여 사진을 더 부드럽게 만듭니다. 렌더링 문제가 발생하면 이 옵션을 비활성화할 수 있습니다."), ("Use texture rendering", "텍스처 렌더링 사용"), - ("Floating window", "플로팅 윈도우"), - ("floating_window_tip", "RustDesk 백그라운드 서비스를 유지하는 것이 좋습니다."), + ("Floating window", "플로팅 창"), + ("floating_window_tip", "RustDesk 백그라운드 서비스를 유지하는 데 도움이 됩니다"), ("Keep screen on", "화면 켜짐 유지"), ("Never", "없음"), ("During controlled", "제어되는 동안"), - ("During service is on", "서비스가 켜져 있는 동안"), - ("Capture screen using DirectX", "다이렉트X를 사용한 화면 캡처"), + ("During service is on", "서비스 중"), + ("Capture screen using DirectX", "DirectX를 사용하여 화면 캡처"), ("Back", "뒤로"), ("Apps", "앱"), - ("Volume up", "볼륨 증가"), - ("Volume down", "볼륨 감소"), - ("Power", "파워"), - ("Telegram bot", "텔레그램 봇"), - ("enable-bot-tip", "이 기능을 활성화하면 봇으로부터 2FA 코드를 받을 수 있습니다. 또한 연결 알림으로도 작동할 수 있습니다."), - ("enable-bot-desc", "1. @BotFather와 채팅을 시작하세요.\n2. \"/newbot\" 명령어를 보내세요. 토큰을 받게 됩니다.\n3. 새로 생성된 봇과 채팅을 시작하고 \"/hello\" 등의 명령어를 보내 봇을 활성화하세요."), - ("cancel-2fa-confirm-tip", "2FA를 정말 취소하시겠습니까?"), - ("cancel-bot-confirm-tip", "텔레그램 봇을 정말 삭제하시겠습니까?"), - ("About RustDesk", "RustDesk 대하여"), - ("Send clipboard keystrokes", "클립보드 키 입력 전송"), - ("network_error_tip", "네트워크 연결을 확인한 후 다시 시도하세요."), - ("Unlock with PIN", "핀으로 잠금 해제"), + ("Volume up", "볼륨 높이기"), + ("Volume down", "볼륨 낮추기"), + ("Power", "전원"), + ("Telegram bot", "Telegram 봇"), + ("enable-bot-tip", "이 기능을 활성화하면 봇에서 이중 인증 코드를 받을 수 있습니다. 또한 연결 알림 기능도 할 수 있습니다."), + ("enable-bot-desc", "1. @BotFather와 채팅을 시작합니다.\n2. \"/newbot\" 명령을 보내주세요. 이 단계를 완료하면 토큰을 받게 됩니다.\n3. 새로 만든 봇과 채팅을 시작합니다. \"/hello\"와 같이 앞에 슬래시 (\"/\")로 시작하는 메시지를 보내 활성화합니다."), + ("cancel-2fa-confirm-tip", "이중 인증을 취소하시겠습니까?"), + ("cancel-bot-confirm-tip", "Telegram 봇을 취소하시겠습니까?"), + ("About RustDesk", "RustDesk 정보"), + ("Send clipboard keystrokes", "클립보드 키 입력 보내기"), + ("network_error_tip", "네트워크 연결을 확인한 다음 재시도를 클릭하세요."), + ("Unlock with PIN", "PIN으로 잠금 해제"), ("Requires at least {} characters", "최소 {}자 이상 필요합니다."), - ("Wrong PIN", "잘못된 핀"), - ("Set PIN", "핀 설정"), - ("Enable trusted devices", "신뢰할 수 있는 장치 활성화"), + ("Wrong PIN", "잘못된 PIN"), + ("Set PIN", "PIN 설정"), + ("Enable trusted devices", "신뢰할 수 있는 장치 허용"), ("Manage trusted devices", "신뢰할 수 있는 장치 관리"), ("Platform", "플랫폼"), ("Days remaining", "일 남음"), - ("enable-trusted-devices-tip", "신뢰할 수 있는 기기에서 2FA 검증 건너뛰기"), - ("Parent directory", "상위 디렉토리"), + ("enable-trusted-devices-tip", "신뢰할 수 있는 장치에서 이중 인증 건너뛰기"), + ("Parent directory", "상위 디렉터리"), ("Resume", "재개"), ("Invalid file name", "잘못된 파일 이름"), - ("one-way-file-transfer-tip", "단방향 파일 전송은 제어되는 쪽에서 활성화됩니다."), - ("Authentication Required", "인증 필요함"), + ("one-way-file-transfer-tip", "제어되는 측에서는 단방향 파일 전송이 가능합니다."), + ("Authentication Required", "인증 필요"), ("Authenticate", "인증"), - ("web_id_input_tip", ""), - ("Download", ""), - ("Upload folder", ""), - ("Upload files", ""), - ("Clipboard is synchronized", ""), + ("web_id_input_tip", "동일한 서버에 ID를 입력할 수 있으며, 웹 클라이언트에서는 다이렉트 IP 액세스가 지원되지 않습니다.\n다른 서버에 있는 장치에 액세스하려면 서버 주소 (@?key=)를 추가해 주세요. 예를 들어 \n9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=.\n공용 서버에서 장치에 액세스하려면 \"@public\"을 입력해 주세요. 공용 서버에는 키가 필요하지 않습니다."), + ("Download", "다운로드"), + ("Upload folder", "폴더 업로드"), + ("Upload files", "파일 업로드"), + ("Clipboard is synchronized", "클립보드가 동기화되었습니다"), + ("Update client clipboard", "클라이언트 클립보드 업데이트"), + ("Untagged", "태그 없음"), + ("new-version-of-{}-tip", "{}의 새 버전을 사용할 수 있습니다"), + ("Accessible devices", "액세스 가능한 장치"), + ("upgrade_remote_rustdesk_client_to_{}_tip", "RustDesk 클라이언트를 원격 버전 {} 이상으로 업그레이드해 주세요!"), + ("d3d_render_tip", "D3D 렌더링이 활성화되면 일부 기기에서는 원격 화면이 검은색으로 표시될 수 있습니다."), + ("Use D3D rendering", "D3D 렌더링 사용"), + ("Printer", "프린터"), + ("printer-os-requirement-tip", "프린터 출력 기능은 Windows 10 이상이 필요합니다."), + ("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", "WebSocket을 사용할 때는 릴레이 연결만 지원됩니다."), + ("Use 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", "기본적으로 RustDesk는 TLS를 사용하여 프로토콜에 대한 서버 인증서를 검증합니다.\n이 옵션을 활성화하면 RustDesk는 인증 단계를 건너뛰고 인증 실패 시 진행합니다."), + ("Disable UDP", "UDP 사용 안 함"), + ("disable-udp-tip", "TCP만 사용할지 여부를 제어합니다.\n이 옵션을 활성화하면 RustDesk는 더 이상 UDP 2116을 사용하지 않고 대신 TCP 2116을 사용합니다."), + ("server-oss-not-support-tip", "참고: RustDesk 서버 OSS에는 이 기능이 포함되어 있지 않습니다."), + ("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 {}", "{}(으)로 계속"), + ("Display Name", "표시 이름"), + ("password-hidden-tip", "영구 비밀번호가 설정되었습니다 (숨김)."), + ("preset-password-in-use-tip", "현재 사전 설정된 비밀번호가 사용 중입니다."), + ("Enable privacy mode", "개인정보 보호 모드 사용함"), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/kz.rs b/src/lang/kz.rs index 07ca645f2..4476fadc7 100644 --- a/src/lang/kz.rs +++ b/src/lang/kz.rs @@ -41,7 +41,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("length %min% to %max%", ""), ("starts with a letter", ""), ("allowed characters", ""), - ("id_change_tip", "Тек a-z, A-Z, 0-9 және _ (астынғы-сызық) таңбалары рұқсат етілген. Бірінші таңба a-z, A-Z болуы қажет. Ұзындығы 6 мен 16 арасы."), + ("id_change_tip", "Тек a-z, A-Z, 0-9, - (dash) және _ (астынғы-сызық) таңбалары рұқсат етілген. Бірінші таңба a-z, A-Z болуы қажет. Ұзындығы 6 мен 16 арасы."), ("Website", "Web-сайт"), ("About", "Туралы"), ("Slogan_tip", ""), @@ -147,8 +147,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("OS Password", "OS Құпия сөзі"), ("install_tip", "UAC кесірінен, RustDesk кейбірде қашықтағы жақ ретінде дұрыс жұмыс істей алмайды. UAC'пен қиындықты болдырмау үшін, төмендегі батырманы басып RustDesk'ті жүйеге орнатыңыз."), ("Click to upgrade", "Жаңғырту үшін басыңыз"), - ("Click to download", "Жүктеу үшін басыңыз"), - ("Click to update", "Жаңарту үшін басыңыз"), ("Configure", "Қалыптау"), ("config_acc", "Сіздің Жұмыс үстеліңізді қашықтан басқару үшін, RustDesk'ке \"Қолжетімділік\" рұқсаттарын беруіңіз керек."), ("config_screen", "Сіздің Жұмыс үстеліңізге қашықтан қол жеткізу үшін, RustDesk'ке \"Екіренді Жазу\" рұқсаттарын беруіңіз керек."), @@ -267,7 +265,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("No permission of file transfer", "Файыл алмасуға рұқсат берілмеген"), ("Note", "Нота"), ("Connection", "Қосылым"), - ("Share Screen", "Екіренді Бөлісу"), + ("Share screen", "Екіренді Бөлісу"), ("Chat", "Чат"), ("Total", "Барлығы"), ("items", "зат"), @@ -275,8 +273,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Screen Capture", "Екіренді Түсіру"), ("Input Control", "Еңгізуді Басқару/Қадағалау"), ("Audio Capture", "Аудио Түсіру"), - ("File Connection", "Файыл Қосылымы"), - ("Screen Connection", "Екірен Қосылымы"), ("Do you accept?", "Қабылдайсыз ба?"), ("Open System Setting", "Жүйе Орнатпаларын Ашу"), ("How to get Android input permission?", "Android еңгізу рұқсатын қалай алуға болады?"), @@ -381,14 +377,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Keyboard Settings", ""), ("Full Access", ""), ("Screen Share", ""), - ("Wayland requires Ubuntu 21.04 or higher version.", "Wayland Ubuntu 21.04 немесе одан жоғары нұсқасын қажет етеді."), - ("Wayland requires higher version of linux distro. Please try X11 desktop or change your OS.", "Wayland linux дистрибутивінің жоғарырақ нұсқасын қажет етеді. X11 жұмыс үстелін қолданып көріңіз немесе операциялық жүйеңізді өзгертіңіз."), + ("ubuntu-21-04-required", "Wayland Ubuntu 21.04 немесе одан жоғары нұсқасын қажет етеді."), + ("wayland-requires-higher-linux-version", "Wayland linux дистрибутивінің жоғарырақ нұсқасын қажет етеді. X11 жұмыс үстелін қолданып көріңіз немесе операциялық жүйеңізді өзгертіңіз."), + ("xdp-portal-unavailable", ""), ("JumpLink", "View"), ("Please Select the screen to be shared(Operate on the peer side).", "Бөлісетін экранды таңдаңыз (бірдей жағынан жұмыс жасаңыз)."), ("Show RustDesk", ""), ("This PC", ""), ("or", ""), - ("Continue with", ""), ("Elevate", ""), ("Zoom cursor", ""), ("Accept sessions via password", ""), @@ -463,7 +459,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("empty_favorite_tip", ""), ("empty_lan_tip", ""), ("empty_address_book_tip", ""), - ("eg: admin", ""), ("Empty Username", ""), ("Empty Password", ""), ("Me", ""), @@ -653,5 +648,102 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Upload folder", ""), ("Upload files", ""), ("Clipboard is synchronized", ""), + ("Update client clipboard", ""), + ("Untagged", ""), + ("new-version-of-{}-tip", ""), + ("Accessible devices", ""), + ("upgrade_remote_rustdesk_client_to_{}_tip", "Қашықтағы жақтағы RustDesk клиентін {} немесе одан жоғары нұсқаға жаңартуды өтінеміз!"), + ("d3d_render_tip", ""), + ("Use D3D rendering", ""), + ("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", ""), + ("Trackpad speed", ""), + ("Default trackpad speed", ""), + ("Numeric one-time password", ""), + ("Enable IPv6 P2P connection", ""), + ("Enable UDP hole punching", ""), + ("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", ""), + ("allow-insecure-tls-fallback-tip", ""), + ("Disable 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 {}", ""), + ("Display Name", ""), + ("password-hidden-tip", ""), + ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/lt.rs b/src/lang/lt.rs index 9a2069163..47ace51ae 100644 --- a/src/lang/lt.rs +++ b/src/lang/lt.rs @@ -147,8 +147,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("OS Password", "OS slaptažodis"), ("install_tip", "Kai kuriais atvejais UAC gali priversti RustDesk netinkamai veikti nuotoliniame pagrindiniame kompiuteryje. Norėdami apeiti UAC, spustelėkite toliau esantį mygtuką, kad įdiegtumėte RustDesk į savo kompiuterį."), ("Click to upgrade", "Spustelėkite, jei norite atnaujinti"), - ("Click to download", "Spustelėkite norėdami atsisiųsti"), - ("Click to update", "Spustelėkite norėdami atnaujinti"), ("Configure", "Konfigūruoti"), ("config_acc", "Norėdami nuotoliniu būdu valdyti darbalaukį, turite suteikti RustDesk \"prieigos\" leidimus"), ("config_screen", "Norėdami nuotoliniu būdu pasiekti darbalaukį, turite suteikti RustDesk leidimus \"ekrano kopija\""), @@ -267,7 +265,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("No permission of file transfer", "Nėra leidimo perkelti failus"), ("Note", "Pastaba"), ("Connection", "Ryšys"), - ("Share Screen", "Bendrinti ekraną"), + ("Share screen", "Bendrinti ekraną"), ("Chat", "Pokalbis"), ("Total", "Iš viso"), ("items", "elementai"), @@ -275,8 +273,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Screen Capture", "Ekrano nuotrauka"), ("Input Control", "Įvesties valdymas"), ("Audio Capture", "Garso fiksavimas"), - ("File Connection", "Failo ryšys"), - ("Screen Connection", "Ekrano jungtis"), ("Do you accept?", "Ar sutinki?"), ("Open System Setting", "Atviros sistemos nustatymas"), ("How to get Android input permission?", "Kaip gauti Android įvesties leidimą?"), @@ -381,14 +377,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Keyboard Settings", "Klaviatūros nustatymai"), ("Full Access", "Pilna prieiga"), ("Screen Share", "Ekrano bendrinimas"), - ("Wayland requires Ubuntu 21.04 or higher version.", "Wayland reikalauja Ubuntu 21.04 arba naujesnės versijos."), - ("Wayland requires higher version of linux distro. Please try X11 desktop or change your OS.", "Wayland reikalinga naujesnės Linux Distro versijos. Išbandykite X11 darbalaukį arba pakeiskite OS."), + ("ubuntu-21-04-required", "Wayland reikalauja Ubuntu 21.04 arba naujesnės versijos."), + ("wayland-requires-higher-linux-version", "Wayland reikalinga naujesnės Linux Distro versijos. Išbandykite X11 darbalaukį arba pakeiskite OS."), + ("xdp-portal-unavailable", ""), ("JumpLink", "Peržiūra"), ("Please Select the screen to be shared(Operate on the peer side).", "Prašome pasirinkti ekraną, kurį norite bendrinti (veikiantį kitoje pusėje)."), ("Show RustDesk", "Rodyti RustDesk"), ("This PC", "Šis kompiuteris"), ("or", "arba"), - ("Continue with", "Tęsti su"), ("Elevate", "Pakelti"), ("Zoom cursor", "Mastelio keitimo žymeklis"), ("Accept sessions via password", "Priimti seansus naudojant slaptažodį"), @@ -463,7 +459,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("empty_favorite_tip", "Dar neturite parankinių nuotolinių seansų."), ("empty_lan_tip", "Nuotolinių mazgų nerasta."), ("empty_address_book_tip", "Adresų knygelėje nėra nuotolinių kompiuterių."), - ("eg: admin", "pvz.: administratorius"), ("Empty Username", "Tuščias naudotojo vardas"), ("Empty Password", "Tuščias slaptažodis"), ("Me", "Aš"), @@ -653,5 +648,102 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Upload folder", ""), ("Upload files", ""), ("Clipboard is synchronized", ""), + ("Update client clipboard", ""), + ("Untagged", ""), + ("new-version-of-{}-tip", ""), + ("Accessible devices", ""), + ("upgrade_remote_rustdesk_client_to_{}_tip", "Prašome atnaujinti nuotolinės pusės RustDesk klientą į {} ar naujesnę versiją!"), + ("d3d_render_tip", ""), + ("Use D3D rendering", ""), + ("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", ""), + ("Trackpad speed", ""), + ("Default trackpad speed", ""), + ("Numeric one-time password", ""), + ("Enable IPv6 P2P connection", ""), + ("Enable UDP hole punching", ""), + ("View camera", "Peržiūrėti kamerą"), + ("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", ""), + ("allow-insecure-tls-fallback-tip", ""), + ("Disable 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 {}", "Tęsti su {}"), + ("Display Name", ""), + ("password-hidden-tip", ""), + ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/lv.rs b/src/lang/lv.rs index e8ba903df..4f8e1f59f 100644 --- a/src/lang/lv.rs +++ b/src/lang/lv.rs @@ -41,7 +41,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("length %min% to %max%", "garums %min% līdz %max%"), ("starts with a letter", "sākas ar burtu"), ("allowed characters", "atļautās rakstzīmes"), - ("id_change_tip", "Atļautas tikai rakstzīmes a-z, A-Z, 0-9 un _ (pasvītrojums). Pirmajam burtam ir jābūt a-z, A-Z. Garums no 6 līdz 16."), + ("id_change_tip", "Atļautas tikai rakstzīmes a-z, A-Z, 0-9, - (domuzīme) un _ (pasvītrojums). Pirmajam burtam ir jābūt a-z, A-Z. Garums no 6 līdz 16."), ("Website", "Tīmekļa vietne"), ("About", "Par"), ("Slogan_tip", "Radīts ar sirdi šajā haotiskajā pasaulē!"), @@ -147,8 +147,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("OS Password", "OS parole"), ("install_tip", "UAC dēļ RustDesk dažos gadījumos nevar pareizi darboties kā attālā puse. Lai izvairītos no UAC, lūdzu, noklikšķiniet uz tālāk esošās pogas, lai instalētu RustDesk sistēmā."), ("Click to upgrade", "Jaunināt"), - ("Click to download", "Lejupielādēt"), - ("Click to update", "Atjaunināt"), ("Configure", "Konfigurēt"), ("config_acc", "Lai attālināti vadītu savu darbvirsmu, jums ir jāpiešķir RustDesk \"Pieejamība\" atļaujas."), ("config_screen", "Lai attālināti piekļūtu darbvirsmai, jums ir jāpiešķir RustDesk \"Ekrāna tveršana\" atļaujas."), @@ -267,7 +265,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("No permission of file transfer", "Nav atļaujas failu pārsūtīšanai"), ("Note", "Piezīme"), ("Connection", "Savienojums"), - ("Share Screen", "Koplietot ekrānu"), + ("Share screen", "Koplietot ekrānu"), ("Chat", "Tērzēšana"), ("Total", "Kopā"), ("items", "vienumi"), @@ -275,8 +273,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Screen Capture", "Ekrāna tveršana"), ("Input Control", "Ievades vadība"), ("Audio Capture", "Audio tveršana"), - ("File Connection", "Failu savienojums"), - ("Screen Connection", "Ekrāna savienojums"), ("Do you accept?", "Vai Jūs pieņemat?"), ("Open System Setting", "Atvērt sistēmas iestatījumus"), ("How to get Android input permission?", "Kā iegūt Android ievades atļauju?"), @@ -364,7 +360,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Recording", "Ierakstīšana"), ("Directory", "Direktorija"), ("Automatically record incoming sessions", "Automātiski ierakstīt ienākošās sesijas"), - ("Automatically record outgoing sessions", ""), + ("Automatically record outgoing sessions", "Automātiski ierakstīt izejošās sesijas"), ("Change", "Mainīt"), ("Start session recording", "Sākt sesijas ierakstīšanu"), ("Stop session recording", "Apturēt sesijas ierakstīšanu"), @@ -381,14 +377,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Keyboard Settings", "Tastatūras iestatījumi"), ("Full Access", "Pilna piekļuve"), ("Screen Share", "Ekrāna kopīgošana"), - ("Wayland requires Ubuntu 21.04 or higher version.", "Wayland nepieciešama Ubuntu 21.04 vai jaunāka versija."), - ("Wayland requires higher version of linux distro. Please try X11 desktop or change your OS.", "Wayland nepieciešama augstāka Linux distro versija. Lūdzu, izmēģiniet X11 desktop vai mainiet savu OS."), + ("ubuntu-21-04-required", "Wayland nepieciešama Ubuntu 21.04 vai jaunāka versija."), + ("wayland-requires-higher-linux-version", "Wayland nepieciešama augstāka Linux distro versija. Lūdzu, izmēģiniet X11 desktop vai mainiet savu OS."), + ("xdp-portal-unavailable", ""), ("JumpLink", "Skatīt"), ("Please Select the screen to be shared(Operate on the peer side).", "Lūdzu, atlasiet kopīgojamo ekrānu (darbojieties sesijas pusē)."), ("Show RustDesk", "Rādīt RustDesk"), ("This PC", "Šis dators"), ("or", "vai"), - ("Continue with", "Turpināt ar"), ("Elevate", "Pacelt"), ("Zoom cursor", "Tālummaiņas kursors"), ("Accept sessions via password", "Pieņemt sesijas, izmantojot paroli"), @@ -463,7 +459,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("empty_favorite_tip", "Vēl nav iecienītākās sesijas?\nAtradīsim kādu, ar ko sazināties, un pievienosim to jūsu izlasei!"), ("empty_lan_tip", "Ak nē! Šķiet, ka mēs vēl neesam atklājuši nevienu sesiju."), ("empty_address_book_tip", "Ak vai, izskatās, ka jūsu adrešu grāmatā šobrīd nav neviena sesija."), - ("eg: admin", "piemēram: admin"), ("Empty Username", "Tukšs lietotājvārds"), ("Empty Password", "Tukša parole"), ("Me", "Es"), @@ -653,5 +648,102 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Upload folder", "Augšupielādēt mapi"), ("Upload files", "Augšupielādēt failus"), ("Clipboard is synchronized", "Starpliktuve ir sinhronizēta"), + ("Update client clipboard", "Atjaunināt klienta starpliktuvi"), + ("Untagged", "Neatzīmēts"), + ("new-version-of-{}-tip", "Ir pieejama jauna {} versija"), + ("Accessible devices", "Pieejamas ierīces"), + ("upgrade_remote_rustdesk_client_to_{}_tip", "Lūdzu, jauniniet attālās puses RustDesk klientu uz versiju {} vai jaunāku!"), + ("d3d_render_tip", "Ja ir iespējota D3D renderēšana, dažās ierīcēs tālvadības pults ekrāns var būt melns."), + ("Use D3D rendering", "Izmantot D3D renderēšanu"), + ("Printer", "Printeris"), + ("printer-os-requirement-tip", "Printera izejošajai funkcijai nepieciešama operētājsistēma Windows 10 vai jaunāka versija."), + ("printer-requires-installed-{}-client-tip", "Lai izmantotu attālo drukāšanu, šajā ierīcē ir jāinstalē {}."), + ("printer-{}-not-installed-tip", "Printeris {} nav instalēts."), + ("printer-{}-ready-tip", "Printeris {} ir instalēts un gatavs lietošanai."), + ("Install {} Printer", "Instalēt {} printeri"), + ("Outgoing Print Jobs", "Izejošie drukas darbi"), + ("Incoming Print Jobs", "Ienākošie drukas darbi"), + ("Incoming Print Job", "Ienākošais drukas darbs"), + ("use-the-default-printer-tip", "Izmantot noklusējuma printeri"), + ("use-the-selected-printer-tip", "Izmantot atlasīto printeri"), + ("auto-print-tip", "Drukājiet automātiski, izmantojot atlasīto printeri."), + ("print-incoming-job-confirm-tip", "Jūs saņēmāt drukas darbu no attālās ierīces. Vai vēlaties to izpildīt savā pusē?"), + ("remote-printing-disallowed-tile-tip", "Attālā drukāšana ir aizliegta"), + ("remote-printing-disallowed-text-tip", "Kontrolētās puses atļauju iestatījumi liedz attālo drukāšanu."), + ("save-settings-tip", "Saglabāt iestatījumus"), + ("dont-show-again-tip", "Nerādīt šo vēlreiz"), + ("Take screenshot", "Uzņemt ekrānuzņēmumu"), + ("Taking screenshot", "Ekrānuzņēmuma uzņemšana"), + ("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ā"), + ("Copy to clipboard", "Kopēt starpliktuvē"), + ("Enable remote printer", "Iespējot attālo printeri"), + ("Downloading {}", "Notiek {} lejupielāde"), + ("{} Update", "{} atjauninājums"), + ("{}-to-update-tip", "{} tagad tiks aizvērts un tiks instalēta jaunā versija."), + ("download-new-version-failed-tip", "Lejupielāde neizdevās. Varat mēģināt vēlreiz vai noklikšķināt uz pogas \"Lejupielādēt\", lai lejupielādētu no laidiena lapas un manuāli jauninātu."), + ("Auto update", "Automātiskā atjaunināšana"), + ("update-failed-check-msi-tip", "Instalēšanas metodes pārbaude neizdevās. Lūdzu, noklikšķiniet uz pogas \"Lejupielādēt\", lai lejupielādētu no laidiena lapas un manuāli jauninātu."), + ("websocket_tip", "Izmantojot WebSocket, tiek atbalstīti tikai releja savienojumi."), + ("Use WebSocket", "Lietot WebSocket"), + ("Trackpad speed", "Skārienpaliktņa ātrums"), + ("Default trackpad speed", "Noklusējuma skārienpaliktņa ātrums"), + ("Numeric one-time password", "Vienreiz lietojama ciparu parole"), + ("Enable IPv6 P2P connection", "Iespējot IPv6 P2P savienojumu"), + ("Enable UDP hole punching", "Iespējot UDP caurumu veidošanu"), + ("View camera", "Skatīt kameru"), + ("Enable camera", "Iespējot kameru"), + ("No cameras", "Nav kameru"), + ("view_camera_unsupported_tip", "Attālā ierīce neatbalsta kameras skatīšanos."), + ("Terminal", "Terminālis"), + ("Enable terminal", "Iespējot termināli"), + ("New tab", "Jauna cilne"), + ("Keep terminal sessions on disconnect", "Atvienojoties saglabāt termināļa sesijas"), + ("Terminal (Run as administrator)", "Terminālis (Palaist kā administratoram)"), + ("terminal-admin-login-tip", "Lūdzu, ievadiet kontrolētās puses administratora lietotājvārdu un paroli."), + ("Failed to get user token.", "Neizdevās iegūt lietotāja atļauju."), + ("Incorrect username or password.", "Nepareizs lietotājvārds vai parole."), + ("The user is not an administrator.", "Lietotājs nav administrators."), + ("Failed to check if the user is an administrator.", "Neizdevās pārbaudīt, vai lietotājs ir administrators."), + ("Supported only in the installed version.", "Atbalstīts tikai instalētajā versijā."), + ("elevation_username_tip", "Ievadiet lietotājvārdu vai domēnu\\lietotājvārdu"), + ("Preparing for installation ...", "Gatavošanās instalēšanai..."), + ("Show my cursor", "Rādīt manu kursoru"), + ("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", ""), + ("allow-insecure-tls-fallback-tip", ""), + ("Disable 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 {}", "Turpināt ar {}"), + ("Display Name", ""), + ("password-hidden-tip", ""), + ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ml.rs b/src/lang/ml.rs new file mode 100644 index 000000000..4dcfe9e74 --- /dev/null +++ b/src/lang/ml.rs @@ -0,0 +1,749 @@ +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", "ഐഡി മാറ്റുക"), + ("Your new ID", "നിങ്ങളുടെ പുതിയ ഐഡി"), + ("length %min% to %max%", "നീളം %min% മുതൽ %max% വരെ"), + ("starts with a letter", "അക്ഷരത്തിൽ തുടങ്ങണം"), + ("allowed characters", "അനുവദനീയമായ അക്ഷരങ്ങൾ"), + ("id_change_tip", "ഐഡി മാറ്റിയാൽ നിലവിലുള്ള കണക്ഷൻ വിച്ഛേദിക്കപ്പെടും."), + ("Website", "വെബ്സൈറ്റ്"), + ("About", "വിവരങ്ങൾ"), + ("Slogan_tip", "മികച്ച അനുഭവത്തിനായി നിർമ്മിച്ച റിമോട്ട് ഡെസ്ക്ടോപ്പ് സോഫ്റ്റ്‌വെയർ"), + ("Privacy Statement", "സ്വകാര്യതാ പ്രസ്താവന"), + ("Mute", "നിശബ്ദമാക്കുക"), + ("Build Date", "നിർമ്മാണ തീയതി"), + ("Version", "പതിപ്പ്"), + ("Home", "ഹോം"), + ("Audio Input", "ഓഡിയോ ഇൻപുട്ട്"), + ("Enhancements", "മെച്ചപ്പെടുത്തലുകൾ"), + ("Hardware Codec", "ഹാർഡ്‌വെയർ കോഡെക്"), + ("Adaptive bitrate", "അഡാപ്റ്റീവ് ബിറ്റ്റേറ്റ്"), + ("ID Server", "ID സെർവർ"), + ("Relay Server", "റിലേ സെർവർ"), + ("API Server", "API സെർവർ"), + ("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", "ദയവായി ഒരു മിനിറ്റിന് ശേഷം ശ്രമിക്കുക"), + ("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", "ഐഡി നിലവിലില്ല"), + ("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.", "നിങ്ങളുടെ ഇൻസ്റ്റാളേഷൻ പഴയ പതിപ്പാണ്."), + ("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", "ഐഡി തിരയുക"), + ("whitelist_sep", "കോമ, സെമി കോളൻ അല്ലെങ്കിൽ സ്പേസ് ഉപയോഗിച്ച് തിരിക്കുക"), + ("Add 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", "Socks5 പ്രോക്സി"), + ("Socks5/Http(s) Proxy", "Socks5/Http(s) പ്രോക്സി"), + ("Discovered", "കണ്ടെത്തിയവ"), + ("install_daemon_tip", "കമ്പ്യൂട്ടർ തുടങ്ങുമ്പോൾ തന്നെ പ്രവർത്തിക്കാൻ സർവീസ് ഇൻസ്റ്റാൾ ചെയ്യുക"), + ("Remote 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_input_permission_tip1", "ഇൻപുട്ട് അനുമതിക്കായി ആക്‌സസിബിലിറ്റി സർവീസ് ഓൺ ചെയ്യുക."), + ("android_input_permission_tip2", "സെറ്റിംഗ്സിൽ RustDesk കണ്ടെത്തി അത് ഓൺ ചെയ്യുക."), + ("android_new_connection_tip", "പുതിയ കണക്ഷൻ അഭ്യർത്ഥന ലഭിച്ചു."), + ("android_service_will_start_tip", "സ്ക്രീൻ ക്യാപ്ചർ ഓൺ ചെയ്താൽ സർവീസ് താനേ തുടങ്ങും."), + ("android_stop_service_tip", "സർവീസ് നിർത്തുന്നത് എല്ലാ കണക്ഷനുകളും വിച്ഛേദിക്കും."), + ("android_version_audio_tip", "ആൻഡ്രോയിഡ് 10-ൽ കൂടുതൽ വേണം ഓഡിയോ ക്യാപ്ചർ ചെയ്യാൻ."), + ("android_start_service_tip", "സ്ക്രീൻ ഷെയറിംഗ് തുടങ്ങാൻ ക്ലിക്ക് ചെയ്യുക."), + ("android_permission_may_not_change_tip", "അനുമതികൾ പിന്നീട് മാറ്റാൻ കഴിയില്ല, ശ്രദ്ധിച്ച് തിരഞ്ഞെടുക്കുക."), + ("Account", "അക്കൗണ്ട്"), + ("Overwrite", "തിരുത്തിയെഴുതുക (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", "അനുപാതം (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", "ഈ പിസി"), + ("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", "പാസ്‌വേഡ് വഴിയുള്ള കണക്ഷൻ ആണെങ്കിൽ മാത്രം മറയ്ക്കുക"), + ("wayland_experiment_tip", "Wayland പിന്തുണ പരീക്ഷണാടിസ്ഥാനത്തിലാണ്"), + ("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", "എപ്പോഴും സോഫ്റ്റ്‌വെയർ റെൻഡറിംഗ് ഉപയോഗിക്കുക"), + ("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", "റിമോട്ട് ഉപയോക്താവ് UAC വിൻഡോയിൽ 'അതെ' എന്ന് ക്ലിക്ക് ചെയ്യേണ്ടതുണ്ട്."), + ("Request Elevation", "എലവേഷൻ ആവശ്യപ്പെടുക"), + ("wait_accept_uac_tip", "റിമോട്ട് ഉപയോക്താവ് UAC അംഗീകരിക്കാൻ കാത്തിരിക്കുക."), + ("Elevate successfully", "വിജയകരമായി എലവേറ്റ് ചെയ്തു"), + ("uppercase", "വലിയ അക്ഷരം (Uppercase)"), + ("lowercase", "ചെറിയ അക്ഷരം (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", "FPS"), + ("Auto", "ഓട്ടോ"), + ("Other Default Options", "മറ്റ് സാധാരണ ഓപ്ഷനുകൾ"), + ("Voice call", "വോയിസ് കോൾ"), + ("Text chat", "ടെക്സ്റ്റ് ചാറ്റ്"), + ("Stop voice call", "വോയിസ് കോൾ നിർത്തുക"), + ("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", "ലോക്കൽ നെറ്റ്‌വർക്കിലെ ഉപകരണങ്ങൾ ഇവിടെ കാണാം."), + ("empty_address_book_tip", "അഡ്രസ് ബുക്ക് ശൂന്യമാണ്."), + ("Empty Username", "യൂസർ നെയിം നൽകിയില്ല"), + ("Empty Password", "പാസ്‌വേഡ് നൽകിയില്ല"), + ("Me", "ഞാൻ"), + ("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", "ഡിഫോൾട്ട്"), + ("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", "കണക്ഷൻ അംഗീകരിച്ച് UAC അനുമതികൾ നൽകുക."), + ("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", "നിങ്ങൾക്ക് പരിചയമില്ലാത്ത ആരെങ്കിലും RustDesk ഉപയോഗിക്കാൻ ആവശ്യപ്പെട്ടാൽ ഉടൻ കണക്ഷൻ വിച്ഛേദിക്കുക."), + ("scam_text2", "ഇതൊരു തട്ടിപ്പായിരിക്കാം. ആർക്കും പാസ്‌വേഡ് നൽകരുത്."), + ("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", "SELinux പ്രവർത്തനക്ഷമമാണ്."), + ("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", "Windows 10 (2004) എങ്കിലും വേണം."), + ("input_source_1_tip", "ഇൻപുട്ട് സോഴ്സ് 1"), + ("input_source_2_tip", "ഇൻപുട്ട് സോഴ്സ് 2"), + ("Swap control-command key", "Control-Command കീകൾ പരസ്പരം മാറ്റുക"), + ("swap-left-right-mouse", "ഇടത്-വലത് മൗസ് ബട്ടണുകൾ മാറ്റുക"), + ("2FA code", "2FA കോഡ്"), + ("More", "കൂടുതൽ"), + ("enable-2fa-title", "2FA ഓൺ ചെയ്യുക"), + ("enable-2fa-desc", "അതന്റിക്കേറ്റർ ആപ്പ് സജ്ജമാക്കുക."), + ("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", "RustDesk വിൻഡോ തുറന്നിരിക്കുമ്പോൾ മാത്രം കണക്ഷൻ അനുവദിക്കുക"), + ("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", "ബാക്ക്ഗ്രൗണ്ടിലാണെങ്കിലും RustDesk കാണിക്കുക"), + ("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", "2FA റദ്ദാക്കണമെന്ന് ഉറപ്പാണോ?"), + ("cancel-bot-confirm-tip", "ബോട്ട് റദ്ദാക്കണമെന്ന് ഉറപ്പാണോ?"), + ("About RustDesk", "RustDesk-നെ കുറിച്ച്"), + ("Send clipboard keystrokes", "ക്ലിപ്പ്ബോർഡ് കീസ്ട്രോക്കുകൾ അയക്കുക"), + ("network_error_tip", "നെറ്റ്‌വർക്ക് പിശക്, വീണ്ടും ശ്രമിക്കുക."), + ("Unlock with PIN", "പിൻ ഉപയോഗിച്ച് അൺലോക്ക് ചെയ്യുക"), + ("Requires at least {} characters", "കുറഞ്ഞത് {} അക്ഷരങ്ങൾ വേണം"), + ("Wrong PIN", "തെറ്റായ പിൻ"), + ("Set 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", "D3D റെൻഡറിംഗ് ഉപയോഗിക്കുക"), + ("Use D3D rendering", ""), + ("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", "അപ്‌ഡേറ്റ് പരാജയപ്പെട്ടു, MSI ഫയൽ പരിശോധിക്കുക."), + ("websocket_tip", "പോട്ടുകൾ തടഞ്ഞിട്ടുണ്ടെങ്കിൽ WebSocket ഉപയോഗിക്കുക."), + ("Use WebSocket", "WebSocket ഉപയോഗിക്കുക"), + ("Trackpad speed", "ട്രാക്ക്പാഡ് വേഗത"), + ("Default trackpad speed", "സാധാരണ ട്രാക്ക്പാഡ് വേഗത"), + ("Numeric one-time password", "അക്കങ്ങൾ മാത്രമുള്ള OTP"), + ("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", "ഏലിയാസ് (Alias)"), + ("ScrollEdge", "സ്ക്രോൾ എഡ്ജ്"), + ("Allow insecure TLS fallback", "സുരക്ഷിതമല്ലാത്ത TLS അനുവദിക്കുക"), + ("allow-insecure-tls-fallback-tip", "പഴയ സെർവറുകൾക്കായി ഉപയോഗിക്കുക."), + ("Disable UDP", "UDP ഒഴിവാക്കുക"), + ("disable-udp-tip", "കണക്ഷൻ പ്രശ്നങ്ങൾക്ക് UDP ഒഴിവാക്കുക."), + ("server-oss-not-support-tip", "OSS സെർവർ ഇത് പിന്തുണയ്ക്കുന്നില്ല."), + ("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", "മാറ്റങ്ങൾ (Changelog)"), + ("keep-awake-during-outgoing-sessions-label", "സെഷൻ നടക്കുമ്പോൾ ഉറക്കത്തിലാകരുത്"), + ("keep-awake-during-incoming-sessions-label", "സെഷൻ വരുമ്പോൾ ഉറക്കത്തിലാകരുത്"), + ("Continue with {}", "{} ഉപയോഗിച്ച് തുടരുക"), + ("Display Name", "ഡിസ്‌പ്ലേ പേര്"), + ("password-hidden-tip", "സുരക്ഷയ്ക്കായി പാസ്‌വേഡ് മറച്ചിരിക്കുന്നു."), + ("preset-password-in-use-tip", "പ്രീസെറ്റ് പാസ്‌വേഡ് ഉപയോഗത്തിലാണ്."), + ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), + ].iter().cloned().collect(); +} diff --git a/src/lang/nb.rs b/src/lang/nb.rs index a91e31e45..9325dfa1f 100644 --- a/src/lang/nb.rs +++ b/src/lang/nb.rs @@ -41,7 +41,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("length %min% to %max%", ""), ("starts with a letter", ""), ("allowed characters", ""), - ("id_change_tip", "Kun tegnene a-z, A-Z, 0-9 og _ (understrek) er tillat. Den første bokstaven skal være a-z, A-Z. Lengde mellom 6 og 16."), + ("id_change_tip", "Kun tegnene a-z, A-Z, 0-9, - (dash) og _ (understrek) er tillat. Den første bokstaven skal være a-z, A-Z. Lengde mellom 6 og 16."), ("Website", "Hjemmeside"), ("About", "Om"), ("Slogan_tip", ""), @@ -147,8 +147,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("OS Password", "Operativsystempassord"), ("install_tip", "På grunn av UAC kan RustDesk ikke fungere korrekt i enkelte tillfeller på fjernskrivebordet. For å unngå UAC klikker du på knappen nedenfor for å installere RustDesk på systemet"), ("Click to upgrade", "Klikk for å oppgradere"), - ("Click to download", "Klikk for å laste ned"), - ("Click to update", "Klikk for å oppdatere"), ("Configure", "Konfigurer"), ("config_acc", "For å kontrollere ditt skrivebord med fjernstyring må du gi RustDesk \"Access \" Rettigheter."), ("config_screen", "For å kunne få adgang til ditt skrivebord med fjernstyring, må du gi RustDesk \"skjerstøtte \" tillatelser."), @@ -267,7 +265,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("No permission of file transfer", "Ingen tillatelse til å overføre filen"), ("Note", "Notat"), ("Connection", "Tilkobling"), - ("Share Screen", "Del skjermen"), + ("Share screen", "Del skjermen"), ("Chat", "Chat"), ("Total", "Total"), ("items", "Objekter"), @@ -275,8 +273,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Screen Capture", "Skjermopptak"), ("Input Control", "Input kontroll"), ("Audio Capture", "Lydopptak"), - ("File Connection", "Filtilkobling"), - ("Screen Connection", "Skjermtilkobing"), ("Do you accept?", "Akepterer du?"), ("Open System Setting", "Åpne systeminnstillinger"), ("How to get Android input permission?", "Hvordan får jeg en Android-input tillatelse?"), @@ -381,14 +377,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Keyboard Settings", "Tastaturinnstillinger"), ("Full Access", "Full tilgang"), ("Screen Share", "Skjermdeling"), - ("Wayland requires Ubuntu 21.04 or higher version.", "Wayland krever Ubuntu version 21.04 eller nyere."), - ("Wayland requires higher version of linux distro. Please try X11 desktop or change your OS.", "Wayland krever en nyere versjon av Linux. Prøv X11 desktop eller skift OS."), + ("ubuntu-21-04-required", "Wayland krever Ubuntu version 21.04 eller nyere."), + ("wayland-requires-higher-linux-version", "Wayland krever en nyere versjon av Linux. Prøv X11 desktop eller skift OS."), + ("xdp-portal-unavailable", ""), ("JumpLink", "JumpLink"), ("Please Select the screen to be shared(Operate on the peer side).", "vennligst velg den skjermen, som skal deles (fjernstyres)."), ("Show RustDesk", "Vis RustDesk"), ("This PC", "Denne PC"), ("or", "eller"), - ("Continue with", "Fortsett med"), ("Elevate", "Elever"), ("Zoom cursor", "Zoom markør"), ("Accept sessions via password", "Aksepter sesjoner via passord"), @@ -463,7 +459,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("empty_favorite_tip", ""), ("empty_lan_tip", ""), ("empty_address_book_tip", ""), - ("eg: admin", "f.eks.: admin"), ("Empty Username", "Tøm brukernavn"), ("Empty Password", "Tøm passord"), ("Me", "Meg"), @@ -653,5 +648,102 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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", ""), + ("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", ""), + ("Trackpad speed", ""), + ("Default trackpad speed", ""), + ("Numeric one-time password", ""), + ("Enable IPv6 P2P connection", ""), + ("Enable UDP hole punching", ""), + ("View camera", "Vis kamera"), + ("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", ""), + ("allow-insecure-tls-fallback-tip", ""), + ("Disable 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 {}", "Fortsett med {}"), + ("Display Name", ""), + ("password-hidden-tip", ""), + ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/nl.rs b/src/lang/nl.rs index ca46a3285..55d272666 100644 --- a/src/lang/nl.rs +++ b/src/lang/nl.rs @@ -3,60 +3,60 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = [ ("Status", "Status"), ("Your Desktop", "Uw Bureaublad"), - ("desk_tip", "Uw bureaublad is toegankelijk via de ID en het wachtwoord hieronder."), + ("desk_tip", "Uw bureaublad is toegankelijk met dit ID en wachtwoord."), ("Password", "Wachtwoord"), ("Ready", "Klaar"), ("Established", "Opgezet"), ("connecting_status", "Verbinding maken met het RustDesk netwerk..."), - ("Enable service", "Service Inschakelen"), + ("Enable service", "Service inschakelen"), ("Start service", "Start service"), ("Service is running", "De service loopt."), ("Service is not running", "De service loopt niet"), - ("not_ready_status", "Niet klaar, controleer de netwerkverbinding"), + ("not_ready_status", "Niet verbonden met de server, controleer de netwerkverbinding"), ("Control Remote Desktop", "Beheer Extern Bureaublad"), - ("Transfer file", "Bestand Overzetten"), + ("Transfer file", "Bestand overzetten"), ("Connect", "Verbinden"), - ("Recent sessions", "Recente Behandelingen"), + ("Recent sessions", "Recente sessies"), ("Address book", "Adresboek"), ("Confirmation", "Bevestiging"), - ("TCP tunneling", "TCP tunneling"), + ("TCP tunneling", "TCP-tunneling"), ("Remove", "Verwijder"), ("Refresh random password", "Vernieuw willekeurig wachtwoord"), ("Set your own password", "Stel uw eigen wachtwoord in"), - ("Enable keyboard/mouse", "Toetsenbord/Muis Inschakelen"), - ("Enable clipboard", "Klembord Inschakelen"), - ("Enable file transfer", "Bestandsoverdracht Inschakelen"), - ("Enable TCP tunneling", "TCP tunneling Inschakelen"), + ("Enable keyboard/mouse", "Toetsenbord/muis inschakelen"), + ("Enable clipboard", "Klembord inschakelen"), + ("Enable file transfer", "Bestandsoverdracht inschakelen"), + ("Enable TCP tunneling", "TCP-tunneling inschakelen"), ("IP Whitelisting", "IP Witte Lijst"), - ("ID/Relay Server", "ID/Relay Server"), - ("Import server config", "Importeer Serverconfiguratie"), - ("Export Server Config", "Exporteer Serverconfiguratie"), + ("ID/Relay Server", "ID-/Relayserver"), + ("Import server config", "Importeer serverconfiguratie"), + ("Export Server Config", "Exporteer serverconfiguratie"), ("Import server configuration successfully", "Importeren serverconfiguratie is geslaagd"), ("Export server configuration successfully", "Exporteren serverconfiguratie is geslaagd"), - ("Invalid server configuration", "Ongeldige Serverconfiguratie"), + ("Invalid server configuration", "Ongeldige serverconfiguratie"), ("Clipboard is empty", "Klembord is leeg"), ("Stop service", "Stop service"), ("Change ID", "Wijzig ID"), - ("Your new ID", "Uw nieuw ID"), + ("Your new ID", "Uw nieuwe ID"), ("length %min% to %max%", "lengte %min% tot %max%"), ("starts with a letter", "begint met een letter"), ("allowed characters", "toegestane tekens"), - ("id_change_tip", "Alleen de letters a-z, A-Z, 0-9, _ (underscore) kunnen worden gebruikt. De eerste letter moet a-z, A-Z zijn. De lengte moet tussen 6 en 16 liggen."), + ("id_change_tip", "Alleen de letters a-z, A-Z, 0-9, - (dash), _ (underscore) kunnen worden gebruikt. De eerste letter moet a-z, A-Z zijn. De lengte moet tussen 6 en 16 liggen."), ("Website", "Website"), ("About", "Over"), - ("Slogan_tip", "Ontwikkeld met het hart voor deze chaotische wereld!"), + ("Slogan_tip", "Met hart en ziel gemaakt in deze chaotische wereld!"), ("Privacy Statement", "Privacyverklaring"), ("Mute", "Geluid uit"), - ("Build Date", "Versie datum"), + ("Build Date", "Datum"), ("Version", "Versie"), ("Home", "Startpagina"), - ("Audio Input", "Audio Ingang"), + ("Audio Input", "Audioingang"), ("Enhancements", "Verbeteringen"), - ("Hardware Codec", "Hardware Codec"), - ("Adaptive bitrate", "Aangepaste Bitsnelheid"), - ("ID Server", "Server ID"), - ("Relay Server", "Relay Server"), - ("API Server", "API Server"), + ("Hardware Codec", "Hardwarecodec"), + ("Adaptive bitrate", "Bitrate automatisch aanpassen"), + ("ID Server", "ID-server"), + ("Relay Server", "Relay-server"), + ("API Server", "API-server"), ("invalid_http", "Moet beginnen met http:// of https://"), ("Invalid IP", "Ongeldig IP"), ("Invalid format", "Ongeldig formaat"), @@ -68,43 +68,43 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Close", "Sluit"), ("Retry", "Probeer opnieuw"), ("OK", "OK"), - ("Password Required", "Wachtwoord vereist"), + ("Password Required", "Wachtwoord Vereist"), ("Please enter your password", "Geef uw wachtwoord in"), ("Remember password", "Wachtwoord onthouden"), - ("Wrong Password", "Verkeerd wachtwoord"), + ("Wrong Password", "Verkeerd Wachtwoord"), ("Do you want to enter again?", "Wilt u het opnieuw invoeren?"), ("Connection Error", "Fout bij verbinding"), ("Error", "Fout"), - ("Reset by the peer", "Reset door de peer"), + ("Reset by the peer", "Door de peer gereset"), ("Connecting...", "Verbinding maken..."), - ("Connection in progress. Please wait.", "Verbinding in uitvoering. Even geduld a.u.b."), + ("Connection in progress. Please wait.", "Verbinding wordt gemaakt. Even geduld a.u.b."), ("Please try 1 minute later", "Probeer 1 minuut later"), - ("Login Error", "Login Fout"), + ("Login Error", "Loginfout"), ("Successful", "Geslaagd"), ("Connected, waiting for image...", "Verbonden, wacht op beeld..."), ("Name", "Naam"), ("Type", "Type"), ("Modified", "Gewijzigd"), ("Size", "Grootte"), - ("Show Hidden Files", "Toon verborgen bestanden"), - ("Receive", "Ontvangen"), - ("Send", "Verzenden"), + ("Show Hidden Files", "Toon Verborgen Bestanden"), + ("Receive", "Ontvang"), + ("Send", "Verzend"), ("Refresh File", "Bestand Verversen"), ("Local", "Lokaal"), - ("Remote", "Op afstand"), + ("Remote", "Op Afstand"), ("Remote Computer", "Externe Computer"), ("Local Computer", "Lokale Computer"), ("Confirm Delete", "Bevestig Verwijderen"), ("Delete", "Verwijder"), ("Properties", "Eigenschappen"), - ("Multi Select", "Meervoudig selecteren"), + ("Multi Select", "Meervoudig Selecteren"), ("Select All", "Selecteer Alle"), - ("Unselect All", "De-selecteer alles"), + ("Unselect All", "De-selecteer Alle"), ("Empty Directory", "Lege Map"), ("Not an empty directory", "Geen lege map"), ("Are you sure you want to delete this file?", "Weet u zeker dat u dit bestand wilt verwijderen?"), ("Are you sure you want to delete this empty directory?", "Weet u zeker dat u deze lege map wilt verwijderen?"), - ("Are you sure you want to delete the file of this directory?", "Weet u zeker dat u het bestand uit deze map wilt verwijderen?"), + ("Are you sure you want to delete the file of this directory?", "Weet u zeker dat u de bestanden uit deze map wilt verwijderen?"), ("Do this for all conflicts", "Doe dit voor alle conflicten"), ("This is irreversible!", "Dit is onomkeerbaar!"), ("Deleting", "Verwijderen"), @@ -112,16 +112,16 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Waiting", "Wachten"), ("Finished", "Voltooid"), ("Speed", "Snelheid"), - ("Custom Image Quality", "Aangepaste beeldkwaliteit"), + ("Custom Image Quality", "Aangepaste Beeldkwaliteit"), ("Privacy mode", "Privacymodus"), ("Block user input", "Gebruikersinvoer blokkeren"), - ("Unblock user input", "Gebruikersinvoer opheffen"), + ("Unblock user input", "Gebruikersinvoer deblokkeren"), ("Adjust Window", "Venster Aanpassen"), ("Original", "Origineel"), ("Shrink", "Verkleinen"), ("Stretch", "Uitrekken"), ("Scrollbar", "Schuifbalk"), - ("ScrollAuto", "Auto Schuiven"), + ("ScrollAuto", "Automatisch schuiven"), ("Good image quality", "Goede beeldkwaliteit"), ("Balanced", "Gebalanceerd"), ("Optimize reaction time", "Optimaliseer reactietijd"), @@ -130,8 +130,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Show quality monitor", "Kwaliteitsmonitor tonen"), ("Disable clipboard", "Klembord uitschakelen"), ("Lock after session end", "Vergrendelen na einde sessie"), - ("Insert Ctrl + Alt + Del", "Ctrl + Alt + Del Invoegen"), - ("Insert Lock", "Vergrendeling Invoegen"), + ("Insert Ctrl + Alt + Del", "Ctrl + Alt + Del Invoeren"), + ("Insert Lock", "Vergrendelen"), ("Refresh", "Vernieuwen"), ("ID does not exist", "ID bestaat niet"), ("Failed to connect to rendezvous server", "Verbinding met rendez-vous-server mislukt"), @@ -139,32 +139,30 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Remote desktop is offline", "Extern bureaublad is offline"), ("Key mismatch", "Code onjuist"), ("Timeout", "Time-out"), - ("Failed to connect to relay server", "Verbinding met relayserver mislukt"), - ("Failed to connect via rendezvous server", "Verbinding via rendez-vous-server mislukt"), - ("Failed to connect via relay server", "Verbinding via relaisserver mislukt"), - ("Failed to make direct connection to remote desktop", "Onmogelijk direct verbinding te maken met extern bureaublad"), + ("Failed to connect to relay server", "Verbinden met relayserver mislukt"), + ("Failed to connect via rendezvous server", "Verbinden via rendez-vous-server mislukt"), + ("Failed to connect via relay server", "Verbinden via relaisserver mislukt"), + ("Failed to make direct connection to remote desktop", "Direct verbinden met extern bureaublad is mislukt"), ("Set Password", "Wachtwoord Instellen"), ("OS Password", "OS Wachtwoord"), - ("install_tip", "U gebruikt een niet geïnstalleerde versie. Als gevolg van UAC-beperkingen is het in sommige gevallen niet mogelijk om als controleterminal de muis en het toetsenbord te bedienen of het scherm over te nemen. Klik op de knop hieronder om RustDesk op het systeem te installeren om het bovenstaande probleem te voorkomen."), + ("install_tip", "Door UAC-beperkingen lukt het niet altijd om uw bureaublad op afstand te bedienen. Installeer RustDesk op het systeem om dit probleem te voorkomen."), ("Click to upgrade", "Klik voor upgrade"), - ("Click to download", "Klik om te downloaden"), - ("Click to update", "Klik om bij te werken"), ("Configure", "Configureren"), - ("config_acc", "Om uw bureaublad op afstand te kunnen bedienen, moet u RustDesk \"toegankelijkheid\" toestemming geven."), - ("config_screen", "Om toegang te krijgen tot het externe bureaublad, moet u RustDesk de toestemming \"schermregistratie\" geven."), + ("config_acc", "Om uw apparaat op afstand te kunnen bedienen, moet u RustDesk toestemming voor Toegankelijkheid geven."), + ("config_screen", "Om uw apparaat op afstand te kunnen bedienen, moet u RustDesk toestemming voor Schermopname geven."), ("Installing ...", "Installeren ..."), ("Install", "Installeer"), ("Installation", "Installatie"), - ("Installation Path", "Installatie Pad"), - ("Create start menu shortcuts", "Startmenu snelkoppelingen maken"), + ("Installation Path", "Locatie"), + ("Create start menu shortcuts", "Startmenu-snelkoppelingen maken"), ("Create desktop icon", "Bureaubladpictogram maken"), ("agreement_tip", "Het starten van de installatie betekent het accepteren van de licentieovereenkomst."), ("Accept and Install", "Accepteren en installeren"), ("End-user license agreement", "Licentieovereenkomst eindgebruiker"), ("Generating ...", "Genereert ..."), ("Your installation is lower version.", "Uw installatie is een lagere versie."), - ("not_close_tcp_tip", "Gelieve dit venster niet te sluiten wanneer u de tunnel gebruikt"), - ("Listening ...", "Luisteren ..."), + ("not_close_tcp_tip", "Sluit dit venster niet zolang u de tunnel gebruikt"), + ("Listening ...", "Luistert ..."), ("Remote Host", "Externe Host"), ("Remote Port", "Externe Poort"), ("Action", "Actie"), @@ -172,7 +170,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Local Port", "Lokale Poort"), ("Local Address", "Lokaal Adres"), ("Change Local Port", "Wijzig Lokale Poort"), - ("setup_server_tip", "Als u een snellere verbindingssnelheid nodig heeft, kunt u ervoor kiezen om uw eigen server aan te maken"), + ("setup_server_tip", "Als u een hogere verbindingssnelheid nodig heeft, kunt u ervoor kiezen om uw eigen server aan te maken"), ("Too short, at least 6 characters.", "Te kort, minstens 6 tekens."), ("The confirmation is not identical.", "De bevestiging is niet identiek."), ("Permissions", "Machtigingen"), @@ -194,10 +192,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Rename", "Naam wijzigen"), ("Space", "Spatie"), ("Create desktop shortcut", "Snelkoppeling op bureaublad maken"), - ("Change Path", "Pad wijzigen"), + ("Change Path", "Pad Wijzigen"), ("Create Folder", "Map Maken"), ("Please enter the folder name", "Geef de mapnaam op"), - ("Fix it", "Repareer het"), + ("Fix it", "Repareer"), ("Warning", "Waarschuwing"), ("Login screen using Wayland is not supported", "Aanmeldingsscherm via Wayland wordt niet ondersteund"), ("Reboot required", "Opnieuw opstarten vereist"), @@ -205,20 +203,20 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("x11 expected", "x11 verwacht"), ("Port", "Poort"), ("Settings", "Instellingen"), - ("Username", "Gebruikersnaam"), + ("Username", "Gebruiker"), ("Invalid port", "Ongeldige poort"), ("Closed manually by the peer", "Handmatig gesloten door de peer"), - ("Enable remote configuration modification", "Wijziging configuratie op afstand inschakelen"), + ("Enable remote configuration modification", "Configuratiewijziging op afstand inschakelen"), ("Run without install", "Uitvoeren zonder installatie"), - ("Connect via relay", "Verbinden via relais"), + ("Connect via relay", "Verbinden via relay"), ("Always connect via relay", "Altijd verbinden via relay"), - ("whitelist_tip", "Alleen een IP-adres op de witte lijst krijgt toegang tot mijn toestel"), + ("whitelist_tip", "Alleen IP-adressen op de witte lijst krijgen toegang tot mijn toestel"), ("Login", "Log In"), ("Verify", "Controleer"), ("Remember me", "Herinner mij"), ("Trust this device", "Vertrouw dit apparaat"), - ("Verification code", "Verificatie code"), - ("verification_tip", "Er is een nieuw apparaat gedetecteerd en er is een verificatiecode naar het geregistreerde e-mailadres gestuurd, voer de verificatiecode in om de verbinding voort te zetten."), + ("Verification code", "Verificatiecode"), + ("verification_tip", "Er is een verificatiecode naar het geregistreerde e-mailadres gestuurd, voer de verificatiecode in om de verbinding voort te zetten."), ("Logout", "Log Uit"), ("Tags", "Labels"), ("Search ID", "Zoek ID"), @@ -238,24 +236,24 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Remove from Favorites", "Verwijderen uit Favorieten"), ("Empty", "Leeg"), ("Invalid folder name", "Ongeldige mapnaam"), - ("Socks5 Proxy", "Socks5 Proxy"), - ("Socks5/Http(s) Proxy", "Socks5/Http(s) Proxy"), + ("Socks5 Proxy", "SOCKS5 Proxy"), + ("Socks5/Http(s) Proxy", "SOCKS5/HTTP(S) Proxy"), ("Discovered", "Ontdekt"), - ("install_daemon_tip", "Om bij het opstarten van de computer te kunnen beginnen, moet u de systeemservice installeren."), - ("Remote ID", "Externe ID"), + ("install_daemon_tip", "Om te starten bij het opstarten van de computer, moet u de systeemservice installeren."), + ("Remote ID", "Extern ID"), ("Paste", "Plakken"), - ("Paste here?", "Hier plakken"), + ("Paste here?", "Hier plakken?"), ("Are you sure to close the connection?", "Weet u zeker dat u de verbinding wilt sluiten?"), ("Download new version", "Download nieuwe versie"), - ("Touch mode", "Aanraak modus"), + ("Touch mode", "Aanraakmodus"), ("Mouse mode", "Muismodus"), ("One-Finger Tap", "Een-Vinger Tik"), ("Left Mouse", "Linkermuis"), ("One-Long Tap", "Een-Vinger-Lange-Tik"), ("Two-Finger Tap", "Twee-Vingers-Tik"), - ("Right Mouse", "Rechter muis"), + ("Right Mouse", "Rechtermuis"), ("One-Finger Move", "Een-Vinger-Verplaatsing"), - ("Double Tap & Move", "Dubbel Tik en Verplaatsen"), + ("Double Tap & Move", "Dubbel-Tik en Verplaatsen"), ("Mouse Drag", "Muis Slepen"), ("Three-Finger vertically", "Drie-Vinger verticaal"), ("Mouse Wheel", "Muiswiel"), @@ -267,7 +265,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("No permission of file transfer", "Geen toestemming voor bestandsoverdracht"), ("Note", "Opmerking"), ("Connection", "Verbinding"), - ("Share Screen", "Scherm Delen"), + ("Share screen", "Scherm Delen"), ("Chat", "Chat"), ("Total", "Totaal"), ("items", "items"), @@ -275,8 +273,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Screen Capture", "Schermopname"), ("Input Control", "Invoercontrole"), ("Audio Capture", "Audio Opnemen"), - ("File Connection", "Bestandsverbinding"), - ("Screen Connection", "Schermverbinding"), ("Do you accept?", "Geeft u toestemming?"), ("Open System Setting", "Systeeminstelling Openen"), ("How to get Android input permission?", "Hoe krijg ik Android invoer toestemming?"), @@ -304,18 +300,18 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Turned off", "Uitgeschakeld"), ("Language", "Taal"), ("Keep RustDesk background service", "RustDesk achtergronddienst behouden"), - ("Ignore Battery Optimizations", "Negeer Batterij Optimalisaties"), + ("Ignore Battery Optimizations", "Negeer Batterij-optimalisaties"), ("android_open_battery_optimizations_tip", "Ga naar de volgende pagina met instellingen"), ("Start on boot", "Starten bij Opstarten"), - ("Start the screen sharing service on boot, requires special permissions", "Start de schermdelings service bij het opstarten, vereist speciale rechten"), + ("Start the screen sharing service on boot, requires special permissions", "Start de schermdelingsservice bij het opstarten, vereist speciale rechten"), ("Connection not allowed", "Verbinding niet toegestaan"), - ("Legacy mode", "Verouderde modus"), - ("Map mode", "Map mode"), + ("Legacy mode", "Legacymodus"), + ("Map mode", "Mapmodus"), ("Translate mode", "Vertaalmodus"), ("Use permanent password", "Gebruik permanent wachtwoord"), ("Use both passwords", "Gebruik beide wachtwoorden"), ("Set permanent password", "Stel permanent wachtwoord in"), - ("Enable remote restart", "Schakel Herstart op afstand in"), + ("Enable remote restart", "Herstart op afstand inschakelen"), ("Restart remote device", "Apparaat op afstand herstarten"), ("Are you sure you want to restart", "Weet u zeker dat u wilt herstarten"), ("Restarting remote device", "Apparaat op afstand herstarten"), @@ -336,19 +332,19 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Relaisverbinding"), ("Secure Connection", "Beveiligde Verbinding"), ("Insecure Connection", "Onveilige Verbinding"), - ("Scale original", "Oorspronkelijke schaal"), - ("Scale adaptive", "Schaalaanpassing"), + ("Scale original", "Oorspronkelijk formaat"), + ("Scale adaptive", "Automatisch schalen"), ("General", "Algemeen"), ("Security", "Beveiliging"), ("Theme", "Thema"), ("Dark Theme", "Donker Thema"), - ("Light Theme", "Lichte Thema"), + ("Light Theme", "Licht Thema"), ("Dark", "Donker"), ("Light", "Licht"), - ("Follow System", "Volg Systeem"), - ("Enable hardware codec", "Hardware codec inschakelen"), + ("Follow System", "Volg systeem"), + ("Enable hardware codec", "Hardwarecodec inschakelen"), ("Unlock Security Settings", "Beveiligingsinstellingen vrijgeven"), - ("Enable audio", "Audio Inschakelen"), + ("Enable audio", "Audio inschakelen"), ("Unlock Network Settings", "Netwerkinstellingen Vrijgeven"), ("Server", "Server"), ("Direct IP Access", "Directe IP toegang"), @@ -363,45 +359,45 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Unpin Toolbar", "Werkbalk Losmaken"), ("Recording", "Opnemen"), ("Directory", "Map"), - ("Automatically record incoming sessions", "Automatisch inkomende sessies opnemen"), - ("Automatically record outgoing sessions", ""), - ("Change", "Wissel"), + ("Automatically record incoming sessions", "Inkomende sessies automatisch opnemen"), + ("Automatically record outgoing sessions", "Uitgaande sessies automatisch opnemen"), + ("Change", "Aanpassen"), ("Start session recording", "Start de sessieopname"), ("Stop session recording", "Stop de sessieopname"), - ("Enable recording session", "Opnamesessie Activeren"), + ("Enable recording session", "Sessieopname activeren"), ("Enable LAN discovery", "LAN-detectie inschakelen"), - ("Deny LAN discovery", "LAN-detectie Weigeren"), + ("Deny LAN discovery", "LAN-detectie weigeren"), ("Write a message", "Schrijf een bericht"), - ("Prompt", "Verzoek"), + ("Prompt", "Melding"), ("Please wait for confirmation of UAC...", "Wacht op bevestiging van UAC..."), ("elevated_foreground_window_tip", "Het momenteel geopende venster van de op afstand bediende computer vereist hogere rechten. Daarom is het momenteel niet mogelijk de muis en het toetsenbord te gebruiken. Vraag de gebruiker wiens computer u op afstand bedient om het venster te minimaliseren of de rechten te verhogen. Om dit probleem in de toekomst te voorkomen, wordt aanbevolen de software te installeren op de op afstand bediende computer."), ("Disconnected", "Afgesloten"), ("Other", "Andere"), ("Confirm before closing multiple tabs", "Bevestig voordat u meerdere tabbladen sluit"), - ("Keyboard Settings", "Toetsenbord instellingen"), + ("Keyboard Settings", "Toetsenbordinstellingen"), ("Full Access", "Volledige Toegang"), ("Screen Share", "Scherm Delen"), - ("Wayland requires Ubuntu 21.04 or higher version.", "Wayland vereist Ubuntu 21.04 of een hogere versie."), - ("Wayland requires higher version of linux distro. Please try X11 desktop or change your OS.", "Wayland vereist een hogere versie van Linux distro. Probeer X11 desktop of verander van OS."), + ("ubuntu-21-04-required", "Wayland vereist Ubuntu 21.04 of hoger."), + ("wayland-requires-higher-linux-version", "Wayland vereist een hogere versie van Linux distro. Probeer X11 desktop of verander van OS."), + ("xdp-portal-unavailable", ""), ("JumpLink", "JumpLink"), ("Please Select the screen to be shared(Operate on the peer side).", "Selecteer het scherm dat moet worden gedeeld (Bediening aan de kant van de peer)."), ("Show RustDesk", "Toon RustDesk"), ("This PC", "Deze PC"), ("or", "of"), - ("Continue with", "Ga verder met"), ("Elevate", "Verhoog"), - ("Zoom cursor", "Cursor Zoomen"), + ("Zoom cursor", "Zoom cursor"), ("Accept sessions via password", "Sessies accepteren via wachtwoord"), ("Accept sessions via click", "Sessies accepteren via klik"), - ("Accept sessions via both", "Accepteer sessies via beide"), + ("Accept sessions via both", "Accepteer sessies via klik of wachtwoord"), ("Please wait for the remote side to accept your session request...", "Wacht tot de andere kant uw sessieverzoek accepteert..."), ("One-time Password", "Eenmalig Wachtwoord"), - ("Use one-time password", "Gebruik een eenmalig Wachtwoord"), - ("One-time password length", "Eenmalig Wachtwoordlengte"), + ("Use one-time password", "Gebruik een eenmalig wachtwoord"), + ("One-time password length", "Lengte eenmalig wachtwoord"), ("Request access to your device", "Toegang tot uw toestel aanvragen"), ("Hide connection management window", "Verberg het venster voor verbindingsbeheer"), ("hide_cm_tip", "Dit kan alleen als de toegang via een permanent wachtwoord verloopt."), - ("wayland_experiment_tip", "Wayland ondersteuning is slechts experimenteel. Gebruik alsjeblieft X11 als u onbeheerde toegang nodig hebt."), + ("wayland_experiment_tip", "Wayland ondersteuning is slechts experimenteel. Gebruik alstublieft X11 als u onbeheerde toegang nodig heeft."), ("Right click to select tabs", "Rechts klikken om tabbladen te selecteren"), ("Skipped", "Overgeslagen"), ("Add to address book", "Toevoegen aan Adresboek"), @@ -412,8 +408,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Select local keyboard type", "Selecteer lokaal toetsenbord"), ("software_render_tip", "Als u een NVIDIA grafische kaart hebt en het externe venster sluit onmiddellijk na verbinding, kan het helpen om het nieuwe stuurprogramma te installeren en te kiezen voor software rendering. Een software herstart is vereist."), ("Always use software rendering", "Gebruik altijd software rendering"), - ("config_input", "Om het externe bureaublad vanaf het toetsenbord te kunnen bedienen, moet u RustDesk de rechten \"Invoerbewaking\" geven."), - ("config_microphone", "Om op afstand te kunnen chatten, moet u RustDesk 'Audio opnemen' rechten geven."), + ("config_input", "Om een extern apparaat met uw toetsenbord te kunnen bedienen, moet u RustDesk toestemming voor Invoer Vastleggen geven."), + ("config_microphone", "Om te kunnen chatten moet u RustDesk toestemming voor Microfoon geven."), ("request_elevation_tip", "U kunt ook meer rechten vragen als iemand aan de andere kant aanwezig is."), ("Wait", "Wacht"), ("Elevation Error", "Verhogingsfout"), @@ -433,20 +429,20 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Medium", "Middelmatig"), ("Strong", "Sterk"), ("Switch Sides", "Wissel van kant"), - ("Please confirm if you want to share your desktop?", "Bevestig als u uw bureaublad wilt delen?"), + ("Please confirm if you want to share your desktop?", "Bevestig dat u uw bureaublad wilt delen?"), ("Display", "Weergave"), - ("Default View Style", "Standaard Weergave Stijl"), - ("Default Scroll Style", "Standaard Scroll Stijl"), + ("Default View Style", "Standaard Weergavestijl"), + ("Default Scroll Style", "Standaard Scrollstijl"), ("Default Image Quality", "Standaard Beeldkwaliteit"), ("Default Codec", "Standaard Codec"), ("Bitrate", "Bitrate"), ("FPS", "FPS"), ("Auto", "Auto"), - ("Other Default Options", "Andere Standaardopties"), + ("Other Default Options", "Overige Standaardinstellingen"), ("Voice call", "Spraakoproep"), - ("Text chat", "Tekst chat"), + ("Text chat", "Tekstchat"), ("Stop voice call", "Stop spraakoproep"), - ("relay_hint_tip", "Indien een directe verbinding niet mogelijk is, kunt u proberen verbinding te maken via een Relay Server. \nAls u bij de eerste poging een relaisverbinding tot stand wilt brengen, kunt u het achtervoegsel \"/r\" toevoegen aan het ID of de optie \"Altijd verbinden via relaisserver\" selecteren op de externe terminal."), + ("relay_hint_tip", "Indien een directe verbinding niet mogelijk is, kunt u proberen verbinding te maken via een Relay Server.\nAls u bij de eerste poging een relaisverbinding tot stand wilt brengen, kunt u het achtervoegsel \"/r\" toevoegen aan het ID of de optie \"Altijd verbinden via relaisserver\" selecteren op de externe terminal."), ("Reconnect", "Opnieuw verbinden"), ("Codec", "Codec"), ("Resolution", "Resolutie"), @@ -459,17 +455,16 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Minimize", "Minimaliseren"), ("Maximize", "Maximaliseren"), ("Your Device", "Uw Apparaat"), - ("empty_recent_tip", "Oeps, geen actuele situatie!\nTijd om een nieuwe te plannen."), - ("empty_favorite_tip", "Nog geen favoriete Station op afstand? Laat ons iemand vinden om mee te verbinden en voeg hem toe aan uw favorieten!"), + ("empty_recent_tip", "Oeps, geen recente sessies!\nTijd om een nieuwe te plannen."), + ("empty_favorite_tip", "Nog geen favoriete stations op afstand? Laat ons iemand vinden om mee te verbinden en voeg hem toe aan uw favorieten!"), ("empty_lan_tip", "Oh nee, het lijkt erop dat we nog geen extern station hebben ontdekt."), ("empty_address_book_tip", "Oh jee, het lijkt erop dat er momenteel geen externe stations in uw adresboek staan."), - ("eg: admin", "bijvoorbeeld: admin"), ("Empty Username", "Gebruikersnaam Leeg"), ("Empty Password", "Wachtwoord Leeg"), ("Me", "Ik"), ("identical_file_tip", "Dit bestand is identiek aan het bestand van het externe station."), ("show_monitors_tip", "Monitoren weergeven in de werkbalk"), - ("View Mode", "Weergave Mode"), + ("View Mode", "Toeschouwermodus"), ("login_linux_tip", "Toegang tot het externe Linux-account"), ("verify_rustdesk_password_tip", "Bevestiging wachtwoord RustDesk"), ("remember_account_tip", "Herinner dit account"), @@ -508,7 +503,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Exit", "Afsluiten"), ("Open", "Open"), ("logout_tip", "Weet u zeker dat u zich wilt afmelden?"), - ("Service", "Service"), + ("Service", "Achtergrondservice"), ("Start", "Start"), ("Stop", "Stop"), ("exceed_max_devices", "Het maximum aantal gecontroleerde apparaten is bereikt."), @@ -544,7 +539,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Timeout in minutes", "Time-out in minuten"), ("auto_disconnect_option_tip", "Inkomende sessies automatisch sluiten bij inactiviteit van de gebruiker"), ("Connection failed due to inactivity", "Automatisch verbinding verbroken wegens inactiviteit"), - ("Check for software update on startup", "Checken voor updates bij opstarten"), + ("Check for software update on startup", "Controleer op updates bij opstarten"), ("upgrade_rustdesk_server_pro_to_{}_tip", "Upgrade RustDesk Server Pro naar versie {} of nieuwer!"), ("pull_group_failed_tip", "Vernieuwen van groep mislukt"), ("Filter by intersection", "Filter op kruising"), @@ -564,28 +559,28 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Plug out all", "Sluit alle"), ("True color (4:4:4)", "Ware kleur (4:4:4)"), ("Enable blocking user input", "Blokkeren van gebruikersinvoer inschakelen"), - ("id_input_tip", "Je kunt een ID, een direct IP of een domein met een poort (:) invoeren. Als je toegang wilt als apparaat op een andere server, voeg dan het serveradres toe (@?key=), bijvoorbeeld \n9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=.Als je toegang wilt als apparaat op een openbare server, voer dan \"@public\" in, voor de openbare server is de sleutel niet nodig."), - ("privacy_mode_impl_mag_tip", "Modus 1"), - ("privacy_mode_impl_virtual_display_tip", "Modus 2"), + ("id_input_tip", "U kunt een ID, een direct IP of een domein met poort (:) invoeren. Als u toegang wilt tot een apparaat op een andere server, voeg dan een serveradres en public key toe (@?key=), bijvoorbeeld \n9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=.Als je toegang wilt als apparaat op een openbare server, voer dan \"@public\" in, voor de openbare server is de sleutel niet nodig."), + ("privacy_mode_impl_mag_tip", "Modus 1: Overlayscherm"), + ("privacy_mode_impl_virtual_display_tip", "Modus 2: Monitor slaapstand"), ("Enter privacy mode", "Privacymodus openen"), ("Exit privacy mode", "Privacymodus afsluiten"), ("idd_not_support_under_win10_2004_tip", "Het indirecte displaystuurprogramma wordt niet ondersteund. Windows 10 versie 2004 of later is vereist."), - ("input_source_1_tip", "Invoerbron 1"), - ("input_source_2_tip", "Invoerbron 2"), + ("input_source_1_tip", "Invoerbron 1: Standaard"), + ("input_source_2_tip", "Invoerbron 2: Verouderd"), ("Swap control-command key", "Wissel controle-commando toets"), - ("swap-left-right-mouse", "wissel-links-rechts-muis"), + ("swap-left-right-mouse", "Wissel linker- en rechtermuisknop"), ("2FA code", "2FA-code"), ("More", "Meer"), - ("enable-2fa-title", "activeer-2fa-titel"), - ("enable-2fa-desc", "activeer-2fa-desc"), - ("wrong-2fa-code", "foutieve-2fa-code"), - ("enter-2fa-title", "geef-2fa-titel in"), + ("enable-2fa-title", "Tweefactorauthenticatie inschakelen"), + ("enable-2fa-desc", "Stel nu uw authenticator in. U kunt een authenticator-app zoals Authy, Microsoft of Google Authenticator op uw telefoon of desktop gebruiken.\n\nScan de QR-code met uw app en voer de code in die uw app toont om tweefactorauthenticatie in te schakelen."), + ("wrong-2fa-code", "Kan de code niet verifiëren. Controleer of de code en lokale tijdinstellingen correct zijn."), + ("enter-2fa-title", "Tweefactorauthenticatie (2FA)"), ("Email verification code must be 6 characters.", "E-mailverificatiecode moet 6 tekens lang zijn."), ("2FA code must be 6 digits.", "2FA-code moet 6 cijfers lang zijn."), ("Multiple Windows sessions found", "Meerdere Windows-sessies gevonden"), - ("Please select the session you want to connect to", "Selecteer de sessie waarmee je verbinding wilt maken"), + ("Please select the session you want to connect to", "Selecteer de sessie waarmee u verbinding wilt maken"), ("powered_by_me", "Werkt met Rustdesk"), - ("outgoing_only_desk_tip", "Je kan verbinding maken met andere apparaten, maar andere apparaten kunnen geen verbinding maken met dit apparaat."), + ("outgoing_only_desk_tip", "U kan verbinding maken met andere apparaten, maar andere apparaten kunnen geen verbinding maken met u."), ("preset_password_warning", "Dit is een aangepaste editie en wordt geleverd met een vooraf ingesteld wachtwoord. Iedereen die dit wachtwoord kent, kan de volledige controle over het apparaat krijgen."), ("Security Alert", "Beveiligingswaarschuwing"), ("My address book", "Mijn adresboek"), @@ -603,16 +598,16 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("no_need_privacy_mode_no_physical_displays_tip", "Geen fysieke schermen, geen privémodus nodig."), ("Follow remote cursor", "Volg de cursor op afstand"), ("Follow remote window focus", "Volg de focus van het venster op afstand"), - ("default_proxy_tip", "Typisch protocol en poort - Socks5 en 1080"), + ("default_proxy_tip", "Standaard protocol en poort: Socks5 en 1080"), ("no_audio_input_device_tip", "Er is geen invoerapparaat gevonden."), ("Incoming", "Inkomend"), ("Outgoing", "Uitgaand"), ("Clear Wayland screen selection", "Wayland-scherm wissen"), - ("clear_Wayland_screen_selection_tip", "Nadat je de schermselectie hebt gewist, kun je het scherm dat je wilt delen opnieuw selecteren."), - ("confirm_clear_Wayland_screen_selection_tip", "Weet je zeker dat je de Wayland-schermselectie wilt wissen?"), + ("clear_Wayland_screen_selection_tip", "Nadat u de schermselectie heeft gewist, kunt u het scherm dat u wilt delen opnieuw selecteren."), + ("confirm_clear_Wayland_screen_selection_tip", "Weet u zeker dat u de Wayland-schermselectie wilt wissen?"), ("android_new_voice_call_tip", "Er is een nieuwe spraakoproep ontvangen. Als u het aanvaardt, schakelt de audio over naar spraakcommunicatie."), ("texture_render_tip", "Pas textuurrendering toe om afbeeldingen vloeiender te maken."), - ("Use texture rendering", "Textuurrendering gebruiken"), + ("Use texture rendering", "Textuurweergave gebruiken"), ("Floating window", "Zwevend venster"), ("floating_window_tip", "Helpt RustDesk op de achtergrond actief te houden"), ("Keep screen on", "Scherm ingeschakeld laten"), @@ -627,9 +622,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Power", "Stroom"), ("Telegram bot", "Telegram bot"), ("enable-bot-tip", "Als u deze functie inschakelt, kunt u een 2FA-code ontvangen van uw bot. Het kan ook fungeren als een verbindingsmelding."), - ("enable-bot-desc", "1, Open een chat met @BotFather.\n2, Verzend het commando \"/newbot\". Als deze stap voltooid is, ontvang je een token.\n3, Start een chat met de nieuw aangemaakte bot. Om hem te activeren stuurt u een bericht dat begint met een schuine streep (\"/\"), bijvoorbeeld \"/hello\".\n"), - ("cancel-2fa-confirm-tip", "Weet je zeker dat je 2FA wilt annuleren?"), - ("cancel-bot-confirm-tip", "Weet je zeker dat je de Telegram-bot wilt annuleren?"), + ("enable-bot-desc", "1, Open een chat met @BotFather.\n2, Verzend het commando \"/newbot\". Als deze stap voltooid is, ontvangt u een token.\n3, Start een chat met de nieuw aangemaakte bot. Om hem te activeren stuurt u een bericht dat begint met een schuine streep (\"/\"), bijvoorbeeld \"/hello\".\n"), + ("cancel-2fa-confirm-tip", "Weet u zeker dat u 2FA wilt annuleren?"), + ("cancel-bot-confirm-tip", "Weet u zeker dat u de Telegram-bot wilt annuleren?"), ("About RustDesk", "Over RustDesk"), ("Send clipboard keystrokes", "Klembord toetsaanslagen verzenden"), ("network_error_tip", "Controleer de netwerkverbinding en selecteer 'Opnieuw proberen'."), @@ -648,10 +643,107 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("one-way-file-transfer-tip", "Eenzijdige bestandsoverdracht is ingeschakeld aan de gecontroleerde kant."), ("Authentication Required", "Verificatie vereist"), ("Authenticate", "Verificatie"), - ("web_id_input_tip", "Je kunt een ID invoeren op dezelfde server, directe IP-toegang wordt niet ondersteund in de webclient.\nAls je toegang wilt tot een apparaat op een andere server, voeg je het serveradres toe (@?key=), bijvoorbeeld,\n9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=.\nAls je toegang wilt krijgen tot een apparaat op een publieke server, voer dan \"@public\" in, sleutel is niet nodig voor de publieke server."), + ("web_id_input_tip", "Je kunt een ID invoeren op dezelfde server, directe IP-toegang wordt niet ondersteund in de webclient.\nAls u toegang wilt tot een apparaat op een andere server, voegt u het serveradres toe (@?key=), bijvoorbeeld,\n9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=.\nAls u toegang wilt krijgen tot een apparaat op een publieke server, voer dan \"@public\" in, sleutel is niet nodig voor de publieke server."), ("Download", "Downloaden"), ("Upload folder", "Map uploaden"), ("Upload files", "Bestanden uploaden"), ("Clipboard is synchronized", "Klembord is gesynchroniseerd"), + ("Update client clipboard", "Klembord van client bijwerken"), + ("Untagged", "Ongemarkeerd"), + ("new-version-of-{}-tip", "Er is een nieuwe versie van {} beschikbaar"), + ("Accessible devices", "Toegankelijke apparaten"), + ("upgrade_remote_rustdesk_client_to_{}_tip", "Upgrade de RustDesk client naar versie {} of nieuwer op de externe computer!"), + ("d3d_render_tip", "Wanneer D3D-rendering is ingeschakeld kan het externe scherm op sommige apparaten, zwart zijn."), + ("Use D3D rendering", "Gebruik D3D-rendering"), + ("Printer", "Printer"), + ("printer-os-requirement-tip", "Windows 10 of hoger is vereist om de uitgaande functie met de printer te laten werken."), + ("printer-requires-installed-{}-client-tip", "Om afdrukken op afstand te gebruiken, moet {} geïnstalleerd zijn op dit apparaat."), + ("printer-{}-not-installed-tip", "De printer {} is niet geïnstalleerd."), + ("printer-{}-ready-tip", "De printer {} is geïnstalleerd en klaar voor gebruik."), + ("Install {} Printer", "Installeer {} Printer"), + ("Outgoing Print Jobs", "Uitgaande Afdruktaken"), + ("Incoming Print Jobs", "Inkomende Afdruktaken"), + ("Incoming Print Job", "Inkomende Afdruktaak"), + ("use-the-default-printer-tip", "Gebruik de standaard printer"), + ("use-the-selected-printer-tip", "Gebruik de geselecteerde printer"), + ("auto-print-tip", "Automatisch afdrukken op de geselecteerde printer."), + ("print-incoming-job-confirm-tip", "Er werd een afdruktaak ontvangen van een extern apparaat. Moet ik deze lokaal afdrukken?"), + ("remote-printing-disallowed-tile-tip", "Afdruk op afstand is verboden"), + ("remote-printing-disallowed-text-tip", "Machtigingsinstellingen aan beheerde zijde verhinderen afdrukken op afstand."), + ("save-settings-tip", "Instellingen opslaan"), + ("dont-show-again-tip", "Dit bericht wordt niet meer weergegeven"), + ("Take screenshot", "Maak een schermafbeelding"), + ("Taking screenshot", "Schermafbeelding maken"), + ("screenshot-merged-screen-not-supported-tip", "Schermafbeeldingen van meerdere schermen samenvoegen wordt momenteel niet ondersteund. Schakel over naar een enkel scherm en herhaal de actie."), + ("screenshot-action-tip", "Kies wat je met de gemaakte schermafbeelding wilt doen."), + ("Save as", "Opslaan als"), + ("Copy to clipboard", "Kopiëren naar het klembord"), + ("Enable remote printer", "Printer op afstand inschakelen"), + ("Downloading {}", "Downloaden {}"), + ("{} Update", "{} Updaten"), + ("{}-to-update-tip", "{} zal sluiten en de nieuwe versie installeren."), + ("download-new-version-failed-tip", "Fout bij het downloaden. Je kunt het opnieuw proberen of op de knop Downloaden klikken om de applicatie van de officiële website te downloaden en handmatig bij te werken."), + ("Auto update", "Automatisch updaten"), + ("update-failed-check-msi-tip", "Kan de installatiemethode niet bepalen. Klik op “Downloaden” om de applicatie van de officiële website te downloaden en handmatig bij te werken."), + ("websocket_tip", "Het WebSocketprotocol ondersteunt alleen verbindingen met de repeater."), + ("Use WebSocket", "Gebruik het WebSocketprotocol"), + ("Trackpad speed", "Snelheid Trackpad"), + ("Default trackpad speed", "Standaardsnelheid Trackpad"), + ("Numeric one-time password", "Eenmalig numeriek wachtwoord"), + ("Enable IPv6 P2P connection", "IPv6 P2P-verbinding inschakelen"), + ("Enable UDP hole punching", "UDP-hole punching inschakelen"), + ("View camera", "Camera bekijken"), + ("Enable camera", "Camera inschakelen"), + ("No cameras", "Geen camera's"), + ("view_camera_unsupported_tip", "Het externe apparaat ondersteunt geen cameraweergave."), + ("Terminal", "Terminal"), + ("Enable terminal", "Terminal inschakelen"), + ("New tab", "Nieuw tabblad"), + ("Keep terminal sessions on disconnect", "Terminalsessies bij verbreking van de verbinding behouden"), + ("Terminal (Run as administrator)", "Terminal (Als administrator uitvoeren)"), + ("terminal-admin-login-tip", "Voer de gebruikersnaam en het wachtwoord in van de beheerder van het gecontroleerde apparaat."), + ("Failed to get user token.", "Kan geen gebruikerstoken krijgen."), + ("Incorrect username or password.", "Foutieve gebruikersnaam of wachtwoord."), + ("The user is not an administrator.", "De gebruiker is geen beheerder."), + ("Failed to check if the user is an administrator.", "Fout bij het controleren of de gebruiker een beheerder is."), + ("Supported only in the installed version.", "Alleen ondersteund in de geïnstalleerde versie."), + ("elevation_username_tip", "Voer je gebruikersnaam of domeinnaam in"), + ("Preparing for installation ...", "Installatie voorbereiden ..."), + ("Show my cursor", "Toon mijn cursor"), + ("Scale custom", "Aangepaste schaal"), + ("Custom scale slider", "Aangepaste schuifregelaar voor schaal"), + ("Decrease", "Verlagen"), + ("Increase", "Verhogen"), + ("Show virtual mouse", "Virtuele muis weergeven"), + ("Virtual mouse size", "Virtuele muis grootte"), + ("Small", "Klein"), + ("Large", "Groot"), + ("Show virtual joystick", "Virtuele joystick weergeven"), + ("Edit note", "Opmerking bewerken"), + ("Alias", "Alias"), + ("ScrollEdge", "Schuifbalk"), + ("Allow insecure TLS fallback", "Onbeveiligde TLS-terugval toestaan"), + ("allow-insecure-tls-fallback-tip", "Standaard controleert RustDesk het certificaat van de server bij het gebruik van protocollen die TLS gebruiken. Wanneer deze optie is ingeschakeld, laat RustDesk verbindingen toe, zelfs als de verificatiestap mislukt."), + ("Disable UDP", "UDP uitschakelen"), + ("disable-udp-tip", "Controleert of alleen TCP moet worden gebruikt. Als deze optie is ingeschakeld, gebruikt RustDesk niet langer UDP 21116, maar TCP 21116."), + ("server-oss-not-support-tip", "Opmerking: Deze functie is niet beschikbaar in de open-sourceversie van de RustDesk-server."), + ("input note here", "voeg hier een opmerking toe"), + ("note-at-conn-end-tip", "Vraag om een opmerking aan het einde van de verbinding"), + ("Show terminal extra keys", "Toon extra toetsen voor terminal"), + ("Relative mouse mode", "Relatieve muismodus"), + ("rel-mouse-not-supported-peer-tip", "De relatieve muismodus wordt niet ondersteund door het externe apparaat."), + ("rel-mouse-not-ready-tip", "De relatieve muismodus was nog niet klaar, probeer het later opnieuw."), + ("rel-mouse-lock-failed-tip", "Het vergrendelen van de cursor is mislukt. De relatieve muismodus is uitgeschakeld."), + ("rel-mouse-exit-{}-tip", "Druk op {} om af te sluiten."), + ("rel-mouse-permission-lost-tip", "De toetsenbordcontrole is uitgeschakeld. De relatieve muismodus is uitgeschakeld."), + ("Changelog", "Wijzigingenlogboek"), + ("keep-awake-during-outgoing-sessions-label", "Houd het scherm open tijdens de uitgaande sessies."), + ("keep-awake-during-incoming-sessions-label", "Houd het scherm open tijdens de inkomende sessies."), + ("Continue with {}", "Ga verder met {}"), + ("Display Name", "Naam Weergeven"), + ("password-hidden-tip", "Er is een permanent wachtwoord ingesteld (verborgen)."), + ("preset-password-in-use-tip", "Het basis wachtwoord is momenteel in gebruik."), + ("Enable privacy mode", "Privacymodus inschakelen"), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/pl.rs b/src/lang/pl.rs index fd5641ac1..fdf4ae8c5 100644 --- a/src/lang/pl.rs +++ b/src/lang/pl.rs @@ -13,7 +13,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Service is running", "Usługa uruchomiona"), ("Service is not running", "Usługa nie jest uruchomiona"), ("not_ready_status", "Brak gotowości"), - ("Control Remote Desktop", "Połącz się z"), + ("Control Remote Desktop", "Steruj pulpitem zdalnym"), ("Transfer file", "Transfer plików"), ("Connect", "Połącz"), ("Recent sessions", "Ostatnie sesje"), @@ -41,7 +41,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("length %min% to %max%", "o długości od %min% do %max%"), ("starts with a letter", "rozpoczyna się literą"), ("allowed characters", "dozwolone znaki"), - ("id_change_tip", "Nowy ID może być złożony z małych i dużych liter a-zA-z, cyfry 0-9 oraz _ (podkreślenie). Pierwszym znakiem powinna być litera a-zA-Z, a całe ID powinno składać się z 6 do 16 znaków."), + ("id_change_tip", "Nowy ID może być złożony z małych i dużych liter a-zA-z, cyfry 0-9, - (dash) oraz _ (podkreślenie). Pierwszym znakiem powinna być litera a-zA-Z, a całe ID powinno składać się z 6 do 16 znaków."), ("Website", "Strona internetowa"), ("About", "O aplikacji"), ("Slogan_tip", "Tworzone z miłością w tym pełnym chaosu świecie!"), @@ -75,7 +75,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Do you want to enter again?", "Czy chcesz wprowadzić ponownie?"), ("Connection Error", "Błąd połączenia"), ("Error", "Błąd"), - ("Reset by the peer", "Połączenie zresetowanie przez zdalne urządzenie"), + ("Reset by the peer", "Połączenie zresetowane przez zdalne urządzenie"), ("Connecting...", "Łączenie..."), ("Connection in progress. Please wait.", "Trwa łączenie. Proszę czekać."), ("Please try 1 minute later", "Spróbuj za minutę"), @@ -120,7 +120,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Original", "Oryginalny"), ("Shrink", "Zmniejsz"), ("Stretch", "Rozciągnij"), - ("Scrollbar", "Przewijanie ręczne"), + ("Scrollbar", "Pasek przewijania"), ("ScrollAuto", "Przewijanie automatyczne"), ("Good image quality", "Wysoka jakość obrazu"), ("Balanced", "Tryb zbalansowany"), @@ -147,8 +147,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("OS Password", "Hasło systemu operacyjnego"), ("install_tip", "RustDesk może nie działać poprawnie na maszynie zdalnej z przyczyn związanych z UAC. W celu uniknięcia problemów z UAC, kliknij poniższy przycisk by zainstalować RustDesk w swoim systemie."), ("Click to upgrade", "Zaktualizuj"), - ("Click to download", "Pobierz"), - ("Click to update", "Uaktualnij"), ("Configure", "Konfiguruj"), ("config_acc", "Konfiguracja konta"), ("config_screen", "Konfiguracja ekranu"), @@ -163,7 +161,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("End-user license agreement", "Umowa licencyjna użytkownika końcowego"), ("Generating ...", "Trwa generowanie..."), ("Your installation is lower version.", "Twoja instalacja jest w niższej wersji"), - ("not_close_tcp_tip", "Podczas korzystanie z tunelowania, nie zamykaj tego okna."), + ("not_close_tcp_tip", "Podczas korzystania z tunelowania, nie zamykaj tego okna."), ("Listening ...", "Nasłuchiwanie..."), ("Remote Host", "Host zdalny"), ("Remote Port", "Port zdalny"), @@ -200,7 +198,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Fix it", "Napraw to"), ("Warning", "Ostrzeżenie"), ("Login screen using Wayland is not supported", "Ekran logowania korzystający z Wayland nie jest obsługiwany"), - ("Reboot required", "Wymagany ponowne uruchomienie"), + ("Reboot required", "Wymagane ponowne uruchomienie"), ("Unsupported display server", "Nieobsługiwany serwer wyświetlania"), ("x11 expected", "Wymagany jest X11"), ("Port", "Port"), @@ -227,7 +225,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Add Tag", "Dodaj Tag"), ("Unselect all tags", "Odznacz wszystkie tagi"), ("Network error", "Błąd sieci"), - ("Username missed", "Nieprawidłowe nazwa użytkownika"), + ("Username missed", "Nieprawidłowa nazwa użytkownika"), ("Password missed", "Nieprawidłowe hasło"), ("Wrong credentials", "Błędne dane uwierzytelniające"), ("The verification code is incorrect or has expired", "Kod weryfikacyjny jest niepoprawny lub wygasł"), @@ -267,7 +265,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("No permission of file transfer", "Brak uprawnień na przesyłanie plików"), ("Note", "Notatka"), ("Connection", "Połączenie"), - ("Share Screen", "Udostępnij ekran"), + ("Share screen", "Udostępnianie ekranu"), ("Chat", "Czat"), ("Total", "Łącznie"), ("items", "elementów"), @@ -275,8 +273,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Screen Capture", "Przechwytywanie ekranu"), ("Input Control", "Kontrola wejścia"), ("Audio Capture", "Przechwytywanie dźwięku"), - ("File Connection", "Przekazywanie plików"), - ("Screen Connection", "Przekazywanie ekranu"), ("Do you accept?", "Akceptujesz?"), ("Open System Setting", "Otwórz ustawienia systemowe"), ("How to get Android input permission?", "Jak uzyskać uprawnienia do wprowadzania danych w systemie Android?"), @@ -318,10 +314,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Enable remote restart", "Włącz zdalne restartowanie"), ("Restart remote device", "Zrestartuj zdalne urządzenie"), ("Are you sure you want to restart", "Czy na pewno uruchomić ponownie"), - ("Restarting remote device", "Trwa restartowanie Zdalnego Urządzenia"), + ("Restarting remote device", "Trwa restartowanie zdalnego urządzenia"), ("remote_restarting_tip", "Trwa ponownie uruchomienie zdalnego urządzenia, zamknij ten komunikat i ponownie nawiąż za chwilę połączenie używając hasła permanentnego"), ("Copied", "Skopiowano"), - ("Exit Fullscreen", "Wyłączyć tryb pełnoekranowy"), + ("Exit Fullscreen", "Wyłącz tryb pełnoekranowy"), ("Fullscreen", "Tryb pełnoekranowy"), ("Mobile Actions", "Dostępne mobilne polecenia"), ("Select Monitor", "Wybierz ekran"), @@ -381,14 +377,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Keyboard Settings", "Ustawienia klawiatury"), ("Full Access", "Pełny dostęp"), ("Screen Share", "Udostępnianie ekranu"), - ("Wayland requires Ubuntu 21.04 or higher version.", "Wayland wymaga Ubuntu 21.04 lub nowszego."), - ("Wayland requires higher version of linux distro. Please try X11 desktop or change your OS.", "Wayland wymaga nowszej dystrybucji Linuksa. Wypróbuj pulpit X11 lub zmień system operacyjny."), - ("JumpLink", "View"), + ("ubuntu-21-04-required", "Wayland wymaga Ubuntu 21.04 lub nowszego."), + ("wayland-requires-higher-linux-version", "Wayland wymaga nowszej dystrybucji Linuksa. Wypróbuj pulpit X11 lub zmień system operacyjny."), + ("xdp-portal-unavailable", "Nie udało się przechwycić ekranu Wayland. Portal XDG Desktop mógł ulec awarii lub jest niedostępny. Spróbuj go ponownie uruchomić poleceniem `systemctl --user restart xdg-desktop-portal`."), + ("JumpLink", "Podgląd"), ("Please Select the screen to be shared(Operate on the peer side).", "Wybierz ekran do udostępnienia (działaj po zdalnego urządzenia)."), ("Show RustDesk", "Pokaż RustDesk"), ("This PC", "Ten komputer"), ("or", "lub"), - ("Continue with", "Kontynuuj z"), ("Elevate", "Uzyskaj uprawnienia"), ("Zoom cursor", "Powiększenie kursora"), ("Accept sessions via password", "Uwierzytelnij sesję używając hasła"), @@ -407,13 +403,13 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Add to address book", "Dodaj do Książki Adresowej"), ("Group", "Grupy"), ("Search", "Szukaj"), - ("Closed manually by web console", "Zakończone manualnie z konsoli Web"), + ("Closed manually by web console", "Zakończone ręcznie z poziomu konsoli webowej"), ("Local keyboard type", "Lokalny typ klawiatury"), ("Select local keyboard type", "Wybierz lokalny typ klawiatury"), ("software_render_tip", "Jeżeli posiadasz kartę graficzną Nvidia i okno zamyka się natychmiast po nawiązaniu połączenia, instalacja sterownika nouveau i wybór renderowania programowego mogą pomóc. Restart aplikacji jest wymagany."), ("Always use software rendering", "Zawsze używaj renderowania programowego"), ("config_input", "By kontrolować zdalne urządzenie przy pomocy klawiatury, musisz udzielić aplikacji RustDesk uprawnień do \"Urządzeń Wejściowych\"."), - ("config_microphone", "Aby umożliwić zdalne rozmowy należy przyznać RuskDesk uprawnienia do \"Nagrań audio\"."), + ("config_microphone", "Aby umożliwić zdalne rozmowy należy przyznać RustDesk uprawnienia do \"Nagrań audio\"."), ("request_elevation_tip", "Możesz poprosić o podniesienie uprawnień jeżeli ktoś posiada dostęp do zdalnego urządzenia."), ("Wait", "Czekaj"), ("Elevation Error", "Błąd przy podnoszeniu uprawnień"), @@ -463,9 +459,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("empty_favorite_tip", "Brak ulubionych?\nZnajdźmy kogoś, z kim możesz się połączyć i dodaj Go do ulubionych!"), ("empty_lan_tip", "Ojej, wygląda na to, że nie odkryliśmy żadnych urządzeń z RustDesk w Twojej sieci."), ("empty_address_book_tip", "Ojej, wygląda na to, że nie ma żadnych wpisów w Twojej książce adresowej."), - ("eg: admin", "np. admin"), - ("Empty Username", "Pusty użytkownik"), - ("Empty Password", "Puste hasło"), + ("Empty Username", "Pole nazwy użytkownika jest puste"), + ("Empty Password", "Pole hasła jest puste"), ("Me", "Ja"), ("identical_file_tip", "Ten plik jest identyczny z plikiem na drugim komputerze."), ("show_monitors_tip", "Pokaż monitory w zasobniku"), @@ -653,5 +648,102 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Upload folder", "Wyślij folder"), ("Upload files", "Wyślij pliki"), ("Clipboard is synchronized", "Schowek jest zsynchronizowany"), + ("Update client clipboard", "Uaktualnij schowek klienta"), + ("Untagged", "Bez etykiety"), + ("new-version-of-{}-tip", "Dostępna jest nowa wersja {}"), + ("Accessible devices", "Dostępne urządzenia"), + ("upgrade_remote_rustdesk_client_to_{}_tip", "Proszę zaktualizować zdalny klient RustDesk do wersji {} lub nowszej!"), + ("d3d_render_tip", "Kiedy włączenie renderowania D3D jest włączone, ekran zdalnej kontroli może być czarny w niektórych przypadkach"), + ("Use D3D rendering", "Użyj renderowania D3D"), + ("Printer", "Drukarka"), + ("printer-os-requirement-tip", "Funkcja drukowania zdalnego wymaga Windows 10 lub nowszego"), + ("printer-requires-installed-{}-client-tip", "Aby włączyć funkcję zdalnego drukowania, {} musi być zainstalowany na tym urządzeniu."), + ("printer-{}-not-installed-tip", "Drukarka {} nie jest zainstalowana."), + ("printer-{}-ready-tip", "Drukarka {} jest zainstalowana i gotowa do użycia."), + ("Install {} Printer", "Zainstaluj drukarkę {}"), + ("Outgoing Print Jobs", "Wychodzące zadania drukowania"), + ("Incoming Print Jobs", "Przychodzące zadania drukowania"), + ("Incoming Print Job", "Przychodzące zadanie drukowania"), + ("use-the-default-printer-tip", "Użyj domyślnej drukarki"), + ("use-the-selected-printer-tip", "Użyj wybranej drukarki"), + ("auto-print-tip", "Drukuj automatycznie używając wybranej drukarki"), + ("print-incoming-job-confirm-tip", "Otrzymałeś zadanie zdalnego drukowania. Chcesz wykonać je po swojej stronie?"), + ("remote-printing-disallowed-tile-tip", "Zdalne drukowanie niedozwolone"), + ("remote-printing-disallowed-text-tip", "Ustawienia uprawnień po zdalnej stronie uniemożliwiają zdalne drukowanie."), + ("save-settings-tip", "Zapisz ustawienia"), + ("dont-show-again-tip", "Nie pokazuj więcej"), + ("Take screenshot", "Zrób zrzut ekranu"), + ("Taking screenshot", "Tworzenie zrzutu ekranu"), + ("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"), + ("Copy to clipboard", "Kopiuj do schowka"), + ("Enable remote printer", "Włącz zdalne drukowanie"), + ("Downloading {}", "Pobieranie {}"), + ("{} Update", "Aktualizacja {}"), + ("{}-to-update-tip", "{} zostanie teraz zamknięty i zostanie zainstalowana nowa wersja."), + ("download-new-version-failed-tip", "Pobieranie nie powiodło się. Możesz spróbować ponownie lub kliknąć przycisk \"Pobierz\", aby pobrać ze strony programu i uaktualnić ręcznie."), + ("Auto update", "Automatyczna aktualizacja"), + ("update-failed-check-msi-tip", "Sprawdzenie metody instalacji nie powiodło się. Kliknij przycisk \"Pobierz\", aby pobrać ze strony wydania i uaktualnić ręcznie."), + ("websocket_tip", "Gdy używasz WebSocket, obsługiwane są tylko połączenia przekaźnikowe."), + ("Use WebSocket", "Użyj WebSocket"), + ("Trackpad speed", "Szybkość gładzika"), + ("Default trackpad speed", "Domyślna szybkość gładzika"), + ("Numeric one-time password", "Jednorazowe hasło cyfrowe"), + ("Enable IPv6 P2P connection", "Włącz połączenie P2P IPv6"), + ("Enable UDP hole punching", "Włącz tworzenie tunelu UDP"), + ("View camera", "Podgląd kamery"), + ("Enable camera", "Włącz kamerę"), + ("No cameras", "Brak kamer"), + ("view_camera_unsupported_tip", "Zdalne urządzenie nie obsługuje podglądu kamery."), + ("Terminal", "Terminal"), + ("Enable terminal", "Włącz terminal"), + ("New tab", "Nowa zakładka"), + ("Keep terminal sessions on disconnect", "Utrzymaj sesję terminala przy rozłączeniu"), + ("Terminal (Run as administrator)", "Terminal (uruchom jako administrator)"), + ("terminal-admin-login-tip", "Proszę wprowadzić użytkownika i hasło administratora kontrolowanego urządzenia."), + ("Failed to get user token.", "Błąd pobierania tokenu użytkownika."), + ("Incorrect username or password.", "Nieprawidłowy użytkownik lub hasło."), + ("The user is not an administrator.", "Użytkownik nie posiada praw administratora."), + ("Failed to check if the user is an administrator.", "Błąd sprawdzania, czy użytkownik jest administratorem."), + ("Supported only in the installed version.", "Wspierane tylko dla zainstalowanej aplikacji."), + ("elevation_username_tip", "Podaj nazwę użytkownika lub domena\\użytkownik"), + ("Preparing for installation ...", "Przygotowywanie do instalacji ..."), + ("Show my cursor", "Pokaż mój kursor"), + ("Scale custom", "Skala użytkownika"), + ("Custom scale slider", "Suwak skali użytkownika"), + ("Decrease", "Zmniejsz"), + ("Increase", "Zwiększ"), + ("Show virtual mouse", "Pokaż wirtualną mysz"), + ("Virtual mouse size", "Wielkość wirtualnego kursora myszy"), + ("Small", "Mały"), + ("Large", "Duży"), + ("Show virtual joystick", "Pokaz wirtualny joystick"), + ("Edit note", "Edytuj notatkę"), + ("Alias", "Alias"), + ("ScrollEdge", "Przewijanie na krawędzi"), + ("Allow insecure TLS fallback", "Zezwól na nie zweryfikowane połączenia TLS"), + ("allow-insecure-tls-fallback-tip", "Domyślnie RustDesk weryfikuje certyfikat serwera dla protokołów korzystających z TLS.\n Po włączeniu tej opcji, RustDesk pominie etap weryfikacji i będzie kontynuował działanie w przypadku negatywnej weryfikacji."), + ("Disable UDP", "Wyłącz protokół UDP"), + ("disable-udp-tip", "Kontroluje, czy używać wyłącznie protokołu TCP.\nPo włączeniu tej opcji, RustDesk nie będzie używać protokołu UDP 21116, zamiast niego będzie używać protokołu TCP 21116."), + ("server-oss-not-support-tip", "UWAGA: Serwer OSS RustDesk nie obsługuje tej funkcji."), + ("input note here", "Wstaw tutaj notatkę"), + ("note-at-conn-end-tip", "Poproś o notatkę po zakończeniu połączenia."), + ("Show terminal extra keys", "Pokaż dodatkowe klawisze terminala"), + ("Relative mouse mode", "Tryb przechwytywania myszy"), + ("rel-mouse-not-supported-peer-tip", "Zdalne urządzenie nie obsługuje trybu przechwytywania myszy"), + ("rel-mouse-not-ready-tip", "Tryb przechwytywania myszy nie jest gotowy"), + ("rel-mouse-lock-failed-tip", "Nie udało się przechwycić kursora myszy"), + ("rel-mouse-exit-{}-tip", "Aby wyłączyć tryb przechwytywania myszy, naciśnij {}"), + ("rel-mouse-permission-lost-tip", "Utracono uprawnienia do trybu przechwytywania myszy"), + ("Changelog", "Dziennik zmian"), + ("keep-awake-during-outgoing-sessions-label", "Utrzymuj urządzenie w stanie aktywnym podczas sesji wychodzących"), + ("keep-awake-during-incoming-sessions-label", "Utrzymuj urządzenie w stanie aktywnym podczas sesji przychodzących"), + ("Continue with {}", "Kontynuuj z {}"), + ("Display Name", "Nazwa wyświetlana"), + ("password-hidden-tip", "Ustawiono (ukryto) stare hasło."), + ("preset-password-in-use-tip", "Obecnie używane jest hasło domyślne."), + ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/pt_PT.rs b/src/lang/pt_PT.rs index c0564e0f4..4138b46e4 100644 --- a/src/lang/pt_PT.rs +++ b/src/lang/pt_PT.rs @@ -41,7 +41,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("length %min% to %max%", ""), ("starts with a letter", ""), ("allowed characters", ""), - ("id_change_tip", "Somente os caracteres a-z, A-Z, 0-9 e _ (sublinhado) são permitidos. A primeira letra deve ser a-z, A-Z. Comprimento entre 6 e 16."), + ("id_change_tip", "Somente os caracteres a-z, A-Z, 0-9, - (dash) e _ (sublinhado) são permitidos. A primeira letra deve ser a-z, A-Z. Comprimento entre 6 e 16."), ("Website", "Website"), ("About", "Sobre"), ("Slogan_tip", ""), @@ -147,8 +147,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("OS Password", "Senha do SO"), ("install_tip", "Devido ao UAC, o RustDesk não funciona correctamente em alguns casos. Para evitar o UAC, por favor clique no botão abaixo para instalar o RustDesk no sistema."), ("Click to upgrade", "Clique para atualizar"), - ("Click to download", "Clique para carregar"), - ("Click to update", "Clique para fazer a actualização"), ("Configure", "Configurar"), ("config_acc", "Para controlar o seu Ambiente de Trabalho remotamente, é preciso conceder ao RustDesk permissões de \"Acessibilidade\"."), ("config_screen", "Para aceder ao seu Ambiente de Trabalho remotamente, é preciso conceder ao RustDesk permissões de \"Gravar a Tela\"/"), @@ -267,7 +265,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("No permission of file transfer", "Sem permissões de transferência de ficheiro"), ("Note", "Nota"), ("Connection", "Ligação"), - ("Share Screen", "Partilhar ecrã"), + ("Share screen", "Partilhar ecrã"), ("Chat", "Conversar"), ("Total", "Total"), ("items", "itens"), @@ -275,8 +273,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Screen Capture", "Captura de Ecran"), ("Input Control", "Controle de Entrada"), ("Audio Capture", "Captura de Áudio"), - ("File Connection", "Ligação de Arquivo"), - ("Screen Connection", "Ligação de Ecran"), ("Do you accept?", "Aceita?"), ("Open System Setting", "Abrir Configurações do Sistema"), ("How to get Android input permission?", "Como activar a permissão de entrada do Android?"), @@ -381,14 +377,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Keyboard Settings", "Configurações do teclado"), ("Full Access", "Controlo total"), ("Screen Share", ""), - ("Wayland requires Ubuntu 21.04 or higher version.", "Wayland requer Ubuntu 21.04 ou versão superior."), - ("Wayland requires higher version of linux distro. Please try X11 desktop or change your OS.", "Wayland requer uma versão superior da distribuição linux. Por favor, tente o desktop X11 ou mude seu sistema operacional."), + ("ubuntu-21-04-required", "Wayland requer Ubuntu 21.04 ou versão superior."), + ("wayland-requires-higher-linux-version", "Wayland requer uma versão superior da distribuição linux. Por favor, tente o desktop X11 ou mude seu sistema operacional."), + ("xdp-portal-unavailable", ""), ("JumpLink", "View"), ("Please Select the screen to be shared(Operate on the peer side).", "Por favor, selecione a tela a ser compartilhada (operar no lado do peer)."), ("Show RustDesk", ""), ("This PC", ""), ("or", ""), - ("Continue with", ""), ("Elevate", ""), ("Zoom cursor", ""), ("Accept sessions via password", ""), @@ -463,7 +459,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("empty_favorite_tip", ""), ("empty_lan_tip", ""), ("empty_address_book_tip", ""), - ("eg: admin", ""), ("Empty Username", ""), ("Empty Password", ""), ("Me", ""), @@ -653,5 +648,102 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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", ""), + ("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", ""), + ("Trackpad speed", ""), + ("Default trackpad speed", ""), + ("Numeric one-time password", ""), + ("Enable IPv6 P2P connection", ""), + ("Enable UDP hole punching", ""), + ("View camera", "Ver câmara"), + ("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", "Escala personalizada"), + ("Custom scale slider", "Controlo deslizante de escala personalizada"), + ("Decrease", "Diminuir"), + ("Increase", "Aumentar"), + ("Show virtual mouse", ""), + ("Virtual mouse size", ""), + ("Small", ""), + ("Large", ""), + ("Show virtual joystick", ""), + ("Edit note", ""), + ("Alias", ""), + ("ScrollEdge", ""), + ("Allow insecure TLS fallback", ""), + ("allow-insecure-tls-fallback-tip", ""), + ("Disable 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 {}", ""), + ("Display Name", ""), + ("password-hidden-tip", ""), + ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ptbr.rs b/src/lang/ptbr.rs index 14254388c..1428a71d0 100644 --- a/src/lang/ptbr.rs +++ b/src/lang/ptbr.rs @@ -41,7 +41,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("length %min% to %max%", "tamanho %min% para %max%"), ("starts with a letter", "começa com uma letra"), ("allowed characters", "caracteres permitidos"), - ("id_change_tip", "Somente os caracteres a-z, A-Z, 0-9 e _ (sublinhado) são permitidos. A primeira letra deve ser a-z, A-Z. Comprimento entre 6 e 16."), + ("id_change_tip", "Somente os caracteres a-z, A-Z, 0-9, - (dash) e _ (sublinhado) são permitidos. A primeira letra deve ser a-z, A-Z. Comprimento entre 6 e 16."), ("Website", "Website"), ("About", "Sobre"), ("Slogan_tip", "Feito de coração neste mundo caótico!"), @@ -147,8 +147,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("OS Password", "Senha do SO"), ("install_tip", "Devido ao UAC, o RustDesk não funciona corretamente como o lado remoto em alguns casos. Para evitar o UAC, por favor clique no botão abaixo para instalar o RustDesk no sistema."), ("Click to upgrade", "Clique para fazer o upgrade"), - ("Click to download", "Clique para baixar"), - ("Click to update", "Clique para fazer o update"), ("Configure", "Configurar"), ("config_acc", "Para controlar seu computador remotamente, você precisa conceder ao RustDesk permissões de \"Acessibilidade\"."), ("config_screen", "Para acessar seu computador remotamente, você precisa conceder ao RustDesk permissões de \"Gravar a Tela\"/"), @@ -230,7 +228,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Username missed", "Nome de usuário requerido"), ("Password missed", "Senha requerida"), ("Wrong credentials", "Nome de usuário ou senha incorretos"), - ("The verification code is incorrect or has expired", ""), + ("The verification code is incorrect or has expired", "O código de verificação está incorreto ou expirou"), ("Edit Tag", "Editar Tag"), ("Forget Password", "Esquecer Senha"), ("Favorites", "Favoritos"), @@ -267,7 +265,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("No permission of file transfer", "Sem permissão para transferência de arquivo"), ("Note", "Nota"), ("Connection", "Conexão"), - ("Share Screen", "Compartilhar Tela"), + ("Share screen", "Compartilhar Tela"), ("Chat", "Chat"), ("Total", "Total"), ("items", "itens"), @@ -275,8 +273,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Screen Capture", "Captura de Tela"), ("Input Control", "Controle de Entrada"), ("Audio Capture", "Captura de Áudio"), - ("File Connection", "Conexão de Arquivo"), - ("Screen Connection", "Conexão de Tela"), ("Do you accept?", "Você aceita?"), ("Open System Setting", "Abrir Configurações do Sistema"), ("How to get Android input permission?", "Como habilitar a permissão de entrada do Android?"), @@ -330,8 +326,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Ratio", "Proporção"), ("Image Quality", "Qualidade de Imagem"), ("Scroll Style", "Estilo de Rolagem"), - ("Show Toolbar", ""), - ("Hide Toolbar", ""), + ("Show Toolbar", "Mostrar Barra de Ferramentas"), + ("Hide Toolbar", "Ocultar Barra de Ferramentas"), ("Direct Connection", "Conexão Direta"), ("Relay Connection", "Conexão via Relay"), ("Secure Connection", "Conexão Segura"), @@ -345,7 +341,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Light Theme", "Tema Claro"), ("Dark", "Escuro"), ("Light", "Claro"), - ("Follow System", "Seguir sistema"), + ("Follow System", "Padrão do sistema"), ("Enable hardware codec", "Habilitar codec de hardware"), ("Unlock Security Settings", "Desbloquear configurações de segurança"), ("Enable audio", "Habilitar áudio"), @@ -359,12 +355,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Audio Input Device", "Dispositivo de entrada de áudio"), ("Use IP Whitelisting", "Utilizar lista de IPs confiáveis"), ("Network", "Rede"), - ("Pin Toolbar", ""), - ("Unpin Toolbar", ""), + ("Pin Toolbar", "Fixar Barra de Ferramentas"), + ("Unpin Toolbar", "Desafixar Barra de Ferramentas"), ("Recording", "Gravando"), ("Directory", "Diretório"), ("Automatically record incoming sessions", "Gravar automaticamente sessões de entrada"), - ("Automatically record outgoing sessions", ""), + ("Automatically record outgoing sessions", "Gravar automaticamente sessões de saída"), ("Change", "Alterar"), ("Start session recording", "Iniciar gravação da sessão"), ("Stop session recording", "Parar gravação da sessão"), @@ -381,14 +377,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Keyboard Settings", "Configurações de teclado"), ("Full Access", "Acesso completo"), ("Screen Share", "Compartilhamento de tela"), - ("Wayland requires Ubuntu 21.04 or higher version.", "Wayland requer Ubuntu 21.04 ou versão superior."), - ("Wayland requires higher version of linux distro. Please try X11 desktop or change your OS.", "Wayland requer uma versão superior da distribuição linux. Por favor, tente o desktop X11 ou mude seu sistema operacional."), + ("ubuntu-21-04-required", "Wayland requer Ubuntu 21.04 ou versão superior."), + ("wayland-requires-higher-linux-version", "Wayland requer uma versão superior da distribuição linux. Por favor, tente o desktop X11 ou mude seu sistema operacional."), + ("xdp-portal-unavailable", ""), ("JumpLink", "JumpLink"), ("Please Select the screen to be shared(Operate on the peer side).", "Por favor, selecione a tela a ser compartilhada (operar no lado do parceiro)."), ("Show RustDesk", "Exibir RustDesk"), ("This PC", "Este Computador"), ("or", "ou"), - ("Continue with", "Continuar com"), ("Elevate", "Elevar"), ("Zoom cursor", "Aumentar cursor"), ("Accept sessions via password", "Aceitar sessões via senha"), @@ -463,7 +459,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("empty_favorite_tip", "Ainda não há parceiros favoritos?\nVamos encontrar alguém para se conectar e adicioná-lo aos seus favoritos!"), ("empty_lan_tip", "Ah não, parece que ainda não descobrimos nenhum parceiro."), ("empty_address_book_tip", "Oh céus, parece que atualmente não há parceiros listados em seu catálogo de endereços."), - ("eg: admin", "ex. admin"), ("Empty Username", "Nome de Usuário vazio"), ("Empty Password", "Senha Vazia"), ("Me", "Eu"), @@ -534,7 +529,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Installation Successful!", "Instalação bem-sucedida!"), ("Installation failed!", "A instalação falhou!"), ("Reverse mouse wheel", "Inverter rolagem do mouse"), - ("{} sessions", ""), + ("{} sessions", "{} sessões"), ("scam_title", "Você pode estar sendo ENGANADO!"), ("scam_text1", "Se você estiver ao telefone com alguém que NÃO conhece e em quem NÃO confia e essa pessoa pedir para você usar o RustDesk e iniciar o serviço, NÃO faça isso !! e desligue imediatamente."), ("scam_text2", "Provavelmente são golpistas tentando roubar seu dinheiro ou informações privadas."), @@ -547,7 +542,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Check for software update on startup", "Verificar atualizações do software ao iniciar"), ("upgrade_rustdesk_server_pro_to_{}_tip", "Atualize o RustDesk Server Pro para a versão {} ou superior."), ("pull_group_failed_tip", "Não foi possível atualizar o grupo."), - ("Filter by intersection", ""), + ("Filter by intersection", "Filtrar por interseção"), ("Remove wallpaper during incoming sessions", "Remover papel de parede durante sessão remota"), ("Test", "Teste"), ("display_is_plugged_out_msg", "A tela está desconectada. Mudando para a principal."), @@ -565,8 +560,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("True color (4:4:4)", "Cor verdadeira (4:4:4)"), ("Enable blocking user input", "Habilitar bloqueio da entrada do usuário"), ("id_input_tip", "Você pode inserir um ID, um IP direto ou um domínio com uma porta (:).\nPara acessar um dispositivo em outro servidor, adicione o IP do servidor (@?key=), por exemplo,\n9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=.\nPara acessar um dispositivo em um servidor público, insira \"@public\", a chave não é necessária para um servidor público."), - ("privacy_mode_impl_mag_tip", ""), - ("privacy_mode_impl_virtual_display_tip", ""), + ("privacy_mode_impl_mag_tip", "Modo 1"), + ("privacy_mode_impl_virtual_display_tip", "Modo 2"), ("Enter privacy mode", "Entrar no modo privado"), ("Exit privacy mode", "Sair do modo privado"), ("idd_not_support_under_win10_2004_tip", "O driver de tela indireto não é suportado. É necessário o Windows 10, versão 2004 ou superior."), @@ -589,10 +584,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("preset_password_warning", "Atenção: esta edição personalizada vem com uma senha predefinida. Qualquer pessoa que a conhecer poderá controlar totalmente seu dispositivo. Se isso não for o que você deseja, desinstale o software imediatamente."), ("Security Alert", "Alerta de Segurança"), ("My address book", "Minha lista de contatos"), - ("Personal", ""), + ("Personal", "Pessoal"), ("Owner", "Proprietário"), ("Set shared password", "Definir senha compartilhada"), - ("Exist in", ""), + ("Exist in", "Existe em"), ("Read-only", "Apenas leitura"), ("Read/Write", "Leitura/escrita"), ("Full Control", "Controle total"), @@ -603,14 +598,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("no_need_privacy_mode_no_physical_displays_tip", "Sem telas físicas, o modo privado não é necessário"), ("Follow remote cursor", "Seguir cursor remoto"), ("Follow remote window focus", "Seguir janela remota ativa"), - ("default_proxy_tip", ""), + ("default_proxy_tip", "O protocolo e a porta padrão são Socks5 e 1080"), ("no_audio_input_device_tip", "Nenhum dispositivo de entrada de áudio encontrado"), - ("Incoming", ""), - ("Outgoing", ""), + ("Incoming", "Entrada"), + ("Outgoing", "Saída"), ("Clear Wayland screen selection", "Limpar seleção de tela do Wayland"), ("clear_Wayland_screen_selection_tip", "Depois de limpar a seleção de tela, você pode selecioná-la novamente para compartilhar."), ("confirm_clear_Wayland_screen_selection_tip", "Tem certeza que deseja limpar a seleção da tela do Wayland?"), - ("android_new_voice_call_tip", ""), + ("android_new_voice_call_tip", "Uma nova solicitação de chamada de voz foi recebida. Se você aceitar, o áudio será alternado para comunicação por voz."), ("texture_render_tip", "Use renderização de textura para tornar as imagens mais suaves"), ("Use texture rendering", "Usar renderização de textura"), ("Floating window", "Janela flutuante"), @@ -624,7 +619,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Apps", "Apps"), ("Volume up", "Aumentar volume"), ("Volume down", "Diminuir volume"), - ("Power", ""), + ("Power", "Energia"), ("Telegram bot", "Bot Telegram"), ("enable-bot-tip", "Se você ativar este recurso, poderá receber o código 2FA do seu bot. Ele também pode funcionar como uma notificação de conexão."), ("enable-bot-desc", "1. Abra um chat com @BotFather.\n2. Envie o comando \"/newbot\". Você receberá um token após completar esta etapa.\n3. Inicie um chat com o seu bot recém-criado. Envie uma mensagem começando com uma barra invertida (\"/\"), como \"/hello\", para ativá-lo.\n"), @@ -645,13 +640,110 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Parent directory", "Diretório pai"), ("Resume", "Continuar"), ("Invalid file name", "Nome de arquivo inválido"), - ("one-way-file-transfer-tip", ""), - ("Authentication Required", ""), - ("Authenticate", ""), - ("web_id_input_tip", ""), - ("Download", ""), - ("Upload folder", ""), - ("Upload files", ""), - ("Clipboard is synchronized", ""), + ("one-way-file-transfer-tip", "A transferência de arquivos unidirecional está ativada no dispositivo controlado."), + ("Authentication Required", "Autenticação necessária"), + ("Authenticate", "Autenticar"), + ("web_id_input_tip", "Você pode inserir um ID no mesmo servidor; o acesso direto por IP não é suportado no cliente web.\nSe desejar acessar um dispositivo em outro servidor, por favor, adicione o endereço do servidor (@?key=), por exemplo,\n9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=.\nSe desejar acessar um dispositivo em um servidor público, por favor, insira \"@public\", a chave não é necessária para servidores públicos."), + ("Download", "Baixar"), + ("Upload folder", "Carregar pasta"), + ("Upload files", "Carregar arquivos"), + ("Clipboard is synchronized", "A área de transferência está sincronizada"), + ("Update client clipboard", "Atualizar a área de transferência do cliente"), + ("Untagged", "Sem etiqueta"), + ("new-version-of-{}-tip", "Uma nova versão de {} está disponível"), + ("Accessible devices", "Dispositivos acessíveis"), + ("upgrade_remote_rustdesk_client_to_{}_tip", "Atualize o cliente RustDesk para a versão {} ou superior no lado remoto."), + ("d3d_render_tip", "Em algumas máquinas, a tela do controle remoto pode ficar preta ao usar a renderização D3D."), + ("Use D3D rendering", "Usar renderização D3D"), + ("Printer", "Impressora"), + ("printer-os-requirement-tip", "A função de impressão de saída requer Windows 10 ou superior."), + ("printer-requires-installed-{}-client-tip", "{} deve ser instalado neste dispositivo antes que você possa usar a impressão remota."), + ("printer-{}-not-installed-tip", "A impressora {} não está instalada."), + ("printer-{}-ready-tip", "A impressora {} está instalada e operacional."), + ("Install {} Printer", "Instalar impressora {}"), + ("Outgoing Print Jobs", "Trabalhos de impressão enviados"), + ("Incoming Print Jobs", "Trabalhos de impressão recebidos"), + ("Incoming Print Job", "Impressão recebida"), + ("use-the-default-printer-tip", "Usar impressora padrão"), + ("use-the-selected-printer-tip", "Usar impressora selecionada"), + ("auto-print-tip", "Imprimir automaticamente usando a impressora selecionada."), + ("print-incoming-job-confirm-tip", "O dispositivo remoto enviou uma impressão. Deseja imprimir?"), + ("remote-printing-disallowed-tile-tip", "Impressão remota não permitida"), + ("remote-printing-disallowed-text-tip", "As configurações do dispositivo controlado não permitem impressão remota."), + ("save-settings-tip", "Salvar configurações"), + ("dont-show-again-tip", "Não mostrar novamente"), + ("Take screenshot", "Capturar de tela"), + ("Taking screenshot", "Capturando tela"), + ("screenshot-merged-screen-not-supported-tip", "Mesclar a captura de tela de múltiplos monitores não é suportada no momento. Por favor, alterne para um único monitor e tente novamente."), + ("screenshot-action-tip", "Por favor, selecione como seguir com a captura de tela."), + ("Save as", "Salvar como"), + ("Copy to clipboard", "Copiar para área de transferência"), + ("Enable remote printer", "Habilitar impressora remota"), + ("Downloading {}", "Baixando {}"), + ("{} Update", "Atualização do {}"), + ("{}-to-update-tip", "{} será fechado agora para instalar a nova versão."), + ("download-new-version-failed-tip", "Falha no download. Você pode tentar novamente ou clicar no botão \"Download\" para baixar da página releases e atualizar manualmente."), + ("Auto update", "Atualização automática"), + ("update-failed-check-msi-tip", "Falha na verificação do método de instalação. Clique no botão \"Download\" para baixar da página releases e atualizar manualmente."), + ("websocket_tip", "Usando WebSocket, apenas conexões via relay são suportadas."), + ("Use WebSocket", "Usar WebSocket"), + ("Trackpad speed", "Velocidade do trackpad"), + ("Default trackpad speed", "Velocidade padrão do trackpad"), + ("Numeric one-time password", "Senha numérica de uso único"), + ("Enable IPv6 P2P connection", "Habilitar conexão IPv6 P2P"), + ("Enable UDP hole punching", "Habilitar UDP hole punching"), + ("View camera", "Visualizar câmera"), + ("Enable camera", "Ativar câmera"), + ("No cameras", "Sem câmeras"), + ("view_camera_unsupported_tip", "O dispositivo remoto não suporta visualização da câmera."), + ("Terminal", "Terminal"), + ("Enable terminal", "Habilitar Terminal"), + ("New tab", "Nova aba"), + ("Keep terminal sessions on disconnect", "Manter sessões de terminal ao desconectar"), + ("Terminal (Run as administrator)", "Terminal (Executar como administrador)"), + ("terminal-admin-login-tip", "Insira o nome do usuário e senha de administrador do dispositivo controlado."), + ("Failed to get user token.", "Falha ao obter token do usuário."), + ("Incorrect username or password.", "Usuário ou senha incorretos"), + ("The user is not an administrator.", "O usuário não é administrador"), + ("Failed to check if the user is an administrator.", "Falha ao verificar se o usuário é administrador"), + ("Supported only in the installed version.", "Funciona somente na versão instalada"), + ("elevation_username_tip", "Insira o nome do usuário ou domínio\\usuário"), + ("Preparing for installation ...", "Preparando para instalação ..."), + ("Show my cursor", "Mostrar meu cursor"), + ("Scale custom", "Escala personalizada"), + ("Custom scale slider", "Controle deslizante de escala personalizada"), + ("Decrease", "Diminuir"), + ("Increase", "Aumentar"), + ("Show virtual mouse", "Mostrar mouse virtual"), + ("Virtual mouse size", "Tamanho do mouse virtual"), + ("Small", "Pequeno"), + ("Large", "Grande"), + ("Show virtual joystick", "Mostrar joystick virtual"), + ("Edit note", "Editar nota"), + ("Alias", "Apelido"), + ("ScrollEdge", "Rolagem nas bordas"), + ("Allow insecure TLS fallback", "Permitir fallback TLS inseguro"), + ("allow-insecure-tls-fallback-tip", "Por padrão, o RustDesk verifica o certificado do servidor para protocolos que usam TLS.\nCom esta opção habilitada, o RustDesk ignorará a verificação e prosseguirá em caso de falha."), + ("Disable UDP", "Desabilitar UDP"), + ("disable-udp-tip", "Controla se deve usar somente TCP.\nCom esta opção habilitada, o RustDesk não usará mais UDP 21116, TCP 21116 será usado no lugar."), + ("server-oss-not-support-tip", "NOTA: O servidor RustDesk OSS não inclui este recurso."), + ("input note here", "Insira uma nota aqui"), + ("note-at-conn-end-tip", "Solicitar nota ao final da conexão"), + ("Show terminal extra keys", "Mostrar teclas extras do terminal"), + ("Relative mouse mode", "Modo de Mouse Relativo"), + ("rel-mouse-not-supported-peer-tip", "O Modo de Mouse Relativo não é suportado pelo parceiro conectado."), + ("rel-mouse-not-ready-tip", "O Modo de Mouse Relativo ainda não está pronto. Por favor, tente novamente."), + ("rel-mouse-lock-failed-tip", "Falha ao bloquear o cursor. O Modo de Mouse Relativo foi desabilitado."), + ("rel-mouse-exit-{}-tip", "Pressione {} para sair."), + ("rel-mouse-permission-lost-tip", "Permissão de teclado revogada. O Modo Mouse Relativo foi desabilitado."), + ("Changelog", "Registro de alterações"), + ("keep-awake-during-outgoing-sessions-label", "Manter tela ativa durante sessões de saída"), + ("keep-awake-during-incoming-sessions-label", "Manter tela ativa durante sessões de entrada"), + ("Continue with {}", "Continuar com {}"), + ("Display Name", "Nome de Exibição"), + ("password-hidden-tip", "A senha permanente está definida como (oculta)."), + ("preset-password-in-use-tip", "A senha predefinida está sendo usada."), + ("Enable privacy mode", "Habilitar modo de privacidade"), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ro.rs b/src/lang/ro.rs index cbce2f2a9..bde4a4201 100644 --- a/src/lang/ro.rs +++ b/src/lang/ro.rs @@ -41,7 +41,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("length %min% to %max%", "lungime între %min% și %max%"), ("starts with a letter", "începe cu o literă"), ("allowed characters", "caractere permise"), - ("id_change_tip", "Pot fi utilizate doar caractere a-z, A-Z, 0-9, _ (bară jos). Primul caracter trebuie să fie a-z, A-Z. Lungimea trebuie să fie între 6 și 16 caractere."), + ("id_change_tip", "Pot fi utilizate doar caractere a-z, A-Z, 0-9, - (dash), _ (bară jos). Primul caracter trebuie să fie a-z, A-Z. Lungimea trebuie să fie între 6 și 16 caractere."), ("Website", "Site web"), ("About", "Despre"), ("Slogan_tip", "Făcut din inimă în lumea aceasta haotică!"), @@ -62,7 +62,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Invalid format", "Format nevalid"), ("server_not_support", "Încă nu este compatibil cu serverul"), ("Not available", "Indisponibil"), - ("Too frequent", "Modificat prea frecvent"), + ("Too frequent", "Prea frecvent"), ("Cancel", "Anulează"), ("Skip", "Omite"), ("Close", "Închide"), @@ -87,7 +87,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Modified", "Modificat"), ("Size", "Dimensiune"), ("Show Hidden Files", "Afișează fișiere ascunse"), - ("Receive", "Acceptă"), + ("Receive", "Primește"), ("Send", "Trimite"), ("Refresh File", "Actualizează fișier"), ("Local", "Local"), @@ -108,7 +108,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Do this for all conflicts", "Aplică la toate conflictele"), ("This is irreversible!", "Această acțiune este ireversibilă!"), ("Deleting", "În curs de ștergere..."), - ("files", "fișier"), + ("files", "fișiere"), ("Waiting", "În așteptare..."), ("Finished", "Finalizat"), ("Speed", "Viteză"), @@ -147,8 +147,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("OS Password", "Parolă sistem"), ("install_tip", "Din cauza restricțiilor CCU, este posibil ca RustDesk să nu funcționeze corespunzător. Pentru a evita acest lucru, dă clic pe butonul de mai jos pentru a instala RustDesk."), ("Click to upgrade", "Dă clic pentru a face upgrade"), - ("Click to download", "Dă clic pentru a descărca"), - ("Click to update", "Dă clic pentru a actualiza"), ("Configure", "Configurează"), ("config_acc", "Pentru a controla desktopul la distanță, trebuie să permiți RustDesk acces la setările de Accesibilitate."), ("config_screen", "Pentru a controla desktopul la distanță, trebuie să permiți RustDesk acces la setările de Înregistrare ecran."), @@ -205,7 +203,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("x11 expected", "Este necesar X11"), ("Port", "Port"), ("Settings", "Setări"), - ("Username", " Nume utilizator"), + ("Username", "Nume utilizator"), ("Invalid port", "Port nevalid"), ("Closed manually by the peer", "Conexiune închisă manual de dispozitivul pereche"), ("Enable remote configuration modification", "Activează modificarea configurației de la distanță"), @@ -218,7 +216,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Remember me", "Reține-mă"), ("Trust this device", "Acest dispozitiv este de încredere"), ("Verification code", "Cod de verificare"), - ("verification_tip", ""), + ("verification_tip", "Introdu codul de verificare trimis la adresa ta de e-mail sau generat de aplicația de autentificare."), ("Logout", "Deconectează-te"), ("Tags", "Etichete"), ("Search ID", "Caută după ID"), @@ -230,9 +228,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Username missed", "Lipsește numele de utilizator"), ("Password missed", "Lipsește parola"), ("Wrong credentials", "Nume sau parolă greșită"), - ("The verification code is incorrect or has expired", ""), + ("The verification code is incorrect or has expired", "Codul de verificare este incorect sau a expirat"), ("Edit Tag", "Modifică etichetă"), - ("Forget Password", "Uită parola"), + ("Forget Password", "Parolă uitată"), ("Favorites", "Favorite"), ("Add to Favorites", "Adaugă la Favorite"), ("Remove from Favorites", "Șterge din Favorite"), @@ -265,9 +263,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Canvas Zoom", "Mărire ecran"), ("Reset canvas", "Reinițializează ecranul"), ("No permission of file transfer", "Nicio permisiune pentru transferul de fișiere"), - ("Note", "Reține"), + ("Note", "Notă"), ("Connection", "Conexiune"), - ("Share Screen", "Partajează ecran"), + ("Share screen", "Partajează ecran"), ("Chat", "Mesaje"), ("Total", "Total"), ("items", "elemente"), @@ -275,19 +273,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Screen Capture", "Capturare ecran"), ("Input Control", "Control intrări"), ("Audio Capture", "Capturare audio"), - ("File Connection", "Conexiune fișier"), - ("Screen Connection", "Conexiune ecran"), ("Do you accept?", "Accepți?"), ("Open System Setting", "Deschide setări sistem"), ("How to get Android input permission?", "Cum autorizez dispozitive de intrare pe Android?"), - ("android_input_permission_tip1", "Pentru ca un dispozitiv la distanță să poată controla un dispozitiv Android folosind mouse-ul sau suportul tactil, trebuie să permiți RustDesk să utilize serviciul „Accesibilitate”."), + ("android_input_permission_tip1", "Pentru ca un dispozitiv la distanță să poată controla un dispozitiv Android folosind mouse-ul sau suportul tactil, trebuie să permiți RustDesk să utilizeze serviciul „Accesibilitate\"."), ("android_input_permission_tip2", "Accesează următoarea pagină din Setări, deschide [Aplicații instalate] și pornește serviciul [RustDesk Input]."), ("android_new_connection_tip", "Ai primit o nouă solicitare de controlare a dispozitivului actual."), ("android_service_will_start_tip", "Activarea setării de capturare a ecranului va porni automat serviciul, permițând altor dispozitive să solicite conectarea la dispozitivul tău."), ("android_stop_service_tip", "Închiderea serviciului va închide automat toate conexiunile stabilite."), ("android_version_audio_tip", "Versiunea actuală de Android nu suportă captura audio. Fă upgrade la Android 10 sau la o versiune superioară."), ("android_start_service_tip", "Apasă [Pornește serviciu] sau DESCHIDE [Capturare ecran] pentru a porni serviciul de partajare a ecranului."), - ("android_permission_may_not_change_tip", ""), + ("android_permission_may_not_change_tip", "Este posibil ca unele permisiuni să nu poată fi modificate în funcție de versiunea de Android."), ("Account", "Cont"), ("Overwrite", "Suprascrie"), ("This file exists, skip or overwrite this file?", "Fișier deja existent. Omite sau suprascrie?"), @@ -308,15 +304,15 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("android_open_battery_optimizations_tip", "Pentru dezactivarea acestei funcții, accesează setările aplicației RustDesk, deschide secțiunea [Baterie] și deselectează [Fără restricții]."), ("Start on boot", "Pornește la boot"), ("Start the screen sharing service on boot, requires special permissions", "Pornește serviciul de partajare a ecranului la boot; necesită permisiuni speciale"), - ("Connection not allowed", "Conexiune neautoriztă"), + ("Connection not allowed", "Conexiune neautorizată"), ("Legacy mode", "Mod legacy"), ("Map mode", "Mod hartă"), ("Translate mode", "Mod traducere"), ("Use permanent password", "Folosește parola permanentă"), - ("Use both passwords", "Folosește ambele programe"), + ("Use both passwords", "Folosește ambele parole"), ("Set permanent password", "Setează parola permanentă"), ("Enable remote restart", "Activează repornirea la distanță"), - ("Restart remote device", "Repornește dispozivul la distanță"), + ("Restart remote device", "Repornește dispozitivul la distanță"), ("Are you sure you want to restart", "Sigur vrei să repornești dispozitivul?"), ("Restarting remote device", "Se repornește dispozitivul la distanță"), ("remote_restarting_tip", "Dispozitivul este în curs de repornire. Închide acest mesaj și reconectează-te cu parola permanentă după un timp."), @@ -363,8 +359,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Unpin Toolbar", "Detașează bara de instrumente"), ("Recording", "Înregistrare"), ("Directory", "Director"), - ("Automatically record incoming sessions", "Înregistrează automat sesiunile viitoare"), - ("Automatically record outgoing sessions", ""), + ("Automatically record incoming sessions", "Înregistrează automat sesiunile primite"), + ("Automatically record outgoing sessions", "Înregistrează automat sesiunile de ieșire"), ("Change", "Modifică"), ("Start session recording", "Începe înregistrarea"), ("Stop session recording", "Oprește înregistrarea"), @@ -381,14 +377,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Keyboard Settings", "Setări tastatură"), ("Full Access", "Acces total"), ("Screen Share", "Partajare ecran"), - ("Wayland requires Ubuntu 21.04 or higher version.", "Wayland necesită Ubuntu 21.04 sau o versiune superioară."), - ("Wayland requires higher version of linux distro. Please try X11 desktop or change your OS.", "Wayland necesită o versiune superioară a distribuției Linux. Încearcă desktopul X11 sau schimbă sistemul de operare."), + ("ubuntu-21-04-required", "Wayland necesită Ubuntu 21.04 sau o versiune superioară."), + ("wayland-requires-higher-linux-version", "Wayland necesită o versiune superioară a distribuției Linux. Încearcă desktopul X11 sau schimbă sistemul de operare."), + ("xdp-portal-unavailable", "Portalul XDG Desktop nu este disponibil. Asigură-te că rulezi o sesiune Wayland cu suport pentru portal."), ("JumpLink", "Afișează"), ("Please Select the screen to be shared(Operate on the peer side).", "Partajează ecranul care urmează să fie partajat (operează din partea dispozitivului pereche)."), ("Show RustDesk", "Afișează RustDesk"), ("This PC", "Acest PC"), ("or", "sau"), - ("Continue with", "Continuă cu"), ("Elevate", "Sporește privilegii"), ("Zoom cursor", "Cursor lupă"), ("Accept sessions via password", "Acceptă începerea sesiunii folosind parola"), @@ -440,13 +436,13 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Default Image Quality", "Calitatea implicită a imaginii"), ("Default Codec", "Codec implicit"), ("Bitrate", "Rată de biți"), - ("FPS", "CPS"), + ("FPS", "FPS"), ("Auto", "Auto"), ("Other Default Options", "Alte opțiuni implicite"), ("Voice call", "Apel vocal"), ("Text chat", "Conversație text"), ("Stop voice call", "Încheie apel vocal"), - ("relay_hint_tip", "Este posibil să nu te poți conecta direct; poți încerca să te conectezi prin retransmisie. De asemenea, dacă dorești să te conectezi direct prin retransmisie, poți adăuga sufixul „/r” la ID sau să bifezi opțiunea Conectează-te mereu prin retransmisie."), + ("relay_hint_tip", "Este posibil să nu te poți conecta direct; poți încerca să te conectezi prin retransmisie. De asemenea, dacă dorești să te conectezi direct prin retransmisie, poți adăuga sufixul „/r\" la ID sau să bifezi opțiunea Conectează-te mereu prin retransmisie."), ("Reconnect", "Reconectează-te"), ("Codec", "Codec"), ("Resolution", "Rezoluție"), @@ -463,7 +459,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("empty_favorite_tip", "Încă nu ai niciun dispozitiv pereche favorit?\nHai să-ți găsim pe cineva cu care să te conectezi, iar apoi poți adăuga dispozitivul la Favorite!"), ("empty_lan_tip", "Of! S-ar părea că încă nu am descoperit niciun dispozitiv."), ("empty_address_book_tip", "Măi să fie! Se pare că deocamdată nu figurează niciun dispozitiv în agenda ta."), - ("eg: admin", "ex: admin"), ("Empty Username", "Nume utilizator nespecificat"), ("Empty Password", "Parolă nespecificată"), ("Me", "Eu"), @@ -508,150 +503,247 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Exit", "Ieși"), ("Open", "Deschide"), ("logout_tip", "Sigur vrei să te deconectezi?"), - ("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", ""), - ("Installation Successful!", ""), - ("Installation failed!", ""), - ("Reverse mouse wheel", ""), - ("{} sessions", ""), - ("scam_title", ""), - ("scam_text1", ""), - ("scam_text2", ""), - ("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)", ""), - ("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", ""), - ("swap-left-right-mouse", ""), - ("2FA code", ""), - ("More", ""), - ("enable-2fa-title", ""), - ("enable-2fa-desc", ""), - ("wrong-2fa-code", ""), - ("enter-2fa-title", ""), - ("Email verification code must be 6 characters.", ""), - ("2FA code must be 6 digits.", ""), - ("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", ""), - ("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", ""), - ("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", ""), - ("Requires at least {} characters", ""), - ("Wrong PIN", ""), - ("Set 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", ""), + ("Service", "Serviciu"), + ("Start", "Pornește"), + ("Stop", "Oprește"), + ("exceed_max_devices", "Numărul maxim de dispozitive a fost depășit"), + ("Sync with recent sessions", "Sincronizează cu sesiunile recente"), + ("Sort tags", "Sortează etichete"), + ("Open connection in new tab", "Deschide conexiunea într-o filă nouă"), + ("Move tab to new window", "Mută fila într-o fereastră nouă"), + ("Can not be empty", "Nu poate fi gol"), + ("Already exists", "Există deja"), + ("Change Password", "Schimbă parola"), + ("Refresh Password", "Reîmprospătează parola"), + ("ID", "ID"), + ("Grid View", "Vizualizare grilă"), + ("List View", "Vizualizare listă"), + ("Select", "Selectează"), + ("Toggle Tags", "Comută etichete"), + ("pull_ab_failed_tip", "Sincronizarea agendei a eșuat. Verifică conexiunea la rețea sau autentifică-te din nou."), + ("push_ab_failed_tip", "Salvarea agendei pe server a eșuat. Verifică conexiunea la rețea sau autentifică-te din nou."), + ("synced_peer_readded_tip", "Dispozitivele pereche eliminate au fost re-adăugate automat din sesiunile recente."), + ("Change Color", "Schimbă culoarea"), + ("Primary Color", "Culoare principală"), + ("HSV Color", "Culoare HSV"), + ("Installation Successful!", "Instalare reușită!"), + ("Installation failed!", "Instalare eșuată!"), + ("Reverse mouse wheel", "Inversează rotiță mouse"), + ("{} sessions", "{} sesiuni"), + ("scam_title", "Avertisment de securitate"), + ("scam_text1", "Escrocii se pot da drept angajați ai asistenței tehnice și îți pot solicita să instalezi sau să rulezi RustDesk pentru a-ți accesa dispozitivul."), + ("scam_text2", "Dacă nu ai contactat tu primul asistența tehnică, te rugăm să închizi această aplicație imediat."), + ("Don't show again", "Nu mai afișa"), + ("I Agree", "Sunt de acord"), + ("Decline", "Refuză"), + ("Timeout in minutes", "Timp de expirare în minute"), + ("auto_disconnect_option_tip", "Deconectează automat sesiunile de la distanță după o perioadă de inactivitate."), + ("Connection failed due to inactivity", "Conexiunea a eșuat din cauza inactivității"), + ("Check for software update on startup", "Verifică actualizări la pornire"), + ("upgrade_rustdesk_server_pro_to_{}_tip", "Versiunea serverului RustDesk Pro este mai mică decât {}. Te rugăm să o actualizezi."), + ("pull_group_failed_tip", "Sincronizarea grupului a eșuat. Verifică conexiunea la rețea sau autentifică-te din nou."), + ("Filter by intersection", "Filtrează prin intersecție"), + ("Remove wallpaper during incoming sessions", "Elimină imaginea de fundal în timpul sesiunilor primite"), + ("Test", "Test"), + ("display_is_plugged_out_msg", "Monitorul selectat a fost deconectat. Sesiunea continuă pe monitorul disponibil."), + ("No displays", "Niciun monitor"), + ("Open in new window", "Deschide în fereastră nouă"), + ("Show displays as individual windows", "Afișează monitoarele ca ferestre individuale"), + ("Use all my displays for the remote session", "Folosește toate monitoarele mele pentru sesiunea la distanță"), + ("selinux_tip", "SELinux este activat pe acest sistem. Este posibil ca unele funcții să nu funcționeze corect. Te rugăm să consulți documentația pentru instrucțiuni de configurare."), + ("Change view", "Schimbă vizualizarea"), + ("Big tiles", "Dale mari"), + ("Small tiles", "Dale mici"), + ("List", "Listă"), + ("Virtual display", "Monitor virtual"), + ("Plug out all", "Deconectează toate"), + ("True color (4:4:4)", "Culori reale (4:4:4)"), + ("Enable blocking user input", "Activează blocarea intrărilor utilizatorului"), + ("id_input_tip", "Introdu ID-ul sau adresa IP a dispozitivului la distanță"), + ("privacy_mode_impl_mag_tip", "Modul privat prin Magnificare — nu este suportat pe toate sistemele"), + ("privacy_mode_impl_virtual_display_tip", "Modul privat prin monitor virtual — necesită driverul de monitor virtual"), + ("Enter privacy mode", "Intră în modul privat"), + ("Exit privacy mode", "Ieși din modul privat"), + ("idd_not_support_under_win10_2004_tip", "Driverul de monitor virtual nu este suportat pe versiuni de Windows anterioare versiunii 2004 (build 19041)."), + ("input_source_1_tip", "Sursă de intrare 1 — folosește metodele standard de simulare a tastaturii și mouse-ului"), + ("input_source_2_tip", "Sursă de intrare 2 — folosește driver-ul RustDesk pentru simulare la nivel de kernel"), + ("Swap control-command key", "Schimbă tastele Control și Command"), + ("swap-left-right-mouse", "Schimbă butoanele stâng și drept ale mouse-ului"), + ("2FA code", "Cod 2FA"), + ("More", "Mai mult"), + ("enable-2fa-title", "Activează autentificarea în doi pași (2FA)"), + ("enable-2fa-desc", "Scanează codul QR cu o aplicație de autentificare (de ex. Google Authenticator) și introdu codul generat pentru a confirma activarea."), + ("wrong-2fa-code", "Cod 2FA incorect"), + ("enter-2fa-title", "Introdu codul de autentificare în doi pași"), + ("Email verification code must be 6 characters.", "Codul de verificare prin e-mail trebuie să aibă 6 caractere."), + ("2FA code must be 6 digits.", "Codul 2FA trebuie să conțină 6 cifre."), + ("Multiple Windows sessions found", "Au fost găsite mai multe sesiuni Windows"), + ("Please select the session you want to connect to", "Selectează sesiunea la care vrei să te conectezi"), + ("powered_by_me", "Realizat cu RustDesk"), + ("outgoing_only_desk_tip", "Acest dispozitiv este configurat doar pentru conexiuni de ieșire și nu acceptă conexiuni de intrare."), + ("preset_password_warning", "Parola prestabilită nu este recomandată din motive de securitate. Te rugăm să o schimbi cât mai curând posibil."), + ("Security Alert", "Alertă de securitate"), + ("My address book", "Agenda mea"), + ("Personal", "Personal"), + ("Owner", "Proprietar"), + ("Set shared password", "Setează parola partajată"), + ("Exist in", "Există în"), + ("Read-only", "Doar citire"), + ("Read/Write", "Citire/Scriere"), + ("Full Control", "Control total"), + ("share_warning_tip", "Datele partajate vor fi vizibile pentru toți membrii grupului selectat. Asigură-te că partajezi doar informații adecvate."), + ("Everyone", "Toată lumea"), + ("ab_web_console_tip", "Gestionează agenda prin consola web RustDesk Pro."), + ("allow-only-conn-window-open-tip", "Permite conexiunile numai atunci când fereastra de gestionare a conexiunilor este deschisă"), + ("no_need_privacy_mode_no_physical_displays_tip", "Modul privat nu este necesar deoarece nu există monitoare fizice conectate."), + ("Follow remote cursor", "Urmărește cursorul de la distanță"), + ("Follow remote window focus", "Urmărește fereastra activă de la distanță"), + ("default_proxy_tip", "Proxy-ul implicit este utilizat pentru toate conexiunile dacă nu este specificat altul."), + ("no_audio_input_device_tip", "Nu a fost găsit niciun dispozitiv de intrare audio. Conectează un microfon și reîncearcă."), + ("Incoming", "Intrare"), + ("Outgoing", "Ieșire"), + ("Clear Wayland screen selection", "Șterge selecția de ecran Wayland"), + ("clear_Wayland_screen_selection_tip", "Șterge selecția de ecran Wayland salvată, astfel încât să poți alege un alt ecran la următoarea conexiune."), + ("confirm_clear_Wayland_screen_selection_tip", "Sigur vrei să ștergi selecția de ecran Wayland?"), + ("android_new_voice_call_tip", "Ai primit un nou apel vocal. Apasă pentru a accepta sau respinge."), + ("texture_render_tip", "Randarea prin textură poate îmbunătăți performanța grafică pe unele dispozitive. Repornește aplicația dacă apar probleme de afișare."), + ("Use texture rendering", "Folosește randarea prin textură"), + ("Floating window", "Fereastră flotantă"), + ("floating_window_tip", "Fereastra flotantă ajută la menținerea serviciului de partajare a ecranului activ în fundal pe Android."), + ("Keep screen on", "Menține ecranul pornit"), + ("Never", "Niciodată"), + ("During controlled", "În timpul controlului"), + ("During service is on", "Cât timp serviciul este activ"), + ("Capture screen using DirectX", "Capturează ecranul folosind DirectX"), + ("Back", "Înapoi"), + ("Apps", "Aplicații"), + ("Volume up", "Mărește volumul"), + ("Volume down", "Micșorează volumul"), + ("Power", "Alimentare"), + ("Telegram bot", "Bot Telegram"), + ("enable-bot-tip", "Activează botul Telegram pentru a primi notificări și a gestiona conexiunile."), + ("enable-bot-desc", "Configurează un bot Telegram pentru notificări RustDesk. Introdu token-ul botului și ID-ul chat-ului."), + ("cancel-2fa-confirm-tip", "Sigur vrei să dezactivezi autentificarea în doi pași? Aceasta va reduce securitatea contului tău."), + ("cancel-bot-confirm-tip", "Sigur vrei să dezactivezi botul Telegram?"), + ("About RustDesk", "Despre RustDesk"), + ("Send clipboard keystrokes", "Trimite conținutul clipboard-ului ca apăsări de taste"), + ("network_error_tip", "Eroare de rețea. Verifică conexiunea la internet și încearcă din nou."), + ("Unlock with PIN", "Deblochează cu PIN"), + ("Requires at least {} characters", "Necesită cel puțin {} caractere"), + ("Wrong PIN", "PIN incorect"), + ("Set PIN", "Setează PIN"), + ("Enable trusted devices", "Activează dispozitive de încredere"), + ("Manage trusted devices", "Gestionează dispozitivele de încredere"), + ("Platform", "Platformă"), + ("Days remaining", "Zile rămase"), + ("enable-trusted-devices-tip", "Dispozitivele de încredere pot accesa contul fără verificare suplimentară."), + ("Parent directory", "Director părinte"), + ("Resume", "Reia"), + ("Invalid file name", "Nume de fișier nevalid"), + ("one-way-file-transfer-tip", "Transferul de fișiere în sens unic permite doar trimiterea sau primirea de fișiere, nu ambele direcții simultan."), + ("Authentication Required", "Autentificare necesară"), + ("Authenticate", "Autentifică-te"), + ("web_id_input_tip", "Introdu ID-ul RustDesk al dispozitivului la care vrei să te conectezi"), + ("Download", "Descarcă"), + ("Upload folder", "Încarcă folder"), + ("Upload files", "Încarcă fișiere"), + ("Clipboard is synchronized", "Clipboard-ul este sincronizat"), + ("Update client clipboard", "Actualizează clipboard-ul clientului"), + ("Untagged", "Neetichetat"), + ("new-version-of-{}-tip", "Este disponibilă o nouă versiune a {}. Fă clic pentru a actualiza."), + ("Accessible devices", "Dispozitive accesibile"), + ("upgrade_remote_rustdesk_client_to_{}_tip", "Versiunea clientului RustDesk de la distanță este mai mică decât {}. Te rugăm să o actualizezi pentru o compatibilitate completă."), + ("d3d_render_tip", "Randarea Direct3D poate îmbunătăți performanța pe sistemele Windows cu suport hardware adecvat."), + ("Use D3D rendering", "Folosește randarea D3D"), + ("Printer", "Imprimantă"), + ("printer-os-requirement-tip", "Imprimarea la distanță necesită Windows 10 sau o versiune superioară."), + ("printer-requires-installed-{}-client-tip", "Imprimarea la distanță necesită instalarea clientului {} pe dispozitivul local."), + ("printer-{}-not-installed-tip", "Imprimanta {} nu este instalată. Instalează driverul imprimantei pentru a continua."), + ("printer-{}-ready-tip", "Imprimanta {} este pregătită pentru utilizare."), + ("Install {} Printer", "Instalează imprimanta {}"), + ("Outgoing Print Jobs", "Lucrări de imprimare de ieșire"), + ("Incoming Print Jobs", "Lucrări de imprimare de intrare"), + ("Incoming Print Job", "Lucrare de imprimare de intrare"), + ("use-the-default-printer-tip", "Folosește imprimanta implicită a sistemului pentru lucrările de imprimare primite."), + ("use-the-selected-printer-tip", "Folosește imprimanta selectată pentru lucrările de imprimare primite."), + ("auto-print-tip", "Imprimă automat lucrările primite fără confirmare."), + ("print-incoming-job-confirm-tip", "Ai primit o lucrare de imprimare. Vrei să o imprimești?"), + ("remote-printing-disallowed-tile-tip", "Imprimare la distanță nepermisă"), + ("remote-printing-disallowed-text-tip", "Dispozitivul la distanță nu permite imprimarea. Contactează administratorul pentru a activa această funcție."), + ("save-settings-tip", "Salvează setările curente ca implicite pentru sesiunile viitoare."), + ("dont-show-again-tip", "Nu mai afișa acest mesaj"), + ("Take screenshot", "Fă captură de ecran"), + ("Taking screenshot", "Se face captura de ecran..."), + ("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"), + ("Copy to clipboard", "Copiază în clipboard"), + ("Enable remote printer", "Activează imprimanta la distanță"), + ("Downloading {}", "Se descarcă {}"), + ("{} Update", "Actualizare {}"), + ("{}-to-update-tip", "Este disponibilă o actualizare pentru {}. Fă clic pentru a descărca și instala."), + ("download-new-version-failed-tip", "Descărcarea noii versiuni a eșuat. Verifică conexiunea la internet și încearcă din nou."), + ("Auto update", "Actualizare automată"), + ("update-failed-check-msi-tip", "Actualizarea a eșuat. Încearcă să descarci și să instalezi manual fișierul MSI."), + ("websocket_tip", "WebSocket oferă o conexiune mai stabilă în unele medii de rețea restrictive."), + ("Use WebSocket", "Folosește WebSocket"), + ("Trackpad speed", "Viteza touchpad-ului"), + ("Default trackpad speed", "Viteza implicită a touchpad-ului"), + ("Numeric one-time password", "Parolă unică numerică"), + ("Enable IPv6 P2P connection", "Activează conexiunea P2P prin IPv6"), + ("Enable UDP hole punching", "Activează traversarea UDP (hole punching)"), + ("View camera", "Vezi camera"), + ("Enable camera", "Activează camera"), + ("No cameras", "Nicio cameră disponibilă"), + ("view_camera_unsupported_tip", "Vizualizarea camerei nu este suportată pe dispozitivul la distanță."), + ("Terminal", "Terminal"), + ("Enable terminal", "Activează terminalul"), + ("New tab", "Filă nouă"), + ("Keep terminal sessions on disconnect", "Păstrează sesiunile de terminal la deconectare"), + ("Terminal (Run as administrator)", "Terminal (Rulează ca administrator)"), + ("terminal-admin-login-tip", "Introdu datele de autentificare ale administratorului pentru a rula terminalul cu privilegii sporite."), + ("Failed to get user token.", "Obținerea tokenului de utilizator a eșuat."), + ("Incorrect username or password.", "Nume de utilizator sau parolă incorectă."), + ("The user is not an administrator.", "Utilizatorul nu este administrator."), + ("Failed to check if the user is an administrator.", "Verificarea privilegiilor de administrator a eșuat."), + ("Supported only in the installed version.", "Suportat doar în versiunea instalată."), + ("elevation_username_tip", "Introdu numele de utilizator al contului de administrator pentru a solicita sporirea privilegiilor."), + ("Preparing for installation ...", "Se pregătește instalarea..."), + ("Show my cursor", "Afișează cursorul meu"), + ("Scale custom", "Scalare personalizată"), + ("Custom scale slider", "Glisor pentru scalare personalizată"), + ("Decrease", "Micșorează"), + ("Increase", "Mărește"), + ("Show virtual mouse", "Afișează mouse virtual"), + ("Virtual mouse size", "Dimensiunea mouse-ului virtual"), + ("Small", "Mic"), + ("Large", "Mare"), + ("Show virtual joystick", "Afișează joystick virtual"), + ("Edit note", "Editează notă"), + ("Alias", "Alias"), + ("ScrollEdge", "Derulare la margine"), + ("Allow insecure TLS fallback", "Permite revenirea la TLS nesecurizat"), + ("allow-insecure-tls-fallback-tip", "Permite conexiunile cu certificate TLS nevalide sau expirate. Nu este recomandat din motive de securitate."), + ("Disable UDP", "Dezactivează UDP"), + ("disable-udp-tip", "Dezactivează conexiunile UDP și folosește doar TCP. Poate reduce performanța conexiunii."), + ("server-oss-not-support-tip", "Serverul open-source nu suportă această funcție. Folosește RustDesk Pro pentru funcționalitate completă."), + ("input note here", "Introdu o notă aici"), + ("note-at-conn-end-tip", "Afișează această notă la sfârșitul sesiunii de conexiune."), + ("Show terminal extra keys", "Afișează taste suplimentare pentru terminal"), + ("Relative mouse mode", "Mod mouse relativ"), + ("rel-mouse-not-supported-peer-tip", "Dispozitivul pereche nu suportă modul mouse relativ."), + ("rel-mouse-not-ready-tip", "Modul mouse relativ nu este pregătit. Încearcă din nou."), + ("rel-mouse-lock-failed-tip", "Blocarea mouse-ului în modul relativ a eșuat."), + ("rel-mouse-exit-{}-tip", "Apasă {} pentru a ieși din modul mouse relativ."), + ("rel-mouse-permission-lost-tip", "Permisiunea pentru modul mouse relativ a fost pierdută."), + ("Changelog", "Jurnal de modificări"), + ("keep-awake-during-outgoing-sessions-label", "Menține ecranul activ în timpul sesiunilor de ieșire"), + ("keep-awake-during-incoming-sessions-label", "Menține ecranul activ în timpul sesiunilor de intrare"), + ("Continue with {}", "Continuă cu {}"), + ("Display Name", "Nume afișat"), + ("password-hidden-tip", "Parola este ascunsă din motive de securitate. Fă clic pe pictograma ochiului pentru a o afișa."), + ("preset-password-in-use-tip", "Se folosește o parolă prestabilită. Se recomandă setarea unei parole personalizate pentru securitate sporită."), + ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ru.rs b/src/lang/ru.rs index 6d173f109..2605582f4 100644 --- a/src/lang/ru.rs +++ b/src/lang/ru.rs @@ -13,7 +13,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Service is running", "Служба запущена"), ("Service is not running", "Служба не запущена"), ("not_ready_status", "Не подключено. Проверьте соединение."), - ("Control Remote Desktop", "Управление удалённым рабочим столом"), + ("Control Remote Desktop", "Новое соединение"), ("Transfer file", "Передать файлы"), ("Connect", "Подключиться"), ("Recent sessions", "Последние сеансы"), @@ -41,7 +41,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("length %min% to %max%", "длина %min%...%max%"), ("starts with a letter", "начинается с буквы"), ("allowed characters", "допустимые символы"), - ("id_change_tip", "Допускаются только символы a-z, A-Z, 0-9 и _ (подчёркивание). Первой должна быть буква a-z, A-Z. Длина от 6 до 16."), + ("id_change_tip", "Допускаются только символы a-z, A-Z, 0-9, - (dash) и _ (подчёркивание). Первой должна быть буква a-z, A-Z. Длина от 6 до 16."), ("Website", "Сайт"), ("About", "О приложении"), ("Slogan_tip", "Сделано с душой в этом безумном мире!"), @@ -147,8 +147,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("OS Password", "Пароль входа в ОС"), ("install_tip", "В некоторых случаях из-за UAC RustDesk может работать неправильно на удалённом узле. Чтобы избежать возможных проблем с UAC, нажмите кнопку ниже для установки RustDesk в системе."), ("Click to upgrade", "Нажмите, чтобы обновить"), - ("Click to download", "Нажмите, чтобы скачать"), - ("Click to update", "Нажмите, чтобы обновить"), ("Configure", "Настроить"), ("config_acc", "Чтобы удалённо управлять своим рабочим столом, вы должны предоставить RustDesk права \"доступа\""), ("config_screen", "Для удалённого доступа к рабочему столу вы должны предоставить RustDesk права \"снимок экрана\""), @@ -267,7 +265,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("No permission of file transfer", "Нет разрешения на передачу файлов"), ("Note", "Заметка"), ("Connection", "Подключение"), - ("Share Screen", "Демонстрация экрана"), + ("Share screen", "Демонстрация экрана"), ("Chat", "Чат"), ("Total", "Всего"), ("items", "элементы"), @@ -275,8 +273,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Screen Capture", "Захват экрана"), ("Input Control", "Управление вводом"), ("Audio Capture", "Захват аудио"), - ("File Connection", "Подключение передачи файлов"), - ("Screen Connection", "Подключение просмотра/управления экраном"), ("Do you accept?", "Вы согласны?"), ("Open System Setting", "Открыть настройки системы"), ("How to get Android input permission?", "Как получить разрешение на ввод Android?"), @@ -299,7 +295,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Unsupported", "Не поддерживается"), ("Peer denied", "Отклонено удалённым узлом"), ("Please install plugins", "Установите плагины"), - ("Peer exit", "Удалённый узел отключён"), + ("Peer exit", "Отключено пользователем"), ("Failed to turn off", "Невозможно отключить"), ("Turned off", "Отключён"), ("Language", "Язык"), @@ -381,14 +377,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Keyboard Settings", "Настройки клавиатуры"), ("Full Access", "Полный доступ"), ("Screen Share", "Демонстрация экрана"), - ("Wayland requires Ubuntu 21.04 or higher version.", "Wayland требуется Ubuntu версии 21.04 или новее."), - ("Wayland requires higher version of linux distro. Please try X11 desktop or change your OS.", "Для Wayland требуется более поздняя версия дистрибутива Linux. Используйте рабочий стол X11 или смените ОС."), + ("ubuntu-21-04-required", "Wayland требуется Ubuntu версии 21.04 или новее."), + ("wayland-requires-higher-linux-version", "Для Wayland требуется более поздняя версия дистрибутива Linux. Используйте рабочий стол X11 или смените ОС."), + ("xdp-portal-unavailable", "Невозможно сделать снимок экрана Wayland. Возможно, в XDG Desktop Portal сбой или он недоступен. Попробуйте перезапустить его с помощью `systemctl --user restart xdg-desktop-portal`."), ("JumpLink", "Просмотр"), ("Please Select the screen to be shared(Operate on the peer side).", "Выберите экран для демонстрации (работайте на одноранговой стороне)."), ("Show RustDesk", "Показать RustDesk"), ("This PC", "Этот компьютер"), ("or", "или"), - ("Continue with", "Продолжить с"), ("Elevate", "Повысить"), ("Zoom cursor", "Масштабировать курсор"), ("Accept sessions via password", "Принимать сеансы по паролю"), @@ -463,7 +459,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("empty_favorite_tip", "Ещё нет избранных удалённых узлов?\nДавайте найдём, кого можно добавить в избранное!"), ("empty_lan_tip", "Не найдено удалённых узлов."), ("empty_address_book_tip", "В адресной книге нет удалённых узлов."), - ("eg: admin", "например: admin"), ("Empty Username", "Пустое имя пользователя"), ("Empty Password", "Пустой пароль"), ("Me", "Я"), @@ -567,8 +562,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("id_input_tip", "Можно ввести идентификатор, прямой IP-адрес или домен с портом (<домен>:<порт>).\nЕсли необходимо получить доступ к устройству на другом сервере, добавьте адрес сервера (@<адрес_сервера>?key=<ключ_значение>), например:\n9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=.\nЕсли необходимо получить доступ к устройству на общедоступном сервере, введите \"@public\", ключ для публичного сервера не требуется."), ("privacy_mode_impl_mag_tip", "Режим 1"), ("privacy_mode_impl_virtual_display_tip", "Режим 2"), - ("Enter privacy mode", "Включить режим конфиденциальности"), - ("Exit privacy mode", "Отключить режим конфиденциальности"), + ("Enter privacy mode", "Режим конфиденциальности включён"), + ("Exit privacy mode", "Режим конфиденциальности отключён"), ("idd_not_support_under_win10_2004_tip", "Драйвер непрямого отображения не поддерживается. Требуется Windows 10 версии 2004 или новее."), ("input_source_1_tip", "Источник ввода 1"), ("input_source_2_tip", "Источник ввода 2"), @@ -631,7 +626,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("cancel-2fa-confirm-tip", "Отключить двухфакторную аутентификацию?"), ("cancel-bot-confirm-tip", "Отключить Telegram-бота?"), ("About RustDesk", "О RustDesk"), - ("Send clipboard keystrokes", "Отправлять нажатия клавиш из буфера обмена"), + ("Send clipboard keystrokes", "Отправлять нажатия клавиш в буфер обмена"), ("network_error_tip", "Проверьте подключение к сети, затем нажмите \"Повтор\"."), ("Unlock with PIN", "Разблокировать PIN-кодом"), ("Requires at least {} characters", "Требуется не менее {} символов"), @@ -653,5 +648,102 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Upload folder", "Загрузить папку"), ("Upload files", "Загрузить файлы"), ("Clipboard is synchronized", "Буфер обмена синхронизирован"), + ("Update client clipboard", "Обновить буфер обмена клиента"), + ("Untagged", "Без метки"), + ("new-version-of-{}-tip", "Доступна новая версия {}"), + ("Accessible devices", "Доступные устройства"), + ("upgrade_remote_rustdesk_client_to_{}_tip", "Обновите клиент RustDesk до версии {} или новее на удалённой стороне!"), + ("d3d_render_tip", "При включении визуализации D3D на некоторых устройствах удалённый экран может быть чёрным."), + ("Use D3D rendering", "Использовать визуализацию D3D"), + ("Printer", "Принтер"), + ("printer-os-requirement-tip", "Для работы функции исходящей связи с принтером требуется Windows 10 или более поздней версии."), + ("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", "WebSocket поддерживает только подключения к ретранслятору."), + ("Use WebSocket", "Использовать WebSocket"), + ("Trackpad speed", "Скорость трекпада"), + ("Default trackpad speed", "Скорость трекпада по умолчанию"), + ("Numeric one-time password", "Цифровой одноразовый пароль"), + ("Enable IPv6 P2P connection", "Использовать подключение IPv6 P2P"), + ("Enable UDP hole punching", "Использовать UDP hole punching"), + ("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", "По умолчанию RustDesk проверяет сертификат сервера на наличие протоколов, использующих TLS.\nЕсли эта функция включена, RustDesk пропустит данный этап и продолжит работу в случае неудачной проверки."), + ("Disable UDP", "Отключить UDP"), + ("disable-udp-tip", "Определяет, следует ли использовать только TCP.\nЕсли включено, RustDesk не будет использовать UDP 21116, вместо него будет использоваться TCP 21116."), + ("server-oss-not-support-tip", "ПРИМЕЧАНИЕ: в OSS-сервере RustDesk эта функция отсутствует."), + ("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 {}", "Продолжить с {}"), + ("Display Name", "Отображаемое имя"), + ("password-hidden-tip", "Установлен постоянный пароль (скрытый)."), + ("preset-password-in-use-tip", "Установленный пароль сейчас используется."), + ("Enable privacy mode", "Использовать режим конфиденциальности"), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/sc.rs b/src/lang/sc.rs new file mode 100644 index 000000000..06919b752 --- /dev/null +++ b/src/lang/sc.rs @@ -0,0 +1,749 @@ +lazy_static::lazy_static! { +pub static ref T: std::collections::HashMap<&'static str, &'static str> = + [ + ("Status", "Istadu"), + ("Your Desktop", "Custu elaboradore"), + ("desk_tip", "Podes atzèdere a custu elaboradore impreende s'ID e sa crae de intrada inditados inoghe in suta."), + ("Password", "Crae"), + ("Ready", "Prontu"), + ("Established", "Istabilida"), + ("connecting_status", "Connessione a sa rete RustDesk..."), + ("Enable service", "Abìlita servìtziu"), + ("Start service", "Allughe su servìtziu"), + ("Service is running", "Su servìtziu est in funtzione"), + ("Service is not running", "Su servìtziu no est in funtzione"), + ("not_ready_status", "Non prontu. Verìfica sa connessione"), + ("Control Remote Desktop", "Controlla s'elaboradore remotu"), + ("Transfer file", "Tràmuda documentos"), + ("Connect", "Cunnete·ti"), + ("Recent sessions", "Sessiones reghentes"), + ("Address book", "Rubrica"), + ("Confirmation", "Cunfirma"), + ("TCP tunneling", "Tunnel TCP"), + ("Remove", "Boga"), + ("Refresh random password", "Crae casuale noa"), + ("Set your own password", "Imposta sa crae"), + ("Enable keyboard/mouse", "Abìlita tecladu/ratu"), + ("Enable clipboard", "Abìlita punta de billete"), + ("Enable file transfer", "Abìlita su tramudòngiu de documentos"), + ("Enable TCP tunneling", "Abìlita tunnel TCP"), + ("IP Whitelisting", "IP autorizados"), + ("ID/Relay Server", "Serbidore ID/Tràmuda"), + ("Import server config", "Importa configuratzione serbidore dae sa punta de billete"), + ("Export Server Config", "Esporta configurazione serbidore a sa punta de billete"), + ("Import server configuration successfully", "Configuratzione serbidore importada cumprida"), + ("Export server configuration successfully", "Configuratzione serbidore esportada cumprida"), + ("Invalid server configuration", "Configuratzione serbidore non vàlida"), + ("Clipboard is empty", "Sa punta de billete est bòida"), + ("Stop service", "Firma su servìtziu"), + ("Change ID", "Càmbia ID"), + ("Your new ID", "S'ID nou"), + ("length %min% to %max%", "longària dae %min% a %max%"), + ("starts with a letter", "incumintza cun una lìtera"), + ("allowed characters", "caràteres cunsentidos"), + ("id_change_tip", "Podes impreare petzi sos caràteres a-z, A-Z, 0-9, - (tratigheddu) e _ (sutaliniadu).\nSu primu caràtere depet èssere a-z o A-Z.\nSa longària depet èssere de intre 6 e 16 caràteres."), + ("Website", "Situ web programma"), + ("About", "Info programma"), + ("Slogan_tip", "Fatu cun su coro in custu mundu caòticu!"), + ("Privacy Statement", "Informativa subra de sa riservadesa"), + ("Mute", "Sonu istudadu"), + ("Build Date", "Data build"), + ("Version", "Versione"), + ("Home", "Pàgina printzipale"), + ("Audio Input", "Intrada àudio"), + ("Enhancements", "Megioros"), + ("Hardware Codec", "Codificadore fìsicu (hardware)"), + ("Adaptive bitrate", "Velotzidade de bits adativa"), + ("ID Server", "ID serbidore"), + ("Relay Server", "Serbidore de tràmuda"), + ("API Server", "Serbidore API"), + ("invalid_http", "depet incumintzare cun http:// o https://"), + ("Invalid IP", "Indiritzu IP non vàlidu"), + ("Invalid format", "Formadu non vàlidu"), + ("server_not_support", "Galu non suportadu dae su serbidore"), + ("Not available", "No a disponimentu"), + ("Too frequent", "Tropu fitianu"), + ("Cancel", "Annulla"), + ("Skip", "Ignora"), + ("Close", "Serra"), + ("Retry", "Torra a proare"), + ("OK", "AB"), + ("Password Required", "Bisòngiat sa crae"), + ("Please enter your password", "Inserta sa crae tua"), + ("Remember password", "Ammenta sa crae"), + ("Wrong Password", "Crae isballiada"), + ("Do you want to enter again?", "Boles torrare a intrare?"), + ("Connection Error", "Errore de connessione"), + ("Error", "Errore"), + ("Reset by the peer", "Resetada dae su dispositivu de s'àtera parte"), + ("Connecting...", "Connetende..."), + ("Connection in progress. Please wait.", "Connetende. Iseta."), + ("Please try 1 minute later", "Torra a proare a pustis de 1 minutu"), + ("Login Error", "Faddina de atzessu"), + ("Successful", "Cumpridu"), + ("Connected, waiting for image...", "Connessu, isetende s'immàgine..."), + ("Name", "Nùmene"), + ("Type", "Casta"), + ("Modified", "Modificadu"), + ("Size", "Mannària"), + ("Show Hidden Files", "Mustra sos documentos cuados"), + ("Receive", "Retzi"), + ("Send", "Imbia"), + ("Refresh File", "Annoa sos documentos"), + ("Local", "Locale"), + ("Remote", "Remotu"), + ("Remote Computer", "Elaboradore remotu"), + ("Local Computer", "Elaboradore locale"), + ("Confirm Delete", "Cunfirma s'iscantzelladura"), + ("Delete", "Iscantzella"), + ("Properties", "Propiedades"), + ("Multi Select", "Seletzione mùltipla"), + ("Select All", "Seletziona totu"), + ("Unselect All", "Deseletziona totu"), + ("Empty Directory", "Cartella bòida"), + ("Not an empty directory", "No est una cartella bòida"), + ("Are you sure you want to delete this file?", "Ses seguru de bòlere iscantzellare custu documentu?"), + ("Are you sure you want to delete this empty directory?", "Ses seguru de bòlere iscantzellare custa cartella bòida?"), + ("Are you sure you want to delete the file of this directory?", "Ses seguru de bòlere iscantzellare su documentu de custa cartella?"), + ("Do this for all conflicts", "Ammenta custu issèberu pro totu sos cunflitos"), + ("This is irreversible!", "Custu non si podet annullare!"), + ("Deleting", "Iscantzellende"), + ("files", "documentos"), + ("Waiting", "Isetende"), + ("Finished", "Acabadu"), + ("Speed", "Lestresa"), + ("Custom Image Quality", "Calidade immàgine personalizada"), + ("Privacy mode", "Modalidade de riservadesa"), + ("Block user input", "Bloca sas atziones de utente"), + ("Unblock user input", "Isbloca sas atziones de utente"), + ("Adjust Window", "Adata sa ventana"), + ("Original", "Originale"), + ("Shrink", "Astringhe"), + ("Stretch", "Illàrghia"), + ("Scrollbar", "Istanga de iscurrimentu"), + ("ScrollAuto", "Iscurre in automàticu"), + ("Good image quality", "Calidade bona de s'immàgine"), + ("Balanced", "Bilantziada"), + ("Optimize reaction time", "Otimiza su tempus de reatzione"), + ("Custom", "Profilu personalizadu"), + ("Show remote cursor", "Mustra su cursore remotu"), + ("Show quality monitor", "Mustra sa calidade vìdeu"), + ("Disable clipboard", "Disabìlita sa punta de billete"), + ("Lock after session end", "Bloca a sa fine de sa sessione"), + ("Insert Ctrl + Alt + Del", "Inserta Ctrl + Alt + Del"), + ("Insert Lock", "Blocu insertada"), + ("Refresh", "Annoa"), + ("ID does not exist", "S'ID no esistit"), + ("Failed to connect to rendezvous server", "Errore connessione a su sebidore de atòbiu"), + ("Please try later", "Torra a proare prus a a tardu"), + ("Remote desktop is offline", "S'iscrivania remota no est in lìnia"), + ("Key mismatch", "Sa crae non currispondet"), + ("Timeout", "Tempus iscadidu"), + ("Failed to connect to relay server", "Connessione a su serbidore de tràmuda fallida"), + ("Failed to connect via rendezvous server", "Connessione pro mèdiu de su serbidore de atòbiu fallida"), + ("Failed to connect via relay server", "Connessione pro mèdiu de su serbidore de tràmuda fallida"), + ("Failed to make direct connection to remote desktop", "Connessione direta a s'iscrivania remota fallida"), + ("Set Password", "Imposta sa crae"), + ("OS Password", "Crae sistema operativu"), + ("install_tip", "Pro neghe de su Controllu Contu Utente (UAC), RustDesk diat pòdere non funtzionare comente si tocat comente iscrivania remota.\nPro evitare custu problema, incarca in su butone inoghe in suta pro installare RustDesk a livellu de sistema."), + ("Click to upgrade", "Atualiza"), + ("Configure", "Cunfigura"), + ("config_acc", "Pro controllare s'iscrivania dae foras, depes frunire a RustDesk su permissu 'Atzessibilidade'."), + ("config_screen", "Pro controllare s'iscrivania dae foras, depes frunire a RustDesk su permissu 'Registratzione ischermu'."), + ("Installing ...", "Installatzione ..."), + ("Install", "Installa"), + ("Installation", "Installatzione"), + ("Installation Path", "Àndala de installatzione"), + ("Create start menu shortcuts", "Crea sos ligàmenes in su menù de incumintzu"), + ("Create desktop icon", "Crea un'icona in s'iscrivania"), + ("agreement_tip", "Incaminende s'installazione, atzetas sos tèrmines de su cuntratu de lissèntzia."), + ("Accept and Install", "Atzeta e installa"), + ("End-user license agreement", "Cuntratu de lissèntzia utente finale"), + ("Generating ...", "Ingendrende ..."), + ("Your installation is lower version.", "Cuta installazione no est atualizada."), + ("not_close_tcp_tip", "Non Serres custa ventana in su mentres chi ses impreende su tunnel"), + ("Listening ...", "Ascurtende ..."), + ("Remote Host", "Istrangiaore (host) remotu"), + ("Remote Port", "Ghenna remota"), + ("Action", "Atzione"), + ("Add", "Annanghe"), + ("Local Port", "Ghenna locale"), + ("Local Address", "Indiritzu locale"), + ("Change Local Port", "Càmbia ghenna locale"), + ("setup_server_tip", "Pro una connessione prus lestra, cunfigura unu serbidore ispetzìficu"), + ("Too short, at least 6 characters.", "Tropu curtza, a su nessi 6 caràteres"), + ("The confirmation is not identical.", "Sa crae de cunfirma non currispondet"), + ("Permissions", "Permissos"), + ("Accept", "Atzeta"), + ("Dismiss", "Naga"), + ("Disconnect", "Iscollega·ti"), + ("Enable file copy and paste", "Permite sa còpia e s'incollòngiu de documentos"), + ("Connected", "Connessu"), + ("Direct and encrypted connection", "Connessione direta e tzifrada"), + ("Relayed and encrypted connection", "Connessione inoltrada (relayed) e tzifrada"), + ("Direct and unencrypted connection", "Connessione direta e non tzifrada"), + ("Relayed and unencrypted connection", "Connessione inoltrada (relayed) e non tzifrada"), + ("Enter Remote ID", "Inserta ID remotu"), + ("Enter your password", "Inserta sa crae tua"), + ("Logging in...", "Intrende..."), + ("Enable RDP session sharing", "Abìlita sa cumpartzidura sessione RDP"), + ("Auto Login", "Atzessu automàticu"), + ("Enable direct IP access", "Abìlita s'intrada direta pro mèdiu de s'IP"), + ("Rename", "Càmbia de nùmene"), + ("Space", "Ispàtziu"), + ("Create desktop shortcut", "Crea unu ligàmene in s'iscrivania"), + ("Change Path", "Modìfica s'àndala"), + ("Create Folder", "Crea una cartella"), + ("Please enter the folder name", "Inserta su nùmene de sa cartella"), + ("Fix it", "Risolve"), + ("Warning", "Avisu"), + ("Login screen using Wayland is not supported", "S'ischemada de intrada no est suportada impreende Wayland"), + ("Reboot required", "B'at bisòngiu de una torrada a aviare"), + ("Unsupported display server", "Serbidore de visualizatzione non suportadu"), + ("x11 expected", "bisòngiat xll"), + ("Port", "Ghenna"), + ("Settings", "Impostatziones"), + ("Username", "Nùmene utente"), + ("Invalid port", "Nùmeru ghenna non vàlidu"), + ("Closed manually by the peer", "Serradu a manu dae su dispositivu remotu"), + ("Enable remote configuration modification", "Abìlita sa modìfica remota de sa cunfiguratzione"), + ("Run without install", "Allughe chene installare"), + ("Connect via relay", "Collega·ti impreende una tràmuda relay"), + ("Always connect via relay", "Collega·ti semper impreende una tràmuda relay"), + ("whitelist_tip", "Si podent connètere a custa iscrivania petzi sos indiritzos IP autorizados"), + ("Login", "Intra"), + ("Verify", "Avèrgua"), + ("Remember me", "Ammenta·ti de mene"), + ("Trust this device", "Registra custu dispositivu comente de fidùtzia"), + ("Verification code", "Còdighe de verìfica"), + ("verification_tip", "Amus imbiadu unu còdighe de averguada a s'indiritzu de posta eletrònica registradu, pro intrare inserta·lu."), + ("Logout", "Essi"), + ("Tags", "Etichetas"), + ("Search ID", "Chirca ID"), + ("whitelist_sep", "Separados dae vìrgulas, puntu e vìrgula, ispatziu o riga a suta"), + ("Add ID", "Annanghe ID"), + ("Add Tag", "Annanghe eticheta"), + ("Unselect all tags", "Deseletziona totu sas etichetas"), + ("Network error", "Errore de rete"), + ("Username missed", "Mancat su nùmene utente"), + ("Password missed", "Mancat sa crae de intrada"), + ("Wrong credentials", "Credentziales isballiadas"), + ("The verification code is incorrect or has expired", "Su còdighe de verìfica no est curretu o est iscadidu"), + ("Edit Tag", "Modìfica eticheta"), + ("Forget Password", "Ismèntiga sa crae"), + ("Favorites", "Preferidos"), + ("Add to Favorites", "Annanghe a sos preferidos"), + ("Remove from Favorites", "Boga dae sos preferidos"), + ("Empty", "Bòidu"), + ("Invalid folder name", "Nùmene de sa cartella non vàlidu"), + ("Socks5 Proxy", "Serbidore intermediàriu Socks5"), + ("Socks5/Http(s) Proxy", "Serbidore intermediàriu Socks5/Http(s)"), + ("Discovered", "Rileva"), + ("install_daemon_tip", "Pro aviare su programma a s'allughìngiu, tocat a l'installare comente servìtziu de sistema."), + ("Remote ID", "ID remotu"), + ("Paste", "Incolla"), + ("Paste here?", "Incollare inoghe?"), + ("Are you sure to close the connection?", "Ses seguru de bòlere serrare sa connessione?"), + ("Download new version", "Iscàrriga sa versione noa"), + ("Touch mode", "Modalidade tocu"), + ("Mouse mode", "Modalidade ratu"), + ("One-Finger Tap", "Tocu cun unu pòddighe"), + ("Left Mouse", "Butone de manca de su ratu"), + ("One-Long Tap", "Tocu longu cun unu pòddighe"), + ("Two-Finger Tap", "Tocu cun duos pòddighes"), + ("Right Mouse", "Butone de destra de su ratu"), + ("One-Finger Move", "Movimentu cun unu pòddighe"), + ("Double Tap & Move", "Tocu dòpiu e movimentu"), + ("Mouse Drag", "Trisinada de su ratu"), + ("Three-Finger vertically", "Tres pòddighes in verticale"), + ("Mouse Wheel", "Rodedda de su ratu"), + ("Two-Finger Move", "Movimentu cun duos pòddighes"), + ("Canvas Move", "Isposta sa tela"), + ("Pinch to Zoom", "Pìtziga pro ismanniare"), + ("Canvas Zoom", "Ismanniamentu tela"), + ("Reset canvas", "Reseta sa tela"), + ("No permission of file transfer", "Perunu permissu pro sa tràmuda de documentos"), + ("Note", "Nota"), + ("Connection", "Connessione"), + ("Share screen", "Cumpartzi ischermu"), + ("Chat", "Tzarrada"), + ("Total", "Totale"), + ("items", "Elementos"), + ("Selected", "Seletzionadu"), + ("Screen Capture", "Catura de ischermu"), + ("Input Control", "Controllu atziones"), + ("Audio Capture", "Catura de s'àudio"), + ("Do you accept?", "Atzetas?"), + ("Open System Setting", "Aberi sas impostatziones de sistema"), + ("How to get Android input permission?", "Comente otènnere s'autorizatzione de intrada (input) in Android?"), + ("android_input_permission_tip1", "Pro chi unu dispositivu remotu potzat controllare unu dispositivu Android pro mèdiu de unu ratu o cun su tocu, depes cunsentire a RustDesk de impreare su servìtziu 'Atzessibilidade'."), + ("android_input_permission_tip2", "Bae a sa pàgina de sas impostatziones de sistema chi s'at a abèrrere a pustis, busca e intra a [Servìtzios installados], allughe su servìtziu [Intrada RustDesk]."), + ("android_new_connection_tip", "Est istada retzida una dimanda noa de controllu pro su dispositivu atuale."), + ("android_service_will_start_tip", "S'ativatzione de Catura ischermu at a aviare in automàticu su servìtziu, permitende a àteros dispositivos de pedire una connessione dae custu dispositivu."), + ("android_stop_service_tip", "Sa serrada de su servìtziu at a tancare in automàticu totu sas connessiones istabilidas."), + ("android_version_audio_tip", "Sa versione atuale de Android non suportat s'achirimentu àudio, faghe s'atualizatzione a Android 10 o versiones prus noas."), + ("android_start_service_tip", "Pro aviare su servìtziu de cumpartzidura de s'ischermu seletziona [Avia su servìtziu] o abìlita s'autorizatzione [Catura de ischermu]."), + ("android_permission_may_not_change_tip", "Sas autorizatziones pro sas connessiones istabilidas non si podent modificare in manera istantànea finas a sa riconnessione."), + ("Account", "Contu"), + ("Overwrite", "Subraiscrie"), + ("This file exists, skip or overwrite this file?", "Custu documentu esistit, boles ignorare o subraiscìere custu archìviu?"), + ("Quit", "Essi"), + ("Help", "Agiudu"), + ("Failed", "Fallidu"), + ("Succeeded", "Cumpridu"), + ("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"), + ("Language", "Limba"), + ("Keep RustDesk background service", "Mantene su servìtziu de RustDesk in s'isfundu"), + ("Ignore Battery Optimizations", "Ignora sas otimizatziones de sa bateria"), + ("android_open_battery_optimizations_tip", "Si boles disabilitare custa funtzione, bae a sas impostatziones de s'aplicatzione RustDesk, aberi sa setzione 'Bateria' e boga sa seletzione a 'Chene restritziones'."), + ("Start on boot", "Avia a s'allughidura"), + ("Start the screen sharing service on boot, requires special permissions", "S'aviu de su servìtziu de cumpartzidura de s'ischermu a s'allughidura tenet bisòngiu de permissos ispetziales"), + ("Connection not allowed", "Connessione non permìtida"), + ("Legacy mode", "Modalidade antiga"), + ("Map mode", "Modalidade mapa"), + ("Translate mode", "Modalidade tradutzione"), + ("Use permanent password", "Imprea una crae de intrada permanente"), + ("Use both passwords", "Imprea craes de intrada monoimpreu e permanente"), + ("Set permanent password", "Imposta sa crae permanente"), + ("Enable remote restart", "Abìlita riaviu dae remotu"), + ("Restart remote device", "Torra a aviare su dispositivu remotu"), + ("Are you sure you want to restart", "Ses seguru de bòlere torrare a allùghere?"), + ("Restarting remote device", "Su dispositivu remotu s'est torrende a allùghere"), + ("remote_restarting_tip", "Torra a allùghere su dispositivu remotu"), + ("Copied", "Copiadu"), + ("Exit Fullscreen", "Essi dae sa modalidade a ischermu intreu"), + ("Fullscreen", "A ischermu intreu"), + ("Mobile Actions", "Atziones mòbiles"), + ("Select Monitor", "Seleziona ischermu"), + ("Control Actions", "Atziones de controllu"), + ("Display Settings", "Impostatziones de visualizatzione"), + ("Ratio", "Raportu"), + ("Image Quality", "Calidade de s'immàgine"), + ("Scroll Style", "Istile de iscurrimentu"), + ("Show Toolbar", "Mustra s'istanga de trastes"), + ("Hide Toolbar", "Cua s'istanga de trastes"), + ("Direct Connection", "Connessione direta"), + ("Relay Connection", "Connessione tramudada (relay)"), + ("Secure Connection", "Connessione segura"), + ("Insecure Connection", "Connessione non segura"), + ("Scale original", "Iscala originale"), + ("Scale adaptive", "Iscala adativa"), + ("General", "Generale"), + ("Security", "Seguresa"), + ("Theme", "Tema"), + ("Dark Theme", "Tema iscuru"), + ("Light Theme", "Tema craru"), + ("Dark", "Iscuru"), + ("Light", "Craru"), + ("Follow System", "Sistema"), + ("Enable hardware codec", "Abìlita codificadore fìsicu"), + ("Unlock Security Settings", "Isbloca sas impostatziones de seguresa"), + ("Enable audio", "Abìlita àudio"), + ("Unlock Network Settings", "Isbloca impostatziones de rete"), + ("Server", "Serbidore"), + ("Direct IP Access", "Atzessu IP diretu"), + ("Proxy", "Serbidore intermediàriu"), + ("Apply", "Àplica"), + ("Disconnect all devices?", "Boles iscollegare totu sos dispositivos?"), + ("Clear", "Isbòida"), + ("Audio Input Device", "Dispositivu intrada àudio"), + ("Use IP Whitelisting", "Imprea elencu IP autorizados"), + ("Network", "Rete"), + ("Pin Toolbar", "Bloca s'istanga de trastes"), + ("Unpin Toolbar", "Isbloca s'istanga de trastes"), + ("Recording", "Registratzione"), + ("Directory", "Cartella"), + ("Automatically record incoming sessions", "Registra in automàticu sas sessiones in intrada"), + ("Automatically record outgoing sessions", "Registra in automàticu sas sessiones in essida"), + ("Change", "Modìfica"), + ("Start session recording", "Incumintza sa registrazione de sa sessione"), + ("Stop session recording", "Firma sa registrazione de sa sessione"), + ("Enable recording session", "Abìlita sa registrazione de sa sessione"), + ("Enable LAN discovery", "Abìlita su rilevamentu LAN"), + ("Deny LAN discovery", "Disabìlita su rilevamentu LAN"), + ("Write a message", "Iscrie unu messàgiu"), + ("Prompt", "Pedi"), + ("Please wait for confirmation of UAC...", "Iseta sa cunfirma de s'UAC..."), + ("elevated_foreground_window_tip", "Sa ventana atuale de s'elaboradore remotu tenet bisòngiu, pro funtzionare, de privilègios prus mannos, duncas non faghet a impreare in manera temporànea su ratu e su tecladu.\nSi podet pedire a s'utente remotu de minimare a icona sa ventana atuale o de seletzionare su pulsante de artària in sa ventana de gestione de sa connessione.\nPro evitare custu problema, ti cussigiamus de installare su programma in su dispositivu remotu."), + ("Disconnected", "Iscollegadu"), + ("Other", "Àteru"), + ("Confirm before closing multiple tabs", "Cunfirma in antis de serrare prus ischedas"), + ("Keyboard Settings", "Impostatziones de tecladu"), + ("Full Access", "Atzessu cumpridu"), + ("Screen Share", "Cumpartzidura de ischermu"), + ("ubuntu-21-04-required", "Wayland tenet bisòngiu de Ubuntu 21.04 o versione prus noa."), + ("wayland-requires-higher-linux-version", "Wayland tenet bisòngiu de una versione prus noa de sa distributzione Linux.\nProa X11 pro elaboradores o càmbia su sistema operativu."), + ("xdp-portal-unavailable", ""), + ("JumpLink", "Bae a"), + ("Please Select the screen to be shared(Operate on the peer side).", "Seletziona s'ischermu de cumpartzire (òpera dae s'ala de su dispositivu remotu)."), + ("Show RustDesk", "Mustra RustDesk"), + ("This PC", "Custu PC"), + ("or", "O"), + ("Elevate", "Cresche"), + ("Zoom cursor", "Cursore de ismanniamentu"), + ("Accept sessions via password", "Atzeta sessiones cun sa crae"), + ("Accept sessions via click", "Atzeta sessiones cun sas incarcadas"), + ("Accept sessions via both", "Atzeta sessiones cun totu sas duas craes"), + ("Please wait for the remote side to accept your session request...", "Iseta chi su dispositivu remotu atzetet sa dimanda de sessione..."), + ("One-time Password", "Crae monoimpreu"), + ("Use one-time password", "Imprea crae monoimpreu"), + ("One-time password length", "Longària crae monoimpreu"), + ("Request access to your device", "Pedi s'atzessu a su dispositivu"), + ("Hide connection management window", "Cua sa ventana de gestione de sas connessiones"), + ("hide_cm_tip", "Permite de cuare petzi si s'atzetant sessiones cun crae permanente"), + ("wayland_experiment_tip", "Su suportu Wayland est in fase isperimentale, si boles un'atzessu istàbile imprea X11."), + ("Right click to select tabs", "Incarca cun su pulsante destru pro seletzionare sas ischedas"), + ("Skipped", "Brincadu"), + ("Add to address book", "Annanghe a sa rubrica"), + ("Group", "Grupu"), + ("Search", "Chirca"), + ("Closed manually by web console", "Serra in manera manuale dae sa console web"), + ("Local keyboard type", "Casta de tecladu locale"), + ("Select local keyboard type", "Seletziona sa casta de tecladu locale"), + ("software_render_tip", "Si in s'elaboradore cun Linux b'at un'ischeda grafica Nvidia e sa ventana remota si serrat deretu a pustis de sa connessione, installa su driver nou a còdighe abertu e imprea sa renderitzatzione tràmite programma (software).\nDiat pòdere bisongiare a torrare a allùghere su programma."), + ("Always use software rendering", "Imprea semper sa renderizatzione tràmite programma"), + ("config_input", "Pro controllare s'elaboradore remotu cun su tecladu bisòngiat a frunire a RustDesk sos permissos de 'Monitoràgiu insertada'."), + ("config_microphone", "Per pòdere mutire, bisòngiat a frunire su premissu 'Registra àudio' a RustDesk."), + ("request_elevation_tip", "Si b'at calicunu in s'ala remota si podet pedire sa crèschida."), + ("Wait", "Iseta"), + ("Elevation Error", "Faddina durante sa crèschida de sos deretos"), + ("Ask the remote user for authentication", "Pedi s'autenticatzione a s'utente remotu"), + ("Choose this if the remote account is administrator", "Issèbera custa optzione si su contu remotu est amministradore"), + ("Transmit the username and password of administrator", "Trasmite su nùmene utente e sa crae de intrada de s'amministradore"), + ("still_click_uac_tip", "Torra a pedire chi s'utente remotu seletziones 'AB' in sa ventana UAC de s'esecutzione de RustDesk."), + ("Request Elevation", "Pedi sa crèschida de sos deretos"), + ("wait_accept_uac_tip", "Iseta chi s'utente remotu atzetet sa ventana de diàlogu UAC."), + ("Elevate successfully", "Crèschida de sos deretos cumprida"), + ("uppercase", "Majùscula"), + ("lowercase", "Minùscula"), + ("digit", "Nùmeru"), + ("special character", "Caràtere ispetziale"), + ("length>=8", "Lunghezza >= 8"), + ("Weak", "Dèbile"), + ("Medium", "Mesana"), + ("Strong", "Forte"), + ("Switch Sides", "Càmbia ala"), + ("Please confirm if you want to share your desktop?", "Boles cumpartzire s'elaboradore?"), + ("Display", "Visualizatzione"), + ("Default View Style", "Istile de visualiztazione predefinidu"), + ("Default Scroll Style", "Istile de iscurrimentu predefinidu"), + ("Default Image Quality", "Calidade de s'immàgine predefinida"), + ("Default Codec", "Codificadore predefinidu"), + ("Bitrate", "Tassu de bits"), + ("FPS", "FPS"), + ("Auto", "Automàticu"), + ("Other Default Options", "Àteras optziones predefinidas"), + ("Voice call", "Mutida vocale"), + ("Text chat", "Tzarrada de testu"), + ("Stop voice call", "Interrumpe sa mutida vocale"), + ("relay_hint_tip", "Si non faghet a si connètere in manera direta, podes proare a ti collegare impreende unu serbidore de tràmuda.\nIn prus, si boles imprearevsu serbidore de tràmuda in su primu tentativu, podes annànghere a s'ID su suffissu '/r\' o seletzionare in s'ischeda si esistit s'optzione 'Collega·ti semper impreende una tràmuda relay'."), + ("Reconnect", "Collega·ti torra"), + ("Codec", "Codificadore"), + ("Resolution", "Risolutzione"), + ("No transfers in progress", "Peruna tràmuda in cursu"), + ("Set one-time password length", "Imposta sa longària de sa crae monoimpreu"), + ("RDP Settings", "Impostatziones RDP"), + ("Sort by", "Òrdina pro"), + ("New Connection", "Connessione noa"), + ("Restore", "Riprìstina"), + ("Minimize", "Mìnima"), + ("Maximize", "Massimiza"), + ("Your Device", "Custu dispositivu"), + ("empty_recent_tip", "Non b'at galu peruna sessione reghente!\nPianifica·nde una."), + ("empty_favorite_tip", "Galu peruna connessione?\nBusca calicunu cun chie ti collegare e annanghe·lu a sos preferidos!"), + ("empty_lan_tip", "Paret a beru chi non siat istada atzapada peruna connessione."), + ("empty_address_book_tip", "Paret chi pro como in sa rubrica non b'apat connessiones."), + ("Empty Username", "Nùmene utente bòidu"), + ("Empty Password", "Crae bòida"), + ("Me", "Deo"), + ("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"), + ("New RDP", "RDP nou"), + ("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"), + ("Collapse toolbar", "Mìnima s'istanga de sos trastes"), + ("Accept and Elevate", "Atzeta e cresche"), + ("accept_and_elevate_btn_tooltip", "Atzeta sa connessione e cresche sos permissos UAC."), + ("clipboard_wait_response_timeout_tip", "Tempus de isetu de rispota dae sa còpia iscadidu."), + ("Incoming connection", "Connessiones in intrada"), + ("Outgoing connection", "Connessiones in essida"), + ("Exit", "Essi dae RustDesk"), + ("Open", "Aberi RustDesk"), + ("logout_tip", "Ses seguru de bòlere essire?"), + ("Service", "Servìtziu"), + ("Start", "Allughe"), + ("Stop", "Firma"), + ("exceed_max_devices", "Ses arribbadu a su nùmeru màssimu de dispositivos chi podes manigiare."), + ("Sync with recent sessions", "Sincroniza cun sas sessiones reghentes"), + ("Sort tags", "Òrdina sas etichetas"), + ("Open connection in new tab", "Aberi sa connessione in un'ischeda noa"), + ("Move tab to new window", "Move s'ischeda a sa ventana imbeniente"), + ("Can not be empty", "Non podet èssere bòidu"), + ("Already exists", "Esistit giai"), + ("Change Password", "Modìfica sa crae"), + ("Refresh Password", "Annoa sa crae"), + ("ID", "ID"), + ("Grid View", "Vista grìllia"), + ("List View", "Vista elencu"), + ("Select", "Seletziona"), + ("Toggle Tags", "Allughe/istuda eticheta"), + ("pull_ab_failed_tip", "Non faghet a annoare sa rubrica"), + ("push_ab_failed_tip", "Non faghet a sincronizare sa rubrica cun su serbidore"), + ("synced_peer_readded_tip", "Sos dispositivos chi bi sunt in sas sessiones reghentes s'ant a torrare a sincronizare in sa rubrica."), + ("Change Color", "Modìfica colore"), + ("Primary Color", "Colore primàriu"), + ("HSV Color", "Colore HSV"), + ("Installation Successful!", "Installatzione cumprida"), + ("Installation failed!", "Installtazione fallida"), + ("Reverse mouse wheel", "Funtzione rodedda ratu furriada"), + ("{} sessions", "{} sessiones"), + ("scam_title", "Ti diant pòdere àere TRAMPADU!"), + ("scam_text1", "Si ses in su telèfonu cun calicunu chi NON connosches NON FIDADU chi t'at pedidu de impreare RustDesk e de allùghere su servìtziu, non sigas e tanca deretu."), + ("scam_text2", "Est dàbile chi siat unu trampadore chi chircat de furare su dinare tuo o àteras informatziones privadas tuas."), + ("Don't show again", "Non mustres prus"), + ("I Agree", "Atzeto"), + ("Decline", "No atzeto"), + ("Timeout in minutes", "Tempus de iscadèntzia in minutos"), + ("auto_disconnect_option_tip", "Serra in automàticu sas sessiones in intrada pro inatividade de s'utente"), + ("Connection failed due to inactivity", "Connessione non resèssida pro neghe de inatividade"), + ("Check for software update on startup", "A s'allughìngiu avèrgua sa presèntzia de atualizatziones pro su programma"), + ("upgrade_rustdesk_server_pro_to_{}_tip", "Atualiza RustDesk Server Pro a sa versione {} o prus noa!"), + ("pull_group_failed_tip", "Non faghet a annoare su grupu"), + ("Filter by intersection", "Filtra pro rugrada"), + ("Remove wallpaper during incoming sessions", "Boga s'isfundu durante sas sessiones in intrada"), + ("Test", "Proa"), + ("display_is_plugged_out_msg", "S'ischermu est iscollegadu, colo a su primu ischermu."), + ("No displays", "Perunu ischermu"), + ("Open in new window", "Aberi in una ventana noa"), + ("Show displays as individual windows", "Mustra sos ischermos comente ventanas individuales"), + ("Use all my displays for the remote session", "In sa sessione remota imprea totu sos ischermos"), + ("selinux_tip", "In custu dispositivu est abilitadu SELinux, chi diat pòdere su funtzionamentu curretu de RustDesk comente ala controllada."), + ("Change view", "Modìfica vista"), + ("Big tiles", "Iconas mannas"), + ("Small tiles", "Iconas minores"), + ("List", "Elencu"), + ("Virtual display", "Ischermu virtuale"), + ("Plug out all", "Iscollega totu"), + ("True color (4:4:4)", "Colore reale (4:4:4)"), + ("Enable blocking user input", "Abìlita blocu insertada utente"), + ("id_input_tip", "Podes insertare un'ID, un'IP diretu o unu domìniu cun una ghenna (:).\nSi boles atzèdere a unu dispositivu in un'àteru serbidore, annanghe s'indiritzu de su serbidore (@?key=), a esèmpiu\n9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=.\nSi boles atzèdere a unu dispositivu in unu serbidore pùblicu, inserta \"@public\", pro su serbidore pùblicu sa crae non serbit\n\nSi boles fortzare s'impreu de una connessione de inoltru a sa prima connessione, annanghe \"/r\" a sa fine de s'ID, a esèmpiu \"9123456234/r\"."), + ("privacy_mode_impl_mag_tip", "Manera 1"), + ("privacy_mode_impl_virtual_display_tip", "Manera 2"), + ("Enter privacy mode", "Intra in modalidade de riservadesa"), + ("Exit privacy mode", "Essi dae sa modalidade de riservadesa"), + ("idd_not_support_under_win10_2004_tip", "Su driver vìdeu indiretu no est suportadu. Bisòngiat Windows 10, versione 2004 o prus noa."), + ("input_source_1_tip", "Fonte intrada (1)"), + ("input_source_2_tip", "Fonte intrada (2)"), + ("Swap control-command key", "Cuncàmbia tecla controllu-cumandu"), + ("swap-left-right-mouse", "Cuncàmbia pulsante mancu-destru ratu"), + ("2FA code", "Còdighe 2FA"), + ("More", "Àteru"), + ("enable-2fa-title", "Abìlita s'autenticatzione a duos fases"), + ("enable-2fa-desc", "Cunfigura s'autenticadore.\nPodes impreare un'aplicatzione de autenticatzione che a Authy, Microsoft o Google Authenticator in su telèfonu o elaboredore.\n\nPro abilitare s'autenticatzione a duas fases iscansi su còdighe QR cun s'aplicatzione e inserta su còdighe mustradu dae s'aplicatzione."), + ("wrong-2fa-code", "Non faghet a averguare su còdighe.\nVerìfica chi sas impostatziones de su còdighe e de s'ora locale siant curretas"), + ("enter-2fa-title", "Autenticatzione a duas fases"), + ("Email verification code must be 6 characters.", "Su còdighe de verìfica posta eletrònica depet cuntènnere 6 caràteres."), + ("2FA code must be 6 digits.", "Su còdighe 2FA depet èssere fatu de 6 tzifras."), + ("Multiple Windows sessions found", "Sessiones de Windows mùltiplas atzapadas"), + ("Please select the session you want to connect to", "Seletziona sa sessione cun chi ti boles cunnètere"), + ("powered_by_me", "Alimentadu dae RustDesk"), + ("outgoing_only_desk_tip", "Custa est un'editzione personalizada.\nTi podes connètere a àteros dispositivos, ma sos àteros dispositivos non si podent connètere a custu dispositivu."), + ("preset_password_warning", "Custa est un'editzione personalizada e benit frunida cun una crae de intrada pre-impostada.\nTotu sos chi connoschent custa crae diant pòdere otènnere su controllu totale de su dispositivu.\nSi non ti l'isetaias, disinstalla deretu su programma."), + ("Security Alert", "Avisu de seguresa"), + ("My address book", "Rubrica"), + ("Personal", "Personale"), + ("Owner", "Proprietàriu"), + ("Set shared password", "Imposta una crae cumpartzida"), + ("Exist in", "Esistit in"), + ("Read-only", "Leghidura ebbia"), + ("Read/Write", "Leghidura/iscritura"), + ("Full Control", "Controllu totale"), + ("share_warning_tip", "Sos campos inoghe in subra sunt cumpartzidos e sos àteros los pòdent bìdere."), + ("Everyone", "Totus"), + ("ab_web_console_tip", "Àteras informatziones subra de sa console web"), + ("allow-only-conn-window-open-tip", "Permite sa connessione petzi si sa ventana RustDesk est aberta"), + ("no_need_privacy_mode_no_physical_displays_tip", "Perunu ischermu fìsicu, peruna netzessidade de impreare sa modalidade de riservadesa."), + ("Follow remote cursor", "Sighi su cursore remotu"), + ("Follow remote window focus", "Sighi su focus de sa ventana remota"), + ("default_proxy_tip", "Protocollu e ghenna predefinidos sunt Socks5 e 1080"), + ("no_audio_input_device_tip", "Perunu dispositivu de intrada àudio atzapadu."), + ("Incoming", "In intrada"), + ("Outgoing", "In essida"), + ("Clear Wayland screen selection", "Annulla seletzione ischermada Wayland"), + ("clear_Wayland_screen_selection_tip", "A pustis de àere annulladu sa seletzione de ischermu, podes torrare a seletzionare s'ischermu de cumpartzire."), + ("confirm_clear_Wayland_screen_selection_tip", "Ses seguru de bòlere annullare sa seletzione de ischermu Wayland?"), + ("android_new_voice_call_tip", "As retzidu una rechuesta noa de mutida vocale. Si l'atzetas, sàudio at a colare a sa comunicatzione vocale."), + ("texture_render_tip", "Imprea sa tessidura de renderizatzione pro fàghere sas immàgines prus flùidas. Si atzapas problemas, proa a disabilitare custa optzione."), + ("Use texture rendering", "Imprea sa tessidura de renderizatzione"), + ("Floating window", "Ventana gallegiante"), + ("floating_window_tip", "Agiudat a mantènnere su servìtziu in s'isfundu de RustDesk"), + ("Keep screen on", "Mantene s'ischermu allutu"), + ("Never", "Mai"), + ("During controlled", "Durante su controllu"), + ("During service is on", "Cando su servìtziu est ativu"), + ("Capture screen using DirectX", "Catura s'ischermu impreende DirectX"), + ("Back", "In segus"), + ("Apps", "Aplicatziones"), + ("Volume up", "Volume +"), + ("Volume down", "Volume -"), + ("Power", "Alimentatzione"), + ("Telegram bot", "Bot de Telegram"), + ("enable-bot-tip", "Si abilitas custa funtzione, podes retzire su còdighe 2FA dae su bot tuo.\nPodes funtzionare fintzas comente notìfica de connessione."), + ("enable-bot-desc", "1. aberi una tzarrada cun @BotFather.\n2. Inbia su cumandu \"/newbot\", a pustis de àere fatu custu passàgiu as a retzire unu getone.\n3. Incumintza una tzarrada cun su bot tuo creadu como. Imbia unu messàgiu chi incumintzat cun un'istanga (\"/\") a tipu \"/salude\".\n"), + ("cancel-2fa-confirm-tip", "Ses seguru de bòlere annullare sa 2FA?"), + ("cancel-bot-confirm-tip", "Ses seguru de bòlere annullare Telegram?"), + ("About RustDesk", "Informatziones subra de RustDesk"), + ("Send clipboard keystrokes", "Imbia fileras teclas puntas de billete"), + ("network_error_tip", "Controlla sa connessione de rete, e a pustis seletziona 'Torra a proare'."), + ("Unlock with PIN", "Abìlita s'isblocu cun PIN"), + ("Requires at least {} characters", "Bisòngiant a su nessi {} caràteres"), + ("Wrong PIN", "PIN isballiadu"), + ("Set PIN", "Imposta su PIN"), + ("Enable trusted devices", "Abìlita dispositivos fidados"), + ("Manage trusted devices", "Manìgia sos dispositivos fidados"), + ("Platform", "Prataforma"), + ("Days remaining", "Dies chi abarrant"), + ("enable-trusted-devices-tip", "Brinca sa verìfica 2FA in sos dispositivos fidados"), + ("Parent directory", "Cartella printzipale"), + ("Resume", "Sighi"), + ("Invalid file name", "Nùmene archìviu non vàlidu"), + ("one-way-file-transfer-tip", "In s'ala controllada est abilitada sa tràmuda de archìvios a una diretzione ebbia."), + ("Authentication Required", "Dimanda de autenticatzione"), + ("Authenticate", "Autèntica"), + ("web_id_input_tip", "Podes insertare un'ID in su matessi serbidore, in su cliente web no est suportadu s'atzessu cun IP diretu.\nSi boles atzèdere a unu dispositivu in un'àteru serbidore, annanghe s'indiritzu de su serbidore (@?key=), a esèmpiu,\n9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=.\nSi boles intrare a unu dispositivu in unu serbidore pùblicu, inserta \"@public\", non b'at bisòngiu de sa crae pro su serbidore pùblicu."), + ("Download", "Iscàrriga"), + ("Upload folder", "Cartella de carrigamentu"), + ("Upload files", "Carrigamentu de archìvios upload"), + ("Clipboard is synchronized", "Sa punta de billete est sincronizada"), + ("Update client clipboard", "Annoa sa punta de billete de su cliente"), + ("Untagged", "Chene tag"), + ("new-version-of-{}-tip", "B'at una versione noa de {} a disponimentu"), + ("Accessible devices", "Dispositivos atzessìbiles"), + ("upgrade_remote_rustdesk_client_to_{}_tip", "Atualiza su cliente RustDesk remotu a sa versione {} o prus noa!"), + ("d3d_render_tip", "Cando sa renderizatzione D3D est abilitada, s'ischermu de controllu remotu diat pòdere èssere nieddu in unas cantas màchinas"), + ("Use D3D rendering", "Imprea sa renderizatzione D3D"), + ("Printer", "Imprentadora"), + ("printer-os-requirement-tip", "Pro pòdere impreare s'imprentadora in seddida bisòngiat a installare {} in custu dispositivu."), + ("printer-requires-installed-{}-client-tip", "Pro sa funtzionalidade de imprenta in essida b'at bisòngiu de Window 10 o prus nou."), + ("printer-{}-not-installed-tip", "S'imprentadora {} no est installada"), + ("printer-{}-ready-tip", "S'imprentadora {} est installada e pronta pro s'impreu."), + ("Install {} Printer", "Installa s'imprentadora {}"), + ("Outgoing Print Jobs", "Traballos de imprenta in essida"), + ("Incoming Print Jobs", "Traballos de imprenta in intrada"), + ("Incoming Print Job", "Traballu de imprenta in intrada"), + ("use-the-default-printer-tip", "Imprea s'imprentadora predefinida"), + ("use-the-selected-printer-tip", "Imprea s'imprentadora seletzionada"), + ("auto-print-tip", "Imprenta in automàticu impreende s'imprentadora seletzionada."), + ("print-incoming-job-confirm-tip", "As retzidu unu traballu de imprenta dae remotu. Lu boles esecutare dae s'ala tua?"), + ("remote-printing-disallowed-tile-tip", "Imprenta remota disabilitada"), + ("remote-printing-disallowed-text-tip", "Sas impostatziones de sos permissos de s'ala controllada negant s'imprenta remota."), + ("save-settings-tip", "Sarva sas impostatziones"), + ("dont-show-again-tip", "Non mustres prus custu messàgiu"), + ("Take screenshot", "Faghe un'ischermada"), + ("Taking screenshot", "Faghende un'ischermada"), + ("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"), + ("Copy to clipboard", "Còpia in punta de billete"), + ("Enable remote printer", "Abìlita imprentadora remota"), + ("Downloading {}", "Iscarrighende {}"), + ("{} Update", "Atualiza {}"), + ("{}-to-update-tip", "{} s'at a serrare e a installare sa versione nova"), + ("download-new-version-failed-tip", "Iscarrigamentu fallidu.\nPodes torrare a proare o seletzionare 'Iscàrriga' pro iscarrigare e atualizare a manera manuale."), + ("Auto update", "Atualizatzione automàtica"), + ("update-failed-check-msi-tip", "Controllu de sa manera de installatzione fallidu.\nSeletziona 'Iscàrriga' pro iscarrigare su programma e l'atualizare a manera manuale."), + ("websocket_tip", "Cando impreas WebSocket, sunt suportadas petzi sas connessiones de tràmuda relay"), + ("Use WebSocket", "Imprea WebSocket"), + ("Trackpad speed", "Velotzidade de su pannellu tàtile"), + ("Default trackpad speed", "Velotzidade predefinida de su pannellu tàtile"), + ("Numeric one-time password", "Crae numèrica monoimpreu"), + ("Enable IPv6 P2P connection", "Abìlita connessione P2P IPv6"), + ("Enable UDP hole punching", "Abìlita s'istampadura UDP"), + ("View camera", "Mustra sa càmera"), + ("Enable camera", "Abìlita sa càmera"), + ("No cameras", "Peruna càmera"), + ("view_camera_unsupported_tip", "Su dispositivu remotu non suportat sa visualizatzione de sa càmera"), + ("Terminal", "Terminale"), + ("Enable terminal", "Abìlita su terminale"), + ("New tab", "Ischeda noa"), + ("Keep terminal sessions on disconnect", "Cando ti disconnetes mantene aberta sa sessione de terminale"), + ("Terminal (Run as administrator)", "Terminale (imprea comente amministradore)"), + ("terminal-admin-login-tip", "Inserta su nùmene utente e sa crae de intrada de s'amministradore de s'ala controllada."), + ("Failed to get user token.", "Otenimentu de su getone de utente fallidu."), + ("Incorrect username or password.", "Nùmene utente o crae de intrada isballiados."), + ("The user is not an administrator.", "S'utente no est un'amministradore."), + ("Failed to check if the user is an administrator.", "Non faghet a verificare si s'utente est un'amministradore."), + ("Supported only in the installed version.", "Suportadu petzi in sa versione installada."), + ("elevation_username_tip", "Inserta Nùmene utente o domìniu de fonte\\nùmene Utente"), + ("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", ""), + ("allow-insecure-tls-fallback-tip", ""), + ("Disable 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 {}", "Sighi cun {}"), + ("Display Name", ""), + ("password-hidden-tip", ""), + ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), + ].iter().cloned().collect(); +} diff --git a/src/lang/sk.rs b/src/lang/sk.rs index b3c8fddf9..963f48728 100644 --- a/src/lang/sk.rs +++ b/src/lang/sk.rs @@ -41,7 +41,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("length %min% to %max%", "dĺžka medzi %min% a %max%"), ("starts with a letter", "začína písmenom"), ("allowed characters", "povolené znaky"), - ("id_change_tip", "Povolené sú len znaky a-z, A-Z, 0-9 a _ (podčiarkovník). Prvý znak musí byť a-z, A-Z. Dĺžka musí byť medzi 6 a 16 znakmi."), + ("id_change_tip", "Povolené sú len znaky a-z, A-Z, 0-9, - (dash) a _ (podčiarkovník). Prvý znak musí byť a-z, A-Z. Dĺžka musí byť medzi 6 a 16 znakmi."), ("Website", "Webová stránka"), ("About", "O RustDesk"), ("Slogan_tip", "Stvorené srdcom v tomto chaotickom svete!"), @@ -147,8 +147,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("OS Password", "Heslo do operačného systému"), ("install_tip", "V niektorých prípadoch RustDesk nefunguje správne z dôvodu riadenia užívateľských oprávnení (UAC). Vyhnete sa tomu kliknutím na nižšie zobrazene tlačítko a nainštalovaním RuskDesk do systému."), ("Click to upgrade", "Kliknutím nainštalujete aktualizáciu"), - ("Click to download", "Kliknutím potvrďte stiahnutie"), - ("Click to update", "Kliknutím aktualizovať"), ("Configure", "Nastaviť"), ("config_acc", "Aby bolo možné na diaľku ovládať vašu plochu, je potrebné aplikácii RustDesk udeliť práva \"Dostupnosť\"."), ("config_screen", "Aby bolo možné na diaľku sledovať vašu obrazovku, je potrebné aplikácii RustDesk udeliť práva \"Zachytávanie obsahu obrazovky\"."), @@ -267,7 +265,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("No permission of file transfer", "Prenos súborov nie je povolený"), ("Note", "Poznámka"), ("Connection", "Pripojenie"), - ("Share Screen", "Zdielať obrazovku"), + ("Share screen", "Zdielať obrazovku"), ("Chat", "Chat"), ("Total", "Celkom"), ("items", "položiek"), @@ -275,8 +273,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Screen Capture", "Snímanie obrazovky"), ("Input Control", "Ovládanie vstupných zariadení"), ("Audio Capture", "Snímanie zvuku"), - ("File Connection", "Pripojenie súborov"), - ("Screen Connection", "Pripojenie obrazu"), ("Do you accept?", "Súhlasíte?"), ("Open System Setting", "Otvorenie nastavení systému"), ("How to get Android input permission?", "Ako v systéme Android povoliť oprávnenie písať zo vstupného zariadenia?"), @@ -381,14 +377,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Keyboard Settings", "Nastavenia klávesnice"), ("Full Access", "Úplný prístup"), ("Screen Share", "Zdielanie obrazovky"), - ("Wayland requires Ubuntu 21.04 or higher version.", "Wayland vyžaduje Ubuntu 21.04 alebo vyššiu verziu."), - ("Wayland requires higher version of linux distro. Please try X11 desktop or change your OS.", "Wayland vyžaduje vyššiu verziu linuxovej distribúcie. Skúste X11 desktop alebo zmeňte OS."), + ("ubuntu-21-04-required", "Wayland vyžaduje Ubuntu 21.04 alebo vyššiu verziu."), + ("wayland-requires-higher-linux-version", "Wayland vyžaduje vyššiu verziu linuxovej distribúcie. Skúste X11 desktop alebo zmeňte OS."), + ("xdp-portal-unavailable", ""), ("JumpLink", "View"), ("Please Select the screen to be shared(Operate on the peer side).", "Vyberte obrazovku, ktorú chcete zdieľať (Ovládajte na strane partnera)."), ("Show RustDesk", "Zobraziť RustDesk"), ("This PC", "Tento počítač"), ("or", "alebo"), - ("Continue with", "Pokračovať s"), ("Elevate", "Zvýšiť"), ("Zoom cursor", "Kurzor priblíženia"), ("Accept sessions via password", "Prijímanie relácií pomocou hesla"), @@ -463,7 +459,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("empty_favorite_tip", "Ešte nemáte obľúbeného partnera?\nNájdite niekoho, s kým sa môžete spojiť, a pridajte si ho do obľúbených!"), ("empty_lan_tip", "Ale nie, zdá sa, že sme zatiaľ neobjavili žiadnu protistranu."), ("empty_address_book_tip", "Ach bože, zdá sa, že vo vašom adresári momentálne nie sú uvedení žiadni kolegovia."), - ("eg: admin", "napr. admin"), ("Empty Username", "Prázdne používateľské meno"), ("Empty Password", "Prázdne heslo"), ("Me", "Ja"), @@ -653,5 +648,102 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Upload folder", ""), ("Upload files", ""), ("Clipboard is synchronized", ""), + ("Update client clipboard", ""), + ("Untagged", ""), + ("new-version-of-{}-tip", ""), + ("Accessible devices", ""), + ("upgrade_remote_rustdesk_client_to_{}_tip", "Aktualizujte klienta RustDesk na verziu {} alebo novšiu na vzdialenej strane!"), + ("d3d_render_tip", ""), + ("Use D3D rendering", ""), + ("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", ""), + ("Trackpad speed", ""), + ("Default trackpad speed", ""), + ("Numeric one-time password", ""), + ("Enable IPv6 P2P connection", ""), + ("Enable UDP hole punching", ""), + ("View camera", "Zobraziť kameru"), + ("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", ""), + ("allow-insecure-tls-fallback-tip", ""), + ("Disable 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 {}", "Pokračovať s {}"), + ("Display Name", ""), + ("password-hidden-tip", ""), + ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/sl.rs b/src/lang/sl.rs index 20fd24c9c..0f85af0c3 100755 --- a/src/lang/sl.rs +++ b/src/lang/sl.rs @@ -3,7 +3,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = [ ("Status", "Stanje"), ("Your Desktop", "Vaše namizje"), - ("desk_tip", "Do vašega namizja lahko dostopate s spodnjim IDjem in geslom"), + ("desk_tip", "S spodnjim IDjem in geslom omogočite oddaljeni nadzor vašega računalnika"), ("Password", "Geslo"), ("Ready", "Pripravljen"), ("Established", "Povezava vzpostavljena"), @@ -41,7 +41,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("length %min% to %max%", "dolžina od %min% do %max%"), ("starts with a letter", "začne se s črko"), ("allowed characters", "dovoljeni znaki"), - ("id_change_tip", "Dovoljeni znaki so a-z, A-Z (brez šumnikov), 0-9 in _. Prvi znak mora biti črka, dolžina od 6 do 16 znakov."), + ("id_change_tip", "Dovoljeni znaki so a-z, A-Z (brez šumnikov), 0-9, - (dash) in _. Prvi znak mora biti črka, dolžina od 6 do 16 znakov."), ("Website", "Spletna stran"), ("About", "O programu"), ("Slogan_tip", ""), @@ -147,8 +147,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("OS Password", "Geslo operacijskega sistema"), ("install_tip", "Zaradi nadzora uporabniškega računa, RustDesk v nekaterih primerih na oddaljeni strani ne deluje pravilno. Temu se lahko izognete z namestitvijo."), ("Click to upgrade", "Klikni za nadgradnjo"), - ("Click to download", "Klikni za prenos"), - ("Click to update", "Klikni za posodobitev"), ("Configure", "Nastavi"), ("config_acc", "Za oddaljeni nadzor namizja morate RustDesku dodeliti pravico za dostopnost"), ("config_screen", "Za oddaljeni dostop do namizja morate RustDesku dodeliti pravico snemanje zaslona"), @@ -190,7 +188,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Logging in...", "Prijavljanje..."), ("Enable RDP session sharing", "Omogoči deljenje RDP seje"), ("Auto Login", "Samodejna prijava"), - ("Enable direct IP access", "Omogoči neposredni dostop preko IP"), + ("Enable direct IP access", "Omogoči neposredni dostop preko IP naslova"), ("Rename", "Preimenuj"), ("Space", "Prazno"), ("Create desktop shortcut", "Ustvari bližnjico na namizju"), @@ -267,7 +265,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("No permission of file transfer", "Ni pravic za prenos datotek"), ("Note", "Opomba"), ("Connection", "Povezava"), - ("Share Screen", "Deli zaslon"), + ("Share screen", "Deli zaslon"), ("Chat", "Pogovor"), ("Total", "Skupaj"), ("items", "elementi"), @@ -275,8 +273,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Screen Capture", "Zajem zaslona"), ("Input Control", "Nadzor vnosa"), ("Audio Capture", "Zajem zvoka"), - ("File Connection", "Datotečna povezava"), - ("Screen Connection", "Zaslonska povezava"), ("Do you accept?", "Ali sprejmete?"), ("Open System Setting", "Odpri sistemske nastavitve"), ("How to get Android input permission?", "Kako pridobiti dovoljenje za vnos na Androidu?"), @@ -364,7 +360,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Recording", "Snemanje"), ("Directory", "Imenik"), ("Automatically record incoming sessions", "Samodejno snemaj vhodne seje"), - ("Automatically record outgoing sessions", ""), + ("Automatically record outgoing sessions", "Samodejno snemaj odhodne seje"), ("Change", "Spremeni"), ("Start session recording", "Začni snemanje seje"), ("Stop session recording", "Ustavi snemanje seje"), @@ -381,14 +377,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Keyboard Settings", "Nastavitve tipkovnice"), ("Full Access", "Poln dostop"), ("Screen Share", "Deljenje zaslona"), - ("Wayland requires Ubuntu 21.04 or higher version.", "Wayland zahteva Ubuntu 21.04 ali novejši"), - ("Wayland requires higher version of linux distro. Please try X11 desktop or change your OS.", "Zahtevana je novejša različica Waylanda. Posodobite vašo distribucijo ali pa uporabite X11."), + ("ubuntu-21-04-required", "Wayland zahteva Ubuntu 21.04 ali novejši"), + ("wayland-requires-higher-linux-version", "Zahtevana je novejša različica Waylanda. Posodobite vašo distribucijo ali pa uporabite X11."), + ("xdp-portal-unavailable", ""), ("JumpLink", "Pogled"), ("Please Select the screen to be shared(Operate on the peer side).", "Izberite zaslon za delitev (na oddaljeni strani)."), ("Show RustDesk", "Prikaži RustDesk"), ("This PC", "Ta računalnik"), ("or", "ali"), - ("Continue with", "Nadaljuj z"), ("Elevate", "Povzdig pravic"), ("Zoom cursor", "Prilagodi velikost miškinega kazalca"), ("Accept sessions via password", "Sprejmi seje z geslom"), @@ -412,8 +408,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Select local keyboard type", "Izberite lokalno vrsto tipkovnice"), ("software_render_tip", "Če na Linuxu uporabljate Nvidino grafično kartico in se oddaljeno okno zapre takoj po vzpostavitvi povezave, lahko pomaga preklop na odprtokodni gonilnik Nouveau in uporaba programskega upodabljanja. Potreben je ponovni zagon programa."), ("Always use software rendering", "Vedno uporabi programsko upodabljanje"), - ("config_input", "Za nadzor oddaljenega namizja s tipkovnico, rabi RustDesk pravico »Nadzor vnosa«."), - ("config_microphone", "Za zajem zvoka, rabi RustDesk pravico »Snemanje zvoka«."), + ("config_input", "RustDesk potrebuje pravico »Nadzor vnosa« za nadzor oddaljenega namizja s tipkovnico."), + ("config_microphone", "RustDesk potrebuje pravico »Snemanje zvoka« za zajemanje zvoka."), ("request_elevation_tip", "Lahko tudi zaprosite za dvig pravic, če je kdo na oddaljeni strani."), ("Wait", "Čakaj"), ("Elevation Error", "Napaka pri povzdigovanju"), @@ -446,7 +442,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Voice call", "Glasovni klic"), ("Text chat", "Besedilni klepet"), ("Stop voice call", "Prekini glasovni klic"), - ("relay_hint_tip", "Morda neposredna povezava ni možna; lahko se poikusite povezati preko posrednika. Če želite uporabiti posrednika ob prvem poizkusu vzpotavljanja povezave, lahko na konec IDja dodate »/r«, ali pa izberete možnost »Vedno poveži preko posrednika« v kartici nedavnih sej, če le-ta obstja."), + ("relay_hint_tip", "Morda neposredna povezava ni možna; lahko se poizkusite povezati preko posrednika. Če želite uporabiti posrednika ob prvem poizkusu vzpotavljanja povezave, lahko na konec IDja dodate »/r«, ali pa izberete možnost »Vedno poveži preko posrednika« v kartici nedavnih sej, če le-ta obstja."), ("Reconnect", "Ponovna povezava"), ("Codec", "Kodek"), ("Resolution", "Ločljivost"), @@ -463,7 +459,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("empty_favorite_tip", "Nimate še priljubljenih partnerjev?\nVzpostavite povezavo, in jo dodajte med priljubljene."), ("empty_lan_tip", "Nismo našli še nobenih partnerjev."), ("empty_address_book_tip", "Vaš adresar je prazen."), - ("eg: admin", "npr. admin"), ("Empty Username", "Prazno uporabniško ime"), ("Empty Password", "Prazno geslo"), ("Me", "Jaz"), @@ -649,9 +644,106 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Authentication Required", "Potrebno je preverjanje pristnosti"), ("Authenticate", "Preverjanje pristnosti"), ("web_id_input_tip", "Vnesete lahko ID iz istega strežnika, neposredni dostop preko IP naslova v spletnem odjemalcu ni podprt.\nČe želite dostopati do naprave na drugem strežniku, pripnite naslov strežnika (@?key=), npr. 9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=.\nČe želite dostopati do naprave na javnem strežniku, vnesite »@public«; ključ za javni strežnik ni potreben."), - ("Download", ""), - ("Upload folder", ""), - ("Upload files", ""), - ("Clipboard is synchronized", ""), + ("Download", "Prenos"), + ("Upload folder", "Naloži mapo"), + ("Upload files", "Naloži datoteke"), + ("Clipboard is synchronized", "Odložišče je usklajeno"), + ("Update client clipboard", "Osveži odjemalčevo odložišče"), + ("Untagged", "Neoznačeno"), + ("new-version-of-{}-tip", "Na voljo je nova različica {}"), + ("Accessible devices", ""), + ("upgrade_remote_rustdesk_client_to_{}_tip", "Prosimo, nadgradite RustDesk odjemalec na različico {} ali novejšo na oddaljeni strani."), + ("d3d_render_tip", ""), + ("Use D3D rendering", ""), + ("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", ""), + ("Trackpad speed", ""), + ("Default trackpad speed", ""), + ("Numeric one-time password", ""), + ("Enable IPv6 P2P connection", ""), + ("Enable UDP hole punching", ""), + ("View camera", "Pogled kamere"), + ("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", ""), + ("allow-insecure-tls-fallback-tip", ""), + ("Disable 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 {}", "Nadaljuj z {}"), + ("Display Name", ""), + ("password-hidden-tip", ""), + ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/sq.rs b/src/lang/sq.rs index 7c63c8ea5..7c965cd45 100644 --- a/src/lang/sq.rs +++ b/src/lang/sq.rs @@ -41,7 +41,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("length %min% to %max%", ""), ("starts with a letter", ""), ("allowed characters", ""), - ("id_change_tip", "Lejohen Vetëm karkteret a-z,A-Z,0-9 dhe _(nënvizimet).Shkronja e parë duhet të jetë a-z, A-Z. Gjatesia midis 6 dhe 16."), + ("id_change_tip", "Lejohen Vetëm karkteret a-z,A-Z,0-9, - (dash) dhe _(nënvizimet).Shkronja e parë duhet të jetë a-z, A-Z. Gjatesia midis 6 dhe 16."), ("Website", "Faqe ëebi"), ("About", "Rreth"), ("Slogan_tip", ""), @@ -147,8 +147,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("OS Password", "OS fjalëkalim"), ("install_tip", "Për shkak të UAC, RustDesk nuk mund të punoj sic duhet si nje remote në distancë në disa raste. Për të shamngur UAC, ju lutem klikoni butonin më poshtë për të instaluar RustDesk në sistem."), ("Click to upgrade", "Klikoni për përmirësim"), - ("Click to download", "Klikoni për tu shkarkuar"), - ("Click to update", "Klikoni për përditësim"), ("Configure", "Koniguro"), ("config_acc", "Për të kontrolluar Desktopin tuaj nga distanca, duhet të jepni leje RustDesk \"Aksesueshmëri\"."), ("config_screen", "Për të aksesuar Desktopin tuaj nga distanca, duhet ti jepni lejet RustDesk \"Regjistrimin e ekranit\"."), @@ -267,7 +265,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("No permission of file transfer", "Nuk ka leje për transferimin e dosjesve"), ("Note", "Shënime"), ("Connection", "Lidhja"), - ("Share Screen", "Ndaj ekranin"), + ("Share screen", "Ndaj ekranin"), ("Chat", "Biseda"), ("Total", "Total"), ("items", "artikuj"), @@ -275,8 +273,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Screen Capture", "Kapja e ekranit"), ("Input Control", "Kontrollo inputin"), ("Audio Capture", "Kapja e zërit"), - ("File Connection", "Lidhja e skedarëve"), - ("Screen Connection", "Lidhja e ekranit"), ("Do you accept?", "E pranoni"), ("Open System Setting", "Hapni cilësimet e sistemit"), ("How to get Android input permission?", "Si të merrni leje e inputit të Android"), @@ -381,14 +377,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Keyboard Settings", "Cilësimet e tastierës"), ("Full Access", "Qasje e plotë"), ("Screen Share", "Ndarja e ekranit"), - ("Wayland requires Ubuntu 21.04 or higher version.", "Wayland kërkon Ubuntu 21.04 ose version më të lartë"), - ("Wayland requires higher version of linux distro. Please try X11 desktop or change your OS.", "Wayland kërkon një version më të lartë të shpërndarjes linux. Ju lutemi provoni desktopin X11 ose ndryshoni OS."), + ("ubuntu-21-04-required", "Wayland kërkon Ubuntu 21.04 ose version më të lartë"), + ("wayland-requires-higher-linux-version", "Wayland kërkon një version më të lartë të shpërndarjes linux. Ju lutemi provoni desktopin X11 ose ndryshoni OS."), + ("xdp-portal-unavailable", ""), ("JumpLink", "JumpLink"), ("Please Select the screen to be shared(Operate on the peer side).", "Ju lutemi zgjidhni ekranin që do të ndahet (Vepro në anën e kolegëve"), ("Show RustDesk", "Shfaq RustDesk"), ("This PC", "Ky PC"), ("or", "ose"), - ("Continue with", "Vazhdo me"), ("Elevate", "Ngritja"), ("Zoom cursor", "Zmadho kursorin"), ("Accept sessions via password", "Prano sesionin nëpërmjet fjalëkalimit"), @@ -463,7 +459,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("empty_favorite_tip", ""), ("empty_lan_tip", ""), ("empty_address_book_tip", ""), - ("eg: admin", ""), ("Empty Username", ""), ("Empty Password", ""), ("Me", ""), @@ -653,5 +648,102 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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", ""), + ("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", ""), + ("Trackpad speed", ""), + ("Default trackpad speed", ""), + ("Numeric one-time password", ""), + ("Enable IPv6 P2P connection", ""), + ("Enable UDP hole punching", ""), + ("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", ""), + ("allow-insecure-tls-fallback-tip", ""), + ("Disable 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 {}", "Vazhdo me {}"), + ("Display Name", ""), + ("password-hidden-tip", ""), + ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/sr.rs b/src/lang/sr.rs index e80bb6181..fc33e4671 100644 --- a/src/lang/sr.rs +++ b/src/lang/sr.rs @@ -41,7 +41,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("length %min% to %max%", ""), ("starts with a letter", ""), ("allowed characters", ""), - ("id_change_tip", "Dozvoljeni su samo a-z, A-Z, 0-9 i _ (donja crta) znakovi. Prvi znak mora biti slovo a-z, A-Z. Dužina je od 6 do 16."), + ("id_change_tip", "Dozvoljeni su samo a-z, A-Z, 0-9, - (dash) i _ (donja crta) znakovi. Prvi znak mora biti slovo a-z, A-Z. Dužina je od 6 do 16."), ("Website", "Web sajt"), ("About", "O programu"), ("Slogan_tip", ""), @@ -147,8 +147,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("OS Password", "OS lozinka"), ("install_tip", "Zbog UAC RustDesk ne može raditi pravilno u nekim slučajevima. Da biste prevazišli UAC, kliknite taster ispod da instalirate RustDesk na sistem."), ("Click to upgrade", "Klik za nadogradnju"), - ("Click to download", "Klik za preuzimanje"), - ("Click to update", "Klik za ažuriranje"), ("Configure", "Konfigurisanje"), ("config_acc", "Da biste daljinski kontrolisali radnu površinu, RustDesk-u treba da dodelite \"Accessibility\" prava."), ("config_screen", "Da biste daljinski pristupili radnoj površini, RustDesk-u treba da dodelite \"Screen Recording\" prava."), @@ -267,7 +265,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("No permission of file transfer", "Nemate pravo prenosa datoteka"), ("Note", "Primedba"), ("Connection", "Konekcija"), - ("Share Screen", "Podeli ekran"), + ("Share screen", "Podeli ekran"), ("Chat", "Dopisivanje"), ("Total", "Ukupno"), ("items", "stavki"), @@ -275,8 +273,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Screen Capture", "Snimanje ekrana"), ("Input Control", "Kontrola unosa"), ("Audio Capture", "Snimanje zvuka"), - ("File Connection", "Spajanje preko datoteke"), - ("Screen Connection", "Podeli konekciju"), ("Do you accept?", "Prihvatate?"), ("Open System Setting", "Postavke otvorenog sistema"), ("How to get Android input permission?", "Kako dobiti pristup za Android unos?"), @@ -381,14 +377,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Keyboard Settings", "Postavke tastature"), ("Full Access", "Pun pristup"), ("Screen Share", "Deljenje ekrana"), - ("Wayland requires Ubuntu 21.04 or higher version.", "Wayland zahteva Ubuntu 21.04 ili veću verziju"), - ("Wayland requires higher version of linux distro. Please try X11 desktop or change your OS.", "Wayland zahteva veću verziju Linux distribucije. Molimo pokušajte X11 ili promenite OS."), + ("ubuntu-21-04-required", "Wayland zahteva Ubuntu 21.04 ili veću verziju"), + ("wayland-requires-higher-linux-version", "Wayland zahteva veću verziju Linux distribucije. Molimo pokušajte X11 ili promenite OS."), + ("xdp-portal-unavailable", ""), ("JumpLink", "Vidi"), ("Please Select the screen to be shared(Operate on the peer side).", "Molimo izaberite ekran koji će biti podeljen (Za rad na klijent strani)"), ("Show RustDesk", "Prikazi RustDesk"), ("This PC", "Ovaj PC"), ("or", "ili"), - ("Continue with", "Nastavi sa"), ("Elevate", "Izdigni"), ("Zoom cursor", "Zumiraj kursor"), ("Accept sessions via password", "Prihvati sesije preko lozinke"), @@ -463,7 +459,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("empty_favorite_tip", ""), ("empty_lan_tip", ""), ("empty_address_book_tip", ""), - ("eg: admin", ""), ("Empty Username", ""), ("Empty Password", ""), ("Me", ""), @@ -653,5 +648,102 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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", ""), + ("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", ""), + ("Trackpad speed", ""), + ("Default trackpad speed", ""), + ("Numeric one-time password", ""), + ("Enable IPv6 P2P connection", ""), + ("Enable UDP hole punching", ""), + ("View camera", "Pregled kamere"), + ("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", ""), + ("allow-insecure-tls-fallback-tip", ""), + ("Disable 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 {}", "Nastavi sa {}"), + ("Display Name", ""), + ("password-hidden-tip", ""), + ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/sv.rs b/src/lang/sv.rs index dae48e7a3..664dc4745 100644 --- a/src/lang/sv.rs +++ b/src/lang/sv.rs @@ -38,18 +38,18 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Stop service", "Avsluta tjänsten"), ("Change ID", "Byt ID"), ("Your new ID", "Ditt nya ID"), - ("length %min% to %max%", ""), - ("starts with a letter", ""), - ("allowed characters", ""), - ("id_change_tip", "Bara a-z, A-Z, 0-9 och _ (understräck) tecken är tillåtna. Den första bokstaven måste vara a-z, A-Z. Längd mellan 6 och 16."), + ("length %min% to %max%", "längd %min% till %max%"), + ("starts with a letter", "börjar med en bokstav"), + ("allowed characters", "tillåtna tecken"), + ("id_change_tip", "Bara a-z, A-Z, 0-9, - (dash) och _ (understräck) tecken är tillåtna. Den första bokstaven måste vara a-z, A-Z. Längd mellan 6 och 16."), ("Website", "Hemsida"), ("About", "Om"), ("Slogan_tip", ""), - ("Privacy Statement", ""), + ("Privacy Statement", "Integritetspolicy"), ("Mute", "Tyst"), ("Build Date", ""), - ("Version", ""), - ("Home", ""), + ("Version", "Version"), + ("Home", "Hem"), ("Audio Input", "Ljud input"), ("Enhancements", "Förbättringar"), ("Hardware Codec", "Hårdvarucodec"), @@ -147,8 +147,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("OS Password", "OS lösenord"), ("install_tip", "På grund av UAC, kan inte RustDesk fungera ordentligt på klientsidan. För att undvika problem med UAC, tryck på knappen nedan för att installera RustDesk på systemet."), ("Click to upgrade", "Klicka för att nedgradera"), - ("Click to download", "Klicka för att ladda ner"), - ("Click to update", "Klicka för att uppdatera"), ("Configure", "Konfigurera"), ("config_acc", "För att kontrollera din dator på distans måste du ge RustDesk \"Tillgänglighets\" rättigheter."), ("config_screen", "För att kontrollera din dator på distans måste du ge RustDesk \"Skärminspelnings\" rättigheter."), @@ -218,7 +216,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Remember me", "Kom ihåg mig"), ("Trust this device", "Lita på denna enhet"), ("Verification code", "Verifikationskod"), - ("verification_tip", ""), + ("verification_tip", "verifikation_tips"), ("Logout", "Logga ut"), ("Tags", "Taggar"), ("Search ID", "Sök ID"), @@ -230,7 +228,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Username missed", "Användarnamn saknas"), ("Password missed", "Lösenord saknas"), ("Wrong credentials", "Fel användarnamn eller lösenord"), - ("The verification code is incorrect or has expired", ""), + ("The verification code is incorrect or has expired", "Verifikationskoden är felaktig eller har löpt ut"), ("Edit Tag", "Ändra Tagg"), ("Forget Password", "Glöm lösenord"), ("Favorites", "Favoriter"), @@ -267,7 +265,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("No permission of file transfer", "Rättigheter saknas"), ("Note", "Notering"), ("Connection", "Anslutning"), - ("Share Screen", "Dela skärm"), + ("Share screen", "Dela skärm"), ("Chat", "Chatt"), ("Total", "Totalt"), ("items", "föremål"), @@ -275,8 +273,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Screen Capture", "Skärminspelning"), ("Input Control", "Inputkontroll"), ("Audio Capture", "Ljudinspelning"), - ("File Connection", "Fil anslutning"), - ("Screen Connection", "Skärm anslutning"), ("Do you accept?", "Accepterar du?"), ("Open System Setting", "Öppna systeminställnig"), ("How to get Android input permission?", "Hur får man Android rättigheter?"), @@ -286,7 +282,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("android_service_will_start_tip", "Sätter du på \"skärminspelning\" kommer tjänsten automatiskt att starta. Detta tillåter andra enheter att kontrollera din enhet."), ("android_stop_service_tip", "Genom att stänga av tjänsten kommer alla enheter att kopplas ifrån."), ("android_version_audio_tip", "Din version av Android stödjer inte ljudinspelning, Android 10 eller nyare krävs"), - ("android_start_service_tip", ""), + ("android_start_service_tip", "android_start_service_tips"), ("android_permission_may_not_change_tip", ""), ("Account", "Konto"), ("Overwrite", "Skriv över"), @@ -306,8 +302,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Keep RustDesk background service", "Behåll RustDesk i bakgrunden"), ("Ignore Battery Optimizations", "Ignorera batterioptimering"), ("android_open_battery_optimizations_tip", "Om du vill stänga av denna funktion, gå till nästa RustDesk programs inställningar, hitta [Batteri], Checka ur [Obegränsad]"), - ("Start on boot", ""), - ("Start the screen sharing service on boot, requires special permissions", ""), + ("Start on boot", "Starta vid uppstart"), + ("Start the screen sharing service on boot, requires special permissions", "Starta skärmdelningstjänsten vid uppstart, kräver särskilda rättigheter"), ("Connection not allowed", "Anslutning ej tillåten"), ("Legacy mode", "Legacy mode"), ("Map mode", "Kartläge"), @@ -330,8 +326,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Ratio", "Ratio"), ("Image Quality", "Bildkvalitet"), ("Scroll Style", "Scrollstil"), - ("Show Toolbar", ""), - ("Hide Toolbar", ""), + ("Show Toolbar", "Visa verktygsfältet"), + ("Hide Toolbar", "Dölj verktygsfältet"), ("Direct Connection", "Direktanslutning"), ("Relay Connection", "Relayanslutning"), ("Secure Connection", "Säker anslutning"), @@ -342,7 +338,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Security", "Säkerhet"), ("Theme", "Tema"), ("Dark Theme", "Mörkt tema"), - ("Light Theme", ""), + ("Light Theme", "Ljust tema"), ("Dark", "Mörk"), ("Light", "Ljus"), ("Follow System", "Följ system"), @@ -359,12 +355,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Audio Input Device", "Inmatningsenhet för ljud"), ("Use IP Whitelisting", "Använd IP-Vitlistning"), ("Network", "Nätverk"), - ("Pin Toolbar", ""), - ("Unpin Toolbar", ""), + ("Pin Toolbar", "Fäst verktygsfältet"), + ("Unpin Toolbar", "Ta bort verktygsfältet"), ("Recording", "Spelar in"), ("Directory", "Katalog"), ("Automatically record incoming sessions", "Spela in inkommande sessioner automatiskt"), - ("Automatically record outgoing sessions", ""), + ("Automatically record outgoing sessions", "Spela in utgående sessioner automatiskt"), ("Change", "Byt"), ("Start session recording", "Starta inspelning"), ("Stop session recording", "Avsluta inspelning"), @@ -381,14 +377,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Keyboard Settings", "Tangentbordsinställningar"), ("Full Access", "Full tillgång"), ("Screen Share", "Skärmdelning"), - ("Wayland requires Ubuntu 21.04 or higher version.", "Wayland kräver Ubuntu 21.04 eller högre."), - ("Wayland requires higher version of linux distro. Please try X11 desktop or change your OS.", "Wayland kräver en högre version av linux. Försök igen eller byt OS."), + ("ubuntu-21-04-required", "Wayland kräver Ubuntu 21.04 eller högre."), + ("wayland-requires-higher-linux-version", "Wayland kräver en högre version av linux. Försök igen eller byt OS."), + ("xdp-portal-unavailable", ""), ("JumpLink", "JumpLink"), ("Please Select the screen to be shared(Operate on the peer side).", "Välj skärm att dela"), ("Show RustDesk", "Visa RustDesk"), ("This PC", "Denna dator"), ("or", "eller"), - ("Continue with", "Fortsätt med"), ("Elevate", "Höj upp"), ("Zoom cursor", "Zoom"), ("Accept sessions via password", "Acceptera sessioner via lösenord"), @@ -402,79 +398,78 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Hide connection management window", "Göm hanteringsfönster"), ("hide_cm_tip", "Tillåt att gömma endast om accepterande sessioner med lösenord och permanenta lösenord"), ("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", ""), + ("Right click to select tabs", "Högerklicka för att välja flikar"), + ("Skipped", "Hoppade över"), + ("Add to address book", "Lägg till i adressboken"), + ("Group", "Grupp"), + ("Search", "Sök"), + ("Closed manually by web console", "Stängt manuellt av webkonsolen"), + ("Local keyboard type", "Lokal tangentbordstyp"), + ("Select local keyboard type", "Välj lokal tangentbordstyp"), ("software_render_tip", ""), - ("Always use software rendering", ""), + ("Always use software rendering", "Använd alltid mjukvarurendering"), ("config_input", ""), ("config_microphone", ""), ("request_elevation_tip", ""), - ("Wait", ""), + ("Wait", "Vänta"), ("Elevation Error", ""), - ("Ask the remote user for authentication", ""), - ("Choose this if the remote account is administrator", ""), - ("Transmit the username and password of administrator", ""), + ("Ask the remote user for authentication", "Fråga fjärranvändaren för autentisering"), + ("Choose this if the remote account is administrator", "Välj detta om fjärrkontot är administratör"), + ("Transmit the username and password of administrator", "Skicka administratörens användarnamn och lösenord"), ("still_click_uac_tip", ""), ("Request Elevation", ""), ("wait_accept_uac_tip", ""), ("Elevate successfully", ""), - ("uppercase", ""), - ("lowercase", ""), - ("digit", ""), - ("special character", ""), - ("length>=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", ""), + ("uppercase", "versal"), + ("lowercase", "gemen"), + ("digit", "siffra"), + ("special character", "specialtecken"), + ("length>=8", "längd>=8"), + ("Weak", "Svag"), + ("Medium", "Medium"), + ("Strong", "Stark"), + ("Switch Sides", "Byt sidor"), + ("Please confirm if you want to share your desktop?", "Vänligen bekräfta att du vill dela ditt skrivbord?"), + ("Display", "Display"), + ("Default View Style", "Standardvisningsstil"), + ("Default Scroll Style", "Standardscrollstil"), + ("Default Image Quality", "Standardbildkvalitet"), + ("Default Codec", "Standard Kodek"), + ("Bitrate", "Bithastighet"), + ("FPS", "FPS"), + ("Auto", "Auto"), + ("Other Default Options", "Andra Standardinställningar"), + ("Voice call", "Röstsamtal"), + ("Text chat", "Meddelandechatt"), + ("Stop voice call", "Stoppa röstsamtal"), ("relay_hint_tip", ""), - ("Reconnect", ""), - ("Codec", ""), - ("Resolution", ""), - ("No transfers in progress", ""), - ("Set one-time password length", ""), - ("RDP Settings", ""), - ("Sort by", ""), - ("New Connection", ""), - ("Restore", ""), - ("Minimize", ""), - ("Maximize", ""), - ("Your Device", ""), + ("Reconnect", "Återanslut"), + ("Codec", "Kodek"), + ("Resolution", "Upplösning"), + ("No transfers in progress", "Inga överförningar pågår"), + ("Set one-time password length", "Ställ in engångslösenordets längd"), + ("RDP Settings", "RDP inställningar"), + ("Sort by", "Sortera efter"), + ("New Connection", "Ny Anslutning"), + ("Restore", "Återställ"), + ("Minimize", "Minimera"), + ("Maximize", "Maximera"), + ("Your Device", "Din Enhet"), ("empty_recent_tip", ""), ("empty_favorite_tip", ""), ("empty_lan_tip", ""), ("empty_address_book_tip", ""), - ("eg: admin", ""), - ("Empty Username", ""), - ("Empty Password", ""), - ("Me", ""), + ("Empty Username", "Tomt användarnamn"), + ("Empty Password", "Tomt lösenord"), + ("Me", "Jag"), ("identical_file_tip", ""), ("show_monitors_tip", ""), - ("View Mode", ""), + ("View Mode", "Visningsläge"), ("login_linux_tip", ""), ("verify_rustdesk_password_tip", ""), ("remember_account_tip", ""), ("os_account_desk_tip", ""), - ("OS Account", ""), + ("OS Account", "OS-konto"), ("another_user_login_title_tip", ""), ("another_user_login_text_tip", ""), ("xorg_not_found_title_tip", ""), @@ -482,176 +477,273 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("no_desktop_title_tip", ""), ("no_desktop_text_tip", ""), ("No need to elevate", ""), - ("System Sound", ""), - ("Default", ""), - ("New RDP", ""), - ("Fingerprint", ""), - ("Copy Fingerprint", ""), - ("no fingerprints", ""), + ("System Sound", "Systemljud"), + ("Default", "Standard"), + ("New RDP", "Ny RDP"), + ("Fingerprint", "Fingeravtryck"), + ("Copy Fingerprint", "Kopiera fingeravtryck"), + ("no fingerprints", "inga fingeravtryck"), ("Select a peer", ""), ("Select peers", ""), - ("Plugins", ""), - ("Uninstall", ""), - ("Update", ""), - ("Enable", ""), - ("Disable", ""), - ("Options", ""), + ("Plugins", "Plugin"), + ("Uninstall", "Avinstallera"), + ("Update", "Uppdatera"), + ("Enable", "Aktivera"), + ("Disable", "Inaktivera"), + ("Options", "Inställningar"), ("resolution_original_tip", ""), ("resolution_fit_local_tip", ""), ("resolution_custom_tip", ""), - ("Collapse toolbar", ""), + ("Collapse toolbar", "Komprimera verktygsfältet"), ("Accept and Elevate", ""), ("accept_and_elevate_btn_tooltip", ""), ("clipboard_wait_response_timeout_tip", ""), - ("Incoming connection", ""), - ("Outgoing connection", ""), - ("Exit", ""), - ("Open", ""), + ("Incoming connection", "Inkommande anslutning"), + ("Outgoing connection", "Utgående anslutning"), + ("Exit", "Stäng"), + ("Open", "Öppna"), ("logout_tip", ""), - ("Service", ""), - ("Start", ""), - ("Stop", ""), + ("Service", "Tjänst"), + ("Start", "Start"), + ("Stop", "Stopp"), ("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", ""), + ("Sync with recent sessions", "Synkronisera med senaste sessioner"), + ("Sort tags", "Sortera taggar"), + ("Open connection in new tab", "Öppna anslutning i ny flik"), + ("Move tab to new window", "Flytta flik till nytt fönster"), + ("Can not be empty", "Kan ej vara tom"), + ("Already exists", "Existerar redan"), + ("Change Password", "Byt lösenord"), + ("Refresh Password", "Uppdatera lösenord"), + ("ID", "ID"), + ("Grid View", "Rutnätsvy"), + ("List View", "Listvy"), + ("Select", "Välj"), + ("Toggle Tags", "Växla flikar"), ("pull_ab_failed_tip", ""), ("push_ab_failed_tip", ""), ("synced_peer_readded_tip", ""), - ("Change Color", ""), - ("Primary Color", ""), - ("HSV Color", ""), - ("Installation Successful!", ""), - ("Installation failed!", ""), - ("Reverse mouse wheel", ""), - ("{} sessions", ""), + ("Change Color", "Byt färg"), + ("Primary Color", "Primärfärg"), + ("HSV Color", "HSV färg"), + ("Installation Successful!", "Installationen lyckades!"), + ("Installation failed!", "Installationen misslyckades!"), + ("Reverse mouse wheel", "Ändra riktning för scrollhjulet"), + ("{} sessions", "{} sessioner"), ("scam_title", ""), ("scam_text1", ""), ("scam_text2", ""), - ("Don't show again", ""), - ("I Agree", ""), - ("Decline", ""), - ("Timeout in minutes", ""), + ("Don't show again", "Visa inte igen"), + ("I Agree", "Jag godkänner"), + ("Decline", "Avböj"), + ("Timeout in minutes", "Timeout i minuter"), ("auto_disconnect_option_tip", ""), - ("Connection failed due to inactivity", ""), - ("Check for software update on startup", ""), + ("Connection failed due to inactivity", "Anslutningen misslyckades på grund av inaktivitet"), + ("Check for software update on startup", "Kolla efter mjukvaruuppdateringar vid start"), ("upgrade_rustdesk_server_pro_to_{}_tip", ""), ("pull_group_failed_tip", ""), ("Filter by intersection", ""), - ("Remove wallpaper during incoming sessions", ""), - ("Test", ""), + ("Remove wallpaper during incoming sessions", "Dölj bakgrunden vid inkommande sessioner"), + ("Test", "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", ""), + ("No displays", "Inga skärmar"), + ("Open in new window", "Öppna i nytt fönster"), + ("Show displays as individual windows", "Visa skärmar som enskilda fönster"), + ("Use all my displays for the remote session", "Använd alla mina skärmar för fjärrsessionen"), ("selinux_tip", ""), - ("Change view", ""), - ("Big tiles", ""), - ("Small tiles", ""), - ("List", ""), - ("Virtual display", ""), - ("Plug out all", ""), - ("True color (4:4:4)", ""), - ("Enable blocking user input", ""), + ("Change view", "Byt vy"), + ("Big tiles", "Stora rutor"), + ("Small tiles", "Små rutor"), + ("List", "Lista"), + ("Virtual display", "Virtuell skärm"), + ("Plug out all", "Koppla ur alla"), + ("True color (4:4:4)", "Sann färg (4:4:4)"), + ("Enable blocking user input", "Aktivera blockering av användarinmatning"), ("id_input_tip", ""), ("privacy_mode_impl_mag_tip", ""), ("privacy_mode_impl_virtual_display_tip", ""), - ("Enter privacy mode", ""), - ("Exit privacy mode", ""), + ("Enter privacy mode", "Aktivera privatläge"), + ("Exit privacy mode", "Inaktivera privatläge"), ("idd_not_support_under_win10_2004_tip", ""), ("input_source_1_tip", ""), ("input_source_2_tip", ""), - ("Swap control-command key", ""), + ("Swap control-command key", "Byt control-command knapp"), ("swap-left-right-mouse", ""), - ("2FA code", ""), - ("More", ""), + ("2FA code", "Tvåstegsverifieringskod"), + ("More", "Mer"), ("enable-2fa-title", ""), ("enable-2fa-desc", ""), ("wrong-2fa-code", ""), ("enter-2fa-title", ""), - ("Email verification code must be 6 characters.", ""), - ("2FA code must be 6 digits.", ""), - ("Multiple Windows sessions found", ""), - ("Please select the session you want to connect to", ""), + ("Email verification code must be 6 characters.", "Mailverifikationskoden måste vara 6 tecken."), + ("2FA code must be 6 digits.", "Tvåstegsverifikationskoden måste vara 6 siffor."), + ("Multiple Windows sessions found", "Flera Windows sessioner hittades"), + ("Please select the session you want to connect to", "Välj den session du vill ansluta till"), ("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", ""), + ("Security Alert", "Säkerhetsvarning"), + ("My address book", "Min adressbok"), + ("Personal", "Personlig"), + ("Owner", "Ägare"), + ("Set shared password", "Välj delat lösenord"), + ("Exist in", "Existerar i"), + ("Read-only", "Skrivskyddad"), + ("Read/Write", "Läs/Skriv"), + ("Full Control", "Full kontroll"), ("share_warning_tip", ""), - ("Everyone", ""), + ("Everyone", "Alla"), ("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", ""), + ("Follow remote cursor", "Följ fjärrpekaren"), + ("Follow remote window focus", "Följ fjärrfönstrets fokus"), ("default_proxy_tip", ""), ("no_audio_input_device_tip", ""), - ("Incoming", ""), - ("Outgoing", ""), - ("Clear Wayland screen selection", ""), + ("Incoming", "Inkommande"), + ("Outgoing", "Utgående"), + ("Clear Wayland screen selection", "Rensa wayland-skärmens val"), ("clear_Wayland_screen_selection_tip", ""), ("confirm_clear_Wayland_screen_selection_tip", ""), ("android_new_voice_call_tip", ""), ("texture_render_tip", ""), - ("Use texture rendering", ""), - ("Floating window", ""), + ("Use texture rendering", "Använd texturrendering"), + ("Floating window", "Flytande fönster"), ("floating_window_tip", ""), - ("Keep screen on", ""), - ("Never", ""), + ("Keep screen on", "Behåll skärmen på"), + ("Never", "Aldrig"), ("During controlled", ""), - ("During service is on", ""), - ("Capture screen using DirectX", ""), - ("Back", ""), - ("Apps", ""), - ("Volume up", ""), - ("Volume down", ""), - ("Power", ""), - ("Telegram bot", ""), + ("During service is on", "Medan tjänsten är på"), + ("Capture screen using DirectX", "Spela in skärmen med DirectX"), + ("Back", "Bak"), + ("Apps", "Appar"), + ("Volume up", "Volym upp"), + ("Volume down", "Volym ner"), + ("Power", "Strömbrytare"), + ("Telegram bot", "Telegram bot"), ("enable-bot-tip", ""), ("enable-bot-desc", ""), ("cancel-2fa-confirm-tip", ""), ("cancel-bot-confirm-tip", ""), - ("About RustDesk", ""), - ("Send clipboard keystrokes", ""), + ("About RustDesk", "Om RustDesk"), + ("Send clipboard keystrokes", "Skicka knappkombination för urklipp"), ("network_error_tip", ""), - ("Unlock with PIN", ""), - ("Requires at least {} characters", ""), - ("Wrong PIN", ""), - ("Set PIN", ""), - ("Enable trusted devices", ""), - ("Manage trusted devices", ""), - ("Platform", ""), - ("Days remaining", ""), + ("Unlock with PIN", "Lås upp med PIN"), + ("Requires at least {} characters", "Kräver minst {} tecken}"), + ("Wrong PIN", "Fel PIN"), + ("Set PIN", "Välj PIN"), + ("Enable trusted devices", "Tillåt betrodda enheter"), + ("Manage trusted devices", "Hantera betrodda enheter"), + ("Platform", "Plattform"), + ("Days remaining", "Dagar kvar"), ("enable-trusted-devices-tip", ""), - ("Parent directory", ""), - ("Resume", ""), - ("Invalid file name", ""), + ("Parent directory", "Föräldrakatalog"), + ("Resume", "Återuppta"), + ("Invalid file name", "Felaktigt filnamn"), ("one-way-file-transfer-tip", ""), - ("Authentication Required", ""), - ("Authenticate", ""), + ("Authentication Required", "Autentisering krävs"), + ("Authenticate", "Autentisera"), ("web_id_input_tip", ""), - ("Download", ""), - ("Upload folder", ""), - ("Upload files", ""), - ("Clipboard is synchronized", ""), + ("Download", "Ladda ner"), + ("Upload folder", "Ladda upp mapp"), + ("Upload files", "Ladda upp filer"), + ("Clipboard is synchronized", "Urklippet är synkroniserat"), + ("Update client clipboard", "Uppdatera klientens urklipp"), + ("Untagged", "Otaggad"), + ("new-version-of-{}-tip", ""), + ("Accessible devices", "Tillgängliga enheter"), + ("upgrade_remote_rustdesk_client_to_{}_tip", ""), + ("d3d_render_tip", ""), + ("Use D3D rendering", "Använd D3D rendering"), + ("Printer", "Skrivarer"), + ("printer-os-requirement-tip", ""), + ("printer-requires-installed-{}-client-tip", ""), + ("printer-{}-not-installed-tip", ""), + ("printer-{}-ready-tip", ""), + ("Install {} Printer", "Installera {} skrivare"), + ("Outgoing Print Jobs", "Utgående skrivarjobb"), + ("Incoming Print Jobs", "Inkommande skrivarjobb"), + ("Incoming Print Job", "Inkommande skrivarjobb"), + ("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", "Ta skärmbild"), + ("Taking screenshot", "Tar skärmbild"), + ("screenshot-merged-screen-not-supported-tip", ""), + ("screenshot-action-tip", ""), + ("Save as", "Spara som"), + ("Copy to clipboard", "Kppiera till urklipp"), + ("Enable remote printer", "Aktivera fjärrskrivare"), + ("Downloading {}", "Laddar ner {}"), + ("{} Update", "{} Uppdatera"), + ("{}-to-update-tip", ""), + ("download-new-version-failed-tip", ""), + ("Auto update", "Automatisk uppdatering"), + ("update-failed-check-msi-tip", ""), + ("websocket_tip", ""), + ("Use WebSocket", "Använd WebSocket"), + ("Trackpad speed", "Styrplattans hastighet"), + ("Default trackpad speed", "Standardhastighet för styrplattan"), + ("Numeric one-time password", "Numeriskt engångslösenord"), + ("Enable IPv6 P2P connection", "Aktivera IPv6 P2P anslutning"), + ("Enable UDP hole punching", "Aktivera UDP hålslagning"), + ("View camera", "Visa kamera"), + ("Enable camera", "Aktivera kamera"), + ("No cameras", "Inga kameror"), + ("view_camera_unsupported_tip", ""), + ("Terminal", "Terminal"), + ("Enable terminal", "Aktivera terminal"), + ("New tab", "Ny flik"), + ("Keep terminal sessions on disconnect", "Behåll terminalsessioner vid frånkpppling"), + ("Terminal (Run as administrator)", "Terminal (Kör som administratör)"), + ("terminal-admin-login-tip", ""), + ("Failed to get user token.", "Misslyckades med att hämta användartoken."), + ("Incorrect username or password.", "Felaktigt användarnamn eller lösenord."), + ("The user is not an administrator.", "Användaren är inte en administratör."), + ("Failed to check if the user is an administrator.", "Misslyckades med att kontrollera om användaren är administratör."), + ("Supported only in the installed version.", "Stöds endast i den installerade versionen."), + ("elevation_username_tip", ""), + ("Preparing for installation ...", "Förbereder för 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", ""), + ("allow-insecure-tls-fallback-tip", ""), + ("Disable 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 {}", "Fortsätt med {}"), + ("Display Name", ""), + ("password-hidden-tip", ""), + ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ta.rs b/src/lang/ta.rs new file mode 100644 index 000000000..93aeb6462 --- /dev/null +++ b/src/lang/ta.rs @@ -0,0 +1,749 @@ +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", "ஐடி/ரிலே சர்வர்"), + ("Import server config", "சர்வர் உள்ளமைவு இறக்குமதி"), + ("Export Server Config", "சர்வர் உள்ளமைவு ஏற்றுமதி"), + ("Import server configuration successfully", "சர்வர் உள்ளமைவு இறக்குமதி வெற்றி"), + ("Export server configuration successfully", "சர்வர் உள்ளமைவு ஏற்றுமதி வெற்றி"), + ("Invalid server configuration", "தவறான சர்வர் உள்ளமைவு"), + ("Clipboard is empty", "கிளிப்போர்டு காலி"), + ("Stop service", "சேவையை நிறுத்து"), + ("Change ID", "ஐடி மாற்று"), + ("Your new ID", "உங்கள் புதிய ஐடி"), + ("length %min% to %max%", "நீளம் %min% முதல் %max%"), + ("starts with a letter", "ஒரு எழுத்தால் தொடங்கு"), + ("allowed characters", "அனுமதிக்கப்பட்ட எழுத்துக்கள்"), + ("id_change_tip", "ஐடி_மாற்ற_குறிப்பு"), + ("Website", "இணையதளம்"), + ("About", "பற்றி"), + ("Slogan_tip", "சுலோகம்_குறிப்பு"), + ("Privacy Statement", "தனியுரிமை அறிக்கை"), + ("Mute", "ஒலியடக்கவும்"), + ("Build Date", "கட்டப்பட்ட தேதி"), + ("Version", "பதிப்பு"), + ("Home", "வீடு"), + ("Audio Input", "ஒலி உள்ளீடு"), + ("Enhancements", "மேம்பாடுகள்"), + ("Hardware Codec", "வன்பொருள் கோடெக்"), + ("Adaptive bitrate", "தகவமைப்பு பிட்ரேட்"), + ("ID Server", "ஐடி சர்வர்"), + ("Relay Server", "ரிலே சர்வர்"), + ("API Server", "API சர்வர்"), + ("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", "ஐடி இல்லை"), + ("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", "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.", "குறைந்த பதிப்பு நிறுவப்பட்டுள்ளது"), + ("not_close_tcp_tip", "tcp_மூடாதே_குறிப்பு"), + ("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", "ஐடி தேடு"), + ("whitelist_sep", "அனுமதிப்பட்டியல்_sep"), + ("Add 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", "Socks5 ப்ராக்ஸி"), + ("Socks5/Http(s) Proxy", "Socks5/Http(s) ப்ராக்ஸி"), + ("Discovered", "கண்டுபிடிக்கப்பட்டது"), + ("install_daemon_tip", "டீமான்_நிறுவு_குறிப்பு"), + ("Remote 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", "RustDesk இந்த Android சாதனத்தை கட்டுப்படுத்த \"அணுகல் சேவைகள்\" அனுமதி தேவை."), + ("android_input_permission_tip2", "சிஸ்டம் அமைப்புகளில் [நிறுவப்பட்ட சேவைகள்] கண்டுபிடித்து, RustDesk சேவை இயக்கவும்."), + ("android_new_connection_tip", "புதிய கட்டுப்பாட்டு கோரிக்கை வந்துள்ளது"), + ("android_service_will_start_tip", "திரை பிடிப்பு இயக்கினால் சேவை தானாக தொடங்கும்"), + ("android_stop_service_tip", "சேவை நிறுத்தினால் எல்லா இணைப்புகளும் மூடிவிடும்"), + ("android_version_audio_tip", "Android 10+ தேவை ஒலி பிடிப்புக்கு"), + ("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", "RustDesk க்கு பேட்டரி மேம்படுத்தல் அணைக்க அமைப்புகளுக்கு செல்லவும்"), + ("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", "Wayland க்கு Ubuntu 21.04+ தேவை"), + ("wayland-requires-higher-linux-version", "Wayland க்கு உயர் Linux பதிப்பு தேவை. X11 முயற்சிக்கவும் அல்லது OS மாற்றவும்."), + ("xdp-portal-unavailable", ""), + ("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", "இணைப்பு_மேலாளர்_மறை_குறிப்பு"), + ("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", "எப்போதும் மென்பொருள் ரெண்டரிங்"), + ("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", "uac_ஐ_இன்னும்_சொடுக்கவும்_குறிப்பு"), + ("Request Elevation", "உயர்வு கோரிக்கை"), + ("wait_accept_uac_tip", "uac_ஏற்புக்காக_காத்திரு_குறிப்பு"), + ("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", "FPS"), + ("Auto", "தானியங்கு"), + ("Other Default Options", "பிற இயல்புநிலை விருப்பங்கள்"), + ("Voice call", "குரல் அழைப்பு"), + ("Text chat", "உரை அரட்டை"), + ("Stop voice call", "குரல் அழைப்பு நிறுத்து"), + ("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", "காலி_லேன்_குறிப்பு"), + ("empty_address_book_tip", "காலி_முகவரி_புத்தக_குறிப்பு"), + ("Empty Username", "காலி பயனர்பெயர்"), + ("Empty Password", "காலி கடவுச்சொல்"), + ("Me", "நான்"), + ("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", "இயல்புநிலை"), + ("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", "தொலைபேசி மோசடியின் பலியாகலாம்!"), + ("scam_text2", "RustDesk ஊழியர் இவ்வாறு தொடர்பு கொள்ள மாட்டார்கள்"), + ("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", "RustDesk Server Pro {} க்கு மேம்படுத்து"), + ("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", "SELinux இயக்கப்பட்டது, RustDesk அனுமதி வேண்டும்"), + ("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", "Windows Magnifier API"), + ("privacy_mode_impl_virtual_display_tip", "Virtual Display Driver"), + ("Enter privacy mode", "தனியுரிமை முறையில் நுழை"), + ("Exit privacy mode", "தனியுரிமை முறையிலிருந்து வெளியேறு"), + ("idd_not_support_under_win10_2004_tip", "Virtual Display Driver Windows 10 2004 க்கு கீழ் ஆதரவில்லை"), + ("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", "இரு காரணி அங்கீகாரம் இயக்கு"), + ("enable-2fa-desc", "RustDesk இரு காரணி அங்கீகாரம் ஆதரிக்கிறது"), + ("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", "பல Windows அமர்வுகள் கண்டறியப்பட்டன"), + ("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", "wayland_திரை_தேர்வு_அழி_குறிப்பு"), + ("confirm_clear_Wayland_screen_selection_tip", "wayland_திரை_தேர்வு_அழிக்க_உறுதிப்படுத்து_குறிப்பு"), + ("android_new_voice_call_tip", "android_புதிய_குரல்_அழைப்பு_குறிப்பு"), + ("texture_render_tip", "டெக்ஸ்ச்சர்_ரெண்டர்_குறிப்பு"), + ("Use texture rendering", "texture ரெண்டரிங் பயன்படுத்து"), + ("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", "Telegram போட்"), + ("enable-bot-tip", "போட்_இயக்க_குறிப்பு"), + ("enable-bot-desc", "RustDesk Telegram போட் ஆதரிக்கிறது"), + ("cancel-2fa-confirm-tip", "2fa_ரத்து_உறுதி_குறிப்பு"), + ("cancel-bot-confirm-tip", "போட்_ரத்து_உறுதி_குறிப்பு"), + ("About RustDesk", "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", "ரிமோட்_rustdesk_கிளையன்டை_{}_மேம்படுத்து_குறிப்பு"), + ("d3d_render_tip", "d3d_ரெண்டர்_குறிப்பு"), + ("Use D3D rendering", "D3D ரெண்டரிங் பயன்படுத்து"), + ("Printer", "அச்சுப்பொறி"), + ("printer-os-requirement-tip", "பிரிண்டர்_os_தேவை_குறிப்பு"), + ("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 hole punching இயக்கு"), + ("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", ""), + ("allow-insecure-tls-fallback-tip", ""), + ("Disable 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 {}", "{} உடன் தொடர்"), + ("Display Name", ""), + ("password-hidden-tip", ""), + ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), + ].iter().cloned().collect(); +} diff --git a/src/lang/template.rs b/src/lang/template.rs index 60b281851..33b359c5e 100644 --- a/src/lang/template.rs +++ b/src/lang/template.rs @@ -147,8 +147,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("OS Password", ""), ("install_tip", ""), ("Click to upgrade", ""), - ("Click to download", ""), - ("Click to update", ""), ("Configure", ""), ("config_acc", ""), ("config_screen", ""), @@ -267,7 +265,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("No permission of file transfer", ""), ("Note", ""), ("Connection", ""), - ("Share Screen", ""), + ("Share screen", ""), ("Chat", ""), ("Total", ""), ("items", ""), @@ -275,8 +273,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Screen Capture", ""), ("Input Control", ""), ("Audio Capture", ""), - ("File Connection", ""), - ("Screen Connection", ""), ("Do you accept?", ""), ("Open System Setting", ""), ("How to get Android input permission?", ""), @@ -381,14 +377,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Keyboard Settings", ""), ("Full Access", ""), ("Screen Share", ""), - ("Wayland requires Ubuntu 21.04 or higher version.", ""), - ("Wayland requires higher version of linux distro. Please try X11 desktop or change your OS.", ""), + ("ubuntu-21-04-required", ""), + ("wayland-requires-higher-linux-version", ""), + ("xdp-portal-unavailable", ""), ("JumpLink", ""), ("Please Select the screen to be shared(Operate on the peer side).", ""), ("Show RustDesk", ""), ("This PC", ""), ("or", ""), - ("Continue with", ""), ("Elevate", ""), ("Zoom cursor", ""), ("Accept sessions via password", ""), @@ -463,7 +459,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("empty_favorite_tip", ""), ("empty_lan_tip", ""), ("empty_address_book_tip", ""), - ("eg: admin", ""), ("Empty Username", ""), ("Empty Password", ""), ("Me", ""), @@ -653,5 +648,102 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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", ""), + ("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", ""), + ("Trackpad speed", ""), + ("Default trackpad speed", ""), + ("Numeric one-time password", ""), + ("Enable IPv6 P2P connection", ""), + ("Enable UDP hole punching", ""), + ("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", ""), + ("allow-insecure-tls-fallback-tip", ""), + ("Disable 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 {}", ""), + ("Display Name", ""), + ("password-hidden-tip", ""), + ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/th.rs b/src/lang/th.rs index 71af446c1..a24c60bf6 100644 --- a/src/lang/th.rs +++ b/src/lang/th.rs @@ -41,7 +41,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("length %min% to %max%", "ความยาวตั้งแต่ %min% ถึง %max%"), ("starts with a letter", "เริ่มต้นด้วยตัวอักษร"), ("allowed characters", "ตัวอักขระที่อนุญาต"), - ("id_change_tip", "อนุญาตเฉพาะตัวอักษร a-z A-Z 0-9 และ _ (ขีดล่าง) เท่านั้น โดยตัวอักษรขึ้นต้นจะต้องเป็น a-z หรือไม่ก็ A-Z และมีความยาวระหว่าง 6 ถึง 16 ตัวอักษร"), + ("id_change_tip", "อนุญาตเฉพาะตัวอักษร a-z A-Z 0-9, - (dash) และ _ (ขีดล่าง) เท่านั้น โดยตัวอักษรขึ้นต้นจะต้องเป็น a-z หรือไม่ก็ A-Z และมีความยาวระหว่าง 6 ถึง 16 ตัวอักษร"), ("Website", "เว็บไซต์"), ("About", "เกี่ยวกับ"), ("Slogan_tip", "ทำด้วยใจ ในโลกที่วุ่นวาย!"), @@ -147,8 +147,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("OS Password", "รหัสผ่านระบบปฏิบัติการ"), ("install_tip", "เนื่องด้วยข้อจำกัดของการใช้งาน UAC ทำให้ RustDesk ไม่สามารถทำงานได้ปกติในฝั่งปลายทางในบางครั้ง เพื่อหลีกเลี่ยงข้อจำกัดของ UAC กรุณากดปุ่มด้านล่างเพื่อติดตั้ง RustDesk ไปยังระบบของคุณ"), ("Click to upgrade", "คลิกเพื่ออัปเกรด"), - ("Click to download", "คลิกเพื่อดาวน์โหลด"), - ("Click to update", "คลิกเพื่ออัปเดต"), ("Configure", "ปรับแต่งค่า"), ("config_acc", "เพื่อที่จะควบคุมเดสก์ท็อปปลายทางของคุณ คุณจำเป็นจะต้องอนุญาตสิทธิ์ \"การเข้าถึง\" ให้แก่ RustDesk"), ("config_screen", "เพื่อที่จะควบคุมเดสก์ท็อปปลายทางของคุณ คุณจำเป็นจะต้องอนุญาตสิทธิ์ \"การบันทึกภาพหน้าจอ\" ให้แก่ RustDesk"), @@ -267,7 +265,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("No permission of file transfer", "ไม่มีสิทธิ์ในการถ่ายโอนไฟล์"), ("Note", "บันทึกข้อความ"), ("Connection", "การเชื่อมต่อ"), - ("Share Screen", "แชร์หน้าจอ"), + ("Share screen", "แชร์หน้าจอ"), ("Chat", "แชท"), ("Total", "รวม"), ("items", "รายการ"), @@ -275,8 +273,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Screen Capture", "บันทึกหน้าจอ"), ("Input Control", "ควบคุมอินพุท"), ("Audio Capture", "บันทึกเสียง"), - ("File Connection", "การเชื่อมต่อไฟล์"), - ("Screen Connection", "การเชื่อมต่อหน้าจอ"), ("Do you accept?", "ยอมรับหรือไม่?"), ("Open System Setting", "เปิดการตั้งค่าระบบ"), ("How to get Android input permission?", "เปิดสิทธิ์การใช้งานอินพุทของแอนดรอยด์ได้อย่างไร?"), @@ -381,14 +377,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Keyboard Settings", "การตั้งค่าคีย์บอร์ด"), ("Full Access", "การเข้าถึงทั้งหมด"), ("Screen Share", "การแชร์จอ"), - ("Wayland requires Ubuntu 21.04 or higher version.", "Wayland ต้องการ Ubuntu เวอร์ชัน 21.04 หรือสูงกว่า"), - ("Wayland requires higher version of linux distro. Please try X11 desktop or change your OS.", "Wayland ต้องการลินุกซ์เวอร์ชันที่สูงกว่านี้ กรุณาเปลี่ยนไปใช้เดสก์ท็อป X11 หรือเปลี่ยนระบบปฏิบัติการของคุณ"), + ("ubuntu-21-04-required", "Wayland ต้องการ Ubuntu เวอร์ชัน 21.04 หรือสูงกว่า"), + ("wayland-requires-higher-linux-version", "Wayland ต้องการลินุกซ์เวอร์ชันที่สูงกว่านี้ กรุณาเปลี่ยนไปใช้เดสก์ท็อป X11 หรือเปลี่ยนระบบปฏิบัติการของคุณ"), + ("xdp-portal-unavailable", ""), ("JumpLink", "View"), ("Please Select the screen to be shared(Operate on the peer side).", "กรุณาเลือกหน้าจอที่ต้องการแชร์ (ใช้งานในอีกฝั่งของการเชื่อมต่อ)"), ("Show RustDesk", "แสดง RustDesk"), ("This PC", "พีซีเครื่องนี้"), ("or", "หรือ"), - ("Continue with", "ทำต่อด้วย"), ("Elevate", "ยกระดับ"), ("Zoom cursor", "ขยายเคอร์เซอร์"), ("Accept sessions via password", "ยอมรับการเชื่อมต่อด้วยรหัสผ่าน"), @@ -463,7 +459,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("empty_favorite_tip", "ยังไม่มีการเชื่อมต่อรายการโปรดเหรอ? มาเริ่มต้นหาใครซักคนเพื่อเชื่อมต่อด้วย และเพิ่มเข้าไปยังรายการโปรดของคุณกัน"), ("empty_lan_tip", "ไม่นะ ดูเหมือนว่าเราจะยังไม่พบใครตรงนี้"), ("empty_address_book_tip", "ดูเหมือนว่าคุณยังไม่มีใครถูกบันทึกในสมุดรายชื่อของคุณ"), - ("eg: admin", "เช่น ผู้ดูแลระบบ"), ("Empty Username", "ชื่อผู้ใช้งานว่างเปล่า"), ("Empty Password", "รหัสผ่านว่างเปล่า"), ("Me", "ฉัน"), @@ -653,5 +648,102 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Upload folder", ""), ("Upload files", ""), ("Clipboard is synchronized", ""), + ("Update client clipboard", ""), + ("Untagged", ""), + ("new-version-of-{}-tip", ""), + ("Accessible devices", ""), + ("upgrade_remote_rustdesk_client_to_{}_tip", "กรุณาอัปเดต RustDesk ไคลเอนต์ไปยังเวอร์ชัน {} หรือใหม่กว่าที่ฝั่งปลายทาง!"), + ("d3d_render_tip", ""), + ("Use D3D rendering", ""), + ("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", ""), + ("Trackpad speed", ""), + ("Default trackpad speed", ""), + ("Numeric one-time password", ""), + ("Enable IPv6 P2P connection", ""), + ("Enable UDP hole punching", ""), + ("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", ""), + ("allow-insecure-tls-fallback-tip", ""), + ("Disable 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 {}", "ทำต่อด้วย {}"), + ("Display Name", ""), + ("password-hidden-tip", ""), + ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/tr.rs b/src/lang/tr.rs index ce11544b5..c28086cc9 100644 --- a/src/lang/tr.rs +++ b/src/lang/tr.rs @@ -3,8 +3,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = [ ("Status", "Durum"), ("Your Desktop", "Sizin Masaüstünüz"), - ("desk_tip", "Masaüstünüze bu ID ve şifre ile erişilebilir"), - ("Password", "Şifre"), + ("desk_tip", "Masaüstünüze bu ID ve parola ile erişilebilir"), + ("Password", "Parola"), ("Ready", "Hazır"), ("Established", "Bağlantı sağlandı"), ("connecting_status", "Bağlanılıyor "), @@ -13,16 +13,16 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Service is running", "Servis çalışıyor"), ("Service is not running", "Servis çalışmıyor"), ("not_ready_status", "Hazır değil. Bağlantınızı kontrol edin"), - ("Control Remote Desktop", "Bağlanılacak Uzak Bağlantı ID"), + ("Control Remote Desktop", "Uzak Masaüstünü Denetle"), ("Transfer file", "Dosya transferi"), ("Connect", "Bağlan"), - ("Recent sessions", "Son Bağlanılanlar"), + ("Recent sessions", "Son oturumlar"), ("Address book", "Adres Defteri"), ("Confirmation", "Onayla"), - ("TCP tunneling", "TCP Tünelleri"), + ("TCP tunneling", "TCP tünelleri"), ("Remove", "Kaldır"), - ("Refresh random password", "Yeni rastgele şifre oluştur"), - ("Set your own password", "Kendi şifreni oluştur"), + ("Refresh random password", "Yeni rastgele parola oluştur"), + ("Set your own password", "Kendi parolanı oluştur"), ("Enable keyboard/mouse", "Klavye ve Fareye izin ver"), ("Enable clipboard", "Kopyalanan geçici veriye izin ver"), ("Enable file transfer", "Dosya Transferine izin ver"), @@ -37,19 +37,19 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Clipboard is empty", "Kopyalanan geçici veri boş"), ("Stop service", "Servisi Durdur"), ("Change ID", "ID Değiştir"), - ("Your new ID", ""), - ("length %min% to %max%", ""), - ("starts with a letter", ""), - ("allowed characters", ""), - ("id_change_tip", "Yalnızca a-z, A-Z, 0-9 ve _ (alt çizgi) karakterlerini kullanabilirsiniz. İlk karakter a-z veya A-Z olmalıdır. Uzunluk 6 ile 16 karakter arasında olmalıdır."), + ("Your new ID", "Yeni ID'niz"), + ("length %min% to %max%", "uzunluk %min% ila %max%"), + ("starts with a letter", "bir harfle başlar"), + ("allowed characters", "izin verilen karakterler"), + ("id_change_tip", "Yalnızca a-z, A-Z, 0-9, - (dash) ve _ (alt çizgi) karakterlerini kullanabilirsiniz. İlk karakter a-z veya A-Z olmalıdır. Uzunluk 6 ile 16 karakter arasında olmalıdır."), ("Website", "Website"), ("About", "Hakkında"), - ("Slogan_tip", ""), - ("Privacy Statement", ""), + ("Slogan_tip", "Bu kaotik dünyada gönülden yapıldı!"), + ("Privacy Statement", "Gizlilik Beyanı"), ("Mute", "Sustur"), - ("Build Date", ""), - ("Version", ""), - ("Home", ""), + ("Build Date", "Derleme Tarihi"), + ("Version", "Sürüm"), + ("Home", "Ana Sayfa"), ("Audio Input", "Ses Girişi"), ("Enhancements", "Geliştirmeler"), ("Hardware Codec", "Donanımsal Codec"), @@ -64,18 +64,18 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Not available", "Erişilebilir değil"), ("Too frequent", "Çok sık"), ("Cancel", "İptal"), - ("Skip", "Geç"), + ("Skip", "Atla"), ("Close", "Kapat"), ("Retry", "Tekrar Dene"), ("OK", "Tamam"), - ("Password Required", "Şifre Gerekli"), - ("Please enter your password", "Lütfen şifrenizi giriniz"), - ("Remember password", "Şifreyi hatırla"), - ("Wrong Password", "Hatalı şifre"), + ("Password Required", "Parola Gerekli"), + ("Please enter your password", "Lütfen parolanızı giriniz"), + ("Remember password", "Parolayı hatırla"), + ("Wrong Password", "Hatalı parola"), ("Do you want to enter again?", "Tekrar giriş yapmak ister misiniz?"), ("Connection Error", "Bağlantı Hatası"), ("Error", "Hata"), - ("Reset by the peer", "Eş tarafında sıfırla"), + ("Reset by the peer", "Eş tarafından sıfırlandı"), ("Connecting...", "Bağlanılıyor..."), ("Connection in progress. Please wait.", "Bağlantı sağlanıyor. Lütfen bekleyiniz."), ("Please try 1 minute later", "Lütfen 1 dakika sonra tekrar deneyiniz"), @@ -135,20 +135,18 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Refresh", "Yenile"), ("ID does not exist", "ID bulunamadı"), ("Failed to connect to rendezvous server", "ID oluşturma sunucusuna bağlanılamadı"), - ("Please try later", "Dağa sonra tekrar deneyiniz"), + ("Please try later", "Daha sonra tekrar deneyiniz"), ("Remote desktop is offline", "Uzak masaüstü kapalı"), ("Key mismatch", "Anahtar uyumlu değil"), ("Timeout", "Zaman aşımı"), ("Failed to connect to relay server", "Relay sunucusuna bağlanılamadı"), ("Failed to connect via rendezvous server", "ID oluşturma sunucusuna bağlanılamadı"), - ("Failed to connect via relay server", "Relay oluşturma sunucusuna bağlanılamadı"), + ("Failed to connect via relay server", "Aktarma sunucusuna bağlanılamadı"), ("Failed to make direct connection to remote desktop", "Uzak masaüstüne doğrudan bağlantı kurulamadı"), - ("Set Password", "Şifre ayarla"), - ("OS Password", "İşletim Sistemi Şifresi"), + ("Set Password", "Parola ayarla"), + ("OS Password", "İşletim Sistemi Parolası"), ("install_tip", "Kullanıcı Hesabı Denetimi nedeniyle, RustDesk bir uzak masaüstü olarak düzgün çalışmayabilir. Bu sorunu önlemek için, RustDesk'i sistem seviyesinde kurmak için aşağıdaki butona tıklayın."), ("Click to upgrade", "Yükseltmek için tıklayınız"), - ("Click to download", "İndirmek için tıklayınız"), - ("Click to update", "Güncellemek için tıklayınız"), ("Configure", "Ayarla"), ("config_acc", "Masaüstünüzü dışarıdan kontrol etmek için RustDesk'e \"Erişilebilirlik\""), ("config_screen", "Masaüstünüzü dışarıdan kontrol etmek için RustDesk'e \"Ekran Kaydı\" iznini vermeniz gerekir."), @@ -186,7 +184,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Direct and unencrypted connection", "Doğrudan ve şifrelenmemiş bağlantı"), ("Relayed and unencrypted connection", "Aktarmalı ve şifrelenmemiş bağlantı"), ("Enter Remote ID", "Uzak ID'yi Girin"), - ("Enter your password", "Şifrenizi girin"), + ("Enter your password", "Parolanızı girin"), ("Logging in...", "Giriş yapılıyor..."), ("Enable RDP session sharing", "RDP oturum paylaşımını etkinleştir"), ("Auto Login", "Otomatik giriş"), @@ -210,15 +208,15 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Closed manually by the peer", "Eş tarafından manuel olarak kapatıldı"), ("Enable remote configuration modification", "Uzaktan yapılandırma değişikliğini etkinleştir"), ("Run without install", "Yüklemeden çalıştır"), - ("Connect via relay", ""), - ("Always connect via relay", "Always connect via relay"), + ("Connect via relay", "Aktarmalı üzerinden bağlan"), + ("Always connect via relay", "Her zaman aktarmalı üzerinden bağlan"), ("whitelist_tip", "Bu masaüstüne yalnızca yetkili IP adresleri bağlanabilir"), ("Login", "Giriş yap"), - ("Verify", ""), - ("Remember me", ""), - ("Trust this device", ""), - ("Verification code", ""), - ("verification_tip", ""), + ("Verify", "Doğrula"), + ("Remember me", "Beni hatırla"), + ("Trust this device", "Bu cihaza güvenin"), + ("Verification code", "Doğrulama kodu"), + ("verification_tip", "doğrulama tipi"), ("Logout", "Çıkış yap"), ("Tags", "Etiketler"), ("Search ID", "ID Arama"), @@ -228,11 +226,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Unselect all tags", "Tüm etiketlerin seçimini kaldır"), ("Network error", "Bağlantı hatası"), ("Username missed", "Kullanıcı adı boş"), - ("Password missed", "Şifre boş"), + ("Password missed", "Parola boş"), ("Wrong credentials", "Yanlış kimlik bilgileri"), - ("The verification code is incorrect or has expired", ""), + ("The verification code is incorrect or has expired", "Doğrulama kodu hatalı veya süresi dolmuş"), ("Edit Tag", "Etiketi düzenle"), - ("Forget Password", "Şifreyi Unut"), + ("Forget Password", "Parolayı Unut"), ("Favorites", "Favoriler"), ("Add to Favorites", "Favorilere ekle"), ("Remove from Favorites", "Favorilerden çıkar"), @@ -267,16 +265,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("No permission of file transfer", "Dosya aktarımı izni yok"), ("Note", "Not"), ("Connection", "Bağlantı"), - ("Share Screen", "Ekranı Paylaş"), + ("Share screen", "Ekranı Paylaş"), ("Chat", "Mesajlaş"), ("Total", "Toplam"), - ("items", "öğeler"), + ("items", "ögeler"), ("Selected", "Seçildi"), - ("Screen Capture", "Ekran görüntüsü"), + ("Screen Capture", "Ekran Görüntüsü"), ("Input Control", "Giriş Kontrolü"), ("Audio Capture", "Ses Yakalama"), - ("File Connection", "Dosya Bağlantısı"), - ("Screen Connection", "Ekran Bağlantısı"), ("Do you accept?", "Kabul ediyor musun?"), ("Open System Setting", "Sistem Ayarını Aç"), ("How to get Android input permission?", "Android giriş izni nasıl alınır?"), @@ -286,10 +282,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("android_service_will_start_tip", "Ekran Yakalamanın etkinleştirilmesi, hizmeti otomatik olarak başlatacak ve diğer cihazların bu cihazdan bağlantı talep etmesine izin verecektir."), ("android_stop_service_tip", "Hizmetin kapatılması, kurulan tüm bağlantıları otomatik olarak kapatacaktır."), ("android_version_audio_tip", "Mevcut Android sürümü ses yakalamayı desteklemiyor, lütfen Android 10 veya sonraki bir sürüme yükseltin."), - ("android_start_service_tip", ""), - ("android_permission_may_not_change_tip", ""), + ("android_start_service_tip", "Ekran paylaşım hizmetini başlatmak için [Hizmeti başlat] ögesine dokunun veya [Ekran Görüntüsü] iznini etkinleştirin."), + ("android_permission_may_not_change_tip", "Kurulan bağlantılara ait izinler, yeniden bağlantı kurulana kadar anında değiştirilemez."), ("Account", "Hesap"), - ("Overwrite", "üzerine yaz"), + ("Overwrite", "Üzerine yaz"), ("This file exists, skip or overwrite this file?", "Bu dosya var, bu dosya atlansın veya üzerine yazılsın mı?"), ("Quit", "Çıkış"), ("Help", "Yardım"), @@ -299,50 +295,50 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Unsupported", "desteklenmiyor"), ("Peer denied", "eş reddedildi"), ("Please install plugins", "Lütfen eklentileri yükleyin"), - ("Peer exit", "eş çıkışı"), - ("Failed to turn off", "kapatılamadı"), + ("Peer exit", "Eş çıkışı"), + ("Failed to turn off", "Kapatılamadı"), ("Turned off", "Kapatıldı"), ("Language", "Dil"), ("Keep RustDesk background service", "RustDesk arka plan hizmetini sürdürün"), ("Ignore Battery Optimizations", "Pil Optimizasyonlarını Yoksay"), - ("android_open_battery_optimizations_tip", ""), - ("Start on boot", ""), - ("Start the screen sharing service on boot, requires special permissions", ""), - ("Connection not allowed", "bağlantıya izin verilmedi"), + ("android_open_battery_optimizations_tip", "Bu özelliği devre dışı bırakmak istiyorsanız lütfen bir sonraki RustDesk uygulama ayarları sayfasına gidin, [Pil] ögesini bulun ve girin, [Sınırsız] ögesinin işaretini kaldırın"), + ("Start on boot", "Önyüklemede başla"), + ("Start the screen sharing service on boot, requires special permissions", "Ekran paylaşım hizmetini önyüklemede başlatmak için özel izinler gerekir"), + ("Connection not allowed", "Bağlantıya izin verilmedi"), ("Legacy mode", "Eski mod"), ("Map mode", "Haritalama modu"), ("Translate mode", "Çeviri modu"), - ("Use permanent password", "Kalıcı şifre kullan"), - ("Use both passwords", "İki şifreyide kullan"), - ("Set permanent password", "Kalıcı şifre oluştur"), + ("Use permanent password", "Kalıcı parola kullan"), + ("Use both passwords", "İki parolayı da kullan"), + ("Set permanent password", "Kalıcı parola oluştur"), ("Enable remote restart", "Uzaktan yeniden başlatmayı aktif et"), ("Restart remote device", "Uzaktaki cihazı yeniden başlat"), - ("Are you sure you want to restart", "Yeniden başlatmak istediğinize emin misin?"), + ("Are you sure you want to restart", "Yeniden başlatmak istediğine emin misin?"), ("Restarting remote device", "Uzaktan yeniden başlatılıyor"), - ("remote_restarting_tip", "remote_restarting_tip"), + ("remote_restarting_tip", "Uzak cihaz yeniden başlatılıyor, lütfen bu mesaj kutusunu kapatın ve bir süre sonra kalıcı parola ile yeniden bağlanın"), ("Copied", "Kopyalandı"), - ("Exit Fullscreen", "Tam ekrandan çık"), - ("Fullscreen", "Tam ekran"), + ("Exit Fullscreen", "Tam Ekrandan Çık"), + ("Fullscreen", "Tam Ekran"), ("Mobile Actions", "Mobil İşlemler"), ("Select Monitor", "Monitörü Seç"), ("Control Actions", "Kontrol Eylemleri"), - ("Display Settings", "Görüntü ayarları"), + ("Display Settings", "Görüntü Ayarları"), ("Ratio", "Oran"), - ("Image Quality", "Görüntü kalitesi"), + ("Image Quality", "Görüntü Kalitesi"), ("Scroll Style", "Kaydırma Stili"), - ("Show Toolbar", ""), - ("Hide Toolbar", ""), + ("Show Toolbar", "Araç Çubuğunu Göster"), + ("Hide Toolbar", "Araç Çubuğunu Gizle"), ("Direct Connection", "Doğrudan Bağlantı"), - ("Relay Connection", "Röle Bağlantısı"), - ("Secure Connection", "Güvenli bağlantı"), - ("Insecure Connection", "Güvenli Bağlantı"), - ("Scale original", "Orijinali ölçeklendir"), - ("Scale adaptive", "Ölçek uyarlanabilir"), + ("Relay Connection", "Aktarmalı Bağlantı"), + ("Secure Connection", "Güvenli Bağlantı"), + ("Insecure Connection", "Güvenli Olmayan Bağlantı"), + ("Scale original", "Orijinal ölçekte"), + ("Scale adaptive", "Uyarlanabilir ölçekte"), ("General", "Genel"), ("Security", "Güvenlik"), ("Theme", "Tema"), ("Dark Theme", "Koyu Tema"), - ("Light Theme", ""), + ("Light Theme", "Açık Tema"), ("Dark", "Koyu"), ("Light", "Açık"), ("Follow System", "Sisteme Uy"), @@ -351,26 +347,26 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Enable audio", "Sesi Aktif Et"), ("Unlock Network Settings", "Ağ Ayarlarını Aç"), ("Server", "Sunucu"), - ("Direct IP Access", "Direk IP Erişimi"), + ("Direct IP Access", "Doğrudan IP Erişimi"), ("Proxy", "Vekil"), ("Apply", "Uygula"), - ("Disconnect all devices?", "Tüm cihazların bağlantısını kes?"), + ("Disconnect all devices?", "Tüm cihazların bağlantısı kesilsin mi?"), ("Clear", "Temizle"), ("Audio Input Device", "Ses Giriş Aygıtı"), ("Use IP Whitelisting", "IP Beyaz Listeyi Kullan"), ("Network", "Ağ"), - ("Pin Toolbar", ""), - ("Unpin Toolbar", ""), - ("Recording", "Kayıt Ediliyor"), - ("Directory", "Klasör"), - ("Automatically record incoming sessions", "Gelen oturumları otomatik olarak kayıt et"), - ("Automatically record outgoing sessions", ""), + ("Pin Toolbar", "Araç Çubuğunu Sabitle"), + ("Unpin Toolbar", "Araç Çubuğunu Sabitlemeyi Kaldır"), + ("Recording", "Kaydediliyor"), + ("Directory", "Dizin"), + ("Automatically record incoming sessions", "Gelen oturumları otomatik olarak kaydet"), + ("Automatically record outgoing sessions", "Giden oturumları otomatik olarak kaydet"), ("Change", "Değiştir"), ("Start session recording", "Oturum kaydını başlat"), ("Stop session recording", "Oturum kaydını sonlandır"), ("Enable recording session", "Kayıt Oturumunu Aktif Et"), ("Enable LAN discovery", "Yerel Ağ Keşfine İzin Ver"), - ("Deny LAN discovery", "Yerl Ağ Keşfine İzin Verme"), + ("Deny LAN discovery", "Yerel Ağ Keşfine İzin Verme"), ("Write a message", "Bir mesaj yazın"), ("Prompt", "İstem"), ("Please wait for confirmation of UAC...", "UAC onayı için lütfen bekleyiniz..."), @@ -381,23 +377,23 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Keyboard Settings", "Klavye Ayarları"), ("Full Access", "Tam Erişim"), ("Screen Share", "Ekran Paylaşımı"), - ("Wayland requires Ubuntu 21.04 or higher version.", "Wayland, Ubuntu 21.04 veya daha yüksek bir sürüm gerektirir."), - ("Wayland requires higher version of linux distro. Please try X11 desktop or change your OS.", "Wayland, linux dağıtımının daha yüksek bir sürümünü gerektirir. Lütfen X11 masaüstünü deneyin veya işletim sisteminizi değiştirin."), + ("ubuntu-21-04-required", "Wayland, Ubuntu 21.04 veya daha yüksek bir sürüm gerektirir."), + ("wayland-requires-higher-linux-version", "Wayland, linux dağıtımının daha yüksek bir sürümünü gerektirir. Lütfen X11 masaüstünü deneyin veya işletim sisteminizi değiştirin."), + ("xdp-portal-unavailable", ""), ("JumpLink", "View"), ("Please Select the screen to be shared(Operate on the peer side).", "Lütfen paylaşılacak ekranı seçiniz (Ekran tarafında çalıştırın)."), ("Show RustDesk", "RustDesk'i Göster"), ("This PC", "Bu PC"), ("or", "veya"), - ("Continue with", "bununla devam et"), ("Elevate", "Yükseltme"), ("Zoom cursor", "Yakınlaştırma imleci"), ("Accept sessions via password", "Oturumları parola ile kabul etme"), ("Accept sessions via click", "Tıklama yoluyla oturumları kabul edin"), ("Accept sessions via both", "Her ikisi aracılığıyla oturumları kabul edin"), ("Please wait for the remote side to accept your session request...", "Lütfen uzak tarafın oturum isteğinizi kabul etmesini bekleyin..."), - ("One-time Password", "Tek Kullanımlık Şifre"), + ("One-time Password", "Tek Kullanımlık Parola"), ("Use one-time password", "Tek seferlik parola kullanın"), - ("One-time password length", "Tek seferlik şifre uzunluğu"), + ("One-time password length", "Tek seferlik parola uzunluğu"), ("Request access to your device", "Cihazınıza erişim talep edin"), ("Hide connection management window", "Bağlantı yönetimi penceresini gizle"), ("hide_cm_tip", "Oturumları yalnızca parola ile kabul edebilir ve kalıcı parola kullanıyorsanız gizlemeye izin verin"), @@ -446,7 +442,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Voice call", "Sesli görüşme"), ("Text chat", "Metin sohbeti"), ("Stop voice call", "Sesli görüşmeyi durdur"), - ("relay_hint_tip", "Doğrudan bağlanmak mümkün olmayabilir; röle aracılığıyla bağlanmayı deneyebilirsiniz. Ayrıca, ilk denemenizde bir röle kullanmak istiyorsanız, ID'nin sonuna \"/r\" ekleyebilir veya son oturum kartındaki \"Her Zaman Röle Üzerinden Bağlan\" seçeneğini seçebilirsiniz."), + ("relay_hint_tip", "Doğrudan bağlanmak mümkün olmayabilir; aktarmalı bağlanmayı deneyebilirsiniz. Ayrıca, ilk denemenizde aktarma sunucusu kullanmak istiyorsanız ID'nin sonuna \"/r\" ekleyebilir veya son oturum kartındaki \"Her Zaman Aktarmalı Üzerinden Bağlan\" seçeneğini seçebilirsiniz."), ("Reconnect", "Yeniden Bağlan"), ("Codec", "Kodlayıcı"), ("Resolution", "Çözünürlük"), @@ -463,7 +459,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("empty_favorite_tip", "Henüz favori cihazınız yok mu?\nBağlanacak ve favorilere eklemek için birini bulalım!"), ("empty_lan_tip", "Hayır, henüz hiçbir cihaz bulamadık gibi görünüyor."), ("empty_address_book_tip", "Üzgünüm, şu anda adres defterinizde kayıtlı cihaz yok gibi görünüyor."), - ("eg: admin", "örn: admin"), ("Empty Username", "Boş Kullanıcı Adı"), ("Empty Password", "Boş Parola"), ("Me", "Ben"), @@ -482,7 +477,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("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 Ses"), + ("System Sound", "Sistem Sesi"), ("Default", "Varsayılan"), ("New RDP", "Yeni RDP"), ("Fingerprint", "Parmak İzi"), @@ -500,7 +495,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("resolution_fit_local_tip", "Yerel çözünürlüğe sığdır"), ("resolution_custom_tip", "Özel çözünürlük"), ("Collapse toolbar", "Araç çubuğunu daralt"), - ("Accept and Elevate", "Kabul et ve yükselt"), + ("Accept and Elevate", "Kabul Et ve Yükselt"), ("accept_and_elevate_btn_tooltip", "Bağlantıyı kabul et ve UAC izinlerini yükselt."), ("clipboard_wait_response_timeout_tip", "Kopyalama yanıtı için zaman aşımına uğradı."), ("Incoming connection", "Gelen bağlantı"), @@ -531,127 +526,224 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Change Color", "Rengi Değiştir"), ("Primary Color", "Birincil Renk"), ("HSV Color", "HSV Rengi"), - ("Installation Successful!", ""), - ("Installation failed!", ""), - ("Reverse mouse wheel", ""), - ("{} sessions", ""), - ("scam_title", ""), - ("scam_text1", ""), - ("scam_text2", ""), - ("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)", ""), - ("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", ""), - ("swap-left-right-mouse", ""), - ("2FA code", ""), - ("More", ""), - ("enable-2fa-title", ""), - ("enable-2fa-desc", ""), - ("wrong-2fa-code", ""), - ("enter-2fa-title", ""), - ("Email verification code must be 6 characters.", ""), - ("2FA code must be 6 digits.", ""), - ("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", ""), - ("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", ""), - ("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", ""), - ("Requires at least {} characters", ""), - ("Wrong PIN", ""), - ("Set 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", ""), + ("Installation Successful!", "Kurulum Başarılı!"), + ("Installation failed!", "Kurulum başarısız!"), + ("Reverse mouse wheel", "Ters fare tekerleği"), + ("{} sessions", "{} oturum"), + ("scam_title", "Dolandırılıyor Olabilirsiniz!"), + ("scam_text1", "Eğer tanımadığınız ve güvenmediğiniz birisiyle telefonda konuşuyorsanız ve sizden RustDesk'i kullanmanızı ve hizmeti başlatmanızı istiyorsa devam etmeyin ve hemen telefonu kapatın."), + ("scam_text2", "Muhtemelen paranızı veya diğer özel bilgilerinizi çalmaya çalışan dolandırıcılardır."), + ("Don't show again", "Bir daha gösterme"), + ("I Agree", "Kabul Ediyorum"), + ("Decline", "Reddet"), + ("Timeout in minutes", "Zaman aşımı (dakika)"), + ("auto_disconnect_option_tip", "Kullanıcı etkin olmadığında gelen oturumları otomatik olarak kapat"), + ("Connection failed due to inactivity", "Etkin olmama nedeniyle otomatik olarak bağlantı kesildi"), + ("Check for software update on startup", "Başlangıçta yazılım güncellemesini kontrol et"), + ("upgrade_rustdesk_server_pro_to_{}_tip", "Lütfen RustDesk Server Pro'yu {} veya daha yeni bir sürüme yükseltin!"), + ("pull_group_failed_tip", "Grup yenilenemedi"), + ("Filter by intersection", "Kesişim noktasına göre filtrele"), + ("Remove wallpaper during incoming sessions", "Gelen oturumlar sırasında duvar kağıdını kaldır"), + ("Test", "Test"), + ("display_is_plugged_out_msg", "Ekran fişi çekilmiş, ilk ekrana geç."), + ("No displays", "Görüntü yok"), + ("Open in new window", "Yeni pencerede aç"), + ("Show displays as individual windows", "Ekranları ayrı pencereler olarak göster"), + ("Use all my displays for the remote session", "Uzak oturum için tüm ekranlarımı kullan"), + ("selinux_tip", "Cihazınızda SELinux etkin olduğundan, RustDesk'in kontrollü tarafta düzgün çalışmasını engelleyebilir."), + ("Change view", "Görünümü değiştir"), + ("Big tiles", "Büyük döşemeler"), + ("Small tiles", "Küçük döşemeler"), + ("List", "Liste"), + ("Virtual display", "Sanal ekran"), + ("Plug out all", "Tümünü çıkar"), + ("True color (4:4:4)", "Gerçek renk (4:4:4)"), + ("Enable blocking user input", "Kullanıcı girişini engellemeyi etkinleştir"), + ("id_input_tip", "Bir ID, doğrudan IP veya portlu bir etki alanı (:) girebilirsiniz.\nBaşka bir sunucudaki bir cihaza erişmek istiyorsanız lütfen sunucu adresini (@?key=) ekleyin, örneğin,\n9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=.\nGenel bir sunucudaki bir cihaza erişmek istiyorsanız lütfen \"@public\" girin, genel sunucu için anahtara gerek yoktur.\n\nİlk bağlantıda bir aktarma bağlantısının kullanılmasını zorlamak istiyorsanız ID'nin sonuna \"/r\" ekleyin, örneğin, \"9123456234/r\"."), + ("privacy_mode_impl_mag_tip", "Mod 1"), + ("privacy_mode_impl_virtual_display_tip", "Mod 2"), + ("Enter privacy mode", "Gizlilik moduna gir"), + ("Exit privacy mode", "Gizlilik modundan çık"), + ("idd_not_support_under_win10_2004_tip", "Dolaylı ekran sürücüsü desteklenmiyor. Windows 10, sürüm 2004 veya daha yenisi gereklidir."), + ("input_source_1_tip", "Giriş kaynağı 1"), + ("input_source_2_tip", "Giriş kaynağı 2"), + ("Swap control-command key", "Kontrol-komut tuşunu değiştir"), + ("swap-left-right-mouse", "Sol-sağ fare tuşlarını değiştir"), + ("2FA code", "2FA kodu"), + ("More", "Daha"), + ("enable-2fa-title", "İki faktörlü kimlik doğrulamayı etkinleştir"), + ("enable-2fa-desc", "Lütfen kimlik doğrulayıcınızı şimdi kurun. Telefonunuzda veya masaüstünüzde Authy, Microsoft veya Google Authenticator gibi bir kimlik doğrulayıcı uygulaması kullanabilirsiniz. İki faktörlü kimlik doğrulamayı etkinleştirmek için QR kodunu uygulamanızla tarayın ve uygulamanızın gösterdiği kodu girin."), + ("wrong-2fa-code", "Kod doğrulanamıyor. Kod ve yerel saat ayarlarının doğru olduğundan emin olun."), + ("enter-2fa-title", "İki faktörlü kimlik doğrulama"), + ("Email verification code must be 6 characters.", "E-posta doğrulama kodu 6 karakterden oluşmalıdır."), + ("2FA code must be 6 digits.", "2FA kodu 6 haneli olmalıdır."), + ("Multiple Windows sessions found", "Birden fazla Windows oturumu bulundu"), + ("Please select the session you want to connect to", "Lütfen bağlanmak istediğiniz oturumu seçin"), + ("powered_by_me", "RustDesk tarafından desteklenmektedir"), + ("outgoing_only_desk_tip", "Bu özelleştirilmiş bir sürümdür.\nDiğer cihazlara bağlanabilirsiniz, ancak diğer cihazlar cihazınıza bağlanamaz."), + ("preset_password_warning", "Bu özelleştirilmiş sürüm, önceden ayarlanmış bir parola ile birlikte gelir. Bu parolayı bilen herkes cihazınızın tam kontrolünü ele geçirebilir. Bunu beklemiyorsanız yazılımı hemen kaldırın."), + ("Security Alert", "Güvenlik Uyarısı"), + ("My address book", "Adres defterim"), + ("Personal", "Kişisel"), + ("Owner", "Sahip"), + ("Set shared password", "Paylaşılan parolayı ayarla"), + ("Exist in", "İçinde varolan"), + ("Read-only", "Salt okunur"), + ("Read/Write", "Okuma/Yazma"), + ("Full Control", "Tam Kontrol"), + ("share_warning_tip", "Yukarıdaki alanlar paylaşılır ve başkaları tarafından görülebilir"), + ("Everyone", "Herkes"), + ("ab_web_console_tip", "Web konsolu hakkında daha fazla bilgi"), + ("allow-only-conn-window-open-tip", "Yalnızca RustDesk penceresi açıksa bağlantıya izin ver"), + ("no_need_privacy_mode_no_physical_displays_tip", "Fiziksel ekran yok, gizlilik modunu kullanmaya gerek yok."), + ("Follow remote cursor", "Uzak imleci takip et"), + ("Follow remote window focus", "Uzak pencere odağını takip et"), + ("default_proxy_tip", "Varsayılan protokol ve port Socks5 ve 1080'dir."), + ("no_audio_input_device_tip", "Ses girişi aygıtı bulunamadı."), + ("Incoming", "Gelen"), + ("Outgoing", "Giden"), + ("Clear Wayland screen selection", "Wayland ekran seçimini temizle"), + ("clear_Wayland_screen_selection_tip", "Ekran seçimini temizledikten sonra paylaşılacak ekranı tekrar seçebilirsiniz."), + ("confirm_clear_Wayland_screen_selection_tip", "Wayland ekran seçimini temizlemek istediğinizden emin misiniz?"), + ("android_new_voice_call_tip", "Yeni bir sesli arama isteği alındı. Kabul ederseniz sesli iletişime geçilecektir."), + ("texture_render_tip", "Resimleri daha pürüzsüz hale getirmek için doku oluşturmayı kullanın. Oluşturma sorunlarıyla karşılaşırsanız bu seçeneği devre dışı bırakmayı deneyebilirsiniz."), + ("Use texture rendering", "Doku oluşturmayı kullan"), + ("Floating window", "Yüzen pencere"), + ("floating_window_tip", "RustDesk arka plan hizmetini açık tutmaya yardımcı olur"), + ("Keep screen on", "Ekranı açık tut"), + ("Never", "Asla"), + ("During controlled", "Kontrol sırasında"), + ("During service is on", "Servis açıkken"), + ("Capture screen using DirectX", "DirectX kullanarak ekran görüntüsü al"), + ("Back", "Geri"), + ("Apps", "Uygulamalar"), + ("Volume up", "Sesi yükselt"), + ("Volume down", "Sesi azalt"), + ("Power", "Güç"), + ("Telegram bot", "Telegram botu"), + ("enable-bot-tip", "Bu özelliği etkinleştirirseniz botunuzdan 2FA kodunu alabilirsiniz. Aynı zamanda bağlantı bildirimi işlevi de görebilir."), + ("enable-bot-desc", "1. @BotFather ile bir sohbet açın.\n2. \"/newbot\" komutunu gönderin. Bu adımı tamamladıktan sonra bir jeton alacaksınız.\n3. Yeni oluşturduğunuz botla bir sohbet başlatın. Etkinleştirmek için eğik çizgiyle (\"/\") başlayan \"/merhaba\" gibi bir mesaj gönderin.\n"), + ("cancel-2fa-confirm-tip", "2FA'yı iptal etmek istediğinizden emin misiniz?"), + ("cancel-bot-confirm-tip", "Telegram botunu iptal etmek istediğinizden emin misiniz?"), + ("About RustDesk", "RustDesk Hakkında"), + ("Send clipboard keystrokes", "Panoya tuş vuruşlarını gönder"), + ("network_error_tip", "Lütfen ağ bağlantınızı kontrol edin ve ardından yeniden dene'ye tıklayın."), + ("Unlock with PIN", "PIN ile kilidi açın"), + ("Requires at least {} characters", "En az {} karakter gerektirir"), + ("Wrong PIN", "Yanlış PIN"), + ("Set PIN", "PIN'i ayarla"), + ("Enable trusted devices", "Güvenilir cihazları etkinleştir"), + ("Manage trusted devices", "Güvenilir cihazları yönet"), + ("Platform", "Platform"), + ("Days remaining", "Kalan gün sayısı"), + ("enable-trusted-devices-tip", "Güvenilir cihazlarda 2FA doğrulamasını atla"), + ("Parent directory", "Üst dizin"), + ("Resume", "Devam ettir"), + ("Invalid file name", "Geçersiz dosya adı"), + ("one-way-file-transfer-tip", "Kontrol edilen tarafta tek yönlü dosya transferi aktiftir."), + ("Authentication Required", "Kimlik Doğrulama Gerekli"), + ("Authenticate", "Kimlik Doğrula"), + ("web_id_input_tip", "Aynı sunucuda bir kimlik girebilirsiniz, web istemcisinde doğrudan IP erişimi desteklenmez.\nBaşka bir sunucudaki bir cihaza erişmek istiyorsanız lütfen sunucu adresini (@?key=) ekleyin, örneğin,\n9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=.\nGenel bir sunucudaki bir cihaza erişmek istiyorsanız, lütfen \"@public\" girin, genel sunucu için anahtara gerek yoktur."), + ("Download", "İndir"), + ("Upload folder", "Klasör yükle"), + ("Upload files", "Dosya yükle"), + ("Clipboard is synchronized", "Pano senkronize edildi"), + ("Update client clipboard", "İstemci panosunu güncelle"), + ("Untagged", "Etiketsiz"), + ("new-version-of-{}-tip", "{}'nin yeni bir sürümü mevcut"), + ("Accessible devices", "Erişilebilir cihazlar"), + ("upgrade_remote_rustdesk_client_to_{}_tip", "Lütfen uzak tarafta RustDesk istemcisini {} sürümüne veya daha yenisine güncelleyin!"), + ("d3d_render_tip", "D3D oluşturma etkinleştirildiğinde, bazı bilgisayarlarda uzak kontrol ekranı siyah görünebilir."), + ("Use D3D rendering", "D3D oluşturmayı kullan"), + ("Printer", "Yazıcı"), + ("printer-os-requirement-tip", "Yazıcı çıkış fonksiyonu için Windows 10 veya üzeri gereklidir."), + ("printer-requires-installed-{}-client-tip", "Uzaktan yazdırmayı kullanabilmek için bu cihaza {} yüklenmesi gerekir."), + ("printer-{}-not-installed-tip", "{} Yazıcısı yüklü değil."), + ("printer-{}-ready-tip", "{} Yazıcısı kuruldu ve kullanıma hazır."), + ("Install {} Printer", "{} Yazıcısını Yükle"), + ("Outgoing Print Jobs", "Giden Yazdırma İşleri"), + ("Incoming Print Jobs", "Gelen Yazdırma İşleri"), + ("Incoming Print Job", "Gelen Yazdırma İşi"), + ("use-the-default-printer-tip", "Varsayılan yazıcıyı kullan"), + ("use-the-selected-printer-tip", "Seçili yazıcıyı kullan"), + ("auto-print-tip", "Seçili yazıcıyı kullanarak otomatik olarak yazdır."), + ("print-incoming-job-confirm-tip", "Uzak bir kaynaktan yazdırma işi aldınız. Bunu kendi tarafınızda çalıştırmak ister misiniz?"), + ("remote-printing-disallowed-tile-tip", "Uzak Yazdırma engellendi"), + ("remote-printing-disallowed-text-tip", "Kontrol edilen tarafın izin ayarları Uzak Yazdırmaya izin vermiyor."), + ("save-settings-tip", "Ayarları kaydet"), + ("dont-show-again-tip", "Bunu bir daha gösterme"), + ("Take screenshot", "Ekran görüntüsü al"), + ("Taking screenshot", "Ekran görüntüsü alınıyor"), + ("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"), + ("Copy to clipboard", "Panoya kopyala"), + ("Enable remote printer", "Uzak yazıcıyı etkinleştir"), + ("Downloading {}", "{} indiriliyor"), + ("{} Update", "{} Güncellemesi"), + ("{}-to-update-tip", "{} şimdi kapanacak ve yeni sürüm kurulacak."), + ("download-new-version-failed-tip", "İndirme başarısız oldu. Tekrar deneyebilir veya 'İndir' düğmesine tıklayarak sürüm sayfasından manuel olarak indirip güncelleyebilirsiniz."), + ("Auto update", "Otomatik güncelleme"), + ("update-failed-check-msi-tip", "Kurulum yöntemi denetimi başarısız oldu. Sürüm sayfasından indirmek ve manuel olarak yükseltmek için lütfen \"İndir\" düğmesine tıklayın."), + ("websocket_tip", "WebSocket kullanıldığında yalnızca aktarma bağlantıları desteklenir."), + ("Use WebSocket", "WebSocket'ı kullan"), + ("Trackpad speed", "İzleme paneli hızı"), + ("Default trackpad speed", "Varsayılan izleme paneli hızı"), + ("Numeric one-time password", "Sayısal tek seferlik parola"), + ("Enable IPv6 P2P connection", "IPv6 P2P bağlantısını etkinleştir"), + ("Enable UDP hole punching", "UDP delik açmayı etkinleştir"), + ("View camera", "Kamerayı görüntüle"), + ("Enable camera", "Kamerayı etkinleştir"), + ("No cameras", "Kamera yok"), + ("view_camera_unsupported_tip", "Uzak cihaz, kameranın görüntülenmesini desteklemiyor."), + ("Terminal", "Terminal"), + ("Enable terminal", "Terminali etkinleştir"), + ("New tab", "Yeni sekme"), + ("Keep terminal sessions on disconnect", "Bağlantı kesildiğinde terminal oturumlarını açık tut"), + ("Terminal (Run as administrator)", "Terminal (Yönetici olarak çalıştır)"), + ("terminal-admin-login-tip", "Lütfen kontrol edilen tarafın yönetici kullanıcı adı ve parolasını giriniz."), + ("Failed to get user token.", "Kullanıcı belirteci alınamadı."), + ("Incorrect username or password.", "Hatalı kullanıcı adı veya parola."), + ("The user is not an administrator.", "Kullanıcı bir yönetici değil."), + ("Failed to check if the user is an administrator.", "Kullanıcının yönetici olup olmadığı kontrol edilemedi."), + ("Supported only in the installed version.", "Sadece yüklü sürümde desteklenir."), + ("elevation_username_tip", "Kullanıcı adı veya etki alanı\\kullanıcı adı girin"), + ("Preparing for installation ...", "Kuruluma hazırlanıyor..."), + ("Show my cursor", "İmlecimi göster"), + ("Scale custom", "Özel ölçekte"), + ("Custom scale slider", "Özel ölçek kaydırıcısı"), + ("Decrease", "Azalt"), + ("Increase", "Arttır"), + ("Show virtual mouse", "Sanal fareyi göster"), + ("Virtual mouse size", "Sanal fare boyutu"), + ("Small", "Küçük"), + ("Large", "Büyük"), + ("Show virtual joystick", "Sanal joystiği göster"), + ("Edit note", "Notu düzenle"), + ("Alias", "Takma ad"), + ("ScrollEdge", "Kaydırma kenarı"), + ("Allow insecure TLS fallback", "Güvensiz TLS geri dönüşüne izin ver"), + ("allow-insecure-tls-fallback-tip", "Varsayılan olarak, RustDesk sunucu sertifikasını TLS kullanarak protokoller için doğrular.\nBu seçenek etkinleştirildiğinde, doğrulama başarısızlığı durumunda RustDesk doğrulama adımını atlayarak işleme devam eder."), + ("Disable UDP", "UDP'yi devre dışı bırak"), + ("disable-udp-tip", "Yalnızca TCP kullanılıp kullanılmayacağını kontrol eder.\nBu seçenek etkinleştirildiğinde, RustDesk artık UDP 21116'yı kullanmayacak, bunun yerine TCP 21116 kullanılacaktır."), + ("server-oss-not-support-tip", "NOT: RustDesk sunucu OSS'si bu özelliği içermemektedir."), + ("input note here", "Notu buraya girin"), + ("note-at-conn-end-tip", "Bağlantı bittiğinde not sorulsun"), + ("Show terminal extra keys", "Terminal ek tuşlarını göster"), + ("Relative mouse mode", "Fareyi göreli modda kullan"), + ("rel-mouse-not-supported-peer-tip", "Karşı taraf göreli fare modunu desteklemiyor"), + ("rel-mouse-not-ready-tip", "Göreli fare modu henüz hazır değil"), + ("rel-mouse-lock-failed-tip", "Göreli fare kilitlenemedi"), + ("rel-mouse-exit-{}-tip", "Göreli fare modundan çıkmak için {}"), + ("rel-mouse-permission-lost-tip", "Göreli fare izinleri geçerli değil"), + ("Changelog", "Değişiklik Günlüğü"), + ("keep-awake-during-outgoing-sessions-label", "Giden oturumlar süresince ekranı açık tutun"), + ("keep-awake-during-incoming-sessions-label", "Gelen oturumlar süresince ekranı açık tutun"), + ("Continue with {}", "{} ile devam et"), + ("Display Name", "Görünen Ad"), + ("password-hidden-tip", "Parola gizli"), + ("preset-password-in-use-tip", "Önceden ayarlanmış parola kullanılıyor"), + ("Enable privacy mode", "Gizlilik modunu etkinleştir"), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/tw.rs b/src/lang/tw.rs index b0f64f82d..6df025303 100644 --- a/src/lang/tw.rs +++ b/src/lang/tw.rs @@ -7,7 +7,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Password", "密碼"), ("Ready", "就緒"), ("Established", "已建立"), - ("connecting_status", "正在連線到 RustDesk 網路 ..."), + ("connecting_status", "正在連線到 RustDesk 網路..."), ("Enable service", "啟用服務"), ("Start service", "啟動服務"), ("Service is running", "服務正在執行"), @@ -41,11 +41,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("length %min% to %max%", "長度在 %min% 與 %max% 之間"), ("starts with a letter", "以字母開頭"), ("allowed characters", "允許的字元"), - ("id_change_tip", "僅能使用以下字元:a-z、A-Z、0-9、_ (底線)。第一個字元必須為 a-z 或 A-Z。長度介於 6 到 16 之間。"), + ("id_change_tip", "僅能使用以下字元:a-z、A-Z、0-9、 - (dash)、_ (底線)。第一個字元必須為 a-z 或 A-Z。長度介於 6 到 16 之間。"), ("Website", "網站"), ("About", "關於"), ("Slogan_tip", "在這個混沌的世界中用心製作!"), - ("Privacy Statement", "隱私權聲明"), + ("Privacy Statement", "隱私權宣告"), ("Mute", "靜音"), ("Build Date", "建構日期"), ("Version", "版本"), @@ -76,12 +76,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Connection Error", "連線錯誤"), ("Error", "錯誤"), ("Reset by the peer", "對方重設了連線"), - ("Connecting...", "正在連線 ..."), + ("Connecting...", "正在連線..."), ("Connection in progress. Please wait.", "正在連線,請稍候。"), ("Please try 1 minute later", "請於 1 分鐘後再試"), ("Login Error", "登入錯誤"), ("Successful", "成功"), - ("Connected, waiting for image...", "已連線,等待畫面傳輸 ..."), + ("Connected, waiting for image...", "已連線,等待畫面傳輸..."), ("Name", "名稱"), ("Type", "類型"), ("Modified", "修改時間"), @@ -107,9 +107,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Are you sure you want to delete the file of this directory?", "您確定要刪除此資料夾中的檔案嗎?"), ("Do this for all conflicts", "套用到其他衝突"), ("This is irreversible!", "此操作不可逆!"), - ("Deleting", "正在刪除 ..."), + ("Deleting", "正在刪除..."), ("files", "檔案"), - ("Waiting", "正在等候 ..."), + ("Waiting", "正在等候..."), ("Finished", "已完成"), ("Speed", "速度"), ("Custom Image Quality", "自訂畫面品質"), @@ -120,8 +120,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Original", "原始"), ("Shrink", "縮減"), ("Stretch", "延展"), - ("Scrollbar", "滾動條"), - ("ScrollAuto", "自動滾動"), + ("Scrollbar", "捲動條"), + ("ScrollAuto", "自動捲動"), ("Good image quality", "最佳化畫面品質"), ("Balanced", "平衡"), ("Optimize reaction time", "最佳化反應時間"), @@ -147,12 +147,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("OS Password", "作業系統密碼"), ("install_tip", "UAC 會導致 RustDesk 在某些情況下無法正常作為遠端端點運作。若要避開 UAC,請點選下方按鈕將 RustDesk 安裝到系統中。"), ("Click to upgrade", "點選以升級"), - ("Click to download", "點選以下載"), - ("Click to update", "點選以更新"), ("Configure", "設定"), - ("config_acc", "為了遠端控制您的桌面,您需要授予 RustDesk「協助工具」權限。"), + ("config_acc", "為了遠端控制您的桌面,您需要授予 RustDesk「無障礙功能」權限。"), ("config_screen", "為了遠端存取您的桌面,您需要授予 RustDesk「螢幕錄製」權限。"), - ("Installing ...", "正在安裝 ..."), + ("Installing ...", "正在安裝..."), ("Install", "安裝"), ("Installation", "安裝"), ("Installation Path", "安裝路徑"), @@ -161,16 +159,16 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("agreement_tip", "開始安裝即表示您接受授權條款。"), ("Accept and Install", "接受並安裝"), ("End-user license agreement", "終端使用者授權合約"), - ("Generating ...", "正在產生 ..."), + ("Generating ...", "正在產生..."), ("Your installation is lower version.", "您安裝的版本過舊。"), ("not_close_tcp_tip", "在使用通道時請不要關閉此視窗"), - ("Listening ...", "正在等待通道連線 ..."), + ("Listening ...", "正在等待通道連線..."), ("Remote Host", "遠端主機"), ("Remote Port", "遠端連接埠"), ("Action", "操作"), ("Add", "新增"), ("Local Port", "本機連接埠"), - ("Local Address", "本機地址"), + ("Local Address", "本機位址"), ("Change Local Port", "修改本機連接埠"), ("setup_server_tip", "若您需要更快的連線速度,您可以選擇自行建立伺服器"), ("Too short, at least 6 characters.", "過短,至少需要 6 個字元。"), @@ -187,8 +185,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relayed and unencrypted connection", "中繼且未加密的連線"), ("Enter Remote ID", "輸入遠端 ID"), ("Enter your password", "輸入您的密碼"), - ("Logging in...", "正在登入 ..."), - ("Enable RDP session sharing", "啟用 RDP 工作階段共享"), + ("Logging in...", "正在登入..."), + ("Enable RDP session sharing", "啟用 RDP 工作階段分享"), ("Auto Login", "自動登入 (只在您設定「工作階段結束後鎖定」時有效)"), ("Enable direct IP access", "啟用 IP 直接存取"), ("Rename", "重新命名"), @@ -216,9 +214,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Login", "登入"), ("Verify", "驗證"), ("Remember me", "記住我"), - ("Trust this device", "信任此裝置"), + ("Trust this device", "信任這部裝置"), ("Verification code", "驗證碼"), - ("verification_tip", "驗證碼已發送到註冊的電子郵件地址,請輸入驗證碼以繼續登入。"), + ("verification_tip", "驗證碼已傳送到註冊的電子郵件地址,請輸入驗證碼以繼續登入。"), ("Logout", "登出"), ("Tags", "標籤"), ("Search ID", "搜尋 ID"), @@ -267,7 +265,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("No permission of file transfer", "沒有檔案傳輸權限"), ("Note", "備註"), ("Connection", "連線"), - ("Share Screen", "螢幕分享"), + ("Share screen", "螢幕分享"), ("Chat", "聊天"), ("Total", "總計"), ("items", "個項目"), @@ -275,8 +273,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Screen Capture", "畫面錄製"), ("Input Control", "輸入控制"), ("Audio Capture", "音訊錄製"), - ("File Connection", "檔案連線"), - ("Screen Connection", "畫面連線"), ("Do you accept?", "是否接受?"), ("Open System Setting", "開啟系統設定"), ("How to get Android input permission?", "如何取得 Android 的輸入權限?"), @@ -329,7 +325,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Display Settings", "顯示設定"), ("Ratio", "比例"), ("Image Quality", "畫質"), - ("Scroll Style", "滾動樣式"), + ("Scroll Style", "捲動樣式"), ("Show Toolbar", "顯示工具列"), ("Hide Toolbar", "隱藏工具列"), ("Direct Connection", "直接連線"), @@ -354,7 +350,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Direct IP Access", "IP 直接連線"), ("Proxy", "代理伺服器"), ("Apply", "套用"), - ("Disconnect all devices?", "中斷所有遠端連線?"), + ("Disconnect all devices?", "是否中斷所有遠端連線?"), ("Clear", "清空"), ("Audio Input Device", "音訊輸入裝置"), ("Use IP Whitelisting", "只允許白名單上的 IP 進行連線"), @@ -364,7 +360,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Recording", "錄製"), ("Directory", "路徑"), ("Automatically record incoming sessions", "自動錄製連入的工作階段"), - ("Automatically record outgoing sessions", ""), + ("Automatically record outgoing sessions", "自動錄製連出的工作階段"), ("Change", "變更"), ("Start session recording", "開始錄影"), ("Stop session recording", "停止錄影"), @@ -373,7 +369,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Deny LAN discovery", "拒絕區域網路探索"), ("Write a message", "輸入聊天訊息"), ("Prompt", "提示"), - ("Please wait for confirmation of UAC...", "請等待對方確認 UAC ..."), + ("Please wait for confirmation of UAC...", "請等待對方確認 UAC..."), ("elevated_foreground_window_tip", "目前遠端桌面的視窗需要更高的權限才能繼續操作,您暫時無法使用滑鼠和鍵盤,您可以請求對方最小化目前視窗,或者在連線管理視窗點選提升權限。為了避免這個問題,建議在遠端裝置上安裝本軟體。"), ("Disconnected", "斷開連線"), ("Other", "其他"), @@ -381,20 +377,20 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Keyboard Settings", "鍵盤設定"), ("Full Access", "完全存取"), ("Screen Share", "僅分享螢幕畫面"), - ("Wayland requires Ubuntu 21.04 or higher version.", "Wayland 需要 Ubuntu 21.04 或更新的版本。"), - ("Wayland requires higher version of linux distro. Please try X11 desktop or change your OS.", "Wayland 需要更新版的 Linux 發行版。請嘗試使用 X11 桌面或更改您的作業系統。"), + ("ubuntu-21-04-required", "Wayland 需要 Ubuntu 21.04 或更新的版本。"), + ("wayland-requires-higher-linux-version", "Wayland 需要更新版的 Linux 發行版。請嘗試使用 X11 桌面或更改您的作業系統。"), + ("xdp-portal-unavailable", ""), ("JumpLink", "查看"), ("Please Select the screen to be shared(Operate on the peer side).", "請選擇要分享的螢幕畫面(在對方的裝置上操作)。"), ("Show RustDesk", "顯示 RustDesk"), ("This PC", "此電腦"), ("or", "或"), - ("Continue with", "繼續"), ("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...", "請等待對方接受您的連線請求 ..."), + ("Please wait for the remote side to accept your session request...", "請等待對方接受您的連線請求..."), ("One-time Password", "一次性密碼"), ("Use one-time password", "使用一次性密碼"), ("One-time password length", "一次性密碼長度"), @@ -410,16 +406,16 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Closed manually by web console", "被 Web 控制台手動關閉"), ("Local keyboard type", "本機鍵盤類型"), ("Select local keyboard type", "請選擇本機鍵盤類型"), - ("software_render_tip", "如果您使用 Nvidia 顯示卡,並且遠端視窗在建立連線後會立刻關閉,那麼請安裝 nouveau 顯示卡驅動程式並且選擇使用軟體渲染可能會有幫助。重新啟動軟體後生效。"), - ("Always use software rendering", "使用軟體渲染"), - ("config_input", "為了能夠透過鍵盤控制遠端桌面,請給予 RustDesk \"輸入監控\" 權限。"), - ("config_microphone", "為了支援透過麥克風進行音訊傳輸,請給予 RustDesk \"錄音\"權限。"), + ("software_render_tip", "如果您使用 Nvidia 顯示卡,並且遠端視窗在建立連線後會立刻關閉,那麼請安裝 nouveau 顯示卡驅動程式並且選擇使用軟體繪製可能會有幫助。重新啟動軟體後生效。"), + ("Always use software rendering", "使用軟體繪製"), + ("config_input", "為了能夠透過鍵盤控制遠端桌面,請給予 RustDesk「輸入監控」權限。"), + ("config_microphone", "為了支援透過麥克風進行音訊傳輸,請給予 RustDesk「錄音」權限。"), ("request_elevation_tip", "如果遠端使用者可以操作電腦,您可以請求提升權限。"), ("Wait", "等待"), ("Elevation Error", "權限提升失敗"), - ("Ask the remote user for authentication", "請求遠端使用者進行身分驗證"), + ("Ask the remote user for authentication", "請求遠端使用者進行驗證"), ("Choose this if the remote account is administrator", "當遠端使用者帳戶是管理員時,請選擇此選項"), - ("Transmit the username and password of administrator", "發送管理員的使用者名稱和密碼"), + ("Transmit the username and password of administrator", "傳送管理員的使用者名稱和密碼"), ("still_click_uac_tip", "依然需要遠端使用者在執行 RustDesk 時於 UAC 視窗點選「是」。"), ("Request Elevation", "請求權限提升"), ("wait_accept_uac_tip", "請等待遠端使用者確認 UAC 對話框。"), @@ -436,17 +432,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Please confirm if you want to share your desktop?", "請確認是否要讓對方存取您的桌面?"), ("Display", "顯示"), ("Default View Style", "預設顯示方式"), - ("Default Scroll Style", "預設滾動方式"), - ("Default Image Quality", "預設圖像品質"), + ("Default Scroll Style", "預設捲動方式"), + ("Default Image Quality", "預設影像品質"), ("Default Codec", "預設編解碼器"), ("Bitrate", "位元速率"), - ("FPS", "幀率"), + ("FPS", "FPS"), ("Auto", "自動"), ("Other Default Options", "其他預設選項"), ("Voice call", "語音通話"), ("Text chat", "文字聊天"), ("Stop voice call", "停止語音通話"), - ("relay_hint_tip", "可能無法使用直接連線,您可以嘗試中繼連線。\n另外,如果想要直接使用中繼連線,您可以在 ID 後面新增 \"/r\",或是如果近期的工作階段裡存在該設備,您也可以在設備選項裡選擇「一律透過中繼連線」。"), + ("relay_hint_tip", "可能無法使用直接連線,您可以嘗試中繼連線。\n另外,如果想要直接使用中繼連線,您可以在 ID 後面新增「/r」,或是如果近期的工作階段裡存在該裝置,您也可以在裝置選項裡選擇「一律透過中繼連線」。"), ("Reconnect", "重新連線"), ("Codec", "編解碼器"), ("Resolution", "解析度"), @@ -463,7 +459,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("empty_favorite_tip", "空空如也"), ("empty_lan_tip", "喔不,看來我們目前找不到任何夥伴。"), ("empty_address_book_tip", "老天,看來您的通訊錄中沒有任何夥伴。"), - ("eg: admin", "例如:admin"), ("Empty Username", "空使用者帳號"), ("Empty Password", "空密碼"), ("Me", "我"), @@ -498,7 +493,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Options", "選項"), ("resolution_original_tip", "原始解析度"), ("resolution_fit_local_tip", "調整成本機解析度"), - ("resolution_custom_tip", "自動解析度"), + ("resolution_custom_tip", "自訂解析度"), ("Collapse toolbar", "收回工具列"), ("Accept and Elevate", "接受並提升權限"), ("accept_and_elevate_btn_tooltip", "接受連線並提升 UAC 權限。"), @@ -526,8 +521,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Select", "選擇"), ("Toggle Tags", "切換標籤"), ("pull_ab_failed_tip", "通訊錄更新失敗"), - ("push_ab_failed_tip", "成功同步通訊錄至伺服器"), - ("synced_peer_readded_tip", "最近會話中存在的設備將會被重新同步到通訊錄。"), + ("push_ab_failed_tip", "同步通訊錄至伺服器失敗"), + ("synced_peer_readded_tip", "最近工作階段中存在的裝置將會被重新同步到通訊錄。"), ("Change Color", "更改顏色"), ("Primary Color", "基本色"), ("HSV Color", "HSV 色"), @@ -559,17 +554,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Change view", "更改檢視方式"), ("Big tiles", "大磁磚"), ("Small tiles", "小磁磚"), - ("List", "列表"), + ("List", "清單"), ("Virtual display", "虛擬螢幕"), ("Plug out all", "拔出所有"), ("True color (4:4:4)", "全彩模式(4:4:4)"), ("Enable blocking user input", "允許封鎖使用者輸入"), - ("id_input_tip", "您可以輸入 ID、IP、或網域名稱+通訊埠號(<網域名稱>:<通訊埠號>)。\n如果您要存取位於其他伺服器上的設備,請在 ID 之後添加伺服器地址(@<伺服器地址>?key=<金鑰>)\n例如:9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=\n要存取公共伺服器上的設備,請輸入\"@public\",不需輸入金鑰。\n\n如果您想要在第一次連線時,強制使用中繼連接,請在 ID 的末尾添加 \"/r\",例如,\"9123456234/r\"。"), + ("id_input_tip", "您可以輸入 ID、IP、或網域名稱+連接埠(<網域名稱>:<連接埠>)。\n如果您要存取位於其他伺服器上的裝置,請在 ID 之後新增伺服器位址(@<伺服器位址>?key=<金鑰>)\n例如:9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=\n要存取公共伺服器上的裝置,請輸入「@public」,不需輸入金鑰。\n\n如果您想要在第一次連線時,強制使用中繼連線,請在 ID 的結尾新增「/r」,例如,「9123456234/r」。"), ("privacy_mode_impl_mag_tip", "模式 1"), ("privacy_mode_impl_virtual_display_tip", "模式 2"), ("Enter privacy mode", "進入隱私模式"), ("Exit privacy mode", "退出隱私模式"), - ("idd_not_support_under_win10_2004_tip", "不支援 Indirect display driver。 需要 Windows 10 版本 2004 或更新的版本。"), + ("idd_not_support_under_win10_2004_tip", "不支援 Indirect display driver。需要 Windows 10 版本 2004 以上版本。"), ("input_source_1_tip", "輸入源 1"), ("input_source_2_tip", "輸入源 2"), ("Swap control-command key", "交換 Control 和 Command 按鍵"), @@ -577,28 +572,28 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("2FA code", "二步驟驗證碼"), ("More", "更多"), ("enable-2fa-title", "啟用二步驟驗證"), - ("enable-2fa-desc", "現在請您設定您的二步驟驗證程式。 您可以在手機或電腦使用 Authy、Microsoft 或 Google Authenticator 等驗證器程式。\n\n用它掃描QR Code或輸入下方金鑰至您的驗證器,然後輸入顯示的驗證碼以啟用二步驟驗證。"), - ("wrong-2fa-code", "無法驗證此驗證碼。 請確認您的驗證碼和您的本地時間設置是正確的"), + ("enable-2fa-desc", "現在請您設定您的二步驟驗證程式。您可以在手機或電腦使用 Authy、Microsoft 或 Google Authenticator 等驗證器程式。\n\n用它掃描QR Code或輸入下方金鑰至您的驗證器,然後輸入顯示的驗證碼以啟用二步驟驗證。"), + ("wrong-2fa-code", "無法驗證此驗證碼。請確認您的驗證碼和您的本機時間設定是正確的"), ("enter-2fa-title", "二步驟驗證"), - ("Email verification code must be 6 characters.", "Email 驗證碼必須是 6 個字元。"), + ("Email verification code must be 6 characters.", "電子郵件驗證碼必須是 6 個字元。"), ("2FA code must be 6 digits.", "二步驟驗證碼必須是 6 位數字。"), ("Multiple Windows sessions found", "發現多個 Windows 工作階段"), ("Please select the session you want to connect to", "請選擇您想要連結的工作階段"), ("powered_by_me", "由 RustDesk 提供支援"), - ("outgoing_only_desk_tip", "目前版本的軟體是自定義版本。\n您可以連接至其他設備,但是其他設備無法連接至您的設備。"), - ("preset_password_warning", "此客製化版本附有預設密碼。任何知曉此密碼的人都能完全控制您的裝置。如果這不是您所預期的,請立即卸載此軟體。"), + ("outgoing_only_desk_tip", "目前版本的軟體是自訂版本。\n您可以連線至其他裝置,但是其他裝置無法連線至您的裝置。"), + ("preset_password_warning", "此客製化版本附有預設密碼。任何知曉此密碼的人都能完全控制您的裝置。如果這不是您所預期的,請立即移除此軟體。"), ("Security Alert", "安全警告"), ("My address book", "我的通訊錄"), ("Personal", "個人的"), ("Owner", "擁有者"), - ("Set shared password", "設定共享密碼"), + ("Set shared password", "設定共用密碼"), ("Exist in", "存在於"), ("Read-only", "唯讀"), ("Read/Write", "讀寫"), ("Full Control", "完全控制"), - ("share_warning_tip", "上述的欄位為共享且對其他人可見。"), + ("share_warning_tip", "上述的欄位為共用且對其他人可見。"), ("Everyone", "所有人"), - ("ab_web_console_tip", "打開 Web 控制台以進行更多操作"), + ("ab_web_console_tip", "開啟 Web 控制台以進行更多操作"), ("allow-only-conn-window-open-tip", "只在 RustDesk 視窗開啟時允許連接"), ("no_need_privacy_mode_no_physical_displays_tip", "沒有物理螢幕,沒必要使用隱私模式。"), ("Follow remote cursor", "跟隨遠端游標"), @@ -611,8 +606,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("clear_Wayland_screen_selection_tip", "清除 Wayland 的螢幕選擇後,您可以重新選擇分享的螢幕。"), ("confirm_clear_Wayland_screen_selection_tip", "是否確認清除 Wayland 的分享螢幕選擇?"), ("android_new_voice_call_tip", "收到新的語音通話請求。如果您接受,音訊將切換為語音通訊。"), - ("texture_render_tip", "使用紋理渲染,讓圖片更加順暢。 如果您遭遇渲染問題,可嘗試關閉此選項。"), - ("Use texture rendering", "使用紋理渲染"), + ("texture_render_tip", "使用紋理繪製,讓圖片更加順暢。如果您遭遇繪製問題,可嘗試關閉此選項。"), + ("Use texture rendering", "使用紋理繪製"), ("Floating window", "懸浮視窗"), ("floating_window_tip", "有助於保持 RustDesk 後台服務"), ("Keep screen on", "保持螢幕開啟"), @@ -627,31 +622,128 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Power", "電源"), ("Telegram bot", "Telegram 機器人"), ("enable-bot-tip", "如果您啟用此功能,您可以從您的機器人接收二步驟驗證碼,亦可作為連線通知之用。"), - ("enable-bot-desc", "1. 開啟與 @BotFather 的對話。\n2. 傳送指令 \"/newbot\"。 您將會在完成此步驟後收到權杖 (Token)。\n3. 開始與您剛創立的機器人的對話。 傳送一則以正斜槓 (\"/\") 開頭的訊息來啟用它,例如 \"/hello\"。"), + ("enable-bot-desc", "1. 開啟與 @BotFather 的對話。\n2. 傳送指令「/newbot」。您將會在完成此步驟後收到權杖 (Token)。\n3. 開始與您剛創立的機器人的對話。傳送一則以正斜線 (「/」) 開頭的訊息來啟用它,例如「/hello」。"), ("cancel-2fa-confirm-tip", "確定要取消二步驟驗證嗎?"), ("cancel-bot-confirm-tip", "確定要取消 Telegram 機器人嗎?"), ("About RustDesk", "關於 RustDesk"), - ("Send clipboard keystrokes", "發送剪貼簿按鍵"), - ("network_error_tip", "請檢查網路連結,然後點擊重試"), + ("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", "管理信任設備"), + ("Enable trusted devices", "啟用信任裝置"), + ("Manage trusted devices", "管理信任裝置"), ("Platform", "平台"), ("Days remaining", "剩餘天數"), - ("enable-trusted-devices-tip", "允許受信任的設備跳過 2FA 驗證"), + ("enable-trusted-devices-tip", "允許受信任的裝置跳過 2FA 驗證"), ("Parent directory", "父目錄"), ("Resume", "繼續"), - ("Invalid file name", "無效文件名"), - ("one-way-file-transfer-tip", "被控端啟用了單向文件傳輸"), - ("Authentication Required", "需要身分驗證"), + ("Invalid file name", "無效檔名"), + ("one-way-file-transfer-tip", "被控端啟用了單向檔案傳輸"), + ("Authentication Required", "需要驗證"), ("Authenticate", "認證"), - ("web_id_input_tip", "您可以輸入同一個伺服器內的 ID,Web 客戶端不支援直接 IP 存取。\n如果您要存取位於其他伺服器上的設備,請在 ID 之後添加伺服器地址(@<伺服器地址>?key=<金鑰>)\n例如:9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=\n要存取公共伺服器上的設備,請輸入\"@public\",不需輸入金鑰。"), + ("web_id_input_tip", "您可以輸入同一個伺服器內的 ID,Web 客戶端不支援直接 IP 存取。\n如果您要存取位於其他伺服器上的裝置,請在 ID 之後新增伺服器位址(@<伺服器位址>?key=<金鑰>)\n例如:9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=\n要存取公共伺服器上的裝置,請輸入「@public」,不需輸入金鑰。"), ("Download", "下載"), ("Upload folder", "上傳資料夾"), ("Upload files", "上傳檔案"), - ("Clipboard is synchronized", ""), + ("Clipboard is synchronized", "剪貼簿已同步"), + ("Update client clipboard", "更新客戶端的剪貼簿"), + ("Untagged", "無標籤"), + ("new-version-of-{}-tip", "有新版本的 {} 可用"), + ("Accessible devices", "可存取的裝置"), + ("upgrade_remote_rustdesk_client_to_{}_tip", "請將遠端 RustDesk 客戶端升級到 {} 或更新版本!"), + ("d3d_render_tip", "當啟用 D3D 渲染時,某些機器可能會無法顯示遠端畫面。"), + ("Use D3D rendering", "使用 D3D 渲染"), + ("Printer", "印表機"), + ("printer-os-requirement-tip", "印表機的傳出功能需要 Windows 10 或更高版本。"), + ("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", "使用 WebSocket 時,只支援使用中繼連接。"), + ("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", "預設情況下,對於使用 TLS 的協定,RustDesk 會驗證伺服器的憑證。\n啟用此選項後,在驗證失敗時,RustDesk 將轉為跳過驗證步驟並繼續連接。"), + ("Disable UDP", "停用 UDP"), + ("disable-udp-tip", "控制是否僅使用 TCP。\n啟用此選項後,RustDesk 將不再使用 UDP 21116,而是使用 TCP 21116。"), + ("server-oss-not-support-tip", "注意:RustDesk 開源伺服器 (OSS server) 不包含此功能。"), + ("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 {}", "使用 {} 登入"), + ("Display Name", "顯示名稱"), + ("password-hidden-tip", "固定密碼已設定(已隱藏)"), + ("preset-password-in-use-tip", "目前正在使用預設密碼"), + ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/uk.rs b/src/lang/uk.rs index ff5c8b64a..7107bc261 100644 --- a/src/lang/uk.rs +++ b/src/lang/uk.rs @@ -41,7 +41,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("length %min% to %max%", "від %min% до %max% символів"), ("starts with a letter", "починається з літери"), ("allowed characters", "дозволені символи"), - ("id_change_tip", "Допускаються лише символи a-z, A-Z, 0-9 і _ (підкреслення). Першою повинна бути літера a-z, A-Z. Довжина — від 6 до 16 символів"), + ("id_change_tip", "Допускаються лише символи a-z, A-Z, 0-9, - (dash) і _ (підкреслення). Першою повинна бути літера a-z, A-Z. Довжина — від 6 до 16 символів"), ("Website", "Веб-сайт"), ("About", "Про застосунок"), ("Slogan_tip", "Створено з душею в цьому хаотичному світі!"), @@ -120,9 +120,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Original", "Оригінал"), ("Shrink", "Зменшити"), ("Stretch", "Розтягнути"), - ("Scrollbar", "Смуга прокрутки"), - ("ScrollAuto", "Автоматична прокрутка"), - ("Good image quality", "Хороша якість зображення"), + ("Scrollbar", "Смужка гортання"), + ("ScrollAuto", "Автоматичне гортання"), + ("Good image quality", "Гарна якість зображення"), ("Balanced", "Збалансована"), ("Optimize reaction time", "Оптимізувати час реакції"), ("Custom", "Користувацька"), @@ -147,8 +147,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("OS Password", "Пароль ОС"), ("install_tip", "Через UAC, в деяких випадках RustDesk може працювати некоректно на віддаленому вузлі. Щоб уникнути UAC, натисніть кнопку нижче для встановлення RustDesk в системі"), ("Click to upgrade", "Натисніть, щоб перевірити наявність оновлень"), - ("Click to download", "Натисніть, щоб завантажити"), - ("Click to update", "Натисніть, щоб оновити"), ("Configure", "Налаштувати"), ("config_acc", "Для віддаленого керування вашою стільницею, вам необхідно надати RustDesk дозволи \"Спеціальні можливості\""), ("config_screen", "Для віддаленого доступу до вашої стільниці, вам необхідно надати RustDesk дозволи на \"Запис екрана\""), @@ -199,10 +197,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Please enter the folder name", "Будь ласка, введіть назву для теки"), ("Fix it", "Виправити"), ("Warning", "Попередження"), - ("Login screen using Wayland is not supported", "Вхід в систему з використанням Wayland не підтримується"), + ("Login screen using Wayland is not supported", "Екран входу, який використовує Wayland, не підтримується"), ("Reboot required", "Потрібне перезавантаження"), ("Unsupported display server", "Графічний сервер не підтримується"), - ("x11 expected", "Очікується X11"), + ("x11 expected", "Потрібен X11"), ("Port", "Порт"), ("Settings", "Налаштування"), ("Username", "Імʼя користувача"), @@ -220,21 +218,21 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Verification code", "Код підтвердження"), ("verification_tip", "Код підтвердження надіслано на зареєстровану email-адресу, введіть код підтвердження для продовження авторизації."), ("Logout", "Вийти"), - ("Tags", "Теги"), + ("Tags", "Мітки"), ("Search ID", "Пошук за ID"), ("whitelist_sep", "Відокремлення комою, крапкою з комою, пропуском або новим рядком"), ("Add ID", "Додати ID"), - ("Add Tag", "Додати ключове слово"), - ("Unselect all tags", "Скасувати вибір усіх тегів"), + ("Add Tag", "Додати мітку"), + ("Unselect all tags", "Скасувати вибір усіх міток"), ("Network error", "Помилка мережі"), ("Username missed", "Імʼя користувача відсутнє"), ("Password missed", "Пароль відсутній"), ("Wrong credentials", "Неправильні дані"), ("The verification code is incorrect or has expired", "Код підтвердження некоректний або протермінований"), - ("Edit Tag", "Редагувати тег"), + ("Edit Tag", "Редагувати мітку"), ("Forget Password", "Не зберігати пароль"), ("Favorites", "Вибране"), - ("Add to Favorites", "Додати в обране"), + ("Add to Favorites", "Додати до обраного"), ("Remove from Favorites", "Видалити з обраного"), ("Empty", "Пусто"), ("Invalid folder name", "Неприпустима назва теки"), @@ -246,7 +244,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Paste", "Вставити"), ("Paste here?", "Вставити сюди?"), ("Are you sure to close the connection?", "Ви впевнені, що хочете завершити підключення?"), - ("Download new version", "Завантажте нову версію"), + ("Download new version", "Завантажити нову версію"), ("Touch mode", "Сенсорний режим"), ("Mouse mode", "Режим миші"), ("One-Finger Tap", "Дотик одним пальцем"), @@ -267,7 +265,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("No permission of file transfer", "Немає дозволу на передачу файлів"), ("Note", "Примітка"), ("Connection", "Підключення"), - ("Share Screen", "Поділитися екраном"), + ("Share screen", "Поділитися екраном"), ("Chat", "Чат"), ("Total", "Всього"), ("items", "елементи"), @@ -275,11 +273,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Screen Capture", "Захоплення екрана"), ("Input Control", "Керування введенням"), ("Audio Capture", "Захоплення аудіо"), - ("File Connection", "Файлове підключення"), - ("Screen Connection", "Підключення екрана"), ("Do you accept?", "Ви згодні?"), ("Open System Setting", "Відкрити налаштування системи"), - ("How to get Android input permission?", "Як отримати дозвіл на введення Android?"), + ("How to get Android input permission?", "Як отримати дозвіл на введення в Android?"), ("android_input_permission_tip1", "Для того, щоб віддалений пристрій міг керувати вашим Android-пристроєм за допомогою миші або дотику, вам необхідно дозволити RustDesk використовувати службу \"Спеціальні можливості\"."), ("android_input_permission_tip2", "Будь ласка, перейдіть на наступну сторінку системних налаштувань, знайдіть та увійдіть у [Встановлені служби], увімкніть службу [RustDesk Input]."), ("android_new_connection_tip", "Отримано новий запит на керування вашим поточним пристроєм."), @@ -307,7 +303,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Ignore Battery Optimizations", "Ігнорувати оптимізації батареї"), ("android_open_battery_optimizations_tip", "Перейдіть на наступну сторінку налаштувань"), ("Start on boot", "Автозапуск"), - ("Start the screen sharing service on boot, requires special permissions", "Запустити службу службу спільного доступу до екрана під час завантаження, потребує спеціальних дозволів"), + ("Start the screen sharing service on boot, requires special permissions", "Запускати службу спільного доступу до екрана під час завантаження, потребує спеціальних дозволів"), ("Connection not allowed", "Підключення не дозволено"), ("Legacy mode", "Застарілий режим"), ("Map mode", "Режим карти"), @@ -329,15 +325,15 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Display Settings", "Налаштування дисплею"), ("Ratio", "Співвідношення"), ("Image Quality", "Якість зображення"), - ("Scroll Style", "Стиль прокрутки"), + ("Scroll Style", "Стиль гортання"), ("Show Toolbar", "Показати панель інструментів"), ("Hide Toolbar", "Приховати панель інструментів"), ("Direct Connection", "Пряме підключення"), ("Relay Connection", "Ретрансльоване підключення"), ("Secure Connection", "Безпечне підключення"), ("Insecure Connection", "Небезпечне підключення"), - ("Scale original", "Оригінал масштабу"), - ("Scale adaptive", "Масштаб адаптивний"), + ("Scale original", "Оригінальний масштаб"), + ("Scale adaptive", "Адаптивний масштаб"), ("General", "Загальні"), ("Security", "Безпека"), ("Theme", "Тема"), @@ -364,7 +360,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Recording", "Запис"), ("Directory", "Директорія"), ("Automatically record incoming sessions", "Автоматично записувати вхідні сеанси"), - ("Automatically record outgoing sessions", ""), + ("Automatically record outgoing sessions", "Автоматично записувати вихідні сеанси"), ("Change", "Змінити"), ("Start session recording", "Розпочати запис сеансу"), ("Stop session recording", "Закінчити запис сеансу"), @@ -381,14 +377,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Keyboard Settings", "Налаштування клавіатури"), ("Full Access", "Повний доступ"), ("Screen Share", "Демонстрація екрана"), - ("Wayland requires Ubuntu 21.04 or higher version.", "Wayland потребує Ubuntu 21.04 або новішої версії."), - ("Wayland requires higher version of linux distro. Please try X11 desktop or change your OS.", "Для Wayland потрібна новіша версія дистрибутива Linux. Будь ласка, спробуйте стільницю на X11 або змініть свою ОС."), + ("ubuntu-21-04-required", "Wayland потребує Ubuntu 21.04 або новішої версії."), + ("wayland-requires-higher-linux-version", "Для Wayland потрібна новіша версія дистрибутива Linux. Будь ласка, спробуйте стільницю на X11 або змініть свою ОС."), + ("xdp-portal-unavailable", ""), ("JumpLink", "Перегляд"), ("Please Select the screen to be shared(Operate on the peer side).", "Будь ласка, виберіть екран, до якого потрібно надати доступ (на віддаленому пристрої)."), ("Show RustDesk", "Показати RustDesk"), ("This PC", "Цей ПК"), ("or", "чи"), - ("Continue with", "Продовжити з"), ("Elevate", "Розширення прав"), ("Zoom cursor", "Збільшити вказівник"), ("Accept sessions via password", "Підтверджувати сеанси паролем"), @@ -402,7 +398,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Hide connection management window", "Приховати вікно керування підключеннями"), ("hide_cm_tip", "Дозволено приховати лише якщо сеанс підтверджується постійним паролем"), ("wayland_experiment_tip", "Підтримка Wayland на експериментальній стадії, будь ласка, використовуйте X11, якщо необхідний автоматичний доступ."), - ("Right click to select tabs", "Правий клік для вибору вкладки"), + ("Right click to select tabs", "Вибір вкладок клацанням правою"), ("Skipped", "Пропущено"), ("Add to address book", "Додати IP до Адресної книги"), ("Group", "Група"), @@ -463,7 +459,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("empty_favorite_tip", "Досі немає улюблених вузлів?\nДавайте організуємо нове підключення та додамо його до улюблених!"), ("empty_lan_tip", "О ні, схоже ми ще не виявили жодного віддаленого пристрою."), ("empty_address_book_tip", "Ой лишенько, схоже у вашій адресній книзі немає жодного віддаленого пристрою."), - ("eg: admin", "напр., admin"), ("Empty Username", "Незаповнене імʼя"), ("Empty Password", "Незаповнений пароль"), ("Me", "Я"), @@ -513,7 +508,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Stop", "Зупинити"), ("exceed_max_devices", "У вас максимальна кількість керованих пристроїв."), ("Sync with recent sessions", "Синхронізація з нещодавніми сеансами"), - ("Sort tags", "Сортувати теги"), + ("Sort tags", "Сортувати мітки"), ("Open connection in new tab", "Відкрити підключення в новій вкладці"), ("Move tab to new window", "Перемістити вкладку до нового вікна"), ("Can not be empty", "Не може бути порожнім"), @@ -524,7 +519,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Grid View", "Перегляд ґраткою"), ("List View", "Перегляд списком"), ("Select", "Вибрати"), - ("Toggle Tags", "Видимість тегів"), + ("Toggle Tags", "Видимість міток"), ("pull_ab_failed_tip", "Не вдалося оновити адресну книгу"), ("push_ab_failed_tip", "Не вдалося синхронізувати адресну книгу"), ("synced_peer_readded_tip", "Пристрої з нещодавніх сеансів будуть синхронізовані з адресною книгою."), @@ -533,7 +528,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("HSV Color", "Колір HSV"), ("Installation Successful!", "Успішне встановлення!"), ("Installation failed!", "Невдале встановлення!"), - ("Reverse mouse wheel", "Зворотній напрям прокрутки"), + ("Reverse mouse wheel", "Зворотній напрям гортання"), ("{} sessions", "{} сеансів"), ("scam_title", "Вас можуть ОБМАНУТИ!"), ("scam_text1", "Якщо ви розмовляєте по телефону з кимось, кого НЕ ЗНАЄТЕ чи кому НЕ ДОВІРЯЄТЕ, і ця особа хоче, щоб ви використали RustDesk та запустили службу, не робіть цього та негайно завершіть дзвінок."), @@ -648,10 +643,107 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("one-way-file-transfer-tip", "На стороні, що керується, увімкнено односторонню передачу файлів."), ("Authentication Required", "Потрібна автентифікація"), ("Authenticate", "Автентифікувати"), - ("web_id_input_tip", "Ви можете ввести ID з того самого серверу, прямий IP-доступ у веб-клієнті не підтримується.\nЯкщо ви хочете отримати доступ до пристрою на іншому сервері, будь ласка, додайте адресу сервера (@<адреса_сервера>?key=<значення_ключа>), наприклад,\n9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=.\nЯкщо ви хочете отримати доступ до пристрою на публічному сервері, будь ласка, введіть \"@public\", для публічного сервера ключ не потрібен."), - ("Download", ""), - ("Upload folder", ""), - ("Upload files", ""), - ("Clipboard is synchronized", ""), + ("web_id_input_tip", "Ви можете ввести ID на тому самому серверу, прямий IP-доступ у веб-клієнті не підтримується.\nЯкщо ви хочете отримати доступ до пристрою на іншому сервері, будь ласка, додайте адресу сервера (@<адреса_сервера>?key=<значення_ключа>). Наприклад,\n9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=.\nЯкщо ви хочете отримати доступ до пристрою на публічному сервері, будь ласка, введіть \"@public\". Для публічного сервера ключ не потрібен."), + ("Download", "Отримати"), + ("Upload folder", "Надіслати теку"), + ("Upload files", "Надіслати файли"), + ("Clipboard is synchronized", "Буфер обміну синхронізовано"), + ("Update client clipboard", "Оновити буфер обміну клієнта"), + ("Untagged", "Без міток"), + ("new-version-of-{}-tip", "Доступна нова версія {}"), + ("Accessible devices", ""), + ("upgrade_remote_rustdesk_client_to_{}_tip", "Будь ласка, оновіть RustDesk клієнт на віддаленому пристрої до версії {} чи новіше!"), + ("d3d_render_tip", ""), + ("Use D3D rendering", ""), + ("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", ""), + ("Trackpad speed", ""), + ("Default trackpad speed", ""), + ("Numeric one-time password", ""), + ("Enable IPv6 P2P connection", ""), + ("Enable UDP hole punching", ""), + ("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", ""), + ("allow-insecure-tls-fallback-tip", ""), + ("Disable 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 {}", "Продовжити з {}"), + ("Display Name", ""), + ("password-hidden-tip", ""), + ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/vi.rs b/src/lang/vi.rs new file mode 100644 index 000000000..0910025ed --- /dev/null +++ b/src/lang/vi.rs @@ -0,0 +1,749 @@ +lazy_static::lazy_static! { +pub static ref T: std::collections::HashMap<&'static str, &'static str> = + [ + ("Status", "Trạng thái hiện tại"), + ("Your Desktop", "Desktop của bạn"), + ("desk_tip", "Desktop của bạn có thể được truy cập bằng ID và mật khẩu này."), + ("Password", "Mật khẩu"), + ("Ready", "Sẵn sàng"), + ("Established", "Đã được thiết lập"), + ("connecting_status", "Đang kết nối đến mạng lưới RustDesk..."), + ("Enable service", "Bật dịch vụ"), + ("Start service", "Bắt đầu dịch vụ"), + ("Service is running", "Dịch vụ hiện đang chạy"), + ("Service is not running", "Dịch vụ hiện đang dừng"), + ("not_ready_status", "Hiện chưa sẵn sàng. Hãy kiểm tra kết nối của bạn"), + ("Control Remote Desktop", "Điều khiển Desktop Từ Xa"), + ("Transfer file", "Truyền Tệp Tin"), + ("Connect", "Kết nối"), + ("Recent sessions", "Các phiên gần đây"), + ("Address book", "Sổ địa chỉ"), + ("Confirmation", "Xác nhận"), + ("TCP tunneling", "TCP tunneling"), + ("Remove", "Loại bỏ"), + ("Refresh random password", "Làm mới mật khẩu ngẫu nhiên"), + ("Set your own password", "Đặt mật khẩu riêng"), + ("Enable keyboard/mouse", "Cho phép sử dụng bàn phím/chuột"), + ("Enable clipboard", "Cho phép sử dụng Clipboard"), + ("Enable file transfer", "Cho phép truyền tệp tin"), + ("Enable TCP tunneling", "Cho phép TCP tunneling"), + ("IP Whitelisting", "Danh sách trắng IP"), + ("ID/Relay Server", "Máy chủ ID/Chuyển tiếp"), + ("Import server config", "Nhập cấu hình máy chủ"), + ("Export Server Config", "Xuất cấu hình máy chủ"), + ("Import server configuration successfully", "Nhập cấu hình máy chủ thành công"), + ("Export server configuration successfully", "Xuất cấu hình máy chủ thành công"), + ("Invalid server configuration", "Cấu hình máy chủ không hợp lệ"), + ("Clipboard is empty", "Khay nhớ tạm trống"), + ("Stop service", "Dừng dịch vụ"), + ("Change ID", "Thay đổi ID"), + ("Your new ID", "ID mới của bạn"), + ("length %min% to %max%", "độ dài từ %min% đến %max%"), + ("starts with a letter", "bắt đầu bằng một chữ cái"), + ("allowed characters", "các ký tự được phép"), + ("id_change_tip", "Các ký tự được phép: a-z, A-Z, 0-9, - (gạch ngang) và _ (gạch dưới). Ký tự đầu tiên phải là chữ cái. Độ dài từ 6 đến 16."), + ("Website", "Trang web"), + ("About", "Giới thiệu"), + ("Slogan_tip", "Được tạo ra với sự tận tâm trong thế giới đầy hỗn loạn này!"), + ("Privacy Statement", "Chính sách bảo mật"), + ("Mute", "Tắt tiếng"), + ("Build Date", "Ngày đóng gói"), + ("Version", "Phiên bản"), + ("Home", "Trang chủ"), + ("Audio Input", "Đầu vào âm thanh"), + ("Enhancements", "Tiện ích mở rộng"), + ("Hardware Codec", "Codec phần cứng"), + ("Adaptive bitrate", "Bitrate thích ứng"), + ("ID Server", "Máy chủ ID"), + ("Relay Server", "Máy chủ Chuyển tiếp"), + ("API Server", "Máy chủ API"), + ("invalid_http", "phải bắt đầu bằng http:// hoặc https://"), + ("Invalid IP", "IP không hợp lệ"), + ("Invalid format", "Định dạng không hợp lệ"), + ("server_not_support", "Máy chủ chưa hỗ trợ"), + ("Not available", "Không khả dụng"), + ("Too frequent", "Thao tác quá thường xuyên"), + ("Cancel", "Hủy"), + ("Skip", "Bỏ qua"), + ("Close", "Đóng"), + ("Retry", "Thử lại"), + ("OK", "OK"), + ("Password Required", "Yêu cầu mật khẩu"), + ("Please enter your password", "Vui lòng nhập mật khẩu"), + ("Remember password", "Nhớ mật khẩu"), + ("Wrong Password", "Sai mật khẩu"), + ("Do you want to enter again?", "Bạn có muốn nhập lại không?"), + ("Connection Error", "Lỗi kết nối"), + ("Error", "Lỗi"), + ("Reset by the peer", "Phía đối tác đã đặt lại kết nối"), + ("Connecting...", "Đang kết nối..."), + ("Connection in progress. Please wait.", "Đang thiết lập kết nối. Vui lòng chờ."), + ("Please try 1 minute later", "Vui lòng thử lại sau 1 phút"), + ("Login Error", "Lỗi đăng nhập"), + ("Successful", "Thành công"), + ("Connected, waiting for image...", "Đã kết nối, đang đợi hình ảnh..."), + ("Name", "Tên"), + ("Type", "Loại"), + ("Modified", "Ngày chỉnh sửa"), + ("Size", "Kích cỡ"), + ("Show Hidden Files", "Hiện tệp ẩn"), + ("Receive", "Nhận"), + ("Send", "Gửi"), + ("Refresh File", "Làm mới tệp"), + ("Local", "Cục bộ"), + ("Remote", "Từ xa"), + ("Remote Computer", "Máy tính từ xa"), + ("Local Computer", "Máy tính cục bộ"), + ("Confirm Delete", "Xác nhận xóa"), + ("Delete", "Xóa"), + ("Properties", "Thuộc tính"), + ("Multi Select", "Chọn nhiều"), + ("Select All", "Chọn tất cả"), + ("Unselect All", "Bỏ chọn tất cả"), + ("Empty Directory", "Thư mục trống"), + ("Not an empty directory", "Thư mục không trống"), + ("Are you sure you want to delete this file?", "Bạn có chắc chắn muốn xóa tệp này không?"), + ("Are you sure you want to delete this empty directory?", "Bạn có chắc chắn muốn xóa thư mục trống này không?"), + ("Are you sure you want to delete the file of this directory?", "Bạn có chắc chắn muốn xóa các tệp trong thư mục này không?"), + ("Do this for all conflicts", "Áp dụng cho mọi xung đột"), + ("This is irreversible!", "Hành động này không thể hoàn tác!"), + ("Deleting", "Đang xóa"), + ("files", "tệp"), + ("Waiting", "Đang chờ"), + ("Finished", "Hoàn thành"), + ("Speed", "Tốc độ"), + ("Custom Image Quality", "Tùy chỉnh chất lượng hình ảnh"), + ("Privacy mode", "Chế độ riêng tư"), + ("Block user input", "Chặn tương tác người dùng"), + ("Unblock user input", "Hủy chặn tương tác người dùng"), + ("Adjust Window", "Điều chỉnh cửa sổ"), + ("Original", "Gốc"), + ("Shrink", "Thu nhỏ"), + ("Stretch", "Kéo giãn"), + ("Scrollbar", "Thanh cuộn"), + ("ScrollAuto", "Tự động cuộn"), + ("Good image quality", "Chất lượng hình ảnh tốt"), + ("Balanced", "Cân bằng"), + ("Optimize reaction time", "Tối ưu thời gian phản hồi"), + ("Custom", "Tùy chỉnh"), + ("Show remote cursor", "Hiện con trỏ từ xa"), + ("Show quality monitor", "Hiện thông tin chất lượng"), + ("Disable clipboard", "Tắt Clipboard"), + ("Lock after session end", "Khóa máy sau khi kết thúc"), + ("Insert Ctrl + Alt + Del", "Gửi Ctrl + Alt + Del"), + ("Insert Lock", "Khóa máy"), + ("Refresh", "Làm mới"), + ("ID does not exist", "ID không tồn tại"), + ("Failed to connect to rendezvous server", "Không thể kết nối đến máy chủ Rendezvous"), + ("Please try later", "Vui lòng thử lại sau"), + ("Remote desktop is offline", "Máy tính từ xa đang ngoại tuyến"), + ("Key mismatch", "Khóa không khớp"), + ("Timeout", "Quá thời gian"), + ("Failed to connect to relay server", "Không thể kết nối tới máy chủ Chuyển tiếp"), + ("Failed to connect via rendezvous server", "Không thể kết nối qua máy chủ Rendezvous"), + ("Failed to connect via relay server", "Không thể kết nối qua máy chủ Chuyển tiếp"), + ("Failed to make direct connection to remote desktop", "Không thể kết nối trực tiếp"), + ("Set Password", "Đặt mật khẩu"), + ("OS Password", "Mật khẩu hệ điều hành"), + ("install_tip", "Do cơ chế UAC, RustDesk có thể không hoạt động ổn định ở phía người dùng từ xa trong một số trường hợp. Để tránh vấn đề này, vui lòng nhấn nút bên dưới để cài đặt RustDesk vào hệ thống."), + ("Click to upgrade", "Nhấn để nâng cấp"), + ("Configure", "Cấu hình"), + ("config_acc", "Để điều khiển từ xa, bạn cần cấp quyền \"Trợ năng\" cho RustDesk."), + ("config_screen", "Để truy cập từ xa, bạn cần cấp quyền \"Ghi màn hình\" cho RustDesk."), + ("Installing ...", "Đang cài đặt..."), + ("Install", "Cài đặt"), + ("Installation", "Cài đặt"), + ("Installation Path", "Đường dẫn cài đặt"), + ("Create start menu shortcuts", "Tạo shortcut ở Start Menu"), + ("Create desktop icon", "Tạo biểu tượng ngoài màn hình"), + ("agreement_tip", "Bằng việc bắt đầu cài đặt, bạn đồng ý với các điều khoản cấp phép."), + ("Accept and Install", "Chấp nhận và Cài đặt"), + ("End-user license agreement", "Thỏa thuận người dùng cuối"), + ("Generating ...", "Đang khởi tạo..."), + ("Your installation is lower version.", "Phiên bản cài đặt của bạn cũ hơn."), + ("not_close_tcp_tip", "Đừng đóng cửa sổ này khi đang sử dụng Tunnel"), + ("Listening ...", "Đang lắng nghe..."), + ("Remote Host", "Máy chủ từ xa"), + ("Remote Port", "Cổng từ xa"), + ("Action", "Hành động"), + ("Add", "Thêm"), + ("Local Port", "Cổng nội bộ"), + ("Local Address", "Địa chỉ nội bộ"), + ("Change Local Port", "Đổi cổng nội bộ"), + ("setup_server_tip", "Để kết nối nhanh hơn, hãy tự thiết lập máy chủ riêng"), + ("Too short, at least 6 characters.", "Quá ngắn, cần ít nhất 6 ký tự."), + ("The confirmation is not identical.", "Mật khẩu xác nhận không khớp"), + ("Permissions", "Quyền"), + ("Accept", "Chấp nhận"), + ("Dismiss", "Bỏ qua"), + ("Disconnect", "Ngắt kết nối"), + ("Enable file copy and paste", "Cho phép sao chép và dán tệp"), + ("Connected", "Đã kết nối"), + ("Direct and encrypted connection", "Kết nối trực tiếp và mã hóa"), + ("Relayed and encrypted connection", "Kết nối chuyển tiếp và mã hóa"), + ("Direct and unencrypted connection", "Kết nối trực tiếp và không mã hóa"), + ("Relayed and unencrypted connection", "Kết nối chuyển tiếp và không mã hóa"), + ("Enter Remote ID", "Nhập ID từ xa"), + ("Enter your password", "Nhập mật khẩu của bạn"), + ("Logging in...", "Đang đăng nhập..."), + ("Enable RDP session sharing", "Cho phép chia sẻ phiên RDP"), + ("Auto Login", "Tự động đăng nhập"), + ("Enable direct IP access", "Cho phép truy cập IP trực tiếp"), + ("Rename", "Đổi tên"), + ("Space", "Khoảng cách"), + ("Create desktop shortcut", "Tạo shortcut màn hình"), + ("Change Path", "Đổi đường dẫn"), + ("Create Folder", "Tạo thư mục"), + ("Please enter the folder name", "Vui lòng nhập tên thư mục"), + ("Fix it", "Sửa lỗi"), + ("Warning", "Cảnh báo"), + ("Login screen using Wayland is not supported", "Màn hình đăng nhập Wayland không được hỗ trợ"), + ("Reboot required", "Yêu cầu khởi động lại"), + ("Unsupported display server", "Máy chủ hiển thị không được hỗ trợ"), + ("x11 expected", "Yêu cầu X11"), + ("Port", "Cổng"), + ("Settings", "Cài đặt"), + ("Username", "Tên người dùng"), + ("Invalid port", "Cổng không hợp lệ"), + ("Closed manually by the peer", "Bị đóng thủ công bởi đối tác"), + ("Enable remote configuration modification", "Cho phép sửa cấu hình từ xa"), + ("Run without install", "Chạy không cần cài đặt"), + ("Connect via relay", "Kết nối qua chuyển tiếp"), + ("Always connect via relay", "Luôn kết nối qua chuyển tiếp"), + ("whitelist_tip", "Chỉ IP trong danh sách trắng mới có thể truy cập"), + ("Login", "Đăng nhập"), + ("Verify", "Xác thực"), + ("Remember me", "Ghi nhớ"), + ("Trust this device", "Tin tưởng thiết bị này"), + ("Verification code", "Mã xác thực"), + ("verification_tip", "Bạn đang đăng nhập trên thiết bị mới. Một mã xác thực đã được gửi đến email của bạn, vui lòng nhập mã để tiếp tục."), + ("Logout", "Đăng xuất"), + ("Tags", "Thẻ"), + ("Search ID", "Tìm ID"), + ("whitelist_sep", "Phân cách bởi dấu phẩy, dấu chấm phẩy, khoảng trắng hoặc dòng mới"), + ("Add ID", "Thêm ID"), + ("Add Tag", "Thêm thẻ"), + ("Unselect all tags", "Bỏ chọn tất cả thẻ"), + ("Network error", "Lỗi mạng"), + ("Username missed", "Thiếu tên người dùng"), + ("Password missed", "Thiếu mật khẩu"), + ("Wrong credentials", "Thông tin đăng nhập sai"), + ("The verification code is incorrect or has expired", "Mã xác thực không đúng hoặc đã hết hạn"), + ("Edit Tag", "Sửa thẻ"), + ("Forget Password", "Quên mật khẩu"), + ("Favorites", "Yêu thích"), + ("Add to Favorites", "Thêm vào yêu thích"), + ("Remove from Favorites", "Xóa khỏi yêu thích"), + ("Empty", "Trống"), + ("Invalid folder name", "Tên thư mục không hợp lệ"), + ("Socks5 Proxy", "Socks5 Proxy"), + ("Socks5/Http(s) Proxy", "Socks5/Http(s) Proxy"), + ("Discovered", "Đã phát hiện"), + ("install_daemon_tip", "Để khởi động cùng hệ thống, bạn cần cài đặt dịch vụ daemon."), + ("Remote ID", "ID từ xa"), + ("Paste", "Dán"), + ("Paste here?", "Dán vào đây?"), + ("Are you sure to close the connection?", "Bạn có chắc chắn muốn đóng kết nối?"), + ("Download new version", "Tải phiên bản mới"), + ("Touch mode", "Chế độ chạm"), + ("Mouse mode", "Chế độ chuột"), + ("One-Finger Tap", "Chạm một ngón"), + ("Left Mouse", "Chuột trái"), + ("One-Long Tap", "Chạm giữ một ngón"), + ("Two-Finger Tap", "Chạm hai ngón"), + ("Right Mouse", "Chuột phải"), + ("One-Finger Move", "Di chuyển một ngón"), + ("Double Tap & Move", "Chạm đúp và di chuyển"), + ("Mouse Drag", "Kéo chuột"), + ("Three-Finger vertically", "Ba ngón theo chiều dọc"), + ("Mouse Wheel", "Con lăn chuột"), + ("Two-Finger Move", "Di chuyển hai ngón"), + ("Canvas Move", "Di chuyển khung hình"), + ("Pinch to Zoom", "Véo để thu phóng"), + ("Canvas Zoom", "Thu phóng khung hình"), + ("Reset canvas", "Đặt lại khung hình"), + ("No permission of file transfer", "Không có quyền truyền tệp"), + ("Note", "Ghi chú"), + ("Connection", "Kết nối"), + ("Share screen", "Chia sẻ màn hình"), + ("Chat", "Trò chuyện"), + ("Total", "Tổng cộng"), + ("items", "mục"), + ("Selected", "Đã chọn"), + ("Screen Capture", "Chụp màn hình"), + ("Input Control", "Kiểm soát đầu vào"), + ("Audio Capture", "Ghi âm thanh"), + ("Do you accept?", "Bạn có đồng ý không?"), + ("Open System Setting", "Mở cài đặt hệ thống"), + ("How to get Android input permission?", "Làm sao để lấy quyền nhập liệu trên Android?"), + ("android_input_permission_tip1", "Để điều khiển Android bằng chuột hoặc chạm, bạn cần cấp quyền [Trợ năng]."), + ("android_input_permission_tip2", "Vui lòng tìm [Dịch vụ đã cài đặt] trong cài đặt và bật [RustDesk Input]."), + ("android_new_connection_tip", "Yêu cầu điều khiển mới đã được nhận."), + ("android_service_will_start_tip", "Bật [Ghi màn hình] sẽ tự động khởi động dịch vụ."), + ("android_stop_service_tip", "Dừng dịch vụ sẽ đóng tất cả các kết nối."), + ("android_version_audio_tip", "Phiên bản Android này không hỗ trợ ghi âm, vui lòng nâng cấp lên Android 10+."), + ("android_start_service_tip", "Nhấn [Bắt đầu dịch vụ] để chia sẻ màn hình."), + ("android_permission_may_not_change_tip", "Quyền có thể không thay đổi ngay lập tức cho đến khi kết nối lại."), + ("Account", "Tài khoản"), + ("Overwrite", "Ghi đè"), + ("This file exists, skip or overwrite this file?", "Tệp đã tồn tại, bỏ qua hay ghi đè?"), + ("Quit", "Thoát"), + ("Help", "Trợ giúp"), + ("Failed", "Thất bại"), + ("Succeeded", "Thành công"), + ("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"), + ("Language", "Ngôn ngữ"), + ("Keep RustDesk background service", "Giữ dịch vụ RustDesk chạy nền"), + ("Ignore Battery Optimizations", "Bỏ qua tối ưu hóa pin"), + ("android_open_battery_optimizations_tip", "Vui lòng chọn [Không hạn chế] trong cài đặt Pin."), + ("Start on boot", "Khởi động cùng hệ thống"), + ("Start the screen sharing service on boot, requires special permissions", "Khởi động dịch vụ chia sẻ màn hình khi bật máy (cần quyền đặc biệt)"), + ("Connection not allowed", "Kết nối không được phép"), + ("Legacy mode", "Chế độ cũ"), + ("Map mode", "Chế độ bản đồ"), + ("Translate mode", "Chế độ dịch"), + ("Use permanent password", "Dùng mật khẩu vĩnh viễn"), + ("Use both passwords", "Dùng cả hai mật khẩu"), + ("Set permanent password", "Đặt mật khẩu vĩnh viễn"), + ("Enable remote restart", "Cho phép khởi động lại từ xa"), + ("Restart remote device", "Khởi động lại máy từ xa"), + ("Are you sure you want to restart", "Bạn có chắc chắn muốn khởi động lại?"), + ("Restarting remote device", "Đang khởi động lại máy từ xa..."), + ("remote_restarting_tip", "Máy từ xa đang khởi động lại, vui lòng kết nối lại sau ít phút."), + ("Copied", "Đã sao chép"), + ("Exit Fullscreen", "Thoát toàn màn hình"), + ("Fullscreen", "Toàn màn hình"), + ("Mobile Actions", "Thao tác di động"), + ("Select Monitor", "Chọn màn hình"), + ("Control Actions", "Thao tác điều khiển"), + ("Display Settings", "Cài đặt hiển thị"), + ("Ratio", "Tỷ lệ"), + ("Image Quality", "Chất lượng hình ảnh"), + ("Scroll Style", "Kiểu cuộn"), + ("Show Toolbar", "Hiện thanh công cụ"), + ("Hide Toolbar", "Ẩn thanh công cụ"), + ("Direct Connection", "Kết nối trực tiếp"), + ("Relay Connection", "Kết nối chuyển tiếp"), + ("Secure Connection", "Kết nối bảo mật"), + ("Insecure Connection", "Kết nối không bảo mật"), + ("Scale original", "Tỷ lệ gốc"), + ("Scale adaptive", "Tỷ lệ thích ứng"), + ("General", "Chung"), + ("Security", "Bảo mật"), + ("Theme", "Chủ đề"), + ("Dark Theme", "Chủ đề Tối"), + ("Light Theme", "Chủ đề Sáng"), + ("Dark", "Tối"), + ("Light", "Sáng"), + ("Follow System", "Theo hệ thống"), + ("Enable hardware codec", "Bật Codec phần cứng"), + ("Unlock Security Settings", "Mở khóa cài đặt bảo mật"), + ("Enable audio", "Bật âm thanh"), + ("Unlock Network Settings", "Mở khóa cài đặt mạng"), + ("Server", "Máy chủ"), + ("Direct IP Access", "Truy cập IP trực tiếp"), + ("Proxy", "Proxy"), + ("Apply", "Áp dụng"), + ("Disconnect all devices?", "Ngắt tất cả thiết bị?"), + ("Clear", "Xóa sạch"), + ("Audio Input Device", "Thiết bị đầu vào âm thanh"), + ("Use IP Whitelisting", "Sử dụng danh sách trắng IP"), + ("Network", "Mạng"), + ("Pin Toolbar", "Ghim thanh công cụ"), + ("Unpin Toolbar", "Bỏ ghim thanh công cụ"), + ("Recording", "Đang ghi hình"), + ("Directory", "Thư mục"), + ("Automatically record incoming sessions", "Tự động ghi lại các kết nối đến"), + ("Automatically record outgoing sessions", "Tự động ghi lại các kết nối đi"), + ("Change", "Thay đổi"), + ("Start session recording", "Bắt đầu ghi hình phiên"), + ("Stop session recording", "Dừng ghi hình phiên"), + ("Enable recording session", "Cho phép ghi hình phiên"), + ("Enable LAN discovery", "Bật phát hiện trong mạng LAN"), + ("Deny LAN discovery", "Từ chối phát hiện trong mạng LAN"), + ("Write a message", "Viết tin nhắn..."), + ("Prompt", "Gợi ý"), + ("Please wait for confirmation of UAC...", "Vui lòng chờ xác nhận UAC..."), + ("elevated_foreground_window_tip", "Cửa sổ phía trước yêu cầu quyền cao hơn, tạm thời không thể sử dụng chuột/phím. Yêu cầu phía đối tác thu nhỏ cửa sổ hoặc cấp quyền."), + ("Disconnected", "Đã ngắt kết nối"), + ("Other", "Khác"), + ("Confirm before closing multiple tabs", "Xác nhận trước khi đóng nhiều tab"), + ("Keyboard Settings", "Cài đặt bàn phím"), + ("Full Access", "Toàn quyền truy cập"), + ("Screen Share", "Chia sẻ màn hình"), + ("ubuntu-21-04-required", "Wayland yêu cầu Ubuntu 21.04 trở lên."), + ("wayland-requires-higher-linux-version", "Wayland yêu cầu phiên bản Linux mới hơn. Hãy thử X11 hoặc đổi hệ điều hành."), + ("xdp-portal-unavailable", ""), + ("JumpLink", "Xem"), + ("Please Select the screen to be shared(Operate on the peer side).", "Vui lòng chọn màn hình chia sẻ (Thao tác ở phía đối tác)."), + ("Show RustDesk", "Hiện RustDesk"), + ("This PC", "Máy tính này"), + ("or", "hoặc"), + ("Elevate", "Nâng quyền"), + ("Zoom cursor", "Phóng to con trỏ"), + ("Accept sessions via password", "Chấp nhận phiên qua mật khẩu"), + ("Accept sessions via click", "Chấp nhận phiên qua xác nhận"), + ("Accept sessions via both", "Chấp nhận phiên qua cả hai"), + ("Please wait for the remote side to accept your session request...", "Vui lòng chờ phía đối tác chấp nhận yêu cầu kết nối..."), + ("One-time Password", "Mật khẩu dùng một lần"), + ("Use one-time password", "Sử dụng mật khẩu một lần"), + ("One-time password length", "Độ dài mật khẩu một lần"), + ("Request access to your device", "Yêu cầu truy cập thiết bị của bạn"), + ("Hide connection management window", "Ẩn cửa sổ quản lý kết nối"), + ("hide_cm_tip", "Chỉ ẩn khi sử dụng mật khẩu vĩnh viễn"), + ("wayland_experiment_tip", "Wayland đang thử nghiệm, hãy dùng X11 nếu muốn ổn định."), + ("Right click to select tabs", "Chuột phải để chọn tab"), + ("Skipped", "Đã bỏ qua"), + ("Add to address book", "Thêm vào sổ địa chỉ"), + ("Group", "Nhóm"), + ("Search", "Tìm kiếm"), + ("Closed manually by web console", "Đã đóng bởi Web Console"), + ("Local keyboard type", "Loại bàn phím cục bộ"), + ("Select local keyboard type", "Chọn loại bàn phím cục bộ"), + ("software_render_tip", "Nếu gặp lỗi hiển thị trên Linux với Nvidia, hãy thử phần mềm render."), + ("Always use software rendering", "Luôn sử dụng render bằng phần mềm"), + ("config_input", "Cấp quyền [Theo dõi đầu vào] để dùng bàn phím."), + ("config_microphone", "Cấp quyền [Ghi âm] để trò chuyện."), + ("request_elevation_tip", "Bạn cũng có thể yêu cầu nâng quyền từ người ở phía xa."), + ("Wait", "Chờ"), + ("Elevation Error", "Lỗi nâng quyền"), + ("Ask the remote user for authentication", "Yêu cầu người dùng từ xa xác thực"), + ("Choose this if the remote account is administrator", "Chọn nếu tài khoản từ xa là Quản trị viên"), + ("Transmit the username and password of administrator", "Gửi tên đăng nhập và mật khẩu Quản trị viên"), + ("still_click_uac_tip", "Người dùng từ xa vẫn cần nhấn OK trên hộp thoại UAC."), + ("Request Elevation", "Yêu cầu nâng quyền"), + ("wait_accept_uac_tip", "Vui lòng chờ đối tác chấp nhận UAC."), + ("Elevate successfully", "Nâng quyền thành công"), + ("uppercase", "chữ hoa"), + ("lowercase", "chữ thường"), + ("digit", "số"), + ("special character", "ký tự đặc biệt"), + ("length>=8", "độ dài >= 8"), + ("Weak", "Yếu"), + ("Medium", "Trung bình"), + ("Strong", "Mạnh"), + ("Switch Sides", "Đổi bên"), + ("Please confirm if you want to share your desktop?", "Xác nhận chia sẻ màn hình?"), + ("Display", "Hiển thị"), + ("Default View Style", "Kiểu xem mặc định"), + ("Default Scroll Style", "Kiểu cuộn mặc định"), + ("Default Image Quality", "Chất lượng hình ảnh mặc định"), + ("Default Codec", "Codec mặc định"), + ("Bitrate", "Bitrate"), + ("FPS", "FPS"), + ("Auto", "Tự động"), + ("Other Default Options", "Các tùy chọn mặc định khác"), + ("Voice call", "Gọi thoại"), + ("Text chat", "Chat văn bản"), + ("Stop voice call", "Dừng gọi thoại"), + ("relay_hint_tip", "Nếu không kết nối trực tiếp được, hãy thử qua máy chủ chuyển tiếp (ID/r)."), + ("Reconnect", "Kết nối lại"), + ("Codec", "Codec"), + ("Resolution", "Độ phân giải"), + ("No transfers in progress", "Không có tệp nào đang truyền"), + ("Set one-time password length", "Đặt độ dài mật khẩu một lần"), + ("RDP Settings", "Cài đặt RDP"), + ("Sort by", "Sắp xếp theo"), + ("New Connection", "Kết nối mới"), + ("Restore", "Khôi phục"), + ("Minimize", "Thu nhỏ"), + ("Maximize", "Phóng to"), + ("Your Device", "Thiết bị của bạn"), + ("empty_recent_tip", "Chưa có kết nối gần đây."), + ("empty_favorite_tip", "Chưa có mục yêu thích."), + ("empty_lan_tip", "Không tìm thấy thiết bị nào trong LAN."), + ("empty_address_book_tip", "Sổ địa chỉ đang trống."), + ("Empty Username", "Tên người dùng trống"), + ("Empty Password", "Mật khẩu trống"), + ("Me", "Tôi"), + ("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"), + ("New RDP", "RDP mới"), + ("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"), + ("Collapse toolbar", "Thu gọn thanh công cụ"), + ("Accept and Elevate", "Chấp nhận và Nâng quyền"), + ("accept_and_elevate_btn_tooltip", "Chấp nhận kết nối và nâng quyền UAC."), + ("clipboard_wait_response_timeout_tip", "Hết thời gian chờ Clipboard phản hồi."), + ("Incoming connection", "Kết nối đến"), + ("Outgoing connection", "Kết nối đi"), + ("Exit", "Thoát"), + ("Open", "Mở"), + ("logout_tip", "Bạn có chắc muốn đăng xuất?"), + ("Service", "Dịch vụ"), + ("Start", "Bắt đầu"), + ("Stop", "Dừng"), + ("exceed_max_devices", "Vượt quá số lượng thiết bị tối đa."), + ("Sync with recent sessions", "Đồng bộ với các phiên gần đây"), + ("Sort tags", "Sắp xếp thẻ"), + ("Open connection in new tab", "Mở kết nối trong tab mới"), + ("Move tab to new window", "Di chuyển tab sang cửa sổ mới"), + ("Can not be empty", "Không được để trống"), + ("Already exists", "Đã tồn tại"), + ("Change Password", "Đổi mật khẩu"), + ("Refresh Password", "Làm mới mật khẩu"), + ("ID", "ID"), + ("Grid View", "Dạng lưới"), + ("List View", "Dạng danh sách"), + ("Select", "Chọn"), + ("Toggle Tags", "Bật/Tắt thẻ"), + ("pull_ab_failed_tip", "Lấy sổ địa chỉ thất bại."), + ("push_ab_failed_tip", "Đồng bộ sổ địa chỉ thất bại."), + ("synced_peer_readded_tip", "Thiết bị đã đồng bộ được thêm lại."), + ("Change Color", "Đổi màu"), + ("Primary Color", "Màu chính"), + ("HSV Color", "Màu HSV"), + ("Installation Successful!", "Cài đặt thành công!"), + ("Installation failed!", "Cài đặt thất bại!"), + ("Reverse mouse wheel", "Đảo ngược con lăn chuột"), + ("{} sessions", "{} phiên"), + ("scam_title", "CẢNH BÁO LỪA ĐẢO"), + ("scam_text1", "KHÔNG chia sẻ ID/Mật khẩu với người lạ qua điện thoại. Nếu họ yêu cầu, họ có thể là kẻ lừa đảo."), + ("scam_text2", "Chỉ sử dụng RustDesk với những người bạn thực sự tin tưởng."), + ("Don't show again", "Không hiển thị lại"), + ("I Agree", "Tôi đồng ý"), + ("Decline", "Từ chối"), + ("Timeout in minutes", "Thời gian chờ (phút)"), + ("auto_disconnect_option_tip", "Tự động ngắt kết nối khi không hoạt động"), + ("Connection failed due to inactivity", "Ngắt kết nối do không hoạt động"), + ("Check for software update on startup", "Kiểm tra cập nhật khi khởi động"), + ("upgrade_rustdesk_server_pro_to_{}_tip", "Nâng cấp lên Pro để có thêm tính năng"), + ("pull_group_failed_tip", "Lấy thông tin nhóm thất bại"), + ("Filter by intersection", "Lọc theo giao điểm"), + ("Remove wallpaper during incoming sessions", "Xóa hình nền khi có kết nối đến"), + ("Test", "Kiểm tra"), + ("display_is_plugged_out_msg", "Màn hình đã bị rút."), + ("No displays", "Không có màn hình"), + ("Open in new window", "Mở trong cửa sổ mới"), + ("Show displays as individual windows", "Hiển thị mỗi màn hình một cửa sổ"), + ("Use all my displays for the remote session", "Sử dụng tất cả màn hình của tôi"), + ("selinux_tip", "SELinux đang bật, có thể gây lỗi."), + ("Change view", "Đổi kiểu xem"), + ("Big tiles", "Ô lớn"), + ("Small tiles", "Ô nhỏ"), + ("List", "Danh sách"), + ("Virtual display", "Màn hình ảo"), + ("Plug out all", "Rút tất cả"), + ("True color (4:4:4)", "Màu thực (4:4:4)"), + ("Enable blocking user input", "Cho phép chặn đầu vào người dùng"), + ("id_input_tip", "Nhập ID hoặc IP."), + ("privacy_mode_impl_mag_tip", "Chế độ riêng tư (Magnifier)"), + ("privacy_mode_impl_virtual_display_tip", "Chế độ riêng tư (Virtual Display)"), + ("Enter privacy mode", "Vào chế độ riêng tư"), + ("Exit privacy mode", "Thoát chế độ riêng tư"), + ("idd_not_support_under_win10_2004_tip", "Yêu cầu Windows 10 2004 trở lên."), + ("input_source_1_tip", "Nguồn đầu vào 1"), + ("input_source_2_tip", "Nguồn đầu vào 2"), + ("Swap control-command key", "Hoán đổi phím Ctrl-Cmd"), + ("swap-left-right-mouse", "Hoán đổi chuột trái-phải"), + ("2FA code", "Mã 2FA"), + ("More", "Thêm"), + ("enable-2fa-title", "Bật xác thực 2 bước"), + ("enable-2fa-desc", "Vui lòng quét mã QR để bật 2FA."), + ("wrong-2fa-code", "Mã 2FA sai"), + ("enter-2fa-title", "Nhập mã 2FA"), + ("Email verification code must be 6 characters.", "Mã xác thực email phải có 6 ký tự."), + ("2FA code must be 6 digits.", "Mã 2FA phải có 6 chữ số."), + ("Multiple Windows sessions found", "Tìm thấy nhiều phiên Windows"), + ("Please select the session you want to connect to", "Chọn phiên bạn muốn kết nối"), + ("powered_by_me", "Cung cấp bởi tôi"), + ("outgoing_only_desk_tip", "Chỉ cho phép kết nối đi."), + ("preset_password_warning", "Cảnh báo mật khẩu thiết lập sẵn"), + ("Security Alert", "Cảnh báo bảo mật"), + ("My address book", "Sổ địa chỉ của tôi"), + ("Personal", "Cá nhân"), + ("Owner", "Chủ sở hữu"), + ("Set shared password", "Đặt mật khẩu chia sẻ"), + ("Exist in", "Tồn tại trong"), + ("Read-only", "Chỉ đọc"), + ("Read/Write", "Đọc/Ghi"), + ("Full Control", "Toàn quyền"), + ("share_warning_tip", "Cẩn thận khi chia sẻ quyền điều khiển!"), + ("Everyone", "Mọi người"), + ("ab_web_console_tip", "Quản lý qua Web Console"), + ("allow-only-conn-window-open-tip", "Chỉ cho phép khi cửa sổ RustDesk mở"), + ("no_need_privacy_mode_no_physical_displays_tip", "Không cần chế độ riêng tư vì không có màn hình vật lý."), + ("Follow remote cursor", "Theo con trỏ từ xa"), + ("Follow remote window focus", "Theo tiêu điểm cửa sổ từ xa"), + ("default_proxy_tip", "Proxy mặc định"), + ("no_audio_input_device_tip", "Không tìm thấy thiết bị thu âm."), + ("Incoming", "Đang đến"), + ("Outgoing", "Đang đi"), + ("Clear Wayland screen selection", "Xóa lựa chọn màn hình Wayland"), + ("clear_Wayland_screen_selection_tip", "Đặt lại các quyền chọn màn hình."), + ("confirm_clear_Wayland_screen_selection_tip", "Bạn có chắc muốn đặt lại?"), + ("android_new_voice_call_tip", "Yêu cầu gọi thoại mới."), + ("texture_render_tip", "Sử dụng Texture Rendering"), + ("Use texture rendering", "Sử dụng Texture Rendering"), + ("Floating window", "Cửa sổ nổi"), + ("floating_window_tip", "Giữ RustDesk trên cùng"), + ("Keep screen on", "Giữ màn hình luôn bật"), + ("Never", "Không bao giờ"), + ("During controlled", "Trong khi bị điều khiển"), + ("During service is on", "Trong khi dịch vụ đang bật"), + ("Capture screen using DirectX", "Chụp màn hình bằng DirectX"), + ("Back", "Trở về"), + ("Apps", "Ứng dụng"), + ("Volume up", "Tăng âm lượng"), + ("Volume down", "Giảm âm lượng"), + ("Power", "Nguồn"), + ("Telegram bot", "Telegram Bot"), + ("enable-bot-tip", "Bật thông báo qua Telegram"), + ("enable-bot-desc", "Liên kết với Telegram Bot của bạn."), + ("cancel-2fa-confirm-tip", "Xác nhận tắt 2FA?"), + ("cancel-bot-confirm-tip", "Xác nhận tắt Bot?"), + ("About RustDesk", "Về RustDesk"), + ("Send clipboard keystrokes", "Gửi phím từ Clipboard"), + ("network_error_tip", "Lỗi mạng, vui lòng kiểm tra lại."), + ("Unlock with PIN", "Mở khóa bằng mã PIN"), + ("Requires at least {} characters", "Yêu cầu ít nhất {} ký tự"), + ("Wrong PIN", "Mã PIN sai"), + ("Set PIN", "Đặt mã PIN"), + ("Enable trusted devices", "Bật thiết bị tin cậy"), + ("Manage trusted devices", "Quản lý thiết bị tin cậy"), + ("Platform", "Nền tảng"), + ("Days remaining", "Số ngày còn lại"), + ("enable-trusted-devices-tip", "Chỉ thiết bị tin cậy mới có thể kết nối không cần mật khẩu."), + ("Parent directory", "Thư mục cha"), + ("Resume", "Tiếp tục"), + ("Invalid file name", "Tên tệp không hợp lệ"), + ("one-way-file-transfer-tip", "Chỉ cho phép truyền tệp một chiều."), + ("Authentication Required", "Yêu cầu xác thực"), + ("Authenticate", "Xác thực"), + ("web_id_input_tip", "Nhập ID để bắt đầu kết nối Web."), + ("Download", "Tải xuống"), + ("Upload folder", "Tải lên thư mục"), + ("Upload files", "Tải lên tệp"), + ("Clipboard is synchronized", "Clipboard đã được đồng bộ"), + ("Update client clipboard", "Cập nhật Clipboard của khách"), + ("Untagged", "Chưa gắn thẻ"), + ("new-version-of-{}-tip", "Đã có phiên bản mới của {}"), + ("Accessible devices", "Thiết bị có thể truy cập"), + ("upgrade_remote_rustdesk_client_to_{}_tip", "Vui lòng nâng cấp đối tác lên {}"), + ("d3d_render_tip", "Sử dụng D3D Rendering"), + ("Use D3D rendering", "Sử dụng D3D Rendering"), + ("Printer", "Máy in"), + ("printer-os-requirement-tip", "Yêu cầu hệ điều hành hỗ trợ máy in."), + ("printer-requires-installed-{}-client-tip", "Cần cài đặt driver {}"), + ("printer-{}-not-installed-tip", "Máy in {} chưa được cài đặt."), + ("printer-{}-ready-tip", "Máy in {} đã sẵn sàng."), + ("Install {} Printer", "Cài đặt máy in {}"), + ("Outgoing Print Jobs", "Yêu cầu in đi"), + ("Incoming Print Jobs", "Yêu cầu in đến"), + ("Incoming Print Job", "Yêu cầu in đến"), + ("use-the-default-printer-tip", "Sử dụng máy in mặc định"), + ("use-the-selected-printer-tip", "Sử dụng máy in đã chọn"), + ("auto-print-tip", "Tự động in"), + ("print-incoming-job-confirm-tip", "Xác nhận in tệp này?"), + ("remote-printing-disallowed-tile-tip", "In từ xa bị cấm"), + ("remote-printing-disallowed-text-tip", "Vui lòng bật quyền in trong cài đặt."), + ("save-settings-tip", "Lưu cài đặt"), + ("dont-show-again-tip", "Đừng hiện lại"), + ("Take screenshot", "Chụp màn hình"), + ("Taking screenshot", "Đang chụp màn hình..."), + ("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"), + ("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 {}"), + ("{} Update", "Cập nhật {}"), + ("{}-to-update-tip", "Cần nâng cấp để sử dụng tính năng này."), + ("download-new-version-failed-tip", "Tải phiên bản mới thất bại."), + ("Auto update", "Tự động cập nhật"), + ("update-failed-check-msi-tip", "Cập nhật lỗi, vui lòng kiểm tra file MSI."), + ("websocket_tip", "Sử dụng giao thức WebSocket"), + ("Use WebSocket", "Sử dụng WebSocket"), + ("Trackpad speed", "Tốc độ Trackpad"), + ("Default trackpad speed", "Tốc độ Trackpad mặc định"), + ("Numeric one-time password", "Mật khẩu số dùng một lần"), + ("Enable IPv6 P2P connection", "Cho phép kết nối IPv6 P2P"), + ("Enable UDP hole punching", "Bật UDP Hole Punching"), + ("View camera", "Xem Camera"), + ("Enable camera", "Bật Camera"), + ("No cameras", "Không có camera"), + ("view_camera_unsupported_tip", "Đối tác chưa hỗ trợ xem camera."), + ("Terminal", "Terminal"), + ("Enable terminal", "Bật Terminal"), + ("New tab", "Tab mới"), + ("Keep terminal sessions on disconnect", "Giữ phiên terminal khi ngắt kết nối"), + ("Terminal (Run as administrator)", "Terminal (Quyền Quản trị viên)"), + ("terminal-admin-login-tip", "Đang đăng nhập quyền quản trị..."), + ("Failed to get user token.", "Lấy mã token người dùng thất bại."), + ("Incorrect username or password.", "Tên người dùng hoặc mật khẩu sai."), + ("The user is not an administrator.", "Người dùng không phải Quản trị viên."), + ("Failed to check if the user is an administrator.", "Kiểm tra quyền Quản trị viên thất bại."), + ("Supported only in the installed version.", "Chỉ hỗ trợ trên bản đã cài đặt."), + ("elevation_username_tip", "Tên đăng nhập để nâng quyền"), + ("Preparing for installation ...", "Đang chuẩn bị cài đặt..."), + ("Show my cursor", "Hiện con trỏ của tôi"), + ("Scale custom", "Tùy chỉnh tỷ lệ"), + ("Custom scale slider", "Thanh trượt tỷ lệ"), + ("Decrease", "Giảm"), + ("Increase", "Tăng"), + ("Show virtual mouse", "Hiện chuột ảo"), + ("Virtual mouse size", "Kích thước chuột ảo"), + ("Small", "Nhỏ"), + ("Large", "Lớn"), + ("Show virtual joystick", "Hiện Joystick ảo"), + ("Edit note", "Sửa ghi chú"), + ("Alias", "Bí danh"), + ("ScrollEdge", "Cuộn ở cạnh"), + ("Allow insecure TLS fallback", "Cho phép hạ cấp TLS không an toàn"), + ("allow-insecure-tls-fallback-tip", "Cho phép kết nối nếu máy chủ dùng TLS cũ."), + ("Disable UDP", "Tắt UDP"), + ("disable-udp-tip", "Chỉ sử dụng TCP để kết nối."), + ("server-oss-not-support-tip", "Máy chủ mã nguồn mở không hỗ trợ tính năng này."), + ("input note here", "nhập ghi chú tại đây"), + ("note-at-conn-end-tip", "Hiện ghi chú khi kết thúc phiên"), + ("Show terminal extra keys", "Hiện các phím phụ Terminal"), + ("Relative mouse mode", "Chế độ chuột tương đối"), + ("rel-mouse-not-supported-peer-tip", "Đối tác không hỗ trợ chuột tương đối."), + ("rel-mouse-not-ready-tip", "Chuột tương đối chưa sẵn sàng."), + ("rel-mouse-lock-failed-tip", "Khóa chuột thất bại."), + ("rel-mouse-exit-{}-tip", "Thoát chế độ chuột tương đối: {}"), + ("rel-mouse-permission-lost-tip", "Mất quyền điều khiển chuột tương đối."), + ("Changelog", "Nhật ký thay đổi"), + ("keep-awake-during-outgoing-sessions-label", ""), + ("keep-awake-during-incoming-sessions-label", ""), + ("Continue with {}", "Tiếp tục với {}"), + ("Display Name", ""), + ("password-hidden-tip", ""), + ("preset-password-in-use-tip", ""), + ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), + ].iter().cloned().collect(); +} diff --git a/src/lang/vn.rs b/src/lang/vn.rs deleted file mode 100644 index 5a2c47bef..000000000 --- a/src/lang/vn.rs +++ /dev/null @@ -1,657 +0,0 @@ -lazy_static::lazy_static! { -pub static ref T: std::collections::HashMap<&'static str, &'static str> = - [ - ("Status", "Trạng thái hiện tại"), - ("Your Desktop", "Desktop của bạn"), - ("desk_tip", "Desktop của bạn có thể đuợc truy cập bằng ID và mật khẩu này."), - ("Password", "Mật khẩu"), - ("Ready", "Sẵn sàng"), - ("Established", "Đã đuợc thiết lập"), - ("connecting_status", "Đang kết nối đến mạng lưới RustDesk..."), - ("Enable service", "Bật dịch vụ"), - ("Start service", "Bắt đầu dịch vụ"), - ("Service is running", "Dịch vụ hiện đang chạy"), - ("Service is not running", "Dịch vụ hiện đang dừng"), - ("not_ready_status", "Hiện chưa sẵn sàng. Hãy kiểm tra kết nối của bạn"), - ("Control Remote Desktop", "Điều khiển Desktop Từ Xa"), - ("Transfer file", "Truyền Tệp Tin"), - ("Connect", "Kết nối"), - ("Recent sessions", "Các session gần đây"), - ("Address book", "Quyển địa chỉ"), - ("Confirmation", "Xác nhận"), - ("TCP tunneling", "TCP tunneling"), - ("Remove", "Loại bỏ"), - ("Refresh random password", "Làm mới mật khẩu ngẫu nhiên"), - ("Set your own password", "Đặt mật khẩu riêng"), - ("Enable keyboard/mouse", "Cho phép sử dụng bàn phím/chuột"), - ("Enable clipboard", "Cho phép sử dụng clipboard"), - ("Enable file transfer", "Cho phép truyền tệp tin"), - ("Enable TCP tunneling", "Cho phép TCP tunneling"), - ("IP Whitelisting", "Cho phép IP"), - ("ID/Relay Server", "Máy chủ ID/chuyển tiếp"), - ("Import server config", "Nhập cấu hình máy chủ"), - ("Export Server Config", "Xuất cấu hình máy chủ"), - ("Import server configuration successfully", "Nhập cấu hình máy chủ thành công"), - ("Export server configuration successfully", "Xuất cấu hình máy chủ thành công"), - ("Invalid server configuration", "Cấu hình máy chủ không hợp lệ"), - ("Clipboard is empty", "Khay nhớ tạm trống"), - ("Stop service", "Dừng dịch vụ"), - ("Change ID", "Thay đổi ID"), - ("Your new ID", "ID mới của bạn"), - ("length %min% to %max%", "độ dài %min% đến %max%"), - ("starts with a letter", "bắt đầu bằng một chữ"), - ("allowed characters", "các ký tự cho phép"), - ("id_change_tip", "Các kí tự đuợc phép là: từ a-z, A-Z, 0-9 và _ (dấu gạch dưới). Kí tự đầu tiên phải bắt đầu từ a-z, A-Z. Độ dài kí tự từ 6 đến 16"), - ("Website", "Trang web"), - ("About", "Giới thiệu"), - ("Slogan_tip", ""), - ("Privacy Statement", "Bảo Mật Thông tin"), - ("Mute", "Tắt tiếng"), - ("Build Date", "Ngày xuất bản"), - ("Version", "Phiên bản"), - ("Home", "Trang chủ"), - ("Audio Input", "Đầu vào âm thanh"), - ("Enhancements", "Các tiện ích"), - ("Hardware Codec", "Codec phần cứng"), - ("Adaptive bitrate", "Bitrate thích ứng"), - ("ID Server", "Máy chủ ID"), - ("Relay Server", "Máy chủ Chuyển tiếp"), - ("API Server", "Máy chủ API"), - ("invalid_http", "phải bắt đầu bằng http:// hoặc https://"), - ("Invalid IP", "IP không hợp lệ"), - ("Invalid format", "Định dạng không hợp lệnh"), - ("server_not_support", "Chưa đuợc hỗ trợ bởi máy chủ"), - ("Not available", "Chưa có mặt"), - ("Too frequent", "Quá thường xuyên"), - ("Cancel", "Hủy"), - ("Skip", "Bỏ qua"), - ("Close", "Đóng"), - ("Retry", "Thử lại"), - ("OK", "OK"), - ("Password Required", "Yêu cầu mật khẩu"), - ("Please enter your password", "Mời nhập mật khẩu"), - ("Remember password", "Nhớ mật khẩu"), - ("Wrong Password", "Sai mật khẩu"), - ("Do you want to enter again?", "Bạn có muốn nhập lại không?"), - ("Connection Error", "Kết nối bị lỗi"), - ("Error", "Lỗi"), - ("Reset by the peer", "Đựoc cài đặt lại bởi người dùng từ xa"), - ("Connecting...", "Đang kết nối..."), - ("Connection in progress. Please wait.", "Đang kết nối. Vui lòng chờ."), - ("Please try 1 minute later", "Hãy thử lại sau 1 phút"), - ("Login Error", "Đăng nhập bị lỗi"), - ("Successful", "Thành công"), - ("Connected, waiting for image...", "Đã kết nối, đang đợi hình ảnh..."), - ("Name", "Tên"), - ("Type", "Loại"), - ("Modified", "Chỉnh sửa"), - ("Size", "Kích cỡ"), - ("Show Hidden Files", "Hiển thị tệp tin bị ẩn"), - ("Receive", "Nhận"), - ("Send", "Gửi"), - ("Refresh File", "Làm mới tệp tin"), - ("Local", "Cục bộ"), - ("Remote", "Từ xa"), - ("Remote Computer", "Máy tính từ xa"), - ("Local Computer", "Máy tính cục bộ"), - ("Confirm Delete", "Xác nhận xóa"), - ("Delete", "Xóa"), - ("Properties", "Thuộc tính"), - ("Multi Select", "Chọn nhiều"), - ("Select All", "Chọn tất cả"), - ("Unselect All", "Bỏ chọn tất cả"), - ("Empty Directory", "Thư mục rỗng"), - ("Not an empty directory", "Không phải thư mục rỗng"), - ("Are you sure you want to delete this file?", "Bạn chắc bạn có muốn xóa tệp tin này không?"), - ("Are you sure you want to delete this empty directory?", "Bạn chắc bạn có muốn xóa thư mục rỗng này không?"), - ("Are you sure you want to delete the file of this directory?", "Bạn chắc bạn có muốn xóa những tệp tin trong thư mục này không?"), - ("Do this for all conflicts", "Xác nhận đối với tất cả các trùng lặp"), - ("This is irreversible!", "Không thể hoàn tác!"), - ("Deleting", "Đang xóa"), - ("files", "các tệp tin"), - ("Waiting", "Đang chờ"), - ("Finished", "Hoàn thành"), - ("Speed", "Tốc độ"), - ("Custom Image Quality", "Chất lượng hình ảnh"), - ("Privacy mode", "Chế độ riêng tư"), - ("Block user input", "Chặn các tương tác từ người dùng"), - ("Unblock user input", "Hủy chặn các tương tác từ người dùng"), - ("Adjust Window", "Điều chỉnh cửa sổ"), - ("Original", "Gốc"), - ("Shrink", "Thu nhỏ"), - ("Stretch", "Kéo giãn"), - ("Scrollbar", "Thanh cuộn"), - ("ScrollAuto", "Tự động cuộn"), - ("Good image quality", "Chất lượng hình ảnh tốt"), - ("Balanced", "Cân bằng"), - ("Optimize reaction time", "Tối ưu thời gian phản ứng"), - ("Custom", "Tùy chỉnh"), - ("Show remote cursor", "Hiển thị con trỏ từ máy từ xa"), - ("Show quality monitor", "Hiện thị chất lượng của màn hình"), - ("Disable clipboard", "Tắt clipboard"), - ("Lock after session end", "Khóa sau khi kết thúc phiên kết nối"), - ("Insert Ctrl + Alt + Del", "Cài Ctrl + Alt + Del"), - ("Insert Lock", "Cài khóa"), - ("Refresh", "Làm mới"), - ("ID does not exist", "ID không tồn tại"), - ("Failed to connect to rendezvous server", "Không thể kết nối đến máy chủ rendezvous"), - ("Please try later", "Thử lại sau"), - ("Remote desktop is offline", "Máy tính từ xa hiện đang offline"), - ("Key mismatch", "Chìa không khớp"), - ("Timeout", "Quá thời gian"), - ("Failed to connect to relay server", "Không thể kết nối tới máy chủ chuyển tiếp"), - ("Failed to connect via rendezvous server", "Không thể kết nối qua máy chủ rendezvous"), - ("Failed to connect via relay server", "Không thể kết nối qua máy chủ chuyển tiếp"), - ("Failed to make direct connection to remote desktop", "Không thể kết nối thẳng tới máy tính từ xa"), - ("Set Password", "Cài đặt mật khẩu"), - ("OS Password", "Mật khẩu hệ điều hành"), - ("install_tip", "Do UAC, RustDesk sẽ không thể hoạt động đúng cách là bên từ xa trong vài trường hợp. Để tránh UAC, hãy nhấn cái nút dưới đây để cài RustDesk vào hệ thống."), - ("Click to upgrade", "Nhấn để nâng cấp"), - ("Click to download", "Nhấn để tải xuống"), - ("Click to update", "Nhấn để cập nhật"), - ("Configure", "Cài đặt"), - ("config_acc", "Để có thể điều khiển máy tính từ xa, bạn cần phải cung cấp quyền \"Trợ năng\" cho RustDesk"), - ("config_screen", "Để có thể truy cập máy tính từ xa, bạn cần phải cung cấp quyền \"Ghi Màn Hình\" cho RustDesk."), - ("Installing ...", "Đang cài ..."), - ("Install", "Cài"), - ("Installation", "Cài"), - ("Installation Path", "Địa điểm cài"), - ("Create start menu shortcuts", "Tạo shortcut tại start menu"), - ("Create desktop icon", "Tạo biểu tượng trên desktop"), - ("agreement_tip", "Bằng cách bắt đầu cài đặt, bạn chấp nhận thỏa thuận cấp phép."), - ("Accept and Install", "Chấp nhận và Cài"), - ("End-user license agreement", "Thỏa thuận cấp phép dành cho người dùng"), - ("Generating ...", "Đang tạo ..."), - ("Your installation is lower version.", "Phiên bản của bạn là phiên bản cũ"), - ("not_close_tcp_tip", "Đừng đóng cửa sổ này khi bạn đang sử dụng tunnel"), - ("Listening ...", "Đang nghe ..."), - ("Remote Host", "Máy từ xa"), - ("Remote Port", "Cổng từ xa"), - ("Action", "Hành động"), - ("Add", "Thêm"), - ("Local Port", "Cổng nội bộ"), - ("Local Address", "Địa chỉ nội bộ"), - ("Change Local Port", "Thay đổi cổng nội bộ"), - ("setup_server_tip", "Để kết nối nhanh hơn, hãy tự tạo máy chủ riêng"), - ("Too short, at least 6 characters.", "Quá ngắn, độ dài phải ít nhất là 6."), - ("The confirmation is not identical.", "Xác minh không khớp"), - ("Permissions", "Quyền"), - ("Accept", "Chấp nhận"), - ("Dismiss", "Bỏ qua"), - ("Disconnect", "Ngắt kết nối"), - ("Enable file copy and paste", "Cho phép sao chép và dán tệp tin"), - ("Connected", "Đã kết nối"), - ("Direct and encrypted connection", "Kết nối trực tiếp và đuợc mã hóa"), - ("Relayed and encrypted connection", "Kết nối chuyển tiếp và mã hóa"), - ("Direct and unencrypted connection", "Kết nối trực tiếp và không đuợc mã hóa"), - ("Relayed and unencrypted connection", "Kết nối chuyển tiếp và không đuợc mã hóa"), - ("Enter Remote ID", "Nhập ID từ xa"), - ("Enter your password", "Nhập mật khẩu"), - ("Logging in...", "Đang đăng nhập"), - ("Enable RDP session sharing", "Cho phép chia sẻ phiên kết nối RDP"), - ("Auto Login", "Tự động đăng nhập"), - ("Enable direct IP access", "Cho phép truy cập trực tiếp qua IP"), - ("Rename", "Đổi tên"), - ("Space", "Dấu cách"), - ("Create desktop shortcut", "Tạo shortcut trên desktop"), - ("Change Path", "Đổi địa điểm"), - ("Create Folder", "Tạo thư mục"), - ("Please enter the folder name", "Hãy nhập tên thư mục"), - ("Fix it", "Sửa nó"), - ("Warning", "Cảnh báo"), - ("Login screen using Wayland is not supported", "Màn hình đăng nhập sử dụng Wayland không được hỗ trợ"), - ("Reboot required", "Yêu cầu khởi động lại"), - ("Unsupported display server", "Máy chủ hiển thị không đuợc hỗ trọ"), - ("x11 expected", "Cần x11"), - ("Port", "Cổng"), - ("Settings", "Cài đặt"), - ("Username", "Tên người dùng"), - ("Invalid port", "Cổng không hợp lệ"), - ("Closed manually by the peer", "Đã đóng thủ công bởi người dùng từ xa"), - ("Enable remote configuration modification", "Cho phép thay đổi cấu hình bên từ xa"), - ("Run without install", "Chạy mà không cần cài đặt"), - ("Connect via relay", "Kết nối qua máy chủ chuyển tiếp"), - ("Always connect via relay", "Luôn kết nối qua máy chủ chuyển tiếp"), - ("whitelist_tip", "Chỉ có những IP đựoc cho phép mới có thể truy cập"), - ("Login", "Đăng nhập"), - ("Verify", "Xác thực"), - ("Remember me", "Nhớ tài khoản"), - ("Trust this device", "Tin thiết bị này"), - ("Verification code", "Mã xác thực"), - ("verification_tip", "Bạn đang đăng nhập trên một thiết bị mới, một mã xác thực đã được gửi tới email đăng ký của bạn, hãy nhập mã xác thực để tiếp tục đăng nhập."), - ("Logout", "Đăng xuất"), - ("Tags", "Tags"), - ("Search ID", "Tìm ID"), - ("whitelist_sep", "Đuợc cách nhau bởi dấu phẩy, dấu chấm phẩy, dấu cách hay dòng mới"), - ("Add ID", "Thêm ID"), - ("Add Tag", "Thêm Tag"), - ("Unselect all tags", "Hủy chọn tất cả các tag"), - ("Network error", "Lỗi mạng"), - ("Username missed", "Mất tên người dùng"), - ("Password missed", "Mất mật khẩu"), - ("Wrong credentials", "Chứng danh bị sai"), - ("The verification code is incorrect or has expired", ""), - ("Edit Tag", "Chỉnh sửa Tag"), - ("Forget Password", "Quên mật khẩu"), - ("Favorites", "Ưa thích"), - ("Add to Favorites", "Thêm vào mục Ưa thích"), - ("Remove from Favorites", "Xóa khỏi mục Ưa thích"), - ("Empty", "Trống"), - ("Invalid folder name", "Tên thư mục không hợp lệ"), - ("Socks5 Proxy", "Proxy Socks5"), - ("Socks5/Http(s) Proxy", "Proxy Socks5/Http(s)"), - ("Discovered", "Đuợc phát hiện"), - ("install_daemon_tip", "Để chạy lúc khởi động máy, bạn cần phải cài dịch vụ hệ thống."), - ("Remote ID", "ID từ xa"), - ("Paste", "Dán"), - ("Paste here?", "Dán ở đây?"), - ("Are you sure to close the connection?", "Bạn có chắc muốn đóng kết nối không"), - ("Download new version", "Tải về phiên bản mới"), - ("Touch mode", "Chế độ chạm"), - ("Mouse mode", "Chế độ dùng chuột"), - ("One-Finger Tap", "Chạm bằng một ngón tay"), - ("Left Mouse", "Chuột trái"), - ("One-Long Tap", "Chạm lâu bằng một ngón tay"), - ("Two-Finger Tap", "Chạm bằng hai ngón tay"), - ("Right Mouse", "Chuột phải"), - ("One-Finger Move", "Di chuyển bằng một ngón tay"), - ("Double Tap & Move", "Chạm hai lần và di chuyển"), - ("Mouse Drag", "Di chuyển bằng chuột"), - ("Three-Finger vertically", "Ba ngón tay theo chiều dọc"), - ("Mouse Wheel", "Bánh xe lăn trê con chuột"), - ("Two-Finger Move", "Di chuyển bằng hai ngón tay"), - ("Canvas Move", "Di chuyển canvas"), - ("Pinch to Zoom", "Véo để phóng to/nhỏ"), - ("Canvas Zoom", "Phóng to/nhỏ canvas"), - ("Reset canvas", "Cài đặt lại canvas"), - ("No permission of file transfer", "Không có quyền truyền tệp tin"), - ("Note", "Ghi nhớ"), - ("Connection", "Kết nối"), - ("Share Screen", "Chia sẻ màn hình"), - ("Chat", "Chat"), - ("Total", "Tổng"), - ("items", "items"), - ("Selected", "Đã đuợc chọn"), - ("Screen Capture", "Ghi màn hình"), - ("Input Control", "Điều khiển đầu vào"), - ("Audio Capture", "Ghi âm thanh"), - ("File Connection", "Kết nối tệp tin"), - ("Screen Connection", "Kết nối màn hình"), - ("Do you accept?", "Bạn có chấp nhận không?"), - ("Open System Setting", "Mở cài đặt hệ thống"), - ("How to get Android input permission?", "Cách để có quyền nhập trên Android?"), - ("android_input_permission_tip1", "Để thiết bị từ xa điều khiển thiết bị Android của bạn bằng chuột hoặc chạm, bạn cần cho phép RustDesk sử dụng dịch vụ \"Trợ năng\"."), - ("android_input_permission_tip2", "Vui lòng chuyển đến trang cài đặt hệ thống tiếp theo, tìm và nhập [Dịch vụ đã cài đặt], bật dịch vụ [RustDesk Input]."), - ("android_new_connection_tip", "Yêu cầu kiểm soát mới đã được nhận, yêu cầu này muốn kiểm soát thiết bị hiện tại của bạn."), - ("android_service_will_start_tip", "Bật \"Ghi màn hình\" sẽ tự động khởi động dịch vụ, cho phép các thiết bị khác yêu cầu kết nối với thiết bị của bạn."), - ("android_stop_service_tip", "Đóng dịch vụ sẽ tự động đóng tất cả các kết nối đã thiết lập."), - ("android_version_audio_tip", "Phiên bản Android hiện tại không hỗ trợ ghi âm, vui lòng nâng cấp lên Android 10 trở lên."), - ("android_start_service_tip", "Nhấn [Bắt đầu dịch vụ] hoặc bật quyền [Ghi màn hình] để bắt đầu dịch vụ chia sẻ màn hình"), - ("android_permission_may_not_change_tip", "Quyền cho các kết nối đã được thiếp lập có thể không được thay đổi ngay cho tới khi kết nối lại"), - ("Account", "Tài khoản"), - ("Overwrite", "Ghi đè"), - ("This file exists, skip or overwrite this file?", "Tệp tin này đã tồn tại, bạn có muốn bỏ qua hay ghi đè lên tệp tin này?"), - ("Quit", "Thoát"), - ("Help", "Trợ giúp"), - ("Failed", "Thất bại"), - ("Succeeded", "Thành công"), - ("Someone turns on privacy mode, exit", "Ai đó đã bật chế độ riêng tư, thoát"), - ("Unsupported", "Không hỗ trợ"), - ("Peer denied", "Người dùng từ xa đã từ chối"), - ("Please install plugins", "Hãy cài plugins"), - ("Peer exit", "Người dùng từ xa đã thoát"), - ("Failed to turn off", "Không thể tắt"), - ("Turned off", "Đã tắt"), - ("Language", "Ngôn ngữ"), - ("Keep RustDesk background service", "Giữ dịch vụ nền RustDesk"), - ("Ignore Battery Optimizations", "Bỏ qua các tối ưu pin"), - ("android_open_battery_optimizations_tip", "Nếu bạn muốn tắt tính năng này, vui lòng chuyển đến trang cài đặt ứng dụng RustDesk tiếp theo, tìm và nhập [Pin], Bỏ chọn [Không hạn chế]"), - ("Start on boot", "Chạy khi khởi động"), - ("Start the screen sharing service on boot, requires special permissions", "Chạy dịch vụ chia sẻ màn hình khi khởi động, yêu cầu quyền đặc biệt"), - ("Connection not allowed", "Kết nối không đuợc phép"), - ("Legacy mode", "Chế độ cũ"), - ("Map mode", "Chế độ map"), - ("Translate mode", "Chế độ phiên dịch"), - ("Use permanent password", "Sử dụng mật khẩu vĩnh viễn"), - ("Use both passwords", "Sử dụng cả hai mật khẩu"), - ("Set permanent password", "Đặt mật khẩu vĩnh viễn"), - ("Enable remote restart", "Bật khởi động lại từ xa"), - ("Restart remote device", "Khởi động lại thiết bị từ xa"), - ("Are you sure you want to restart", "Bạn có chắc bạn muốn khởi động lại không"), - ("Restarting remote device", "Đang khởi động lại thiết bị từ xa"), - ("remote_restarting_tip", "Thiết bị từ xa đang khởi động lại, hãy đóng cửa sổ tin nhắn này và kết nối lại với mật khẩu vĩnh viễn sau một khoảng thời gian"), - ("Copied", "Đã sao chép"), - ("Exit Fullscreen", "Thoát toàn màn hình"), - ("Fullscreen", "Toàn màn hình"), - ("Mobile Actions", "Hành động trên thiết bị di động"), - ("Select Monitor", "Chọn màn hình"), - ("Control Actions", "Kiểm soát hành động"), - ("Display Settings", "Thiết lập hiển thị"), - ("Ratio", "Tỉ lệ"), - ("Image Quality", "Chất lượng hình ảnh"), - ("Scroll Style", "Kiểu cuộn"), - ("Show Toolbar", "Hiện thanh công cụ"), - ("Hide Toolbar", "Ẩn thanh công cụ"), - ("Direct Connection", "Kết nối trực tiếp"), - ("Relay Connection", "Kết nối chuyển tiếp"), - ("Secure Connection", "Kết nối an toàn"), - ("Insecure Connection", "Kết nối không an toàn"), - ("Scale original", "Quy mô gốc"), - ("Scale adaptive", "Quy mô thích ứng"), - ("General", "Chung"), - ("Security", "Bảo mật"), - ("Theme", "Chủ đề"), - ("Dark Theme", "Chủ đề Tối"), - ("Light Theme", "Chủ đề Sáng"), - ("Dark", "Tối"), - ("Light", "Sáng"), - ("Follow System", "Theo hệ thống"), - ("Enable hardware codec", "Bật codec phần cứng"), - ("Unlock Security Settings", "Mở khóa cài đặt bảo mật"), - ("Enable audio", "Bật âm thanh"), - ("Unlock Network Settings", "Mở khóa cài đặt mạng"), - ("Server", "Máy chủ"), - ("Direct IP Access", "Truy cập trực tiếp qua IP"), - ("Proxy", ""), - ("Apply", "Áp dụng"), - ("Disconnect all devices?", "Ngắt kết nối tất cả thiết bị"), - ("Clear", "Làm trống"), - ("Audio Input Device", "Thiết bị âm thanh đầu vào"), - ("Use IP Whitelisting", "Dùng danh sách các IP cho phép"), - ("Network", "Mạng"), - ("Pin Toolbar", "Ghim thanh công cụ"), - ("Unpin Toolbar", "Bỏ ghim thanh công cụ"), - ("Recording", "Đang ghi hình"), - ("Directory", "Thư mục"), - ("Automatically record incoming sessions", "Tự động ghi những phiên kết nối vào"), - ("Automatically record outgoing sessions", ""), - ("Change", "Thay đổi"), - ("Start session recording", "Bắt đầu ghi hình phiên kết nối"), - ("Stop session recording", "Dừng ghi hình phiên kết nối"), - ("Enable recording session", "Bật ghi hình phiên kết nối"), - ("Enable LAN discovery", "Bật phát hiện mạng nội bộ (LAN)"), - ("Deny LAN discovery", "Từ chối phát hiện mạng nội bộ (LAN)"), - ("Write a message", "Viết một tin nhắn"), - ("Prompt", ""), - ("Please wait for confirmation of UAC...", "Vui lòng chờ cho phép UAC"), - ("elevated_foreground_window_tip", "Cửa sổ hiện tại của máy tính từ xa yêu cầu quyền cao hơn để vận hành, nên bạn không thể sử dụng chuột và bàn phím tạm thời. Bạn có thể yêu cầu người dùng từ xa thu nhỏ cửa sổ hiện tại, hoặc nhấn vào nút Cấp Quyền trong cửa sổ quản lý kết nối. Để tránh tính trạng này, chúng tôi gợi ý nên cài đặt phần mềm ở phía thiết bị từ xa."), - ("Disconnected", "Đã ngắt kết nối"), - ("Other", "Khác"), - ("Confirm before closing multiple tabs", "Xác nhận trước khi đóng nhiều cửa sổ"), - ("Keyboard Settings", "Cài đặt bàn phím"), - ("Full Access", "Truy cập không giới hạng"), - ("Screen Share", "Chia sẻ màn hình"), - ("Wayland requires Ubuntu 21.04 or higher version.", "Wayland yêu cầu phiên bản Ubuntu 21.04 trở lên."), - ("Wayland requires higher version of linux distro. Please try X11 desktop or change your OS.", "Wayland yêu cầu phiên bản distro linux cao hơn. Vui lòng thử máy tính để bàn X11 hoặc thay đổi hệ điều hành của bạn."), - ("JumpLink", "View"), - ("Please Select the screen to be shared(Operate on the peer side).", "Vui lòng Chọn màn hình để chia sẻ (Vận hành ở phía người dùng từ xa)."), - ("Show RustDesk", "Hiện RustDesk"), - ("This PC", ""), - ("or", "hoặc"), - ("Continue with", "Tiếp tục với"), - ("Elevate", "Cấp Quyền"), - ("Zoom cursor", "Phóng to chuột"), - ("Accept sessions via password", "Chấp nhận phiên kết nối bằng mật khẩu"), - ("Accept sessions via click", "Chấp nhận phiên kết nối bằng chuột"), - ("Accept sessions via both", "Chấp nhận phiên kết nối bằng cả hai"), - ("Please wait for the remote side to accept your session request...", "Vui lòng chờ phía người dùng từ xa chấp nhận kết nối của bạn..."), - ("One-time Password", "Mật khẩu một lần"), - ("Use one-time password", "Dùng mật khẩu một lần"), - ("One-time password length", "Độ dài mật khẩu một lần"), - ("Request access to your device", "Yêu cầu quyền truy cập vào thiết bị của bạn"), - ("Hide connection management window", "Ẩn cửa sổ quản lý kết nối"), - ("hide_cm_tip", "Cho phép ẩn chỉ khi chấp nhận phiên kết nối bằng mật khẩu vĩnh viễn"), - ("wayland_experiment_tip", "Hỗ trợ cho Wayland đang trong giai đoạn thử nghiệm, vui lòng dùng DX11 nếu bạn muốn sử dụng kết nối không giám sát."), - ("Right click to select tabs", "Chuột phải để chọn cửa sổ"), - ("Skipped", "Đã bỏ qua"), - ("Add to address book", "Thêm vào Quyển địa chỉ"), - ("Group", "Nhóm"), - ("Search", "Tìm"), - ("Closed manually by web console", "Đã đóng thủ công bằng bảng điều khiển web"), - ("Local keyboard type", "Loại bàn phím cục bộ"), - ("Select local keyboard type", "Chọn kiểu bàn phím cục bộ"), - ("software_render_tip", "Nếu bạn đang dùng card đồ họa Nvidia trên Linux và cửa sổ từ xa bị tắt ngay lập tức sau khi kết nối, chuyển sang driver mã nguồn mở Nouveau và chọn sử dụng render bằng phần mềm có thể khắc phục được. Yêu cầu khởi động lại phần mềm."), - ("Always use software rendering", "Cho phép render bằng phần mềm"), - ("config_input", "Để điều khiển được máy tính từ xa với bàn phím, bạn cần cho phép RustDesk quyền \"Theo dõi đầu vào\" (Input Monitoring)"), - ("config_microphone", "Để nói chuyện từ xa, bạn phải cho phép RustDesk quyền \"Ghi âm thanh\" (Record Audio)"), - ("request_elevation_tip", "Bạn cũng có thể yêu cầu được cấp quyền nếu có người nào đó ở bên phía kết nối."), - ("Wait", "Chờ"), - ("Elevation Error", "Cấp Quyền Lỗi"), - ("Ask the remote user for authentication", "Yêu cầu người dùng từ xa xác thực"), - ("Choose this if the remote account is administrator", "Chọn cái này nếu tài khoản từ xa là quản trị viên"), - ("Transmit the username and password of administrator", "Truyền tên tài khoản và mật khẩu của quản trị viên"), - ("still_click_uac_tip", "Vẫn cần người dùng từ xa nhấn OK trên cửa sổ UAC của RustDesk đang chạy."), - ("Request Elevation", "Yêu cầu Cấp Quyền"), - ("wait_accept_uac_tip", "Vui lòng chờ cho người dùng từ xa chấp nhận cửa sổ UAC"), - ("Elevate successfully", "Cấp quyền thành công"), - ("uppercase", "chữ hoa"), - ("lowercase", "chữ thường"), - ("digit", "chữ số"), - ("special character", "ký tự đặc biệt"), - ("length>=8", "độ dài>=8"), - ("Weak", "Yếu"), - ("Medium", "Trung bình"), - ("Strong", "Mạng"), - ("Switch Sides", "Đổi bên"), - ("Please confirm if you want to share your desktop?", "Vui lòng xác nhận nếu bạn muốn chia sẻ máy tính?"), - ("Display", "Hiển thị"), - ("Default View Style", "Kiểu xem mặc định"), - ("Default Scroll Style", "Kiểu cuộn mặc định"), - ("Default Image Quality", "Chất lượng hình ảnh mặc định"), - ("Default Codec", "Codec mặc định"), - ("Bitrate", "T"), - ("FPS", ""), - ("Auto", "Tự động"), - ("Other Default Options", "Các tùy chọn mặc định khác"), - ("Voice call", "Gọi âm thanh"), - ("Text chat", "Tin nhắn"), - ("Stop voice call", "Dừng cuộc gọi"), - ("relay_hint_tip", "Việc kết nối trực tiếp có thể không khả thi, bạn có thể thử kết nối qua máy chủ chuyển tiếp. \nThêm vào đó, nếu bạn muốn sử dụng máy chủ chuyển tiếp trong lần thử đầu tiên, bạn có thể thêm hậu tố \"/r\" vào sau ID, hoặc chọn tùy chọn \"Luôn kết nối qua máy chủ chuyển tiếp\""), - ("Reconnect", "Kết nối lại"), - ("Codec", ""), - ("Resolution", "Độ phân giải"), - ("No transfers in progress", "Không có tệp tin nào đang được truyền"), - ("Set one-time password length", "Thiết lập độ dài mật khẩu một lần"), - ("RDP Settings", "Cài đặt RDP"), - ("Sort by", "Sắp xếp theo"), - ("New Connection", "Kết nối mới"), - ("Restore", "Khôi phục"), - ("Minimize", "Thu nhỏ"), - ("Maximize", "Phóng to"), - ("Your Device", "Thiết bị của bạn"), - ("empty_recent_tip", "Oops, không có kết nối nào gần đây!\nĐã đến lúc kết nối rồi."), - ("empty_favorite_tip", "Chưa có người dùng yêu thích nào cả?\nHãy tìm ai đó để kết nối cùng và thêm họ vào danh sách yêu thích!"), - ("empty_lan_tip", "Ôi không, có vẻ như chúng ta chưa phát hiện ra bất cứ người dùng nào cả."), - ("empty_address_book_tip", "Ôi bạn ơi, có vẻ như bạn chưa thêm ai vào quyển địa chỉ cả."), - ("eg: admin", "ví dụ: admin"), - ("Empty Username", "Tên tài khoản trống"), - ("Empty Password", "Mật khẩu trống"), - ("Me", "Tôi"), - ("identical_file_tip", "Tệp tin này giống hệt với tệp tin của người dùng từ xa"), - ("show_monitors_tip", "Hiện các màn hình trong thanh công cụ"), - ("View Mode", "Chế độ xem"), - ("login_linux_tip", "Bạn cần đăng nhập vào tài khoản Linux từ xa để bật X phiên kết nối"), - ("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 này đã được dùng để đăng nhập tới hệ điều hành từ xa và kích hoạt phiên kết nối ở chế độ headless"), - ("OS Account", "Tài khoản hệ điều hành"), - ("another_user_login_title_tip", "Có người dùng khác đã đăng nhập"), - ("another_user_login_text_tip", "Ngắt kết nố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 khả dụng"), - ("no_desktop_text_tip", "Vui lòng cài đặt desktop GNOME"), - ("No need to elevate", "Không cần phải cấp quyền"), - ("System Sound", "Âm thanh hệ thống"), - ("Default", "Mặc định"), - ("New RDP", "RDP mới"), - ("Fingerprint", ""), - ("Copy Fingerprint", "Sao chép fingerprint"), - ("no fingerprints", "không có fingerprints"), - ("Select a peer", "Chọn một người dùng"), - ("Select peers", "Chọn nhiều người dùng"), - ("Plugins", "Tiện ích"), - ("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 độ phân giải cục bộ"), - ("resolution_custom_tip", "Độ phân giải tùy chỉnh"), - ("Collapse toolbar", "Thu nhỏ thanh công cụ"), - ("Accept and Elevate", "Chấp nhận và Cấp Quyền"), - ("accept_and_elevate_btn_tooltip", "Chấp nhận kết nối và cấp các quyền UAC."), - ("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", ""), - ("Installation Successful!", ""), - ("Installation failed!", ""), - ("Reverse mouse wheel", ""), - ("{} sessions", ""), - ("scam_title", ""), - ("scam_text1", ""), - ("scam_text2", ""), - ("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)", ""), - ("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", ""), - ("swap-left-right-mouse", ""), - ("2FA code", ""), - ("More", ""), - ("enable-2fa-title", ""), - ("enable-2fa-desc", ""), - ("wrong-2fa-code", ""), - ("enter-2fa-title", ""), - ("Email verification code must be 6 characters.", ""), - ("2FA code must be 6 digits.", ""), - ("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", ""), - ("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", ""), - ("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", ""), - ("Requires at least {} characters", ""), - ("Wrong PIN", ""), - ("Set 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", ""), - ].iter().cloned().collect(); -} diff --git a/src/lib.rs b/src/lib.rs index 7f9ca4e9a..5621d5e2a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,7 +3,8 @@ mod keyboard; pub mod platform; #[cfg(not(any(target_os = "android", target_os = "ios")))] pub use platform::{ - get_cursor, get_cursor_data, get_cursor_pos, get_focused_display, start_os_service, + clip_cursor, get_cursor, get_cursor_data, get_cursor_pos, get_focused_display, + set_cursor_pos, start_os_service, }; #[cfg(not(any(target_os = "ios")))] /// cbindgen:ignore @@ -39,14 +40,14 @@ use common::*; mod auth_2fa; #[cfg(feature = "cli")] pub mod cli; +#[cfg(not(target_os = "ios"))] +mod clipboard; #[cfg(not(any(target_os = "android", target_os = "ios", feature = "cli")))] pub mod core_main; mod custom_server; mod lang; #[cfg(not(any(target_os = "android", target_os = "ios")))] mod port_forward; -#[cfg(not(any(target_os = "android", target_os = "ios")))] -mod clipboard; #[cfg(all(feature = "flutter", feature = "plugin_framework"))] #[cfg(not(any(target_os = "android", target_os = "ios")))] @@ -55,6 +56,12 @@ pub mod plugin; #[cfg(not(any(target_os = "android", target_os = "ios")))] mod tray; +#[cfg(not(any(target_os = "android", target_os = "ios")))] +mod whiteboard; + +#[cfg(not(any(target_os = "android", target_os = "ios")))] +mod updater; + mod ui_cm_interface; mod ui_interface; mod ui_session_interface; @@ -68,3 +75,5 @@ pub mod privacy_mode; #[cfg(windows)] pub mod virtual_display_manager; + +mod kcp_stream; diff --git a/src/main.rs b/src/main.rs index f295363aa..9bc90a8fa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ use librustdesk::*; #[cfg(any(target_os = "android", target_os = "ios", feature = "flutter"))] fn main() { if !common::global_init() { + eprintln!("Global initialization failed."); return; } common::test_rendezvous_server(); @@ -22,9 +23,6 @@ fn main() { feature = "flutter" )))] fn main() { - if !common::global_init() { - return; - } #[cfg(all(windows, not(feature = "inline")))] unsafe { winapi::um::shellscalingapi::SetProcessDpiAwareness(2); diff --git a/src/platform/gtk_sudo.rs b/src/platform/gtk_sudo.rs index 9aeea1e2b..37b541cbe 100644 --- a/src/platform/gtk_sudo.rs +++ b/src/platform/gtk_sudo.rs @@ -5,7 +5,9 @@ use crate::lang::translate; use gtk::{glib, prelude::*}; use hbb_common::{ anyhow::{bail, Error}, - log, ResultType, + log, + platform::linux::CMD_SH, + ResultType, }; use nix::{ libc::{fcntl, kill}, @@ -463,12 +465,12 @@ fn ui_parent( fn child(su_user: Option, args: Vec) -> ResultType<()> { // https://doc.rust-lang.org/std/env/consts/constant.OS.html let os = std::env::consts::OS; - let bsd = os == "freebsd" || os == "dragonfly" || os == "netbsd" || os == "openbad"; + let bsd = os == "freebsd" || os == "dragonfly" || os == "netbsd" || os == "openbsd"; let mut params = vec!["sudo".to_string()]; if su_user.is_some() { params.push("-S".to_string()); } - params.push("/bin/sh".to_string()); + params.push(CMD_SH.to_string()); params.push("-c".to_string()); let command = args diff --git a/src/platform/linux.rs b/src/platform/linux.rs index 08cf0fb9a..9a4bb37ec 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -1,74 +1,180 @@ use super::{gtk_sudo, CursorData, ResultType}; use desktop::Desktop; -use hbb_common::config::keys::OPTION_ALLOW_LINUX_HEADLESS; pub use hbb_common::platform::linux::*; use hbb_common::{ allow_err, anyhow::anyhow, bail, - config::Config, - libc::{c_char, c_int, c_long, c_void}, + config::{keys::OPTION_ALLOW_LINUX_HEADLESS, Config}, + libc::{c_char, c_int, c_long, c_uint, c_ulong, c_void}, log, message_proto::{DisplayInfo, Resolution}, regex::{Captures, Regex}, + users::{get_user_by_name, os::unix::UserExt}, }; +use libxdo_sys::{self, xdo_t, Window}; use std::{ cell::RefCell, - ffi::OsStr, + ffi::{OsStr, OsString}, path::{Path, PathBuf}, process::{Child, Command}, string::String, - sync::{ - atomic::{AtomicBool, Ordering}, - Arc, - }, + sync::atomic::{AtomicBool, Ordering}, + sync::Arc, time::{Duration, Instant}, }; -use users::{get_user_by_name, os::unix::UserExt}; +use terminfo::{capability as cap, Database}; use wallpaper; -type Xdo = *const c_void; - pub const PA_SAMPLE_RATE: u32 = 48000; static mut UNMODIFIED: bool = true; +#[derive(Clone, Debug)] +struct ActiveUserLookupCache { + uid: String, + username: String, +} + +const INVALID_TERM_VALUES: [&str; 3] = ["", "unknown", "dumb"]; +const SHELL_PROCESSES: [&str; 4] = ["bash", "zsh", "fish", "sh"]; + +// Terminal type constants +const TERM_XTERM_256COLOR: &str = "xterm-256color"; +const TERM_SCREEN_256COLOR: &str = "screen-256color"; +const TERM_XTERM: &str = "xterm"; + lazy_static::lazy_static! { pub static ref IS_X11: bool = hbb_common::platform::linux::is_x11_or_headless(); + // 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 = { + match Database::from_name(TERM_XTERM_256COLOR) { + Ok(database) => Some(database), + Err(err) => { + log::error!("Failed to initialize {} database: {}", TERM_XTERM_256COLOR, err); + None + } + } + }; + static ref ACTIVE_USER_LOOKUP_CACHE: std::sync::Mutex> = + std::sync::Mutex::new(None); + // https://github.com/rustdesk/rustdesk/issues/13705 + // Check if `sudo -E` actually preserves environment. + // + // This flag is only used by `run_as_user()` (root service -> user session). If the current process is not + // running as `root`, this check is meaningless (and `sudo -n` may fail), so we return `false` directly. + // + // On Ubuntu 25.10, `sudo -E` may still succeed but effectively ignores `-E`. Some versions print a warning + // to stderr (wording may vary by locale), so we verify behavior instead: + // - Inject a sentinel environment variable into the `sudo` process + // - Run `sudo -n -E env` and check whether the sentinel is present in stdout + static ref SUDO_E_PRESERVES_ENV: bool = { + if !is_root() { + log::warn!("Not running as root, SUDO_E_PRESERVES_ENV check skipped"); + false + } else { + let key = format!("__RUSTDESK_SUDO_E_TEST_{}", std::process::id()); + let val = "1"; + let expected = format!("{key}={val}"); + Command::new("sudo") + // -n for non-interactive to avoid password prompt + .env(&key, val) + .args(["-n", "-E", "env"]) + .output() + .map(|o| { + o.status.success() + && String::from_utf8_lossy(&o.stdout).contains(expected.as_str()) + }) + .unwrap_or(false) + } + }; +} + +#[inline] +fn update_active_user_lookup_cache(desktop: &Desktop) { + if let Ok(mut cache) = ACTIVE_USER_LOOKUP_CACHE.lock() { + if desktop.uid.is_empty() || desktop.username.is_empty() { + *cache = None; + } else { + *cache = Some(ActiveUserLookupCache { + uid: desktop.uid.clone(), + username: desktop.username.clone(), + }); + } + } +} + +#[inline] +fn get_active_user_id_name_from_cache() -> Option<(String, String)> { + let cache = ACTIVE_USER_LOOKUP_CACHE.lock().ok()?; + let entry = cache.as_ref()?; + Some((entry.uid.clone(), entry.username.clone())) } thread_local! { - static XDO: RefCell = RefCell::new(unsafe { xdo_new(std::ptr::null()) }); + // XDO context - created via libxdo-sys (which uses dynamic loading stub). + // If libxdo is not available, xdo will be null and xdo-based functions become no-ops. + static XDO: RefCell<*mut xdo_t> = RefCell::new({ + let xdo = unsafe { libxdo_sys::xdo_new(std::ptr::null()) }; + if xdo.is_null() { + log::warn!("Failed to create xdo context, xdo functions will be disabled"); + } else { + log::info!("xdo context created successfully"); + } + xdo + }); static DISPLAY: RefCell<*mut c_void> = RefCell::new(unsafe { XOpenDisplay(std::ptr::null())}); } -extern "C" { - fn xdo_get_mouse_location( - xdo: Xdo, - x: *mut c_int, - y: *mut c_int, - screen_num: *mut c_int, - ) -> c_int; - fn xdo_new(display: *const c_char) -> Xdo; - fn xdo_get_active_window(xdo: Xdo, window: *mut *mut c_void) -> c_int; - fn xdo_get_window_location( - xdo: Xdo, - window: *mut c_void, - x: *mut c_int, - y: *mut c_int, - screen_num: *mut c_int, - ) -> c_int; - fn xdo_get_window_size( - xdo: Xdo, - window: *mut c_void, - width: *mut c_int, - height: *mut c_int, - ) -> c_int; +// X11 error event structure for the custom error handler. +// See: https://www.x.org/releases/current/doc/libX11/libX11/libX11.html#Using-the-Default-Error-Handlers +#[repr(C)] +struct XErrorEvent { + type_: c_int, + display: *mut c_void, // Display* + resourceid: c_ulong, // XID + serial: c_ulong, + error_code: u8, + request_code: u8, + minor_code: u8, +} + +type XErrorHandler = unsafe extern "C" fn(*mut c_void, *mut XErrorEvent) -> c_int; + +const X11_BAD_WINDOW: u8 = 3; +const XDO_SUCCESS: c_int = 0; +const XDO_ERROR: c_int = 1; + +/// Atomic flag set by the custom X error handler when a BadWindow error occurs. +static X_BAD_WINDOW_DETECTED: AtomicBool = AtomicBool::new(false); +static X_UNEXPECTED_ERROR_DETECTED: AtomicBool = AtomicBool::new(false); + +/// Custom X error handler that catches BadWindow errors (error_code == 3) instead of +/// letting the default handler terminate the process. +/// See issue: https://github.com/rustdesk/rustdesk/issues/9003 +unsafe extern "C" fn handle_x_error(_display: *mut c_void, event: *mut XErrorEvent) -> c_int { + if !event.is_null() && (*event).error_code == X11_BAD_WINDOW { + X_BAD_WINDOW_DETECTED.store(true, Ordering::SeqCst); + log::debug!("Caught X11 BadWindow error (suppressed), window was likely destroyed"); + return 0; + } + X_UNEXPECTED_ERROR_DETECTED.store(true, Ordering::SeqCst); + if !event.is_null() { + log::warn!( + "X11 error: error_code={}, request_code={}, minor_code={}", + (*event).error_code, + (*event).request_code, + (*event).minor_code, + ); + } + 0 } #[link(name = "X11")] extern "C" { fn XOpenDisplay(display_name: *const c_char) -> *mut c_void; // fn XCloseDisplay(d: *mut c_void) -> c_int; + fn XSetErrorHandler(handler: Option) -> Option; } #[link(name = "Xfixes")] @@ -110,14 +216,19 @@ fn sleep_millis(millis: u64) { pub fn get_cursor_pos() -> Option<(i32, i32)> { let mut res = None; XDO.with(|xdo| { - if let Ok(xdo) = xdo.try_borrow_mut() { + if let Ok(xdo) = xdo.try_borrow() { if xdo.is_null() { return; } let mut x: c_int = 0; let mut y: c_int = 0; unsafe { - xdo_get_mouse_location(*xdo, &mut x as _, &mut y as _, std::ptr::null_mut()); + libxdo_sys::xdo_get_mouse_location( + *xdo as *const _, + &mut x as _, + &mut y as _, + std::ptr::null_mut(), + ); } res = Some((x, y)); } @@ -125,40 +236,118 @@ pub fn get_cursor_pos() -> Option<(i32, i32)> { res } +pub fn set_cursor_pos(x: i32, y: i32) -> bool { + let mut res = false; + XDO.with(|xdo| { + match xdo.try_borrow() { + Ok(xdo) => { + if xdo.is_null() { + log::debug!("set_cursor_pos: xdo is null"); + return; + } + unsafe { + let ret = libxdo_sys::xdo_move_mouse(*xdo as *const _, x, y, 0); + if ret != 0 { + log::debug!( + "set_cursor_pos: xdo_move_mouse failed with code {} for coordinates ({}, {})", + ret, x, y + ); + } + res = ret == 0; + } + } + Err(_) => { + log::debug!("set_cursor_pos: failed to borrow xdo"); + } + } + }); + res +} + +/// Clip cursor - Linux implementation is a no-op. +/// +/// On X11, there's no direct equivalent to Windows ClipCursor. XGrabPointer +/// can confine the pointer but requires a window handle and has side effects. +/// +/// On Wayland, pointer constraints require the zwp_pointer_constraints_v1 +/// protocol which is compositor-dependent. +/// +/// For relative mouse mode on Linux, the Flutter side uses pointer warping +/// (set_cursor_pos) to re-center the cursor after each movement, which achieves +/// a similar effect without requiring cursor clipping. +/// +/// Returns true (always succeeds as no-op). +pub fn clip_cursor(_rect: Option<(i32, i32, i32, i32)>) -> bool { + // Log only once per process to avoid flooding logs when called frequently. + static LOGGED: AtomicBool = AtomicBool::new(false); + if !LOGGED.swap(true, Ordering::Relaxed) { + log::debug!("clip_cursor called (no-op on Linux, this message is logged only once)"); + } + true +} + pub fn reset_input_cache() {} pub fn get_focused_display(displays: Vec) -> Option { let mut res = None; XDO.with(|xdo| { - if let Ok(xdo) = xdo.try_borrow_mut() { + if let Ok(xdo) = xdo.try_borrow() { if xdo.is_null() { return; } let mut x: c_int = 0; let mut y: c_int = 0; - let mut width: c_int = 0; - let mut height: c_int = 0; - let mut window: *mut c_void = std::ptr::null_mut(); + let mut width: c_uint = 0; + let mut height: c_uint = 0; + let mut window: Window = 0; unsafe { - if xdo_get_active_window(*xdo, &mut window) != 0 { + if libxdo_sys::xdo_get_active_window(*xdo as *const _, &mut window) != 0 { return; } - if xdo_get_window_location( - *xdo, + + // XSetErrorHandler is process-global, not scoped to this Display/thread. + // This path is currently called by the single window_focus service thread. + // While installed, this handler can still observe unrelated X11 errors from + // other threads; unexpected errors make this geometry query fail. + X_BAD_WINDOW_DETECTED.store(false, Ordering::SeqCst); + X_UNEXPECTED_ERROR_DETECTED.store(false, Ordering::SeqCst); + let prev_handler = XSetErrorHandler(Some(handle_x_error)); + + let loc_ret = libxdo_sys::xdo_get_window_location( + *xdo as *const _, window, &mut x as _, &mut y as _, std::ptr::null_mut(), - ) != 0 + ); + let size_ret = if loc_ret == XDO_SUCCESS { + libxdo_sys::xdo_get_window_size( + *xdo as *const _, + window, + &mut width, + &mut height, + ) + } else { + XDO_ERROR + }; + + // Do not call XSync(DISPLAY) here: DISPLAY is a separate + // XOpenDisplay() connection, while libxdo owns the Display* + // used by these geometry queries. These libxdo calls are + // synchronous XGetWindowAttributes-based queries, so the target + // BadWindow is expected to be delivered before the calls return. + XSetErrorHandler(prev_handler); + if X_BAD_WINDOW_DETECTED.load(Ordering::SeqCst) + || X_UNEXPECTED_ERROR_DETECTED.load(Ordering::SeqCst) + || loc_ret != XDO_SUCCESS + || size_ret != XDO_SUCCESS { return; } - if xdo_get_window_size(*xdo, window, &mut width as _, &mut height as _) != 0 { - return; - } - let center_x = x + width / 2; - let center_y = y + height / 2; + + let center_x = x + (width / 2) as c_int; + let center_y = y + (height / 2) as c_int; res = displays.iter().position(|d| { center_x >= d.x && center_x < d.x + d.width @@ -255,6 +444,188 @@ fn start_uinput_service() { }); } +/// Suggests the best terminal type based on the environment. +/// +/// The function prioritizes terminal types in the following order: +/// 1. `screen-256color`: Preferred when running inside `tmux` or `screen` sessions, +/// as these multiplexers often support advanced terminal features. +/// 2. `xterm-256color`: Selected if the terminal supports 256 colors, which is +/// suitable for modern terminal applications. +/// 3. `xterm`: Used as a fallback for basic terminal compatibility. +/// +/// Terminals like `linux` and `vt100` are excluded because they lack support for +/// modern features required by many applications. +fn suggest_best_term() -> String { + if is_running_in_tmux() || is_running_in_screen() { + return TERM_SCREEN_256COLOR.to_string(); + } + if term_supports_256_colors(TERM_XTERM_256COLOR) { + return TERM_XTERM_256COLOR.to_string(); + } + TERM_XTERM.to_string() +} + +fn is_running_in_tmux() -> bool { + std::env::var("TMUX").is_ok() +} + +fn is_running_in_screen() -> bool { + std::env::var("STY").is_ok() +} + +fn supports_256_colors(db: &Database) -> bool { + db.get::().map_or(false, |n| n.0 >= 256) +} + +fn term_supports_256_colors(term: &str) -> bool { + match term { + TERM_XTERM_256COLOR => DATABASE_XTERM_256COLOR + .as_ref() + .map_or(false, |db| supports_256_colors(db)), + _ => Database::from_name(term).map_or(false, |db| supports_256_colors(&db)), + } +} + +fn get_cur_term(uid: &str) -> Option { + // Check cache first - if TERM_XTERM_256COLOR was found before, reuse it + if let Ok(cache) = CACHED_TERM.lock() { + if let Some(ref cached) = *cache { + if cached == TERM_XTERM_256COLOR { + return Some(cached.clone()); + } + } + } + + if uid.is_empty() { + return None; + } + + // Check current process environment + if let Ok(term) = std::env::var("TERM") { + if term == TERM_XTERM_256COLOR { + if let Ok(mut cache) = CACHED_TERM.lock() { + *cache = Some(term.clone()); + } + return Some(term); + } + } + + // Collect all TERM values from shell processes, looking for TERM_XTERM_256COLOR + let terms = get_all_term_values(uid); + + // Prefer TERM_XTERM_256COLOR + if terms.iter().any(|t| t == TERM_XTERM_256COLOR) { + if let Ok(mut cache) = CACHED_TERM.lock() { + *cache = Some(TERM_XTERM_256COLOR.to_string()); + } + return Some(TERM_XTERM_256COLOR.to_string()); + } + + // Return first valid TERM if no TERM_XTERM_256COLOR found + let fallback = terms.into_iter().next(); + if let Some(ref term) = fallback { + log::debug!( + "TERM_XTERM_256COLOR not found, using fallback TERM: {}", + term + ); + } + fallback +} + +/// Get all TERM values from shell processes (bash, zsh, fish, sh). +/// Returns a Vec of unique, valid TERM values. +fn get_all_term_values(uid: &str) -> Vec { + let Ok(uid_num) = uid.parse::() else { + return Vec::new(); + }; + + // Build regex pattern to match shell processes using only argv[0] (the executable path) + // Pattern: match process name at start or after '/', followed by space or end + // e.g., "bash", "/bin/bash", "/usr/bin/zsh" + let shell_pattern = SHELL_PROCESSES + .iter() + .map(|p| format!(r"(^|/){p}(\s|$)")) + .collect::>() + .join("|"); + let Ok(re) = Regex::new(&shell_pattern) else { + return Vec::new(); + }; + + let Ok(entries) = std::fs::read_dir("/proc") else { + return Vec::new(); + }; + + let mut terms = Vec::new(); + + 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(); + + // Check if process belongs to the specified uid + if let Ok(meta) = std::fs::metadata(&proc_path) { + use std::os::unix::fs::MetadataExt; + if meta.uid() != uid_num { + continue; + } + } else { + continue; + } + + // Check cmdline matches process pattern + // /proc//cmdline is a sequence of null-terminated strings; the first + // one (argv[0]) is the executable path. Match the regex only against that + // to avoid false positives from arguments (e.g., "python /path/to/bash-script.py"). + let cmdline_path = proc_path.join("cmdline"); + let Ok(cmdline) = std::fs::read(&cmdline_path) else { + continue; + }; + let exe_end = cmdline + .iter() + .position(|&b| b == 0) + .unwrap_or(cmdline.len()); + let exe_str = String::from_utf8_lossy(&cmdline[..exe_end]); + if !re.is_match(&exe_str) { + continue; + } + + // Read environ and extract TERM + let environ_path = proc_path.join("environ"); + let Ok(environ) = std::fs::read(&environ_path) else { + continue; + }; + + for part in environ.split(|&b| b == 0) { + if part.is_empty() { + continue; + } + if let Some(eq) = part.iter().position(|&b| b == b'=') { + let key_bytes = &part[..eq]; + if key_bytes == b"TERM" { + let val_bytes = &part[eq + 1..]; + let term = String::from_utf8_lossy(val_bytes).into_owned(); + if !INVALID_TERM_VALUES.contains(&term.as_str()) && !terms.contains(&term) { + // Early return if we found the preferred term + if term == TERM_XTERM_256COLOR { + return vec![term]; + } + terms.push(term); + } + break; + } + } + } + } + + terms +} + #[inline] fn try_start_server_(desktop: Option<&Desktop>) -> ResultType> { match desktop { @@ -272,6 +643,13 @@ fn try_start_server_(desktop: Option<&Desktop>) -> ResultType> { if !desktop.home.is_empty() { envs.push(("HOME", desktop.home.clone())); } + if !desktop.dbus.is_empty() { + envs.push(("DBUS_SESSION_BUS_ADDRESS", desktop.dbus.clone())); + } + envs.push(( + "TERM", + get_cur_term(&desktop.uid).unwrap_or_else(|| suggest_best_term()), + )); run_as_user( vec!["--server"], Some((desktop.uid.clone(), desktop.username.clone())), @@ -320,7 +698,7 @@ fn set_x11_env(desktop: &Desktop) { #[inline] fn stop_rustdesk_servers() { let _ = run_cmds(&format!( - r##"ps -ef | grep -E '{} +--server' | awk '{{printf("kill -9 %d\n", $2)}}' | bash"##, + r##"ps -ef | grep -E '{} +--server' | awk '{{print $2}}' | xargs -r kill -9"##, crate::get_app_name().to_lowercase(), )); } @@ -328,11 +706,11 @@ fn stop_rustdesk_servers() { #[inline] fn stop_subprocess() { let _ = run_cmds(&format!( - r##"ps -ef | grep '/etc/{}/xorg.conf' | grep -v grep | awk '{{printf("kill -9 %d\n", $2)}}' | bash"##, + 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 '{{printf("kill -9 %d\n", $2)}}' | bash"##, + r##"ps -ef | grep -E '{} +--cm-no-ui' | grep -v grep | awk '{{print $2}}' | xargs -r kill -9"##, crate::get_app_name().to_lowercase(), )); } @@ -369,6 +747,12 @@ fn should_start_server( && ((*cm0 && last_restart.elapsed().as_secs() > 60) || last_restart.elapsed().as_secs() > 3600) { + let terminal_session_count = crate::ipc::get_terminal_session_count().unwrap_or(0); + if terminal_session_count > 0 { + // There are terminal sessions, so we don't restart the server. + // We also need to keep `cm0` unchanged, so that we can reach this branch the next time. + return false; + } // restart server if new connections all closed, or every one hour, // as a workaround to resolve "SpotUdp" (dns resolve) // and x server get displays failure issue @@ -434,6 +818,7 @@ pub fn start_os_service() { let mut last_restart = Instant::now(); while running.load(Ordering::SeqCst) { desktop.refresh(); + update_active_user_lookup_cache(&desktop); // Duplicate logic here with should_start_server // Login wayland will try to start a headless --server. @@ -506,18 +891,35 @@ pub fn start_os_service() { } #[inline] +/// Returns the cached active `(uid, username)` snapshot when available. +/// Callers that require a fresh seat0 lookup should call `get_values_of_seat0` directly. pub fn get_active_user_id_name() -> (String, String) { + if let Some(id_name) = get_active_user_id_name_from_cache() { + return id_name; + } let vec_id_name = get_values_of_seat0(&[1, 2]); (vec_id_name[0].clone(), vec_id_name[1].clone()) } #[inline] +/// Returns the cached active uid when available. +/// Callers that require a fresh seat0 lookup should call `get_values_of_seat0` directly. pub fn get_active_userid() -> String { + if let Some((uid, _)) = get_active_user_id_name_from_cache() { + return uid; + } + get_values_of_seat0(&[1])[0].clone() +} + +#[inline] +/// Returns the active uid from a fresh seat0 lookup, bypassing the service-loop cache. +pub fn get_active_userid_fresh() -> String { get_values_of_seat0(&[1])[0].clone() } fn get_cm() -> bool { - if let Ok(output) = Command::new("ps").args(vec!["aux"]).output() { + // 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", @@ -566,7 +968,12 @@ fn _get_display_manager() -> String { } #[inline] +/// Returns the cached active username when available. +/// Callers that require a fresh seat0 lookup should call `get_values_of_seat0` directly. pub fn get_active_username() -> String { + if let Some((_, username)) = get_active_user_id_name_from_cache() { + return username; + } get_values_of_seat0(&[2])[0].clone() } @@ -619,8 +1026,36 @@ pub fn is_prelogin() -> bool { if is_flatpak() { return false; } - let n = get_active_userid().len(); - n < 4 && n > 1 + let name = get_active_username(); + if let Ok(res) = run_cmds(&format!("getent passwd {}", name)) { + return res.contains("/bin/false") || res.contains("/usr/sbin/nologin"); + } + false +} + +// Check "Lock". +// "Switch user" can't be checked, because `get_values_of_seat0(&[0])` does not return the session. +// The logged in session is "online" not "active". +// And the "Switch user" screen is usually Wayland login session, which we do not support. +pub fn is_locked() -> bool { + if is_prelogin() { + return false; + } + + let values = get_values_of_seat0(&[0]); + // Though the values can't be empty, we still add check here for safety. + // Because we cannot guarantee whether the internal implementation will change in the future. + // https://github.com/rustdesk/hbb_common/blob/ebb4d4a48cf7ed6ca62e93f8ed124065c6408536/src/platform/linux.rs#L119 + if values.is_empty() { + log::debug!("Failed to check is locked, values vector is empty."); + return false; + } + let session = &values[0]; + if session.is_empty() { + log::debug!("Failed to check is locked, session is empty."); + return false; + } + is_session_locked(session) } pub fn is_root() -> bool { @@ -654,14 +1089,58 @@ where if uid.is_empty() { bail!("No valid uid"); } - let xdg = &format!("XDG_RUNTIME_DIR=/run/user/{}", uid) as &str; - let mut args = vec![xdg, "-u", &username, cmd.to_str().unwrap_or("")]; - args.append(&mut arg.clone()); - // -E is required to preserve env - args.insert(0, "-E"); - let task = Command::new("sudo").envs(envs).args(args).spawn()?; - Ok(Some(task)) + let xdg = &format!("XDG_RUNTIME_DIR=/run/user/{uid}"); + if *SUDO_E_PRESERVES_ENV { + // Original logic: use sudo -E to preserve environment + let mut args = vec![xdg, "-u", &username, cmd.to_str().unwrap_or("")]; + args.append(&mut arg.clone()); + // -E is required to preserve env + args.insert(0, "-E"); + let task = Command::new("sudo").envs(envs).args(args).spawn()?; + Ok(Some(task)) + } else { + // Fallback: sudo -u username env VAR=VALUE ... cmd args + // For systems where sudo -E is not supported (e.g., Ubuntu 25.10+) + // + // SECURITY: No shell is involved here (we use execve-style argv). + // Environment is passed via `env` arguments, + // so there is no shell injection vector. + // + // Only accept portable env var names (POSIX portable character set for shells). + // Most legitimate env vars follow [A-Za-z_][A-Za-z0-9_]* convention. + // Variables with dots (e.g., "java.home") are Java system properties, not env vars. + // Being restrictive here is intentional for security in this sudo context. + fn is_valid_env_key(key: &str) -> bool { + let mut it = key.chars(); + match it.next() { + Some(c) if c.is_ascii_alphabetic() || c == '_' => {} + _ => return false, + } + it.all(|c| c.is_ascii_alphanumeric() || c == '_') + } + + let mut sudo = Command::new("sudo"); + sudo.arg("-u").arg(&username).arg("--").arg("env").arg(xdg); + + for (k, v) in envs { + let key = k.as_ref().to_string_lossy(); + if !is_valid_env_key(&key) { + log::warn!("Skipping environment variable with invalid key: '{}'. Only [A-Za-z_][A-Za-z0-9_]* are allowed in sudo context.", key); + continue; + } + // IMPORTANT: do NOT add shell quotes here; `Command` does not invoke a shell. + // Passing KEY=VALUE as a single argv element is safe and preserves spaces. + let mut arg = OsString::from(&*key); + arg.push("="); + arg.push(v.as_ref()); + sudo.arg(arg); + } + + sudo.arg(cmd).args(arg); + let task = sudo.spawn()?; + Ok(Some(task)) + } } pub fn get_pa_monitor() -> String { @@ -742,6 +1221,156 @@ pub fn is_installed() -> bool { } } +/// Get multiple environment variables from a process matching the given criteria. +/// This version reads /proc directly instead of spawning shell commands. +/// +/// # Arguments +/// * `uid` - User ID to filter processes +/// * `process_pat` - Regex pattern to match process cmdline +/// * `names` - Environment variable names to retrieve. **Must be <= 64 elements** due to +/// the internal bitmask used for tie-breaking. +/// +/// # Panics (debug builds) +/// Panics if `names.len() > 64`. +/// +/// # Implementation notes +/// - Returns values from a *single* best-matching process_pat (for consistency). +/// - Avoids repeated scanning by parsing `environ` once per process. +fn get_envs<'a>( + uid: &str, + process_pat: &str, + names: &[&'a str], +) -> std::collections::HashMap<&'a str, String> { + // The tie-breaking logic uses a u64 bitmask, limiting us to 64 variables. + debug_assert!( + names.len() <= 64, + "get_envs: names.len() must be <= 64, got {}", + names.len() + ); + + let empty: std::collections::HashMap<&'a str, String> = + names.iter().map(|&n| (n, String::new())).collect(); + + let Ok(uid_num) = uid.parse::() else { + return empty; + }; + let Ok(re) = Regex::new(process_pat) else { + return empty; + }; + + // Used for stable tie-breaking when multiple processes match. + // Higher bits correspond to earlier entries in `names`. + let name_indices: std::collections::HashMap<&'a str, usize> = + names.iter().enumerate().map(|(i, &n)| (n, i)).collect(); + + let mut best = empty.clone(); + let mut best_count = 0usize; + let mut best_mask: u64 = 0; + + // Iterate /proc to find matching processes + let Ok(entries) = std::fs::read_dir("/proc") else { + return best; + }; + + 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(); + + // Check if process belongs to the specified uid + if let Ok(meta) = std::fs::metadata(&proc_path) { + use std::os::unix::fs::MetadataExt; + if meta.uid() != uid_num { + continue; + } + } else { + continue; + } + + // Check cmdline matches process pattern + let cmdline_path = proc_path.join("cmdline"); + let Ok(cmdline) = std::fs::read(&cmdline_path) else { + continue; + }; + let cmdline_str = String::from_utf8_lossy(&cmdline).replace('\0', " "); + if !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; + }; + + let mut found = empty.clone(); + let mut found_count = 0usize; + let mut found_mask: u64 = 0; + + for part in environ.split(|&b| b == 0) { + if part.is_empty() { + continue; + } + let Some(eq) = part.iter().position(|&b| b == b'=') else { + continue; + }; + let key_bytes = &part[..eq]; + let val_bytes = &part[eq + 1..]; + + let Ok(key) = std::str::from_utf8(key_bytes) else { + continue; + }; + if let Some(slot) = found.get_mut(key) { + if slot.is_empty() { + *slot = String::from_utf8_lossy(val_bytes).into_owned(); + found_count += 1; + + if let Some(&idx) = name_indices.get(key) { + let total = names.len(); + if total <= 64 { + let bit = 1u64 << (total - 1 - idx); + found_mask |= bit; + } + } + + if found_count == names.len() { + return found; + } + } + } + } + + if found_count > best_count || (found_count == best_count && found_mask > best_mask) { + best = found; + best_count = found_count; + best_mask = found_mask; + } + } + + 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); @@ -981,20 +1610,28 @@ mod desktop { pub const XFCE4_PANEL: &str = "xfce4-panel"; pub const SDDM_GREETER: &str = "sddm-greeter"; + // xdg-desktop-portal runs on all Wayland desktops (GNOME, KDE, wlroots, etc.) + const XDG_DESKTOP_PORTAL: &str = "xdg-desktop-portal"; const XWAYLAND: &str = "Xwayland"; const IBUS_DAEMON: &str = "ibus-daemon"; const PLASMA_KDED: &str = "kded[0-9]+"; const GNOME_GOA_DAEMON: &str = "goa-daemon"; + const ENV_KEY_DISPLAY: &str = "DISPLAY"; + const ENV_KEY_XAUTHORITY: &str = "XAUTHORITY"; + const ENV_KEY_WAYLAND_DISPLAY: &str = "WAYLAND_DISPLAY"; + const ENV_KEY_DBUS_SESSION_BUS_ADDRESS: &str = "DBUS_SESSION_BUS_ADDRESS"; + #[derive(Debug, Clone, Default)] pub struct Desktop { pub sid: String, pub username: String, pub uid: String, - pub protocal: String, + pub protocol: String, pub display: String, pub xauth: String, pub home: String, + pub dbus: String, pub is_rustdesk_subprocess: bool, pub wl_display: String, } @@ -1002,12 +1639,12 @@ mod desktop { impl Desktop { #[inline] pub fn is_wayland(&self) -> bool { - self.protocal == DISPLAY_SERVER_WAYLAND + self.protocol == DISPLAY_SERVER_WAYLAND } #[inline] pub fn is_login_wayland(&self) -> bool { - super::is_gdm_user(&self.username) && self.protocal == DISPLAY_SERVER_WAYLAND + super::is_gdm_user(&self.username) && self.protocol == DISPLAY_SERVER_WAYLAND } #[inline] @@ -1015,10 +1652,42 @@ mod desktop { 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. + let mut envs = get_envs( + &self.uid, + XDG_DESKTOP_PORTAL, + &[ + ENV_KEY_WAYLAND_DISPLAY, + ENV_KEY_DBUS_SESSION_BUS_ADDRESS, + ENV_KEY_DISPLAY, + ENV_KEY_XAUTHORITY, + ], + ); + self.display = envs.remove(ENV_KEY_DISPLAY).unwrap_or_default(); + self.xauth = envs.remove(ENV_KEY_XAUTHORITY).unwrap_or_default(); + self.wl_display = envs.remove(ENV_KEY_WAYLAND_DISPLAY).unwrap_or_default(); + self.dbus = envs + .remove(ENV_KEY_DBUS_SESSION_BUS_ADDRESS) + .unwrap_or_default(); + // For pure Wayland sessions, prefer `WAYLAND_DISPLAY`. + // NOTE: On some systems (e.g. Ubuntu 25.10), `DISPLAY`/`XAUTHORITY` may exist even when XWayland + // is not running, so do NOT treat them as a success condition here. + let has_wayland = !self.wl_display.is_empty(); + let has_dbus = !self.dbus.is_empty(); + if has_wayland && has_dbus { + return; + } + sleep_millis(300); + } + } + fn get_display_xauth_xwayland(&mut self) { let tray = format!("{} +--tray", crate::get_app_name().to_lowercase()); - for _ in 0..5 { + for _ in 1..=10 { let display_proc = vec![ + XDG_DESKTOP_PORTAL, XWAYLAND, IBUS_DAEMON, GNOME_GOA_DAEMON, @@ -1026,11 +1695,12 @@ mod desktop { tray.as_str(), ]; for proc in display_proc { - self.display = get_env("DISPLAY", &self.uid, proc); - self.xauth = get_env("XAUTHORITY", &self.uid, proc); - self.wl_display = get_env("WAYLAND_DISPLAY", &self.uid, 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() { - break; + return; } } sleep_millis(300); @@ -1038,7 +1708,7 @@ mod desktop { } fn get_display_x11(&mut self) { - for _ in 0..10 { + for _ in 1..=10 { let display_proc = vec![ XWAYLAND, IBUS_DAEMON, @@ -1048,11 +1718,14 @@ mod desktop { SDDM_GREETER, ]; for proc in display_proc { - self.display = get_env("DISPLAY", &self.uid, proc); + self.display = get_env(ENV_KEY_DISPLAY, &self.uid, proc); if !self.display.is_empty() { break; } } + if !self.display.is_empty() { + break; + } sleep_millis(300); } @@ -1064,7 +1737,7 @@ mod desktop { } self.display = self .display - .replace(&whoami::hostname(), "") + .replace(&hbb_common::whoami::hostname(), "") .replace("localhost", ""); } @@ -1122,7 +1795,7 @@ mod desktop { fn get_xauth_x11(&mut self) { // try by direct access to window manager process by name let tray = format!("{} +--tray", crate::get_app_name().to_lowercase()); - for _ in 0..10 { + for _ in 1..=10 { let display_proc = vec![ XWAYLAND, IBUS_DAEMON, @@ -1138,6 +1811,9 @@ mod desktop { break; } } + if !self.xauth.is_empty() { + break; + } sleep_millis(300); } @@ -1232,6 +1908,8 @@ mod desktop { if is_xwayland_running() && !self.is_login_wayland() { self.get_display_xauth_xwayland(); self.is_rustdesk_subprocess = false; + } else if self.is_wayland() { + self.get_display_xauth_wayland(); } return; } @@ -1246,7 +1924,7 @@ mod desktop { self.sid = seat0_values[0].clone(); self.uid = seat0_values[1].clone(); self.username = seat0_values[2].clone(); - self.protocal = get_display_server_of_session(&self.sid).into(); + self.protocol = get_display_server_of_session(&self.sid).into(); if self.is_login_wayland() { self.display = "".to_owned(); self.xauth = "".to_owned(); @@ -1259,8 +1937,7 @@ mod desktop { if is_xwayland_running() { self.get_display_xauth_xwayland(); } else { - self.display = "".to_owned(); - self.xauth = "".to_owned(); + self.get_display_xauth_wayland(); } self.is_rustdesk_subprocess = false; } else { @@ -1322,25 +1999,57 @@ pub fn run_cmds_privileged(cmds: &str) -> bool { crate::platform::gtk_sudo::run(vec![cmds]).is_ok() } +/// Spawn the current executable after a delay. +/// +/// # Security +/// The executable path is safely quoted using `shell_quote()` to prevent +/// command injection vulnerabilities. The `secs` parameter is a u32, so it +/// cannot contain malicious input. +/// +/// # Arguments +/// * `secs` - Number of seconds to wait before spawning pub fn run_me_with(secs: u32) { - let exe = std::env::current_exe() - .unwrap_or("".into()) - .to_string_lossy() - .to_string(); - std::process::Command::new("sh") + let exe = match std::env::current_exe() { + Ok(path) => path, + Err(e) => { + log::error!("Failed to get current exe: {}", e); + return; + } + }; + + // SECURITY: Use shell_quote to safely escape the executable path, + // preventing command injection even if the path contains special characters. + let exe_quoted = shell_quote(&exe.to_string_lossy()); + + // Spawn a background process that sleeps and then executes. + // The child process is automatically orphaned when parent exits, + // and will be adopted by init (PID 1). + Command::new(CMD_SH.as_str()) .arg("-c") - .arg(&format!("sleep {secs}; {exe}")) + .arg(&format!("sleep {secs}; exec {exe_quoted}")) .spawn() .ok(); } fn switch_service(stop: bool) -> String { - let home = std::env::var("HOME").unwrap_or_default(); + // SECURITY: Use trusted home directory lookup via getpwuid instead of $HOME env var + // to prevent confused-deputy attacks where an attacker manipulates environment variables. + let home = get_home_dir_trusted() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); Config::set_option("stop-service".into(), if stop { "Y" } else { "" }.into()); - if home != "/root" && !Config::get().is_empty() { - let p = format!(".config/{}", crate::get_app_name().to_lowercase()); + if !home.is_empty() && home != "/root" && !Config::get().is_empty() { + let app_name_lower = crate::get_app_name().to_lowercase(); let app_name0 = crate::get_app_name(); - format!("cp -f {home}/{p}/{app_name0}.toml /root/{p}/; cp -f {home}/{p}/{app_name0}2.toml /root/{p}/;") + let config_subdir = format!(".config/{}", app_name_lower); + + // SECURITY: Quote all paths to prevent shell injection from paths containing + // spaces, semicolons, or other special characters. + let src1 = shell_quote(&format!("{}/{}/{}.toml", home, config_subdir, app_name0)); + let src2 = shell_quote(&format!("{}/{}/{}2.toml", home, config_subdir, app_name0)); + let dst = shell_quote(&format!("/root/{}/", config_subdir)); + + format!("cp -f {} {}; cp -f {} {};", src1, dst, src2, dst) } else { "".to_owned() } @@ -1394,7 +2103,15 @@ fn check_if_stop_service() { } pub fn check_autostart_config() -> ResultType<()> { - let home = std::env::var("HOME").unwrap_or_default(); + // SECURITY: Use trusted home directory lookup via getpwuid instead of $HOME env var + // to prevent confused-deputy attacks where an attacker manipulates environment variables. + let home = match get_home_dir_trusted() { + Some(p) => p.to_string_lossy().to_string(), + None => { + log::warn!("Failed to get trusted home directory for autostart config check"); + return Ok(()); + } + }; let app_name = crate::get_app_name().to_lowercase(); let path = format!("{home}/.config/autostart"); let file = format!("{path}/{app_name}.desktop"); @@ -1489,3 +2206,125 @@ pub fn is_selinux_enforcing() -> bool { }, } } + +/// Get the app ID for shortcuts inhibitor permission. +/// Returns different ID based on whether running in Flatpak or native. +/// The ID must match the installed .desktop filename, as GNOME Shell's +/// inhibitShortcutsDialog uses `Shell.WindowTracker.get_window_app(window).get_id()`. +fn get_shortcuts_inhibitor_app_id() -> String { + if is_flatpak() { + // In Flatpak, FLATPAK_ID is set automatically by the runtime to the app ID + // (e.g., "com.rustdesk.RustDesk"). This is the most reliable source. + // Fall back to constructing from app name if not available. + match std::env::var("FLATPAK_ID") { + Ok(id) if !id.is_empty() => format!("{}.desktop", id), + _ => { + let app_name = crate::get_app_name(); + format!("com.{}.{}.desktop", app_name.to_lowercase(), app_name) + } + } + } else { + format!("{}.desktop", crate::get_app_name().to_lowercase()) + } +} + +const PERMISSION_STORE_DEST: &str = "org.freedesktop.impl.portal.PermissionStore"; +const PERMISSION_STORE_PATH: &str = "/org/freedesktop/impl/portal/PermissionStore"; +const PERMISSION_STORE_IFACE: &str = "org.freedesktop.impl.portal.PermissionStore"; + +/// Clear GNOME shortcuts inhibitor permission via D-Bus. +/// This allows the permission dialog to be shown again. +pub fn clear_gnome_shortcuts_inhibitor_permission() -> ResultType<()> { + let app_id = get_shortcuts_inhibitor_app_id(); + log::info!( + "Clearing shortcuts inhibitor permission for app_id: {}, is_flatpak: {}", + app_id, + is_flatpak() + ); + + let conn = dbus::blocking::Connection::new_session()?; + let proxy = conn.with_proxy( + PERMISSION_STORE_DEST, + PERMISSION_STORE_PATH, + std::time::Duration::from_secs(3), + ); + + // DeletePermission(s table, s id, s app) -> () + let result: Result<(), dbus::Error> = proxy.method_call( + PERMISSION_STORE_IFACE, + "DeletePermission", + ("gnome", "shortcuts-inhibitor", app_id.as_str()), + ); + + match result { + Ok(()) => { + log::info!("Successfully cleared GNOME shortcuts inhibitor permission"); + Ok(()) + } + Err(e) => { + let err_name = e.name().unwrap_or(""); + // If the permission doesn't exist, that's also fine + if err_name == "org.freedesktop.portal.Error.NotFound" + || err_name == "org.freedesktop.DBus.Error.UnknownObject" + || err_name == "org.freedesktop.DBus.Error.ServiceUnknown" + { + log::info!( + "GNOME shortcuts inhibitor permission was not set ({})", + err_name + ); + Ok(()) + } else { + bail!("Failed to clear permission: {}", e) + } + } + } +} + +/// Check if GNOME shortcuts inhibitor permission exists. +pub fn has_gnome_shortcuts_inhibitor_permission() -> bool { + let app_id = get_shortcuts_inhibitor_app_id(); + + let conn = match dbus::blocking::Connection::new_session() { + Ok(c) => c, + Err(e) => { + log::debug!("Failed to connect to session bus: {}", e); + return false; + } + }; + let proxy = conn.with_proxy( + PERMISSION_STORE_DEST, + PERMISSION_STORE_PATH, + std::time::Duration::from_secs(3), + ); + + // Lookup(s table, s id) -> (a{sas} permissions, v data) + // We only need the permissions dict; check if app_id is a key. + let result: Result< + ( + std::collections::HashMap>, + dbus::arg::Variant>, + ), + dbus::Error, + > = proxy.method_call( + PERMISSION_STORE_IFACE, + "Lookup", + ("gnome", "shortcuts-inhibitor"), + ); + + match result { + Ok((permissions, _)) => { + let found = permissions.contains_key(&app_id); + log::debug!( + "Shortcuts inhibitor permission lookup: app_id={}, found={}, keys={:?}", + app_id, + found, + permissions.keys().collect::>() + ); + found + } + Err(e) => { + log::debug!("Failed to query shortcuts inhibitor permission: {}", e); + false + } + } +} diff --git a/src/platform/linux_desktop_manager.rs b/src/platform/linux_desktop_manager.rs index 0acb089f1..0a512939b 100644 --- a/src/platform/linux_desktop_manager.rs +++ b/src/platform/linux_desktop_manager.rs @@ -2,9 +2,14 @@ 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_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 hbb_common::{allow_err, bail, log, rand::prelude::*, tokio::time}; use pam; use std::{ collections::HashMap, @@ -18,7 +23,6 @@ use std::{ }, time::{Duration, Instant}, }; -use users::{get_user_by_name, os::unix::UserExt, User}; lazy_static::lazy_static! { static ref DESKTOP_RUNNING: Arc = Arc::new(AtomicBool::new(false)); @@ -90,6 +94,49 @@ fn detect_headless() -> Option<&'static str> { 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() { @@ -132,14 +179,21 @@ pub fn try_start_desktop(_username: &str, _passsword: &str) -> String { } } Err(e) => { - log::error!("Failed to start xsession {}", e); - LOGIN_MSG_DESKTOP_XSESSION_FAILED.to_owned() + 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) -> ResultType<(String, bool)> { +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() { @@ -157,7 +211,9 @@ fn try_start_x_session(username: &str, password: &str) -> ResultType<(String, bo desktop_manager.is_running(), )) } else { - bail!(crate::client::LOGIN_MSG_DESKTOP_NOT_INITED); + Err(XSessionStartError::env( + crate::client::LOGIN_MSG_DESKTOP_NOT_INITED.to_owned(), + )) } } @@ -243,10 +299,15 @@ impl DesktopManager { self.is_child_running.load(Ordering::SeqCst) } - fn try_start_x_session(&mut self, username: &str, password: &str) -> ResultType<()> { + 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())?; + 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); @@ -263,17 +324,24 @@ impl DesktopManager { Ok(()) } Err(e) => { - bail!("failed to start x session, {}", e); + Err(XSessionStartError::env(format!( + "failed to start x session, {}", + e + ))) } } } - Err(e) => { - bail!("failed to check user pass for {}, {}", username, e); + Err(_e) => { + Err(XSessionStartError::auth( + XSESSION_AUTH_FAILURE_DETAIL.to_owned(), + )) } } } None => { - bail!("failed to get userinfo of {}", username); + Err(XSessionStartError::auth( + XSESSION_AUTH_FAILURE_DETAIL.to_owned(), + )) } } } @@ -321,7 +389,7 @@ impl DesktopManager { ), // ("DISPLAY", self.display.clone()), // ("XAUTHORITY", self.xauth.clone()), - // (ENV_DESKTOP_PROTOCAL, XProtocal::X11.to_string()), + // (ENV_DESKTOP_PROTOCOL, XProtocol::X11.to_string()), ]); self.child_exit.store(false, Ordering::SeqCst); let is_child_running = self.is_child_running.clone(); diff --git a/src/platform/macos.mm b/src/platform/macos.mm index 0f963b97f..3303855a6 100644 --- a/src/platform/macos.mm +++ b/src/platform/macos.mm @@ -4,6 +4,13 @@ #include #include +#include +#include +#include +#include +#include +#include + extern "C" bool CanUseNewApiForScreenCaptureCheck() { #ifdef NO_InputMonitoringAuthStatus return false; @@ -153,8 +160,28 @@ size_t bitDepth(CGDisplayModeRef mode) { return depth; } +static bool isHiDPIMode(CGDisplayModeRef mode) { + // Check if the mode is HiDPI by comparing pixel width to width + // If pixel width is greater than width, it's a HiDPI mode + return CGDisplayModeGetPixelWidth(mode) > CGDisplayModeGetWidth(mode); +} + +CFArrayRef getAllModes(CGDirectDisplayID display) { + // Create options dictionary to include HiDPI modes + CFMutableDictionaryRef options = CFDictionaryCreateMutable( + kCFAllocatorDefault, + 0, + &kCFTypeDictionaryKeyCallBacks, + &kCFTypeDictionaryValueCallBacks); + // Include HiDPI modes + CFDictionarySetValue(options, kCGDisplayShowDuplicateLowResolutionModes, kCFBooleanTrue); + CFArrayRef allModes = CGDisplayCopyAllDisplayModes(display, options); + CFRelease(options); + return allModes; +} + extern "C" bool MacGetModeNum(CGDirectDisplayID display, uint32_t *numModes) { - CFArrayRef allModes = CGDisplayCopyAllDisplayModes(display, NULL); + CFArrayRef allModes = getAllModes(display); if (allModes == NULL) { return false; } @@ -163,12 +190,12 @@ extern "C" bool MacGetModeNum(CGDirectDisplayID display, uint32_t *numModes) { return true; } -extern "C" bool MacGetModes(CGDirectDisplayID display, uint32_t *widths, uint32_t *heights, uint32_t max, uint32_t *numModes) { +extern "C" bool MacGetModes(CGDirectDisplayID display, uint32_t *widths, uint32_t *heights, bool *hidpis, uint32_t max, uint32_t *numModes) { CGDisplayModeRef currentMode = CGDisplayCopyDisplayMode(display); if (currentMode == NULL) { return false; } - CFArrayRef allModes = CGDisplayCopyAllDisplayModes(display, NULL); + CFArrayRef allModes = getAllModes(display); if (allModes == NULL) { CGDisplayModeRelease(currentMode); return false; @@ -181,6 +208,7 @@ extern "C" bool MacGetModes(CGDirectDisplayID display, uint32_t *widths, uint32_ bitDepth(currentMode) == bitDepth(mode)) { widths[realNum] = (uint32_t)CGDisplayModeGetWidth(mode); heights[realNum] = (uint32_t)CGDisplayModeGetHeight(mode); + hidpis[realNum] = isHiDPIMode(mode); realNum++; } } @@ -201,7 +229,6 @@ extern "C" bool MacGetMode(CGDirectDisplayID display, uint32_t *width, uint32_t return true; } - static bool setDisplayToMode(CGDirectDisplayID display, CGDisplayModeRef mode) { CGError rc; CGDisplayConfigRef config; @@ -220,30 +247,663 @@ static bool setDisplayToMode(CGDirectDisplayID display, CGDisplayModeRef mode) { return true; } -extern "C" bool MacSetMode(CGDirectDisplayID display, uint32_t width, uint32_t height) +// Set the display to a specific mode based on width and height. +// Returns true if the display mode was successfully changed, false otherwise. +// If no such mode is available, it will not change the display mode. +// +// If `tryHiDPI` is true, it will try to set the display to a HiDPI mode if available. +// If no HiDPI mode is available, it will fall back to a non-HiDPI mode with the same resolution. +// If `tryHiDPI` is false, it sets the display to the first mode with the same resolution, no matter if it's HiDPI or not. +extern "C" bool MacSetMode(CGDirectDisplayID display, uint32_t width, uint32_t height, bool tryHiDPI) { bool ret = false; CGDisplayModeRef currentMode = CGDisplayCopyDisplayMode(display); if (currentMode == NULL) { return ret; } - CFArrayRef allModes = CGDisplayCopyAllDisplayModes(display, NULL); + CFArrayRef allModes = getAllModes(display); + if (allModes == NULL) { CGDisplayModeRelease(currentMode); return ret; } int numModes = CFArrayGetCount(allModes); + CGDisplayModeRef preferredHiDPIMode = NULL; + CGDisplayModeRef fallbackMode = NULL; for (int i = 0; i < numModes; i++) { CGDisplayModeRef mode = (CGDisplayModeRef)CFArrayGetValueAtIndex(allModes, i); if (width == CGDisplayModeGetWidth(mode) && height == CGDisplayModeGetHeight(mode) && CGDisplayModeGetRefreshRate(currentMode) == CGDisplayModeGetRefreshRate(mode) && bitDepth(currentMode) == bitDepth(mode)) { - ret = setDisplayToMode(display, mode); - break; + + if (isHiDPIMode(mode)) { + preferredHiDPIMode = mode; + break; + } else { + fallbackMode = mode; + if (!tryHiDPI) { + break; + } + } } } + + if (preferredHiDPIMode) { + ret = setDisplayToMode(display, preferredHiDPIMode); + } else if (fallbackMode) { + ret = setDisplayToMode(display, fallbackMode); + } + CGDisplayModeRelease(currentMode); CFRelease(allModes); return ret; -} \ No newline at end of file +} + +static CFMachPortRef g_eventTap = NULL; +static CFRunLoopSourceRef g_runLoopSource = NULL; +static std::mutex g_privacyModeMutex; +static bool g_privacyModeActive = false; + +// Flag to request asynchronous shutdown of privacy mode. +// This is set by DisplayReconfigurationCallback when an error occurs, instead of calling +// TurnOffPrivacyModeInternal() directly from within the callback. This avoids potential +// issues with unregistering a callback from within itself, which is not explicitly +// guaranteed to be safe by Apple documentation. +static bool g_privacyModeShutdownRequested = false; + +// Timestamp of the last display reconfiguration event (in milliseconds). +// Used for debouncing rapid successive changes (e.g., multiple resolution changes). +static uint64_t g_lastReconfigTimestamp = 0; + +// Flag indicating whether a delayed blackout reapplication is already scheduled. +// Prevents multiple concurrent delayed tasks from being created. +static bool g_blackoutReapplicationScheduled = false; + +// Use CFStringRef (UUID) as key instead of CGDirectDisplayID for stability across reconnections +// CGDirectDisplayID can change when displays are reconnected, but UUID remains stable +static std::map> g_originalGammas; + +// The event source user data value used by enigo library for injected events. +// This allows us to distinguish remote input (which should be allowed) from local physical input. +// See: libs/enigo/src/macos/macos_impl.rs - ENIGO_INPUT_EXTRA_VALUE +static const int64_t ENIGO_INPUT_EXTRA_VALUE = 100; + +// Duration in milliseconds to monitor and enforce blackout after display reconfiguration. +// macOS may restore default gamma (via ColorSync) at unpredictable times after display changes, +// so we need to actively monitor and reapply blackout during this period. +static const int64_t DISPLAY_RECONFIG_MONITOR_DURATION_MS = 5000; + +// Interval in milliseconds between gamma checks during the monitoring period. +static const int64_t GAMMA_CHECK_INTERVAL_MS = 200; + +// Helper function to get UUID string from DisplayID +static std::string GetDisplayUUID(CGDirectDisplayID displayId) { + CFUUIDRef uuid = CGDisplayCreateUUIDFromDisplayID(displayId); + if (uuid == NULL) { + return ""; + } + CFStringRef uuidStr = CFUUIDCreateString(kCFAllocatorDefault, uuid); + CFRelease(uuid); + if (uuidStr == NULL) { + return ""; + } + char buffer[128]; + if (CFStringGetCString(uuidStr, buffer, sizeof(buffer), kCFStringEncodingUTF8)) { + CFRelease(uuidStr); + return std::string(buffer); + } + CFRelease(uuidStr); + return ""; +} + +// Helper function to find DisplayID by UUID from current online displays +static CGDirectDisplayID FindDisplayIdByUUID(const std::string& targetUuid) { + uint32_t count = 0; + CGGetOnlineDisplayList(0, NULL, &count); + if (count == 0) return kCGNullDirectDisplay; + + std::vector displays(count); + CGGetOnlineDisplayList(count, displays.data(), &count); + + for (uint32_t i = 0; i < count; i++) { + std::string uuid = GetDisplayUUID(displays[i]); + if (uuid == targetUuid) { + return displays[i]; + } + } + return kCGNullDirectDisplay; +} + +// Helper function to restore gamma values for all displays in g_originalGammas. +// Returns true if all displays were restored successfully, false if any failed. +// Note: This function does NOT clear g_originalGammas - caller should do that if needed. +static bool RestoreAllGammas() { + bool allSuccess = true; + for (auto const& [uuid, gamma] : g_originalGammas) { + CGDirectDisplayID d = FindDisplayIdByUUID(uuid); + if (d == kCGNullDirectDisplay) { + NSLog(@"Display with UUID %s no longer online, skipping gamma restore", uuid.c_str()); + continue; + } + + uint32_t sampleCount = gamma.size() / 3; + if (sampleCount > 0) { + const CGGammaValue* red = gamma.data(); + const CGGammaValue* green = red + sampleCount; + const CGGammaValue* blue = green + sampleCount; + CGError error = CGSetDisplayTransferByTable(d, sampleCount, red, green, blue); + if (error != kCGErrorSuccess) { + NSLog(@"Failed to restore gamma for display (ID: %u, UUID: %s, error: %d)", (unsigned)d, uuid.c_str(), error); + allSuccess = false; + } + } + } + return allSuccess; +} + +// Helper function to apply blackout to a single display +static bool ApplyBlackoutToDisplay(CGDirectDisplayID display) { + uint32_t capacity = CGDisplayGammaTableCapacity(display); + if (capacity > 0) { + std::vector zeros(capacity, 0.0f); + CGError error = CGSetDisplayTransferByTable(display, capacity, zeros.data(), zeros.data(), zeros.data()); + if (error != kCGErrorSuccess) { + NSLog(@"ApplyBlackoutToDisplay: Failed to set gamma for display %u (error %d)", (unsigned)display, error); + return false; + } + return true; + } + NSLog(@"ApplyBlackoutToDisplay: Display %u has zero gamma table capacity, blackout not supported", (unsigned)display); + return false; +} + +// Forward declaration - defined later in the file +// Must be called while holding g_privacyModeMutex +static bool TurnOffPrivacyModeInternal(); + +// Helper function to schedule asynchronous shutdown of privacy mode. +// This is called from DisplayReconfigurationCallback when an error occurs, +// instead of calling TurnOffPrivacyModeInternal() directly. This avoids +// potential issues with unregistering a callback from within itself. +// Note: This function should be called while holding g_privacyModeMutex. +static void ScheduleAsyncPrivacyModeShutdown(const char* reason) { + if (g_privacyModeShutdownRequested) { + // Already requested, no need to schedule again + return; + } + g_privacyModeShutdownRequested = true; + NSLog(@"Privacy mode shutdown requested: %s", reason); + + // Schedule the actual shutdown on the main queue asynchronously + // This ensures we're outside the callback when we unregister it + dispatch_async(dispatch_get_main_queue(), ^{ + std::lock_guard lock(g_privacyModeMutex); + if (g_privacyModeShutdownRequested && g_privacyModeActive) { + NSLog(@"Executing deferred privacy mode shutdown"); + TurnOffPrivacyModeInternal(); + } + g_privacyModeShutdownRequested = false; + }); +} + +// Helper function to apply blackout to all online displays. +// Must be called while holding g_privacyModeMutex. +static void ApplyBlackoutToAllDisplays() { + uint32_t onlineCount = 0; + CGGetOnlineDisplayList(0, NULL, &onlineCount); + std::vector onlineDisplays(onlineCount); + CGGetOnlineDisplayList(onlineCount, onlineDisplays.data(), &onlineCount); + + for (uint32_t i = 0; i < onlineCount; i++) { + ApplyBlackoutToDisplay(onlineDisplays[i]); + } +} + +// Helper function to get current timestamp in milliseconds +static uint64_t GetCurrentTimestampMs() { + return (uint64_t)(CFAbsoluteTimeGetCurrent() * 1000.0); +} + +// Helper function to check if a display's gamma is currently blacked out (all zeros). +// Returns true if gamma appears to be blacked out, false otherwise. +static bool IsDisplayBlackedOut(CGDirectDisplayID display) { + uint32_t capacity = CGDisplayGammaTableCapacity(display); + if (capacity == 0) { + return true; // Can't check, assume it's fine + } + + std::vector red(capacity), green(capacity), blue(capacity); + uint32_t sampleCount = 0; + if (CGGetDisplayTransferByTable(display, capacity, red.data(), green.data(), blue.data(), &sampleCount) != kCGErrorSuccess) { + return true; // Can't read, assume it's fine + } + + // Check if all values are zero (or very close to zero) + for (uint32_t i = 0; i < sampleCount; i++) { + if (red[i] > 0.01f || green[i] > 0.01f || blue[i] > 0.01f) { + return false; // Not blacked out + } + } + return true; +} + +// Internal function that monitors and enforces blackout for a period after display reconfiguration. +// This function checks gamma values periodically and reapplies blackout if needed. +// Must NOT be called while holding g_privacyModeMutex (it acquires the lock internally). +static void RunBlackoutMonitor() { + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(GAMMA_CHECK_INTERVAL_MS * NSEC_PER_MSEC)), dispatch_get_main_queue(), ^{ + std::lock_guard lock(g_privacyModeMutex); + + if (!g_privacyModeActive) { + g_blackoutReapplicationScheduled = false; + return; + } + + uint64_t now = GetCurrentTimestampMs(); + + // Calculate effective end time based on the last reconfig event + uint64_t effectiveEndTime = g_lastReconfigTimestamp + DISPLAY_RECONFIG_MONITOR_DURATION_MS; + + // Check all displays and reapply blackout if any has been restored + uint32_t onlineCount = 0; + CGGetOnlineDisplayList(0, NULL, &onlineCount); + std::vector onlineDisplays(onlineCount); + CGGetOnlineDisplayList(onlineCount, onlineDisplays.data(), &onlineCount); + + bool needsReapply = false; + for (uint32_t i = 0; i < onlineCount; i++) { + if (!IsDisplayBlackedOut(onlineDisplays[i])) { + needsReapply = true; + break; + } + } + + if (needsReapply) { + NSLog(@"Gamma was restored by system, reapplying blackout"); + ApplyBlackoutToAllDisplays(); + } + + // Continue monitoring if we haven't reached the end time + if (now < effectiveEndTime) { + RunBlackoutMonitor(); + } else { + NSLog(@"Blackout monitoring period ended"); + g_blackoutReapplicationScheduled = false; + } + }); +} + +// Helper function to start monitoring and enforcing blackout after display reconfiguration. +// This is used after display reconfiguration events because macOS may restore +// default gamma (via ColorSync) at unpredictable times after display changes. +// Note: This function should be called while holding g_privacyModeMutex. +static void ScheduleDelayedBlackoutReapplication(const char* reason) { + // Update timestamp to current time + g_lastReconfigTimestamp = GetCurrentTimestampMs(); + + NSLog(@"Starting blackout monitor: %s", reason); + + // Only schedule if not already scheduled + if (!g_blackoutReapplicationScheduled) { + g_blackoutReapplicationScheduled = true; + RunBlackoutMonitor(); + } + // If already scheduled, the running monitor will see the updated timestamp + // and extend its monitoring period +} + +// Display reconfiguration callback to handle display connect/disconnect events +// +// IMPORTANT: When errors occur in this callback, we use ScheduleAsyncPrivacyModeShutdown() +// instead of calling TurnOffPrivacyModeInternal() directly. This is because: +// 1. TurnOffPrivacyModeInternal() calls CGDisplayRemoveReconfigurationCallback to unregister +// this callback, and unregistering a callback from within itself is not explicitly +// guaranteed to be safe by Apple documentation. +// 2. Using async dispatch ensures we're completely outside the callback context when +// performing the cleanup, avoiding any potential undefined behavior. +static void DisplayReconfigurationCallback(CGDirectDisplayID display, CGDisplayChangeSummaryFlags flags, void *userInfo) { + (void)userInfo; + + // Note: We need to handle the callback carefully because: + // 1. macOS may call this callback multiple times during display reconfiguration + // 2. The system may restore ColorSync settings after our gamma change + // 3. We should not hold the lock for too long in the callback + + // Skip begin configuration flag - wait for the actual change + if (flags & kCGDisplayBeginConfigurationFlag) { + return; + } + + std::lock_guard lock(g_privacyModeMutex); + + if (!g_privacyModeActive) { + return; + } + + if (flags & kCGDisplayAddFlag) { + // A display was added - apply blackout to it + NSLog(@"Display %u added during privacy mode, applying blackout", (unsigned)display); + std::string uuid = GetDisplayUUID(display); + if (uuid.empty()) { + NSLog(@"Failed to get UUID for newly added display %u, exiting privacy mode", (unsigned)display); + ScheduleAsyncPrivacyModeShutdown("Failed to get UUID for newly added display"); + return; + } + + // Save original gamma if not already saved for this UUID + if (g_originalGammas.find(uuid) == g_originalGammas.end()) { + uint32_t capacity = CGDisplayGammaTableCapacity(display); + if (capacity > 0) { + std::vector red(capacity), green(capacity), blue(capacity); + uint32_t sampleCount = 0; + if (CGGetDisplayTransferByTable(display, capacity, red.data(), green.data(), blue.data(), &sampleCount) == kCGErrorSuccess) { + std::vector all; + all.insert(all.end(), red.begin(), red.begin() + sampleCount); + all.insert(all.end(), green.begin(), green.begin() + sampleCount); + all.insert(all.end(), blue.begin(), blue.begin() + sampleCount); + g_originalGammas[uuid] = all; + } else { + NSLog(@"DisplayReconfigurationCallback: Failed to get gamma table for display %u (UUID: %s), exiting privacy mode", (unsigned)display, uuid.c_str()); + ScheduleAsyncPrivacyModeShutdown("Failed to get gamma table for newly added display"); + return; + } + } else { + NSLog(@"DisplayReconfigurationCallback: Display %u (UUID: %s) has zero gamma table capacity, exiting privacy mode", (unsigned)display, uuid.c_str()); + ScheduleAsyncPrivacyModeShutdown("Newly added display has zero gamma table capacity"); + return; + } + } + + // Apply blackout to the new display immediately + if (!ApplyBlackoutToDisplay(display)) { + NSLog(@"DisplayReconfigurationCallback: Failed to blackout display %u (UUID: %s), exiting privacy mode", (unsigned)display, uuid.c_str()); + ScheduleAsyncPrivacyModeShutdown("Failed to blackout newly added display"); + return; + } + + // Schedule a delayed re-application to handle ColorSync restoration + // macOS may restore default gamma for ALL displays after a new display is added, + // so we need to reapply blackout to all online displays, not just the new one + ScheduleDelayedBlackoutReapplication("after new display added"); + } else if (flags & kCGDisplayRemoveFlag) { + // A display was removed - update our mapping and reapply blackout to remaining displays + NSLog(@"Display %u removed during privacy mode", (unsigned)display); + std::string uuid = GetDisplayUUID(display); + (void)uuid; // UUID retrieved for potential future use or logging + + // When a display is removed, macOS may reconfigure other displays and restore their gamma. + // Schedule a delayed re-application of blackout to all remaining online displays. + ScheduleDelayedBlackoutReapplication("after display removal"); + } else if (flags & kCGDisplaySetModeFlag) { + // Display mode changed (resolution change, ColorSync/Night Shift interference, etc.) + // macOS resets gamma to default when display mode changes, so we need to reapply blackout. + // Schedule a delayed re-application because ColorSync restoration happens asynchronously. + NSLog(@"Display %u mode changed during privacy mode, reapplying blackout", (unsigned)display); + ScheduleDelayedBlackoutReapplication("after display mode change"); + } +} + +CGEventRef MyEventTapCallback(CGEventTapProxy proxy, CGEventType type, CGEventRef event, void *refcon) { + (void)proxy; + (void)refcon; + + // Handle EventTap being disabled by system timeout + if (type == kCGEventTapDisabledByTimeout) { + NSLog(@"EventTap was disabled by timeout, re-enabling"); + if (g_eventTap) { + CGEventTapEnable(g_eventTap, true); + } + return event; + } + + // Handle EventTap being disabled by user input + if (type == kCGEventTapDisabledByUserInput) { + NSLog(@"EventTap was disabled by user input, re-enabling"); + if (g_eventTap) { + CGEventTapEnable(g_eventTap, true); + } + return event; + } + + // Allow events explicitly injected by enigo (remote input), identified via custom user data. + int64_t userData = CGEventGetIntegerValueField(event, kCGEventSourceUserData); + if (userData == ENIGO_INPUT_EXTRA_VALUE) { + return event; + } + // Block local physical HID input. + if (CGEventGetIntegerValueField(event, kCGEventSourceStateID) == kCGEventSourceStateHIDSystemState) { + return NULL; + } + return event; +} + +// Helper function to set up EventTap on the main thread +// Returns true if EventTap was successfully created and enabled +static bool SetupEventTapOnMainThread() { + __block bool success = false; + + void (^setupBlock)(void) = ^{ + if (g_eventTap) { + // Already set up + success = true; + return; + } + + // Note: kCGEventTapDisabledByTimeout and kCGEventTapDisabledByUserInput are special + // notification types (0xFFFFFFFE and 0xFFFFFFFF) that are delivered via the callback's + // type parameter, not through the event mask. They should NOT be included in eventMask + // as bit-shifting by these values causes undefined behavior. + CGEventMask eventMask = (1 << kCGEventKeyDown) | (1 << kCGEventKeyUp) | + (1 << kCGEventLeftMouseDown) | (1 << kCGEventLeftMouseUp) | + (1 << kCGEventRightMouseDown) | (1 << kCGEventRightMouseUp) | + (1 << kCGEventOtherMouseDown) | (1 << kCGEventOtherMouseUp) | + (1 << kCGEventLeftMouseDragged) | (1 << kCGEventRightMouseDragged) | + (1 << kCGEventOtherMouseDragged) | + (1 << kCGEventMouseMoved) | (1 << kCGEventScrollWheel); + + g_eventTap = CGEventTapCreate(kCGHIDEventTap, kCGHeadInsertEventTap, kCGEventTapOptionDefault, + eventMask, MyEventTapCallback, NULL); + if (g_eventTap) { + g_runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, g_eventTap, 0); + CFRunLoopAddSource(CFRunLoopGetMain(), g_runLoopSource, kCFRunLoopCommonModes); + CGEventTapEnable(g_eventTap, true); + success = true; + } else { + NSLog(@"MacSetPrivacyMode: Failed to create CGEventTap; input blocking not enabled."); + success = false; + } + }; + + // Execute on main thread to ensure CFRunLoop operations are safe. + // Use dispatch_sync if not on main thread, otherwise execute directly to avoid deadlock. + // + // IMPORTANT: Potential deadlock consideration: + // Using dispatch_sync while holding g_privacyModeMutex could deadlock if the main thread + // tries to acquire g_privacyModeMutex. Currently this is safe because: + // 1. MacSetPrivacyMode (which holds the mutex) is only called from background threads + // 2. The main thread never directly calls MacSetPrivacyMode + // If this assumption changes in the future, consider releasing the mutex before dispatch_sync + // or restructuring the locking strategy. + if ([NSThread isMainThread]) { + setupBlock(); + } else { + dispatch_sync(dispatch_get_main_queue(), setupBlock); + } + + return success; +} + +// Helper function to tear down EventTap on the main thread +static void TeardownEventTapOnMainThread() { + void (^teardownBlock)(void) = ^{ + if (g_eventTap) { + CGEventTapEnable(g_eventTap, false); + CFRunLoopRemoveSource(CFRunLoopGetMain(), g_runLoopSource, kCFRunLoopCommonModes); + CFRelease(g_runLoopSource); + CFRelease(g_eventTap); + g_eventTap = NULL; + g_runLoopSource = NULL; + } + }; + + // Execute on main thread to ensure CFRunLoop operations are safe. + // + // NOTE: We use dispatch_sync here instead of dispatch_async because: + // 1. TurnOffPrivacyModeInternal() expects EventTap to be fully torn down before + // proceeding with gamma restoration - using async would cause race conditions. + // 2. The caller (MacSetPrivacyMode) needs deterministic cleanup order. + // + // IMPORTANT: Potential deadlock consideration (same as SetupEventTapOnMainThread): + // Using dispatch_sync while holding g_privacyModeMutex could deadlock if the main thread + // tries to acquire g_privacyModeMutex. Currently this is safe because: + // 1. MacSetPrivacyMode (which holds the mutex) is only called from background threads + // 2. The main thread never directly calls MacSetPrivacyMode + // If this assumption changes in the future, consider releasing the mutex before dispatch_sync + // or restructuring the locking strategy. + if ([NSThread isMainThread]) { + teardownBlock(); + } else { + dispatch_sync(dispatch_get_main_queue(), teardownBlock); + } +} + +// Internal function to turn off privacy mode without acquiring the mutex +// Must be called while holding g_privacyModeMutex +static bool TurnOffPrivacyModeInternal() { + if (!g_privacyModeActive) { + return true; + } + + // 1. Unregister display reconfiguration callback + CGDisplayRemoveReconfigurationCallback(DisplayReconfigurationCallback, NULL); + + // 2. Input - restore (tear down EventTap on main thread) + TeardownEventTapOnMainThread(); + + // 3. Gamma - restore using UUID to find current DisplayID + bool restoreSuccess = RestoreAllGammas(); + + // 4. Fallback: Always call CGDisplayRestoreColorSyncSettings as a safety net + // This ensures displays return to normal even if our restoration failed or + // if the system (ColorSync/Night Shift) modified gamma during privacy mode + CGDisplayRestoreColorSyncSettings(); + + // Clean up + g_originalGammas.clear(); + g_privacyModeActive = false; + g_privacyModeShutdownRequested = false; + g_lastReconfigTimestamp = 0; + g_blackoutReapplicationScheduled = false; + + return restoreSuccess; +} + +extern "C" bool MacSetPrivacyMode(bool on) { + std::lock_guard lock(g_privacyModeMutex); + if (on) { + // Already in privacy mode + if (g_privacyModeActive) { + return true; + } + + // 1. Input Blocking - set up EventTap on main thread + if (!SetupEventTapOnMainThread()) { + return false; + } + + // 2. Register display reconfiguration callback to handle hot-plug events + CGDisplayRegisterReconfigurationCallback(DisplayReconfigurationCallback, NULL); + + // 3. Gamma Blackout + uint32_t count = 0; + CGGetOnlineDisplayList(0, NULL, &count); + std::vector displays(count); + CGGetOnlineDisplayList(count, displays.data(), &count); + + uint32_t blackoutSuccessCount = 0; + uint32_t blackoutAttemptCount = 0; + + for (uint32_t i = 0; i < count; i++) { + CGDirectDisplayID d = displays[i]; + std::string uuid = GetDisplayUUID(d); + + if (uuid.empty()) { + NSLog(@"MacSetPrivacyMode: Failed to get UUID for display %u, privacy mode requires all displays", (unsigned)d); + // Privacy mode requires ALL connected displays to be successfully blacked out + // to ensure user privacy. If we can't identify a display (no UUID), + // we can't safely manage its state or restore it later. + // Therefore, we must abort the entire operation and clean up any resources + // already allocated (like event taps and reconfiguration callbacks). + CGDisplayRemoveReconfigurationCallback(DisplayReconfigurationCallback, NULL); + TeardownEventTapOnMainThread(); + // Restore gamma for displays that were already blacked out before this failure + if (!RestoreAllGammas()) { + // If any display failed to restore, use system reset as fallback + CGDisplayRestoreColorSyncSettings(); + } + g_originalGammas.clear(); + return false; + } + + // Save original gamma using UUID as key (stable across reconnections) + if (g_originalGammas.find(uuid) == g_originalGammas.end()) { + uint32_t capacity = CGDisplayGammaTableCapacity(d); + if (capacity > 0) { + std::vector red(capacity), green(capacity), blue(capacity); + uint32_t sampleCount = 0; + if (CGGetDisplayTransferByTable(d, capacity, red.data(), green.data(), blue.data(), &sampleCount) == kCGErrorSuccess) { + std::vector all; + all.insert(all.end(), red.begin(), red.begin() + sampleCount); + all.insert(all.end(), green.begin(), green.begin() + sampleCount); + all.insert(all.end(), blue.begin(), blue.begin() + sampleCount); + g_originalGammas[uuid] = all; + } else { + NSLog(@"MacSetPrivacyMode: Failed to get gamma table for display %u (UUID: %s)", (unsigned)d, uuid.c_str()); + } + } else { + NSLog(@"MacSetPrivacyMode: Display %u (UUID: %s) has zero gamma table capacity, not supported", (unsigned)d, uuid.c_str()); + } + } + + // Set to black only if we have saved original gamma for this display + if (g_originalGammas.find(uuid) != g_originalGammas.end()) { + uint32_t capacity = CGDisplayGammaTableCapacity(d); + if (capacity > 0) { + std::vector zeros(capacity, 0.0f); + blackoutAttemptCount++; + CGError error = CGSetDisplayTransferByTable(d, capacity, zeros.data(), zeros.data(), zeros.data()); + if (error != kCGErrorSuccess) { + NSLog(@"MacSetPrivacyMode: Failed to blackout display (ID: %u, UUID: %s, error: %d)", (unsigned)d, uuid.c_str(), error); + } else { + blackoutSuccessCount++; + } + } else { + NSLog(@"MacSetPrivacyMode: Display %u (UUID: %s) has zero gamma table capacity for blackout", (unsigned)d, uuid.c_str()); + } + } + } + + // Return false if any display failed to blackout - privacy mode requires ALL displays to be blacked out + if (blackoutAttemptCount > 0 && blackoutSuccessCount < blackoutAttemptCount) { + NSLog(@"MacSetPrivacyMode: Failed to blackout all displays (%u/%u succeeded)", blackoutSuccessCount, blackoutAttemptCount); + // Clean up: unregister callback and disable event tap since we're failing + CGDisplayRemoveReconfigurationCallback(DisplayReconfigurationCallback, NULL); + TeardownEventTapOnMainThread(); + // Restore gamma for displays that were successfully blacked out + if (!RestoreAllGammas()) { + // If any display failed to restore, use system reset as fallback + NSLog(@"Some displays failed to restore gamma during cleanup, using CGDisplayRestoreColorSyncSettings as fallback"); + CGDisplayRestoreColorSyncSettings(); + } + g_originalGammas.clear(); + return false; + } + + g_privacyModeActive = true; + return true; + + } else { + return TurnOffPrivacyModeInternal(); + } +} diff --git a/src/platform/macos.rs b/src/platform/macos.rs index b3c5546a6..2e68cf5d8 100644 --- a/src/platform/macos.rs +++ b/src/platform/macos.rs @@ -27,12 +27,37 @@ use include_dir::{include_dir, Dir}; use objc::rc::autoreleasepool; use objc::{class, msg_send, sel, sel_impl}; use scrap::{libc::c_void, quartz::ffi::*}; -use std::path::PathBuf; +use std::{ + collections::HashMap, + os::unix::process::CommandExt, + path::{Path, PathBuf}, + process::{Command, Stdio}, + sync::Mutex, +}; + +// macOS boolean_t is defined as `int` in +type BooleanT = hbb_common::libc::c_int; static PRIVILEGES_SCRIPTS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/src/platform/privileges_scripts"); static mut LATEST_SEED: i32 = 0; +#[inline] +fn get_update_temp_dir() -> PathBuf { + let euid = unsafe { hbb_common::libc::geteuid() }; + Path::new("/tmp").join(format!(".rustdeskupdate-{}", euid)) +} + +#[inline] +fn get_update_temp_dir_string() -> String { + get_update_temp_dir().to_string_lossy().into_owned() +} + +/// Global mutex to serialize CoreGraphics cursor operations. +/// This prevents race conditions between cursor visibility (hide depth tracking) +/// and cursor positioning/clipping operations. +static CG_CURSOR_MUTEX: Mutex<()> = Mutex::new(()); + extern "C" { fn CGSCurrentCursorSeed() -> i32; fn CGEventCreate(r: *const c_void) -> *const c_void; @@ -48,12 +73,15 @@ extern "C" { display: u32, widths: *mut u32, heights: *mut u32, + hidpis: *mut BOOL, max: u32, numModes: *mut u32, ) -> BOOL; fn majorVersion() -> u32; fn MacGetMode(display: u32, width: *mut u32, height: *mut u32) -> BOOL; - fn MacSetMode(display: u32, width: u32, height: u32) -> BOOL; + fn MacSetMode(display: u32, width: u32, height: u32, tryHiDPI: bool) -> BOOL; + fn CGWarpMouseCursorPosition(newCursorPosition: CGPoint) -> CGError; + fn CGAssociateMouseAndMouseCursorPosition(connected: BooleanT) -> CGError; } pub fn major_version() -> u32 { @@ -155,11 +183,15 @@ pub fn install_service() -> bool { is_installed_daemon(false) } +// Remember to check if `update_daemon_agent()` need to be changed if changing `is_installed_daemon()`. +// No need to merge the existing dup code, because the code in these two functions are too critical. +// New code should be written in a common function. pub fn is_installed_daemon(prompt: bool) -> bool { let daemon = format!("{}_service.plist", crate::get_full_name()); let agent = format!("{}_server.plist", crate::get_full_name()); let agent_plist_file = format!("/Library/LaunchAgents/{}", agent); if !prompt { + // in macos 13, there is new way to check if they are running or enabled, https://developer.apple.com/documentation/servicemanagement/updating-helper-executables-from-earlier-versions-of-macos#Respond-to-changes-in-System-Settings if !std::path::Path::new(&format!("/Library/LaunchDaemons/{}", daemon)).exists() { return false; } @@ -218,9 +250,65 @@ pub fn is_installed_daemon(prompt: bool) -> bool { false } +fn update_daemon_agent(agent_plist_file: String, update_source_dir: String, sync: bool) { + let update_script_file = "update.scpt"; + let Some(update_script) = PRIVILEGES_SCRIPTS_DIR.get_file(update_script_file) else { + return; + }; + let Some(update_script_body) = update_script.contents_utf8().map(correct_app_name) else { + return; + }; + + let Some(daemon_plist) = PRIVILEGES_SCRIPTS_DIR.get_file("daemon.plist") else { + return; + }; + let Some(daemon_plist_body) = daemon_plist.contents_utf8().map(correct_app_name) else { + return; + }; + let Some(agent_plist) = PRIVILEGES_SCRIPTS_DIR.get_file("agent.plist") else { + return; + }; + let Some(agent_plist_body) = agent_plist.contents_utf8().map(correct_app_name) else { + return; + }; + + let func = move || { + let mut binding = std::process::Command::new("osascript"); + let cmd = binding + .arg("-e") + .arg(update_script_body) + .arg(daemon_plist_body) + .arg(agent_plist_body) + .arg(&get_active_username()) + .arg(std::process::id().to_string()) + .arg(update_source_dir); + match cmd.status() { + Err(e) => { + log::error!("run osascript failed: {}", e); + } + Ok(status) if !status.success() => { + log::warn!("run osascript failed with status: {}", status); + } + _ => { + let installed = std::path::Path::new(&agent_plist_file).exists(); + log::info!("Agent file {} installed: {}", &agent_plist_file, installed); + } + } + }; + if sync { + func(); + } else { + std::thread::spawn(func); + } +} + fn correct_app_name(s: &str) -> String { - let s = s.replace("rustdesk", &crate::get_app_name().to_lowercase()); - let s = s.replace("RustDesk", &crate::get_app_name()); + let mut s = s.to_owned(); + if let Some(bundleid) = get_bundle_id() { + s = s.replace("com.carriez.rustdesk", &bundleid); + } + s = s.replace("rustdesk", &crate::get_app_name().to_lowercase()); + s = s.replace("RustDesk", &crate::get_app_name()); s } @@ -305,6 +393,101 @@ pub fn get_cursor_pos() -> Option<(i32, i32)> { */ } +/// Warp the mouse cursor to the specified screen position. +/// +/// # Thread Safety +/// This function affects global cursor state and acquires `CG_CURSOR_MUTEX`. +/// Callers must ensure no nested calls occur while the mutex is held. +/// +/// # Arguments +/// * `x` - X coordinate in screen points (macOS uses points, not pixels) +/// * `y` - Y coordinate in screen points +pub fn set_cursor_pos(x: i32, y: i32) -> bool { + // Acquire lock with deadlock detection in debug builds. + // In debug builds, try_lock detects re-entrant calls early; on failure we return immediately. + // In release builds, we use blocking lock() which will wait if contended. + #[cfg(debug_assertions)] + let _guard = match CG_CURSOR_MUTEX.try_lock() { + Ok(guard) => guard, + Err(std::sync::TryLockError::WouldBlock) => { + log::error!( + "[BUG] set_cursor_pos: CG_CURSOR_MUTEX is already held - potential deadlock!" + ); + debug_assert!(false, "Re-entrant call to set_cursor_pos detected"); + return false; + } + Err(std::sync::TryLockError::Poisoned(e)) => e.into_inner(), + }; + #[cfg(not(debug_assertions))] + let _guard = CG_CURSOR_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + unsafe { + let result = CGWarpMouseCursorPosition(CGPoint { + x: x as f64, + y: y as f64, + }); + if result != CGError::Success { + log::error!( + "CGWarpMouseCursorPosition({}, {}) returned error: {:?}", + x, + y, + result + ); + } + result == CGError::Success + } +} + +/// Toggle pointer lock (dissociate/associate mouse from cursor position). +/// +/// On macOS, cursor clipping is not supported directly like Windows ClipCursor. +/// Instead, we use CGAssociateMouseAndMouseCursorPosition to dissociate mouse +/// movement from cursor position, achieving a "pointer lock" effect. +/// +/// # Thread Safety +/// This function affects global cursor state and acquires `CG_CURSOR_MUTEX`. +/// Callers must ensure only one owner toggles pointer lock at a time; +/// nested Some/None transitions from different call sites may cause unexpected behavior. +/// +/// # Arguments +/// * `rect` - When `Some(_)`, dissociates mouse from cursor (enables pointer lock). +/// When `None`, re-associates mouse with cursor (disables pointer lock). +/// The rect coordinate values are ignored on macOS; only `Some`/`None` matters. +/// The parameter signature matches Windows for API consistency. +pub fn clip_cursor(rect: Option<(i32, i32, i32, i32)>) -> bool { + // Acquire lock with deadlock detection in debug builds. + // In debug builds, try_lock detects re-entrant calls early; on failure we return immediately. + // In release builds, we use blocking lock() which will wait if contended. + #[cfg(debug_assertions)] + let _guard = match CG_CURSOR_MUTEX.try_lock() { + Ok(guard) => guard, + Err(std::sync::TryLockError::WouldBlock) => { + log::error!("[BUG] clip_cursor: CG_CURSOR_MUTEX is already held - potential deadlock!"); + debug_assert!(false, "Re-entrant call to clip_cursor detected"); + return false; + } + Err(std::sync::TryLockError::Poisoned(e)) => e.into_inner(), + }; + #[cfg(not(debug_assertions))] + let _guard = CG_CURSOR_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + // CGAssociateMouseAndMouseCursorPosition takes a boolean_t: + // 1 (true) = associate mouse with cursor position (normal mode) + // 0 (false) = dissociate mouse from cursor position (pointer lock mode) + // When rect is Some, we want pointer lock (dissociate), so associate = false (0). + // When rect is None, we want normal mode (associate), so associate = true (1). + let associate: BooleanT = if rect.is_some() { 0 } else { 1 }; + unsafe { + let result = CGAssociateMouseAndMouseCursorPosition(associate); + if result != CGError::Success { + log::warn!( + "CGAssociateMouseAndMouseCursorPosition({}) returned error: {:?}", + associate, + result + ); + } + result == CGError::Success + } +} + pub fn get_focused_display(displays: Vec) -> Option { autoreleasepool(|| unsafe_get_focused_display(displays)) } @@ -491,6 +674,38 @@ pub fn is_prelogin() -> bool { get_active_userid() == "0" } +// https://stackoverflow.com/questions/11505255/osx-check-if-the-screen-is-locked +// No "CGSSessionScreenIsLocked" can be found when macOS is not locked. +// +// `ioreg -n Root -d1` returns `"CGSSessionScreenIsLocked"=Yes` +// `ioreg -n Root -d1 -a` returns +// ``` +// ... +// CGSSessionScreenIsLocked +// +// ... +// ``` +pub fn is_locked() -> bool { + match std::process::Command::new("ioreg") + .arg("-n") + .arg("Root") + .arg("-d1") + .output() + { + Ok(output) => { + let output_str = String::from_utf8_lossy(&output.stdout); + // Although `"CGSSessionScreenIsLocked"=Yes` was printed on my macOS, + // I also check `"CGSSessionScreenIsLocked"=true` for better compability. + output_str.contains("\"CGSSessionScreenIsLocked\"=Yes") + || output_str.contains("\"CGSSessionScreenIsLocked\"=true") + } + Err(e) => { + log::error!("Failed to query ioreg for the lock state: {}", e); + false + } + } +} + pub fn is_root() -> bool { crate::username() == "root" } @@ -515,53 +730,6 @@ pub fn lock_screen() { pub fn start_os_service() { log::info!("Username: {}", crate::username()); - let mut sys = System::new(); - let path = - std::fs::canonicalize(std::env::current_exe().unwrap_or_default()).unwrap_or_default(); - let mut server = get_server_start_time(&mut sys, &path); - if server.is_none() { - log::error!("Agent not started yet, please restart --server first to make delegate work",); - std::process::exit(-1); - } - let my_start_time = sys - .process((std::process::id() as usize).into()) - .map(|p| p.start_time()) - .unwrap_or_default() as i64; - log::info!("Startime: {my_start_time} vs {:?}", server); - - std::thread::spawn(move || loop { - std::thread::sleep(std::time::Duration::from_secs(1)); - if server.is_none() { - server = get_server_start_time(&mut sys, &path); - } - let Some((start_time, pid)) = server else { - log::error!( - "Agent not started yet, please restart --server first to make delegate work", - ); - std::process::exit(-1); - }; - if my_start_time <= start_time + 3 { - log::error!( - "Agent start later, {my_start_time} vs {start_time}, please start --server first to make delegate work, earlier more 3 seconds", - ); - std::process::exit(-1); - } - // only refresh this pid and check if valid, no need to refresh all processes since refreshing all is expensive, about 10ms on my machine - if !sys.refresh_process_specifics(pid, ProcessRefreshKind::new()) { - server = None; - continue; - } - if let Some(p) = sys.process(pid.into()) { - if let Some(p) = get_server_start_time_of(p, &path) { - server = Some((p, pid)); - } else { - server = None; - } - } else { - server = None; - } - }); - if let Err(err) = crate::ipc::start("_service") { log::error!("Failed to start ipc_service: {}", err); } @@ -649,6 +817,198 @@ pub fn quit_gui() { }; } +#[inline] +pub fn try_remove_temp_update_dir(dir: Option<&str>) { + let target_path_buf = dir.map(PathBuf::from).unwrap_or_else(get_update_temp_dir); + let target_path = target_path_buf.as_path(); + if target_path.exists() { + std::fs::remove_dir_all(target_path).ok(); + } +} + +pub fn update_me() -> ResultType<()> { + let is_installed_daemon = is_installed_daemon(false); + let option_stop_service = "stop-service"; + let is_service_stopped = hbb_common::config::option2bool( + option_stop_service, + &crate::ui_interface::get_option(option_stop_service), + ); + + let cmd = std::env::current_exe()?; + // RustDesk.app/Contents/MacOS/RustDesk + let app_dir = cmd + .parent() + .and_then(|p| p.parent()) + .and_then(|p| p.parent()) + .map(|d| d.to_string_lossy().to_string()); + let Some(app_dir) = app_dir else { + bail!("Unknown app directory of current exe file: {:?}", cmd); + }; + + let app_name = crate::get_app_name(); + if is_installed_daemon && !is_service_stopped { + let agent = format!("{}_server.plist", crate::get_full_name()); + let agent_plist_file = format!("/Library/LaunchAgents/{}", agent); + update_daemon_agent(agent_plist_file, app_dir, true); + } else { + // `kill -9` may not work without "administrator privileges" + let update_body = r#" +on run {app_name, cur_pid, app_dir, user_name} + set app_bundle to "/Applications/" & app_name & ".app" + set app_bundle_q to quoted form of app_bundle + set app_dir_q to quoted form of app_dir + set user_name_q to quoted form of user_name + + set check_source to "test -d " & app_dir_q & " || exit 1;" + set kill_others to "pids=$(pgrep -x '" & app_name & "' | grep -vx " & cur_pid & " || true); if [ -n \"$pids\" ]; then echo \"$pids\" | xargs kill -9 || true; fi;" + set copy_files to "rm -rf " & app_bundle_q & " && ditto " & app_dir_q & " " & app_bundle_q & " && chown -R " & user_name_q & ":staff " & app_bundle_q & " && (xattr -r -d com.apple.quarantine " & app_bundle_q & " || true);" + set sh to "set -e;" & check_source & kill_others & copy_files + + do shell script sh with prompt app_name & " wants to update itself" with administrator privileges +end run + "#; + let active_user = get_active_username(); + let status = Command::new("osascript") + .arg("-e") + .arg(update_body) + .arg(app_name.to_string()) + .arg(std::process::id().to_string()) + .arg(app_dir) + .arg(active_user) + .status(); + match status { + Ok(status) if !status.success() => { + log::error!("osascript execution failed with status: {}", status); + } + Err(e) => { + log::error!("run osascript failed: {}", e); + } + _ => {} + } + } + std::process::Command::new("open") + .arg("-n") + .arg(&format!("/Applications/{}.app", app_name)) + .spawn() + .ok(); + // leave open a little time + std::thread::sleep(std::time::Duration::from_millis(300)); + Ok(()) +} + +pub fn update_from_dmg(dmg_path: &str) -> ResultType<()> { + let update_temp_dir = get_update_temp_dir_string(); + println!("Starting update from DMG: {}", dmg_path); + extract_dmg(dmg_path, &update_temp_dir)?; + println!("DMG extracted"); + update_extracted(&update_temp_dir)?; + println!("Update process started"); + Ok(()) +} + +pub fn update_to(_file: &str) -> ResultType<()> { + let update_temp_dir = get_update_temp_dir_string(); + update_extracted(&update_temp_dir)?; + Ok(()) +} + +pub fn extract_update_dmg(file: &str) { + let update_temp_dir = get_update_temp_dir_string(); + let mut evt: HashMap<&str, String> = + HashMap::from([("name", "extract-update-dmg".to_string())]); + match extract_dmg(file, &update_temp_dir) { + Ok(_) => { + log::info!("Extracted dmg file to {}", update_temp_dir); + } + Err(e) => { + evt.insert("err", e.to_string()); + log::error!("Failed to extract dmg file {}: {}", file, e); + } + } + let evt = serde_json::ser::to_string(&evt).unwrap_or("".to_owned()); + #[cfg(feature = "flutter")] + crate::flutter::push_global_event(crate::flutter::APP_TYPE_MAIN, evt); +} + +fn extract_dmg(dmg_path: &str, target_dir: &str) -> ResultType<()> { + let mount_point = "/Volumes/RustDeskUpdate"; + let target_path = Path::new(target_dir); + + if target_path.exists() { + std::fs::remove_dir_all(target_path)?; + } + std::fs::create_dir_all(target_path)?; + + let status = Command::new("hdiutil") + .args(&["attach", "-nobrowse", "-mountpoint", mount_point, dmg_path]) + .status()?; + + if !status.success() { + bail!("Failed to attach DMG image at {}: {:?}", dmg_path, status); + } + + struct DmgGuard(&'static str); + impl Drop for DmgGuard { + fn drop(&mut self) { + let _ = Command::new("hdiutil") + .args(&["detach", self.0, "-force"]) + .status(); + } + } + let _guard = DmgGuard(mount_point); + + let app_name = format!("{}.app", crate::get_app_name()); + let src_path = format!("{}/{}", mount_point, app_name); + let dest_path = format!("{}/{}", target_dir, app_name); + + let copy_status = Command::new("ditto") + .args(&[&src_path, &dest_path]) + .status()?; + + if !copy_status.success() { + bail!( + "Failed to copy application from {} to {}: {:?}", + src_path, + dest_path, + copy_status + ); + } + + if !Path::new(&dest_path).exists() { + bail!( + "Copy operation failed - destination not found at {}", + dest_path + ); + } + + Ok(()) +} + +fn update_extracted(target_dir: &str) -> ResultType<()> { + let app_name = crate::get_app_name(); + let exe_path = format!( + "{}/{}.app/Contents/MacOS/{}", + target_dir, app_name, app_name + ); + let _child = unsafe { + if let Err(e) = Command::new(&exe_path) + .arg("--update") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .pre_exec(|| { + hbb_common::libc::setsid(); + Ok(()) + }) + .spawn() + { + try_remove_temp_update_dir(Some(target_dir)); + bail!(e); + } + }; + Ok(()) +} + pub fn get_double_click_time() -> u32 { // to-do: https://github.com/servo/core-foundation-rs/blob/786895643140fa0ee4f913d7b4aeb0c4626b2085/cocoa/src/appkit.rs#L2823 500 as _ @@ -661,7 +1021,8 @@ pub fn hide_dock() { } #[inline] -fn get_server_start_time_of(p: &Process, path: &PathBuf) -> Option { +#[allow(dead_code)] +fn get_server_start_time_of(p: &Process, path: &Path) -> Option { let cmd = p.cmd(); if cmd.len() <= 1 { return None; @@ -679,7 +1040,8 @@ fn get_server_start_time_of(p: &Process, path: &PathBuf) -> Option { } #[inline] -fn get_server_start_time(sys: &mut System, path: &PathBuf) -> Option<(i64, Pid)> { +#[allow(dead_code)] +fn get_server_start_time(sys: &mut System, path: &Path) -> Option<(i64, Pid)> { sys.refresh_processes_specifics(ProcessRefreshKind::new()); for (_, p) in sys.processes() { if let Some(t) = get_server_start_time_of(p, path) { @@ -697,33 +1059,62 @@ pub fn handle_application_should_open_untitled_file() { } } +/// Get all resolutions of the display. The resolutions are: +/// 1. Sorted by width and height in descending order, with duplicates removed. +/// 2. Filtered out if the width is less than 800 (800x600) if there are too many (e.g., >15). +/// 3. Contain HiDPI resolutions and the real resolutions. +/// +/// We don't need to distinguish between HiDPI and real resolutions. +/// When the controlling side changes the resolution, it will call `change_resolution_directly()`. +/// `change_resolution_directly()` will try to use the HiDPI resolution first. +/// This is how teamviewer does it for now. +/// +/// If we need to distinguish HiDPI and real resolutions, we can add a flag to the `Resolution` struct. pub fn resolutions(name: &str) -> Vec { let mut v = vec![]; if let Ok(display) = name.parse::() { let mut num = 0; unsafe { if YES == MacGetModeNum(display, &mut num) { - let (mut widths, mut heights) = (vec![0; num as _], vec![0; num as _]); + let (mut widths, mut heights, mut _hidpis) = + (vec![0; num as _], vec![0; num as _], vec![NO; num as _]); let mut real_num = 0; if YES == MacGetModes( display, widths.as_mut_ptr(), heights.as_mut_ptr(), + _hidpis.as_mut_ptr(), num, &mut real_num, ) { if real_num <= num { - for i in 0..real_num { - let resolution = Resolution { + v = (0..real_num) + .map(|i| Resolution { width: widths[i as usize] as _, height: heights[i as usize] as _, ..Default::default() - }; - if !v.contains(&resolution) { - v.push(resolution); + }) + .collect::>(); + // Sort by (w, h), desc + v.sort_by(|a, b| { + if a.width == b.width { + b.height.cmp(&a.height) + } else { + b.width.cmp(&a.width) } + }); + // Remove duplicates + v.dedup_by(|a, b| a.width == b.width && a.height == b.height); + // Filter out the ones that are less than width 800 (800x600) if there are too many. + // We can also do this filtering on the client side, but it is better not to change the client side to reduce the impact. + if v.len() > 15 { + // Most width > 800, so it's ok to remove the small ones. + v.retain(|r| r.width >= 800); + } + if v.len() > 15 { + // Ignore if the length is still too long. } } } @@ -751,7 +1142,7 @@ pub fn current_resolution(name: &str) -> ResultType { pub fn change_resolution_directly(name: &str, width: usize, height: usize) -> ResultType<()> { let display = name.parse::().map_err(|e| anyhow!(e))?; unsafe { - if NO == MacSetMode(display, width as _, height as _) { + if NO == MacSetMode(display, width as _, height as _, true) { bail!("MacSetMode failed"); } } @@ -813,3 +1204,27 @@ impl WakeLock { .ok_or(anyhow!("no AwakeHandle"))? } } + +fn get_bundle_id() -> Option { + unsafe { + let bundle: id = msg_send![class!(NSBundle), mainBundle]; + if bundle.is_null() { + return None; + } + + let bundle_id: id = msg_send![bundle, bundleIdentifier]; + if bundle_id.is_null() { + return None; + } + + let c_str: *const std::os::raw::c_char = msg_send![bundle_id, UTF8String]; + if c_str.is_null() { + return None; + } + + let bundle_id_str = std::ffi::CStr::from_ptr(c_str) + .to_string_lossy() + .to_string(); + Some(bundle_id_str) + } +} diff --git a/src/platform/mod.rs b/src/platform/mod.rs index d0ddd09bf..c1bc38232 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -26,8 +26,13 @@ pub mod linux_desktop_manager; #[cfg(target_os = "linux")] pub mod gtk_sudo; +#[cfg(all( + not(all(target_os = "windows", not(target_pointer_width = "64"))), + not(any(target_os = "android", target_os = "ios")) +))] +use hbb_common::sysinfo::System; #[cfg(not(any(target_os = "android", target_os = "ios")))] -use hbb_common::{message_proto::CursorData, ResultType}; +use hbb_common::{message_proto::CursorData, sysinfo::Pid, ResultType}; use std::sync::{Arc, Mutex}; #[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] pub const SERVICE_INTERVAL: u64 = 300; @@ -115,16 +120,18 @@ pub fn get_wakelock(_display: bool) -> WakeLock { return crate::platform::WakeLock::new(_display, true, false); } +#[cfg(any(target_os = "windows", target_os = "linux"))] pub(crate) struct InstallingService; // please use new +#[cfg(any(target_os = "windows", target_os = "linux"))] impl InstallingService { - #[cfg(any(target_os = "windows", target_os = "linux"))] pub fn new() -> Self { *INSTALLING_SERVICE.lock().unwrap() = true; Self } } +#[cfg(any(target_os = "windows", target_os = "linux"))] impl Drop for InstallingService { fn drop(&mut self) { *INSTALLING_SERVICE.lock().unwrap() = false; @@ -137,6 +144,72 @@ pub fn is_prelogin() -> bool { false } +// Note: This method is inefficient on Windows. It will get all the processes. +// It should only be called when performance is not critical. +// If we wanted to get the command line ourselves, there would be a lot of new code. +#[allow(dead_code)] +#[cfg(not(any(target_os = "android", target_os = "ios")))] +fn get_pids_of_process_with_args, S2: AsRef>( + name: S1, + args: &[S2], +) -> Vec { + // This function does not work when the process is 32-bit and the OS is 64-bit Windows, + // `process.cmd()` always returns [] in this case. + // So we use `windows::get_pids_with_args_by_wmic()` instead. + #[cfg(all(target_os = "windows", not(target_pointer_width = "64")))] + { + return windows::get_pids_with_args_by_wmic(name, args); + } + #[cfg(not(all(target_os = "windows", not(target_pointer_width = "64"))))] + { + let name = name.as_ref().to_lowercase(); + let system = System::new_all(); + system + .processes() + .iter() + .filter(|(_, process)| { + process.name().to_lowercase() == name + && process.cmd().len() == args.len() + 1 + && args.iter().enumerate().all(|(i, arg)| { + process.cmd()[i + 1].to_lowercase() == arg.as_ref().to_lowercase() + }) + }) + .map(|(&pid, _)| pid) + .collect() + } +} + +// Note: This method is inefficient on Windows. It will get all the processes. +// It should only be called when performance is not critical. +#[cfg(not(any(target_os = "android", target_os = "ios")))] +pub fn get_pids_of_process_with_first_arg, S2: AsRef>( + name: S1, + arg: S2, +) -> Vec { + // This function does not work when the process is 32-bit and the OS is 64-bit Windows, + // `process.cmd()` always returns [] in this case. + // So we use `windows::get_pids_with_first_arg_by_wmic()` instead. + #[cfg(all(target_os = "windows", not(target_pointer_width = "64")))] + { + return windows::get_pids_with_first_arg_by_wmic(name, arg); + } + #[cfg(not(all(target_os = "windows", not(target_pointer_width = "64"))))] + { + let name = name.as_ref().to_lowercase(); + let system = System::new_all(); + system + .processes() + .iter() + .filter(|(_, process)| { + process.name().to_lowercase() == name + && process.cmd().len() >= 2 + && process.cmd()[1].to_lowercase() == arg.as_ref().to_lowercase() + }) + .map(|(&pid, _)| pid) + .collect() + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/platform/privileges_scripts/agent.plist b/src/platform/privileges_scripts/agent.plist index 5ff786a49..28f9c024a 100644 --- a/src/platform/privileges_scripts/agent.plist +++ b/src/platform/privileges_scripts/agent.plist @@ -4,6 +4,10 @@ Label com.carriez.RustDesk_server + + AssociatedBundleIdentifiers + com.carriez.rustdesk + LimitLoadToSessionType LoginWindow @@ -27,5 +31,7 @@ WorkingDirectory /Applications/RustDesk.app/Contents/MacOS/ + ProcessType + Interactive diff --git a/src/platform/privileges_scripts/daemon.plist b/src/platform/privileges_scripts/daemon.plist index 61efc25ec..c003ea2be 100644 --- a/src/platform/privileges_scripts/daemon.plist +++ b/src/platform/privileges_scripts/daemon.plist @@ -4,6 +4,10 @@ Label com.carriez.RustDesk_service + + AssociatedBundleIdentifiers + com.carriez.rustdesk + KeepAlive ThrottleInterval @@ -12,7 +16,7 @@ /bin/sh -c - sleep 3; if pgrep -f '/Applications/RustDesk.app/Contents/MacOS/RustDesk --server' > /dev/null; then /Applications/RustDesk.app/Contents/MacOS/RustDesk --service; fi + /Applications/RustDesk.app/Contents/MacOS/service RunAtLoad diff --git a/src/platform/privileges_scripts/install.scpt b/src/platform/privileges_scripts/install.scpt index c38320db5..797d02c9e 100644 --- a/src/platform/privileges_scripts/install.scpt +++ b/src/platform/privileges_scripts/install.scpt @@ -12,5 +12,5 @@ on run {daemon_file, agent_file, user} set sh to sh1 & sh2 & sh3 & sh4 & sh5 - do shell script sh with prompt "RustDesk want to install daemon and agent" with administrator privileges + do shell script sh with prompt "RustDesk wants to install daemon and agent" with administrator privileges end run diff --git a/src/platform/privileges_scripts/uninstall.scpt b/src/platform/privileges_scripts/uninstall.scpt index 3d91e61f9..4a19fb3fd 100644 --- a/src/platform/privileges_scripts/uninstall.scpt +++ b/src/platform/privileges_scripts/uninstall.scpt @@ -3,4 +3,4 @@ set sh2 to "/bin/rm /Library/LaunchDaemons/com.carriez.RustDesk_service.plist;" set sh3 to "/bin/rm /Library/LaunchAgents/com.carriez.RustDesk_server.plist;" set sh to sh1 & sh2 & sh3 -do shell script sh with prompt "RustDesk want to unload daemon" with administrator privileges \ No newline at end of file +do shell script sh with prompt "RustDesk wants to unload daemon" with administrator privileges \ No newline at end of file diff --git a/src/platform/privileges_scripts/update.scpt b/src/platform/privileges_scripts/update.scpt new file mode 100644 index 000000000..0484c257a --- /dev/null +++ b/src/platform/privileges_scripts/update.scpt @@ -0,0 +1,26 @@ +on run {daemon_file, agent_file, user, cur_pid, source_dir} + + set agent_plist to "/Library/LaunchAgents/com.carriez.RustDesk_server.plist" + set daemon_plist to "/Library/LaunchDaemons/com.carriez.RustDesk_service.plist" + set app_bundle to "/Applications/RustDesk.app" + + set check_source to "test -d " & quoted form of source_dir & " || exit 1;" + set resolve_uid to "uid=$(id -u " & quoted form of user & " 2>/dev/null || true);" + set unload_agent to "if [ -n \"$uid\" ]; then launchctl bootout gui/$uid " & quoted form of agent_plist & " 2>/dev/null || launchctl bootout user/$uid " & quoted form of agent_plist & " 2>/dev/null || launchctl unload -w " & quoted form of agent_plist & " || true; else launchctl unload -w " & quoted form of agent_plist & " || true; fi;" + set unload_service to "launchctl unload -w " & daemon_plist & " || true;" + set kill_others to "pids=$(pgrep -x 'RustDesk' | grep -vx " & cur_pid & " || true); if [ -n \"$pids\" ]; then echo \"$pids\" | xargs kill -9 || true; fi;" + + set copy_files to "(rm -rf " & quoted form of app_bundle & " && ditto " & quoted form of source_dir & " " & quoted form of app_bundle & " && chown -R " & quoted form of user & ":staff " & quoted form of app_bundle & " && (xattr -r -d com.apple.quarantine " & quoted form of app_bundle & " || true)) || exit 1;" + + set write_daemon_plist to "echo " & quoted form of daemon_file & " > " & daemon_plist & " && chown root:wheel " & daemon_plist & ";" + set write_agent_plist to "echo " & quoted form of agent_file & " > " & agent_plist & " && chown root:wheel " & agent_plist & ";" + set load_service to "launchctl load -w " & daemon_plist & ";" + set agent_label_cmd to "agent_label=$(basename " & quoted form of agent_plist & " .plist);" + set bootstrap_agent to "if [ -n \"$uid\" ]; then launchctl bootstrap gui/$uid " & quoted form of agent_plist & " 2>/dev/null || launchctl bootstrap user/$uid " & quoted form of agent_plist & " 2>/dev/null || launchctl load -w " & quoted form of agent_plist & " || true; else launchctl load -w " & quoted form of agent_plist & " || true; fi;" + set kickstart_agent to "if [ -n \"$uid\" ]; then launchctl kickstart -k gui/$uid/$agent_label 2>/dev/null || launchctl kickstart -k user/$uid/$agent_label 2>/dev/null || true; fi;" + set load_agent to agent_label_cmd & bootstrap_agent & kickstart_agent + + set sh to "set -e;" & check_source & resolve_uid & unload_agent & unload_service & kill_others & copy_files & write_daemon_plist & write_agent_plist & load_service & load_agent + + do shell script sh with prompt "RustDesk wants to update itself" with administrator privileges +end run diff --git a/src/platform/windows.cc b/src/platform/windows.cc index 9ee3c1f5c..9027d9d89 100644 --- a/src/platform/windows.cc +++ b/src/platform/windows.cc @@ -1,6 +1,8 @@ #include #include #include +#include +#include #include #include #include @@ -11,6 +13,7 @@ #include #include #include +#include extern "C" uint32_t get_session_user_info(PWSTR bufin, uint32_t nin, uint32_t id); @@ -222,7 +225,12 @@ extern "C" return IsWindowsServer(); } - HANDLE LaunchProcessWin(LPCWSTR cmd, DWORD dwSessionId, BOOL as_user, DWORD *pDwTokenPid) + bool is_windows_10_or_greater() + { + return IsWindows10OrGreater(); + } + + HANDLE LaunchProcessWin(LPCWSTR cmd, DWORD dwSessionId, BOOL as_user, BOOL show, DWORD *pDwTokenPid) { HANDLE hProcess = NULL; HANDLE hToken = NULL; @@ -232,8 +240,13 @@ extern "C" ZeroMemory(&si, sizeof si); si.cb = sizeof si; si.dwFlags = STARTF_USESHOWWINDOW; + if (show) + { + si.lpDesktop = (LPWSTR)L"winsta0\\default"; + si.wShowWindow = SW_SHOW; + } wchar_t buf[MAX_PATH]; - wcscpy_s(buf, sizeof(buf), cmd); + wcscpy_s(buf, MAX_PATH, cmd); PROCESS_INFORMATION pi; LPVOID lpEnvironment = NULL; DWORD dwCreationFlags = DETACHED_PROCESS; @@ -538,6 +551,9 @@ extern "C" DWORD count; auto rdp = "rdp"; auto nrdp = strlen(rdp); + // https://github.com/rustdesk/rustdesk/discussions/937#discussioncomment-12373814 citrix session + auto ica = "ica"; + auto nica = strlen(ica); if (WTSEnumerateSessionsA(WTS_CURRENT_SERVER_HANDLE, NULL, 1, &pInfos, &count)) { for (DWORD i = 0; i < count; i++) @@ -553,7 +569,7 @@ extern "C" WTSFreeMemory(pInfos); return id; } - if (!strnicmp(info.pWinStationName, rdp, nrdp)) + if (!strnicmp(info.pWinStationName, rdp, nrdp) || !strnicmp(info.pWinStationName, ica, nica)) { rdp_or_console = info.SessionId; } @@ -564,6 +580,30 @@ extern "C" return rdp_or_console; } + BOOL is_session_locked(DWORD session_id) + { + if (session_id == 0xFFFFFFFF) { + return FALSE; + } + PWTSINFOEXW pInfo = NULL; + DWORD bytes = 0; + BOOL locked = FALSE; + if (WTSQuerySessionInformationW( + WTS_CURRENT_SERVER_HANDLE, + session_id, + WTSSessionInfoEx, + (LPWSTR *)&pInfo, + &bytes)) { + if (pInfo && pInfo->Level == 1) { + locked = (pInfo->Data.WTSInfoExLevel1.SessionFlags == WTS_SESSIONSTATE_LOCK); + } + if (pInfo) { + WTSFreeMemory(pInfo); + } + } + return locked; + } + uint32_t get_active_user(PWSTR bufin, uint32_t nin, BOOL rdp) { uint32_t nout = 0; @@ -609,6 +649,8 @@ extern "C" auto info = pInfos[i]; auto rdp = "rdp"; auto nrdp = strlen(rdp); + auto ica = "ica"; + auto nica = strlen(ica); if (info.State == WTSActive) { if (info.pWinStationName == NULL) continue; @@ -621,6 +663,9 @@ extern "C" else if (include_rdp && !strnicmp(info.pWinStationName, rdp, nrdp)) { sessionIds.push_back(std::wstring(L"RDP:") + std::to_wstring(info.SessionId)); } + else if (include_rdp && !strnicmp(info.pWinStationName, ica, nica)) { + sessionIds.push_back(std::wstring(L"ICA:") + std::to_wstring(info.SessionId)); + } } } WTSFreeMemory(pInfos); @@ -846,4 +891,168 @@ extern "C" return isRunning; } -} // end of extern "C" \ No newline at end of file +} // end of extern "C" + +// Remote printing +extern "C" +{ +// Dynamic loading of XPS Print functions +typedef HRESULT(WINAPI *StartXpsPrintJobFunc)( + LPCWSTR printerName, + LPCWSTR jobName, + LPCWSTR outputFileName, + HANDLE progressEvent, + HANDLE completionEvent, + UINT8* printablePagesOn, + UINT32 printablePagesOnCount, + IXpsPrintJob** xpsPrintJob, + IXpsPrintJobStream** documentStream, + IXpsPrintJobStream** printTicketStream); + +static HMODULE xpsPrintModule = nullptr; +static StartXpsPrintJobFunc StartXpsPrintJobPtr = nullptr; + +static bool InitXpsPrint() +{ + if (xpsPrintModule == nullptr) + { + xpsPrintModule = LoadLibraryA("XpsPrint.dll"); + if (xpsPrintModule == nullptr) + { + flog("Failed to load XpsPrint.dll. Error: %d\n", GetLastError()); + return false; + } + + StartXpsPrintJobPtr = (StartXpsPrintJobFunc)GetProcAddress(xpsPrintModule, "StartXpsPrintJob"); + if (StartXpsPrintJobPtr == nullptr) + { + flog("Failed to get StartXpsPrintJob function. Error: %d\n", GetLastError()); + FreeLibrary(xpsPrintModule); + xpsPrintModule = nullptr; + return false; + } + } + return true; +} +#pragma warning(push) +#pragma warning(disable : 4995) + +#define PRINT_XPS_CHECK_HR(hr, msg) \ + if (FAILED(hr)) \ + { \ + _com_error err(hr); \ + flog("%s Error: %s\n", msg, err.ErrorMessage()); \ + return -1; \ + } + + int PrintXPSRawData(LPWSTR printerName, BYTE *rawData, ULONG dataSize) + { + // Check if XPS Print DLL is available + if (!InitXpsPrint()) + { + flog("XPS Print functionality not available on this system\n"); + return -1; + } + + BOOL isCoInitializeOk = FALSE; + HRESULT hr = CoInitializeEx(nullptr, COINIT_MULTITHREADED); + if (hr == RPC_E_CHANGED_MODE) + { + hr = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + } + if (hr == S_OK) + { + isCoInitializeOk = TRUE; + } + std::shared_ptr coInitGuard(nullptr, [isCoInitializeOk](int *) { + if (isCoInitializeOk) CoUninitialize(); + }); + + IXpsOMObjectFactory *xpsFactory = nullptr; + hr = CoCreateInstance( + __uuidof(XpsOMObjectFactory), + nullptr, + CLSCTX_INPROC_SERVER, + __uuidof(IXpsOMObjectFactory), + reinterpret_cast(&xpsFactory)); + PRINT_XPS_CHECK_HR(hr, "Failed to create XPS object factory."); + std::shared_ptr xpsFactoryGuard( + xpsFactory, + [](IXpsOMObjectFactory *xpsFactory) { + xpsFactory->Release(); + }); + + HANDLE completionEvent = CreateEvent(nullptr, TRUE, FALSE, nullptr); + if (completionEvent == nullptr) + { + flog("Failed to create completion event. Last error: %d\n", GetLastError()); + return -1; + } + std::shared_ptr completionEventGuard( + &completionEvent, + [](HANDLE *completionEvent) { + CloseHandle(*completionEvent); + }); + + IXpsPrintJob *job = nullptr; + IXpsPrintJobStream *jobStream = nullptr; + // `StartXpsPrintJob()` is deprecated, but we still use it for compatibility. + // We may change to use the `Print Document Package API` in the future. + // https://learn.microsoft.com/en-us/windows/win32/printdocs/xpsprint-functions + hr = StartXpsPrintJobPtr( + printerName, + L"Print Job 1", + nullptr, + nullptr, + completionEvent, + nullptr, + 0, + &job, + &jobStream, + nullptr); + PRINT_XPS_CHECK_HR(hr, "Failed to start XPS print job."); + + std::shared_ptr jobStreamGuard(jobStream, [](IXpsPrintJobStream *jobStream) { + jobStream->Release(); + }); + BOOL jobOk = FALSE; + std::shared_ptr jobGuard(job, [&jobOk](IXpsPrintJob* job) { + if (jobOk == FALSE) + { + job->Cancel(); + } + job->Release(); + }); + + DWORD bytesWritten = 0; + hr = jobStream->Write(rawData, dataSize, &bytesWritten); + PRINT_XPS_CHECK_HR(hr, "Failed to write data to print job stream."); + + hr = jobStream->Close(); + PRINT_XPS_CHECK_HR(hr, "Failed to close print job stream."); + + // Wait about 5 minutes for the print job to complete. + DWORD waitMillis = 300 * 1000; + DWORD waitResult = WaitForSingleObject(completionEvent, waitMillis); + if (waitResult != WAIT_OBJECT_0) + { + flog("Wait for print job completion failed. Last error: %d\n", GetLastError()); + return -1; + } + jobOk = TRUE; + + return 0; + } + + void CleanupXpsPrint() + { + if (xpsPrintModule != nullptr) + { + FreeLibrary(xpsPrintModule); + xpsPrintModule = nullptr; + StartXpsPrintJobPtr = nullptr; + } + } + +#pragma warning(pop) +} diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 4495af538..1dc4a788a 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -13,48 +13,80 @@ use hbb_common::{ libc::{c_int, wchar_t}, log, message_proto::{DisplayInfo, Resolution, WindowsSession}, - sleep, timeout, tokio, + sleep, + sysinfo::{Pid, System}, + timeout, tokio, }; use std::{ collections::HashMap, ffi::{CString, OsString}, - fs, io, - io::prelude::*, + fs, + io::{self, prelude::*}, mem, - os::windows::process::CommandExt, + os::{ + raw::c_ulong, + windows::{ffi::OsStringExt, process::CommandExt}, + }, path::*, - process::{Command, Stdio}, ptr::null_mut, sync::{atomic::Ordering, Arc, Mutex}, time::{Duration, Instant}, }; use wallpaper; +#[cfg(not(debug_assertions))] +use winapi::um::libloaderapi::{LoadLibraryExW, LOAD_LIBRARY_SEARCH_USER_DIRS}; use winapi::{ ctypes::c_void, shared::{minwindef::*, ntdef::NULL, windef::*, winerror::*}, - um::sysinfoapi::{GetNativeSystemInfo, SYSTEM_INFO}, um::{ errhandlingapi::GetLastError, - handleapi::CloseHandle, - libloaderapi::{GetProcAddress, LoadLibraryA}, + handleapi::{CloseHandle, INVALID_HANDLE_VALUE}, + libloaderapi::{ + GetProcAddress, LoadLibraryA, LoadLibraryExA, LOAD_LIBRARY_SEARCH_SYSTEM32, + }, minwinbase::STILL_ACTIVE, processthreadsapi::{ GetCurrentProcess, GetCurrentProcessId, GetExitCodeProcess, OpenProcess, OpenProcessToken, ProcessIdToSessionId, PROCESS_INFORMATION, STARTUPINFOW, }, - securitybaseapi::GetTokenInformation, + securitybaseapi::{ + AllocateAndInitializeSid, DuplicateToken, EqualSid, FreeSid, GetTokenInformation, + }, shellapi::ShellExecuteW, + sysinfoapi::{GetNativeSystemInfo, SYSTEM_INFO}, winbase::*, wingdi::*, winnt::{ - TokenElevation, ES_AWAYMODE_REQUIRED, ES_CONTINUOUS, ES_DISPLAY_REQUIRED, + SecurityImpersonation, TokenElevation, TokenGroups, TokenImpersonation, TokenType, + DOMAIN_ALIAS_RID_ADMINS, ES_AWAYMODE_REQUIRED, ES_CONTINUOUS, ES_DISPLAY_REQUIRED, ES_SYSTEM_REQUIRED, HANDLE, PROCESS_ALL_ACCESS, PROCESS_QUERY_LIMITED_INFORMATION, - TOKEN_ELEVATION, TOKEN_QUERY, + PSID, SECURITY_BUILTIN_DOMAIN_RID, SECURITY_NT_AUTHORITY, SID_IDENTIFIER_AUTHORITY, + TOKEN_ELEVATION, TOKEN_GROUPS, TOKEN_QUERY, TOKEN_TYPE, }, winreg::HKEY_CURRENT_USER, + winspool::{ + EnumPrintersW, GetDefaultPrinterW, PRINTER_ENUM_CONNECTIONS, PRINTER_ENUM_LOCAL, + PRINTER_INFO_1W, + }, winuser::*, }, }; +use windows::Win32::{ + Foundation::{CloseHandle as WinCloseHandle, HANDLE as WinHANDLE}, + Security::{ + GetTokenInformation as WinGetTokenInformation, IsWellKnownSid, TokenUser, + WinLocalSystemSid, TOKEN_QUERY as WIN_TOKEN_QUERY, TOKEN_USER, + }, + System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, + TH32CS_SNAPPROCESS, + }, + System::Threading::{ + OpenProcess as WinOpenProcess, OpenProcessToken as WinOpenProcessToken, + QueryFullProcessImageNameW as WinQueryFullProcessImageNameW, + PROCESS_QUERY_LIMITED_INFORMATION as WIN_PROCESS_QUERY_LIMITED_INFORMATION, + }, +}; use windows_service::{ define_windows_service, service::{ @@ -65,12 +97,21 @@ use windows_service::{ }; use winreg::{enums::*, RegKey}; +mod acl; +pub(crate) use acl::current_process_user_sid_string; +pub use acl::{ + set_path_permission, set_path_permission_for_portable_service_shmem_dir, + set_path_permission_for_portable_service_shmem_file, + validate_path_for_portable_service_shmem_dir, +}; + pub const FLUTTER_RUNNER_WIN32_WINDOW_CLASS: &'static str = "FLUTTER_RUNNER_WIN32_WINDOW"; // main window, install window pub const EXPLORER_EXE: &'static str = "explorer.exe"; 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"; pub fn get_focused_display(displays: Vec) -> Option { unsafe { @@ -83,21 +124,56 @@ pub fn get_focused_display(displays: Vec) -> Option { let center_x = rect.left + (rect.right - rect.left) / 2; let center_y = rect.top + (rect.bottom - rect.top) / 2; center_x >= display.x - && center_x <= display.x + display.width + && center_x < display.x + display.width && center_y >= display.y - && center_y <= display.y + display.height + && center_y < display.y + display.height }) } } pub fn get_cursor_pos() -> Option<(i32, i32)> { unsafe { - #[allow(invalid_value)] - let mut out = mem::MaybeUninit::uninit().assume_init(); - if GetCursorPos(&mut out) == FALSE { + let mut out = mem::MaybeUninit::::uninit(); + if GetCursorPos(out.as_mut_ptr()) == FALSE { return None; } - return Some((out.x, out.y)); + let out = out.assume_init(); + Some((out.x, out.y)) + } +} + +pub fn set_cursor_pos(x: i32, y: i32) -> bool { + unsafe { + if SetCursorPos(x, y) == FALSE { + let err = GetLastError(); + log::warn!("SetCursorPos failed: x={}, y={}, error_code={}", x, y, err); + return false; + } + true + } +} + +/// Clip cursor to a rectangle. Pass None to unclip. +pub fn clip_cursor(rect: Option<(i32, i32, i32, i32)>) -> bool { + unsafe { + let result = match rect { + Some((left, top, right, bottom)) => { + let r = RECT { + left, + top, + right, + bottom, + }; + ClipCursor(&r) + } + None => ClipCursor(std::ptr::null()), + }; + if result == FALSE { + let err = GetLastError(); + log::warn!("ClipCursor failed: rect={:?}, error_code={}", rect, err); + return false; + } + true } } @@ -464,10 +540,12 @@ const SERVICE_TYPE: ServiceType = ServiceType::OWN_PROCESS; extern "C" { fn get_current_session(rdp: BOOL) -> DWORD; + fn is_session_locked(session_id: DWORD) -> BOOL; fn LaunchProcessWin( cmd: *const u16, session_id: DWORD, as_user: BOOL, + show: BOOL, token_pid: &mut DWORD, ) -> HANDLE; fn GetSessionUserTokenWin( @@ -479,6 +557,7 @@ extern "C" { fn selectInputDesktop() -> BOOL; fn inputDesktopSelected() -> BOOL; fn is_windows_server() -> BOOL; + fn is_windows_10_or_greater() -> BOOL; fn handleMask( out: *mut u8, mask: *const u8, @@ -499,6 +578,60 @@ extern "C" { fn is_service_running_w(svc_name: *const u16) -> bool; } +pub fn get_current_session_id(share_rdp: bool) -> DWORD { + unsafe { get_current_session(if share_rdp { TRUE } else { FALSE }) } +} + +#[inline] +fn resolve_expected_active_session_id_for_service(session_id: u32) -> Option { + let share_rdp_enabled = is_share_rdp(); + if get_available_sessions(false) + .iter() + .any(|e| e.sid == session_id) + { + return Some(session_id); + } + let current_active_session = + unsafe { get_current_session(if share_rdp_enabled { TRUE } else { FALSE }) }; + if current_active_session == u32::MAX { + None + } else { + Some(current_active_session) + } +} + +#[inline] +fn authorize_service_scoped_ipc_connection( + stream: &ipc::Connection, + expected_active_session_id: Option, +) -> bool { + let (authorized, peer_pid, peer_session_id, peer_is_system) = + stream.service_authorization_status_for_session(expected_active_session_id); + if !authorized { + ipc::log_rejected_windows_ipc_connection( + crate::POSTFIX_SERVICE, + peer_pid, + peer_session_id, + expected_active_session_id, + peer_is_system, + None, + ); + return false; + } + if let Err(err) = + ipc::ensure_peer_executable_matches_current_by_pid_opt(peer_pid, crate::POSTFIX_SERVICE) + { + log::warn!( + "Rejected unauthorized connection on protected service-scoped IPC channel due to executable mismatch: postfix={}, peer_pid={:?}, err={}", + crate::POSTFIX_SERVICE, + peer_pid, + err + ); + return false; + } + true +} + extern "system" { fn BlockInput(v: BOOL) -> BOOL; } @@ -553,7 +686,11 @@ async fn run_service(_arguments: Vec) -> ResultType<()> { let current_active_session = unsafe { get_current_session(share_rdp()) }; if session_id != current_active_session { session_id = current_active_session; - h_process = launch_server(session_id, true).await.unwrap_or(NULL); + // https://github.com/rustdesk/rustdesk/discussions/10039 + let count = ipc::get_port_forward_session_count(1000).await.unwrap_or(0); + if count == 0 { + h_process = launch_server(session_id, true).await.unwrap_or(NULL); + } } } let res = timeout(super::SERVICE_INTERVAL, incoming.next()).await; @@ -561,6 +698,15 @@ async fn run_service(_arguments: Vec) -> ResultType<()> { Ok(res) => match res { Some(Ok(stream)) => { let mut stream = ipc::Connection::new(stream); + // Keep IPC authorization consistent with the session we are currently serving. + // Recompute expected session right before authorization to avoid using a stale + // session_id after awaiting incoming.next(). + let expected_active_session_id = + resolve_expected_active_session_id_for_service(session_id); + if !authorize_service_scoped_ipc_connection(&stream, expected_active_session_id) + { + continue; + } if let Ok(Some(data)) = stream.next_timeout(1000).await { match data { ipc::Data::Close => { @@ -602,8 +748,11 @@ async fn run_service(_arguments: Vec) -> ResultType<()> { if tmp != session_id && stored_usid != Some(session_id) { log::info!("session changed from {} to {}", session_id, tmp); session_id = tmp; - send_close_async("").await.ok(); - close_sent = true; + let count = ipc::get_port_forward_session_count(1000).await.unwrap_or(0); + if count == 0 { + send_close_async("").await.ok(); + close_sent = true; + } } let mut exit_code: DWORD = 0; if h_process.is_null() @@ -652,6 +801,10 @@ async fn launch_server(session_id: DWORD, close_first: bool) -> ResultType ResultType { use std::os::windows::ffi::OsStrExt; let wstr: Vec = std::ffi::OsStr::new(&cmd) .encode_wide() @@ -659,9 +812,12 @@ async fn launch_server(session_id: DWORD, close_first: bool) -> ResultType ResultType) -> ResultType> { - let cmd = format!( - "\"{}\" {}", - std::env::current_exe()?.to_str().unwrap_or(""), - arg.join(" "), - ); - let Some(session_id) = get_current_process_session_id() else { - bail!("Failed to get current process session id"); - }; + run_exe_in_cur_session(std::env::current_exe()?.to_str().unwrap_or(""), arg, false) +} + +pub fn run_exe_direct( + exe: &str, + arg: Vec<&str>, + show: bool, +) -> ResultType> { + let mut cmd = std::process::Command::new(exe); + for a in arg { + cmd.arg(a); + } + if !show { + cmd.creation_flags(CREATE_NO_WINDOW); + } + match cmd.spawn() { + Ok(child) => Ok(Some(child)), + Err(e) => bail!("Failed to start process: {}", e), + } +} + +pub fn run_exe_in_cur_session( + exe: &str, + arg: Vec<&str>, + show: bool, +) -> ResultType> { + if is_root() { + let Some(session_id) = get_current_process_session_id() else { + bail!("Failed to get current process session id"); + }; + run_exe_in_session(exe, arg, session_id, show) + } else { + run_exe_direct(exe, arg, show) + } +} + +pub fn run_exe_in_session( + exe: &str, + arg: Vec<&str>, + session_id: DWORD, + show: bool, +) -> ResultType> { use std::os::windows::ffi::OsStrExt; + let cmd = format!("\"{}\" {}", exe, arg.join(" "),); let wstr: Vec = std::ffi::OsStr::new(&cmd) .encode_wide() .chain(Some(0).into_iter()) .collect(); let wstr = wstr.as_ptr(); let mut token_pid = 0; - let h = unsafe { LaunchProcessWin(wstr, session_id, TRUE, &mut token_pid) }; + let h = unsafe { + LaunchProcessWin( + wstr, + session_id, + TRUE, + if show { TRUE } else { FALSE }, + &mut token_pid, + ) + }; if h.is_null() { if token_pid == 0 { bail!( @@ -729,7 +928,79 @@ pub fn send_sas() { } unsafe { log::info!("SAS received"); + + // Check and temporarily set SoftwareSASGeneration if needed + let mut original_value: Option = None; + let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); + + if let Ok(policy_key) = hklm.open_subkey_with_flags( + "Software\\Microsoft\\Windows\\CurrentVersion\\Policies\\System", + KEY_READ | KEY_WRITE, + ) { + // Read current value + match policy_key.get_value::("SoftwareSASGeneration") { + Ok(value) => { + /* + - 0 = None (disabled) + - 1 = Services + - 2 = Ease of Access applications + - 3 = Services and Ease of Access applications (Both) + */ + if value != 1 && value != 3 { + original_value = Some(value); + log::info!("SoftwareSASGeneration is {}, setting to 1", value); + // Set to 1 for SendSAS to work + if let Err(e) = policy_key.set_value("SoftwareSASGeneration", &1u32) { + log::error!("Failed to set SoftwareSASGeneration: {}", e); + } + } + } + Err(e) => { + log::info!( + "SoftwareSASGeneration not found or error reading: {}, setting to 1", + e + ); + original_value = Some(0); // Mark that we need to restore (delete) it + // Create and set to 1 + if let Err(e) = policy_key.set_value("SoftwareSASGeneration", &1u32) { + log::error!("Failed to set SoftwareSASGeneration: {}", e); + } + } + } + } else { + log::error!("Failed to open registry key for SoftwareSASGeneration"); + } + + // Send SAS SendSAS(FALSE); + + // Restore original value if we changed it + if let Some(original) = original_value { + if let Ok(policy_key) = hklm.open_subkey_with_flags( + "Software\\Microsoft\\Windows\\CurrentVersion\\Policies\\System", + KEY_WRITE, + ) { + if original == 0 { + // It didn't exist before, delete it + if let Err(e) = policy_key.delete_value("SoftwareSASGeneration") { + log::error!("Failed to delete SoftwareSASGeneration: {}", e); + } else { + log::info!("Deleted SoftwareSASGeneration (restored to original state)"); + } + } else { + // Restore the original value + if let Err(e) = policy_key.set_value("SoftwareSASGeneration", &original) { + log::error!( + "Failed to restore SoftwareSASGeneration to {}: {}", + original, + e + ); + } else { + log::info!("Restored SoftwareSASGeneration to {}", original); + } + } + } + } } } @@ -783,8 +1054,12 @@ pub fn set_share_rdp(enable: bool) { } pub fn get_current_process_session_id() -> Option { + get_session_id_of_process(unsafe { GetCurrentProcessId() }) +} + +pub fn get_session_id_of_process(pid: DWORD) -> Option { let mut sid = 0; - if unsafe { ProcessIdToSessionId(GetCurrentProcessId(), &mut sid) == TRUE } { + if unsafe { ProcessIdToSessionId(pid, &mut sid) == TRUE } { Some(sid) } else { None @@ -942,6 +1217,22 @@ pub fn get_active_user_home() -> Option { None } +#[cfg(not(feature = "flutter"))] +#[inline] +pub fn portable_service_logon_helper_paths() -> Option<(PathBuf, PathBuf)> { + // Keep parity with history for now: derive LocalAppData from user profile path. + // If users report redirected/non-standard LocalAppData issues, switch to: + // `BaseDirs::new()?.data_local_dir()` for Known Folder-based resolution. + let user_dir = hbb_common::directories_next::UserDirs::new()?; + let dir = user_dir + .home_dir() + .join("AppData") + .join("Local") + .join("rustdesk-sciter"); + let dst = dir.join("rustdesk.exe"); + Some((dir, dst)) +} + pub fn is_prelogin() -> bool { let Some(username) = get_current_session_username() else { return false; @@ -949,6 +1240,24 @@ pub fn is_prelogin() -> bool { username.is_empty() || username == "SYSTEM" } +pub fn is_locked() -> bool { + let Some(session_id) = get_current_process_session_id() else { + return false; + }; + unsafe { is_session_locked(session_id) == TRUE } +} + +#[inline] +pub fn is_logon_ui() -> ResultType { + let Some(current_sid) = get_current_process_session_id() else { + return Ok(false); + }; + let pids = get_pids("LogonUI.exe")?; + Ok(pids + .into_iter() + .any(|pid| get_session_id_of_process(pid) == Some(current_sid))) +} + pub fn is_root() -> bool { // https://stackoverflow.com/questions/4023586/correct-way-to-find-out-if-a-service-is-running-as-the-system-user unsafe { is_local_system() == TRUE } @@ -1008,6 +1317,10 @@ pub fn get_install_options() -> String { if let Some(start_menu_shortcuts) = start_menu_shortcuts { opts.insert(REG_NAME_INSTALL_STARTMENUSHORTCUTS, start_menu_shortcuts); } + let printer = get_reg_of_hkcr(&subkey, REG_NAME_INSTALL_PRINTER); + if let Some(printer) = printer { + opts.insert(REG_NAME_INSTALL_PRINTER, printer); + } serde_json::to_string(&opts).unwrap_or("{}".to_owned()) } @@ -1129,10 +1442,43 @@ pub fn copy_exe_cmd(src_exe: &str, exe: &str, path: &str) -> ResultType )) } +#[inline] +pub fn rename_exe_cmd(src_exe: &str, path: &str) -> ResultType { + let src_exe_filename = PathBuf::from(src_exe) + .file_name() + .ok_or(anyhow!("Can't get file name of {src_exe}"))? + .to_string_lossy() + .to_string(); + let app_name = crate::get_app_name().to_lowercase(); + if src_exe_filename.to_lowercase() == format!("{app_name}.exe") { + Ok("".to_owned()) + } else { + Ok(format!( + " + move /Y \"{path}\\{src_exe_filename}\" \"{path}\\{app_name}.exe\" + ", + )) + } +} + +#[inline] +pub fn remove_meta_toml_cmd(is_msi: bool, path: &str) -> String { + if is_msi && crate::is_custom_client() { + format!( + " + del /F /Q \"{path}\\meta.toml\" + ", + ) + } else { + "".to_owned() + } +} + fn get_after_install( exe: &str, reg_value_start_menu_shortcuts: Option, reg_value_desktop_shortcuts: Option, + reg_value_printer: Option, ) -> String { let app_name = crate::get_app_name(); let ext = app_name.to_lowercase(); @@ -1140,7 +1486,7 @@ fn get_after_install( // reg delete HKEY_CURRENT_USER\Software\Classes for // https://github.com/rustdesk/rustdesk/commit/f4bdfb6936ae4804fc8ab1cf560db192622ad01a // and https://github.com/leanflutter/uni_links_desktop/blob/1b72b0226cec9943ca8a84e244c149773f384e46/lib/src/protocol_registrar_impl_windows.dart#L30 - let hcu = winreg::RegKey::predef(HKEY_CURRENT_USER); + let hcu = RegKey::predef(HKEY_CURRENT_USER); hcu.delete_subkey_all(format!("Software\\Classes\\{}", exe)) .ok(); @@ -1156,12 +1502,20 @@ fn get_after_install( ) }) .unwrap_or_default(); + let reg_printer = reg_value_printer + .map(|v| { + format!( + "reg add HKEY_CLASSES_ROOT\\.{ext} /f /v {REG_NAME_INSTALL_PRINTER} /t REG_SZ /d \"{v}\"" + ) + }) + .unwrap_or_default(); format!(" chcp 65001 reg add HKEY_CLASSES_ROOT\\.{ext} /f {desktop_shortcuts} {start_menu_shortcuts} + {reg_printer} reg add HKEY_CLASSES_ROOT\\.{ext}\\DefaultIcon /f reg add HKEY_CLASSES_ROOT\\.{ext}\\DefaultIcon /f /ve /t REG_SZ /d \"\\\"{exe}\\\",0\" reg add HKEY_CLASSES_ROOT\\.{ext}\\shell /f @@ -1182,7 +1536,7 @@ fn get_after_install( } pub fn install_me(options: &str, path: String, silent: bool, debug: bool) -> ResultType<()> { - let uninstall_str = get_uninstall(false); + 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; @@ -1206,7 +1560,11 @@ pub fn install_me(options: &str, path: String, silent: bool, debug: bool) -> Res } let app_name = crate::get_app_name(); + let current_exe = std::env::current_exe()?; + let tmp_path = std::env::temp_dir().to_string_lossy().to_string(); + let cur_exe = current_exe.to_str().unwrap_or("").to_owned(); + let shortcut_icon_location = get_shortcut_icon_location(&path, &cur_exe); let mk_shortcut = write_cmds( format!( " @@ -1215,6 +1573,7 @@ sLinkFile = \"{tmp_path}\\{app_name}.lnk\" Set oLink = oWS.CreateShortcut(sLinkFile) oLink.TargetPath = \"{exe}\" + {shortcut_icon_location} oLink.Save " ), @@ -1243,9 +1602,10 @@ oLink.Save .to_str() .unwrap_or("") .to_owned(); - let tray_shortcut = get_tray_shortcut(&exe, &tmp_path)?; + let tray_shortcut = get_tray_shortcut(&path, &exe, &cur_exe, &tmp_path)?; let mut reg_value_desktop_shortcuts = "0".to_owned(); let mut reg_value_start_menu_shortcuts = "0".to_owned(); + let mut reg_value_printer = "0".to_owned(); let mut shortcuts = Default::default(); if options.contains("desktopicon") { shortcuts = format!( @@ -1265,9 +1625,18 @@ copy /Y \"{tmp_path}\\Uninstall {app_name}.lnk\" \"{start_menu}\\\" ); reg_value_start_menu_shortcuts = "1".to_owned(); } + let install_printer = options.contains("printer") && is_win_10_or_greater(); + if install_printer { + reg_value_printer = "1".to_owned(); + } - let meta = std::fs::symlink_metadata(std::env::current_exe()?)?; - let size = meta.len() / 1024; + let meta = std::fs::symlink_metadata(¤t_exe)?; + let mut size = meta.len() / 1024; + if let Some(parent_dir) = current_exe.parent() { + if let Some(d) = parent_dir.to_str() { + size = get_directory_size_kb(d); + } + } // https://docs.microsoft.com/zh-cn/windows/win32/msi/uninstall-registry-key?redirectedfrom=MSDNa // https://www.windowscentral.com/how-edit-registry-using-command-prompt-windows-10 // https://www.tenforums.com/tutorials/70903-add-remove-allowed-apps-through-windows-firewall-windows-10-a.html @@ -1300,6 +1669,19 @@ copy /Y \"{tmp_path}\\{app_name} Tray.lnk\" \"%PROGRAMDATA%\\Microsoft\\Windows\ ") }; + let install_remote_printer = if install_printer { + // No need to use `|| true` here. + // The script will not exit even if `--install-remote-printer` panics. + format!("\"{}\" --install-remote-printer", &src_exe) + } else if is_win_10_or_greater() { + format!("\"{}\" --uninstall-remote-printer", &src_exe) + } else { + "".to_owned() + }; + + // Remember to check if `update_me` need to be changed if changing the `cmds`. + // No need to merge the existing dup code, because the code in these two functions are too critical. + // New code should be written in a common function. let cmds = format!( " {uninstall_str} @@ -1307,7 +1689,7 @@ chcp 65001 md \"{path}\" {copy_exe} reg add {subkey} /f -reg add {subkey} /f /v DisplayIcon /t REG_SZ /d \"{exe}\" +reg add {subkey} /f /v DisplayIcon /t REG_SZ /d \"{display_icon}\" reg add {subkey} /f /v DisplayName /t REG_SZ /d \"{app_name}\" reg add {subkey} /f /v DisplayVersion /t REG_SZ /d \"{version}\" reg add {subkey} /f /v Version /t REG_SZ /d \"{version}\" @@ -1328,14 +1710,17 @@ copy /Y \"{tmp_path}\\Uninstall {app_name}.lnk\" \"{path}\\\" {dels} {import_config} {after_install} +{install_remote_printer} {sleep} ", + display_icon = get_custom_icon(&path, &cur_exe).unwrap_or(exe.to_string()), version = crate::VERSION.replace("-", "."), build_date = crate::BUILD_DATE, after_install = get_after_install( &exe, Some(reg_value_start_menu_shortcuts), - Some(reg_value_desktop_shortcuts) + Some(reg_value_desktop_shortcuts), + Some(reg_value_printer) ), sleep = if debug { "timeout 300" } else { "" }, dels = if debug { "" } else { &dels }, @@ -1349,7 +1734,11 @@ copy /Y \"{tmp_path}\\Uninstall {app_name}.lnk\" \"{path}\\\" pub fn run_after_install() -> ResultType<()> { let (_, _, _, exe) = get_install_info(); - run_cmds(get_after_install(&exe, None, None), true, "after_install") + run_cmds( + get_after_install(&exe, None, None, None), + true, + "after_install", + ) } pub fn run_before_uninstall() -> ResultType<()> { @@ -1379,22 +1768,39 @@ fn get_before_uninstall(kill_self: bool) -> String { ) } -fn get_uninstall(kill_self: bool) -> String { +/// Constructs the uninstall command string for the application. +/// +/// # Parameters +/// - `kill_self`: The command will kill the process of current app name. If `true`, it will kill +/// the current process as well. If `false`, it will exclude the current process from the kill +/// command. +/// - `uninstall_printer`: If `true`, includes commands to uninstall the remote printer. +/// +/// # Details +/// 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; } let mut uninstall_cert_cmd = "".to_string(); + let mut uninstall_printer_cmd = "".to_string(); if let Ok(exe) = std::env::current_exe() { if let Some(exe_path) = exe.to_str() { uninstall_cert_cmd = format!("\"{}\" --uninstall-cert", exe_path); + if uninstall_printer { + uninstall_printer_cmd = format!("\"{}\" --uninstall-remote-printer", &exe_path); + } } } let (subkey, path, start_menu, _) = get_install_info(); format!( " {before_uninstall} + {uninstall_printer_cmd} {uninstall_cert_cmd} reg delete {subkey} /f {uninstall_amyuni_idd} @@ -1410,7 +1816,7 @@ fn get_uninstall(kill_self: bool) -> String { } pub fn uninstall_me(kill_self: bool) -> ResultType<()> { - run_cmds(get_uninstall(kill_self), true, "uninstall") + run_cmds(get_uninstall(kill_self, true), true, "uninstall") } fn write_cmds(cmds: String, ext: &str, tip: &str) -> ResultType { @@ -1459,15 +1865,13 @@ fn to_le(v: &mut [u16]) -> &[u8] { unsafe { v.align_to().1 } } -fn get_undone_file(tmp: &PathBuf) -> ResultType { - let mut tmp1 = tmp.clone(); - tmp1.set_file_name(format!( +fn get_undone_file(tmp: &Path) -> ResultType { + Ok(tmp.with_file_name(format!( "{}.undone", tmp.file_name() .ok_or(anyhow!("Failed to get filename of {:?}", tmp))? .to_string_lossy() - )); - Ok(tmp1) + ))) } fn run_cmds(cmds: String, show: bool, tip: &str) -> ResultType<()> { @@ -1545,6 +1949,163 @@ fn get_reg_of(subkey: &str, name: &str) -> String { "".to_owned() } +fn get_public_base_dir() -> PathBuf { + if let Ok(allusersprofile) = std::env::var("ALLUSERSPROFILE") { + let path = PathBuf::from(&allusersprofile); + if path.exists() { + return path; + } + } + if let Ok(public) = std::env::var("PUBLIC") { + let path = PathBuf::from(public).join("Documents"); + if path.exists() { + return path; + } + } + let program_data_dir = PathBuf::from("C:\\ProgramData"); + if program_data_dir.exists() { + return program_data_dir; + } + std::env::temp_dir() +} + +#[inline] +pub fn get_custom_client_staging_dir() -> PathBuf { + get_public_base_dir() + .join("RustDesk") + .join("RustDeskCustomClientStaging") +} + +/// Removes the custom client staging directory. +/// +/// Current behavior: intentionally a no-op (does not delete). +/// +/// Rationale +/// - The staging directory only contains a small `custom.txt`, leaving it is harmless. +/// - Deleting directories under a public location (e.g., C:\\ProgramData\\RustDesk) is +/// susceptible to TOCTOU attacks if an unprivileged user can replace the path with a +/// symlink/junction between checks and deletion. +/// +/// Future work: +/// - Use the files (if needed) in the installation directory instead of a public location. +/// This directory only contains a small `custom.txt` file. +/// - Pass the custom client name directly via command line +/// or environment variable during update installation. Then no staging directory is needed. +#[inline] +pub fn remove_custom_client_staging_dir(staging_dir: &Path) -> ResultType { + if !staging_dir.exists() { + return Ok(false); + } + + // First explicitly removes `custom.txt` to ensure stale config is never replayed, + // even if the subsequent directory removal fails. + // + // `std::fs::remove_file` on a symlink removes the symlink itself, not the target, + // so this is safe even in a TOCTOU race. + let custom_txt_path = staging_dir.join("custom.txt"); + if custom_txt_path.exists() { + allow_err!(std::fs::remove_file(&custom_txt_path)); + } + + // Intentionally not deleting. See the function docs for rationale. + log::debug!( + "Skip deleting staging directory {:?} (intentional to avoid TOCTOU)", + staging_dir + ); + Ok(false) +} + +// Prepare custom client update by copying staged custom.txt to current directory and loading it. +// Returns: +// 1. Ok(true) if preparation was successful or no staging directory exists. +// 2. Ok(false) if custom.txt file exists but has invalid contents or fails security checks +// (e.g., is a symlink or has invalid contents). +// 3. Err if any unexpected error occurs during file operations. +pub fn prepare_custom_client_update() -> ResultType { + let custom_client_staging_dir = get_custom_client_staging_dir(); + let current_exe = std::env::current_exe()?; + let current_exe_dir = current_exe + .parent() + .ok_or(anyhow!("Cannot get parent directory of current exe"))?; + + let staging_dir = custom_client_staging_dir.clone(); + let clear_staging_on_exit = crate::SimpleCallOnReturn { + b: true, + f: Box::new( + move || match remove_custom_client_staging_dir(&staging_dir) { + Ok(existed) => { + if existed { + log::info!("Custom client staging directory removed successfully."); + } + } + Err(e) => { + log::error!( + "Failed to remove custom client staging directory {:?}: {}", + staging_dir, + e + ); + } + }, + ), + }; + + if custom_client_staging_dir.exists() { + let custom_txt_path = custom_client_staging_dir.join("custom.txt"); + if !custom_txt_path.exists() { + return Ok(true); + } + + let metadata = std::fs::symlink_metadata(&custom_txt_path)?; + if metadata.is_symlink() { + log::error!( + "custom.txt is a symlink. Refusing to load custom client for security reasons." + ); + drop(clear_staging_on_exit); + return Ok(false); + } + if metadata.is_file() { + // Copy custom.txt to current directory + let local_custom_file_path = current_exe_dir.join("custom.txt"); + log::debug!( + "Copying staged custom file from {:?} to {:?}", + custom_txt_path, + local_custom_file_path + ); + + // No need to check symlink before copying. + // `load_custom_client()` will fail if the file is not valid. + fs::copy(&custom_txt_path, &local_custom_file_path)?; + log::info!("Staged custom client file copied to current directory."); + + // Load custom client + let is_custom_file_exists = + local_custom_file_path.exists() && local_custom_file_path.is_file(); + crate::load_custom_client(); + + // Remove the copied custom.txt file + allow_err!(fs::remove_file(&local_custom_file_path)); + + // Check if loaded successfully + if is_custom_file_exists && !crate::common::is_custom_client() { + // The custom.txt file existed, but its contents are invalid. + log::error!("Failed to load custom client from custom.txt."); + drop(clear_staging_on_exit); + // ERROR_INVALID_DATA + return Ok(false); + } + } else { + log::info!("No custom client files found in staging directory."); + } + } else { + log::info!( + "Custom client staging directory {:?} does not exist.", + custom_client_staging_dir + ); + } + + Ok(true) +} + pub fn get_license_from_exe_name() -> ResultType { let mut exe = std::env::current_exe()?.to_str().unwrap_or("").to_owned(); // if defined portable appname entry, replace original executable name with it. @@ -1554,29 +2115,169 @@ pub fn get_license_from_exe_name() -> ResultType { get_custom_server_from_string(&exe) } +// We can't directly use `RegKey::set_value` to update the registry value, because it will fail with `ERROR_ACCESS_DENIED` +// So we have to use `run_cmds` to update the registry value. +pub fn update_install_option(k: &str, v: &str) -> ResultType<()> { + // Don't update registry if not installed or not server process. + if !is_installed() || !crate::is_server() { + return Ok(()); + } + if ![REG_NAME_INSTALL_PRINTER].contains(&k) || !["0", "1"].contains(&v) { + return Ok(()); + } + let app_name = crate::get_app_name(); + let ext = app_name.to_lowercase(); + let cmds = + format!("chcp 65001 && reg add HKEY_CLASSES_ROOT\\.{ext} /f /v {k} /t REG_SZ /d \"{v}\""); + run_cmds(cmds, false, "update_install_option")?; + Ok(()) +} + #[inline] pub fn is_win_server() -> bool { unsafe { is_windows_server() > 0 } } -pub fn bootstrap() { +#[inline] +pub fn is_win_10_or_greater() -> bool { + unsafe { is_windows_10_or_greater() > 0 } +} + +pub fn bootstrap() -> bool { if let Ok(lic) = get_license_from_exe_name() { *config::EXE_RENDEZVOUS_SERVER.write().unwrap() = lic.host.clone(); } + + #[cfg(debug_assertions)] + { + true + } + #[cfg(not(debug_assertions))] + { + // This function will cause `'sciter.dll' was not found neither in PATH nor near the current executable.` when debugging RustDesk. + // Only call set_safe_load_dll() on Windows 10 or greater + if is_win_10_or_greater() { + set_safe_load_dll() + } else { + true + } + } +} + +#[cfg(not(debug_assertions))] +fn set_safe_load_dll() -> bool { + if !unsafe { set_default_dll_directories() } { + return false; + } + + // `SetDllDirectoryW` should never fail. + // https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-setdlldirectoryw + if unsafe { SetDllDirectoryW(wide_string("").as_ptr()) == FALSE } { + eprintln!("SetDllDirectoryW failed: {}", io::Error::last_os_error()); + return false; + } + + true +} + +// https://docs.microsoft.com/en-us/windows/win32/api/libloaderapi/nf-libloaderapi-setdefaultdlldirectories +#[cfg(not(debug_assertions))] +unsafe fn set_default_dll_directories() -> bool { + let module = LoadLibraryExW( + wide_string("Kernel32.dll").as_ptr(), + 0 as _, + LOAD_LIBRARY_SEARCH_SYSTEM32, + ); + if module.is_null() { + return false; + } + + match CString::new("SetDefaultDllDirectories") { + Err(e) => { + eprintln!("CString::new failed: {}", e); + return false; + } + Ok(func_name) => { + let func = GetProcAddress(module, func_name.as_ptr()); + if func.is_null() { + eprintln!("GetProcAddress failed: {}", io::Error::last_os_error()); + return false; + } + type SetDefaultDllDirectories = unsafe extern "system" fn(DWORD) -> BOOL; + let func: SetDefaultDllDirectories = std::mem::transmute(func); + if func(LOAD_LIBRARY_SEARCH_SYSTEM32 | LOAD_LIBRARY_SEARCH_USER_DIRS) == FALSE { + eprintln!( + "SetDefaultDllDirectories failed: {}", + io::Error::last_os_error() + ); + return false; + } + } + } + true +} + +fn get_custom_icon(install_dir: &str, exe: &str) -> Option { + const RELATIVE_ICON_PATH: &str = "data\\flutter_assets\\assets\\icon.ico"; + if crate::is_custom_client() { + if let Some(p) = PathBuf::from(exe).parent() { + let alter_icon_path = p.join(RELATIVE_ICON_PATH); + if alter_icon_path.exists() { + // During installation, files under `install_dir` may not exist yet. + // So we validate the icon from the current executable directory first. + // But for shortcut/registry icon location, we should point to the final + // installed path so the icon works across different Windows users. + if let Ok(metadata) = std::fs::symlink_metadata(&alter_icon_path) { + if metadata.is_symlink() { + log::warn!( + "Custom icon at {:?} is a symlink, refusing to use it.", + alter_icon_path + ); + return None; + } + if metadata.is_file() { + return if install_dir.is_empty() { + Some(alter_icon_path.to_string_lossy().to_string()) + } else { + Some(format!("{}\\{}", install_dir, RELATIVE_ICON_PATH)) + }; + } + } + } + } + } + None +} + +#[inline] +fn get_shortcut_icon_location(install_dir: &str, exe: &str) -> String { + if exe.is_empty() { + return "".to_owned(); + } + + get_custom_icon(install_dir, exe) + .map(|p| format!("oLink.IconLocation = \"{}\"", p)) + .unwrap_or_default() } pub fn create_shortcut(id: &str) -> ResultType<()> { let exe = std::env::current_exe()?.to_str().unwrap_or("").to_owned(); + // https://github.com/rustdesk/rustdesk/issues/13735 + // Replace ':' with '_' for filename since ':' is not allowed in Windows filenames + // https://github.com/rustdesk/hbb_common/blob/8b0e25867375ba9e6bff548acf44fe6d6ffa7c0e/src/config.rs#L1384 + let filename = id.replace(':', "_"); + let shortcut_icon_location = get_shortcut_icon_location("", &exe); let shortcut = write_cmds( format!( " Set oWS = WScript.CreateObject(\"WScript.Shell\") strDesktop = oWS.SpecialFolders(\"Desktop\") Set objFSO = CreateObject(\"Scripting.FileSystemObject\") -sLinkFile = objFSO.BuildPath(strDesktop, \"{id}.lnk\") +sLinkFile = objFSO.BuildPath(strDesktop, \"{filename}.lnk\") Set oLink = oWS.CreateShortcut(sLinkFile) oLink.TargetPath = \"{exe}\" oLink.Arguments = \"--connect {id}\" + {shortcut_icon_location} oLink.Save " ), @@ -1588,6 +2289,7 @@ oLink.Save .to_owned(); std::process::Command::new("cscript") .arg(&shortcut) + .creation_flags(CREATE_NO_WINDOW) .output()?; allow_err!(std::fs::remove_file(shortcut)); Ok(()) @@ -1717,16 +2419,33 @@ pub fn elevate_or_run_as_system(is_setup: bool, is_elevate: bool, is_run_as_syst is_run_as_system, crate::username(), ); - let arg_elevate = if is_setup { + let mut arg_elevate = if is_setup { "--noinstall --elevate" } else { "--elevate" - }; - let arg_run_as_system = if is_setup { + } + .to_owned(); + let mut arg_run_as_system = if is_setup { "--noinstall --run-as-system" } else { "--run-as-system" - }; + } + .to_owned(); + let shmem_name_from_args = crate::portable_service::portable_service_shmem_name_from_args(); + if shmem_name_from_args.is_none() && crate::portable_service::has_portable_service_shmem_arg() { + log::error!("Invalid portable service shared memory argument, aborting elevation flow"); + // This is a malformed bootstrap argument in a privilege-sensitive path. + // Keep fail-closed process termination here to avoid continuing elevation + // with inconsistent shared-memory contract. + std::process::exit(1); + } + if let Some(shmem_name) = shmem_name_from_args { + let shmem_arg = crate::portable_service::portable_service_shmem_arg(&shmem_name); + arg_elevate.push(' '); + arg_elevate.push_str(&shmem_arg); + arg_run_as_system.push(' '); + arg_run_as_system.push_str(&shmem_arg); + } if is_root() { if is_run_as_system { log::info!("run portable service"); @@ -1737,7 +2456,7 @@ pub fn elevate_or_run_as_system(is_setup: bool, is_elevate: bool, is_run_as_syst Ok(elevated) => { if elevated { if !is_run_as_system { - if run_as_system(arg_run_as_system).is_ok() { + if run_as_system(arg_run_as_system.as_str()).is_ok() { std::process::exit(0); } else { log::error!( @@ -1748,7 +2467,7 @@ pub fn elevate_or_run_as_system(is_setup: bool, is_elevate: bool, is_run_as_syst } } else { if !is_elevate { - if let Ok(true) = elevate(arg_elevate) { + if let Ok(true) = elevate(arg_elevate.as_str()) { std::process::exit(0); } else { log::error!("Failed to elevate, error {}", io::Error::last_os_error()); @@ -1806,6 +2525,115 @@ pub fn is_elevated(process_id: Option) -> ResultType { } } +#[inline] +unsafe fn read_token_user_buffer(token: WinHANDLE, subject: &str) -> ResultType> { + let mut token_user_size = 0u32; + let get_info_result = WinGetTokenInformation(token, TokenUser, None, 0, &mut token_user_size); + match get_info_result { + Ok(()) => { + if token_user_size == 0 { + bail!( + "Failed to get {} token user size: unexpected zero buffer size", + subject + ); + } + } + Err(e) => { + // Allow expected size-probe failures if Windows still returns required size. + let is_insufficient_buffer = + e.code() == windows::core::HRESULT::from_win32(ERROR_INSUFFICIENT_BUFFER as u32); + let is_bad_length = + e.code() == windows::core::HRESULT::from_win32(ERROR_BAD_LENGTH as u32); + if (!is_insufficient_buffer && !is_bad_length) || token_user_size == 0 { + bail!("Failed to get {} token user size: {}", subject, e); + } + } + } + + let mut buffer = vec![0u8; token_user_size as usize]; + WinGetTokenInformation( + token, + TokenUser, + Some(buffer.as_mut_ptr() as *mut core::ffi::c_void), + token_user_size, + &mut token_user_size, + ) + .map_err(|e| anyhow!("Failed to get {} token user: {}", subject, e))?; + + let min_size = std::mem::size_of::(); + if buffer.len() < min_size { + bail!( + "Failed to parse {} token user: buffer too small (got {}, need >= {})", + subject, + buffer.len(), + min_size + ); + } + Ok(buffer) +} + +/// Similar to `is_root()` / `is_local_system()` but for an arbitrary process. +/// +/// Returns `true` if the target process is running as LocalSystem (SID: S-1-5-18). +/// +/// TODO: After a few releases of real-world validation, consider replacing +/// the legacy `is_local_system()` with this implementation. +pub fn is_process_running_as_system(process_id: DWORD) -> ResultType { + unsafe { + let process = WinOpenProcess(WIN_PROCESS_QUERY_LIMITED_INFORMATION, false, process_id) + .map_err(|e| anyhow!("Failed to open process {}: {}", process_id, e))?; + + let mut token = WinHANDLE::default(); + let result = (|| -> ResultType { + WinOpenProcessToken(process, WIN_TOKEN_QUERY, &mut token) + .map_err(|e| anyhow!("Failed to open process {} token: {}", process_id, e))?; + + let token_subject = format!("process {}", process_id); + let buffer = read_token_user_buffer(token, token_subject.as_str())?; + let token_user: TOKEN_USER = + std::ptr::read_unaligned(buffer.as_ptr() as *const TOKEN_USER); + Ok(IsWellKnownSid(token_user.User.Sid, WinLocalSystemSid).as_bool()) + })(); + + if !token.is_invalid() { + let _ = WinCloseHandle(token); + } + let _ = WinCloseHandle(process); + result + } +} + +pub fn get_process_executable_path(process_id: DWORD) -> ResultType { + const PROCESS_IMAGE_PATH_BUFFER_LEN: usize = 32 * 1024; + unsafe { + let process = WinOpenProcess(WIN_PROCESS_QUERY_LIMITED_INFORMATION, false, process_id) + .map_err(|e| anyhow!("Failed to open process {}: {}", process_id, e))?; + + let result = (|| -> ResultType { + let mut buffer = vec![0u16; PROCESS_IMAGE_PATH_BUFFER_LEN]; + let mut length = PROCESS_IMAGE_PATH_BUFFER_LEN as u32; + WinQueryFullProcessImageNameW( + process, + windows::Win32::System::Threading::PROCESS_NAME_FORMAT(0), + windows::core::PWSTR(buffer.as_mut_ptr()), + &mut length, + ) + .map_err(|e| anyhow!("Failed to query process {} image path: {}", process_id, e))?; + if length == 0 { + bail!( + "Failed to query process {} image path: empty result", + process_id + ); + } + buffer.truncate(length as usize); + Ok(PathBuf::from(OsString::from_wide(&buffer))) + })(); + + let _ = WinCloseHandle(process); + result + } +} + pub fn is_foreground_window_elevated() -> ResultType { unsafe { let mut process_id: DWORD = 0; @@ -1872,6 +2700,177 @@ pub fn send_message_to_hnwd( return true; } +pub fn get_logon_user_token(user: &str, pwd: &str) -> ResultType { + let user_split = user.split("\\").collect::>(); + let wuser = wide_string(user_split.get(1).unwrap_or(&user)); + let wpc = wide_string(user_split.get(0).unwrap_or(&"")); + let wpwd = wide_string(pwd); + let mut ph_token: HANDLE = std::ptr::null_mut(); + let res = unsafe { + LogonUserW( + wuser.as_ptr(), + wpc.as_ptr(), + wpwd.as_ptr(), + LOGON32_LOGON_INTERACTIVE, + LOGON32_PROVIDER_DEFAULT, + &mut ph_token as _, + ) + }; + if res == FALSE { + bail!( + "Failed to log on user {}: {}", + user, + std::io::Error::last_os_error() + ); + } else { + if ph_token.is_null() { + bail!( + "Failed to log on user {}: {}", + user, + std::io::Error::last_os_error() + ); + } + Ok(ph_token) + } +} + +// Ensure the token returned is a primary token. +// If the provided token is an impersonation token, it duplicates it to a primary token. +// If the provided token is already a primary token, it returns it as is. +// The caller is responsible for closing the returned token handle. +pub fn ensure_primary_token(user_token: HANDLE) -> ResultType { + if user_token.is_null() || user_token == INVALID_HANDLE_VALUE { + bail!("Invalid user token provided"); + } + + unsafe { + let mut token_type: TOKEN_TYPE = 0; + let mut return_length: DWORD = 0; + + if GetTokenInformation( + user_token, + TokenType, + &mut token_type as *mut _ as *mut _, + std::mem::size_of::() as DWORD, + &mut return_length, + ) == FALSE + { + bail!( + "Failed to get token type, error {}", + io::Error::last_os_error() + ); + } + + if token_type == TokenImpersonation { + let mut duplicate_token: HANDLE = std::ptr::null_mut(); + let dup_res = DuplicateToken(user_token, SecurityImpersonation, &mut duplicate_token); + CloseHandle(user_token); + if dup_res == FALSE { + bail!( + "Failed to duplicate token, error {}", + io::Error::last_os_error() + ); + } + Ok(duplicate_token) + } else { + Ok(user_token) + } + } +} + +pub fn is_user_token_admin(user_token: HANDLE) -> ResultType { + if user_token.is_null() || user_token == INVALID_HANDLE_VALUE { + bail!("Invalid user token provided"); + } + + unsafe { + let mut dw_size: DWORD = 0; + GetTokenInformation( + user_token, + TokenGroups, + std::ptr::null_mut(), + 0, + &mut dw_size, + ); + + let last_error = GetLastError(); + if last_error != ERROR_INSUFFICIENT_BUFFER { + bail!( + "Failed to get token groups buffer size, error: {}", + last_error + ); + } + if dw_size == 0 { + bail!("Token groups buffer size is zero"); + } + + let mut buffer = vec![0u8; dw_size as usize]; + if GetTokenInformation( + user_token, + TokenGroups, + buffer.as_mut_ptr() as *mut _, + dw_size, + &mut dw_size, + ) == FALSE + { + bail!( + "Failed to get token groups information, error: {}", + io::Error::last_os_error() + ); + } + + let p_token_groups = buffer.as_ptr() as *const TOKEN_GROUPS; + let group_count = (*p_token_groups).GroupCount; + + if group_count == 0 { + return Ok(false); + } + + let mut nt_authority: SID_IDENTIFIER_AUTHORITY = SID_IDENTIFIER_AUTHORITY { + Value: SECURITY_NT_AUTHORITY, + }; + let mut administrators_group: PSID = std::ptr::null_mut(); + if AllocateAndInitializeSid( + &mut nt_authority, + 2, + SECURITY_BUILTIN_DOMAIN_RID, + DOMAIN_ALIAS_RID_ADMINS, + 0, + 0, + 0, + 0, + 0, + 0, + &mut administrators_group, + ) == FALSE + { + bail!( + "Failed to allocate administrators group SID, error: {}", + io::Error::last_os_error() + ); + } + if administrators_group.is_null() { + bail!("Failed to create administrators group SID"); + } + + let mut is_admin = false; + let groups = + std::slice::from_raw_parts((*p_token_groups).Groups.as_ptr(), group_count as usize); + for group in groups { + if EqualSid(administrators_group, group.Sid) == TRUE { + is_admin = true; + break; + } + } + + if !administrators_group.is_null() { + FreeSid(administrators_group); + } + + Ok(is_admin) + } +} + pub fn create_process_with_logon(user: &str, pwd: &str, exe: &str, arg: &str) -> ResultType<()> { let last_error_table = HashMap::from([ ( @@ -1927,16 +2926,6 @@ pub fn create_process_with_logon(user: &str, pwd: &str, exe: &str, arg: &str) -> return Ok(()); } -pub fn set_path_permission(dir: &PathBuf, permission: &str) -> ResultType<()> { - std::process::Command::new("icacls") - .arg(dir.as_os_str()) - .arg("/grant") - .arg(format!("*S-1-1-0:(OI)(CI){}", permission)) - .arg("/T") - .spawn()?; - Ok(()) -} - #[inline] fn str_to_device_name(name: &str) -> [u16; 32] { let mut device_name: Vec = wide_string(name); @@ -2033,7 +3022,7 @@ pub fn user_accessible_folder() -> ResultType { } else if dir2.exists() { dir = dir2; } else { - bail!("no vaild user accessible folder"); + bail!("no valid user accessible folder"); } Ok(dir) } @@ -2180,9 +3169,9 @@ pub fn uninstall_service(show_new_window: bool, _: bool) -> bool { pub fn install_service() -> bool { log::info!("Installing service..."); let _installing = crate::platform::InstallingService::new(); - let (_, _, _, exe) = get_install_info(); + let (_, path, _, exe) = get_install_info(); let tmp_path = std::env::temp_dir().to_string_lossy().to_string(); - let tray_shortcut = get_tray_shortcut(&exe, &tmp_path).unwrap_or_default(); + let tray_shortcut = get_tray_shortcut(&path, &exe, &exe, &tmp_path).unwrap_or_default(); let filter = format!(" /FI \"PID ne {}\"", get_current_pid()); Config::set_option("stop-service".into(), "".into()); crate::ipc::EXIT_RECV_CLOSE.store(false, Ordering::Relaxed); @@ -2210,7 +3199,452 @@ if exist \"{tray_shortcut}\" del /f /q \"{tray_shortcut}\" std::process::exit(0); } -pub fn get_tray_shortcut(exe: &str, tmp_path: &str) -> ResultType { +/// Calculate the total size of a directory in KB +/// Does not follow symlinks to prevent directory traversal attacks. +fn get_directory_size_kb(path: &str) -> u64 { + let mut total_size = 0u64; + let mut stack = vec![PathBuf::from(path)]; + + while let Some(current_path) = stack.pop() { + let entries = match std::fs::read_dir(¤t_path) { + Ok(entries) => entries, + Err(_) => continue, + }; + + for entry in entries { + let entry = match entry { + Ok(entry) => entry, + Err(_) => continue, + }; + + let metadata = match std::fs::symlink_metadata(entry.path()) { + Ok(metadata) => metadata, + Err(_) => continue, + }; + + if metadata.is_symlink() { + continue; + } + + if metadata.is_dir() { + stack.push(entry.path()); + } else { + total_size = total_size.saturating_add(metadata.len()); + } + } + } + + total_size / 1024 +} + +pub fn update_me(debug: bool) -> ResultType<()> { + let app_name = crate::get_app_name(); + let src_exe = std::env::current_exe()?.to_string_lossy().to_string(); + let (subkey, path, _, exe) = get_install_info(); + let is_installed = std::fs::metadata(&exe).is_ok(); + if !is_installed { + bail!("{} is not installed.", &app_name); + } + + let app_exe_name = &format!("{}.exe", &app_name); + let main_window_pids = + crate::platform::get_pids_of_process_with_args::<_, &str>(&app_exe_name, &[]); + let main_window_sessions = main_window_pids + .iter() + .map(|pid| get_session_id_of_process(pid.as_u32())) + .flatten() + .collect::>(); + kill_process_by_pids(&app_exe_name, main_window_pids)?; + let tray_pids = crate::platform::get_pids_of_process_with_args(&app_exe_name, &["--tray"]); + let tray_sessions = tray_pids + .iter() + .map(|pid| get_session_id_of_process(pid.as_u32())) + .flatten() + .collect::>(); + kill_process_by_pids(&app_exe_name, tray_pids)?; + let is_service_running = is_self_service_running(); + + let mut version_major = "0"; + let mut version_minor = "0"; + let mut version_build = "0"; + let versions: Vec<&str> = crate::VERSION.split(".").collect(); + if versions.len() > 0 { + version_major = versions[0]; + } + if versions.len() > 1 { + version_minor = versions[1]; + } + if versions.len() > 2 { + version_build = versions[2]; + } + let version = crate::VERSION.replace("-", "."); + let size = get_directory_size_kb(&path); + let build_date = crate::BUILD_DATE; + // 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, + display_icon: &str, + version: &str, + build_date: &str, + version_major: &str, + version_minor: &str, + version_build: &str, + size: u64, + ) -> String { + let reg_display_icon = if is_msi.unwrap_or(false) { + "".to_string() + } else { + format!( + "reg add {} /f /v DisplayIcon /t REG_SZ /d \"{}\"", + subkey, display_icon + ) + }; + format!( + " +{reg_display_icon} +reg add {subkey} /f /v DisplayVersion /t REG_SZ /d \"{version}\" +reg add {subkey} /f /v Version /t REG_SZ /d \"{version}\" +reg add {subkey} /f /v BuildDate /t REG_SZ /d \"{build_date}\" +reg add {subkey} /f /v VersionMajor /t REG_DWORD /d {version_major} +reg add {subkey} /f /v VersionMinor /t REG_DWORD /d {version_minor} +reg add {subkey} /f /v VersionBuild /t REG_DWORD /d {version_build} +reg add {subkey} /f /v EstimatedSize /t REG_DWORD /d {size} + " + ) + } + + let reg_cmd = { + let reg_cmd_main = get_reg_cmd( + &subkey, + is_msi, + &display_icon, + &version, + &build_date, + &version_major, + &version_minor, + &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, + ) + } else { + "".to_owned() + }; + format!("{}{}", reg_cmd_main, reg_cmd_msi) + }; + + let filter = format!(" /FI \"PID ne {}\"", get_current_pid()); + let restore_service_cmd = if is_service_running { + format!("sc start {}", &app_name) + } else { + "".to_owned() + }; + + // No need to check the install option here, `is_rd_printer_installed` rarely fails. + let is_printer_installed = remote_printer::is_rd_printer_installed(&app_name).unwrap_or(false); + // Do nothing if the printer is not installed or failed to query if the printer is installed. + let (uninstall_printer_cmd, install_printer_cmd) = if is_printer_installed { + ( + format!("\"{}\" --uninstall-remote-printer", &src_exe), + format!("\"{}\" --install-remote-printer", &src_exe), + ) + } else { + ("".to_owned(), "".to_owned()) + }; + + // We do not try to remove all files in the old version. + // Because I don't know whether additional files will be installed here after installation, such as drivers. + // Just copy files to the installation directory works fine. + //if exist \"{path}\" rd /s /q \"{path}\" + // md \"{path}\" + // + // We need `taskkill` because: + // 1. There may be some other processes like `rustdesk --connect` are running. + // 2. Sometimes, the main window and the tray icon are showing + // while I cannot find them by `tasklist` or the methods above. + // There's should be 4 processes running: service, server, tray and main window. + // But only 2 processes are shown in the tasklist. + let cmds = format!( + " +chcp 65001 +sc stop {app_name} +taskkill /F /IM {app_name}.exe{filter} +{reg_cmd} +{copy_exe} +{rename_exe} +{remove_meta_toml} +{restore_service_cmd} +{uninstall_printer_cmd} +{install_printer_cmd} +{sleep} + ", + app_name = app_name, + copy_exe = copy_exe_cmd(&src_exe, &exe, &path)?, + rename_exe = rename_exe_cmd(&src_exe, &path)?, + remove_meta_toml = remove_meta_toml_cmd(is_msi.unwrap_or(true), &path), + sleep = if debug { "timeout 300" } else { "" }, + ); + + let _restore_session_guard = crate::common::SimpleCallOnReturn { + b: true, + f: Box::new(move || { + let is_root = is_root(); + if tray_sessions.is_empty() { + log::info!("No tray process found."); + } else { + log::info!( + "Try to restore the tray process..., sessions: {:?}", + &tray_sessions + ); + // When not running as root, only spawn once since run_exe_direct + // doesn't target specific sessions. + let mut spawned_non_root_tray = false; + for s in tray_sessions.clone().into_iter() { + if s != 0 { + // We need to check if is_root here because if `update_me()` is called from + // the main window running with administrator permission, + // `run_exe_in_session()` will fail with error 1314 ("A required privilege is + // not held by the client"). + // + // This issue primarily affects the MSI-installed version running in Administrator + // session during testing, but we check permissions here to be safe. + if is_root { + allow_err!(run_exe_in_session(&exe, vec!["--tray"], s, true)); + } else if !spawned_non_root_tray { + // Only spawn once for non-root since run_exe_direct doesn't take session parameter + allow_err!(run_exe_direct(&exe, vec!["--tray"], false)); + spawned_non_root_tray = true; + } + } + } + } + if main_window_sessions.is_empty() { + log::info!("No main window process found."); + } else { + log::info!("Try to restore the main window process..."); + std::thread::sleep(std::time::Duration::from_millis(2000)); + // When not running as root, only spawn once since run_exe_direct + // doesn't target specific sessions. + let mut spawned_non_root_main = false; + for s in main_window_sessions.clone().into_iter() { + if s != 0 { + if is_root { + allow_err!(run_exe_in_session(&exe, vec![], s, true)); + } else if !spawned_non_root_main { + // Only spawn once for non-root since run_exe_direct doesn't take session parameter + allow_err!(run_exe_direct(&exe, vec![], false)); + spawned_non_root_main = true; + } + } + } + } + std::thread::sleep(std::time::Duration::from_millis(300)); + }), + }; + + run_cmds(cmds, debug, "update")?; + + std::thread::sleep(std::time::Duration::from_millis(2000)); + log::info!("Update completed."); + + Ok(()) +} + +fn get_reg_msi_key(subkey: &str, is_msi: Option) -> Option { + // 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; + } + + // 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) +} + +// Double confirm the process name +fn kill_process_by_pids(name: &str, pids: Vec) -> ResultType<()> { + let name = name.to_lowercase(); + let s = System::new_all(); + // No need to check all names of `pids` first, and kill them then. + // It's rare case that they're not matched. + for pid in pids { + if let Some(process) = s.process(pid) { + if process.name().to_lowercase() != name { + bail!("Failed to kill the process, the names are mismatched."); + } + if !process.kill() { + bail!("Failed to kill the process"); + } + } else { + bail!("Failed to kill the process, the pid is not found"); + } + } + Ok(()) +} + +pub fn handle_custom_client_staging_dir_before_update( + custom_client_staging_dir: &PathBuf, +) -> ResultType<()> { + let Some(current_exe_dir) = std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|p| p.to_path_buf())) + else { + bail!("Failed to get current exe directory"); + }; + + // Clean up existing staging directory + if custom_client_staging_dir.exists() { + log::debug!( + "Removing existing custom client staging directory: {:?}", + custom_client_staging_dir + ); + if let Err(e) = remove_custom_client_staging_dir(custom_client_staging_dir) { + bail!( + "Failed to remove existing custom client staging directory {:?}: {}", + custom_client_staging_dir, + e + ); + } + } + + let src_path = current_exe_dir.join("custom.txt"); + if src_path.exists() { + // Verify that custom.txt is not a symlink before copying + let metadata = match std::fs::symlink_metadata(&src_path) { + Ok(m) => m, + Err(e) => { + bail!( + "Failed to read metadata for custom.txt at {:?}: {}", + src_path, + e + ); + } + }; + + if metadata.is_symlink() { + allow_err!(remove_custom_client_staging_dir(&custom_client_staging_dir)); + bail!( + "custom.txt at {:?} is a symlink, refusing to stage for security reasons.", + src_path + ); + } + + if metadata.is_file() { + if !custom_client_staging_dir.exists() { + if let Err(e) = std::fs::create_dir_all(custom_client_staging_dir) { + bail!("Failed to create parent directory {:?} when staging custom client files: {}", custom_client_staging_dir, e); + } + } + let dst_path = custom_client_staging_dir.join("custom.txt"); + if let Err(e) = std::fs::copy(&src_path, &dst_path) { + allow_err!(remove_custom_client_staging_dir(&custom_client_staging_dir)); + bail!( + "Failed to copy custom txt from {:?} to {:?}: {}", + src_path, + dst_path, + e + ); + } + } else { + log::warn!( + "custom.txt at {:?} is not a regular file, skipping.", + src_path + ); + } + } else { + log::info!("No custom txt found to stage for update."); + } + + Ok(()) +} + +// Used for auto update and manual update in the main window. +pub fn update_to(file: &str) -> ResultType<()> { + if file.ends_with(".exe") { + let custom_client_staging_dir = get_custom_client_staging_dir(); + if crate::is_custom_client() { + handle_custom_client_staging_dir_before_update(&custom_client_staging_dir)?; + } else { + // Clean up any residual staging directory from previous custom client + allow_err!(remove_custom_client_staging_dir(&custom_client_staging_dir)); + } + if !run_uac(file, "--update")? { + bail!( + "Failed to run the update exe with UAC, error: {:?}", + std::io::Error::last_os_error() + ); + } + } else if file.ends_with(".msi") { + if let Err(e) = update_me_msi(file, false) { + bail!("Failed to run the update msi: {}", e); + } + } else { + // unreachable!() + bail!("Unsupported update file format: {}", file); + } + Ok(()) +} + +// Don't launch tray app when running with `\qn`. +// 1. Because `/qn` requires administrator permission and the tray app should be launched with user permission. +// Or launching the main window from the tray app will cause the main window to be launched with administrator permission. +// 2. We are not able to launch the tray app if the UI is in the login screen. +// `fn update_me()` can handle the above cases, but for msi update, we need to do more work to handle the above cases. +// 1. Record the tray app session ids. +// 2. Do the update. +// 3. Restore the tray app sessions. +// `1` and `3` must be done in custom actions. +// We need also to handle the command line parsing to find the tray processes. +pub fn update_me_msi(msi: &str, quiet: bool) -> ResultType<()> { + let cmds = format!( + "chcp 65001 && msiexec /i {msi} {}", + if quiet { "/qn LAUNCH_TRAY_APP=N" } else { "" } + ); + run_cmds(cmds, false, "update-msi")?; + Ok(()) +} + +pub fn get_tray_shortcut( + install_dir: &str, + exe: &str, + icon_source_exe: &str, + tmp_path: &str, +) -> ResultType { + let shortcut_icon_location = get_shortcut_icon_location(install_dir, icon_source_exe); Ok(write_cmds( format!( " @@ -2220,6 +3654,7 @@ sLinkFile = \"{tmp_path}\\{app_name} Tray.lnk\" Set oLink = oWS.CreateShortcut(sLinkFile) oLink.TargetPath = \"{exe}\" oLink.Arguments = \"--tray\" + {shortcut_icon_location} oLink.Save ", app_name = crate::get_app_name(), @@ -2282,6 +3717,44 @@ fn run_after_run_cmds(silent: bool) { std::thread::sleep(std::time::Duration::from_millis(300)); } +#[inline] +pub fn try_remove_temp_update_files() { + let temp_dir = std::env::temp_dir(); + let Ok(entries) = std::fs::read_dir(&temp_dir) else { + log::debug!("Failed to read temp directory: {:?}", temp_dir); + return; + }; + + let one_hour = std::time::Duration::from_secs(60 * 60); + for entry in entries { + if let Ok(entry) = entry { + let path = entry.path(); + if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) { + // Match files like rustdesk-*.msi or rustdesk-*.exe + if file_name.starts_with("rustdesk-") + && (file_name.ends_with(".msi") || file_name.ends_with(".exe")) + { + // Skip files modified within the last hour to avoid deleting files being downloaded + if let Ok(metadata) = std::fs::metadata(&path) { + if let Ok(modified) = metadata.modified() { + if let Ok(elapsed) = modified.elapsed() { + if elapsed < one_hour { + continue; + } + } + } + } + if let Err(e) = std::fs::remove_file(&path) { + log::debug!("Failed to remove temp update file {:?}: {}", path, e); + } else { + log::info!("Removed temp update file: {:?}", path); + } + } + } + } + } +} + #[inline] pub fn try_kill_broker() { allow_err!(std::process::Command::new("cmd") @@ -2294,23 +3767,6 @@ pub fn try_kill_broker() { .spawn()); } -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn test_uninstall_cert() { - println!("uninstall driver certs: {:?}", cert::uninstall_cert()); - } - - #[test] - fn test_get_unicode_char_by_vk() { - let chr = get_char_from_vk(0x41); // VK_A - assert_eq!(chr, Some('a')); - let chr = get_char_from_vk(VK_ESCAPE as u32); // VK_ESC - assert_eq!(chr, None) - } -} - pub fn message_box(text: &str) { let mut text = text.to_owned(); let nodialog = std::env::var("NO_DIALOG").unwrap_or_default() == "Y"; @@ -2474,7 +3930,8 @@ pub fn is_x64() -> bool { pub fn try_kill_rustdesk_main_window_process() -> ResultType<()> { // Kill rustdesk.exe without extra arg, should only be called by --server // We can find the exact process which occupies the ipc, see more from https://github.com/winsiderss/systeminformer - log::info!("try kill rustdesk main window process"); + let app_name = crate::get_app_name().to_lowercase(); + log::info!("try kill main window process"); use hbb_common::sysinfo::System; let mut sys = System::new(); sys.refresh_processes(); @@ -2483,7 +3940,6 @@ pub fn try_kill_rustdesk_main_window_process() -> ResultType<()> { .map(|x| x.user_id()) .unwrap_or_default(); let my_pid = std::process::id(); - let app_name = crate::get_app_name().to_lowercase(); if app_name.is_empty() { bail!("app name is empty"); } @@ -2526,7 +3982,15 @@ pub fn try_kill_rustdesk_main_window_process() -> ResultType<()> { fn nt_terminate_process(process_id: DWORD) -> ResultType<()> { type NtTerminateProcess = unsafe extern "system" fn(HANDLE, DWORD) -> DWORD; unsafe { - let h_module = LoadLibraryA(CString::new("ntdll.dll")?.as_ptr()); + let h_module = if is_win_10_or_greater() { + LoadLibraryExA( + CString::new("ntdll.dll")?.as_ptr(), + std::ptr::null_mut(), + LOAD_LIBRARY_SEARCH_SYSTEM32, + ) + } else { + LoadLibraryA(CString::new("ntdll.dll")?.as_ptr()) + }; if !h_module.is_null() { let f_nt_terminate_process: NtTerminateProcess = std::mem::transmute(GetProcAddress( h_module, @@ -2627,16 +4091,21 @@ pub mod reg_display_settings { None } - pub fn restore_reg_connectivity(reg_recovery: RegRecovery) -> ResultType<()> { + pub fn restore_reg_connectivity(reg_recovery: RegRecovery, force: bool) -> ResultType<()> { let hklm = winreg::RegKey::predef(HKEY_LOCAL_MACHINE); let reg_item = hklm.open_subkey_with_flags(®_recovery.path, KEY_READ | KEY_WRITE)?; - let cur_reg_value = reg_item.get_raw_value(®_recovery.key)?; - let new_reg_value = RegValue { - bytes: reg_recovery.new.0, - vtype: isize_to_reg_type(reg_recovery.new.1), - }; - if cur_reg_value != new_reg_value { - return Ok(()); + if !force { + let cur_reg_value = reg_item.get_raw_value(®_recovery.key)?; + let new_reg_value = RegValue { + bytes: reg_recovery.new.0, + vtype: isize_to_reg_type(reg_recovery.new.1), + }; + // Compare if the current value is the same as the new value. + // If they are not the same, the registry value has been changed by other processes. + // So we do not restore the registry value. + if cur_reg_value != new_reg_value { + return Ok(()); + } } let reg_value = RegValue { bytes: reg_recovery.old.0, @@ -2665,3 +4134,540 @@ pub mod reg_display_settings { } } } + +pub fn get_printer_names() -> ResultType> { + let mut needed_bytes = 0; + let mut returned_count = 0; + + unsafe { + // First call to get required buffer size + EnumPrintersW( + PRINTER_ENUM_LOCAL | PRINTER_ENUM_CONNECTIONS, + std::ptr::null_mut(), + 1, + std::ptr::null_mut(), + 0, + &mut needed_bytes, + &mut returned_count, + ); + + let mut buffer = vec![0u8; needed_bytes as usize]; + + if EnumPrintersW( + PRINTER_ENUM_LOCAL | PRINTER_ENUM_CONNECTIONS, + std::ptr::null_mut(), + 1, + buffer.as_mut_ptr() as *mut _, + needed_bytes, + &mut needed_bytes, + &mut returned_count, + ) == 0 + { + return Err(anyhow!("Failed to enumerate printers")); + } + + let ptr = buffer.as_ptr() as *const PRINTER_INFO_1W; + let printers = std::slice::from_raw_parts(ptr, returned_count as usize); + + Ok(printers + .iter() + .filter_map(|p| { + let name = p.pName; + if !name.is_null() { + let mut len = 0; + while len < 500 { + if name.add(len).is_null() || *name.add(len) == 0 { + break; + } + len += 1; + } + if len > 0 && len < 500 { + Some(String::from_utf16_lossy(std::slice::from_raw_parts( + name, len, + ))) + } else { + None + } + } else { + None + } + }) + .collect()) + } +} + +extern "C" { + fn PrintXPSRawData(printer_name: *const u16, raw_data: *const u8, data_size: c_ulong) -> DWORD; +} + +pub fn send_raw_data_to_printer(printer_name: Option, data: Vec) -> ResultType<()> { + let mut printer_name = printer_name.unwrap_or_default(); + if printer_name.is_empty() { + // use GetDefaultPrinter to get the default printer name + let mut needed_bytes = 0; + unsafe { + GetDefaultPrinterW(std::ptr::null_mut(), &mut needed_bytes); + } + if needed_bytes > 0 { + let mut default_printer_name = vec![0u16; needed_bytes as usize]; + unsafe { + GetDefaultPrinterW( + default_printer_name.as_mut_ptr() as *mut _, + &mut needed_bytes, + ); + } + printer_name = String::from_utf16_lossy(&default_printer_name[..needed_bytes as usize]); + } + } else { + if let Ok(names) = crate::platform::windows::get_printer_names() { + if !names.contains(&printer_name) { + // Don't set the first printer as current printer. + // It may not be the desired printer. + bail!("Printer name \"{}\" not found", &printer_name); + } + } + } + if printer_name.is_empty() { + return Err(anyhow!("Failed to get printer name")); + } + + log::info!("Sending data to printer: {}", &printer_name); + let printer_name = wide_string(&printer_name); + unsafe { + let res = PrintXPSRawData( + printer_name.as_ptr(), + data.as_ptr() as *const u8, + data.len() as c_ulong, + ); + if res != 0 { + bail!("Failed to send data to the printer, see logs in C:\\Windows\\temp\\test_rustdesk.log for more details."); + } else { + log::info!("Successfully sent data to the printer"); + } + } + + Ok(()) +} + +fn get_pids>(name: S) -> ResultType> { + let name = name.as_ref().to_lowercase(); + let mut pids = Vec::new(); + + unsafe { + let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)?; + if snapshot == WinHANDLE::default() { + return Ok(pids); + } + + let mut entry: PROCESSENTRY32W = std::mem::zeroed(); + entry.dwSize = std::mem::size_of::() as u32; + + if Process32FirstW(snapshot, &mut entry).is_ok() { + loop { + let proc_name = OsString::from_wide(&entry.szExeFile) + .to_string_lossy() + .to_lowercase(); + + if proc_name.contains(&name) { + pids.push(entry.th32ProcessID); + } + + if !Process32NextW(snapshot, &mut entry).is_ok() { + break; + } + } + } + + let _ = WinCloseHandle(snapshot); + } + + Ok(pids) +} + +pub fn is_msi_installed() -> std::io::Result { + 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")?) +} + +pub fn is_cur_exe_the_installed() -> bool { + let (_, _, _, exe) = get_install_info(); + // Check if is installed, because `exe` is the default path if is not installed. + if !std::fs::metadata(&exe).is_ok() { + return false; + } + let mut path = std::env::current_exe().unwrap_or_default(); + if let Ok(linked) = path.read_link() { + path = linked; + } + let path = path.to_string_lossy().to_lowercase(); + path == exe.to_lowercase() +} + +#[cfg(not(target_pointer_width = "64"))] +pub fn get_pids_with_first_arg_check_session, S2: AsRef>( + name: S1, + arg: S2, + same_session_id: bool, +) -> ResultType> { + // Though `wmic` can return the sessionId, for simplicity we only return processid. + let pids = get_pids_with_first_arg_by_wmic(name, arg); + if !same_session_id { + return Ok(pids); + } + let Some(cur_sid) = get_current_process_session_id() else { + bail!("Can't get current process session id"); + }; + let mut same_session_pids = vec![]; + for pid in pids.into_iter() { + let mut sid = 0; + if unsafe { ProcessIdToSessionId(pid.as_u32(), &mut sid) == TRUE } { + if sid == cur_sid { + same_session_pids.push(pid); + } + } else { + // Only log here, because this call almost never fails. + log::warn!( + "Failed to get session id of the process id, error: {:?}", + std::io::Error::last_os_error() + ); + } + } + Ok(same_session_pids) +} + +#[cfg(not(target_pointer_width = "64"))] +fn get_pids_with_args_from_wmic_output>( + output: std::borrow::Cow<'_, str>, + name: &str, + args: &[S2], +) -> Vec { + // CommandLine= + // ProcessId=33796 + // + // CommandLine= + // ProcessId=34668 + // + // CommandLine="C:\Program Files\RustDesk\RustDesk.exe" --tray + // ProcessId=13728 + // + // CommandLine="C:\Program Files\RustDesk\RustDesk.exe" + // ProcessId=10136 + let mut pids = Vec::new(); + let mut proc_found = false; + for line in output.lines() { + if line.starts_with("ProcessId=") { + if proc_found { + if let Ok(pid) = line["ProcessId=".len()..].trim().parse::() { + pids.push(hbb_common::sysinfo::Pid::from_u32(pid)); + } + proc_found = false; + } + } else if line.starts_with("CommandLine=") { + proc_found = false; + let cmd = line["CommandLine=".len()..].trim().to_lowercase(); + if args.is_empty() { + if cmd.ends_with(&name) || cmd.ends_with(&format!("{}\"", &name)) { + proc_found = true; + } + } else { + proc_found = args.iter().all(|arg| cmd.contains(arg.as_ref())); + } + } + } + pids +} + +// Note the args are not compared strictly, only check if the args are contained in the command line. +// If we want to check the args strictly, we need to parse the command line and compare each arg. +// Maybe we have to introduce some external crate like `shell_words` to do this. +#[cfg(not(target_pointer_width = "64"))] +pub(super) fn get_pids_with_args_by_wmic, S2: AsRef>( + name: S1, + args: &[S2], +) -> Vec { + let name = name.as_ref().to_lowercase(); + std::process::Command::new("wmic.exe") + .args([ + "process", + "where", + &format!("name='{}'", name), + "get", + "commandline,processid", + "/value", + ]) + .creation_flags(CREATE_NO_WINDOW) + .output() + .map(|output| { + get_pids_with_args_from_wmic_output::( + String::from_utf8_lossy(&output.stdout), + &name, + args, + ) + }) + .unwrap_or_default() +} + +#[cfg(not(target_pointer_width = "64"))] +fn get_pids_with_first_arg_from_wmic_output( + output: std::borrow::Cow<'_, str>, + name: &str, + arg: &str, +) -> Vec { + let mut pids = Vec::new(); + let mut proc_found = false; + for line in output.lines() { + if line.starts_with("ProcessId=") { + if proc_found { + if let Ok(pid) = line["ProcessId=".len()..].trim().parse::() { + pids.push(hbb_common::sysinfo::Pid::from_u32(pid)); + } + proc_found = false; + } + } else if line.starts_with("CommandLine=") { + proc_found = false; + let cmd = line["CommandLine=".len()..].trim().to_lowercase(); + if cmd.is_empty() { + continue; + } + if !arg.is_empty() && cmd.starts_with(arg) { + proc_found = true; + } else { + for x in [&format!("{}\"", name), &format!("{}", name)] { + if cmd.contains(x) { + let cmd = cmd.split(x).collect::>()[1..].join(""); + if arg.is_empty() { + if cmd.trim().is_empty() { + proc_found = true; + } + } else if cmd.trim().starts_with(arg) { + proc_found = true; + } + break; + } + } + } + } + } + pids +} + +// Note the args are not compared strictly, only check if the args are contained in the command line. +// If we want to check the args strictly, we need to parse the command line and compare each arg. +// Maybe we have to introduce some external crate like `shell_words` to do this. +#[cfg(not(target_pointer_width = "64"))] +pub(super) fn get_pids_with_first_arg_by_wmic, S2: AsRef>( + name: S1, + arg: S2, +) -> Vec { + let name = name.as_ref().to_lowercase(); + let arg = arg.as_ref().to_lowercase(); + std::process::Command::new("wmic.exe") + .args([ + "process", + "where", + &format!("name='{}'", name), + "get", + "commandline,processid", + "/value", + ]) + .creation_flags(CREATE_NO_WINDOW) + .output() + .map(|output| { + get_pids_with_first_arg_from_wmic_output( + String::from_utf8_lossy(&output.stdout), + &name, + &arg, + ) + }) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + + // Test-only reusable Win32 HANDLE RAII helper. + // If a future non-test path needs the same pattern, move it out of this test module. + // + // This struct is similar to `hbb_common::platform::windows::RAIIHandle`, + // but `RAIIHandle` depends on `WinApi` crate, while this `HandleGuard` only depends on `windows` crate. + struct HandleGuard(WinHANDLE); + + impl HandleGuard { + #[inline] + fn new(handle: WinHANDLE) -> Self { + Self(handle) + } + + #[inline] + fn get(&self) -> WinHANDLE { + self.0 + } + } + + impl Drop for HandleGuard { + fn drop(&mut self) { + unsafe { + if !self.0.is_invalid() { + let _ = WinCloseHandle(self.0); + } + } + } + } + + #[test] + fn test_is_process_running_as_system_invalid_pid_errors() { + assert!(is_process_running_as_system(u32::MAX).is_err()); + } + + #[test] + fn test_is_process_running_as_system_matches_current_process_token_user() { + let pid = unsafe { windows::Win32::System::Threading::GetCurrentProcessId() }; + let actual = is_process_running_as_system(pid).unwrap(); + + let expected = unsafe { + // Keep this test consistent: use only the `windows` crate APIs/types. + let process = HandleGuard::new( + WinOpenProcess(WIN_PROCESS_QUERY_LIMITED_INFORMATION, false, pid) + .expect("WinOpenProcess should succeed for current process"), + ); + let mut token = WinHANDLE::default(); + WinOpenProcessToken(process.get(), WIN_TOKEN_QUERY, &mut token) + .expect("WinOpenProcessToken should succeed for current process"); + let token = HandleGuard::new(token); + + let mut token_user_size = 0u32; + let _ = WinGetTokenInformation(token.get(), TokenUser, None, 0, &mut token_user_size); + assert_ne!(token_user_size, 0, "TokenUser size should be non-zero"); + + let mut buffer = vec![0u8; token_user_size as usize]; + WinGetTokenInformation( + token.get(), + TokenUser, + Some(buffer.as_mut_ptr() as *mut core::ffi::c_void), + token_user_size, + &mut token_user_size, + ) + .expect("WinGetTokenInformation(TokenUser) should succeed for current process"); + + let min_size = std::mem::size_of::(); + assert!( + buffer.len() >= min_size, + "TokenUser buffer too small (got {}, need >= {})", + buffer.len(), + min_size + ); + let token_user: TOKEN_USER = + std::ptr::read_unaligned(buffer.as_ptr() as *const TOKEN_USER); + let expected = IsWellKnownSid(token_user.User.Sid, WinLocalSystemSid).as_bool(); + expected + }; + + assert_eq!(actual, expected); + } + + #[test] + fn test_uninstall_cert() { + println!("uninstall driver certs: {:?}", cert::uninstall_cert()); + } + + #[test] + fn test_get_unicode_char_by_vk() { + let chr = get_char_from_vk(0x41); // VK_A + assert_eq!(chr, Some('a')); + let chr = get_char_from_vk(VK_ESCAPE as u32); // VK_ESC + assert_eq!(chr, None) + } + + #[cfg(not(target_pointer_width = "64"))] + #[test] + fn test_get_pids_with_args_from_wmic_output() { + let output = r#" +CommandLine= +ProcessId=33796 + +CommandLine= +ProcessId=34668 + +CommandLine="C:\Program Files\testapp\TestApp.exe" --tray +ProcessId=13728 + +CommandLine="C:\Program Files\testapp\TestApp.exe" +ProcessId=10136 +"#; + let name = "testapp.exe"; + let args = vec!["--tray"]; + let pids = super::get_pids_with_args_from_wmic_output( + String::from_utf8_lossy(output.as_bytes()), + name, + &args, + ); + assert_eq!(pids.len(), 1); + assert_eq!(pids[0].as_u32(), 13728); + + let args: Vec<&str> = vec![]; + let pids = super::get_pids_with_args_from_wmic_output( + String::from_utf8_lossy(output.as_bytes()), + name, + &args, + ); + assert_eq!(pids.len(), 1); + assert_eq!(pids[0].as_u32(), 10136); + + let args = vec!["--other"]; + let pids = super::get_pids_with_args_from_wmic_output( + String::from_utf8_lossy(output.as_bytes()), + name, + &args, + ); + assert_eq!(pids.len(), 0); + } + + #[cfg(not(target_pointer_width = "64"))] + #[test] + fn test_get_pids_with_first_arg_from_wmic_output() { + let output = r#" +CommandLine= +ProcessId=33796 + +CommandLine= +ProcessId=34668 + +CommandLine="C:\Program Files\testapp\TestApp.exe" --tray +ProcessId=13728 + +CommandLine="C:\Program Files\testapp\TestApp.exe" +ProcessId=10136 + "#; + let name = "testapp.exe"; + let arg = "--tray"; + let pids = super::get_pids_with_first_arg_from_wmic_output( + String::from_utf8_lossy(output.as_bytes()), + name, + arg, + ); + assert_eq!(pids.len(), 1); + assert_eq!(pids[0].as_u32(), 13728); + + let arg = ""; + let pids = super::get_pids_with_first_arg_from_wmic_output( + String::from_utf8_lossy(output.as_bytes()), + name, + arg, + ); + assert_eq!(pids.len(), 1); + assert_eq!(pids[0].as_u32(), 10136); + + let arg = "--other"; + let pids = super::get_pids_with_first_arg_from_wmic_output( + String::from_utf8_lossy(output.as_bytes()), + name, + arg, + ); + assert_eq!(pids.len(), 0); + } +} diff --git a/src/platform/windows/acl.rs b/src/platform/windows/acl.rs new file mode 100644 index 000000000..682e66fed --- /dev/null +++ b/src/platform/windows/acl.rs @@ -0,0 +1,903 @@ +// https://learn.microsoft.com/en-us/windows/win32/secgloss/security-glossary + +use super::{read_token_user_buffer, wide_string, ResultType}; +use hbb_common::{anyhow::anyhow, bail}; +use std::{ + fs, io, + os::windows::{ffi::OsStrExt, fs::MetadataExt}, + path::Path, +}; +use windows::{ + core::{PCWSTR, PWSTR}, + Win32::{ + Foundation::{CloseHandle, LocalFree, HANDLE, HLOCAL}, + Security::{ + Authorization::{ + ConvertSidToStringSidW, ConvertStringSidToSidW, GetNamedSecurityInfoW, + SetEntriesInAclW, SetNamedSecurityInfoW, EXPLICIT_ACCESS_W, SET_ACCESS, + SE_FILE_OBJECT, TRUSTEE_IS_GROUP, TRUSTEE_IS_SID, TRUSTEE_IS_USER, TRUSTEE_W, + }, + ACE_FLAGS, ACL, CONTAINER_INHERIT_ACE, DACL_SECURITY_INFORMATION, NO_INHERITANCE, + OBJECT_INHERIT_ACE, PROTECTED_DACL_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID, + TOKEN_QUERY, TOKEN_USER, + }, + Storage::FileSystem::{FILE_ALL_ACCESS, FILE_GENERIC_WRITE}, + System::Threading::{GetCurrentProcess, OpenProcessToken}, + }, +}; + +const FILE_ATTRIBUTE_REPARSE_POINT_U32: u32 = 0x400; + +#[inline] +fn is_reparse_point(metadata: &fs::Metadata) -> bool { + (metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT_U32) != 0 +} + +fn apply_grant_sid_allow_ace_to_path( + path: &Path, + sid_ptr: *mut std::ffi::c_void, + access_mask: u32, + is_group: bool, + is_dir: bool, +) -> ResultType<()> { + // Merge mode: read existing DACL and append/replace ACE via SetEntriesInAclW. + // https://learn.microsoft.com/en-us/windows/win32/secauthz/modifying-the-acls-of-an-object-in-c-- + let mut old_dacl: *mut ACL = std::ptr::null_mut(); + let mut security_descriptor = PSECURITY_DESCRIPTOR::default(); + let path_utf16: Vec = path + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + let get_named_result = unsafe { + GetNamedSecurityInfoW( + PCWSTR::from_raw(path_utf16.as_ptr()), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + None, + None, + Some(&mut old_dacl), + None, + &mut security_descriptor, + ) + }; + if get_named_result.0 != 0 { + bail!( + "GetNamedSecurityInfoW failed for '{}': win32_error={}", + path.display(), + get_named_result.0 + ); + } + let _sd_guard = LocalAllocGuard(security_descriptor.0); + + let inherit_flags = if is_dir { + ACE_FLAGS(OBJECT_INHERIT_ACE.0 | CONTAINER_INHERIT_ACE.0) + } else { + NO_INHERITANCE + }; + let explicit_access = [make_sid_trustee_entry( + sid_ptr, + access_mask, + inherit_flags, + is_group, + )]; + let old_acl_option = if old_dacl.is_null() { + None + } else { + Some(old_dacl as *const ACL) + }; + let mut new_acl: *mut ACL = std::ptr::null_mut(); + let set_entries_result = unsafe { + SetEntriesInAclW( + Some(explicit_access.as_slice()), + old_acl_option, + &mut new_acl, + ) + }; + if set_entries_result.0 != 0 { + bail!( + "SetEntriesInAclW failed for '{}': win32_error={}", + path.display(), + set_entries_result.0 + ); + } + if new_acl.is_null() { + bail!( + "SetEntriesInAclW returned null ACL for '{}'", + path.display() + ); + } + let _acl_guard = LocalAllocGuard(new_acl as *mut std::ffi::c_void); + + let set_named_result = unsafe { + SetNamedSecurityInfoW( + PCWSTR::from_raw(path_utf16.as_ptr()), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + None, + None, + Some(new_acl), + None, + ) + }; + if set_named_result.0 != 0 { + bail!( + "SetNamedSecurityInfoW failed for '{}': win32_error={}", + path.display(), + set_named_result.0 + ); + } + Ok(()) +} + +/// Grants `Everyone` on `dir` recursively for helper/runtime files that must be +/// readable/executable across user contexts. +/// +/// `access_mask` is the Win32 file access mask to grant recursively. +pub fn set_path_permission(dir: &Path, access_mask: u32) -> ResultType<()> { + let metadata = fs::symlink_metadata(dir).map_err(|e| { + anyhow!( + "Failed to inspect ACL target directory '{}': {}", + dir.display(), + e + ) + })?; + if is_reparse_point(&metadata) { + bail!( + "ACL target directory is a reparse point and is rejected: '{}'", + dir.display() + ); + } + if !metadata.file_type().is_dir() { + bail!("ACL target is not a directory: '{}'", dir.display()); + } + + let everyone_sid = sid_string_to_local_alloc_guard("S-1-1-0")?; + let mut stack = vec![dir.to_path_buf()]; + while let Some(path) = stack.pop() { + let metadata = fs::symlink_metadata(&path) + .map_err(|e| anyhow!("Failed to inspect ACL target '{}': {}", path.display(), e))?; + if is_reparse_point(&metadata) { + continue; + } + let is_dir = metadata.file_type().is_dir(); + apply_grant_sid_allow_ace_to_path( + &path, + everyone_sid.as_sid_ptr(), + access_mask, + true, + is_dir, + )?; + if !is_dir { + continue; + } + for entry in fs::read_dir(&path) + .map_err(|e| anyhow!("Failed to list ACL target dir '{}': {}", path.display(), e))? + { + let entry = entry.map_err(|e| { + anyhow!( + "Failed to read ACL target dir entry under '{}': {}", + path.display(), + e + ) + })?; + stack.push(entry.path()); + } + } + Ok(()) +} + +/// Returns the current process user SID as a standard SID string +/// (for example: `S-1-5-18`). +/// +/// Source: +/// - Official SID-to-string API (`ConvertSidToStringSidW`): +/// https://learn.microsoft.com/en-us/windows/win32/api/sddl/nf-sddl-convertsidtostringsidw +pub(crate) fn current_process_user_sid_string() -> ResultType { + let mut token = HANDLE::default(); + let result = (|| -> ResultType { + unsafe { + OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) + .map_err(|e| anyhow!("Failed to open current process token: {}", e))?; + } + + let buffer = unsafe { read_token_user_buffer(token, "current process")? }; + let token_user: TOKEN_USER = + unsafe { std::ptr::read_unaligned(buffer.as_ptr() as *const TOKEN_USER) }; + if token_user.User.Sid.0.is_null() { + bail!("Token SID is null"); + } + + let mut sid_string_ptr = PWSTR::null(); + unsafe { + ConvertSidToStringSidW(token_user.User.Sid, &mut sid_string_ptr).map_err(|e| { + anyhow!( + "ConvertSidToStringSidW failed for current process token SID: {}", + e + ) + })?; + } + if sid_string_ptr.is_null() { + bail!("ConvertSidToStringSidW returned null SID string pointer"); + } + let _sid_string_guard = LocalAllocGuard(sid_string_ptr.0 as *mut std::ffi::c_void); + unsafe { + sid_string_ptr + .to_string() + .map_err(|e| anyhow!("Failed to decode SID string as UTF-16: {}", e)) + } + })(); + + if !token.is_invalid() { + unsafe { + let _ = CloseHandle(token); + } + } + result +} + +/// Hardens ACLs for portable-service shared-memory path (directory or file). +/// +/// Why: +/// - Shared memory used by portable service carries runtime control/data and must not inherit +/// broad/default ACLs. +/// - We explicitly grant only trusted principals and remove broad groups to reduce local +/// privilege-boundary bypass risk. +/// +/// ACL policy applied via Win32 ACL APIs (`SetEntriesInAclW` + `SetNamedSecurityInfoW`): +/// - common (directory + file): +/// - `S-1-5-18` (LocalSystem): full control +/// - `S-1-5-32-544` (Built-in Administrators): full control +/// - `current_process_user_sid_string()` result: full control +/// - directory (`portable_service_shmem` parent): +/// - keep `Authenticated Users` directory-level write so other local accounts can +/// create their own runtime shmem files after account switching +/// - `FILE_GENERIC_WRITE + NO_INHERITANCE` means write/create on this directory itself; +/// it is intentionally not inherited by children. +/// Reference: +/// - File access rights: +/// https://learn.microsoft.com/en-us/windows/win32/fileio/file-access-rights-constants +/// - ACE inheritance rules: +/// https://learn.microsoft.com/en-us/windows/win32/secauthz/ace-inheritance-rules +/// - remove `Everyone` and `Users` grants +/// - file (`shared_memory*` flink): +/// - remove broad grants: +/// - `S-1-1-0` (Everyone) +/// - `S-1-5-11` (Authenticated Users) +/// - `S-1-5-32-545` (Users) +/// +/// https://learn.microsoft.com/en-us/windows/win32/secauthz/well-known-sids +pub fn set_path_permission_for_portable_service_shmem_dir(path: &Path) -> ResultType<()> { + set_path_permission_for_portable_service_shmem_impl(path, true) +} + +#[inline] +pub fn validate_path_for_portable_service_shmem_dir(path: &Path) -> ResultType<()> { + validate_portable_service_shmem_dir_target(path) +} + +#[inline] +pub fn set_path_permission_for_portable_service_shmem_file(path: &Path) -> ResultType<()> { + set_path_permission_for_portable_service_shmem_impl(path, false) +} + +#[derive(Debug)] +pub(super) struct LocalAllocGuard(*mut std::ffi::c_void); + +impl LocalAllocGuard { + #[inline] + pub(super) fn as_sid_ptr(&self) -> *mut std::ffi::c_void { + self.0 + } +} + +impl Drop for LocalAllocGuard { + fn drop(&mut self) { + if self.0.is_null() { + return; + } + // Buffers returned by ConvertStringSidToSidW / SetEntriesInAclW / + // ConvertSidToStringSidW are LocalAlloc-owned and must be LocalFree'ed. + unsafe { + let _ = LocalFree(Some(HLOCAL(self.0))); + } + } +} + +#[inline] +pub(super) fn sid_string_to_local_alloc_guard(sid: &str) -> ResultType { + let sid_utf16 = wide_string(sid); + let mut sid_ptr = PSID::default(); + unsafe { + ConvertStringSidToSidW(PCWSTR::from_raw(sid_utf16.as_ptr()), &mut sid_ptr) + .map_err(|e| anyhow!("ConvertStringSidToSidW failed for '{}': {}", sid, e))?; + } + if sid_ptr.0.is_null() { + bail!("ConvertStringSidToSidW returned null SID for '{}'", sid); + } + Ok(LocalAllocGuard(sid_ptr.0)) +} + +#[inline] +fn make_sid_trustee_entry( + sid_ptr: *mut std::ffi::c_void, + access_permissions: u32, + inheritance: ACE_FLAGS, + is_group: bool, +) -> EXPLICIT_ACCESS_W { + // `is_group` is explicitly provided by the caller from the concrete SID semantic + // (e.g. Administrators/Authenticated Users => group, LocalSystem/current user => user). + EXPLICIT_ACCESS_W { + grfAccessPermissions: access_permissions, + grfAccessMode: SET_ACCESS, + grfInheritance: inheritance, + Trustee: TRUSTEE_W { + pMultipleTrustee: std::ptr::null_mut(), + MultipleTrusteeOperation: Default::default(), + TrusteeForm: TRUSTEE_IS_SID, + TrusteeType: if is_group { + TRUSTEE_IS_GROUP + } else { + TRUSTEE_IS_USER + }, + // SAFETY: With TrusteeForm=TRUSTEE_IS_SID, ptstrName is interpreted as PSID. + ptstrName: PWSTR::from_raw(sid_ptr as *mut u16), + }, + } +} + +fn validate_portable_service_shmem_dir_target(path: &Path) -> ResultType<()> { + let metadata = fs::symlink_metadata(path).map_err(|e| { + anyhow!( + "Failed to inspect portable service shared-memory ACL directory '{}': {}", + path.display(), + e + ) + })?; + if is_reparse_point(&metadata) { + bail!( + "Portable service shared-memory ACL directory target is a reparse point and is rejected: '{}'", + path.display() + ); + } + if !metadata.file_type().is_dir() { + bail!( + "Portable service shared-memory ACL target is not a directory: '{}'", + path.display() + ); + } + Ok(()) +} + +fn set_path_permission_for_portable_service_shmem_impl( + path: &Path, + expect_dir: bool, +) -> ResultType<()> { + if expect_dir { + validate_portable_service_shmem_dir_target(path)?; + } else { + let metadata_result = fs::symlink_metadata(path); + match metadata_result { + Ok(metadata) => { + if metadata.file_type().is_dir() { + bail!( + "Portable service shared-memory ACL target is a directory, expected file-like path: '{}'", + path.display() + ); + } + if is_reparse_point(&metadata) { + bail!( + "Portable service shared-memory ACL file target is a reparse point and is rejected: '{}'", + path.display() + ); + } + } + Err(e) + if e.kind() == io::ErrorKind::NotFound + || e.kind() == io::ErrorKind::PermissionDenied => + { + // Keep going and let Win32 ACL APIs return the final OS error. + // `Path::exists()/is_file()` and metadata can collapse ACL-denied paths into + // a false "not found" signal under restricted directory ACLs. + } + Err(e) => { + bail!( + "Failed to inspect portable service shared-memory ACL target '{}': {}", + path.display(), + e + ); + } + } + } + + let user_sid = current_process_user_sid_string()?; + let local_system_sid = sid_string_to_local_alloc_guard("S-1-5-18")?; + let administrators_sid = sid_string_to_local_alloc_guard("S-1-5-32-544")?; + let current_user_sid = sid_string_to_local_alloc_guard(&user_sid)?; + let authenticated_users_sid = if expect_dir { + Some(sid_string_to_local_alloc_guard("S-1-5-11")?) + } else { + None + }; + + let inherit_flags = if expect_dir { + ACE_FLAGS(OBJECT_INHERIT_ACE.0 | CONTAINER_INHERIT_ACE.0) + } else { + NO_INHERITANCE + }; + let mut entries = vec![ + make_sid_trustee_entry( + local_system_sid.as_sid_ptr(), + FILE_ALL_ACCESS.0, + inherit_flags, + false, + ), + make_sid_trustee_entry( + administrators_sid.as_sid_ptr(), + FILE_ALL_ACCESS.0, + inherit_flags, + true, + ), + make_sid_trustee_entry( + current_user_sid.as_sid_ptr(), + FILE_ALL_ACCESS.0, + inherit_flags, + false, + ), + ]; + if let Some(auth_sid) = authenticated_users_sid.as_ref() { + // Keep the shared parent directory multi-user writable at directory level. + entries.push(make_sid_trustee_entry( + auth_sid.as_sid_ptr(), + FILE_GENERIC_WRITE.0, + NO_INHERITANCE, + true, + )); + } + + // Rebuild mode: build a fresh DACL (old ACL not merged) and apply as protected. + // This avoids carrying over broad legacy ACEs from inherited/default ACLs. + // Reference: + // - SetEntriesInAclW: + // https://learn.microsoft.com/en-us/windows/win32/api/aclapi/nf-aclapi-setentriesinaclw + // - SetNamedSecurityInfoW (PROTECTED_DACL_SECURITY_INFORMATION): + // https://learn.microsoft.com/en-us/windows/win32/api/aclapi/nf-aclapi-setnamedsecurityinfow + let mut new_acl: *mut ACL = std::ptr::null_mut(); + let set_entries_result = + unsafe { SetEntriesInAclW(Some(entries.as_slice()), None, &mut new_acl) }; + if set_entries_result.0 != 0 { + bail!( + "SetEntriesInAclW failed for '{}': win32_error={}", + path.display(), + set_entries_result.0 + ); + } + if new_acl.is_null() { + bail!( + "SetEntriesInAclW returned null ACL for '{}'", + path.display() + ); + } + let _acl_guard = LocalAllocGuard(new_acl as *mut std::ffi::c_void); + + let path_utf16: Vec = path + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + let security_info = DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION; + let set_named_result = unsafe { + SetNamedSecurityInfoW( + PCWSTR::from_raw(path_utf16.as_ptr()), + SE_FILE_OBJECT, + security_info, + None, + None, + Some(new_acl), + None, + ) + }; + if set_named_result.0 != 0 { + bail!( + "SetNamedSecurityInfoW failed for '{}': win32_error={}", + path.display(), + set_named_result.0 + ); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{ + current_process_user_sid_string, set_path_permission, + set_path_permission_for_portable_service_shmem_dir, + set_path_permission_for_portable_service_shmem_file, sid_string_to_local_alloc_guard, + LocalAllocGuard, ResultType, + }; + use hbb_common::bail; + use std::{ + fs, + os::windows::{ffi::OsStrExt, fs::symlink_dir, fs::symlink_file}, + path::{Path, PathBuf}, + }; + use windows::{ + core::PCWSTR, + Win32::{ + Security::{ + AclSizeInformation, + Authorization::{GetNamedSecurityInfoW, SE_FILE_OBJECT}, + EqualSid as WinEqualSid, GetAce, GetAclInformation, GetSecurityDescriptorControl, + ACCESS_ALLOWED_ACE, ACE_HEADER, ACL, ACL_SIZE_INFORMATION, + DACL_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID, SE_DACL_PROTECTED, + }, + Storage::FileSystem::{ + FILE_ALL_ACCESS, FILE_GENERIC_EXECUTE, FILE_GENERIC_READ, FILE_GENERIC_WRITE, + }, + }, + }; + + const ACCESS_ALLOWED_ACE_TYPE_U8: u8 = 0; + + fn unique_acl_test_path(prefix: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "rustdesk_acl_{}_{}_{}", + prefix, + std::process::id(), + hbb_common::rand::random::() + )) + } + + fn try_create_dir_reparse_point(target: &Path, link: &Path, test_name: &str) -> bool { + match symlink_dir(target, link) { + Ok(()) => true, + Err(err) => { + eprintln!( + "skip {}: failed to create directory reparse point (symlink): {}", + test_name, err + ); + false + } + } + } + + fn try_create_file_reparse_point(target: &Path, link: &Path, test_name: &str) -> bool { + match symlink_file(target, link) { + Ok(()) => true, + Err(err) => { + eprintln!( + "skip {}: failed to create file reparse point (symlink): {}", + test_name, err + ); + false + } + } + } + + fn get_file_dacl(path: &Path) -> ResultType<(*mut ACL, LocalAllocGuard)> { + let mut dacl: *mut ACL = std::ptr::null_mut(); + let mut sd = PSECURITY_DESCRIPTOR::default(); + let path_utf16: Vec = path + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + let result = unsafe { + GetNamedSecurityInfoW( + PCWSTR::from_raw(path_utf16.as_ptr()), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + None, + None, + Some(&mut dacl), + None, + &mut sd, + ) + }; + if result.0 != 0 { + bail!( + "GetNamedSecurityInfoW failed for '{}': win32_error={}", + path.display(), + result.0 + ); + } + if dacl.is_null() || sd.0.is_null() { + bail!("DACL/security descriptor missing for '{}'", path.display()); + } + Ok((dacl, LocalAllocGuard(sd.0))) + } + + fn has_allow_ace_with_mask( + dacl: *const ACL, + sid_ptr: *mut std::ffi::c_void, + mask: u32, + ) -> bool { + let mut info = ACL_SIZE_INFORMATION::default(); + if unsafe { + GetAclInformation( + dacl, + &mut info as *mut _ as *mut std::ffi::c_void, + std::mem::size_of::() as u32, + AclSizeInformation, + ) + } + .is_err() + { + return false; + } + for index in 0..info.AceCount { + let mut ace_ptr: *mut std::ffi::c_void = std::ptr::null_mut(); + if unsafe { GetAce(dacl, index, &mut ace_ptr) }.is_err() || ace_ptr.is_null() { + continue; + } + let header = unsafe { &*(ace_ptr as *const ACE_HEADER) }; + if header.AceType != ACCESS_ALLOWED_ACE_TYPE_U8 { + continue; + } + let allowed = unsafe { &*(ace_ptr as *const ACCESS_ALLOWED_ACE) }; + let ace_sid = PSID((&allowed.SidStart as *const u32) as *mut std::ffi::c_void); + if unsafe { WinEqualSid(PSID(sid_ptr), ace_sid) }.is_ok() + && (allowed.Mask & mask) == mask + { + return true; + } + } + false + } + + fn has_any_allow_ace_for_sid(dacl: *const ACL, sid_ptr: *mut std::ffi::c_void) -> bool { + has_allow_ace_with_mask(dacl, sid_ptr, 0) + } + + fn is_dacl_protected(sd: PSECURITY_DESCRIPTOR) -> bool { + let mut control: u16 = 0; + let mut revision: u32 = 0; + if unsafe { GetSecurityDescriptorControl(sd, &mut control, &mut revision) }.is_err() { + return false; + } + (control & SE_DACL_PROTECTED.0) != 0 + } + + #[test] + fn test_portable_service_shmem_dir_acl_policy() { + let dir = unique_acl_test_path("dir"); + fs::create_dir_all(&dir).unwrap(); + set_path_permission_for_portable_service_shmem_dir(&dir).unwrap(); + + let (dacl, sd_guard) = get_file_dacl(&dir).unwrap(); + let current_user_sid = + sid_string_to_local_alloc_guard(¤t_process_user_sid_string().unwrap()).unwrap(); + let system_sid = sid_string_to_local_alloc_guard("S-1-5-18").unwrap(); + let admin_sid = sid_string_to_local_alloc_guard("S-1-5-32-544").unwrap(); + let auth_users_sid = sid_string_to_local_alloc_guard("S-1-5-11").unwrap(); + let everyone_sid = sid_string_to_local_alloc_guard("S-1-1-0").unwrap(); + let users_sid = sid_string_to_local_alloc_guard("S-1-5-32-545").unwrap(); + + assert!(has_allow_ace_with_mask( + dacl, + system_sid.as_sid_ptr(), + FILE_ALL_ACCESS.0 + )); + assert!(has_allow_ace_with_mask( + dacl, + admin_sid.as_sid_ptr(), + FILE_ALL_ACCESS.0 + )); + assert!(has_allow_ace_with_mask( + dacl, + current_user_sid.as_sid_ptr(), + FILE_ALL_ACCESS.0 + )); + assert!(has_allow_ace_with_mask( + dacl, + auth_users_sid.as_sid_ptr(), + FILE_GENERIC_WRITE.0 + )); + assert!(!has_any_allow_ace_for_sid(dacl, everyone_sid.as_sid_ptr())); + assert!(!has_any_allow_ace_for_sid(dacl, users_sid.as_sid_ptr())); + assert!(is_dacl_protected(PSECURITY_DESCRIPTOR( + sd_guard.as_sid_ptr() + ))); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn test_portable_service_shmem_file_acl_policy() { + let dir = unique_acl_test_path("file"); + fs::create_dir_all(&dir).unwrap(); + let file = dir.join("shared_memory_portable_service_test"); + fs::write(&file, b"x").unwrap(); + set_path_permission_for_portable_service_shmem_file(&file).unwrap(); + + let (dacl, sd_guard) = get_file_dacl(&file).unwrap(); + let current_user_sid = + sid_string_to_local_alloc_guard(¤t_process_user_sid_string().unwrap()).unwrap(); + let system_sid = sid_string_to_local_alloc_guard("S-1-5-18").unwrap(); + let admin_sid = sid_string_to_local_alloc_guard("S-1-5-32-544").unwrap(); + let auth_users_sid = sid_string_to_local_alloc_guard("S-1-5-11").unwrap(); + let everyone_sid = sid_string_to_local_alloc_guard("S-1-1-0").unwrap(); + let users_sid = sid_string_to_local_alloc_guard("S-1-5-32-545").unwrap(); + + assert!(has_allow_ace_with_mask( + dacl, + system_sid.as_sid_ptr(), + FILE_ALL_ACCESS.0 + )); + assert!(has_allow_ace_with_mask( + dacl, + admin_sid.as_sid_ptr(), + FILE_ALL_ACCESS.0 + )); + assert!(has_allow_ace_with_mask( + dacl, + current_user_sid.as_sid_ptr(), + FILE_ALL_ACCESS.0 + )); + assert!(!has_any_allow_ace_for_sid( + dacl, + auth_users_sid.as_sid_ptr() + )); + assert!(!has_any_allow_ace_for_sid(dacl, everyone_sid.as_sid_ptr())); + assert!(!has_any_allow_ace_for_sid(dacl, users_sid.as_sid_ptr())); + assert!(is_dacl_protected(PSECURITY_DESCRIPTOR( + sd_guard.as_sid_ptr() + ))); + + let _ = fs::remove_file(&file); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn test_set_path_permission_rx_applies_recursively() { + let root = unique_acl_test_path("set_path_permission"); + let child_dir = root.join("child"); + let child_file = child_dir.join("helper.exe"); + fs::create_dir_all(&child_dir).unwrap(); + fs::write(&child_file, b"x").unwrap(); + + if let Err(err) = set_path_permission(&root, FILE_GENERIC_READ.0 | FILE_GENERIC_EXECUTE.0) { + let text = err.to_string(); + let _ = fs::remove_file(&child_file); + let _ = fs::remove_dir_all(&root); + if text.contains("win32_error=5") || text.contains("Access is denied") { + eprintln!( + "skip test_set_path_permission_rx_applies_recursively: insufficient WRITE_DAC in current environment: {}", + text + ); + return; + } + panic!("set_path_permission failed unexpectedly: {}", text); + } + + let everyone_sid = sid_string_to_local_alloc_guard("S-1-1-0").unwrap(); + let rx_mask = FILE_GENERIC_READ.0 | FILE_GENERIC_EXECUTE.0; + for target in [&root, &child_dir, &child_file] { + let (dacl, _sd_guard) = get_file_dacl(target).unwrap(); + assert!( + has_allow_ace_with_mask(dacl, everyone_sid.as_sid_ptr(), rx_mask), + "Everyone RX grant missing on '{}'", + target.display() + ); + } + + let _ = fs::remove_file(&child_file); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn test_portable_service_shmem_dir_acl_rejects_file_target() { + let dir = unique_acl_test_path("dir_target_file"); + fs::create_dir_all(&dir).unwrap(); + let file = dir.join("target.txt"); + fs::write(&file, b"x").unwrap(); + let result = set_path_permission_for_portable_service_shmem_dir(&file); + assert!(result.is_err()); + let _ = fs::remove_file(&file); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn test_portable_service_shmem_file_acl_rejects_dir_target() { + let dir = unique_acl_test_path("file_target_dir"); + fs::create_dir_all(&dir).unwrap(); + let result = set_path_permission_for_portable_service_shmem_file(&dir); + assert!(result.is_err()); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn test_portable_service_shmem_file_acl_rejects_missing_target() { + let path = unique_acl_test_path("missing").join("shared_memory_missing"); + let result = set_path_permission_for_portable_service_shmem_file(&path); + assert!(result.is_err()); + } + + #[test] + fn test_set_path_permission_rejects_reparse_entrypoint() { + let root = unique_acl_test_path("reparse_entry"); + let real_dir = root.join("real"); + let link_dir = root.join("link"); + fs::create_dir_all(&real_dir).unwrap(); + if !try_create_dir_reparse_point( + &real_dir, + &link_dir, + "test_set_path_permission_rejects_reparse_entrypoint", + ) { + let _ = fs::remove_dir_all(&real_dir); + let _ = fs::remove_dir_all(&root); + return; + } + + let result = set_path_permission(&link_dir, FILE_GENERIC_READ.0 | FILE_GENERIC_EXECUTE.0); + let text = result.err().map(|e| e.to_string()).unwrap_or_default(); + assert!( + text.contains("reparse point"), + "expected reparse-point rejection, got '{}'", + text + ); + + let _ = fs::remove_dir(&link_dir); + let _ = fs::remove_dir_all(&real_dir); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn test_portable_service_shmem_dir_acl_rejects_reparse_target() { + let root = unique_acl_test_path("reparse_shmem_dir"); + let real_dir = root.join("real"); + let link_dir = root.join("link"); + fs::create_dir_all(&real_dir).unwrap(); + if !try_create_dir_reparse_point( + &real_dir, + &link_dir, + "test_portable_service_shmem_dir_acl_rejects_reparse_target", + ) { + let _ = fs::remove_dir_all(&real_dir); + let _ = fs::remove_dir_all(&root); + return; + } + + let result = set_path_permission_for_portable_service_shmem_dir(&link_dir); + let text = result.err().map(|e| e.to_string()).unwrap_or_default(); + assert!( + text.contains("reparse point"), + "expected reparse-point rejection, got '{}'", + text + ); + + let _ = fs::remove_dir(&link_dir); + let _ = fs::remove_dir_all(&real_dir); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn test_portable_service_shmem_file_acl_rejects_reparse_target() { + let root = unique_acl_test_path("reparse_shmem_file"); + let real_file = root.join("real.txt"); + let link_file = root.join("link.txt"); + fs::create_dir_all(&root).unwrap(); + fs::write(&real_file, b"x").unwrap(); + if !try_create_file_reparse_point( + &real_file, + &link_file, + "test_portable_service_shmem_file_acl_rejects_reparse_target", + ) { + let _ = fs::remove_file(&real_file); + let _ = fs::remove_dir_all(&root); + return; + } + + let result = set_path_permission_for_portable_service_shmem_file(&link_file); + let text = result.err().map(|e| e.to_string()).unwrap_or_default(); + assert!( + text.contains("reparse point"), + "expected reparse-point rejection, got '{}'", + text + ); + + let _ = fs::remove_file(&link_file); + let _ = fs::remove_file(&real_file); + let _ = fs::remove_dir_all(&root); + } +} diff --git a/src/plugin/manager.rs b/src/plugin/manager.rs index 74a7f736f..f59e4c9ff 100644 --- a/src/plugin/manager.rs +++ b/src/plugin/manager.rs @@ -452,7 +452,7 @@ pub(super) mod install { use std::{ fs::File, io::{BufReader, BufWriter, Write}, - path::PathBuf, + path::Path, }; use zip::ZipArchive; @@ -488,7 +488,7 @@ pub(super) mod install { Ok(()) } - fn download_file(id: &str, url: &str, filename: &PathBuf) -> bool { + fn download_file(id: &str, url: &str, filename: &Path) -> bool { let file = match File::create(filename) { Ok(f) => f, Err(e) => { @@ -505,7 +505,7 @@ pub(super) mod install { true } - fn do_install_file(filename: &PathBuf, target_dir: &PathBuf) -> ResultType<()> { + 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)?; diff --git a/src/plugin/plugins.rs b/src/plugin/plugins.rs index b40ee4116..bf980ee8c 100644 --- a/src/plugin/plugins.rs +++ b/src/plugin/plugins.rs @@ -13,7 +13,7 @@ use serde_derive::Serialize; use std::{ collections::{HashMap, HashSet}, ffi::{c_char, c_void}, - path::PathBuf, + path::Path, sync::{Arc, RwLock}, }; @@ -186,7 +186,6 @@ macro_rules! make_plugin { $(let $field = match unsafe { lib.symbol::<$tp>(stringify!($field)) } { Ok(m) => { - log::debug!("{} method found {}", path, stringify!($field)); *m }, Err(e) => { @@ -299,7 +298,7 @@ pub(super) fn load_plugins(uninstalled_ids: &HashSet) -> ResultType<()> Ok(()) } -fn load_plugin_dir(dir: &PathBuf) { +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 { diff --git a/src/port_forward.rs b/src/port_forward.rs index 28ac624cd..61d6bfd71 100644 --- a/src/port_forward.rs +++ b/src/port_forward.rs @@ -54,7 +54,7 @@ pub async fn listen( remote_host: String, remote_port: i32, ) -> ResultType<()> { - let listener = tcp::new_listener(format!("0.0.0.0:{}", port), true).await?; + let listener = tcp::new_listener(format!("127.0.0.1:{}", port), true).await?; let addr = listener.local_addr()?; log::info!("listening on port {:?}", addr); let is_rdp = port == 0; @@ -118,7 +118,7 @@ async fn connect_and_login( } else { ConnType::PORT_FORWARD }; - let ((mut stream, direct, _pk), (feedback, rendezvous_server)) = + let ((mut stream, direct, _pk, _kcp, _stream_type), (feedback, rendezvous_server)) = Client::start(id, key, token, conn_type, interface.clone()).await?; interface.update_direct(Some(direct)); let mut buffer = Vec::new(); diff --git a/src/privacy_mode.rs b/src/privacy_mode.rs index a02b8bc93..234004d15 100644 --- a/src/privacy_mode.rs +++ b/src/privacy_mode.rs @@ -1,17 +1,13 @@ -#[cfg(windows)] -use crate::platform::is_installed; use crate::ui_interface::get_option; #[cfg(windows)] use crate::{ display_service, ipc::{connect, Data}, + platform::is_installed, }; -use hbb_common::{ - anyhow::anyhow, - bail, lazy_static, - tokio::{self, sync::oneshot}, - ResultType, -}; +#[cfg(windows)] +use hbb_common::tokio; +use hbb_common::{anyhow::anyhow, bail, lazy_static, tokio::sync::oneshot, ResultType}; use serde_derive::{Deserialize, Serialize}; use std::{ collections::HashMap, @@ -27,6 +23,9 @@ pub mod win_mag; #[cfg(windows)] pub mod win_topmost_window; +#[cfg(target_os = "macos")] +pub mod macos; + #[cfg(windows)] mod win_virtual_display; #[cfg(windows)] @@ -39,7 +38,8 @@ pub const TURN_OFF_OTHER_ID: &'static str = pub const NO_PHYSICAL_DISPLAYS: &'static str = "no_need_privacy_mode_no_physical_displays_tip"; pub const PRIVACY_MODE_IMPL_WIN_MAG: &str = "privacy_mode_impl_mag"; -pub const PRIVACY_MODE_IMPL_WIN_EXCLUDE_FROM_CAPTURE: &str = "privacy_mode_impl_exclude_from_capture"; +pub const PRIVACY_MODE_IMPL_WIN_EXCLUDE_FROM_CAPTURE: &str = + "privacy_mode_impl_exclude_from_capture"; pub const PRIVACY_MODE_IMPL_WIN_VIRTUAL_DISPLAY: &str = "privacy_mode_impl_virtual_display"; #[derive(Debug, Serialize, Deserialize, Clone)] @@ -108,7 +108,14 @@ lazy_static::lazy_static! { } #[cfg(not(windows))] { - "".to_owned() + #[cfg(target_os = "macos")] + { + macos::PRIVACY_MODE_IMPL.to_owned() + } + #[cfg(not(target_os = "macos"))] + { + "".to_owned() + } } }; @@ -130,7 +137,13 @@ pub type PrivacyModeCreator = fn(impl_key: &str) -> Box; lazy_static::lazy_static! { static ref PRIVACY_MODE_CREATOR: Arc>> = { #[cfg(not(windows))] - let map: HashMap<&'static str, PrivacyModeCreator> = HashMap::new(); + let mut map: HashMap<&'static str, PrivacyModeCreator> = HashMap::new(); + #[cfg(target_os = "macos")] + { + map.insert(macos::PRIVACY_MODE_IMPL, |impl_key: &str| { + Box::new(macos::PrivacyModeImpl::new(impl_key)) + }); + } #[cfg(windows)] let mut map: HashMap<&'static str, PrivacyModeCreator> = HashMap::new(); #[cfg(windows)] @@ -219,9 +232,10 @@ async fn turn_on_privacy_async(impl_key: String, conn_id: i32) -> Option match res { Ok(res) => res, Err(e) => Some(Err(anyhow!(e.to_string()))), @@ -335,7 +349,14 @@ pub fn get_supported_privacy_mode_impl() -> Vec<(&'static str, &'static str)> { vec_impls } - #[cfg(not(target_os = "windows"))] + #[cfg(target_os = "macos")] + { + // No translation is intended for privacy_mode_impl_macos_tip as it is a + // placeholder for macOS specific privacy mode implementation which currently + // doesn't provide multiple modes like Windows does. + vec![(macos::PRIVACY_MODE_IMPL, "privacy_mode_impl_macos_tip")] + } + #[cfg(not(any(target_os = "windows", target_os = "macos")))] { Vec::new() } diff --git a/src/privacy_mode/macos.rs b/src/privacy_mode/macos.rs new file mode 100644 index 000000000..e6ea11e49 --- /dev/null +++ b/src/privacy_mode/macos.rs @@ -0,0 +1,81 @@ +use super::{PrivacyMode, PrivacyModeState}; +use hbb_common::{anyhow::anyhow, ResultType}; + +extern "C" { + fn MacSetPrivacyMode(on: bool) -> bool; +} + +pub const PRIVACY_MODE_IMPL: &str = "privacy_mode_impl_macos"; + +pub struct PrivacyModeImpl { + impl_key: String, + conn_id: i32, +} + +impl PrivacyModeImpl { + pub fn new(impl_key: &str) -> Self { + Self { + impl_key: impl_key.to_owned(), + conn_id: 0, + } + } +} + +impl PrivacyMode for PrivacyModeImpl { + fn is_async_privacy_mode(&self) -> bool { + false + } + + fn init(&self) -> ResultType<()> { + Ok(()) + } + + fn clear(&mut self) { + unsafe { + MacSetPrivacyMode(false); + } + self.conn_id = 0; + } + + fn turn_on_privacy(&mut self, conn_id: i32) -> ResultType { + if self.check_on_conn_id(conn_id)? { + return Ok(true); + } + let success = unsafe { MacSetPrivacyMode(true) }; + if !success { + return Err(anyhow!("Failed to turn on privacy mode")); + } + self.conn_id = conn_id; + Ok(true) + } + + fn turn_off_privacy(&mut self, conn_id: i32, _state: Option) -> ResultType<()> { + // Note: The `_state` parameter is intentionally ignored on macOS. + // On Windows, it's used to notify the connection manager about privacy mode state changes + // (see win_topmost_window.rs). macOS currently has a simpler single-mode implementation + // without the need for such cross-component state synchronization. + self.check_off_conn_id(conn_id)?; + let success = unsafe { MacSetPrivacyMode(false) }; + if !success { + return Err(anyhow!("Failed to turn off privacy mode")); + } + self.conn_id = 0; + Ok(()) + } + + fn pre_conn_id(&self) -> i32 { + self.conn_id + } + + fn get_impl_key(&self) -> &str { + &self.impl_key + } +} + +impl Drop for PrivacyModeImpl { + fn drop(&mut self) { + // Use the same cleanup logic as other code paths to keep conn_id consistent + // and ensure all cleanup is centralized in one place. + self.clear(); + } +} diff --git a/src/privacy_mode/win_virtual_display.rs b/src/privacy_mode/win_virtual_display.rs index 782d7ed75..f521cbacb 100644 --- a/src/privacy_mode/win_virtual_display.rs +++ b/src/privacy_mode/win_virtual_display.rs @@ -171,8 +171,10 @@ impl PrivacyModeImpl { } } - fn set_primary_display(&mut self) -> ResultType<()> { + fn set_primary_display(&mut self) -> ResultType { + // Multiple virtual displays with different origins are tested. let display = &self.virtual_displays[0]; + let display_name = std::string::String::from_utf16(&display.name)?; #[allow(invalid_value)] let mut new_primary_dm: DEVMODEW = unsafe { std::mem::MaybeUninit::uninit().assume_init() }; @@ -193,9 +195,32 @@ impl PrivacyModeImpl { ); } + // Windows 24H2 requires the virtual display to be set first. + // No idea why, maybe the same issue: https://developercommunity.visualstudio.com/t/Windows-11-Enterprise-24H2-using-WinApi/10851936?sort=newest + let flags = CDS_UPDATEREGISTRY | CDS_NORESET; + let offx = new_primary_dm.u1.s2().dmPosition.x; + let offy = new_primary_dm.u1.s2().dmPosition.y; + new_primary_dm.u1.s2_mut().dmPosition.x = 0; + new_primary_dm.u1.s2_mut().dmPosition.y = 0; + new_primary_dm.dmFields |= DM_POSITION; + let rc = ChangeDisplaySettingsExW( + display.name.as_ptr(), + &mut new_primary_dm, + NULL as _, + flags | CDS_SET_PRIMARY, + NULL, + ); + if rc != DISP_CHANGE_SUCCESSFUL { + let err = Self::change_display_settings_ex_err_msg(rc); + log::error!( + "Failed ChangeDisplaySettingsEx, the virtual display, {}", + &err + ); + bail!("Failed ChangeDisplaySettingsEx, {}", err); + } + let mut i: DWORD = 0; loop { - let mut flags = CDS_UPDATEREGISTRY | CDS_NORESET; #[allow(invalid_value)] let mut dd: DISPLAY_DEVICEW = std::mem::MaybeUninit::uninit().assume_init(); dd.cb = std::mem::size_of::() as _; @@ -208,9 +233,9 @@ impl PrivacyModeImpl { if (dd.StateFlags & DISPLAY_DEVICE_ATTACHED_TO_DESKTOP) == 0 { continue; } - + // Skip the virtual display. if dd.DeviceName == display.name { - flags |= CDS_SET_PRIMARY; + continue; } #[allow(invalid_value)] @@ -227,11 +252,9 @@ impl PrivacyModeImpl { ); } - dm.u1.s2_mut().dmPosition.x -= new_primary_dm.u1.s2().dmPosition.x; - dm.u1.s2_mut().dmPosition.y -= new_primary_dm.u1.s2().dmPosition.y; + dm.u1.s2_mut().dmPosition.x -= offx; + dm.u1.s2_mut().dmPosition.y -= offy; dm.dmFields |= DM_POSITION; - dm.dmPelsWidth = 1920; - dm.dmPelsHeight = 1080; let rc = ChangeDisplaySettingsExW( dd.DeviceName.as_ptr(), &mut dm, @@ -261,9 +284,12 @@ impl PrivacyModeImpl { } } - Ok(()) + Ok(display_name) } + // NOTE: We can't detect if the other virtual displays are physical displays or not. + // We can only use `DeviceString` == `virtual_display_manager::get_cur_device_string()` to detect if the display is a virtual display. + // The other virtual displays can't be restored after exiting the privacy mode on Windows 24H2. fn disable_physical_displays(&self) -> ResultType<()> { for display in &self.displays { let mut dm = display.dm.clone(); @@ -304,21 +330,34 @@ impl PrivacyModeImpl { }] } - pub fn ensure_virtual_display(&mut self) -> ResultType<()> { + // This function will wait at most 6 seconds for the virtual displays to be ready. + // It's ok to wait, because: + // 1. A new thread is created to handle the async privacy mode. + // 2. The user is usually not in a hurry to turn on the privacy mode. + pub fn ensure_virtual_display(&mut self, is_async_mode: bool) -> ResultType<()> { if self.virtual_displays.is_empty() { let displays = virtual_display_manager::plug_in_peer_request(vec![Self::default_display_modes()])?; - if virtual_display_manager::is_amyuni_idd() { - thread::sleep(Duration::from_secs(3)); + if is_async_mode { + thread::sleep(Duration::from_secs(1)); } self.set_displays(); - // No physical displays, no need to use the privacy mode. if self.displays.is_empty() { virtual_display_manager::plug_out_monitor_indices(&displays, false, false)?; bail!(NO_PHYSICAL_DISPLAYS); } + if is_async_mode { + let now = std::time::Instant::now(); + while self.virtual_displays.is_empty() + && now.elapsed() < Duration::from_millis(5000) + { + thread::sleep(Duration::from_millis(500)); + self.set_displays(); + } + } + self.virtual_displays_added.extend(displays); } @@ -365,9 +404,22 @@ impl PrivacyModeImpl { Self::restore_displays(&self.displays); Self::restore_displays(&self.virtual_displays); allow_err!(Self::commit_change_display(0)); - self.restore_plug_out_monitor(); self.displays.clear(); self.virtual_displays.clear(); + let is_virtual_display_added = self.virtual_displays_added.len() > 0; + if is_virtual_display_added { + self.restore_plug_out_monitor(); + } else { + // https://github.com/rustdesk/rustdesk/pull/12114#issuecomment-2983054370 + // No virtual displays added, we need to change the display combination to force the display settings to be reloaded. + // This function changes the user behavior of the virtual displays. + // But it makes the privacy mode more stable. + // No need to restore the virtual displays. It's easy to notice that the virtual displays are plugged out. + let _ = virtual_display_manager::plug_out_monitor(-1, true, false); + + // We can't replug the virtual dislays here. + // TODO: plug out + plug in the virtual displays (`IDD_IMPL_AMYUNI`) in a short time makes the server side crash. + } } fn restore_displays(displays: &[Display]) { @@ -419,21 +471,28 @@ impl PrivacyMode for PrivacyModeImpl { bail!(NO_PHYSICAL_DISPLAYS); } + let is_async_mode = self.is_async_privacy_mode(); let mut guard = TurnOnGuard { privacy_mode: self, succeeded: false, }; - guard.ensure_virtual_display()?; + guard.ensure_virtual_display(is_async_mode)?; if guard.virtual_displays.is_empty() { log::debug!("No virtual displays"); bail!("No virtual displays."); } let reg_connectivity_1 = reg_display_settings::read_reg_connectivity()?; - guard.set_primary_display()?; + let primary_display_name = guard.set_primary_display()?; guard.disable_physical_displays()?; Self::commit_change_display(CDS_RESET)?; + // Explicitly set the resolution(virtual display) to 1920x1080. + allow_err!(crate::platform::change_resolution( + &primary_display_name, + 1920, + 1080 + )); let reg_connectivity_2 = reg_display_settings::read_reg_connectivity()?; if let Some(reg_recovery) = @@ -465,7 +524,9 @@ impl PrivacyMode for PrivacyModeImpl { super::win_input::unhook()?; let _tmp_ignore_changed_holder = crate::display_service::temp_ignore_displays_changed(); self.restore(); - restore_reg_connectivity(false); + // We need to force restore the registry connectivity. + // This is because the registry connection may be changed by `self.restore()`, but will not be fully restored. + restore_reg_connectivity(false, true); if self.conn_id != INVALID_PRIVACY_MODE_CONN_ID { if let Some(state) = state { @@ -506,7 +567,7 @@ fn reset_config_reg_connectivity() { Config::set_option(CONFIG_KEY_REG_RECOVERY.to_owned(), "".to_owned()); } -pub fn restore_reg_connectivity(plug_out_monitors: bool) { +pub fn restore_reg_connectivity(plug_out_monitors: bool, force: bool) { let config_recovery_value = Config::get_option(CONFIG_KEY_REG_RECOVERY); if config_recovery_value.is_empty() { return; @@ -517,7 +578,7 @@ pub fn restore_reg_connectivity(plug_out_monitors: bool) { if let Ok(reg_recovery) = serde_json::from_str::(&config_recovery_value) { - if let Err(e) = reg_display_settings::restore_reg_connectivity(reg_recovery) { + if let Err(e) = reg_display_settings::restore_reg_connectivity(reg_recovery, force) { log::error!("Failed restore_reg_connectivity, error: {}", e); } } diff --git a/src/rendezvous_mediator.rs b/src/rendezvous_mediator.rs index f5d81eaff..89d7fa01e 100644 --- a/src/rendezvous_mediator.rs +++ b/src/rendezvous_mediator.rs @@ -2,9 +2,9 @@ use std::{ net::SocketAddr, sync::{ atomic::{AtomicBool, Ordering}, - Arc, + Arc, RwLock, }, - time::Instant, + time::{Duration, Instant}, }; use uuid::Uuid; @@ -12,33 +12,59 @@ use uuid::Uuid; use hbb_common::{ allow_err, anyhow::{self, bail}, - config::{self, keys::*, option2bool, Config, CONNECT_TIMEOUT, REG_INTERVAL, RENDEZVOUS_PORT}, + config::{ + self, keys::*, option2bool, use_ws, Config, CONNECT_TIMEOUT, REG_INTERVAL, RENDEZVOUS_PORT, + }, futures::future::join_all, log, protobuf::Message as _, - proxy::Proxy, rendezvous_proto::*, sleep, - socket_client::{self, connect_tcp, is_ipv4}, - tcp::FramedStream, + socket_client::{self, connect_tcp, is_ipv4, new_direct_udp_for, new_udp_for}, tokio::{self, select, sync::Mutex, time::interval}, udp::FramedSocket, - AddrMangle, IntoTargetAddr, ResultType, TargetAddr, + AddrMangle, IntoTargetAddr, ResultType, Stream, TargetAddr, }; use crate::{ check_port, server::{check_zombie, new as new_server, ServerPtr}, - ui_interface::get_builtin_option, }; type Message = RendezvousMessage; lazy_static::lazy_static! { - static ref SOLVING_PK_MISMATCH: Arc> = Default::default(); + static ref SOLVING_PK_MISMATCH: Mutex = Default::default(); + static ref LAST_MSG: Mutex<(SocketAddr, Instant)> = Mutex::new((SocketAddr::new([0; 4].into(), 0), Instant::now())); + static ref LAST_RELAY_MSG: Mutex<(SocketAddr, Instant)> = Mutex::new((SocketAddr::new([0; 4].into(), 0), Instant::now())); } static SHOULD_EXIT: AtomicBool = AtomicBool::new(false); static MANUAL_RESTARTED: AtomicBool = AtomicBool::new(false); +static SENT_REGISTER_PK: AtomicBool = AtomicBool::new(false); +pub(crate) static NEEDS_DEPLOY: AtomicBool = AtomicBool::new(false); +// register_pk retry interval (ms) when device is awaiting deployment +const DEPLOY_RETRY_INTERVAL: i64 = 30_000; +lazy_static::lazy_static! { + static ref LAST_NOT_DEPLOYED_REGISTER: Mutex> = Mutex::new(None); +} + +// Single source of truth for the "awaiting deployment" backoff. The server has +// already told us this device is not in its db; until the operator runs +// `rustdesk --deploy --token ` there is no point re-running the +// register path more often than DEPLOY_RETRY_INTERVAL. Gating in the timer +// loops (rather than only inside register_pk) also avoids the +// last_register_sent / fails / latency / UDP-rebind churn the loop would +// otherwise spin on while no response ever comes back. +async fn deploy_register_throttled() -> bool { + if !NEEDS_DEPLOY.load(Ordering::SeqCst) { + return false; + } + LAST_NOT_DEPLOYED_REGISTER + .lock() + .await + .map(|t| (t.elapsed().as_millis() as i64) < DEPLOY_RETRY_INTERVAL) + .unwrap_or(false) +} #[derive(Clone)] pub struct RendezvousMediator { @@ -56,19 +82,19 @@ impl RendezvousMediator { } pub async fn start_all() { + crate::test_nat_type(); if config::is_outgoing_only() { loop { sleep(1.).await; } } crate::hbbs_http::sync::start(); - let mut nat_tested = false; + #[cfg(target_os = "windows")] + if crate::platform::is_installed() && crate::is_server() { + crate::updater::start_auto_update(); + } check_zombie(); let server = new_server(); - if Config::get_nat_type() == NatType::UNKNOWN_NAT as i32 { - crate::test_nat_type(); - nat_tested = true; - } if config::option2bool("stop-service", &Config::get_option("stop-service")) { crate::test_rendezvous_server(); } @@ -90,25 +116,32 @@ impl RendezvousMediator { if crate::is_server() { crate::platform::linux_desktop_manager::start_xdesktop(); } + scrap::codec::test_av1(); loop { + let timeout = Arc::new(RwLock::new(CONNECT_TIMEOUT)); let conn_start_time = Instant::now(); *SOLVING_PK_MISMATCH.lock().await = "".to_owned(); if !config::option2bool("stop-service", &Config::get_option("stop-service")) && !crate::platform::installing_service() { - if !nat_tested { - crate::test_nat_type(); - nat_tested = true; - } let mut futs = Vec::new(); let servers = Config::get_rendezvous_servers(); SHOULD_EXIT.store(false, Ordering::SeqCst); MANUAL_RESTARTED.store(false, Ordering::SeqCst); for host in servers.clone() { let server = server.clone(); + let timeout = timeout.clone(); futs.push(tokio::spawn(async move { if let Err(err) = Self::start(server, host).await { - log::error!("rendezvous mediator error: {err}"); + let err = format!("rendezvous mediator error: {err}"); + // When user reboot, there might be below error, waiting too long + // (CONNECT_TIMEOUT 18s) will make user think there is bug + if err.contains("10054") || err.contains("11001") { + // No such host is known. (os error 11001) + // An existing connection was forcibly closed by the remote host. (os error 10054): also happens for UDP + *timeout.write().unwrap() = 3000; + } + log::error!("{err}"); } // SHOULD_EXIT here is to ensure once one exits, the others also exit. SHOULD_EXIT.store(true, Ordering::SeqCst); @@ -119,11 +152,15 @@ impl RendezvousMediator { server.write().unwrap().close_connections(); } Config::reset_online(); + let timeout = *timeout.read().unwrap(); if !MANUAL_RESTARTED.load(Ordering::SeqCst) { let elapsed = conn_start_time.elapsed().as_millis() as u64; - if elapsed < CONNECT_TIMEOUT { - sleep(((CONNECT_TIMEOUT - elapsed) / 1000) as _).await; + if elapsed < timeout { + sleep(((timeout - elapsed) / 1000) as _).await; } + } else { + // https://github.com/rustdesk/rustdesk/issues/12233 + sleep(0.033).await; } } } @@ -143,7 +180,8 @@ impl RendezvousMediator { pub async fn start_udp(server: ServerPtr, host: String) -> ResultType<()> { let host = check_port(&host, RENDEZVOUS_PORT); - let (mut socket, mut addr) = socket_client::new_udp_for(&host, CONNECT_TIMEOUT).await?; + log::info!("start udp: {host}"); + let (mut socket, mut addr) = new_udp_for(&host, CONNECT_TIMEOUT).await?; let mut rz = Self { addr: addr.clone(), host: host.clone(), @@ -202,7 +240,7 @@ impl RendezvousMediator { log::debug!("Non-protobuf message bytes received: {:?}", bytes); } }, - Some(Err(e)) => bail!("Failed to receive next {}", e), // maybe socks5 tcp disconnected + Some(Err(e)) => bail!("Failed to receive next: {}", e), // maybe socks5 tcp disconnected None => { bail!("Socket receive none. Maybe socks5 server is down."); }, @@ -212,6 +250,14 @@ impl RendezvousMediator { if SHOULD_EXIT.load(Ordering::SeqCst) { break; } + // The server already told us this device is not deployed. Skip + // the whole register / fails / latency / UDP-rebind path until + // DEPLOY_RETRY_INTERVAL elapses, otherwise the loop spins every + // few seconds (log spam + misapplied network-recovery rebind) + // until the operator runs `rustdesk --deploy`. + if deploy_register_throttled().await { + continue; + } let now = Some(Instant::now()); let expired = last_register_resp.map(|x| x.elapsed().as_millis() as i64 >= REG_INTERVAL).unwrap_or(true); let timeout = last_register_sent.map(|x| x.elapsed().as_millis() as i64 >= reg_timeout).unwrap_or(false); @@ -275,10 +321,22 @@ impl RendezvousMediator { Config::set_key_confirmed(true); Config::set_host_key_confirmed(&self.host_prefix, true); *SOLVING_PK_MISMATCH.lock().await = "".to_owned(); + NEEDS_DEPLOY.store(false, Ordering::SeqCst); } Ok(register_pk_response::Result::UUID_MISMATCH) => { self.handle_uuid_mismatch(sink).await?; } + Ok(register_pk_response::Result::NOT_DEPLOYED) => { + if !NEEDS_DEPLOY.load(Ordering::SeqCst) { + log::warn!("Server requires deployment. Run `rustdesk --deploy --token ` on this device."); + } + NEEDS_DEPLOY.store(true, Ordering::SeqCst); + // Clear key_confirmed so the UI reflects the truth: this device is + // not currently registered. Covers the case where an online device + // was deleted by an admin while running. + Config::set_key_confirmed(false); + Config::set_host_key_confirmed(&self.host_prefix, false); + } _ => { log::error!("unknown RegisterPkResponse"); } @@ -327,6 +385,7 @@ impl RendezvousMediator { pub async fn start_tcp(server: ServerPtr, host: String) -> ResultType<()> { let host = check_port(&host, RENDEZVOUS_PORT); + log::info!("start tcp: {}", hbb_common::websocket::check_ws(&host)); let mut conn = connect_tcp(host.clone(), CONNECT_TIMEOUT).await?; let key = crate::get_key(true).await; crate::secure_tcp(&mut conn, &key).await?; @@ -340,7 +399,7 @@ impl RendezvousMediator { let mut last_register_sent: Option = None; let mut last_recv_msg = Instant::now(); // we won't support connecting to multiple rendzvous servers any more, so we can use a global variable here. - Config::set_host_key_confirmed(&host, false); + Config::set_host_key_confirmed(&rz.host_prefix, false); loop { let mut update_latency = || { let latency = last_register_sent @@ -354,6 +413,8 @@ impl RendezvousMediator { last_recv_msg = Instant::now(); let bytes = res.ok_or_else(|| anyhow::anyhow!("Rendezvous connection is reset by the peer"))??; if bytes.is_empty() { + // After fixing frequent register_pk, for websocket, nginx need to set proxy_read_timeout to more than 60 seconds, eg: 120s + // https://serverfault.com/questions/1060525/why-is-my-websocket-connection-gets-closed-in-60-seconds conn.send_bytes(bytes::Bytes::new()).await?; continue; // heartbeat } @@ -369,7 +430,7 @@ impl RendezvousMediator { bail!("Rendezvous connection is timeout"); } if (!Config::get_key_confirmed() || - !Config::get_host_key_confirmed(&host)) && + !Config::get_host_key_confirmed(&rz.host_prefix)) && last_register_sent.map(|x| x.elapsed().as_millis() as i64).unwrap_or(REG_INTERVAL) >= REG_INTERVAL { rz.register_pk(Sink::Stream(&mut conn)).await?; last_register_sent = Some(Instant::now()); @@ -383,15 +444,10 @@ impl RendezvousMediator { pub async fn start(server: ServerPtr, host: String) -> ResultType<()> { log::info!("start rendezvous mediator of {}", host); //If the investment agent type is http or https, then tcp forwarding is enabled. - let is_http_proxy = if let Some(conf) = Config::get_socks() { - let proxy = Proxy::from_conf(&conf, None)?; - proxy.is_http_or_https() - } else { - false - }; if (cfg!(debug_assertions) && option_env!("TEST_TCP").is_some()) - || is_http_proxy - || get_builtin_option(config::keys::OPTION_DISABLE_UDP) == "Y" + || Config::is_proxy() + || use_ws() + || crate::is_udp_disabled() { Self::start_tcp(server, host).await } else { @@ -400,6 +456,14 @@ impl RendezvousMediator { } async fn handle_request_relay(&self, rr: RequestRelay, server: ServerPtr) -> ResultType<()> { + let addr = AddrMangle::decode(&rr.socket_addr); + let last = *LAST_RELAY_MSG.lock().await; + *LAST_RELAY_MSG.lock().await = (addr, Instant::now()); + // skip duplicate relay request messages + if last.0 == addr && last.1.elapsed().as_millis() < 100 { + return Ok(()); + } + self.create_relay( rr.socket_addr.into(), rr.relay_server, @@ -407,6 +471,8 @@ impl RendezvousMediator { server, rr.secure, false, + Default::default(), + rr.control_permissions.clone().into_option(), ) .await } @@ -419,6 +485,8 @@ impl RendezvousMediator { server: ServerPtr, secure: bool, initiate: bool, + socket_addr_v6: bytes::Bytes, + control_permissions: Option, ) -> ResultType<()> { let peer_addr = AddrMangle::decode(&socket_addr); log::info!( @@ -435,6 +503,7 @@ impl RendezvousMediator { let mut rr = RelayResponse { socket_addr: socket_addr.into(), version: crate::VERSION.to_owned(), + socket_addr_v6, ..Default::default() }; if initiate { @@ -451,17 +520,41 @@ impl RendezvousMediator { peer_addr, secure, is_ipv4(&self.addr), + control_permissions, ) .await; Ok(()) } async fn handle_intranet(&self, fla: FetchLocalAddr, server: ServerPtr) -> ResultType<()> { + let addr = AddrMangle::decode(&fla.socket_addr); + let last = *LAST_MSG.lock().await; + *LAST_MSG.lock().await = (addr, Instant::now()); + // skip duplicate punch hole messages + if last.0 == addr && last.1.elapsed().as_millis() < 100 { + return Ok(()); + } + let peer_addr_v6 = hbb_common::AddrMangle::decode(&fla.socket_addr_v6); let relay_server = self.get_relay_server(fla.relay_server.clone()); - // nat64, go relay directly, because current hbbs will crash if demangle ipv6 address - if is_ipv4(&self.addr) && !config::is_disable_tcp_listen() && !Config::is_proxy() { + let relay = use_ws() || Config::is_proxy(); + let mut socket_addr_v6 = Default::default(); + if peer_addr_v6.port() > 0 && !relay { + socket_addr_v6 = start_ipv6( + peer_addr_v6, + addr, + server.clone(), + fla.control_permissions.clone().into_option(), + ) + .await; + } + if is_ipv4(&self.addr) && !relay && !config::is_disable_tcp_listen() { if let Err(err) = self - .handle_intranet_(fla.clone(), server.clone(), relay_server.clone()) + .handle_intranet_( + fla.clone(), + server.clone(), + relay_server.clone(), + socket_addr_v6.clone(), + ) .await { log::debug!("Failed to handle intranet: {:?}, will try relay", err); @@ -477,6 +570,8 @@ impl RendezvousMediator { server, true, true, + socket_addr_v6, + fla.control_permissions.into_option(), ) .await } @@ -486,6 +581,7 @@ impl RendezvousMediator { fla: FetchLocalAddr, server: ServerPtr, relay_server: String, + socket_addr_v6: bytes::Bytes, ) -> ResultType<()> { let peer_addr = AddrMangle::decode(&fla.socket_addr); log::debug!("Handle intranet from {:?}", peer_addr); @@ -501,19 +597,49 @@ impl RendezvousMediator { local_addr: AddrMangle::encode(local_addr).into(), relay_server, version: crate::VERSION.to_owned(), + socket_addr_v6, ..Default::default() }); let bytes = msg_out.write_to_bytes()?; socket.send_raw(bytes).await?; - crate::accept_connection(server.clone(), socket, peer_addr, true).await; + crate::accept_connection( + server.clone(), + socket, + peer_addr, + true, + fla.control_permissions.into_option(), + ) + .await; Ok(()) } async fn handle_punch_hole(&self, ph: PunchHole, server: ServerPtr) -> ResultType<()> { + let mut peer_addr = AddrMangle::decode(&ph.socket_addr); + let last = *LAST_MSG.lock().await; + *LAST_MSG.lock().await = (peer_addr, Instant::now()); + // skip duplicate punch hole messages + if last.0 == peer_addr && last.1.elapsed().as_millis() < 100 { + return Ok(()); + } + let peer_addr_v6 = hbb_common::AddrMangle::decode(&ph.socket_addr_v6); + let relay = use_ws() || Config::is_proxy() || ph.force_relay; + let mut socket_addr_v6 = Default::default(); + let control_permissions = ph.control_permissions.into_option(); + if peer_addr_v6.port() > 0 && !relay { + socket_addr_v6 = start_ipv6( + peer_addr_v6, + peer_addr, + server.clone(), + control_permissions.clone(), + ) + .await; + } let relay_server = self.get_relay_server(ph.relay_server); + // for ensure, websocket go relay directly if ph.nat_type.enum_value() == Ok(NatType::SYMMETRIC) || Config::get_nat_type() == NatType::SYMMETRIC as i32 - || config::is_disable_tcp_listen() + || relay + || (config::is_disable_tcp_listen() && ph.udp_port <= 0) { let uuid = Uuid::new_v4().to_string(); return self @@ -524,11 +650,29 @@ impl RendezvousMediator { server, true, true, + socket_addr_v6.clone(), + control_permissions, ) .await; } - let peer_addr = AddrMangle::decode(&ph.socket_addr); - log::debug!("Punch hole to {:?}", peer_addr); + use hbb_common::protobuf::Enum; + let nat_type = NatType::from_i32(Config::get_nat_type()).unwrap_or(NatType::UNKNOWN_NAT); + let msg_punch = PunchHoleSent { + socket_addr: ph.socket_addr, + id: Config::get_id(), + relay_server, + nat_type: nat_type.into(), + version: crate::VERSION.to_owned(), + socket_addr_v6, + ..Default::default() + }; + if ph.udp_port > 0 { + peer_addr.set_port(ph.udp_port as u16); + self.punch_udp_hole(peer_addr, server, msg_punch, control_permissions) + .await?; + return Ok(()); + } + log::debug!("Punch tcp hole to {:?}", peer_addr); let mut socket = { let socket = connect_tcp(&*self.host, CONNECT_TIMEOUT).await?; let local_addr = socket.local_addr(); @@ -538,23 +682,61 @@ impl RendezvousMediator { socket }; let mut msg_out = Message::new(); - use hbb_common::protobuf::Enum; - let nat_type = NatType::from_i32(Config::get_nat_type()).unwrap_or(NatType::UNKNOWN_NAT); - msg_out.set_punch_hole_sent(PunchHoleSent { - socket_addr: ph.socket_addr, - id: Config::get_id(), - relay_server, - nat_type: nat_type.into(), - version: crate::VERSION.to_owned(), - ..Default::default() - }); + msg_out.set_punch_hole_sent(msg_punch); let bytes = msg_out.write_to_bytes()?; socket.send_raw(bytes).await?; - crate::accept_connection(server.clone(), socket, peer_addr, true).await; + crate::accept_connection(server.clone(), socket, peer_addr, true, control_permissions) + .await; + Ok(()) + } + + async fn punch_udp_hole( + &self, + peer_addr: SocketAddr, + server: ServerPtr, + msg_punch: PunchHoleSent, + control_permissions: Option, + ) -> ResultType<()> { + let mut msg_out = Message::new(); + msg_out.set_punch_hole_sent(msg_punch); + let (socket, addr) = new_direct_udp_for(&self.host).await?; + let data = msg_out.write_to_bytes()?; + socket.send_to(&data, addr).await?; + let socket_cloned = socket.clone(); + tokio::spawn(async move { + for _ in 0..2 { + let tm = (hbb_common::time_based_rand() % 20 + 10) as f32 / 1000.; + hbb_common::sleep(tm).await; + socket.send_to(&data, addr).await.ok(); + } + }); + udp_nat_listen( + socket_cloned.clone(), + peer_addr, + peer_addr, + server, + control_permissions, + ) + .await?; Ok(()) } async fn register_pk(&mut self, socket: Sink<'_>) -> ResultType<()> { + // Throttle register_pk when the device is awaiting deployment: server + // already told us we're not in its db; sending more often than every + // DEPLOY_RETRY_INTERVAL ms is wasted traffic until the operator runs + // `rustdesk --deploy --token `. + if NEEDS_DEPLOY.load(Ordering::SeqCst) { + let mut last = LAST_NOT_DEPLOYED_REGISTER.lock().await; + if let Some(t) = *last { + if (t.elapsed().as_millis() as i64) < DEPLOY_RETRY_INTERVAL { + return Ok(()); + } + } + *last = Some(Instant::now()); + } else { + *LAST_NOT_DEPLOYED_REGISTER.lock().await = None; + } let mut msg_out = Message::new(); let pk = Config::get_key_pair().1; let uuid = hbb_common::get_uuid(); @@ -563,9 +745,11 @@ impl RendezvousMediator { id, uuid: uuid.into(), pk: pk.into(), + no_register_device: Config::no_register_device(), ..Default::default() }); socket.send(&msg_out).await?; + SENT_REGISTER_PK.store(true, Ordering::SeqCst); Ok(()) } @@ -690,6 +874,7 @@ async fn direct_server(server: ServerPtr) { hbb_common::Stream::from(stream, local_addr), addr, false, + None, // Direct connections don't have control_permissions ) .await ); @@ -705,7 +890,7 @@ async fn direct_server(server: ServerPtr) { enum Sink<'a> { Framed(&'a mut FramedSocket, &'a TargetAddr<'a>), - Stream(&'a mut FramedStream), + Stream(&'a mut Stream), } impl Sink<'_> { @@ -716,3 +901,92 @@ impl Sink<'_> { } } } + +async fn start_ipv6( + peer_addr_v6: SocketAddr, + peer_addr_v4: SocketAddr, + server: ServerPtr, + control_permissions: Option, +) -> bytes::Bytes { + crate::test_ipv6().await; + if let Some((socket, local_addr_v6)) = crate::get_ipv6_socket().await { + let server = server.clone(); + tokio::spawn(async move { + allow_err!( + udp_nat_listen( + socket.clone(), + peer_addr_v6, + peer_addr_v4, + server, + control_permissions + ) + .await + ); + }); + return local_addr_v6; + } + Default::default() +} + +async fn udp_nat_listen( + socket: Arc, + peer_addr: SocketAddr, + peer_addr_v4: SocketAddr, + server: ServerPtr, + control_permissions: Option, +) -> ResultType<()> { + let tm = Instant::now(); + let socket_cloned = socket.clone(); + let func = async { + socket.connect(peer_addr).await?; + let res = crate::punch_udp(socket.clone(), true).await?; + let stream = crate::kcp_stream::KcpStream::accept( + socket, + Duration::from_millis(CONNECT_TIMEOUT as _), + res, + ) + .await?; + crate::server::create_tcp_connection( + server, + stream.1, + peer_addr_v4, + true, + control_permissions, + ) + .await?; + Ok(()) + }; + func.await.map_err(|e: anyhow::Error| { + anyhow::anyhow!( + "Stop listening on {:?} for remote {peer_addr} with KCP, {:?} elapsed: {e}", + socket_cloned.local_addr(), + tm.elapsed() + ) + })?; + Ok(()) +} + +// When config is not yet synced from root, register_pk may have already been sent with a new generated pk. +// After config sync completes, the pk may change. This struct detects pk changes and triggers +// a re-registration by setting key_confirmed to false. +// NOTE: +// This only corrects PK registration for the current ID. If root uses a non-default mac-generated ID, +// this does not resolve the multi-ID issue by itself. +pub struct CheckIfResendPk { + pk: Option>, +} +impl CheckIfResendPk { + pub fn new() -> Self { + Self { + pk: Config::get_cached_pk(), + } + } +} +impl Drop for CheckIfResendPk { + fn drop(&mut self) { + if SENT_REGISTER_PK.load(Ordering::SeqCst) && Config::get_cached_pk() != self.pk { + Config::set_key_confirmed(false); + log::info!("Set key_confirmed to false due to pk changed, will resend register_pk"); + } + } +} diff --git a/src/server.rs b/src/server.rs index 02522db96..86f7b5396 100644 --- a/src/server.rs +++ b/src/server.rs @@ -24,16 +24,24 @@ use hbb_common::{ sodiumoxide::crypto::{box_, sign}, timeout, tokio, ResultType, Stream, }; +use scrap::camera; #[cfg(not(any(target_os = "android", target_os = "ios")))] use service::ServiceTmpl; use service::{EmptyExtraFieldService, GenericService, Service, Subscriber}; +use video_service::VideoSource; use crate::ipc::Data; pub mod audio_service; +#[cfg(target_os = "windows")] +pub mod terminal_helper; +#[cfg(not(any(target_os = "android", target_os = "ios")))] +pub mod terminal_service; cfg_if::cfg_if! { -if #[cfg(not(any(target_os = "android", target_os = "ios")))] { +if #[cfg(not(target_os = "ios"))] { mod clipboard_service; +#[cfg(target_os = "android")] +pub use clipboard_service::is_clipboard_service_ok; #[cfg(target_os = "linux")] pub(crate) mod wayland; #[cfg(target_os = "linux")] @@ -42,20 +50,24 @@ pub mod uinput; pub mod rdp_input; #[cfg(target_os = "linux")] pub mod dbus; +#[cfg(not(target_os = "android"))] pub mod input_service; } else { mod clipboard_service { pub const NAME: &'static str = ""; } -pub mod input_service { -pub const NAME_CURSOR: &'static str = ""; -pub const NAME_POS: &'static str = ""; -pub const NAME_WINDOW_FOCUS: &'static str = ""; -} } } +#[cfg(any(target_os = "android", target_os = "ios"))] +pub mod input_service { + pub const NAME_CURSOR: &'static str = ""; + pub const NAME_POS: &'static str = ""; + pub const NAME_WINDOW_FOCUS: &'static str = ""; +} + mod connection; +mod login_failure_check; pub mod display_service; #[cfg(windows)] pub mod portable_service; @@ -63,15 +75,21 @@ mod service; mod video_qos; pub mod video_service; +#[cfg(all(target_os = "windows", feature = "flutter"))] +pub mod printer_service; + pub type Childs = Arc>>; type ConnMap = HashMap; #[cfg(any(target_os = "macos", target_os = "linux"))] const CONFIG_SYNC_INTERVAL_SECS: f32 = 0.3; +#[cfg(any(target_os = "macos", target_os = "linux"))] +// 3s is enough for at least one initial sync attempt: +// 0.3s backoff + up to 1s connect timeout + up to 1s response timeout. +const CONFIG_SYNC_INITIAL_WAIT_SECS: u64 = 3; lazy_static::lazy_static! { pub static ref CHILD_PROCESS: Childs = Default::default(); - pub static ref CONN_COUNT: Arc> = Default::default(); // A client server used to provide local services(audio, video, clipboard, etc.) // for all initiative connections. // @@ -99,10 +117,18 @@ pub fn new() -> ServerPtr { }; server.add_service(Box::new(audio_service::new())); #[cfg(not(target_os = "ios"))] - server.add_service(Box::new(display_service::new())); + { + server.add_service(Box::new(display_service::new())); + server.add_service(Box::new(clipboard_service::new( + clipboard_service::NAME.to_owned(), + ))); + #[cfg(feature = "unix-file-copy-paste")] + server.add_service(Box::new(clipboard_service::new( + clipboard_service::FILE_NAME.to_owned(), + ))); + } #[cfg(not(any(target_os = "android", target_os = "ios")))] { - server.add_service(Box::new(clipboard_service::new())); if !display_service::capture_cursor_embedded() { server.add_service(Box::new(input_service::new_cursor())); server.add_service(Box::new(input_service::new_pos())); @@ -111,23 +137,52 @@ pub fn new() -> ServerPtr { // wayland does not support multiple displays currently server.add_service(Box::new(input_service::new_window_focus())); } + #[cfg(not(target_os = "linux"))] + server.add_service(Box::new(input_service::new_window_focus())); } } + #[cfg(all(target_os = "windows", feature = "flutter"))] + { + match printer_service::init(&crate::get_app_name()) { + Ok(()) => { + log::info!("printer service initialized"); + server.add_service(Box::new(printer_service::new( + printer_service::NAME.to_owned(), + ))); + } + Err(e) => { + log::error!("printer service init failed: {}", e); + } + } + } + // Terminal service is created per connection, not globally Arc::new(RwLock::new(server)) } -async fn accept_connection_(server: ServerPtr, socket: Stream, secure: bool) -> ResultType<()> { +async fn accept_connection_( + server: ServerPtr, + socket: Stream, + secure: bool, + control_permissions: Option, +) -> ResultType<()> { let local_addr = socket.local_addr(); drop(socket); // even we drop socket, below still may fail if not use reuse_addr, // there is TIME_WAIT before socket really released, so sometimes we - // see “Only one usage of each socket address is normally permitted” on windows sometimes, + // see "Only one usage of each socket address is normally permitted" on windows sometimes, let listener = new_listener(local_addr, true).await?; log::info!("Server listening on: {}", &listener.local_addr()?); if let Ok((stream, addr)) = timeout(CONNECT_TIMEOUT, listener.accept()).await? { stream.set_nodelay(true).ok(); let stream_addr = stream.local_addr()?; - create_tcp_connection(server, Stream::from(stream, stream_addr), addr, secure).await?; + create_tcp_connection( + server, + Stream::from(stream, stream_addr), + addr, + secure, + control_permissions, + ) + .await?; } Ok(()) } @@ -137,6 +192,7 @@ pub async fn create_tcp_connection( stream: Stream, addr: SocketAddr, secure: bool, + control_permissions: Option, ) -> ResultType<()> { let mut stream = stream; let id = server.write().unwrap().get_new_id(); @@ -195,14 +251,23 @@ pub async fn create_tcp_connection( #[cfg(target_os = "macos")] { use std::process::Command; - Command::new("/usr/bin/caffeinate") + if let Ok(task) = Command::new("/usr/bin/caffeinate") .arg("-u") .arg("-t 5") .spawn() - .ok(); + { + super::CHILD_PROCESS.lock().unwrap().push(task); + } log::info!("wake up macos"); } - Connection::start(addr, stream, id, Arc::downgrade(&server)).await; + Connection::start( + addr, + stream, + id, + Arc::downgrade(&server), + control_permissions, + ) + .await; Ok(()) } @@ -211,9 +276,10 @@ pub async fn accept_connection( socket: Stream, peer_addr: SocketAddr, secure: bool, + control_permissions: Option, ) { - if let Err(err) = accept_connection_(server, socket, secure).await { - log::error!("Failed to accept connection from {}: {}", peer_addr, err); + if let Err(err) = accept_connection_(server, socket, secure, control_permissions).await { + log::warn!("Failed to accept connection from {}: {}", peer_addr, err); } } @@ -224,9 +290,18 @@ pub async fn create_relay_connection( peer_addr: SocketAddr, secure: bool, ipv4: bool, + control_permissions: Option, ) { - if let Err(err) = - create_relay_connection_(server, relay_server, uuid.clone(), peer_addr, secure, ipv4).await + if let Err(err) = create_relay_connection_( + server, + relay_server, + uuid.clone(), + peer_addr, + secure, + ipv4, + control_permissions, + ) + .await { log::error!( "Failed to create relay connection for {} with uuid {}: {}", @@ -244,6 +319,7 @@ async fn create_relay_connection_( peer_addr: SocketAddr, secure: bool, ipv4: bool, + control_permissions: Option, ) -> ResultType<()> { let mut stream = socket_client::connect_tcp( socket_client::ipv4_to_ipv6(crate::check_port(relay_server, RELAY_PORT), ipv4), @@ -258,28 +334,59 @@ async fn create_relay_connection_( ..Default::default() }); stream.send(&msg_out).await?; - create_tcp_connection(server, stream, peer_addr, secure).await?; + create_tcp_connection(server, stream, peer_addr, secure, control_permissions).await?; Ok(()) } impl Server { fn is_video_service_name(name: &str) -> bool { - name.starts_with(video_service::NAME) + name.starts_with(VideoSource::Monitor.service_name_prefix()) + || name.starts_with(VideoSource::Camera.service_name_prefix()) + } + + pub fn try_add_primary_camera_service(&mut self) { + if !camera::primary_camera_exists() { + return; + } + let primary_camera_name = + video_service::get_service_name(VideoSource::Camera, camera::PRIMARY_CAMERA_IDX); + if !self.contains(&primary_camera_name) { + self.add_service(Box::new(video_service::new( + VideoSource::Camera, + camera::PRIMARY_CAMERA_IDX, + ))); + } } pub fn try_add_primay_video_service(&mut self) { - let primary_video_service_name = - video_service::get_service_name(*display_service::PRIMARY_DISPLAY_IDX); + let primary_video_service_name = video_service::get_service_name( + VideoSource::Monitor, + *display_service::PRIMARY_DISPLAY_IDX, + ); if !self.contains(&primary_video_service_name) { self.add_service(Box::new(video_service::new( + VideoSource::Monitor, *display_service::PRIMARY_DISPLAY_IDX, ))); } } + pub fn add_camera_connection(&mut self, conn: ConnInner) { + if camera::primary_camera_exists() { + let primary_camera_name = + video_service::get_service_name(VideoSource::Camera, camera::PRIMARY_CAMERA_IDX); + if let Some(s) = self.services.get(&primary_camera_name) { + s.on_subscribe(conn.clone()); + } + } + self.connections.insert(conn.id(), conn); + } + pub fn add_connection(&mut self, conn: ConnInner, noperms: &Vec<&'static str>) { - let primary_video_service_name = - video_service::get_service_name(*display_service::PRIMARY_DISPLAY_IDX); + let primary_video_service_name = video_service::get_service_name( + VideoSource::Monitor, + *display_service::PRIMARY_DISPLAY_IDX, + ); for s in self.services.values() { let name = s.name(); if Self::is_video_service_name(&name) && name != primary_video_service_name { @@ -292,7 +399,6 @@ impl Server { #[cfg(target_os = "macos")] self.update_enable_retina(); self.connections.insert(conn.id(), conn); - *CONN_COUNT.lock().unwrap() = self.connections.len(); } pub fn remove_connection(&mut self, conn: &ConnInner) { @@ -300,7 +406,6 @@ impl Server { s.on_unsubscribe(conn.id()); } self.connections.remove(&conn.id()); - *CONN_COUNT.lock().unwrap() = self.connections.len(); #[cfg(target_os = "macos")] self.update_enable_retina(); } @@ -346,10 +451,15 @@ impl Server { self.id_count } - pub fn set_video_service_opt(&self, display: Option, opt: &str, value: &str) { + pub fn set_video_service_opt( + &self, + display: Option<(VideoSource, usize)>, + opt: &str, + value: &str, + ) { for (k, v) in self.services.iter() { - if let Some(display) = display { - if k != &video_service::get_service_name(display) { + if let Some((source, display)) = display { + if k != &video_service::get_service_name(source, display) { continue; } } @@ -377,13 +487,14 @@ impl Server { fn capture_displays( &mut self, conn: ConnInner, + source: VideoSource, displays: &[usize], include: bool, exclude: bool, ) { let displays = displays .iter() - .map(|d| video_service::get_service_name(*d)) + .map(|d| video_service::get_service_name(source, *d)) .collect::>(); let keys = self.services.keys().cloned().collect::>(); for name in keys.iter() { @@ -494,11 +605,10 @@ pub async fn start_server(is_server: bool, no_server: bool) { allow_err!(input_service::setup_uinput(0, 1920, 0, 1080).await); } #[cfg(any(target_os = "macos", target_os = "linux"))] - tokio::spawn(async { sync_and_watch_config_dir().await }); + wait_initial_config_sync().await; #[cfg(target_os = "windows")] crate::platform::try_kill_broker(); #[cfg(feature = "hwcodec")] - #[cfg(not(any(target_os = "android", target_os = "ios")))] scrap::hwcodec::start_check_process(); crate::RendezvousMediator::start_all().await; } else { @@ -580,19 +690,49 @@ pub async fn start_ipc_url_server() { } #[cfg(any(target_os = "macos", target_os = "linux"))] -async fn sync_and_watch_config_dir() { +async fn wait_initial_config_sync() { if crate::platform::is_root() { return; } + // Non-server process should not block startup, but still keeps background sync/watch alive. + if !crate::is_server() { + tokio::spawn(async move { + sync_and_watch_config_dir(None).await; + }); + return; + } + + let (sync_done_tx, mut sync_done_rx) = tokio::sync::oneshot::channel::<()>(); + tokio::spawn(async move { + sync_and_watch_config_dir(Some(sync_done_tx)).await; + }); + + // Server process waits up to N seconds for initial root->local sync to reduce stale-start window. + tokio::select! { + _ = &mut sync_done_rx => { + } + _ = tokio::time::sleep(Duration::from_secs(CONFIG_SYNC_INITIAL_WAIT_SECS)) => { + log::warn!( + "timed out waiting {}s for initial config sync, continue startup and keep syncing in background", + CONFIG_SYNC_INITIAL_WAIT_SECS + ); + } + } +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +async fn sync_and_watch_config_dir(sync_done_tx: Option>) { let mut cfg0 = (Config::get(), Config2::get()); let mut synced = false; + let mut is_root_config_empty = false; + let mut sync_done_tx = sync_done_tx; let tries = if crate::is_server() { 30 } else { 3 }; log::debug!("#tries of ipc service connection: {}", tries); use hbb_common::sleep; for i in 1..=tries { sleep(i as f32 * CONFIG_SYNC_INTERVAL_SECS).await; - match crate::ipc::connect(1000, "_service").await { + match crate::ipc::connect_service(1000).await { Ok(mut conn) => { if !synced { if conn.send(&Data::SyncConfig(None)).await.is_ok() { @@ -601,6 +741,8 @@ async fn sync_and_watch_config_dir() { Data::SyncConfig(Some(configs)) => { let (config, config2) = *configs; let _chk = crate::ipc::CheckIfRestart::new(); + #[cfg(target_os = "macos")] + let _chk_pk = crate::CheckIfResendPk::new(); if !config.is_empty() { if cfg0.0 != config { cfg0.0 = config.clone(); @@ -612,28 +754,51 @@ async fn sync_and_watch_config_dir() { Config2::set(config2); log::info!("sync config2 from root"); } + } else { + // only on macos, because this issue was only reproduced on macos + #[cfg(target_os = "macos")] + { + // root config is empty, mark for sync in watch loop + // to prevent root from generating a new config on login screen + is_root_config_empty = true; + } } synced = true; + // Notify startup waiter once initial sync phase finishes successfully. + if let Some(tx) = sync_done_tx.take() { + let _ = tx.send(()); + } } _ => {} }; }; } + if !synced { + log::warn!( + "initial config sync from root failed, reconnecting to ipc_service" + ); + continue; + } } loop { sleep(CONFIG_SYNC_INTERVAL_SECS).await; let cfg = (Config::get(), Config2::get()); - if cfg != cfg0 { - log::info!("config updated, sync to root"); + let should_sync = + cfg != cfg0 || (is_root_config_empty && !cfg.0.is_empty()); + if should_sync { + if is_root_config_empty { + log::info!("root config is empty, sync our config to root"); + } else { + log::info!("config updated, sync to root"); + } match conn.send(&Data::SyncConfig(Some(cfg.clone().into()))).await { Err(e) => { log::error!("sync config to root failed: {}", e); - match crate::ipc::connect(1000, "_service").await { + match crate::ipc::connect_service(1000).await { Ok(mut _conn) => { conn = _conn; log::info!("reconnected to ipc_service"); - break; } _ => {} } @@ -641,6 +806,7 @@ async fn sync_and_watch_config_dir() { _ => { cfg0 = cfg; conn.next_timeout(1000).await.ok(); + is_root_config_empty = false; } } } @@ -651,6 +817,10 @@ async fn sync_and_watch_config_dir() { } } } + // Notify startup waiter even when initial sync is skipped/failed, to avoid unnecessary waiting. + if let Some(tx) = sync_done_tx.take() { + let _ = tx.send(()); + } log::warn!("skipped config sync"); } diff --git a/src/server/audio_service.rs b/src/server/audio_service.rs index f227bd232..d1bb2d878 100644 --- a/src/server/audio_service.rs +++ b/src/server/audio_service.rs @@ -78,6 +78,19 @@ pub fn restart() { #[cfg(any(target_os = "linux", target_os = "android"))] mod pa_impl { use super::*; + + // SAFETY: constrains of hbb_common::mem::aligned_u8_vec must be held + unsafe fn align_to_32(data: Vec) -> Vec { + if (data.as_ptr() as usize & 3) == 0 { + return data; + } + + let mut buf = vec![]; + buf = unsafe { hbb_common::mem::aligned_u8_vec(data.len(), 4) }; + buf.extend_from_slice(data.as_ref()); + buf + } + #[tokio::main(flavor = "current_thread")] pub async fn run(sp: EmptyExtraFieldService) -> ResultType<()> { hbb_common::sleep(0.1).await; // one moment to wait for _pa ipc @@ -106,23 +119,29 @@ mod pa_impl { sps.send(create_format_msg(crate::platform::PA_SAMPLE_RATE, 2)); Ok(()) })?; + #[cfg(target_os = "linux")] if let Ok(data) = stream.next_raw().await { if data.len() == 0 { send_f32(&zero_audio_frame, &mut encoder, &sp); continue; } + if data.len() != AUDIO_DATA_SIZE_U8 { continue; } + + let data = unsafe { align_to_32(data.into()) }; let data = unsafe { std::slice::from_raw_parts::(data.as_ptr() as _, data.len() / 4) }; send_f32(data, &mut encoder, &sp); } + #[cfg(target_os = "android")] if scrap::android::ffi::get_audio_raw(&mut android_data, &mut vec![]).is_some() { let data = unsafe { + android_data = align_to_32(android_data); std::slice::from_raw_parts::( android_data.as_ptr() as _, android_data.len() / 4, @@ -137,6 +156,14 @@ mod pa_impl { } } +#[inline] +#[cfg(feature = "screencapturekit")] +pub fn is_screen_capture_kit_available() -> bool { + cpal::available_hosts() + .iter() + .any(|host| *host == cpal::HostId::ScreenCaptureKit) +} + #[cfg(not(any(target_os = "linux", target_os = "android")))] mod cpal_impl { use self::service::{Reset, ServiceSwap}; @@ -151,6 +178,11 @@ mod cpal_impl { static ref INPUT_BUFFER: Arc>> = Default::default(); } + #[cfg(feature = "screencapturekit")] + lazy_static::lazy_static! { + static ref HOST_SCREEN_CAPTURE_KIT: Result = cpal::host_from_id(cpal::HostId::ScreenCaptureKit); + } + #[derive(Default)] pub struct State { stream: Option<(Box, Arc)>, @@ -227,6 +259,27 @@ mod cpal_impl { send_f32(&data, encoder, sp); } + #[cfg(feature = "screencapturekit")] + fn get_device() -> ResultType<(Device, SupportedStreamConfig)> { + let audio_input = super::get_audio_input(); + if !audio_input.is_empty() { + return get_audio_input(&audio_input); + } + if !is_screen_capture_kit_available() { + return get_audio_input(""); + } + let device = HOST_SCREEN_CAPTURE_KIT + .as_ref()? + .default_input_device() + .with_context(|| "Failed to get default input device for loopback")?; + let format = device + .default_input_config() + .map_err(|e| anyhow!(e)) + .with_context(|| "Failed to get input output format")?; + log::info!("Default input format: {:?}", format); + Ok((device, format)) + } + #[cfg(windows)] fn get_device() -> ResultType<(Device, SupportedStreamConfig)> { let audio_input = super::get_audio_input(); @@ -248,7 +301,7 @@ mod cpal_impl { Ok((device, format)) } - #[cfg(not(windows))] + #[cfg(not(any(windows, feature = "screencapturekit")))] fn get_device() -> ResultType<(Device, SupportedStreamConfig)> { let audio_input = super::get_audio_input(); get_audio_input(&audio_input) @@ -256,7 +309,20 @@ mod cpal_impl { fn get_audio_input(audio_input: &str) -> ResultType<(Device, SupportedStreamConfig)> { let mut device = None; - if !audio_input.is_empty() { + #[cfg(feature = "screencapturekit")] + if !audio_input.is_empty() && is_screen_capture_kit_available() { + for d in HOST_SCREEN_CAPTURE_KIT + .as_ref()? + .devices() + .with_context(|| "Failed to get audio devices")? + { + if d.name().unwrap_or("".to_owned()) == audio_input { + device = Some(d); + break; + } + } + } + if device.is_none() && !audio_input.is_empty() { for d in HOST .devices() .with_context(|| "Failed to get audio devices")? diff --git a/src/server/clipboard_service.rs b/src/server/clipboard_service.rs index 3aadb3ad5..1d2f0a3fb 100644 --- a/src/server/clipboard_service.rs +++ b/src/server/clipboard_service.rs @@ -1,61 +1,94 @@ use super::*; -pub use crate::clipboard::{ - check_clipboard, ClipboardContext, ClipboardSide, CLIPBOARD_INTERVAL as INTERVAL, - CLIPBOARD_NAME as NAME, -}; +#[cfg(not(target_os = "android"))] +use crate::clipboard::clipboard_listener; +#[cfg(not(target_os = "android"))] +pub use crate::clipboard::{check_clipboard, ClipboardContext, ClipboardSide}; +pub use crate::clipboard::{CLIPBOARD_INTERVAL as INTERVAL, CLIPBOARD_NAME as NAME}; #[cfg(windows)] use crate::ipc::{self, ClipboardFile, ClipboardNonFile, Data}; -use clipboard_master::{CallbackResult, ClipboardHandler}; +#[cfg(feature = "unix-file-copy-paste")] +pub use crate::{ + clipboard::{check_clipboard_files, FILE_CLIPBOARD_NAME as FILE_NAME}, + clipboard_file::unix_file_clip, +}; +#[cfg(all(feature = "unix-file-copy-paste", target_os = "linux"))] +use clipboard::platform::unix::fuse::{init_fuse_context, uninit_fuse_context}; +#[cfg(not(target_os = "android"))] +use clipboard_master::CallbackResult; +#[cfg(target_os = "android")] +use hbb_common::config::{keys, option2bool}; +#[cfg(target_os = "android")] +use std::sync::atomic::{AtomicBool, Ordering}; use std::{ io, - sync::mpsc::{channel, RecvTimeoutError, Sender}, + sync::mpsc::{channel, RecvTimeoutError}, time::Duration, }; #[cfg(windows)] use tokio::runtime::Runtime; +#[cfg(target_os = "android")] +static CLIPBOARD_SERVICE_OK: AtomicBool = AtomicBool::new(false); + +#[cfg(not(target_os = "android"))] struct Handler { - sp: EmptyExtraFieldService, ctx: Option, - tx_cb_result: Sender, #[cfg(target_os = "windows")] stream: Option>, #[cfg(target_os = "windows")] rt: Option, } -pub fn new() -> GenericService { - let svc = EmptyExtraFieldService::new(NAME.to_owned(), true); +#[cfg(target_os = "android")] +pub fn is_clipboard_service_ok() -> bool { + CLIPBOARD_SERVICE_OK.load(Ordering::SeqCst) +} + +pub fn new(name: String) -> GenericService { + let svc = EmptyExtraFieldService::new(name, false); GenericService::run(&svc.clone(), run); svc.sp } +#[cfg(not(target_os = "android"))] fn run(sp: EmptyExtraFieldService) -> ResultType<()> { + #[cfg(all(feature = "unix-file-copy-paste", target_os = "linux"))] + let _fuse_call_on_ret = { + if sp.name() == FILE_NAME { + Some(init_fuse_context(false).map(|_| crate::SimpleCallOnReturn { + b: true, + f: Box::new(|| { + uninit_fuse_context(false); + }), + })) + } else { + None + } + }; + let (tx_cb_result, rx_cb_result) = channel(); - let handler = Handler { - sp: sp.clone(), - ctx: Some(ClipboardContext::new()?), - tx_cb_result, + let ctx = Some(ClipboardContext::new().map_err(|e| io::Error::new(io::ErrorKind::Other, e))?); + clipboard_listener::subscribe(sp.name(), tx_cb_result)?; + let mut handler = Handler { + ctx, #[cfg(target_os = "windows")] stream: None, #[cfg(target_os = "windows")] rt: None, }; - let (tx_start_res, rx_start_res) = channel(); - let h = crate::clipboard::start_clipbard_master_thread(handler, tx_start_res); - let shutdown = match rx_start_res.recv() { - Ok((Some(s), _)) => s, - Ok((None, err)) => { - bail!(err); - } - Err(e) => { - bail!("Failed to create clipboard listener: {}", e); - } - }; - while sp.ok() { match rx_cb_result.recv_timeout(Duration::from_millis(INTERVAL)) { + Ok(CallbackResult::Next) => { + #[cfg(feature = "unix-file-copy-paste")] + if sp.name() == FILE_NAME { + handler.check_clipboard_file(); + continue; + } + if let Some(msg) = handler.get_clipboard_msg() { + sp.send(msg); + } + } Ok(CallbackResult::Stop) => { log::debug!("Clipboard listener stopped"); break; @@ -64,35 +97,44 @@ fn run(sp: EmptyExtraFieldService) -> ResultType<()> { bail!("Clipboard listener stopped with error: {}", err); } Err(RecvTimeoutError::Timeout) => {} - _ => {} + Err(RecvTimeoutError::Disconnected) => { + log::error!("Clipboard listener disconnected"); + break; + } } } - shutdown.signal(); - h.join().ok(); + + clipboard_listener::unsubscribe(&sp.name()); Ok(()) } -impl ClipboardHandler for Handler { - fn on_clipboard_change(&mut self) -> CallbackResult { - self.sp.snapshot(|_sps| Ok(())).ok(); - if self.sp.ok() { - if let Some(msg) = self.get_clipboard_msg() { - self.sp.send(msg); +#[cfg(not(target_os = "android"))] +impl Handler { + #[cfg(feature = "unix-file-copy-paste")] + fn check_clipboard_file(&mut self) { + if let Some(urls) = check_clipboard_files(&mut self.ctx, ClipboardSide::Host, false) { + if !urls.is_empty() { + #[cfg(target_os = "macos")] + if crate::clipboard::is_file_url_set_by_rustdesk(&urls) { + return; + } + match clipboard::platform::unix::serv_files::sync_files(&urls) { + Ok(()) => { + // Use `send_data()` here to reuse `handle_file_clip()` in `connection.rs`. + hbb_common::allow_err!(clipboard::send_data( + 0, + unix_file_clip::get_format_list() + )); + } + Err(e) => { + log::error!("Failed to sync clipboard files: {}", e); + } + } } } - CallbackResult::Next } - fn on_clipboard_error(&mut self, error: io::Error) -> CallbackResult { - self.tx_cb_result - .send(CallbackResult::StopWithError(error)) - .ok(); - CallbackResult::Next - } -} - -impl Handler { fn get_clipboard_msg(&mut self) -> Option { #[cfg(target_os = "windows")] if crate::common::is_server() && crate::platform::is_root() { @@ -129,6 +171,7 @@ impl Handler { } } } + check_clipboard(&mut self.ctx, ClipboardSide::Host, false) } @@ -216,3 +259,16 @@ impl Handler { bail!("failed to get clipboard data from cm"); } } + +#[cfg(target_os = "android")] +fn run(sp: EmptyExtraFieldService) -> ResultType<()> { + CLIPBOARD_SERVICE_OK.store(sp.ok(), Ordering::SeqCst); + while sp.ok() { + if let Some(msg) = crate::clipboard::get_clipboards_msg(false) { + sp.send(msg); + } + std::thread::sleep(Duration::from_millis(INTERVAL)); + } + CLIPBOARD_SERVICE_OK.store(false, Ordering::SeqCst); + Ok(()) +} diff --git a/src/server/connection.rs b/src/server/connection.rs index c0cf8c784..538503d9c 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -1,4 +1,11 @@ +#[cfg(target_os = "windows")] +use super::login_failure_check::try_acquire_os_credential_login_gate; +use super::login_failure_check::{ + evaluate_os_credential_policy, record_os_credential_failure, FailureScope, +}; use super::{input_service::*, *}; +#[cfg(feature = "unix-file-copy-paste")] +use crate::clipboard::try_empty_clipboard_files; #[cfg(not(any(target_os = "android", target_os = "ios")))] use crate::clipboard::{update_clipboard, ClipboardSide}; #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] @@ -6,8 +13,6 @@ 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::is_x11; -#[cfg(target_os = "linux")] use crate::platform::linux_desktop_manager; #[cfg(any(target_os = "windows", target_os = "linux"))] use crate::platform::WallPaperRemover; @@ -22,17 +27,17 @@ use crate::{ #[cfg(any(target_os = "android", target_os = "ios"))] use crate::{common::DEVICE_NAME, flutter::connection_manager::start_channel}; use cidr_utils::cidr::IpCidr; -#[cfg(target_os = "linux")] -use hbb_common::platform::linux::run_cmds; #[cfg(target_os = "android")] use hbb_common::protobuf::EnumOrUnknown; use hbb_common::{ + config::decode_permanent_password_h1_from_storage, config::{self, keys, Config, TrustedDevice}, - fs::{self, can_enable_overwrite_detection}, + fs::{self, can_enable_overwrite_detection, JobType}, futures::{SinkExt, StreamExt}, get_time, get_version_number, message_proto::{option_message::BoolOption, permission_info::Permission}, password_security::{self as password, ApproveMode}, + sha2::{Digest, Sha256}, sleep, timeout, tokio::{ net::TcpStream, @@ -43,32 +48,84 @@ use hbb_common::{ }; #[cfg(any(target_os = "android", target_os = "ios"))] use scrap::android::{call_main_service_key_event, call_main_service_pointer_input}; +use scrap::camera; use serde_derive::Serialize; use serde_json::{json, value::Value}; -use sha2::{Digest, Sha256}; #[cfg(not(any(target_os = "android", target_os = "ios")))] use std::sync::atomic::Ordering; use std::{ + collections::HashSet, + net::Ipv6Addr, num::NonZeroI64, path::PathBuf, + str::FromStr, sync::{atomic::AtomicI64, mpsc as std_mpsc}, }; #[cfg(not(any(target_os = "android", target_os = "ios")))] use system_shutdown; +#[cfg(target_os = "windows")] +use windows::Win32::Foundation::{CloseHandle, HANDLE}; #[cfg(windows)] use crate::virtual_display_manager; -#[cfg(not(any(target_os = "ios")))] -use std::collections::HashSet; pub type Sender = mpsc::UnboundedSender<(Instant, Arc)>; lazy_static::lazy_static! { static ref LOGIN_FAILURES: [Arc::>>; 2] = Default::default(); static ref SESSIONS: Arc::>> = Default::default(); static ref ALIVE_CONNS: Arc::>> = Default::default(); - pub static ref AUTHED_CONNS: Arc::>> = Default::default(); - static ref SWITCH_SIDES_UUID: Arc::>> = Default::default(); + pub static ref AUTHED_CONNS: Arc::>> = Default::default(); + pub static ref CONTROL_PERMISSIONS_ARRAY: Arc::>> = Default::default(); static ref WAKELOCK_SENDER: Arc::>> = Arc::new(Mutex::new(start_wakelock_thread())); + static ref WAKELOCK_KEEP_AWAKE_OPTION: Arc::>> = Default::default(); +} + +#[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(); +} + +#[cfg(target_os = "windows")] +const TERMINAL_OS_LOGIN_FAILED_MSG: &str = "Incorrect username or password."; + +fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + // Avoid data-dependent early exits. + let mut x: u8 = 0; + for i in 0..a.len() { + x |= a[i] ^ b[i]; + } + 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() } #[cfg(any(target_os = "windows", target_os = "linux"))] @@ -123,9 +180,18 @@ pub struct ConnInner { tx_video: Option, } +struct InputMouse { + msg: MouseEvent, + conn_id: i32, + username: String, + argb: u32, + simulate: bool, + show_cursor: bool, +} + enum MessageInput { #[cfg(not(any(target_os = "android", target_os = "ios")))] - Mouse((MouseEvent, i32)), + Mouse(InputMouse), #[cfg(not(any(target_os = "android", target_os = "ios")))] Key((KeyEvent, bool)), #[cfg(not(any(target_os = "android", target_os = "ios")))] @@ -167,8 +233,28 @@ pub enum AuthConnType { Remote, FileTransfer, PortForward, + ViewCamera, + Terminal, } +#[cfg(not(any(target_os = "android", target_os = "ios")))] +#[derive(Clone, Debug)] +enum TerminalUserToken { + SelfUser, + #[cfg(target_os = "windows")] + CurrentLogonUser(crate::terminal_service::UserToken), +} + +#[cfg(not(any(target_os = "android", target_os = "ios")))] +impl TerminalUserToken { + fn to_terminal_service_token(&self) -> Option { + match self { + TerminalUserToken::SelfUser => None, + #[cfg(target_os = "windows")] + TerminalUserToken::CurrentLogonUser(token) => Some(*token), + } + } +} pub struct Connection { inner: ConnInner, display_idx: usize, @@ -179,6 +265,8 @@ pub struct Connection { timer: crate::RustDeskInterval, file_timer: crate::RustDeskInterval, file_transfer: Option<(String, bool)>, + view_camera: bool, + terminal: bool, port_forward_socket: Option>, port_forward_address: String, tx_to_cm: mpsc::UnboundedSender, @@ -191,6 +279,8 @@ pub struct Connection { restart: bool, recording: bool, block_input: bool, + privacy_mode: bool, + control_permissions: Option, last_test_delay: Option, network_delay: u32, lock_after_session_end: bool, @@ -200,6 +290,9 @@ pub struct Connection { // by peer disable_keyboard: bool, // by peer + #[cfg(not(any(target_os = "android", target_os = "ios")))] + show_my_cursor: bool, + // by peer disable_clipboard: bool, // by peer disable_audio: bool, @@ -215,6 +308,7 @@ pub struct Connection { server_audit_conn: String, server_audit_file: String, lr: LoginRequest, + peer_argb: u32, session_last_recv_time: Option>>, chat_unanswered: bool, file_transferred: bool, @@ -222,13 +316,13 @@ pub struct Connection { portable: PortableState, from_switch: bool, voice_call_request_timestamp: Option, + voice_calling: bool, options_in_login: Option, #[cfg(not(any(target_os = "ios")))] pressed_modifiers: HashSet, #[cfg(target_os = "linux")] linux_headless_handle: LinuxHeadlessHandle, closed: bool, - delay_response_instant: Instant, #[cfg(not(any(target_os = "android", target_os = "ios")))] start_cm_ipc_para: Option, auto_disconnect_timer: Option<(Instant, u64)>, @@ -242,6 +336,24 @@ pub struct Connection { follow_remote_cursor: bool, follow_remote_window: bool, multi_ui_session: bool, + tx_from_authed: mpsc::UnboundedSender, + printer_data: Vec<(Instant, String, Vec)>, + // For post requests that need to be sent sequentially. + // eg. post_conn_audit + tx_post_seq: mpsc::UnboundedSender<(String, Value)>, + // Tracks read job IDs delegated to CM process. + // When a read job is delegated to CM (via FS::ReadFile), the job id is added here. + // Used to filter stale responses (FileBlockFromCM, FileReadDone, etc.) for + // cancelled or unknown jobs. + cm_read_job_ids: HashSet, + terminal_service_id: String, + terminal_persistent: bool, + // The user token must be set when terminal is enabled. + // 0 indicates SYSTEM user + // other values indicate current user + #[cfg(not(any(target_os = "android", target_os = "ios")))] + terminal_user_token: Option, + terminal_generic_service: Option>, } impl ConnInner { @@ -292,8 +404,14 @@ impl Connection { stream: super::Stream, id: i32, server: super::ServerPtrWeak, + control_permissions: Option, ) { + // Android is not supported yet, so we always set control_permissions to None. + #[cfg(target_os = "android")] + let control_permissions = None; let _raii_id = raii::ConnectionID::new(id); + let _raii_control_permissions_id = + raii::ControlPermissionsID::new(id, &control_permissions); let hash = Hash { salt: Config::get_salt(), challenge: Config::get_auto_password(6), @@ -306,6 +424,7 @@ impl Connection { let (tx, mut rx) = mpsc::unbounded_channel::<(Instant, Arc)>(); let (tx_video, mut rx_video) = mpsc::unbounded_channel::<(Instant, Arc)>(); 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); @@ -315,6 +434,11 @@ impl Connection { 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; + }); + #[cfg(not(any(target_os = "android", target_os = "ios")))] let tx_cloned = tx.clone(); let mut conn = Self { @@ -332,18 +456,22 @@ impl Connection { timer: crate::rustdesk_interval(time::interval(SEC30)), file_timer: crate::rustdesk_interval(time::interval(SEC30)), file_transfer: None, + view_camera: false, + terminal: false, port_forward_socket: None, port_forward_address: "".to_owned(), tx_to_cm, authorized: false, - keyboard: Connection::permission("enable-keyboard"), - clipboard: Connection::permission("enable-clipboard"), - audio: Connection::permission("enable-audio"), + keyboard: Self::permission(keys::OPTION_ENABLE_KEYBOARD, &control_permissions), + clipboard: Self::permission(keys::OPTION_ENABLE_CLIPBOARD, &control_permissions), + audio: Self::permission(keys::OPTION_ENABLE_AUDIO, &control_permissions), // to-do: make sure is the option correct here - file: Connection::permission(keys::OPTION_ENABLE_FILE_TRANSFER), - restart: Connection::permission("enable-remote-restart"), - recording: Connection::permission("enable-record-session"), - block_input: Connection::permission("enable-block-input"), + file: Self::permission(keys::OPTION_ENABLE_FILE_TRANSFER, &control_permissions), + restart: Self::permission(keys::OPTION_ENABLE_REMOTE_RESTART, &control_permissions), + recording: Self::permission(keys::OPTION_ENABLE_RECORD_SESSION, &control_permissions), + block_input: Self::permission(keys::OPTION_ENABLE_BLOCK_INPUT, &control_permissions), + privacy_mode: Self::permission(keys::OPTION_ENABLE_PRIVACY_MODE, &control_permissions), + control_permissions, last_test_delay: None, network_delay: 0, lock_after_session_end: false, @@ -357,11 +485,14 @@ impl Connection { enable_file_transfer: false, disable_clipboard: false, disable_keyboard: false, + #[cfg(not(any(target_os = "android", target_os = "ios")))] + show_my_cursor: false, tx_input, video_ack_required: false, server_audit_conn: "".to_owned(), server_audit_file: "".to_owned(), lr: Default::default(), + peer_argb: 0u32, session_last_recv_time: None, chat_unanswered: false, file_transferred: false, @@ -370,13 +501,13 @@ impl Connection { from_switch: false, audio_sender: None, voice_call_request_timestamp: None, + voice_calling: false, options_in_login: None, #[cfg(not(any(target_os = "ios")))] pressed_modifiers: Default::default(), #[cfg(target_os = "linux")] linux_headless_handle, closed: false, - delay_response_instant: Instant::now(), #[cfg(not(any(target_os = "android", target_os = "ios")))] start_cm_ipc_para: Some(StartCmIpcPara { rx_to_cm, @@ -392,6 +523,15 @@ impl Connection { delayed_read_dir: None, #[cfg(target_os = "macos")] retina: Retina::default(), + tx_from_authed, + printer_data: Vec::new(), + tx_post_seq, + cm_read_job_ids: HashSet::new(), + terminal_service_id: "".to_owned(), + terminal_persistent: false, + #[cfg(not(any(target_os = "android", target_os = "ios")))] + terminal_user_token: None, + terminal_generic_service: None, }; let addr = hbb_common::try_into_v4(addr); if !conn.on_open(addr).await { @@ -427,12 +567,15 @@ impl Connection { if !conn.block_input { conn.send_permission(Permission::BlockInput, false).await; } + if !conn.privacy_mode { + conn.send_permission(Permission::PrivacyMode, false).await; + } let mut test_delay_timer = crate::rustdesk_interval(time::interval_at(Instant::now(), TEST_DELAY_TIMEOUT)); let mut last_recv_time = Instant::now(); conn.stream.set_send_timeout( - if conn.file_transfer.is_some() || conn.port_forward_socket.is_some() { + if conn.file_transfer.is_some() || conn.port_forward_socket.is_some() || conn.terminal { SEND_TIMEOUT_OTHER } else { SEND_TIMEOUT_VIDEO @@ -443,6 +586,28 @@ impl Connection { std::thread::spawn(move || Self::handle_input(_rx_input, tx_cloned)); let mut second_timer = crate::rustdesk_interval(time::interval(Duration::from_secs(1))); + #[cfg(feature = "unix-file-copy-paste")] + let rx_clip_holder; + let mut rx_clip; + let _tx_clip: mpsc::UnboundedSender; + #[cfg(feature = "unix-file-copy-paste")] + { + rx_clip_holder = ( + clipboard::get_rx_cliprdr_server(id), + crate::SimpleCallOnReturn { + b: true, + f: Box::new(move || { + clipboard::remove_channel_by_conn_id(id); + }), + }, + ); + rx_clip = rx_clip_holder.0.lock().await; + } + #[cfg(not(feature = "unix-file-copy-paste"))] + { + (_tx_clip, rx_clip) = mpsc::unbounded_channel::(); + } + loop { tokio::select! { // biased; // video has higher priority // causing test_delay_timer failed while transferring big file @@ -451,7 +616,9 @@ impl Connection { match data { ipc::Data::Authorize => { conn.require_2fa.take(); - conn.send_logon_response().await; + if !conn.send_logon_response_and_keep_alive().await { + break; + } if conn.port_forward_socket.is_some() { break; } @@ -463,11 +630,6 @@ impl Connection { conn.on_close("connection manager", true).await; break; } - #[cfg(target_os = "android")] - ipc::Data::InputControl(v) => { - conn.keyboard = v; - conn.send_permission(Permission::Keyboard, v).await; - } ipc::Data::CmErr(e) => { if e != "expected" { // cm closed before connection @@ -492,6 +654,15 @@ impl Connection { conn.keyboard = enabled; conn.send_permission(Permission::Keyboard, enabled).await; if let Some(s) = conn.server.upgrade() { + s.write().unwrap().subscribe( + super::clipboard_service::NAME, + conn.inner.clone(), conn.can_sub_clipboard_service()); + #[cfg(feature = "unix-file-copy-paste")] + s.write().unwrap().subscribe( + super::clipboard_service::FILE_NAME, + conn.inner.clone(), + conn.can_sub_file_clipboard_service(), + ); s.write().unwrap().subscribe( NAME_CURSOR, conn.inner.clone(), enabled || conn.show_remote_cursor); @@ -502,21 +673,41 @@ impl Connection { if let Some(s) = conn.server.upgrade() { s.write().unwrap().subscribe( super::clipboard_service::NAME, - conn.inner.clone(), conn.clipboard_enabled() && conn.peer_keyboard_enabled()); + conn.inner.clone(), conn.can_sub_clipboard_service()); } } else if &name == "audio" { conn.audio = enabled; conn.send_permission(Permission::Audio, enabled).await; if conn.authorized { if let Some(s) = conn.server.upgrade() { - s.write().unwrap().subscribe( - super::audio_service::NAME, - conn.inner.clone(), conn.audio_enabled()); + if conn.is_authed_view_camera_conn() { + if conn.voice_calling || !conn.audio_enabled() { + s.write().unwrap().subscribe( + super::audio_service::NAME, + conn.inner.clone(), conn.audio_enabled()); + } + } else { + s.write().unwrap().subscribe( + super::audio_service::NAME, + conn.inner.clone(), conn.audio_enabled()); + } } } } else if &name == "file" { conn.file = enabled; conn.send_permission(Permission::File, enabled).await; + #[cfg(feature = "unix-file-copy-paste")] + if !enabled { + conn.try_empty_file_clipboard(); + } + #[cfg(feature = "unix-file-copy-paste")] + if let Some(s) = conn.server.upgrade() { + s.write().unwrap().subscribe( + super::clipboard_service::FILE_NAME, + conn.inner.clone(), + conn.can_sub_file_clipboard_service(), + ); + } } else if &name == "restart" { conn.restart = enabled; conn.send_permission(Permission::Restart, enabled).await; @@ -526,14 +717,72 @@ impl Connection { } else if &name == "block_input" { conn.block_input = enabled; conn.send_permission(Permission::BlockInput, enabled).await; + } else if &name == "privacy_mode" { + // Keep permission state and runtime state consistent: + // when revoking the permission, try to leave privacy mode first. + // Otherwise we could end up in an inconsistent state where + // permission looks disabled while privacy mode is still active. + if !enabled && privacy_mode::is_in_privacy_mode() { + if let Some(conn_id) = privacy_mode::get_privacy_mode_conn_id() { + if conn_id == conn.inner.id() { + let impl_key = + privacy_mode::get_cur_impl_key().unwrap_or_default(); + let turn_off_res = + privacy_mode::turn_off_privacy(conn_id, None); + match turn_off_res { + Some(Ok(_)) => { + let msg_out = crate::common::make_privacy_mode_msg( + back_notification::PrivacyModeState::PrvOffByPeer, + impl_key.clone(), + ); + conn.send(msg_out).await; + } + _ => { + let msg_out = Self::turn_off_privacy_result_to_msg( + turn_off_res, + impl_key, + ); + conn.send(msg_out).await; + // Turn-off failed, so revert CM's optimistic toggle + // and keep the previous permission value. + conn.send_to_cm(ipc::Data::SwitchPermission { + name: "privacy_mode".to_owned(), + enabled: conn.privacy_mode, + }); + continue; + } + } + } + } + } + conn.privacy_mode = enabled; + conn.send_permission(Permission::PrivacyMode, enabled).await; } } ipc::Data::RawMessage(bytes) => { allow_err!(conn.stream.send_raw(bytes).await); } - #[cfg(any(target_os="windows", target_os="linux", target_os = "macos"))] + #[cfg(target_os = "windows")] ipc::Data::ClipboardFile(clip) => { - allow_err!(conn.stream.send(&clip_2_msg(clip)).await); + if !conn.is_remote() { + continue; + } + match clip { + clipboard::ClipboardFile::Files { files } => { + let files = files.into_iter().map(|(f, s)| { + (f, s as i64) + }).collect::>(); + conn.post_file_audit( + FileAuditType::RemoteSend, + "", + files, + json!({}), + ); + } + _ => { + allow_err!(conn.stream.send(&clip_2_msg(clip)).await); + } + } } ipc::Data::PrivacyModeState((_, state, impl_key)) => { let msg_out = match state { @@ -564,6 +813,8 @@ impl Connection { log::error!("Failed to start portable service from cm: {:?}", e); } } + #[cfg(feature = "flutter")] + #[cfg(not(any(target_os = "android", target_os = "ios")))] ipc::Data::SwitchSidesBack => { let mut misc = Misc::new(); misc.set_switch_back(SwitchBack::default()); @@ -581,6 +832,36 @@ impl Connection { let msg = new_voice_call_request(false); conn.send(msg).await; } + ipc::Data::ReadJobInitResult { id, file_num, include_hidden, conn_id, result } => { + if conn_id == conn.inner.id() { + conn.handle_read_job_init_result(id, file_num, include_hidden, result).await; + } + } + ipc::Data::FileBlockFromCM { id, file_num, data, compressed, conn_id } => { + if conn_id == conn.inner.id() { + conn.handle_file_block_from_cm(id, file_num, data, compressed).await; + } + } + ipc::Data::FileReadDone { id, file_num, conn_id } => { + if conn_id == conn.inner.id() { + conn.handle_file_read_done(id, file_num).await; + } + } + ipc::Data::FileReadError { id, file_num, err, conn_id } => { + if conn_id == conn.inner.id() { + conn.handle_file_read_error(id, file_num, err).await; + } + } + ipc::Data::FileDigestFromCM { id, file_num, last_modified, file_size, is_resume, conn_id } => { + if conn_id == conn.inner.id() { + conn.handle_file_digest_from_cm(id, file_num, last_modified, file_size, is_resume).await; + } + } + ipc::Data::AllFilesResult { id, conn_id, path, result } => { + if conn_id == conn.inner.id() { + conn.handle_all_files_result(id, path, result).await; + } + } _ => {} } }, @@ -640,7 +921,9 @@ impl Connection { } Some((instant, value)) = rx_video.recv() => { if !conn.video_ack_required { - video_service::notify_video_frame_fetched(id, Some(instant.into())); + if let Some(message::Union::VideoFrame(vf)) = &value.union { + video_service::notify_video_frame_fetched(vf.display as usize, id, Some(instant.into())); + } } if let Err(err) = conn.stream.send(&value as &Message).await { conn.on_close(&err.to_string(), false).await; @@ -690,7 +973,7 @@ impl Connection { } } Some(message::Union::MultiClipboards(_multi_clipboards)) => { - #[cfg(not(any(target_os = "android", target_os = "ios")))] + #[cfg(not(target_os = "ios"))] if let Some(msg_out) = crate::clipboard::get_msg_if_not_support_multi_clip(&conn.lr.version, &conn.lr.my_platform, _multi_clipboards) { if let Err(err) = conn.stream.send(&msg_out).await { conn.on_close(&err.to_string(), false).await; @@ -708,9 +991,23 @@ impl Connection { break; } }, + Some(data) = rx_from_authed.recv() => { + match data { + #[cfg(all(target_os = "windows", feature = "flutter"))] + ipc::Data::PrinterData(data) => { + if Self::permission(keys::OPTION_ENABLE_REMOTE_PRINTER, &conn.control_permissions) { + conn.send_printer_request(data).await; + } else { + conn.send_remote_printing_disallowed().await; + } + } + _ => {} + } + } _ = second_timer.tick() => { #[cfg(windows)] conn.portable_check(); + raii::AuthedConnID::check_wake_lock_on_setting_changed(); if let Some((instant, minute)) = conn.auto_disconnect_timer.as_ref() { if instant.elapsed().as_secs() > minute * 60 { conn.send_close_reason_no_retry("Connection failed due to inactivity").await; @@ -738,14 +1035,35 @@ impl Connection { }); conn.send(msg_out.into()).await; } - video_service::VIDEO_QOS.lock().unwrap().user_delay_response_elapsed(conn.inner.id(), conn.delay_response_instant.elapsed().as_millis()); + if conn.is_authed_remote_conn() || conn.view_camera { + if let Some(last_test_delay) = conn.last_test_delay { + video_service::VIDEO_QOS.lock().unwrap().user_delay_response_elapsed(id, last_test_delay.elapsed().as_millis()); + } + } } + clip_file = rx_clip.recv() => match clip_file { + Some(_clip) => { + #[cfg(feature = "unix-file-copy-paste")] + if crate::is_support_file_copy_paste(&conn.lr.version) + { + conn.handle_file_clip(_clip).await; + } + } + None => { + // + } + }, } } + #[cfg(feature = "unix-file-copy-paste")] + { + conn.try_empty_file_clipboard(); + } + if let Some(video_privacy_conn_id) = privacy_mode::get_privacy_mode_conn_id() { if video_privacy_conn_id == id { - let _ = Self::turn_off_privacy_to_msg(id); + let _ = Self::turn_off_privacy_to_msg(id, String::new()); } } #[cfg(all(feature = "flutter", feature = "plugin_framework"))] @@ -754,7 +1072,7 @@ impl Connection { crate::plugin::EVENT_ON_CONN_CLOSE_SERVER.to_owned(), conn.lr.my_id.clone(), ); - video_service::notify_video_frame_fetched(id, None); + video_service::notify_video_frame_fetched_by_conn_id(id, None); if conn.authorized { password::update_temporary_password(); } @@ -789,16 +1107,24 @@ impl Connection { loop { match receiver.recv_timeout(std::time::Duration::from_millis(500)) { Ok(v) => match v { - MessageInput::Mouse((msg, id)) => { - handle_mouse(&msg, id); + MessageInput::Mouse(mouse_input) => { + handle_mouse( + &mouse_input.msg, + mouse_input.conn_id, + mouse_input.username, + mouse_input.argb, + mouse_input.simulate, + mouse_input.show_cursor, + ); } MessageInput::Key((mut msg, press)) => { - // todo: press and down have similar meanings. - if press && msg.mode.enum_value() == Ok(KeyboardMode::Legacy) { + // Set the press state to false, use `down` only in `handle_key()`. + msg.press = false; + if press { msg.down = true; } handle_key(&msg); - if press && msg.mode.enum_value() == Ok(KeyboardMode::Legacy) { + if press { msg.down = false; handle_key(&msg); } @@ -870,7 +1196,14 @@ impl Connection { } #[cfg(target_os = "linux")] clear_remapped_keycode(); - log::info!("Input thread exited"); + log::debug!("Input thread exited"); + } + + async fn post_seq_loop(mut rx: mpsc::UnboundedReceiver<(String, Value)>) { + while let Some((url, v)) = rx.recv().await { + allow_err!(Self::post_audit_async(url, v).await); + } + log::debug!("post_seq_loop exited"); } async fn try_port_forward_loop( @@ -1027,9 +1360,23 @@ 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); - tokio::spawn(async move { - allow_err!(Self::post_audit_async(url, v).await); - }); + allow_err!(self.tx_post_seq.send((url, v))); + } + + fn get_files_for_audit(job_type: fs::JobType, mut files: Vec) -> Vec<(String, i64)> { + files + .drain(..) + .map(|f| { + ( + if job_type == fs::JobType::Printer { + "Remote print".to_owned() + } else { + f.name + }, + f.size as _, + ) + }) + .collect() } fn post_file_audit( @@ -1091,9 +1438,66 @@ impl Connection { crate::post_request(url, v.to_string(), "").await } - async fn send_logon_response(&mut self) { + fn normalize_port_forward_target(pf: &mut PortForward) -> (String, bool) { + let mut is_rdp = false; + if pf.host == "RDP" && pf.port == 0 { + pf.host = "localhost".to_owned(); + pf.port = 3389; + is_rdp = true; + } + if pf.host.is_empty() { + pf.host = "localhost".to_owned(); + } + (format!("{}:{}", pf.host, pf.port), is_rdp) + } + + async fn connect_port_forward_if_needed(&mut self) -> bool { + if self.port_forward_socket.is_some() { + return true; + } + let Some(login_request::Union::PortForward(pf)) = self.lr.union.as_ref() else { + return true; + }; + let mut pf = pf.clone(); + let (mut addr, is_rdp) = Self::normalize_port_forward_target(&mut pf); + self.port_forward_address = addr.clone(); + match timeout(3000, TcpStream::connect(&addr)).await { + Ok(Ok(sock)) => { + self.port_forward_socket = Some(Framed::new(sock, BytesCodec::new())); + true + } + Ok(Err(e)) => { + log::warn!("Port forward connect failed for {}: {}", addr, e); + if is_rdp { + addr = "RDP".to_owned(); + } + self.send_login_error(format!( + "Failed to access remote {}. Please make sure it is reachable/open.", + addr + )) + .await; + false + } + Err(e) => { + log::warn!("Port forward connect timed out for {}: {}", addr, e); + if is_rdp { + addr = "RDP".to_owned(); + } + self.send_login_error(format!( + "Failed to access remote {}. Please make sure it is reachable/open.", + addr + )) + .await; + false + } + } + } + + // Returns whether this connection should be kept alive. + // `true` does not necessarily mean authorization succeeded (e.g. REQUIRE_2FA case). + async fn send_logon_response_and_keep_alive(&mut self) -> bool { if self.authorized { - return; + return true; } if self.require_2fa.is_some() && !self.is_recent_session(true) && !self.from_switch { self.require_2fa.as_ref().map(|totp| { @@ -1124,13 +1528,24 @@ impl Connection { } }); self.send_login_error(crate::client::REQUIRE_2FA).await; - return; + // Keep the connection alive so the client can continue with 2FA. + return true; + } + if let Some(keep_alive) = self.prepare_terminal_login_for_authorization().await { + return keep_alive; + } + if !self.connect_port_forward_if_needed().await { + return false; } self.authorized = true; let (conn_type, auth_conn_type) = if self.file_transfer.is_some() { (1, AuthConnType::FileTransfer) } else if self.port_forward_socket.is_some() { (2, AuthConnType::PortForward) + } else if self.view_camera { + (3, AuthConnType::ViewCamera) + } else if self.terminal { + (4, AuthConnType::Terminal) } else { (0, AuthConnType::Remote) }; @@ -1138,6 +1553,8 @@ impl Connection { self.inner.id(), auth_conn_type, self.session_key(), + self.tx_from_authed.clone(), + self.lr.clone(), )); self.session_last_recv_time = SESSIONS .lock() @@ -1158,8 +1575,8 @@ impl Connection { #[cfg(not(target_os = "android"))] { - pi.hostname = whoami::hostname(); - pi.platform = whoami::platform().to_string(); + pi.hostname = crate::whoami_hostname(); + pi.platform = hbb_common::whoami::platform().to_string(); } #[cfg(target_os = "android")] { @@ -1167,7 +1584,7 @@ impl Connection { pi.platform = "Android".into(); } #[cfg(all(target_os = "macos", not(feature = "unix-file-copy-paste")))] - let platform_additions = serde_json::Map::new(); + let mut platform_additions = serde_json::Map::new(); #[cfg(any( target_os = "windows", target_os = "linux", @@ -1200,16 +1617,35 @@ impl Connection { json!(privacy_mode::get_supported_privacy_mode_impl()), ); } - - #[cfg(any( - target_os = "windows", - all( - any(target_os = "linux", target_os = "macos"), - feature = "unix-file-copy-paste" - ) - ))] + #[cfg(target_os = "macos")] { - platform_additions.insert("has_file_clipboard".into(), json!(true)); + platform_additions.insert( + "supported_privacy_mode_impl".into(), + json!(privacy_mode::get_supported_privacy_mode_impl()), + ); + } + + #[cfg(any(target_os = "windows", feature = "unix-file-copy-paste"))] + { + let is_both_windows = cfg!(target_os = "windows") + && self.lr.my_platform == hbb_common::whoami::Platform::Windows.to_string(); + #[cfg(feature = "unix-file-copy-paste")] + let is_unix_and_peer_supported = crate::is_support_file_copy_paste(&self.lr.version); + #[cfg(not(feature = "unix-file-copy-paste"))] + let is_unix_and_peer_supported = false; + let is_both_macos = cfg!(target_os = "macos") + && self.lr.my_platform == hbb_common::whoami::Platform::MacOS.to_string(); + let is_peer_support_paste_if_macos = + crate::is_support_file_paste_if_macos(&self.lr.version); + let has_file_clipboard = is_both_windows + || (is_unix_and_peer_supported + && (!is_both_macos || is_peer_support_paste_if_macos)); + platform_additions.insert("has_file_clipboard".into(), json!(has_file_clipboard)); + } + + #[cfg(any(target_os = "windows", target_os = "linux"))] + { + platform_additions.insert("support_view_camera".into(), json!(true)); } #[cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))] @@ -1222,10 +1658,10 @@ impl Connection { res.set_peer_info(pi); msg_out.set_login_response(res); self.send(msg_out).await; - return; + return true; } #[cfg(target_os = "linux")] - if !self.file_transfer.is_some() && !self.port_forward_socket.is_some() { + if self.is_remote() { let mut msg = "".to_string(); if crate::platform::linux::is_login_screen_wayland() { msg = crate::client::LOGIN_SCREEN_WAYLAND.to_owned() @@ -1245,7 +1681,7 @@ impl Connection { let mut msg_out = Message::new(); msg_out.set_login_response(res); self.send(msg_out).await; - return; + return true; } } #[allow(unused_mut)] @@ -1256,7 +1692,8 @@ impl Connection { } #[cfg(not(any(target_os = "android", target_os = "ios")))] if self.file_transfer.is_some() { - if crate::platform::is_prelogin() || self.tx_to_cm.send(ipc::Data::Test).is_err() { + if crate::platform::is_prelogin() { + // }|| self.tx_to_cm.send(ipc::Data::Test).is_err() { username = "".to_owned(); } } @@ -1267,10 +1704,19 @@ impl Connection { .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"))); + #[cfg(target_os = "windows")] + { + terminal = terminal && portable_pty::win::check_support().is_ok(); + } pi.username = username; pi.sas_enabled = sas_enabled; pi.features = Some(Features { privacy_mode: privacy_mode::is_privacy_mode_supported(), + #[cfg(not(any(target_os = "android", target_os = "ios")))] + terminal, ..Default::default() }) .into(); @@ -1279,9 +1725,34 @@ impl Connection { #[allow(unused_mut)] let mut wait_session_id_confirm = false; #[cfg(windows)] - self.handle_windows_specific_session(&mut pi, &mut wait_session_id_confirm); - if self.file_transfer.is_some() { + if !self.terminal { + self.handle_windows_specific_session(&mut pi, &mut wait_session_id_confirm); + } + if self.file_transfer.is_some() || self.terminal { res.set_peer_info(pi); + } else if self.view_camera { + let supported_encoding = scrap::codec::Encoder::supported_encoding(); + self.last_supported_encoding = Some(supported_encoding.clone()); + log::info!("peer info supported_encoding: {:?}", supported_encoding); + pi.encoding = Some(supported_encoding).into(); + + pi.displays = camera::Cameras::all_info().unwrap_or(Vec::new()); + pi.current_display = camera::PRIMARY_CAMERA_IDX as _; + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + pi.resolutions = Some(SupportedResolutions { + resolutions: camera::Cameras::get_camera_resolution( + pi.current_display as usize, + ) + .ok() + .into_iter() + .collect(), + ..Default::default() + }) + .into(); + } + res.set_peer_info(pi); + self.update_codec_on_login(); } else { let supported_encoding = scrap::codec::Encoder::supported_encoding(); self.last_supported_encoding = Some(supported_encoding.clone()); @@ -1349,15 +1820,43 @@ impl Connection { } else { self.delayed_read_dir = Some((dir.to_owned(), show_hidden)); } + } else if self.terminal { + self.keyboard = false; + #[cfg(not(any(target_os = "android", target_os = "ios")))] + self.init_terminal_service().await; + } else if self.view_camera { + if !wait_session_id_confirm { + self.try_sub_camera_displays(); + } + self.keyboard = false; + self.send_permission(Permission::Keyboard, false).await; } else if sub_service { if !wait_session_id_confirm { - self.try_sub_services(); + self.try_sub_monitor_services(); } } + true + } + + fn try_sub_camera_displays(&mut self) { + if let Some(s) = self.server.upgrade() { + let mut s = s.write().unwrap(); + + s.try_add_primary_camera_service(); + s.add_camera_connection(self.inner.clone()); + } } - fn try_sub_services(&mut self) { - let is_remote = self.file_transfer.is_none() && self.port_forward_socket.is_none(); + #[inline] + fn is_remote(&self) -> bool { + self.file_transfer.is_none() + && self.port_forward_socket.is_none() + && !self.view_camera + && !self.terminal + } + + fn try_sub_monitor_services(&mut self) { + let is_remote = self.is_remote(); if is_remote && !self.services_subed { self.services_subed = true; if let Some(s) = self.server.upgrade() { @@ -1371,12 +1870,13 @@ impl Connection { if !self.follow_remote_window { noperms.push(NAME_WINDOW_FOCUS); } - if !self.clipboard_enabled() - || !self.peer_keyboard_enabled() - || crate::get_builtin_option(keys::OPTION_ONE_WAY_CLIPBOARD_REDIRECTION) == "Y" - { + if !self.can_sub_clipboard_service() { noperms.push(super::clipboard_service::NAME); } + #[cfg(feature = "unix-file-copy-paste")] + if !self.can_sub_file_clipboard_service() { + noperms.push(super::clipboard_service::FILE_NAME); + } if !self.audio_enabled() { noperms.push(super::audio_service::NAME); } @@ -1400,7 +1900,7 @@ impl Connection { if let Some(current_sid) = crate::platform::get_current_process_session_id() { if crate::platform::is_installed() && crate::platform::is_share_rdp() - && raii::AuthedConnID::remote_and_file_conn_count() == 1 + && raii::AuthedConnID::non_port_forward_conn_count() == 1 && sessions.len() > 1 && sessions.iter().any(|e| e.sid == current_sid) && get_version_number(&self.lr.version) >= get_version_number("1.2.4") @@ -1446,22 +1946,39 @@ impl Connection { self.clipboard && !self.disable_clipboard } + #[inline] + fn can_sub_clipboard_service(&self) -> bool { + self.clipboard_enabled() + && self.peer_keyboard_enabled() + && crate::get_builtin_option(keys::OPTION_ONE_WAY_CLIPBOARD_REDIRECTION) != "Y" + } + fn audio_enabled(&self) -> bool { self.audio && !self.disable_audio } - #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] + #[cfg(any(target_os = "windows", feature = "unix-file-copy-paste"))] fn file_transfer_enabled(&self) -> bool { self.file && self.enable_file_transfer } + #[cfg(feature = "unix-file-copy-paste")] + fn can_sub_file_clipboard_service(&self) -> bool { + self.clipboard_enabled() + && self.file_transfer_enabled() + && crate::get_builtin_option(keys::OPTION_ONE_WAY_FILE_TRANSFER) != "Y" + } + fn try_start_cm(&mut self, peer_id: String, name: String, authorized: bool) { self.send_to_cm(ipc::Data::Login { id: self.inner.id(), is_file_transfer: self.file_transfer.is_some(), + is_view_camera: self.view_camera, + is_terminal: self.terminal, port_forward: self.port_forward_address.clone(), peer_id, name, + avatar: self.lr.avatar.clone(), authorized, keyboard: self.keyboard, clipboard: self.clipboard, @@ -1471,6 +1988,7 @@ impl Connection { restart: self.restart, recording: self.recording, block_input: self.block_input, + privacy_mode: self.privacy_mode, from_switch: self.from_switch, }); } @@ -1516,8 +2034,25 @@ impl Connection { #[inline] #[cfg(not(any(target_os = "android", target_os = "ios")))] - fn input_mouse(&self, msg: MouseEvent, conn_id: i32) { - self.tx_input.send(MessageInput::Mouse((msg, conn_id))).ok(); + fn input_mouse( + &self, + msg: MouseEvent, + conn_id: i32, + username: String, + argb: u32, + simulate: bool, + show_cursor: bool, + ) { + self.tx_input + .send(MessageInput::Mouse(InputMouse { + msg, + conn_id, + username, + argb, + simulate, + show_cursor, + })) + .ok(); } #[inline] @@ -1536,34 +2071,135 @@ impl Connection { self.tx_input.send(MessageInput::Key((msg, press))).ok(); } - fn validate_one_password(&self, password: String) -> bool { - if password.len() == 0 { - return false; - } - let mut hasher = Sha256::new(); - hasher.update(password); - hasher.update(&self.hash.salt); + fn verify_h1(&self, h1: &[u8]) -> bool { let mut hasher2 = Sha256::new(); - hasher2.update(&hasher.finalize()[..]); - hasher2.update(&self.hash.challenge); - hasher2.finalize()[..] == self.lr.password[..] + hasher2.update(h1); + hasher2.update(self.hash.challenge.as_bytes()); + // A normal `==` on slices may short-circuit on the first mismatch, which can leak how many leading + // bytes matched via timing. In typical remote scenarios this is difficult to exploit due to network + // jitter, changing challenges, and login attempt throttling, but a constant-time comparison here is + // low-cost defensive programming. + constant_time_eq(&hasher2.finalize()[..], &self.lr.password[..]) } - fn validate_password(&mut self) -> bool { + fn validate_password_plain(&self, password: &str) -> bool { + if password.is_empty() { + return false; + } + + let mut hasher = Sha256::new(); + hasher.update(password.as_bytes()); + hasher.update(self.hash.salt.as_bytes()); + let h1_plain = hasher.finalize(); + self.verify_h1(&h1_plain[..]) + } + + fn validate_password_storage(&self, storage: &str) -> bool { + if storage.is_empty() { + return false; + } + + // Use strict decode success to detect hashed storage. + // If decode fails, treat as legacy plaintext storage for compatibility. + if let Some(h1) = decode_permanent_password_h1_from_storage(storage) { + return self.verify_h1(&h1[..]); + } + + // Legacy plaintext storage path. + self.validate_password_plain(storage) + } + + // This is coarse brute-force protection for the current temporary password value. + // We only care whether the active temporary password itself was presented correctly, + // not whether later authorization steps succeed. A successful temporary-password + // match clears this state immediately, and the counter also resets whenever the + // temporary password changes or is rotated. + fn check_update_temporary_password(&self, temporary_password_success: bool) { + const MAX_CONSECUTIVE_FAILURES: i32 = 10; + #[derive(Default)] + struct State { + password: String, + failures: i32, + } + lazy_static::lazy_static! { + static ref TEMPORARY_PASSWORD_FAILURES: Mutex = + Mutex::new(State::default()); + } + + if !password::temporary_enabled() { + return; + } + + let mut state = TEMPORARY_PASSWORD_FAILURES.lock().unwrap(); + let current_password = password::temporary_password(); + if current_password.is_empty() { + return; + } + if state.password != current_password { + state.password = current_password; + state.failures = 0; + } + + if temporary_password_success { + state.failures = 0; + return; + } + state.failures += 1; + + if state.failures < MAX_CONSECUTIVE_FAILURES { + return; + } + + password::update_temporary_password(); + let new_password = password::temporary_password(); + log::warn!( + "Temporary password rotated after too many consecutive wrong attempts: failures={}, ip={}", + state.failures, + self.ip, + ); + state.password = new_password; + state.failures = 0; + } + + fn validate_password(&mut self, allow_permanent_password: bool) -> bool { if password::temporary_enabled() { let password = password::temporary_password(); - if self.validate_one_password(password.clone()) { + if self.validate_password_plain(&password) { raii::AuthedConnID::update_or_insert_session( self.session_key(), Some(password), Some(false), ); + self.check_update_temporary_password(true); return true; } } - if password::permanent_enabled() { - if self.validate_one_password(Config::get_permanent_password()) { - return true; + if password::permanent_enabled() || allow_permanent_password { + let print_fallback = || { + if allow_permanent_password && !password::permanent_enabled() { + log::info!("Permanent password accepted via logon-screen fallback"); + } + }; + // Since hashed storage uses a prefix-based encoding, a hard plaintext that + // happens to look like hashed storage could be mis-detected. Validate local storage + // and hard/preset plaintext via separate paths to avoid that ambiguity. + let (local_storage, _) = Config::get_local_permanent_password_storage_and_salt(); + if !local_storage.is_empty() { + if self.validate_password_storage(&local_storage) { + print_fallback(); + return true; + } + } else { + let hard = config::HARD_SETTINGS + .read() + .unwrap() + .get("password") + .cloned() + .unwrap_or_default(); + if !hard.is_empty() && self.validate_password_plain(&hard) { + print_fallback(); + return true; + } } } false @@ -1583,7 +2219,7 @@ impl Connection { if let Some(session) = session { if !self.lr.password.is_empty() && (tfa && session.tfa - || !tfa && self.validate_one_password(session.random_password.clone())) + || !tfa && self.validate_password_plain(&session.random_password)) { log::info!("is recent session"); return true; @@ -1592,7 +2228,8 @@ impl Connection { false } - pub fn permission(enable_prefix_option: &str) -> bool { + #[inline] + pub fn is_permission_enabled_locally(enable_prefix_option: &str) -> bool { #[cfg(feature = "flutter")] #[cfg(not(any(target_os = "android", target_os = "ios")))] { @@ -1609,6 +2246,38 @@ impl Connection { ) } + fn permission( + enable_prefix_option: &str, + control_permissions: &Option, + ) -> bool { + use hbb_common::rendezvous_proto::control_permissions::Permission; + if let Some(control_permissions) = control_permissions { + let permission = match enable_prefix_option { + keys::OPTION_ENABLE_KEYBOARD => Some(Permission::keyboard), + keys::OPTION_ENABLE_REMOTE_PRINTER => Some(Permission::remote_printer), + keys::OPTION_ENABLE_CLIPBOARD => Some(Permission::clipboard), + keys::OPTION_ENABLE_FILE_TRANSFER => Some(Permission::file), + keys::OPTION_ENABLE_AUDIO => Some(Permission::audio), + keys::OPTION_ENABLE_CAMERA => Some(Permission::camera), + keys::OPTION_ENABLE_TERMINAL => Some(Permission::terminal), + keys::OPTION_ENABLE_TUNNEL => Some(Permission::tunnel), + keys::OPTION_ENABLE_REMOTE_RESTART => Some(Permission::restart), + keys::OPTION_ENABLE_RECORD_SESSION => Some(Permission::recording), + keys::OPTION_ENABLE_BLOCK_INPUT => Some(Permission::block_input), + keys::OPTION_ENABLE_PRIVACY_MODE => Some(Permission::privacy_mode), + _ => None, + }; + if let Some(permission) = permission { + if let Some(enabled) = + crate::get_control_permission(control_permissions.permissions, permission) + { + return enabled; + } + } + } + Self::is_permission_enabled_locally(enable_prefix_option) + } + fn update_codec_on_login(&self) { use scrap::codec::{Encoder, EncodingUpdate::*}; if let Some(o) = self.lr.clone().option.as_ref() { @@ -1632,6 +2301,7 @@ impl Connection { 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); if let Some(o) = lr.option.as_ref() { self.options_in_login = Some(o.clone()); } @@ -1665,7 +2335,7 @@ impl Connection { ) .await { - log::error!("ipc to connection manager exit: {}", err); + 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)] if !crate::platform::is_prelogin() @@ -1686,6 +2356,16 @@ impl Connection { } async fn on_message(&mut self, msg: Message) -> bool { + if let Some(message::Union::Misc(misc)) = &msg.union { + // Move the CloseReason forward, as this message needs to be received when unauthorized, especially for kcp. + if let Some(misc::Union::CloseReason(s)) = &misc.union { + log::info!("receive close reason: {}", s); + self.on_close("Peer close", true).await; + raii::AuthedConnID::check_remove_session(self.inner.id(), self.session_key()); + return false; + } + } + // After handling CloseReason messages, proceed to process other message types if let Some(message::Union::LoginRequest(lr)) = msg.union { self.handle_login_request_without_validation(&lr).await; if self.authorized { @@ -1693,7 +2373,10 @@ impl Connection { } match lr.union { Some(login_request::Union::FileTransfer(ft)) => { - if !Connection::permission(keys::OPTION_ENABLE_FILE_TRANSFER) { + if !Self::permission( + keys::OPTION_ENABLE_FILE_TRANSFER, + &self.control_permissions, + ) { self.send_login_error("No permission of file transfer") .await; sleep(1.).await; @@ -1701,39 +2384,44 @@ impl Connection { } self.file_transfer = Some((ft.dir, ft.show_hidden)); } + Some(login_request::Union::ViewCamera(_vc)) => { + if !Self::permission(keys::OPTION_ENABLE_CAMERA, &self.control_permissions) { + self.send_login_error("No permission of viewing camera") + .await; + sleep(1.).await; + return false; + } + self.view_camera = true; + } + Some(login_request::Union::Terminal(terminal)) => { + if !Self::permission(keys::OPTION_ENABLE_TERMINAL, &self.control_permissions) { + self.send_login_error("No permission of terminal").await; + sleep(1.).await; + return false; + } + #[cfg(target_os = "windows")] + if !lr.os_login.username.is_empty() && !crate::platform::is_installed() { + self.send_login_error("Supported only in the installed version.") + .await; + sleep(1.).await; + return false; + } + + self.terminal = true; + if let Some(o) = self.options_in_login.as_ref() { + self.terminal_persistent = + o.terminal_persistent.enum_value() == Ok(BoolOption::Yes); + } + self.terminal_service_id = terminal.service_id; + } Some(login_request::Union::PortForward(mut pf)) => { - if !Connection::permission("enable-tunnel") { + if !Self::permission(keys::OPTION_ENABLE_TUNNEL, &self.control_permissions) { self.send_login_error("No permission of IP tunneling").await; sleep(1.).await; return false; } - let mut is_rdp = false; - if pf.host == "RDP" && pf.port == 0 { - pf.host = "localhost".to_owned(); - pf.port = 3389; - is_rdp = true; - } - if pf.host.is_empty() { - pf.host = "localhost".to_owned(); - } - let mut addr = format!("{}:{}", pf.host, pf.port); - self.port_forward_address = addr.clone(); - match timeout(3000, TcpStream::connect(&addr)).await { - Ok(Ok(sock)) => { - self.port_forward_socket = Some(Framed::new(sock, BytesCodec::new())); - } - _ => { - if is_rdp { - addr = "RDP".to_owned(); - } - self.send_login_error(format!( - "Failed to access remote {}, please make sure if it is open", - addr - )) - .await; - return false; - } - } + let (addr, _is_rdp) = Self::normalize_port_forward_target(&mut pf); + self.port_forward_address = addr; } _ => { if !self.check_privacy_mode_on().await { @@ -1742,8 +2430,43 @@ impl Connection { } } + if !hbb_common::is_ip_str(&lr.username) + && !hbb_common::is_domain_port_str(&lr.username) + && lr.username != Config::get_id() + { + self.send_login_error(crate::client::LOGIN_MSG_OFFLINE) + .await; + return false; + } + + #[cfg(target_os = "windows")] + if self.terminal + && lr.os_login.username.trim().is_empty() + && crate::platform::is_prelogin() + { + self.send_login_error( + "No active console user logged on, please connect and logon first.", + ) + .await; + sleep(1.).await; + return false; + } + #[cfg(not(any(target_os = "android", target_os = "ios")))] - self.try_start_cm_ipc(); + if !should_use_terminal_os_login_scope(self.terminal, &lr.os_login.username) { + 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(); @@ -1755,22 +2478,56 @@ impl Connection { // 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; } - if !hbb_common::is_ip_str(&lr.username) - && !hbb_common::is_domain_port_str(&lr.username) - && lr.username != Config::get_id() - { - self.send_login_error(crate::client::LOGIN_MSG_OFFLINE) - .await; - return false; - } else if (password::approve_mode() == ApproveMode::Click - && !(crate::platform::is_prelogin() - && crate::get_builtin_option(keys::OPTION_ALLOW_LOGON_SCREEN_PASSWORD) == "Y")) + // 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. + #[cfg(target_os = "windows")] + let is_logon = || { + crate::platform::is_prelogin() || crate::platform::is_locked() || { + match crate::platform::is_logon_ui() { + Ok(result) => result, + Err(e) => { + log::error!("Failed to detect logon UI: {:?}", e); + false + } + } + } + }; + #[cfg(any(target_os = "linux", target_os = "macos"))] + let is_logon = || crate::platform::is_prelogin() || crate::platform::is_locked(); + #[cfg(any(target_os = "android", target_os = "ios"))] + let is_logon = || crate::platform::is_prelogin(); + + let allow_logon_screen_password = + crate::get_builtin_option(keys::OPTION_ALLOW_LOGON_SCREEN_PASSWORD) == "Y" + && is_logon(); + + if (password::approve_mode() == ApproveMode::Click && !allow_logon_screen_password) || password::approve_mode() == ApproveMode::Both && !password::has_valid_password() { + #[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); if hbb_common::get_version_number(&lr.version) >= hbb_common::get_version_number("1.2.0") @@ -1783,13 +2540,23 @@ impl Connection { if err_msg.is_empty() { #[cfg(target_os = "linux")] self.linux_headless_handle.wait_desktop_cm_ready().await; - self.send_logon_response().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; } } 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; + } + } self.try_start_cm(lr.my_id, lr.my_name, false); } else { self.send_login_error( @@ -1802,8 +2569,9 @@ impl Connection { if !res { return true; } - if !self.validate_password() { - self.update_failure(failure, false, 0); + 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; @@ -1815,11 +2583,13 @@ impl Connection { .await; } } else { - self.update_failure(failure, true, 0); + 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; - self.send_logon_response().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; @@ -1837,7 +2607,9 @@ impl Connection { self.update_failure(failure, true, 1); self.require_2fa.take(); raii::AuthedConnID::set_session_2fa(self.session_key()); - self.send_logon_response().await; + if !self.send_logon_response_and_keep_alive().await { + return false; + } self.try_start_cm( self.lr.my_id.to_owned(), self.lr.my_name.to_owned(), @@ -1874,10 +2646,10 @@ impl Connection { .user_network_delay(self.inner.id(), new_delay); self.network_delay = new_delay; } - self.delay_response_instant = Instant::now(); } } else if let Some(message::Union::SwitchSidesResponse(_s)) = msg.union { #[cfg(feature = "flutter")] + #[cfg(not(any(target_os = "android", target_os = "ios")))] if let Some(lr) = _s.lr.clone().take() { self.handle_login_request_without_validation(&lr).await; SWITCH_SIDES_UUID @@ -1889,7 +2661,9 @@ impl Connection { if let Some((_instant, uuid_old)) = uuid_old { if uuid == uuid_old { self.from_switch = true; - self.send_logon_response().await; + if !self.send_logon_response_and_keep_alive().await { + return false; + } self.try_start_cm( lr.my_id.clone(), lr.my_name.clone(), @@ -1908,6 +2682,9 @@ impl Connection { match msg.union { #[allow(unused_mut)] Some(message::Union::MouseEvent(mut me)) => { + if self.is_authed_view_camera_conn() { + return true; + } #[cfg(any(target_os = "android", target_os = "ios"))] if let Err(e) = call_main_service_pointer_input("mouse", me.mask, me.x, me.y) { log::debug!("call_main_service_pointer_input fail:{}", e); @@ -1921,11 +2698,32 @@ impl Connection { } #[cfg(target_os = "macos")] self.retina.on_mouse_event(&mut me, self.display_idx); - self.input_mouse(me, self.inner.id()); + self.input_mouse( + me, + self.inner.id(), + self.lr.my_name.clone(), + self.peer_argb, + true, + self.show_my_cursor, + ); + } else if self.show_my_cursor { + #[cfg(target_os = "macos")] + self.retina.on_mouse_event(&mut me, self.display_idx); + self.input_mouse( + me, + self.inner.id(), + self.lr.my_name.clone(), + self.peer_argb, + false, + true, + ); } self.update_auto_disconnect_timer(); } Some(message::Union::PointerDeviceEvent(pde)) => { + if self.is_authed_view_camera_conn() { + return true; + } #[cfg(any(target_os = "android", target_os = "ios"))] if let Err(e) = match pde.union { Some(pointer_device_event::Union::TouchEvent(touch)) => match touch.union { @@ -1965,8 +2763,9 @@ impl Connection { Some(message::Union::KeyEvent(..)) => {} #[cfg(any(target_os = "android"))] Some(message::Union::KeyEvent(mut me)) => { - let is_press = (me.press || me.down) && !crate::is_modifier(&me); - + if self.is_authed_view_camera_conn() { + return true; + } let key = match me.mode.enum_value() { Ok(KeyboardMode::Map) => { Some(crate::keyboard::keycode_to_rdev_key(me.chr())) @@ -1982,6 +2781,9 @@ impl Connection { } .filter(crate::keyboard::is_modifier); + let is_press = + (me.press || me.down) && !(crate::is_modifier(&me) || key.is_some()); + if let Some(key) = key { if is_press { self.pressed_modifiers.insert(key); @@ -2016,20 +2818,15 @@ impl Connection { } #[cfg(not(any(target_os = "android", target_os = "ios")))] Some(message::Union::KeyEvent(me)) => { + if self.is_authed_view_camera_conn() { + return true; + } if self.peer_keyboard_enabled() { if is_enter(&me) { CLICK_TIME.store(get_time(), Ordering::SeqCst); } // https://github.com/rustdesk/rustdesk/issues/8633 MOUSE_MOVE_TIME.store(get_time(), Ordering::SeqCst); - // handle all down as press - // fix unexpected repeating key on remote linux, seems also fix abnormal alt/shift, which - // make sure all key are released - let is_press = if cfg!(target_os = "linux") { - (me.press || me.down) && !crate::is_modifier(&me) - } else { - me.press - }; let key = match me.mode.enum_value() { Ok(KeyboardMode::Map) => { @@ -2046,6 +2843,16 @@ impl Connection { } .filter(crate::keyboard::is_modifier); + // handle all down as press + // fix unexpected repeating key on remote linux, seems also fix abnormal alt/shift, which + // make sure all key are released + // https://github.com/rustdesk/rustdesk/issues/6793 + let is_press = if cfg!(target_os = "linux") { + (me.press || me.down) && !(crate::is_modifier(&me) || key.is_some()) + } else { + me.press + }; + if let Some(key) = key { if is_press { self.pressed_modifiers.insert(key); @@ -2074,7 +2881,9 @@ impl Connection { if self.clipboard { #[cfg(not(any(target_os = "android", target_os = "ios")))] update_clipboard(vec![cb], ClipboardSide::Host); - #[cfg(all(feature = "flutter", target_os = "android"))] + // ios as the controlled side is actually not supported for now. + // The following code is only used to preserve the logic of handling text clipboard on mobile. + #[cfg(target_os = "ios")] { let content = if cb.compress { hbb_common::compress::decompress(&cb.content) @@ -2092,25 +2901,102 @@ impl Connection { } } } + #[cfg(target_os = "android")] + crate::clipboard::handle_msg_clipboard(cb); } } - Some(message::Union::MultiClipboards(_mcb)) => - { + Some(message::Union::MultiClipboards(_mcb)) => { #[cfg(not(any(target_os = "android", target_os = "ios")))] if self.clipboard { update_clipboard(_mcb.clipboards, ClipboardSide::Host); } + #[cfg(target_os = "android")] + crate::clipboard::handle_msg_multi_clipboards(_mcb); } - Some(message::Union::Cliprdr(_clip)) => - { - #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] - if let Some(clip) = msg_2_clip(_clip) { - log::debug!("got clipfile from client peer"); - self.send_to_cm(ipc::Data::ClipboardFile(clip)) + #[cfg(any(target_os = "windows", feature = "unix-file-copy-paste"))] + Some(message::Union::Cliprdr(clip)) => { + if let Some(cliprdr::Union::Files(files)) = &clip.union { + self.post_file_audit( + FileAuditType::RemoteReceive, + "", + files + .files + .iter() + .map(|f| (f.name.clone(), f.size as i64)) + .collect::>(), + json!({}), + ); + } else if let Some(clip) = msg_2_clip(clip) { + #[cfg(target_os = "windows")] + { + self.send_to_cm(ipc::Data::ClipboardFile(clip)); + } + #[cfg(feature = "unix-file-copy-paste")] + if crate::is_support_file_copy_paste(&self.lr.version) { + let mut out_msgs = vec![]; + + #[cfg(target_os = "macos")] + if clipboard::platform::unix::macos::should_handle_msg(&clip) { + if let Err(e) = clipboard::ContextSend::make_sure_enabled() { + log::error!("failed to restart clipboard context: {}", e); + } else { + let _ = + clipboard::ContextSend::proc(|context| -> ResultType<()> { + context + .server_clip_file(self.inner.id(), clip) + .map_err(|e| e.into()) + }); + } + } else { + out_msgs = unix_file_clip::serve_clip_messages( + ClipboardSide::Host, + clip, + self.inner.id(), + ); + } + + #[cfg(not(target_os = "macos"))] + { + out_msgs = unix_file_clip::serve_clip_messages( + ClipboardSide::Host, + clip, + self.inner.id(), + ); + } + + for msg in out_msgs.into_iter() { + if let Some(message::Union::Cliprdr(cliprdr)) = msg.union.as_ref() { + if let Some(cliprdr::Union::Files(files)) = + cliprdr.union.as_ref() + { + self.post_file_audit( + FileAuditType::RemoteSend, + "", + files + .files + .iter() + .map(|f| (f.name.clone(), f.size as i64)) + .collect::>(), + json!({}), + ); + continue; + } + } + self.send(msg).await; + } + } } } Some(message::Union::FileAction(fa)) => { - if self.file_transfer.is_some() { + let mut handle_fa = self.file_transfer.is_some(); + if !handle_fa { + if let Some(file_action::Union::Send(s)) = fa.union.as_ref() { + if JobType::from_proto(s.file_type) == JobType::Printer { + handle_fa = true; + } + } + } + if handle_fa { if self.delayed_read_dir.is_some() { if let Some(file_action::Union::ReadDir(rd)) = fa.union { self.delayed_read_dir = Some((rd.path, rd.include_hidden)); @@ -2144,56 +3030,108 @@ impl Connection { } } match fa.union { + Some(file_action::Union::ReadEmptyDirs(rd)) => { + self.read_empty_dirs(&rd.path, rd.include_hidden); + } Some(file_action::Union::ReadDir(rd)) => { self.read_dir(&rd.path, rd.include_hidden); } Some(file_action::Union::AllFiles(f)) => { - match fs::get_recursive_files(&f.path, f.include_hidden) { - Err(err) => { - self.send(fs::new_error(f.id, err, -1)).await; - } - Ok(files) => { - self.send(fs::new_dir(f.id, f.path, files)).await; + if crate::common::need_fs_cm_send_files() { + self.send_fs(ipc::FS::ReadAllFiles { + path: f.path, + id: f.id, + include_hidden: f.include_hidden, + conn_id: self.inner.id(), + }); + } else { + match fs::get_recursive_files(&f.path, f.include_hidden) { + Err(err) => { + log::error!( + "Failed to get recursive files for {}: {}", + f.path, + err + ); + self.send(fs::new_error(f.id, err, -1)).await; + } + Ok(files) => { + if let Err(msg) = + crate::ui_cm_interface::check_file_count_limit( + files.len(), + ) + { + self.send(fs::new_error(f.id, msg, -1)).await; + } else { + self.send(fs::new_dir(f.id, f.path, files)).await; + } + } } } } Some(file_action::Union::Send(s)) => { // server to client let id = s.id; - let od = can_enable_overwrite_detection(get_version_number( - &self.lr.version, - )); let path = s.path.clone(); - match fs::TransferJob::new_read( - id, - "".to_string(), - path.clone(), - s.file_num, - s.include_hidden, - false, - od, - ) { - Err(err) => { - self.send(fs::new_error(id, err, 0)).await; - } - Ok(mut job) => { - self.send(fs::new_dir(id, path, job.files().to_vec())) - .await; - let mut files = job.files().to_owned(); - job.is_remote = true; - job.conn_id = self.inner.id(); - self.read_jobs.push(job); - self.file_timer = - crate::rustdesk_interval(time::interval(MILLI1)); - self.post_file_audit( - FileAuditType::RemoteSend, - &s.path, - files - .drain(..) - .map(|f| (f.name, f.size as _)) - .collect(), - json!({}), + let job_type = JobType::from_proto(s.file_type); + match job_type { + JobType::Generic => { + let od = can_enable_overwrite_detection( + get_version_number(&self.lr.version), ); + if crate::common::need_fs_cm_send_files() { + // Delegate file reading to CM on Windows + self.cm_read_job_ids.insert(id); + self.send_fs(ipc::FS::ReadFile { + path, + id, + file_num: s.file_num, + include_hidden: s.include_hidden, + conn_id: self.inner.id(), + overwrite_detection: od, + }); + } else { + // Handle file reading in Connection on non-Windows + let data_source = + fs::DataSource::FilePath(PathBuf::from(&path)); + self.create_and_start_read_job( + id, + job_type, + data_source, + s.file_num, + s.include_hidden, + od, + path, + true, // check file count limit + ) + .await; + } + } + JobType::Printer => { + if let Some((_, _, data)) = self + .printer_data + .iter() + .position(|(_, p, _)| *p == path) + .map(|index| self.printer_data.remove(index)) + { + let data_source = fs::DataSource::MemoryCursor( + std::io::Cursor::new(data), + ); + // Printer jobs don't need file count limit check + self.create_and_start_read_job( + id, + job_type, + data_source, + s.file_num, + s.include_hidden, + true, // always enable overwrite detection for printer + path, + false, // no file count limit for printer + ) + .await; + } else { + // Ignore this message if the printer data is not found + return true; + } } } self.file_transferred = true; @@ -2222,11 +3160,7 @@ impl Connection { self.post_file_audit( FileAuditType::RemoteReceive, &r.path, - r.files - .to_vec() - .drain(..) - .map(|f| (f.name, f.size as _)) - .collect(), + Self::get_files_for_audit(fs::JobType::Generic, r.files), json!({}), ); self.file_transferred = true; @@ -2265,17 +3199,34 @@ impl Connection { } Some(file_action::Union::Cancel(c)) => { self.send_fs(ipc::FS::CancelWrite { id: c.id }); - if let Some(job) = fs::get_job_immutable(c.id, &self.read_jobs) { + let _ = self.cm_read_job_ids.remove(&c.id); + self.send_fs(ipc::FS::CancelRead { + id: c.id, + conn_id: self.inner.id(), + }); + if let Some(job) = fs::remove_job(c.id, &mut self.read_jobs) { self.send_to_cm(ipc::Data::FileTransferLog(( "transfer".to_string(), - fs::serialize_transfer_job(job, false, true, ""), + fs::serialize_transfer_job(&job, false, true, ""), ))); } - fs::remove_job(c.id, &mut self.read_jobs); } Some(file_action::Union::SendConfirm(r)) => { if let Some(job) = fs::get_job(r.id, &mut self.read_jobs) { - job.confirm(&r); + job.confirm(&r).await; + } else if self.cm_read_job_ids.contains(&r.id) { + // Forward to CM for CM-read jobs + self.send_fs(ipc::FS::SendConfirmForRead { + id: r.id, + file_num: r.file_num, + skip: r.skip(), + offset_blk: r.offset_blk(), + conn_id: self.inner.id(), + }); + } else { + if let Ok(sc) = r.write_to_bytes() { + self.send_fs(ipc::FS::SendConfirm(sc)); + } } } Some(file_action::Union::Rename(r)) => { @@ -2319,6 +3270,7 @@ impl Connection { file_size: d.file_size, last_modified: d.last_modified, is_upload: true, + is_resume: d.is_resume, }), Some(file_response::Union::Error(e)) => { self.send_fs(ipc::FS::WriteError { @@ -2367,20 +3319,11 @@ impl Connection { self.update_auto_disconnect_timer(); } Some(misc::Union::VideoReceived(_)) => { - video_service::notify_video_frame_fetched( + video_service::notify_video_frame_fetched_by_conn_id( self.inner.id, Some(Instant::now().into()), ); } - Some(misc::Union::CloseReason(_)) => { - self.on_close("Peer close", true).await; - raii::AuthedConnID::check_remove_session( - self.inner.id(), - self.session_key(), - ); - return false; - } - Some(misc::Union::RestartRemoteDevice(_)) => { #[cfg(not(any(target_os = "android", target_os = "ios")))] if self.restart { @@ -2422,8 +3365,13 @@ impl Connection { } } #[cfg(feature = "flutter")] + #[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( + self.lr.my_id.clone(), + uuid.clone(), + ); crate::run_me(vec![ "--connect", &self.lr.my_id, @@ -2464,7 +3412,7 @@ impl Connection { let sessions = crate::platform::get_available_sessions(false); if crate::platform::is_installed() && crate::platform::is_share_rdp() - && raii::AuthedConnID::remote_and_file_conn_count() == 1 + && raii::AuthedConnID::non_port_forward_conn_count() == 1 && sessions.len() > 1 && current_process_sid != sid && sessions.iter().any(|e| e.sid == sid) @@ -2478,15 +3426,19 @@ impl Connection { if let Some((dir, show_hidden)) = self.delayed_read_dir.take() { self.read_dir(&dir, show_hidden); } - } else { - self.try_sub_services(); + } else if self.view_camera { + self.try_sub_camera_displays(); + } else if !self.terminal { + self.try_sub_monitor_services(); } } } Some(misc::Union::MessageQuery(mq)) => { - if let Some(msg_out) = - video_service::make_display_changed_msg(mq.switch_display as _, None) - { + if let Some(msg_out) = video_service::make_display_changed_msg( + mq.switch_display as _, + None, + self.video_source(), + ) { self.send(msg_out).await; } } @@ -2518,41 +3470,459 @@ impl Connection { Some(message::Union::VoiceCallResponse(_response)) => { // TODO: Maybe we can do a voice call from cm directly. } + Some(message::Union::ScreenshotRequest(request)) => { + if let Some(tx) = self.inner.tx.clone() { + crate::video_service::set_take_screenshot( + request.display as _, + request.sid.clone(), + tx, + ); + self.refresh_video_display(Some(request.display as usize)); + } + } + Some(message::Union::TerminalAction(action)) => { + #[cfg(not(any(target_os = "android", target_os = "ios")))] + allow_err!(self.handle_terminal_action(action).await); + #[cfg(any(target_os = "android", target_os = "ios"))] + log::warn!("Terminal action received but not supported on this platform"); + } _ => {} } } true } - fn update_failure(&self, (mut failure, time): ((i32, i32, i32), i32), remove: bool, i: usize) { - if remove { - if failure.0 != 0 { - LOGIN_FAILURES[i].lock().unwrap().remove(&self.ip); + #[cfg(any(target_os = "linux", target_os = "macos"))] + fn fill_terminal_user_token( + &mut self, + _username: &str, + _password: &str, + ) -> Option<&'static str> { + self.terminal_user_token = Some(TerminalUserToken::SelfUser); + None + } + + // Try to fill user token for terminal connection. + // If username is empty, use the user token of the current session. + // If username is not empty, try to logon and check if the user is an administrator. + // If the user is an administrator, use the user token of current process (SYSTEM). + // If the user is not an administrator, return an error message. + // Note: Only local and domain users are supported, Microsoft account (online account) not supported for now. + #[cfg(target_os = "windows")] + fn fill_terminal_user_token(&mut self, username: &str, password: &str) -> Option<&'static str> { + // No need to check if the password is empty. + if !username.is_empty() { + return self.handle_administrator_check(username, password); + } + + if crate::platform::is_prelogin() { + self.terminal_user_token = None; + return Some("No active console user logged on, please connect and logon first."); + } + + if crate::platform::is_installed() { + return self.handle_installed_user(); + } + + self.terminal_user_token = Some(TerminalUserToken::SelfUser); + None + } + + #[cfg(target_os = "windows")] + fn handle_administrator_check( + &mut self, + username: &str, + password: &str, + ) -> Option<&'static str> { + let check_admin_res = + crate::platform::get_logon_user_token(username, password).map(|token| { + let is_token_admin = crate::platform::is_user_token_admin(token); + unsafe { + hbb_common::allow_err!(CloseHandle(HANDLE(token as _))); + }; + is_token_admin + }); + match check_admin_res { + Ok(Ok(b)) => { + if b { + self.terminal_user_token = Some(TerminalUserToken::SelfUser); + None + } else { + Some(TERMINAL_OS_LOGIN_FAILED_MSG) + } + } + Ok(Err(e)) => { + log::error!("Failed to check if the user is an administrator: {}", e); + Some(TERMINAL_OS_LOGIN_FAILED_MSG) + } + Err(e) => { + log::error!("Failed to get logon user token: {}", e); + Some(TERMINAL_OS_LOGIN_FAILED_MSG) + } + } + } + + #[cfg(target_os = "windows")] + fn handle_installed_user(&mut self) -> Option<&'static str> { + let session_id = crate::platform::get_current_session_id(true); + if session_id == 0xFFFFFFFF { + return Some("Failed to get current session id."); + } + let token = crate::platform::get_user_token(session_id, true); + if !token.is_null() { + match crate::platform::ensure_primary_token(token) { + Ok(t) => { + self.terminal_user_token = Some(TerminalUserToken::CurrentLogonUser( + crate::terminal_service::UserToken::new(t as usize), + )); + } + Err(e) => { + log::error!("Failed to ensure primary token: {}", e); + self.terminal_user_token = Some(TerminalUserToken::CurrentLogonUser( + crate::terminal_service::UserToken::new(token as usize), + )); + } + } + None + } else { + log::error!( + "Failed to get user token for terminal action, {}", + std::io::Error::last_os_error() + ); + Some("Failed to get user token.") + } + } + + #[cfg(not(any(target_os = "android", target_os = "ios")))] + async fn prepare_terminal_login_for_authorization(&mut self) -> Option { + if !self.terminal || self.terminal_user_token.is_some() { + return None; + } + + #[derive(Copy, Clone)] + enum TerminalAuthorizationMode { + OsLogin { + failure: ((i32, i32, i32), i32), + scope: FailureScope, + }, + SessionUser, + } + + let normalized_username = self.lr.os_login.username.trim().to_owned(); + let auth_mode = if should_use_terminal_os_login_scope(self.terminal, &normalized_username) { + // Check failure state + let failure_scope = FailureScope::TerminalOsLogin; + let (failure, res) = self.check_failure_with_scope(0, failure_scope).await; + if !res { + log::warn!( + "OS credential login blocked by failure policy: ip={} conn_id={} scope={:?}", + self.ip, + self.inner.id(), + failure_scope + ); + // Terminal OS login is sensitive. Close this connection instead of keeping it + // alive for retries on the same socket after a rate-limit block. + return Some(false); + } + TerminalAuthorizationMode::OsLogin { + failure, + scope: failure_scope, + } + } else { + TerminalAuthorizationMode::SessionUser + }; + + let is_terminal_os_login = matches!(auth_mode, TerminalAuthorizationMode::OsLogin { .. }); + let failure_scope = match auth_mode { + TerminalAuthorizationMode::OsLogin { scope, .. } => scope, + TerminalAuthorizationMode::SessionUser => FailureScope::Default, + }; + + let username = normalized_username; + let password = self.lr.os_login.password.clone(); + let terminal_login_error = { + #[cfg(target_os = "windows")] + { + // Concurrency gate for terminal OS login with credentials, to prevent brute-force attacks. + let _os_login_concurrency_guard = if is_terminal_os_login { + let guard = try_acquire_os_credential_login_gate(); + if guard.is_err() { + log::warn!( + "OS credential login blocked by concurrency gate: ip={} conn_id={} scope={:?}", + self.ip, + self.inner.id(), + failure_scope + ); + self.send_login_error("Please try 1 minute later").await; + sleep(1.).await; + Self::post_alarm_audit( + AlarmAuditType::TerminalOsLoginConcurrency, + json!({ + "ip": self.ip, + "id": self.lr.my_id.clone(), + "name": self.lr.my_name.clone(), + }), + ); + return Some(false); + } + guard.ok() + } else { + None + }; + self.fill_terminal_user_token(&username, &password) + } + #[cfg(not(target_os = "windows"))] + { + self.fill_terminal_user_token(&username, &password) + } + }; + if let Some(msg) = terminal_login_error { + if let TerminalAuthorizationMode::OsLogin { failure, scope } = auth_mode { + self.update_failure_with_scope(failure, false, 0, scope); + } + let auth_context = if is_terminal_os_login { + "OS credential login verification" + } else { + "Terminal session-user authorization" + }; + log::warn!( + "{} failed: ip={} conn_id={} scope={:?} msg='{}'", + auth_context, + self.ip, + self.inner.id(), + failure_scope, + msg + ); + self.send_login_error(msg).await; + sleep(1.).await; + return Some(false); + } + if let TerminalAuthorizationMode::OsLogin { failure, scope } = auth_mode { + self.update_failure_with_scope(failure, true, 0, scope); + } + + if let Some(is_user) = + terminal_service::is_service_specified_user(&self.terminal_service_id) + { + if let Some(user_token) = &self.terminal_user_token { + let has_service_token = user_token.to_terminal_service_token().is_some(); + if is_user != has_service_token { + log::error!( + "Terminal service user mismatch: ip={} conn_id={} service_is_user={} has_service_token={}. The service ID may have been manually changed in the configuration, causing validation to fail.", + self.ip, + self.inner.id(), + is_user, + has_service_token + ); + // No need to translate the following message, because it is in an abnormal case. + self.send_login_error("Terminal service user mismatch detected.") + .await; + sleep(1.).await; + return Some(false); + } + } + } + if is_terminal_os_login { + self.try_start_cm_ipc(); + } + None + } + + #[cfg(any(target_os = "android", target_os = "ios"))] + async fn prepare_terminal_login_for_authorization(&mut self) -> Option { + None + } + + // Try to parse connection IP as IPv6 address, returning /64, /56, and /48 prefixes. + // Parsing an IPv4 address just returns None. + // note: we specifically don't use hbb_common::is_ipv6_str to avoid divergence issues + // between its regex and the system std::net::Ipv6Addr implementation. + fn get_ipv6_prefixes(&self) -> Option<(String, String, String)> { + fn mask_u128(addr: u128, prefix: u8) -> u128 { + let mask = if prefix == 0 || prefix > 128 { + 0 + } else { + (!0u128) << (128 - prefix) + }; + addr & mask + } + // eliminate zone-ids like "fe80::1%eth0" + let ip_only = self.ip.split('%').next().unwrap_or(&self.ip).trim(); + let ip = Ipv6Addr::from_str(ip_only).ok()?; + + let as_u128 = u128::from_be_bytes(ip.octets()); + + let p64 = Ipv6Addr::from(mask_u128(as_u128, 64).to_be_bytes()).to_string() + "/64"; + let p56 = Ipv6Addr::from(mask_u128(as_u128, 56).to_be_bytes()).to_string() + "/56"; + let p48 = Ipv6Addr::from(mask_u128(as_u128, 48).to_be_bytes()).to_string() + "/48"; + + Some((p64, p56, p48)) + } + + fn bump_failure_entry(mut cur: (i32, i32, i32), time: i32) -> (i32, i32, i32) { + if cur.0 == time { + cur.1 += 1; + cur.2 += 1; + } else { + cur.0 = time; + cur.1 = 1; + cur.2 += 1; + } + cur + } + + fn update_failure(&self, failure: ((i32, i32, i32), i32), remove: bool, i: usize) { + self.update_failure_with_scope(failure, remove, i, FailureScope::Default); + } + + fn update_failure_with_scope( + &self, + (failure, time): ((i32, i32, i32), i32), + remove: bool, + i: usize, + scope: FailureScope, + ) { + let os_credential_scope = matches!(scope, FailureScope::TerminalOsLogin); + if os_credential_scope { + if !remove { + record_os_credential_failure(scope); } return; } - if failure.0 == time { - failure.1 += 1; - failure.2 += 1; - } else { - failure.0 = time; - failure.1 = 1; - failure.2 += 1; + + let map_mutex = &LOGIN_FAILURES[i]; + if remove { + if failure.0 != 0 { + if let Some((p64, p56, p48)) = self.get_ipv6_prefixes() { + let mut m = map_mutex.lock().unwrap(); + m.remove(&p64); + m.remove(&p56); + m.remove(&p48); + m.remove(&self.ip); + } else { + map_mutex.lock().unwrap().remove(&self.ip); + } + } + return; } - LOGIN_FAILURES[i] + // Bump the prefixes, fetching existing values + if let Some((p64, p56, p48)) = self.get_ipv6_prefixes() { + let mut m = map_mutex.lock().unwrap(); + for key in [p64, p56, p48] { + let cur = m.get(&key).copied().unwrap_or((0, 0, 0)); + m.insert(key, Self::bump_failure_entry(cur, time)); + } + let current_ip = m.get(&self.ip).copied().unwrap_or((0, 0, 0)); + m.insert(self.ip.clone(), Self::bump_failure_entry(current_ip, time)); + } else { + // Re-read the full IP bucket in case another failed attempt updated it. + let mut m = map_mutex.lock().unwrap(); + let current_ip = m.get(&self.ip).copied().unwrap_or((0, 0, 0)); + m.insert(self.ip.clone(), Self::bump_failure_entry(current_ip, time)); + } + } + + async fn check_failure_ipv6_prefix( + &mut self, + i: usize, + time: i32, + prefix: &str, + prefix_num: i8, + thresh: i32, + ) -> Option<(((i32, i32, i32), i32), bool)> { + let failure_prefix = LOGIN_FAILURES[i] .lock() .unwrap() - .insert(self.ip.clone(), failure); + .get(prefix) + .copied() + .unwrap_or((0, 0, 0)); + + if failure_prefix.2 > thresh { + self.send_login_error(format!( + "Too many wrong attempts for IPv6 prefix /{}", + prefix_num + )) + .await; + Self::post_alarm_audit( + AlarmAuditType::ExceedIPv6PrefixAttempts, + json!({ + "ip": self.ip, + "id": self.lr.my_id.clone(), + "name": self.lr.my_name.clone(), + }), + ); + Some(((failure_prefix, time), false)) + } else { + None + } } async fn check_failure(&mut self, i: usize) -> (((i32, i32, i32), i32), bool) { + self.check_failure_with_scope(i, FailureScope::Default) + .await + } + + async fn check_failure_with_scope( + &mut self, + i: usize, + scope: FailureScope, + ) -> (((i32, i32, i32), i32), bool) { + let time = (get_time() / 60_000) as i32; + + if matches!(scope, FailureScope::TerminalOsLogin) { + let decision = evaluate_os_credential_policy(scope, get_time()); + let res = if decision.allowed { + true + } else { + log::warn!( + "OS credential login blocked by policy: ip={} conn_id={} i={} msg='{}'", + self.ip, + self.inner.id(), + i, + decision.login_error.as_deref().unwrap_or("") + ); + if let Some(login_error) = decision.login_error { + // Rare branch and currently temporary response copy; translation can be added later if needed. + self.send_login_error(login_error).await; + } + if let Some(audit) = decision.audit { + // For OS blocked/backoff events, we currently emit one alarm report per blocked attempt. + // TODO: Add unified cumulative/aggregation fields across alarm producers. + Self::post_alarm_audit( + audit, + json!({ + "ip": self.ip, + "id": self.lr.my_id.clone(), + "name": self.lr.my_name.clone(), + }), + ); + } + false + }; + return (((0, 0, 0), time), res); + } + + // IPv6 addresses are cheap to make so we check prefix/netblock as well + if let Some((p64, p56, p48)) = self.get_ipv6_prefixes() { + if let Some(res) = self.check_failure_ipv6_prefix(i, time, &p64, 64, 60).await { + return res; + } + if let Some(res) = self.check_failure_ipv6_prefix(i, time, &p56, 56, 80).await { + return res; + } + if let Some(res) = self.check_failure_ipv6_prefix(i, time, &p48, 48, 100).await { + return res; + } + } + + // checks IPv6 and IPv4 direct addresses let failure = LOGIN_FAILURES[i] .lock() .unwrap() .get(&self.ip) - .map(|x| x.clone()) + .copied() .unwrap_or((0, 0, 0)); - let time = (get_time() / 60_000) as i32; + let res = if failure.2 > 30 { self.send_login_error("Too many wrong attempts").await; Self::post_alarm_audit( @@ -2585,7 +3955,7 @@ impl Connection { video_service::refresh(); self.server.upgrade().map(|s| { s.read().unwrap().set_video_service_opt( - display, + display.map(|d| (self.video_source(), d)), video_service::OPTION_REFRESH, super::service::SERVICE_OPTION_VALUE_TRUE, ); @@ -2615,19 +3985,33 @@ impl Connection { // 1. For compatibility with old versions ( < 1.2.4 ). // 2. Sciter version. // 3. Update `SupportedResolutions`. - if let Some(msg_out) = video_service::make_display_changed_msg(self.display_idx, None) { + if let Some(msg_out) = + video_service::make_display_changed_msg(self.display_idx, None, self.video_source()) + { self.send(msg_out).await; } } } + fn video_source(&self) -> VideoSource { + if self.view_camera { + VideoSource::Camera + } else { + VideoSource::Monitor + } + } + fn switch_display_to(&mut self, display_idx: usize, server: Arc>) { - let new_service_name = video_service::get_service_name(display_idx); - let old_service_name = video_service::get_service_name(self.display_idx); + let new_service_name = video_service::get_service_name(self.video_source(), display_idx); + let old_service_name = + video_service::get_service_name(self.video_source(), self.display_idx); let mut lock = server.write().unwrap(); if display_idx != *display_service::PRIMARY_DISPLAY_IDX { if !lock.contains(&new_service_name) { - lock.add_service(Box::new(video_service::new(display_idx))); + lock.add_service(Box::new(video_service::new( + self.video_source(), + display_idx, + ))); } } // For versions greater than 1.2.4, a `CaptureDisplays` message will be sent immediately. @@ -2662,26 +4046,27 @@ impl Connection { } async fn capture_displays(&mut self, add: &[usize], sub: &[usize], set: &[usize]) { + let video_source = self.video_source(); if let Some(sever) = self.server.upgrade() { let mut lock = sever.write().unwrap(); for display in add.iter() { - let service_name = video_service::get_service_name(*display); + let service_name = video_service::get_service_name(video_source, *display); if !lock.contains(&service_name) { - lock.add_service(Box::new(video_service::new(*display))); + lock.add_service(Box::new(video_service::new(video_source, *display))); } } for display in set.iter() { - let service_name = video_service::get_service_name(*display); + let service_name = video_service::get_service_name(video_source, *display); if !lock.contains(&service_name) { - lock.add_service(Box::new(video_service::new(*display))); + lock.add_service(Box::new(video_service::new(video_source, *display))); } } if !add.is_empty() { - lock.capture_displays(self.inner.clone(), add, true, false); + lock.capture_displays(self.inner.clone(), video_source, add, true, false); } else if !sub.is_empty() { - lock.capture_displays(self.inner.clone(), sub, false, true); + lock.capture_displays(self.inner.clone(), video_source, sub, false, true); } else { - lock.capture_displays(self.inner.clone(), set, true, true); + lock.capture_displays(self.inner.clone(), video_source, set, true, true); } self.multi_ui_session = lock.get_subbed_displays_count(self.inner.id()) > 1; if self.follow_remote_window { @@ -2762,15 +4147,24 @@ impl Connection { { return; } + #[allow(unused_mut)] let mut record_changed = true; #[cfg(windows)] if virtual_display_manager::amyuni_idd::is_my_display(&name) { record_changed = false; } + #[cfg(not(target_os = "macos"))] + let scale = 1.0; + #[cfg(target_os = "macos")] + let scale = display.scale(); + let original = ( + ((display.width() as f64) / scale).round() as _, + (display.height() as f64 / scale).round() as _, + ); if record_changed { display_service::set_last_changed_resolution( &name, - (display.width() as _, display.height() as _), + original, (r.width, r.height), ); } @@ -2803,6 +4197,16 @@ impl Connection { self.send_to_cm(Data::CloseVoiceCall("".to_owned())); } self.send(msg).await; + self.voice_calling = accepted; + if self.is_authed_view_camera_conn() { + if let Some(s) = self.server.upgrade() { + s.write().unwrap().subscribe( + super::audio_service::NAME, + self.inner.clone(), + self.audio_enabled() && accepted, + ); + } + } } else { log::warn!("Possible a voice call attack."); } @@ -2812,6 +4216,14 @@ impl Connection { crate::audio_service::set_voice_call_input_device(None, true); // Notify the connection manager that the voice call has been closed. self.send_to_cm(Data::CloseVoiceCall("".to_owned())); + self.voice_calling = false; + if self.is_authed_view_camera_conn() { + if let Some(s) = self.server.upgrade() { + s.write() + .unwrap() + .subscribe(super::audio_service::NAME, self.inner.clone(), false); + } + } } async fn update_options(&mut self, o: &OptionMessage) { @@ -2888,21 +4300,44 @@ impl Connection { if q != BoolOption::NotSet { self.disable_audio = q == BoolOption::Yes; if let Some(s) = self.server.upgrade() { - s.write().unwrap().subscribe( - super::audio_service::NAME, - self.inner.clone(), - self.audio_enabled(), - ); + if self.is_authed_view_camera_conn() { + if self.voice_calling || !self.audio_enabled() { + s.write().unwrap().subscribe( + super::audio_service::NAME, + self.inner.clone(), + self.audio_enabled(), + ); + } + } else { + s.write().unwrap().subscribe( + super::audio_service::NAME, + self.inner.clone(), + self.audio_enabled(), + ); + } } } } - #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] + #[cfg(any(target_os = "windows", feature = "unix-file-copy-paste"))] if let Ok(q) = o.enable_file_transfer.enum_value() { if q != BoolOption::NotSet { self.enable_file_transfer = q == BoolOption::Yes; + #[cfg(target_os = "windows")] self.send_to_cm(ipc::Data::ClipboardFileEnabled( self.file_transfer_enabled(), )); + #[cfg(feature = "unix-file-copy-paste")] + if !self.enable_file_transfer { + self.try_empty_file_clipboard(); + } + #[cfg(feature = "unix-file-copy-paste")] + if let Some(s) = self.server.upgrade() { + s.write().unwrap().subscribe( + super::clipboard_service::FILE_NAME, + self.inner.clone(), + self.can_sub_file_clipboard_service(), + ); + } } } if let Ok(q) = o.disable_clipboard.enum_value() { @@ -2912,7 +4347,7 @@ impl Connection { s.write().unwrap().subscribe( super::clipboard_service::NAME, self.inner.clone(), - self.clipboard_enabled() && self.peer_keyboard_enabled(), + self.can_sub_clipboard_service(), ); } } @@ -2924,7 +4359,13 @@ impl Connection { s.write().unwrap().subscribe( super::clipboard_service::NAME, self.inner.clone(), - self.clipboard_enabled() && self.peer_keyboard_enabled(), + self.can_sub_clipboard_service(), + ); + #[cfg(feature = "unix-file-copy-paste")] + s.write().unwrap().subscribe( + super::clipboard_service::FILE_NAME, + self.inner.clone(), + self.can_sub_file_clipboard_service(), ); s.write().unwrap().subscribe( NAME_CURSOR, @@ -2976,9 +4417,68 @@ impl Connection { } } } + #[cfg(not(any(target_os = "android", target_os = "ios")))] + if let Ok(q) = o.terminal_persistent.enum_value() { + if q != BoolOption::NotSet { + self.update_terminal_persistence(q == BoolOption::Yes).await; + } + } + #[cfg(not(any(target_os = "android", target_os = "ios")))] + if let Ok(q) = o.show_my_cursor.enum_value() { + if q != BoolOption::NotSet { + use crate::whiteboard; + self.show_my_cursor = q == BoolOption::Yes; + #[cfg(target_os = "windows")] + let is_lower_win10 = !crate::platform::windows::is_win_10_or_greater(); + #[cfg(not(target_os = "windows"))] + let is_lower_win10 = false; + #[cfg(target_os = "linux")] + let is_linux_supported = crate::whiteboard::is_supported(); + #[cfg(not(target_os = "linux"))] + let is_linux_supported = false; + let not_support_msg = if is_lower_win10 { + "Windows 10 or greater is required." + } else if cfg!(target_os = "linux") && !is_linux_supported { + "This feature is not supported on native Wayland, please install XWayland or switch to X11." + } else { + "" + }; + if q == BoolOption::Yes { + if not_support_msg.is_empty() { + whiteboard::register_whiteboard(whiteboard::get_key_cursor(self.inner.id)); + } else { + let mut msg_out = Message::new(); + let res = MessageBox { + msgtype: "nook-nocancel-hasclose".to_owned(), + title: "Show my cursor".to_owned(), + text: not_support_msg.to_owned(), + link: "".to_owned(), + ..Default::default() + }; + msg_out.set_message_box(res); + self.send(msg_out).await; + } + } else { + if not_support_msg.is_empty() { + whiteboard::unregister_whiteboard(whiteboard::get_key_cursor( + self.inner.id, + )); + } + } + } + } } async fn turn_on_privacy(&mut self, impl_key: String) { + if !self.is_authed_remote_conn() || !self.privacy_mode { + let msg_out = crate::common::make_privacy_mode_msg( + back_notification::PrivacyModeState::PrvOnFailedDenied, + impl_key, + ); + self.send(msg_out).await; + return; + } + let msg_out = if !privacy_mode::is_privacy_mode_supported() { crate::common::make_privacy_mode_msg_with_details( back_notification::PrivacyModeState::PrvNotSupported, @@ -3020,7 +4520,7 @@ impl Connection { "Check privacy mode failed: {}, turn off privacy mode.", &err_msg ); - let _ = Self::turn_off_privacy_to_msg(self.inner.id); + let _ = Self::turn_off_privacy_to_msg(self.inner.id, String::new()); crate::common::make_privacy_mode_msg_with_details( back_notification::PrivacyModeState::PrvOnFailed, err_msg, @@ -3039,6 +4539,7 @@ impl Connection { if privacy_mode::is_in_privacy_mode() { let _ = Self::turn_off_privacy_to_msg( privacy_mode::INVALID_PRIVACY_MODE_CONN_ID, + String::new(), ); } crate::common::make_privacy_mode_msg_with_details( @@ -3066,14 +4567,23 @@ impl Connection { impl_key, ) } else { - Self::turn_off_privacy_to_msg(self.inner.id) + Self::turn_off_privacy_to_msg(self.inner.id, impl_key) }; self.send(msg_out).await; } - pub fn turn_off_privacy_to_msg(_conn_id: i32) -> Message { - let impl_key = "".to_owned(); - match privacy_mode::turn_off_privacy(_conn_id, None) { + pub fn turn_off_privacy_to_msg(_conn_id: i32, impl_key: String) -> Message { + Self::turn_off_privacy_result_to_msg( + privacy_mode::turn_off_privacy(_conn_id, None), + impl_key, + ) + } + + fn turn_off_privacy_result_to_msg( + turn_off_res: Option>, + impl_key: String, + ) -> Message { + match turn_off_res { Some(Ok(_)) => crate::common::make_privacy_mode_msg( back_notification::PrivacyModeState::PrvOffSucceeded, impl_key, @@ -3140,6 +4650,227 @@ impl Connection { raii::AuthedConnID::check_remove_session(self.inner.id(), self.session_key()); } + async fn handle_read_job_init_result( + &mut self, + id: i32, + _file_num: i32, + _include_hidden: bool, + result: Result, String>, + ) { + // Check if this response is still expected (not stale/cancelled) + if !self.cm_read_job_ids.contains(&id) { + log::warn!( + "Received ReadJobInitResult for unknown or stale job id={}, ignoring", + id + ); + return; + } + + match result { + Err(error) => { + self.cm_read_job_ids.remove(&id); + self.send(fs::new_error(id, error, 0)).await; + } + Ok(dir_bytes) => { + // Deserialize FileDirectory from protobuf bytes + let dir = match FileDirectory::parse_from_bytes(&dir_bytes) { + Ok(d) => d, + Err(e) => { + log::error!("Failed to parse FileDirectory: {}", e); + self.cm_read_job_ids.remove(&id); + self.send(fs::new_error(id, "internal error".to_string(), 0)) + .await; + return; + } + }; + + let path_str = dir.path.clone(); + let file_entries: Vec = dir.entries.into(); + + // Send file directory to client + self.send(fs::new_dir(id, path_str.clone(), file_entries.clone())) + .await; + + // Post audit for file transfer + self.post_file_audit( + FileAuditType::RemoteSend, + &path_str, + Self::get_files_for_audit(fs::JobType::Generic, file_entries), + json!({}), + ); + + // CM will handle the actual file reading and send blocks via IPC + self.file_transferred = true; + } + } + } + + async fn handle_file_block_from_cm( + &mut self, + id: i32, + file_num: i32, + data: bytes::Bytes, + compressed: bool, + ) { + // Check if the job is still valid (not cancelled) + if !self.cm_read_job_ids.contains(&id) { + log::debug!( + "Dropping file block for cancelled/unknown job id={}, file_num={}", + id, + file_num + ); + return; + } + + // Forward file block to client + let mut block = FileTransferBlock::new(); + block.id = id; + block.file_num = file_num; + block.data = data.to_vec().into(); + block.compressed = compressed; + + let mut msg = Message::new(); + let mut fr = FileResponse::new(); + fr.set_block(block); + msg.set_file_response(fr); + self.send(msg).await; + } + + async fn handle_file_read_done(&mut self, id: i32, file_num: i32) { + // Drop stale completions for cancelled/unknown jobs + if !self.cm_read_job_ids.remove(&id) { + log::debug!( + "Dropping FileReadDone for cancelled/unknown job id={}, file_num={}", + id, + file_num + ); + return; + } + + // Forward done message to client + let mut done = FileTransferDone::new(); + done.id = id; + done.file_num = file_num; + + let mut msg = Message::new(); + let mut fr = FileResponse::new(); + fr.set_done(done); + msg.set_file_response(fr); + self.send(msg).await; + } + + async fn handle_file_read_error(&mut self, id: i32, file_num: i32, err: String) { + // Drop stale errors for cancelled/unknown jobs + if !self.cm_read_job_ids.remove(&id) { + log::debug!( + "Dropping FileReadError for cancelled/unknown job id={}, file_num={}", + id, + file_num + ); + return; + } + + // Forward error to client + self.send(fs::new_error(id, err, file_num)).await; + } + + async fn handle_file_digest_from_cm( + &mut self, + id: i32, + file_num: i32, + last_modified: u64, + file_size: u64, + is_resume: bool, + ) { + // Check if the job is still valid (not cancelled) + if !self.cm_read_job_ids.contains(&id) { + log::debug!( + "Dropping digest for cancelled/unknown job id={}, file_num={}", + id, + file_num + ); + return; + } + + // Forward digest to client for overwrite detection + let mut digest = FileTransferDigest::new(); + digest.id = id; + digest.file_num = file_num; + digest.last_modified = last_modified; + digest.file_size = file_size; + digest.is_upload = false; // Server sending to client + digest.is_resume = is_resume; + + let mut msg = Message::new(); + let mut fr = FileResponse::new(); + fr.set_digest(digest); + msg.set_file_response(fr); + self.send(msg).await; + } + + async fn process_new_read_job(&mut self, mut job: fs::TransferJob, path: String) { + let files = job.files().to_owned(); + let job_type = job.r#type; + self.send(fs::new_dir(job.id, path.clone(), files.clone())) + .await; + job.is_remote = true; + job.conn_id = self.inner.id(); + self.read_jobs.push(job); + self.file_timer = crate::rustdesk_interval(time::interval(MILLI1)); + let audit_path = if job_type == fs::JobType::Printer { + "Remote print".to_owned() + } else { + path + }; + self.post_file_audit( + FileAuditType::RemoteSend, + &audit_path, + Self::get_files_for_audit(job_type, files), + json!({}), + ); + } + + async fn handle_all_files_result( + &mut self, + id: i32, + path: String, + result: Result, String>, + ) { + match result { + Err(err) => { + self.send(fs::new_error(id, err, -1)).await; + } + Ok(bytes) => { + // Deserialize FileDirectory from protobuf bytes and send as FileResponse + match FileDirectory::parse_from_bytes(&bytes) { + Ok(fd) => { + let mut msg = Message::new(); + let mut fr = FileResponse::new(); + fr.set_dir(fd); + msg.set_file_response(fr); + self.send(msg).await; + } + Err(e) => { + self.send(fs::new_error( + id, + format!("deserialize failed for {}: {}", path, e), + -1, + )) + .await; + } + } + } + } + } + + fn read_empty_dirs(&mut self, dir: &str, include_hidden: bool) { + let dir = dir.to_string(); + self.send_fs(ipc::FS::ReadEmptyDirs { + dir, + include_hidden, + }); + } + fn read_dir(&mut self, dir: &str, include_hidden: bool) { let dir = dir.to_string(); self.send_fs(ipc::FS::ReadDir { @@ -3148,6 +4879,57 @@ impl Connection { }); } + /// Create a new read job and start processing it (Connection-side). + /// + /// This is a generic Connection-side read job creation helper used for: + /// - Generic file transfers on non-Windows platforms + /// - Printer jobs on all platforms (including Windows) + /// + /// On Windows, generic file reads are delegated to CM via `start_read_job()` in + /// `src/ui_cm_interface.rs` for elevated access. Printer jobs bypass this delegation + /// since they read from in-memory data (`MemoryCursor`), not the filesystem. + /// + /// Both Connection-side and CM-side implementations use `TransferJob::new_read()` + /// with similar parameters. When modifying job creation logic, ensure both paths + /// stay in sync. + async fn create_and_start_read_job( + &mut self, + id: i32, + job_type: fs::JobType, + data_source: fs::DataSource, + file_num: i32, + include_hidden: bool, + overwrite_detection: bool, + path: String, + check_file_limit: bool, + ) { + match fs::TransferJob::new_read( + id, + job_type, + "".to_string(), + data_source, + file_num, + include_hidden, + false, + overwrite_detection, + ) { + Err(err) => { + self.send(fs::new_error(id, err, 0)).await; + } + Ok(job) => { + if check_file_limit { + if let Err(msg) = + crate::ui_cm_interface::check_file_count_limit(job.files().len()) + { + self.send(fs::new_error(id, msg, -1)).await; + return; + } + } + self.process_new_read_job(job, path).await; + } + } + } + #[inline] async fn send(&mut self, msg: Message) { allow_err!(self.stream.send(&msg).await); @@ -3159,11 +4941,7 @@ impl Connection { #[cfg(windows)] fn portable_check(&mut self) { - if self.portable.is_installed - || self.file_transfer.is_some() - || self.port_forward_socket.is_some() - || !self.keyboard - { + if self.portable.is_installed || !self.is_remote() || !self.keyboard { return; } let running = portable_client::running(); @@ -3300,8 +5078,146 @@ impl Connection { session_id: self.lr.session_id, } } + + fn is_authed_remote_conn(&self) -> bool { + if let Some(id) = self.authed_conn_id.as_ref() { + return id.conn_type() == AuthConnType::Remote; + } + false + } + + fn is_authed_view_camera_conn(&self) -> bool { + if let Some(id) = self.authed_conn_id.as_ref() { + return id.conn_type() == AuthConnType::ViewCamera; + } + false + } + + #[cfg(feature = "unix-file-copy-paste")] + async fn handle_file_clip(&mut self, clip: clipboard::ClipboardFile) { + let is_stopping_allowed = clip.is_stopping_allowed(); + let file_transfer_enabled = self.file_transfer_enabled(); + let stop = is_stopping_allowed && !file_transfer_enabled; + log::debug!( + "Process clipboard message from clip, stop: {}, is_stopping_allowed: {}, file_transfer_enabled: {}", + stop, is_stopping_allowed, file_transfer_enabled); + if !stop { + use hbb_common::config::keys::OPTION_ONE_WAY_FILE_TRANSFER; + // Note: Code will not reach here if `crate::get_builtin_option(OPTION_ONE_WAY_FILE_TRANSFER) == "Y"` is true. + // Because `file-clipboard` service will not be subscribed. + // But we still check it here to keep the same logic to windows version in `ui_cm_interface.rs`. + if clip.is_beginning_message() + && crate::get_builtin_option(OPTION_ONE_WAY_FILE_TRANSFER) == "Y" + { + // If one way file transfer is enabled, don't send clipboard file to client + } else { + // Maybe we should end the connection, because copy&paste files causes everything to wait. + allow_err!( + self.stream + .send(&crate::clipboard_file::clip_2_msg(clip)) + .await + ); + } + } + } + + #[inline] + #[cfg(feature = "unix-file-copy-paste")] + fn try_empty_file_clipboard(&mut self) { + try_empty_clipboard_files(ClipboardSide::Host, self.inner.id()); + } + + #[cfg(all(target_os = "windows", feature = "flutter"))] + async fn send_printer_request(&mut self, data: Vec) { + // This path is only used to identify the printer job. + let path = format!("RustDesk://FsJob//Printer/{}", get_time()); + + let msg = fs::new_send(0, fs::JobType::Printer, path.clone(), 1, false); + self.send(msg).await; + self.printer_data + .retain(|(t, _, _)| t.elapsed().as_secs() < 60); + self.printer_data.push((Instant::now(), path, data)); + } + + #[cfg(all(target_os = "windows", feature = "flutter"))] + async fn send_remote_printing_disallowed(&mut self) { + let mut msg_out = Message::new(); + let res = MessageBox { + msgtype: "custom-nook-nocancel-hasclose".to_owned(), + title: "remote-printing-disallowed-tile-tip".to_owned(), + text: "remote-printing-disallowed-text-tip".to_owned(), + link: "".to_owned(), + ..Default::default() + }; + msg_out.set_message_box(res); + self.send(msg_out).await; + } + + #[cfg(not(any(target_os = "android", target_os = "ios")))] + async fn update_terminal_persistence(&mut self, persistent: bool) { + self.terminal_persistent = persistent; + terminal_service::set_persistent(&self.terminal_service_id, persistent).ok(); + } + + #[cfg(not(any(target_os = "android", target_os = "ios")))] + async fn init_terminal_service(&mut self) { + debug_assert!(self.terminal_user_token.is_some()); + let Some(user_token) = self.terminal_user_token.clone() else { + // unreachable, but keep it for safety + log::error!("Terminal user token is not set."); + return; + }; + if self.terminal_service_id.is_empty() { + self.terminal_service_id = terminal_service::generate_service_id(); + } + let s = Box::new(terminal_service::new( + self.terminal_service_id.clone(), + self.terminal_persistent, + user_token.to_terminal_service_token(), + )); + s.on_subscribe(self.inner.clone()); + self.terminal_generic_service = Some(s); + } + + #[cfg(not(any(target_os = "android", target_os = "ios")))] + async fn handle_terminal_action(&mut self, action: TerminalAction) -> ResultType<()> { + debug_assert!(self.terminal_user_token.is_some()); + let Some(user_token) = self.terminal_user_token.clone() else { + // unreacheable, but keep it for safety + bail!("Terminal user token is not set."); + }; + let mut proxy = terminal_service::TerminalServiceProxy::new( + self.terminal_service_id.clone(), + Some(self.terminal_persistent), + user_token.to_terminal_service_token(), + ); + + match proxy.handle_action(&action) { + Ok(Some(response)) => { + let mut msg_out = Message::new(); + msg_out.set_terminal_response(response); + self.send(msg_out).await; + } + Ok(None) => { + // No response needed + } + Err(err) => { + let mut response = TerminalResponse::new(); + let mut error = TerminalError::new(); + error.message = format!("Failed to handle action: {}", err); + response.set_error(error); + let mut msg_out = Message::new(); + msg_out.set_terminal_response(response); + self.send(msg_out).await; + } + } + + Ok(()) + } } +#[cfg(feature = "flutter")] +#[cfg(not(any(target_os = "android", target_os = "ios")))] pub fn insert_switch_sides_uuid(id: String, uuid: uuid::Uuid) { SWITCH_SIDES_UUID .lock() @@ -3309,7 +5225,31 @@ pub fn insert_switch_sides_uuid(id: String, uuid: uuid::Uuid) { .insert(id, (tokio::time::Instant::now(), 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) { + 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)); +} + +#[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 { + 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 + } +} + +#[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, @@ -3324,10 +5264,19 @@ 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 let Ok(s) = crate::ipc::connect(1000, "_cm").await { - stream = Some(s); - } else { + if !headless_cm { + 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"]; @@ -3337,75 +5286,123 @@ async fn start_ipc( // Cm run as user, wait until desktop session is ready. #[cfg(target_os = "linux")] - if crate::platform::is_headless_allowed() && linux_desktop_manager::is_headless() { + if headless_cm { let mut username = linux_desktop_manager::get_username(); loop { if !username.is_empty() { break; } + // `_rx_desktop_ready` is used as a wake-up signal from desktop/session state changes + // (for example wait_desktop_cm_ready paths). It is not itself a proof of CM readiness. + // TODO: + // When `_rx_desktop_ready` is closed, `recv()` returns + // `None` immediately and this loop may spin if `username` remains empty. + // Keep behavior unchanged for now; if field reports appear, handle `Ok(None)` by + // breaking/returning to avoid hot-looping. let _res = timeout(1_000, _rx_desktop_ready.recv()).await; username = linux_desktop_manager::get_username(); } let uid = { - let output = run_cmds(&format!("id -u {}", &username))?; + 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.is_empty() || !output.parse::().is_ok() { - bail!("Invalid username {}", &username); + if output.parse::().is_err() { + bail!("Invalid uid {}", output); } output.to_string() }; user = Some((uid, username)); args = vec!["--cm-no-ui"]; } - 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; - if let Ok(s) = crate::ipc::connect(1000, "_cm").await { + #[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); - break; } } if stream.is_none() { - bail!("Failed to connect to connection manager"); + 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; + } + } + if let Ok(s) = crate::ipc::connect(1000, "_cm").await { + stream = Some(s); + break; + } + } } } + if stream.is_none() { + bail!("Failed to connect to connection manager"); + } let _res = tx_stream_ready.send(()).await; let mut stream = stream.ok_or(anyhow!("none stream"))?; @@ -3423,6 +5420,23 @@ async fn start_ipc( let data = ipc::Data::ClickTime(ct); stream.send(&data).await?; } + // FileBlockFromCM: data is always sent separately via send_raw. + // The data field has #[serde(skip)], so it's empty after deserialization. + // Read the raw data bytes following this message. + // + // Note: Empty data (for empty files) is correctly handled. BytesCodec with + // raw=false adds a length prefix, so next_raw() returns empty BytesMut for + // zero-length frames. This mirrors the WriteBlock pattern below. + ipc::Data::FileBlockFromCM { id, file_num, data: _, compressed, conn_id } => { + let raw_data = stream.next_raw().await?; + tx_from_cm.send(ipc::Data::FileBlockFromCM { + id, + file_num, + data: raw_data.into(), + compressed, + conn_id, + })?; + } _ => { tx_from_cm.send(data)?; } @@ -3467,6 +5481,12 @@ pub enum AlarmAuditType { IpWhitelist = 0, ExceedThirtyAttempts = 1, SixAttemptsWithinOneMinute = 2, + // ExceedThirtyLoginAttempts = 3, + // MultipleLoginsAttemptsWithinOneMinute = 4, + // MultipleLoginsAttemptsWithinOneHour = 5, + ExceedIPv6PrefixAttempts = 6, + TerminalOsLoginBackoff = 7, + TerminalOsLoginConcurrency = 8, } pub enum FileAuditType { @@ -3591,6 +5611,7 @@ impl FileRemoveLogControl { } fn start_wakelock_thread() -> std::sync::mpsc::Sender<(usize, usize)> { + // Check if we should keep awake during incoming sessions use crate::platform::{get_wakelock, WakeLock}; let (tx, rx) = std::sync::mpsc::channel::<(usize, usize)>(); std::thread::spawn(move || { @@ -3599,9 +5620,15 @@ fn start_wakelock_thread() -> std::sync::mpsc::Sender<(usize, usize)> { loop { match rx.recv() { Ok((conn_count, remote_count)) => { - if conn_count == 0 { - wakelock = None; - log::info!("drop wakelock"); + let keep_awake = config::Config::get_bool_option( + keys::OPTION_KEEP_AWAKE_DURING_INCOMING_SESSIONS, + ); + *WAKELOCK_KEEP_AWAKE_OPTION.lock().unwrap() = Some(keep_awake); + if conn_count == 0 || !keep_awake { + if wakelock.is_some() { + wakelock = None; + log::info!("drop wakelock"); + } } else { let mut display = remote_count > 0; if let Some(_w) = wakelock.as_mut() { @@ -3635,6 +5662,19 @@ fn start_wakelock_thread() -> std::sync::mpsc::Sender<(usize, usize)> { tx } +#[cfg(all(target_os = "windows", feature = "flutter"))] +pub fn on_printer_data(data: Vec) { + crate::server::AUTHED_CONNS + .lock() + .unwrap() + .iter() + .filter(|c| c.printer) + .next() + .map(|c| { + c.sender.send(Data::PrinterData(data)).ok(); + }); +} + #[cfg(windows)] pub struct PortableState { pub last_uac: bool, @@ -3659,6 +5699,19 @@ impl Drop for Connection { fn drop(&mut self) { #[cfg(not(any(target_os = "android", target_os = "ios")))] self.release_pressed_modifiers(); + + if let Some(s) = self.terminal_generic_service.as_ref() { + s.join(); + } + + #[cfg(target_os = "windows")] + if let Some(TerminalUserToken::CurrentLogonUser(token)) = self.terminal_user_token.take() { + if token.as_raw() != 0 { + unsafe { + hbb_common::allow_err!(CloseHandle(HANDLE(token.as_raw() as _))); + }; + } + } } } @@ -3730,9 +5783,13 @@ impl Retina { #[inline] fn on_mouse_event(&mut self, e: &mut MouseEvent, current: usize) { - let evt_type = e.mask & 0x7; - if evt_type == crate::input::MOUSE_TYPE_WHEEL { - // x and y are always 0, +1 or -1 + let evt_type = e.mask & crate::input::MOUSE_TYPE_MASK; + // Delta-based events do not contain absolute coordinates. + // Avoid applying Retina coordinate scaling to them. + if evt_type == crate::input::MOUSE_TYPE_WHEEL + || evt_type == crate::input::MOUSE_TYPE_TRACKPAD + || evt_type == crate::input::MOUSE_TYPE_MOVE_RELATIVE + { return; } let Some(d) = self.displays.get(current) else { @@ -3768,10 +5825,53 @@ impl Retina { } } +/// Get control permission state from CONTROL_PERMISSIONS_ARRAY. +/// Returns: Some(false) if any disable, Some(true) if any enable (and no disable), None if not set. +pub fn get_control_permission_state( + permission: hbb_common::rendezvous_proto::control_permissions::Permission, + disable_if_has_disabled: bool, +) -> Option { + let control_permissions = CONTROL_PERMISSIONS_ARRAY.lock().unwrap(); + let mut has_enable = false; + let mut has_disable = false; + for (_, cp) in control_permissions.iter() { + match crate::get_control_permission(cp.permissions, permission) { + Some(false) => has_disable = true, + Some(true) => has_enable = true, + None => {} + } + } + if disable_if_has_disabled { + if has_disable { + Some(false) + } else if has_enable { + Some(true) + } else { + None + } + } else { + if has_enable { + Some(true) + } else if has_disable { + Some(false) + } else { + None + } + } +} + +pub struct AuthedConn { + pub conn_id: i32, + pub conn_type: AuthConnType, + pub session_key: SessionKey, + pub sender: mpsc::UnboundedSender, + pub printer: bool, +} + mod raii { - // CONN_COUNT: remote connection count in fact // ALIVE_CONNS: all connections, including unauthorized connections // AUTHED_CONNS: all authorized connections + // CONTROL_PERMISSIONS_ARRAY: all non-None control permissions use super::*; pub struct ConnectionID(i32); @@ -3787,27 +5887,41 @@ mod raii { fn drop(&mut self) { let mut active_conns_lock = ALIVE_CONNS.lock().unwrap(); active_conns_lock.retain(|&c| c != self.0); - video_service::VIDEO_QOS - .lock() - .unwrap() - .on_connection_close(self.0); } } pub struct AuthedConnID(i32, AuthConnType); impl AuthedConnID { - pub fn new(conn_id: i32, conn_type: AuthConnType, session_key: SessionKey) -> Self { - AUTHED_CONNS - .lock() - .unwrap() - .push((conn_id, conn_type, session_key)); + pub fn new( + conn_id: i32, + conn_type: AuthConnType, + session_key: SessionKey, + sender: mpsc::UnboundedSender, + lr: LoginRequest, + ) -> Self { + let printer = conn_type == crate::server::AuthConnType::Remote + && crate::is_support_remote_print(&lr.version) + && lr.my_platform == hbb_common::whoami::Platform::Windows.to_string(); + AUTHED_CONNS.lock().unwrap().push(AuthedConn { + conn_id, + conn_type, + session_key, + sender, + printer, + }); Self::check_wake_lock(); use std::sync::Once; static _ONCE: Once = Once::new(); _ONCE.call_once(|| { shutdown_hooks::add_shutdown_hook(connection_shutdown_hook); }); + if conn_type == AuthConnType::Remote || conn_type == AuthConnType::ViewCamera { + video_service::VIDEO_QOS + .lock() + .unwrap() + .on_connection_open(conn_id); + } Self(conn_id, conn_type) } @@ -3817,7 +5931,7 @@ mod raii { .lock() .unwrap() .iter() - .filter(|c| c.1 == AuthConnType::Remote) + .filter(|c| c.conn_type == AuthConnType::Remote) .count(); allow_err!(WAKELOCK_SENDER .lock() @@ -3825,12 +5939,22 @@ mod raii { .send((conn_count, remote_count))); } - pub fn remote_and_file_conn_count() -> usize { + pub fn check_wake_lock_on_setting_changed() { + let current = + config::Config::get_bool_option(keys::OPTION_KEEP_AWAKE_DURING_INCOMING_SESSIONS); + let cached = *WAKELOCK_KEEP_AWAKE_OPTION.lock().unwrap(); + if cached != Some(current) { + Self::check_wake_lock(); + } + } + + #[cfg(windows)] + pub fn non_port_forward_conn_count() -> usize { AUTHED_CONNS .lock() .unwrap() .iter() - .filter(|c| c.1 == AuthConnType::Remote || c.1 == AuthConnType::FileTransfer) + .filter(|c| c.conn_type != AuthConnType::PortForward) .count() } @@ -3838,16 +5962,22 @@ mod raii { let mut lock = SESSIONS.lock().unwrap(); let contains = lock.contains_key(&key); if contains { + // No two remote connections with the same session key, just for ensure. + let is_remote = AUTHED_CONNS + .lock() + .unwrap() + .iter() + .any(|c| c.conn_id == conn_id && c.conn_type == AuthConnType::Remote); // If there are 2 connections with the same peer_id and session_id, a remote connection and a file transfer or port forward connection, // If any of the connections is closed allowing retry, this will not be called; // If the file transfer/port forward connection is closed with no retry, the session should be kept for remote control menu action; // If the remote connection is closed with no retry, keep the session is not reasonable in case there is a retry button in the remote side, and ignore network fluctuations. - let another_remote = AUTHED_CONNS - .lock() - .unwrap() - .iter() - .any(|c| c.0 != conn_id && c.2 == key && c.1 == AuthConnType::Remote); - if !another_remote { + let another_remote = AUTHED_CONNS.lock().unwrap().iter().any(|c| { + c.conn_id != conn_id + && c.session_key == key + && c.conn_type == AuthConnType::Remote + }); + if is_remote || !another_remote { lock.remove(&key); log::info!("remove session"); } else { @@ -3899,19 +6029,30 @@ mod raii { ); } } + + pub fn conn_type(&self) -> AuthConnType { + self.1 + } } impl Drop for AuthedConnID { fn drop(&mut self) { - if self.1 == AuthConnType::Remote { + if self.1 == AuthConnType::Remote || self.1 == AuthConnType::ViewCamera { scrap::codec::Encoder::update(scrap::codec::EncodingUpdate::Remove(self.0)); + video_service::VIDEO_QOS + .lock() + .unwrap() + .on_connection_close(self.0); } - AUTHED_CONNS.lock().unwrap().retain(|c| c.0 != self.0); + // Clear per-connection state to avoid stale behavior if conn ids are reused. + #[cfg(not(any(target_os = "android", target_os = "ios")))] + clear_relative_mouse_active(self.0); + AUTHED_CONNS.lock().unwrap().retain(|c| c.conn_id != self.0); let remote_count = AUTHED_CONNS .lock() .unwrap() .iter() - .filter(|c| c.1 == AuthConnType::Remote) + .filter(|c| c.conn_type == AuthConnType::Remote) .count(); if remote_count == 0 { #[cfg(any(target_os = "windows", target_os = "linux"))] @@ -3919,13 +6060,46 @@ mod raii { *WALLPAPER_REMOVER.lock().unwrap() = None; } #[cfg(not(any(target_os = "android", target_os = "ios")))] - display_service::reset_resolutions(); + display_service::restore_resolutions(); #[cfg(windows)] let _ = virtual_display_manager::reset_all(); #[cfg(target_os = "linux")] scrap::wayland::pipewire::try_close_session(); } Self::check_wake_lock(); + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + use crate::whiteboard; + whiteboard::unregister_whiteboard(whiteboard::get_key_cursor(self.0)); + } + } + } + + pub struct ControlPermissionsID { + id: i32, + control_permissions: Option, + } + + impl Drop for ControlPermissionsID { + fn drop(&mut self) { + if self.control_permissions.is_some() { + let mut lock = CONTROL_PERMISSIONS_ARRAY.lock().unwrap(); + lock.retain(|(conn_id, _)| *conn_id != self.id); + } + } + } + impl ControlPermissionsID { + pub fn new(id: i32, control_permissions: &Option) -> Self { + if let Some(s) = control_permissions { + CONTROL_PERMISSIONS_ARRAY + .lock() + .unwrap() + .push((id, s.clone())); + } + Self { + id, + control_permissions: control_permissions.clone(), + } } } } @@ -3965,4 +6139,11 @@ mod test { assert_eq!(pos.x, 510); assert_eq!(pos.y, 510); } + + #[test] + fn ipv6() { + assert!(Ipv6Addr::from_str("::1").is_ok()); + assert!(Ipv6Addr::from_str("127.0.0.1").is_err()); + assert!(Ipv6Addr::from_str("0").is_err()); + } } diff --git a/src/server/display_service.rs b/src/server/display_service.rs index 98b42a5fa..fe3621f26 100644 --- a/src/server/display_service.rs +++ b/src/server/display_service.rs @@ -133,12 +133,13 @@ pub fn set_last_changed_resolution(display_name: &str, original: (i32, i32), cha #[inline] #[cfg(not(any(target_os = "android", target_os = "ios")))] -pub fn reset_resolutions() { +pub fn restore_resolutions() { for (name, res) in CHANGED_RESOLUTIONS.read().unwrap().iter() { let (w, h) = res.original; + log::info!("Restore resolution of display '{}' to ({}, {})", name, w, h); if let Err(e) = crate::platform::change_resolution(name, w as _, h as _) { log::error!( - "Failed to reset resolution of display '{}' to ({},{}): {}", + "Failed to restore resolution of display '{}' to ({},{}): {}", name, w, h, @@ -146,7 +147,7 @@ pub fn reset_resolutions() { ); } } - // Can be cleared because reset resolutions is called when there is no client connected. + // Can be cleared because restore resolutions is called when there is no client connected. CHANGED_RESOLUTIONS.write().unwrap().clear(); } @@ -303,6 +304,12 @@ pub(super) fn get_display_info(idx: usize) -> Option { // Display to DisplayInfo // The DisplayInfo is be sent to the peer. pub(super) fn check_update_displays(all: &Vec) { + // For compatibility: if only one display, scale remains 1.0 and we use the physical size for `uinput`. + // If there are multiple displays, we use the logical size for `uinput` by setting scale to d.scale(). + #[cfg(target_os = "linux")] + let use_logical_scale = !is_x11() + && crate::is_server() + && scrap::wayland::display::get_displays().displays.len() > 1; let displays = all .iter() .map(|d| { @@ -314,6 +321,12 @@ pub(super) fn check_update_displays(all: &Vec) { { scale = d.scale(); } + #[cfg(target_os = "linux")] + { + if use_logical_scale { + scale = d.scale(); + } + } let original_resolution = get_original_resolution( &display_name, ((d.width() as f64) / scale).round() as usize, diff --git a/src/server/input_service.rs b/src/server/input_service.rs index c7f651e9a..97dc78755 100644 --- a/src/server/input_service.rs +++ b/src/server/input_service.rs @@ -1,9 +1,9 @@ #[cfg(target_os = "linux")] use super::rdp_input::client::{RdpInputKeyboard, RdpInputMouse}; use super::*; -#[cfg(target_os = "macos")] -use crate::common::is_server; use crate::input::*; +#[cfg(not(any(target_os = "android", target_os = "ios")))] +use crate::whiteboard; #[cfg(target_os = "macos")] use dispatch::Queue; use enigo::{Enigo, Key, KeyboardControllable, MouseButton, MouseControllable}; @@ -17,13 +17,16 @@ use rdev::{self, EventType, Key as RdevKey, KeyCode, RawKey}; use rdev::{CGEventSourceStateID, CGEventTapLocation, VirtualInput}; #[cfg(target_os = "linux")] use scrap::wayland::pipewire::RDP_SESSION_INFO; +#[cfg(target_os = "linux")] +use std::sync::mpsc; use std::{ convert::TryFrom, - ops::{Deref, DerefMut, Sub}, + ops::{Deref, DerefMut}, sync::atomic::{AtomicBool, Ordering}, thread, time::{self, Duration, Instant}, }; + #[cfg(windows)] use winapi::um::winuser::WHEEL_DELTA; @@ -108,6 +111,10 @@ struct Input { const KEY_CHAR_START: u64 = 9999; +// XKB keycode for Insert key (evdev KEY_INSERT code 110 + 8 for XKB offset) +#[cfg(target_os = "linux")] +const XKB_KEY_INSERT: u16 = evdev::Key::KEY_INSERT.code() + 8; + #[derive(Clone, Default)] pub struct MouseCursorSub { inner: ConnInner, @@ -204,6 +211,7 @@ impl LockModesHandler { } let mut num_lock_changed = false; + #[allow(unused)] let mut event_num_enabled = false; if is_numpad_key { let local_num_enabled = en.get_key_state(enigo::Key::NumLock); @@ -444,7 +452,36 @@ lazy_static::lazy_static! { static ref KEYS_DOWN: Arc>> = Default::default(); static ref LATEST_PEER_INPUT_CURSOR: Arc> = Default::default(); static ref LATEST_SYS_CURSOR_POS: Arc, (i32, i32))>> = Arc::new(Mutex::new((None, (INVALID_CURSOR_POS, INVALID_CURSOR_POS)))); + // Track connections that are currently using relative mouse movement. + // Used to disable whiteboard/cursor display for all events while in relative mode. + static ref RELATIVE_MOUSE_CONNS: Arc>> = Default::default(); } + +#[inline] +fn set_relative_mouse_active(conn: i32, active: bool) { + let mut lock = RELATIVE_MOUSE_CONNS.lock().unwrap(); + if active { + lock.insert(conn); + } else { + lock.remove(&conn); + } +} + +#[inline] +fn is_relative_mouse_active(conn: i32) -> bool { + RELATIVE_MOUSE_CONNS.lock().unwrap().contains(&conn) +} + +/// Clears the relative mouse mode state for a connection. +/// +/// This must be called when an authenticated connection is dropped (during connection teardown) +/// to avoid leaking the connection id in `RELATIVE_MOUSE_CONNS` (a `Mutex>`). +/// Callers are responsible for invoking this on disconnect. +#[inline] +pub(crate) fn clear_relative_mouse_active(conn: i32) { + set_relative_mouse_active(conn, false); +} + static EXITING: AtomicBool = AtomicBool::new(false); const MOUSE_MOVE_PROTECTION_TIMEOUT: Duration = Duration::from_millis(1_000); @@ -457,7 +494,7 @@ static RECORD_CURSOR_POS_RUNNING: AtomicBool = AtomicBool::new(false); // We need to do some special handling for macOS when using the legacy mode. #[cfg(target_os = "macos")] static LAST_KEY_LEGACY_MODE: AtomicBool = AtomicBool::new(true); -// We use enigo to +// We use enigo to // 1. Simulate mouse events // 2. Simulate the legacy mode key events // 3. Simulate the functioin key events, like LockScreen @@ -501,8 +538,13 @@ pub fn try_start_record_cursor_pos() -> Option> { } pub fn try_stop_record_cursor_pos() { - let count_lock = CONN_COUNT.lock().unwrap(); - if *count_lock > 0 { + let remote_count = AUTHED_CONNS + .lock() + .unwrap() + .iter() + .filter(|c| c.conn_type == AuthConnType::Remote) + .count(); + if remote_count > 0 { return; } RECORD_CURSOR_POS_RUNNING.store(false, Ordering::SeqCst); @@ -636,8 +678,8 @@ async fn set_uinput_resolution(minx: i32, maxx: i32, miny: i32, maxy: i32) -> Re pub fn is_left_up(evt: &MouseEvent) -> bool { let buttons = evt.mask >> 3; - let evt_type = evt.mask & 0x7; - return buttons == 1 && evt_type == 2; + let evt_type = evt.mask & MOUSE_TYPE_MASK; + buttons == MOUSE_BUTTON_LEFT && evt_type == MOUSE_TYPE_UP } #[cfg(windows)] @@ -659,10 +701,20 @@ fn is_pressed(key: &Key, en: &mut Enigo) -> bool { get_modifier_state(key.clone(), en) } +// Sleep for 8ms is enough in my tests, but we sleep 12ms to be safe. +// sleep 12ms In my test, the characters are already output in real time. #[inline] #[cfg(target_os = "macos")] fn key_sleep() { - std::thread::sleep(Duration::from_millis(20)); + // https://www.reddit.com/r/rustdesk/comments/1kn1w5x/typing_lags_when_connecting_to_macos_clients/ + // + // There's a strange bug when running by `launchctl load -w /Library/LaunchAgents/abc.plist` + // `std::thread::sleep(Duration::from_millis(20));` may sleep 90ms or more. + // Though `/Applications/RustDesk.app/Contents/MacOS/rustdesk --server` in terminal is ok. + let now = Instant::now(); + while now.elapsed() < Duration::from_millis(12) { + std::thread::sleep(Duration::from_millis(1)); + } } #[inline] @@ -684,24 +736,33 @@ fn get_modifier_state(key: Key, en: &mut Enigo) -> bool { } } -pub fn handle_mouse(evt: &MouseEvent, conn: i32) { +#[allow(unreachable_code)] +pub fn handle_mouse( + evt: &MouseEvent, + conn: i32, + username: String, + argb: u32, + simulate: bool, + show_cursor: bool, +) { #[cfg(target_os = "macos")] - if !is_server() { - // having GUI, run main GUI thread, otherwise crash + { + // having GUI (--server has tray, it is GUI too), run main GUI thread, otherwise crash let evt = evt.clone(); - QUEUE.exec_async(move || handle_mouse_(&evt, conn)); + QUEUE.exec_async(move || handle_mouse_(&evt, conn, username, argb, simulate, show_cursor)); return; } #[cfg(windows)] - crate::portable_service::client::handle_mouse(evt, conn); + crate::portable_service::client::handle_mouse(evt, conn, username, argb, simulate, show_cursor); #[cfg(not(windows))] - handle_mouse_(evt, conn); + handle_mouse_(evt, conn, username, argb, simulate, show_cursor); } // to-do: merge handle_mouse and handle_pointer +#[allow(unreachable_code)] pub fn handle_pointer(evt: &PointerDeviceEvent, conn: i32) { #[cfg(target_os = "macos")] - if !is_server() { + { // having GUI, run main GUI thread, otherwise crash let evt = evt.clone(); QUEUE.exec_async(move || handle_pointer_(&evt, conn)); @@ -748,7 +809,7 @@ fn record_key_is_control_key(record_key: u64) -> bool { #[inline] fn record_key_is_chr(record_key: u64) -> bool { - record_key < KEY_CHAR_START + record_key >= KEY_CHAR_START } #[inline] @@ -879,7 +940,7 @@ fn get_last_input_cursor_pos() -> (i32, i32) { } // check if mouse is moved by the controlled side user to make controlled side has higher mouse priority than remote. -fn active_mouse_(conn: i32) -> bool { +fn active_mouse_(_conn: i32) -> bool { true /* this method is buggy (not working on macOS, making fast moving mouse event discarded here) and added latency (this is blocking way, must do in async way), so we disable it for now // out of time protection @@ -964,7 +1025,32 @@ pub fn handle_pointer_(evt: &PointerDeviceEvent, conn: i32) { } } -pub fn handle_mouse_(evt: &MouseEvent, conn: i32) { +pub fn handle_mouse_( + evt: &MouseEvent, + conn: i32, + _username: String, + _argb: u32, + simulate: bool, + _show_cursor: bool, +) { + if simulate { + handle_mouse_simulation_(evt, conn); + } + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + let evt_type = evt.mask & MOUSE_TYPE_MASK; + // Relative (delta) mouse events do not include absolute coordinates, so + // whiteboard/cursor rendering must be disabled during relative mode to prevent + // incorrect cursor/whiteboard updates. We check both is_relative_mouse_active(conn) + // (connection already in relative mode from prior events) and evt_type (current + // event is relative) to guard against the first relative event before the flag is set. + if _show_cursor && !is_relative_mouse_active(conn) && evt_type != MOUSE_TYPE_MOVE_RELATIVE { + handle_mouse_show_cursor_(evt, conn, _username, _argb); + } + } +} + +pub fn handle_mouse_simulation_(evt: &MouseEvent, conn: i32) { if !active_mouse_(conn) { return; } @@ -976,7 +1062,7 @@ pub fn handle_mouse_(evt: &MouseEvent, conn: i32) { #[cfg(windows)] crate::platform::windows::try_change_desktop(); let buttons = evt.mask >> 3; - let evt_type = evt.mask & 0x7; + let evt_type = evt.mask & MOUSE_TYPE_MASK; let mut en = ENIGO.lock().unwrap(); #[cfg(target_os = "macos")] en.set_ignore_flags(enigo_ignore_flags()); @@ -1004,6 +1090,8 @@ pub fn handle_mouse_(evt: &MouseEvent, conn: i32) { } match evt_type { MOUSE_TYPE_MOVE => { + // Switching back to absolute movement implicitly disables relative mouse mode. + set_relative_mouse_active(conn, false); en.mouse_move_to(evt.x, evt.y); *LATEST_PEER_INPUT_CURSOR.lock().unwrap() = Input { conn, @@ -1012,6 +1100,32 @@ pub fn handle_mouse_(evt: &MouseEvent, conn: i32) { y: evt.y, }; } + // MOUSE_TYPE_MOVE_RELATIVE: Relative mouse movement for gaming/3D applications. + // Each client independently decides whether to use relative mode. + // Multiple clients can mix absolute and relative movements without conflict, + // as the server simply applies the delta to the current cursor position. + MOUSE_TYPE_MOVE_RELATIVE => { + set_relative_mouse_active(conn, true); + // Clamp delta to prevent extreme/malicious values from reaching OS APIs. + // This matches the Flutter client's kMaxRelativeMouseDelta constant. + const MAX_RELATIVE_MOUSE_DELTA: i32 = 10000; + let dx = evt + .x + .clamp(-MAX_RELATIVE_MOUSE_DELTA, MAX_RELATIVE_MOUSE_DELTA); + let dy = evt + .y + .clamp(-MAX_RELATIVE_MOUSE_DELTA, MAX_RELATIVE_MOUSE_DELTA); + en.mouse_move_relative(dx, dy); + // Get actual cursor position after relative movement for tracking + if let Some((x, y)) = crate::get_cursor_pos() { + *LATEST_PEER_INPUT_CURSOR.lock().unwrap() = Input { + conn, + time: get_time(), + x, + y, + }; + } + } MOUSE_TYPE_DOWN => match buttons { MOUSE_BUTTON_LEFT => { allow_err!(en.mouse_down(MouseButton::Left)); @@ -1107,6 +1221,52 @@ pub fn handle_mouse_(evt: &MouseEvent, conn: i32) { } } +#[cfg(not(any(target_os = "android", target_os = "ios")))] +pub fn handle_mouse_show_cursor_(evt: &MouseEvent, conn: i32, username: String, argb: u32) { + let buttons = evt.mask >> 3; + let evt_type = evt.mask & MOUSE_TYPE_MASK; + match evt_type { + MOUSE_TYPE_MOVE => { + whiteboard::update_whiteboard( + whiteboard::get_key_cursor(conn), + whiteboard::CustomEvent::Cursor(whiteboard::Cursor { + x: evt.x as _, + y: evt.y as _, + argb, + btns: 0, + text: username, + }), + ); + } + MOUSE_TYPE_UP => { + if buttons == MOUSE_BUTTON_LEFT { + // Some clients intentionally send button events without coordinates. + // Fall back to the last known cursor position to avoid jumping to (0, 0). + // TODO(protocol): (0, 0) is a valid screen coordinate. Consider using a dedicated + // sentinel value (e.g. INVALID_CURSOR_POS) or a protocol-level flag to distinguish + // "coordinates not provided" from "coordinates are (0, 0)". Impact is minor since + // this only affects whiteboard rendering and clicking exactly at (0, 0) is rare. + let (x, y) = if evt.x == 0 && evt.y == 0 { + get_last_input_cursor_pos() + } else { + (evt.x, evt.y) + }; + whiteboard::update_whiteboard( + whiteboard::get_key_cursor(conn), + whiteboard::CustomEvent::Cursor(whiteboard::Cursor { + x: x as _, + y: y as _, + argb, + btns: buttons, + text: username, + }), + ); + } + } + _ => {} + } +} + #[cfg(target_os = "windows")] fn handle_scale(scale: i32) { let mut en = ENIGO.lock().unwrap(); @@ -1186,6 +1346,13 @@ pub fn handle_key(evt: &KeyEvent) { // having GUI, run main GUI thread, otherwise crash let evt = evt.clone(); QUEUE.exec_async(move || handle_key_(&evt)); + // Key sleep is required for macOS. + // If we don't sleep, the key press/release events may not take effect. + // + // For example, the controlled side osx `12.7.6` or `15.1.1` + // If we input characters quickly and continuously, and press or release "Shift" for a short period of time, + // it is possible that after releasing "Shift", the controlled side will still print uppercase characters. + // Though it is not very easy to reproduce. key_sleep(); } @@ -1200,11 +1367,7 @@ fn reset_input() { #[cfg(target_os = "macos")] pub fn reset_input_ondisconn() { - if !is_server() { - QUEUE.exec_async(reset_input); - } else { - reset_input(); - } + QUEUE.exec_async(reset_input); } fn sim_rdev_rawkey_position(code: KeyCode, keydown: bool) { @@ -1246,7 +1409,7 @@ fn sim_rdev_rawkey_virtual(code: u32, keydown: bool) { fn simulate_(event_type: &EventType) { unsafe { let _lock = VIRTUAL_INPUT_MTX.lock(); - if let Some(input) = &VIRTUAL_INPUT_STATE { + if let Some(input) = VIRTUAL_INPUT_STATE.as_ref() { let _ = input.simulate(&event_type); } } @@ -1258,7 +1421,7 @@ fn press_capslock() { let caps_key = RdevKey::RawKey(rdev::RawKey::MacVirtualKeycode(rdev::kVK_CapsLock)); unsafe { let _lock = VIRTUAL_INPUT_MTX.lock(); - if let Some(input) = &mut VIRTUAL_INPUT_STATE { + if let Some(input) = VIRTUAL_INPUT_STATE.as_mut() { if input.simulate(&EventType::KeyPress(caps_key)).is_ok() { input.capslock_down = true; key_sleep(); @@ -1273,7 +1436,7 @@ fn release_capslock() { let caps_key = RdevKey::RawKey(rdev::RawKey::MacVirtualKeycode(rdev::kVK_CapsLock)); unsafe { let _lock = VIRTUAL_INPUT_MTX.lock(); - if let Some(input) = &mut VIRTUAL_INPUT_STATE { + if let Some(input) = VIRTUAL_INPUT_STATE.as_mut() { if input.simulate(&EventType::KeyRelease(caps_key)).is_ok() { input.capslock_down = false; key_sleep(); @@ -1310,20 +1473,26 @@ fn map_keyboard_mode(evt: &KeyEvent) { // Wayland #[cfg(target_os = "linux")] if !crate::platform::linux::is_x11() { - let mut en = ENIGO.lock().unwrap(); - let code = evt.chr() as u16; - - if evt.down { - en.key_down(enigo::Key::Raw(code)).ok(); - } else { - en.key_up(enigo::Key::Raw(code)); - } + wayland_send_raw_key(evt.chr() as u16, evt.down); return; } sim_rdev_rawkey_position(evt.chr() as _, evt.down); } +/// Send raw keycode on Wayland via the active backend (uinput or RemoteDesktop portal). +/// The keycode is expected to be a Linux keycode (evdev code + 8 for X11 compatibility). +#[cfg(target_os = "linux")] +#[inline] +fn wayland_send_raw_key(code: u16, down: bool) { + let mut en = ENIGO.lock().unwrap(); + if down { + en.key_down(enigo::Key::Raw(code)).ok(); + } else { + en.key_up(enigo::Key::Raw(code)); + } +} + #[cfg(target_os = "macos")] fn add_flags_to_enigo(en: &mut Enigo, key_event: &KeyEvent) { // When long-pressed the command key, then press and release @@ -1344,6 +1513,27 @@ fn get_control_key_value(key_event: &KeyEvent) -> i32 { } } +#[inline] +fn has_hotkey_modifiers(key_event: &KeyEvent) -> bool { + key_event.modifiers.iter().any(|ck| { + let v = ck.value(); + v == ControlKey::Control.value() + || v == ControlKey::RControl.value() + || v == ControlKey::Meta.value() + || v == ControlKey::RWin.value() + || { + #[cfg(any(target_os = "windows", target_os = "linux"))] + { + v == ControlKey::Alt.value() || v == ControlKey::RAlt.value() + } + #[cfg(target_os = "macos")] + { + false + } + } + }) +} + fn release_unpressed_modifiers(en: &mut Enigo, key_event: &KeyEvent) { let ck_value = get_control_key_value(key_event); fix_modifiers(&key_event.modifiers[..], en, ck_value); @@ -1403,7 +1593,31 @@ fn need_to_uppercase(en: &mut Enigo) -> bool { get_modifier_state(Key::Shift, en) || get_modifier_state(Key::CapsLock, en) } -fn process_chr(en: &mut Enigo, chr: u32, down: bool) { +fn process_chr(en: &mut Enigo, chr: u32, down: bool, _hotkey: bool) { + // On Wayland with uinput mode, use clipboard for character input + #[cfg(target_os = "linux")] + if !crate::platform::linux::is_x11() && wayland_use_uinput() { + // Skip clipboard for hotkeys (Ctrl/Alt/Meta pressed) + if !is_hotkey_modifier_pressed(en) { + if down { + if let Ok(c) = char::try_from(chr) { + input_char_via_clipboard_server(en, c); + } + } + return; + } + } + + #[cfg(any(target_os = "macos", target_os = "windows"))] + if !_hotkey { + if down { + if let Ok(chr) = char::try_from(chr) { + en.key_sequence(&chr.to_string()); + } + } + return; + } + let key = char_value_to_key(chr); if down { @@ -1423,15 +1637,136 @@ fn process_chr(en: &mut Enigo, chr: u32, down: bool) { } fn process_unicode(en: &mut Enigo, chr: u32) { + // On Wayland with uinput mode, use clipboard for character input + #[cfg(target_os = "linux")] + if !crate::platform::linux::is_x11() && wayland_use_uinput() { + if let Ok(c) = char::try_from(chr) { + input_char_via_clipboard_server(en, c); + } + return; + } + if let Ok(chr) = char::try_from(chr) { en.key_sequence(&chr.to_string()); } } fn process_seq(en: &mut Enigo, sequence: &str) { + // On Wayland with uinput mode, use clipboard for text input + #[cfg(target_os = "linux")] + if !crate::platform::linux::is_x11() && wayland_use_uinput() { + input_text_via_clipboard_server(en, sequence); + return; + } + en.key_sequence(&sequence); } +/// Delay in milliseconds to wait for clipboard to sync on Wayland. +/// This is an empirical value — Wayland provides no callback or event to confirm +/// clipboard content has been received by the compositor. Under heavy system load, +/// this delay may be insufficient, but there is no reliable alternative mechanism. +#[cfg(target_os = "linux")] +const CLIPBOARD_SYNC_DELAY_MS: u64 = 50; + +/// Internal: Set clipboard content without delay. +/// Returns true if clipboard was set successfully. +#[cfg(target_os = "linux")] +fn set_clipboard_content(text: &str) -> bool { + use arboard::{Clipboard, LinuxClipboardKind, SetExtLinux}; + + let mut clipboard = match Clipboard::new() { + Ok(cb) => cb, + Err(e) => { + log::error!("set_clipboard_content: failed to create clipboard: {:?}", e); + return false; + } + }; + + // Set both CLIPBOARD and PRIMARY selections + // Terminal uses PRIMARY for Shift+Insert, GUI apps use CLIPBOARD + if let Err(e) = clipboard + .set() + .clipboard(LinuxClipboardKind::Clipboard) + .text(text.to_owned()) + { + log::error!("set_clipboard_content: failed to set CLIPBOARD: {:?}", e); + return false; + } + if let Err(e) = clipboard + .set() + .clipboard(LinuxClipboardKind::Primary) + .text(text.to_owned()) + { + log::warn!("set_clipboard_content: failed to set PRIMARY: {:?}", e); + // Continue anyway, CLIPBOARD might work + } + + true +} + +/// Set clipboard content for paste operation (sync version for use in blocking contexts). +/// +/// Note: The original clipboard content is intentionally NOT restored after paste. +/// Restoring clipboard could cause race conditions where subsequent keystrokes +/// might accidentally paste the old clipboard content instead of the intended input. +/// This trade-off prioritizes input reliability over preserving clipboard state. +#[cfg(target_os = "linux")] +#[inline] +pub(super) fn set_clipboard_for_paste_sync(text: &str) -> bool { + if !set_clipboard_content(text) { + return false; + } + std::thread::sleep(std::time::Duration::from_millis(CLIPBOARD_SYNC_DELAY_MS)); + true +} + +/// Check if a character is ASCII printable (0x20-0x7E). +#[cfg(target_os = "linux")] +#[inline] +pub(super) fn is_ascii_printable(c: char) -> bool { + c as u32 >= 0x20 && c as u32 <= 0x7E +} + +/// Input a single character via clipboard + Shift+Insert in server process. +#[cfg(target_os = "linux")] +#[inline] +fn input_char_via_clipboard_server(en: &mut Enigo, chr: char) { + input_text_via_clipboard_server(en, &chr.to_string()); +} + +/// Input text via clipboard + Shift+Insert in server process. +/// Shift+Insert is more universal than Ctrl+V, works in both GUI apps and terminals. +/// +/// Note: Clipboard content is NOT restored after paste - see `set_clipboard_for_paste_sync` for rationale. +#[cfg(target_os = "linux")] +fn input_text_via_clipboard_server(en: &mut Enigo, text: &str) { + if text.is_empty() { + return; + } + if !set_clipboard_for_paste_sync(text) { + return; + } + + // Use ENIGO's custom_keyboard directly to avoid creating new IPC connections + // which would cause excessive logging and keyboard device creation/destruction + if en.key_down(Key::Shift).is_err() { + log::error!("input_text_via_clipboard_server: failed to press Shift, skipping paste"); + return; + } + if en.key_down(Key::Raw(XKB_KEY_INSERT)).is_err() { + log::error!("input_text_via_clipboard_server: failed to press Insert, releasing Shift"); + en.key_up(Key::Shift); + return; + } + en.key_up(Key::Raw(XKB_KEY_INSERT)); + en.key_up(Key::Shift); + + // Brief delay to allow the target application to process the paste event. + // Empirical value — no reliable synchronization mechanism exists on Wayland. + std::thread::sleep(std::time::Duration::from_millis(20)); +} + #[cfg(not(target_os = "macos"))] fn release_keys(en: &mut Enigo, to_release: &Vec) { for key in to_release { @@ -1466,6 +1801,64 @@ fn is_function_key(ck: &EnumOrUnknown) -> bool { return res; } +/// Check if any hotkey modifier (Ctrl/Alt/Meta) is currently pressed. +/// Used to detect hotkey combinations like Ctrl+C, Alt+Tab, etc. +/// +/// Note: Shift is intentionally NOT checked here. Shift+character produces a different +/// character (e.g., Shift+a → 'A'), which is normal text input, not a hotkey. +/// Shift is only relevant as a hotkey modifier when combined with Ctrl/Alt/Meta +/// (e.g., Ctrl+Shift+Z), in which case this function already returns true via Ctrl. +#[cfg(target_os = "linux")] +#[inline] +fn is_hotkey_modifier_pressed(en: &mut Enigo) -> bool { + get_modifier_state(Key::Control, en) + || get_modifier_state(Key::RightControl, en) + || get_modifier_state(Key::Alt, en) + || get_modifier_state(Key::RightAlt, en) + || get_modifier_state(Key::Meta, en) + || get_modifier_state(Key::RWin, en) +} + +/// Release Shift keys before character input in Legacy/Translate mode. +/// In these modes, the character has already been converted by the client, +/// so we should input it directly without Shift modifier affecting the result. +/// +/// Note: Does NOT release Shift if hotkey modifiers (Ctrl/Alt/Meta) are pressed, +/// to preserve combinations like Ctrl+Shift+Z. +#[cfg(target_os = "linux")] +fn release_shift_for_char_input(en: &mut Enigo) { + // Don't release Shift if hotkey modifiers (Ctrl/Alt/Meta) are pressed. + // This preserves combinations like Ctrl+Shift+Z. + if is_hotkey_modifier_pressed(en) { + return; + } + + // In translate mode, the client has already converted the keystroke to a character + // (e.g., Shift+a → 'A'). We release Shift here so the server inputs the character + // directly without Shift affecting the result. + // + // Shift is intentionally NOT restored after input — the client will send an explicit + // Shift key_up event when the user physically releases Shift. Restoring it here would + // cause a brief Shift re-press that could interfere with the next input event. + + let is_x11 = crate::platform::linux::is_x11(); + + if get_modifier_state(Key::Shift, en) { + if !is_x11 { + en.key_up(Key::Shift); + } else { + simulate_(&EventType::KeyRelease(RdevKey::ShiftLeft)); + } + } + if get_modifier_state(Key::RightShift, en) { + if !is_x11 { + en.key_up(Key::RightShift); + } else { + simulate_(&EventType::KeyRelease(RdevKey::ShiftRight)); + } + } +} + fn legacy_keyboard_mode(evt: &KeyEvent) { #[cfg(windows)] crate::platform::windows::try_change_desktop(); @@ -1485,11 +1878,24 @@ fn legacy_keyboard_mode(evt: &KeyEvent) { process_control_key(&mut en, &ck, down) } Some(key_event::Union::Chr(chr)) => { + // For character input in Legacy mode, we need to release Shift first. + // The character has already been converted by the client, so we should + // input it directly without Shift modifier affecting the result. + // Only Ctrl/Alt/Meta should be kept for hotkeys like Ctrl+C. + #[cfg(target_os = "linux")] + release_shift_for_char_input(&mut en); + let record_key = chr as u64 + KEY_CHAR_START; record_pressed_key(KeysDown::EnigoKey(record_key), down); - process_chr(&mut en, chr, down) + process_chr(&mut en, chr, down, has_hotkey_modifiers(evt)) + } + Some(key_event::Union::Unicode(chr)) => { + // Same as Chr: release Shift for Unicode input + #[cfg(target_os = "linux")] + release_shift_for_char_input(&mut en); + + process_unicode(&mut en, chr) } - Some(key_event::Union::Unicode(chr)) => process_unicode(&mut en, chr), Some(key_event::Union::Seq(ref seq)) => process_seq(&mut en, seq), _ => {} } @@ -1510,6 +1916,51 @@ fn translate_process_code(code: u32, down: bool) { fn translate_keyboard_mode(evt: &KeyEvent) { match &evt.union { Some(key_event::Union::Seq(seq)) => { + // On Wayland, handle character input directly in this (--server) process using clipboard. + // This function runs in the --server process (logged-in user session), which has + // WAYLAND_DISPLAY and XDG_RUNTIME_DIR — so clipboard operations work here. + // + // Why not let it go through uinput IPC: + // 1. For uinput mode: the uinput service thread runs in the --service (root) process, + // which typically lacks user session environment. Clipboard operations there are + // unreliable. Handling clipboard here avoids that issue. + // 2. For RDP input mode: Portal's notify_keyboard_keysym API interprets keysyms + // based on its internal modifier state, which may not match our released state. + // Using clipboard bypasses this issue entirely. + #[cfg(target_os = "linux")] + if !crate::platform::linux::is_x11() { + let mut en = ENIGO.lock().unwrap(); + + // Check if this is a hotkey (Ctrl/Alt/Meta pressed) + // For hotkeys, we send character-based key events via Enigo instead of + // using the clipboard. This relies on the local keyboard layout for + // mapping characters to physical keys. + // This assumes client and server use the same keyboard layout (common case). + // Note: For non-Latin keyboards (e.g., Arabic), hotkeys may not work + // correctly if the character cannot be mapped to a key via KEY_MAP_LAYOUT. + // This is a known limitation - most common hotkeys (Ctrl+A/C/V/Z) use Latin + // characters which are mappable on most keyboard layouts. + if is_hotkey_modifier_pressed(&mut en) { + // For hotkeys, send character-based key events via Enigo. + // This relies on the local keyboard layout mapping (KEY_MAP_LAYOUT). + for chr in seq.chars() { + if !is_ascii_printable(chr) { + log::warn!( + "Hotkey with non-ASCII character may not work correctly on non-Latin keyboard layouts" + ); + } + en.key_click(Key::Layout(chr)); + } + return; + } + + // Normal text input: release Shift and use clipboard + release_shift_for_char_input(&mut en); + + input_text_via_clipboard_server(&mut en, seq); + return; + } + // Fr -> US // client: Shift + & => 1(send to remote) // remote: Shift + 1 => ! @@ -1527,11 +1978,16 @@ fn translate_keyboard_mode(evt: &KeyEvent) { #[cfg(target_os = "linux")] let simulate_win_hot_key = false; if !simulate_win_hot_key { - if get_modifier_state(Key::Shift, &mut en) { - simulate_(&EventType::KeyRelease(RdevKey::ShiftLeft)); - } - if get_modifier_state(Key::RightShift, &mut en) { - simulate_(&EventType::KeyRelease(RdevKey::ShiftRight)); + #[cfg(target_os = "linux")] + release_shift_for_char_input(&mut en); + #[cfg(target_os = "windows")] + { + if get_modifier_state(Key::Shift, &mut en) { + simulate_(&EventType::KeyRelease(RdevKey::ShiftLeft)); + } + if get_modifier_state(Key::RightShift, &mut en) { + simulate_(&EventType::KeyRelease(RdevKey::ShiftRight)); + } } } for chr in seq.chars() { @@ -1551,7 +2007,16 @@ fn translate_keyboard_mode(evt: &KeyEvent) { Some(key_event::Union::Chr(..)) => { #[cfg(target_os = "windows")] translate_process_code(evt.chr(), evt.down); - #[cfg(not(target_os = "windows"))] + #[cfg(target_os = "linux")] + { + if !crate::platform::linux::is_x11() { + // Wayland: use uinput to send raw keycode + wayland_send_raw_key(evt.chr() as u16, evt.down); + } else { + sim_rdev_rawkey_position(evt.chr() as _, evt.down); + } + } + #[cfg(target_os = "macos")] sim_rdev_rawkey_position(evt.chr() as _, evt.down); } Some(key_event::Union::Unicode(..)) => { @@ -1562,7 +2027,11 @@ fn translate_keyboard_mode(evt: &KeyEvent) { simulate_win2win_hotkey(*code, evt.down); } _ => { - log::debug!("Unreachable. Unexpected key event {:?}", &evt); + log::debug!( + "Unreachable. Unexpected key event (mode={:?}, down={:?})", + &evt.mode, + &evt.down + ); } } } @@ -1754,6 +2223,51 @@ pub fn wayland_use_rdp_input() -> bool { !crate::platform::is_x11() && !crate::is_server() } +#[cfg(target_os = "linux")] +pub struct TemporaryMouseMoveHandle { + thread_handle: Option>, + tx: Option>, +} + +#[cfg(target_os = "linux")] +impl TemporaryMouseMoveHandle { + pub fn new() -> Self { + let (tx, rx) = mpsc::channel::<(i32, i32)>(); + let thread_handle = std::thread::spawn(move || { + log::debug!("TemporaryMouseMoveHandle thread started"); + for (x, y) in rx { + ENIGO.lock().unwrap().mouse_move_to(x, y); + } + log::debug!("TemporaryMouseMoveHandle thread exiting"); + }); + TemporaryMouseMoveHandle { + thread_handle: Some(thread_handle), + tx: Some(tx), + } + } + + pub fn move_mouse_to(&self, x: i32, y: i32) { + if let Some(tx) = &self.tx { + let _ = tx.send((x, y)); + } + } +} + +#[cfg(target_os = "linux")] +impl Drop for TemporaryMouseMoveHandle { + fn drop(&mut self) { + log::debug!("Dropping TemporaryMouseMoveHandle"); + // Close the channel to signal the thread to exit. + self.tx.take(); + // Wait for the thread to finish. + if let Some(thread_handle) = self.thread_handle.take() { + if let Err(e) = thread_handle.join() { + log::error!("Error joining TemporaryMouseMoveHandle thread: {:?}", e); + } + } + } +} + lazy_static::lazy_static! { static ref MODIFIER_MAP: HashMap = [ (ControlKey::Alt, Key::Alt), diff --git a/src/server/login_failure_check.rs b/src/server/login_failure_check.rs new file mode 100644 index 000000000..4394213ec --- /dev/null +++ b/src/server/login_failure_check.rs @@ -0,0 +1,231 @@ +use crate::AlarmAuditType; +use hbb_common::get_time; +#[cfg(target_os = "windows")] +use hbb_common::tokio::sync::{Mutex as TokioMutex, OwnedMutexGuard}; +use std::sync::Mutex; +#[cfg(target_os = "windows")] +use std::sync::Arc; + +const OS_CREDENTIAL_LOGIN_TOTAL_IDLE_RESET_MS: i64 = 120 * 60 * 1_000; +const OS_CREDENTIAL_LOGIN_BACKOFF_BASE_SECONDS: i64 = 15; +const OS_CREDENTIAL_LOGIN_BACKOFF_MAX_SECONDS: i64 = 30 * 60; + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub(crate) enum FailureScope { + Default, + TerminalOsLogin, +} + +pub(crate) struct OsCredentialPolicyDecision { + pub allowed: bool, + pub login_error: Option, + pub audit: Option, +} + +#[derive(Copy, Clone, Debug, Default)] +struct OsCredentialFailureState { + total_failures: i32, + backoff_until_ms: Option, + last_failure_ms: Option, +} + +lazy_static::lazy_static! { + static ref OS_CREDENTIAL_LOGIN_FAILURE_STATE: Mutex = + Mutex::new(OsCredentialFailureState::default()); +} + +#[cfg(target_os = "windows")] +lazy_static::lazy_static! { + static ref OS_CREDENTIAL_LOGIN_MUTEX: Arc> = Arc::new(TokioMutex::new(())); +} + +fn is_os_credential_scope(scope: FailureScope) -> bool { + matches!(scope, FailureScope::TerminalOsLogin) +} + +fn state_for_os_credential_scope( + scope: FailureScope, +) -> Option<&'static Mutex> { + if is_os_credential_scope(scope) { + Some(&OS_CREDENTIAL_LOGIN_FAILURE_STATE) + } else { + None + } +} + +fn backoff_audit_type_for_scope(scope: FailureScope) -> Option { + match scope { + FailureScope::TerminalOsLogin => Some(AlarmAuditType::TerminalOsLoginBackoff), + FailureScope::Default => None, + } +} + +fn os_credential_login_backoff_seconds(total_failures: i32) -> i64 { + if total_failures <= 2 { + return 0; + } + let exp = (total_failures - 3).min(7); + let seconds = OS_CREDENTIAL_LOGIN_BACKOFF_BASE_SECONDS * (1_i64 << exp); + seconds.min(OS_CREDENTIAL_LOGIN_BACKOFF_MAX_SECONDS) +} + +fn normalize_backoff(state: &mut OsCredentialFailureState, now_ms: i64) { + if let Some(until_ms) = state.backoff_until_ms { + if until_ms <= now_ms { + state.backoff_until_ms = None; + } + } +} + +fn reset_totals_on_idle(state: &mut OsCredentialFailureState, now_ms: i64) { + if let Some(last_ms) = state.last_failure_ms { + if now_ms.saturating_sub(last_ms) >= OS_CREDENTIAL_LOGIN_TOTAL_IDLE_RESET_MS { + state.total_failures = 0; + state.backoff_until_ms = None; + state.last_failure_ms = None; + } + } +} + +fn allow_decision() -> OsCredentialPolicyDecision { + OsCredentialPolicyDecision { + allowed: true, + login_error: None, + audit: None, + } +} + +fn block_decision( + login_error: String, + alarm_type: Option, +) -> OsCredentialPolicyDecision { + OsCredentialPolicyDecision { + allowed: false, + login_error: Some(login_error), + audit: alarm_type, + } +} + +pub(crate) fn evaluate_os_credential_policy( + scope: FailureScope, + now_ms: i64, +) -> OsCredentialPolicyDecision { + if !is_os_credential_scope(scope) { + return allow_decision(); + } + let Some(state_mutex) = state_for_os_credential_scope(scope) else { + return allow_decision(); + }; + let mut state = state_mutex.lock().unwrap(); + reset_totals_on_idle(&mut state, now_ms); + normalize_backoff(&mut state, now_ms); + + if let Some(until_ms) = state.backoff_until_ms { + let remaining_ms = (until_ms - now_ms).max(0); + let remaining_seconds = ((remaining_ms + 999) / 1_000).max(1); + let seconds_label = if remaining_seconds == 1 { + "second" + } else { + "seconds" + }; + block_decision( + format!( + "Please try again in {} {}.", + remaining_seconds, seconds_label + ), + backoff_audit_type_for_scope(scope), + ) + } else { + allow_decision() + } +} + +pub(crate) fn record_os_credential_failure(scope: FailureScope) { + if !is_os_credential_scope(scope) { + return; + } + let Some(state_mutex) = state_for_os_credential_scope(scope) else { + return; + }; + let mut state = state_mutex.lock().unwrap(); + let now_ms = get_time(); + reset_totals_on_idle(&mut state, now_ms); + normalize_backoff(&mut state, now_ms); + state.total_failures = state.total_failures.saturating_add(1); + state.last_failure_ms = Some(now_ms); + let backoff_seconds = os_credential_login_backoff_seconds(state.total_failures); + if backoff_seconds > 0 { + state.backoff_until_ms = Some(now_ms + backoff_seconds * 1_000); + } +} + +#[cfg(target_os = "windows")] +pub(crate) fn try_acquire_os_credential_login_gate() -> Result, ()> { + OS_CREDENTIAL_LOGIN_MUTEX + .clone() + .try_lock_owned() + .map_err(|_| ()) +} + +#[cfg(test)] +mod tests { + use super::*; + + static TEST_MUTEX: Mutex<()> = Mutex::new(()); + + fn clear_os_credential_failure_state(scope: FailureScope) { + if let Some(state_mutex) = state_for_os_credential_scope(scope) { + *state_mutex.lock().unwrap() = OsCredentialFailureState::default(); + } + } + + #[test] + fn os_credential_policy_prioritizes_backoff() { + let _guard = TEST_MUTEX.lock().unwrap(); + clear_os_credential_failure_state(FailureScope::TerminalOsLogin); + let now_ms = get_time(); + for _ in 0..3 { + record_os_credential_failure(FailureScope::TerminalOsLogin); + } + let decision = evaluate_os_credential_policy(FailureScope::TerminalOsLogin, now_ms); + assert!(!decision.allowed); + assert!(decision.login_error.is_some()); + clear_os_credential_failure_state(FailureScope::TerminalOsLogin); + } + + #[test] + fn os_credential_policy_idle_window_resets_total_counter() { + let _guard = TEST_MUTEX.lock().unwrap(); + clear_os_credential_failure_state(FailureScope::TerminalOsLogin); + for _ in 0..13 { + record_os_credential_failure(FailureScope::TerminalOsLogin); + } + let blocked = evaluate_os_credential_policy(FailureScope::TerminalOsLogin, get_time()); + assert!(!blocked.allowed); + + let after_failures_ms = get_time(); + let after_idle_ms = after_failures_ms + OS_CREDENTIAL_LOGIN_TOTAL_IDLE_RESET_MS + 1_000; + let allowed = evaluate_os_credential_policy(FailureScope::TerminalOsLogin, after_idle_ms); + assert!(allowed.allowed); + clear_os_credential_failure_state(FailureScope::TerminalOsLogin); + } + + #[test] + fn os_credential_policy_audits_every_backoff_block() { + let _guard = TEST_MUTEX.lock().unwrap(); + clear_os_credential_failure_state(FailureScope::TerminalOsLogin); + + for _ in 0..3 { + record_os_credential_failure(FailureScope::TerminalOsLogin); + } + let now_ms = get_time(); + let first = evaluate_os_credential_policy(FailureScope::TerminalOsLogin, now_ms); + let second = evaluate_os_credential_policy(FailureScope::TerminalOsLogin, now_ms + 1_000); + assert!(!first.allowed); + assert!(!second.allowed); + assert!(first.audit.is_some()); + assert!(second.audit.is_some()); + + clear_os_credential_failure_state(FailureScope::TerminalOsLogin); + } +} diff --git a/src/server/portable_service.rs b/src/server/portable_service.rs index ca86e48e7..23b69a70c 100644 --- a/src/server/portable_service.rs +++ b/src/server/portable_service.rs @@ -1,3 +1,11 @@ +use crate::{ + ipc::{self, new_listener, Connection, Data, DataPortableService, IPC_TOKEN_LEN}, + platform::{ + set_path_permission, set_path_permission_for_portable_service_shmem_dir, + set_path_permission_for_portable_service_shmem_file, + validate_path_for_portable_service_shmem_dir, + }, +}; use core::slice; use hbb_common::{ allow_err, @@ -15,26 +23,26 @@ use shared_memory::*; use std::{ mem::size_of, ops::{Deref, DerefMut}, - path::PathBuf, - sync::{Arc, Mutex}, + path::{Path, PathBuf}, + sync::{ + atomic::{AtomicBool, AtomicU64, Ordering}, + Arc, Mutex, + }, time::Duration, }; use winapi::{ shared::minwindef::{BOOL, FALSE, TRUE}, um::winuser::{self, CURSORINFO, PCURSORINFO}, }; - -use crate::{ - ipc::{self, new_listener, Connection, Data, DataPortableService}, - platform::set_path_permission, -}; +use windows::Win32::Storage::FileSystem::{FILE_GENERIC_EXECUTE, FILE_GENERIC_READ}; use super::video_qos; const SIZE_COUNTER: usize = size_of::() * 2; const FRAME_ALIGN: usize = 64; -const ADDR_CURSOR_PARA: usize = 0; +const ADDR_IPC_TOKEN: usize = 0; +const ADDR_CURSOR_PARA: usize = ADDR_IPC_TOKEN + IPC_TOKEN_LEN; const ADDR_CURSOR_COUNTER: usize = ADDR_CURSOR_PARA + size_of::(); const ADDR_CAPTURER_PARA: usize = ADDR_CURSOR_COUNTER + SIZE_COUNTER; @@ -44,12 +52,186 @@ const ADDR_CAPTURE_FRAME_COUNTER: usize = ADDR_CAPTURE_WOULDBLOCK + size_of:: bool { + !name.is_empty() + && name.len() <= SHMEM_NAME_MAX_LEN + && name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-') +} + +#[inline] +pub fn portable_service_shmem_arg(name: &str) -> String { + format!("{SHMEM_ARG_PREFIX}{name}") +} + +#[inline] +fn is_valid_portable_service_ipc_token(token: &str) -> bool { + token.len() == IPC_TOKEN_LEN + && token + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) +} + +#[inline] +fn read_ipc_token_from_shmem(shmem: &SharedMemory) -> Option { + if shmem.len() < ADDR_IPC_TOKEN + IPC_TOKEN_LEN { + log::error!( + "Portable service shared memory too small: len={}, need>={}", + shmem.len(), + ADDR_IPC_TOKEN + IPC_TOKEN_LEN + ); + return None; + } + unsafe { + let ptr = shmem.as_ptr().add(ADDR_IPC_TOKEN); + let bytes = slice::from_raw_parts(ptr, IPC_TOKEN_LEN); + let end = bytes + .iter() + .position(|byte| *byte == 0) + .unwrap_or(IPC_TOKEN_LEN); + if end == 0 { + return None; + } + let token = std::str::from_utf8(&bytes[..end]).ok()?.to_owned(); + if is_valid_portable_service_ipc_token(&token) { + Some(token) + } else { + None + } + } +} + +#[inline] +fn validate_runtime_shmem_layout(shmem: &SharedMemory) -> ResultType<()> { + if shmem.len() < MIN_RUNTIME_SHMEM_LEN { + bail!( + "Portable service shared memory too small for runtime layout: len={}, need>={}", + shmem.len(), + MIN_RUNTIME_SHMEM_LEN + ); + } + Ok(()) +} + +#[inline] +fn is_valid_capture_frame_length(shmem_len: usize, frame_len: usize) -> bool { + let frame_capacity = shmem_len.saturating_sub(ADDR_CAPTURE_FRAME); + frame_len > 0 && frame_len <= frame_capacity +} + +#[inline] +fn shared_memory_flink_path_by_name(name: &str) -> ResultType { + let mut dir = crate::platform::user_accessible_folder()?; + dir = dir.join(hbb_common::config::APP_NAME.read().unwrap().clone()); + dir = dir.join(SHMEM_PARENT_DIR); + Ok(dir.join(format!("shared_memory{}", name))) +} + +#[inline] +fn remove_shared_memory_flink_once(name: &str, log_on_error: bool, log_context: &str) -> bool { + let flink = match shared_memory_flink_path_by_name(name) { + Ok(path) => path, + Err(err) => { + if log_on_error { + log::warn!( + "{} failed to resolve portable service shared-memory flink path for '{}': {}", + log_context, + name, + err + ); + } + return false; + } + }; + match std::fs::remove_file(&flink) { + Ok(()) => { + log::info!( + "{} removed portable service shared-memory flink artifact: {:?}", + log_context, + flink + ); + true + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => true, + Err(err) => { + if log_on_error { + log::warn!( + "{} failed to remove portable service shared-memory flink artifact {:?}: {}", + log_context, + flink, + err + ); + } + false + } + } +} + +#[inline] +fn write_ipc_token_to_shmem(shmem: &SharedMemory, token: &str) -> ResultType<()> { + if !is_valid_portable_service_ipc_token(token) { + bail!("Invalid portable service ipc token"); + } + shmem.write(ADDR_IPC_TOKEN, token.as_bytes()); + Ok(()) +} + +#[inline] +fn clear_ipc_token_in_shmem(shmem: &SharedMemory) { + shmem.write(ADDR_IPC_TOKEN, &[0u8; IPC_TOKEN_LEN]); +} + +#[inline] +fn portable_service_arg_value_candidate_from_arg<'a>( + arg: &'a str, + prefix: &str, +) -> Option<&'a str> { + let mut value = arg.strip_prefix(prefix)?; + value = value.trim_start(); + value = value + .strip_prefix('"') + .or_else(|| value.strip_prefix('\'')) + .unwrap_or(value); + value = value.split_whitespace().next().unwrap_or_default(); + value = value.trim_matches(|c| c == '"' || c == '\''); + Some(value) +} + +#[inline] +pub fn portable_service_shmem_name_from_args() -> Option { + for arg in std::env::args() { + if let Some(value) = portable_service_arg_value_candidate_from_arg(&arg, SHMEM_ARG_PREFIX) { + if is_valid_portable_service_shmem_name(value) { + return Some(value.to_owned()); + } + log::error!( + "Invalid portable service shared memory name argument: '{}'", + value + ); + return None; + } + } + None +} + +#[inline] +pub fn has_portable_service_shmem_arg() -> bool { + std::env::args().any(|arg| arg.starts_with(SHMEM_ARG_PREFIX)) +} + pub struct SharedMemory { inner: Shmem, } @@ -92,7 +274,27 @@ impl SharedMemory { } }; log::info!("Create shared memory, size: {}, flink: {}", size, flink); - set_path_permission(&PathBuf::from(flink), "F").ok(); + if let Err(err) = set_path_permission_for_portable_service_shmem_file(Path::new(&flink)) { + // Release shmem handle first so best-effort flink cleanup has a chance to succeed. + drop(shmem); + match std::fs::remove_file(&flink) { + Ok(()) => { + log::info!( + "Create cleanup removed portable service shared-memory flink artifact: {}", + flink + ); + } + Err(remove_err) if remove_err.kind() == std::io::ErrorKind::NotFound => {} + Err(remove_err) => { + log::warn!( + "Create cleanup failed to remove portable service shared-memory flink artifact {}: {}", + flink, + remove_err + ); + } + } + return Err(err); + } Ok(SharedMemory { inner: shmem }) } @@ -120,9 +322,18 @@ impl SharedMemory { fn flink(name: String) -> ResultType { let mut dir = crate::platform::user_accessible_folder()?; dir = dir.join(hbb_common::config::APP_NAME.read().unwrap().clone()); - if !dir.exists() { - std::fs::create_dir(&dir)?; - set_path_permission(&dir, "F").ok(); + dir = dir.join(SHMEM_PARENT_DIR); + let parent_created = !dir.exists(); + if parent_created { + std::fs::create_dir_all(&dir)?; + } + if parent_created || crate::platform::is_root() { + // Harden parent ACL on first provisioning and periodically on SYSTEM path. + set_path_permission_for_portable_service_shmem_dir(&dir)?; + } else { + // Existing parents still need type/reparse validation. Non-SYSTEM callers may lack + // WRITE_DAC on a valid parent, so avoid rebuilding the ACL here. + validate_path_for_portable_service_shmem_dir(&dir)?; } Ok(dir .join(format!("shared_memory{}", name)) @@ -232,16 +443,45 @@ pub mod server { lazy_static::lazy_static! { static ref EXIT: Arc> = Default::default(); + static ref FORCE_EXIT_ARMED: AtomicBool = AtomicBool::new(false); } pub fn run_portable_service() { - let shmem = match SharedMemory::open_existing(SHMEM_NAME) { + let shmem_name = match portable_service_shmem_name_from_args() { + Some(name) => name, + None => { + if has_portable_service_shmem_arg() { + log::error!( + "Invalid portable service shared memory argument, aborting startup" + ); + } else { + log::error!( + "Missing portable service shared memory argument, aborting startup" + ); + } + return; + } + }; + let shmem = match SharedMemory::open_existing(&shmem_name) { Ok(shmem) => Arc::new(shmem), Err(e) => { log::error!("Failed to open existing shared memory: {:?}", e); return; } }; + if let Err(e) = validate_runtime_shmem_layout(shmem.as_ref()) { + log::error!("{}", e); + return; + } + let ipc_token = match read_ipc_token_from_shmem(shmem.as_ref()) { + Some(token) => token, + None => { + log::error!( + "Missing portable service ipc token in shared memory, aborting startup" + ); + return; + } + }; let shmem1 = shmem.clone(); let shmem2 = shmem.clone(); let mut threads = vec![]; @@ -251,17 +491,24 @@ pub mod server { threads.push(std::thread::spawn(|| { run_capture(shmem2); })); - threads.push(std::thread::spawn(|| { - run_ipc_client(); + threads.push(std::thread::spawn(move || { + run_ipc_client(ipc_token); })); - threads.push(std::thread::spawn(|| { + // Detached shutdown watchdog: + // - gives graceful shutdown/cleanup a short window + // - force-exits the process if workers are still stuck + std::thread::spawn(|| { run_exit_check(); - })); + }); let record_pos_handle = crate::input_service::try_start_record_cursor_pos(); + // Arm forced-exit watchdog only for worker join phase. + // Once join phase completes, cleanup should not be interrupted by forced exit. + FORCE_EXIT_ARMED.store(true, Ordering::SeqCst); for th in threads.drain(..) { th.join().ok(); log::info!("thread joined"); } + FORCE_EXIT_ARMED.store(false, Ordering::SeqCst); crate::input_service::try_stop_record_cursor_pos(); if let Some(handle) = record_pos_handle { @@ -270,16 +517,47 @@ pub mod server { Err(e) => log::error!("record_pos_handle join error {:?}", &e), } } + drop(shmem); + remove_shared_memory_flink_with_retry(&shmem_name); } fn run_exit_check() { + const FORCED_EXIT_DELAY: Duration = Duration::from_secs(3); loop { if EXIT.lock().unwrap().clone() { - std::thread::sleep(Duration::from_millis(50)); - std::process::exit(0); + break; } std::thread::sleep(Duration::from_millis(50)); } + // Fallback only: normal shutdown path should complete and process should exit naturally. + // This forced exit is a last resort when worker threads are stuck and graceful teardown + // does not finish in time. + std::thread::sleep(FORCED_EXIT_DELAY); + if FORCE_EXIT_ARMED.load(Ordering::SeqCst) { + log::warn!( + "Portable service shutdown watchdog fallback triggered: forcing process exit after {:?}", + FORCED_EXIT_DELAY + ); + std::process::exit(0); + } + } + + fn remove_shared_memory_flink_with_retry(name: &str) { + const MAX_RETRY: usize = 20; + const RETRY_INTERVAL: Duration = Duration::from_millis(200); + for attempt in 0..MAX_RETRY { + let is_last_attempt = attempt + 1 == MAX_RETRY; + if remove_shared_memory_flink_once(name, is_last_attempt, "SYSTEM cleanup") { + return; + } + if !is_last_attempt { + std::thread::sleep(RETRY_INTERVAL); + } + } + log::warn!( + "SYSTEM cleanup failed to remove portable service shared-memory flink artifact '{}' after retry", + name + ); } fn run_get_cursor_info(shmem: Arc) { @@ -386,6 +664,17 @@ pub mod server { match c.as_mut().map(|f| f.frame(spf)) { Some(Ok(f)) => match f { Frame::PixelBuffer(f) => { + let frame_capacity = shmem.len().saturating_sub(ADDR_CAPTURE_FRAME); + if f.data().len() > frame_capacity { + log::error!( + "Portable service capture frame exceeds shared memory capacity: frame_len={}, capacity={}, shmem_len={}", + f.data().len(), + frame_capacity, + shmem.len() + ); + *EXIT.lock().unwrap() = true; + return; + } utils::set_frame_info( &shmem, FrameInfo { @@ -436,17 +725,33 @@ pub mod server { } #[tokio::main(flavor = "current_thread")] - async fn run_ipc_client() { + async fn run_ipc_client(ipc_token: String) { use DataPortableService::*; let postfix = IPC_SUFFIX; match ipc::connect(1000, postfix).await { Ok(mut stream) => { + if let Err(err) = + ipc::portable_service_ipc_handshake_as_client(&mut stream, &ipc_token).await + { + log::error!("portable service ipc handshake failed: {}", err); + *EXIT.lock().unwrap() = true; + return; + } let mut timer = crate::rustdesk_interval(tokio::time::interval(Duration::from_secs(1))); let mut nack = 0; loop { + if *EXIT.lock().unwrap() { + log::info!("Portable service EXIT signaled, closing ipc client loop"); + stream + .send(&Data::DataPortableService(WillClose)) + .await + .ok(); + break; + } + tokio::select! { res = stream.next() => { match res { @@ -476,9 +781,9 @@ pub mod server { break; } } - Mouse((v, conn)) => { + Mouse((v, conn, username, argb, simulate, show_cursor)) => { if let Ok(evt) = MouseEvent::parse_from_bytes(&v) { - crate::input_service::handle_mouse_(&evt, conn); + crate::input_service::handle_mouse_(&evt, conn, username, argb, simulate, show_cursor); } } Pointer((v, conn)) => { @@ -526,7 +831,11 @@ pub mod client { lazy_static::lazy_static! { static ref RUNNING: Arc> = Default::default(); + static ref STARTING: Arc> = Default::default(); + static ref STARTING_TOKEN: AtomicU64 = AtomicU64::new(0); static ref SHMEM: Arc>> = Default::default(); + static ref SHMEM_RUNTIME_NAME: Arc>> = Default::default(); + static ref IPC_RUNTIME_TOKEN: Arc>> = Default::default(); static ref SENDER : Mutex> = Mutex::new(client::start_ipc_server()); static ref QUICK_SUPPORT: Arc> = Default::default(); } @@ -536,12 +845,176 @@ pub mod client { Logon(String, String), } + fn has_running_portable_service_process() -> bool { + let app_exe = format!("{}.exe", crate::get_app_name().to_lowercase()); + !crate::platform::get_pids_of_process_with_first_arg(&app_exe, "--portable-service") + .is_empty() + } + + #[inline] + fn next_portable_service_shmem_name() -> String { + format!( + "{}_{}_{:08x}", + crate::portable_service::SHMEM_NAME, + std::process::id(), + hbb_common::rand::random::() + ) + } + + #[inline] + fn set_runtime_ipc_token(token: String) { + *IPC_RUNTIME_TOKEN.lock().unwrap() = Some(token); + } + + #[inline] + fn schedule_remove_runtime_shmem_flink_retry(name: String) { + std::thread::spawn(move || { + const MAX_RETRY: usize = 20; + const RETRY_INTERVAL: Duration = Duration::from_millis(200); + for _ in 0..MAX_RETRY { + std::thread::sleep(RETRY_INTERVAL); + if remove_shared_memory_flink_once(&name, false, "Client cleanup") { + return; + } + } + log::warn!( + "Failed to remove portable service shared-memory flink artifact '{}' after retry", + name + ); + }); + } + + #[inline] + fn clear_runtime_shmem_state() { + let mut runtime_token = IPC_RUNTIME_TOKEN.lock().unwrap(); + let mut shmem_lock = SHMEM.lock().unwrap(); + if let Some(shmem) = shmem_lock.as_mut() { + clear_ipc_token_in_shmem(shmem); + } + *shmem_lock = None; + let runtime_name = SHMEM_RUNTIME_NAME.lock().unwrap().take(); + *runtime_token = None; + drop(runtime_token); + drop(shmem_lock); + if let Some(name) = runtime_name.as_deref() { + if !remove_shared_memory_flink_once(name, true, "Client cleanup") { + schedule_remove_runtime_shmem_flink_retry(name.to_owned()); + } + } + } + + #[inline] + fn consume_runtime_ipc_token_if_match(candidate: &str) -> (bool, Option) { + let mut token = IPC_RUNTIME_TOKEN.lock().unwrap(); + if !token + .as_deref() + .is_some_and(|expected| ipc::constant_time_ipc_token_eq(expected, candidate)) + { + return (false, None); + } + let mut shmem_lock = SHMEM.lock().unwrap(); + let matched_shmem_name = SHMEM_RUNTIME_NAME.lock().unwrap().clone(); + *token = None; + if let Some(shmem) = shmem_lock.as_mut() { + clear_ipc_token_in_shmem(shmem); + } + (true, matched_shmem_name) + } + + #[inline] + fn restore_runtime_ipc_token_after_failed_handshake( + token: &str, + expected_shmem_name: Option<&str>, + ) { + let mut runtime_token = IPC_RUNTIME_TOKEN.lock().unwrap(); + if let Some(current) = runtime_token.as_deref() { + if current != token { + log::debug!( + "Skip restoring portable service ipc token after handshake failure: runtime token has changed to a newer value" + ); + return; + } + } + let mut shmem_lock = SHMEM.lock().unwrap(); + let current_shmem_name = SHMEM_RUNTIME_NAME.lock().unwrap().clone(); + if current_shmem_name.as_deref() != expected_shmem_name { + if runtime_token.as_deref() == Some(token) { + *runtime_token = None; + } + log::debug!( + "Skip restoring portable service ipc token after handshake failure: shared-memory instance has changed" + ); + return; + } + let shmem_write_error = if let Some(shmem) = shmem_lock.as_mut() { + write_ipc_token_to_shmem(shmem, token) + .err() + .map(|err| err.to_string()) + } else { + Some("shared memory unavailable".to_owned()) + }; + if let Some(err) = shmem_write_error { + if runtime_token.as_deref() == Some(token) { + *runtime_token = None; + } + log::warn!( + "Failed to restore portable service ipc token after handshake failure: {}", + err + ); + return; + } + *runtime_token = Some(token.to_owned()); + } + + #[inline] + fn schedule_starting_timeout_reset(launch_token: u64) { + std::thread::spawn(move || { + std::thread::sleep(PORTABLE_SERVICE_STARTUP_TIMEOUT); + let should_reset = { + // Guard against stale watchdogs from previous launches: + // only the watchdog that matches the latest STARTING_TOKEN may reset STARTING. + let current_token = STARTING_TOKEN.load(Ordering::SeqCst); + // Keep lock guards in explicit short scopes to make it obvious + // there is no nested lock ordering (and to avoid Copilot false positives). + let starting = { *STARTING.lock().unwrap() }; + let running = { *RUNNING.lock().unwrap() }; + current_token == launch_token && starting && !running + }; + if should_reset { + log::warn!( + "Portable service startup timeout before IPC ready, reset STARTING state" + ); + *STARTING.lock().unwrap() = false; + } + }); + } + + // Launch flow summary: + // 1) Prepare/reset runtime shared memory + IPC token. + // 2) Start helper process (direct or logon) with shmem argument. + // 3) Keep STARTING=true until IPC ping/pong marks RUNNING, or timeout watchdog resets it. pub(crate) fn start_portable_service(para: StartPara) -> ResultType<()> { log::info!("start portable service"); - if RUNNING.lock().unwrap().clone() { - bail!("already running"); - } - if SHMEM.lock().unwrap().is_none() { + let launch_token = { + // Keep lock guards in explicit short scopes to make it obvious + // there is no nested lock ordering (and to avoid Copilot false positives). + let running = { *RUNNING.lock().unwrap() }; + let mut starting = STARTING.lock().unwrap(); + if *starting && !running && !has_running_portable_service_process() { + log::warn!( + "Detected stale portable service STARTING state without running process, reset it" + ); + *starting = false; + } + if *starting || running { + bail!("already running"); + } + *starting = true; + STARTING_TOKEN.fetch_add(1, Ordering::SeqCst) + 1 + }; + let start_result = (|| -> ResultType<()> { + clear_runtime_shmem_state(); + let mut shmem_lock = SHMEM.lock().unwrap(); let displays = scrap::Display::all()?; if displays.is_empty() { bail!("no display available!"); @@ -558,84 +1031,153 @@ pub mod client { } } } - let shmem_size = utils::align(ADDR_CAPTURE_FRAME + max_pixel * 4, align); + let shmem_size = + utils::align(ADDR_CAPTURE_FRAME + max_pixel * 4, align).max(MIN_RUNTIME_SHMEM_LEN); + let shmem_name = next_portable_service_shmem_name(); + if !is_valid_portable_service_shmem_name(&shmem_name) { + bail!("Generated invalid portable service shared memory name"); + } + let ipc_token = ipc::generate_one_time_ipc_token()?; // os error 112, no enough space - *SHMEM.lock().unwrap() = Some(crate::portable_service::SharedMemory::create( - crate::portable_service::SHMEM_NAME, + *shmem_lock = Some(crate::portable_service::SharedMemory::create( + &shmem_name, shmem_size, )?); + *SHMEM_RUNTIME_NAME.lock().unwrap() = Some(shmem_name); shutdown_hooks::add_shutdown_hook(drop_portable_service_shared_memory); - } - if let Some(shmem) = SHMEM.lock().unwrap().as_mut() { - unsafe { - libc::memset(shmem.as_ptr() as _, 0, shmem.len() as _); - } - } - match para { - StartPara::Direct => { - if let Err(e) = crate::platform::run_background( - &std::env::current_exe()?.to_string_lossy().to_string(), - "--portable-service", - ) { - *SHMEM.lock().unwrap() = None; - bail!("Failed to run portable service process: {}", e); + let shmem_name = SHMEM_RUNTIME_NAME + .lock() + .unwrap() + .clone() + .ok_or_else(|| anyhow!("portable service shared memory name is unavailable"))?; + let init_token_result = if let Some(shmem) = shmem_lock.as_mut() { + unsafe { + libc::memset(shmem.as_ptr() as _, 0, shmem.len() as _); } + write_ipc_token_to_shmem(shmem, &ipc_token) + } else { + Ok(()) + }; + if let Err(e) = init_token_result { + drop(shmem_lock); + clear_runtime_shmem_state(); + bail!( + "Failed to initialize portable service ipc token in shared memory: {}", + e + ); + }; + drop(shmem_lock); + set_runtime_ipc_token(ipc_token.clone()); + let portable_service_arg = format!( + "--portable-service {}", + crate::portable_service::portable_service_shmem_arg(&shmem_name) + ); + { + let _sender = SENDER.lock().unwrap(); } - StartPara::Logon(username, password) => { - #[allow(unused_mut)] - let mut exe = std::env::current_exe()?.to_string_lossy().to_string(); - #[cfg(feature = "flutter")] - { - if let Some(dir) = PathBuf::from(&exe).parent() { - if set_path_permission(&PathBuf::from(dir), "RX").is_err() { - *SHMEM.lock().unwrap() = None; - bail!("Failed to set permission of {:?}", dir); + match para { + StartPara::Direct => { + match crate::platform::run_background( + &std::env::current_exe()?.to_string_lossy().to_string(), + &portable_service_arg, + ) { + Ok(true) => {} + Ok(false) => { + clear_runtime_shmem_state(); + bail!("Failed to run portable service process"); + } + Err(e) => { + clear_runtime_shmem_state(); + bail!("Failed to run portable service process: {}", e); } } } - #[cfg(not(feature = "flutter"))] - match hbb_common::directories_next::UserDirs::new() { - Some(user_dir) => { - let dir = user_dir - .home_dir() - .join("AppData") - .join("Local") - .join("rustdesk-sciter"); - if std::fs::create_dir_all(&dir).is_ok() { - let dst = dir.join("rustdesk.exe"); - if std::fs::copy(&exe, &dst).is_ok() { - if dst.exists() { - if set_path_permission(&dir, "RX").is_ok() { - exe = dst.to_string_lossy().to_string(); - } - } + StartPara::Logon(username, password) => { + #[allow(unused_mut)] + let mut exe = std::env::current_exe()?.to_string_lossy().to_string(); + #[cfg(feature = "flutter")] + { + if let Some(dir) = Path::new(&exe).parent() { + if let Err(err) = set_path_permission( + Path::new(dir), + FILE_GENERIC_READ.0 | FILE_GENERIC_EXECUTE.0, + ) { + clear_runtime_shmem_state(); + bail!("Failed to set permission of {:?}: {}", dir, err); } } } - None => {} - } - if let Err(e) = crate::platform::windows::create_process_with_logon( - username.as_str(), - password.as_str(), - &exe, - "--portable-service", - ) { - *SHMEM.lock().unwrap() = None; - bail!("Failed to run portable service process: {}", e); + #[cfg(not(feature = "flutter"))] + if let Some((dir, dst)) = + crate::platform::windows::portable_service_logon_helper_paths() + { + let cleanup_helper_artifacts = || { + if Path::new(&exe) != dst { + std::fs::remove_file(&dst).ok(); + } + std::fs::remove_dir(&dir).ok(); + }; + let mut use_logon_helper_exe = false; + if let Err(err) = std::fs::create_dir_all(&dir) { + log::warn!( + "Failed to create portable service logon helper dir {:?}: {}", + dir, + err + ); + } else if let Err(err) = std::fs::copy(&exe, &dst) { + log::warn!( + "Failed to copy portable service logon helper binary from '{}' to {:?}: {}", + exe, + dst, + err + ); + cleanup_helper_artifacts(); + } else if !dst.exists() { + log::warn!( + "Portable service logon helper binary missing after copy: {:?}", + dst + ); + cleanup_helper_artifacts(); + } else if let Err(err) = + set_path_permission(&dir, FILE_GENERIC_READ.0 | FILE_GENERIC_EXECUTE.0) + { + log::warn!( + "Failed to set portable service logon helper path permission for {:?}: {}", + dir, + err + ); + cleanup_helper_artifacts(); + } else { + use_logon_helper_exe = true; + } + if use_logon_helper_exe { + exe = dst.to_string_lossy().to_string(); + } + } + if let Err(e) = crate::platform::windows::create_process_with_logon( + username.as_str(), + password.as_str(), + &exe, + &portable_service_arg, + ) { + clear_runtime_shmem_state(); + bail!("Failed to run portable service process: {}", e); + } } } + schedule_starting_timeout_reset(launch_token); + Ok(()) + })(); + if start_result.is_err() { + *STARTING.lock().unwrap() = false; } - let _sender = SENDER.lock().unwrap(); - Ok(()) + start_result } pub extern "C" fn drop_portable_service_shared_memory() { // 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 - let mut lock = SHMEM.lock().unwrap(); - if lock.is_some() { - *lock = None; - } + clear_runtime_shmem_state(); } pub fn set_quick_support(v: bool) { @@ -655,7 +1197,11 @@ pub mod client { let mut option = SHMEM.lock().unwrap(); if let Some(shmem) = option.as_mut() { unsafe { - libc::memset(shmem.as_ptr() as _, 0, shmem.len() as _); + libc::memset( + shmem.as_ptr().add(ADDR_CURSOR_PARA) as _, + 0, + shmem.len().saturating_sub(ADDR_CURSOR_PARA) as _, + ); } utils::set_para( shmem, @@ -702,6 +1248,19 @@ pub mod client { if utils::counter_ready(base.add(ADDR_CAPTURE_FRAME_COUNTER)) { let frame_info_ptr = shmem.as_ptr().add(ADDR_CAPTURE_FRAME_INFO); let frame_info = frame_info_ptr as *const FrameInfo; + let frame_len = (*frame_info).length; + if !is_valid_capture_frame_length(shmem.len(), frame_len) { + log::error!( + "Portable service frame length exceeds shared memory capacity: frame_len={}, shmem_len={}, frame_addr={}", + frame_len, + shmem.len(), + ADDR_CAPTURE_FRAME + ); + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "invalid portable service frame length".to_string(), + )); + } if (*frame_info).width != self.width || (*frame_info).height != self.height { log::info!( "skip frame, ({},{}) != ({},{})", @@ -716,8 +1275,8 @@ pub mod client { )); } let frame_ptr = base.add(ADDR_CAPTURE_FRAME); - let data = slice::from_raw_parts(frame_ptr, (*frame_info).length); - Ok(Frame::PixelBuffer(PixelBuffer::new( + let data = slice::from_raw_parts(frame_ptr, frame_len); + Ok(Frame::PixelBuffer(PixelBuffer::with_BGRA( data, self.width, self.height, @@ -778,10 +1337,49 @@ pub mod client { Some(result) = incoming.next() => { match result { Ok(stream) => { + let mut stream = Connection::new(stream); + if !ipc::authorize_windows_portable_service_ipc_connection( + &stream, postfix, + ) { + continue; + } + let mut consumed_token: Option = None; + let mut consumed_token_shmem_name: Option = None; + let handshake_result = + ipc::portable_service_ipc_handshake_as_server( + &mut stream, + |token| { + let (matched, matched_shmem_name) = + consume_runtime_ipc_token_if_match(token); + if matched { + consumed_token = Some(token.to_owned()); + consumed_token_shmem_name = matched_shmem_name; + true + } else { + false + } + }, + ) + .await; + if let Err(err) = handshake_result { + if let Some(token) = consumed_token.as_deref() { + restore_runtime_ipc_token_after_failed_handshake( + token, + consumed_token_shmem_name.as_deref(), + ); + *STARTING.lock().unwrap() = false; + } + log::warn!( + "Rejected portable service ipc connection due to token handshake failure: postfix={}, err={}", + postfix, + err + ); + continue; + } log::info!("Got portable service ipc connection"); let rx_clone = rx.clone(); tokio::spawn(async move { - let mut stream = Connection::new(stream); + let mut stream = stream; let postfix = postfix.to_owned(); let mut timer = crate::rustdesk_interval(tokio::time::interval(Duration::from_secs(1))); let mut nack = 0; @@ -805,11 +1403,17 @@ pub mod client { Pong => { nack = 0; *RUNNING.lock().unwrap() = true; + *STARTING.lock().unwrap() = false; }, ConnCount(None) => { if !quick_support { - let cnt = crate::server::CONN_COUNT.lock().unwrap().clone(); - stream.send(&Data::DataPortableService(ConnCount(Some(cnt)))).await.ok(); + let remote_count = crate::server::AUTHED_CONNS + .lock() + .unwrap() + .iter() + .filter(|c| c.conn_type == crate::server::AuthConnType::Remote) + .count(); + stream.send(&Data::DataPortableService(ConnCount(Some(remote_count)))).await.ok(); } }, WillClose => { @@ -836,6 +1440,7 @@ pub mod client { } } *RUNNING.lock().unwrap() = false; + *STARTING.lock().unwrap() = false; }); } Err(err) => { @@ -870,11 +1475,23 @@ pub mod client { } } - fn handle_mouse_(evt: &MouseEvent, conn: i32) -> ResultType<()> { + fn handle_mouse_( + evt: &MouseEvent, + conn: i32, + username: String, + argb: u32, + simulate: bool, + show_cursor: bool, + ) -> ResultType<()> { let mut v = vec![]; evt.write_to_vec(&mut v)?; ipc_send(Data::DataPortableService(DataPortableService::Mouse(( - v, conn, + v, + conn, + username, + argb, + simulate, + show_cursor, )))) } @@ -922,12 +1539,19 @@ pub mod client { } } - pub fn handle_mouse(evt: &MouseEvent, conn: i32) { + pub fn handle_mouse( + evt: &MouseEvent, + conn: i32, + username: String, + argb: u32, + simulate: bool, + show_cursor: bool, + ) { if RUNNING.lock().unwrap().clone() { crate::input_service::update_latest_input_cursor_time(conn); - handle_mouse_(evt, conn).ok(); + handle_mouse_(evt, conn, username, argb, simulate, show_cursor).ok(); } else { - crate::input_service::handle_mouse_(evt, conn); + crate::input_service::handle_mouse_(evt, conn, username, argb, simulate, show_cursor); } } @@ -966,3 +1590,23 @@ pub struct FrameInfo { width: usize, height: usize, } + +#[cfg(test)] +mod tests { + use super::{is_valid_capture_frame_length, ADDR_CAPTURE_FRAME}; + + #[test] + fn test_is_valid_capture_frame_length_rejects_zero_length() { + assert!(!is_valid_capture_frame_length(ADDR_CAPTURE_FRAME + 1024, 0)); + } + + #[test] + fn test_is_valid_capture_frame_length_rejects_out_of_bounds_length() { + assert!(!is_valid_capture_frame_length(ADDR_CAPTURE_FRAME + 16, 17)); + } + + #[test] + fn test_is_valid_capture_frame_length_accepts_in_bounds_length() { + assert!(is_valid_capture_frame_length(ADDR_CAPTURE_FRAME + 16, 16)); + } +} diff --git a/src/server/printer_service.rs b/src/server/printer_service.rs new file mode 100644 index 000000000..edf5f3c1d --- /dev/null +++ b/src/server/printer_service.rs @@ -0,0 +1,163 @@ +use super::service::{EmptyExtraFieldService, GenericService, Service}; +use hbb_common::{bail, dlopen::symbor::Library, log, ResultType}; +use std::{ + sync::{Arc, Mutex}, + thread, + time::Duration, +}; + +pub const NAME: &'static str = "remote-printer"; + +const LIB_NAME_PRINTER_DRIVER_ADAPTER: &str = "printer_driver_adapter"; + +// Return 0 if success, otherwise return error code. +pub type Init = fn(tag_name: *const i8) -> i32; +pub type Uninit = fn(); +// dur_mills: Get the file generated in the last `dur_mills` milliseconds. +// data: The raw prn data, xps format. +// data_len: The length of the raw prn data. +pub type GetPrnData = fn(dur_mills: u32, data: *mut *mut i8, data_len: *mut u32); +// Free the prn data allocated by GetPrnData(). +pub type FreePrnData = fn(data: *mut i8); + +macro_rules! make_lib_wrapper { + ($($field:ident : $tp:ty),+) => { + struct LibWrapper { + _lib: Option, + $($field: Option<$tp>),+ + } + + impl LibWrapper { + fn new() -> Self { + let lib_name = match get_lib_name() { + Ok(name) => name, + Err(e) => { + log::warn!("Failed to get lib name, {}", e); + return Self { + _lib: None, + $( $field: None ),+ + }; + } + }; + let lib = match Library::open(&lib_name) { + Ok(lib) => Some(lib), + Err(e) => { + log::warn!("Failed to load library {}, {}", &lib_name, e); + None + } + }; + + $(let $field = if let Some(lib) = &lib { + match unsafe { lib.symbol::<$tp>(stringify!($field)) } { + Ok(m) => { + Some(*m) + }, + Err(e) => { + log::warn!("Failed to load func {}, {}", stringify!($field), e); + None + } + } + } else { + None + };)+ + + Self { + _lib: lib, + $( $field ),+ + } + } + } + + impl Default for LibWrapper { + fn default() -> Self { + Self::new() + } + } + } +} + +make_lib_wrapper!( + init: Init, + uninit: Uninit, + get_prn_data: GetPrnData, + free_prn_data: FreePrnData +); + +lazy_static::lazy_static! { + static ref LIB_WRAPPER: Arc> = Default::default(); +} + +fn get_lib_name() -> ResultType { + let exe_file = std::env::current_exe()?; + if let Some(cur_dir) = exe_file.parent() { + let dll_name = format!("{}.dll", LIB_NAME_PRINTER_DRIVER_ADAPTER); + let full_path = cur_dir.join(dll_name); + if !full_path.exists() { + bail!("{} not found", full_path.to_string_lossy().as_ref()); + } else { + Ok(full_path.to_string_lossy().into_owned()) + } + } else { + bail!( + "Invalid exe parent for {}", + exe_file.to_string_lossy().as_ref() + ); + } +} + +pub fn init(app_name: &str) -> ResultType<()> { + let lib_wrapper = LIB_WRAPPER.lock().unwrap(); + let Some(fn_init) = lib_wrapper.init.as_ref() else { + bail!("Failed to load func init"); + }; + + let tag_name = std::ffi::CString::new(app_name)?; + let ret = fn_init(tag_name.as_ptr()); + if ret != 0 { + bail!("Failed to init printer driver"); + } + Ok(()) +} + +pub fn uninit() { + let lib_wrapper = LIB_WRAPPER.lock().unwrap(); + if let Some(fn_uninit) = lib_wrapper.uninit.as_ref() { + fn_uninit(); + } +} + +fn get_prn_data(dur_mills: u32) -> ResultType> { + let lib_wrapper = LIB_WRAPPER.lock().unwrap(); + if let Some(fn_get_prn_data) = lib_wrapper.get_prn_data.as_ref() { + let mut data = std::ptr::null_mut(); + let mut data_len = 0u32; + fn_get_prn_data(dur_mills, &mut data, &mut data_len); + if data.is_null() || data_len == 0 { + return Ok(Vec::new()); + } + let bytes = + Vec::from(unsafe { std::slice::from_raw_parts(data as *const u8, data_len as usize) }); + lib_wrapper.free_prn_data.map(|f| f(data)); + Ok(bytes) + } else { + bail!("Failed to load func get_prn_file"); + } +} + +pub fn new(name: String) -> GenericService { + let svc = EmptyExtraFieldService::new(name, false); + GenericService::run(&svc.clone(), run); + svc.sp +} + +fn run(sp: EmptyExtraFieldService) -> ResultType<()> { + while sp.ok() { + let bytes = get_prn_data(1000)?; + if !bytes.is_empty() { + log::info!("Got prn data, data len: {}", bytes.len()); + crate::server::on_printer_data(bytes); + } + thread::sleep(Duration::from_millis(300)); + } + Ok(()) +} diff --git a/src/server/rdp_input.rs b/src/server/rdp_input.rs index 910a19276..5348f2f24 100644 --- a/src/server/rdp_input.rs +++ b/src/server/rdp_input.rs @@ -1,7 +1,8 @@ -use crate::uinput::service::map_key; +use super::input_service::set_clipboard_for_paste_sync; +use crate::uinput::service::{can_input_via_keysym, char_to_keysym, map_key}; use dbus::{blocking::SyncConnection, Path}; use enigo::{Key, KeyboardControllable, MouseButton, MouseControllable}; -use hbb_common::ResultType; +use hbb_common::{log, ResultType}; use scrap::wayland::pipewire::{get_portal, PwStreamInfo}; use scrap::wayland::remote_desktop_portal::OrgFreedesktopPortalRemoteDesktop as remote_desktop_portal; use std::collections::HashMap; @@ -19,14 +20,74 @@ pub mod client { const PRESSED_DOWN_STATE: u32 = 1; const PRESSED_UP_STATE: u32 = 0; + /// Modifier key state tracking for RDP input. + /// Portal API doesn't provide a way to query key state, so we track it ourselves. + #[derive(Default)] + struct ModifierState { + shift_left: bool, + shift_right: bool, + ctrl_left: bool, + ctrl_right: bool, + alt_left: bool, + alt_right: bool, + meta_left: bool, + meta_right: bool, + } + + impl ModifierState { + fn update(&mut self, key: &Key, down: bool) { + match key { + Key::Shift => self.shift_left = down, + Key::RightShift => self.shift_right = down, + Key::Control => self.ctrl_left = down, + Key::RightControl => self.ctrl_right = down, + Key::Alt => self.alt_left = down, + Key::RightAlt => self.alt_right = down, + Key::Meta | Key::Super | Key::Windows | Key::Command => self.meta_left = down, + Key::RWin => self.meta_right = down, + // Handle raw keycodes for modifier keys (Linux evdev codes + 8) + // In translate mode, modifier keys may be sent as Chr events with raw keycodes. + // The +8 offset converts evdev codes to X11/XKB keycodes. + Key::Raw(code) => { + const EVDEV_OFFSET: u16 = 8; + const KEY_LEFTSHIFT: u16 = evdev::Key::KEY_LEFTSHIFT.code() + EVDEV_OFFSET; + const KEY_RIGHTSHIFT: u16 = evdev::Key::KEY_RIGHTSHIFT.code() + EVDEV_OFFSET; + const KEY_LEFTCTRL: u16 = evdev::Key::KEY_LEFTCTRL.code() + EVDEV_OFFSET; + const KEY_RIGHTCTRL: u16 = evdev::Key::KEY_RIGHTCTRL.code() + EVDEV_OFFSET; + const KEY_LEFTALT: u16 = evdev::Key::KEY_LEFTALT.code() + EVDEV_OFFSET; + const KEY_RIGHTALT: u16 = evdev::Key::KEY_RIGHTALT.code() + EVDEV_OFFSET; + const KEY_LEFTMETA: u16 = evdev::Key::KEY_LEFTMETA.code() + EVDEV_OFFSET; + const KEY_RIGHTMETA: u16 = evdev::Key::KEY_RIGHTMETA.code() + EVDEV_OFFSET; + match *code { + KEY_LEFTSHIFT => self.shift_left = down, + KEY_RIGHTSHIFT => self.shift_right = down, + KEY_LEFTCTRL => self.ctrl_left = down, + KEY_RIGHTCTRL => self.ctrl_right = down, + KEY_LEFTALT => self.alt_left = down, + KEY_RIGHTALT => self.alt_right = down, + KEY_LEFTMETA => self.meta_left = down, + KEY_RIGHTMETA => self.meta_right = down, + _ => {} + } + } + _ => {} + } + } + } + pub struct RdpInputKeyboard { conn: Arc, session: Path<'static>, + modifier_state: ModifierState, } impl RdpInputKeyboard { pub fn new(conn: Arc, session: Path<'static>) -> ResultType { - Ok(Self { conn, session }) + Ok(Self { + conn, + session, + modifier_state: ModifierState::default(), + }) } } @@ -39,29 +100,192 @@ pub mod client { self } - fn get_key_state(&mut self, _: Key) -> bool { - // no api for this - false + fn get_key_state(&mut self, key: Key) -> bool { + // Use tracked modifier state for supported keys + match key { + Key::Shift => self.modifier_state.shift_left, + Key::RightShift => self.modifier_state.shift_right, + Key::Control => self.modifier_state.ctrl_left, + Key::RightControl => self.modifier_state.ctrl_right, + Key::Alt => self.modifier_state.alt_left, + Key::RightAlt => self.modifier_state.alt_right, + Key::Meta | Key::Super | Key::Windows | Key::Command => { + self.modifier_state.meta_left + } + Key::RWin => self.modifier_state.meta_right, + _ => false, + } } fn key_sequence(&mut self, s: &str) { for c in s.chars() { - let key = Key::Layout(c); - let _ = handle_key(true, key, self.conn.clone(), &self.session); - let _ = handle_key(false, key, self.conn.clone(), &self.session); + let keysym = char_to_keysym(c); + // ASCII characters: use keysym + if can_input_via_keysym(c, keysym) { + if let Err(e) = send_keysym(keysym, true, self.conn.clone(), &self.session) { + log::error!("Failed to send keysym down: {:?}", e); + } + if let Err(e) = send_keysym(keysym, false, self.conn.clone(), &self.session) { + log::error!("Failed to send keysym up: {:?}", e); + } + } else { + // Non-ASCII: use clipboard + input_text_via_clipboard(&c.to_string(), self.conn.clone(), &self.session); + } } } fn key_down(&mut self, key: Key) -> enigo::ResultType { - handle_key(true, key, self.conn.clone(), &self.session)?; + if let Key::Layout(chr) = key { + let keysym = char_to_keysym(chr); + // ASCII characters: use keysym + if can_input_via_keysym(chr, keysym) { + send_keysym(keysym, true, self.conn.clone(), &self.session)?; + } else { + // Non-ASCII: use clipboard (complete key press in key_down) + input_text_via_clipboard(&chr.to_string(), self.conn.clone(), &self.session); + } + } else { + handle_key(true, key.clone(), self.conn.clone(), &self.session)?; + // Update modifier state only after successful send — + // if handle_key fails, we don't want stale "pressed" state + // affecting subsequent key event decisions. + self.modifier_state.update(&key, true); + } Ok(()) } + fn key_up(&mut self, key: Key) { - let _ = handle_key(false, key, self.conn.clone(), &self.session); + // Intentionally asymmetric with key_down: update state BEFORE sending. + // On release, we always mark as released even if the send fails below, + // to avoid permanently stuck-modifier state in our tracker. The trade-off + // (tracker says "released" while OS may still have it pressed) is acceptable + // because such failures are rare and subsequent events will resynchronize. + self.modifier_state.update(&key, false); + + if let Key::Layout(chr) = key { + // ASCII characters: send keysym up if we also sent it on key_down + let keysym = char_to_keysym(chr); + if can_input_via_keysym(chr, keysym) { + if let Err(e) = send_keysym(keysym, false, self.conn.clone(), &self.session) + { + log::error!("Failed to send keysym up: {:?}", e); + } + } + // Non-ASCII: already handled completely in key_down via clipboard paste, + // no corresponding release needed (clipboard paste is an atomic operation) + } else { + if let Err(e) = handle_key(false, key, self.conn.clone(), &self.session) { + log::error!("Failed to handle key up: {:?}", e); + } + } } + fn key_click(&mut self, key: Key) { - let _ = handle_key(true, key, self.conn.clone(), &self.session); - let _ = handle_key(false, key, self.conn.clone(), &self.session); + if let Key::Layout(chr) = key { + let keysym = char_to_keysym(chr); + // ASCII characters: use keysym + if can_input_via_keysym(chr, keysym) { + if let Err(e) = send_keysym(keysym, true, self.conn.clone(), &self.session) { + log::error!("Failed to send keysym down: {:?}", e); + } + if let Err(e) = send_keysym(keysym, false, self.conn.clone(), &self.session) { + log::error!("Failed to send keysym up: {:?}", e); + } + } else { + // Non-ASCII: use clipboard + input_text_via_clipboard(&chr.to_string(), self.conn.clone(), &self.session); + } + } else { + if let Err(e) = handle_key(true, key.clone(), self.conn.clone(), &self.session) { + log::error!("Failed to handle key down: {:?}", e); + } else { + // Only mark modifier as pressed if key-down was actually delivered + self.modifier_state.update(&key, true); + } + // Always mark as released to avoid stuck-modifier state + self.modifier_state.update(&key, false); + if let Err(e) = handle_key(false, key, self.conn.clone(), &self.session) { + log::error!("Failed to handle key up: {:?}", e); + } + } + } + } + + /// Input text via clipboard + Shift+Insert. + /// Shift+Insert is more universal than Ctrl+V, works in both GUI apps and terminals. + /// + /// Note: Clipboard content is NOT restored after paste - see `set_clipboard_for_paste_sync` for rationale. + fn input_text_via_clipboard(text: &str, conn: Arc, session: &Path<'static>) { + if text.is_empty() { + return; + } + if !set_clipboard_for_paste_sync(text) { + return; + } + + let portal = get_portal(&conn); + let shift_keycode = evdev::Key::KEY_LEFTSHIFT.code() as i32; + let insert_keycode = evdev::Key::KEY_INSERT.code() as i32; + + // Send Shift+Insert (universal paste shortcut) + if let Err(e) = remote_desktop_portal::notify_keyboard_keycode( + &portal, + session, + HashMap::new(), + shift_keycode, + PRESSED_DOWN_STATE, + ) { + log::error!("input_text_via_clipboard: failed to press Shift: {:?}", e); + return; + } + + // Press Insert + if let Err(e) = remote_desktop_portal::notify_keyboard_keycode( + &portal, + session, + HashMap::new(), + insert_keycode, + PRESSED_DOWN_STATE, + ) { + log::error!("input_text_via_clipboard: failed to press Insert: {:?}", e); + // Still try to release Shift. + // Note: clipboard has already been set by set_clipboard_for_paste_sync but paste + // never happened. We don't attempt to restore the previous clipboard contents + // because reading the clipboard on Wayland requires focus/permission. + let _ = remote_desktop_portal::notify_keyboard_keycode( + &portal, + session, + HashMap::new(), + shift_keycode, + PRESSED_UP_STATE, + ); + return; + } + + // Release Insert + if let Err(e) = remote_desktop_portal::notify_keyboard_keycode( + &portal, + session, + HashMap::new(), + insert_keycode, + PRESSED_UP_STATE, + ) { + log::error!( + "input_text_via_clipboard: failed to release Insert: {:?}", + e + ); + } + + // Release Shift + if let Err(e) = remote_desktop_portal::notify_keyboard_keycode( + &portal, + session, + HashMap::new(), + shift_keycode, + PRESSED_UP_STATE, + ) { + log::error!("input_text_via_clipboard: failed to release Shift: {:?}", e); } } @@ -71,6 +295,7 @@ pub mod client { stream: PwStreamInfo, resolution: (usize, usize), scale: Option, + position: (f64, f64), } impl RdpInputMouse { @@ -83,7 +308,7 @@ pub mod client { // https://github.com/rustdesk/rustdesk/pull/9019#issuecomment-2295252388 // There may be a bug in Rdp input on Gnome util Ubuntu 24.04 (Gnome 46) // - // eg. Resultion 800x600, Fractional scale: 200% (logic size: 400x300) + // eg. Resolution 800x600, Fractional scale: 200% (logic size: 400x300) // https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.impl.portal.RemoteDesktop.html#:~:text=new%20pointer%20position-,in%20the%20streams%20logical%20coordinate%20space,-. // Then (x,y) in `mouse_move_to()` and `mouse_move_relative()` should be scaled to the logic size(stream.get_size()), which is from (0,0) to (400,300). // For Ubuntu 24.04(Gnome 46), (x,y) is restricted from (0,0) to (400,300), but the actual range in screen is: @@ -98,12 +323,14 @@ pub mod client { } else { None }; + let pos = stream.get_position(); Ok(Self { conn, session, stream, resolution, scale, + position: (pos.0 as f64, pos.1 as f64), }) } } @@ -128,6 +355,8 @@ pub mod client { } else { y as f64 }; + let x = x - self.position.0; + let y = y - self.position.1; let portal = get_portal(&self.conn); let _ = remote_desktop_portal::notify_pointer_motion_absolute( &portal, @@ -191,6 +420,39 @@ pub mod client { } } + /// Send a keysym via RemoteDesktop portal. + fn send_keysym( + keysym: i32, + down: bool, + conn: Arc, + session: &Path<'static>, + ) -> ResultType<()> { + let state: u32 = if down { + PRESSED_DOWN_STATE + } else { + PRESSED_UP_STATE + }; + let portal = get_portal(&conn); + log::trace!( + "send_keysym: calling notify_keyboard_keysym, keysym={:#x}, state={}", + keysym, + state + ); + match remote_desktop_portal::notify_keyboard_keysym( + &portal, + session, + HashMap::new(), + keysym, + state, + ) { + Ok(_) => { + log::trace!("send_keysym: notify_keyboard_keysym succeeded"); + Ok(()) + } + Err(e) => Err(e.into()), + } + } + fn get_raw_evdev_keycode(key: u16) -> i32 { // 8 is the offset between xkb and evdev let mut key = key as i32 - 8; @@ -226,22 +488,86 @@ pub mod client { } _ => { if let Ok((key, is_shift)) = map_key(&key) { - if is_shift { - remote_desktop_portal::notify_keyboard_keycode( + let shift_keycode = evdev::Key::KEY_LEFTSHIFT.code() as i32; + if down { + // Press: Shift down first, then key down + if is_shift { + if let Err(e) = remote_desktop_portal::notify_keyboard_keycode( + &portal, + &session, + HashMap::new(), + shift_keycode, + state, + ) { + log::error!("handle_key: failed to press Shift: {:?}", e); + return Err(e.into()); + } + } + if let Err(e) = remote_desktop_portal::notify_keyboard_keycode( &portal, &session, HashMap::new(), - evdev::Key::KEY_LEFTSHIFT.code() as i32, + key.code() as i32, state, - )?; + ) { + log::error!("handle_key: failed to press key: {:?}", e); + // Best-effort: release Shift if it was pressed + if is_shift { + if let Err(e) = remote_desktop_portal::notify_keyboard_keycode( + &portal, + &session, + HashMap::new(), + shift_keycode, + PRESSED_UP_STATE, + ) { + log::warn!( + "handle_key: best-effort Shift release also failed: {:?}", + e + ); + } + } + return Err(e.into()); + } + } else { + // Release: key up first, then Shift up + if let Err(e) = remote_desktop_portal::notify_keyboard_keycode( + &portal, + &session, + HashMap::new(), + key.code() as i32, + PRESSED_UP_STATE, + ) { + log::error!("handle_key: failed to release key: {:?}", e); + // Best-effort: still try to release Shift + if is_shift { + if let Err(e) = remote_desktop_portal::notify_keyboard_keycode( + &portal, + &session, + HashMap::new(), + shift_keycode, + PRESSED_UP_STATE, + ) { + log::warn!( + "handle_key: best-effort Shift release also failed: {:?}", + e + ); + } + } + return Err(e.into()); + } + if is_shift { + if let Err(e) = remote_desktop_portal::notify_keyboard_keycode( + &portal, + &session, + HashMap::new(), + shift_keycode, + PRESSED_UP_STATE, + ) { + log::error!("handle_key: failed to release Shift: {:?}", e); + return Err(e.into()); + } + } } - remote_desktop_portal::notify_keyboard_keycode( - &portal, - &session, - HashMap::new(), - key.code() as i32, - state, - )?; } } } diff --git a/src/server/terminal_helper.rs b/src/server/terminal_helper.rs new file mode 100644 index 000000000..fd85d2a4c --- /dev/null +++ b/src/server/terminal_helper.rs @@ -0,0 +1,1092 @@ +//! Terminal Helper Process +//! +//! This module implements a helper process that runs as the logged-in user and creates +//! the ConPTY + Shell. This is necessary because ConPTY has compatibility issues with +//! CreateProcessAsUserW when the ConPTY is created by a different user (SYSTEM service). +//! +//! Architecture: +//! ``` +//! SYSTEM Service (terminal_service.rs) +//! | +//! +-- CreateProcessAsUserW --> Terminal Helper (this module, runs as user) +//! | | +//! | +-- CreateProcessW + ConPTY --> Shell +//! | | +//! +-- Named Pipes <----------------+ +//! ``` +//! +//! This module also contains Windows-specific utility functions used by terminal_service.rs: +//! - Named pipe creation and connection +//! - User token and SID handling +//! - Helper process launching + +use hbb_common::{ + anyhow::{anyhow, Context, Result}, + log, +}; +use portable_pty::{CommandBuilder, MasterPty, PtySize}; +use std::{ + ffi::{c_void, OsStr}, + fs::File, + io::{Read, Write}, + os::windows::{ffi::OsStrExt, io::FromRawHandle, raw::HANDLE as RawHandle}, + ptr, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, + }, + thread, + time::Duration, +}; + +use windows::{ + core::{PCWSTR, PWSTR}, + Win32::{ + Foundation::{ + CloseHandle, LocalFree, ERROR_IO_PENDING, ERROR_PIPE_CONNECTED, HANDLE, HLOCAL, + INVALID_HANDLE_VALUE, WAIT_OBJECT_0, + }, + Security::{ + Authorization::{ + SetEntriesInAclW, EXPLICIT_ACCESS_W, SET_ACCESS, TRUSTEE_IS_SID, TRUSTEE_IS_USER, + TRUSTEE_W, + }, + CreateWellKnownSid, GetLengthSid, GetTokenInformation, InitializeSecurityDescriptor, + SetSecurityDescriptorDacl, TokenUser, WinLocalSystemSid, ACE_FLAGS, ACL, + PSECURITY_DESCRIPTOR, PSID, SECURITY_ATTRIBUTES, TOKEN_USER, + }, + Storage::FileSystem::{ + CreateFileW, FILE_ALL_ACCESS, FILE_FLAGS_AND_ATTRIBUTES, FILE_FLAG_OVERLAPPED, + FILE_GENERIC_READ, FILE_GENERIC_WRITE, FILE_SHARE_READ, FILE_SHARE_WRITE, + OPEN_EXISTING, + }, + System::{ + Environment::{CreateEnvironmentBlock, DestroyEnvironmentBlock}, + Pipes::{ + ConnectNamedPipe, CreateNamedPipeW, PIPE_READMODE_BYTE, PIPE_TYPE_BYTE, PIPE_WAIT, + }, + Threading::{ + CreateEventW, CreateProcessAsUserW, WaitForSingleObject, CREATE_NO_WINDOW, + CREATE_UNICODE_ENVIRONMENT, PROCESS_CREATION_FLAGS, PROCESS_INFORMATION, + STARTUPINFOW, + }, + IO::{GetOverlappedResult, OVERLAPPED}, + }, + }, +}; + +// Re-export types needed by terminal_service.rs +pub use windows::Win32::{ + Foundation::{ + CloseHandle as WinCloseHandle, HANDLE as WinHANDLE, WAIT_OBJECT_0 as WIN_WAIT_OBJECT_0, + }, + System::Threading::{ + GetExitCodeProcess as WinGetExitCodeProcess, TerminateProcess as WinTerminateProcess, + WaitForSingleObject as WinWaitForSingleObject, + }, +}; + +/// User token wrapper for cross-module use. +/// +/// Using newtype pattern for type safety. The inner value is `usize` to match +/// platform pointer size (32-bit on x86, 64-bit on x64). +/// Windows HANDLE is defined as `*mut c_void`, which has the same size as `usize`. +/// +/// # Design Note +/// This type is defined here (terminal_helper.rs) for Windows and in +/// terminal_service.rs for non-Windows platforms. This avoids circular +/// dependencies while keeping the API consistent across platforms. +/// Both definitions MUST have identical public API (new, as_raw methods). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct UserToken(pub usize); + +impl UserToken { + /// Create a new UserToken from a raw handle value. + pub fn new(handle: usize) -> Self { + Self(handle) + } + + /// Get the raw handle value. + pub fn as_raw(&self) -> usize { + self.0 + } +} + +// Windows pipe access mode constants (not exported by windows crate) +const PIPE_ACCESS_INBOUND: u32 = 0x00000001; +const PIPE_ACCESS_OUTBOUND: u32 = 0x00000002; + +// Named pipe configuration constants +const PIPE_BUFFER_SIZE: u32 = 65536; // 64KB for better throughput with large terminal output +const PIPE_DEFAULT_TIMEOUT_MS: u32 = 5000; +/// Timeout for waiting for helper process to connect to pipes +pub const PIPE_CONNECTION_TIMEOUT_MS: u32 = 10000; + +/// Message type constants for helper protocol. +/// Used to distinguish between terminal data and control commands. +/// Note: Using non-zero values to make debugging easier (0x00 could indicate uninitialized memory). +pub const MSG_TYPE_DATA: u8 = 0x01; +pub const MSG_TYPE_RESIZE: u8 = 0x02; + +/// Message header size: 1 byte type + 4 bytes length +pub const MSG_HEADER_SIZE: usize = 5; + +/// Maximum payload size to prevent denial of service from malicious messages. +/// 16MB should be more than enough for any legitimate terminal data. +const MAX_PAYLOAD_SIZE: usize = 16 * 1024 * 1024; + +/// Timeout in milliseconds to wait for helper process to exit gracefully before force termination. +/// Using 500ms to allow helper process enough time to clean up, especially under high system load. +pub const HELPER_GRACEFUL_EXIT_TIMEOUT_MS: u64 = 500; + +/// Information about a launched helper process. +/// Contains both the process handle and PID for tracking and status checks. +#[derive(Debug)] +pub struct HelperProcessInfo { + /// Process handle for termination and waiting + pub handle: HANDLE, + /// Process ID for logging and status display + pub pid: u32, +} + +/// Wrapper for Windows HANDLE that implements Send. +/// This is safe because Windows HANDLEs are valid across threads. +/// Note: We only implement Send, not Sync. The handle is protected by +/// Mutex in TerminalSession, so concurrent access is controlled there. +/// +/// # Ownership and Cleanup +/// This type intentionally does NOT implement Drop. The handle is owned by +/// `TerminalSession` and explicitly closed in `TerminalSession::close_internal()` +/// after graceful shutdown logic (waiting for helper to exit, force termination if needed). +/// Implementing Drop here would interfere with that cleanup sequence. +#[derive(Debug)] +pub struct SendableHandle(HANDLE); + +impl SendableHandle { + /// Create a new SendableHandle from a raw HANDLE. + pub fn new(handle: HANDLE) -> Self { + Self(handle) + } + + /// Get the raw HANDLE value. + pub fn as_raw(&self) -> HANDLE { + self.0 + } +} + +unsafe impl Send for SendableHandle {} + +/// RAII wrapper for Windows HANDLE that automatically closes the handle on drop. +/// This ensures proper resource cleanup even when errors occur or code paths diverge. +pub struct OwnedHandle(HANDLE); + +impl OwnedHandle { + /// Create a new OwnedHandle from a raw HANDLE. + /// The handle will be closed when this OwnedHandle is dropped. + pub fn new(handle: HANDLE) -> Self { + Self(handle) + } + + /// Consume the OwnedHandle and return the raw HANDLE without closing it. + /// Use this when transferring ownership to another resource (e.g., File). + pub fn into_raw(self) -> HANDLE { + let handle = self.0; + std::mem::forget(self); // Prevent Drop from closing the handle + handle + } + + /// Get the raw HANDLE value. + pub fn as_raw(&self) -> HANDLE { + self.0 + } +} + +impl Drop for OwnedHandle { + fn drop(&mut self) { + if self.0 != INVALID_HANDLE_VALUE && !self.0.is_invalid() { + unsafe { + let _ = CloseHandle(self.0); + } + } + } +} + +/// RAII guard for helper process that terminates the process on drop. +/// This prevents helper process leaks when pipe connection fails or other errors occur. +/// +/// Unlike OwnedHandle (which only closes the handle), this guard: +/// 1. Terminates the process using TerminateProcess +/// 2. Then closes the handle +/// +/// Use `disarm()` to prevent termination when the helper is successfully handed off +/// to the terminal session for proper lifecycle management. +pub struct HelperProcessGuard { + handle: HANDLE, + pid: u32, + armed: bool, +} + +impl HelperProcessGuard { + /// Create a new guard for a helper process. + pub fn new(handle: HANDLE, pid: u32) -> Self { + Self { + handle, + pid, + armed: true, + } + } + + /// Get the raw process HANDLE. + pub fn as_raw(&self) -> HANDLE { + self.handle + } + + /// Get the process ID. + pub fn pid(&self) -> u32 { + self.pid + } + + /// Disarm the guard and return the raw HANDLE. + /// After calling this, the guard will NOT terminate the process on drop. + /// Use this when successfully handing off the helper to session management. + pub fn disarm(self) -> HANDLE { + let handle = self.handle; + std::mem::forget(self); // Prevent Drop from running + handle + } +} + +impl Drop for HelperProcessGuard { + fn drop(&mut self) { + if self.armed && self.handle != INVALID_HANDLE_VALUE && !self.handle.is_invalid() { + log::warn!( + "HelperProcessGuard: terminating leaked helper process (PID {})", + self.pid + ); + unsafe { + // Terminate the process first + let _ = WinTerminateProcess(self.handle, 1); + // Then close the handle + let _ = CloseHandle(self.handle); + } + } + } +} + +/// Encode a message for the helper protocol. +/// Format: [type: u8][length: u32 LE][payload: bytes] +pub fn encode_helper_message(msg_type: u8, payload: &[u8]) -> Vec { + let mut msg = Vec::with_capacity(MSG_HEADER_SIZE + payload.len()); + msg.push(msg_type); + msg.extend_from_slice(&(payload.len() as u32).to_le_bytes()); + msg.extend_from_slice(payload); + msg +} + +/// Encode a resize message for the helper protocol. +/// Payload: rows (u16 LE) + cols (u16 LE) +pub fn encode_resize_message(rows: u16, cols: u16) -> Vec { + let mut payload = Vec::with_capacity(4); + payload.extend_from_slice(&rows.to_le_bytes()); + payload.extend_from_slice(&cols.to_le_bytes()); + encode_helper_message(MSG_TYPE_RESIZE, &payload) +} + +/// Get the default shell for Windows. +pub fn get_default_shell() -> String { + // Try PowerShell Core first (absolute paths only) + let pwsh_paths = [ + "pwsh.exe", + r"C:\Program Files\PowerShell\7\pwsh.exe", + r"C:\Program Files\PowerShell\6\pwsh.exe", + ]; + + for path in &pwsh_paths { + if std::path::Path::new(path).exists() { + log::debug!("Found PowerShell Core: {}", path); + return path.to_string(); + } + } + + // Try Windows PowerShell + let powershell_path = r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"; + if std::path::Path::new(powershell_path).exists() { + return powershell_path.to_string(); + } + + // Fallback to cmd.exe + std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_string()) +} + +fn utf8_shell_args(shell: &str) -> Vec { + let name = std::path::Path::new(shell) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(shell) + .to_ascii_lowercase(); + + if name == "cmd.exe" || name == "cmd" { + return vec!["/K".to_string(), "chcp 65001 >NUL".to_string()]; + } + + if name == "pwsh.exe" || name == "pwsh" || name == "powershell.exe" { + return vec![ + "-NoLogo".to_string(), + "-NoExit".to_string(), + "-Command".to_string(), + "chcp.com 65001 > $null; [Console]::InputEncoding = [System.Text.Encoding]::UTF8; [Console]::OutputEncoding = [System.Text.Encoding]::UTF8".to_string(), + ]; + } + + Vec::new() +} + +pub fn configure_utf8_shell_command(shell: &str, cmd: &mut CommandBuilder) { + for arg in utf8_shell_args(shell) { + cmd.arg(arg); + } +} + +/// Get the SID of the user from a token. +/// Returns a Vec containing the SID bytes. +pub fn get_user_sid_from_token(user_token: UserToken) -> Result> { + let token_handle = HANDLE(user_token.as_raw() as _); + + // First call to get required buffer size + let mut return_length = 0u32; + let _ = unsafe { GetTokenInformation(token_handle, TokenUser, None, 0, &mut return_length) }; + + if return_length == 0 { + return Err(anyhow!( + "Failed to get token information size: {}", + std::io::Error::last_os_error() + )); + } + + // Allocate buffer and get token information + let mut buffer = vec![0u8; return_length as usize]; + unsafe { + GetTokenInformation( + token_handle, + TokenUser, + Some(buffer.as_mut_ptr() as *mut c_void), + return_length, + &mut return_length, + ) + .map_err(|e| anyhow!("Failed to get token information: {}", e))?; + } + + // Extract SID from TOKEN_USER structure + let token_user = unsafe { &*(buffer.as_ptr() as *const TOKEN_USER) }; + let sid_ptr = token_user.User.Sid; + + // Get SID length and copy to owned buffer + let sid_length = unsafe { GetLengthSid(sid_ptr) }; + + if sid_length == 0 { + return Err(anyhow!("Invalid SID length")); + } + + let mut sid_buffer = vec![0u8; sid_length as usize]; + unsafe { + ptr::copy_nonoverlapping( + sid_ptr.0 as *const u8, + sid_buffer.as_mut_ptr(), + sid_length as usize, + ); + } + + Ok(sid_buffer) +} + +/// Create a restricted DACL that only allows SYSTEM and a specific user. +/// Returns a pointer to the ACL that must be freed with LocalFree. +/// +/// # Safety +/// +/// This function is safe to call, but contains internal unsafe code that relies on +/// pointer lifetime guarantees: +/// +/// - The `user_sid` slice must contain valid SID binary data. +/// - Internally, raw pointers to `system_sid_buffer` (stack-allocated) and `user_sid` +/// are stored in `TRUSTEE_W.ptstrName` fields. These pointers are only used during +/// the `SetEntriesInAclW` call, which occurs before either buffer goes out of scope. +/// - The returned ACL pointer is allocated by Windows and must be freed with `LocalFree`. +pub fn create_restricted_dacl(user_sid: &[u8]) -> Result<*mut c_void> { + // Create SYSTEM SID (well-known SID: S-1-5-18) + // SAFETY: This buffer must outlive the TRUSTEE_W structures that reference it + let mut system_sid_buffer = vec![0u8; 64]; // Max SID size + let mut system_sid_size = system_sid_buffer.len() as u32; + unsafe { + CreateWellKnownSid( + WinLocalSystemSid, + None, // No domain SID + Some(PSID(system_sid_buffer.as_mut_ptr() as *mut c_void)), + &mut system_sid_size, + ) + .map_err(|e| anyhow!("Failed to create SYSTEM SID: {}", e))?; + } + + // Build EXPLICIT_ACCESS entries for SYSTEM and user + // SAFETY: The ptstrName pointers below reference system_sid_buffer and user_sid. + // These buffers must remain valid until SetEntriesInAclW returns. + let mut explicit_access: [EXPLICIT_ACCESS_W; 2] = unsafe { std::mem::zeroed() }; + + // Entry 0: SYSTEM - full access + explicit_access[0].grfAccessPermissions = FILE_ALL_ACCESS.0; + explicit_access[0].grfAccessMode = SET_ACCESS; + explicit_access[0].grfInheritance = ACE_FLAGS(0); // No inheritance for pipes + explicit_access[0].Trustee = TRUSTEE_W { + pMultipleTrustee: ptr::null_mut(), + MultipleTrusteeOperation: Default::default(), + TrusteeForm: TRUSTEE_IS_SID, + TrusteeType: TRUSTEE_IS_USER, + ptstrName: PWSTR::from_raw(system_sid_buffer.as_ptr() as *mut u16), + }; + + // Entry 1: User - full access + explicit_access[1].grfAccessPermissions = FILE_ALL_ACCESS.0; + explicit_access[1].grfAccessMode = SET_ACCESS; + explicit_access[1].grfInheritance = ACE_FLAGS(0); // No inheritance for pipes + // SAFETY: When TrusteeForm is TRUSTEE_IS_SID, ptstrName is interpreted as a PSID + // pointer, not a string pointer. The Windows API reuses this field for different + // purposes based on TrusteeForm. The SID binary data in user_sid is valid for + // the duration of this function call (until SetEntriesInAclW returns). + explicit_access[1].Trustee = TRUSTEE_W { + pMultipleTrustee: ptr::null_mut(), + MultipleTrusteeOperation: Default::default(), + TrusteeForm: TRUSTEE_IS_SID, + TrusteeType: TRUSTEE_IS_USER, + ptstrName: PWSTR::from_raw(user_sid.as_ptr() as *mut u16), + }; + + // Create ACL from explicit access entries + // After this call returns, system_sid_buffer and user_sid are no longer needed + let mut new_acl: *mut ACL = ptr::null_mut(); + let result = unsafe { + SetEntriesInAclW( + Some(&explicit_access), + None, // No existing ACL + &mut new_acl, + ) + }; + + if result.0 != 0 { + return Err(anyhow!( + "SetEntriesInAclW failed with error code: {}", + result.0 + )); + } + + if new_acl.is_null() { + return Err(anyhow!("SetEntriesInAclW returned null ACL")); + } + + Ok(new_acl as *mut c_void) +} + +/// Create a named pipe with a restricted DACL. +/// Only SYSTEM and the specified user can access the pipe. +/// +/// # Arguments +/// * `pipe_name` - The name of the pipe to create +/// * `for_input` - True if service writes to this pipe (helper reads), false otherwise +/// * `user_token` - Required user token for creating restricted DACL +/// +/// # Security +/// +/// The restricted DACL limits pipe access to: +/// - SYSTEM account (the service) +/// - The specific user whose token was provided (the helper process) +/// +/// This function requires a valid user_token and will fail if DACL creation fails, +/// rather than falling back to a less secure NULL DACL. +pub fn create_named_pipe_server( + pipe_name: &str, + for_input: bool, + user_token: UserToken, +) -> Result { + // SECURITY_DESCRIPTOR minimum length is 40 bytes on x64. + const SD_BUFFER_SIZE: usize = 64; + const _: () = assert!( + SD_BUFFER_SIZE >= 40, + "SD_BUFFER_SIZE must be at least 40 bytes for SECURITY_DESCRIPTOR" + ); + + let mut sd_buffer = [0u8; SD_BUFFER_SIZE]; + let sd_ptr = PSECURITY_DESCRIPTOR(sd_buffer.as_mut_ptr() as *mut c_void); + + // Initialize security descriptor + unsafe { + InitializeSecurityDescriptor(sd_ptr, 1) + .map_err(|e| anyhow!("Failed to initialize security descriptor: {}", e))?; + } + + // Create restricted DACL - fail if this doesn't work (no NULL DACL fallback) + let user_sid = get_user_sid_from_token(user_token) + .context("Failed to get user SID from token for pipe DACL")?; + let acl_ptr = + create_restricted_dacl(&user_sid).context("Failed to create restricted DACL for pipe")?; + + log::debug!("Created restricted DACL for pipe: {}", pipe_name); + + // Set DACL on security descriptor + unsafe { + SetSecurityDescriptorDacl(sd_ptr, true, Some(acl_ptr as *const _ as *const _), false) + .map_err(|e| { + // Clean up ACL on error (ignore result - cleanup is best-effort, original error takes precedence) + let _ = LocalFree(Some(HLOCAL(acl_ptr))); + anyhow!("Failed to set restricted DACL: {}", e) + })?; + } + + let sa = SECURITY_ATTRIBUTES { + nLength: std::mem::size_of::() as u32, + lpSecurityDescriptor: sd_buffer.as_mut_ptr() as *mut c_void, + bInheritHandle: false.into(), + }; + + let wide_name: Vec = OsStr::new(pipe_name) + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + + let access_mode = if for_input { + FILE_FLAGS_AND_ATTRIBUTES(PIPE_ACCESS_INBOUND | FILE_FLAG_OVERLAPPED.0) + } else { + FILE_FLAGS_AND_ATTRIBUTES(PIPE_ACCESS_OUTBOUND | FILE_FLAG_OVERLAPPED.0) + }; + + log::debug!( + "Creating named pipe: {} (for_input={}, restricted_dacl=true)", + pipe_name, + for_input + ); + + let handle = unsafe { + CreateNamedPipeW( + PCWSTR::from_raw(wide_name.as_ptr()), + access_mode, + PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT, + 1, // max instances + PIPE_BUFFER_SIZE, + PIPE_BUFFER_SIZE, + PIPE_DEFAULT_TIMEOUT_MS, + Some(&sa), + ) + }; + + // Clean up ACL after pipe creation (security descriptor has been applied) + // Ignore result: LocalFree failure is non-critical since the pipe is already created + unsafe { + let _ = LocalFree(Some(HLOCAL(acl_ptr))); + } + + if handle == INVALID_HANDLE_VALUE { + return Err(anyhow!( + "Failed to create named pipe {}: {}", + pipe_name, + std::io::Error::last_os_error() + )); + } + + log::debug!("Named pipe created: {}", pipe_name); + Ok(handle) +} + +/// Wait for client to connect to named pipe with timeout. +/// +/// # Ownership +/// This function **takes ownership** of the `pipe_handle` via OwnedHandle: +/// - On success: the handle is extracted and wrapped in a `File`. +/// - On failure: the handle is automatically closed when OwnedHandle drops. +pub fn wait_for_pipe_connection( + pipe_handle: OwnedHandle, + pipe_name: &str, + timeout_ms: u32, +) -> Result { + log::debug!("Waiting for pipe connection: {}", pipe_name); + + // Create an event for overlapped I/O (also wrapped in OwnedHandle for RAII) + let event = unsafe { CreateEventW(None, true, false, PCWSTR::null()) } + .map_err(|e| anyhow!("Failed to create event for pipe connection: {}", e))?; + let event_handle = OwnedHandle::new(event); + + let mut overlapped: OVERLAPPED = unsafe { std::mem::zeroed() }; + overlapped.hEvent = event_handle.as_raw(); + + let result = unsafe { ConnectNamedPipe(pipe_handle.as_raw(), Some(&mut overlapped)) }; + if result.is_err() { + let err = std::io::Error::last_os_error(); + let err_code = err.raw_os_error().unwrap_or(0); + + // ERROR_PIPE_CONNECTED means client already connected, which is OK + if err_code == ERROR_PIPE_CONNECTED.0 as i32 { + log::debug!("Pipe already connected: {}", pipe_name); + return Ok(unsafe { File::from_raw_handle(pipe_handle.into_raw().0 as RawHandle) }); + } + + // ERROR_IO_PENDING means we need to wait + if err_code == ERROR_IO_PENDING.0 as i32 { + log::debug!("Pipe connection pending, waiting with timeout..."); + let wait_result = unsafe { WaitForSingleObject(event_handle.as_raw(), timeout_ms) }; + + if wait_result != WAIT_OBJECT_0 { + log::error!("Timeout waiting for pipe connection: {}", pipe_name); + return Err(anyhow!( + "Timeout waiting for pipe connection: {}", + pipe_name + )); + } + + // Check if connection was successful + let mut bytes_transferred = 0u32; + let overlapped_result = unsafe { + GetOverlappedResult( + pipe_handle.as_raw(), + &overlapped, + &mut bytes_transferred, + false, + ) + }; + if overlapped_result.is_err() { + let err = std::io::Error::last_os_error(); + log::error!("Failed to complete pipe connection {}: {}", pipe_name, err); + return Err(anyhow!( + "Failed to complete pipe connection {}: {}", + pipe_name, + err + )); + } + + log::debug!("Pipe connected: {}", pipe_name); + } else { + log::error!("Failed to connect named pipe {}: {}", pipe_name, err); + return Err(anyhow!( + "Failed to connect named pipe {}: {}", + pipe_name, + err + )); + } + } else { + log::debug!("Pipe connected immediately: {}", pipe_name); + } + + // Success: transfer pipe ownership to File, event_handle drops + Ok(unsafe { File::from_raw_handle(pipe_handle.into_raw().0 as RawHandle) }) +} + +/// Launch terminal helper process as the logged-in user using the provided token. +/// The helper process creates ConPTY and shell, communicating via named pipes. +/// This uses CreateProcessAsUserW directly with the user token, which works because +/// the helper process itself doesn't need ConPTY - it creates ConPTY internally. +/// +/// Returns HelperProcessInfo containing the process handle and PID. + +/// RAII guard for environment block cleanup. +/// Ensures DestroyEnvironmentBlock is called even if an error occurs. +struct EnvironmentBlockGuard { + ptr: *mut c_void, +} + +impl Drop for EnvironmentBlockGuard { + fn drop(&mut self) { + if !self.ptr.is_null() { + unsafe { + // Ignore result: DestroyEnvironmentBlock failure is non-critical during cleanup + let _ = DestroyEnvironmentBlock(self.ptr); + } + } + } +} + +pub fn launch_terminal_helper_with_token( + user_token: UserToken, + input_pipe_name: &str, + output_pipe_name: &str, + terminal_id: i32, + rows: u16, + cols: u16, +) -> Result { + let exe_path = + std::env::current_exe().map_err(|e| anyhow!("Failed to get current exe path: {}", e))?; + + // Build command line arguments (without exe path to avoid escaping issues) + // lpApplicationName will contain the exe path separately + let cmd_args = format!( + "--terminal-helper {} {} {} {} {}", + input_pipe_name, output_pipe_name, rows, cols, terminal_id + ); + + log::debug!("Launching terminal helper for terminal {}", terminal_id); + + // Convert exe path to wide string for lpApplicationName + let exe_path_wide: Vec = OsStr::new(exe_path.as_os_str()) + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + + // Command line must include exe name as first argument per Windows convention + let cmd_line = format!("\"{}\" {}", exe_path.display(), cmd_args); + let mut cmd_wide: Vec = OsStr::new(&cmd_line) + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + + let mut si: STARTUPINFOW = unsafe { std::mem::zeroed() }; + si.cb = std::mem::size_of::() as u32; + + let mut pi: PROCESS_INFORMATION = unsafe { std::mem::zeroed() }; + + // Create environment block for the user with RAII cleanup + let mut environment: *mut c_void = ptr::null_mut(); + let env_ok = unsafe { + CreateEnvironmentBlock( + &mut environment, + Some(HANDLE(user_token.as_raw() as _)), + true, + ) + } + .is_ok(); + + // Use RAII guard to ensure cleanup even on error paths + let _env_guard = if env_ok && !environment.is_null() { + Some(EnvironmentBlockGuard { ptr: environment }) + } else { + if !env_ok { + log::warn!("Failed to create environment block, using default"); + } + None + }; + + let creation_flags = CREATE_NO_WINDOW + | if env_ok { + CREATE_UNICODE_ENVIRONMENT + } else { + PROCESS_CREATION_FLAGS(0) + }; + + // Use lpApplicationName to pass exe path separately from command line + // This avoids potential issues with special characters in the exe path + let result = unsafe { + CreateProcessAsUserW( + Some(HANDLE(user_token.as_raw() as _)), + PCWSTR::from_raw(exe_path_wide.as_ptr()), // lpApplicationName: exe path + Some(PWSTR::from_raw(cmd_wide.as_mut_ptr())), // lpCommandLine: full command + None, + None, + false, // Don't inherit handles + creation_flags, + if env_ok { Some(environment) } else { None }, + PCWSTR::null(), // Use default current directory + &si, + &mut pi, + ) + }; + + // Environment block cleanup is handled by _env_guard's Drop + + if let Err(e) = result { + log::error!("CreateProcessAsUserW failed: {}", e); + return Err(anyhow!("Failed to launch terminal helper: {}", e)); + } + + // Close thread handle - we only need the process handle for tracking + // Ignore result: CloseHandle failure here is non-critical since process is already launched + unsafe { + let _ = CloseHandle(pi.hThread); + } + + log::info!("Terminal helper launched with PID {}", pi.dwProcessId); + // Return process info for tracking + Ok(HelperProcessInfo { + handle: pi.hProcess, + pid: pi.dwProcessId, + }) +} + +/// Check if a helper process is still running. +/// Returns true if the process is running, false if it has exited. +pub fn is_helper_process_running(handle: HANDLE) -> bool { + let wait_result = unsafe { WaitForSingleObject(handle, 0) }; + // WAIT_TIMEOUT (258) means process is still running + // WAIT_OBJECT_0 (0) means process has exited + wait_result != WAIT_OBJECT_0 +} + +/// Run terminal helper process +/// Args: --terminal-helper +pub fn run_terminal_helper(args: &[String]) -> Result<()> { + if args.len() < 5 { + return Err(anyhow!( + "Usage: --terminal-helper " + )); + } + + let input_pipe_name = &args[0]; + let output_pipe_name = &args[1]; + let rows: u16 = args[2] + .parse() + .map_err(|e| anyhow!("Failed to parse rows '{}': {}", args[2], e))?; + let cols: u16 = args[3] + .parse() + .map_err(|e| anyhow!("Failed to parse cols '{}': {}", args[3], e))?; + let terminal_id: i32 = args[4] + .parse() + .map_err(|e| anyhow!("Failed to parse terminal_id '{}': {}", args[4], e))?; + + log::debug!( + "Terminal helper starting: terminal_id={}, size={}x{}", + terminal_id, + cols, + rows + ); + + // Open named pipes (created by the service) + let mut input_pipe = open_pipe(input_pipe_name, true)?; + let mut output_pipe = open_pipe(output_pipe_name, false)?; + + // Create ConPTY and shell + let pty_size = PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + }; + + let pty_system = portable_pty::native_pty_system(); + let pty_pair = pty_system.openpty(pty_size).context("Failed to open PTY")?; + + let shell = get_default_shell(); + log::debug!("Using shell: {}", shell); + + let mut cmd = CommandBuilder::new(&shell); + configure_utf8_shell_command(&shell, &mut cmd); + let mut child = pty_pair + .slave + .spawn_command(cmd) + .context("Failed to spawn shell")?; + + // Explicitly drop slave after spawning to release resources + drop(pty_pair.slave); + + let pid = child.process_id().unwrap_or(0); + log::debug!("Shell started with PID: {}", pid); + + let mut pty_writer = pty_pair + .master + .take_writer() + .context("Failed to get PTY writer")?; + + let mut pty_reader = pty_pair + .master + .try_clone_reader() + .context("Failed to get PTY reader")?; + + // Wrap pty_pair.master in Arc for sharing with input thread (for resize). + let pty_master: Arc>> = Arc::new(Mutex::new(pty_pair.master)); + + let exiting = Arc::new(AtomicBool::new(false)); + + // Thread: Read from input pipe, parse messages, write data to PTY or handle control commands + let exiting_clone = exiting.clone(); + let pty_master_clone = pty_master.clone(); + let input_thread = thread::spawn(move || { + let mut input_pipe = input_pipe; + let mut header_buf = [0u8; MSG_HEADER_SIZE]; + let mut payload_buf = vec![0u8; 4096]; + + loop { + if exiting_clone.load(Ordering::SeqCst) { + break; + } + + // Read message header + match read_exact_or_eof(&mut input_pipe, &mut header_buf) { + Ok(false) => { + log::debug!("Input pipe EOF"); + break; + } + Ok(true) => {} + Err(e) => { + log::error!("Input pipe header read error: {}", e); + break; + } + } + + let msg_type = header_buf[0]; + let payload_len = + u32::from_le_bytes([header_buf[1], header_buf[2], header_buf[3], header_buf[4]]) + as usize; + + // Validate payload length to prevent denial of service + if payload_len > MAX_PAYLOAD_SIZE { + log::error!( + "Payload too large: {} bytes (max {})", + payload_len, + MAX_PAYLOAD_SIZE + ); + break; + } + + // Ensure payload buffer is large enough + if payload_buf.len() < payload_len { + payload_buf.resize(payload_len, 0); + } + + // Read payload + if payload_len > 0 { + match read_exact_or_eof(&mut input_pipe, &mut payload_buf[..payload_len]) { + Ok(false) => { + log::debug!("Input pipe EOF during payload read"); + break; + } + Ok(true) => {} + Err(e) => { + log::error!("Input pipe payload read error: {}", e); + break; + } + } + } + + match msg_type { + MSG_TYPE_DATA => { + // Write terminal data to PTY + if let Err(e) = pty_writer.write_all(&payload_buf[..payload_len]) { + log::error!("PTY write error: {}", e); + break; + } + if let Err(e) = pty_writer.flush() { + log::error!("PTY flush error: {}", e); + break; + } + } + MSG_TYPE_RESIZE => { + if payload_len >= 4 { + let rows = u16::from_le_bytes([payload_buf[0], payload_buf[1]]); + let cols = u16::from_le_bytes([payload_buf[2], payload_buf[3]]); + log::debug!("Resize: {}x{}", cols, rows); + if let Ok(master) = pty_master_clone.lock() { + let _ = master.resize(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + }); + } + } + } + _ => { + // Unknown type may indicate data corruption - stop to avoid parse errors + log::error!("Unknown message type: {}, terminating", msg_type); + break; + } + } + } + log::debug!("Input thread exiting"); + }); + + // Thread: Read from PTY, write to output pipe + let exiting_clone = exiting.clone(); + let output_thread = thread::spawn(move || { + let mut output_pipe = output_pipe; + let mut buf = vec![0u8; 4096]; + loop { + if exiting_clone.load(Ordering::SeqCst) { + break; + } + match pty_reader.read(&mut buf) { + Ok(0) => { + log::debug!("PTY EOF"); + break; + } + Ok(n) => { + if let Err(e) = output_pipe.write_all(&buf[..n]) { + log::error!("Output pipe write error: {}", e); + break; + } + if let Err(e) = output_pipe.flush() { + log::error!("Output pipe flush error: {}", e); + break; + } + } + Err(e) => { + if e.kind() != std::io::ErrorKind::WouldBlock { + log::error!("PTY read error: {}", e); + break; + } + thread::sleep(Duration::from_millis(10)); + } + } + } + log::debug!("Output thread exiting"); + }); + + // Wait for child process to exit + let exit_status = child.wait(); + log::info!("Shell exited: {:?}", exit_status); + + exiting.store(true, Ordering::SeqCst); + + // Wait for threads + let _ = input_thread.join(); + let _ = output_thread.join(); + + // pty_master will be dropped here, releasing PTY resources + drop(pty_master); + + log::info!("Terminal helper exiting"); + Ok(()) +} + +/// Read exactly `buf.len()` bytes from reader. +/// Returns Ok(true) if successful, Ok(false) on EOF, Err on error. +fn read_exact_or_eof(reader: &mut R, buf: &mut [u8]) -> std::io::Result { + let mut pos = 0; + while pos < buf.len() { + match reader.read(&mut buf[pos..]) { + Ok(0) => return Ok(false), // EOF + Ok(n) => pos += n, + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue, + Err(e) => return Err(e), + } + } + Ok(true) +} + +/// Open a named pipe as a client. +/// `for_read`: true for reading (input pipe), false for writing (output pipe). +fn open_pipe(pipe_name: &str, for_read: bool) -> Result { + let wide_name: Vec = OsStr::new(pipe_name) + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + + let access = if for_read { + FILE_GENERIC_READ.0 + } else { + FILE_GENERIC_WRITE.0 + }; + + let handle = unsafe { + CreateFileW( + PCWSTR::from_raw(wide_name.as_ptr()), + access, + FILE_SHARE_READ | FILE_SHARE_WRITE, + None, + OPEN_EXISTING, + FILE_FLAGS_AND_ATTRIBUTES(0), + None, + ) + }; + + match handle { + Ok(h) => Ok(unsafe { File::from_raw_handle(h.0 as _) }), + Err(e) => Err(anyhow!( + "Failed to open {} pipe '{}': {}", + if for_read { "input" } else { "output" }, + pipe_name, + e + )), + } +} diff --git a/src/server/terminal_service.rs b/src/server/terminal_service.rs new file mode 100644 index 000000000..52a296b74 --- /dev/null +++ b/src/server/terminal_service.rs @@ -0,0 +1,2166 @@ +use super::*; +use hbb_common::{ + anyhow::{anyhow, Context, Result}, + compress, +}; +use portable_pty::{Child, CommandBuilder, PtySize}; +use std::{ + collections::{HashMap, VecDeque}, + io::{Read, Write}, + ops::{Deref, DerefMut}, + sync::{ + atomic::{AtomicBool, Ordering}, + mpsc::{self, Receiver, SyncSender}, + Arc, Mutex, + }, + thread, + time::{Duration, Instant}, +}; + +// Windows-specific imports from terminal_helper module +#[cfg(target_os = "windows")] +use super::terminal_helper::{ + configure_utf8_shell_command, create_named_pipe_server, encode_helper_message, + encode_resize_message, is_helper_process_running, launch_terminal_helper_with_token, + wait_for_pipe_connection, HelperProcessGuard, OwnedHandle, SendableHandle, WinCloseHandle, + WinTerminateProcess, WinWaitForSingleObject, MSG_TYPE_DATA, PIPE_CONNECTION_TIMEOUT_MS, + WIN_WAIT_OBJECT_0, +}; + +const MAX_OUTPUT_BUFFER_SIZE: usize = 1024 * 1024; // 1MB per terminal +const MAX_BUFFER_LINES: usize = 10000; +const MAX_SERVICES: usize = 100; // Maximum number of persistent terminal services +const SERVICE_IDLE_TIMEOUT: Duration = Duration::from_secs(3600); // 1 hour idle timeout +const CHANNEL_BUFFER_SIZE: usize = 500; // Channel buffer size. Max per-message size ~4KB (reader buffer), so worst case ~500*4KB ≈ 2MB/terminal. Increased from 100 to reduce data loss during disconnects. +const COMPRESS_THRESHOLD: usize = 512; // Compress terminal data larger than this + // Default max bytes for reconnection buffer replay. +const DEFAULT_RECONNECT_BUFFER_BYTES: usize = 8 * 1024; +const MAX_SIGWINCH_PHASE_ATTEMPTS: u8 = 3; // Max attempts per SIGWINCH phase before giving up + +/// Two-phase SIGWINCH trigger for TUI app redraw on reconnection. +/// +/// Why two phases? A single resize-then-restore done back-to-back is too fast: +/// by the time the TUI app handles the asynchronous SIGWINCH signal and calls +/// `ioctl(TIOCGWINSZ)`, the PTY size has already been restored to the original. +/// ncurses sees no size change and skips the full redraw. +/// +/// Splitting across two `read_outputs()` calls (~30ms apart) ensures the app +/// sees a real size change on each SIGWINCH, forcing a complete redraw. +#[derive(Debug, Clone)] +enum SigwinchPhase { + /// No SIGWINCH needed. + Idle, + /// Phase 1: Resize PTY to temp dimensions (rows±1). The app handles SIGWINCH + /// and redraws at the temporary size. + TempResize { retries: u8 }, + /// Phase 2: Restore PTY to correct dimensions. The app handles SIGWINCH, + /// detects the size change, and performs a full redraw at the correct size. + Restore { retries: u8 }, +} + +/// Which resize to perform in the two-phase SIGWINCH sequence. +enum SigwinchAction { + /// Phase 1: resize to temp dimensions (rows±1) to trigger SIGWINCH with a visible size change. + TempResize, + /// Phase 2: restore to correct dimensions to trigger SIGWINCH and force full redraw. + Restore, +} + +/// Session state machine for terminal streaming. +#[derive(Debug)] +enum SessionState { + /// Session is closed, not streaming data to client. + Closed, + /// Session is active, streaming data to client. + /// pending_buffer: historical buffer to send before real-time data (set on reconnection). + /// sigwinch: two-phase SIGWINCH trigger state for TUI app redraw. + Active { + pending_buffer: Option>, + sigwinch: SigwinchPhase, + }, +} + +lazy_static::lazy_static! { + // Global registry of persistent terminal services indexed by service_id + static ref TERMINAL_SERVICES: Arc>>>> = + Arc::new(Mutex::new(HashMap::new())); + + // Cleanup task handle + static ref CLEANUP_TASK: Arc>>> = Arc::new(Mutex::new(None)); + + // List of terminal child processes to check for zombies + static ref TERMINAL_TASKS: Arc>>> = Arc::new(Mutex::new(Vec::new())); +} + +/// Service metadata that is sent to clients +#[derive(Clone, Debug)] +pub struct ServiceMetadata { + pub service_id: String, + pub created_at: Instant, + pub terminal_count: usize, + pub is_persistent: bool, +} + +/// Generate a new persistent service ID +pub fn generate_service_id() -> String { + format!("ts_{}", uuid::Uuid::new_v4()) +} + +fn get_default_shell() -> String { + #[cfg(target_os = "windows")] + { + // Use shared implementation from terminal_helper + super::terminal_helper::get_default_shell() + } + #[cfg(not(target_os = "windows"))] + { + // First try the SHELL environment variable + if let Ok(shell) = std::env::var("SHELL") { + if !shell.is_empty() { + return shell; + } + } + + // Check for common shells in order of preference + let shells = ["/bin/bash", "/bin/zsh", "/bin/sh"]; + for shell in &shells { + if std::path::Path::new(shell).exists() { + return shell.to_string(); + } + } + + // Final fallback to /bin/sh which should exist on all POSIX systems + "/bin/sh".to_string() + } +} + +#[cfg(target_os = "macos")] +fn locale_value_is_utf8(value: &str) -> bool { + let value = value.to_ascii_uppercase(); + value.contains("UTF-8") || value.contains("UTF8") +} + +#[cfg(target_os = "macos")] +fn should_force_process_utf8_ctype() -> bool { + if let Ok(value) = std::env::var("LC_ALL") { + return !locale_value_is_utf8(&value); + } + if let Ok(value) = std::env::var("LC_CTYPE") { + return !locale_value_is_utf8(&value); + } + if let Ok(value) = std::env::var("LANG") { + return !locale_value_is_utf8(&value); + } + true +} + +pub fn is_service_specified_user(service_id: &str) -> Option { + get_service(service_id).map(|s| s.lock().unwrap().is_specified_user) +} + +/// Get or create a persistent terminal service +fn get_or_create_service( + service_id: String, + is_persistent: bool, + is_specified_user: bool, +) -> Result>> { + let mut services = TERMINAL_SERVICES.lock().unwrap(); + + // Check service limit + if !services.contains_key(&service_id) && services.len() >= MAX_SERVICES { + return Err(anyhow!( + "Maximum number of terminal services ({}) reached", + MAX_SERVICES + )); + } + + let service = services + .entry(service_id.clone()) + .or_insert_with(|| { + log::info!( + "Creating new terminal service: {} (persistent: {})", + service_id, + is_persistent + ); + Arc::new(Mutex::new(PersistentTerminalService::new( + service_id.clone(), + is_persistent, + is_specified_user, + ))) + }) + .clone(); + + // Ensure cleanup task is running + ensure_cleanup_task(); + + service.lock().unwrap().reset_status(is_persistent); + + Ok(service) +} + +/// Remove a service from the global registry +fn remove_service(service_id: &str) { + let mut services = TERMINAL_SERVICES.lock().unwrap(); + if let Some(service) = services.remove(service_id) { + log::info!("Removed service: {}", service_id); + // Close all terminals in the service + let sessions = service.lock().unwrap().sessions.clone(); + for (_, session) in sessions.iter() { + let mut session = session.lock().unwrap(); + session.stop(); + } + } +} + +/// List all active terminal services +pub fn list_services() -> Vec { + let services = TERMINAL_SERVICES.lock().unwrap(); + services + .iter() + .filter_map(|(id, service)| { + service.lock().ok().map(|svc| ServiceMetadata { + service_id: id.clone(), + created_at: svc.created_at, + terminal_count: svc.sessions.len(), + is_persistent: svc.is_persistent, + }) + }) + .collect() +} + +/// Get service by ID +pub fn get_service(service_id: &str) -> Option>> { + let services = TERMINAL_SERVICES.lock().unwrap(); + services.get(service_id).cloned() +} + +/// Clean up inactive services +pub fn cleanup_inactive_services() { + let services = TERMINAL_SERVICES.lock().unwrap(); + let now = Instant::now(); + let mut to_remove = Vec::new(); + + for (service_id, service) in services.iter() { + if let Ok(svc) = service.lock() { + // Remove non-persistent services after idle timeout + if !svc.is_persistent && now.duration_since(svc.last_activity) > SERVICE_IDLE_TIMEOUT { + to_remove.push(service_id.clone()); + log::info!("Cleaning up idle non-persistent service: {}", service_id); + } + // Remove persistent services with no active terminals after longer timeout + else if svc.is_persistent + && svc.sessions.is_empty() + && now.duration_since(svc.last_activity) > SERVICE_IDLE_TIMEOUT * 2 + { + to_remove.push(service_id.clone()); + log::info!("Cleaning up empty persistent service: {}", service_id); + } + } + } + + // Remove outside of iteration to avoid deadlock + drop(services); + for id in to_remove { + remove_service(&id); + } +} + +/// Add a child process to the zombie reaper +fn add_to_reaper(child: Box) { + if let Ok(mut tasks) = TERMINAL_TASKS.lock() { + tasks.push(child); + } +} + +/// Check and reap zombie terminal processes +fn check_zombie_terminals() { + let mut tasks = match TERMINAL_TASKS.lock() { + Ok(t) => t, + Err(_) => return, + }; + + let mut i = 0; + while i < tasks.len() { + match tasks[i].try_wait() { + Ok(Some(_)) => { + // Process has exited, remove it + log::info!("Process exited: {:?}", tasks[i].process_id()); + tasks.remove(i); + } + Ok(None) => { + // Still running + i += 1; + } + Err(err) => { + // Error checking status, remove it + log::info!( + "Process exited with error: {:?}, err: {err}", + tasks[i].process_id() + ); + tasks.remove(i); + } + } + } +} + +/// Ensure the cleanup task is running +fn ensure_cleanup_task() { + let mut task_handle = CLEANUP_TASK.lock().unwrap(); + if task_handle.is_none() { + let handle = std::thread::spawn(|| { + log::info!("Started cleanup task"); + let mut last_service_cleanup = Instant::now(); + loop { + // Check for zombie processes every 100ms + check_zombie_terminals(); + + // Check for inactive services every 5 minutes + if last_service_cleanup.elapsed() > Duration::from_secs(300) { + cleanup_inactive_services(); + last_service_cleanup = Instant::now(); + } + + std::thread::sleep(Duration::from_millis(100)); + } + }); + *task_handle = Some(handle); + } +} + +#[cfg(target_os = "linux")] +pub fn get_terminal_session_count(include_zombie_tasks: bool) -> usize { + let mut c = TERMINAL_SERVICES.lock().unwrap().len(); + if include_zombie_tasks { + c += TERMINAL_TASKS.lock().unwrap().len(); + } + c +} + +/// User token wrapper for cross-module use. +/// +/// # Design Note +/// On Windows, this type is defined in terminal_helper.rs and re-exported here. +/// On non-Windows platforms, it's defined here directly. +/// This design avoids circular dependencies while keeping the API consistent. +/// Both definitions MUST have identical public API (new, as_raw methods). +#[cfg(not(target_os = "windows"))] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct UserToken(pub usize); + +#[cfg(not(target_os = "windows"))] +impl UserToken { + pub fn new(handle: usize) -> Self { + Self(handle) + } + + pub fn as_raw(&self) -> usize { + self.0 + } +} + +#[cfg(target_os = "windows")] +pub use super::terminal_helper::UserToken; + +#[derive(Clone)] +pub struct TerminalService { + sp: GenericService, + user_token: Option, +} + +impl Deref for TerminalService { + type Target = ServiceTmpl; + + fn deref(&self) -> &Self::Target { + &self.sp + } +} + +impl DerefMut for TerminalService { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.sp + } +} + +pub fn get_service_name(source: VideoSource, idx: usize) -> String { + format!("{}{}", source.service_name_prefix(), idx) +} + +pub fn new( + service_id: String, + is_persistent: bool, + user_token: Option, +) -> GenericService { + // Create the service with initial persistence setting + allow_err!(get_or_create_service( + service_id.clone(), + is_persistent, + user_token.is_some() + )); + let svc = TerminalService { + sp: GenericService::new(service_id.clone(), false), + user_token, + }; + GenericService::run(&svc.clone(), move |sp| run(sp, service_id.clone())); + svc.sp +} + +fn run(sp: TerminalService, service_id: String) -> ResultType<()> { + while sp.ok() { + let responses = TerminalServiceProxy::new(service_id.clone(), None, sp.user_token.clone()) + .read_outputs(); + for response in responses { + let mut msg_out = Message::new(); + msg_out.set_terminal_response(response); + sp.send(msg_out); + } + + thread::sleep(Duration::from_millis(30)); // Read at ~33fps for responsive terminal + } + + // Clean up non-persistent service when loop exits + if let Some(service) = get_service(&service_id) { + let should_remove = !service.lock().unwrap().is_persistent; + if should_remove { + remove_service(&service_id); + } + } + + Ok(()) +} + +/// Output buffer for terminal session +struct OutputBuffer { + lines: VecDeque>, + total_size: usize, + last_line_incomplete: bool, +} + +impl OutputBuffer { + fn new() -> Self { + Self { + lines: VecDeque::new(), + total_size: 0, + last_line_incomplete: false, + } + } + + fn append(&mut self, data: &[u8]) { + if data.is_empty() { + return; + } + + // Handle incomplete lines + let mut start = 0; + if self.last_line_incomplete { + if let Some(last_line) = self.lines.back_mut() { + // Find first newline in new data + if let Some(newline_pos) = data.iter().position(|&b| b == b'\n') { + last_line.extend_from_slice(&data[..=newline_pos]); + self.total_size += newline_pos + 1; + start = newline_pos + 1; + self.last_line_incomplete = false; + } else { + // Still no newline, append all + last_line.extend_from_slice(data); + self.total_size += data.len(); + return; + } + } + } + + // Process remaining data + let remaining = &data[start..]; + let ends_with_newline = remaining.last() == Some(&b'\n'); + + // Split by lines + let lines: Vec<&[u8]> = remaining.split(|&b| b == b'\n').collect(); + + for (i, line) in lines.iter().enumerate() { + if i == lines.len() - 1 && !ends_with_newline && !line.is_empty() { + // Last line without newline + self.last_line_incomplete = true; + } + + if !line.is_empty() || i < lines.len() - 1 { + let mut line_data = line.to_vec(); + if i < lines.len() - 1 || ends_with_newline { + line_data.push(b'\n'); + } + + self.total_size += line_data.len(); + self.lines.push_back(line_data); + } + } + + // Trim old data if buffer is too large + while self.total_size > MAX_OUTPUT_BUFFER_SIZE || self.lines.len() > MAX_BUFFER_LINES { + if let Some(removed) = self.lines.pop_front() { + if removed.len() > self.total_size { + log::error!( + "OutputBuffer total_size underflow avoided: total_size={}, removed_len={}, lines_len={}", + self.total_size, + removed.len(), + self.lines.len() + ); + self.total_size = self.lines.iter().map(|line| line.len()).sum(); + } else { + self.total_size -= removed.len(); + } + if self.lines.is_empty() { + self.last_line_incomplete = false; + } + } else { + log::error!( + "OutputBuffer trim invariant broken: total_size={}, lines_len=0", + self.total_size + ); + self.total_size = 0; + self.last_line_incomplete = false; + break; + } + } + } + + fn get_recent(&self, max_bytes: usize) -> Vec { + if max_bytes == 0 { + return Vec::new(); + } + let mut chunks: Vec<&[u8]> = Vec::new(); + let mut size = 0; + + // Collect whole chunks from newest to oldest, preserving chronological continuity. + // If the newest chunk alone exceeds max_bytes, take its tail (truncation may split + // an ANSI escape, but the terminal will self-correct on subsequent output). + for line in self.lines.iter().rev() { + if size + line.len() > max_bytes { + if size == 0 && line.len() > max_bytes { + // Single oversized chunk: take the tail to preserve the most recent content. + // Align offset forward to a UTF-8 char boundary so that downstream + // clients (e.g. Dart) that decode the payload as UTF-8 text don't + // encounter split code points. The protobuf bytes field itself allows + // arbitrary bytes; this is a best-effort mitigation for client-side decoding. + let mut offset = line.len() - max_bytes; + // Skip at most 3 continuation bytes (UTF-8 max 4-byte sequence). + // Prevents runaway skipping on non-UTF-8 binary data. + let mut skipped = 0u8; + while skipped < 3 + && offset < line.len() + && (line[offset] & 0b1100_0000) == 0b1000_0000 + { + offset += 1; + skipped += 1; + } + // If we skipped past all remaining bytes (degenerate data), drop the + // chunk entirely rather than emitting a slice that decodes poorly on the client. + if offset < line.len() { + chunks.push(&line[offset..]); + size = line.len() - offset; + } + } + break; + } + size += line.len(); + chunks.push(line); + } + + // Reverse to restore chronological order and concatenate + chunks.reverse(); + let mut result = Vec::with_capacity(size); + for chunk in chunks { + result.extend_from_slice(chunk); + } + + result + } +} + +/// Find the largest prefix of `buf` that does not end in the middle of a UTF-8 +/// code point. Invalid bytes are treated as complete so they can continue +/// downstream and be rendered with replacement characters if needed. +fn find_utf8_split_point(buf: &[u8]) -> usize { + if buf.is_empty() { + return 0; + } + + let start = buf.len().saturating_sub(3); + for i in (start..buf.len()).rev() { + let b = buf[i]; + if b & 0x80 == 0 { + return buf.len(); + } + if b & 0xC0 == 0x80 { + continue; + } + + let seq_len = if b & 0xE0 == 0xC0 { + 2 + } else if b & 0xF0 == 0xE0 { + 3 + } else if b & 0xF8 == 0xF0 { + 4 + } else { + return buf.len(); + }; + + return if buf.len() - i >= seq_len { + buf.len() + } else { + i + }; + } + + buf.len() +} + +// Terminal output currently follows a UTF-8 text model end to end: the service +// keeps replay buffers on UTF-8 boundaries, and Flutter decodes payload bytes as +// UTF-8 before writing to xterm. This accumulator only prevents splitting a +// trailing UTF-8 code point across PTY reads. Supporting non-UTF-8 terminals +// would need a separate design covering remote encoding detection, Flutter +// decoding, replay truncation, and input transcoding. +#[derive(Default)] +struct Utf8ChunkAccumulator { + remainder: Vec, +} + +impl Utf8ChunkAccumulator { + fn push_chunk(&mut self, mut data: Vec) -> Option> { + if data.is_empty() { + return None; + } + + let had_remainder = !self.remainder.is_empty(); + if had_remainder { + let mut combined = std::mem::take(&mut self.remainder); + combined.extend_from_slice(&data); + data = combined; + } + + let split = find_utf8_split_point(&data); + if split == data.len() { + return Some(data); + } + + // Only hold back a candidate incomplete suffix when we have evidence that + // the bytes before it are already UTF-8 text. If split is 0, the whole + // read may be the start of a UTF-8 character, so keep it for the next read. + if !had_remainder && split > 0 && std::str::from_utf8(&data[..split]).is_err() { + return Some(data); + } + + self.remainder = data.split_off(split); + if data.is_empty() { + None + } else { + Some(data) + } + } + + fn finish(&mut self) -> Option> { + if self.remainder.is_empty() { + None + } else { + Some(std::mem::take(&mut self.remainder)) + } + } +} + +/// Try to send data through the output channel with rate-limited drop logging. +/// Returns `true` if the caller should break out of the read loop (channel disconnected). +fn try_send_output( + output_tx: &mpsc::SyncSender>, + data: Vec, + terminal_id: i32, + label: &str, + drop_count: &mut u64, + last_drop_warn: &mut Instant, +) -> bool { + match output_tx.try_send(data) { + Ok(_) => { + if *drop_count > 0 { + log::trace!( + "Terminal {}{} output channel recovered, dropped {} chunks since last report", + terminal_id, + label, + *drop_count + ); + *drop_count = 0; + } + false + } + Err(mpsc::TrySendError::Full(_)) => { + *drop_count += 1; + if last_drop_warn.elapsed() >= Duration::from_secs(5) { + log::trace!( + "Terminal {}{} output channel full, dropped {} chunks in last {:?}", + terminal_id, + label, + *drop_count, + last_drop_warn.elapsed() + ); + *drop_count = 0; + *last_drop_warn = Instant::now(); + } + false + } + Err(mpsc::TrySendError::Disconnected(_)) => { + log::debug!( + "Terminal {}{} output channel disconnected", + terminal_id, + label + ); + true + } + } +} + +pub struct TerminalSession { + pub created_at: Instant, + last_activity: Instant, + pty_pair: Option, + child: Option>, + // Channel for sending input to the writer thread + input_tx: Option>>, + // Channel for receiving output from the reader thread + output_rx: Option>>, + exiting: Arc, + // Thread handles + reader_thread: Option>, + writer_thread: Option>, + output_buffer: OutputBuffer, + title: String, + pid: u32, + rows: u16, + cols: u16, + // Track if we've already sent the closed message + closed_message_sent: bool, + // Session state machine for reconnection handling + state: SessionState, + // Helper mode: PTY is managed by helper process, communication via message protocol + #[cfg(target_os = "windows")] + is_helper_mode: bool, + // Handle to helper process for termination when session closes + #[cfg(target_os = "windows")] + helper_process_handle: Option, +} + +impl TerminalSession { + fn new(terminal_id: i32, rows: u16, cols: u16) -> Self { + Self { + created_at: Instant::now(), + last_activity: Instant::now(), + pty_pair: None, + child: None, + input_tx: None, + output_rx: None, + exiting: Arc::new(AtomicBool::new(false)), + reader_thread: None, + writer_thread: None, + output_buffer: OutputBuffer::new(), + title: format!("Terminal {}", terminal_id), + pid: 0, + rows, + cols, + closed_message_sent: false, + state: SessionState::Closed, + #[cfg(target_os = "windows")] + is_helper_mode: false, + #[cfg(target_os = "windows")] + helper_process_handle: None, + } + } + + fn update_activity(&mut self) { + self.last_activity = Instant::now(); + } + + // This helper function is to ensure that the threads are joined before the child process is dropped. + // Though this is not strictly necessary on macOS. + fn stop(&mut self) { + self.state = SessionState::Closed; + self.exiting.store(true, Ordering::SeqCst); + + // Drop the input channel to signal writer thread to exit + if let Some(input_tx) = self.input_tx.take() { + // Send a final newline to ensure the reader can read some data, and then exit. + // This is required on Windows and Linux. + // Although `self.pty_pair = None;` is called below, we can still send a final newline here. + #[cfg(target_os = "windows")] + let final_msg = if self.is_helper_mode { + encode_helper_message(MSG_TYPE_DATA, b"\r\n") + } else { + b"\r\n".to_vec() + }; + #[cfg(not(target_os = "windows"))] + let final_msg = b"\r\n".to_vec(); + + if let Err(e) = input_tx.send(final_msg) { + log::warn!("Failed to send final newline to the terminal: {}", e); + } + drop(input_tx); + } + self.output_rx = None; + + // CRITICAL: In helper mode, we must terminate the helper process BEFORE joining threads! + // The reader thread is blocking on output_pipe.read(), which only returns EOF when + // the helper process exits. If we try to join the reader thread first, we deadlock. + // + // Sequence for helper mode: + // 1. Signal exiting and close input channel (done above) + // 2. Terminate helper process (causes output pipe EOF) + // 3. Join reader thread (now unblocked due to EOF) + // 4. Join writer thread + #[cfg(target_os = "windows")] + if self.is_helper_mode { + if let Some(helper_handle) = self.helper_process_handle.take() { + let handle = helper_handle.as_raw(); + log::debug!("Helper mode: terminating helper process before joining threads..."); + + // Give helper a very short time to exit gracefully (it should detect pipe close) + // But don't wait too long - we need to unblock the reader thread + let wait_result = unsafe { WinWaitForSingleObject(handle, 100) }; + + if wait_result == WIN_WAIT_OBJECT_0 { + log::debug!("Helper process exited gracefully"); + } else { + // Force terminate to unblock reader thread + log::debug!("Force terminating helper process to unblock reader thread"); + unsafe { + let _ = WinTerminateProcess(handle, 0); + } + } + + unsafe { + let _ = WinCloseHandle(handle); + } + } + } + + // 1. Windows (non-helper mode) + // `pty_pair` uses pipe. https://github.com/rustdesk-org/wezterm/blob/80174f8009f41565f0fa8c66dab90d4f9211ae16/pty/src/win/conpty.rs#L16 + // `read()` may stuck at https://github.com/rustdesk-org/wezterm/blob/80174f8009f41565f0fa8c66dab90d4f9211ae16/filedescriptor/src/windows.rs#L345 + // We can close the pipe to signal the reader thread to exit. + // After https://github.com/rustdesk-org/wezterm/blob/80174f8009f41565f0fa8c66dab90d4f9211ae16/pty/src/win/psuedocon.rs#L86, the reader reads `[27, 91, 63, 57, 48, 48, 49, 108, 27, 91, 63, 49, 48, 48, 52, 108]` in my tests. + // 2. Linux + // `pty_pair` uses `libc::openpty`. https://github.com/rustdesk-org/wezterm/blob/80174f8009f41565f0fa8c66dab90d4f9211ae16/pty/src/unix.rs#L32 + // We can also call the drop method first. https://github.com/rustdesk-org/wezterm/blob/80174f8009f41565f0fa8c66dab90d4f9211ae16/pty/src/unix.rs#L352 + // The reader will get [13, 10] after dropping the `pty_pair`. + // 3. macOS + // No stuck cases have been found so far, more testing is needed. + #[cfg(any(target_os = "windows", target_os = "linux"))] + { + self.pty_pair = None; + } + + // Wait for threads to finish + // The reader thread should join before the writer thread on Windows. + if let Some(reader_thread) = self.reader_thread.take() { + let _ = reader_thread.join(); + } + + // The read can read the last "\r\n" after the writer thread (not the child process) exits + // on Linux in my tests. + // But we still send "\r\n" to the writer thread and let the reader thread exit first for safety. + if let Some(writer_thread) = self.writer_thread.take() { + let _ = writer_thread.join(); + } + + if let Some(mut child) = self.child.take() { + // Kill the process + let _ = child.kill(); + add_to_reaper(child); + } + } +} + +impl Drop for TerminalSession { + fn drop(&mut self) { + // Ensure child process is properly handled when session is dropped + self.stop(); + } +} + +/// Persistent terminal service that can survive connection drops +pub struct PersistentTerminalService { + service_id: String, + sessions: HashMap>>, + pub created_at: Instant, + last_activity: Instant, + pub is_persistent: bool, + needs_session_sync: bool, + is_specified_user: bool, +} + +impl PersistentTerminalService { + pub fn new(service_id: String, is_persistent: bool, is_specified_user: bool) -> Self { + Self { + service_id, + sessions: HashMap::new(), + created_at: Instant::now(), + last_activity: Instant::now(), + is_persistent, + needs_session_sync: false, + is_specified_user, + } + } + + fn update_activity(&mut self) { + self.last_activity = Instant::now(); + } + + /// Get list of terminal metadata + pub fn list_terminals(&self) -> Vec<(i32, String, u32, Instant)> { + self.sessions + .iter() + .map(|(id, session)| { + let s = session.lock().unwrap(); + (*id, s.title.clone(), s.pid, s.created_at) + }) + .collect() + } + + /// Get buffered output for a terminal + pub fn get_terminal_buffer(&self, terminal_id: i32, max_bytes: usize) -> Option> { + self.sessions.get(&terminal_id).map(|session| { + let session = session.lock().unwrap(); + session.output_buffer.get_recent(max_bytes) + }) + } + + /// Get terminal info for recovery + pub fn get_terminal_info(&self, terminal_id: i32) -> Option<(u16, u16, Vec)> { + self.sessions.get(&terminal_id).map(|session| { + let session = session.lock().unwrap(); + ( + session.rows, + session.cols, + session + .output_buffer + .get_recent(DEFAULT_RECONNECT_BUFFER_BYTES), + ) + }) + } + + /// Check if service has active terminals + pub fn has_active_terminals(&self) -> bool { + !self.sessions.is_empty() + } + + fn reset_status(&mut self, is_persistent: bool) { + self.is_persistent = is_persistent; + self.needs_session_sync = true; + for session in self.sessions.values() { + let mut session = session.lock().unwrap(); + session.state = SessionState::Closed; + } + } +} + +pub struct TerminalServiceProxy { + service_id: String, + is_persistent: bool, + #[cfg(target_os = "windows")] + user_token: Option, +} + +pub fn set_persistent(service_id: &str, is_persistent: bool) -> Result<()> { + if let Some(service) = get_service(service_id) { + service.lock().unwrap().is_persistent = is_persistent; + Ok(()) + } else { + Err(anyhow!("Service {} not found", service_id)) + } +} + +impl TerminalServiceProxy { + pub fn new( + service_id: String, + is_persistent: Option, + _user_token: Option, + ) -> Self { + // Get persistence from the service if it exists + let is_persistent = + is_persistent.unwrap_or(if let Some(service) = get_service(&service_id) { + service.lock().unwrap().is_persistent + } else { + false + }); + TerminalServiceProxy { + service_id, + is_persistent, + #[cfg(target_os = "windows")] + user_token: _user_token, + } + } + + pub fn get_service_id(&self) -> &str { + &self.service_id + } + + pub fn handle_action(&mut self, action: &TerminalAction) -> Result> { + let service = match get_service(&self.service_id) { + Some(s) => s, + None => { + let mut response = TerminalResponse::new(); + let mut error = TerminalError::new(); + error.message = format!("Terminal service {} not found", self.service_id); + response.set_error(error); + return Ok(Some(response)); + } + }; + service.lock().unwrap().update_activity(); + match &action.union { + Some(terminal_action::Union::Open(open)) => { + self.handle_open(&mut service.lock().unwrap(), open) + } + Some(terminal_action::Union::Resize(resize)) => { + let session = service + .lock() + .unwrap() + .sessions + .get(&resize.terminal_id) + .cloned(); + self.handle_resize(session, resize) + } + Some(terminal_action::Union::Data(data)) => { + let session = service + .lock() + .unwrap() + .sessions + .get(&data.terminal_id) + .cloned(); + self.handle_data(session, data) + } + Some(terminal_action::Union::Close(close)) => { + self.handle_close(&mut service.lock().unwrap(), close) + } + _ => Ok(None), + } + } + + fn handle_open( + &self, + service: &mut PersistentTerminalService, + open: &OpenTerminal, + ) -> Result> { + let mut response = TerminalResponse::new(); + + // When the client requests a terminal_id that doesn't exist but there are + // surviving persistent sessions, remap the lowest-ID session to the requested + // terminal_id. This handles the case where _nextTerminalId resets to 1 on + // reconnect but the server-side sessions have non-contiguous IDs (e.g. {2: htop}). + // + // The client's requested terminal_id may not match any surviving session ID + // (e.g. _nextTerminalId incremented beyond the surviving IDs). This remap is a + // one-time handle reassignment — only the first reconnect triggers it because + // needs_session_sync is cleared afterward. Remaining sessions are communicated + // back via `persistent_sessions` with their original server-side IDs. + if !service.sessions.contains_key(&open.terminal_id) + && service.needs_session_sync + && !service.sessions.is_empty() + { + if let Some(&lowest_id) = service.sessions.keys().min() { + log::info!( + "Remapping persistent session {} -> {} for reconnection", + lowest_id, + open.terminal_id + ); + if let Some(session_arc) = service.sessions.remove(&lowest_id) { + service.sessions.insert(open.terminal_id, session_arc); + } + } + } + + // Check if terminal already exists + if let Some(session_arc) = service.sessions.get(&open.terminal_id) { + // Reconnect to existing terminal + let mut session = session_arc.lock().unwrap(); + // Directly enter Active state with pending replay for immediate streaming. + // The replay combines output_buffer history and the channel backlog that was + // already pending at reconnect time so the client can suppress stale xterm + // query answers without requiring a protobuf schema change. + // During disconnect, read_outputs() is not called; channel data can still be lost + // if output_rx fills before reconnect drains it. + let mut buffer = session + .output_buffer + .get_recent(DEFAULT_RECONNECT_BUFFER_BYTES); + let mut reconnect_backlog = Vec::new(); + if let Some(output_rx) = &session.output_rx { + // Cap reconnect-time drain so a chatty PTY cannot keep OpenTerminal + // inside this loop indefinitely. Remaining output is drained by read_outputs(). + for _ in 0..CHANNEL_BUFFER_SIZE { + let Ok(data) = output_rx.try_recv() else { + break; + }; + reconnect_backlog.push(data); + } + } + let has_reconnect_backlog = !reconnect_backlog.is_empty(); + for data in reconnect_backlog { + session.output_buffer.append(&data); + } + if has_reconnect_backlog { + buffer = session + .output_buffer + .get_recent(DEFAULT_RECONNECT_BUFFER_BYTES); + } + let has_pending = !buffer.is_empty(); + session.state = SessionState::Active { + pending_buffer: if has_pending { Some(buffer) } else { None }, + // Always trigger two-phase SIGWINCH on reconnect to force TUI app redraw, + // regardless of whether there's pending buffer data. This avoids edge cases + // where buffer is empty but a TUI app (top/htop) still needs a full redraw. + sigwinch: SigwinchPhase::TempResize { + retries: MAX_SIGWINCH_PHASE_ATTEMPTS, + }, + }; + let mut opened = TerminalOpened::new(); + opened.terminal_id = open.terminal_id; + opened.success = true; + opened.message = if has_pending { + "Reconnected to existing terminal with pending output".to_string() + } else { + "Reconnected to existing terminal".to_string() + }; + opened.pid = session.pid; + opened.service_id = self.service_id.clone(); + opened.replay_terminal_output = has_pending; + if service.needs_session_sync { + if service.sessions.len() > 1 { + // No need to include the current terminal in the list. + // Because the `persistent_sessions` is used to restore the other sessions. + opened.persistent_sessions = service + .sessions + .keys() + .filter(|&id| *id != open.terminal_id) + .cloned() + .collect(); + } + service.needs_session_sync = false; + } + response.set_opened(opened); + + return Ok(Some(response)); + } + + // Windows with user_token: use helper process to run shell as the logged-in user + // This solves the ConPTY + CreateProcessAsUserW incompatibility issue where + // vim, Claude Code, and other TUI applications hang when ConPTY is created + // by SYSTEM service but shell runs as user via CreateProcessAsUserW. + #[cfg(target_os = "windows")] + if self.user_token.is_some() { + return self.handle_open_with_helper(service, open); + } + + // Create new terminal session + log::info!( + "Creating new terminal {} for service {}", + open.terminal_id, + service.service_id + ); + let mut session = + TerminalSession::new(open.terminal_id, open.rows as u16, open.cols as u16); + + let pty_size = PtySize { + rows: open.rows as u16, + cols: open.cols as u16, + pixel_width: 0, + pixel_height: 0, + }; + + log::debug!("Opening PTY with size: {}x{}", open.rows, open.cols); + let pty_system = portable_pty::native_pty_system(); + let pty_pair = pty_system.openpty(pty_size).context("Failed to open PTY")?; + + // Use default shell for the platform + let shell = get_default_shell(); + log::debug!("Using shell: {}", shell); + + #[allow(unused_mut)] + let mut cmd = CommandBuilder::new(&shell); + + #[cfg(target_os = "windows")] + configure_utf8_shell_command(&shell, &mut cmd); + + // macOS-specific terminal configuration + // 1. Use login shell (-l) to load user's shell profile (~/.zprofile, ~/.bash_profile) + // This ensures PATH includes Homebrew paths (/opt/homebrew/bin, /usr/local/bin) + // 2. Set TERM environment variable for proper terminal behavior + // This fixes issues with control sequences (e.g., Delete/Backspace keys) + // macOS terminfo uses hex naming: '78' = 'x' for xterm entries + // Note: For Linux, `TERM` is set in src/platform/linux.rs try_start_server_() + #[cfg(target_os = "macos")] + { + // Start as login shell to load user environment (PATH, etc.) + cmd.arg("-l"); + log::debug!("Added -l flag for macOS login shell"); + + let term = if std::path::Path::new("/usr/share/terminfo/78/xterm-256color").exists() { + "xterm-256color" + } else { + "xterm" + }; + cmd.env("TERM", term); + log::debug!("Set TERM={} for macOS PTY", term); + + if should_force_process_utf8_ctype() { + cmd.env_remove("LC_ALL"); + cmd.env("LC_CTYPE", "en_US.UTF-8"); + log::debug!("Set LC_CTYPE=en_US.UTF-8 for macOS PTY"); + } + } + + // Note: On Windows with user_token, we use helper mode (handle_open_with_helper) + // which is dispatched earlier in this function. This code path is only reached + // when user_token is None (e.g., running directly as user, not as SYSTEM service). + + log::debug!("Spawning shell process..."); + let child = pty_pair + .slave + .spawn_command(cmd) + .context("Failed to spawn command")?; + + let writer = pty_pair + .master + .take_writer() + .context("Failed to get writer")?; + + let reader = pty_pair + .master + .try_clone_reader() + .context("Failed to get reader")?; + + session.pid = child.process_id().unwrap_or(0) as u32; + + // Create channels for input/output + let (input_tx, input_rx) = mpsc::sync_channel::>(CHANNEL_BUFFER_SIZE); + let (output_tx, output_rx) = mpsc::sync_channel::>(CHANNEL_BUFFER_SIZE); + + // Spawn writer thread + let terminal_id = open.terminal_id; + let writer_thread = thread::spawn(move || { + let mut writer = writer; + while let Ok(data) = input_rx.recv() { + if let Err(e) = writer.write_all(&data) { + log::error!("Terminal {} write error: {}", terminal_id, e); + break; + } + if let Err(e) = writer.flush() { + log::error!("Terminal {} flush error: {}", terminal_id, e); + } + } + log::debug!("Terminal {} writer thread exiting", terminal_id); + }); + + let exiting = session.exiting.clone(); + // Spawn reader thread + let terminal_id = open.terminal_id; + let reader_thread = thread::spawn(move || { + let mut reader = reader; + let mut buf = vec![0u8; 4096]; + let mut utf8_chunks = Utf8ChunkAccumulator::default(); + let mut drop_count: u64 = 0; + // Initialize to > 5s ago so the first drop triggers a warning immediately. + let mut last_drop_warn = Instant::now() - Duration::from_secs(6); + loop { + match reader.read(&mut buf) { + Ok(0) => { + // EOF + // This branch can be reached when the child process exits on macOS. + // But not on Linux and Windows in my tests. + if let Some(data) = utf8_chunks.finish() { + let _ = try_send_output( + &output_tx, + data, + terminal_id, + "", + &mut drop_count, + &mut last_drop_warn, + ); + } + break; + } + Ok(n) => { + if exiting.load(Ordering::SeqCst) { + break; + } + let Some(data) = utf8_chunks.push_chunk(buf[..n].to_vec()) else { + continue; + }; + // Use try_send to avoid blocking the reader thread when channel is full. + // During disconnect, the run loop (sp.ok()) stops and read_outputs() is + // no longer called, so the channel won't be drained. Blocking send would + // deadlock the reader thread in that case. + // Note: data produced during disconnect may be lost if channel fills up, + // since output_buffer is only updated in read_outputs(). The buffer will + // contain history from before the disconnect, not data produced after it. + if try_send_output( + &output_tx, + data, + terminal_id, + "", + &mut drop_count, + &mut last_drop_warn, + ) { + break; + } + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + // This branch is not reached in my tests, but we still add `exiting` check to ensure we can exit. + if exiting.load(Ordering::SeqCst) { + break; + } + // For non-blocking I/O, sleep briefly + thread::sleep(Duration::from_millis(10)); + } + Err(e) => { + log::error!("Terminal {} read error: {}", terminal_id, e); + break; + } + } + } + log::debug!("Terminal {} reader thread exiting", terminal_id); + }); + + session.pty_pair = Some(pty_pair); + session.child = Some(child); + session.input_tx = Some(input_tx); + session.output_rx = Some(output_rx); + session.reader_thread = Some(reader_thread); + session.writer_thread = Some(writer_thread); + session.state = SessionState::Active { + pending_buffer: None, + sigwinch: SigwinchPhase::Idle, + }; + + let mut opened = TerminalOpened::new(); + opened.terminal_id = open.terminal_id; + opened.success = true; + opened.message = "Terminal opened".to_string(); + opened.pid = session.pid; + opened.service_id = service.service_id.clone(); + if service.needs_session_sync { + if !service.sessions.is_empty() { + opened.persistent_sessions = service.sessions.keys().cloned().collect(); + } + service.needs_session_sync = false; + } + response.set_opened(opened); + + log::info!( + "Terminal {} opened successfully with PID {}", + open.terminal_id, + session.pid + ); + + // Store the session + service + .sessions + .insert(open.terminal_id, Arc::new(Mutex::new(session))); + + Ok(Some(response)) + } + + /// Windows-only: Open terminal using helper process pattern + /// This solves the ConPTY + CreateProcessAsUserW incompatibility issue. + /// The helper process runs as the logged-in user and creates ConPTY + shell, + /// communicating with this service via named pipes. + #[cfg(target_os = "windows")] + fn handle_open_with_helper( + &self, + service: &mut PersistentTerminalService, + open: &OpenTerminal, + ) -> Result> { + let mut response = TerminalResponse::new(); + + log::info!( + "Creating new terminal {} using helper process for service: {}", + open.terminal_id, + service.service_id + ); + + let mut session = + TerminalSession::new(open.terminal_id, open.rows as u16, open.cols as u16); + + // Generate unique pipe names for this terminal + let pipe_id = uuid::Uuid::new_v4(); + let input_pipe_name = format!(r"\\.\pipe\rustdesk_term_in_{}", pipe_id); + let output_pipe_name = format!(r"\\.\pipe\rustdesk_term_out_{}", pipe_id); + + log::debug!( + "Creating pipes: input={}, output={}", + input_pipe_name, + output_pipe_name + ); + + // Get user_token early - needed for both DACL creation and helper launch + let user_token = self + .user_token + .ok_or_else(|| anyhow!("user_token is required for helper mode"))?; + + // Create pipes (server side, don't wait for connection yet) + // input_pipe: service WRITES to this, helper READS from this + // output_pipe: service READS from this, helper WRITES to this + // Using OwnedHandle for RAII - handles are automatically closed on error + // Pass user_token to create restricted DACL (only SYSTEM + user can access) + let input_pipe_handle = OwnedHandle::new(create_named_pipe_server( + &input_pipe_name, + false, + user_token, + )?); + let output_pipe_handle = OwnedHandle::new(create_named_pipe_server( + &output_pipe_name, + true, + user_token, + )?); + + let helper_process_info = launch_terminal_helper_with_token( + user_token, + &input_pipe_name, + &output_pipe_name, + open.terminal_id, + open.rows as u16, + open.cols as u16, + )?; + + // Use HelperProcessGuard for RAII cleanup - terminates process on error + // Unlike OwnedHandle which only closes the handle, this guard ensures + // the helper process is terminated if pipe connection fails or other errors occur. + let helper_process_guard = + HelperProcessGuard::new(helper_process_info.handle, helper_process_info.pid); + let helper_pid = helper_process_guard.pid(); + + // Wait for helper to connect to pipes + // If this fails, HelperProcessGuard will terminate the helper process + let mut input_pipe = wait_for_pipe_connection( + input_pipe_handle, + &input_pipe_name, + PIPE_CONNECTION_TIMEOUT_MS, + )?; + let mut output_pipe = wait_for_pipe_connection( + output_pipe_handle, + &output_pipe_name, + PIPE_CONNECTION_TIMEOUT_MS, + )?; + + // Check if helper process is still running after pipe connection + // This provides early detection if helper crashed during startup + if !is_helper_process_running(helper_process_guard.as_raw()) { + return Err(anyhow!( + "Helper process (PID {}) exited unexpectedly after pipe connection", + helper_pid + )); + } + + // Disarm the guard and transfer ownership to session + // From this point, the session is responsible for terminating the helper + let helper_raw_handle = helper_process_guard.disarm(); + + // Use helper process PID for session tracking + // Note: This is the helper process PID, not the actual shell PID. + // The real shell runs inside the helper process but its PID is not exposed here. + // For process management (termination, status), the helper PID is what we need. + session.pid = helper_pid; + + // Create channels for input/output (same as direct PTY mode) + let (input_tx, input_rx) = mpsc::sync_channel::>(CHANNEL_BUFFER_SIZE); + let (output_tx, output_rx) = mpsc::sync_channel::>(CHANNEL_BUFFER_SIZE); + + // Spawn writer thread: reads from channel, writes to input pipe + let terminal_id = open.terminal_id; + let writer_thread = thread::spawn(move || { + while let Ok(data) = input_rx.recv() { + if let Err(e) = input_pipe.write_all(&data) { + log::error!("Terminal {} pipe write error: {}", terminal_id, e); + break; + } + if let Err(e) = input_pipe.flush() { + log::error!("Terminal {} pipe flush error: {}", terminal_id, e); + } + } + log::debug!( + "Terminal {} writer thread (helper mode) exiting", + terminal_id + ); + }); + + // Spawn reader thread: reads from output pipe, sends to channel + // Note: The output pipe was created with FILE_FLAG_OVERLAPPED for timeout support + // during ConnectNamedPipe. However, once converted to a File handle, reads are + // performed synchronously. The WouldBlock handling below is defensive but may + // not be triggered in practice since File::read() blocks until data is available. + let exiting = session.exiting.clone(); + let terminal_id = open.terminal_id; + let reader_thread = thread::spawn(move || { + let mut buf = vec![0u8; 4096]; + let mut utf8_chunks = Utf8ChunkAccumulator::default(); + let mut drop_count: u64 = 0; + // Initialize to > 5s ago so the first drop triggers a warning immediately. + let mut last_drop_warn = Instant::now() - Duration::from_secs(6); + loop { + match output_pipe.read(&mut buf) { + Ok(0) => { + if let Some(data) = utf8_chunks.finish() { + let _ = try_send_output( + &output_tx, + data, + terminal_id, + " (helper)", + &mut drop_count, + &mut last_drop_warn, + ); + } + // EOF - helper process exited + log::debug!("Terminal {} helper output EOF", terminal_id); + break; + } + Ok(n) => { + if exiting.load(Ordering::SeqCst) { + break; + } + let Some(data) = utf8_chunks.push_chunk(buf[..n].to_vec()) else { + continue; + }; + // Use try_send to avoid blocking the reader thread (same as direct PTY mode) + if try_send_output( + &output_tx, + data, + terminal_id, + " (helper)", + &mut drop_count, + &mut last_drop_warn, + ) { + break; + } + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + // Defensive: WouldBlock is unlikely with synchronous File::read(), + // but handle it gracefully just in case. + if exiting.load(Ordering::SeqCst) { + break; + } + thread::sleep(Duration::from_millis(10)); + } + Err(e) => { + log::error!("Terminal {} pipe read error: {}", terminal_id, e); + break; + } + } + } + log::debug!( + "Terminal {} reader thread (helper mode) exiting", + terminal_id + ); + }); + + // In helper mode, we don't have pty_pair or child - helper manages those + session.pty_pair = None; + session.child = None; + session.input_tx = Some(input_tx); + session.output_rx = Some(output_rx); + session.reader_thread = Some(reader_thread); + session.writer_thread = Some(writer_thread); + session.state = SessionState::Active { + pending_buffer: None, + sigwinch: SigwinchPhase::Idle, + }; + session.is_helper_mode = true; + session.helper_process_handle = Some(SendableHandle::new(helper_raw_handle)); + + let mut opened = TerminalOpened::new(); + opened.terminal_id = open.terminal_id; + opened.success = true; + opened.message = "Terminal opened (helper mode)".to_string(); + opened.pid = session.pid; + opened.service_id = service.service_id.clone(); + if service.needs_session_sync { + if !service.sessions.is_empty() { + opened.persistent_sessions = service.sessions.keys().cloned().collect(); + } + service.needs_session_sync = false; + } + response.set_opened(opened); + + log::info!( + "Terminal {} opened successfully using helper process (PID {})", + open.terminal_id, + session.pid + ); + + service + .sessions + .insert(open.terminal_id, Arc::new(Mutex::new(session))); + + Ok(Some(response)) + } + + fn handle_resize( + &self, + session: Option>>, + resize: &ResizeTerminal, + ) -> Result> { + if let Some(session_arc) = session { + let mut session = session_arc.lock().unwrap(); + session.update_activity(); + session.rows = resize.rows as u16; + session.cols = resize.cols as u16; + + // Note: we do NOT clear the sigwinch phase here. The server-side two-phase + // SIGWINCH mechanism in read_outputs() is self-contained (temp resize → restore + // across two polling cycles), so client resize is purely a dimension sync and + // doesn't affect it. + + // Windows: handle helper mode vs direct PTY mode + #[cfg(target_os = "windows")] + { + if session.is_helper_mode { + // Helper mode: send resize command via message protocol + if let Some(input_tx) = &session.input_tx { + let msg = encode_resize_message(resize.rows as u16, resize.cols as u16); + if let Err(e) = input_tx.send(msg) { + log::error!("Failed to send resize to helper: {}", e); + } + } else { + log::warn!( + "Terminal {} is in helper mode but input_tx is None, cannot send resize", + resize.terminal_id + ); + } + } else { + // Direct PTY mode + Self::resize_pty(&session, resize)?; + } + } + + // Non-Windows: always direct PTY mode + #[cfg(not(target_os = "windows"))] + { + Self::resize_pty(&session, resize)?; + } + } + Ok(None) + } + + /// Resize PTY directly (used for non-helper mode) + fn resize_pty(session: &TerminalSession, resize: &ResizeTerminal) -> Result<()> { + if let Some(pty_pair) = &session.pty_pair { + pty_pair.master.resize(PtySize { + rows: resize.rows as u16, + cols: resize.cols as u16, + pixel_width: 0, + pixel_height: 0, + })?; + } + Ok(()) + } + + fn handle_data( + &self, + session: Option>>, + data: &TerminalData, + ) -> Result> { + if let Some(session_arc) = session { + let input = { + let mut session = session_arc.lock().unwrap(); + session.update_activity(); + if let Some(input_tx) = session.input_tx.clone() { + // Encode data for helper mode or send raw for direct PTY mode + #[cfg(target_os = "windows")] + let msg = if session.is_helper_mode { + encode_helper_message(MSG_TYPE_DATA, &data.data) + } else { + data.data.to_vec() + }; + #[cfg(not(target_os = "windows"))] + let msg = data.data.to_vec(); + + Some((input_tx, msg)) + } else { + None + } + }; + + if let Some((input_tx, msg)) = input { + // Send outside the session lock; SyncSender::send can block when full. + if let Err(e) = input_tx.send(msg) { + log::error!( + "Failed to send data to terminal {}: {}", + data.terminal_id, + e + ); + } + } + } + + Ok(None) + } + + fn handle_close( + &self, + service: &mut PersistentTerminalService, + close: &CloseTerminal, + ) -> Result> { + let mut response = TerminalResponse::new(); + + // Always close and remove the terminal + if let Some(session_arc) = service.sessions.remove(&close.terminal_id) { + let mut session = session_arc.lock().unwrap(); + let exit_code = if let Some(mut child) = session.child.take() { + child.kill()?; + add_to_reaper(child); + -1 // -1 indicates forced termination + } else { + 0 + }; + + let mut closed = TerminalClosed::new(); + closed.terminal_id = close.terminal_id; + closed.exit_code = exit_code; + response.set_closed(closed); + Ok(Some(response)) + } else { + Ok(None) + } + } + + /// Perform a single PTY resize as part of the two-phase SIGWINCH sequence. + /// Returns true if the resize succeeded. + /// + /// Takes individual field references to avoid borrowing the entire TerminalSession, + /// which would conflict with the mutable borrow of session.state in read_outputs(). + fn do_sigwinch_resize( + terminal_id: i32, + rows: u16, + cols: u16, + pty_pair: &Option, + input_tx: &Option>>, + _is_helper_mode: bool, + action: &SigwinchAction, + ) -> bool { + // Skip if dimensions are not initialized (shouldn't happen on reconnect, + // but guard against it to avoid resizing to nonsensical values). + if rows == 0 || cols == 0 { + return false; + } + + let target_rows = match action { + SigwinchAction::TempResize => { + // For very small terminals (≤2 rows), subtracting 1 would result in an unusable + // size (0 or 1 row), so we add 1 instead. Either direction triggers SIGWINCH. + if rows > 2 { + rows.saturating_sub(1) + } else { + rows.saturating_add(1) + } + } + SigwinchAction::Restore => rows, + }; + + let phase_name = match action { + SigwinchAction::TempResize => "temp resize", + SigwinchAction::Restore => "restore", + }; + + #[cfg(target_os = "windows")] + let use_helper = _is_helper_mode; + #[cfg(not(target_os = "windows"))] + let use_helper = false; + + if use_helper { + #[cfg(target_os = "windows")] + { + let input_tx = match input_tx { + Some(tx) => tx, + None => return false, + }; + let msg = encode_resize_message(target_rows, cols); + if let Err(e) = input_tx.try_send(msg) { + log::warn!( + "Terminal {} SIGWINCH {} via helper failed: {}", + terminal_id, + phase_name, + e + ); + return false; + } + true + } + #[cfg(not(target_os = "windows"))] + { + let _ = (input_tx, phase_name); + false + } + } else if let Some(pty_pair) = pty_pair { + if let Err(e) = pty_pair.master.resize(PtySize { + rows: target_rows, + cols, + pixel_width: 0, + pixel_height: 0, + }) { + log::warn!( + "Terminal {} SIGWINCH {} failed: {}", + terminal_id, + phase_name, + e + ); + return false; + } + true + } else { + false + } + } + + /// Helper to create a TerminalResponse with optional compression. + fn create_terminal_data_response(terminal_id: i32, data: Vec) -> TerminalResponse { + let mut response = TerminalResponse::new(); + let mut terminal_data = TerminalData::new(); + terminal_data.terminal_id = terminal_id; + + if data.len() > COMPRESS_THRESHOLD { + let compressed = compress::compress(&data); + if compressed.len() < data.len() { + terminal_data.data = bytes::Bytes::from(compressed); + terminal_data.compressed = true; + } else { + terminal_data.data = bytes::Bytes::from(data); + } + } else { + terminal_data.data = bytes::Bytes::from(data); + } + + response.set_data(terminal_data); + response + } + + pub fn read_outputs(&self) -> Vec { + let service = match get_service(&self.service_id) { + Some(s) => s, + None => { + return vec![]; + } + }; + + // Get session references with minimal service lock time + let sessions: Vec<(i32, Arc>)> = { + let service = service.lock().unwrap(); + service + .sessions + .iter() + .map(|(id, session)| (*id, session.clone())) + .collect() + }; + + let mut responses = Vec::new(); + let mut closed_terminals = Vec::new(); + + // Process each session with its own lock + for (terminal_id, session_arc) in sessions { + if let Ok(mut session) = session_arc.try_lock() { + // Check if reader thread is still alive and we haven't sent closed message yet + let mut should_send_closed = false; + if !session.closed_message_sent { + if let Some(thread) = &session.reader_thread { + if thread.is_finished() { + should_send_closed = true; + session.closed_message_sent = true; + } + } + } + // It's Ok to put the closed message here. + // Because the `reader_thread` is joined in `stop()`, + // and `stop()` is called before the session is dropped. + if should_send_closed { + closed_terminals.push(terminal_id); + } + + // Always drain the output channel regardless of session state. + // When Active: data is sent to client. When Closed (within the same + // connection): data is buffered in output_buffer for reconnection replay. + // Note: during actual disconnect, the run loop exits and read_outputs() + // is not called, so channel data produced after disconnect may be lost. + let mut has_activity = false; + let mut received_data = Vec::new(); + if let Some(output_rx) = &session.output_rx { + // Try to read all available data + while let Ok(data) = output_rx.try_recv() { + has_activity = true; + received_data.push(data); + } + } + + // Update buffer (always buffer for reconnection support) + for data in &received_data { + session.output_buffer.append(data); + } + + // Skip sending responses if session is not Active. + // Data is already buffered above and will be sent on next reconnection. + // Use a scoped block to limit the mutable borrow of session.state, + // so we can immutably borrow other session fields afterwards. + let (replay_buffer, sigwinch_action) = { + let (pending_buffer, sigwinch) = match &mut session.state { + SessionState::Active { + pending_buffer, + sigwinch, + } => (pending_buffer, sigwinch), + _ => continue, + }; + + let replay_buffer = pending_buffer.take(); + + // Two-phase SIGWINCH: see SigwinchPhase doc comments for rationale. + // Each phase is a single PTY resize, spaced ~30ms apart by the polling + // interval, ensuring the TUI app sees a real size change on each signal. + let sigwinch_action = match sigwinch { + SigwinchPhase::TempResize { retries } => { + if *retries == 0 { + log::warn!( + "Terminal {} SIGWINCH phase 1 (temp resize) failed after {} attempts, giving up", + terminal_id, MAX_SIGWINCH_PHASE_ATTEMPTS + ); + *sigwinch = SigwinchPhase::Idle; + None + } else { + *retries -= 1; + Some(SigwinchAction::TempResize) + } + } + SigwinchPhase::Restore { retries } => { + if *retries == 0 { + log::warn!( + "Terminal {} SIGWINCH phase 2 (restore) failed after {} attempts, giving up", + terminal_id, MAX_SIGWINCH_PHASE_ATTEMPTS + ); + *sigwinch = SigwinchPhase::Idle; + None + } else { + *retries -= 1; + Some(SigwinchAction::Restore) + } + } + SigwinchPhase::Idle => None, + }; + (replay_buffer, sigwinch_action) + }; + + if let Some(buffer) = replay_buffer { + if !buffer.is_empty() { + responses.push(Self::create_terminal_data_response(terminal_id, buffer)); + } + } + + if has_activity { + session.update_activity(); + } + + // Execute SIGWINCH resize outside the mutable borrow scope of session.state. + if let Some(action) = sigwinch_action { + #[cfg(target_os = "windows")] + let is_helper = session.is_helper_mode; + #[cfg(not(target_os = "windows"))] + let is_helper = false; + let resize_ok = Self::do_sigwinch_resize( + terminal_id, + session.rows, + session.cols, + &session.pty_pair, + &session.input_tx, + is_helper, + &action, + ); + if let SessionState::Active { sigwinch, .. } = &mut session.state { + match action { + SigwinchAction::TempResize => { + if resize_ok { + // Phase 1 succeeded — advance to phase 2 (restore). + *sigwinch = SigwinchPhase::Restore { + retries: MAX_SIGWINCH_PHASE_ATTEMPTS, + }; + } + // If failed, retries already decremented; will retry phase 1. + } + SigwinchAction::Restore => { + if resize_ok { + // Phase 2 succeeded — SIGWINCH sequence complete. + *sigwinch = SigwinchPhase::Idle; + } + // If failed, retries already decremented; will retry phase 2. + } + } + } + } + + // Send real-time data after historical buffer + for data in received_data { + responses.push(Self::create_terminal_data_response(terminal_id, data)); + } + } + } + + // Clean up closed terminals (requires service lock briefly) + if !closed_terminals.is_empty() { + let mut sessions = service.lock().unwrap().sessions.clone(); + for terminal_id in closed_terminals { + let mut exit_code = 0; + + if !self.is_persistent { + if let Some(session_arc) = sessions.remove(&terminal_id) { + service.lock().unwrap().sessions.remove(&terminal_id); + let mut session = session_arc.lock().unwrap(); + // Take the child and add to zombie reaper + if let Some(mut child) = session.child.take() { + // Try to get exit code if available + if let Ok(Some(status)) = child.try_wait() { + exit_code = status.exit_code() as i32; + } + add_to_reaper(child); + } + } + } else { + // For persistent sessions, just clear the child reference + if let Some(session_arc) = sessions.get(&terminal_id) { + let mut session = session_arc.lock().unwrap(); + if let Some(mut child) = session.child.take() { + // Try to get exit code if available + if let Ok(Some(status)) = child.try_wait() { + exit_code = status.exit_code() as i32; + } + add_to_reaper(child); + } + } + } + + let mut response = TerminalResponse::new(); + let mut closed = TerminalClosed::new(); + closed.terminal_id = terminal_id; + closed.exit_code = exit_code; + response.set_closed(closed); + responses.push(response); + } + } + + responses + } + + /// Cleanup when connection drops + pub fn on_disconnect(&self) { + if !self.is_persistent { + // Remove non-persistent service + remove_service(&self.service_id); + } + } +} + +#[cfg(test)] +mod tests { + use super::{find_utf8_split_point, OutputBuffer, Utf8ChunkAccumulator, MAX_BUFFER_LINES}; + + #[test] + fn utf8_split_point_returns_full_len_for_complete_input() { + assert_eq!(find_utf8_split_point(b"hello"), 5); + assert_eq!(find_utf8_split_point("中文".as_bytes()), "中文".len()); + assert_eq!(find_utf8_split_point("😀".as_bytes()), "😀".len()); + } + + #[test] + fn utf8_split_point_detects_incomplete_trailing_sequence() { + let data = [b'a', 0xE4, 0xB8]; + assert_eq!(find_utf8_split_point(&data), 1); + } + + #[test] + fn utf8_split_point_keeps_malformed_prefix_but_buffers_trailing_lead_byte() { + let data = [0xFF, 0xE4]; + assert_eq!(find_utf8_split_point(&data), 1); + } + + #[test] + fn utf8_split_point_treats_orphan_continuations_as_complete() { + let data = [0x80, 0x81, 0x82]; + assert_eq!(find_utf8_split_point(&data), data.len()); + } + + #[test] + fn utf8_chunk_accumulator_reassembles_split_multibyte_output() { + let full = "你好世界".as_bytes(); + let mut chunker = Utf8ChunkAccumulator::default(); + let mut output = Vec::new(); + + for chunk in full.chunks(5) { + if let Some(data) = chunker.push_chunk(chunk.to_vec()) { + output.extend_from_slice(&data); + } + } + + if let Some(data) = chunker.finish() { + output.extend_from_slice(&data); + } + + assert_eq!(output, full); + } + + #[test] + fn utf8_chunk_accumulator_buffers_leading_split_multibyte_output() { + let mut chunker = Utf8ChunkAccumulator::default(); + + assert!(chunker.push_chunk(vec![0xE4]).is_none()); + assert!(chunker.push_chunk(vec![0xB8]).is_none()); + assert_eq!( + chunker.push_chunk(vec![0xAD]), + Some("中".as_bytes().to_vec()) + ); + assert!(chunker.finish().is_none()); + } + + #[test] + fn utf8_chunk_accumulator_flushes_incomplete_tail_on_finish() { + let mut chunker = Utf8ChunkAccumulator::default(); + assert_eq!(chunker.push_chunk(vec![b'a', 0xE4]), Some(vec![b'a'])); + assert_eq!(chunker.finish(), Some(vec![0xE4])); + assert!(chunker.finish().is_none()); + } + + #[test] + fn utf8_chunk_accumulator_does_not_stall_on_malformed_bytes() { + let mut chunker = Utf8ChunkAccumulator::default(); + assert_eq!(chunker.push_chunk(vec![0xFF]), Some(vec![0xFF])); + assert!(chunker.finish().is_none()); + } + + #[test] + fn utf8_chunk_accumulator_buffers_lone_utf8_lead_bytes() { + let mut chunker = Utf8ChunkAccumulator::default(); + assert!(chunker.push_chunk(vec![0xE4]).is_none()); + assert_eq!(chunker.finish(), Some(vec![0xE4])); + } + + #[test] + fn utf8_chunk_accumulator_does_not_hold_back_non_utf8_prefixes() { + let mut chunker = Utf8ChunkAccumulator::default(); + assert_eq!(chunker.push_chunk(vec![0xFF, 0xE4]), Some(vec![0xFF, 0xE4])); + assert!(chunker.finish().is_none()); + } + + #[test] + fn output_buffer_trim_after_incomplete_merge_does_not_underflow() { + let mut buffer = OutputBuffer::new(); + + // Create an incomplete line first. + buffer.append(b"hello"); + + // Merge a large chunk that contains the first newline at the tail. + // This exercises the "append to last incomplete line" branch. + let mut large = vec![b'a'; 30_000]; + large.push(b'\n'); + buffer.append(&large); + + // Exceed MAX_BUFFER_LINES so trim pops the first large merged line. + for _ in 0..=MAX_BUFFER_LINES { + buffer.append(b"x\n"); + } + + let actual_size: usize = buffer.lines.iter().map(|line| line.len()).sum(); + assert_eq!(buffer.total_size, actual_size); + } +} diff --git a/src/server/uinput.rs b/src/server/uinput.rs index 60c647862..a1947d79f 100644 --- a/src/server/uinput.rs +++ b/src/server/uinput.rs @@ -90,6 +90,13 @@ pub mod client { } fn key_sequence(&mut self, sequence: &str) { + // Sequence events are normally handled in the --server process before reaching here. + // Forward via IPC as a fallback — input_text_wayland can still handle ASCII chars + // via keysym/uinput, though non-ASCII will be skipped (no clipboard in --service). + log::debug!( + "UInputKeyboard::key_sequence called (len={})", + sequence.len() + ); allow_err!(self.send(Data::Keyboard(DataKeyboard::Sequence(sequence.to_string())))); } @@ -178,6 +185,13 @@ pub mod client { pub mod service { use super::*; use hbb_common::lazy_static; + #[cfg(target_os = "linux")] + use parity_tokio_ipc::Connection as RawIpcConnection; + use scrap::wayland::{ + pipewire::RDP_SESSION_INFO, remote_desktop_portal::OrgFreedesktopPortalRemoteDesktop, + }; + #[cfg(target_os = "linux")] + use std::os::unix::io::AsRawFd; use std::{collections::HashMap, sync::Mutex}; lazy_static::lazy_static! { @@ -239,7 +253,7 @@ pub mod service { (enigo::Key::Select, evdev::Key::KEY_SELECT), (enigo::Key::Print, evdev::Key::KEY_PRINT), // (enigo::Key::Execute, evdev::Key::KEY_EXECUTE), - // (enigo::Key::Snapshot, evdev::Key::KEY_SNAPSHOT), + (enigo::Key::Snapshot, evdev::Key::KEY_SYSRQ), (enigo::Key::Insert, evdev::Key::KEY_INSERT), (enigo::Key::Help, evdev::Key::KEY_HELP), (enigo::Key::Sleep, evdev::Key::KEY_SLEEP), @@ -247,7 +261,7 @@ pub mod service { (enigo::Key::Scroll, evdev::Key::KEY_SCROLLLOCK), (enigo::Key::NumLock, evdev::Key::KEY_NUMLOCK), (enigo::Key::RWin, evdev::Key::KEY_RIGHTMETA), - (enigo::Key::Apps, evdev::Key::KEY_CONTEXT_MENU), + (enigo::Key::Apps, evdev::Key::KEY_COMPOSE), // it's a little strange that the key is mapped to KEY_COMPOSE, not KEY_MENU (enigo::Key::Multiply, evdev::Key::KEY_KPASTERISK), (enigo::Key::Add, evdev::Key::KEY_KPPLUS), (enigo::Key::Subtract, evdev::Key::KEY_KPMINUS), @@ -309,6 +323,9 @@ pub mod service { ('/', (evdev::Key::KEY_SLASH, false)), (';', (evdev::Key::KEY_SEMICOLON, false)), ('\'', (evdev::Key::KEY_APOSTROPHE, false)), + // Space is intentionally in both KEY_MAP_LAYOUT (char-to-evdev for text input) + // and KEY_MAP (Key::Space for key events). Both maps serve different lookup paths. + (' ', (evdev::Key::KEY_SPACE, false)), // Shift + key ('A', (evdev::Key::KEY_A, true)), @@ -364,6 +381,155 @@ pub mod service { static ref RESOLUTION: Mutex<((i32, i32), (i32, i32))> = Mutex::new(((0, 0), (0, 0))); } + /// Input text on Wayland using layout-independent methods. + /// ASCII chars (0x20-0x7E): Portal keysym or uinput fallback + /// Non-ASCII chars: skipped — this runs in the --service (root) process where clipboard + /// operations are unreliable (typically no user session environment). + /// Non-ASCII input is normally handled by the --server process via input_text_via_clipboard_server. + fn input_text_wayland(text: &str, keyboard: &mut VirtualDevice) { + let portal_info = { + let session_info = RDP_SESSION_INFO.lock().unwrap(); + session_info + .as_ref() + .map(|info| (info.conn.clone(), info.session.clone())) + }; + + for c in text.chars() { + let keysym = char_to_keysym(c); + if can_input_via_keysym(c, keysym) { + // Try Portal first — down+up on the same channel + if let Some((ref conn, ref session)) = portal_info { + let portal = scrap::wayland::pipewire::get_portal(conn); + if portal + .notify_keyboard_keysym(session, HashMap::new(), keysym, 1) + .is_ok() + { + if let Err(e) = + portal.notify_keyboard_keysym(session, HashMap::new(), keysym, 0) + { + log::warn!( + "input_text_wayland: portal key-up failed for keysym {:#x}: {:?}", + keysym, + e + ); + } + continue; + } + } + // Portal unavailable or failed, fallback to uinput (down+up together) + let key = enigo::Key::Layout(c); + if let Ok((evdev_key, is_shift)) = map_key(&key) { + let mut shift_pressed = false; + if is_shift { + let shift_down = + InputEvent::new(EventType::KEY, evdev::Key::KEY_LEFTSHIFT.code(), 1); + if keyboard.emit(&[shift_down]).is_ok() { + shift_pressed = true; + } else { + log::warn!("input_text_wayland: failed to press Shift for '{}'", c); + } + } + let key_down = InputEvent::new(EventType::KEY, evdev_key.code(), 1); + let key_up = InputEvent::new(EventType::KEY, evdev_key.code(), 0); + allow_err!(keyboard.emit(&[key_down, key_up])); + if shift_pressed { + let shift_up = + InputEvent::new(EventType::KEY, evdev::Key::KEY_LEFTSHIFT.code(), 0); + allow_err!(keyboard.emit(&[shift_up])); + } + } + } else { + log::debug!("Skipping non-ASCII character in uinput service (no clipboard access)"); + } + } + } + + /// Send a single key down or up event for a Layout character. + /// Used by KeyDown/KeyUp to maintain correct press/release semantics. + /// `down`: true for key press, false for key release. + fn input_char_wayland_key_event(chr: char, down: bool, keyboard: &mut VirtualDevice) { + let keysym = char_to_keysym(chr); + let portal_state: u32 = if down { 1 } else { 0 }; + + if can_input_via_keysym(chr, keysym) { + let portal_info = { + let session_info = RDP_SESSION_INFO.lock().unwrap(); + session_info + .as_ref() + .map(|info| (info.conn.clone(), info.session.clone())) + }; + if let Some((ref conn, ref session)) = portal_info { + let portal = scrap::wayland::pipewire::get_portal(conn); + if portal + .notify_keyboard_keysym(session, HashMap::new(), keysym, portal_state) + .is_ok() + { + return; + } + } + // Portal unavailable or failed, fallback to uinput + let key = enigo::Key::Layout(chr); + if let Ok((evdev_key, is_shift)) = map_key(&key) { + if down { + // Press: Shift↓ (if needed) → Key↓ + if is_shift { + let shift_down = + InputEvent::new(EventType::KEY, evdev::Key::KEY_LEFTSHIFT.code(), 1); + if let Err(e) = keyboard.emit(&[shift_down]) { + log::warn!("input_char_wayland_key_event: failed to press Shift for '{}': {:?}", chr, e); + } + } + let key_down = InputEvent::new(EventType::KEY, evdev_key.code(), 1); + allow_err!(keyboard.emit(&[key_down])); + } else { + // Release: Key↑ → Shift↑ (if needed) + let key_up = InputEvent::new(EventType::KEY, evdev_key.code(), 0); + allow_err!(keyboard.emit(&[key_up])); + if is_shift { + let shift_up = + InputEvent::new(EventType::KEY, evdev::Key::KEY_LEFTSHIFT.code(), 0); + if let Err(e) = keyboard.emit(&[shift_up]) { + log::warn!("input_char_wayland_key_event: failed to release Shift for '{}': {:?}", chr, e); + } + } + } + } + } else { + // Non-ASCII: no reliable down/up semantics available. + // Clipboard paste is atomic and handled elsewhere. + log::debug!( + "Skipping non-ASCII character key {} in uinput service", + if down { "down" } else { "up" } + ); + } + } + + /// Check if character can be input via keysym (ASCII printable with valid keysym). + #[inline] + pub(crate) fn can_input_via_keysym(c: char, keysym: i32) -> bool { + // ASCII printable: 0x20 (space) to 0x7E (tilde) + (c as u32 >= 0x20 && c as u32 <= 0x7E) && keysym != 0 + } + + /// Convert a Unicode character to X11 keysym. + pub(crate) fn char_to_keysym(c: char) -> i32 { + let codepoint = c as u32; + if codepoint == 0 { + // Null character has no keysym + 0 + } else if (0x20..=0x7E).contains(&codepoint) { + // ASCII printable (0x20-0x7E): keysym == Unicode codepoint + codepoint as i32 + } else if (0xA0..=0xFF).contains(&codepoint) { + // Latin-1 supplement (0xA0-0xFF): keysym == Unicode codepoint (per X11 keysym spec) + codepoint as i32 + } else { + // Everything else (control chars 0x01-0x1F, DEL 0x7F, and all other non-ASCII Unicode): + // keysym = 0x01000000 | codepoint (X11 Unicode keysym encoding) + (0x0100_0000 | codepoint) as i32 + } + } + fn create_uinput_keyboard() -> ResultType { // TODO: ensure keys here let mut keys = AttributeSet::::new(); @@ -390,13 +556,13 @@ pub mod service { pub fn map_key(key: &enigo::Key) -> ResultType<(evdev::Key, bool)> { if let Some(k) = KEY_MAP.get(&key) { - log::trace!("mapkey {:?}, get {:?}", &key, &k); + log::trace!("mapkey matched in KEY_MAP, evdev={:?}", &k); return Ok((k.clone(), false)); } else { match key { enigo::Key::Layout(c) => { if let Some((k, is_shift)) = KEY_MAP_LAYOUT.get(&c) { - log::trace!("mapkey {:?}, get {:?}", &key, k); + log::trace!("mapkey Layout matched, evdev={:?}", k); return Ok((k.clone(), is_shift.clone())); } } @@ -421,41 +587,74 @@ pub mod service { keyboard: &mut VirtualDevice, data: &DataKeyboard, ) { - log::trace!("handle_keyboard {:?}", &data); + let data_desc = match data { + DataKeyboard::Sequence(seq) => format!("Sequence(len={})", seq.len()), + DataKeyboard::KeyDown(Key::Layout(_)) + | DataKeyboard::KeyUp(Key::Layout(_)) + | DataKeyboard::KeyClick(Key::Layout(_)) => "Layout()".to_string(), + _ => format!("{:?}", data), + }; + log::trace!("handle_keyboard received: {}", data_desc); match data { - DataKeyboard::Sequence(_seq) => { - // ignore + DataKeyboard::Sequence(seq) => { + // Normally handled by --server process (input_text_via_clipboard_server). + // Fallback: input_text_wayland handles ASCII via keysym/uinput; + // non-ASCII will be skipped (no clipboard access in --service process). + if !seq.is_empty() { + input_text_wayland(seq, keyboard); + } } DataKeyboard::KeyDown(enigo::Key::Raw(code)) => { - let down_event = InputEvent::new(EventType::KEY, *code - 8, 1); - allow_err!(keyboard.emit(&[down_event])); - } - DataKeyboard::KeyUp(enigo::Key::Raw(code)) => { - let up_event = InputEvent::new(EventType::KEY, *code - 8, 0); - allow_err!(keyboard.emit(&[up_event])); - } - DataKeyboard::KeyDown(key) => { - if let Ok((k, is_shift)) = map_key(key) { - if is_shift { - let down_event = - InputEvent::new(EventType::KEY, evdev::Key::KEY_LEFTSHIFT.code(), 1); - allow_err!(keyboard.emit(&[down_event])); - } - let down_event = InputEvent::new(EventType::KEY, k.code(), 1); + if *code < 8 { + log::error!( + "Invalid Raw keycode {} (must be >= 8 due to XKB offset), skipping", + code + ); + } else { + let down_event = InputEvent::new(EventType::KEY, *code - 8, 1); allow_err!(keyboard.emit(&[down_event])); } } - DataKeyboard::KeyUp(key) => { - if let Ok((k, _)) = map_key(key) { - let up_event = InputEvent::new(EventType::KEY, k.code(), 0); + DataKeyboard::KeyUp(enigo::Key::Raw(code)) => { + if *code < 8 { + log::error!( + "Invalid Raw keycode {} (must be >= 8 due to XKB offset), skipping", + code + ); + } else { + let up_event = InputEvent::new(EventType::KEY, *code - 8, 0); allow_err!(keyboard.emit(&[up_event])); } } + DataKeyboard::KeyDown(key) => { + if let Key::Layout(chr) = key { + input_char_wayland_key_event(*chr, true, keyboard); + } else { + if let Ok((k, _is_shift)) = map_key(key) { + let down_event = InputEvent::new(EventType::KEY, k.code(), 1); + allow_err!(keyboard.emit(&[down_event])); + } + } + } + DataKeyboard::KeyUp(key) => { + if let Key::Layout(chr) = key { + input_char_wayland_key_event(*chr, false, keyboard); + } else { + if let Ok((k, _)) = map_key(key) { + let up_event = InputEvent::new(EventType::KEY, k.code(), 0); + allow_err!(keyboard.emit(&[up_event])); + } + } + } DataKeyboard::KeyClick(key) => { - if let Ok((k, _)) = map_key(key) { - let down_event = InputEvent::new(EventType::KEY, k.code(), 1); - let up_event = InputEvent::new(EventType::KEY, k.code(), 0); - allow_err!(keyboard.emit(&[down_event, up_event])); + if let Key::Layout(chr) = key { + input_text_wayland(&chr.to_string(), keyboard); + } else { + if let Ok((k, _is_shift)) = map_key(key) { + let down_event = InputEvent::new(EventType::KEY, k.code(), 1); + let up_event = InputEvent::new(EventType::KEY, k.code(), 0); + allow_err!(keyboard.emit(&[down_event, up_event])); + } } } DataKeyboard::GetKeyState(key) => { @@ -580,9 +779,13 @@ pub mod service { } fn spawn_keyboard_handler(mut stream: Connection) { + log::debug!("spawn_keyboard_handler: new keyboard handler connection"); tokio::spawn(async move { let mut keyboard = match create_uinput_keyboard() { - Ok(keyboard) => keyboard, + Ok(keyboard) => { + log::debug!("UInput keyboard device created successfully"); + keyboard + } Err(e) => { log::error!("Failed to create keyboard {}", e); return; @@ -602,6 +805,7 @@ pub mod service { handle_keyboard(&mut stream, &mut keyboard, &data).await; } _ => { + log::warn!("Unexpected data type in keyboard handler"); } } } @@ -715,6 +919,35 @@ pub mod service { }); } + #[cfg(target_os = "linux")] + fn authorize_uinput_peer(postfix: &str, stream: &RawIpcConnection) -> bool { + if !hbb_common::config::is_service_ipc_postfix(postfix) { + return true; + } + let peer_uid = ipc::peer_uid_from_fd(stream.as_raw_fd()); + let active_uid = crate::platform::linux::get_active_userid_fresh() + .trim() + .parse::() + .ok(); + let authorized = + peer_uid.is_some_and(|uid| ipc::is_allowed_service_peer_uid(uid, active_uid)); + if !authorized { + crate::ipc::log_rejected_uinput_connection(postfix, peer_uid, active_uid); + return false; + } + if let Err(err) = + ipc::ensure_peer_executable_matches_current_by_fd(stream.as_raw_fd(), postfix) + { + log::warn!( + "Rejected connection on protected uinput ipc channel due to executable mismatch: postfix={}, err={}", + postfix, + err + ); + return false; + } + true + } + /// Start uinput service. async fn start_service(postfix: &str, handler: F) { match new_listener(postfix).await { @@ -722,6 +955,10 @@ pub mod service { while let Some(result) = incoming.next().await { match result { Ok(stream) => { + #[cfg(target_os = "linux")] + if !authorize_uinput_peer(postfix, &stream) { + continue; + } log::debug!("Got new connection of uinput ipc {}", postfix); handler(Connection::new(stream)); } diff --git a/src/server/video_qos.rs b/src/server/video_qos.rs index 5d3aeca85..b02f6adf3 100644 --- a/src/server/video_qos.rs +++ b/src/server/video_qos.rs @@ -1,287 +1,222 @@ use super::*; -use scrap::codec::Quality; -use std::time::Duration; +use scrap::codec::{Quality, BR_BALANCED, BR_BEST, BR_SPEED}; +use std::{ + collections::VecDeque, + time::{Duration, Instant}, +}; + +/* +FPS adjust: +a. new user connected =>set to INIT_FPS +b. TestDelay receive => update user's fps according to network delay + When network delay < DELAY_THRESHOLD_150MS, set minimum fps according to image quality, and increase fps; + When network delay >= DELAY_THRESHOLD_150MS, set minimum fps according to image quality, and decrease fps; +c. second timeout / TestDelay receive => update real fps to the minimum fps from all users + +ratio adjust: +a. user set image quality => update to the maximum ratio of the latest quality +b. 3 seconds timeout => update ratio according to network delay + When network delay < DELAY_THRESHOLD_150MS, increase ratio, max 150kbps; + When network delay >= DELAY_THRESHOLD_150MS, decrease ratio; + +adjust between FPS and ratio: + When network delay < DELAY_THRESHOLD_150MS, fps is always higher than the minimum fps, and ratio is increasing; + When network delay >= DELAY_THRESHOLD_150MS, fps is always lower than the minimum fps, and ratio is decreasing; + +delay: + use delay minus RTT as the actual network delay +*/ + +// Constants pub const FPS: u32 = 30; pub const MIN_FPS: u32 = 1; pub const MAX_FPS: u32 = 120; -trait Percent { - fn as_percent(&self) -> u32; +pub const INIT_FPS: u32 = 15; + +// Bitrate ratio constants for different quality levels +const BR_MAX: f32 = 40.0; // 2000 * 2 / 100 +const BR_MIN: f32 = 0.2; +const BR_MIN_HIGH_RESOLUTION: f32 = 0.1; // For high resolution, BR_MIN is still too high, so we set a lower limit +const MAX_BR_MULTIPLE: f32 = 1.0; + +const HISTORY_DELAY_LEN: usize = 2; +const ADJUST_RATIO_INTERVAL: usize = 3; // Adjust quality ratio every 3 seconds +const DYNAMIC_SCREEN_THRESHOLD: usize = 2; // Allow increase quality ratio if encode more than 2 times in one second +const DELAY_THRESHOLD_150MS: u32 = 150; // 150ms is the threshold for good network condition + +#[derive(Default, Debug, Clone)] +struct UserDelay { + response_delayed: bool, + delay_history: VecDeque, + fps: Option, + rtt_calculator: RttCalculator, + quick_increase_fps_count: usize, + increase_fps_count: usize, } -impl Percent for ImageQuality { - fn as_percent(&self) -> u32 { - match self { - ImageQuality::NotSet => 0, - ImageQuality::Low => 50, - ImageQuality::Balanced => 66, - ImageQuality::Best => 100, +impl UserDelay { + fn add_delay(&mut self, delay: u32) { + self.rtt_calculator.update(delay); + if self.delay_history.len() > HISTORY_DELAY_LEN { + self.delay_history.pop_front(); + } + self.delay_history.push_back(delay); + } + + // Average delay minus RTT + fn avg_delay(&self) -> u32 { + let len = self.delay_history.len(); + if len > 0 { + let avg_delay = self.delay_history.iter().sum::() / len as u32; + + // If RTT is available, subtract it from average delay to get actual network latency + if let Some(rtt) = self.rtt_calculator.get_rtt() { + if avg_delay > rtt { + avg_delay - rtt + } else { + avg_delay + } + } else { + avg_delay + } + } else { + DELAY_THRESHOLD_150MS } } } -#[derive(Default, Debug, Copy, Clone)] -struct Delay { - state: DelayState, - staging_state: DelayState, - delay: u32, - counter: u32, - slower_than_old_state: Option, -} - -#[derive(Default, Debug, Copy, Clone)] +// User session data structure +#[derive(Default, Debug, Clone)] struct UserData { auto_adjust_fps: Option, // reserve for compatibility custom_fps: Option, quality: Option<(i64, Quality)>, // (time, quality) - delay: Option, - response_delayed: bool, + delay: UserDelay, record: bool, } +#[derive(Default, Debug, Clone)] +struct DisplayData { + send_counter: usize, // Number of times encode during period + support_changing_quality: bool, +} + +// Main QoS controller structure pub struct VideoQoS { fps: u32, - quality: Quality, + ratio: f32, users: HashMap, + displays: HashMap, bitrate_store: u32, - support_abr: HashMap, -} - -#[derive(PartialEq, Debug, Clone, Copy)] -enum DelayState { - Normal = 0, - LowDelay = 200, - HighDelay = 500, - Broken = 1000, -} - -impl Default for DelayState { - fn default() -> Self { - DelayState::Normal - } -} - -impl DelayState { - fn from_delay(delay: u32) -> Self { - if delay > DelayState::Broken as u32 { - DelayState::Broken - } else if delay > DelayState::HighDelay as u32 { - DelayState::HighDelay - } else if delay > DelayState::LowDelay as u32 { - DelayState::LowDelay - } else { - DelayState::Normal - } - } + adjust_ratio_instant: Instant, + abr_config: bool, + new_user_instant: Instant, } impl Default for VideoQoS { fn default() -> Self { VideoQoS { fps: FPS, - quality: Default::default(), + ratio: BR_BALANCED, users: Default::default(), + displays: Default::default(), bitrate_store: 0, - support_abr: Default::default(), + adjust_ratio_instant: Instant::now(), + abr_config: true, + new_user_instant: Instant::now(), } } } -#[derive(Debug, PartialEq, Eq)] -pub enum RefreshType { - SetImageQuality, -} - +// Basic functionality impl VideoQoS { + // Calculate seconds per frame based on current FPS pub fn spf(&self) -> Duration { Duration::from_secs_f32(1. / (self.fps() as f32)) } + // Get current FPS within valid range pub fn fps(&self) -> u32 { - if self.fps >= MIN_FPS && self.fps <= MAX_FPS { - self.fps + let fps = self.fps; + if fps >= MIN_FPS && fps <= MAX_FPS { + fps } else { FPS } } + // Store bitrate for later use pub fn store_bitrate(&mut self, bitrate: u32) { self.bitrate_store = bitrate; } + // Get stored bitrate pub fn bitrate(&self) -> u32 { self.bitrate_store } - pub fn quality(&self) -> Quality { - self.quality + // Get current bitrate ratio with bounds checking + pub fn ratio(&mut self) -> f32 { + if self.ratio < BR_MIN_HIGH_RESOLUTION || self.ratio > BR_MAX { + self.ratio = BR_BALANCED; + } + self.ratio } + // Check if any user is in recording mode pub fn record(&self) -> bool { self.users.iter().any(|u| u.1.record) } - pub fn set_support_abr(&mut self, display_idx: usize, support: bool) { - self.support_abr.insert(display_idx, support); + pub fn set_support_changing_quality(&mut self, video_service_name: &str, support: bool) { + if let Some(display) = self.displays.get_mut(video_service_name) { + display.support_changing_quality = support; + } } + // Check if variable bitrate encoding is supported and enabled pub fn in_vbr_state(&self) -> bool { - Config::get_option("enable-abr") != "N" && self.support_abr.iter().all(|e| *e.1) + self.abr_config && self.displays.iter().all(|e| e.1.support_changing_quality) + } +} + +// User session management +impl VideoQoS { + // Initialize new user session + pub fn on_connection_open(&mut self, id: i32) { + self.users.insert(id, UserData::default()); + self.abr_config = Config::get_option("enable-abr") != "N"; + self.new_user_instant = Instant::now(); } - pub fn refresh(&mut self, typ: Option) { - // fps - let user_fps = |u: &UserData| { - // custom_fps - let mut fps = u.custom_fps.unwrap_or(FPS); - // auto adjust fps - if let Some(auto_adjust_fps) = u.auto_adjust_fps { - if fps == 0 || auto_adjust_fps < fps { - fps = auto_adjust_fps; - } - } - // delay - if let Some(delay) = u.delay { - fps = match delay.state { - DelayState::Normal => fps, - DelayState::LowDelay => fps * 3 / 4, - DelayState::HighDelay => fps / 2, - DelayState::Broken => fps / 4, - } - } - // delay response - if u.response_delayed { - if fps > MIN_FPS + 2 { - fps = MIN_FPS + 2; - } - } - return fps; - }; - let mut fps = self - .users - .iter() - .map(|(_, u)| user_fps(u)) - .filter(|u| *u >= MIN_FPS) - .min() - .unwrap_or(FPS); - if fps > MAX_FPS { - fps = MAX_FPS; + // Clean up user session + pub fn on_connection_close(&mut self, id: i32) { + self.users.remove(&id); + if self.users.is_empty() { + *self = Default::default(); } - self.fps = fps; - - // quality - // latest image quality - let latest_quality = self - .users - .iter() - .map(|(_, u)| u.quality) - .filter(|q| *q != None) - .max_by(|a, b| a.unwrap_or_default().0.cmp(&b.unwrap_or_default().0)) - .unwrap_or_default() - .unwrap_or_default() - .1; - let mut quality = latest_quality; - - // network delay - let abr_enabled = self.in_vbr_state(); - if abr_enabled && typ != Some(RefreshType::SetImageQuality) { - // max delay - let delay = self - .users - .iter() - .map(|u| u.1.delay) - .filter(|d| d.is_some()) - .max_by(|a, b| { - (a.unwrap_or_default().state as u32).cmp(&(b.unwrap_or_default().state as u32)) - }); - let delay = delay.unwrap_or_default().unwrap_or_default().state; - if delay != DelayState::Normal { - match self.quality { - Quality::Best => { - quality = if delay == DelayState::Broken { - Quality::Low - } else { - Quality::Balanced - }; - } - Quality::Balanced => { - quality = Quality::Low; - } - Quality::Low => { - quality = Quality::Low; - } - Quality::Custom(b) => match delay { - DelayState::LowDelay => { - quality = - Quality::Custom(if b >= 150 { 100 } else { std::cmp::min(50, b) }); - } - DelayState::HighDelay => { - quality = - Quality::Custom(if b >= 100 { 50 } else { std::cmp::min(25, b) }); - } - DelayState::Broken => { - quality = - Quality::Custom(if b >= 50 { 25 } else { std::cmp::min(10, b) }); - } - DelayState::Normal => {} - }, - } - } else { - match self.quality { - Quality::Low => { - if latest_quality == Quality::Best { - quality = Quality::Balanced; - } - } - Quality::Custom(current_b) => { - if let Quality::Custom(latest_b) = latest_quality { - if current_b < latest_b / 2 { - quality = Quality::Custom(latest_b / 2); - } - } - } - _ => {} - } - } - } - self.quality = quality; } pub fn user_custom_fps(&mut self, id: i32, fps: u32) { - if fps < MIN_FPS { + if fps < MIN_FPS || fps > MAX_FPS { return; } if let Some(user) = self.users.get_mut(&id) { user.custom_fps = Some(fps); - } else { - self.users.insert( - id, - UserData { - custom_fps: Some(fps), - ..Default::default() - }, - ); } - self.refresh(None); } pub fn user_auto_adjust_fps(&mut self, id: i32, fps: u32) { + if fps < MIN_FPS || fps > MAX_FPS { + return; + } if let Some(user) = self.users.get_mut(&id) { user.auto_adjust_fps = Some(fps); - } else { - self.users.insert( - id, - UserData { - auto_adjust_fps: Some(fps), - ..Default::default() - }, - ); } - self.refresh(None); } pub fn user_image_quality(&mut self, id: i32, image_quality: i32) { - // https://github.com/rustdesk/rustdesk/blob/d716e2b40c38737f1aa3f16de0dec67394a6ac68/src/server/video_service.rs#L493 - let convert_quality = |q: i32| { + let convert_quality = |q: i32| -> Quality { if q == ImageQuality::Balanced.value() { Quality::Balanced } else if q == ImageQuality::Low.value() { @@ -289,92 +224,16 @@ impl VideoQoS { } else if q == ImageQuality::Best.value() { Quality::Best } else { - let mut b = (q >> 8 & 0xFFF) * 2; - b = std::cmp::max(b, 20); - b = std::cmp::min(b, 8000); - Quality::Custom(b as u32) + let b = ((q >> 8 & 0xFFF) * 2) as f32 / 100.0; + Quality::Custom(b.clamp(BR_MIN, BR_MAX)) } }; let quality = Some((hbb_common::get_time(), convert_quality(image_quality))); if let Some(user) = self.users.get_mut(&id) { user.quality = quality; - } else { - self.users.insert( - id, - UserData { - quality, - ..Default::default() - }, - ); - } - self.refresh(Some(RefreshType::SetImageQuality)); - } - - pub fn user_network_delay(&mut self, id: i32, delay: u32) { - let state = DelayState::from_delay(delay); - let debounce = 3; - if let Some(user) = self.users.get_mut(&id) { - if let Some(d) = &mut user.delay { - d.delay = (delay + d.delay) / 2; - let new_state = DelayState::from_delay(d.delay); - let slower_than_old_state = new_state as i32 - d.staging_state as i32; - let slower_than_old_state = if slower_than_old_state > 0 { - Some(true) - } else if slower_than_old_state < 0 { - Some(false) - } else { - None - }; - if d.slower_than_old_state == slower_than_old_state { - let old_counter = d.counter; - d.counter += delay / 1000 + 1; - if old_counter < debounce && d.counter >= debounce { - d.counter = 0; - d.state = d.staging_state; - d.staging_state = new_state; - } - if d.counter % debounce == 0 { - self.refresh(None); - } - } else { - d.counter = 0; - d.staging_state = new_state; - d.slower_than_old_state = slower_than_old_state; - } - } else { - user.delay = Some(Delay { - state: DelayState::Normal, - staging_state: state, - delay, - counter: 0, - slower_than_old_state: None, - }); - } - } else { - self.users.insert( - id, - UserData { - delay: Some(Delay { - state: DelayState::Normal, - staging_state: state, - delay, - counter: 0, - slower_than_old_state: None, - }), - ..Default::default() - }, - ); - } - } - - pub fn user_delay_response_elapsed(&mut self, id: i32, elapsed: u128) { - if let Some(user) = self.users.get_mut(&id) { - let old = user.response_delayed; - user.response_delayed = elapsed > 3000; - if old != user.response_delayed { - self.refresh(None); - } + // update ratio directly + self.ratio = self.latest_quality().ratio(); } } @@ -384,8 +243,353 @@ impl VideoQoS { } } - pub fn on_connection_close(&mut self, id: i32) { - self.users.remove(&id); - self.refresh(None); + pub fn user_network_delay(&mut self, id: i32, delay: u32) { + let highest_fps = self.highest_fps(); + let target_ratio = self.latest_quality().ratio(); + + // For bad network, small fps means quick reaction and high quality + let (min_fps, normal_fps) = if target_ratio >= BR_BEST { + (8, 16) + } else if target_ratio >= BR_BALANCED { + (10, 20) + } else { + (12, 24) + }; + + // Calculate minimum acceptable delay-fps product + let dividend_ms = DELAY_THRESHOLD_150MS * min_fps; + + let mut adjust_ratio = false; + if let Some(user) = self.users.get_mut(&id) { + let delay = delay.max(10); + let old_avg_delay = user.delay.avg_delay(); + user.delay.add_delay(delay); + let mut avg_delay = user.delay.avg_delay(); + avg_delay = avg_delay.max(10); + let mut fps = self.fps; + + // Adaptive FPS adjustment based on network delay: + if avg_delay < 50 { + user.delay.quick_increase_fps_count += 1; + let mut step = if fps < normal_fps { 1 } else { 0 }; + if user.delay.quick_increase_fps_count >= 3 { + // After 3 consecutive good samples, increase more aggressively + user.delay.quick_increase_fps_count = 0; + step = 5; + } + fps = min_fps.max(fps + step); + } else if avg_delay < 100 { + let step = if avg_delay < old_avg_delay { + if fps < normal_fps { + 1 + } else { + 0 + } + } else { + 0 + }; + fps = min_fps.max(fps + step); + } else if avg_delay < DELAY_THRESHOLD_150MS { + fps = min_fps.max(fps); + } else { + let devide_fps = ((fps as f32) / (avg_delay as f32 / DELAY_THRESHOLD_150MS as f32)) + .ceil() as u32; + if avg_delay < 200 { + fps = min_fps.max(devide_fps); + } else if avg_delay < 300 { + fps = min_fps.min(devide_fps); + } else if avg_delay < 600 { + fps = dividend_ms / avg_delay; + } else { + fps = (dividend_ms / avg_delay).min(devide_fps); + } + } + + if avg_delay < DELAY_THRESHOLD_150MS { + user.delay.increase_fps_count += 1; + } else { + user.delay.increase_fps_count = 0; + } + if user.delay.increase_fps_count >= 3 { + // After 3 stable samples, try increasing FPS + user.delay.increase_fps_count = 0; + fps += 1; + } + + // Reset quick increase counter if network condition worsens + if avg_delay > 50 { + user.delay.quick_increase_fps_count = 0; + } + + fps = fps.clamp(MIN_FPS, highest_fps); + // first network delay message + adjust_ratio = user.delay.fps.is_none(); + user.delay.fps = Some(fps); + } + self.adjust_fps(); + if adjust_ratio && !cfg!(target_os = "linux") { + //Reduce the possibility of vaapi being created twice + self.adjust_ratio(false); + } + } + + pub fn user_delay_response_elapsed(&mut self, id: i32, elapsed: u128) { + if let Some(user) = self.users.get_mut(&id) { + user.delay.response_delayed = elapsed > 2000; + if user.delay.response_delayed { + user.delay.add_delay(elapsed as u32); + self.adjust_fps(); + } + } + } +} + +// Common adjust functions +impl VideoQoS { + pub fn new_display(&mut self, video_service_name: String) { + self.displays + .insert(video_service_name, DisplayData::default()); + } + + pub fn remove_display(&mut self, video_service_name: &str) { + self.displays.remove(video_service_name); + } + + pub fn update_display_data(&mut self, video_service_name: &str, send_counter: usize) { + if let Some(display) = self.displays.get_mut(video_service_name) { + display.send_counter += send_counter; + } + self.adjust_fps(); + let abr_enabled = self.in_vbr_state(); + if abr_enabled { + if self.adjust_ratio_instant.elapsed().as_secs() >= ADJUST_RATIO_INTERVAL as u64 { + let dynamic_screen = self + .displays + .iter() + .any(|d| d.1.send_counter >= ADJUST_RATIO_INTERVAL * DYNAMIC_SCREEN_THRESHOLD); + self.displays.iter_mut().for_each(|d| { + d.1.send_counter = 0; + }); + self.adjust_ratio(dynamic_screen); + } + } else { + self.ratio = self.latest_quality().ratio(); + } + } + + #[inline] + fn highest_fps(&self) -> u32 { + let user_fps = |u: &UserData| { + let mut fps = u.custom_fps.unwrap_or(FPS); + if let Some(auto_adjust_fps) = u.auto_adjust_fps { + if fps == 0 || auto_adjust_fps < fps { + fps = auto_adjust_fps; + } + } + fps + }; + + let fps = self + .users + .iter() + .map(|(_, u)| user_fps(u)) + .filter(|u| *u >= MIN_FPS) + .min() + .unwrap_or(FPS); + + fps.clamp(MIN_FPS, MAX_FPS) + } + + // Get latest quality settings from all users + pub fn latest_quality(&self) -> Quality { + self.users + .iter() + .map(|(_, u)| u.quality) + .filter(|q| *q != None) + .max_by(|a, b| a.unwrap_or_default().0.cmp(&b.unwrap_or_default().0)) + .flatten() + .unwrap_or((0, Quality::Balanced)) + .1 + } + + // Adjust quality ratio based on network delay and screen changes + fn adjust_ratio(&mut self, dynamic_screen: bool) { + if !self.in_vbr_state() { + return; + } + // Get maximum delay from all users + let max_delay = self.users.iter().map(|u| u.1.delay.avg_delay()).max(); + let Some(max_delay) = max_delay else { + return; + }; + + let target_quality = self.latest_quality(); + let target_ratio = self.latest_quality().ratio(); + let current_ratio = self.ratio; + let current_bitrate = self.bitrate(); + + // Calculate minimum ratio for high resolution (1Mbps baseline) + let ratio_1mbps = if current_bitrate > 0 { + Some((current_ratio * 1000.0 / current_bitrate as f32).max(BR_MIN_HIGH_RESOLUTION)) + } else { + None + }; + + // Calculate ratio for adding 150kbps bandwidth + let ratio_add_150kbps = if current_bitrate > 0 { + Some((current_bitrate + 150) as f32 * current_ratio / current_bitrate as f32) + } else { + None + }; + + // Set minimum ratio based on quality mode + let min = match target_quality { + Quality::Best => { + // For Best quality, ensure minimum 1Mbps for high resolution + let mut min = BR_BEST / 2.5; + if let Some(ratio_1mbps) = ratio_1mbps { + if min > ratio_1mbps { + min = ratio_1mbps; + } + } + min.max(BR_MIN) + } + Quality::Balanced => { + let mut min = (BR_BALANCED / 2.0).min(0.4); + if let Some(ratio_1mbps) = ratio_1mbps { + if min > ratio_1mbps { + min = ratio_1mbps; + } + } + min.max(BR_MIN_HIGH_RESOLUTION) + } + Quality::Low => BR_MIN_HIGH_RESOLUTION, + Quality::Custom(_) => BR_MIN_HIGH_RESOLUTION, + }; + let max = target_ratio * MAX_BR_MULTIPLE; + + let mut v = current_ratio; + + // Adjust ratio based on network delay thresholds + if max_delay < 50 { + if dynamic_screen { + v = current_ratio * 1.15; + } + } else if max_delay < 100 { + if dynamic_screen { + v = current_ratio * 1.1; + } + } else if max_delay < DELAY_THRESHOLD_150MS { + if dynamic_screen { + v = current_ratio * 1.05; + } + } else if max_delay < 200 { + v = current_ratio * 0.95; + } else if max_delay < 300 { + v = current_ratio * 0.9; + } else if max_delay < 500 { + v = current_ratio * 0.85; + } else { + v = current_ratio * 0.8; + } + + // Limit quality increase rate for better stability + if let Some(ratio_add_150kbps) = ratio_add_150kbps { + if v > ratio_add_150kbps + && ratio_add_150kbps > current_ratio + && current_ratio >= BR_SPEED + { + v = ratio_add_150kbps; + } + } + + self.ratio = v.clamp(min, max); + self.adjust_ratio_instant = Instant::now(); + } + + // Adjust fps based on network delay and user response time + fn adjust_fps(&mut self) { + let highest_fps = self.highest_fps(); + // Get minimum fps from all users + let mut fps = self + .users + .iter() + .map(|u| u.1.delay.fps.unwrap_or(INIT_FPS)) + .min() + .unwrap_or(INIT_FPS); + + if self.users.iter().any(|u| u.1.delay.response_delayed) { + if fps > MIN_FPS + 1 { + fps = MIN_FPS + 1; + } + } + + // For new connections (within 1 second), cap fps to INIT_FPS to ensure stability + if self.new_user_instant.elapsed().as_secs() < 1 { + if fps > INIT_FPS { + fps = INIT_FPS; + } + } + + // Ensure fps stays within valid range + self.fps = fps.clamp(MIN_FPS, highest_fps); + } +} + +#[derive(Default, Debug, Clone)] +struct RttCalculator { + min_rtt: Option, // Historical minimum RTT ever observed + window_min_rtt: Option, // Minimum RTT within last 60 samples + smoothed_rtt: Option, // Smoothed RTT estimation + samples: VecDeque, // Last 60 RTT samples +} + +impl RttCalculator { + const WINDOW_SAMPLES: usize = 60; // Keep last 60 samples + const MIN_SAMPLES: usize = 10; // Require at least 10 samples + const ALPHA: f32 = 0.5; // Smoothing factor for weighted average + + /// Update RTT estimates with a new sample + pub fn update(&mut self, delay: u32) { + // 1. Update historical minimum RTT + match self.min_rtt { + Some(min_rtt) if delay < min_rtt => self.min_rtt = Some(delay), + None => self.min_rtt = Some(delay), + _ => {} + } + + // 2. Update sample window + if self.samples.len() >= Self::WINDOW_SAMPLES { + self.samples.pop_front(); + } + self.samples.push_back(delay); + + // 3. Calculate minimum RTT within the window + self.window_min_rtt = self.samples.iter().min().copied(); + + // 4. Calculate smoothed RTT + // Use weighted average if we have enough samples + if self.samples.len() >= Self::WINDOW_SAMPLES { + if let (Some(min), Some(window_min)) = (self.min_rtt, self.window_min_rtt) { + // Weighted average of historical minimum and window minimum + let new_srtt = + ((1.0 - Self::ALPHA) * min as f32 + Self::ALPHA * window_min as f32) as u32; + self.smoothed_rtt = Some(new_srtt); + } + } + } + + /// Get current RTT estimate + /// Returns None if no valid estimation is available + pub fn get_rtt(&self) -> Option { + if let Some(rtt) = self.smoothed_rtt { + return Some(rtt); + } + if self.samples.len() >= Self::MIN_SAMPLES { + if let Some(rtt) = self.min_rtt { + return Some(rtt); + } + } + None } } diff --git a/src/server/video_service.rs b/src/server/video_service.rs index 55bfa08f0..13a781c28 100644 --- a/src/server/video_service.rs +++ b/src/server/video_service.rs @@ -18,12 +18,7 @@ // to-do: // https://slhck.info/video/2017/03/01/rate-control.html -use super::{ - display_service::{check_display_changed, get_display_info}, - service::ServiceTmpl, - video_qos::VideoQoS, - *, -}; +use super::{display_service::check_display_changed, service::ServiceTmpl, video_qos::VideoQoS, *}; #[cfg(target_os = "linux")] use crate::common::SimpleCallOnReturn; #[cfg(target_os = "linux")] @@ -51,10 +46,10 @@ use scrap::vram::{VRamEncoder, VRamEncoderConfig}; use scrap::Capturer; use scrap::{ aom::AomEncoderConfig, - codec::{Encoder, EncoderCfg, Quality}, + codec::{Encoder, EncoderCfg}, record::{Recorder, RecorderContext}, vpxcodec::{VpxEncoderConfig, VpxVideoCodecId}, - CodecFormat, Display, EncodeInput, TraitCapturer, + CodecFormat, Display, EncodeInput, TraitCapturer, TraitPixelBuffer, }; #[cfg(windows)] use std::sync::Once; @@ -65,32 +60,72 @@ use std::{ time::{self, Duration, Instant}, }; -pub const NAME: &'static str = "video"; pub const OPTION_REFRESH: &'static str = "refresh"; +type FrameFetchedNotifierSender = UnboundedSender<(i32, Option)>; +type FrameFetchedNotifierReceiver = Arc)>>>; + lazy_static::lazy_static! { - static ref FRAME_FETCHED_NOTIFIER: (UnboundedSender<(i32, Option)>, Arc)>>>) = { - let (tx, rx) = unbounded_channel(); - (tx, Arc::new(TokioMutex::new(rx))) - }; + static ref FRAME_FETCHED_NOTIFIERS: Mutex> = Mutex::new(HashMap::default()); + + // display_idx -> set of conn id. + // Used to record which connections need to be notified when + // 1. A new frame is received from a web client. + // Because web client does not send the display index in message `VideoReceived`. + // 2. The client is closing. + static ref DISPLAY_CONN_IDS: Arc>>> = Default::default(); pub static ref VIDEO_QOS: Arc> = Default::default(); pub static ref IS_UAC_RUNNING: Arc> = Default::default(); pub static ref IS_FOREGROUND_WINDOW_ELEVATED: Arc> = Default::default(); + static ref SCREENSHOTS: Mutex> = Default::default(); +} + +struct Screenshot { + sid: String, + tx: Sender, + restore_vram: bool, } #[inline] -pub fn notify_video_frame_fetched(conn_id: i32, frame_tm: Option) { - FRAME_FETCHED_NOTIFIER.0.send((conn_id, frame_tm)).ok(); +pub fn notify_video_frame_fetched(display_idx: usize, conn_id: i32, frame_tm: Option) { + if let Some(notifier) = FRAME_FETCHED_NOTIFIERS.lock().unwrap().get(&display_idx) { + notifier.0.send((conn_id, frame_tm)).ok(); + } +} + +#[inline] +pub fn notify_video_frame_fetched_by_conn_id(conn_id: i32, frame_tm: Option) { + let vec_display_idx: Vec = { + let display_conn_ids = DISPLAY_CONN_IDS.lock().unwrap(); + display_conn_ids + .iter() + .filter_map(|(display_idx, conn_ids)| { + if conn_ids.contains(&conn_id) { + Some(*display_idx) + } else { + None + } + }) + .collect() + }; + let notifiers = FRAME_FETCHED_NOTIFIERS.lock().unwrap(); + for display_idx in vec_display_idx { + if let Some(notifier) = notifiers.get(&display_idx) { + notifier.0.send((conn_id, frame_tm)).ok(); + } + } } struct VideoFrameController { + display_idx: usize, cur: Instant, send_conn_ids: HashSet, } impl VideoFrameController { - fn new() -> Self { + fn new(display_idx: usize) -> Self { Self { + display_idx, cur: Instant::now(), send_conn_ids: HashSet::new(), } @@ -104,6 +139,10 @@ impl VideoFrameController { if !conn_ids.is_empty() { self.cur = tm; self.send_conn_ids = conn_ids; + DISPLAY_CONN_IDS + .lock() + .unwrap() + .insert(self.display_idx, self.send_conn_ids.clone()); } } @@ -114,8 +153,20 @@ impl VideoFrameController { } let timeout_dur = Duration::from_millis(timeout_millis as u64); - match tokio::time::timeout(timeout_dur, FRAME_FETCHED_NOTIFIER.1.lock().await.recv()).await - { + let receiver = { + match FRAME_FETCHED_NOTIFIERS + .lock() + .unwrap() + .get(&self.display_idx) + { + Some(notifier) => notifier.1.clone(), + None => { + return; + } + } + }; + let mut receiver_guard = receiver.lock().await; + match tokio::time::timeout(timeout_dur, receiver_guard.recv()).await { Err(_) => { // break if timeout // log::error!("blocking wait frame receiving timeout {}", timeout_millis); @@ -130,6 +181,37 @@ impl VideoFrameController { // this branch would never be reached } } + while !receiver_guard.is_empty() { + if let Some((id, instant)) = receiver_guard.recv().await { + if let Some(tm) = instant { + log::trace!("Channel recv latency: {}", tm.elapsed().as_secs_f32()); + } + fetched_conn_ids.insert(id); + } + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum VideoSource { + Monitor, + Camera, +} + +impl VideoSource { + pub fn service_name_prefix(&self) -> &'static str { + match self { + VideoSource::Monitor => "monitor", + VideoSource::Camera => "camera", + } + } + + pub fn is_monitor(&self) -> bool { + matches!(self, VideoSource::Monitor) + } + + pub fn is_camera(&self) -> bool { + matches!(self, VideoSource::Camera) } } @@ -137,6 +219,7 @@ impl VideoFrameController { pub struct VideoService { sp: GenericService, idx: usize, + source: VideoSource, } impl Deref for VideoService { @@ -153,14 +236,23 @@ impl DerefMut for VideoService { } } -pub fn get_service_name(idx: usize) -> String { - format!("{}{}", NAME, idx) +pub fn get_service_name(source: VideoSource, idx: usize) -> String { + format!("{}{}", source.service_name_prefix(), idx) } -pub fn new(idx: usize) -> GenericService { +pub fn new(source: VideoSource, idx: usize) -> GenericService { + let _ = FRAME_FETCHED_NOTIFIERS + .lock() + .unwrap() + .entry(idx) + .or_insert_with(|| { + let (tx, rx) = unbounded_channel(); + (tx, Arc::new(TokioMutex::new(rx))) + }); let vs = VideoService { - sp: GenericService::new(get_service_name(idx), true), + sp: GenericService::new(get_service_name(source, idx), true), idx, + source, }; GenericService::run(&vs, run); vs.sp @@ -292,11 +384,14 @@ impl DerefMut for CapturerInfo { } } -fn get_capturer(current: usize, portable_service_running: bool) -> ResultType { +fn get_capturer_monitor( + current: usize, + portable_service_running: bool, +) -> ResultType { #[cfg(target_os = "linux")] { if !is_x11() { - return super::wayland::get_capturer(); + return super::wayland::get_capturer_for_display(current); } } @@ -309,6 +404,7 @@ fn get_capturer(current: usize, portable_service_running: bool) -> ResultType ResultType ResultType { + let cameras = camera::Cameras::get_sync_cameras(); + let ncamera = cameras.len(); + if ncamera <= current { + bail!("Failed to get camera {}, cameras len: {}", current, ncamera,); + } + let Some(camera) = cameras.get(current) else { + bail!( + "Camera of index {} doesn't exist or platform not supported", + current + ); + }; + let capturer = camera::Cameras::get_capturer(current)?; + let (width, height) = (camera.width as usize, camera.height as usize); + let origin = (camera.x as i32, camera.y as i32); + let name = &camera.name; + let privacy_mode_id = get_privacy_mode_conn_id().unwrap_or(INVALID_PRIVACY_MODE_CONN_ID); + let _capturer_privacy_mode_id = privacy_mode_id; + log::debug!( + "#cameras={}, current={}, origin: {:?}, width={}, height={}, cpus={}/{}, name:{}", + ncamera, + current, + &origin, + width, + height, + num_cpus::get_physical(), + num_cpus::get(), + name, + ); + return Ok(CapturerInfo { + origin, + width, + height, + ndisplay: ncamera, + current, + privacy_mode_id, + _capturer_privacy_mode_id: privacy_mode_id, + capturer, + }); +} +fn get_capturer( + source: VideoSource, + current: usize, + portable_service_running: bool, +) -> ResultType { + match source { + VideoSource::Monitor => get_capturer_monitor(current, portable_service_running), + VideoSource::Camera => get_capturer_camera(current), + } +} + fn run(vs: VideoService) -> ResultType<()> { - let _raii = Raii::new(vs.idx); + let mut _raii = Raii::new(vs.idx, vs.sp.name()); // Wayland only support one video capturer for now. It is ok to call ensure_inited() here. // // ensure_inited() is needed because clear() may be called. @@ -392,11 +539,20 @@ fn run(vs: VideoService) -> ResultType<()> { #[cfg(target_os = "linux")] super::wayland::ensure_inited()?; #[cfg(target_os = "linux")] - let _wayland_call_on_ret = SimpleCallOnReturn { - b: true, - f: Box::new(|| { - super::wayland::clear(); - }), + let _wayland_call_on_ret = { + // Increment active display count when starting + let _display_count = super::wayland::increment_active_display_count(); + + SimpleCallOnReturn { + b: true, + f: Box::new(|| { + // Decrement active display count and only clear if this was the last display + let remaining_count = super::wayland::decrement_active_display_count(); + if remaining_count == 0 { + super::wayland::clear(); + } + }), + } }; #[cfg(windows)] @@ -406,16 +562,15 @@ fn run(vs: VideoService) -> ResultType<()> { let display_idx = vs.idx; let sp = vs.sp; - let mut c = get_capturer(display_idx, last_portable_service_running)?; + let mut c = get_capturer(vs.source, display_idx, last_portable_service_running)?; #[cfg(windows)] if !scrap::codec::enable_directx_capture() && !c.is_gdi() { log::info!("disable dxgi with option, fall back to gdi"); c.set_gdi(); } let mut video_qos = VIDEO_QOS.lock().unwrap(); - video_qos.refresh(None); - let mut spf; - let mut quality = video_qos.quality(); + let mut spf = video_qos.spf(); + let mut quality = video_qos.ratio(); let record_incoming = config::option2bool( "allow-auto-record-incoming", &Config::get_option("allow-auto-record-incoming"), @@ -424,11 +579,13 @@ fn run(vs: VideoService) -> ResultType<()> { drop(video_qos); let (mut encoder, encoder_cfg, codec_format, use_i444, recorder) = match setup_encoder( &c, - display_idx, + sp.name(), quality, client_record, record_incoming, last_portable_service_running, + vs.source, + display_idx, ) { Ok(result) => result, Err(err) => { @@ -442,33 +599,37 @@ fn run(vs: VideoService) -> ResultType<()> { })); setup_encoder( &c, - display_idx, + sp.name(), quality, client_record, record_incoming, last_portable_service_running, + vs.source, + display_idx, )? } }; #[cfg(feature = "vram")] c.set_output_texture(encoder.input_texture()); #[cfg(target_os = "android")] - if let Err(e) = check_change_scale(encoder.is_hardware()) { - try_broadcast_display_changed(&sp, display_idx, &c, true).ok(); - bail!(e); + if vs.source.is_monitor() { + if let Err(e) = check_change_scale(encoder.is_hardware()) { + try_broadcast_display_changed(&sp, display_idx, &c, true).ok(); + bail!(e); + } } VIDEO_QOS.lock().unwrap().store_bitrate(encoder.bitrate()); VIDEO_QOS .lock() .unwrap() - .set_support_abr(display_idx, encoder.support_abr()); + .set_support_changing_quality(&sp.name(), encoder.support_changing_quality()); log::info!("initial quality: {quality:?}"); if sp.is_option_true(OPTION_REFRESH) { sp.set_option_bool(OPTION_REFRESH, false); } - let mut frame_controller = VideoFrameController::new(); + let mut frame_controller = VideoFrameController::new(display_idx); let start = time::Instant::now(); let mut last_check_displays = time::Instant::now(); @@ -489,34 +650,24 @@ fn run(vs: VideoService) -> ResultType<()> { let mut first_frame = true; let capture_width = c.width; let capture_height = c.height; + let (mut second_instant, mut send_counter) = (Instant::now(), 0); while sp.ok() { #[cfg(windows)] check_uac_switch(c.privacy_mode_id, c._capturer_privacy_mode_id)?; - - let mut video_qos = VIDEO_QOS.lock().unwrap(); - spf = video_qos.spf(); - if quality != video_qos.quality() { - log::debug!("quality: {:?} -> {:?}", quality, video_qos.quality()); - quality = video_qos.quality(); - if encoder.support_changing_quality() { - allow_err!(encoder.set_quality(quality)); - video_qos.store_bitrate(encoder.bitrate()); - } else { - if !video_qos.in_vbr_state() && !quality.is_custom() { - log::info!("switch to change quality"); - bail!("SWITCH"); - } - } - } - if client_record != video_qos.record() { - log::info!("switch due to record changed"); - bail!("SWITCH"); - } - drop(video_qos); - + check_qos( + &mut encoder, + &mut quality, + &mut spf, + client_record, + &mut send_counter, + &mut second_instant, + &sp.name(), + )?; if sp.is_option_true(OPTION_REFRESH) { - let _ = try_broadcast_display_changed(&sp, display_idx, &c, true); + if vs.source.is_monitor() { + let _ = try_broadcast_display_changed(&sp, display_idx, &c, true); + } log::info!("switch to refresh"); bail!("SWITCH"); } @@ -540,10 +691,12 @@ fn run(vs: VideoService) -> ResultType<()> { #[cfg(all(windows, feature = "vram"))] if c.is_gdi() && encoder.input_texture() { log::info!("changed to gdi when using vram"); - VRamEncoder::set_fallback_gdi(display_idx, true); + VRamEncoder::set_fallback_gdi(sp.name(), true); bail!("SWITCH"); } - check_privacy_mode_changed(&sp, display_idx, &c)?; + if vs.source.is_monitor() { + check_privacy_mode_changed(&sp, display_idx, &c)?; + } #[cfg(windows)] { if crate::platform::windows::desktop_changed() @@ -553,7 +706,7 @@ fn run(vs: VideoService) -> ResultType<()> { } } let now = time::Instant::now(); - if last_check_displays.elapsed().as_millis() > 1000 { + if vs.source.is_monitor() && last_check_displays.elapsed().as_millis() > 1000 { last_check_displays = now; // This check may be redundant, but it is better to be safe. // The previous check in `sp.is_option_true(OPTION_REFRESH)` block may be enough. @@ -568,6 +721,49 @@ fn run(vs: VideoService) -> ResultType<()> { Ok(frame) => { repeat_encode_counter = 0; if frame.valid() { + let screenshot = SCREENSHOTS.lock().unwrap().remove(&display_idx); + if let Some(mut screenshot) = screenshot { + let restore_vram = screenshot.restore_vram; + let (msg, w, h, data) = match &frame { + scrap::Frame::PixelBuffer(f) => match get_rgba_from_pixelbuf(f) { + Ok(rgba) => ("".to_owned(), f.width(), f.height(), rgba), + Err(e) => { + let serr = e.to_string(); + log::error!( + "Failed to convert the pix format into rgba, {}", + &serr + ); + (format!("Convert pixfmt: {}", serr), 0, 0, vec![]) + } + }, + scrap::Frame::Texture(_) => { + if restore_vram { + // Already set one time, just ignore to break infinite loop. + // Though it's unreachable, this branch is kept to avoid infinite loop. + ( + "Please change codec and try again.".to_owned(), + 0, + 0, + vec![], + ) + } else { + #[cfg(all(windows, feature = "vram"))] + VRamEncoder::set_not_use(sp.name(), true); + screenshot.restore_vram = true; + SCREENSHOTS.lock().unwrap().insert(display_idx, screenshot); + _raii.try_vram = false; + bail!("SWITCH"); + } + } + }; + std::thread::spawn(move || { + handle_screenshot(screenshot, msg, w, h, data); + }); + if restore_vram { + bail!("SWITCH"); + } + } + let frame = frame.to(encoder.yuvfmt(), &mut yuv, &mut mid_data)?; let send_conn_ids = handle_one_frame( display_idx, @@ -582,12 +778,13 @@ fn run(vs: VideoService) -> ResultType<()> { capture_height, )?; frame_controller.set_send(now, send_conn_ids); + send_counter += 1; } #[cfg(windows)] { #[cfg(feature = "vram")] if try_gdi == 1 && !c.is_gdi() { - VRamEncoder::set_fallback_gdi(display_idx, false); + VRamEncoder::set_fallback_gdi(sp.name(), false); } try_gdi = 0; } @@ -640,13 +837,16 @@ fn run(vs: VideoService) -> ResultType<()> { capture_height, )?; frame_controller.set_send(now, send_conn_ids); + send_counter += 1; } } } Err(err) => { // This check may be redundant, but it is better to be safe. // The previous check in `sp.is_option_true(OPTION_REFRESH)` block may be enough. - try_broadcast_display_changed(&sp, display_idx, &c, true)?; + if vs.source.is_monitor() { + try_broadcast_display_changed(&sp, display_idx, &c, true)?; + } #[cfg(windows)] if !c.is_gdi() { @@ -668,13 +868,16 @@ fn run(vs: VideoService) -> ResultType<()> { let timeout_millis = 3_000u64; let wait_begin = Instant::now(); while wait_begin.elapsed().as_millis() < timeout_millis as _ { - check_privacy_mode_changed(&sp, display_idx, &c)?; + if vs.source.is_monitor() { + check_privacy_mode_changed(&sp, display_idx, &c)?; + } frame_controller.try_wait_next(&mut fetched_conn_ids, 300); // break if all connections have received current frame if fetched_conn_ids.len() >= frame_controller.send_conn_ids.len() { break; } } + DISPLAY_CONN_IDS.lock().unwrap().remove(&display_idx); let elapsed = now.elapsed(); // may need to enable frame(timeout) @@ -687,31 +890,47 @@ fn run(vs: VideoService) -> ResultType<()> { Ok(()) } -struct Raii(usize); +struct Raii { + display_idx: usize, + name: String, + try_vram: bool, +} impl Raii { - fn new(display_idx: usize) -> Self { - Raii(display_idx) + fn new(display_idx: usize, name: String) -> Self { + log::info!("new video service: {}", name); + VIDEO_QOS.lock().unwrap().new_display(name.clone()); + Raii { + display_idx, + name, + try_vram: true, + } } } impl Drop for Raii { fn drop(&mut self) { + log::info!("stop video service: {}", self.name); #[cfg(feature = "vram")] - VRamEncoder::set_not_use(self.0, false); + if self.try_vram { + VRamEncoder::set_not_use(self.name.clone(), false); + } #[cfg(feature = "vram")] Encoder::update(scrap::codec::EncodingUpdate::Check); - VIDEO_QOS.lock().unwrap().set_support_abr(self.0, true); + VIDEO_QOS.lock().unwrap().remove_display(&self.name); + DISPLAY_CONN_IDS.lock().unwrap().remove(&self.display_idx); } } fn setup_encoder( c: &CapturerInfo, - display_idx: usize, - quality: Quality, + name: String, + quality: f32, client_record: bool, record_incoming: bool, last_portable_service_running: bool, + source: VideoSource, + display_idx: usize, ) -> ResultType<( Encoder, EncoderCfg, @@ -721,20 +940,15 @@ fn setup_encoder( )> { let encoder_cfg = get_encoder_config( &c, - display_idx, + name.to_string(), quality, client_record || record_incoming, last_portable_service_running, + source, ); Encoder::set_fallback(&encoder_cfg); let codec_format = Encoder::negotiated_codec(); - let recorder = get_recorder( - c.width, - c.height, - &codec_format, - record_incoming, - display_idx, - ); + let recorder = get_recorder(record_incoming, display_idx, source == VideoSource::Camera); let use_i444 = Encoder::use_i444(&encoder_cfg); let encoder = Encoder::new(encoder_cfg.clone(), use_i444)?; Ok((encoder, encoder_cfg, codec_format, use_i444, recorder)) @@ -742,15 +956,16 @@ fn setup_encoder( fn get_encoder_config( c: &CapturerInfo, - _display_idx: usize, - quality: Quality, + _name: String, + quality: f32, record: bool, _portable_service: bool, + _source: VideoSource, ) -> EncoderCfg { #[cfg(all(windows, feature = "vram"))] - if _portable_service || c.is_gdi() { + if _portable_service || c.is_gdi() || _source == VideoSource::Camera { log::info!("gdi:{}, portable:{}", c.is_gdi(), _portable_service); - VRamEncoder::set_not_use(_display_idx, true); + VRamEncoder::set_not_use(_name, true); } #[cfg(feature = "vram")] Encoder::update(scrap::codec::EncodingUpdate::Check); @@ -817,11 +1032,9 @@ fn get_encoder_config( } fn get_recorder( - width: usize, - height: usize, - codec_format: &CodecFormat, record_incoming: bool, - display: usize, + display_idx: usize, + camera: bool, ) -> Arc>> { #[cfg(windows)] let root = crate::platform::is_root(); @@ -841,7 +1054,8 @@ fn get_recorder( server: true, id: Config::get_id(), dir: crate::ui_interface::video_save_directory(root), - display, + display_idx, + camera, tx, }) .map_or(Default::default(), |r| Arc::new(Mutex::new(Some(r)))) @@ -857,14 +1071,15 @@ fn check_change_scale(hardware: bool) -> ResultType<()> { use hbb_common::config::keys::OPTION_ENABLE_ANDROID_SOFTWARE_ENCODING_HALF_SCALE as SCALE_SOFT; // isStart flag is set at the end of startCapture() in Android, wait it to be set. - for i in 0..6 { + let n = 60; // 3s + for i in 0..n { if scrap::is_start() == Some(true) { log::info!("start flag is set"); break; } log::info!("wait for start, {i}"); std::thread::sleep(Duration::from_millis(50)); - if i == 5 { + if i == n - 1 { log::error!("wait for start timeout"); } } @@ -971,6 +1186,7 @@ fn handle_one_frame( } match e.to_string().as_str() { scrap::codec::ENCODE_NEED_SWITCH => { + encoder.disable(); log::error!("switch due to encoder need switch"); bail!("SWITCH"); } @@ -1024,7 +1240,9 @@ fn try_broadcast_display_changed( (cap.origin.0, cap.origin.1, cap.width, cap.height), ) { log::info!("Display {} changed", display); - if let Some(msg_out) = make_display_changed_msg(display_idx, Some(display)) { + if let Some(msg_out) = + make_display_changed_msg(display_idx, Some(display), VideoSource::Monitor) + { let msg_out = Arc::new(msg_out); sp.send_shared(msg_out.clone()); // switch display may occur before the first video frame, add snapshot to send to new subscribers @@ -1041,10 +1259,16 @@ fn try_broadcast_display_changed( pub fn make_display_changed_msg( display_idx: usize, opt_display: Option, + source: VideoSource, ) -> Option { let display = match opt_display { Some(d) => d, - None => get_display_info(display_idx)?, + None => match source { + VideoSource::Monitor => display_service::get_display_info(display_idx)?, + VideoSource::Camera => camera::Cameras::get_sync_cameras() + .get(display_idx)? + .clone(), + }, }; let mut misc = Misc::new(); misc.set_switch_display(SwitchDisplay { @@ -1053,13 +1277,24 @@ pub fn make_display_changed_msg( y: display.y, width: display.width, height: display.height, - cursor_embedded: display_service::capture_cursor_embedded(), + cursor_embedded: match source { + VideoSource::Monitor => display_service::capture_cursor_embedded(), + VideoSource::Camera => false, + }, #[cfg(not(target_os = "android"))] resolutions: Some(SupportedResolutions { - resolutions: if display.name.is_empty() { - vec![] - } else { - crate::platform::resolutions(&display.name) + resolutions: match source { + VideoSource::Monitor => { + if display.name.is_empty() { + vec![] + } else { + crate::platform::resolutions(&display.name) + } + } + VideoSource::Camera => camera::Cameras::get_camera_resolution(display_idx) + .ok() + .into_iter() + .collect(), }, ..SupportedResolutions::default() }) @@ -1071,3 +1306,114 @@ pub fn make_display_changed_msg( msg_out.set_misc(misc); Some(msg_out) } + +fn check_qos( + encoder: &mut Encoder, + ratio: &mut f32, + spf: &mut Duration, + client_record: bool, + send_counter: &mut usize, + second_instant: &mut Instant, + name: &str, +) -> ResultType<()> { + let mut video_qos = VIDEO_QOS.lock().unwrap(); + *spf = video_qos.spf(); + if *ratio != video_qos.ratio() { + *ratio = video_qos.ratio(); + if encoder.support_changing_quality() { + allow_err!(encoder.set_quality(*ratio)); + video_qos.store_bitrate(encoder.bitrate()); + } else { + // Now only vaapi doesn't support changing quality + if !video_qos.in_vbr_state() && !video_qos.latest_quality().is_custom() { + log::info!("switch to change quality"); + bail!("SWITCH"); + } + } + } + if client_record != video_qos.record() { + log::info!("switch due to record changed"); + bail!("SWITCH"); + } + if second_instant.elapsed() > Duration::from_secs(1) { + *second_instant = Instant::now(); + video_qos.update_display_data(&name, *send_counter); + *send_counter = 0; + } + drop(video_qos); + Ok(()) +} + +pub fn set_take_screenshot(display_idx: usize, sid: String, tx: Sender) { + SCREENSHOTS.lock().unwrap().insert( + display_idx, + Screenshot { + sid, + tx, + restore_vram: false, + }, + ); +} + +// We need to this function, because the `stride` may be larger than `width * 4`. +fn get_rgba_from_pixelbuf<'a>(pixbuf: &scrap::PixelBuffer<'a>) -> ResultType> { + let w = pixbuf.width(); + let h = pixbuf.height(); + let stride = pixbuf.stride(); + let Some(s) = stride.get(0) else { + bail!("Invalid pixel buf stride.") + }; + + if *s == w * 4 { + let mut rgba = vec![]; + scrap::convert(pixbuf, scrap::Pixfmt::RGBA, &mut rgba)?; + Ok(rgba) + } else { + let bgra = pixbuf.data(); + let mut bit_flipped = Vec::with_capacity(w * h * 4); + for y in 0..h { + for x in 0..w { + let i = s * y + 4 * x; + bit_flipped.extend_from_slice(&[bgra[i + 2], bgra[i + 1], bgra[i], bgra[i + 3]]); + } + } + Ok(bit_flipped) + } +} + +fn handle_screenshot(screenshot: Screenshot, msg: String, w: usize, h: usize, data: Vec) { + let mut response = ScreenshotResponse::new(); + response.sid = screenshot.sid; + if msg.is_empty() { + if data.is_empty() { + response.msg = "Failed to take screenshot, please try again later.".to_owned(); + } else { + fn encode_png(width: usize, height: usize, rgba: Vec) -> ResultType> { + let mut png = Vec::new(); + let mut encoder = + repng::Options::smallest(width as _, height as _).build(&mut png)?; + encoder.write(&rgba)?; + encoder.finish()?; + Ok(png) + } + match encode_png(w as _, h as _, data) { + Ok(png) => { + response.data = png.into(); + } + Err(e) => { + response.msg = format!("Error encoding png: {}", e); + } + } + } + } else { + response.msg = msg; + } + let mut msg_out = Message::new(); + msg_out.set_screenshot_response(response); + if let Err(e) = screenshot + .tx + .send((hbb_common::tokio::time::Instant::now(), Arc::new(msg_out))) + { + log::error!("Failed to send screenshot, {}", e); + } +} diff --git a/src/server/wayland.rs b/src/server/wayland.rs index 5560cb95e..1e0efc0f4 100644 --- a/src/server/wayland.rs +++ b/src/server/wayland.rs @@ -1,32 +1,47 @@ use super::*; -use hbb_common::{allow_err, platform::linux::DISTRO}; -use scrap::{is_cursor_embedded, set_map_err, Capturer, Display, Frame, TraitCapturer}; +use hbb_common::{allow_err, anyhow, platform::linux::DISTRO}; +use scrap::{ + is_cursor_embedded, set_map_err, + wayland::pipewire::{fill_displays, try_fix_logical_size}, + Capturer, Display, Frame, TraitCapturer, +}; +use std::collections::HashMap; use std::io; -use std::process::{Command, Output}; use crate::{ client::{ - SCRAP_OTHER_VERSION_OR_X11_REQUIRED, SCRAP_UBUNTU_HIGHER_REQUIRED, SCRAP_X11_REQUIRED, + SCRAP_OTHER_VERSION_OR_X11_REQUIRED, SCRAP_UBUNTU_HIGHER_REQUIRED, + SCRAP_X11_REQUIRED, SCRAP_XDP_PORTAL_UNAVAILABLE, }, platform::linux::is_x11, }; lazy_static::lazy_static! { - static ref CAP_DISPLAY_INFO: RwLock = RwLock::new(0); + static ref CAP_DISPLAY_INFO: RwLock> = RwLock::new(HashMap::new()); + static ref PIPEWIRE_INITIALIZED: RwLock = RwLock::new(false); static ref LOG_SCRAP_COUNT: Mutex = Mutex::new(0); + static ref ACTIVE_DISPLAY_COUNT: RwLock = RwLock::new(0); } pub fn init() { set_map_err(map_err_scrap); } -fn map_err_scrap(err: String) -> io::Error { - // to-do: Remove this the following log - log::error!( - "REMOVE ME ===================================== wayland scrap error {}", - &err - ); +pub(super) fn increment_active_display_count() -> usize { + let mut count = ACTIVE_DISPLAY_COUNT.write().unwrap(); + *count += 1; + *count +} +pub(super) fn decrement_active_display_count() -> usize { + let mut count = ACTIVE_DISPLAY_COUNT.write().unwrap(); + if *count > 0 { + *count -= 1; + } + *count +} + +fn map_err_scrap(err: String) -> io::Error { // to-do: Handle error better, do not restart server if err.starts_with("Did not receive a reply") { log::error!("Fatal pipewire error, {}", &err); @@ -42,10 +57,15 @@ fn map_err_scrap(err: String) -> io::Error { } } else { try_log(&err); - if err.contains("org.freedesktop.portal") - || err.contains("pipewire") - || err.contains("dbus") + let err_lower = err.to_ascii_lowercase(); + if err_lower.contains("org.freedesktop.portal") + || err_lower.contains("dbus") + || err_lower.contains("d-bus") { + // The portal D-Bus interface is unreachable. This typically means + // xdg-desktop-portal has crashed... for more info, see: Issue #12897 + io::Error::new(io::ErrorKind::Other, SCRAP_XDP_PORTAL_UNAVAILABLE) + } else if err_lower.contains("pipewire") { io::Error::new(io::ErrorKind::Other, SCRAP_OTHER_VERSION_OR_X11_REQUIRED) } else { io::Error::new(io::ErrorKind::Other, SCRAP_X11_REQUIRED) @@ -73,7 +93,7 @@ impl Clone for CapturerPtr { } impl TraitCapturer for CapturerPtr { - fn frame<'a>(&'a mut self, timeout: Duration) -> io::Result> { + fn frame<'a>(&'a mut self, timeout: std::time::Duration) -> std::io::Result> { unsafe { (*self.0).frame(timeout) } } } @@ -96,7 +116,7 @@ pub(super) fn is_inited() -> Option { if is_x11() { None } else { - if *CAP_DISPLAY_INFO.read().unwrap() == 0 { + if CAP_DISPLAY_INFO.read().unwrap().is_empty() { let mut msg_out = Message::new(); let res = MessageBox { msgtype: "nook-nocancel-hasclose".to_owned(), @@ -113,37 +133,48 @@ pub(super) fn is_inited() -> Option { } } -fn get_max_desktop_resolution() -> Option { - // works with Xwayland - let output: Output = Command::new("sh") - .arg("-c") - .arg("xrandr | awk '/current/ { print $8,$9,$10 }'") - .output() - .ok()?; - - if output.status.success() { - let result = String::from_utf8_lossy(&output.stdout); - Some(result.trim().to_string()) - } else { - None - } -} - pub(super) async fn check_init() -> ResultType<()> { if !is_x11() { - let mut minx = 0; - let mut maxx = 0; - let mut miny = 0; - let mut maxy = 0; - let use_uinput = crate::input_service::wayland_use_uinput(); + if CAP_DISPLAY_INFO.read().unwrap().is_empty() { + if crate::input_service::wayland_use_uinput() { + if let Some((minx, maxx, miny, maxy)) = + scrap::wayland::display::get_desktop_rect_for_uinput() + { + log::info!( + "update mouse resolution: ({}, {}), ({}, {})", + minx, + maxx, + miny, + maxy + ); + allow_err!( + input_service::update_mouse_resolution(minx, maxx, miny, maxy).await + ); + } else { + log::warn!("Failed to get desktop rect for uinput"); + } + } - if *CAP_DISPLAY_INFO.read().unwrap() == 0 { let mut lock = CAP_DISPLAY_INFO.write().unwrap(); - if *lock == 0 { + if lock.is_empty() { + // Check if PipeWire is already initialized to prevent duplicate recorder creation + if *PIPEWIRE_INITIALIZED.read().unwrap() { + log::warn!("wayland_diag: Preventing duplicate PipeWire initialization"); + return Ok(()); + } + let mut all = Display::all()?; + log::debug!("Initializing displays with fill_displays()"); + { + let temp_mouse_move_handle = input_service::TemporaryMouseMoveHandle::new(); + let move_mouse_to = |x, y| temp_mouse_move_handle.move_mouse_to(x, y); + fill_displays(move_mouse_to, crate::get_cursor_pos, &mut all)?; + } + log::debug!("Attempting to fix logical size with try_fix_logical_size()"); + try_fix_logical_size(&mut all); + *PIPEWIRE_INITIALIZED.write().unwrap() = true; let num = all.len(); let primary = super::display_service::get_primary_2(&all); - let current = primary; super::display_service::check_update_displays(&all); let mut displays = super::display_service::get_sync_displays(); for display in displays.iter_mut() { @@ -155,69 +186,34 @@ pub(super) async fn check_init() -> ResultType<()> { rects.push((d.origin(), d.width(), d.height())); } - let display = all.remove(current); - let (origin, width, height) = (display.origin(), display.width(), display.height()); log::debug!( - "#displays={}, current={}, origin: {:?}, width={}, height={}, cpus={}/{}", - num, - current, - &origin, - width, - height, - num_cpus::get_physical(), - num_cpus::get(), - ); - - if use_uinput { - let (max_width, max_height) = match get_max_desktop_resolution() { - Some(result) if !result.is_empty() => { - let resolution: Vec<&str> = result.split(" ").collect(); - let w: i32 = resolution[0].parse().unwrap_or(origin.0 + width as i32); - let h: i32 = resolution[2] - .trim_end_matches(",") - .parse() - .unwrap_or(origin.1 + height as i32); - if w < origin.0 + width as i32 || h < origin.1 + height as i32 { - (origin.0 + width as i32, origin.1 + height as i32) - } else { - (w, h) - } - } - _ => (origin.0 + width as i32, origin.1 + height as i32), - }; - - minx = 0; - maxx = max_width; - miny = 0; - maxy = max_height; - } - - let capturer = Box::into_raw(Box::new( - Capturer::new(display).with_context(|| "Failed to create capturer")?, - )); - let capturer = CapturerPtr(capturer); - let cap_display_info = Box::into_raw(Box::new(CapDisplayInfo { - rects, - displays, + "#displays={}, primary={}, rects: {:?}, cpus={}/{}", num, primary, - current, - capturer, - })); - *lock = cap_display_info as _; - } - } - - if use_uinput { - if minx != maxx && miny != maxy { - log::info!( - "update mouse resolution: ({}, {}), ({}, {})", - minx, - maxx, - miny, - maxy + rects, + num_cpus::get_physical(), + num_cpus::get() ); - allow_err!(input_service::update_mouse_resolution(minx, maxx, miny, maxy).await); + + // Create individual CapDisplayInfo for each display with its own capturer + for (idx, display) in all.into_iter().enumerate() { + let capturer = + Box::into_raw(Box::new(Capturer::new(display).with_context(|| { + format!("Failed to create capturer for display {}", idx) + })?)); + let capturer = CapturerPtr(capturer); + + let cap_display_info = Box::into_raw(Box::new(CapDisplayInfo { + rects: rects.clone(), + displays: displays.clone(), + num, + primary, + current: idx, + capturer, + })); + + lock.insert(idx, cap_display_info as u64); + } } } } @@ -226,9 +222,9 @@ pub(super) async fn check_init() -> ResultType<()> { pub(super) async fn get_displays() -> ResultType> { check_init().await?; - let addr = *CAP_DISPLAY_INFO.read().unwrap(); - if addr != 0 { - let cap_display_info: *const CapDisplayInfo = addr as _; + let cap_map = CAP_DISPLAY_INFO.read().unwrap(); + if let Some(addr) = cap_map.values().next() { + let cap_display_info: *const CapDisplayInfo = *addr as _; unsafe { let cap_display_info = &*cap_display_info; Ok(cap_display_info.displays.clone()) @@ -239,9 +235,9 @@ pub(super) async fn get_displays() -> ResultType> { } pub(super) fn get_primary() -> ResultType { - let addr = *CAP_DISPLAY_INFO.read().unwrap(); - if addr != 0 { - let cap_display_info: *const CapDisplayInfo = addr as _; + let cap_map = CAP_DISPLAY_INFO.read().unwrap(); + if let Some(addr) = cap_map.values().next() { + let cap_display_info: *const CapDisplayInfo = *addr as _; unsafe { let cap_display_info = &*cap_display_info; Ok(cap_display_info.primary) @@ -256,23 +252,28 @@ pub fn clear() { return; } let mut write_lock = CAP_DISPLAY_INFO.write().unwrap(); - if *write_lock != 0 { - let cap_display_info: *mut CapDisplayInfo = *write_lock as _; + for (_, addr) in write_lock.iter() { + let cap_display_info: *mut CapDisplayInfo = *addr as _; unsafe { let _box_capturer = Box::from_raw((*cap_display_info).capturer.0); let _box_cap_display_info = Box::from_raw(cap_display_info); - *write_lock = 0; } } + write_lock.clear(); + + // Reset PipeWire initialization flag to allow recreation on next init + *PIPEWIRE_INITIALIZED.write().unwrap() = false; } -pub(super) fn get_capturer() -> ResultType { +pub(super) fn get_capturer_for_display( + display_idx: usize, +) -> ResultType { if is_x11() { bail!("Do not call this function if not wayland"); } - let addr = *CAP_DISPLAY_INFO.read().unwrap(); - if addr != 0 { - let cap_display_info: *const CapDisplayInfo = addr as _; + let cap_map = CAP_DISPLAY_INFO.read().unwrap(); + 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]; @@ -288,7 +289,10 @@ pub(super) fn get_capturer() -> ResultType { }) } } else { - bail!("Failed to get capturer display info"); + bail!( + "Failed to get capturer display info for display {}", + display_idx + ); } } diff --git a/src/service.rs b/src/service.rs new file mode 100644 index 000000000..ce1855bdb --- /dev/null +++ b/src/service.rs @@ -0,0 +1,11 @@ +use librustdesk::*; + +#[cfg(not(target_os = "macos"))] +fn main() {} + +#[cfg(target_os = "macos")] +fn main() { + crate::common::load_custom_client(); + hbb_common::init_log(false, "service"); + crate::start_os_service(); +} diff --git a/src/tray.rs b/src/tray.rs index 3a3ae92f3..e8db0efc0 100644 --- a/src/tray.rs +++ b/src/tray.rs @@ -10,17 +10,15 @@ use std::time::Duration; pub fn start_tray() { if crate::ui_interface::get_builtin_option(hbb_common::config::keys::OPTION_HIDE_TRAY) == "Y" { - #[cfg(target_os = "macos")] - { - loop { - std::thread::sleep(std::time::Duration::from_secs(1)); - } - } #[cfg(not(target_os = "macos"))] { return; } } + + #[cfg(target_os = "linux")] + crate::server::check_zombie(); + allow_err!(make_tray()); } @@ -56,9 +54,22 @@ fn make_tray() -> hbb_common::ResultType<()> { let mut event_loop = EventLoopBuilder::new().build(); let tray_menu = Menu::new(); - let quit_i = MenuItem::new(translate("Exit".to_owned()), true, None); + let hide_stop_service = crate::ui_interface::get_builtin_option( + hbb_common::config::keys::OPTION_HIDE_STOP_SERVICE, + ) == "Y"; + // The tray icon is only shown when the service is running, so we don't need to check + // the `stop-service` option here. + let quit_i = if !hide_stop_service { + Some(MenuItem::new(translate("Stop service".to_owned()), true, None)) + } else { + None + }; let open_i = MenuItem::new(translate("Open".to_owned()), true, None); - tray_menu.append_items(&[&open_i, &quit_i]).ok(); + if let Some(quit_i) = &quit_i { + tray_menu.append_items(&[&open_i, quit_i]).ok(); + } else { + tray_menu.append_items(&[&open_i]).ok(); + } let tooltip = |count: usize| { if count == 0 { format!( @@ -99,9 +110,11 @@ fn make_tray() -> hbb_common::ResultType<()> { } #[cfg(target_os = "linux")] { - // Do not use "xdg-open", it won't read config + // Do not use "xdg-open", it won't read the config. if crate::dbus::invoke_new_connection(crate::get_uri_prefix()).is_err() { - crate::run_me::<&str>(vec![]).ok(); + if let Ok(task) = crate::run_me::<&str>(vec![]) { + crate::server::CHILD_PROCESS.lock().unwrap().push(task); + } } } }; @@ -123,6 +136,11 @@ fn make_tray() -> hbb_common::ResultType<()> { ); if let tao::event::Event::NewEvents(tao::event::StartCause::Init) = event { + // for fixing https://github.com/rustdesk/rustdesk/discussions/10210#discussioncomment-14600745 + // so we start tray, but not to show it + if crate::ui_interface::get_builtin_option(hbb_common::config::keys::OPTION_HIDE_TRAY) == "Y" { + return; + } // 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 tray = TrayIconBuilder::new() @@ -150,15 +168,19 @@ fn make_tray() -> hbb_common::ResultType<()> { } if let Ok(event) = menu_channel.try_recv() { - if event.id == quit_i.id() { - /* failed in windows, seems no permission to check system process - if !crate::check_process("--server", false) { - *control_flow = ControlFlow::Exit; - return; - } - */ - if !crate::platform::uninstall_service(false, false) { - *control_flow = ControlFlow::Exit; + if let Some(quit_i) = &quit_i { + if event.id == quit_i.id() { + /* failed in windows, seems no permission to check system process + if !crate::check_process("--server", false) { + *control_flow = ControlFlow::Exit; + return; + } + */ + if !crate::platform::uninstall_service(false, false) { + *control_flow = ControlFlow::Exit; + } + } else if event.id == open_i.id() { + open_func(); } } else if event.id == open_i.id() { open_func(); diff --git a/src/ui.rs b/src/ui.rs index d3d291433..6d0d0927a 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -42,7 +42,7 @@ pub fn start(args: &mut [String]) { #[cfg(all(target_os = "linux", feature = "inline"))] { let app_dir = std::env::var("APPDIR").unwrap_or("".to_string()); - let mut so_path = "/usr/lib/rustdesk/libsciter-gtk.so".to_owned(); + let mut so_path = "/usr/share/rustdesk/libsciter-gtk.so".to_owned(); for (prefix, dir) in [ ("", "/usr"), ("", "/app"), @@ -51,7 +51,7 @@ pub fn start(args: &mut [String]) { ] .iter() { - let path = format!("{prefix}{dir}/lib/rustdesk/libsciter-gtk.so"); + let path = format!("{prefix}{dir}/share/rustdesk/libsciter-gtk.so"); if std::path::Path::new(&path).exists() { so_path = path; break; @@ -118,6 +118,11 @@ pub fn start(args: &mut [String]) { Box::new(cm::SciterConnectionManager::new()) }); page = "cm.html"; + *cm::HIDE_CM.lock().unwrap() = crate::ipc::get_config("hide_cm") + .ok() + .flatten() + .unwrap_or_default() + == "true"; } else if (args[0] == "--connect" || args[0] == "--file-transfer" || args[0] == "--port-forward" @@ -178,6 +183,13 @@ pub fn start(args: &mut [String]) { .unwrap_or("".to_owned()), page )); + let hide_cm = *cm::HIDE_CM.lock().unwrap(); + if !args.is_empty() && args[0] == "--cm" && hide_cm { + // run_app calls expand(show) + run_loop, we use collapse(hide) + run_loop instead to create a hidden window + frame.collapse(true); + frame.run_loop(); + return; + } frame.run_app(); } @@ -200,12 +212,16 @@ impl UI { update_temporary_password() } - fn permanent_password(&self) -> String { - permanent_password() + fn set_permanent_password(&self, password: String) { + let _ = set_permanent_password_with_result(password); } - fn set_permanent_password(&self, password: String) { - set_permanent_password(password); + fn is_local_permanent_password_set(&self) -> bool { + is_local_permanent_password_set() + } + + fn is_permanent_password_set(&self) -> bool { + is_permanent_password_set() } fn get_remote_id(&mut self) -> String { @@ -272,6 +288,34 @@ impl UI { crate::using_public_server() } + fn is_incoming_only(&self) -> bool { + hbb_common::config::is_incoming_only() + } + + pub fn is_outgoing_only(&self) -> bool { + hbb_common::config::is_outgoing_only() + } + + pub fn is_custom_client(&self) -> bool { + crate::common::is_custom_client() + } + + pub fn is_disable_settings(&self) -> bool { + hbb_common::config::is_disable_settings() + } + + pub fn is_disable_account(&self) -> bool { + hbb_common::config::is_disable_account() + } + + pub fn is_disable_installation(&self) -> bool { + hbb_common::config::is_disable_installation() + } + + pub fn is_disable_ab(&self) -> bool { + hbb_common::config::is_disable_ab() + } + fn get_options(&self) -> Value { let hashmap: HashMap = serde_json::from_str(&get_options()).unwrap_or_default(); @@ -328,6 +372,11 @@ impl UI { is_installed() } + fn get_supported_privacy_mode_impls(&self) -> String { + serde_json::to_string(&crate::privacy_mode::get_supported_privacy_mode_impl()) + .unwrap_or_default() + } + fn is_root(&self) -> bool { is_root() } @@ -634,6 +683,10 @@ impl UI { verify2fa(code) } + fn verify_login(&self, raw: String, id: String) -> bool { + crate::verify_login(&raw, &id) + } + fn generate_2fa_img_src(&self, data: String) -> String { let v = qrcode_generator::to_png_to_vec(data, qrcode_generator::QrCodeEcc::Low, 128) .unwrap_or_default(); @@ -647,6 +700,23 @@ impl UI { pub fn check_hwcodec(&self) { check_hwcodec() } + + fn is_option_fixed(&self, key: String) -> bool { + crate::ui_interface::is_option_fixed(&key) + } + + fn get_builtin_option(&self, key: String) -> String { + crate::ui_interface::get_builtin_option(&key) + } + + fn is_remote_modify_enabled_by_control_permissions(&self) -> String { + match crate::ui_interface::is_remote_modify_enabled_by_control_permissions() { + Some(true) => "true", + Some(false) => "false", + None => "", + } + .to_string() + } } impl sciter::EventHandler for UI { @@ -655,11 +725,19 @@ impl sciter::EventHandler for UI { fn get_api_server(); fn is_xfce(); fn using_public_server(); + fn is_custom_client(); + fn is_outgoing_only(); + fn is_incoming_only(); + fn is_disable_settings(); + fn is_disable_account(); + fn is_disable_installation(); + fn is_disable_ab(); fn get_id(); fn temporary_password(); fn update_temporary_password(); - fn permanent_password(); fn set_permanent_password(String); + fn is_local_permanent_password_set(); + fn is_permanent_password_set(); fn get_remote_id(); fn set_remote_id(String); fn closing(i32, i32, i32, i32); @@ -679,6 +757,7 @@ impl sciter::EventHandler for UI { fn get_icon(); fn install_me(String, String); fn is_installed(); + fn get_supported_privacy_mode_impls(); fn is_root(); fn is_release(); fn set_socks(String, String, String); @@ -739,6 +818,10 @@ impl sciter::EventHandler for UI { fn generate_2fa_img_src(String); fn verify2fa(String); fn check_hwcodec(); + fn verify_login(String, String); + fn is_option_fixed(String); + fn get_builtin_option(String); + fn is_remote_modify_enabled_by_control_permissions(); } } diff --git a/src/ui/ab.tis b/src/ui/ab.tis index 2c8724750..d0c2e9edf 100644 --- a/src/ui/ab.tis +++ b/src/ui/ab.tis @@ -543,15 +543,15 @@ class MultipleSessions: Reactor.Component { {translate('Recent sessions')} {translate('Favorites')} {handler.is_installed() && {translate('Discovered')}} - {translate('Address book')} + {!disable_account && !disable_ab && {translate('Address book')}} - {!this.hidden && } - {!this.hidden && } + {!this.hidden && !(disable_account && type == "ab") && } + {!this.hidden && !(disable_account && type == "ab") && } {!this.hidden && ((type == "fav" && ) || (type == "lan" && handler.is_installed() && ) || - (type == "ab" && ) || + (type == "ab" && !disable_account && !disable_ab && ) || )} ; } diff --git a/src/ui/chatbox.html b/src/ui/chatbox.html index 10d85a567..87d616289 100644 --- a/src/ui/chatbox.html +++ b/src/ui/chatbox.html @@ -12,7 +12,11 @@ include "common.tis"; var p = view.parameters; view.refresh = function() { + var draft_input = $(input); + var draft = draft_input ? (draft_input.value || "") : ""; $(body).content(); + var next_input = $(input); + if (next_input) next_input.value = draft; view.focus = $(input); } function self.closing() { diff --git a/src/ui/cm.css b/src/ui/cm.css index baa774309..3ac6c7be3 100644 --- a/src/ui/cm.css +++ b/src/ui/cm.css @@ -57,6 +57,11 @@ div.icon { font-weight: bold; } +img.icon { + size: 96px; + border-radius: 8px; +} + div.id { @ELLIPSIS; color: color(green-blue); @@ -88,6 +93,13 @@ div.permissions > div:active { opacity: 0.5; } +div.permissions.locked, +div.permissions.locked *, +div.permissions.locked > div:active { + cursor: default !important; + opacity: 1; +} + icon.keyboard { background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAAAgVBMVEUAAAD///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////9d3yJTAAAAKnRSTlMA0Gd/0y8ILZgbJffDPUwV2nvzt+TMqZxyU7CMb1pYQyzsvKunkXE4AwJnNC24AAAA+0lEQVQ4y83O2U7DMBCF4ZMxk9rZk26kpQs7nPd/QJy4EiLbLf01N5Y/2YP/qxDFQvGB5NPC/ZpVnfJx4b5xyGfF95rkHvNCWH1u+N6J6T0sC7gqRy8uGPfBLEbozPXUjlkQKwGaFPNizwQbwkx0TDvhCii34ExZCSQVBdzIOEOyeclSHgBGXkpeygXSQgStACtWx4Z8rr8COHOvfEP/IbbsQAToFUAAV1M408IIjIGYAPoCSNRP7DQutfQTqxuAiH7UUg1FaJR2AGrrx52sK2ye28LZ0wBAEyR6y8X+NADhm1B4fgiiHXbRrTrxpwEY9RdM9wsepnvFHfUDwYEeiwAJr/gAAAAASUVORK5CYII='); } @@ -116,6 +128,10 @@ icon.block_input { background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAjdJREFUWEe1V8tNAzEQfXOHAx2QG0UgQSqBFIIgHdABoQqOhBq4cCMlcMh90FvZq/HEXtvJxlKUZNceP783no+gY6jqNYBHAHcA+JufXTDBb37eRWTbalZqE82mz7W55v0ABMBGRCLA7PJJAKr6AiC3sT11NHyf2SEyQjvtAMKp3wBYo9VTGbYegjxxU65d5tg4YEBVbwF8ALgw2lLX4in80QqyZUEkAMLCb7P5n4hcdWifTA32Pg0bByA8AE4+oL3n9A1s7ERkEeeNAJzD/QC4OVaCAgjrU7wdK86zAHREJSKqyvvORRxVb67JFOT4NfYGpxwAqCo34oYcKxHZhOdzg7D2BhYigHj6RJ+5QbjrPezlqR61sZTOKYfztSUBWPoXpdA5FwjnC2sCGK+eiNRC8yw+oap0RiayLQHEPwf65zx7DibMoXcEEB0wq/85QJQAbEVkWbvP8f0pTFi/65ZgjtuRyJ7QYWL0OZnwTmiLDobH5nLqGDlUlcmON49jQwnsg/Wxma/VJ1zcGQIR7+OYJGyqbJWhhwlDPxh3JpNRL4Ba7nAsJckoYaFUv7UCyslBvQ3TNDWEfVsPJGH2FCkKTPAxD8ox+poFwJfZqqX15H6eYyK+TgJeriidLCJ7wAQHZ4Udy7u9iFxaG7mynEx4EF1leZDANzV7AE8i8joJICz2cvBxbExIYTZYTTQmxTxTzP+VnvC8rZlLOLEj7m5OW6JqtTs2US6247Hvy7XnX0OV05FP/gHde5fLZaGS8AAAAABJRU5ErkJggg=='); } +icon.privacy_mode { + background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAB7UlEQVR4AdyTrVYDMRCFuyjqiiuuOJA46sCVR6jDgQTXN+CgQIJCgkOCA0cduOLAgaOOuuW7czYhyWY5FcXQc28n85O5m9nsUuuPf/9IoCzLLnxd9MTCET3SvNckQnwL7lfcpnYueIGiKNbY8QYjERo+wZK4HuAcK94rVvGSWCO8gCqKjAixTXLPsAl7ldBxriASqAo6lfUnqUTaWAP5FajTYjxGCNXeYSRAwSflToBlKxSZKSCiMoUa6Uh+QNW/B37LC9D8lkTYHNegTf7JqNP8b5RB5AT7AkPoNqqXxUyATT28AUzhRuFFaLpDUYc9V1ihr7+EA/JdxUyAxQTWQDM3CuVSEWugGiUztJ5OIJPPhlKRbFEVXJZ1Anph8iNyTCsieA0dvIgCQY3ckBtyTIBjfuDcwRR2TPJDElkRcrpd6XcyJm7X2ATY3CKwi1UxxkNPeyiP/BAa8LVZObtdBMOPcYbvX7wXYJNE2lidBuNxyhgm0I1LCdcgFXmguXqoxhgJKELBKvYMhljH+ULEwDr8mEIRXWHSP6gJKIXIESxYh3PHzWJK1IuwjpAVcBWIhHPX0x2QE/vkHGofIzUevwr4KhZ003wvsOKYkAcxXfPoxbvk3AJuQ5MNRNwFsNKFCaibRGB0CxcqIJGU3wAAAP//8GtoDAAAAAZJREFUAwCJJuAxFVNbWwAAAABJRU5ErkJggg=='); +} + div.outer_buttons { flow:vertical; border-spacing:8; diff --git a/src/ui/cm.rs b/src/ui/cm.rs index c8c8c657f..4a68a571d 100644 --- a/src/ui/cm.rs +++ b/src/ui/cm.rs @@ -7,6 +7,10 @@ use sciter::{make_args, Element, Value, HELEMENT}; use std::sync::Mutex; use std::{ops::Deref, sync::Arc}; +lazy_static::lazy_static! { + pub static ref HIDE_CM: Arc> = Arc::new(Mutex::new(false)); +} + #[derive(Clone, Default)] pub struct SciterHandler { pub element: Arc>>, @@ -19,9 +23,12 @@ impl InvokeUiCM for SciterHandler { &make_args!( client.id, client.is_file_transfer, + client.is_view_camera, + client.is_terminal, client.port_forward.clone(), client.peer_id.clone(), client.name.clone(), + client.avatar.clone(), client.authorized, client.keyboard, client.clipboard, @@ -29,7 +36,8 @@ impl InvokeUiCM for SciterHandler { client.file, client.restart, client.recording, - client.block_input + client.block_input, + client.privacy_mode ), ); } @@ -45,12 +53,12 @@ impl InvokeUiCM for SciterHandler { self.call("newMessage", &make_args!(id, text)); } - fn change_theme(&self, _dark: String) { - // TODO + fn change_theme(&self, dark: String) { + self.call("changeTheme", &make_args!(dark)); } fn change_language(&self) { - // TODO + self.call("changeLanguage", &make_args!()); } fn show_elevation(&self, show: bool) { @@ -149,6 +157,19 @@ impl SciterConnectionManager { fn get_option(&self, key: String) -> String { crate::ui_interface::get_option(key) } + + fn get_builtin_option(&self, key: String) -> String { + crate::ui_interface::get_builtin_option(&key) + } + + fn hide_cm(&self) -> bool { + *crate::ui::cm::HIDE_CM.lock().unwrap() + } + + fn get_supported_privacy_mode_impls(&self) -> String { + serde_json::to_string(&crate::privacy_mode::get_supported_privacy_mode_impl()) + .unwrap_or_default() + } } impl sciter::EventHandler for SciterConnectionManager { @@ -170,5 +191,8 @@ impl sciter::EventHandler for SciterConnectionManager { fn can_elevate(); fn elevate_portable(i32); fn get_option(String); + fn get_builtin_option(String); + fn hide_cm(); + fn get_supported_privacy_mode_impls(); } } diff --git a/src/ui/cm.tis b/src/ui/cm.tis index 38f5c5c2d..f306e9032 100644 --- a/src/ui/cm.tis +++ b/src/ui/cm.tis @@ -4,8 +4,18 @@ var body; var connections = []; var show_chat = false; var show_elevation = true; +var is_privacy_mode_supported = handler.get_supported_privacy_mode_impls() != '[]'; +var allow_perm_change_in_accept_window = + handler.get_builtin_option('enable-perm-change-in-accept-window') != 'N'; var svg_elevate = ; +var hide_cm = undefined; +function setWindowState(state) { + if (hide_cm == undefined) hide_cm = handler.hide_cm(); + if (hide_cm) return; + view.windowState = state; +} + class Body: Reactor.Component { this var cur = 0; @@ -28,16 +38,19 @@ class Body: Reactor.Component me.sendMsg(msg); }; var right_style = show_chat ? "" : "display: none"; + var permissions_locked = !allow_perm_change_in_accept_window; var disconnected = c.disconnected; - var show_elevation_btn = handler.can_elevate() && show_elevation && !c.is_file_transfer && c.port_forward.length == 0; + var show_elevation_btn = handler.can_elevate() && show_elevation && !c.is_file_transfer && !c.is_view_camera && !c.is_terminal && c.port_forward.length == 0; var show_accept_btn = handler.get_option('approve-mode') != 'password'; // below size:* is a workaround for Linux, it already set in css, but not work, shit sciter return

+ {c.avatar ? + :
{c.name[0].toUpperCase()} -
+
}
{c.name}
({c.peer_id})
@@ -48,18 +61,22 @@ class Body: Reactor.Component
- {c.is_file_transfer || c.port_forward || disconnected ? "" :
{translate('Permissions')}
} - {c.is_file_transfer || c.port_forward || disconnected ? "" :
+ {c.is_file_transfer || c.is_terminal || c.port_forward || disconnected ? "" :
{translate('Permissions')}
} + {c.is_file_transfer || c.is_terminal || c.port_forward || disconnected ? "" :
-
+
+
} + {c.is_file_transfer ?
{translate('Transfer file')}
: ""} + {c.is_view_camera ?
{translate('View camera')}
: ""} + {c.is_terminal ?
{translate('Terminal')}
: ""} {c.port_forward ?
Port Forwarding: {c.port_forward}
: ""}
@@ -72,10 +89,10 @@ class Body: Reactor.Component {auth && !disconnected ? : "" } {auth && disconnected ? : "" }
- {c.is_file_transfer || c.port_forward ? "" :
{svg_chat}
} + {c.is_file_transfer || c.is_terminal || c.port_forward ? "" :
{svg_chat}
}
- {c.is_file_transfer || c.port_forward ? "" : } + {c.is_file_transfer || c.is_terminal || c.port_forward ? "" : }
; } @@ -91,6 +108,7 @@ class Body: Reactor.Component } event click $(icon.keyboard) (e) { + if (!allow_perm_change_in_accept_window) return; var { cid, connection } = this; checkClickTime(function() { connection.keyboard = !connection.keyboard; @@ -100,6 +118,7 @@ class Body: Reactor.Component } event click $(icon.clipboard) { + if (!allow_perm_change_in_accept_window) return; var { cid, connection } = this; checkClickTime(function() { connection.clipboard = !connection.clipboard; @@ -109,6 +128,7 @@ class Body: Reactor.Component } event click $(icon.audio) { + if (!allow_perm_change_in_accept_window) return; var { cid, connection } = this; checkClickTime(function() { connection.audio = !connection.audio; @@ -118,6 +138,7 @@ class Body: Reactor.Component } event click $(icon.file) { + if (!allow_perm_change_in_accept_window) return; var { cid, connection } = this; checkClickTime(function() { connection.file = !connection.file; @@ -127,6 +148,7 @@ class Body: Reactor.Component } event click $(icon.restart) { + if (!allow_perm_change_in_accept_window) return; var { cid, connection } = this; checkClickTime(function() { connection.restart = !connection.restart; @@ -136,6 +158,7 @@ class Body: Reactor.Component } event click $(icon.recording) { + if (!allow_perm_change_in_accept_window) return; var { cid, connection } = this; checkClickTime(function() { connection.recording = !connection.recording; @@ -145,6 +168,7 @@ class Body: Reactor.Component } event click $(icon.block_input) { + if (!allow_perm_change_in_accept_window) return; var { cid, connection } = this; checkClickTime(function() { connection.block_input = !connection.block_input; @@ -153,6 +177,16 @@ class Body: Reactor.Component }); } + event click $(icon.privacy_mode) { + if (!allow_perm_change_in_accept_window) return; + var { cid, connection } = this; + checkClickTime(function() { + connection.privacy_mode = !connection.privacy_mode; + body.update(); + handler.switch_permission(cid, "privacy_mode", connection.privacy_mode); + }); + } + event click $(button#accept) { var { cid, connection } = this; checkClickTime(function() { @@ -160,7 +194,7 @@ class Body: Reactor.Component body.update(); handler.authorize(cid); self.timer(30ms, function() { - view.windowState = View.WINDOW_MINIMIZED; + setWindowState(View.WINDOW_MINIMIZED); }); }); } @@ -174,7 +208,7 @@ class Body: Reactor.Component handler.elevate_portable(cid); handler.authorize(cid); self.timer(30ms, function() { - view.windowState = View.WINDOW_MINIMIZED; + setWindowState(View.WINDOW_MINIMIZED); }); }); } @@ -186,7 +220,7 @@ class Body: Reactor.Component body.update(); handler.elevate_portable(cid); self.timer(30ms, function() { - view.windowState = View.WINDOW_MINIMIZED; + setWindowState(View.WINDOW_MINIMIZED); }); }); } @@ -347,7 +381,7 @@ function bring_to_top(idx=-1) { if (is_linux) { view.focus = self; } else { - view.windowState = View.WINDOW_SHOWN; + setWindowState(View.WINDOW_SHOWN); } if (idx >= 0) body.cur = idx; } else { @@ -356,7 +390,7 @@ function bring_to_top(idx=-1) { } } -handler.addConnection = function(id, is_file_transfer, port_forward, peer_id, name, authorized, keyboard, clipboard, audio, file, restart, recording, block_input) { +handler.addConnection = function(id, is_file_transfer, is_view_camera, is_terminal, port_forward, peer_id, name, avatar, authorized, keyboard, clipboard, audio, file, restart, recording, block_input, privacy_mode) { stdout.println("new connection #" + id + ": " + peer_id); var conn; connections.map(function(c) { @@ -364,6 +398,7 @@ handler.addConnection = function(id, is_file_transfer, port_forward, peer_id, na }); if (conn) { conn.authorized = authorized; + conn.privacy_mode = privacy_mode; update(); return; } @@ -373,12 +408,13 @@ handler.addConnection = function(id, is_file_transfer, port_forward, peer_id, na }); if (!name) name = "NA"; conn = { - id: id, is_file_transfer: is_file_transfer, peer_id: peer_id, + id: id, is_file_transfer: is_file_transfer, is_view_camera: is_view_camera, is_terminal: is_terminal, peer_id: peer_id, port_forward: port_forward, + avatar: avatar, name: name, authorized: authorized, time: new Date(), now: new Date(), keyboard: keyboard, clipboard: clipboard, msgs: [], unreaded: 0, audio: audio, file: file, restart: restart, recording: recording, - block_input:block_input, + block_input:block_input, privacy_mode:privacy_mode, disconnected: false }; if (idx < 0) { @@ -393,7 +429,7 @@ handler.addConnection = function(id, is_file_transfer, port_forward, peer_id, na self.timer(1ms, adjustHeader); if (authorized) { self.timer(3s, function() { - view.windowState = View.WINDOW_MINIMIZED; + setWindowState(View.WINDOW_MINIMIZED); }); } } @@ -467,15 +503,21 @@ function getElapsed(time, now) { return out; } -var ui_status_cache = [""]; +var ui_status_cache = ["", ""]; function check_update_ui() { self.timer(1s, function() { var approve_mode = handler.get_option('approve-mode'); + var allow_perm_change = handler.get_builtin_option('enable-perm-change-in-accept-window'); var changed = false; if (ui_status_cache[0] != approve_mode) { ui_status_cache[0] = approve_mode; changed = true; } + if (ui_status_cache[1] != allow_perm_change) { + ui_status_cache[1] = allow_perm_change; + allow_perm_change_in_accept_window = allow_perm_change != 'N'; + changed = true; + } if (changed) update(); check_update_ui(); }); @@ -506,7 +548,7 @@ var tm0 = getTime(); function self.closing() { if (connections.length == 0 && getTime() - tm0 > 30000) return true; - view.windowState = View.WINDOW_HIDDEN; + setWindowState(View.WINDOW_HIDDEN); return false; } @@ -550,7 +592,7 @@ function adjustHeader() { view.on("size", adjustHeader); -// handler.addConnection(0, false, 0, "", "test1", true, false, false, true, true); -// handler.addConnection(1, false, 0, "", "test2--------", true, false, false, false, false); -// handler.addConnection(2, false, 0, "", "test3", true, false, false, false, false); +// handler.addConnection(0, false, false, 0, "", "test1", true, false, false, true, true); +// handler.addConnection(1, false, false, 0, "", "test2--------", true, false, false, false, false); +// handler.addConnection(2, false, false, 0, "", "test3", true, false, false, false, false); // handler.newMessage(0, 'h'); diff --git a/src/ui/common.css b/src/ui/common.css index ff2f83883..16dd6ca9f 100644 --- a/src/ui/common.css +++ b/src/ui/common.css @@ -72,6 +72,11 @@ button.button:hover, button.outline:hover { border-color: color(hover-border); } +button:disabled, +button:disabled:hover { + opacity: 0.3; +} + button.link { background: none !important; border: none; @@ -458,6 +463,15 @@ div#msgbox div.set-password input { font-size: 1em; } +.wrap-text { + width: *; + word-wrap: break-word; + overflow-wrap: break-word; + white-space: normal; + height: auto; + overflow: hidden; +} + div#msgbox #error { color: red; } @@ -475,4 +489,4 @@ div.user-session select { background: color(bg); color: color(text); padding-left: 0.5em; -} \ No newline at end of file +} diff --git a/src/ui/common.tis b/src/ui/common.tis index b6d2b8ee2..240799059 100644 --- a/src/ui/common.tis +++ b/src/ui/common.tis @@ -18,7 +18,8 @@ function isEnterKey(evt) { function getScaleFactor() { if (!is_win) return 1; - return self.toPixels(10000dip) / 10000.; + var s = self.toPixels(10000dip) / 10000.; + return s < 0.000001 ? 1 : s; } var scaleFactor = getScaleFactor(); view << event resolutionchange { diff --git a/src/ui/file_transfer.tis b/src/ui/file_transfer.tis index 6c741b31f..1090c018d 100644 --- a/src/ui/file_transfer.tis +++ b/src/ui/file_transfer.tis @@ -69,8 +69,6 @@ function getExt(name) { return ""; } -var jobIdCounter = 1; - class JobTable: Reactor.Component { this var jobs = []; this var job_map = {}; @@ -126,8 +124,7 @@ class JobTable: Reactor.Component { } if (!to) return; to += handler.get_path_sep(!is_remote) + getFileName(is_remote, path); - var id = jobIdCounter; - jobIdCounter += 1; + var id = handler.get_next_job_id(); this.jobs.push({ type: "transfer", id: id, path: path, to: to, include_hidden: show_hidden, @@ -135,20 +132,24 @@ class JobTable: Reactor.Component { is_last: false }); this.job_map[id] = this.jobs[this.jobs.length - 1]; - handler.send_files(id, path, to, 0, show_hidden, is_remote); + handler.send_files(id, 0, path, to, 0, show_hidden, is_remote); var self = this; self.timer(30ms, function() { self.update(); }); } - function addJob(id, path, to, file_num, show_hidden, is_remote) { + function addJob(id, path, to, file_num, show_hidden, is_remote, auto_start) { var job = { type: "transfer", id: id, path: path, to: to, include_hidden: show_hidden, is_remote: is_remote, is_last: true, file_num: file_num }; this.jobs.push(job); this.job_map[id] = this.jobs[this.jobs.length - 1]; - jobIdCounter = id + 1; - handler.add_job(id, path, to, file_num, show_hidden, is_remote); + handler.update_next_job_id(id + 1); + handler.add_job(id, 0, path, to, file_num, show_hidden, is_remote); + if (auto_start) { + this.continueJob(id); + this.update(); + } stdout.println(JSON.stringify(job)); } @@ -162,16 +163,14 @@ class JobTable: Reactor.Component { } function addDelDir(path, is_remote) { - var id = jobIdCounter; - jobIdCounter += 1; + var id = handler.get_next_job_id(); this.jobs.push({ type: "del-dir", id: id, path: path, is_remote: is_remote }); this.job_map[id] = this.jobs[this.jobs.length - 1]; this.update(); } function addDelFile(path, is_remote) { - var id = jobIdCounter; - jobIdCounter += 1; + var id = handler.get_next_job_id(); this.jobs.push({ type: "del-file", id: id, path: path, is_remote: is_remote }); this.job_map[id] = this.jobs[this.jobs.length - 1]; this.update(); @@ -284,7 +283,8 @@ class JobTable: Reactor.Component { if (!err) { handler.remove_dir(job.id, job.path, job.is_remote); refreshDir(job.is_remote); - if (is_remote) file_transfer.remote_folder_view.table.resetCurrent(); + // Use the job's is_remote; local variable `is_remote` is undefined in this scope. + if (job.is_remote) file_transfer.remote_folder_view.table.resetCurrent(); else file_transfer.local_folder_view.table.resetCurrent(); } } else if (!job.no_confirm) { @@ -552,9 +552,9 @@ class FolderView : Reactor.Component { return; } var path = me.joinPath(name); - handler.create_dir(jobIdCounter, path, me.is_remote); - create_dir_jobs[jobIdCounter] = { is_remote: me.is_remote, path: path }; - jobIdCounter += 1; + var id = handler.get_next_job_id(); + handler.create_dir(id, path, me.is_remote); + create_dir_jobs[id] = { is_remote: me.is_remote, path: path }; }); } @@ -702,9 +702,9 @@ handler.clearAllJobs = function() { file_transfer.job_table.clearAllJobs(); } -handler.addJob = function (id, path, to, file_num, show_hidden, is_remote) { // load last job +handler.addJob = function (id, path, to, file_num, show_hidden, is_remote, auto_start) { // load last job // stdout.println("restore job: " + is_remote); - file_transfer.job_table.addJob(id,path,to,file_num,show_hidden,is_remote); + file_transfer.job_table.addJob(id,path,to,file_num,show_hidden,is_remote,auto_start); } handler.updateTransferList = function () { diff --git a/src/ui/header.tis b/src/ui/header.tis index 3116f1f54..40ccbcbf2 100644 --- a/src/ui/header.tis +++ b/src/ui/header.tis @@ -117,6 +117,13 @@ class Header: Reactor.Component { icon_conn = svg_insecure_relay; title_conn = translate("Relayed and unencrypted connection"); } + var stream_type = this.stream_type; + if (stream_type == "Relay") { + stream_type = "TCP"; + } + if (stream_type) { + title_conn += " (" + stream_type + ")"; + } var title = get_id(); if (pi.hostname) title += "(" + pi.username + "@" + pi.hostname + ")"; if ((pi.displays || []).length == 0) { @@ -174,6 +181,13 @@ class Header: Reactor.Component { } } + var is_file_copy_paste_supported = false; + if (handler.version_cmp(pi.version, '1.2.4') < 0) { + is_file_copy_paste_supported = is_win && pi.platform == "Windows"; + } else { + is_file_copy_paste_supported = handler.has_file_clipboard() && pi.platform_additions?.has_file_clipboard; + } + return
  • {translate('Adjust Window')}
  • @@ -201,10 +215,10 @@ class Header: Reactor.Component { {
  • {svg_checkmark}{translate('Follow remote window focus')}
  • }
  • {svg_checkmark}{translate('Show quality monitor')}
  • {audio_enabled ?
  • {svg_checkmark}{translate('Mute')}
  • : ""} - {(is_win && pi.platform == "Windows") && file_enabled ?
  • {svg_checkmark}{translate('Enable file copy and paste')}
  • : ""} + {is_file_copy_paste_supported && file_enabled ?
  • {svg_checkmark}{translate('Enable file copy and paste')}
  • : ""} {keyboard_enabled && clipboard_enabled ?
  • {svg_checkmark}{translate('Disable clipboard')}
  • : ""} {keyboard_enabled ?
  • {svg_checkmark}{translate('Lock after session end')}
  • : ""} - {keyboard_enabled && pi.platform == "Windows" ?
  • {svg_checkmark}{translate('Privacy mode')}
  • : ""} + {(pi.platform == "Windows" || pi.platform == "Mac OS") && (handler.get_toggle_option("privacy-mode") || (keyboard_enabled && privacy_mode_enabled)) ?
  • {svg_checkmark}{translate('Privacy mode')}
  • : ""} {keyboard_enabled && ((is_osx && pi.platform != "Mac OS") || (!is_osx && pi.platform == "Mac OS")) ?
  • {svg_checkmark}{translate('Swap control-command key')}
  • : ""} {handler.version_cmp(pi.version, '1.2.4') >= 0 ?
  • {svg_checkmark}{translate('True color (4:4:4)')}
  • : ""} @@ -223,6 +237,7 @@ class Header: Reactor.Component { {restart_enabled && (pi.platform == "Linux" || pi.platform == "Windows" || pi.platform == "Mac OS") ?
  • {translate('Restart remote device')}
  • : ""} {keyboard_enabled ?
  • {translate('Insert Lock')}
  • : ""} {keyboard_enabled && pi.platform == "Windows" && pi.sas_enabled ?
  • {translate("Block user input")}
  • : ""} + {handler.is_screenshot_supported() ?
  • {translate('Take screenshot')}
  • : "" }
  • {translate('Refresh')}
  • ; @@ -369,6 +384,10 @@ class Header: Reactor.Component { event click $(#lock-screen) { handler.lock_screen(); } + + event click $(#take-screenshot) { + handler.take_screenshot(pi.current_display, ""); + } event click $(#refresh) { // 0 is just a dummy value. It will be ignored by the handler. @@ -408,7 +427,7 @@ class Header: Reactor.Component { adaptDisplay(); } else if (type == "codec-preference") { handler.set_option("codec-preference", me.id); - handler.change_prefer_codec(); + handler.update_supported_decodings(); } toggleMenuState(); } @@ -432,7 +451,7 @@ function handle_custom_image_quality() { var extendedBitrate = bitrate > 100; var maxRate = extendedBitrate ? 2000 : 100; msgbox("custom-image-quality", "Custom Image Quality", "
    \ -
    x% Bitrate More
    \ +
    x% Bitrate More
    \
    ", "", function(res=null) { if (!res) return; if (res.id === "extended-slider") { @@ -539,6 +558,26 @@ handler.setCurrentDisplay = function(v) { } } +handler.screenshot = function(msg) { + if (msg) { + msgbox( + "custom-nocancel-nook-hasclose-error", + translate("Take screenshot"), + msg, + "", + function() {} + ); + } else { + msgbox( + "custom-take-screenshot-nocancel-nook", + translate("Take screenshot"), + translate("screenshot-action-tip"), + "", + function() {} + ); + } +} + function updatePrivacyMode() { var el = $(li#privacy-mode); if (el) { @@ -563,7 +602,13 @@ function togglePrivacyMode(privacy_id) { if (!supported) { msgbox("nocancel", translate("Privacy mode"), translate("Unsupported"), "", function() { }); } else { - handler.toggle_option(privacy_id); + var privacy_mode_impls = pi.platform_additions?.supported_privacy_mode_impl; + if (privacy_mode_impls == null || privacy_mode_impls == undefined) { + handler.toggle_option(privacy_id); + return; + } + var is_on = handler.get_toggle_option("privacy-mode"); + handler.toggle_privacy_mode("", !is_on); } } @@ -580,7 +625,7 @@ function toggleQualityMonitor(name) { function toggleI444(name) { handler.toggle_option(name); - handler.change_prefer_codec(); + handler.update_supported_decodings(); toggleMenuState(); } @@ -663,14 +708,15 @@ function startChat() { chatbox = view.window(params); } -handler.setConnectionType = function(secured, direct) { +handler.setConnectionType = function(secured, direct, stream_type) { header.update({ secure_connection: secured, direct_connection: direct, + stream_type: stream_type, }); } handler.updateRecordStatus = function(status) { recording = status; header.update(); -} \ No newline at end of file +} diff --git a/src/ui/index.css b/src/ui/index.css index 2fb2f958d..d23e4f038 100644 --- a/src/ui/index.css +++ b/src/ui/index.css @@ -31,6 +31,7 @@ body { height: *; background: color(bg); border-right: color(border) 1px solid; + position: relative; } #ab .left-pane { @@ -49,6 +50,14 @@ body { .left-pane > div:nth-child(1) { border-spacing: 1em; padding: 20px; + padding-bottom: 60px; /* reserve space for bottom connect-status */ +} + +.left-pane > div.connect-status { + position: absolute; + bottom: 0; + left: 0; + right: 0; } .left-pane div { @@ -413,6 +422,16 @@ svg#refresh-password:hover { li:disabled, li:disabled:hover { color: color(lighter-text); background: color(menu); + opacity: 0.8; +} + +.grey-text { + color: #888 !important; +} + +input.grey-text, +textarea.grey-text { + color: #888 !important; } @media platform == "OSX" { diff --git a/src/ui/index.tis b/src/ui/index.tis index 3ae54637f..a099b95f9 100644 --- a/src/ui/index.tis +++ b/src/ui/index.tis @@ -2,18 +2,49 @@ if (is_osx) view.windowBlurbehind = #light; stdout.println("current platform:", OS); stdout.println("is_xfce: ", is_xfce); +// See default height in common.tis `msgbox()`. +const msgbox_default_height = 180; +const incoming_only_width = 180; + +const outgoing_only = handler.is_outgoing_only(); +const incoming_only = handler.is_incoming_only(); +const disable_installation = handler.is_disable_installation(); +const disable_account = handler.is_disable_account(); +const disable_settings = handler.is_disable_settings(); +const is_custom_client = handler.is_custom_client(); +const disable_ab = handler.is_disable_ab(); +const hide_server_settings = handler.get_builtin_option("hide-server-settings") == "Y"; +const hide_proxy_settings = handler.get_builtin_option("hide-proxy-settings") == "Y"; +const hide_websocket_settings = handler.get_builtin_option("hide-websocket-settings") == "Y"; +const hide_stop_service = handler.get_builtin_option("hide-stop-service") == "Y"; +const disable_change_permanent_password = handler.get_builtin_option("disable-change-permanent-password") == "Y"; +const disable_change_id = handler.get_builtin_option("disable-change-id") == "Y"; + // html min-width, min-height not working on mac, below works for all -view.windowMinSize = (scaleIt(560), scaleIt(300)); +if (incoming_only) { + view.windowMinSize = (scaleIt(incoming_only_width), scaleIt((handler.is_installed() || disable_installation) ? 300 : 390)); +} else { + view.windowMinSize = (scaleIt(560), scaleIt(300)); +} var app; var tmp = handler.get_connect_status(); var connect_status = tmp[0]; var service_stopped = handler.get_option("stop-service") == "Y"; +var disable_udp = handler.get_option("disable-udp") == "Y"; var using_public_server = handler.using_public_server(); var software_update_url = ""; var key_confirmed = tmp[1]; var system_error = ""; +const default_option_lang = is_custom_client ? 'default' : ''; +const default_option_yes = is_custom_client ? 'Y' : ''; +const default_option_no = is_custom_client ? 'N' : ''; +const default_option_whitelist = is_custom_client ? ',' : ''; +const default_option_approve_mode = is_custom_client ? 'password-click' : ''; + +const grey_text_style = "color:#888;"; + var svg_menu = @@ -27,6 +58,14 @@ function get_id() { return my_id; } +function get_msgbox_width(width=500) { + if (incoming_only) { + var maxw = scaleIt(incoming_only_width); + if (width > maxw) width = maxw; + } + return width; +} + class ConnectStatus: Reactor.Component { function render() { return @@ -104,7 +143,7 @@ class DirectServer: Reactor.Component { is_edit_rdp_port = false; return; } - handler.set_option("direct-server", handler.get_option("direct-server") == "Y" ? "" : "Y"); + handler.set_option("direct-server", handler.get_option("direct-server") == "Y" ? default_option_no : "Y"); this.update(); } } @@ -147,6 +186,10 @@ class AudioInputs: Reactor.Component { var el = this.$(li#enable-audio); var enabled = handler.get_option(el.id) != "N"; el.attributes.toggleClass("selected", !enabled); + var is_opt_fixed = handler.is_option_fixed("enable-audio"); + if (disable_settings || is_opt_fixed) { + el.state.disabled = true; + } var v = this.get_value(); for (var el in this.$$(menu#audio-input>li)) { if (el.id == 'enable-audio') continue; @@ -156,9 +199,10 @@ class AudioInputs: Reactor.Component { } event click $(menu#audio-input>li) (_, me) { + if (me.state.disabled) return; var v = me.id; if (v == 'enable-audio') { - handler.set_option(v, handler.get_option(v) != 'N' ? 'N' : ''); + handler.set_option(v, handler.get_option(v) != 'N' ? 'N' : default_option_yes); } else { if (v == this.get_value()) return; if (v == this.get_default()) v = ""; @@ -187,15 +231,20 @@ class Languages: Reactor.Component { function toggleMenuState() { var cur = handler.get_local_option("lang") || "default"; + var is_opt_fixed = handler.is_option_fixed("lang"); for (var el in this.$$(menu#languages>li)) { var selected = cur == el.id; el.attributes.toggleClass("selected", selected); + if (is_opt_fixed) { + el.state.disabled = true; + } } } event click $(menu#languages>li) (_, me) { + if (me.state.disabled) return; var v = me.id; - if (v == "default") v = ""; + if (v == "default") v = default_option_lang; handler.set_local_option("lang", v); app.update(); this.toggleMenuState(); @@ -220,6 +269,7 @@ class Enhancements: Reactor.Component {
  • {svg_checkmark}{translate("Adaptive bitrate")} (beta)
  • {translate("Recording")}
  • {support_remove_wallpaper ?
  • {svg_checkmark}{translate("Remove wallpaper during incoming sessions")}
  • : ""} +
  • {svg_checkmark}{translate("keep-awake-during-incoming-sessions-label")}
  • ; } @@ -229,49 +279,74 @@ class Enhancements: Reactor.Component { if (el.id && el.id.indexOf("enable-") == 0) { var enabled = handler.get_option(el.id) != "N"; el.attributes.toggleClass("selected", enabled); + var is_opt_fixed = handler.is_option_fixed(el.id); + if (is_opt_fixed) { + el.state.disabled = true; + } } else if (el.id && el.id.indexOf("allow-") == 0) { var enabled = handler.get_option(el.id) == "Y"; el.attributes.toggleClass("selected", enabled); + var is_opt_fixed = handler.is_option_fixed(el.id); + if (is_opt_fixed) { + el.state.disabled = true; + } + } else if (el.id == "keep-awake-during-incoming-sessions") { + var enabled = handler.get_option(el.id) != "N"; + el.attributes.toggleClass("selected", enabled); + var is_opt_fixed = handler.is_option_fixed(el.id); + if (is_opt_fixed) { + el.state.disabled = true; + } } } } event click $(menu#enhancements-menu>li) (_, me) { + if (me.state.disabled) return; var v = me.id; if (v.indexOf("enable-") == 0) { - var set_value = handler.get_option(v) != 'N' ? 'N' : ''; + var set_value = handler.get_option(v) != 'N' ? 'N' : default_option_yes; handler.set_option(v, set_value); - if (v == "enable-hwcodec" && set_value == '') { + if (v == "enable-hwcodec" && set_value != 'N') { handler.check_hwcodec(); } } else if (v.indexOf("allow-") == 0) { - handler.set_option(v, handler.get_option(v) == 'Y' ? '' : 'Y'); + handler.set_option(v, handler.get_option(v) == 'Y' ? default_option_no : 'Y'); + } else if (v == 'keep-awake-during-incoming-sessions') { + handler.set_option(v, handler.get_option(v) != 'N' ? 'N' : default_option_yes); } else if (v == 'screen-recording') { var show_root_dir = is_win && handler.is_installed(); var user_dir = handler.video_save_directory(false); var root_dir = show_root_dir ? handler.video_save_directory(true) : ""; - var ts0 = handler.get_option("enable-record-session") == '' ? { checked: true } : {}; + var ts0 = handler.get_option("enable-record-session") != 'N' ? { checked: true } : {}; var ts1 = handler.get_option("allow-auto-record-incoming") == 'Y' ? { checked: true } : {}; var ts2 = handler.get_local_option("allow-auto-record-outgoing") == 'Y' ? { checked: true } : {}; + var is_opt_fixed_enable_record = handler.is_option_fixed("enable-record-session"); + var is_opt_fixed_auto_incoming = handler.is_option_fixed("allow-auto-record-incoming"); + var is_opt_fixed_auto_outgoing = handler.is_option_fixed("allow-auto-record-outgoing"); + var is_opt_fixed_video_dir = handler.is_option_fixed("video-save-directory"); + if (is_opt_fixed_enable_record) { ts0.disabled = true; ts0.style = grey_text_style; } + if (is_opt_fixed_auto_incoming) { ts1.disabled = true; ts1.style = grey_text_style; } + if (is_opt_fixed_auto_outgoing) { ts2.disabled = true; ts2.style = grey_text_style; } msgbox("custom-recording", translate('Recording'),
    -
    {translate('Enable recording session')}
    -
    {translate('Automatically record incoming sessions')}
    -
    {translate('Automatically record outgoing sessions')}
    -
    +
    {translate('Enable recording session')}
    +
    {translate('Automatically record incoming sessions')}
    +
    {translate('Automatically record outgoing sessions')}
    +
    {show_root_dir ?
    {translate("Incoming")}:  {root_dir}
    : ""}
    {translate(show_root_dir ? "Outgoing" : "Directory")}:  {user_dir}
    -
    + {is_opt_fixed_video_dir ? "" :
    }
    , "", function(res=null) { if (!res) return; - handler.set_option("enable-record-session", res.enable_record_session ? '' : 'N'); - handler.set_option("allow-auto-record-incoming", res.auto_record_incoming ? 'Y' : ''); - handler.set_local_option("allow-auto-record-outgoing", res.auto_record_outgoing ? 'Y' : ''); - handler.set_local_option("video-save-directory", $(#folderPath).text); - }); + if (!is_opt_fixed_enable_record) handler.set_option("enable-record-session", res.enable_record_session ? default_option_yes : 'N'); + if (!is_opt_fixed_auto_incoming) handler.set_option("allow-auto-record-incoming", res.auto_record_incoming ? 'Y' : default_option_no); + if (!is_opt_fixed_auto_outgoing) handler.set_local_option("allow-auto-record-outgoing", res.auto_record_outgoing ? 'Y' : default_option_no); + if (!is_opt_fixed_video_dir) handler.set_local_option("video-save-directory", $(#folderPath).text); + }, msgbox_default_height, get_msgbox_width()); } this.toggleMenuState(); } @@ -284,6 +359,133 @@ function getUserName() { return ''; } +function getAccountLabelWithHandle() { + try { + var user = JSON.parse(handler.get_local_option("user_info")); + var username = (user.name || '').trim(); + if (!username) { + return ''; + } + var displayName = (user.display_name || '').trim(); + if (!displayName || displayName == username) { + return username; + } + return displayName + " (@" + username + ")"; + } catch(e) {} + return ''; +} + +// Shared dialog functions +function open_custom_server_dialog() { + var configOptions = handler.get_options(); + var old_relay = configOptions["relay-server"] || ""; + var old_api = configOptions["api-server"] || ""; + var old_id = configOptions["custom-rendezvous-server"] || ""; + var old_key = configOptions["key"] || ""; + msgbox("custom-server", "ID/Relay Server", "
    \ +
    " + translate("ID Server") + ":
    \ +
    " + translate("Relay Server") + ":
    \ +
    " + translate("API Server") + ":
    \ +
    " + translate("Key") + ":
    \ +
    \ + ", "", function(res=null, show_progress) { + if (!res) return; + if (typeof show_progress === 'function') show_progress(); + var id = (res.id || "").trim(); + var relay = (res.relay || "").trim(); + var api = (res.api || "").trim().toLowerCase(); + var key = (res.key || "").trim(); + if (id == old_id && relay == old_relay && key == old_key && api == old_api) return; + if (id) { + var err = handler.test_if_valid_server(id, true); + if (err) { if (typeof show_progress === 'function') show_progress(false, translate("ID Server") + ": " + err); return; } + } + if (relay) { + var err = handler.test_if_valid_server(relay, true); + if (err) { if (typeof show_progress === 'function') show_progress(false, translate("Relay Server") + ": " + err); return; } + } + if (api) { + if (0 != api.indexOf("https://") && 0 != api.indexOf("http://")) { + if (typeof show_progress === 'function') show_progress(false, translate("API Server") + ": " + translate("invalid_http")); + return; + } + } + configOptions["custom-rendezvous-server"] = id; + configOptions["relay-server"] = relay; + configOptions["api-server"] = api; + configOptions["key"] = key; + handler.set_options(configOptions); + if (typeof show_progress === 'function') show_progress(-1); + }, 260, get_msgbox_width()); +} + +function open_whitelist_dialog() { + var is_opt_fixed = handler.is_option_fixed("whitelist"); + var v = handler.get_option("whitelist"); + var old_value = v == default_option_whitelist ? '' : v.split(",").join("\n"); + var type_str = is_opt_fixed ? "custom-whitelist-nook" : "custom-whitelist"; + var readonly_attr = is_opt_fixed ? " readonly=\"readonly\"" : ""; + var grey_class = is_opt_fixed ? " class=\"grey-text\"" : ""; + msgbox(type_str, translate("IP Whitelisting"), "
    \ + " + translate("whitelist_sep") + "
    \ + \ +
    \ + ", "", function(res=null, show_progress) { + if (!res) return; + if (typeof show_progress === 'function') show_progress(); + var value = (res.text || "").trim(); + if (value) { + var values = value.split(/[\s,;\n]+/g); + for (var ip in values) { + if (!ip.match(/^(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]?|0)\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]?|0)\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]?|0)\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]?|0)(\/([1-9]|[1-2][0-9]|3[0-2])){0,1}$/) + && !ip.match(/^(((?:[0-9A-Fa-f]{1,4}))*((?::[0-9A-Fa-f]{1,4}))*::((?:[0-9A-Fa-f]{1,4}))*((?::[0-9A-Fa-f]{1,4}))*|((?:[0-9A-Fa-f]{1,4}))((?::[0-9A-Fa-f]{1,4})){7})(\/([1-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])){0,1}$/)) { + if (typeof show_progress === 'function') show_progress(false, translate("Invalid IP") + ": " + ip); + return; + } + } + value = values.join("\n"); + } + if (value == old_value) return; + if (!value) value = default_option_whitelist; + handler.set_option("whitelist", value.replace("\n", ",")); + if (typeof show_progress === 'function') show_progress(-1); + }, 300, get_msgbox_width()); +} + +function open_proxy_dialog() { + var is_opt_fixed = handler.is_option_fixed("proxy-url"); + var socks5 = handler.get_socks() || {}; + var old_proxy = socks5[0] || ""; + var old_username = socks5[1] || ""; + var old_password = socks5[2] || ""; + var type_str = is_opt_fixed ? "custom-server-nook" : "custom-server"; + var greyStyle = is_opt_fixed ? grey_text_style : ""; + msgbox(type_str, "Socks5/Http(s) Proxy",
    +
    {translate("Server")}:
    +
    {translate("Username")}:
    +
    {translate("Password")}:{ is_opt_fixed ? : }
    +
    + , "", function(res=null, show_progress) { + if (!res) return; + if (typeof show_progress === 'function') show_progress(); + var proxy = (res.proxy || "").trim(); + var username = (res.username || "").trim(); + var password = (res.password || "").trim(); + if (proxy == old_proxy && username == old_username && password == old_password) return; + if (proxy) { + var domain_port = proxy; + var protocol_index = domain_port.indexOf('://'); + if (protocol_index !== -1) { + domain_port = domain_port.substring(protocol_index + 3); + } + var err = handler.test_if_valid_server(domain_port, false); + if (err) { if (typeof show_progress === 'function') show_progress(false, translate("Server") + ": " + err); return; } + } + handler.set_socks(proxy, username, password); + if (typeof show_progress === 'function') show_progress(-1); + }, 240, get_msgbox_width()); +} + function updateTheme() { var root_element = self; if (handler.get_option("allow-darktheme") == "Y") { @@ -308,36 +510,43 @@ class MyIdMenu: Reactor.Component { } function renderPop() { - var username = handler.get_local_option("access_token") ? getUserName() : ''; + var accountLabel = handler.get_local_option("access_token") ? getAccountLabelWithHandle() : ''; return -
  • {svg_checkmark}{translate('Enable keyboard/mouse')}
  • -
  • {svg_checkmark}{translate('Enable clipboard')}
  • -
  • {svg_checkmark}{translate('Enable file transfer')}
  • -
  • {svg_checkmark}{translate('Enable remote restart')}
  • -
  • {svg_checkmark}{translate('Enable TCP tunneling')}
  • - {is_win ?
  • {svg_checkmark}{translate('Enable blocking user input')}
  • : ""} -
  • {svg_checkmark}{translate('Enable LAN discovery')}
  • + {!disable_settings &&
  • {svg_checkmark}{translate('Enable keyboard/mouse')}
  • } + {!disable_settings &&
  • {svg_checkmark}{translate('Enable clipboard')}
  • } + {!disable_settings &&
  • {svg_checkmark}{translate('Enable file transfer')}
  • } + {!disable_settings &&
  • {svg_checkmark}{translate('Enable camera')}
  • } + {!disable_settings &&
  • {svg_checkmark}{translate('Enable terminal')}
  • } + {!disable_settings &&
  • {svg_checkmark}{translate('Enable remote restart')}
  • } + {!disable_settings &&
  • {svg_checkmark}{translate('Enable TCP tunneling')}
  • } + {!disable_settings && is_win ?
  • {svg_checkmark}{translate('Enable blocking user input')}
  • : ""} + {!disable_settings && (handler.get_supported_privacy_mode_impls() != '[]') &&
  • {svg_checkmark}{translate('Enable privacy mode')}
  • } + {!disable_settings &&
  • {svg_checkmark}{translate('Enable LAN discovery')}
  • } -
  • {svg_checkmark}{translate('Enable remote configuration modification')}
  • + {!disable_settings &&
  • {svg_checkmark}{translate('Enable remote configuration modification')}
  • } + {!disable_settings &&
    } + {!disable_settings && !hide_server_settings &&
  • {translate('ID/Relay Server')}
  • } + {!disable_settings &&
  • {translate('IP Whitelisting')}
  • } + {!disable_settings && !hide_proxy_settings &&
  • {translate('Socks5/Http(s) Proxy')}
  • } + {!disable_settings && !hide_websocket_settings &&
  • {svg_checkmark}{translate('Use WebSocket')}
  • } + {!disable_settings && !using_public_server && !outgoing_only &&
  • {svg_checkmark}{translate('Disable UDP')}
  • } + {!disable_settings && !using_public_server &&
  • {svg_checkmark}{translate('Allow insecure TLS fallback')}
  • }
    -
  • {translate('ID/Relay Server')}
  • -
  • {translate('IP Whitelisting')}
  • -
  • {translate('Socks5 Proxy')}
  • -
    -
  • {svg_checkmark}{translate("Enable service")}
  • - {is_win && handler.is_installed() ? : ""} - - {false && handler.using_public_server() &&
  • {svg_checkmark}{translate('Always connect via relay')}
  • } - {handler.is_ok_change_id() ?
    : ""} - {username ? -
  • {translate('Logout')} ({username})
  • : -
  • {translate('Login')}
  • } - {handler.is_ok_change_id() && key_confirmed && connect_status > 0 ?
  • {translate('Change ID')}
  • : ""} + {(!hide_stop_service || service_stopped) &&
  • {svg_checkmark}{translate("Enable service")}
  • } + {!disable_settings && is_win && handler.is_installed() ? : ""} + {!disable_settings && } + {!disable_settings && false && handler.using_public_server() &&
  • {svg_checkmark}{translate('Always connect via relay')}
  • } + {!disable_change_id && handler.is_ok_change_id() ?
    : ""} + {!disable_account && (accountLabel ? +
  • {translate('Logout')} ({accountLabel})
  • : +
  • {translate('Login')}
  • )} + {!disable_change_id && !disable_settings && handler.is_ok_change_id() && key_confirmed && connect_status > 0 ?
  • {translate('Change ID')}
  • : ""}
  • {svg_checkmark}{translate('Dark Theme')}
  • + {disable_installation ? "" :
  • {svg_checkmark}{translate('Auto update')}
  • }
  • {translate('About')} {" "}{handler.get_app_name()}
  • ; @@ -365,15 +574,24 @@ class MyIdMenu: Reactor.Component { function toggleMenuState() { for (var el in $$(menu#config-options>li)) { - if (el.id && el.id.indexOf("enable-") == 0) { - var enabled = handler.get_option(el.id) != "N"; + var id = el.id; + if (!id) continue; + var is_opt_fixed = handler.is_option_fixed(id); + if (id.indexOf("enable-") == 0) { + var enabled = handler.get_option(id) != "N"; el.attributes.toggleClass("selected", enabled); el.attributes.toggleClass("line-through", !enabled); + } else if (id.indexOf("allow-") == 0) { + var enabled = handler.get_option(id) == "Y"; + el.attributes.toggleClass("selected", enabled); + el.attributes.toggleClass("line-through", !enabled); + } else if (id == "whitelist") { + // whitelist should be clickable even when fixed (to view the content) + // The dialog will show readonly textarea and no OK button when fixed + continue; } - if (el.id && el.id.indexOf("allow-") == 0) { - var enabled = handler.get_option(el.id) == "Y"; - el.attributes.toggleClass("selected", enabled); - el.attributes.toggleClass("line-through", !enabled); + if (is_opt_fixed) { + el.state.disabled = true; } } } @@ -385,7 +603,7 @@ class MyIdMenu: Reactor.Component {
    Fingerprint: " + handler.get_fingerprint() + " \
    " + translate("Privacy Statement") + "
    \
    " + translate("Website") + "
    \ -
    Copyright © 2024 Purslane Ltd.\ +
    Copyright © 2025 Purslane Ltd.\
    " + handler.get_license() + " \

    " + translate("Slogan_tip") + "

    \
    \ @@ -393,105 +611,33 @@ class MyIdMenu: Reactor.Component { if (el && el.attributes) { handler.open_url(el.attributes['url']); }; - }, 400); + }, 400, get_msgbox_width()); } event click $(menu#config-options>li) (_, me) { + if (me.state.disabled) return; if (me.id && me.id.indexOf("enable-") == 0) { - handler.set_option(me.id, handler.get_option(me.id) == "N" ? "" : "N"); + handler.set_option(me.id, handler.get_option(me.id) == "N" ? default_option_yes : "N"); } if (me.id && me.id.indexOf("allow-") == 0) { - handler.set_option(me.id, handler.get_option(me.id) == "Y" ? "" : "Y"); + handler.set_option(me.id, handler.get_option(me.id) == "Y" ? default_option_no : "Y"); } if (me.id == "whitelist") { - var old_value = handler.get_option("whitelist").split(",").join("\n"); - msgbox("custom-whitelist", translate("IP Whitelisting"), "
    \ -
    " + translate("whitelist_sep") + "
    \ - \ -
    \ - ", "", function(res=null) { - if (!res) return; - var value = (res.text || "").trim(); - if (value) { - var values = value.split(/[\s,;\n]+/g); - for (var ip in values) { - if (!ip.match(/^(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]?|0)\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]?|0)\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]?|0)\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]?|0)(\/([1-9]|[1-2][0-9]|3[0-2])){0,1}$/) - && !ip.match(/^(((?:[0-9A-Fa-f]{1,4}))*((?::[0-9A-Fa-f]{1,4}))*::((?:[0-9A-Fa-f]{1,4}))*((?::[0-9A-Fa-f]{1,4}))*|((?:[0-9A-Fa-f]{1,4}))((?::[0-9A-Fa-f]{1,4})){7})(\/([1-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])){0,1}$/)) { - return translate("Invalid IP") + ": " + ip; - } - } - value = values.join("\n"); - } - if (value == old_value) return; - stdout.println("whitelist updated"); - handler.set_option("whitelist", value.replace("\n", ",")); - }, 300); + open_whitelist_dialog(); } else if (me.id == "custom-server") { - var configOptions = handler.get_options(); - var old_relay = configOptions["relay-server"] || ""; - var old_api = configOptions["api-server"] || ""; - var old_id = configOptions["custom-rendezvous-server"] || ""; - var old_key = configOptions["key"] || ""; - msgbox("custom-server", "ID/Relay Server", "
    \ -
    " + translate("ID Server") + ":
    \ -
    " + translate("Relay Server") + ":
    \ -
    " + translate("API Server") + ":
    \ -
    " + translate("Key") + ":
    \ -
    \ - ", "", function(res=null) { - if (!res) return; - var id = (res.id || "").trim(); - var relay = (res.relay || "").trim(); - var api = (res.api || "").trim().toLowerCase(); - var key = (res.key || "").trim(); - if (id == old_id && relay == old_relay && key == old_key && api == old_api) return; - if (id) { - var err = handler.test_if_valid_server(id, true); - if (err) return translate("ID Server") + ": " + err; - } - if (relay) { - var err = handler.test_if_valid_server(relay, true); - if (err) return translate("Relay Server") + ": " + err; - } - if (api) { - if (0 != api.indexOf("https://") && 0 != api.indexOf("http://")) { - return translate("API Server") + ": " + translate("invalid_http"); - } - } - configOptions["custom-rendezvous-server"] = id; - configOptions["relay-server"] = relay; - configOptions["api-server"] = api; - configOptions["key"] = key; - handler.set_options(configOptions); - }, 260); + open_custom_server_dialog(); } else if (me.id == "socks5-server") { - var socks5 = handler.get_socks() || {}; - var old_proxy = socks5[0] || ""; - var old_username = socks5[1] || ""; - var old_password = socks5[2] || ""; - msgbox("custom-server", "Socks5 Proxy",
    -
    {translate("Server")}:
    -
    {translate("Username")}:
    -
    {translate("Password")}:
    -
    - , "", function(res=null) { - if (!res) return; - var proxy = (res.proxy || "").trim(); - var username = (res.username || "").trim(); - var password = (res.password || "").trim(); - if (proxy == old_proxy && username == old_username && password == old_password) return; - if (proxy) { - var err = handler.test_if_valid_server(proxy, false); - if (err) return translate("Server") + ": " + err; - } - handler.set_socks(proxy, username, password); - }, 240); + open_proxy_dialog(); + } else if (me.id == "disable-udp") { + handler.set_option("disable-udp", handler.get_option("disable-udp") == "Y" ? "N" : "Y"); } else if (me.id == "stop-service") { - handler.set_option("stop-service", service_stopped ? "" : "Y"); + handler.set_option("stop-service", service_stopped ? default_option_no : "Y"); } else if (me.id == "change-id") { + var id_label_width = incoming_only ? "50px" : "100px"; + var input_width = incoming_only ? (incoming_only_width - 20) + "px" : "250px"; msgbox("custom-id", translate("Change ID"), "
    \
    " + translate('id_change_tip') + "
    \ -
    ID:
    \ +
    ID:
    \
    \ ", "", function(res=null, show_progress) { if (!res) return; @@ -510,7 +656,7 @@ class MyIdMenu: Reactor.Component { } check_status(); return " "; - }); + }, msgbox_default_height, get_msgbox_width()); } else if (me.id == "allow-darktheme") { updateTheme(); } else if (me.id == "about") { @@ -534,11 +680,14 @@ class EditDirectAccessPort: Reactor.Component { } function editDirectAccessPort() { + var is_opt_fixed = handler.is_option_fixed("direct-access-port"); var p0 = handler.get_option('direct-access-port'); - var port = p0 ? : - ; - msgbox("custom-direct-access-port", translate('Direct IP Access Settings'),
    -
    {translate('Port')}:{port}
    + var greyStyle = is_opt_fixed ? grey_text_style : ""; + var port = p0 ? : + ; + var type_str = is_opt_fixed ? "custom-direct-access-port-nook" : "custom-direct-access-port"; + msgbox(type_str, translate('Direct IP Access Settings'),
    +
    {translate('Port')}:{port}
    , "", function(res=null) { if (!res) return; var p = (res.port || '').trim(); @@ -550,7 +699,7 @@ function editDirectAccessPort() { p = p + ''; } if (p != p0) handler.set_option('direct-access-port', p); - }); + }, msgbox_default_height, get_msgbox_width()); } class App: Reactor.Component @@ -563,27 +712,33 @@ class App: Reactor.Component var is_can_screen_recording = handler.is_can_screen_recording(false); return
    -
    +
    -
    {translate('Your Desktop')}
    -
    {translate('desk_tip')}
    -
    + {is_custom_client && handler.get_builtin_option("hide-powered-by-me") != "Y" ?
    {translate('powered_by_me')}
    : ""} +
    + {translate('Your Desktop')} + {outgoing_only ? {svg_menu} : ""} +
    +
    {outgoing_only ? translate('outgoing_only_desk_tip') : translate('desk_tip')}
    + {outgoing_only ?
    : ""} + {!outgoing_only &&
    {key_confirmed ? : translate("Generating ...")} -
    - +
    } + {!outgoing_only && }
    - {!is_win || handler.is_installed() ? "": } - {software_update_url ? : ""} - {is_win && handler.is_installed() && !software_update_url && handler.is_installed_lower_version() ? : ""} + {(!is_win || handler.is_installed() || disable_installation) ? "" : } + {software_update_url && !disable_installation ? : ""} + {is_win && handler.is_installed() && !software_update_url && handler.is_installed_lower_version() && !disable_installation ? : ""} {is_can_screen_recording ? "": } {is_can_screen_recording && !handler.is_process_trusted(false) ? : ""} {!service_stopped && is_can_screen_recording && handler.is_process_trusted(false) && handler.is_installed() && !handler.is_installed_daemon(false) ? : ""} {system_error ? : ""} {!system_error && handler.is_login_wayland() && !handler.current_is_wayland() ? : ""} {!system_error && handler.current_is_wayland() ? : ""} + {incoming_only ? : ""}
    -
    + {!incoming_only &&
    {translate('Control Remote Desktop')}
    @@ -595,10 +750,10 @@ class App: Reactor.Component
    - -
    + {!outgoing_only ? : ""} +
    }
    -
    ; +
    ; } event click $(button#connect) { @@ -671,7 +826,9 @@ class UpdateMe: Reactor.Component { return
    {translate('Status')}
    There is a newer version of {handler.get_app_name()} ({handler.get_new_version()}) available.
    -
    {translate('Click to ' + update_or_download)}
    + {is_custom_client + ?
    {translate('Enable \"Auto update\" or contact your administrator for the latest version.')}
    + :
    {translate('Click to ' + update_or_download)}
    }
    ; } @@ -831,11 +988,12 @@ class PasswordEyeArea : Reactor.Component { render() { var method = handler.get_option('verification-method'); var mode= handler.get_option('approve-mode'); - var value = mode == 'click' || method == 'use-permanent-password' ? "-" : password_cache[0]; + var hide_one_time = mode == 'click' || method == 'use-permanent-password'; + var value = hide_one_time ? "-" : password_cache[0]; return
    - {svg_refresh_password} + {hide_one_time ? "" : svg_refresh_password}
    ; } @@ -856,7 +1014,7 @@ class TemporaryPasswordLengthMenu: Reactor.Component { var me = this; var method = handler.get_option('verification-method'); self.timer(1ms, function() { me.toggleMenuState() }); - return
  • {translate("One-time password length")} + return
  • {translate("One-time password length")}
  • {svg_checkmark}6
  • {svg_checkmark}8
  • @@ -866,15 +1024,20 @@ class TemporaryPasswordLengthMenu: Reactor.Component { } function toggleMenuState() { + var is_opt_fixed = handler.is_option_fixed('temporary-password-length'); var length = handler.get_option("temporary-password-length"); var index = ['6', '8', '10'].indexOf(length); if (index < 0) index = 0; for (var (i, el) in this.$$(menu#temporary-password-length>li)) { el.attributes.toggleClass("selected", i == index); + if (is_opt_fixed) { + el.state.disabled = true; + } } } event click $(menu#temporary-password-length>li) (_, me) { + if (me.state.disabled) return; var length = me.id.substring('temporary-password-length-'.length); var old_length = handler.get_option('temporary-password-length'); if (length != old_length) { @@ -901,7 +1064,7 @@ class PasswordArea: Reactor.Component {
    {this.renderPop()} - {svg_edit} + {!disable_settings && svg_edit}
  • ; } @@ -910,6 +1073,7 @@ class PasswordArea: Reactor.Component { var method = handler.get_option('verification-method'); var approve_mode= handler.get_option('approve-mode'); var show_password = approve_mode != 'click'; + var has_local_password = handler.is_local_permanent_password_set(); return
  • {svg_checkmark}{translate('Accept sessions via password')}
  • {svg_checkmark}{translate('Accept sessions via click')}
  • @@ -919,7 +1083,8 @@ class PasswordArea: Reactor.Component { { !show_password ? '' :
  • {svg_checkmark}{translate('Use permanent password')}
  • } { !show_password ? '' :
  • {svg_checkmark}{translate('Use both passwords')}
  • } { !show_password ? '' :
    } - { !show_password ? '' :
  • {translate('Set permanent password')}
  • } + { !show_password || disable_change_permanent_password ? '' :
  • {translate('Set permanent password')}
  • } + { !show_password || disable_change_permanent_password ? '' :
  • {translate('Clear permanent password')}
  • } { !show_password ? '' : }
  • {svg_checkmark}{translate('enable-2fa-title')}
  • @@ -940,10 +1105,22 @@ class PasswordArea: Reactor.Component { pwd_id = 'use-both-passwords'; var has_valid_2fa = handler.has_valid_2fa(); for (var el in this.$$(menu#edit-password-context>li)) { - if (el.id.indexOf("approve-mode-") == 0) + if (el.id.indexOf("approve-mode-") == 0) { el.attributes.toggleClass("selected", el.id == mode_id); - if (el.id.indexOf("use-") == 0) + if (handler.is_option_fixed('approve-mode')) { + el.state.disabled = true; + } + } + if (el.id.indexOf("use-") == 0) { el.attributes.toggleClass("selected", el.id == pwd_id); + if (handler.is_option_fixed('verification-method')) { + el.state.disabled = true; + } + } + if (el.id == "clear-password") { + var has_local_password = handler.is_local_permanent_password_set(); + el.state.disabled = !has_local_password; + } if (el.id == "tfa") el.attributes.toggleClass("selected", has_valid_2fa); } @@ -959,16 +1136,28 @@ class PasswordArea: Reactor.Component { event click $(li#set-password) { var me = this; - var password = handler.permanent_password(); - var value_field = password.length == 0 ? "" : "value=" + password; + var has_local_password = handler.is_local_permanent_password_set(); + var permanent_password_set = handler.is_permanent_password_set(); + var password_hidden_tip = translate('password-hidden-tip'); + var preset_password_tip = translate('preset-password-in-use-tip'); + var password_tip = ""; + if (has_local_password) { + password_tip = "
    [!] " + password_hidden_tip + "
    "; + } else if (permanent_password_set) { + password_tip = "
    [!] " + preset_password_tip + "
    "; + } msgbox("custom-password", translate("Set Password"), "
    \ -
    " + translate('Password') + ":
    \ -
    " + translate('Confirmation') + ":
    \ +
    " + translate('Password') + ":
    \ +
    " + translate('Confirmation') + ":
    \ + " + password_tip + " \
    \ ", "", function(res=null) { if (!res) return; var p0 = (res.password || "").trim(); var p1 = (res.confirmation || "").trim(); + if (p0.length == 0 && p1.length == 0) { + return " "; + } if (p0.length < 6 && p0.length != 0) { return translate("Too short, at least 6 characters."); } @@ -977,10 +1166,20 @@ class PasswordArea: Reactor.Component { } handler.set_permanent_password(p0); me.update(); + }, msgbox_default_height, get_msgbox_width()); + self.timer(30ms, function() { + updateSetPasswordSubmitState(); }); } + event click $(li#clear-password) { + if (this.$(li#clear-password).state.disabled) return; + handler.set_permanent_password(""); + this.update(); + } + event click $(menu#edit-password-context>li) (_, me) { + if (me.state.disabled) return; if (me.id.indexOf('use-') == 0) { handler.set_option('verification-method', me.id); this.toggleMenuState(); @@ -992,7 +1191,7 @@ class PasswordArea: Reactor.Component { else if (me.id == 'approve-mode-click') approve_mode = 'click'; else - approve_mode = ''; + approve_mode = default_option_approve_mode; handler.set_option('approve-mode', approve_mode); this.toggleMenuState(); passwordArea.update(); @@ -1021,7 +1220,7 @@ class PasswordArea: Reactor.Component { return translate('wrong-2fa-code'); } me.update(); - }, 400); + }, 400, get_msgbox_width()); } } } @@ -1050,11 +1249,23 @@ function updatePasswordArea() { password_cache[3] = approve_mode; update = true; } - if (update) passwordArea.update(); + if (update && passwordArea) passwordArea.update(); updatePasswordArea(); }); } -updatePasswordArea(); +if (!outgoing_only) updatePasswordArea(); + +function updateSetPasswordSubmitState() { + var dialog = $(#msgbox); + if (!dialog) return; + var password = dialog.$(input[name='password']); + var confirmation = dialog.$(input[name='confirmation']); + var submit = dialog.$(button#submit); + if (!password || !confirmation || !submit) return; + var can_submit = (password.value || "").trim().length > 0 || + (confirmation.value || "").trim().length > 0; + submit.state.disabled = !can_submit; +} class ID: Reactor.Component { function render() { @@ -1113,8 +1324,53 @@ event keydown (evt) { } } +event keyup $(#msgbox input[name='password']) { + updateSetPasswordSubmitState(); +} + +event keyup $(#msgbox input[name='confirmation']) { + updateSetPasswordSubmitState(); +} + +event change $(#msgbox input[name='password']) { + updateSetPasswordSubmitState(); +} + +event change $(#msgbox input[name='confirmation']) { + updateSetPasswordSubmitState(); +} + $(body).content(
    ); +event click $(#powered-by) { + handler.open_url("https://rustdesk.com"); +} + +event click $(#open-settings) (_, me) { + showSettings(); +} + +// Event handlers for outgoing_only mode (when menu items are in main UI, not in MyIdMenu) +event click $(li#custom-server) (_, me) { + if (!outgoing_only) return; + open_custom_server_dialog(); +} + +event click $(li#whitelist) (_, me) { + if (!outgoing_only) return; + open_whitelist_dialog(); +} + +event click $(li#socks5-server) (_, me) { + if (!outgoing_only) return; + open_proxy_dialog(); +} + +event click $(li#login) (_, me) { + if (!outgoing_only) return; + login(); +} + function self.closing() { var (x, y, w, h) = view.box(#rectw, #border, #screen); handler.closing(x, y, w, h); @@ -1128,10 +1384,10 @@ function self.ready() { if (r[2] >= sw && r[3] >= sh) { self.timer(1ms, function() { view.windowState = View.WINDOW_MAXIMIZED; }); } else { - view.move(r[0], r[1], r[2], r[3]); + view.move(r[0], r[1], incoming_only ? scaleIt(incoming_only_width) : r[2], r[3]); } } else { - centerize(scaleIt(800), scaleIt(600)); + centerize(scaleIt(incoming_only ? incoming_only_width : 800), scaleIt(incoming_only ? 390 : 600)); } if (!handler.get_remote_id()) { view.focus = $(#remote_id); @@ -1146,7 +1402,16 @@ function showAbout() { function showSettings() { if ($(#overlay).style#display == 'block') return; - myIdMenu.showSettingMenu(); + var menu = myIdMenu.$(menu#config-options); + var anchor = $(#open-settings); + if (!anchor) anchor = myIdMenu.$(svg#menu); + // show immediately at button, then update menu state asynchronously + anchor.popup(menu); + self.timer(1ms, function() { + audioInputMenu.update({ show: true }); + myIdMenu.toggleMenuState(); + if (direct_server) direct_server.update(); + }); } function checkConnectStatus() { @@ -1191,6 +1456,11 @@ function checkConnectStatus() { updateAbPeer(); app.update(); } + tmp = handler.get_option("disable-udp") == "Y"; + if (tmp != disable_udp) { + disable_udp = tmp; + app.update(); + } check_if_overlay(); checkConnectStatus(); }); @@ -1211,7 +1481,16 @@ function self.onMouse(evt) { } function check_if_overlay() { - if (handler.get_option('allow-remote-config-modification') != 'Y') { + var enabled; + var is_enabled_by_control_permissions = handler.is_remote_modify_enabled_by_control_permissions(); + if (is_enabled_by_control_permissions == "true") { + enabled = true; + } else if (is_enabled_by_control_permissions == "false") { + enabled = false; + } else { + enabled = handler.get_option('allow-remote-config-modification') == 'Y'; + } + if (!enabled) { var time0 = getTime(); handler.check_mouse_time(); self.timer(120ms, function() { @@ -1226,6 +1505,12 @@ checkConnectStatus(); function set_local_user_info(user) { var user_info = {name: user.name}; + if (user.display_name) { + user_info.display_name = user.display_name; + } + if (user.avatar) { + user_info.avatar = user.avatar; + } if (user.status) { user_info.status = user.status; } @@ -1278,7 +1563,7 @@ function login() { show_progress(false, err); }); return " "; - }); + }, msgbox_default_height, get_msgbox_width()); } function on_2fa_check(last_msg) { @@ -1332,7 +1617,9 @@ function on_2fa_check(last_msg) { } ); return " "; - } + }, + msgbox_default_height, + get_msgbox_width() ); } @@ -1357,7 +1644,8 @@ function logout() { } function refreshCurrentUser() { - if (!handler.get_local_option("access_token")) return; + var token = handler.get_local_option("access_token"); + if (!token) { return; } abLoading = true; abError = ""; app.update(); @@ -1369,6 +1657,10 @@ function refreshCurrentUser() { handleAbError(data.error); return; } + if (!handler.verify_login(data.verifier, token)) { + handleAbError("Please update your self-hosting server Pro to latest version"); + return; + } set_local_user_info(data); myIdMenu.update(); getAb(); diff --git a/src/ui/msgbox.tis b/src/ui/msgbox.tis index 34f6b0443..6e6b6a62f 100644 --- a/src/ui/msgbox.tis +++ b/src/ui/msgbox.tis @@ -132,6 +132,17 @@ class MsgboxComponent: Reactor.Component { return this.type.indexOf("skip") >= 0; } + function getScreenshotButtons() { + var isScreenshot = this.type.indexOf("take-screenshot") >= 0; + return isScreenshot + ?
    + + + +
    + : ""; + } + function render() { this.set_outline_focus(); var color = this.getColor(); @@ -170,6 +181,7 @@ class MsgboxComponent: Reactor.Component { {hasOk || this.hasRetry ? : ""} {hasLink ? : ""} {hasClose ? : ""} + {this.getScreenshotButtons()}
    @@ -181,8 +193,10 @@ class MsgboxComponent: Reactor.Component { } function submit() { - if (this.$(button#submit)) { - this.$(button#submit).sendEvent("click"); + var submit_btn = this.$(button#submit); + if (submit_btn) { + if (submit_btn.state.disabled) return; + submit_btn.sendEvent("click"); } } @@ -245,6 +259,39 @@ class MsgboxComponent: Reactor.Component { this.close(); } } + + event click $(button#screenshotSaveAs) { + this.close(); + + handler.leave(handler.get_keyboard_mode()); + const filter = "Png file (*.png)"; + const defaultExt = "png"; + const initialPath = System.path(#USER_DOCUMENTS, "screenshot"); + const caption = "Save as"; + var url = view.selectFile(#save, filter, defaultExt, initialPath, caption); + handler.enter(handler.get_keyboard_mode()); + if(url) { + var res = handler.handle_screenshot("0:" + URL.toPath(url)); + if (res) { + msgbox("custom-error-nocancel-nook-hasclose", "Take screenshot", res, "", function() {}); + } + } else { + handler.handle_screenshot("2"); + } + } + + event click $(button#screenshotCopyToClip) { + this.close(); + var res = handler.handle_screenshot("1"); + if (res) { + msgbox("custom-error-nocancel-nook-hasclose", "Take screenshot", res, "", function() {}); + } + } + + event click $(button#screenshotCancel) { + this.close(); + handler.handle_screenshot("2"); + } event keydown (evt) { if (!evt.shortcutKey) { @@ -317,6 +364,9 @@ class MsgboxComponent: Reactor.Component { if (this.type == "multiple-sessions-nocancel") { values.sid = (this.$$(select))[0].value; } + if (this.type == "remote-printer-selector") { + values.name = (this.$$(select))[0].value; + } return values; } diff --git a/src/ui/printer.tis b/src/ui/printer.tis new file mode 100644 index 000000000..c28482601 --- /dev/null +++ b/src/ui/printer.tis @@ -0,0 +1,41 @@ +include "sciter:reactor.tis"; + +handler.printerRequest = function(id, path) { + show_printer_selector(id, path); +}; + +function show_printer_selector(id, path) +{ + var names = handler.get_printer_names(); + msgbox("remote-printer-selector", "Incoming Print Job", , "", function(res=null) { + if (res && res.name) { + handler.on_printer_selected(id, path, res.name); + } + }, 180); +} + +class PrinterComponent extends Reactor.Component { + this var names = []; + this var jobTip = translate("print-incoming-job-confirm-tip"); + + function this(params) { + if (params && params.names) { + this.names = params.names; + } + } + + function render() { + return
    +
    {translate("print-incoming-job-confirm-tip")}
    +
    +
    + +
    +
    +
    ; + } +} diff --git a/src/ui/remote.html b/src/ui/remote.html index d58c3449b..70e909d17 100644 --- a/src/ui/remote.html +++ b/src/ui/remote.html @@ -15,6 +15,7 @@ include "port_forward.tis"; include "grid.tis"; include "header.tis"; + include "printer.tis";
    diff --git a/src/ui/remote.rs b/src/ui/remote.rs index 0296d82bd..8b6f01ae0 100644 --- a/src/ui/remote.rs +++ b/src/ui/remote.rs @@ -1,7 +1,7 @@ use std::{ collections::HashMap, ops::{Deref, DerefMut}, - sync::{Arc, Mutex, RwLock}, + sync::{atomic::AtomicUsize, Arc, Mutex, RwLock}, }; use sciter::{ @@ -66,6 +66,55 @@ impl SciterHandler { } displays_value } + + fn make_platform_additions(data: &str) -> Option { + if let Ok(v2) = serde_json::from_str::>(data) { + let mut value = Value::map(); + for (k, v) in v2 { + match v { + serde_json::Value::String(s) => { + value.set_item(k, s); + } + serde_json::Value::Number(n) => { + if let Some(n) = n.as_i64() { + value.set_item(k, n as i32); + } else if let Some(n) = n.as_f64() { + value.set_item(k, n); + } + } + serde_json::Value::Bool(b) => { + value.set_item(k, b); + } + serde_json::Value::Array(arr) if k == "supported_privacy_mode_impl" => { + let mut impls = Value::array(0); + for item in arr { + if let serde_json::Value::Array(entry) = item { + let impl_key = entry.get(0).and_then(|v| v.as_str()); + let impl_name = entry.get(1).and_then(|v| v.as_str()); + if let (Some(impl_key), Some(impl_name)) = (impl_key, impl_name) { + let mut impl_item = Value::array(0); + impl_item.push(impl_key); + impl_item.push(impl_name); + impls.push(impl_item); + } + } + } + value.set_item(k, impls); + } + _ => { + // ignore for now + } + } + } + if value.len() > 0 { + return Some(value); + } else { + None + } + } else { + None + } + } } impl InvokeUiSession for SciterHandler { @@ -92,8 +141,9 @@ impl InvokeUiSession for SciterHandler { } } - fn set_display(&self, x: i32, y: i32, w: i32, h: i32, cursor_embedded: bool) { - self.call("setDisplay", &make_args!(x, y, w, h, cursor_embedded)); + fn set_display(&self, x: i32, y: i32, w: i32, h: i32, cursor_embedded: bool, scale: f64) { + let scale = if scale <= 0.0 { 1.0 } else { scale }; + self.call("setDisplay", &make_args!(x, y, w, h, cursor_embedded, scale)); // https://sciter.com/forums/topic/color_spaceiyuv-crash // Nothing spectacular in decoder – done on CPU side. // So if you can do BGRA translation on your side – the better. @@ -145,8 +195,11 @@ impl InvokeUiSession for SciterHandler { self.call("setCursorPosition", &make_args!(cp.x, cp.y)); } - fn set_connection_type(&self, is_secured: bool, direct: bool) { - self.call("setConnectionType", &make_args!(is_secured, direct)); + fn set_connection_type(&self, is_secured: bool, direct: bool, stream_type: &str) { + self.call( + "setConnectionType", + &make_args!(is_secured, direct, stream_type.to_string()), + ); } fn set_fingerprint(&self, _fingerprint: String) {} @@ -163,7 +216,7 @@ impl InvokeUiSession for SciterHandler { self.call("clearAllJobs", &make_args!()); } - fn load_last_job(&self, cnt: i32, job_json: &str) { + fn load_last_job(&self, cnt: i32, job_json: &str, auto_start: bool) { let job: Result = serde_json::from_str(job_json); if let Ok(job) = job { let path; @@ -177,7 +230,15 @@ impl InvokeUiSession for SciterHandler { } self.call( "addJob", - &make_args!(cnt, path, to, job.file_num, job.show_hidden, job.is_remote), + &make_args!( + cnt, + path, + to, + job.file_num, + job.show_hidden, + job.is_remote, + auto_start + ), ); } } @@ -245,6 +306,9 @@ impl InvokeUiSession for SciterHandler { pi_sciter.set_item("displays", Self::make_displays_array(&pi.displays)); pi_sciter.set_item("current_display", pi.current_display); pi_sciter.set_item("version", pi.version.clone()); + if let Some(v) = Self::make_platform_additions(&pi.platform_additions) { + pi_sciter.set_item("platform_additions", v); + } self.call("updatePi", &make_args!(pi_sciter)); } @@ -277,12 +341,10 @@ impl InvokeUiSession for SciterHandler { fn on_connected(&self, conn_type: ConnType) { match conn_type { - ConnType::RDP => {} - ConnType::PORT_FORWARD => {} - ConnType::FILE_TRANSFER => {} ConnType::DEFAULT_CONN => { crate::keyboard::client::start_grab_loop(); } + _ => {} } } @@ -339,6 +401,19 @@ impl InvokeUiSession for SciterHandler { fn update_record_status(&self, start: bool) { self.call("updateRecordStatus", &make_args!(start)); } + + fn printer_request(&self, id: i32, path: String) { + self.call("printerRequest", &make_args!(id, path)); + } + + fn handle_screenshot_resp(&self, _sid: String, msg: String) { + self.call("screenshot", &make_args!(msg)); + } + + fn handle_terminal_response(&self, _response: TerminalResponse) { + // Terminal support is not implemented for Sciter UI + // This is a stub implementation to satisfy the trait requirements + } } pub struct SciterSession(Session); @@ -451,6 +526,8 @@ impl sciter::EventHandler for SciterSession { fn get_chatbox(); fn get_icon(); fn get_home_dir(); + fn get_next_job_id(); + fn update_next_job_id(i32); fn read_dir(String, bool); fn remove_dir(i32, String, bool); fn create_dir(i32, String, bool); @@ -462,8 +539,8 @@ impl sciter::EventHandler for SciterSession { fn confirm_delete_files(i32, i32); fn set_no_confirm(i32); fn cancel_job(i32); - fn send_files(i32, String, String, i32, bool, bool); - fn add_job(i32, String, String, i32, bool, bool); + fn send_files(i32, i32, String, String, i32, bool, bool); + fn add_job(i32, i32, String, String, i32, bool, bool); fn resume_job(i32, bool); fn get_platform(bool); fn get_path_sep(bool); @@ -483,9 +560,13 @@ impl sciter::EventHandler for SciterSession { fn save_custom_image_quality(i32); fn refresh_video(i32); fn record_screen(bool); + fn is_screenshot_supported(); + fn take_screenshot(i32, String); + fn handle_screenshot(String); fn get_toggle_option(String); fn is_privacy_mode_supported(); fn toggle_option(String); + fn toggle_privacy_mode(String, bool); fn get_remember(); fn peer_platform(); fn set_write_override(i32, i32, bool, bool, bool); @@ -493,13 +574,16 @@ impl sciter::EventHandler for SciterSession { fn is_keyboard_mode_supported(String); fn save_keyboard_mode(String); fn alternative_codecs(); - fn change_prefer_codec(); + fn update_supported_decodings(); fn restart_remote_device(); fn request_voice_call(); fn close_voice_call(); fn version_cmp(String, String); fn set_selected_windows_session_id(String); fn is_recording(); + fn has_file_clipboard(); + fn get_printer_names(); + fn on_printer_selected(i32, String, String); } } @@ -512,11 +596,14 @@ impl SciterSession { server_keyboard_enabled: Arc::new(RwLock::new(true)), server_file_transfer_enabled: Arc::new(RwLock::new(true)), server_clipboard_enabled: Arc::new(RwLock::new(true)), + reconnect_count: Arc::new(AtomicUsize::new(0)), ..Default::default() }; let conn_type = if cmd.eq("--file-transfer") { ConnType::FILE_TRANSFER + } else if cmd.eq("--view-camera") { + ConnType::VIEW_CAMERA } else if cmd.eq("--port-forward") { ConnType::PORT_FORWARD } else if cmd.eq("--rdp") { @@ -607,6 +694,10 @@ impl SciterSession { self.send_selected_session_id(u_sid); } + fn has_file_clipboard(&self) -> bool { + cfg!(any(target_os = "windows", feature = "unix-file-copy-paste")) + } + fn get_port_forwards(&mut self) -> Value { let port_forwards = self.lc.read().unwrap().port_forwards.clone(); let mut v = Value::array(0); @@ -795,6 +886,26 @@ impl SciterSession { fn version_cmp(&self, v1: String, v2: String) -> i32 { (hbb_common::get_version_number(&v1) - hbb_common::get_version_number(&v2)) as i32 } + + fn get_printer_names(&self) -> Value { + #[cfg(target_os = "windows")] + let printer_names = crate::platform::windows::get_printer_names().unwrap_or_default(); + #[cfg(not(target_os = "windows"))] + let printer_names: Vec = vec![]; + let mut v = Value::array(0); + for name in printer_names { + v.push(name); + } + v + } + + fn on_printer_selected(&self, id: i32, path: String, printer_name: String) { + self.printer_response(id, path, printer_name); + } + + fn handle_screenshot(&self, action: String) -> String { + crate::client::screenshot::handle_screenshot(action) + } } pub fn make_fd(id: i32, entries: &Vec, only_count: bool) -> Value { diff --git a/src/ui/remote.tis b/src/ui/remote.tis index 00dceac5b..28fbc3763 100644 --- a/src/ui/remote.tis +++ b/src/ui/remote.tis @@ -4,24 +4,30 @@ var is_port_forward = handler.is_port_forward(); var input_blocked = false; var display_width = 0; var display_height = 0; +var display_remote_scale = 1; var display_origin_x = 0; var display_origin_y = 0; var display_cursor_embedded = false; var display_scale = 1; +// the scale factor is different from `display_scale` if peer platform is Linux (Wayland). +var cursor_scale = 1; var keyboard_enabled = true; // server side var clipboard_enabled = true; // server side var audio_enabled = true; // server side var file_enabled = true; // server side var restart_enabled = true; // server side var recording_enabled = true; // server side +var privacy_mode_enabled = true; // server side var scroll_body = $(body); +var peer_platform = ""; -handler.setDisplay = function(x, y, w, h, cursor_embedded) { +handler.setDisplay = function(x, y, w, h, cursor_embedded, scale) { display_width = w; display_height = h; display_origin_x = x; display_origin_y = y; display_cursor_embedded = cursor_embedded; + display_remote_scale = scale; adaptDisplay(); if (recording) handler.record_screen(true, 0, w, h); } @@ -29,12 +35,24 @@ handler.setDisplay = function(x, y, w, h, cursor_embedded) { // in case toolbar not shown correctly view.windowMinSize = (scaleIt(500), scaleIt(300)); +function get_peer_platform() { + if (peer_platform == "") { + peer_platform = handler.peer_platform(); + } + return peer_platform; +} + +function isRemoteLinux() { + return get_peer_platform() == "Linux"; +} + function adaptDisplay() { var w = display_width; var h = display_height; if (!w || !h) return; var style = handler.get_view_style(); display_scale = 1.; + cursor_scale = 1.; var (sx, sy, sw, sh) = view.screenBox(view.windowState == View.WINDOW_FULL_SCREEN ? #frame : #workarea, #rectw); if (sw >= w && sh > h) { var hh = $(header).box(#height, #border); @@ -71,6 +89,12 @@ function adaptDisplay() { } } } + if (isRemoteLinux()) { + cursor_scale = display_scale * display_remote_scale; + } else { + cursor_scale = display_scale; + } + if (cursor_scale <= 0.0001) cursor_scale = 1.; refreshCursor(); handler.style.set { width: w / scaleFactor + "px", @@ -119,6 +143,14 @@ function resetWheel() { } var INERTIA_ACCELERATION = 30; +var WHEEL_ACCEL_VELOCITY_THRESHOLD = 5000; +var WHEEL_ACCEL_DT_FAST = 0.04; +var WHEEL_ACCEL_DT_MEDIUM = 0.08; +var WHEEL_ACCEL_VALUE_FAST = 3; +var WHEEL_ACCEL_VALUE_MEDIUM = 2; +// Wheel burst acceleration (empirical tuning). +// Applies only on fast, non-smooth wheel bursts to keep single-step scroll unchanged. +// Sciter uses seconds for dt, so velocity is in delta/sec. // not good, precision not enough to simulate acceleration effect, // seems have to use pixel based rather line based delta @@ -214,12 +246,28 @@ function handler.onMouse(evt) // mouseWheelDistance = 8 * [currentUserDefs floatForKey:@"com.apple.scrollwheel.scaling"]; mask = 3; { - var (dx, dy) = evt.wheelDeltas; - if (dx > 0) dx = 1; - else if (dx < 0) dx = -1; - if (dy > 0) dy = 1; - else if (dy < 0) dy = -1; - if (Math.abs(dx) > Math.abs(dy)) { + var now = getTime(); + var dt = last_wheel_time > 0 ? (now - last_wheel_time) / 1000 : 0; + var (raw_dx, raw_dy) = evt.wheelDeltas; + var dx = 0; + var dy = 0; + var abs_dx = Math.abs(raw_dx); + var abs_dy = Math.abs(raw_dy); + var dominant = abs_dx > abs_dy ? abs_dx : abs_dy; + var is_smooth = dominant < 1; + var accel = 1; + if (!is_smooth && dt > 0 && (is_win || is_linux) && get_peer_platform() == "Mac OS") { + var velocity = dominant / dt; + if (velocity >= WHEEL_ACCEL_VELOCITY_THRESHOLD) { + if (dt < WHEEL_ACCEL_DT_FAST) accel = WHEEL_ACCEL_VALUE_FAST; + else if (dt < WHEEL_ACCEL_DT_MEDIUM) accel = WHEEL_ACCEL_VALUE_MEDIUM; + } + } + if (raw_dx > 0) dx = accel; + else if (raw_dx < 0) dx = -accel; + if (raw_dy > 0) dy = accel; + else if (raw_dy < 0) dy = -accel; + if (abs_dx > abs_dy) { dy = 0; } else { dx = 0; @@ -230,8 +278,6 @@ function handler.onMouse(evt) wheel_delta_y = acc_wheel_delta_y.toInteger(); acc_wheel_delta_x -= wheel_delta_x; acc_wheel_delta_y -= wheel_delta_y; - var now = getTime(); - var dt = last_wheel_time > 0 ? (now - last_wheel_time) / 1000 : 0; if (dt > 0) { var vx = dx / dt; var vy = dy / dt; @@ -274,12 +320,14 @@ function handler.onMouse(evt) entered = true; stdout.println("enter"); handler.enter(handler.get_keyboard_mode()); + last_wheel_time = 0; return keyboard_enabled; case Event.MOUSE_LEAVE: entered = false; stdout.println("leave"); handler.leave(handler.get_keyboard_mode()); - if (is_left_down && handler.peer_platform() == "Android") { + last_wheel_time = 0; + if (is_left_down && get_peer_platform() == "Android") { is_left_down = false; handler.send_mouse((1 << 3) | 2, 0, 0, evt.altKey, evt.ctrlKey, evt.shiftKey, evt.commandKey); @@ -303,8 +351,8 @@ function handler.onMouse(evt) resetWheel(); } if (!keyboard_enabled) return false; - x = (x / display_scale).toInteger(); - y = (y / display_scale).toInteger(); + x = (x / cursor_scale).toInteger(); + y = (y / cursor_scale).toInteger(); // insert down between two up, osx has this behavior for triple click if (last_mouse_mask == 2 && mask == 2) { handler.send_mouse((evt.buttons << 3) | 1, 0, 0, evt.altKey, @@ -339,14 +387,18 @@ var cursors = {}; var image_binded; function scaleCursorImage(img) { - var w = (img.width * display_scale).toInteger(); - var h = (img.height * display_scale).toInteger(); + var factor = cursor_scale; + if (cursor_img.style#display != 'none') { + factor /= scaleFactor; + } + var w = (img.width * factor).toInteger(); + var h = (img.height * factor).toInteger(); cursor_img.style.set { width: w + "px", height: h + "px", }; self.bindImage("in-memory:cursor", img); - if (display_scale == 1) return img; + if (factor == 1) return img; function paint(gfx) { gfx.drawImage(img, 0, 0, w, h); } @@ -360,7 +412,7 @@ function updateCursor(system=false) { if (system) { handler.style#cursor = undefined; } else if (cur_img) { - handler.style.cursor(cur_img, (cur_hotx * display_scale).toInteger(), (cur_hoty * display_scale).toInteger()); + handler.style.cursor(cur_img, (cur_hotx * cursor_scale).toInteger(), (cur_hoty * cursor_scale).toInteger()); } } @@ -413,14 +465,15 @@ handler.setCursorPosition = function(x, y) { cur_y = y - display_origin_y; var x = cur_x - cur_hotx; var y = cur_y - cur_hoty; - x *= display_scale / scaleFactor; - y *= display_scale / scaleFactor; + x *= cursor_scale / scaleFactor; + y *= cursor_scale / scaleFactor; cursor_img.style.set { left: x + "px", top: y + "px", }; if (cursor_img.style#display == 'none') { cursor_img.style#display = "block"; + refreshCursor(); } } @@ -536,6 +589,7 @@ handler.setPermission = function(name, enabled) { if (name == "clipboard") clipboard_enabled = enabled; if (name == "restart") restart_enabled = enabled; if (name == "recording") recording_enabled = enabled; + if (name == "privacy_mode") privacy_mode_enabled = enabled; input_blocked = false; header.update(); }); diff --git a/src/ui_cm_interface.rs b/src/ui_cm_interface.rs index c34e15e26..cab0d7f1c 100644 --- a/src/ui_cm_interface.rs +++ b/src/ui_cm_interface.rs @@ -1,18 +1,22 @@ -#[cfg(target_os = "windows")] -use crate::ipc::ClipboardNonFile; #[cfg(not(any(target_os = "android", target_os = "ios")))] use crate::ipc::Connection; #[cfg(not(any(target_os = "ios")))] use crate::ipc::{self, Data}; -#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] +#[cfg(target_os = "windows")] +use crate::{clipboard::ClipboardSide, ipc::ClipboardNonFile}; +#[cfg(target_os = "windows")] use clipboard::ContextSend; +#[cfg(not(any(target_os = "ios")))] +use hbb_common::fs::serialize_transfer_job; #[cfg(not(any(target_os = "android", target_os = "ios")))] use hbb_common::tokio::sync::mpsc::unbounded_channel; use hbb_common::{ - allow_err, - config::{keys::*, option2bool, Config}, - fs::is_write_need_confirmation, - fs::{self, get_string, new_send_confirm, DigestCheckResult}, + allow_err, bail, + config::{ + keys::{OPTION_ENABLE_PERM_CHANGE_IN_ACCEPT_WINDOW, OPTION_FILE_TRANSFER_MAX_FILES}, + option2bool, Config, + }, + fs::{self, get_string, is_write_need_confirmation, new_send_confirm, DigestCheckResult}, log, message_proto::*, protobuf::Message as _, @@ -21,13 +25,16 @@ use hbb_common::{ sync::mpsc::{self, UnboundedSender}, task::spawn_blocking, }, + ResultType, }; -#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] -use hbb_common::{tokio::sync::Mutex as TokioMutex, ResultType}; +#[cfg(target_os = "windows")] +use hbb_common::{config::keys::*, tokio::sync::Mutex as TokioMutex}; use serde_derive::Serialize; #[cfg(any(target_os = "android", target_os = "ios", feature = "flutter"))] use std::iter::FromIterator; -#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] +#[cfg(not(any(target_os = "ios")))] +use std::path::PathBuf; +#[cfg(target_os = "windows")] use std::sync::Arc; use std::{ collections::HashMap, @@ -38,14 +45,96 @@ use std::{ }, }; +/// Default maximum number of files allowed per transfer request. +/// Unit: number of files (not bytes). +#[cfg(not(any(target_os = "ios")))] +const DEFAULT_MAX_VALIDATED_FILES: usize = 10_000; + +/// Maximum number of files allowed in a single file transfer request. +/// +/// This limit prevents excessive I/O and memory usage when dealing with +/// large directories. It applies to: +/// - CM-side read jobs (server to client file transfers on Windows) +/// - `AllFiles` recursive directory listing operations +/// - Connection-side read jobs (non-Windows platforms) +/// +/// Unit: number of files (not bytes). +/// Default: 10,000 files. +/// Configured via: `OPTION_FILE_TRANSFER_MAX_FILES` ("file-transfer-max-files") +#[cfg(not(any(target_os = "ios")))] +static MAX_VALIDATED_FILES: std::sync::OnceLock = std::sync::OnceLock::new(); + +/// Get the maximum number of files allowed per transfer request. +/// +/// Initializes the value from configuration (`OPTION_FILE_TRANSFER_MAX_FILES`) +/// on first call. Semantics: +/// - If the option is set to `0`, `DEFAULT_MAX_VALIDATED_FILES` (10,000) is used as a safe upper bound. +/// - If the option is unset, negative, or non-integer, +/// `usize::MAX` is used to represent "no limit" for backward compatibility with older versions +/// that did not enforce any file‑count restriction. +/// (Note: negative values are not valid for `usize` and will cause parsing to fail.) +/// +/// Unit: number of files. +#[cfg(not(any(target_os = "ios")))] +#[inline] +pub fn get_max_validated_files() -> usize { + // If `OPTION_FILE_TRANSFER_MAX_FILES` unset, negative, or non-integer, use + // `usize::MAX` to represent "no limit", maintaining backward compatibility + // with versions that had no file transfer restrictions. + const NO_LIMIT_FILE_COUNT: usize = usize::MAX; + *MAX_VALIDATED_FILES.get_or_init(|| { + let c = crate::get_builtin_option(OPTION_FILE_TRANSFER_MAX_FILES) + .trim() + .parse::() + .unwrap_or(NO_LIMIT_FILE_COUNT); + if c == 0 { + DEFAULT_MAX_VALIDATED_FILES + } else { + c + } + }) +} + +/// Check if file count exceeds the maximum allowed limit. +/// +/// This check is enforced in: +/// - `start_read_job()` for CM-side read jobs +/// - `read_all_files()` for recursive directory listings +/// - `Connection::on_message()` for connection-side read jobs +/// +/// # Arguments +/// * `file_count` - Number of files in the transfer request +/// +/// # Returns +/// * `Ok(())` if within limit +/// * `Err(String)` with error message if limit exceeded +#[cfg(not(any(target_os = "ios")))] +pub fn check_file_count_limit(file_count: usize) -> Result<(), String> { + let max_files = get_max_validated_files(); + if file_count > max_files { + let msg = format!( + "file transfer rejected: too many files ({} files exceeds limit of {}). \ + Adjust '{}' option to increase limit.", + file_count, max_files, OPTION_FILE_TRANSFER_MAX_FILES + ); + log::warn!("{}", msg); + Err(msg) + } else { + Ok(()) + } +} + #[derive(Serialize, Clone)] pub struct Client { pub id: i32, pub authorized: bool, pub disconnected: bool, pub is_file_transfer: bool, + pub is_view_camera: bool, + pub is_terminal: bool, pub port_forward: String, pub name: String, + pub avatar: String, pub peer_id: String, pub keyboard: bool, pub clipboard: bool, @@ -54,6 +143,7 @@ pub struct Client { pub restart: bool, pub recording: bool, pub block_input: bool, + pub privacy_mode: bool, pub from_switch: bool, pub in_voice_call: bool, pub incoming_voice_call: bool, @@ -71,10 +161,12 @@ struct IpcTaskRunner { close: bool, running: bool, conn_id: i32, - #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] + #[cfg(target_os = "windows")] file_transfer_enabled: bool, - #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] + #[cfg(target_os = "windows")] file_transfer_enabled_peer: bool, + /// Read jobs for CM-side file reading (server to client transfers) + read_jobs: Vec, } lazy_static::lazy_static! { @@ -125,9 +217,12 @@ impl ConnectionManager { &self, id: i32, is_file_transfer: bool, + is_view_camera: bool, + is_terminal: bool, port_forward: String, peer_id: String, name: String, + avatar: String, authorized: bool, keyboard: bool, clipboard: bool, @@ -136,6 +231,7 @@ impl ConnectionManager { restart: bool, recording: bool, block_input: bool, + privacy_mode: bool, from_switch: bool, #[cfg(not(any(target_os = "ios")))] tx: mpsc::UnboundedSender, ) { @@ -144,8 +240,11 @@ impl ConnectionManager { authorized, disconnected: false, is_file_transfer, + is_view_camera, + is_terminal, port_forward, name: name.clone(), + avatar, peer_id: peer_id.clone(), keyboard, clipboard, @@ -154,6 +253,7 @@ impl ConnectionManager { restart, recording, block_input, + privacy_mode, from_switch, #[cfg(not(any(target_os = "ios")))] tx, @@ -169,7 +269,7 @@ impl ConnectionManager { } #[inline] - #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] + #[cfg(target_os = "windows")] fn is_authorized(&self, id: i32) -> bool { CLIENTS .read() @@ -190,12 +290,9 @@ impl ConnectionManager { .map(|c| c.disconnected = true); } - #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] + #[cfg(target_os = "windows")] { - let _ = ContextSend::proc(|context| -> ResultType<()> { - context.empty_clipboard(id)?; - Ok(()) - }); + crate::clipboard::try_empty_clipboard_files(ClipboardSide::Host, id); } #[cfg(any(target_os = "android"))] @@ -203,7 +300,7 @@ impl ConnectionManager { .read() .unwrap() .iter() - .filter(|(_k, v)| !v.is_file_transfer) + .filter(|(_k, v)| !v.is_file_transfer && !v.is_terminal) .next() .is_none() { @@ -280,15 +377,6 @@ pub fn close(id: i32) { }; } -#[inline] -#[cfg(target_os = "android")] -pub fn notify_input_control(v: bool) { - for (_, mut client) in CLIENTS.write().unwrap().iter_mut() { - client.keyboard = v; - allow_err!(client.tx.send(Data::InputControl(v))); - } -} - #[inline] pub fn remove(id: i32) { CLIENTS.write().unwrap().remove(&id); @@ -307,11 +395,52 @@ pub fn send_chat(id: i32, text: String) { #[inline] #[cfg(not(any(target_os = "ios")))] pub fn switch_permission(id: i32, name: String, enabled: bool) { + #[cfg(target_os = "android")] + let is_keyboard_permission = name == "keyboard"; + #[cfg(not(target_os = "android"))] + let is_keyboard_permission = false; + if !option2bool( + OPTION_ENABLE_PERM_CHANGE_IN_ACCEPT_WINDOW, + &crate::get_builtin_option(OPTION_ENABLE_PERM_CHANGE_IN_ACCEPT_WINDOW), + ) && !is_keyboard_permission + { + log::info!( + "blocked cm switch_permission by policy, conn_id={}, permission={}, enabled={}", + id, + name, + enabled + ); + return; + } if let Some(client) = CLIENTS.read().unwrap().get(&id) { allow_err!(client.tx.send(Data::SwitchPermission { name, enabled })); }; } +#[inline] +#[cfg(target_os = "android")] +pub fn switch_permission_all(name: String, enabled: bool) { + if name != "keyboard" + && !option2bool( + OPTION_ENABLE_PERM_CHANGE_IN_ACCEPT_WINDOW, + &crate::get_builtin_option(OPTION_ENABLE_PERM_CHANGE_IN_ACCEPT_WINDOW), + ) + { + log::info!( + "blocked cm switch_permission_all by policy, permission={}, enabled={}", + name, + enabled + ); + return; + } + for (_, client) in CLIENTS.read().unwrap().iter() { + allow_err!(client.tx.send(Data::SwitchPermission { + name: name.clone(), + enabled + })); + } +} + #[cfg(any(target_os = "android", target_os = "ios", feature = "flutter"))] #[inline] pub fn get_clients_state() -> String { @@ -326,9 +455,16 @@ pub fn get_clients_length() -> usize { clients.len() } +#[inline] +#[cfg(target_os = "android")] +pub fn has_active_clients() -> bool { + let clients = CLIENTS.read().unwrap(); + clients.values().any(|c| !c.disconnected) +} + #[inline] #[cfg(feature = "flutter")] -#[cfg(not(any(target_os = "ios")))] +#[cfg(not(any(target_os = "android", target_os = "ios")))] pub fn switch_back(id: i32) { if let Some(client) = CLIENTS.read().unwrap().get(&id) { allow_err!(client.tx.send(Data::SwitchSidesBack)); @@ -339,35 +475,51 @@ pub fn switch_back(id: i32) { impl IpcTaskRunner { async fn run(&mut self) { use hbb_common::config::LocalConfig; + use hbb_common::tokio::time::{self, Duration, Instant}; + + const MILLI5: Duration = Duration::from_millis(5); + const SEC30: Duration = Duration::from_secs(30); // for tmp use, without real conn id let mut write_jobs: Vec = Vec::new(); + // File timer for processing read_jobs + let mut file_timer = + crate::rustdesk_interval(time::interval_at(Instant::now() + SEC30, SEC30)); - #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] + #[cfg(target_os = "windows")] let is_authorized = self.cm.is_authorized(self.conn_id); - #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] - let rx_clip1; + #[cfg(target_os = "windows")] + let rx_clip_holder; let mut rx_clip; let _tx_clip; - #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] + #[cfg(target_os = "windows")] if self.conn_id > 0 && is_authorized { log::debug!("Clipboard is enabled from client peer: type 1"); - rx_clip1 = clipboard::get_rx_cliprdr_server(self.conn_id); - rx_clip = rx_clip1.lock().await; + let conn_id = self.conn_id; + rx_clip_holder = ( + clipboard::get_rx_cliprdr_server(conn_id), + Some(crate::SimpleCallOnReturn { + b: true, + f: Box::new(move || { + clipboard::remove_channel_by_conn_id(conn_id); + }), + }), + ); + rx_clip = rx_clip_holder.0.lock().await; } else { log::debug!("Clipboard is enabled from client peer, actually useless: type 2"); let rx_clip2; (_tx_clip, rx_clip2) = unbounded_channel::(); - rx_clip1 = Arc::new(TokioMutex::new(rx_clip2)); - rx_clip = rx_clip1.lock().await; + rx_clip_holder = (Arc::new(TokioMutex::new(rx_clip2)), None); + rx_clip = rx_clip_holder.0.lock().await; } - #[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))] + #[cfg(not(target_os = "windows"))] { (_tx_clip, rx_clip) = unbounded_channel::(); } - #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] + #[cfg(target_os = "windows")] { if ContextSend::is_enabled() { log::debug!("Clipboard is enabled"); @@ -391,11 +543,11 @@ impl IpcTaskRunner { } Ok(Some(data)) => { match data { - Data::Login{id, is_file_transfer, port_forward, peer_id, name, authorized, keyboard, clipboard, audio, file, file_transfer_enabled: _file_transfer_enabled, restart, recording, block_input, from_switch} => { + Data::Login{id, is_file_transfer, is_view_camera, is_terminal, port_forward, peer_id, name, avatar, authorized, keyboard, clipboard, audio, file, file_transfer_enabled: _file_transfer_enabled, restart, recording, block_input, privacy_mode, from_switch} => { log::debug!("conn_id: {}", id); - self.cm.add_connection(id, is_file_transfer, port_forward, peer_id, name, authorized, keyboard, clipboard, audio, file, restart, recording, block_input, from_switch, self.tx.clone()); + self.cm.add_connection(id, is_file_transfer, is_view_camera, is_terminal, port_forward, peer_id, name, avatar, authorized, keyboard, clipboard, audio, file, restart, recording, block_input, privacy_mode, from_switch, self.tx.clone()); self.conn_id = id; - #[cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))] + #[cfg(target_os = "windows")] { self.file_transfer_enabled = _file_transfer_enabled; } @@ -421,14 +573,40 @@ impl IpcTaskRunner { Data::ChatMessage { text } => { self.cm.new_message(self.conn_id, text); } + Data::SwitchPermission { name, enabled } => { + // Keep this branch scoped to privacy mode rollback. + // Other CM permission toggles are updated optimistically by the UI itself. + // The backend currently sends SwitchPermission back to CM only when + // privacy-mode turn-off fails and the UI state must be restored. + if name == "privacy_mode" { + let client = { + let mut clients = CLIENTS.write().unwrap(); + clients.get_mut(&self.conn_id).map(|c| { + c.privacy_mode = enabled; + c.clone() + }) + }; + if let Some(client) = client { + // This reuses add_connection(), and cm.tis only selectively updates + // existing rows (authorized/privacy_mode) for this fallback path. + self.cm.ui_handler.add_connection(&client); + } + } + } Data::FS(mut fs) => { if let ipc::FS::WriteBlock { id, file_num, data: _, compressed } = fs { if let Ok(bytes) = self.stream.next_raw().await { fs = ipc::FS::WriteBlock{id, file_num, data:bytes.into(), compressed}; - handle_fs(fs, &mut write_jobs, &self.tx, Some(&tx_log)).await; + handle_fs(fs, &mut write_jobs, &mut self.read_jobs, &self.tx, Some(&tx_log), self.conn_id).await; } } else { - handle_fs(fs, &mut write_jobs, &self.tx, Some(&tx_log)).await; + handle_fs(fs, &mut write_jobs, &mut self.read_jobs, &self.tx, Some(&tx_log), self.conn_id).await; + } + // Activate fast timer immediately when read jobs exist. + // This ensures new jobs start processing without waiting for the slow 30s timer. + // Deactivation (back to 30s) happens in tick handler when jobs are exhausted. + if !self.read_jobs.is_empty() { + file_timer = crate::rustdesk_interval(time::interval(MILLI5)); } let log = fs::serialize_transfer_jobs(&write_jobs); self.cm.ui_handler.file_transfer_log("transfer", &log); @@ -436,34 +614,31 @@ impl IpcTaskRunner { Data::FileTransferLog((action, log)) => { self.cm.ui_handler.file_transfer_log(&action, &log); } - #[cfg(not(any(target_os = "android", target_os = "ios")))] + #[cfg(target_os = "windows")] Data::ClipboardFile(_clip) => { - #[cfg(any(target_os = "windows", target_os="linux", target_os = "macos"))] - { - let is_stopping_allowed = _clip.is_beginning_message(); - let is_clipboard_enabled = ContextSend::is_enabled(); - let file_transfer_enabled = self.file_transfer_enabled; - let stop = !is_stopping_allowed && !(is_clipboard_enabled && file_transfer_enabled); - log::debug!( - "Process clipboard message from client peer, stop: {}, is_stopping_allowed: {}, is_clipboard_enabled: {}, file_transfer_enabled: {}", - stop, is_stopping_allowed, is_clipboard_enabled, file_transfer_enabled); - if stop { - ContextSend::set_is_stopped(); - } else { - if !is_authorized { - log::debug!("Clipboard message from client peer, but not authorized"); - continue; - } - let conn_id = self.conn_id; - let _ = ContextSend::proc(|context| -> ResultType<()> { - context.server_clip_file(conn_id, _clip) - .map_err(|e| e.into()) - }); + let is_stopping_allowed = _clip.is_beginning_message(); + let is_clipboard_enabled = ContextSend::is_enabled(); + let file_transfer_enabled = self.file_transfer_enabled; + let stop = !is_stopping_allowed && !(is_clipboard_enabled && file_transfer_enabled); + log::debug!( + "Process clipboard message from client peer, stop: {}, is_stopping_allowed: {}, is_clipboard_enabled: {}, file_transfer_enabled: {}", + stop, is_stopping_allowed, is_clipboard_enabled, file_transfer_enabled); + if stop { + ContextSend::set_is_stopped(); + } else { + if !is_authorized { + log::debug!("Clipboard message from client peer, but not authorized"); + continue; } + let conn_id = self.conn_id; + let _ = ContextSend::proc(|context| -> ResultType<()> { + context.server_clip_file(conn_id, _clip) + .map_err(|e| e.into()) + }); } } Data::ClipboardFileEnabled(_enabled) => { - #[cfg(any(target_os= "windows",target_os ="linux", target_os = "macos"))] + #[cfg(target_os = "windows")] { self.file_transfer_enabled_peer = _enabled; } @@ -535,13 +710,38 @@ impl IpcTaskRunner { } } Some(data) = self.rx.recv() => { + // For FileBlockFromCM, data is sent separately via send_raw (data field has #[serde(skip)]). + // This avoids JSON encoding overhead for large binary data. + // This mirrors the WriteBlock pattern in start_ipc (see rx_to_cm handler). + // + // Note: Empty data (for empty files) is correctly handled. BytesCodec with raw=false + // (the default for IPC connections) adds a length prefix, so send_raw(Bytes::new()) + // sends a 1-byte frame that next_raw() can correctly receive as empty data. + if let Data::FileBlockFromCM { id, file_num, ref data, compressed, conn_id } = data { + // Send metadata first (data field is skipped by serde), then raw data bytes + if let Err(e) = self.stream.send(&Data::FileBlockFromCM { + id, + file_num, + data: bytes::Bytes::new(), // placeholder, skipped by serde + compressed, + conn_id, + }).await { + log::error!("error sending FileBlockFromCM metadata: {}", e); + break; + } + if let Err(e) = self.stream.send_raw(data.clone()).await { + log::error!("error sending FileBlockFromCM data: {}", e); + break; + } + continue; + } if let Err(e) = self.stream.send(&data).await { log::error!("error encountered in IPC task, quitting: {}", e); break; } match &data { Data::SwitchPermission{name: _name, enabled: _enabled} => { - #[cfg(any(target_os="linux", target_os="windows", target_os = "macos"))] + #[cfg(target_os = "windows")] if _name == "file" { self.file_transfer_enabled = *_enabled; } @@ -556,7 +756,7 @@ impl IpcTaskRunner { }, clip_file = rx_clip.recv() => match clip_file { Some(_clip) => { - #[cfg(any(target_os = "windows", target_os ="linux", target_os = "macos"))] + #[cfg(target_os = "windows")] { let is_stopping_allowed = _clip.is_stopping_allowed(); let is_clipboard_enabled = ContextSend::is_enabled(); @@ -585,6 +785,18 @@ impl IpcTaskRunner { Some(job_log) = rx_log.recv() => { self.cm.ui_handler.file_transfer_log("transfer", &job_log); } + _ = file_timer.tick() => { + if !self.read_jobs.is_empty() { + let conn_id = self.conn_id; + if let Err(e) = handle_read_jobs_tick(&mut self.read_jobs, &self.tx, conn_id).await { + log::error!("Error processing read jobs: {}", e); + } + let log = fs::serialize_transfer_jobs(&self.read_jobs); + self.cm.ui_handler.file_transfer_log("transfer", &log); + } else { + file_timer = crate::rustdesk_interval(time::interval_at(Instant::now() + SEC30, SEC30)); + } + } } } } @@ -600,10 +812,11 @@ impl IpcTaskRunner { close: true, running: true, conn_id: 0, - #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] + #[cfg(target_os = "windows")] file_transfer_enabled: false, - #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] + #[cfg(target_os = "windows")] file_transfer_enabled_peer: false, + read_jobs: Vec::new(), }; while task_runner.running { @@ -621,17 +834,15 @@ impl IpcTaskRunner { #[cfg(not(any(target_os = "android", target_os = "ios")))] #[tokio::main(flavor = "current_thread")] pub async fn start_ipc(cm: ConnectionManager) { - #[cfg(any( - target_os = "windows", - all( - any(target_os = "linux", target_os = "macos"), - feature = "unix-file-copy-paste" - ), - ))] - ContextSend::enable(option2bool( - OPTION_ENABLE_FILE_TRANSFER, - &Config::get_option(OPTION_ENABLE_FILE_TRANSFER), - )); + #[cfg(target_os = "windows")] + { + let enabled = crate::Connection::is_permission_enabled_locally(OPTION_ENABLE_FILE_TRANSFER); + let mut lock = crate::ui_interface::IS_FILE_TRANSFER_ENABLED + .lock() + .unwrap(); + ContextSend::enable(enabled); + *lock = Some(enabled); + } match ipc::new_listener("_cm").await { Ok(mut incoming) => { while let Some(result) = incoming.next().await { @@ -670,9 +881,12 @@ pub async fn start_listen( Some(Data::Login { id, is_file_transfer, + is_view_camera, + is_terminal, port_forward, peer_id, name, + avatar, authorized, keyboard, clipboard, @@ -681,6 +895,7 @@ pub async fn start_listen( restart, recording, block_input, + privacy_mode, from_switch, .. }) => { @@ -688,9 +903,12 @@ pub async fn start_listen( cm.add_connection( id, is_file_transfer, + is_view_camera, + is_terminal, port_forward, peer_id, name, + avatar, authorized, keyboard, clipboard, @@ -699,6 +917,7 @@ pub async fn start_listen( restart, recording, block_input, + privacy_mode, from_switch, tx.clone(), ); @@ -707,7 +926,17 @@ pub async fn start_listen( cm.new_message(current_id, text); } Some(Data::FS(fs)) => { - handle_fs(fs, &mut write_jobs, &tx, None).await; + // Android doesn't need CM-side file reading (no need_validate_file_read_access) + let mut read_jobs_placeholder: Vec = Vec::new(); + handle_fs( + fs, + &mut write_jobs, + &mut read_jobs_placeholder, + &tx, + None, + current_id, + ) + .await; } Some(Data::Close) => { break; @@ -734,12 +963,18 @@ pub async fn start_listen( async fn handle_fs( fs: ipc::FS, write_jobs: &mut Vec, + read_jobs: &mut Vec, tx: &UnboundedSender, tx_log: Option<&UnboundedSender>, + _conn_id: i32, ) { - use hbb_common::fs::serialize_transfer_job; - match fs { + ipc::FS::ReadEmptyDirs { + dir, + include_hidden, + } => { + read_empty_dirs(&dir, include_hidden, tx).await; + } ipc::FS::ReadDir { dir, include_hidden, @@ -768,51 +1003,58 @@ async fn handle_fs( total_size, conn_id, } => { + // Convert files to FileEntry + let file_entries: Vec = files + .drain(..) + .map(|f| FileEntry { + name: f.0, + modified_time: f.1, + ..Default::default() + }) + .collect(); + // cm has no show_hidden context // dummy remote, show_hidden, is_remote let mut job = fs::TransferJob::new_write( id, + fs::JobType::Generic, "".to_string(), - path, + fs::DataSource::FilePath(PathBuf::from(&path)), file_num, false, false, - files - .drain(..) - .map(|f| FileEntry { - name: f.0, - modified_time: f.1, - ..Default::default() - }) - .collect(), overwrite_detection, ); + if let Err(e) = job.set_files(file_entries) { + log::warn!("Reject unsafe transfer file list for {}: {}", path, e); + send_raw(fs::new_error(id, e, file_num), tx); + return; + } job.total_size = total_size; job.conn_id = conn_id; write_jobs.push(job); } ipc::FS::CancelWrite { id } => { - if let Some(job) = fs::get_job(id, write_jobs) { + if let Some(job) = fs::remove_job(id, write_jobs) { job.remove_download_file(); - tx_log.map(|tx: &UnboundedSender| { - tx.send(serialize_transfer_job(job, false, true, "")) - }); - fs::remove_job(id, write_jobs); + if let Some(tx) = tx_log { + if let Err(e) = tx.send(serialize_transfer_job(&job, false, true, "")) { + log::error!("error sending transfer job log via IPC: {}", e); + } + } } } ipc::FS::WriteDone { id, file_num } => { - if let Some(job) = fs::get_job(id, write_jobs) { + if let Some(job) = fs::remove_job(id, write_jobs) { job.modify_time(); send_raw(fs::new_done(id, file_num), tx); - tx_log.map(|tx| tx.send(serialize_transfer_job(job, true, false, ""))); - fs::remove_job(id, write_jobs); + tx_log.map(|tx| tx.send(serialize_transfer_job(&job, true, false, ""))); } } ipc::FS::WriteError { id, file_num, err } => { - if let Some(job) = fs::get_job(id, write_jobs) { - tx_log.map(|tx| tx.send(serialize_transfer_job(job, false, false, &err))); + if let Some(job) = fs::remove_job(id, write_jobs) { + tx_log.map(|tx| tx.send(serialize_transfer_job(&job, false, false, &err))); send_raw(fs::new_error(job.id(), err, file_num), tx); - fs::remove_job(job.id(), write_jobs); } } ipc::FS::WriteBlock { @@ -842,6 +1084,7 @@ async fn handle_fs( file_size, last_modified, is_upload, + is_resume, } => { if let Some(job) = fs::get_job(id, write_jobs) { let mut req = FileTransferSendConfirmRequest { @@ -858,44 +1101,433 @@ async fn handle_fs( ..Default::default() }; if let Some(file) = job.files().get(file_num as usize) { - let path = get_string(&job.join(&file.name)); - match is_write_need_confirmation(&path, &digest) { - Ok(digest_result) => { - match digest_result { - DigestCheckResult::IsSame => { - req.set_skip(true); - let msg_out = new_send_confirm(req); - send_raw(msg_out, &tx); - } - DigestCheckResult::NeedConfirm(mut digest) => { - // upload to server, but server has the same file, request - digest.is_upload = is_upload; - let mut msg_out = Message::new(); - let mut fr = FileResponse::new(); - fr.set_digest(digest); - msg_out.set_file_response(fr); - send_raw(msg_out, &tx); - } - DigestCheckResult::NoSuchFile => { - let msg_out = new_send_confirm(req); - send_raw(msg_out, &tx); + if let fs::DataSource::FilePath(p) = &job.data_source { + let path = get_string(&fs::TransferJob::join(p, &file.name)); + match is_write_need_confirmation(is_resume, &path, &digest) { + Ok(digest_result) => { + job.set_digest(file_size, last_modified); + match digest_result { + DigestCheckResult::IsSame => { + req.set_skip(true); + let msg_out = new_send_confirm(req); + send_raw(msg_out, &tx); + } + DigestCheckResult::NeedConfirm(mut digest) => { + // upload to server, but server has the same file, request + digest.is_upload = is_upload; + let mut msg_out = Message::new(); + let mut fr = FileResponse::new(); + fr.set_digest(digest); + msg_out.set_file_response(fr); + send_raw(msg_out, &tx); + } + DigestCheckResult::NoSuchFile => { + let msg_out = new_send_confirm(req); + send_raw(msg_out, &tx); + } } } - } - Err(err) => { - send_raw(fs::new_error(id, err, file_num), &tx); + Err(err) => { + send_raw(fs::new_error(id, err, file_num), &tx); + } } } } } } + ipc::FS::SendConfirm(bytes) => { + if let Ok(r) = FileTransferSendConfirmRequest::parse_from_bytes(&bytes) { + if let Some(job) = fs::get_job(r.id, write_jobs) { + job.confirm(&r).await; + } + } + } ipc::FS::Rename { id, path, new_name } => { rename_file(path, new_name, id, tx).await; } + ipc::FS::ReadFile { + path, + id, + file_num, + include_hidden, + conn_id, + overwrite_detection, + } => { + start_read_job( + path, + file_num, + include_hidden, + id, + conn_id, + overwrite_detection, + read_jobs, + tx, + ) + .await; + } + // Cancel an ongoing read job (file transfer from server to client). + // Note: This only cancels jobs in `read_jobs`. It does NOT cancel `ReadAllFiles` + // operations, which are one-shot directory scans that complete quickly and don't + // have persistent job tracking. + ipc::FS::CancelRead { id, conn_id: _ } => { + if let Some(job) = fs::remove_job(id, read_jobs) { + if let Some(tx) = tx_log { + if let Err(e) = tx.send(serialize_transfer_job(&job, false, true, "")) { + log::error!("error sending transfer job log via IPC: {}", e); + } + } + } + } + ipc::FS::SendConfirmForRead { + id, + file_num: _, + skip, + offset_blk, + conn_id: _, + } => { + if let Some(job) = fs::get_job(id, read_jobs) { + let req = FileTransferSendConfirmRequest { + id, + file_num: job.file_num(), + union: if skip { + Some(file_transfer_send_confirm_request::Union::Skip(true)) + } else { + Some(file_transfer_send_confirm_request::Union::OffsetBlk( + offset_blk, + )) + }, + ..Default::default() + }; + job.confirm(&req).await; + } + } + // Recursively list all files in a directory. + // This is a one-shot operation that cannot be cancelled via CancelRead. + // The operation typically completes quickly as it only reads directory metadata, + // not file contents. File count is limited by `check_file_count_limit()`. + ipc::FS::ReadAllFiles { + path, + id, + include_hidden, + conn_id, + } => { + read_all_files(path, include_hidden, id, conn_id, tx).await; + } _ => {} } } +/// Start a read job in CM for file transfer from server to client (Windows only). +/// +/// This creates a `TransferJob` using `new_read()`, validates it, and sends the +/// initial file list back to Connection via IPC. +/// +/// NOTE: This is the CM-side equivalent of `create_and_start_read_job()` in +/// `src/server/connection.rs`. On non-Windows platforms, Connection handles +/// read jobs directly. Both use `TransferJob::new_read()` with similar logic. +/// When modifying job creation or validation, ensure both paths stay in sync. +#[cfg(not(any(target_os = "ios")))] +async fn start_read_job( + path: String, + file_num: i32, + include_hidden: bool, + id: i32, + conn_id: i32, + overwrite_detection: bool, + read_jobs: &mut Vec, + tx: &UnboundedSender, +) { + let path_clone = path.clone(); + let result = spawn_blocking(move || -> ResultType { + let data_source = fs::DataSource::FilePath(PathBuf::from(&path)); + fs::TransferJob::new_read( + id, + fs::JobType::Generic, + "".to_string(), + data_source, + file_num, + include_hidden, + true, + overwrite_detection, + ) + }) + .await; + + match result { + Ok(Ok(mut job)) => { + // Optional: enforce file count limit for CM-side jobs to avoid + // excessive I/O. This is applied on the job's file list produced + // by `new_read`, similar to how AllFiles uses the same helper. + if let Err(msg) = check_file_count_limit(job.files().len()) { + if let Err(e) = tx.send(Data::ReadJobInitResult { + id, + file_num, + include_hidden, + conn_id, + result: Err(msg), + }) { + log::error!("error sending ReadJobInitResult via IPC: {}", e); + } + return; + } + + // Build FileDirectory from the job's file list and serialize + let files = job.files().to_owned(); + let mut dir = FileDirectory::new(); + dir.id = id; + dir.path = path_clone.clone(); + dir.entries = files.clone().into(); + + let dir_bytes = match dir.write_to_bytes() { + Ok(bytes) => bytes, + Err(e) => { + if let Err(e) = tx.send(Data::ReadJobInitResult { + id, + file_num, + include_hidden, + conn_id, + result: Err(format!("serialize failed: {}", e)), + }) { + log::error!("error sending ReadJobInitResult via IPC: {}", e); + } + return; + } + }; + + if let Err(e) = tx.send(Data::ReadJobInitResult { + id, + file_num, + include_hidden, + conn_id, + result: Ok(dir_bytes), + }) { + log::error!("error sending ReadJobInitResult via IPC: {}", e); + } + + // Attach connection id so CM can route read blocks back correctly + job.conn_id = conn_id; + read_jobs.push(job); + } + Ok(Err(e)) => { + if let Err(e) = tx.send(Data::ReadJobInitResult { + id, + file_num, + include_hidden, + conn_id, + result: Err(format!("validation failed: {}", e)), + }) { + log::error!("error sending ReadJobInitResult via IPC: {}", e); + } + } + Err(e) => { + if let Err(e) = tx.send(Data::ReadJobInitResult { + id, + file_num, + include_hidden, + conn_id, + result: Err(format!("validation task failed: {}", e)), + }) { + log::error!("error sending ReadJobInitResult via IPC: {}", e); + } + } + } +} + +/// Process read jobs periodically, reading file blocks and sending them via IPC. +/// +/// NOTE: This is the CM-side equivalent of `handle_read_jobs()` in +/// `libs/hbb_common/src/fs.rs`. The logic mirrors that implementation +/// but communicates via IPC instead of direct network stream. +/// When modifying job processing logic, ensure both implementations stay in sync. +#[cfg(not(any(target_os = "ios")))] +async fn handle_read_jobs_tick( + jobs: &mut Vec, + tx: &UnboundedSender, + conn_id: i32, +) -> ResultType<()> { + let mut finished = Vec::new(); + + for job in jobs.iter_mut() { + if job.is_last_job { + continue; + } + + // Initialize data stream if needed (opens file, sends digest for overwrite detection) + if let Err(err) = init_read_job_for_cm(job, tx, conn_id).await { + if let Err(e) = tx.send(Data::FileReadError { + id: job.id, + file_num: job.file_num(), + err: format!("{}", err), + conn_id, + }) { + log::error!("error sending FileReadError via IPC: {}", e); + } + finished.push(job.id); + continue; + } + + // Read a block from the file + match job.read().await { + Err(err) => { + if let Err(e) = tx.send(Data::FileReadError { + id: job.id, + file_num: job.file_num(), + err: format!("{}", err), + conn_id, + }) { + log::error!("error sending FileReadError via IPC: {}", e); + } + // Mark job as finished to prevent infinite retries. + // Connection side will have already removed cm_read_job_ids + // after receiving FileReadError, so continuing would be pointless. + finished.push(job.id); + } + Ok(Some(block)) => { + if let Err(e) = tx.send(Data::FileBlockFromCM { + id: block.id, + file_num: block.file_num, + data: block.data, + compressed: block.compressed, + conn_id, + }) { + log::error!("error sending FileBlockFromCM via IPC: {}", e); + } + } + Ok(None) => { + if job.job_completed() { + finished.push(job.id); + match job.job_error() { + Some(err) => { + if let Err(e) = tx.send(Data::FileReadError { + id: job.id, + file_num: job.file_num(), + err, + conn_id, + }) { + log::error!("error sending FileReadError via IPC: {}", e); + } + } + None => { + if let Err(e) = tx.send(Data::FileReadDone { + id: job.id, + file_num: job.file_num(), + conn_id, + }) { + log::error!("error sending FileReadDone via IPC: {}", e); + } + } + } + } + // else: waiting for confirmation from peer + } + } + // Break to handle jobs one by one. + break; + } + + for id in finished { + let _ = fs::remove_job(id, jobs); + } + + Ok(()) +} + +/// Initialize a read job's data stream and handle digest sending for overwrite detection. +/// +/// NOTE: This is the CM-side equivalent of `TransferJob::init_data_stream()` in +/// `libs/hbb_common/src/fs.rs`. It calls `init_data_stream_for_cm()` and sends +/// digest via IPC instead of direct network stream. +/// When modifying initialization or digest logic, ensure both paths stay in sync. +#[cfg(not(any(target_os = "ios")))] +async fn init_read_job_for_cm( + job: &mut fs::TransferJob, + tx: &UnboundedSender, + conn_id: i32, +) -> ResultType<()> { + // Initialize data stream and get digest info if overwrite detection is needed + match job.init_data_stream_for_cm().await? { + Some((last_modified, file_size)) => { + // Send digest via IPC for overwrite detection + if let Err(e) = tx.send(Data::FileDigestFromCM { + id: job.id, + file_num: job.file_num(), + last_modified, + file_size, + is_resume: job.is_resume, + conn_id, + }) { + log::error!("error sending FileDigestFromCM via IPC: {}", e); + } + } + None => { + // Job done or already initialized, nothing to do + } + } + Ok(()) +} + +#[cfg(not(any(target_os = "ios")))] +async fn read_all_files( + path: String, + include_hidden: bool, + id: i32, + conn_id: i32, + tx: &UnboundedSender, +) { + let path_clone = path.clone(); + let result = spawn_blocking(move || fs::get_recursive_files(&path, include_hidden)).await; + + let result = match result { + Ok(Ok(files)) => { + // Check file count limit to prevent excessive I/O and resource usage + if let Err(msg) = check_file_count_limit(files.len()) { + Err(msg) + } else { + // Serialize FileDirectory to protobuf bytes + let mut fd = FileDirectory::new(); + fd.id = id; + fd.path = path_clone.clone(); + fd.entries = files.into(); + match fd.write_to_bytes() { + Ok(bytes) => Ok(bytes), + Err(e) => Err(format!("serialize failed: {}", e)), + } + } + } + Ok(Err(e)) => Err(format!("{}", e)), + Err(e) => Err(format!("task failed: {}", e)), + }; + + if let Err(e) = tx.send(Data::AllFilesResult { + id, + conn_id, + path: path_clone, + result, + }) { + log::error!("error sending AllFilesResult via IPC: {}", e); + } +} + +#[cfg(not(any(target_os = "ios")))] +async fn read_empty_dirs(dir: &str, include_hidden: bool, tx: &UnboundedSender) { + let path = dir.to_owned(); + let path_clone = dir.to_owned(); + + if let Ok(Ok(fds)) = + spawn_blocking(move || fs::get_empty_dirs_recursive(&path, include_hidden)).await + { + let mut msg_out = Message::new(); + let mut file_response = FileResponse::new(); + file_response.set_empty_dirs(ReadEmptyDirsResponse { + path: path_clone, + empty_dirs: fds, + ..Default::default() + }); + msg_out.set_file_response(file_response); + send_raw(msg_out, tx); + } +} + #[cfg(not(any(target_os = "ios")))] async fn read_dir(dir: &str, include_hidden: bool, tx: &UnboundedSender) { let path = { @@ -1056,3 +1688,111 @@ pub fn quit_cm() { CLIENTS.write().unwrap().clear(); crate::platform::quit_gui(); } + +#[cfg(test)] +mod tests { + use super::*; + + use crate::ipc::Data; + use hbb_common::{ + message_proto::{FileDirectory, Message}, + tokio::{runtime::Runtime, sync::mpsc::unbounded_channel}, + }; + use std::fs; + + #[test] + #[cfg(not(any(target_os = "ios")))] + fn read_all_files_success() { + let rt = Runtime::new().unwrap(); + rt.block_on(async { + let (tx, mut rx) = unbounded_channel(); + let dir = std::env::temp_dir().join("rustdesk_read_all_test"); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("test.txt"), b"hello").unwrap(); + + let path_str = dir.to_string_lossy().to_string(); + super::read_all_files(path_str.clone(), false, 1, 2, &tx).await; + + match rx.recv().await.unwrap() { + Data::AllFilesResult { result, .. } => { + let bytes = result.unwrap(); + let fd = FileDirectory::parse_from_bytes(&bytes).unwrap(); + assert!(!fd.entries.is_empty()); + } + _ => panic!("unexpected data"), + } + let _ = fs::remove_dir_all(&dir); + }); + } + + #[test] + #[cfg(not(any(target_os = "ios")))] + fn read_dir_success() { + let rt = Runtime::new().unwrap(); + rt.block_on(async { + let (tx, mut rx) = unbounded_channel(); + let dir = std::env::temp_dir().join("rustdesk_read_dir_test"); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + + 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!(msg + .file_response() + .dir() + .path + .contains("rustdesk_read_dir_test")); + } + _ => panic!("unexpected data"), + } + let _ = fs::remove_dir_all(&dir); + }); + } + + /// Tests that symlink creation works on this platform. + /// This is a helper to verify the test environment supports symlinks. + #[test] + #[cfg(not(any(target_os = "ios")))] + fn test_symlink_creation_works() { + let base_dir = std::env::temp_dir().join("rustdesk_symlink_test"); + let _ = fs::remove_dir_all(&base_dir); + fs::create_dir_all(&base_dir).unwrap(); + + // Create target file in a subdirectory + let target_dir = base_dir.join("target_dir"); + fs::create_dir_all(&target_dir).unwrap(); + let target_file = target_dir.join("target.txt"); + fs::write(&target_file, b"content").unwrap(); + + // Create symlink in a different directory + let link_dir = base_dir.join("link_dir"); + fs::create_dir_all(&link_dir).unwrap(); + let link_path = link_dir.join("link.txt"); + + #[cfg(unix)] + { + use std::os::unix::fs::symlink; + if symlink(&target_file, &link_path).is_err() { + let _ = fs::remove_dir_all(&base_dir); + return; + } + } + + #[cfg(windows)] + { + use std::os::windows::fs::symlink_file; + if symlink_file(&target_file, &link_path).is_err() { + // Skip if no permission (needs admin or dev mode on Windows) + let _ = fs::remove_dir_all(&base_dir); + return; + } + } + + let _ = fs::remove_dir_all(&base_dir); + } +} diff --git a/src/ui_interface.rs b/src/ui_interface.rs index bab54c79a..1645b242d 100644 --- a/src/ui_interface.rs +++ b/src/ui_interface.rs @@ -3,10 +3,7 @@ use hbb_common::password_security; use hbb_common::{ allow_err, bytes::Bytes, - config::{ - self, keys::*, option2bool, Config, LocalConfig, PeerConfig, CONNECT_TIMEOUT, - RENDEZVOUS_PORT, - }, + config::{self, keys::*, Config, LocalConfig, PeerConfig, CONNECT_TIMEOUT, RENDEZVOUS_PORT}, directories_next, futures::future::join_all, log, @@ -23,7 +20,6 @@ use serde_derive::Serialize; use std::process::Child; use std::{ collections::HashMap, - sync::atomic::{AtomicUsize, Ordering}, sync::{Arc, Mutex}, }; @@ -47,6 +43,8 @@ pub struct UiStatus { pub mouse_time: i64, #[cfg(not(feature = "flutter"))] pub id: String, + #[cfg(feature = "flutter")] + pub video_conn_count: usize, } #[derive(Debug, Clone, Serialize)] @@ -65,14 +63,15 @@ lazy_static::lazy_static! { mouse_time: 0, #[cfg(not(feature = "flutter"))] id: "".to_owned(), + #[cfg(feature = "flutter")] + video_conn_count: 0, })); static ref ASYNC_JOB_STATUS : Arc> = Default::default(); static ref ASYNC_HTTP_STATUS : Arc>> = Arc::new(Mutex::new(HashMap::new())); static ref TEMPORARY_PASSWD : Arc> = Arc::new(Mutex::new("".to_owned())); + static ref IS_REMOTE_MODIFY_ENABLED_BY_CONTROL_PERMISSIONS : Arc>> = Arc::new(Mutex::new(None)); } -pub static VIDEO_CONN_COUNT: AtomicUsize = AtomicUsize::new(0); - #[cfg(not(any(target_os = "android", target_os = "ios")))] lazy_static::lazy_static! { static ref OPTION_SYNCED: Arc> = Default::default(); @@ -81,6 +80,11 @@ lazy_static::lazy_static! { static ref CHILDREN : Children = Default::default(); } +#[cfg(target_os = "windows")] +lazy_static::lazy_static! { + pub static ref IS_FILE_TRANSFER_ENABLED: Arc>> = Arc::new(Mutex::new(None)); +} + const INIT_ASYNC_JOB_STATUS: &str = " "; #[cfg(any(target_os = "android", target_os = "ios", feature = "flutter"))] @@ -173,29 +177,58 @@ pub fn get_option>(key: T) -> String { } #[inline] -#[cfg(target_os = "macos")] pub fn use_texture_render() -> bool { - cfg!(feature = "flutter") && LocalConfig::get_option(config::keys::OPTION_TEXTURE_RENDER) == "Y" + #[cfg(target_os = "android")] + return false; + #[cfg(target_os = "ios")] + return false; + + #[cfg(target_os = "macos")] + return cfg!(feature = "flutter") + && LocalConfig::get_option(config::keys::OPTION_TEXTURE_RENDER) == "Y"; + + #[cfg(target_os = "linux")] + return cfg!(feature = "flutter") + && LocalConfig::get_option(config::keys::OPTION_TEXTURE_RENDER) != "N"; + + #[cfg(target_os = "windows")] + { + if !cfg!(feature = "flutter") { + return false; + } + // https://learn.microsoft.com/en-us/windows/win32/sysinfo/targeting-your-application-at-windows-8-1 + #[cfg(debug_assertions)] + let default_texture = true; + #[cfg(not(debug_assertions))] + let default_texture = crate::platform::is_win_10_or_greater(); + if default_texture { + LocalConfig::get_option(config::keys::OPTION_TEXTURE_RENDER) != "N" + } else { + return LocalConfig::get_option(config::keys::OPTION_TEXTURE_RENDER) == "Y"; + } + } } #[inline] -#[cfg(any(target_os = "windows", target_os = "linux"))] -pub fn use_texture_render() -> bool { - cfg!(feature = "flutter") && LocalConfig::get_option(config::keys::OPTION_TEXTURE_RENDER) != "N" -} - -#[inline] -#[cfg(any(target_os = "android", target_os = "ios"))] -pub fn use_texture_render() -> bool { - false +pub fn is_option_fixed(key: &str) -> bool { + config::OVERWRITE_DISPLAY_SETTINGS + .read() + .unwrap() + .contains_key(key) + || config::OVERWRITE_LOCAL_SETTINGS + .read() + .unwrap() + .contains_key(key) + || config::OVERWRITE_SETTINGS.read().unwrap().contains_key(key) } #[inline] pub fn get_local_option(key: String) -> String { - LocalConfig::get_option(&key) + crate::get_local_option(&key) } #[inline] +#[cfg(feature = "flutter")] pub fn get_hard_option(key: String) -> String { config::HARD_SETTINGS .read() @@ -212,7 +245,20 @@ pub fn get_builtin_option(key: &str) -> String { #[inline] pub fn set_local_option(key: String, value: String) { - LocalConfig::set_option(key.clone(), value.clone()); + LocalConfig::set_option(key.clone(), value); +} + +/// Resolve relative avatar path (e.g. "/avatar/xxx") to absolute URL +/// by prepending the API server address. +pub fn resolve_avatar_url(avatar: String) -> String { + let avatar = avatar.trim().to_owned(); + if avatar.starts_with('/') { + let api_server = get_api_server(); + if !api_server.is_empty() { + return format!("{}{}", api_server.trim_end_matches('/'), avatar); + } + } + avatar } #[cfg(any(target_os = "android", target_os = "ios", feature = "flutter"))] @@ -321,6 +367,8 @@ pub fn get_sound_inputs() -> Vec { fn get_sound_inputs_() -> Vec { let mut out = Vec::new(); use cpal::traits::{DeviceTrait, HostTrait}; + // Do not use `cpal::host_from_id(cpal::HostId::ScreenCaptureKit)` for feature = "screencapturekit" + // Because we explicitly handle the "System Sound" device. let host = cpal::default_host(); if let Ok(devices) = host.devices() { for device in devices { @@ -410,7 +458,10 @@ pub fn set_option(key: String, value: String) { ipc::set_options(options.clone()).ok(); } #[cfg(any(target_os = "android", target_os = "ios"))] - Config::set_option(key, value); + { + let _nat = crate::CheckTestNatType::new(); + Config::set_option(key, value); + } } #[inline] @@ -433,10 +484,8 @@ pub fn install_options() -> String { pub fn get_socks() -> Vec { #[cfg(not(any(target_os = "android", target_os = "ios")))] let s = ipc::get_socks(); - #[cfg(target_os = "android")] + #[cfg(any(target_os = "android", target_os = "ios"))] let s = Config::get_socks(); - #[cfg(target_os = "ios")] - let s: Option = None; match s { None => Vec::new(), Some(s) => { @@ -458,20 +507,24 @@ pub fn set_socks(proxy: String, username: String, password: String) { }; #[cfg(not(any(target_os = "android", target_os = "ios")))] ipc::set_socks(socks).ok(); - #[cfg(target_os = "android")] + #[cfg(any(target_os = "android", target_os = "ios"))] { + let _nat = crate::CheckTestNatType::new(); if socks.proxy.is_empty() { Config::set_socks(None); } else { Config::set_socks(Some(socks)); } - crate::common::test_nat_type(); - crate::RendezvousMediator::restart(); log::info!("socks updated"); } + #[cfg(target_os = "android")] + { + crate::RendezvousMediator::restart(); + } } #[inline] +#[cfg(feature = "flutter")] pub fn get_proxy_status() -> bool { #[cfg(not(any(target_os = "android", target_os = "ios")))] return ipc::get_proxy_status(); @@ -556,19 +609,57 @@ pub fn update_temporary_password() { } #[inline] -pub fn permanent_password() -> String { +pub fn is_permanent_password_set() -> bool { #[cfg(any(target_os = "android", target_os = "ios"))] - return Config::get_permanent_password(); + return Config::has_permanent_password(); #[cfg(not(any(target_os = "android", target_os = "ios")))] - return ipc::get_permanent_password(); + { + let daemon_is_set = ipc::is_permanent_password_set(); + // `daemon_is_set` is authoritative for the return value. Local storage is only used to + // decide whether we should attempt a sync to clear stale user-side state. + let local_storage_is_empty = if daemon_is_set { + true + } else { + let (storage, _) = Config::get_local_permanent_password_storage_and_salt(); + storage.is_empty() + }; + if daemon_is_set || !local_storage_is_empty { + allow_err!(ipc::sync_permanent_password_storage_from_daemon()); + } + daemon_is_set + } } #[inline] -pub fn set_permanent_password(password: String) { +pub fn is_local_permanent_password_set() -> bool { #[cfg(any(target_os = "android", target_os = "ios"))] - Config::set_permanent_password(&password); + return Config::has_local_permanent_password(); #[cfg(not(any(target_os = "android", target_os = "ios")))] - allow_err!(ipc::set_permanent_password(password)); + { + allow_err!(ipc::sync_permanent_password_storage_from_daemon()); + Config::has_local_permanent_password() + } +} + +pub fn set_permanent_password_with_result(password: String) -> bool { + if config::Config::is_disable_change_permanent_password() { + return false; + } + #[cfg(any(target_os = "android", target_os = "ios"))] + { + config::Config::set_permanent_password(&password); + return true; + } + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + match crate::ipc::set_permanent_password_with_ack(password) { + Ok(ok) => ok, + Err(err) => { + log::warn!("Failed to set permanent password via IPC: {err}"); + false + } + } + } } #[inline] @@ -793,6 +884,7 @@ pub fn get_async_http_status(url: String) -> Option { } #[inline] +#[cfg(not(feature = "flutter"))] pub fn post_request(url: String, body: String, header: String) { *ASYNC_JOB_STATUS.lock().unwrap() = " ".to_owned(); std::thread::spawn(move || { @@ -840,7 +932,7 @@ pub fn video_save_directory(root: bool) -> String { { let drive = std::env::var("SystemDrive").unwrap_or("C:".to_owned()); let dir = - std::path::PathBuf::from(format!("{drive}\\ProgramData\\RustDesk\\recording",)); + std::path::PathBuf::from(format!("{drive}\\ProgramData\\{appname}\\recording",)); return dir.to_string_lossy().to_string(); } } @@ -855,7 +947,7 @@ pub fn video_save_directory(root: bool) -> String { #[cfg(any(target_os = "android", target_os = "ios"))] if let Ok(home) = config::APP_HOME_DIR.read() { let mut path = home.to_owned(); - path.push_str("/RustDesk/ScreenRecord"); + path.push_str(format!("/{appname}/ScreenRecord").as_str()); let dir = try_create(&std::path::Path::new(&path)); if !dir.is_empty() { return dir; @@ -1127,16 +1219,10 @@ async fn check_connect_status_(reconnect: bool, rx: mpsc::UnboundedReceiver { *OPTIONS.lock().unwrap() = v; *OPTION_SYNCED.lock().unwrap() = true; - - #[cfg(any( - target_os = "windows", - all( - any(target_os="linux", target_os = "macos"), - feature = "unix-file-copy-paste" - ) - ))] - { - let b = OPTIONS.lock().unwrap().get(OPTION_ENABLE_FILE_TRANSFER).map(|x| x.to_string()).unwrap_or_default(); - if b != enable_file_transfer { - clipboard::ContextSend::enable(option2bool(OPTION_ENABLE_FILE_TRANSFER, &b)); - enable_file_transfer = b; - } - } } Ok(Some(ipc::Data::Config((name, Some(value))))) => { if name == "id" { @@ -1187,8 +1258,9 @@ async fn check_connect_status_(reconnect: bool, rx: mpsc::UnboundedReceiver { - VIDEO_CONN_COUNT.store(n, Ordering::Relaxed); + video_conn_count = n; } Ok(Some(ipc::Data::OnlineStatus(Some((mut x, _c))))) => { if x > 0 { @@ -1206,8 +1278,23 @@ async fn check_connect_status_(reconnect: bool, rx: mpsc::UnboundedReceiver { + *IS_REMOTE_MODIFY_ENABLED_BY_CONTROL_PERMISSIONS.lock().unwrap() = v; + } + #[cfg(target_os = "windows")] + Ok(Some(ipc::Data::FileTransferEnabledState(v))) => { + if let Some(enabled) = v { + let mut lock = IS_FILE_TRANSFER_ENABLED.lock().unwrap(); + if *lock != v { + clipboard::ContextSend::enable(enabled); + *lock = v; + } + } + } _ => {} } } @@ -1219,7 +1306,11 @@ async fn check_connect_status_(reconnect: bool, rx: mpsc::UnboundedReceiver String { pub async fn change_id_shared_(id: String, old_id: String) -> &'static str { if !hbb_common::is_valid_custom_id(&id) { + log::debug!( + "debugging invalid id: \"{id}\", len: {}, base64: \"{}\"", + id.len(), + crate::encode64(&id) + ); + let bom = id.trim_start_matches('\u{FEFF}'); + log::debug!("bom: {}", hbb_common::is_valid_custom_id(&bom)); return INVALID_FORMAT; } @@ -1503,3 +1603,9 @@ pub fn clear_trusted_devices() { pub fn max_encrypt_len() -> usize { hbb_common::config::ENCRYPT_MAX_LEN } + +pub fn is_remote_modify_enabled_by_control_permissions() -> Option { + *IS_REMOTE_MODIFY_ENABLED_BY_CONTROL_PERMISSIONS + .lock() + .unwrap() +} diff --git a/src/ui_session_interface.rs b/src/ui_session_interface.rs index 321707d3f..e6c8ac6a2 100644 --- a/src/ui_session_interface.rs +++ b/src/ui_session_interface.rs @@ -1,21 +1,15 @@ use crate::{ common::{get_supported_keyboard_modes, is_keyboard_mode_supported}, - input::{MOUSE_BUTTON_LEFT, MOUSE_TYPE_DOWN, MOUSE_TYPE_UP, MOUSE_TYPE_WHEEL}, + input::{ + MOUSE_BUTTON_LEFT, MOUSE_BUTTON_RIGHT, MOUSE_TYPE_DOWN, MOUSE_TYPE_MASK, + MOUSE_TYPE_TRACKPAD, MOUSE_TYPE_UP, MOUSE_TYPE_WHEEL, + }, ui_interface::use_texture_render, }; use async_trait::async_trait; use bytes::Bytes; -use rdev::{Event, EventType::*, KeyCode}; -use std::{ - collections::HashMap, - ffi::c_void, - ops::{Deref, DerefMut}, - str::FromStr, - sync::{Arc, Mutex, RwLock}, - time::SystemTime, -}; -use uuid::Uuid; - +#[cfg(all(target_os = "windows", not(feature = "flutter")))] +use hbb_common::config::keys; #[cfg(not(feature = "flutter"))] use hbb_common::fs; use hbb_common::{ @@ -29,14 +23,28 @@ use hbb_common::{ sync::mpsc, time::{Duration as TokioDuration, Instant}, }, - Stream, + whoami, Stream, }; +use rdev::{Event, EventType::*, KeyCode}; +#[cfg(all(feature = "vram", feature = "flutter"))] +use std::ffi::c_void; +use std::{ + collections::HashMap, + ops::{Deref, DerefMut}, + str::FromStr, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, Mutex, RwLock, + }, + time::SystemTime, +}; +use uuid::Uuid; use crate::client::io_loop::Remote; use crate::client::{ check_if_retry, handle_hash, handle_login_error, handle_login_from_ui, handle_test_delay, - input_os_password, send_mouse, send_pointer_device_event, start_video_audio_threads, - FileManager, Key, LoginConfigHandler, QualityStatus, KEY_MAP, + input_os_password, send_mouse, send_pointer_device_event, FileManager, Key, LoginConfigHandler, + QualityStatus, KEY_MAP, }; #[cfg(not(any(target_os = "android", target_os = "ios")))] use crate::common::GrabState; @@ -58,6 +66,12 @@ pub struct Session { pub server_clipboard_enabled: Arc>, pub last_change_display: Arc>, pub connection_round_state: Arc>, + pub printer_names: Arc>>, + // Indicate whether the session is reconnected. + // Used to auto start file transfer after reconnection. + pub reconnect_count: Arc, + pub last_audit_note: Arc>, + pub audit_guid: Arc>, } #[derive(Clone)] @@ -162,6 +176,13 @@ impl SessionPermissionConfig { && *self.server_keyboard_enabled.read().unwrap() && !self.lc.read().unwrap().disable_clipboard.v } + + #[cfg(feature = "unix-file-copy-paste")] + pub fn is_file_clipboard_required(&self) -> bool { + *self.server_keyboard_enabled.read().unwrap() + && *self.server_file_transfer_enabled.read().unwrap() + && self.lc.read().unwrap().enable_file_copy_paste.v + } } impl Session { @@ -183,6 +204,22 @@ impl Session { .eq(&ConnType::FILE_TRANSFER) } + pub fn is_default(&self) -> bool { + self.lc + .read() + .unwrap() + .conn_type + .eq(&ConnType::DEFAULT_CONN) + } + + pub fn is_view_camera(&self) -> bool { + self.lc.read().unwrap().conn_type.eq(&ConnType::VIEW_CAMERA) + } + + pub fn is_terminal(&self) -> bool { + self.lc.read().unwrap().conn_type.eq(&ConnType::TERMINAL) + } + pub fn is_port_forward(&self) -> bool { let conn_type = self.lc.read().unwrap().conn_type; conn_type == ConnType::PORT_FORWARD || conn_type == ConnType::RDP @@ -206,6 +243,10 @@ impl Session { self.lc.read().unwrap().scroll_style.clone() } + pub fn get_edge_scroll_edge_thickness(&self) -> i32 { + self.lc.read().unwrap().edge_scroll_edge_thickness + } + pub fn get_image_quality(&self) -> String { self.lc.read().unwrap().image_quality.clone() } @@ -218,6 +259,10 @@ impl Session { self.lc.read().unwrap().version.clone() } + pub fn get_trackpad_speed(&self) -> i32 { + self.lc.read().unwrap().trackpad_speed + } + pub fn fallback_keyboard_mode(&self) -> String { let peer_version = self.get_peer_version(); let platform = self.peer_platform(); @@ -314,6 +359,13 @@ impl Session { self.lc.write().unwrap().save_scroll_style(value); } + pub fn save_edge_scroll_edge_thickness(&self, value: i32) { + self.lc + .write() + .unwrap() + .save_edge_scroll_edge_thickness(value); + } + pub fn save_flutter_option(&self, k: String, v: String) { self.lc.write().unwrap().save_ui_flutter(k, v); } @@ -324,8 +376,8 @@ impl Session { pub fn toggle_option(&self, name: String) { let msg = self.lc.write().unwrap().toggle_option(name.clone()); - #[cfg(not(feature = "flutter"))] - if name == hbb_common::config::keys::OPTION_ENABLE_FILE_COPY_PASTE { + #[cfg(all(target_os = "windows", not(feature = "flutter")))] + if name == keys::OPTION_ENABLE_FILE_COPY_PASTE { self.send(Data::ToggleClipboardFile); } if let Some(msg) = msg { @@ -354,13 +406,20 @@ impl Session { self.lc.read().unwrap().is_privacy_mode_supported() } - #[cfg(not(any(target_os = "android", target_os = "ios")))] + #[cfg(not(target_os = "ios"))] pub fn is_text_clipboard_required(&self) -> bool { *self.server_clipboard_enabled.read().unwrap() && *self.server_keyboard_enabled.read().unwrap() && !self.lc.read().unwrap().disable_clipboard.v } + #[cfg(any(target_os = "windows", feature = "unix-file-copy-paste"))] + pub fn is_file_clipboard_required(&self) -> bool { + *self.server_keyboard_enabled.read().unwrap() + && *self.server_file_transfer_enabled.read().unwrap() + && self.lc.read().unwrap().enable_file_copy_paste.v + } + #[cfg(feature = "flutter")] pub fn refresh_video(&self, display: i32) { if crate::common::is_support_multi_ui_session_num(self.lc.read().unwrap().version) { @@ -390,16 +449,19 @@ impl Session { } pub fn record_screen(&self, start: bool) { - let mut misc = Misc::new(); - misc.set_client_record_status(start); - let mut msg = Message::new(); - msg.set_misc(misc); - self.send(Data::Message(msg)); self.send(Data::RecordScreen(start)); } + pub fn is_screenshot_supported(&self) -> bool { + crate::common::is_support_screenshot_num(self.lc.read().unwrap().version) + } + + pub fn take_screenshot(&self, display: i32, sid: String) { + self.send(Data::TakeScreenshot((display, sid))); + } + pub fn is_recording(&self) -> bool { - self.lc.read().unwrap().record + self.lc.read().unwrap().record_state } pub fn save_custom_image_quality(&self, custom_image_quality: i32) { @@ -426,6 +488,10 @@ impl Session { } } + pub fn save_trackpad_speed(&self, trackpad_speed: i32) { + self.lc.write().unwrap().save_trackpad_speed(trackpad_speed); + } + pub fn set_custom_fps(&self, custom_fps: i32) { let msg = self.lc.write().unwrap().set_custom_fps(custom_fps, true); self.send(Data::Message(msg)); @@ -475,14 +541,14 @@ impl Session { (vp8, av1, h264, h265) } - pub fn change_prefer_codec(&self) { + pub fn update_supported_decodings(&self) { let msg = self.lc.write().unwrap().update_supported_decodings(); self.send(Data::Message(msg)); } pub fn use_texture_render_changed(&self) { self.send(Data::ResetDecoder(None)); - self.change_prefer_codec(); + self.update_supported_decodings(); self.send(Data::Message(LoginConfigHandler::refresh())); } @@ -518,6 +584,7 @@ impl Session { let url = self.get_audit_server("conn".to_string()); let id = self.get_id(); let session_id = self.lc.read().unwrap().session_id; + *self.last_audit_note.lock().unwrap() = note.clone(); std::thread::spawn(move || { send_note(url, id, session_id, note); }); @@ -526,10 +593,7 @@ impl Session { #[cfg(not(feature = "flutter"))] #[cfg(not(any(target_os = "android", target_os = "ios")))] pub fn is_xfce(&self) -> bool { - #[cfg(not(any(target_os = "ios")))] - return crate::platform::is_xfce(); - #[cfg(any(target_os = "ios"))] - false + crate::platform::is_xfce() } pub fn remove_port_forward(&self, port: i32) { @@ -719,6 +783,56 @@ impl Session { self.send(Data::Message(msg_out)); } + // Terminal methods + pub fn open_terminal(&self, terminal_id: i32, rows: u32, cols: u32) { + let mut action = TerminalAction::new(); + action.set_open(OpenTerminal { + terminal_id, + rows, + cols, + ..Default::default() + }); + let mut msg_out = Message::new(); + msg_out.set_terminal_action(action); + self.send(Data::Message(msg_out)); + } + + pub fn send_terminal_input(&self, terminal_id: i32, data: String) { + let mut action = TerminalAction::new(); + action.set_data(TerminalData { + terminal_id, + data: bytes::Bytes::from(data.into_bytes()), + ..Default::default() + }); + let mut msg_out = Message::new(); + msg_out.set_terminal_action(action); + self.send(Data::Message(msg_out)); + } + + pub fn resize_terminal(&self, terminal_id: i32, rows: u32, cols: u32) { + let mut action = TerminalAction::new(); + action.set_resize(ResizeTerminal { + terminal_id, + rows, + cols, + ..Default::default() + }); + let mut msg_out = Message::new(); + msg_out.set_terminal_action(action); + self.send(Data::Message(msg_out)); + } + + pub fn close_terminal(&self, terminal_id: i32) { + let mut action = TerminalAction::new(); + action.set_close(CloseTerminal { + terminal_id, + ..Default::default() + }); + let mut msg_out = Message::new(); + msg_out.set_terminal_action(action); + self.send(Data::Message(msg_out)); + } + pub fn capture_displays(&self, add: Vec, sub: Vec, set: Vec) { let mut misc = Misc::new(); misc.set_capture_displays(CaptureDisplays { @@ -756,12 +870,14 @@ impl Session { #[cfg(not(any(target_os = "android", target_os = "ios")))] pub fn enter(&self, keyboard_mode: String) { - keyboard::client::change_grab_status(GrabState::Run, &keyboard_mode); + let session_id = self.lc.read().unwrap().session_id as u128; + keyboard::client::change_grab_status(GrabState::Run, &keyboard_mode, session_id); } #[cfg(not(any(target_os = "android", target_os = "ios")))] pub fn leave(&self, keyboard_mode: String) { - keyboard::client::change_grab_status(GrabState::Wait, &keyboard_mode); + let session_id = self.lc.read().unwrap().session_id as u128; + keyboard::client::change_grab_status(GrabState::Wait, &keyboard_mode, session_id); } // flutter only TODO new input @@ -1111,7 +1227,9 @@ impl Session { } } - let (x, y) = if mask == MOUSE_TYPE_WHEEL || mask == MOUSE_TYPE_TRACKPAD { + // Compute event type once using MOUSE_TYPE_MASK for reuse + let event_type = mask & MOUSE_TYPE_MASK; + let (x, y) = if event_type == MOUSE_TYPE_WHEEL || event_type == MOUSE_TYPE_TRACKPAD { self.get_scroll_xy((x, y)) } else { (x, y) @@ -1120,8 +1238,6 @@ impl Session { // #[cfg(not(any(target_os = "android", target_os = "ios")))] let (alt, ctrl, shift, command) = keyboard::client::get_modifiers_state(alt, ctrl, shift, command); - - use crate::input::*; let is_left = (mask & (MOUSE_BUTTON_LEFT << 3)) > 0; let is_right = (mask & (MOUSE_BUTTON_RIGHT << 3)) > 0; if is_left ^ is_right { @@ -1141,9 +1257,8 @@ impl Session { // to-do: how about ctrl + left from win to macos if cfg!(target_os = "macos") { let buttons = mask >> 3; - let evt_type = mask & 0x7; if buttons == MOUSE_BUTTON_LEFT - && evt_type == MOUSE_TYPE_DOWN + && event_type == MOUSE_TYPE_DOWN && ctrl && self.peer_platform() != "Mac OS" { @@ -1176,11 +1291,13 @@ impl Session { drop(connection_round_state_lock); let cloned = self.clone(); + // override only if true if true == force_relay { self.lc.write().unwrap().force_relay = true; } self.lc.write().unwrap().peer_info = None; + self.reconnect_count.fetch_add(1, Ordering::SeqCst); let mut lock = self.thread.lock().unwrap(); // No need to join the previous thread, because it will exit automatically. // And the previous thread will not change important states. @@ -1281,6 +1398,24 @@ impl Session { self.send(Data::Close); } + fn try_auto_start_job_str(is_reconnected: bool, job_str: &str) -> Option { + if is_reconnected { + let job_str = job_str.trim(); + if let Some(stripped) = job_str.strip_suffix('}') { + format!(r#"{},"auto_start": true}}"#, stripped).into() + } else { + // unreachable in normal cases + log::warn!( + "The last character is not '}}': {}, auto start is ignored on flutter", + job_str + ); + Some(job_str.to_owned()) + } + } else { + None + } + } + pub fn load_last_jobs(&self) { self.clear_all_jobs(); let pc = self.load_config(); @@ -1288,18 +1423,32 @@ impl Session { // no last jobs return; } + let reconnect_count_thr = if cfg!(feature = "flutter") { 0 } else { 1 }; + let is_reconnected = self.reconnect_count.load(Ordering::SeqCst) > reconnect_count_thr; // TODO: can add a confirm dialog let mut cnt = 1; for job_str in pc.transfer.read_jobs.iter() { if !job_str.is_empty() { - self.load_last_job(cnt, job_str); + self.load_last_job( + cnt, + Self::try_auto_start_job_str(is_reconnected, job_str) + .as_deref() + .unwrap_or(job_str), + is_reconnected, + ); cnt += 1; log::info!("restore read_job: {:?}", job_str); } } for job_str in pc.transfer.write_jobs.iter() { if !job_str.is_empty() { - self.load_last_job(cnt, job_str); + self.load_last_job( + cnt, + Self::try_auto_start_job_str(is_reconnected, job_str) + .as_deref() + .unwrap_or(job_str), + is_reconnected, + ); cnt += 1; log::info!("restore write_job: {:?}", job_str); } @@ -1315,10 +1464,11 @@ impl Session { self.send(Data::ElevateWithLogon(username, password)); } - #[cfg(any(target_os = "ios"))] + #[cfg(any(target_os = "android", target_os = "ios", not(feature = "flutter")))] pub fn switch_sides(&self) {} - #[cfg(not(any(target_os = "ios")))] + #[cfg(feature = "flutter")] + #[cfg(not(any(target_os = "android", target_os = "ios")))] #[tokio::main(flavor = "current_thread")] pub async fn switch_sides(&self) { match crate::ipc::connect(1000, "").await { @@ -1397,9 +1547,10 @@ impl Session { #[inline] fn try_change_init_resolution(&self, display: i32) { - if let Some((w, h)) = self.lc.read().unwrap().get_custom_resolution(display) { - self.change_resolution(display, w, h); - } + let Some((w, h)) = self.lc.read().unwrap().get_custom_resolution(display) else { + return; + }; + self.change_resolution(display, w, h); } fn do_change_resolution(&self, display: i32, width: i32, height: i32) { @@ -1460,7 +1611,7 @@ impl Session { self.read_remote_dir(remote_dir, show_hidden); } } - } else { + } else if !self.is_terminal() { self.msgbox( "success", "Successful", @@ -1494,13 +1645,27 @@ impl Session { pub fn get_conn_token(&self) -> Option { self.lc.read().unwrap().get_conn_token() } + + pub fn printer_response(&self, id: i32, path: String, printer_name: String) { + self.printer_names.write().unwrap().insert(id, printer_name); + let to = std::env::temp_dir().join(format!("rustdesk_printer_{id}")); + self.send(Data::SendFiles(( + id, + hbb_common::fs::JobType::Printer, + path, + to.to_string_lossy().to_string(), + 0, + false, + true, + ))); + } } pub trait InvokeUiSession: Send + Sync + Clone + 'static + Sized + Default { fn set_cursor_data(&self, cd: CursorData); fn set_cursor_id(&self, id: String); fn set_cursor_position(&self, cp: CursorPosition); - fn set_display(&self, x: i32, y: i32, w: i32, h: i32, cursor_embedded: bool); + fn set_display(&self, x: i32, y: i32, w: i32, h: i32, cursor_embedded: bool, scale: f64); fn switch_display(&self, display: &SwitchDisplay); fn set_peer_info(&self, peer_info: &PeerInfo); // flutter fn set_displays(&self, displays: &Vec); @@ -1510,14 +1675,14 @@ pub trait InvokeUiSession: Send + Sync + Clone + 'static + Sized + Default { fn set_permission(&self, name: &str, value: bool); fn close_success(&self); fn update_quality_status(&self, qs: QualityStatus); - fn set_connection_type(&self, is_secured: bool, direct: bool); + fn set_connection_type(&self, is_secured: bool, direct: bool, stream_type: &str); fn set_fingerprint(&self, fingerprint: String); fn job_error(&self, id: i32, err: String, file_num: i32); fn job_done(&self, id: i32, file_num: i32); fn clear_all_jobs(&self); fn new_message(&self, msg: String); fn update_transfer_list(&self); - fn load_last_job(&self, cnt: i32, job_json: &str); + fn load_last_job(&self, cnt: i32, job_json: &str, auto_start: bool); fn update_folder_files( &self, id: i32, @@ -1558,6 +1723,10 @@ pub trait InvokeUiSession: Send + Sync + Clone + 'static + Sized + Default { #[cfg(feature = "flutter")] fn is_multi_ui_session(&self) -> bool; fn update_record_status(&self, start: bool); + fn update_empty_dirs(&self, _res: ReadEmptyDirsResponse) {} + fn printer_request(&self, id: i32, path: String); + fn handle_screenshot_resp(&self, sid: String, msg: String); + fn handle_terminal_response(&self, response: TerminalResponse); } impl Deref for Session { @@ -1618,11 +1787,16 @@ impl Interface for Session { self.on_error("No active console user logged on, please connect and logon first."); return; } - } else if !self.is_port_forward() { + } else if !self.is_port_forward() && !self.is_terminal() { if pi.displays.is_empty() { self.lc.write().unwrap().handle_peer_info(&pi); self.update_privacy_mode(); - self.msgbox("error", "Remote Error", "No Displays", ""); + let msg = if self.is_view_camera() { + "No cameras" + } else { + "No displays" + }; + self.msgbox("error", "Error", msg, ""); return; } self.try_change_init_resolution(pi.current_display); @@ -1637,15 +1811,19 @@ impl Interface for Session { current.width, current.height, current.cursor_embedded, + current.scale, ); } self.update_privacy_mode(); + // Clear audit_guid when connection is established successfully + *self.audit_guid.lock().unwrap() = String::new(); + *self.last_audit_note.lock().unwrap() = String::new(); // Save recent peers, then push event to flutter. So flutter can refresh peer page. self.lc.write().unwrap().handle_peer_info(&pi); self.set_peer_info(&pi); if self.is_file_transfer() { self.close_success(); - } else if !self.is_port_forward() { + } else if !self.is_port_forward() && !self.is_terminal() { self.msgbox( "success", "Successful", @@ -1746,18 +1924,6 @@ impl Session { #[tokio::main(flavor = "current_thread")] pub async fn io_loop(handler: Session, round: u32) { - // It is ok to call this function multiple times. - #[cfg(any( - target_os = "windows", - all( - any(target_os = "linux", target_os = "macos"), - feature = "unix-file-copy-paste" - ) - ))] - if !handler.is_file_transfer() && !handler.is_port_forward() { - clipboard::ContextSend::enable(true); - } - #[cfg(any(target_os = "android", target_os = "ios"))] let (sender, receiver) = mpsc::unbounded_channel::(); #[cfg(not(any(target_os = "android", target_os = "ios")))] @@ -1851,42 +2017,9 @@ pub async fn io_loop(handler: Session, round: u32) { } return; } - let frame_count_map: Arc>> = Default::default(); - let frame_count_map_cl = frame_count_map.clone(); - let ui_handler = handler.ui_handler.clone(); - let (video_sender, audio_sender, video_queue_map, decode_fps, chroma) = - start_video_audio_threads( - handler.clone(), - move |display: usize, - data: &mut scrap::ImageRgb, - _texture: *mut c_void, - pixelbuffer: bool| { - let mut write_lock = frame_count_map_cl.write().unwrap(); - let count = write_lock.get(&display).unwrap_or(&0) + 1; - write_lock.insert(display, count); - drop(write_lock); - if pixelbuffer { - ui_handler.on_rgba(display, data); - } else { - #[cfg(all(feature = "vram", feature = "flutter"))] - ui_handler.on_texture(display, _texture); - } - }, - ); - - let mut remote = Remote::new( - handler, - video_queue_map, - video_sender, - audio_sender, - receiver, - sender, - frame_count_map, - decode_fps, - chroma, - ); + let mut remote = Remote::new(handler, receiver, sender); remote.io_loop(&key, &token, round).await; - remote.sync_jobs_status_to_local().await; + let _ = remote.sync_jobs_status_to_local().await; } #[cfg(not(any(target_os = "android", target_os = "ios")))] diff --git a/src/updater.rs b/src/updater.rs new file mode 100644 index 000000000..357f111a7 --- /dev/null +++ b/src/updater.rs @@ -0,0 +1,290 @@ +use crate::{common::do_check_software_update, hbbs_http::create_http_client_with_url}; +use hbb_common::{bail, config, log, ResultType}; +use std::{ + io::Write, + path::PathBuf, + sync::{ + atomic::{AtomicUsize, Ordering}, + mpsc::{channel, Receiver, Sender}, + Mutex, + }, + time::{Duration, Instant}, +}; + +enum UpdateMsg { + CheckUpdate, + Exit, +} + +lazy_static::lazy_static! { + static ref TX_MSG : Mutex> = Mutex::new(start_auto_update_check()); +} + +static CONTROLLING_SESSION_COUNT: AtomicUsize = AtomicUsize::new(0); + +const DUR_ONE_DAY: Duration = Duration::from_secs(60 * 60 * 24); + +pub fn update_controlling_session_count(count: usize) { + CONTROLLING_SESSION_COUNT.store(count, Ordering::SeqCst); +} + +#[allow(dead_code)] +pub fn start_auto_update() { + let _sender = TX_MSG.lock().unwrap(); +} + +#[allow(dead_code)] +pub fn manually_check_update() -> ResultType<()> { + let sender = TX_MSG.lock().unwrap(); + sender.send(UpdateMsg::CheckUpdate)?; + Ok(()) +} + +#[allow(dead_code)] +pub fn stop_auto_update() { + let sender = TX_MSG.lock().unwrap(); + sender.send(UpdateMsg::Exit).unwrap_or_default(); +} + +#[inline] +fn has_no_active_conns() -> bool { + let conns = crate::Connection::alive_conns(); + conns.is_empty() && has_no_controlling_conns() +} + +#[cfg(any(not(target_os = "windows"), feature = "flutter"))] +fn has_no_controlling_conns() -> bool { + CONTROLLING_SESSION_COUNT.load(Ordering::SeqCst) == 0 +} + +#[cfg(not(any(not(target_os = "windows"), feature = "flutter")))] +fn has_no_controlling_conns() -> bool { + let app_exe = format!("{}.exe", crate::get_app_name().to_lowercase()); + for arg in [ + "--connect", + "--play", + "--file-transfer", + "--view-camera", + "--port-forward", + "--rdp", + ] { + if !crate::platform::get_pids_of_process_with_first_arg(&app_exe, arg).is_empty() { + return false; + } + } + true +} + +fn start_auto_update_check() -> Sender { + let (tx, rx) = channel(); + std::thread::spawn(move || start_auto_update_check_(rx)); + return tx; +} + +fn start_auto_update_check_(rx_msg: Receiver) { + std::thread::sleep(Duration::from_secs(30)); + if let Err(e) = check_update(false) { + log::error!("Error checking for updates: {}", e); + } + + const MIN_INTERVAL: Duration = Duration::from_secs(60 * 10); + const RETRY_INTERVAL: Duration = Duration::from_secs(60 * 30); + let mut last_check_time = Instant::now(); + let mut check_interval = DUR_ONE_DAY; + loop { + let recv_res = rx_msg.recv_timeout(check_interval); + match &recv_res { + Ok(UpdateMsg::CheckUpdate) | Err(_) => { + if last_check_time.elapsed() < MIN_INTERVAL { + // log::debug!("Update check skipped due to minimum interval."); + continue; + } + // Don't check update if there are alive connections. + if !has_no_active_conns() { + check_interval = RETRY_INTERVAL; + continue; + } + if let Err(e) = check_update(matches!(recv_res, Ok(UpdateMsg::CheckUpdate))) { + log::error!("Error checking for updates: {}", e); + check_interval = RETRY_INTERVAL; + } else { + last_check_time = Instant::now(); + check_interval = DUR_ONE_DAY; + } + } + Ok(UpdateMsg::Exit) => break, + } + } +} + +fn check_update(manually: bool) -> ResultType<()> { + #[cfg(target_os = "windows")] + let update_msi = crate::platform::is_msi_installed()? && !crate::is_custom_client(); + if !(manually || config::Config::get_bool_option(config::keys::OPTION_ALLOW_AUTO_UPDATE)) { + return Ok(()); + } + if do_check_software_update().is_err() { + // ignore + return Ok(()); + } + + let update_url = crate::common::SOFTWARE_UPDATE_URL.lock().unwrap().clone(); + if update_url.is_empty() { + log::debug!("No update available."); + } else { + let download_url = update_url.replace("tag", "download"); + let version = download_url.split('/').last().unwrap_or_default(); + #[cfg(target_os = "windows")] + let download_url = if cfg!(feature = "flutter") { + format!( + "{}/rustdesk-{}-x86_64.{}", + download_url, + version, + if update_msi { "msi" } else { "exe" } + ) + } else { + format!("{}/rustdesk-{}-x86-sciter.exe", download_url, version) + }; + log::debug!("New version available: {}", &version); + let client = create_http_client_with_url(&download_url); + let Some(file_path) = get_download_file_from_url(&download_url) else { + bail!("Failed to get the file path from the URL: {}", download_url); + }; + let mut is_file_exists = false; + if file_path.exists() { + // Check if the file size is the same as the server file size + // If the file size is the same, we don't need to download it again. + let file_size = std::fs::metadata(&file_path)?.len(); + let response = client.head(&download_url).send()?; + if !response.status().is_success() { + bail!("Failed to get the file size: {}", response.status()); + } + let total_size = response + .headers() + .get(reqwest::header::CONTENT_LENGTH) + .and_then(|ct_len| ct_len.to_str().ok()) + .and_then(|ct_len| ct_len.parse::().ok()); + let Some(total_size) = total_size else { + bail!("Failed to get content length"); + }; + if file_size == total_size { + is_file_exists = true; + } else { + std::fs::remove_file(&file_path)?; + } + } + if !is_file_exists { + let response = client.get(&download_url).send()?; + if !response.status().is_success() { + bail!( + "Failed to download the new version file: {}", + response.status() + ); + } + let file_data = response.bytes()?; + let mut file = std::fs::File::create(&file_path)?; + file.write_all(&file_data)?; + } + // We have checked if the `conns` is empty before, but we need to check again. + // No need to care about the downloaded file here, because it's rare case that the `conns` are empty + // before the download, but not empty after the download. + if has_no_active_conns() { + #[cfg(target_os = "windows")] + update_new_version(update_msi, &version, &file_path); + } + } + Ok(()) +} + +#[cfg(target_os = "windows")] +fn update_new_version(update_msi: bool, version: &str, file_path: &PathBuf) { + log::debug!( + "New version is downloaded, update begin, update msi: {update_msi}, version: {version}, file: {:?}", + file_path.to_str() + ); + if let Some(p) = file_path.to_str() { + if let Some(session_id) = crate::platform::get_current_process_session_id() { + if update_msi { + match crate::platform::update_me_msi(p, true) { + Ok(_) => { + log::debug!("New version \"{}\" updated.", version); + } + Err(e) => { + log::error!( + "Failed to install the new msi version \"{}\": {}", + version, + e + ); + std::fs::remove_file(&file_path).ok(); + } + } + } else { + let custom_client_staging_dir = if crate::is_custom_client() { + let custom_client_staging_dir = + crate::platform::get_custom_client_staging_dir(); + if let Err(e) = crate::platform::handle_custom_client_staging_dir_before_update( + &custom_client_staging_dir, + ) { + log::error!( + "Failed to handle custom client staging dir before update: {}", + e + ); + std::fs::remove_file(&file_path).ok(); + return; + } + Some(custom_client_staging_dir) + } else { + // Clean up any residual staging directory from previous custom client + let staging_dir = crate::platform::get_custom_client_staging_dir(); + hbb_common::allow_err!(crate::platform::remove_custom_client_staging_dir( + &staging_dir + )); + None + }; + let update_launched = match crate::platform::launch_privileged_process( + session_id, + &format!("{} --update", p), + ) { + Ok(h) => { + if h.is_null() { + log::error!("Failed to update to the new version: {}", version); + false + } else { + log::debug!("New version \"{}\" is launched.", version); + true + } + } + Err(e) => { + log::error!("Failed to run the new version: {}", e); + false + } + }; + if !update_launched { + if let Some(dir) = custom_client_staging_dir { + hbb_common::allow_err!(crate::platform::remove_custom_client_staging_dir( + &dir + )); + } + std::fs::remove_file(&file_path).ok(); + } + } + } else { + log::error!( + "Failed to get the current process session id, Error {}", + std::io::Error::last_os_error() + ); + std::fs::remove_file(&file_path).ok(); + } + } else { + // unreachable!() + log::error!( + "Failed to convert the file path to string: {}", + file_path.display() + ); + } +} + +pub fn get_download_file_from_url(url: &str) -> Option { + let filename = url.split('/').last()?; + Some(std::env::temp_dir().join(filename)) +} diff --git a/src/virtual_display_manager.rs b/src/virtual_display_manager.rs index 41e5b3fc8..f0645e4bf 100644 --- a/src/virtual_display_manager.rs +++ b/src/virtual_display_manager.rs @@ -446,6 +446,8 @@ pub mod amyuni_idd { if crate::platform::windows::is_x64() { log::info!("Uninstalling driver by deviceinstaller64.exe"); install_if_x86_on_x64(&work_dir, "remove usbmmidd")?; + // Sleep some time to wait for the driver to be uninstalled. + std::thread::sleep(Duration::from_secs(2)); return Ok(()); } } @@ -529,12 +531,25 @@ pub mod amyuni_idd { } #[inline] - fn plug_monitor_(add: bool) -> Result<(), win_device::DeviceError> { + fn plug_monitor_( + add: bool, + wait_timeout: Option, + ) -> Result<(), win_device::DeviceError> { let cmd = if add { 0x10 } else { 0x00 }; let cmd = [cmd, 0x00, 0x00, 0x00]; + let now = Instant::now(); + let c1 = get_monitor_count(); unsafe { win_device::device_io_control(&INTERFACE_GUID, PLUG_MONITOR_IO_CONTROL_CDOE, &cmd, 0)?; } + if let Some(wait_timeout) = wait_timeout { + while now.elapsed() < wait_timeout { + if get_monitor_count() != c1 { + break; + } + std::thread::sleep(Duration::from_millis(30)); + } + } // No need to consider concurrency here. if add { // If the monitor is plugged in, increase the count. @@ -552,12 +567,16 @@ pub mod amyuni_idd { // `std::thread::sleep()` with a timeout is acceptable here. // Because user can wait for a while to plug in a monitor. - fn plug_in_monitor_(add: bool, is_driver_async_installed: bool) -> ResultType<()> { + fn plug_in_monitor_( + add: bool, + is_driver_async_installed: bool, + wait_timeout: Option, + ) -> ResultType<()> { let timeout = Duration::from_secs(3); let now = Instant::now(); let reg_connectivity_old = reg_display_settings::read_reg_connectivity(); loop { - match plug_monitor_(add) { + match plug_monitor_(add, wait_timeout) { Ok(_) => { break; } @@ -622,7 +641,7 @@ pub mod amyuni_idd { bail!("Failed to install driver."); } - plug_in_monitor_(true, is_async) + plug_in_monitor_(true, is_async, Some(Duration::from_millis(3_000))) } pub fn plug_in_monitor() -> ResultType<()> { @@ -636,7 +655,7 @@ pub mod amyuni_idd { bail!("There are already {VIRTUAL_DISPLAY_MAX_COUNT} monitors plugged in."); } - plug_in_monitor_(true, is_async) + plug_in_monitor_(true, is_async, None) } // `index` the display index to plug out. -1 means plug out all. @@ -650,8 +669,8 @@ pub mod amyuni_idd { // we still forcibly plug out all virtual displays. // // 1. RustDesk plug in 2 virtual displays. (RustDesk) - // 2. Other process plug out all virtual displays. (User mannually) - // 3. Other process plug in 1 virtual display. (User mannually) + // 2. Other process plug out all virtual displays. (User manually) + // 3. Other process plug in 1 virtual display. (User manually) // 4. RustDesk plug out all virtual displays in this call. (RustDesk disconnect) // // This is not a normal scenario, RustDesk will plug out virtual display unexpectedly. @@ -700,7 +719,7 @@ pub mod amyuni_idd { } for _i in 0..to_plug_out_count { - let _ = plug_monitor_(false); + let _ = plug_monitor_(false, None); } Ok(()) } diff --git a/src/whiteboard/client.rs b/src/whiteboard/client.rs new file mode 100644 index 000000000..0d816ba27 --- /dev/null +++ b/src/whiteboard/client.rs @@ -0,0 +1,258 @@ +use super::{Cursor, CustomEvent}; +use crate::{ + ipc::{self, Data}, + CHILD_PROCESS, +}; +use hbb_common::{ + allow_err, + anyhow::anyhow, + bail, log, sleep, + tokio::{ + self, + sync::mpsc::{unbounded_channel, UnboundedSender}, + time::interval_at, + }, + ResultType, +}; +use lazy_static::lazy_static; +use std::{collections::HashMap, sync::RwLock, time::Instant}; + +lazy_static! { + static ref TX_WHITEBOARD: RwLock>> = + RwLock::new(None); + static ref CONNS: RwLock> = Default::default(); +} + +struct Conn { + last_cursor_pos: (f32, f32), // For click ripple + last_cursor_evt: LastCursorEvent, +} + +struct LastCursorEvent { + evt: Option, + tm: Instant, + c: usize, +} + +#[inline] +pub fn get_key_cursor(conn_id: i32) -> String { + format!("{}-cursor", conn_id) +} + +pub fn register_whiteboard(k: String) { + std::thread::spawn(|| { + allow_err!(start_whiteboard_()); + }); + let mut conns = CONNS.write().unwrap(); + if !conns.contains_key(&k) { + conns.insert( + k, + Conn { + last_cursor_pos: (0.0, 0.0), + last_cursor_evt: LastCursorEvent { + evt: None, + tm: Instant::now(), + c: 0, + }, + }, + ); + } +} + +pub fn unregister_whiteboard(k: String) { + let mut conns = CONNS.write().unwrap(); + conns.remove(&k); + let is_conns_empty = conns.is_empty(); + drop(conns); + + TX_WHITEBOARD.read().unwrap().as_ref().map(|tx| { + allow_err!(tx.send((k, CustomEvent::Clear))); + }); + if is_conns_empty { + std::thread::spawn(|| { + let mut whiteboard = TX_WHITEBOARD.write().unwrap(); + whiteboard.as_ref().map(|tx| { + allow_err!(tx.send(("".to_string(), CustomEvent::Exit))); + // Simple sleep to wait the whiteboard process exiting. + std::thread::sleep(std::time::Duration::from_millis(3_00)); + }); + whiteboard.take(); + }); + } +} + +pub fn update_whiteboard(k: String, e: CustomEvent) { + let mut conns = CONNS.write().unwrap(); + let Some(conn) = conns.get_mut(&k) else { + return; + }; + match &e { + CustomEvent::Cursor(cursor) => { + conn.last_cursor_evt.c += 1; + conn.last_cursor_evt.tm = Instant::now(); + if cursor.btns == 0 { + // Send one movement event every 4. + if conn.last_cursor_evt.c > 3 { + conn.last_cursor_evt.c = 0; + conn.last_cursor_evt.evt = None; + tx_send_event(conn, k, e); + } else { + conn.last_cursor_evt.evt = Some(e); + } + } else { + if let Some(evt) = conn.last_cursor_evt.evt.take() { + tx_send_event(conn, k.clone(), evt); + conn.last_cursor_evt.c = 0; + } + let click_evt = CustomEvent::Cursor(Cursor { + x: conn.last_cursor_pos.0, + y: conn.last_cursor_pos.1, + argb: cursor.argb, + btns: cursor.btns, + text: cursor.text.clone(), + }); + tx_send_event(conn, k, click_evt); + } + } + _ => { + tx_send_event(conn, k, e); + } + } +} + +#[inline] +fn tx_send_event(conn: &mut Conn, k: String, event: CustomEvent) { + if let CustomEvent::Cursor(cursor) = &event { + if cursor.btns == 0 { + conn.last_cursor_pos = (cursor.x, cursor.y); + } + } + + TX_WHITEBOARD.read().unwrap().as_ref().map(|tx| { + allow_err!(tx.send((k, event))); + }); +} + +#[tokio::main(flavor = "current_thread")] +async fn start_whiteboard_() -> ResultType<()> { + let mut tx_whiteboard = TX_WHITEBOARD.write().unwrap(); + if tx_whiteboard.is_some() { + log::warn!("Whiteboard already started"); + return Ok(()); + } + + loop { + if !crate::platform::is_prelogin() { + break; + } + sleep(1.).await; + } + let mut stream = None; + if let Ok(s) = ipc::connect(1000, "_whiteboard").await { + stream = Some(s); + } else { + #[allow(unused_mut)] + #[allow(unused_assignments)] + let mut args = vec!["--whiteboard"]; + #[allow(unused_mut)] + #[cfg(target_os = "linux")] + let mut user = 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 whiteboard"); + res = crate::platform::run_as_user(args.clone()); + } + #[cfg(target_os = "linux")] + { + log::debug!("Start whiteboard"); + res = crate::platform::run_as_user( + args.clone(), + user.clone(), + None::<(&str, &str)>, + ); + } + if res.is_ok() { + break; + } + log::error!("Failed to run whiteboard: {res:?}"); + sleep(1.).await; + } + if let Some(task) = res? { + CHILD_PROCESS.lock().unwrap().push(task); + } + run_done = true; + } else { + run_done = false; + } + if !run_done { + log::debug!("Start whiteboard"); + CHILD_PROCESS.lock().unwrap().push(crate::run_me(args)?); + } + for _ in 0..20 { + sleep(0.3).await; + if let Ok(s) = ipc::connect(1000, "_whiteboard").await { + stream = Some(s); + break; + } + } + if stream.is_none() { + bail!("Failed to connect to connection manager"); + } + } + + let mut stream = stream.ok_or(anyhow!("none stream"))?; + let (tx, mut rx) = unbounded_channel(); + tx_whiteboard.replace(tx); + drop(tx_whiteboard); + let _call_on_ret = crate::common::SimpleCallOnReturn { + b: true, + f: Box::new(move || { + let _ = TX_WHITEBOARD.write().unwrap().take(); + }), + }; + + let dur = tokio::time::Duration::from_millis(300); + let mut timer = interval_at(tokio::time::Instant::now() + dur, dur); + timer.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + tokio::select! { + res = rx.recv() => { + match res { + Some(data) => { + if matches!(data.1, CustomEvent::Exit) { + break; + } else { + allow_err!(stream.send(&Data::Whiteboard(data)).await); + timer.reset(); + } + } + None => { + bail!("expected"); + } + } + }, + _ = timer.tick() => { + let mut conns = CONNS.write().unwrap(); + for (k, conn) in conns.iter_mut() { + if conn.last_cursor_evt.tm.elapsed().as_millis() > 300 { + if let Some(evt) = conn.last_cursor_evt.evt.take() { + allow_err!(stream.send(&Data::Whiteboard((k.clone(), evt))).await); + conn.last_cursor_evt.c = 0; + } + } + } + } + } + } + allow_err!( + stream + .send(&Data::Whiteboard(("".to_string(), CustomEvent::Exit))) + .await + ); + Ok(()) +} diff --git a/src/whiteboard/linux.rs b/src/whiteboard/linux.rs new file mode 100644 index 000000000..686a0d0b1 --- /dev/null +++ b/src/whiteboard/linux.rs @@ -0,0 +1,463 @@ +use super::{ + server::{Ripple, EVENT_PROXY}, + win_linux::{create_font_face, draw_text}, + Cursor, CustomEvent, +}; +use hbb_common::{bail, log, tokio::sync::mpsc::unbounded_channel, ResultType}; +use softbuffer::{Context, Surface}; +use std::{ + collections::HashMap, + ffi::{c_int, c_short, c_ulong, c_ushort}, + num::NonZeroU32, + sync::Arc, + time::Instant, +}; +use tiny_skia::{Color, FillRule, Paint, PathBuilder, PixmapMut, Stroke, Transform}; +use ttf_parser::Face; +use winit::raw_window_handle::{ + DisplayHandle, HasDisplayHandle, HasWindowHandle, RawDisplayHandle, RawWindowHandle, +}; +use winit::{ + application::ApplicationHandler, + dpi::{PhysicalPosition, PhysicalSize}, + event::WindowEvent, + event_loop::{ActiveEventLoop, EventLoop}, + platform::x11::{WindowAttributesExtX11, WindowType}, + window::{Window, WindowId, WindowLevel}, +}; + +enum _XDisplay {} +type Display = _XDisplay; + +type XID = c_ulong; +type XserverRegion = XID; + +#[derive(Debug, Clone, Copy, PartialEq)] +#[repr(C)] +pub struct XRectangle { + pub x: c_short, + pub y: c_short, + pub width: c_ushort, + pub height: c_ushort, +} + +#[link(name = "Xfixes")] +extern "C" { + fn XFixesCreateRegion( + dpy: *mut Display, + rectangles: *mut XRectangle, + nrectangles: c_int, + ) -> XserverRegion; + fn XFixesDestroyRegion(dpy: *mut Display, region: XserverRegion) -> (); + fn XFixesSetWindowShapeRegion( + dpy: *mut Display, + win: XID, + shape_kind: c_int, + x_off: c_int, + y_off: c_int, + region: XserverRegion, + ) -> (); +} + +const SHAPE_INPUT: std::ffi::c_int = 2; + +fn get_display_from_xwayland() -> Option { + if let Ok(output) = crate::platform::run_cmds("pgrep -a Xwayland") { + // 1410 /usr/bin/Xwayland :1 -auth /run/user/1000/xauth_RoDZey -listenfd 8 -listenfd 9 -displayfd 76 -wm 78 -rootless -enable-ei-portal + if output.contains("Xwayland") { + if let Some(display) = output.split_whitespace().nth(2) { + if display.starts_with(':') { + return Some(display.to_string()); + } + } + } + } + None +} + +fn preset_env() -> bool { + if crate::platform::is_x11() { + return true; + } + if let Some(display) = get_display_from_xwayland() { + // https://github.com/rust-windowing/winit/blob/f6893a4390dfe6118ce4b33458d458fd3efd3025/src/event_loop.rs#L99 + // It is acceptable to modify global environment variables here because this process is an isolated, + // dedicated "whiteboard" process. + std::env::set_var("DISPLAY", display); + std::env::remove_var("WAYLAND_DISPLAY"); + return true; + } + false +} + +pub fn is_supported() -> bool { + crate::platform::is_x11() || get_display_from_xwayland().is_some() +} + +pub fn run() { + if !preset_env() { + return; + } + + let event_loop = match EventLoop::<(String, CustomEvent)>::with_user_event().build() { + Ok(el) => el, + Err(e) => { + log::error!("Failed to create event loop: {}", e); + return; + } + }; + + let event_loop_proxy = event_loop.create_proxy(); + EVENT_PROXY.write().unwrap().replace(event_loop_proxy); + + let (tx_exit, rx_exit) = unbounded_channel(); + std::thread::spawn(move || { + super::server::start_ipc(rx_exit); + }); + + let mut app = match WhiteboardApplication::new(&event_loop) { + Ok(app) => app, + Err(e) => { + log::error!("Failed to create whiteboard application: {}", e); + tx_exit.send(()).ok(); + return; + } + }; + + if let Err(e) = event_loop.run_app(&mut app) { + log::error!("Failed to run app: {}", e); + tx_exit.send(()).ok(); + return; + } +} + +struct WindowState { + window: Arc, + // NOTE: This surface must be dropped before the `Window`. + surface: Surface, Arc>, + ripples: Vec, + last_cursors: HashMap, +} + +struct WhiteboardApplication { + windows: Vec, + // Drawing context. + // + // With OpenGL it could be EGLDisplay. + context: Option>>, + face: Option>, + close_requested: bool, +} + +impl WhiteboardApplication { + fn new(event_loop: &EventLoop) -> ResultType { + // https://github.com/rust-windowing/winit/blob/f6893a4390dfe6118ce4b33458d458fd3efd3025/examples/window.rs#L91 + // SAFETY: we drop the context right before the event loop is stopped, thus making it safe. + let context = match Context::new(unsafe { + std::mem::transmute::, DisplayHandle<'static>>( + event_loop.display_handle()?, + ) + }) { + Ok(ctx) => Some(ctx), + Err(e) => { + bail!("Failed to create context: {}", e); + } + }; + let face = match create_font_face() { + Ok(face) => Some(face), + Err(err) => { + log::error!("Failed to create font face: {}", err); + None + } + }; + Ok(Self { + windows: Vec::new(), + context, + face, + close_requested: false, + }) + } +} + +impl ApplicationHandler<(String, CustomEvent)> for WhiteboardApplication { + fn user_event(&mut self, _event_loop: &ActiveEventLoop, (k, evt): (String, CustomEvent)) { + match evt { + CustomEvent::Cursor(cursor) => { + if let Some(state) = self.windows.first_mut() { + if cursor.btns != 0 { + state.ripples.push(Ripple { + x: cursor.x, + y: cursor.y, + start_time: Instant::now(), + }); + } + state.last_cursors.insert(k, cursor); + state.window.request_redraw(); + } + } + CustomEvent::Exit => { + self.close_requested = true; + } + _ => {} + } + } + + fn resumed(&mut self, event_loop: &ActiveEventLoop) { + let (x, y, w, h) = match super::server::get_displays_rect() { + Ok(r) => r, + Err(err) => { + log::error!("Failed to get displays rect: {}", err); + self.close_requested = true; + return; + } + }; + + let window_attributes = Window::default_attributes() + .with_title("RustDesk whiteboard") + .with_inner_size(PhysicalSize::new(w, h)) + .with_position(PhysicalPosition::new(x, y)) + .with_decorations(false) + .with_transparent(true) + .with_window_level(WindowLevel::AlwaysOnTop) + .with_x11_window_type(vec![WindowType::Dock]) + .with_override_redirect(true); + + let window = match event_loop.create_window(window_attributes) { + Ok(window) => Arc::new(window), + Err(e) => { + log::error!("Failed to create window: {}", e); + self.close_requested = true; + return; + } + }; + + let display = match window.display_handle() { + Ok(d) => d, + Err(e) => { + log::error!("Failed to get display handle: {}", e); + self.close_requested = true; + return; + } + }; + let rwh = match window.window_handle() { + Ok(w) => w, + Err(e) => { + log::error!("Failed to get window handle: {}", e); + self.close_requested = true; + return; + } + }; + + // Both the following block and `window.set_cursor_hittest(false)` in `draw()` are necessary to ensure cursor events are properly passed through the window. + // These issues may be related to winit X11 handling. + // https://github.com/rust-windowing/winit/issues/3509 + // https://github.com/rust-windowing/winit/issues/4120 + // If either block is removed, cursor events may not be passed through as expected. + // If you update winit, please revisit this workaround. + match (rwh.as_raw(), display.as_raw()) { + (RawWindowHandle::Xlib(xlib_window), RawDisplayHandle::Xlib(xlib_display)) => { + unsafe { + let xwindow = xlib_window.window; + if let Some(display_ptr) = xlib_display.display { + let xdisplay = display_ptr.as_ptr() as *mut Display; + // Mouse event passthrough + let empty_region = XFixesCreateRegion(xdisplay, std::ptr::null_mut(), 0); + if empty_region == 0 { + log::error!("XFixesCreateRegion failed: returned null region"); + } else { + XFixesSetWindowShapeRegion( + xdisplay, + xwindow, + SHAPE_INPUT, + 0, + 0, + empty_region, + ); + XFixesDestroyRegion(xdisplay, empty_region); + } + } + } + } + _ => { + log::error!("Unsupported windowing system for shape extension"); + self.close_requested = true; + return; + } + } + + let Some(ctx) = self.context.as_ref() else { + // unreachable + self.close_requested = true; + return; + }; + + let surface = match Surface::new(ctx, window.clone()) { + Ok(s) => s, + Err(e) => { + log::error!("Failed to create surface: {}", e); + self.close_requested = true; + return; + } + }; + + let state = WindowState { + window, + surface, + ripples: Vec::new(), + last_cursors: HashMap::new(), + }; + + self.windows.push(state); + } + + fn window_event( + &mut self, + _event_loop: &ActiveEventLoop, + window_id: WindowId, + event: WindowEvent, + ) { + match event { + WindowEvent::CloseRequested => { + self.close_requested = true; + } + WindowEvent::RedrawRequested => { + let Some(state) = self.windows.iter_mut().find(|w| w.window.id() == window_id) + else { + log::error!("No window found for id: {:?}", window_id); + return; + }; + if let Err(err) = state.draw(&self.face) { + log::error!("Failed to draw window: {}", err); + } + } + _ => (), + } + } + + fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { + if !self.close_requested { + for state in self.windows.iter() { + state.window.request_redraw(); + } + } else { + event_loop.exit(); + } + } + + fn exiting(&mut self, _event_loop: &ActiveEventLoop) { + // We must drop the context here. + self.context = None; + } +} + +impl WindowState { + fn draw(&mut self, face: &Option>) -> ResultType<()> { + let (width, height) = { + let size = self.window.inner_size(); + (size.width, size.height) + }; + + let (Some(width), Some(height)) = (NonZeroU32::new(width), NonZeroU32::new(height)) else { + bail!("Invalid window size, {width}x{height}") + }; + if let Err(e) = self.surface.resize(width, height) { + bail!("Failed to resize surface: {}", e); + } + + let mut buffer = match self.surface.buffer_mut() { + Ok(buf) => buf, + Err(e) => { + bail!("Failed to get buffer: {}", e); + } + }; + + let Some(mut pixmap) = PixmapMut::from_bytes( + bytemuck::cast_slice_mut(&mut buffer), + width.get(), + height.get(), + ) else { + bail!("Failed to create pixmap from buffer"); + }; + pixmap.fill(Color::TRANSPARENT); + + Ripple::retain_active(&mut self.ripples); + for ripple in &self.ripples { + let (radius, alpha) = ripple.get_radius_alpha(); + + let mut ripple_paint = Paint::default(); + // Note: The real color is bgra here. + ripple_paint.set_color_rgba8(64, 64, 255, (alpha * 128.0) as u8); + ripple_paint.anti_alias = true; + + let mut ripple_pb = PathBuilder::new(); + ripple_pb.push_circle(ripple.x, ripple.y, radius); + if let Some(path) = ripple_pb.finish() { + pixmap.fill_path( + &path, + &ripple_paint, + FillRule::Winding, + Transform::identity(), + None, + ); + } + } + + for cursor in self.last_cursors.values() { + let (x, y) = (cursor.x, cursor.y); + let size = 1.5f32; + + let mut pb = PathBuilder::new(); + pb.move_to(x, y); + pb.line_to(x, y + 16.0 * size); + pb.line_to(x + 4.0 * size, y + 13.0 * size); + pb.line_to(x + 7.0 * size, y + 20.0 * size); + pb.line_to(x + 9.0 * size, y + 19.0 * size); + pb.line_to(x + 6.0 * size, y + 12.0 * size); + pb.line_to(x + 11.0 * size, y + 12.0 * size); + pb.close(); + + if let Some(path) = pb.finish() { + let mut arrow_paint = Paint::default(); + let rgba = super::argb_to_rgba(cursor.argb); + arrow_paint.set_color_rgba8(rgba.2, rgba.1, rgba.0, rgba.3); + arrow_paint.anti_alias = true; + pixmap.fill_path( + &path, + &arrow_paint, + FillRule::Winding, + Transform::identity(), + None, + ); + + let mut black_paint = Paint::default(); + black_paint.set_color_rgba8(0, 0, 0, 255); + black_paint.anti_alias = true; + let mut stroke = Stroke::default(); + stroke.width = 1.0f32; + pixmap.stroke_path(&path, &black_paint, &stroke, Transform::identity(), None); + + face.as_ref().map(|face| { + draw_text( + &mut pixmap, + face, + &cursor.text, + x + 24.0 * size, + y + 24.0 * size, + &arrow_paint, + 14.0f32, + ); + }); + } + } + + self.window.pre_present_notify(); + + if let Err(e) = buffer.present() { + log::error!("Failed to present buffer: {}", e); + } + + self.window.set_cursor_hittest(false).ok(); + + Ok(()) + } +} diff --git a/src/whiteboard/macos.rs b/src/whiteboard/macos.rs new file mode 100644 index 000000000..d1c28b57c --- /dev/null +++ b/src/whiteboard/macos.rs @@ -0,0 +1,323 @@ +use super::{server::EVENT_PROXY, Cursor, CustomEvent, Ripple}; +use core_graphics::context::CGContextRef; +use foreign_types::ForeignTypeRef; +use hbb_common::{bail, log, ResultType}; +use objc::{class, msg_send, runtime::Object, sel, sel_impl}; +use piet::{ + kurbo::{BezPath, Point}, + FontFamily, RenderContext, Text, TextLayout, TextLayoutBuilder, +}; +use piet_coregraphics::{CoreGraphicsContext, CoreGraphicsTextLayout}; +use std::{collections::HashMap, sync::Arc, time::Instant}; +use tao::{ + dpi::{LogicalSize, PhysicalPosition}, + event::{Event, StartCause, WindowEvent}, + event_loop::{ControlFlow, EventLoop, EventLoopBuilder}, + platform::macos::MonitorHandleExtMacOS, + rwh_06::{HasWindowHandle, RawWindowHandle}, + window::{Window, WindowBuilder, WindowId}, +}; + +const MAXIMUM_WINDOW_LEVEL: i64 = 2147483647; +const CURSOR_TEXT_FONT_SIZE: f64 = 14.0; +const CURSOR_TEXT_OFFSET: f64 = 20.0; + +struct WindowState { + window: Arc, + logical_size: LogicalSize, + outer_position: PhysicalPosition, + // A simple workaround to the (logical) cursor position. + display_origin: (f64, f64), +} + +struct CursorInfo { + window_id: WindowId, + text_key: (String, u32), + cursor: Cursor, +} + +fn set_window_properties(window: &Arc) -> ResultType<()> { + let handle = window.window_handle()?; + if let RawWindowHandle::AppKit(appkit_handle) = handle.as_raw() { + unsafe { + let ns_view = appkit_handle.ns_view.as_ptr() as *mut Object; + if ns_view.is_null() { + bail!("Ns view of the window handle is null."); + } + let ns_window: *mut Object = msg_send![ns_view, window]; + if ns_window.is_null() { + bail!("Ns window of the ns view is null."); + } + let _: () = msg_send![ns_window, setOpaque: false]; + let _: () = msg_send![ns_window, setLevel: MAXIMUM_WINDOW_LEVEL]; + // NSWindowCollectionBehaviorCanJoinAllSpaces | NSWindowCollectionBehaviorIgnoresCycle + let _: () = msg_send![ns_window, setCollectionBehavior: 5]; + let current_style_mask: u64 = msg_send![ns_window, styleMask]; + // NSWindowStyleMaskNonactivatingPanel + let new_style_mask = current_style_mask | (1 << 7); + let _: () = msg_send![ns_window, setStyleMask: new_style_mask]; + let _: () = msg_send![ns_window, setIgnoresMouseEvents: true]; + } + } + Ok(()) +} + +fn create_windows(event_loop: &EventLoop<(String, CustomEvent)>) -> ResultType> { + let mut windows = Vec::new(); + let map_display_origins: HashMap<_, _> = crate::server::display_service::try_get_displays()? + .into_iter() + .map(|display| (display.name(), display.origin())) + .collect(); + // We can't use `crate::server::display_service::try_get_displays()` here. + // Because the `display` returned by `crate::server::display_service::try_get_displays()`: + // 1. `display.origin()` is the logic position. + // 2. `display.width()` and `display.height()` are the physical size. + for monitor in event_loop.available_monitors() { + let Some(origin) = map_display_origins.get(&monitor.native_id().to_string()) else { + // unreachable! + bail!( + "Failed to find display origin for monitor: {}", + monitor.native_id() + ); + }; + + let window_builder = WindowBuilder::new() + .with_title("RustDesk whiteboard") + .with_transparent(true) + .with_decorations(false) + .with_position(monitor.position()) + .with_inner_size(monitor.size()); + + let window = Arc::new(window_builder.build::<(String, CustomEvent)>(event_loop)?); + set_window_properties(&window)?; + + let mut scale_factor = window.scale_factor(); + if scale_factor == 0.0 { + scale_factor = 1.0; + } + let physical_size = window.inner_size(); + let logical_size = physical_size.to_logical::(scale_factor); + let inner_position = window.inner_position()?; + let outer_position = inner_position; + windows.push(WindowState { + window, + logical_size, + outer_position, + display_origin: (origin.0 as f64, origin.1 as f64), + }); + } + Ok(windows) +} + +fn draw_cursors( + windows: &Vec, + window_id: WindowId, + window_ripples: &mut HashMap>, + last_cursors: &HashMap, + map_cursor_text: &mut HashMap<(String, u32), CoreGraphicsTextLayout>, +) { + for window in windows.iter() { + if window.window.id() != window_id { + continue; + } + + if let Ok(handle) = window.window.window_handle() { + if let RawWindowHandle::AppKit(appkit_handle) = handle.as_raw() { + unsafe { + let ns_view = appkit_handle.ns_view.as_ptr() as *mut Object; + let current_context: *mut Object = + msg_send![class!(NSGraphicsContext), currentContext]; + if !current_context.is_null() { + let cg_context_ptr: *mut std::ffi::c_void = + msg_send![current_context, CGContext]; + if !cg_context_ptr.is_null() { + let cg_context_ref = + CGContextRef::from_ptr_mut(cg_context_ptr as *mut _); + let mut context = CoreGraphicsContext::new_y_up( + cg_context_ref, + window.logical_size.height, + None, + ); + context.clear(None, piet::Color::TRANSPARENT); + + if let Some(ripples) = window_ripples.get_mut(&window_id) { + Ripple::retain_active(ripples); + for ripple in ripples.iter() { + let (radius, alpha) = ripple.get_radius_alpha(); + let color = piet::Color::rgba(1.0, 0.25, 0.25, alpha * 0.5); + let circle = + piet::kurbo::Circle::new((ripple.x, ripple.y), radius); + context.stroke(circle, &color, 2.0); + } + } + + for info in last_cursors.values() { + if info.window_id != window.window.id() { + continue; + } + let cursor = &info.cursor; + + let (x, y) = (cursor.x as f64, cursor.y as f64); + let size = 1.0; + + let mut pb = BezPath::new(); + pb.move_to((x, y)); + pb.line_to((x, y + 16.0 * size)); + pb.line_to((x + 4.0 * size, y + 13.0 * size)); + pb.line_to((x + 7.0 * size, y + 20.0 * size)); + pb.line_to((x + 9.0 * size, y + 19.0 * size)); + pb.line_to((x + 6.0 * size, y + 12.0 * size)); + pb.line_to((x + 11.0 * size, y + 12.0 * size)); + + let rgba = super::argb_to_rgba(cursor.argb); + let color = piet::Color::rgba8(rgba.0, rgba.1, rgba.2, rgba.3); + context.fill(pb, &color); + + let pos = + (x + CURSOR_TEXT_OFFSET * size, y + CURSOR_TEXT_OFFSET * size); + let get_rounded_rect = |layout: &CoreGraphicsTextLayout| { + let text_pos = Point::new(pos.0, pos.1); + let padded_bounds = (layout.image_bounds() + + text_pos.to_vec2()) + .inflate(3.0, 3.0); + padded_bounds.to_rounded_rect(5.0) + }; + + if let Some(layout) = map_cursor_text.get(&info.text_key) { + context.fill(get_rounded_rect(layout), &piet::Color::WHITE); + context.draw_text(layout, pos); + } else { + let text = context.text(); + let color = piet::Color::rgba8(0, 0, 0, 255); + if let Ok(layout) = text + .new_text_layout(cursor.text.clone()) + .font(FontFamily::SYSTEM_UI, CURSOR_TEXT_FONT_SIZE) + .text_color(color) + .build() + { + context + .fill(get_rounded_rect(&layout), &piet::Color::WHITE); + context.draw_text(&layout, pos); + map_cursor_text.insert(info.text_key.clone(), layout); + } + } + } + if let Err(e) = context.finish() { + log::error!("Failed to draw cursor: {}", e); + } + } else { + log::warn!("CGContext is null"); + } + } + let _: () = msg_send![ns_view, setNeedsDisplay:true]; + } + } + } + } +} + +pub(super) fn create_event_loop() -> ResultType<()> { + crate::platform::hide_dock(); + let event_loop = EventLoopBuilder::<(String, CustomEvent)>::with_user_event().build(); + + let windows = create_windows(&event_loop)?; + + let proxy = event_loop.create_proxy(); + EVENT_PROXY.write().unwrap().replace(proxy); + let _call_on_ret = crate::common::SimpleCallOnReturn { + b: true, + f: Box::new(move || { + let _ = EVENT_PROXY.write().unwrap().take(); + }), + }; + + let mut window_ripples: HashMap> = HashMap::new(); + let mut last_cursors: HashMap = HashMap::new(); + let mut map_cursor_text: HashMap<(String, u32), CoreGraphicsTextLayout> = HashMap::new(); + + event_loop.run(move |event, _, control_flow| { + *control_flow = ControlFlow::Poll; + + match event { + Event::NewEvents(StartCause::Init) => { + for window in windows.iter() { + window.window.set_outer_position(window.outer_position); + window.window.request_redraw(); + } + crate::platform::hide_dock(); + } + Event::WindowEvent { event, .. } => match event { + WindowEvent::CloseRequested => { + *control_flow = ControlFlow::Exit; + } + _ => {} + }, + Event::RedrawRequested(window_id) => { + draw_cursors( + &windows, + window_id, + &mut window_ripples, + &last_cursors, + &mut map_cursor_text, + ); + } + Event::MainEventsCleared => { + for window in windows.iter() { + window.window.request_redraw(); + } + } + Event::UserEvent((k, evt)) => match evt { + CustomEvent::Cursor(cursor) => { + for window in windows.iter() { + let (l, t, r, b) = ( + window.display_origin.0, + window.display_origin.1, + window.display_origin.0 + window.logical_size.width, + window.display_origin.1 + window.logical_size.height, + ); + if (cursor.x as f64) < l + || (cursor.x as f64) > r + || (cursor.y as f64) < t + || (cursor.y as f64) > b + { + continue; + } + + if cursor.btns != 0 { + let window_id = window.window.id(); + let ripple = Ripple { + x: (cursor.x as f64 - window.display_origin.0), + y: (cursor.y as f64 - window.display_origin.1), + start_time: Instant::now(), + }; + if let Some(ripples) = window_ripples.get_mut(&window_id) { + ripples.push(ripple); + } else { + window_ripples.insert(window_id, vec![ripple]); + } + } + last_cursors.insert( + k, + CursorInfo { + window_id: window.window.id(), + text_key: (cursor.text.clone(), cursor.argb), + cursor: Cursor { + x: (cursor.x - window.display_origin.0 as f32), + y: (cursor.y - window.display_origin.1 as f32), + ..cursor + }, + }, + ); + window.window.request_redraw(); + break; + } + } + CustomEvent::Exit => { + *control_flow = ControlFlow::Exit; + } + _ => {} + }, + _ => (), + } + }); +} diff --git a/src/whiteboard/mod.rs b/src/whiteboard/mod.rs new file mode 100644 index 000000000..42befe84f --- /dev/null +++ b/src/whiteboard/mod.rs @@ -0,0 +1,41 @@ +use serde_derive::{Deserialize, Serialize}; + +mod client; +mod server; + +#[cfg(target_os = "windows")] +mod windows; +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "macos")] +mod macos; +#[cfg(any(target_os = "windows", target_os = "linux"))] +mod win_linux; + +#[cfg(target_os = "windows")] +use windows::create_event_loop; +#[cfg(target_os = "macos")] +use macos::create_event_loop; +#[cfg(target_os = "linux")] +pub use linux::is_supported; + +pub use client::*; +pub use server::*; + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(tag = "t", content = "c")] +pub enum CustomEvent { + Cursor(Cursor), + Clear, + Exit, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(tag = "t")] +pub struct Cursor { + pub x: f32, + pub y: f32, + pub argb: u32, + pub btns: i32, + pub text: String, +} diff --git a/src/whiteboard/server.rs b/src/whiteboard/server.rs new file mode 100644 index 000000000..040110598 --- /dev/null +++ b/src/whiteboard/server.rs @@ -0,0 +1,171 @@ +use super::CustomEvent; +use crate::ipc::{new_listener, Connection, Data}; +#[cfg(any(target_os = "windows", target_os = "macos"))] +use hbb_common::tokio::sync::mpsc::unbounded_channel; +#[cfg(any(target_os = "windows", target_os = "linux"))] +use hbb_common::ResultType; +use hbb_common::{ + allow_err, log, + tokio::{self, sync::mpsc::UnboundedReceiver}, +}; +use lazy_static::lazy_static; +use std::sync::RwLock; +use std::time::{Duration, Instant}; + +#[cfg(any(target_os = "windows", target_os = "macos"))] +use tao::event_loop::EventLoopProxy; +#[cfg(target_os = "linux")] +use winit::event_loop::EventLoopProxy; + +lazy_static! { + pub(super) static ref EVENT_PROXY: RwLock>> = + RwLock::new(None); +} + +const RIPPLE_DURATION: Duration = Duration::from_millis(500); +#[cfg(target_os = "macos")] +type RippleFloat = f64; +#[cfg(any(target_os = "windows", target_os = "linux"))] +type RippleFloat = f32; + +#[cfg(target_os = "linux")] +pub use super::linux::run; + +#[cfg(any(target_os = "windows", target_os = "macos"))] +pub fn run() { + let (tx_exit, rx_exit) = unbounded_channel(); + std::thread::spawn(move || { + start_ipc(rx_exit); + }); + if let Err(e) = super::create_event_loop() { + log::error!("Failed to create event loop: {}", e); + tx_exit.send(()).ok(); + return; + } +} + +#[tokio::main(flavor = "current_thread")] +pub(super) async fn start_ipc(mut rx_exit: UnboundedReceiver<()>) { + match new_listener("_whiteboard").await { + Ok(mut incoming) => loop { + tokio::select! { + _ = rx_exit.recv() => { + log::info!("Exiting IPC"); + break; + } + res = incoming.next() => match res { + Some(result) => match result { + Ok(stream) => { + log::debug!("Got new connection"); + tokio::spawn(handle_new_stream(Connection::new(stream))); + } + Err(err) => { + log::error!("Couldn't get whiteboard client: {:?}", err); + } + }, + None => { + log::error!("Failed to get whiteboard client"); + } + } + } + }, + Err(err) => { + log::error!("Failed to start whiteboard ipc server: {}", err); + } + } +} + +async fn handle_new_stream(mut conn: Connection) { + loop { + tokio::select! { + res = conn.next() => { + match res { + Err(err) => { + log::info!("whiteboard ipc connection closed: {}", err); + break; + } + Ok(Some(data)) => { + match data { + Data::Whiteboard((k, evt)) => { + if matches!(evt, CustomEvent::Exit) { + log::info!("whiteboard ipc connection closed"); + break; + } else { + EVENT_PROXY.read().unwrap().as_ref().map(|ep| { + allow_err!(ep.send_event((k, evt))); + }); + } + } + _ => {} + } + } + Ok(None) => { + log::info!("whiteboard ipc connection closed"); + break; + } + } + } + } + } + EVENT_PROXY.read().unwrap().as_ref().map(|ep| { + allow_err!(ep.send_event(("".to_string(), CustomEvent::Exit))); + }); +} + +#[cfg(any(target_os = "windows", target_os = "linux"))] +pub(super) fn get_displays_rect() -> ResultType<(i32, i32, u32, u32)> { + let displays = crate::server::display_service::try_get_displays()?; + let mut min_x = i32::MAX; + let mut min_y = i32::MAX; + let mut max_x = i32::MIN; + let mut max_y = i32::MIN; + + for display in displays { + let (x, y) = (display.origin().0 as i32, display.origin().1 as i32); + let (w, h) = (display.width() as i32, display.height() as i32); + min_x = min_x.min(x); + min_y = min_y.min(y); + max_x = max_x.max(x + w); + max_y = max_y.max(y + h); + } + let (x, y) = (min_x, min_y); + let (w, h) = ((max_x - min_x) as u32, (max_y - min_y) as u32); + Ok((x, y, w, h)) +} + +#[inline] +pub(super) fn argb_to_rgba(argb: u32) -> (u8, u8, u8, u8) { + ( + (argb >> 16 & 0xFF) as u8, + (argb >> 8 & 0xFF) as u8, + (argb & 0xFF) as u8, + (argb >> 24 & 0xFF) as u8, + ) +} + +pub(super) struct Ripple { + pub x: RippleFloat, + pub y: RippleFloat, + pub start_time: Instant, +} + +impl Ripple { + #[inline] + pub fn retain_active(ripples: &mut Vec) { + ripples.retain(|r| r.start_time.elapsed() < RIPPLE_DURATION); + } + + pub fn get_radius_alpha(&self) -> (RippleFloat, RippleFloat) { + let elapsed = self.start_time.elapsed(); + #[cfg(target_os = "macos")] + let progress = (elapsed.as_secs_f64() / RIPPLE_DURATION.as_secs_f64()).min(1.0); + #[cfg(any(target_os = "windows", target_os = "linux"))] + let progress = (elapsed.as_secs_f32() / RIPPLE_DURATION.as_secs_f32()).min(1.0); + #[cfg(target_os = "macos")] + let radius = 25.0 * progress; + #[cfg(any(target_os = "windows", target_os = "linux"))] + let radius = 45.0 * progress; + let alpha = 1.0 - progress; + (radius, alpha) + } +} diff --git a/src/whiteboard/win_linux.rs b/src/whiteboard/win_linux.rs new file mode 100644 index 000000000..9e4722fff --- /dev/null +++ b/src/whiteboard/win_linux.rs @@ -0,0 +1,180 @@ +use hbb_common::{bail, ResultType}; +use tiny_skia::{FillRule, Paint, PathBuilder, PixmapMut, Point, Rect, Transform}; +use ttf_parser::Face; +// A helper struct to bridge `ttf-parser` and `tiny-skia`. +struct PathBuilderWrapper<'a> { + path_builder: &'a mut PathBuilder, + transform: Transform, +} + +impl ttf_parser::OutlineBuilder for PathBuilderWrapper<'_> { + fn move_to(&mut self, x: f32, y: f32) { + let mut pt = Point::from_xy(x, y); + self.transform.map_point(&mut pt); + self.path_builder.move_to(pt.x, pt.y); + } + + fn line_to(&mut self, x: f32, y: f32) { + let mut pt = Point::from_xy(x, y); + self.transform.map_point(&mut pt); + self.path_builder.line_to(pt.x, pt.y); + } + + fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) { + let mut pt1 = Point::from_xy(x1, y1); + self.transform.map_point(&mut pt1); + let mut pt = Point::from_xy(x, y); + self.transform.map_point(&mut pt); + self.path_builder.quad_to(pt1.x, pt1.y, pt.x, pt.y); + } + + fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) { + let mut pt1 = Point::from_xy(x1, y1); + self.transform.map_point(&mut pt1); + let mut pt2 = Point::from_xy(x2, y2); + self.transform.map_point(&mut pt2); + let mut pt = Point::from_xy(x, y); + self.transform.map_point(&mut pt); + self.path_builder + .cubic_to(pt1.x, pt1.y, pt2.x, pt2.y, pt.x, pt.y); + } + + fn close(&mut self) { + self.path_builder.close(); + } +} + +// Draws a string of text with the white background rectangle onto the pixmap. +pub(super) fn draw_text( + pixmap: &mut PixmapMut, + face: &Face, + text: &str, + x: f32, + y: f32, + paint: &Paint, + font_size: f32, +) { + let units_per_em = face.units_per_em() as f32; + let scale = font_size / units_per_em; + + // --- 1. Calculate text dimensions for the background --- + let mut total_width = 0.0; + for ch in text.chars() { + let glyph_id = face.glyph_index(ch).unwrap_or_default(); + if let Some(h_advance) = face.glyph_hor_advance(glyph_id) { + total_width += h_advance as f32 * scale; + } + } + + // Use font metrics for a consistent background height. + let font_height = (face.ascender() - face.descender()) as f32 * scale; + let ascent = face.ascender() as f32 * scale; + // Add some padding around the text + let padding = 3.0; + + let mut bg_filled = false; + // --- 2. Draw the white background rectangle --- + if let Some(bg_rect) = Rect::from_xywh( + x - padding, + y - ascent - padding, + total_width + 2.0 * padding, + font_height + 2.0 * padding, + ) { + // Corner radius + let radius = 5.0; + let path = { + let mut pb = PathBuilder::new(); + let r_x = bg_rect.x(); + let r_y = bg_rect.y(); + let r_w = bg_rect.width(); + let r_h = bg_rect.height(); + pb.move_to(r_x + radius, r_y); + pb.line_to(r_x + r_w - radius, r_y); + pb.quad_to(r_x + r_w, r_y, r_x + r_w, r_y + radius); + pb.line_to(r_x + r_w, r_y + r_h - radius); + pb.quad_to(r_x + r_w, r_y + r_h, r_x + r_w - radius, r_y + r_h); + pb.line_to(r_x + radius, r_y + r_h); + pb.quad_to(r_x, r_y + r_h, r_x, r_y + r_h - radius); + pb.line_to(r_x, r_y + radius); + pb.quad_to(r_x, r_y, r_x + radius, r_y); + pb.close(); + pb.finish() + }; + + if let Some(path) = path { + let mut bg_paint = Paint::default(); + bg_paint.set_color_rgba8(255, 255, 255, 255); + bg_paint.anti_alias = true; + pixmap.fill_path( + &path, + &bg_paint, + FillRule::Winding, + Transform::identity(), + None, + ); + bg_filled = true; + } + } + + // --- 3. Draw the text --- + let transform = Transform::from_translate(x, y).pre_scale(scale, -scale); + let mut path_builder = PathBuilder::new(); + let mut current_x = 0.0; + + for ch in text.chars() { + let glyph_id = face.glyph_index(ch).unwrap_or_default(); + + let mut builder = PathBuilderWrapper { + path_builder: &mut path_builder, + transform: transform.post_translate(current_x, 0.0), + }; + + face.outline_glyph(glyph_id, &mut builder); + + if let Some(h_advance) = face.glyph_hor_advance(glyph_id) { + current_x += h_advance as f32 * scale; + } + } + + if let Some(path) = path_builder.finish() { + if bg_filled { + let mut text_paint = Paint::default(); + text_paint.set_color_rgba8(0, 0, 0, 255); + text_paint.anti_alias = true; + pixmap.fill_path( + &path, + &text_paint, + FillRule::Winding, + Transform::identity(), + None, + ); + } else { + pixmap.fill_path(&path, paint, FillRule::Winding, Transform::identity(), None); + } + } +} + +pub(super) fn create_font_face() -> ResultType> { + let mut font_db = fontdb::Database::new(); + font_db.load_system_fonts(); + let query = fontdb::Query { + families: &[fontdb::Family::Monospace, fontdb::Family::SansSerif], + ..fontdb::Query::default() + }; + let Some(font_id) = font_db.query(&query) else { + bail!("No monospace or sans-serif font found!"); + }; + let Some((font_source, face_index)) = font_db.face_source(font_id) else { + bail!("No face found for font!"); + }; + // Load the font data into a static slice to satisfy `ttf-parser`'s lifetime requirements. + // We use `Box::leak` to leak the memory, which is acceptable here since the font data + // is needed for the entire lifetime of the application. + let font_data: &'static [u8] = Box::leak(match font_source { + fontdb::Source::File(path) => std::fs::read(path)?.into_boxed_slice(), + fontdb::Source::Binary(data) => data.as_ref().as_ref().to_vec().into_boxed_slice(), + fontdb::Source::SharedFile(path, _) => std::fs::read(path)?.into_boxed_slice(), + }); + let face = Face::parse(font_data, face_index)?; + Ok(face) +} diff --git a/src/whiteboard/windows.rs b/src/whiteboard/windows.rs new file mode 100644 index 000000000..dc6a8c30e --- /dev/null +++ b/src/whiteboard/windows.rs @@ -0,0 +1,230 @@ +use super::{ + server::{Ripple, EVENT_PROXY}, + win_linux::{create_font_face, draw_text}, + Cursor, CustomEvent, +}; +use hbb_common::{anyhow::anyhow, log, ResultType}; +use softbuffer::{Context, Surface}; +use std::{collections::HashMap, num::NonZeroU32, sync::Arc, time::Instant}; +use tao::{ + dpi::{PhysicalPosition, PhysicalSize}, + event::{Event, WindowEvent}, + event_loop::{ControlFlow, EventLoopBuilder}, + platform::windows::WindowBuilderExtWindows, + window::WindowBuilder, +}; +use tiny_skia::{Color, FillRule, Paint, PathBuilder, PixmapMut, Stroke, Transform}; + +pub(super) fn create_event_loop() -> ResultType<()> { + let face = match create_font_face() { + Ok(face) => Some(face), + Err(err) => { + log::error!("Failed to create font face: {}", err); + None + } + }; + + let event_loop = EventLoopBuilder::<(String, CustomEvent)>::with_user_event().build(); + let mut window_builder = WindowBuilder::new() + .with_title("RustDesk whiteboard") + .with_transparent(true) + .with_always_on_top(true) + .with_skip_taskbar(true) + .with_decorations(false); + + let mut final_size = None; + if let Ok((x, y, w, h)) = super::server::get_displays_rect() { + if w > 0 && h > 0 { + final_size = Some(PhysicalSize::new(w, h)); + window_builder = window_builder + .with_position(PhysicalPosition::new(x, y)) + .with_inner_size(PhysicalSize::new(1, 1)); + } else { + window_builder = + window_builder.with_fullscreen(Some(tao::window::Fullscreen::Borderless(None))); + } + } else { + window_builder = + window_builder.with_fullscreen(Some(tao::window::Fullscreen::Borderless(None))); + } + + let window = Arc::new(window_builder.build::<(String, CustomEvent)>(&event_loop)?); + window.set_ignore_cursor_events(true)?; + + let context = Context::new(window.clone()).map_err(|e| { + log::error!("Failed to create context: {}", e); + anyhow!(e.to_string()) + })?; + let mut surface = Surface::new(&context, window.clone()).map_err(|e| { + log::error!("Failed to create surface: {}", e); + anyhow!(e.to_string()) + })?; + + let proxy = event_loop.create_proxy(); + EVENT_PROXY.write().unwrap().replace(proxy); + let _call_on_ret = crate::common::SimpleCallOnReturn { + b: true, + f: Box::new(move || { + let _ = EVENT_PROXY.write().unwrap().take(); + }), + }; + + let mut ripples: Vec = Vec::new(); + let mut last_cursors: HashMap = HashMap::new(); + let mut resized = final_size.is_none(); + + event_loop.run(move |event, _, control_flow| { + *control_flow = ControlFlow::Poll; + + match event { + Event::WindowEvent { event, .. } => match event { + WindowEvent::CloseRequested => { + *control_flow = ControlFlow::Exit; + } + _ => {} + }, + Event::RedrawRequested(_) => { + if !resized { + if let Some(size) = final_size.take() { + window.set_inner_size(size); + } + resized = true; + return; + } + + let (width, height) = { + let size = window.inner_size(); + (size.width, size.height) + }; + + let (Some(width), Some(height)) = (NonZeroU32::new(width), NonZeroU32::new(height)) + else { + return; + }; + if let Err(e) = surface.resize(width, height) { + log::error!("Failed to resize surface: {}", e); + return; + } + + let mut buffer = match surface.buffer_mut() { + Ok(buf) => buf, + Err(e) => { + log::error!("Failed to get buffer: {}", e); + return; + } + }; + let Some(mut pixmap) = PixmapMut::from_bytes( + bytemuck::cast_slice_mut(&mut buffer), + width.get(), + height.get(), + ) else { + log::error!("Failed to create pixmap from buffer"); + return; + }; + pixmap.fill(Color::TRANSPARENT); + + Ripple::retain_active(&mut ripples); + for ripple in &ripples { + let (radius, alpha) = ripple.get_radius_alpha(); + + let mut ripple_paint = Paint::default(); + // Note: The real color is bgra here. + ripple_paint.set_color_rgba8(64, 64, 255, (alpha * 128.0) as u8); + ripple_paint.anti_alias = true; + + let mut ripple_pb = PathBuilder::new(); + ripple_pb.push_circle(ripple.x, ripple.y, radius); + if let Some(path) = ripple_pb.finish() { + pixmap.fill_path( + &path, + &ripple_paint, + FillRule::Winding, + Transform::identity(), + None, + ); + } + } + + for cursor in last_cursors.values() { + let (x, y) = (cursor.x, cursor.y); + let size = 1.5f32; + + let mut pb = PathBuilder::new(); + pb.move_to(x, y); + pb.line_to(x, y + 16.0 * size); + pb.line_to(x + 4.0 * size, y + 13.0 * size); + pb.line_to(x + 7.0 * size, y + 20.0 * size); + pb.line_to(x + 9.0 * size, y + 19.0 * size); + pb.line_to(x + 6.0 * size, y + 12.0 * size); + pb.line_to(x + 11.0 * size, y + 12.0 * size); + pb.close(); + + if let Some(path) = pb.finish() { + let rgba = super::argb_to_rgba(cursor.argb); + let mut arrow_paint = Paint::default(); + // Note: The real color is bgra here. + arrow_paint.set_color_rgba8(rgba.2, rgba.1, rgba.0, rgba.3); + arrow_paint.anti_alias = true; + pixmap.fill_path( + &path, + &arrow_paint, + FillRule::Winding, + Transform::identity(), + None, + ); + + let mut black_paint = Paint::default(); + black_paint.set_color_rgba8(0, 0, 0, 255); + black_paint.anti_alias = true; + let mut stroke = Stroke::default(); + stroke.width = 1.0f32; + pixmap.stroke_path( + &path, + &black_paint, + &stroke, + Transform::identity(), + None, + ); + + face.as_ref().map(|face| { + draw_text( + &mut pixmap, + face, + &cursor.text, + x + 24.0 * size, + y + 24.0 * size, + &arrow_paint, + 14.0f32, + ); + }); + } + } + + if let Err(e) = buffer.present() { + log::error!("Failed to present surface: {}", e); + return; + } + } + Event::MainEventsCleared => { + window.request_redraw(); + } + Event::UserEvent((k, evt)) => match evt { + CustomEvent::Cursor(cursor) => { + if cursor.btns != 0 { + ripples.push(Ripple { + x: cursor.x, + y: cursor.y, + start_time: Instant::now(), + }); + } + last_cursors.insert(k, cursor); + } + CustomEvent::Exit => { + *control_flow = ControlFlow::Exit; + } + _ => {} + }, + _ => (), + } + }); +} diff --git a/vcpkg.json b/vcpkg.json index 81484772a..d41b91c22 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -24,10 +24,6 @@ "name": "oboe", "platform": "android" }, - { - "name": "oboe-wrapper", - "platform": "android" - }, { "name": "opus", "host": true @@ -52,6 +48,16 @@ "name": "libyuv", "host": false }, + { + "name": "mfx-dispatch", + "host": true, + "platform": "((x86 | x64) & (android | linux)) | (windows & !uwp)" + }, + { + "name": "mfx-dispatch", + "host": false, + "platform": "((x86 | x64) & (android | linux)) | (windows & !uwp)" + }, { "name": "ffmpeg", "host": true, @@ -80,15 +86,20 @@ "vcpkg-configuration": { "default-registry": { "kind": "builtin", - "baseline": "f7423ee180c4b7f40d43402c2feb3859161ef625" + "baseline": "120deac3062162151622ca4860575a33844ba10b" }, "overlay-ports": [ "./res/vcpkg" ] }, "overrides": [ - { "name": "ffnvcodec", "version": "12.1.14.0" }, - { "name": "amd-amf", "version": "1.4.29" }, - { "name": "mfx-dispatch", "version": "1.35.1" } + { + "name": "ffnvcodec", + "version": "12.1.14.0" + }, + { + "name": "amd-amf", + "version": "1.4.35" + } ] -} \ No newline at end of file +} diff --git a/vdi/README.md b/vdi/README.md deleted file mode 100644 index 85e6ff194..000000000 --- a/vdi/README.md +++ /dev/null @@ -1 +0,0 @@ -# WIP diff --git a/vdi/host/.cargo/config.toml b/vdi/host/.cargo/config.toml deleted file mode 100644 index 70f9eaeb2..000000000 --- a/vdi/host/.cargo/config.toml +++ /dev/null @@ -1,2 +0,0 @@ -[registries.crates-io] -protocol = "sparse" diff --git a/vdi/host/.gitignore b/vdi/host/.gitignore deleted file mode 100644 index ea8c4bf7f..000000000 --- a/vdi/host/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/target diff --git a/vdi/host/Cargo.lock b/vdi/host/Cargo.lock deleted file mode 100644 index 0b2e8ca2b..000000000 --- a/vdi/host/Cargo.lock +++ /dev/null @@ -1,2543 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 3 - -[[package]] -name = "addr2line" -version = "0.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a76fd60b23679b7d19bd066031410fb7e458ccc5e958eb5c325888ce4baedc97" -dependencies = [ - "gimli", -] - -[[package]] -name = "adler" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" - -[[package]] -name = "ahash" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcb51a0695d8f838b1ee009b3fbf66bda078cd64590202a864a8f3e8c4315c47" -dependencies = [ - "getrandom", - "once_cell", - "version_check", -] - -[[package]] -name = "aho-corasick" -version = "0.7.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc936419f96fa211c1b9166887b38e5e40b19958e5b895be7c1f93adec7071ac" -dependencies = [ - "memchr", -] - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] -name = "anyhow" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224afbd727c3d6e4b90103ece64b8d1b67fbb1973b1046c2281eed3f3803f800" - -[[package]] -name = "async-broadcast" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90622698a1218e0b2fb846c97b5f19a0831f6baddee73d9454156365ccfa473b" -dependencies = [ - "easy-parallel", - "event-listener", - "futures-core", -] - -[[package]] -name = "async-broadcast" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d26004fe83b2d1cd3a97609b21e39f9a31535822210fe83205d2ce48866ea61" -dependencies = [ - "event-listener", - "futures-core", - "parking_lot", -] - -[[package]] -name = "async-channel" -version = "1.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf46fee83e5ccffc220104713af3292ff9bc7c64c7de289f66dae8e38d826833" -dependencies = [ - "concurrent-queue", - "event-listener", - "futures-core", -] - -[[package]] -name = "async-executor" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17adb73da160dfb475c183343c8cccd80721ea5a605d3eb57125f0a7b7a92d0b" -dependencies = [ - "async-lock", - "async-task", - "concurrent-queue", - "fastrand", - "futures-lite", - "slab", -] - -[[package]] -name = "async-io" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c374dda1ed3e7d8f0d9ba58715f924862c63eae6849c92d3a18e7fbde9e2794" -dependencies = [ - "async-lock", - "autocfg", - "concurrent-queue", - "futures-lite", - "libc", - "log", - "parking", - "polling", - "slab", - "socket2 0.4.7", - "waker-fn", - "windows-sys 0.42.0", -] - -[[package]] -name = "async-lock" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8101efe8695a6c17e02911402145357e718ac92d3ff88ae8419e84b1707b685" -dependencies = [ - "event-listener", - "futures-lite", -] - -[[package]] -name = "async-recursion" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7d78656ba01f1b93024b7c3a0467f1608e4be67d725749fdcd7d2c7678fd7a2" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "async-task" -version = "4.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a40729d2133846d9ed0ea60a8b9541bccddab49cd30f0715a1da672fe9a2524" - -[[package]] -name = "async-trait" -version = "0.1.64" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cd7fce9ba8c3c042128ce72d8b2ddbf3a05747efb67ea0313c635e10bda47a2" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "autocfg" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" - -[[package]] -name = "backtrace" -version = "0.3.67" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "233d376d6d185f2a3093e58f283f60f880315b6c60075b01f36b3b85154564ca" -dependencies = [ - "addr2line", - "cc", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", -] - -[[package]] -name = "bit_field" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc827186963e592360843fb5ba4b973e145841266c1357f7180c43526f2e5b61" - -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - -[[package]] -name = "bitflags" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "487f1e0fcbe47deb8b0574e646def1c903389d95241dd1bbcc6ce4a715dfc0c1" - -[[package]] -name = "bumpalo" -version = "3.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d261e256854913907f67ed06efbc3338dfe6179796deefc1ff763fc1aee5535" - -[[package]] -name = "bytemuck" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17febce684fd15d89027105661fec94afb475cb995fbc59d2865198446ba2eea" - -[[package]] -name = "byteorder" -version = "1.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" - -[[package]] -name = "bytes" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89b2fd2a0dcf38d7971e2194b6b6eebab45ae01067456a7fd93d5547a61b70be" -dependencies = [ - "serde", -] - -[[package]] -name = "cc" -version = "1.0.79" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50d30906286121d95be3d479533b458f87493b30a4b5f79a607db8f5d11aa91f" -dependencies = [ - "jobserver", -] - -[[package]] -name = "cfg-if" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" - -[[package]] -name = "chrono" -version = "0.4.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16b0a3d9ed01224b22057780a37bb8c5dbfe1be8ba48678e7bf57ec4b385411f" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-integer", - "num-traits", - "time", - "wasm-bindgen", - "winapi", -] - -[[package]] -name = "clap" -version = "4.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42dfd32784433290c51d92c438bb72ea5063797fc3cc9a21a8c4346bebbb2098" -dependencies = [ - "bitflags 2.0.2", - "clap_derive", - "clap_lex", - "is-terminal", - "once_cell", - "strsim", - "termcolor", -] - -[[package]] -name = "clap_derive" -version = "4.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fddf67631444a3a3e3e5ac51c36a5e01335302de677bd78759eaa90ab1f46644" -dependencies = [ - "heck", - "proc-macro-error", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "clap_lex" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "033f6b7a4acb1f358c742aaca805c939ee73b4c6209ae4318ec7aca81c42e646" -dependencies = [ - "os_str_bytes", -] - -[[package]] -name = "codespan-reporting" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" -dependencies = [ - "termcolor", - "unicode-width", -] - -[[package]] -name = "color_quant" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" - -[[package]] -name = "concurrent-queue" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c278839b831783b70278b14df4d45e1beb1aad306c07bb796637de9a0e323e8e" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "confy" -version = "0.4.0" -source = "git+https://github.com/open-trade/confy#630cc28a396cb7d01eefdd9f3824486fe4d8554b" -dependencies = [ - "directories-next", - "serde", - "thiserror", - "toml", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5827cebf4670468b8772dd191856768aedcb1b0278a04f989f7766351917b9dc" - -[[package]] -name = "crc32fast" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "crossbeam-channel" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2dd04ddaf88237dc3b8d8f9a3c1004b506b54b3313403944054d23c0870c521" -dependencies = [ - "cfg-if", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-deque" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "715e8152b692bba2d374b53d4875445368fdf21a94751410af607a5ac677d1fc" -dependencies = [ - "cfg-if", - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01a9af1f4c2ef74bb8aa1f7e19706bc72d03598c8a570bb5de72243c7a9d9d5a" -dependencies = [ - "autocfg", - "cfg-if", - "crossbeam-utils", - "memoffset 0.7.1", - "scopeguard", -] - -[[package]] -name = "crossbeam-queue" -version = "0.3.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1cfb3ea8a53f37c40dea2c7bedcbd88bdfae54f5e2175d6ecaff1c988353add" -dependencies = [ - "cfg-if", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fb766fa798726286dbbb842f174001dab8abc7b627a1dd86e0b7222a95d929f" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "crunchy" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" - -[[package]] -name = "cxx" -version = "1.0.91" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86d3488e7665a7a483b57e25bdd90d0aeb2bc7608c8d0346acf2ad3f1caf1d62" -dependencies = [ - "cc", - "cxxbridge-flags", - "cxxbridge-macro", - "link-cplusplus", -] - -[[package]] -name = "cxx-build" -version = "1.0.91" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48fcaf066a053a41a81dfb14d57d99738b767febb8b735c3016e469fac5da690" -dependencies = [ - "cc", - "codespan-reporting", - "once_cell", - "proc-macro2", - "quote", - "scratch", - "syn", -] - -[[package]] -name = "cxxbridge-flags" -version = "1.0.91" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2ef98b8b717a829ca5603af80e1f9e2e48013ab227b68ef37872ef84ee479bf" - -[[package]] -name = "cxxbridge-macro" -version = "1.0.91" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "086c685979a698443656e5cf7856c95c642295a38599f12fb1ff76fb28d19892" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "derivative" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "directories-next" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "339ee130d97a610ea5a5872d2bbb130fdf68884ff09d3028b81bec8a1ac23bbc" -dependencies = [ - "cfg-if", - "dirs-sys-next", -] - -[[package]] -name = "dirs" -version = "4.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-next" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" -dependencies = [ - "cfg-if", - "dirs-sys-next", -] - -[[package]] -name = "dirs-sys" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" -dependencies = [ - "libc", - "redox_users", - "winapi", -] - -[[package]] -name = "dirs-sys-next" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" -dependencies = [ - "libc", - "redox_users", - "winapi", -] - -[[package]] -name = "easy-parallel" -version = "3.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6907e25393cdcc1f4f3f513d9aac1e840eb1cc341a0fccb01171f7d14d10b946" - -[[package]] -name = "ed25519" -version = "1.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91cff35c70bba8a626e3185d8cd48cc11b5437e1a5bcd15b9b5fa3c64b6dfee7" -dependencies = [ - "signature", -] - -[[package]] -name = "either" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fcaabb2fef8c910e7f4c7ce9f67a1283a1715879a7c230ca9d6d1ae31f16d91" - -[[package]] -name = "enumflags2" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e75d4cd21b95383444831539909fbb14b9dc3fdceb2a6f5d36577329a1f55ccb" -dependencies = [ - "enumflags2_derive", - "serde", -] - -[[package]] -name = "enumflags2_derive" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f58dc3c5e468259f19f2d46304a6b28f1c3d034442e14b322d2b850e36f6d5ae" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "env_logger" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85cdab6a89accf66733ad5a1693a4dcced6aeff64602b634530dd73c1f3ee9f0" -dependencies = [ - "humantime", - "is-terminal", - "log", - "regex", - "termcolor", -] - -[[package]] -name = "errno" -version = "0.2.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f639046355ee4f37944e44f60642c6f3a7efa3cf6b78c78a0d989a8ce6c396a1" -dependencies = [ - "errno-dragonfly", - "libc", - "winapi", -] - -[[package]] -name = "errno-dragonfly" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf" -dependencies = [ - "cc", - "libc", -] - -[[package]] -name = "event-listener" -version = "2.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" - -[[package]] -name = "exr" -version = "1.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdd2162b720141a91a054640662d3edce3d50a944a50ffca5313cd951abb35b4" -dependencies = [ - "bit_field", - "flume", - "half", - "lebe", - "miniz_oxide", - "rayon-core", - "smallvec", - "zune-inflate", -] - -[[package]] -name = "fastrand" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" -dependencies = [ - "instant", -] - -[[package]] -name = "filetime" -version = "0.2.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a3de6e8d11b22ff9edc6d916f890800597d60f8b2da1caf2955c274638d6412" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "windows-sys 0.45.0", -] - -[[package]] -name = "flate2" -version = "1.0.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8a2db397cb1c8772f31494cb8917e48cd1e64f0fa7efac59fbd741a0a8ce841" -dependencies = [ - "crc32fast", - "miniz_oxide", -] - -[[package]] -name = "flexi_logger" -version = "0.25.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6eae57842a8221ef13f1f207632d786a175dd13bd8fbdc8be9d852f7c9cf1046" -dependencies = [ - "chrono", - "crossbeam-channel", - "crossbeam-queue", - "glob", - "is-terminal", - "lazy_static", - "log", - "nu-ansi-term", - "regex", - "thiserror", -] - -[[package]] -name = "flume" -version = "0.10.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1657b4441c3403d9f7b3409e47575237dac27b1b5726df654a6ecbf92f0f7577" -dependencies = [ - "futures-core", - "futures-sink", - "nanorand", - "pin-project", - "spin", -] - -[[package]] -name = "futures" -version = "0.3.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13e2792b0ff0340399d58445b88fd9770e3489eff258a4cbc1523418f12abf84" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e5317663a9089767a1ec00a487df42e0ca174b61b4483213ac24448e4664df5" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec90ff4d0fe1f57d600049061dc6bb68ed03c7d2fbd697274c41805dcb3f8608" - -[[package]] -name = "futures-executor" -version = "0.3.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8de0a35a6ab97ec8869e32a2473f4b1324459e14c29275d14b10cb1fd19b50e" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfb8371b6fb2aeb2d280374607aeabfc99d95c72edfe51692e42d3d7f0d08531" - -[[package]] -name = "futures-lite" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694489acd39452c77daa48516b894c153f192c3578d5a839b62c58099fcbf48" -dependencies = [ - "fastrand", - "futures-core", - "futures-io", - "memchr", - "parking", - "pin-project-lite", - "waker-fn", -] - -[[package]] -name = "futures-macro" -version = "0.3.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95a73af87da33b5acf53acfebdc339fe592ecf5357ac7c0a7734ab9d8c876a70" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "futures-sink" -version = "0.3.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f310820bb3e8cfd46c80db4d7fb8353e15dfff853a127158425f31e0be6c8364" - -[[package]] -name = "futures-task" -version = "0.3.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcf79a1bf610b10f42aea489289c5a2c478a786509693b80cd39c44ccd936366" - -[[package]] -name = "futures-util" -version = "0.3.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c1d6de3acfef38d2be4b1f543f553131788603495be83da675e180c8d6b7bd1" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "pin-utils", - "slab", -] - -[[package]] -name = "getrandom" -version = "0.2.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c05aeb6a22b8f62540c194aac980f2115af067bfe15a0734d7277a768d396b31" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi 0.11.0+wasi-snapshot-preview1", - "wasm-bindgen", -] - -[[package]] -name = "gif" -version = "0.11.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3edd93c6756b4dfaf2709eafcc345ba2636565295c198a9cfbf75fa5e3e00b06" -dependencies = [ - "color_quant", - "weezl", -] - -[[package]] -name = "gimli" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad0a93d233ebf96623465aad4046a8d3aa4da22d4f4beba5388838c8a434bbb4" - -[[package]] -name = "glob" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" - -[[package]] -name = "half" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02b4af3693f1b705df946e9fe5631932443781d0aabb423b62fcd4d73f6d2fd0" -dependencies = [ - "crunchy", -] - -[[package]] -name = "hashbrown" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" -dependencies = [ - "ahash", -] - -[[package]] -name = "hbb_common" -version = "0.1.0" -dependencies = [ - "anyhow", - "backtrace", - "bytes", - "chrono", - "confy", - "directories-next", - "dirs-next", - "env_logger", - "filetime", - "flexi_logger", - "futures", - "futures-util", - "lazy_static", - "libc", - "log", - "mac_address", - "machine-uid", - "osascript", - "protobuf", - "protobuf-codegen", - "rand", - "regex", - "serde", - "serde_derive", - "socket2 0.3.19", - "sodiumoxide", - "sysinfo", - "tokio", - "tokio-socks", - "tokio-util", - "winapi", - "zstd", -] - -[[package]] -name = "heck" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" - -[[package]] -name = "hermit-abi" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee512640fe35acbfb4bb779db6f0d80704c2cacfa2e39b601ef3e3f47d1ae4c7" -dependencies = [ - "libc", -] - -[[package]] -name = "hermit-abi" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fed44880c466736ef9a5c5b5facefb5ed0785676d0c02d612db14e54f0d84286" - -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[package]] -name = "humantime" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" - -[[package]] -name = "iana-time-zone" -version = "0.1.53" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64c122667b287044802d6ce17ee2ddf13207ed924c712de9a66a5814d5b64765" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "wasm-bindgen", - "winapi", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0703ae284fc167426161c2e3f1da3ea71d94b21bedbcc9494e92b28e334e3dca" -dependencies = [ - "cxx", - "cxx-build", -] - -[[package]] -name = "image" -version = "0.24.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69b7ea949b537b0fd0af141fff8c77690f2ce96f4f41f042ccb6c69c6c965945" -dependencies = [ - "bytemuck", - "byteorder", - "color_quant", - "exr", - "gif", - "jpeg-decoder", - "num-rational", - "num-traits", - "png", - "scoped_threadpool", - "tiff", -] - -[[package]] -name = "indexmap" -version = "1.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885e79c1fc4b10f0e172c475f458b7f7b93061064d98c3293e98c5ba0c8b399" -dependencies = [ - "autocfg", - "hashbrown", -] - -[[package]] -name = "instant" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "io-lifetimes" -version = "1.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09270fd4fa1111bc614ed2246c7ef56239a3063d5be0d1ec3b589c505d400aeb" -dependencies = [ - "hermit-abi 0.3.1", - "libc", - "windows-sys 0.45.0", -] - -[[package]] -name = "is-terminal" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8687c819457e979cc940d09cb16e42a1bf70aa6b60a549de6d3a62a0ee90c69e" -dependencies = [ - "hermit-abi 0.3.1", - "io-lifetimes", - "rustix", - "windows-sys 0.45.0", -] - -[[package]] -name = "itoa" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fad582f4b9e86b6caa621cabeb0963332d92eea04729ab12892c2533951e6440" - -[[package]] -name = "jobserver" -version = "0.1.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "068b1ee6743e4d11fb9c6a1e6064b3693a1b600e7f5f5988047d98b3dc9fb90b" -dependencies = [ - "libc", -] - -[[package]] -name = "jpeg-decoder" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc0000e42512c92e31c2252315bda326620a4e034105e900c98ec492fa077b3e" -dependencies = [ - "rayon", -] - -[[package]] -name = "js-sys" -version = "0.3.61" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "445dde2150c55e483f3d8416706b97ec8e8237c307e5b7b4b8dd15e6af2a0730" -dependencies = [ - "wasm-bindgen", -] - -[[package]] -name = "lazy_static" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" - -[[package]] -name = "lebe" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03087c2bad5e1034e8cace5926dec053fb3790248370865f5117a7d0213354c8" - -[[package]] -name = "libc" -version = "0.2.139" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "201de327520df007757c1f0adce6e827fe8562fbc28bfd9c15571c66ca1f5f79" - -[[package]] -name = "libsodium-sys" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b779387cd56adfbc02ea4a668e704f729be8d6a6abd2c27ca5ee537849a92fd" -dependencies = [ - "cc", - "libc", - "pkg-config", - "walkdir", -] - -[[package]] -name = "libusb1-sys" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9d0e2afce4245f2c9a418511e5af8718bcaf2fa408aefb259504d1a9cb25f27" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "link-cplusplus" -version = "1.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecd207c9c713c34f95a097a5b029ac2ce6010530c7b49d7fea24d977dede04f5" -dependencies = [ - "cc", -] - -[[package]] -name = "linux-raw-sys" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f051f77a7c8e6957c0696eac88f26b0117e54f52d3fc682ab19397a8812846a4" - -[[package]] -name = "lock_api" -version = "0.4.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "435011366fe56583b16cf956f9df0095b405b82d76425bc8981c0e22e60ec4df" -dependencies = [ - "autocfg", - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "mac_address" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b238e3235c8382b7653c6408ed1b08dd379bdb9fdf990fb0bbae3db2cc0ae963" -dependencies = [ - "nix 0.23.2", - "winapi", -] - -[[package]] -name = "machine-uid" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f1595709b0a7386bcd56ba34d250d626e5503917d05d32cdccddcd68603e212" -dependencies = [ - "winreg", -] - -[[package]] -name = "memchr" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" - -[[package]] -name = "memoffset" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" -dependencies = [ - "autocfg", -] - -[[package]] -name = "memoffset" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5de893c32cde5f383baa4c04c5d6dbdd735cfd4a794b0debdb2bb1b421da5ff4" -dependencies = [ - "autocfg", -] - -[[package]] -name = "miniz_oxide" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b275950c28b37e794e8c55d88aeb5e139d0ce23fdbbeda68f8d7174abdf9e8fa" -dependencies = [ - "adler", -] - -[[package]] -name = "mio" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b9d9a46eff5b4ff64b45a9e316a6d1e0bc719ef429cbec4dc630684212bfdf9" -dependencies = [ - "libc", - "log", - "wasi 0.11.0+wasi-snapshot-preview1", - "windows-sys 0.45.0", -] - -[[package]] -name = "nanorand" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3" -dependencies = [ - "getrandom", -] - -[[package]] -name = "nix" -version = "0.23.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f3790c00a0150112de0f4cd161e3d7fc4b2d8a5542ffc35f099a2562aecb35c" -dependencies = [ - "bitflags 1.3.2", - "cc", - "cfg-if", - "libc", - "memoffset 0.6.5", -] - -[[package]] -name = "nix" -version = "0.24.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa52e972a9a719cecb6864fb88568781eb706bac2cd1d4f04a648542dbf78069" -dependencies = [ - "bitflags 1.3.2", - "cfg-if", - "libc", - "memoffset 0.6.5", -] - -[[package]] -name = "nom8" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae01545c9c7fc4486ab7debaf2aad7003ac19431791868fb2e8066df97fad2f8" -dependencies = [ - "memchr", -] - -[[package]] -name = "ntapi" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc51db7b362b205941f71232e56c625156eb9a929f8cf74a428fd5bc094a4afc" -dependencies = [ - "winapi", -] - -[[package]] -name = "nu-ansi-term" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" -dependencies = [ - "overload", - "winapi", -] - -[[package]] -name = "num-integer" -version = "0.1.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "225d3389fb3509a24c93f5c29eb6bde2586b98d9f016636dff58d7c6f7569cd9" -dependencies = [ - "autocfg", - "num-traits", -] - -[[package]] -name = "num-rational" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0638a1c9d0a3c0914158145bc76cff373a75a627e6ecbfb71cbe6f453a5a19b0" -dependencies = [ - "autocfg", - "num-integer", - "num-traits", -] - -[[package]] -name = "num-traits" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "578ede34cf02f8924ab9447f50c28075b4d3e5b269972345e7e0372b38c6cdcd" -dependencies = [ - "autocfg", -] - -[[package]] -name = "num_cpus" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fac9e2da13b5eb447a6ce3d392f23a29d8694bff781bf03a16cd9ac8697593b" -dependencies = [ - "hermit-abi 0.2.6", - "libc", -] - -[[package]] -name = "object" -version = "0.30.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea86265d3d3dcb6a27fc51bd29a4bf387fae9d2986b823079d4986af253eb439" -dependencies = [ - "memchr", -] - -[[package]] -name = "once_cell" -version = "1.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7e5500299e16ebb147ae15a00a942af264cf3688f47923b8fc2cd5858f23ad3" - -[[package]] -name = "ordered-stream" -version = "0.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44630c059eacfd6e08bdaa51b1db2ce33119caa4ddc1235e923109aa5f25ccb1" -dependencies = [ - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "os_str_bytes" -version = "6.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ceedf44fb00f2d1984b0bc98102627ce622e083e49a5bacdb3e514fa4238e267" - -[[package]] -name = "osascript" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38731fa859ef679f1aec66ca9562165926b442f298467f76f5990f431efe87dc" -dependencies = [ - "serde", - "serde_derive", - "serde_json", -] - -[[package]] -name = "overload" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" - -[[package]] -name = "parking" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "427c3892f9e783d91cc128285287e70a59e206ca452770ece88a76f7a3eddd72" - -[[package]] -name = "parking_lot" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9069cbb9f99e3a5083476ccb29ceb1de18b9118cafa53e90c9551235de2b9521" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-sys 0.45.0", -] - -[[package]] -name = "pin-project" -version = "1.0.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad29a609b6bcd67fee905812e544992d216af9d755757c05ed2d0e15a74c6ecc" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.0.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "069bdb1e05adc7a8990dce9cc75370895fbe4e3d58b9b73bf1aee56359344a55" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0a7ae3ac2f1173085d398531c705756c94a4c56843785df85a60c1a0afac116" - -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - -[[package]] -name = "pkg-config" -version = "0.3.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ac9a59f73473f1b8d852421e59e64809f025994837ef743615c6d0c5b305160" - -[[package]] -name = "png" -version = "0.17.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d708eaf860a19b19ce538740d2b4bdeeb8337fa53f7738455e706623ad5c638" -dependencies = [ - "bitflags 1.3.2", - "crc32fast", - "flate2", - "miniz_oxide", -] - -[[package]] -name = "polling" -version = "2.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22122d5ec4f9fe1b3916419b76be1e80bcb93f618d071d2edf841b137b2a2bd6" -dependencies = [ - "autocfg", - "cfg-if", - "libc", - "log", - "wepoll-ffi", - "windows-sys 0.42.0", -] - -[[package]] -name = "ppv-lite86" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" - -[[package]] -name = "proc-macro-crate" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66618389e4ec1c7afe67d51a9bf34ff9236480f8d51e7489b7d5ab0303c13f34" -dependencies = [ - "once_cell", - "toml_edit", -] - -[[package]] -name = "proc-macro-error" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" -dependencies = [ - "proc-macro-error-attr", - "proc-macro2", - "quote", - "syn", - "version_check", -] - -[[package]] -name = "proc-macro-error-attr" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" -dependencies = [ - "proc-macro2", - "quote", - "version_check", -] - -[[package]] -name = "proc-macro2" -version = "1.0.51" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d727cae5b39d21da60fa540906919ad737832fe0b1c165da3a34d6548c849d6" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "protobuf" -version = "3.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b55bad9126f378a853655831eb7363b7b01b81d19f8cb1218861086ca4a1a61e" -dependencies = [ - "bytes", - "once_cell", - "protobuf-support", - "thiserror", -] - -[[package]] -name = "protobuf-codegen" -version = "3.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dd418ac3c91caa4032d37cb80ff0d44e2ebe637b2fb243b6234bf89cdac4901" -dependencies = [ - "anyhow", - "once_cell", - "protobuf", - "protobuf-parse", - "regex", - "tempfile", - "thiserror", -] - -[[package]] -name = "protobuf-parse" -version = "3.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d39b14605eaa1f6a340aec7f320b34064feb26c93aec35d6a9a2272a8ddfa49" -dependencies = [ - "anyhow", - "indexmap", - "log", - "protobuf", - "protobuf-support", - "tempfile", - "thiserror", - "which", -] - -[[package]] -name = "protobuf-support" -version = "3.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5d4d7b8601c814cfb36bcebb79f0e61e45e1e93640cf778837833bbed05c372" -dependencies = [ - "thiserror", -] - -[[package]] -name = "qemu-display" -version = "0.1.0" -source = "git+https://github.com/rustdesk/qemu-display#e8a0925c2e804aa1eb07ee3027deaf8dd1c71b1d" -dependencies = [ - "async-broadcast 0.3.4", - "async-lock", - "async-trait", - "cfg-if", - "derivative", - "enumflags2", - "futures", - "futures-util", - "libc", - "log", - "once_cell", - "serde", - "serde_bytes", - "serde_repr", - "uds_windows", - "usbredirhost", - "windows", - "zbus", - "zvariant", -] - -[[package]] -name = "qemu-rustdesk" -version = "0.1.0" -dependencies = [ - "async-trait", - "clap", - "hbb_common", - "image", - "qemu-display", - "zbus", -] - -[[package]] -name = "quote" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8856d8364d252a14d474036ea1358d63c9e6965c8e5c1885c18f73d70bff9c7b" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" -dependencies = [ - "libc", - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom", -] - -[[package]] -name = "rayon" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db3a213adf02b3bcfd2d3846bb41cb22857d131789e01df434fb7e7bc0759b7" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b8f95bd6966f5c87776639160a66bd8ab9895d9d4ab01ddba9fc60661aebe8d" -dependencies = [ - "crossbeam-channel", - "crossbeam-deque", - "crossbeam-utils", - "num_cpus", -] - -[[package]] -name = "redox_syscall" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" -dependencies = [ - "bitflags 1.3.2", -] - -[[package]] -name = "redox_users" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b033d837a7cf162d7993aded9304e30a83213c648b6e389db233191f891e5c2b" -dependencies = [ - "getrandom", - "redox_syscall", - "thiserror", -] - -[[package]] -name = "regex" -version = "1.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48aaa5748ba571fb95cd2c85c09f629215d3a6ece942baa100950af03a34f733" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.6.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "456c603be3e8d448b072f410900c09faf164fbce2d480456f50eea6e25f9c848" - -[[package]] -name = "remove_dir_all" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7" -dependencies = [ - "winapi", -] - -[[package]] -name = "rusb" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703aa035c21c589b34fb5136b12e68fc8dcf7ea46486861381361dd8ebf5cee0" -dependencies = [ - "libc", - "libusb1-sys", -] - -[[package]] -name = "rustc-demangle" -version = "0.1.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef03e0a2b150c7a90d01faf6254c9c48a41e95fb2a8c2ac1c6f0d2b9aefc342" - -[[package]] -name = "rustix" -version = "0.36.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db4165c9963ab29e422d6c26fbc1d37f15bace6b2810221f9d925023480fcf0e" -dependencies = [ - "bitflags 1.3.2", - "errno", - "io-lifetimes", - "libc", - "linux-raw-sys", - "windows-sys 0.45.0", -] - -[[package]] -name = "ryu" -version = "1.0.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b4b9743ed687d4b4bcedf9ff5eaa7398495ae14e61cba0a295704edbc7decde" - -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "scoped_threadpool" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d51f5df5af43ab3f1360b429fa5e0152ac5ce8c0bd6485cae490332e96846a8" - -[[package]] -name = "scopeguard" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" - -[[package]] -name = "scratch" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddccb15bcce173023b3fedd9436f882a0739b8dfb45e4f6b6002bee5929f61b2" - -[[package]] -name = "serde" -version = "1.0.152" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb7d1f0d3021d347a83e556fc4683dea2ea09d87bccdf88ff5c12545d89d5efb" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde-xml-rs" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0bf1ba0696ccf0872866277143ff1fd14d22eec235d2b23702f95e6660f7dfa" -dependencies = [ - "log", - "serde", - "thiserror", - "xml-rs", -] - -[[package]] -name = "serde_bytes" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "416bda436f9aab92e02c8e10d49a15ddd339cea90b6e340fe51ed97abb548294" -dependencies = [ - "serde", -] - -[[package]] -name = "serde_derive" -version = "1.0.152" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af487d118eecd09402d70a5d72551860e788df87b464af30e5ea6a38c75c541e" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.93" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cad406b69c91885b5107daf2c29572f6c8cdb3c66826821e286c533490c0bc76" -dependencies = [ - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "serde_repr" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a5ec9fa74a20ebbe5d9ac23dac1fc96ba0ecfe9f50f2843b52e537b10fbcb4e" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "sha1" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1da05c97445caa12d05e848c4a4fcbbea29e748ac28f7e80e9b010392063770" -dependencies = [ - "sha1_smol", -] - -[[package]] -name = "sha1_smol" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae1a47186c03a32177042e55dbc5fd5aee900b8e0069a8d70fba96a9375cd012" - -[[package]] -name = "signal-hook-registry" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1" -dependencies = [ - "libc", -] - -[[package]] -name = "signature" -version = "1.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c" - -[[package]] -name = "simd-adler32" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "238abfbb77c1915110ad968465608b68e869e0772622c9656714e73e5a1a522f" - -[[package]] -name = "slab" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6528351c9bc8ab22353f9d776db39a20288e8d6c37ef8cfe3317cf875eecfc2d" -dependencies = [ - "autocfg", -] - -[[package]] -name = "smallvec" -version = "1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a507befe795404456341dfab10cef66ead4c041f62b8b11bbb92bffe5d0953e0" - -[[package]] -name = "socket2" -version = "0.3.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "122e570113d28d773067fab24266b66753f6ea915758651696b6e35e49f88d6e" -dependencies = [ - "cfg-if", - "libc", - "winapi", -] - -[[package]] -name = "socket2" -version = "0.4.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02e2d2db9033d13a1567121ddd7a095ee144db4e1ca1b1bda3419bc0da294ebd" -dependencies = [ - "libc", - "winapi", -] - -[[package]] -name = "sodiumoxide" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e26be3acb6c2d9a7aac28482586a7856436af4cfe7100031d219de2d2ecb0028" -dependencies = [ - "ed25519", - "libc", - "libsodium-sys", - "serde", -] - -[[package]] -name = "spin" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5d6e0250b93c8427a177b849d144a96d5acc57006149479403d7861ab721e34" -dependencies = [ - "lock_api", -] - -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - -[[package]] -name = "strsim" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" - -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "sysinfo" -version = "0.28.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f69e0d827cce279e61c2f3399eb789271a8f136d8245edef70f06e3c9601a670" -dependencies = [ - "cfg-if", - "core-foundation-sys", - "libc", - "ntapi", - "once_cell", - "rayon", - "winapi", -] - -[[package]] -name = "tempfile" -version = "3.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cdb1ef4eaeeaddc8fbd371e5017057064af0911902ef36b39801f67cc6d79e4" -dependencies = [ - "cfg-if", - "fastrand", - "libc", - "redox_syscall", - "remove_dir_all", - "winapi", -] - -[[package]] -name = "termcolor" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be55cf8942feac5c765c2c993422806843c9a9a45d4d5c407ad6dd2ea95eb9b6" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "thiserror" -version = "1.0.38" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a9cd18aa97d5c45c6603caea1da6628790b37f7a34b6ca89522331c5180fed0" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.38" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fb327af4685e4d03fa8cbcf1716380da910eeb2bb8be417e7f9fd3fb164f36f" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tiff" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7449334f9ff2baf290d55d73983a7d6fa15e01198faef72af07e2a8db851e471" -dependencies = [ - "flate2", - "jpeg-decoder", - "weezl", -] - -[[package]] -name = "time" -version = "0.1.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b797afad3f312d1c66a56d11d0316f916356d11bd158fbc6ca6389ff6bf805a" -dependencies = [ - "libc", - "wasi 0.10.0+wasi-snapshot-preview1", - "winapi", -] - -[[package]] -name = "tokio" -version = "1.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8e00990ebabbe4c14c08aca901caed183ecd5c09562a12c824bb53d3c3fd3af" -dependencies = [ - "autocfg", - "bytes", - "libc", - "memchr", - "mio", - "num_cpus", - "parking_lot", - "pin-project-lite", - "signal-hook-registry", - "socket2 0.4.7", - "tokio-macros", - "windows-sys 0.42.0", -] - -[[package]] -name = "tokio-macros" -version = "1.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d266c00fde287f55d3f1c3e96c500c362a2b8c695076ec180f27918820bc6df8" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tokio-socks" -version = "0.5.1-1" -source = "git+https://github.com/open-trade/tokio-socks#7034e79263ce25c348be072808d7601d82cd892d" -dependencies = [ - "bytes", - "either", - "futures-core", - "futures-sink", - "futures-util", - "pin-project", - "thiserror", - "tokio", - "tokio-util", -] - -[[package]] -name = "tokio-util" -version = "0.7.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5427d89453009325de0d8f342c9490009f76e999cb7672d77e46267448f7e6b2" -dependencies = [ - "bytes", - "futures-core", - "futures-io", - "futures-sink", - "futures-util", - "hashbrown", - "pin-project-lite", - "slab", - "tokio", - "tracing", -] - -[[package]] -name = "toml" -version = "0.5.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" -dependencies = [ - "serde", -] - -[[package]] -name = "toml_datetime" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4553f467ac8e3d374bc9a177a26801e5d0f9b211aa1673fb137a403afd1c9cf5" - -[[package]] -name = "toml_edit" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56c59d8dd7d0dcbc6428bf7aa2f0e823e26e43b3c9aca15bbc9475d23e5fa12b" -dependencies = [ - "indexmap", - "nom8", - "toml_datetime", -] - -[[package]] -name = "tracing" -version = "0.1.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ce8c33a8d48bd45d624a6e523445fd21ec13d3653cd51f681abf67418f54eb8" -dependencies = [ - "cfg-if", - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4017f8f45139870ca7e672686113917c71c7a6e02d4924eda67186083c03081a" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tracing-core" -version = "0.1.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24eb03ba0eab1fd845050058ce5e616558e8f8d8fca633e6b163fe25c797213a" -dependencies = [ - "once_cell", -] - -[[package]] -name = "uds_windows" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce65604324d3cce9b966701489fbd0cf318cb1f7bd9dd07ac9a4ee6fb791930d" -dependencies = [ - "tempfile", - "winapi", -] - -[[package]] -name = "unicode-ident" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84a22b9f218b40614adcb3f4ff08b703773ad44fa9423e4e0d346d5db86e4ebc" - -[[package]] -name = "unicode-width" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b" - -[[package]] -name = "usbredirhost" -version = "0.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87485e4dfeb0176203afd1086f11ed2ead837053143b12b6eed55c598e9393d5" -dependencies = [ - "libc", - "rusb", - "usbredirhost-sys", - "usbredirparser", -] - -[[package]] -name = "usbredirhost-sys" -version = "0.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b27c305da1f7601b665d68948bcfaf9909d443bec94510ab776118ab8afc2c7d" -dependencies = [ - "libusb1-sys", - "pkg-config", - "usbredirparser-sys", -] - -[[package]] -name = "usbredirparser" -version = "0.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0f8b5241d7cbb3e08b4677212a9ac001f116f50731c2737d16129a84ecf6a56" -dependencies = [ - "libc", - "usbredirparser-sys", -] - -[[package]] -name = "usbredirparser-sys" -version = "0.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8b0e834e187916fc762bccdc9d64e454a0ee58b134f8f7adab321141e8e0d91" -dependencies = [ - "pkg-config", -] - -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - -[[package]] -name = "version_check" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" - -[[package]] -name = "waker-fn" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d5b2c62b4012a3e1eca5a7e077d13b3bf498c4073e33ccd58626607748ceeca" - -[[package]] -name = "walkdir" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "808cf2735cd4b6866113f648b791c6adc5714537bc222d9347bb203386ffda56" -dependencies = [ - "same-file", - "winapi", - "winapi-util", -] - -[[package]] -name = "wasi" -version = "0.10.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" - -[[package]] -name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" - -[[package]] -name = "wasm-bindgen" -version = "0.2.84" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31f8dcbc21f30d9b8f2ea926ecb58f6b91192c17e9d33594b3df58b2007ca53b" -dependencies = [ - "cfg-if", - "wasm-bindgen-macro", -] - -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.84" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95ce90fd5bcc06af55a641a86428ee4229e44e07033963a2290a8e241607ccb9" -dependencies = [ - "bumpalo", - "log", - "once_cell", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.84" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c21f77c0bedc37fd5dc21f897894a5ca01e7bb159884559461862ae90c0b4c5" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.84" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2aff81306fcac3c7515ad4e177f521b5c9a15f2b08f4e32d823066102f35a5f6" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-backend", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.84" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0046fef7e28c3804e5e38bfa31ea2a0f73905319b677e57ebe37e49358989b5d" - -[[package]] -name = "weezl" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9193164d4de03a926d909d3bc7c30543cecb35400c02114792c2cae20d5e2dbb" - -[[package]] -name = "wepoll-ffi" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d743fdedc5c64377b5fc2bc036b01c7fd642205a0d96356034ae3404d49eb7fb" -dependencies = [ - "cc", -] - -[[package]] -name = "which" -version = "4.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2441c784c52b289a054b7201fc93253e288f094e2f4be9058343127c4226a269" -dependencies = [ - "either", - "libc", - "once_cell", -] - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-util" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" -dependencies = [ - "winapi", -] - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "windows" -version = "0.43.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04662ed0e3e5630dfa9b26e4cb823b817f1a9addda855d973a9458c236556244" -dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows-sys" -version = "0.42.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a3e1820f08b8513f676f7ab6c1f99ff312fb97b553d30ff4dd86f9f15728aa7" -dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-targets" -version = "0.42.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2522491fbfcd58cc84d47aeb2958948c4b8982e9a2d8a2a35bbaed431390e7" -dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c9864e83243fdec7fc9c5444389dcbbfd258f745e7853198f365e3c4968a608" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c8b1b673ffc16c47a9ff48570a9d85e25d265735c503681332589af6253c6c7" - -[[package]] -name = "windows_i686_gnu" -version = "0.42.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de3887528ad530ba7bdbb1faa8275ec7a1155a45ffa57c37993960277145d640" - -[[package]] -name = "windows_i686_msvc" -version = "0.42.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf4d1122317eddd6ff351aa852118a2418ad4214e6613a50e0191f7004372605" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1040f221285e17ebccbc2591ffdc2d44ee1f9186324dd3e84e99ac68d699c45" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "628bfdf232daa22b0d64fdb62b09fcc36bb01f05a3939e20ab73aaf9470d0463" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "447660ad36a13288b1db4d4248e857b510e8c3a225c822ba4fb748c0aafecffd" - -[[package]] -name = "winreg" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2986deb581c4fe11b621998a5e53361efe6b48a151178d0cd9eeffa4dc6acc9" -dependencies = [ - "winapi", -] - -[[package]] -name = "xml-rs" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2d7d3948613f75c98fd9328cfdcc45acc4d360655289d0a7d4ec931392200a3" - -[[package]] -name = "zbus" -version = "3.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41ce2de393c874ba871292e881bf3c13a0d5eb38170ebab2e50b4c410eaa222b" -dependencies = [ - "async-broadcast 0.4.1", - "async-channel", - "async-executor", - "async-io", - "async-lock", - "async-recursion", - "async-task", - "async-trait", - "byteorder", - "derivative", - "dirs", - "enumflags2", - "event-listener", - "futures-core", - "futures-sink", - "futures-util", - "hex", - "nix 0.24.3", - "once_cell", - "ordered-stream", - "rand", - "serde", - "serde-xml-rs", - "serde_repr", - "sha1", - "static_assertions", - "tracing", - "uds_windows", - "winapi", - "zbus_macros", - "zbus_names", - "zvariant", -] - -[[package]] -name = "zbus_macros" -version = "3.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a13d08f5dc6cf725b693cb6ceacd43cd430ec0664a879188f29e7d7dcd98f96d" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "regex", - "syn", -] - -[[package]] -name = "zbus_names" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f34f314916bd89bdb9934154627fab152f4f28acdda03e7c4c68181b214fe7e3" -dependencies = [ - "serde", - "static_assertions", - "zvariant", -] - -[[package]] -name = "zstd" -version = "0.9.2+zstd.1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2390ea1bf6c038c39674f22d95f0564725fc06034a47129179810b2fc58caa54" -dependencies = [ - "zstd-safe", -] - -[[package]] -name = "zstd-safe" -version = "4.1.3+zstd.1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e99d81b99fb3c2c2c794e3fe56c305c63d5173a16a46b5850b07c935ffc7db79" -dependencies = [ - "libc", - "zstd-sys", -] - -[[package]] -name = "zstd-sys" -version = "1.6.2+zstd.1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2daf2f248d9ea44454bfcb2516534e8b8ad2fc91bf818a1885495fc42bc8ac9f" -dependencies = [ - "cc", - "libc", -] - -[[package]] -name = "zune-inflate" -version = "0.2.51" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a01728b79fb9b7e28a8c11f715e1cd8dc2cda7416a007d66cac55cebb3a8ac6b" -dependencies = [ - "simd-adler32", -] - -[[package]] -name = "zvariant" -version = "3.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "903169c05b9ab948ee93fefc9127d08930df4ce031d46c980784274439803e51" -dependencies = [ - "byteorder", - "enumflags2", - "libc", - "serde", - "serde_bytes", - "static_assertions", - "zvariant_derive", -] - -[[package]] -name = "zvariant_derive" -version = "3.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cce76636e8fab7911be67211cf378c252b115ee7f2bae14b18b84821b39260b5" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn", -] diff --git a/vdi/host/Cargo.toml b/vdi/host/Cargo.toml deleted file mode 100644 index 0584b4690..000000000 --- a/vdi/host/Cargo.toml +++ /dev/null @@ -1,13 +0,0 @@ -[package] -name = "qemu-rustdesk" -version = "0.1.0" -authors = ["rustdesk "] -edition = "2021" - -[dependencies] -qemu-display = { git = "https://github.com/rustdesk/qemu-display" } -hbb_common = { path = "../../libs/hbb_common" } -clap = { version = "4.1", features = ["derive"] } -zbus = { version = "3.14.1" } -image = "0.24" -async-trait = "0.1" diff --git a/vdi/host/README.md b/vdi/host/README.md deleted file mode 100644 index 0283266bf..000000000 --- a/vdi/host/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# RustDesk protocol on QEMU D-Bus display - -``` -sudo apt install libusbredirparser-dev libusbredirhost-dev libusb-1.0-0-dev -``` diff --git a/vdi/host/src/connection.rs b/vdi/host/src/connection.rs deleted file mode 100644 index 9f856fa2e..000000000 --- a/vdi/host/src/connection.rs +++ /dev/null @@ -1,11 +0,0 @@ -use hbb_common::{message_proto::*, tokio, ResultType}; -pub use tokio::sync::{mpsc, Mutex}; -pub struct Connection { - pub tx: mpsc::UnboundedSender, -} - -impl Connection { - pub async fn on_message(&mut self, message: Message) -> ResultType { - Ok(true) - } -} diff --git a/vdi/host/src/console.rs b/vdi/host/src/console.rs deleted file mode 100644 index a342f1a9a..000000000 --- a/vdi/host/src/console.rs +++ /dev/null @@ -1,119 +0,0 @@ -use hbb_common::{tokio, ResultType}; -use image::GenericImage; -use qemu_display::{Console, ConsoleListenerHandler, MouseButton}; -use std::{collections::HashSet, sync::Arc}; -pub use tokio::sync::{mpsc, Mutex}; - -#[derive(Debug)] -pub enum Event { - ConsoleUpdate((i32, i32, i32, i32)), - Disconnected, -} - -const PIXMAN_X8R8G8B8: u32 = 0x20020888; -pub type BgraImage = image::ImageBuffer, Vec>; -#[derive(Debug)] -pub struct ConsoleListener { - pub image: Arc>, - pub tx: mpsc::UnboundedSender, -} - -#[async_trait::async_trait] -impl ConsoleListenerHandler for ConsoleListener { - async fn scanout(&mut self, s: qemu_display::Scanout) { - *self.image.lock().await = image_from_vec(s.format, s.width, s.height, s.stride, s.data); - } - - async fn update(&mut self, u: qemu_display::Update) { - let update = image_from_vec(u.format, u.w as _, u.h as _, u.stride, u.data); - let mut image = self.image.lock().await; - if (u.x, u.y) == (0, 0) && update.dimensions() == image.dimensions() { - *image = update; - } else { - image.copy_from(&update, u.x as _, u.y as _).unwrap(); - } - self.tx - .send(Event::ConsoleUpdate((u.x, u.y, u.w, u.h))) - .ok(); - } - - async fn scanout_dmabuf(&mut self, _scanout: qemu_display::ScanoutDMABUF) { - unimplemented!() - } - - async fn update_dmabuf(&mut self, _update: qemu_display::UpdateDMABUF) { - unimplemented!() - } - - async fn mouse_set(&mut self, set: qemu_display::MouseSet) { - dbg!(set); - } - - async fn cursor_define(&mut self, cursor: qemu_display::Cursor) { - dbg!(cursor); - } - - fn disconnected(&mut self) { - self.tx.send(Event::Disconnected).ok(); - } -} - -pub async fn key_event(console: &mut Console, qnum: u32, down: bool) -> ResultType<()> { - if down { - console.keyboard.press(qnum).await?; - } else { - console.keyboard.release(qnum).await?; - } - Ok(()) -} - -fn image_from_vec(format: u32, width: u32, height: u32, stride: u32, data: Vec) -> BgraImage { - if format != PIXMAN_X8R8G8B8 { - todo!("unhandled pixman format: {}", format) - } - if cfg!(target_endian = "big") { - todo!("pixman/image in big endian") - } - let layout = image::flat::SampleLayout { - channels: 4, - channel_stride: 1, - width, - width_stride: 4, - height, - height_stride: stride as _, - }; - let samples = image::flat::FlatSamples { - samples: data, - layout, - color_hint: None, - }; - samples - .try_into_buffer::>() - .or_else::<&str, _>(|(_err, samples)| { - let view = samples.as_view::>().unwrap(); - let mut img = BgraImage::new(width, height); - img.copy_from(&view, 0, 0).unwrap(); - Ok(img) - }) - .unwrap() -} - -fn button_mask_to_set(mask: u8) -> HashSet { - let mut set = HashSet::new(); - if mask & 0b0000_0001 != 0 { - set.insert(MouseButton::Left); - } - if mask & 0b0000_0010 != 0 { - set.insert(MouseButton::Middle); - } - if mask & 0b0000_0100 != 0 { - set.insert(MouseButton::Right); - } - if mask & 0b0000_1000 != 0 { - set.insert(MouseButton::WheelUp); - } - if mask & 0b0001_0000 != 0 { - set.insert(MouseButton::WheelDown); - } - set -} diff --git a/vdi/host/src/lib.rs b/vdi/host/src/lib.rs deleted file mode 100644 index e9f8d7ed3..000000000 --- a/vdi/host/src/lib.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod server; -mod console; -mod connection; diff --git a/vdi/host/src/main.rs b/vdi/host/src/main.rs deleted file mode 100644 index ea32a028a..000000000 --- a/vdi/host/src/main.rs +++ /dev/null @@ -1,6 +0,0 @@ -fn main() { - hbb_common::init_log(false, ""); - if let Err(err) = qemu_rustdesk::server::run() { - hbb_common::log::error!("{err}"); - } -} diff --git a/vdi/host/src/server.rs b/vdi/host/src/server.rs deleted file mode 100644 index b43bd364f..000000000 --- a/vdi/host/src/server.rs +++ /dev/null @@ -1,172 +0,0 @@ -use clap::Parser; -use hbb_common::{ - allow_err, - anyhow::{bail, Context}, - log, - message_proto::*, - protobuf::Message as _, - tokio, - tokio::net::TcpListener, - ResultType, Stream, -}; -use qemu_display::{Console, VMProxy}; -use std::{borrow::Borrow, sync::Arc}; - -use crate::connection::*; -use crate::console::*; - -#[derive(Parser, Debug)] -pub struct SocketAddrArgs { - /// IP address - #[clap(short, long, default_value = "0.0.0.0")] - address: std::net::IpAddr, - /// IP port number - #[clap(short, long, default_value = "21116")] - port: u16, -} - -impl From for std::net::SocketAddr { - fn from(args: SocketAddrArgs) -> Self { - (args.address, args.port).into() - } -} - -#[derive(Parser, Debug)] -struct Cli { - #[clap(flatten)] - address: SocketAddrArgs, - #[clap(short, long)] - dbus_address: Option, -} - -#[derive(Debug)] -struct Server { - vm_name: String, - rx_console: mpsc::UnboundedReceiver, - tx_console: mpsc::UnboundedSender, - rx_conn: mpsc::UnboundedReceiver, - tx_conn: mpsc::UnboundedSender, - image: Arc>, - console: Arc>, -} - -impl Server { - async fn new(vm_name: String, console: Console) -> ResultType { - let width = console.width().await?; - let height = console.height().await?; - let image = BgraImage::new(width as _, height as _); - let (tx_console, rx_console) = mpsc::unbounded_channel(); - let (tx_conn, rx_conn) = mpsc::unbounded_channel(); - Ok(Self { - vm_name, - rx_console, - tx_console, - rx_conn, - tx_conn, - image: Arc::new(Mutex::new(image)), - console: Arc::new(Mutex::new(console)), - }) - } - - async fn stop_console(&self) -> ResultType<()> { - self.console.lock().await.unregister_listener(); - Ok(()) - } - - async fn run_console(&self) -> ResultType<()> { - self.console - .lock() - .await - .register_listener(ConsoleListener { - image: self.image.clone(), - tx: self.tx_console.clone(), - }) - .await?; - Ok(()) - } - - async fn dimensions(&self) -> (u16, u16) { - let image = self.image.lock().await; - (image.width() as u16, image.height() as u16) - } - - async fn handle_connection(&mut self, stream: Stream) -> ResultType<()> { - let mut stream = stream; - self.run_console().await?; - let mut conn = Connection { - tx: self.tx_conn.clone(), - }; - - loop { - tokio::select! { - Some(evt) = self.rx_console.recv() => { - match evt { - _ => {} - } - } - Some(msg) = self.rx_conn.recv() => { - allow_err!(stream.send(&msg).await); - } - res = stream.next() => { - if let Some(res) = res { - match res { - Err(err) => { - bail!(err); - } - Ok(bytes) => { - if let Ok(msg_in) = Message::parse_from_bytes(&bytes) { - match conn.on_message(msg_in).await { - Ok(false) => { - break; - } - Err(err) => { - log::error!("{err}"); - } - _ => {} - } - } - } - } - } else { - bail!("Reset by the peer"); - } - } - } - } - - self.stop_console().await?; - Ok(()) - } -} - -#[tokio::main] -pub async fn run() -> ResultType<()> { - let args = Cli::parse(); - - let listener = TcpListener::bind::(args.address.into()) - .await - .unwrap(); - let dbus = if let Some(addr) = args.dbus_address { - zbus::ConnectionBuilder::address(addr.borrow())? - .build() - .await - } else { - zbus::Connection::session().await - } - .context("Failed to connect to DBus")?; - - let vm_name = VMProxy::new(&dbus).await?.name().await?; - let console = Console::new(&dbus.into(), 0) - .await - .context("Failed to get the console")?; - let mut server = Server::new(format!("qemu-rustdesk ({})", vm_name), console).await?; - loop { - let (stream, addr) = listener.accept().await?; - stream.set_nodelay(true).ok(); - let laddr = stream.local_addr()?; - let stream = Stream::from(stream, laddr); - if let Err(err) = server.handle_connection(stream).await { - log::error!("Connection from {addr} closed: {err}"); - } - } -}