mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-09-10 14:31:02 +03:00
Compare commits
91 Commits
1.4.9
...
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 | ||
|
|
12b5cc7f72 | ||
|
|
480e9e8234 | ||
|
|
29e1852a68 | ||
|
|
8314335b31 | ||
|
|
acb9f63e1d | ||
|
|
005a8b4a04 | ||
|
|
e2149974cc |
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
|
||||
85
.github/workflows/wf-cliprdr-ci.yml
vendored
85
.github/workflows/wf-cliprdr-ci.yml
vendored
@@ -1,85 +0,0 @@
|
||||
name: wf-cliprdr CI
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
paths:
|
||||
- "libs/clipboard/src/windows/**"
|
||||
- "tests/test_invariant_wf_cliprdr.c"
|
||||
- ".github/workflows/wf-cliprdr-ci.yml"
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- "libs/clipboard/src/windows/**"
|
||||
- "tests/test_invariant_wf_cliprdr.c"
|
||||
- ".github/workflows/wf-cliprdr-ci.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: wf_cliprdr invariant test
|
||||
runs-on: windows-2022
|
||||
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up MSVC
|
||||
uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756
|
||||
with:
|
||||
arch: x64
|
||||
|
||||
- name: Setup vcpkg with GitHub Actions binary cache
|
||||
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
|
||||
with:
|
||||
vcpkgDirectory: C:\vcpkg
|
||||
doNotCache: false
|
||||
|
||||
- name: Install vcpkg dependency
|
||||
shell: pwsh
|
||||
run: |
|
||||
& "$env:VCPKG_ROOT\vcpkg.exe" install check:x64-windows --classic --x-install-root="$env:VCPKG_ROOT\installed"
|
||||
|
||||
- name: Build test
|
||||
shell: pwsh
|
||||
run: |
|
||||
$testRoot = Join-Path $env:GITHUB_WORKSPACE 'build\wf-cliprdr'
|
||||
New-Item -ItemType Directory -Force $testRoot | Out-Null
|
||||
|
||||
$testSource = (($env:GITHUB_WORKSPACE -replace '\\', '/') + '/tests/test_invariant_wf_cliprdr.c')
|
||||
$cmakeLists = @(
|
||||
'cmake_minimum_required(VERSION 3.20)'
|
||||
'project(test_invariant_wf_cliprdr C)'
|
||||
''
|
||||
'set(CMAKE_C_STANDARD 11)'
|
||||
'set(CMAKE_C_STANDARD_REQUIRED ON)'
|
||||
'set(CMAKE_C_EXTENSIONS OFF)'
|
||||
''
|
||||
'find_package(check CONFIG REQUIRED)'
|
||||
''
|
||||
'add_executable(test_invariant_wf_cliprdr'
|
||||
' "TEST_SOURCE"'
|
||||
')'
|
||||
''
|
||||
'target_link_libraries(test_invariant_wf_cliprdr PRIVATE'
|
||||
' $<$<TARGET_EXISTS:Check::check>:Check::check>'
|
||||
' $<$<NOT:$<TARGET_EXISTS:Check::check>>:Check::checkShared>'
|
||||
')'
|
||||
) -join [Environment]::NewLine
|
||||
$cmakeLists.Replace('TEST_SOURCE', $testSource) | Set-Content -NoNewline (Join-Path $testRoot 'CMakeLists.txt')
|
||||
|
||||
cmake -S $testRoot -B (Join-Path $testRoot 'out') -G "Visual Studio 17 2022" -A x64 -DCMAKE_TOOLCHAIN_FILE="$env:VCPKG_ROOT\scripts\buildsystems\vcpkg.cmake" -DVCPKG_TARGET_TRIPLET=x64-windows
|
||||
cmake --build (Join-Path $testRoot 'out') --config Release
|
||||
|
||||
- name: Run test
|
||||
shell: pwsh
|
||||
run: .\build\wf-cliprdr\out\Release\test_invariant_wf_cliprdr.exe
|
||||
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
|
||||
|
||||
@@ -6,83 +6,82 @@ ANDROID_ABI=$1
|
||||
|
||||
# Build RustDesk dependencies for Android using vcpkg.json
|
||||
# Required:
|
||||
# 1. set VCPKG_ROOT / ANDROID_NDK path environment variables
|
||||
# 1. set VCPKG_ROOT / ANDROID_NDK_HOME path environment variables
|
||||
# 2. vcpkg initialized
|
||||
# 3. ndk, version: r25c or newer
|
||||
|
||||
if [ -z "$ANDROID_NDK_HOME" ]; then
|
||||
echo "Failed! Please set ANDROID_NDK_HOME"
|
||||
exit 1
|
||||
if [ -z "${ANDROID_NDK_HOME}" ]; then
|
||||
echo "ERROR: Please set ANDROID_NDK_HOME environment variable" 1>&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$VCPKG_ROOT" ]; then
|
||||
echo "Failed! Please set VCPKG_ROOT"
|
||||
exit 1
|
||||
if [ -z "${VCPKG_ROOT}" ]; then
|
||||
echo "ERROR: Please set VCPKG_ROOT environment variable" 1>&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
API_LEVEL="21"
|
||||
case "${ANDROID_ABI}" in
|
||||
arm64-v8a)
|
||||
VCPKG_TARGET=arm64-android
|
||||
;;
|
||||
armeabi-v7a)
|
||||
VCPKG_TARGET=arm-neon-android
|
||||
;;
|
||||
x86_64)
|
||||
VCPKG_TARGET=x64-android
|
||||
;;
|
||||
x86)
|
||||
VCPKG_TARGET=x86-android
|
||||
;;
|
||||
*)
|
||||
echo "Usage: build_android_deps.sh <arm64-v8a|armeabi-v7a|x86_64|x86>" 1>&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Get directory of this script
|
||||
|
||||
SCRIPTDIR="$(readlink -f "$0")"
|
||||
SCRIPTDIR="$(dirname "$SCRIPTDIR")"
|
||||
SCRIPTDIR="$(dirname "${SCRIPTDIR}")"
|
||||
|
||||
# Check if vcpkg.json is one level up - in root directory of RD
|
||||
|
||||
if [ ! -f "$SCRIPTDIR/../vcpkg.json" ]; then
|
||||
echo "Failed! Please check where vcpkg.json is!"
|
||||
exit 1
|
||||
if [ ! -f "${SCRIPTDIR}/../vcpkg.json" ]; then
|
||||
echo "ERROR: Can not find vcpkg.json in RustDesk top-level directory" 1>&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# NDK llvm toolchain
|
||||
echo "INFO: Building and install vcpkg dependencies for Android ${ANDROID_ABI} ..."
|
||||
|
||||
HOST_TAG="linux-x86_64" # current platform, set as `ls $ANDROID_NDK/toolchains/llvm/prebuilt/`
|
||||
TOOLCHAIN=$ANDROID_NDK/toolchains/llvm/prebuilt/$HOST_TAG
|
||||
pushd "${SCRIPTDIR}/.."
|
||||
|
||||
function build {
|
||||
ANDROID_ABI=$1
|
||||
"${VCPKG_ROOT}/vcpkg" install \
|
||||
--triplet "${VCPKG_TARGET}" \
|
||||
--x-install-root="${VCPKG_ROOT}/installed"
|
||||
|
||||
case "$ANDROID_ABI" in
|
||||
arm64-v8a)
|
||||
ABI=aarch64-linux-android$API_LEVEL
|
||||
VCPKG_TARGET=arm64-android
|
||||
;;
|
||||
armeabi-v7a)
|
||||
ABI=armv7a-linux-androideabi$API_LEVEL
|
||||
VCPKG_TARGET=arm-neon-android
|
||||
;;
|
||||
x86_64)
|
||||
ABI=x86_64-linux-android$API_LEVEL
|
||||
VCPKG_TARGET=x64-android
|
||||
;;
|
||||
x86)
|
||||
ABI=i686-linux-android$API_LEVEL
|
||||
VCPKG_TARGET=x86-android
|
||||
;;
|
||||
*)
|
||||
echo "ERROR: ANDROID_ABI must be one of: arm64-v8a, armeabi-v7a, x86_64, x86" >&2
|
||||
return 1
|
||||
esac
|
||||
popd
|
||||
|
||||
echo "*** [$ANDROID_ABI][Start] Build and install vcpkg dependencies"
|
||||
pushd "$SCRIPTDIR/.."
|
||||
$VCPKG_ROOT/vcpkg install --triplet $VCPKG_TARGET --x-install-root="$VCPKG_ROOT/installed"
|
||||
popd
|
||||
head -n 100 "${VCPKG_ROOT}/buildtrees/ffmpeg/build-$VCPKG_TARGET-rel-out.log" || true
|
||||
echo "*** [$ANDROID_ABI][Finished] Build and install vcpkg dependencies"
|
||||
echo "INFO: Completed building vcpkg dependencies for Android ${ANDROID_ABI}"
|
||||
|
||||
if [ -d "$VCPKG_ROOT/installed/arm-neon-android" ]; then
|
||||
echo "*** [Start] Move arm-neon-android to arm-android"
|
||||
if [ "${ANDROID_ABI}" = 'armeabi-v7a' ]; then
|
||||
# Symlink arm-neon-android to arm-android because cargo-ndk does not
|
||||
# understand NEON suffix.
|
||||
|
||||
mv "$VCPKG_ROOT/installed/arm-neon-android" "$VCPKG_ROOT/installed/arm-android"
|
||||
if [ -d "${VCPKG_ROOT}/installed/arm-neon-android" ]; then
|
||||
echo 'INFO: Symlinking arm-neon-android to arm-android'
|
||||
|
||||
echo "*** [Finished] Move arm-neon-android to arm-android"
|
||||
fi
|
||||
}
|
||||
|
||||
if [ ! -z "$ANDROID_ABI" ]; then
|
||||
build "$ANDROID_ABI"
|
||||
else
|
||||
echo "Usage: build-android-deps.sh <ANDROID-ABI>" >&2
|
||||
exit 1
|
||||
ln -sf \
|
||||
"${VCPKG_ROOT}/installed/arm-neon-android" \
|
||||
"${VCPKG_ROOT}/installed/arm-android"
|
||||
|
||||
echo 'INFO: Symlinked arm-neon-android to arm-android'
|
||||
else
|
||||
cat 0<<.a
|
||||
ERROR: 'vcpkg install' seem to complete successfully but
|
||||
directory '${VCPKG_ROOT}/installed/arm-neon-android' is missing!
|
||||
|
||||
.a
|
||||
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
7
res/vcpkg-triplets/arm-neon-android.cmake
Normal file
7
res/vcpkg-triplets/arm-neon-android.cmake
Normal file
@@ -0,0 +1,7 @@
|
||||
set(VCPKG_TARGET_ARCHITECTURE arm)
|
||||
set(VCPKG_CRT_LINKAGE dynamic)
|
||||
set(VCPKG_LIBRARY_LINKAGE static)
|
||||
set(VCPKG_CMAKE_SYSTEM_NAME Android)
|
||||
set(VCPKG_CMAKE_SYSTEM_VERSION 21)
|
||||
set(VCPKG_MAKE_BUILD_TRIPLET "--host=armv7a-linux-androideabi")
|
||||
set(VCPKG_CMAKE_CONFIGURE_OPTIONS -DANDROID_ABI=armeabi-v7a -DANDROID_ARM_NEON=ON)
|
||||
7
res/vcpkg-triplets/arm64-android.cmake
Normal file
7
res/vcpkg-triplets/arm64-android.cmake
Normal file
@@ -0,0 +1,7 @@
|
||||
set(VCPKG_TARGET_ARCHITECTURE arm64)
|
||||
set(VCPKG_CRT_LINKAGE dynamic)
|
||||
set(VCPKG_LIBRARY_LINKAGE static)
|
||||
set(VCPKG_CMAKE_SYSTEM_NAME Android)
|
||||
set(VCPKG_CMAKE_SYSTEM_VERSION 21)
|
||||
set(VCPKG_MAKE_BUILD_TRIPLET "--host=aarch64-linux-android")
|
||||
set(VCPKG_CMAKE_CONFIGURE_OPTIONS -DANDROID_ABI=arm64-v8a)
|
||||
7
res/vcpkg-triplets/x64-android.cmake
Normal file
7
res/vcpkg-triplets/x64-android.cmake
Normal file
@@ -0,0 +1,7 @@
|
||||
set(VCPKG_TARGET_ARCHITECTURE x64)
|
||||
set(VCPKG_CRT_LINKAGE dynamic)
|
||||
set(VCPKG_LIBRARY_LINKAGE static)
|
||||
set(VCPKG_CMAKE_SYSTEM_NAME Android)
|
||||
set(VCPKG_CMAKE_SYSTEM_VERSION 21)
|
||||
set(VCPKG_MAKE_BUILD_TRIPLET "--host=x86_64-linux-android")
|
||||
set(VCPKG_CMAKE_CONFIGURE_OPTIONS -DANDROID_ABI=x86_64)
|
||||
7
res/vcpkg-triplets/x86-android.cmake
Normal file
7
res/vcpkg-triplets/x86-android.cmake
Normal file
@@ -0,0 +1,7 @@
|
||||
set(VCPKG_TARGET_ARCHITECTURE x86)
|
||||
set(VCPKG_CRT_LINKAGE dynamic)
|
||||
set(VCPKG_LIBRARY_LINKAGE static)
|
||||
set(VCPKG_CMAKE_SYSTEM_NAME Android)
|
||||
set(VCPKG_CMAKE_SYSTEM_VERSION 21)
|
||||
set(VCPKG_MAKE_BUILD_TRIPLET "--host=i686-linux-android")
|
||||
set(VCPKG_CMAKE_CONFIGURE_OPTIONS -DANDROID_ABI=x86)
|
||||
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,
|
||||
|
||||
@@ -9,8 +9,8 @@ mod http_client;
|
||||
pub mod record_upload;
|
||||
pub mod sync;
|
||||
pub use http_client::{
|
||||
create_http_client_async, create_http_client_async_with_url, create_http_client_with_url,
|
||||
get_url_for_tls,
|
||||
create_http_client_async, create_http_client_async_with_url_strict,
|
||||
create_http_client_with_url, create_http_client_with_url_strict, get_url_for_tls,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::create_http_client_async_with_url;
|
||||
use super::create_http_client_async_with_url_strict;
|
||||
use hbb_common::{
|
||||
bail,
|
||||
lazy_static::lazy_static,
|
||||
@@ -167,7 +167,7 @@ async fn do_download(
|
||||
auto_del_dur: Option<Duration>,
|
||||
mut rx_cancel: UnboundedReceiver<()>,
|
||||
) -> ResultType<bool> {
|
||||
let client = create_http_client_async_with_url(&url).await;
|
||||
let client = create_http_client_async_with_url_strict(&url).await?;
|
||||
|
||||
let mut is_all_downloaded = false;
|
||||
tokio::select! {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use hbb_common::{
|
||||
async_recursion::async_recursion,
|
||||
bail,
|
||||
config::{Config, Socks5Server},
|
||||
log::{self, info},
|
||||
proxy::{Proxy, ProxyScheme},
|
||||
@@ -7,6 +8,7 @@ use hbb_common::{
|
||||
get_cached_tls_accept_invalid_cert, get_cached_tls_type, is_plain, upsert_tls_cache,
|
||||
TlsType,
|
||||
},
|
||||
ResultType,
|
||||
};
|
||||
use reqwest::{blocking::Client as SyncClient, Client as AsyncClient};
|
||||
|
||||
@@ -137,6 +139,32 @@ pub fn create_http_client_with_url(url: &str) -> SyncClient {
|
||||
)
|
||||
}
|
||||
|
||||
pub fn create_http_client_with_url_strict(url: &str) -> ResultType<SyncClient> {
|
||||
let parsed_url = url::Url::parse(url)?;
|
||||
if parsed_url.scheme() != "https" {
|
||||
bail!("Strict HTTP client requires HTTPS: {}", url);
|
||||
}
|
||||
let proxy_conf = Config::get_socks();
|
||||
let tls_url = get_url_for_tls(url, &proxy_conf);
|
||||
let cached_tls_type = get_cached_tls_type(tls_url);
|
||||
let cached_danger_accept_invalid_cert = get_cached_tls_accept_invalid_cert(tls_url);
|
||||
let can_reuse_cached_probe =
|
||||
cached_tls_type.is_some() && cached_danger_accept_invalid_cert == Some(false);
|
||||
let tls_type = if can_reuse_cached_probe {
|
||||
cached_tls_type.unwrap_or(TlsType::Rustls)
|
||||
} else {
|
||||
TlsType::Rustls
|
||||
};
|
||||
Ok(create_http_client_with_url_(
|
||||
url,
|
||||
tls_url,
|
||||
tls_type,
|
||||
can_reuse_cached_probe,
|
||||
Some(false),
|
||||
Some(false),
|
||||
))
|
||||
}
|
||||
|
||||
fn create_http_client_with_url_(
|
||||
url: &str,
|
||||
tls_url: &str,
|
||||
@@ -247,6 +275,33 @@ pub async fn create_http_client_async_with_url(url: &str) -> AsyncClient {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create_http_client_async_with_url_strict(url: &str) -> ResultType<AsyncClient> {
|
||||
let parsed_url = url::Url::parse(url)?;
|
||||
if parsed_url.scheme() != "https" {
|
||||
bail!("Strict HTTP client requires HTTPS: {}", url);
|
||||
}
|
||||
let proxy_conf = Config::get_socks();
|
||||
let tls_url = get_url_for_tls(url, &proxy_conf);
|
||||
let cached_tls_type = get_cached_tls_type(tls_url);
|
||||
let cached_danger_accept_invalid_cert = get_cached_tls_accept_invalid_cert(tls_url);
|
||||
let can_reuse_cached_probe =
|
||||
cached_tls_type.is_some() && cached_danger_accept_invalid_cert == Some(false);
|
||||
let tls_type = if can_reuse_cached_probe {
|
||||
cached_tls_type.unwrap_or(TlsType::Rustls)
|
||||
} else {
|
||||
TlsType::Rustls
|
||||
};
|
||||
Ok(create_http_client_async_with_url_(
|
||||
url,
|
||||
tls_url,
|
||||
tls_type,
|
||||
can_reuse_cached_probe,
|
||||
Some(false),
|
||||
Some(false),
|
||||
)
|
||||
.await)
|
||||
}
|
||||
|
||||
#[async_recursion]
|
||||
async fn create_http_client_async_with_url_(
|
||||
url: &str,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user