Merge branch 'master' into master

This commit is contained in:
Andrzej Rudnik 2026-06-06 11:44:20 +02:00 committed by GitHub
commit f9cd229e8b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
175 changed files with 18712 additions and 6448 deletions

View file

@ -25,7 +25,7 @@ jobs:
}
steps:
- name: Checkout source code
uses: actions/checkout@v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
submodules: recursive
@ -49,25 +49,25 @@ jobs:
wget
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@v1
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
with:
toolchain: ${{ env.RUST_VERSION }}
targets: ${{ matrix.job.target }}
components: "rustfmt"
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
prefix-key: bridge-${{ matrix.job.os }}
- name: Cache Bridge
id: cache-bridge
uses: actions/cache@v3
uses: actions/cache@6f8efc29b200d32929f49075959781ed54ec270c # v3
with:
path: /tmp/flutter_rust_bridge
key: vcpkg-${{ matrix.job.arch }}
- name: Install flutter
uses: subosito/flutter-action@v2
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
with:
channel: "stable"
flutter-version: ${{ env.FLUTTER_VERSION }}
@ -86,7 +86,7 @@ jobs:
cp ./flutter/macos/Runner/bridge_generated.h ./flutter/ios/Runner/bridge_generated.h
- name: Upload Artifact
uses: actions/upload-artifact@master
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: bridge-artifact
path: |

View file

@ -29,13 +29,13 @@ jobs:
# name: Ensure 'cargo fmt' has been run
# runs-on: ubuntu-20.04
# steps:
# - uses: actions-rs/toolchain@v1
# - uses: actions-rs/toolchain@16499b5e05bf2e26879000db0c1d13f7e13fa3af # v1
# with:
# toolchain: stable
# default: true
# profile: minimal
# components: rustfmt
# - uses: actions/checkout@v3
# - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
# - run: cargo fmt -- --check
# min_version:
@ -43,24 +43,24 @@ jobs:
# runs-on: ubuntu-20.04
# steps:
# - name: Checkout source code
# uses: actions/checkout@v3
# uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
# with:
# submodules: recursive
# - name: Install rust toolchain (v${{ env.MIN_SUPPORTED_RUST_VERSION }})
# uses: actions-rs/toolchain@v1
# uses: actions-rs/toolchain@16499b5e05bf2e26879000db0c1d13f7e13fa3af # v1
# with:
# toolchain: ${{ env.MIN_SUPPORTED_RUST_VERSION }}
# default: true
# profile: minimal # minimal component installation (ie, no documentation)
# components: clippy
# - name: Run clippy (on minimum supported rust version to prevent warnings we can't fix)
# uses: actions-rs/cargo@v1
# uses: actions-rs/cargo@844f36862e911db73fe0815f00a4a2602c279505 # v1
# with:
# command: clippy
# args: --locked --all-targets --all-features -- --allow clippy::unknown_clippy_lints
# - name: Run tests
# uses: actions-rs/cargo@v1
# uses: actions-rs/cargo@844f36862e911db73fe0815f00a4a2602c279505 # v1
# with:
# command: test
# args: --locked
@ -86,9 +86,9 @@ jobs:
steps:
- name: Free Disk Space (Ubuntu)
if: runner.os == 'Linux'
# jlumbroso/free-disk-space@main is used in .github\workflows\flutter-build.yml
# jlumbroso/free-disk-space@v1.3.1 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
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1
with:
tool-cache: false
android: true
@ -99,14 +99,14 @@ jobs:
swap-storage: false
- name: Export GitHub Actions cache environment variables
uses: actions/github-script@v6
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
with:
script: |
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
- name: Checkout source code
uses: actions/checkout@v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
submodules: recursive
@ -145,7 +145,7 @@ jobs:
esac
- name: Setup vcpkg with Github Actions binary cache
uses: lukka/run-vcpkg@v11
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
with:
vcpkgDirectory: /opt/artifacts/vcpkg
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
@ -156,7 +156,7 @@ jobs:
shell: bash
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@v1
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
with:
toolchain: stable
targets: ${{ matrix.job.target }}
@ -172,10 +172,10 @@ jobs:
cargo -V
rustc -V
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
- name: Build
uses: actions-rs/cargo@v1
uses: actions-rs/cargo@844f36862e911db73fe0815f00a4a2602c279505 # v1
with:
use-cross: ${{ matrix.job.use-cross }}
command: build
@ -243,7 +243,7 @@ jobs:
echo "CARGO_TEST_OPTIONS=${CARGO_TEST_OPTIONS}" >> $GITHUB_OUTPUT
- name: Run tests
uses: actions-rs/cargo@v1
uses: actions-rs/cargo@844f36862e911db73fe0815f00a4a2602c279505 # v1
with:
use-cross: ${{ matrix.job.use-cross }}
command: test

View file

@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Clear cache
uses: actions/github-script@v7
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
with:
script: |
console.log("About to clear")
@ -30,7 +30,7 @@ jobs:
console.log("Clear completed")
- name: Purge cache # Above seems not clear thouroughly, so add this to double clear
uses: MyAlbum/purge-cache@v2
uses: MyAlbum/purge-cache@881eb5957687193fa612bf74c0042adc78ea5e54 # v2
with:
accessed: true # Purge caches by their last accessed time (default)
created: false # Purge caches by their created time (default)

View file

@ -31,7 +31,7 @@ jobs:
shell: bash
- name: Publish RustDesk version file
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
with:
prerelease: true
tag_name: "fdroid-version"

View file

@ -39,7 +39,7 @@ env:
# 2. Update the `VCPKG_COMMIT_ID` in `ci.yml` and `playground.yml`.
VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b"
ARMV7_VCPKG_COMMIT_ID: "6f29f12e82a8293156836ad81cc9bf5af41fe836" # 2025.01.13, got "/opt/artifacts/vcpkg/vcpkg: No such file or directory" with latest version
VERSION: "1.4.6"
VERSION: "1.4.7"
NDK_VERSION: "r28c"
#signing keys env variable checks
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"
@ -81,30 +81,30 @@ jobs:
# - { target: aarch64-pc-windows-msvc, os: windows-2022, arch: aarch64 }
steps:
- name: Export GitHub Actions cache environment variables
uses: actions/github-script@v6
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
with:
script: |
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
- name: Checkout source code
uses: actions/checkout@v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
submodules: recursive
- name: Restore bridge files
uses: actions/download-artifact@master
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: bridge-artifact
path: ./
- name: Install LLVM and Clang
uses: KyleMayes/install-llvm-action@v1
uses: KyleMayes/install-llvm-action@1a3da29f56261a1e1f937ec88f0856a9b8321d7e # v1
with:
version: ${{ env.LLVM_VERSION }}
- name: Install flutter
uses: subosito/flutter-action@v2.12.0 #https://github.com/subosito/flutter-action/issues/277
uses: subosito/flutter-action@2783a3f08e1baf891508463f8c6653c258246225 # v2.12.0; https://github.com/subosito/flutter-action/issues/277
with:
channel: "stable"
flutter-version: ${{ env.FLUTTER_VERSION }}
@ -126,18 +126,18 @@ jobs:
[[ "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
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
with:
toolchain: ${{ env.SCITER_RUST_VERSION }}
targets: ${{ matrix.job.target }}
components: "rustfmt"
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
prefix-key: ${{ matrix.job.os }}
- name: Setup vcpkg with Github Actions binary cache
uses: lukka/run-vcpkg@v11
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
with:
vcpkgDirectory: C:\vcpkg
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
@ -220,7 +220,7 @@ jobs:
fi
- name: Download RustDeskTempTopMostWindow artifacts
uses: actions/download-artifact@master
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
if: ${{ inputs.upload-artifact }}
with:
name: topmostwindow-artifacts
@ -228,7 +228,7 @@ jobs:
- name: Upload unsigned
if: env.UPLOAD_ARTIFACT == 'true'
uses: actions/upload-artifact@master
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rustdesk-unsigned-windows-${{ matrix.job.arch }}
path: rustdesk
@ -253,7 +253,7 @@ jobs:
mv ./target/release/rustdesk-portable-packer.exe ./SignOutput/rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.exe
- name: Add MSBuild to PATH
uses: microsoft/setup-msbuild@v2
uses: microsoft/setup-msbuild@6fb02220983dee41ce7ae257b6f4d8f9bf5ed4ce # v2
- name: Build msi
if: env.UPLOAD_ARTIFACT == 'true'
@ -272,7 +272,7 @@ jobs:
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
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
if: env.UPLOAD_ARTIFACT == 'true'
with:
prerelease: true
@ -302,35 +302,35 @@ jobs:
# - { target: aarch64-pc-windows-msvc, os: windows-2022 }
steps:
- name: Export GitHub Actions cache environment variables
uses: actions/github-script@v6
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
with:
script: |
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
- name: Checkout source code
uses: actions/checkout@v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
submodules: recursive
- name: Install LLVM and Clang
uses: rustdesk-org/install-llvm-action-32bit@master
uses: rustdesk-org/install-llvm-action-32bit@6aa7d9ad3df84dff01cd4596dd0fc880a7f47fce # no release tag; commit 2026-05-26
with:
version: ${{ env.LLVM_VERSION }}
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@v1
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
with:
toolchain: nightly-2023-10-13-${{ matrix.job.target }} # must use nightly here, because of abi_thiscall feature required
targets: ${{ matrix.job.target }}
components: "rustfmt"
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
prefix-key: ${{ matrix.job.os }}-sciter
- name: Setup vcpkg with Github Actions binary cache
uses: lukka/run-vcpkg@v11
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
with:
vcpkgDirectory: C:\vcpkg
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
@ -363,7 +363,8 @@ jobs:
python3 res/inline-sciter.py
# Patch sciter x86
sed -i 's/branch = "dyn"/branch = "dyn_x86"/g' ./Cargo.toml
cargo build --features inline,vram,hwcodec --release --bins
cargo update -p sciter-rs --precise 674e07d3066ca9a92ced3816203ab6b652629d1e
cargo build --locked --features inline,vram,hwcodec --release --bins
mkdir -p ./Release
mv ./target/release/rustdesk.exe ./Release/rustdesk.exe
curl -LJ -o ./Release/sciter.dll https://github.com/c-smile/sciter-sdk/raw/master/bin.win/x32/sciter.dll
@ -394,7 +395,7 @@ jobs:
- name: Upload unsigned
if: env.UPLOAD_ARTIFACT == 'true'
uses: actions/upload-artifact@master
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rustdesk-unsigned-windows-${{ matrix.job.arch }}
path: Release
@ -424,7 +425,7 @@ jobs:
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
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
if: env.UPLOAD_ARTIFACT == 'true'
with:
prerelease: true
@ -449,7 +450,7 @@ jobs:
}
steps:
- name: Export GitHub Actions cache environment variables
uses: actions/github-script@v6
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
with:
script: |
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
@ -459,12 +460,12 @@ jobs:
run: |
brew install nasm yasm
- name: Checkout source code
uses: actions/checkout@v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
submodules: recursive
- name: Install flutter
uses: subosito/flutter-action@v2
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
with:
channel: "stable"
flutter-version: ${{ env.FLUTTER_VERSION }}
@ -475,7 +476,7 @@ jobs:
[[ "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
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
with:
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
doNotCache: false
@ -499,19 +500,19 @@ jobs:
shell: bash
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@v1
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
with:
toolchain: ${{ env.RUST_VERSION }}
targets: ${{ matrix.job.target }}
components: "rustfmt"
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
prefix-key: rustdesk-lib-cache-ios
key: ${{ matrix.job.target }}
- name: Restore bridge files
uses: actions/download-artifact@master
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: bridge-artifact
path: ./
@ -519,10 +520,10 @@ jobs:
- name: Build rustdesk lib
run: |
rustup target add ${{ matrix.job.target }}
cargo build --features flutter,hwcodec --release --target aarch64-apple-ios --lib
cargo build --locked --features flutter,hwcodec --release --target aarch64-apple-ios --lib
- name: Upload liblibrustdesk.a Artifacts
uses: actions/upload-artifact@master
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: liblibrustdesk.a
path: target/aarch64-apple-ios/release/liblibrustdesk.a
@ -537,14 +538,14 @@ jobs:
# - name: Upload Artifacts
# # if: env.ANDROID_SIGNING_KEY != null && env.UPLOAD_ARTIFACT == 'true'
# uses: actions/upload-artifact@master
# uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
# 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
# uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
# with:
# prerelease: true
# tag_name: ${{ env.TAG_NAME }}
@ -577,20 +578,20 @@ jobs:
}
steps:
- name: Export GitHub Actions cache environment variables
uses: actions/github-script@v6
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
with:
script: |
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
- name: Checkout source code
uses: actions/checkout@v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
submodules: recursive
- name: Import the codesign cert
if: env.MACOS_P12_BASE64 != null
uses: apple-actions/import-codesign-certs@v1
uses: apple-actions/import-codesign-certs@253ddeeac23f2bdad1646faac5c8c2832e800071 # v1
with:
p12-file-base64: ${{ secrets.MACOS_P12_BASE64 }}
p12-password: ${{ secrets.MACOS_P12_PASSWORD }}
@ -604,7 +605,7 @@ jobs:
- name: Import notarize key
if: env.MACOS_P12_BASE64 != null
uses: timheuer/base64-to-file@v1.2
uses: timheuer/base64-to-file@adaa40c0c581f276132199d4cf60afa07ce60eac # v1.2
with:
# https://gregoryszorc.com/docs/apple-codesign/stable/apple_codesign_rcodesign.html#notarizing-and-stapling
fileName: rustdesk.json
@ -643,7 +644,7 @@ jobs:
nasm --version
- name: Install flutter
uses: subosito/flutter-action@v2
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
with:
channel: "stable"
flutter-version: ${{ env.FLUTTER_VERSION }}
@ -662,24 +663,24 @@ jobs:
grep -n '_setFramesEnabledState(false);' ../packages/flutter/lib/src/scheduler/binding.dart
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@v1
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
with:
toolchain: ${{ env.MAC_RUST_VERSION }}
targets: ${{ matrix.job.target }}
components: "rustfmt"
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
prefix-key: ${{ matrix.job.os }}
- name: Restore bridge files
uses: actions/download-artifact@master
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: bridge-artifact
path: ./
- name: Setup vcpkg with Github Actions binary cache
uses: lukka/run-vcpkg@v11
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
with:
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
doNotCache: false
@ -731,7 +732,7 @@ jobs:
- name: Upload unsigned macOS app
if: env.UPLOAD_ARTIFACT == 'true'
uses: actions/upload-artifact@master
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rustdesk-unsigned-macos-${{ matrix.job.arch }}
path: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.dmg # can not upload the directory directly or tar.gz, which destroy the link structure, causing the codesign failed
@ -763,7 +764,7 @@ jobs:
- name: Publish DMG package
if: env.UPLOAD_ARTIFACT == 'true'
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
with:
prerelease: true
tag_name: ${{ env.TAG_NAME }}
@ -779,25 +780,25 @@ jobs:
if: ${{ inputs.upload-artifact }}
steps:
- name: Download artifacts
uses: actions/download-artifact@master
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: rustdesk-unsigned-macos-x86_64
path: ./
- name: Download Artifacts
uses: actions/download-artifact@master
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: rustdesk-unsigned-macos-aarch64
path: ./
- name: Download Artifacts
uses: actions/download-artifact@master
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: rustdesk-unsigned-windows-x86_64
path: ./windows-x86_64/
- name: Download Artifacts
uses: actions/download-artifact@master
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: rustdesk-unsigned-windows-x86
path: ./windows-x86/
@ -807,7 +808,7 @@ jobs:
tar czf rustdesk-${{ env.VERSION }}-unsigned.tar.gz *.dmg windows-x86_64 windows-x86
- name: Publish unsigned app
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
with:
prerelease: true
tag_name: ${{ env.TAG_NAME }}
@ -844,7 +845,7 @@ jobs:
}
steps:
- name: Free Disk Space (Ubuntu)
uses: jlumbroso/free-disk-space@main
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1
with:
tool-cache: false
android: false
@ -855,7 +856,7 @@ jobs:
swap-storage: false
- name: Export GitHub Actions cache environment variables
uses: actions/github-script@v6
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
with:
script: |
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
@ -897,12 +898,12 @@ jobs:
wget
- name: Checkout source code
uses: actions/checkout@v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
submodules: recursive
- name: Install flutter
uses: subosito/flutter-action@v2
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
with:
channel: "stable"
flutter-version: ${{ env.ANDROID_FLUTTER_VERSION }}
@ -912,14 +913,14 @@ jobs:
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
- uses: nttld/setup-ndk@ed92fe6cadad69be94a966a7ee3271275e62f779 # v1
id: setup-ndk
with:
ndk-version: ${{ env.NDK_VERSION }}
add-to-path: true
- name: Setup vcpkg with Github Actions binary cache
uses: lukka/run-vcpkg@v11
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
with:
vcpkgDirectory: /opt/artifacts/vcpkg
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
@ -954,18 +955,18 @@ jobs:
shell: bash
- name: Restore bridge files
uses: actions/download-artifact@master
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: bridge-artifact
path: ./
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@v1
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
with:
toolchain: ${{ env.RUST_VERSION }}
components: "rustfmt"
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
prefix-key: rustdesk-lib-cache-android # TODO: drop '-android' part after caches are invalidated
key: ${{ matrix.job.target }}
@ -1001,7 +1002,7 @@ jobs:
esac
- name: Upload Rustdesk library to Artifacts
uses: actions/upload-artifact@master
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: librustdesk.so.${{ matrix.job.target }}
path: ./target/${{ matrix.job.target }}/release/liblibrustdesk.so
@ -1066,7 +1067,7 @@ jobs:
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
- uses: r0adkll/sign-android-release@349ebdef58775b1e0d8099458af0816dc79b6407 # v1
name: Sign app APK
if: env.ANDROID_SIGNING_KEY != null
id: sign-rustdesk
@ -1082,14 +1083,14 @@ jobs:
- name: Upload Artifacts
if: env.ANDROID_SIGNING_KEY != null && env.UPLOAD_ARTIFACT == 'true'
uses: actions/upload-artifact@master
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.apk
path: ${{steps.sign-rustdesk.outputs.signedReleaseFile}}
- name: Publish signed apk package
if: env.ANDROID_SIGNING_KEY != null && env.UPLOAD_ARTIFACT == 'true'
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
with:
prerelease: true
tag_name: ${{ env.TAG_NAME }}
@ -1098,7 +1099,7 @@ jobs:
- name: Publish unsigned apk package
if: env.ANDROID_SIGNING_KEY == null && env.UPLOAD_ARTIFACT == 'true'
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
with:
prerelease: true
tag_name: ${{ env.TAG_NAME }}
@ -1116,7 +1117,7 @@ jobs:
suffix: ""
steps:
- name: Free Disk Space (Ubuntu)
uses: jlumbroso/free-disk-space@main
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1
with:
tool-cache: false
android: false
@ -1127,7 +1128,7 @@ jobs:
swap-storage: false
- name: Export GitHub Actions cache environment variables
uses: actions/github-script@v6
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
with:
script: |
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
@ -1169,12 +1170,12 @@ jobs:
wget
- name: Checkout source code
uses: actions/checkout@v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
submodules: recursive
- name: Install flutter
uses: subosito/flutter-action@v2
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
with:
channel: "stable"
flutter-version: ${{ env.ANDROID_FLUTTER_VERSION }}
@ -1185,32 +1186,32 @@ jobs:
[[ "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
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: bridge-artifact
path: ./
- name: Download Rustdesk library from Artifacts
uses: actions/download-artifact@master
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: librustdesk.so.aarch64-linux-android
path: ./flutter/android/app/src/main/jniLibs/arm64-v8a
- name: Download Rustdesk library from Artifacts
uses: actions/download-artifact@master
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: librustdesk.so.armv7-linux-androideabi
path: ./flutter/android/app/src/main/jniLibs/armeabi-v7a
- name: Download Rustdesk library from Artifacts
uses: actions/download-artifact@master
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: librustdesk.so.x86_64-linux-android
path: ./flutter/android/app/src/main/jniLibs/x86_64
- name: Download Rustdesk library from Artifacts
if: ${{ env.reltype == 'debug' }}
uses: actions/download-artifact@master
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: librustdesk.so.i686-linux-android
path: ./flutter/android/app/src/main/jniLibs/x86
@ -1250,7 +1251,7 @@ jobs:
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
- uses: r0adkll/sign-android-release@349ebdef58775b1e0d8099458af0816dc79b6407 # v1
name: Sign app APK
if: env.ANDROID_SIGNING_KEY != null
id: sign-rustdesk
@ -1266,14 +1267,14 @@ jobs:
- name: Upload Artifacts
if: env.ANDROID_SIGNING_KEY != null && env.UPLOAD_ARTIFACT == 'true'
uses: actions/upload-artifact@master
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.apk
path: ${{steps.sign-rustdesk.outputs.signedReleaseFile}}
- name: Publish signed apk package
if: env.ANDROID_SIGNING_KEY != null && env.UPLOAD_ARTIFACT == 'true'
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
with:
prerelease: true
tag_name: ${{ env.TAG_NAME }}
@ -1282,7 +1283,7 @@ jobs:
- name: Publish unsigned apk package
if: env.ANDROID_SIGNING_KEY == null && env.UPLOAD_ARTIFACT == 'true'
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
with:
prerelease: true
tag_name: ${{ env.TAG_NAME }}
@ -1316,7 +1317,7 @@ jobs:
}
steps:
- name: Export GitHub Actions cache environment variables
uses: actions/github-script@v6
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
with:
script: |
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
@ -1334,13 +1335,13 @@ jobs:
fi
- name: Checkout source code
uses: actions/checkout@v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
submodules: recursive
- name: Set Swap Space
if: ${{ matrix.job.arch == 'x86_64' }}
uses: pierotofy/set-swap-space@master
uses: pierotofy/set-swap-space@49819abfb41bd9b44fb781159c033dba90353a7c # v1.0
with:
swap-size-gb: 12
@ -1350,7 +1351,7 @@ jobs:
free -m
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@v1
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
if: matrix.job.arch == 'x86_64' || env.UPLOAD_ARTIFACT == 'true'
with:
toolchain: ${{ env.RUST_VERSION }}
@ -1369,14 +1370,14 @@ jobs:
- name: Restore bridge files
if: matrix.job.arch == 'x86_64' || env.UPLOAD_ARTIFACT == 'true'
uses: actions/download-artifact@master
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: bridge-artifact
path: ./
- name: Setup vcpkg with Github Actions binary cache
if: matrix.job.arch == 'x86_64' || env.UPLOAD_ARTIFACT == 'true'
uses: lukka/run-vcpkg@v11
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
with:
vcpkgDirectory: /opt/artifacts/vcpkg
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
@ -1404,12 +1405,12 @@ jobs:
- name: Restore bridge files
if: matrix.job.arch == 'x86_64' || env.UPLOAD_ARTIFACT == 'true'
uses: actions/download-artifact@master
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: bridge-artifact
path: ./
- uses: rustdesk-org/run-on-arch-action@amd64-support
- uses: rustdesk-org/run-on-arch-action@d3fcfbb632b84cf7f6bc772bfaaa2c2f4f8789a8 # no release tag; commit 2026-05-26
name: Build rustdesk
id: vcpkg
if: matrix.job.arch == 'x86_64' || env.UPLOAD_ARTIFACT == 'true'
@ -1491,7 +1492,7 @@ jobs:
export JOBS=""
fi
echo $JOBS
cargo build --lib $JOBS --features hwcodec,flutter,unix-file-copy-paste --release
cargo build --locked --lib $JOBS --features hwcodec,flutter,unix-file-copy-paste --release
rm -rf target/release/deps target/release/build
rm -rf ~/.cargo
@ -1583,7 +1584,7 @@ jobs:
- name: Publish debian/rpm package
if: env.UPLOAD_ARTIFACT == 'true'
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
with:
prerelease: true
tag_name: ${{ env.TAG_NAME }}
@ -1592,7 +1593,7 @@ jobs:
rustdesk-*.rpm
- name: Upload deb
uses: actions/upload-artifact@master
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: env.UPLOAD_ARTIFACT == 'true'
with:
name: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.deb
@ -1611,7 +1612,7 @@ jobs:
- name: Build archlinux package
if: matrix.job.arch == 'x86_64' && env.UPLOAD_ARTIFACT == 'true'
uses: rustdesk-org/arch-makepkg-action@master
uses: rustdesk-org/arch-makepkg-action@04200739ed1d0bf6f2188b6736b26a767c57a7f9 # no release tag; commit 2026-05-26
with:
packages:
scripts: |
@ -1619,7 +1620,7 @@ jobs:
- name: Publish archlinux package
if: matrix.job.arch == 'x86_64' && env.UPLOAD_ARTIFACT == 'true'
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
with:
prerelease: true
tag_name: ${{ env.TAG_NAME }}
@ -1657,14 +1658,14 @@ jobs:
}
steps:
- name: Export GitHub Actions cache environment variables
uses: actions/github-script@v6
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
with:
script: |
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
- name: Checkout source code
uses: actions/checkout@v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
submodules: recursive
@ -1682,7 +1683,7 @@ jobs:
free -m
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@v1
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
with:
toolchain: ${{ env.SCITER_RUST_VERSION }}
targets: ${{ matrix.job.target }}
@ -1693,7 +1694,7 @@ jobs:
RUST_TOOLCHAIN_VERSION=$(cargo --version | awk '{print $2}')
echo "RUST_TOOLCHAIN_VERSION=$RUST_TOOLCHAIN_VERSION" >> $GITHUB_ENV
- uses: rustdesk-org/run-on-arch-action@amd64-support
- uses: rustdesk-org/run-on-arch-action@d3fcfbb632b84cf7f6bc772bfaaa2c2f4f8789a8 # no release tag; commit 2026-05-26
name: Build rustdesk sciter binary for ${{ matrix.job.arch }}
id: vcpkg
with:
@ -1821,7 +1822,7 @@ jobs:
# build rustdesk
python3 ./res/inline-sciter.py
export CARGO_INCREMENTAL=0
cargo build --features inline${{ matrix.job.extra_features }} --release --bins --jobs 1
cargo build --locked --features inline${{ matrix.job.extra_features }} --release --bins --jobs 1
# make debian package
mkdir -p ./Release
mv ./target/release/rustdesk ./Release/rustdesk
@ -1839,7 +1840,7 @@ jobs:
- name: Publish debian package
if: env.UPLOAD_ARTIFACT == 'true'
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
with:
prerelease: true
tag_name: ${{ env.TAG_NAME }}
@ -1847,7 +1848,7 @@ jobs:
rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}-sciter.deb
- name: Upload deb
uses: actions/upload-artifact@master
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: env.UPLOAD_ARTIFACT == 'true'
with:
name: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}-sciter.deb
@ -1866,12 +1867,12 @@ jobs:
- { target: aarch64-unknown-linux-gnu, arch: aarch64 }
steps:
- name: Checkout source code
uses: actions/checkout@v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
submodules: recursive
- name: Download Binary
uses: actions/download-artifact@master
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.deb
path: .
@ -1896,7 +1897,7 @@ jobs:
- name: Publish appimage package
if: env.UPLOAD_ARTIFACT == 'true'
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
with:
prerelease: true
tag_name: ${{ env.TAG_NAME }}
@ -1939,12 +1940,12 @@ jobs:
}
steps:
- name: Checkout source code
uses: actions/checkout@v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
submodules: recursive
- name: Download Binary
uses: actions/download-artifact@master
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}${{ matrix.job.suffix }}.deb
path: .
@ -1953,7 +1954,7 @@ jobs:
run: |
mv rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}${{ matrix.job.suffix }}.deb flatpak/rustdesk.deb
- uses: rustdesk-org/run-on-arch-action@amd64-support
- uses: rustdesk-org/run-on-arch-action@d3fcfbb632b84cf7f6bc772bfaaa2c2f4f8789a8 # no release tag; commit 2026-05-26
name: Build rustdesk flatpak package for ${{ matrix.job.arch }}
id: flatpak
with:
@ -1981,7 +1982,7 @@ jobs:
flatpak build-bundle ./repo rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}${{ matrix.job.suffix }}.flatpak com.rustdesk.RustDesk
- name: Publish flatpak package
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
with:
prerelease: true
tag_name: ${{ env.TAG_NAME }}
@ -2000,7 +2001,7 @@ jobs:
RELEASE_NAME: web-basic
steps:
- name: Checkout source code
uses: actions/checkout@v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
submodules: recursive
@ -2010,7 +2011,7 @@ jobs:
sudo apt-get install -y wget npm
- name: Install flutter
uses: subosito/flutter-action@v2.12.0 #https://github.com/subosito/flutter-action/issues/277
uses: subosito/flutter-action@2783a3f08e1baf891508463f8c6653c258246225 # v2.12.0; https://github.com/subosito/flutter-action/issues/277
with:
channel: "stable"
flutter-version: ${{ env.FLUTTER_VERSION }}
@ -2054,7 +2055,7 @@ jobs:
- name: Publish web
if: env.UPLOAD_ARTIFACT == 'true'
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
with:
prerelease: true
tag_name: ${{ env.TAG_NAME }}

