mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-06-22 17:04:00 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 04faf21c78 |
@@ -25,7 +25,7 @@ jobs:
|
||||
}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
@@ -49,25 +49,25 @@ jobs:
|
||||
wget
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
|
||||
uses: dtolnay/rust-toolchain@v1
|
||||
with:
|
||||
toolchain: ${{ env.RUST_VERSION }}
|
||||
targets: ${{ matrix.job.target }}
|
||||
components: "rustfmt"
|
||||
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
prefix-key: bridge-${{ matrix.job.os }}
|
||||
|
||||
- name: Cache Bridge
|
||||
id: cache-bridge
|
||||
uses: actions/cache@6f8efc29b200d32929f49075959781ed54ec270c # v3
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: /tmp/flutter_rust_bridge
|
||||
key: vcpkg-${{ matrix.job.arch }}
|
||||
|
||||
- name: Install flutter
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
uses: subosito/flutter-action@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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@master
|
||||
with:
|
||||
name: bridge-artifact
|
||||
path: |
|
||||
|
||||
+15
-15
@@ -29,13 +29,13 @@ jobs:
|
||||
# name: Ensure 'cargo fmt' has been run
|
||||
# runs-on: ubuntu-20.04
|
||||
# steps:
|
||||
# - uses: actions-rs/toolchain@16499b5e05bf2e26879000db0c1d13f7e13fa3af # v1
|
||||
# - uses: actions-rs/toolchain@v1
|
||||
# with:
|
||||
# toolchain: stable
|
||||
# default: true
|
||||
# profile: minimal
|
||||
# components: rustfmt
|
||||
# - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
|
||||
# - uses: actions/checkout@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@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
|
||||
# uses: actions/checkout@v3
|
||||
# with:
|
||||
# submodules: recursive
|
||||
|
||||
# - name: Install rust toolchain (v${{ env.MIN_SUPPORTED_RUST_VERSION }})
|
||||
# uses: actions-rs/toolchain@16499b5e05bf2e26879000db0c1d13f7e13fa3af # v1
|
||||
# uses: actions-rs/toolchain@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@844f36862e911db73fe0815f00a4a2602c279505 # v1
|
||||
# uses: actions-rs/cargo@v1
|
||||
# with:
|
||||
# command: clippy
|
||||
# args: --locked --all-targets --all-features -- --allow clippy::unknown_clippy_lints
|
||||
# - name: Run tests
|
||||
# uses: actions-rs/cargo@844f36862e911db73fe0815f00a4a2602c279505 # v1
|
||||
# uses: actions-rs/cargo@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@v1.3.1 is used in .github\workflows\flutter-build.yml
|
||||
# jlumbroso/free-disk-space@main is used in .github\workflows\flutter-build.yml
|
||||
# But pinning to a specific version to avoid unexpected issues is preferred.
|
||||
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1
|
||||
uses: jlumbroso/free-disk-space@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@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
|
||||
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
|
||||
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
@@ -145,7 +145,7 @@ jobs:
|
||||
esac
|
||||
|
||||
- name: Setup vcpkg with Github Actions binary cache
|
||||
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
|
||||
uses: lukka/run-vcpkg@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@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
|
||||
uses: dtolnay/rust-toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
targets: ${{ matrix.job.target }}
|
||||
@@ -172,10 +172,10 @@ jobs:
|
||||
cargo -V
|
||||
rustc -V
|
||||
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Build
|
||||
uses: actions-rs/cargo@844f36862e911db73fe0815f00a4a2602c279505 # v1
|
||||
uses: actions-rs/cargo@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@844f36862e911db73fe0815f00a4a2602c279505 # v1
|
||||
uses: actions-rs/cargo@v1
|
||||
with:
|
||||
use-cross: ${{ matrix.job.use-cross }}
|
||||
command: test
|
||||
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Clear cache
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
uses: actions/github-script@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@881eb5957687193fa612bf74c0042adc78ea5e54 # v2
|
||||
uses: MyAlbum/purge-cache@v2
|
||||
with:
|
||||
accessed: true # Purge caches by their last accessed time (default)
|
||||
created: false # Purge caches by their created time (default)
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
- name: Publish RustDesk version file
|
||||
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
prerelease: true
|
||||
tag_name: "fdroid-version"
|
||||
|
||||
+106
-107
@@ -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.8"
|
||||
VERSION: "1.4.6"
|
||||
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@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
|
||||
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
|
||||
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Restore bridge files
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@master
|
||||
with:
|
||||
name: bridge-artifact
|
||||
path: ./
|
||||
|
||||
- name: Install LLVM and Clang
|
||||
uses: KyleMayes/install-llvm-action@1a3da29f56261a1e1f937ec88f0856a9b8321d7e # v1
|
||||
uses: KyleMayes/install-llvm-action@v1
|
||||
with:
|
||||
version: ${{ env.LLVM_VERSION }}
|
||||
|
||||
- name: Install flutter
|
||||
uses: subosito/flutter-action@2783a3f08e1baf891508463f8c6653c258246225 # v2.12.0; https://github.com/subosito/flutter-action/issues/277
|
||||
uses: subosito/flutter-action@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@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
|
||||
uses: dtolnay/rust-toolchain@v1
|
||||
with:
|
||||
toolchain: ${{ env.SCITER_RUST_VERSION }}
|
||||
targets: ${{ matrix.job.target }}
|
||||
components: "rustfmt"
|
||||
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
prefix-key: ${{ matrix.job.os }}
|
||||
|
||||
- name: Setup vcpkg with Github Actions binary cache
|
||||
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
|
||||
uses: lukka/run-vcpkg@v11
|
||||
with:
|
||||
vcpkgDirectory: C:\vcpkg
|
||||
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
|
||||
@@ -220,7 +220,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Download RustDeskTempTopMostWindow artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@master
|
||||
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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@master
|
||||
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@6fb02220983dee41ce7ae257b6f4d8f9bf5ed4ce # v2
|
||||
uses: microsoft/setup-msbuild@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@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
uses: softprops/action-gh-release@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@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
|
||||
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
|
||||
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Install LLVM and Clang
|
||||
uses: rustdesk-org/install-llvm-action-32bit@6aa7d9ad3df84dff01cd4596dd0fc880a7f47fce # no release tag; commit 2026-05-26
|
||||
uses: rustdesk-org/install-llvm-action-32bit@master
|
||||
with:
|
||||
version: ${{ env.LLVM_VERSION }}
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
|
||||
uses: dtolnay/rust-toolchain@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@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
prefix-key: ${{ matrix.job.os }}-sciter
|
||||
|
||||
- name: Setup vcpkg with Github Actions binary cache
|
||||
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
|
||||
uses: lukka/run-vcpkg@v11
|
||||
with:
|
||||
vcpkgDirectory: C:\vcpkg
|
||||
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
|
||||
@@ -363,8 +363,7 @@ jobs:
|
||||
python3 res/inline-sciter.py
|
||||
# Patch sciter x86
|
||||
sed -i 's/branch = "dyn"/branch = "dyn_x86"/g' ./Cargo.toml
|
||||
cargo update -p sciter-rs --precise 674e07d3066ca9a92ced3816203ab6b652629d1e
|
||||
cargo build --locked --features inline,vram,hwcodec --release --bins
|
||||
cargo build --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
|
||||
@@ -395,7 +394,7 @@ jobs:
|
||||
|
||||
- name: Upload unsigned
|
||||
if: env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@master
|
||||
with:
|
||||
name: rustdesk-unsigned-windows-${{ matrix.job.arch }}
|
||||
path: Release
|
||||
@@ -425,7 +424,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@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
uses: softprops/action-gh-release@v1
|
||||
if: env.UPLOAD_ARTIFACT == 'true'
|
||||
with:
|
||||
prerelease: true
|
||||
@@ -450,7 +449,7 @@ jobs:
|
||||
}
|
||||
steps:
|
||||
- name: Export GitHub Actions cache environment variables
|
||||
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
|
||||
@@ -460,12 +459,12 @@ jobs:
|
||||
run: |
|
||||
brew install nasm yasm
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Install flutter
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
@@ -476,7 +475,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@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
|
||||
uses: lukka/run-vcpkg@v11
|
||||
with:
|
||||
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
|
||||
doNotCache: false
|
||||
@@ -500,19 +499,19 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
|
||||
uses: dtolnay/rust-toolchain@v1
|
||||
with:
|
||||
toolchain: ${{ env.RUST_VERSION }}
|
||||
targets: ${{ matrix.job.target }}
|
||||
components: "rustfmt"
|
||||
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
prefix-key: rustdesk-lib-cache-ios
|
||||
key: ${{ matrix.job.target }}
|
||||
|
||||
- name: Restore bridge files
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@master
|
||||
with:
|
||||
name: bridge-artifact
|
||||
path: ./
|
||||
@@ -520,10 +519,10 @@ jobs:
|
||||
- name: Build rustdesk lib
|
||||
run: |
|
||||
rustup target add ${{ matrix.job.target }}
|
||||
cargo build --locked --features flutter,hwcodec --release --target aarch64-apple-ios --lib
|
||||
cargo build --features flutter,hwcodec --release --target aarch64-apple-ios --lib
|
||||
|
||||
- name: Upload liblibrustdesk.a Artifacts
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@master
|
||||
with:
|
||||
name: liblibrustdesk.a
|
||||
path: target/aarch64-apple-ios/release/liblibrustdesk.a
|
||||
@@ -538,14 +537,14 @@ jobs:
|
||||
|
||||
# - name: Upload Artifacts
|
||||
# # if: env.ANDROID_SIGNING_KEY != null && env.UPLOAD_ARTIFACT == 'true'
|
||||
# uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
# uses: actions/upload-artifact@master
|
||||
# with:
|
||||
# name: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.apk
|
||||
# path: flutter/build/ios/ipa/*.ipa
|
||||
|
||||
# - name: Publish ipa package
|
||||
# # if: env.ANDROID_SIGNING_KEY != null && env.UPLOAD_ARTIFACT == 'true'
|
||||
# uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
# uses: softprops/action-gh-release@v1
|
||||
# with:
|
||||
# prerelease: true
|
||||
# tag_name: ${{ env.TAG_NAME }}
|
||||
@@ -578,20 +577,20 @@ jobs:
|
||||
}
|
||||
steps:
|
||||
- name: Export GitHub Actions cache environment variables
|
||||
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
|
||||
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
|
||||
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Import the codesign cert
|
||||
if: env.MACOS_P12_BASE64 != null
|
||||
uses: apple-actions/import-codesign-certs@253ddeeac23f2bdad1646faac5c8c2832e800071 # v1
|
||||
uses: apple-actions/import-codesign-certs@v1
|
||||
with:
|
||||
p12-file-base64: ${{ secrets.MACOS_P12_BASE64 }}
|
||||
p12-password: ${{ secrets.MACOS_P12_PASSWORD }}
|
||||
@@ -605,7 +604,7 @@ jobs:
|
||||
|
||||
- name: Import notarize key
|
||||
if: env.MACOS_P12_BASE64 != null
|
||||
uses: timheuer/base64-to-file@adaa40c0c581f276132199d4cf60afa07ce60eac # v1.2
|
||||
uses: timheuer/base64-to-file@v1.2
|
||||
with:
|
||||
# https://gregoryszorc.com/docs/apple-codesign/stable/apple_codesign_rcodesign.html#notarizing-and-stapling
|
||||
fileName: rustdesk.json
|
||||
@@ -644,7 +643,7 @@ jobs:
|
||||
nasm --version
|
||||
|
||||
- name: Install flutter
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
@@ -663,24 +662,24 @@ jobs:
|
||||
grep -n '_setFramesEnabledState(false);' ../packages/flutter/lib/src/scheduler/binding.dart
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
|
||||
uses: dtolnay/rust-toolchain@v1
|
||||
with:
|
||||
toolchain: ${{ env.MAC_RUST_VERSION }}
|
||||
targets: ${{ matrix.job.target }}
|
||||
components: "rustfmt"
|
||||
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
prefix-key: ${{ matrix.job.os }}
|
||||
|
||||
- name: Restore bridge files
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@master
|
||||
with:
|
||||
name: bridge-artifact
|
||||
path: ./
|
||||
|
||||
- name: Setup vcpkg with Github Actions binary cache
|
||||
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
|
||||
uses: lukka/run-vcpkg@v11
|
||||
with:
|
||||
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
|
||||
doNotCache: false
|
||||
@@ -732,7 +731,7 @@ jobs:
|
||||
|
||||
- name: Upload unsigned macOS app
|
||||
if: env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@master
|
||||
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
|
||||
@@ -764,7 +763,7 @@ jobs:
|
||||
|
||||
- name: Publish DMG package
|
||||
if: env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
prerelease: true
|
||||
tag_name: ${{ env.TAG_NAME }}
|
||||
@@ -780,25 +779,25 @@ jobs:
|
||||
if: ${{ inputs.upload-artifact }}
|
||||
steps:
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@master
|
||||
with:
|
||||
name: rustdesk-unsigned-macos-x86_64
|
||||
path: ./
|
||||
|
||||
- name: Download Artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@master
|
||||
with:
|
||||
name: rustdesk-unsigned-macos-aarch64
|
||||
path: ./
|
||||
|
||||
- name: Download Artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@master
|
||||
with:
|
||||
name: rustdesk-unsigned-windows-x86_64
|
||||
path: ./windows-x86_64/
|
||||
|
||||
- name: Download Artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@master
|
||||
with:
|
||||
name: rustdesk-unsigned-windows-x86
|
||||
path: ./windows-x86/
|
||||
@@ -808,7 +807,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@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
prerelease: true
|
||||
tag_name: ${{ env.TAG_NAME }}
|
||||
@@ -845,7 +844,7 @@ jobs:
|
||||
}
|
||||
steps:
|
||||
- name: Free Disk Space (Ubuntu)
|
||||
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1
|
||||
uses: jlumbroso/free-disk-space@main
|
||||
with:
|
||||
tool-cache: false
|
||||
android: false
|
||||
@@ -856,7 +855,7 @@ jobs:
|
||||
swap-storage: false
|
||||
|
||||
- name: Export GitHub Actions cache environment variables
|
||||
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
|
||||
@@ -898,12 +897,12 @@ jobs:
|
||||
wget
|
||||
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Install flutter
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: ${{ env.ANDROID_FLUTTER_VERSION }}
|
||||
@@ -913,14 +912,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@ed92fe6cadad69be94a966a7ee3271275e62f779 # v1
|
||||
- uses: nttld/setup-ndk@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@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
|
||||
uses: lukka/run-vcpkg@v11
|
||||
with:
|
||||
vcpkgDirectory: /opt/artifacts/vcpkg
|
||||
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
|
||||
@@ -955,18 +954,18 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
- name: Restore bridge files
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@master
|
||||
with:
|
||||
name: bridge-artifact
|
||||
path: ./
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
|
||||
uses: dtolnay/rust-toolchain@v1
|
||||
with:
|
||||
toolchain: ${{ env.RUST_VERSION }}
|
||||
components: "rustfmt"
|
||||
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
prefix-key: rustdesk-lib-cache-android # TODO: drop '-android' part after caches are invalidated
|
||||
key: ${{ matrix.job.target }}
|
||||
@@ -1002,7 +1001,7 @@ jobs:
|
||||
esac
|
||||
|
||||
- name: Upload Rustdesk library to Artifacts
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@master
|
||||
with:
|
||||
name: librustdesk.so.${{ matrix.job.target }}
|
||||
path: ./target/${{ matrix.job.target }}/release/liblibrustdesk.so
|
||||
@@ -1067,7 +1066,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@349ebdef58775b1e0d8099458af0816dc79b6407 # v1
|
||||
- uses: r0adkll/sign-android-release@v1
|
||||
name: Sign app APK
|
||||
if: env.ANDROID_SIGNING_KEY != null
|
||||
id: sign-rustdesk
|
||||
@@ -1083,14 +1082,14 @@ jobs:
|
||||
|
||||
- name: Upload Artifacts
|
||||
if: env.ANDROID_SIGNING_KEY != null && env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@master
|
||||
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@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
prerelease: true
|
||||
tag_name: ${{ env.TAG_NAME }}
|
||||
@@ -1099,7 +1098,7 @@ jobs:
|
||||
|
||||
- name: Publish unsigned apk package
|
||||
if: env.ANDROID_SIGNING_KEY == null && env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
prerelease: true
|
||||
tag_name: ${{ env.TAG_NAME }}
|
||||
@@ -1117,7 +1116,7 @@ jobs:
|
||||
suffix: ""
|
||||
steps:
|
||||
- name: Free Disk Space (Ubuntu)
|
||||
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1
|
||||
uses: jlumbroso/free-disk-space@main
|
||||
with:
|
||||
tool-cache: false
|
||||
android: false
|
||||
@@ -1128,7 +1127,7 @@ jobs:
|
||||
swap-storage: false
|
||||
|
||||
- name: Export GitHub Actions cache environment variables
|
||||
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
|
||||
@@ -1170,12 +1169,12 @@ jobs:
|
||||
wget
|
||||
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Install flutter
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: ${{ env.ANDROID_FLUTTER_VERSION }}
|
||||
@@ -1186,32 +1185,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@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@master
|
||||
with:
|
||||
name: bridge-artifact
|
||||
path: ./
|
||||
|
||||
- name: Download Rustdesk library from Artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@master
|
||||
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@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@master
|
||||
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@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@master
|
||||
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@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@master
|
||||
with:
|
||||
name: librustdesk.so.i686-linux-android
|
||||
path: ./flutter/android/app/src/main/jniLibs/x86
|
||||
@@ -1251,7 +1250,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@349ebdef58775b1e0d8099458af0816dc79b6407 # v1
|
||||
- uses: r0adkll/sign-android-release@v1
|
||||
name: Sign app APK
|
||||
if: env.ANDROID_SIGNING_KEY != null
|
||||
id: sign-rustdesk
|
||||
@@ -1267,14 +1266,14 @@ jobs:
|
||||
|
||||
- name: Upload Artifacts
|
||||
if: env.ANDROID_SIGNING_KEY != null && env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@master
|
||||
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@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
prerelease: true
|
||||
tag_name: ${{ env.TAG_NAME }}
|
||||
@@ -1283,7 +1282,7 @@ jobs:
|
||||
|
||||
- name: Publish unsigned apk package
|
||||
if: env.ANDROID_SIGNING_KEY == null && env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
prerelease: true
|
||||
tag_name: ${{ env.TAG_NAME }}
|
||||
@@ -1317,7 +1316,7 @@ jobs:
|
||||
}
|
||||
steps:
|
||||
- name: Export GitHub Actions cache environment variables
|
||||
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
|
||||
@@ -1335,13 +1334,13 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Set Swap Space
|
||||
if: ${{ matrix.job.arch == 'x86_64' }}
|
||||
uses: pierotofy/set-swap-space@49819abfb41bd9b44fb781159c033dba90353a7c # v1.0
|
||||
uses: pierotofy/set-swap-space@master
|
||||
with:
|
||||
swap-size-gb: 12
|
||||
|
||||
@@ -1351,7 +1350,7 @@ jobs:
|
||||
free -m
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
|
||||
uses: dtolnay/rust-toolchain@v1
|
||||
if: matrix.job.arch == 'x86_64' || env.UPLOAD_ARTIFACT == 'true'
|
||||
with:
|
||||
toolchain: ${{ env.RUST_VERSION }}
|
||||
@@ -1370,14 +1369,14 @@ jobs:
|
||||
|
||||
- name: Restore bridge files
|
||||
if: matrix.job.arch == 'x86_64' || env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@master
|
||||
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@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
|
||||
uses: lukka/run-vcpkg@v11
|
||||
with:
|
||||
vcpkgDirectory: /opt/artifacts/vcpkg
|
||||
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
|
||||
@@ -1405,12 +1404,12 @@ jobs:
|
||||
|
||||
- name: Restore bridge files
|
||||
if: matrix.job.arch == 'x86_64' || env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@master
|
||||
with:
|
||||
name: bridge-artifact
|
||||
path: ./
|
||||
|
||||
- uses: rustdesk-org/run-on-arch-action@d3fcfbb632b84cf7f6bc772bfaaa2c2f4f8789a8 # no release tag; commit 2026-05-26
|
||||
- uses: rustdesk-org/run-on-arch-action@amd64-support
|
||||
name: Build rustdesk
|
||||
id: vcpkg
|
||||
if: matrix.job.arch == 'x86_64' || env.UPLOAD_ARTIFACT == 'true'
|
||||
@@ -1492,7 +1491,7 @@ jobs:
|
||||
export JOBS=""
|
||||
fi
|
||||
echo $JOBS
|
||||
cargo build --locked --lib $JOBS --features hwcodec,flutter,unix-file-copy-paste --release
|
||||
cargo build --lib $JOBS --features hwcodec,flutter,unix-file-copy-paste --release
|
||||
rm -rf target/release/deps target/release/build
|
||||
rm -rf ~/.cargo
|
||||
|
||||
@@ -1584,7 +1583,7 @@ jobs:
|
||||
|
||||
- name: Publish debian/rpm package
|
||||
if: env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
prerelease: true
|
||||
tag_name: ${{ env.TAG_NAME }}
|
||||
@@ -1593,7 +1592,7 @@ jobs:
|
||||
rustdesk-*.rpm
|
||||
|
||||
- name: Upload deb
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@master
|
||||
if: env.UPLOAD_ARTIFACT == 'true'
|
||||
with:
|
||||
name: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.deb
|
||||
@@ -1612,7 +1611,7 @@ jobs:
|
||||
|
||||
- name: Build archlinux package
|
||||
if: matrix.job.arch == 'x86_64' && env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: rustdesk-org/arch-makepkg-action@04200739ed1d0bf6f2188b6736b26a767c57a7f9 # no release tag; commit 2026-05-26
|
||||
uses: rustdesk-org/arch-makepkg-action@master
|
||||
with:
|
||||
packages:
|
||||
scripts: |
|
||||
@@ -1620,7 +1619,7 @@ jobs:
|
||||
|
||||
- name: Publish archlinux package
|
||||
if: matrix.job.arch == 'x86_64' && env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
prerelease: true
|
||||
tag_name: ${{ env.TAG_NAME }}
|
||||
@@ -1658,14 +1657,14 @@ jobs:
|
||||
}
|
||||
steps:
|
||||
- name: Export GitHub Actions cache environment variables
|
||||
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
|
||||
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
|
||||
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
@@ -1683,7 +1682,7 @@ jobs:
|
||||
free -m
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
|
||||
uses: dtolnay/rust-toolchain@v1
|
||||
with:
|
||||
toolchain: ${{ env.SCITER_RUST_VERSION }}
|
||||
targets: ${{ matrix.job.target }}
|
||||
@@ -1694,7 +1693,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@d3fcfbb632b84cf7f6bc772bfaaa2c2f4f8789a8 # no release tag; commit 2026-05-26
|
||||
- uses: rustdesk-org/run-on-arch-action@amd64-support
|
||||
name: Build rustdesk sciter binary for ${{ matrix.job.arch }}
|
||||
id: vcpkg
|
||||
with:
|
||||
@@ -1822,7 +1821,7 @@ jobs:
|
||||
# build rustdesk
|
||||
python3 ./res/inline-sciter.py
|
||||
export CARGO_INCREMENTAL=0
|
||||
cargo build --locked --features inline${{ matrix.job.extra_features }} --release --bins --jobs 1
|
||||
cargo build --features inline${{ matrix.job.extra_features }} --release --bins --jobs 1
|
||||
# make debian package
|
||||
mkdir -p ./Release
|
||||
mv ./target/release/rustdesk ./Release/rustdesk
|
||||
@@ -1840,7 +1839,7 @@ jobs:
|
||||
|
||||
- name: Publish debian package
|
||||
if: env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
prerelease: true
|
||||
tag_name: ${{ env.TAG_NAME }}
|
||||
@@ -1848,7 +1847,7 @@ jobs:
|
||||
rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}-sciter.deb
|
||||
|
||||
- name: Upload deb
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@master
|
||||
if: env.UPLOAD_ARTIFACT == 'true'
|
||||
with:
|
||||
name: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}-sciter.deb
|
||||
@@ -1867,12 +1866,12 @@ jobs:
|
||||
- { target: aarch64-unknown-linux-gnu, arch: aarch64 }
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Download Binary
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@master
|
||||
with:
|
||||
name: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.deb
|
||||
path: .
|
||||
@@ -1897,7 +1896,7 @@ jobs:
|
||||
|
||||
- name: Publish appimage package
|
||||
if: env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
prerelease: true
|
||||
tag_name: ${{ env.TAG_NAME }}
|
||||
@@ -1940,12 +1939,12 @@ jobs:
|
||||
}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Download Binary
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@master
|
||||
with:
|
||||
name: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}${{ matrix.job.suffix }}.deb
|
||||
path: .
|
||||
@@ -1954,7 +1953,7 @@ jobs:
|
||||
run: |
|
||||
mv rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}${{ matrix.job.suffix }}.deb flatpak/rustdesk.deb
|
||||
|
||||
- uses: rustdesk-org/run-on-arch-action@d3fcfbb632b84cf7f6bc772bfaaa2c2f4f8789a8 # no release tag; commit 2026-05-26
|
||||
- uses: rustdesk-org/run-on-arch-action@amd64-support
|
||||
name: Build rustdesk flatpak package for ${{ matrix.job.arch }}
|
||||
id: flatpak
|
||||
with:
|
||||
@@ -1982,7 +1981,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@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
prerelease: true
|
||||
tag_name: ${{ env.TAG_NAME }}
|
||||
@@ -2001,7 +2000,7 @@ jobs:
|
||||
RELEASE_NAME: web-basic
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
@@ -2011,7 +2010,7 @@ jobs:
|
||||
sudo apt-get install -y wget npm
|
||||
|
||||
- name: Install flutter
|
||||
uses: subosito/flutter-action@2783a3f08e1baf891508463f8c6653c258246225 # v2.12.0; https://github.com/subosito/flutter-action/issues/277
|
||||
uses: subosito/flutter-action@v2.12.0 #https://github.com/subosito/flutter-action/issues/277
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
@@ -2055,7 +2054,7 @@ jobs:
|
||||
|
||||
- name: Publish web
|
||||
if: env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
prerelease: true
|
||||
tag_name: ${{ env.TAG_NAME }}
|
||||
|
||||
@@ -17,7 +17,7 @@ env:
|
||||
TAG_NAME: "nightly"
|
||||
VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite"
|
||||
VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b"
|
||||
VERSION: "1.4.8"
|
||||
VERSION: "1.4.6"
|
||||
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@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
|
||||
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
|
||||
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ref: ${{ matrix.job.ref }}
|
||||
submodules: recursive
|
||||
|
||||
- name: Import the codesign cert
|
||||
if: env.MACOS_P12_BASE64 != null
|
||||
uses: apple-actions/import-codesign-certs@253ddeeac23f2bdad1646faac5c8c2832e800071 # v1
|
||||
uses: apple-actions/import-codesign-certs@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@adaa40c0c581f276132199d4cf60afa07ce60eac # v1.2
|
||||
uses: timheuer/base64-to-file@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@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: ${{ matrix.job.flutter }}
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
|
||||
uses: dtolnay/rust-toolchain@v1
|
||||
with:
|
||||
toolchain: ${{ env.RUST_VERSION }}
|
||||
targets: ${{ matrix.job.target }}
|
||||
components: "rustfmt"
|
||||
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
prefix-key: ${{ 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@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
|
||||
uses: lukka/run-vcpkg@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@8a5116de2b552d6fc8894e9774aacaf2e5db4823 # v7 2026-05-26
|
||||
uses: lukka/run-vcpkg@v7
|
||||
if: false
|
||||
with:
|
||||
setupOnly: true
|
||||
@@ -222,7 +222,7 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Publish DMG package
|
||||
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
prerelease: true
|
||||
tag_name: ${{ env.TAG_NAME }}
|
||||
@@ -247,7 +247,7 @@ jobs:
|
||||
}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ref: ${{ matrix.job.ref }}
|
||||
submodules: recursive
|
||||
@@ -290,13 +290,13 @@ jobs:
|
||||
wget
|
||||
|
||||
- name: Install flutter
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
|
||||
uses: dtolnay/rust-toolchain@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@ed92fe6cadad69be94a966a7ee3271275e62f779 # v1
|
||||
- uses: nttld/setup-ndk@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@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
|
||||
uses: lukka/run-vcpkg@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@349ebdef58775b1e0d8099458af0816dc79b6407 # v1
|
||||
- uses: r0adkll/sign-android-release@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@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
prerelease: true
|
||||
tag_name: ${{ env.TAG_NAME }}
|
||||
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
build_output_dir: RustDeskTempTopMostWindow/WindowInjection/${{ inputs.platform }}/${{ inputs.configuration }}
|
||||
steps:
|
||||
- name: Add MSBuild to PATH
|
||||
uses: microsoft/setup-msbuild@6fb02220983dee41ce7ae257b6f4d8f9bf5ed4ce # v2
|
||||
uses: microsoft/setup-msbuild@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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@master
|
||||
if: ${{ inputs.upload-artifact }}
|
||||
with:
|
||||
name: topmostwindow-artifacts
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
name: wf-cliprdr CI
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
paths:
|
||||
- "libs/clipboard/src/windows/**"
|
||||
- "tests/test_invariant_wf_cliprdr.c"
|
||||
- ".github/workflows/wf-cliprdr-ci.yml"
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- "libs/clipboard/src/windows/**"
|
||||
- "tests/test_invariant_wf_cliprdr.c"
|
||||
- ".github/workflows/wf-cliprdr-ci.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: wf_cliprdr invariant test
|
||||
runs-on: windows-2022
|
||||
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up MSVC
|
||||
uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756
|
||||
with:
|
||||
arch: x64
|
||||
|
||||
- name: Setup vcpkg with GitHub Actions binary cache
|
||||
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
|
||||
with:
|
||||
vcpkgDirectory: C:\vcpkg
|
||||
doNotCache: false
|
||||
|
||||
- name: Install vcpkg dependency
|
||||
shell: pwsh
|
||||
run: |
|
||||
& "$env:VCPKG_ROOT\vcpkg.exe" install check:x64-windows --classic --x-install-root="$env:VCPKG_ROOT\installed"
|
||||
|
||||
- name: Build test
|
||||
shell: pwsh
|
||||
run: |
|
||||
$testRoot = Join-Path $env:GITHUB_WORKSPACE 'build\wf-cliprdr'
|
||||
New-Item -ItemType Directory -Force $testRoot | Out-Null
|
||||
|
||||
$testSource = (($env:GITHUB_WORKSPACE -replace '\\', '/') + '/tests/test_invariant_wf_cliprdr.c')
|
||||
$cmakeLists = @(
|
||||
'cmake_minimum_required(VERSION 3.20)'
|
||||
'project(test_invariant_wf_cliprdr C)'
|
||||
''
|
||||
'set(CMAKE_C_STANDARD 11)'
|
||||
'set(CMAKE_C_STANDARD_REQUIRED ON)'
|
||||
'set(CMAKE_C_EXTENSIONS OFF)'
|
||||
''
|
||||
'find_package(check CONFIG REQUIRED)'
|
||||
''
|
||||
'add_executable(test_invariant_wf_cliprdr'
|
||||
' "TEST_SOURCE"'
|
||||
')'
|
||||
''
|
||||
'target_link_libraries(test_invariant_wf_cliprdr PRIVATE'
|
||||
' $<$<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
|
||||
+3
-1
@@ -55,4 +55,6 @@ examples/**/target/
|
||||
vcpkg_installed
|
||||
flutter/lib/generated_plugin_registrant.dart
|
||||
libsciter.dylib
|
||||
flutter/web/
|
||||
flutter/web/
|
||||
# Local git worktrees
|
||||
.worktrees/
|
||||
|
||||
@@ -53,6 +53,30 @@
|
||||
* Use `spawn_blocking` or dedicated threads for blocking work.
|
||||
* Do not use `std::thread::sleep()` in async code.
|
||||
|
||||
## Flutter Rust Bridge
|
||||
|
||||
* Do **not** run `flutter_rust_bridge_codegen` — it requires a specific pinned version that is not easy to set up locally.
|
||||
* When adding new FFI functions in `src/flutter_ffi.rs`, hand-write the corresponding Dart wrappers instead of regenerating.
|
||||
* Web bridge (committed): edit `flutter/lib/web/bridge.dart` directly. Follow the existing patterns there for `SyncReturn<T>` / `Future<T>` and the `dart:js` glue.
|
||||
* Native bridge (`flutter/lib/generated_bridge.dart`, `src/bridge_generated.rs`, `src/bridge_generated.io.rs`): these are gitignored and regenerated by the project's CI codegen. Manually editing them locally is fine for development testing, but those edits do not persist into commits.
|
||||
|
||||
## Web (Flutter Web) Architecture
|
||||
|
||||
Flutter Web in this repo is **not** "Dart compiled to JS via Flutter alone". The runtime is split:
|
||||
|
||||
* **Native targets (Win/Mac/Linux/Android/iOS)**: Rust drives sessions via `flutter_rust_bridge`; Dart only renders UI.
|
||||
* **Web target**: Rust does **not** run. There is a separate hand-written TypeScript / JavaScript client at `flutter/web/js/` (gitignored — not present in this repo, lives in the maintainer's local tree). It owns connection, codec, keyboard, clipboard, etc. — basically a JS port of the Rust client. The Dart UI talks to it through `flutter/lib/web/bridge.dart`, which uses `dart:js` to call JS-side functions and to register Dart-side callbacks on `window.*`.
|
||||
|
||||
Implications when adding any session-runtime feature (keyboard, clipboard, audio, …):
|
||||
|
||||
* The Rust implementation in `src/` is for **native only**. Don't try to compile it to wasm.
|
||||
* The matching Web-side logic must be written in TS/JS under `flutter/web/js/src/`. It's a translation of the Rust logic, usually simpler — Web is single-window, so any per-session-id plumbing in Rust collapses to a single global on Web.
|
||||
* `flutter/lib/web/bridge.dart` is the only place where Dart sees JS. Other Dart code stays platform-agnostic and goes through `bind`. Don't sprinkle `if (isWeb)` runtime branches in shared Dart files to call Web-specific logic — put the platform divergence in the bridge.
|
||||
* For JS → Dart events (e.g., a Web matcher firing), the convention is: Dart sets `js.context['onFooBar'] = (...) {...}` once at startup (typically in `mainInit`); the JS side calls `window.onFooBar(...)`. See `onLoadAbFinished`, `onLoadGroupFinished` for reference.
|
||||
* The maintainer cannot easily run `flutter_rust_bridge_codegen`, so when a new FFI function lands in `src/flutter_ffi.rs`:
|
||||
1. add the Web counterpart to `flutter/lib/web/bridge.dart` by hand;
|
||||
2. note that on the Web target it may need to be a no-op or a JS bridge call rather than a real Rust invocation.
|
||||
|
||||
## Editing Hygiene
|
||||
|
||||
* Change only what is required.
|
||||
@@ -60,27 +84,3 @@
|
||||
* 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`.
|
||||
|
||||
Generated
+6
-6
@@ -292,7 +292,7 @@ checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487"
|
||||
[[package]]
|
||||
name = "arboard"
|
||||
version = "3.4.0"
|
||||
source = "git+https://github.com/rustdesk-org/arboard#c7d5781f563176df9efd8df6287e823fb1b9bed5"
|
||||
source = "git+https://github.com/rustdesk-org/arboard#85be1218668ff218a7b170c9d424fde73e069914"
|
||||
dependencies = [
|
||||
"clipboard-win",
|
||||
"core-graphics 0.23.2",
|
||||
@@ -3952,7 +3952,7 @@ checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4"
|
||||
[[package]]
|
||||
name = "hwcodec"
|
||||
version = "0.7.1"
|
||||
source = "git+https://github.com/rustdesk-org/hwcodec#778df1f99597722473b29443bac22ae6c23946fe"
|
||||
source = "git+https://github.com/rustdesk-org/hwcodec#398e5a8938dd8768ade0fcdc27ea80e8b4b38738"
|
||||
dependencies = [
|
||||
"bindgen 0.59.2",
|
||||
"cc",
|
||||
@@ -5996,8 +5996,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "parity-tokio-ipc"
|
||||
version = "0.7.3-6"
|
||||
source = "git+https://github.com/rustdesk-org/parity-tokio-ipc#d0ae39bffe5d5a3e8d82a1b6bcb1ca5a9b2f1c01"
|
||||
version = "0.7.3-5"
|
||||
source = "git+https://github.com/rustdesk-org/parity-tokio-ipc#c8c8bbcbabf9be1201c53afb0269b92b9b02d291"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"libc",
|
||||
@@ -7270,7 +7270,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustdesk"
|
||||
version = "1.4.8"
|
||||
version = "1.4.6"
|
||||
dependencies = [
|
||||
"android-wakelock",
|
||||
"android_logger",
|
||||
@@ -7385,7 +7385,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustdesk-portable-packer"
|
||||
version = "1.4.8"
|
||||
version = "1.4.6"
|
||||
dependencies = [
|
||||
"brotli",
|
||||
"dirs 5.0.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rustdesk"
|
||||
version = "1.4.8"
|
||||
version = "1.4.6"
|
||||
authors = ["rustdesk <info@rustdesk.com>"]
|
||||
edition = "2021"
|
||||
build= "build.rs"
|
||||
|
||||
@@ -18,7 +18,7 @@ AppDir:
|
||||
id: rustdesk
|
||||
name: rustdesk
|
||||
icon: rustdesk
|
||||
version: 1.4.8
|
||||
version: 1.4.6
|
||||
exec: usr/share/rustdesk/rustdesk
|
||||
exec_args: $@
|
||||
apt:
|
||||
|
||||
@@ -18,7 +18,7 @@ AppDir:
|
||||
id: rustdesk
|
||||
name: rustdesk
|
||||
icon: rustdesk
|
||||
version: 1.4.8
|
||||
version: 1.4.6
|
||||
exec: usr/share/rustdesk/rustdesk
|
||||
exec_args: $@
|
||||
apt:
|
||||
|
||||
@@ -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 . --locked && popd
|
||||
pushd /tmp/flutter_rust_bridge/frb_codegen && cargo install --path . && 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-0t64 | libgtk-3-0, libxcb-randr0, libxdo3 | libxdo4, libxfixes3, libxcb-shape0, libxcb-xfixes0, libasound2t64 | libasound2, libsystemd0, curl, libva2, libva-drm2, libva-x11-2, libgstreamer-plugins-base1.0-0, libpam0g, gstreamer1.0-pipewire%s
|
||||
Depends: libgtk-3-0, libxcb-randr0, libxdo3 | libxdo4, libxfixes3, libxcb-shape0, libxcb-xfixes0, libasound2, libsystemd0, curl, libva2, libva-drm2, libva-x11-2, libgstreamer-plugins-base1.0-0, libpam0g, gstreamer1.0-pipewire%s
|
||||
Recommends: libayatana-appindicator3-1
|
||||
Description: A remote control software.
|
||||
|
||||
@@ -317,7 +317,7 @@ def ffi_bindgen_function_refactor():
|
||||
|
||||
def build_flutter_deb(version, features):
|
||||
if not skip_cargo:
|
||||
system2(f'cargo build --locked --features {features} --lib --release')
|
||||
system2(f'cargo build --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 --locked --features {features} --release')
|
||||
f'MACOSX_DEPLOYMENT_TARGET=10.14 cargo build --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 --locked --features {features} --lib --release')
|
||||
system2(f'cargo build --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 --locked --features {features} --lib --release')
|
||||
system2(f'cargo build --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 --locked --release')
|
||||
system2('cargo build --release')
|
||||
os.chdir('../../..')
|
||||
|
||||
if flutter:
|
||||
build_flutter_windows(version, features, args.skip_portable_pack)
|
||||
return
|
||||
system2('cargo build --locked --release --features ' + features)
|
||||
system2('cargo build --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')
|
||||
@@ -519,7 +519,7 @@ def main():
|
||||
if flutter:
|
||||
build_flutter_arch_manjaro(version, features)
|
||||
else:
|
||||
system2('cargo build --locked --release --features ' + features)
|
||||
system2('cargo build --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 --locked --release --features ' + features)
|
||||
system2('cargo build --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 --locked --release --features ' + features)
|
||||
system2('cargo build --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 --locked bundle --release --features ' + features)
|
||||
system2('cargo bundle --release --features ' + features)
|
||||
if osx:
|
||||
system2(
|
||||
'strip target/release/bundle/osx/RustDesk.app/Contents/MacOS/rustdesk')
|
||||
|
||||
+9
-9
@@ -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>] | [<a href="README-RO.md">Română</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>]<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)**: 화면 캡쳐
|
||||
|
||||
+48
-75
@@ -1,82 +1,55 @@
|
||||
<p align="center">
|
||||
<img src="../res/logo-header.svg" alt="RustDesk - Seu desktop remoto"><br>
|
||||
<a href="#compilar">Compilar</a> •
|
||||
<a href="#como-compilar-com-o-docker">Docker</a> •
|
||||
<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="#estrutura-de-arquivos">Estrutura</a> •
|
||||
<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>
|
||||
<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>
|
||||
</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)
|
||||
|
||||
[](https://rustdesk.com/pricing.html)
|
||||
[](https://rustdesk.com/pricing.html)
|
||||
|
||||
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).
|
||||
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).
|
||||
|
||||

|
||||
RustDesk acolhe contribuições de todos. Leia [`docs/CONTRIBUTING.md`](CONTRIBUTING.md) para ver como começar.
|
||||
|
||||
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)
|
||||
[**DOWNLOAD DE BINÁRIOS**](https://github.com/rustdesk/rustdesk/releases)
|
||||
|
||||
## Dependências
|
||||
|
||||
As versões de desktop usam Flutter ou Sciter (descontinuado) para a interface gráfica (GUI). Este tutorial é apenas para o Sciter, por ser mais fácil e amigável para começar. 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.
|
||||
Versões de desktop utilizam [sciter](https://sciter.com/) para a GUI, por favor baixe a biblioteca dinâmica 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)
|
||||
|
||||
## Passos básicos para compilar
|
||||
## Compilação crua
|
||||
|
||||
- Prepare seu ambiente de desenvolvimento Rust e o ambiente de compilação C++
|
||||
- Prepare seu ambiente de desenvolvimento Rust e ambiente de compilação C++
|
||||
|
||||
- Instale o [vcpkg](https://github.com/microsoft/vcpkg) e configure a variável de ambiente `VCPKG_ROOT` corretamente
|
||||
- Instale [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`
|
||||
|
||||
## [Compilar](https://rustdesk.com/docs/en/dev/build/)
|
||||
|
||||
## Como Compilar no Linux
|
||||
## Como compilar no Linux
|
||||
|
||||
### Ubuntu 18 (Debian 10)
|
||||
|
||||
```sh
|
||||
sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev
|
||||
```
|
||||
|
||||
### openSUSE Tumbleweed
|
||||
|
||||
```sh
|
||||
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel
|
||||
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
|
||||
```
|
||||
|
||||
### Fedora 28 (CentOS 8)
|
||||
|
||||
```sh
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel
|
||||
```
|
||||
|
||||
### Arch (Manjaro)
|
||||
@@ -85,7 +58,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
|
||||
```
|
||||
|
||||
### Instalar o vcpkg
|
||||
### Instale vcpkg
|
||||
|
||||
```sh
|
||||
git clone https://github.com/microsoft/vcpkg
|
||||
@@ -97,7 +70,7 @@ export VCPKG_ROOT=$HOME/vcpkg
|
||||
vcpkg/vcpkg install libvpx libyuv opus aom
|
||||
```
|
||||
|
||||
### Corrigir o libvpx (Para Fedora)
|
||||
### Conserte libvpx (Para o Fedora)
|
||||
|
||||
```sh
|
||||
cd vcpkg/buildtrees/libvpx/src
|
||||
@@ -110,12 +83,12 @@ cp libvpx.a $HOME/vcpkg/installed/x64-linux/lib/
|
||||
cd
|
||||
```
|
||||
|
||||
### Compilar
|
||||
### Compile
|
||||
|
||||
```sh
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
source $HOME/.cargo/env
|
||||
git clone --recurse-submodules https://github.com/rustdesk/rustdesk
|
||||
git clone https://github.com/rustdesk/rustdesk
|
||||
cd rustdesk
|
||||
mkdir -p target/debug
|
||||
wget https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so
|
||||
@@ -123,57 +96,57 @@ mv libsciter-gtk.so target/debug
|
||||
VCPKG_ROOT=$HOME/vcpkg cargo run
|
||||
```
|
||||
|
||||
## Como compilar com o Docker
|
||||
## Como compilar com Docker
|
||||
|
||||
Comece clonando o repositório e construindo o contêiner Docker:
|
||||
Comece clonando o repositório e montando o container docker:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/rustdesk/rustdesk
|
||||
cd rustdesk
|
||||
git submodule update --init --recursive
|
||||
docker build -t "rustdesk-builder" .
|
||||
```
|
||||
|
||||
Depois, cada vez que precisar compilar o aplicativo, execute o seguinte comando:
|
||||
Então, sempre que precisar compilar a aplicação, execute este 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 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:
|
||||
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:
|
||||
|
||||
```sh
|
||||
target/debug/rustdesk
|
||||
```
|
||||
|
||||
Ou, se estiver executando o executável de lançamento:
|
||||
Ou, se estiver rodando um executável de release:
|
||||
|
||||
```sh
|
||||
target/release/rustdesk
|
||||
```
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Estrutura de Arquivos
|
||||
## Estrutura de arquivos
|
||||
|
||||
- **[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.
|
||||
- **[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
|
||||
|
||||
## Capturas de Tela
|
||||
> [!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.
|
||||
|
||||

|
||||
## Screenshots
|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||

|
||||
|
||||
+1
-1
@@ -33,4 +33,4 @@ if [ -z $release ]; then
|
||||
fi
|
||||
set -f
|
||||
#shellcheck disable=2086
|
||||
VCPKG_ROOT=/vcpkg cargo build --locked $argv
|
||||
VCPKG_ROOT=/vcpkg cargo build $argv
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="199"><path fill="#0089d6" d="M118.432 187.698c32.89-5.81 60.055-10.618 60.367-10.684l.568-.12-31.052-36.935c-17.078-20.314-31.051-37.014-31.051-37.11 0-.182 32.063-88.477 32.243-88.792.06-.105 21.88 37.567 52.893 91.32 29.035 50.323 52.973 91.815 53.195 92.203l.405.707-98.684-.012-98.684-.013 59.8-10.564zM0 176.435c0-.052 14.631-25.451 32.514-56.442l32.514-56.347 37.891-31.799C123.76 14.358 140.867.027 140.935.001c.069-.026-.205.664-.609 1.534s-18.919 40.582-41.145 88.25l-40.41 86.67-29.386.037c-16.162.02-29.385-.005-29.385-.057z"/></svg>
|
||||
|
After Width: | Height: | Size: 604 B |
@@ -460,7 +460,6 @@ build)
|
||||
--target "${RUST_TARGET}" \
|
||||
--bindgen \
|
||||
build \
|
||||
--locked \
|
||||
--release \
|
||||
--features "${RUSTDESK_FEATURES}"
|
||||
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
#!/usr/bin/env bash
|
||||
cargo build --locked --features flutter,hwcodec --release --target aarch64-apple-ios --lib
|
||||
cargo build --features flutter,hwcodec --release --target aarch64-apple-ios --lib
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
#!/usr/bin/env bash
|
||||
cargo build --locked --features flutter --release --target x86_64-apple-ios --lib
|
||||
cargo build --features flutter --release --target x86_64-apple-ios --lib
|
||||
|
||||
+10
-65
@@ -716,17 +716,6 @@ 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);
|
||||
}
|
||||
}
|
||||
@@ -3713,54 +3702,14 @@ Widget loadPowered(BuildContext context) {
|
||||
).marginOnly(top: 6);
|
||||
}
|
||||
|
||||
const _kDefaultLogoAsset = 'assets/logo.png';
|
||||
const _kLightLogoAsset = 'assets/logo_light.png';
|
||||
const _kDarkLogoAsset = 'assets/logo_dark.png';
|
||||
|
||||
List<String> _logoAssetCandidatesForBrightness(Brightness brightness) {
|
||||
return brightness == Brightness.dark
|
||||
? [_kDarkLogoAsset, _kDefaultLogoAsset]
|
||||
: [_kLightLogoAsset, _kDefaultLogoAsset];
|
||||
}
|
||||
|
||||
Future<String?> _resolveLogoAsset(Brightness brightness) async {
|
||||
for (final asset in _logoAssetCandidatesForBrightness(brightness)) {
|
||||
try {
|
||||
await rootBundle.load(asset);
|
||||
return asset;
|
||||
} on FlutterError {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
class _Logo extends StatefulWidget {
|
||||
const _Logo();
|
||||
|
||||
@override
|
||||
State<_Logo> createState() => _LogoState();
|
||||
}
|
||||
|
||||
class _LogoState extends State<_Logo> {
|
||||
final Map<Brightness, Future<String?>> _logoFutures = {};
|
||||
|
||||
Future<String?> _logoFutureFor(Brightness brightness) {
|
||||
return _logoFutures.putIfAbsent(
|
||||
brightness,
|
||||
() => _resolveLogoAsset(brightness),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<String?>(
|
||||
future: _logoFutureFor(Theme.of(context).brightness),
|
||||
builder: (BuildContext context, AsyncSnapshot<String?> snapshot) {
|
||||
final asset = snapshot.data;
|
||||
if (asset != null) {
|
||||
// max 300 x 60
|
||||
Widget loadLogo() {
|
||||
return FutureBuilder<ByteData>(
|
||||
future: rootBundle.load('assets/logo.png'),
|
||||
builder: (BuildContext context, AsyncSnapshot<ByteData> snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
final image = Image.asset(
|
||||
asset,
|
||||
'assets/logo.png',
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (ctx, error, stackTrace) {
|
||||
return Container();
|
||||
@@ -3772,14 +3721,9 @@ class _LogoState extends State<_Logo> {
|
||||
).marginOnly(left: 12, right: 12, top: 12);
|
||||
}
|
||||
return const Offstage();
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// max 300 x 60
|
||||
Widget loadLogo() => const _Logo();
|
||||
|
||||
Widget loadIcon(double size) {
|
||||
return Image.asset('assets/icon.png',
|
||||
width: size,
|
||||
@@ -4235,7 +4179,8 @@ Widget? buildAvatarWidget({
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => fallback ?? SizedBox.shrink(),
|
||||
errorBuilder: (_, __, ___) =>
|
||||
fallback ?? SizedBox.shrink(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hbb/common/formatter/id_formatter.dart';
|
||||
import '../../../models/platform_model.dart';
|
||||
@@ -8,136 +5,27 @@ import 'package:flutter_hbb/models/peer_model.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/common/widgets/peer_card.dart';
|
||||
|
||||
@visibleForTesting
|
||||
List<Peer> mergeAutocompletePeers({
|
||||
Iterable<Peer> addressBookPeers = const [],
|
||||
Iterable<Peer> groupPeers = const [],
|
||||
Iterable<Peer> lanPeers = const [],
|
||||
Iterable<Peer> recentPeers = const [],
|
||||
Iterable<String> restRecentPeerIds = const [],
|
||||
}) {
|
||||
final combinedPeers = <String, Peer>{};
|
||||
|
||||
void addPeer(Peer peer) {
|
||||
if (peer.id.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final existingPeer = combinedPeers[peer.id];
|
||||
if (existingPeer == null) {
|
||||
combinedPeers[peer.id] = Peer.copy(peer);
|
||||
} else if (peer.online) {
|
||||
existingPeer.online = true;
|
||||
}
|
||||
}
|
||||
|
||||
for (final peer in addressBookPeers) {
|
||||
addPeer(peer);
|
||||
}
|
||||
for (final peer in groupPeers) {
|
||||
addPeer(peer);
|
||||
}
|
||||
for (final peer in lanPeers) {
|
||||
addPeer(peer);
|
||||
}
|
||||
for (final peer in recentPeers) {
|
||||
addPeer(peer);
|
||||
}
|
||||
for (final id in restRecentPeerIds) {
|
||||
if (id.isNotEmpty && !combinedPeers.containsKey(id)) {
|
||||
combinedPeers[id] = Peer.fromJson({'id': id});
|
||||
}
|
||||
}
|
||||
|
||||
return combinedPeers.values.toList(growable: false);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
bool updateAutocompletePeerOnlineStates(
|
||||
List<Peer> peers, {
|
||||
required Set<String> onlines,
|
||||
required Set<String> offlines,
|
||||
}) {
|
||||
var changed = false;
|
||||
for (final peer in peers) {
|
||||
if (onlines.contains(peer.id)) {
|
||||
if (!peer.online) {
|
||||
peer.online = true;
|
||||
changed = true;
|
||||
}
|
||||
} else if (offlines.contains(peer.id)) {
|
||||
if (peer.online) {
|
||||
peer.online = false;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
List<String> autocompleteOnlineQueryIds(
|
||||
Iterable<Peer> options, {
|
||||
required int limit,
|
||||
}) {
|
||||
final ids = <String>[];
|
||||
final seenIds = <String>{};
|
||||
for (final peer in options) {
|
||||
if (peer.id.isEmpty || seenIds.contains(peer.id)) {
|
||||
continue;
|
||||
}
|
||||
seenIds.add(peer.id);
|
||||
ids.add(peer.id);
|
||||
if (ids.length >= limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
class AllPeersLoader {
|
||||
List<Peer> peers = [];
|
||||
|
||||
bool _isPeersLoading = false;
|
||||
bool _isPeersLoaded = false;
|
||||
Set<String> _lastQueryOnlineIds = {};
|
||||
DateTime _lastQueryOnlineTime = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
Timer? _queryOnlineTimer;
|
||||
List<Peer> _lastQueryOnlineOptions = const [];
|
||||
Set<String> _lastOnlineIds = {};
|
||||
Set<String> _lastOfflineIds = {};
|
||||
final Future<void> Function(List<String> ids) _queryOnlines;
|
||||
final Duration _queryOnlineDebounce;
|
||||
void Function(VoidCallback)? _setState;
|
||||
bool _isCleared = false;
|
||||
|
||||
final String _listenerKey = 'AllPeersLoader';
|
||||
static const String _cbQueryOnlines = 'callback_query_onlines';
|
||||
static const Duration _queryOnlineInterval = Duration(seconds: 5);
|
||||
static const Duration _defaultQueryOnlineDebounce =
|
||||
Duration(milliseconds: 300);
|
||||
static const int _maxQueryOnlineOptions = 20;
|
||||
|
||||
late void Function(VoidCallback) setState;
|
||||
|
||||
bool get needLoad => !_isPeersLoaded && !_isPeersLoading;
|
||||
bool get isPeersLoaded => _isPeersLoaded;
|
||||
|
||||
AllPeersLoader({
|
||||
@visibleForTesting Future<void> Function(List<String> ids)? queryOnlines,
|
||||
@visibleForTesting Duration? queryOnlineDebounce,
|
||||
}) : _queryOnlines = queryOnlines ?? ((ids) => bind.queryOnlines(ids: ids)),
|
||||
_queryOnlineDebounce =
|
||||
queryOnlineDebounce ?? _defaultQueryOnlineDebounce;
|
||||
AllPeersLoader();
|
||||
|
||||
void init(void Function(VoidCallback) setState) {
|
||||
_setState = setState;
|
||||
_isCleared = false;
|
||||
this.setState = setState;
|
||||
gFFI.recentPeersModel.addListener(_mergeAllPeers);
|
||||
gFFI.lanPeersModel.addListener(_mergeAllPeers);
|
||||
gFFI.abModel.addPeerUpdateListener(_listenerKey, _mergeAllPeers);
|
||||
gFFI.groupModel.addPeerUpdateListener(_listenerKey, _mergeAllPeers);
|
||||
platformFFI.registerEventHandler(_cbQueryOnlines, _listenerKey,
|
||||
(evt) async {
|
||||
_updateOnlineState(evt);
|
||||
});
|
||||
}
|
||||
|
||||
void clear() {
|
||||
@@ -145,11 +33,6 @@ class AllPeersLoader {
|
||||
gFFI.lanPeersModel.removeListener(_mergeAllPeers);
|
||||
gFFI.abModel.removePeerUpdateListener(_listenerKey);
|
||||
gFFI.groupModel.removePeerUpdateListener(_listenerKey);
|
||||
platformFFI.unregisterEventHandler(_cbQueryOnlines, _listenerKey);
|
||||
_queryOnlineTimer?.cancel();
|
||||
_lastQueryOnlineOptions = const [];
|
||||
_setState = null;
|
||||
_isCleared = true;
|
||||
}
|
||||
|
||||
Future<void> getAllPeers() async {
|
||||
@@ -176,106 +59,50 @@ class AllPeersLoader {
|
||||
}
|
||||
|
||||
void _mergeAllPeers() {
|
||||
if (_isCleared) {
|
||||
return;
|
||||
Map<String, dynamic> combinedPeers = {};
|
||||
for (var p in gFFI.abModel.allPeers()) {
|
||||
if (!combinedPeers.containsKey(p.id)) {
|
||||
combinedPeers[p.id] = p.toJson();
|
||||
}
|
||||
}
|
||||
peers = mergeAutocompletePeers(
|
||||
addressBookPeers: gFFI.abModel.allPeers(),
|
||||
groupPeers: gFFI.groupModel.peers,
|
||||
lanPeers: gFFI.lanPeersModel.peers,
|
||||
recentPeers: gFFI.recentPeersModel.peers,
|
||||
restRecentPeerIds: gFFI.recentPeersModel.restPeerIds,
|
||||
);
|
||||
_applyLastOnlineState(peers);
|
||||
_scheduleSetState(() {
|
||||
for (var p in gFFI.groupModel.peers.map((e) => Peer.copy(e)).toList()) {
|
||||
if (!combinedPeers.containsKey(p.id)) {
|
||||
combinedPeers[p.id] = p.toJson();
|
||||
}
|
||||
}
|
||||
|
||||
List<Peer> parsedPeers = [];
|
||||
for (var peer in combinedPeers.values) {
|
||||
parsedPeers.add(Peer.fromJson(peer));
|
||||
}
|
||||
|
||||
Set<String> peerIds = combinedPeers.keys.toSet();
|
||||
for (final peer in gFFI.lanPeersModel.peers) {
|
||||
if (!peerIds.contains(peer.id)) {
|
||||
parsedPeers.add(peer);
|
||||
peerIds.add(peer.id);
|
||||
}
|
||||
}
|
||||
|
||||
for (final peer in gFFI.recentPeersModel.peers) {
|
||||
if (!peerIds.contains(peer.id)) {
|
||||
parsedPeers.add(peer);
|
||||
peerIds.add(peer.id);
|
||||
}
|
||||
}
|
||||
for (final id in gFFI.recentPeersModel.restPeerIds) {
|
||||
if (!peerIds.contains(id)) {
|
||||
parsedPeers.add(Peer.fromJson({'id': id}));
|
||||
peerIds.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
peers = parsedPeers;
|
||||
setState(() {
|
||||
_isPeersLoading = false;
|
||||
_isPeersLoaded = true;
|
||||
});
|
||||
}
|
||||
|
||||
void _updateOnlineState(Map<String, dynamic> evt) {
|
||||
if (_isCleared) {
|
||||
return;
|
||||
}
|
||||
_lastOnlineIds = _splitPeerIds(evt['onlines']);
|
||||
_lastOfflineIds = _splitPeerIds(evt['offlines']);
|
||||
final peersChanged = _applyLastOnlineState(peers);
|
||||
final optionsChanged = _applyLastOnlineState(_lastQueryOnlineOptions);
|
||||
if (peersChanged || optionsChanged) {
|
||||
_scheduleSetState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
void _scheduleSetState(VoidCallback callback) {
|
||||
if (_isCleared) {
|
||||
return;
|
||||
}
|
||||
final setState = _setState;
|
||||
if (setState == null) {
|
||||
callback();
|
||||
} else {
|
||||
setState(callback);
|
||||
}
|
||||
}
|
||||
|
||||
bool _applyLastOnlineState(List<Peer> peers) {
|
||||
return updateAutocompletePeerOnlineStates(
|
||||
peers,
|
||||
onlines: _lastOnlineIds,
|
||||
offlines: _lastOfflineIds,
|
||||
);
|
||||
}
|
||||
|
||||
Set<String> _splitPeerIds(dynamic ids) {
|
||||
if (ids is! String || ids.isEmpty) {
|
||||
return {};
|
||||
}
|
||||
return ids.split(',').where((id) => id.isNotEmpty).toSet();
|
||||
}
|
||||
|
||||
void queryOnlines(Iterable<Peer> options) {
|
||||
if (_isCleared) {
|
||||
return;
|
||||
}
|
||||
_lastQueryOnlineOptions = options.toList(growable: false);
|
||||
final ids = autocompleteOnlineQueryIds(
|
||||
_lastQueryOnlineOptions,
|
||||
limit: _maxQueryOnlineOptions,
|
||||
).toSet();
|
||||
_queryOnlineTimer?.cancel();
|
||||
_queryOnlineTimer = null;
|
||||
if (ids.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final now = DateTime.now();
|
||||
if (setEquals(ids, _lastQueryOnlineIds) &&
|
||||
now.difference(_lastQueryOnlineTime) < _queryOnlineInterval) {
|
||||
return;
|
||||
}
|
||||
|
||||
_queryOnlineTimer = Timer(_queryOnlineDebounce, () async {
|
||||
try {
|
||||
await _queryOnlines(ids.toList(growable: false));
|
||||
if (_isCleared) {
|
||||
return;
|
||||
}
|
||||
_lastQueryOnlineIds = ids;
|
||||
_lastQueryOnlineTime = DateTime.now();
|
||||
} catch (e) {
|
||||
debugPrint('query autocomplete online state failed: $e');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
void updateOnlineStateForTesting(Map<String, dynamic> evt) {
|
||||
_updateOnlineState(evt);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
bool applyLastOnlineStateForTesting(List<Peer> peers) {
|
||||
return _applyLastOnlineState(peers);
|
||||
}
|
||||
}
|
||||
|
||||
class AutocompletePeerTile extends StatefulWidget {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// flutter/lib/common/widgets/keyboard_shortcuts/display.dart
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../../../consts.dart';
|
||||
import '../../../models/platform_model.dart';
|
||||
|
||||
/// Read the bindings JSON and produce a human-readable shortcut string for
|
||||
/// `actionId`, formatted for the current OS. Returns null if unbound.
|
||||
class ShortcutDisplay {
|
||||
static String? formatFor(String actionId) {
|
||||
final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey);
|
||||
if (raw.isEmpty) return null;
|
||||
final Map<String, dynamic> parsed;
|
||||
try {
|
||||
parsed = jsonDecode(raw) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
if (parsed['enabled'] != true) return null;
|
||||
final list = (parsed['bindings'] as List? ?? []).cast<Map<String, dynamic>>();
|
||||
final found = list.firstWhere(
|
||||
(b) => b['action'] == actionId,
|
||||
orElse: () => {},
|
||||
);
|
||||
if (found.isEmpty) return null;
|
||||
|
||||
// Guard against a hand-edited / corrupt config where `key` is missing or
|
||||
// not a string — silently treat the binding as unbound rather than
|
||||
// crashing the toolbar render.
|
||||
final keyValue = found['key'];
|
||||
if (keyValue is! String) return null;
|
||||
|
||||
final isMac = defaultTargetPlatform == TargetPlatform.macOS ||
|
||||
defaultTargetPlatform == TargetPlatform.iOS;
|
||||
// `mods` similarly may be malformed; treat a non-list as no modifiers.
|
||||
final modsRaw = found['mods'];
|
||||
final mods = modsRaw is List
|
||||
? modsRaw.whereType<String>().toList()
|
||||
: const <String>[];
|
||||
final parts = <String>[];
|
||||
for (final m in ['primary', 'alt', 'shift']) {
|
||||
if (!mods.contains(m)) continue;
|
||||
switch (m) {
|
||||
case 'primary': parts.add(isMac ? '⌘' : 'Ctrl'); break;
|
||||
case 'alt': parts.add(isMac ? '⌥' : 'Alt'); break;
|
||||
case 'shift': parts.add(isMac ? '⇧' : 'Shift'); break;
|
||||
}
|
||||
}
|
||||
parts.add(_keyDisplay(keyValue, isMac));
|
||||
return isMac ? parts.join('') : parts.join('+');
|
||||
}
|
||||
|
||||
static String _keyDisplay(String key, bool isMac) {
|
||||
switch (key) {
|
||||
case 'delete': return isMac ? '⌫' : 'Del';
|
||||
case 'enter': return isMac ? '⏎' : 'Enter';
|
||||
case 'arrow_left': return '←';
|
||||
case 'arrow_right':return '→';
|
||||
case 'arrow_up': return '↑';
|
||||
case 'arrow_down': return '↓';
|
||||
}
|
||||
if (key.startsWith('digit')) return key.substring(5);
|
||||
return key.toUpperCase();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
// flutter/lib/common/widgets/keyboard_shortcuts/page_body.dart
|
||||
//
|
||||
// Shared body widget for the Keyboard Shortcuts configuration page. Both the
|
||||
// desktop (`desktop/pages/desktop_keyboard_shortcuts_page.dart`) and mobile
|
||||
// (`mobile/pages/mobile_keyboard_shortcuts_page.dart`) pages render this
|
||||
// widget inside their own platform-styled Scaffold + AppBar shell.
|
||||
//
|
||||
// The body owns:
|
||||
// * the top-level enable/disable toggle (mirrors the General-tab toggle —
|
||||
// same JSON key, same semantics);
|
||||
// * a grouped list of actions, each with its current binding plus
|
||||
// edit / clear icons;
|
||||
// * the JSON read/write helpers under [kShortcutLocalConfigKey] in the
|
||||
// canonical {enabled, bindings:[{action,mods,key}]} shape;
|
||||
// * the recording-dialog round-trip and conflict-replace bookkeeping;
|
||||
// * "Reset to defaults" (called from the platform AppBar).
|
||||
//
|
||||
// Platform shells supply only:
|
||||
// * the AppBar (with a "Reset to defaults" action that calls
|
||||
// [KeyboardShortcutsPageBodyState.resetToDefaultsWithConfirm]);
|
||||
// * surrounding padding / list-tile vs. dense-row visuals via the
|
||||
// [compact] flag.
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../common.dart';
|
||||
import '../../../consts.dart';
|
||||
import '../../../models/platform_model.dart';
|
||||
import '../../../models/shortcut_model.dart';
|
||||
import 'recording_dialog.dart';
|
||||
|
||||
/// One configurable action — id + i18n key for its label.
|
||||
class KeyboardShortcutActionEntry {
|
||||
final String id;
|
||||
final String labelKey;
|
||||
const KeyboardShortcutActionEntry(this.id, this.labelKey);
|
||||
}
|
||||
|
||||
/// A named group of actions (e.g. "Session Control").
|
||||
class KeyboardShortcutActionGroup {
|
||||
final String titleKey;
|
||||
final List<KeyboardShortcutActionEntry> actions;
|
||||
const KeyboardShortcutActionGroup(this.titleKey, this.actions);
|
||||
}
|
||||
|
||||
/// Canonical action group definitions used by both the desktop and mobile
|
||||
/// configuration pages. The order of groups and entries here is the order
|
||||
/// the user sees in the UI. (Not `const` because the per-tab ids come from
|
||||
/// the `kShortcutActionSwitchTab(n)` helper in `consts.dart`.)
|
||||
final List<KeyboardShortcutActionGroup> kKeyboardShortcutActionGroups = [
|
||||
KeyboardShortcutActionGroup('Session Control', [
|
||||
KeyboardShortcutActionEntry(
|
||||
kShortcutActionSendCtrlAltDel, 'Insert Ctrl + Alt + Del'),
|
||||
KeyboardShortcutActionEntry(kShortcutActionInsertLock, 'Insert Lock'),
|
||||
KeyboardShortcutActionEntry(kShortcutActionRefresh, 'Refresh'),
|
||||
KeyboardShortcutActionEntry(kShortcutActionSwitchSides, 'Switch Sides'),
|
||||
KeyboardShortcutActionEntry(
|
||||
kShortcutActionToggleRecording, 'Toggle Recording'),
|
||||
KeyboardShortcutActionEntry(
|
||||
kShortcutActionToggleBlockInput, 'Toggle Block User Input'),
|
||||
]),
|
||||
KeyboardShortcutActionGroup('Display', [
|
||||
KeyboardShortcutActionEntry(
|
||||
kShortcutActionToggleFullscreen, 'Toggle Fullscreen'),
|
||||
KeyboardShortcutActionEntry(
|
||||
kShortcutActionSwitchDisplayNext, 'Switch to next display'),
|
||||
KeyboardShortcutActionEntry(
|
||||
kShortcutActionSwitchDisplayPrev, 'Switch to previous display'),
|
||||
KeyboardShortcutActionEntry(kShortcutActionViewMode1to1, 'View Mode 1:1'),
|
||||
KeyboardShortcutActionEntry(
|
||||
kShortcutActionViewModeShrink, 'View Mode Shrink'),
|
||||
KeyboardShortcutActionEntry(
|
||||
kShortcutActionViewModeStretch, 'View Mode Stretch'),
|
||||
]),
|
||||
KeyboardShortcutActionGroup('Other', [
|
||||
KeyboardShortcutActionEntry(kShortcutActionScreenshot, 'Take Screenshot'),
|
||||
KeyboardShortcutActionEntry(kShortcutActionToggleAudio, 'Toggle Audio'),
|
||||
KeyboardShortcutActionEntry(
|
||||
kShortcutActionTogglePrivacyMode, 'Toggle Privacy Mode'),
|
||||
for (var n = 1; n <= 9; n++)
|
||||
KeyboardShortcutActionEntry(
|
||||
kShortcutActionSwitchTab(n), 'Switch Tab $n'),
|
||||
]),
|
||||
];
|
||||
|
||||
/// The shared body widget. Render this inside a platform-styled Scaffold.
|
||||
///
|
||||
/// [compact] toggles the desktop dense-row layout (`true`) versus the mobile
|
||||
/// touch-friendly ListTile layout (`false`).
|
||||
///
|
||||
/// [editButtonHint] is shown as the tooltip on the Edit icon. Mobile shells
|
||||
/// use this to clarify that recording requires a physical keyboard.
|
||||
///
|
||||
/// [headerBanner] is an optional widget rendered above the toggle. Mobile
|
||||
/// uses this to show the "Recording requires a physical keyboard" hint.
|
||||
class KeyboardShortcutsPageBody extends StatefulWidget {
|
||||
final bool compact;
|
||||
final String? editButtonHint;
|
||||
final Widget? headerBanner;
|
||||
|
||||
const KeyboardShortcutsPageBody({
|
||||
Key? key,
|
||||
this.compact = true,
|
||||
this.editButtonHint,
|
||||
this.headerBanner,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<KeyboardShortcutsPageBody> createState() =>
|
||||
KeyboardShortcutsPageBodyState();
|
||||
}
|
||||
|
||||
/// Public state so platform shells can call [resetToDefaultsWithConfirm] from
|
||||
/// their AppBar action.
|
||||
class KeyboardShortcutsPageBodyState extends State<KeyboardShortcutsPageBody> {
|
||||
// ----- Persistence helpers -----
|
||||
|
||||
Map<String, dynamic> _readJson() {
|
||||
final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey);
|
||||
if (raw.isEmpty) return {'enabled': false, 'bindings': <dynamic>[]};
|
||||
try {
|
||||
final parsed = jsonDecode(raw) as Map<String, dynamic>;
|
||||
parsed['bindings'] ??= <dynamic>[];
|
||||
parsed['enabled'] ??= false;
|
||||
return parsed;
|
||||
} catch (_) {
|
||||
return {'enabled': false, 'bindings': <dynamic>[]};
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _writeJson(Map<String, dynamic> json) async {
|
||||
await bind.mainSetLocalOption(
|
||||
key: kShortcutLocalConfigKey, value: jsonEncode(json));
|
||||
// Refresh the matcher cache so writes take effect immediately. On native
|
||||
// this hits the Rust matcher; on Web the bridge forwards to the JS-side
|
||||
// matcher in flutter/web/js/.
|
||||
bind.mainReloadKeyboardShortcuts();
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
/// Replace the bindings entry for [actionId] with [binding]. If [binding]
|
||||
/// is null, removes the existing entry. If the user is replacing a
|
||||
/// conflicting binding, [clearActionId] points at the action whose
|
||||
/// (now-stale) binding should be removed in the same write.
|
||||
Future<void> _setBinding(
|
||||
String actionId, {
|
||||
Map<String, dynamic>? binding,
|
||||
String? clearActionId,
|
||||
}) async {
|
||||
final json = _readJson();
|
||||
final list = ((json['bindings'] as List?) ?? <dynamic>[])
|
||||
.cast<Map<String, dynamic>>()
|
||||
.toList();
|
||||
list.removeWhere((b) {
|
||||
final a = b['action'];
|
||||
return a == actionId || (clearActionId != null && a == clearActionId);
|
||||
});
|
||||
if (binding != null) {
|
||||
list.add(binding);
|
||||
}
|
||||
json['bindings'] = list;
|
||||
await _writeJson(json);
|
||||
}
|
||||
|
||||
Future<void> _setEnabled(bool v) async {
|
||||
final json = _readJson();
|
||||
json['enabled'] = v;
|
||||
// First-time enable: seed defaults if the user has never bound anything.
|
||||
final list = (json['bindings'] as List?) ?? const [];
|
||||
if (v && list.isEmpty) {
|
||||
json['bindings'] = jsonDecode(bind.mainGetDefaultKeyboardShortcuts());
|
||||
}
|
||||
await _writeJson(json);
|
||||
}
|
||||
|
||||
Future<void> _resetToDefaults() async {
|
||||
final json = _readJson();
|
||||
json['bindings'] = jsonDecode(bind.mainGetDefaultKeyboardShortcuts());
|
||||
await _writeJson(json);
|
||||
}
|
||||
|
||||
String _labelFor(String actionId) {
|
||||
for (final g in kKeyboardShortcutActionGroups) {
|
||||
for (final a in g.actions) {
|
||||
if (a.id == actionId) return translate(a.labelKey);
|
||||
}
|
||||
}
|
||||
return actionId;
|
||||
}
|
||||
|
||||
// ----- UI handlers -----
|
||||
|
||||
Future<void> _onEdit(KeyboardShortcutActionEntry entry) async {
|
||||
final json = _readJson();
|
||||
final bindings = ((json['bindings'] as List?) ?? <dynamic>[])
|
||||
.cast<Map<String, dynamic>>();
|
||||
final result = await showRecordingDialog(
|
||||
context: context,
|
||||
actionId: entry.id,
|
||||
actionLabel: translate(entry.labelKey),
|
||||
existingBindings: bindings,
|
||||
actionLabelLookup: _labelFor,
|
||||
);
|
||||
if (result == null) return;
|
||||
await _setBinding(
|
||||
entry.id,
|
||||
binding: result.binding,
|
||||
clearActionId: result.clearActionId,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onClear(KeyboardShortcutActionEntry entry) async {
|
||||
await _setBinding(entry.id, binding: null);
|
||||
}
|
||||
|
||||
/// Public — invoked from the platform AppBar action.
|
||||
Future<void> resetToDefaultsWithConfirm() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(translate('Reset to defaults')),
|
||||
content: Text(translate('shortcut-reset-confirm-tip')),
|
||||
actions: [
|
||||
dialogButton('Cancel',
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
isOutline: true),
|
||||
dialogButton('OK', onPressed: () => Navigator.of(ctx).pop(true)),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed == true) {
|
||||
await _resetToDefaults();
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Build -----
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final enabled = ShortcutModel.isEnabled();
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
if (widget.headerBanner != null) ...[
|
||||
widget.headerBanner!,
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
// Top toggle — mirrors the General-tab _OptionCheckBox semantics.
|
||||
Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: enabled,
|
||||
onChanged: (v) async {
|
||||
if (v == null) return;
|
||||
await _setEnabled(v);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => _setEnabled(!enabled),
|
||||
child: Text(
|
||||
translate('Enable keyboard shortcuts in remote session'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text(
|
||||
translate('shortcut-page-description'),
|
||||
style: TextStyle(color: theme.hintColor),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Disabled visual state when toggle is off — but still scrollable.
|
||||
Opacity(
|
||||
opacity: enabled ? 1.0 : 0.5,
|
||||
child: AbsorbPointer(
|
||||
absorbing: !enabled,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final group in kKeyboardShortcutActionGroups)
|
||||
_buildGroup(context, group),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGroup(BuildContext context, KeyboardShortcutActionGroup group) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
translate(group.titleKey),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Expanded(
|
||||
child: Divider(thickness: 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
for (final action in group.actions)
|
||||
widget.compact
|
||||
? _buildCompactRow(context, action)
|
||||
: _buildTouchRow(context, action),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Desktop dense row: label | shortcut | edit | clear, all in one Row.
|
||||
Widget _buildCompactRow(
|
||||
BuildContext context, KeyboardShortcutActionEntry entry) {
|
||||
final shortcut = ShortcutDisplayForActionId.format(entry.id);
|
||||
final hasBinding = shortcut != null;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 5,
|
||||
child: Text(translate(entry.labelKey)),
|
||||
),
|
||||
Expanded(
|
||||
flex: 4,
|
||||
child: Text(
|
||||
shortcut ?? '—',
|
||||
style: TextStyle(
|
||||
fontFamily: defaultTargetPlatform == TargetPlatform.windows
|
||||
? 'Consolas'
|
||||
: 'monospace',
|
||||
color: hasBinding ? null : Theme.of(context).hintColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: widget.editButtonHint ?? translate('Edit'),
|
||||
onPressed: () => _onEdit(entry),
|
||||
icon: const Icon(Icons.edit_outlined, size: 18),
|
||||
),
|
||||
SizedBox(
|
||||
width: 40,
|
||||
child: hasBinding
|
||||
? IconButton(
|
||||
tooltip: translate('Clear'),
|
||||
onPressed: () => _onClear(entry),
|
||||
icon: const Icon(Icons.close, size: 18),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Mobile touch row: ListTile with title + subtitle + trailing icons.
|
||||
Widget _buildTouchRow(
|
||||
BuildContext context, KeyboardShortcutActionEntry entry) {
|
||||
final shortcut = ShortcutDisplayForActionId.format(entry.id);
|
||||
final hasBinding = shortcut != null;
|
||||
return ListTile(
|
||||
dense: false,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
title: Text(translate(entry.labelKey)),
|
||||
subtitle: Text(
|
||||
shortcut ?? '—',
|
||||
style: TextStyle(
|
||||
fontFamily: defaultTargetPlatform == TargetPlatform.windows
|
||||
? 'Consolas'
|
||||
: 'monospace',
|
||||
color: hasBinding ? null : Theme.of(context).hintColor,
|
||||
),
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: widget.editButtonHint ?? translate('Edit'),
|
||||
onPressed: () => _onEdit(entry),
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
),
|
||||
if (hasBinding)
|
||||
IconButton(
|
||||
tooltip: translate('Clear'),
|
||||
onPressed: () => _onClear(entry),
|
||||
icon: const Icon(Icons.close),
|
||||
)
|
||||
else
|
||||
const SizedBox(width: 48),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Thin wrapper around [ShortcutDisplay.formatFor] that ignores the
|
||||
/// `enabled` flag so the configuration page can always show the user what
|
||||
/// they have bound, even when the feature is currently disabled.
|
||||
class ShortcutDisplayForActionId {
|
||||
static String? format(String actionId) {
|
||||
final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey);
|
||||
if (raw.isEmpty) return null;
|
||||
final Map<String, dynamic> parsed;
|
||||
try {
|
||||
parsed = jsonDecode(raw) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
final list = (parsed['bindings'] as List? ?? const [])
|
||||
.cast<Map<String, dynamic>>();
|
||||
final found = list.firstWhere(
|
||||
(b) => b['action'] == actionId,
|
||||
orElse: () => {},
|
||||
);
|
||||
if (found.isEmpty) return null;
|
||||
|
||||
// Guard against a hand-edited / corrupt config where `key` is missing or
|
||||
// not a string — render the row as unbound instead of crashing the
|
||||
// settings page.
|
||||
final keyValue = found['key'];
|
||||
if (keyValue is! String) return null;
|
||||
|
||||
final isMac = defaultTargetPlatform == TargetPlatform.macOS ||
|
||||
defaultTargetPlatform == TargetPlatform.iOS;
|
||||
// `mods` similarly may be malformed; treat a non-list as no modifiers.
|
||||
final modsRaw = found['mods'];
|
||||
final mods = modsRaw is List
|
||||
? modsRaw.whereType<String>().toList()
|
||||
: const <String>[];
|
||||
final parts = <String>[];
|
||||
for (final m in ['primary', 'alt', 'shift']) {
|
||||
if (!mods.contains(m)) continue;
|
||||
switch (m) {
|
||||
case 'primary':
|
||||
parts.add(isMac ? '⌘' : 'Ctrl');
|
||||
break;
|
||||
case 'alt':
|
||||
parts.add(isMac ? '⌥' : 'Alt');
|
||||
break;
|
||||
case 'shift':
|
||||
parts.add(isMac ? '⇧' : 'Shift');
|
||||
break;
|
||||
}
|
||||
}
|
||||
parts.add(_keyDisplay(keyValue, isMac));
|
||||
return isMac ? parts.join('') : parts.join('+');
|
||||
}
|
||||
|
||||
static String _keyDisplay(String key, bool isMac) {
|
||||
switch (key) {
|
||||
case 'delete':
|
||||
return isMac ? '⌫' : 'Del';
|
||||
case 'enter':
|
||||
return isMac ? '⏎' : 'Enter';
|
||||
case 'arrow_left':
|
||||
return '←';
|
||||
case 'arrow_right':
|
||||
return '→';
|
||||
case 'arrow_up':
|
||||
return '↑';
|
||||
case 'arrow_down':
|
||||
return '↓';
|
||||
}
|
||||
if (key.startsWith('digit')) return key.substring(5);
|
||||
return key.toUpperCase();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
// flutter/lib/common/widgets/keyboard_shortcuts/recording_dialog.dart
|
||||
//
|
||||
// Modal dialog used by the Keyboard Shortcuts settings page to capture a new
|
||||
// key combination for a given action. The dialog listens for KeyDown events,
|
||||
// extracts the modifier set + non-modifier key, validates against the
|
||||
// "must include Ctrl+Alt+Shift (Cmd+Option+Shift on macOS)" rule, and reports
|
||||
// any conflict with another already-bound action.
|
||||
//
|
||||
// On Save, returns the new binding map ({action, mods, key}) plus the
|
||||
// optional id of the action whose binding should be cleared (the conflict
|
||||
// "Replace" path). On Cancel, returns null.
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../../common.dart';
|
||||
|
||||
/// Result of the recording dialog.
|
||||
class RecordingResult {
|
||||
/// The new binding map to write: {action, mods, key}.
|
||||
final Map<String, dynamic> binding;
|
||||
|
||||
/// If the chosen combo conflicted with another action, the user chose
|
||||
/// "Replace" — the caller must clear this action's binding before writing
|
||||
/// the new one.
|
||||
final String? clearActionId;
|
||||
|
||||
RecordingResult(this.binding, this.clearActionId);
|
||||
}
|
||||
|
||||
/// Show the recording dialog.
|
||||
///
|
||||
/// [actionId] is the action being edited (used for the title and to detect
|
||||
/// "binding to itself" — that's not a conflict).
|
||||
/// [actionLabel] is the translated, user-facing action name.
|
||||
/// [existingBindings] is the current bindings list (used for conflict detection).
|
||||
/// [actionLabelLookup] resolves an actionId to its translated label, used in
|
||||
/// the conflict warning.
|
||||
Future<RecordingResult?> showRecordingDialog({
|
||||
required BuildContext context,
|
||||
required String actionId,
|
||||
required String actionLabel,
|
||||
required List<Map<String, dynamic>> existingBindings,
|
||||
required String Function(String) actionLabelLookup,
|
||||
}) {
|
||||
return showDialog<RecordingResult>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (ctx) => _RecordingDialog(
|
||||
actionId: actionId,
|
||||
actionLabel: actionLabel,
|
||||
existingBindings: existingBindings,
|
||||
actionLabelLookup: actionLabelLookup,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _RecordingDialog extends StatefulWidget {
|
||||
final String actionId;
|
||||
final String actionLabel;
|
||||
final List<Map<String, dynamic>> existingBindings;
|
||||
final String Function(String) actionLabelLookup;
|
||||
|
||||
const _RecordingDialog({
|
||||
required this.actionId,
|
||||
required this.actionLabel,
|
||||
required this.existingBindings,
|
||||
required this.actionLabelLookup,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_RecordingDialog> createState() => _RecordingDialogState();
|
||||
}
|
||||
|
||||
class _RecordingDialogState extends State<_RecordingDialog> {
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
|
||||
// Captured combo. null until the user presses something with a non-modifier.
|
||||
Set<String> _mods = {};
|
||||
String? _key;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_focusNode.requestFocus();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool get _isMac =>
|
||||
defaultTargetPlatform == TargetPlatform.macOS ||
|
||||
defaultTargetPlatform == TargetPlatform.iOS;
|
||||
|
||||
/// True when the captured combo includes the required Ctrl+Alt+Shift
|
||||
/// (Cmd+Option+Shift on macOS) prefix and a non-modifier key.
|
||||
bool get _hasRequiredPrefix =>
|
||||
_mods.contains('primary') &&
|
||||
_mods.contains('alt') &&
|
||||
_mods.contains('shift');
|
||||
|
||||
/// Return the actionId that this combo currently conflicts with, or null.
|
||||
/// The action being edited is not a conflict with itself.
|
||||
String? get _conflictActionId {
|
||||
if (_key == null || !_hasRequiredPrefix) return null;
|
||||
for (final b in widget.existingBindings) {
|
||||
final otherAction = b['action'] as String?;
|
||||
if (otherAction == null || otherAction == widget.actionId) continue;
|
||||
final otherKey = b['key'] as String?;
|
||||
final otherMods =
|
||||
((b['mods'] as List?) ?? const []).cast<String>().toSet();
|
||||
if (otherKey == _key &&
|
||||
otherMods.length == _mods.length &&
|
||||
otherMods.containsAll(_mods)) {
|
||||
return otherAction;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) {
|
||||
if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.escape) {
|
||||
Navigator.of(context).pop();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (event is! KeyDownEvent) return KeyEventResult.handled;
|
||||
|
||||
// Ignore modifier-only KeyDowns: don't lock in a partial combo.
|
||||
final logical = event.logicalKey;
|
||||
final keyName = _logicalToKeyName(logical);
|
||||
|
||||
final mods = <String>{};
|
||||
if (HardwareKeyboard.instance.isAltPressed) mods.add('alt');
|
||||
if (HardwareKeyboard.instance.isShiftPressed) mods.add('shift');
|
||||
final primary = _isMac
|
||||
? HardwareKeyboard.instance.isMetaPressed
|
||||
: HardwareKeyboard.instance.isControlPressed;
|
||||
if (primary) mods.add('primary');
|
||||
|
||||
setState(() {
|
||||
_mods = mods;
|
||||
// Only lock in the key when it's a non-modifier we recognize.
|
||||
// Modifier-only KeyDowns (Shift, Ctrl, etc.) leave the captured key
|
||||
// untouched, so the user can adjust modifiers after the fact.
|
||||
if (keyName != null) {
|
||||
_key = keyName;
|
||||
}
|
||||
});
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
void _onSave() {
|
||||
if (_key == null || !_hasRequiredPrefix) return;
|
||||
// Sort mods to match the canonical order used by Rust default_bindings:
|
||||
// primary, alt, shift.
|
||||
final ordered = <String>[
|
||||
if (_mods.contains('primary')) 'primary',
|
||||
if (_mods.contains('alt')) 'alt',
|
||||
if (_mods.contains('shift')) 'shift',
|
||||
];
|
||||
final binding = <String, dynamic>{
|
||||
'action': widget.actionId,
|
||||
'mods': ordered,
|
||||
'key': _key!,
|
||||
};
|
||||
Navigator.of(context).pop(RecordingResult(binding, _conflictActionId));
|
||||
}
|
||||
|
||||
String _formatPrefix() {
|
||||
if (_isMac) return 'Cmd+Option+Shift';
|
||||
return 'Ctrl+Alt+Shift';
|
||||
}
|
||||
|
||||
String _formatCombo() {
|
||||
final parts = <String>[];
|
||||
for (final m in ['primary', 'alt', 'shift']) {
|
||||
if (!_mods.contains(m)) continue;
|
||||
switch (m) {
|
||||
case 'primary':
|
||||
parts.add(_isMac ? '⌘' : 'Ctrl');
|
||||
break;
|
||||
case 'alt':
|
||||
parts.add(_isMac ? '⌥' : 'Alt');
|
||||
break;
|
||||
case 'shift':
|
||||
parts.add(_isMac ? '⇧' : 'Shift');
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (_key != null) {
|
||||
parts.add(_keyDisplay(_key!));
|
||||
}
|
||||
if (parts.isEmpty) return translate('shortcut-recording-press-keys-tip');
|
||||
return _isMac ? parts.join('') : parts.join('+');
|
||||
}
|
||||
|
||||
String _keyDisplay(String key) {
|
||||
switch (key) {
|
||||
case 'delete':
|
||||
return _isMac ? '⌫' : 'Del';
|
||||
case 'enter':
|
||||
return _isMac ? '⏎' : 'Enter';
|
||||
case 'arrow_left':
|
||||
return '←';
|
||||
case 'arrow_right':
|
||||
return '→';
|
||||
case 'arrow_up':
|
||||
return '↑';
|
||||
case 'arrow_down':
|
||||
return '↓';
|
||||
}
|
||||
if (key.startsWith('digit')) return key.substring(5);
|
||||
return key.toUpperCase();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasKey = _key != null;
|
||||
final conflictId = _conflictActionId;
|
||||
final hasConflict = conflictId != null;
|
||||
final canSave = hasKey && _hasRequiredPrefix;
|
||||
|
||||
Widget statusLine;
|
||||
if (!hasKey) {
|
||||
statusLine = Text(
|
||||
translate('shortcut-recording-press-keys-tip'),
|
||||
style: TextStyle(color: Theme.of(context).hintColor),
|
||||
);
|
||||
} else if (!_hasRequiredPrefix) {
|
||||
statusLine = Row(
|
||||
children: [
|
||||
Icon(Icons.close, size: 16, color: Colors.red),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: Text(
|
||||
'${translate('shortcut-must-include-prefix')} ${_formatPrefix()}',
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else if (hasConflict) {
|
||||
final otherLabel = widget.actionLabelLookup(conflictId);
|
||||
statusLine = Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber_outlined,
|
||||
size: 16, color: Colors.orange.shade700),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: Text(
|
||||
'${translate('shortcut-already-bound-to')} "$otherLabel"',
|
||||
style: TextStyle(color: Colors.orange.shade700),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
statusLine = Row(
|
||||
children: [
|
||||
const Icon(Icons.check, size: 16, color: Colors.green),
|
||||
const SizedBox(width: 6),
|
||||
Text(translate('Valid'),
|
||||
style: const TextStyle(color: Colors.green)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
final saveLabel = hasConflict ? 'Replace' : 'Save';
|
||||
|
||||
return AlertDialog(
|
||||
title: Text(
|
||||
'${translate('Set Shortcut')}: ${widget.actionLabel}',
|
||||
),
|
||||
content: Focus(
|
||||
focusNode: _focusNode,
|
||||
autofocus: true,
|
||||
onKeyEvent: _onKeyEvent,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minWidth: 380),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(translate('shortcut-recording-instruction')),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 18, horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Theme.of(context).dividerColor),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
_formatCombo(),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: hasKey
|
||||
? Theme.of(context).textTheme.titleLarge?.color
|
||||
: Theme.of(context).hintColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
statusLine,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
dialogButton('Cancel',
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
isOutline: true),
|
||||
dialogButton(saveLabel, onPressed: canSave ? _onSave : null),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Mirror of `event_to_key_name` in `src/keyboard/shortcuts.rs` and
|
||||
/// `logicalToKeyName` in `flutter/web/js/src/shortcut_matcher.ts` — keep
|
||||
/// the three in lockstep. Returns null for modifier-only or unsupported keys.
|
||||
static String? _logicalToKeyName(LogicalKeyboardKey k) {
|
||||
if (k == LogicalKeyboardKey.delete) return 'delete';
|
||||
if (k == LogicalKeyboardKey.enter ||
|
||||
k == LogicalKeyboardKey.numpadEnter) return 'enter';
|
||||
if (k == LogicalKeyboardKey.arrowLeft) return 'arrow_left';
|
||||
if (k == LogicalKeyboardKey.arrowRight) return 'arrow_right';
|
||||
if (k == LogicalKeyboardKey.arrowUp) return 'arrow_up';
|
||||
if (k == LogicalKeyboardKey.arrowDown) return 'arrow_down';
|
||||
|
||||
final letters = <LogicalKeyboardKey, String>{
|
||||
LogicalKeyboardKey.keyA: 'a', LogicalKeyboardKey.keyB: 'b',
|
||||
LogicalKeyboardKey.keyC: 'c', LogicalKeyboardKey.keyD: 'd',
|
||||
LogicalKeyboardKey.keyE: 'e', LogicalKeyboardKey.keyF: 'f',
|
||||
LogicalKeyboardKey.keyG: 'g', LogicalKeyboardKey.keyH: 'h',
|
||||
LogicalKeyboardKey.keyI: 'i', LogicalKeyboardKey.keyJ: 'j',
|
||||
LogicalKeyboardKey.keyK: 'k', LogicalKeyboardKey.keyL: 'l',
|
||||
LogicalKeyboardKey.keyM: 'm', LogicalKeyboardKey.keyN: 'n',
|
||||
LogicalKeyboardKey.keyO: 'o', LogicalKeyboardKey.keyP: 'p',
|
||||
LogicalKeyboardKey.keyQ: 'q', LogicalKeyboardKey.keyR: 'r',
|
||||
LogicalKeyboardKey.keyS: 's', LogicalKeyboardKey.keyT: 't',
|
||||
LogicalKeyboardKey.keyU: 'u', LogicalKeyboardKey.keyV: 'v',
|
||||
LogicalKeyboardKey.keyW: 'w', LogicalKeyboardKey.keyX: 'x',
|
||||
LogicalKeyboardKey.keyY: 'y', LogicalKeyboardKey.keyZ: 'z',
|
||||
};
|
||||
if (letters.containsKey(k)) return letters[k];
|
||||
|
||||
final digits = <LogicalKeyboardKey, String>{
|
||||
LogicalKeyboardKey.digit1: 'digit1',
|
||||
LogicalKeyboardKey.digit2: 'digit2',
|
||||
LogicalKeyboardKey.digit3: 'digit3',
|
||||
LogicalKeyboardKey.digit4: 'digit4',
|
||||
LogicalKeyboardKey.digit5: 'digit5',
|
||||
LogicalKeyboardKey.digit6: 'digit6',
|
||||
LogicalKeyboardKey.digit7: 'digit7',
|
||||
LogicalKeyboardKey.digit8: 'digit8',
|
||||
LogicalKeyboardKey.digit9: 'digit9',
|
||||
};
|
||||
if (digits.containsKey(k)) return digits[k];
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -24,35 +24,6 @@ const kOpSvgList = [
|
||||
'microsoft'
|
||||
];
|
||||
|
||||
class _OidcProviderBranding {
|
||||
final String label;
|
||||
final String iconKey;
|
||||
|
||||
const _OidcProviderBranding({
|
||||
required this.label,
|
||||
required this.iconKey,
|
||||
});
|
||||
}
|
||||
|
||||
_OidcProviderBranding _oidcProviderBranding(String op) {
|
||||
switch (op.toLowerCase()) {
|
||||
case 'azure':
|
||||
return _OidcProviderBranding(
|
||||
label: 'Microsoft',
|
||||
iconKey: 'microsoft',
|
||||
);
|
||||
default:
|
||||
return _OidcProviderBranding(
|
||||
label: {
|
||||
'github': 'GitHub',
|
||||
'gitlab': 'GitLab',
|
||||
}[op.toLowerCase()] ??
|
||||
toCapitalized(op),
|
||||
iconKey: op.toLowerCase(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _IconOP extends StatelessWidget {
|
||||
final String op;
|
||||
final String? icon;
|
||||
@@ -103,8 +74,11 @@ class ButtonOP extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final branding = _oidcProviderBranding(op);
|
||||
final buttonLabel = translate("Continue with {${branding.label}}");
|
||||
final opLabel = {
|
||||
'github': 'GitHub',
|
||||
'gitlab': 'GitLab'
|
||||
}[op.toLowerCase()] ??
|
||||
toCapitalized(op);
|
||||
return Row(children: [
|
||||
Container(
|
||||
height: height,
|
||||
@@ -121,7 +95,7 @@ class ButtonOP extends StatelessWidget {
|
||||
SizedBox(
|
||||
width: 30,
|
||||
child: _IconOP(
|
||||
op: branding.iconKey,
|
||||
op: op,
|
||||
icon: icon,
|
||||
margin: EdgeInsets.only(right: 5),
|
||||
),
|
||||
@@ -129,7 +103,8 @@ class ButtonOP extends StatelessWidget {
|
||||
Expanded(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Center(child: Text(buttonLabel)),
|
||||
child: Center(
|
||||
child: Text(translate("Continue with {$opLabel}"))),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -13,81 +13,21 @@ 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;
|
||||
final VoidCallback? onPressed;
|
||||
Widget? trailingIcon;
|
||||
bool divider;
|
||||
final String? actionId;
|
||||
TTextMenu(
|
||||
{required this.child,
|
||||
required this.onPressed,
|
||||
this.trailingIcon,
|
||||
this.divider = false});
|
||||
this.divider = false,
|
||||
this.actionId});
|
||||
|
||||
Widget getChild() {
|
||||
if (trailingIcon != null) {
|
||||
@@ -149,179 +89,12 @@ 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
|
||||
@@ -371,60 +144,11 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
||||
v.add(TTextMenu(
|
||||
child: Text(translate('Send clipboard keystrokes')),
|
||||
onPressed: () async {
|
||||
Future<void> sendClipboardKeystrokes() async {
|
||||
ClipboardData? data = await Clipboard.getData(Clipboard.kTextPlain);
|
||||
if (data != null && data.text != null) {
|
||||
bind.sessionInputString(
|
||||
sessionId: sessionId, value: data.text ?? "");
|
||||
}
|
||||
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
|
||||
@@ -507,7 +231,8 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
||||
v.add(
|
||||
TTextMenu(
|
||||
child: Text('${translate("Insert Ctrl + Alt + Del")}'),
|
||||
onPressed: () => bind.sessionCtrlAltDel(sessionId: sessionId)),
|
||||
onPressed: () => bind.sessionCtrlAltDel(sessionId: sessionId),
|
||||
actionId: kShortcutActionSendCtrlAltDel),
|
||||
);
|
||||
}
|
||||
// restart
|
||||
@@ -528,7 +253,8 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
||||
v.add(
|
||||
TTextMenu(
|
||||
child: Text(translate('Insert Lock')),
|
||||
onPressed: () => bind.sessionLockScreen(sessionId: sessionId)),
|
||||
onPressed: () => bind.sessionLockScreen(sessionId: sessionId),
|
||||
actionId: kShortcutActionInsertLock),
|
||||
);
|
||||
}
|
||||
// blockUserInput
|
||||
@@ -546,7 +272,8 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
||||
sessionId: sessionId,
|
||||
value: '${blockInput.value ? 'un' : ''}block-input');
|
||||
blockInput.value = !blockInput.value;
|
||||
}));
|
||||
},
|
||||
actionId: kShortcutActionToggleBlockInput));
|
||||
}
|
||||
// switchSides
|
||||
if (isDefaultConn &&
|
||||
@@ -558,13 +285,15 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
||||
v.add(TTextMenu(
|
||||
child: Text(translate('Switch Sides')),
|
||||
onPressed: () =>
|
||||
showConfirmSwitchSidesDialog(sessionId, id, ffi.dialogManager)));
|
||||
showConfirmSwitchSidesDialog(sessionId, id, ffi.dialogManager),
|
||||
actionId: kShortcutActionSwitchSides));
|
||||
}
|
||||
// refresh
|
||||
if (pi.version.isNotEmpty) {
|
||||
v.add(TTextMenu(
|
||||
child: Text(translate('Refresh')),
|
||||
onPressed: () => sessionRefreshVideo(sessionId, pi),
|
||||
actionId: kShortcutActionRefresh,
|
||||
));
|
||||
}
|
||||
// record
|
||||
@@ -586,7 +315,8 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
||||
)
|
||||
],
|
||||
),
|
||||
onPressed: () => ffi.recordingModel.toggle()));
|
||||
onPressed: () => ffi.recordingModel.toggle(),
|
||||
actionId: kShortcutActionToggleRecording));
|
||||
}
|
||||
|
||||
// to-do:
|
||||
@@ -620,6 +350,7 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
||||
});
|
||||
}
|
||||
},
|
||||
actionId: kShortcutActionScreenshot,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -630,6 +361,13 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
||||
onPressed: () => onCopyFingerprint(FingerprintState.find(id).value),
|
||||
));
|
||||
}
|
||||
// Register tagged callbacks with the shortcut model so global keyboard
|
||||
// shortcuts can dispatch the same actions as the toolbar menu items.
|
||||
for (final menu in v) {
|
||||
if (menu.actionId != null && menu.onPressed != null) {
|
||||
ffi.shortcutModel.register(menu.actionId!, menu.onPressed!);
|
||||
}
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
@@ -962,9 +700,8 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
|
||||
child: Text(translate('Lock after session end'))));
|
||||
}
|
||||
|
||||
final privacyModeState = PrivacyModeState.find(id);
|
||||
if (pi.isSupportMultiDisplay &&
|
||||
(privacyModeState.isEmpty || allowDisplaySwitchInPrivacyMode(pi)) &&
|
||||
PrivacyModeState.find(id).isEmpty &&
|
||||
pi.displaysCount.value > 1 &&
|
||||
bind.mainGetUserDefaultOption(key: kKeyShowMonitorsToolbar) == 'Y') {
|
||||
final value =
|
||||
@@ -1038,26 +775,15 @@ 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 = !ffiModel.viewOnly &&
|
||||
(hasPrivacyModePermission || privacyModeState.isNotEmpty);
|
||||
final enabled = !ffi.ffiModel.viewOnly;
|
||||
return TToggleMenu(
|
||||
value: privacyModeState.isNotEmpty,
|
||||
onChanged: enabled
|
||||
? (value) {
|
||||
if (value == null) return;
|
||||
if (!allowDisplaySwitchInPrivacyMode(pi) &&
|
||||
ffiModel.pi.currentDisplay != 0 &&
|
||||
if (ffiModel.pi.currentDisplay != 0 &&
|
||||
ffiModel.pi.currentDisplay != kAllDisplayValue) {
|
||||
msgBox(
|
||||
sessionId,
|
||||
@@ -1100,29 +826,18 @@ List<TToggleMenu> toolbarPrivacyMode(
|
||||
})
|
||||
];
|
||||
} else {
|
||||
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) {
|
||||
return privacyModeImpls.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: enabled
|
||||
? (value) {
|
||||
if (value == null) return;
|
||||
if (value && !hasPrivacyModePermission) return;
|
||||
togglePrivacyModeTime = DateTime.now();
|
||||
bind.sessionTogglePrivacyMode(
|
||||
sessionId: sessionId, implKey: implKey, on: value);
|
||||
}
|
||||
: null);
|
||||
onChanged: (value) {
|
||||
if (value == null) return;
|
||||
togglePrivacyModeTime = DateTime.now();
|
||||
bind.sessionTogglePrivacyMode(
|
||||
sessionId: sessionId, implKey: implKey, on: value);
|
||||
});
|
||||
}).toList();
|
||||
}
|
||||
}
|
||||
|
||||
+21
-7
@@ -114,9 +114,6 @@ 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";
|
||||
@@ -142,10 +139,6 @@ 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";
|
||||
@@ -693,3 +686,24 @@ extension WindowsTargetExt on int {
|
||||
}
|
||||
|
||||
const kCheckSoftwareUpdateFinish = 'check_software_update_finish';
|
||||
|
||||
// Keyboard shortcut Action IDs - must match src/keyboard/shortcuts.rs::action_id.
|
||||
const kShortcutActionSendCtrlAltDel = 'send_ctrl_alt_del';
|
||||
const kShortcutActionToggleFullscreen = 'toggle_fullscreen';
|
||||
const kShortcutActionSwitchDisplayNext = 'switch_display_next';
|
||||
const kShortcutActionSwitchDisplayPrev = 'switch_display_prev';
|
||||
const kShortcutActionScreenshot = 'screenshot';
|
||||
const kShortcutActionInsertLock = 'insert_lock';
|
||||
const kShortcutActionRefresh = 'refresh';
|
||||
const kShortcutActionToggleAudio = 'toggle_audio';
|
||||
const kShortcutActionToggleBlockInput = 'toggle_block_input';
|
||||
const kShortcutActionToggleRecording = 'toggle_recording';
|
||||
const kShortcutActionTogglePrivacyMode = 'toggle_privacy_mode';
|
||||
const kShortcutActionViewMode1to1 = 'view_mode_1_to_1';
|
||||
const kShortcutActionViewModeShrink = 'view_mode_shrink';
|
||||
const kShortcutActionViewModeStretch = 'view_mode_stretch';
|
||||
const kShortcutActionSwitchSides = 'switch_sides';
|
||||
String kShortcutActionSwitchTab(int n) => 'switch_tab_$n';
|
||||
|
||||
const kShortcutLocalConfigKey = 'keyboard-shortcuts';
|
||||
const kShortcutEventName = 'shortcut_triggered';
|
||||
|
||||
@@ -398,7 +398,6 @@ class _ConnectionPageState extends State<ConnectionPage>
|
||||
.contains(textToFind) ||
|
||||
peer.alias.toLowerCase().contains(textToFind))
|
||||
.toList();
|
||||
_allPeersLoader.queryOnlines(_autocompleteOpts);
|
||||
}
|
||||
return _autocompleteOpts;
|
||||
},
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// flutter/lib/desktop/pages/desktop_keyboard_shortcuts_page.dart
|
||||
//
|
||||
// Desktop shell for the Keyboard Shortcuts configuration page. Users land
|
||||
// here from the General settings tab. The page exposes:
|
||||
// * A top-level enable/disable toggle (mirrors the General-tab toggle —
|
||||
// same JSON key, same semantics).
|
||||
// * A grouped, scrollable list of actions, each with a current binding and
|
||||
// edit / clear icons.
|
||||
// * An AppBar "Reset to defaults" action with a confirmation dialog.
|
||||
//
|
||||
// All edits write back to LocalConfig under [kShortcutLocalConfigKey] in the
|
||||
// canonical {enabled, bindings:[{action,mods,key}]} shape that the Rust and
|
||||
// Web matchers consume.
|
||||
//
|
||||
// The body — group definitions, JSON I/O, conflict-replace flow,
|
||||
// recording-dialog round-trip — lives in
|
||||
// `common/widgets/keyboard_shortcuts/page_body.dart` and is shared with the
|
||||
// mobile shell at `mobile/pages/mobile_keyboard_shortcuts_page.dart`.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import '../../common.dart';
|
||||
import '../../common/widgets/keyboard_shortcuts/page_body.dart';
|
||||
|
||||
class DesktopKeyboardShortcutsPage extends StatefulWidget {
|
||||
const DesktopKeyboardShortcutsPage({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<DesktopKeyboardShortcutsPage> createState() =>
|
||||
_DesktopKeyboardShortcutsPageState();
|
||||
}
|
||||
|
||||
class _DesktopKeyboardShortcutsPageState
|
||||
extends State<DesktopKeyboardShortcutsPage> {
|
||||
final GlobalKey<KeyboardShortcutsPageBodyState> _bodyKey = GlobalKey();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(translate('Keyboard Shortcuts')),
|
||||
actions: [
|
||||
TextButton.icon(
|
||||
onPressed: () =>
|
||||
_bodyKey.currentState?.resetToDefaultsWithConfirm(),
|
||||
icon: const Icon(Icons.restore),
|
||||
label: Text(translate('Reset to defaults')),
|
||||
).marginOnly(right: 12),
|
||||
],
|
||||
),
|
||||
body: KeyboardShortcutsPageBody(
|
||||
key: _bodyKey,
|
||||
compact: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,12 +10,14 @@ import 'package:flutter_hbb/common/widgets/audio_input.dart';
|
||||
import 'package:flutter_hbb/common/widgets/setting_widgets.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/desktop/pages/desktop_home_page.dart';
|
||||
import 'package:flutter_hbb/desktop/pages/desktop_keyboard_shortcuts_page.dart';
|
||||
import 'package:flutter_hbb/desktop/pages/desktop_tab_page.dart';
|
||||
import 'package:flutter_hbb/desktop/widgets/remote_toolbar.dart';
|
||||
import 'package:flutter_hbb/mobile/widgets/dialog.dart';
|
||||
import 'package:flutter_hbb/models/platform_model.dart';
|
||||
import 'package:flutter_hbb/models/printer_model.dart';
|
||||
import 'package:flutter_hbb/models/server_model.dart';
|
||||
import 'package:flutter_hbb/models/shortcut_model.dart';
|
||||
import 'package:flutter_hbb/models/state_model.dart';
|
||||
import 'package:flutter_hbb/plugin/manager.dart';
|
||||
import 'package:flutter_hbb/plugin/widgets/desktop_settings.dart';
|
||||
@@ -421,11 +423,57 @@ class _GeneralState extends State<_General> {
|
||||
if (!isWeb) audio(context),
|
||||
if (!isWeb) record(context),
|
||||
if (!isWeb) WaylandCard(),
|
||||
other()
|
||||
other(),
|
||||
if (!bind.isIncomingOnly()) keyboardShortcuts(),
|
||||
],
|
||||
).marginOnly(bottom: _kListViewBottomMargin);
|
||||
}
|
||||
|
||||
Widget keyboardShortcuts() {
|
||||
// The bindings JSON (LocalConfig key `keyboard-shortcuts`) is the single
|
||||
// source of truth — it embeds an `enabled` boolean alongside the bindings
|
||||
// list. We mutate the JSON in place via _OptionCheckBox's optGetter /
|
||||
// optSetter hooks rather than introducing a parallel boolean key, so the
|
||||
// Rust matcher and the Web matcher both read the same flag without drift.
|
||||
return _Card(title: 'Keyboard Shortcuts', children: [
|
||||
_OptionCheckBox(
|
||||
context,
|
||||
'Enable keyboard shortcuts in remote session',
|
||||
kShortcutLocalConfigKey,
|
||||
isServer: false,
|
||||
optGetter: ShortcutModel.isEnabled,
|
||||
optSetter: (k, v) async {
|
||||
final raw = bind.mainGetLocalOption(key: k);
|
||||
Map<String, dynamic> parsed = {};
|
||||
if (raw.isNotEmpty) {
|
||||
try {
|
||||
parsed = jsonDecode(raw) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
parsed = {};
|
||||
}
|
||||
}
|
||||
parsed['enabled'] = v;
|
||||
parsed['bindings'] ??= <dynamic>[];
|
||||
// Seed defaults the first time the user enables shortcuts so the
|
||||
// common combos (Ctrl+Alt+Shift+Enter for fullscreen, etc.) work
|
||||
// out of the box. Mirrors the same logic on the dedicated config
|
||||
// page.
|
||||
final list = (parsed['bindings'] as List?) ?? const [];
|
||||
if (v && list.isEmpty) {
|
||||
parsed['bindings'] =
|
||||
jsonDecode(bind.mainGetDefaultKeyboardShortcuts());
|
||||
}
|
||||
await bind.mainSetLocalOption(key: k, value: jsonEncode(parsed));
|
||||
// Refresh the matcher cache so the new flag / bindings take effect
|
||||
// immediately. On native this hits the Rust matcher; on Web the
|
||||
// bridge forwards to the JS-side matcher in flutter/web/js/.
|
||||
bind.mainReloadKeyboardShortcuts();
|
||||
},
|
||||
),
|
||||
_ShortcutsConfigureRow(),
|
||||
]);
|
||||
}
|
||||
|
||||
Widget theme() {
|
||||
final current = MyTheme.getThemeModePreference().toShortString();
|
||||
onChanged(String value) async {
|
||||
@@ -488,16 +536,6 @@ 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()) ...[
|
||||
@@ -1072,10 +1110,6 @@ 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),
|
||||
@@ -2960,6 +2994,37 @@ class _CountDownButtonState extends State<_CountDownButton> {
|
||||
}
|
||||
}
|
||||
|
||||
// Tappable row that pushes the shortcut configuration page.
|
||||
class _ShortcutsConfigureRow extends StatelessWidget {
|
||||
// ignore: unused_element
|
||||
const _ShortcutsConfigureRow({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => const DesktopKeyboardShortcutsPage(),
|
||||
));
|
||||
},
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(translate('Configure shortcuts...')),
|
||||
),
|
||||
Icon(Icons.arrow_forward_ios,
|
||||
size: 16, color: disabledTextColor(context, true))
|
||||
.marginOnly(right: 4),
|
||||
],
|
||||
).marginOnly(
|
||||
left: _kCheckBoxLeftMargin,
|
||||
top: 6,
|
||||
bottom: 6,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region dialogs
|
||||
|
||||
@@ -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 = false.obs;
|
||||
final RxBool printer = true.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'] == '1';
|
||||
printer.value = installOptions['PRINTER'] != '0';
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -17,6 +17,7 @@ import '../../common/widgets/toolbar.dart';
|
||||
import '../../models/model.dart';
|
||||
import '../../models/input_model.dart';
|
||||
import '../../models/platform_model.dart';
|
||||
import '../../models/shortcut_model.dart';
|
||||
import '../../common/shared_state.dart';
|
||||
import '../../utils/image.dart';
|
||||
import '../widgets/remote_toolbar.dart';
|
||||
@@ -101,9 +102,6 @@ class _RemotePageState extends State<RemotePage>
|
||||
Function(bool)? _onEnterOrLeaveImage4Toolbar;
|
||||
|
||||
late FFI _ffi;
|
||||
Worker? _waylandKeyboardModeWorker;
|
||||
bool _waylandKeyboardModeNormalized = false;
|
||||
bool _waylandKeyboardModeNormalizing = false;
|
||||
|
||||
SessionID get sessionId => _ffi.sessionId;
|
||||
|
||||
@@ -129,6 +127,19 @@ class _RemotePageState extends State<RemotePage>
|
||||
_ffi.ffiModel.pi.platform, _ffi.dialogManager);
|
||||
_ffi.recordingModel
|
||||
.updateStatus(bind.sessionGetIsRecording(sessionId: _ffi.sessionId));
|
||||
// Seed shortcut action callbacks once the session is ready, so that
|
||||
// global keyboard shortcuts work even if the user never opens the
|
||||
// toolbar menu. The returned list is intentionally discarded — the
|
||||
// side effect of registering callbacks (inside toolbarControls) is
|
||||
// what we want here.
|
||||
if (mounted) {
|
||||
toolbarControls(context, widget.id, _ffi);
|
||||
// Register the default-bound actions that `toolbarControls` doesn't
|
||||
// own (fullscreen, switch display, switch tab). Done in addition,
|
||||
// not instead of, the toolbar registration above.
|
||||
registerSessionShortcutActions(_ffi,
|
||||
tabController: widget.tabController);
|
||||
}
|
||||
});
|
||||
_ffi.canvasModel.initializeEdgeScrollFallback(this);
|
||||
_ffi.start(
|
||||
@@ -181,48 +192,6 @@ 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
|
||||
@@ -363,7 +332,6 @@ 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: ...).
|
||||
@@ -377,9 +345,6 @@ 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();
|
||||
|
||||
@@ -610,24 +610,19 @@ 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,
|
||||
{required bool canModify}) {
|
||||
Function(bool)? onTap, String tooltipText) {
|
||||
return Tooltip(
|
||||
message: "$tooltipText: ${enabled ? "ON" : "OFF"}",
|
||||
waitDuration: Duration.zero,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: enabled
|
||||
? (canModify ? MyTheme.accent : MyTheme.accent.withOpacity(0.6))
|
||||
: Colors.grey[700],
|
||||
color: enabled ? MyTheme.accent : Colors.grey[700],
|
||||
borderRadius: BorderRadius.circular(10.0),
|
||||
),
|
||||
padding: EdgeInsets.all(8.0),
|
||||
child: InkWell(
|
||||
onTap: canModify
|
||||
? () =>
|
||||
checkClickTime(widget.client.id, () => onTap?.call(!enabled))
|
||||
: null,
|
||||
onTap: () =>
|
||||
checkClickTime(widget.client.id, () => onTap?.call(!enabled)),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
@@ -648,9 +643,6 @@ 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,
|
||||
@@ -697,7 +689,6 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
||||
});
|
||||
},
|
||||
translate('Enable audio'),
|
||||
canModify: canModifyPermission,
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.recording,
|
||||
@@ -712,7 +703,6 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
||||
});
|
||||
},
|
||||
translate('Enable recording session'),
|
||||
canModify: canModifyPermission,
|
||||
),
|
||||
]
|
||||
: [
|
||||
@@ -729,7 +719,6 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
||||
});
|
||||
},
|
||||
translate('Enable keyboard/mouse'),
|
||||
canModify: canModifyPermission,
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.clipboard,
|
||||
@@ -744,7 +733,6 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
||||
});
|
||||
},
|
||||
translate('Enable clipboard'),
|
||||
canModify: canModifyPermission,
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.audio,
|
||||
@@ -759,7 +747,6 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
||||
});
|
||||
},
|
||||
translate('Enable audio'),
|
||||
canModify: canModifyPermission,
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.file,
|
||||
@@ -774,7 +761,6 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
||||
});
|
||||
},
|
||||
translate('Enable file copy and paste'),
|
||||
canModify: canModifyPermission,
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.restart,
|
||||
@@ -789,7 +775,6 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
||||
});
|
||||
},
|
||||
translate('Enable remote restart'),
|
||||
canModify: canModifyPermission,
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.recording,
|
||||
@@ -804,7 +789,6 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
||||
});
|
||||
},
|
||||
translate('Enable recording session'),
|
||||
canModify: canModifyPermission,
|
||||
),
|
||||
// only windows support block input
|
||||
if (isWindows)
|
||||
@@ -821,23 +805,6 @@ 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,
|
||||
)
|
||||
],
|
||||
),
|
||||
|
||||
@@ -27,7 +27,6 @@ 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);
|
||||
@@ -44,9 +43,6 @@ 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;
|
||||
@@ -159,27 +155,13 @@ 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) {
|
||||
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;
|
||||
if (_cellHeight == null) {
|
||||
return const EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0);
|
||||
}
|
||||
final rows = (heightPx / _cellHeight!).floor();
|
||||
final extraSpace = heightPx - rows * _cellHeight!;
|
||||
final topBottom = extraSpace / 2.0;
|
||||
return EdgeInsets.symmetric(
|
||||
horizontal: _defaultTerminalPadding.horizontal / 2,
|
||||
vertical: topBottom,
|
||||
);
|
||||
return EdgeInsets.symmetric(horizontal: 5.0, vertical: topBottom);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -46,7 +46,6 @@ 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'],
|
||||
@@ -145,8 +144,6 @@ 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.
|
||||
@@ -371,34 +368,8 @@ 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) {
|
||||
if (!existingTerminalIds.add(terminalId)) {
|
||||
continue;
|
||||
}
|
||||
_addNewTerminal(peerId, terminalId: terminalId);
|
||||
_addNewTerminalForCurrentPeer(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.
|
||||
@@ -575,11 +546,6 @@ 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
@@ -99,7 +99,6 @@ class DesktopTabController {
|
||||
/// index, key
|
||||
Function(int, String)? onRemoved;
|
||||
Function(String)? onSelected;
|
||||
Future<void> Function()? onCloseWindow;
|
||||
|
||||
DesktopTabController(
|
||||
{required this.tabType, this.onRemoved, this.onSelected});
|
||||
@@ -593,13 +592,13 @@ class _DesktopTabState extends State<DesktopTab>
|
||||
}
|
||||
|
||||
Widget _buildBar() {
|
||||
final isIncomingHomePage = bind.isIncomingOnly() && isInHomePage();
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
// custom double tap handler
|
||||
onTap: !isIncomingHomePage && showMaximize
|
||||
onTap: !(bind.isIncomingOnly() && isInHomePage()) &&
|
||||
showMaximize
|
||||
? () {
|
||||
final current = DateTime.now().millisecondsSinceEpoch;
|
||||
final elapsed = current - _lastClickTime;
|
||||
@@ -610,7 +609,7 @@ class _DesktopTabState extends State<DesktopTab>
|
||||
.then((value) => stateGlobal.setMaximized(value));
|
||||
}
|
||||
}
|
||||
: (isIncomingHomePage ? () {} : null), // Keep tap recognizer for Windows touch.
|
||||
: null,
|
||||
onPanStart: (_) => startDragging(isMainWindow),
|
||||
onPanCancel: () {
|
||||
// We want to disable dragging of the tab area in the tab bar.
|
||||
|
||||
@@ -27,7 +27,6 @@ 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'
|
||||
@@ -576,14 +575,6 @@ _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) {
|
||||
|
||||
@@ -207,7 +207,6 @@ class _ConnectionPageState extends State<ConnectionPage> {
|
||||
.contains(textToFind) ||
|
||||
peer.alias.toLowerCase().contains(textToFind))
|
||||
.toList();
|
||||
_allPeersLoader.queryOnlines(_autocompleteOpts);
|
||||
}
|
||||
return _autocompleteOpts;
|
||||
},
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// flutter/lib/mobile/pages/mobile_keyboard_shortcuts_page.dart
|
||||
//
|
||||
// Mobile shell for the Keyboard Shortcuts configuration page. Mirrors
|
||||
// `desktop/pages/desktop_keyboard_shortcuts_page.dart` but with a touch-
|
||||
// friendly layout (ListTile rows instead of dense rows) and a hint banner
|
||||
// that explains the recording flow only works with a physical keyboard.
|
||||
//
|
||||
// All actual logic — group definitions, JSON I/O, conflict-replace flow,
|
||||
// recording-dialog round-trip, "Reset to defaults" — lives in the shared
|
||||
// `common/widgets/keyboard_shortcuts/page_body.dart`. This file only
|
||||
// supplies the AppBar, the AppBar action, and the platform hint banner.
|
||||
//
|
||||
// Mobile keyboard detection limitation: Flutter has no reliable
|
||||
// "is a physical keyboard attached?" API on iOS or Android. Soft keyboards
|
||||
// don't generate the `KeyDownEvent`s the recording dialog listens for, so
|
||||
// in practice the dialog only does anything useful when the user actually
|
||||
// has a hardware keyboard plugged in (USB / Bluetooth / Smart Connector).
|
||||
// For V1 we don't try to detect attachment — we just surface the
|
||||
// requirement as an in-page hint instead of disabling the Edit button.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../common.dart';
|
||||
import '../../common/widgets/keyboard_shortcuts/page_body.dart';
|
||||
|
||||
class MobileKeyboardShortcutsPage extends StatefulWidget {
|
||||
const MobileKeyboardShortcutsPage({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<MobileKeyboardShortcutsPage> createState() =>
|
||||
_MobileKeyboardShortcutsPageState();
|
||||
}
|
||||
|
||||
class _MobileKeyboardShortcutsPageState
|
||||
extends State<MobileKeyboardShortcutsPage> {
|
||||
final GlobalKey<KeyboardShortcutsPageBodyState> _bodyKey = GlobalKey();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(translate('Keyboard Shortcuts')),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: translate('Reset to defaults'),
|
||||
onPressed: () =>
|
||||
_bodyKey.currentState?.resetToDefaultsWithConfirm(),
|
||||
icon: const Icon(Icons.restore),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: KeyboardShortcutsPageBody(
|
||||
key: _bodyKey,
|
||||
compact: false,
|
||||
editButtonHint: translate('shortcut-mobile-physical-keyboard-tip'),
|
||||
headerBanner: _PhysicalKeyboardHintBanner(theme: theme),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A muted info banner shown above the master toggle on mobile. We can't
|
||||
/// reliably detect whether a physical keyboard is attached, so instead of
|
||||
/// disabling the Edit button we surface the requirement up front.
|
||||
class _PhysicalKeyboardHintBanner extends StatelessWidget {
|
||||
final ThemeData theme;
|
||||
const _PhysicalKeyboardHintBanner({required this.theme});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = theme.colorScheme.primary.withOpacity(0.08);
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.info_outline,
|
||||
size: 18, color: theme.colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
translate('shortcut-mobile-physical-keyboard-tip'),
|
||||
style: TextStyle(color: theme.colorScheme.onSurface),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import '../../common/widgets/remote_input.dart';
|
||||
import '../../models/input_model.dart';
|
||||
import '../../models/model.dart';
|
||||
import '../../models/platform_model.dart';
|
||||
import '../../models/shortcut_model.dart';
|
||||
import '../../utils/image.dart';
|
||||
import '../widgets/dialog.dart';
|
||||
import '../widgets/custom_scale_widget.dart';
|
||||
@@ -75,9 +76,6 @@ 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;
|
||||
|
||||
@@ -122,35 +120,25 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
||||
}
|
||||
_disableAndroidSoftKeyboard(
|
||||
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();
|
||||
// Seed shortcut action callbacks once the session is ready, so that
|
||||
// global keyboard shortcuts work even if the user never opens the
|
||||
// toolbar menu. The returned list is intentionally discarded — the
|
||||
// side effect of registering callbacks (inside toolbarControls) is
|
||||
// what we want here.
|
||||
if (mounted) {
|
||||
toolbarControls(context, widget.id, gFFI);
|
||||
// Mobile has no DesktopTabController, so tab-switch shortcuts
|
||||
// remain unregistered (they will simply log a no-handler debug
|
||||
// line if a mobile user binds one — they have no tabs to switch).
|
||||
registerSessionShortcutActions(gFFI);
|
||||
}
|
||||
});
|
||||
if (gFFI.ffiModel.pi.isSet.value) {
|
||||
_initWaylandKeyboardGateIfNeeded();
|
||||
}
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
}
|
||||
|
||||
@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);
|
||||
@@ -160,9 +148,6 @@ 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();
|
||||
@@ -191,40 +176,6 @@ 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.
|
||||
@@ -356,7 +307,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);
|
||||
_openKeyboardUnlocked();
|
||||
openKeyboard();
|
||||
return;
|
||||
}
|
||||
bind.sessionInputString(sessionId: sessionId, value: content);
|
||||
@@ -368,9 +319,6 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
||||
|
||||
// handle mobile virtual keyboard
|
||||
void handleSoftKeyboardInput(String newValue) {
|
||||
if (!inputModel.keyboardInputAllowed) {
|
||||
return;
|
||||
}
|
||||
if (isIOS) {
|
||||
_handleIOSSoftKeyboardInput(newValue);
|
||||
} else {
|
||||
@@ -379,9 +327,6 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
||||
}
|
||||
|
||||
void inputChar(String char) {
|
||||
if (!inputModel.keyboardInputAllowed) {
|
||||
return;
|
||||
}
|
||||
if (char == '\n') {
|
||||
char = 'VK_RETURN';
|
||||
} else if (char == ' ') {
|
||||
@@ -391,29 +336,6 @@ 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;
|
||||
@@ -517,12 +439,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,
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
@@ -1276,8 +1196,7 @@ void showOptions(
|
||||
List<TToggleMenu> privacyModeList = [];
|
||||
// privacy mode
|
||||
final privacyModeState = PrivacyModeState.find(id);
|
||||
if ((gFFI.ffiModel.pi.features.privacyMode && gFFI.ffiModel.keyboard) ||
|
||||
privacyModeState.isNotEmpty) {
|
||||
if (gFFI.ffiModel.keyboard && gFFI.ffiModel.pi.features.privacyMode) {
|
||||
privacyModeList = toolbarPrivacyMode(privacyModeState, context, id, gFFI);
|
||||
if (privacyModeList.length == 1) {
|
||||
displayToggles.add(privacyModeList[0]);
|
||||
|
||||
@@ -583,16 +583,9 @@ 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 allowPermChangeInAcceptWindow = option2bool(
|
||||
kOptionEnablePermChangeInAcceptWindow,
|
||||
bind.mainGetBuildinOption(
|
||||
key: kOptionEnablePermChangeInAcceptWindow,
|
||||
));
|
||||
final permissionChangeLocked = isAndroid &&
|
||||
serverModel.clients.any((c) => !c.disconnected) &&
|
||||
!allowPermChangeInAcceptWindow;
|
||||
final hideStopService =
|
||||
isAndroid &&
|
||||
bind.mainGetBuildinOption(key: kOptionHideStopService) == 'Y';
|
||||
return PaddingCard(
|
||||
title: translate("Permissions"),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
@@ -615,21 +608,13 @@ 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,
|
||||
enabled: !permissionChangeLocked,
|
||||
),
|
||||
PermissionRow(translate("Input Control"), serverModel.inputOk,
|
||||
serverModel.toggleInput),
|
||||
PermissionRow(translate("Transfer file"), serverModel.fileOk,
|
||||
serverModel.toggleFile),
|
||||
hasAudioPermission
|
||||
? PermissionRow(translate("Audio Capture"), serverModel.audioOk,
|
||||
serverModel.toggleAudio,
|
||||
enabled: !permissionChangeLocked)
|
||||
serverModel.toggleAudio)
|
||||
: Row(children: [
|
||||
Icon(Icons.info_outline).marginOnly(right: 15),
|
||||
Expanded(
|
||||
@@ -638,25 +623,19 @@ class _PermissionCheckerState extends State<PermissionChecker> {
|
||||
style: const TextStyle(color: MyTheme.darkGray),
|
||||
))
|
||||
]),
|
||||
PermissionRow(
|
||||
translate("Enable clipboard"),
|
||||
serverModel.clipboardOk,
|
||||
serverModel.toggleClipboard,
|
||||
enabled: !permissionChangeLocked,
|
||||
),
|
||||
PermissionRow(translate("Enable clipboard"), serverModel.clipboardOk,
|
||||
serverModel.toggleClipboard),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
class PermissionRow extends StatelessWidget {
|
||||
const PermissionRow(this.name, this.isOk, this.onPressed,
|
||||
{Key? key, this.enabled = true})
|
||||
const PermissionRow(this.name, this.isOk, this.onPressed, {Key? key})
|
||||
: super(key: key);
|
||||
|
||||
final String name;
|
||||
final bool isOk;
|
||||
final VoidCallback onPressed;
|
||||
final bool enabled;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -665,11 +644,9 @@ class PermissionRow extends StatelessWidget {
|
||||
contentPadding: EdgeInsets.all(0),
|
||||
title: Text(name),
|
||||
value: isOk,
|
||||
onChanged: enabled
|
||||
? (bool value) {
|
||||
onPressed();
|
||||
}
|
||||
: null);
|
||||
onChanged: (bool value) {
|
||||
onPressed();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,9 +17,10 @@ import '../../common/widgets/login.dart';
|
||||
import '../../consts.dart';
|
||||
import '../../models/model.dart';
|
||||
import '../../models/platform_model.dart';
|
||||
import '../widgets/deploy_dialog.dart';
|
||||
import '../../models/shortcut_model.dart';
|
||||
import '../widgets/dialog.dart';
|
||||
import 'home_page.dart';
|
||||
import 'mobile_keyboard_shortcuts_page.dart';
|
||||
import 'scan_page.dart';
|
||||
|
||||
class SettingsPage extends StatefulWidget implements PageShape {
|
||||
@@ -729,13 +730,6 @@ 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')),
|
||||
@@ -827,6 +821,22 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
||||
showThemeSettings(gFFI.dialogManager);
|
||||
},
|
||||
),
|
||||
SettingsTile.navigation(
|
||||
leading: Icon(Icons.keyboard_outlined),
|
||||
title: Text(translate('Keyboard Shortcuts')),
|
||||
description: Text(ShortcutModel.isEnabled()
|
||||
? translate('On')
|
||||
: translate('Off')),
|
||||
onPressed: (context) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const MobileKeyboardShortcutsPage(),
|
||||
)).then((_) {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
},
|
||||
),
|
||||
if (!bind.isDisableAccount())
|
||||
SettingsTile.switchTile(
|
||||
title: Text(translate('note-at-conn-end-tip')),
|
||||
@@ -1360,3 +1370,4 @@ SettingsTile _getPopupDialogRadioEntry({
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -117,13 +117,13 @@ void showServerSettingsWithValue(
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: serverSettingsTextFormField(
|
||||
label: label,
|
||||
child: TextFormField(
|
||||
controller: controller,
|
||||
errorMsg: errorMsg,
|
||||
contentPadding:
|
||||
EdgeInsets.symmetric(horizontal: 8, vertical: 12),
|
||||
showLabelText: false,
|
||||
decoration: InputDecoration(
|
||||
errorText: errorMsg.isEmpty ? null : errorMsg,
|
||||
contentPadding:
|
||||
EdgeInsets.symmetric(horizontal: 8, vertical: 12),
|
||||
),
|
||||
validator: validator,
|
||||
autofocus: autofocus,
|
||||
).workaroundFreezeLinuxMint(),
|
||||
@@ -132,10 +132,12 @@ void showServerSettingsWithValue(
|
||||
);
|
||||
}
|
||||
|
||||
return serverSettingsTextFormField(
|
||||
label: label,
|
||||
return TextFormField(
|
||||
controller: controller,
|
||||
errorMsg: errorMsg,
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
errorText: errorMsg.isEmpty ? null : errorMsg,
|
||||
),
|
||||
validator: validator,
|
||||
).workaroundFreezeLinuxMint();
|
||||
}
|
||||
@@ -207,35 +209,6 @@ void showServerSettingsWithValue(
|
||||
});
|
||||
}
|
||||
|
||||
TextFormField serverSettingsTextFormField({
|
||||
required String label,
|
||||
required TextEditingController controller,
|
||||
required String errorMsg,
|
||||
String? Function(String?)? validator,
|
||||
bool autofocus = false,
|
||||
bool showLabelText = true,
|
||||
EdgeInsetsGeometry? contentPadding,
|
||||
}) {
|
||||
return TextFormField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
labelText: showLabelText ? label : null,
|
||||
errorText: errorMsg.isEmpty ? null : errorMsg,
|
||||
contentPadding: contentPadding,
|
||||
),
|
||||
validator: validator,
|
||||
autofocus: autofocus,
|
||||
keyboardType: TextInputType.visiblePassword,
|
||||
textCapitalization: TextCapitalization.none,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
smartDashesType: SmartDashesType.disabled,
|
||||
smartQuotesType: SmartQuotesType.disabled,
|
||||
enableIMEPersonalizedLearning: false,
|
||||
spellCheckConfiguration: const SpellCheckConfiguration.disabled(),
|
||||
);
|
||||
}
|
||||
|
||||
void setPrivacyModeDialog(
|
||||
OverlayDialogManager dialogManager,
|
||||
List<TToggleMenu> privacyModeList,
|
||||
|
||||
@@ -391,30 +391,14 @@ class FileController {
|
||||
|
||||
await Future.delayed(Duration(milliseconds: 100));
|
||||
|
||||
final savedDir = (await bind.sessionGetPeerOption(
|
||||
final dir = (await bind.sessionGetPeerOption(
|
||||
sessionId: sessionId, name: isLocal ? "local_dir" : "remote_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();
|
||||
openDirectory(dir.isEmpty ? options.value.home : dir);
|
||||
|
||||
await Future.delayed(Duration(seconds: 1));
|
||||
|
||||
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();
|
||||
if (directory.value.path.isEmpty) {
|
||||
openDirectory(options.value.home);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -445,23 +429,19 @@ class FileController {
|
||||
});
|
||||
}
|
||||
|
||||
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> refresh() async {
|
||||
await openDirectory(directory.value.path);
|
||||
}
|
||||
|
||||
Future<bool> openDirectory(String path, {bool isBack = false}) async {
|
||||
if (!isBack && path == ".") {
|
||||
return await refresh();
|
||||
Future<void> openDirectory(String path, {bool isBack = false}) async {
|
||||
if (path == ".") {
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
if (!isBack && path == "..") {
|
||||
return await _goToParentDirectory(isBack: isBack);
|
||||
if (path == "..") {
|
||||
goToParentDirectory();
|
||||
return;
|
||||
}
|
||||
return await _openDirectoryPath(path, isBack: isBack);
|
||||
}
|
||||
|
||||
Future<bool> _openDirectoryPath(String path, {bool isBack = false}) async {
|
||||
if (!isBack) {
|
||||
pushHistory();
|
||||
}
|
||||
@@ -478,10 +458,8 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -509,22 +487,19 @@ class FileController {
|
||||
goBack();
|
||||
return;
|
||||
}
|
||||
unawaited(_openDirectoryPath(path, isBack: true).then<void>((_) {}));
|
||||
openDirectory(path, isBack: true);
|
||||
}
|
||||
|
||||
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) {
|
||||
return await _openDirectoryPath('/', isBack: isBack);
|
||||
openDirectory('/');
|
||||
return;
|
||||
}
|
||||
return await _openDirectoryPath(parent, isBack: isBack);
|
||||
openDirectory(parent);
|
||||
}
|
||||
|
||||
// TODO deprecated this
|
||||
|
||||
@@ -346,7 +346,7 @@ class InputModel {
|
||||
/// 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 (!Platform.isLinux) return;
|
||||
if (_sideButtonChannelInitialized) return;
|
||||
_sideButtonChannelInitialized = true;
|
||||
|
||||
@@ -474,10 +474,6 @@ 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;
|
||||
@@ -703,6 +699,7 @@ class InputModel {
|
||||
}
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
// 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() {
|
||||
@@ -830,6 +827,7 @@ class InputModel {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
}
|
||||
|
||||
if (isWindows || isLinux) {
|
||||
// Ignore meta keys. Because flutter window will loose focus if meta key is pressed.
|
||||
if (e.physicalKey == PhysicalKeyboardKey.metaLeft ||
|
||||
|
||||
@@ -21,6 +21,7 @@ import 'package:flutter_hbb/models/peer_model.dart';
|
||||
import 'package:flutter_hbb/models/peer_tab_model.dart';
|
||||
import 'package:flutter_hbb/models/printer_model.dart';
|
||||
import 'package:flutter_hbb/models/server_model.dart';
|
||||
import 'package:flutter_hbb/models/shortcut_model.dart';
|
||||
import 'package:flutter_hbb/models/user_model.dart';
|
||||
import 'package:flutter_hbb/models/state_model.dart';
|
||||
import 'package:flutter_hbb/models/desktop_render_texture.dart';
|
||||
@@ -55,8 +56,6 @@ import 'package:flutter_hbb/native/custom_cursor.dart'
|
||||
typedef HandleMsgBox = Function(Map<String, dynamic> evt, String id);
|
||||
typedef ReconnectHandle = Function(OverlayDialogManager, SessionID, bool);
|
||||
final _constSessionId = Uuid().v4obj();
|
||||
// Empirical restart reconnect cadence: keep the last frame briefly and retry quickly.
|
||||
const _restartReconnectSilentDelaySecs = 5;
|
||||
|
||||
class CachedPeerData {
|
||||
Map<String, dynamic> updatePrivacyMode = {};
|
||||
@@ -121,7 +120,6 @@ class FfiModel with ChangeNotifier {
|
||||
bool _touchMode = false;
|
||||
late VirtualMouseMode virtualMouseMode;
|
||||
Timer? _timer;
|
||||
Timer? _restartReconnectDelayTimer;
|
||||
var _reconnects = 1;
|
||||
DateTime? _offlineReconnectStartTime;
|
||||
bool _viewOnly = false;
|
||||
@@ -253,7 +251,6 @@ class FfiModel with ChangeNotifier {
|
||||
_inputBlocked = false;
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
resetRestartReconnectState();
|
||||
clearPermissions();
|
||||
waitForImageTimer?.cancel();
|
||||
timerScreenshot?.cancel();
|
||||
@@ -345,7 +342,6 @@ class FfiModel with ChangeNotifier {
|
||||
} else if (name == 'connection_ready') {
|
||||
setConnectionType(peerId, evt['secure'] == 'true',
|
||||
evt['direct'] == 'true', evt['stream_type'] ?? '');
|
||||
resetRestartReconnectState();
|
||||
} else if (name == 'switch_display') {
|
||||
// switch display is kept for backward compatibility
|
||||
handleSwitchDisplay(evt, sessionId, peerId);
|
||||
@@ -481,6 +477,11 @@ class FfiModel with ChangeNotifier {
|
||||
} else if (name == 'exit_relative_mouse_mode') {
|
||||
// Handle exit shortcut from rdev grab loop (Ctrl+Alt on Win/Linux, Cmd+G on macOS)
|
||||
parent.target?.inputModel.exitRelativeMouseModeWithKeyRelease();
|
||||
} else if (name == kShortcutEventName) {
|
||||
final action = evt['action'];
|
||||
if (action is String) {
|
||||
parent.target?.shortcutModel.onTriggered(action);
|
||||
}
|
||||
} else {
|
||||
debugPrint('Event is not handled in the fixed branch: $name');
|
||||
}
|
||||
@@ -927,28 +928,8 @@ class FfiModel with ChangeNotifier {
|
||||
enterUserLoginAndPasswordDialog(
|
||||
sessionId, dialogManager, 'terminal-admin-login-tip', false);
|
||||
} else if (type == 'restarting') {
|
||||
// Treat restart messages as reconnect control events. Rust still sends
|
||||
// title/text for legacy UI and translation reuse; Flutter keeps the last
|
||||
// frame briefly, then shows the Connecting overlay.
|
||||
if (_restartReconnectDelayTimer == null) {
|
||||
parent.target?.inputModel.setRelativeMouseMode(false);
|
||||
bind.sessionReconnect(sessionId: sessionId, forceRelay: false);
|
||||
clearPermissions();
|
||||
// Retry once more after the silent window so restart reconnect attempts
|
||||
// are spaced by the empirical short cadence instead of only updating UI.
|
||||
_restartReconnectDelayTimer =
|
||||
Timer(Duration(seconds: _restartReconnectSilentDelaySecs), () {
|
||||
_restartReconnectDelayTimer = null;
|
||||
if (parent.target?.closed == true) {
|
||||
return;
|
||||
}
|
||||
reconnect(dialogManager, sessionId, false);
|
||||
});
|
||||
}
|
||||
} else if (type == 'restarting-show') {
|
||||
_restartReconnectDelayTimer?.cancel();
|
||||
_restartReconnectDelayTimer = null;
|
||||
reconnect(dialogManager, sessionId, false);
|
||||
showMsgBox(sessionId, type, title, text, link, false, dialogManager,
|
||||
hasCancel: false);
|
||||
} else if (type == 'wait-remote-accept-nook') {
|
||||
showWaitAcceptDialog(sessionId, type, title, text, dialogManager);
|
||||
} else if (type == 'on-uac' || type == 'on-foreground-elevated') {
|
||||
@@ -974,11 +955,6 @@ class FfiModel with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
void resetRestartReconnectState() {
|
||||
_restartReconnectDelayTimer?.cancel();
|
||||
_restartReconnectDelayTimer = null;
|
||||
}
|
||||
|
||||
/// Auto-retry check for "Remote desktop is offline" error.
|
||||
/// returns true to auto-retry, false otherwise.
|
||||
bool shouldAutoRetryOnOffline(
|
||||
@@ -1404,7 +1380,6 @@ class FfiModel with ChangeNotifier {
|
||||
if (displays.isNotEmpty) {
|
||||
_reconnects = 1;
|
||||
_offlineReconnectStartTime = null;
|
||||
resetRestartReconnectState();
|
||||
waitForFirstImage.value = true;
|
||||
isRefreshing = false;
|
||||
}
|
||||
@@ -3654,6 +3629,7 @@ class FFI {
|
||||
late final ElevationModel elevationModel; // session
|
||||
late final CmFileModel cmFileModel; // cm
|
||||
late final TextureModel textureModel; //session
|
||||
late final ShortcutModel shortcutModel; // session
|
||||
late final Peers recentPeersModel; // global
|
||||
late final Peers favoritePeersModel; // global
|
||||
late final Peers lanPeersModel; // global
|
||||
@@ -3683,6 +3659,7 @@ class FFI {
|
||||
elevationModel = ElevationModel(WeakReference(this));
|
||||
cmFileModel = CmFileModel(WeakReference(this));
|
||||
textureModel = TextureModel(WeakReference(this));
|
||||
shortcutModel = ShortcutModel(WeakReference(this));
|
||||
recentPeersModel = Peers(
|
||||
name: PeersModelName.recent,
|
||||
loadEvent: LoadEvent.recent,
|
||||
@@ -3697,7 +3674,6 @@ class FFI {
|
||||
|
||||
/// Mobile reuse FFI
|
||||
void mobileReset() {
|
||||
ffiModel.resetRestartReconnectState();
|
||||
ffiModel.waitForFirstImage.value = true;
|
||||
ffiModel.isRefreshing = false;
|
||||
ffiModel.waitForImageDialogShow.value = true;
|
||||
@@ -3911,7 +3887,6 @@ class FFI {
|
||||
}
|
||||
if (ffiModel.waitForFirstImage.value == true) {
|
||||
ffiModel.waitForFirstImage.value = false;
|
||||
ffiModel.resetRestartReconnectState();
|
||||
dialogManager.dismissAll();
|
||||
await canvasModel.updateViewStyle();
|
||||
await canvasModel.updateScrollStyle();
|
||||
|
||||
@@ -145,26 +145,23 @@ class Peer {
|
||||
note == other.note;
|
||||
}
|
||||
|
||||
factory Peer.copy(Peer other) {
|
||||
final peer = Peer(
|
||||
id: other.id,
|
||||
hash: other.hash,
|
||||
password: other.password,
|
||||
username: other.username,
|
||||
hostname: other.hostname,
|
||||
platform: other.platform,
|
||||
alias: other.alias,
|
||||
tags: other.tags.toList(),
|
||||
forceAlwaysRelay: other.forceAlwaysRelay,
|
||||
rdpPort: other.rdpPort,
|
||||
rdpUsername: other.rdpUsername,
|
||||
loginName: other.loginName,
|
||||
device_group_name: other.device_group_name,
|
||||
note: other.note,
|
||||
sameServer: other.sameServer);
|
||||
peer.online = other.online;
|
||||
return peer;
|
||||
}
|
||||
Peer.copy(Peer other)
|
||||
: this(
|
||||
id: other.id,
|
||||
hash: other.hash,
|
||||
password: other.password,
|
||||
username: other.username,
|
||||
hostname: other.hostname,
|
||||
platform: other.platform,
|
||||
alias: other.alias,
|
||||
tags: other.tags.toList(),
|
||||
forceAlwaysRelay: other.forceAlwaysRelay,
|
||||
rdpPort: other.rdpPort,
|
||||
rdpUsername: other.rdpUsername,
|
||||
loginName: other.loginName,
|
||||
device_group_name: other.device_group_name,
|
||||
note: other.note,
|
||||
sameServer: other.sameServer);
|
||||
}
|
||||
|
||||
enum UpdateEvent { online, load }
|
||||
|
||||
@@ -298,7 +298,7 @@ class ServerModel with ChangeNotifier {
|
||||
}
|
||||
|
||||
toggleAudio() async {
|
||||
if (clients.any((c) => !c.disconnected)) {
|
||||
if (clients.isNotEmpty) {
|
||||
await showClientsMayNotBeChangedAlert(parent.target);
|
||||
}
|
||||
if (!_audioOk && !await AndroidPermissionManager.check(kRecordAudio)) {
|
||||
@@ -316,7 +316,7 @@ class ServerModel with ChangeNotifier {
|
||||
}
|
||||
|
||||
toggleFile() async {
|
||||
if (clients.any((c) => !c.disconnected)) {
|
||||
if (clients.isNotEmpty) {
|
||||
await showClientsMayNotBeChangedAlert(parent.target);
|
||||
}
|
||||
if (!_fileOk &&
|
||||
@@ -345,7 +345,7 @@ class ServerModel with ChangeNotifier {
|
||||
}
|
||||
|
||||
toggleInput() async {
|
||||
if (clients.any((c) => !c.disconnected)) {
|
||||
if (clients.isNotEmpty) {
|
||||
await showClientsMayNotBeChangedAlert(parent.target);
|
||||
}
|
||||
if (_inputOk) {
|
||||
@@ -549,19 +549,10 @@ 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 {
|
||||
final index = _clients.indexWhere((c) => c.id == client.id);
|
||||
if (index >= 0) {
|
||||
_clients[index].privacyMode = client.privacyMode;
|
||||
notifyListeners();
|
||||
if (_clients.any((c) => c.id == client.id)) {
|
||||
return;
|
||||
}
|
||||
_clients.add(client);
|
||||
@@ -827,7 +818,6 @@ class Client {
|
||||
bool restart = false;
|
||||
bool recording = false;
|
||||
bool blockInput = false;
|
||||
bool privacyMode = false;
|
||||
bool disconnected = false;
|
||||
bool fromSwitch = false;
|
||||
bool inVoiceCall = false;
|
||||
@@ -856,7 +846,6 @@ 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'];
|
||||
@@ -881,7 +870,6 @@ 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;
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../common.dart';
|
||||
import '../consts.dart';
|
||||
import '../desktop/widgets/tabbar_widget.dart' show DesktopTabController;
|
||||
import '../models/model.dart';
|
||||
import '../models/platform_model.dart';
|
||||
import '../models/state_model.dart';
|
||||
|
||||
/// Per-session shortcut dispatcher. Attached to FFI when a session is created.
|
||||
///
|
||||
/// The Rust matcher (src/keyboard/shortcuts.rs) emits `shortcut_triggered`
|
||||
/// session events containing the matched `action` id. The session event
|
||||
/// listener in [FfiModel.startEventListener] forwards those to this model
|
||||
/// via [onTriggered], which runs whatever callback the toolbar / menu
|
||||
/// builders previously registered for that action id.
|
||||
class ShortcutModel {
|
||||
final WeakReference<FFI> parent;
|
||||
final Map<String, VoidCallback> _callbacks = {};
|
||||
|
||||
ShortcutModel(this.parent);
|
||||
|
||||
/// Called by toolbar / menu builders to register what to do when the
|
||||
/// matched shortcut fires.
|
||||
void register(String actionId, VoidCallback callback) {
|
||||
_callbacks[actionId] = callback;
|
||||
}
|
||||
|
||||
void unregister(String actionId) {
|
||||
_callbacks.remove(actionId);
|
||||
}
|
||||
|
||||
/// Called by the session event listener when a `shortcut_triggered` event
|
||||
/// arrives for this session.
|
||||
void onTriggered(String actionId) {
|
||||
final cb = _callbacks[actionId];
|
||||
if (cb != null) {
|
||||
cb();
|
||||
} else {
|
||||
debugPrint('shortcut_triggered: no handler for $actionId');
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the bindings JSON from LocalConfig.
|
||||
static List<Map<String, dynamic>> readBindings() {
|
||||
final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey);
|
||||
if (raw.isEmpty) return [];
|
||||
try {
|
||||
final parsed = jsonDecode(raw) as Map<String, dynamic>;
|
||||
final list = (parsed['bindings'] as List?) ?? [];
|
||||
return list.cast<Map<String, dynamic>>();
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static bool isEnabled() {
|
||||
final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey);
|
||||
if (raw.isEmpty) return false;
|
||||
try {
|
||||
final parsed = jsonDecode(raw) as Map<String, dynamic>;
|
||||
return parsed['enabled'] == true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Register the default-bound shortcut actions that aren't already wired by
|
||||
/// `toolbarControls(...)` (which handles things like Ctrl+Alt+Shift+Del and the
|
||||
/// screenshot action). Called once per session from the desktop / mobile
|
||||
/// remote page, after the toolbar registrations have run.
|
||||
///
|
||||
/// [tabController] is the desktop window's tab controller; `null` on mobile /
|
||||
/// web (where tab-switch shortcuts don't apply).
|
||||
///
|
||||
/// Each callback below is a no-op when the underlying state required to
|
||||
/// service the action isn't available (e.g. only one display, only one tab).
|
||||
void registerSessionShortcutActions(
|
||||
FFI ffi, {
|
||||
DesktopTabController? tabController,
|
||||
}) {
|
||||
final sessionId = ffi.sessionId;
|
||||
|
||||
// Toggle Fullscreen — desktop & web-desktop only. `stateGlobal.setFullscreen`
|
||||
// handles native window vs. browser fullscreen; on mobile fullscreen is the
|
||||
// permanent default, so we leave the action unregistered (becomes a logged
|
||||
// no-op if a mobile user binds it).
|
||||
if (isDesktop || isWebDesktop) {
|
||||
ffi.shortcutModel.register(kShortcutActionToggleFullscreen, () {
|
||||
stateGlobal.setFullscreen(!stateGlobal.fullscreen.value);
|
||||
});
|
||||
}
|
||||
|
||||
// Switch Display Next / Prev — requires the peer to have at least 2
|
||||
// displays. No-op when only one display is available or when the user has
|
||||
// selected the "All displays" pseudo-display.
|
||||
void switchDisplayBy(int delta) {
|
||||
final pi = ffi.ffiModel.pi;
|
||||
final count = pi.displays.length;
|
||||
if (count <= 1) return;
|
||||
final current = pi.currentDisplay;
|
||||
if (current == kAllDisplayValue) return;
|
||||
final next = ((current + delta) % count + count) % count;
|
||||
bind.sessionSwitchDisplay(
|
||||
isDesktop: isDesktop,
|
||||
sessionId: sessionId,
|
||||
value: Int32List.fromList([next]),
|
||||
);
|
||||
if (pi.isSupportMultiUiSession) {
|
||||
// On multi-ui-session peers no switch-display message is sent back, so
|
||||
// update the local state directly (mirrors `model.dart` handling).
|
||||
ffi.ffiModel.switchToNewDisplay(next, sessionId, ffi.id);
|
||||
}
|
||||
}
|
||||
|
||||
ffi.shortcutModel.register(kShortcutActionSwitchDisplayNext, () {
|
||||
switchDisplayBy(1);
|
||||
});
|
||||
ffi.shortcutModel.register(kShortcutActionSwitchDisplayPrev, () {
|
||||
switchDisplayBy(-1);
|
||||
});
|
||||
|
||||
// Switch Tab 1..9 — desktop only. The remote-screen tabs live in the
|
||||
// window-scoped DesktopTabController, not on the FFI itself, so we need
|
||||
// the controller from the page that owns this session. No-op on mobile /
|
||||
// web (no controller passed) and when the requested tab index is out of
|
||||
// range.
|
||||
if (tabController != null) {
|
||||
for (var n = 1; n <= 9; n++) {
|
||||
final idx = n - 1;
|
||||
ffi.shortcutModel.register(kShortcutActionSwitchTab(n), () {
|
||||
if (tabController.state.value.tabs.length > idx) {
|
||||
tabController.jumpTo(idx);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,30 +27,25 @@ 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 _markViewReadyScheduled = false;
|
||||
bool _suppressTerminalOutput = false;
|
||||
bool _suppressNextTerminalDataOutput = false;
|
||||
|
||||
bool get isPeerWindows => parent.ffiModel.pi.platform == kPeerPlatformWindows;
|
||||
|
||||
void Function(int w, int h, int pw, int ph)? onResizeExternal;
|
||||
|
||||
Future<void> _handleInput(String data) async {
|
||||
// Soft keyboards (notably iOS) emit '\n' when Enter is pressed, while a
|
||||
// real keyboard's Enter sends '\r'. Some Android keyboards also emit '\n'.
|
||||
// - Peer Windows: '\r' works, '\n' is just a newline.
|
||||
// - Peer Linux: canonical-mode shells accept both, but raw-mode apps
|
||||
// (readline, prompt_toolkit, vim, TUI frameworks) expect '\r'.
|
||||
// - Peer macOS: same as Linux, raw-mode apps expect '\r'
|
||||
// (https://github.com/rustdesk/rustdesk/issues/14907).
|
||||
// So on mobile / web-mobile, always normalize a lone '\n' to '\r'.
|
||||
// We deliberately do not touch multi-character payloads (e.g. pasted text)
|
||||
// so embedded newlines in pasted content are preserved.
|
||||
// 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'.
|
||||
final isMobileOrWebMobile = (isMobile || (isWeb && !isWebDesktop));
|
||||
if (isMobileOrWebMobile && data == '\n') {
|
||||
if (isMobileOrWebMobile && isPeerWindows && data == '\n') {
|
||||
data = '\r';
|
||||
}
|
||||
if (_terminalOpened) {
|
||||
@@ -75,10 +70,7 @@ class TerminalModel with ChangeNotifier {
|
||||
terminalController = TerminalController();
|
||||
|
||||
// Setup terminal callbacks
|
||||
terminal.onOutput = (data) {
|
||||
if (_suppressTerminalOutput) return;
|
||||
_handleInput(data);
|
||||
};
|
||||
terminal.onOutput = _handleInput;
|
||||
|
||||
terminal.onResize = (w, h, pw, ph) async {
|
||||
// Validate all dimensions before using them
|
||||
@@ -92,7 +84,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) {
|
||||
_scheduleMarkViewReady();
|
||||
_markViewReady();
|
||||
}
|
||||
|
||||
if (_terminalOpened) {
|
||||
@@ -118,16 +110,14 @@ class TerminalModel with ChangeNotifier {
|
||||
void onReady() {
|
||||
parent.dialogManager.dismissAll();
|
||||
|
||||
// Fire and forget - don't block onReady. If the transport reconnects while
|
||||
// this model is still open, re-send OpenTerminal so the remote service marks
|
||||
// the persistent session active again and resumes output streaming.
|
||||
openTerminal(force: _terminalOpened).catchError((e) {
|
||||
// Fire and forget - don't block onReady
|
||||
openTerminal().catchError((e) {
|
||||
debugPrint('[TerminalModel] Error opening terminal: $e');
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> openTerminal({bool force = false}) async {
|
||||
if (_terminalOpened && !force) return;
|
||||
Future<void> openTerminal() async {
|
||||
if (_terminalOpened) 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
|
||||
|
||||
@@ -285,12 +275,9 @@ class TerminalModel with ChangeNotifier {
|
||||
if (success) {
|
||||
_terminalOpened = true;
|
||||
|
||||
// On reconnect, the server may replay recent output. That replay can include
|
||||
// terminal queries like DSR/DA; xterm answers them through onOutput as
|
||||
// "^[[1;1R^[[2;2R^[[>0;0;0c", which must not be sent back to the peer.
|
||||
final replayTerminalOutput = evt['replay_terminal_output'];
|
||||
_suppressNextTerminalDataOutput = replayTerminalOutput == true ||
|
||||
message == 'Reconnected to existing terminal with pending output';
|
||||
// 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.
|
||||
|
||||
// Fallback: if terminal view is not yet ready but already has valid
|
||||
// dimensions (e.g. layout completed before open response arrived),
|
||||
@@ -298,7 +285,7 @@ class TerminalModel with ChangeNotifier {
|
||||
if (!_terminalViewReady &&
|
||||
terminal.viewWidth > 0 &&
|
||||
terminal.viewHeight > 0) {
|
||||
_scheduleMarkViewReady();
|
||||
_markViewReady();
|
||||
}
|
||||
|
||||
// Process any buffered input
|
||||
@@ -310,16 +297,12 @@ class TerminalModel with ChangeNotifier {
|
||||
});
|
||||
|
||||
final persistentSessions =
|
||||
(evt['persistent_sessions'] as List<dynamic>? ?? [])
|
||||
.whereType<int>()
|
||||
.where((id) => !parent.terminalModels.containsKey(id))
|
||||
.toList();
|
||||
evt['persistent_sessions'] as List<dynamic>? ?? [];
|
||||
if (kWindowId != null && persistentSessions.isNotEmpty) {
|
||||
DesktopMultiWindow.invokeMethod(
|
||||
kWindowId!,
|
||||
kWindowEventRestoreTerminalSessions,
|
||||
jsonEncode({
|
||||
'peer_id': id,
|
||||
'persistent_sessions': persistentSessions,
|
||||
}));
|
||||
}
|
||||
@@ -349,8 +332,6 @@ class TerminalModel with ChangeNotifier {
|
||||
final data = evt['data'];
|
||||
|
||||
if (data != null) {
|
||||
final suppressTerminalOutput = _suppressNextTerminalDataOutput;
|
||||
_suppressNextTerminalDataOutput = false;
|
||||
try {
|
||||
String text = '';
|
||||
if (data is String) {
|
||||
@@ -370,7 +351,7 @@ class TerminalModel with ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
|
||||
_writeToTerminal(text, suppressTerminalOutput: suppressTerminalOutput);
|
||||
_writeToTerminal(text);
|
||||
} catch (e) {
|
||||
debugPrint('[TerminalModel] Failed to process terminal data: $e');
|
||||
}
|
||||
@@ -380,10 +361,7 @@ 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, {
|
||||
bool suppressTerminalOutput = false,
|
||||
}) {
|
||||
void _writeToTerminal(String text) {
|
||||
if (!_terminalViewReady) {
|
||||
// If a single chunk exceeds the cap, keep only its tail.
|
||||
// Note: truncation may split a multi-byte ANSI escape sequence,
|
||||
@@ -395,73 +373,34 @@ 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;
|
||||
}
|
||||
_writeTerminalChunk(text, suppressTerminalOutput: suppressTerminalOutput);
|
||||
terminal.write(text);
|
||||
}
|
||||
|
||||
void _flushOutputBuffer() {
|
||||
if (_pendingOutputChunks.isEmpty) return;
|
||||
debugPrint(
|
||||
'[TerminalModel] Flushing $_pendingOutputSize buffered chars (${_pendingOutputChunks.length} chunks)');
|
||||
for (var i = 0; i < _pendingOutputChunks.length; i++) {
|
||||
_writeTerminalChunk(
|
||||
_pendingOutputChunks[i],
|
||||
suppressTerminalOutput: _pendingOutputSuppressFlags[i],
|
||||
);
|
||||
for (final chunk in _pendingOutputChunks) {
|
||||
terminal.write(chunk);
|
||||
}
|
||||
_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;
|
||||
@@ -487,10 +426,7 @@ 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();
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:uuid/uuid.dart';
|
||||
import 'dart:html' as html;
|
||||
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/common.dart' as common;
|
||||
|
||||
final _privateConstructorUsedError = UnsupportedError(
|
||||
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models');
|
||||
@@ -930,6 +931,30 @@ class RustdeskImpl {
|
||||
]));
|
||||
}
|
||||
|
||||
// Tell the JS-side matcher (flutter/web/js/src/shortcut_matcher.ts) to
|
||||
// re-read its bindings from LocalStorage. Mirrors the native call which
|
||||
// refreshes the Rust matcher's in-memory cache.
|
||||
void mainReloadKeyboardShortcuts({dynamic hint}) {
|
||||
js.context.callMethod('reloadShortcuts', []);
|
||||
}
|
||||
|
||||
// Mirror of `default_bindings()` in `src/keyboard/shortcuts.rs`. Keep these
|
||||
// two lists in sync — if you add or change a default binding on the Rust
|
||||
// side, update the literal below to match.
|
||||
String mainGetDefaultKeyboardShortcuts({dynamic hint}) {
|
||||
const prefix = ['primary', 'alt', 'shift'];
|
||||
final list = <Map<String, dynamic>>[
|
||||
{'action': 'send_ctrl_alt_del', 'mods': prefix, 'key': 'delete'},
|
||||
{'action': 'toggle_fullscreen', 'mods': prefix, 'key': 'enter'},
|
||||
{'action': 'switch_display_next', 'mods': prefix, 'key': 'arrow_right'},
|
||||
{'action': 'switch_display_prev', 'mods': prefix, 'key': 'arrow_left'},
|
||||
{'action': 'screenshot', 'mods': prefix, 'key': 'p'},
|
||||
for (var n = 1; n <= 9; n++)
|
||||
{'action': 'switch_tab_$n', 'mods': prefix, 'key': 'digit$n'},
|
||||
];
|
||||
return jsonEncode(list);
|
||||
}
|
||||
|
||||
String mainGetInputSource({dynamic hint}) {
|
||||
final inputSource =
|
||||
js.context.callMethod('getByName', ['option:local', 'input-source']);
|
||||
@@ -1176,6 +1201,15 @@ class RustdeskImpl {
|
||||
}
|
||||
|
||||
Future<void> mainInit({required String appDir, dynamic hint}) {
|
||||
// JS -> Dart shortcut bridge. The matcher in flutter/web/js/src/
|
||||
// shortcut_matcher.ts calls `window.onShortcutTriggered(actionId)` when a
|
||||
// binding fires; route it to the active session's ShortcutModel.
|
||||
// Web is single-window so `gFFI` is always the active session.
|
||||
js.context['onShortcutTriggered'] = (dynamic action) {
|
||||
if (action is String) {
|
||||
common.gFFI.shortcutModel.onTriggered(action);
|
||||
}
|
||||
};
|
||||
return Future.value();
|
||||
}
|
||||
|
||||
@@ -1729,7 +1763,7 @@ class RustdeskImpl {
|
||||
}
|
||||
|
||||
String mainSupportedPrivacyModeImpls({dynamic hint}) {
|
||||
return '[]';
|
||||
throw UnimplementedError("mainSupportedPrivacyModeImpls");
|
||||
}
|
||||
|
||||
String mainSupportedInputSource({dynamic hint}) {
|
||||
@@ -2034,14 +2068,7 @@ class RustdeskImpl {
|
||||
}
|
||||
|
||||
String mainResolveAvatarUrl({required String avatar, dynamic hint}) {
|
||||
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");
|
||||
return js.context.callMethod('getByName', ['resolve_avatar_url', avatar])?.toString() ?? avatar;
|
||||
}
|
||||
|
||||
void dispose() {}
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
#!/usr/bin/env bash
|
||||
cargo ndk --platform 21 --target armv7-linux-androideabi build --locked --release --features flutter,hwcodec
|
||||
cargo ndk --platform 21 --target armv7-linux-androideabi build --release --features flutter,hwcodec
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
#!/usr/bin/env bash
|
||||
cargo ndk --platform 21 --target aarch64-linux-android build --locked --release --features flutter,hwcodec
|
||||
cargo ndk --platform 21 --target aarch64-linux-android build --release --features flutter,hwcodec
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
#!/usr/bin/env bash
|
||||
cargo ndk --platform 21 --target x86_64-linux-android build --locked --release --features flutter
|
||||
cargo ndk --platform 21 --target x86_64-linux-android build --release --features flutter
|
||||
|
||||
+1
-1
@@ -7,4 +7,4 @@
|
||||
export CFLAGS="-DBROKEN_CLANG_ATOMICS"
|
||||
export CXXFLAGS="-DBROKEN_CLANG_ATOMICS"
|
||||
|
||||
cargo ndk --platform 21 --target i686-linux-android build --locked --release --features flutter
|
||||
cargo ndk --platform 21 --target i686-linux-android build --release --features flutter
|
||||
|
||||
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
|
||||
# Read more about iOS versioning at
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# 1.1.9-1 works for android, but for ios it becomes 1.1.91, need to set it to 1.1.9-a.1 for iOS, will get 1.1.9.1, but iOS store not allow 4 numbers
|
||||
version: 1.4.8+66
|
||||
version: 1.4.6+64
|
||||
|
||||
environment:
|
||||
sdk: '^3.1.0'
|
||||
|
||||
+2
-2
@@ -1,9 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
cargo install flutter_rust_bridge_codegen --version 1.80.1 --features uuid --locked
|
||||
cargo install flutter_rust_bridge_codegen --version 1.80.1 --features uuid
|
||||
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 --locked --features flutter
|
||||
cargo build --features flutter
|
||||
flutter run $@
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
import 'package:flutter_hbb/common/widgets/autocomplete.dart';
|
||||
import 'package:flutter_hbb/models/peer_model.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
Peer _peer({
|
||||
required String id,
|
||||
String alias = '',
|
||||
String username = '',
|
||||
String hostname = '',
|
||||
bool online = false,
|
||||
}) {
|
||||
final peer = Peer(
|
||||
id: id,
|
||||
username: username,
|
||||
hostname: hostname,
|
||||
alias: alias,
|
||||
platform: '',
|
||||
tags: [],
|
||||
hash: '',
|
||||
password: '',
|
||||
forceAlwaysRelay: false,
|
||||
rdpPort: '',
|
||||
rdpUsername: '',
|
||||
loginName: '',
|
||||
device_group_name: '',
|
||||
note: '',
|
||||
);
|
||||
peer.online = online;
|
||||
return peer;
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('merged autocomplete peers keep address book metadata and online state',
|
||||
() {
|
||||
final peers = mergeAutocompletePeers(
|
||||
addressBookPeers: [
|
||||
_peer(id: '123456789', alias: 'Office PC', username: 'ab-user'),
|
||||
],
|
||||
lanPeers: [
|
||||
_peer(id: '123456789', username: 'lan-user', online: true),
|
||||
],
|
||||
);
|
||||
|
||||
expect(peers, hasLength(1));
|
||||
expect(peers.single.id, '123456789');
|
||||
expect(peers.single.alias, 'Office PC');
|
||||
expect(peers.single.username, 'ab-user');
|
||||
expect(peers.single.online, isTrue);
|
||||
});
|
||||
|
||||
test('peer copies preserve online state', () {
|
||||
final peer = _peer(id: '987654321', online: true);
|
||||
|
||||
expect(Peer.copy(peer).online, isTrue);
|
||||
});
|
||||
|
||||
test('online callbacks update autocomplete-only peers', () {
|
||||
final peers = mergeAutocompletePeers(restRecentPeerIds: ['112233445']);
|
||||
|
||||
final changed = updateAutocompletePeerOnlineStates(
|
||||
peers,
|
||||
onlines: {'112233445'},
|
||||
offlines: {},
|
||||
);
|
||||
|
||||
expect(changed, isTrue);
|
||||
expect(peers.single.online, isTrue);
|
||||
});
|
||||
|
||||
test('online query ids are deduplicated and limited', () {
|
||||
final peers = List.generate(
|
||||
25,
|
||||
(index) => _peer(id: index.toString()),
|
||||
)..insert(1, _peer(id: '0'));
|
||||
|
||||
final ids = autocompleteOnlineQueryIds(peers, limit: 20);
|
||||
|
||||
expect(ids, hasLength(20));
|
||||
expect(ids.first, '0');
|
||||
expect(ids.where((id) => id == '0'), hasLength(1));
|
||||
expect(ids.last, '19');
|
||||
});
|
||||
|
||||
test('empty online query ids cancel pending debounce', () async {
|
||||
final queriedIds = <List<String>>[];
|
||||
final loader = AllPeersLoader(
|
||||
queryOnlines: (ids) async {
|
||||
queriedIds.add(ids);
|
||||
},
|
||||
queryOnlineDebounce: Duration(milliseconds: 1),
|
||||
);
|
||||
|
||||
loader.queryOnlines([_peer(id: '123456789')]);
|
||||
loader.queryOnlines([]);
|
||||
await Future.delayed(Duration(milliseconds: 2));
|
||||
|
||||
expect(queriedIds, isEmpty);
|
||||
});
|
||||
|
||||
test('failed online query enqueue does not suppress retry', () async {
|
||||
var queryCount = 0;
|
||||
final loader = AllPeersLoader(
|
||||
queryOnlines: (ids) {
|
||||
queryCount += 1;
|
||||
return Future<void>.error(Exception('queue full'));
|
||||
},
|
||||
queryOnlineDebounce: Duration(milliseconds: 1),
|
||||
);
|
||||
|
||||
loader.queryOnlines([_peer(id: '123456789')]);
|
||||
await Future.delayed(Duration(milliseconds: 2));
|
||||
|
||||
loader.queryOnlines([_peer(id: '123456789')]);
|
||||
await Future.delayed(Duration(milliseconds: 2));
|
||||
|
||||
expect(queryCount, 2);
|
||||
});
|
||||
|
||||
test('online callback updates currently displayed options', () async {
|
||||
final loader = AllPeersLoader(
|
||||
queryOnlines: (ids) async {},
|
||||
queryOnlineDebounce: Duration(milliseconds: 1),
|
||||
);
|
||||
final displayedOptions = [_peer(id: '123456789')];
|
||||
|
||||
loader.queryOnlines(displayedOptions);
|
||||
loader.updateOnlineStateForTesting({
|
||||
'onlines': '123456789',
|
||||
'offlines': '',
|
||||
});
|
||||
|
||||
expect(displayedOptions.single.online, isTrue);
|
||||
await Future.delayed(Duration(milliseconds: 2));
|
||||
});
|
||||
|
||||
test('cached online callback state is reapplied after peers merge', () {
|
||||
final loader = AllPeersLoader();
|
||||
loader.updateOnlineStateForTesting({
|
||||
'onlines': '123456789',
|
||||
'offlines': '',
|
||||
});
|
||||
|
||||
final mergedPeers = [_peer(id: '123456789')];
|
||||
loader.applyLastOnlineStateForTesting(mergedPeers);
|
||||
|
||||
expect(mergedPeers.single.online, isTrue);
|
||||
});
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/desktop/pages/server_page.dart';
|
||||
import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
|
||||
import 'package:flutter_hbb/main.dart';
|
||||
import 'package:flutter_hbb/models/server_model.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
final testClients = [
|
||||
Client(0, false, false, false, "UserAAAAAA", "123123123", true, false, false),
|
||||
Client(1, false, false, false, "UserBBBBB", "221123123", true, false, false),
|
||||
Client(2, false, false, false, "UserC", "331123123", true, false, false),
|
||||
Client(3, false, false, false, "UserDDDDDDDDDDDd", "441123123", true, false,
|
||||
false)
|
||||
];
|
||||
|
||||
/// flutter run -d {platform} -t test/cm_demo.dart to test cm
|
||||
void main() async {
|
||||
isTest = true;
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await windowManager.ensureInitialized();
|
||||
await windowManager.setSize(const Size(400, 600));
|
||||
await windowManager.setAlignment(Alignment.topRight);
|
||||
await initEnv(kAppTypeMain);
|
||||
for (var client in testClients) {
|
||||
gFFI.serverModel.clients.add(client);
|
||||
gFFI.serverModel.tabController.add(TabInfo(
|
||||
key: client.id.toString(),
|
||||
label: client.name,
|
||||
closable: false,
|
||||
page: buildConnectionCard(client)));
|
||||
}
|
||||
|
||||
runApp(GetMaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: MyTheme.lightTheme,
|
||||
darkTheme: MyTheme.darkTheme,
|
||||
themeMode: MyTheme.currentThemeMode(),
|
||||
localizationsDelegates: const [
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
supportedLocales: supportedLocales,
|
||||
home: const DesktopServerPage()));
|
||||
WindowOptions windowOptions = getHiddenTitleBarWindowOptions(
|
||||
size: kConnectionManagerWindowSizeClosedChat);
|
||||
windowManager.waitUntilReadyToShow(windowOptions, () async {
|
||||
await windowManager.show();
|
||||
// ensure initial window size to be changed
|
||||
await windowManager.setSize(kConnectionManagerWindowSizeClosedChat);
|
||||
await Future.wait([
|
||||
windowManager.setAlignment(Alignment.topRight),
|
||||
windowManager.focus(),
|
||||
windowManager.setOpacity(1)
|
||||
]);
|
||||
// ensure
|
||||
windowManager.setAlignment(Alignment.topRight);
|
||||
});
|
||||
}
|
||||
+57
-15
@@ -1,20 +1,62 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/desktop/pages/server_page.dart';
|
||||
import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
|
||||
import 'package:flutter_hbb/main.dart';
|
||||
import 'package:flutter_hbb/models/server_model.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import 'cm_demo.dart' as cm_demo;
|
||||
final testClients = [
|
||||
Client(0, false, false, false, "UserAAAAAA", "123123123", true, false, false, false),
|
||||
Client(1, false, false, false, "UserBBBBB", "221123123", true, false, false, false),
|
||||
Client(2, false, false, false, "UserC", "331123123", true, false, false, false),
|
||||
Client(3, false, false, false, "UserDDDDDDDDDDDd", "441123123", true, false, false, false)
|
||||
];
|
||||
|
||||
void main() {
|
||||
test('connection manager demo clients match the current Client API', () {
|
||||
expect(cm_demo.testClients, hasLength(4));
|
||||
expect(cm_demo.testClients.map((client) => client.name), [
|
||||
'UserAAAAAA',
|
||||
'UserBBBBB',
|
||||
'UserC',
|
||||
'UserDDDDDDDDDDDd',
|
||||
/// flutter run -d {platform} -t test/cm_test.dart to test cm
|
||||
void main(List<String> args) async {
|
||||
isTest = true;
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await windowManager.ensureInitialized();
|
||||
await windowManager.setSize(const Size(400, 600));
|
||||
await windowManager.setAlignment(Alignment.topRight);
|
||||
await initEnv(kAppTypeMain);
|
||||
for (var client in testClients) {
|
||||
gFFI.serverModel.clients.add(client);
|
||||
gFFI.serverModel.tabController.add(TabInfo(
|
||||
key: client.id.toString(),
|
||||
label: client.name,
|
||||
closable: false,
|
||||
page: buildConnectionCard(client)));
|
||||
}
|
||||
|
||||
runApp(GetMaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: MyTheme.lightTheme,
|
||||
darkTheme: MyTheme.darkTheme,
|
||||
themeMode: MyTheme.currentThemeMode(),
|
||||
localizationsDelegates: const [
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
supportedLocales: supportedLocales,
|
||||
home: const DesktopServerPage()));
|
||||
WindowOptions windowOptions = getHiddenTitleBarWindowOptions(
|
||||
size: kConnectionManagerWindowSizeClosedChat);
|
||||
windowManager.waitUntilReadyToShow(windowOptions, () async {
|
||||
await windowManager.show();
|
||||
// ensure initial window size to be changed
|
||||
await windowManager.setSize(kConnectionManagerWindowSizeClosedChat);
|
||||
await Future.wait([
|
||||
windowManager.setAlignment(Alignment.topRight),
|
||||
windowManager.focus(),
|
||||
windowManager.setOpacity(1)
|
||||
]);
|
||||
expect(
|
||||
cm_demo.testClients.every(
|
||||
(client) => client.keyboard && !client.clipboard && !client.audio),
|
||||
isTrue,
|
||||
);
|
||||
// ensure
|
||||
windowManager.setAlignment(Alignment.topRight);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter_hbb/mobile/widgets/dialog.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('server settings text fields preserve literal input',
|
||||
(tester) async {
|
||||
final controller = TextEditingController(text: 'AbCdR1c1E=');
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: Scaffold(
|
||||
body: serverSettingsTextFormField(
|
||||
label: 'Key',
|
||||
controller: controller,
|
||||
errorMsg: '',
|
||||
autofocus: true,
|
||||
),
|
||||
),
|
||||
));
|
||||
|
||||
final textField = tester.widget<TextField>(find.byType(TextField));
|
||||
|
||||
expect(textField.controller, controller);
|
||||
expect(textField.autofocus, isTrue);
|
||||
expect(textField.keyboardType, TextInputType.visiblePassword);
|
||||
expect(textField.textCapitalization, TextCapitalization.none);
|
||||
expect(textField.autocorrect, isFalse);
|
||||
expect(textField.enableSuggestions, isFalse);
|
||||
expect(textField.smartDashesType, SmartDashesType.disabled);
|
||||
expect(textField.smartQuotesType, SmartQuotesType.disabled);
|
||||
expect(textField.enableIMEPersonalizedLearning, isFalse);
|
||||
expect(
|
||||
textField.spellCheckConfiguration,
|
||||
const SpellCheckConfiguration.disabled(),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -7,8 +7,7 @@ use fuser::MountOption;
|
||||
use hbb_common::{config::APP_NAME, log};
|
||||
use parking_lot::Mutex;
|
||||
use std::{
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
path::PathBuf,
|
||||
sync::{mpsc::Sender, Arc},
|
||||
time::Duration,
|
||||
};
|
||||
@@ -54,16 +53,8 @@ pub fn init_fuse_context(is_client: bool) -> Result<(), CliprdrError> {
|
||||
} else {
|
||||
FUSE_CONTEXT_SERVER.lock()
|
||||
};
|
||||
if let Some(ctx) = fuse_context_lock.as_ref() {
|
||||
if is_mount_point_healthy(&ctx.mount_point) {
|
||||
return Ok(());
|
||||
}
|
||||
log::warn!(
|
||||
"clipboard FUSE mount {} is disconnected, remounting",
|
||||
ctx.mount_point.display()
|
||||
);
|
||||
let stale_context = fuse_context_lock.take();
|
||||
drop(stale_context);
|
||||
if fuse_context_lock.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let mount_point = if is_client {
|
||||
FUSE_MOUNT_POINT_CLIENT.clone()
|
||||
@@ -168,100 +159,35 @@ struct FuseContext {
|
||||
}
|
||||
|
||||
// this function must be called after the main IPC is up
|
||||
fn prepare_fuse_mount_point(mount_point: &Path) {
|
||||
fn prepare_fuse_mount_point(mount_point: &PathBuf) {
|
||||
use std::{
|
||||
fs::{self, Permissions},
|
||||
os::unix::prelude::PermissionsExt,
|
||||
};
|
||||
|
||||
if let Some(parent) = mount_point.parent() {
|
||||
if let Err(e) = fs::create_dir_all(parent) {
|
||||
log::warn!("failed to create FUSE mount parent {:?}: {:?}", parent, e);
|
||||
}
|
||||
}
|
||||
fs::create_dir(mount_point).ok();
|
||||
fs::set_permissions(mount_point, Permissions::from_mode(0o777)).ok();
|
||||
|
||||
unmount_fuse_mount_point(mount_point);
|
||||
|
||||
if let Err(e) = fs::create_dir_all(mount_point) {
|
||||
log::warn!(
|
||||
"failed to create FUSE mount point {:?}: {:?}",
|
||||
mount_point,
|
||||
e
|
||||
);
|
||||
}
|
||||
if let Err(e) = fs::set_permissions(mount_point, Permissions::from_mode(0o777)) {
|
||||
log::warn!(
|
||||
"failed to set FUSE mount point permissions {:?}: {:?}",
|
||||
mount_point,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn is_mount_point_healthy(mount_point: &Path) -> bool {
|
||||
is_mount_point_healthy_result(std::fs::metadata(mount_point))
|
||||
}
|
||||
|
||||
fn is_mount_point_healthy_result<T>(result: io::Result<T>) -> bool {
|
||||
match result {
|
||||
Ok(_) => true,
|
||||
Err(e) => {
|
||||
e.raw_os_error() != Some(libc::ENOTCONN) && e.kind() != io::ErrorKind::NotFound
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn unmount_fuse_mount_point(mount_point: &Path) {
|
||||
if run_unmount_command("umount", &["-l"], mount_point) {
|
||||
return;
|
||||
}
|
||||
if run_unmount_command("fusermount3", &["-uz"], mount_point) {
|
||||
return;
|
||||
}
|
||||
run_unmount_command("fusermount", &["-uz"], mount_point);
|
||||
}
|
||||
|
||||
fn run_unmount_command(program: &str, args: &[&str], mount_point: &Path) -> bool {
|
||||
match std::process::Command::new(program)
|
||||
.args(args)
|
||||
if let Err(e) = std::process::Command::new("umount")
|
||||
.arg(mount_point)
|
||||
.status()
|
||||
{
|
||||
Ok(status) if status.success() => {}
|
||||
Ok(status) => {
|
||||
log::debug!(
|
||||
"{} {:?} exited with status {:?}",
|
||||
program,
|
||||
mount_point,
|
||||
status.code()
|
||||
);
|
||||
return false;
|
||||
}
|
||||
Err(e) => {
|
||||
log::debug!("failed to run {} for {:?}: {:?}", program, mount_point, e);
|
||||
return false;
|
||||
}
|
||||
log::warn!("umount {:?} may fail: {:?}", mount_point, e);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn uninit_fuse_context_(is_client: bool) {
|
||||
let mut fuse_context_lock = if is_client {
|
||||
FUSE_CONTEXT_CLIENT.lock()
|
||||
if is_client {
|
||||
let _ = FUSE_CONTEXT_CLIENT.lock().take();
|
||||
} else {
|
||||
FUSE_CONTEXT_SERVER.lock()
|
||||
};
|
||||
let ctx = fuse_context_lock.take();
|
||||
drop(ctx);
|
||||
let _ = FUSE_CONTEXT_SERVER.lock().take();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for FuseContext {
|
||||
fn drop(&mut self) {
|
||||
self.session.lock().take().map(|s| s.join());
|
||||
log::info!("unmounting clipboard FUSE from {}", self.mount_point.display());
|
||||
unmount_fuse_mount_point(&self.mount_point);
|
||||
if let Some(session) = self.session.lock().take() {
|
||||
session.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,30 +223,3 @@ impl FuseContext {
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::{fs, io};
|
||||
|
||||
#[test]
|
||||
fn reports_disconnected_fuse_mount_as_unhealthy() {
|
||||
let err = io::Error::from_raw_os_error(libc::ENOTCONN);
|
||||
|
||||
assert!(!is_mount_point_healthy_result::<()>(Err(err)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_existing_directory_mount_point_as_healthy() {
|
||||
let mount_point = std::env::temp_dir().join(format!(
|
||||
"rustdesk-fuse-mount-health-test-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&mount_point);
|
||||
fs::create_dir(&mount_point).unwrap();
|
||||
|
||||
assert!(is_mount_point_healthy(&mount_point));
|
||||
|
||||
let _ = fs::remove_dir_all(&mount_point);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,28 +39,6 @@
|
||||
|
||||
#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
|
||||
*/
|
||||
@@ -246,7 +224,6 @@ struct wf_clipboard
|
||||
|
||||
HWND hwnd;
|
||||
HANDLE hmem;
|
||||
SIZE_T hmem_data_len;
|
||||
HANDLE thread;
|
||||
HANDLE formatDataRespEvent;
|
||||
BOOL formatDataRespReceived;
|
||||
@@ -647,55 +624,10 @@ 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
|
||||
*/
|
||||
@@ -814,9 +746,6 @@ 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)
|
||||
@@ -834,48 +763,40 @@ static HRESULT STDMETHODCALLTYPE CliprdrDataObject_GetData(IDataObject *This, FO
|
||||
* is the number of FILEDESCRIPTOR's */
|
||||
// dsc = (FILEGROUPDESCRIPTOR *)GlobalLock(clipboard->hmem);
|
||||
dsc = (FILEGROUPDESCRIPTORW *)GlobalLock(clipboard->hmem);
|
||||
if (!dsc)
|
||||
instance->m_nStreams = dsc->cItems;
|
||||
GlobalUnlock(clipboard->hmem);
|
||||
|
||||
if (instance->m_nStreams > 0)
|
||||
{
|
||||
pMedium->hGlobal = NULL;
|
||||
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])
|
||||
if (!instance->m_pStream)
|
||||
{
|
||||
return wf_cliprdr_fail_locked_file_descriptor_data(
|
||||
clipboard, pMedium, instance, streams, i, E_OUTOFMEMORY);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GlobalUnlock(clipboard->hmem);
|
||||
wf_cliprdr_reset_streams(instance);
|
||||
instance->m_pStream = streams;
|
||||
instance->m_nStreams = stream_count;
|
||||
return S_OK;
|
||||
if (!instance->m_pStream)
|
||||
{
|
||||
if (clipboard->hmem)
|
||||
{
|
||||
GlobalFree(clipboard->hmem);
|
||||
clipboard->hmem = NULL;
|
||||
}
|
||||
|
||||
pMedium->hGlobal = NULL;
|
||||
return E_OUTOFMEMORY;
|
||||
}
|
||||
}
|
||||
else if (instance->m_pFormatEtc[idx].cfFormat == RegisterClipboardFormat(CFSTR_FILECONTENTS))
|
||||
{
|
||||
@@ -2239,16 +2160,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] =
|
||||
@@ -2856,7 +2777,6 @@ wf_cliprdr_server_format_data_response(CliprdrClientContext *context,
|
||||
break;
|
||||
}
|
||||
clipboard->hmem = NULL;
|
||||
clipboard->hmem_data_len = 0;
|
||||
|
||||
if (formatDataResponse->msgFlags != CB_RESPONSE_OK)
|
||||
{
|
||||
@@ -2890,7 +2810,6 @@ wf_cliprdr_server_format_data_response(CliprdrClientContext *context,
|
||||
break;
|
||||
}
|
||||
|
||||
clipboard->hmem_data_len = formatDataResponse->dataLen;
|
||||
clipboard->hmem = hMem;
|
||||
rc = CHANNEL_RC_OK;
|
||||
} while (0);
|
||||
|
||||
+1
-1
Submodule libs/hbb_common updated: 387603f47c...87b11a7959
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rustdesk-portable-packer"
|
||||
version = "1.4.8"
|
||||
version = "1.4.6"
|
||||
edition = "2021"
|
||||
description = "RustDesk Remote Desktop"
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import os
|
||||
import optparse
|
||||
import subprocess
|
||||
from hashlib import md5
|
||||
import brotli
|
||||
import datetime
|
||||
@@ -66,15 +65,11 @@ def write_app_metadata(output_folder: str):
|
||||
print(f"App metadata has been written to {output_path}")
|
||||
|
||||
def build_portable(output_folder: str, target: str):
|
||||
current_dir = os.getcwd()
|
||||
try:
|
||||
os.chdir(output_folder)
|
||||
cmd = ["cargo", "build", "--locked", "--release"]
|
||||
if target:
|
||||
cmd.extend(["--target", target])
|
||||
subprocess.run(cmd, check=True)
|
||||
finally:
|
||||
os.chdir(current_dir)
|
||||
os.chdir(output_folder)
|
||||
if target:
|
||||
os.system("cargo build --release --target " + target)
|
||||
else:
|
||||
os.system("cargo build --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
|
||||
|
||||
@@ -276,21 +276,12 @@ 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, &convert, &sink])?;
|
||||
src.link(&convert)?;
|
||||
convert.link(&sink)?;
|
||||
pipeline.add_many(&[&src, &sink])?;
|
||||
src.link(&sink)?;
|
||||
|
||||
let appsink = sink
|
||||
.dynamic_cast::<AppSink>()
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
pkgname=rustdesk
|
||||
pkgver=1.4.8
|
||||
pkgver=1.4.6
|
||||
pkgrel=0
|
||||
epoch=
|
||||
pkgdesc=""
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
#! /usr/bin/env bash
|
||||
sed -i "s/\b$1\b/$2/g" res/*spec res/PKGBUILD flutter/pubspec.yaml Cargo.toml .github/workflows/*yml flatpak/*json appimage/*yml libs/portable/Cargo.toml
|
||||
sed -i "s/$1/$2/g" res/*spec res/PKGBUILD flutter/pubspec.yaml Cargo.toml .github/workflows/*yml flatpak/*json appimage/*yml libs/portable/Cargo.toml
|
||||
cargo run # to bump version in cargo lock
|
||||
|
||||
@@ -31,17 +31,17 @@ LExit:
|
||||
return WcaFinalize(er);
|
||||
}
|
||||
|
||||
// Helper function to safely delete a file using handle-based deletion.
|
||||
// Directories are refused after opening the handle.
|
||||
// Helper function to safely delete a file or directory using handle-based deletion.
|
||||
// This avoids TOCTOU (Time-Of-Check-Time-Of-Use) race conditions.
|
||||
BOOL SafeDeleteItem(LPCWSTR fullPath)
|
||||
{
|
||||
// Open the file/directory with delete and attribute-read access plus FILE_FLAG_OPEN_REPARSE_POINT
|
||||
// Open the file/directory with DELETE access and FILE_FLAG_OPEN_REPARSE_POINT
|
||||
// to prevent following symlinks.
|
||||
// Use shared access to allow deletion even when other processes have the file open.
|
||||
DWORD flags = FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT;
|
||||
HANDLE hFile = CreateFileW(
|
||||
fullPath,
|
||||
DELETE | FILE_READ_ATTRIBUTES,
|
||||
DELETE,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, // Allow shared access
|
||||
NULL,
|
||||
OPEN_EXISTING,
|
||||
@@ -55,21 +55,6 @@ 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;
|
||||
@@ -92,74 +77,98 @@ BOOL SafeDeleteItem(LPCWSTR fullPath)
|
||||
return result;
|
||||
}
|
||||
|
||||
BOOL PathEndsWithSlash(LPCWSTR path)
|
||||
// Helper function to recursively delete a directory's contents with detailed logging.
|
||||
void RecursiveDelete(LPCWSTR path)
|
||||
{
|
||||
size_t length = 0;
|
||||
HRESULT hr = StringCchLengthW(path, MAX_PATH, &length);
|
||||
if (FAILED(hr) || length == 0)
|
||||
{
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
WCHAR last = path[length - 1];
|
||||
return last == L'\\' || last == L'/';
|
||||
}
|
||||
|
||||
void ClearReadOnlyAttribute(LPCWSTR fullPath, DWORD attributes)
|
||||
{
|
||||
if (!(attributes & FILE_ATTRIBUTE_READONLY))
|
||||
// Ensure the path is not empty or null.
|
||||
if (path == NULL || path[0] == L'\0')
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
DWORD writableAttributes = attributes & ~FILE_ATTRIBUTE_READONLY;
|
||||
if (writableAttributes == 0)
|
||||
// Extra safety: never operate directly on a root path.
|
||||
if (PathIsRootW(path))
|
||||
{
|
||||
writableAttributes = FILE_ATTRIBUTE_NORMAL;
|
||||
}
|
||||
|
||||
if (SetFileAttributesW(fullPath, writableAttributes))
|
||||
{
|
||||
WcaLog(LOGMSG_STANDARD, "Runtime cleanup cleared read-only attribute for '%ls'.", fullPath);
|
||||
WcaLog(LOGMSG_STANDARD, "RecursiveDelete: refusing to operate on root path '%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;
|
||||
// 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;
|
||||
}
|
||||
|
||||
DWORD attributes = GetFileAttributesW(fullPath);
|
||||
if (attributes == INVALID_FILE_ATTRIBUTES)
|
||||
WIN32_FIND_DATAW findData;
|
||||
HANDLE hFind = FindFirstFileW(searchPath, &findData);
|
||||
|
||||
if (hFind == INVALID_HANDLE_VALUE)
|
||||
{
|
||||
DWORD error = GetLastError();
|
||||
if (error == ERROR_FILE_NOT_FOUND || error == ERROR_PATH_NOT_FOUND)
|
||||
// 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;
|
||||
}
|
||||
|
||||
do
|
||||
{
|
||||
// Skip '.' and '..' directories.
|
||||
if (wcscmp(findData.cFileName, L".") == 0 || wcscmp(findData.cFileName, L"..") == 0)
|
||||
{
|
||||
return TRUE;
|
||||
continue;
|
||||
}
|
||||
|
||||
WcaLog(LOGMSG_STANDARD, "Runtime cleanup cannot stat '%ls'. Error: %lu", fullPath, error);
|
||||
return FALSE;
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
|
||||
if (attributes & FILE_ATTRIBUTE_DIRECTORY)
|
||||
// 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, "Runtime cleanup skipped directory '%ls'.", fullPath);
|
||||
return FALSE;
|
||||
WcaLog(LOGMSG_STANDARD, "RecursiveDelete: FindNextFileW failed with error %lu", lastError);
|
||||
}
|
||||
|
||||
ClearReadOnlyAttribute(fullPath, attributes);
|
||||
WcaLog(LOGMSG_STANDARD, "Runtime cleanup deleting '%ls'.", fullPath);
|
||||
return SafeDeleteItem(fullPath);
|
||||
FindClose(hFind);
|
||||
}
|
||||
|
||||
// See `Package.wxs` for the sequence of this custom action.
|
||||
@@ -169,13 +178,13 @@ BOOL DeleteRuntimeGeneratedFile(LPCWSTR installFolder, LPCWSTR fileName)
|
||||
// 2. RemoveExistingProducts
|
||||
// ├─ TerminateProcesses
|
||||
// ├─ TryStopDeleteService
|
||||
// ├─ RemoveRuntimeGeneratedFiles - <-- Here
|
||||
// ├─ RemoveInstallFolder - <-- Here
|
||||
// └─ RemoveFiles
|
||||
// 3. InstallValidate
|
||||
// 4. InstallFiles
|
||||
// 5. InstallExecute
|
||||
// 6. InstallFinalize
|
||||
UINT __stdcall RemoveRuntimeGeneratedFiles(
|
||||
UINT __stdcall RemoveInstallFolder(
|
||||
__in MSIHANDLE hInstall)
|
||||
{
|
||||
HRESULT hr = S_OK;
|
||||
@@ -185,7 +194,7 @@ UINT __stdcall RemoveRuntimeGeneratedFiles(
|
||||
LPWSTR pwz = NULL;
|
||||
LPWSTR pwzData = NULL;
|
||||
|
||||
hr = WcaInitialize(hInstall, "RemoveRuntimeGeneratedFiles");
|
||||
hr = WcaInitialize(hInstall, "RemoveInstallFolder");
|
||||
ExitOnFailure(hr, "Failed to initialize");
|
||||
|
||||
hr = WcaGetProperty(L"CustomActionData", &pwzData);
|
||||
@@ -193,20 +202,24 @@ UINT __stdcall RemoveRuntimeGeneratedFiles(
|
||||
|
||||
pwz = pwzData;
|
||||
hr = WcaReadStringFromCaData(&pwz, &installFolder);
|
||||
ExitOnFailure(hr, "failed to read install folder from custom action data: %ls", pwz);
|
||||
ExitOnFailure(hr, "failed to read database key from custom action data: %ls", pwz);
|
||||
|
||||
if (installFolder == NULL || installFolder[0] == L'\0') {
|
||||
WcaLog(LOGMSG_STANDARD, "Install folder path is empty, skipping runtime cleanup.");
|
||||
WcaLog(LOGMSG_STANDARD, "Install folder path is empty, skipping recursive delete.");
|
||||
goto LExit;
|
||||
}
|
||||
|
||||
if (PathIsRootW(installFolder)) {
|
||||
WcaLog(LOGMSG_STANDARD, "Refusing runtime cleanup in root folder '%ls'.", installFolder);
|
||||
WcaLog(LOGMSG_STANDARD, "Refusing to recursively delete root folder '%ls'.", installFolder);
|
||||
goto LExit;
|
||||
}
|
||||
|
||||
WcaLog(LOGMSG_STANDARD, "Removing runtime-generated files from install folder: %ls", installFolder);
|
||||
DeleteRuntimeGeneratedFile(installFolder, L"RuntimeBroker_rustdesk.exe");
|
||||
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.
|
||||
|
||||
LExit:
|
||||
ReleaseStr(pwzData);
|
||||
|
||||
@@ -2,7 +2,7 @@ LIBRARY "CustomActions"
|
||||
|
||||
EXPORTS
|
||||
CustomActionHello
|
||||
RemoveRuntimeGeneratedFiles
|
||||
RemoveInstallFolder
|
||||
TerminateProcesses
|
||||
AddFirewallRules
|
||||
SetPropertyIsServiceRunning
|
||||
|
||||
@@ -16,15 +16,8 @@
|
||||
<!-- 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" />
|
||||
|
||||
<!-- 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 ~>> "\$(var.Product)\"" />
|
||||
<!-- 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 ~>> "\$(var.Product)"" />
|
||||
<!-- 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 ~>> "\" AND NOT (INSTALLFOLDER ~>> "\$(var.Product)\" OR INSTALLFOLDER ~>> "\$(var.Product)")" />
|
||||
<!-- 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 ~>> "\" AND NOT (INSTALLFOLDER ~>> "\$(var.Product)\" OR INSTALLFOLDER ~>> "\$(var.Product)")" />
|
||||
<!-- 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" />
|
||||
|
||||
<!-- INSTALLFOLDER_INNER is defined for compatibility with previous versions of the installer. -->
|
||||
<!-- Because we need to use INSTALLFOLDER as the command line argument. -->
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</Component>
|
||||
</DirectoryRef>
|
||||
|
||||
<CustomAction Id="RemoveRuntimeGeneratedFiles.SetParam" Return="check" Property="RemoveRuntimeGeneratedFiles" Value="[INSTALLFOLDER_INNER]" />
|
||||
<CustomAction Id="RemoveInstallFolder.SetParam" Return="check" Property="RemoveInstallFolder" 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);"[INSTALLFOLDER_INNER]$(var.Product).exe" --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 >= 603 AND (PRINTER = 1 OR PRINTER = "Y" OR PRINTER = "y")" />
|
||||
<Custom Action="InstallPrinter" Before="InstallFinalize" Condition="VersionNT >= 603 AND PRINTER = 1 OR PRINTER = "Y" OR PRINTER = "y"" />
|
||||
<Custom Action="InstallPrinter.SetParam" Before="InstallPrinter" Condition="VersionNT >= 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="outgoing")"/>
|
||||
|
||||
<Custom Action="RemoveRuntimeGeneratedFiles" Before="RemoveFiles" Condition="Installed AND (REMOVE="ALL" OR UPGRADINGPRODUCTCODE)"/>
|
||||
<Custom Action="RemoveRuntimeGeneratedFiles.SetParam" Before="RemoveRuntimeGeneratedFiles" Condition="Installed AND (REMOVE="ALL" OR UPGRADINGPRODUCTCODE)"/>
|
||||
<Custom Action="TryStopDeleteService" Before="RemoveRuntimeGeneratedFiles.SetParam" />
|
||||
<Custom Action="RemoveInstallFolder" Before="RemoveFiles"/>
|
||||
<Custom Action="RemoveInstallFolder.SetParam" Before="RemoveInstallFolder"/>
|
||||
<Custom Action="TryStopDeleteService" Before="RemoveInstallFolder.SetParam" />
|
||||
<Custom Action="TryStopDeleteService.SetParam" Before="TryStopDeleteService" />
|
||||
|
||||
<Custom Action="RemoveFirewallRules" Before="RemoveFiles"/>
|
||||
<Custom Action="RemoveFirewallRules.SetParam" Before="RemoveFirewallRules"/>
|
||||
|
||||
<Custom Action="UninstallPrinter" Before="RemoveRuntimeGeneratedFiles" Condition="VersionNT >= 603" />
|
||||
<Custom Action="UninstallPrinter" Before="RemoveInstallFolder" Condition="VersionNT >= 603" />
|
||||
|
||||
<Custom Action="TerminateProcesses" Before="RemoveRuntimeGeneratedFiles"/>
|
||||
<Custom Action="TerminateProcesses" Before="RemoveInstallFolder"/>
|
||||
<Custom Action="TerminateProcesses.SetParam" Before="TerminateProcesses"/>
|
||||
<Custom Action="TerminateBrokers" Before="RemoveRuntimeGeneratedFiles"/>
|
||||
<Custom Action="TerminateBrokers" Before="RemoveInstallFolder"/>
|
||||
<Custom Action="TerminateBrokers.SetParam" Before="TerminateBrokers"/>
|
||||
<Custom Action="RemoveAmyuniIdd" Before="RemoveRuntimeGeneratedFiles"/>
|
||||
<Custom Action="RemoveAmyuniIdd" Before="RemoveInstallFolder"/>
|
||||
<Custom Action="RemoveAmyuniIdd.SetParam" Before="RemoveAmyuniIdd"/>
|
||||
</InstallExecuteSequence>
|
||||
|
||||
|
||||
@@ -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="RemoveRuntimeGeneratedFiles" DllEntry="RemoveRuntimeGeneratedFiles" Impersonate="no" Execute="deferred" Return="ignore" BinaryRef="Custom_Actions_Dll"/>
|
||||
<CustomAction Id="RemoveInstallFolder" DllEntry="RemoveInstallFolder" 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"/>
|
||||
|
||||
@@ -4,17 +4,17 @@
|
||||
<?include ..\Includes.wxi?>
|
||||
|
||||
<!--
|
||||
Properties and related actions for specifying whether to install shortcuts and the printer.
|
||||
Properties and related actions for specifying whether to install start menu/desktop shortcuts.
|
||||
-->
|
||||
|
||||
<!-- These are the actual properties that get used in conditions to determine whether to
|
||||
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. -->
|
||||
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. -->
|
||||
<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" Secure="yes"></Property>
|
||||
<Property Id="PRINTER" Value="1" 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,11 +77,7 @@
|
||||
<!-- 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 = "Y" OR CREATESTARTMENUSHORTCUTS = "y")" />
|
||||
<SetProperty Id="DESKTOPSHORTCUTS" Value="" After="RestoreSavedDesktopShortcutsValue" Sequence="first" Condition="CREATEDESKTOPSHORTCUTS AND NOT (CREATEDESKTOPSHORTCUTS = 1 OR CREATEDESKTOPSHORTCUTS = "Y" OR CREATEDESKTOPSHORTCUTS = "y")" />
|
||||
<!-- 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 = "Y" OR INSTALLPRINTER = "y"" />
|
||||
<SetProperty Action="SetPrinterValueDisabled" Id="PRINTER" Value="" After="SetPrinterValueEnabled" Sequence="first" Condition="INSTALLPRINTER AND NOT (INSTALLPRINTER = 1 OR INSTALLPRINTER = "Y" OR INSTALLPRINTER = "y")" />
|
||||
<SetProperty Id="PRINTER" Value="" After="RestoreSavedPrinterValue" Sequence="first" Condition="INSTALLPRINTER AND NOT (INSTALLPRINTER = 1 OR INSTALLPRINTER = "Y" OR INSTALLPRINTER = "y")" />
|
||||
|
||||
</Fragment>
|
||||
</Wix>
|
||||
|
||||
@@ -23,13 +23,12 @@ 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="5" Condition="NOT WIXUI_DONTVALIDATEPATH" />
|
||||
<Publish Dialog="MyInstallDirDlg" Control="Next" Event="DoAction" Value="WixUIValidatePath_$(WIXUIARCH)" Order="2" Condition="NOT WIXUI_DONTVALIDATEPATH" />
|
||||
</UI>
|
||||
|
||||
<UIRef Id="UI_MyInstallDialog" />
|
||||
@@ -65,16 +64,9 @@ Patch dialog sequence:
|
||||
<Publish Dialog="LicenseAgreementDlg" Control="Next" Event="NewDialog" Value="MyInstallDirDlg" Condition="LicenseAccepted = "1"" />
|
||||
|
||||
<Publish Dialog="MyInstallDirDlg" Control="Back" Event="NewDialog" Value="LicenseAgreementDlg" />
|
||||
<!-- 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 ~>> "\$(var.Product)"" />
|
||||
<!-- 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 ~>> "\" AND NOT (INSTALLFOLDER_INNER ~>> "\$(var.Product)\" OR INSTALLFOLDER_INNER ~>> "\$(var.Product)")" />
|
||||
<!-- 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 ~>> "\" AND NOT (INSTALLFOLDER_INNER ~>> "\$(var.Product)\" OR INSTALLFOLDER_INNER ~>> "\$(var.Product)")" />
|
||||
<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<>"1"" />
|
||||
<Publish Dialog="MyInstallDirDlg" Control="Next" Event="NewDialog" Value="VerifyReadyDlg" Order="7" Condition="WIXUI_DONTVALIDATEPATH OR WIXUI_INSTALLDIR_VALID="1"" />
|
||||
<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<>"1"" />
|
||||
<Publish Dialog="MyInstallDirDlg" Control="Next" Event="NewDialog" Value="VerifyReadyDlg" Order="4" Condition="WIXUI_DONTVALIDATEPATH OR WIXUI_INSTALLDIR_VALID="1"" />
|
||||
<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" />
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
echo $MACOS_CODESIGN_IDENTITY
|
||||
cargo install flutter_rust_bridge_codegen --version 1.80.1 --features uuid --locked
|
||||
cargo install flutter_rust_bridge_codegen --version 1.80.1 --features uuid
|
||||
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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
Name: rustdesk
|
||||
Version: 1.4.8
|
||||
Version: 1.4.6
|
||||
Release: 0
|
||||
Summary: RPM package
|
||||
License: GPL-3.0
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
Name: rustdesk
|
||||
Version: 1.4.8
|
||||
Version: 1.4.6
|
||||
Release: 0
|
||||
Summary: RPM package
|
||||
License: GPL-3.0
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
Name: rustdesk
|
||||
Version: 1.4.8
|
||||
Version: 1.4.6
|
||||
Release: 0
|
||||
Summary: RPM package
|
||||
License: GPL-3.0
|
||||
|
||||
+11
-114
@@ -96,8 +96,6 @@ pub mod screenshot;
|
||||
|
||||
pub const MILLI1: Duration = Duration::from_millis(1);
|
||||
pub const SEC30: Duration = Duration::from_secs(30);
|
||||
// Empirical restart reconnect grace window.
|
||||
const RESTART_REMOTE_DEVICE_GRACE: Duration = Duration::from_secs(5 * 60);
|
||||
pub const VIDEO_QUEUE_SIZE: usize = 120;
|
||||
const MAX_DECODE_FAIL_COUNTER: usize = 3;
|
||||
|
||||
@@ -1742,17 +1740,11 @@ pub struct LoginConfigHandler {
|
||||
features: Option<Features>,
|
||||
pub session_id: u64, // used for local <-> server communication
|
||||
pub supported_encoding: SupportedEncoding,
|
||||
restarting_remote_device: bool,
|
||||
// Start time of the restart grace window. On Windows the peer may briefly
|
||||
// reconnect before the real reboot disconnect.
|
||||
restart_remote_device_at: Option<Instant>,
|
||||
pub restarting_remote_device: bool,
|
||||
pub force_relay: bool,
|
||||
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>>>,
|
||||
@@ -1854,7 +1846,7 @@ impl LoginConfigHandler {
|
||||
}
|
||||
self.session_id = sid;
|
||||
self.supported_encoding = Default::default();
|
||||
self.clear_restarting_remote_device();
|
||||
self.restarting_remote_device = false;
|
||||
self.force_relay =
|
||||
config::option2bool("force-always-relay", &self.get_option("force-always-relay"))
|
||||
|| force_relay
|
||||
@@ -1869,11 +1861,6 @@ 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;
|
||||
@@ -1887,23 +1874,6 @@ 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 {
|
||||
@@ -2784,30 +2754,6 @@ impl LoginConfigHandler {
|
||||
msg_out
|
||||
}
|
||||
|
||||
pub fn mark_restarting_remote_device(&mut self) {
|
||||
self.restarting_remote_device = true;
|
||||
self.restart_remote_device_at = Some(Instant::now());
|
||||
}
|
||||
|
||||
pub fn clear_restarting_remote_device(&mut self) {
|
||||
self.restarting_remote_device = false;
|
||||
self.restart_remote_device_at = None;
|
||||
}
|
||||
|
||||
pub fn is_restarting_remote_device(&self) -> bool {
|
||||
if !self.restarting_remote_device {
|
||||
return false;
|
||||
}
|
||||
// Keep this flag alive for a short grace window instead of clearing it on
|
||||
// connection_ready or the first peer bytes. During OS restart the peer can
|
||||
// briefly reconnect before the real reboot disconnect, and clearing it too
|
||||
// early would let the next disconnect escape the restart flow and fall back
|
||||
// to the normal error dialog / manual reconnect path.
|
||||
self.restart_remote_device_at
|
||||
.map(|started_at| started_at.elapsed() < RESTART_REMOTE_DEVICE_GRACE)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn get_conn_token(&self) -> Option<String> {
|
||||
if self.password.is_empty() {
|
||||
return None;
|
||||
@@ -3431,36 +3377,6 @@ 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.
|
||||
///
|
||||
@@ -3481,22 +3397,12 @@ pub async fn handle_hash(
|
||||
// Take care of password application order
|
||||
|
||||
// switch_uuid
|
||||
#[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;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
// last password
|
||||
@@ -3747,18 +3653,9 @@ pub trait Interface: Send + Clone + 'static + Sized {
|
||||
fn on_establish_connection_error(&self, err: String) {
|
||||
let title = "Connection Error";
|
||||
let text = err.to_string();
|
||||
let lch = self.get_lch();
|
||||
let (is_restarting, direct, received) = {
|
||||
let lc = lch.read().unwrap();
|
||||
(lc.is_restarting_remote_device(), lc.direct, lc.received)
|
||||
};
|
||||
if is_restarting {
|
||||
log::info!("Restart remote device, suppress connection error: {err}");
|
||||
// Flutter treats this as a reconnect control event. The text is kept
|
||||
// for legacy UI and existing translation reuse.
|
||||
self.msgbox("restarting", "Restarting remote device", "Connection in progress. Please wait.", "");
|
||||
return;
|
||||
}
|
||||
let lc = self.get_lch();
|
||||
let direct = lc.read().unwrap().direct;
|
||||
let received = lc.read().unwrap().received;
|
||||
|
||||
let mut relay_hint = false;
|
||||
let mut relay_hint_type = "relay-hint";
|
||||
|
||||
+6
-41
@@ -10,10 +10,6 @@ use crate::{
|
||||
common::get_default_sound_input,
|
||||
ui_session_interface::{InvokeUiSession, Session},
|
||||
};
|
||||
|
||||
// Empirical no-data window before exposing the restart reconnect state to the UI.
|
||||
// Restart msgbox text is kept as a legacy UI fallback; Flutter handles the type as a control event.
|
||||
const RESTART_REMOTE_DEVICE_NO_DATA_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
#[cfg(feature = "unix-file-copy-paste")]
|
||||
use crate::{clipboard::try_empty_clipboard_files, clipboard_file::unix_file_clip};
|
||||
#[cfg(any(
|
||||
@@ -157,6 +153,7 @@ impl<T: InvokeUiSession> Remote<T> {
|
||||
}
|
||||
};
|
||||
|
||||
let mut last_recv_time = Instant::now();
|
||||
let mut received = false;
|
||||
let conn_type = if self.handler.is_file_transfer() {
|
||||
ConnType::FILE_TRANSFER
|
||||
@@ -222,7 +219,6 @@ impl<T: InvokeUiSession> Remote<T> {
|
||||
let mut fps_instant = Instant::now();
|
||||
|
||||
let _keep_it = client::hc_connection(feedback, rendezvous_server, token).await;
|
||||
let mut last_recv_time = Instant::now();
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
@@ -248,7 +244,7 @@ impl<T: InvokeUiSession> Remote<T> {
|
||||
} else {
|
||||
if self.handler.is_restarting_remote_device() {
|
||||
log::info!("Restart remote device");
|
||||
self.handler.msgbox("restarting", "Restarting remote device", "Connection in progress. Please wait.", "");
|
||||
self.handler.msgbox("restarting", "Restarting remote device", "remote_restarting_tip", "");
|
||||
} else {
|
||||
log::info!("Reset by the peer");
|
||||
self.handler.msgbox("error", "Connection Error", "Reset by the peer", "");
|
||||
@@ -283,12 +279,6 @@ impl<T: InvokeUiSession> Remote<T> {
|
||||
}
|
||||
}
|
||||
_ = status_timer.tick() => {
|
||||
if self.handler.is_restarting_remote_device()
|
||||
&& last_recv_time.elapsed() >= RESTART_REMOTE_DEVICE_NO_DATA_TIMEOUT
|
||||
{
|
||||
self.handler.msgbox("restarting-show", "Restarting remote device", "Connection in progress. Please wait.", "");
|
||||
break;
|
||||
}
|
||||
let elapsed = fps_instant.elapsed().as_millis();
|
||||
if elapsed < 1000 {
|
||||
continue;
|
||||
@@ -1436,11 +1426,7 @@ impl<T: InvokeUiSession> Remote<T> {
|
||||
self.handler.set_cursor_position(cp);
|
||||
}
|
||||
Some(message::Union::Clipboard(cb)) => {
|
||||
let clipboard_allowed = {
|
||||
let lc = self.handler.lc.read().unwrap();
|
||||
!lc.disable_clipboard.v && !lc.view_only.v
|
||||
};
|
||||
if clipboard_allowed {
|
||||
if !self.handler.lc.read().unwrap().disable_clipboard.v {
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
update_clipboard(vec![cb], ClipboardSide::Client);
|
||||
#[cfg(target_os = "ios")]
|
||||
@@ -1459,11 +1445,7 @@ impl<T: InvokeUiSession> Remote<T> {
|
||||
}
|
||||
}
|
||||
Some(message::Union::MultiClipboards(_mcb)) => {
|
||||
let clipboard_allowed = {
|
||||
let lc = self.handler.lc.read().unwrap();
|
||||
!lc.disable_clipboard.v && !lc.view_only.v
|
||||
};
|
||||
if clipboard_allowed {
|
||||
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")]
|
||||
@@ -1815,9 +1797,6 @@ 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);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -1941,23 +1920,9 @@ impl<T: InvokeUiSession> Remote<T> {
|
||||
);
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
Some(misc::Union::SwitchBack(_)) => {
|
||||
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(feature = "flutter")]
|
||||
self.handler.switch_back(&self.handler.get_id());
|
||||
}
|
||||
#[cfg(all(feature = "flutter", feature = "plugin_framework"))]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
|
||||
+6
-66
@@ -1,7 +1,5 @@
|
||||
#[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},
|
||||
@@ -56,27 +54,6 @@ 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();
|
||||
}
|
||||
@@ -87,7 +64,8 @@ fn read_clipboard_message(
|
||||
let mut msg = Message::new();
|
||||
let clipboards = proto::create_multi_clipboards(content);
|
||||
msg.set_multi_clipboards(clipboards.clone());
|
||||
return Some((msg, clipboards));
|
||||
*LAST_MULTI_CLIPBOARDS.lock().unwrap() = clipboards;
|
||||
return Some(msg);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -241,7 +219,10 @@ fn do_update_clipboard_(mut to_update_data: Vec<ClipboardData>, side: ClipboardS
|
||||
}
|
||||
}
|
||||
if let Some(ctx) = ctx.as_mut() {
|
||||
to_update_data = append_owner_marker(to_update_data, side);
|
||||
to_update_data.push(ClipboardData::Special((
|
||||
RUSTDESK_CLIPBOARD_OWNER_FORMAT.to_owned(),
|
||||
side.get_owner_data(),
|
||||
)));
|
||||
if let Err(e) = ctx.set(&to_update_data) {
|
||||
log::debug!("Failed to set clipboard: {}", e);
|
||||
} else {
|
||||
@@ -250,29 +231,6 @@ 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 || {
|
||||
@@ -424,24 +382,6 @@ 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>,
|
||||
|
||||
+9
-153
@@ -146,13 +146,7 @@ pub fn core_main() -> Option<Vec<String>> {
|
||||
crate::portable_service::client::set_quick_support(_is_quick_support);
|
||||
}
|
||||
let mut log_name = "".to_owned();
|
||||
// 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("--") {
|
||||
if args.len() > 0 && args[0].starts_with("--") {
|
||||
let name = args[0].replace("--", "");
|
||||
if !name.is_empty() {
|
||||
log_name = name;
|
||||
@@ -199,20 +193,6 @@ 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;
|
||||
@@ -262,9 +242,11 @@ pub fn core_main() -> Option<Vec<String>> {
|
||||
if config::is_disable_installation() {
|
||||
return None;
|
||||
}
|
||||
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);
|
||||
#[cfg(not(windows))]
|
||||
let options = "desktopicon startmenu";
|
||||
#[cfg(windows)]
|
||||
let options = "desktopicon startmenu printer";
|
||||
let res = platform::install_me(options, "".to_owned(), true, args.len() > 1);
|
||||
let text = match res {
|
||||
Ok(_) => translate("Installation Successful!".to_string()),
|
||||
Err(err) => {
|
||||
@@ -430,7 +412,7 @@ pub fn core_main() -> Option<Vec<String>> {
|
||||
}
|
||||
return None;
|
||||
} else if args[0] == "--password" {
|
||||
if is_cli_setting_change_disabled() {
|
||||
if config::is_disable_settings() {
|
||||
println!("Settings are disabled!");
|
||||
return None;
|
||||
}
|
||||
@@ -472,7 +454,7 @@ pub fn core_main() -> Option<Vec<String>> {
|
||||
println!("{}", crate::ipc::get_id());
|
||||
return None;
|
||||
} else if args[0] == "--set-id" {
|
||||
if is_cli_setting_change_disabled() {
|
||||
if config::is_disable_settings() {
|
||||
println!("Settings are disabled!");
|
||||
return None;
|
||||
}
|
||||
@@ -519,7 +501,7 @@ pub fn core_main() -> Option<Vec<String>> {
|
||||
}
|
||||
return None;
|
||||
} else if args[0] == "--option" {
|
||||
if is_cli_setting_change_disabled() {
|
||||
if config::is_disable_settings() {
|
||||
println!("Settings are disabled!");
|
||||
return None;
|
||||
}
|
||||
@@ -639,56 +621,6 @@ 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();
|
||||
@@ -908,82 +840,6 @@ 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)]
|
||||
|
||||
@@ -1135,10 +1135,6 @@ 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)));
|
||||
|
||||
+29
-68
@@ -326,14 +326,12 @@ pub fn session_toggle_option(session_id: SessionID, value: String) {
|
||||
try_sync_peer_option(&session, &session_id, &value, None);
|
||||
}
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
if sessions::get_session_by_session_id(&session_id).is_some()
|
||||
&& (value == "disable-clipboard" || value == "view-only")
|
||||
{
|
||||
if sessions::get_session_by_session_id(&session_id).is_some() && value == "disable-clipboard" {
|
||||
crate::flutter::update_text_clipboard_required();
|
||||
}
|
||||
#[cfg(feature = "unix-file-copy-paste")]
|
||||
if sessions::get_session_by_session_id(&session_id).is_some()
|
||||
&& (value == config::keys::OPTION_ENABLE_FILE_COPY_PASTE || value == "view-only")
|
||||
&& value == config::keys::OPTION_ENABLE_FILE_COPY_PASTE
|
||||
{
|
||||
crate::flutter::update_file_clipboard_required();
|
||||
}
|
||||
@@ -577,6 +575,7 @@ pub fn session_handle_flutter_key_event(
|
||||
if let Some(session) = sessions::get_session_by_session_id(&session_id) {
|
||||
let keyboard_mode = session.get_keyboard_mode();
|
||||
session.handle_flutter_key_event(
|
||||
session_id,
|
||||
&keyboard_mode,
|
||||
&character,
|
||||
usb_hid,
|
||||
@@ -597,6 +596,7 @@ pub fn session_handle_flutter_raw_key_event(
|
||||
if let Some(session) = sessions::get_session_by_session_id(&session_id) {
|
||||
let keyboard_mode = session.get_keyboard_mode();
|
||||
session.handle_flutter_raw_key_event(
|
||||
session_id,
|
||||
&keyboard_mode,
|
||||
&name,
|
||||
platform_code,
|
||||
@@ -974,27 +974,6 @@ 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(
|
||||
@@ -1042,29 +1021,7 @@ pub fn main_get_options_sync() -> SyncReturn<String> {
|
||||
}
|
||||
|
||||
pub fn main_set_options(json: String) {
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let map: HashMap<String, String> = serde_json::from_str(&json).unwrap_or(HashMap::new());
|
||||
if !map.is_empty() {
|
||||
set_options(map)
|
||||
}
|
||||
@@ -1155,22 +1112,6 @@ 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))
|
||||
}
|
||||
@@ -1789,6 +1730,7 @@ pub fn cm_get_clients_length() -> usize {
|
||||
|
||||
pub fn main_init(app_dir: String, custom_client_config: String) {
|
||||
initialize(&app_dir, &custom_client_config);
|
||||
crate::keyboard::shortcuts::reload_from_config();
|
||||
}
|
||||
|
||||
pub fn main_device_id(id: String) {
|
||||
@@ -2134,7 +2076,6 @@ 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();
|
||||
}
|
||||
}
|
||||
@@ -2232,7 +2173,7 @@ pub fn cm_elevate_portable(conn_id: i32) {
|
||||
}
|
||||
|
||||
pub fn cm_switch_back(conn_id: i32) {
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
#[cfg(not(any(target_os = "ios")))]
|
||||
crate::ui_cm_interface::switch_back(conn_id);
|
||||
}
|
||||
|
||||
@@ -2309,6 +2250,17 @@ pub fn main_init_input_source() -> SyncReturn<()> {
|
||||
SyncReturn(())
|
||||
}
|
||||
|
||||
pub fn main_reload_keyboard_shortcuts() -> SyncReturn<()> {
|
||||
crate::keyboard::shortcuts::reload_from_config();
|
||||
SyncReturn(())
|
||||
}
|
||||
|
||||
pub fn main_get_default_keyboard_shortcuts() -> SyncReturn<String> {
|
||||
let bindings = crate::keyboard::shortcuts::default_bindings();
|
||||
let json = serde_json::to_string(&bindings).unwrap_or_default();
|
||||
SyncReturn(json)
|
||||
}
|
||||
|
||||
pub fn main_is_installed_lower_version() -> SyncReturn<bool> {
|
||||
SyncReturn(is_installed_lower_version())
|
||||
}
|
||||
@@ -2490,13 +2442,23 @@ 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::is_using_preset_password();
|
||||
return config::Config::matches_permanent_password_plain(&hard);
|
||||
}
|
||||
|
||||
// Don't call this function for desktop version.
|
||||
@@ -3074,7 +3036,6 @@ 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();
|
||||
}
|
||||
|
||||
|
||||
+105
-600
@@ -1,28 +1,33 @@
|
||||
#[path = "ipc/auth.rs"]
|
||||
mod ipc_auth;
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
#[path = "ipc/fs.rs"]
|
||||
mod ipc_fs;
|
||||
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::*};
|
||||
|
||||
#[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::OPTION_ALLOW_WEBSOCKET, Config, Config2},
|
||||
config::{
|
||||
self,
|
||||
keys::{self, OPTION_ALLOW_WEBSOCKET},
|
||||
Config, Config2,
|
||||
},
|
||||
futures::StreamExt as _,
|
||||
futures_util::sink::SinkExt,
|
||||
log, password_security as password, timeout,
|
||||
@@ -33,92 +38,13 @@ use hbb_common::{
|
||||
tokio_util::codec::Framed,
|
||||
ResultType,
|
||||
};
|
||||
#[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},
|
||||
};
|
||||
|
||||
use crate::{common::is_server, privacy_mode, rendezvous_mediator::RendezvousMediator};
|
||||
|
||||
// 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 {
|
||||
@@ -281,8 +207,6 @@ 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)),
|
||||
@@ -313,7 +237,6 @@ pub enum Data {
|
||||
restart: bool,
|
||||
recording: bool,
|
||||
block_input: bool,
|
||||
privacy_mode: bool,
|
||||
from_switch: bool,
|
||||
},
|
||||
ChatMessage {
|
||||
@@ -349,7 +272,6 @@ 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")))]
|
||||
@@ -362,14 +284,7 @@ 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,
|
||||
@@ -488,22 +403,6 @@ 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 {
|
||||
@@ -512,48 +411,9 @@ 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -568,77 +428,20 @@ pub async fn start(postfix: &str) -> ResultType<()> {
|
||||
|
||||
pub async fn new_listener(postfix: &str) -> ResultType<Incoming> {
|
||||
let path = Config::ipc_path(postfix);
|
||||
#[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)?;
|
||||
}
|
||||
#[cfg(not(any(windows, target_os = "android", target_os = "ios")))]
|
||||
check_pid(postfix).await;
|
||||
let mut endpoint = Endpoint::new(path.clone());
|
||||
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 {
|
||||
match SecurityAttributes::allow_everyone_create() {
|
||||
Ok(attr) => endpoint.set_security_attributes(attr),
|
||||
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());
|
||||
}
|
||||
}
|
||||
Err(err) => log::error!("Failed to set ipc{} security: {}", postfix, err),
|
||||
};
|
||||
match endpoint.incoming() {
|
||||
Ok(incoming) => {
|
||||
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"))]
|
||||
log::info!("Started ipc{} server at path: {}", postfix, &path);
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
// 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());
|
||||
}
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o0777)).ok();
|
||||
write_pid(postfix);
|
||||
}
|
||||
Ok(incoming)
|
||||
@@ -839,7 +642,15 @@ async fn handle(data: Data, stream: &mut Connection) {
|
||||
"N".to_owned()
|
||||
});
|
||||
} else if name == "permanent-password-is-preset" {
|
||||
value = Some(if Config::is_using_preset_password() {
|
||||
let hard = config::HARD_SETTINGS
|
||||
.read()
|
||||
.unwrap()
|
||||
.get("password")
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let is_preset =
|
||||
!hard.is_empty() && Config::matches_permanent_password_plain(&hard);
|
||||
value = Some(if is_preset {
|
||||
"Y".to_owned()
|
||||
} else {
|
||||
"N".to_owned()
|
||||
@@ -890,7 +701,7 @@ async fn handle(data: Data, stream: &mut Connection) {
|
||||
log::warn!("Changing permanent password is disabled");
|
||||
updated = false;
|
||||
} else {
|
||||
updated = Config::set_permanent_password(&value);
|
||||
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
|
||||
@@ -959,12 +770,6 @@ 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());
|
||||
@@ -974,19 +779,6 @@ 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,
|
||||
@@ -1138,210 +930,13 @@ 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"),
|
||||
}
|
||||
}
|
||||
|
||||
#[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(¤t_exe)?;
|
||||
let current_pid = hbb_common::sysinfo::Pid::from_u32(std::process::id());
|
||||
let mut sys = hbb_common::sysinfo::System::new();
|
||||
sys.refresh_processes();
|
||||
let mut server_uids = Vec::new();
|
||||
for process in sys.processes().values() {
|
||||
if process.pid() == current_pid {
|
||||
continue;
|
||||
}
|
||||
if process.cmd().get(1).map_or(true, |arg| arg != "--server") {
|
||||
continue;
|
||||
}
|
||||
let Ok(process_path) = std::fs::canonicalize(process.exe()) else {
|
||||
continue;
|
||||
};
|
||||
if process_path != current_exe_path {
|
||||
continue;
|
||||
}
|
||||
let Some(uid) = process.user_id().map(|uid| **uid as u32) else {
|
||||
// Root CLI management commands need a stable matching `--server` target.
|
||||
// If this key process races during enumeration, failing the command is clearer
|
||||
// than silently skipping it; `--server` is not expected to exit frequently.
|
||||
bail!("Failed to read --server process uid");
|
||||
};
|
||||
server_uids.push(uid);
|
||||
}
|
||||
Ok(server_uids)
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
fn user_main_ipc_server_uid() -> ResultType<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
|
||||
let path = Config::ipc_path(postfix);
|
||||
let client = timeout(ms_timeout, Endpoint::connect(&path)).await??;
|
||||
Ok(ConnectionTmpl::new(client))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -1421,6 +1016,54 @@ 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>,
|
||||
}
|
||||
@@ -1542,6 +1185,11 @@ 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(())
|
||||
}
|
||||
@@ -1862,13 +1510,6 @@ 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")
|
||||
@@ -1886,10 +1527,9 @@ 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_service(1000).await?;
|
||||
let mut stream = crate::ipc::connect(1000, crate::POSTFIX_SERVICE).await?;
|
||||
timeout(1000, stream.send(&crate::ipc::Data::UserSid(usid))).await??;
|
||||
Ok(())
|
||||
}
|
||||
@@ -2015,76 +1655,13 @@ 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 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)
|
||||
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);
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
async fn handle_wayland_screencast_restore_token(
|
||||
@@ -2115,81 +1692,9 @@ 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
File diff suppressed because it is too large
Load Diff
-951
@@ -1,951 +0,0 @@
|
||||
#[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
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use crate::{client::get_key_state, common::GrabState};
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
use hbb_common::log;
|
||||
use hbb_common::message_proto::*;
|
||||
use hbb_common::SessionID;
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
use rdev::KeyCode;
|
||||
use rdev::{Event, EventType, Key};
|
||||
@@ -79,6 +80,8 @@ lazy_static::lazy_static! {
|
||||
};
|
||||
}
|
||||
|
||||
pub mod shortcuts;
|
||||
|
||||
pub mod client {
|
||||
use super::*;
|
||||
|
||||
@@ -319,6 +322,33 @@ pub mod client {
|
||||
}
|
||||
|
||||
pub fn process_event(keyboard_mode: &str, event: &Event, lock_modes: Option<i32>) {
|
||||
// Shortcut intercept — must come before any wire encoding.
|
||||
// Only fires on KeyPress (event_to_key_name in shortcuts.rs returns None
|
||||
// for KeyRelease and other non-press events), so flushed releases from
|
||||
// release_remote_keys pass straight through to the encode/forward path.
|
||||
if let Some(action_id) = crate::keyboard::shortcuts::match_event(event) {
|
||||
#[cfg(feature = "flutter")]
|
||||
{
|
||||
// The rdev grab loop is genuinely process-wide: it does not know which
|
||||
// Flutter SessionID the keystroke was meant for, so we route to the
|
||||
// globally-current session via flutter::get_cur_session_id() (maintained
|
||||
// by session_enter_or_leave). This is the only behavior available on the
|
||||
// rdev path; the Flutter path threads the explicit per-call SessionID
|
||||
// through process_event_with_session instead.
|
||||
let session_id = crate::flutter::get_cur_session_id();
|
||||
crate::flutter::push_session_event(
|
||||
&session_id,
|
||||
"shortcut_triggered",
|
||||
vec![("action", &action_id)],
|
||||
);
|
||||
}
|
||||
#[cfg(not(feature = "flutter"))]
|
||||
{
|
||||
let _ = action_id;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let keyboard_mode = get_keyboard_mode_enum(keyboard_mode);
|
||||
if is_long_press(&event) {
|
||||
return;
|
||||
@@ -334,7 +364,33 @@ pub mod client {
|
||||
event: &Event,
|
||||
lock_modes: Option<i32>,
|
||||
session: &Session<T>,
|
||||
session_id: SessionID,
|
||||
) {
|
||||
// Shortcut intercept — must come before any wire encoding.
|
||||
// Only fires on KeyPress (event_to_key_name in shortcuts.rs returns None
|
||||
// for KeyRelease and other non-press events), so flushed releases from
|
||||
// release_remote_keys pass straight through to the encode/forward path.
|
||||
if let Some(action_id) = crate::keyboard::shortcuts::match_event(event) {
|
||||
#[cfg(feature = "flutter")]
|
||||
{
|
||||
// The Flutter path threads the explicit SessionID from the FFI entry
|
||||
// (session_handle_flutter_*key_event) through this call, so the dispatch
|
||||
// targets the exact tab the keystroke originated from — no dependency on
|
||||
// the global focus tracker and no multi-window race.
|
||||
crate::flutter::push_session_event(
|
||||
&session_id,
|
||||
"shortcut_triggered",
|
||||
vec![("action", &action_id)],
|
||||
);
|
||||
}
|
||||
#[cfg(not(feature = "flutter"))]
|
||||
{
|
||||
let _ = action_id;
|
||||
let _ = session_id;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let keyboard_mode = get_keyboard_mode_enum(keyboard_mode);
|
||||
if is_long_press(&event) {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
//! Keyboard shortcuts for triggering session actions locally.
|
||||
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const LOCAL_CONFIG_KEY: &str = "keyboard-shortcuts";
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref CACHE: RwLock<Arc<Bindings>> = RwLock::new(Arc::new(Bindings::default()));
|
||||
}
|
||||
|
||||
/// Registry of all valid action ids that may appear in `Binding.action`.
|
||||
/// Source-of-truth lives on the Flutter side (`flutter/lib/consts.dart`,
|
||||
/// `kShortcutAction*`); these mirror that vocabulary so Rust code can reach
|
||||
/// for them without re-stringifying.
|
||||
#[allow(dead_code)]
|
||||
pub mod action_id {
|
||||
pub const SEND_CTRL_ALT_DEL: &str = "send_ctrl_alt_del";
|
||||
pub const TOGGLE_FULLSCREEN: &str = "toggle_fullscreen";
|
||||
pub const SWITCH_DISPLAY_NEXT: &str = "switch_display_next";
|
||||
pub const SWITCH_DISPLAY_PREV: &str = "switch_display_prev";
|
||||
pub const SCREENSHOT: &str = "screenshot";
|
||||
pub const INSERT_LOCK: &str = "insert_lock";
|
||||
pub const REFRESH: &str = "refresh";
|
||||
pub const TOGGLE_AUDIO: &str = "toggle_audio";
|
||||
pub const TOGGLE_BLOCK_INPUT: &str = "toggle_block_input";
|
||||
pub const TOGGLE_RECORDING: &str = "toggle_recording";
|
||||
pub const TOGGLE_PRIVACY_MODE: &str = "toggle_privacy_mode";
|
||||
pub const VIEW_MODE_1_TO_1: &str = "view_mode_1_to_1";
|
||||
pub const VIEW_MODE_SHRINK: &str = "view_mode_shrink";
|
||||
pub const VIEW_MODE_STRETCH: &str = "view_mode_stretch";
|
||||
pub const SWITCH_SIDES: &str = "switch_sides";
|
||||
// switch_tab_1 .. switch_tab_9 are generated below.
|
||||
}
|
||||
|
||||
pub fn switch_tab_action_id(n: u8) -> Option<&'static str> {
|
||||
match n {
|
||||
1 => Some("switch_tab_1"),
|
||||
2 => Some("switch_tab_2"),
|
||||
3 => Some("switch_tab_3"),
|
||||
4 => Some("switch_tab_4"),
|
||||
5 => Some("switch_tab_5"),
|
||||
6 => Some("switch_tab_6"),
|
||||
7 => Some("switch_tab_7"),
|
||||
8 => Some("switch_tab_8"),
|
||||
9 => Some("switch_tab_9"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Modifier {
|
||||
Primary,
|
||||
Alt,
|
||||
Shift,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Binding {
|
||||
pub action: String,
|
||||
pub mods: Vec<Modifier>,
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct Bindings {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub bindings: Vec<Binding>,
|
||||
}
|
||||
|
||||
pub fn default_bindings() -> Vec<Binding> {
|
||||
let prefix = || vec![Modifier::Primary, Modifier::Alt, Modifier::Shift];
|
||||
let mut v = vec![
|
||||
Binding { action: action_id::SEND_CTRL_ALT_DEL.into(), mods: prefix(), key: "delete".into() },
|
||||
Binding { action: action_id::TOGGLE_FULLSCREEN.into(), mods: prefix(), key: "enter".into() },
|
||||
Binding { action: action_id::SWITCH_DISPLAY_NEXT.into(), mods: prefix(), key: "arrow_right".into() },
|
||||
Binding { action: action_id::SWITCH_DISPLAY_PREV.into(), mods: prefix(), key: "arrow_left".into() },
|
||||
Binding { action: action_id::SCREENSHOT.into(), mods: prefix(), key: "p".into() },
|
||||
];
|
||||
for n in 1..=9u8 {
|
||||
if let Some(action) = switch_tab_action_id(n) {
|
||||
v.push(Binding {
|
||||
action: action.into(),
|
||||
mods: prefix(),
|
||||
key: format!("digit{n}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Match a normalized (key, modifiers) pair against the given bindings.
|
||||
/// Returns the matched action ID, or None.
|
||||
pub fn match_normalized<'a>(key: &str, mods: &[Modifier], b: &'a Bindings) -> Option<&'a str> {
|
||||
if !b.enabled {
|
||||
return None;
|
||||
}
|
||||
for binding in &b.bindings {
|
||||
if binding.key == key && mods_equal(&binding.mods, mods) {
|
||||
return Some(binding.action.as_str());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn normalize_modifiers(alt: bool, ctrl: bool, shift: bool, command: bool) -> Vec<Modifier> {
|
||||
let mut v = Vec::new();
|
||||
let primary = if cfg!(target_os = "macos") { command } else { ctrl };
|
||||
if primary { v.push(Modifier::Primary); }
|
||||
if alt { v.push(Modifier::Alt); }
|
||||
if shift { v.push(Modifier::Shift); }
|
||||
v
|
||||
}
|
||||
|
||||
/// Map an rdev::Event to a string key name, matching the storage schema.
|
||||
/// Returns None for events we don't intercept (modifier-only presses, releases, etc.).
|
||||
pub fn event_to_key_name(event: &rdev::Event) -> Option<String> {
|
||||
use rdev::{EventType, Key};
|
||||
let key = match event.event_type {
|
||||
EventType::KeyPress(k) => k,
|
||||
_ => return None,
|
||||
};
|
||||
Some(match key {
|
||||
Key::Delete => "delete".into(),
|
||||
Key::Return => "enter".into(),
|
||||
Key::LeftArrow => "arrow_left".into(),
|
||||
Key::RightArrow => "arrow_right".into(),
|
||||
Key::UpArrow => "arrow_up".into(),
|
||||
Key::DownArrow => "arrow_down".into(),
|
||||
Key::KeyA => "a".into(),
|
||||
Key::KeyB => "b".into(),
|
||||
Key::KeyC => "c".into(),
|
||||
Key::KeyD => "d".into(),
|
||||
Key::KeyE => "e".into(),
|
||||
Key::KeyF => "f".into(),
|
||||
Key::KeyG => "g".into(),
|
||||
Key::KeyH => "h".into(),
|
||||
Key::KeyI => "i".into(),
|
||||
Key::KeyJ => "j".into(),
|
||||
Key::KeyK => "k".into(),
|
||||
Key::KeyL => "l".into(),
|
||||
Key::KeyM => "m".into(),
|
||||
Key::KeyN => "n".into(),
|
||||
Key::KeyO => "o".into(),
|
||||
Key::KeyP => "p".into(),
|
||||
Key::KeyQ => "q".into(),
|
||||
Key::KeyR => "r".into(),
|
||||
Key::KeyS => "s".into(),
|
||||
Key::KeyT => "t".into(),
|
||||
Key::KeyU => "u".into(),
|
||||
Key::KeyV => "v".into(),
|
||||
Key::KeyW => "w".into(),
|
||||
Key::KeyX => "x".into(),
|
||||
Key::KeyY => "y".into(),
|
||||
Key::KeyZ => "z".into(),
|
||||
Key::Num1 => "digit1".into(),
|
||||
Key::Num2 => "digit2".into(),
|
||||
Key::Num3 => "digit3".into(),
|
||||
Key::Num4 => "digit4".into(),
|
||||
Key::Num5 => "digit5".into(),
|
||||
Key::Num6 => "digit6".into(),
|
||||
Key::Num7 => "digit7".into(),
|
||||
Key::Num8 => "digit8".into(),
|
||||
Key::Num9 => "digit9".into(),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Read keyboard-shortcut bindings from `LocalConfig` and refresh the cache.
|
||||
///
|
||||
/// Empty or invalid JSON falls back to `Bindings::default()` (disabled, no
|
||||
/// bindings). Call this once at startup and again whenever the config is
|
||||
/// written.
|
||||
pub fn reload_from_config() {
|
||||
let raw = hbb_common::config::LocalConfig::get_option(LOCAL_CONFIG_KEY);
|
||||
let parsed = if raw.is_empty() {
|
||||
Bindings::default()
|
||||
} else {
|
||||
serde_json::from_str(&raw).unwrap_or_default()
|
||||
};
|
||||
if let Ok(mut w) = CACHE.write() {
|
||||
*w = Arc::new(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot of the currently cached bindings. Cheap (one atomic increment) —
|
||||
/// safe to call on every keystroke.
|
||||
pub fn current() -> Arc<Bindings> {
|
||||
CACHE
|
||||
.read()
|
||||
.map(|b| Arc::clone(&b))
|
||||
.unwrap_or_else(|_| Arc::new(Bindings::default()))
|
||||
}
|
||||
|
||||
/// Match an `rdev::Event` against the cached bindings. Returns the matched
|
||||
/// action id, or `None` if no binding fires. The Flutter side ignores unknown
|
||||
/// action ids (logged as "no handler"), so no whitelist check is needed here.
|
||||
pub fn match_event(event: &rdev::Event) -> Option<String> {
|
||||
let bindings = current();
|
||||
if !bindings.enabled {
|
||||
return None;
|
||||
}
|
||||
let key_name = event_to_key_name(event)?;
|
||||
let (alt, ctrl, shift, command) =
|
||||
crate::keyboard::client::get_modifiers_state(false, false, false, false);
|
||||
let mods = normalize_modifiers(alt, ctrl, shift, command);
|
||||
match_normalized(&key_name, &mods, &bindings).map(str::to_owned)
|
||||
}
|
||||
|
||||
fn mods_bits(m: &[Modifier]) -> u8 {
|
||||
let mut bits = 0u8;
|
||||
for x in m {
|
||||
bits |= match x {
|
||||
Modifier::Primary => 1,
|
||||
Modifier::Alt => 2,
|
||||
Modifier::Shift => 4,
|
||||
};
|
||||
}
|
||||
bits
|
||||
}
|
||||
|
||||
fn mods_equal(a: &[Modifier], b: &[Modifier]) -> bool {
|
||||
mods_bits(a) == mods_bits(b)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bindings_round_trip_json() {
|
||||
let json = r#"{
|
||||
"enabled": true,
|
||||
"bindings": [
|
||||
{"action": "send_ctrl_alt_del", "mods": ["primary","alt","shift"], "key": "delete"},
|
||||
{"action": "toggle_fullscreen", "mods": ["primary","alt","shift"], "key": "enter"}
|
||||
]
|
||||
}"#;
|
||||
let parsed: Bindings = serde_json::from_str(json).expect("parse");
|
||||
assert!(parsed.enabled);
|
||||
assert_eq!(parsed.bindings.len(), 2);
|
||||
assert_eq!(parsed.bindings[0].action, "send_ctrl_alt_del");
|
||||
assert_eq!(parsed.bindings[0].key, "delete");
|
||||
|
||||
let serialized = serde_json::to_string(&parsed).expect("serialize");
|
||||
let reparsed: Bindings = serde_json::from_str(&serialized).expect("reparse");
|
||||
assert_eq!(parsed, reparsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_match_design_doc() {
|
||||
let defaults = default_bindings();
|
||||
let actions: Vec<&str> = defaults.iter().map(|b| b.action.as_str()).collect();
|
||||
assert!(actions.contains(&action_id::SEND_CTRL_ALT_DEL));
|
||||
assert!(actions.contains(&action_id::TOGGLE_FULLSCREEN));
|
||||
assert!(actions.contains(&action_id::SWITCH_DISPLAY_NEXT));
|
||||
assert!(actions.contains(&action_id::SWITCH_DISPLAY_PREV));
|
||||
assert!(actions.contains(&action_id::SCREENSHOT));
|
||||
assert!(actions.contains(&"switch_tab_1"));
|
||||
assert!(actions.contains(&"switch_tab_9"));
|
||||
// every default binding includes the three-modifier prefix
|
||||
for b in &defaults {
|
||||
assert!(b.mods.contains(&Modifier::Primary));
|
||||
assert!(b.mods.contains(&Modifier::Alt));
|
||||
assert!(b.mods.contains(&Modifier::Shift));
|
||||
}
|
||||
}
|
||||
|
||||
fn match_for_test<'a>(key: &str, mods: &[Modifier], b: &'a Bindings) -> Option<&'a str> {
|
||||
match_normalized(key, mods, b)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_returns_none_when_disabled() {
|
||||
let bindings = Bindings { enabled: false, bindings: default_bindings() };
|
||||
let result = match_for_test("p", &[Modifier::Primary, Modifier::Alt, Modifier::Shift], &bindings);
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_screenshot_when_enabled() {
|
||||
let bindings = Bindings { enabled: true, bindings: default_bindings() };
|
||||
let result = match_for_test("p", &[Modifier::Primary, Modifier::Alt, Modifier::Shift], &bindings);
|
||||
assert_eq!(result, Some(action_id::SCREENSHOT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_returns_none_when_modifiers_partial() {
|
||||
let bindings = Bindings { enabled: true, bindings: default_bindings() };
|
||||
// missing Shift
|
||||
let result = match_for_test("p", &[Modifier::Primary, Modifier::Alt], &bindings);
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_does_not_fire_on_extra_unbound_keys() {
|
||||
let bindings = Bindings { enabled: true, bindings: default_bindings() };
|
||||
let result = match_for_test("z", &[Modifier::Primary, Modifier::Alt, Modifier::Shift], &bindings);
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_handles_duplicate_modifiers_in_input() {
|
||||
// A user-edited config could contain duplicate modifiers; the matcher must
|
||||
// treat the modifier list as a set, not a multiset.
|
||||
let bindings = Bindings {
|
||||
enabled: true,
|
||||
bindings: vec![Binding {
|
||||
action: "x".into(),
|
||||
mods: vec![Modifier::Primary, Modifier::Alt],
|
||||
key: "a".into(),
|
||||
}],
|
||||
};
|
||||
// Caller passes Primary twice — must not match a binding with Primary+Alt.
|
||||
assert_eq!(
|
||||
match_normalized("a", &[Modifier::Primary, Modifier::Primary], &bindings),
|
||||
None,
|
||||
);
|
||||
// Caller passes Primary+Alt with one duplicate — should still match.
|
||||
assert_eq!(
|
||||
match_normalized("a", &[Modifier::Primary, Modifier::Alt, Modifier::Alt], &bindings),
|
||||
Some("x"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modifier_normalization_primary_resolves_per_os() {
|
||||
// On Win/Linux: pressing Ctrl satisfies Primary
|
||||
let mods = normalize_modifiers(/*alt=*/true, /*ctrl=*/true, /*shift=*/true, /*command=*/false);
|
||||
if cfg!(target_os = "macos") {
|
||||
// On macOS Ctrl is NOT primary
|
||||
assert!(!mods.contains(&Modifier::Primary));
|
||||
} else {
|
||||
assert!(mods.contains(&Modifier::Primary));
|
||||
}
|
||||
assert!(mods.contains(&Modifier::Alt));
|
||||
assert!(mods.contains(&Modifier::Shift));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modifier_normalization_command_is_primary_on_mac() {
|
||||
let mods = normalize_modifiers(true, false, true, /*command=*/true);
|
||||
if cfg!(target_os = "macos") {
|
||||
assert!(mods.contains(&Modifier::Primary));
|
||||
} else {
|
||||
// On Win/Linux Command/Meta is NOT primary
|
||||
assert!(!mods.contains(&Modifier::Primary));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reload_handles_missing_and_invalid_json() {
|
||||
// empty (no value set) → defaults
|
||||
hbb_common::config::LocalConfig::set_option(LOCAL_CONFIG_KEY.into(), String::new());
|
||||
reload_from_config();
|
||||
let b = current();
|
||||
assert!(!b.enabled);
|
||||
assert!(b.bindings.is_empty());
|
||||
|
||||
// invalid JSON → defaults (no panic)
|
||||
hbb_common::config::LocalConfig::set_option(LOCAL_CONFIG_KEY.into(), "not json".into());
|
||||
reload_from_config();
|
||||
let b = current();
|
||||
assert!(!b.enabled);
|
||||
}
|
||||
}
|
||||
@@ -743,20 +743,5 @@ 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();
|
||||
}
|
||||
|
||||
+2
-17
@@ -360,7 +360,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Recording", "Запіс"),
|
||||
("Directory", "Каталог"),
|
||||
("Automatically record incoming sessions", "Аўтаматычна запісваць уваходныя сесіі"),
|
||||
("Automatically record outgoing sessions", "Аўтаматычна запісваць выходныя сесіі"),
|
||||
("Automatically record outgoing sessions", ""),
|
||||
("Change", "Змяніць"),
|
||||
("Start session recording", "Пачаць запіс сесіі"),
|
||||
("Stop session recording", "Спыніць запіс сесіі"),
|
||||
@@ -379,7 +379,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Screen Share", "Дэманстрацыя экрана"),
|
||||
("ubuntu-21-04-required", "Wayland патрабуе Ubuntu версіі 21.04 або навейшай."),
|
||||
("wayland-requires-higher-linux-version", "Для Wayland патрабуецца вышэйшая версія дыстрыбутыва Linux. Карыстайцеся працоўным сталом X11 або зменіце сваю АС."),
|
||||
("xdp-portal-unavailable", "Не ўдалося захапіць экран Wayland. Магчыма, XDG Desktop Portal завяршыўся аварыйна або недаступны. Паспрабуйце перазапусціць яго камандай `systemctl --user restart xdg-desktop-portal`."),
|
||||
("xdp-portal-unavailable", ""),
|
||||
("JumpLink", "Прагляд"),
|
||||
("Please Select the screen to be shared(Operate on the peer side).", "Выберыце экран для дэманстрацыі (кіруецца на баку абанента)."),
|
||||
("Show RustDesk", "Паказаць RustDesk"),
|
||||
@@ -743,20 +743,5 @@ 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)", "Карыстальніцкі ID (неабавязкова)"),
|
||||
("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();
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user