mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-07-03 05:44:54 +03:00
Compare commits
121 Commits
terminal-u
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bbadbad21 | ||
|
|
a2b79462ab | ||
|
|
dce221be5a | ||
|
|
9d1ab3fba3 | ||
|
|
b3bd18845d | ||
|
|
435f6ec61d | ||
|
|
0497814004 | ||
|
|
4b1ef9e20d | ||
|
|
10d5250d23 | ||
|
|
2ee580d49d | ||
|
|
4a54029cac | ||
|
|
001848bf2f | ||
|
|
989bf80fe8 | ||
|
|
78b5f47668 | ||
|
|
97e9e44faa | ||
|
|
ff226f6d80 | ||
|
|
0cbdb6ffb3 | ||
|
|
b8117c5c34 | ||
|
|
a69614d464 | ||
|
|
58ee593e26 | ||
|
|
09bc9056c9 | ||
|
|
0c6df924d1 | ||
|
|
456817b4f4 | ||
|
|
16570ee34f | ||
|
|
2b40c61d8e | ||
|
|
dcc64cdeae | ||
|
|
2747d3d8b4 | ||
|
|
3c574a4182 | ||
|
|
311d4708e5 | ||
|
|
5cf4323d07 | ||
|
|
3976701ac6 | ||
|
|
9ded8d6ab2 | ||
|
|
cd2fff0655 | ||
|
|
10f61ffdc2 | ||
|
|
d72952bf93 | ||
|
|
a7c55db9ac | ||
|
|
bff47e2b81 | ||
|
|
3d478c4935 | ||
|
|
a658e987b7 | ||
|
|
7c8b0adc1e | ||
|
|
a732ebc3e1 | ||
|
|
30c0867e40 | ||
|
|
8f50ea64dc | ||
|
|
0797ebb695 | ||
|
|
c9391fb894 | ||
|
|
8a955888bf | ||
|
|
36e812e550 | ||
|
|
8baa995c7a | ||
|
|
f4a0535289 | ||
|
|
6665242edf | ||
|
|
3cdf1cce54 | ||
|
|
88ae00ba73 | ||
|
|
7c26575dbd | ||
|
|
93d064a9b0 | ||
|
|
bf206dc309 | ||
|
|
6d116cf1c9 | ||
|
|
1fc33218dc | ||
|
|
b73e5bbfa0 | ||
|
|
78533e428e | ||
|
|
cc7fe4efdc | ||
|
|
84af60c07e | ||
|
|
6426269d41 | ||
|
|
7c41f993fe | ||
|
|
0c86d46162 | ||
|
|
e87797418f | ||
|
|
78a3a2aeb9 | ||
|
|
e18cf7a245 | ||
|
|
50d5823ef5 | ||
|
|
518296f257 | ||
|
|
3217125dd3 | ||
|
|
00032854eb | ||
|
|
d99ddf6816 | ||
|
|
32c6e32e04 | ||
|
|
c55b1f3359 | ||
|
|
fabeae4180 | ||
|
|
5eed50961d | ||
|
|
bed0976eb9 | ||
|
|
70d92d9b07 | ||
|
|
fb4ba31504 | ||
|
|
152c5c71b1 | ||
|
|
fa369365a5 | ||
|
|
7345366ba7 | ||
|
|
6151ea7128 | ||
|
|
440ab26b69 | ||
|
|
caadd72ab2 | ||
|
|
d59d543ec1 | ||
|
|
58d1109510 | ||
|
|
9c52e25a6a | ||
|
|
62a44c5a09 | ||
|
|
e5fa40e903 | ||
|
|
8177083992 | ||
|
|
4bfd8e9f61 | ||
|
|
81e7d27ec8 | ||
|
|
f3fc0b5ac2 | ||
|
|
fb7bca436b | ||
|
|
c19a0ceba2 | ||
|
|
1f26e452fc | ||
|
|
0af6b7ede9 | ||
|
|
6ad56075d6 | ||
|
|
b81ae6c894 | ||
|
|
546e9f1702 | ||
|
|
bb51c6aa42 | ||
|
|
78e8134ad5 | ||
|
|
bc2c36215d | ||
|
|
377547fa11 | ||
|
|
472c4fc03a | ||
|
|
9f8f726f12 | ||
|
|
701a9c6cdc | ||
|
|
0d40cf2101 | ||
|
|
dd265dadd7 | ||
|
|
fe5a8cb2ad | ||
|
|
b6caa1a7b2 | ||
|
|
55c9707639 | ||
|
|
d8808baa83 | ||
|
|
1978020d27 | ||
|
|
0e4b91b8d7 | ||
|
|
9c831dc59b | ||
|
|
b757e97c11 | ||
|
|
9df486a689 | ||
|
|
72d27c3c47 | ||
|
|
6c20fc936d |
@@ -2,6 +2,8 @@
|
||||
rustflags = ["-Ctarget-feature=+crt-static"]
|
||||
[target.i686-pc-windows-msvc]
|
||||
rustflags = ["-C", "target-feature=+crt-static", "-C", "link-args=/NODEFAULTLIB:MSVCRT"]
|
||||
[target.aarch64-pc-windows-msvc]
|
||||
rustflags = ["-Ctarget-feature=+crt-static"]
|
||||
[target.'cfg(target_os="macos")']
|
||||
rustflags = [
|
||||
"-C", "link-args=-sectcreate __CGPreLoginApp __cgpreloginapp /dev/null",
|
||||
|
||||
39
.github/patches/apply_flutter_3.44_source_patches.sh
vendored
Normal file
39
.github/patches/apply_flutter_3.44_source_patches.sh
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
# Applies the Flutter 3.44-only source/pubspec changes on the fly, in CI only.
|
||||
#
|
||||
# Windows arm64 needs Flutter >= 3.44 (the first stable release shipping an arm64 Dart SDK +
|
||||
# engine), which renamed DialogTheme/TabBarTheme -> *Data and needs newer extended_text/
|
||||
# google_fonts. Every other platform is still on Flutter 3.24.5, where the old names/versions
|
||||
# are required, so these changes are kept OUT of the committed sources and applied here instead.
|
||||
#
|
||||
# Used by BOTH the Windows arm64 build (flutter-build.yml) and its dedicated bridge artifact
|
||||
# (bridge.yml) so they share an identical 3.44 source state -- the generated *.freezed.dart must
|
||||
# compile against the same Flutter/freezed version the arm64 build resolves.
|
||||
#
|
||||
# Remove this script (and commit the changes) once upstream bumps Flutter across the board.
|
||||
#
|
||||
# Run from the repository root. sed is used (not a git-apply patch) because the checked-out
|
||||
# sources are CRLF on the windows-11-arm runner; the substitutions below are anchor-free and
|
||||
# therefore CRLF-safe.
|
||||
set -euo pipefail
|
||||
|
||||
# ThemeData API renames (Flutter 3.27+):
|
||||
sed -i 's/dialogTheme: DialogTheme(/dialogTheme: DialogThemeData(/g' flutter/lib/common.dart
|
||||
sed -i 's/tabBarTheme: const TabBarTheme(/tabBarTheme: const TabBarThemeData(/g' flutter/lib/common.dart
|
||||
sed -i '/static ThemeData lightTheme = ThemeData(/,/static ThemeData darkTheme = ThemeData(/s/dialogTheme: DialogThemeData(/dialogTheme: DialogThemeData(\
|
||||
backgroundColor: Colors.white,/' flutter/lib/common.dart
|
||||
sed -i '/static ThemeData darkTheme = ThemeData(/,/scrollbarTheme: scrollbarThemeDark,/s/dialogTheme: DialogThemeData(/dialogTheme: DialogThemeData(\
|
||||
backgroundColor: Color(0xFF18191E),/' flutter/lib/common.dart
|
||||
# Dependency bumps required by the newer Dart/Flutter:
|
||||
sed -i 's/extended_text: 14.0.0/extended_text: 15.0.2/' flutter/pubspec.yaml
|
||||
sed -i 's/google_fonts: \^6.2.1/google_fonts: ^8.1.0/' flutter/pubspec.yaml
|
||||
|
||||
# Fail loudly if any expected string drifted, so we never silently build unpatched:
|
||||
grep -qF 'dialogTheme: DialogThemeData(' flutter/lib/common.dart
|
||||
grep -qF 'tabBarTheme: const TabBarThemeData(' flutter/lib/common.dart
|
||||
grep -qF 'backgroundColor: Colors.white,' flutter/lib/common.dart
|
||||
grep -qF 'backgroundColor: Color(0xFF18191E),' flutter/lib/common.dart
|
||||
grep -qF 'extended_text: 15.0.2' flutter/pubspec.yaml
|
||||
grep -qF 'google_fonts: ^8.1.0' flutter/pubspec.yaml
|
||||
|
||||
git --no-pager diff -- flutter/lib/common.dart flutter/pubspec.yaml
|
||||
40
.github/workflows/bridge.yml
vendored
40
.github/workflows/bridge.yml
vendored
@@ -7,7 +7,6 @@ on:
|
||||
|
||||
env:
|
||||
CARGO_EXPAND_VERSION: "1.0.95"
|
||||
FLUTTER_VERSION: "3.22.3"
|
||||
FLUTTER_RUST_BRIDGE_VERSION: "1.80.1"
|
||||
RUST_VERSION: "1.75" # https://github.com/rustdesk/rustdesk/discussions/7503
|
||||
|
||||
@@ -18,14 +17,25 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
job:
|
||||
# Default bridge for every platform still on Flutter 3.24.5 (generated with 3.22.3).
|
||||
- {
|
||||
target: x86_64-unknown-linux-gnu,
|
||||
os: ubuntu-22.04,
|
||||
extra-build-args: "",
|
||||
flutter-version: "3.22.3",
|
||||
artifact-name: "bridge-artifact",
|
||||
}
|
||||
# Dedicated bridge for the Windows arm64 build (Flutter 3.44); runs in parallel.
|
||||
- {
|
||||
target: x86_64-unknown-linux-gnu,
|
||||
os: ubuntu-22.04,
|
||||
extra-build-args: "",
|
||||
flutter-version: "3.44.0",
|
||||
artifact-name: "bridge-artifact-flutter-3.44",
|
||||
}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
@@ -49,28 +59,28 @@ jobs:
|
||||
wget
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@v1
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
|
||||
with:
|
||||
toolchain: ${{ env.RUST_VERSION }}
|
||||
targets: ${{ matrix.job.target }}
|
||||
components: "rustfmt"
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
with:
|
||||
prefix-key: bridge-${{ matrix.job.os }}
|
||||
|
||||
- name: Cache Bridge
|
||||
id: cache-bridge
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@6f8efc29b200d32929f49075959781ed54ec270c # v3
|
||||
with:
|
||||
path: /tmp/flutter_rust_bridge
|
||||
key: vcpkg-${{ matrix.job.arch }}
|
||||
key: bridge-${{ matrix.job.flutter-version }}
|
||||
|
||||
- name: Install flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
flutter-version: ${{ matrix.job.flutter-version }}
|
||||
cache: true
|
||||
|
||||
- name: Install flutter rust bridge deps
|
||||
@@ -78,7 +88,15 @@ jobs:
|
||||
run: |
|
||||
cargo install cargo-expand --version ${{ env.CARGO_EXPAND_VERSION }} --locked
|
||||
cargo install flutter_rust_bridge_codegen --version ${{ env.FLUTTER_RUST_BRIDGE_VERSION }} --features "uuid" --locked
|
||||
pushd flutter && sed -i -e 's/extended_text: 14.0.0/extended_text: 13.0.0/g' pubspec.yaml && flutter pub get && popd
|
||||
if [[ "${{ matrix.job.flutter-version }}" == "3.22.3" ]]; then
|
||||
# Default Flutter 3.22.3: extended_text 14 needs a newer Dart, so downgrade for resolution.
|
||||
sed -i -e 's/extended_text: 14.0.0/extended_text: 13.0.0/g' flutter/pubspec.yaml
|
||||
else
|
||||
# Flutter 3.44 bridge for Windows arm64: match that build's source/pubspec state so the
|
||||
# generated *.freezed.dart compiles against the same Flutter/freezed it resolves.
|
||||
bash .github/patches/apply_flutter_3.44_source_patches.sh
|
||||
fi
|
||||
pushd flutter && flutter pub get && popd
|
||||
|
||||
- name: Run flutter rust bridge
|
||||
run: |
|
||||
@@ -86,9 +104,9 @@ jobs:
|
||||
cp ./flutter/macos/Runner/bridge_generated.h ./flutter/ios/Runner/bridge_generated.h
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@master
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: bridge-artifact
|
||||
name: ${{ matrix.job.artifact-name }}
|
||||
path: |
|
||||
./src/bridge_generated.rs
|
||||
./src/bridge_generated.io.rs
|
||||
|
||||
31
.github/workflows/ci.yml
vendored
31
.github/workflows/ci.yml
vendored
@@ -29,13 +29,13 @@ jobs:
|
||||
# name: Ensure 'cargo fmt' has been run
|
||||
# runs-on: ubuntu-20.04
|
||||
# steps:
|
||||
# - uses: actions-rs/toolchain@v1
|
||||
# - uses: actions-rs/toolchain@16499b5e05bf2e26879000db0c1d13f7e13fa3af # v1
|
||||
# with:
|
||||
# toolchain: stable
|
||||
# default: true
|
||||
# profile: minimal
|
||||
# components: rustfmt
|
||||
# - uses: actions/checkout@v3
|
||||
# - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
|
||||
# - run: cargo fmt -- --check
|
||||
|
||||
# min_version:
|
||||
@@ -43,24 +43,24 @@ jobs:
|
||||
# runs-on: ubuntu-20.04
|
||||
# steps:
|
||||
# - name: Checkout source code
|
||||
# uses: actions/checkout@v3
|
||||
# uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
|
||||
# with:
|
||||
# submodules: recursive
|
||||
|
||||
# - name: Install rust toolchain (v${{ env.MIN_SUPPORTED_RUST_VERSION }})
|
||||
# uses: actions-rs/toolchain@v1
|
||||
# uses: actions-rs/toolchain@16499b5e05bf2e26879000db0c1d13f7e13fa3af # v1
|
||||
# with:
|
||||
# toolchain: ${{ env.MIN_SUPPORTED_RUST_VERSION }}
|
||||
# default: true
|
||||
# profile: minimal # minimal component installation (ie, no documentation)
|
||||
# components: clippy
|
||||
# - name: Run clippy (on minimum supported rust version to prevent warnings we can't fix)
|
||||
# uses: actions-rs/cargo@v1
|
||||
# uses: actions-rs/cargo@844f36862e911db73fe0815f00a4a2602c279505 # v1
|
||||
# with:
|
||||
# command: clippy
|
||||
# args: --locked --all-targets --all-features -- --allow clippy::unknown_clippy_lints
|
||||
# - name: Run tests
|
||||
# uses: actions-rs/cargo@v1
|
||||
# uses: actions-rs/cargo@844f36862e911db73fe0815f00a4a2602c279505 # v1
|
||||
# with:
|
||||
# command: test
|
||||
# args: --locked
|
||||
@@ -81,14 +81,15 @@ jobs:
|
||||
# - { target: x86_64-apple-darwin , os: macos-10.15 }
|
||||
# - { target: x86_64-pc-windows-gnu , os: windows-2022 }
|
||||
# - { target: x86_64-pc-windows-msvc , os: windows-2022 }
|
||||
# - { target: aarch64-pc-windows-msvc , os: windows-11-arm }
|
||||
- { target: x86_64-unknown-linux-gnu , os: ubuntu-24.04 }
|
||||
# - { target: x86_64-unknown-linux-musl , os: ubuntu-20.04, use-cross: true }
|
||||
steps:
|
||||
- name: Free Disk Space (Ubuntu)
|
||||
if: runner.os == 'Linux'
|
||||
# jlumbroso/free-disk-space@main is used in .github\workflows\flutter-build.yml
|
||||
# jlumbroso/free-disk-space@v1.3.1 is used in .github\workflows\flutter-build.yml
|
||||
# But pinning to a specific version to avoid unexpected issues is preferred.
|
||||
uses: jlumbroso/free-disk-space@v1.3.1
|
||||
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1
|
||||
with:
|
||||
tool-cache: false
|
||||
android: true
|
||||
@@ -99,14 +100,14 @@ jobs:
|
||||
swap-storage: false
|
||||
|
||||
- name: Export GitHub Actions cache environment variables
|
||||
uses: actions/github-script@v6
|
||||
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
|
||||
with:
|
||||
script: |
|
||||
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
|
||||
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
|
||||
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
@@ -145,7 +146,7 @@ jobs:
|
||||
esac
|
||||
|
||||
- name: Setup vcpkg with Github Actions binary cache
|
||||
uses: lukka/run-vcpkg@v11
|
||||
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
|
||||
with:
|
||||
vcpkgDirectory: /opt/artifacts/vcpkg
|
||||
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
|
||||
@@ -156,7 +157,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@v1
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
|
||||
with:
|
||||
toolchain: stable
|
||||
targets: ${{ matrix.job.target }}
|
||||
@@ -172,10 +173,10 @@ jobs:
|
||||
cargo -V
|
||||
rustc -V
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
|
||||
- name: Build
|
||||
uses: actions-rs/cargo@v1
|
||||
uses: actions-rs/cargo@844f36862e911db73fe0815f00a4a2602c279505 # v1
|
||||
with:
|
||||
use-cross: ${{ matrix.job.use-cross }}
|
||||
command: build
|
||||
@@ -243,7 +244,7 @@ jobs:
|
||||
echo "CARGO_TEST_OPTIONS=${CARGO_TEST_OPTIONS}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Run tests
|
||||
uses: actions-rs/cargo@v1
|
||||
uses: actions-rs/cargo@844f36862e911db73fe0815f00a4a2602c279505 # v1
|
||||
with:
|
||||
use-cross: ${{ matrix.job.use-cross }}
|
||||
command: test
|
||||
|
||||
4
.github/workflows/clear-cache.yml
vendored
4
.github/workflows/clear-cache.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Clear cache
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
with:
|
||||
script: |
|
||||
console.log("About to clear")
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
console.log("Clear completed")
|
||||
|
||||
- name: Purge cache # Above seems not clear thouroughly, so add this to double clear
|
||||
uses: MyAlbum/purge-cache@v2
|
||||
uses: MyAlbum/purge-cache@881eb5957687193fa612bf74c0042adc78ea5e54 # v2
|
||||
with:
|
||||
accessed: true # Purge caches by their last accessed time (default)
|
||||
created: false # Purge caches by their created time (default)
|
||||
|
||||
2
.github/workflows/fdroid.yml
vendored
2
.github/workflows/fdroid.yml
vendored
@@ -31,7 +31,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
- name: Publish RustDesk version file
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
with:
|
||||
prerelease: true
|
||||
tag_name: "fdroid-version"
|
||||
|
||||
338
.github/workflows/flutter-build.yml
vendored
338
.github/workflows/flutter-build.yml
vendored
@@ -27,6 +27,11 @@ env:
|
||||
LLVM_VERSION: "15.0.6"
|
||||
FLUTTER_VERSION: "3.24.5"
|
||||
ANDROID_FLUTTER_VERSION: "3.24.5"
|
||||
# Windows arm64 only: the first stable Flutter to ship a native arm64 Windows Dart SDK +
|
||||
# engine is 3.44. Every other platform stays on FLUTTER_VERSION (3.24.5) until Windows 7
|
||||
# support is restored after the upstream-wide Flutter bump. The arm64 job patches the few
|
||||
# 3.44-only source/pubspec changes on the fly (see "Patch RustDesk sources for Flutter 3.44").
|
||||
FLUTTER_WINDOWS_ARM_VERSION: "3.44.0"
|
||||
# for arm64 linux because official Dart SDK does not work
|
||||
FLUTTER_ELINUX_VERSION: "3.16.9"
|
||||
TAG_NAME: "${{ inputs.upload-tag }}"
|
||||
@@ -39,7 +44,7 @@ env:
|
||||
# 2. Update the `VCPKG_COMMIT_ID` in `ci.yml` and `playground.yml`.
|
||||
VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b"
|
||||
ARMV7_VCPKG_COMMIT_ID: "6f29f12e82a8293156836ad81cc9bf5af41fe836" # 2025.01.13, got "/opt/artifacts/vcpkg/vcpkg: No such file or directory" with latest version
|
||||
VERSION: "1.4.6"
|
||||
VERSION: "1.4.8"
|
||||
NDK_VERSION: "r28c"
|
||||
#signing keys env variable checks
|
||||
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"
|
||||
@@ -53,14 +58,24 @@ jobs:
|
||||
|
||||
build-RustDeskTempTopMostWindow:
|
||||
uses: ./.github/workflows/third-party-RustDeskTempTopMostWindow.yml
|
||||
with:
|
||||
upload-artifact: ${{ inputs.upload-artifact }}
|
||||
target: windows-2022
|
||||
configuration: Release
|
||||
platform: x64
|
||||
target_version: Windows10
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
job:
|
||||
- {
|
||||
target: windows-2022,
|
||||
platform: x64,
|
||||
}
|
||||
- {
|
||||
target: windows-11-arm,
|
||||
platform: ARM64,
|
||||
}
|
||||
with:
|
||||
upload-artifact: ${{ inputs.upload-artifact }}
|
||||
target: ${{ matrix.job.target }}
|
||||
configuration: Release
|
||||
platform: ${{ matrix.job.platform }}
|
||||
target_version: Windows10
|
||||
|
||||
build-for-windows-flutter:
|
||||
name: ${{ matrix.job.target }}
|
||||
@@ -76,68 +91,134 @@ jobs:
|
||||
target: x86_64-pc-windows-msvc,
|
||||
os: windows-2022,
|
||||
arch: x86_64,
|
||||
flutter-arch: x64,
|
||||
vcpkg-triplet: x64-windows-static,
|
||||
build-args: "--vram",
|
||||
}
|
||||
- {
|
||||
target: aarch64-pc-windows-msvc,
|
||||
os: windows-11-arm,
|
||||
arch: aarch64,
|
||||
flutter-arch: arm64,
|
||||
vcpkg-triplet: arm64-windows-static,
|
||||
# vram is x86/x64-only (NVENC needs CUDA, Intel MediaSDK needs __rdtsc);
|
||||
# no NV/Intel/AMD hardware exists on Windows-on-ARM, so vram stays disabled here.
|
||||
build-args: "",
|
||||
}
|
||||
# - { target: aarch64-pc-windows-msvc, os: windows-2022, arch: aarch64 }
|
||||
steps:
|
||||
- name: Export GitHub Actions cache environment variables
|
||||
uses: actions/github-script@v6
|
||||
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
|
||||
with:
|
||||
script: |
|
||||
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
|
||||
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
|
||||
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Restore bridge files
|
||||
uses: actions/download-artifact@master
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: bridge-artifact
|
||||
# arm64 is on Flutter 3.44, so it needs the bridge generated with the same Flutter
|
||||
# (its *.freezed.dart must match the freezed the arm64 build resolves). x64 and every
|
||||
# other platform keep the default 3.22.3-generated bridge.
|
||||
name: ${{ matrix.job.arch == 'aarch64' && 'bridge-artifact-flutter-3.44' || 'bridge-artifact' }}
|
||||
path: ./
|
||||
|
||||
- name: Install LLVM and Clang
|
||||
uses: KyleMayes/install-llvm-action@v1
|
||||
uses: KyleMayes/install-llvm-action@ebc0426251bc40c7cd31162802432c68818ab8f0 # v2.0.9
|
||||
with:
|
||||
version: ${{ env.LLVM_VERSION }}
|
||||
|
||||
- name: Install flutter
|
||||
uses: subosito/flutter-action@v2.12.0 #https://github.com/subosito/flutter-action/issues/277
|
||||
id: flutter
|
||||
# arm64 builds with FLUTTER_WINDOWS_ARM_VERSION (>=3.44); x64 stays on FLUTTER_VERSION.
|
||||
# subosito only ships an x64 Windows SDK (Flutter's release manifest lists x64 only),
|
||||
# so it installs x64 on both arches. The arm64 runner re-bootstraps Dart to arm64 in
|
||||
# the next step.
|
||||
uses: subosito/flutter-action@2783a3f08e1baf891508463f8c6653c258246225 # v2.12.0; https://github.com/subosito/flutter-action/issues/277
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
flutter-version: ${{ matrix.job.arch == 'aarch64' && env.FLUTTER_WINDOWS_ARM_VERSION || env.FLUTTER_VERSION }}
|
||||
architecture: x64
|
||||
|
||||
- name: Force arm64 Dart SDK + engine
|
||||
# The x64 SDK subosito installs bundles an x64 Dart with a matching engine-dart-sdk.stamp,
|
||||
# so update_dart_sdk.ps1 short-circuits (stamp matches -> return) and keeps x64 Dart;
|
||||
# `flutter build windows` then targets the Dart VM's arch = x64, even on this arm64 host.
|
||||
# On this native-arm64 runner (PROCESSOR_ARCHITECTURE=ARM64), deleting the stamp and
|
||||
# re-running update_dart_sdk.ps1 pulls the arm64 Dart (available since Flutter 3.44.0).
|
||||
# https://github.com/flutter/flutter/issues/186730#issuecomment-4573214964
|
||||
if: ${{ matrix.job.arch == 'aarch64' }}
|
||||
run: |
|
||||
$flutterRoot = "${{ steps.flutter.outputs['CACHE-PATH'] }}"
|
||||
Write-Host "PROCESSOR_ARCHITECTURE=$env:PROCESSOR_ARCHITECTURE"
|
||||
Write-Host "Flutter root: $flutterRoot"
|
||||
Remove-Item -Force "$flutterRoot\bin\cache\engine-dart-sdk.stamp" -ErrorAction SilentlyContinue
|
||||
& "$flutterRoot\bin\internal\update_dart_sdk.ps1"
|
||||
# Confirm the Dart we ended up with is arm64 ("on windows_arm64"); fail loudly if not.
|
||||
$dartVer = & "$flutterRoot\bin\dart.bat" --version 2>&1 | Out-String
|
||||
Write-Host $dartVer
|
||||
if ($dartVer -notmatch "windows_arm64") {
|
||||
Write-Error "Expected an arm64 Dart SDK but got: $dartVer"
|
||||
exit 1
|
||||
}
|
||||
& "$flutterRoot\bin\flutter.bat" precache --windows
|
||||
# Fail fast if precache pulled the wrong-arch Windows engine: an arm64 Dart should
|
||||
# fetch windows-arm64 engine artifacts. Bailing here saves the ~25min Rust build.
|
||||
$engineDir = "$flutterRoot\bin\cache\artifacts\engine"
|
||||
Write-Host "Engine artifacts present:"
|
||||
Get-ChildItem $engineDir -Directory | Select-Object -ExpandProperty Name | Write-Host
|
||||
if (-not (Test-Path "$engineDir\windows-arm64-release")) {
|
||||
Write-Error "Expected windows-arm64-release engine artifacts but they are missing (wrong-arch SDK)."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# https://github.com/flutter/flutter/issues/155685
|
||||
# x64 only: arm64 uses the stock native arm64 Windows engine, and the rustdesk/engine
|
||||
# windows-x64-release.zip is built for the 3.24-era x64 engine (matches FLUTTER_VERSION).
|
||||
- name: Replace engine with rustdesk custom flutter engine
|
||||
if: ${{ matrix.job.arch == 'x86_64' }}
|
||||
run: |
|
||||
flutter doctor -v
|
||||
flutter precache --windows
|
||||
Invoke-WebRequest -Uri https://github.com/rustdesk/engine/releases/download/main/windows-x64-release.zip -OutFile windows-x64-release.zip
|
||||
Expand-Archive -Path windows-x64-release.zip -DestinationPath windows-x64-release
|
||||
mv -Force windows-x64-release/* C:/hostedtoolcache/windows/flutter/stable-${{ env.FLUTTER_VERSION }}-x64/bin/cache/artifacts/engine/windows-x64-release/
|
||||
mv -Force windows-x64-release/* C:/hostedtoolcache/windows/flutter/stable-${{ env.FLUTTER_VERSION }}-x64/bin/cache/artifacts/engine/windows-x64-release/
|
||||
|
||||
- name: Patch flutter
|
||||
# x64 stays on Flutter 3.24.5, which needs the dropdown filter patch.
|
||||
# arm64 is on Flutter 3.44 (patched separately below) and does not use this patch.
|
||||
if: ${{ matrix.job.arch == 'x86_64' }}
|
||||
shell: bash
|
||||
run: |
|
||||
cp .github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff $(dirname $(dirname $(which flutter)))
|
||||
cd $(dirname $(dirname $(which flutter)))
|
||||
[[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]] && git apply flutter_3.24.4_dropdown_menu_enableFilter.diff
|
||||
|
||||
- name: Patch RustDesk sources for Flutter 3.44 (arm64)
|
||||
# arm64 is the only target on Flutter 3.44; apply its source/pubspec deltas on the fly
|
||||
# (shared with the 3.44 bridge job) so the committed sources stay on Flutter 3.24.5.
|
||||
# `flutter build` then runs `pub get`, regenerating pubspec.lock for the bumped deps.
|
||||
if: ${{ matrix.job.arch == 'aarch64' }}
|
||||
shell: bash
|
||||
run: bash .github/patches/apply_flutter_3.44_source_patches.sh
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@v1
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
|
||||
with:
|
||||
toolchain: ${{ env.SCITER_RUST_VERSION }}
|
||||
targets: ${{ matrix.job.target }}
|
||||
components: "rustfmt"
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
with:
|
||||
prefix-key: ${{ matrix.job.os }}
|
||||
|
||||
- name: Setup vcpkg with Github Actions binary cache
|
||||
uses: lukka/run-vcpkg@v11
|
||||
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
|
||||
with:
|
||||
vcpkgDirectory: C:\vcpkg
|
||||
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
|
||||
@@ -163,11 +244,19 @@ jobs:
|
||||
head -n 100 "${VCPKG_ROOT}/buildtrees/ffmpeg/build-${{ matrix.job.vcpkg-triplet }}-rel-out.log" || true
|
||||
shell: bash
|
||||
|
||||
- name: Set SODIUM_LIB_DIR (arm64)
|
||||
# libsodium-sys ships no arm64 Windows prebuilt lib; point it at the vcpkg-built one
|
||||
# (only for arm64 — leaving it unset lets x64 use the crate's bundled lib).
|
||||
if: ${{ matrix.job.arch == 'aarch64' }}
|
||||
shell: bash
|
||||
run: echo "SODIUM_LIB_DIR=$VCPKG_ROOT/installed/${{ matrix.job.vcpkg-triplet }}/lib" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build rustdesk
|
||||
run: |
|
||||
# Windows: build RustDesk
|
||||
python3 .\build.py --portable --hwcodec --flutter --vram --skip-portable-pack
|
||||
mv ./flutter/build/windows/x64/runner/Release ./rustdesk
|
||||
# --hwcodec is shared by all Windows targets; per-target extras (e.g. --vram) come from the matrix
|
||||
python3 .\build.py --portable --flutter --skip-portable-pack --hwcodec ${{ matrix.job.build-args }}
|
||||
mv ./flutter/build/windows/${{ matrix.job.flutter-arch }}/runner/Release ./rustdesk
|
||||
|
||||
# Download usbmmidd_v2.zip and extract it to ./rustdesk
|
||||
Invoke-WebRequest -Uri https://github.com/rustdesk-org/rdev/releases/download/usbmmidd_v2/usbmmidd_v2.zip -OutFile usbmmidd_v2.zip
|
||||
@@ -220,15 +309,15 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Download RustDeskTempTopMostWindow artifacts
|
||||
uses: actions/download-artifact@master
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
if: ${{ inputs.upload-artifact }}
|
||||
with:
|
||||
name: topmostwindow-artifacts
|
||||
name: ${{ matrix.job.arch == 'aarch64' && 'topmostwindow-artifacts-ARM64' || 'topmostwindow-artifacts-x64' }}
|
||||
path: "./rustdesk"
|
||||
|
||||
- name: Upload unsigned
|
||||
if: env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: actions/upload-artifact@master
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: rustdesk-unsigned-windows-${{ matrix.job.arch }}
|
||||
path: rustdesk
|
||||
@@ -253,16 +342,21 @@ jobs:
|
||||
mv ./target/release/rustdesk-portable-packer.exe ./SignOutput/rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.exe
|
||||
|
||||
- name: Add MSBuild to PATH
|
||||
uses: microsoft/setup-msbuild@v2
|
||||
uses: microsoft/setup-msbuild@6fb02220983dee41ce7ae257b6f4d8f9bf5ed4ce # v2
|
||||
|
||||
- name: Build msi
|
||||
# Builds the MSI for the matrix arch. res/msi (WiX v4 + native CustomActions) carries
|
||||
# both x64 and ARM64 platform configs; WcaUtil/DUtil ship arm64 libs. msbuild platform
|
||||
# is x64 / ARM64; the produced Package.msi is globbed since its bin/<platform>/ dir varies.
|
||||
if: env.UPLOAD_ARTIFACT == 'true'
|
||||
run: |
|
||||
pushd ./res/msi
|
||||
python preprocess.py --arp -d ../../rustdesk
|
||||
nuget restore msi.sln
|
||||
msbuild msi.sln -p:Configuration=Release -p:Platform=x64 /p:TargetVersion=Windows10
|
||||
mv ./Package/bin/x64/Release/en-us/Package.msi ../../SignOutput/rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.msi
|
||||
$msiPlatform = if ('${{ matrix.job.arch }}' -eq 'aarch64') { 'ARM64' } else { 'x64' }
|
||||
msbuild msi.sln -p:Configuration=Release -p:Platform=$msiPlatform /p:TargetVersion=Windows10
|
||||
$msi = Get-ChildItem ./Package/bin/*/Release/en-us/Package.msi | Select-Object -First 1
|
||||
mv $msi.FullName ../../SignOutput/rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.msi
|
||||
sha256sum ../../SignOutput/rustdesk-*.msi
|
||||
|
||||
- name: Sign rustdesk self-extracted file
|
||||
@@ -272,7 +366,7 @@ jobs:
|
||||
BASE_URL=${{ env.SIGN_BASE_URL }} SECRET_KEY=${{ secrets.SIGN_SECRET_KEY }} python3 res/job.py sign_files ./SignOutput
|
||||
|
||||
- name: Publish Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
if: env.UPLOAD_ARTIFACT == 'true'
|
||||
with:
|
||||
prerelease: true
|
||||
@@ -302,35 +396,35 @@ jobs:
|
||||
# - { target: aarch64-pc-windows-msvc, os: windows-2022 }
|
||||
steps:
|
||||
- name: Export GitHub Actions cache environment variables
|
||||
uses: actions/github-script@v6
|
||||
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
|
||||
with:
|
||||
script: |
|
||||
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
|
||||
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
|
||||
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Install LLVM and Clang
|
||||
uses: rustdesk-org/install-llvm-action-32bit@master
|
||||
uses: rustdesk-org/install-llvm-action-32bit@6aa7d9ad3df84dff01cd4596dd0fc880a7f47fce # no release tag; commit 2026-05-26
|
||||
with:
|
||||
version: ${{ env.LLVM_VERSION }}
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@v1
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
|
||||
with:
|
||||
toolchain: nightly-2023-10-13-${{ matrix.job.target }} # must use nightly here, because of abi_thiscall feature required
|
||||
targets: ${{ matrix.job.target }}
|
||||
components: "rustfmt"
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
with:
|
||||
prefix-key: ${{ matrix.job.os }}-sciter
|
||||
|
||||
- name: Setup vcpkg with Github Actions binary cache
|
||||
uses: lukka/run-vcpkg@v11
|
||||
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
|
||||
with:
|
||||
vcpkgDirectory: C:\vcpkg
|
||||
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
|
||||
@@ -363,7 +457,8 @@ jobs:
|
||||
python3 res/inline-sciter.py
|
||||
# Patch sciter x86
|
||||
sed -i 's/branch = "dyn"/branch = "dyn_x86"/g' ./Cargo.toml
|
||||
cargo build --features inline,vram,hwcodec --release --bins
|
||||
cargo update -p sciter-rs --precise 674e07d3066ca9a92ced3816203ab6b652629d1e
|
||||
cargo build --locked --features inline,vram,hwcodec --release --bins
|
||||
mkdir -p ./Release
|
||||
mv ./target/release/rustdesk.exe ./Release/rustdesk.exe
|
||||
curl -LJ -o ./Release/sciter.dll https://github.com/c-smile/sciter-sdk/raw/master/bin.win/x32/sciter.dll
|
||||
@@ -394,7 +489,7 @@ jobs:
|
||||
|
||||
- name: Upload unsigned
|
||||
if: env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: actions/upload-artifact@master
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: rustdesk-unsigned-windows-${{ matrix.job.arch }}
|
||||
path: Release
|
||||
@@ -424,7 +519,7 @@ jobs:
|
||||
BASE_URL=${{ env.SIGN_BASE_URL }} SECRET_KEY=${{ secrets.SIGN_SECRET_KEY }} python3 res/job.py sign_files ./SignOutput/
|
||||
|
||||
- name: Publish Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
if: env.UPLOAD_ARTIFACT == 'true'
|
||||
with:
|
||||
prerelease: true
|
||||
@@ -449,7 +544,7 @@ jobs:
|
||||
}
|
||||
steps:
|
||||
- name: Export GitHub Actions cache environment variables
|
||||
uses: actions/github-script@v6
|
||||
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
|
||||
with:
|
||||
script: |
|
||||
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
|
||||
@@ -459,12 +554,12 @@ jobs:
|
||||
run: |
|
||||
brew install nasm yasm
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Install flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
@@ -475,7 +570,7 @@ jobs:
|
||||
[[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]] && git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff
|
||||
|
||||
- name: Setup vcpkg with Github Actions binary cache
|
||||
uses: lukka/run-vcpkg@v11
|
||||
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
|
||||
with:
|
||||
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
|
||||
doNotCache: false
|
||||
@@ -499,19 +594,19 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@v1
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
|
||||
with:
|
||||
toolchain: ${{ env.RUST_VERSION }}
|
||||
targets: ${{ matrix.job.target }}
|
||||
components: "rustfmt"
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
with:
|
||||
prefix-key: rustdesk-lib-cache-ios
|
||||
key: ${{ matrix.job.target }}
|
||||
|
||||
- name: Restore bridge files
|
||||
uses: actions/download-artifact@master
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: bridge-artifact
|
||||
path: ./
|
||||
@@ -519,10 +614,10 @@ jobs:
|
||||
- name: Build rustdesk lib
|
||||
run: |
|
||||
rustup target add ${{ matrix.job.target }}
|
||||
cargo build --features flutter,hwcodec --release --target aarch64-apple-ios --lib
|
||||
cargo build --locked --features flutter,hwcodec --release --target aarch64-apple-ios --lib
|
||||
|
||||
- name: Upload liblibrustdesk.a Artifacts
|
||||
uses: actions/upload-artifact@master
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: liblibrustdesk.a
|
||||
path: target/aarch64-apple-ios/release/liblibrustdesk.a
|
||||
@@ -537,14 +632,14 @@ jobs:
|
||||
|
||||
# - name: Upload Artifacts
|
||||
# # if: env.ANDROID_SIGNING_KEY != null && env.UPLOAD_ARTIFACT == 'true'
|
||||
# uses: actions/upload-artifact@master
|
||||
# uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
# with:
|
||||
# name: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.apk
|
||||
# path: flutter/build/ios/ipa/*.ipa
|
||||
|
||||
# - name: Publish ipa package
|
||||
# # if: env.ANDROID_SIGNING_KEY != null && env.UPLOAD_ARTIFACT == 'true'
|
||||
# uses: softprops/action-gh-release@v1
|
||||
# uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
# with:
|
||||
# prerelease: true
|
||||
# tag_name: ${{ env.TAG_NAME }}
|
||||
@@ -577,20 +672,20 @@ jobs:
|
||||
}
|
||||
steps:
|
||||
- name: Export GitHub Actions cache environment variables
|
||||
uses: actions/github-script@v6
|
||||
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
|
||||
with:
|
||||
script: |
|
||||
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
|
||||
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
|
||||
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Import the codesign cert
|
||||
if: env.MACOS_P12_BASE64 != null
|
||||
uses: apple-actions/import-codesign-certs@v1
|
||||
uses: apple-actions/import-codesign-certs@253ddeeac23f2bdad1646faac5c8c2832e800071 # v1
|
||||
with:
|
||||
p12-file-base64: ${{ secrets.MACOS_P12_BASE64 }}
|
||||
p12-password: ${{ secrets.MACOS_P12_PASSWORD }}
|
||||
@@ -604,7 +699,7 @@ jobs:
|
||||
|
||||
- name: Import notarize key
|
||||
if: env.MACOS_P12_BASE64 != null
|
||||
uses: timheuer/base64-to-file@v1.2
|
||||
uses: timheuer/base64-to-file@adaa40c0c581f276132199d4cf60afa07ce60eac # v1.2
|
||||
with:
|
||||
# https://gregoryszorc.com/docs/apple-codesign/stable/apple_codesign_rcodesign.html#notarizing-and-stapling
|
||||
fileName: rustdesk.json
|
||||
@@ -643,7 +738,7 @@ jobs:
|
||||
nasm --version
|
||||
|
||||
- name: Install flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
@@ -662,24 +757,24 @@ jobs:
|
||||
grep -n '_setFramesEnabledState(false);' ../packages/flutter/lib/src/scheduler/binding.dart
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@v1
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
|
||||
with:
|
||||
toolchain: ${{ env.MAC_RUST_VERSION }}
|
||||
targets: ${{ matrix.job.target }}
|
||||
components: "rustfmt"
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
with:
|
||||
prefix-key: ${{ matrix.job.os }}
|
||||
|
||||
- name: Restore bridge files
|
||||
uses: actions/download-artifact@master
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: bridge-artifact
|
||||
path: ./
|
||||
|
||||
- name: Setup vcpkg with Github Actions binary cache
|
||||
uses: lukka/run-vcpkg@v11
|
||||
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
|
||||
with:
|
||||
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
|
||||
doNotCache: false
|
||||
@@ -731,7 +826,7 @@ jobs:
|
||||
|
||||
- name: Upload unsigned macOS app
|
||||
if: env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: actions/upload-artifact@master
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: rustdesk-unsigned-macos-${{ matrix.job.arch }}
|
||||
path: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.dmg # can not upload the directory directly or tar.gz, which destroy the link structure, causing the codesign failed
|
||||
@@ -763,7 +858,7 @@ jobs:
|
||||
|
||||
- name: Publish DMG package
|
||||
if: env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
with:
|
||||
prerelease: true
|
||||
tag_name: ${{ env.TAG_NAME }}
|
||||
@@ -779,25 +874,25 @@ jobs:
|
||||
if: ${{ inputs.upload-artifact }}
|
||||
steps:
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@master
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: rustdesk-unsigned-macos-x86_64
|
||||
path: ./
|
||||
|
||||
- name: Download Artifacts
|
||||
uses: actions/download-artifact@master
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: rustdesk-unsigned-macos-aarch64
|
||||
path: ./
|
||||
|
||||
- name: Download Artifacts
|
||||
uses: actions/download-artifact@master
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: rustdesk-unsigned-windows-x86_64
|
||||
path: ./windows-x86_64/
|
||||
|
||||
- name: Download Artifacts
|
||||
uses: actions/download-artifact@master
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: rustdesk-unsigned-windows-x86
|
||||
path: ./windows-x86/
|
||||
@@ -807,7 +902,7 @@ jobs:
|
||||
tar czf rustdesk-${{ env.VERSION }}-unsigned.tar.gz *.dmg windows-x86_64 windows-x86
|
||||
|
||||
- name: Publish unsigned app
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
with:
|
||||
prerelease: true
|
||||
tag_name: ${{ env.TAG_NAME }}
|
||||
@@ -844,7 +939,7 @@ jobs:
|
||||
}
|
||||
steps:
|
||||
- name: Free Disk Space (Ubuntu)
|
||||
uses: jlumbroso/free-disk-space@main
|
||||
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1
|
||||
with:
|
||||
tool-cache: false
|
||||
android: false
|
||||
@@ -855,7 +950,7 @@ jobs:
|
||||
swap-storage: false
|
||||
|
||||
- name: Export GitHub Actions cache environment variables
|
||||
uses: actions/github-script@v6
|
||||
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
|
||||
with:
|
||||
script: |
|
||||
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
|
||||
@@ -897,12 +992,12 @@ jobs:
|
||||
wget
|
||||
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Install flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: ${{ env.ANDROID_FLUTTER_VERSION }}
|
||||
@@ -912,14 +1007,14 @@ jobs:
|
||||
cd $(dirname $(dirname $(which flutter)))
|
||||
[[ "3.24.5" == ${{env.ANDROID_FLUTTER_VERSION}} ]] && git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff
|
||||
|
||||
- uses: nttld/setup-ndk@v1
|
||||
- uses: nttld/setup-ndk@ed92fe6cadad69be94a966a7ee3271275e62f779 # v1
|
||||
id: setup-ndk
|
||||
with:
|
||||
ndk-version: ${{ env.NDK_VERSION }}
|
||||
add-to-path: true
|
||||
|
||||
- name: Setup vcpkg with Github Actions binary cache
|
||||
uses: lukka/run-vcpkg@v11
|
||||
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
|
||||
with:
|
||||
vcpkgDirectory: /opt/artifacts/vcpkg
|
||||
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
|
||||
@@ -954,18 +1049,18 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
- name: Restore bridge files
|
||||
uses: actions/download-artifact@master
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: bridge-artifact
|
||||
path: ./
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@v1
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
|
||||
with:
|
||||
toolchain: ${{ env.RUST_VERSION }}
|
||||
components: "rustfmt"
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
with:
|
||||
prefix-key: rustdesk-lib-cache-android # TODO: drop '-android' part after caches are invalidated
|
||||
key: ${{ matrix.job.target }}
|
||||
@@ -1001,7 +1096,7 @@ jobs:
|
||||
esac
|
||||
|
||||
- name: Upload Rustdesk library to Artifacts
|
||||
uses: actions/upload-artifact@master
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: librustdesk.so.${{ matrix.job.target }}
|
||||
path: ./target/${{ matrix.job.target }}/release/liblibrustdesk.so
|
||||
@@ -1066,7 +1161,7 @@ jobs:
|
||||
echo "ANDROID_SIGN_TOOL_VERSION=$BUILD_TOOL_VERSION" >> $GITHUB_ENV
|
||||
echo Last build tool version is: $BUILD_TOOL_VERSION
|
||||
|
||||
- uses: r0adkll/sign-android-release@v1
|
||||
- uses: r0adkll/sign-android-release@349ebdef58775b1e0d8099458af0816dc79b6407 # v1
|
||||
name: Sign app APK
|
||||
if: env.ANDROID_SIGNING_KEY != null
|
||||
id: sign-rustdesk
|
||||
@@ -1082,14 +1177,14 @@ jobs:
|
||||
|
||||
- name: Upload Artifacts
|
||||
if: env.ANDROID_SIGNING_KEY != null && env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: actions/upload-artifact@master
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.apk
|
||||
path: ${{steps.sign-rustdesk.outputs.signedReleaseFile}}
|
||||
|
||||
- name: Publish signed apk package
|
||||
if: env.ANDROID_SIGNING_KEY != null && env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
with:
|
||||
prerelease: true
|
||||
tag_name: ${{ env.TAG_NAME }}
|
||||
@@ -1098,7 +1193,7 @@ jobs:
|
||||
|
||||
- name: Publish unsigned apk package
|
||||
if: env.ANDROID_SIGNING_KEY == null && env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
with:
|
||||
prerelease: true
|
||||
tag_name: ${{ env.TAG_NAME }}
|
||||
@@ -1116,7 +1211,7 @@ jobs:
|
||||
suffix: ""
|
||||
steps:
|
||||
- name: Free Disk Space (Ubuntu)
|
||||
uses: jlumbroso/free-disk-space@main
|
||||
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1
|
||||
with:
|
||||
tool-cache: false
|
||||
android: false
|
||||
@@ -1127,7 +1222,7 @@ jobs:
|
||||
swap-storage: false
|
||||
|
||||
- name: Export GitHub Actions cache environment variables
|
||||
uses: actions/github-script@v6
|
||||
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
|
||||
with:
|
||||
script: |
|
||||
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
|
||||
@@ -1169,12 +1264,12 @@ jobs:
|
||||
wget
|
||||
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Install flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: ${{ env.ANDROID_FLUTTER_VERSION }}
|
||||
@@ -1185,32 +1280,32 @@ jobs:
|
||||
[[ "3.24.5" == ${{env.ANDROID_FLUTTER_VERSION}} ]] && git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff
|
||||
|
||||
- name: Restore bridge files
|
||||
uses: actions/download-artifact@master
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: bridge-artifact
|
||||
path: ./
|
||||
|
||||
- name: Download Rustdesk library from Artifacts
|
||||
uses: actions/download-artifact@master
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: librustdesk.so.aarch64-linux-android
|
||||
path: ./flutter/android/app/src/main/jniLibs/arm64-v8a
|
||||
|
||||
- name: Download Rustdesk library from Artifacts
|
||||
uses: actions/download-artifact@master
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: librustdesk.so.armv7-linux-androideabi
|
||||
path: ./flutter/android/app/src/main/jniLibs/armeabi-v7a
|
||||
|
||||
- name: Download Rustdesk library from Artifacts
|
||||
uses: actions/download-artifact@master
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: librustdesk.so.x86_64-linux-android
|
||||
path: ./flutter/android/app/src/main/jniLibs/x86_64
|
||||
|
||||
- name: Download Rustdesk library from Artifacts
|
||||
if: ${{ env.reltype == 'debug' }}
|
||||
uses: actions/download-artifact@master
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: librustdesk.so.i686-linux-android
|
||||
path: ./flutter/android/app/src/main/jniLibs/x86
|
||||
@@ -1250,7 +1345,7 @@ jobs:
|
||||
echo "ANDROID_SIGN_TOOL_VERSION=$BUILD_TOOL_VERSION" >> $GITHUB_ENV
|
||||
echo Last build tool version is: $BUILD_TOOL_VERSION
|
||||
|
||||
- uses: r0adkll/sign-android-release@v1
|
||||
- uses: r0adkll/sign-android-release@349ebdef58775b1e0d8099458af0816dc79b6407 # v1
|
||||
name: Sign app APK
|
||||
if: env.ANDROID_SIGNING_KEY != null
|
||||
id: sign-rustdesk
|
||||
@@ -1266,14 +1361,14 @@ jobs:
|
||||
|
||||
- name: Upload Artifacts
|
||||
if: env.ANDROID_SIGNING_KEY != null && env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: actions/upload-artifact@master
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.apk
|
||||
path: ${{steps.sign-rustdesk.outputs.signedReleaseFile}}
|
||||
|
||||
- name: Publish signed apk package
|
||||
if: env.ANDROID_SIGNING_KEY != null && env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
with:
|
||||
prerelease: true
|
||||
tag_name: ${{ env.TAG_NAME }}
|
||||
@@ -1282,7 +1377,7 @@ jobs:
|
||||
|
||||
- name: Publish unsigned apk package
|
||||
if: env.ANDROID_SIGNING_KEY == null && env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
with:
|
||||
prerelease: true
|
||||
tag_name: ${{ env.TAG_NAME }}
|
||||
@@ -1316,7 +1411,7 @@ jobs:
|
||||
}
|
||||
steps:
|
||||
- name: Export GitHub Actions cache environment variables
|
||||
uses: actions/github-script@v6
|
||||
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
|
||||
with:
|
||||
script: |
|
||||
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
|
||||
@@ -1334,13 +1429,13 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Set Swap Space
|
||||
if: ${{ matrix.job.arch == 'x86_64' }}
|
||||
uses: pierotofy/set-swap-space@master
|
||||
uses: pierotofy/set-swap-space@49819abfb41bd9b44fb781159c033dba90353a7c # v1.0
|
||||
with:
|
||||
swap-size-gb: 12
|
||||
|
||||
@@ -1350,7 +1445,7 @@ jobs:
|
||||
free -m
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@v1
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
|
||||
if: matrix.job.arch == 'x86_64' || env.UPLOAD_ARTIFACT == 'true'
|
||||
with:
|
||||
toolchain: ${{ env.RUST_VERSION }}
|
||||
@@ -1369,14 +1464,14 @@ jobs:
|
||||
|
||||
- name: Restore bridge files
|
||||
if: matrix.job.arch == 'x86_64' || env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: actions/download-artifact@master
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: bridge-artifact
|
||||
path: ./
|
||||
|
||||
- name: Setup vcpkg with Github Actions binary cache
|
||||
if: matrix.job.arch == 'x86_64' || env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: lukka/run-vcpkg@v11
|
||||
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
|
||||
with:
|
||||
vcpkgDirectory: /opt/artifacts/vcpkg
|
||||
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
|
||||
@@ -1404,12 +1499,12 @@ jobs:
|
||||
|
||||
- name: Restore bridge files
|
||||
if: matrix.job.arch == 'x86_64' || env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: actions/download-artifact@master
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: bridge-artifact
|
||||
path: ./
|
||||
|
||||
- uses: rustdesk-org/run-on-arch-action@amd64-support
|
||||
- uses: rustdesk-org/run-on-arch-action@d3fcfbb632b84cf7f6bc772bfaaa2c2f4f8789a8 # no release tag; commit 2026-05-26
|
||||
name: Build rustdesk
|
||||
id: vcpkg
|
||||
if: matrix.job.arch == 'x86_64' || env.UPLOAD_ARTIFACT == 'true'
|
||||
@@ -1491,7 +1586,7 @@ jobs:
|
||||
export JOBS=""
|
||||
fi
|
||||
echo $JOBS
|
||||
cargo build --lib $JOBS --features hwcodec,flutter,unix-file-copy-paste --release
|
||||
cargo build --locked --lib $JOBS --features hwcodec,flutter,unix-file-copy-paste --release
|
||||
rm -rf target/release/deps target/release/build
|
||||
rm -rf ~/.cargo
|
||||
|
||||
@@ -1583,7 +1678,7 @@ jobs:
|
||||
|
||||
- name: Publish debian/rpm package
|
||||
if: env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
with:
|
||||
prerelease: true
|
||||
tag_name: ${{ env.TAG_NAME }}
|
||||
@@ -1592,7 +1687,7 @@ jobs:
|
||||
rustdesk-*.rpm
|
||||
|
||||
- name: Upload deb
|
||||
uses: actions/upload-artifact@master
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: env.UPLOAD_ARTIFACT == 'true'
|
||||
with:
|
||||
name: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.deb
|
||||
@@ -1611,7 +1706,7 @@ jobs:
|
||||
|
||||
- name: Build archlinux package
|
||||
if: matrix.job.arch == 'x86_64' && env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: rustdesk-org/arch-makepkg-action@master
|
||||
uses: rustdesk-org/arch-makepkg-action@04200739ed1d0bf6f2188b6736b26a767c57a7f9 # no release tag; commit 2026-05-26
|
||||
with:
|
||||
packages:
|
||||
scripts: |
|
||||
@@ -1619,7 +1714,7 @@ jobs:
|
||||
|
||||
- name: Publish archlinux package
|
||||
if: matrix.job.arch == 'x86_64' && env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
with:
|
||||
prerelease: true
|
||||
tag_name: ${{ env.TAG_NAME }}
|
||||
@@ -1657,14 +1752,14 @@ jobs:
|
||||
}
|
||||
steps:
|
||||
- name: Export GitHub Actions cache environment variables
|
||||
uses: actions/github-script@v6
|
||||
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
|
||||
with:
|
||||
script: |
|
||||
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
|
||||
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
|
||||
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
@@ -1682,7 +1777,7 @@ jobs:
|
||||
free -m
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@v1
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
|
||||
with:
|
||||
toolchain: ${{ env.SCITER_RUST_VERSION }}
|
||||
targets: ${{ matrix.job.target }}
|
||||
@@ -1693,7 +1788,7 @@ jobs:
|
||||
RUST_TOOLCHAIN_VERSION=$(cargo --version | awk '{print $2}')
|
||||
echo "RUST_TOOLCHAIN_VERSION=$RUST_TOOLCHAIN_VERSION" >> $GITHUB_ENV
|
||||
|
||||
- uses: rustdesk-org/run-on-arch-action@amd64-support
|
||||
- uses: rustdesk-org/run-on-arch-action@d3fcfbb632b84cf7f6bc772bfaaa2c2f4f8789a8 # no release tag; commit 2026-05-26
|
||||
name: Build rustdesk sciter binary for ${{ matrix.job.arch }}
|
||||
id: vcpkg
|
||||
with:
|
||||
@@ -1821,7 +1916,7 @@ jobs:
|
||||
# build rustdesk
|
||||
python3 ./res/inline-sciter.py
|
||||
export CARGO_INCREMENTAL=0
|
||||
cargo build --features inline${{ matrix.job.extra_features }} --release --bins --jobs 1
|
||||
cargo build --locked --features inline${{ matrix.job.extra_features }} --release --bins --jobs 1
|
||||
# make debian package
|
||||
mkdir -p ./Release
|
||||
mv ./target/release/rustdesk ./Release/rustdesk
|
||||
@@ -1839,7 +1934,7 @@ jobs:
|
||||
|
||||
- name: Publish debian package
|
||||
if: env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
with:
|
||||
prerelease: true
|
||||
tag_name: ${{ env.TAG_NAME }}
|
||||
@@ -1847,7 +1942,7 @@ jobs:
|
||||
rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}-sciter.deb
|
||||
|
||||
- name: Upload deb
|
||||
uses: actions/upload-artifact@master
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: env.UPLOAD_ARTIFACT == 'true'
|
||||
with:
|
||||
name: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}-sciter.deb
|
||||
@@ -1866,12 +1961,12 @@ jobs:
|
||||
- { target: aarch64-unknown-linux-gnu, arch: aarch64 }
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Download Binary
|
||||
uses: actions/download-artifact@master
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.deb
|
||||
path: .
|
||||
@@ -1889,6 +1984,7 @@ jobs:
|
||||
sudo apt-get install -y libarchive-tools libfuse2
|
||||
# set-up appimage-builder
|
||||
# https://github.com/AppImage/AppImageKit/issues/1395
|
||||
sudo pip3 install "setuptools_scm<10"
|
||||
sudo pip3 install git+https://github.com/rustdesk-org/appimage-builder.git
|
||||
# run appimage-builder
|
||||
pushd appimage
|
||||
@@ -1896,7 +1992,7 @@ jobs:
|
||||
|
||||
- name: Publish appimage package
|
||||
if: env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
with:
|
||||
prerelease: true
|
||||
tag_name: ${{ env.TAG_NAME }}
|
||||
@@ -1939,12 +2035,12 @@ jobs:
|
||||
}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Download Binary
|
||||
uses: actions/download-artifact@master
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}${{ matrix.job.suffix }}.deb
|
||||
path: .
|
||||
@@ -1953,7 +2049,7 @@ jobs:
|
||||
run: |
|
||||
mv rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}${{ matrix.job.suffix }}.deb flatpak/rustdesk.deb
|
||||
|
||||
- uses: rustdesk-org/run-on-arch-action@amd64-support
|
||||
- uses: rustdesk-org/run-on-arch-action@d3fcfbb632b84cf7f6bc772bfaaa2c2f4f8789a8 # no release tag; commit 2026-05-26
|
||||
name: Build rustdesk flatpak package for ${{ matrix.job.arch }}
|
||||
id: flatpak
|
||||
with:
|
||||
@@ -1981,7 +2077,7 @@ jobs:
|
||||
flatpak build-bundle ./repo rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}${{ matrix.job.suffix }}.flatpak com.rustdesk.RustDesk
|
||||
|
||||
- name: Publish flatpak package
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
with:
|
||||
prerelease: true
|
||||
tag_name: ${{ env.TAG_NAME }}
|
||||
@@ -2000,7 +2096,7 @@ jobs:
|
||||
RELEASE_NAME: web-basic
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
@@ -2010,7 +2106,7 @@ jobs:
|
||||
sudo apt-get install -y wget npm
|
||||
|
||||
- name: Install flutter
|
||||
uses: subosito/flutter-action@v2.12.0 #https://github.com/subosito/flutter-action/issues/277
|
||||
uses: subosito/flutter-action@2783a3f08e1baf891508463f8c6653c258246225 # v2.12.0; https://github.com/subosito/flutter-action/issues/277
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
@@ -2054,7 +2150,7 @@ jobs:
|
||||
|
||||
- name: Publish web
|
||||
if: env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
with:
|
||||
prerelease: true
|
||||
tag_name: ${{ env.TAG_NAME }}
|
||||
|
||||
36
.github/workflows/playground.yml
vendored
36
.github/workflows/playground.yml
vendored
@@ -17,7 +17,7 @@ env:
|
||||
TAG_NAME: "nightly"
|
||||
VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite"
|
||||
VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b"
|
||||
VERSION: "1.4.6"
|
||||
VERSION: "1.4.8"
|
||||
NDK_VERSION: "r26d"
|
||||
#signing keys env variable checks
|
||||
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"
|
||||
@@ -79,21 +79,21 @@ jobs:
|
||||
}
|
||||
steps:
|
||||
- name: Export GitHub Actions cache environment variables
|
||||
uses: actions/github-script@v6
|
||||
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
|
||||
with:
|
||||
script: |
|
||||
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
|
||||
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
|
||||
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
|
||||
with:
|
||||
ref: ${{ matrix.job.ref }}
|
||||
submodules: recursive
|
||||
|
||||
- name: Import the codesign cert
|
||||
if: env.MACOS_P12_BASE64 != null
|
||||
uses: apple-actions/import-codesign-certs@v1
|
||||
uses: apple-actions/import-codesign-certs@253ddeeac23f2bdad1646faac5c8c2832e800071 # v1
|
||||
with:
|
||||
p12-file-base64: ${{ secrets.MACOS_P12_BASE64 }}
|
||||
p12-password: ${{ secrets.MACOS_P12_PASSWORD }}
|
||||
@@ -107,7 +107,7 @@ jobs:
|
||||
|
||||
- name: Import notarize key
|
||||
if: env.MACOS_P12_BASE64 != null
|
||||
uses: timheuer/base64-to-file@v1.2
|
||||
uses: timheuer/base64-to-file@adaa40c0c581f276132199d4cf60afa07ce60eac # v1.2
|
||||
with:
|
||||
# https://gregoryszorc.com/docs/apple-codesign/stable/apple_codesign_rcodesign.html#notarizing-and-stapling
|
||||
fileName: rustdesk.json
|
||||
@@ -129,19 +129,19 @@ jobs:
|
||||
brew install llvm create-dmg nasm pkg-config
|
||||
|
||||
- name: Install flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: ${{ matrix.job.flutter }}
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@v1
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
|
||||
with:
|
||||
toolchain: ${{ env.RUST_VERSION }}
|
||||
targets: ${{ matrix.job.target }}
|
||||
components: "rustfmt"
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
with:
|
||||
prefix-key: ${{ matrix.job.os }}
|
||||
|
||||
@@ -156,7 +156,7 @@ jobs:
|
||||
~/.cargo/bin/flutter_rust_bridge_codegen --rust-input ./src/flutter_ffi.rs --dart-output ./flutter/lib/generated_bridge.dart --c-output ./flutter/macos/Runner/bridge_generated.h
|
||||
|
||||
- name: Setup vcpkg with Github Actions binary cache
|
||||
uses: lukka/run-vcpkg@v11
|
||||
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
|
||||
with:
|
||||
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
|
||||
|
||||
@@ -165,7 +165,7 @@ jobs:
|
||||
$VCPKG_ROOT/vcpkg install --x-install-root="$VCPKG_ROOT/installed"
|
||||
|
||||
- name: Restore from cache and install vcpkg
|
||||
uses: lukka/run-vcpkg@v7
|
||||
uses: lukka/run-vcpkg@8a5116de2b552d6fc8894e9774aacaf2e5db4823 # v7 2026-05-26
|
||||
if: false
|
||||
with:
|
||||
setupOnly: true
|
||||
@@ -222,7 +222,7 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Publish DMG package
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
with:
|
||||
prerelease: true
|
||||
tag_name: ${{ env.TAG_NAME }}
|
||||
@@ -247,7 +247,7 @@ jobs:
|
||||
}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
|
||||
with:
|
||||
ref: ${{ matrix.job.ref }}
|
||||
submodules: recursive
|
||||
@@ -290,13 +290,13 @@ jobs:
|
||||
wget
|
||||
|
||||
- name: Install flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@v1
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
|
||||
with:
|
||||
toolchain: ${{ env.RUST_VERSION }}
|
||||
components: "rustfmt"
|
||||
@@ -310,14 +310,14 @@ jobs:
|
||||
pushd flutter ; flutter pub get ; popd
|
||||
~/.cargo/bin/flutter_rust_bridge_codegen --rust-input ./src/flutter_ffi.rs --dart-output ./flutter/lib/generated_bridge.dart
|
||||
|
||||
- uses: nttld/setup-ndk@v1
|
||||
- uses: nttld/setup-ndk@ed92fe6cadad69be94a966a7ee3271275e62f779 # v1
|
||||
id: setup-ndk
|
||||
with:
|
||||
ndk-version: ${{ env.NDK_VERSION }}
|
||||
add-to-path: true
|
||||
|
||||
- name: Setup vcpkg with Github Actions binary cache
|
||||
uses: lukka/run-vcpkg@v11
|
||||
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
|
||||
with:
|
||||
vcpkgDirectory: /opt/artifacts/vcpkg
|
||||
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
|
||||
@@ -395,7 +395,7 @@ jobs:
|
||||
mkdir -p signed-apk; pushd signed-apk
|
||||
mv ../rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.apk ./rustdesk-test-${{ matrix.job.ref }}-${{ matrix.job.ndk }}.apk
|
||||
|
||||
- uses: r0adkll/sign-android-release@v1
|
||||
- uses: r0adkll/sign-android-release@349ebdef58775b1e0d8099458af0816dc79b6407 # v1
|
||||
name: Sign app APK
|
||||
if: env.ANDROID_SIGNING_KEY != null
|
||||
id: sign-rustdesk
|
||||
@@ -410,7 +410,7 @@ jobs:
|
||||
BUILD_TOOLS_VERSION: "30.0.2"
|
||||
|
||||
- name: Publish signed apk package
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
with:
|
||||
prerelease: true
|
||||
tag_name: ${{ env.TAG_NAME }}
|
||||
|
||||
@@ -39,22 +39,21 @@ jobs:
|
||||
build_output_dir: RustDeskTempTopMostWindow/WindowInjection/${{ inputs.platform }}/${{ inputs.configuration }}
|
||||
steps:
|
||||
- name: Add MSBuild to PATH
|
||||
uses: microsoft/setup-msbuild@v2
|
||||
uses: microsoft/setup-msbuild@6fb02220983dee41ce7ae257b6f4d8f9bf5ed4ce # v2
|
||||
|
||||
- name: Download the source code
|
||||
run: |
|
||||
git clone https://github.com/rustdesk-org/RustDeskTempTopMostWindow RustDeskTempTopMostWindow
|
||||
|
||||
# Build. commit 53b548a5398624f7149a382000397993542ad796 is tag v0.3
|
||||
- name: Build the project
|
||||
run: |
|
||||
cd RustDeskTempTopMostWindow && git checkout 53b548a5398624f7149a382000397993542ad796
|
||||
cd RustDeskTempTopMostWindow && git checkout ecd8d6a139eee76845ea66423fb739af450fda90
|
||||
msbuild ${{ env.project_path }} -p:Configuration=${{ inputs.configuration }} -p:Platform=${{ inputs.platform }} /p:TargetVersion=${{ inputs.target_version }}
|
||||
|
||||
- name: Archive build artifacts
|
||||
uses: actions/upload-artifact@master
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: ${{ inputs.upload-artifact }}
|
||||
with:
|
||||
name: topmostwindow-artifacts
|
||||
name: topmostwindow-artifacts-${{ inputs.platform }}
|
||||
path: |
|
||||
./${{ env.build_output_dir }}/WindowInjection.dll
|
||||
|
||||
85
.github/workflows/wf-cliprdr-ci.yml
vendored
Normal file
85
.github/workflows/wf-cliprdr-ci.yml
vendored
Normal file
@@ -0,0 +1,85 @@
|
||||
name: wf-cliprdr CI
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
paths:
|
||||
- "libs/clipboard/src/windows/**"
|
||||
- "tests/test_invariant_wf_cliprdr.c"
|
||||
- ".github/workflows/wf-cliprdr-ci.yml"
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- "libs/clipboard/src/windows/**"
|
||||
- "tests/test_invariant_wf_cliprdr.c"
|
||||
- ".github/workflows/wf-cliprdr-ci.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: wf_cliprdr invariant test
|
||||
runs-on: windows-2022
|
||||
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up MSVC
|
||||
uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756
|
||||
with:
|
||||
arch: x64
|
||||
|
||||
- name: Setup vcpkg with GitHub Actions binary cache
|
||||
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
|
||||
with:
|
||||
vcpkgDirectory: C:\vcpkg
|
||||
doNotCache: false
|
||||
|
||||
- name: Install vcpkg dependency
|
||||
shell: pwsh
|
||||
run: |
|
||||
& "$env:VCPKG_ROOT\vcpkg.exe" install check:x64-windows --classic --x-install-root="$env:VCPKG_ROOT\installed"
|
||||
|
||||
- name: Build test
|
||||
shell: pwsh
|
||||
run: |
|
||||
$testRoot = Join-Path $env:GITHUB_WORKSPACE 'build\wf-cliprdr'
|
||||
New-Item -ItemType Directory -Force $testRoot | Out-Null
|
||||
|
||||
$testSource = (($env:GITHUB_WORKSPACE -replace '\\', '/') + '/tests/test_invariant_wf_cliprdr.c')
|
||||
$cmakeLists = @(
|
||||
'cmake_minimum_required(VERSION 3.20)'
|
||||
'project(test_invariant_wf_cliprdr C)'
|
||||
''
|
||||
'set(CMAKE_C_STANDARD 11)'
|
||||
'set(CMAKE_C_STANDARD_REQUIRED ON)'
|
||||
'set(CMAKE_C_EXTENSIONS OFF)'
|
||||
''
|
||||
'find_package(check CONFIG REQUIRED)'
|
||||
''
|
||||
'add_executable(test_invariant_wf_cliprdr'
|
||||
' "TEST_SOURCE"'
|
||||
')'
|
||||
''
|
||||
'target_link_libraries(test_invariant_wf_cliprdr PRIVATE'
|
||||
' $<$<TARGET_EXISTS:Check::check>:Check::check>'
|
||||
' $<$<NOT:$<TARGET_EXISTS:Check::check>>:Check::checkShared>'
|
||||
')'
|
||||
) -join [Environment]::NewLine
|
||||
$cmakeLists.Replace('TEST_SOURCE', $testSource) | Set-Content -NoNewline (Join-Path $testRoot 'CMakeLists.txt')
|
||||
|
||||
cmake -S $testRoot -B (Join-Path $testRoot 'out') -G "Visual Studio 17 2022" -A x64 -DCMAKE_TOOLCHAIN_FILE="$env:VCPKG_ROOT\scripts\buildsystems\vcpkg.cmake" -DVCPKG_TARGET_TRIPLET=x64-windows
|
||||
cmake --build (Join-Path $testRoot 'out') --config Release
|
||||
|
||||
- name: Run test
|
||||
shell: pwsh
|
||||
run: .\build\wf-cliprdr\out\Release\test_invariant_wf_cliprdr.exe
|
||||
24
AGENTS.md
24
AGENTS.md
@@ -60,3 +60,27 @@
|
||||
* 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`.
|
||||
|
||||
43
Cargo.lock
generated
43
Cargo.lock
generated
@@ -292,7 +292,7 @@ checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487"
|
||||
[[package]]
|
||||
name = "arboard"
|
||||
version = "3.4.0"
|
||||
source = "git+https://github.com/rustdesk-org/arboard#85be1218668ff218a7b170c9d424fde73e069914"
|
||||
source = "git+https://github.com/rustdesk-org/arboard#c7d5781f563176df9efd8df6287e823fb1b9bed5"
|
||||
dependencies = [
|
||||
"clipboard-win",
|
||||
"core-graphics 0.23.2",
|
||||
@@ -1324,7 +1324,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "clipboard-master"
|
||||
version = "4.0.0-beta.6"
|
||||
source = "git+https://github.com/rustdesk-org/clipboard-master#ddc39f00a6211959489ae683aa6ae6eedf03a809"
|
||||
source = "git+https://github.com/rustdesk-org/clipboard-master#7762d74e38db37cfeb6ded88c964b9cdbddfb6db"
|
||||
dependencies = [
|
||||
"objc",
|
||||
"objc-foundation",
|
||||
@@ -2329,7 +2329,7 @@ version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412"
|
||||
dependencies = [
|
||||
"libloading 0.8.4",
|
||||
"libloading 0.7.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2694,7 +2694,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3952,7 +3952,7 @@ checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4"
|
||||
[[package]]
|
||||
name = "hwcodec"
|
||||
version = "0.7.1"
|
||||
source = "git+https://github.com/rustdesk-org/hwcodec#398e5a8938dd8768ade0fcdc27ea80e8b4b38738"
|
||||
source = "git+https://github.com/rustdesk-org/hwcodec#778df1f99597722473b29443bac22ae6c23946fe"
|
||||
dependencies = [
|
||||
"bindgen 0.59.2",
|
||||
"cc",
|
||||
@@ -4494,7 +4494,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e310b3a6b5907f99202fcdb4960ff45b93735d7c7d96b760fcff8db2dc0e103d"
|
||||
dependencies = [
|
||||
"cfg-if 1.0.0",
|
||||
"windows-targets 0.52.6",
|
||||
"windows-targets 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4695,7 +4695,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "magnum-opus"
|
||||
version = "0.4.0"
|
||||
source = "git+https://github.com/rustdesk-org/magnum-opus#5cd2bf989c148662fa3a2d9d539a71d71fd1d256"
|
||||
source = "git+https://github.com/rustdesk-org/magnum-opus#588c6e1f9ed50c3a01fa64f3bd3e7cdb0378a114"
|
||||
dependencies = [
|
||||
"bindgen 0.59.2",
|
||||
"pkg-config",
|
||||
@@ -5996,8 +5996,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "parity-tokio-ipc"
|
||||
version = "0.7.3-5"
|
||||
source = "git+https://github.com/rustdesk-org/parity-tokio-ipc#c8c8bbcbabf9be1201c53afb0269b92b9b02d291"
|
||||
version = "0.7.3-6"
|
||||
source = "git+https://github.com/rustdesk-org/parity-tokio-ipc#d0ae39bffe5d5a3e8d82a1b6bcb1ca5a9b2f1c01"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"libc",
|
||||
@@ -6673,7 +6673,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2 0.5.10",
|
||||
"tracing",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6920,7 +6920,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "rdev"
|
||||
version = "0.5.0-2"
|
||||
source = "git+https://github.com/rustdesk-org/rdev#f9b60b1dd0f3300a1b797d7a74c116683cd232c8"
|
||||
source = "git+https://github.com/rustdesk-org/rdev#871bf1c856d6a30af2f56ab8848396a025140855"
|
||||
dependencies = [
|
||||
"cocoa 0.24.1",
|
||||
"core-foundation 0.9.4",
|
||||
@@ -7270,7 +7270,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustdesk"
|
||||
version = "1.4.6"
|
||||
version = "1.4.8"
|
||||
dependencies = [
|
||||
"android-wakelock",
|
||||
"android_logger",
|
||||
@@ -7385,7 +7385,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustdesk-portable-packer"
|
||||
version = "1.4.6"
|
||||
version = "1.4.8"
|
||||
dependencies = [
|
||||
"brotli",
|
||||
"dirs 5.0.1",
|
||||
@@ -7457,7 +7457,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.11.0",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7514,7 +7514,7 @@ dependencies = [
|
||||
"security-framework 3.5.1",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9733,9 +9733,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wayland-protocols-wlr"
|
||||
version = "0.3.3"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd993de54a40a40fbe5601d9f1fbcaef0aebcc5fda447d7dc8f6dcbaae4f8953"
|
||||
checksum = "efd94963ed43cf9938a090ca4f7da58eb55325ec8200c3848963e98dc25b78ec"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"wayland-backend",
|
||||
@@ -10838,16 +10838,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wl-clipboard-rs"
|
||||
version = "0.9.0"
|
||||
version = "0.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4de22eebb1d1e2bad2d970086e96da0e12cde0b411321e5b0f7b2a1f876aa26f"
|
||||
checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"os_pipe",
|
||||
"rustix 0.38.34",
|
||||
"tempfile",
|
||||
"thiserror 1.0.61",
|
||||
"rustix 1.1.2",
|
||||
"thiserror 2.0.17",
|
||||
"tree_magic_mini",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rustdesk"
|
||||
version = "1.4.6"
|
||||
version = "1.4.8"
|
||||
authors = ["rustdesk <info@rustdesk.com>"]
|
||||
edition = "2021"
|
||||
build= "build.rs"
|
||||
@@ -213,7 +213,7 @@ exclude = ["vdi/host", "examples/custom_plugin"]
|
||||
libxdo-sys = { path = "libs/libxdo-sys-stub" }
|
||||
|
||||
[package.metadata.winres]
|
||||
LegalCopyright = "Copyright © 2025 Purslane Ltd. All rights reserved."
|
||||
LegalCopyright = "Copyright © 2026 Purslane Tech Pte. Ltd. All rights reserved."
|
||||
ProductName = "RustDesk"
|
||||
FileDescription = "RustDesk Remote Desktop"
|
||||
OriginalFilename = "rustdesk.exe"
|
||||
|
||||
@@ -18,7 +18,7 @@ AppDir:
|
||||
id: rustdesk
|
||||
name: rustdesk
|
||||
icon: rustdesk
|
||||
version: 1.4.6
|
||||
version: 1.4.8
|
||||
exec: usr/share/rustdesk/rustdesk
|
||||
exec_args: $@
|
||||
apt:
|
||||
|
||||
@@ -18,7 +18,7 @@ AppDir:
|
||||
id: rustdesk
|
||||
name: rustdesk
|
||||
icon: rustdesk
|
||||
version: 1.4.6
|
||||
version: 1.4.8
|
||||
exec: usr/share/rustdesk/rustdesk
|
||||
exec_args: $@
|
||||
apt:
|
||||
|
||||
35
build.py
35
build.py
@@ -17,7 +17,8 @@ osx = platform.platform().startswith(
|
||||
hbb_name = 'rustdesk' + ('.exe' if windows else '')
|
||||
exe_path = 'target/release/' + hbb_name
|
||||
if windows:
|
||||
flutter_build_dir = 'build/windows/x64/runner/Release/'
|
||||
win_arch = 'arm64' if platform.machine().lower() in ('arm64', 'aarch64') else 'x64'
|
||||
flutter_build_dir = f'build/windows/{win_arch}/runner/Release/'
|
||||
elif osx:
|
||||
flutter_build_dir = 'build/macos/Build/Products/Release/'
|
||||
else:
|
||||
@@ -172,7 +173,7 @@ def generate_build_script_for_docker():
|
||||
# flutter_rust_bridge
|
||||
dart pub global activate ffigen --version 5.0.1
|
||||
pushd /tmp && git clone https://github.com/SoLongAndThanksForAllThePizza/flutter_rust_bridge --depth=1 && popd
|
||||
pushd /tmp/flutter_rust_bridge/frb_codegen && cargo install --path . && popd
|
||||
pushd /tmp/flutter_rust_bridge/frb_codegen && cargo install --path . --locked && popd
|
||||
pushd flutter && flutter pub get && popd
|
||||
~/.cargo/bin/flutter_rust_bridge_codegen --rust-input ./src/flutter_ffi.rs --dart-output ./flutter/lib/generated_bridge.dart
|
||||
# install vcpkg
|
||||
@@ -299,7 +300,7 @@ Version: %s
|
||||
Architecture: %s
|
||||
Maintainer: rustdesk <info@rustdesk.com>
|
||||
Homepage: https://rustdesk.com
|
||||
Depends: libgtk-3-0, libxcb-randr0, libxdo3 | libxdo4, libxfixes3, libxcb-shape0, libxcb-xfixes0, libasound2, libsystemd0, curl, libva2, libva-drm2, libva-x11-2, libgstreamer-plugins-base1.0-0, libpam0g, gstreamer1.0-pipewire%s
|
||||
Depends: libgtk-3-0t64 | libgtk-3-0, libxcb-randr0, libxdo3 | libxdo4, libxfixes3, libxcb-shape0, libxcb-xfixes0, libasound2t64 | libasound2, libsystemd0, curl, libva2, libva-drm2, libva-x11-2, libgstreamer-plugins-base1.0-0, libpam0g, gstreamer1.0-pipewire%s
|
||||
Recommends: libayatana-appindicator3-1
|
||||
Description: A remote control software.
|
||||
|
||||
@@ -317,7 +318,7 @@ def ffi_bindgen_function_refactor():
|
||||
|
||||
def build_flutter_deb(version, features):
|
||||
if not skip_cargo:
|
||||
system2(f'cargo build --features {features} --lib --release')
|
||||
system2(f'cargo build --locked --features {features} --lib --release')
|
||||
ffi_bindgen_function_refactor()
|
||||
os.chdir('flutter')
|
||||
system2('flutter build linux --release')
|
||||
@@ -405,12 +406,17 @@ def build_flutter_dmg(version, features):
|
||||
if not skip_cargo:
|
||||
# set minimum osx build target, now is 10.14, which is the same as the flutter xcode project
|
||||
system2(
|
||||
f'MACOSX_DEPLOYMENT_TARGET=10.14 cargo build --features {features} --release')
|
||||
f'MACOSX_DEPLOYMENT_TARGET=10.14 cargo build --locked --features {features} --release')
|
||||
# copy dylib
|
||||
system2(
|
||||
"cp target/release/liblibrustdesk.dylib target/release/librustdesk.dylib")
|
||||
os.chdir('flutter')
|
||||
system2('flutter build macos --release')
|
||||
# cargo builds a single-arch dylib for the host; restrict Xcode to the same arch
|
||||
# so the universal-by-default ARCHS_STANDARD doesn't try to link a missing slice.
|
||||
# FLUTTER_XCODE_* env vars are forwarded to xcodebuild as build settings.
|
||||
mac_arch = 'arm64' if platform.machine().lower() in ('arm64', 'aarch64') else 'x86_64'
|
||||
system2(
|
||||
f'FLUTTER_XCODE_ARCHS={mac_arch} FLUTTER_XCODE_ONLY_ACTIVE_ARCH=YES flutter build macos --release')
|
||||
system2('cp -rf ../target/release/service ./build/macos/Build/Products/Release/RustDesk.app/Contents/MacOS/')
|
||||
'''
|
||||
system2(
|
||||
@@ -422,7 +428,7 @@ def build_flutter_dmg(version, features):
|
||||
|
||||
def build_flutter_arch_manjaro(version, features):
|
||||
if not skip_cargo:
|
||||
system2(f'cargo build --features {features} --lib --release')
|
||||
system2(f'cargo build --locked --features {features} --lib --release')
|
||||
ffi_bindgen_function_refactor()
|
||||
os.chdir('flutter')
|
||||
system2('flutter build linux --release')
|
||||
@@ -433,7 +439,7 @@ def build_flutter_arch_manjaro(version, features):
|
||||
|
||||
def build_flutter_windows(version, features, skip_portable_pack):
|
||||
if not skip_cargo:
|
||||
system2(f'cargo build --features {features} --lib --release')
|
||||
system2(f'cargo build --locked --features {features} --lib --release')
|
||||
if not os.path.exists("target/release/librustdesk.dll"):
|
||||
print("cargo build failed, please check rust source code.")
|
||||
exit(-1)
|
||||
@@ -489,13 +495,13 @@ def main():
|
||||
if windows:
|
||||
# build virtual display dynamic library
|
||||
os.chdir('libs/virtual_display/dylib')
|
||||
system2('cargo build --release')
|
||||
system2('cargo build --locked --release')
|
||||
os.chdir('../../..')
|
||||
|
||||
if flutter:
|
||||
build_flutter_windows(version, features, args.skip_portable_pack)
|
||||
return
|
||||
system2('cargo build --release --features ' + features)
|
||||
system2('cargo build --locked --release --features ' + features)
|
||||
# system2('upx.exe target/release/rustdesk.exe')
|
||||
system2('mv target/release/rustdesk.exe target/release/RustDesk.exe')
|
||||
pa = os.environ.get('P')
|
||||
@@ -506,6 +512,7 @@ def main():
|
||||
'target\\release\\rustdesk.exe')
|
||||
else:
|
||||
print('Not signed')
|
||||
os.makedirs(res_dir, exist_ok=True)
|
||||
system2(
|
||||
f'cp -rf target/release/RustDesk.exe {res_dir}')
|
||||
os.chdir('libs/portable')
|
||||
@@ -519,7 +526,7 @@ def main():
|
||||
if flutter:
|
||||
build_flutter_arch_manjaro(version, features)
|
||||
else:
|
||||
system2('cargo build --release --features ' + features)
|
||||
system2('cargo build --locked --release --features ' + features)
|
||||
system2('git checkout src/ui/common.tis')
|
||||
system2('strip target/release/rustdesk')
|
||||
system2('ln -s res/pacman_install && ln -s res/PKGBUILD')
|
||||
@@ -528,7 +535,7 @@ def main():
|
||||
version, version))
|
||||
# pacman -U ./rustdesk.pkg.tar.zst
|
||||
elif os.path.isfile('/usr/bin/yum'):
|
||||
system2('cargo build --release --features ' + features)
|
||||
system2('cargo build --locked --release --features ' + features)
|
||||
system2('strip target/release/rustdesk')
|
||||
system2(
|
||||
"sed -i 's/Version: .*/Version: %s/g' res/rpm.spec" % version)
|
||||
@@ -538,7 +545,7 @@ def main():
|
||||
version, version))
|
||||
# yum localinstall rustdesk.rpm
|
||||
elif os.path.isfile('/usr/bin/zypper'):
|
||||
system2('cargo build --release --features ' + features)
|
||||
system2('cargo build --locked --release --features ' + features)
|
||||
system2('strip target/release/rustdesk')
|
||||
system2(
|
||||
"sed -i 's/Version: .*/Version: %s/g' res/rpm-suse.spec" % version)
|
||||
@@ -557,7 +564,7 @@ def main():
|
||||
# 'mv target/release/bundle/deb/rustdesk*.deb ./flutter/rustdesk.deb')
|
||||
build_flutter_deb(version, features)
|
||||
else:
|
||||
system2('cargo bundle --release --features ' + features)
|
||||
system2('cargo --locked bundle --release --features ' + features)
|
||||
if osx:
|
||||
system2(
|
||||
'strip target/release/bundle/osx/RustDesk.app/Contents/MacOS/rustdesk')
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<p align="center">
|
||||
<img src="../res/logo-header.svg" alt="RustDesk - Your remote desktop"><br>
|
||||
<a href="#빌드를 위한 원시 단계">빌드</a> •
|
||||
<a href="#Docker로 빌드하는 방법">Docker</a> •
|
||||
<a href="#파일 구조">구조</a> •
|
||||
<a href="#스크린샷">스냇샷</a><br>
|
||||
[<a href="../README.md">English</a>] | [<a href="README-UA.md">Українська</a>] | [<a href="README-CS.md">česky</a>] | [<a href="README-ZH.md">中文</a>] | [<a href="README-HU.md">Magyar</a>] | [<a href="README-ES.md">Español</a>] | [<a href="README-FA.md">فارسی</a>] | [<a href="README-FR.md">Français</a>] | [<a href="README-DE.md">Deutsch</a>] | [<a href="README-PL.md">Polski</a>] | [<a href="README-ID.md">Indonesian</a>] | [<a href="README-FI.md">Suomi</a>] | [<a href="README-ML.md">മലയാളം</a>] | [<a href="README-JP.md">日本語</a>] | [<a href="README-NL.md">Nederlands</a>] | [<a href="README-IT.md">Italiano</a>] | [<a href="README-RU.md">Русский</a>] | [<a href="README-PTBR.md">Português (Brasil)</a>] | [<a href="README-EO.md">Esperanto</a>] | [<a href="README-KR.md">한국어</a>] | [<a href="README-AR.md">العربي</a>] | [<a href="README-VN.md">Tiếng Việt</a>] | [<a href="README-DA.md">Dansk</a>] | [<a href="README-GR.md">Ελληνικά</a>] | [<a href="README-TR.md">Türkçe</a>] | [<a href="README-NO.md">Norsk</a>]<br>
|
||||
<a href="#빌드를_위한_원시_단계">빌드</a> •
|
||||
<a href="#Docker로_빌드하는_방법">Docker</a> •
|
||||
<a href="#파일_구조">구조</a> •
|
||||
<a href="#스크린샷">스냅샷</a><br>
|
||||
[<a href="../README.md">English</a>] | [<a href="README-UA.md">Українська</a>] | [<a href="README-CS.md">česky</a>] | [<a href="README-ZH.md">中文</a>] | [<a href="README-HU.md">Magyar</a>] | [<a href="README-ES.md">Español</a>] | [<a href="README-FA.md">فارسی</a>] | [<a href="README-FR.md">Français</a>] | [<a href="README-DE.md">Deutsch</a>] | [<a href="README-PL.md">Polski</a>] | [<a href="README-ID.md">Indonesian</a>] | [<a href="README-FI.md">Suomi</a>] | [<a href="README-ML.md">മലയാളം</a>] | [<a href="README-JP.md">日本語</a>] | [<a href="README-NL.md">Nederlands</a>] | [<a href="README-IT.md">Italiano</a>] | [<a href="README-RU.md">Русский</a>] | [<a href="README-PTBR.md">Português (Brasil)</a>] | [<a href="README-EO.md">Esperanto</a>] | [<a href="README-KR.md">한국어</a>] | [<a href="README-AR.md">العربي</a>] | [<a href="README-VN.md">Tiếng Việt</a>] | [<a href="README-DA.md">Dansk</a>] | [<a href="README-GR.md">Ελληνικά</a>] | [<a href="README-TR.md">Türkçe</a>] | [<a href="README-NO.md">Norsk</a>] | [<a href="README-RO.md">Română</a>]<br>
|
||||
<b>이 README, <a href="https://github.com/rustdesk/rustdesk/tree/master/src/lang">RustDesk UI</a> 및 <a href="https://github.com/rustdesk/doc.rustdesk.com">RustDesk 문서</a>를 귀하의 모국어로 번역하는 데 도움이 필요합니다</b>
|
||||
</p>
|
||||
|
||||
@@ -46,9 +46,9 @@ Sciter 동적 라이브러리를 직접 다운로드하세요.
|
||||
[Linux](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so) |
|
||||
[macOS](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.osx/libsciter.dylib)
|
||||
|
||||
## 빌드를 위한 원시 단계
|
||||
## 빌드를_위한_원시_단계
|
||||
|
||||
- Rust 개발 환경과 C++ 빌드 환경을 준비합니다
|
||||
- Rust 개발 환경과 C++ 빌드 환경 준비
|
||||
|
||||
- [vcpkg](https://github.com/microsoft/vcpkg)를 설치하고 `VCPKG_ROOT` 환경 변수를 올바르게 설정합니다
|
||||
|
||||
@@ -125,7 +125,7 @@ mv libsciter-gtk.so target/debug
|
||||
VCPKG_ROOT=$HOME/vcpkg cargo run
|
||||
```
|
||||
|
||||
## Docker로 빌드하는 방법
|
||||
## Docker로_빌드하는_방법
|
||||
|
||||
먼저 리포지토리를 복제하고 Docker 컨테이너를 빌드합니다:
|
||||
|
||||
@@ -156,7 +156,7 @@ target/release/rustdesk
|
||||
|
||||
RustDesk 리포지토리의 루트에서 이러한 명령을 실행하고 있는지 확인하세요. 그렇지 않으면 응용 프로그램이 필요한 리소스를 찾지 못할 수 있습니다. 또한 `install` 또는 `run` 과 같은 다른 cargo 하위 명령은 호스트가 아닌 컨테이너 내부에 프로그램을 설치하거나 실행하므로 현재 이 방법을 통해 지원되지 않는다는 점에 유의하세요.
|
||||
|
||||
## 파일 구조
|
||||
## 파일_구조
|
||||
|
||||
- **[libs/hbb_common](https://github.com/rustdesk/rustdesk/tree/master/libs/hbb_common)**: 비디오 코덱, 구성, tcp/udp wrapper, protobuf, 파일 전송을 위한 fs 함수 및 기타 유틸리티 함수
|
||||
- **[libs/scrap](https://github.com/rustdesk/rustdesk/tree/master/libs/scrap)**: 화면 캡쳐
|
||||
|
||||
@@ -1,55 +1,82 @@
|
||||
<p align="center">
|
||||
<img src="../res/logo-header.svg" alt="RustDesk - Seu desktop remoto"><br>
|
||||
<a href="#servidores-públicos-grátis">Servidores</a> •
|
||||
<a href="#compilação-crua">Compilar</a> •
|
||||
<a href="#como-compilar-com-docker">Docker</a> •
|
||||
<a href="#compilar">Compilar</a> •
|
||||
<a href="#como-compilar-com-o-docker">Docker</a> •
|
||||
<a href="#estrutura-de-arquivos">Estrutura</a> •
|
||||
<a href="#screenshots">Screenshots</a><br>
|
||||
[<a href="../README.md">English</a>] | [<a href="README-UA.md">Українська</a>] | [<a href="README-CS.md">česky</a>] | [<a href="README-ZH.md">中文</a>] | [<a href="README-HU.md">Magyar</a>] | [<a href="README-ES.md">Español</a>] | [<a href="README-FA.md">فارسی</a>] | [<a href="README-FR.md">Français</a>] | [<a href="README-DE.md">Deutsch</a>] | [<a href="README-PL.md">Polski</a>] | [<a href="README-ID.md">Indonesian</a>] | [<a href="README-FI.md">Suomi</a>] | [<a href="README-ML.md">മലയാളം</a>] | [<a href="README-JP.md">日本語</a>] | [<a href="README-NL.md">Nederlands</a>] | [<a href="README-IT.md">Italiano</a>] | [<a href="README-RU.md">Русский</a>] | [<a href="README-EO.md">Esperanto</a>] | [<a href="README-KR.md">한국어</a>] | [<a href="README-AR.md">العربي</a>] | [<a href="README-VN.md">Tiếng Việt</a>] | [<a href="README-GR.md">Ελληνικά</a>]<br>
|
||||
<b>Precisamos de sua ajuda para traduzir este README e a <a href="https://github.com/rustdesk/rustdesk/tree/master/src/lang">UI do RustDesk</a> para sua língua nativa</b>
|
||||
<a href="#capturas-de-tela">Capturas de Tela</a><br>
|
||||
[<a href="../README.md">Inglês</a>] | [<a href="docs/README-UA.md">Ucraniano</a>] | [<a href="docs/README-CS.md">Tcheco</a>] | [<a href="docs/README-ZH.md">Chinês</a>] | [<a href="docs/README-HU.md">Húngaro</a>] | [<a href="docs/README-ES.md">Espanhol</a>] | [<a href="docs/README-FA.md">Persa</a>] | [<a href="docs/README-FR.md">Francês</a>] | [<a href="docs/README-DE.md">Alemão</a>] | [<a href="docs/README-PL.md">Polonês</a>] | [<a href="docs/README-ID.md">Indonésio</a>] | [<a href="docs/README-FI.md">Finlandês</a>] | [<a href="docs/README-ML.md">Malaiala</a>] | [<a href="docs/README-JP.md">Japonês</a>] | [<a href="docs/README-NL.md">Holandês</a>] | [<a href="docs/README-IT.md">Italiano</a>] | [<a href="docs/README-RU.md">Russo</a>] | [<a href="docs/README-EO.md">Esperanto</a>] | [<a href="docs/README-KR.md">Coreano</a>] | [<a href="docs/README-AR.md">Árabe</a>] | [<a href="docs/README-VN.md">Vietnamita</a>] | [<a href="docs/README-DA.md">Dinamarquês</a>] | [<a href="docs/README-GR.md">Grego</a>] | [<a href="docs/README-TR.md">Turco</a>] | [<a href="docs/README-NO.md">Norueguês</a>] | [<a href="docs/README-RO.md">Romeno</a>]<br>
|
||||
<b>Precisamos da sua ajuda para traduzir este README, a <a href="https://github.com/rustdesk/rustdesk/tree/master/src/lang">Interface do RustDesk</a> e a <a href="https://github.com/rustdesk/doc.rustdesk.com">Documentação do RustDesk</a> para o seu idioma nativo</b>
|
||||
</p>
|
||||
|
||||
> [!Caution]
|
||||
> **Aviso de Isenção de Responsabilidade por Uso Indevido:** <br>
|
||||
> Os desenvolvedores do RustDesk não toleram ou apoiam qualquer uso antiético ou ilegal deste software. O uso indevido, como acesso não autorizado, controle ou invasão de privacidade, viola estritamente nossas diretrizes. Os autores não são responsáveis por qualquer uso indevido do aplicativo.
|
||||
|
||||
|
||||
Converse conosco: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk)
|
||||
|
||||
[](https://rustdesk.com/pricing.html)
|
||||
[](https://rustdesk.com/pricing.html)
|
||||
|
||||
Mais um software de desktop remoto, escrito em Rust. Funciona por padrão, sem necessidade de configuração. Você tem completo controle de seus dados, sem se preocupar com segurança. Você pode usar nossos servidores de rendezvous/relay, [configurar seu próprio](https://rustdesk.com/server), ou [escrever seu próprio servidor de rendezvous/relay](https://github.com/rustdesk/rustdesk-server-demo).
|
||||
Mais uma solução de desktop remoto, escrita em Rust. Funciona imediatamente, sem necessidade de configuração. Você tem controle total dos seus dados, sem preocupações com segurança. Você pode usar nosso servidor de conexão/retransmissão (rendezvous/relay), [configurar o seu próprio](https://rustdesk.com/server) ou [escrever seu próprio servidor de conexão/retransmissão](https://github.com/rustdesk/rustdesk-server-demo).
|
||||
|
||||
RustDesk acolhe contribuições de todos. Leia [`docs/CONTRIBUTING.md`](CONTRIBUTING.md) para ver como começar.
|
||||

|
||||
|
||||
[**DOWNLOAD DE BINÁRIOS**](https://github.com/rustdesk/rustdesk/releases)
|
||||
O RustDesk acolhe a contribuição de todos. Veja [CONTRIBUTING.md](docs/CONTRIBUTING.md) para ajuda em como começar.
|
||||
|
||||
[**Perguntas Frequentes (FAQ)**](https://github.com/rustdesk/rustdesk/wiki/FAQ)
|
||||
|
||||
[**DOWNLOAD DOS BINÁRIOS**](https://github.com/rustdesk/rustdesk/releases)
|
||||
|
||||
[**VERSÕES NIGHTLY (EM DESENVOLVIMENTO)**](https://github.com/rustdesk/rustdesk/releases/tag/nightly)
|
||||
|
||||
[<img src="https://f-droid.org/badge/get-it-on.png"
|
||||
alt="Baixe no F-Droid"
|
||||
height="80">](https://f-droid.org/en/packages/com.carriez.flutter_hbb)
|
||||
[<img src="https://flathub.org/api/badge?svg&locale=en"
|
||||
alt="Baixe no Flathub"
|
||||
height="80">](https://flathub.org/apps/com.rustdesk.RustDesk)
|
||||
|
||||
## Dependências
|
||||
|
||||
Versões de desktop utilizam [sciter](https://sciter.com/) para a GUI, por favor baixe a biblioteca dinâmica sciter por conta própria.
|
||||
As versões de desktop usam Flutter ou Sciter (descontinuado) para a interface gráfica (GUI). Este tutorial é apenas para o Sciter, por ser mais fácil e amigável para começar. Verifique nosso [CI](https://github.com/rustdesk/rustdesk/blob/master/.github/workflows/flutter-build.yml) para instruções de compilação da versão em Flutter.
|
||||
|
||||
Por favor, faça o download da biblioteca dinâmica do Sciter por conta própria.
|
||||
|
||||
[Windows](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.win/x64/sciter.dll) |
|
||||
[Linux](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so) |
|
||||
[MacOS](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.osx/libsciter.dylib)
|
||||
[macOS](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.osx/libsciter.dylib)
|
||||
|
||||
## Compilação crua
|
||||
## Passos básicos para compilar
|
||||
|
||||
- Prepare seu ambiente de desenvolvimento Rust e ambiente de compilação C++
|
||||
- Prepare seu ambiente de desenvolvimento Rust e o ambiente de compilação C++
|
||||
|
||||
- Instale [vcpkg](https://github.com/microsoft/vcpkg), e configure a variável de ambiente `VCPKG_ROOT` corretamente
|
||||
- Instale o [vcpkg](https://github.com/microsoft/vcpkg) e configure a variável de ambiente `VCPKG_ROOT` corretamente
|
||||
|
||||
- Windows: vcpkg install libvpx:x64-windows-static libyuv:x64-windows-static opus:x64-windows-static aom:x64-windows-static
|
||||
- Linux/MacOS: vcpkg install libvpx libyuv opus aom
|
||||
- Windows: `vcpkg install libvpx:x64-windows-static libyuv:x64-windows-static opus:x64-windows-static aom:x64-windows-static`
|
||||
- Linux/macOS: `vcpkg install libvpx libyuv opus aom`
|
||||
|
||||
- Execute `cargo run`
|
||||
|
||||
## Como compilar no Linux
|
||||
## [Compilar](https://rustdesk.com/docs/en/dev/build/)
|
||||
|
||||
## Como Compilar no Linux
|
||||
|
||||
### Ubuntu 18 (Debian 10)
|
||||
|
||||
```sh
|
||||
sudo apt install -y g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake
|
||||
sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev
|
||||
```
|
||||
|
||||
### openSUSE Tumbleweed
|
||||
|
||||
```sh
|
||||
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel
|
||||
```
|
||||
|
||||
### Fedora 28 (CentOS 8)
|
||||
|
||||
```sh
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel
|
||||
```
|
||||
|
||||
### Arch (Manjaro)
|
||||
@@ -58,7 +85,7 @@ sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-
|
||||
sudo pacman -Syu --needed unzip git cmake gcc curl wget yasm nasm zip make pkg-config clang gtk3 xdotool libxcb libxfixes alsa-lib pipewire
|
||||
```
|
||||
|
||||
### Instale vcpkg
|
||||
### Instalar o vcpkg
|
||||
|
||||
```sh
|
||||
git clone https://github.com/microsoft/vcpkg
|
||||
@@ -70,7 +97,7 @@ export VCPKG_ROOT=$HOME/vcpkg
|
||||
vcpkg/vcpkg install libvpx libyuv opus aom
|
||||
```
|
||||
|
||||
### Conserte libvpx (Para o Fedora)
|
||||
### Corrigir o libvpx (Para Fedora)
|
||||
|
||||
```sh
|
||||
cd vcpkg/buildtrees/libvpx/src
|
||||
@@ -83,12 +110,12 @@ cp libvpx.a $HOME/vcpkg/installed/x64-linux/lib/
|
||||
cd
|
||||
```
|
||||
|
||||
### Compile
|
||||
### Compilar
|
||||
|
||||
```sh
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
source $HOME/.cargo/env
|
||||
git clone https://github.com/rustdesk/rustdesk
|
||||
git clone --recurse-submodules https://github.com/rustdesk/rustdesk
|
||||
cd rustdesk
|
||||
mkdir -p target/debug
|
||||
wget https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so
|
||||
@@ -96,57 +123,57 @@ mv libsciter-gtk.so target/debug
|
||||
VCPKG_ROOT=$HOME/vcpkg cargo run
|
||||
```
|
||||
|
||||
## Como compilar com Docker
|
||||
## Como compilar com o Docker
|
||||
|
||||
Comece clonando o repositório e montando o container docker:
|
||||
Comece clonando o repositório e construindo o contêiner Docker:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/rustdesk/rustdesk
|
||||
cd rustdesk
|
||||
git submodule update --init --recursive
|
||||
docker build -t "rustdesk-builder" .
|
||||
```
|
||||
|
||||
Então, sempre que precisar compilar a aplicação, execute este comando:
|
||||
Depois, cada vez que precisar compilar o aplicativo, execute o seguinte comando:
|
||||
|
||||
```sh
|
||||
docker run --rm -it -v $PWD:/home/user/rustdesk -v rustdesk-git-cache:/home/user/.cargo/git -v rustdesk-registry-cache:/home/user/.cargo/registry -e PUID="$(id -u)" -e PGID="$(id -g)" rustdesk-builder
|
||||
```
|
||||
|
||||
Note que a primeira compilação pode demorar mais antes que as dependências sejam armazenadas em cache, as compilações subsequentes serão mais rápidas. Adicionalmente, se você precisar especificar argumentos diferentes para o comando de compilação, você pode fazê-lo ao final do comando na posição do `<OPTIONAL-ARGS>`. Por exemplo, se você gostaria de compilar uma versão de release otimizada, você executaria o comando acima seguido de `--release`. O executável gerado estará disponível no diretório alvo no seu sistema, e pode ser executado com:
|
||||
Note que a primeira compilação pode demorar mais até que as dependências sejam armazenadas em cache; as compilações subsequentes serão mais rápidas. Além disso, se você precisar especificar argumentos diferentes para o comando de compilação, poderá fazê-lo ao final do comando na posição `<ARGUMENTOS-OPCIONAIS>`. Por exemplo, se você quiser compilar uma versão de lançamento (release) otimizada, executaria o comando acima seguido de `--release`. O executável resultante estará disponível na pasta `target` do seu sistema e pode ser executado com:
|
||||
|
||||
```sh
|
||||
target/debug/rustdesk
|
||||
```
|
||||
|
||||
Ou, se estiver rodando um executável de release:
|
||||
Ou, se estiver executando o executável de lançamento:
|
||||
|
||||
```sh
|
||||
target/release/rustdesk
|
||||
```
|
||||
|
||||
Por favor verifique que está executando estes comandos da raiz do repositório do RustDesk, senão a aplicação pode não encontrar os recursos necessários. Note também que outros subcomandos do cargo como `install` ou `run` não são suportados atualmente via este método, já que eles iriam instalar ou rodar o programa dentro do container ao invés do host.
|
||||
Certifique-se de executar esses comandos a partir da raiz do repositório do RustDesk, do contrário o aplicativo pode não encontrar os recursos necessários. Note também que outros subcomandos do cargo, como `install` ou `run`, não são suportados atualmente por este método, pois instalariam ou executariam o programa dentro do contêiner em vez de no sistema hospedeiro.
|
||||
|
||||
## Estrutura de arquivos
|
||||
## Estrutura de Arquivos
|
||||
|
||||
- **[libs/hbb_common](https://github.com/rustdesk/rustdesk/tree/master/libs/hbb_common)**: codec de vídeo, configurações, wrapper de tcp/udp, protobuf, funções de sistema de arquivos para transferência de arquivos, e outras funções utilitárias
|
||||
- **[libs/scrap](https://github.com/rustdesk/rustdesk/tree/master/libs/scrap)**: captura de tela
|
||||
- **[libs/enigo](https://github.com/rustdesk/rustdesk/tree/master/libs/enigo)**: controle de teclado/mouse específico a cada plataforma
|
||||
- **[src/ui](https://github.com/rustdesk/rustdesk/tree/master/src/ui)**: GUI
|
||||
- **[src/server](https://github.com/rustdesk/rustdesk/tree/master/src/server)**: serviços de áudio/área de transferência/entrada/vídeo, e conexões de rede
|
||||
- **[src/client.rs](https://github.com/rustdesk/rustdesk/tree/master/src/client.rs)**: iniciar uma conexão "peer to peer"
|
||||
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Comunicação com [rustdesk-server](https://github.com/rustdesk/rustdesk-server), aguardar pela conexão remota direta (TCP hole punching) ou conexão indireta (relayed)
|
||||
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: código específico a cada plataforma
|
||||
- **[libs/hbb_common](https://github.com/rustdesk/rustdesk/tree/master/libs/hbb_common)**: codec de vídeo, configuração, encapsulador (wrapper) tcp/udp, protobuf, funções de sistema de arquivos para transferência de arquivos e algumas outras funções utilitárias.
|
||||
- **[libs/scrap](https://github.com/rustdesk/rustdesk/tree/master/libs/scrap)**: captura de tela.
|
||||
- **[libs/enigo](https://github.com/rustdesk/rustdesk/tree/master/libs/enigo)**: controle de teclado/mouse específico de cada plataforma.
|
||||
- **[libs/clipboard](https://github.com/rustdesk/rustdesk/tree/master/libs/clipboard)**: implementação de copiar e colar arquivos para Windows, Linux e macOS.
|
||||
- **[src/ui](https://github.com/rustdesk/rustdesk/tree/master/src/ui)**: interface Sciter antiga (descontinuada).
|
||||
- **[src/server](https://github.com/rustdesk/rustdesk/tree/master/src/server)**: serviços de áudio/área de transferência/entrada/vídeo e conexões de rede.
|
||||
- **[src/client.rs](https://github.com/rustdesk/rustdesk/tree/master/src/client.rs)**: inicia uma conexão direta (peer connection).
|
||||
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Comunica-se com o [rustdesk-server](https://github.com/rustdesk/rustdesk-server), aguarda por conexão remota direta (perfuração de túnel TCP / hole punching) ou retransmitida.
|
||||
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: código específico de cada plataforma.
|
||||
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: código Flutter para desktop e dispositivos móveis.
|
||||
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/v1/js)**: JavaScript para o cliente web do Flutter.
|
||||
|
||||
> [!Cuidadob]
|
||||
> **Aviso de uso indevido:** <br>
|
||||
> Os desenvolvedores do RustDesk não aprovam nem apoiam qualquer uso antiético ou ilegal deste software. O uso indevido, como acesso não autorizado, controle ou invasão de privacidade, é estritamente contra nossas diretrizes. Os autores não são responsáveis por qualquer uso indevido da aplicação.
|
||||
## Capturas de Tela
|
||||
|
||||
## Screenshots
|
||||

|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
@@ -33,4 +33,4 @@ if [ -z $release ]; then
|
||||
fi
|
||||
set -f
|
||||
#shellcheck disable=2086
|
||||
VCPKG_ROOT=/vcpkg cargo build $argv
|
||||
VCPKG_ROOT=/vcpkg cargo build --locked $argv
|
||||
|
||||
@@ -8,6 +8,7 @@ package com.carriez.flutter_hbb
|
||||
|
||||
import android.accessibilityservice.AccessibilityService
|
||||
import android.accessibilityservice.GestureDescription
|
||||
import android.content.Intent
|
||||
import android.graphics.Path
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
@@ -68,6 +69,16 @@ class InputService : AccessibilityService() {
|
||||
get() = ctx != null
|
||||
}
|
||||
|
||||
private fun notifyInputState() {
|
||||
val inputState = isOpen.toString()
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
MainActivity.flutterMethodChannel?.invokeMethod(
|
||||
"on_state_changed",
|
||||
mapOf("name" to "input", "value" to inputState)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val logTag = "input service"
|
||||
private var leftIsDown = false
|
||||
private var touchPath = Path()
|
||||
@@ -716,6 +727,7 @@ class InputService : AccessibilityService() {
|
||||
override fun onServiceConnected() {
|
||||
super.onServiceConnected()
|
||||
ctx = this
|
||||
notifyInputState()
|
||||
val info = AccessibilityServiceInfo()
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
info.flags = FLAG_INPUT_METHOD_EDITOR or FLAG_RETRIEVE_INTERACTIVE_WINDOWS
|
||||
@@ -734,8 +746,16 @@ class InputService : AccessibilityService() {
|
||||
|
||||
override fun onDestroy() {
|
||||
ctx = null
|
||||
// Keep this fallback even though onUnbind usually notifies first.
|
||||
notifyInputState()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onUnbind(intent: Intent?): Boolean {
|
||||
ctx = null
|
||||
notifyInputState()
|
||||
return super.onUnbind(intent)
|
||||
}
|
||||
|
||||
override fun onInterrupt() {}
|
||||
}
|
||||
|
||||
@@ -200,12 +200,13 @@ class MainActivity : FlutterActivity() {
|
||||
"stop_input" -> {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
InputService.ctx?.disableSelf()
|
||||
} else {
|
||||
InputService.ctx = null
|
||||
Companion.flutterMethodChannel?.invokeMethod(
|
||||
"on_state_changed",
|
||||
mapOf("name" to "input", "value" to InputService.isOpen.toString())
|
||||
)
|
||||
}
|
||||
InputService.ctx = null
|
||||
Companion.flutterMethodChannel?.invokeMethod(
|
||||
"on_state_changed",
|
||||
mapOf("name" to "input", "value" to InputService.isOpen.toString())
|
||||
)
|
||||
result.success(true)
|
||||
}
|
||||
"cancel_notification" -> {
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 604 B |
7
flutter/assets/display_switcher.svg
Normal file
7
flutter/assets/display_switcher.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||
<g fill="#000000" fill-rule="evenodd">
|
||||
<rect x="4" y="6" width="24" height="16" rx="3"/>
|
||||
<rect x="14.5" y="22" width="3" height="2"/>
|
||||
<rect x="9.5" y="24" width="13" height="2.5" rx="1.25"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 303 B |
@@ -460,6 +460,7 @@ build)
|
||||
--target "${RUST_TARGET}" \
|
||||
--bindgen \
|
||||
build \
|
||||
--locked \
|
||||
--release \
|
||||
--features "${RUSTDESK_FEATURES}"
|
||||
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
#!/usr/bin/env bash
|
||||
cargo build --features flutter,hwcodec --release --target aarch64-apple-ios --lib
|
||||
cargo build --locked --features flutter,hwcodec --release --target aarch64-apple-ios --lib
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
#!/usr/bin/env bash
|
||||
cargo build --features flutter --release --target x86_64-apple-ios --lib
|
||||
cargo build --locked --features flutter --release --target x86_64-apple-ios --lib
|
||||
|
||||
@@ -716,6 +716,17 @@ closeConnection({String? id}) {
|
||||
stateGlobal.isInMainPage = true;
|
||||
} else {
|
||||
final controller = Get.find<DesktopTabController>();
|
||||
if (controller.tabType == DesktopTabType.terminal &&
|
||||
controller.onCloseWindow != null) {
|
||||
// Terminal windows are scoped to one peer. The optional id passed to
|
||||
// closeConnection() is that peer id, not a terminal tab key
|
||||
// (${peerId}_${terminalId}). Closing from terminal dialogs should close
|
||||
// the peer's whole terminal window, including all terminal tabs.
|
||||
unawaited(controller.onCloseWindow!().catchError((e, _) {
|
||||
debugPrint('[closeConnection] Failed to close terminal window: $e');
|
||||
}));
|
||||
return;
|
||||
}
|
||||
controller.closeBy(id);
|
||||
}
|
||||
}
|
||||
@@ -3702,14 +3713,54 @@ Widget loadPowered(BuildContext context) {
|
||||
).marginOnly(top: 6);
|
||||
}
|
||||
|
||||
// max 300 x 60
|
||||
Widget loadLogo() {
|
||||
return FutureBuilder<ByteData>(
|
||||
future: rootBundle.load('assets/logo.png'),
|
||||
builder: (BuildContext context, AsyncSnapshot<ByteData> snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
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) {
|
||||
final image = Image.asset(
|
||||
'assets/logo.png',
|
||||
asset,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (ctx, error, stackTrace) {
|
||||
return Container();
|
||||
@@ -3721,9 +3772,14 @@ Widget loadLogo() {
|
||||
).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,
|
||||
@@ -4179,8 +4235,7 @@ Widget? buildAvatarWidget({
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) =>
|
||||
fallback ?? SizedBox.shrink(),
|
||||
errorBuilder: (_, __, ___) => fallback ?? SizedBox.shrink(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
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';
|
||||
@@ -5,27 +8,136 @@ 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';
|
||||
|
||||
late void Function(VoidCallback) setState;
|
||||
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;
|
||||
|
||||
bool get needLoad => !_isPeersLoaded && !_isPeersLoading;
|
||||
bool get isPeersLoaded => _isPeersLoaded;
|
||||
|
||||
AllPeersLoader();
|
||||
AllPeersLoader({
|
||||
@visibleForTesting Future<void> Function(List<String> ids)? queryOnlines,
|
||||
@visibleForTesting Duration? queryOnlineDebounce,
|
||||
}) : _queryOnlines = queryOnlines ?? ((ids) => bind.queryOnlines(ids: ids)),
|
||||
_queryOnlineDebounce =
|
||||
queryOnlineDebounce ?? _defaultQueryOnlineDebounce;
|
||||
|
||||
void init(void Function(VoidCallback) setState) {
|
||||
this.setState = setState;
|
||||
_setState = setState;
|
||||
_isCleared = false;
|
||||
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() {
|
||||
@@ -33,6 +145,11 @@ 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 {
|
||||
@@ -59,50 +176,106 @@ class AllPeersLoader {
|
||||
}
|
||||
|
||||
void _mergeAllPeers() {
|
||||
Map<String, dynamic> combinedPeers = {};
|
||||
for (var p in gFFI.abModel.allPeers()) {
|
||||
if (!combinedPeers.containsKey(p.id)) {
|
||||
combinedPeers[p.id] = p.toJson();
|
||||
}
|
||||
if (_isCleared) {
|
||||
return;
|
||||
}
|
||||
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(() {
|
||||
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(() {
|
||||
_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 {
|
||||
|
||||
@@ -24,6 +24,35 @@ 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;
|
||||
@@ -74,11 +103,8 @@ class ButtonOP extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final opLabel = {
|
||||
'github': 'GitHub',
|
||||
'gitlab': 'GitLab'
|
||||
}[op.toLowerCase()] ??
|
||||
toCapitalized(op);
|
||||
final branding = _oidcProviderBranding(op);
|
||||
final buttonLabel = translate("Continue with {${branding.label}}");
|
||||
return Row(children: [
|
||||
Container(
|
||||
height: height,
|
||||
@@ -95,7 +121,7 @@ class ButtonOP extends StatelessWidget {
|
||||
SizedBox(
|
||||
width: 30,
|
||||
child: _IconOP(
|
||||
op: op,
|
||||
op: branding.iconKey,
|
||||
icon: icon,
|
||||
margin: EdgeInsets.only(right: 5),
|
||||
),
|
||||
@@ -103,8 +129,7 @@ class ButtonOP extends StatelessWidget {
|
||||
Expanded(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Center(
|
||||
child: Text(translate("Continue with {$opLabel}"))),
|
||||
child: Center(child: Text(buttonLabel)),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -532,9 +532,7 @@ class _RawTouchGestureDetectorRegionState
|
||||
// Official
|
||||
TapGestureRecognizer:
|
||||
GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
|
||||
() => TapGestureRecognizer(
|
||||
supportedDevices: kTouchBasedDeviceKinds,
|
||||
), (instance) {
|
||||
() => TapGestureRecognizer(), (instance) {
|
||||
instance
|
||||
..onTapDown = onTapDown
|
||||
..onTapUp = onTapUp
|
||||
@@ -542,18 +540,14 @@ class _RawTouchGestureDetectorRegionState
|
||||
}),
|
||||
DoubleTapGestureRecognizer:
|
||||
GestureRecognizerFactoryWithHandlers<DoubleTapGestureRecognizer>(
|
||||
() => DoubleTapGestureRecognizer(
|
||||
supportedDevices: kTouchBasedDeviceKinds,
|
||||
), (instance) {
|
||||
() => DoubleTapGestureRecognizer(), (instance) {
|
||||
instance
|
||||
..onDoubleTapDown = onDoubleTapDown
|
||||
..onDoubleTap = onDoubleTap;
|
||||
}),
|
||||
LongPressGestureRecognizer:
|
||||
GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>(
|
||||
() => LongPressGestureRecognizer(
|
||||
supportedDevices: kTouchBasedDeviceKinds,
|
||||
), (instance) {
|
||||
() => LongPressGestureRecognizer(), (instance) {
|
||||
instance
|
||||
..onLongPressDown = onLongPressDown
|
||||
..onLongPressUp = onLongPressUp
|
||||
@@ -563,9 +557,7 @@ class _RawTouchGestureDetectorRegionState
|
||||
// Customized
|
||||
HoldTapMoveGestureRecognizer:
|
||||
GestureRecognizerFactoryWithHandlers<HoldTapMoveGestureRecognizer>(
|
||||
() => HoldTapMoveGestureRecognizer(
|
||||
supportedDevices: kTouchBasedDeviceKinds,
|
||||
),
|
||||
() => HoldTapMoveGestureRecognizer(),
|
||||
(instance) => instance
|
||||
..onHoldDragStart = onHoldDragStart
|
||||
..onHoldDragUpdate = onHoldDragUpdate
|
||||
@@ -573,18 +565,14 @@ class _RawTouchGestureDetectorRegionState
|
||||
..onHoldDragEnd = onHoldDragEnd),
|
||||
DoubleFinerTapGestureRecognizer:
|
||||
GestureRecognizerFactoryWithHandlers<DoubleFinerTapGestureRecognizer>(
|
||||
() => DoubleFinerTapGestureRecognizer(
|
||||
supportedDevices: kTouchBasedDeviceKinds,
|
||||
), (instance) {
|
||||
() => DoubleFinerTapGestureRecognizer(), (instance) {
|
||||
instance
|
||||
..onDoubleFinerTap = onDoubleFinerTap
|
||||
..onDoubleFinerTapDown = onDoubleFinerTapDown;
|
||||
}),
|
||||
CustomTouchGestureRecognizer:
|
||||
GestureRecognizerFactoryWithHandlers<CustomTouchGestureRecognizer>(
|
||||
() => CustomTouchGestureRecognizer(
|
||||
supportedDevices: kTouchBasedDeviceKinds,
|
||||
), (instance) {
|
||||
() => CustomTouchGestureRecognizer(), (instance) {
|
||||
instance.onOneFingerPanStart =
|
||||
(DragStartDetails d) => onOneFingerPanStart(context, d);
|
||||
instance
|
||||
|
||||
@@ -13,8 +13,84 @@ 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool _isWindowsMode1PrivacyImpl(String privacyModeImpl) {
|
||||
return privacyModeImpl == kPrivacyModeImplMag ||
|
||||
privacyModeImpl == kPrivacyModeImplExcludeFromCapture;
|
||||
}
|
||||
|
||||
// macOS privacy mode blacks out all online displays. Windows Mode 1 also
|
||||
// covers every local monitor with privacy overlay windows, so remote display
|
||||
// switching does not weaken local privacy protection.
|
||||
//
|
||||
// Keep this separate from the capture backend capability. The legacy Windows
|
||||
// magnifier capturer is not reliable for multi-monitor capture; WebRTC's
|
||||
// screen_capturer_win_magnifier also disables it when SM_CMONITORS != 1:
|
||||
// https://webrtc.googlesource.com/src/+/1845922d5a1bf9c27deeffb4a8c8daea124434c1/modules/desktop_capture/win/screen_capturer_win_magnifier.cc
|
||||
bool allowDisplaySwitchInPrivacyMode(PeerInfo pi, String privacyModeImpl) {
|
||||
return pi.platform == kPeerPlatformMacOS ||
|
||||
(pi.platform == kPeerPlatformWindows &&
|
||||
_isWindowsMode1PrivacyImpl(privacyModeImpl) &&
|
||||
versionCmp(pi.version, '1.4.8') >= 0);
|
||||
}
|
||||
|
||||
class TTextMenu {
|
||||
final Widget child;
|
||||
@@ -87,12 +163,179 @@ handleOsPasswordAction(
|
||||
}
|
||||
}
|
||||
|
||||
void showWaylandKeyboardInputWarningDialog(
|
||||
{required String id,
|
||||
required String connectionId,
|
||||
required FFI ffi,
|
||||
required Future<void> Function() onEnable}) {
|
||||
bool remember = false;
|
||||
bool consentInProgress = false;
|
||||
bool dialogClosed = false;
|
||||
|
||||
final dialogFuture = ffi.dialogManager.show((setState, close, context) {
|
||||
void safeSetState(VoidCallback fn) {
|
||||
if (dialogClosed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setState(fn);
|
||||
} catch (e) {
|
||||
debugPrint('Ignore setState after dialog disposal: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void closeDialog() {
|
||||
if (dialogClosed) {
|
||||
return;
|
||||
}
|
||||
dialogClosed = true;
|
||||
close();
|
||||
}
|
||||
|
||||
Future<void> enableAndContinue() async {
|
||||
if (consentInProgress || dialogClosed) {
|
||||
return;
|
||||
}
|
||||
consentInProgress = true;
|
||||
safeSetState(() {});
|
||||
try {
|
||||
await onEnable();
|
||||
} catch (e, st) {
|
||||
debugPrint('Failed to enable Wayland keyboard input consent: $e');
|
||||
debugPrintStack(stackTrace: st);
|
||||
consentInProgress = false;
|
||||
safeSetState(() {});
|
||||
return;
|
||||
}
|
||||
|
||||
ffi.inputModel.keyboardInputAllowed = true;
|
||||
var rememberPersisted = true;
|
||||
if (remember) {
|
||||
try {
|
||||
await bind.mainSetPeerOption(
|
||||
id: id,
|
||||
key: kPeerOptionAllowWaylandKeyboard,
|
||||
value: bool2option(kPeerOptionAllowWaylandKeyboard, true));
|
||||
} catch (e) {
|
||||
rememberPersisted = false;
|
||||
debugPrint('Failed to persist Wayland keyboard input consent: $e');
|
||||
}
|
||||
}
|
||||
// Always suppress prompt for current connection after explicit consent.
|
||||
setWaylandKeyboardPromptSuppressedForConnection(connectionId, true);
|
||||
closeDialog();
|
||||
if (remember && !rememberPersisted) {
|
||||
// It's a rare edge case that persisting the user's choice fails.
|
||||
// Failed to persist the user's choice, but still allow keyboard input for current session.
|
||||
showToast(translate('Failed'));
|
||||
}
|
||||
}
|
||||
|
||||
void cancel() {
|
||||
if (consentInProgress) {
|
||||
return;
|
||||
}
|
||||
closeDialog();
|
||||
}
|
||||
|
||||
return CustomAlertDialog(
|
||||
title: null,
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
msgboxContent(
|
||||
'',
|
||||
'wayland-keyboard-input-disabled-tip',
|
||||
'wayland-keyboard-input-consent-tip',
|
||||
),
|
||||
SizedBox(height: isMobile ? 2 : 6),
|
||||
if (isMobile) ...[
|
||||
Text(
|
||||
translate('wayland-keyboard-input-applies-to-tip'),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||
).marginOnly(bottom: 6),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
waylandKeyboardScopeChip(
|
||||
context, translate('Send clipboard keystrokes')),
|
||||
waylandKeyboardScopeChip(
|
||||
context, translate('wayland-soft-keyboard-input-label')),
|
||||
],
|
||||
).marginOnly(bottom: 10),
|
||||
],
|
||||
TextButton(
|
||||
onPressed: consentInProgress
|
||||
? null
|
||||
: () async {
|
||||
try {
|
||||
final opened = await openWaylandKeyboardIssueUrl();
|
||||
if (!opened) {
|
||||
// Opening this optional help link almost never fails in
|
||||
// normal desktop environments. Keep the result handled
|
||||
// for review hygiene, but avoid a low-value user toast.
|
||||
debugPrint('Failed to open Wayland keyboard issue URL');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'Failed to open Wayland keyboard issue URL: $e');
|
||||
}
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.blue,
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
child: Text(
|
||||
translate('Why this happens'),
|
||||
style: const TextStyle(decoration: TextDecoration.underline),
|
||||
),
|
||||
).marginOnly(bottom: 6),
|
||||
CheckboxListTile(
|
||||
value: remember,
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
title: Text(translate('remember-wayland-keyboard-choice-tip')),
|
||||
onChanged: consentInProgress
|
||||
? null
|
||||
: (v) {
|
||||
safeSetState(() => remember = v == true);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
dialogButton(
|
||||
'Cancel',
|
||||
onPressed: consentInProgress ? null : cancel,
|
||||
isOutline: true,
|
||||
),
|
||||
dialogButton(
|
||||
'OK',
|
||||
onPressed:
|
||||
consentInProgress ? null : () => unawaited(enableAndContinue()),
|
||||
),
|
||||
],
|
||||
onCancel: consentInProgress ? null : cancel,
|
||||
onSubmit: consentInProgress ? null : () => unawaited(enableAndContinue()),
|
||||
);
|
||||
}, clickMaskDismiss: false, backDismiss: false);
|
||||
unawaited(dialogFuture.whenComplete(() => dialogClosed = true));
|
||||
}
|
||||
|
||||
List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
||||
final ffiModel = ffi.ffiModel;
|
||||
final pi = ffiModel.pi;
|
||||
final perms = ffiModel.permissions;
|
||||
final sessionId = ffi.sessionId;
|
||||
final isDefaultConn = ffi.connType == ConnType.defaultConn;
|
||||
final isWaylandPeer = pi.platform == kPeerPlatformLinux && pi.isWayland;
|
||||
|
||||
List<TTextMenu> v = [];
|
||||
// elevation
|
||||
@@ -142,11 +385,60 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
||||
v.add(TTextMenu(
|
||||
child: Text(translate('Send clipboard keystrokes')),
|
||||
onPressed: () async {
|
||||
ClipboardData? data = await Clipboard.getData(Clipboard.kTextPlain);
|
||||
if (data != null && data.text != null) {
|
||||
bind.sessionInputString(
|
||||
sessionId: sessionId, value: data.text ?? "");
|
||||
Future<void> sendClipboardKeystrokes() async {
|
||||
ClipboardData? data = await Clipboard.getData(Clipboard.kTextPlain);
|
||||
if (data != null && data.text != null) {
|
||||
bind.sessionInputString(
|
||||
sessionId: sessionId, value: data.text ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
final allowWaylandKeyboard =
|
||||
mainGetPeerBoolOptionSync(id, kPeerOptionAllowWaylandKeyboard);
|
||||
if (shouldShowWaylandKeyboardPrompt(
|
||||
connectionId: sessionId.toString(),
|
||||
isWaylandPeer: isWaylandPeer,
|
||||
allowWaylandKeyboardRemembered: allowWaylandKeyboard,
|
||||
)) {
|
||||
ffi.inputModel.keyboardInputAllowed = false;
|
||||
showWaylandKeyboardInputWarningDialog(
|
||||
id: id,
|
||||
connectionId: sessionId.toString(),
|
||||
ffi: ffi,
|
||||
onEnable: sendClipboardKeystrokes,
|
||||
);
|
||||
return;
|
||||
}
|
||||
await sendClipboardKeystrokes();
|
||||
}));
|
||||
}
|
||||
if (isDefaultConn &&
|
||||
isWaylandPeer &&
|
||||
(mainGetPeerBoolOptionSync(id, kPeerOptionAllowWaylandKeyboard) ||
|
||||
isWaylandKeyboardPromptSuppressedForConnection(
|
||||
sessionId.toString()))) {
|
||||
v.add(TTextMenu(
|
||||
child: Text(translate('wayland-keyboard-input-reset-choice-tip')),
|
||||
onPressed: () async {
|
||||
var persistedCleared = false;
|
||||
try {
|
||||
await bind.mainSetPeerOption(
|
||||
id: id,
|
||||
key: kPeerOptionAllowWaylandKeyboard,
|
||||
value: bool2option(kPeerOptionAllowWaylandKeyboard, false));
|
||||
persistedCleared = true;
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'Failed to clear persisted Wayland keyboard permission: $e');
|
||||
} finally {
|
||||
clearWaylandKeyboardPromptSuppressedForConnection(
|
||||
sessionId.toString());
|
||||
ffi.inputModel.keyboardInputAllowed = false;
|
||||
if (isMobile) {
|
||||
await ffi.invokeMethod("enable_soft_keyboard", false);
|
||||
}
|
||||
}
|
||||
showToast(translate(persistedCleared ? 'Successful' : 'Failed'));
|
||||
}));
|
||||
}
|
||||
// reset canvas
|
||||
@@ -684,8 +976,10 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
|
||||
child: Text(translate('Lock after session end'))));
|
||||
}
|
||||
|
||||
final privacyModeState = PrivacyModeState.find(id);
|
||||
if (pi.isSupportMultiDisplay &&
|
||||
PrivacyModeState.find(id).isEmpty &&
|
||||
(privacyModeState.isEmpty ||
|
||||
allowDisplaySwitchInPrivacyMode(pi, privacyModeState.value)) &&
|
||||
pi.displaysCount.value > 1 &&
|
||||
bind.mainGetUserDefaultOption(key: kKeyShowMonitorsToolbar) == 'Y') {
|
||||
final value =
|
||||
@@ -759,7 +1053,8 @@ List<TToggleMenu> toolbarPrivacyMode(
|
||||
final ffiModel = ffi.ffiModel;
|
||||
final pi = ffiModel.pi;
|
||||
final sessionId = ffi.sessionId;
|
||||
final hasPrivacyModePermission = ffiModel.permissions['privacy_mode'] != false;
|
||||
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
|
||||
@@ -768,23 +1063,28 @@ List<TToggleMenu> toolbarPrivacyMode(
|
||||
return []; // No permission and not active, hide options.
|
||||
}
|
||||
|
||||
getDefaultMenu(Future<void> Function(SessionID sid, String opt) toggleFunc) {
|
||||
final enabled =
|
||||
!ffiModel.viewOnly && (hasPrivacyModePermission || privacyModeState.isNotEmpty);
|
||||
bool checkDisplayAllowedForPrivacyMode(String targetImplKey, bool turnOn) {
|
||||
if (!turnOn ||
|
||||
allowDisplaySwitchInPrivacyMode(pi, targetImplKey) ||
|
||||
(ffiModel.pi.currentDisplay == 0 &&
|
||||
!bind.sessionIsMultiUiSession(sessionId: sessionId))) {
|
||||
return true;
|
||||
}
|
||||
msgBox(sessionId, 'custom-nook-nocancel-hasclose', 'info',
|
||||
'Please switch to Display 1 first', '', ffi.dialogManager);
|
||||
return false;
|
||||
}
|
||||
|
||||
getDefaultMenu(Future<void> Function(SessionID sid, String opt) toggleFunc,
|
||||
String targetImplKey) {
|
||||
final enabled = !ffiModel.viewOnly &&
|
||||
(hasPrivacyModePermission || privacyModeState.isNotEmpty);
|
||||
return TToggleMenu(
|
||||
value: privacyModeState.isNotEmpty,
|
||||
onChanged: enabled
|
||||
? (value) {
|
||||
if (value == null) return;
|
||||
if (ffiModel.pi.currentDisplay != 0 &&
|
||||
ffiModel.pi.currentDisplay != kAllDisplayValue) {
|
||||
msgBox(
|
||||
sessionId,
|
||||
'custom-nook-nocancel-hasclose',
|
||||
'info',
|
||||
'Please switch to Display 1 first',
|
||||
'',
|
||||
ffi.dialogManager);
|
||||
if (!checkDisplayAllowedForPrivacyMode(targetImplKey, value)) {
|
||||
return;
|
||||
}
|
||||
final option = 'privacy-mode';
|
||||
@@ -802,7 +1102,7 @@ List<TToggleMenu> toolbarPrivacyMode(
|
||||
getDefaultMenu((sid, opt) async {
|
||||
bind.sessionToggleOption(sessionId: sid, value: opt);
|
||||
togglePrivacyModeTime = DateTime.now();
|
||||
})
|
||||
}, kPrivacyModeImplMag)
|
||||
];
|
||||
}
|
||||
if (privacyModeImpls.isEmpty) {
|
||||
@@ -816,7 +1116,7 @@ List<TToggleMenu> toolbarPrivacyMode(
|
||||
bind.sessionTogglePrivacyMode(
|
||||
sessionId: sid, implKey: implKey, on: privacyModeState.isEmpty);
|
||||
togglePrivacyModeTime = DateTime.now();
|
||||
})
|
||||
}, implKey)
|
||||
];
|
||||
} else {
|
||||
final visibleImpls = hasPrivacyModePermission
|
||||
@@ -837,6 +1137,9 @@ List<TToggleMenu> toolbarPrivacyMode(
|
||||
? (value) {
|
||||
if (value == null) return;
|
||||
if (value && !hasPrivacyModePermission) return;
|
||||
if (!checkDisplayAllowedForPrivacyMode(implKey, value)) {
|
||||
return;
|
||||
}
|
||||
togglePrivacyModeTime = DateTime.now();
|
||||
bind.sessionTogglePrivacyMode(
|
||||
sessionId: sessionId, implKey: implKey, on: value);
|
||||
|
||||
@@ -29,6 +29,10 @@ const String kPlatformAdditionsHasFileClipboard = "has_file_clipboard";
|
||||
const String kPlatformAdditionsSupportedPrivacyModeImpl =
|
||||
"supported_privacy_mode_impl";
|
||||
|
||||
const String kPrivacyModeImplMag = 'privacy_mode_impl_mag';
|
||||
const String kPrivacyModeImplExcludeFromCapture =
|
||||
'privacy_mode_impl_exclude_from_capture';
|
||||
|
||||
const String kPeerPlatformWindows = "Windows";
|
||||
const String kPeerPlatformLinux = "Linux";
|
||||
const String kPeerPlatformMacOS = "Mac OS";
|
||||
@@ -142,6 +146,10 @@ const String kOptionSwapLeftRightMouse = "swap-left-right-mouse";
|
||||
const String kOptionCodecPreference = "codec-preference";
|
||||
const String kOptionRemoteMenubarDragLeft = "remote-menubar-drag-left";
|
||||
const String kOptionRemoteMenubarDragRight = "remote-menubar-drag-right";
|
||||
const String kOptionRemoteMenubarEdge = "remote-menubar-edge";
|
||||
const String kOptionRemoteMenubarFraction = "remote-menubar-frac";
|
||||
const String kOptionAllowMultiEdgeToolbarDock =
|
||||
"allow-multi-edge-toolbar-dock";
|
||||
const String kOptionHideAbTagsPanel = "hideAbTagsPanel";
|
||||
const String kOptionRemoteMenubarState = "remoteMenubarState";
|
||||
const String kOptionPeerSorting = "peer-sorting";
|
||||
@@ -166,6 +174,8 @@ const String kOptionShowVirtualMouse = "show-virtual-mouse";
|
||||
const String kOptionVirtualMouseScale = "virtual-mouse-scale";
|
||||
const String kOptionShowVirtualJoystick = "show-virtual-joystick";
|
||||
const String kOptionAllowAskForNoteAtEndOfConnection = "allow-ask-for-note";
|
||||
const String kOptionAllowMonitorSwitchMainToolbar = "allow-monitor-switch-main-toolbar";
|
||||
const String kOptionAllowMonitorSwitchMinToolbar = "allow-monitor-switch-min-toolbar";
|
||||
const String kOptionEnableShowTerminalExtraKeys = "enable-show-terminal-extra-keys";
|
||||
|
||||
// network options
|
||||
|
||||
@@ -398,6 +398,7 @@ class _ConnectionPageState extends State<ConnectionPage>
|
||||
.contains(textToFind) ||
|
||||
peer.alias.toLowerCase().contains(textToFind))
|
||||
.toList();
|
||||
_allPeersLoader.queryOnlines(_autocompleteOpts);
|
||||
}
|
||||
return _autocompleteOpts;
|
||||
},
|
||||
|
||||
@@ -407,6 +407,7 @@ class _GeneralState extends State<_General> {
|
||||
final RxBool serviceStop =
|
||||
isWeb ? RxBool(false) : Get.find<RxBool>(tag: 'stop-service');
|
||||
RxBool serviceBtnEnabled = true.obs;
|
||||
final GlobalKey _minToolbarOptionKey = GlobalKey();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -482,15 +483,28 @@ class _GeneralState extends State<_General> {
|
||||
}
|
||||
|
||||
Widget other() {
|
||||
final incomingOnly = bind.isIncomingOnly();
|
||||
final outgoingOnly = bind.isOutgoingOnly();
|
||||
final showAutoUpdate = isWindows && bind.mainIsInstalled();
|
||||
final children = <Widget>[
|
||||
if (!isWeb && !bind.isIncomingOnly())
|
||||
if (!isWeb && !incomingOnly)
|
||||
_OptionCheckBox(context, 'Confirm before closing multiple tabs',
|
||||
kOptionEnableConfirmClosingTabs,
|
||||
isServer: false),
|
||||
_OptionCheckBox(context, 'Adaptive bitrate', kOptionEnableAbr),
|
||||
if (!incomingOnly)
|
||||
_OptionCheckBox(
|
||||
context,
|
||||
'allow-remote-toolbar-docking-any-edge',
|
||||
kOptionAllowMultiEdgeToolbarDock,
|
||||
isServer: false,
|
||||
update: (_) {
|
||||
reloadAllWindows();
|
||||
},
|
||||
),
|
||||
if (!isWeb && !outgoingOnly)
|
||||
_OptionCheckBox(context, 'Adaptive bitrate', kOptionEnableAbr),
|
||||
if (!isWeb) wallpaper(),
|
||||
if (!isWeb && !bind.isIncomingOnly()) ...[
|
||||
if (!isWeb && !incomingOnly) ...[
|
||||
_OptionCheckBox(
|
||||
context,
|
||||
'Open connection in new tab',
|
||||
@@ -529,40 +543,40 @@ class _GeneralState extends State<_General> {
|
||||
isServer: false,
|
||||
),
|
||||
),
|
||||
if (!isWeb && !bind.isCustomClient())
|
||||
_OptionCheckBox(
|
||||
context,
|
||||
'Check for software update on startup',
|
||||
kOptionEnableCheckUpdate,
|
||||
isServer: false,
|
||||
),
|
||||
if (showAutoUpdate)
|
||||
_OptionCheckBox(
|
||||
context,
|
||||
'Auto update',
|
||||
kOptionAllowAutoUpdate,
|
||||
isServer: true,
|
||||
),
|
||||
if (isWindows && !bind.isOutgoingOnly())
|
||||
_OptionCheckBox(
|
||||
context,
|
||||
'Capture screen using DirectX',
|
||||
kOptionDirectxCapture,
|
||||
),
|
||||
if (!bind.isIncomingOnly()) ...[
|
||||
_OptionCheckBox(
|
||||
context,
|
||||
'Enable UDP hole punching',
|
||||
kOptionEnableUdpPunch,
|
||||
isServer: false,
|
||||
),
|
||||
_OptionCheckBox(
|
||||
context,
|
||||
'Enable IPv6 P2P connection',
|
||||
kOptionEnableIpv6Punch,
|
||||
isServer: false,
|
||||
),
|
||||
],
|
||||
],
|
||||
if (!isWeb && !bind.isCustomClient())
|
||||
_OptionCheckBox(
|
||||
context,
|
||||
'Check for software update on startup',
|
||||
kOptionEnableCheckUpdate,
|
||||
isServer: false,
|
||||
),
|
||||
if (showAutoUpdate)
|
||||
_OptionCheckBox(
|
||||
context,
|
||||
'Auto update',
|
||||
kOptionAllowAutoUpdate,
|
||||
isServer: true,
|
||||
),
|
||||
if (isWindows && !outgoingOnly)
|
||||
_OptionCheckBox(
|
||||
context,
|
||||
'Capture screen using DirectX',
|
||||
kOptionDirectxCapture,
|
||||
),
|
||||
if (!isWeb && !incomingOnly) ...[
|
||||
_OptionCheckBox(
|
||||
context,
|
||||
'Enable UDP hole punching',
|
||||
kOptionEnableUdpPunch,
|
||||
isServer: false,
|
||||
),
|
||||
_OptionCheckBox(
|
||||
context,
|
||||
'Enable IPv6 P2P connection',
|
||||
kOptionEnableIpv6Punch,
|
||||
isServer: false,
|
||||
),
|
||||
],
|
||||
];
|
||||
|
||||
@@ -595,6 +609,47 @@ class _GeneralState extends State<_General> {
|
||||
},
|
||||
));
|
||||
}
|
||||
children.add(_OptionCheckBox(
|
||||
context,
|
||||
'Show monitor switch button on the main toolbar',
|
||||
kOptionAllowMonitorSwitchMainToolbar,
|
||||
isServer: false,
|
||||
update: (enabled) async {
|
||||
if (!enabled) {
|
||||
await mainSetLocalBoolOption(
|
||||
kOptionAllowMonitorSwitchMinToolbar, false);
|
||||
}
|
||||
if (mounted) setState(() {});
|
||||
reloadAllWindows();
|
||||
if (enabled) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final ctx = _minToolbarOptionKey.currentContext;
|
||||
if (ctx != null) {
|
||||
Scrollable.ensureVisible(
|
||||
ctx,
|
||||
alignment: 0.5,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
));
|
||||
if (mainGetLocalBoolOptionSync(kOptionAllowMonitorSwitchMainToolbar)) {
|
||||
children.add(KeyedSubtree(
|
||||
key: _minToolbarOptionKey,
|
||||
child: _OptionCheckBox(
|
||||
context,
|
||||
'Show on the minimized toolbar',
|
||||
kOptionAllowMonitorSwitchMinToolbar,
|
||||
isServer: false,
|
||||
update: (_) {
|
||||
reloadAllWindows();
|
||||
},
|
||||
).marginOnly(left: _kCheckBoxLeftMargin * 3),
|
||||
));
|
||||
}
|
||||
return _Card(title: 'Other', children: children);
|
||||
}
|
||||
|
||||
@@ -2419,7 +2474,7 @@ class _AboutState extends State<_About> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Copyright © ${DateTime.now().toString().substring(0, 4)} Purslane Ltd.\n$license',
|
||||
'Copyright © ${DateTime.now().toString().substring(0, 4)} Purslane Tech Pte. Ltd.\n$license',
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
Text(
|
||||
|
||||
@@ -65,7 +65,7 @@ class _InstallPageBodyState extends State<_InstallPageBody>
|
||||
late final TextEditingController controller;
|
||||
final RxBool startmenu = true.obs;
|
||||
final RxBool desktopicon = true.obs;
|
||||
final RxBool printer = true.obs;
|
||||
final RxBool printer = false.obs;
|
||||
final RxBool showProgress = false.obs;
|
||||
final RxBool btnEnabled = true.obs;
|
||||
|
||||
@@ -80,7 +80,7 @@ class _InstallPageBodyState extends State<_InstallPageBody>
|
||||
final installOptions = jsonDecode(bind.installInstallOptions());
|
||||
startmenu.value = installOptions['STARTMENUSHORTCUTS'] != '0';
|
||||
desktopicon.value = installOptions['DESKTOPSHORTCUTS'] != '0';
|
||||
printer.value = installOptions['PRINTER'] != '0';
|
||||
printer.value = installOptions['PRINTER'] == '1';
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -101,6 +101,9 @@ class _RemotePageState extends State<RemotePage>
|
||||
Function(bool)? _onEnterOrLeaveImage4Toolbar;
|
||||
|
||||
late FFI _ffi;
|
||||
Worker? _waylandKeyboardModeWorker;
|
||||
bool _waylandKeyboardModeNormalized = false;
|
||||
bool _waylandKeyboardModeNormalizing = false;
|
||||
|
||||
SessionID get sessionId => _ffi.sessionId;
|
||||
|
||||
@@ -178,6 +181,48 @@ class _RemotePageState extends State<RemotePage>
|
||||
// Register callback to cancel debounce timer when relative mouse mode is disabled
|
||||
_ffi.inputModel.onRelativeMouseModeDisabled =
|
||||
_cancelPointerLockCenterDebounceTimer;
|
||||
|
||||
_waylandKeyboardModeWorker = ever(_ffi.ffiModel.pi.isSet, (bool isSet) {
|
||||
if (isSet) {
|
||||
unawaited(_normalizeWaylandKeyboardModeIfNeeded());
|
||||
}
|
||||
});
|
||||
if (_ffi.ffiModel.pi.isSet.value) {
|
||||
unawaited(_normalizeWaylandKeyboardModeIfNeeded());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _normalizeWaylandKeyboardModeIfNeeded() async {
|
||||
if (!mounted ||
|
||||
_waylandKeyboardModeNormalized ||
|
||||
_waylandKeyboardModeNormalizing) {
|
||||
return;
|
||||
}
|
||||
_waylandKeyboardModeNormalizing = true;
|
||||
try {
|
||||
final pi = _ffi.ffiModel.pi;
|
||||
if (pi.platform != kPeerPlatformLinux || !pi.isWayland) return;
|
||||
final mapSupported = bind.sessionIsKeyboardModeSupported(
|
||||
sessionId: sessionId, mode: kKeyMapMode);
|
||||
if (!mapSupported) return;
|
||||
final current = await bind.sessionGetKeyboardMode(sessionId: sessionId);
|
||||
if (!mounted) return;
|
||||
if (current == kKeyMapMode) {
|
||||
_waylandKeyboardModeNormalized = true;
|
||||
return;
|
||||
}
|
||||
await bind.sessionSetKeyboardMode(
|
||||
sessionId: sessionId, value: kKeyMapMode);
|
||||
if (!mounted) return;
|
||||
await _ffi.inputModel.updateKeyboardMode();
|
||||
if (!mounted) return;
|
||||
_waylandKeyboardModeNormalized = true;
|
||||
} catch (e, st) {
|
||||
debugPrint('Failed to normalize Wayland keyboard mode: $e');
|
||||
debugPrintStack(stackTrace: st);
|
||||
} finally {
|
||||
_waylandKeyboardModeNormalizing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel the pointer lock center debounce timer
|
||||
@@ -318,6 +363,7 @@ class _RemotePageState extends State<RemotePage>
|
||||
|
||||
_pointerLockCenterDebounceTimer?.cancel();
|
||||
_pointerLockCenterDebounceTimer = null;
|
||||
_waylandKeyboardModeWorker?.dispose();
|
||||
// Clear callback reference to prevent memory leaks and stale references
|
||||
_ffi.inputModel.onRelativeMouseModeDisabled = null;
|
||||
// Relative mouse mode cleanup is centralized in FFI.close(closeSession: ...).
|
||||
@@ -331,6 +377,9 @@ class _RemotePageState extends State<RemotePage>
|
||||
_ffi.imageModel.disposeImage();
|
||||
_ffi.cursorModel.disposeImages();
|
||||
_rawKeyFocusNode.dispose();
|
||||
if (closeSession) {
|
||||
clearWaylandKeyboardPromptSuppressedForConnection(sessionId.toString());
|
||||
}
|
||||
await _ffi.close(closeSession: closeSession);
|
||||
_timer?.cancel();
|
||||
_ffi.dialogManager.dismissAll();
|
||||
|
||||
@@ -27,6 +27,7 @@ class TerminalPage extends StatefulWidget {
|
||||
final bool? isSharedPassword;
|
||||
final String? connToken;
|
||||
final int terminalId;
|
||||
|
||||
/// Tab key for focus management, passed from parent to avoid duplicate construction
|
||||
final String tabKey;
|
||||
final SimpleWrapper<State<TerminalPage>?> _lastState = SimpleWrapper(null);
|
||||
@@ -43,6 +44,9 @@ class TerminalPage extends StatefulWidget {
|
||||
|
||||
class _TerminalPageState extends State<TerminalPage>
|
||||
with AutomaticKeepAliveClientMixin {
|
||||
static const EdgeInsets _defaultTerminalPadding =
|
||||
EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0);
|
||||
|
||||
late FFI _ffi;
|
||||
late TerminalModel _terminalModel;
|
||||
double? _cellHeight;
|
||||
@@ -91,6 +95,13 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
// Register this terminal model with FFI for event routing
|
||||
_ffi.registerTerminalModel(widget.terminalId, _terminalModel);
|
||||
|
||||
// Auto-close tab when shell exits
|
||||
_terminalModel.onClosed = () {
|
||||
if (mounted) {
|
||||
widget.tabController.closeBy(widget.tabKey);
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize terminal connection
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
widget.tabController.onSelected?.call(widget.id);
|
||||
@@ -155,13 +166,27 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
// extra space left after dividing the available height by the height of a single
|
||||
// terminal row (`_cellHeight`) and distributing it evenly as top and bottom padding.
|
||||
EdgeInsets _calculatePadding(double heightPx) {
|
||||
if (_cellHeight == null) {
|
||||
return const EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0);
|
||||
final cellHeight = _cellHeight;
|
||||
if (!heightPx.isFinite ||
|
||||
heightPx <= 0 ||
|
||||
cellHeight == null ||
|
||||
!cellHeight.isFinite ||
|
||||
cellHeight <= 0) {
|
||||
return _defaultTerminalPadding;
|
||||
}
|
||||
final rows = (heightPx / cellHeight).floor();
|
||||
if (rows <= 0) {
|
||||
return _defaultTerminalPadding;
|
||||
}
|
||||
final extraSpace = heightPx - rows * cellHeight;
|
||||
if (!extraSpace.isFinite || extraSpace < 0) {
|
||||
return _defaultTerminalPadding;
|
||||
}
|
||||
final rows = (heightPx / _cellHeight!).floor();
|
||||
final extraSpace = heightPx - rows * _cellHeight!;
|
||||
final topBottom = extraSpace / 2.0;
|
||||
return EdgeInsets.symmetric(horizontal: 5.0, vertical: topBottom);
|
||||
return EdgeInsets.symmetric(
|
||||
horizontal: _defaultTerminalPadding.horizontal / 2,
|
||||
vertical: topBottom,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -46,6 +46,7 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
||||
.setTitle(getWindowNameWithId(id));
|
||||
};
|
||||
tabController.onRemoved = (_, id) => onRemoveId(id);
|
||||
tabController.onCloseWindow = _closeWindowFromConnection;
|
||||
final terminalId = params['terminalId'] ?? _nextTerminalId++;
|
||||
tabController.add(_createTerminalTab(
|
||||
peerId: params['id'],
|
||||
@@ -144,6 +145,8 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
||||
_windowClosing = true;
|
||||
final tabKeys = tabController.state.value.tabs.map((t) => t.key).toList();
|
||||
// Remove all UI tabs immediately (same instant behavior as the old tabController.clear())
|
||||
// Keep the cleanup target lookup below synchronous before its first await:
|
||||
// it relies on the current frame still retaining each TerminalPage's FFI/model.
|
||||
tabController.clear();
|
||||
// Run session cleanup in parallel with bounded timeout (closeTerminal() has internal 3s timeout).
|
||||
// Skip tabs already being closed by a concurrent _closeTab() to avoid duplicate FFI calls.
|
||||
@@ -368,8 +371,34 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
||||
final persistentSessions =
|
||||
args['persistent_sessions'] as List<dynamic>? ?? [];
|
||||
final sortedSessions = persistentSessions.whereType<int>().toList()..sort();
|
||||
var peerId = args['peer_id'] as String? ?? '';
|
||||
if (peerId.isEmpty) {
|
||||
if (tabController.state.value.tabs.isEmpty ||
|
||||
tabController.state.value.selected >=
|
||||
tabController.state.value.tabs.length) {
|
||||
debugPrint('[TerminalTabPage] Skip restore: no selected tab');
|
||||
return;
|
||||
}
|
||||
final currentTab = tabController.state.value.selectedTabInfo;
|
||||
final parsed = _parseTabKey(currentTab.key);
|
||||
if (parsed == null) return;
|
||||
peerId = parsed.$1;
|
||||
}
|
||||
final existingTerminalIds = tabController.state.value.tabs
|
||||
.map((tab) => _parseTabKey(tab.key))
|
||||
.where((parsed) => parsed != null && parsed.$1 == peerId)
|
||||
.map((parsed) => parsed!.$2)
|
||||
.toSet();
|
||||
if (existingTerminalIds.isEmpty) {
|
||||
debugPrint(
|
||||
'[TerminalTabPage] Skip restore: no seed tab for peer $peerId');
|
||||
return;
|
||||
}
|
||||
for (final terminalId in sortedSessions) {
|
||||
_addNewTerminalForCurrentPeer(terminalId: terminalId);
|
||||
if (!existingTerminalIds.add(terminalId)) {
|
||||
continue;
|
||||
}
|
||||
_addNewTerminal(peerId, terminalId: terminalId);
|
||||
// A delay is required to ensure the UI has sufficient time to update
|
||||
// before adding the next terminal. Without this delay, `_TerminalPageState::dispose()`
|
||||
// may be called prematurely while the tab widget is still in the tab controller.
|
||||
@@ -546,6 +575,11 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _closeWindowFromConnection() async {
|
||||
await _closeAllTabs();
|
||||
await WindowController.fromWindowId(windowId()).close();
|
||||
}
|
||||
|
||||
int windowId() {
|
||||
return widget.params["windowId"];
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -99,6 +99,7 @@ class DesktopTabController {
|
||||
/// index, key
|
||||
Function(int, String)? onRemoved;
|
||||
Function(String)? onSelected;
|
||||
Future<void> Function()? onCloseWindow;
|
||||
|
||||
DesktopTabController(
|
||||
{required this.tabType, this.onRemoved, this.onSelected});
|
||||
@@ -592,13 +593,13 @@ class _DesktopTabState extends State<DesktopTab>
|
||||
}
|
||||
|
||||
Widget _buildBar() {
|
||||
final isIncomingHomePage = bind.isIncomingOnly() && isInHomePage();
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
// custom double tap handler
|
||||
onTap: !(bind.isIncomingOnly() && isInHomePage()) &&
|
||||
showMaximize
|
||||
onTap: !isIncomingHomePage && showMaximize
|
||||
? () {
|
||||
final current = DateTime.now().millisecondsSinceEpoch;
|
||||
final elapsed = current - _lastClickTime;
|
||||
@@ -609,7 +610,7 @@ class _DesktopTabState extends State<DesktopTab>
|
||||
.then((value) => stateGlobal.setMaximized(value));
|
||||
}
|
||||
}
|
||||
: null,
|
||||
: (isIncomingHomePage ? () {} : null), // Keep tap recognizer for Windows touch.
|
||||
onPanStart: (_) => startDragging(isMainWindow),
|
||||
onPanCancel: () {
|
||||
// We want to disable dragging of the tab area in the tab bar.
|
||||
|
||||
@@ -27,6 +27,7 @@ import 'common.dart';
|
||||
import 'consts.dart';
|
||||
import 'mobile/pages/home_page.dart';
|
||||
import 'mobile/pages/server_page.dart';
|
||||
import 'mobile/widgets/deploy_dialog.dart';
|
||||
import 'models/platform_model.dart';
|
||||
|
||||
import 'package:flutter_hbb/plugin/handlers.dart'
|
||||
@@ -575,6 +576,14 @@ _registerEventHandler() {
|
||||
NativeUiHandler.instance.onEvent(evt);
|
||||
});
|
||||
}
|
||||
if (isAndroid) {
|
||||
platformFFI.registerEventHandler(
|
||||
'android_needs_deploy', 'android_needs_deploy', (_) async {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
showDeployPromptDialog();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Widget keyListenerBuilder(BuildContext context, Widget? child) {
|
||||
|
||||
@@ -207,6 +207,7 @@ class _ConnectionPageState extends State<ConnectionPage> {
|
||||
.contains(textToFind) ||
|
||||
peer.alias.toLowerCase().contains(textToFind))
|
||||
.toList();
|
||||
_allPeersLoader.queryOnlines(_autocompleteOpts);
|
||||
}
|
||||
return _autocompleteOpts;
|
||||
},
|
||||
|
||||
@@ -75,6 +75,9 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
||||
final FocusNode _physicalFocusNode = FocusNode();
|
||||
var _showEdit = false; // use soft keyboard
|
||||
|
||||
Worker? _waylandKeyboardGateWorker;
|
||||
bool _waylandKeyboardGateInitialized = false;
|
||||
|
||||
InputModel get inputModel => gFFI.inputModel;
|
||||
SessionID get sessionId => gFFI.sessionId;
|
||||
|
||||
@@ -121,11 +124,33 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
||||
isKeyboardVisible: keyboardVisibilityController.isVisible);
|
||||
});
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
|
||||
inputModel.keyboardInputAllowed = true;
|
||||
|
||||
// Wayland sessions may use clipboard-based text input on the controlled side.
|
||||
// Require explicit user confirmation before allowing soft-keyboard and
|
||||
// clipboard-assisted text input. Physical keyboard events are not gated here.
|
||||
_waylandKeyboardGateWorker = ever(gFFI.ffiModel.pi.isSet, (bool isSet) {
|
||||
if (isSet) {
|
||||
_initWaylandKeyboardGateIfNeeded();
|
||||
}
|
||||
});
|
||||
if (gFFI.ffiModel.pi.isSet.value) {
|
||||
_initWaylandKeyboardGateIfNeeded();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
// Close the session up-front. `gFFI.close()` below only calls `sessionClose`
|
||||
// after several awaits (canvas save, image update, the `enable_soft_keyboard`
|
||||
// platform call), so if the app is backgrounded while this page is disposing,
|
||||
// dispose can be suspended before reaching it and the connection is never torn
|
||||
// down. The reconnect then re-attaches to the leaked session and is stuck on
|
||||
// "Connecting...". Dispatching it here makes teardown happen synchronously on
|
||||
// pop; the `sessionClose` in `gFFI.close()` becomes a no-op once removed.
|
||||
unawaited(bind.sessionClose(sessionId: sessionId));
|
||||
// https://github.com/flutter/flutter/issues/64935
|
||||
super.dispose();
|
||||
gFFI.dialogManager.hideMobileActionsOverlay(store: false);
|
||||
@@ -135,6 +160,9 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
||||
await gFFI.invokeMethod("enable_soft_keyboard", true);
|
||||
_mobileFocusNode.dispose();
|
||||
_physicalFocusNode.dispose();
|
||||
clearWaylandKeyboardPromptSuppressedForConnection(sessionId.toString());
|
||||
_waylandKeyboardGateWorker?.dispose();
|
||||
inputModel.keyboardInputAllowed = true;
|
||||
await gFFI.close();
|
||||
_timer?.cancel();
|
||||
_iosKeyboardWorkaroundTimer?.cancel();
|
||||
@@ -163,6 +191,40 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
||||
gFFI.invokeMethod("try_sync_clipboard");
|
||||
}
|
||||
|
||||
bool _shouldGateKeyboardForWayland() {
|
||||
if (!(isAndroid || isIOS)) return false;
|
||||
final pi = gFFI.ffiModel.pi;
|
||||
return pi.platform == kPeerPlatformLinux && pi.isWayland;
|
||||
}
|
||||
|
||||
void _initWaylandKeyboardGateIfNeeded() {
|
||||
if (!mounted) return;
|
||||
if (_waylandKeyboardGateInitialized) return;
|
||||
if (!_shouldGateKeyboardForWayland()) return;
|
||||
|
||||
_waylandKeyboardGateInitialized = true;
|
||||
|
||||
final allowWaylandKeyboard =
|
||||
mainGetPeerBoolOptionSync(widget.id, kPeerOptionAllowWaylandKeyboard);
|
||||
if (!shouldShowWaylandKeyboardPrompt(
|
||||
connectionId: sessionId.toString(),
|
||||
isWaylandPeer: _shouldGateKeyboardForWayland(),
|
||||
allowWaylandKeyboardRemembered: allowWaylandKeyboard,
|
||||
)) {
|
||||
inputModel.keyboardInputAllowed = true;
|
||||
return;
|
||||
}
|
||||
|
||||
inputModel.keyboardInputAllowed = false;
|
||||
|
||||
// Ensure soft keyboard is not active before user confirms.
|
||||
_showEdit = false;
|
||||
gFFI.invokeMethod("enable_soft_keyboard", false);
|
||||
_mobileFocusNode.unfocus();
|
||||
_physicalFocusNode.requestFocus();
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
// to-do: It should be better to use transparent color instead of the bgColor.
|
||||
// But for now, the transparent color will cause the canvas to be white.
|
||||
// I'm sure that the white color is caused by the Overlay widget in BlockableOverlay.
|
||||
@@ -294,7 +356,7 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
||||
content == '【】')) {
|
||||
// can not only input content[0], because when input ], [ are also auo insert, which cause ] never be input
|
||||
bind.sessionInputString(sessionId: sessionId, value: content);
|
||||
openKeyboard();
|
||||
_openKeyboardUnlocked();
|
||||
return;
|
||||
}
|
||||
bind.sessionInputString(sessionId: sessionId, value: content);
|
||||
@@ -306,6 +368,9 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
||||
|
||||
// handle mobile virtual keyboard
|
||||
void handleSoftKeyboardInput(String newValue) {
|
||||
if (!inputModel.keyboardInputAllowed) {
|
||||
return;
|
||||
}
|
||||
if (isIOS) {
|
||||
_handleIOSSoftKeyboardInput(newValue);
|
||||
} else {
|
||||
@@ -314,6 +379,9 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
||||
}
|
||||
|
||||
void inputChar(String char) {
|
||||
if (!inputModel.keyboardInputAllowed) {
|
||||
return;
|
||||
}
|
||||
if (char == '\n') {
|
||||
char = 'VK_RETURN';
|
||||
} else if (char == ' ') {
|
||||
@@ -323,6 +391,29 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
||||
}
|
||||
|
||||
void openKeyboard() {
|
||||
final allowWaylandKeyboard =
|
||||
mainGetPeerBoolOptionSync(widget.id, kPeerOptionAllowWaylandKeyboard);
|
||||
if (shouldShowWaylandKeyboardPrompt(
|
||||
connectionId: sessionId.toString(),
|
||||
isWaylandPeer: _shouldGateKeyboardForWayland(),
|
||||
allowWaylandKeyboardRemembered: allowWaylandKeyboard,
|
||||
)) {
|
||||
inputModel.keyboardInputAllowed = false;
|
||||
showWaylandKeyboardInputWarningDialog(
|
||||
id: widget.id,
|
||||
connectionId: sessionId.toString(),
|
||||
ffi: gFFI,
|
||||
onEnable: () async {
|
||||
_openKeyboardUnlocked();
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
_openKeyboardUnlocked();
|
||||
}
|
||||
|
||||
void _openKeyboardUnlocked() {
|
||||
inputModel.keyboardInputAllowed = true;
|
||||
gFFI.invokeMethod("enable_soft_keyboard", true);
|
||||
// destroy first, so that our _value trick can work
|
||||
_value = initText;
|
||||
@@ -426,10 +517,12 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
||||
}
|
||||
return Container(
|
||||
color: MyTheme.canvasColor,
|
||||
child: RawTouchGestureDetectorRegion(
|
||||
child: getBodyForMobile(),
|
||||
ffi: gFFI,
|
||||
),
|
||||
child: inputModel.isPhysicalMouse.value
|
||||
? getBodyForMobile()
|
||||
: RawTouchGestureDetectorRegion(
|
||||
child: getBodyForMobile(),
|
||||
ffi: gFFI,
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
@@ -1127,7 +1220,11 @@ void showOptions(
|
||||
if (image != null) {
|
||||
displays.add(Padding(padding: const EdgeInsets.only(top: 8), child: image));
|
||||
}
|
||||
if (pi.displays.length > 1 && pi.currentDisplay != kAllDisplayValue) {
|
||||
final privacyModeState = PrivacyModeState.find(id);
|
||||
if (pi.displays.length > 1 &&
|
||||
pi.currentDisplay != kAllDisplayValue &&
|
||||
(privacyModeState.isEmpty ||
|
||||
allowDisplaySwitchInPrivacyMode(pi, privacyModeState.value))) {
|
||||
final cur = pi.currentDisplay;
|
||||
final children = <Widget>[];
|
||||
final isDarkTheme = MyTheme.currentThemeMode() == ThemeMode.dark;
|
||||
@@ -1181,8 +1278,6 @@ void showOptions(
|
||||
await toolbarDisplayToggle(context, id, gFFI);
|
||||
|
||||
List<TToggleMenu> privacyModeList = [];
|
||||
// privacy mode
|
||||
final privacyModeState = PrivacyModeState.find(id);
|
||||
if ((gFFI.ffiModel.pi.features.privacyMode && gFFI.ffiModel.keyboard) ||
|
||||
privacyModeState.isNotEmpty) {
|
||||
privacyModeList = toolbarPrivacyMode(privacyModeState, context, id, gFFI);
|
||||
|
||||
@@ -17,6 +17,7 @@ import '../../common/widgets/login.dart';
|
||||
import '../../consts.dart';
|
||||
import '../../models/model.dart';
|
||||
import '../../models/platform_model.dart';
|
||||
import '../widgets/deploy_dialog.dart';
|
||||
import '../widgets/dialog.dart';
|
||||
import 'home_page.dart';
|
||||
import 'scan_page.dart';
|
||||
@@ -728,6 +729,13 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
||||
onPressed: (context) {
|
||||
changeSocks5Proxy();
|
||||
}),
|
||||
if (isAndroid && !bind.isOutgoingOnly())
|
||||
SettingsTile(
|
||||
title: Text(translate('Deploy')),
|
||||
leading: Icon(Icons.cloud_upload),
|
||||
onPressed: (context) {
|
||||
showDeployDialog();
|
||||
}),
|
||||
if (!disabledSettings && !_hideNetwork && !_hideWebSocket)
|
||||
SettingsTile.switchTile(
|
||||
title: Text(translate('Use WebSocket')),
|
||||
|
||||
@@ -83,6 +83,13 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
// Register this terminal model with FFI for event routing
|
||||
_ffi.registerTerminalModel(widget.terminalId, _terminalModel);
|
||||
|
||||
// Auto-close connection when shell exits
|
||||
_terminalModel.onClosed = () {
|
||||
if (mounted) {
|
||||
closeConnection(id: widget.id);
|
||||
}
|
||||
};
|
||||
|
||||
// Web desktop users have full hardware keyboard access, so the on-screen
|
||||
// terminal extra keys bar is unnecessary and disabled.
|
||||
_showTerminalExtraKeys = !isWebDesktop &&
|
||||
|
||||
@@ -259,11 +259,13 @@ class _ViewCameraPageState extends State<ViewCameraPage>
|
||||
}
|
||||
return Container(
|
||||
color: MyTheme.canvasColor,
|
||||
child: RawTouchGestureDetectorRegion(
|
||||
child: getBodyForMobile(),
|
||||
ffi: gFFI,
|
||||
isCamera: true,
|
||||
),
|
||||
child: inputModel.isPhysicalMouse.value
|
||||
? getBodyForMobile()
|
||||
: RawTouchGestureDetectorRegion(
|
||||
child: getBodyForMobile(),
|
||||
ffi: gFFI,
|
||||
isCamera: true,
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
|
||||
114
flutter/lib/mobile/widgets/deploy_dialog.dart
Normal file
114
flutter/lib/mobile/widgets/deploy_dialog.dart
Normal file
@@ -0,0 +1,114 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import '../../common.dart';
|
||||
import '../../models/platform_model.dart';
|
||||
|
||||
const _deployDialogTag = 'android-deploy-device';
|
||||
|
||||
void showDeployPromptDialog() {
|
||||
gFFI.dialogManager.dismissByTag(_deployDialogTag);
|
||||
gFFI.dialogManager.show<bool>((setState, close, context) {
|
||||
submit() => close(true);
|
||||
return CustomAlertDialog(
|
||||
title: Text(translate("Deploy")),
|
||||
content: Text(translate("server_requires_deployment_tip")),
|
||||
actions: [
|
||||
dialogButton("Cancel", onPressed: close, isOutline: true),
|
||||
dialogButton("OK", onPressed: submit),
|
||||
],
|
||||
onSubmit: submit,
|
||||
onCancel: close,
|
||||
);
|
||||
}, tag: _deployDialogTag).then((deploy) {
|
||||
if (deploy == true) {
|
||||
showDeployDialog();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void showDeployDialog() {
|
||||
gFFI.dialogManager.dismissByTag(_deployDialogTag);
|
||||
final tokenController = TextEditingController();
|
||||
final idController = TextEditingController();
|
||||
var errorText = "";
|
||||
var isInProgress = false;
|
||||
gFFI.dialogManager.show((setState, close, context) {
|
||||
submit() async {
|
||||
if (isInProgress) return;
|
||||
final token = tokenController.text.trim();
|
||||
if (token.isEmpty) {
|
||||
setState(() {
|
||||
errorText = translate("token is required!");
|
||||
});
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
errorText = "";
|
||||
isInProgress = true;
|
||||
});
|
||||
String res;
|
||||
try {
|
||||
res = await bind.mainDeployDevice(
|
||||
token: token, id: idController.text.trim());
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
errorText = translate(e.toString());
|
||||
isInProgress = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (res.isEmpty) {
|
||||
close();
|
||||
await gFFI.serverModel.fetchID();
|
||||
showToast(translate("Successful"));
|
||||
} else {
|
||||
setState(() {
|
||||
errorText = translate(res.toString());
|
||||
isInProgress = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return CustomAlertDialog(
|
||||
title: Text(translate("Deploy")),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: tokenController,
|
||||
decoration: InputDecoration(labelText: translate("API Token")),
|
||||
obscureText: true,
|
||||
enableSuggestions: false,
|
||||
autocorrect: false,
|
||||
autofocus: true,
|
||||
).workaroundFreezeLinuxMint(),
|
||||
TextField(
|
||||
controller: idController,
|
||||
decoration:
|
||||
InputDecoration(labelText: translate("Custom ID (optional)")),
|
||||
).workaroundFreezeLinuxMint(),
|
||||
if (errorText.isNotEmpty)
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: SelectableText(
|
||||
errorText,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
fontSize: 12,
|
||||
),
|
||||
).paddingOnly(top: 8),
|
||||
),
|
||||
if (isInProgress) const LinearProgressIndicator().paddingOnly(top: 8),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
dialogButton("Cancel",
|
||||
onPressed: isInProgress ? null : close, isOutline: true),
|
||||
dialogButton("OK", onPressed: isInProgress ? null : submit),
|
||||
],
|
||||
onSubmit: submit,
|
||||
onCancel: isInProgress ? null : close,
|
||||
);
|
||||
}, tag: _deployDialogTag);
|
||||
}
|
||||
@@ -117,13 +117,13 @@ void showServerSettingsWithValue(
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
child: serverSettingsTextFormField(
|
||||
label: label,
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
errorText: errorMsg.isEmpty ? null : errorMsg,
|
||||
contentPadding:
|
||||
EdgeInsets.symmetric(horizontal: 8, vertical: 12),
|
||||
),
|
||||
errorMsg: errorMsg,
|
||||
contentPadding:
|
||||
EdgeInsets.symmetric(horizontal: 8, vertical: 12),
|
||||
showLabelText: false,
|
||||
validator: validator,
|
||||
autofocus: autofocus,
|
||||
).workaroundFreezeLinuxMint(),
|
||||
@@ -132,12 +132,10 @@ void showServerSettingsWithValue(
|
||||
);
|
||||
}
|
||||
|
||||
return TextFormField(
|
||||
return serverSettingsTextFormField(
|
||||
label: label,
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
errorText: errorMsg.isEmpty ? null : errorMsg,
|
||||
),
|
||||
errorMsg: errorMsg,
|
||||
validator: validator,
|
||||
).workaroundFreezeLinuxMint();
|
||||
}
|
||||
@@ -209,6 +207,35 @@ 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,
|
||||
|
||||
@@ -142,12 +142,22 @@ class FileModel {
|
||||
}
|
||||
|
||||
Future<void> postOverrideFileConfirm(Map<String, dynamic> evt) async {
|
||||
final id = int.tryParse(evt['id']?.toString() ?? '');
|
||||
if (id == null || !jobController.hasTransferConflictJob(id)) {
|
||||
debugPrint("Ignore stale override confirm event: $evt");
|
||||
return;
|
||||
}
|
||||
evtLoop.pushEvent(
|
||||
_FileDialogEvent(WeakReference(this), FileDialogType.overwrite, evt));
|
||||
}
|
||||
|
||||
Future<void> overrideFileConfirm(Map<String, dynamic> evt,
|
||||
{bool? overrideConfirm, bool skip = false}) async {
|
||||
final id = int.tryParse(evt['id']?.toString() ?? '') ?? 0;
|
||||
if (id == 0 || !jobController.hasTransferConflictJob(id)) {
|
||||
debugPrint("Ignore override confirm for inactive job: $evt");
|
||||
return;
|
||||
}
|
||||
// If `skip == true`, it means to skip this file without showing dialog.
|
||||
// Because `resp` may be null after the user operation or the last remembered operation,
|
||||
// and we should distinguish them.
|
||||
@@ -156,15 +166,12 @@ class FileModel {
|
||||
? await showFileConfirmDialog(translate("Overwrite"),
|
||||
"${evt['read_path']}", true, evt['is_identical'] == "true")
|
||||
: null);
|
||||
final id = int.tryParse(evt['id']) ?? 0;
|
||||
if (!jobController.hasTransferConflictJob(id)) {
|
||||
debugPrint("Ignore override confirm result for inactive job: $evt");
|
||||
return;
|
||||
}
|
||||
if (false == resp) {
|
||||
final jobIndex = jobController.getJob(id);
|
||||
if (jobIndex != -1) {
|
||||
await jobController.cancelJob(id);
|
||||
final job = jobController.jobTable[jobIndex];
|
||||
job.state = JobState.done;
|
||||
jobController.jobTable.refresh();
|
||||
}
|
||||
await jobController.cancelTransferConflictBatch(id);
|
||||
} else {
|
||||
var need_override = false;
|
||||
if (resp == null) {
|
||||
@@ -176,6 +183,7 @@ class FileModel {
|
||||
}
|
||||
// Update the loop config.
|
||||
if (fileConfirmCheckboxRemember) {
|
||||
jobController.rememberTransferConflictBatch(id, resp);
|
||||
evtLoop.setSkip(!need_override);
|
||||
}
|
||||
await bind.sessionSetConfirmOverrideFile(
|
||||
@@ -285,6 +293,8 @@ class FileModel {
|
||||
final isWindows = otherSideData.options.isWindows;
|
||||
final showHidden = otherSideData.options.showHidden;
|
||||
final jobID = jobController.addTransferJob(entry, false);
|
||||
jobController.registerTransferConflictBatch([jobID],
|
||||
batchId: int.tryParse(obj['batchId']?.toString() ?? ''));
|
||||
webSendLocalFiles(
|
||||
handleIndex: handleIndex,
|
||||
actId: jobID,
|
||||
@@ -391,14 +401,30 @@ class FileController {
|
||||
|
||||
await Future.delayed(Duration(milliseconds: 100));
|
||||
|
||||
final dir = (await bind.sessionGetPeerOption(
|
||||
final savedDir = (await bind.sessionGetPeerOption(
|
||||
sessionId: sessionId, name: isLocal ? "local_dir" : "remote_dir"));
|
||||
openDirectory(dir.isEmpty ? options.value.home : dir);
|
||||
Future<bool> tryOpenReadyDirs() async {
|
||||
final dirs = <String>{
|
||||
if (directory.value.path.isNotEmpty) directory.value.path,
|
||||
if (savedDir.isNotEmpty) savedDir,
|
||||
options.value.home,
|
||||
};
|
||||
for (final dir in dirs) {
|
||||
if (await _openDirectoryPath(dir, isBack: true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
var opened = await tryOpenReadyDirs();
|
||||
|
||||
await Future.delayed(Duration(seconds: 1));
|
||||
|
||||
if (directory.value.path.isEmpty) {
|
||||
openDirectory(options.value.home);
|
||||
if (!opened) {
|
||||
// The peer may become ready during the reconnect delay, so retry the
|
||||
// same candidates instead of only retrying the default home directory.
|
||||
await tryOpenReadyDirs();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -429,19 +455,23 @@ class FileController {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
await openDirectory(directory.value.path);
|
||||
Future<bool> refresh() async {
|
||||
// "." can be both a refresh command and a real remote directory path.
|
||||
// Refresh must bypass openDirectory's command dispatch to avoid recursion.
|
||||
return await _openDirectoryPath(directory.value.path, isBack: true);
|
||||
}
|
||||
|
||||
Future<void> openDirectory(String path, {bool isBack = false}) async {
|
||||
if (path == ".") {
|
||||
refresh();
|
||||
return;
|
||||
Future<bool> openDirectory(String path, {bool isBack = false}) async {
|
||||
if (!isBack && path == ".") {
|
||||
return await refresh();
|
||||
}
|
||||
if (path == "..") {
|
||||
goToParentDirectory();
|
||||
return;
|
||||
if (!isBack && path == "..") {
|
||||
return await _goToParentDirectory(isBack: isBack);
|
||||
}
|
||||
return await _openDirectoryPath(path, isBack: isBack);
|
||||
}
|
||||
|
||||
Future<bool> _openDirectoryPath(String path, {bool isBack = false}) async {
|
||||
if (!isBack) {
|
||||
pushHistory();
|
||||
}
|
||||
@@ -458,8 +488,10 @@ class FileController {
|
||||
final fd = await fileFetcher.fetchDirectory(path, isLocal, showHidden);
|
||||
fd.format(isWindows, sort: sortBy.value);
|
||||
directory.value = fd;
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint("Failed to openDirectory $path: $e");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -487,19 +519,22 @@ class FileController {
|
||||
goBack();
|
||||
return;
|
||||
}
|
||||
openDirectory(path, isBack: true);
|
||||
unawaited(_openDirectoryPath(path, isBack: true).then<void>((_) {}));
|
||||
}
|
||||
|
||||
void goToParentDirectory() {
|
||||
unawaited(_goToParentDirectory().then<void>((_) {}));
|
||||
}
|
||||
|
||||
Future<bool> _goToParentDirectory({bool isBack = false}) async {
|
||||
final isWindows = options.value.isWindows;
|
||||
final dirPath = directory.value.path;
|
||||
var parent = PathUtil.dirname(dirPath, isWindows);
|
||||
// specially for C:\, D:\, goto '/'
|
||||
if (parent == dirPath && isWindows) {
|
||||
openDirectory('/');
|
||||
return;
|
||||
return await _openDirectoryPath('/', isBack: isBack);
|
||||
}
|
||||
openDirectory(parent);
|
||||
return await _openDirectoryPath(parent, isBack: isBack);
|
||||
}
|
||||
|
||||
// TODO deprecated this
|
||||
@@ -545,8 +580,15 @@ class FileController {
|
||||
final toPath = otherSideData.directory.path;
|
||||
final isWindows = otherSideData.options.isWindows;
|
||||
final showHidden = otherSideData.options.showHidden;
|
||||
final transferJobs = <(Entry, int)>[];
|
||||
final transferJobIds = <int>[];
|
||||
for (var from in items.items) {
|
||||
final jobID = jobController.addTransferJob(from, isRemoteToLocal);
|
||||
transferJobs.add((from, jobID));
|
||||
transferJobIds.add(jobID);
|
||||
}
|
||||
jobController.registerTransferConflictBatch(transferJobIds);
|
||||
for (final (from, jobID) in transferJobs) {
|
||||
bind.sessionSendFiles(
|
||||
sessionId: sessionId,
|
||||
actId: jobID,
|
||||
@@ -892,6 +934,10 @@ class JobController {
|
||||
static final JobID jobID = JobID();
|
||||
final jobTable = List<JobProgress>.empty(growable: true).obs;
|
||||
final jobResultListener = JobResultListener<Map<String, dynamic>>();
|
||||
int _nextTransferConflictBatchId = 1;
|
||||
final Map<int, int> _transferConflictJobToBatch = {};
|
||||
int? _transferConflictRememberBatchId;
|
||||
bool? _transferConflictRememberOverrideConfirm;
|
||||
final GetSessionID getSessionID;
|
||||
final GetDialogManager getDialogManager;
|
||||
SessionID get sessionId => getSessionID();
|
||||
@@ -904,6 +950,57 @@ class JobController {
|
||||
return jobTable.indexWhere((element) => element.id == id);
|
||||
}
|
||||
|
||||
void registerTransferConflictBatch(Iterable<int> jobIds, {int? batchId}) {
|
||||
final ids = jobIds.toList(growable: false);
|
||||
if (ids.isEmpty) {
|
||||
return;
|
||||
}
|
||||
batchId ??= _nextTransferConflictBatchId++;
|
||||
if (batchId >= _nextTransferConflictBatchId) {
|
||||
_nextTransferConflictBatchId = batchId + 1;
|
||||
}
|
||||
for (final jobId in ids) {
|
||||
_transferConflictJobToBatch[jobId] = batchId;
|
||||
}
|
||||
}
|
||||
|
||||
int? transferConflictBatchId(int jobId) {
|
||||
return _transferConflictJobToBatch[jobId];
|
||||
}
|
||||
|
||||
bool hasTransferConflictJob(int jobId) {
|
||||
return transferConflictBatchId(jobId) != null;
|
||||
}
|
||||
|
||||
bool isTransferConflictRememberBatch(int? batchId) {
|
||||
return batchId != null && batchId == _transferConflictRememberBatchId;
|
||||
}
|
||||
|
||||
bool? transferConflictRememberOverrideConfirm(int? batchId) {
|
||||
if (!isTransferConflictRememberBatch(batchId)) {
|
||||
return null;
|
||||
}
|
||||
return _transferConflictRememberOverrideConfirm;
|
||||
}
|
||||
|
||||
void rememberTransferConflictBatch(int jobId, bool? overrideConfirm) {
|
||||
_transferConflictRememberBatchId = _transferConflictJobToBatch[jobId];
|
||||
_transferConflictRememberOverrideConfirm = overrideConfirm;
|
||||
}
|
||||
|
||||
void unregisterTransferConflictJob(int jobId) {
|
||||
final batchId = _transferConflictJobToBatch.remove(jobId);
|
||||
if (batchId == null) {
|
||||
return;
|
||||
}
|
||||
if (!_transferConflictJobToBatch.containsValue(batchId)) {
|
||||
if (_transferConflictRememberBatchId == batchId) {
|
||||
_transferConflictRememberBatchId = null;
|
||||
_transferConflictRememberOverrideConfirm = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// return jobID
|
||||
int addTransferJob(Entry from, bool isRemoteToLocal) {
|
||||
final jobID = JobController.jobID.next();
|
||||
@@ -975,7 +1072,10 @@ class JobController {
|
||||
id = int.parse(evt['id']);
|
||||
} catch (_) {}
|
||||
final jobIndex = getJob(id);
|
||||
if (jobIndex == -1) return true;
|
||||
if (jobIndex == -1) {
|
||||
unregisterTransferConflictJob(id);
|
||||
return true;
|
||||
}
|
||||
final job = jobTable[jobIndex];
|
||||
job.recvJobRes = true;
|
||||
if (job.type == JobType.deleteFile) {
|
||||
@@ -1001,6 +1101,9 @@ class JobController {
|
||||
job.state = JobState.done;
|
||||
}
|
||||
jobTable.refresh();
|
||||
if (job.state == JobState.done || job.state == JobState.error) {
|
||||
unregisterTransferConflictJob(id);
|
||||
}
|
||||
if (job.type == JobType.deleteDir) {
|
||||
return job.state == JobState.done;
|
||||
} else {
|
||||
@@ -1010,9 +1113,15 @@ class JobController {
|
||||
|
||||
void jobError(Map<String, dynamic> evt) {
|
||||
final err = evt['err'].toString();
|
||||
int jobIndex = getJob(int.parse(evt['id']));
|
||||
final id = int.tryParse(evt['id']?.toString() ?? '');
|
||||
if (id == null) {
|
||||
debugPrint("Ignore job error with invalid id: $evt");
|
||||
return;
|
||||
}
|
||||
int jobIndex = getJob(id);
|
||||
if (jobIndex != -1) {
|
||||
final job = jobTable[jobIndex];
|
||||
if (job.state == JobState.done && job.err == "cancel") return;
|
||||
job.state = JobState.error;
|
||||
job.err = err;
|
||||
job.recvJobRes = true;
|
||||
@@ -1035,6 +1144,11 @@ class JobController {
|
||||
}
|
||||
}
|
||||
jobTable.refresh();
|
||||
if (job.state == JobState.done || job.state == JobState.error) {
|
||||
unregisterTransferConflictJob(job.id);
|
||||
}
|
||||
} else {
|
||||
unregisterTransferConflictJob(id);
|
||||
}
|
||||
if (err == _kOneWayFileTransferError) {
|
||||
if (DateTime.now().millisecondsSinceEpoch - _lastTimeShowMsgbox > 3000) {
|
||||
@@ -1071,9 +1185,42 @@ class JobController {
|
||||
}
|
||||
|
||||
Future<void> cancelJob(int id) async {
|
||||
unregisterTransferConflictJob(id);
|
||||
await bind.sessionCancelJob(sessionId: sessionId, actId: id);
|
||||
}
|
||||
|
||||
Future<void> cancelTransferConflictBatch(int jobId) async {
|
||||
final batchId = _transferConflictJobToBatch[jobId];
|
||||
final batchJobIds = batchId == null ? [jobId] : <int>[];
|
||||
if (batchId != null) {
|
||||
for (final entry in _transferConflictJobToBatch.entries) {
|
||||
if (entry.value == batchId) {
|
||||
batchJobIds.add(entry.key);
|
||||
}
|
||||
}
|
||||
for (final id in batchJobIds) {
|
||||
unregisterTransferConflictJob(id);
|
||||
}
|
||||
}
|
||||
final jobIdsToCancel = batchJobIds.toSet();
|
||||
for (final job in jobTable) {
|
||||
if (!jobIdsToCancel.contains(job.id) || job.state == JobState.done) {
|
||||
continue;
|
||||
}
|
||||
job.state = JobState.done;
|
||||
job.err = "cancel";
|
||||
job.recvJobRes = true;
|
||||
}
|
||||
jobTable.refresh();
|
||||
for (final id in batchJobIds) {
|
||||
try {
|
||||
await bind.sessionCancelJob(sessionId: sessionId, actId: id);
|
||||
} catch (e) {
|
||||
debugPrint("Failed to cancel transfer job $id in conflict batch: $e");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadLastJob(Map<String, dynamic> evt) async {
|
||||
debugPrint("load last job: $evt");
|
||||
Map<String, dynamic> jobDetail = json.decode(evt['value']);
|
||||
@@ -1120,7 +1267,7 @@ class JobController {
|
||||
..state = JobState.paused;
|
||||
jobTable.add(jobProgress);
|
||||
}
|
||||
|
||||
registerTransferConflictBatch([currJobId]);
|
||||
await bind.sessionAddJob(
|
||||
sessionId: sessionId,
|
||||
isRemote: isRemote,
|
||||
@@ -1168,6 +1315,9 @@ class JobController {
|
||||
|
||||
void clear() {
|
||||
jobTable.clear();
|
||||
_transferConflictJobToBatch.clear();
|
||||
_transferConflictRememberBatchId = null;
|
||||
_transferConflictRememberOverrideConfirm = null;
|
||||
jobResultListener.clear();
|
||||
}
|
||||
}
|
||||
@@ -1510,6 +1660,9 @@ class JobProgress {
|
||||
|
||||
String display() {
|
||||
if (type == JobType.transfer) {
|
||||
if (state == JobState.done && err == "cancel") {
|
||||
return translate("Cancel");
|
||||
}
|
||||
if (state == JobState.done && err == "skipped") {
|
||||
return translate("Skipped");
|
||||
}
|
||||
@@ -1819,21 +1972,44 @@ class _FileDialogEvent extends BaseEvent<FileDialogType, Map<String, dynamic>> {
|
||||
|
||||
class FileDialogEventLoop
|
||||
extends BaseEventLoop<FileDialogType, Map<String, dynamic>> {
|
||||
int? _batchId;
|
||||
bool? _overrideConfirm;
|
||||
bool _skip = false;
|
||||
|
||||
@override
|
||||
Future<void> onPreConsume(
|
||||
BaseEvent<FileDialogType, Map<String, dynamic>> evt) async {
|
||||
var event = evt as _FileDialogEvent;
|
||||
final event = evt as _FileDialogEvent;
|
||||
final model = event.fileModel.target;
|
||||
final jobId = int.tryParse(evt.data['id']?.toString() ?? '');
|
||||
final batchId = model == null || jobId == null
|
||||
? null
|
||||
: model.jobController.transferConflictBatchId(jobId);
|
||||
final keepRemembered = model != null &&
|
||||
model.jobController.isTransferConflictRememberBatch(batchId);
|
||||
// The loop only preloads the remembered batch choice. The model updates it
|
||||
// after the user answers the current overwrite dialog.
|
||||
if (_batchId != batchId && !keepRemembered) {
|
||||
_batchId = batchId;
|
||||
_overrideConfirm = null;
|
||||
_skip = false;
|
||||
} else {
|
||||
_batchId = batchId;
|
||||
}
|
||||
if (keepRemembered) {
|
||||
_overrideConfirm =
|
||||
model.jobController.transferConflictRememberOverrideConfirm(batchId);
|
||||
_skip = _overrideConfirm == null;
|
||||
}
|
||||
event.setOverrideConfirm(_overrideConfirm);
|
||||
event.setSkip(_skip);
|
||||
debugPrint(
|
||||
"FileDialogEventLoop: consuming<jobId: ${evt.data['id']} overrideConfirm: $_overrideConfirm, skip: $_skip>");
|
||||
"FileDialogEventLoop: consuming<jobId: ${evt.data['id']} batchId: $_batchId overrideConfirm: $_overrideConfirm, skip: $_skip>");
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onEventsClear() {
|
||||
_batchId = null;
|
||||
_overrideConfirm = null;
|
||||
_skip = false;
|
||||
return super.onEventsClear();
|
||||
|
||||
@@ -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 (!Platform.isLinux) return;
|
||||
if (!isLinux) return;
|
||||
if (_sideButtonChannelInitialized) return;
|
||||
_sideButtonChannelInitialized = true;
|
||||
|
||||
@@ -474,6 +474,10 @@ class InputModel {
|
||||
|
||||
late final SessionID sessionId;
|
||||
|
||||
// Local gate for clipboard-assisted input flows on mobile Wayland dialogs.
|
||||
// It should not block physical keyboard events.
|
||||
bool keyboardInputAllowed = true;
|
||||
|
||||
bool get keyboardPerm => parent.target!.ffiModel.keyboard;
|
||||
String get id => parent.target?.id ?? '';
|
||||
String? get peerPlatform => parent.target?.ffiModel.pi.platform;
|
||||
@@ -1303,7 +1307,8 @@ class InputModel {
|
||||
}
|
||||
if (isPhysicalMouse.value) {
|
||||
if (!_relativeMouse.handleRelativeMouseMove(e.localPosition)) {
|
||||
handleMouse(_getMouseEvent(e, _kMouseEventMove), e.position,
|
||||
final canvasPosition = _pointerPositionForRemoteCanvas(e);
|
||||
handleMouse(_getMouseEvent(e, _kMouseEventMove), canvasPosition,
|
||||
edgeScroll: useEdgeScroll);
|
||||
}
|
||||
}
|
||||
@@ -1544,7 +1549,8 @@ class InputModel {
|
||||
_relativeMouse
|
||||
.sendRelativeMouseButton(_getMouseEvent(e, _kMouseEventDown));
|
||||
} else {
|
||||
handleMouse(_getMouseEvent(e, _kMouseEventDown), e.position);
|
||||
final canvasPosition = _pointerPositionForRemoteCanvas(e);
|
||||
handleMouse(_getMouseEvent(e, _kMouseEventDown), canvasPosition);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1566,7 +1572,8 @@ class InputModel {
|
||||
_relativeMouse
|
||||
.sendRelativeMouseButton(_getMouseEvent(e, _kMouseEventUp));
|
||||
} else {
|
||||
handleMouse(_getMouseEvent(e, _kMouseEventUp), e.position);
|
||||
final canvasPosition = _pointerPositionForRemoteCanvas(e);
|
||||
handleMouse(_getMouseEvent(e, _kMouseEventUp), canvasPosition);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1588,12 +1595,40 @@ class InputModel {
|
||||
}
|
||||
if (isPhysicalMouse.value) {
|
||||
if (!_relativeMouse.handleRelativeMouseMove(e.localPosition)) {
|
||||
handleMouse(_getMouseEvent(e, _kMouseEventMove), e.position,
|
||||
final canvasPosition = _pointerPositionForRemoteCanvas(e);
|
||||
handleMouse(_getMouseEvent(e, _kMouseEventMove), canvasPosition,
|
||||
edgeScroll: useEdgeScroll);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert pointer coordinates into the visible remote canvas space.
|
||||
///
|
||||
/// On mobile, the remote page body is wrapped in `SafeArea`, but the pointer
|
||||
/// listener that feeds these events sits outside that subtree. As a result,
|
||||
/// `event.localPosition` still includes the top/left safe-area inset.
|
||||
///
|
||||
/// When the keyboard-visible path shows `KeyHelpTools`, the remote canvas is
|
||||
/// also shifted downward by `CanvasModel.getAdjustY()`. The downstream mouse
|
||||
/// mapping logic expects coordinates relative to the visible canvas area, so
|
||||
/// we subtract both the mobile safe-area padding and the current canvas
|
||||
/// adjustment before passing the position into mouse mapping.
|
||||
///
|
||||
/// Desktop and web desktop continue to use the global position directly
|
||||
/// because their pointer mapping is window-based.
|
||||
Offset _pointerPositionForRemoteCanvas(PointerEvent event) {
|
||||
if (isDesktop || isWebDesktop) {
|
||||
return event.position;
|
||||
}
|
||||
final mediaData = MediaQueryData.fromView(
|
||||
WidgetsBinding.instance.platformDispatcher.views.first);
|
||||
final adjustY = parent.target?.canvasModel.getAdjustY() ?? 0.0;
|
||||
return Offset(
|
||||
event.localPosition.dx - mediaData.padding.left,
|
||||
event.localPosition.dy - mediaData.padding.top - adjustY,
|
||||
);
|
||||
}
|
||||
|
||||
static Future<Rect?> fillRemoteCoordsAndGetCurFrame(
|
||||
List<RemoteWindowCoords> remoteWindowCoords) async {
|
||||
final coords =
|
||||
|
||||
@@ -55,6 +55,8 @@ 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 = {};
|
||||
@@ -119,6 +121,7 @@ class FfiModel with ChangeNotifier {
|
||||
bool _touchMode = false;
|
||||
late VirtualMouseMode virtualMouseMode;
|
||||
Timer? _timer;
|
||||
Timer? _restartReconnectDelayTimer;
|
||||
var _reconnects = 1;
|
||||
DateTime? _offlineReconnectStartTime;
|
||||
bool _viewOnly = false;
|
||||
@@ -250,6 +253,7 @@ class FfiModel with ChangeNotifier {
|
||||
_inputBlocked = false;
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
resetRestartReconnectState();
|
||||
clearPermissions();
|
||||
waitForImageTimer?.cancel();
|
||||
timerScreenshot?.cancel();
|
||||
@@ -341,6 +345,7 @@ 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);
|
||||
@@ -922,8 +927,28 @@ class FfiModel with ChangeNotifier {
|
||||
enterUserLoginAndPasswordDialog(
|
||||
sessionId, dialogManager, 'terminal-admin-login-tip', false);
|
||||
} else if (type == 'restarting') {
|
||||
showMsgBox(sessionId, type, title, text, link, false, dialogManager,
|
||||
hasCancel: false);
|
||||
// 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);
|
||||
} else if (type == 'wait-remote-accept-nook') {
|
||||
showWaitAcceptDialog(sessionId, type, title, text, dialogManager);
|
||||
} else if (type == 'on-uac' || type == 'on-foreground-elevated') {
|
||||
@@ -949,6 +974,11 @@ 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(
|
||||
@@ -1374,6 +1404,7 @@ class FfiModel with ChangeNotifier {
|
||||
if (displays.isNotEmpty) {
|
||||
_reconnects = 1;
|
||||
_offlineReconnectStartTime = null;
|
||||
resetRestartReconnectState();
|
||||
waitForFirstImage.value = true;
|
||||
isRefreshing = false;
|
||||
}
|
||||
@@ -3666,6 +3697,7 @@ class FFI {
|
||||
|
||||
/// Mobile reuse FFI
|
||||
void mobileReset() {
|
||||
ffiModel.resetRestartReconnectState();
|
||||
ffiModel.waitForFirstImage.value = true;
|
||||
ffiModel.isRefreshing = false;
|
||||
ffiModel.waitForImageDialogShow.value = true;
|
||||
@@ -3879,6 +3911,7 @@ class FFI {
|
||||
}
|
||||
if (ffiModel.waitForFirstImage.value == true) {
|
||||
ffiModel.waitForFirstImage.value = false;
|
||||
ffiModel.resetRestartReconnectState();
|
||||
dialogManager.dismissAll();
|
||||
await canvasModel.updateViewStyle();
|
||||
await canvasModel.updateScrollStyle();
|
||||
|
||||
@@ -145,23 +145,26 @@ class Peer {
|
||||
note == other.note;
|
||||
}
|
||||
|
||||
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);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
enum UpdateEvent { online, load }
|
||||
|
||||
@@ -27,25 +27,34 @@ class TerminalModel with ChangeNotifier {
|
||||
// Buffer for output data received before terminal view has valid dimensions.
|
||||
// This prevents NaN errors when writing to terminal before layout is complete.
|
||||
final _pendingOutputChunks = <String>[];
|
||||
final _pendingOutputSuppressFlags = <bool>[];
|
||||
int _pendingOutputSize = 0;
|
||||
static const int _kMaxOutputBufferChars = 8 * 1024;
|
||||
// View ready state: true when terminal has valid dimensions, safe to write
|
||||
bool _terminalViewReady = false;
|
||||
|
||||
bool get isPeerWindows => parent.ffiModel.pi.platform == kPeerPlatformWindows;
|
||||
bool _markViewReadyScheduled = false;
|
||||
bool _suppressTerminalOutput = false;
|
||||
bool _suppressNextTerminalDataOutput = false;
|
||||
|
||||
void Function(int w, int h, int pw, int ph)? onResizeExternal;
|
||||
|
||||
/// Called when the terminal session ends (shell exits).
|
||||
/// The listener (typically TerminalPage) can use this to auto-close the tab/page.
|
||||
VoidCallback? onClosed;
|
||||
|
||||
Future<void> _handleInput(String data) async {
|
||||
// If we press the `Enter` button on Android,
|
||||
// `data` can be '\r' or '\n' when using different keyboards.
|
||||
// Android -> Windows. '\r' works, but '\n' does not. '\n' is just a newline.
|
||||
// Android -> Linux. Both '\r' and '\n' work as expected (execute a command).
|
||||
// So when we receive '\n', we may need to convert it to '\r' to ensure compatibility.
|
||||
// Desktop -> Desktop works fine.
|
||||
// Check if we are on mobile or web(mobile), and convert '\n' to '\r'.
|
||||
// Soft keyboards (notably iOS) emit '\n' when Enter is pressed, while a
|
||||
// real keyboard's Enter sends '\r'. Some Android keyboards also emit '\n'.
|
||||
// - Peer Windows: '\r' works, '\n' is just a newline.
|
||||
// - Peer Linux: canonical-mode shells accept both, but raw-mode apps
|
||||
// (readline, prompt_toolkit, vim, TUI frameworks) expect '\r'.
|
||||
// - Peer macOS: same as Linux, raw-mode apps expect '\r'
|
||||
// (https://github.com/rustdesk/rustdesk/issues/14907).
|
||||
// So on mobile / web-mobile, always normalize a lone '\n' to '\r'.
|
||||
// We deliberately do not touch multi-character payloads (e.g. pasted text)
|
||||
// so embedded newlines in pasted content are preserved.
|
||||
final isMobileOrWebMobile = (isMobile || (isWeb && !isWebDesktop));
|
||||
if (isMobileOrWebMobile && isPeerWindows && data == '\n') {
|
||||
if (isMobileOrWebMobile && data == '\n') {
|
||||
data = '\r';
|
||||
}
|
||||
if (_terminalOpened) {
|
||||
@@ -70,7 +79,10 @@ class TerminalModel with ChangeNotifier {
|
||||
terminalController = TerminalController();
|
||||
|
||||
// Setup terminal callbacks
|
||||
terminal.onOutput = _handleInput;
|
||||
terminal.onOutput = (data) {
|
||||
if (_suppressTerminalOutput) return;
|
||||
_handleInput(data);
|
||||
};
|
||||
|
||||
terminal.onResize = (w, h, pw, ph) async {
|
||||
// Validate all dimensions before using them
|
||||
@@ -84,7 +96,7 @@ class TerminalModel with ChangeNotifier {
|
||||
// Mark terminal view as ready and flush any buffered output on first valid resize.
|
||||
// Must be after onResizeExternal so the view layer has valid dimensions before flushing.
|
||||
if (!_terminalViewReady) {
|
||||
_markViewReady();
|
||||
_scheduleMarkViewReady();
|
||||
}
|
||||
|
||||
if (_terminalOpened) {
|
||||
@@ -110,14 +122,16 @@ class TerminalModel with ChangeNotifier {
|
||||
void onReady() {
|
||||
parent.dialogManager.dismissAll();
|
||||
|
||||
// Fire and forget - don't block onReady
|
||||
openTerminal().catchError((e) {
|
||||
// Fire and forget - don't block onReady. If the transport reconnects while
|
||||
// this model is still open, re-send OpenTerminal so the remote service marks
|
||||
// the persistent session active again and resumes output streaming.
|
||||
openTerminal(force: _terminalOpened).catchError((e) {
|
||||
debugPrint('[TerminalModel] Error opening terminal: $e');
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> openTerminal() async {
|
||||
if (_terminalOpened) return;
|
||||
Future<void> openTerminal({bool force = false}) async {
|
||||
if (_terminalOpened && !force) return;
|
||||
// Request the remote side to open a terminal with default shell
|
||||
// The remote side will decide which shell to use based on its OS
|
||||
|
||||
@@ -275,9 +289,12 @@ class TerminalModel with ChangeNotifier {
|
||||
if (success) {
|
||||
_terminalOpened = true;
|
||||
|
||||
// On reconnect ("Reconnected to existing terminal"), server may replay recent output.
|
||||
// If this TerminalView instance is reused (not rebuilt), duplicate lines can appear.
|
||||
// We intentionally accept this tradeoff for now to keep logic simple.
|
||||
// On reconnect, the server may replay recent output. That replay can include
|
||||
// terminal queries like DSR/DA; xterm answers them through onOutput as
|
||||
// "^[[1;1R^[[2;2R^[[>0;0;0c", which must not be sent back to the peer.
|
||||
final replayTerminalOutput = evt['replay_terminal_output'];
|
||||
_suppressNextTerminalDataOutput = replayTerminalOutput == true ||
|
||||
message == 'Reconnected to existing terminal with pending output';
|
||||
|
||||
// Fallback: if terminal view is not yet ready but already has valid
|
||||
// dimensions (e.g. layout completed before open response arrived),
|
||||
@@ -285,7 +302,7 @@ class TerminalModel with ChangeNotifier {
|
||||
if (!_terminalViewReady &&
|
||||
terminal.viewWidth > 0 &&
|
||||
terminal.viewHeight > 0) {
|
||||
_markViewReady();
|
||||
_scheduleMarkViewReady();
|
||||
}
|
||||
|
||||
// Process any buffered input
|
||||
@@ -297,12 +314,16 @@ class TerminalModel with ChangeNotifier {
|
||||
});
|
||||
|
||||
final persistentSessions =
|
||||
evt['persistent_sessions'] as List<dynamic>? ?? [];
|
||||
(evt['persistent_sessions'] as List<dynamic>? ?? [])
|
||||
.whereType<int>()
|
||||
.where((id) => !parent.terminalModels.containsKey(id))
|
||||
.toList();
|
||||
if (kWindowId != null && persistentSessions.isNotEmpty) {
|
||||
DesktopMultiWindow.invokeMethod(
|
||||
kWindowId!,
|
||||
kWindowEventRestoreTerminalSessions,
|
||||
jsonEncode({
|
||||
'peer_id': id,
|
||||
'persistent_sessions': persistentSessions,
|
||||
}));
|
||||
}
|
||||
@@ -332,6 +353,8 @@ class TerminalModel with ChangeNotifier {
|
||||
final data = evt['data'];
|
||||
|
||||
if (data != null) {
|
||||
final suppressTerminalOutput = _suppressNextTerminalDataOutput;
|
||||
_suppressNextTerminalDataOutput = false;
|
||||
try {
|
||||
String text = '';
|
||||
if (data is String) {
|
||||
@@ -351,7 +374,7 @@ class TerminalModel with ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
|
||||
_writeToTerminal(text);
|
||||
_writeToTerminal(text, suppressTerminalOutput: suppressTerminalOutput);
|
||||
} catch (e) {
|
||||
debugPrint('[TerminalModel] Failed to process terminal data: $e');
|
||||
}
|
||||
@@ -361,7 +384,10 @@ class TerminalModel with ChangeNotifier {
|
||||
/// Write text to terminal, buffering if the view is not yet ready.
|
||||
/// All terminal output should go through this method to avoid NaN errors
|
||||
/// from writing before the terminal view has valid layout dimensions.
|
||||
void _writeToTerminal(String text) {
|
||||
void _writeToTerminal(
|
||||
String text, {
|
||||
bool suppressTerminalOutput = false,
|
||||
}) {
|
||||
if (!_terminalViewReady) {
|
||||
// If a single chunk exceeds the cap, keep only its tail.
|
||||
// Note: truncation may split a multi-byte ANSI escape sequence,
|
||||
@@ -373,34 +399,73 @@ class TerminalModel with ChangeNotifier {
|
||||
_pendingOutputChunks
|
||||
..clear()
|
||||
..add(truncated);
|
||||
_pendingOutputSuppressFlags
|
||||
..clear()
|
||||
..add(suppressTerminalOutput);
|
||||
_pendingOutputSize = truncated.length;
|
||||
} else {
|
||||
_pendingOutputChunks.add(text);
|
||||
_pendingOutputSuppressFlags.add(suppressTerminalOutput);
|
||||
_pendingOutputSize += text.length;
|
||||
// Drop oldest chunks if exceeds limit (whole chunks to preserve ANSI sequences)
|
||||
while (_pendingOutputSize > _kMaxOutputBufferChars &&
|
||||
_pendingOutputChunks.length > 1) {
|
||||
final removed = _pendingOutputChunks.removeAt(0);
|
||||
_pendingOutputSuppressFlags.removeAt(0);
|
||||
_pendingOutputSize -= removed.length;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
terminal.write(text);
|
||||
_writeTerminalChunk(text, suppressTerminalOutput: suppressTerminalOutput);
|
||||
}
|
||||
|
||||
void _flushOutputBuffer() {
|
||||
if (_pendingOutputChunks.isEmpty) return;
|
||||
debugPrint(
|
||||
'[TerminalModel] Flushing $_pendingOutputSize buffered chars (${_pendingOutputChunks.length} chunks)');
|
||||
for (final chunk in _pendingOutputChunks) {
|
||||
terminal.write(chunk);
|
||||
for (var i = 0; i < _pendingOutputChunks.length; i++) {
|
||||
_writeTerminalChunk(
|
||||
_pendingOutputChunks[i],
|
||||
suppressTerminalOutput: _pendingOutputSuppressFlags[i],
|
||||
);
|
||||
}
|
||||
_pendingOutputChunks.clear();
|
||||
_pendingOutputSuppressFlags.clear();
|
||||
_pendingOutputSize = 0;
|
||||
}
|
||||
|
||||
void _writeTerminalChunk(
|
||||
String text, {
|
||||
required bool suppressTerminalOutput,
|
||||
}) {
|
||||
if (!suppressTerminalOutput) {
|
||||
terminal.write(text);
|
||||
return;
|
||||
}
|
||||
final previous = _suppressTerminalOutput;
|
||||
_suppressTerminalOutput = true;
|
||||
try {
|
||||
terminal.write(text);
|
||||
} finally {
|
||||
_suppressTerminalOutput = previous;
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark terminal view as ready and flush buffered output.
|
||||
void _scheduleMarkViewReady() {
|
||||
if (_disposed || _terminalViewReady || _markViewReadyScheduled) return;
|
||||
_markViewReadyScheduled = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_markViewReadyScheduled = false;
|
||||
if (_disposed || _terminalViewReady) return;
|
||||
if (terminal.viewWidth > 0 && terminal.viewHeight > 0) {
|
||||
_markViewReady();
|
||||
}
|
||||
});
|
||||
WidgetsBinding.instance.ensureVisualUpdate();
|
||||
}
|
||||
|
||||
void _markViewReady() {
|
||||
if (_terminalViewReady) return;
|
||||
_terminalViewReady = true;
|
||||
@@ -412,6 +477,8 @@ class TerminalModel with ChangeNotifier {
|
||||
_writeToTerminal('\r\nTerminal closed with exit code: $exitCode\r\n');
|
||||
_terminalOpened = false;
|
||||
notifyListeners();
|
||||
// Auto-close the tab/page
|
||||
onClosed?.call();
|
||||
}
|
||||
|
||||
void _handleTerminalError(Map<String, dynamic> evt) {
|
||||
@@ -426,7 +493,10 @@ class TerminalModel with ChangeNotifier {
|
||||
// Clear buffers to free memory
|
||||
_inputBuffer.clear();
|
||||
_pendingOutputChunks.clear();
|
||||
_pendingOutputSuppressFlags.clear();
|
||||
_pendingOutputSize = 0;
|
||||
_markViewReadyScheduled = false;
|
||||
_suppressNextTerminalDataOutput = false;
|
||||
// Terminal cleanup is handled server-side when service closes
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -2034,7 +2034,14 @@ class RustdeskImpl {
|
||||
}
|
||||
|
||||
String mainResolveAvatarUrl({required String avatar, dynamic hint}) {
|
||||
return js.context.callMethod('getByName', ['resolve_avatar_url', avatar])?.toString() ?? avatar;
|
||||
return js.context.callMethod(
|
||||
'getByName', ['resolve_avatar_url', avatar])?.toString() ??
|
||||
avatar;
|
||||
}
|
||||
|
||||
Future<String> mainDeployDevice(
|
||||
{required String token, required String id, dynamic hint}) {
|
||||
throw UnimplementedError("mainDeployDevice");
|
||||
}
|
||||
|
||||
void dispose() {}
|
||||
|
||||
@@ -11,4 +11,4 @@ PRODUCT_NAME = RustDesk
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.carriez.flutterHbb
|
||||
|
||||
// The copyright displayed in application information
|
||||
PRODUCT_COPYRIGHT = Copyright © 2025 Purslane Ltd. All rights reserved.
|
||||
PRODUCT_COPYRIGHT = Copyright © 2026 Purslane Tech Pte. Ltd. All rights reserved.
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
#!/usr/bin/env bash
|
||||
cargo ndk --platform 21 --target armv7-linux-androideabi build --release --features flutter,hwcodec
|
||||
cargo ndk --platform 21 --target armv7-linux-androideabi build --locked --release --features flutter,hwcodec
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
#!/usr/bin/env bash
|
||||
cargo ndk --platform 21 --target aarch64-linux-android build --release --features flutter,hwcodec
|
||||
cargo ndk --platform 21 --target aarch64-linux-android build --locked --release --features flutter,hwcodec
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
#!/usr/bin/env bash
|
||||
cargo ndk --platform 21 --target x86_64-linux-android build --release --features flutter
|
||||
cargo ndk --platform 21 --target x86_64-linux-android build --locked --release --features flutter
|
||||
|
||||
@@ -7,4 +7,4 @@
|
||||
export CFLAGS="-DBROKEN_CLANG_ATOMICS"
|
||||
export CXXFLAGS="-DBROKEN_CLANG_ATOMICS"
|
||||
|
||||
cargo ndk --platform 21 --target i686-linux-android build --release --features flutter
|
||||
cargo ndk --platform 21 --target i686-linux-android build --locked --release --features flutter
|
||||
|
||||
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
|
||||
# Read more about iOS versioning at
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# 1.1.9-1 works for android, but for ios it becomes 1.1.91, need to set it to 1.1.9-a.1 for iOS, will get 1.1.9.1, but iOS store not allow 4 numbers
|
||||
version: 1.4.6+64
|
||||
version: 1.4.8+66
|
||||
|
||||
environment:
|
||||
sdk: '^3.1.0'
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
cargo install flutter_rust_bridge_codegen --version 1.80.1 --features uuid
|
||||
cargo install flutter_rust_bridge_codegen --version 1.80.1 --features uuid --locked
|
||||
flutter pub get
|
||||
~/.cargo/bin/flutter_rust_bridge_codegen --rust-input ../src/flutter_ffi.rs --dart-output ./lib/generated_bridge.dart --c-output ./macos/Runner/bridge_generated.h
|
||||
# call `flutter clean` if cargo build fails
|
||||
# export LLVM_HOME=/Library/Developer/CommandLineTools/usr/
|
||||
cargo build --features flutter
|
||||
cargo build --locked --features flutter
|
||||
flutter run $@
|
||||
|
||||
148
flutter/test/autocomplete_peer_merge_test.dart
Normal file
148
flutter/test/autocomplete_peer_merge_test.dart
Normal file
@@ -0,0 +1,148 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
63
flutter/test/cm_demo.dart
Normal file
63
flutter/test/cm_demo.dart
Normal file
@@ -0,0 +1,63 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -1,62 +1,20 @@
|
||||
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 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
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)
|
||||
];
|
||||
import 'cm_demo.dart' as cm_demo;
|
||||
|
||||
/// 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)
|
||||
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',
|
||||
]);
|
||||
// ensure
|
||||
windowManager.setAlignment(Alignment.topRight);
|
||||
expect(
|
||||
cm_demo.testClients.every(
|
||||
(client) => client.keyboard && !client.clipboard && !client.audio),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
38
flutter/test/server_settings_dialog_test.dart
Normal file
38
flutter/test/server_settings_dialog_test.dart
Normal file
@@ -0,0 +1,38 @@
|
||||
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(),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -89,11 +89,11 @@ BEGIN
|
||||
BEGIN
|
||||
BLOCK "040904e4"
|
||||
BEGIN
|
||||
VALUE "CompanyName", "Purslane Ltd" "\0"
|
||||
VALUE "CompanyName", "Purslane Tech Pte. Ltd." "\0"
|
||||
VALUE "FileDescription", "RustDesk Remote Desktop" "\0"
|
||||
VALUE "FileVersion", VERSION_AS_STRING "\0"
|
||||
VALUE "InternalName", "rustdesk" "\0"
|
||||
VALUE "LegalCopyright", "Copyright © 2025 Purslane Ltd. All rights reserved." "\0"
|
||||
VALUE "LegalCopyright", "Copyright © 2026 Purslane Tech Pte. Ltd. All rights reserved." "\0"
|
||||
VALUE "OriginalFilename", "rustdesk.exe" "\0"
|
||||
VALUE "ProductName", "RustDesk" "\0"
|
||||
VALUE "ProductVersion", VERSION_AS_STRING "\0"
|
||||
|
||||
@@ -533,7 +533,7 @@ impl FuseServer {
|
||||
offset: i64,
|
||||
size: u32,
|
||||
) -> Result<Vec<u8>, std::io::Error> {
|
||||
// todo: async and concurrent read, generate stream_id per request
|
||||
let request_stream_id = rand::random();
|
||||
let cb_requested = unsafe {
|
||||
// convert `size` from u32 to i32
|
||||
// yet with same bit representation
|
||||
@@ -543,7 +543,7 @@ impl FuseServer {
|
||||
let (n_position_high, n_position_low) =
|
||||
((offset >> 32) as i32, (offset & (u32::MAX as i64)) as i32);
|
||||
let request = ClipboardFile::FileContentsRequest {
|
||||
stream_id: node.stream_id,
|
||||
stream_id: request_stream_id,
|
||||
list_index: node.index as i32,
|
||||
dw_flags: 2,
|
||||
n_position_low,
|
||||
@@ -573,7 +573,7 @@ impl FuseServer {
|
||||
stream_id,
|
||||
requested_data,
|
||||
} => {
|
||||
if stream_id != node.stream_id {
|
||||
if stream_id != request_stream_id {
|
||||
log::debug!("stream id mismatch, ignore");
|
||||
continue;
|
||||
}
|
||||
@@ -611,11 +611,6 @@ struct FuseNode {
|
||||
/// connection id
|
||||
pub conn_id: i32,
|
||||
|
||||
// todo: use stream_id to identify a FileContents request-reply
|
||||
// instead of a whole file
|
||||
/// stream id
|
||||
pub stream_id: i32,
|
||||
|
||||
/// file index in peer's file list
|
||||
/// NOTE:
|
||||
/// it is NOT the same as inode, this is the index in the file list
|
||||
@@ -639,7 +634,6 @@ impl FuseNode {
|
||||
pub fn from_description(inode: Inode, desc: FileDescription) -> Self {
|
||||
Self {
|
||||
conn_id: desc.conn_id,
|
||||
stream_id: rand::random(),
|
||||
index: inode as usize - 2,
|
||||
name: desc
|
||||
.name
|
||||
@@ -656,7 +650,6 @@ impl FuseNode {
|
||||
pub fn new_root() -> Self {
|
||||
Self {
|
||||
conn_id: 0,
|
||||
stream_id: rand::random(),
|
||||
index: 0,
|
||||
name: String::from("/"),
|
||||
parent: None,
|
||||
|
||||
@@ -4,23 +4,24 @@ use super::filetype::FileDescription;
|
||||
use crate::{ClipboardFile, CliprdrError};
|
||||
use cs::FuseServer;
|
||||
use fuser::MountOption;
|
||||
use hbb_common::{config::APP_NAME, log};
|
||||
use hbb_common::{config::Config, log};
|
||||
use parking_lot::Mutex;
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
sync::{mpsc::Sender, Arc},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref FUSE_MOUNT_POINT_CLIENT: Arc<String> = {
|
||||
let mnt_path = format!("/tmp/{}/{}", APP_NAME.read().unwrap(), "cliprdr-client");
|
||||
let mnt_path = fuse_mount_point("cliprdr-client");
|
||||
// No need to run `canonicalize()` here.
|
||||
Arc::new(mnt_path)
|
||||
};
|
||||
|
||||
static ref FUSE_MOUNT_POINT_SERVER: Arc<String> = {
|
||||
let mnt_path = format!("/tmp/{}/{}", APP_NAME.read().unwrap(), "cliprdr-server");
|
||||
let mnt_path = fuse_mount_point("cliprdr-server");
|
||||
// No need to run `canonicalize()` here.
|
||||
Arc::new(mnt_path)
|
||||
};
|
||||
@@ -31,6 +32,21 @@ lazy_static::lazy_static! {
|
||||
|
||||
static FUSE_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum MountPointState {
|
||||
HealthyMount,
|
||||
NotMounted,
|
||||
StaleMount,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
fn fuse_mount_point(name: &str) -> String {
|
||||
let mut path = PathBuf::from(Config::ipc_path(""));
|
||||
path.pop();
|
||||
path.push(name);
|
||||
path.to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
pub fn get_exclude_paths(is_client: bool) -> Arc<String> {
|
||||
if is_client {
|
||||
FUSE_MOUNT_POINT_CLIENT.clone()
|
||||
@@ -53,8 +69,27 @@ pub fn init_fuse_context(is_client: bool) -> Result<(), CliprdrError> {
|
||||
} else {
|
||||
FUSE_CONTEXT_SERVER.lock()
|
||||
};
|
||||
if fuse_context_lock.is_some() {
|
||||
return Ok(());
|
||||
if let Some(ctx) = fuse_context_lock.as_ref() {
|
||||
match inspect_mount_point_state(&ctx.mount_point) {
|
||||
MountPointState::HealthyMount => return Ok(()),
|
||||
MountPointState::StaleMount | MountPointState::NotMounted => {
|
||||
log::warn!(
|
||||
"clipboard FUSE mount {} is disconnected, remounting",
|
||||
ctx.mount_point.display()
|
||||
);
|
||||
let stale_context = fuse_context_lock.take();
|
||||
drop(fuse_context_lock);
|
||||
drop(stale_context);
|
||||
return init_fuse_context(is_client);
|
||||
}
|
||||
MountPointState::Unknown => {
|
||||
log::warn!(
|
||||
"failed to verify clipboard FUSE mount {}",
|
||||
ctx.mount_point.display()
|
||||
);
|
||||
return Err(CliprdrError::CliprdrInit);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mount_point = if is_client {
|
||||
FUSE_MOUNT_POINT_CLIENT.clone()
|
||||
@@ -63,10 +98,32 @@ pub fn init_fuse_context(is_client: bool) -> Result<(), CliprdrError> {
|
||||
};
|
||||
|
||||
let mount_point = std::path::PathBuf::from(&*mount_point);
|
||||
match inspect_mount_point_state(&mount_point) {
|
||||
MountPointState::HealthyMount => {
|
||||
log::warn!(
|
||||
"clipboard FUSE mount {} is already active in another context",
|
||||
mount_point.display()
|
||||
);
|
||||
return Err(CliprdrError::ClipboardOccupied);
|
||||
}
|
||||
MountPointState::StaleMount => {
|
||||
log::warn!(
|
||||
"clipboard FUSE mount {} is stale, cleaning up before remount",
|
||||
mount_point.display()
|
||||
);
|
||||
unmount_fuse_mount_point(&mount_point);
|
||||
validate_mount_state_after_stale_cleanup(
|
||||
&mount_point,
|
||||
inspect_mount_point_state(&mount_point),
|
||||
)?;
|
||||
}
|
||||
MountPointState::Unknown => return Err(CliprdrError::CliprdrInit),
|
||||
MountPointState::NotMounted => {}
|
||||
}
|
||||
let (server, tx) = FuseServer::new(FUSE_TIMEOUT);
|
||||
let server = Arc::new(Mutex::new(server));
|
||||
|
||||
prepare_fuse_mount_point(&mount_point);
|
||||
prepare_fuse_mount_point(&mount_point)?;
|
||||
let mnt_opts = [
|
||||
MountOption::FSName("rustdesk-cliprdr-fs".to_string()),
|
||||
MountOption::NoAtime,
|
||||
@@ -159,35 +216,246 @@ struct FuseContext {
|
||||
}
|
||||
|
||||
// this function must be called after the main IPC is up
|
||||
fn prepare_fuse_mount_point(mount_point: &PathBuf) {
|
||||
fn prepare_fuse_mount_point(mount_point: &PathBuf) -> Result<(), CliprdrError> {
|
||||
use std::{
|
||||
fs::{self, Permissions},
|
||||
os::unix::prelude::PermissionsExt,
|
||||
};
|
||||
|
||||
fs::create_dir(mount_point).ok();
|
||||
fs::set_permissions(mount_point, Permissions::from_mode(0o777)).ok();
|
||||
if let Some(parent) = mount_point.parent() {
|
||||
reject_symlink_path(parent)?;
|
||||
if let Err(e) = fs::create_dir_all(parent) {
|
||||
log::warn!("failed to create FUSE mount parent {:?}: {:?}", parent, e);
|
||||
return Err(CliprdrError::CliprdrInit);
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = std::process::Command::new("umount")
|
||||
.arg(mount_point)
|
||||
.status()
|
||||
{
|
||||
log::warn!("umount {:?} may fail: {:?}", mount_point, e);
|
||||
reject_symlink_path(mount_point)?;
|
||||
|
||||
let recovered_stale_mount = if let Err(e) = fs::create_dir_all(mount_point) {
|
||||
log::warn!(
|
||||
"failed to create clipboard FUSE mount point {}, trying stale mount cleanup: {:?}",
|
||||
mount_point.display(),
|
||||
e
|
||||
);
|
||||
unmount_fuse_mount_point(mount_point);
|
||||
fs::create_dir_all(mount_point).map_err(|e| {
|
||||
log::error!(
|
||||
"failed to create clipboard FUSE mount point {} after cleanup: {:?}",
|
||||
mount_point.display(),
|
||||
e
|
||||
);
|
||||
CliprdrError::CliprdrInit
|
||||
})?;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if let Err(e) = fs::set_permissions(mount_point, Permissions::from_mode(0o777)) {
|
||||
log::warn!(
|
||||
"failed to set clipboard FUSE mount point permissions {}: {:?}",
|
||||
mount_point.display(),
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
if !recovered_stale_mount {
|
||||
unmount_fuse_mount_point(mount_point);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn inspect_mount_point_state(mount_point: &Path) -> MountPointState {
|
||||
if ensure_mount_point_path_is_safe(mount_point).is_err() {
|
||||
return MountPointState::Unknown;
|
||||
}
|
||||
inspect_mount_point_state_with(
|
||||
mount_point,
|
||||
std::fs::metadata(mount_point),
|
||||
std::fs::read_to_string("/proc/self/mountinfo"),
|
||||
)
|
||||
}
|
||||
|
||||
fn validate_mount_state_after_stale_cleanup(
|
||||
mount_point: &Path,
|
||||
mount_state: MountPointState,
|
||||
) -> Result<(), CliprdrError> {
|
||||
match mount_state {
|
||||
MountPointState::NotMounted => Ok(()),
|
||||
MountPointState::HealthyMount => {
|
||||
log::warn!(
|
||||
"clipboard FUSE mount {} is still active after stale cleanup",
|
||||
mount_point.display()
|
||||
);
|
||||
Err(CliprdrError::ClipboardOccupied)
|
||||
}
|
||||
MountPointState::StaleMount => {
|
||||
log::warn!(
|
||||
"clipboard FUSE mount {} is still stale after cleanup",
|
||||
mount_point.display()
|
||||
);
|
||||
Err(CliprdrError::CliprdrInit)
|
||||
}
|
||||
MountPointState::Unknown => {
|
||||
log::warn!(
|
||||
"failed to verify clipboard FUSE mount {} after cleanup",
|
||||
mount_point.display()
|
||||
);
|
||||
Err(CliprdrError::CliprdrInit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn uninit_fuse_context_(is_client: bool) {
|
||||
if is_client {
|
||||
let _ = FUSE_CONTEXT_CLIENT.lock().take();
|
||||
} else {
|
||||
let _ = FUSE_CONTEXT_SERVER.lock().take();
|
||||
fn inspect_mount_point_state_with<T>(
|
||||
mount_point: &Path,
|
||||
metadata_result: io::Result<T>,
|
||||
mountinfo_result: io::Result<String>,
|
||||
) -> MountPointState {
|
||||
match metadata_result {
|
||||
Ok(_) => match mountinfo_result {
|
||||
Ok(mountinfo) => {
|
||||
if is_mount_point_listed_in_mountinfo(mount_point, &mountinfo) {
|
||||
MountPointState::HealthyMount
|
||||
} else {
|
||||
MountPointState::NotMounted
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("failed to read mountinfo for {:?}: {:?}", mount_point, e);
|
||||
MountPointState::Unknown
|
||||
}
|
||||
},
|
||||
Err(e) if e.raw_os_error() == Some(libc::ENOTCONN) => MountPointState::StaleMount,
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => MountPointState::NotMounted,
|
||||
Err(e) => {
|
||||
log::warn!("failed to inspect FUSE mount {:?}: {:?}", mount_point, e);
|
||||
MountPointState::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_mount_point_listed_in_mountinfo(mount_point: &Path, mountinfo: &str) -> bool {
|
||||
let mount_point = mount_point.to_string_lossy();
|
||||
mountinfo.lines().any(|line| {
|
||||
let mut fields = line.split_whitespace();
|
||||
let _mount_id = fields.next();
|
||||
let _parent_id = fields.next();
|
||||
let _major_minor = fields.next();
|
||||
let _root = fields.next();
|
||||
let mount_path = fields.next();
|
||||
mount_path == Some(mount_point.as_ref())
|
||||
})
|
||||
}
|
||||
|
||||
fn reject_symlink_metadata_result(
|
||||
path: &Path,
|
||||
metadata_result: io::Result<std::fs::Metadata>,
|
||||
allow_disconnected_mount: bool,
|
||||
) -> Result<(), CliprdrError> {
|
||||
match metadata_result {
|
||||
Ok(metadata) if metadata.file_type().is_symlink() => {
|
||||
log::warn!("refusing to use symlinked FUSE path {:?}", path);
|
||||
Err(CliprdrError::CliprdrInit)
|
||||
}
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) if allow_disconnected_mount && e.raw_os_error() == Some(libc::ENOTCONN) => Ok(()),
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => {
|
||||
log::warn!("failed to inspect FUSE path {:?}: {:?}", path, e);
|
||||
Err(CliprdrError::CliprdrInit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reject_symlink_path(path: &Path) -> Result<(), CliprdrError> {
|
||||
reject_symlink_metadata_result(path, std::fs::symlink_metadata(path), false)
|
||||
}
|
||||
|
||||
fn ensure_mount_point_path_is_safe(mount_point: &Path) -> Result<(), CliprdrError> {
|
||||
if let Some(parent) = mount_point.parent() {
|
||||
reject_symlink_path(parent)?;
|
||||
}
|
||||
reject_symlink_metadata_result(mount_point, std::fs::symlink_metadata(mount_point), true)
|
||||
}
|
||||
|
||||
fn unmount_fuse_mount_point(mount_point: &Path) {
|
||||
if ensure_mount_point_path_is_safe(mount_point).is_err() {
|
||||
log::warn!(
|
||||
"refusing to unmount unsafe clipboard FUSE mount point {:?}",
|
||||
mount_point
|
||||
);
|
||||
return;
|
||||
}
|
||||
if inspect_mount_point_state_with(
|
||||
mount_point,
|
||||
std::fs::metadata(mount_point),
|
||||
std::fs::read_to_string("/proc/self/mountinfo"),
|
||||
) == MountPointState::NotMounted
|
||||
{
|
||||
return;
|
||||
}
|
||||
for (program, args) in unmount_command_candidates() {
|
||||
if run_unmount_command(program, args, mount_point) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
log::warn!(
|
||||
"failed to unmount clipboard FUSE mount point {:?}",
|
||||
mount_point
|
||||
);
|
||||
}
|
||||
|
||||
fn unmount_command_candidates() -> [(&'static str, &'static [&'static str]); 3] {
|
||||
[
|
||||
("fusermount3", &["-uz"]),
|
||||
("fusermount", &["-uz"]),
|
||||
("umount", &["-l"]),
|
||||
]
|
||||
}
|
||||
|
||||
fn run_unmount_command(program: &str, args: &[&str], mount_point: &Path) -> bool {
|
||||
match std::process::Command::new(program)
|
||||
.args(args)
|
||||
.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;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn uninit_fuse_context_(is_client: bool) {
|
||||
let ctx = {
|
||||
let mut fuse_context_lock = if is_client {
|
||||
FUSE_CONTEXT_CLIENT.lock()
|
||||
} else {
|
||||
FUSE_CONTEXT_SERVER.lock()
|
||||
};
|
||||
fuse_context_lock.take()
|
||||
};
|
||||
drop(ctx);
|
||||
}
|
||||
|
||||
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());
|
||||
log::info!(
|
||||
"unmounting clipboard FUSE from {}",
|
||||
self.mount_point.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,3 +491,66 @@ impl FuseContext {
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::{fs, io};
|
||||
|
||||
#[cfg(target_family = "unix")]
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
#[test]
|
||||
fn classifies_mount_point_state_from_metadata_and_mountinfo() {
|
||||
let mount_point = std::env::temp_dir().join(format!(
|
||||
"rustdesk-fuse-mount-state-test-{}-{}",
|
||||
std::process::id(),
|
||||
line!()
|
||||
));
|
||||
let mountinfo = format!(
|
||||
"123 1 0:45 / {} rw,nosuid,nodev - fuse.rustdesk rustdesk rw\n",
|
||||
mount_point.display()
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
inspect_mount_point_state_with(&mount_point, Ok(()), Ok(mountinfo)),
|
||||
MountPointState::HealthyMount
|
||||
);
|
||||
assert_eq!(
|
||||
inspect_mount_point_state_with(&mount_point, Ok(()), Ok(String::new())),
|
||||
MountPointState::NotMounted
|
||||
);
|
||||
|
||||
let disconnected_metadata: io::Result<()> =
|
||||
Err(io::Error::from_raw_os_error(libc::ENOTCONN));
|
||||
assert_eq!(
|
||||
inspect_mount_point_state_with(&mount_point, disconnected_metadata, Ok(String::new())),
|
||||
MountPointState::StaleMount
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(target_family = "unix")]
|
||||
fn rejects_symlink_mount_point() {
|
||||
let base = std::env::temp_dir().join(format!(
|
||||
"rustdesk-fuse-symlink-test-{}-{}",
|
||||
std::process::id(),
|
||||
line!()
|
||||
));
|
||||
let mount_parent = base.join("parent");
|
||||
let mount_point = mount_parent.join("cliprdr-client");
|
||||
let symlink_target = base.join("symlink-target");
|
||||
let _ = fs::remove_dir_all(&base);
|
||||
fs::create_dir_all(&base).unwrap();
|
||||
fs::create_dir_all(&mount_parent).unwrap();
|
||||
fs::create_dir_all(&symlink_target).unwrap();
|
||||
symlink(&symlink_target, &mount_point).unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
prepare_fuse_mount_point(&mount_point),
|
||||
Err(CliprdrError::CliprdrInit)
|
||||
));
|
||||
|
||||
let _ = fs::remove_dir_all(&base);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,20 +192,16 @@ impl LocalFile {
|
||||
});
|
||||
};
|
||||
|
||||
if offset != self.offset.load(Ordering::Relaxed) {
|
||||
let read_result = if offset != self.offset.load(Ordering::Relaxed) {
|
||||
handle
|
||||
.seek(std::io::SeekFrom::Start(offset))
|
||||
.map_err(|e| CliprdrError::FileError {
|
||||
path: self.path.to_string_lossy().to_string(),
|
||||
err: e,
|
||||
})?;
|
||||
.and_then(|_| handle.read_exact(buf))
|
||||
} else {
|
||||
handle.read_exact(buf)
|
||||
};
|
||||
if let Err(e) = read_result {
|
||||
return Err(self.invalidate_handle(e));
|
||||
}
|
||||
handle
|
||||
.read_exact(buf)
|
||||
.map_err(|e| CliprdrError::FileError {
|
||||
path: self.path.to_string_lossy().to_string(),
|
||||
err: e,
|
||||
})?;
|
||||
let new_offset = offset + (buf.len() as u64);
|
||||
self.offset.store(new_offset, Ordering::Relaxed);
|
||||
|
||||
@@ -217,6 +213,15 @@ impl LocalFile {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn invalidate_handle(&mut self, err: std::io::Error) -> CliprdrError {
|
||||
self.offset.store(0, Ordering::Relaxed);
|
||||
self.handle = None;
|
||||
CliprdrError::FileError {
|
||||
path: self.path.to_string_lossy().to_string(),
|
||||
err,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn construct_file_list(paths: &[PathBuf]) -> Result<Vec<LocalFile>, CliprdrError> {
|
||||
@@ -278,7 +283,10 @@ pub(super) fn construct_file_list(paths: &[PathBuf]) -> Result<Vec<LocalFile>, C
|
||||
|
||||
#[cfg(test)]
|
||||
mod file_list_test {
|
||||
use std::{path::PathBuf, sync::atomic::AtomicU64};
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
|
||||
use hbb_common::bytes::{BufMut, BytesMut};
|
||||
|
||||
@@ -384,4 +392,30 @@ mod file_list_test {
|
||||
as_bin_parse_test("/test")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_exact_at_reopens_after_read_failure() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let file_path = std::env::temp_dir().join(format!(
|
||||
"rustdesk-clipboard-local-file-{}",
|
||||
std::process::id()
|
||||
));
|
||||
std::fs::write(&file_path, b"")?;
|
||||
|
||||
let mut file = LocalFile::try_open(&std::env::temp_dir(), &file_path)?;
|
||||
file.size = 1;
|
||||
|
||||
let mut buf = [0u8; 1];
|
||||
assert!(file.read_exact_at(&mut buf, 0).is_err());
|
||||
assert!(file.handle.is_none());
|
||||
assert_eq!(file.offset.load(Ordering::Relaxed), 0);
|
||||
|
||||
std::fs::write(&file_path, [42u8])?;
|
||||
|
||||
file.read_exact_at(&mut buf, 0)?;
|
||||
assert_eq!(buf, [42u8]);
|
||||
assert!(file.handle.is_none());
|
||||
|
||||
std::fs::remove_file(file_path)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use hbb_common::{
|
||||
log,
|
||||
};
|
||||
use parking_lot::Mutex;
|
||||
use std::{path::PathBuf, sync::Arc, usize};
|
||||
use std::{path::PathBuf, sync::Arc, time::SystemTime, usize};
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
// local files are cached, this value should not be changed when copying files
|
||||
@@ -30,9 +30,35 @@ enum FileContentsRequest {
|
||||
},
|
||||
}
|
||||
|
||||
// Cheap fingerprint of one top-level selected entry. A change in size/mtime --
|
||||
// or a directory in the selection -- forces sync_files() to rebuild (see below).
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
struct FileSig {
|
||||
size: u64,
|
||||
mtime: Option<SystemTime>,
|
||||
is_dir: bool,
|
||||
}
|
||||
|
||||
// Stat the top-level selected paths only (no recursion), same order as `files`.
|
||||
fn fingerprint(files: &[String]) -> Vec<FileSig> {
|
||||
files
|
||||
.iter()
|
||||
.map(|s| match std::fs::metadata(s) {
|
||||
Ok(mt) => FileSig {
|
||||
size: mt.len(),
|
||||
mtime: mt.modified().ok(),
|
||||
is_dir: mt.is_dir(),
|
||||
},
|
||||
Err(_) => FileSig::default(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ClipFiles {
|
||||
files: Vec<String>,
|
||||
// Fingerprint of `files` (same len/order); detects in-place edits on re-copy.
|
||||
sigs: Vec<FileSig>,
|
||||
file_list: Vec<LocalFile>,
|
||||
first_file_index: usize,
|
||||
files_pdu: Vec<u8>,
|
||||
@@ -41,12 +67,17 @@ struct ClipFiles {
|
||||
impl ClipFiles {
|
||||
fn clear(&mut self) {
|
||||
self.files.clear();
|
||||
self.sigs.clear();
|
||||
self.file_list.clear();
|
||||
self.first_file_index = usize::MAX;
|
||||
self.files_pdu.clear();
|
||||
}
|
||||
|
||||
fn sync_files(&mut self, clipboard_files: &[String]) -> Result<(), CliprdrError> {
|
||||
fn sync_files(
|
||||
&mut self,
|
||||
clipboard_files: &[String],
|
||||
sigs: Vec<FileSig>,
|
||||
) -> Result<(), CliprdrError> {
|
||||
let clipboard_paths = clipboard_files
|
||||
.iter()
|
||||
.map(|s| PathBuf::from(s))
|
||||
@@ -58,6 +89,7 @@ impl ClipFiles {
|
||||
.position(|f| !f.path.is_dir())
|
||||
.unwrap_or(usize::MAX);
|
||||
self.files = clipboard_files.to_vec();
|
||||
self.sigs = sigs;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -258,14 +290,128 @@ pub fn read_file_contents(
|
||||
}
|
||||
|
||||
pub fn sync_files(files: &[String]) -> Result<(), CliprdrError> {
|
||||
// Dedup: skip the rebuild only when paths + sizes + mtimes match and no dir is
|
||||
// selected (a dir's own mtime doesn't change when a file inside it is edited).
|
||||
let current = fingerprint(files);
|
||||
let mut files_lock = CLIP_FILES.lock();
|
||||
if files_lock.files == files {
|
||||
if files_lock.files == files
|
||||
&& files_lock.sigs == current
|
||||
&& !current.iter().any(|sig| sig.is_dir)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
files_lock.sync_files(files)?;
|
||||
files_lock.sync_files(files, current)?;
|
||||
Ok(files_lock.build_file_list_pdu())
|
||||
}
|
||||
|
||||
pub fn get_file_list_pdu() -> Vec<u8> {
|
||||
CLIP_FILES.lock().files_pdu.clone()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod sig_test {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
// Unique temp dir under the system temp dir; removed on drop (no dev-dep).
|
||||
struct TmpDir(PathBuf);
|
||||
impl TmpDir {
|
||||
fn new(tag: &str) -> Self {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
let mut dir = std::env::temp_dir();
|
||||
dir.push(format!("rustdesk_sig_test_{}_{}", tag, nanos));
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
TmpDir(dir)
|
||||
}
|
||||
fn join(&self, name: &str) -> PathBuf {
|
||||
self.0.join(name)
|
||||
}
|
||||
}
|
||||
impl Drop for TmpDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn path_str(p: &PathBuf) -> String {
|
||||
p.to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_missing_path_is_default() {
|
||||
let tmp = TmpDir::new("missing");
|
||||
let missing = path_str(&tmp.join("does_not_exist.bin"));
|
||||
let sigs = fingerprint(&[missing]);
|
||||
assert_eq!(sigs.len(), 1);
|
||||
// A path that can't be stat'd -> default sig, which forces a rebuild.
|
||||
assert_eq!(sigs[0], FileSig::default());
|
||||
assert_eq!(sigs[0].mtime, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_detects_inplace_edit() {
|
||||
let tmp = TmpDir::new("edit");
|
||||
let file = tmp.join("a.bin");
|
||||
fs::write(&file, b"small").unwrap();
|
||||
let p = path_str(&file);
|
||||
|
||||
let before = fingerprint(&[p.clone()]);
|
||||
// Same content, same path: fingerprint must be stable.
|
||||
let again = fingerprint(&[p.clone()]);
|
||||
assert_eq!(before, again);
|
||||
assert_eq!(before[0].size, 5);
|
||||
assert!(!before[0].is_dir);
|
||||
|
||||
// Edit in place so the file grows.
|
||||
fs::write(&file, b"much larger contents than before").unwrap();
|
||||
let after = fingerprint(&[p]);
|
||||
assert_ne!(before, after);
|
||||
assert!(after[0].size > before[0].size);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_flags_directory() {
|
||||
let tmp = TmpDir::new("dir");
|
||||
let sub = tmp.join("subdir");
|
||||
fs::create_dir_all(&sub).unwrap();
|
||||
let sigs = fingerprint(&[path_str(&sub)]);
|
||||
assert_eq!(sigs.len(), 1);
|
||||
assert!(sigs[0].is_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recopy_after_edit_refreshes_cached_size() {
|
||||
let tmp = TmpDir::new("recopy");
|
||||
let file = tmp.join("doc.bin");
|
||||
fs::write(&file, b"v1").unwrap(); // 2 bytes
|
||||
let files = vec![path_str(&file)];
|
||||
|
||||
// Drive the public, guarded `sync_files` over the global CLIP_FILES;
|
||||
// reset first (this is the only test that touches the global).
|
||||
clear_files();
|
||||
|
||||
sync_files(&files).unwrap();
|
||||
{
|
||||
let cache = CLIP_FILES.lock();
|
||||
let idx = cache.first_file_index;
|
||||
assert_eq!(cache.file_list[idx].size, 2);
|
||||
}
|
||||
|
||||
// In-place edit grows the file; the re-copy must rebuild. Pre-fix the
|
||||
// path-only guard early-returned and left the cached size stale at 2.
|
||||
fs::write(&file, b"v2 is bigger").unwrap(); // 12 bytes
|
||||
sync_files(&files).unwrap();
|
||||
{
|
||||
let cache = CLIP_FILES.lock();
|
||||
let idx = cache.first_file_index;
|
||||
assert_eq!(cache.file_list[idx].size, 12);
|
||||
}
|
||||
|
||||
clear_files(); // leave the global clean for other tests
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,55 @@
|
||||
|
||||
#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
|
||||
/* Registered clipboard formats use IDs 0xC000 through 0xFFFF.
|
||||
* https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-registerclipboardformatw */
|
||||
#define WF_CLIPRDR_MAX_FORMATS 0x4000u
|
||||
/* Registered format names are string atoms; cap the converted WCHAR name.
|
||||
* https://learn.microsoft.com/en-us/windows/win32/dataxchg/about-atom-tables */
|
||||
#define WF_CLIPRDR_MAX_FORMAT_NAME_WCHARS 255u
|
||||
/* Bound the peer-provided UTF-8 scan separately from the converted Windows name. */
|
||||
#define WF_CLIPRDR_MAX_FORMAT_NAME_UTF8_BYTES (WF_CLIPRDR_MAX_FORMAT_NAME_WCHARS * 4u)
|
||||
|
||||
/* Validates the remote descriptor array size after cItems has been read safely. */
|
||||
static BOOL wf_cliprdr_file_group_descriptor_size_valid(SIZE_T size, UINT count)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
static BOOL wf_cliprdr_bounded_strlen(const char *value, size_t max_len, size_t *len)
|
||||
{
|
||||
size_t i;
|
||||
|
||||
if (!value || !len)
|
||||
return FALSE;
|
||||
|
||||
for (i = 0; i <= max_len; i++)
|
||||
{
|
||||
if (value[i] == '\0')
|
||||
{
|
||||
*len = i;
|
||||
return TRUE;
|
||||
}
|
||||
}
|
||||
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clipboard Formats
|
||||
*/
|
||||
@@ -224,6 +273,7 @@ struct wf_clipboard
|
||||
|
||||
HWND hwnd;
|
||||
HANDLE hmem;
|
||||
SIZE_T hmem_data_len;
|
||||
HANDLE thread;
|
||||
HANDLE formatDataRespEvent;
|
||||
BOOL formatDataRespReceived;
|
||||
@@ -629,6 +679,50 @@ void CliprdrStream_Delete(CliprdrStream *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
|
||||
*/
|
||||
@@ -747,6 +841,9 @@ static HRESULT STDMETHODCALLTYPE CliprdrDataObject_GetData(IDataObject *This, FO
|
||||
{
|
||||
// FILEGROUPDESCRIPTOR *dsc;
|
||||
FILEGROUPDESCRIPTORW *dsc;
|
||||
IStream **streams = NULL;
|
||||
UINT stream_count = 0;
|
||||
SIZE_T hmem_size;
|
||||
// DWORD remote_format_id = get_remote_format_id(clipboard, instance->m_pFormatEtc[idx].cfFormat);
|
||||
// FIXME: origin code may be failed here???
|
||||
if (cliprdr_send_data_request(instance->m_connID, clipboard, instance->m_pFormatEtc[idx].cfFormat) != 0)
|
||||
@@ -764,40 +861,48 @@ static HRESULT STDMETHODCALLTYPE CliprdrDataObject_GetData(IDataObject *This, FO
|
||||
* is the number of FILEDESCRIPTOR's */
|
||||
// dsc = (FILEGROUPDESCRIPTOR *)GlobalLock(clipboard->hmem);
|
||||
dsc = (FILEGROUPDESCRIPTORW *)GlobalLock(clipboard->hmem);
|
||||
instance->m_nStreams = dsc->cItems;
|
||||
GlobalUnlock(clipboard->hmem);
|
||||
|
||||
if (instance->m_nStreams > 0)
|
||||
if (!dsc)
|
||||
{
|
||||
if (!instance->m_pStream)
|
||||
{
|
||||
instance->m_pStream = (LPSTREAM *)calloc(instance->m_nStreams, sizeof(LPSTREAM));
|
||||
|
||||
if (instance->m_pStream)
|
||||
{
|
||||
for (i = 0; i < instance->m_nStreams; i++)
|
||||
{
|
||||
instance->m_pStream[i] =
|
||||
(IStream *)CliprdrStream_New(instance->m_connID, i, clipboard, &dsc->fgd[i]);
|
||||
|
||||
if (!instance->m_pStream[i])
|
||||
return E_OUTOFMEMORY;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!instance->m_pStream)
|
||||
{
|
||||
if (clipboard->hmem)
|
||||
{
|
||||
GlobalFree(clipboard->hmem);
|
||||
clipboard->hmem = NULL;
|
||||
}
|
||||
|
||||
pMedium->hGlobal = NULL;
|
||||
return E_OUTOFMEMORY;
|
||||
GlobalFree(clipboard->hmem);
|
||||
clipboard->hmem = NULL;
|
||||
clipboard->hmem_data_len = 0;
|
||||
wf_cliprdr_reset_streams(instance);
|
||||
return E_UNEXPECTED;
|
||||
}
|
||||
|
||||
hmem_size = clipboard->hmem_data_len;
|
||||
/* cItems is remote-controlled; verify the fixed header exists before reading it. */
|
||||
if (hmem_size < offsetof(FILEGROUPDESCRIPTORW, fgd))
|
||||
return wf_cliprdr_fail_locked_file_descriptor_data(
|
||||
clipboard, pMedium, instance, NULL, 0, E_UNEXPECTED);
|
||||
|
||||
stream_count = dsc->cItems;
|
||||
if (!wf_cliprdr_file_group_descriptor_size_valid(hmem_size, stream_count))
|
||||
return wf_cliprdr_fail_locked_file_descriptor_data(
|
||||
clipboard, pMedium, instance, NULL, 0, E_UNEXPECTED);
|
||||
|
||||
streams = (IStream **)calloc(stream_count, sizeof(IStream *));
|
||||
if (!streams)
|
||||
return wf_cliprdr_fail_locked_file_descriptor_data(
|
||||
clipboard, pMedium, instance, NULL, 0, E_OUTOFMEMORY);
|
||||
|
||||
for (i = 0; i < stream_count; i++)
|
||||
{
|
||||
streams[i] =
|
||||
(IStream *)CliprdrStream_New(instance->m_connID, i, clipboard, &dsc->fgd[i]);
|
||||
if (!streams[i])
|
||||
{
|
||||
return wf_cliprdr_fail_locked_file_descriptor_data(
|
||||
clipboard, pMedium, instance, streams, i, E_OUTOFMEMORY);
|
||||
}
|
||||
}
|
||||
|
||||
GlobalUnlock(clipboard->hmem);
|
||||
wf_cliprdr_reset_streams(instance);
|
||||
instance->m_pStream = streams;
|
||||
instance->m_nStreams = stream_count;
|
||||
return S_OK;
|
||||
}
|
||||
else if (instance->m_pFormatEtc[idx].cfFormat == RegisterClipboardFormat(CFSTR_FILECONTENTS))
|
||||
{
|
||||
@@ -1328,25 +1433,35 @@ static UINT32 get_remote_format_id(wfClipboard *clipboard, UINT32 local_format)
|
||||
return local_format;
|
||||
}
|
||||
|
||||
static void map_ensure_capacity(wfClipboard *clipboard)
|
||||
static BOOL map_ensure_capacity(wfClipboard *clipboard, size_t capacity)
|
||||
{
|
||||
size_t old_size;
|
||||
formatMapping *new_map;
|
||||
|
||||
if (!clipboard)
|
||||
return;
|
||||
return FALSE;
|
||||
|
||||
if (clipboard->map_size >= clipboard->map_capacity)
|
||||
{
|
||||
size_t new_size;
|
||||
formatMapping *new_map;
|
||||
new_size = clipboard->map_capacity * 2;
|
||||
new_map =
|
||||
(formatMapping *)realloc(clipboard->format_mappings, sizeof(formatMapping) * new_size);
|
||||
if (!clipboard->format_mappings)
|
||||
return FALSE;
|
||||
|
||||
if (!new_map)
|
||||
return;
|
||||
if (capacity <= clipboard->map_capacity)
|
||||
return TRUE;
|
||||
|
||||
clipboard->format_mappings = new_map;
|
||||
clipboard->map_capacity = new_size;
|
||||
}
|
||||
if (capacity > WF_CLIPRDR_MAX_FORMATS ||
|
||||
capacity > ((size_t)-1) / sizeof(formatMapping))
|
||||
return FALSE;
|
||||
|
||||
old_size = clipboard->map_capacity;
|
||||
new_map =
|
||||
(formatMapping *)realloc(clipboard->format_mappings, sizeof(formatMapping) * capacity);
|
||||
|
||||
if (!new_map)
|
||||
return FALSE;
|
||||
|
||||
memset(new_map + old_size, 0, sizeof(formatMapping) * (capacity - old_size));
|
||||
clipboard->format_mappings = new_map;
|
||||
clipboard->map_capacity = capacity;
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
static BOOL clear_format_map(wfClipboard *clipboard)
|
||||
@@ -1373,6 +1488,13 @@ static BOOL clear_format_map(wfClipboard *clipboard)
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
static UINT wf_cliprdr_server_format_list_fail(wfClipboard *clipboard)
|
||||
{
|
||||
clear_format_map(clipboard);
|
||||
clipboard->copied = FALSE;
|
||||
return ERROR_INTERNAL_ERROR;
|
||||
}
|
||||
|
||||
static UINT cliprdr_send_tempdir(wfClipboard *clipboard)
|
||||
{
|
||||
CLIPRDR_TEMP_DIRECTORY tempDirectory;
|
||||
@@ -2161,16 +2283,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((size_t)MAX_PATH * sizeof(WCHAR));
|
||||
|
||||
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] =
|
||||
@@ -2365,6 +2487,16 @@ static UINT wf_cliprdr_server_format_list(CliprdrClientContext *context,
|
||||
|
||||
if (!clear_format_map(clipboard))
|
||||
return ERROR_INTERNAL_ERROR;
|
||||
clipboard->copied = FALSE;
|
||||
|
||||
if (formatList->numFormats > WF_CLIPRDR_MAX_FORMATS)
|
||||
return ERROR_INTERNAL_ERROR;
|
||||
|
||||
if (formatList->numFormats > 0 && !formatList->formats)
|
||||
return ERROR_INTERNAL_ERROR;
|
||||
|
||||
if (!map_ensure_capacity(clipboard, formatList->numFormats))
|
||||
return ERROR_INTERNAL_ERROR;
|
||||
|
||||
clipboard->copied = TRUE;
|
||||
|
||||
@@ -2372,19 +2504,58 @@ static UINT wf_cliprdr_server_format_list(CliprdrClientContext *context,
|
||||
{
|
||||
format = &(formatList->formats[i]);
|
||||
mapping = &(clipboard->format_mappings[i]);
|
||||
/* Do not validate the peer-provided formatId as a Windows registered format.
|
||||
* It is only a remote protocol ID used when requesting data from the peer.
|
||||
* For named formats, RegisterClipboardFormatW creates the local Windows
|
||||
* clipboard ID below, and that local ID is checked before publishing. */
|
||||
mapping->remote_format_id = format->formatId;
|
||||
|
||||
if (format->formatName)
|
||||
{
|
||||
int size = MultiByteToWideChar(CP_UTF8, 0, format->formatName,
|
||||
strlen(format->formatName), NULL, 0);
|
||||
mapping->name = calloc(size + 1, sizeof(WCHAR));
|
||||
size_t name_len;
|
||||
int size;
|
||||
|
||||
if (mapping->name)
|
||||
if (!wf_cliprdr_bounded_strlen(format->formatName,
|
||||
WF_CLIPRDR_MAX_FORMAT_NAME_UTF8_BYTES, &name_len))
|
||||
{
|
||||
MultiByteToWideChar(CP_UTF8, 0, format->formatName, strlen(format->formatName),
|
||||
mapping->name, size);
|
||||
mapping->local_format_id = RegisterClipboardFormatW((LPWSTR)mapping->name);
|
||||
return wf_cliprdr_server_format_list_fail(clipboard);
|
||||
}
|
||||
|
||||
if (name_len == 0)
|
||||
{
|
||||
return wf_cliprdr_server_format_list_fail(clipboard);
|
||||
}
|
||||
|
||||
size = MultiByteToWideChar(CP_UTF8, 0, format->formatName, (int)name_len,
|
||||
NULL, 0);
|
||||
if (size <= 0)
|
||||
{
|
||||
return wf_cliprdr_server_format_list_fail(clipboard);
|
||||
}
|
||||
|
||||
if ((UINT)size > WF_CLIPRDR_MAX_FORMAT_NAME_WCHARS)
|
||||
{
|
||||
return wf_cliprdr_server_format_list_fail(clipboard);
|
||||
}
|
||||
|
||||
mapping->name = calloc((size_t)size + 1, sizeof(WCHAR));
|
||||
if (!mapping->name)
|
||||
{
|
||||
return wf_cliprdr_server_format_list_fail(clipboard);
|
||||
}
|
||||
|
||||
if (MultiByteToWideChar(CP_UTF8, 0, format->formatName, (int)name_len,
|
||||
mapping->name, size) != size)
|
||||
{
|
||||
free(mapping->name);
|
||||
mapping->name = NULL;
|
||||
return wf_cliprdr_server_format_list_fail(clipboard);
|
||||
}
|
||||
|
||||
mapping->local_format_id = RegisterClipboardFormatW((LPWSTR)mapping->name);
|
||||
if (mapping->local_format_id == 0)
|
||||
{
|
||||
return wf_cliprdr_server_format_list_fail(clipboard);
|
||||
}
|
||||
}
|
||||
else
|
||||
@@ -2394,7 +2565,6 @@ static UINT wf_cliprdr_server_format_list(CliprdrClientContext *context,
|
||||
}
|
||||
|
||||
clipboard->map_size++;
|
||||
map_ensure_capacity(clipboard);
|
||||
}
|
||||
|
||||
if (file_transferring(clipboard))
|
||||
@@ -2778,6 +2948,7 @@ wf_cliprdr_server_format_data_response(CliprdrClientContext *context,
|
||||
break;
|
||||
}
|
||||
clipboard->hmem = NULL;
|
||||
clipboard->hmem_data_len = 0;
|
||||
|
||||
if (formatDataResponse->msgFlags != CB_RESPONSE_OK)
|
||||
{
|
||||
@@ -2811,6 +2982,7 @@ wf_cliprdr_server_format_data_response(CliprdrClientContext *context,
|
||||
break;
|
||||
}
|
||||
|
||||
clipboard->hmem_data_len = formatDataResponse->dataLen;
|
||||
clipboard->hmem = hMem;
|
||||
rc = CHANNEL_RC_OK;
|
||||
} while (0);
|
||||
|
||||
@@ -113,11 +113,11 @@ pub enum MouseButton {
|
||||
|
||||
/// Scroll up button
|
||||
ScrollUp,
|
||||
/// Left right button
|
||||
/// Scroll down button
|
||||
ScrollDown,
|
||||
/// Left right button
|
||||
/// Scroll left button
|
||||
ScrollLeft,
|
||||
/// Left right button
|
||||
/// Scroll right button
|
||||
ScrollRight,
|
||||
}
|
||||
|
||||
|
||||
@@ -223,7 +223,7 @@ impl KeyboardControllable for Enigo {
|
||||
// Windows uses uft-16 encoding. We need to check
|
||||
// for variable length characters. As such some
|
||||
// characters can be 32 bit long and those are
|
||||
// encoded in such called hight and low surrogates
|
||||
// encoded in so-called high and low surrogates
|
||||
// each 16 bit wide that needs to be send after
|
||||
// another to the SendInput function without
|
||||
// being interrupted by "keyup"
|
||||
|
||||
Submodule libs/hbb_common updated: 3e31a94939...7e1c392c62
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rustdesk-portable-packer"
|
||||
version = "1.4.6"
|
||||
version = "1.4.8"
|
||||
edition = "2021"
|
||||
description = "RustDesk Remote Desktop"
|
||||
|
||||
@@ -26,7 +26,7 @@ windows = { version = "0.61", features = [
|
||||
native-windows-gui = {version = "1.0", default-features = false, features = ["animation-timer", "image-decoder"]}
|
||||
|
||||
[package.metadata.winres]
|
||||
LegalCopyright = "Copyright © 2025 Purslane Ltd. All rights reserved."
|
||||
LegalCopyright = "Copyright © 2026 Purslane Tech Pte. Ltd. All rights reserved."
|
||||
ProductName = "RustDesk"
|
||||
OriginalFilename = "rustdesk.exe"
|
||||
FileDescription = "RustDesk Remote Desktop"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import os
|
||||
import optparse
|
||||
import subprocess
|
||||
from hashlib import md5
|
||||
import brotli
|
||||
import datetime
|
||||
@@ -65,11 +66,15 @@ 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):
|
||||
os.chdir(output_folder)
|
||||
if target:
|
||||
os.system("cargo build --release --target " + target)
|
||||
else:
|
||||
os.system("cargo build --release")
|
||||
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)
|
||||
|
||||
# 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
|
||||
|
||||
@@ -47,7 +47,7 @@ fn link_vcpkg(mut path: PathBuf, name: &str) -> PathBuf {
|
||||
format!("{}-{}", target_arch, target_os)
|
||||
}
|
||||
} else if target_os == "windows" {
|
||||
"x64-windows-static".to_owned()
|
||||
format!("{}-windows-static", target_arch)
|
||||
} else {
|
||||
format!("{}-{}", target_arch, target_os)
|
||||
};
|
||||
|
||||
@@ -79,6 +79,10 @@ mod webrtc {
|
||||
}
|
||||
}
|
||||
|
||||
fn tile_log2(threads: u32) -> std::os::raw::c_uint {
|
||||
(threads as f64).log2().ceil() as _
|
||||
}
|
||||
|
||||
fn get_super_block_size(width: u32, height: u32, threads: u32) -> aom_superblock_size_t {
|
||||
use aom_superblock_size::*;
|
||||
let resolution = width * height;
|
||||
@@ -160,8 +164,7 @@ mod webrtc {
|
||||
} else {
|
||||
AV1E_SET_TILE_COLUMNS
|
||||
};
|
||||
// Failed on android
|
||||
call_ctl!(ctx, tile_set, (cfg.g_threads as f64 * 1.0f64).log2().ceil());
|
||||
call_ctl!(ctx, tile_set, tile_log2(cfg.g_threads));
|
||||
call_ctl!(ctx, AV1E_SET_ROW_MT, 1);
|
||||
call_ctl!(ctx, AV1E_SET_ENABLE_OBMC, 0);
|
||||
call_ctl!(ctx, AV1E_SET_NOISE_SENSITIVITY, 0);
|
||||
@@ -197,6 +200,23 @@ mod webrtc {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::os::raw::c_uint;
|
||||
|
||||
#[test]
|
||||
fn tile_log2_uses_c_uint_and_rounds_up() {
|
||||
let one_thread: c_uint = tile_log2(1);
|
||||
let three_threads: c_uint = tile_log2(3);
|
||||
let max_threads: c_uint = tile_log2(64);
|
||||
|
||||
assert_eq!(one_thread, 0);
|
||||
assert_eq!(three_threads, 2);
|
||||
assert_eq!(max_threads, 6);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EncoderApi for AomEncoder {
|
||||
|
||||
@@ -52,6 +52,33 @@ lazy_static::lazy_static! {
|
||||
static ref MAG_BUFFER: Mutex<(bool, Vec<u8>)> = Default::default();
|
||||
}
|
||||
|
||||
fn find_windows(cls: &str, name: &str) -> Result<Vec<HWND>> {
|
||||
let name_c = CString::new(name)?;
|
||||
let cls_c = if cls.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(CString::new(cls)?)
|
||||
};
|
||||
let mut hwnds = Vec::new();
|
||||
unsafe {
|
||||
let mut after = NULL as _;
|
||||
loop {
|
||||
let hwnd = FindWindowExA(
|
||||
NULL as _,
|
||||
after,
|
||||
cls_c.as_ref().map_or(NULL as _, |c| c.as_ptr()),
|
||||
name_c.as_ptr(),
|
||||
);
|
||||
if hwnd.is_null() {
|
||||
break;
|
||||
}
|
||||
hwnds.push(hwnd);
|
||||
after = hwnd;
|
||||
}
|
||||
}
|
||||
Ok(hwnds)
|
||||
}
|
||||
|
||||
pub type REFWICPixelFormatGUID = *const GUID;
|
||||
pub type WICPixelFormatGUID = GUID;
|
||||
|
||||
@@ -247,6 +274,8 @@ pub struct CapturerMag {
|
||||
rect: RECT,
|
||||
width: usize,
|
||||
height: usize,
|
||||
excluded_window_target: Option<(String, String)>,
|
||||
excluded_windows: Vec<HWND>,
|
||||
}
|
||||
|
||||
impl Drop for CapturerMag {
|
||||
@@ -261,6 +290,10 @@ impl CapturerMag {
|
||||
MagInterface::new().is_ok()
|
||||
}
|
||||
|
||||
// This captures through the legacy Windows Magnification API. Do not infer
|
||||
// multi-monitor capture support from privacy overlay coverage: WebRTC also
|
||||
// disables its magnifier capturer when SM_CMONITORS != 1.
|
||||
// https://webrtc.googlesource.com/src/+/1845922d5a1bf9c27deeffb4a8c8daea124434c1/modules/desktop_capture/win/screen_capturer_win_magnifier.cc
|
||||
pub(crate) fn new(origin: (i32, i32), width: usize, height: usize) -> Result<Self> {
|
||||
unsafe {
|
||||
let x = GetSystemMetrics(SM_XVIRTUALSCREEN);
|
||||
@@ -305,6 +338,8 @@ impl CapturerMag {
|
||||
},
|
||||
width,
|
||||
height,
|
||||
excluded_window_target: None,
|
||||
excluded_windows: Vec::new(),
|
||||
};
|
||||
|
||||
unsafe {
|
||||
@@ -436,19 +471,41 @@ impl CapturerMag {
|
||||
}
|
||||
|
||||
pub(crate) fn exclude(&mut self, cls: &str, name: &str) -> Result<bool> {
|
||||
let name_c = CString::new(name)?;
|
||||
let mut hwnds = find_windows(cls, name)?;
|
||||
hwnds.sort_unstable_by_key(|hwnd| *hwnd as usize);
|
||||
self.excluded_window_target = Some((cls.to_owned(), name.to_owned()));
|
||||
if hwnds.is_empty() {
|
||||
self.excluded_windows.clear();
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
self.exclude_windows(&mut hwnds)?;
|
||||
self.excluded_windows = hwnds;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn refresh_excluded_windows(&mut self) -> Result<()> {
|
||||
let Some((cls, name)) = self.excluded_window_target.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
let mut hwnds = find_windows(cls, name)?;
|
||||
hwnds.sort_unstable_by_key(|hwnd| *hwnd as usize);
|
||||
// This runs from frame() because refreshed privacy overlays get new
|
||||
// HWNDs. It is only used on the legacy magnifier backend while privacy
|
||||
// mode is active; if it shows up as hot-path cost, throttle this check.
|
||||
// Keep the previous filter list while privacy windows are being recreated.
|
||||
if hwnds.is_empty() || hwnds == self.excluded_windows {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.exclude_windows(&mut hwnds)?;
|
||||
self.excluded_windows = hwnds;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn exclude_windows(&mut self, hwnds: &mut [HWND]) -> Result<bool> {
|
||||
let count = hwnds.len() as _;
|
||||
unsafe {
|
||||
let mut hwnd = if cls.len() == 0 {
|
||||
FindWindowExA(NULL as _, NULL as _, NULL as _, name_c.as_ptr())
|
||||
} else {
|
||||
let cls_c = CString::new(cls).unwrap();
|
||||
FindWindowExA(NULL as _, NULL as _, cls_c.as_ptr(), name_c.as_ptr())
|
||||
};
|
||||
|
||||
if hwnd.is_null() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
if let Some(set_window_filter_list_func) =
|
||||
self.mag_interface.set_window_filter_list_func
|
||||
{
|
||||
@@ -456,16 +513,15 @@ impl CapturerMag {
|
||||
== set_window_filter_list_func(
|
||||
self.magnifier_window,
|
||||
MW_FILTERMODE_EXCLUDE,
|
||||
1,
|
||||
&mut hwnd,
|
||||
count,
|
||||
hwnds.as_mut_ptr(),
|
||||
)
|
||||
{
|
||||
return Err(Error::new(
|
||||
ErrorKind::Other,
|
||||
format!(
|
||||
"Failed MagSetWindowFilterList for cls {} name {}, error {}",
|
||||
cls,
|
||||
name,
|
||||
"Failed MagSetWindowFilterList for {} windows, error {}",
|
||||
count,
|
||||
Error::last_os_error()
|
||||
),
|
||||
));
|
||||
@@ -496,6 +552,7 @@ impl CapturerMag {
|
||||
}
|
||||
|
||||
pub(crate) fn frame(&mut self, data: &mut Vec<u8>) -> Result<()> {
|
||||
self.refresh_excluded_windows()?;
|
||||
Self::clear_data();
|
||||
|
||||
unsafe {
|
||||
|
||||
@@ -276,12 +276,21 @@ impl PipeWireRecorder {
|
||||
// see: https://gitlab.freedesktop.org/pipewire/pipewire/-/issues/982
|
||||
src.set_property("always-copy", &true)?;
|
||||
|
||||
// COSMIC/Wayland fix: insert videoconvert between pipewiresrc and appsink.
|
||||
// xdg-desktop-portal-cosmic's modifier negotiation fails when the downstream
|
||||
// format set is too narrow (appsink only accepts BGRx/RGBx), producing
|
||||
// "no more output formats" / not-negotiated (-4). videoconvert accepts any
|
||||
// system-memory video/x-raw format, widening negotiation so the portal can
|
||||
// settle on a format it can deliver via its SHM path.
|
||||
let convert = gst::ElementFactory::make("videoconvert", None)?;
|
||||
|
||||
let sink = gst::ElementFactory::make("appsink", None)?;
|
||||
sink.set_property("drop", &true)?;
|
||||
sink.set_property("max-buffers", &1u32)?;
|
||||
|
||||
pipeline.add_many(&[&src, &sink])?;
|
||||
src.link(&sink)?;
|
||||
pipeline.add_many(&[&src, &convert, &sink])?;
|
||||
src.link(&convert)?;
|
||||
convert.link(&sink)?;
|
||||
|
||||
let appsink = sink
|
||||
.dynamic_cast::<AppSink>()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
pkgname=rustdesk
|
||||
pkgver=1.4.6
|
||||
pkgver=1.4.8
|
||||
pkgrel=0
|
||||
epoch=
|
||||
pkgdesc=""
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
#! /usr/bin/env bash
|
||||
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
|
||||
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
|
||||
cargo run # to bump version in cargo lock
|
||||
|
||||
@@ -31,17 +31,17 @@ LExit:
|
||||
return WcaFinalize(er);
|
||||
}
|
||||
|
||||
// Helper function to safely delete a file or directory using handle-based deletion.
|
||||
// This avoids TOCTOU (Time-Of-Check-Time-Of-Use) race conditions.
|
||||
// Helper function to safely delete a file using handle-based deletion.
|
||||
// Directories are refused after opening the handle.
|
||||
BOOL SafeDeleteItem(LPCWSTR fullPath)
|
||||
{
|
||||
// Open the file/directory with DELETE access and FILE_FLAG_OPEN_REPARSE_POINT
|
||||
// Open the file/directory with delete and attribute-read access plus FILE_FLAG_OPEN_REPARSE_POINT
|
||||
// to prevent following symlinks.
|
||||
// Use shared access to allow deletion even when other processes have the file open.
|
||||
DWORD flags = FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT;
|
||||
HANDLE hFile = CreateFileW(
|
||||
fullPath,
|
||||
DELETE,
|
||||
DELETE | FILE_READ_ATTRIBUTES,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, // Allow shared access
|
||||
NULL,
|
||||
OPEN_EXISTING,
|
||||
@@ -55,6 +55,21 @@ BOOL SafeDeleteItem(LPCWSTR fullPath)
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
BY_HANDLE_FILE_INFORMATION fileInfo;
|
||||
if (FALSE == GetFileInformationByHandle(hFile, &fileInfo))
|
||||
{
|
||||
WcaLog(LOGMSG_STANDARD, "SafeDeleteItem: Failed to inspect '%ls'. Error: %lu", fullPath, GetLastError());
|
||||
CloseHandle(hFile);
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
if (fileInfo.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY)
|
||||
{
|
||||
WcaLog(LOGMSG_STANDARD, "SafeDeleteItem: Refusing to delete directory '%ls'.", fullPath);
|
||||
CloseHandle(hFile);
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
// Use SetFileInformationByHandle to mark for deletion.
|
||||
// The file will be deleted when the handle is closed.
|
||||
FILE_DISPOSITION_INFO dispInfo;
|
||||
@@ -77,98 +92,74 @@ BOOL SafeDeleteItem(LPCWSTR fullPath)
|
||||
return result;
|
||||
}
|
||||
|
||||
// Helper function to recursively delete a directory's contents with detailed logging.
|
||||
void RecursiveDelete(LPCWSTR path)
|
||||
BOOL PathEndsWithSlash(LPCWSTR path)
|
||||
{
|
||||
// Ensure the path is not empty or null.
|
||||
if (path == NULL || path[0] == L'\0')
|
||||
size_t length = 0;
|
||||
HRESULT hr = StringCchLengthW(path, MAX_PATH, &length);
|
||||
if (FAILED(hr) || length == 0)
|
||||
{
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
WCHAR last = path[length - 1];
|
||||
return last == L'\\' || last == L'/';
|
||||
}
|
||||
|
||||
void ClearReadOnlyAttribute(LPCWSTR fullPath, DWORD attributes)
|
||||
{
|
||||
if (!(attributes & FILE_ATTRIBUTE_READONLY))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Extra safety: never operate directly on a root path.
|
||||
if (PathIsRootW(path))
|
||||
DWORD writableAttributes = attributes & ~FILE_ATTRIBUTE_READONLY;
|
||||
if (writableAttributes == 0)
|
||||
{
|
||||
WcaLog(LOGMSG_STANDARD, "RecursiveDelete: refusing to operate on root path '%ls'.", path);
|
||||
writableAttributes = FILE_ATTRIBUTE_NORMAL;
|
||||
}
|
||||
|
||||
if (SetFileAttributesW(fullPath, writableAttributes))
|
||||
{
|
||||
WcaLog(LOGMSG_STANDARD, "Runtime cleanup cleared read-only attribute for '%ls'.", fullPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// MAX_PATH is enough here since the installer should not be using longer paths.
|
||||
// No need to handle extended-length paths (\\?\) in this context.
|
||||
WCHAR searchPath[MAX_PATH];
|
||||
HRESULT hr = StringCchPrintfW(searchPath, MAX_PATH, L"%s\\*", path);
|
||||
if (FAILED(hr)) {
|
||||
WcaLog(LOGMSG_STANDARD, "RecursiveDelete: Path too long to enumerate: %ls", path);
|
||||
return;
|
||||
WcaLog(LOGMSG_STANDARD, "Runtime cleanup failed to clear read-only attribute for '%ls'. Error: %lu", fullPath, GetLastError());
|
||||
}
|
||||
|
||||
BOOL DeleteRuntimeGeneratedFile(LPCWSTR installFolder, LPCWSTR fileName)
|
||||
{
|
||||
WCHAR fullPath[MAX_PATH];
|
||||
LPCWSTR separator = PathEndsWithSlash(installFolder) ? L"" : L"\\";
|
||||
HRESULT hr = StringCchPrintfW(fullPath, MAX_PATH, L"%s%s%s", installFolder, separator, fileName);
|
||||
if (FAILED(hr))
|
||||
{
|
||||
WcaLog(LOGMSG_STANDARD, "Runtime cleanup path is too long for '%ls'.", fileName);
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
WIN32_FIND_DATAW findData;
|
||||
HANDLE hFind = FindFirstFileW(searchPath, &findData);
|
||||
|
||||
if (hFind == INVALID_HANDLE_VALUE)
|
||||
DWORD attributes = GetFileAttributesW(fullPath);
|
||||
if (attributes == INVALID_FILE_ATTRIBUTES)
|
||||
{
|
||||
// This can happen if the directory is empty or doesn't exist, which is not an error in our case.
|
||||
WcaLog(LOGMSG_STANDARD, "RecursiveDelete: Failed to enumerate directory '%ls'. It may be missing or inaccessible. Error: %lu", path, GetLastError());
|
||||
return;
|
||||
DWORD error = GetLastError();
|
||||
if (error == ERROR_FILE_NOT_FOUND || error == ERROR_PATH_NOT_FOUND)
|
||||
{
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
WcaLog(LOGMSG_STANDARD, "Runtime cleanup cannot stat '%ls'. Error: %lu", fullPath, error);
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
do
|
||||
if (attributes & FILE_ATTRIBUTE_DIRECTORY)
|
||||
{
|
||||
// Skip '.' and '..' directories.
|
||||
if (wcscmp(findData.cFileName, L".") == 0 || wcscmp(findData.cFileName, L"..") == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// MAX_PATH is enough here since the installer should not be using longer paths.
|
||||
// No need to handle extended-length paths (\\?\) in this context.
|
||||
WCHAR fullPath[MAX_PATH];
|
||||
hr = StringCchPrintfW(fullPath, MAX_PATH, L"%s\\%s", path, findData.cFileName);
|
||||
if (FAILED(hr)) {
|
||||
WcaLog(LOGMSG_STANDARD, "RecursiveDelete: Path too long for item '%ls' in '%ls', skipping.", findData.cFileName, path);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Before acting, ensure the read-only attribute is not set.
|
||||
if (findData.dwFileAttributes & FILE_ATTRIBUTE_READONLY)
|
||||
{
|
||||
if (FALSE == SetFileAttributesW(fullPath, findData.dwFileAttributes & ~FILE_ATTRIBUTE_READONLY))
|
||||
{
|
||||
WcaLog(LOGMSG_STANDARD, "RecursiveDelete: Failed to remove read-only attribute. Error: %lu", GetLastError());
|
||||
}
|
||||
}
|
||||
|
||||
if (findData.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY)
|
||||
{
|
||||
// Check for reparse points (symlinks/junctions) to prevent directory traversal attacks.
|
||||
// Do not follow reparse points, only remove the link itself.
|
||||
if (findData.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT)
|
||||
{
|
||||
WcaLog(LOGMSG_STANDARD, "RecursiveDelete: Not recursing into reparse point (symlink/junction), deleting link itself: %ls", fullPath);
|
||||
SafeDeleteItem(fullPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Recursively delete directory contents first
|
||||
RecursiveDelete(fullPath);
|
||||
// Then delete the directory itself
|
||||
SafeDeleteItem(fullPath);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Delete file using safe handle-based deletion
|
||||
SafeDeleteItem(fullPath);
|
||||
}
|
||||
} while (FindNextFileW(hFind, &findData) != 0);
|
||||
|
||||
DWORD lastError = GetLastError();
|
||||
if (lastError != ERROR_NO_MORE_FILES)
|
||||
{
|
||||
WcaLog(LOGMSG_STANDARD, "RecursiveDelete: FindNextFileW failed with error %lu", lastError);
|
||||
WcaLog(LOGMSG_STANDARD, "Runtime cleanup skipped directory '%ls'.", fullPath);
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
FindClose(hFind);
|
||||
ClearReadOnlyAttribute(fullPath, attributes);
|
||||
WcaLog(LOGMSG_STANDARD, "Runtime cleanup deleting '%ls'.", fullPath);
|
||||
return SafeDeleteItem(fullPath);
|
||||
}
|
||||
|
||||
// See `Package.wxs` for the sequence of this custom action.
|
||||
@@ -178,13 +169,13 @@ void RecursiveDelete(LPCWSTR path)
|
||||
// 2. RemoveExistingProducts
|
||||
// ├─ TerminateProcesses
|
||||
// ├─ TryStopDeleteService
|
||||
// ├─ RemoveInstallFolder - <-- Here
|
||||
// ├─ RemoveRuntimeGeneratedFiles - <-- Here
|
||||
// └─ RemoveFiles
|
||||
// 3. InstallValidate
|
||||
// 4. InstallFiles
|
||||
// 5. InstallExecute
|
||||
// 6. InstallFinalize
|
||||
UINT __stdcall RemoveInstallFolder(
|
||||
UINT __stdcall RemoveRuntimeGeneratedFiles(
|
||||
__in MSIHANDLE hInstall)
|
||||
{
|
||||
HRESULT hr = S_OK;
|
||||
@@ -194,7 +185,7 @@ UINT __stdcall RemoveInstallFolder(
|
||||
LPWSTR pwz = NULL;
|
||||
LPWSTR pwzData = NULL;
|
||||
|
||||
hr = WcaInitialize(hInstall, "RemoveInstallFolder");
|
||||
hr = WcaInitialize(hInstall, "RemoveRuntimeGeneratedFiles");
|
||||
ExitOnFailure(hr, "Failed to initialize");
|
||||
|
||||
hr = WcaGetProperty(L"CustomActionData", &pwzData);
|
||||
@@ -202,24 +193,20 @@ UINT __stdcall RemoveInstallFolder(
|
||||
|
||||
pwz = pwzData;
|
||||
hr = WcaReadStringFromCaData(&pwz, &installFolder);
|
||||
ExitOnFailure(hr, "failed to read database key from custom action data: %ls", pwz);
|
||||
ExitOnFailure(hr, "failed to read install folder from custom action data: %ls", pwz);
|
||||
|
||||
if (installFolder == NULL || installFolder[0] == L'\0') {
|
||||
WcaLog(LOGMSG_STANDARD, "Install folder path is empty, skipping recursive delete.");
|
||||
WcaLog(LOGMSG_STANDARD, "Install folder path is empty, skipping runtime cleanup.");
|
||||
goto LExit;
|
||||
}
|
||||
|
||||
if (PathIsRootW(installFolder)) {
|
||||
WcaLog(LOGMSG_STANDARD, "Refusing to recursively delete root folder '%ls'.", installFolder);
|
||||
WcaLog(LOGMSG_STANDARD, "Refusing runtime cleanup in root folder '%ls'.", installFolder);
|
||||
goto LExit;
|
||||
}
|
||||
|
||||
WcaLog(LOGMSG_STANDARD, "Attempting to recursively delete contents of install folder: %ls", installFolder);
|
||||
|
||||
RecursiveDelete(installFolder);
|
||||
|
||||
// The standard MSI 'RemoveFolders' action will take care of removing the (now empty) directories.
|
||||
// We don't need to call RemoveDirectoryW on installFolder itself, as it might still be in use by the installer.
|
||||
WcaLog(LOGMSG_STANDARD, "Removing runtime-generated files from install folder: %ls", installFolder);
|
||||
DeleteRuntimeGeneratedFile(installFolder, L"RuntimeBroker_rustdesk.exe");
|
||||
|
||||
LExit:
|
||||
ReleaseStr(pwzData);
|
||||
|
||||
@@ -2,7 +2,7 @@ LIBRARY "CustomActions"
|
||||
|
||||
EXPORTS
|
||||
CustomActionHello
|
||||
RemoveInstallFolder
|
||||
RemoveRuntimeGeneratedFiles
|
||||
TerminateProcesses
|
||||
AddFirewallRules
|
||||
SetPropertyIsServiceRunning
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
<Configuration>Release</Configuration>
|
||||
<Platform>x64</Platform>
|
||||
</ProjectConfiguration>
|
||||
<ProjectConfiguration Include="Release|ARM64">
|
||||
<Configuration>Release</Configuration>
|
||||
<Platform>ARM64</Platform>
|
||||
</ProjectConfiguration>
|
||||
</ItemGroup>
|
||||
<PropertyGroup Label="Globals">
|
||||
<Keyword>Win32Proj</Keyword>
|
||||
@@ -22,6 +26,12 @@
|
||||
<WholeProgramOptimization>true</WholeProgramOptimization>
|
||||
<CharacterSet>Unicode</CharacterSet>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|ARM64'" Label="Configuration">
|
||||
<ConfigurationType>DynamicLibrary</ConfigurationType>
|
||||
<UseDebugLibraries>false</UseDebugLibraries>
|
||||
<WholeProgramOptimization>true</WholeProgramOptimization>
|
||||
<CharacterSet>Unicode</CharacterSet>
|
||||
</PropertyGroup>
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
|
||||
<ImportGroup Label="ExtensionSettings">
|
||||
</ImportGroup>
|
||||
@@ -30,6 +40,9 @@
|
||||
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
|
||||
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
|
||||
</ImportGroup>
|
||||
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Release|ARM64'">
|
||||
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
|
||||
</ImportGroup>
|
||||
<PropertyGroup Label="UserMacros" />
|
||||
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
|
||||
<ClCompile>
|
||||
@@ -53,6 +66,28 @@
|
||||
<ModuleDefinitionFile>CustomActions.def</ModuleDefinitionFile>
|
||||
</Link>
|
||||
</ItemDefinitionGroup>
|
||||
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|ARM64'">
|
||||
<ClCompile>
|
||||
<WarningLevel>Level3</WarningLevel>
|
||||
<FunctionLevelLinking>true</FunctionLevelLinking>
|
||||
<IntrinsicFunctions>true</IntrinsicFunctions>
|
||||
<SDLCheck>true</SDLCheck>
|
||||
<PreprocessorDefinitions>NDEBUG;EXAMPLECADLL_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions>
|
||||
<ConformanceMode>true</ConformanceMode>
|
||||
<PrecompiledHeader>Use</PrecompiledHeader>
|
||||
<PrecompiledHeaderFile>pch.h</PrecompiledHeaderFile>
|
||||
<RuntimeLibrary>MultiThreaded</RuntimeLibrary>
|
||||
</ClCompile>
|
||||
<Link>
|
||||
<AdditionalDependencies>msi.lib;version.lib;%(AdditionalDependencies)</AdditionalDependencies>
|
||||
<SubSystem>Windows</SubSystem>
|
||||
<EnableCOMDATFolding>true</EnableCOMDATFolding>
|
||||
<OptimizeReferences>true</OptimizeReferences>
|
||||
<GenerateDebugInformation>true</GenerateDebugInformation>
|
||||
<EnableUAC>false</EnableUAC>
|
||||
<ModuleDefinitionFile>CustomActions.def</ModuleDefinitionFile>
|
||||
</Link>
|
||||
</ItemDefinitionGroup>
|
||||
<ItemGroup>
|
||||
<ClInclude Include="Common.h" />
|
||||
<ClInclude Include="framework.h" />
|
||||
@@ -65,6 +100,7 @@
|
||||
<ClCompile Include="FirewallRules.cpp" />
|
||||
<ClCompile Include="pch.cpp">
|
||||
<PrecompiledHeader Condition="'$(Configuration)|$(Platform)'=='Release|x64'">Create</PrecompiledHeader>
|
||||
<PrecompiledHeader Condition="'$(Configuration)|$(Platform)'=='Release|ARM64'">Create</PrecompiledHeader>
|
||||
</ClCompile>
|
||||
<ClCompile Include="ReadConfig.cpp" />
|
||||
<ClCompile Include="RemotePrinter.cpp" />
|
||||
|
||||
@@ -16,8 +16,15 @@
|
||||
<!-- If a command line value was stored, restore it after the registry search has been performed -->
|
||||
<SetProperty Action="RestoreSavedInstallFolderValue" Id="INSTALLFOLDER" Value="[SavedInstallFolderCmdLineValue]" After="AppSearch" Sequence="first" Condition="SavedInstallFolderCmdLineValue" />
|
||||
|
||||
<!-- If a command line value or registry value was set, update the main properties with the value -->
|
||||
<SetProperty Id="INSTALLFOLDER_INNER" Value="[INSTALLFOLDER]" After="RestoreSavedInstallFolderValue" Sequence="first" Condition="INSTALLFOLDER" />
|
||||
<!-- Normalize INSTALLFOLDER from the command line or registry before assigning INSTALLFOLDER_INNER. -->
|
||||
<!-- Case 1: already ends with \$(var.Product)\, keep it unchanged. -->
|
||||
<SetProperty Action="SetInstallFolderInnerFromProductDir" Id="INSTALLFOLDER_INNER" Value="[INSTALLFOLDER]" After="RestoreSavedInstallFolderValue" Sequence="first" Condition="INSTALLFOLDER AND INSTALLFOLDER ~>> "\$(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)")" />
|
||||
|
||||
<!-- 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="RemoveInstallFolder.SetParam" Return="check" Property="RemoveInstallFolder" Value="[INSTALLFOLDER_INNER]" />
|
||||
<CustomAction Id="RemoveRuntimeGeneratedFiles.SetParam" Return="check" Property="RemoveRuntimeGeneratedFiles" Value="[INSTALLFOLDER_INNER]" />
|
||||
<CustomAction Id="AddFirewallRules.SetParam" Return="check" Property="AddFirewallRules" Value="1[INSTALLFOLDER_INNER]$(var.Product).exe" />
|
||||
<CustomAction Id="RemoveFirewallRules.SetParam" Return="check" Property="RemoveFirewallRules" Value="0[INSTALLFOLDER_INNER]$(var.Product).exe" />
|
||||
<CustomAction Id="CreateStartService.SetParam" Return="check" Property="CreateStartService" Value="$(var.Product);"[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="RemoveInstallFolder" Before="RemoveFiles"/>
|
||||
<Custom Action="RemoveInstallFolder.SetParam" Before="RemoveInstallFolder"/>
|
||||
<Custom Action="TryStopDeleteService" Before="RemoveInstallFolder.SetParam" />
|
||||
<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="TryStopDeleteService.SetParam" Before="TryStopDeleteService" />
|
||||
|
||||
<Custom Action="RemoveFirewallRules" Before="RemoveFiles"/>
|
||||
<Custom Action="RemoveFirewallRules.SetParam" Before="RemoveFirewallRules"/>
|
||||
|
||||
<Custom Action="UninstallPrinter" Before="RemoveInstallFolder" Condition="VersionNT >= 603" />
|
||||
<Custom Action="UninstallPrinter" Before="RemoveRuntimeGeneratedFiles" Condition="VersionNT >= 603" />
|
||||
|
||||
<Custom Action="TerminateProcesses" Before="RemoveInstallFolder"/>
|
||||
<Custom Action="TerminateProcesses" Before="RemoveRuntimeGeneratedFiles"/>
|
||||
<Custom Action="TerminateProcesses.SetParam" Before="TerminateProcesses"/>
|
||||
<Custom Action="TerminateBrokers" Before="RemoveInstallFolder"/>
|
||||
<Custom Action="TerminateBrokers" Before="RemoveRuntimeGeneratedFiles"/>
|
||||
<Custom Action="TerminateBrokers.SetParam" Before="TerminateBrokers"/>
|
||||
<Custom Action="RemoveAmyuniIdd" Before="RemoveInstallFolder"/>
|
||||
<Custom Action="RemoveAmyuniIdd" Before="RemoveRuntimeGeneratedFiles"/>
|
||||
<Custom Action="RemoveAmyuniIdd.SetParam" Before="RemoveAmyuniIdd"/>
|
||||
</InstallExecuteSequence>
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<Binary Id="Custom_Actions_Dll" SourceFile="$(var.CustomActions.TargetDir)$(var.CustomActions.TargetName).dll" />
|
||||
|
||||
<CustomAction Id="CustomActionHello" DllEntry="CustomActionHello" Impersonate="yes" Execute="immediate" Return="ignore" BinaryRef="Custom_Actions_Dll"/>
|
||||
<CustomAction Id="RemoveInstallFolder" DllEntry="RemoveInstallFolder" Impersonate="no" Execute="deferred" Return="ignore" BinaryRef="Custom_Actions_Dll"/>
|
||||
<CustomAction Id="RemoveRuntimeGeneratedFiles" DllEntry="RemoveRuntimeGeneratedFiles" Impersonate="no" Execute="deferred" Return="ignore" BinaryRef="Custom_Actions_Dll"/>
|
||||
<CustomAction Id="TerminateProcesses" DllEntry="TerminateProcesses" Impersonate="yes" Execute="immediate" Return="ignore" BinaryRef="Custom_Actions_Dll"/>
|
||||
<CustomAction Id="TerminateBrokers" DllEntry="TerminateProcesses" Impersonate="yes" Execute="immediate" Return="ignore" BinaryRef="Custom_Actions_Dll"/>
|
||||
<CustomAction Id="AddFirewallRules" DllEntry="AddFirewallRules" Impersonate="no" Execute="deferred" Return="ignore" BinaryRef="Custom_Actions_Dll"/>
|
||||
|
||||
@@ -4,17 +4,17 @@
|
||||
<?include ..\Includes.wxi?>
|
||||
|
||||
<!--
|
||||
Properties and related actions for specifying whether to install start menu/desktop shortcuts.
|
||||
Properties and related actions for specifying whether to install shortcuts and the printer.
|
||||
-->
|
||||
|
||||
<!-- These are the actual properties that get used in conditions to determine whether to
|
||||
install start menu shortcuts, they are initialized with a default value to install shortcuts.
|
||||
They should not be set directly from the command line or registry, instead the CREATE* properties
|
||||
below should be set, then they will update these properties with their values only if set. -->
|
||||
install start menu shortcuts or the printer. Shortcut properties default to install;
|
||||
PRINTER defaults to not install. The CREATE* properties below update shortcut
|
||||
properties from command line, bundle, or registry values. -->
|
||||
<Property Id="STARTMENUSHORTCUTS" Value="1" Secure="yes"></Property>
|
||||
<Property Id="DESKTOPSHORTCUTS" Value="1" Secure="yes"></Property>
|
||||
<Property Id="STARTUPSHORTCUTS" Value="1" Secure="yes"></Property>
|
||||
<Property Id="PRINTER" Value="1" Secure="yes"></Property>
|
||||
<Property Id="PRINTER" Secure="yes"></Property>
|
||||
|
||||
<!-- These properties get set from either the command line, bundle or registry value,
|
||||
if set they update the properties above with their value. -->
|
||||
@@ -77,7 +77,11 @@
|
||||
<!-- If a command line value or registry value was set, update the main properties with the value -->
|
||||
<SetProperty Id="STARTMENUSHORTCUTS" Value="" After="RestoreSavedStartMenuShortcutsValue" Sequence="first" Condition="CREATESTARTMENUSHORTCUTS AND NOT (CREATESTARTMENUSHORTCUTS = 1 OR CREATESTARTMENUSHORTCUTS = "Y" OR CREATESTARTMENUSHORTCUTS = "y")" />
|
||||
<SetProperty Id="DESKTOPSHORTCUTS" Value="" After="RestoreSavedDesktopShortcutsValue" Sequence="first" Condition="CREATEDESKTOPSHORTCUTS AND NOT (CREATEDESKTOPSHORTCUTS = 1 OR CREATEDESKTOPSHORTCUTS = "Y" OR CREATEDESKTOPSHORTCUTS = "y")" />
|
||||
<SetProperty Id="PRINTER" Value="" After="RestoreSavedPrinterValue" Sequence="first" Condition="INSTALLPRINTER AND NOT (INSTALLPRINTER = 1 OR INSTALLPRINTER = "Y" OR INSTALLPRINTER = "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")" />
|
||||
|
||||
</Fragment>
|
||||
</Wix>
|
||||
|
||||
@@ -79,7 +79,7 @@ heading 1;}{\s2\ql \li0\ri0\nowidctlpar\wrapdefault\faauto\outlinelevel1\rin0\li
|
||||
\ab\af1 \ltrch\fcs0 \b\ul\cf2\lang1033\langfe2052\langnp1033\insrsid1917520
|
||||
\par }\pard \ltrpar\s2\qj \li0\ri0\sb100\sa100\nowidctlpar\wrapdefault\faauto\outlinelevel1\rin0\lin0\itap0\pararsid8979511 {\rtlch\fcs1 \af1\afs18 \ltrch\fcs0 \fs18\lang1033\langfe2052\langnp1033\insrsid8979511\charrsid8979511 \hich\af1\dbch\af31505\loch\f1
|
||||
\hich\f1 This Privacy Policy (hereinafter the \'93}{\rtlch\fcs1 \ab\af1\afs21 \ltrch\fcs0 \b\fs21\lang1033\langfe2052\langnp1033\insrsid8979511\charrsid1523795 \hich\af1\dbch\af31505\loch\f1 Policy}{\rtlch\fcs1 \af1\afs18 \ltrch\fcs0
|
||||
\fs18\lang1033\langfe2052\langnp1033\insrsid8979511\charrsid8979511 \loch\af1\dbch\af31505\hich\f1 \'94\loch\f1 \hich\f1 ) governs the terms and conditions under which Purslane Ltd. (hereinafter \'93}{\rtlch\fcs1 \ab\af1\afs21 \ltrch\fcs0
|
||||
\fs18\lang1033\langfe2052\langnp1033\insrsid8979511\charrsid8979511 \loch\af1\dbch\af31505\hich\f1 \'94\loch\f1 \hich\f1 ) governs the terms and conditions under which Purslane Tech Pte. Ltd. (hereinafter \'93}{\rtlch\fcs1 \ab\af1\afs21 \ltrch\fcs0
|
||||
\b\fs21\lang1033\langfe2052\langnp1033\insrsid8979511\charrsid1523795 \hich\af1\dbch\af31505\loch\f1 us}{\rtlch\fcs1 \af1\afs18 \ltrch\fcs0 \fs18\lang1033\langfe2052\langnp1033\insrsid8979511\charrsid8979511 \loch\af1\dbch\af31505\hich\f1 \'94\loch\f1
|
||||
\hich\f1 or \'93}{\rtlch\fcs1 \ab\af1\afs21 \ltrch\fcs0 \b\fs21\lang1033\langfe2052\langnp1033\insrsid8979511\charrsid1523795 \hich\af1\dbch\af31505\loch\f1 we}{\rtlch\fcs1 \af1\afs18 \ltrch\fcs0
|
||||
\fs18\lang1033\langfe2052\langnp1033\insrsid8979511\charrsid8979511 \loch\af1\dbch\af31505\hich\f1 \'94\loch\f1 \hich\f1
|
||||
@@ -300,4 +300,4 @@ b060828e6f37ed1567914b284d262452282e3198720e274a939cd08a54f980ae38a38f56e422a3a6
|
||||
\lsdpriority49 \lsdlocked0 List Table 4 Accent 5;\lsdpriority50 \lsdlocked0 List Table 5 Dark Accent 5;\lsdpriority51 \lsdlocked0 List Table 6 Colorful Accent 5;\lsdpriority52 \lsdlocked0 List Table 7 Colorful Accent 5;
|
||||
\lsdpriority46 \lsdlocked0 List Table 1 Light Accent 6;\lsdpriority47 \lsdlocked0 List Table 2 Accent 6;\lsdpriority48 \lsdlocked0 List Table 3 Accent 6;\lsdpriority49 \lsdlocked0 List Table 4 Accent 6;
|
||||
\lsdpriority50 \lsdlocked0 List Table 5 Dark Accent 6;\lsdpriority51 \lsdlocked0 List Table 6 Colorful Accent 6;\lsdpriority52 \lsdlocked0 List Table 7 Colorful Accent 6;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Mention;
|
||||
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Smart Hyperlink;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Hashtag;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Unresolved Mention;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Smart Link;}}}
|
||||
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Smart Hyperlink;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Hashtag;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Unresolved Mention;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Smart Link;}}}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<IncludeSearchPaths>
|
||||
</IncludeSearchPaths>
|
||||
<Configurations>Release</Configurations>
|
||||
<Platforms>x64</Platforms>
|
||||
<Platforms>x64;ARM64</Platforms>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Content Include="Includes.wxi" />
|
||||
|
||||
@@ -23,12 +23,13 @@ Patch dialog sequence:
|
||||
-->
|
||||
|
||||
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs" xmlns:ui="http://wixtoolset.org/schemas/v4/wxs/ui">
|
||||
<?include ../Includes.wxi?>
|
||||
<?foreach WIXUIARCH in X86;X64;A64 ?>
|
||||
<Fragment>
|
||||
<UI Id="UI_MyInstallDialog_$(WIXUIARCH)">
|
||||
<Publish Dialog="LicenseAgreementDlg" Control="Print" Event="DoAction" Value="WixUIPrintEula_$(WIXUIARCH)" />
|
||||
<Publish Dialog="BrowseDlg" Control="OK" Event="DoAction" Value="WixUIValidatePath_$(WIXUIARCH)" Order="3" Condition="NOT WIXUI_DONTVALIDATEPATH" />
|
||||
<Publish Dialog="MyInstallDirDlg" Control="Next" Event="DoAction" Value="WixUIValidatePath_$(WIXUIARCH)" Order="2" Condition="NOT WIXUI_DONTVALIDATEPATH" />
|
||||
<Publish Dialog="MyInstallDirDlg" Control="Next" Event="DoAction" Value="WixUIValidatePath_$(WIXUIARCH)" Order="5" Condition="NOT WIXUI_DONTVALIDATEPATH" />
|
||||
</UI>
|
||||
|
||||
<UIRef Id="UI_MyInstallDialog" />
|
||||
@@ -64,9 +65,16 @@ Patch dialog sequence:
|
||||
<Publish Dialog="LicenseAgreementDlg" Control="Next" Event="NewDialog" Value="MyInstallDirDlg" Condition="LicenseAccepted = "1"" />
|
||||
|
||||
<Publish Dialog="MyInstallDirDlg" Control="Back" Event="NewDialog" Value="LicenseAgreementDlg" />
|
||||
<Publish Dialog="MyInstallDirDlg" Control="Next" Event="SetTargetPath" Value="[WIXUI_INSTALLDIR]" Order="1" />
|
||||
<Publish Dialog="MyInstallDirDlg" Control="Next" Event="SpawnDialog" Value="InvalidDirDlg" Order="3" Condition="NOT WIXUI_DONTVALIDATEPATH AND WIXUI_INSTALLDIR_VALID<>"1"" />
|
||||
<Publish Dialog="MyInstallDirDlg" Control="Next" Event="NewDialog" Value="VerifyReadyDlg" Order="4" Condition="WIXUI_DONTVALIDATEPATH OR WIXUI_INSTALLDIR_VALID="1"" />
|
||||
<!-- 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="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" />
|
||||
|
||||
@@ -10,12 +10,17 @@ EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Release|x64 = Release|x64
|
||||
Release|ARM64 = Release|ARM64
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{F403A403-CEFF-4399-B51C-CC646C8E98CF}.Release|x64.ActiveCfg = Release|x64
|
||||
{F403A403-CEFF-4399-B51C-CC646C8E98CF}.Release|x64.Build.0 = Release|x64
|
||||
{F403A403-CEFF-4399-B51C-CC646C8E98CF}.Release|ARM64.ActiveCfg = Release|ARM64
|
||||
{F403A403-CEFF-4399-B51C-CC646C8E98CF}.Release|ARM64.Build.0 = Release|ARM64
|
||||
{6B3647E0-B4A3-46AE-8757-A22EE51C1DAC}.Release|x64.ActiveCfg = Release|x64
|
||||
{6B3647E0-B4A3-46AE-8757-A22EE51C1DAC}.Release|x64.Build.0 = Release|x64
|
||||
{6B3647E0-B4A3-46AE-8757-A22EE51C1DAC}.Release|ARM64.ActiveCfg = Release|ARM64
|
||||
{6B3647E0-B4A3-46AE-8757-A22EE51C1DAC}.Release|ARM64.Build.0 = Release|ARM64
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
||||
@@ -85,7 +85,7 @@ def make_parser():
|
||||
"-m",
|
||||
"--manufacturer",
|
||||
type=str,
|
||||
default="PURSLANE",
|
||||
default="Purslane Tech Pte. Ltd.",
|
||||
help="The app manufacturer.",
|
||||
)
|
||||
return parser
|
||||
@@ -499,7 +499,7 @@ def update_license_file(app_name):
|
||||
license_content = f.read()
|
||||
license_content = license_content.replace("website rustdesk.com and other ", "")
|
||||
license_content = license_content.replace("RustDesk", app_name)
|
||||
license_content = re.sub("Purslane Ltd", app_name, license_content, flags=re.IGNORECASE)
|
||||
license_content = re.sub(r"Purslane(?: Tech Pte\.)? Ltd", app_name, license_content, flags=re.IGNORECASE)
|
||||
with open(license_file, "w", encoding="utf-8") as f:
|
||||
f.write(license_content)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
echo $MACOS_CODESIGN_IDENTITY
|
||||
cargo install flutter_rust_bridge_codegen --version 1.80.1 --features uuid
|
||||
cargo install flutter_rust_bridge_codegen --version 1.80.1 --features uuid --locked
|
||||
cd flutter; flutter pub get; cd -
|
||||
~/.cargo/bin/flutter_rust_bridge_codegen --rust-input ./src/flutter_ffi.rs --dart-output ./flutter/lib/generated_bridge.dart --c-output ./flutter/macos/Runner/bridge_generated.h
|
||||
./build.py --flutter
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
Name: rustdesk
|
||||
Version: 1.4.6
|
||||
Version: 1.4.8
|
||||
Release: 0
|
||||
Summary: RPM package
|
||||
License: GPL-3.0
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
Name: rustdesk
|
||||
Version: 1.4.6
|
||||
Version: 1.4.8
|
||||
Release: 0
|
||||
Summary: RPM package
|
||||
License: GPL-3.0
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
Name: rustdesk
|
||||
Version: 1.4.6
|
||||
Version: 1.4.8
|
||||
Release: 0
|
||||
Summary: RPM package
|
||||
License: GPL-3.0
|
||||
|
||||
@@ -45,7 +45,7 @@ pre_start()
|
||||
return 0
|
||||
}
|
||||
|
||||
# When loging out from the interactive shell, the execution sequence is:
|
||||
# When logging out from the interactive shell, the execution sequence is:
|
||||
#
|
||||
# IF ~/.bash_logout exists THEN
|
||||
# execute ~/.bash_logout
|
||||
|
||||
@@ -130,14 +130,18 @@ elseif(VCPKG_TARGET_IS_WINDOWS)
|
||||
--cc=cl \
|
||||
--enable-gpl \
|
||||
--enable-d3d11va \
|
||||
--enable-cuda \
|
||||
--enable-ffnvcodec \
|
||||
--enable-hwaccel=h264_nvdec \
|
||||
--enable-hwaccel=hevc_nvdec \
|
||||
--enable-hwaccel=h264_d3d11va \
|
||||
--enable-hwaccel=hevc_d3d11va \
|
||||
--enable-hwaccel=h264_d3d11va2 \
|
||||
--enable-hwaccel=hevc_d3d11va2 \
|
||||
")
|
||||
|
||||
if(VCPKG_TARGET_ARCHITECTURE STREQUAL "x86" OR VCPKG_TARGET_ARCHITECTURE STREQUAL "x64")
|
||||
string(APPEND OPTIONS "\
|
||||
--enable-cuda \
|
||||
--enable-ffnvcodec \
|
||||
--enable-hwaccel=h264_nvdec \
|
||||
--enable-hwaccel=hevc_nvdec \
|
||||
--enable-amf \
|
||||
--enable-encoder=h264_amf \
|
||||
--enable-encoder=hevc_amf \
|
||||
@@ -147,6 +151,7 @@ elseif(VCPKG_TARGET_IS_WINDOWS)
|
||||
--enable-encoder=h264_qsv \
|
||||
--enable-encoder=hevc_qsv \
|
||||
")
|
||||
endif()
|
||||
|
||||
if(VCPKG_TARGET_ARCHITECTURE STREQUAL "x86")
|
||||
set(LIB_MACHINE_ARG /machine:x86)
|
||||
@@ -154,6 +159,9 @@ elseif(VCPKG_TARGET_IS_WINDOWS)
|
||||
elseif(VCPKG_TARGET_ARCHITECTURE STREQUAL "x64")
|
||||
set(LIB_MACHINE_ARG /machine:x64)
|
||||
string(APPEND OPTIONS " --arch=x86_64")
|
||||
elseif(VCPKG_TARGET_ARCHITECTURE STREQUAL "arm64")
|
||||
set(LIB_MACHINE_ARG /machine:arm64)
|
||||
string(APPEND OPTIONS " --arch=aarch64 --enable-cross-compile")
|
||||
else()
|
||||
message(FATAL_ERROR "Unsupported target architecture")
|
||||
endif()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user