View file

@ -17,7 +17,7 @@ env:
TAG_NAME: "nightly"
VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite"
VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b"
VERSION: "1.4.6"
VERSION: "1.4.7"
NDK_VERSION: "r26d"
#signing keys env variable checks
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"
@ -79,21 +79,21 @@ jobs:
}
steps:
- name: Export GitHub Actions cache environment variables
uses: actions/github-script@v6
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
with:
script: |
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
- name: Checkout source code
uses: actions/checkout@v3
uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # 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
uses: apple-actions/import-codesign-certs@253ddeeac23f2bdad1646faac5c8c2832e800071 # v1
with:
p12-file-base64: ${{ secrets.MACOS_P12_BASE64 }}
p12-password: ${{ secrets.MACOS_P12_PASSWORD }}
@ -107,7 +107,7 @@ jobs:
- name: Import notarize key
if: env.MACOS_P12_BASE64 != null
uses: timheuer/base64-to-file@v1.2
uses: timheuer/base64-to-file@adaa40c0c581f276132199d4cf60afa07ce60eac # v1.2
with:
# https://gregoryszorc.com/docs/apple-codesign/stable/apple_codesign_rcodesign.html#notarizing-and-stapling
fileName: rustdesk.json
@ -129,19 +129,19 @@ jobs:
brew install llvm create-dmg nasm pkg-config
- name: Install flutter
uses: subosito/flutter-action@v2
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
with:
channel: "stable"
flutter-version: ${{ matrix.job.flutter }}
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@v1
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
with:
toolchain: ${{ env.RUST_VERSION }}
targets: ${{ matrix.job.target }}
components: "rustfmt"
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
prefix-key: ${{ matrix.job.os }}
@ -156,7 +156,7 @@ jobs:
~/.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: Setup vcpkg with Github Actions binary cache
uses: lukka/run-vcpkg@v11
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
with:
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
@ -165,7 +165,7 @@ jobs:
$VCPKG_ROOT/vcpkg install --x-install-root="$VCPKG_ROOT/installed"
- name: Restore from cache and install vcpkg
uses: lukka/run-vcpkg@v7
uses: lukka/run-vcpkg@8a5116de2b552d6fc8894e9774aacaf2e5db4823 # v7 2026-05-26
if: false
with:
setupOnly: true
@ -222,7 +222,7 @@ jobs:
done
- name: Publish DMG package
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
with:
prerelease: true
tag_name: ${{ env.TAG_NAME }}
@ -247,7 +247,7 @@ jobs:
}
steps:
- name: Checkout source code
uses: actions/checkout@v3
uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
with:
ref: ${{ matrix.job.ref }}
submodules: recursive
@ -290,13 +290,13 @@ jobs:
wget
- name: Install flutter
uses: subosito/flutter-action@v2
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
with:
channel: "stable"
flutter-version: ${{ env.FLUTTER_VERSION }}
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@v1
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
with:
toolchain: ${{ env.RUST_VERSION }}
components: "rustfmt"
@ -310,14 +310,14 @@ jobs:
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
- uses: nttld/setup-ndk@v1
- uses: nttld/setup-ndk@ed92fe6cadad69be94a966a7ee3271275e62f779 # v1
id: setup-ndk
with:
ndk-version: ${{ env.NDK_VERSION }}
add-to-path: true
- name: Setup vcpkg with Github Actions binary cache
uses: lukka/run-vcpkg@v11
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
with:
vcpkgDirectory: /opt/artifacts/vcpkg
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
@ -395,7 +395,7 @@ jobs:
mkdir -p signed-apk; pushd signed-apk
mv ../rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.apk ./rustdesk-test-${{ matrix.job.ref }}-${{ matrix.job.ndk }}.apk
- uses: r0adkll/sign-android-release@v1
- uses: r0adkll/sign-android-release@349ebdef58775b1e0d8099458af0816dc79b6407 # v1
name: Sign app APK
if: env.ANDROID_SIGNING_KEY != null
id: sign-rustdesk
@ -410,7 +410,7 @@ jobs:
BUILD_TOOLS_VERSION: "30.0.2"
- name: Publish signed apk package
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
with:
prerelease: true
tag_name: ${{ env.TAG_NAME }}

View file

@ -39,7 +39,7 @@ jobs:
build_output_dir: RustDeskTempTopMostWindow/WindowInjection/${{ inputs.platform }}/${{ inputs.configuration }}
steps:
- name: Add MSBuild to PATH
uses: microsoft/setup-msbuild@v2
uses: microsoft/setup-msbuild@6fb02220983dee41ce7ae257b6f4d8f9bf5ed4ce # v2
- name: Download the source code
run: |
@ -52,7 +52,7 @@ jobs:
msbuild ${{ env.project_path }} -p:Configuration=${{ inputs.configuration }} -p:Platform=${{ inputs.platform }} /p:TargetVersion=${{ inputs.target_version }}
- name: Archive build artifacts
uses: actions/upload-artifact@master
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: ${{ inputs.upload-artifact }}
with:
name: topmostwindow-artifacts

85
.github/workflows/wf-cliprdr-ci.yml vendored Normal file
View file

@ -0,0 +1,85 @@
name: wf-cliprdr CI
on:
workflow_dispatch:
pull_request:
paths:
- "libs/clipboard/src/windows/**"
- "tests/test_invariant_wf_cliprdr.c"
- ".github/workflows/wf-cliprdr-ci.yml"
push:
branches:
- master
paths:
- "libs/clipboard/src/windows/**"
- "tests/test_invariant_wf_cliprdr.c"
- ".github/workflows/wf-cliprdr-ci.yml"
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
name: wf_cliprdr invariant test
runs-on: windows-2022
steps:
- name: Checkout source code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
- name: Set up MSVC
uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756
with:
arch: x64
- name: Setup vcpkg with GitHub Actions binary cache
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
with:
vcpkgDirectory: C:\vcpkg
doNotCache: false
- name: Install vcpkg dependency
shell: pwsh
run: |
& "$env:VCPKG_ROOT\vcpkg.exe" install check:x64-windows --classic --x-install-root="$env:VCPKG_ROOT\installed"
- name: Build test
shell: pwsh
run: |
$testRoot = Join-Path $env:GITHUB_WORKSPACE 'build\wf-cliprdr'
New-Item -ItemType Directory -Force $testRoot | Out-Null
$testSource = (($env:GITHUB_WORKSPACE -replace '\\', '/') + '/tests/test_invariant_wf_cliprdr.c')
$cmakeLists = @(
'cmake_minimum_required(VERSION 3.20)'
'project(test_invariant_wf_cliprdr C)'
''
'set(CMAKE_C_STANDARD 11)'
'set(CMAKE_C_STANDARD_REQUIRED ON)'
'set(CMAKE_C_EXTENSIONS OFF)'
''
'find_package(check CONFIG REQUIRED)'
''
'add_executable(test_invariant_wf_cliprdr'
' "TEST_SOURCE"'
')'
''
'target_link_libraries(test_invariant_wf_cliprdr PRIVATE'
' $<$<TARGET_EXISTS:Check::check>:Check::check>'
' $<$<NOT:$<TARGET_EXISTS:Check::check>>:Check::checkShared>'
')'
) -join [Environment]::NewLine
$cmakeLists.Replace('TEST_SOURCE', $testSource) | Set-Content -NoNewline (Join-Path $testRoot 'CMakeLists.txt')
cmake -S $testRoot -B (Join-Path $testRoot 'out') -G "Visual Studio 17 2022" -A x64 -DCMAKE_TOOLCHAIN_FILE="$env:VCPKG_ROOT\scripts\buildsystems\vcpkg.cmake" -DVCPKG_TARGET_TRIPLET=x64-windows
cmake --build (Join-Path $testRoot 'out') --config Release
- name: Run test
shell: pwsh
run: .\build\wf-cliprdr\out\Release\test_invariant_wf_cliprdr.exe

146
AGENTS.md
View file

@ -1,47 +1,18 @@
# RustDesk Guide
# RustDesk Guide
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Development Commands
### Build Commands
- `cargo run` - Build and run the desktop application (requires libsciter library)
- `python3 build.py --flutter` - Build Flutter version (desktop)
- `python3 build.py --flutter --release` - Build Flutter version in release mode
- `python3 build.py --hwcodec` - Build with hardware codec support
- `python3 build.py --vram` - Build with VRAM feature (Windows only)
- `cargo build --release` - Build Rust binary in release mode
- `cargo build --features hwcodec` - Build with specific features
### Flutter Mobile Commands
- `cd flutter && flutter build android` - Build Android APK
- `cd flutter && flutter build ios` - Build iOS app
- `cd flutter && flutter run` - Run Flutter app in development mode
- `cd flutter && flutter test` - Run Flutter tests
### Testing
- `cargo test` - Run Rust tests
- `cd flutter && flutter test` - Run Flutter tests
### Platform-Specific Build Scripts
- `flutter/build_android.sh` - Android build script
- `flutter/build_ios.sh` - iOS build script
- `flutter/build_fdroid.sh` - F-Droid build script
## Project Architecture
## Project Layout
### Directory Structure
- **`src/`** - Main Rust application code
- `src/ui/` - Legacy Sciter UI (deprecated, use Flutter instead)
- `src/server/` - Audio/clipboard/input/video services and network connections
- `src/client.rs` - Peer connection handling
- `src/platform/` - Platform-specific code
- **`flutter/`** - Flutter UI code for desktop and mobile
- **`libs/`** - Core libraries
- `libs/hbb_common/` - Video codec, config, network wrapper, protobuf, file transfer utilities
- `libs/scrap/` - Screen capture functionality
- `libs/enigo/` - Platform-specific keyboard/mouse control
- `libs/clipboard/` - Cross-platform clipboard implementation
* `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
@ -57,50 +28,59 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- Mobile: `flutter/lib/mobile/`
- Shared: `flutter/lib/common/` and `flutter/lib/models/`
## Important Build Notes
### Dependencies
- Requires vcpkg for C++ dependencies: `libvpx`, `libyuv`, `opus`, `aom`
- Set `VCPKG_ROOT` environment variable
- Download appropriate Sciter library for legacy UI support
### Ignore Patterns
When working with files, ignore these directories:
- `target/` - Rust build artifacts
- `flutter/build/` - Flutter build output
- `flutter/.dart_tool/` - Flutter tooling files
### Cross-Platform Considerations
- Windows builds require additional DLLs and virtual display drivers
- macOS builds need proper signing and notarization for distribution
- Linux builds support multiple package formats (deb, rpm, AppImage)
- Mobile builds require platform-specific toolchains (Android SDK, Xcode)
### Feature Flags
- `hwcodec` - Hardware video encoding/decoding
- `vram` - VRAM optimization (Windows only)
- `flutter` - Enable Flutter UI
- `unix-file-copy-paste` - Unix file clipboard support
- `screencapturekit` - macOS ScreenCaptureKit (macOS only)
### Config
All configurations or options are under `libs/hbb_common/src/config.rs` file, 4 types:
- Settings
- Local
- Display
- Built-in
## Rust Rules
- In Rust code, do not introduce `unwrap()` or `expect()`.
- Allowed exceptions:
- Tests may use `unwrap()` or `expect()` when it keeps the test focused and readable.
- Lock acquisition may use `unwrap()` only when the locking API makes that the practical option and the failure mode is poison handling rather than normal control flow.
- Outside those exceptions, propagate errors, handle them explicitly, or use safer fallbacks instead of `unwrap()` and `expect()`.
* 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
- Do not introduce formatting-only changes.
- Do not run repository-wide formatters or reflow unrelated code unless the
user explicitly asks for formatting.
- Keep diffs limited to semantic changes required for the task.
* 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.
## Localization (`src/lang/*.rs`)
Each file is a `HashMap<key, translation>`. Layout:
* `template.rs` is the master list of every key. **Never edit it** as part of translation work.
* `en.rs` holds only the keys whose English display text differs from the key itself.
* Every other file (`de.rs`, `fr.rs`, …) carries the full key set; an untranslated entry has an empty value: `("key", "")`.
### Finding the English source for a key
When filling an empty entry, determine the source English text with this rule:
* If `key` exists in `en.rs` **with a non-empty value**, that value is the source text (look it up in `en.rs`).
* Otherwise the **key string itself is the source text** (the key is already plain English).
Then translate that source into the file's target language (infer the language from the file's existing non-empty entries / filename).
### Translation hygiene
* Only fill empty values. Never change keys, and never touch existing non-empty translations.
* Preserve placeholders (`{}`) and escape sequences (`\n`, `\"`) exactly as in the source.
* Do not translate brand or technical tokens: `RustDesk`, `Socks5`, `TLS`, `UAC`, `Wayland`, `X11`, `TCP`, `UDP`, `2FA`, `RDP`, `D3D`, etc.
* Copy URL values (e.g. `doc_*` keys) verbatim from `en.rs`.

8
Cargo.lock generated
View file

@ -5996,8 +5996,8 @@ dependencies = [
[[package]]
name = "parity-tokio-ipc"
version = "0.7.3-5"
source = "git+https://github.com/rustdesk-org/parity-tokio-ipc#c8c8bbcbabf9be1201c53afb0269b92b9b02d291"
version = "0.7.3-6"
source = "git+https://github.com/rustdesk-org/parity-tokio-ipc#d0ae39bffe5d5a3e8d82a1b6bcb1ca5a9b2f1c01"
dependencies = [
"futures",
"libc",
@ -7270,7 +7270,7 @@ dependencies = [
[[package]]
name = "rustdesk"
version = "1.4.6"
version = "1.4.7"
dependencies = [
"android-wakelock",
"android_logger",
@ -7385,7 +7385,7 @@ dependencies = [
[[package]]
name = "rustdesk-portable-packer"
version = "1.4.6"
version = "1.4.7"
dependencies = [
"brotli",
"dirs 5.0.1",

View file

@ -1,6 +1,6 @@
[package]
name = "rustdesk"
version = "1.4.6"
version = "1.4.7"
authors = ["rustdesk <info@rustdesk.com>"]
edition = "2021"
build= "build.rs"
@ -245,3 +245,6 @@ panic = 'abort'
strip = true
#opt-level = 'z' # only have smaller size after strip
rpath = true
[profile.dev]
debug = 1

View file

@ -18,7 +18,7 @@ AppDir:
id: rustdesk
name: rustdesk
icon: rustdesk
version: 1.4.6
version: 1.4.7
exec: usr/share/rustdesk/rustdesk
exec_args: $@
apt:

View file

@ -18,7 +18,7 @@ AppDir:
id: rustdesk
name: rustdesk
icon: rustdesk
version: 1.4.6
version: 1.4.7
exec: usr/share/rustdesk/rustdesk
exec_args: $@
apt:

View file

@ -172,7 +172,7 @@ def generate_build_script_for_docker():
# flutter_rust_bridge
dart pub global activate ffigen --version 5.0.1
pushd /tmp && git clone https://github.com/SoLongAndThanksForAllThePizza/flutter_rust_bridge --depth=1 && popd
pushd /tmp/flutter_rust_bridge/frb_codegen && cargo install --path . && popd
pushd /tmp/flutter_rust_bridge/frb_codegen && cargo install --path . --locked && popd
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
# install vcpkg
@ -299,7 +299,7 @@ Version: %s
Architecture: %s
Maintainer: rustdesk <info@rustdesk.com>
Homepage: https://rustdesk.com
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
Depends: libgtk-3-0t64 | libgtk-3-0, libxcb-randr0, libxdo3 | libxdo4, libxfixes3, libxcb-shape0, libxcb-xfixes0, libasound2t64 | libasound2, libsystemd0, curl, libva2, libva-drm2, libva-x11-2, libgstreamer-plugins-base1.0-0, libpam0g, gstreamer1.0-pipewire%s
Recommends: libayatana-appindicator3-1
Description: A remote control software.
@ -317,7 +317,7 @@ def ffi_bindgen_function_refactor():
def build_flutter_deb(version, features):
if not skip_cargo:
system2(f'cargo build --features {features} --lib --release')
system2(f'cargo build --locked --features {features} --lib --release')
ffi_bindgen_function_refactor()
os.chdir('flutter')
system2('flutter build linux --release')
@ -405,7 +405,7 @@ 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} --release')
f'MACOSX_DEPLOYMENT_TARGET=10.14 cargo build --locked --features {features} --release')
# copy dylib
system2(
"cp target/release/liblibrustdesk.dylib target/release/librustdesk.dylib")
@ -422,7 +422,7 @@ def build_flutter_dmg(version, features):
def build_flutter_arch_manjaro(version, features):
if not skip_cargo:
system2(f'cargo build --features {features} --lib --release')
system2(f'cargo build --locked --features {features} --lib --release')
ffi_bindgen_function_refactor()
os.chdir('flutter')
system2('flutter build linux --release')
@ -433,7 +433,7 @@ def build_flutter_arch_manjaro(version, features):
def build_flutter_windows(version, features, skip_portable_pack):
if not skip_cargo:
system2(f'cargo build --features {features} --lib --release')
system2(f'cargo build --locked --features {features} --lib --release')
if not os.path.exists("target/release/librustdesk.dll"):
print("cargo build failed, please check rust source code.")
exit(-1)
@ -489,13 +489,13 @@ def main():
if windows:
# build virtual display dynamic library
os.chdir('libs/virtual_display/dylib')
system2('cargo build --release')
system2('cargo build --locked --release')
os.chdir('../../..')
if flutter:
build_flutter_windows(version, features, args.skip_portable_pack)
return
system2('cargo build --release --features ' + features)
system2('cargo build --locked --release --features ' + features)
# system2('upx.exe target/release/rustdesk.exe')
system2('mv target/release/rustdesk.exe target/release/RustDesk.exe')
pa = os.environ.get('P')
@ -512,14 +512,14 @@ 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)
if flutter:
build_flutter_arch_manjaro(version, features)
else:
system2('cargo build --release --features ' + features)
system2('cargo build --locked --release --features ' + features)
system2('git checkout src/ui/common.tis')
system2('strip target/release/rustdesk')
system2('ln -s res/pacman_install && ln -s res/PKGBUILD')
@ -528,7 +528,7 @@ def main():
version, version))
# pacman -U ./rustdesk.pkg.tar.zst
elif os.path.isfile('/usr/bin/yum'):
system2('cargo build --release --features ' + features)
system2('cargo build --locked --release --features ' + features)
system2('strip target/release/rustdesk')
system2(
"sed -i 's/Version: .*/Version: %s/g' res/rpm.spec" % version)
@ -538,7 +538,7 @@ def main():
version, version))
# yum localinstall rustdesk.rpm
elif os.path.isfile('/usr/bin/zypper'):
system2('cargo build --release --features ' + features)
system2('cargo build --locked --release --features ' + features)
system2('strip target/release/rustdesk')
system2(
"sed -i 's/Version: .*/Version: %s/g' res/rpm-suse.spec" % version)
@ -557,7 +557,7 @@ def main():
# 'mv target/release/bundle/deb/rustdesk*.deb ./flutter/rustdesk.deb')
build_flutter_deb(version, features)
else:
system2('cargo bundle --release --features ' + features)
system2('cargo --locked bundle --release --features ' + features)
if osx:
system2(
'strip target/release/bundle/osx/RustDesk.app/Contents/MacOS/rustdesk')

