mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-09-10 14:31:02 +03:00
Compare commits
84 Commits
nightly
...
fix-textur
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24a16d9a30 | ||
|
|
8fc82d04ac | ||
|
|
c6c001a15e | ||
|
|
7da2bbe6ac | ||
|
|
c5adac828b | ||
|
|
7c23e1f4b9 | ||
|
|
c4fd7d692d | ||
|
|
dfca2c1b8f | ||
|
|
10bcf976f7 | ||
|
|
63822048df | ||
|
|
1d09760ef7 | ||
|
|
23256e6ac1 | ||
|
|
ff07ff7f13 | ||
|
|
947cb3f17b | ||
|
|
d407db9fae | ||
|
|
594e63805c | ||
|
|
7c23fd3073 | ||
|
|
2915076642 | ||
|
|
11190fa54e | ||
|
|
d057fe14b2 | ||
|
|
4234b99029 | ||
|
|
6fd96dda6e | ||
|
|
429c8c6711 | ||
|
|
9a81c8a138 | ||
|
|
ddad47925c | ||
|
|
f5ab01f8bd | ||
|
|
cc85685b96 | ||
|
|
7eb9150116 | ||
|
|
ef3a57580f | ||
|
|
402ed07b0c | ||
|
|
3cf32e7066 | ||
|
|
6f1eb164d6 | ||
|
|
4389687d9d | ||
|
|
a84bad4639 | ||
|
|
e6dd925ab0 | ||
|
|
d752823b8c | ||
|
|
2f8822ec7a | ||
|
|
ffe20bb297 | ||
|
|
a5018a022b | ||
|
|
6c69faaa1c | ||
|
|
b19f1ef76f | ||
|
|
807e05ea9a | ||
|
|
e0254d997e | ||
|
|
006b9737e4 | ||
|
|
5aeb4cf945 | ||
|
|
c6c53f094a | ||
|
|
e63df74715 | ||
|
|
9aeb54cf33 | ||
|
|
3442648afe | ||
|
|
8545b5ed98 | ||
|
|
72c052cb9a | ||
|
|
12f2de5959 | ||
|
|
a6708f40e7 | ||
|
|
85a5fefab8 | ||
|
|
d412d19872 | ||
|
|
4dd8e20392 | ||
|
|
dabdbf73bb | ||
|
|
d6ea170061 | ||
|
|
eefd22b205 | ||
|
|
5882346caa | ||
|
|
b1fad7bbed | ||
|
|
57456f0b52 | ||
|
|
cefff781d4 | ||
|
|
ad9dac1001 | ||
|
|
b4af82157b | ||
|
|
beaa754299 | ||
|
|
929e989f17 | ||
|
|
1c2dd71891 | ||
|
|
5b4d6baf47 | ||
|
|
7696b0ee51 | ||
|
|
20ab5ab0ad | ||
|
|
c01300be20 | ||
|
|
5f015c9da1 | ||
|
|
082a5a2a4e | ||
|
|
61f0944990 | ||
|
|
96e2a330b8 | ||
|
|
5abf4e9724 | ||
|
|
cf2b28faf9 | ||
|
|
bdb38c4730 | ||
|
|
fa418cace6 | ||
|
|
94a2a2bb4a | ||
|
|
865fe71c46 | ||
|
|
137298e05a | ||
|
|
685a89a171 |
115
.github/patches/apply_flutter_3.44_source_patches.sh
vendored
115
.github/patches/apply_flutter_3.44_source_patches.sh
vendored
@@ -17,6 +17,109 @@
|
||||
# therefore CRLF-safe.
|
||||
set -euo pipefail
|
||||
|
||||
readonly NO_MATCHES=0
|
||||
readonly SINGLE_MATCH=1
|
||||
readonly THEME_MATCHES=2
|
||||
|
||||
has_exact_count() {
|
||||
local -r expected_count="$1"
|
||||
local -r pattern="$2"
|
||||
local -r file="$3"
|
||||
local actual_count
|
||||
[[ -r "$file" ]] || return 1
|
||||
actual_count="$(grep -cF "$pattern" "$file" || true)"
|
||||
[[ "$actual_count" -eq "$expected_count" ]]
|
||||
}
|
||||
|
||||
# The target background-color line must directly follow DialogThemeData in the selected range.
|
||||
has_dialog_background_in_theme_range() {
|
||||
local -r start_pattern="$1"
|
||||
local -r end_pattern="$2"
|
||||
local -r target_pattern="$3"
|
||||
local -r file="$4"
|
||||
awk -v start_pattern="$start_pattern" \
|
||||
-v end_pattern="$end_pattern" \
|
||||
-v target_pattern="$target_pattern" '
|
||||
index($0, start_pattern) {
|
||||
in_theme = 1
|
||||
next
|
||||
}
|
||||
in_theme && index($0, end_pattern) {
|
||||
exit
|
||||
}
|
||||
in_theme && index($0, "dialogTheme: DialogThemeData(") {
|
||||
if (getline > 0) {
|
||||
line = $0
|
||||
sub(/\r$/, "", line)
|
||||
sub(/^[[:space:]]+/, "", line)
|
||||
matched = line == target_pattern
|
||||
}
|
||||
exit
|
||||
}
|
||||
END {
|
||||
exit matched ? 0 : 1
|
||||
}
|
||||
' "$file"
|
||||
}
|
||||
|
||||
validate_patch_inputs() {
|
||||
if [[ ! -f flutter/lib/common.dart || ! -r flutter/lib/common.dart ]]; then
|
||||
echo "Flutter 3.44 source patch input is missing or unreadable: flutter/lib/common.dart" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ ! -f flutter/pubspec.yaml || ! -r flutter/pubspec.yaml ]]; then
|
||||
echo "Flutter 3.44 source patch input is missing or unreadable: flutter/pubspec.yaml" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
is_complete_patch_state() {
|
||||
has_exact_count "$THEME_MATCHES" 'dialogTheme: DialogThemeData(' flutter/lib/common.dart &&
|
||||
has_exact_count "$THEME_MATCHES" 'tabBarTheme: const TabBarThemeData(' flutter/lib/common.dart &&
|
||||
has_exact_count "$SINGLE_MATCH" 'backgroundColor: Colors.white,' flutter/lib/common.dart &&
|
||||
has_exact_count "$SINGLE_MATCH" 'backgroundColor: Color(0xFF18191E),' flutter/lib/common.dart &&
|
||||
has_exact_count "$SINGLE_MATCH" 'extended_text: 15.0.2' flutter/pubspec.yaml &&
|
||||
has_exact_count "$SINGLE_MATCH" 'google_fonts: ^8.1.0' flutter/pubspec.yaml &&
|
||||
has_exact_count "$NO_MATCHES" 'dialogTheme: DialogTheme(' flutter/lib/common.dart &&
|
||||
has_exact_count "$NO_MATCHES" 'tabBarTheme: const TabBarTheme(' flutter/lib/common.dart &&
|
||||
has_exact_count "$NO_MATCHES" 'extended_text: 14.0.0' flutter/pubspec.yaml &&
|
||||
has_exact_count "$NO_MATCHES" 'google_fonts: ^6.2.1' flutter/pubspec.yaml &&
|
||||
has_dialog_background_in_theme_range 'static ThemeData lightTheme = ThemeData(' \
|
||||
'static ThemeData darkTheme = ThemeData(' 'backgroundColor: Colors.white,' \
|
||||
flutter/lib/common.dart &&
|
||||
has_dialog_background_in_theme_range 'static ThemeData darkTheme = ThemeData(' \
|
||||
'scrollbarTheme: scrollbarThemeDark,' 'backgroundColor: Color(0xFF18191E),' \
|
||||
flutter/lib/common.dart
|
||||
}
|
||||
|
||||
is_unpatched_state() {
|
||||
has_exact_count "$THEME_MATCHES" 'dialogTheme: DialogTheme(' flutter/lib/common.dart &&
|
||||
has_exact_count "$THEME_MATCHES" 'tabBarTheme: const TabBarTheme(' flutter/lib/common.dart &&
|
||||
has_exact_count "$SINGLE_MATCH" 'extended_text: 14.0.0' flutter/pubspec.yaml &&
|
||||
has_exact_count "$SINGLE_MATCH" 'google_fonts: ^6.2.1' flutter/pubspec.yaml &&
|
||||
has_exact_count "$NO_MATCHES" 'dialogTheme: DialogThemeData(' flutter/lib/common.dart &&
|
||||
has_exact_count "$NO_MATCHES" 'tabBarTheme: const TabBarThemeData(' flutter/lib/common.dart &&
|
||||
has_exact_count "$NO_MATCHES" 'backgroundColor: Colors.white,' flutter/lib/common.dart &&
|
||||
has_exact_count "$NO_MATCHES" 'backgroundColor: Color(0xFF18191E),' flutter/lib/common.dart &&
|
||||
has_exact_count "$NO_MATCHES" 'extended_text: 15.0.2' flutter/pubspec.yaml &&
|
||||
has_exact_count "$NO_MATCHES" 'google_fonts: ^8.1.0' flutter/pubspec.yaml
|
||||
}
|
||||
|
||||
if ! validate_patch_inputs; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if is_complete_patch_state; then
|
||||
echo "Flutter 3.44 source patches already applied."
|
||||
git --no-pager diff -- flutter/lib/common.dart flutter/pubspec.yaml
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! is_unpatched_state; then
|
||||
echo "Flutter 3.44 source patches are partially applied or their anchors have drifted." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ThemeData API renames (Flutter 3.27+):
|
||||
sed -i 's/dialogTheme: DialogTheme(/dialogTheme: DialogThemeData(/g' flutter/lib/common.dart
|
||||
sed -i 's/tabBarTheme: const TabBarTheme(/tabBarTheme: const TabBarThemeData(/g' flutter/lib/common.dart
|
||||
@@ -28,12 +131,10 @@ sed -i '/static ThemeData darkTheme = ThemeData(/,/scrollbarTheme: scrollbarThem
|
||||
sed -i 's/extended_text: 14.0.0/extended_text: 15.0.2/' flutter/pubspec.yaml
|
||||
sed -i 's/google_fonts: \^6.2.1/google_fonts: ^8.1.0/' flutter/pubspec.yaml
|
||||
|
||||
# Fail loudly if any expected string drifted, so we never silently build unpatched:
|
||||
grep -qF 'dialogTheme: DialogThemeData(' flutter/lib/common.dart
|
||||
grep -qF 'tabBarTheme: const TabBarThemeData(' flutter/lib/common.dart
|
||||
grep -qF 'backgroundColor: Colors.white,' flutter/lib/common.dart
|
||||
grep -qF 'backgroundColor: Color(0xFF18191E),' flutter/lib/common.dart
|
||||
grep -qF 'extended_text: 15.0.2' flutter/pubspec.yaml
|
||||
grep -qF 'google_fonts: ^8.1.0' flutter/pubspec.yaml
|
||||
# Fail loudly if any expected substitution did not produce the complete state.
|
||||
if ! is_complete_patch_state; then
|
||||
echo "Flutter 3.44 source patches did not produce the expected state." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git --no-pager diff -- flutter/lib/common.dart flutter/pubspec.yaml
|
||||
|
||||
51
.github/patches/apply_flutter_3.44_web_patches.sh
vendored
Executable file
51
.github/patches/apply_flutter_3.44_web_patches.sh
vendored
Executable file
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
# Prepares a web build on Flutter 3.44.x. Companion to
|
||||
# apply_flutter_3.44_source_patches.sh (which it runs first): the web target
|
||||
# additionally needs qr_code_scanner's web implementation patched for the
|
||||
# dart:ui platformViewRegistry removal, and flutter/web/fonts refreshed with
|
||||
# the font paths the 3.44 engine requests for offline/air-gapped support
|
||||
# (rustdesk-server-pro#996; see flutter/web/fonts/sync_fonts.py).
|
||||
#
|
||||
# Run from the repository root with Flutter 3.44.x on PATH, then build:
|
||||
# bash .github/patches/apply_flutter_3.44_web_patches.sh
|
||||
# (cd flutter && flutter build web --release) # or ./web/js/flutter_build.py
|
||||
#
|
||||
# Idempotent. To undo the source changes locally:
|
||||
# git checkout -- flutter/lib/common.dart flutter/pubspec.yaml flutter/pubspec.lock
|
||||
set -euo pipefail
|
||||
|
||||
flutter --version | grep -q "Flutter 3\.44\." || {
|
||||
echo "Flutter 3.44.x must be on PATH; found:" >&2
|
||||
flutter --version | grep "^Flutter" >&2 || true
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Shared 3.44 source/pubspec patches own their complete-state validation.
|
||||
bash .github/patches/apply_flutter_3.44_source_patches.sh
|
||||
|
||||
# Populate the pub cache with the 3.44 dependency resolution.
|
||||
(cd flutter && flutter pub get)
|
||||
|
||||
# qr_code_scanner 1.0.1 (unmaintained) reads platformViewRegistry from
|
||||
# dart:ui, which Flutter 3.44 removed; point it at dart:ui_web instead. The
|
||||
# patched file also compiles on Flutter 3.24 (dart:ui_web exists there), so
|
||||
# mutating the shared pub cache is safe for other local builds.
|
||||
QR_WEB="${PUB_CACHE:-$HOME/.pub-cache}/hosted/pub.dev/qr_code_scanner-1.0.1/lib/src/web/flutter_qr_web.dart"
|
||||
if ! grep -qF "dart:ui_web" "$QR_WEB"; then
|
||||
sed -i.bak "s|import 'dart:ui' as ui;|import 'dart:ui' as ui; import 'dart:ui_web' as ui_web;|" "$QR_WEB"
|
||||
rm -f "$QR_WEB.bak"
|
||||
fi
|
||||
if grep -qF "ui.platformViewRegistry" "$QR_WEB"; then
|
||||
sed -i.bak "s|ui\.platformViewRegistry|ui_web.platformViewRegistry|g" "$QR_WEB"
|
||||
rm -f "$QR_WEB.bak"
|
||||
fi
|
||||
|
||||
# Mirror the fonts this engine version requests into flutter/web/fonts.
|
||||
python3 flutter/web/fonts/sync_fonts.py
|
||||
|
||||
# Fail loudly if any expected state is missing:
|
||||
grep -qF "import 'dart:ui' as ui; import 'dart:ui_web' as ui_web;" "$QR_WEB"
|
||||
grep -qF "ui_web.platformViewRegistry" "$QR_WEB"
|
||||
grep -qF 'google_fonts: ^8.1.0' flutter/pubspec.yaml
|
||||
|
||||
echo "Flutter 3.44 web patches applied."
|
||||
2
.github/workflows/bridge.yml
vendored
2
.github/workflows/bridge.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
target: x86_64-unknown-linux-gnu,
|
||||
os: ubuntu-22.04,
|
||||
extra-build-args: "",
|
||||
flutter-version: "3.44.0",
|
||||
flutter-version: "3.44.8",
|
||||
artifact-name: "bridge-artifact-flutter-3.44",
|
||||
}
|
||||
steps:
|
||||
|
||||
333
.github/workflows/flutter-build.yml
vendored
333
.github/workflows/flutter-build.yml
vendored
@@ -31,7 +31,7 @@ env:
|
||||
# engine is 3.44. Every other platform stays on FLUTTER_VERSION (3.24.5) until Windows 7
|
||||
# support is restored after the upstream-wide Flutter bump. The arm64 job patches the few
|
||||
# 3.44-only source/pubspec changes on the fly (see "Patch RustDesk sources for Flutter 3.44").
|
||||
FLUTTER_WINDOWS_ARM_VERSION: "3.44.0"
|
||||
FLUTTER_WINDOWS_ARM_VERSION: "3.44.8"
|
||||
# for arm64 linux because official Dart SDK does not work
|
||||
FLUTTER_ELINUX_VERSION: "3.16.9"
|
||||
TAG_NAME: "${{ inputs.upload-tag }}"
|
||||
@@ -53,6 +53,34 @@ env:
|
||||
SIGN_BASE_URL: "${{ secrets.SIGN_BASE_URL }}-2"
|
||||
|
||||
jobs:
|
||||
generate-sbom:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Install Syft
|
||||
uses: anchore/sbom-action/download-syft@v0
|
||||
|
||||
- name: Generate SBOM
|
||||
run: |
|
||||
syft dir:. \
|
||||
-o cyclonedx-json=rustdesk.sbom.json
|
||||
|
||||
- name: Publish Release
|
||||
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
if: env.UPLOAD_ARTIFACT == 'true'
|
||||
with:
|
||||
prerelease: true
|
||||
tag_name: ${{ env.TAG_NAME }}
|
||||
files: |
|
||||
rustdesk.sbom.json
|
||||
|
||||
generate-bridge:
|
||||
uses: ./.github/workflows/bridge.yml
|
||||
|
||||
@@ -196,7 +224,9 @@ jobs:
|
||||
run: |
|
||||
cp .github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff $(dirname $(dirname $(which flutter)))
|
||||
cd $(dirname $(dirname $(which flutter)))
|
||||
[[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]] && git apply flutter_3.24.4_dropdown_menu_enableFilter.diff
|
||||
if [[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]]; then
|
||||
git apply flutter_3.24.4_dropdown_menu_enableFilter.diff
|
||||
fi
|
||||
|
||||
- name: Patch RustDesk sources for Flutter 3.44 (arm64)
|
||||
# arm64 is the only target on Flutter 3.44; apply its source/pubspec deltas on the fly
|
||||
@@ -567,7 +597,9 @@ jobs:
|
||||
- name: Patch flutter
|
||||
run: |
|
||||
cd $(dirname $(dirname $(which flutter)))
|
||||
[[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]] && git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff
|
||||
if [[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]]; then
|
||||
git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff
|
||||
fi
|
||||
|
||||
- name: Setup vcpkg with Github Actions binary cache
|
||||
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
|
||||
@@ -746,7 +778,9 @@ jobs:
|
||||
- name: Patch flutter
|
||||
run: |
|
||||
cd $(dirname $(dirname $(which flutter)))
|
||||
[[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]] && git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff
|
||||
if [[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]]; then
|
||||
git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff
|
||||
fi
|
||||
|
||||
- name: Workaround for flutter issue
|
||||
shell: bash
|
||||
@@ -1005,7 +1039,9 @@ jobs:
|
||||
- name: Patch flutter
|
||||
run: |
|
||||
cd $(dirname $(dirname $(which flutter)))
|
||||
[[ "3.24.5" == ${{env.ANDROID_FLUTTER_VERSION}} ]] && git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff
|
||||
if [[ "3.24.5" == ${{env.ANDROID_FLUTTER_VERSION}} ]]; then
|
||||
git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff
|
||||
fi
|
||||
|
||||
- uses: nttld/setup-ndk@ed92fe6cadad69be94a966a7ee3271275e62f779 # v1
|
||||
id: setup-ndk
|
||||
@@ -1277,7 +1313,9 @@ jobs:
|
||||
- name: Patch flutter
|
||||
run: |
|
||||
cd $(dirname $(dirname $(which flutter)))
|
||||
[[ "3.24.5" == ${{env.ANDROID_FLUTTER_VERSION}} ]] && git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff
|
||||
if [[ "3.24.5" == ${{env.ANDROID_FLUTTER_VERSION}} ]]; then
|
||||
git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff
|
||||
fi
|
||||
|
||||
- name: Restore bridge files
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
@@ -1721,6 +1759,276 @@ jobs:
|
||||
files: |
|
||||
res/rustdesk-${{ env.VERSION }}*.zst
|
||||
|
||||
# Same build as build-rustdesk-linux x86_64 -- same vcpkg/ffmpeg, same ubuntu18.04 container, same
|
||||
# rust and flutter -- only with the drm feature on, so it ships as the separate
|
||||
# rustdesk-unattended-wayland deb. libdrmtap is built on the runner because bionic's meson is too
|
||||
# old for it. A separate job rather than a matrix entry of build-rustdesk-linux: appimage and
|
||||
# flatpak need that job, and a failure here must not skip them.
|
||||
build-rustdesk-linux-drm:
|
||||
needs: [generate-bridge]
|
||||
name: build rustdesk linux drm x86_64
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Export GitHub Actions cache environment variables
|
||||
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
|
||||
with:
|
||||
script: |
|
||||
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
|
||||
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
|
||||
|
||||
- name: Maximize build space
|
||||
run: |
|
||||
sudo rm -rf /opt/ghc
|
||||
sudo rm -rf /usr/local/lib/android
|
||||
sudo rm -rf /usr/share/dotnet
|
||||
sudo apt-get update -y
|
||||
sudo apt-get install -y nasm
|
||||
sudo apt-get install -y qemu-user-static
|
||||
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Set Swap Space
|
||||
uses: pierotofy/set-swap-space@49819abfb41bd9b44fb781159c033dba90353a7c # v1.0
|
||||
with:
|
||||
swap-size-gb: 12
|
||||
|
||||
- name: Free Space
|
||||
run: |
|
||||
df -h
|
||||
free -m
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
|
||||
with:
|
||||
toolchain: ${{ env.RUST_VERSION }}
|
||||
targets: x86_64-unknown-linux-gnu
|
||||
components: "rustfmt"
|
||||
|
||||
- name: Save Rust toolchain version
|
||||
run: |
|
||||
RUST_TOOLCHAIN_VERSION=$(cargo --version | awk '{print $2}')
|
||||
echo "RUST_TOOLCHAIN_VERSION=$RUST_TOOLCHAIN_VERSION" >> $GITHUB_ENV
|
||||
|
||||
- name: Disable rust bridge build
|
||||
run: |
|
||||
# only build cdylib
|
||||
sed -i "s/\[\"cdylib\", \"staticlib\", \"rlib\"\]/\[\"cdylib\"\]/g" Cargo.toml
|
||||
|
||||
- name: Restore bridge files
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: bridge-artifact
|
||||
path: ./
|
||||
|
||||
- name: Setup vcpkg with Github Actions binary cache
|
||||
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
|
||||
with:
|
||||
vcpkgDirectory: /opt/artifacts/vcpkg
|
||||
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
|
||||
doNotCache: false
|
||||
|
||||
- name: Install vcpkg dependencies
|
||||
run: |
|
||||
sudo apt install -y libva-dev && apt show libva-dev
|
||||
if ! $VCPKG_ROOT/vcpkg \
|
||||
install \
|
||||
--triplet x64-linux \
|
||||
--x-install-root="$VCPKG_ROOT/installed"; then
|
||||
find "${VCPKG_ROOT}/" -name "*.log" | while read -r _1; do
|
||||
echo "$_1:"
|
||||
echo "======"
|
||||
cat "$_1"
|
||||
echo "======"
|
||||
echo ""
|
||||
done
|
||||
exit 1
|
||||
fi
|
||||
head -n 100 "${VCPKG_ROOT}/buildtrees/ffmpeg/build-x64-linux-rel-out.log" || true
|
||||
shell: bash
|
||||
|
||||
# The container's meson is too old to build libdrmtap, so build it here from the pin in
|
||||
# build.py and hand the .so to the container below via DRMTAP_PREBUILT_DIR.
|
||||
- name: Build libdrmtap
|
||||
run: |
|
||||
sudo apt-get install -y meson ninja-build pkg-config \
|
||||
libdrm-dev libegl1-mesa-dev libgles2-mesa-dev
|
||||
python3 - <<'PY'
|
||||
import importlib.util, sys
|
||||
spec = importlib.util.spec_from_file_location("b", "build.py")
|
||||
b = importlib.util.module_from_spec(spec)
|
||||
sys.argv = ["build.py"]
|
||||
spec.loader.exec_module(b)
|
||||
print(f"::notice::built {b.build_libdrmtap_so()}")
|
||||
PY
|
||||
shell: bash
|
||||
|
||||
- uses: rustdesk-org/run-on-arch-action@d3fcfbb632b84cf7f6bc772bfaaa2c2f4f8789a8 # no release tag; commit 2026-05-26
|
||||
name: Build rustdesk
|
||||
id: vcpkg
|
||||
with:
|
||||
arch: x86_64
|
||||
distro: ubuntu18.04
|
||||
githubToken: ${{ github.token }}
|
||||
setup: |
|
||||
ls -l "${PWD}"
|
||||
ls -l /opt/artifacts/vcpkg/installed
|
||||
dockerRunArgs: |
|
||||
--volume "${PWD}:/workspace"
|
||||
--volume "/opt/artifacts:/opt/artifacts"
|
||||
shell: /bin/bash
|
||||
install: |
|
||||
apt-get update -y
|
||||
echo -e "installing deps"
|
||||
apt-get install -y \
|
||||
build-essential \
|
||||
clang \
|
||||
cmake \
|
||||
curl \
|
||||
gcc \
|
||||
git \
|
||||
g++ \
|
||||
libayatana-appindicator3-dev \
|
||||
libasound2-dev \
|
||||
libclang-10-dev \
|
||||
libgstreamer1.0-dev \
|
||||
libgstreamer-plugins-base1.0-dev \
|
||||
libgtk-3-dev \
|
||||
libpam0g-dev \
|
||||
libpulse-dev \
|
||||
libva-dev \
|
||||
libxcb-randr0-dev \
|
||||
libxcb-shape0-dev \
|
||||
libxcb-xfixes0-dev \
|
||||
libxdo-dev \
|
||||
libxfixes-dev \
|
||||
llvm-10-dev \
|
||||
nasm \
|
||||
ninja-build \
|
||||
pkg-config \
|
||||
tree \
|
||||
python3 \
|
||||
rpm \
|
||||
unzip \
|
||||
wget \
|
||||
xz-utils \
|
||||
libssl-dev
|
||||
# we have libopus compiled by us.
|
||||
apt-get remove -y libopus-dev || true
|
||||
# output devs
|
||||
ls -l ./
|
||||
tree -L 3 /opt/artifacts/vcpkg/installed
|
||||
run: |
|
||||
# disable git safe.directory
|
||||
git config --global --add safe.directory "*"
|
||||
# rust
|
||||
pushd /opt
|
||||
# do not use rustup, because memory overflow in qemu
|
||||
wget -O rust.tar.gz https://static.rust-lang.org/dist/rust-${{env.RUST_TOOLCHAIN_VERSION}}-x86_64-unknown-linux-gnu.tar.gz
|
||||
tar -zxvf rust.tar.gz > /dev/null && rm rust.tar.gz
|
||||
cd rust-${{env.RUST_TOOLCHAIN_VERSION}}-x86_64-unknown-linux-gnu && ./install.sh
|
||||
rm -rf rust-${{env.RUST_TOOLCHAIN_VERSION}}-x86_64-unknown-linux-gnu
|
||||
# edit config
|
||||
mkdir -p ~/.cargo/
|
||||
echo """
|
||||
[source.crates-io]
|
||||
registry = 'https://github.com/rust-lang/crates.io-index'
|
||||
""" > ~/.cargo/config
|
||||
cat ~/.cargo/config
|
||||
# start build
|
||||
pushd /workspace
|
||||
export VCPKG_ROOT=/opt/artifacts/vcpkg
|
||||
# use the .so built on the runner; build.py checks it is the pinned checkout
|
||||
export DRMTAP_PREBUILT_DIR=/workspace/third_party/libdrmtap/build-pkg
|
||||
# ask build.py for the features so this line and the packaging line cannot drift
|
||||
FEATURES=$(python3 ./build.py --flutter --drm --hwcodec --unix-file-copy-paste --print-features)
|
||||
# an empty or error-shaped value would silently build a stock binary
|
||||
for want in drm drm-wake; do
|
||||
case ",$FEATURES," in
|
||||
*",$want,"*) ;;
|
||||
*) echo "::error::build.py returned no '$want' feature: $FEATURES"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
cargo build --locked --lib --features "$FEATURES" --release
|
||||
rm -rf target/release/deps target/release/build
|
||||
rm -rf ~/.cargo
|
||||
|
||||
# Setup Flutter
|
||||
# disable git safe.directory
|
||||
git config --global --add safe.directory "*"
|
||||
export PATH=/opt/flutter/bin:$PATH
|
||||
pushd /opt
|
||||
wget https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_${{ env.FLUTTER_VERSION }}-stable.tar.xz
|
||||
tar xf flutter_linux_${{ env.FLUTTER_VERSION }}-stable.tar.xz
|
||||
flutter doctor -v
|
||||
|
||||
if [[ "3.24.5" == ${{ env.FLUTTER_VERSION }} ]]; then
|
||||
pushd /opt/flutter
|
||||
git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff
|
||||
popd
|
||||
fi
|
||||
|
||||
# build flutter
|
||||
pushd /workspace
|
||||
export CARGO_INCREMENTAL=0
|
||||
export DEB_ARCH=amd64
|
||||
python3 ./build.py --flutter --drm --hwcodec --unix-file-copy-paste --skip-cargo
|
||||
for name in rustdesk*??.deb; do
|
||||
mv "$name" "${name%%.deb}-x86_64.deb"
|
||||
done
|
||||
|
||||
# build.py can exit 0 on some inner failures, so check the artifact rather than the status.
|
||||
# The package name is the informed consent for consent-free capture, so a stock binary must
|
||||
# never ship under it: assert the bundled library AND the dlopen path in the binary.
|
||||
- name: Check the deb is a drm build
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Resolve by glob, not from env.VERSION: build.py names the deb from Cargo.toml, so a
|
||||
# hardcoded name fails with a bare exit 1 the first time those two drift.
|
||||
shopt -s nullglob
|
||||
debs=(rustdesk-unattended-wayland-*-x86_64.deb)
|
||||
if [ "${#debs[@]}" -ne 1 ]; then
|
||||
echo "::error::expected one rustdesk-unattended-wayland-*-x86_64.deb, found ${#debs[@]}: ${debs[*]-none}"
|
||||
exit 1
|
||||
fi
|
||||
deb="${debs[0]}"
|
||||
echo "DRM_DEB=$deb" >> "$GITHUB_ENV"
|
||||
contents="$(dpkg -c "$deb")"
|
||||
if [[ ! "$contents" =~ usr/lib/rustdesk/libdrmtap\.so\.0\.[0-9]+\.[0-9]+ ]]; then
|
||||
echo "::error::$deb has no versioned libdrmtap.so.0.x.y"
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$contents" != *"usr/lib/rustdesk/libdrmtap.so.0 ->"* ]]; then
|
||||
echo "::error::$deb has no libdrmtap.so.0 soname symlink"
|
||||
exit 1
|
||||
fi
|
||||
rm -rf /tmp/deb && dpkg-deb -R "$deb" /tmp/deb
|
||||
if ! grep -qF /usr/lib/rustdesk/libdrmtap.so.0 /tmp/deb/usr/share/rustdesk/lib/librustdesk.so; then
|
||||
echo "::error::$deb was not built with the drm feature"
|
||||
exit 1
|
||||
fi
|
||||
shell: bash
|
||||
|
||||
- name: Publish debian package
|
||||
if: env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
with:
|
||||
prerelease: true
|
||||
tag_name: ${{ env.TAG_NAME }}
|
||||
files: |
|
||||
${{ env.DRM_DEB }}
|
||||
|
||||
# No UPLOAD_ARTIFACT gate: on a PR this is the only way to get at the deb that was just built.
|
||||
# always(), because a deb that failed the check above is the one most worth downloading.
|
||||
- name: Upload deb
|
||||
if: always() && env.DRM_DEB != ''
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: ${{ env.DRM_DEB }}
|
||||
path: ${{ env.DRM_DEB }}
|
||||
|
||||
build-rustdesk-linux-sciter:
|
||||
if: ${{ inputs.upload-artifact }}
|
||||
runs-on: ${{ matrix.job.on }}
|
||||
@@ -2115,7 +2423,18 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
cd $(dirname $(dirname $(which flutter)))
|
||||
[[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]] && git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff
|
||||
if [[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]]; then
|
||||
git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff
|
||||
fi
|
||||
|
||||
- name: Patch sources for Flutter 3.44 web
|
||||
# No-op while the web stays on Flutter 3.24.5; makes this job work as-is
|
||||
# once FLUTTER_VERSION moves to 3.44.x (qr_code_scanner + fonts, see script).
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "${{ env.FLUTTER_VERSION }}" == 3.44.* ]]; then
|
||||
bash .github/patches/apply_flutter_3.44_web_patches.sh
|
||||
fi
|
||||
|
||||
# https://rustdesk.com/docs/en/dev/build/web/
|
||||
- name: Build web
|
||||
|
||||
71
.github/workflows/update-webpki-roots.yml
vendored
Normal file
71
.github/workflows/update-webpki-roots.yml
vendored
Normal file
@@ -0,0 +1,71 @@
|
||||
name: Update webpki-roots
|
||||
|
||||
# Weekly refresh of the compiled-in TLS root certificates (the webpki-roots
|
||||
# crate, a snapshot of the Mozilla root store). Roots are otherwise frozen at
|
||||
# whatever Cargo.lock pins, so old builds miss newly added CAs and keep
|
||||
# removed (distrusted) ones. Changes go through a PR on purpose: added or
|
||||
# removed roots should be reviewed, not silently baked into releases.
|
||||
#
|
||||
# Note: PRs created with the default GITHUB_TOKEN do not trigger other
|
||||
# workflows (GitHub limitation). Close and reopen the PR, or push to its
|
||||
# branch, to run CI on it.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 3 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
# A manual dispatch overlapping the weekly run would race it force-pushing
|
||||
# the same branch; queue instead of overlapping, and never cancel a run
|
||||
# that may have already pushed.
|
||||
concurrency:
|
||||
group: update-webpki-roots
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
update:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
env:
|
||||
BRANCH: auto-update-webpki-roots
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Update webpki-roots in all lockfiles
|
||||
id: update
|
||||
run: |
|
||||
set -e
|
||||
git ls-files -z '*Cargo.lock' | while IFS= read -r -d '' lock; do
|
||||
dir=$(dirname "$lock")
|
||||
for v in $(sed -n '/name = "webpki-roots"/{n;s/.*version = "\(.*\)"/\1/p;}' "$lock" | sort -u); do
|
||||
echo "updating webpki-roots@$v in $dir"
|
||||
(cd "$dir" && cargo update -p "webpki-roots@$v")
|
||||
done
|
||||
done
|
||||
if git diff --quiet -- '*Cargo.lock'; then
|
||||
echo "changed=0" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changed=1" >> "$GITHUB_OUTPUT"
|
||||
git --no-pager diff -- '*Cargo.lock'
|
||||
fi
|
||||
|
||||
- name: Create pull request
|
||||
if: steps.update.outputs.changed == '1'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -e
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git checkout -B "$BRANCH"
|
||||
git add -- '*Cargo.lock'
|
||||
git commit -m "chore: update webpki-roots to latest Mozilla root store"
|
||||
git push -f origin "$BRANCH"
|
||||
if [ -z "$(gh pr list --head "$BRANCH" --state open --json number --jq '.[].number')" ]; then
|
||||
gh pr create \
|
||||
--title "chore: update webpki-roots to latest Mozilla root store" \
|
||||
--body "Automated weekly refresh of the compiled-in TLS root certificates (webpki-roots). Please review the added/removed roots. CI does not run automatically on PRs created by GITHUB_TOKEN; close and reopen this PR to trigger it."
|
||||
fi
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -55,4 +55,6 @@ examples/**/target/
|
||||
vcpkg_installed
|
||||
flutter/lib/generated_plugin_registrant.dart
|
||||
libsciter.dylib
|
||||
flutter/web/
|
||||
flutter/web/
|
||||
# libdrmtap is cloned at build time by build.py (not a submodule)
|
||||
/third_party/libdrmtap/
|
||||
|
||||
19
AGENTS.md
19
AGENTS.md
@@ -61,6 +61,19 @@
|
||||
* Do not make formatting-only changes.
|
||||
* Keep naming/style consistent with nearby code.
|
||||
|
||||
### Comments
|
||||
|
||||
* Keep them short: one line by default, three at most.
|
||||
* Say **why**, never what. If the code already says it, delete the comment.
|
||||
* A comment must never be longer than the code it describes.
|
||||
* Applies to YAML, shell and Python too, not just Rust.
|
||||
|
||||
### Be minimally invasive
|
||||
|
||||
* Prefer purely additive changes: layer new (`#[cfg]`-gated) blocks or new functions around existing code instead of restructuring it. The ideal diff for a fix adds lines and modifies/deletes none.
|
||||
* Do not extract or reshape existing code just to enable your new code; look for a mechanism that leaves existing lines untouched (e.g. hide/show an existing object instead of refactoring its construction into a helper for rebuilding).
|
||||
* Put new logic in self-contained functions in the module it belongs to (platform-specific logic in `src/platform/`, with `use` inside the function body to avoid churning shared import blocks). Call sites in shared files (`src/tray.rs`, `src/core_main.rs`, `src/server/connection.rs`, …) should be thin one-line hooks.
|
||||
|
||||
## Localization (`src/lang/*.rs`)
|
||||
|
||||
Each file is a `HashMap<key, translation>`. Layout:
|
||||
@@ -84,3 +97,9 @@ Then translate that source into the file's target language (infer the language f
|
||||
* 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`.
|
||||
|
||||
### Adding new keys (feature work)
|
||||
|
||||
* New English-text keys use sentence case, not Title Case: `Use ID whitelisting`, **not** `Use ID Whitelisting`. Acronyms (ID, IP, 2FA…) stay uppercase. Legacy Title-Case keys (e.g. `Use IP Whitelisting`) stay as-is — do not rename them.
|
||||
* Since the key itself is the English display text, a sentence-case key usually needs **no** `en.rs` entry; add one only when the display text must differ from the key (e.g. `*_tip` keys).
|
||||
* Append each new key to `template.rs` (with `""`) and to every `src/lang/*.rs` file (translated, or `""` if unsure), at the end of the list.
|
||||
|
||||
74
Cargo.lock
generated
74
Cargo.lock
generated
@@ -771,6 +771,26 @@ dependencies = [
|
||||
"syn 2.0.98",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bindgen"
|
||||
version = "0.72.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"cexpr",
|
||||
"clang-sys",
|
||||
"itertools 0.12.1",
|
||||
"log",
|
||||
"prettyplease",
|
||||
"proc-macro2 1.0.93",
|
||||
"quote 1.0.36",
|
||||
"regex",
|
||||
"rustc-hash 2.1.1",
|
||||
"shlex",
|
||||
"syn 2.0.98",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit_field"
|
||||
version = "0.10.2"
|
||||
@@ -1457,6 +1477,8 @@ dependencies = [
|
||||
"compression-core",
|
||||
"flate2",
|
||||
"memchr",
|
||||
"zstd 0.13.1",
|
||||
"zstd-safe 7.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2329,7 +2351,7 @@ version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412"
|
||||
dependencies = [
|
||||
"libloading 0.7.4",
|
||||
"libloading 0.8.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2694,7 +2716,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3052,9 +3074,8 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
||||
|
||||
[[package]]
|
||||
name = "fuser"
|
||||
version = "0.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53274f494609e77794b627b1a3cddfe45d675a6b2e9ba9c0fdc8d8eee2184369"
|
||||
version = "0.16.0"
|
||||
source = "git+https://github.com/rustdesk-org/fuser?branch=refact/tag-0.16.0-cargo-1.75.0#a3c0babe4a533f8dbcff5bce59ae7f2424b8d877"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
@@ -3799,7 +3820,7 @@ dependencies = [
|
||||
"url",
|
||||
"users 0.11.0",
|
||||
"uuid",
|
||||
"webpki-roots 1.0.4",
|
||||
"webpki-roots 1.0.9",
|
||||
"webrtc",
|
||||
"whoami",
|
||||
"winapi 0.3.9",
|
||||
@@ -3998,7 +4019,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots 1.0.4",
|
||||
"webpki-roots 1.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4494,7 +4515,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e310b3a6b5907f99202fcdb4960ff45b93735d7c7d96b760fcff8db2dc0e103d"
|
||||
dependencies = [
|
||||
"cfg-if 1.0.0",
|
||||
"windows-targets 0.48.5",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6920,7 +6941,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "rdev"
|
||||
version = "0.5.0-2"
|
||||
source = "git+https://github.com/rustdesk-org/rdev#871bf1c856d6a30af2f56ab8848396a025140855"
|
||||
source = "git+https://github.com/rustdesk-org/rdev#23e24dd6b35452a495dae0ae6d99395e9755ab0f"
|
||||
dependencies = [
|
||||
"cocoa 0.24.1",
|
||||
"core-foundation 0.9.4",
|
||||
@@ -7090,7 +7111,7 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"webpki-roots 1.0.4",
|
||||
"webpki-roots 1.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7190,18 +7211,6 @@ dependencies = [
|
||||
"realfft",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "runas"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b96d6b6c505282b007a9b009f2aa38b2fd0359b81a0430ceacc60f69ade4c6a0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
"which",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-ini"
|
||||
version = "0.18.0"
|
||||
@@ -7320,7 +7329,6 @@ dependencies = [
|
||||
"reqwest",
|
||||
"ringbuf",
|
||||
"rubato",
|
||||
"runas",
|
||||
"rust-pulsectl",
|
||||
"samplerate",
|
||||
"sciter-rs",
|
||||
@@ -7434,7 +7442,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.11.0",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7491,7 +7499,7 @@ dependencies = [
|
||||
"security-framework 3.5.1",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7578,7 +7586,7 @@ name = "scrap"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"bindgen 0.65.1",
|
||||
"bindgen 0.72.1",
|
||||
"block",
|
||||
"cfg-if 1.0.0",
|
||||
"dbus",
|
||||
@@ -8804,7 +8812,7 @@ dependencies = [
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls",
|
||||
"tungstenite",
|
||||
"webpki-roots 0.26.9",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9118,7 +9126,7 @@ dependencies = [
|
||||
"sha1",
|
||||
"thiserror 2.0.17",
|
||||
"utf-8",
|
||||
"webpki-roots 0.26.9",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9791,18 +9799,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.26.9"
|
||||
version = "0.26.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29aad86cec885cafd03e8305fd727c418e970a521322c91688414d5b8efba16b"
|
||||
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
"webpki-roots 1.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.4"
|
||||
version = "1.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e"
|
||||
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
14
Cargo.toml
14
Cargo.toml
@@ -30,6 +30,13 @@ default = ["use_dasp"]
|
||||
hwcodec = ["scrap/hwcodec"]
|
||||
vram = ["scrap/vram"]
|
||||
mediacodec = ["scrap/mediacodec"]
|
||||
drm = ["scrap/drm"]
|
||||
# The display wake, as its OWN compile gate on top of `drm`. Everything else in the drm backend
|
||||
# READS (it captures a scanout); the wake WRITES, injecting one synthetic pointer event from the
|
||||
# root service so a compositor that idle-disabled its outputs re-enables them. That is a different
|
||||
# kind of operation and deserves a switch that can remove it from the binary entirely, without
|
||||
# giving up DRM capture: `--features drm` builds the capture path with no wake code compiled in.
|
||||
drm-wake = ["drm"]
|
||||
plugin_framework = []
|
||||
linux-pkg-config = ["magnum-opus/linux-pkg-config", "scrap/linux-pkg-config"]
|
||||
unix-file-copy-paste = [
|
||||
@@ -79,7 +86,7 @@ shutdown_hooks = "0.1"
|
||||
totp-rs = { version = "5.4", default-features = false, features = ["gen_secret", "otpauth"] }
|
||||
stunclient = "0.4"
|
||||
kcp-sys= { git = "https://github.com/rustdesk-org/kcp-sys"}
|
||||
reqwest = { version = "0.12", features = ["blocking", "socks", "json", "native-tls", "rustls-tls", "rustls-tls-native-roots", "gzip"], default-features=false }
|
||||
reqwest = { version = "0.12", features = ["blocking", "socks", "json", "native-tls", "rustls-tls", "rustls-tls-native-roots", "gzip", "zstd"], default-features=false }
|
||||
|
||||
[target.'cfg(not(target_os = "linux"))'.dependencies]
|
||||
# https://github.com/rustdesk/rustdesk/discussions/10197, not use cpal on linux
|
||||
@@ -124,14 +131,18 @@ windows = { version = "0.61", features = [
|
||||
"Win32_Security_Authorization",
|
||||
"Win32_Storage_FileSystem",
|
||||
"Win32_System",
|
||||
"Win32_System_Com",
|
||||
"Win32_System_Diagnostics",
|
||||
"Win32_System_Diagnostics_ToolHelp",
|
||||
"Win32_System_Environment",
|
||||
"Win32_System_IO",
|
||||
"Win32_System_Memory",
|
||||
"Win32_System_Pipes",
|
||||
"Win32_System_Registry",
|
||||
"Win32_System_SystemInformation",
|
||||
"Win32_System_Threading",
|
||||
"Win32_UI_Shell",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
winreg = "0.11"
|
||||
windows-service = "0.6"
|
||||
@@ -140,7 +151,6 @@ remote_printer = { path = "libs/remote_printer" }
|
||||
impersonate_system = { git = "https://github.com/rustdesk-org/impersonate-system" }
|
||||
shared_memory = "0.12"
|
||||
tauri-winrt-notification = "0.1"
|
||||
runas = "1.2"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc = "0.2"
|
||||
|
||||
@@ -38,7 +38,7 @@ RustDesk welcomes contribution from everyone. See [CONTRIBUTING.md](docs/CONTRIB
|
||||
|
||||
## Dependencies
|
||||
|
||||
Desktop versions use Flutter or Sciter (deprecated) for GUI, this tutorial is for Sciter only, since it is easier and more friendly to start. Check out our [CI](https://github.com/rustdesk/rustdesk/blob/master/.github/workflows/flutter-build.yml) for building Flutter version.
|
||||
Desktop versions use Flutter or Sciter (deprecated) for GUI. This tutorial is for Sciter only, since it is easier and more friendly to start. Check out our [CI](https://github.com/rustdesk/rustdesk/blob/master/.github/workflows/flutter-build.yml) for building the Flutter version.
|
||||
|
||||
Please download Sciter dynamic library yourself.
|
||||
|
||||
|
||||
514
build.py
514
build.py
@@ -1,16 +1,25 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import os
|
||||
import glob
|
||||
import contextlib
|
||||
import pathlib
|
||||
import platform
|
||||
import zipfile
|
||||
import urllib.request
|
||||
import shutil
|
||||
import hashlib
|
||||
import re
|
||||
import subprocess
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Captured at import, while cwd is still the repo root: before Python 3.9 the main script's __file__
|
||||
# stays relative (bpo-20443), so abspath() re-resolves it against the cwd -- and the ubuntu18.04
|
||||
# packaging container runs 3.6 and chdir's into flutter/ before it reaches the libdrmtap code.
|
||||
REPO_ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
windows = platform.platform().startswith('Windows')
|
||||
osx = platform.platform().startswith(
|
||||
'Darwin') or platform.platform().startswith("macOS")
|
||||
@@ -130,6 +139,19 @@ def make_parser():
|
||||
action='store_true',
|
||||
help='Build with unix file copy paste feature'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--drm',
|
||||
action='store_true',
|
||||
help='Linux only: build the DRM/KMS capture backend (bundles libdrmtap.so, '
|
||||
'dlopen-ed in-process by the root service). Off by default.'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--print-features',
|
||||
action='store_true',
|
||||
help='Print the cargo feature list these flags select, and exit without building. For a '
|
||||
'caller that runs its own cargo line and then packages with --skip-cargo: it can ask '
|
||||
'for the list rather than repeat it, so the two cannot drift.'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--skip-cargo',
|
||||
action='store_true',
|
||||
@@ -272,6 +294,24 @@ def external_resources(flutter, args, res_dir):
|
||||
shutil.copytree(f, f'{flutter_build_dir_2}{f.stem}')
|
||||
|
||||
|
||||
def linux_packaging_branch():
|
||||
"""Which packaging path `main()` will take on THIS host.
|
||||
|
||||
MUST mirror the elif chain in main() (pacman / yum / zypper / else), and exists so `--drm` can
|
||||
refuse a branch that is not drm-aware instead of silently producing a stock-named package with
|
||||
the capture backend compiled in. Only the final `deb` branch reaches `build_flutter_deb`, which
|
||||
is what bundles libdrmtap, renames the package, adds Conflicts/Provides and asserts the staged
|
||||
binary really is a drm build.
|
||||
"""
|
||||
if os.path.isfile('/usr/bin/pacman'):
|
||||
return 'pacman'
|
||||
if os.path.isfile('/usr/bin/yum'):
|
||||
return 'yum'
|
||||
if os.path.isfile('/usr/bin/zypper'):
|
||||
return 'zypper'
|
||||
return 'deb'
|
||||
|
||||
|
||||
def get_features(args):
|
||||
features = ['inline'] if not args.flutter else []
|
||||
if args.hwcodec:
|
||||
@@ -282,6 +322,30 @@ def get_features(args):
|
||||
features.append('flutter')
|
||||
if args.unix_file_copy_paste:
|
||||
features.append('unix-file-copy-paste')
|
||||
if args.drm:
|
||||
# Say so rather than quietly handing back a stock build: the backend is Linux-only, so on
|
||||
# any other host the flag cannot be honoured and the resulting binary would look like a
|
||||
# DRM build without being one.
|
||||
if windows or osx:
|
||||
raise Exception('--drm is Linux only')
|
||||
# And only on the deb branch. The other three Linux paths (pacman/yum/zypper) package
|
||||
# straight from `target/release` without bundling libdrmtap, without the rename, without
|
||||
# Conflicts/Provides and without assert_staged_binary_is_drm() -- so they would emit a
|
||||
# package NAMED `rustdesk` carrying the consent-bypass backend and the root-side uinput
|
||||
# injection. The separate package name is the informed consent this feature rests on, so
|
||||
# refuse rather than ship a stock-named build of it.
|
||||
branch = linux_packaging_branch()
|
||||
if branch != 'deb':
|
||||
raise Exception(
|
||||
f'--drm is only supported on the deb packaging path; this host would package via '
|
||||
f'{branch}, which cannot bundle libdrmtap or name the package distinctly')
|
||||
features.append('drm')
|
||||
# The display wake is its own compile gate on top of `drm`, and the unattended package is
|
||||
# exactly where it belongs: that variant exists to reach a machine nobody is sitting at,
|
||||
# and a machine whose screen went dark is the case it is for. Dropping `drm-wake` from
|
||||
# this line builds the same capture backend with no wake code in the binary at all.
|
||||
# It is ALSO switchable at runtime; see OPTION_ENABLE_DRM_DISPLAY_WAKE.
|
||||
features.append('drm-wake')
|
||||
if osx:
|
||||
if args.screencapturekit:
|
||||
features.append('screencapturekit')
|
||||
@@ -316,6 +380,322 @@ def ffi_bindgen_function_refactor():
|
||||
'sed -i "s/ffi.NativeFunction<ffi.Bool Function(DartPort/ffi.NativeFunction<ffi.Uint8 Function(DartPort/g" flutter/lib/generated_bridge.dart')
|
||||
|
||||
|
||||
# libdrmtap is fetched at build time from the rustdesk-org fork at a pinned
|
||||
# commit — the same way rustdesk sources its other native build deps (vcpkg,
|
||||
# flutter_rust_bridge, ...), rather than carrying a git submodule. It is the ONLY
|
||||
# pin for the drm backend: rustdesk dlopens this .so at runtime and does not depend on
|
||||
# the libdrmtap-sys crate (whose build.rs would statically link the C tree, a helper and
|
||||
# libdrm/seccomp/cap). DRMTAP_REPO, DRMTAP_SHA and DRMTAP_PREBUILT_DIR override it for local testing
|
||||
# or another fork, and each requires DRMTAP_ALLOW_UNPINNED=1 alongside it (see below).
|
||||
# The commit is fetched directly by sha, so no branch or tag name takes part in the build: see
|
||||
# build_libdrmtap_so(). This is the SINGLE source of truth for the pin, deliberately not duplicated in
|
||||
# any workflow, so a bump is one edit here (plus the informational version comment in
|
||||
# libs/scrap/Cargo.toml). This commit is libdrmtap v0.5.2.
|
||||
LIBDRMTAP_REPO_PINNED = 'https://github.com/rustdesk-org/libdrmtap'
|
||||
LIBDRMTAP_SHA_PINNED = '653de8c774bc245eaf960611ca7a136f7a544d21'
|
||||
LIBDRMTAP_REPO = os.environ.get('DRMTAP_REPO', LIBDRMTAP_REPO_PINNED)
|
||||
LIBDRMTAP_SHA = os.environ.get('DRMTAP_SHA', LIBDRMTAP_SHA_PINNED)
|
||||
# Every way of getting a different .so than the pin needs the same explicit opt-in. Otherwise the
|
||||
# claim this feature rests on -- that the privileged capture library is the reviewed object at
|
||||
# LIBDRMTAP_SHA_PINNED -- would hold only as long as nobody happened to have one of these set, and a
|
||||
# build that silently used something else would be indistinguishable from one that did not.
|
||||
# DRMTAP_PREBUILT_DIR is in the list because it is the widest of the three: it skips both the fetch
|
||||
# and the sha verification and hands over an object built from nothing this script can see.
|
||||
DRMTAP_UNPINNED_OK = os.environ.get('DRMTAP_ALLOW_UNPINNED') == '1'
|
||||
|
||||
|
||||
def _prebuilt_dir_is_the_pinned_checkout(prebuilt_dir):
|
||||
# A .so built from this repo's own third_party/libdrmtap at the pinned sha is the pinned object,
|
||||
# not an override, so it must not need the opt-in. This is how CI hands the library from a step
|
||||
# that has meson to a packaging container that does not.
|
||||
src = os.path.join(REPO_ROOT, 'third_party', 'libdrmtap')
|
||||
try:
|
||||
inside = os.path.commonpath([os.path.abspath(prebuilt_dir), src]) == src
|
||||
except ValueError:
|
||||
return False
|
||||
if not inside or not os.path.isdir(os.path.join(src, '.git')):
|
||||
return False
|
||||
try:
|
||||
head = subprocess.check_output(['git', '-C', src, 'rev-parse', 'HEAD']).decode().strip()
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
return False
|
||||
return head == LIBDRMTAP_SHA
|
||||
|
||||
|
||||
def _validate_libdrmtap_pin():
|
||||
# Called from build_libdrmtap_so(), NOT at import: a stock (non --drm) build must stay
|
||||
# byte-identical to upstream in behaviour too, and leftover DRMTAP_* variables in the
|
||||
# environment (or a malformed sha) must not be able to fail a build that never touches
|
||||
# libdrmtap.
|
||||
# `or None` so an empty value reads as unset here exactly as it does in build_libdrmtap_so(),
|
||||
# which tests it for truthiness.
|
||||
prebuilt = os.environ.get('DRMTAP_PREBUILT_DIR') or None
|
||||
if prebuilt and _prebuilt_dir_is_the_pinned_checkout(prebuilt):
|
||||
prebuilt = None
|
||||
overridden = [
|
||||
name
|
||||
for name, value, pinned in (
|
||||
('DRMTAP_REPO', LIBDRMTAP_REPO, LIBDRMTAP_REPO_PINNED),
|
||||
('DRMTAP_SHA', LIBDRMTAP_SHA, LIBDRMTAP_SHA_PINNED),
|
||||
('DRMTAP_PREBUILT_DIR', prebuilt, None),
|
||||
)
|
||||
if value != pinned
|
||||
]
|
||||
if overridden and not DRMTAP_UNPINNED_OK:
|
||||
raise Exception(
|
||||
f'{", ".join(overridden)} would build libdrmtap from something other than the pinned '
|
||||
f'{LIBDRMTAP_REPO_PINNED} at {LIBDRMTAP_SHA_PINNED}. That is supported for local work and '
|
||||
'cross-builds, but it has to be deliberate: set DRMTAP_ALLOW_UNPINNED=1 as well.')
|
||||
if overridden:
|
||||
print(f'WARNING: libdrmtap is NOT the pinned build ({", ".join(overridden)} set)')
|
||||
# Both are interpolated into shell commands below, and both are env-overridable, so validate
|
||||
# their SHAPE before they get there. This is not only about a hostile environment: a truncated
|
||||
# or abbreviated sha would otherwise reach `git fetch` and fail with something far less obvious
|
||||
# than saying so here, and an abbreviated one would defeat the point of pinning.
|
||||
if not re.fullmatch(r'[0-9a-f]{40}', LIBDRMTAP_SHA):
|
||||
raise Exception(
|
||||
f'DRMTAP_SHA must be a full 40-character commit sha, got {LIBDRMTAP_SHA!r}')
|
||||
if not re.fullmatch(r'(https://|git@)[A-Za-z0-9._~:/@-]+', LIBDRMTAP_REPO):
|
||||
raise Exception(f'DRMTAP_REPO does not look like a git remote url: {LIBDRMTAP_REPO!r}')
|
||||
|
||||
|
||||
def _single_real_so(paths, where):
|
||||
# Return the one real libdrmtap.so.0.* object among `paths`, failing if there are zero or several.
|
||||
# glob order is arbitrary, so silently taking [0] could ship a stale or wrong-arch object left
|
||||
# over from an earlier build; a mismatch should fail the build loudly instead.
|
||||
real = sorted(p for p in paths if os.path.isfile(p) and not os.path.islink(p))
|
||||
if len(real) != 1:
|
||||
raise Exception(
|
||||
f'expected exactly one real libdrmtap.so.0.* in {where}, found {len(real)}: {real}')
|
||||
return real[0]
|
||||
|
||||
|
||||
def build_libdrmtap_so():
|
||||
# Build libdrmtap.so from the rustdesk-org fork, fetched at the pinned LIBDRMTAP_SHA. The
|
||||
# pivot dlopen-s this .so in-process in the root service (which already holds
|
||||
# CAP_SYS_ADMIN) — no setcap helper, no privileged child. Only the shared
|
||||
# library target is built (the source also carries a helper binary we do not
|
||||
# ship). Returns the path to the built versioned .so (e.g. libdrmtap.so.0.4.x).
|
||||
_validate_libdrmtap_pin()
|
||||
# Allow a caller (e.g. CI) to build the .so ahead of time and hand it in via
|
||||
# DRMTAP_PREBUILT_DIR (must contain the real libdrmtap.so.0.* object).
|
||||
prebuilt_dir = os.environ.get('DRMTAP_PREBUILT_DIR')
|
||||
if prebuilt_dir:
|
||||
# DRMTAP_PREBUILT_DIR explicitly names the artifact source, so honor it strictly: fail
|
||||
# (rather than silently falling back to a source build) if it holds no single real .so.
|
||||
prebuilt = glob.glob(os.path.join(prebuilt_dir, 'libdrmtap.so.0.*'))
|
||||
so = _single_real_so(prebuilt, f'DRMTAP_PREBUILT_DIR={prebuilt_dir}')
|
||||
# Check the stub case HERE too, not only on the source path below. This is the widest
|
||||
# override of the three -- no fetch, no sha verification, an object built by something this
|
||||
# script cannot see -- so it is the likeliest to hand over a CPU-only build, and skipping the
|
||||
# assertion on exactly this path would leave the check guarding only the case that was
|
||||
# already trustworthy.
|
||||
_assert_so_has_egl(so)
|
||||
return so
|
||||
# Fetch the pinned source if it is not already present. third_party/libdrmtap is not a submodule
|
||||
# anymore; it is git-ignored. The commit is fetched BY SHA rather than by cloning a branch:
|
||||
# `clone --depth 1 --branch main` only ever fetches the tip, so the moment upstream pushes to
|
||||
# `main` the pinned commit is not in the shallow clone at all and the build fails on an unreachable
|
||||
# object. Fetching the sha needs no branch name, so it keeps working across every upstream push and
|
||||
# is immune to a ref being moved or repointed.
|
||||
src = os.path.join(REPO_ROOT, 'third_party', 'libdrmtap')
|
||||
if not os.path.exists(os.path.join(src, 'meson.build')):
|
||||
if os.path.isdir(src):
|
||||
shutil.rmtree(src)
|
||||
os.makedirs(src, exist_ok=True)
|
||||
system2(f'git -C "{src}" init -q')
|
||||
system2(f'git -C "{src}" remote add origin {LIBDRMTAP_REPO}')
|
||||
system2(f'git -C "{src}" fetch --depth 1 origin {LIBDRMTAP_SHA}')
|
||||
system2(f'git -C "{src}" checkout -q FETCH_HEAD')
|
||||
# Verify the pin whenever the source is a GIT checkout. A fetch by sha cannot resolve to anything
|
||||
# else, so this now guards the OTHER case: a reused checkout left by an earlier build at a
|
||||
# different pin, which is what a bump leaves behind. Reject and remove it so the next run re-fetches
|
||||
# cleanly. A NON-git tree placed here on purpose (a developer building unreleased local libdrmtap
|
||||
# source) has nothing to verify and is used as-is.
|
||||
if os.path.isdir(os.path.join(src, '.git')):
|
||||
got_sha = subprocess.check_output(
|
||||
['git', '-C', src, 'rev-parse', 'HEAD']).decode().strip()
|
||||
if got_sha != LIBDRMTAP_SHA:
|
||||
shutil.rmtree(src, ignore_errors=True)
|
||||
raise Exception(
|
||||
f'libdrmtap at {src} is {got_sha}, expected {LIBDRMTAP_SHA} '
|
||||
f'(stale checkout from a different pin; removed, re-run to re-fetch)')
|
||||
build_dir = os.path.join(src, 'build-pkg')
|
||||
if not os.path.exists(os.path.join(build_dir, 'build.ninja')):
|
||||
system2(f'meson setup "{build_dir}" "{src}" --buildtype=release')
|
||||
# Build only the shared library, not the bundled helper binary or the static archive. Since
|
||||
# libdrmtap 0.4.11 the project is `both_libraries` (a version-scripted .so + a static .a), so the
|
||||
# bare `drmtap` target is ambiguous ("drmtap:shared_library" vs "drmtap:static_library"); ask for
|
||||
# the shared one explicitly (rustdesk dlopens the .so and never needs the archive).
|
||||
system2(f'meson compile -C "{build_dir}" drmtap:shared_library')
|
||||
sos = glob.glob(os.path.join(build_dir, 'libdrmtap.so.0.*'))
|
||||
# keep the real object (libdrmtap.so.0.4.x), not the .so/.so.0 symlinks or meson's .p dir, and
|
||||
# require exactly one so a stale object from an earlier build is never silently picked.
|
||||
so = _single_real_so(sos, f'the libdrmtap meson build dir {build_dir}')
|
||||
_assert_so_has_egl(so)
|
||||
return so
|
||||
|
||||
|
||||
def _assert_so_has_egl(so_path):
|
||||
# libdrmtap treats egl/glesv2 as OPTIONAL dependencies: without their headers and pkg-config
|
||||
# files, meson silently builds a CPU-only stub. That stub still exports every symbol the loader
|
||||
# checks for, so nothing downstream notices -- and the split architecture depends entirely on the
|
||||
# unprivileged side EGL-detiling the scanout it receives. The result is a build where DRM capture
|
||||
# quietly degrades to PipeWire on every tiled-scanout host, which is most of them.
|
||||
#
|
||||
# Assert on the ARTIFACT rather than passing an option that demands it: `-Degl=enabled` exists
|
||||
# only in libdrmtap past 0.4.15, and checking what was actually produced also catches a stale or
|
||||
# hand-substituted object, which a build flag cannot.
|
||||
#
|
||||
# EGL is reached by lazy dlopen, on purpose, so that the privileged service never links the GPU
|
||||
# stack. That means there is no DT_NEEDED to look for and an ELF-level check reports "no EGL" on a
|
||||
# perfectly good library; the dlopen name and an extension symbol are what a CPU-only stub really
|
||||
# lacks.
|
||||
try:
|
||||
with open(so_path, 'rb') as f:
|
||||
blob = f.read()
|
||||
except OSError as err:
|
||||
raise Exception(f'cannot read the built libdrmtap at {so_path}: {err}') from err
|
||||
missing = [m for m in (b'libEGL.so.1', b'eglCreateImageKHR') if m not in blob]
|
||||
if missing:
|
||||
raise Exception(
|
||||
f'{so_path} looks like a CPU-only libdrmtap stub (missing '
|
||||
f'{", ".join(m.decode() for m in missing)}): the EGL detile path the split capture '
|
||||
'depends on is not in it, and DRM capture would silently fall back to PipeWire. '
|
||||
'Install the EGL development packages and rebuild (Debian/Ubuntu: libegl-dev '
|
||||
'libgles2-mesa-dev; Arch: mesa libglvnd).')
|
||||
|
||||
|
||||
DRM_PACKAGE_NAME = 'rustdesk-unattended-wayland'
|
||||
|
||||
|
||||
def assert_so_satisfies_the_runtime_abi_gate(so_path):
|
||||
"""The .so we are about to ship must be one the RUNTIME will actually accept.
|
||||
|
||||
`abi_accepted` in libs/scrap/src/common/drmtap_dl.rs is the only place the pinned library's
|
||||
version is ever validated, and it runs at dlopen time on the USER's machine. Nothing in the
|
||||
build or in CI compared the two, so the pin and the gate could drift apart and every existing
|
||||
assertion would still pass: the EGL check does not look at the version, the CI symbol contract
|
||||
does not call drmtap_version(), and the deb-contents regex matches any `libdrmtap.so.0.X.Y`.
|
||||
A green pipeline could therefore produce a deb in which DRM capture can never start, and the
|
||||
only symptom on the host is one log line before it falls back to the portal.
|
||||
|
||||
So parse the gate out of the Rust and apply it here, to the object being staged. This is the
|
||||
same rule, not a copy of the numbers: if someone bumps the constants, this reads the new ones.
|
||||
"""
|
||||
m = re.search(r'libdrmtap\.so\.(\d+)\.(\d+)\.(\d+)', os.path.basename(so_path))
|
||||
if not m:
|
||||
# Not a versioned soname (a local dev build, say). The gate cannot be evaluated, and
|
||||
# inventing a verdict would be worse than saying so.
|
||||
print(f'[drm] cannot read a version out of {so_path}; skipping the ABI-gate cross-check')
|
||||
return
|
||||
so_ver = tuple(int(g) for g in m.groups())
|
||||
# REPO_ROOT, not abspath(__file__): both callers have chdir'd into flutter/ by now.
|
||||
gate_path = os.path.join(REPO_ROOT, 'libs', 'scrap', 'src', 'common', 'drmtap_dl.rs')
|
||||
with open(gate_path) as f:
|
||||
gate_src = f.read()
|
||||
|
||||
def _const(name):
|
||||
mm = re.search(rf'const {name}: c_int = (\d+);', gate_src)
|
||||
return int(mm.group(1)) if mm else None
|
||||
|
||||
major, minor = _const('DRMTAP_ABI_MAJOR'), _const('DRMTAP_ABI_MINOR')
|
||||
mm = re.search(r'const DRMTAP_MIN_MINOR_PATCH: \(c_int, c_int\) = \((\d+), (\d+)\);', gate_src)
|
||||
floor = (int(mm.group(1)), int(mm.group(2))) if mm else None
|
||||
if major is None or minor is None or floor is None:
|
||||
raise Exception(
|
||||
'could not parse the libdrmtap ABI gate out of drmtap_dl.rs (DRMTAP_ABI_MAJOR / '
|
||||
'DRMTAP_ABI_MINOR / DRMTAP_MIN_MINOR_PATCH). The gate moved and this check did not; '
|
||||
'fix the check rather than removing it, or the pin and the gate can drift silently.')
|
||||
accepted = so_ver[0] == major and so_ver[1] == minor and (so_ver[1], so_ver[2]) >= floor
|
||||
if not accepted:
|
||||
raise Exception(
|
||||
f'the libdrmtap being packaged is {so_ver[0]}.{so_ver[1]}.{so_ver[2]}, which the '
|
||||
f'runtime loader would REFUSE: drmtap_dl.rs accepts exactly major {major}, minor '
|
||||
f'{minor}, patch >= {floor[1]}. Shipping it produces a deb whose DRM capture can never '
|
||||
'start. Move the build pin and the gate together, or fix whichever one is wrong.')
|
||||
print(f'[drm] libdrmtap {so_ver[0]}.{so_ver[1]}.{so_ver[2]} satisfies the runtime ABI gate '
|
||||
f'(major {major}, minor {minor}, patch >= {floor[1]})')
|
||||
|
||||
|
||||
def stage_libdrmtap_into_deb(so_path):
|
||||
# Put the built libdrmtap object plus its soname symlink into the staged deb. Only the soname
|
||||
# symlink is needed: libdrmtap is resolved by ABSOLUTE path (/usr/lib/rustdesk/libdrmtap.so.0) at
|
||||
# the in-process dlopen site (drmtap_dl.rs), so the deb does NOT drop /usr/lib/rustdesk into the
|
||||
# system-wide /etc/ld.so.conf.d search path, which would let this private library shadow a system
|
||||
# library for every binary on the host (Debian Policy 10.2 forbids that). No ld.so.conf.d drop-in
|
||||
# and no ldconfig trigger are shipped, so the stock postinst is used unchanged.
|
||||
assert_so_satisfies_the_runtime_abi_gate(so_path)
|
||||
so_basename = os.path.basename(so_path)
|
||||
system2('mkdir -p tmpdeb/usr/lib/rustdesk')
|
||||
# Quoted: so_path comes from the repo root or from DRMTAP_PREBUILT_DIR, either of which can
|
||||
# contain a space, and an unquoted interpolation would split the argument and fail obscurely.
|
||||
system2(f'cp "{so_path}" tmpdeb/usr/lib/rustdesk/')
|
||||
system2(f'ln -sf "{so_basename}" tmpdeb/usr/lib/rustdesk/libdrmtap.so.0')
|
||||
|
||||
|
||||
def _max_glibc_minor(path):
|
||||
# Read from .dynstr rather than via objdump so packaging needs no binutils; chunked because
|
||||
# librustdesk.so is ~45 MB.
|
||||
best = 0
|
||||
with open(path, 'rb') as f:
|
||||
tail = b''
|
||||
while True:
|
||||
chunk = f.read(1 << 20)
|
||||
if not chunk:
|
||||
return best
|
||||
for m in re.finditer(rb'GLIBC_2\.(\d+)', tail + chunk):
|
||||
best = max(best, int(m.group(1)))
|
||||
tail = chunk[-16:]
|
||||
|
||||
|
||||
def measured_glibc_floor():
|
||||
# libdrmtap is built on a newer base than the rest of the deb, so the floor is whichever staged
|
||||
# object is higher -- and it moves whenever either base does.
|
||||
paths = [p for p in glob.glob('tmpdeb/usr/lib/rustdesk/libdrmtap.so.0.*')
|
||||
+ glob.glob('tmpdeb/usr/share/rustdesk/lib/librustdesk.so')
|
||||
+ glob.glob('tmpdeb/usr/share/rustdesk/rustdesk')
|
||||
if os.path.isfile(p) and not os.path.islink(p)]
|
||||
minor = max((_max_glibc_minor(p) for p in paths), default=0)
|
||||
if not minor:
|
||||
raise Exception(
|
||||
f'could not measure a GLIBC_2.x floor from any staged object ({paths or "none found"}); '
|
||||
'refusing to ship the unattended-wayland variant with an undeclared libc6 floor, which '
|
||||
'is what lets it install on a host where libdrmtap can never load')
|
||||
return f'2.{minor}'
|
||||
|
||||
|
||||
def retarget_control_to_drm_variant():
|
||||
# Rewrite the control file that generate_control_file just produced, instead of parameterizing that
|
||||
# function: the stock packaging path stays exactly as upstream wrote it, and everything specific to
|
||||
# this variant lives here. The variant installs the same files as the stock package, so it must
|
||||
# conflict with and replace it: you install one or the other, never both. It also needs libdrmtap's
|
||||
# own runtime deps, which the stock package has no reason to carry.
|
||||
path = '../res/DEBIAN/control'
|
||||
floor = measured_glibc_floor()
|
||||
print(f'[drm] {DRM_PACKAGE_NAME} libc6 floor measured at {floor}')
|
||||
with open(path) as f:
|
||||
lines = f.readlines()
|
||||
out = []
|
||||
for line in lines:
|
||||
if line.startswith('Package: rustdesk'):
|
||||
out.append(f'Package: {DRM_PACKAGE_NAME}\n')
|
||||
out.append('Conflicts: rustdesk\nReplaces: rustdesk\nProvides: rustdesk\n')
|
||||
elif line.startswith('Depends:'):
|
||||
# 2.4.101 is where drmModeGetFB2 landed; below it libdrmtap loads and can never capture.
|
||||
out.append(line.rstrip('\n') + ', libdrm2 (>= 2.4.101), libegl1, libgles2, '
|
||||
f'libc6 (>= {floor})\n')
|
||||
else:
|
||||
out.append(line)
|
||||
body = ''.join(out)
|
||||
# Fail loudly rather than silently shipping a package that says `rustdesk`: a stock control file
|
||||
# that stopped matching either anchor would otherwise produce a variant deb wearing the stock name.
|
||||
if f'Package: {DRM_PACKAGE_NAME}\n' not in body or 'libegl1' not in body:
|
||||
raise Exception(f'could not retarget {path} to the drm variant; upstream control layout changed')
|
||||
with open(path, 'w') as f:
|
||||
f.write(body)
|
||||
|
||||
|
||||
def build_flutter_deb(version, features):
|
||||
if not skip_cargo:
|
||||
system2(f'cargo build --locked --features {features} --lib --release')
|
||||
@@ -352,9 +732,22 @@ def build_flutter_deb(version, features):
|
||||
'cp ../res/pam.d/rustdesk.debian tmpdeb/etc/pam.d/rustdesk')
|
||||
system2(
|
||||
"echo \"#!/bin/sh\" >> tmpdeb/usr/share/rustdesk/files/polkit && chmod a+x tmpdeb/usr/share/rustdesk/files/polkit")
|
||||
# Bundle libdrmtap.so only when this build actually enabled the `drm` feature, so stock packages
|
||||
# stay exactly what they were. The root service dlopens it in-process by absolute path.
|
||||
# `features` is the comma-joined string, so split it: a bare substring test would also match any
|
||||
# future feature merely containing "drm" (drm-lease, vaapi-drm) and rename the deb to the
|
||||
# consent-bypass variant without --drm ever being passed.
|
||||
ships_so = 'drm' in features.split(',')
|
||||
if ships_so:
|
||||
# Same artifact assertion as the --package path. Under --skip-cargo nothing here rebuilt the
|
||||
# binary, so `features` says what was ASKED for while the staged bundle can be anything.
|
||||
assert_staged_binary_is_drm()
|
||||
stage_libdrmtap_into_deb(build_libdrmtap_so())
|
||||
|
||||
system2('mkdir -p tmpdeb/DEBIAN')
|
||||
generate_control_file(version)
|
||||
if ships_so:
|
||||
retarget_control_to_drm_variant()
|
||||
system2('cp -a ../res/DEBIAN/* tmpdeb/DEBIAN/')
|
||||
md5_file_folder("tmpdeb/")
|
||||
system2('dpkg-deb -b tmpdeb rustdesk.deb;')
|
||||
@@ -362,10 +755,68 @@ def build_flutter_deb(version, features):
|
||||
system2('/bin/rm -rf tmpdeb/')
|
||||
system2('/bin/rm -rf ../res/DEBIAN/control')
|
||||
os.rename('rustdesk.deb', '../rustdesk-%s.deb' % version)
|
||||
if ships_so:
|
||||
# Named apart from the stock package so installing the consent-free variant is a deliberate act.
|
||||
os.rename('../rustdesk-%s.deb' % version, f'../{DRM_PACKAGE_NAME}-{version}.deb')
|
||||
os.chdir("..")
|
||||
|
||||
|
||||
def build_deb_from_folder(version, binary_folder):
|
||||
DRMTAP_DLOPEN_MARKER = b'/usr/lib/rustdesk/libdrmtap.so.0'
|
||||
# Present only when `drm-wake` is compiled in: the runtime option constant is itself
|
||||
# #[cfg(feature = "drm-wake")] (src/ipc/drm.rs). The dlopen marker above cannot stand in for it -
|
||||
# `--features drm` alone produces a binary that carries the dlopen path and NO wake code, and that
|
||||
# is exactly the deb this assertion is here to refuse.
|
||||
DRMTAP_WAKE_MARKER = b'enable-drm-display-wake'
|
||||
|
||||
|
||||
def _carries_drmtap_marker(path, marker=DRMTAP_DLOPEN_MARKER):
|
||||
# Chunked, with an overlap of len(marker)-1 so the marker cannot be missed at a chunk boundary:
|
||||
# librustdesk.so is ~45 MB and there is no reason to hold it all in memory, and the `with`
|
||||
# closes deterministically instead of relying on refcounting.
|
||||
with open(path, 'rb') as f:
|
||||
tail = b''
|
||||
while True:
|
||||
chunk = f.read(1 << 20)
|
||||
if not chunk:
|
||||
return False
|
||||
if marker in tail + chunk:
|
||||
return True
|
||||
tail = chunk[-(len(marker) - 1):]
|
||||
|
||||
|
||||
def assert_staged_binary_is_drm():
|
||||
"""The staged BINARY must really be a drm build before it is named the unattended-wayland
|
||||
variant. That package conflicts with and replaces the stock one, so shipping a stock binary
|
||||
under that name produces something that can never capture and cannot be installed alongside
|
||||
what it replaced. The marker is the absolute dlopen path from drmtap_dl.rs, present only when
|
||||
the feature is compiled in -- assert what was produced, not what was asked for.
|
||||
|
||||
Called from BOTH packaging paths. It used to guard only one of them, and `--skip-cargo` (which
|
||||
is how CI packages) reaches the other, where nothing had rebuilt the binary at all.
|
||||
"""
|
||||
binaries = [p for p in glob.glob('tmpdeb/usr/share/rustdesk/lib/librustdesk.so')
|
||||
+ glob.glob('tmpdeb/usr/share/rustdesk/rustdesk') if os.path.isfile(p)]
|
||||
if not any(_carries_drmtap_marker(p) for p in binaries):
|
||||
raise Exception(
|
||||
f'--drm was requested but the staged bundle does not look like a drm build (no '
|
||||
f'{DRMTAP_DLOPEN_MARKER.decode()} dlopen path in {binaries or "any staged binary"}); '
|
||||
'refusing to package it as the unattended-wayland variant, which conflicts with and '
|
||||
'replaces the stock package but could never capture')
|
||||
# And the WAKE half. `--drm` enables `drm-wake` too (see get_features), and the deb is named and
|
||||
# documented as the variant that can reach a machine whose screen has gone dark. The dlopen
|
||||
# marker above does not distinguish them: `--features drm` alone carries it and has no wake code
|
||||
# at all. Asserting only the first half is how a deb can be named for a feature it does not have.
|
||||
if not any(_carries_drmtap_marker(p, DRMTAP_WAKE_MARKER) for p in binaries):
|
||||
raise Exception(
|
||||
f'--drm was requested but the staged binary has no {DRMTAP_WAKE_MARKER.decode()} '
|
||||
f'marker in {binaries or "any staged binary"}, so it was built without `drm-wake`; '
|
||||
'refusing to package it as the unattended-wayland variant, which is named and '
|
||||
'documented as the build that can wake an idle-disabled display. If this fired under '
|
||||
'--skip-cargo, the cargo line that produced the bundle is missing the feature: '
|
||||
'--features ...,drm,drm-wake')
|
||||
|
||||
|
||||
def build_deb_from_folder(version, binary_folder, want_drm=False):
|
||||
os.chdir('flutter')
|
||||
system2('mkdir -p tmpdeb/usr/bin/')
|
||||
system2('mkdir -p tmpdeb/usr/share/rustdesk')
|
||||
@@ -389,9 +840,53 @@ def build_deb_from_folder(version, binary_folder):
|
||||
'cp ../res/rustdesk-link.desktop tmpdeb/usr/share/applications/rustdesk-link.desktop')
|
||||
system2(
|
||||
"echo \"#!/bin/sh\" >> tmpdeb/usr/share/rustdesk/files/polkit && chmod a+x tmpdeb/usr/share/rustdesk/files/polkit")
|
||||
# Where the capture library comes from for a `--package <folder> --drm` build. Two shapes are
|
||||
# supported, because two exist in practice: a bundle that already carries libdrmtap.so.0.*
|
||||
# (someone staged it, e.g. a CI artifact), and a plain bundle, which is what every build path
|
||||
# here actually produces -- the flutter deb builds the library straight into the staged deb, so
|
||||
# nothing ever puts it inside the bundle folder. Demanding it in the bundle made this flag
|
||||
# combination impossible to satisfy.
|
||||
bundled_glob = glob.glob('tmpdeb/usr/share/rustdesk/libdrmtap.so.0.*')
|
||||
bundle_carries_so = any(os.path.isfile(p) and not os.path.islink(p) for p in bundled_glob)
|
||||
# The variant must be decided by the EXPLICIT --drm request, not merely by what happens to be
|
||||
# staged: a bundle that carries the .so must NOT be shipped as the consent-bypass variant when
|
||||
# --drm was never passed.
|
||||
if bundle_carries_so and not want_drm:
|
||||
raise Exception(
|
||||
'the staged bundle carries libdrmtap.so.0.* but --drm was not passed; refusing '
|
||||
'to silently ship the consent-bypass unattended-wayland variant (pass --drm to '
|
||||
'build it deliberately)')
|
||||
if want_drm:
|
||||
# Whichever shape we are in, the staged BINARY must really be a drm build. This is the
|
||||
# property the old presence-of-the-.so test stood in for, badly: a stock binary packaged as
|
||||
# the unattended-wayland variant would carry the consent-bypass name, conflict with and
|
||||
# replace the stock package, and never be able to capture. The marker is the absolute
|
||||
# dlopen path from drmtap_dl.rs, present only when the feature is compiled in -- the same
|
||||
# kind of artifact assertion as _assert_so_has_egl, and for the same reason: assert what
|
||||
# was produced, not what was asked for.
|
||||
assert_staged_binary_is_drm()
|
||||
if bundle_carries_so:
|
||||
so = _single_real_so(bundled_glob, 'the staged --drm bundle')
|
||||
# The THIRD artifact source, and the last one that was missing the check: --package
|
||||
# takes the .so straight out of a bundle somebody else produced, so it has the same
|
||||
# exposure as DRMTAP_PREBUILT_DIR (see the comment on that branch). A CPU-only stub
|
||||
# would ship, the loader would accept it, and capture would degrade to PipeWire
|
||||
# without a word.
|
||||
_assert_so_has_egl(so)
|
||||
stage_libdrmtap_into_deb(so)
|
||||
system2(f'rm -f "{so}"')
|
||||
system2('rm -f tmpdeb/usr/share/rustdesk/libdrmtap.so tmpdeb/usr/share/rustdesk/libdrmtap.so.0')
|
||||
else:
|
||||
# Build it here, exactly as the flutter deb path does (build_libdrmtap_so asserts the
|
||||
# EGL backend itself). The library is independent of the staged binary.
|
||||
stage_libdrmtap_into_deb(build_libdrmtap_so())
|
||||
|
||||
system2('mkdir -p tmpdeb/DEBIAN')
|
||||
generate_control_file(version)
|
||||
# Keyed on the EXPLICIT request, not on what happened to be staged: by here a --drm build has
|
||||
# its library in tmpdeb whichever of the two shapes it came from.
|
||||
if want_drm:
|
||||
retarget_control_to_drm_variant()
|
||||
system2('cp -a ../res/DEBIAN/* tmpdeb/DEBIAN/')
|
||||
md5_file_folder("tmpdeb/")
|
||||
system2('dpkg-deb -b tmpdeb rustdesk.deb;')
|
||||
@@ -399,6 +894,8 @@ def build_deb_from_folder(version, binary_folder):
|
||||
system2('/bin/rm -rf tmpdeb/')
|
||||
system2('/bin/rm -rf ../res/DEBIAN/control')
|
||||
os.rename('rustdesk.deb', '../rustdesk-%s.deb' % version)
|
||||
if want_drm:
|
||||
os.rename('../rustdesk-%s.deb' % version, f'../{DRM_PACKAGE_NAME}-{version}.deb')
|
||||
os.chdir("..")
|
||||
|
||||
|
||||
@@ -473,6 +970,19 @@ def main():
|
||||
parser = make_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
# Before anything with a side effect: this is a query, and a caller uses it to build the very
|
||||
# binary it will then package. `get_features` stays the single definition of what a flag
|
||||
# combination means; a caller that hardcodes the list instead is one edit away from compiling
|
||||
# something other than what it ships.
|
||||
if args.print_features:
|
||||
# stdout carries the list and nothing else, so a caller can use it directly in a command
|
||||
# substitution. `get_features` prints a human-readable line of its own; send that to stderr
|
||||
# for this call rather than silencing it, which would change what every other path prints.
|
||||
with contextlib.redirect_stdout(sys.stderr):
|
||||
feats = ','.join(get_features(args))
|
||||
print(feats)
|
||||
return
|
||||
|
||||
if os.path.exists(exe_path):
|
||||
os.unlink(exe_path)
|
||||
if os.path.isfile('/usr/bin/pacman'):
|
||||
@@ -488,7 +998,7 @@ def main():
|
||||
portable = args.portable
|
||||
package = args.package
|
||||
if package:
|
||||
build_deb_from_folder(version, package)
|
||||
build_deb_from_folder(version, package, args.drm)
|
||||
return
|
||||
res_dir = 'resources'
|
||||
external_resources(flutter, args, res_dir)
|
||||
|
||||
@@ -107,7 +107,7 @@ Violating these terms may lead to a permanent ban.
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Contributing to RustDesk
|
||||
|
||||
RustDesk welcomes contribution from everyone. Here are the guidelines if you are
|
||||
RustDesk welcomes contributions from everyone. Here are the guidelines if you are
|
||||
thinking of helping us:
|
||||
|
||||
## Contributions
|
||||
|
||||
@@ -106,6 +106,16 @@ class MainActivity : FlutterActivity() {
|
||||
|
||||
override fun onDestroy() {
|
||||
Log.e(logTag, "onDestroy")
|
||||
// The process can outlive the UI whenever something keeps it alive:
|
||||
// MainService, or the accessibility InputService on its own. Only the
|
||||
// former gets onTaskRemoved, so close outgoing sessions here too,
|
||||
// otherwise a session survives with no UI left to close it.
|
||||
// `isFinishing` distinguishes the user really leaving from a destroy
|
||||
// for recreation (configuration change, "don't keep activities"),
|
||||
// which must not tear down a live session.
|
||||
if (isFinishing) {
|
||||
FFI.closeAllSessions()
|
||||
}
|
||||
mainService?.let {
|
||||
unbindService(serviceConnection)
|
||||
}
|
||||
|
||||
@@ -254,6 +254,16 @@ class MainService : Service() {
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
// Swiping the app away from recents destroys the UI but this service keeps
|
||||
// the process alive, so outgoing sessions would stay connected with no way
|
||||
// to close them. Incoming connections are unaffected: the service keeps
|
||||
// running so the device stays reachable.
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
Log.d(logTag, "onTaskRemoved, closing outgoing sessions")
|
||||
FFI.closeAllSessions()
|
||||
super.onTaskRemoved(rootIntent)
|
||||
}
|
||||
|
||||
private var isHalfScale: Boolean? = null;
|
||||
private fun updateScreenInfo(orientation: Int) {
|
||||
var w: Int
|
||||
|
||||
@@ -21,6 +21,7 @@ object FFI {
|
||||
external fun onAudioFrameUpdate(buf: ByteBuffer)
|
||||
external fun translateLocale(localeName: String, input: String): String
|
||||
external fun refreshScreen()
|
||||
external fun closeAllSessions()
|
||||
external fun setFrameRawEnable(name: String, value: Boolean)
|
||||
external fun setCodecInfo(info: String)
|
||||
external fun getLocalOption(key: String): String
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CLIENT_ID</key>
|
||||
<string>768133699366-k1rn3ls1u2n3nklmgd9t4cmpdob0c8bn.apps.googleusercontent.com</string>
|
||||
<key>REVERSED_CLIENT_ID</key>
|
||||
<string>com.googleusercontent.apps.768133699366-k1rn3ls1u2n3nklmgd9t4cmpdob0c8bn</string>
|
||||
<key>API_KEY</key>
|
||||
<string>AIzaSyCf57HjCwSokt91CqFI0Mwf8D--ek0jvfc</string>
|
||||
<key>GCM_SENDER_ID</key>
|
||||
<string>768133699366</string>
|
||||
<key>PLIST_VERSION</key>
|
||||
<string>1</string>
|
||||
<key>BUNDLE_ID</key>
|
||||
<string>com.carriez.flutterHbb</string>
|
||||
<key>PROJECT_ID</key>
|
||||
<string>rustdesk</string>
|
||||
<key>STORAGE_BUCKET</key>
|
||||
<string>rustdesk.appspot.com</string>
|
||||
<key>IS_ADS_ENABLED</key>
|
||||
<false></false>
|
||||
<key>IS_ANALYTICS_ENABLED</key>
|
||||
<false></false>
|
||||
<key>IS_APPINVITE_ENABLED</key>
|
||||
<true></true>
|
||||
<key>IS_GCM_ENABLED</key>
|
||||
<true></true>
|
||||
<key>IS_SIGNIN_ENABLED</key>
|
||||
<true></true>
|
||||
<key>GOOGLE_APP_ID</key>
|
||||
<string>1:768133699366:ios:c33078a6181b9d507993e7</string>
|
||||
<key>DATABASE_URL</key>
|
||||
<string>https://rustdesk.firebaseio.com</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -3124,6 +3124,15 @@ void onCopyFingerprint(String value) {
|
||||
}
|
||||
}
|
||||
|
||||
void onCopyId(String value) {
|
||||
if (value.isNotEmpty) {
|
||||
Clipboard.setData(ClipboardData(text: value));
|
||||
showToast('$value\n${translate("Copied")}');
|
||||
} else {
|
||||
showToast(translate("Invalid ID"));
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> callMainCheckSuperUserPermission() async {
|
||||
bool checked = await bind.mainCheckSuperUserPermission();
|
||||
if (isMacOS) {
|
||||
@@ -4004,6 +4013,11 @@ bool whitelistNotEmpty() {
|
||||
return v != '' && v != ',';
|
||||
}
|
||||
|
||||
bool idWhitelistNotEmpty() {
|
||||
final v = bind.mainGetOptionSync(key: kOptionIdWhitelist);
|
||||
return v != '' && v != ',';
|
||||
}
|
||||
|
||||
// `setMovable()` is only supported on macOS.
|
||||
//
|
||||
// On macOS, the window can be dragged by the tab bar by default.
|
||||
@@ -4034,7 +4048,8 @@ Widget netWorkErrorWidget() {
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(translate("network_error_tip")),
|
||||
if (!gFFI.userModel.networkErrorFromServer.value)
|
||||
Text(translate("network_error_tip")),
|
||||
ElevatedButton(
|
||||
onPressed: gFFI.userModel.refreshCurrentUser,
|
||||
child: Text(translate("Retry")))
|
||||
|
||||
@@ -205,6 +205,10 @@ void changeWhiteList({Function()? callback}) async {
|
||||
const SizedBox(
|
||||
height: 8.0,
|
||||
),
|
||||
Text(translate("whitelist_cidr_tip")),
|
||||
const SizedBox(
|
||||
height: 8.0,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
@@ -282,6 +286,111 @@ void changeWhiteList({Function()? callback}) async {
|
||||
});
|
||||
}
|
||||
|
||||
void changeIdWhiteList({Function()? callback}) async {
|
||||
final curIdWhiteList = await bind.mainGetOption(key: kOptionIdWhitelist);
|
||||
var newIdWhiteListField = curIdWhiteList == defaultOptionWhitelist
|
||||
? ''
|
||||
: curIdWhiteList.split(',').join('\n');
|
||||
var controller = TextEditingController(text: newIdWhiteListField);
|
||||
var msg = "";
|
||||
var isInProgress = false;
|
||||
final isOptFixed = isOptionFixed(kOptionIdWhitelist);
|
||||
gFFI.dialogManager.show((setState, close, context) {
|
||||
return CustomAlertDialog(
|
||||
title: Text(translate("ID whitelisting")),
|
||||
content: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(translate("whitelist_sep")),
|
||||
const SizedBox(
|
||||
height: 8.0,
|
||||
),
|
||||
Text(translate("id_whitelist_wildcard_tip")),
|
||||
const SizedBox(
|
||||
height: 8.0,
|
||||
),
|
||||
Text(translate("id_whitelist_caveat_tip")),
|
||||
const SizedBox(
|
||||
height: 8.0,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
maxLines: null,
|
||||
decoration: InputDecoration(
|
||||
errorText: msg.isEmpty ? null : translate(msg),
|
||||
),
|
||||
controller: controller,
|
||||
enabled: !isOptFixed,
|
||||
autofocus: true)
|
||||
.workaroundFreezeLinuxMint(),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(
|
||||
height: 4.0,
|
||||
),
|
||||
// NOT use Offstage to wrap LinearProgressIndicator
|
||||
if (isInProgress) const LinearProgressIndicator(),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
dialogButton("Cancel", onPressed: close, isOutline: true),
|
||||
if (!isOptFixed)
|
||||
dialogButton("Clear", onPressed: () async {
|
||||
await bind.mainSetOption(
|
||||
key: kOptionIdWhitelist, value: defaultOptionWhitelist);
|
||||
callback?.call();
|
||||
close();
|
||||
}, isOutline: true),
|
||||
if (!isOptFixed)
|
||||
dialogButton(
|
||||
"OK",
|
||||
onPressed: () async {
|
||||
setState(() {
|
||||
msg = "";
|
||||
isInProgress = true;
|
||||
});
|
||||
newIdWhiteListField = controller.text.trim();
|
||||
var newIdWhiteList = "";
|
||||
if (newIdWhiteListField.isEmpty) {
|
||||
// pass
|
||||
} else {
|
||||
final ids = newIdWhiteListField
|
||||
.trim()
|
||||
.split(RegExp(r"[\s,;\n]+"))
|
||||
.where((e) => e.isNotEmpty)
|
||||
.toList();
|
||||
// Separators are handled above; allow all other Unicode characters.
|
||||
for (final id in ids) {
|
||||
final hasControlCharacters = id.runes.any(
|
||||
(char) => char <= 0x1f || (char >= 0x7f && char <= 0x9f));
|
||||
if (hasControlCharacters) {
|
||||
msg = "${translate("Invalid ID")} $id";
|
||||
setState(() {
|
||||
isInProgress = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
newIdWhiteList = ids.join(',');
|
||||
}
|
||||
if (newIdWhiteList.trim().isEmpty) {
|
||||
newIdWhiteList = defaultOptionWhitelist;
|
||||
}
|
||||
await bind.mainSetOption(
|
||||
key: kOptionIdWhitelist, value: newIdWhiteList);
|
||||
callback?.call();
|
||||
close();
|
||||
},
|
||||
),
|
||||
],
|
||||
onCancel: close,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<String> changeDirectAccessPort(
|
||||
String currentIP, String currentPort) async {
|
||||
final controller = TextEditingController(text: currentPort);
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hbb/common/hbbs/hbbs.dart';
|
||||
import 'package:flutter_hbb/models/platform_model.dart';
|
||||
import 'package:flutter_hbb/models/user_model.dart';
|
||||
@@ -11,6 +12,7 @@ import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../common.dart';
|
||||
import './dialog.dart';
|
||||
import './oidc_auth_status.dart';
|
||||
|
||||
const kOpSvgList = [
|
||||
'github',
|
||||
@@ -23,6 +25,8 @@ const kOpSvgList = [
|
||||
'auth0',
|
||||
'microsoft'
|
||||
];
|
||||
const _requestingAccountAuth = 'Requesting account auth';
|
||||
const _waitingAccountAuth = 'Waiting account auth';
|
||||
|
||||
class _OidcProviderBranding {
|
||||
final String label;
|
||||
@@ -90,6 +94,7 @@ class ButtonOP extends StatelessWidget {
|
||||
final Color primaryColor;
|
||||
final double height;
|
||||
final Function() onTap;
|
||||
final bool Function() canStartAuth;
|
||||
|
||||
const ButtonOP({
|
||||
Key? key,
|
||||
@@ -99,6 +104,7 @@ class ButtonOP extends StatelessWidget {
|
||||
required this.primaryColor,
|
||||
required this.height,
|
||||
required this.onTap,
|
||||
required this.canStartAuth,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
@@ -111,11 +117,10 @@ class ButtonOP extends StatelessWidget {
|
||||
width: 200,
|
||||
child: Obx(() => ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: curOP.value.isEmpty || curOP.value == op
|
||||
? primaryColor
|
||||
: Colors.grey,
|
||||
backgroundColor: primaryColor,
|
||||
).copyWith(elevation: ButtonStyleButton.allOrNull(0.0)),
|
||||
onPressed: curOP.value.isEmpty || curOP.value == op ? onTap : null,
|
||||
onPressed:
|
||||
curOP.value == 'rustdesk' || !canStartAuth() ? null : onTap,
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
@@ -145,15 +150,120 @@ class ConfigOP {
|
||||
ConfigOP({required this.op, required this.icon});
|
||||
}
|
||||
|
||||
class _OidcAuthController {
|
||||
final RxString curOP = ''.obs;
|
||||
Future<void> _pendingOperation = Future<void>.value();
|
||||
int _authAttempt = 0;
|
||||
bool _closed = false;
|
||||
final _cancelInProgress = false.obs;
|
||||
|
||||
bool _isCurrent(int authAttempt, String op) {
|
||||
return !_closed && authAttempt == _authAttempt && curOP.value == op;
|
||||
}
|
||||
|
||||
Future<bool> start(String op) {
|
||||
if (!canStart()) {
|
||||
return Future<bool>.value(false);
|
||||
}
|
||||
final authAttempt = ++_authAttempt;
|
||||
curOP.value = op;
|
||||
// Web auth must start during the original user gesture so popups are allowed.
|
||||
if (isWeb) {
|
||||
return _startWeb(authAttempt, op);
|
||||
}
|
||||
final completer = Completer<bool>();
|
||||
_pendingOperation = _pendingOperation.then((_) async {
|
||||
if (!_isCurrent(authAttempt, op)) {
|
||||
completer.complete(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await bind.mainAccountAuthCancel();
|
||||
if (!_isCurrent(authAttempt, op)) {
|
||||
completer.complete(false);
|
||||
return;
|
||||
}
|
||||
await bind.mainAccountAuth(op: op, rememberMe: true);
|
||||
completer.complete(_isCurrent(authAttempt, op));
|
||||
} catch (error, stackTrace) {
|
||||
completer.completeError(error, stackTrace);
|
||||
}
|
||||
});
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
Future<bool> _startWeb(int authAttempt, String op) async {
|
||||
await bind.mainAccountAuth(op: op, rememberMe: true);
|
||||
return _isCurrent(authAttempt, op);
|
||||
}
|
||||
|
||||
bool canStart() {
|
||||
return !_closed && !_cancelInProgress.value;
|
||||
}
|
||||
|
||||
Future<bool> cancelCurrent(String op) {
|
||||
if (!canStart() || curOP.value != op) {
|
||||
return Future<bool>.value(false);
|
||||
}
|
||||
final authAttempt = ++_authAttempt;
|
||||
final completer = Completer<bool>();
|
||||
_cancelInProgress.value = true;
|
||||
_pendingOperation = _pendingOperation.then((_) async {
|
||||
try {
|
||||
await bind.mainAccountAuthCancel();
|
||||
completer.complete(_isCurrent(authAttempt, op));
|
||||
} catch (error, stackTrace) {
|
||||
completer.completeError(error, stackTrace);
|
||||
} finally {
|
||||
_cancelInProgress.value = false;
|
||||
}
|
||||
});
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
Future<void> _cancelBackend() async {
|
||||
try {
|
||||
await bind.mainAccountAuthCancel();
|
||||
} catch (error, stackTrace) {
|
||||
debugPrint('Failed to cancel account authentication $error');
|
||||
debugPrintStack(stackTrace: stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> close() async {
|
||||
if (_closed) {
|
||||
return;
|
||||
}
|
||||
final hasActiveOidcAuth =
|
||||
curOP.value.isNotEmpty && curOP.value != 'rustdesk';
|
||||
_closed = true;
|
||||
_authAttempt++;
|
||||
curOP.value = '';
|
||||
if (hasActiveOidcAuth) {
|
||||
await _cancelBackend();
|
||||
}
|
||||
await _pendingOperation;
|
||||
if (hasActiveOidcAuth) {
|
||||
await _cancelBackend();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class WidgetOP extends StatefulWidget {
|
||||
final ConfigOP config;
|
||||
final RxString curOP;
|
||||
final Function(Map<String, dynamic>) cbLogin;
|
||||
final Future<bool> Function(String) startAuth;
|
||||
final Future<bool> Function(String) cancelAuth;
|
||||
final bool Function() canStartAuth;
|
||||
const WidgetOP({
|
||||
Key? key,
|
||||
required this.config,
|
||||
required this.curOP,
|
||||
required this.cbLogin,
|
||||
required this.startAuth,
|
||||
required this.cancelAuth,
|
||||
required this.canStartAuth,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
@@ -164,6 +274,8 @@ class WidgetOP extends StatefulWidget {
|
||||
|
||||
class _WidgetOPState extends State<WidgetOP> {
|
||||
Timer? _updateTimer;
|
||||
bool _isAuthStatusQueryInFlight = false;
|
||||
int _authAttempt = 0;
|
||||
String _stateMsg = '';
|
||||
String _failedMsg = '';
|
||||
String _url = '';
|
||||
@@ -174,55 +286,180 @@ class _WidgetOPState extends State<WidgetOP> {
|
||||
_updateTimer?.cancel();
|
||||
}
|
||||
|
||||
_beginQueryState() {
|
||||
_beginQueryState(int authAttempt) {
|
||||
_updateTimer?.cancel();
|
||||
unawaited(_runAuthStatusQuery(() => _updateState(authAttempt)));
|
||||
_updateTimer = Timer.periodic(Duration(seconds: 1), (timer) {
|
||||
_updateState();
|
||||
unawaited(_runAuthStatusQuery(() => _updateState(authAttempt)));
|
||||
});
|
||||
}
|
||||
|
||||
_updateState() {
|
||||
bind.mainAccountAuthResult().then((result) {
|
||||
if (result.isEmpty) {
|
||||
Future<void> _runAuthStatusQuery(Future<void> Function() query) async {
|
||||
if (_isAuthStatusQueryInFlight) {
|
||||
return;
|
||||
}
|
||||
_isAuthStatusQueryInFlight = true;
|
||||
try {
|
||||
await query();
|
||||
} finally {
|
||||
_isAuthStatusQueryInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _launchAuthUrl(String url) async {
|
||||
try {
|
||||
final launched = await launchUrl(
|
||||
Uri.parse(url),
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
if (!launched) {
|
||||
debugPrint('Failed to open OIDC authentication URL');
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
debugPrint(
|
||||
'Failed to open OIDC authentication URL (${error.runtimeType})');
|
||||
debugPrintStack(stackTrace: stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _copyAuthUrl(String url) async {
|
||||
try {
|
||||
await Clipboard.setData(ClipboardData(text: url));
|
||||
showToast(
|
||||
translate('Copied'),
|
||||
);
|
||||
} catch (error, stackTrace) {
|
||||
debugPrint(
|
||||
'Failed to copy OIDC authentication URL (${error.runtimeType})');
|
||||
debugPrintStack(stackTrace: stackTrace);
|
||||
showToast(translate('Failed'));
|
||||
}
|
||||
}
|
||||
|
||||
void _runCurrentAuthUrlAction(
|
||||
int authAttempt,
|
||||
String authUrl,
|
||||
Future<void> Function(String) action,
|
||||
) {
|
||||
if (!mounted ||
|
||||
authAttempt != _authAttempt ||
|
||||
widget.curOP.value != widget.config.op ||
|
||||
authUrl.isEmpty ||
|
||||
_url != authUrl) {
|
||||
return;
|
||||
}
|
||||
unawaited(action(authUrl));
|
||||
}
|
||||
|
||||
void _invalidateAuthAttempt() {
|
||||
_authAttempt++;
|
||||
_url = '';
|
||||
}
|
||||
|
||||
bool _isCurrentAuthAttempt(int authAttempt) {
|
||||
return mounted &&
|
||||
authAttempt == _authAttempt &&
|
||||
widget.curOP.value == widget.config.op;
|
||||
}
|
||||
|
||||
Future<void> _handleAuthFailure(
|
||||
int authAttempt,
|
||||
Object error,
|
||||
String operation,
|
||||
) async {
|
||||
debugPrint('Failed to $operation $error');
|
||||
if (!_isCurrentAuthAttempt(authAttempt)) {
|
||||
return;
|
||||
}
|
||||
_updateTimer?.cancel();
|
||||
setState(() => _failedMsg = 'Failed');
|
||||
try {
|
||||
final canceled = await widget.cancelAuth(widget.config.op);
|
||||
if (!canceled || !_isCurrentAuthAttempt(authAttempt)) {
|
||||
return;
|
||||
}
|
||||
} catch (cancelError, stackTrace) {
|
||||
debugPrint('Failed to cancel account authentication $cancelError');
|
||||
debugPrintStack(stackTrace: stackTrace);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_invalidateAuthAttempt();
|
||||
widget.curOP.value = '';
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _updateState(int authAttempt) {
|
||||
if (!mounted ||
|
||||
authAttempt != _authAttempt ||
|
||||
widget.curOP.value != widget.config.op) {
|
||||
_updateTimer?.cancel();
|
||||
return Future<void>.value();
|
||||
}
|
||||
return bind.mainAccountAuthResult().then<void>((result) {
|
||||
if (!mounted ||
|
||||
authAttempt != _authAttempt ||
|
||||
widget.curOP.value != widget.config.op ||
|
||||
result.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final resultMap = jsonDecode(result);
|
||||
if (resultMap == null) {
|
||||
return;
|
||||
}
|
||||
final String stateMsg = resultMap['state_msg'];
|
||||
final String backendStateMsg = resultMap['state_msg'];
|
||||
String failedMsg = resultMap['failed_msg'];
|
||||
final String? url = resultMap['url'];
|
||||
final stateMsg = backendStateMsg == _requestingAccountAuth &&
|
||||
(url == null || url.isEmpty)
|
||||
? _waitingAccountAuth
|
||||
: backendStateMsg;
|
||||
final bool urlLaunched = (resultMap['url_launched'] as bool?) ?? false;
|
||||
final authBody = resultMap['auth_body'];
|
||||
if (_stateMsg != stateMsg || _failedMsg != failedMsg) {
|
||||
if (_url.isEmpty && url != null && url.isNotEmpty) {
|
||||
if (!urlLaunched) {
|
||||
launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
|
||||
}
|
||||
_url = url;
|
||||
}
|
||||
if (authBody != null) {
|
||||
_updateTimer?.cancel();
|
||||
widget.curOP.value = '';
|
||||
widget.cbLogin(authBody as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_stateMsg = stateMsg;
|
||||
_failedMsg = failedMsg;
|
||||
if (failedMsg.isNotEmpty) {
|
||||
widget.curOP.value = '';
|
||||
_updateTimer?.cancel();
|
||||
}
|
||||
});
|
||||
if (authBody != null) {
|
||||
_updateTimer?.cancel();
|
||||
_invalidateAuthAttempt();
|
||||
widget.curOP.value = '';
|
||||
widget.cbLogin(authBody as Map<String, dynamic>);
|
||||
return;
|
||||
}
|
||||
});
|
||||
final stateChanged = _stateMsg != stateMsg || _failedMsg != failedMsg;
|
||||
final newUrl = _url.isEmpty && url != null && url.isNotEmpty ? url : null;
|
||||
if (!stateChanged && newUrl == null) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_stateMsg = stateMsg;
|
||||
_failedMsg = failedMsg;
|
||||
if (newUrl != null) {
|
||||
_url = newUrl;
|
||||
}
|
||||
if (failedMsg.isNotEmpty) {
|
||||
_invalidateAuthAttempt();
|
||||
widget.curOP.value = '';
|
||||
_updateTimer?.cancel();
|
||||
}
|
||||
});
|
||||
if (newUrl != null && failedMsg.isEmpty && !urlLaunched) {
|
||||
unawaited(_launchAuthUrl(newUrl));
|
||||
}
|
||||
}).catchError(
|
||||
(e) => _handleAuthFailure(
|
||||
authAttempt,
|
||||
e,
|
||||
'query account authentication',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_resetState() {
|
||||
_stateMsg = '';
|
||||
_failedMsg = '';
|
||||
_url = '';
|
||||
int _resetState() {
|
||||
_updateTimer?.cancel();
|
||||
setState(() {
|
||||
_invalidateAuthAttempt();
|
||||
_stateMsg = _waitingAccountAuth;
|
||||
_failedMsg = '';
|
||||
});
|
||||
return _authAttempt;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -235,11 +472,31 @@ class _WidgetOPState extends State<WidgetOP> {
|
||||
icon: widget.config.icon,
|
||||
primaryColor: str2color(widget.config.op, 0x7f),
|
||||
height: 36,
|
||||
canStartAuth: widget.canStartAuth,
|
||||
onTap: () async {
|
||||
_resetState();
|
||||
widget.curOP.value = widget.config.op;
|
||||
await bind.mainAccountAuth(op: widget.config.op, rememberMe: true);
|
||||
_beginQueryState();
|
||||
if (!widget.canStartAuth()) {
|
||||
return;
|
||||
}
|
||||
final authAttempt = _resetState();
|
||||
try {
|
||||
final started = await widget.startAuth(widget.config.op);
|
||||
if (!started) {
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
await _handleAuthFailure(
|
||||
authAttempt,
|
||||
e,
|
||||
'start account authentication',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!mounted ||
|
||||
authAttempt != _authAttempt ||
|
||||
widget.curOP.value != widget.config.op) {
|
||||
return;
|
||||
}
|
||||
_beginQueryState(authAttempt);
|
||||
},
|
||||
),
|
||||
Obx(() {
|
||||
@@ -247,6 +504,8 @@ class _WidgetOPState extends State<WidgetOP> {
|
||||
widget.curOP.value != widget.config.op) {
|
||||
_failedMsg = '';
|
||||
}
|
||||
final authAttempt = _authAttempt;
|
||||
final authUrl = _url;
|
||||
return Offstage(
|
||||
offstage:
|
||||
_failedMsg.isEmpty && widget.curOP.value != widget.config.op,
|
||||
@@ -256,19 +515,27 @@ class _WidgetOPState extends State<WidgetOP> {
|
||||
if (_stateMsg.isNotEmpty && _failedMsg.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: SelectableText(
|
||||
translate(_stateMsg),
|
||||
style: DefaultTextStyle.of(context)
|
||||
.style
|
||||
.copyWith(fontSize: 12),
|
||||
child: OidcAuthStatus(
|
||||
message: translate(_stateMsg),
|
||||
browserFallbackPrompt: translate(
|
||||
"Browser didn't open? Use the url below to sign in.",
|
||||
),
|
||||
authUrl: authUrl,
|
||||
copyLabel: translate('Copy to clipboard'),
|
||||
onCopy: authUrl.isEmpty
|
||||
? null
|
||||
: () => _runCurrentAuthUrlAction(
|
||||
authAttempt,
|
||||
authUrl,
|
||||
_copyAuthUrl,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_failedMsg.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Builder(builder: (context) {
|
||||
final errorColor =
|
||||
Theme.of(context).colorScheme.error;
|
||||
final errorColor = Theme.of(context).colorScheme.error;
|
||||
final bgColor = Theme.of(context)
|
||||
.colorScheme
|
||||
.errorContainer
|
||||
@@ -289,12 +556,11 @@ class _WidgetOPState extends State<WidgetOP> {
|
||||
Flexible(
|
||||
child: SelectableText(
|
||||
translate(_failedMsg),
|
||||
style: DefaultTextStyle.of(context)
|
||||
.style
|
||||
.copyWith(
|
||||
fontSize: 13,
|
||||
color: errorColor,
|
||||
),
|
||||
style:
|
||||
DefaultTextStyle.of(context).style.copyWith(
|
||||
fontSize: 13,
|
||||
color: errorColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -306,34 +572,6 @@ class _WidgetOPState extends State<WidgetOP> {
|
||||
),
|
||||
);
|
||||
}),
|
||||
Obx(
|
||||
() => Offstage(
|
||||
offstage: widget.curOP.value != widget.config.op,
|
||||
child: const SizedBox(
|
||||
height: 5.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
Obx(
|
||||
() => Offstage(
|
||||
offstage: widget.curOP.value != widget.config.op,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: 20),
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
widget.curOP.value = '';
|
||||
_updateTimer?.cancel();
|
||||
_resetState();
|
||||
bind.mainAccountAuthCancel();
|
||||
},
|
||||
child: Text(
|
||||
translate('Cancel'),
|
||||
style: TextStyle(fontSize: 15),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -343,12 +581,18 @@ class LoginWidgetOP extends StatelessWidget {
|
||||
final List<ConfigOP> ops;
|
||||
final RxString curOP;
|
||||
final Function(Map<String, dynamic>) cbLogin;
|
||||
final Future<bool> Function(String) startAuth;
|
||||
final Future<bool> Function(String) cancelAuth;
|
||||
final bool Function() canStartAuth;
|
||||
|
||||
LoginWidgetOP({
|
||||
Key? key,
|
||||
required this.ops,
|
||||
required this.curOP,
|
||||
required this.cbLogin,
|
||||
required this.startAuth,
|
||||
required this.cancelAuth,
|
||||
required this.canStartAuth,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
@@ -359,6 +603,9 @@ class LoginWidgetOP extends StatelessWidget {
|
||||
config: op,
|
||||
curOP: curOP,
|
||||
cbLogin: cbLogin,
|
||||
startAuth: startAuth,
|
||||
cancelAuth: cancelAuth,
|
||||
canStartAuth: canStartAuth,
|
||||
),
|
||||
const Divider(
|
||||
indent: 5,
|
||||
@@ -436,12 +683,11 @@ class LoginWidgetUserPass extends StatelessWidget {
|
||||
translate('Login'),
|
||||
style: TextStyle(fontSize: 16),
|
||||
),
|
||||
onPressed:
|
||||
curOP.value.isEmpty || curOP.value == 'rustdesk'
|
||||
? () {
|
||||
onLogin();
|
||||
}
|
||||
: null,
|
||||
onPressed: curOP.value.isEmpty && !isInProgress
|
||||
? () {
|
||||
onLogin();
|
||||
}
|
||||
: null,
|
||||
)),
|
||||
),
|
||||
])),
|
||||
@@ -452,8 +698,28 @@ class LoginWidgetUserPass extends StatelessWidget {
|
||||
|
||||
const kAuthReqTypeOidc = 'oidc/';
|
||||
|
||||
Future<bool?>? _activeLoginDialog;
|
||||
|
||||
// call this directly
|
||||
Future<bool?> loginDialog() async {
|
||||
Future<bool?> loginDialog() {
|
||||
final activeDialog = _activeLoginDialog;
|
||||
if (activeDialog != null) {
|
||||
return activeDialog;
|
||||
}
|
||||
final dialog = _openLoginDialogOnce();
|
||||
_activeLoginDialog = dialog;
|
||||
return dialog;
|
||||
}
|
||||
|
||||
Future<bool?> _openLoginDialogOnce() async {
|
||||
try {
|
||||
return await _openLoginDialog();
|
||||
} finally {
|
||||
_activeLoginDialog = null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool?> _openLoginDialog() async {
|
||||
var username =
|
||||
TextEditingController(text: UserModel.getLocalUserInfo()?['name'] ?? '');
|
||||
var password = TextEditingController();
|
||||
@@ -463,14 +729,28 @@ Future<bool?> loginDialog() async {
|
||||
String? usernameMsg;
|
||||
String? passwordMsg;
|
||||
var isInProgress = false;
|
||||
final RxString curOP = ''.obs;
|
||||
final oidcAuth = _OidcAuthController();
|
||||
final curOP = oidcAuth.curOP;
|
||||
// Track hover state for the close icon
|
||||
bool isCloseHovered = false;
|
||||
|
||||
final loginOptions = [].obs;
|
||||
Future.delayed(Duration.zero, () async {
|
||||
loginOptions.value = await UserModel.queryOidcLoginOptions();
|
||||
});
|
||||
final loginOptionsError = Rxn<Object>();
|
||||
final loginOptionsInProgress = false.obs;
|
||||
fetchLoginOptions() async {
|
||||
loginOptionsInProgress.value = true;
|
||||
try {
|
||||
loginOptions.value = await UserModel.queryOidcLoginOptions();
|
||||
loginOptionsError.value = null;
|
||||
} catch (e) {
|
||||
debugPrint("queryOidcLoginOptions failed: $e");
|
||||
loginOptionsError.value = e;
|
||||
} finally {
|
||||
loginOptionsInProgress.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future.delayed(Duration.zero, fetchLoginOptions);
|
||||
|
||||
final res = await gFFI.dialogManager.show<bool>((setState, close, context) {
|
||||
username.addListener(() {
|
||||
@@ -544,6 +824,9 @@ Future<bool?> loginDialog() async {
|
||||
}
|
||||
|
||||
onLogin() async {
|
||||
if (curOP.value.isNotEmpty || isInProgress) {
|
||||
return;
|
||||
}
|
||||
// validate
|
||||
if (username.text.isEmpty) {
|
||||
setState(() => usernameMsg = translate('Username missed'));
|
||||
@@ -574,6 +857,36 @@ Future<bool?> loginDialog() async {
|
||||
}
|
||||
|
||||
thirdAuthWidget() => Obx(() {
|
||||
final error = loginOptionsError.value;
|
||||
final inProgress = loginOptionsInProgress.value;
|
||||
if (error != null) {
|
||||
return Column(
|
||||
children: [
|
||||
const SizedBox(height: 8.0),
|
||||
// NOT use Offstage to wrap LinearProgressIndicator
|
||||
if (inProgress) const LinearProgressIndicator(),
|
||||
if (!inProgress && error is! RequestException)
|
||||
Text(
|
||||
translate('network_error_tip'),
|
||||
style: const TextStyle(fontSize: 12),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
onPressed: inProgress ? null : fetchLoginOptions,
|
||||
child: Text(translate('Retry')),
|
||||
),
|
||||
if (!inProgress)
|
||||
SelectableText(
|
||||
error.toString(),
|
||||
style: const TextStyle(fontSize: 11, color: Colors.red),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return Offstage(
|
||||
offstage: loginOptions.isEmpty,
|
||||
child: Column(
|
||||
@@ -594,6 +907,9 @@ Future<bool?> loginDialog() async {
|
||||
.map((e) => ConfigOP(op: e['name'], icon: e['icon']))
|
||||
.toList(),
|
||||
curOP: curOP,
|
||||
startAuth: oidcAuth.start,
|
||||
cancelAuth: oidcAuth.cancelCurrent,
|
||||
canStartAuth: oidcAuth.canStart,
|
||||
cbLogin: (Map<String, dynamic> authBody) async {
|
||||
LoginResponse? resp;
|
||||
try {
|
||||
@@ -675,7 +991,7 @@ Future<bool?> loginDialog() async {
|
||||
onCancel: onDialogCancel,
|
||||
onSubmit: onLogin,
|
||||
);
|
||||
});
|
||||
}).whenComplete(oidcAuth.close);
|
||||
|
||||
if (res != null) {
|
||||
await UserModel.updateOtherModels();
|
||||
|
||||
157
flutter/lib/common/widgets/oidc_auth_status.dart
Normal file
157
flutter/lib/common/widgets/oidc_auth_status.dart
Normal file
@@ -0,0 +1,157 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
const _statusFontSize = 12.0;
|
||||
const _statusSpacing = 4.0;
|
||||
const _messageActionSpacing = 8.0;
|
||||
const _desktopActionSize = 28.0;
|
||||
const _touchPlatforms = <TargetPlatform>{
|
||||
TargetPlatform.android,
|
||||
TargetPlatform.iOS,
|
||||
TargetPlatform.fuchsia,
|
||||
};
|
||||
|
||||
class OidcAuthStatus extends StatelessWidget {
|
||||
final String message;
|
||||
final String browserFallbackPrompt;
|
||||
final String authUrl;
|
||||
final String copyLabel;
|
||||
final VoidCallback? onCopy;
|
||||
|
||||
const OidcAuthStatus({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.browserFallbackPrompt,
|
||||
required this.authUrl,
|
||||
required this.copyLabel,
|
||||
this.onCopy,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final messageStyle =
|
||||
DefaultTextStyle.of(context).style.copyWith(fontSize: _statusFontSize);
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SelectableText(message, style: messageStyle),
|
||||
if (authUrl.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: _messageActionSpacing),
|
||||
child: _OidcAuthFallback(
|
||||
browserFallbackPrompt: browserFallbackPrompt,
|
||||
authUrl: authUrl,
|
||||
copyLabel: copyLabel,
|
||||
onCopy: onCopy,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OidcAuthFallback extends StatefulWidget {
|
||||
final String browserFallbackPrompt;
|
||||
final String authUrl;
|
||||
final String copyLabel;
|
||||
final VoidCallback? onCopy;
|
||||
|
||||
const _OidcAuthFallback({
|
||||
required this.browserFallbackPrompt,
|
||||
required this.authUrl,
|
||||
required this.copyLabel,
|
||||
required this.onCopy,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_OidcAuthFallback> createState() => _OidcAuthFallbackState();
|
||||
}
|
||||
|
||||
class _OidcAuthFallbackState extends State<_OidcAuthFallback> {
|
||||
bool _expanded = false;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _OidcAuthFallback oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.authUrl != widget.authUrl) {
|
||||
_expanded = false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final helperStyle = DefaultTextStyle.of(context).style.copyWith(
|
||||
fontSize: _statusFontSize,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
);
|
||||
final linkColor = theme.brightness == Brightness.dark
|
||||
? Colors.blue.shade300
|
||||
: Colors.blue.shade800;
|
||||
final isTouchPlatform = _touchPlatforms.contains(theme.platform);
|
||||
final actionSize =
|
||||
isTouchPlatform ? kMinInteractiveDimension : _desktopActionSize;
|
||||
final urlStyle =
|
||||
DefaultTextStyle.of(context).style.copyWith(fontSize: _statusFontSize);
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
widget.browserFallbackPrompt,
|
||||
style: helperStyle,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: _statusSpacing),
|
||||
child: _buildUrl(urlStyle, linkColor, actionSize),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _copyAndExpand() {
|
||||
setState(() => _expanded = true);
|
||||
widget.onCopy?.call();
|
||||
}
|
||||
|
||||
Widget _buildUrl(TextStyle urlStyle, Color linkColor, double actionSize) {
|
||||
final collapsedUrl = SizedBox(
|
||||
width: double.infinity,
|
||||
child: TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: linkColor,
|
||||
minimumSize: Size(0, actionSize),
|
||||
padding: EdgeInsets.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity: VisualDensity.standard,
|
||||
),
|
||||
onPressed: _copyAndExpand,
|
||||
child: Text(
|
||||
widget.authUrl,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
softWrap: false,
|
||||
style: urlStyle.copyWith(
|
||||
color: linkColor,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
final collapsedChild = widget.onCopy == null
|
||||
? collapsedUrl
|
||||
: Tooltip(message: widget.copyLabel, child: collapsedUrl);
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
constraints: BoxConstraints(minHeight: actionSize),
|
||||
alignment: Alignment.centerLeft,
|
||||
padding: const EdgeInsets.symmetric(horizontal: _messageActionSpacing),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Theme.of(context).dividerColor),
|
||||
borderRadius: BorderRadius.circular(_statusSpacing),
|
||||
),
|
||||
child: _expanded
|
||||
? SelectableText(widget.authUrl, style: urlStyle)
|
||||
: collapsedChild,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -583,6 +583,7 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
||||
}
|
||||
// record
|
||||
if (!(isDesktop || isWeb) &&
|
||||
bind.mainGetLocalOption(key: kOptionHideRecordingButton) != 'Y' &&
|
||||
(ffi.recordingModel.start || (perms["recording"] != false))) {
|
||||
v.add(TTextMenu(
|
||||
child: Row(
|
||||
|
||||
@@ -88,6 +88,7 @@ const String kOptionEdgeScrollEdgeThickness = "edge-scroll-edge-thickness";
|
||||
const String kOptionImageQuality = "image_quality";
|
||||
const String kOptionOpenNewConnInTabs = "enable-open-new-connections-in-tabs";
|
||||
const String kOptionTextureRender = "use-texture-render";
|
||||
const String kOptionTextureRenderHealth = "texture-render-health";
|
||||
const String kOptionD3DRender = "allow-d3d-render";
|
||||
const String kOptionOpenInTabs = "allow-open-in-tabs";
|
||||
const String kOptionOpenInWindows = "allow-open-in-windows";
|
||||
@@ -95,6 +96,7 @@ const String kOptionForceAlwaysRelay = "force-always-relay";
|
||||
const String kOptionViewOnly = "view_only";
|
||||
const String kOptionEnableLanDiscovery = "enable-lan-discovery";
|
||||
const String kOptionWhitelist = "whitelist";
|
||||
const String kOptionIdWhitelist = "id-whitelist";
|
||||
const String kOptionEnableAbr = "enable-abr";
|
||||
const String kOptionEnableRecordSession = "enable-record-session";
|
||||
const String kOptionDirectServer = "direct-server";
|
||||
@@ -104,6 +106,7 @@ const String kOptionAutoDisconnectTimeout = "auto-disconnect-timeout";
|
||||
const String kOptionEnableHwcodec = "enable-hwcodec";
|
||||
const String kOptionAllowAutoRecordIncoming = "allow-auto-record-incoming";
|
||||
const String kOptionAllowAutoRecordOutgoing = "allow-auto-record-outgoing";
|
||||
const String kOptionHideRecordingButton = "hide-recording-button";
|
||||
const String kOptionVideoSaveDirectory = "video-save-directory";
|
||||
const String kOptionAccessMode = "access-mode";
|
||||
const String kOptionEnableKeyboard = "enable-keyboard";
|
||||
@@ -177,6 +180,7 @@ 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";
|
||||
const String kOptionShowTerminalCtrlKeys = "show-terminal-extra-ctrl-keys";
|
||||
|
||||
// network options
|
||||
const String kOptionAllowWebSocket = "allow-websocket";
|
||||
|
||||
@@ -25,6 +25,7 @@ import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
import 'package:window_size/window_size.dart' as window_size;
|
||||
import '../widgets/button.dart';
|
||||
import '../widgets/texture_render_probe.dart';
|
||||
|
||||
class DesktopHomePage extends StatefulWidget {
|
||||
const DesktopHomePage({Key? key}) : super(key: key);
|
||||
@@ -60,15 +61,20 @@ class _DesktopHomePageState extends State<DesktopHomePage>
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
final isIncomingOnly = bind.isIncomingOnly();
|
||||
return _buildBlock(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
return Stack(
|
||||
children: [
|
||||
buildLeftPane(context),
|
||||
if (!isIncomingOnly) const VerticalDivider(width: 1),
|
||||
if (!isIncomingOnly) Expanded(child: buildRightPane(context)),
|
||||
_buildBlock(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
buildLeftPane(context),
|
||||
if (!isIncomingOnly) const VerticalDivider(width: 1),
|
||||
if (!isIncomingOnly) Expanded(child: buildRightPane(context)),
|
||||
],
|
||||
)),
|
||||
const Positioned(left: 0, top: 0, child: TextureRenderProbe()),
|
||||
],
|
||||
));
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBlock({required Widget child}) {
|
||||
|
||||
@@ -485,7 +485,8 @@ class _GeneralState extends State<_General> {
|
||||
Widget other() {
|
||||
final incomingOnly = bind.isIncomingOnly();
|
||||
final outgoingOnly = bind.isOutgoingOnly();
|
||||
final showAutoUpdate = isWindows && bind.mainIsInstalled();
|
||||
final showAutoUpdate = (isWindows && bind.mainIsInstalled()) ||
|
||||
(isMacOS && bind.mainIsInstalled() && bind.mainIsInstalledDaemon(prompt: false) && !bind.isCustomClient());
|
||||
final children = <Widget>[
|
||||
if (!isWeb && !incomingOnly)
|
||||
_OptionCheckBox(context, 'Confirm before closing multiple tabs',
|
||||
@@ -1297,6 +1298,7 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
|
||||
reverse: true, enabled: enabled),
|
||||
...directIp(context),
|
||||
whitelist(),
|
||||
idWhitelist(),
|
||||
...autoDisconnect(context),
|
||||
_OptionCheckBox(context, 'keep-awake-during-incoming-sessions-label',
|
||||
kOptionKeepAwakeDuringIncomingSessions,
|
||||
@@ -1454,6 +1456,52 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
|
||||
return tmpWrapper();
|
||||
}
|
||||
|
||||
Widget idWhitelist() {
|
||||
bool enabled = !locked;
|
||||
RxBool hasIdWhitelist = idWhitelistNotEmpty().obs;
|
||||
update() async {
|
||||
hasIdWhitelist.value = idWhitelistNotEmpty();
|
||||
}
|
||||
|
||||
onChanged(bool? checked) async {
|
||||
changeIdWhiteList(callback: update);
|
||||
}
|
||||
|
||||
final isOptFixed = isOptionFixed(kOptionIdWhitelist);
|
||||
return GestureDetector(
|
||||
child: Tooltip(
|
||||
message: translate('id_whitelist_tip'),
|
||||
child: Obx(() => Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: hasIdWhitelist.value,
|
||||
onChanged: enabled && !isOptFixed ? onChanged : null)
|
||||
.marginOnly(right: 5),
|
||||
Offstage(
|
||||
offstage: !hasIdWhitelist.value,
|
||||
child: MouseRegion(
|
||||
child: const Icon(Icons.warning_amber_rounded,
|
||||
color: Color.fromARGB(255, 255, 204, 0))
|
||||
.marginOnly(right: 5),
|
||||
cursor: SystemMouseCursors.click,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
translate('Use ID whitelisting'),
|
||||
style: TextStyle(color: disabledTextColor(context, enabled)),
|
||||
))
|
||||
],
|
||||
)),
|
||||
),
|
||||
onTap: enabled
|
||||
? () {
|
||||
onChanged(!hasIdWhitelist.value);
|
||||
}
|
||||
: null,
|
||||
).marginOnly(left: _kCheckBoxLeftMargin);
|
||||
}
|
||||
|
||||
Widget hide_cm(bool enabled) {
|
||||
return ChangeNotifierProvider.value(
|
||||
value: gFFI.serverModel,
|
||||
@@ -2414,17 +2462,20 @@ class _AboutState extends State<_About> {
|
||||
final version = await bind.mainGetVersion();
|
||||
final buildDate = await bind.mainGetBuildDate();
|
||||
final fingerprint = await bind.mainGetFingerprint();
|
||||
final myId = await bind.mainGetMyId();
|
||||
return {
|
||||
'license': license,
|
||||
'version': version,
|
||||
'buildDate': buildDate,
|
||||
'fingerprint': fingerprint
|
||||
'fingerprint': fingerprint,
|
||||
'myId': myId
|
||||
};
|
||||
}(), hasData: (data) {
|
||||
final license = data['license'].toString();
|
||||
final version = data['version'].toString();
|
||||
final buildDate = data['buildDate'].toString();
|
||||
final fingerprint = data['fingerprint'].toString();
|
||||
final myId = data['myId'].toString();
|
||||
const linkStyle = TextStyle(decoration: TextDecoration.underline);
|
||||
final scrollController = ScrollController();
|
||||
return SingleChildScrollView(
|
||||
@@ -2446,6 +2497,9 @@ class _AboutState extends State<_About> {
|
||||
SelectionArea(
|
||||
child: Text('${translate('Fingerprint')}: $fingerprint')
|
||||
.marginSymmetric(vertical: 4.0)),
|
||||
SelectionArea(
|
||||
child: Text('${translate('ID')}: $myId')
|
||||
.marginSymmetric(vertical: 4.0)),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
launchUrlString('https://rustdesk.com/privacy.html');
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
class MacOSFullScreenFocusRecovery {
|
||||
int _generation = 0;
|
||||
int? _pendingGeneration;
|
||||
|
||||
int? get pendingGeneration => _pendingGeneration;
|
||||
|
||||
int queue() {
|
||||
_generation += 1;
|
||||
_pendingGeneration = _generation;
|
||||
return _generation;
|
||||
}
|
||||
|
||||
void cancel() {
|
||||
_pendingGeneration = null;
|
||||
}
|
||||
|
||||
bool isCurrent(int generation) => _pendingGeneration == generation;
|
||||
|
||||
bool consume(int generation) {
|
||||
if (!isCurrent(generation)) return false;
|
||||
_pendingGeneration = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,9 @@ import '../../common/shared_state.dart';
|
||||
import '../../utils/image.dart';
|
||||
import '../widgets/remote_toolbar.dart';
|
||||
import '../widgets/kb_layout_type_chooser.dart';
|
||||
import '../widgets/raster_stall_monitor.dart';
|
||||
import '../widgets/tabbar_widget.dart';
|
||||
import 'macos_full_screen_focus_recovery.dart';
|
||||
|
||||
import 'package:flutter_hbb/native/custom_cursor.dart'
|
||||
if (dart.library.html) 'package:flutter_hbb/web/custom_cursor.dart';
|
||||
@@ -64,6 +66,13 @@ class RemotePage extends StatefulWidget {
|
||||
|
||||
FFI get ffi => (_lastState.value! as _RemotePageState)._ffi;
|
||||
|
||||
void releaseMacOSInputForTabTransfer() {
|
||||
if (!isMacOS) return;
|
||||
// Release before removing the source tab. Its delayed disposal must not
|
||||
// disable a native keyboard hook already acquired by the destination page.
|
||||
(_lastState.value! as _RemotePageState)._releaseMacOSRemoteInput();
|
||||
}
|
||||
|
||||
@override
|
||||
State<RemotePage> createState() {
|
||||
final state = _RemotePageState(id);
|
||||
@@ -76,10 +85,28 @@ class _RemotePageState extends State<RemotePage>
|
||||
with
|
||||
AutomaticKeepAliveClientMixin,
|
||||
MultiWindowListener,
|
||||
WidgetsBindingObserver,
|
||||
TickerProviderStateMixin {
|
||||
Timer? _timer;
|
||||
String keyboardMode = "legacy";
|
||||
bool _isWindowBlur = false;
|
||||
// Known macOS remote-input trade-offs (kept simple intentionally):
|
||||
// 1. Dialogs rely on FocusNode loss plus middleBlocked, not mirrored dialog
|
||||
// state. Reproduce: activate remote input, open a dialog, then type.
|
||||
// 2. Delayed fullscreen recovery can race a local-control focus change; no
|
||||
// owner state is added. Reproduce: focus the toolbar during a Space switch.
|
||||
// 3. Input-source switching releases native input without updating this
|
||||
// page's cache. Reproduce: switch sources, then type before and after
|
||||
// clicking the remote image; the click reasserts input.
|
||||
// These latches compensate for out-of-order macOS focus events. Treat them
|
||||
// as coupled when changing a transition or _syncMacOSKeyboardGrab().
|
||||
AppLifecycleState? _macOSLifecycleState;
|
||||
bool _macOSLocalFocusLost = false;
|
||||
bool _macOSInputActive = false;
|
||||
bool _macOSInputSuppressed = false;
|
||||
final _macOSFullScreenFocusRecovery = MacOSFullScreenFocusRecovery();
|
||||
bool _macOSExplicitFocusRequestPending = false;
|
||||
StreamSubscription<DesktopTabState>? _tabStateSubscription;
|
||||
final _cursorOverImage = false.obs;
|
||||
late RxBool _showRemoteCursor;
|
||||
late RxBool _zoomCursor;
|
||||
@@ -122,7 +149,15 @@ class _RemotePageState extends State<RemotePage>
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ffi = FFI(widget.sessionId);
|
||||
if (isMacOS) {
|
||||
// SchedulerBinding.instance.lifecycleState is null in the first connection in a new window.
|
||||
_macOSLifecycleState = SchedulerBinding.instance.lifecycleState;
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_tabStateSubscription =
|
||||
widget.tabController?.state.listen(_onMacOSTabStateChanged);
|
||||
}
|
||||
Get.put<FFI>(_ffi, tag: widget.id);
|
||||
RasterStallMonitor.start();
|
||||
_ffi.imageModel.addCallbackOnFirstImage((String peerId) {
|
||||
_ffi.canvasModel.activateLocalCursor();
|
||||
showKBLayoutTypeChooserIfNeeded(
|
||||
@@ -231,19 +266,224 @@ class _RemotePageState extends State<RemotePage>
|
||||
_pointerLockCenterDebounceTimer = null;
|
||||
}
|
||||
|
||||
bool get _isSelectedTab {
|
||||
final controller = widget.tabController;
|
||||
if (controller == null) return true;
|
||||
final tabState = controller.state.value;
|
||||
final selected = tabState.selected;
|
||||
return selected >= 0 &&
|
||||
selected < tabState.tabs.length &&
|
||||
tabState.tabs[selected].key == widget.id;
|
||||
}
|
||||
|
||||
bool get _isMacOSKeyboardContextActive {
|
||||
return stateGlobal.isFocused.value && !_isWindowBlur && _isSelectedTab;
|
||||
}
|
||||
|
||||
void _onMacOSTabStateChanged(DesktopTabState _) {
|
||||
if (!_isSelectedTab) {
|
||||
_macOSFullScreenFocusRecovery.cancel();
|
||||
_syncMacOSKeyboardGrab();
|
||||
return;
|
||||
}
|
||||
// Tab listeners run synchronously. Defer the selected page so the previous
|
||||
// page releases first; a late leave from it can disable the new session.
|
||||
scheduleMicrotask(() {
|
||||
if (mounted) {
|
||||
_syncMacOSKeyboardGrab(reassert: true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _releaseMacOSRemoteInput() {
|
||||
_macOSFullScreenFocusRecovery.cancel();
|
||||
_macOSExplicitFocusRequestPending = false;
|
||||
_macOSInputSuppressed = true;
|
||||
_macOSLocalFocusLost = true;
|
||||
_ffi.inputModel.enterOrLeave(false);
|
||||
_macOSInputActive = false;
|
||||
_rawKeyFocusNode.unfocus();
|
||||
}
|
||||
|
||||
void _onMacOSFocusChange() {
|
||||
// requestFocus() notifies later; only a recorded explicit request may clear
|
||||
// the local-focus-loss latch.
|
||||
if (_rawKeyFocusNode.hasPrimaryFocus) {
|
||||
final explicitRequest = _macOSExplicitFocusRequestPending;
|
||||
_macOSExplicitFocusRequestPending = false;
|
||||
if (explicitRequest && _isMacOSKeyboardContextActive) {
|
||||
_macOSLocalFocusLost = false;
|
||||
}
|
||||
_syncMacOSKeyboardGrab(allowInactiveLifecycle: explicitRequest);
|
||||
} else {
|
||||
if (_macOSInputActive) {
|
||||
_ffi.inputModel.enterOrLeave(false);
|
||||
_macOSInputActive = false;
|
||||
}
|
||||
if (_isMacOSKeyboardContextActive) {
|
||||
_macOSLocalFocusLost = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Sync the keyboard grab state with the current context.
|
||||
// 2. Call enterOrLeave() to update the input state in the FFI layer.
|
||||
// 3. Request or unfocus the raw key focus node based on the current context.
|
||||
// Flutter focus and native input are separate; native input activates only
|
||||
// after the FocusNode has primary focus.
|
||||
void _syncMacOSKeyboardGrab({
|
||||
bool reassert = false,
|
||||
bool allowInactiveLifecycle = false,
|
||||
}) {
|
||||
if (!isMacOS) return;
|
||||
// A secondary engine may stay hidden while its window is visible, so
|
||||
// explicit pointer/fullscreen recovery must bypass the global lifecycle.
|
||||
final lifecycleAllowsInput = allowInactiveLifecycle ||
|
||||
_macOSLifecycleState == null ||
|
||||
_macOSLifecycleState == AppLifecycleState.resumed;
|
||||
// Input stays pointer-gated except for focused fullscreen recovery, which
|
||||
// compensates when macOS omits PointerEnter during a Space switch.
|
||||
final shouldFocus = lifecycleAllowsInput &&
|
||||
_isMacOSKeyboardContextActive &&
|
||||
!_macOSInputSuppressed &&
|
||||
_blockableOverlayState.middleBlocked.isFalse &&
|
||||
_cursorOverImage.value &&
|
||||
!_macOSLocalFocusLost;
|
||||
final hasFocus = _rawKeyFocusNode.hasPrimaryFocus;
|
||||
final shouldActivateInput = shouldFocus && hasFocus;
|
||||
|
||||
if (shouldActivateInput != _macOSInputActive ||
|
||||
(shouldActivateInput && reassert)) {
|
||||
_ffi.inputModel.enterOrLeave(shouldActivateInput);
|
||||
}
|
||||
_macOSInputActive = shouldActivateInput;
|
||||
|
||||
if (!shouldFocus) {
|
||||
_macOSExplicitFocusRequestPending = false;
|
||||
if (hasFocus) _rawKeyFocusNode.unfocus();
|
||||
} else if (!hasFocus) {
|
||||
_macOSExplicitFocusRequestPending = allowInactiveLifecycle;
|
||||
_rawKeyFocusNode.requestFocus();
|
||||
} else {
|
||||
_macOSExplicitFocusRequestPending = false;
|
||||
}
|
||||
}
|
||||
|
||||
void _restoreMacOSKeyboardAfterFullScreen({
|
||||
required int generation,
|
||||
bool allowHiddenLifecycle = false,
|
||||
}) {
|
||||
// Fullscreen callbacks preserve recovery while hidden. Native window focus
|
||||
// may bypass a stale hidden lifecycle for the newly visible Space.
|
||||
if (!_macOSFullScreenFocusRecovery.isCurrent(generation) ||
|
||||
(!allowHiddenLifecycle &&
|
||||
_macOSLifecycleState == AppLifecycleState.hidden)) {
|
||||
return;
|
||||
}
|
||||
final contextActive =
|
||||
stateGlobal.isFocused.value && !_isWindowBlur && _isSelectedTab;
|
||||
// macOS can focus a fullscreen Space without sending PointerEnter. Native
|
||||
// window focus is authoritative here; a later blur cancels this generation
|
||||
// before an off-screen window can restore input.
|
||||
final shouldInferPointerInside = !_cursorOverImage.value &&
|
||||
allowHiddenLifecycle &&
|
||||
stateGlobal.fullscreen.isTrue &&
|
||||
contextActive;
|
||||
final canRestore = contextActive &&
|
||||
_blockableOverlayState.middleBlocked.isFalse &&
|
||||
(_cursorOverImage.value || shouldInferPointerInside);
|
||||
if (!_macOSFullScreenFocusRecovery.consume(generation)) return;
|
||||
if (!canRestore) {
|
||||
// Consuming recovery here requires a later pointer/window/tab event.
|
||||
return;
|
||||
}
|
||||
if (shouldInferPointerInside) {
|
||||
_cursorOverImage.value = true;
|
||||
}
|
||||
_macOSLocalFocusLost = false;
|
||||
stateGlobal.getInputSource(force: true);
|
||||
_syncMacOSKeyboardGrab(reassert: true, allowInactiveLifecycle: true);
|
||||
}
|
||||
|
||||
void _scheduleMacOSKeyboardAfterFullScreen({
|
||||
required int generation,
|
||||
bool allowHiddenLifecycle = false,
|
||||
}) {
|
||||
// Fullscreen can deliver FocusNode loss after its callback; wait for frame
|
||||
// completion and then advance one event-loop turn before restoring.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
Timer.run(() {
|
||||
if (mounted) {
|
||||
_restoreMacOSKeyboardAfterFullScreen(
|
||||
generation: generation,
|
||||
allowHiddenLifecycle: allowHiddenLifecycle,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
WidgetsBinding.instance.ensureVisualUpdate();
|
||||
}
|
||||
|
||||
void _queueMacOSKeyboardAfterFullScreen({
|
||||
bool allowHiddenLifecycle = false,
|
||||
}) {
|
||||
final generation = _macOSFullScreenFocusRecovery.queue();
|
||||
if (_macOSLifecycleState == AppLifecycleState.paused ||
|
||||
_macOSLifecycleState == AppLifecycleState.detached) {
|
||||
_macOSFullScreenFocusRecovery.cancel();
|
||||
return;
|
||||
}
|
||||
_scheduleMacOSKeyboardAfterFullScreen(
|
||||
generation: generation,
|
||||
allowHiddenLifecycle: allowHiddenLifecycle,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
super.didChangeAppLifecycleState(state);
|
||||
if (!isMacOS || _macOSLifecycleState == state) return;
|
||||
_macOSLifecycleState = state;
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
_syncMacOSKeyboardGrab(reassert: true);
|
||||
} else if (_macOSInputActive) {
|
||||
_ffi.inputModel.enterOrLeave(false);
|
||||
_macOSInputActive = false;
|
||||
}
|
||||
|
||||
final generation = _macOSFullScreenFocusRecovery.pendingGeneration;
|
||||
if (generation == null) return;
|
||||
if (state == AppLifecycleState.inactive ||
|
||||
state == AppLifecycleState.resumed) {
|
||||
_scheduleMacOSKeyboardAfterFullScreen(generation: generation);
|
||||
} else if (state == AppLifecycleState.paused ||
|
||||
state == AppLifecycleState.detached) {
|
||||
_macOSFullScreenFocusRecovery.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowBlur() {
|
||||
super.onWindowBlur();
|
||||
// On windows, we use `focus` way to handle keyboard better.
|
||||
// Now on Linux, there's some rdev issues which will break the input.
|
||||
// We disable the `focus` way for non-Windows temporarily.
|
||||
if (isWindows) {
|
||||
// We disable the `focus` way for Linux temporarily.
|
||||
if (isWindows || isMacOS) {
|
||||
_isWindowBlur = true;
|
||||
}
|
||||
if (isMacOS) {
|
||||
_macOSFullScreenFocusRecovery.cancel();
|
||||
// A blur or Space switch may not emit PointerExit, so cursor state alone
|
||||
// cannot prevent the old remote surface from reclaiming the keyboard.
|
||||
_macOSLocalFocusLost = true;
|
||||
}
|
||||
if (isWindows) {
|
||||
// unfocus the primary-focus when the whole window is lost focus,
|
||||
// and let OS to handle events instead.
|
||||
_rawKeyFocusNode.unfocus();
|
||||
}
|
||||
stateGlobal.isFocused.value = false;
|
||||
_syncMacOSKeyboardGrab();
|
||||
|
||||
// When window loses focus, temporarily release relative mouse mode constraints
|
||||
// to allow user to interact with other applications normally.
|
||||
@@ -257,16 +497,41 @@ class _RemotePageState extends State<RemotePage>
|
||||
void onWindowFocus() {
|
||||
super.onWindowFocus();
|
||||
// See [onWindowBlur].
|
||||
if (isWindows) {
|
||||
if (isWindows || isMacOS) {
|
||||
_isWindowBlur = false;
|
||||
}
|
||||
if (isMacOS) stateGlobal.getInputSource(force: true);
|
||||
stateGlobal.isFocused.value = true;
|
||||
|
||||
// Normal macOS windows wait for PointerEnter or PointerDown. A focused
|
||||
// fullscreen Space queues delayed recovery; if this window blurs again, the
|
||||
// pending recovery is cancelled before native input can reactivate.
|
||||
// Regression: switch directly between fullscreen remote Spaces without
|
||||
// moving or clicking; only the newly focused session may receive input.
|
||||
if (isMacOS &&
|
||||
stateGlobal.fullscreen.isTrue &&
|
||||
!_ffi.inputModel.relativeMouseMode.value) {
|
||||
// Native window focus is authoritative when a secondary engine retains a
|
||||
// stale hidden lifecycle state after its fullscreen Space becomes visible.
|
||||
_queueMacOSKeyboardAfterFullScreen(allowHiddenLifecycle: true);
|
||||
}
|
||||
|
||||
// Restore relative mouse mode constraints when window regains focus.
|
||||
if (_ffi.inputModel.relativeMouseMode.value) {
|
||||
_rawKeyFocusNode.requestFocus();
|
||||
if (isMacOS) {
|
||||
// Native relative mode retains pointer capture and does not emit
|
||||
// PointerEnter after window focus returns. Restore both latches unless
|
||||
// a local overlay still owns input.
|
||||
if (_blockableOverlayState.middleBlocked.isFalse) {
|
||||
_cursorOverImage.value = true;
|
||||
_macOSLocalFocusLost = false;
|
||||
}
|
||||
} else {
|
||||
_rawKeyFocusNode.requestFocus();
|
||||
}
|
||||
_ffi.inputModel.onWindowFocus();
|
||||
}
|
||||
_syncMacOSKeyboardGrab(reassert: true, allowInactiveLifecycle: true);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -327,6 +592,13 @@ class _RemotePageState extends State<RemotePage>
|
||||
void onWindowMinimize() {
|
||||
super.onWindowMinimize();
|
||||
WakelockManager.disable(_uniqueKey);
|
||||
if (isMacOS) {
|
||||
_macOSFullScreenFocusRecovery.cancel();
|
||||
_isWindowBlur = true;
|
||||
_cursorOverImage.value = false;
|
||||
stateGlobal.isFocused.value = false;
|
||||
_syncMacOSKeyboardGrab();
|
||||
}
|
||||
// Release cursor constraints when minimized
|
||||
if (_ffi.inputModel.relativeMouseMode.value) {
|
||||
_ffi.inputModel.onWindowBlur();
|
||||
@@ -338,6 +610,7 @@ class _RemotePageState extends State<RemotePage>
|
||||
super.onWindowEnterFullScreen();
|
||||
if (isMacOS) {
|
||||
stateGlobal.setFullscreen(true);
|
||||
_queueMacOSKeyboardAfterFullScreen();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,6 +619,7 @@ class _RemotePageState extends State<RemotePage>
|
||||
super.onWindowLeaveFullScreen();
|
||||
if (isMacOS) {
|
||||
stateGlobal.setFullscreen(false);
|
||||
_queueMacOSKeyboardAfterFullScreen();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -354,6 +628,14 @@ class _RemotePageState extends State<RemotePage>
|
||||
final closeSession = closeSessionOnDispose.remove(widget.id) ?? true;
|
||||
|
||||
// https://github.com/flutter/flutter/issues/64935
|
||||
if (isMacOS) {
|
||||
// Tab moves release before transfer to avoid a late retained-session leave.
|
||||
if (closeSession) {
|
||||
_releaseMacOSRemoteInput();
|
||||
}
|
||||
_tabStateSubscription?.cancel();
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
}
|
||||
super.dispose();
|
||||
debugPrint("REMOTE PAGE dispose session $sessionId ${widget.id}");
|
||||
|
||||
@@ -367,9 +649,10 @@ class _RemotePageState extends State<RemotePage>
|
||||
// Clear callback reference to prevent memory leaks and stale references
|
||||
_ffi.inputModel.onRelativeMouseModeDisabled = null;
|
||||
// Relative mouse mode cleanup is centralized in FFI.close(closeSession: ...).
|
||||
_ffi.textureModel.onRemotePageDispose(closeSession);
|
||||
if (closeSession) {
|
||||
_ffi.textureModel.onRemotePageDispose();
|
||||
if (closeSession && !isMacOS) {
|
||||
// ensure we leave this session, this is a double check
|
||||
// enterOrLeave() is already called previously in _releaseMacOSRemoteInput() for macOS.
|
||||
_ffi.inputModel.enterOrLeave(false);
|
||||
}
|
||||
DesktopMultiWindow.removeListener(this);
|
||||
@@ -444,6 +727,8 @@ class _RemotePageState extends State<RemotePage>
|
||||
} else {
|
||||
_ffi.inputModel.enterOrLeave(false);
|
||||
}
|
||||
} else if (isMacOS) {
|
||||
_onMacOSFocusChange();
|
||||
}
|
||||
},
|
||||
inputModel: _ffi.inputModel,
|
||||
@@ -549,7 +834,11 @@ class _RemotePageState extends State<RemotePage>
|
||||
}
|
||||
|
||||
// See [onWindowBlur].
|
||||
if (!isWindows) {
|
||||
if (isMacOS) {
|
||||
_macOSLocalFocusLost = false;
|
||||
stateGlobal.getInputSource(force: true);
|
||||
_syncMacOSKeyboardGrab(reassert: true, allowInactiveLifecycle: true);
|
||||
} else if (!isWindows) {
|
||||
if (!_rawKeyFocusNode.hasFocus) {
|
||||
_rawKeyFocusNode.requestFocus();
|
||||
}
|
||||
@@ -575,7 +864,9 @@ class _RemotePageState extends State<RemotePage>
|
||||
}
|
||||
|
||||
// See [onWindowBlur].
|
||||
if (!isWindows) {
|
||||
if (isMacOS) {
|
||||
_syncMacOSKeyboardGrab();
|
||||
} else if (!isWindows) {
|
||||
_ffi.inputModel.enterOrLeave(false);
|
||||
}
|
||||
}
|
||||
@@ -600,17 +891,29 @@ class _RemotePageState extends State<RemotePage>
|
||||
onEnter: onEnter,
|
||||
onExit: onExit,
|
||||
onPointerDown: (event) {
|
||||
// A double check for blur status.
|
||||
// A double check for blur status on Windows and macOS.
|
||||
// Note: If there's an `onPointerDown` event is triggered, `_isWindowBlur` is expected being false.
|
||||
// Sometimes the system does not send the necessary focus event to flutter. We should manually
|
||||
// handle this inconsistent status by setting `_isWindowBlur` to false. So we can
|
||||
// ensure the grab-key thread is running when our users are clicking the remote canvas.
|
||||
if (_isWindowBlur) {
|
||||
if ((isWindows || isMacOS) && _isWindowBlur) {
|
||||
debugPrint(
|
||||
"Unexpected status: onPointerDown is triggered while the remote window is in blur status");
|
||||
_isWindowBlur = false;
|
||||
}
|
||||
if (!_rawKeyFocusNode.hasFocus) {
|
||||
if (isMacOS) {
|
||||
// Regions without matching enter/exit callbacks cannot safely own
|
||||
// keyboard state.
|
||||
if (onEnter == null || onExit == null) return;
|
||||
if (!stateGlobal.isFocused.value) {
|
||||
stateGlobal.isFocused.value = true;
|
||||
}
|
||||
_cursorOverImage.value = true;
|
||||
_macOSLocalFocusLost = false;
|
||||
stateGlobal.getInputSource(force: true);
|
||||
_syncMacOSKeyboardGrab(
|
||||
reassert: !isInputSourceFlutter, allowInactiveLifecycle: true);
|
||||
} else if (!_rawKeyFocusNode.hasFocus) {
|
||||
_rawKeyFocusNode.requestFocus();
|
||||
}
|
||||
},
|
||||
@@ -1101,3 +1404,4 @@ class CursorPaint extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -513,15 +513,17 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
|
||||
final args = jsonDecode(call.arguments);
|
||||
final id = args['id'];
|
||||
final close = args['close'];
|
||||
RemotePage? remotePage;
|
||||
try {
|
||||
final remotePage = tabController.state.value.tabs
|
||||
remotePage = tabController.state.value.tabs
|
||||
.firstWhere((tab) => tab.key == id)
|
||||
.page as RemotePage;
|
||||
returnValue = remotePage.ffi.ffiModel.cachedPeerData.toString();
|
||||
} catch (e) {
|
||||
debugPrint('Failed to get cached session data: $e');
|
||||
}
|
||||
if (close && returnValue != null) {
|
||||
if (close && returnValue != null && remotePage != null) {
|
||||
remotePage.releaseMacOSInputForTabTransfer();
|
||||
closeSessionOnDispose[id] = false;
|
||||
tabController.closeBy(id);
|
||||
}
|
||||
|
||||
@@ -495,14 +495,14 @@ class _CmHeaderState extends State<_CmHeader>
|
||||
if (client.type_() == ClientType.file)
|
||||
FittedBox(
|
||||
child: Text(
|
||||
translate("File Transfer"),
|
||||
translate("Transfer file"),
|
||||
style: TextStyle(color: Colors.white70, fontSize: 12),
|
||||
),
|
||||
),
|
||||
if (client.type_() == ClientType.camera)
|
||||
FittedBox(
|
||||
child: Text(
|
||||
translate("View Camera"),
|
||||
translate("View camera"),
|
||||
style: TextStyle(color: Colors.white70, fontSize: 12),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -19,6 +19,7 @@ import '../../common/shared_state.dart';
|
||||
import '../../utils/image.dart';
|
||||
import '../widgets/remote_toolbar.dart';
|
||||
import '../widgets/kb_layout_type_chooser.dart';
|
||||
import '../widgets/raster_stall_monitor.dart';
|
||||
import '../widgets/tabbar_widget.dart';
|
||||
|
||||
import 'package:flutter_hbb/native/custom_cursor.dart'
|
||||
@@ -102,6 +103,7 @@ class _ViewCameraPageState extends State<ViewCameraPage>
|
||||
super.initState();
|
||||
_ffi = FFI(widget.sessionId);
|
||||
Get.put<FFI>(_ffi, tag: widget.id);
|
||||
RasterStallMonitor.start();
|
||||
_ffi.imageModel.addCallbackOnFirstImage((String peerId) {
|
||||
showKBLayoutTypeChooserIfNeeded(
|
||||
_ffi.ffiModel.pi.platform, _ffi.dialogManager);
|
||||
@@ -222,7 +224,7 @@ class _ViewCameraPageState extends State<ViewCameraPage>
|
||||
// https://github.com/flutter/flutter/issues/64935
|
||||
super.dispose();
|
||||
debugPrint("VIEW CAMERA PAGE dispose session $sessionId ${widget.id}");
|
||||
_ffi.textureModel.onViewCameraPageDispose(closeSession);
|
||||
_ffi.textureModel.onViewCameraPageDispose();
|
||||
if (closeSession) {
|
||||
// ensure we leave this session, this is a double check
|
||||
_ffi.inputModel.enterOrLeave(false);
|
||||
|
||||
52
flutter/lib/desktop/widgets/raster_stall_monitor.dart
Normal file
52
flutter/lib/desktop/widgets/raster_stall_monitor.dart
Normal file
@@ -0,0 +1,52 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
|
||||
import '../../common.dart';
|
||||
import '../../consts.dart';
|
||||
import '../../models/platform_model.dart';
|
||||
import '../../models/state_model.dart';
|
||||
|
||||
/// Records a hung raster thread (frames continuously scheduled but no frame
|
||||
/// timings delivered for 30s) in `texture-render-health`; a hang cannot be
|
||||
/// rescued in-process, so the next launch defaults texture rendering off.
|
||||
class RasterStallMonitor {
|
||||
static bool _started = false;
|
||||
static bool _reported = false;
|
||||
static DateTime? _lastTimings;
|
||||
static DateTime _lastQuiet = DateTime.now();
|
||||
|
||||
static void start() {
|
||||
if (_started || isWeb) return;
|
||||
_started = true;
|
||||
SchedulerBinding.instance.addTimingsCallback((_) {
|
||||
_lastTimings = DateTime.now();
|
||||
});
|
||||
Timer.periodic(const Duration(seconds: 2), (_) {
|
||||
if (_reported) return;
|
||||
final now = DateTime.now();
|
||||
final lifecycle = SchedulerBinding.instance.lifecycleState;
|
||||
// Minimized/inactive (incl. screen lock) or idle (nothing scheduled):
|
||||
// no timings is legitimate, keep moving the quiet anchor forward.
|
||||
if (stateGlobal.isMinimized ||
|
||||
(lifecycle != null && lifecycle != AppLifecycleState.resumed) ||
|
||||
!SchedulerBinding.instance.hasScheduledFrame) {
|
||||
_lastQuiet = now;
|
||||
return;
|
||||
}
|
||||
var ref = _lastQuiet;
|
||||
final lastTimings = _lastTimings;
|
||||
if (lastTimings != null && lastTimings.isAfter(ref)) {
|
||||
ref = lastTimings;
|
||||
}
|
||||
if (now.difference(ref) > const Duration(seconds: 30)) {
|
||||
_reported = true;
|
||||
bind.mainSetLocalOption(
|
||||
key: kOptionTextureRenderHealth, value: 'failed-raster-stall');
|
||||
debugPrint(
|
||||
'raster thread stall detected, texture rendering disabled for next launch');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2484,6 +2484,8 @@ class _KeyboardMenu extends StatelessWidget {
|
||||
? (v) async {
|
||||
if (v != null) {
|
||||
await stateGlobal.setInputSource(ffi.sessionId, v);
|
||||
// Release native input; see the macOS trade-offs in RemotePage.
|
||||
if (isMacOS) ffi.inputModel.enterOrLeave(false);
|
||||
await ffi.ffiModel.checkDesktopKeyboardMode();
|
||||
await ffi.inputModel.updateKeyboardMode();
|
||||
}
|
||||
@@ -2740,7 +2742,9 @@ class _RecordMenu extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
var ffi = Provider.of<FfiModel>(context);
|
||||
var recordingModel = Provider.of<RecordingModel>(context);
|
||||
final visible =
|
||||
final hideRecordingButton =
|
||||
bind.mainGetLocalOption(key: kOptionHideRecordingButton) == 'Y';
|
||||
final visible = !hideRecordingButton &&
|
||||
(recordingModel.start || ffi.permissions['recording'] != false);
|
||||
if (!visible) return Offstage();
|
||||
return _IconMenuButton(
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/desktop/pages/remote_page.dart';
|
||||
import 'package:flutter_hbb/desktop/pages/view_camera_page.dart';
|
||||
import 'package:flutter_hbb/main.dart';
|
||||
import 'package:flutter_hbb/models/model.dart';
|
||||
import 'package:flutter_hbb/models/platform_model.dart';
|
||||
import 'package:flutter_hbb/models/state_model.dart';
|
||||
import 'package:get/get.dart';
|
||||
@@ -388,6 +389,7 @@ class _DesktopTabState extends State<DesktopTab>
|
||||
void onWindowMinimize() {
|
||||
stateGlobal.setMinimized(true);
|
||||
stateGlobal.setMaximized(false);
|
||||
_updateSessionsRenderVisible(false);
|
||||
super.onWindowMinimize();
|
||||
}
|
||||
|
||||
@@ -395,6 +397,7 @@ class _DesktopTabState extends State<DesktopTab>
|
||||
void onWindowMaximize() {
|
||||
stateGlobal.setMinimized(false);
|
||||
_setMaximized(true);
|
||||
_updateSessionsRenderVisible(true);
|
||||
super.onWindowMaximize();
|
||||
}
|
||||
|
||||
@@ -402,9 +405,34 @@ class _DesktopTabState extends State<DesktopTab>
|
||||
void onWindowUnmaximize() {
|
||||
stateGlobal.setMinimized(false);
|
||||
_setMaximized(false);
|
||||
_updateSessionsRenderVisible(true);
|
||||
super.onWindowUnmaximize();
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowRestore() {
|
||||
// A plain restore (no maximize involved) must clear the minimized flag.
|
||||
stateGlobal.setMinimized(false);
|
||||
_updateSessionsRenderVisible(true);
|
||||
super.onWindowRestore();
|
||||
}
|
||||
|
||||
// A hidden window composites nothing; pause the Rust-side texture watchdog
|
||||
// for its sessions so it cannot record a false failure.
|
||||
void _updateSessionsRenderVisible(bool visible) {
|
||||
if (tabType != DesktopTabType.remoteScreen &&
|
||||
tabType != DesktopTabType.viewCamera) {
|
||||
return;
|
||||
}
|
||||
for (final tab in controller.state.value.tabs) {
|
||||
try {
|
||||
final ffi = Get.find<FFI>(tag: tab.key);
|
||||
bind.sessionSetRenderVisible(
|
||||
sessionId: ffi.sessionId, visible: visible);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
_saveFrame({bool? flush}) async {
|
||||
try {
|
||||
if (tabType == DesktopTabType.main) {
|
||||
|
||||
172
flutter/lib/desktop/widgets/texture_render_probe.dart
Normal file
172
flutter/lib/desktop/widgets/texture_render_probe.dart
Normal file
@@ -0,0 +1,172 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
|
||||
import '../../common.dart';
|
||||
import '../../consts.dart';
|
||||
import '../../models/platform_model.dart';
|
||||
import '../../models/state_model.dart';
|
||||
|
||||
import 'package:texture_rgba_renderer/texture_rgba_renderer.dart'
|
||||
if (dart.library.html) 'package:flutter_hbb/web/texture_rgba_renderer.dart';
|
||||
|
||||
/// Startup probe: renders one frame through a 1x1 external texture and
|
||||
/// records in `texture-render-health` whether the engine consumed it, so a
|
||||
/// broken environment is detected before the first session goes black.
|
||||
class TextureRenderProbe extends StatefulWidget {
|
||||
const TextureRenderProbe({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<TextureRenderProbe> createState() => _TextureRenderProbeState();
|
||||
}
|
||||
|
||||
class _TextureRenderProbeState extends State<TextureRenderProbe> {
|
||||
static bool _ranThisLaunch = false;
|
||||
final _renderer = TextureRgbaRenderer();
|
||||
int _textureId = -1;
|
||||
int _textureKey = -1;
|
||||
int _ptr = 0;
|
||||
Timer? _timer;
|
||||
int _ticks = 0;
|
||||
bool _sawTimings = false;
|
||||
bool _wasEffectiveOn = false;
|
||||
DateTime? _lastTimings;
|
||||
DateTime? _firstPush;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (_ranThisLaunch || isWeb || !isDesktop) return;
|
||||
_ranThisLaunch = true;
|
||||
if (bind.isIncomingOnly()) return;
|
||||
// An old plugin without the consumed counter cannot be judged, and a
|
||||
// recorded raster-stall means compositing a texture may hang this window.
|
||||
if (!bind.mainTextureRenderProbeSupported()) return;
|
||||
if (bind
|
||||
.mainGetLocalOption(key: kOptionTextureRenderHealth)
|
||||
.startsWith('failed-raster-stall')) {
|
||||
return;
|
||||
}
|
||||
_wasEffectiveOn = bind.mainGetUseTextureRender();
|
||||
// Only probe after the window has really rendered a frame: a hidden
|
||||
// window (silent/tray start) must not record a false failure.
|
||||
SchedulerBinding.instance.addTimingsCallback(_onTimings);
|
||||
Future.delayed(const Duration(seconds: 5), () {
|
||||
if (!_sawTimings) {
|
||||
SchedulerBinding.instance.removeTimingsCallback(_onTimings);
|
||||
_finish(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _onTimings(List<FrameTiming> timings) {
|
||||
_lastTimings = DateTime.now();
|
||||
if (_sawTimings) return;
|
||||
_sawTimings = true;
|
||||
_start();
|
||||
}
|
||||
|
||||
void _start() async {
|
||||
if (!mounted) return;
|
||||
_textureKey = bind.getNextTextureKey();
|
||||
final id = await _renderer.createTexture(_textureKey);
|
||||
if (!mounted || id == -1) {
|
||||
_finish(!mounted ? null : false);
|
||||
return;
|
||||
}
|
||||
_ptr = await _renderer.getTexturePtr(_textureKey);
|
||||
if (!mounted || _ptr <= 0) {
|
||||
_finish(!mounted ? null : false);
|
||||
return;
|
||||
}
|
||||
setState(() => _textureId = id);
|
||||
_timer = Timer.periodic(const Duration(milliseconds: 100), (_) {
|
||||
_ticks += 1;
|
||||
_firstPush ??= DateTime.now();
|
||||
bind.mainPushTextureProbeFrame(ptr: _ptr);
|
||||
final consumed = bind.mainGetTextureProbeConsumed(ptr: _ptr) > 0;
|
||||
// "Consumed" advances inside the plugin callback, before the GL/Metal
|
||||
// upload; only a frame timing after the push proves a completed frame.
|
||||
final frameCompleted = consumed &&
|
||||
_lastTimings != null &&
|
||||
_lastTimings!.isAfter(_firstPush!);
|
||||
if (frameCompleted) {
|
||||
_finish(true);
|
||||
} else if (_ticks >= 10) {
|
||||
if (consumed) {
|
||||
_finish(null);
|
||||
return;
|
||||
}
|
||||
// Only a window that is visibly compositing can prove a failure.
|
||||
final lifecycle = SchedulerBinding.instance.lifecycleState;
|
||||
final active =
|
||||
lifecycle == null || lifecycle == AppLifecycleState.resumed;
|
||||
final timingsFresh = _lastTimings != null &&
|
||||
DateTime.now().difference(_lastTimings!) <
|
||||
const Duration(milliseconds: 1500);
|
||||
_finish(!stateGlobal.isMinimized && active && timingsFresh
|
||||
? false
|
||||
: null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _finish(bool? ok) {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
SchedulerBinding.instance.removeTimingsCallback(_onTimings);
|
||||
if (ok != null) {
|
||||
final old = bind.mainGetLocalOption(key: kOptionTextureRenderHealth);
|
||||
if (ok) {
|
||||
// This rgba probe disproves only the rgba black-texture class: gpu
|
||||
// failures and raster stalls clear via the option toggle alone.
|
||||
final clearable = old.isEmpty ||
|
||||
old.startsWith('failed-probe') ||
|
||||
old.startsWith('failed-watchdog-rgba');
|
||||
if (clearable) {
|
||||
bind.mainSetLocalOption(key: kOptionTextureRenderHealth, value: 'ok');
|
||||
}
|
||||
} else if (!old.startsWith('failed')) {
|
||||
debugPrint('texture render probe failed, disabling texture rendering');
|
||||
bind.mainSetLocalOption(
|
||||
key: kOptionTextureRenderHealth, value: 'failed-probe');
|
||||
if (_wasEffectiveOn) {
|
||||
showToast(translate('texture-render-fallback-tip'));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (_textureKey != -1) {
|
||||
_renderer.closeTexture(_textureKey);
|
||||
_textureKey = -1;
|
||||
}
|
||||
_ptr = 0;
|
||||
if (mounted && _textureId != -1) {
|
||||
setState(() => _textureId = -1);
|
||||
} else {
|
||||
_textureId = -1;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
SchedulerBinding.instance.removeTimingsCallback(_onTimings);
|
||||
if (_textureKey != -1) {
|
||||
_renderer.closeTexture(_textureKey);
|
||||
_textureKey = -1;
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_textureId == -1) return const SizedBox.shrink();
|
||||
// Must actually composite for the engine to sample the texture; the
|
||||
// pushed pixel is fully transparent.
|
||||
return IgnorePointer(
|
||||
child: SizedBox(
|
||||
width: 1, height: 1, child: Texture(textureId: _textureId)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -588,7 +588,8 @@ _registerEventHandler() {
|
||||
|
||||
Widget keyListenerBuilder(BuildContext context, Widget? child) {
|
||||
return RawKeyboardListener(
|
||||
focusNode: FocusNode(),
|
||||
// `skipTraversal: isWeb` is to fix "Bad state: RenderBox was not laid out: minified:aeL#c19e4"
|
||||
focusNode: FocusNode(skipTraversal: isWeb),
|
||||
child: child ?? Container(),
|
||||
onKey: (RawKeyEvent event) {
|
||||
if (event.logicalKey == LogicalKeyboardKey.shiftLeft) {
|
||||
|
||||
@@ -78,6 +78,7 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
||||
var _enableAbr = false;
|
||||
var _denyLANDiscovery = false;
|
||||
var _onlyWhiteList = false;
|
||||
var _onlyIdWhiteList = false;
|
||||
var _enableDirectIPAccess = false;
|
||||
var _enableRecordSession = false;
|
||||
var _enableHardwareCodec = false;
|
||||
@@ -89,6 +90,7 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
||||
var _directAccessPort = "";
|
||||
var _fingerprint = "";
|
||||
var _buildDate = "";
|
||||
var _myId = "";
|
||||
var _autoDisconnectTimeout = "";
|
||||
var _hideServer = false;
|
||||
var _hideProxy = false;
|
||||
@@ -109,6 +111,7 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
||||
_denyLANDiscovery = !option2bool(kOptionEnableLanDiscovery,
|
||||
bind.mainGetOptionSync(key: kOptionEnableLanDiscovery));
|
||||
_onlyWhiteList = whitelistNotEmpty();
|
||||
_onlyIdWhiteList = idWhitelistNotEmpty();
|
||||
_enableDirectIPAccess = option2bool(
|
||||
kOptionDirectServer, bind.mainGetOptionSync(key: kOptionDirectServer));
|
||||
_enableRecordSession = option2bool(kOptionEnableRecordSession,
|
||||
@@ -217,6 +220,12 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
||||
_buildDate = buildDate;
|
||||
}
|
||||
|
||||
final myId = await bind.mainGetMyId();
|
||||
if (_myId != myId) {
|
||||
update = true;
|
||||
_myId = myId;
|
||||
}
|
||||
|
||||
final isUsingPublicServer = await bind.mainIsUsingPublicServer();
|
||||
if (_isUsingPublicServer != isUsingPublicServer) {
|
||||
update = true;
|
||||
@@ -400,6 +409,29 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
||||
changeWhiteList(callback: update);
|
||||
},
|
||||
),
|
||||
SettingsTile.switchTile(
|
||||
title: Row(children: [
|
||||
Expanded(child: Text(translate('Use ID whitelisting'))),
|
||||
Offstage(
|
||||
offstage: !_onlyIdWhiteList,
|
||||
child: const Icon(Icons.warning_amber_rounded,
|
||||
color: Color.fromARGB(255, 255, 204, 0)))
|
||||
.marginOnly(left: 5)
|
||||
]),
|
||||
initialValue: _onlyIdWhiteList,
|
||||
onToggle: (_) async {
|
||||
update() async {
|
||||
final onlyIdWhiteList = idWhitelistNotEmpty();
|
||||
if (onlyIdWhiteList != _onlyIdWhiteList) {
|
||||
setState(() {
|
||||
_onlyIdWhiteList = onlyIdWhiteList;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
changeIdWhiteList(callback: update);
|
||||
},
|
||||
),
|
||||
SettingsTile.switchTile(
|
||||
title: Text(translate('Adaptive bitrate')),
|
||||
initialValue: _enableAbr,
|
||||
@@ -982,6 +1014,14 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
||||
child: Text(_fingerprint),
|
||||
),
|
||||
leading: Icon(Icons.fingerprint)),
|
||||
SettingsTile(
|
||||
onPressed: (context) => onCopyId(_myId),
|
||||
title: Text(translate("ID")),
|
||||
value: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8),
|
||||
child: Text(_myId),
|
||||
),
|
||||
leading: Icon(Icons.perm_identity)),
|
||||
SettingsTile(
|
||||
title: Text(translate("Privacy Statement")),
|
||||
onPressed: (context) =>
|
||||
|
||||
@@ -5,8 +5,13 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/common/widgets/dialog.dart';
|
||||
import 'package:flutter_hbb/models/input_modifier_utils.dart';
|
||||
import 'package:flutter_hbb/models/model.dart';
|
||||
import 'package:flutter_hbb/models/platform_model.dart';
|
||||
import 'package:flutter_hbb/models/terminal_model.dart';
|
||||
import 'package:flutter_hbb/mobile/terminal_keyboard_utils.dart';
|
||||
import 'package:flutter_hbb/web/dummy.dart'
|
||||
if (dart.library.html) 'package:flutter_hbb/web/terminal_font.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:xterm/xterm.dart';
|
||||
import '../../desktop/pages/terminal_connection_manager.dart';
|
||||
@@ -42,6 +47,11 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
final GlobalKey _keyboardKey = GlobalKey();
|
||||
double _keyboardHeight = 0;
|
||||
late bool _showTerminalExtraKeys;
|
||||
// Ctrl lock state for virtual keyboard: active key presses are mapped to control codes
|
||||
bool _ctrlLocked = false;
|
||||
bool _altLocked = false;
|
||||
// Row3 expand/collapse state for compact keyboard layout
|
||||
bool _row3Expanded = false;
|
||||
// For iOS edge swipe gesture
|
||||
double _swipeStartX = 0;
|
||||
double _swipeCurrentX = 0;
|
||||
@@ -59,6 +69,10 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
|
||||
if (isWeb) {
|
||||
loadLocalTerminalFontIfNeeded();
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
'[TerminalPage] Initializing terminal ${widget.terminalId} for peer ${widget.id}');
|
||||
|
||||
@@ -94,6 +108,18 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
// terminal extra keys bar is unnecessary and disabled.
|
||||
_showTerminalExtraKeys = !isWebDesktop &&
|
||||
mainGetLocalBoolOptionSync(kOptionEnableShowTerminalExtraKeys);
|
||||
_terminalModel.isCtrlLocked = () => _ctrlLocked;
|
||||
_terminalModel.clearCtrlLock = () {
|
||||
if (_ctrlLocked) setState(() => _ctrlLocked = false);
|
||||
};
|
||||
_terminalModel.isAltLocked = () => _altLocked;
|
||||
_terminalModel.clearAltLock = () {
|
||||
if (_altLocked) setState(() => _altLocked = false);
|
||||
};
|
||||
// Load Row3 expand/collapse state from persistent storage. The raw option
|
||||
// read keeps Row3 collapsed when no value has been saved yet.
|
||||
_row3Expanded =
|
||||
bind.mainGetLocalOption(key: kOptionShowTerminalCtrlKeys) == 'Y';
|
||||
// Initialize terminal connection
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_ffi.dialogManager
|
||||
@@ -148,6 +174,39 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
return EdgeInsets.only(left: 5.0, right: 5.0, top: topBottom, bottom: topBottom + _sysKeyboardHeight + _keyboardHeight);
|
||||
}
|
||||
|
||||
/// Pastes clipboard text through TerminalModel so keyboard-only modifiers and
|
||||
/// mobile Enter normalization never alter clipboard data.
|
||||
Future<void> _pasteClipboardText() async {
|
||||
final data = await Clipboard.getData(Clipboard.kTextPlain);
|
||||
final text = data?.text;
|
||||
if (text == null || !mounted) return;
|
||||
|
||||
await _terminalModel.pasteText(text);
|
||||
if (mounted) {
|
||||
_terminalModel.terminalController.clearSelection();
|
||||
}
|
||||
}
|
||||
|
||||
KeyEventResult _handleTerminalKeyEvent(FocusNode _, KeyEvent event) {
|
||||
final hardwareKeyboard = HardwareKeyboard.instance;
|
||||
final shouldPaste = shouldHandleTerminalPasteShortcut(
|
||||
logicalKey: event.logicalKey,
|
||||
isKeyDown: event is KeyDownEvent,
|
||||
isKeyRepeat: event is KeyRepeatEvent,
|
||||
controlPressed: hardwareKeyboard.isControlPressed,
|
||||
metaPressed: hardwareKeyboard.isMetaPressed,
|
||||
altPressed: hardwareKeyboard.isAltPressed,
|
||||
shiftPressed: hardwareKeyboard.isShiftPressed,
|
||||
modifierLockActive: _ctrlLocked || _altLocked,
|
||||
);
|
||||
if (!shouldPaste) return KeyEventResult.ignored;
|
||||
|
||||
// Only locked virtual modifiers need interception. Without a lock, keep
|
||||
// xterm's default hardware paste behavior, including bracketed paste mode.
|
||||
unawaited(_pasteClipboardText());
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
@@ -185,6 +244,7 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
//
|
||||
// Android works fine without this workaround.
|
||||
deleteDetection: isIOS,
|
||||
onKeyEvent: _handleTerminalKeyEvent,
|
||||
padding: _calculatePadding(heightPx),
|
||||
onSecondaryTapDown: (details, offset) async {
|
||||
final selection = _terminalModel.terminalController.selection;
|
||||
@@ -193,11 +253,7 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
_terminalModel.terminalController.clearSelection();
|
||||
await Clipboard.setData(ClipboardData(text: text));
|
||||
} else {
|
||||
final data = await Clipboard.getData('text/plain');
|
||||
final text = data?.text;
|
||||
if (text != null) {
|
||||
_terminalModel.terminal.paste(text);
|
||||
}
|
||||
await _pasteClipboardText();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -324,66 +380,171 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Row 1 follows the latest reviewed PR layout.
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: _buildKeyboardKeyButtons(terminalKeyboardRow1Keys),
|
||||
),
|
||||
// Row 2 ends with the full-width Row3 collapse/expand toggle.
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_buildKeyButton('Esc'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('/'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('|'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('Home'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('↑'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('End'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('PgUp'),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_buildKeyButton('Tab'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('Ctrl+C'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('~'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('←'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('↓'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('→'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('PgDn'),
|
||||
..._buildKeyboardKeyButtons(terminalKeyboardRow2Keys),
|
||||
const SizedBox(width: terminalKeyboardKeySpacing),
|
||||
_buildCollapseButton(),
|
||||
],
|
||||
),
|
||||
// Row 3 restores paging keys and trailing alignment placeholders.
|
||||
if (_row3Expanded)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
..._buildKeyboardKeyButtons(terminalKeyboardRow3Keys),
|
||||
for (var i = 0;
|
||||
i < terminalKeyboardRow3TrailingPlaceholderCount;
|
||||
i++) ...[
|
||||
const SizedBox(width: terminalKeyboardKeySpacing),
|
||||
const SizedBox(width: terminalKeyboardKeyWidth),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Ctrl toggle button with highlighted locked state
|
||||
Widget _buildCtrlKeyButton() {
|
||||
return _buildModifierToggleButton(
|
||||
text: 'Ctrl',
|
||||
semanticsLabel: 'Ctrl',
|
||||
isLocked: _ctrlLocked,
|
||||
onPressed: () => setState(() => _ctrlLocked = !_ctrlLocked),
|
||||
);
|
||||
}
|
||||
|
||||
// Alt toggle button with highlighted locked state
|
||||
Widget _buildAltKeyButton() {
|
||||
return _buildModifierToggleButton(
|
||||
text: 'Alt',
|
||||
semanticsLabel: 'Alt',
|
||||
isLocked: _altLocked,
|
||||
onPressed: () => setState(() => _altLocked = !_altLocked),
|
||||
);
|
||||
}
|
||||
|
||||
// Collapse/expand toggle button for Row3
|
||||
void _toggleRow3Expanded() {
|
||||
final willExpand = !_row3Expanded;
|
||||
final shouldClearModifiers = shouldClearTerminalModifiersWhenRow3Collapses(
|
||||
wasExpanded: _row3Expanded,
|
||||
willExpand: willExpand,
|
||||
ctrlLocked: _ctrlLocked,
|
||||
altLocked: _altLocked,
|
||||
);
|
||||
setState(() {
|
||||
_row3Expanded = willExpand;
|
||||
if (shouldClearModifiers) {
|
||||
_ctrlLocked = false;
|
||||
_altLocked = false;
|
||||
}
|
||||
});
|
||||
mainSetLocalBoolOption(kOptionShowTerminalCtrlKeys, willExpand);
|
||||
|
||||
// The floating keyboard height changes after Row3 is inserted/removed.
|
||||
// Re-measure on the next frame so terminal padding uses the new height.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || !_showTerminalExtraKeys) return;
|
||||
setState(() {
|
||||
_updateKeyboardHeight();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildCollapseButton() {
|
||||
return Semantics(
|
||||
label: translate('Show terminal extra keys'),
|
||||
toggled: _row3Expanded,
|
||||
child: ElevatedButton(
|
||||
onPressed: _toggleRow3Expanded,
|
||||
child: Text(_row3Expanded ? '∧' : '∨'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(terminalKeyboardKeyWidth, 32),
|
||||
padding: EdgeInsets.zero,
|
||||
textStyle: const TextStyle(fontSize: 12),
|
||||
backgroundColor:
|
||||
Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
foregroundColor: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds a fixed-width key sequence with the reviewed 2dp spacing.
|
||||
List<Widget> _buildKeyboardKeyButtons(List<String> labels) {
|
||||
return [
|
||||
for (var i = 0; i < labels.length; i++) ...[
|
||||
_buildKeyButton(labels[i]),
|
||||
if (i < labels.length - 1)
|
||||
const SizedBox(width: terminalKeyboardKeySpacing),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/// Build a modifier toggle button (Ctrl/Alt) with one-shot behavior.
|
||||
/// When [isLocked] is true, the button highlights in blue and the next
|
||||
/// single-character input is mapped to its modified equivalent.
|
||||
Widget _buildModifierToggleButton({
|
||||
required String text,
|
||||
required String semanticsLabel,
|
||||
required bool isLocked,
|
||||
required VoidCallback onPressed,
|
||||
}) {
|
||||
return Semantics(
|
||||
// Ctrl and Alt are technical key names and intentionally stay unchanged.
|
||||
label: semanticsLabel,
|
||||
toggled: isLocked,
|
||||
child: ElevatedButton(
|
||||
onPressed: onPressed,
|
||||
child: Text(text),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(terminalKeyboardKeyWidth, 32),
|
||||
padding: EdgeInsets.zero,
|
||||
textStyle: const TextStyle(fontSize: 12),
|
||||
backgroundColor: isLocked
|
||||
? Colors.blue
|
||||
: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
foregroundColor: isLocked
|
||||
? Colors.white
|
||||
: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildKeyButton(String label) {
|
||||
if (label == 'Ctrl') return _buildCtrlKeyButton();
|
||||
if (label == 'Alt') return _buildAltKeyButton();
|
||||
|
||||
return ElevatedButton(
|
||||
onPressed: () {
|
||||
_sendKeyToTerminal(label);
|
||||
},
|
||||
child: Text(label),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(48, 32),
|
||||
minimumSize: const Size(terminalKeyboardKeyWidth, 32),
|
||||
padding: EdgeInsets.zero,
|
||||
textStyle: const TextStyle(fontSize: 12),
|
||||
backgroundColor: Theme.of(context).colorScheme.surfaceVariant,
|
||||
backgroundColor:
|
||||
Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
foregroundColor: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _sendKeyToTerminal(String key) {
|
||||
String? send;
|
||||
String send;
|
||||
|
||||
switch (key) {
|
||||
case 'Esc':
|
||||
@@ -427,9 +588,7 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
break;
|
||||
}
|
||||
|
||||
if (send != null) {
|
||||
_terminalModel.sendVirtualKey(send);
|
||||
}
|
||||
_terminalModel.sendVirtualKey(send);
|
||||
}
|
||||
|
||||
// https://github.com/TerminalStudio/xterm.dart/issues/42#issuecomment-877495472
|
||||
|
||||
20
flutter/lib/mobile/terminal_keyboard_utils.dart
Normal file
20
flutter/lib/mobile/terminal_keyboard_utils.dart
Normal file
@@ -0,0 +1,20 @@
|
||||
/// Reviewed mobile terminal keyboard layout from PR #15532.
|
||||
///
|
||||
/// Keeping the key order outside the widget makes the intended layout explicit
|
||||
/// and prevents behavior fixes from silently moving keys between rows.
|
||||
const terminalKeyboardRow1Keys = ['Esc', '/', '|', 'Home', '↑', 'End', r'\'];
|
||||
const terminalKeyboardRow2Keys = ['Tab', 'Ctrl+C', '~', '←', '↓', '→'];
|
||||
const terminalKeyboardRow3Keys = ['Ctrl', 'Alt', '-', 'PgUp', 'PgDn'];
|
||||
|
||||
const terminalKeyboardKeyWidth = 48.0;
|
||||
const terminalKeyboardKeySpacing = 2.0;
|
||||
|
||||
/// Empty 48dp slots keep expanded Row3 aligned with the two rows above it.
|
||||
const terminalKeyboardRow3TrailingPlaceholderCount = 2;
|
||||
|
||||
/// Returns the fixed width occupied by a row of equally sized key slots.
|
||||
double terminalKeyboardRowWidth(int slotCount) {
|
||||
if (slotCount <= 0) return 0;
|
||||
return slotCount * terminalKeyboardKeyWidth +
|
||||
(slotCount - 1) * terminalKeyboardKeySpacing;
|
||||
}
|
||||
@@ -16,6 +16,8 @@ class _PixelbufferTexture {
|
||||
int _display = 0;
|
||||
SessionID? _sessionId;
|
||||
bool _destroying = false;
|
||||
bool _closed = false;
|
||||
int _ptr = 0;
|
||||
int? _id;
|
||||
|
||||
final textureRenderer = TextureRgbaRenderer();
|
||||
@@ -27,11 +29,22 @@ class _PixelbufferTexture {
|
||||
_textureKey = bind.getNextTextureKey();
|
||||
_sessionId = sessionId;
|
||||
|
||||
textureRenderer.createTexture(_textureKey).then((id) async {
|
||||
final textureKey = _textureKey;
|
||||
textureRenderer.createTexture(textureKey).then((id) async {
|
||||
_id = id;
|
||||
if (id != -1) {
|
||||
if (_closed) {
|
||||
// Destroyed while creation was still in flight (rapid
|
||||
// connect/disconnect); nobody else will close this texture.
|
||||
await textureRenderer.closeTexture(textureKey);
|
||||
return;
|
||||
}
|
||||
ffi.textureModel.setRgbaTextureId(display: d, id: id);
|
||||
final ptr = await textureRenderer.getTexturePtr(_textureKey);
|
||||
final ptr = await textureRenderer.getTexturePtr(textureKey);
|
||||
if (_closed) {
|
||||
return;
|
||||
}
|
||||
_ptr = ptr;
|
||||
platformFFI.registerPixelbufferTexture(sessionId, display, ptr);
|
||||
debugPrint(
|
||||
"create pixelbuffer texture: peerId: ${ffi.id} display:$_display, textureId:$id, texturePtr:$ptr");
|
||||
@@ -39,13 +52,16 @@ class _PixelbufferTexture {
|
||||
});
|
||||
}
|
||||
|
||||
destroy(bool unregisterTexture, FFI ffi) async {
|
||||
destroy(FFI ffi) async {
|
||||
_closed = true;
|
||||
if (!_destroying && _textureKey != -1 && _sessionId != null) {
|
||||
_destroying = true;
|
||||
if (unregisterTexture) {
|
||||
platformFFI.registerPixelbufferTexture(_sessionId!, display, 0);
|
||||
// sleep for a while to avoid the texture is used after it's unregistered.
|
||||
await Future.delayed(Duration(milliseconds: 100));
|
||||
if (_ptr != 0) {
|
||||
// Compare-and-clear: only clears if Rust still holds this pointer
|
||||
// (#8016-safe); returning from this synchronous call also means no
|
||||
// push through the old pointer is still in flight.
|
||||
platformFFI.unregisterPixelbufferTexture(_sessionId!, display, _ptr);
|
||||
_ptr = 0;
|
||||
}
|
||||
await textureRenderer.closeTexture(_textureKey);
|
||||
_textureKey = -1;
|
||||
@@ -61,6 +77,7 @@ class _GpuTexture {
|
||||
SessionID? _sessionId;
|
||||
final support = bind.mainHasGpuTextureRender();
|
||||
bool _destroying = false;
|
||||
bool _closed = false;
|
||||
int _display = 0;
|
||||
int? _id;
|
||||
int? _output;
|
||||
@@ -79,9 +96,18 @@ class _GpuTexture {
|
||||
gpuTextureRenderer.registerTexture().then((id) async {
|
||||
_id = id;
|
||||
if (id != null) {
|
||||
if (_closed) {
|
||||
// Destroyed while creation was still in flight (rapid
|
||||
// connect/disconnect); nobody else will unregister this texture.
|
||||
await gpuTextureRenderer.unregisterTexture(id);
|
||||
return;
|
||||
}
|
||||
_textureId = id;
|
||||
ffi.textureModel.setGpuTextureId(display: d, id: id);
|
||||
final output = await gpuTextureRenderer.output(id);
|
||||
if (_closed) {
|
||||
return;
|
||||
}
|
||||
_output = output;
|
||||
if (output != null) {
|
||||
platformFFI.registerGpuTexture(sessionId, d, output);
|
||||
@@ -95,20 +121,22 @@ class _GpuTexture {
|
||||
}
|
||||
}
|
||||
|
||||
destroy(bool unregisterTexture, FFI ffi) async {
|
||||
destroy(FFI ffi) async {
|
||||
// must stop texture render, render unregistered texture cause crash
|
||||
_closed = true;
|
||||
if (!_destroying && support && _sessionId != null && _textureId != -1) {
|
||||
_destroying = true;
|
||||
if (unregisterTexture) {
|
||||
platformFFI.registerGpuTexture(_sessionId!, _display, 0);
|
||||
// sleep for a while to avoid the texture is used after it's unregistered.
|
||||
await Future.delayed(Duration(milliseconds: 100));
|
||||
final output = _output;
|
||||
if (output != null) {
|
||||
// Compare-and-clear, see _PixelbufferTexture.destroy.
|
||||
platformFFI.unregisterGpuTexture(_sessionId!, _display, output);
|
||||
_output = null;
|
||||
}
|
||||
await gpuTextureRenderer.unregisterTexture(_textureId);
|
||||
_textureId = -1;
|
||||
_destroying = false;
|
||||
debugPrint(
|
||||
"destroy gpu texture: peerId: ${ffi.id} display:$_display, textureId:$_id, output:$_output");
|
||||
"destroy gpu texture: peerId: ${ffi.id} display:$_display, textureId:$_id, output:$output");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -200,11 +228,11 @@ class TextureModel {
|
||||
tryRemoveTexture(int idx) {
|
||||
_control.remove(idx);
|
||||
if (_pixelbufferRenderTextures.containsKey(idx)) {
|
||||
_pixelbufferRenderTextures[idx]!.destroy(true, ffi);
|
||||
_pixelbufferRenderTextures[idx]!.destroy(ffi);
|
||||
_pixelbufferRenderTextures.remove(idx);
|
||||
}
|
||||
if (_gpuRenderTextures.containsKey(idx)) {
|
||||
_gpuRenderTextures[idx]!.destroy(true, ffi);
|
||||
_gpuRenderTextures[idx]!.destroy(ffi);
|
||||
_gpuRenderTextures.remove(idx);
|
||||
}
|
||||
}
|
||||
@@ -224,25 +252,25 @@ class TextureModel {
|
||||
}
|
||||
}
|
||||
|
||||
onRemotePageDispose(bool closeSession) async {
|
||||
onRemotePageDispose() async {
|
||||
final ffi = parent.target;
|
||||
if (ffi == null) return;
|
||||
for (final texture in _pixelbufferRenderTextures.values) {
|
||||
await texture.destroy(closeSession, ffi);
|
||||
await texture.destroy(ffi);
|
||||
}
|
||||
for (final texture in _gpuRenderTextures.values) {
|
||||
await texture.destroy(closeSession, ffi);
|
||||
await texture.destroy(ffi);
|
||||
}
|
||||
}
|
||||
|
||||
onViewCameraPageDispose(bool closeSession) async {
|
||||
onViewCameraPageDispose() async {
|
||||
final ffi = parent.target;
|
||||
if (ffi == null) return;
|
||||
for (final texture in _pixelbufferRenderTextures.values) {
|
||||
await texture.destroy(closeSession, ffi);
|
||||
await texture.destroy(ffi);
|
||||
}
|
||||
for (final texture in _gpuRenderTextures.values) {
|
||||
await texture.destroy(closeSession, ffi);
|
||||
await texture.destroy(ffi);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Identifies where terminal input originated so paste data can bypass all
|
||||
/// keyboard-only transformations.
|
||||
enum TerminalInputSource {
|
||||
keyboard,
|
||||
paste,
|
||||
}
|
||||
|
||||
/// Returns true when a stale mobile one-shot Shift state should be released
|
||||
/// by replaying a tracked Shift key-down as a synthesized key-up.
|
||||
@@ -36,3 +44,147 @@ bool shouldReleaseStaleMobileShift({
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Applies the terminal Ctrl/Alt one-shot modifiers to a single input payload.
|
||||
///
|
||||
String applyTerminalInputModifiers(
|
||||
String data, {
|
||||
required bool ctrlLocked,
|
||||
required bool altLocked,
|
||||
}) {
|
||||
var result = data;
|
||||
if (ctrlLocked) {
|
||||
result = _applyTerminalCtrlModifier(result);
|
||||
}
|
||||
if (altLocked) {
|
||||
result = '\x1B$result';
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Builds the exact payload xterm sends for paste, without applying modifiers.
|
||||
String terminalPastePayload(String text, {required bool bracketedPasteMode}) {
|
||||
if (!bracketedPasteMode) {
|
||||
return text;
|
||||
}
|
||||
return '\x1B[200~$text\x1B[201~';
|
||||
}
|
||||
|
||||
/// Returns whether one-shot Ctrl/Alt may transform and consume this input.
|
||||
///
|
||||
/// xterm emits terminal control keys as either one control byte or a longer
|
||||
/// escape sequence. Neither form is ordinary text input, so a pending modifier
|
||||
/// must survive until the user enters a printable character.
|
||||
bool shouldApplyTerminalInputModifiers(String data) {
|
||||
if (data.characters.length != 1) return false;
|
||||
final codeUnit = data.codeUnitAt(0);
|
||||
return codeUnit >= 0x20 && codeUnit != 0x7F;
|
||||
}
|
||||
|
||||
/// Builds the payload sent to the remote terminal for keyboard and paste input.
|
||||
///
|
||||
/// Keyboard input keeps the mobile Enter workaround and one-shot Ctrl/Alt
|
||||
/// mapping. Paste input deliberately bypasses both transformations so even a
|
||||
/// one-character clipboard payload is preserved exactly.
|
||||
String prepareTerminalInputPayload(
|
||||
String data, {
|
||||
required TerminalInputSource source,
|
||||
required bool isMobileOrWebMobile,
|
||||
required bool bracketedPasteMode,
|
||||
required bool ctrlLocked,
|
||||
required bool altLocked,
|
||||
}) {
|
||||
if (source == TerminalInputSource.paste) {
|
||||
return terminalPastePayload(
|
||||
data,
|
||||
bracketedPasteMode: bracketedPasteMode,
|
||||
);
|
||||
}
|
||||
|
||||
var result = data;
|
||||
if (isMobileOrWebMobile && result == '\n') {
|
||||
result = '\r';
|
||||
}
|
||||
if ((ctrlLocked || altLocked) && shouldApplyTerminalInputModifiers(result)) {
|
||||
result = applyTerminalInputModifiers(
|
||||
result,
|
||||
ctrlLocked: ctrlLocked,
|
||||
altLocked: altLocked,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Returns true when a hardware paste shortcut must bypass keyboard modifiers.
|
||||
///
|
||||
/// xterm already handles hardware Ctrl/Cmd+V correctly in the common case. Only
|
||||
/// intercept while a virtual Ctrl/Alt lock is active, because xterm can emit a
|
||||
/// one-character paste as normal text when bracketed paste mode is disabled.
|
||||
bool shouldHandleTerminalPasteShortcut({
|
||||
required LogicalKeyboardKey logicalKey,
|
||||
required bool isKeyDown,
|
||||
required bool isKeyRepeat,
|
||||
required bool controlPressed,
|
||||
required bool metaPressed,
|
||||
required bool altPressed,
|
||||
required bool shiftPressed,
|
||||
required bool modifierLockActive,
|
||||
}) {
|
||||
if (!modifierLockActive) return false;
|
||||
if (!isKeyDown && !isKeyRepeat) return false;
|
||||
if (logicalKey != LogicalKeyboardKey.keyV) return false;
|
||||
if (altPressed || shiftPressed) return false;
|
||||
return controlPressed != metaPressed;
|
||||
}
|
||||
|
||||
/// Returns true when collapsing Row3 should also clear hidden modifier state.
|
||||
bool shouldClearTerminalModifiersWhenRow3Collapses({
|
||||
required bool wasExpanded,
|
||||
required bool willExpand,
|
||||
required bool ctrlLocked,
|
||||
required bool altLocked,
|
||||
}) {
|
||||
return wasExpanded && !willExpand && (ctrlLocked || altLocked);
|
||||
}
|
||||
|
||||
String _applyTerminalCtrlModifier(String data) {
|
||||
// Ctrl mappings are defined only for ASCII scalars. A visible character can
|
||||
// be multiple scalars (for example, a decomposed accent), so leave those
|
||||
// graphemes untouched instead of rewriting only their ASCII base letter.
|
||||
final graphemes = data.characters.toList(growable: false);
|
||||
if (graphemes.length != 1) {
|
||||
return data;
|
||||
}
|
||||
|
||||
final runes = graphemes.single.runes.toList(growable: false);
|
||||
if (runes.length != 1) {
|
||||
return data;
|
||||
}
|
||||
|
||||
final code = runes.single;
|
||||
if (code >= 0x61 && code <= 0x7A) {
|
||||
return String.fromCharCode(code - 0x60);
|
||||
}
|
||||
if (code >= 0x41 && code <= 0x5A) {
|
||||
return String.fromCharCode(code - 0x40);
|
||||
}
|
||||
if (code == 0x20) {
|
||||
return String.fromCharCode(0);
|
||||
}
|
||||
if (code == 0x5B) {
|
||||
return String.fromCharCode(27);
|
||||
}
|
||||
if (code == 0x5C) {
|
||||
return String.fromCharCode(28);
|
||||
}
|
||||
if (code == 0x5D) {
|
||||
return String.fromCharCode(29);
|
||||
}
|
||||
if (code == 0x5E) {
|
||||
return String.fromCharCode(30);
|
||||
}
|
||||
if (code == 0x5F || code == 0x2F) {
|
||||
return String.fromCharCode(31);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -735,6 +735,11 @@ class FfiModel with ChangeNotifier {
|
||||
_handleUseTextureRender(
|
||||
Map<String, dynamic> evt, SessionID sessionId, String peerId) {
|
||||
parent.target?.imageModel.setUseTextureRender(evt['v'] == 'Y');
|
||||
if (evt['reason'] == 'fallback') {
|
||||
// The Rust watchdog detected that pushed frames were never rendered
|
||||
// and switched this session to software rendering.
|
||||
showToast(translate('texture-render-fallback-tip'));
|
||||
}
|
||||
waitForFirstImage.value = true;
|
||||
isRefreshing = true;
|
||||
showConnectedWaitingForImage(parent.target!.dialogManager, sessionId,
|
||||
@@ -1952,6 +1957,12 @@ class ImageModel with ChangeNotifier {
|
||||
platformFFI.nextRgba(sessionId, display);
|
||||
}
|
||||
|
||||
// web only: image already created from a decoded WebCodecs frame
|
||||
Future<void> onImage(
|
||||
int display, ui.Image image, bool Function() isCurrentSession) async {
|
||||
await update(image, isCurrentSession: isCurrentSession);
|
||||
}
|
||||
|
||||
decodeAndUpdate(int display, Uint8List rgba) async {
|
||||
final pid = parent.target?.id;
|
||||
final rect = parent.target?.ffiModel.pi.getDisplayRect(display);
|
||||
@@ -1963,11 +1974,16 @@ class ImageModel with ChangeNotifier {
|
||||
? ui.PixelFormat.rgba8888
|
||||
: ui.PixelFormat.bgra8888,
|
||||
);
|
||||
if (parent.target?.id != pid) return;
|
||||
if (parent.target?.id != pid) {
|
||||
image?.dispose();
|
||||
return;
|
||||
}
|
||||
await update(image);
|
||||
}
|
||||
|
||||
update(ui.Image? image) async {
|
||||
Future<void> update(ui.Image? image,
|
||||
{bool Function()? isCurrentSession}) async {
|
||||
if (_disposeIfStale(image, isCurrentSession)) return;
|
||||
if (_image == null && image != null) {
|
||||
if (isDesktop || isWebDesktop) {
|
||||
await parent.target?.canvasModel.updateViewStyle();
|
||||
@@ -1978,11 +1994,19 @@ class ImageModel with ChangeNotifier {
|
||||
await initializeCursorAndCanvas(parent.target!);
|
||||
}
|
||||
}
|
||||
if (_disposeIfStale(image, isCurrentSession)) return;
|
||||
_image?.dispose();
|
||||
_image = image;
|
||||
if (image != null) notifyListeners();
|
||||
}
|
||||
|
||||
bool _disposeIfStale(ui.Image? image, bool Function()? isCurrentSession) {
|
||||
if (image == null || isCurrentSession == null) return false;
|
||||
if (isCurrentSession()) return false;
|
||||
image.dispose();
|
||||
return true;
|
||||
}
|
||||
|
||||
// mobile only
|
||||
double get maxScale {
|
||||
if (_image == null) return 1.5;
|
||||
@@ -3853,6 +3877,15 @@ class FFI {
|
||||
onEvent2UIRgba();
|
||||
imageModel.onRgba(display, data);
|
||||
});
|
||||
platformFFI.setVideoFrameCallback((int display, ui.Image image,
|
||||
bool Function() isCurrentSession) async {
|
||||
if (!isCurrentSession()) {
|
||||
image.dispose();
|
||||
return;
|
||||
}
|
||||
await onEvent2UIRgba();
|
||||
await imageModel.onImage(display, image, isCurrentSession);
|
||||
});
|
||||
this.id = id;
|
||||
return;
|
||||
}
|
||||
@@ -3940,7 +3973,7 @@ class FFI {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
void onEvent2UIRgba() async {
|
||||
Future<void> onEvent2UIRgba() async {
|
||||
if (ffiModel.waitForImageDialogShow.isTrue) {
|
||||
ffiModel.waitForImageDialogShow.value = false;
|
||||
ffiModel.waitForImageTimer?.cancel();
|
||||
@@ -3996,6 +4029,9 @@ class FFI {
|
||||
/// Close the remote session.
|
||||
Future<void> close({bool closeSession = true}) async {
|
||||
closed = true;
|
||||
if (isWeb) {
|
||||
platformFFI.clearVideoFrameCallback();
|
||||
}
|
||||
chatModel.close();
|
||||
// Close all terminal models
|
||||
for (final model in _terminalModels.values) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:ffi';
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:external_path/external_path.dart';
|
||||
@@ -25,6 +26,23 @@ typedef F3 = Pointer<Uint8> Function(Pointer<Utf8>, int);
|
||||
typedef F3Dart = Pointer<Uint8> Function(Pointer<Utf8>, Int32);
|
||||
typedef HandleEvent = Future<void> Function(Map<String, dynamic> evt);
|
||||
|
||||
/// The Linux bundle keeps the core library at lib/librustdesk.so next to the
|
||||
/// executable. Prefer that copy, mirroring flutter/linux/main.cc: the plain
|
||||
/// name relies on the loader search path, which repackaged installs may not
|
||||
/// cover. https://github.com/rustdesk/rustdesk/discussions/14407
|
||||
DynamicLibrary _openLinuxCoreLib() {
|
||||
final bundled =
|
||||
'${File(Platform.resolvedExecutable).parent.path}/lib/librustdesk.so';
|
||||
try {
|
||||
if (File(bundled).existsSync()) {
|
||||
return DynamicLibrary.open(bundled);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Failed to load '$bundled': $e");
|
||||
}
|
||||
return DynamicLibrary.open('librustdesk.so');
|
||||
}
|
||||
|
||||
/// FFI wrapper around the native Rust core.
|
||||
/// Hides the platform differences.
|
||||
class PlatformFFI {
|
||||
@@ -113,6 +131,12 @@ class PlatformFFI {
|
||||
void registerGpuTexture(SessionID sessionId, int display, int ptr) =>
|
||||
_ffiBind.sessionRegisterGpuTexture(
|
||||
sessionId: sessionId, display: display, ptr: ptr);
|
||||
void unregisterPixelbufferTexture(SessionID sessionId, int display, int ptr) =>
|
||||
_ffiBind.sessionUnregisterPixelbufferTexture(
|
||||
sessionId: sessionId, display: display, ptr: ptr);
|
||||
void unregisterGpuTexture(SessionID sessionId, int display, int ptr) =>
|
||||
_ffiBind.sessionUnregisterGpuTexture(
|
||||
sessionId: sessionId, display: display, ptr: ptr);
|
||||
|
||||
/// Init the FFI class, loads the native Rust core library.
|
||||
Future<void> init(String appType) async {
|
||||
@@ -120,7 +144,7 @@ class PlatformFFI {
|
||||
final dylib = isAndroid
|
||||
? DynamicLibrary.open('librustdesk.so')
|
||||
: isLinux
|
||||
? DynamicLibrary.open('librustdesk.so')
|
||||
? _openLinuxCoreLib()
|
||||
: isWindows
|
||||
? DynamicLibrary.open('librustdesk.dll')
|
||||
:
|
||||
@@ -266,6 +290,12 @@ class PlatformFFI {
|
||||
|
||||
void setRgbaCallback(void Function(int, Uint8List) fun) async {}
|
||||
|
||||
// web only, decoded WebCodecs frames arriving as ready-made images
|
||||
void setVideoFrameCallback(
|
||||
Future<void> Function(int, ui.Image, bool Function()) fun) {}
|
||||
|
||||
void clearVideoFrameCallback() {}
|
||||
|
||||
void startDesktopWebListener() {}
|
||||
|
||||
void stopDesktopWebListener() {}
|
||||
|
||||
14
flutter/lib/models/rustdesk_terminal.dart
Normal file
14
flutter/lib/models/rustdesk_terminal.dart
Normal file
@@ -0,0 +1,14 @@
|
||||
import 'package:xterm/xterm.dart';
|
||||
|
||||
class RustDeskTerminal extends Terminal {
|
||||
RustDeskTerminal({super.maxLines});
|
||||
|
||||
@override
|
||||
void eraseScrollbackOnly() {
|
||||
final scrollBack = buffer.scrollBack;
|
||||
if (scrollBack == 0) return;
|
||||
|
||||
// Selection anchors require retained buffer lines to be reindexed.
|
||||
buffer.lines.remove(0, scrollBack);
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,11 @@ import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/main.dart';
|
||||
import 'package:xterm/xterm.dart';
|
||||
|
||||
import 'input_modifier_utils.dart';
|
||||
import 'model.dart';
|
||||
import 'platform_model.dart';
|
||||
import 'rustdesk_terminal.dart';
|
||||
import 'terminal_mouse_handler.dart';
|
||||
|
||||
class TerminalModel with ChangeNotifier {
|
||||
final String id; // peer id
|
||||
@@ -22,7 +25,25 @@ class TerminalModel with ChangeNotifier {
|
||||
|
||||
bool _disposed = false;
|
||||
|
||||
/// Callback to check whether Ctrl modifier lock is currently active.
|
||||
/// When active, keyboard input is mapped to control codes (e.g. 'b' → \x02).
|
||||
bool Function()? isCtrlLocked;
|
||||
|
||||
/// Callback to clear Ctrl lock after a key is pressed (one-shot mode).
|
||||
void Function()? clearCtrlLock;
|
||||
|
||||
/// Callback to check whether Alt modifier lock is currently active.
|
||||
bool Function()? isAltLocked;
|
||||
|
||||
/// Callback to clear Alt lock after a key is pressed (one-shot mode).
|
||||
void Function()? clearAltLock;
|
||||
|
||||
final _inputBuffer = <String>[];
|
||||
|
||||
/// Exposes buffered input only for lifecycle regression tests.
|
||||
@visibleForTesting
|
||||
int get debugBufferedInputCount => _inputBuffer.length;
|
||||
|
||||
// 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>[];
|
||||
@@ -42,6 +63,10 @@ class TerminalModel with ChangeNotifier {
|
||||
VoidCallback? onClosed;
|
||||
|
||||
Future<void> _handleInput(String data) async {
|
||||
// xterm can complete asynchronous input after the Flutter page has gone
|
||||
// away. Stop before reading or clearing widget-owned modifier state.
|
||||
if (_disposed) return;
|
||||
|
||||
// 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.
|
||||
@@ -49,13 +74,44 @@ class TerminalModel with ChangeNotifier {
|
||||
// (readline, prompt_toolkit, vim, TUI frameworks) expect '\r'.
|
||||
// - Peer macOS: same as Linux, raw-mode apps expect '\r'
|
||||
// (https://github.com/rustdesk/rustdesk/issues/14907).
|
||||
// So on mobile / web-mobile, always normalize a lone '\n' to '\r'.
|
||||
// We deliberately do not touch multi-character payloads (e.g. pasted text)
|
||||
// so embedded newlines in pasted content are preserved.
|
||||
final isMobileOrWebMobile = (isMobile || (isWeb && !isWebDesktop));
|
||||
if (isMobileOrWebMobile && data == '\n') {
|
||||
data = '\r';
|
||||
// So on mobile / web-mobile, normalize the original lone '\n' to '\r'
|
||||
// before modifier mappings. This keeps Ctrl+J mapped to LF instead of
|
||||
// having the generated control code rewritten to CR afterward.
|
||||
// Multi-character keyboard payloads, such as terminal escape sequences,
|
||||
// remain unchanged. Paste input follows a separate preprocessing path.
|
||||
final ctrlLocked = isCtrlLocked?.call() ?? false;
|
||||
final altLocked = isAltLocked?.call() ?? false;
|
||||
final modifiersActive = ctrlLocked || altLocked;
|
||||
// Use the same predicate for transformation and consumption. Control keys
|
||||
// and escape sequences must not silently consume a pending one-shot lock.
|
||||
final shouldConsumeModifiers =
|
||||
modifiersActive && shouldApplyTerminalInputModifiers(data);
|
||||
data = prepareTerminalInputPayload(
|
||||
data,
|
||||
// IME soft-keyboard paste prompts currently arrive from xterm as normal
|
||||
// text input with no paste-origin metadata. Keep them on the keyboard path;
|
||||
// clipboard-content heuristics can misclassify ordinary typing.
|
||||
source: TerminalInputSource.keyboard,
|
||||
isMobileOrWebMobile: isMobile || (isWeb && !isWebDesktop),
|
||||
bracketedPasteMode: terminal.bracketedPasteMode,
|
||||
ctrlLocked: ctrlLocked,
|
||||
altLocked: altLocked,
|
||||
);
|
||||
if (shouldConsumeModifiers) {
|
||||
if (ctrlLocked) clearCtrlLock?.call();
|
||||
if (altLocked) clearAltLock?.call();
|
||||
}
|
||||
return _sendInputPayload(data);
|
||||
}
|
||||
|
||||
/// Sends an already prepared payload without applying keyboard semantics.
|
||||
/// Both normal input and paste use this transport path after their source-
|
||||
/// specific preprocessing has completed.
|
||||
Future<void> _sendInputPayload(String data) async {
|
||||
// Clipboard reads and native sends may complete after the terminal page has
|
||||
// closed. Never send or re-buffer input once this model is disposed.
|
||||
if (_disposed) return;
|
||||
|
||||
if (_terminalOpened) {
|
||||
// Send user input to remote terminal
|
||||
try {
|
||||
@@ -74,7 +130,8 @@ class TerminalModel with ChangeNotifier {
|
||||
}
|
||||
|
||||
TerminalModel(this.parent, [this.terminalId = 0]) : id = parent.id {
|
||||
terminal = Terminal(maxLines: 10000);
|
||||
terminal = RustDeskTerminal(maxLines: 10000);
|
||||
terminal.mouseHandler = const WheelButtonFixMouseHandler();
|
||||
terminalController = TerminalController();
|
||||
|
||||
// Setup terminal callbacks
|
||||
@@ -176,6 +233,18 @@ class TerminalModel with ChangeNotifier {
|
||||
return _handleInput(data);
|
||||
}
|
||||
|
||||
Future<void> pasteText(String data) async {
|
||||
final payload = prepareTerminalInputPayload(
|
||||
data,
|
||||
source: TerminalInputSource.paste,
|
||||
isMobileOrWebMobile: false,
|
||||
bracketedPasteMode: terminal.bracketedPasteMode,
|
||||
ctrlLocked: false,
|
||||
altLocked: false,
|
||||
);
|
||||
return _sendInputPayload(payload);
|
||||
}
|
||||
|
||||
Future<void> closeTerminal() async {
|
||||
if (_terminalOpened) {
|
||||
try {
|
||||
@@ -516,6 +585,14 @@ class TerminalModel with ChangeNotifier {
|
||||
void dispose() {
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
terminal.onOutput = null;
|
||||
terminal.onResize = null;
|
||||
isCtrlLocked = null;
|
||||
clearCtrlLock = null;
|
||||
isAltLocked = null;
|
||||
clearAltLock = null;
|
||||
onResizeExternal = null;
|
||||
onClosed = null;
|
||||
// Clear buffers to free memory
|
||||
_inputBuffer.clear();
|
||||
_pendingOutputChunks.clear();
|
||||
|
||||
42
flutter/lib/models/terminal_mouse_handler.dart
Normal file
42
flutter/lib/models/terminal_mouse_handler.dart
Normal file
@@ -0,0 +1,42 @@
|
||||
import 'package:xterm/xterm.dart';
|
||||
|
||||
/// xterm 4.0.0 encodes wheel buttons as 68..71; the extra bit reads as a Shift
|
||||
/// modifier, so strict full-screen apps ignore the report and never scroll.
|
||||
/// Upstream fix: TerminalStudio/xterm.dart#238.
|
||||
class WheelButtonFixMouseHandler implements TerminalMouseHandler {
|
||||
const WheelButtonFixMouseHandler();
|
||||
|
||||
@override
|
||||
String? call(TerminalMouseEvent event) {
|
||||
if (!event.button.isWheel) {
|
||||
return defaultMouseHandler(event);
|
||||
}
|
||||
// Same gate as UpDownMouseHandler: only the scroll modes report a wheel,
|
||||
// and a wheel release is never reported, so the report is always a press.
|
||||
if (!event.state.mouseMode.reportScroll ||
|
||||
event.buttonState == TerminalMouseButtonState.up) {
|
||||
return null;
|
||||
}
|
||||
return _reportWheel(event);
|
||||
}
|
||||
|
||||
String _reportWheel(TerminalMouseEvent event) {
|
||||
// Wheel buttons 4..7 go on the wire as 64..67, but `id` is 64 + 4..7.
|
||||
final button = event.button.id - 4;
|
||||
final x = event.position.x + 1;
|
||||
final y = event.position.y + 1;
|
||||
switch (event.state.mouseReportMode) {
|
||||
case MouseReportMode.normal:
|
||||
case MouseReportMode.utf:
|
||||
final limit =
|
||||
event.state.mouseReportMode == MouseReportMode.normal ? 223 : 2015;
|
||||
final col = x > limit ? '\x00' : String.fromCharCode(32 + x);
|
||||
final row = y > limit ? '\x00' : String.fromCharCode(32 + y);
|
||||
return '\x1b[M${String.fromCharCode(32 + button)}$col$row';
|
||||
case MouseReportMode.sgr:
|
||||
return '\x1b[<$button;$x;${y}M';
|
||||
case MouseReportMode.urxvt:
|
||||
return '\x1b[${32 + button};$x;${y}M';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,9 @@ class UserModel {
|
||||
final RxString avatar = ''.obs;
|
||||
final RxBool isAdmin = false.obs;
|
||||
final RxString networkError = ''.obs;
|
||||
// True when networkError carries a server-reported error rather than a
|
||||
// connectivity failure; netWorkErrorWidget hides the network tip then.
|
||||
final RxBool networkErrorFromServer = false.obs;
|
||||
bool get isLogin => userName.isNotEmpty;
|
||||
String get displayNameOrUserName =>
|
||||
displayName.value.trim().isEmpty ? userName.value : displayName.value;
|
||||
@@ -50,6 +53,7 @@ class UserModel {
|
||||
void refreshCurrentUser() async {
|
||||
if (bind.isDisableAccount()) return;
|
||||
networkError.value = '';
|
||||
networkErrorFromServer.value = false;
|
||||
final token = bind.mainGetLocalOption(key: 'access_token');
|
||||
if (token == '') {
|
||||
await updateOtherModels();
|
||||
@@ -85,6 +89,10 @@ class UserModel {
|
||||
final data = json.decode(decode_http_response(response));
|
||||
final error = data['error'];
|
||||
if (error != null) {
|
||||
// The only failure known to come from the server itself, so the
|
||||
// check-your-network tip does not apply. Flag before the message is
|
||||
// set in the catch below so rebuilds read a consistent pair.
|
||||
networkErrorFromServer.value = true;
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -92,6 +100,13 @@ class UserModel {
|
||||
_parseAndUpdateUser(user);
|
||||
} catch (e) {
|
||||
debugPrint('Failed to refreshCurrentUser: $e');
|
||||
// Surface failures in the address book / group tabs, which offer a
|
||||
// retry. Anything not flagged above -- transport errors, non-JSON or
|
||||
// unexpected-schema bodies (e.g. a filter's block page) -- keeps the
|
||||
// check-your-network tip.
|
||||
if (networkError.value.isEmpty) {
|
||||
networkError.value = e.toString();
|
||||
}
|
||||
} finally {
|
||||
refreshingUser = false;
|
||||
await updateOtherModels();
|
||||
@@ -219,28 +234,32 @@ class UserModel {
|
||||
return loginResponse;
|
||||
}
|
||||
|
||||
/// Throws on network failures, non-success responses, and invalid response
|
||||
/// data. Returns an empty list when no API server is configured or a
|
||||
/// successful response contains no third-party login options.
|
||||
static Future<List<dynamic>> queryOidcLoginOptions() async {
|
||||
try {
|
||||
final url = await bind.mainGetApiServer();
|
||||
if (url.trim().isEmpty) return [];
|
||||
final resp = await http.get(Uri.parse('$url/api/login-options'));
|
||||
final List<String> ops = [];
|
||||
for (final item in jsonDecode(resp.body)) {
|
||||
ops.add(item as String);
|
||||
}
|
||||
for (final item in ops) {
|
||||
if (item.startsWith('common-oidc/')) {
|
||||
return jsonDecode(item.substring('common-oidc/'.length));
|
||||
}
|
||||
}
|
||||
return ops
|
||||
.where((item) => item.startsWith('oidc/'))
|
||||
.map((item) => {'name': item.substring('oidc/'.length)})
|
||||
.toList();
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
"queryOidcLoginOptions: jsonDecode resp body failed: ${e.toString()}");
|
||||
return [];
|
||||
final url = await bind.mainGetApiServer();
|
||||
if (url.trim().isEmpty) return [];
|
||||
final resp = await http.get(Uri.parse('$url/api/login-options'));
|
||||
const successStatusCodeStart = 200;
|
||||
const successStatusCodeEnd = 300;
|
||||
if (resp.statusCode < successStatusCodeStart ||
|
||||
resp.statusCode >= successStatusCodeEnd) {
|
||||
throw RequestException(
|
||||
resp.statusCode, resp.reasonPhrase ?? 'Request failed');
|
||||
}
|
||||
final List<String> ops = [];
|
||||
for (final item in jsonDecode(resp.body)) {
|
||||
ops.add(item as String);
|
||||
}
|
||||
for (final item in ops) {
|
||||
if (item.startsWith('common-oidc/')) {
|
||||
return jsonDecode(item.substring('common-oidc/'.length));
|
||||
}
|
||||
}
|
||||
return ops
|
||||
.where((item) => item.startsWith('oidc/'))
|
||||
.map((item) => {'name': item.substring('oidc/'.length)})
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,18 @@
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:js_interop';
|
||||
import 'dart:js_interop_unsafe';
|
||||
import 'dart:typed_data';
|
||||
import 'dart:js';
|
||||
import 'dart:html';
|
||||
import 'dart:async';
|
||||
import 'dart:ui' as ui;
|
||||
import 'dart:ui_web' as ui_web;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_hbb/common/widgets/login.dart';
|
||||
import 'package:flutter_hbb/models/state_model.dart';
|
||||
import 'package:flutter_hbb/models/web_video_frame_queue.dart';
|
||||
|
||||
import 'package:flutter_hbb/web/bridge.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
@@ -18,6 +22,22 @@ import 'package:uuid/uuid.dart';
|
||||
final List<StreamSubscription<MouseEvent>> mouseListeners = [];
|
||||
final List<StreamSubscription<KeyboardEvent>> keyListeners = [];
|
||||
|
||||
// WebCodecs VideoFrames handed over from js/src/webcodecs.js arrive as plain
|
||||
// interop objects (the package language version predates extension types).
|
||||
// This side owns each frame and must close it quickly: hardware decoders
|
||||
// stall once their small output frame pool is exhausted.
|
||||
int _videoFrameWidth(JSObject frame) =>
|
||||
frame.getProperty<JSNumber>('displayWidth'.toJS).toDartInt;
|
||||
int _videoFrameHeight(JSObject frame) =>
|
||||
frame.getProperty<JSNumber>('displayHeight'.toJS).toDartInt;
|
||||
void _closeVideoFrame(JSObject frame) {
|
||||
try {
|
||||
frame.callMethod<JSAny?>('close'.toJS);
|
||||
} catch (error) {
|
||||
debugPrint('VideoFrame.close failed: $error');
|
||||
}
|
||||
}
|
||||
|
||||
typedef HandleEvent = Future<void> Function(Map<String, dynamic> evt);
|
||||
|
||||
class PlatformFFI {
|
||||
@@ -33,6 +53,13 @@ class PlatformFFI {
|
||||
}
|
||||
|
||||
PlatformFFI._() {
|
||||
_videoFrameQueue = WebVideoFrameQueue(
|
||||
importFrame: _importVideoFrame,
|
||||
closeFrame: _closeVideoFrame,
|
||||
disposeImage: (image) => image.dispose(),
|
||||
onImportError: _handleVideoFrameImportError,
|
||||
onCallbackError: _handleVideoImageCallbackError,
|
||||
);
|
||||
window.document.addEventListener(
|
||||
'visibilitychange',
|
||||
(event) => {
|
||||
@@ -109,6 +136,12 @@ class PlatformFFI {
|
||||
void registerGpuTexture(SessionID sessionId, int display, int ptr) =>
|
||||
_ffiBind.sessionRegisterGpuTexture(
|
||||
sessionId: sessionId, display: display, ptr: ptr);
|
||||
void unregisterPixelbufferTexture(SessionID sessionId, int display, int ptr) =>
|
||||
_ffiBind.sessionUnregisterPixelbufferTexture(
|
||||
sessionId: sessionId, display: display, ptr: ptr);
|
||||
void unregisterGpuTexture(SessionID sessionId, int display, int ptr) =>
|
||||
_ffiBind.sessionUnregisterGpuTexture(
|
||||
sessionId: sessionId, display: display, ptr: ptr);
|
||||
|
||||
Future<void> init(String appType) async {
|
||||
Completer completer = Completer();
|
||||
@@ -162,6 +195,46 @@ class PlatformFFI {
|
||||
};
|
||||
}
|
||||
|
||||
late final WebVideoFrameQueue<JSObject, ui.Image> _videoFrameQueue;
|
||||
|
||||
// Zero-readback video path: the JS decoder hands decoded VideoFrames here
|
||||
// (checking typeof window.onVideoFrame before every frame), and the engine
|
||||
// imports them GPU-to-GPU via createImageBitmap. Unregistering the JS global
|
||||
// reverts the JS side to the RGBA readback path.
|
||||
void setVideoFrameCallback(
|
||||
Future<void> Function(int, ui.Image, bool Function()) fun) {
|
||||
_videoFrameQueue.beginSession(fun);
|
||||
if (!_videoFrameQueue.isEnabled) return;
|
||||
globalContext.setProperty(
|
||||
'onVideoFrame'.toJS,
|
||||
((JSNumber display, JSObject frame) {
|
||||
_videoFrameQueue.submit(display.toDartInt, frame);
|
||||
}).toJS,
|
||||
);
|
||||
}
|
||||
|
||||
void clearVideoFrameCallback() {
|
||||
_videoFrameQueue.endSession();
|
||||
globalContext.setProperty('onVideoFrame'.toJS, null);
|
||||
}
|
||||
|
||||
Future<ui.Image> _importVideoFrame(JSObject frame) async {
|
||||
return await ui_web.createImageFromTextureSource(frame,
|
||||
width: _videoFrameWidth(frame), height: _videoFrameHeight(frame));
|
||||
}
|
||||
|
||||
void _handleVideoFrameImportError(Object error, StackTrace stackTrace) {
|
||||
debugPrintStack(
|
||||
label: 'createImageFromTextureSource failed, using RGBA path: $error',
|
||||
stackTrace: stackTrace);
|
||||
globalContext.setProperty('onVideoFrame'.toJS, null);
|
||||
}
|
||||
|
||||
void _handleVideoImageCallbackError(Object error, StackTrace stackTrace) {
|
||||
debugPrintStack(
|
||||
label: 'video image callback error: $error', stackTrace: stackTrace);
|
||||
}
|
||||
|
||||
void startDesktopWebListener() {
|
||||
mouseListeners.add(
|
||||
window.document.onContextMenu.listen((evt) => evt.preventDefault()));
|
||||
|
||||
133
flutter/lib/models/web_video_frame_queue.dart
Normal file
133
flutter/lib/models/web_video_frame_queue.dart
Normal file
@@ -0,0 +1,133 @@
|
||||
import 'dart:async';
|
||||
|
||||
typedef VideoFrameImporter<Frame, Image> = Future<Image> Function(Frame frame);
|
||||
typedef VideoFrameCloser<Frame> = void Function(Frame frame);
|
||||
typedef VideoImageDisposer<Image> = void Function(Image image);
|
||||
typedef VideoSessionValidator = bool Function();
|
||||
typedef VideoImageCallback<Image> = Future<void> Function(
|
||||
int display, Image image, VideoSessionValidator isCurrentSession);
|
||||
typedef VideoQueueErrorCallback = void Function(
|
||||
Object error, StackTrace stackTrace);
|
||||
|
||||
class WebVideoFrameQueue<Frame, Image> {
|
||||
WebVideoFrameQueue({
|
||||
required VideoFrameImporter<Frame, Image> importFrame,
|
||||
required VideoFrameCloser<Frame> closeFrame,
|
||||
required VideoImageDisposer<Image> disposeImage,
|
||||
required VideoQueueErrorCallback onImportError,
|
||||
required VideoQueueErrorCallback onCallbackError,
|
||||
}) : _importFrame = importFrame,
|
||||
_closeFrame = closeFrame,
|
||||
_disposeImage = disposeImage,
|
||||
_onImportError = onImportError,
|
||||
_onCallbackError = onCallbackError;
|
||||
|
||||
final VideoFrameImporter<Frame, Image> _importFrame;
|
||||
final VideoFrameCloser<Frame> _closeFrame;
|
||||
final VideoImageDisposer<Image> _disposeImage;
|
||||
final VideoQueueErrorCallback _onImportError;
|
||||
final VideoQueueErrorCallback _onCallbackError;
|
||||
final Map<int, _QueuedFrame<Frame>> _pending = {};
|
||||
|
||||
VideoImageCallback<Image>? _callback;
|
||||
int _generation = 0;
|
||||
bool _processing = false;
|
||||
bool _enabled = true;
|
||||
|
||||
bool get isEnabled => _enabled;
|
||||
|
||||
void beginSession(VideoImageCallback<Image> callback) {
|
||||
_invalidateSession();
|
||||
_enabled = true;
|
||||
_callback = callback;
|
||||
}
|
||||
|
||||
void endSession() {
|
||||
_invalidateSession();
|
||||
_callback = null;
|
||||
}
|
||||
|
||||
void _invalidateSession() {
|
||||
_generation++;
|
||||
for (final queued in _pending.values) {
|
||||
_closeFrame(queued.frame);
|
||||
}
|
||||
_pending.clear();
|
||||
}
|
||||
|
||||
bool submit(int display, Frame frame) {
|
||||
if (!_enabled || _callback == null) {
|
||||
_closeFrame(frame);
|
||||
return false;
|
||||
}
|
||||
final replaced = _pending.remove(display);
|
||||
if (replaced != null) {
|
||||
_closeFrame(replaced.frame);
|
||||
}
|
||||
_pending[display] = _QueuedFrame(display, frame, _generation);
|
||||
_startProcessing();
|
||||
return true;
|
||||
}
|
||||
|
||||
void _startProcessing() {
|
||||
if (_processing) return;
|
||||
_processing = true;
|
||||
unawaited(Future<void>(_process));
|
||||
}
|
||||
|
||||
Future<void> _process() async {
|
||||
while (_pending.isNotEmpty) {
|
||||
final display = _pending.keys.first;
|
||||
final queued = _pending.remove(display)!;
|
||||
if (!_enabled || queued.generation != _generation) {
|
||||
_closeFrame(queued.frame);
|
||||
continue;
|
||||
}
|
||||
await _importAndDeliver(queued);
|
||||
}
|
||||
_processing = false;
|
||||
}
|
||||
|
||||
Future<void> _importAndDeliver(_QueuedFrame<Frame> queued) async {
|
||||
Image? image;
|
||||
try {
|
||||
image = await _importFrame(queued.frame);
|
||||
} catch (error, stackTrace) {
|
||||
if (queued.generation == _generation) {
|
||||
_enabled = false;
|
||||
_onImportError(error, stackTrace);
|
||||
}
|
||||
} finally {
|
||||
_closeFrame(queued.frame);
|
||||
}
|
||||
if (image != null) {
|
||||
await _deliver(queued, image);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deliver(_QueuedFrame<Frame> queued, Image image) async {
|
||||
final callback = _callback;
|
||||
bool isCurrentSession() =>
|
||||
_enabled &&
|
||||
queued.generation == _generation &&
|
||||
identical(callback, _callback);
|
||||
if (!isCurrentSession() || callback == null) {
|
||||
_disposeImage(image);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await callback(queued.display, image, isCurrentSession);
|
||||
} catch (error, stackTrace) {
|
||||
_disposeImage(image);
|
||||
_onCallbackError(error, stackTrace);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _QueuedFrame<Frame> {
|
||||
const _QueuedFrame(this.display, this.frame, this.generation);
|
||||
|
||||
final int display;
|
||||
final Frame frame;
|
||||
final int generation;
|
||||
}
|
||||
@@ -44,32 +44,51 @@ class HttpService {
|
||||
return _parseHttpResponse(resJson);
|
||||
}
|
||||
|
||||
// Bounds only the pure-Dart branch below, which the OS would otherwise
|
||||
// let hang forever (e.g. a black-holed TLS handshake), see #15700.
|
||||
// The Rust branch has its own 12s-per-attempt timeouts and must be
|
||||
// awaited to completion: a Dart-side timeout there would race the
|
||||
// URL-keyed ASYNC_HTTP_STATUS entry of the abandoned request.
|
||||
static const _requestTimeout = Duration(seconds: 30);
|
||||
|
||||
Future<http.Response> _pollFlutterHttp(
|
||||
Uri url,
|
||||
HttpMethod method, {
|
||||
Map<String, String>? headers,
|
||||
dynamic body,
|
||||
}) async {
|
||||
var response = http.Response('', 400);
|
||||
final client = http.Client();
|
||||
try {
|
||||
var response = http.Response('', 400);
|
||||
|
||||
switch (method) {
|
||||
case HttpMethod.get:
|
||||
response = await http.get(url, headers: headers);
|
||||
break;
|
||||
case HttpMethod.post:
|
||||
response = await http.post(url, headers: headers, body: body);
|
||||
break;
|
||||
case HttpMethod.put:
|
||||
response = await http.put(url, headers: headers, body: body);
|
||||
break;
|
||||
case HttpMethod.delete:
|
||||
response = await http.delete(url, headers: headers, body: body);
|
||||
break;
|
||||
default:
|
||||
throw Exception('Unsupported HTTP method');
|
||||
switch (method) {
|
||||
case HttpMethod.get:
|
||||
response =
|
||||
await client.get(url, headers: headers).timeout(_requestTimeout);
|
||||
break;
|
||||
case HttpMethod.post:
|
||||
response = await client
|
||||
.post(url, headers: headers, body: body)
|
||||
.timeout(_requestTimeout);
|
||||
break;
|
||||
case HttpMethod.put:
|
||||
response = await client
|
||||
.put(url, headers: headers, body: body)
|
||||
.timeout(_requestTimeout);
|
||||
break;
|
||||
case HttpMethod.delete:
|
||||
response = await client
|
||||
.delete(url, headers: headers, body: body)
|
||||
.timeout(_requestTimeout);
|
||||
break;
|
||||
default:
|
||||
throw Exception('Unsupported HTTP method');
|
||||
}
|
||||
|
||||
return response;
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
Future<String> _pollForResponse(String url) async {
|
||||
|
||||
@@ -1450,6 +1450,31 @@ class RustdeskImpl {
|
||||
required int ptr,
|
||||
dynamic hint}) {}
|
||||
|
||||
void sessionUnregisterPixelbufferTexture(
|
||||
{required UuidValue sessionId,
|
||||
required int display,
|
||||
required int ptr,
|
||||
dynamic hint}) {}
|
||||
|
||||
void sessionUnregisterGpuTexture(
|
||||
{required UuidValue sessionId,
|
||||
required int display,
|
||||
required int ptr,
|
||||
dynamic hint}) {}
|
||||
|
||||
void sessionSetRenderVisible(
|
||||
{required UuidValue sessionId, required bool visible, dynamic hint}) {}
|
||||
|
||||
bool mainTextureRenderProbeSupported({dynamic hint}) {
|
||||
return false;
|
||||
}
|
||||
|
||||
void mainPushTextureProbeFrame({required int ptr, dynamic hint}) {}
|
||||
|
||||
int mainGetTextureProbeConsumed({required int ptr, dynamic hint}) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
Future<void> queryOnlines({required List<String> ids, dynamic hint}) {
|
||||
return Future(() =>
|
||||
js.context.callMethod('setByName', ['query_onlines', jsonEncode(ids)]));
|
||||
|
||||
@@ -12,3 +12,5 @@ Future<void> webSendLocalFiles(
|
||||
required bool isRemote}) {
|
||||
throw UnimplementedError("webSendLocalFiles");
|
||||
}
|
||||
|
||||
Future<void> loadLocalTerminalFontIfNeeded() async {}
|
||||
|
||||
33
flutter/lib/web/terminal_font.dart
Normal file
33
flutter/lib/web/terminal_font.dart
Normal file
@@ -0,0 +1,33 @@
|
||||
import 'dart:html' as html;
|
||||
import 'dart:js' as js;
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
bool _loadRequested = false;
|
||||
|
||||
/// When Google CDNs are unreachable, `index.html` sets
|
||||
/// `window.rustdeskLocalFonts` and `GoogleFonts.robotoMono()` cannot download
|
||||
/// the terminal font. Load the copy bundled with the web app instead,
|
||||
/// registered under the family name google_fonts gives the terminal's
|
||||
/// TextStyle ('RobotoMono_regular').
|
||||
Future<void> loadLocalTerminalFontIfNeeded() async {
|
||||
if (_loadRequested || js.context['rustdeskLocalFonts'] != true) {
|
||||
return;
|
||||
}
|
||||
_loadRequested = true;
|
||||
try {
|
||||
final req = await html.HttpRequest.request(
|
||||
'fonts/RobotoMono-Regular.ttf',
|
||||
responseType: 'arraybuffer',
|
||||
);
|
||||
final data = ByteData.view(req.response as ByteBuffer);
|
||||
final loader = FontLoader('RobotoMono_regular')
|
||||
..addFont(Future.value(data));
|
||||
await loader.load();
|
||||
} catch (e) {
|
||||
_loadRequested = false;
|
||||
debugPrint('Failed to load bundled Roboto Mono: $e');
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
#include <dlfcn.h>
|
||||
#include <limits.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include "my_application.h"
|
||||
|
||||
#define RUSTDESK_LIB_PATH "librustdesk.so"
|
||||
@@ -7,8 +11,36 @@ bool gIsConnectionManager = false;
|
||||
|
||||
void print_help_install_pkg(const char* so);
|
||||
|
||||
// The bundle keeps the core library at lib/librustdesk.so next to the
|
||||
// executable. Resolve that path explicitly instead of relying on the
|
||||
// runner's RPATH, which repackaged installs may strip.
|
||||
// https://github.com/rustdesk/rustdesk/discussions/14407
|
||||
static void* dlopen_bundled_lib() {
|
||||
char exe_path[PATH_MAX];
|
||||
ssize_t len = readlink("/proc/self/exe", exe_path, sizeof(exe_path) - 1);
|
||||
if (len <= 0 || len >= (ssize_t)(sizeof(exe_path) - 1)) return nullptr;
|
||||
exe_path[len] = '\0';
|
||||
char* last_slash = strrchr(exe_path, '/');
|
||||
if (last_slash == nullptr) return nullptr;
|
||||
*last_slash = '\0';
|
||||
char lib_path[PATH_MAX + sizeof("/lib/" RUSTDESK_LIB_PATH)];
|
||||
snprintf(lib_path, sizeof(lib_path), "%s/lib/%s", exe_path, RUSTDESK_LIB_PATH);
|
||||
if (access(lib_path, F_OK) != 0) return nullptr;
|
||||
void* librustdesk = dlopen(lib_path, RTLD_LAZY);
|
||||
if (!librustdesk) {
|
||||
char* error = dlerror();
|
||||
if (error != nullptr) {
|
||||
fprintf(stderr, "Failed to load \"%s\": %s\n", lib_path, error);
|
||||
}
|
||||
}
|
||||
return librustdesk;
|
||||
}
|
||||
|
||||
bool flutter_rustdesk_core_main() {
|
||||
void* librustdesk = dlopen(RUSTDESK_LIB_PATH, RTLD_LAZY);
|
||||
void* librustdesk = dlopen_bundled_lib();
|
||||
if (!librustdesk) {
|
||||
librustdesk = dlopen(RUSTDESK_LIB_PATH, RTLD_LAZY);
|
||||
}
|
||||
if (!librustdesk) {
|
||||
fprintf(stderr,"Failed to load \"librustdesk.so\"\n");
|
||||
char* error;
|
||||
|
||||
@@ -340,7 +340,7 @@ packages:
|
||||
description:
|
||||
path: "."
|
||||
ref: HEAD
|
||||
resolved-ref: b47e8385e5a75d38319ad706a64b0ead3108b093
|
||||
resolved-ref: 533883bcb0ffe91a9afdb13b8bac9b14b3e054ba
|
||||
url: "https://github.com/rustdesk-org/rustdesk_desktop_multi_window"
|
||||
source: git
|
||||
version: "0.1.0"
|
||||
@@ -538,8 +538,8 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: "08a471bb8ceccdd50483c81cdfa8b81b07b14b87"
|
||||
resolved-ref: "08a471bb8ceccdd50483c81cdfa8b81b07b14b87"
|
||||
ref: "208619e750a5fd904c689a9babd6ccf0f7c1ca88"
|
||||
resolved-ref: "208619e750a5fd904c689a9babd6ccf0f7c1ca88"
|
||||
url: "https://github.com/rustdesk-org/flutter_gpu_texture_renderer"
|
||||
source: git
|
||||
version: "0.0.1"
|
||||
@@ -1298,8 +1298,8 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: "42797e0f03141dc2b585f76c64a13974508058b4"
|
||||
resolved-ref: "42797e0f03141dc2b585f76c64a13974508058b4"
|
||||
ref: "883326ddd4fb2af1484bf873b4ea856a0ac440bc"
|
||||
resolved-ref: "883326ddd4fb2af1484bf873b4ea856a0ac440bc"
|
||||
url: "https://github.com/rustdesk-org/flutter_texture_rgba_renderer"
|
||||
source: git
|
||||
version: "0.0.16"
|
||||
@@ -1589,7 +1589,7 @@ packages:
|
||||
description:
|
||||
path: "."
|
||||
ref: HEAD
|
||||
resolved-ref: "85789bfe6e4cfaf4ecc00c52857467fdb7f26879"
|
||||
resolved-ref: cf4aef0512092fad9344a27ffe1c47ad83269dfc
|
||||
url: "https://github.com/rustdesk-org/window_manager"
|
||||
source: git
|
||||
version: "0.3.6"
|
||||
|
||||
@@ -88,13 +88,13 @@ dependencies:
|
||||
texture_rgba_renderer:
|
||||
git:
|
||||
url: https://github.com/rustdesk-org/flutter_texture_rgba_renderer
|
||||
ref: 42797e0f03141dc2b585f76c64a13974508058b4
|
||||
ref: 883326ddd4fb2af1484bf873b4ea856a0ac440bc
|
||||
percent_indicator: ^4.2.2
|
||||
dropdown_button2: ^2.0.0
|
||||
flutter_gpu_texture_renderer:
|
||||
git:
|
||||
url: https://github.com/rustdesk-org/flutter_gpu_texture_renderer
|
||||
ref: 08a471bb8ceccdd50483c81cdfa8b81b07b14b87
|
||||
ref: 208619e750a5fd904c689a9babd6ccf0f7c1ca88
|
||||
uuid: ^3.0.7
|
||||
auto_size_text_field: ^2.2.1
|
||||
flex_color_picker: ^3.3.0
|
||||
|
||||
@@ -122,4 +122,394 @@ void main() {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('shouldApplyTerminalInputModifiers', () {
|
||||
test('accepts ordinary single-character keyboard input', () {
|
||||
expect(shouldApplyTerminalInputModifiers('a'), isTrue);
|
||||
expect(shouldApplyTerminalInputModifiers(' '), isTrue);
|
||||
expect(shouldApplyTerminalInputModifiers('/'), isTrue);
|
||||
});
|
||||
|
||||
test('accepts supplementary-plane single-character keyboard input', () {
|
||||
expect(shouldApplyTerminalInputModifiers('😀'), isTrue);
|
||||
});
|
||||
|
||||
test('rejects terminal control bytes and multi-character sequences', () {
|
||||
for (final input in ['\x00', '\x03', '\t', '\n', '\r', '\x1B', '\x7F']) {
|
||||
expect(
|
||||
shouldApplyTerminalInputModifiers(input),
|
||||
isFalse,
|
||||
reason: '${input.codeUnits} must not consume a one-shot modifier',
|
||||
);
|
||||
}
|
||||
expect(shouldApplyTerminalInputModifiers('\x1B[A'), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('applyTerminalInputModifiers', () {
|
||||
test('keeps decomposed graphemes intact under Ctrl', () {
|
||||
const decomposedEAcute = 'e\u0301';
|
||||
|
||||
expect(
|
||||
applyTerminalInputModifiers(
|
||||
decomposedEAcute,
|
||||
ctrlLocked: true,
|
||||
altLocked: false,
|
||||
),
|
||||
decomposedEAcute,
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps non-ASCII graphemes intact under Ctrl', () {
|
||||
for (final input in ['é', '😀']) {
|
||||
expect(
|
||||
applyTerminalInputModifiers(
|
||||
input,
|
||||
ctrlLocked: true,
|
||||
altLocked: false,
|
||||
),
|
||||
input,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('maps Ctrl underscore to unit separator', () {
|
||||
expect(
|
||||
applyTerminalInputModifiers(
|
||||
'_',
|
||||
ctrlLocked: true,
|
||||
altLocked: false,
|
||||
),
|
||||
'\x1F',
|
||||
);
|
||||
});
|
||||
|
||||
test('maps the complete Ctrl symbol range', () {
|
||||
const mappings = {
|
||||
'[': '\x1B',
|
||||
r'\': '\x1C',
|
||||
']': '\x1D',
|
||||
'^': '\x1E',
|
||||
'_': '\x1F',
|
||||
'/': '\x1F',
|
||||
};
|
||||
|
||||
for (final entry in mappings.entries) {
|
||||
expect(
|
||||
applyTerminalInputModifiers(
|
||||
entry.key,
|
||||
ctrlLocked: true,
|
||||
altLocked: false,
|
||||
),
|
||||
entry.value,
|
||||
reason: 'Ctrl+${entry.key} should map to ${entry.value.codeUnits}',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('applies Ctrl before Alt for combined modifiers', () {
|
||||
expect(
|
||||
applyTerminalInputModifiers(
|
||||
'b',
|
||||
ctrlLocked: true,
|
||||
altLocked: true,
|
||||
),
|
||||
'\x1B\x02',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('terminalPastePayload', () {
|
||||
test('wraps paste text when bracketed paste mode is active', () {
|
||||
expect(
|
||||
terminalPastePayload('d', bracketedPasteMode: true),
|
||||
'\x1B[200~d\x1B[201~',
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps a lone newline unchanged when bracketed paste is disabled', () {
|
||||
expect(
|
||||
terminalPastePayload('\n', bracketedPasteMode: false),
|
||||
'\n',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('prepareTerminalInputPayload', () {
|
||||
test('normalizes a mobile keyboard Enter to carriage return', () {
|
||||
expect(
|
||||
prepareTerminalInputPayload(
|
||||
'\n',
|
||||
source: TerminalInputSource.keyboard,
|
||||
isMobileOrWebMobile: true,
|
||||
bracketedPasteMode: false,
|
||||
ctrlLocked: false,
|
||||
altLocked: false,
|
||||
),
|
||||
'\r',
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps Ctrl+J as line feed on mobile', () {
|
||||
expect(
|
||||
prepareTerminalInputPayload(
|
||||
'j',
|
||||
source: TerminalInputSource.keyboard,
|
||||
isMobileOrWebMobile: true,
|
||||
bracketedPasteMode: false,
|
||||
ctrlLocked: true,
|
||||
altLocked: false,
|
||||
),
|
||||
'\n',
|
||||
);
|
||||
});
|
||||
|
||||
test('does not apply Alt to a terminal control byte', () {
|
||||
expect(
|
||||
prepareTerminalInputPayload(
|
||||
'\x1B',
|
||||
source: TerminalInputSource.keyboard,
|
||||
isMobileOrWebMobile: true,
|
||||
bracketedPasteMode: false,
|
||||
ctrlLocked: false,
|
||||
altLocked: true,
|
||||
),
|
||||
'\x1B',
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps large keyboard payloads unchanged when modifiers are inactive',
|
||||
() {
|
||||
final payload = 'd' * (1024 * 1024);
|
||||
|
||||
expect(
|
||||
prepareTerminalInputPayload(
|
||||
payload,
|
||||
source: TerminalInputSource.keyboard,
|
||||
isMobileOrWebMobile: false,
|
||||
bracketedPasteMode: false,
|
||||
ctrlLocked: false,
|
||||
altLocked: false,
|
||||
),
|
||||
payload,
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps decomposed graphemes intact with locked keyboard modifiers',
|
||||
() {
|
||||
const decomposedEAcute = 'e\u0301';
|
||||
|
||||
expect(
|
||||
prepareTerminalInputPayload(
|
||||
decomposedEAcute,
|
||||
source: TerminalInputSource.keyboard,
|
||||
isMobileOrWebMobile: true,
|
||||
bracketedPasteMode: false,
|
||||
ctrlLocked: true,
|
||||
altLocked: false,
|
||||
),
|
||||
decomposedEAcute,
|
||||
);
|
||||
});
|
||||
|
||||
test('preserves a lone pasted newline when modifiers are locked', () {
|
||||
expect(
|
||||
prepareTerminalInputPayload(
|
||||
'\n',
|
||||
source: TerminalInputSource.paste,
|
||||
isMobileOrWebMobile: true,
|
||||
bracketedPasteMode: false,
|
||||
ctrlLocked: true,
|
||||
altLocked: true,
|
||||
),
|
||||
'\n',
|
||||
);
|
||||
});
|
||||
|
||||
test('wraps paste without applying locked modifiers', () {
|
||||
expect(
|
||||
prepareTerminalInputPayload(
|
||||
'd',
|
||||
source: TerminalInputSource.paste,
|
||||
isMobileOrWebMobile: true,
|
||||
bracketedPasteMode: true,
|
||||
ctrlLocked: true,
|
||||
altLocked: true,
|
||||
),
|
||||
'\x1B[200~d\x1B[201~',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('shouldHandleTerminalPasteShortcut', () {
|
||||
test(
|
||||
'keeps default xterm paste behavior when virtual modifiers are inactive',
|
||||
() {
|
||||
expect(
|
||||
shouldHandleTerminalPasteShortcut(
|
||||
logicalKey: LogicalKeyboardKey.keyV,
|
||||
isKeyDown: true,
|
||||
isKeyRepeat: false,
|
||||
controlPressed: true,
|
||||
metaPressed: false,
|
||||
altPressed: false,
|
||||
shiftPressed: false,
|
||||
modifierLockActive: false,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('handles Ctrl+V and Meta+V when a virtual modifier lock is active',
|
||||
() {
|
||||
expect(
|
||||
shouldHandleTerminalPasteShortcut(
|
||||
logicalKey: LogicalKeyboardKey.keyV,
|
||||
isKeyDown: true,
|
||||
isKeyRepeat: false,
|
||||
controlPressed: true,
|
||||
metaPressed: false,
|
||||
altPressed: false,
|
||||
shiftPressed: false,
|
||||
modifierLockActive: true,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
shouldHandleTerminalPasteShortcut(
|
||||
logicalKey: LogicalKeyboardKey.keyV,
|
||||
isKeyDown: true,
|
||||
isKeyRepeat: false,
|
||||
controlPressed: false,
|
||||
metaPressed: true,
|
||||
altPressed: false,
|
||||
shiftPressed: false,
|
||||
modifierLockActive: true,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('handles paste shortcut repeats while a virtual lock is active', () {
|
||||
expect(
|
||||
shouldHandleTerminalPasteShortcut(
|
||||
logicalKey: LogicalKeyboardKey.keyV,
|
||||
isKeyDown: false,
|
||||
isKeyRepeat: true,
|
||||
controlPressed: true,
|
||||
metaPressed: false,
|
||||
altPressed: false,
|
||||
shiftPressed: false,
|
||||
modifierLockActive: true,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('ignores key-up and unmodified V events', () {
|
||||
expect(
|
||||
shouldHandleTerminalPasteShortcut(
|
||||
logicalKey: LogicalKeyboardKey.keyV,
|
||||
isKeyDown: false,
|
||||
isKeyRepeat: false,
|
||||
controlPressed: true,
|
||||
metaPressed: false,
|
||||
altPressed: false,
|
||||
shiftPressed: false,
|
||||
modifierLockActive: true,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
shouldHandleTerminalPasteShortcut(
|
||||
logicalKey: LogicalKeyboardKey.keyV,
|
||||
isKeyDown: true,
|
||||
isKeyRepeat: false,
|
||||
controlPressed: false,
|
||||
metaPressed: false,
|
||||
altPressed: false,
|
||||
shiftPressed: false,
|
||||
modifierLockActive: true,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('ignores paste shortcuts with extra modifiers', () {
|
||||
for (final state in [
|
||||
(control: true, meta: false, alt: true, shift: false),
|
||||
(control: true, meta: false, alt: false, shift: true),
|
||||
(control: false, meta: true, alt: false, shift: true),
|
||||
(control: true, meta: true, alt: false, shift: false),
|
||||
]) {
|
||||
expect(
|
||||
shouldHandleTerminalPasteShortcut(
|
||||
logicalKey: LogicalKeyboardKey.keyV,
|
||||
isKeyDown: true,
|
||||
isKeyRepeat: false,
|
||||
controlPressed: state.control,
|
||||
metaPressed: state.meta,
|
||||
altPressed: state.alt,
|
||||
shiftPressed: state.shift,
|
||||
modifierLockActive: true,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('ignores non-V key events', () {
|
||||
expect(
|
||||
shouldHandleTerminalPasteShortcut(
|
||||
logicalKey: LogicalKeyboardKey.keyC,
|
||||
isKeyDown: true,
|
||||
isKeyRepeat: false,
|
||||
controlPressed: true,
|
||||
metaPressed: false,
|
||||
altPressed: false,
|
||||
shiftPressed: false,
|
||||
modifierLockActive: true,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('shouldClearTerminalModifiersWhenRow3Collapses', () {
|
||||
test('clears visible modifier state when expanded row is collapsed', () {
|
||||
expect(
|
||||
shouldClearTerminalModifiersWhenRow3Collapses(
|
||||
wasExpanded: true,
|
||||
willExpand: false,
|
||||
ctrlLocked: true,
|
||||
altLocked: false,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('does not clear modifiers when row expands', () {
|
||||
expect(
|
||||
shouldClearTerminalModifiersWhenRow3Collapses(
|
||||
wasExpanded: false,
|
||||
willExpand: true,
|
||||
ctrlLocked: true,
|
||||
altLocked: true,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('clears Alt state when expanded row is collapsed', () {
|
||||
expect(
|
||||
shouldClearTerminalModifiersWhenRow3Collapses(
|
||||
wasExpanded: true,
|
||||
willExpand: false,
|
||||
ctrlLocked: false,
|
||||
altLocked: true,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
40
flutter/test/terminal_keyboard_utils_test.dart
Normal file
40
flutter/test/terminal_keyboard_utils_test.dart
Normal file
@@ -0,0 +1,40 @@
|
||||
import 'package:flutter_hbb/mobile/terminal_keyboard_utils.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('mobile terminal keyboard layout', () {
|
||||
test('keeps the latest key order from the reviewed PR layout', () {
|
||||
expect(
|
||||
terminalKeyboardRow1Keys,
|
||||
['Esc', '/', '|', 'Home', '↑', 'End', r'\'],
|
||||
);
|
||||
expect(
|
||||
terminalKeyboardRow2Keys,
|
||||
['Tab', 'Ctrl+C', '~', '←', '↓', '→'],
|
||||
);
|
||||
expect(
|
||||
terminalKeyboardRow3Keys,
|
||||
['Ctrl', 'Alt', '-', 'PgUp', 'PgDn'],
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps two trailing Row3 placeholders for row alignment', () {
|
||||
expect(terminalKeyboardRow3TrailingPlaceholderCount, 2);
|
||||
});
|
||||
|
||||
test('keeps every expanded row aligned at 348dp', () {
|
||||
final rowWidths = [
|
||||
terminalKeyboardRowWidth(terminalKeyboardRow1Keys.length),
|
||||
terminalKeyboardRowWidth(terminalKeyboardRow2Keys.length + 1),
|
||||
terminalKeyboardRowWidth(
|
||||
terminalKeyboardRow3Keys.length +
|
||||
terminalKeyboardRow3TrailingPlaceholderCount,
|
||||
),
|
||||
];
|
||||
|
||||
expect(terminalKeyboardKeyWidth, 48);
|
||||
expect(terminalKeyboardKeySpacing, 2);
|
||||
expect(rowWidths, everyElement(348));
|
||||
});
|
||||
});
|
||||
}
|
||||
68
flutter/test/terminal_model_lifecycle_test.dart
Normal file
68
flutter/test/terminal_model_lifecycle_test.dart
Normal file
@@ -0,0 +1,68 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_hbb/models/model.dart';
|
||||
import 'package:flutter_hbb/models/terminal_model.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:xterm/xterm.dart';
|
||||
|
||||
class _FakeFFI implements FFI {
|
||||
@override
|
||||
String id = 'test-peer';
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('ignores paste that completes after the terminal model is disposed',
|
||||
() async {
|
||||
final model = TerminalModel(_FakeFFI());
|
||||
final delayedClipboardText = Completer<String>();
|
||||
|
||||
// This mirrors Ctrl/Cmd+V: clipboard access starts first, then the page and
|
||||
// model are disposed before the asynchronous read supplies its text.
|
||||
final paste = delayedClipboardText.future.then(model.pasteText);
|
||||
model.dispose();
|
||||
delayedClipboardText.complete('late clipboard text');
|
||||
await paste;
|
||||
|
||||
expect(model.debugBufferedInputCount, 0);
|
||||
});
|
||||
|
||||
test('ignores terminal text input after the terminal model is disposed', () {
|
||||
final model = TerminalModel(_FakeFFI());
|
||||
var checkedCtrlLock = false;
|
||||
var clearedCtrlLock = false;
|
||||
|
||||
model.isCtrlLocked = () {
|
||||
checkedCtrlLock = true;
|
||||
return true;
|
||||
};
|
||||
model.clearCtrlLock = () {
|
||||
clearedCtrlLock = true;
|
||||
};
|
||||
|
||||
model.dispose();
|
||||
model.terminal.textInput('d');
|
||||
|
||||
expect(checkedCtrlLock, isFalse);
|
||||
expect(clearedCtrlLock, isFalse);
|
||||
expect(model.debugBufferedInputCount, 0);
|
||||
});
|
||||
|
||||
test('builds its terminal with the wheel button fix', () {
|
||||
final model = TerminalModel(_FakeFFI());
|
||||
addTearDown(model.dispose);
|
||||
|
||||
final captured = <String>[];
|
||||
model.terminal.onOutput = captured.add;
|
||||
model.terminal.write('\x1b[?1000h\x1b[?1006h');
|
||||
model.terminal.mouseInput(
|
||||
TerminalMouseButton.wheelUp,
|
||||
TerminalMouseButtonState.down,
|
||||
const CellOffset(10, 5),
|
||||
);
|
||||
|
||||
expect(captured.single, '\x1b[<64;11;6M');
|
||||
});
|
||||
}
|
||||
114
flutter/test/terminal_mouse_handler_test.dart
Normal file
114
flutter/test/terminal_mouse_handler_test.dart
Normal file
@@ -0,0 +1,114 @@
|
||||
import 'package:flutter_hbb/models/terminal_mouse_handler.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:xterm/xterm.dart';
|
||||
|
||||
void main() {
|
||||
late Terminal terminal;
|
||||
late List<String> output;
|
||||
|
||||
setUp(() {
|
||||
output = <String>[];
|
||||
terminal = Terminal(mouseHandler: const WheelButtonFixMouseHandler())
|
||||
..onOutput = output.add;
|
||||
});
|
||||
|
||||
String? report(
|
||||
TerminalMouseButton button, [
|
||||
TerminalMouseButtonState state = TerminalMouseButtonState.down,
|
||||
CellOffset position = const CellOffset(10, 5),
|
||||
]) {
|
||||
output.clear();
|
||||
terminal.mouseInput(button, state, position);
|
||||
return output.isEmpty ? null : output.single;
|
||||
}
|
||||
|
||||
test('reports SGR wheel buttons without the Shift modifier bit', () {
|
||||
terminal.write('\x1b[?1000h\x1b[?1006h');
|
||||
|
||||
expect(report(TerminalMouseButton.wheelUp), '\x1b[<64;11;6M');
|
||||
expect(report(TerminalMouseButton.wheelDown), '\x1b[<65;11;6M');
|
||||
expect(report(TerminalMouseButton.wheelLeft), '\x1b[<66;11;6M');
|
||||
expect(report(TerminalMouseButton.wheelRight), '\x1b[<67;11;6M');
|
||||
});
|
||||
|
||||
test('reports normal-encoding wheel buttons in the 64..67 range', () {
|
||||
terminal.write('\x1b[?1000h');
|
||||
|
||||
expect(
|
||||
report(TerminalMouseButton.wheelUp),
|
||||
'\x1b[M${String.fromCharCode(32 + 64)}'
|
||||
'${String.fromCharCode(32 + 11)}${String.fromCharCode(32 + 6)}',
|
||||
);
|
||||
expect(
|
||||
report(TerminalMouseButton.wheelDown),
|
||||
'\x1b[M${String.fromCharCode(32 + 65)}'
|
||||
'${String.fromCharCode(32 + 11)}${String.fromCharCode(32 + 6)}',
|
||||
);
|
||||
});
|
||||
|
||||
test('reports utf-encoding wheel buttons beyond the normal-mode range', () {
|
||||
terminal.write('\x1b[?1000h\x1b[?1005h');
|
||||
|
||||
expect(
|
||||
report(
|
||||
TerminalMouseButton.wheelDown,
|
||||
TerminalMouseButtonState.down,
|
||||
const CellOffset(400, 300),
|
||||
),
|
||||
'\x1b[M${String.fromCharCode(32 + 65)}'
|
||||
'${String.fromCharCode(32 + 401)}${String.fromCharCode(32 + 301)}',
|
||||
);
|
||||
});
|
||||
|
||||
test('reports urxvt-encoding wheel buttons shifted by 32', () {
|
||||
terminal.write('\x1b[?1000h\x1b[?1015h');
|
||||
|
||||
expect(report(TerminalMouseButton.wheelUp), '\x1b[96;11;6M');
|
||||
expect(report(TerminalMouseButton.wheelDown), '\x1b[97;11;6M');
|
||||
});
|
||||
|
||||
test('sends a null byte for coordinates past the encoding limit', () {
|
||||
terminal.write('\x1b[?1000h');
|
||||
|
||||
expect(
|
||||
report(
|
||||
TerminalMouseButton.wheelUp,
|
||||
TerminalMouseButtonState.down,
|
||||
const CellOffset(300, 300),
|
||||
),
|
||||
'\x1b[M${String.fromCharCode(32 + 64)}\x00\x00',
|
||||
);
|
||||
});
|
||||
|
||||
test('leaves non-wheel buttons to the upstream handler', () {
|
||||
terminal.write('\x1b[?1000h\x1b[?1006h');
|
||||
|
||||
expect(report(TerminalMouseButton.left), '\x1b[<0;11;6M');
|
||||
expect(report(TerminalMouseButton.middle), '\x1b[<1;11;6M');
|
||||
expect(
|
||||
report(TerminalMouseButton.right, TerminalMouseButtonState.up),
|
||||
'\x1b[<2;11;6m',
|
||||
);
|
||||
});
|
||||
|
||||
test('stays silent when the peer has not enabled mouse reporting', () {
|
||||
expect(report(TerminalMouseButton.wheelDown), isNull);
|
||||
expect(report(TerminalMouseButton.left), isNull);
|
||||
});
|
||||
|
||||
test('stays silent for the wheel in click-only mode', () {
|
||||
terminal.write('\x1b[?9h\x1b[?1006h');
|
||||
|
||||
expect(report(TerminalMouseButton.wheelDown), isNull);
|
||||
expect(report(TerminalMouseButton.left), '\x1b[<0;11;6M');
|
||||
});
|
||||
|
||||
test('does not report wheel button releases', () {
|
||||
terminal.write('\x1b[?1000h\x1b[?1006h');
|
||||
|
||||
expect(
|
||||
report(TerminalMouseButton.wheelDown, TerminalMouseButtonState.up),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -19,6 +19,66 @@
|
||||
|
||||
#include "win32_desktop.h"
|
||||
|
||||
namespace {
|
||||
|
||||
// If the window is resized between the creation of the Flutter surface and the
|
||||
// present of the first frame - which is what the PowerToys FancyZones option
|
||||
// "Move newly created windows to their last known zone" does - the embedder's
|
||||
// resize synchronization enters kResizeStarted and from then on only presents
|
||||
// frames that match the new size. A frame already generated for the old size
|
||||
// is rejected, nothing schedules a matching one, and the window stays white
|
||||
// until a real resize re-enters OnWindowSizeChanged, which resets the resize
|
||||
// target and resends the window metrics. That is why minimize/restore heals
|
||||
// it; ForceChildRefresh() below does the same programmatically.
|
||||
// https://github.com/rustdesk/rustdesk/issues/6756
|
||||
// https://github.com/flutter/flutter/issues/159630
|
||||
//
|
||||
// The timer below drives that recovery. Two subtleties, verified against the
|
||||
// embedder sources (identical in 3.24.5 and 3.44.0):
|
||||
// - FlutterViewController::ForceRedraw() only schedules a frame when NO resize
|
||||
// is pending (resize_status_ == kDone), so it cannot heal the wedge above.
|
||||
// It is kept as a cheap first kick for the case it was designed for: a
|
||||
// window created hidden and shown later, with nothing scheduling a frame.
|
||||
// - The SetNextFrameCallback used to detect the first frame fires when a frame
|
||||
// is GENERATED (raster thread), even if the resize gate then rejects its
|
||||
// present. So it must not be the only stop condition: one final
|
||||
// ForceChildRefresh() is issued to guarantee a present at the current size.
|
||||
// Note this premise is not load-bearing, and the redundancy is deliberate:
|
||||
// if the callback in fact only fired on a successful present, then
|
||||
// first_frame_rendered_ would stay false and the timer below would keep
|
||||
// nudging until it healed.
|
||||
// This also relies on HandleTopLevelWindowProc not consuming WM_TIMER (no
|
||||
// plugin registers a delegate for it today).
|
||||
constexpr UINT_PTR kForceRedrawTimerId = 0xFB15;
|
||||
constexpr UINT kForceRedrawIntervalMs = 200;
|
||||
// Give up eventually (with a log), so a genuinely stuck engine doesn't keep a
|
||||
// timer alive forever. 25 * 200ms covers slow starts comfortably.
|
||||
constexpr UINT kForceRedrawMaxTries = 25;
|
||||
// The first ticks use the cheap ForceRedraw(); later ticks use
|
||||
// ForceChildRefresh(), which may block the platform thread for up to 2x100ms
|
||||
// per call (each nudge re-enters the 100ms resize wait).
|
||||
constexpr UINT kForceRedrawCheapTries = 2;
|
||||
|
||||
// Re-enters the embedder's OnWindowSizeChanged by nudging the Flutter child
|
||||
// window by 1px and back: this resets the resize target and resends the window
|
||||
// metrics. Same as BaseFlutterWindow::ForceChildRefresh() on the
|
||||
// rustdesk_desktop_multi_window side.
|
||||
void ForceChildRefresh(HWND child) {
|
||||
if (!child) {
|
||||
return;
|
||||
}
|
||||
RECT rect;
|
||||
GetWindowRect(child, &rect);
|
||||
LONG width = rect.right - rect.left;
|
||||
LONG height = rect.bottom - rect.top;
|
||||
SetWindowPos(child, nullptr, 0, 0, width + 1, height,
|
||||
SWP_NOZORDER | SWP_NOOWNERZORDER | SWP_NOMOVE | SWP_FRAMECHANGED);
|
||||
SetWindowPos(child, nullptr, 0, 0, width, height,
|
||||
SWP_NOZORDER | SWP_NOOWNERZORDER | SWP_NOMOVE | SWP_FRAMECHANGED);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
FlutterWindow::FlutterWindow(const flutter::DartProject& project)
|
||||
: project_(project) {}
|
||||
|
||||
@@ -92,10 +152,17 @@ bool FlutterWindow::OnCreate() {
|
||||
registry->GetRegistrarForPlugin("FlutterGpuTextureRendererPluginCApi"));
|
||||
});
|
||||
SetChildContent(flutter_controller_->view()->GetNativeWindow());
|
||||
|
||||
// See the comment on kForceRedrawTimerId above.
|
||||
flutter_controller_->engine()->SetNextFrameCallback(
|
||||
[this]() { first_frame_rendered_ = true; });
|
||||
SetTimer(GetHandle(), kForceRedrawTimerId, kForceRedrawIntervalMs, nullptr);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void FlutterWindow::OnDestroy() {
|
||||
KillTimer(GetHandle(), kForceRedrawTimerId);
|
||||
if (flutter_controller_) {
|
||||
flutter_controller_ = nullptr;
|
||||
}
|
||||
@@ -121,6 +188,48 @@ FlutterWindow::MessageHandler(HWND hwnd, UINT const message,
|
||||
case WM_FONTCHANGE:
|
||||
flutter_controller_->engine()->ReloadSystemFonts();
|
||||
break;
|
||||
case WM_TIMER:
|
||||
if (wparam == kForceRedrawTimerId) {
|
||||
if (!flutter_controller_) {
|
||||
KillTimer(hwnd, kForceRedrawTimerId);
|
||||
} else if (first_frame_rendered_) {
|
||||
// A frame was generated, which does not mean it was presented: if a
|
||||
// resize was pending, the gate rejected it (see the comment on
|
||||
// kForceRedrawTimerId). One child refresh guarantees a present at the
|
||||
// current size. Unconditional because gating it bought nothing: the
|
||||
// WM_SIZE that CreateWindow() sends already arrives before the first
|
||||
// frame, so the flag this used to check was always set by the time we
|
||||
// got here. Doing it unconditionally is safe either way - at worst it
|
||||
// is one extra nudge, and it is cheap once the engine is running.
|
||||
ForceChildRefresh(flutter_controller_->view()->GetNativeWindow());
|
||||
KillTimer(hwnd, kForceRedrawTimerId);
|
||||
} else if (++force_redraw_tries_ > kForceRedrawMaxTries) {
|
||||
// Not std::cerr: the runner only attaches a console when started from
|
||||
// one or under a debugger (see main.cpp), and this fires on end-user
|
||||
// machines. OutputDebugString is readable with DebugView there.
|
||||
OutputDebugStringA(
|
||||
"rustdesk: Flutter window did not render its first frame, "
|
||||
"giving up.\n");
|
||||
KillTimer(hwnd, kForceRedrawTimerId);
|
||||
} else if (force_redraw_tries_ <= kForceRedrawCheapTries) {
|
||||
flutter_controller_->ForceRedraw();
|
||||
} else {
|
||||
ForceChildRefresh(flutter_controller_->view()->GetNativeWindow());
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
break;
|
||||
case WM_SHOWWINDOW:
|
||||
// A window created hidden (e.g. the connection manager) may be shown
|
||||
// long after the creation-time force-redraw timer has given up, and
|
||||
// FancyZones moves windows exactly when they are shown. Re-arm the
|
||||
// protection if the first frame still hasn't been rendered by now (see
|
||||
// kForceRedrawTimerId).
|
||||
if (wparam == TRUE && !first_frame_rendered_ && flutter_controller_) {
|
||||
force_redraw_tries_ = 0;
|
||||
SetTimer(hwnd, kForceRedrawTimerId, kForceRedrawIntervalMs, nullptr);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return Win32Window::MessageHandler(hwnd, message, wparam, lparam);
|
||||
|
||||
@@ -28,6 +28,14 @@ class FlutterWindow : public Win32Window {
|
||||
|
||||
// The Flutter instance hosted by this window.
|
||||
std::unique_ptr<flutter::FlutterViewController> flutter_controller_;
|
||||
|
||||
// Whether the engine has generated its first frame. Note that a generated
|
||||
// frame is not necessarily presented: the resize synchronization may reject
|
||||
// it (see kForceRedrawTimerId in the .cpp file).
|
||||
bool first_frame_rendered_ = false;
|
||||
|
||||
// Number of force-redraw attempts made so far.
|
||||
UINT force_redraw_tries_ = 0;
|
||||
};
|
||||
|
||||
#endif // RUNNER_FLUTTER_WINDOW_H_
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
typedef char** (*FUNC_RUSTDESK_CORE_MAIN)(int*);
|
||||
typedef void (*FUNC_RUSTDESK_FREE_ARGS)( char**, int);
|
||||
typedef int (*FUNC_RUSTDESK_GET_APP_NAME)(wchar_t*, int);
|
||||
typedef int (*FUNC_RUSTDESK_IS_DISABLE_INSTALLATION)();
|
||||
/// Note: `--server`, `--service` are already handled in [core_main.rs].
|
||||
const std::vector<std::string> parameters_white_list = {"--install", "--cm"};
|
||||
|
||||
@@ -62,6 +63,22 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
|
||||
}
|
||||
std::vector<std::string> rust_args(c_args, c_args + args_len);
|
||||
free_c_args(c_args, args_len);
|
||||
FUNC_RUSTDESK_IS_DISABLE_INSTALLATION rustdesk_is_disable_installation =
|
||||
(FUNC_RUSTDESK_IS_DISABLE_INSTALLATION)GetProcAddress(hInstance, "rustdesk_is_disable_installation");
|
||||
bool is_disable_installation =
|
||||
rustdesk_is_disable_installation && rustdesk_is_disable_installation() != 0;
|
||||
const auto installParam = std::string("--install");
|
||||
// Flutter reads the original process command line, not only rust_args, so
|
||||
// remove the `--install` injected by the portable wrapper here as well. This
|
||||
// also lets `no-install.exe` continue as a portable app when installation is
|
||||
// disabled. See: https://github.com/rustdesk/rustdesk-server-pro/issues/991#issuecomment-4978376890
|
||||
if (is_disable_installation) {
|
||||
command_line_arguments.erase(
|
||||
std::remove(command_line_arguments.begin(),
|
||||
command_line_arguments.end(),
|
||||
installParam),
|
||||
command_line_arguments.end());
|
||||
}
|
||||
|
||||
std::wstring app_name = L"RustDesk";
|
||||
FUNC_RUSTDESK_GET_APP_NAME get_rustdesk_app_name = (FUNC_RUSTDESK_GET_APP_NAME)GetProcAddress(hInstance, "get_rustdesk_app_name");
|
||||
@@ -118,7 +135,6 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
|
||||
is_cm_page = true;
|
||||
}
|
||||
bool is_install_page = false;
|
||||
auto installParam = std::string("--install");
|
||||
if (!command_line_arguments.empty() && command_line_arguments.front().compare(0, installParam.size(), installParam.c_str()) == 0) {
|
||||
is_install_page = true;
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ once_cell = {version = "1.18", optional = true}
|
||||
percent-encoding = {version ="2.3", optional = true}
|
||||
x11-clipboard = {git="https://github.com/clslaid/x11-clipboard", branch = "feat/store-batch", optional = true}
|
||||
x11rb = {version = "0.12", features = ["all-extensions"], optional = true}
|
||||
fuser = {version = "0.15", default-features = false, optional = true}
|
||||
fuser = {git="https://github.com/rustdesk-org/fuser", branch = "refact/tag-0.16.0-cargo-1.75.0", default-features = false, optional = true}
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
cacao = {git="https://github.com/clslaid/cacao", branch = "feat/set-file-urls", optional = true}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# clipboard
|
||||
|
||||
Copy files and text through network.
|
||||
Main lowlevel logic from [FreeRDP](https://github.com/FreeRDP/FreeRDP).
|
||||
Main low-level logic from [FreeRDP](https://github.com/FreeRDP/FreeRDP).
|
||||
|
||||
To enjoy file copy and paste feature on Linux/OSX,
|
||||
please build with `unix-file-copy-paste` feature.
|
||||
@@ -151,7 +151,7 @@ the FUSE server will figure out the file system tree and rearrange its content.
|
||||
- you may notice
|
||||
the mountpoint is still occupied after the application quits.
|
||||
That's because the FUSE server was not mounted with `AUTO_UNMOUNT`.
|
||||
- It's hard to implement gressful shutdown for a multi-processed program
|
||||
- It's hard to implement graceful shutdown for a multi-processed program
|
||||
- `AUTO_UNMOUNT` was not enabled by default and requires enable
|
||||
`user_allow_other` in configure. Letting users edit such global
|
||||
configuration to use this feature might not be a good idea.
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::{FLAGS_FD_ATTRIBUTES, FLAGS_FD_LAST_WRITE, FLAGS_FD_UNIX_MODE, LDAP_EPOCH_DELTA};
|
||||
use super::{
|
||||
FILE_NAME_FIELD_SIZE, FLAGS_FD_ATTRIBUTES, FLAGS_FD_LAST_WRITE, FLAGS_FD_UNIX_MODE,
|
||||
LDAP_EPOCH_DELTA,
|
||||
};
|
||||
use crate::CliprdrError;
|
||||
use hbb_common::{
|
||||
bytes::{Buf, Bytes},
|
||||
@@ -47,6 +50,23 @@ pub struct FileDescription {
|
||||
pub perm: u16,
|
||||
}
|
||||
|
||||
pub(super) fn validate_file_name(name: &str) -> Result<(), CliprdrError> {
|
||||
if matches!(name.as_bytes(), [letter, b':', b'/', ..] if letter.is_ascii_alphabetic())
|
||||
|| name
|
||||
.split('/')
|
||||
.any(|component| component.is_empty() || component == ".")
|
||||
{
|
||||
return Err(CliprdrError::InvalidRequest {
|
||||
description: "clipboard file name is not a normalized relative path".to_string(),
|
||||
});
|
||||
}
|
||||
hbb_common::fs::validate_file_name_no_traversal(name).map_err(|error| {
|
||||
CliprdrError::InvalidRequest {
|
||||
description: error.to_string(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
impl FileDescription {
|
||||
fn parse_file_descriptor(
|
||||
bytes: &mut Bytes,
|
||||
@@ -68,13 +88,21 @@ impl FileDescription {
|
||||
// file size
|
||||
let file_size_high = bytes.get_u32_le();
|
||||
let file_size_low = bytes.get_u32_le();
|
||||
// utf16 file name, double \0 terminated, in 520 bytes block
|
||||
// NUL-terminated UTF-16 file name in a fixed-size field.
|
||||
// read with another pointer, and advance the main pointer
|
||||
let block = bytes.clone();
|
||||
bytes.advance(520);
|
||||
bytes.advance(FILE_NAME_FIELD_SIZE);
|
||||
|
||||
let block = &block[..520];
|
||||
let wstr = WStr::from_utf16le(block).map_err(|e| {
|
||||
let block = &block[..FILE_NAME_FIELD_SIZE];
|
||||
let utf16_unit_size = std::mem::size_of::<u16>();
|
||||
let name_end = block
|
||||
.chunks_exact(utf16_unit_size)
|
||||
.position(|unit| unit == [0_u8, 0_u8])
|
||||
.ok_or_else(|| CliprdrError::InvalidRequest {
|
||||
description: "clipboard file name is not null-terminated".to_string(),
|
||||
})?
|
||||
* utf16_unit_size;
|
||||
let wstr = WStr::from_utf16le(&block[..name_end]).map_err(|e| {
|
||||
log::error!("cannot convert file descriptor path: {:?}", e);
|
||||
CliprdrError::ConversionFailure
|
||||
})?;
|
||||
@@ -136,7 +164,8 @@ impl FileDescription {
|
||||
};
|
||||
|
||||
let name = wstr.to_utf8().replace('\\', "/");
|
||||
let name = PathBuf::from(name.trim_end_matches('\0'));
|
||||
validate_file_name(&name)?;
|
||||
let name = PathBuf::from(name);
|
||||
|
||||
let desc = FileDescription {
|
||||
conn_id,
|
||||
@@ -186,3 +215,81 @@ impl FileDescription {
|
||||
Ok(files)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::mem::size_of;
|
||||
|
||||
const PDU_HEADER_SIZE: usize = size_of::<u32>();
|
||||
const DESCRIPTOR_SIZE: usize = 592;
|
||||
const ATTRIBUTES_OFFSET: usize = PDU_HEADER_SIZE + 36;
|
||||
const NAME_OFFSET: usize = PDU_HEADER_SIZE + 72;
|
||||
const FILE_NAME_CODE_UNITS: usize = 260;
|
||||
const INVALID_UTF16_UNIT: u16 = 0xdc00;
|
||||
const FILE_ATTRIBUTE_NORMAL: u32 = 0x80;
|
||||
|
||||
fn descriptor_pdu(name: &str) -> Vec<u8> {
|
||||
let mut pdu = vec![0_u8; PDU_HEADER_SIZE + DESCRIPTOR_SIZE];
|
||||
pdu[..PDU_HEADER_SIZE].copy_from_slice(&1_u32.to_le_bytes());
|
||||
pdu[PDU_HEADER_SIZE..PDU_HEADER_SIZE + size_of::<u32>()]
|
||||
.copy_from_slice(&FLAGS_FD_ATTRIBUTES.to_le_bytes());
|
||||
pdu[ATTRIBUTES_OFFSET..ATTRIBUTES_OFFSET + size_of::<u32>()]
|
||||
.copy_from_slice(&FILE_ATTRIBUTE_NORMAL.to_le_bytes());
|
||||
for (index, unit) in name.encode_utf16().enumerate() {
|
||||
let offset = NAME_OFFSET + index * size_of::<u16>();
|
||||
pdu[offset..offset + size_of::<u16>()].copy_from_slice(&unit.to_le_bytes());
|
||||
}
|
||||
pdu
|
||||
}
|
||||
|
||||
fn parse_name(name: &str) -> Result<Vec<FileDescription>, CliprdrError> {
|
||||
FileDescription::parse_file_descriptors(descriptor_pdu(name), 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsafe_file_names() {
|
||||
for name in [
|
||||
"../payload",
|
||||
"/tmp/payload",
|
||||
"C:\\payload",
|
||||
"folder//payload",
|
||||
"folder/./payload",
|
||||
"folder/",
|
||||
"",
|
||||
".",
|
||||
] {
|
||||
assert!(matches!(
|
||||
parse_name(name),
|
||||
Err(CliprdrError::InvalidRequest { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_nested_relative_file_name() {
|
||||
let files = parse_name("folder\\nested\\file.txt").unwrap();
|
||||
assert_eq!(files[0].name, PathBuf::from("folder/nested/file.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_data_after_null_terminator() {
|
||||
let name = "file.txt";
|
||||
let mut pdu = descriptor_pdu(name);
|
||||
let padding_offset = NAME_OFFSET + (name.encode_utf16().count() + 1) * size_of::<u16>();
|
||||
pdu[padding_offset..padding_offset + size_of::<u16>()]
|
||||
.copy_from_slice(&INVALID_UTF16_UNIT.to_le_bytes());
|
||||
|
||||
let files = FileDescription::parse_file_descriptors(pdu, 0).unwrap();
|
||||
assert_eq!(files[0].name, PathBuf::from("file.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_terminated_file_name() {
|
||||
let name = "a".repeat(FILE_NAME_CODE_UNITS);
|
||||
assert!(matches!(
|
||||
parse_name(&name),
|
||||
Err(CliprdrError::InvalidRequest { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::{BLOCK_SIZE, LDAP_EPOCH_DELTA};
|
||||
use super::{
|
||||
filetype::validate_file_name, BLOCK_SIZE, FILE_NAME_CODE_UNITS, FILE_NAME_FIELD_SIZE,
|
||||
LDAP_EPOCH_DELTA,
|
||||
};
|
||||
use crate::{
|
||||
platform::unix::{
|
||||
FLAGS_FD_ATTRIBUTES, FLAGS_FD_LAST_WRITE, FLAGS_FD_PROGRESSUI, FLAGS_FD_SIZE,
|
||||
@@ -21,15 +24,19 @@ use std::{
|
||||
};
|
||||
use utf16string::WString;
|
||||
|
||||
const FILE_DESCRIPTOR_SIZE: usize = 592;
|
||||
const MAX_FILE_NAME_CODE_UNITS: usize = FILE_NAME_CODE_UNITS - 1;
|
||||
const UTF16_CODE_UNIT_SIZE: usize = std::mem::size_of::<u16>();
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct LocalFile {
|
||||
pub relative_root: PathBuf,
|
||||
pub path: PathBuf,
|
||||
|
||||
pub handle: Option<BufReader<File>>,
|
||||
pub offset: AtomicU64,
|
||||
|
||||
pub name: String,
|
||||
descriptor_name: String,
|
||||
pub size: u64,
|
||||
pub last_write_time: SystemTime,
|
||||
pub is_dir: bool,
|
||||
@@ -42,7 +49,38 @@ pub(super) struct LocalFile {
|
||||
}
|
||||
|
||||
impl LocalFile {
|
||||
fn descriptor_name_too_long_error() -> CliprdrError {
|
||||
CliprdrError::InvalidRequest {
|
||||
description: format!(
|
||||
"clipboard file name exceeds {MAX_FILE_NAME_CODE_UNITS} UTF-16 code units"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn validated_descriptor_name(
|
||||
relative_root: &Path,
|
||||
path: &Path,
|
||||
) -> Result<String, CliprdrError> {
|
||||
let descriptor_path =
|
||||
path.strip_prefix(relative_root)
|
||||
.map_err(|_| CliprdrError::InvalidRequest {
|
||||
description: "clipboard file path is outside its relative root".to_string(),
|
||||
})?;
|
||||
if descriptor_path.is_absolute() {
|
||||
return Err(CliprdrError::InvalidRequest {
|
||||
description: "clipboard file path must be relative".to_string(),
|
||||
});
|
||||
}
|
||||
let descriptor_name = descriptor_path.to_string_lossy().into_owned();
|
||||
validate_file_name(&descriptor_name)?;
|
||||
if descriptor_name.encode_utf16().count() > MAX_FILE_NAME_CODE_UNITS {
|
||||
return Err(Self::descriptor_name_too_long_error());
|
||||
}
|
||||
Ok(descriptor_name)
|
||||
}
|
||||
|
||||
pub fn try_open(relative_root: &Path, path: &Path) -> Result<Self, CliprdrError> {
|
||||
let descriptor_name = Self::validated_descriptor_name(relative_root, path)?;
|
||||
let mt = std::fs::metadata(path).map_err(|e| CliprdrError::FileError {
|
||||
path: path.to_string_lossy().to_string(),
|
||||
err: e,
|
||||
@@ -70,11 +108,11 @@ impl LocalFile {
|
||||
|
||||
Ok(Self {
|
||||
name,
|
||||
relative_root: relative_root.to_path_buf(),
|
||||
path: path.to_path_buf(),
|
||||
handle,
|
||||
offset,
|
||||
size,
|
||||
descriptor_name,
|
||||
last_write_time,
|
||||
is_dir,
|
||||
read_only,
|
||||
@@ -85,17 +123,37 @@ impl LocalFile {
|
||||
normal,
|
||||
})
|
||||
}
|
||||
pub fn as_bin(&self) -> Vec<u8> {
|
||||
let mut buf = BytesMut::with_capacity(592);
|
||||
|
||||
fn put_descriptor_name(&self, buf: &mut BytesMut) -> Result<(), CliprdrError> {
|
||||
validate_file_name(&self.descriptor_name)?;
|
||||
let wstr: WString<utf16string::LE> = WString::from(&self.descriptor_name);
|
||||
let name = wstr.as_bytes();
|
||||
let Some(name_field_size) = name.len().checked_add(UTF16_CODE_UNIT_SIZE) else {
|
||||
return Err(Self::descriptor_name_too_long_error());
|
||||
};
|
||||
if name_field_size > FILE_NAME_FIELD_SIZE {
|
||||
return Err(Self::descriptor_name_too_long_error());
|
||||
}
|
||||
log::trace!(
|
||||
"put file to list: name_len {}, name {}",
|
||||
name.len(),
|
||||
&self.name
|
||||
);
|
||||
buf.put(name);
|
||||
buf.put_u16_le(0);
|
||||
buf.put_bytes(0, FILE_NAME_FIELD_SIZE - name_field_size);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn as_bin(&self) -> Result<Vec<u8>, CliprdrError> {
|
||||
let mut buf = BytesMut::with_capacity(FILE_DESCRIPTOR_SIZE);
|
||||
let read_only_flag = if self.read_only { 0x1 } else { 0 };
|
||||
let hidden_flag = if self.hidden { 0x2 } else { 0 };
|
||||
let system_flag = if self.system { 0x4 } else { 0 };
|
||||
let directory_flag = if self.is_dir { 0x10 } else { 0 };
|
||||
let archive_flag = if self.archive { 0x20 } else { 0 };
|
||||
let normal_flag = if self.normal { 0x80 } else { 0 };
|
||||
|
||||
let file_attributes: u32 = read_only_flag
|
||||
let file_attributes = read_only_flag
|
||||
| hidden_flag
|
||||
| system_flag
|
||||
| directory_flag
|
||||
@@ -112,23 +170,6 @@ impl LocalFile {
|
||||
|
||||
let size_high = (self.size >> 32) as u32;
|
||||
let size_low = (self.size & (u32::MAX as u64)) as u32;
|
||||
|
||||
let path = self
|
||||
.path
|
||||
.strip_prefix(&self.relative_root)
|
||||
.unwrap_or(&self.path)
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
|
||||
let wstr: WString<utf16string::LE> = WString::from(&path);
|
||||
let name = wstr.as_bytes();
|
||||
|
||||
log::trace!(
|
||||
"put file to list: name_len {}, name {}",
|
||||
name.len(),
|
||||
&self.name
|
||||
);
|
||||
|
||||
let flags = FLAGS_FD_SIZE
|
||||
| FLAGS_FD_LAST_WRITE
|
||||
| FLAGS_FD_ATTRIBUTES
|
||||
@@ -157,12 +198,10 @@ impl LocalFile {
|
||||
buf.put_u32_le(size_high);
|
||||
// file size (low)
|
||||
buf.put_u32_le(size_low);
|
||||
// put name and padding to 520 bytes
|
||||
let name_len = name.len();
|
||||
buf.put(name);
|
||||
buf.put(&vec![0u8; 520 - name_len][..]);
|
||||
// Put the null-terminated name and padding into the fixed-size field.
|
||||
self.put_descriptor_name(&mut buf)?;
|
||||
|
||||
buf.to_vec()
|
||||
Ok(buf.to_vec())
|
||||
}
|
||||
|
||||
#[inline]
|
||||
@@ -263,20 +302,18 @@ pub(super) fn construct_file_list(paths: &[PathBuf]) -> Result<Vec<LocalFile>, C
|
||||
}
|
||||
|
||||
let mut file_list = Vec::new();
|
||||
let mut visited = HashSet::new();
|
||||
|
||||
let relative_root = paths
|
||||
.first()
|
||||
.ok_or(CliprdrError::InvalidRequest {
|
||||
if paths.is_empty() {
|
||||
return Err(CliprdrError::InvalidRequest {
|
||||
description: "empty file list".to_string(),
|
||||
})?
|
||||
.parent()
|
||||
.ok_or(CliprdrError::InvalidRequest {
|
||||
description: "empty parent".to_string(),
|
||||
})?
|
||||
.to_path_buf();
|
||||
});
|
||||
}
|
||||
for path in paths {
|
||||
constr_file_lst(&relative_root, path, &mut file_list, &mut visited)?;
|
||||
let relative_root = path.parent().ok_or(CliprdrError::InvalidRequest {
|
||||
description: "empty parent".to_string(),
|
||||
})?;
|
||||
let mut visited = HashSet::new();
|
||||
constr_file_lst(relative_root, path, &mut file_list, &mut visited)?;
|
||||
}
|
||||
Ok(file_list)
|
||||
}
|
||||
@@ -284,7 +321,7 @@ pub(super) fn construct_file_list(paths: &[PathBuf]) -> Result<Vec<LocalFile>, C
|
||||
#[cfg(test)]
|
||||
mod file_list_test {
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
path::{Path, PathBuf},
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
|
||||
@@ -292,7 +329,7 @@ mod file_list_test {
|
||||
|
||||
use crate::{platform::unix::filetype::FileDescription, CliprdrError};
|
||||
|
||||
use super::LocalFile;
|
||||
use super::{LocalFile, FILE_DESCRIPTOR_SIZE, MAX_FILE_NAME_CODE_UNITS, UTF16_CODE_UNIT_SIZE};
|
||||
|
||||
#[inline]
|
||||
fn generate_tree(prefix: &str) -> Vec<LocalFile> {
|
||||
@@ -304,10 +341,10 @@ mod file_list_test {
|
||||
#[inline]
|
||||
fn generate_file(path: &str, name: &str, is_dir: bool) -> LocalFile {
|
||||
LocalFile {
|
||||
relative_root: PathBuf::from("."),
|
||||
path: PathBuf::from(path),
|
||||
handle: None,
|
||||
name: name.to_string(),
|
||||
descriptor_name: path.to_string(),
|
||||
size: 0,
|
||||
offset: AtomicU64::new(0),
|
||||
last_write_time: std::time::SystemTime::UNIX_EPOCH,
|
||||
@@ -352,29 +389,22 @@ mod file_list_test {
|
||||
let mut pdu = BytesMut::with_capacity(4 + 592 * tree.len());
|
||||
pdu.put_u32_le(tree.len() as u32);
|
||||
for file in tree {
|
||||
pdu.put(file.as_bin().as_slice());
|
||||
pdu.put(file.as_bin()?.as_slice());
|
||||
}
|
||||
|
||||
let parsed = FileDescription::parse_file_descriptors(pdu.to_vec(), 0)?;
|
||||
assert_eq!(parsed.len(), 4);
|
||||
|
||||
if !prefix.is_empty() {
|
||||
assert_eq!(parsed[0].name.to_str().unwrap(), format!("{}", prefix));
|
||||
assert_eq!(
|
||||
parsed[1].name.to_str().unwrap(),
|
||||
format!("{}/a.txt", prefix)
|
||||
);
|
||||
assert_eq!(parsed[2].name.to_str().unwrap(), format!("{}/b", prefix));
|
||||
assert_eq!(
|
||||
parsed[3].name.to_str().unwrap(),
|
||||
format!("{}/b/c.txt", prefix)
|
||||
);
|
||||
} else {
|
||||
assert_eq!(parsed[0].name.to_str().unwrap(), ".");
|
||||
assert_eq!(parsed[1].name.to_str().unwrap(), "a.txt");
|
||||
assert_eq!(parsed[2].name.to_str().unwrap(), "b");
|
||||
assert_eq!(parsed[3].name.to_str().unwrap(), "b/c.txt");
|
||||
}
|
||||
assert_eq!(parsed[0].name.to_str().unwrap(), format!("{}", prefix));
|
||||
assert_eq!(
|
||||
parsed[1].name.to_str().unwrap(),
|
||||
format!("{}/a.txt", prefix)
|
||||
);
|
||||
assert_eq!(parsed[2].name.to_str().unwrap(), format!("{}/b", prefix));
|
||||
assert_eq!(
|
||||
parsed[3].name.to_str().unwrap(),
|
||||
format!("{}/b/c.txt", prefix)
|
||||
);
|
||||
|
||||
assert!(parsed[0].perm & 0o777 == 0o754);
|
||||
assert!(parsed[1].perm & 0o777 == 0o754);
|
||||
@@ -386,10 +416,52 @@ mod file_list_test {
|
||||
|
||||
#[test]
|
||||
fn test_parse_file_descriptors() -> Result<(), CliprdrError> {
|
||||
as_bin_parse_test("")?;
|
||||
as_bin_parse_test("/")?;
|
||||
as_bin_parse_test("test")?;
|
||||
as_bin_parse_test("/test")?;
|
||||
as_bin_parse_test("test/nested")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_file_outside_relative_root() {
|
||||
let result = LocalFile::try_open(Path::new("/relative/root"), Path::new("/other/file"));
|
||||
assert!(matches!(result, Err(CliprdrError::InvalidRequest { .. })));
|
||||
|
||||
let result = LocalFile::try_open(
|
||||
Path::new("relative/root"),
|
||||
Path::new("relative/root/../outside"),
|
||||
);
|
||||
assert!(matches!(result, Err(CliprdrError::InvalidRequest { .. })));
|
||||
|
||||
let mut file = generate_tree("root").remove(0);
|
||||
file.descriptor_name = "../outside".to_string();
|
||||
assert!(matches!(
|
||||
file.as_bin(),
|
||||
Err(CliprdrError::InvalidRequest { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_utf16_descriptor_name_length() -> Result<(), CliprdrError> {
|
||||
let validate = |name: &str| {
|
||||
let path = Path::new("root").join(name);
|
||||
LocalFile::validated_descriptor_name(Path::new("root"), &path)
|
||||
};
|
||||
let valid_name = validate(&"a".repeat(MAX_FILE_NAME_CODE_UNITS))?;
|
||||
let oversized_name = "a".repeat(MAX_FILE_NAME_CODE_UNITS + 1);
|
||||
let invalid_name = validate(&oversized_name);
|
||||
let mut valid_file = generate_tree("").remove(0);
|
||||
valid_file.descriptor_name = valid_name;
|
||||
let valid_descriptor = valid_file.as_bin()?;
|
||||
valid_file.descriptor_name = oversized_name;
|
||||
let invalid_descriptor = valid_file.as_bin();
|
||||
|
||||
assert_eq!(valid_descriptor.len(), FILE_DESCRIPTOR_SIZE);
|
||||
assert!(valid_descriptor.ends_with(&[0_u8; UTF16_CODE_UNIT_SIZE]));
|
||||
assert!(invalid_name.is_err());
|
||||
assert!(matches!(
|
||||
invalid_descriptor,
|
||||
Err(CliprdrError::InvalidRequest { .. })
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@ use crate::{
|
||||
platform::unix::{FileDescription, FileType, BLOCK_SIZE},
|
||||
send_data, ClipboardFile, CliprdrError, ProgressPercent,
|
||||
};
|
||||
use hbb_common::{allow_err, log, tokio::time::Instant};
|
||||
use hbb_common::{allow_err, fs::join_validated_path, log, tokio::time::Instant};
|
||||
use std::{
|
||||
cmp::min,
|
||||
fs::{File, FileTimes},
|
||||
fs::{File, FileTimes, OpenOptions},
|
||||
io::{BufWriter, Write},
|
||||
os::macos::fs::FileTimesExt,
|
||||
path::{Path, PathBuf},
|
||||
@@ -27,6 +27,10 @@ const RECEIVE_WAIT_TIMEOUT: Duration = Duration::from_millis(5_000);
|
||||
const TIMESTAMP_FOR_FILE_PROGRESS_COMPLETED: u64 = 443779200;
|
||||
const ATTR_PROGRESS_FRACTION_COMPLETED: &str = "com.apple.progress.fractionCompleted";
|
||||
|
||||
fn create_new_file(path: impl AsRef<Path>) -> std::io::Result<File> {
|
||||
OpenOptions::new().write(true).create_new(true).open(path)
|
||||
}
|
||||
|
||||
pub struct FileContentsResponse {
|
||||
pub conn_id: i32,
|
||||
pub msg_flags: i32,
|
||||
@@ -117,7 +121,15 @@ impl PasteTask {
|
||||
target_dir,
|
||||
files,
|
||||
};
|
||||
task_handle.update_next(0).ok();
|
||||
// Path validation and creation are not atomic. Local filesystem changes can
|
||||
// invalidate checked paths, and entries created before an error are not rolled back.
|
||||
if let Err(error) = task_handle
|
||||
.validate_paths()
|
||||
.and_then(|_| task_handle.update_next(0))
|
||||
{
|
||||
log::error!("Failed to initialize paste task: {}", &error);
|
||||
task_handle.on_error(error);
|
||||
}
|
||||
if task_handle.is_finished() {
|
||||
task_handle.on_finished();
|
||||
} else {
|
||||
@@ -250,6 +262,13 @@ impl PasteTask {
|
||||
}
|
||||
|
||||
impl PasteTaskHandle {
|
||||
fn validate_paths(&self) -> Result<(), CliprdrError> {
|
||||
for file in &self.files {
|
||||
Self::join_file_path(&self.target_dir, &file.name)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn update_next(&mut self, size: u64) -> Result<(), CliprdrError> {
|
||||
if self.is_finished() {
|
||||
return Ok(());
|
||||
@@ -259,7 +278,7 @@ impl PasteTaskHandle {
|
||||
let is_start = self.progress.list_index == -1;
|
||||
if is_start || (self.progress.offset + size) >= self.progress.download_file_size {
|
||||
if !is_start {
|
||||
self.on_done();
|
||||
self.on_done()?;
|
||||
}
|
||||
for i in (self.progress.list_index + 1)..self.files.len() as i32 {
|
||||
let Some(file_desc) = self.files.get(i as usize) else {
|
||||
@@ -270,14 +289,12 @@ impl PasteTaskHandle {
|
||||
match file_desc.kind {
|
||||
FileType::File => {
|
||||
if file_desc.size == 0 {
|
||||
if let Some(new_file_path) =
|
||||
Self::get_new_filename(&self.target_dir, file_desc)
|
||||
{
|
||||
if let Ok(f) = std::fs::File::create(&new_file_path) {
|
||||
f.set_len(0).ok();
|
||||
Self::set_file_metadata(&f, file_desc);
|
||||
}
|
||||
};
|
||||
let path = Self::join_file_path(&self.target_dir, &file_desc.name)?;
|
||||
if let Some(path) = Self::get_new_filename(path, file_desc) {
|
||||
let f = create_new_file(&path)
|
||||
.map_err(|err| CliprdrError::FileError { path, err })?;
|
||||
Self::set_file_metadata(&f, file_desc);
|
||||
}
|
||||
} else {
|
||||
self.progress.list_index = i;
|
||||
self.progress.offset = 0;
|
||||
@@ -286,10 +303,11 @@ impl PasteTaskHandle {
|
||||
}
|
||||
}
|
||||
FileType::Directory => {
|
||||
let path = self.target_dir.join(&file_desc.name);
|
||||
if !path.exists() {
|
||||
std::fs::create_dir_all(path).ok();
|
||||
}
|
||||
let path = Self::join_file_path(&self.target_dir, &file_desc.name)?;
|
||||
std::fs::create_dir_all(&path).map_err(|err| CliprdrError::FileError {
|
||||
path: path.to_string_lossy().to_string(),
|
||||
err,
|
||||
})?;
|
||||
}
|
||||
FileType::Symlink => {
|
||||
// to-do: handle symlink
|
||||
@@ -362,9 +380,7 @@ impl PasteTaskHandle {
|
||||
});
|
||||
};
|
||||
|
||||
let original_file_path = self
|
||||
.target_dir
|
||||
.join(&file.name)
|
||||
let original_file_path = Self::join_file_path(&self.target_dir, &file.name)?
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let Some(download_file_path) = Self::get_first_filename(
|
||||
@@ -391,7 +407,7 @@ impl PasteTaskHandle {
|
||||
});
|
||||
}
|
||||
}
|
||||
match std::fs::File::create(&download_file_path) {
|
||||
match create_new_file(&download_file_path) {
|
||||
Ok(handle) => {
|
||||
let writer = BufWriter::with_capacity(BLOCK_SIZE as usize * 2, handle);
|
||||
self.progress.download_file_index = self.progress.list_index;
|
||||
@@ -446,6 +462,15 @@ impl PasteTaskHandle {
|
||||
None
|
||||
}
|
||||
|
||||
fn join_file_path(target_dir: &PathBuf, name: &Path) -> Result<PathBuf, CliprdrError> {
|
||||
let name = name.to_str().ok_or_else(|| CliprdrError::InvalidRequest {
|
||||
description: "clipboard file name is not valid UTF-8".to_string(),
|
||||
})?;
|
||||
join_validated_path(target_dir, name).map_err(|error| CliprdrError::InvalidRequest {
|
||||
description: error.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn progress_percent(&self) -> ProgressPercent {
|
||||
let percent = self.progress.current_size as f64 / self.progress.total_size as f64;
|
||||
ProgressPercent {
|
||||
@@ -476,8 +501,12 @@ impl PasteTaskHandle {
|
||||
fn on_finished(&mut self) {
|
||||
if self.progress.error.is_some() {
|
||||
self.on_cancelled();
|
||||
} else {
|
||||
self.on_done();
|
||||
return;
|
||||
}
|
||||
if let Err(error) = self.on_done() {
|
||||
log::error!("Failed to finish paste task: {}", &error);
|
||||
self.on_error(error);
|
||||
return;
|
||||
}
|
||||
if self.progress.current_size != self.progress.total_size {
|
||||
self.progress.error = Some(CliprdrError::InvalidRequest {
|
||||
@@ -496,15 +525,16 @@ impl PasteTaskHandle {
|
||||
std::fs::remove_file(&self.progress.download_file_path).ok();
|
||||
}
|
||||
|
||||
fn on_done(&mut self) {
|
||||
fn on_done(&mut self) -> Result<(), CliprdrError> {
|
||||
self.update_progress_completed(Some(1.0));
|
||||
Self::remove_progress_completed(&self.progress.download_file_path);
|
||||
|
||||
let Some(file) = self.progress.file_handle.as_mut() else {
|
||||
return;
|
||||
return Ok(());
|
||||
};
|
||||
if self.progress.download_file_index == PasteTask::INVALID_FILE_INDEX {
|
||||
return;
|
||||
log::error!("Invalid download file index");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Err(e) = file.flush() {
|
||||
@@ -518,26 +548,26 @@ impl PasteTaskHandle {
|
||||
"Failed to get file description: {}",
|
||||
self.progress.download_file_index
|
||||
);
|
||||
return;
|
||||
return Ok(());
|
||||
};
|
||||
let Some(rename_to_path) = Self::get_new_filename(&self.target_dir, file_desc) else {
|
||||
return;
|
||||
let path = Self::join_file_path(&self.target_dir, &file_desc.name)?;
|
||||
let Some(rename_to_path) = Self::get_new_filename(path, file_desc) else {
|
||||
return Ok(());
|
||||
};
|
||||
match std::fs::rename(&self.progress.download_file_path, &rename_to_path) {
|
||||
Ok(_) => Self::set_file_metadata2(&rename_to_path, file_desc),
|
||||
Err(e) => {
|
||||
log::error!("Failed to rename file: {:?}", e);
|
||||
std::fs::rename(&self.progress.download_file_path, &rename_to_path).map_err(|err| {
|
||||
CliprdrError::FileError {
|
||||
path: rename_to_path.clone(),
|
||||
err,
|
||||
}
|
||||
}
|
||||
})?;
|
||||
Self::set_file_metadata2(&rename_to_path, file_desc);
|
||||
self.progress.download_file_path = "".to_owned();
|
||||
self.progress.download_file_index = PasteTask::INVALID_FILE_INDEX;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_new_filename(target_dir: &PathBuf, file_desc: &FileDescription) -> Option<String> {
|
||||
let mut rename_to_path = target_dir
|
||||
.join(&file_desc.name)
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
fn get_new_filename(path: PathBuf, file_desc: &FileDescription) -> Option<String> {
|
||||
let mut rename_to_path = path.to_string_lossy().to_string();
|
||||
if Path::new(&rename_to_path).exists() {
|
||||
let Some(new_path) = Self::get_first_filename(rename_to_path.clone(), file_desc.kind)
|
||||
else {
|
||||
@@ -637,3 +667,122 @@ impl PasteTaskHandle {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
struct TestDirectory(PathBuf);
|
||||
|
||||
impl Drop for TestDirectory {
|
||||
fn drop(&mut self) {
|
||||
std::fs::remove_dir_all(&self.0).ok();
|
||||
}
|
||||
}
|
||||
|
||||
fn test_directories() -> (TestDirectory, PathBuf, PathBuf) {
|
||||
let temp = TestDirectory(std::env::temp_dir().join(uuid::Uuid::new_v4().to_string()));
|
||||
std::fs::create_dir(&temp.0).unwrap();
|
||||
let target = temp.0.join("target");
|
||||
let outside = temp.0.join("outside");
|
||||
std::fs::create_dir(&target).unwrap();
|
||||
std::fs::create_dir(&outside).unwrap();
|
||||
(temp, target, outside)
|
||||
}
|
||||
|
||||
fn file_description(name: &str, kind: FileType, size: u64) -> FileDescription {
|
||||
FileDescription {
|
||||
conn_id: 0,
|
||||
name: PathBuf::from(name),
|
||||
kind,
|
||||
atime: SystemTime::UNIX_EPOCH,
|
||||
last_modified: SystemTime::UNIX_EPOCH,
|
||||
last_metadata_changed: SystemTime::UNIX_EPOCH,
|
||||
creation_time: SystemTime::UNIX_EPOCH,
|
||||
size,
|
||||
perm: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn paste_task_handle(target_dir: PathBuf, files: Vec<FileDescription>) -> PasteTaskHandle {
|
||||
PasteTaskHandle {
|
||||
progress: PasteTaskProgress {
|
||||
list_index: -1,
|
||||
offset: 0,
|
||||
total_size: files.iter().map(|file| file.size).sum(),
|
||||
current_size: 0,
|
||||
last_sent_time: Instant::now(),
|
||||
download_file_index: PasteTask::INVALID_FILE_INDEX,
|
||||
download_file_size: 0,
|
||||
download_file_path: String::new(),
|
||||
download_file_current_size: 0,
|
||||
file_handle: None,
|
||||
error: None,
|
||||
is_canceled: false,
|
||||
},
|
||||
target_dir,
|
||||
files,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_all_paths_before_creating_files() {
|
||||
let (_temp, target, outside) = test_directories();
|
||||
symlink(&outside, target.join("link")).unwrap();
|
||||
|
||||
let files = vec![
|
||||
file_description("created.txt", FileType::File, 0),
|
||||
file_description("link/escaped", FileType::Directory, 0),
|
||||
];
|
||||
let mut task = paste_task_handle(target.clone(), files);
|
||||
|
||||
assert!(matches!(
|
||||
task.validate_paths().and_then(|_| task.update_next(0)),
|
||||
Err(CliprdrError::InvalidRequest { .. })
|
||||
));
|
||||
assert!(!target.join("created.txt").exists());
|
||||
assert!(!outside.join("escaped").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn final_path_validation_failure_marks_task_failed_and_removes_download() {
|
||||
let (_temp, target, outside) = test_directories();
|
||||
|
||||
let download_path = target.join("file.rddownload");
|
||||
let download_file = create_new_file(&download_path).unwrap();
|
||||
let files = vec![file_description("link/file.txt", FileType::File, 1)];
|
||||
let mut task = paste_task_handle(target.clone(), files);
|
||||
task.progress.list_index = 1;
|
||||
task.progress.current_size = 1;
|
||||
task.progress.download_file_index = 0;
|
||||
task.progress.download_file_size = 1;
|
||||
task.progress.download_file_path = download_path.to_string_lossy().to_string();
|
||||
task.progress.download_file_current_size = 1;
|
||||
task.progress.file_handle = Some(BufWriter::new(download_file));
|
||||
symlink(&outside, target.join("link")).unwrap();
|
||||
|
||||
task.on_finished();
|
||||
|
||||
assert!(matches!(
|
||||
task.progress.error,
|
||||
Some(CliprdrError::InvalidRequest { .. })
|
||||
));
|
||||
assert!(!download_path.exists());
|
||||
assert!(!outside.join("file.txt").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_symlink_component_when_creating_directory() {
|
||||
let (_temp, target, outside) = test_directories();
|
||||
symlink(&outside, target.join("link")).unwrap();
|
||||
|
||||
let directory = file_description("link/escaped", FileType::Directory, 0);
|
||||
let mut task = paste_task_handle(target, vec![directory]);
|
||||
assert!(matches!(
|
||||
task.update_next(0),
|
||||
Err(CliprdrError::InvalidRequest { .. })
|
||||
));
|
||||
assert!(!outside.join("escaped").exists());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,10 @@ pub const FILECONTENTS_FORMAT_NAME: &str = "FileContents";
|
||||
/// block size for fuse, align to our asynchronic request size over FileContentsRequest.
|
||||
pub(crate) const BLOCK_SIZE: u32 = 4 * 1024 * 1024;
|
||||
|
||||
/// `FILEDESCRIPTORW::cFileName` capacity, including the trailing NUL code unit.
|
||||
pub(super) const FILE_NAME_CODE_UNITS: usize = 260;
|
||||
pub(super) const FILE_NAME_FIELD_SIZE: usize = FILE_NAME_CODE_UNITS * std::mem::size_of::<u16>();
|
||||
|
||||
// begin of epoch used by microsoft
|
||||
// 1601-01-01 00:00:00 + LDAP_EPOCH_DELTA*(100 ns) = 1970-01-01 00:00:00
|
||||
const LDAP_EPOCH_DELTA: u64 = 116444772610000000;
|
||||
|
||||
@@ -93,13 +93,14 @@ impl ClipFiles {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_file_list_pdu(&mut self) {
|
||||
fn build_file_list_pdu(&mut self) -> Result<(), CliprdrError> {
|
||||
let mut data = BytesMut::with_capacity(4 + 592 * self.file_list.len());
|
||||
data.put_u32_le(self.file_list.len() as u32);
|
||||
for file in self.file_list.iter() {
|
||||
data.put(file.as_bin().as_slice());
|
||||
data.put(file.as_bin()?.as_slice());
|
||||
}
|
||||
self.files_pdu = data.to_vec()
|
||||
self.files_pdu = data.to_vec();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_files_for_audit(&self, request: &FileContentsRequest) -> Option<ClipboardFile> {
|
||||
@@ -301,7 +302,7 @@ pub fn sync_files(files: &[String]) -> Result<(), CliprdrError> {
|
||||
return Ok(());
|
||||
}
|
||||
files_lock.sync_files(files, current)?;
|
||||
Ok(files_lock.build_file_list_pdu())
|
||||
files_lock.build_file_list_pdu()
|
||||
}
|
||||
|
||||
pub fn get_file_list_pdu() -> Vec<u8> {
|
||||
|
||||
@@ -521,6 +521,8 @@ extern "C" {
|
||||
pub(crate) fn init_cliprdr(context: *mut CliprdrClientContext) -> BOOL;
|
||||
pub(crate) fn uninit_cliprdr(context: *mut CliprdrClientContext) -> BOOL;
|
||||
pub(crate) fn empty_cliprdr(context: *mut CliprdrClientContext, connID: UINT32) -> BOOL;
|
||||
#[cfg(test)]
|
||||
fn wf_cliprdr_file_descriptor_name_valid(name: *const WCHAR) -> BOOL;
|
||||
}
|
||||
|
||||
unsafe impl Send for CliprdrClientContext {}
|
||||
@@ -1325,3 +1327,77 @@ extern "C" fn client_file_contents_response(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::iter::once;
|
||||
|
||||
const FILE_NAME_CODE_UNITS: usize = 260;
|
||||
const FILE_NAME_CASES: &[(&str, bool)] = &[
|
||||
("", false),
|
||||
("/absolute", false),
|
||||
("C:\\absolute", false),
|
||||
("dir\\..\\payload", false),
|
||||
("dir//payload", false),
|
||||
("file.", false),
|
||||
("file ", false),
|
||||
(" report.txt", false),
|
||||
(" NUL.txt", false),
|
||||
("dir\\ nested.txt", false),
|
||||
("CON", false),
|
||||
("nul.txt", false),
|
||||
("dir\\AUX.log", false),
|
||||
("PRN.tar.gz", false),
|
||||
("com1", false),
|
||||
("COM\u{00b9}.txt", false),
|
||||
("COM\u{00b2}.txt", false),
|
||||
("lpt9.log", false),
|
||||
("dir/LPT\u{00b3}", false),
|
||||
("CONIN$", false),
|
||||
("dir\\conout$", false),
|
||||
("CLOCK$", false),
|
||||
("bad<name", false),
|
||||
("bad>name", false),
|
||||
("bad:name", false),
|
||||
("bad\"name", false),
|
||||
("bad|name", false),
|
||||
("bad?name", false),
|
||||
("bad*name", false),
|
||||
("bad\u{0001}name", false),
|
||||
("dir\\bad\u{001f}name", false),
|
||||
("normal.txt", true),
|
||||
(".gitignore", true),
|
||||
("dir\\nested file.txt", true),
|
||||
("dir/nested file.txt", true),
|
||||
("com10.txt", true),
|
||||
("auxiliary.log", true),
|
||||
("clock$.txt", true),
|
||||
("conin$.txt", true),
|
||||
];
|
||||
|
||||
fn file_descriptor_name_valid(name: &str) -> bool {
|
||||
let wide_name: Vec<_> = name.encode_utf16().chain(once(0)).collect();
|
||||
unsafe { wf_cliprdr_file_descriptor_name_valid(wide_name.as_ptr()) == TRUE }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_file_descriptor_names() {
|
||||
for &(name, expected) in FILE_NAME_CASES {
|
||||
assert_eq!(
|
||||
file_descriptor_name_valid(name),
|
||||
expected,
|
||||
"unexpected validity for {name:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_terminated_file_descriptor_name() {
|
||||
let wide_name = [WCHAR::from(b'a'); FILE_NAME_CODE_UNITS];
|
||||
assert_eq!(
|
||||
unsafe { wf_cliprdr_file_descriptor_name_valid(wide_name.as_ptr()) },
|
||||
FALSE
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Submodule libs/hbb_common updated: 7e1c392c62...3ed938544f
@@ -11,6 +11,16 @@ edition = "2018"
|
||||
|
||||
[features]
|
||||
wayland = ["gstreamer", "gstreamer-app", "gstreamer-video", "dbus", "tracing", "zbus"]
|
||||
# `drm` is a pure runtime-dlopen backend: rustdesk loads `libdrmtap.so.0` at runtime (`drmtap_dl.rs`)
|
||||
# and NEVER link-time links it, so the graceful PipeWire fallback when the .so or EGL is absent is
|
||||
# preserved and the drm build pulls in no libdrm/seccomp/cap/EGL link-time deps. The .so is pinned by
|
||||
# `DRMTAP_SHA` in build.py, which fetches that exact commit (libdrmtap v0.5.2). We deliberately do
|
||||
# NOT depend on the `libdrmtap-sys` crate: its build.rs statically compiles the whole libdrmtap C tree
|
||||
# and a CAP_SYS_ADMIN helper and emits `-ldrm -lseccomp -lcap`, which would defeat the dlopen model.
|
||||
# Depends on `wayland`: the three drm modules live inside the `#[cfg(feature = "wayland")]` arm of
|
||||
# common/mod.rs, so `scrap/drm` on its own would compile nothing. The root crate happens to always
|
||||
# enable `scrap/wayland`, which is what hid this.
|
||||
drm = ["wayland"]
|
||||
mediacodec = ["ndk"]
|
||||
linux-pkg-config = ["dep:pkg-config"]
|
||||
hwcodec = ["dep:hwcodec"]
|
||||
@@ -48,7 +58,7 @@ quest = "0.3"
|
||||
|
||||
[build-dependencies]
|
||||
target_build_utils = "0.3"
|
||||
bindgen = "0.65"
|
||||
bindgen = "0.72.1"
|
||||
pkg-config = { version = "0.3.27", optional = true }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
|
||||
477
libs/scrap/src/common/drm_reader.rs
Normal file
477
libs/scrap/src/common/drm_reader.rs
Normal file
@@ -0,0 +1,477 @@
|
||||
// Service-side DRM/KMS read engine, in the ROOT `--service`: libdrmtap reads the scanout in-process (direct mode). The DRM_DEVICE env is not consulted here.
|
||||
|
||||
use super::drmtap_dl::{
|
||||
self, drmtap_config, drmtap_ctx, drmtap_cursor_info, drmtap_device, drmtap_display,
|
||||
drmtap_dmabuf_desc, drmtap_frame_info, DrmtapLib,
|
||||
};
|
||||
use hbb_common::log;
|
||||
use std::ffi::CString;
|
||||
use std::io;
|
||||
use std::os::fd::{FromRawFd, OwnedFd};
|
||||
|
||||
// Trust-boundary limits and formats `drm_render` (the unprivileged converter) imports: two copies that drift apart would weaken one side.
|
||||
// 16384 covers 8K+ with headroom; anything larger is rejected as a bogus/hostile geometry.
|
||||
pub(crate) const MAX_DIM: u32 = 16384;
|
||||
// 256 MiB covers an 8K BGRA frame (7680x4320x4 ~= 127 MiB) with margin.
|
||||
pub(crate) const MAX_FRAME_BYTES: usize = 256 * 1024 * 1024;
|
||||
// XRGB/ARGB are little-endian B,G,R,{X,A} in memory == `Pixfmt::BGRA`; XBGR/ABGR are R,G,B,{X,A} == `Pixfmt::RGBA`.
|
||||
pub(crate) const DRM_FORMAT_XRGB8888: u32 = 0x3432_5258; // 'XR24'
|
||||
pub(crate) const DRM_FORMAT_ARGB8888: u32 = 0x3432_5241; // 'AR24'
|
||||
pub(crate) const DRM_FORMAT_XBGR8888: u32 = 0x3432_4258; // 'XB24'
|
||||
pub(crate) const DRM_FORMAT_ABGR8888: u32 = 0x3432_4241; // 'AB24'
|
||||
|
||||
/// Cursor id published when the plane reports the cursor hidden, so the id changes and, where the DRM cursor is authoritative, the client drops the last shape.
|
||||
pub const HIDDEN_CURSOR_ID: u64 = u64::MAX;
|
||||
|
||||
pub struct CursorSnapshot {
|
||||
pub id: u64,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub hotx: i32,
|
||||
pub hoty: i32,
|
||||
pub colors: Vec<u8>,
|
||||
}
|
||||
|
||||
/// One enumerated DRM display, physical geometry only (the server overlays the Wayland logical origin/scale where it can match one).
|
||||
pub struct DisplaySnapshot {
|
||||
pub name: String,
|
||||
pub crtc_id: u32,
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
pub struct DrmDevice {
|
||||
pub path: String,
|
||||
/// Render node, or empty if this device has none.
|
||||
pub render_node: String,
|
||||
pub display_count: u32,
|
||||
}
|
||||
|
||||
/// Copy a fixed C char array into a `String`, stopping at the first NUL WITHIN the array, so a
|
||||
/// field libdrmtap failed to terminate cannot read past it.
|
||||
fn cstr_field(buf: &[std::os::raw::c_char]) -> String {
|
||||
// SAFETY: c_char and u8 share size/alignment; the slice is the exact length of `buf`.
|
||||
let bytes: &[u8] =
|
||||
unsafe { std::slice::from_raw_parts(buf.as_ptr() as *const u8, buf.len()) };
|
||||
let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
|
||||
String::from_utf8_lossy(&bytes[..end]).into_owned()
|
||||
}
|
||||
|
||||
/// Enumerate every DRM device with KMS resources. `None` = unavailable, too old, or failed (the caller then scans /dev/dri/card* itself); empty `Vec` = none found.
|
||||
pub fn list_devices() -> Option<Vec<DrmDevice>> {
|
||||
let lib = drmtap_dl::get()?;
|
||||
let f = lib.list_devices?;
|
||||
const MAX: usize = 16;
|
||||
let mut raw: [drmtap_device; MAX] = unsafe { std::mem::zeroed() };
|
||||
// SAFETY: `raw` is MAX valid, zeroed drmtap_device slots; the call fills up to MAX and returns the count.
|
||||
let n = unsafe { f(raw.as_mut_ptr(), MAX as std::os::raw::c_int) };
|
||||
if n < 0 {
|
||||
log::warn!("drmtap_list_devices failed ({n}); using single-device auto-detect");
|
||||
return None;
|
||||
}
|
||||
let n = (n as usize).min(MAX);
|
||||
Some(
|
||||
raw[..n]
|
||||
.iter()
|
||||
.map(|d| DrmDevice {
|
||||
path: cstr_field(&d.path),
|
||||
render_node: cstr_field(&d.render_node),
|
||||
display_count: d.display_count,
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The CANONICAL path, when `path` canonicalizes to a node directly under /dev/dri/, else `None`.
|
||||
/// Callers must open the value returned: opening the original re-resolves every symlink component after the check.
|
||||
pub(super) fn device_under_dev_dri(path: &str) -> Option<std::path::PathBuf> {
|
||||
let p = std::fs::canonicalize(path).ok()?;
|
||||
if p.parent() == Some(std::path::Path::new("/dev/dri")) {
|
||||
Some(p)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// An open DRM read context. Not Send/Sync deliberately (the raw ctx is used on one thread).
|
||||
pub struct DrmReader {
|
||||
lib: &'static DrmtapLib,
|
||||
ctx: *mut drmtap_ctx,
|
||||
buf: Vec<u8>,
|
||||
}
|
||||
|
||||
impl DrmReader {
|
||||
/// Open the DRM device. `device = None` auto-detects, `Some(path)` is realpath-gated to /dev/dri/. `crtc_id = 0` auto-selects the first active CRTC.
|
||||
pub fn open(device: Option<&str>, crtc_id: u32) -> Option<DrmReader> {
|
||||
let lib = drmtap_dl::get()?;
|
||||
let device_cstr = match device {
|
||||
None => None,
|
||||
Some(d) => {
|
||||
let Some(canonical) = device_under_dev_dri(d) else {
|
||||
log::warn!("DRM device {d:?} is not under /dev/dri; refusing to open");
|
||||
return None;
|
||||
};
|
||||
match canonical.to_str().and_then(|s| CString::new(s).ok()) {
|
||||
Some(c) => Some(c),
|
||||
None => return None,
|
||||
}
|
||||
}
|
||||
};
|
||||
let cfg = drmtap_config {
|
||||
device_path: device_cstr.as_ref().map_or(std::ptr::null(), |c| c.as_ptr()),
|
||||
crtc_id,
|
||||
helper_path: std::ptr::null(),
|
||||
debug: 0,
|
||||
};
|
||||
// SAFETY: cfg is a valid struct; device_cstr outlives this call.
|
||||
let ctx = unsafe { (lib.open)(&cfg) };
|
||||
drop(device_cstr);
|
||||
if ctx.is_null() {
|
||||
log::info!("drmtap_open failed; DRM capture unavailable");
|
||||
return None;
|
||||
}
|
||||
Some(DrmReader {
|
||||
lib,
|
||||
ctx,
|
||||
buf: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Grab one frame, tightly packed as BGRA (`w*4*h` bytes), into the internal buffer; valid until the next grab.
|
||||
pub fn grab(&mut self) -> io::Result<(&[u8], usize, usize)> {
|
||||
// SAFETY: ctx is valid; frame is zeroed before the call. The frame is released on every return path that OWNS one: a failing
|
||||
// `drmtap_grab_mapped` leaves nothing to release, and releasing anyway would be a double free.
|
||||
unsafe {
|
||||
let mut frame: drmtap_frame_info = std::mem::zeroed();
|
||||
let ret = (self.lib.grab_mapped)(self.ctx, &mut frame);
|
||||
if ret < 0 {
|
||||
let errno = -ret;
|
||||
if errno == hbb_common::libc::EAGAIN
|
||||
|| errno == hbb_common::libc::EBUSY
|
||||
|| errno == hbb_common::libc::EINTR
|
||||
{
|
||||
return Err(io::ErrorKind::WouldBlock.into());
|
||||
}
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
format!("drmtap_grab_mapped failed: errno {errno}"),
|
||||
));
|
||||
}
|
||||
if frame.data.is_null() || frame.width == 0 || frame.height == 0 {
|
||||
(self.lib.frame_release)(self.ctx, &mut frame);
|
||||
return Err(io::ErrorKind::WouldBlock.into());
|
||||
}
|
||||
let w = frame.width;
|
||||
let h = frame.height;
|
||||
let stride = frame.stride as usize;
|
||||
// The row copy reads w*4 bytes from a source only stride*height bytes: reject sub-32bpp / insane geometry to avoid an OOB read.
|
||||
if w > MAX_DIM || h > MAX_DIM || stride < (w as usize) * 4 {
|
||||
log::warn!(
|
||||
"DRM scanout not 32-bit BGRA-compatible ({w}x{h} stride {stride} fourcc {:#010x}); falling back",
|
||||
frame.format
|
||||
);
|
||||
(self.lib.frame_release)(self.ctx, &mut frame);
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
"unsupported DRM scanout format",
|
||||
));
|
||||
}
|
||||
// XBGR8888 passes the stride check but, labeled BGRA downstream, would ship red and blue swapped; a zero fourcc falls through to the stride invariant (kept for libdrmtap builds that do not set it).
|
||||
if frame.format != 0
|
||||
&& frame.format != DRM_FORMAT_XRGB8888
|
||||
&& frame.format != DRM_FORMAT_ARGB8888
|
||||
{
|
||||
log::warn!(
|
||||
"DRM scanout fourcc {:#010x} is not BGRA-compatible; falling back",
|
||||
frame.format
|
||||
);
|
||||
(self.lib.frame_release)(self.ctx, &mut frame);
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
"unsupported DRM scanout format",
|
||||
));
|
||||
}
|
||||
let (w, h) = (w as usize, h as usize);
|
||||
let frame_size = match w.checked_mul(4).and_then(|x| x.checked_mul(h)) {
|
||||
Some(sz) if sz > 0 && sz <= MAX_FRAME_BYTES => sz,
|
||||
other => {
|
||||
log::warn!(
|
||||
"DRM scanout geometry {w}x{h} yields an out-of-range frame ({other:?} bytes); falling back"
|
||||
);
|
||||
(self.lib.frame_release)(self.ctx, &mut frame);
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
"DRM scanout frame too large",
|
||||
));
|
||||
}
|
||||
};
|
||||
// Bound the SOURCE extent too: the row loop reads up to (h-1)*stride + w*4, and `y * stride` can overflow.
|
||||
match stride.checked_mul(h) {
|
||||
Some(sz) if sz > 0 && sz <= MAX_FRAME_BYTES => {}
|
||||
other => {
|
||||
log::warn!(
|
||||
"DRM scanout stride {stride} x {h} rows is out of range ({other:?} bytes); falling back"
|
||||
);
|
||||
(self.lib.frame_release)(self.ctx, &mut frame);
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
"DRM scanout stride out of range",
|
||||
));
|
||||
}
|
||||
}
|
||||
if self.buf.len() != frame_size {
|
||||
self.buf.resize(frame_size, 0);
|
||||
}
|
||||
let src = frame.data as *const u8;
|
||||
let dst = self.buf.as_mut_ptr();
|
||||
if stride == w * 4 {
|
||||
std::ptr::copy_nonoverlapping(src, dst, frame_size);
|
||||
} else {
|
||||
for y in 0..h {
|
||||
std::ptr::copy_nonoverlapping(src.add(y * stride), dst.add(y * w * 4), w * 4);
|
||||
}
|
||||
}
|
||||
(self.lib.frame_release)(self.ctx, &mut frame);
|
||||
Ok((&self.buf, w, h))
|
||||
}
|
||||
}
|
||||
|
||||
/// Render node of the GPU this reader captures from, so the converter binds to the device that EXPORTS the scanout:
|
||||
/// importing across vendors can fail on an incompatible tiling modifier. `None` if the symbol is absent or the device is display-only.
|
||||
pub fn render_node(&mut self) -> Option<String> {
|
||||
let f = self.lib.render_node?;
|
||||
// SAFETY: self.ctx is valid; the returned pointer is owned by the context and stays valid until it is closed.
|
||||
let ptr = unsafe { f(self.ctx) };
|
||||
if ptr.is_null() {
|
||||
return None;
|
||||
}
|
||||
unsafe { std::ffi::CStr::from_ptr(ptr) }
|
||||
.to_str()
|
||||
.ok()
|
||||
.map(|s| s.to_owned())
|
||||
}
|
||||
|
||||
/// Zero-copy EXPORT grab: fills a `drmtap_dmabuf_desc` (dma-buf fd, plane layout, HDR metadata) WITHOUT mapping, detiling or copying pixels, so on this
|
||||
/// path the root process never loads libEGL/libGLESv2. The exported fd is READ-ONLY (libdrmtap drops `DRM_RDWR` and `dup` shares that open file
|
||||
/// description), so the `--server` that receives it can map the scanout but never write the live framebuffer. Validation here is METADATA ONLY.
|
||||
pub fn grab_desc(&mut self) -> io::Result<(OwnedFd, drmtap_dmabuf_desc)> {
|
||||
let grab_desc = self.lib.grab_desc;
|
||||
// SAFETY: self.ctx is valid; desc/frame are zeroed before the call. Only paths that reach a populated frame release it: on `-EINVAL`
|
||||
// libdrmtap returns before allocating, a failed inner grab has already cleaned up, and on `-ENOTSUP` libdrmtap releases the frame itself.
|
||||
unsafe {
|
||||
let mut desc: drmtap_dmabuf_desc = std::mem::zeroed();
|
||||
let mut frame: drmtap_frame_info = std::mem::zeroed();
|
||||
let ret = grab_desc(self.ctx, &mut desc, &mut frame);
|
||||
if ret < 0 {
|
||||
let errno = -ret;
|
||||
if errno == hbb_common::libc::EAGAIN
|
||||
|| errno == hbb_common::libc::EBUSY
|
||||
|| errno == hbb_common::libc::EINTR
|
||||
{
|
||||
return Err(io::ErrorKind::WouldBlock.into());
|
||||
}
|
||||
if errno == hbb_common::libc::ENOTSUP {
|
||||
// A distinct error so the caller degrades to the mapped/PipeWire path instead of tight-looping a rebuild.
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
"drmtap_grab_desc: no transferable dma-buf (ENOTSUP)",
|
||||
));
|
||||
}
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
format!("drmtap_grab_desc failed: errno {errno}"),
|
||||
));
|
||||
}
|
||||
// `desc.dma_buf_fd` is the canonical fd (what split_capture.c sends); `frame` owns it too and `frame_release` closes the library's copy.
|
||||
let raw_fd = if desc.dma_buf_fd >= 0 {
|
||||
desc.dma_buf_fd
|
||||
} else {
|
||||
frame.dma_buf_fd
|
||||
};
|
||||
if raw_fd < 0 {
|
||||
(self.lib.frame_release)(self.ctx, &mut frame);
|
||||
return Err(io::ErrorKind::WouldBlock.into());
|
||||
}
|
||||
let w = desc.width;
|
||||
let h = desc.height;
|
||||
if w == 0 || h == 0 || w > MAX_DIM || h > MAX_DIM {
|
||||
(self.lib.frame_release)(self.ctx, &mut frame);
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
format!("DRM scanout geometry {w}x{h} out of range"),
|
||||
));
|
||||
}
|
||||
// No fourcc gate here: the converter handles every format libdrmtap supports, and gating here dropped convertible scanouts such as XR30.
|
||||
let planes = if desc.num_planes == 0 { 1 } else { desc.num_planes };
|
||||
if planes > 4 {
|
||||
(self.lib.frame_release)(self.ctx, &mut frame);
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
format!("DRM scanout num_planes {} out of range (1..=4)", desc.num_planes),
|
||||
));
|
||||
}
|
||||
for p in 0..(planes as usize) {
|
||||
let extent = (desc.pitches[p] as usize)
|
||||
.checked_mul(h as usize)
|
||||
.and_then(|rows| rows.checked_add(desc.offsets[p] as usize));
|
||||
match extent {
|
||||
Some(end) if end <= MAX_FRAME_BYTES => {}
|
||||
other => {
|
||||
(self.lib.frame_release)(self.ctx, &mut frame);
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
format!(
|
||||
"DRM scanout plane {p} out of range (offset {} pitch {} over {h} rows -> {other:?}, cap {MAX_FRAME_BYTES})",
|
||||
desc.offsets[p], desc.pitches[p]
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
// dup BEFORE releasing the frame: after release the library may recycle its handle, while an independent fd on the same open dma-buf
|
||||
// keeps the buffer alive for the peer. F_DUPFD_CLOEXEC, not dup(): `dup` never copies close-on-exec and this root service forks elsewhere.
|
||||
let dup_fd = hbb_common::libc::fcntl(raw_fd, hbb_common::libc::F_DUPFD_CLOEXEC, 0);
|
||||
if dup_fd < 0 {
|
||||
let e = io::Error::last_os_error();
|
||||
(self.lib.frame_release)(self.ctx, &mut frame);
|
||||
return Err(e);
|
||||
}
|
||||
let owned = OwnedFd::from_raw_fd(dup_fd);
|
||||
(self.lib.frame_release)(self.ctx, &mut frame);
|
||||
desc.num_planes = planes;
|
||||
desc.dma_buf_fd = -1;
|
||||
Ok((owned, desc))
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the hardware cursor plane: the hidden sentinel when the plane reports the cursor invisible, the real shape when visible, and `None` when the read fails.
|
||||
pub fn cursor(&mut self) -> Option<CursorSnapshot> {
|
||||
// SAFETY: ctx valid; c zeroed; released on EVERY path after a successful get_cursor. Only a failed get_cursor returns without releasing, because then there is nothing to release.
|
||||
unsafe {
|
||||
let mut c: drmtap_cursor_info = std::mem::zeroed();
|
||||
let cret = (self.lib.get_cursor)(self.ctx, &mut c);
|
||||
if cret != 0 {
|
||||
return None;
|
||||
}
|
||||
let out = if c.visible == 0 {
|
||||
Some(CursorSnapshot {
|
||||
id: HIDDEN_CURSOR_ID,
|
||||
width: 1,
|
||||
height: 1,
|
||||
hotx: 0,
|
||||
hoty: 0,
|
||||
colors: vec![0, 0, 0, 0],
|
||||
})
|
||||
} else if !c.pixels.is_null()
|
||||
&& c.width > 0
|
||||
&& c.height > 0
|
||||
&& (c.width as i64) * (c.height as i64) <= 256 * 256
|
||||
{
|
||||
let cw = c.width as i32;
|
||||
let ch = c.height as i32;
|
||||
let n = (cw * ch) as usize;
|
||||
let src = std::slice::from_raw_parts(c.pixels, n);
|
||||
let mut hash: u64 = 1469598103934665603;
|
||||
let mut colors = Vec::with_capacity(n * 4);
|
||||
let (mut minx, mut miny, mut maxx, mut maxy) = (cw, ch, -1i32, -1i32);
|
||||
for (i, &p) in src.iter().enumerate() {
|
||||
let a = ((p >> 24) & 0xff) as u8;
|
||||
let r = ((p >> 16) & 0xff) as u8;
|
||||
let g = ((p >> 8) & 0xff) as u8;
|
||||
let b = (p & 0xff) as u8;
|
||||
colors.push(r);
|
||||
colors.push(g);
|
||||
colors.push(b);
|
||||
colors.push(a);
|
||||
hash ^= p as u64;
|
||||
hash = hash.wrapping_mul(1099511628211);
|
||||
if a >= 128 {
|
||||
let x = (i as i32) % cw;
|
||||
let y = (i as i32) / cw;
|
||||
if x < minx { minx = x; }
|
||||
if x > maxx { maxx = x; }
|
||||
if y < miny { miny = y; }
|
||||
if y > maxy { maxy = y; }
|
||||
}
|
||||
}
|
||||
let (hotx, hoty) = if c.hot_x != 0 || c.hot_y != 0 {
|
||||
(c.hot_x, c.hot_y)
|
||||
} else if maxx >= minx && maxy >= miny {
|
||||
let (bw, bh) = (maxx - minx + 1, maxy - miny + 1);
|
||||
if bh > bw * 2 {
|
||||
((minx + maxx) / 2, (miny + maxy) / 2)
|
||||
} else {
|
||||
(minx, miny)
|
||||
}
|
||||
} else {
|
||||
(0, 0)
|
||||
};
|
||||
// Fold geometry + hotspot into the id: identical pixels with a changed size or
|
||||
// hotspot must count as a new shape, otherwise drm_capture_worker suppresses the
|
||||
// update (it dedupes by id) and the client keeps rendering the stale cursor.
|
||||
let mut id = hash;
|
||||
for v in [cw as u32 as u64, ch as u32 as u64, hotx as u32 as u64, hoty as u32 as u64] {
|
||||
id ^= v;
|
||||
id = id.wrapping_mul(1099511628211);
|
||||
}
|
||||
Some(CursorSnapshot {
|
||||
id,
|
||||
width: cw as u32,
|
||||
height: ch as u32,
|
||||
hotx,
|
||||
hoty,
|
||||
colors,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
(self.lib.cursor_release)(self.ctx, &mut c);
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
pub fn displays(&mut self) -> Vec<DisplaySnapshot> {
|
||||
// SAFETY: ctx valid; raw is a zeroed, correctly-sized array; count is clamped to the buffer before indexing.
|
||||
unsafe {
|
||||
let mut raw = vec![std::mem::zeroed::<drmtap_display>(); 16];
|
||||
let cap = raw.len() as i32;
|
||||
let n = (self.lib.list_displays)(self.ctx, raw.as_mut_ptr(), cap);
|
||||
if n <= 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let count = (n as usize).min(raw.len());
|
||||
(0..count)
|
||||
.map(|i| {
|
||||
let name_bytes: Vec<u8> = raw[i]
|
||||
.name
|
||||
.iter()
|
||||
.take_while(|&&ch| ch != 0)
|
||||
.map(|&ch| ch as u8)
|
||||
.collect();
|
||||
DisplaySnapshot {
|
||||
name: String::from_utf8_lossy(&name_bytes).to_string(),
|
||||
crtc_id: raw[i].crtc_id,
|
||||
x: raw[i].x as i32,
|
||||
y: raw[i].y as i32,
|
||||
width: raw[i].width,
|
||||
height: raw[i].height,
|
||||
active: raw[i].active != 0,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DrmReader {
|
||||
fn drop(&mut self) {
|
||||
if !self.ctx.is_null() {
|
||||
// SAFETY: ctx came from drmtap_open and is non-null.
|
||||
unsafe { (self.lib.close)(self.ctx) };
|
||||
self.ctx = std::ptr::null_mut();
|
||||
}
|
||||
}
|
||||
}
|
||||
184
libs/scrap/src/common/drm_render.rs
Normal file
184
libs/scrap/src/common/drm_render.rs
Normal file
@@ -0,0 +1,184 @@
|
||||
// Unprivileged half of the split DRM/KMS capture path: the root `--service` exports a scanout
|
||||
// dma-buf fd + descriptor, this side imports it and EGL-detiles. libEGL/libGLESv2 are dlopen'd
|
||||
// in the UNPRIVILEGED process on this path; the root service loads them only if it falls back to
|
||||
// its own CPU-mapped grab (`drmtap_grab_mapped`).
|
||||
|
||||
use super::drmtap_dl::{self, drmtap_ctx, drmtap_dmabuf_desc, drmtap_frame_info, DrmtapLib};
|
||||
use super::Pixfmt;
|
||||
use hbb_common::log;
|
||||
use std::ffi::CString;
|
||||
use std::io;
|
||||
use std::os::fd::RawFd;
|
||||
|
||||
use super::drm_reader::{
|
||||
DRM_FORMAT_ABGR8888, DRM_FORMAT_ARGB8888, DRM_FORMAT_XBGR8888, DRM_FORMAT_XRGB8888,
|
||||
MAX_DIM, MAX_FRAME_BYTES,
|
||||
};
|
||||
|
||||
/// Unprivileged DRM render-node convert context. !Send/!Sync via the raw ctx pointer: the context
|
||||
/// and libdrmtap's thread-local EGL state must be created, used (`convert`) and closed on ONE thread.
|
||||
pub struct RenderConverter {
|
||||
lib: &'static DrmtapLib,
|
||||
ctx: *mut drmtap_ctx,
|
||||
}
|
||||
|
||||
impl RenderConverter {
|
||||
/// `node` is the render node of the GPU that exports the scanout; `None`/invalid path falls back to libdrmtap auto-selection.
|
||||
pub fn open_render(node: Option<&str>) -> Option<RenderConverter> {
|
||||
let lib = drmtap_dl::get()?;
|
||||
let open_render = lib.open_render;
|
||||
let node_cstr = match node.filter(|n| !n.is_empty()) {
|
||||
None => None,
|
||||
// Open the CANONICAL path the gate resolved: opening the IPC string would re-walk its symlinks after the check.
|
||||
Some(n) => match super::drm_reader::device_under_dev_dri(n) {
|
||||
None => {
|
||||
log::warn!("drm: render node {n:?} is not under /dev/dri; auto-selecting");
|
||||
None
|
||||
}
|
||||
Some(canonical) => canonical.to_str().and_then(|s| CString::new(s).ok()),
|
||||
},
|
||||
};
|
||||
// SAFETY: resolved C entry point; `node_cstr` outlives the call, NULL requests auto-selection.
|
||||
let ctx = unsafe {
|
||||
open_render(node_cstr.as_ref().map_or(std::ptr::null(), |c| c.as_ptr()))
|
||||
};
|
||||
if ctx.is_null() {
|
||||
log::info!(
|
||||
"drmtap_open_render({}) failed; no usable DRM render node",
|
||||
node_cstr.as_ref().map_or("NULL".to_owned(), |c| format!("{c:?}"))
|
||||
);
|
||||
return None;
|
||||
}
|
||||
match node_cstr {
|
||||
Some(c) => log::info!(
|
||||
"drm: opened unprivileged convert context on the exporting GPU ({c:?})"
|
||||
),
|
||||
None => log::info!(
|
||||
"drm: opened unprivileged render-node convert context (auto-selected)"
|
||||
),
|
||||
}
|
||||
Some(RenderConverter { lib, ctx })
|
||||
}
|
||||
|
||||
/// Returns context-owned linear pixels valid ONLY until the next `convert()`; row stride is `len / height`.
|
||||
pub fn convert(
|
||||
&mut self,
|
||||
desc: &mut drmtap_dmabuf_desc,
|
||||
received_fd: RawFd,
|
||||
) -> io::Result<(&[u8], u32, u32, Pixfmt)> {
|
||||
{
|
||||
let (w, h) = (desc.width, desc.height);
|
||||
if w == 0 || h == 0 || w > MAX_DIM || h > MAX_DIM {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("drm: refusing a dma-buf descriptor with geometry {w}x{h}"),
|
||||
));
|
||||
}
|
||||
// Reject, do not clamp, and write the normalized count back so the C reads the count bounded here.
|
||||
let planes = if desc.num_planes == 0 { 1 } else { desc.num_planes };
|
||||
if planes > 4 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!(
|
||||
"drm: refusing a dma-buf descriptor with num_planes {} (1..=4)",
|
||||
desc.num_planes
|
||||
),
|
||||
));
|
||||
}
|
||||
desc.num_planes = planes;
|
||||
let planes = planes as usize;
|
||||
for p in 0..planes {
|
||||
let extent = (desc.pitches[p] as usize)
|
||||
.checked_mul(h as usize)
|
||||
.and_then(|rows| rows.checked_add(desc.offsets[p] as usize));
|
||||
match extent {
|
||||
Some(end) if end <= MAX_FRAME_BYTES => {}
|
||||
other => {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!(
|
||||
"drm: refusing dma-buf plane {p} (offset {} pitch {} over {h} rows -> {other:?}, cap {MAX_FRAME_BYTES})",
|
||||
desc.offsets[p], desc.pitches[p]
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let convert_dmabuf = self.lib.convert_dmabuf;
|
||||
// LOAD-BEARING: the fd the exporter serialized was process-local; -1 means reuse the cached import for `fb_id`.
|
||||
desc.dma_buf_fd = received_fd;
|
||||
// SAFETY: self.ctx is a valid render context; `desc` is fully initialized; `frame` is zeroed
|
||||
// before the call. libdrmtap OWNS `frame.data`: no release/free from this side (drmtap.h).
|
||||
unsafe {
|
||||
let mut frame: drmtap_frame_info = std::mem::zeroed();
|
||||
let ret = convert_dmabuf(self.ctx, &*desc as *const drmtap_dmabuf_desc, &mut frame);
|
||||
if ret < 0 {
|
||||
let errno = -ret;
|
||||
if errno == hbb_common::libc::EAGAIN
|
||||
|| errno == hbb_common::libc::EBUSY
|
||||
|| errno == hbb_common::libc::EINTR
|
||||
{
|
||||
return Err(io::ErrorKind::WouldBlock.into());
|
||||
}
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
format!("drmtap_convert_dmabuf failed: errno {errno}"),
|
||||
));
|
||||
}
|
||||
if frame.data.is_null() || frame.width == 0 || frame.height == 0 || frame.stride == 0 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
"drmtap_convert_dmabuf produced an empty frame",
|
||||
));
|
||||
}
|
||||
let w = frame.width;
|
||||
let h = frame.height;
|
||||
let stride = frame.stride as usize;
|
||||
// A stride below 32bpp under-sizes the row and, read as BGRA downstream, discloses adjacent memory.
|
||||
if w > MAX_DIM || h > MAX_DIM || stride < (w as usize) * 4 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
format!(
|
||||
"drmtap_convert_dmabuf bad geometry {w}x{h} stride {stride} fourcc {:#010x}",
|
||||
frame.format
|
||||
),
|
||||
));
|
||||
}
|
||||
let len = match stride.checked_mul(h as usize) {
|
||||
Some(sz) if sz > 0 && sz <= MAX_FRAME_BYTES => sz,
|
||||
other => {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
format!("drmtap_convert_dmabuf frame size out of range ({other:?} bytes)"),
|
||||
));
|
||||
}
|
||||
};
|
||||
let pixfmt = match frame.format {
|
||||
DRM_FORMAT_XRGB8888 | DRM_FORMAT_ARGB8888 => Pixfmt::BGRA,
|
||||
DRM_FORMAT_XBGR8888 | DRM_FORMAT_ABGR8888 => Pixfmt::RGBA,
|
||||
// Unset by an older convert -> libdrmtap's normalized BGRA.
|
||||
0 => Pixfmt::BGRA,
|
||||
other => {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
format!("drmtap_convert_dmabuf produced an unsupported output fourcc {other:#010x}"),
|
||||
));
|
||||
}
|
||||
};
|
||||
let data = std::slice::from_raw_parts(frame.data as *const u8, len);
|
||||
Ok((data, w, h, pixfmt))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RenderConverter {
|
||||
fn drop(&mut self) {
|
||||
if !self.ctx.is_null() {
|
||||
// SAFETY: ctx came from drmtap_open_render and is non-null; the !Send ctx pointer keeps
|
||||
// this drop on the thread that created and used it (thread-local EGL + cached imports).
|
||||
unsafe { (self.lib.close)(self.ctx) };
|
||||
self.ctx = std::ptr::null_mut();
|
||||
}
|
||||
}
|
||||
}
|
||||
421
libs/scrap/src/common/drmtap_dl.rs
Normal file
421
libs/scrap/src/common/drmtap_dl.rs
Normal file
@@ -0,0 +1,421 @@
|
||||
// Runtime loader for libdrmtap.so (the DRM/KMS capture engine), dlopen'd so the binary carries no hard libdrm/libEGL/libGLESv2 dependency.
|
||||
|
||||
use hbb_common::{libloading::Library, log};
|
||||
use std::os::raw::{c_char, c_int, c_void};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
// C ABI structs: must match libdrmtap include/drmtap.h.
|
||||
|
||||
#[repr(C)]
|
||||
pub struct drmtap_ctx {
|
||||
_private: [u8; 0],
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
pub struct drmtap_config {
|
||||
pub device_path: *const c_char, // NULL = auto-detect /dev/dri/card*
|
||||
pub crtc_id: u32, // 0 = auto-select first active CRTC
|
||||
pub helper_path: *const c_char, // only consulted if the direct DRM export is denied (no CAP_SYS_ADMIN)
|
||||
pub debug: c_int,
|
||||
}
|
||||
|
||||
impl Default for drmtap_config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
device_path: std::ptr::null(),
|
||||
crtc_id: 0,
|
||||
helper_path: std::ptr::null(),
|
||||
debug: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct drmtap_display {
|
||||
pub crtc_id: u32,
|
||||
pub connector_id: u32,
|
||||
pub name: [c_char; 32],
|
||||
pub x: u32,
|
||||
pub y: u32,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub refresh_hz: u32,
|
||||
pub active: c_int,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct drmtap_device {
|
||||
pub path: [c_char; 64],
|
||||
pub render_node: [c_char; 64],
|
||||
pub driver: [c_char; 32],
|
||||
pub display_count: u32,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
pub struct drmtap_frame_info {
|
||||
pub data: *mut c_void,
|
||||
pub dma_buf_fd: c_int,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub stride: u32,
|
||||
pub format: u32,
|
||||
pub modifier: u64,
|
||||
pub fb_id: u32,
|
||||
pub _priv: *mut c_void,
|
||||
}
|
||||
|
||||
// Descriptor of an externally-supplied scanout DMA-BUF: the privileged exporter fills it via
|
||||
// `drmtap_grab_desc`; the converter overwrites `dma_buf_fd` with the fd it got via SCM_RIGHTS.
|
||||
// Mirrors `drmtap_dmabuf_desc` EXACTLY (field order + widths); a mismatch mis-reads CCS/HDR scanouts.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct drmtap_dmabuf_desc {
|
||||
pub dma_buf_fd: c_int, // scanout DMA-BUF; -1 for an already-imported fb_id
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub format: u32, // DRM fourcc of the scanout
|
||||
pub modifier: u64, // DRM format modifier (tiling/compression)
|
||||
pub fb_id: u32, // import-once cache key; 0 disables caching
|
||||
pub num_planes: u32, // used entries in offsets/pitches (1..4); 0 => 1
|
||||
pub offsets: [u32; 4], // per-plane byte offsets (CCS main+aux+clear-color)
|
||||
pub pitches: [u32; 4], // per-plane strides; pitches[0] = main stride
|
||||
pub hdr_eotf: u32, // DRMTAP_EOTF_* (SDR=0, PQ=2, HLG=3)
|
||||
pub hdr_max_nits: u32, // mastering/content peak luminance cd/m2; 0=unknown
|
||||
}
|
||||
|
||||
impl Default for drmtap_dmabuf_desc {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
dma_buf_fd: -1,
|
||||
width: 0,
|
||||
height: 0,
|
||||
format: 0,
|
||||
modifier: 0,
|
||||
fb_id: 0,
|
||||
num_planes: 0,
|
||||
offsets: [0; 4],
|
||||
pitches: [0; 4],
|
||||
hdr_eotf: 0,
|
||||
hdr_max_nits: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
pub struct drmtap_cursor_info {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub hot_x: i32,
|
||||
pub hot_y: i32,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub pixels: *mut u32,
|
||||
pub visible: c_int,
|
||||
pub _priv: *mut c_void,
|
||||
}
|
||||
|
||||
// Resolved symbol typedefs.
|
||||
|
||||
type FnVersion = unsafe extern "C" fn() -> c_int;
|
||||
type FnOpen = unsafe extern "C" fn(*const drmtap_config) -> *mut drmtap_ctx;
|
||||
type FnClose = unsafe extern "C" fn(*mut drmtap_ctx);
|
||||
type FnListDisplays = unsafe extern "C" fn(*mut drmtap_ctx, *mut drmtap_display, c_int) -> c_int;
|
||||
type FnListDevices = unsafe extern "C" fn(*mut drmtap_device, c_int) -> c_int;
|
||||
type FnGrabMapped = unsafe extern "C" fn(*mut drmtap_ctx, *mut drmtap_frame_info) -> c_int;
|
||||
type FnFrameRelease = unsafe extern "C" fn(*mut drmtap_ctx, *mut drmtap_frame_info);
|
||||
type FnGetCursor = unsafe extern "C" fn(*mut drmtap_ctx, *mut drmtap_cursor_info) -> c_int;
|
||||
type FnCursorRelease = unsafe extern "C" fn(*mut drmtap_ctx, *mut drmtap_cursor_info);
|
||||
// Split-capture entry points (libdrmtap >= 0.4.10), required: `grab_desc` runs on the privileged
|
||||
// export side, `open_render`/`convert_dmabuf` on the unprivileged converter side.
|
||||
type FnGrabDesc =
|
||||
unsafe extern "C" fn(*mut drmtap_ctx, *mut drmtap_dmabuf_desc, *mut drmtap_frame_info) -> c_int;
|
||||
type FnOpenRender = unsafe extern "C" fn(*const c_char) -> *mut drmtap_ctx;
|
||||
// libdrmtap >= 0.4.15; returns a ctx-owned string, or NULL if it has none.
|
||||
type FnRenderNode = unsafe extern "C" fn(*mut drmtap_ctx) -> *const c_char;
|
||||
type FnConvertDmabuf =
|
||||
unsafe extern "C" fn(*mut drmtap_ctx, *const drmtap_dmabuf_desc, *mut drmtap_frame_info) -> c_int;
|
||||
|
||||
/// The dlopen'd libdrmtap; the `Library` is kept alive for the process lifetime, so the raw fn pointers stay valid.
|
||||
pub struct DrmtapLib {
|
||||
_lib: Library,
|
||||
pub open: FnOpen,
|
||||
pub close: FnClose,
|
||||
pub list_displays: FnListDisplays,
|
||||
pub list_devices: Option<FnListDevices>,
|
||||
pub grab_mapped: FnGrabMapped,
|
||||
pub frame_release: FnFrameRelease,
|
||||
pub get_cursor: FnGetCursor,
|
||||
pub cursor_release: FnCursorRelease,
|
||||
pub grab_desc: FnGrabDesc,
|
||||
pub open_render: FnOpenRender,
|
||||
pub convert_dmabuf: FnConvertDmabuf,
|
||||
pub render_node: Option<FnRenderNode>,
|
||||
pub version: (c_int, c_int, c_int),
|
||||
}
|
||||
|
||||
// SAFETY: the resolved fn pointers are plain C entry points with no interior mutability;
|
||||
// libdrmtap contexts are used single-threaded by the caller. The Library handle is never moved out.
|
||||
unsafe impl Send for DrmtapLib {}
|
||||
unsafe impl Sync for DrmtapLib {}
|
||||
|
||||
const DRMTAP_ABI_MAJOR: c_int = 0;
|
||||
|
||||
// Lowest (minor, patch) accepted. 0.5.0 is the floor because it fixes the padded-framebuffer read
|
||||
// (a scanout whose pitch exceeds width*bpp was decoded at the wrong stride); the whole split API
|
||||
// has been present since 0.4.10.
|
||||
const DRMTAP_MIN_MINOR_PATCH: (c_int, c_int) = (5, 0);
|
||||
|
||||
// The MINOR series this build's mirrored structs were verified against: libdrmtap's header freezes
|
||||
// only `drmtap_device` and `drmtap_dmabuf_desc`, so an unverified minor could be read at wrong offsets.
|
||||
const DRMTAP_ABI_MINOR: c_int = 5;
|
||||
|
||||
/// Whether a library reporting `major.minor.patch` may be loaded (major and minor exact, patch at or above the floor).
|
||||
fn abi_accepted(major: c_int, minor: c_int, patch: c_int) -> bool {
|
||||
major == DRMTAP_ABI_MAJOR
|
||||
&& minor == DRMTAP_ABI_MINOR
|
||||
&& (minor, patch) >= DRMTAP_MIN_MINOR_PATCH
|
||||
}
|
||||
|
||||
impl DrmtapLib {
|
||||
fn load() -> Option<Self> {
|
||||
// Absolute path FIRST: the deb bundles the .so privately under /usr/lib/rustdesk and does NOT register that dir with ld.so.
|
||||
const INSTALLED: &str = "/usr/lib/rustdesk/libdrmtap.so.0";
|
||||
// Bare sonames exist so an unpackaged development build can load a locally built .so from
|
||||
// the normal ld.so search path. They are NOT offered when running as root: this is the one
|
||||
// place where which file happens to be on the load path decides what gets mapped into the
|
||||
// CAP_SYS_ADMIN process, and the packaged service always finds the absolute path first
|
||||
// anyway. A root process that reaches the fallback has no bundled library, which is the
|
||||
// PipeWire-fallback case, not a reason to search.
|
||||
const DEV_ONLY: [&str; 2] = ["libdrmtap.so.0", "libdrmtap.so"];
|
||||
let is_root = unsafe { hbb_common::libc::geteuid() } == 0;
|
||||
let candidates: Vec<&str> = if is_root {
|
||||
vec![INSTALLED]
|
||||
} else {
|
||||
std::iter::once(INSTALLED).chain(DEV_ONLY).collect()
|
||||
};
|
||||
unsafe {
|
||||
let mut errs = Vec::new();
|
||||
let found = candidates.iter().find_map(|n| match Library::new(*n) {
|
||||
Ok(l) => Some((l, *n)),
|
||||
Err(e) => {
|
||||
errs.push(format!("{n}: {e}"));
|
||||
None
|
||||
}
|
||||
});
|
||||
let Some((lib, name)) = found else {
|
||||
// The dlerror names the real cause (a missing soname, a glibc too old for the
|
||||
// bundled build); the caller only reports that DRM capture is off.
|
||||
log::warn!("libdrmtap dlopen failed: {}", errs.join("; "));
|
||||
return None;
|
||||
};
|
||||
// Canonicalize the absolute candidate only: `dlopen` does not search the CWD for a bare
|
||||
// soname, while `canonicalize` resolves a relative name against it.
|
||||
let real = std::path::Path::new(name)
|
||||
.is_absolute()
|
||||
.then(|| std::fs::canonicalize(name).ok())
|
||||
.flatten();
|
||||
let version: FnVersion = *lib.get(b"drmtap_version").ok()?;
|
||||
let v = version();
|
||||
let (major, minor, patch) = ((v >> 16) & 0xff, (v >> 8) & 0xff, v & 0xff);
|
||||
if !abi_accepted(major, minor, patch) {
|
||||
let why = if major != DRMTAP_ABI_MAJOR {
|
||||
"the struct layouts this build mirrors track the ABI major, so reading a \
|
||||
frame descriptor through a mismatched one would mis-decode it"
|
||||
} else if minor != DRMTAP_ABI_MINOR {
|
||||
"this build mirrors the struct layouts of one minor and only that one; \
|
||||
under 0.x semver the minor is the breaking axis, so an unverified minor \
|
||||
could be read at the wrong offsets. Widening it is a deliberate act, done \
|
||||
with the layouts re-checked field by field"
|
||||
} else {
|
||||
"it predates the split-capture API, so its only capture path converts \
|
||||
in-process, which in the root service means loading the GL stack there"
|
||||
};
|
||||
let (min_minor, min_patch) = DRMTAP_MIN_MINOR_PATCH;
|
||||
log::warn!(
|
||||
"libdrmtap {name} reports v{major}.{minor}.{patch}, which this build cannot \
|
||||
use (needs ABI major {DRMTAP_ABI_MAJOR}, minor {DRMTAP_ABI_MINOR}, at least \
|
||||
v{DRMTAP_ABI_MAJOR}.{min_minor}.{min_patch}): {why}. Refusing to load; \
|
||||
falling back to PipeWire/portal."
|
||||
);
|
||||
return None;
|
||||
}
|
||||
let open: FnOpen = *lib.get(b"drmtap_open").ok()?;
|
||||
let close: FnClose = *lib.get(b"drmtap_close").ok()?;
|
||||
let list_displays: FnListDisplays = *lib.get(b"drmtap_list_displays").ok()?;
|
||||
let list_devices: Option<FnListDevices> =
|
||||
lib.get(b"drmtap_list_devices").ok().map(|s| *s);
|
||||
let grab_mapped: FnGrabMapped = *lib.get(b"drmtap_grab_mapped").ok()?;
|
||||
let frame_release: FnFrameRelease = *lib.get(b"drmtap_frame_release").ok()?;
|
||||
let get_cursor: FnGetCursor = *lib.get(b"drmtap_get_cursor").ok()?;
|
||||
let cursor_release: FnCursorRelease = *lib.get(b"drmtap_cursor_release").ok()?;
|
||||
let grab: Option<FnGrabDesc> = lib.get(b"drmtap_grab_desc").ok().map(|s| *s);
|
||||
let open_r: Option<FnOpenRender> = lib.get(b"drmtap_open_render").ok().map(|s| *s);
|
||||
let conv: Option<FnConvertDmabuf> =
|
||||
lib.get(b"drmtap_convert_dmabuf").ok().map(|s| *s);
|
||||
let (grab_desc, open_render, convert_dmabuf) = match (grab, open_r, conv) {
|
||||
(Some(g), Some(o), Some(c)) => (g, o, c),
|
||||
(grab, open_r, conv) => {
|
||||
let mut missing = Vec::new();
|
||||
if grab.is_none() {
|
||||
missing.push("drmtap_grab_desc");
|
||||
}
|
||||
if open_r.is_none() {
|
||||
missing.push("drmtap_open_render");
|
||||
}
|
||||
if conv.is_none() {
|
||||
missing.push("drmtap_convert_dmabuf");
|
||||
}
|
||||
log::warn!(
|
||||
"libdrmtap {name} reports v{major}.{minor}.{patch} but does not export \
|
||||
{}: it is a stale or pre-release build, not the version it claims. \
|
||||
Refusing to load; falling back to PipeWire/portal.",
|
||||
missing.join(", ")
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let render_node: Option<FnRenderNode> =
|
||||
lib.get(b"drmtap_render_node").ok().map(|s| *s);
|
||||
// Log the load only now that every required symbol resolved: this fn still returns None on a missing one.
|
||||
let loaded_from = real
|
||||
.as_ref()
|
||||
.map_or_else(|| name.to_owned(), |p| p.display().to_string());
|
||||
if loaded_from == name {
|
||||
log::info!("libdrmtap loaded: {name} (v{major}.{minor}.{patch})");
|
||||
} else {
|
||||
log::info!("libdrmtap loaded: {name} -> {loaded_from} (v{major}.{minor}.{patch})");
|
||||
}
|
||||
let (no_node, no_devices) = (render_node.is_none(), list_devices.is_none());
|
||||
if (minor, patch) >= (4, 15) && (no_node || no_devices) {
|
||||
let missing = if no_node && no_devices {
|
||||
"drmtap_render_node and drmtap_list_devices"
|
||||
} else if no_node {
|
||||
"drmtap_render_node"
|
||||
} else {
|
||||
"drmtap_list_devices"
|
||||
};
|
||||
let effect = if no_node && no_devices {
|
||||
"Multi-GPU display enumeration and exporting-GPU selection stay disabled."
|
||||
} else if no_node {
|
||||
"Exporting-GPU selection stays disabled."
|
||||
} else {
|
||||
"Multi-GPU display enumeration stays disabled."
|
||||
};
|
||||
log::warn!(
|
||||
"libdrmtap at {loaded_from} reports v{major}.{minor}.{patch} but is missing \
|
||||
{missing}: it is a stale or pre-release build. Check what the soname symlink \
|
||||
points at and remove any leftover libdrmtap.so.0* beside it. {effect}"
|
||||
);
|
||||
}
|
||||
Some(DrmtapLib {
|
||||
_lib: lib,
|
||||
open,
|
||||
close,
|
||||
list_displays,
|
||||
list_devices,
|
||||
grab_mapped,
|
||||
frame_release,
|
||||
get_cursor,
|
||||
cursor_release,
|
||||
grab_desc,
|
||||
open_render,
|
||||
convert_dmabuf,
|
||||
render_node,
|
||||
version: (major, minor, patch),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static DRMTAP_LIB: OnceLock<Option<DrmtapLib>> = OnceLock::new();
|
||||
|
||||
/// The loaded libdrmtap, or None if the .so (or a runtime dep) is absent or its version/exports fall outside the ABI gate. Loaded once; a failure is remembered.
|
||||
pub fn get() -> Option<&'static DrmtapLib> {
|
||||
DRMTAP_LIB
|
||||
.get_or_init(|| {
|
||||
let lib = DrmtapLib::load();
|
||||
if lib.is_none() {
|
||||
log::info!("libdrmtap not available or not usable; DRM capture disabled");
|
||||
}
|
||||
lib
|
||||
})
|
||||
.as_ref()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{abi_accepted, DRMTAP_ABI_MAJOR, DRMTAP_ABI_MINOR, DRMTAP_MIN_MINOR_PATCH};
|
||||
|
||||
#[test]
|
||||
fn abi_gate_rejects_a_library_from_before_the_split() {
|
||||
// These are refused because their MINOR differs from the verified one, which is the only
|
||||
// reason the gate needs. Naming the pre-split releases keeps the intent readable, but do
|
||||
// not read this as the floor doing the work: see the test below.
|
||||
for (minor, patch) in [(3, 3), (4, 0), (4, 8), (4, 9)] {
|
||||
assert!(
|
||||
!abi_accepted(DRMTAP_ABI_MAJOR, minor, patch),
|
||||
"v0.{minor}.{patch} is not the verified minor and must be refused"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_patch_floor_is_currently_vacuous_and_that_is_deliberate() {
|
||||
// With MIN_MINOR_PATCH.0 == DRMTAP_ABI_MINOR the floor can never reject anything: the
|
||||
// minor equality already forces `(minor, patch) >= (minor, 0)`. It is kept because it is
|
||||
// the mechanism that WOULD do the work the next time a floor lands mid-minor, as (4, 10)
|
||||
// did for the split API. This test exists so nobody reads the pre-split test above as
|
||||
// evidence that the floor is live -- if that ever matters, this assert is the tripwire.
|
||||
let (floor_minor, floor_patch) = DRMTAP_MIN_MINOR_PATCH;
|
||||
assert_eq!(
|
||||
floor_minor, DRMTAP_ABI_MINOR,
|
||||
"the floor is inside the verified minor; a floor in a DIFFERENT minor is unreachable"
|
||||
);
|
||||
if floor_patch == 0 {
|
||||
assert!(
|
||||
abi_accepted(DRMTAP_ABI_MAJOR, DRMTAP_ABI_MINOR, 0),
|
||||
"patch 0 of the verified minor must be accepted while the floor is 0"
|
||||
);
|
||||
} else {
|
||||
assert!(!abi_accepted(DRMTAP_ABI_MAJOR, DRMTAP_ABI_MINOR, floor_patch - 1));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abi_gate_accepts_the_floor_and_later_patches_of_the_same_minor() {
|
||||
let (min_minor, min_patch) = DRMTAP_MIN_MINOR_PATCH;
|
||||
assert!(abi_accepted(DRMTAP_ABI_MAJOR, min_minor, min_patch));
|
||||
for (minor, patch) in [(DRMTAP_ABI_MINOR, min_patch + 15), (DRMTAP_ABI_MINOR, 200)] {
|
||||
assert!(
|
||||
abi_accepted(DRMTAP_ABI_MAJOR, minor, patch),
|
||||
"v0.{minor}.{patch} is a patch of the verified minor and must be accepted"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abi_gate_rejects_an_unknown_newer_minor() {
|
||||
// Relative to DRMTAP_ABI_MINOR, so the next bump cannot leave this test asserting that the
|
||||
// NEW verified minor must be refused -- which is what a hardcoded list did before.
|
||||
let verified = DRMTAP_ABI_MINOR;
|
||||
for (minor, patch) in [
|
||||
(verified - 1, 99),
|
||||
(verified + 1, 0),
|
||||
(verified + 1, 99),
|
||||
(verified + 4, 9),
|
||||
] {
|
||||
assert!(
|
||||
!abi_accepted(DRMTAP_ABI_MAJOR, minor, patch),
|
||||
"v0.{minor}.{patch} is an unverified minor and must be refused"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abi_gate_rejects_another_major_in_both_directions() {
|
||||
assert!(!abi_accepted(DRMTAP_ABI_MAJOR + 1, 0, 0));
|
||||
assert!(!abi_accepted(DRMTAP_ABI_MAJOR + 1, 99, 99));
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,12 @@ cfg_if! {
|
||||
mod linux;
|
||||
mod wayland;
|
||||
mod x11;
|
||||
#[cfg(all(target_os = "linux", feature = "drm"))]
|
||||
pub mod drmtap_dl;
|
||||
#[cfg(all(target_os = "linux", feature = "drm"))]
|
||||
pub mod drm_reader;
|
||||
#[cfg(all(target_os = "linux", feature = "drm"))]
|
||||
pub mod drm_render;
|
||||
pub use self::linux::*;
|
||||
pub use self::wayland::set_map_err;
|
||||
pub use self::x11::PixelBuffer;
|
||||
|
||||
@@ -20,6 +20,22 @@ use webm::mux::{self, Segment, Track, VideoTrack, Writer};
|
||||
|
||||
const MIN_SECS: u64 = 1;
|
||||
|
||||
// Replace characters that are invalid in Windows filename components so recordings remain portable.
|
||||
// Control characters are also replaced because they can make filenames invalid
|
||||
// on Windows or invisible and difficult to handle on Linux and macOS.
|
||||
fn sanitize_filename_component(value: &str) -> String {
|
||||
value
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_control() || matches!(c, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*') {
|
||||
'_'
|
||||
} else {
|
||||
c
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RecorderContext {
|
||||
pub server: bool,
|
||||
@@ -45,7 +61,7 @@ impl RecorderContext2 {
|
||||
}
|
||||
let file = if ctx.server { "incoming" } else { "outgoing" }.to_string()
|
||||
+ "_"
|
||||
+ &ctx.id.clone()
|
||||
+ &sanitize_filename_component(&ctx.id)
|
||||
+ &chrono::Local::now().format("_%Y%m%d%H%M%S%3f_").to_string()
|
||||
+ &format!(
|
||||
"{}{}_",
|
||||
@@ -421,3 +437,24 @@ impl Drop for HwRecorder {
|
||||
self.ctx.tx.as_ref().map(|tx| tx.send(state));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::sanitize_filename_component;
|
||||
|
||||
#[test]
|
||||
fn sanitize_recording_filename_component() {
|
||||
assert_eq!(
|
||||
sanitize_filename_component("192.168.1.2:21118"),
|
||||
"192.168.1.2_21118"
|
||||
);
|
||||
assert_eq!(
|
||||
sanitize_filename_component("[2001:db8::1]:21118"),
|
||||
"[2001_db8__1]_21118"
|
||||
);
|
||||
assert_eq!(
|
||||
sanitize_filename_component("peer/name\\with?bad\nchars"),
|
||||
"peer_name_with_bad_chars"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@ lazy_static! {
|
||||
static ref DISPLAYS: Mutex<Option<Arc<Displays>>> = Mutex::new(None);
|
||||
}
|
||||
|
||||
static MISSING_LOGICAL_SIZE_WARNED: std::sync::atomic::AtomicBool =
|
||||
std::sync::atomic::AtomicBool::new(false);
|
||||
|
||||
const COMMAND_TIMEOUT: Duration = Duration::from_millis(1000);
|
||||
|
||||
pub struct Displays {
|
||||
@@ -217,7 +220,26 @@ pub fn clear_wayland_displays_cache() {
|
||||
// Return (min_x, max_x, min_y, max_y)
|
||||
pub fn get_desktop_rect_for_uinput() -> Option<(i32, i32, i32, i32)> {
|
||||
let wayland_displays = get_displays();
|
||||
let displays = &wayland_displays.displays;
|
||||
desktop_rect_of(&wayland_displays.displays)
|
||||
}
|
||||
|
||||
// The desktop rect and per-display logical rects, always read live from the
|
||||
// compositor in a single roundtrip. Skips the displays cache and the primary-monitor
|
||||
// detection (which may spawn external commands), so it is cheap enough to poll for
|
||||
// layout changes. https://github.com/rustdesk/rustdesk/issues/15601
|
||||
pub fn get_layout_for_uinput_live() -> Option<((i32, i32, i32, i32), Vec<DisplayRect>)> {
|
||||
match get_wayland_displays() {
|
||||
Ok(displays) => {
|
||||
desktop_rect_of(&displays).map(|rect| (rect, logical_rects_of(&displays)))
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("Failed to get wayland displays: {}", err);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn desktop_rect_of(displays: &[WaylandDisplayInfo]) -> Option<(i32, i32, i32, i32)> {
|
||||
if displays.is_empty() {
|
||||
return None;
|
||||
}
|
||||
@@ -243,10 +265,13 @@ pub fn get_desktop_rect_for_uinput() -> Option<(i32, i32, i32, i32)> {
|
||||
// This may occur if the Wayland compositor does not provide logical size information,
|
||||
// or if display information is incomplete. We fall back to physical size, which provides
|
||||
// usable dimensions, but may not always be correct depending on compositor behavior.
|
||||
warn!(
|
||||
// Warn only once, the live path polls this while a session is active.
|
||||
if !MISSING_LOGICAL_SIZE_WARNED.swap(true, std::sync::atomic::Ordering::Relaxed) {
|
||||
warn!(
|
||||
"Display at ({}, {}) is missing logical_size; falling back to physical size ({}, {}).",
|
||||
d.x, d.y, d.width, d.height
|
||||
);
|
||||
}
|
||||
(d.width, d.height)
|
||||
};
|
||||
max_x = max_x.max(d.x + size.0);
|
||||
@@ -254,3 +279,289 @@ pub fn get_desktop_rect_for_uinput() -> Option<(i32, i32, i32, i32)> {
|
||||
}
|
||||
Some((min_x, max_x, min_y, max_y))
|
||||
}
|
||||
|
||||
/// One display's logical rectangle in the desktop coordinate space the client uses:
|
||||
/// logical origin plus logical size, falling back to physical size when the compositor
|
||||
/// reports no logical size (matching `desktop_rect_of`).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DisplayRect {
|
||||
pub name: String,
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub w: i32,
|
||||
pub h: i32,
|
||||
}
|
||||
|
||||
fn logical_rects_of(displays: &[WaylandDisplayInfo]) -> Vec<DisplayRect> {
|
||||
// Match `desktop_rect_of`: a single display uses its physical size (its scale is
|
||||
// reported as 1.0 to the client), multiple displays use logical size. This keeps a
|
||||
// single display a no-op for the remap (its origin never shifts) and keeps the rects
|
||||
// in the same coordinate space the client's coordinates are expressed in.
|
||||
let single = displays.len() == 1;
|
||||
displays
|
||||
.iter()
|
||||
.map(|d| {
|
||||
let (w, h) = if single {
|
||||
(d.width, d.height)
|
||||
} else {
|
||||
d.logical_size.unwrap_or((d.width, d.height))
|
||||
};
|
||||
DisplayRect {
|
||||
name: d.name.clone(),
|
||||
x: d.x,
|
||||
y: d.y,
|
||||
w,
|
||||
h,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// Per-display logical rects from the cached init snapshot. The client's injected
|
||||
// coordinates are `local + origin` in this layout, so it is the baseline to map from.
|
||||
pub fn get_display_rects_for_uinput() -> Vec<DisplayRect> {
|
||||
logical_rects_of(&get_displays().displays)
|
||||
}
|
||||
|
||||
/// Remap an injected coordinate from the layout the client still believes in
|
||||
/// (`baseline`, captured at session init) to the current compositor layout (`live`).
|
||||
///
|
||||
/// A single-display client sends whole-desktop coordinates: `local + baseline_origin[d]`
|
||||
/// for whichever display `d` it is following. If that display's origin or logical size
|
||||
/// has since changed (e.g. another monitor was rescaled, shifting this one), the
|
||||
/// coordinate lands offset. We find the baseline display the point falls in, then map
|
||||
/// the point into the same display's live rectangle, matched by connector name (or, when
|
||||
/// the compositor reports no names, by index while the display count is unchanged).
|
||||
///
|
||||
/// Returns the input unchanged when the point is outside every baseline display or the
|
||||
/// matched display is gone, so a failed match never moves the cursor further off than
|
||||
/// leaving it alone. https://github.com/rustdesk/rustdesk/issues/15601
|
||||
pub fn remap_to_live_layout(
|
||||
x: i32,
|
||||
y: i32,
|
||||
baseline: &[DisplayRect],
|
||||
live: &[DisplayRect],
|
||||
) -> (i32, i32) {
|
||||
let Some((bi, b)) = baseline
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(_, r)| x >= r.x && x < r.x + r.w && y >= r.y && y < r.y + r.h)
|
||||
else {
|
||||
return (x, y);
|
||||
};
|
||||
let matched = if b.name.is_empty() {
|
||||
// Nameless compositor: index-match, but only while the count is unchanged. A
|
||||
// named display that is simply gone from the live layout must fall through to
|
||||
// "unchanged" below, not get index-matched to whatever now sits at its index.
|
||||
if baseline.len() == live.len() {
|
||||
live.get(bi)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
live.iter().find(|r| r.name == b.name)
|
||||
};
|
||||
let Some(l) = matched else {
|
||||
return (x, y);
|
||||
};
|
||||
// Map the point into the live rectangle, preserving position within the display so a
|
||||
// scale change on the followed display itself is corrected too, not only a shift.
|
||||
// Scale by (extent - 1) so both endpoints land exactly: the client clamps its
|
||||
// coordinate to `[origin, origin + w - 1]`, and mapping that span to the live span's
|
||||
// `[0, w' - 1]` keeps the far edge reachable (hot corners) in both directions, and
|
||||
// stays an exact shift when the size is unchanged.
|
||||
let nx = map_axis(x, b.x, b.w, l.x, l.w);
|
||||
let ny = map_axis(y, b.y, b.h, l.y, l.h);
|
||||
(nx, ny)
|
||||
}
|
||||
|
||||
fn map_axis(v: i32, base_origin: i32, base_extent: i32, live_origin: i32, live_extent: i32) -> i32 {
|
||||
if base_extent <= 1 || live_extent <= 1 {
|
||||
return live_origin;
|
||||
}
|
||||
live_origin + ((v - base_origin) as i64 * (live_extent - 1) as i64 / (base_extent - 1) as i64) as i32
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn display(
|
||||
x: i32,
|
||||
y: i32,
|
||||
width: i32,
|
||||
height: i32,
|
||||
logical_size: Option<(i32, i32)>,
|
||||
) -> WaylandDisplayInfo {
|
||||
WaylandDisplayInfo {
|
||||
name: "".to_owned(),
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
logical_size,
|
||||
refresh_rate: 60,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_desktop_rect_empty() {
|
||||
assert_eq!(desktop_rect_of(&[]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_desktop_rect_single_display_uses_physical_size() {
|
||||
let displays = [display(0, 0, 2880, 1800, Some((1859, 1162)))];
|
||||
assert_eq!(desktop_rect_of(&displays), Some((0, 2880, 0, 1800)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_desktop_rect_multi_display_uses_logical_size() {
|
||||
// Laptop panel at 155% below two stacked externals at 100%.
|
||||
let displays = [
|
||||
display(0, 718, 2880, 1800, Some((1859, 1162))),
|
||||
display(1859, 0, 1920, 1080, Some((1920, 1080))),
|
||||
display(1859, 1080, 1920, 1080, Some((1920, 1080))),
|
||||
];
|
||||
assert_eq!(desktop_rect_of(&displays), Some((0, 3779, 0, 2160)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_desktop_rect_missing_logical_size_falls_back_to_physical() {
|
||||
let displays = [
|
||||
display(0, 0, 2560, 1440, None),
|
||||
display(2560, 0, 2560, 1440, Some((2560, 1440))),
|
||||
];
|
||||
assert_eq!(desktop_rect_of(&displays), Some((0, 5120, 0, 1440)));
|
||||
}
|
||||
|
||||
fn rect(name: &str, x: i32, y: i32, w: i32, h: i32) -> DisplayRect {
|
||||
DisplayRect {
|
||||
name: name.to_owned(),
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
}
|
||||
}
|
||||
|
||||
// The reported failure: connect to the second display, rescale the primary.
|
||||
// Baseline: two 2560-wide displays side by side, both at 100%.
|
||||
// Live: the primary (DP-1) rescaled to 125% -> 2048 logical wide, so the second
|
||||
// display (DP-2) shifts left from x=2560 to x=2048. A client following DP-2 keeps
|
||||
// sending coordinates offset by DP-2's old origin (2560).
|
||||
#[test]
|
||||
fn test_remap_primary_rescale_shifts_second_display() {
|
||||
let baseline = [
|
||||
rect("DP-1", 0, 0, 2560, 1440),
|
||||
rect("DP-2", 2560, 0, 2560, 1440),
|
||||
];
|
||||
let live = [
|
||||
rect("DP-1", 0, 0, 2048, 1440),
|
||||
rect("DP-2", 2048, 0, 2560, 1440),
|
||||
];
|
||||
// Top-left of DP-2: client sends (2560, 0), should land at live DP-2 origin.
|
||||
assert_eq!(remap_to_live_layout(2560, 0, &baseline, &live), (2048, 0));
|
||||
// Middle of DP-2 keeps its fractional position.
|
||||
assert_eq!(
|
||||
remap_to_live_layout(3840, 720, &baseline, &live),
|
||||
(3328, 720)
|
||||
);
|
||||
}
|
||||
|
||||
// A point on the rescaled display itself is squeezed to its new logical width.
|
||||
#[test]
|
||||
fn test_remap_scales_within_resized_display() {
|
||||
let baseline = [rect("DP-1", 0, 0, 2560, 1440), rect("DP-2", 2560, 0, 2560, 1440)];
|
||||
let live = [rect("DP-1", 0, 0, 2048, 1440), rect("DP-2", 2048, 0, 2560, 1440)];
|
||||
// x=1280 across the 2560-wide baseline DP-1 -> proportionally across the 2048-wide
|
||||
// live DP-1 (endpoint-preserving scale, so ~1px off the naive midpoint).
|
||||
assert_eq!(remap_to_live_layout(1280, 500, &baseline, &live), (1023, 500));
|
||||
}
|
||||
|
||||
// The far edge of the followed display stays reachable when it is enlarged, so hot
|
||||
// corners keep working. Baseline DP-1 is 2048 wide, live DP-1 is 2560 wide; the
|
||||
// client's last column (2047) must map to the live last column (2559), not 2558.
|
||||
#[test]
|
||||
fn test_remap_enlarged_display_reaches_far_edge() {
|
||||
let baseline = [rect("DP-1", 0, 0, 2048, 1440), rect("DP-2", 2048, 0, 1920, 1080)];
|
||||
let live = [rect("DP-1", 0, 0, 2560, 1440), rect("DP-2", 2560, 0, 1920, 1080)];
|
||||
assert_eq!(remap_to_live_layout(2047, 0, &baseline, &live), (2559, 0));
|
||||
assert_eq!(remap_to_live_layout(0, 0, &baseline, &live), (0, 0));
|
||||
}
|
||||
|
||||
// No drift: identical layouts map every point to itself.
|
||||
#[test]
|
||||
fn test_remap_identity_when_unchanged() {
|
||||
let layout = [rect("DP-1", 0, 0, 2560, 1440), rect("DP-2", 2560, 0, 2560, 1440)];
|
||||
assert_eq!(remap_to_live_layout(3000, 700, &layout, &layout), (3000, 700));
|
||||
}
|
||||
|
||||
// Point outside every baseline display is left untouched.
|
||||
#[test]
|
||||
fn test_remap_point_outside_all_displays_unchanged() {
|
||||
let baseline = [rect("DP-1", 0, 0, 2560, 1440)];
|
||||
let live = [rect("DP-1", 0, 0, 2048, 1440)];
|
||||
assert_eq!(remap_to_live_layout(9000, 9000, &baseline, &live), (9000, 9000));
|
||||
}
|
||||
|
||||
// Matched display gone from the live layout (e.g. unplugged): leave the point be
|
||||
// rather than mapping it somewhere wrong.
|
||||
#[test]
|
||||
fn test_remap_display_removed_unchanged() {
|
||||
let baseline = [rect("DP-1", 0, 0, 2560, 1440), rect("DP-2", 2560, 0, 2560, 1440)];
|
||||
let live = [rect("DP-1", 0, 0, 2560, 1440)];
|
||||
assert_eq!(remap_to_live_layout(2600, 100, &baseline, &live), (2600, 100));
|
||||
}
|
||||
|
||||
// Nameless compositor: fall back to index matching while the count is unchanged.
|
||||
#[test]
|
||||
fn test_remap_nameless_index_fallback() {
|
||||
let baseline = [rect("", 0, 0, 2560, 1440), rect("", 2560, 0, 2560, 1440)];
|
||||
let live = [rect("", 0, 0, 2048, 1440), rect("", 2048, 0, 2560, 1440)];
|
||||
assert_eq!(remap_to_live_layout(2560, 0, &baseline, &live), (2048, 0));
|
||||
}
|
||||
|
||||
// Nameless compositor with a changed count: cannot index-match safely, so no-op.
|
||||
#[test]
|
||||
fn test_remap_nameless_count_changed_unchanged() {
|
||||
let baseline = [rect("", 0, 0, 2560, 1440), rect("", 2560, 0, 2560, 1440)];
|
||||
let live = [rect("", 0, 0, 2048, 1440)];
|
||||
assert_eq!(remap_to_live_layout(2560, 0, &baseline, &live), (2560, 0));
|
||||
}
|
||||
|
||||
// A named display absent from the live layout, but the count is unchanged (e.g. a
|
||||
// monitor was swapped for a different one at the same index): the index fallback is
|
||||
// for nameless layouts only, so a named miss stays unchanged rather than mapping to
|
||||
// whatever now occupies that index.
|
||||
#[test]
|
||||
fn test_remap_named_miss_equal_count_unchanged() {
|
||||
let baseline = [rect("DP-1", 0, 0, 2560, 1440), rect("DP-2", 2560, 0, 2560, 1440)];
|
||||
let live = [rect("DP-1", 0, 0, 2048, 1440), rect("HDMI-1", 2048, 0, 1920, 1080)];
|
||||
assert_eq!(remap_to_live_layout(2600, 100, &baseline, &live), (2600, 100));
|
||||
}
|
||||
|
||||
// A single display uses physical size in both baseline and live (scale reported as
|
||||
// 1.0), so it never drifts and the remap is a no-op even across a rescale.
|
||||
#[test]
|
||||
fn test_logical_rects_single_display_uses_physical() {
|
||||
let displays = [display(0, 0, 2560, 1440, Some((2048, 1152)))];
|
||||
assert_eq!(
|
||||
logical_rects_of(&displays),
|
||||
vec![rect("", 0, 0, 2560, 1440)]
|
||||
);
|
||||
}
|
||||
|
||||
// Multiple displays use logical size, falling back to physical when absent.
|
||||
#[test]
|
||||
fn test_logical_rects_multi_display_uses_logical() {
|
||||
let displays = [
|
||||
display(0, 0, 2560, 1440, Some((2048, 1152))),
|
||||
display(2048, 0, 1920, 1080, None),
|
||||
];
|
||||
assert_eq!(
|
||||
logical_rects_of(&displays),
|
||||
vec![rect("", 0, 0, 2048, 1152), rect("", 2048, 0, 1920, 1080)]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -507,6 +507,22 @@ where
|
||||
})
|
||||
}
|
||||
|
||||
// The request object path a portal method call will use, derived from our unique
|
||||
// bus name and the `handle_token` we pass in the call arguments. Knowing it up
|
||||
// front lets us subscribe to the `Response` signal *before* making the call.
|
||||
// https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Request.html
|
||||
fn get_request_path(
|
||||
conn: &SyncConnection,
|
||||
handle_token: &str,
|
||||
) -> Result<dbus::Path<'static>, dbus::Error> {
|
||||
let sender = conn.unique_name().trim_start_matches(':').replace('.', "_");
|
||||
dbus::Path::new(format!(
|
||||
"/org/freedesktop/portal/desktop/request/{}/{}",
|
||||
sender, handle_token
|
||||
))
|
||||
.map_err(|_| dbus::Error::new_failed("Failed to construct portal request path"))
|
||||
}
|
||||
|
||||
pub fn get_portal(conn: &SyncConnection) -> Proxy<&SyncConnection> {
|
||||
conn.with_proxy(
|
||||
"org.freedesktop.portal.Desktop",
|
||||
@@ -632,13 +648,14 @@ pub fn request_remote_desktop(
|
||||
let failure_res = failure.clone();
|
||||
let session: Arc<Mutex<Option<dbus::Path>>> = Arc::new(Mutex::new(None));
|
||||
let session_res = session.clone();
|
||||
let create_session_handle_token = "u1";
|
||||
args.insert(
|
||||
"session_handle_token".to_string(),
|
||||
Variant(Box::new("u1".to_string())),
|
||||
Variant(Box::new(create_session_handle_token.to_string())),
|
||||
);
|
||||
args.insert(
|
||||
"handle_token".to_string(),
|
||||
Variant(Box::new("u1".to_string())),
|
||||
Variant(Box::new(create_session_handle_token.to_string())),
|
||||
);
|
||||
|
||||
let mut is_support_restore_token = false;
|
||||
@@ -654,15 +671,9 @@ pub fn request_remote_desktop(
|
||||
// between the caller subscribing to the signal after receiving the reply for the method call and the signal getting emitted,
|
||||
// a convention for Request object paths has been established that allows
|
||||
// the caller to subscribe to the signal before making the method call.
|
||||
let path;
|
||||
if is_server_running() {
|
||||
path = screencast_portal::create_session(&portal, args)?;
|
||||
} else {
|
||||
path = remote_desktop_portal::create_session(&portal, args)?;
|
||||
}
|
||||
handle_response(
|
||||
&conn,
|
||||
path,
|
||||
get_request_path(&conn, create_session_handle_token)?,
|
||||
on_create_session_response(
|
||||
fd.clone(),
|
||||
streams.clone(),
|
||||
@@ -673,6 +684,11 @@ pub fn request_remote_desktop(
|
||||
),
|
||||
failure_res.clone(),
|
||||
)?;
|
||||
if is_server_running() {
|
||||
let _ = screencast_portal::create_session(&portal, args)?;
|
||||
} else {
|
||||
let _ = remote_desktop_portal::create_session(&portal, args)?;
|
||||
}
|
||||
|
||||
// wait 3 minutes for user interaction
|
||||
for _ in 0..1800 {
|
||||
@@ -751,9 +767,10 @@ fn on_create_session_response(
|
||||
// persist_mode may be configured by the user.
|
||||
args.insert("persist_mode".to_string(), Variant(Box::new(2u32)));
|
||||
}
|
||||
let select_sources_handle_token = "u3";
|
||||
args.insert(
|
||||
"handle_token".to_string(),
|
||||
Variant(Box::new("u3".to_string())),
|
||||
Variant(Box::new(select_sources_handle_token.to_string())),
|
||||
);
|
||||
// https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.ScreenCast.html
|
||||
if is_server_running() {
|
||||
@@ -769,42 +786,43 @@ fn on_create_session_response(
|
||||
});
|
||||
}
|
||||
|
||||
let path = portal.select_sources(ses.clone(), args)?;
|
||||
handle_response(
|
||||
c,
|
||||
path,
|
||||
get_request_path(c, select_sources_handle_token)?,
|
||||
on_select_sources_response(
|
||||
fd.clone(),
|
||||
streams.clone(),
|
||||
failure.clone(),
|
||||
ses,
|
||||
ses.clone(),
|
||||
is_support_restore_token,
|
||||
),
|
||||
failure.clone(),
|
||||
)?;
|
||||
let _ = portal.select_sources(ses.clone(), args)?;
|
||||
} else {
|
||||
// TODO: support persist_mode for remote_desktop_portal
|
||||
// https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.RemoteDesktop.html
|
||||
|
||||
let select_devices_handle_token = "u2";
|
||||
args.insert(
|
||||
"handle_token".to_string(),
|
||||
Variant(Box::new("u2".to_string())),
|
||||
Variant(Box::new(select_devices_handle_token.to_string())),
|
||||
);
|
||||
args.insert("types".to_string(), Variant(Box::new(7u32)));
|
||||
|
||||
let path = portal.select_devices(ses.clone(), args)?;
|
||||
handle_response(
|
||||
c,
|
||||
path,
|
||||
get_request_path(c, select_devices_handle_token)?,
|
||||
on_select_devices_response(
|
||||
fd.clone(),
|
||||
streams.clone(),
|
||||
failure.clone(),
|
||||
ses,
|
||||
ses.clone(),
|
||||
is_support_restore_token,
|
||||
),
|
||||
failure.clone(),
|
||||
)?;
|
||||
let _ = portal.select_devices(ses.clone(), args)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -825,9 +843,10 @@ fn on_select_devices_response(
|
||||
move |_: OrgFreedesktopPortalRequestResponse, c, _| {
|
||||
let portal = get_portal(c);
|
||||
let mut args: PropMap = HashMap::new();
|
||||
let select_sources_handle_token = "u3";
|
||||
args.insert(
|
||||
"handle_token".to_string(),
|
||||
Variant(Box::new("u3".to_string())),
|
||||
Variant(Box::new(select_sources_handle_token.to_string())),
|
||||
);
|
||||
// https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.ScreenCast.html
|
||||
if is_server_running() {
|
||||
@@ -836,19 +855,19 @@ fn on_select_devices_response(
|
||||
args.insert("types".into(), Variant(Box::new(1u32))); //| 2u32)));
|
||||
|
||||
let session = session.clone();
|
||||
let path = portal.select_sources(session.clone(), args)?;
|
||||
handle_response(
|
||||
c,
|
||||
path,
|
||||
get_request_path(c, select_sources_handle_token)?,
|
||||
on_select_sources_response(
|
||||
fd.clone(),
|
||||
streams.clone(),
|
||||
failure.clone(),
|
||||
session,
|
||||
session.clone(),
|
||||
is_support_restore_token,
|
||||
),
|
||||
failure.clone(),
|
||||
)?;
|
||||
let _ = portal.select_sources(session.clone(), args)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -868,19 +887,14 @@ fn on_select_sources_response(
|
||||
move |_: OrgFreedesktopPortalRequestResponse, c, _| {
|
||||
let portal = get_portal(c);
|
||||
let mut args: PropMap = HashMap::new();
|
||||
let start_handle_token = "u4";
|
||||
args.insert(
|
||||
"handle_token".to_string(),
|
||||
Variant(Box::new("u4".to_string())),
|
||||
Variant(Box::new(start_handle_token.to_string())),
|
||||
);
|
||||
let path;
|
||||
if is_server_running() {
|
||||
path = screencast_portal::start(&portal, session.clone(), "", args)?;
|
||||
} else {
|
||||
path = remote_desktop_portal::start(&portal, session.clone(), "", args)?;
|
||||
}
|
||||
handle_response(
|
||||
c,
|
||||
path,
|
||||
get_request_path(c, start_handle_token)?,
|
||||
on_start_response(
|
||||
fd.clone(),
|
||||
streams.clone(),
|
||||
@@ -889,6 +903,11 @@ fn on_select_sources_response(
|
||||
),
|
||||
failure.clone(),
|
||||
)?;
|
||||
if is_server_running() {
|
||||
let _ = screencast_portal::start(&portal, session.clone(), "", args)?;
|
||||
} else {
|
||||
let _ = remote_desktop_portal::start(&portal, session.clone(), "", args)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
146
src/client.rs
146
src/client.rs
@@ -252,7 +252,7 @@ impl Client {
|
||||
(i32, String),
|
||||
bool,
|
||||
)> {
|
||||
if config::is_incoming_only() {
|
||||
if config::is_incoming_only() && !is_switch_sides_back(conn_type, &interface).await {
|
||||
bail!("Incoming only mode");
|
||||
}
|
||||
// to-do: remember the port for each peer, so that we can retry easier
|
||||
@@ -426,8 +426,8 @@ impl Client {
|
||||
NatType::from_i32(my_nat_type).unwrap_or(NatType::UNKNOWN_NAT)
|
||||
};
|
||||
|
||||
if !key.is_empty() && !token.is_empty() {
|
||||
// mainly for the security of token
|
||||
let switch_code = interface.get_switch_code();
|
||||
if !key.is_empty() && (!token.is_empty() || !switch_code.is_empty()) {
|
||||
secure_tcp(&mut socket, &key)
|
||||
.await
|
||||
.map_err(|e| anyhow!("Failed to secure tcp: {}", e))?;
|
||||
@@ -469,6 +469,7 @@ impl Client {
|
||||
udp_port: udp_nat_port as _,
|
||||
force_relay: interface.is_force_relay(),
|
||||
socket_addr_v6: ipv6.1.unwrap_or_default(),
|
||||
switch_code,
|
||||
..Default::default()
|
||||
});
|
||||
for i in 1..=3 {
|
||||
@@ -716,6 +717,7 @@ impl Client {
|
||||
let mut direct = !conn.is_err();
|
||||
if interface.is_force_relay() || conn.is_err() {
|
||||
if !relay_server.is_empty() {
|
||||
let switch_code = interface.get_switch_code();
|
||||
conn = Self::request_relay(
|
||||
peer_id,
|
||||
relay_server.to_owned(),
|
||||
@@ -724,6 +726,7 @@ impl Client {
|
||||
key,
|
||||
token,
|
||||
conn_type,
|
||||
&switch_code,
|
||||
)
|
||||
.await;
|
||||
if let Err(e) = conn {
|
||||
@@ -844,6 +847,7 @@ impl Client {
|
||||
key: &str,
|
||||
token: &str,
|
||||
conn_type: ConnType,
|
||||
switch_code: &str,
|
||||
) -> ResultType<Stream> {
|
||||
let mut succeed = false;
|
||||
let mut uuid = "".to_owned();
|
||||
@@ -855,8 +859,7 @@ impl Client {
|
||||
.await
|
||||
.with_context(|| "Failed to connect to rendezvous server")?;
|
||||
|
||||
if !key.is_empty() && !token.is_empty() {
|
||||
// mainly for the security of token
|
||||
if !key.is_empty() && (!token.is_empty() || !switch_code.is_empty()) {
|
||||
secure_tcp(&mut socket, key).await?;
|
||||
}
|
||||
|
||||
@@ -877,6 +880,7 @@ impl Client {
|
||||
uuid: uuid.clone(),
|
||||
relay_server: relay_server.clone(),
|
||||
secure,
|
||||
switch_code: switch_code.to_owned(),
|
||||
..Default::default()
|
||||
});
|
||||
socket.send(&msg_out).await?;
|
||||
@@ -1401,6 +1405,10 @@ impl AudioHandler {
|
||||
|
||||
/// Handle audio format and create an audio decoder.
|
||||
pub fn handle_format(&mut self, f: AudioFormat) {
|
||||
if !is_supported_audio_channel_count(f.channels) {
|
||||
log::error!("Unsupported audio channel count: {}", f.channels);
|
||||
return;
|
||||
}
|
||||
match AudioDecoder::new(f.sample_rate, if f.channels > 1 { Stereo } else { Mono }) {
|
||||
Ok(d) => {
|
||||
let buffer = vec![0.; f.sample_rate as usize * f.channels as usize];
|
||||
@@ -1540,6 +1548,23 @@ impl AudioHandler {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_supported_audio_channel_count(channels: u32) -> bool {
|
||||
(1..=2).contains(&channels)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod audio_format_tests {
|
||||
use super::is_supported_audio_channel_count;
|
||||
|
||||
#[test]
|
||||
fn only_mono_and_stereo_are_supported() {
|
||||
assert!(is_supported_audio_channel_count(1));
|
||||
assert!(is_supported_audio_channel_count(2));
|
||||
assert!(!is_supported_audio_channel_count(0));
|
||||
assert!(!is_supported_audio_channel_count(u32::MAX));
|
||||
}
|
||||
}
|
||||
|
||||
/// Video handler for the [`Client`].
|
||||
pub struct VideoHandler {
|
||||
decoder: Decoder,
|
||||
@@ -2650,9 +2675,6 @@ impl LoginConfigHandler {
|
||||
os_password: String,
|
||||
password: Vec<u8>,
|
||||
) -> Message {
|
||||
#[cfg(any(target_os = "android", target_os = "ios"))]
|
||||
let my_id = Config::get_id_or(crate::DEVICE_ID.lock().unwrap().clone());
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
let my_id = Config::get_id();
|
||||
let (my_id, pure_id) = if let Some((id, _, _)) = self.other_server.as_ref() {
|
||||
let server = Config::get_rendezvous_server();
|
||||
@@ -3433,9 +3455,55 @@ pub fn handle_login_error(
|
||||
}
|
||||
}
|
||||
|
||||
// "Switch sides" requires the incoming-only client to connect back to its
|
||||
// controlling peer; verify the local pending uuid before opening the connection.
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
async fn consume_local_switch_sides_uuid(id: &str, uuid: &Uuid) -> bool {
|
||||
async fn is_switch_sides_back(conn_type: ConnType, interface: &impl Interface) -> bool {
|
||||
if conn_type != ConnType::DEFAULT_CONN {
|
||||
return false;
|
||||
}
|
||||
let (id, uuid) = {
|
||||
let lch = interface.get_lch();
|
||||
let lc = lch.read().unwrap();
|
||||
let Some(uuid) = lc.switch_uuid.as_deref() else {
|
||||
return false;
|
||||
};
|
||||
let Ok(uuid) = Uuid::parse_str(uuid) else {
|
||||
return false;
|
||||
};
|
||||
(lc.id.clone(), uuid)
|
||||
};
|
||||
if !request_local_switch_sides_uuid(
|
||||
&id,
|
||||
&uuid,
|
||||
crate::ipc::SwitchSidesUuidAction::Check,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let lch = interface.get_lch();
|
||||
let lc = lch.read().unwrap();
|
||||
let current_uuid = lc
|
||||
.switch_uuid
|
||||
.as_deref()
|
||||
.and_then(|value| Uuid::parse_str(value).ok());
|
||||
lc.id == id && current_uuid.as_ref() == Some(&uuid)
|
||||
}
|
||||
|
||||
#[cfg(not(all(feature = "flutter", not(any(target_os = "android", target_os = "ios")))))]
|
||||
async fn is_switch_sides_back(_conn_type: ConnType, _interface: &impl Interface) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
async fn request_local_switch_sides_uuid(
|
||||
id: &str,
|
||||
uuid: &Uuid,
|
||||
action: crate::ipc::SwitchSidesUuidAction,
|
||||
) -> bool {
|
||||
let Ok(mut conn) = crate::ipc::connect(1000, "").await else {
|
||||
return false;
|
||||
};
|
||||
@@ -3444,6 +3512,7 @@ async fn consume_local_switch_sides_uuid(id: &str, uuid: &Uuid) -> bool {
|
||||
.send(&crate::ipc::Data::SwitchSidesUuid(
|
||||
uuid.clone(),
|
||||
id.to_owned(),
|
||||
action,
|
||||
None,
|
||||
))
|
||||
.await
|
||||
@@ -3455,9 +3524,10 @@ async fn consume_local_switch_sides_uuid(id: &str, uuid: &Uuid) -> bool {
|
||||
Ok(Some(crate::ipc::Data::SwitchSidesUuid(
|
||||
returned_uuid,
|
||||
returned_id,
|
||||
returned_action,
|
||||
Some(true),
|
||||
))) => {
|
||||
returned_uuid == uuid && returned_id == id
|
||||
returned_uuid == uuid && returned_id == id && returned_action == action
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
@@ -3478,7 +3548,7 @@ pub async fn handle_hash(
|
||||
hash: Hash,
|
||||
interface: &impl Interface,
|
||||
peer: &mut Stream,
|
||||
) {
|
||||
) -> bool {
|
||||
lc.write().unwrap().hash = hash.clone();
|
||||
// Take care of password application order
|
||||
|
||||
@@ -3490,16 +3560,35 @@ pub async fn handle_hash(
|
||||
if let Some(uuid) = uuid {
|
||||
if let Ok(uuid) = uuid::Uuid::from_str(&uuid) {
|
||||
let id = lc.read().unwrap().id.clone();
|
||||
if !consume_local_switch_sides_uuid(&id, &uuid).await {
|
||||
if !request_local_switch_sides_uuid(
|
||||
&id,
|
||||
&uuid,
|
||||
crate::ipc::SwitchSidesUuidAction::Consume,
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::warn!("Ignored untrusted switch_uuid");
|
||||
} else {
|
||||
lc.write().unwrap().allow_switch_back_once();
|
||||
send_switch_login_request(lc.clone(), peer, uuid).await;
|
||||
lc.write().unwrap().password_source = Default::default();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Incoming-only may connect out solely for a verified switch-back;
|
||||
// never fall through to password login, including on repeated hashes.
|
||||
if config::is_incoming_only() {
|
||||
interface.msgbox("error", "Connection Error", "Incoming only mode", "");
|
||||
let mut misc = Misc::new();
|
||||
misc.set_close_reason(
|
||||
"Connection not allowed in incoming-only mode".to_owned(),
|
||||
);
|
||||
let mut msg = Message::new();
|
||||
msg.set_misc(misc);
|
||||
allow_err!(peer.send(&msg).await);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// last password
|
||||
let mut password = lc.read().unwrap().password.clone();
|
||||
@@ -3562,7 +3651,7 @@ pub async fn handle_hash(
|
||||
interface.msgbox("terminal-admin-login", "", "", "");
|
||||
}
|
||||
lc.write().unwrap().hash = hash;
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
let password = if password.is_empty() {
|
||||
@@ -3588,6 +3677,7 @@ pub async fn handle_hash(
|
||||
|
||||
send_login(lc.clone(), os_username, os_password, password, peer).await;
|
||||
lc.write().unwrap().hash = hash;
|
||||
true
|
||||
}
|
||||
|
||||
#[inline]
|
||||
@@ -3715,7 +3805,7 @@ pub trait Interface: Send + Clone + 'static + Sized {
|
||||
fn on_error(&self, err: &str) {
|
||||
self.msgbox("error", "Error", err, "");
|
||||
}
|
||||
async fn handle_hash(&self, pass: &str, hash: Hash, peer: &mut Stream);
|
||||
async fn handle_hash(&self, pass: &str, hash: Hash, peer: &mut Stream) -> bool;
|
||||
async fn handle_login_from_ui(
|
||||
&self,
|
||||
os_username: String,
|
||||
@@ -3736,6 +3826,16 @@ pub trait Interface: Send + Clone + 'static + Sized {
|
||||
self.get_lch().read().unwrap().force_relay
|
||||
}
|
||||
|
||||
fn get_switch_code(&self) -> String {
|
||||
match self.get_lch().read().unwrap().switch_uuid.clone() {
|
||||
Some(u) if !u.is_empty() => {
|
||||
use hbb_common::sodiumoxide::crypto::hash::sha256;
|
||||
crate::encode64(sha256::hash(u.as_bytes()).0)
|
||||
}
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn swap_modifier_mouse(&self, _msg: &mut hbb_common::protos::message::MouseEvent) {}
|
||||
|
||||
fn update_direct(&self, direct: Option<bool>) {
|
||||
@@ -3999,9 +4099,25 @@ pub fn check_if_retry(msgtype: &str, title: &str, text: &str, retry_for_relay: b
|
||||
&& !text.to_lowercase().contains("mismatch")
|
||||
&& !text.to_lowercase().contains("manually")
|
||||
&& !text.to_lowercase().contains("restricted")
|
||||
&& !text.to_lowercase().contains("incoming only")
|
||||
&& !text.to_lowercase().contains("not allowed")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod retry_tests {
|
||||
use super::check_if_retry;
|
||||
|
||||
#[test]
|
||||
fn incoming_only_error_is_not_retryable() {
|
||||
assert!(!check_if_retry(
|
||||
"error",
|
||||
"Connection Error",
|
||||
"Incoming only mode",
|
||||
false,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn hc_connection(
|
||||
feedback: i32,
|
||||
rendezvous_server: String,
|
||||
|
||||
@@ -410,7 +410,7 @@ impl<T: InvokeUiSession> Remote<T> {
|
||||
|| !self.is_connected
|
||||
|| !(server_file_transfer_enabled && file_transfer_enabled));
|
||||
log::debug!(
|
||||
"Process clipboard message from system, stop: {}, is_stopping_allowed: {}, view_only: {}, server_file_transfer_enabled: {}, file_transfer_enabled: {}",
|
||||
"Process clipboard message from system, view_only: {}, stop: {}, is_stopping_allowed: {}, server_file_transfer_enabled: {}, file_transfer_enabled: {}",
|
||||
view_only, stop, is_stopping_allowed, server_file_transfer_enabled, file_transfer_enabled
|
||||
);
|
||||
if stop {
|
||||
@@ -1353,9 +1353,13 @@ impl<T: InvokeUiSession> Remote<T> {
|
||||
}
|
||||
}
|
||||
Some(message::Union::Hash(hash)) => {
|
||||
self.handler
|
||||
if !self
|
||||
.handler
|
||||
.handle_hash(&self.handler.password.clone(), hash, peer)
|
||||
.await;
|
||||
.await
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Some(message::Union::LoginResponse(lr)) => match lr.union {
|
||||
Some(login_response::Union::Error(err)) => {
|
||||
|
||||
@@ -36,6 +36,17 @@ const CLIPBOARD_GET_MAX_RETRY: usize = 3;
|
||||
#[cfg(not(target_os = "android"))]
|
||||
const CLIPBOARD_GET_RETRY_INTERVAL_DUR: Duration = Duration::from_millis(33);
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
fn valid_rgba_dimensions(width: i32, height: i32, data_len: usize) -> Option<(usize, usize)> {
|
||||
let width = usize::try_from(width).ok()?;
|
||||
let height = usize::try_from(height).ok()?;
|
||||
if width == 0 || height == 0 {
|
||||
return None;
|
||||
}
|
||||
let expected_len = width.checked_mul(height)?.checked_mul(4)?;
|
||||
(data_len == expected_len).then_some((width, height))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
const SUPPORTED_FORMATS: &[ClipboardFormat] = &[
|
||||
ClipboardFormat::Text,
|
||||
@@ -722,11 +733,15 @@ mod proto {
|
||||
Ok(ClipboardFormat::Text) => String::from_utf8(data).ok().map(ClipboardData::Text),
|
||||
Ok(ClipboardFormat::Rtf) => String::from_utf8(data).ok().map(ClipboardData::Rtf),
|
||||
Ok(ClipboardFormat::Html) => String::from_utf8(data).ok().map(ClipboardData::Html),
|
||||
Ok(ClipboardFormat::ImageRgba) => Some(ClipboardData::Image(arboard::ImageData::rgba(
|
||||
clipboard.width as _,
|
||||
clipboard.height as _,
|
||||
data.into(),
|
||||
))),
|
||||
Ok(ClipboardFormat::ImageRgba) => {
|
||||
let (width, height) =
|
||||
super::valid_rgba_dimensions(clipboard.width, clipboard.height, data.len())?;
|
||||
Some(ClipboardData::Image(arboard::ImageData::rgba(
|
||||
width,
|
||||
height,
|
||||
data.into(),
|
||||
)))
|
||||
}
|
||||
Ok(ClipboardFormat::ImagePng) => {
|
||||
Some(ClipboardData::Image(arboard::ImageData::png(data.into())))
|
||||
}
|
||||
@@ -770,6 +785,22 @@ mod proto {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, not(target_os = "android")))]
|
||||
mod rgba_tests {
|
||||
use super::valid_rgba_dimensions;
|
||||
|
||||
#[test]
|
||||
fn validates_dimensions_against_content_length() {
|
||||
assert_eq!(valid_rgba_dimensions(1, 1, 4), Some((1, 1)));
|
||||
assert_eq!(valid_rgba_dimensions(1, 1, 3), None);
|
||||
assert_eq!(valid_rgba_dimensions(-1, 1, 4), None);
|
||||
assert_eq!(valid_rgba_dimensions(0, 1, 0), None);
|
||||
assert_eq!(valid_rgba_dimensions(i32::MAX, i32::MAX, 4), None);
|
||||
#[cfg(target_pointer_width = "32")]
|
||||
assert_eq!(valid_rgba_dimensions(i32::MAX, 2, 0), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
pub fn handle_msg_clipboard(mut cb: Clipboard) {
|
||||
use hbb_common::protobuf::Message;
|
||||
|
||||
@@ -1024,7 +1024,7 @@ pub fn get_full_name() -> String {
|
||||
}
|
||||
|
||||
pub fn is_setup(name: &str) -> bool {
|
||||
name.to_lowercase().ends_with("install.exe")
|
||||
!config::is_disable_installation() && name.to_lowercase().ends_with("install.exe")
|
||||
}
|
||||
|
||||
pub fn get_custom_rendezvous_server(custom: String) -> String {
|
||||
@@ -1405,6 +1405,58 @@ pub async fn post_request(url: String, body: String, header: &str) -> ResultType
|
||||
.await
|
||||
}
|
||||
|
||||
/// POST request via TCP proxy, preserving the HTTP status code.
|
||||
async fn post_request_via_tcp_proxy_status(
|
||||
url: &str,
|
||||
body: &str,
|
||||
header: &str,
|
||||
) -> ResultType<(u16, String)> {
|
||||
let headers = parse_simple_header(header);
|
||||
let resp = tcp_proxy_request("POST", url, body.as_bytes(), headers).await?;
|
||||
if !resp.error.is_empty() {
|
||||
bail!("TCP proxy error: {}", resp.error);
|
||||
}
|
||||
Ok((
|
||||
resp.status as u16,
|
||||
String::from_utf8_lossy(&resp.body).to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Like `post_request`, but returns the HTTP status code so callers can tell
|
||||
/// a server-side failure from success. Same fallback rules: on connection
|
||||
/// failure or 5xx, retry once through the raw TCP proxy when eligible.
|
||||
pub async fn post_request_with_status(
|
||||
url: String,
|
||||
body: String,
|
||||
header: &str,
|
||||
) -> ResultType<(u16, String)> {
|
||||
if should_use_raw_tcp_for_api(&url) {
|
||||
return post_request_via_tcp_proxy_status(&url, &body, header).await;
|
||||
}
|
||||
let http_result = post_request_http(&url, &body, header).await;
|
||||
let should_fallback = match &http_result {
|
||||
Err(_) => true,
|
||||
Ok((status, _)) => *status >= 500,
|
||||
};
|
||||
if should_fallback && can_fallback_to_raw_tcp(&url) {
|
||||
log::warn!(
|
||||
"HTTP POST to {} failed or 5xx (result: {:?}), trying TCP proxy fallback",
|
||||
tcp_proxy_log_target(&url),
|
||||
http_result
|
||||
.as_ref()
|
||||
.map(|(s, _)| *s)
|
||||
.map_err(|e| e.to_string()),
|
||||
);
|
||||
match post_request_via_tcp_proxy_status(&url, &body, header).await {
|
||||
Ok(resp) => return Ok(resp),
|
||||
Err(tcp_err) => {
|
||||
log::warn!("TCP proxy fallback also failed: {:?}", tcp_err);
|
||||
}
|
||||
}
|
||||
}
|
||||
http_result
|
||||
}
|
||||
|
||||
#[async_recursion]
|
||||
async fn post_request_(
|
||||
url: &str,
|
||||
@@ -2623,6 +2675,20 @@ pub fn is_direct_ip_access(peer: &str) -> bool {
|
||||
hbb_common::is_ip_str(peer) || hbb_common::is_domain_port_str(peer)
|
||||
}
|
||||
|
||||
// Align the maximum length of the peer id to the maximum length of the peer id in the server.
|
||||
const MAX_UNTRUSTED_PEER_ID_LEN: usize = 253;
|
||||
const UNTRUSTED_PEER_ID_FORBIDDEN_CHARS: &[char] = &['"', '<', '>', '/', '\\', '|', '?', '*'];
|
||||
|
||||
// Shared validation for peer/connect ids that cross untrusted boundaries before
|
||||
// they are stored or written into command/script contexts.
|
||||
pub fn is_valid_untrusted_peer_id(id: &str) -> bool {
|
||||
!id.is_empty()
|
||||
&& id.len() <= MAX_UNTRUSTED_PEER_ID_LEN
|
||||
&& !id.chars().any(|ch| {
|
||||
ch.is_control() || ch.is_whitespace() || UNTRUSTED_PEER_ID_FORBIDDEN_CHARS.contains(&ch)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -2653,6 +2719,29 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untrusted_peer_id_validation() {
|
||||
let cases = [
|
||||
("123456789", true),
|
||||
("m\u{00FC}nchen-pc", true),
|
||||
("192.168.1.10:21118", true),
|
||||
("9123456234@public", true),
|
||||
(
|
||||
r#"1" & oWS.Run("cmd.exe /k whoami /priv",1,False) & ""#,
|
||||
false,
|
||||
),
|
||||
("", false),
|
||||
("peer id", false),
|
||||
("peer\nid", false),
|
||||
("peer/id", false),
|
||||
("peer?id", false),
|
||||
];
|
||||
|
||||
for (id, expected) in cases {
|
||||
assert_eq!(is_valid_untrusted_peer_id(id), expected, "{id:?}");
|
||||
}
|
||||
}
|
||||
|
||||
// ThrottledInterval tick at the same time as tokio interval, if no sleeps
|
||||
#[allow(non_snake_case)]
|
||||
#[tokio::test]
|
||||
|
||||
@@ -127,6 +127,13 @@ pub fn core_main() -> Option<Vec<String>> {
|
||||
if args.contains(&"--noinstall".to_string()) {
|
||||
args.clear();
|
||||
}
|
||||
// The portable wrapper injects `--install` when its name ends with `install.exe`,
|
||||
// including `no-install.exe`. Drop the argument instead of exiting so disabled
|
||||
// clients can continue running as portable applications.
|
||||
if config::is_disable_installation() {
|
||||
args.retain(|arg| arg != "--install");
|
||||
flutter_args.retain(|arg| arg != "--install");
|
||||
}
|
||||
if args.len() > 0 {
|
||||
if args[0] == "--version" {
|
||||
println!("{}", crate::VERSION);
|
||||
@@ -660,7 +667,8 @@ pub fn core_main() -> Option<Vec<String>> {
|
||||
None
|
||||
}
|
||||
};
|
||||
let new_id = get_value("--id");
|
||||
// An empty --id (e.g. an unset var) would deploy a blank id; the Android flow guards this too (#15146).
|
||||
let new_id = get_value("--id").filter(|s| !s.is_empty());
|
||||
match crate::ui_interface::deploy_device(token, new_id) {
|
||||
crate::ui_interface::DeployResult::Ok => {
|
||||
println!("Device deployed.");
|
||||
|
||||
594
src/flutter.rs
594
src/flutter.rs
@@ -23,9 +23,10 @@ use std::{
|
||||
os::raw::{c_char, c_int, c_void},
|
||||
str::FromStr,
|
||||
sync::{
|
||||
atomic::{AtomicBool, AtomicUsize, Ordering},
|
||||
Arc, RwLock,
|
||||
atomic::{AtomicBool, AtomicU8, AtomicUsize, Ordering},
|
||||
Arc, Mutex, RwLock,
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
/// tag "main" for [Desktop Main Page] and [Mobile (Client and Server)] (the mobile don't need multiple windows, only one global event stream is needed)
|
||||
@@ -136,6 +137,12 @@ pub extern "C" fn rustdesk_core_main_args(args_len: *mut c_int) -> *mut *mut c_c
|
||||
return std::ptr::null_mut() as _;
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[no_mangle]
|
||||
pub extern "C" fn rustdesk_is_disable_installation() -> c_int {
|
||||
hbb_common::config::is_disable_installation() as c_int
|
||||
}
|
||||
|
||||
// https://gist.github.com/iskakaushik/1c5b8aa75c77479c33c4320913eebef6
|
||||
#[cfg(windows)]
|
||||
fn rust_args_to_c_args(args: Vec<String>, outlen: *mut c_int) -> *mut *mut c_char {
|
||||
@@ -263,26 +270,119 @@ pub type FlutterGpuTextureRendererPluginCApiSetTexture =
|
||||
#[cfg(feature = "vram")]
|
||||
pub type FlutterGpuTextureRendererPluginCApiGetAdapterLuid = unsafe extern "C" fn() -> i64;
|
||||
|
||||
pub type FlutterRgbaRendererPluginGetConsumed = unsafe extern "C" fn(texture_rgba: *mut c_void) -> u64;
|
||||
|
||||
#[cfg(feature = "vram")]
|
||||
pub type FlutterGpuTextureRendererPluginCApiGetConsumed =
|
||||
unsafe extern "C" fn(output: *mut c_void) -> u64;
|
||||
|
||||
pub(super) type TextureRgbaPtr = usize;
|
||||
|
||||
// Which texture backend the watchdog saw fail; the health record carries it
|
||||
// so the rgba-only startup probe never clears a gpu-path failure.
|
||||
pub(super) const WATCHDOG_FAILED_RGBA: u8 = 1;
|
||||
#[cfg(feature = "vram")]
|
||||
pub(super) const WATCHDOG_FAILED_GPU: u8 = 2;
|
||||
|
||||
#[derive(Default)]
|
||||
struct DisplaySessionInfo {
|
||||
// TextureRgba pointer in flutter native.
|
||||
texture_rgba_ptr: TextureRgbaPtr,
|
||||
size: (usize, usize),
|
||||
size_mismatch_count: u32,
|
||||
#[cfg(feature = "vram")]
|
||||
gpu_output_ptr: usize,
|
||||
notify_render_type: Option<RenderType>,
|
||||
// Watchdog: frames pushed to a texture the engine never consumes mean
|
||||
// texture rendering is broken (black view on a live connection). Armed
|
||||
// until a consumption is observed since arming.
|
||||
pushed_count: u64,
|
||||
watchdog_consumed_base: Option<u64>,
|
||||
watchdog_pushed_base: u64,
|
||||
watchdog_since: Option<Instant>,
|
||||
watchdog_last_sample: Option<Instant>,
|
||||
watchdog_armed: bool,
|
||||
}
|
||||
|
||||
impl DisplaySessionInfo {
|
||||
fn reset_watchdog(&mut self) {
|
||||
self.pushed_count = 0;
|
||||
self.watchdog_consumed_base = None;
|
||||
self.watchdog_pushed_base = 0;
|
||||
self.watchdog_since = None;
|
||||
self.watchdog_last_sample = None;
|
||||
self.watchdog_armed = true;
|
||||
}
|
||||
|
||||
// Restart the observation window without disarming; used while the window
|
||||
// is hidden, where the engine legitimately composites nothing.
|
||||
fn pause_watchdog(&mut self) {
|
||||
self.watchdog_consumed_base = None;
|
||||
self.watchdog_since = None;
|
||||
}
|
||||
|
||||
// The plugin counter is cumulative and never resets, so compare against a
|
||||
// snapshot taken when arming; damage-driven streams can be sparse, so
|
||||
// judge on pushes within the observation window plus elapsed time.
|
||||
fn check_watchdog(&mut self, consumed: u64) -> bool {
|
||||
let now = Instant::now();
|
||||
let Some(base) = self.watchdog_consumed_base else {
|
||||
self.watchdog_consumed_base = Some(consumed);
|
||||
self.watchdog_pushed_base = self.pushed_count;
|
||||
self.watchdog_since = Some(now);
|
||||
return false;
|
||||
};
|
||||
if consumed > base {
|
||||
self.watchdog_armed = false;
|
||||
return false;
|
||||
}
|
||||
if self.pushed_count - self.watchdog_pushed_base >= 30
|
||||
&& self
|
||||
.watchdog_since
|
||||
.map(|t| now.duration_since(t) >= Duration::from_secs(3))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
self.watchdog_armed = false;
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn watchdog_sample_due(&mut self) -> bool {
|
||||
if !self.watchdog_armed {
|
||||
return false;
|
||||
}
|
||||
let now = Instant::now();
|
||||
match self.watchdog_last_sample {
|
||||
Some(t) if now.duration_since(t) < Duration::from_secs(1) => false,
|
||||
_ => {
|
||||
self.watchdog_last_sample = Some(now);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Video Texture Renderer in Flutter
|
||||
// Per-display mutexes: the per-frame plugin call must not hold session-level
|
||||
// locks, or a stalled plugin/driver call freezes every window's UI thread.
|
||||
#[derive(Clone)]
|
||||
struct VideoRenderer {
|
||||
is_support_multi_ui_session: bool,
|
||||
map_display_sessions: Arc<RwLock<HashMap<usize, DisplaySessionInfo>>>,
|
||||
map_display_sessions: Arc<RwLock<HashMap<usize, Arc<Mutex<DisplaySessionInfo>>>>>,
|
||||
// Latched by the watchdog (WATCHDOG_FAILED_*); consumed once by the
|
||||
// pushing caller to trigger the software-render fallback for this session.
|
||||
texture_render_failed: Arc<AtomicU8>,
|
||||
// Hidden windows legitimately composite nothing; the watchdog pauses.
|
||||
render_visible: Arc<AtomicBool>,
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
on_rgba_func: Option<Symbol<'static, FlutterRgbaRendererPluginOnRgba>>,
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
get_consumed_func: Option<Symbol<'static, FlutterRgbaRendererPluginGetConsumed>>,
|
||||
#[cfg(feature = "vram")]
|
||||
on_texture_func: Option<Symbol<'static, FlutterGpuTextureRendererPluginCApiSetTexture>>,
|
||||
#[cfg(feature = "vram")]
|
||||
get_gpu_consumed_func: Option<Symbol<'static, FlutterGpuTextureRendererPluginCApiGetConsumed>>,
|
||||
}
|
||||
|
||||
impl Default for VideoRenderer {
|
||||
@@ -306,6 +406,17 @@ impl Default for VideoRenderer {
|
||||
None
|
||||
}
|
||||
};
|
||||
// Absent in older plugin builds; the watchdog just stays disabled.
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
let get_consumed_func = match &*TEXTURE_RGBA_RENDERER_PLUGIN {
|
||||
Ok(lib) => unsafe {
|
||||
lib.symbol::<FlutterRgbaRendererPluginGetConsumed>(
|
||||
"FlutterRgbaRendererPluginGetConsumed",
|
||||
)
|
||||
.ok()
|
||||
},
|
||||
Err(_) => None,
|
||||
};
|
||||
#[cfg(feature = "vram")]
|
||||
let on_texture_func = match &*TEXTURE_GPU_RENDERER_PLUGIN {
|
||||
Ok(lib) => {
|
||||
@@ -327,14 +438,30 @@ impl Default for VideoRenderer {
|
||||
None
|
||||
}
|
||||
};
|
||||
#[cfg(feature = "vram")]
|
||||
let get_gpu_consumed_func = match &*TEXTURE_GPU_RENDERER_PLUGIN {
|
||||
Ok(lib) => unsafe {
|
||||
lib.symbol::<FlutterGpuTextureRendererPluginCApiGetConsumed>(
|
||||
"FlutterGpuTextureRendererPluginCApiGetConsumed",
|
||||
)
|
||||
.ok()
|
||||
},
|
||||
Err(_) => None,
|
||||
};
|
||||
|
||||
Self {
|
||||
map_display_sessions: Default::default(),
|
||||
is_support_multi_ui_session: false,
|
||||
texture_render_failed: Default::default(),
|
||||
render_visible: Arc::new(AtomicBool::new(true)),
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
on_rgba_func,
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
get_consumed_func,
|
||||
#[cfg(feature = "vram")]
|
||||
on_texture_func,
|
||||
#[cfg(feature = "vram")]
|
||||
get_gpu_consumed_func,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -343,19 +470,18 @@ impl VideoRenderer {
|
||||
#[inline]
|
||||
fn set_size(&mut self, display: usize, width: usize, height: usize) {
|
||||
let mut sessions_lock = self.map_display_sessions.write().unwrap();
|
||||
if let Some(info) = sessions_lock.get_mut(&display) {
|
||||
if let Some(info) = sessions_lock.get(&display) {
|
||||
let mut info = info.lock().unwrap();
|
||||
info.size = (width, height);
|
||||
info.size_mismatch_count = 0;
|
||||
info.notify_render_type = None;
|
||||
} else {
|
||||
sessions_lock.insert(
|
||||
display,
|
||||
DisplaySessionInfo {
|
||||
texture_rgba_ptr: usize::default(),
|
||||
Arc::new(Mutex::new(DisplaySessionInfo {
|
||||
size: (width, height),
|
||||
#[cfg(feature = "vram")]
|
||||
gpu_output_ptr: usize::default(),
|
||||
notify_render_type: None,
|
||||
},
|
||||
..Default::default()
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -363,7 +489,8 @@ impl VideoRenderer {
|
||||
fn register_pixelbuffer_texture(&self, display: usize, ptr: usize) {
|
||||
let mut sessions_lock = self.map_display_sessions.write().unwrap();
|
||||
if ptr == 0 {
|
||||
if let Some(info) = sessions_lock.get_mut(&display) {
|
||||
if let Some(info_arc) = sessions_lock.get(&display).cloned() {
|
||||
let mut info = info_arc.lock().unwrap();
|
||||
if info.texture_rgba_ptr != usize::default() {
|
||||
info.texture_rgba_ptr = usize::default();
|
||||
}
|
||||
@@ -371,10 +498,12 @@ impl VideoRenderer {
|
||||
if info.gpu_output_ptr != usize::default() {
|
||||
return;
|
||||
}
|
||||
drop(info);
|
||||
sessions_lock.remove(&display);
|
||||
}
|
||||
sessions_lock.remove(&display);
|
||||
} else {
|
||||
if let Some(info) = sessions_lock.get_mut(&display) {
|
||||
if let Some(info) = sessions_lock.get(&display) {
|
||||
let mut info = info.lock().unwrap();
|
||||
if info.texture_rgba_ptr != usize::default()
|
||||
&& info.texture_rgba_ptr != ptr as TextureRgbaPtr
|
||||
{
|
||||
@@ -386,38 +515,59 @@ impl VideoRenderer {
|
||||
}
|
||||
info.texture_rgba_ptr = ptr as _;
|
||||
info.notify_render_type = None;
|
||||
info.reset_watchdog();
|
||||
} else {
|
||||
if ptr != 0 {
|
||||
sessions_lock.insert(
|
||||
display,
|
||||
DisplaySessionInfo {
|
||||
texture_rgba_ptr: ptr as _,
|
||||
size: (0, 0),
|
||||
#[cfg(feature = "vram")]
|
||||
gpu_output_ptr: usize::default(),
|
||||
notify_render_type: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
let mut info = DisplaySessionInfo {
|
||||
texture_rgba_ptr: ptr as _,
|
||||
..Default::default()
|
||||
};
|
||||
info.reset_watchdog();
|
||||
sessions_lock.insert(display, Arc::new(Mutex::new(info)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compare-and-clear: an unconditional clear could wipe the registration a
|
||||
// new window just made when a tab moves between windows (#8016); waiting
|
||||
// on the display mutex also drains an in-flight push via the old pointer.
|
||||
fn unregister_pixelbuffer_texture(&self, display: usize, ptr: usize) {
|
||||
if ptr == 0 {
|
||||
return;
|
||||
}
|
||||
let mut sessions_lock = self.map_display_sessions.write().unwrap();
|
||||
if let Some(info_arc) = sessions_lock.get(&display).cloned() {
|
||||
let mut info = info_arc.lock().unwrap();
|
||||
if info.texture_rgba_ptr != ptr as TextureRgbaPtr {
|
||||
return;
|
||||
}
|
||||
info.texture_rgba_ptr = usize::default();
|
||||
#[cfg(feature = "vram")]
|
||||
if info.gpu_output_ptr != usize::default() {
|
||||
return;
|
||||
}
|
||||
drop(info);
|
||||
sessions_lock.remove(&display);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "vram")]
|
||||
pub fn register_gpu_output(&self, display: usize, ptr: usize) {
|
||||
let mut sessions_lock = self.map_display_sessions.write().unwrap();
|
||||
if ptr == 0 {
|
||||
if let Some(info) = sessions_lock.get_mut(&display) {
|
||||
if let Some(info_arc) = sessions_lock.get(&display).cloned() {
|
||||
let mut info = info_arc.lock().unwrap();
|
||||
if info.gpu_output_ptr != usize::default() {
|
||||
info.gpu_output_ptr = usize::default();
|
||||
}
|
||||
if info.texture_rgba_ptr != usize::default() {
|
||||
return;
|
||||
}
|
||||
drop(info);
|
||||
sessions_lock.remove(&display);
|
||||
}
|
||||
sessions_lock.remove(&display);
|
||||
} else {
|
||||
if let Some(info) = sessions_lock.get_mut(&display) {
|
||||
if let Some(info) = sessions_lock.get(&display) {
|
||||
let mut info = info.lock().unwrap();
|
||||
if info.gpu_output_ptr != usize::default() && info.gpu_output_ptr != ptr {
|
||||
log::error!(
|
||||
"gpu_output_ptr is not null and not equal to ptr, relace {} to {}",
|
||||
@@ -427,50 +577,91 @@ impl VideoRenderer {
|
||||
}
|
||||
info.gpu_output_ptr = ptr as _;
|
||||
info.notify_render_type = None;
|
||||
info.reset_watchdog();
|
||||
} else {
|
||||
if ptr != usize::default() {
|
||||
sessions_lock.insert(
|
||||
display,
|
||||
DisplaySessionInfo {
|
||||
texture_rgba_ptr: usize::default(),
|
||||
size: (0, 0),
|
||||
gpu_output_ptr: ptr,
|
||||
notify_render_type: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
let mut info = DisplaySessionInfo {
|
||||
gpu_output_ptr: ptr,
|
||||
..Default::default()
|
||||
};
|
||||
info.reset_watchdog();
|
||||
sessions_lock.insert(display, Arc::new(Mutex::new(info)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// See unregister_pixelbuffer_texture for why this is compare-and-clear.
|
||||
#[cfg(feature = "vram")]
|
||||
pub fn unregister_gpu_output(&self, display: usize, ptr: usize) {
|
||||
if ptr == 0 {
|
||||
return;
|
||||
}
|
||||
let mut sessions_lock = self.map_display_sessions.write().unwrap();
|
||||
if let Some(info_arc) = sessions_lock.get(&display).cloned() {
|
||||
let mut info = info_arc.lock().unwrap();
|
||||
if info.gpu_output_ptr != ptr {
|
||||
return;
|
||||
}
|
||||
info.gpu_output_ptr = usize::default();
|
||||
if info.texture_rgba_ptr != usize::default() {
|
||||
return;
|
||||
}
|
||||
drop(info);
|
||||
sessions_lock.remove(&display);
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn display_session_info(&self, display: usize) -> Option<Arc<Mutex<DisplaySessionInfo>>> {
|
||||
let read_lock = self.map_display_sessions.read().unwrap();
|
||||
if !self.is_support_multi_ui_session {
|
||||
read_lock.values().next().cloned()
|
||||
} else {
|
||||
read_lock.get(&display).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
pub fn on_rgba(&self, display: usize, rgba: &scrap::ImageRgb) -> bool {
|
||||
let mut write_lock = self.map_display_sessions.write().unwrap();
|
||||
let opt_info = if !self.is_support_multi_ui_session {
|
||||
write_lock.values_mut().next()
|
||||
} else {
|
||||
write_lock.get_mut(&display)
|
||||
};
|
||||
let Some(info) = opt_info else {
|
||||
let Some(info_arc) = self.display_session_info(display) else {
|
||||
return false;
|
||||
};
|
||||
let mut info = info_arc.lock().unwrap();
|
||||
if info.texture_rgba_ptr == usize::default() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if info.size.0 != rgba.w || info.size.1 != rgba.h {
|
||||
log::error!(
|
||||
"width/height mismatch: ({},{}) != ({},{})",
|
||||
info.size.0,
|
||||
info.size.1,
|
||||
rgba.w,
|
||||
rgba.h
|
||||
);
|
||||
// Peer info's handling is async and may be late than video frame's handling
|
||||
// Allow peer info not set, but not allow wrong width/height for correct local cursor position
|
||||
if info.size != (0, 0) {
|
||||
return false;
|
||||
info.size_mismatch_count += 1;
|
||||
if info.size_mismatch_count == 1 {
|
||||
log::error!(
|
||||
"width/height mismatch: ({},{}) != ({},{})",
|
||||
info.size.0,
|
||||
info.size.1,
|
||||
rgba.w,
|
||||
rgba.h
|
||||
);
|
||||
}
|
||||
// If sizes still disagree after this many frames the peer info
|
||||
// is not coming and dropping forever leaves a live session
|
||||
// black. Legacy single-ui-session pairs frames loosely
|
||||
// (values().next()), so only adopt where pairing is exact.
|
||||
if !self.is_support_multi_ui_session || info.size_mismatch_count < 30 {
|
||||
return false;
|
||||
}
|
||||
log::warn!(
|
||||
"adopting frame size ({},{}) after {} mismatched frames",
|
||||
rgba.w,
|
||||
rgba.h,
|
||||
info.size_mismatch_count
|
||||
);
|
||||
info.size = (rgba.w, rgba.h);
|
||||
info.size_mismatch_count = 0;
|
||||
}
|
||||
} else {
|
||||
info.size_mismatch_count = 0;
|
||||
}
|
||||
if let Some(func) = &self.on_rgba_func {
|
||||
unsafe {
|
||||
@@ -484,6 +675,23 @@ impl VideoRenderer {
|
||||
)
|
||||
};
|
||||
}
|
||||
info.pushed_count += 1;
|
||||
if let Some(get_consumed) = &self.get_consumed_func {
|
||||
if !self.render_visible.load(Ordering::Relaxed) {
|
||||
info.pause_watchdog();
|
||||
} else if info.watchdog_sample_due() {
|
||||
let consumed = unsafe { get_consumed(info.texture_rgba_ptr as _) };
|
||||
if info.check_watchdog(consumed) {
|
||||
log::error!(
|
||||
"texture rendering broken: {} frames pushed to display {}, none consumed",
|
||||
info.pushed_count,
|
||||
display
|
||||
);
|
||||
self.texture_render_failed
|
||||
.store(WATCHDOG_FAILED_RGBA, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
}
|
||||
if info.notify_render_type != Some(RenderType::PixelBuffer) {
|
||||
info.notify_render_type = Some(RenderType::PixelBuffer);
|
||||
true
|
||||
@@ -494,21 +702,36 @@ impl VideoRenderer {
|
||||
|
||||
#[cfg(feature = "vram")]
|
||||
pub fn on_texture(&self, display: usize, texture: *mut c_void) -> bool {
|
||||
let mut write_lock = self.map_display_sessions.write().unwrap();
|
||||
let opt_info = if !self.is_support_multi_ui_session {
|
||||
write_lock.values_mut().next()
|
||||
} else {
|
||||
write_lock.get_mut(&display)
|
||||
};
|
||||
let Some(info) = opt_info else {
|
||||
let Some(info_arc) = self.display_session_info(display) else {
|
||||
return false;
|
||||
};
|
||||
let mut info = info_arc.lock().unwrap();
|
||||
if info.gpu_output_ptr == usize::default() {
|
||||
return false;
|
||||
}
|
||||
if let Some(func) = &self.on_texture_func {
|
||||
unsafe { func(info.gpu_output_ptr as _, texture) };
|
||||
}
|
||||
info.pushed_count += 1;
|
||||
// Gpu "consumed" counts descriptor fetches (an EGL bind failure still
|
||||
// advances it), so this only detects never-composited outputs; the
|
||||
// rgba path and the startup probe cover bind-failure black screens.
|
||||
if let Some(get_consumed) = &self.get_gpu_consumed_func {
|
||||
if !self.render_visible.load(Ordering::Relaxed) {
|
||||
info.pause_watchdog();
|
||||
} else if info.watchdog_sample_due() {
|
||||
let consumed = unsafe { get_consumed(info.gpu_output_ptr as _) };
|
||||
if info.check_watchdog(consumed) {
|
||||
log::error!(
|
||||
"gpu texture rendering broken: {} frames pushed to display {}, none consumed",
|
||||
info.pushed_count,
|
||||
display
|
||||
);
|
||||
self.texture_render_failed
|
||||
.store(WATCHDOG_FAILED_GPU, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
}
|
||||
if info.notify_render_type != Some(RenderType::Texture) {
|
||||
info.notify_render_type = Some(RenderType::Texture);
|
||||
true
|
||||
@@ -518,11 +741,10 @@ impl VideoRenderer {
|
||||
}
|
||||
|
||||
pub fn reset_all_display_render_type(&self) {
|
||||
let mut write_lock = self.map_display_sessions.write().unwrap();
|
||||
write_lock
|
||||
.values_mut()
|
||||
.map(|v| v.notify_render_type = None)
|
||||
.count();
|
||||
let read_lock = self.map_display_sessions.read().unwrap();
|
||||
for info in read_lock.values() {
|
||||
info.lock().unwrap().notify_render_type = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -655,9 +877,24 @@ impl FlutterHandler {
|
||||
}
|
||||
|
||||
pub fn update_use_texture_render(&self) {
|
||||
self.use_texture_render
|
||||
.store(crate::ui_interface::use_texture_render(), Ordering::Relaxed);
|
||||
let v = crate::ui_interface::use_texture_render();
|
||||
self.use_texture_render.store(v, Ordering::Relaxed);
|
||||
self.display_rgbas.write().unwrap().clear();
|
||||
if v {
|
||||
// Texture render was (re-)enabled; validate it afresh so a still
|
||||
// broken environment fails over again instead of staying black.
|
||||
for (_, session) in self.session_handlers.read().unwrap().iter() {
|
||||
for info in session
|
||||
.renderer
|
||||
.map_display_sessions
|
||||
.read()
|
||||
.unwrap()
|
||||
.values()
|
||||
{
|
||||
info.lock().unwrap().reset_watchdog();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -881,12 +1118,13 @@ impl InvokeUiSession for FlutterHandler {
|
||||
if !self.use_texture_render.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
for (_, session) in self.session_handlers.read().unwrap().iter() {
|
||||
for (session_id, session) in self.session_handlers.read().unwrap().iter() {
|
||||
if session.renderer.on_texture(display, texture) {
|
||||
if let Some(stream) = &session.event_stream {
|
||||
stream.add(EventToUI::Texture(display, true));
|
||||
}
|
||||
}
|
||||
Self::check_texture_render_failed(session_id, session);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1256,16 +1494,31 @@ impl FlutterHandler {
|
||||
display: usize,
|
||||
rgba: &mut scrap::ImageRgb,
|
||||
) {
|
||||
for (_, session) in self.session_handlers.read().unwrap().iter() {
|
||||
for (session_id, session) in self.session_handlers.read().unwrap().iter() {
|
||||
if use_texture_render || session.displays.len() > 1 {
|
||||
if session.renderer.on_rgba(display, rgba) {
|
||||
if let Some(stream) = &session.event_stream {
|
||||
stream.add(EventToUI::Texture(display, false));
|
||||
}
|
||||
}
|
||||
Self::check_texture_render_failed(session_id, session);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Consume the watchdog latch outside the per-frame hot path work; the
|
||||
// actual fallback (config write, decoder reset) runs on its own thread.
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
fn check_texture_render_failed(session_id: &SessionID, session: &SessionHandler) {
|
||||
let kind = session
|
||||
.renderer
|
||||
.texture_render_failed
|
||||
.swap(0, Ordering::SeqCst);
|
||||
if kind != 0 {
|
||||
let session_id = session_id.clone();
|
||||
std::thread::spawn(move || on_texture_render_failed(session_id, kind));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// This function is only used for the default connection session.
|
||||
@@ -1789,6 +2042,168 @@ pub fn session_register_gpu_texture(_session_id: SessionID, _display: usize, _ou
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn session_unregister_pixelbuffer_texture(session_id: SessionID, display: usize, ptr: usize) {
|
||||
for s in sessions::get_sessions() {
|
||||
if let Some(h) = s
|
||||
.ui_handler
|
||||
.session_handlers
|
||||
.read()
|
||||
.unwrap()
|
||||
.get(&session_id)
|
||||
{
|
||||
h.renderer.unregister_pixelbuffer_texture(display, ptr);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hidden windows legitimately composite nothing; pausing the watchdog there
|
||||
// keeps a minimized/background window from recording a false failure.
|
||||
#[inline]
|
||||
pub fn session_set_render_visible(session_id: SessionID, visible: bool) {
|
||||
for s in sessions::get_sessions() {
|
||||
if let Some(h) = s
|
||||
.ui_handler
|
||||
.session_handlers
|
||||
.read()
|
||||
.unwrap()
|
||||
.get(&session_id)
|
||||
{
|
||||
h.renderer
|
||||
.render_visible
|
||||
.store(visible, Ordering::Relaxed);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn session_unregister_gpu_texture(_session_id: SessionID, _display: usize, _output_ptr: usize) {
|
||||
#[cfg(feature = "vram")]
|
||||
for s in sessions::get_sessions() {
|
||||
if let Some(h) = s
|
||||
.ui_handler
|
||||
.session_handlers
|
||||
.read()
|
||||
.unwrap()
|
||||
.get(&_session_id)
|
||||
{
|
||||
h.renderer.unregister_gpu_output(_display, _output_ptr);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Startup-probe plumbing: the main window pushes one frame into a throwaway
|
||||
// 1x1 texture and polls whether the engine consumed it, validating the
|
||||
// texture pipeline before any session depends on it.
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
pub fn texture_render_probe_supported() -> bool {
|
||||
match &*TEXTURE_RGBA_RENDERER_PLUGIN {
|
||||
Ok(lib) => unsafe {
|
||||
lib.symbol::<FlutterRgbaRendererPluginGetConsumed>(
|
||||
"FlutterRgbaRendererPluginGetConsumed",
|
||||
)
|
||||
.is_ok()
|
||||
},
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
pub fn push_texture_probe_frame(ptr: usize) {
|
||||
if ptr == 0 {
|
||||
return;
|
||||
}
|
||||
let Ok(lib) = &*TEXTURE_RGBA_RENDERER_PLUGIN else {
|
||||
return;
|
||||
};
|
||||
let Ok(func) = (unsafe {
|
||||
lib.symbol::<FlutterRgbaRendererPluginOnRgba>("FlutterRgbaRendererPluginOnRgba")
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
// Fully transparent so the 1x1 probe pixel is invisible on any theme.
|
||||
let frame: [u8; 4] = [0, 0, 0, 0];
|
||||
unsafe { func(ptr as _, frame.as_ptr(), 4, 1, 1, 1) };
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
pub fn get_texture_probe_consumed(ptr: usize) -> u64 {
|
||||
if ptr == 0 {
|
||||
return 0;
|
||||
}
|
||||
let Ok(lib) = &*TEXTURE_RGBA_RENDERER_PLUGIN else {
|
||||
return 0;
|
||||
};
|
||||
let Ok(func) = (unsafe {
|
||||
lib.symbol::<FlutterRgbaRendererPluginGetConsumed>("FlutterRgbaRendererPluginGetConsumed")
|
||||
}) else {
|
||||
return 0;
|
||||
};
|
||||
unsafe { func(ptr as _) }
|
||||
}
|
||||
|
||||
// Frames are being pushed but the engine never consumes them: fall back to
|
||||
// software rendering and record the breakage (health flips the default off;
|
||||
// a passing startup probe or an explicit option toggle clears it).
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
fn on_texture_render_failed(session_id: SessionID, kind: u8) {
|
||||
// One record per breakage; later fires (other displays/sessions) no-op.
|
||||
// Sessions already running were downgraded when the record was written.
|
||||
if crate::ui_interface::texture_render_health_failed() {
|
||||
return;
|
||||
}
|
||||
log::error!(
|
||||
"texture rendering failed for session {}, falling back to software rendering",
|
||||
session_id
|
||||
);
|
||||
let backend = if kind == WATCHDOG_FAILED_RGBA {
|
||||
"rgba"
|
||||
} else {
|
||||
"gpu"
|
||||
};
|
||||
LocalConfig::set_option(
|
||||
hbb_common::config::keys::OPTION_TEXTURE_RENDER_HEALTH.to_owned(),
|
||||
format!(
|
||||
"failed-watchdog-{}@{}",
|
||||
backend,
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
),
|
||||
);
|
||||
// Mirror main_set_local_option: every render session must observe the
|
||||
// new effective value, not only the failing one.
|
||||
for session in sessions::get_sessions() {
|
||||
if !(session.is_default() || session.is_view_camera()) {
|
||||
continue;
|
||||
}
|
||||
// The soft path cannot rescue multi-display windows; don't claim it.
|
||||
let fallback_rescues = session
|
||||
.ui_handler
|
||||
.session_handlers
|
||||
.read()
|
||||
.unwrap()
|
||||
.get(&session_id)
|
||||
.map(|h| h.displays.len() <= 1)
|
||||
.unwrap_or(false);
|
||||
if fallback_rescues {
|
||||
session.push_event(
|
||||
"use_texture_render",
|
||||
&[("v", "N"), ("reason", "fallback")],
|
||||
&[],
|
||||
);
|
||||
} else {
|
||||
session.push_event("use_texture_render", &[("v", "N")], &[]);
|
||||
}
|
||||
session.use_texture_render_changed();
|
||||
session.ui_handler.update_use_texture_render();
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[cfg(not(feature = "vram"))]
|
||||
pub fn get_adapter_luid() -> Option<i64> {
|
||||
@@ -2125,6 +2540,45 @@ pub mod sessions {
|
||||
s
|
||||
}
|
||||
|
||||
/// Close every client session, returning how many peer sessions were closed.
|
||||
///
|
||||
/// Used when the UI is gone but the process keeps running, e.g. the Android
|
||||
/// task is swiped away from recents while a foreground service keeps the
|
||||
/// process alive. The orphaned `io_loop` would otherwise keep answering
|
||||
/// `TestDelay`, so the peer never hits its inactivity timeout and the
|
||||
/// session stays established with no way to close it.
|
||||
#[cfg(any(target_os = "android", target_os = "ios"))]
|
||||
pub fn close_all_sessions() -> usize {
|
||||
// Release held keys before draining: the release path sends through
|
||||
// `get_cur_session()`, which resolves against SESSIONS, so draining
|
||||
// first would take TO_RELEASE and then silently drop every key-up,
|
||||
// leaving the key stuck on the controlled side. A no-op when nothing
|
||||
// is held.
|
||||
crate::keyboard::release_remote_keys("map");
|
||||
// Drain so the map lock is released before closing each session.
|
||||
let sessions: Vec<FlutterSession> = SESSIONS
|
||||
.write()
|
||||
.unwrap()
|
||||
.drain()
|
||||
.map(|(_, session)| session)
|
||||
.collect();
|
||||
for session in sessions.iter() {
|
||||
let session_ids: Vec<SessionID> = session
|
||||
.ui_handler
|
||||
.session_handlers
|
||||
.read()
|
||||
.unwrap()
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect();
|
||||
for session_id in session_ids {
|
||||
session.close_event_stream(session_id);
|
||||
}
|
||||
session.close();
|
||||
}
|
||||
sessions.len()
|
||||
}
|
||||
|
||||
/// Check if removing a session by session_id would result in removing the entire peer.
|
||||
///
|
||||
/// Returns:
|
||||
|
||||
@@ -1222,11 +1222,32 @@ pub fn main_set_env(key: String, value: Option<String>) -> SyncReturn<()> {
|
||||
|
||||
pub fn main_set_local_option(key: String, value: String) {
|
||||
let is_texture_render_key = key.eq(config::keys::OPTION_TEXTURE_RENDER);
|
||||
let is_texture_render_health_key = key.eq(config::keys::OPTION_TEXTURE_RENDER_HEALTH);
|
||||
let is_d3d_render_key = key.eq(config::keys::OPTION_ALLOW_D3D_RENDER);
|
||||
set_local_option(key, value.clone());
|
||||
let is_render_target =
|
||||
|session: &crate::flutter::FlutterSession| session.is_default() || session.is_view_camera();
|
||||
if is_texture_render_health_key && value.starts_with("failed") {
|
||||
// Probe/raster-stall failures must also downgrade sessions that are
|
||||
// already running (they snapshotted the old effective value, and the
|
||||
// watchdog's own fallback no-ops once a record exists).
|
||||
for session in sessions::get_sessions() {
|
||||
if !is_render_target(&session) {
|
||||
continue;
|
||||
}
|
||||
session.push_event("use_texture_render", &[("v", "N")], &[]);
|
||||
session.use_texture_render_changed();
|
||||
session.ui_handler.update_use_texture_render();
|
||||
}
|
||||
}
|
||||
if is_texture_render_key {
|
||||
// An explicit user toggle gives texture rendering a fresh chance; a
|
||||
// stale failure record must not override it (the watchdog re-records
|
||||
// if the environment is still broken).
|
||||
set_local_option(
|
||||
config::keys::OPTION_TEXTURE_RENDER_HEALTH.to_owned(),
|
||||
"".to_owned(),
|
||||
);
|
||||
let session_event = [("v", &value)];
|
||||
for session in sessions::get_sessions() {
|
||||
if !is_render_target(&session) {
|
||||
@@ -2295,6 +2316,52 @@ pub fn session_register_gpu_texture(
|
||||
))
|
||||
}
|
||||
|
||||
pub fn session_unregister_pixelbuffer_texture(
|
||||
session_id: SessionID,
|
||||
display: usize,
|
||||
ptr: usize,
|
||||
) -> SyncReturn<()> {
|
||||
SyncReturn(super::flutter::session_unregister_pixelbuffer_texture(
|
||||
session_id, display, ptr,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn session_unregister_gpu_texture(
|
||||
session_id: SessionID,
|
||||
display: usize,
|
||||
ptr: usize,
|
||||
) -> SyncReturn<()> {
|
||||
SyncReturn(super::flutter::session_unregister_gpu_texture(
|
||||
session_id, display, ptr,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn session_set_render_visible(session_id: SessionID, visible: bool) -> SyncReturn<()> {
|
||||
SyncReturn(super::flutter::session_set_render_visible(
|
||||
session_id, visible,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn main_texture_render_probe_supported() -> SyncReturn<bool> {
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
return SyncReturn(super::flutter::texture_render_probe_supported());
|
||||
#[cfg(any(target_os = "android", target_os = "ios"))]
|
||||
SyncReturn(false)
|
||||
}
|
||||
|
||||
pub fn main_push_texture_probe_frame(ptr: usize) -> SyncReturn<()> {
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
super::flutter::push_texture_probe_frame(ptr);
|
||||
SyncReturn(())
|
||||
}
|
||||
|
||||
pub fn main_get_texture_probe_consumed(ptr: usize) -> SyncReturn<u64> {
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
return SyncReturn(super::flutter::get_texture_probe_consumed(ptr));
|
||||
#[cfg(any(target_os = "android", target_os = "ios"))]
|
||||
SyncReturn(0)
|
||||
}
|
||||
|
||||
pub fn query_onlines(ids: Vec<String>) {
|
||||
let _ = flutter::async_tasks::query_onlines(ids);
|
||||
}
|
||||
@@ -3128,6 +3195,16 @@ pub mod server_side {
|
||||
crate::server::video_service::refresh()
|
||||
}
|
||||
|
||||
/// Close outgoing sessions when the UI goes away but the process may not,
|
||||
/// so a session cannot outlive the UI that is able to close it.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "system" fn Java_ffi_FFI_closeAllSessions(_env: JNIEnv, _class: JClass) {
|
||||
let closed = crate::flutter::sessions::close_all_sessions();
|
||||
if closed > 0 {
|
||||
log::info!("closed {} outgoing session(s)", closed);
|
||||
}
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "system" fn Java_ffi_FFI_getLocalOption(
|
||||
env: JNIEnv,
|
||||
|
||||
@@ -113,7 +113,7 @@ pub struct OidcSession {
|
||||
failed_msg: String,
|
||||
code_url: Option<OidcAuthUrl>,
|
||||
auth_body: Option<AuthBody>,
|
||||
keep_querying: bool,
|
||||
auth_attempt: u64,
|
||||
running: bool,
|
||||
query_timeout: Duration,
|
||||
}
|
||||
@@ -140,7 +140,7 @@ impl OidcSession {
|
||||
failed_msg: "".to_owned(),
|
||||
code_url: None,
|
||||
auth_body: None,
|
||||
keep_querying: false,
|
||||
auth_attempt: 0,
|
||||
running: false,
|
||||
query_timeout: Duration::from_secs(QUERY_TIMEOUT_SECS),
|
||||
}
|
||||
@@ -169,6 +169,7 @@ impl OidcSession {
|
||||
"id": id,
|
||||
"uuid": uuid,
|
||||
"deviceInfo": crate::ui_interface::get_login_device_info(),
|
||||
"apiDomain": api_server,
|
||||
})
|
||||
.to_string();
|
||||
let resp = crate::post_request_sync(format!("{}/api/oidc/auth", api_server), body, "")?;
|
||||
@@ -191,12 +192,8 @@ impl OidcSession {
|
||||
body: String,
|
||||
}
|
||||
|
||||
let resp = crate::http_request_sync(
|
||||
url.to_string(),
|
||||
"GET".to_owned(),
|
||||
None,
|
||||
"{}".to_owned(),
|
||||
)?;
|
||||
let resp =
|
||||
crate::http_request_sync(url.to_string(), "GET".to_owned(), None, "{}".to_owned())?;
|
||||
let resp = serde_json::from_str::<HttpResponseBody>(&resp)?;
|
||||
HbbHttpResponse::parse(&resp.body)
|
||||
}
|
||||
@@ -204,7 +201,6 @@ impl OidcSession {
|
||||
fn reset(&mut self) {
|
||||
self.state_msg = REQUESTING_ACCOUNT_AUTH;
|
||||
self.failed_msg = "".to_owned();
|
||||
self.keep_querying = true;
|
||||
self.running = false;
|
||||
self.code_url = None;
|
||||
self.auth_body = None;
|
||||
@@ -219,49 +215,92 @@ impl OidcSession {
|
||||
self.running = false;
|
||||
}
|
||||
|
||||
fn start_auth_attempt(&mut self) -> u64 {
|
||||
self.auth_attempt = self.auth_attempt.wrapping_add(1);
|
||||
self.auth_attempt
|
||||
}
|
||||
|
||||
fn cancel_auth_attempt(&mut self) {
|
||||
self.auth_attempt = self.auth_attempt.wrapping_add(1);
|
||||
}
|
||||
|
||||
fn is_current_auth_attempt(&self, auth_attempt: u64) -> bool {
|
||||
self.auth_attempt == auth_attempt
|
||||
}
|
||||
|
||||
fn auth_attempt_is_current(auth_attempt: u64) -> bool {
|
||||
OIDC_SESSION
|
||||
.read()
|
||||
.unwrap()
|
||||
.is_current_auth_attempt(auth_attempt)
|
||||
}
|
||||
|
||||
fn set_state_if_current(auth_attempt: u64, state_msg: &'static str, failed_msg: String) {
|
||||
let mut session = OIDC_SESSION.write().unwrap();
|
||||
if session.is_current_auth_attempt(auth_attempt) {
|
||||
session.set_state(state_msg, failed_msg);
|
||||
}
|
||||
}
|
||||
|
||||
fn sleep(secs: f32) {
|
||||
std::thread::sleep(std::time::Duration::from_secs_f32(secs));
|
||||
}
|
||||
|
||||
fn auth_task(api_server: String, op: String, id: String, uuid: String, remember_me: bool) {
|
||||
fn auth_task(
|
||||
api_server: String,
|
||||
op: String,
|
||||
id: String,
|
||||
uuid: String,
|
||||
remember_me: bool,
|
||||
auth_attempt: u64,
|
||||
) {
|
||||
let auth_request_res = Self::auth(&api_server, &op, &id, &uuid);
|
||||
log::info!("Request oidc auth result: {:?}", &auth_request_res);
|
||||
if !Self::auth_attempt_is_current(auth_attempt) {
|
||||
return;
|
||||
}
|
||||
let code_url = match auth_request_res {
|
||||
Ok(HbbHttpResponse::<_>::Data(code_url)) => code_url,
|
||||
Ok(HbbHttpResponse::<_>::Error(err)) => {
|
||||
OIDC_SESSION
|
||||
.write()
|
||||
.unwrap()
|
||||
.set_state(REQUESTING_ACCOUNT_AUTH, err);
|
||||
Self::set_state_if_current(auth_attempt, REQUESTING_ACCOUNT_AUTH, err);
|
||||
return;
|
||||
}
|
||||
Ok(_) => {
|
||||
OIDC_SESSION
|
||||
.write()
|
||||
.unwrap()
|
||||
.set_state(REQUESTING_ACCOUNT_AUTH, "Invalid auth response".to_owned());
|
||||
Self::set_state_if_current(
|
||||
auth_attempt,
|
||||
REQUESTING_ACCOUNT_AUTH,
|
||||
"Invalid auth response".to_owned(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
Err(err) => {
|
||||
OIDC_SESSION
|
||||
.write()
|
||||
.unwrap()
|
||||
.set_state(REQUESTING_ACCOUNT_AUTH, err.to_string());
|
||||
Self::set_state_if_current(auth_attempt, REQUESTING_ACCOUNT_AUTH, err.to_string());
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
OIDC_SESSION
|
||||
.write()
|
||||
.unwrap()
|
||||
.set_state(WAITING_ACCOUNT_AUTH, "".to_owned());
|
||||
OIDC_SESSION.write().unwrap().code_url = Some(code_url.clone());
|
||||
{
|
||||
let mut session = OIDC_SESSION.write().unwrap();
|
||||
if !session.is_current_auth_attempt(auth_attempt) {
|
||||
return;
|
||||
}
|
||||
session.set_state(WAITING_ACCOUNT_AUTH, "".to_owned());
|
||||
session.code_url = Some(code_url.clone());
|
||||
}
|
||||
|
||||
let begin = Instant::now();
|
||||
let query_timeout = OIDC_SESSION.read().unwrap().query_timeout;
|
||||
while OIDC_SESSION.read().unwrap().keep_querying && begin.elapsed() < query_timeout {
|
||||
match Self::query(&api_server, &code_url.code, &id, &uuid) {
|
||||
while Self::auth_attempt_is_current(auth_attempt) && begin.elapsed() < query_timeout {
|
||||
let query_result = Self::query(&api_server, &code_url.code, &id, &uuid);
|
||||
if !Self::auth_attempt_is_current(auth_attempt) {
|
||||
return;
|
||||
}
|
||||
match query_result {
|
||||
Ok(HbbHttpResponse::<_>::Data(auth_body)) => {
|
||||
let mut session = OIDC_SESSION.write().unwrap();
|
||||
if !session.is_current_auth_attempt(auth_attempt) {
|
||||
return;
|
||||
}
|
||||
if auth_body.r#type == "access_token" {
|
||||
if remember_me {
|
||||
LocalConfig::set_option(
|
||||
@@ -280,21 +319,15 @@ impl OidcSession {
|
||||
);
|
||||
}
|
||||
}
|
||||
OIDC_SESSION
|
||||
.write()
|
||||
.unwrap()
|
||||
.set_state(LOGIN_ACCOUNT_AUTH, "".to_owned());
|
||||
OIDC_SESSION.write().unwrap().auth_body = Some(auth_body);
|
||||
session.set_state(LOGIN_ACCOUNT_AUTH, "".to_owned());
|
||||
session.auth_body = Some(auth_body);
|
||||
return;
|
||||
}
|
||||
Ok(HbbHttpResponse::<_>::Error(err)) => {
|
||||
if err.contains("No authed oidc is found") {
|
||||
// ignore, keep querying
|
||||
} else {
|
||||
OIDC_SESSION
|
||||
.write()
|
||||
.unwrap()
|
||||
.set_state(WAITING_ACCOUNT_AUTH, err);
|
||||
Self::set_state_if_current(auth_attempt, WAITING_ACCOUNT_AUTH, err);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -309,14 +342,9 @@ impl OidcSession {
|
||||
Self::sleep(QUERY_INTERVAL_SECS);
|
||||
}
|
||||
|
||||
if begin.elapsed() >= query_timeout {
|
||||
OIDC_SESSION
|
||||
.write()
|
||||
.unwrap()
|
||||
.set_state(WAITING_ACCOUNT_AUTH, "timeout".to_owned());
|
||||
if begin.elapsed() >= query_timeout && Self::auth_attempt_is_current(auth_attempt) {
|
||||
Self::set_state_if_current(auth_attempt, WAITING_ACCOUNT_AUTH, "timeout".to_owned());
|
||||
}
|
||||
|
||||
// no need to handle "keep_querying == false"
|
||||
}
|
||||
|
||||
fn set_state(&mut self, state_msg: &'static str, failed_msg: String) {
|
||||
@@ -338,11 +366,17 @@ impl OidcSession {
|
||||
uuid: String,
|
||||
remember_me: bool,
|
||||
) {
|
||||
Self::auth_cancel();
|
||||
let auth_attempt = OIDC_SESSION.write().unwrap().start_auth_attempt();
|
||||
Self::wait_stop_querying();
|
||||
OIDC_SESSION.write().unwrap().before_task();
|
||||
{
|
||||
let mut session = OIDC_SESSION.write().unwrap();
|
||||
if !session.is_current_auth_attempt(auth_attempt) {
|
||||
return;
|
||||
}
|
||||
session.before_task();
|
||||
}
|
||||
std::thread::spawn(move || {
|
||||
Self::auth_task(api_server, op, id, uuid, remember_me);
|
||||
Self::auth_task(api_server, op, id, uuid, remember_me, auth_attempt);
|
||||
OIDC_SESSION.write().unwrap().after_task();
|
||||
});
|
||||
}
|
||||
@@ -357,7 +391,7 @@ impl OidcSession {
|
||||
}
|
||||
|
||||
pub fn auth_cancel() {
|
||||
OIDC_SESSION.write().unwrap().keep_querying = false;
|
||||
OIDC_SESSION.write().unwrap().cancel_auth_attempt();
|
||||
}
|
||||
|
||||
pub fn get_result() -> AuthResult {
|
||||
|
||||
@@ -308,3 +308,135 @@ fn handle_config_options(config_options: HashMap<String, String>) {
|
||||
pub fn is_pro() -> bool {
|
||||
PRO.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
// Fire-and-forget by design: the switch flow must not block on this POST.
|
||||
// If the device clock is outside the server's accepted window, the server
|
||||
// returns its current Unix time and this task re-signs and retries once.
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
pub fn register_switch_grant(switch_uuid: String) {
|
||||
tokio::spawn(async move {
|
||||
let api_server = crate::ui_interface::get_api_server();
|
||||
if api_server.is_empty() || crate::is_public(&api_server) {
|
||||
return;
|
||||
}
|
||||
use hbb_common::sodiumoxide::crypto::{hash::sha256, sign};
|
||||
let switch_code = crate::encode64(sha256::hash(switch_uuid.as_bytes()).0);
|
||||
let switch_code_verifier = switch_code_verifier(&switch_code);
|
||||
let timestamp = (hbb_common::get_time() / 1000).to_string();
|
||||
let id = Config::get_id();
|
||||
let kp = Config::get_key_pair();
|
||||
let Some(sk) = sign::SecretKey::from_slice(&kp.0) else {
|
||||
log::error!("Failed to register switch grant: no device key");
|
||||
return;
|
||||
};
|
||||
let url = format!("{}/api/switch-grant", api_server);
|
||||
let mut timestamp = timestamp;
|
||||
for attempt in 0..2 {
|
||||
let signature = sign::sign_detached(
|
||||
&switch_grant_signed_msg(&id, &switch_code_verifier, ×tamp),
|
||||
&sk,
|
||||
);
|
||||
let body = json!({
|
||||
"id": &id,
|
||||
"switch_code_verifier": &switch_code_verifier,
|
||||
"timestamp": ×tamp,
|
||||
"signature": crate::encode64(signature.to_bytes()),
|
||||
})
|
||||
.to_string();
|
||||
let response = match crate::post_request(url.clone(), body, "").await {
|
||||
Ok(response) => response,
|
||||
Err(e) => {
|
||||
log::error!("Failed to register switch grant: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let response = match serde_json::from_str::<Value>(&response) {
|
||||
Ok(response) => response,
|
||||
Err(e) => {
|
||||
log::error!("Failed to register switch grant: invalid response: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
match response.get("accepted").and_then(Value::as_bool) {
|
||||
Some(true) => return,
|
||||
Some(false) => {}
|
||||
None => {
|
||||
log::error!("Failed to register switch grant: missing accepted response");
|
||||
return;
|
||||
}
|
||||
}
|
||||
let Some(server_time) = response["server_time"].as_i64() else {
|
||||
log::error!("Failed to register switch grant: rejected by server");
|
||||
return;
|
||||
};
|
||||
if attempt == 0 {
|
||||
log::warn!("Switch grant timestamp rejected, retrying with server time");
|
||||
timestamp = server_time.to_string();
|
||||
} else {
|
||||
log::error!("Failed to register switch grant after retrying with server time");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
fn switch_code_verifier(switch_code: &str) -> String {
|
||||
use hbb_common::sodiumoxide::crypto::hash::sha256;
|
||||
|
||||
let prefix = b"switch-grant-verifier\0";
|
||||
let mut msg = Vec::with_capacity(prefix.len() + switch_code.len());
|
||||
msg.extend_from_slice(prefix);
|
||||
msg.extend_from_slice(switch_code.as_bytes());
|
||||
crate::encode64(sha256::hash(&msg).0)
|
||||
}
|
||||
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
fn switch_grant_signed_msg(id: &str, switch_code_verifier: &str, timestamp: &str) -> Vec<u8> {
|
||||
let mut msg =
|
||||
Vec::with_capacity(13 + id.len() + 1 + switch_code_verifier.len() + 1 + timestamp.len());
|
||||
msg.extend_from_slice(b"switch-grant\0");
|
||||
msg.extend_from_slice(id.as_bytes());
|
||||
msg.push(0);
|
||||
msg.extend_from_slice(switch_code_verifier.as_bytes());
|
||||
msg.push(0);
|
||||
msg.extend_from_slice(timestamp.as_bytes());
|
||||
msg
|
||||
}
|
||||
|
||||
#[cfg(all(
|
||||
test,
|
||||
feature = "flutter",
|
||||
not(any(target_os = "android", target_os = "ios"))
|
||||
))]
|
||||
mod tests {
|
||||
use super::{switch_code_verifier, switch_grant_signed_msg};
|
||||
|
||||
#[test]
|
||||
fn test_switch_code_verifier_is_not_raw_switch_code() {
|
||||
let switch_code = "code-abc";
|
||||
let verifier = switch_code_verifier(switch_code);
|
||||
assert_ne!(verifier, switch_code);
|
||||
assert_eq!(verifier, switch_code_verifier(switch_code));
|
||||
assert_eq!(
|
||||
verifier,
|
||||
"dMIn3uiPe77XodFB5IKi7PrKJ7l7+zVquNn0ObSaHQc="
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_switch_grant_signed_msg_layout() {
|
||||
let expected: Vec<u8> = [
|
||||
&b"switch-grant\0"[..],
|
||||
b"id1",
|
||||
b"\0",
|
||||
b"c1",
|
||||
b"\0",
|
||||
b"1700000000",
|
||||
]
|
||||
.concat();
|
||||
assert_eq!(switch_grant_signed_msg("id1", "c1", "1700000000"), expected);
|
||||
}
|
||||
}
|
||||
|
||||
147
src/ipc.rs
147
src/ipc.rs
@@ -3,6 +3,21 @@ mod ipc_auth;
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
#[path = "ipc/fs.rs"]
|
||||
mod ipc_fs;
|
||||
// The DRM/KMS capture producer, the `_drm` channel and its SCM_RIGHTS framing live in their own
|
||||
// module, declared the same way as the other pieces of this file, so the opt-in feature adds a
|
||||
// bounded, self-contained surface here instead of ~1800 lines in the middle of the shared IPC.
|
||||
#[cfg(all(target_os = "linux", feature = "drm"))]
|
||||
#[path = "ipc/drm.rs"]
|
||||
mod ipc_drm;
|
||||
// Re-exported so the paths callers already use (`crate::ipc::start_drm`, `crate::ipc::connect_drm`,
|
||||
// `crate::ipc::DrmDisplayInfo`) keep working, and so the `Data` variants can name the two
|
||||
// payload types.
|
||||
#[cfg(all(target_os = "linux", feature = "drm"))]
|
||||
pub use ipc_drm::{start_drm, DmabufDesc, DrmDisplayInfo};
|
||||
#[cfg(all(target_os = "linux", feature = "drm"))]
|
||||
pub(crate) use ipc_drm::DrmConn;
|
||||
#[cfg(all(target_os = "linux", feature = "drm"))]
|
||||
pub(crate) use ipc_drm::connect_drm;
|
||||
|
||||
#[cfg(all(feature = "flutter", feature = "plugin_framework"))]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
@@ -41,6 +56,8 @@ pub(crate) use ipc_auth::ensure_peer_executable_matches_current_by_pid_opt;
|
||||
pub(crate) use ipc_auth::log_rejected_windows_ipc_connection;
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
use ipc_auth::{active_uid, authorize_service_scoped_ipc_connection};
|
||||
#[cfg(target_os = "macos")]
|
||||
use ipc_auth::authorize_user_server_process;
|
||||
#[cfg(windows)]
|
||||
use ipc_auth::{
|
||||
authorize_windows_main_ipc_connection, portable_service_listener_security_attributes,
|
||||
@@ -58,6 +75,9 @@ use ipc_fs::{
|
||||
check_pid, ensure_secure_ipc_parent_dir, scrub_secure_ipc_parent_dir,
|
||||
should_scrub_parent_entries_after_check_pid, write_pid,
|
||||
};
|
||||
// Gated with the module that uses it, so a `drm`-less build does not carry an unused import.
|
||||
#[cfg(all(target_os = "linux", feature = "drm"))]
|
||||
use ipc_fs::remove_ipc_entry_via_secure_parent_fd;
|
||||
use parity_tokio_ipc::{
|
||||
Connection as Conn, ConnectionClient as ConnClient, Endpoint, Incoming, SecurityAttributes,
|
||||
};
|
||||
@@ -292,6 +312,14 @@ pub enum DataPortableService {
|
||||
CmShowElevation(bool),
|
||||
}
|
||||
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SwitchSidesUuidAction {
|
||||
Check,
|
||||
Consume,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
#[serde(tag = "t", content = "c")]
|
||||
pub enum Data {
|
||||
@@ -367,7 +395,7 @@ pub enum Data {
|
||||
SwitchSidesRequest(String),
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
SwitchSidesUuid(String, String, Option<bool>),
|
||||
SwitchSidesUuid(String, String, SwitchSidesUuidAction, Option<bool>),
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
SwitchSidesBack,
|
||||
@@ -472,11 +500,58 @@ pub enum Data {
|
||||
#[cfg(target_os = "windows")]
|
||||
PortForwardSessionCount(Option<usize>),
|
||||
SocksWs(Option<Box<(Option<config::Socks5Server>, String)>>),
|
||||
#[cfg(target_os = "macos")]
|
||||
HasNoActiveConns(Option<bool>),
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
Whiteboard((String, crate::whiteboard::CustomEvent)),
|
||||
ControlPermissionsRemoteModify(Option<bool>),
|
||||
#[cfg(target_os = "windows")]
|
||||
FileTransferEnabledState(Option<bool>),
|
||||
// --- DRM/KMS capture (opt-in `drm` feature) over the `_drm` service-scoped channel ---
|
||||
// All of the following are `cfg(all(linux, drm))`, so the drm-off IPC wire is byte-identical
|
||||
// to upstream. Protocol on `_drm`: on connect the root service sends `DrmDisplayList`, the
|
||||
// client replies `DrmStart{display}`, then the service streams `DrmFrame` + send_raw(BGRA) and
|
||||
// `DrmCursor` + send_raw(RGBA). A frame/cursor header is ALWAYS immediately followed by exactly
|
||||
// one `send_raw()` payload (the same header-then-raw pairing as `FileBlockFromCM`). This keeps
|
||||
// the header extensible. The zero-copy `DrmFrameDmabuf(DmabufDesc)` sibling below carries only a
|
||||
// small JSON metadata descriptor; the scanout dma-buf fd rides an SCM_RIGHTS ancillary message on
|
||||
// the same `DrmConn` send (see `DrmConn::send_msg`), so it has NO trailing `send_raw()` body.
|
||||
/// Client -> service: begin streaming the chosen display.
|
||||
#[cfg(all(target_os = "linux", feature = "drm"))]
|
||||
// `need_cpu` is set by an unprivileged consumer that could not open a render-node convert context
|
||||
// (drmtap_open_render failed, e.g. no /dev/dri/renderD* access). The service then streams the
|
||||
// CPU-converted `DrmFrame` path for this connection instead of a dma-buf fd the consumer cannot
|
||||
// detile, so a render-node-less seat still captures instead of losing the stream.
|
||||
DrmStart { display: i32, need_cpu: bool },
|
||||
/// Service -> client: the enumerated DRM displays (sent once, before frames).
|
||||
#[cfg(all(target_os = "linux", feature = "drm"))]
|
||||
DrmDisplayList(Vec<DrmDisplayInfo>),
|
||||
/// Service -> client: the connector topology changed mid-stream (a monitor hotplug/unplug/modeset,
|
||||
/// observed by the service's udev DRM-uevent listener). Carries the freshly-enumerated list so the
|
||||
/// consumer can swap its sticky positive availability cache off the hot path, WITHOUT re-probing
|
||||
/// `_drm` (which would trip the enumeration restart loop). Interleaved with frames on the same
|
||||
/// stream; carries no `send_raw()` body and no fd.
|
||||
#[cfg(all(target_os = "linux", feature = "drm"))]
|
||||
DrmDisplaysChanged(Vec<DrmDisplayInfo>),
|
||||
/// Service -> client: a frame header; the packed BGRA pixels follow via `send_raw()`.
|
||||
/// CPU-fallback path (no render node, or no transferable dma-buf): pixels cross the wire.
|
||||
#[cfg(all(target_os = "linux", feature = "drm"))]
|
||||
DrmFrame { width: u32, height: u32 },
|
||||
/// Service -> client: a zero-copy dma-buf frame descriptor. The scanout fd is NOT a field; when
|
||||
/// `desc.has_fd` it rides an SCM_RIGHTS ancillary message on the same `DrmConn::send_msg`, and
|
||||
/// there is NO trailing `send_raw()` body. The unprivileged `--server` imports the fd and does
|
||||
/// the EGL detile/convert itself (see `DmabufDesc`).
|
||||
#[cfg(all(target_os = "linux", feature = "drm"))]
|
||||
DrmFrameDmabuf(DmabufDesc),
|
||||
/// Service -> client: a hardware-cursor header; the RGBA pixels follow via `send_raw()`.
|
||||
#[cfg(all(target_os = "linux", feature = "drm"))]
|
||||
DrmCursor {
|
||||
id: u64,
|
||||
width: u32,
|
||||
height: u32,
|
||||
hotx: i32,
|
||||
hoty: i32,
|
||||
},
|
||||
}
|
||||
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
@@ -881,8 +956,14 @@ async fn handle(data: Data, stream: &mut Connection) {
|
||||
Some(value) => {
|
||||
let mut updated = true;
|
||||
if name == "id" {
|
||||
Config::set_key_confirmed(false);
|
||||
Config::set_id(&value);
|
||||
// An empty id would wipe the local id and unconfirm the key (cf. #15626).
|
||||
if value.is_empty() {
|
||||
log::warn!("Ignoring empty id write over IPC");
|
||||
updated = false;
|
||||
} else {
|
||||
Config::set_key_confirmed(false);
|
||||
Config::set_id(&value);
|
||||
}
|
||||
} else if name == "temporary-password" {
|
||||
password::update_temporary_password();
|
||||
} else if name == "permanent-password" {
|
||||
@@ -968,6 +1049,7 @@ async fn handle(data: Data, stream: &mut Connection) {
|
||||
Data::SwitchSidesRequest(id) => {
|
||||
let uuid = uuid::Uuid::new_v4();
|
||||
crate::server::insert_switch_sides_uuid(id, uuid.clone());
|
||||
crate::hbbs_http::sync::register_switch_grant(uuid.to_string());
|
||||
allow_err!(
|
||||
stream
|
||||
.send(&Data::SwitchSidesRequest(uuid.to_string()))
|
||||
@@ -976,14 +1058,21 @@ async fn handle(data: Data, stream: &mut Connection) {
|
||||
}
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
Data::SwitchSidesUuid(uuid, id, None) => {
|
||||
Data::SwitchSidesUuid(uuid, id, action, None) => {
|
||||
let allowed = uuid
|
||||
.parse::<uuid::Uuid>()
|
||||
.map(|uuid| crate::server::remove_pending_switch_sides_uuid(&id, &uuid))
|
||||
.map(|uuid| match action {
|
||||
SwitchSidesUuidAction::Check => {
|
||||
crate::server::has_pending_switch_sides_uuid(&id, &uuid)
|
||||
}
|
||||
SwitchSidesUuidAction::Consume => {
|
||||
crate::server::claim_pending_switch_sides_uuid(&id, &uuid)
|
||||
}
|
||||
})
|
||||
.unwrap_or(false);
|
||||
allow_err!(
|
||||
stream
|
||||
.send(&Data::SwitchSidesUuid(uuid, id, Some(allowed)))
|
||||
.send(&Data::SwitchSidesUuid(uuid, id, action, Some(allowed)))
|
||||
.await
|
||||
);
|
||||
}
|
||||
@@ -1000,6 +1089,16 @@ async fn handle(data: Data, stream: &mut Connection) {
|
||||
.await
|
||||
);
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
Data::HasNoActiveConns(None) => {
|
||||
allow_err!(
|
||||
stream
|
||||
.send(&Data::HasNoActiveConns(Some(
|
||||
crate::updater::has_no_active_conns()
|
||||
)))
|
||||
.await
|
||||
);
|
||||
}
|
||||
#[cfg(all(
|
||||
feature = "flutter",
|
||||
not(any(target_os = "android", target_os = "ios"))
|
||||
@@ -1334,14 +1433,21 @@ pub async fn connect(ms_timeout: u64, postfix: &str) -> ResultType<ConnectionTmp
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
pub async fn connect_for_uid(
|
||||
ms_timeout: u64,
|
||||
uid: u32,
|
||||
postfix: &str,
|
||||
) -> ResultType<ConnectionTmpl<ConnClient>> {
|
||||
let path = Config::ipc_path_for_uid(uid, postfix);
|
||||
connect_with_path(ms_timeout, &path).await
|
||||
let conn = connect_with_path(ms_timeout, &path).await?;
|
||||
#[cfg(target_os = "macos")]
|
||||
if postfix.is_empty()
|
||||
&& !authorize_user_server_process(conn.peer_uid(), conn.peer_pid(), uid)
|
||||
{
|
||||
bail!("Rejected user IPC peer for uid {}", uid);
|
||||
}
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -1689,19 +1795,24 @@ pub fn clear_trusted_devices() {
|
||||
}
|
||||
|
||||
pub fn get_id() -> String {
|
||||
// An empty id may come from a process that took over the main IPC with a
|
||||
// config scope that has no id yet (e.g. a user GUI that became the server
|
||||
// while the installed service was restarting). Treat it as no answer,
|
||||
// otherwise the empty id is adopted below and wipes the local one.
|
||||
if let Ok(Some(v)) = get_config("id") {
|
||||
// update salt also, so that next time reinstallation not causing first-time auto-login failure
|
||||
if let Ok(Some(v2)) = get_config("salt") {
|
||||
Config::set_salt(&v2);
|
||||
if !v.is_empty() {
|
||||
// update salt also, so that next time reinstallation not causing first-time auto-login failure
|
||||
if let Ok(Some(v2)) = get_config("salt") {
|
||||
Config::set_salt(&v2);
|
||||
}
|
||||
if v != Config::get_id() {
|
||||
Config::set_key_confirmed(false);
|
||||
Config::set_id(&v);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
if v != Config::get_id() {
|
||||
Config::set_key_confirmed(false);
|
||||
Config::set_id(&v);
|
||||
}
|
||||
v
|
||||
} else {
|
||||
Config::get_id()
|
||||
}
|
||||
Config::get_id()
|
||||
}
|
||||
|
||||
pub async fn get_rendezvous_server(ms_timeout: u64) -> (String, Vec<String>) {
|
||||
|
||||
@@ -208,6 +208,17 @@ pub(crate) fn active_uid() -> Option<u32> {
|
||||
active_uid_strict()
|
||||
}
|
||||
|
||||
/// The active session uid read ONLY from the service-loop cache, never from a fresh (blocking) seat0
|
||||
/// lookup. `None` on a cache miss. For hot, latency-sensitive, fail-closed re-auth on an async runtime
|
||||
/// thread (the `_drm` per-frame re-auth), where a blocking `loginctl` per frame would stall the stream.
|
||||
// Gated with the feature, not just the OS: the `_drm` per-frame re-auth is its only caller, so a
|
||||
// drm-off Linux build would carry it as dead code and warn about it.
|
||||
#[cfg(all(target_os = "linux", feature = "drm"))]
|
||||
#[inline]
|
||||
pub(crate) fn active_uid_cached() -> Option<u32> {
|
||||
crate::platform::linux::get_active_userid_cached()
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
#[inline]
|
||||
pub(crate) fn peer_uid_from_fd(fd: RawFd) -> Option<u32> {
|
||||
@@ -656,6 +667,32 @@ pub(crate) fn authorize_service_scoped_ipc_connection(stream: &Connection, postf
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(crate) fn authorize_user_server_process(
|
||||
peer_uid: Option<u32>,
|
||||
peer_pid: Option<u32>,
|
||||
expected_uid: u32,
|
||||
) -> bool {
|
||||
if peer_uid != Some(expected_uid) {
|
||||
return false;
|
||||
}
|
||||
let Some(peer_pid) = peer_pid else {
|
||||
return false;
|
||||
};
|
||||
let Ok(peer_exe) = peer_exe_canonical_path_by_pid(peer_pid) else {
|
||||
return false;
|
||||
};
|
||||
let expected_path = PathBuf::from(format!(
|
||||
"/Applications/{}.app/Contents/MacOS/{}",
|
||||
crate::get_app_name(),
|
||||
crate::get_app_name()
|
||||
));
|
||||
let Ok(expected_path) = fs::canonicalize(expected_path) else {
|
||||
return false;
|
||||
};
|
||||
paths_refer_to_same_file(&peer_exe, &expected_path)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn authorize_windows_main_ipc_connection(stream: &Connection, postfix: &str) -> bool {
|
||||
let (
|
||||
|
||||
1799
src/ipc/drm.rs
Normal file
1799
src/ipc/drm.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -164,9 +164,25 @@ fn scrub_preexisting_ipc_parent_entries(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_ipc_socket_via_secure_parent_fd(postfix: &str) -> ResultType<()> {
|
||||
let path = config::Config::ipc_path(postfix);
|
||||
let parent_dir = Path::new(&path)
|
||||
/// Remove one entry from the IPC parent directory through a no-follow fd on that directory.
|
||||
///
|
||||
/// Prefer this over `std::fs::remove_file` for anything about to be bound: `remove_file` is
|
||||
/// `unlink(2)`, which returns EISDIR against a directory-typed squatter and leaves it in place,
|
||||
/// and the bind that follows then fails EADDRINUSE. `remove_parent_entry_via_fd` fstats the
|
||||
/// entry first and picks `AT_REMOVEDIR` when it needs to.
|
||||
///
|
||||
/// `AT_REMOVEDIR` is `rmdir(2)`, so the directory case this closes is the EMPTY one; a non-empty
|
||||
/// squatter still yields ENOTEMPTY and still blocks the bind that follows. That is deliberate, and
|
||||
/// the "obvious" fix is worse than the bug: removing it recursively would be root deleting a tree
|
||||
/// an unprivileged process planted. What the caller gains there is a named error to log ahead of
|
||||
/// the bind's own failure, not a successful bind.
|
||||
pub(crate) fn remove_ipc_entry_via_secure_parent_fd(path: &str) -> ResultType<()> {
|
||||
let entry_name = Path::new(path)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.ok_or_else(|| Error::new(ErrorKind::InvalidInput, format!("invalid ipc path: {path}")))?
|
||||
.to_owned();
|
||||
let parent_dir = Path::new(path)
|
||||
.parent()
|
||||
.ok_or_else(|| Error::new(ErrorKind::InvalidInput, format!("invalid ipc path: {path}")))?;
|
||||
let parent_c = CString::new(parent_dir.as_os_str().as_bytes().to_vec())?;
|
||||
@@ -179,8 +195,8 @@ fn remove_ipc_socket_via_secure_parent_fd(postfix: &str) -> ResultType<()> {
|
||||
return Err(Error::new(
|
||||
open_err.kind(),
|
||||
format!(
|
||||
"failed to open ipc parent dir for stale socket cleanup (no-follow): postfix={}, parent={}, err={}",
|
||||
postfix,
|
||||
"failed to open ipc parent dir for stale socket cleanup (no-follow): path={}, parent={}, err={}",
|
||||
path,
|
||||
parent_dir.display(),
|
||||
open_err
|
||||
),
|
||||
@@ -189,7 +205,11 @@ fn remove_ipc_socket_via_secure_parent_fd(postfix: &str) -> ResultType<()> {
|
||||
}
|
||||
};
|
||||
let _fd_guard = FdGuard(fd);
|
||||
remove_parent_entry_via_fd(fd, parent_dir, &format!("ipc{}", postfix))
|
||||
remove_parent_entry_via_fd(fd, parent_dir, &entry_name)
|
||||
}
|
||||
|
||||
fn remove_ipc_socket_via_secure_parent_fd(postfix: &str) -> ResultType<()> {
|
||||
remove_ipc_entry_via_secure_parent_fd(&config::Config::ipc_path(postfix))
|
||||
}
|
||||
|
||||
// Purpose:
|
||||
@@ -686,6 +706,64 @@ pub(crate) fn should_scrub_parent_entries_after_check_pid(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
// Pins the HELPER's contract, which is all `new_drm_listener` consists of at that line -- not
|
||||
// the call site itself. Binding the real `/tmp/<app>-service/ipc_drm` from a test would collide
|
||||
// with a live root service, so "the listener still calls this" is not covered here.
|
||||
#[test]
|
||||
fn test_remove_ipc_entry_via_secure_parent_fd_clears_an_empty_directory_squatter() {
|
||||
let unique = format!(
|
||||
"rustdesk-ipc-entry-remove-test-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos()
|
||||
);
|
||||
let base = std::env::temp_dir().join(unique);
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
let squatter = base.join("ipc_drm");
|
||||
std::fs::create_dir(&squatter).unwrap();
|
||||
|
||||
// Positive control for the defect this closes: `remove_file` is `unlink(2)` and cannot
|
||||
// remove a directory. That is why the listener could not clear one, and then failed to
|
||||
// bind over it. Without this line a passing test would prove nothing.
|
||||
assert!(
|
||||
std::fs::remove_file(&squatter).is_err(),
|
||||
"remove_file must fail on a directory, or this test is vacuous"
|
||||
);
|
||||
assert!(squatter.is_dir());
|
||||
|
||||
super::remove_ipc_entry_via_secure_parent_fd(squatter.to_string_lossy().as_ref()).unwrap();
|
||||
assert!(
|
||||
!squatter.exists(),
|
||||
"the fd-based removal picks AT_REMOVEDIR and clears it"
|
||||
);
|
||||
|
||||
// Idempotent: this runs before every bind, so a path that is already gone is not an error.
|
||||
super::remove_ipc_entry_via_secure_parent_fd(squatter.to_string_lossy().as_ref()).unwrap();
|
||||
|
||||
// The ORDINARY case, and the one the listener hits on every restart: a stale socket left by
|
||||
// the previous run, i.e. a regular file. Covered here because the other file-removal test
|
||||
// goes through `remove_parent_entry_via_fd` and the postfix path, not this entry point.
|
||||
std::fs::write(&squatter, b"stale").unwrap();
|
||||
super::remove_ipc_entry_via_secure_parent_fd(squatter.to_string_lossy().as_ref()).unwrap();
|
||||
assert!(!squatter.exists(), "a stale regular file is cleared too");
|
||||
|
||||
// And the documented limit, pinned so the doc cannot drift: AT_REMOVEDIR is rmdir(2), so a
|
||||
// NON-empty squatter is reported, not cleared. The caller logs that and carries on; nothing
|
||||
// here should ever start deleting a tree it did not create.
|
||||
std::fs::create_dir(&squatter).unwrap();
|
||||
std::fs::write(squatter.join("planted"), b"x").unwrap();
|
||||
assert!(
|
||||
super::remove_ipc_entry_via_secure_parent_fd(squatter.to_string_lossy().as_ref())
|
||||
.is_err(),
|
||||
"a non-empty directory must be reported, not silently left as success"
|
||||
);
|
||||
assert!(squatter.join("planted").exists(), "and not deleted");
|
||||
|
||||
std::fs::remove_dir_all(&base).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_write_pid_file_rejects_symlink() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
@@ -241,6 +241,14 @@ fn wait_response(
|
||||
Some(rendezvous_message::Union::PeerDiscovery(p)) => {
|
||||
last_recv_time = Instant::now();
|
||||
if p.cmd == "pong" {
|
||||
if !crate::common::is_valid_untrusted_peer_id(&p.id) {
|
||||
log::warn!(
|
||||
"Ignoring LAN discovery response from {} with invalid peer id",
|
||||
addr
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let local_mac = if try_get_ip_by_peer {
|
||||
if let Some(self_addr) = get_ipaddr_by_peer(&addr) {
|
||||
get_mac(&self_addr)
|
||||
|
||||
@@ -332,7 +332,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Relay Connection", "اتصال الوسيط"),
|
||||
("Secure Connection", "اتصال آمن"),
|
||||
("Insecure Connection", "اتصال غير آمن"),
|
||||
("Continue", ""),
|
||||
("Scale original", "المقياس الأصلي"),
|
||||
("Scale adaptive", "مقياس التكيف"),
|
||||
("General", "عام"),
|
||||
@@ -765,5 +764,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("All monitors", "جميع الشاشات"),
|
||||
("#{} monitor", "الشاشة رقم {}"),
|
||||
("conn-e2ee-unavailable-tip", "تعذر التحقق من التشفير من طرف إلى طرف.\nقد يكون الجهاز البعيد ما يزال قيد الإعداد. حاول مرة أخرى لاحقًا.\nإذا استمر حدوث ذلك، فقد يكون الخادم غير موثوق به.\nهل تريد المتابعة على أي حال؟"),
|
||||
("ID whitelisting", "القائمة البيضاء للمعرفات"),
|
||||
("Use ID whitelisting", "استخدام القائمة البيضاء للمعرفات"),
|
||||
("id_whitelist_tip", "فقط المعرفات في القائمة البيضاء تستطيع الوصول لي"),
|
||||
("id_whitelist_wildcard_tip", "أحرف البدل مدعومة: '*' يطابق أي عدد من الأحرف، '?' يطابق حرفاً واحداً بالضبط"),
|
||||
("Invalid ID", "معرف غير صحيح"),
|
||||
("Your ID is blocked by the peer", "تم حظر معرفك من قبل الطرف الآخر"),
|
||||
("Your ip is blocked by the peer", "تم حظر عنوان IP الخاص بك من قبل الطرف الآخر"),
|
||||
("id_whitelist_caveat_tip", "يتم الإبلاغ عن المعرف من قبل العميل المتصل. القائمة البيضاء تقلل من التعرض ولا تغني عن كلمة المرور أو 2FA"),
|
||||
("whitelist_cidr_tip", "يتم دعم صيغة CIDR، مثال: 192.168.1.0/24"),
|
||||
("Continue", ""),
|
||||
("Browser didn't open? Use the url below to sign in.", ""),
|
||||
("texture-render-fallback-tip", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -332,7 +332,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Relay Connection", "Рэтрансляванае падключэнне"),
|
||||
("Secure Connection", "Бяспечнае падключэнне"),
|
||||
("Insecure Connection", "Нябяспечнае падключэнне"),
|
||||
("Continue", ""),
|
||||
("Scale original", "Арыгінальны маштаб"),
|
||||
("Scale adaptive", "Адаптыўны маштаб"),
|
||||
("General", "Агульныя"),
|
||||
@@ -765,5 +764,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("All monitors", "Усе манітори"),
|
||||
("#{} monitor", "Манітор {}"),
|
||||
("conn-e2ee-unavailable-tip", "Не ўдалося праверыць скразное шыфраванне.\nАддаленая прылада, магчыма, яшчэ наладжваецца. Паспрабуйце пазней.\nКалі гэта будзе паўтарацца, сервер можа быць ненадзейным.\nУсё роўна працягнуць?"),
|
||||
("ID whitelisting", "Спіс дазволеных ID"),
|
||||
("Use ID whitelisting", "Выкарыстоўваць белы спіс ID"),
|
||||
("id_whitelist_tip", "Атрымліваць доступ да маёй прылады могуць толькі ID з белага спісу."),
|
||||
("id_whitelist_wildcard_tip", "Падтрымліваюцца падстаноўныя знакі: '*' адпавядае любой колькасці сімвалаў, '?' — роўна аднаму сімвалу"),
|
||||
("Invalid ID", "Няправільны ID"),
|
||||
("Your ID is blocked by the peer", "Ваш ID заблакаваны аддаленай прыладай"),
|
||||
("Your ip is blocked by the peer", "Ваш IP-адрас заблакаваны аддаленай прыладай"),
|
||||
("id_whitelist_caveat_tip", "ID паведамляецца кліентам, які падключаецца. Белы спіс памяншае паверхню атакі і не замяняе пароль або 2FA"),
|
||||
("whitelist_cidr_tip", "Падтрымліваецца натацыя CIDR, напрыклад: 192.168.1.0/24"),
|
||||
("Continue", ""),
|
||||
("Browser didn't open? Use the url below to sign in.", ""),
|
||||
("texture-render-fallback-tip", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -332,7 +332,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Relay Connection", "Релейна връзка"),
|
||||
("Secure Connection", "Сигурна връзка"),
|
||||
("Insecure Connection", "Несигурна връзка"),
|
||||
("Continue", ""),
|
||||
("Scale original", "Оригинален мащаб"),
|
||||
("Scale adaptive", "Приспособимо мащабиране"),
|
||||
("General", "Основен"),
|
||||
@@ -765,5 +764,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("All monitors", "Всички монитори"),
|
||||
("#{} monitor", "Монитор {}"),
|
||||
("conn-e2ee-unavailable-tip", "Шифроването от край до край не може да бъде проверено.\nОтдалеченото устройство може все още да се настройва. Опитайте отново по-късно.\nАко това продължи, сървърът може да не е надежден.\nДа се продължи ли въпреки това?"),
|
||||
("ID whitelisting", "Позволени ID"),
|
||||
("Use ID whitelisting", "Използване бял списък с ID"),
|
||||
("id_whitelist_tip", "Само ID от белия списък имат достъп до мен"),
|
||||
("id_whitelist_wildcard_tip", "Поддържат се заместващи символи: '*' съответства на произволен брой знаци, '?' — на точно един знак"),
|
||||
("Invalid ID", "Невалидно ID"),
|
||||
("Your ID is blocked by the peer", "Вашето ID е блокирано от отсрещната страна"),
|
||||
("Your ip is blocked by the peer", "Вашият IP адрес е блокиран от отсрещната страна"),
|
||||
("id_whitelist_caveat_tip", "ID се съобщава от свързващия се клиент. Белият списък намалява изложеността и не замества паролата или 2FA"),
|
||||
("whitelist_cidr_tip", "Поддържа се CIDR нотация, например: 192.168.1.0/24"),
|
||||
("Continue", ""),
|
||||
("Browser didn't open? Use the url below to sign in.", ""),
|
||||
("texture-render-fallback-tip", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -332,7 +332,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Relay Connection", "Connexió amb repetidor"),
|
||||
("Secure Connection", "Connexió segura"),
|
||||
("Insecure Connection", "Connexió no segura"),
|
||||
("Continue", ""),
|
||||
("Scale original", "Escala original"),
|
||||
("Scale adaptive", "Escala adaptativa"),
|
||||
("General", "General"),
|
||||
@@ -765,5 +764,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("All monitors", "Tots els monitors"),
|
||||
("#{} monitor", "Monitor {}"),
|
||||
("conn-e2ee-unavailable-tip", "No s'ha pogut verificar el xifratge d'extrem a extrem.\nEl dispositiu remot encara es pot estar configurant. Torneu-ho a provar més tard.\nSi això continua passant, el servidor pot no ser de confiança.\nVoleu continuar igualment?"),
|
||||
("ID whitelisting", "ID admesos"),
|
||||
("Use ID whitelisting", "Utilitza un llistat d'ID admesos"),
|
||||
("id_whitelist_tip", "Només els ID admesos es podran connectar"),
|
||||
("id_whitelist_wildcard_tip", "S'admeten comodins: '*' coincideix amb qualsevol nombre de caràcters, '?' amb un sol caràcter"),
|
||||
("Invalid ID", "ID no vàlid"),
|
||||
("Your ID is blocked by the peer", "El vostre ID està bloquejat per l'altre extrem"),
|
||||
("Your ip is blocked by the peer", "La vostra IP està bloquejada per l'altre extrem"),
|
||||
("id_whitelist_caveat_tip", "L'ID és informat pel client que es connecta. Aquesta llista blanca redueix l'exposició i no substitueix la contrasenya ni la 2FA"),
|
||||
("whitelist_cidr_tip", "S'admet la notació CIDR, per exemple 192.168.1.0/24"),
|
||||
("Continue", ""),
|
||||
("Browser didn't open? Use the url below to sign in.", ""),
|
||||
("texture-render-fallback-tip", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -332,7 +332,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Relay Connection", "中继连接"),
|
||||
("Secure Connection", "安全连接"),
|
||||
("Insecure Connection", "非安全连接"),
|
||||
("Continue", ""),
|
||||
("Scale original", "原始尺寸"),
|
||||
("Scale adaptive", "适应窗口"),
|
||||
("General", "常规"),
|
||||
@@ -765,5 +764,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("All monitors", "所有显示器"),
|
||||
("#{} monitor", "{}号显示器"),
|
||||
("conn-e2ee-unavailable-tip", "无法验证端到端加密。\n远程设备可能仍在准备中,请稍后重试。\n如果此问题持续出现,服务器可能不受信任。\n仍要继续吗?"),
|
||||
("ID whitelisting", "ID 白名单"),
|
||||
("Use ID whitelisting", "只允许白名单上的 ID 访问"),
|
||||
("id_whitelist_tip", "只有白名单里的 ID 才能访问本机"),
|
||||
("id_whitelist_wildcard_tip", "支持通配符:'*' 匹配任意数量的字符,'?' 匹配单个字符"),
|
||||
("Invalid ID", "无效 ID"),
|
||||
("Your ID is blocked by the peer", "你的 ID 已被对方阻止"),
|
||||
("Your ip is blocked by the peer", "你的 IP 已被对方阻止"),
|
||||
("id_whitelist_caveat_tip", "ID 由对端客户端上报,白名单用于减少暴露面,不能替代密码或 2FA"),
|
||||
("whitelist_cidr_tip", "支持 CIDR 写法,例如 192.168.1.0/24"),
|
||||
("Continue", "继续"),
|
||||
("Browser didn't open? Use the url below to sign in.", ""),
|
||||
("texture-render-fallback-tip", "纹理渲染失效,已自动切换为软件渲染。"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -332,7 +332,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Relay Connection", "Připojení předávací server"),
|
||||
("Secure Connection", "Zabezpečené připojení"),
|
||||
("Insecure Connection", "Nezabezpečené připojení"),
|
||||
("Continue", ""),
|
||||
("Scale original", "Originální měřítko"),
|
||||
("Scale adaptive", "Adaptivní měřítko"),
|
||||
("General", "Obecné"),
|
||||
@@ -765,5 +764,17 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("All monitors", "Všechny monitory"),
|
||||
("#{} monitor", "Monitor č. {}"),
|
||||
("conn-e2ee-unavailable-tip", "Nepodařilo se ověřit koncové šifrování.\nVzdálené zařízení se možná stále nastavuje. Zkuste to znovu později.\nPokud se to bude opakovat, server nemusí být důvěryhodný.\nPřesto pokračovat?"),
|
||||
("ID whitelisting", "Povolování pouze z daných ID"),
|
||||
("Use ID whitelisting", "Použít bílou listinu ID"),
|
||||
("id_whitelist_tip", "Přístup je umožněn pouze z ID, nacházejících se na seznamu povolených"),
|
||||
("id_whitelist_wildcard_tip", "Jsou podporovány zástupné znaky: '*' odpovídá libovolnému počtu znaků, '?' právě jednomu znaku"),
|
||||
("Invalid ID", "Neplatné ID"),
|
||||
("Your ID is blocked by the peer", "Vaše ID je protistranou blokováno"),
|
||||
("Your ip is blocked by the peer", "Vaše IP adresa je protistranou blokována"),
|
||||
("id_whitelist_caveat_tip", "ID je hlášeno připojujícím se klientem. Tento seznam snižuje vystavení a nenahrazuje heslo ani 2FA"),
|
||||
("whitelist_cidr_tip", "Je podporován zápis CIDR, například 192.168.1.0/24"),
|
||||
("Continue", ""),
|
||||
("Browser didn't open? Use the url below to sign in.", ""),
|
||||
("texture-render-fallback-tip", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user