143
docs/CODE_OF_CONDUCT-FR.md Normal file
View file

@ -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

55
docs/CONTRIBUTING-FR.md Normal file
View file

@ -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).

View file

@ -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

View file

@ -1,10 +1,10 @@
<p align="center">
<img src="../res/logo-header.svg" alt="RustDesk - Your remote desktop"><br>
<a href="#빌드를 위한 원시 단계">빌드</a> •
<a href="#Docker로 빌드하는 방법">Docker</a> •
<a href="#파일 구조">구조</a> •
<a href="#스크린샷">스샷</a><br>
[<a href="../README.md">English</a>] | [<a href="README-UA.md">Українська</a>] | [<a href="README-CS.md">česky</a>] | [<a href="README-ZH.md">中文</a>] | [<a href="README-HU.md">Magyar</a>] | [<a href="README-ES.md">Español</a>] | [<a href="README-FA.md">فارسی</a>] | [<a href="README-FR.md">Français</a>] | [<a href="README-DE.md">Deutsch</a>] | [<a href="README-PL.md">Polski</a>] | [<a href="README-ID.md">Indonesian</a>] | [<a href="README-FI.md">Suomi</a>] | [<a href="README-ML.md">മലയാളം</a>] | [<a href="README-JP.md">日本語</a>] | [<a href="README-NL.md">Nederlands</a>] | [<a href="README-IT.md">Italiano</a>] | [<a href="README-RU.md">Русский</a>] | [<a href="README-PTBR.md">Português (Brasil)</a>] | [<a href="README-EO.md">Esperanto</a>] | [<a href="README-KR.md">한국어</a>] | [<a href="README-AR.md">العربي</a>] | [<a href="README-VN.md">Tiếng Việt</a>] | [<a href="README-DA.md">Dansk</a>] | [<a href="README-GR.md">Ελληνικά</a>] | [<a href="README-TR.md">Türkçe</a>] | [<a href="README-NO.md">Norsk</a>]<br>
<a href="#빌드를_위한_원시_단계">빌드</a> •
<a href="#Docker로_빌드하는_방법">Docker</a> •
<a href="#파일_구조">구조</a> •
<a href="#스크린샷">스샷</a><br>
[<a href="../README.md">English</a>] | [<a href="README-UA.md">Українська</a>] | [<a href="README-CS.md">česky</a>] | [<a href="README-ZH.md">中文</a>] | [<a href="README-HU.md">Magyar</a>] | [<a href="README-ES.md">Español</a>] | [<a href="README-FA.md">فارسی</a>] | [<a href="README-FR.md">Français</a>] | [<a href="README-DE.md">Deutsch</a>] | [<a href="README-PL.md">Polski</a>] | [<a href="README-ID.md">Indonesian</a>] | [<a href="README-FI.md">Suomi</a>] | [<a href="README-ML.md">മലയാളം</a>] | [<a href="README-JP.md">日本語</a>] | [<a href="README-NL.md">Nederlands</a>] | [<a href="README-IT.md">Italiano</a>] | [<a href="README-RU.md">Русский</a>] | [<a href="README-PTBR.md">Português (Brasil)</a>] | [<a href="README-EO.md">Esperanto</a>] | [<a href="README-KR.md">한국어</a>] | [<a href="README-AR.md">العربي</a>] | [<a href="README-VN.md">Tiếng Việt</a>] | [<a href="README-DA.md">Dansk</a>] | [<a href="README-GR.md">Ελληνικά</a>] | [<a href="README-TR.md">Türkçe</a>] | [<a href="README-NO.md">Norsk</a>] | [<a href="README-RO.md">Română</a>]<br>
<b>이 README, <a href="https://github.com/rustdesk/rustdesk/tree/master/src/lang">RustDesk UI</a> 및 <a href="https://github.com/rustdesk/doc.rustdesk.com">RustDesk 문서</a>를 귀하의 모국어로 번역하는 데 도움이 필요합니다</b>
</p>
@ -46,9 +46,9 @@ Sciter 동적 라이브러리를 직접 다운로드하세요.
[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)
## 빌드를 위한 원시 단계
## 빌드를_위한_원시_단계
- Rust 개발 환경과 C++ 빌드 환경 준비합니다
- Rust 개발 환경과 C++ 빌드 환경 준비
- [vcpkg](https://github.com/microsoft/vcpkg)를 설치하고 `VCPKG_ROOT` 환경 변수를 올바르게 설정합니다
@ -125,7 +125,7 @@ mv libsciter-gtk.so target/debug
VCPKG_ROOT=$HOME/vcpkg cargo run
```
## Docker로 빌드하는 방법
## Docker로_빌드하는_방법
먼저 리포지토리를 복제하고 Docker 컨테이너를 빌드합니다:
@ -156,7 +156,7 @@ target/release/rustdesk
RustDesk 리포지토리의 루트에서 이러한 명령을 실행하고 있는지 확인하세요. 그렇지 않으면 응용 프로그램이 필요한 리소스를 찾지 못할 수 있습니다. 또한 `install` 또는 `run` 과 같은 다른 cargo 하위 명령은 호스트가 아닌 컨테이너 내부에 프로그램을 설치하거나 실행하므로 현재 이 방법을 통해 지원되지 않는다는 점에 유의하세요.
## 파일 구조
## 파일_구조
- **[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)**: 화면 캡쳐

View file

@ -1,55 +1,82 @@
<p align="center">
<img src="../res/logo-header.svg" alt="RustDesk - Seu desktop remoto"><br>
<a href="#servidores-públicos-grátis">Servidores</a> •
<a href="#compilação-crua">Compilar</a> •
<a href="#como-compilar-com-docker">Docker</a> •
<a href="#compilar">Compilar</a> •
<a href="#como-compilar-com-o-docker">Docker</a> •
<a href="#estrutura-de-arquivos">Estrutura</a> •
<a href="#screenshots">Screenshots</a><br>
[<a href="../README.md">English</a>] | [<a href="README-UA.md">Українська</a>] | [<a href="README-CS.md">česky</a>] | [<a href="README-ZH.md">中文</a>] | [<a href="README-HU.md">Magyar</a>] | [<a href="README-ES.md">Español</a>] | [<a href="README-FA.md">فارسی</a>] | [<a href="README-FR.md">Français</a>] | [<a href="README-DE.md">Deutsch</a>] | [<a href="README-PL.md">Polski</a>] | [<a href="README-ID.md">Indonesian</a>] | [<a href="README-FI.md">Suomi</a>] | [<a href="README-ML.md">മലയാളം</a>] | [<a href="README-JP.md">日本語</a>] | [<a href="README-NL.md">Nederlands</a>] | [<a href="README-IT.md">Italiano</a>] | [<a href="README-RU.md">Русский</a>] | [<a href="README-EO.md">Esperanto</a>] | [<a href="README-KR.md">한국어</a>] | [<a href="README-AR.md">العربي</a>] | [<a href="README-VN.md">Tiếng Việt</a>] | [<a href="README-GR.md">Ελληνικά</a>]<br>
<b>Precisamos de sua ajuda para traduzir este README e a <a href="https://github.com/rustdesk/rustdesk/tree/master/src/lang">UI do RustDesk</a> para sua língua nativa</b>
<a href="#capturas-de-tela">Capturas de Tela</a><br>
[<a href="../README.md">Inglês</a>] | [<a href="docs/README-UA.md">Ucraniano</a>] | [<a href="docs/README-CS.md">Tcheco</a>] | [<a href="docs/README-ZH.md">Chinês</a>] | [<a href="docs/README-HU.md">Húngaro</a>] | [<a href="docs/README-ES.md">Espanhol</a>] | [<a href="docs/README-FA.md">Persa</a>] | [<a href="docs/README-FR.md">Francês</a>] | [<a href="docs/README-DE.md">Alemão</a>] | [<a href="docs/README-PL.md">Polonês</a>] | [<a href="docs/README-ID.md">Indonésio</a>] | [<a href="docs/README-FI.md">Finlandês</a>] | [<a href="docs/README-ML.md">Malaiala</a>] | [<a href="docs/README-JP.md">Japonês</a>] | [<a href="docs/README-NL.md">Holandês</a>] | [<a href="docs/README-IT.md">Italiano</a>] | [<a href="docs/README-RU.md">Russo</a>] | [<a href="docs/README-EO.md">Esperanto</a>] | [<a href="docs/README-KR.md">Coreano</a>] | [<a href="docs/README-AR.md">Árabe</a>] | [<a href="docs/README-VN.md">Vietnamita</a>] | [<a href="docs/README-DA.md">Dinamarquês</a>] | [<a href="docs/README-GR.md">Grego</a>] | [<a href="docs/README-TR.md">Turco</a>] | [<a href="docs/README-NO.md">Norueguês</a>] | [<a href="docs/README-RO.md">Romeno</a>]<br>
<b>Precisamos da sua ajuda para traduzir este README, a <a href="https://github.com/rustdesk/rustdesk/tree/master/src/lang">Interface do RustDesk</a> e a <a href="https://github.com/rustdesk/doc.rustdesk.com">Documentação do RustDesk</a> para o seu idioma nativo</b>
</p>
> [!Caution]
> **Aviso de Isenção de Responsabilidade por Uso Indevido:** <br>
> Os desenvolvedores do RustDesk não toleram ou apoiam qualquer uso antiético ou ilegal deste software. O uso indevido, como acesso não autorizado, controle ou invasão de privacidade, viola estritamente nossas diretrizes. Os autores não são responsáveis por qualquer uso indevido do aplicativo.
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)
[![RustDesk Server Pro](https://img.shields.io/badge/RustDesk%20Server%20Pro-Recursos%20Avan%C3%A7ados-blue)](https://rustdesk.com/pricing.html)
[![RustDesk Server Pro](https://img.shields.io/badge/RustDesk%20Server%20Pro-Advanced%20Features-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).
Mais uma solução de desktop remoto, escrita em Rust. Funciona imediatamente, sem necessidade de configuração. Você tem controle total dos seus dados, sem preocupações com segurança. Você pode usar nosso servidor de conexão/retransmissão (rendezvous/relay), [configurar o seu próprio](https://rustdesk.com/server) ou [escrever seu próprio servidor de conexão/retransmissão](https://github.com/rustdesk/rustdesk-server-demo).
RustDesk acolhe contribuições de todos. Leia [`docs/CONTRIBUTING.md`](CONTRIBUTING.md) para ver como começar.
![image](https://user-images.githubusercontent.com/71636191/171661982-430285f0-2e12-4b1d-9957-4a58e375304d.png)
[**DOWNLOAD DE BINÁRIOS**](https://github.com/rustdesk/rustdesk/releases)
O RustDesk acolhe a contribuição de todos. Veja [CONTRIBUTING.md](docs/CONTRIBUTING.md) para ajuda em como começar.
[**Perguntas Frequentes (FAQ)**](https://github.com/rustdesk/rustdesk/wiki/FAQ)
[**DOWNLOAD DOS BINÁRIOS**](https://github.com/rustdesk/rustdesk/releases)
[**VERSÕES NIGHTLY (EM DESENVOLVIMENTO)**](https://github.com/rustdesk/rustdesk/releases/tag/nightly)
[<img src="https://f-droid.org/badge/get-it-on.png"
alt="Baixe no F-Droid"
height="80">](https://f-droid.org/en/packages/com.carriez.flutter_hbb)
[<img src="https://flathub.org/api/badge?svg&locale=en"
alt="Baixe no Flathub"
height="80">](https://flathub.org/apps/com.rustdesk.RustDesk)
## Dependências
Versões de desktop utilizam [sciter](https://sciter.com/) para a GUI, por favor baixe a biblioteca dinâmica sciter por conta própria.
As versões de desktop usam Flutter ou Sciter (descontinuado) para a interface gráfica (GUI). Este tutorial é apenas para o Sciter, por ser mais fácil e amigável para começar. Verifique nosso [CI](https://github.com/rustdesk/rustdesk/blob/master/.github/workflows/flutter-build.yml) para instruções de compilação da versão em Flutter.
Por favor, faça o download da biblioteca dinâmica do Sciter por conta própria.
[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)
## Compilação crua
## Passos básicos para compilar
- Prepare seu ambiente de desenvolvimento Rust e ambiente de compilação C++
- Prepare seu ambiente de desenvolvimento Rust e o ambiente de compilação C++
- Instale [vcpkg](https://github.com/microsoft/vcpkg), e configure a variável de ambiente `VCPKG_ROOT` corretamente
- Instale o [vcpkg](https://github.com/microsoft/vcpkg) e configure a variável de ambiente `VCPKG_ROOT` corretamente
- 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
- 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`
- Execute `cargo run`
## Como compilar no Linux
## [Compilar](https://rustdesk.com/docs/en/dev/build/)
## Como Compilar no 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)
@ -58,7 +85,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
```
### Instale vcpkg
### Instalar o vcpkg
```sh
git clone https://github.com/microsoft/vcpkg
@ -70,7 +97,7 @@ export VCPKG_ROOT=$HOME/vcpkg
vcpkg/vcpkg install libvpx libyuv opus aom
```
### Conserte libvpx (Para o Fedora)
### Corrigir o libvpx (Para Fedora)
```sh
cd vcpkg/buildtrees/libvpx/src
@ -83,12 +110,12 @@ cp libvpx.a $HOME/vcpkg/installed/x64-linux/lib/
cd
```
### Compile
### Compilar
```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
@ -96,57 +123,57 @@ mv libsciter-gtk.so target/debug
VCPKG_ROOT=$HOME/vcpkg cargo run
```
## Como compilar com Docker
## Como compilar com o Docker
Comece clonando o repositório e montando o container docker:
Comece clonando o repositório e construindo o contêiner Docker:
```sh
git clone https://github.com/rustdesk/rustdesk
cd rustdesk
git submodule update --init --recursive
docker build -t "rustdesk-builder" .
```
Então, sempre que precisar compilar a aplicação, execute este comando:
Depois, cada vez que precisar compilar o aplicativo, execute o seguinte 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
```
Note que a primeira compilação pode demorar mais antes que as dependências sejam armazenadas em cache, as compilações subsequentes serão mais rápidas. Adicionalmente, se você precisar especificar argumentos diferentes para o comando de compilação, você pode fazê-lo ao final do comando na posição do `<OPTIONAL-ARGS>`. Por exemplo, se você gostaria de compilar uma versão de release otimizada, você executaria o comando acima seguido de `--release`. O executável gerado estará disponível no diretório alvo no seu sistema, e pode ser executado com:
Note que a primeira compilação pode demorar mais até que as dependências sejam armazenadas em cache; as compilações subsequentes serão mais rápidas. Além disso, se você precisar especificar argumentos diferentes para o comando de compilação, poderá fazê-lo ao final do comando na posição `<ARGUMENTOS-OPCIONAIS>`. Por exemplo, se você quiser compilar uma versão de lançamento (release) otimizada, executaria o comando acima seguido de `--release`. O executável resultante estará disponível na pasta `target` do seu sistema e pode ser executado com:
```sh
target/debug/rustdesk
```
Ou, se estiver rodando um executável de release:
Ou, se estiver executando o executável de lançamento:
```sh
target/release/rustdesk
```
Por favor verifique que está executando estes comandos da raiz do repositório do RustDesk, senão a aplicação pode não encontrar os recursos necessários. Note também que outros subcomandos do cargo como `install` ou `run` não são suportados atualmente via este método, já que eles iriam instalar ou rodar o programa dentro do container ao invés do host.
Certifique-se de executar esses comandos a partir da raiz do repositório do RustDesk, do contrário o aplicativo pode não encontrar os recursos necessários. Note também que outros subcomandos do cargo, como `install` ou `run`, não são suportados atualmente por este método, pois instalariam ou executariam o programa dentro do contêiner em vez de no sistema hospedeiro.
## Estrutura de arquivos
## Estrutura de Arquivos
- **[libs/hbb_common](https://github.com/rustdesk/rustdesk/tree/master/libs/hbb_common)**: codec de vídeo, configurações, wrapper de tcp/udp, protobuf, funções de sistema de arquivos para transferência de arquivos, e outras funções utilitárias
- **[libs/scrap](https://github.com/rustdesk/rustdesk/tree/master/libs/scrap)**: captura de tela
- **[libs/enigo](https://github.com/rustdesk/rustdesk/tree/master/libs/enigo)**: controle de teclado/mouse específico a cada plataforma
- **[src/ui](https://github.com/rustdesk/rustdesk/tree/master/src/ui)**: GUI
- **[src/server](https://github.com/rustdesk/rustdesk/tree/master/src/server)**: serviços de áudio/área de transferência/entrada/vídeo, e conexões de rede
- **[src/client.rs](https://github.com/rustdesk/rustdesk/tree/master/src/client.rs)**: iniciar uma conexão "peer to peer"
- **[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
- **[libs/hbb_common](https://github.com/rustdesk/rustdesk/tree/master/libs/hbb_common)**: codec de vídeo, configuração, encapsulador (wrapper) tcp/udp, protobuf, funções de sistema de arquivos para transferência de arquivos e algumas outras funções utilitárias.
- **[libs/scrap](https://github.com/rustdesk/rustdesk/tree/master/libs/scrap)**: captura de tela.
- **[libs/enigo](https://github.com/rustdesk/rustdesk/tree/master/libs/enigo)**: controle de teclado/mouse específico de cada plataforma.
- **[libs/clipboard](https://github.com/rustdesk/rustdesk/tree/master/libs/clipboard)**: implementação de copiar e colar arquivos para Windows, Linux e macOS.
- **[src/ui](https://github.com/rustdesk/rustdesk/tree/master/src/ui)**: interface Sciter antiga (descontinuada).
- **[src/server](https://github.com/rustdesk/rustdesk/tree/master/src/server)**: serviços de áudio/área de transferência/entrada/vídeo e conexões de rede.
- **[src/client.rs](https://github.com/rustdesk/rustdesk/tree/master/src/client.rs)**: inicia uma conexão direta (peer connection).
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Comunica-se com o [rustdesk-server](https://github.com/rustdesk/rustdesk-server), aguarda por conexão remota direta (perfuração de túnel TCP / hole punching) ou retransmitida.
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: código específico de cada plataforma.
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: código Flutter para desktop e dispositivos móveis.
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/v1/js)**: JavaScript para o cliente web do Flutter.
> [!Cuidadob]
> **Aviso de uso indevido:** <br>
> 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.
## Capturas de Tela
## Screenshots
![Gerenciador de Conexões](https://github.com/rustdesk/rustdesk/assets/28412477/db82d4e7-c4bc-4823-8e6f-6af7eadf7651)
![image](https://user-images.githubusercontent.com/71636191/113112362-ae4deb80-923b-11eb-957d-ff88daad4f06.png)
![Conectado a um PC Windows](https://github.com/rustdesk/rustdesk/assets/28412477/9baa91e9-3362-4d06-aa1a-7518edcbd7ea)
![image](https://user-images.githubusercontent.com/71636191/113112619-f705a480-923b-11eb-911d-97e984ef52b6.png)
![Transferência de Arquivos](https://github.com/rustdesk/rustdesk/assets/28412477/39511ad3-aa9a-4f8c-8947-1cce286a46ad)
![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)
![Tunelamento TCP](https://github.com/rustdesk/rustdesk/assets/28412477/78e8708f-e87e-4570-8373-1360033ea6c5)

16
docs/SECURITY-FR.md Normal file
View file

@ -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é.

View file

@ -33,4 +33,4 @@ if [ -z $release ]; then
fi
set -f
#shellcheck disable=2086
VCPKG_ROOT=/vcpkg cargo build $argv
VCPKG_ROOT=/vcpkg cargo build --locked $argv

View file

@ -18,7 +18,7 @@
<li> Supports VP8 / VP9 / AV1 software codecs, and H264 / H265 hardware codecs. </li>
<li> Own your data, easily set up self-hosting solution on your infrastructure. </li>
<li> P2P connection with end-to-end encryption based on NaCl. </li>
<li> No administrative privileges or installation needed for Windows, elevate priviledge locally or from remote on demand. </li>
<li> No administrative privileges or installation needed for Windows, elevate privilege locally or from remote on demand. </li>
<li> We like to keep things simple and will strive to make simpler where possible. </li>
</ul>
<p>
@ -56,4 +56,4 @@
<control>pointing</control>
</supports>
<content_rating type="oars-1.1"/>
</component>
</component>

View file

@ -62,7 +62,13 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart:
return false
}
}
audioRecorder = builder.build()
val recorder = try {
builder.build()
} catch (e: Exception) {
Log.e(logTag, "createAudioRecorder failed", e)
return false
}
audioRecorder = recorder
Log.d(logTag, "createAudioRecorder done,minBufferSize:$minBufferSize")
return true
}

View file

@ -0,0 +1 @@
<svg width="48" height="48" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg"><rect x="4" y="4" width="19" height="19" fill="#f25022"/><rect x="25" y="4" width="19" height="19" fill="#7fba00"/><rect x="4" y="25" width="19" height="19" fill="#00a4ef"/><rect x="25" y="25" width="19" height="19" fill="#ffb900"/></svg>

After

Width:  |  Height:  |  Size: 321 B

View file

@ -460,6 +460,7 @@ build)
--target "${RUST_TARGET}" \
--bindgen \
build \
--locked \
--release \
--features "${RUSTDESK_FEATURES}"

View file

@ -1,2 +1,2 @@
#!/usr/bin/env bash
cargo build --features flutter,hwcodec --release --target aarch64-apple-ios --lib
cargo build --locked --features flutter,hwcodec --release --target aarch64-apple-ios --lib

View file

@ -1,2 +1,2 @@
#!/usr/bin/env bash
cargo build --features flutter --release --target x86_64-apple-ios --lib
cargo build --locked --features flutter --release --target x86_64-apple-ios --lib

View file

@ -716,6 +716,17 @@ closeConnection({String? id}) {
stateGlobal.isInMainPage = true;
} else {
final controller = Get.find<DesktopTabController>();
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);
}
}
@ -2365,6 +2376,19 @@ List<String>? 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), () {
@ -2374,6 +2398,18 @@ List<String>? 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 {
@ -4154,8 +4190,7 @@ Widget? buildAvatarWidget({
width: size,
height: size,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
fallback ?? SizedBox.shrink(),
errorBuilder: (_, __, ___) => fallback ?? SizedBox.shrink(),
),
);
}

View file

@ -54,9 +54,9 @@ class _AddressBookState extends State<AddressBook> {
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,

View file

@ -20,7 +20,8 @@ const kOpSvgList = [
'okta',
'facebook',
'azure',
'auth0'
'auth0',
'microsoft'
];
class _IconOP extends StatelessWidget {
@ -224,21 +225,59 @@ class _WidgetOPState extends State<WidgetOP> {
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>[
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,
),
),
),
],
),
);
}),
),
],
),
);
}),

View file

@ -31,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(
@ -532,7 +532,9 @@ class _RawTouchGestureDetectorRegionState
// Official
TapGestureRecognizer:
GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
() => TapGestureRecognizer(), (instance) {
() => TapGestureRecognizer(
supportedDevices: kTouchBasedDeviceKinds,
), (instance) {
instance
..onTapDown = onTapDown
..onTapUp = onTapUp
@ -540,14 +542,18 @@ class _RawTouchGestureDetectorRegionState
}),
DoubleTapGestureRecognizer:
GestureRecognizerFactoryWithHandlers<DoubleTapGestureRecognizer>(
() => DoubleTapGestureRecognizer(), (instance) {
() => DoubleTapGestureRecognizer(
supportedDevices: kTouchBasedDeviceKinds,
), (instance) {
instance
..onDoubleTapDown = onDoubleTapDown
..onDoubleTap = onDoubleTap;
}),
LongPressGestureRecognizer:
GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>(
() => LongPressGestureRecognizer(), (instance) {
() => LongPressGestureRecognizer(
supportedDevices: kTouchBasedDeviceKinds,
), (instance) {
instance
..onLongPressDown = onLongPressDown
..onLongPressUp = onLongPressUp
@ -557,7 +563,9 @@ class _RawTouchGestureDetectorRegionState
// Customized
HoldTapMoveGestureRecognizer:
GestureRecognizerFactoryWithHandlers<HoldTapMoveGestureRecognizer>(
() => HoldTapMoveGestureRecognizer(),
() => HoldTapMoveGestureRecognizer(
supportedDevices: kTouchBasedDeviceKinds,
),
(instance) => instance
..onHoldDragStart = onHoldDragStart
..onHoldDragUpdate = onHoldDragUpdate
@ -565,14 +573,18 @@ class _RawTouchGestureDetectorRegionState
..onHoldDragEnd = onHoldDragEnd),
DoubleFinerTapGestureRecognizer:
GestureRecognizerFactoryWithHandlers<DoubleFinerTapGestureRecognizer>(
() => DoubleFinerTapGestureRecognizer(), (instance) {
() => DoubleFinerTapGestureRecognizer(
supportedDevices: kTouchBasedDeviceKinds,
), (instance) {
instance
..onDoubleFinerTap = onDoubleFinerTap
..onDoubleFinerTapDown = onDoubleFinerTapDown;
}),
CustomTouchGestureRecognizer:
GestureRecognizerFactoryWithHandlers<CustomTouchGestureRecognizer>(
() => CustomTouchGestureRecognizer(), (instance) {
() => CustomTouchGestureRecognizer(
supportedDevices: kTouchBasedDeviceKinds,
), (instance) {
instance.onOneFingerPanStart =
(DragStartDetails d) => onOneFingerPanStart(context, d);
instance

View file

@ -13,8 +13,70 @@ 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';
import 'package:url_launcher/url_launcher.dart';
bool isEditOsPassword = false;
const String kPeerOptionAllowWaylandKeyboard = 'allow-wayland-keyboard';
const String kWaylandKeyboardIssueUrl =
'https://github.com/rustdesk/rustdesk/issues/14586';
final Set<String> _waylandKeyboardPromptSuppressedConnectionIds = <String>{};
Future<bool> openWaylandKeyboardIssueUrl() {
return launchUrl(
Uri.parse(kWaylandKeyboardIssueUrl),
mode: LaunchMode.externalApplication,
);
}
bool isWaylandKeyboardPromptSuppressedForConnection(String connectionId) {
return _waylandKeyboardPromptSuppressedConnectionIds.contains(connectionId);
}
void setWaylandKeyboardPromptSuppressedForConnection(
String connectionId, bool suppressed) {
if (suppressed) {
_waylandKeyboardPromptSuppressedConnectionIds.add(connectionId);
} else {
_waylandKeyboardPromptSuppressedConnectionIds.remove(connectionId);
}
}
void clearWaylandKeyboardPromptSuppressedForConnection(String connectionId) {
_waylandKeyboardPromptSuppressedConnectionIds.remove(connectionId);
}
bool shouldShowWaylandKeyboardPrompt({
required String connectionId,
required bool isWaylandPeer,
required bool allowWaylandKeyboardRemembered,
}) {
return isWaylandPeer &&
!allowWaylandKeyboardRemembered &&
!isWaylandKeyboardPromptSuppressedForConnection(connectionId);
}
Widget waylandKeyboardScopeChip(BuildContext context, String text) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(999),
border: Border.all(color: colorScheme.primary.withOpacity(0.35)),
),
child: Text(
text,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600),
),
);
}
// 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;
@ -87,12 +149,179 @@ handleOsPasswordAction(
}
}
void showWaylandKeyboardInputWarningDialog(
{required String id,
required String connectionId,
required FFI ffi,
required Future<void> Function() onEnable}) {
bool remember = false;
bool consentInProgress = false;
bool dialogClosed = false;
final dialogFuture = ffi.dialogManager.show((setState, close, context) {
void safeSetState(VoidCallback fn) {
if (dialogClosed) {
return;
}
try {
setState(fn);
} catch (e) {
debugPrint('Ignore setState after dialog disposal: $e');
}
}
void closeDialog() {
if (dialogClosed) {
return;
}
dialogClosed = true;
close();
}
Future<void> enableAndContinue() async {
if (consentInProgress || dialogClosed) {
return;
}
consentInProgress = true;
safeSetState(() {});
try {
await onEnable();
} catch (e, st) {
debugPrint('Failed to enable Wayland keyboard input consent: $e');
debugPrintStack(stackTrace: st);
consentInProgress = false;
safeSetState(() {});
return;
}
ffi.inputModel.keyboardInputAllowed = true;
var rememberPersisted = true;
if (remember) {
try {
await bind.mainSetPeerOption(
id: id,
key: kPeerOptionAllowWaylandKeyboard,
value: bool2option(kPeerOptionAllowWaylandKeyboard, true));
} catch (e) {
rememberPersisted = false;
debugPrint('Failed to persist Wayland keyboard input consent: $e');
}
}
// Always suppress prompt for current connection after explicit consent.
setWaylandKeyboardPromptSuppressedForConnection(connectionId, true);
closeDialog();
if (remember && !rememberPersisted) {
// It's a rare edge case that persisting the user's choice fails.
// Failed to persist the user's choice, but still allow keyboard input for current session.
showToast(translate('Failed'));
}
}
void cancel() {
if (consentInProgress) {
return;
}
closeDialog();
}
return CustomAlertDialog(
title: null,
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
msgboxContent(
'',
'wayland-keyboard-input-disabled-tip',
'wayland-keyboard-input-consent-tip',
),
SizedBox(height: isMobile ? 2 : 6),
if (isMobile) ...[
Text(
translate('wayland-keyboard-input-applies-to-tip'),
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
).marginOnly(bottom: 6),
Wrap(
spacing: 6,
runSpacing: 6,
children: [
waylandKeyboardScopeChip(
context, translate('Send clipboard keystrokes')),
waylandKeyboardScopeChip(
context, translate('wayland-soft-keyboard-input-label')),
],
).marginOnly(bottom: 10),
],
TextButton(
onPressed: consentInProgress
? null
: () async {
try {
final opened = await openWaylandKeyboardIssueUrl();
if (!opened) {
// Opening this optional help link almost never fails in
// normal desktop environments. Keep the result handled
// for review hygiene, but avoid a low-value user toast.
debugPrint('Failed to open Wayland keyboard issue URL');
}
} catch (e) {
debugPrint(
'Failed to open Wayland keyboard issue URL: $e');
}
},
style: TextButton.styleFrom(
foregroundColor: Colors.blue,
padding: EdgeInsets.zero,
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Text(
translate('Why this happens'),
style: const TextStyle(decoration: TextDecoration.underline),
),
).marginOnly(bottom: 6),
CheckboxListTile(
value: remember,
dense: true,
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
title: Text(translate('remember-wayland-keyboard-choice-tip')),
onChanged: consentInProgress
? null
: (v) {
safeSetState(() => remember = v == true);
},
),
],
),
actions: [
dialogButton(
'Cancel',
onPressed: consentInProgress ? null : cancel,
isOutline: true,
),
dialogButton(
'OK',
onPressed:
consentInProgress ? null : () => unawaited(enableAndContinue()),
),
],
onCancel: consentInProgress ? null : cancel,
onSubmit: consentInProgress ? null : () => unawaited(enableAndContinue()),
);
}, clickMaskDismiss: false, backDismiss: false);
unawaited(dialogFuture.whenComplete(() => dialogClosed = true));
}
List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
final ffiModel = ffi.ffiModel;
final pi = ffiModel.pi;
final perms = ffiModel.permissions;
final sessionId = ffi.sessionId;
final isDefaultConn = ffi.connType == ConnType.defaultConn;
final isWaylandPeer = pi.platform == kPeerPlatformLinux && pi.isWayland;
List<TTextMenu> v = [];
// elevation
@ -142,11 +371,60 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
v.add(TTextMenu(
child: Text(translate('Send clipboard keystrokes')),
onPressed: () async {
ClipboardData? data = await Clipboard.getData(Clipboard.kTextPlain);
if (data != null && data.text != null) {
bind.sessionInputString(
sessionId: sessionId, value: data.text ?? "");
Future<void> sendClipboardKeystrokes() async {
ClipboardData? data = await Clipboard.getData(Clipboard.kTextPlain);
if (data != null && data.text != null) {
bind.sessionInputString(
sessionId: sessionId, value: data.text ?? "");
}
}
final allowWaylandKeyboard =
mainGetPeerBoolOptionSync(id, kPeerOptionAllowWaylandKeyboard);
if (shouldShowWaylandKeyboardPrompt(
connectionId: sessionId.toString(),
isWaylandPeer: isWaylandPeer,
allowWaylandKeyboardRemembered: allowWaylandKeyboard,
)) {
ffi.inputModel.keyboardInputAllowed = false;
showWaylandKeyboardInputWarningDialog(
id: id,
connectionId: sessionId.toString(),
ffi: ffi,
onEnable: sendClipboardKeystrokes,
);
return;
}
await sendClipboardKeystrokes();
}));
}
if (isDefaultConn &&
isWaylandPeer &&
(mainGetPeerBoolOptionSync(id, kPeerOptionAllowWaylandKeyboard) ||
isWaylandKeyboardPromptSuppressedForConnection(
sessionId.toString()))) {
v.add(TTextMenu(
child: Text(translate('wayland-keyboard-input-reset-choice-tip')),
onPressed: () async {
var persistedCleared = false;
try {
await bind.mainSetPeerOption(
id: id,
key: kPeerOptionAllowWaylandKeyboard,
value: bool2option(kPeerOptionAllowWaylandKeyboard, false));
persistedCleared = true;
} catch (e) {
debugPrint(
'Failed to clear persisted Wayland keyboard permission: $e');
} finally {
clearWaylandKeyboardPromptSuppressedForConnection(
sessionId.toString());
ffi.inputModel.keyboardInputAllowed = false;
if (isMobile) {
await ffi.invokeMethod("enable_soft_keyboard", false);
}
}
showToast(translate(persistedCleared ? 'Successful' : 'Failed'));
}));
}
// reset canvas
@ -684,8 +962,9 @@ Future<List<TToggleMenu>> 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 =
@ -759,15 +1038,26 @@ List<TToggleMenu> 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<void> 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,
@ -810,18 +1100,29 @@ List<TToggleMenu> toolbarPrivacyMode(
})
];
} else {
return privacyModeImpls.map((e) {
final visibleImpls = hasPrivacyModePermission
? privacyModeImpls
: privacyModeImpls.where((e) {
final implKey = (e as List<dynamic>)[0] as String;
return privacyModeState.value == implKey;
}).toList();
return visibleImpls.map((e) {
final implKey = (e as List<dynamic>)[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();
}
}

View file

@ -114,6 +114,9 @@ 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";
@ -139,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";
@ -187,6 +194,9 @@ 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";

View file

@ -488,6 +488,16 @@ class _GeneralState extends State<_General> {
_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()) ...[
@ -1062,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),

View file

@ -65,7 +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 printer = false.obs;
final RxBool showProgress = false.obs;
final RxBool btnEnabled = true.obs;
@ -80,7 +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';
printer.value = installOptions['PRINTER'] == '1';
}
@override

View file

@ -101,6 +101,9 @@ class _RemotePageState extends State<RemotePage>
Function(bool)? _onEnterOrLeaveImage4Toolbar;
late FFI _ffi;
Worker? _waylandKeyboardModeWorker;
bool _waylandKeyboardModeNormalized = false;
bool _waylandKeyboardModeNormalizing = false;
SessionID get sessionId => _ffi.sessionId;
@ -178,6 +181,48 @@ class _RemotePageState extends State<RemotePage>
// Register callback to cancel debounce timer when relative mouse mode is disabled
_ffi.inputModel.onRelativeMouseModeDisabled =
_cancelPointerLockCenterDebounceTimer;
_waylandKeyboardModeWorker = ever(_ffi.ffiModel.pi.isSet, (bool isSet) {
if (isSet) {
unawaited(_normalizeWaylandKeyboardModeIfNeeded());
}
});
if (_ffi.ffiModel.pi.isSet.value) {
unawaited(_normalizeWaylandKeyboardModeIfNeeded());
}
}
Future<void> _normalizeWaylandKeyboardModeIfNeeded() async {
if (!mounted ||
_waylandKeyboardModeNormalized ||
_waylandKeyboardModeNormalizing) {
return;
}
_waylandKeyboardModeNormalizing = true;
try {
final pi = _ffi.ffiModel.pi;
if (pi.platform != kPeerPlatformLinux || !pi.isWayland) return;
final mapSupported = bind.sessionIsKeyboardModeSupported(
sessionId: sessionId, mode: kKeyMapMode);
if (!mapSupported) return;
final current = await bind.sessionGetKeyboardMode(sessionId: sessionId);
if (!mounted) return;
if (current == kKeyMapMode) {
_waylandKeyboardModeNormalized = true;
return;
}
await bind.sessionSetKeyboardMode(
sessionId: sessionId, value: kKeyMapMode);
if (!mounted) return;
await _ffi.inputModel.updateKeyboardMode();
if (!mounted) return;
_waylandKeyboardModeNormalized = true;
} catch (e, st) {
debugPrint('Failed to normalize Wayland keyboard mode: $e');
debugPrintStack(stackTrace: st);
} finally {
_waylandKeyboardModeNormalizing = false;
}
}
/// Cancel the pointer lock center debounce timer
@ -318,6 +363,7 @@ class _RemotePageState extends State<RemotePage>
_pointerLockCenterDebounceTimer?.cancel();
_pointerLockCenterDebounceTimer = null;
_waylandKeyboardModeWorker?.dispose();
// Clear callback reference to prevent memory leaks and stale references
_ffi.inputModel.onRelativeMouseModeDisabled = null;
// Relative mouse mode cleanup is centralized in FFI.close(closeSession: ...).
@ -331,6 +377,9 @@ class _RemotePageState extends State<RemotePage>
_ffi.imageModel.disposeImage();
_ffi.cursorModel.disposeImages();
_rawKeyFocusNode.dispose();
if (closeSession) {
clearWaylandKeyboardPromptSuppressedForConnection(sessionId.toString());
}
await _ffi.close(closeSession: closeSession);
_timer?.cancel();
_ffi.dialogManager.dismissAll();

View file

@ -610,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: [
@ -643,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,
@ -689,6 +697,7 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
});
},
translate('Enable audio'),
canModify: canModifyPermission,
),
buildPermissionIcon(
client.recording,
@ -703,6 +712,7 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
});
},
translate('Enable recording session'),
canModify: canModifyPermission,
),
]
: [
@ -719,6 +729,7 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
});
},
translate('Enable keyboard/mouse'),
canModify: canModifyPermission,
),
buildPermissionIcon(
client.clipboard,
@ -733,6 +744,7 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
});
},
translate('Enable clipboard'),
canModify: canModifyPermission,
),
buildPermissionIcon(
client.audio,
@ -747,6 +759,7 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
});
},
translate('Enable audio'),
canModify: canModifyPermission,
),
buildPermissionIcon(
client.file,
@ -761,6 +774,7 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
});
},
translate('Enable file copy and paste'),
canModify: canModifyPermission,
),
buildPermissionIcon(
client.restart,
@ -775,6 +789,7 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
});
},
translate('Enable remote restart'),
canModify: canModifyPermission,
),
buildPermissionIcon(
client.recording,
@ -789,6 +804,7 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
});
},
translate('Enable recording session'),
canModify: canModifyPermission,
),
// only windows support block input
if (isWindows)
@ -805,6 +821,23 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
});
},
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,
)
],
),

View file

@ -27,6 +27,7 @@ class TerminalPage extends StatefulWidget {
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<State<TerminalPage>?> _lastState = SimpleWrapper(null);
@ -43,6 +44,9 @@ class TerminalPage extends StatefulWidget {
class _TerminalPageState extends State<TerminalPage>
with AutomaticKeepAliveClientMixin {
static const EdgeInsets _defaultTerminalPadding =
EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0);
late FFI _ffi;
late TerminalModel _terminalModel;
double? _cellHeight;
@ -155,13 +159,27 @@ class _TerminalPageState extends State<TerminalPage>
// 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) {
if (_cellHeight == null) {
return const EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0);
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 rows = (heightPx / _cellHeight!).floor();
final extraSpace = heightPx - rows * _cellHeight!;
final topBottom = extraSpace / 2.0;
return EdgeInsets.symmetric(horizontal: 5.0, vertical: topBottom);
return EdgeInsets.symmetric(
horizontal: _defaultTerminalPadding.horizontal / 2,
vertical: topBottom,
);
}
@override

View file

@ -46,6 +46,7 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
.setTitle(getWindowNameWithId(id));
};
tabController.onRemoved = (_, id) => onRemoveId(id);
tabController.onCloseWindow = _closeWindowFromConnection;
final terminalId = params['terminalId'] ?? _nextTerminalId++;
tabController.add(_createTerminalTab(
peerId: params['id'],
@ -144,6 +145,8 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
_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.
@ -368,8 +371,34 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
final persistentSessions =
args['persistent_sessions'] as List<dynamic>? ?? [];
final sortedSessions = persistentSessions.whereType<int>().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) {
_addNewTerminalForCurrentPeer(terminalId: terminalId);
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.
@ -546,6 +575,11 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
}
}
Future<void> _closeWindowFromConnection() async {
await _closeAllTabs();
await WindowController.fromWindowId(windowId()).close();
}
int windowId() {
return widget.params["windowId"];
}

File diff suppressed because it is too large Load diff

View file

@ -99,6 +99,7 @@ class DesktopTabController {
/// index, key
Function(int, String)? onRemoved;
Function(String)? onSelected;
Future<void> Function()? onCloseWindow;
DesktopTabController(
{required this.tabType, this.onRemoved, this.onSelected});
@ -592,13 +593,13 @@ class _DesktopTabState extends State<DesktopTab>
}
Widget _buildBar() {
final isIncomingHomePage = bind.isIncomingOnly() && isInHomePage();
return Row(
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;
@ -609,7 +610,7 @@ class _DesktopTabState extends State<DesktopTab>
.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.

View file

@ -27,6 +27,7 @@ import 'common.dart';
import 'consts.dart';
import 'mobile/pages/home_page.dart';
import 'mobile/pages/server_page.dart';
import 'mobile/widgets/deploy_dialog.dart';
import 'models/platform_model.dart';
import 'package:flutter_hbb/plugin/handlers.dart'
@ -575,6 +576,14 @@ _registerEventHandler() {
NativeUiHandler.instance.onEvent(evt);
});
}
if (isAndroid) {
platformFFI.registerEventHandler(
'android_needs_deploy', 'android_needs_deploy', (_) async {
WidgetsBinding.instance.addPostFrameCallback((_) {
showDeployPromptDialog();
});
});
}
}
Widget keyListenerBuilder(BuildContext context, Widget? child) {

View file

@ -75,6 +75,9 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
final FocusNode _physicalFocusNode = FocusNode();
var _showEdit = false; // use soft keyboard
Worker? _waylandKeyboardGateWorker;
bool _waylandKeyboardGateInitialized = false;
InputModel get inputModel => gFFI.inputModel;
SessionID get sessionId => gFFI.sessionId;
@ -121,11 +124,33 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
isKeyboardVisible: keyboardVisibilityController.isVisible);
});
WidgetsBinding.instance.addObserver(this);
inputModel.keyboardInputAllowed = true;
// Wayland sessions may use clipboard-based text input on the controlled side.
// Require explicit user confirmation before allowing soft-keyboard and
// clipboard-assisted text input. Physical keyboard events are not gated here.
_waylandKeyboardGateWorker = ever(gFFI.ffiModel.pi.isSet, (bool isSet) {
if (isSet) {
_initWaylandKeyboardGateIfNeeded();
}
});
if (gFFI.ffiModel.pi.isSet.value) {
_initWaylandKeyboardGateIfNeeded();
}
}
@override
Future<void> dispose() async {
WidgetsBinding.instance.removeObserver(this);
// Close the session up-front. `gFFI.close()` below only calls `sessionClose`
// after several awaits (canvas save, image update, the `enable_soft_keyboard`
// platform call), so if the app is backgrounded while this page is disposing,
// dispose can be suspended before reaching it and the connection is never torn
// down. The reconnect then re-attaches to the leaked session and is stuck on
// "Connecting...". Dispatching it here makes teardown happen synchronously on
// pop; the `sessionClose` in `gFFI.close()` becomes a no-op once removed.
unawaited(bind.sessionClose(sessionId: sessionId));
// https://github.com/flutter/flutter/issues/64935
super.dispose();
gFFI.dialogManager.hideMobileActionsOverlay(store: false);
@ -135,6 +160,9 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
await gFFI.invokeMethod("enable_soft_keyboard", true);
_mobileFocusNode.dispose();
_physicalFocusNode.dispose();
clearWaylandKeyboardPromptSuppressedForConnection(sessionId.toString());
_waylandKeyboardGateWorker?.dispose();
inputModel.keyboardInputAllowed = true;
await gFFI.close();
_timer?.cancel();
_iosKeyboardWorkaroundTimer?.cancel();
@ -163,6 +191,40 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
gFFI.invokeMethod("try_sync_clipboard");
}
bool _shouldGateKeyboardForWayland() {
if (!(isAndroid || isIOS)) return false;
final pi = gFFI.ffiModel.pi;
return pi.platform == kPeerPlatformLinux && pi.isWayland;
}
void _initWaylandKeyboardGateIfNeeded() {
if (!mounted) return;
if (_waylandKeyboardGateInitialized) return;
if (!_shouldGateKeyboardForWayland()) return;
_waylandKeyboardGateInitialized = true;
final allowWaylandKeyboard =
mainGetPeerBoolOptionSync(widget.id, kPeerOptionAllowWaylandKeyboard);
if (!shouldShowWaylandKeyboardPrompt(
connectionId: sessionId.toString(),
isWaylandPeer: _shouldGateKeyboardForWayland(),
allowWaylandKeyboardRemembered: allowWaylandKeyboard,
)) {
inputModel.keyboardInputAllowed = true;
return;
}
inputModel.keyboardInputAllowed = false;
// Ensure soft keyboard is not active before user confirms.
_showEdit = false;
gFFI.invokeMethod("enable_soft_keyboard", false);
_mobileFocusNode.unfocus();
_physicalFocusNode.requestFocus();
setState(() {});
}
// 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.
@ -294,7 +356,7 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
content == '【】')) {
// can not only input content[0], because when input ], [ are also auo insert, which cause ] never be input
bind.sessionInputString(sessionId: sessionId, value: content);
openKeyboard();
_openKeyboardUnlocked();
return;
}
bind.sessionInputString(sessionId: sessionId, value: content);
@ -306,6 +368,9 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
// handle mobile virtual keyboard
void handleSoftKeyboardInput(String newValue) {
if (!inputModel.keyboardInputAllowed) {
return;
}
if (isIOS) {
_handleIOSSoftKeyboardInput(newValue);
} else {
@ -314,6 +379,9 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
}
void inputChar(String char) {
if (!inputModel.keyboardInputAllowed) {
return;
}
if (char == '\n') {
char = 'VK_RETURN';
} else if (char == ' ') {
@ -323,6 +391,29 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
}
void openKeyboard() {
final allowWaylandKeyboard =
mainGetPeerBoolOptionSync(widget.id, kPeerOptionAllowWaylandKeyboard);
if (shouldShowWaylandKeyboardPrompt(
connectionId: sessionId.toString(),
isWaylandPeer: _shouldGateKeyboardForWayland(),
allowWaylandKeyboardRemembered: allowWaylandKeyboard,
)) {
inputModel.keyboardInputAllowed = false;
showWaylandKeyboardInputWarningDialog(
id: widget.id,
connectionId: sessionId.toString(),
ffi: gFFI,
onEnable: () async {
_openKeyboardUnlocked();
},
);
return;
}
_openKeyboardUnlocked();
}
void _openKeyboardUnlocked() {
inputModel.keyboardInputAllowed = true;
gFFI.invokeMethod("enable_soft_keyboard", true);
// destroy first, so that our _value trick can work
_value = initText;
@ -426,12 +517,10 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
}
return Container(
color: MyTheme.canvasColor,
child: inputModel.isPhysicalMouse.value
? getBodyForMobile()
: RawTouchGestureDetectorRegion(
child: getBodyForMobile(),
ffi: gFFI,
),
child: RawTouchGestureDetectorRegion(
child: getBodyForMobile(),
ffi: gFFI,
),
);
}),
),
@ -1185,7 +1274,8 @@ void showOptions(
List<TToggleMenu> 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]);

View file

@ -583,9 +583,16 @@ class _PermissionCheckerState extends State<PermissionChecker> {
Widget build(BuildContext context) {
final serverModel = Provider.of<ServerModel>(context);
final hasAudioPermission = androidVersion >= 30;
final hideStopService =
isAndroid &&
bind.mainGetBuildinOption(key: kOptionHideStopService) == 'Y';
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: [
@ -608,13 +615,21 @@ class _PermissionCheckerState extends State<PermissionChecker> {
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),
PermissionRow(
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(
@ -623,19 +638,25 @@ class _PermissionCheckerState extends State<PermissionChecker> {
style: const TextStyle(color: MyTheme.darkGray),
))
]),
PermissionRow(translate("Enable clipboard"), serverModel.clipboardOk,
serverModel.toggleClipboard),
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) {
@ -644,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);
}
}

View file

@ -17,6 +17,7 @@ import '../../common/widgets/login.dart';
import '../../consts.dart';
import '../../models/model.dart';
import '../../models/platform_model.dart';
import '../widgets/deploy_dialog.dart';
import '../widgets/dialog.dart';
import 'home_page.dart';
import 'scan_page.dart';
@ -728,6 +729,13 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
onPressed: (context) {
changeSocks5Proxy();
}),
if (isAndroid && !bind.isOutgoingOnly())
SettingsTile(
title: Text(translate('Deploy')),
leading: Icon(Icons.cloud_upload),
onPressed: (context) {
showDeployDialog();
}),
if (!disabledSettings && !_hideNetwork && !_hideWebSocket)
SettingsTile.switchTile(
title: Text(translate('Use WebSocket')),

View file

@ -259,13 +259,11 @@ class _ViewCameraPageState extends State<ViewCameraPage>
}
return Container(
color: MyTheme.canvasColor,
child: inputModel.isPhysicalMouse.value
? getBodyForMobile()
: RawTouchGestureDetectorRegion(
child: getBodyForMobile(),
ffi: gFFI,
isCamera: true,
),
child: RawTouchGestureDetectorRegion(
child: getBodyForMobile(),
ffi: gFFI,
isCamera: true,
),
);
}),
),

View file

@ -0,0 +1,114 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../common.dart';
import '../../models/platform_model.dart';
const _deployDialogTag = 'android-deploy-device';
void showDeployPromptDialog() {
gFFI.dialogManager.dismissByTag(_deployDialogTag);
gFFI.dialogManager.show<bool>((setState, close, context) {
submit() => close(true);
return CustomAlertDialog(
title: Text(translate("Deploy")),
content: Text(translate("server_requires_deployment_tip")),
actions: [
dialogButton("Cancel", onPressed: close, isOutline: true),
dialogButton("OK", onPressed: submit),
],
onSubmit: submit,
onCancel: close,
);
}, tag: _deployDialogTag).then((deploy) {
if (deploy == true) {
showDeployDialog();
}
});
}
void showDeployDialog() {
gFFI.dialogManager.dismissByTag(_deployDialogTag);
final tokenController = TextEditingController();
final idController = TextEditingController();
var errorText = "";
var isInProgress = false;
gFFI.dialogManager.show((setState, close, context) {
submit() async {
if (isInProgress) return;
final token = tokenController.text.trim();
if (token.isEmpty) {
setState(() {
errorText = translate("token is required!");
});
return;
}
setState(() {
errorText = "";
isInProgress = true;
});
String res;
try {
res = await bind.mainDeployDevice(
token: token, id: idController.text.trim());
} catch (e) {
setState(() {
errorText = translate(e.toString());
isInProgress = false;
});
return;
}
if (res.isEmpty) {
close();
await gFFI.serverModel.fetchID();
showToast(translate("Successful"));
} else {
setState(() {
errorText = translate(res.toString());
isInProgress = false;
});
}
}
return CustomAlertDialog(
title: Text(translate("Deploy")),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: tokenController,
decoration: InputDecoration(labelText: translate("API Token")),
obscureText: true,
enableSuggestions: false,
autocorrect: false,
autofocus: true,
).workaroundFreezeLinuxMint(),
TextField(
controller: idController,
decoration:
InputDecoration(labelText: translate("Custom ID (optional)")),
).workaroundFreezeLinuxMint(),
if (errorText.isNotEmpty)
Align(
alignment: Alignment.centerLeft,
child: SelectableText(
errorText,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 12,
),
).paddingOnly(top: 8),
),
if (isInProgress) const LinearProgressIndicator().paddingOnly(top: 8),
],
),
actions: [
dialogButton("Cancel",
onPressed: isInProgress ? null : close, isOutline: true),
dialogButton("OK", onPressed: isInProgress ? null : submit),
],
onSubmit: submit,
onCancel: isInProgress ? null : close,
);
}, tag: _deployDialogTag);
}

View file

@ -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';
@ -53,7 +52,9 @@ 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;
@ -68,6 +69,7 @@ class AbModel {
var _syncFromRecentLock = false;
var _timerCounter = 0;
var _cacheLoadOnceFlag = false;
var _pulledOnce = false;
var listInitialized = false;
var _maxPeerOneAb = 0;
@ -97,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.
///
@ -110,31 +119,41 @@ class AbModel {
var _pulling = false;
Future<void> 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<void> _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");
@ -142,7 +161,7 @@ class AbModel {
abProfiles.add(AbProfile(_personalAbGuid!, _personalAddressBookName,
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++) {
@ -182,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)) {
@ -197,14 +217,26 @@ class AbModel {
}
}
Future<bool> _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<bool> _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;
}
@ -213,46 +245,57 @@ class AbModel {
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<bool> _getPersonalAbGuid() async {
/// Loads `/api/ab/personal`.
/// Returns `true` to continue init, `false` to stop after a real error.
Future<bool> _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<String, dynamic> json =
_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<bool> _getSharedAbProfiles(List<AbProfile> profiles) async {
Future<bool> _getSharedAbProfiles(List<AbProfile> 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;
@ -273,13 +316,19 @@ class AbModel {
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<String, dynamic> json =
_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'];
@ -302,6 +351,7 @@ class AbModel {
return true;
} catch (err) {
debugPrint('_getSharedAbProfiles err: ${err.toString()}');
_setListPullError(err, quiet: quiet, statusCode: statusCode);
}
return false;
}

View file

@ -391,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<bool> tryOpenReadyDirs() async {
final dirs = <String>{
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();
}
}
@ -429,19 +445,23 @@ class FileController {
});
}
Future<void> refresh() async {
await openDirectory(directory.value.path);
Future<bool> 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<void> openDirectory(String path, {bool isBack = false}) async {
if (path == ".") {
refresh();
return;
Future<bool> 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<bool> _openDirectoryPath(String path, {bool isBack = false}) async {
if (!isBack) {
pushHistory();
}
@ -458,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;
}
}
@ -487,19 +509,22 @@ class FileController {
goBack();
return;
}
openDirectory(path, isBack: true);
unawaited(_openDirectoryPath(path, isBack: true).then<void>((_) {}));
}
void goToParentDirectory() {
unawaited(_goToParentDirectory().then<void>((_) {}));
}
Future<bool> _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

View file

@ -343,6 +343,7 @@ class GroupModel {
}
reset() async {
initialized = false;
groupLoadError.value = '';
deviceGroups.clear();
users.clear();

View file

@ -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';
@ -15,12 +16,13 @@ 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, back }
enum MouseButtons { left, right, wheel, back, forward }
const _kMouseEventDown = 'mousedown';
const _kMouseEventUp = 'mouseup';
@ -157,6 +159,8 @@ extension ToString on MouseButtons {
return 'wheel';
case MouseButtons.back:
return 'back';
case MouseButtons.forward:
return 'forward';
}
}
}
@ -327,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<MouseButtons, InputModel> _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<dynamic, dynamic>;
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<FFI> parent;
String keyboardMode = '';
@ -396,6 +474,10 @@ class InputModel {
late final SessionID sessionId;
// Local gate for clipboard-assisted input flows on mobile Wayland dialogs.
// It should not block physical keyboard events.
bool keyboardInputAllowed = true;
bool get keyboardPerm => parent.target!.ffiModel.keyboard;
String get id => parent.target?.id ?? '';
String? get peerPlatform => parent.target?.ffiModel.pi.platform;
@ -412,6 +494,7 @@ class InputModel {
bool get isRelativeMouseModeSupported => _relativeMouse.isSupported;
InputModel(this.parent) {
initSideButtonChannel();
sessionId = parent.target!.sessionId;
_relativeMouse = RelativeMouseModel(
sessionId: sessionId,
@ -620,6 +703,38 @@ 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;
@ -674,6 +789,27 @@ 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, iosCapsLock);
@ -717,6 +853,8 @@ class InputModel {
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) {
@ -754,6 +892,21 @@ class InputModel {
}
}
}
// 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) {
@ -966,13 +1119,20 @@ class InputModel {
return evt;
}
/// 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<void> _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<void> sendMouse(String type, MouseButtons button) async {
if (!keyboardPerm) return;
if (isViewCamera) return;
await bind.sessionSendMouse(
sessionId: sessionId,
msg: json.encode(modify({'type': type, 'buttons': button.value})));
await _sendMouseUnchecked(type, button);
}
void enterOrLeave(bool enter) {
@ -982,6 +1142,13 @@ class InputModel {
_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();
@ -1332,6 +1499,16 @@ class InputModel {
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;
@ -1344,6 +1521,9 @@ class InputModel {
// 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;
}
@ -1353,6 +1533,10 @@ class InputModel {
}
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;
}

View file

@ -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;
}

View file

@ -3932,6 +3932,7 @@ class FFI {
inputModel.resetModifiers();
// Dispose relative mouse mode resources to ensure cursor is restored
inputModel.disposeRelativeMouseMode();
inputModel.disposeSideButtonTracking();
if (closeSession) {
await bind.sessionClose(sessionId: sessionId);
}

View file

@ -298,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)) {
@ -316,7 +316,7 @@ class ServerModel with ChangeNotifier {
}
toggleFile() async {
if (clients.isNotEmpty) {
if (clients.any((c) => !c.disconnected)) {
await showClientsMayNotBeChangedAlert(parent.target);
}
if (!_fileOk &&
@ -345,7 +345,7 @@ class ServerModel with ChangeNotifier {
}
toggleInput() async {
if (clients.isNotEmpty) {
if (clients.any((c) => !c.disconnected)) {
await showClientsMayNotBeChangedAlert(parent.target);
}
if (_inputOk) {
@ -549,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);
@ -818,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;
@ -846,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'];
@ -870,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;

View file

@ -27,25 +27,30 @@ class TerminalModel with ChangeNotifier {
// 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 = <String>[];
final _pendingOutputSuppressFlags = <bool>[];
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 get isPeerWindows => parent.ffiModel.pi.platform == kPeerPlatformWindows;
bool _markViewReadyScheduled = false;
bool _suppressTerminalOutput = false;
bool _suppressNextTerminalDataOutput = false;
void Function(int w, int h, int pw, int ph)? onResizeExternal;
Future<void> _handleInput(String data) async {
// If we press the `Enter` button on Android,
// `data` can be '\r' or '\n' when using different keyboards.
// Android -> Windows. '\r' works, but '\n' does not. '\n' is just a newline.
// Android -> Linux. Both '\r' and '\n' work as expected (execute a command).
// So when we receive '\n', we may need to convert it to '\r' to ensure compatibility.
// Desktop -> Desktop works fine.
// Check if we are on mobile or web(mobile), and convert '\n' to '\r'.
// 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 && isPeerWindows && data == '\n') {
if (isMobileOrWebMobile && data == '\n') {
data = '\r';
}
if (_terminalOpened) {
@ -70,7 +75,10 @@ class TerminalModel with ChangeNotifier {
terminalController = TerminalController();
// Setup terminal callbacks
terminal.onOutput = _handleInput;
terminal.onOutput = (data) {
if (_suppressTerminalOutput) return;
_handleInput(data);
};
terminal.onResize = (w, h, pw, ph) async {
// Validate all dimensions before using them
@ -84,7 +92,7 @@ class TerminalModel with ChangeNotifier {
// 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) {
_markViewReady();
_scheduleMarkViewReady();
}
if (_terminalOpened) {
@ -110,14 +118,16 @@ class TerminalModel with ChangeNotifier {
void onReady() {
parent.dialogManager.dismissAll();
// Fire and forget - don't block onReady
openTerminal().catchError((e) {
// 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<void> openTerminal() async {
if (_terminalOpened) return;
Future<void> 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
@ -275,9 +285,12 @@ class TerminalModel with ChangeNotifier {
if (success) {
_terminalOpened = true;
// On reconnect ("Reconnected to existing terminal"), server may replay recent output.
// If this TerminalView instance is reused (not rebuilt), duplicate lines can appear.
// We intentionally accept this tradeoff for now to keep logic simple.
// 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),
@ -285,7 +298,7 @@ class TerminalModel with ChangeNotifier {
if (!_terminalViewReady &&
terminal.viewWidth > 0 &&
terminal.viewHeight > 0) {
_markViewReady();
_scheduleMarkViewReady();
}
// Process any buffered input
@ -297,12 +310,16 @@ class TerminalModel with ChangeNotifier {
});
final persistentSessions =
evt['persistent_sessions'] as List<dynamic>? ?? [];
(evt['persistent_sessions'] as List<dynamic>? ?? [])
.whereType<int>()
.where((id) => !parent.terminalModels.containsKey(id))
.toList();
if (kWindowId != null && persistentSessions.isNotEmpty) {
DesktopMultiWindow.invokeMethod(
kWindowId!,
kWindowEventRestoreTerminalSessions,
jsonEncode({
'peer_id': id,
'persistent_sessions': persistentSessions,
}));
}
@ -332,6 +349,8 @@ class TerminalModel with ChangeNotifier {
final data = evt['data'];
if (data != null) {
final suppressTerminalOutput = _suppressNextTerminalDataOutput;
_suppressNextTerminalDataOutput = false;
try {
String text = '';
if (data is String) {
@ -351,7 +370,7 @@ class TerminalModel with ChangeNotifier {
return;
}
_writeToTerminal(text);
_writeToTerminal(text, suppressTerminalOutput: suppressTerminalOutput);
} catch (e) {
debugPrint('[TerminalModel] Failed to process terminal data: $e');
}
@ -361,7 +380,10 @@ class TerminalModel with ChangeNotifier {
/// 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) {
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,
@ -373,34 +395,73 @@ class TerminalModel with ChangeNotifier {
_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;
}
terminal.write(text);
_writeTerminalChunk(text, suppressTerminalOutput: suppressTerminalOutput);
}
void _flushOutputBuffer() {
if (_pendingOutputChunks.isEmpty) return;
debugPrint(
'[TerminalModel] Flushing $_pendingOutputSize buffered chars (${_pendingOutputChunks.length} chunks)');
for (final chunk in _pendingOutputChunks) {
terminal.write(chunk);
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;
@ -426,7 +487,10 @@ class TerminalModel with ChangeNotifier {
// 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();
}

View file

@ -1538,7 +1538,10 @@ class RustdeskImpl {
Future<void> mainAccountAuth(
{required String op, required bool rememberMe, dynamic hint}) {
return Future(() => js.context.callMethod('setByName', [
// 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})
]));
@ -1726,7 +1729,7 @@ class RustdeskImpl {
}
String mainSupportedPrivacyModeImpls({dynamic hint}) {
throw UnimplementedError("mainSupportedPrivacyModeImpls");
return '[]';
}
String mainSupportedInputSource({dynamic hint}) {
@ -2031,7 +2034,14 @@ class RustdeskImpl {
}
String mainResolveAvatarUrl({required String avatar, dynamic hint}) {
return js.context.callMethod('getByName', ['resolve_avatar_url', avatar])?.toString() ?? avatar;
return js.context.callMethod(
'getByName', ['resolve_avatar_url', avatar])?.toString() ??
avatar;
}
Future<String> mainDeployDevice(
{required String token, required String id, dynamic hint}) {
throw UnimplementedError("mainDeployDevice");
}
void dispose() {}

View file

@ -29,6 +29,80 @@ 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.
@ -96,12 +170,12 @@ static void my_application_activate(GApplication* application) {
gtk_widget_show(GTK_WIDGET(window));
gtk_widget_show(GTK_WIDGET(view));
#if defined(GDK_WINDOWING_WAYLAND) && defined(HAS_KEYBOARD_SHORTCUTS_INHIBIT)
// Register callback for sub-windows created by desktop_multi_window plugin
// Only sub-windows (remote windows) need keyboard shortcuts inhibition
// 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)wayland_shortcuts_inhibit_init_for_subwindow);
#endif
(WindowCreatedCallback)on_subwindow_created);
fl_register_plugins(FL_PLUGIN_REGISTRY(view));
@ -116,6 +190,11 @@ static void my_application_activate(GApplication* application) {
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));
}

View file

@ -1,2 +1,2 @@
#!/usr/bin/env bash
cargo ndk --platform 21 --target armv7-linux-androideabi build --release --features flutter,hwcodec
cargo ndk --platform 21 --target armv7-linux-androideabi build --locked --release --features flutter,hwcodec

View file

@ -1,2 +1,2 @@
#!/usr/bin/env bash
cargo ndk --platform 21 --target aarch64-linux-android build --release --features flutter,hwcodec
cargo ndk --platform 21 --target aarch64-linux-android build --locked --release --features flutter,hwcodec

View file

@ -1,2 +1,2 @@
#!/usr/bin/env bash
cargo ndk --platform 21 --target x86_64-linux-android build --release --features flutter
cargo ndk --platform 21 --target x86_64-linux-android build --locked --release --features flutter

View file

@ -7,4 +7,4 @@
export CFLAGS="-DBROKEN_CLANG_ATOMICS"
export CXXFLAGS="-DBROKEN_CLANG_ATOMICS"
cargo ndk --platform 21 --target i686-linux-android build --release --features flutter
cargo ndk --platform 21 --target i686-linux-android build --locked --release --features flutter

View file

@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# 1.1.9-1 works for android, but for ios it becomes 1.1.91, need to set it to 1.1.9-a.1 for iOS, will get 1.1.9.1, but iOS store not allow 4 numbers
version: 1.4.6+64
version: 1.4.7+65
environment:
sdk: '^3.1.0'
@ -113,8 +113,8 @@ dependencies:
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

View file

@ -1,9 +1,9 @@
#!/usr/bin/env bash
cargo install flutter_rust_bridge_codegen --version 1.80.1 --features uuid
cargo install flutter_rust_bridge_codegen --version 1.80.1 --features uuid --locked
flutter pub get
~/.cargo/bin/flutter_rust_bridge_codegen --rust-input ../src/flutter_ffi.rs --dart-output ./lib/generated_bridge.dart --c-output ./macos/Runner/bridge_generated.h
# call `flutter clean` if cargo build fails
# export LLVM_HOME=/Library/Developer/CommandLineTools/usr/
cargo build --features flutter
cargo build --locked --features flutter
flutter run $@

View file

@ -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,
);
});
});
}

View file

@ -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

View file

@ -39,6 +39,28 @@
#define CLIPRDR_SVC_CHANNEL_NAME "cliprdr"
/* Maximum number of clipboard streams accepted from a remote peer (integer overflow / DoS guard) */
#define WF_CLIPRDR_MAX_STREAMS 16384
/* Validates the remote descriptor array size after cItems has been read safely. */
static BOOL wf_cliprdr_file_group_descriptor_size_valid(SIZE_T size, UINT count)
{
SIZE_T header_size = offsetof(FILEGROUPDESCRIPTORW, fgd);
SIZE_T descriptors_size;
if (count == 0 || count > WF_CLIPRDR_MAX_STREAMS)
return FALSE;
if (size < header_size)
return FALSE;
if ((SIZE_T)count > (((SIZE_T)-1) - header_size) / sizeof(FILEDESCRIPTORW))
return FALSE;
descriptors_size = header_size + (SIZE_T)count * sizeof(FILEDESCRIPTORW);
return size >= descriptors_size;
}
/**
* Clipboard Formats
*/
@ -224,6 +246,7 @@ struct wf_clipboard
HWND hwnd;
HANDLE hmem;
SIZE_T hmem_data_len;
HANDLE thread;
HANDLE formatDataRespEvent;
BOOL formatDataRespReceived;
@ -624,10 +647,55 @@ void CliprdrStream_Delete(CliprdrStream *instance)
if (instance)
{
free(instance->iStream.lpVtbl);
instance->iStream.lpVtbl = NULL;
free(instance);
}
}
static void wf_cliprdr_release_streams(IStream **streams, ULONG count)
{
ULONG i;
if (!streams)
return;
for (i = 0; i < count; i++)
{
if (streams[i])
CliprdrStream_Release(streams[i]);
}
free(streams);
}
static void wf_cliprdr_reset_streams(CliprdrDataObject *instance)
{
if (!instance)
return;
wf_cliprdr_release_streams(instance->m_pStream, instance->m_nStreams);
instance->m_pStream = NULL;
instance->m_nStreams = 0;
}
/* Only call after clipboard->hmem has been locked by GlobalLock. */
static HRESULT wf_cliprdr_fail_locked_file_descriptor_data(wfClipboard *clipboard,
STGMEDIUM *medium,
CliprdrDataObject *instance,
IStream **streams,
ULONG stream_count,
HRESULT error)
{
GlobalUnlock(clipboard->hmem);
GlobalFree(clipboard->hmem);
clipboard->hmem = NULL;
clipboard->hmem_data_len = 0;
medium->hGlobal = NULL;
wf_cliprdr_release_streams(streams, stream_count);
wf_cliprdr_reset_streams(instance);
return error;
}
/**
* IDataObject
*/
@ -746,6 +814,9 @@ static HRESULT STDMETHODCALLTYPE CliprdrDataObject_GetData(IDataObject *This, FO
{
// FILEGROUPDESCRIPTOR *dsc;
FILEGROUPDESCRIPTORW *dsc;
IStream **streams = NULL;
UINT stream_count = 0;
SIZE_T hmem_size;
// DWORD remote_format_id = get_remote_format_id(clipboard, instance->m_pFormatEtc[idx].cfFormat);
// FIXME: origin code may be failed here???
if (cliprdr_send_data_request(instance->m_connID, clipboard, instance->m_pFormatEtc[idx].cfFormat) != 0)
@ -763,40 +834,48 @@ static HRESULT STDMETHODCALLTYPE CliprdrDataObject_GetData(IDataObject *This, FO
* is the number of FILEDESCRIPTOR's */
// dsc = (FILEGROUPDESCRIPTOR *)GlobalLock(clipboard->hmem);
dsc = (FILEGROUPDESCRIPTORW *)GlobalLock(clipboard->hmem);
instance->m_nStreams = dsc->cItems;
GlobalUnlock(clipboard->hmem);
if (instance->m_nStreams > 0)
if (!dsc)
{
if (!instance->m_pStream)
{
instance->m_pStream = (LPSTREAM *)calloc(instance->m_nStreams, sizeof(LPSTREAM));
if (instance->m_pStream)
{
for (i = 0; i < instance->m_nStreams; i++)
{
instance->m_pStream[i] =
(IStream *)CliprdrStream_New(instance->m_connID, i, clipboard, &dsc->fgd[i]);
if (!instance->m_pStream[i])
return E_OUTOFMEMORY;
}
}
}
}
if (!instance->m_pStream)
{
if (clipboard->hmem)
{
GlobalFree(clipboard->hmem);
clipboard->hmem = NULL;
}
pMedium->hGlobal = NULL;
return E_OUTOFMEMORY;
GlobalFree(clipboard->hmem);
clipboard->hmem = NULL;
clipboard->hmem_data_len = 0;
wf_cliprdr_reset_streams(instance);
return E_UNEXPECTED;
}
hmem_size = clipboard->hmem_data_len;
/* cItems is remote-controlled; verify the fixed header exists before reading it. */
if (hmem_size < offsetof(FILEGROUPDESCRIPTORW, fgd))
return wf_cliprdr_fail_locked_file_descriptor_data(
clipboard, pMedium, instance, NULL, 0, E_UNEXPECTED);
stream_count = dsc->cItems;
if (!wf_cliprdr_file_group_descriptor_size_valid(hmem_size, stream_count))
return wf_cliprdr_fail_locked_file_descriptor_data(
clipboard, pMedium, instance, NULL, 0, E_UNEXPECTED);
streams = (IStream **)calloc(stream_count, sizeof(IStream *));
if (!streams)
return wf_cliprdr_fail_locked_file_descriptor_data(
clipboard, pMedium, instance, NULL, 0, E_OUTOFMEMORY);
for (i = 0; i < stream_count; i++)
{
streams[i] =
(IStream *)CliprdrStream_New(instance->m_connID, i, clipboard, &dsc->fgd[i]);
if (!streams[i])
{
return wf_cliprdr_fail_locked_file_descriptor_data(
clipboard, pMedium, instance, streams, i, E_OUTOFMEMORY);
}
}
GlobalUnlock(clipboard->hmem);
wf_cliprdr_reset_streams(instance);
instance->m_pStream = streams;
instance->m_nStreams = stream_count;
return S_OK;
}
else if (instance->m_pFormatEtc[idx].cfFormat == RegisterClipboardFormat(CFSTR_FILECONTENTS))
{
@ -2160,16 +2239,16 @@ 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);
if (!clipboard->file_names[clipboard->nFiles])
return FALSE;
// `MAX_PATH` is long enough for the file name.
// So we just return FALSE if the file name is too long, which is not a normal case.
if ((wcslen(full_file_name) + 1) > MAX_PATH)
return FALSE;
clipboard->file_names[clipboard->nFiles] = (LPWSTR)calloc(MAX_PATH, sizeof(WCHAR));
if (!clipboard->file_names[clipboard->nFiles])
return FALSE;
wcsncpy_s(clipboard->file_names[clipboard->nFiles], MAX_PATH, full_file_name, wcslen(full_file_name) + 1);
/* add to descriptor array */
clipboard->fileDescriptor[clipboard->nFiles] =
@ -2777,6 +2856,7 @@ wf_cliprdr_server_format_data_response(CliprdrClientContext *context,
break;
}
clipboard->hmem = NULL;
clipboard->hmem_data_len = 0;
if (formatDataResponse->msgFlags != CB_RESPONSE_OK)
{
@ -2810,6 +2890,7 @@ wf_cliprdr_server_format_data_response(CliprdrClientContext *context,
break;
}
clipboard->hmem_data_len = formatDataResponse->dataLen;
clipboard->hmem = hMem;
rc = CHANNEL_RC_OK;
} while (0);

View file

@ -8,6 +8,7 @@
use crate::{Key, KeyboardControllable, MouseButton, MouseControllable};
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};
@ -32,6 +33,51 @@ 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: *mut xdo_t,
@ -52,6 +98,7 @@ impl Default for EnigoXdo {
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,

@ -1 +1 @@
Subproject commit 618922b2a77f7be44fc7b86e41f6cfba87d62193
Subproject commit 387603f47cbb15c0d3dc3d67ae3396d3eb707daf

View file

@ -1,6 +1,6 @@
[package]
name = "rustdesk-portable-packer"
version = "1.4.6"
version = "1.4.7"
edition = "2021"
description = "RustDesk Remote Desktop"

View file

@ -67,9 +67,9 @@ def write_app_metadata(output_folder: str):
def build_portable(output_folder: str, target: str):
os.chdir(output_folder)
if target:
os.system("cargo build --release --target " + target)
os.system("cargo build --locked --release --target " + target)
else:
os.system("cargo build --release")
os.system("cargo build --locked --release")
# Linux: python3 generate.py -f ../rustdesk-portable-packer/test -o . -e ./test/main.py
# Windows: python3 .\generate.py -f ..\rustdesk\flutter\build\windows\runner\Debug\ -o . -e ..\rustdesk\flutter\build\windows\runner\Debug\rustdesk.exe

View file

@ -151,7 +151,7 @@ fn create_media_codec(name: &str, direction: MediaCodecDirection) -> Option<Medi
log::error!("Failed to start decoder: {:?}", e);
return None;
};
log::debug!("Init decoder successed!: {:?}", name);
log::debug!("Init decoder succeeded!: {:?}", name);
return Some(MediaCodecDecoder {
decoder: codec,
name: name.to_owned(),

View file

@ -276,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::<AppSink>()

View file

@ -29,4 +29,4 @@ TODO
## X11
## OSX
## macOS

View file

@ -1,5 +1,5 @@
pkgname=rustdesk
pkgver=1.4.6
pkgver=1.4.7
pkgrel=0
epoch=
pkgdesc=""

82
res/audits.py Normal file → Executable file
View file

@ -43,7 +43,7 @@ def get_connection_type_name(conn_type):
"""Convert connection type number to readable name"""
type_map = {
0: "Remote Desktop",
1: "File Transfer",
1: "File Transfer",
2: "Port Transfer",
3: "View Camera",
4: "Terminal"
@ -55,7 +55,7 @@ def get_console_type_name(console_type):
"""Convert console audit type number to readable name"""
type_map = {
0: "Group Management",
1: "User Management",
1: "User Management",
2: "Device Management",
3: "Address Book Management"
}
@ -67,7 +67,7 @@ def get_console_operation_name(operation_code):
operation_map = {
0: "User Login",
1: "Add Group",
2: "Add User",
2: "Add User",
3: "Add Device",
4: "Delete Groups",
5: "Disconnect Device",
@ -95,7 +95,7 @@ def get_console_operation_name(operation_code):
def get_alarm_type_name(alarm_type):
"""Convert alarm type number to readable name"""
type_map = {
0: "Access attempt outside the IP whiltelist",
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",
@ -109,24 +109,24 @@ 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
@ -136,14 +136,14 @@ def enhance_audit_data(data, audit_type):
# 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
@ -152,7 +152,7 @@ def check_response(response):
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:
@ -163,28 +163,28 @@ def check_response(response):
return response.text or "Success"
def view_audits_common(url, token, endpoint, filters=None, page_size=None, current=None,
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
@ -205,10 +205,10 @@ def view_audits_common(url, token, endpoint, filters=None, page_size=None, curre
# 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:
@ -221,10 +221,10 @@ def view_audits_common(url, token, endpoint, filters=None, page_size=None, curre
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),
@ -233,7 +233,7 @@ def view_audits_common(url, token, endpoint, filters=None, page_size=None, curre
}
def view_conn_audits(url, token, remote=None, conn_type=None,
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 = {
@ -241,7 +241,7 @@ def view_conn_audits(url, token, remote=None, conn_type=None,
"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
)
@ -254,7 +254,7 @@ def view_file_audits(url, token, remote=None,
"remote": remote
}
non_wildcard_fields = set()
return view_audits_common(
url, token, "file", filters, page_size, current, created_at, days_ago, non_wildcard_fields
)
@ -267,7 +267,7 @@ def view_alarm_audits(url, token, device=None,
"device": device
}
non_wildcard_fields = set()
return view_audits_common(
url, token, "alarm", filters, page_size, current, created_at, days_ago, non_wildcard_fields
)
@ -280,7 +280,7 @@ def view_console_audits(url, token, operator=None,
"operator": operator
}
non_wildcard_fields = set()
return view_audits_common(
url, token, "console", filters, page_size, current, created_at, days_ago, non_wildcard_fields
)
@ -295,15 +295,15 @@ def main():
)
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)")
@ -319,9 +319,9 @@ def main():
if args.command == "view-conn":
# View connection audits
result = view_conn_audits(
args.url,
args.token,
args.remote,
args.url,
args.token,
args.remote,
args.conn_type,
args.page_size,
args.current,
@ -329,12 +329,12 @@ def main():
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.url,
args.token,
args.remote,
args.page_size,
args.current,
@ -342,12 +342,12 @@ def main():
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.url,
args.token,
args.device,
args.page_size,
args.current,
@ -355,12 +355,12 @@ def main():
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.url,
args.token,
args.operator,
args.page_size,
args.current,

View file

@ -31,17 +31,17 @@ LExit:
return WcaFinalize(er);
}
// Helper function to safely delete a file or directory using handle-based deletion.
// This avoids TOCTOU (Time-Of-Check-Time-Of-Use) race conditions.
// 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 access and FILE_FLAG_OPEN_REPARSE_POINT
// 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,
DELETE | FILE_READ_ATTRIBUTES,
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, // Allow shared access
NULL,
OPEN_EXISTING,
@ -55,6 +55,21 @@ BOOL SafeDeleteItem(LPCWSTR fullPath)
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;
@ -77,98 +92,74 @@ BOOL SafeDeleteItem(LPCWSTR fullPath)
return result;
}
// Helper function to recursively delete a directory's contents with detailed logging.
void RecursiveDelete(LPCWSTR path)
BOOL PathEndsWithSlash(LPCWSTR path)
{
// Ensure the path is not empty or null.
if (path == NULL || path[0] == L'\0')
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;
}
// Extra safety: never operate directly on a root path.
if (PathIsRootW(path))
DWORD writableAttributes = attributes & ~FILE_ATTRIBUTE_READONLY;
if (writableAttributes == 0)
{
WcaLog(LOGMSG_STANDARD, "RecursiveDelete: refusing to operate on root path '%ls'.", path);
writableAttributes = FILE_ATTRIBUTE_NORMAL;
}
if (SetFileAttributesW(fullPath, writableAttributes))
{
WcaLog(LOGMSG_STANDARD, "Runtime cleanup cleared read-only attribute for '%ls'.", fullPath);
return;
}
// MAX_PATH is enough here since the installer should not be using longer paths.
// No need to handle extended-length paths (\\?\) in this context.
WCHAR searchPath[MAX_PATH];
HRESULT hr = StringCchPrintfW(searchPath, MAX_PATH, L"%s\\*", path);
if (FAILED(hr)) {
WcaLog(LOGMSG_STANDARD, "RecursiveDelete: Path too long to enumerate: %ls", path);
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;
}
WIN32_FIND_DATAW findData;
HANDLE hFind = FindFirstFileW(searchPath, &findData);
if (hFind == INVALID_HANDLE_VALUE)
DWORD attributes = GetFileAttributesW(fullPath);
if (attributes == INVALID_FILE_ATTRIBUTES)
{
// This can happen if the directory is empty or doesn't exist, which is not an error in our case.
WcaLog(LOGMSG_STANDARD, "RecursiveDelete: Failed to enumerate directory '%ls'. It may be missing or inaccessible. Error: %lu", path, GetLastError());
return;
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;
}
do
if (attributes & FILE_ATTRIBUTE_DIRECTORY)
{
// Skip '.' and '..' directories.
if (wcscmp(findData.cFileName, L".") == 0 || wcscmp(findData.cFileName, L"..") == 0)
{
continue;
}
// MAX_PATH is enough here since the installer should not be using longer paths.
// No need to handle extended-length paths (\\?\) in this context.
WCHAR fullPath[MAX_PATH];
hr = StringCchPrintfW(fullPath, MAX_PATH, L"%s\\%s", path, findData.cFileName);
if (FAILED(hr)) {
WcaLog(LOGMSG_STANDARD, "RecursiveDelete: Path too long for item '%ls' in '%ls', skipping.", findData.cFileName, path);
continue;
}
// Before acting, ensure the read-only attribute is not set.
if (findData.dwFileAttributes & FILE_ATTRIBUTE_READONLY)
{
if (FALSE == SetFileAttributesW(fullPath, findData.dwFileAttributes & ~FILE_ATTRIBUTE_READONLY))
{
WcaLog(LOGMSG_STANDARD, "RecursiveDelete: Failed to remove read-only attribute. Error: %lu", GetLastError());
}
}
if (findData.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY)
{
// Check for reparse points (symlinks/junctions) to prevent directory traversal attacks.
// Do not follow reparse points, only remove the link itself.
if (findData.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT)
{
WcaLog(LOGMSG_STANDARD, "RecursiveDelete: Not recursing into reparse point (symlink/junction), deleting link itself: %ls", fullPath);
SafeDeleteItem(fullPath);
}
else
{
// Recursively delete directory contents first
RecursiveDelete(fullPath);
// Then delete the directory itself
SafeDeleteItem(fullPath);
}
}
else
{
// Delete file using safe handle-based deletion
SafeDeleteItem(fullPath);
}
} while (FindNextFileW(hFind, &findData) != 0);
DWORD lastError = GetLastError();
if (lastError != ERROR_NO_MORE_FILES)
{
WcaLog(LOGMSG_STANDARD, "RecursiveDelete: FindNextFileW failed with error %lu", lastError);
WcaLog(LOGMSG_STANDARD, "Runtime cleanup skipped directory '%ls'.", fullPath);
return FALSE;
}
FindClose(hFind);
ClearReadOnlyAttribute(fullPath, attributes);
WcaLog(LOGMSG_STANDARD, "Runtime cleanup deleting '%ls'.", fullPath);
return SafeDeleteItem(fullPath);
}
// See `Package.wxs` for the sequence of this custom action.
@ -178,13 +169,13 @@ void RecursiveDelete(LPCWSTR path)
// 2. RemoveExistingProducts
// ├─ TerminateProcesses
// ├─ TryStopDeleteService
// ├─ RemoveInstallFolder - <-- Here
// ├─ RemoveRuntimeGeneratedFiles - <-- Here
// └─ RemoveFiles
// 3. InstallValidate
// 4. InstallFiles
// 5. InstallExecute
// 6. InstallFinalize
UINT __stdcall RemoveInstallFolder(
UINT __stdcall RemoveRuntimeGeneratedFiles(
__in MSIHANDLE hInstall)
{
HRESULT hr = S_OK;
@ -194,7 +185,7 @@ UINT __stdcall RemoveInstallFolder(
LPWSTR pwz = NULL;
LPWSTR pwzData = NULL;
hr = WcaInitialize(hInstall, "RemoveInstallFolder");
hr = WcaInitialize(hInstall, "RemoveRuntimeGeneratedFiles");
ExitOnFailure(hr, "Failed to initialize");
hr = WcaGetProperty(L"CustomActionData", &pwzData);
@ -202,24 +193,20 @@ 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);
if (installFolder == NULL || installFolder[0] == L'\0') {
WcaLog(LOGMSG_STANDARD, "Install folder path is empty, skipping recursive delete.");
WcaLog(LOGMSG_STANDARD, "Install folder path is empty, skipping runtime cleanup.");
goto LExit;
}
if (PathIsRootW(installFolder)) {
WcaLog(LOGMSG_STANDARD, "Refusing to recursively delete root folder '%ls'.", installFolder);
WcaLog(LOGMSG_STANDARD, "Refusing runtime cleanup in root folder '%ls'.", installFolder);
goto LExit;
}
WcaLog(LOGMSG_STANDARD, "Attempting to recursively delete contents of install folder: %ls", installFolder);
RecursiveDelete(installFolder);
// The standard MSI 'RemoveFolders' action will take care of removing the (now empty) directories.
// We don't need to call RemoveDirectoryW on installFolder itself, as it might still be in use by the installer.
WcaLog(LOGMSG_STANDARD, "Removing runtime-generated files from install folder: %ls", installFolder);
DeleteRuntimeGeneratedFile(installFolder, L"RuntimeBroker_rustdesk.exe");
LExit:
ReleaseStr(pwzData);
@ -616,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)) {
@ -645,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);
@ -758,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);
@ -874,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'"') {

View file

@ -2,7 +2,7 @@ LIBRARY "CustomActions"
EXPORTS
CustomActionHello
RemoveInstallFolder
RemoveRuntimeGeneratedFiles
TerminateProcesses
AddFirewallRules
SetPropertyIsServiceRunning

View file

@ -16,8 +16,15 @@
<!-- If a command line value was stored, restore it after the registry search has been performed -->
<SetProperty Action="RestoreSavedInstallFolderValue" Id="INSTALLFOLDER" Value="[SavedInstallFolderCmdLineValue]" After="AppSearch" Sequence="first" Condition="SavedInstallFolderCmdLineValue" />
<!-- If a command line value or registry value was set, update the main properties with the value -->
<SetProperty Id="INSTALLFOLDER_INNER" Value="[INSTALLFOLDER]" After="RestoreSavedInstallFolderValue" Sequence="first" Condition="INSTALLFOLDER" />
<!-- Normalize INSTALLFOLDER from the command line or registry before assigning INSTALLFOLDER_INNER. -->
<!-- Case 1: already ends with \$(var.Product)\, keep it unchanged. -->
<SetProperty Action="SetInstallFolderInnerFromProductDir" Id="INSTALLFOLDER_INNER" Value="[INSTALLFOLDER]" After="RestoreSavedInstallFolderValue" Sequence="first" Condition="INSTALLFOLDER AND INSTALLFOLDER ~&gt;&gt; &quot;\$(var.Product)\&quot;" />
<!-- Case 2: already ends with \$(var.Product) but has no trailing slash, add the slash. -->
<SetProperty Action="SetInstallFolderInnerFromProductDirNoSlash" Id="INSTALLFOLDER_INNER" Value="[INSTALLFOLDER]\" After="RestoreSavedInstallFolderValue" Sequence="first" Condition="INSTALLFOLDER AND INSTALLFOLDER ~&gt;&gt; &quot;\$(var.Product)&quot;" />
<!-- Case 3: ends with a slash but not \$(var.Product)\, append $(var.Product)\. -->
<SetProperty Action="SetInstallFolderInnerAppendProduct" Id="INSTALLFOLDER_INNER" Value="[INSTALLFOLDER]$(var.Product)\" After="RestoreSavedInstallFolderValue" Sequence="first" Condition="INSTALLFOLDER AND INSTALLFOLDER ~&gt;&gt; &quot;\&quot; AND NOT (INSTALLFOLDER ~&gt;&gt; &quot;\$(var.Product)\&quot; OR INSTALLFOLDER ~&gt;&gt; &quot;\$(var.Product)&quot;)" />
<!-- Case 4: has no trailing slash and does not end with \$(var.Product), append \$(var.Product)\. -->
<SetProperty Action="SetInstallFolderInnerAppendSlashProduct" Id="INSTALLFOLDER_INNER" Value="[INSTALLFOLDER]\$(var.Product)\" After="RestoreSavedInstallFolderValue" Sequence="first" Condition="INSTALLFOLDER AND NOT INSTALLFOLDER ~&gt;&gt; &quot;\&quot; AND NOT (INSTALLFOLDER ~&gt;&gt; &quot;\$(var.Product)\&quot; OR INSTALLFOLDER ~&gt;&gt; &quot;\$(var.Product)&quot;)" />
<!-- INSTALLFOLDER_INNER is defined for compatibility with previous versions of the installer. -->
<!-- Because we need to use INSTALLFOLDER as the command line argument. -->

View file

@ -12,7 +12,7 @@
</Component>
</DirectoryRef>
<CustomAction Id="RemoveInstallFolder.SetParam" Return="check" Property="RemoveInstallFolder" Value="[INSTALLFOLDER_INNER]" />
<CustomAction Id="RemoveRuntimeGeneratedFiles.SetParam" Return="check" Property="RemoveRuntimeGeneratedFiles" Value="[INSTALLFOLDER_INNER]" />
<CustomAction Id="AddFirewallRules.SetParam" Return="check" Property="AddFirewallRules" Value="1[INSTALLFOLDER_INNER]$(var.Product).exe" />
<CustomAction Id="RemoveFirewallRules.SetParam" Return="check" Property="RemoveFirewallRules" Value="0[INSTALLFOLDER_INNER]$(var.Product).exe" />
<CustomAction Id="CreateStartService.SetParam" Return="check" Property="CreateStartService" Value="$(var.Product);&quot;[INSTALLFOLDER_INNER]$(var.Product).exe&quot; --service" />
@ -67,7 +67,7 @@
Some msi packages reset the `VersionNT` value to 1000 on Windows 10.
https://www.advancedinstaller.com/user-guide/qa-OS-dependent-install.html -->
<!-- Remote printer also works on Win8.1 in my test. -->
<Custom Action="InstallPrinter" Before="InstallFinalize" Condition="VersionNT &gt;= 603 AND PRINTER = 1 OR PRINTER = &quot;Y&quot; OR PRINTER = &quot;y&quot;" />
<Custom Action="InstallPrinter" Before="InstallFinalize" Condition="VersionNT &gt;= 603 AND (PRINTER = 1 OR PRINTER = &quot;Y&quot; OR PRINTER = &quot;y&quot;)" />
<Custom Action="InstallPrinter.SetParam" Before="InstallPrinter" Condition="VersionNT &gt;= 603" />
<!--Workaround of "fire:FirewallException". If Outbound="Yes" or Outbound="true", the following error occurs.-->
@ -77,21 +77,21 @@
<Custom Action="AddRegSoftwareSASGeneration" Before="InstallFinalize" Condition="NOT (Installed AND REMOVE AND NOT UPGRADINGPRODUCTCODE) AND (NOT CC_CONNECTION_TYPE=&quot;outgoing&quot;)"/>
<Custom Action="RemoveInstallFolder" Before="RemoveFiles"/>
<Custom Action="RemoveInstallFolder.SetParam" Before="RemoveInstallFolder"/>
<Custom Action="TryStopDeleteService" Before="RemoveInstallFolder.SetParam" />
<Custom Action="RemoveRuntimeGeneratedFiles" Before="RemoveFiles" Condition="Installed AND (REMOVE=&quot;ALL&quot; OR UPGRADINGPRODUCTCODE)"/>
<Custom Action="RemoveRuntimeGeneratedFiles.SetParam" Before="RemoveRuntimeGeneratedFiles" Condition="Installed AND (REMOVE=&quot;ALL&quot; OR UPGRADINGPRODUCTCODE)"/>
<Custom Action="TryStopDeleteService" Before="RemoveRuntimeGeneratedFiles.SetParam" />
<Custom Action="TryStopDeleteService.SetParam" Before="TryStopDeleteService" />
<Custom Action="RemoveFirewallRules" Before="RemoveFiles"/>
<Custom Action="RemoveFirewallRules.SetParam" Before="RemoveFirewallRules"/>
<Custom Action="UninstallPrinter" Before="RemoveInstallFolder" Condition="VersionNT &gt;= 603" />
<Custom Action="UninstallPrinter" Before="RemoveRuntimeGeneratedFiles" Condition="VersionNT &gt;= 603" />
<Custom Action="TerminateProcesses" Before="RemoveInstallFolder"/>
<Custom Action="TerminateProcesses" Before="RemoveRuntimeGeneratedFiles"/>
<Custom Action="TerminateProcesses.SetParam" Before="TerminateProcesses"/>
<Custom Action="TerminateBrokers" Before="RemoveInstallFolder"/>
<Custom Action="TerminateBrokers" Before="RemoveRuntimeGeneratedFiles"/>
<Custom Action="TerminateBrokers.SetParam" Before="TerminateBrokers"/>
<Custom Action="RemoveAmyuniIdd" Before="RemoveInstallFolder"/>
<Custom Action="RemoveAmyuniIdd" Before="RemoveRuntimeGeneratedFiles"/>
<Custom Action="RemoveAmyuniIdd.SetParam" Before="RemoveAmyuniIdd"/>
</InstallExecuteSequence>

View file

@ -5,7 +5,7 @@
<Binary Id="Custom_Actions_Dll" SourceFile="$(var.CustomActions.TargetDir)$(var.CustomActions.TargetName).dll" />
<CustomAction Id="CustomActionHello" DllEntry="CustomActionHello" Impersonate="yes" Execute="immediate" Return="ignore" BinaryRef="Custom_Actions_Dll"/>
<CustomAction Id="RemoveInstallFolder" DllEntry="RemoveInstallFolder" Impersonate="no" Execute="deferred" Return="ignore" BinaryRef="Custom_Actions_Dll"/>
<CustomAction Id="RemoveRuntimeGeneratedFiles" DllEntry="RemoveRuntimeGeneratedFiles" Impersonate="no" Execute="deferred" Return="ignore" BinaryRef="Custom_Actions_Dll"/>
<CustomAction Id="TerminateProcesses" DllEntry="TerminateProcesses" Impersonate="yes" Execute="immediate" Return="ignore" BinaryRef="Custom_Actions_Dll"/>
<CustomAction Id="TerminateBrokers" DllEntry="TerminateProcesses" Impersonate="yes" Execute="immediate" Return="ignore" BinaryRef="Custom_Actions_Dll"/>
<CustomAction Id="AddFirewallRules" DllEntry="AddFirewallRules" Impersonate="no" Execute="deferred" Return="ignore" BinaryRef="Custom_Actions_Dll"/>

View file

@ -4,17 +4,17 @@
<?include ..\Includes.wxi?>
<!--
Properties and related actions for specifying whether to install start menu/desktop shortcuts.
Properties and related actions for specifying whether to install shortcuts and the printer.
-->
<!-- These are the actual properties that get used in conditions to determine whether to
install start menu shortcuts, they are initialized with a default value to install shortcuts.
They should not be set directly from the command line or registry, instead the CREATE* properties
below should be set, then they will update these properties with their values only if set. -->
install start menu shortcuts or the printer. Shortcut properties default to install;
PRINTER defaults to not install. The CREATE* properties below update shortcut
properties from command line, bundle, or registry values. -->
<Property Id="STARTMENUSHORTCUTS" Value="1" Secure="yes"></Property>
<Property Id="DESKTOPSHORTCUTS" Value="1" Secure="yes"></Property>
<Property Id="STARTUPSHORTCUTS" Value="1" Secure="yes"></Property>
<Property Id="PRINTER" Value="1" Secure="yes"></Property>
<Property Id="PRINTER" Secure="yes"></Property>
<!-- These properties get set from either the command line, bundle or registry value,
if set they update the properties above with their value. -->
@ -77,7 +77,11 @@
<!-- If a command line value or registry value was set, update the main properties with the value -->
<SetProperty Id="STARTMENUSHORTCUTS" Value="" After="RestoreSavedStartMenuShortcutsValue" Sequence="first" Condition="CREATESTARTMENUSHORTCUTS AND NOT (CREATESTARTMENUSHORTCUTS = 1 OR CREATESTARTMENUSHORTCUTS = &quot;Y&quot; OR CREATESTARTMENUSHORTCUTS = &quot;y&quot;)" />
<SetProperty Id="DESKTOPSHORTCUTS" Value="" After="RestoreSavedDesktopShortcutsValue" Sequence="first" Condition="CREATEDESKTOPSHORTCUTS AND NOT (CREATEDESKTOPSHORTCUTS = 1 OR CREATEDESKTOPSHORTCUTS = &quot;Y&quot; OR CREATEDESKTOPSHORTCUTS = &quot;y&quot;)" />
<SetProperty Id="PRINTER" Value="" After="RestoreSavedPrinterValue" Sequence="first" Condition="INSTALLPRINTER AND NOT (INSTALLPRINTER = 1 OR INSTALLPRINTER = &quot;Y&quot; OR INSTALLPRINTER = &quot;y&quot;)" />
<!-- PRINTER defaults to empty now, so a saved or command-line INSTALLPRINTER=1
must explicitly enable the main PRINTER property. Non-truthy INSTALLPRINTER
values still clear PRINTER so upgrades preserve an explicit disabled choice. -->
<SetProperty Action="SetPrinterValueEnabled" Id="PRINTER" Value="1" After="RestoreSavedPrinterValue" Sequence="first" Condition="INSTALLPRINTER = 1 OR INSTALLPRINTER = &quot;Y&quot; OR INSTALLPRINTER = &quot;y&quot;" />
<SetProperty Action="SetPrinterValueDisabled" Id="PRINTER" Value="" After="SetPrinterValueEnabled" Sequence="first" Condition="INSTALLPRINTER AND NOT (INSTALLPRINTER = 1 OR INSTALLPRINTER = &quot;Y&quot; OR INSTALLPRINTER = &quot;y&quot;)" />
</Fragment>
</Wix>

View file

@ -23,12 +23,13 @@ Patch dialog sequence:
-->
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs" xmlns:ui="http://wixtoolset.org/schemas/v4/wxs/ui">
<?include ../Includes.wxi?>
<?foreach WIXUIARCH in X86;X64;A64 ?>
<Fragment>
<UI Id="UI_MyInstallDialog_$(WIXUIARCH)">
<Publish Dialog="LicenseAgreementDlg" Control="Print" Event="DoAction" Value="WixUIPrintEula_$(WIXUIARCH)" />
<Publish Dialog="BrowseDlg" Control="OK" Event="DoAction" Value="WixUIValidatePath_$(WIXUIARCH)" Order="3" Condition="NOT WIXUI_DONTVALIDATEPATH" />
<Publish Dialog="MyInstallDirDlg" Control="Next" Event="DoAction" Value="WixUIValidatePath_$(WIXUIARCH)" Order="2" Condition="NOT WIXUI_DONTVALIDATEPATH" />
<Publish Dialog="MyInstallDirDlg" Control="Next" Event="DoAction" Value="WixUIValidatePath_$(WIXUIARCH)" Order="5" Condition="NOT WIXUI_DONTVALIDATEPATH" />
</UI>
<UIRef Id="UI_MyInstallDialog" />
@ -64,9 +65,16 @@ Patch dialog sequence:
<Publish Dialog="LicenseAgreementDlg" Control="Next" Event="NewDialog" Value="MyInstallDirDlg" Condition="LicenseAccepted = &quot;1&quot;" />
<Publish Dialog="MyInstallDirDlg" Control="Back" Event="NewDialog" Value="LicenseAgreementDlg" />
<Publish Dialog="MyInstallDirDlg" Control="Next" Event="SetTargetPath" Value="[WIXUI_INSTALLDIR]" Order="1" />
<Publish Dialog="MyInstallDirDlg" Control="Next" Event="SpawnDialog" Value="InvalidDirDlg" Order="3" Condition="NOT WIXUI_DONTVALIDATEPATH AND WIXUI_INSTALLDIR_VALID&lt;&gt;&quot;1&quot;" />
<Publish Dialog="MyInstallDirDlg" Control="Next" Event="NewDialog" Value="VerifyReadyDlg" Order="4" Condition="WIXUI_DONTVALIDATEPATH OR WIXUI_INSTALLDIR_VALID=&quot;1&quot;" />
<!-- Normalize INSTALLFOLDER_INNER before SetTargetPath and WixUIValidatePath run. -->
<!-- UI case 1: already ends with \$(var.Product) but has no trailing slash, add the slash. -->
<Publish Dialog="MyInstallDirDlg" Control="Next" Property="INSTALLFOLDER_INNER" Value="[INSTALLFOLDER_INNER]\" Order="1" Condition="INSTALLFOLDER_INNER AND INSTALLFOLDER_INNER ~&gt;&gt; &quot;\$(var.Product)&quot;" />
<!-- UI case 2: ends with a slash but not \$(var.Product)\, append $(var.Product)\. -->
<Publish Dialog="MyInstallDirDlg" Control="Next" Property="INSTALLFOLDER_INNER" Value="[INSTALLFOLDER_INNER]$(var.Product)\" Order="2" Condition="INSTALLFOLDER_INNER AND INSTALLFOLDER_INNER ~&gt;&gt; &quot;\&quot; AND NOT (INSTALLFOLDER_INNER ~&gt;&gt; &quot;\$(var.Product)\&quot; OR INSTALLFOLDER_INNER ~&gt;&gt; &quot;\$(var.Product)&quot;)" />
<!-- UI case 3: has no trailing slash and does not end with \$(var.Product), append \$(var.Product)\. -->
<Publish Dialog="MyInstallDirDlg" Control="Next" Property="INSTALLFOLDER_INNER" Value="[INSTALLFOLDER_INNER]\$(var.Product)\" Order="3" Condition="INSTALLFOLDER_INNER AND NOT INSTALLFOLDER_INNER ~&gt;&gt; &quot;\&quot; AND NOT (INSTALLFOLDER_INNER ~&gt;&gt; &quot;\$(var.Product)\&quot; OR INSTALLFOLDER_INNER ~&gt;&gt; &quot;\$(var.Product)&quot;)" />
<Publish Dialog="MyInstallDirDlg" Control="Next" Event="SetTargetPath" Value="[WIXUI_INSTALLDIR]" Order="4" />
<Publish Dialog="MyInstallDirDlg" Control="Next" Event="SpawnDialog" Value="InvalidDirDlg" Order="6" Condition="NOT WIXUI_DONTVALIDATEPATH AND WIXUI_INSTALLDIR_VALID&lt;&gt;&quot;1&quot;" />
<Publish Dialog="MyInstallDirDlg" Control="Next" Event="NewDialog" Value="VerifyReadyDlg" Order="7" Condition="WIXUI_DONTVALIDATEPATH OR WIXUI_INSTALLDIR_VALID=&quot;1&quot;" />
<Publish Dialog="MyInstallDirDlg" Control="ChangeFolder" Property="_BrowseProperty" Value="[WIXUI_INSTALLDIR]" Order="1" />
<Publish Dialog="MyInstallDirDlg" Control="ChangeFolder" Event="SpawnDialog" Value="BrowseDlg" Order="2" />
<Publish Dialog="VerifyReadyDlg" Control="Back" Event="NewDialog" Value="MyInstallDirDlg" Order="1" Condition="NOT Installed" />

View file

@ -1,7 +1,7 @@
#!/usr/bin/env bash
echo $MACOS_CODESIGN_IDENTITY
cargo install flutter_rust_bridge_codegen --version 1.80.1 --features uuid
cargo install flutter_rust_bridge_codegen --version 1.80.1 --features uuid --locked
cd flutter; flutter pub get; cd -
~/.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
./build.py --flutter

View file

@ -1,5 +1,5 @@
Name: rustdesk
Version: 1.4.6
Version: 1.4.7
Release: 0
Summary: RPM package
License: GPL-3.0

View file

@ -1,5 +1,5 @@
Name: rustdesk
Version: 1.4.6
Version: 1.4.7
Release: 0
Summary: RPM package
License: GPL-3.0

View file

@ -1,5 +1,5 @@
Name: rustdesk
Version: 1.4.6
Version: 1.4.7
Release: 0
Summary: RPM package
License: GPL-3.0

View file

@ -25,7 +25,13 @@ impl Session {
pub fn new(id: &str, sender: mpsc::UnboundedSender<Data>) -> 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(),

View file

@ -1745,6 +1745,9 @@ pub struct LoginConfigHandler {
pub direct: Option<bool>,
pub received: bool,
switch_uuid: Option<String>,
#[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<Mutex<Option<usize>>>,
@ -1861,6 +1864,11 @@ impl LoginConfigHandler {
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;
@ -1874,6 +1882,23 @@ impl LoginConfigHandler {
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.
/// Return password if the client should auto login, otherwise return empty string.
pub fn should_auto_login(&self) -> String {
@ -3377,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.
///
@ -3397,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
@ -3870,6 +3935,7 @@ pub fn check_if_retry(msgtype: &str, title: &str, text: &str, retry_for_relay: b
&& !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")))
}

View file

@ -1448,6 +1448,23 @@ impl<T: InvokeUiSession> Remote<T> {
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);
}
@ -1780,6 +1797,9 @@ impl<T: InvokeUiSession> Remote<T> {
Ok(Permission::BlockInput) => {
self.handler.set_permission("block_input", p.enabled);
}
Ok(Permission::PrivacyMode) => {
self.handler.set_permission("privacy_mode", p.enabled);
}
_ => {}
}
}
@ -1903,9 +1923,23 @@ impl<T: InvokeUiSession> Remote<T> {
);
}
}
#[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")))]

View file

@ -1,5 +1,7 @@
#[cfg(not(target_os = "android"))]
use arboard::{ClipboardData, ClipboardFormat};
#[cfg(target_os = "linux")]
use arboard::{LinuxClipboardKind, SetExtLinux};
use hbb_common::{bail, log, message_proto::*, ResultType};
use std::{
sync::{Arc, Mutex},
@ -54,6 +56,27 @@ pub fn check_clipboard(
side: ClipboardSide,
force: bool,
) -> Option<Message> {
let (msg, clipboards) = read_clipboard_message(ctx, side, force)?;
*LAST_MULTI_CLIPBOARDS.lock().unwrap() = clipboards;
Some(msg)
}
#[cfg(target_os = "linux")]
pub fn peek_clipboard(
ctx: &mut Option<ClipboardContext>,
side: ClipboardSide,
force: bool,
) -> Option<Message> {
let (msg, _) = read_clipboard_message(ctx, side, force)?;
Some(msg)
}
#[cfg(not(target_os = "android"))]
fn read_clipboard_message(
ctx: &mut Option<ClipboardContext>,
side: ClipboardSide,
force: bool,
) -> Option<(Message, MultiClipboards)> {
if ctx.is_none() {
*ctx = ClipboardContext::new().ok();
}
@ -64,8 +87,7 @@ pub fn check_clipboard(
let mut msg = Message::new();
let clipboards = proto::create_multi_clipboards(content);
msg.set_multi_clipboards(clipboards.clone());
*LAST_MULTI_CLIPBOARDS.lock().unwrap() = clipboards;
return Some(msg);
return Some((msg, clipboards));
}
}
Err(e) => {
@ -219,10 +241,7 @@ fn do_update_clipboard_(mut to_update_data: Vec<ClipboardData>, side: ClipboardS
}
}
if let Some(ctx) = ctx.as_mut() {
to_update_data.push(ClipboardData::Special((
RUSTDESK_CLIPBOARD_OWNER_FORMAT.to_owned(),
side.get_owner_data(),
)));
to_update_data = append_owner_marker(to_update_data, side);
if let Err(e) = ctx.set(&to_update_data) {
log::debug!("Failed to set clipboard: {}", e);
} else {
@ -231,6 +250,29 @@ fn do_update_clipboard_(mut to_update_data: Vec<ClipboardData>, side: ClipboardS
}
}
#[cfg(not(target_os = "android"))]
fn append_owner_marker(mut data: Vec<ClipboardData>, side: ClipboardSide) -> Vec<ClipboardData> {
data.push(ClipboardData::Special((
RUSTDESK_CLIPBOARD_OWNER_FORMAT.to_owned(),
side.get_owner_data(),
)));
data
}
#[cfg(target_os = "linux")]
pub fn set_text_clipboard_with_owner_sync(text: &str, side: ClipboardSide) -> ResultType<()> {
let mut ctx = CLIPBOARD_CTX.lock().unwrap();
if ctx.is_none() {
*ctx = Some(ClipboardContext::new()?);
}
let clipboard_ctx = match ctx.as_mut() {
Some(ctx) => ctx,
None => bail!("Failed to create clipboard context"),
};
let data = append_owner_marker(vec![ClipboardData::Text(text.to_owned())], side);
clipboard_ctx.set_with_owner_marker_for_linux(&data)
}
#[cfg(not(target_os = "android"))]
pub fn update_clipboard(multi_clipboards: Vec<Clipboard>, side: ClipboardSide) {
std::thread::spawn(move || {
@ -382,6 +424,24 @@ impl ClipboardContext {
Ok(())
}
#[cfg(target_os = "linux")]
fn set_with_owner_marker_for_linux(&mut self, data: &[ClipboardData]) -> ResultType<()> {
let _lock = ARBOARD_MTX.lock().unwrap();
self.inner
.set()
.clipboard(LinuxClipboardKind::Clipboard)
.formats(data)?;
if let Err(e) = self
.inner
.set()
.clipboard(LinuxClipboardKind::Primary)
.formats(data)
{
log::warn!("Failed to set PRIMARY clipboard with owner marker: {}", e);
}
Ok(())
}
#[cfg(all(feature = "unix-file-copy-paste", target_os = "macos"))]
fn get_file_urls_set_by_rustdesk(
data: Vec<ClipboardData>,

View file

@ -146,7 +146,13 @@ pub fn core_main() -> Option<Vec<String>> {
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;
@ -193,6 +199,20 @@ pub fn core_main() -> Option<Vec<String>> {
}
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/<app>-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;
@ -242,11 +262,9 @@ pub fn core_main() -> Option<Vec<String>> {
if config::is_disable_installation() {
return None;
}
#[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 (printer_override, debug) = parse_silent_install_args(&args);
let options = platform::get_silent_install_options(printer_override);
let res = platform::install_me(options, "".to_owned(), true, debug);
let text = match res {
Ok(_) => translate("Installation Successful!".to_string()),
Err(err) => {
@ -412,7 +430,7 @@ pub fn core_main() -> Option<Vec<String>> {
}
return None;
} else if args[0] == "--password" {
if config::is_disable_settings() {
if is_cli_setting_change_disabled() {
println!("Settings are disabled!");
return None;
}
@ -454,7 +472,7 @@ pub fn core_main() -> Option<Vec<String>> {
println!("{}", crate::ipc::get_id());
return None;
} else if args[0] == "--set-id" {
if config::is_disable_settings() {
if is_cli_setting_change_disabled() {
println!("Settings are disabled!");
return None;
}
@ -501,7 +519,7 @@ pub fn core_main() -> Option<Vec<String>> {
}
return None;
} else if args[0] == "--option" {
if config::is_disable_settings() {
if is_cli_setting_change_disabled() {
println!("Settings are disabled!");
return None;
}
@ -621,6 +639,56 @@ pub fn core_main() -> Option<Vec<String>> {
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 config::is_outgoing_only() {
println!("Cannot deploy Outgoing-only clients.");
} 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");
match crate::ui_interface::deploy_device(token, new_id) {
crate::ui_interface::DeployResult::Ok => {
println!("Device deployed.");
}
crate::ui_interface::DeployResult::NotEnabled => {
println!("Server does not require deployment.");
std::process::exit(3);
}
crate::ui_interface::DeployResult::InvalidInput => {
println!("Invalid input.");
std::process::exit(5);
}
crate::ui_interface::DeployResult::IdTaken(id) => {
println!(
"Id `{}` is already used by another machine on the server.",
id
);
std::process::exit(6);
}
crate::ui_interface::DeployResult::Error(err) => {
println!("{}", err);
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();
@ -840,6 +908,82 @@ fn is_root() -> bool {
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")
)
}
#[inline]
fn is_cli_setting_change_disabled() -> bool {
let option = config::keys::OPTION_ALLOW_COMMAND_LINE_SETTINGS_WHEN_SETTINGS_DISABLED;
let allow_command_line_settings =
config::option2bool(option, &crate::get_builtin_option(option));
config::is_disable_settings() && !allow_command_line_settings
}
#[cfg(windows)]
fn parse_silent_install_args(args: &[String]) -> (Option<bool>, bool) {
let mut printer_override = None;
let mut debug = false;
for arg in args.iter().skip(1) {
match arg.as_str() {
"printer=1" => printer_override = Some(true),
"printer=0" => printer_override = Some(false),
"debug" => debug = true,
_ => {}
}
}
(printer_override, debug)
}
#[cfg(test)]
mod tests {
use super::*;
fn args(values: &[&str]) -> Vec<String> {
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)]

View file

@ -1135,6 +1135,10 @@ impl InvokeUiSession for FlutterHandler {
("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)));

View file

@ -605,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(())
@ -963,6 +972,27 @@ pub fn main_show_option(_key: String) -> SyncReturn<bool> {
}
pub fn main_set_option(key: String, value: String) {
#[cfg(target_os = "android")]
{
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;
}
}
#[cfg(target_os = "android")]
if key.eq(config::keys::OPTION_ENABLE_KEYBOARD) {
crate::ui_cm_interface::switch_permission_all(
@ -1010,7 +1040,29 @@ pub fn main_get_options_sync() -> SyncReturn<String> {
}
pub fn main_set_options(json: String) {
let map: HashMap<String, String> = serde_json::from_str(&json).unwrap_or(HashMap::new());
let mut map: HashMap<String, String> = 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)
}
@ -1101,6 +1153,22 @@ pub fn main_get_api_server() -> String {
get_api_server()
}
pub fn main_deploy_device(token: String, id: String) -> String {
#[cfg(target_os = "android")]
{
let new_id = match id.trim() {
"" => None,
id => Some(id.to_owned()),
};
ui_interface::deploy_device(token, new_id).message()
}
#[cfg(not(target_os = "android"))]
{
let _ = (token, id);
"Deployment is not supported on this platform.".to_owned()
}
}
pub fn main_resolve_avatar_url(avatar: String) -> SyncReturn<String> {
SyncReturn(resolve_avatar_url(avatar))
}
@ -2064,6 +2132,7 @@ pub fn main_start_service() {
#[cfg(target_os = "android")]
{
config::Config::set_option("stop-service".into(), "".into());
crate::rendezvous_mediator::reset_needs_deploy_notification();
crate::rendezvous_mediator::RendezvousMediator::restart();
}
}
@ -2161,7 +2230,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);
}
@ -2419,23 +2488,13 @@ pub fn is_disable_installation() -> SyncReturn<bool> {
}
pub fn is_preset_password() -> bool {
let hard = config::HARD_SETTINGS
.read()
.unwrap()
.get("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);
return config::Config::is_using_preset_password();
}
// Don't call this function for desktop version.
@ -2884,7 +2943,7 @@ pub fn main_set_common(_key: String, _value: String) {
} else if _key == "update-me" {
if let Some(new_version_file) = get_download_file_from_url(&_value) {
log::debug!(
"New version file is downloaed, update begin, {:?}",
"New version file is downloaded, update begin, {:?}",
new_version_file.to_str()
);
if let Some(f) = new_version_file.to_str() {
@ -3013,6 +3072,7 @@ pub mod server_side {
pub unsafe extern "system" fn Java_ffi_FFI_startService(_env: JNIEnv, _class: JClass) {
log::debug!("startService from jvm");
config::Config::set_option("stop-service".into(), "".into());
crate::rendezvous_mediator::reset_needs_deploy_notification();
crate::rendezvous_mediator::RendezvousMediator::restart();
}

View file

@ -1,33 +1,28 @@
use crate::{
common::CheckTestNatType,
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,
keys::{self, OPTION_ALLOW_WEBSOCKET},
Config, Config2,
},
config::{self, keys::OPTION_ALLOW_WEBSOCKET, Config, Config2},
futures::StreamExt as _,
futures_util::sink::SinkExt,
log, password_security as password, timeout,
@ -38,13 +33,92 @@ 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<bool> = 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<ConnectionTmpl<ConnClient>> {
connect(ms_timeout, crate::POSTFIX_SERVICE).await
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(tag = "t", content = "c")]
pub enum FS {
@ -207,6 +281,8 @@ pub enum DataControl {
pub enum DataPortableService {
Ping,
Pong,
AuthToken(String),
AuthResult(bool),
ConnCount(Option<usize>),
Mouse((Vec<u8>, i32, String, u32, bool, bool)),
Pointer((Vec<u8>, i32)),
@ -237,6 +313,7 @@ pub enum Data {
restart: bool,
recording: bool,
block_input: bool,
privacy_mode: bool,
from_switch: bool,
},
ChatMessage {
@ -272,6 +349,7 @@ pub enum Data {
ClipboardNonFile(Option<(String, Vec<ClipboardNonFile>)>),
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")))]
@ -284,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<bool>),
#[cfg(feature = "flutter")]
#[cfg(not(any(target_os = "android", target_os = "ios")))]
SwitchSidesBack,
UrlLink(String),
VoiceCallIncoming,
@ -403,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 {
@ -411,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;
}
}
}
}
}
});
@ -428,20 +568,77 @@ pub async fn start(postfix: &str) -> ResultType<()> {
pub async fn new_listener(postfix: &str) -> ResultType<Incoming> {
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)
@ -642,15 +839,7 @@ async fn handle(data: Data, stream: &mut Connection) {
"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 {
value = Some(if Config::is_using_preset_password() {
"Y".to_owned()
} else {
"N".to_owned()
@ -701,7 +890,7 @@ async fn handle(data: Data, stream: &mut Connection) {
log::warn!("Changing permanent password is disabled");
updated = false;
} else {
Config::set_permanent_password(&value);
updated = 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
@ -770,6 +959,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());
@ -779,6 +974,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::<uuid::Uuid>()
.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,
@ -930,15 +1138,212 @@ async fn handle(data: Data, stream: &mut Connection) {
);
}
_ => {}
};
}
#[cfg(target_os = "windows")]
pub(crate) fn generate_one_time_ipc_token() -> ResultType<String> {
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<T>(
stream: &mut ConnectionTmpl<T>,
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<ConnectionTmpl<ConnClient>> {
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<T, F>(
stream: &mut ConnectionTmpl<T>,
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<ConnectionTmpl<ConnClient>> {
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<u32>,
prefer_root: bool,
) -> ResultType<u32> {
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<Vec<u32>> {
let current_exe = std::env::current_exe()?;
let current_exe_path = std::fs::canonicalize(&current_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<u32> {
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<ConnectionTmpl<ConnClient>> {
#[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<ConnectionTmpl<ConnClient>> {
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() {
@ -1016,54 +1421,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::<usize>().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<T> {
inner: Framed<T, BytesCodec>,
}
@ -1185,11 +1542,6 @@ fn apply_permanent_password_storage_and_salt_payload(payload: Option<&str>) -> R
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(())
}
@ -1510,6 +1862,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")
@ -1527,9 +1886,10 @@ pub fn close_all_instances() -> ResultType<bool> {
}
}
#[cfg(windows)]
#[tokio::main(flavor = "current_thread")]
pub async fn connect_to_user_session(usid: Option<u32>) -> 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(())
}
@ -1655,13 +2015,76 @@ pub async fn update_controlling_session_count(count: usize) -> ResultType<()> {
#[cfg(target_os = "linux")]
#[tokio::main(flavor = "current_thread")]
pub async fn get_terminal_session_count() -> ResultType<usize> {
let ms_timeout = 1_000;
let mut c = connect(ms_timeout, "").await?;
c.send(&Data::TerminalSessionCount(0)).await?;
if let Some(Data::TerminalSessionCount(c)) = c.next_timeout(ms_timeout).await? {
return Ok(c);
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<anyhow::Error> = 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)
}
Ok(0)
}
async fn handle_wayland_screencast_restore_token(
@ -1692,9 +2115,81 @@ pub async fn set_install_option(k: String, v: String) -> ResultType<()> {
#[cfg(test)]
mod test {
use super::*;
#[test]
fn verify_ffi_enum_data_size() {
println!("{}", std::mem::size_of::<Data>());
assert!(std::mem::size_of::<Data>() <= 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());
}
}

1075
src/ipc/auth.rs Normal file

File diff suppressed because it is too large Load diff

951
src/ipc/fs.rs Normal file
View file

@ -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<u32> {
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<i32> {
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<bool> {
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<usize> {
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::<usize>().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
));
}
}

View file

@ -82,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<u128>,
last_grab: Option<std::time::Instant>,
/// 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<Mutex<bool>> = Arc::new(Mutex::new(false));
static ref GRAB_STATE: Arc<Mutex<GrabOwnerState>> = 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() {
@ -96,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);
// 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.swap(true, Ordering::SeqCst);
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", target_os = "linux"))]
KEYBOARD_HOOKED.swap(false, Ordering::SeqCst);
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<i32>) {
@ -341,7 +531,6 @@ fn notify_exit_relative_mouse_mode() {
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")]
@ -540,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<Key, Event> {
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<Key, Event>) {
for (key, mut event) in to_release.into_iter() {
event.event_type = EventType::KeyRelease(key);
client::process_event(keyboard_mode, &event, None);
@ -558,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,
@ -748,7 +945,6 @@ pub fn event_to_key_events(
) -> Vec<KeyEvent> {
peer.retain(|c| !c.is_whitespace());
let mut key_event = KeyEvent::new();
update_modifiers_state(event);
match event.event_type {
@ -761,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 {

View file

@ -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;
@ -47,6 +49,7 @@ mod vi;
mod ta;
mod ge;
mod fi;
mod ml;
pub const LANGS: &[(&str, &str)] = &[
("en", "English"),
@ -95,6 +98,9 @@ pub const LANGS: &[(&str, &str)] = &[
("ta", "தமிழ்"),
("ge", "ქართული"),
("fi", "Suomi"),
("ml", "മലയാളം"),
("hi", "हिंदी"),
("gu", "ગુજરાતી"),
];
#[cfg(not(any(target_os = "android", target_os = "ios")))]
@ -173,6 +179,9 @@ pub fn translate_locale(name: String, locale: &str) -> String {
"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);

View file

@ -743,5 +743,20 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Display Name", "اسم العرض"),
("password-hidden-tip", "كلمة المرور مخفية"),
("preset-password-in-use-tip", "كلمة المرور المحددة مسبقًا قيد الاستخدام"),
("Enable privacy mode", "تفعيل وضع الخصوصية"),
("allow-remote-toolbar-docking-any-edge", "السماح بإرساء شريط الأدوات البعيد إلى أي حافة من حواف النافذة"),
("API Token", "رمز واجهة برمجة التطبيقات API"),
("Deploy", "نشر"),
("Custom ID (optional)", "معرّف مخصص (اختياري)"),
("server_requires_deployment_tip", "يتطلب الخادم نشر هذا الجهاز بشكل صريح. هل تريد النشر الآن؟"),
("The server does not require explicit deployment.", "لا يتطلب الخادم نشرًا صريحًا."),
("Unknown response.", "استجابة غير معروفة."),
("wayland-keyboard-input-disabled-tip", "هل تريد السماح بإدخال لوحة المفاتيح؟"),
("wayland-keyboard-input-consent-tip", "ما تكتبه على هذا الكمبيوتر البعيد (بما في ذلك كلمات المرور) قد تتمكن التطبيقات الأخرى الموجودة عليه من قراءته."),
("wayland-keyboard-input-applies-to-tip", "ينطبق هذا الاختيار على:"),
("wayland-soft-keyboard-input-label", "إدخال لوحة المفاتيح الافتراضية"),
("wayland-keyboard-input-reset-choice-tip", "إعادة تعيين اختيار إدخال لوحة المفاتيح"),
("remember-wayland-keyboard-choice-tip", "لا تسأل مرة أخرى لهذا الكمبيوتر البعيد"),
("Why this happens", "سبب حدوث ذلك"),
].iter().cloned().collect();
}

File diff suppressed because it is too large Load diff

Some files were not shown because too many files have changed in this diff Show more