Merge branch 'master' into custom-client-no-rebuild

This commit is contained in:
fufesou
2026-09-02 13:46:42 +08:00
236 changed files with 14237 additions and 9328 deletions

View File

@@ -1,11 +0,0 @@
version: 2
updates:
- package-ecosystem: "gitsubmodule"
directory: "/"
target-branch: "master"
schedule:
interval: "daily"
commit-message:
prefix: "Git submodule"
labels:
- "dependencies"

View File

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

View 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."

View File

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

View File

@@ -5,7 +5,7 @@ env:
# CICD_INTERMEDIATES_DIR: "_cicd-intermediates"
VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite"
# for multiarch gcc compatibility
VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b"
VCPKG_COMMIT_ID: "9e593bb18ea69cc5095e012465dcd675a822ed0d"
on:
workflow_dispatch:
@@ -124,7 +124,6 @@ jobs:
gcc \
git \
g++ \
libpam0g-dev \
libasound2-dev \
libunwind-dev \
libgstreamer1.0-dev \

View File

@@ -31,20 +31,20 @@ 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.9"
# for arm64 linux because official Dart SDK does not work
FLUTTER_ELINUX_VERSION: "3.16.9"
TAG_NAME: "${{ inputs.upload-tag }}"
VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite"
# vcpkg version: 2025.08.27
# vcpkg version: 2026.07.29
# If we change the `VCPKG COMMIT_ID`, please remember:
# 1. Call `$VCPKG_ROOT/vcpkg x-update-baseline` to update the baseline in `vcpkg.json`.
# Or we may face build issue like
# https://github.com/rustdesk/rustdesk/actions/runs/14414119794/job/40427970174
# 2. Update the `VCPKG_COMMIT_ID` in `ci.yml` and `playground.yml`.
VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b"
VCPKG_COMMIT_ID: "9e593bb18ea69cc5095e012465dcd675a822ed0d"
ARMV7_VCPKG_COMMIT_ID: "6f29f12e82a8293156836ad81cc9bf5af41fe836" # 2025.01.13, got "/opt/artifacts/vcpkg/vcpkg: No such file or directory" with latest version
VERSION: "1.4.9"
VERSION: "1.5.0"
NDK_VERSION: "r28c"
#signing keys env variable checks
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"
@@ -224,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
@@ -643,7 +645,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
@@ -822,7 +826,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
@@ -1069,7 +1075,6 @@ jobs:
libgstreamer1.0-dev \
libgstreamer-plugins-base1.0-dev \
libgtk-3-dev \
libpam0g-dev \
libpulse-dev \
libva-dev \
libxcb-randr0-dev \
@@ -1099,7 +1104,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
@@ -1341,7 +1348,6 @@ jobs:
libgstreamer1.0-dev \
libgstreamer-plugins-base1.0-dev \
libgtk-3-dev \
libpam0g-dev \
libpulse-dev \
libva-dev \
libxcb-randr0-dev \
@@ -1371,7 +1377,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
@@ -1630,7 +1638,6 @@ jobs:
libgstreamer1.0-dev \
libgstreamer-plugins-base1.0-dev \
libgtk-3-dev \
libpam0g-dev \
libpulse-dev \
libva-dev \
libxcb-randr0-dev \
@@ -1815,6 +1822,275 @@ 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 \
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 }}
@@ -1912,7 +2188,6 @@ jobs:
libgstreamer-plugins-base1.0-dev \
libgtk-3-dev \
liblzma-dev \
libpam0g-dev \
libpulse-dev \
libva-dev \
libxcb-randr0-dev \
@@ -1989,7 +2264,7 @@ jobs:
mkdir -p ~/.cargo/
echo """
[source.crates-io]
registry = 'https://github.com/rust-lang/crates.io-index'
registry = 'sparse+https://index.crates.io/'
""" > ~/.cargo/config
cat ~/.cargo/config
# install dependencies from vcpkg
@@ -2209,7 +2484,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

View File

@@ -16,8 +16,8 @@ env:
FLUTTER_ELINUX_VERSION: "3.16.9"
TAG_NAME: "nightly"
VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite"
VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b"
VERSION: "1.4.9"
VCPKG_COMMIT_ID: "9e593bb18ea69cc5095e012465dcd675a822ed0d"
VERSION: "1.5.0"
NDK_VERSION: "r26d"
#signing keys env variable checks
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"
@@ -271,7 +271,6 @@ jobs:
libgstreamer1.0-dev \
libgstreamer-plugins-base1.0-dev \
libgtk-3-dev \
libpam0g-dev \
libpulse-dev \
libva-dev \
libvdpau-dev \
@@ -284,7 +283,7 @@ jobs:
nasm \
yasm \
ninja-build \
openjdk-11-jdk-headless \
openjdk-17-jdk-headless \
pkg-config \
tree \
wget
@@ -366,9 +365,9 @@ jobs:
- name: Build rustdesk
shell: bash
env:
JAVA_HOME: /usr/lib/jvm/java-11-openjdk-amd64
JAVA_HOME: /usr/lib/jvm/java-17-openjdk-amd64
run: |
export PATH=/usr/lib/jvm/java-11-openjdk-amd64/bin:$PATH
export PATH=/usr/lib/jvm/java-17-openjdk-amd64/bin:$PATH
# temporary use debug sign config
sed -i "s/signingConfigs.release/signingConfigs.debug/g" ./flutter/android/app/build.gradle
case ${{ matrix.job.target }} in

View File

@@ -33,6 +33,10 @@ jobs:
steps:
- name: Checkout source code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
# The root workspace lists libs/hbb_common as a member; without the
# submodule its manifest is missing and cargo cannot load the workspace.
submodules: recursive
- name: Update webpki-roots in all lockfiles
id: update

4
.gitignore vendored
View File

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

View File

@@ -61,12 +61,26 @@
* Do not make formatting-only changes.
* Keep naming/style consistent with nearby code.
### Comments
* Avoid comments unless they explain a non-obvious reason, constraint, or workaround.
* Never restate what the code does; prefer clearer code instead.
* If the code is self-explanatory, add no comment.
### 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).
* Accept a little duplication over a restructure. A new function that repeats a few lines of an existing one is a better diff than reshaping the original so both can share it.
* 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.
## Reviewing a PR
* Review only what the diff introduces. Verify ownership with `gh pr diff` before reporting a finding — if the offending lines are untouched context, it is a pre-existing problem, not this PR's.
* List pre-existing problems in a separate section at the end, or leave out the ones that are not fatal. Never mix them into the findings the author has to fix.
* Before re-reviewing, read the author's reply comments. Do not re-raise items they declined on scope grounds.
* State a finding's consequence exactly: distinguish "the value is lost" from "the shortcut is inert but the value still saves".
## Localization (`src/lang/*.rs`)
Each file is a `HashMap<key, translation>`. Layout:

View File

@@ -1 +1 @@
AGENTS.md
@AGENTS.md

153
Cargo.lock generated
View File

@@ -986,27 +986,6 @@ dependencies = [
"serde 1.0.228",
]
[[package]]
name = "bzip2"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8"
dependencies = [
"bzip2-sys",
"libc",
]
[[package]]
name = "bzip2-sys"
version = "0.1.11+1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "736a955f3fa7875102d57c82b8cac37ec45224a07fd32d58f9f7a186b6cd4cdc"
dependencies = [
"cc",
"libc",
"pkg-config",
]
[[package]]
name = "cacao"
version = "0.4.0-beta2"
@@ -1477,8 +1456,8 @@ dependencies = [
"compression-core",
"flate2",
"memchr",
"zstd 0.13.1",
"zstd-safe 7.1.0",
"zstd",
"zstd-safe",
]
[[package]]
@@ -1549,12 +1528,6 @@ dependencies = [
"unicode-xid 0.2.4",
]
[[package]]
name = "constant_time_eq"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc"
[[package]]
name = "constant_time_eq"
version = "0.2.6"
@@ -3074,9 +3047,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",
@@ -3819,14 +3791,14 @@ dependencies = [
"toml 0.7.8",
"tungstenite",
"url",
"users 0.11.0",
"users",
"uuid",
"webpki-roots 1.0.9",
"webrtc",
"whoami",
"winapi 0.3.9",
"x11 2.21.0",
"zstd 0.13.1",
"zstd",
]
[[package]]
@@ -5960,37 +5932,6 @@ dependencies = [
"winapi 0.3.9",
]
[[package]]
name = "pam"
version = "0.7.0"
source = "git+https://github.com/rustdesk-org/pam#7bfd25510202cd269292cbdd7c71f3977a6fd762"
dependencies = [
"libc",
"pam-macros",
"pam-sys",
"users 0.10.0",
]
[[package]]
name = "pam-macros"
version = "0.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c94f3b9b97df3c6d4e51a14916639b24e02c7d15d1dba686ce9b1118277cb811"
dependencies = [
"proc-macro2 1.0.93",
"quote 1.0.36",
"syn 1.0.109",
]
[[package]]
name = "pam-sys"
version = "1.0.0-alpha4"
source = "git+https://github.com/rustdesk-org/pam-sys?branch=fix/v1.0.0-alpha4_gnuc_va_list#3337c9bb9a9c68d7497ec8c93cad2368c26091b7"
dependencies = [
"bindgen 0.59.2",
"libc",
]
[[package]]
name = "pango"
version = "0.18.3"
@@ -6058,35 +5999,12 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "password-hash"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700"
dependencies = [
"base64ct",
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pbkdf2"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917"
dependencies = [
"digest",
"hmac",
"password-hash",
"sha2",
]
[[package]]
name = "peeking_take_while"
version = "0.1.2"
@@ -7259,7 +7177,7 @@ dependencies = [
[[package]]
name = "rustdesk"
version = "1.4.9"
version = "1.5.0"
dependencies = [
"android-wakelock",
"android_logger",
@@ -7317,7 +7235,6 @@ dependencies = [
"once_cell",
"openssl",
"os-version",
"pam",
"parity-tokio-ipc",
"percent-encoding",
"piet",
@@ -7366,12 +7283,11 @@ dependencies = [
"wol-rs",
"x11-clipboard 0.8.1",
"x11rb 0.12.0",
"zip",
]
[[package]]
name = "rustdesk-portable-packer"
version = "1.4.9"
version = "1.5.0"
dependencies = [
"brotli",
"dirs 5.0.1",
@@ -8908,7 +8824,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c4ae9724c5888c0417d2396037ed3b60665925624766416e3e342b6ba5dbd3f"
dependencies = [
"base32",
"constant_time_eq 0.2.6",
"constant_time_eq",
"hmac",
"rand 0.8.5",
"sha1",
@@ -9353,16 +9269,6 @@ version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "users"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa4227e95324a443c9fcb06e03d4d85e91aabe9a5a02aa818688b6918b6af486"
dependencies = [
"libc",
"log",
]
[[package]]
name = "users"
version = "0.11.0"
@@ -11158,52 +11064,13 @@ dependencies = [
"syn 2.0.98",
]
[[package]]
name = "zip"
version = "0.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261"
dependencies = [
"aes",
"byteorder",
"bzip2",
"constant_time_eq 0.1.5",
"crc32fast",
"crossbeam-utils",
"flate2",
"hmac",
"pbkdf2",
"sha1",
"time 0.3.36",
"zstd 0.11.2+zstd.1.5.2",
]
[[package]]
name = "zstd"
version = "0.11.2+zstd.1.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4"
dependencies = [
"zstd-safe 5.0.2+zstd.1.5.2",
]
[[package]]
name = "zstd"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d789b1514203a1120ad2429eae43a7bd32b90976a7bb8a05f7ec02fa88cc23a"
dependencies = [
"zstd-safe 7.1.0",
]
[[package]]
name = "zstd-safe"
version = "5.0.2+zstd.1.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d2a5585e04f9eea4b2a3d1eca508c4dee9592a89ef6f450c11719da0726f4db"
dependencies = [
"libc",
"zstd-sys",
"zstd-safe",
]
[[package]]

View File

@@ -1,6 +1,6 @@
[package]
name = "rustdesk"
version = "1.4.9"
version = "1.5.0"
authors = ["rustdesk <info@rustdesk.com>"]
edition = "2021"
build= "build.rs"
@@ -30,7 +30,13 @@ default = ["use_dasp"]
hwcodec = ["scrap/hwcodec"]
vram = ["scrap/vram"]
mediacodec = ["scrap/mediacodec"]
plugin_framework = []
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"]
linux-pkg-config = ["magnum-opus/linux-pkg-config", "scrap/linux-pkg-config"]
unix-file-copy-paste = [
"dep:x11-clipboard",
@@ -74,7 +80,6 @@ hex = "0.4"
chrono = "0.4"
cidr-utils = "0.5"
fon = "0.6"
zip = "0.6"
shutdown_hooks = "0.1"
totp-rs = { version = "5.4", default-features = false, features = ["gen_secret", "otpauth"] }
stunclient = "0.4"
@@ -184,7 +189,6 @@ async-process = "1.7"
evdev = { git="https://github.com/rustdesk-org/evdev" }
dbus = "0.9"
dbus-crossroads = "0.5"
pam = { git="https://github.com/rustdesk-org/pam" }
x11-clipboard = {git="https://github.com/clslaid/x11-clipboard", branch = "feat/store-batch", optional = true}
x11rb = {version = "0.12", features = ["all-extensions"], optional = true}
percent-encoding = {version = "2.3", optional = true}
@@ -205,7 +209,7 @@ android-wakelock = { git = "https://github.com/rustdesk-org/android-wakelock" }
[workspace]
members = ["libs/scrap", "libs/hbb_common", "libs/enigo", "libs/clipboard", "libs/virtual_display", "libs/virtual_display/dylib", "libs/portable", "libs/remote_printer"]
exclude = ["vdi/host", "examples/custom_plugin"]
exclude = ["vdi/host"]
# Patch libxdo-sys to use a stub implementation that doesn't require libxdo
# This allows building and running on systems without libxdo installed (e.g., Wayland-only)

View File

@@ -19,7 +19,6 @@ RUN apt update -y && \
libxcb-shape0-dev \
libxcb-xfixes0-dev \
libasound2-dev \
libpam0g-dev \
libpulse-dev \
make \
wget \

View File

@@ -3,7 +3,7 @@
<a href="#raw-steps-to-build">Build</a> •
<a href="#how-to-build-with-docker">Docker</a> •
<a href="#file-structure">Structure</a> •
<a href="#snapshot">Snapshot</a><br>
<a href="#screenshots">Screenshots</a><br>
[<a href="docs/README-UA.md">Українська</a>] | [<a href="docs/README-CS.md">česky</a>] | [<a href="docs/README-ZH.md">中文</a>] | [<a href="docs/README-HU.md">Magyar</a>] | [<a href="docs/README-ES.md">Español</a>] | [<a href="docs/README-FA.md">فارسی</a>] | [<a href="docs/README-FR.md">Français</a>] | [<a href="docs/README-DE.md">Deutsch</a>] | [<a href="docs/README-PL.md">Polski</a>] | [<a href="docs/README-ID.md">Indonesian</a>] | [<a href="docs/README-FI.md">Suomi</a>] | [<a href="docs/README-ML.md">മലയാളം</a>] | [<a href="docs/README-JP.md">日本語</a>] | [<a href="docs/README-NL.md">Nederlands</a>] | [<a href="docs/README-IT.md">Italiano</a>] | [<a href="docs/README-RU.md">Русский</a>] | [<a href="docs/README-PTBR.md">Português (Brasil)</a>] | [<a href="docs/README-EO.md">Esperanto</a>] | [<a href="docs/README-KR.md">한국어</a>] | [<a href="docs/README-AR.md">العربي</a>] | [<a href="docs/README-VN.md">Tiếng Việt</a>] | [<a href="docs/README-DA.md">Dansk</a>] | [<a href="docs/README-GR.md">Ελληνικά</a>] | [<a href="docs/README-TR.md">Türkçe</a>] | [<a href="docs/README-NO.md">Norsk</a>] | [<a href="docs/README-RO.md">Română</a>]<br>
<b>We need your help to translate this README, <a href="https://github.com/rustdesk/rustdesk/tree/master/src/lang">RustDesk UI</a> and <a href="https://github.com/rustdesk/doc.rustdesk.com">RustDesk Doc</a> to your native language</b>
</p>
@@ -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.
@@ -66,19 +66,19 @@ Please download Sciter dynamic library yourself.
```sh
sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \
libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
```
### openSUSE Tumbleweed
```sh
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel
```
### Fedora 28 (CentOS 8)
```sh
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel
```
### Arch (Manjaro)
@@ -168,7 +168,6 @@ Please ensure that you run these commands from the root of the RustDesk reposito
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Communicate with [rustdesk-server](https://github.com/rustdesk/rustdesk-server), wait for remote direct (TCP hole punching) or relayed connection
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: platform specific code
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Flutter code for desktop and mobile
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/v1/js)**: JavaScript for Flutter web client
## Screenshots

View File

@@ -18,7 +18,7 @@ AppDir:
id: rustdesk
name: rustdesk
icon: rustdesk
version: 1.4.9
version: 1.5.0
exec: usr/share/rustdesk/rustdesk
exec_args: $@
apt:
@@ -58,7 +58,6 @@ AppDir:
- libpulse0
- packagekit-gtk3-module
- libcanberra-gtk3-module
- libpam0g
- libdrm2
exclude:
- humanity-icon-theme
@@ -77,6 +76,13 @@ AppDir:
env:
GIO_MODULE_DIR: /lib64/gio/modules:/usr/lib/aarch64-linux-gnu/gio/modules:$APPDIR/usr/lib/aarch64-linux-gnu/gio/modules
GDK_BACKEND: x11
# AppRun sets these to "$APPDIR/...:$XDG_DATA_DIRS", and setting them at all suppresses the XDG
# defaults, so a host that leaves them unset loses /usr/share and /etc/xdg. gdk-pixbuf 2.43+
# (Arch, Fedora) then finds no glycin loaders and every PNG decode fails, aborting on the first
# remote cursor. The host value goes last: unset it expands to an empty element, which GLib
# resolves against the CWD, and that must not outrank the defaults below.
XDG_DATA_DIRS: $APPDIR/usr/local/share:$APPDIR/usr/share:/usr/local/share:/usr/share:$XDG_DATA_DIRS
XDG_CONFIG_DIRS: $APPDIR/etc/xdg:/etc/xdg:$XDG_CONFIG_DIRS
APPDIR_LIBRARY_PATH: /lib64:/usr/lib/aarch64-linux-gnu:$APPDIR/lib/aarch64-linux-gnu:$APPDIR/lib/aarch64-linux-gnu/security:$APPDIR/lib/systemd:$APPDIR/usr/lib/aarch64-linux-gnu:$APPDIR/usr/lib/aarch64-linux-gnu/gdk-pixbuf-2.0/2.10.0/loaders:$APPDIR/usr/lib/aarch64-linux-gnu/gstreamer-1.0:$APPDIR/usr/lib/aarch64-linux-gnu/gtk-3.0/3.0.0/immodules:$APPDIR/usr/lib/aarch64-linux-gnu/gtk-3.0/3.0.0/printbackends:$APPDIR/usr/lib/aarch64-linux-gnu/krb5/plugins/preauth:$APPDIR/usr/lib/aarch64-linux-gnu/libcanberra-0.30:$APPDIR/usr/lib/aarch64-linux-gnu/pulseaudio:$APPDIR/usr/lib/aarch64-linux-gnu/sasl2:$APPDIR/usr/lib/aarch64-linux-gnu/vdpau:$APPDIR/usr/share/rustdesk/lib:$APPDIR/lib/aarch64
GST_PLUGIN_PATH: /lib64/gstreamer-1.0:/usr/lib/aarch64-linux-gnu/gstreamer-1.0:$APPDIR/usr/lib/aarch64-linux-gnu/gstreamer-1.0
GST_PLUGIN_SYSTEM_PATH: /lib64/gstreamer-1.0:/usr/lib/aarch64-linux-gnu/gstreamer-1.0:$APPDIR/usr/lib/aarch64-linux-gnu/gstreamer-1.0

View File

@@ -18,7 +18,7 @@ AppDir:
id: rustdesk
name: rustdesk
icon: rustdesk
version: 1.4.9
version: 1.5.0
exec: usr/share/rustdesk/rustdesk
exec_args: $@
apt:
@@ -61,7 +61,6 @@ AppDir:
- libpulse0
- packagekit-gtk3-module
- libcanberra-gtk3-module
- libpam0g
- libdrm2
exclude:
- humanity-icon-theme
@@ -80,6 +79,13 @@ AppDir:
env:
GIO_MODULE_DIR: /lib64/gio/modules:/usr/lib/x86_64-linux-gnu/gio/modules:$APPDIR/usr/lib/x86_64-linux-gnu/gio/modules
GDK_BACKEND: x11
# AppRun sets these to "$APPDIR/...:$XDG_DATA_DIRS", and setting them at all suppresses the XDG
# defaults, so a host that leaves them unset loses /usr/share and /etc/xdg. gdk-pixbuf 2.43+
# (Arch, Fedora) then finds no glycin loaders and every PNG decode fails, aborting on the first
# remote cursor. The host value goes last: unset it expands to an empty element, which GLib
# resolves against the CWD, and that must not outrank the defaults below.
XDG_DATA_DIRS: $APPDIR/usr/local/share:$APPDIR/usr/share:/usr/local/share:/usr/share:$XDG_DATA_DIRS
XDG_CONFIG_DIRS: $APPDIR/etc/xdg:/etc/xdg:$XDG_CONFIG_DIRS
APPDIR_LIBRARY_PATH: /lib64:/usr/lib/x86_64-linux-gnu:$APPDIR/lib/x86_64-linux-gnu:$APPDIR/lib/x86_64-linux-gnu/security:$APPDIR/lib/systemd:$APPDIR/usr/lib/x86_64-linux-gnu:$APPDIR/usr/lib/x86_64-linux-gnu/gdk-pixbuf-2.0/2.10.0/loaders:$APPDIR/usr/lib/x86_64-linux-gnu/gstreamer-1.0:$APPDIR/usr/lib/x86_64-linux-gnu/gtk-3.0/3.0.0/immodules:$APPDIR/usr/lib/x86_64-linux-gnu/gtk-3.0/3.0.0/printbackends:$APPDIR/usr/lib/x86_64-linux-gnu/krb5/plugins/preauth:$APPDIR/usr/lib/x86_64-linux-gnu/libcanberra-0.30:$APPDIR/usr/lib/x86_64-linux-gnu/pulseaudio:$APPDIR/usr/lib/x86_64-linux-gnu/sasl2:$APPDIR/usr/lib/x86_64-linux-gnu/vdpau:$APPDIR/usr/share/rustdesk/lib:$APPDIR/lib/x86_64
GST_PLUGIN_PATH: /lib64/gstreamer-1.0:/usr/lib/x86_64-linux-gnu/gstreamer-1.0:$APPDIR/usr/lib/x86_64-linux-gnu/gstreamer-1.0
GST_PLUGIN_SYSTEM_PATH: /lib64/gstreamer-1.0:/usr/lib/x86_64-linux-gnu/gstreamer-1.0:$APPDIR/usr/lib/x86_64-linux-gnu/gstreamer-1.0

530
build.py
View File

@@ -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')
@@ -300,7 +364,7 @@ Version: %s
Architecture: %s
Maintainer: rustdesk <info@rustdesk.com>
Homepage: https://rustdesk.com
Depends: libgtk-3-0t64 | libgtk-3-0, libxcb-randr0, libxdo3 | libxdo4, libxfixes3, libxcb-shape0, libxcb-xfixes0, libasound2t64 | libasound2, libsystemd0, curl, libva2, libva-drm2, libva-x11-2, libgstreamer-plugins-base1.0-0, libpam0g, gstreamer1.0-pipewire%s
Depends: libgtk-3-0t64 | libgtk-3-0, libxcb-randr0, libxdo3 | libxdo4, libxfixes3, libxcb-shape0, libxcb-xfixes0, libasound2t64 | libasound2, libsystemd0, curl, libva2, libva-drm2, libva-x11-2, libgstreamer-plugins-base1.0-0, gstreamer1.0-pipewire%s
Recommends: libayatana-appindicator3-1
Description: A remote control software.
@@ -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.4.
LIBDRMTAP_REPO_PINNED = 'https://github.com/rustdesk-org/libdrmtap'
LIBDRMTAP_SHA_PINNED = '5da68a3a368db569716d0d0f11cefacbb11b2290'
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')
@@ -324,8 +704,6 @@ def build_flutter_deb(version, features):
system2('flutter build linux --release')
system2('mkdir -p tmpdeb/usr/bin/')
system2('mkdir -p tmpdeb/usr/share/rustdesk')
system2('mkdir -p tmpdeb/etc/rustdesk/')
system2('mkdir -p tmpdeb/etc/pam.d/')
system2('mkdir -p tmpdeb/usr/share/rustdesk/files/systemd/')
system2('mkdir -p tmpdeb/usr/share/icons/hicolor/256x256/apps/')
system2('mkdir -p tmpdeb/usr/share/icons/hicolor/scalable/apps/')
@@ -344,17 +722,24 @@ def build_flutter_deb(version, features):
'cp ../res/rustdesk.desktop tmpdeb/usr/share/applications/rustdesk.desktop')
system2(
'cp ../res/rustdesk-link.desktop tmpdeb/usr/share/applications/rustdesk-link.desktop')
system2(
'cp ../res/startwm.sh tmpdeb/etc/rustdesk/')
system2(
'cp ../res/xorg.conf tmpdeb/etc/rustdesk/')
system2(
'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 +747,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 +832,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 +886,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 +962,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 +990,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)
@@ -622,13 +1124,7 @@ def main():
'cp res/rustdesk.desktop tmpdeb/usr/share/applications/rustdesk.desktop')
system2(
'cp res/rustdesk-link.desktop tmpdeb/usr/share/applications/rustdesk-link.desktop')
os.system('mkdir -p tmpdeb/etc/rustdesk/')
os.system('cp -a res/startwm.sh tmpdeb/etc/rustdesk/')
os.system('mkdir -p tmpdeb/etc/X11/rustdesk/')
os.system('cp res/xorg.conf tmpdeb/etc/X11/rustdesk/')
os.system('cp -a DEBIAN/* tmpdeb/DEBIAN/')
os.system('mkdir -p tmpdeb/etc/pam.d/')
os.system('cp pam.d/rustdesk.debian tmpdeb/etc/pam.d/rustdesk')
system2('strip tmpdeb/usr/bin/rustdesk')
system2('mkdir -p tmpdeb/usr/share/rustdesk')
system2('mv tmpdeb/usr/bin/rustdesk tmpdeb/usr/share/rustdesk/')

View File

@@ -72,7 +72,6 @@ fn install_android_deps() {
path.join("lib").to_str().unwrap()
);
println!("cargo:rustc-link-lib=ndk_compat");
println!("cargo:rustc-link-lib=oboe");
println!("cargo:rustc-link-lib=c++");
println!("cargo:rustc-link-lib=OpenSLES");
}

View File

@@ -24,7 +24,7 @@ Untuk instruksi Git yang lebih lanjut, cek disini [GitHub workflow 101](https://
## Tindakan
<https://github.com/rustdesk/rustdesk/blob/master/docs/CODE_OF_CONDUCT-ID.md>
<https://github.com/rustdesk/rustdesk/blob/master/docs/CODE_OF_CONDUCT.md>
## Komunikasi

View File

@@ -30,7 +30,7 @@ Per istruzioni specifiche su git, vedi [Workflow GitHub - 101](https://github.co
## Condotta
https://github.com/rustdesk/rustdesk/blob/master/docs/CODE_OF_CONDUCT-IT.md
https://github.com/rustdesk/rustdesk/blob/master/docs/CODE_OF_CONDUCT.md
## Comunicazioni

View File

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

View File

@@ -160,7 +160,6 @@ RustDesk يرجى التأكد من أنك تنفذ هذه الأوامر من
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: أو المنقول عن بُعد (TCP hole punching) انتظر الاتصال المباشر [rustdesk-server](https://github.com/rustdesk/rustdesk-server) الإتصال ب
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: رمز خاص بكل منصة
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: رمز الهاتف المحمول
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**:Flutter لعميل الويب الخاص ب Javascript
## لقطات

View File

@@ -144,7 +144,6 @@ Ujistěte se, že tyto příkazy spouštíte z kořenového adresáře RustDesk,
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: komunikace s [rustdesk-server](https://github.com/rustdesk/rustdesk-server), očekávání vzdálených příméhých („proděrováváním“ TCP) nebo předávaných (relay) spojení
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: zdrojové kódy, specifické pro jednotlivé platformy
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: zdrojové kódy pro použití s aplikačním rámcem (framework) Flutter pro mobilní platformy
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Javascript pro Flutter webový klient
## Ukázky

View File

@@ -66,19 +66,19 @@ Bitte laden Sie die dynamische Bibliothek Sciter selbst herunter.
```sh
sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \
libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
```
### openSUSE Tumbleweed
```sh
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel
```
### Fedora 28 (CentOS 8)
```sh
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel
```
### Arch (Manjaro)
@@ -168,7 +168,6 @@ Bitte stellen Sie sicher, dass Sie diese Befehle im Stammverzeichnis des RustDes
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Mit [rustdesk-server](https://github.com/rustdesk/rustdesk-server) kommunizieren, warten auf direkte (TCP hole punching) oder weitergeleitete Verbindung
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: Plattformspezifischer Code
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Flutter-Code für Handys
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: JavaScript für Flutter-Webclient
## Screenshots

View File

@@ -62,19 +62,19 @@ Por favor descarga la librería dinámica de Sciter tú mismo.
```sh
sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \
libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
```
### openSUSE Tumbleweed
```sh
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel
```
### Fedora 28 (CentOS 8)
```sh
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel
```
### Arch (Manjaro)
@@ -163,7 +163,6 @@ Por favor, asegurate de que estás ejecutando estos comandos desde la raíz del
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Comunicación con [rustdesk-server](https://github.com/rustdesk/rustdesk-server), esperar la conexión remota directa ("TCP hole punching") o conexión indirecta ("relayed")
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: código específico de cada plataforma
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Flutter, código para moviles
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Javascript para el cliente web Flutter
> [!Precaución]
> **Descargo de responsabilidad por uso indebido:** <br>

View File

@@ -146,7 +146,6 @@ target/release/rustdesk
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Communicate with [rustdesk-server](https://github.com/rustdesk/rustdesk-server), wait for remote direct (TCP hole punching) or relayed connection
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: platform specific code
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Flutter code for mobile
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Javascript for Flutter web client
## تصاویر محیط نرم‌افزار

View File

@@ -158,7 +158,6 @@ target/release/rustdesk
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Communicate with [rustdesk-server](https://github.com/rustdesk/rustdesk-server), wait for remote direct (TCP hole punching) or relayed connection
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: platform specific code
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Flutter code for mobile
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: JavaScript for Flutter web client
## Στιγμιότυπα

View File

@@ -48,7 +48,7 @@ A telefonos verziók Flutter-t hasznának. Később lehetséges hogy Sciterről
- Futtasd a `cargo run` parancsot
## [Építés](https://rustdesk.com/docs/hu/dev/build/)
## [Építés](https://rustdesk.com/docs/en/dev/build/)
## Hogyan építs Linuxon
@@ -150,7 +150,6 @@ Kérlek mindenképpen nézd meg hogy ezeket a parancsokat a root RustDesk mappá
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Communicate with [rustdesk-server](https://github.com/rustdesk/rustdesk-server), wait for remote direct (TCP hole punching) or relayed connection
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: platform specific code
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Flutter code for mobile
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Javascript for Flutter web client
## Képernyőképek

View File

@@ -162,7 +162,6 @@ Assicurati di eseguire questi comandi dalla radice del repository RustDesk, altr
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: comunica con [rustdesk-server](https://github.com/rustdesk/rustdesk-server), attende la connessione remota diretta (TCP hole punching) oppure indiretta (relayed)
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: codice specifico della piattaforma
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: codice Flutter per desktop e mobile
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: JavaScript per client web Flutter
> [!Attenzione]
> **Dichiarazione di non responsabilità per uso improprio:** <br>

View File

@@ -166,7 +166,6 @@ target/release/rustdesk
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: [rustdesk-server](https://github.com/rustdesk/rustdesk-server)と通信し、リモートの直接接続(TCPホールパンチング)や中継接続を担う。
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: プラットフォーム固有のコード
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: デスクトップとモバイル向けのFlutterコード
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Flutterウェブクライアント向けのJavaScript
> [!注意]
> **:不正使用に関する免責事項** <br>

View File

@@ -66,19 +66,19 @@ Sciter 동적 라이브러리를 직접 다운로드하세요.
```sh
sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \
libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
```
### openSUSE Tumbleweed
```sh
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel
```
### Fedora 28 (CentOS 8)
```sh
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel
```
### Arch (Manjaro)
@@ -168,7 +168,6 @@ RustDesk 리포지토리의 루트에서 이러한 명령을 실행하고 있는
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: [rustdesk-server](https://github.com/rustdesk/rustdesk-server)와 통신, 원격 다이렉트 (TCP 홀 펀칭) 또는 릴레이 연결 대기
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: 플랫폼별 코드
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: 데스크톱 및 모바일용 Flutter 코드
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/v1/js)**: Flutter 웹 클라이언트용 JavaScript
## 스크린샷

View File

@@ -62,19 +62,19 @@ Venligst last ned Sciters dynamiske bibliotek selv.
```sh
sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \
libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
```
### openSUSE Tumbleweed
```sh
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel
```
### Fedora 28 (CentOS 8)
```sh
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel
```
### Arch (Manjaro)
@@ -163,7 +163,6 @@ Venligst pass på att du kjører disse kommandoene fra roten av RustDesk reposit
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Kommunikasjon med [rustdesk-server](https://github.com/rustdesk/rustdesk-server), vent på direkte fjernstyring (TCP hulling) eller vidresendt tilkobling
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: platform spesefik kode
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Flutter kode for desktop og mobil
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: JavaScript for Flutter nettsted klient
## Skjermbilder

View File

@@ -155,7 +155,6 @@ Upewnij się, że uruchamiasz te polecenia z katalogu głównego repozytorium Ru
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Komunikacja z [rustdesk-server](https://github.com/rustdesk/rustdesk-server), czekanie na bezpośrednie (odpytywanie TCP) lub przekazywane połączenie
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: kod specyficzny dla danej platformy
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: kod Flutter dla urządzeń mobilnych
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: JavaScript dla Flutter - klient web
## Zrzuty ekranu

View File

@@ -64,19 +64,19 @@ Por favor, faça o download da biblioteca dinâmica do Sciter por conta própria
### Ubuntu 18 (Debian 10)
```sh
sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev
sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
```
### openSUSE Tumbleweed
```sh
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel
```
### Fedora 28 (CentOS 8)
```sh
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel
```
### Arch (Manjaro)
@@ -166,7 +166,6 @@ Certifique-se de executar esses comandos a partir da raiz do repositório do Rus
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Comunica-se com o [rustdesk-server](https://github.com/rustdesk/rustdesk-server), aguarda por conexão remota direta (perfuração de túnel TCP / hole punching) ou retransmitida.
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: código específico de cada plataforma.
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: código Flutter para desktop e dispositivos móveis.
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/v1/js)**: JavaScript para o cliente web do Flutter.
## Capturas de Tela

View File

@@ -66,19 +66,19 @@ Te rugăm să descarci singur librăria dinamică Sciter.
```sh
sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \
libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
```
### openSUSE Tumbleweed
```sh
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel
```
### Fedora 28 (CentOS 8)
```sh
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel
```
### Arch (Manjaro)
@@ -168,7 +168,6 @@ Asigură-te că rulezi aceste comenzi din rădăcina repository-ului RustDesk, a
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: comunică cu [rustdesk-server](https://github.com/rustdesk/rustdesk-server), așteaptă conexiune directă remote (TCP hole punching) sau prin relay
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: cod specific platformei
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: cod Flutter pentru desktop și mobil
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/v1/js)**: JavaScript pentru clientul Flutter web
## Capturi de ecran

View File

@@ -59,7 +59,7 @@ RustDesk приветствует вклад каждого. Ознакомьт
- Выполните команду `cargo run`
## [Сборка](https://rustdesk.com/docs/ru/dev/build/)
## [Сборка](https://rustdesk.com/docs/en/dev/build/)
## Как собрать на Linux
@@ -68,19 +68,19 @@ RustDesk приветствует вклад каждого. Ознакомьт
```sh
sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \
libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
```
### openSUSE Tumbleweed
```sh
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel
```
### Fedora 28 (CentOS 8)
```sh
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel
```
### Arch (Manjaro)
@@ -170,7 +170,6 @@ target/release/rustdesk
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: связь с [сервером RustDesk](https://github.com/rustdesk/rustdesk-server), ожидает удаленного прямого (через TCP hole punching) или ретранслируемого соединения
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: специфичный для платформы код
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: код Flutter для ПК-версии и мобильных устройств
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/v1/js)**: JavaScript для Web-клиента Flutter
## Скриншоты
@@ -180,4 +179,4 @@ target/release/rustdesk
![Передача файлов](https://github.com/rustdesk/rustdesk/assets/28412477/39511ad3-aa9a-4f8c-8947-1cce286a46ad)
![TCP-туннелирование](https://github.com/rustdesk/rustdesk/assets/28412477/78e8708f-e87e-4570-8373-1360033ea6c5)
![TCP-туннелирование](https://github.com/rustdesk/rustdesk/assets/28412477/78e8708f-e87e-4570-8373-1360033ea6c5)

View File

@@ -166,7 +166,6 @@ Lütfen bu komutları RustDesk reposunun root klasöründe çalıştırdığın
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: [rustdesk-server](https://github.com/rustdesk/rustdesk-server) ile iletişime gir, remote direct(TCP delik açma) yada relay bağlantısı için bekle
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: platforma özgü kod
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Masaüstü ve mobil için Flutter kodu
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/v1/js)**: Flutter web istemcisi için JavaScript
## Ekran Görüntüleri

View File

@@ -59,19 +59,19 @@ RustDesk вітає внесок кожного. Ознайомтеся з [CONT
```sh
sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \
libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
```
### openSUSE Tumbleweed
```sh
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel
```
### Fedora 28 (CentOS 8)
```sh
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel
```
### Arch (Manjaro)
@@ -160,7 +160,6 @@ target/release/rustdesk
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: комунікація з [rustdesk-server](https://github.com/rustdesk/rustdesk-server), очікування віддаленого прямого (обхід TCP NAT) або ретрансльованого зʼєднання
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: специфічний для платформи код
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: код Flutter для мобільних пристроїв
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: JavaScript для веб клієнта на Flutter
## Знімки екрана

View File

@@ -148,7 +148,6 @@ Hãy đảm bảo rằng bạn đang chạy các lệnh này từ gốc của th
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: giao tiếp với [rustdesk-server](https://github.com/rustdesk/rustdesk-server), đợi kết nối trực tiếp (TCP hole punching) hoặc kết nối được chuyển tiếp.
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: mã nguồn riêng cho mỗi nền tảng
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Mã Flutter dành máy tính và điện thoại
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Mã JavaScript dành cho giao diện trên web bằng Flutter
## Snapshot

View File

@@ -220,7 +220,6 @@ target/release/rustdesk
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: 与[rustdesk-server](https://github.com/rustdesk/rustdesk-server)保持UDP通讯, 等待远程连接(通过打洞直连或者中继)
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: 平台服务相关代码
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: 适用于桌面和移动设备的 Flutter 代码
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Flutter Web版本中的Javascript代码
## 截图

View File

@@ -21,18 +21,6 @@
}
]
},
{
"name": "pam",
"buildsystem": "autotools",
"config-opts": ["--disable-selinux"],
"sources": [
{
"type": "archive",
"url": "https://github.com/linux-pam/linux-pam/releases/download/v1.3.1/Linux-PAM-1.3.1.tar.xz",
"sha256": "eff47a4ecd833fbf18de9686632a70ee8d0794b79aecb217ebd0ce11db4cd0db"
}
]
},
{
"name": "rustdesk",
"buildsystem": "simple",
@@ -63,4 +51,4 @@
"--socket=pulseaudio",
"--talk-name=org.freedesktop.Flatpak"
]
}
}

View File

@@ -82,7 +82,8 @@ protobuf {
}
android {
compileSdkVersion 34
namespace "com.carriez.flutter_hbb"
compileSdkVersion 36
sourceSets {
main.java.srcDirs += 'src/main/kotlin'
@@ -91,6 +92,7 @@ android {
}
compileOptions {
coreLibraryDesugaringEnabled true
targetCompatibility JavaVersion.VERSION_1_8
sourceCompatibility JavaVersion.VERSION_1_8
}
@@ -99,7 +101,7 @@ android {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId "com.carriez.flutter_hbb"
minSdkVersion 22
targetSdkVersion 33
targetSdkVersion 36
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
}
@@ -128,6 +130,7 @@ flutter {
}
dependencies {
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4'
implementation 'com.google.protobuf:protobuf-javalite:3.20.1'
implementation "androidx.media:media:1.6.0"
implementation 'com.github.getActivity:XXPermissions:18.5'

View File

@@ -1,15 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="com.carriez.flutter_hbb">
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:node="remove" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" tools:node="remove" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" tools:node="remove" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
@@ -26,7 +30,6 @@
android:name=".MainApplication"
android:icon="@mipmap/ic_launcher"
android:label="RustDesk"
android:requestLegacyExternalStorage="true"
android:roundIcon="@mipmap/ic_launcher"
android:supportsRtl="true">
@@ -88,7 +91,12 @@
<service
android:name=".MainService"
android:enabled="true"
android:foregroundServiceType="mediaProjection" />
android:exported="false"
android:foregroundServiceType="specialUse|mediaProjection|microphone">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="@string/foreground_service_special_use_subtype" />
</service>
<service
android:name=".FloatingWindowService"

View File

@@ -18,7 +18,33 @@ const val AUDIO_SAMPLE_RATE = 48000
const val AUDIO_CHANNEL_MASK = AudioFormat.CHANNEL_IN_STEREO
class AudioRecordHandle(private var context: Context, private var isVideoStart: ()->Boolean, private var isAudioStart: ()->Boolean) {
private val logTag = "LOG_AUDIO_RECORD_HANDLE"
companion object {
private const val LOG_TAG = "LOG_AUDIO_RECORD_HANDLE"
private const val NO_ACTIVE_PUBLISHERS = 0
private var activeAudioFramePublishers = NO_ACTIVE_PUBLISHERS
@Synchronized
private fun acquireAudioFramePublisher() {
if (activeAudioFramePublishers == NO_ACTIVE_PUBLISHERS) {
FFI.setFrameRawEnable("audio", true)
}
activeAudioFramePublishers++
}
@Synchronized
private fun releaseAudioFramePublisher() {
if (activeAudioFramePublishers == NO_ACTIVE_PUBLISHERS) {
Log.e(LOG_TAG, "No active audio frame publisher to release")
return
}
activeAudioFramePublishers--
if (activeAudioFramePublishers == NO_ACTIVE_PUBLISHERS) {
FFI.setFrameRawEnable("audio", false)
}
}
}
private val logTag = LOG_TAG
private var audioRecorder: AudioRecord? = null
private var audioReader: AudioReader? = null
@@ -79,48 +105,94 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart:
return
}
// read f32 to byte , length * 4
minBufferSize = 2 * 4 * AudioRecord.getMinBufferSize(
val bufferSize = 2 * 4 * AudioRecord.getMinBufferSize(
AUDIO_SAMPLE_RATE,
AUDIO_CHANNEL_MASK,
AUDIO_ENCODING
)
if (minBufferSize == 0) {
if (bufferSize <= 0) {
Log.d(logTag, "get min buffer size fail!")
return
}
audioReader = AudioReader(minBufferSize, 4)
audioReader = AudioReader(bufferSize, 4)
minBufferSize = bufferSize
Log.d(logTag, "init audioData len:$minBufferSize")
}
@RequiresApi(Build.VERSION_CODES.M)
fun startAudioRecorder() {
checkAudioReader()
if (audioReader != null && audioRecorder != null && minBufferSize != 0) {
try {
FFI.setFrameRawEnable("audio", true)
audioRecorder!!.startRecording()
audioRecordStat = true
audioThread = thread {
while (audioRecordStat) {
audioReader!!.readSync(audioRecorder!!)?.let {
FFI.onAudioFrameUpdate(it)
}
}
// let's release here rather than onDestroy to avoid threading issue
audioRecorder?.release()
audioRecorder = null
minBufferSize = 0
FFI.setFrameRawEnable("audio", false)
Log.d(logTag, "Exit audio thread")
}
} catch (e: Exception) {
Log.d(logTag, "startAudioRecorder fail:$e")
private fun releaseRecorder(recorder: AudioRecord) {
try {
recorder.release()
} finally {
if (audioRecorder === recorder) {
audioRecorder = null
}
} else {
Log.d(logTag, "startAudioRecorder fail")
}
}
private fun captureAudio(reader: AudioReader, recorder: AudioRecord) {
try {
while (audioRecordStat) {
reader.readSync(recorder)?.let {
FFI.onAudioFrameUpdate(it)
}
}
} finally {
minBufferSize = 0
try {
releaseRecorder(recorder)
} finally {
releaseAudioFramePublisher()
Log.d(logTag, "Exit audio thread")
}
}
}
@RequiresApi(Build.VERSION_CODES.M)
fun startAudioRecorder(): Boolean {
val recorder = audioRecorder
if (recorder == null) {
Log.d(logTag, "startAudioRecorder fail")
return false
}
var audioFramePublisherAcquired = false
return try {
checkAudioReader()
val reader = audioReader
if (reader == null || minBufferSize == 0) {
releaseRecorder(recorder)
Log.d(logTag, "startAudioRecorder fail")
return false
}
recorder.startRecording()
if (recorder.recordingState != AudioRecord.RECORDSTATE_RECORDING) {
throw IllegalStateException("AudioRecord failed to enter recording state")
}
audioRecordStat = true
val captureThread = thread(start = false) { captureAudio(reader, recorder) }
acquireAudioFramePublisher()
audioFramePublisherAcquired = true
audioThread = captureThread
captureThread.start()
true
} catch (error: Exception) {
audioRecordStat = false
audioThread = null
Log.e(logTag, "startAudioRecorder fail", error)
try {
releaseRecorder(recorder)
} finally {
if (audioFramePublisherAcquired) {
releaseAudioFramePublisher()
}
}
false
}
}
fun isVoiceCallActive(): Boolean {
return audioRecorder?.audioSource == MediaRecorder.AudioSource.VOICE_COMMUNICATION
}
fun onVoiceCallStarted(mediaProjection: MediaProjection?): Boolean {
if (!isSupportVoiceCall()) {
return false
@@ -137,11 +209,9 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart:
if (!isSupportVoiceCall()) {
return true
}
if (isVideoStart()) {
switchOutVoiceCall(mediaProjection)
}
val switched = !isVideoStart() || switchOutVoiceCall(mediaProjection)
tryReleaseAudio()
return true
return switched
}
@RequiresApi(Build.VERSION_CODES.M)
@@ -159,8 +229,7 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart:
Log.e(logTag, "createAudioRecorder fail")
return false
}
startAudioRecorder()
return true
return startAudioRecorder()
}
@RequiresApi(Build.VERSION_CODES.M)
@@ -177,8 +246,7 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart:
Log.e(logTag, "createAudioRecorder fail")
return false
}
startAudioRecorder()
return true
return startAudioRecorder()
}
fun tryReleaseAudio() {

View File

@@ -9,6 +9,7 @@ package com.carriez.flutter_hbb
import ffi.FFI
import android.app.Activity
import android.content.ComponentName
import android.content.Context
import android.content.Intent
@@ -24,6 +25,10 @@ import android.media.MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface
import android.media.MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar
import android.media.MediaCodecList
import android.media.MediaFormat
import android.net.Uri
import android.provider.DocumentsContract
import android.provider.OpenableColumns
import android.webkit.MimeTypeMap
import android.util.DisplayMetrics
import androidx.annotation.RequiresApi
import org.json.JSONArray
@@ -33,6 +38,9 @@ import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import kotlin.concurrent.thread
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
class MainActivity : FlutterActivity() {
@@ -46,6 +54,23 @@ class MainActivity : FlutterActivity() {
private val channelTag = "mChannel"
private val logTag = "mMainActivity"
private var mainService: MainService? = null
private sealed class PendingPicker {
data class ImportFiles(val result: MethodChannel.Result) : PendingPicker()
data class ExportFile(val source: File, val result: MethodChannel.Result) : PendingPicker()
data class ImportDirectory(val result: MethodChannel.Result) : PendingPicker()
data class ExportFiles(
val sources: List<File>,
val rejected: Int,
val result: MethodChannel.Result
) : PendingPicker()
}
private data class ExportSource(
val file: File,
val children: List<ExportSource>?
)
private var pendingPicker: PendingPicker? = null
private var isAudioStart = false
private val audioRecordHandle = AudioRecordHandle(this, { false }, { isAudioStart })
@@ -91,6 +116,108 @@ class MainActivity : FlutterActivity() {
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == REQ_IMPORT_FILES) {
val pending = pendingPicker as? PendingPicker.ImportFiles ?: return
pendingPicker = null
if (resultCode != Activity.RESULT_OK || data == null) {
pending.result.success(emptyList<Map<String, String>>())
return
}
val uris = linkedSetOf<Uri>()
data.data?.let { uris.add(it) }
data.clipData?.let { clipData ->
for (index in 0 until clipData.itemCount) {
uris.add(clipData.getItemAt(index).uri)
}
}
thread {
val files = uris.map { uri ->
mapOf(
"uri" to uri.toString(),
"name" to (displayName(uri) ?: uri.lastPathSegment.orEmpty())
)
}
runOnUiThread { pending.result.success(files) }
}
return
}
if (requestCode == REQ_EXPORT_FILE) {
val pending = pendingPicker as? PendingPicker.ExportFile ?: return
pendingPicker = null
val destination = data?.data
if (resultCode != Activity.RESULT_OK || destination == null) {
pending.result.success(false)
return
}
thread {
try {
FileInputStream(pending.source).use { input ->
contentResolver.openOutputStream(destination, "wt")?.use { output ->
input.copyTo(output)
} ?: throw IllegalStateException("Unable to open the selected destination")
}
runOnUiThread { pending.result.success(true) }
} catch (e: Exception) {
Log.e(logTag, "Failed to export file", e)
runOnUiThread {
pending.result.error("export_failed", e.message, null)
}
}
}
return
}
if (requestCode == REQ_IMPORT_DIRECTORY) {
val pending = pendingPicker as? PendingPicker.ImportDirectory ?: return
pendingPicker = null
val treeUri = data?.data
if (resultCode != Activity.RESULT_OK || treeUri == null) {
pending.result.success(null)
return
}
thread {
val selected = mapOf(
"uri" to treeUri.toString(),
"name" to (treeDisplayName(treeUri) ?: "Imported")
)
runOnUiThread { pending.result.success(selected) }
}
return
}
if (requestCode == REQ_EXPORT_FILES) {
val pending = pendingPicker as? PendingPicker.ExportFiles ?: return
pendingPicker = null
val treeUri = data?.data
if (resultCode != Activity.RESULT_OK || treeUri == null) {
pending.result.success(null)
return
}
thread {
var exported = 0
var failed = pending.rejected
var processed = 0
try {
val sources = pending.sources.map { snapshotExportSource(it) }
val rootDocId = DocumentsContract.getTreeDocumentId(treeUri)
sources.forEach { source ->
val ok = source?.let {
copyExportSourceToTree(treeUri, rootDocId, it)
} ?: false
if (ok) exported++ else failed++
processed++
}
} catch (e: Exception) {
Log.e(logTag, "Failed to export selected files", e)
failed += pending.sources.size - processed
}
runOnUiThread {
pending.result.success(mapOf("exported" to exported, "failed" to failed))
}
}
return
}
if (requestCode == REQ_INVOKE_PERMISSION_ACTIVITY_MEDIA_PROJECTION && resultCode == RES_FAILED) {
flutterMethodChannel?.invokeMethod("on_media_projection_canceled", null)
}
@@ -267,6 +394,242 @@ class MainActivity : FlutterActivity() {
result.success(false)
}
}
PICK_IMPORT_FILES -> {
if (pendingPicker != null) {
result.error("picker_in_progress", "Another document picker is already open", null)
} else {
pendingPicker = PendingPicker.ImportFiles(result)
try {
startActivityForResult(
Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "*/*"
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
},
REQ_IMPORT_FILES
)
} catch (e: Exception) {
pendingPicker = null
result.error("picker_unavailable", e.message, null)
}
}
}
IMPORT_FILE -> {
val arguments = call.arguments as? Map<*, *>
val uri = (arguments?.get("uri") as? String)?.let {
runCatching { Uri.parse(it) }.getOrNull()
}
val path = arguments?.get("path") as? String
val overwrite = arguments?.get("overwrite") as? Boolean ?: false
val destination = path?.let { canonicalAppScopedFile(it) }
if (uri?.scheme != "content") {
result.error("invalid_uri", "The selected document URI is invalid", null)
} else if (destination == null ||
destination.isDirectory ||
destination.parentFile?.isDirectory != true) {
result.error("invalid_destination", "The destination is outside app-scoped storage", null)
} else {
thread {
var temporary: File? = null
var reservedDestination = false
var errorCode = "import_failed"
try {
val temporaryFile = File.createTempFile(
".rustdesk-import-",
".tmp",
destination.parentFile
)
temporary = temporaryFile
contentResolver.openInputStream(uri)?.use { input ->
FileOutputStream(temporaryFile).use { output ->
input.copyTo(output)
}
} ?: throw IllegalStateException("Unable to open the selected document")
if (!overwrite) {
reservedDestination = destination.createNewFile()
if (!reservedDestination) {
throw IllegalStateException("The destination already exists")
}
}
if (!temporaryFile.renameTo(destination)) {
if (reservedDestination) {
destination.delete()
}
errorCode = "rename_failed"
throw IllegalStateException("Unable to replace the destination")
}
runOnUiThread { result.success(true) }
} catch (e: Exception) {
Log.e(logTag, "Failed to import file", e)
runOnUiThread {
result.error(errorCode, e.message, null)
}
} finally {
temporary?.delete()
}
}
}
}
EXPORT_FILE -> {
val path = (call.arguments as? Map<*, *>)?.get("path") as? String
val source = path?.let { canonicalExportSource(it) }
if (source?.isFile != true) {
result.error("invalid_source", "The file is outside app-scoped storage", null)
} else if (pendingPicker != null) {
result.error("picker_in_progress", "Another document picker is already open", null)
} else {
val mimeType = MimeTypeMap.getSingleton()
.getMimeTypeFromExtension(source.extension.lowercase())
?: "application/octet-stream"
pendingPicker = PendingPicker.ExportFile(source, result)
try {
startActivityForResult(
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = mimeType
putExtra(Intent.EXTRA_TITLE, source.name)
},
REQ_EXPORT_FILE
)
} catch (e: Exception) {
pendingPicker = null
result.error("picker_unavailable", e.message, null)
}
}
}
PICK_IMPORT_DIRECTORY -> {
if (pendingPicker != null) {
result.error("picker_in_progress", "Another document picker is already open", null)
} else {
pendingPicker = PendingPicker.ImportDirectory(result)
try {
startActivityForResult(
Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
putExtra(Intent.EXTRA_TITLE, "Select the folder to import")
},
REQ_IMPORT_DIRECTORY
)
} catch (e: Exception) {
pendingPicker = null
result.error("picker_unavailable", e.message, null)
}
}
}
IMPORT_DIRECTORY -> {
val arguments = call.arguments as? Map<*, *>
val uri = (arguments?.get("uri") as? String)?.let {
runCatching { Uri.parse(it) }.getOrNull()
}
val path = arguments?.get("path") as? String
val overwrite = arguments?.get("overwrite") as? Boolean ?: false
val destination = path?.let { canonicalAppScopedFile(it) }
if (uri?.scheme != "content") {
result.error("invalid_uri", "The selected document URI is invalid", null)
} else if (destination == null ||
destination.parentFile?.isDirectory != true ||
(destination.exists() && !destination.isDirectory)) {
result.error("invalid_destination", "The destination is outside app-scoped storage", null)
} else {
thread {
var temporary: File? = null
var backup: File? = null
val ok = try {
val parent = destination.parentFile
?: throw IllegalStateException("The destination has no parent")
temporary = File.createTempFile(
".rustdesk-import-dir-",
".tmp",
parent
).also {
if (!it.delete() || !it.mkdir()) {
throw IllegalStateException("Unable to create a temporary folder")
}
}
if (!copyDocumentTreeToFile(uri, temporary!!)) {
throw IllegalStateException("Unable to read all folder contents")
}
if (destination.exists()) {
if (!overwrite) {
throw IllegalStateException("The destination already exists")
}
val backupFile = File.createTempFile(
".rustdesk-import-backup-",
".tmp",
parent
)
if (!backupFile.delete()) {
throw IllegalStateException("Unable to prepare the destination backup")
}
backup = backupFile
if (!destination.renameTo(backupFile)) {
throw IllegalStateException("Unable to replace the destination")
}
}
if (!temporary!!.renameTo(destination)) {
val destinationBackup = backup
if (destinationBackup != null &&
!destinationBackup.renameTo(destination)
) {
throw IllegalStateException(
"Unable to move the imported folder and restore " +
"the destination from $destinationBackup"
)
}
throw IllegalStateException("Unable to move the imported folder")
}
temporary = null
val destinationBackup = backup
if (destinationBackup != null &&
!destinationBackup.deleteRecursively()
) {
throw IllegalStateException(
"Unable to remove the destination backup: $destinationBackup"
)
}
backup = null
true
} catch (e: Exception) {
Log.e(logTag, "Failed to import directory", e)
false
} finally {
temporary?.deleteRecursively()
}
runOnUiThread { result.success(ok) }
}
}
}
EXPORT_FILES -> {
val paths = (call.arguments as? Map<*, *>)?.get("paths") as? List<*>
if (paths.isNullOrEmpty()) {
result.error("invalid_source", "The selected files are outside app-scoped storage", null)
} else {
val sources = paths.mapNotNull {
(it as? String)?.let(::canonicalExportSource)
}
val rejected = paths.size - sources.size
if (sources.isEmpty()) {
result.success(mapOf("exported" to 0, "failed" to rejected))
} else if (pendingPicker != null) {
result.error("picker_in_progress", "Another document picker is already open", null)
} else {
pendingPicker = PendingPicker.ExportFiles(sources, rejected, result)
try {
startActivityForResult(
Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
putExtra(Intent.EXTRA_TITLE, "Select the destination folder")
},
REQ_EXPORT_FILES
)
} catch (e: Exception) {
pendingPicker = null
result.error("picker_unavailable", e.message, null)
}
}
}
}
GET_VALUE -> {
if (call.arguments is String) {
if (call.arguments == KEY_IS_SUPPORT_VOICE_CALL) {
@@ -291,6 +654,228 @@ class MainActivity : FlutterActivity() {
}
}
private fun canonicalAppScopedFile(path: String): File? {
val file = runCatching { File(path).canonicalFile }.getOrNull() ?: return null
val allowedRoots = listOfNotNull(filesDir, getExternalFilesDir(null)).mapNotNull {
runCatching { it.canonicalFile }.getOrNull()
}
return file.takeIf { candidate ->
allowedRoots.any { root ->
candidate == root || candidate.path.startsWith(root.path + File.separator)
}
}
}
private fun canonicalExportSource(path: String): File? {
val original = File(path).absoluteFile
val canonical = canonicalAppScopedFile(path) ?: return null
return canonical.takeIf {
original.path == canonical.path && (canonical.isFile || canonical.isDirectory)
}
}
private fun snapshotExportSource(source: File): ExportSource? {
val safeSource = canonicalExportSource(source.path) ?: return null
if (safeSource.isFile) return ExportSource(safeSource, null)
val sourceChildren = safeSource.listFiles() ?: return null
val children = ArrayList<ExportSource>(sourceChildren.size)
for (child in sourceChildren) {
val snapshot = snapshotExportSource(child) ?: return null
children.add(snapshot)
}
return ExportSource(safeSource, children)
}
private fun copyExportSourceToTree(
treeUri: Uri,
parentDocId: String,
source: ExportSource
): Boolean {
val children = source.children
return if (children == null) {
copyFileToTree(treeUri, parentDocId, source.file)
} else {
copyDirToTree(treeUri, parentDocId, source)
}
}
private fun treeDisplayName(treeUri: Uri): String? {
return try {
val rootDocId = DocumentsContract.getTreeDocumentId(treeUri)
val docUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, rootDocId)
contentResolver.query(
docUri,
arrayOf(DocumentsContract.Document.COLUMN_DISPLAY_NAME),
null,
null,
null
)?.use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null }
} catch (e: Exception) {
Log.w(logTag, "Failed to read selected folder name", e)
null
}
}
private fun copyDocumentTreeToFile(treeUri: Uri, destinationDir: File): Boolean {
val rootDocId = DocumentsContract.getTreeDocumentId(treeUri)
return copyChildrenToFile(treeUri, rootDocId, destinationDir)
}
private fun copyChildrenToFile(
treeUri: Uri,
parentDocId: String,
destinationDir: File
): Boolean {
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, parentDocId)
var ok = true
val destinationNames = HashSet<String>()
val cursor = contentResolver.query(childrenUri, childColumns, null, null, null)
?: return false
cursor.use {
while (cursor.moveToNext()) {
val docId = cursor.getString(0)
val name = cursor.getString(1)
val mime = cursor.getString(2)
val docUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, docId)
if (name != null && !destinationNames.add(name)) {
ok = false
continue
}
val destination = safeDestinationChild(destinationDir, name)
if (destination == null || destination.exists()) {
ok = false
continue
}
if (mime == DocumentsContract.Document.MIME_TYPE_DIR) {
if (!destination.mkdirs() && !destination.isDirectory) {
ok = false
continue
}
if (!copyChildrenToFile(treeUri, docId, destination)) {
ok = false
}
} else if (!copyDocumentToFile(docUri, destination)) {
ok = false
}
}
}
return ok
}
private fun safeDestinationChild(destinationDir: File, name: String?): File? {
if (name.isNullOrEmpty() || name == "." || name == ".." ||
name.indexOf('\u0000') >= 0 || name.contains('/') || name.contains('\\')) {
return null
}
val parent = runCatching { destinationDir.canonicalFile }.getOrNull() ?: return null
val child = runCatching { File(parent, name).canonicalFile }.getOrNull() ?: return null
return child.takeIf { it.path.startsWith(parent.path + File.separator) }
}
private fun copyDocumentToFile(uri: Uri, destination: File): Boolean {
return try {
destination.parentFile?.mkdirs()
if (destination.exists() && !destination.delete()) {
return false
}
contentResolver.openInputStream(uri)?.use { input ->
FileOutputStream(destination).use { output -> input.copyTo(output) }
} != null
} catch (e: Exception) {
Log.e(logTag, "Failed to copy document to $destination", e)
false
}
}
private fun copyFileToTree(treeUri: Uri, parentDocId: String, source: File): Boolean {
val safeSource = canonicalExportSource(source.path)?.takeIf { it.isFile } ?: return false
return try {
val mime = MimeTypeMap.getSingleton()
.getMimeTypeFromExtension(safeSource.extension.lowercase())
?: "application/octet-stream"
val parentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, parentDocId)
val docUri = DocumentsContract.createDocument(
contentResolver,
parentUri,
mime,
safeSource.name
) ?: return false
contentResolver.openOutputStream(docUri, "wt")?.use { output ->
FileInputStream(safeSource).use { input -> input.copyTo(output) }
} ?: return false
true
} catch (e: Exception) {
Log.e(logTag, "Failed to export file $safeSource", e)
false
}
}
private fun copyDirToTree(
treeUri: Uri,
parentDocId: String,
source: ExportSource
): Boolean {
val children = source.children ?: return false
val safeSource = canonicalExportSource(source.file.path)?.takeIf { it.isDirectory }
?: return false
val parentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, parentDocId)
var dirDocId = findChildDocId(treeUri, parentDocId, safeSource.name)
if (dirDocId == null) {
dirDocId = try {
DocumentsContract.createDocument(
contentResolver,
parentUri,
DocumentsContract.Document.MIME_TYPE_DIR,
safeSource.name
)?.let { DocumentsContract.getDocumentId(it) }
} catch (e: Exception) {
Log.e(logTag, "Failed to create folder ${safeSource.name}", e)
null
}
}
if (dirDocId == null) return false
var ok = true
children.forEach { child ->
val childOk = copyExportSourceToTree(treeUri, dirDocId, child)
if (!childOk) ok = false
}
return ok
}
private fun findChildDocId(treeUri: Uri, parentDocId: String, name: String): String? {
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, parentDocId)
val cursor = contentResolver.query(childrenUri, childColumns, null, null, null)
?: throw IllegalStateException("Unable to query destination folder")
cursor.use {
while (cursor.moveToNext()) {
if (cursor.getString(1) == name &&
cursor.getString(2) == DocumentsContract.Document.MIME_TYPE_DIR
) {
return cursor.getString(0)
}
}
}
return null
}
private val childColumns = arrayOf(
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_MIME_TYPE
)
private fun displayName(uri: Uri): String? {
return try {
contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) cursor.getString(0) else null
}
} catch (e: Exception) {
Log.w(logTag, "Failed to read selected document name", e)
null
}
}
private fun setCodecInfo() {
val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS)
val codecs = codecList.codecInfos

View File

@@ -17,6 +17,7 @@ import android.app.PendingIntent.FLAG_UPDATE_CURRENT
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.content.res.Configuration
import android.content.res.Configuration.ORIENTATION_LANDSCAPE
import android.graphics.Color
@@ -150,7 +151,7 @@ class MainService : Service() {
if (incomingVoiceCall) {
voiceCallRequestNotification(id, "Voice Call Request", username, peerId)
} else {
if (!audioRecordHandle.switchOutVoiceCall(mediaProjection)) {
if (!switchOutVoiceCall()) {
Log.e(logTag, "switchOutVoiceCall fail")
MainActivity.flutterMethodChannel?.invokeMethod("msgbox", mapOf(
"type" to "custom-nook-nocancel-hasclose-error",
@@ -159,7 +160,7 @@ class MainService : Service() {
}
}
} else {
if (!audioRecordHandle.switchToVoiceCall(mediaProjection)) {
if (!switchToVoiceCall()) {
Log.e(logTag, "switchToVoiceCall fail")
MainActivity.flutterMethodChannel?.invokeMethod("msgbox", mapOf(
"type" to "custom-nook-nocancel-hasclose-error",
@@ -214,6 +215,19 @@ class MainService : Service() {
// video
private var mediaProjection: MediaProjection? = null
private var mediaProjectionCallback: MediaProjection.Callback? = null
private var captureRestartPending = false
private var captureRestartInVoiceCall = false
private val mediaProjectionResultReceiver =
object : ResultReceiver(Handler(Looper.getMainLooper())) {
override fun onReceiveResult(resultCode: Int, resultData: Bundle?) {
if (resultCode == RES_FAILED) {
cancelMediaProjectionRecovery()
}
}
}
private var mediaProjectionForegroundService = false
private var microphoneForegroundService = false
private var surface: Surface? = null
private val sendVP9Thread = Executors.newSingleThreadExecutor()
private var videoEncoder: MediaCodec? = null
@@ -243,7 +257,9 @@ class MainService : Service() {
// keep the config dir same with flutter
val prefs = applicationContext.getSharedPreferences(KEY_SHARED_PREFERENCES, FlutterActivity.MODE_PRIVATE)
val configPath = prefs.getString(KEY_APP_DIR_CONFIG_PATH, "") ?: ""
FFI.startServer(configPath, "")
val homePath = applicationContext.getExternalFilesDir(null)?.absolutePath
?: applicationContext.filesDir.absolutePath
FFI.startServer(configPath, homePath, "")
createForegroundNotification()
}
@@ -337,8 +353,6 @@ class MainService : Service() {
Log.d("whichService", "this service: ${Thread.currentThread()}")
super.onStartCommand(intent, flags, startId)
if (intent?.action == ACT_INIT_MEDIA_PROJECTION_AND_SERVICE) {
createForegroundNotification()
if (intent.getBooleanExtra(EXT_INIT_FROM_BOOT, false)) {
FFI.startService()
}
@@ -347,10 +361,7 @@ class MainService : Service() {
getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
intent.getParcelableExtra<Intent>(EXT_MEDIA_PROJECTION_RES_INTENT)?.let {
mediaProjection =
mediaProjectionManager.getMediaProjection(Activity.RESULT_OK, it)
checkMediaPermission()
_isReady = true
replaceMediaProjection(mediaProjectionManager, it)
} ?: let {
Log.d(logTag, "getParcelableExtra intent null, invoke requestMediaProjection")
requestMediaProjection()
@@ -364,14 +375,23 @@ class MainService : Service() {
updateScreenInfo(newConfig.orientation)
}
private fun requestMediaProjection() {
private fun requestMediaProjection(recovery: Boolean = false) {
val intent = Intent(this, PermissionRequestTransparentActivity::class.java).apply {
action = ACT_REQUEST_MEDIA_PROJECTION
flags = Intent.FLAG_ACTIVITY_NEW_TASK
if (recovery) {
putExtra(EXT_MEDIA_PROJECTION_RESULT_RECEIVER, mediaProjectionResultReceiver)
}
}
startActivity(intent)
}
@Synchronized
private fun cancelMediaProjectionRecovery() {
captureRestartPending = false
captureRestartInVoiceCall = false
}
@SuppressLint("WrongConstant")
private fun createSurface(): Surface? {
return if (useVP9) {
@@ -405,15 +425,149 @@ class MainService : Service() {
}
}
fun onVoiceCallStarted(): Boolean {
return audioRecordHandle.onVoiceCallStarted(mediaProjection)
private fun releaseMediaProjection() {
val projection = mediaProjection
val callback = mediaProjectionCallback
mediaProjection = null
mediaProjectionCallback = null
if (projection != null && callback != null) {
projection.unregisterCallback(callback)
}
projection?.stop()
}
@Synchronized
private fun handleMediaProjectionStopped(stoppedProjection: MediaProjection) {
if (mediaProjection !== stoppedProjection) {
return
}
Log.d(logTag, "MediaProjection stopped")
setMediaProjectionForegroundService(false)
stopCapture()
virtualDisplay?.release()
virtualDisplay = null
mediaProjection = null
mediaProjectionCallback = null
_isReady = false
checkMediaPermission()
}
@Synchronized
private fun replaceMediaProjection(
mediaProjectionManager: MediaProjectionManager,
resultIntent: Intent,
) {
val wasCapturing = isStart
val restartCapture = wasCapturing || captureRestartPending
val restartInVoiceCall = if (wasCapturing) {
audioRecordHandle.isVoiceCallActive()
} else {
captureRestartInVoiceCall
}
val hadProjection = mediaProjection != null
if (!setMediaProjectionForegroundService(true)) {
if (!hadProjection) {
cancelMediaProjectionRecovery()
_isReady = false
checkMediaPermission()
}
return
}
val projection =
mediaProjectionManager.getMediaProjection(Activity.RESULT_OK, resultIntent)
if (projection == null) {
if (!hadProjection) {
cancelMediaProjectionRecovery()
_isReady = false
setMediaProjectionForegroundService(false)
checkMediaPermission()
}
return
}
if (wasCapturing) {
stopCapture()
}
captureRestartPending = restartCapture
virtualDisplay?.release()
virtualDisplay = null
releaseMediaProjection()
val callback = object : MediaProjection.Callback() {
override fun onStop() {
handleMediaProjectionStopped(projection)
}
}
projection.registerCallback(callback, Handler(Looper.getMainLooper()))
mediaProjection = projection
mediaProjectionCallback = callback
_isReady = true
checkMediaPermission()
if (restartCapture) {
captureRestartPending = false
startCapture(restartInVoiceCall)
}
}
@Synchronized
private fun startMicrophoneCapture(startAudio: () -> Boolean): Boolean {
if (!setMicrophoneForegroundService(true)) {
return false
}
if (startAudio()) {
return true
}
setMicrophoneForegroundService(false)
return false
}
@Synchronized
private fun stopMicrophoneCapture(stopAudio: () -> Boolean): Boolean {
val stopped = stopAudio()
val foregroundServiceUpdated = setMicrophoneForegroundService(false)
return stopped && foregroundServiceUpdated
}
@Synchronized
private fun switchToVoiceCall(): Boolean {
if (captureRestartPending) {
captureRestartInVoiceCall = true
}
return startMicrophoneCapture {
audioRecordHandle.switchToVoiceCall(mediaProjection)
}
}
@Synchronized
private fun switchOutVoiceCall(): Boolean {
captureRestartInVoiceCall = false
val switched = audioRecordHandle.switchOutVoiceCall(mediaProjection)
val foregroundServiceUpdated = setMicrophoneForegroundService(false)
return switched && foregroundServiceUpdated
}
@Synchronized
fun onVoiceCallStarted(): Boolean {
if (captureRestartPending) {
captureRestartInVoiceCall = true
}
return startMicrophoneCapture {
audioRecordHandle.onVoiceCallStarted(mediaProjection)
}
}
@Synchronized
fun onVoiceCallClosed(): Boolean {
return audioRecordHandle.onVoiceCallClosed(mediaProjection)
captureRestartInVoiceCall = false
return stopMicrophoneCapture {
audioRecordHandle.onVoiceCallClosed(mediaProjection)
}
}
fun startCapture(): Boolean {
return startCapture(false)
}
@Synchronized
private fun startCapture(inVoiceCall: Boolean): Boolean {
if (isStart) {
return true
}
@@ -421,25 +575,35 @@ class MainService : Service() {
Log.w(logTag, "startCapture fail,mediaProjection is null")
return false
}
captureRestartInVoiceCall = inVoiceCall
updateScreenInfo(resources.configuration.orientation)
Log.d(logTag, "Start Capture")
surface = createSurface()
if (useVP9) {
val videoStarted = if (useVP9) {
startVP9VideoRecorder(mediaProjection!!)
} else {
startRawVideoRecorder(mediaProjection!!)
}
if (!videoStarted) {
if (!captureRestartPending) {
captureRestartInVoiceCall = false
}
releaseFailedVideoCapture()
return false
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
if (!audioRecordHandle.createAudioRecorder(false, mediaProjection)) {
Log.d(logTag, "createAudioRecorder fail")
val audioStarted = if (inVoiceCall) {
switchToVoiceCall()
} else {
Log.d(logTag, "audio recorder start")
audioRecordHandle.startAudioRecorder()
audioRecordHandle.createAudioRecorder(false, mediaProjection) &&
audioRecordHandle.startAudioRecorder()
}
Log.d(logTag, if (audioStarted) "audio recorder start" else "audio recorder start failed")
}
captureRestartInVoiceCall = false
checkMediaPermission()
_isStart = true
FFI.setFrameRawEnable("video",true)
@@ -447,9 +611,24 @@ class MainService : Service() {
return true
}
private fun releaseFailedVideoCapture() {
imageReader?.close()
imageReader = null
videoEncoder?.let {
it.signalEndOfInputStream()
it.stop()
it.release()
}
videoEncoder = null
surface?.release()
surface = null
}
@Synchronized
fun stopCapture() {
Log.d(logTag, "Stop Capture")
captureRestartPending = false
captureRestartInVoiceCall = false
FFI.setFrameRawEnable("video",false)
_isStart = false
MainActivity.rdClipboardManager?.setCaptureStarted(_isStart)
@@ -480,8 +659,11 @@ class MainService : Service() {
surface?.release()
// release audio
_isAudioStart = false
audioRecordHandle.tryReleaseAudio()
stopMicrophoneCapture {
_isAudioStart = false
audioRecordHandle.tryReleaseAudio()
true
}
}
fun destroy() {
@@ -496,7 +678,9 @@ class MainService : Service() {
virtualDisplay = null
}
mediaProjection = null
releaseMediaProjection()
mediaProjectionForegroundService = false
microphoneForegroundService = false
checkMediaPermission()
stopForeground(true)
stopService(Intent(this, FloatingWindowService::class.java))
@@ -519,49 +703,70 @@ class MainService : Service() {
return isReady
}
private fun startRawVideoRecorder(mp: MediaProjection) {
private fun startRawVideoRecorder(mp: MediaProjection): Boolean {
Log.d(logTag, "startRawVideoRecorder,screen info:$SCREEN_INFO")
if (surface == null) {
val captureSurface = surface
if (captureSurface == null) {
Log.d(logTag, "startRawVideoRecorder failed,surface is null")
return
return false
}
createOrSetVirtualDisplay(mp, surface!!)
return createOrSetVirtualDisplay(mp, captureSurface)
}
private fun startVP9VideoRecorder(mp: MediaProjection) {
private fun startVP9VideoRecorder(mp: MediaProjection): Boolean {
createMediaCodec()
videoEncoder?.let {
surface = it.createInputSurface()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
surface!!.setFrameRate(1F, FRAME_RATE_COMPATIBILITY_DEFAULT)
}
it.setCallback(cb)
it.start()
createOrSetVirtualDisplay(mp, surface!!)
val encoder = videoEncoder ?: return false
val inputSurface = encoder.createInputSurface()
surface = inputSurface
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
inputSurface.setFrameRate(1F, FRAME_RATE_COMPATIBILITY_DEFAULT)
}
encoder.setCallback(cb)
encoder.start()
return createOrSetVirtualDisplay(mp, inputSurface)
}
// https://github.com/bk138/droidVNC-NG/blob/b79af62db5a1c08ed94e6a91464859ffed6f4e97/app/src/main/java/net/christianbeier/droidvnc_ng/MediaProjectionService.java#L250
// Reuse virtualDisplay if it exists, to avoid media projection confirmation dialog every connection.
private fun createOrSetVirtualDisplay(mp: MediaProjection, s: Surface) {
try {
virtualDisplay?.let {
it.resize(SCREEN_INFO.width, SCREEN_INFO.height, SCREEN_INFO.dpi)
it.setSurface(s)
} ?: let {
virtualDisplay = mp.createVirtualDisplay(
private fun createOrSetVirtualDisplay(mp: MediaProjection, s: Surface): Boolean {
return try {
val existingDisplay = virtualDisplay
if (existingDisplay != null) {
existingDisplay.resize(SCREEN_INFO.width, SCREEN_INFO.height, SCREEN_INFO.dpi)
existingDisplay.setSurface(s)
true
} else {
val display = mp.createVirtualDisplay(
"RustDeskVD",
SCREEN_INFO.width, SCREEN_INFO.height, SCREEN_INFO.dpi, VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
s, null, null
)
if (display == null) {
Log.e(logTag, "createOrSetVirtualDisplay failed")
handleVirtualDisplayFailure()
} else {
virtualDisplay = display
true
}
}
} catch (e: SecurityException) {
Log.w(logTag, "createOrSetVirtualDisplay: got SecurityException, re-requesting confirmation");
// This initiates a prompt dialog for the user to confirm screen projection.
requestMediaProjection()
Log.w(logTag, "createOrSetVirtualDisplay: got SecurityException", e)
handleVirtualDisplayFailure()
}
}
private fun handleVirtualDisplayFailure(): Boolean {
captureRestartPending = true
virtualDisplay?.release()
virtualDisplay = null
releaseMediaProjection()
setMediaProjectionForegroundService(false)
_isReady = false
checkMediaPermission()
requestMediaProjection(true)
return false
}
private val cb: MediaCodec.Callback = object : MediaCodec.Callback() {
override fun onInputBufferAvailable(codec: MediaCodec, index: Int) {}
override fun onOutputFormatChanged(codec: MediaCodec, format: MediaFormat) {}
@@ -652,7 +857,63 @@ class MainService : Service() {
.setColor(ContextCompat.getColor(this, R.color.primary))
.setWhen(System.currentTimeMillis())
.build()
startForeground(DEFAULT_NOTIFY_ID, notification)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(DEFAULT_NOTIFY_ID, notification, foregroundServiceType())
} else {
startForeground(DEFAULT_NOTIFY_ID, notification)
}
}
@RequiresApi(Build.VERSION_CODES.Q)
private fun foregroundServiceType(): Int {
var serviceType = ServiceInfo.FOREGROUND_SERVICE_TYPE_NONE
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
// Keep a valid FGS type while the unattended host is idle and no capture type is active.
serviceType = serviceType or ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
}
if (mediaProjectionForegroundService) {
serviceType = serviceType or ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && microphoneForegroundService) {
serviceType = serviceType or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
}
return serviceType
}
private fun setMediaProjectionForegroundService(enabled: Boolean): Boolean {
return updateForegroundServiceTypes(enabled, microphoneForegroundService)
}
private fun setMicrophoneForegroundService(enabled: Boolean): Boolean {
return updateForegroundServiceTypes(mediaProjectionForegroundService, enabled)
}
private fun updateForegroundServiceTypes(
mediaProjectionEnabled: Boolean,
microphoneEnabled: Boolean,
): Boolean {
if (mediaProjectionForegroundService == mediaProjectionEnabled &&
microphoneForegroundService == microphoneEnabled) {
return true
}
val previousMediaProjection = mediaProjectionForegroundService
val previousMicrophone = microphoneForegroundService
mediaProjectionForegroundService = mediaProjectionEnabled
microphoneForegroundService = microphoneEnabled
return try {
createForegroundNotification()
true
} catch (error: SecurityException) {
mediaProjectionForegroundService = previousMediaProjection
microphoneForegroundService = previousMicrophone
Log.e(logTag, "Failed to update foreground service types", error)
false
} catch (error: IllegalStateException) {
mediaProjectionForegroundService = previousMediaProjection
microphoneForegroundService = previousMicrophone
Log.e(logTag, "Failed to update foreground service types", error)
false
}
}
private fun loginRequestNotification(

View File

@@ -5,6 +5,7 @@ import android.content.Intent
import android.media.projection.MediaProjectionManager
import android.os.Build
import android.os.Bundle
import android.os.ResultReceiver
import android.util.Log
class PermissionRequestTransparentActivity: Activity() {
@@ -31,7 +32,13 @@ class PermissionRequestTransparentActivity: Activity() {
if (resultCode == RESULT_OK && data != null) {
launchService(data)
} else {
setResult(RES_FAILED)
val resultReceiver =
intent.getParcelableExtra<ResultReceiver>(EXT_MEDIA_PROJECTION_RESULT_RECEIVER)
if (resultReceiver != null) {
resultReceiver.send(RES_FAILED, null)
} else {
setResult(RES_FAILED)
}
}
}
@@ -51,4 +58,4 @@ class PermissionRequestTransparentActivity: Activity() {
}
}
}
}

View File

@@ -33,11 +33,16 @@ const val ACT_INIT_MEDIA_PROJECTION_AND_SERVICE = "INIT_MEDIA_PROJECTION_AND_SER
const val ACT_LOGIN_REQ_NOTIFY = "LOGIN_REQ_NOTIFY"
const val EXT_INIT_FROM_BOOT = "EXT_INIT_FROM_BOOT"
const val EXT_MEDIA_PROJECTION_RES_INTENT = "MEDIA_PROJECTION_RES_INTENT"
const val EXT_MEDIA_PROJECTION_RESULT_RECEIVER = "MEDIA_PROJECTION_RESULT_RECEIVER"
const val EXT_LOGIN_REQ_NOTIFY = "LOGIN_REQ_NOTIFY"
// Activity requestCode
const val REQ_INVOKE_PERMISSION_ACTIVITY_MEDIA_PROJECTION = 101
const val REQ_REQUEST_MEDIA_PROJECTION = 201
const val REQ_EXPORT_FILE = 301
const val REQ_IMPORT_FILES = 302
const val REQ_IMPORT_DIRECTORY = 303
const val REQ_EXPORT_FILES = 304
// Activity responseCode
const val RES_FAILED = -100
@@ -47,6 +52,12 @@ const val START_ACTION = "start_action"
const val GET_START_ON_BOOT_OPT = "get_start_on_boot_opt"
const val SET_START_ON_BOOT_OPT = "set_start_on_boot_opt"
const val SYNC_APP_DIR_CONFIG_PATH = "sync_app_dir"
const val PICK_IMPORT_FILES = "pick_import_files"
const val IMPORT_FILE = "import_file"
const val EXPORT_FILE = "export_file"
const val PICK_IMPORT_DIRECTORY = "pick_import_directory"
const val IMPORT_DIRECTORY = "import_directory"
const val EXPORT_FILES = "export_files"
const val GET_VALUE = "get_value"
const val KEY_IS_SUPPORT_VOICE_CALL = "KEY_IS_SUPPORT_VOICE_CALL"
@@ -154,4 +165,4 @@ fun getScreenSize(windowManager: WindowManager) : Pair<Int, Int>{
fun translate(input: String): String {
Log.d("common", "translate:$LOCAL_NAME")
return FFI.translateLocale(LOCAL_NAME, input)
}
}

View File

@@ -15,7 +15,7 @@ object FFI {
external fun init(ctx: Context)
external fun onAppStart(ctx: Context)
external fun setClipboardManager(clipboardManager: RdClipboardManager)
external fun startServer(app_dir: String, custom_client_config: String)
external fun startServer(app_dir: String, home_dir: String, custom_client_config: String)
external fun startService()
external fun onVideoFrameUpdate(buf: ByteBuffer)
external fun onAudioFrameUpdate(buf: ByteBuffer)

View File

@@ -1,4 +1,5 @@
<resources>
<string name="app_name">RustDesk</string>
<string name="accessibility_service_description">Allow other devices to control your phone using virtual touch, when RustDesk screen sharing is established</string>
<string name="foreground_service_special_use_subtype">Keeps the RustDesk remote desktop host available for authorized unattended connections and foreground notifications without starting screen capture before user approval.</string>
</resources>

View File

@@ -1,3 +1,29 @@
def legacyPluginNamespaces = [
external_path: 'com.pinciat.external_path',
flutter_keyboard_visibility: 'com.jrai.flutter_keyboard_visibility',
qr_code_scanner: 'net.touchcapture.qr.flutterqr',
sqflite: 'com.tekartik.sqflite',
uni_links: 'name.avioli.unilinks',
]
def java8JvmTarget = JavaVersion.VERSION_1_8.toString()
def java8KotlinJvmTargets = [
app: java8JvmTarget,
external_path: java8JvmTarget,
qr_code_scanner: java8JvmTarget,
]
def configureKotlinJvmTarget = { Project project, String kotlinJvmTarget ->
project.plugins.withId('kotlin-android') {
project.tasks.configureEach { task ->
if (!task.hasProperty('kotlinOptions')) {
return
}
task.kotlinOptions.jvmTarget = kotlinJvmTarget
}
}
}
allprojects {
repositories {
google()
@@ -9,6 +35,16 @@ allprojects {
rootProject.buildDir = '../build'
subprojects {
project.buildDir = "${rootProject.buildDir}/${project.name}"
def legacyNamespace = legacyPluginNamespaces[project.name]
if (legacyNamespace != null) {
project.plugins.withId('com.android.library') {
project.android.namespace = legacyNamespace
}
}
def kotlinJvmTarget = java8KotlinJvmTargets[project.name]
if (kotlinJvmTarget != null) {
configureKotlinJvmTarget(project, kotlinJvmTarget)
}
}
subprojects {
project.evaluationDependsOn(':app')

View File

@@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.4-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip

View File

@@ -18,7 +18,7 @@ pluginManagement {
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "7.3.1" apply false
id "com.android.application" version "8.10.1" apply false
id "org.jetbrains.kotlin.android" version "2.1.21" apply false
}

View File

@@ -84,8 +84,6 @@ const double _kPositionEpsilon = 1e-6;
bool get isMainDesktopWindow =>
desktopType == DesktopType.main || desktopType == DesktopType.cm;
String get screenInfo => screenInfo_;
/// Check if the app is running with single view mode.
bool isSingleViewApp() {
return desktopType == DesktopType.cm;
@@ -1521,13 +1519,6 @@ class AndroidPermissionManager {
static Timer? _timer;
static var _current = "";
static bool isWaitingFile() {
if (_completer != null) {
return !_completer!.isCompleted && _current == kManageExternalStorage;
}
return false;
}
static Future<bool> check(String type) {
if (isDesktop || isWeb) {
return Future.value(true);
@@ -2636,13 +2627,6 @@ connect(BuildContext context, String id,
}
} else {
if (isFileTransfer) {
if (isAndroid) {
if (!await AndroidPermissionManager.check(kManageExternalStorage)) {
if (!await AndroidPermissionManager.request(kManageExternalStorage)) {
return;
}
}
}
if (isWeb) {
Navigator.push(
context,

View File

@@ -936,26 +936,19 @@ void enterPasswordDialog(
);
}
void enterUserLoginDialog(
SessionID sessionId,
OverlayDialogManager dialogManager,
String osAccountDescTip,
bool canRememberAccount) async {
void enterUserLoginDialog(SessionID sessionId,
OverlayDialogManager dialogManager, String osAccountDescTip) async {
await _connectDialog(
sessionId,
dialogManager,
osUsernameController: TextEditingController(),
osPasswordController: TextEditingController(),
osAccountDescTip: osAccountDescTip,
canRememberAccount: canRememberAccount,
);
}
void enterUserLoginAndPasswordDialog(
SessionID sessionId,
OverlayDialogManager dialogManager,
String osAccountDescTip,
bool canRememberAccount) async {
void enterUserLoginAndPasswordDialog(SessionID sessionId,
OverlayDialogManager dialogManager, String osAccountDescTip) async {
await _connectDialog(
sessionId,
dialogManager,
@@ -963,7 +956,6 @@ void enterUserLoginAndPasswordDialog(
osPasswordController: TextEditingController(),
passwordController: TextEditingController(),
osAccountDescTip: osAccountDescTip,
canRememberAccount: canRememberAccount,
);
}
@@ -974,7 +966,6 @@ _connectDialog(
TextEditingController? osPasswordController,
TextEditingController? passwordController,
String? osAccountDescTip,
bool canRememberAccount = true,
}) async {
final errUsername = ''.obs;
var rememberPassword = false;
@@ -982,11 +973,6 @@ _connectDialog(
rememberPassword =
await bind.sessionGetRemember(sessionId: sessionId) ?? false;
}
var rememberAccount = false;
if (canRememberAccount && osUsernameController != null) {
rememberAccount =
await bind.sessionGetRemember(sessionId: sessionId) ?? false;
}
if (osUsernameController != null) {
osUsernameController.addListener(() {
if (errUsername.value.isNotEmpty) {
@@ -1014,12 +1000,6 @@ _connectDialog(
final osPassword = osPasswordController?.text.trim() ?? '';
final password = passwordController?.text.trim() ?? '';
if (passwordController != null && password.isEmpty) return;
if (rememberAccount) {
bind.sessionPeerOption(
sessionId: sessionId, name: 'os-username', value: osUsername);
bind.sessionPeerOption(
sessionId: sessionId, name: 'os-password', value: osPassword);
}
gFFI.login(
osUsername,
osPassword,
@@ -1096,16 +1076,6 @@ _connectDialog(
controller: osPasswordController,
autoFocus: false,
),
if (canRememberAccount)
rememberWidget(
translate('remember_account_tip'),
rememberAccount,
(v) {
if (v != null) {
setState(() => rememberAccount = v);
}
},
),
],
);
}
@@ -1542,91 +1512,6 @@ showSetOSPassword(
});
}
showSetOSAccount(
SessionID sessionId,
OverlayDialogManager dialogManager,
) async {
final usernameController = TextEditingController();
final passwdController = TextEditingController();
var username =
await bind.sessionGetOption(sessionId: sessionId, arg: 'os-username') ??
'';
var password =
await bind.sessionGetOption(sessionId: sessionId, arg: 'os-password') ??
'';
usernameController.text = username;
passwdController.text = password;
dialogManager.show((setState, close, context) {
submit() {
final username = usernameController.text.trim();
final password = usernameController.text.trim();
bind.sessionPeerOption(
sessionId: sessionId, name: 'os-username', value: username);
bind.sessionPeerOption(
sessionId: sessionId, name: 'os-password', value: password);
close();
}
descWidget(String text) {
return Column(
children: [
Align(
alignment: Alignment.centerLeft,
child: Text(
text,
maxLines: 3,
softWrap: true,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 16),
),
),
Container(
height: 8,
),
],
);
}
return CustomAlertDialog(
title: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.password_rounded, color: MyTheme.accent),
Text(translate('OS Account')).paddingOnly(left: 10),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
descWidget(translate("os_account_desk_tip")),
DialogTextField(
title: translate(DialogTextField.kUsernameTitle),
controller: usernameController,
prefixIcon: DialogTextField.kUsernameIcon,
errorText: null,
),
PasswordWidget(controller: passwdController),
],
),
actions: [
dialogButton(
"Cancel",
icon: Icon(Icons.close_rounded),
onPressed: close,
isOutline: true,
),
dialogButton(
"OK",
icon: Icon(Icons.done_rounded),
onPressed: submit,
),
],
onSubmit: submit,
onCancel: close,
);
});
}
Widget buildNoteTextField({
required TextEditingController controller,
required VoidCallback onEscape,
@@ -2014,26 +1899,110 @@ customImageQualityDialog(SessionID sessionId, String id, FFI ffi) async {
msgBoxCommon(ffi.dialogManager, 'Custom Image Quality', content, [btnClose]);
}
trackpadSpeedDialog(SessionID sessionId, FFI ffi) async {
int initSpeed = ffi.inputModel.trackpadSpeed;
int? _validateTrackpadSpeed(String text) {
final speed = int.tryParse(text);
if (speed == null || speed < kMinTrackpadSpeed || speed > kMaxTrackpadSpeed) {
BotToast.showText(
text:
'${translate('Invalid format')}: $kMinTrackpadSpeed-$kMaxTrackpadSpeed',
contentColor: Colors.red,
);
return null;
}
return speed;
}
Future<void> _saveTrackpadSpeed({
required SessionID sessionId,
required FFI ffi,
required int initSpeed,
required int speed,
}) async {
if (speed == initSpeed) {
return;
}
await bind.sessionSetTrackpadSpeed(sessionId: sessionId, value: speed);
await ffi.inputModel.updateTrackpadSpeed();
}
void _showTrackpadSpeedSaveError(Object error, StackTrace stackTrace) {
debugPrint('Failed to save trackpad speed: $error');
debugPrintStack(stackTrace: stackTrace);
BotToast.showText(
text: translate('Failed'),
contentColor: Colors.red,
);
}
List<Widget> _trackpadSpeedDialogActions({
required bool isSubmitting,
required VoidCallback close,
required VoidCallback submit,
}) {
return [
dialogButton(
'Cancel',
icon: Icon(Icons.close_rounded),
onPressed: isSubmitting ? null : close,
isOutline: true,
),
dialogButton(
'OK',
icon: Icon(Icons.done_rounded),
onPressed: isSubmitting ? null : submit,
),
];
}
void trackpadSpeedDialog(SessionID sessionId, FFI ffi) {
final initSpeed = ffi.inputModel.trackpadSpeed;
final curSpeed = SimpleWrapper(initSpeed);
final btnClose = dialogButton('Close', onPressed: () async {
if (curSpeed.value <= kMaxTrackpadSpeed &&
curSpeed.value >= kMinTrackpadSpeed &&
curSpeed.value != initSpeed) {
await bind.sessionSetTrackpadSpeed(
sessionId: sessionId, value: curSpeed.value);
await ffi.inputModel.updateTrackpadSpeed();
var speedText = initSpeed.toString();
var isSubmitting = false;
ffi.dialogManager.show((setState, close, context) {
Future<void> submit([String? submittedText]) async {
if (isSubmitting) {
return;
}
speedText = submittedText ?? speedText;
final speed = _validateTrackpadSpeed(speedText);
if (speed == null) {
return;
}
setState(() => isSubmitting = true);
try {
await _saveTrackpadSpeed(
sessionId: sessionId,
ffi: ffi,
initSpeed: initSpeed,
speed: speed,
);
close();
} catch (error, stackTrace) {
_showTrackpadSpeedSaveError(error, stackTrace);
setState(() => isSubmitting = false);
}
}
ffi.dialogManager.dismissAll();
});
msgBoxCommon(
ffi.dialogManager,
'Trackpad speed',
TrackpadSpeedWidget(
value: curSpeed,
return CustomAlertDialog(
title: Text(
translate('Trackpad speed'),
style: TextStyle(fontSize: 21),
),
[btnClose]);
content: TrackpadSpeedWidget(
value: curSpeed,
onTextChanged: (text) => speedText = text,
onTextSubmitted: submit,
),
actions: _trackpadSpeedDialogActions(
isSubmitting: isSubmitting,
close: close,
submit: submit,
),
onSubmit: isSubmitting ? null : submit,
onCancel: isSubmitting ? null : close,
);
});
}
void deleteConfirmDialog(Function onSubmit, String title) async {

View File

@@ -115,6 +115,7 @@ class _RawTouchGestureDetectorRegionState
InputModel get inputModel => widget.inputModel;
bool get handleTouch => (isDesktop || isWebDesktop) || ffiModel.touchMode;
SessionID get sessionId => ffi.sessionId;
bool get canvasLocked => isMobile && ffi.canvasModel.locked;
@override
Widget build(BuildContext context) {
@@ -471,6 +472,8 @@ class _RawTouchGestureDetectorRegionState
return;
}
if (canvasLocked) return;
if ((isDesktop || isWebDesktop)) {
final scale = ((d.scale - _scale) * 1000).toInt();
_scale = d.scale;

View File

@@ -253,8 +253,18 @@ class TrackpadSpeedWidget extends StatefulWidget {
final SimpleWrapper<int> value;
// If null, no debouncer will be applied.
final Function(int)? onDebouncer;
final ValueChanged<String>? onTextChanged;
// IME actions call TextField.onSubmitted without reaching the dialog's
// raw Enter handler, so the dialog needs a separate submission callback.
final ValueChanged<String>? onTextSubmitted;
TrackpadSpeedWidget({Key? key, required this.value, this.onDebouncer});
TrackpadSpeedWidget({
Key? key,
required this.value,
this.onDebouncer,
this.onTextChanged,
this.onTextSubmitted,
});
@override
TrackpadSpeedWidgetState createState() => TrackpadSpeedWidgetState();
@@ -276,6 +286,34 @@ class TrackpadSpeedWidgetState extends State<TrackpadSpeedWidget> {
debouncerSpeed.setValue(value);
}
});
widget.onTextChanged?.call(_controller.text);
}
void updateTextValue(String text) {
widget.onTextChanged?.call(text);
final newValue = int.tryParse(text);
if (newValue == null ||
newValue < kMinTrackpadSpeed ||
newValue > kMaxTrackpadSpeed) {
return;
}
setState(() => value = newValue);
}
void submitTextValue(String text) {
final onTextSubmitted = widget.onTextSubmitted;
if (onTextSubmitted != null) {
onTextSubmitted(text);
return;
}
if (widget.onTextChanged != null) {
return;
}
final newValue = int.tryParse(text);
if (newValue == null) {
return;
}
updateValue(newValue);
}
@override
@@ -315,12 +353,8 @@ class TrackpadSpeedWidgetState extends State<TrackpadSpeedWidget> {
controller: _controller,
keyboardType: TextInputType.number,
textAlign: TextAlign.center,
onSubmitted: (text) {
int? v = int.tryParse(text);
if (v != null) {
updateValue(v);
}
},
onChanged: updateTextValue,
onSubmitted: submitTextValue,
style: const TextStyle(fontSize: 13),
decoration: InputDecoration(
contentPadding:

View File

@@ -349,12 +349,12 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
showRequestElevationDialog(sessionId, ffi.dialogManager)),
);
}
// osAccount / osPassword
// osPassword
if (isDefaultConn && perms['keyboard'] != false) {
v.add(
TTextMenu(
child: Row(children: [
Text(translate(pi.isHeadless ? 'OS Account' : 'OS Password')),
Text(translate('OS Password')),
]),
trailingIcon: Transform.scale(
scale: (isDesktop || isWebDesktop) ? 0.8 : 1,
@@ -363,18 +363,12 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
if (isMobile && Navigator.canPop(context)) {
Navigator.pop(context);
}
if (pi.isHeadless) {
showSetOSAccount(sessionId, ffi.dialogManager);
} else {
handleOsPasswordEditIcon(sessionId, ffi.dialogManager);
}
handleOsPasswordEditIcon(sessionId, ffi.dialogManager);
},
icon: Icon(Icons.edit, color: isMobile ? MyTheme.accent : null),
),
),
onPressed: () => pi.isHeadless
? showSetOSAccount(sessionId, ffi.dialogManager)
: handleOsPasswordAction(sessionId, ffi.dialogManager),
onPressed: () => handleOsPasswordAction(sessionId, ffi.dialogManager),
),
);
}

View File

@@ -18,7 +18,6 @@ const kKeyMapMode = 'map';
const kKeyTranslateMode = 'translate';
const String kPlatformAdditionsIsWayland = "is_wayland";
const String kPlatformAdditionsHeadless = "headless";
const String kPlatformAdditionsIsInstalled = "is_installed";
const String kPlatformAdditionsIddImpl = "idd_impl";
const String kPlatformAdditionsRustDeskVirtualDisplays =
@@ -55,7 +54,6 @@ const String kAppTypeDesktopTerminal = "terminal";
const String kWindowMainWindowOnTop = "main_window_on_top";
const String kWindowRefreshCurrentUser = "refresh_current_user";
const String kWindowGetWindowInfo = "get_window_info";
const String kWindowGetScreenList = "get_screen_list";
// This method is not used, maybe it can be removed.
const String kWindowDisableGrabKeyboard = "disable_grab_keyboard";
@@ -164,13 +162,14 @@ const String kOptionEnableConfirmClosingTabs = "enable-confirm-closing-tabs";
const String kOptionAllowAlwaysSoftwareRender = "allow-always-software-render";
const String kOptionEnableCheckUpdate = "enable-check-update";
const String kOptionAllowAutoUpdate = "allow-auto-update";
const String kOptionAllowLinuxHeadless = "allow-linux-headless";
const String kOptionAllowRemoveWallpaper = "allow-remove-wallpaper";
const String kOptionStopService = "stop-service";
const String kOptionDirectxCapture = "enable-directx-capture";
const String kOptionAllowRemoteCmModification = "allow-remote-cm-modification";
const String kOptionEnableUdpPunch = "enable-udp-punch";
const String kOptionEnableIpv6Punch = "enable-ipv6-punch";
const String kOptionAllowSyncClipboardBetweenSessions =
"allow-sync-clipboard-between-sessions";
const String kOptionEnableTrustedDevices = "enable-trusted-devices";
const String kOptionShowVirtualMouse = "show-virtual-mouse";
const String kOptionVirtualMouseScale = "virtual-mouse-scale";
@@ -193,6 +192,7 @@ const String kOptionHideProxySetting = "hide-proxy-settings";
const String kOptionHideWebSocketSetting = "hide-websocket-settings";
const String kOptionHideStopService = "hide-stop-service";
const String kOptionHideRemotePrinterSetting = "hide-remote-printer-settings";
const String kOptionHideGeneralSetting = "hide-general-settings";
const String kOptionHideSecuritySetting = "hide-security-settings";
const String kOptionHideNetworkSetting = "hide-network-settings";
const String kOptionRemovePresetPasswordWarning =
@@ -325,10 +325,11 @@ double kNewWindowOffset = isWindows
? 30.0
: 50.0;
const kDragToResizeAreaPaddingSize = 5.0;
EdgeInsets get kDragToResizeAreaPadding => !kUseCompatibleUiMode && isLinux
? stateGlobal.fullscreen.isTrue || stateGlobal.isMaximized.value
? EdgeInsets.zero
: EdgeInsets.all(5.0)
: EdgeInsets.all(kDragToResizeAreaPaddingSize)
: EdgeInsets.zero;
// https://en.wikipedia.org/wiki/Non-breaking_space
const int $nbsp = 0x00A0;
@@ -440,7 +441,6 @@ const kActionApplicationDetailsSettings =
const kActionAccessibilitySettings = "android.settings.ACCESSIBILITY_SETTINGS";
const kRecordAudio = "android.permission.RECORD_AUDIO";
const kManageExternalStorage = "android.permission.MANAGE_EXTERNAL_STORAGE";
const kRequestIgnoreBatteryOptimizations =
"android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS";
const kSystemAlertWindow = "android.permission.SYSTEM_ALERT_WINDOW";
@@ -452,6 +452,12 @@ class AndroidChannel {
static final kGetStartOnBootOpt = "get_start_on_boot_opt";
static final kSetStartOnBootOpt = "set_start_on_boot_opt";
static final kSyncAppDirConfigPath = "sync_app_dir";
static final kPickImportFiles = "pick_import_files";
static final kImportFile = "import_file";
static final kExportFile = "export_file";
static final kPickImportDirectory = "pick_import_directory";
static final kImportDirectory = "import_directory";
static final kExportFiles = "export_files";
}
/// flutter/packages/flutter/lib/src/services/keyboard_key.dart -> _keyLabels

View File

@@ -16,7 +16,6 @@ import 'package:flutter_hbb/desktop/widgets/update_progress.dart';
import 'package:flutter_hbb/models/platform_model.dart';
import 'package:flutter_hbb/models/server_model.dart';
import 'package:flutter_hbb/models/state_model.dart';
import 'package:flutter_hbb/plugin/ui_manager.dart';
import 'package:flutter_hbb/utils/multi_window_manager.dart';
import 'package:flutter_hbb/utils/platform_channel.dart';
import 'package:get/get.dart';
@@ -111,7 +110,6 @@ class _DesktopHomePageState extends State<DesktopHomePage>
}
},
),
buildPluginEntry(),
];
if (isIncomingOnly) {
children.addAll([
@@ -782,13 +780,6 @@ class _DesktopHomePageState extends State<DesktopHomePage>
windowOnTop(null);
} else if (call.method == kWindowRefreshCurrentUser) {
gFFI.userModel.refreshCurrentUser();
} else if (call.method == kWindowGetWindowInfo) {
final screen = (await window_size.getWindowInfo()).screen;
if (screen == null) {
return '';
} else {
return jsonEncode(screenToMap(screen));
}
} else if (call.method == kWindowGetScreenList) {
return jsonEncode(
(await window_size.getScreenList()).map(screenToMap).toList());
@@ -890,21 +881,6 @@ class _DesktopHomePageState extends State<DesktopHomePage>
shouldBeBlocked(_block, canBeBlocked);
}
}
Widget buildPluginEntry() {
final entries = PluginUiManager.instance.entries.entries;
return Offstage(
offstage: entries.isEmpty,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...entries.map((entry) {
return entry.value;
})
],
),
);
}
}
void setPasswordDialog({VoidCallback? notEmptyCallback}) async {

View File

@@ -17,8 +17,6 @@ import 'package:flutter_hbb/models/platform_model.dart';
import 'package:flutter_hbb/models/printer_model.dart';
import 'package:flutter_hbb/models/server_model.dart';
import 'package:flutter_hbb/models/state_model.dart';
import 'package:flutter_hbb/plugin/manager.dart';
import 'package:flutter_hbb/plugin/widgets/desktop_settings.dart';
import 'package:get/get.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
@@ -55,7 +53,6 @@ enum SettingsTabKey {
safety,
network,
display,
plugin,
account,
printer,
about,
@@ -64,7 +61,8 @@ enum SettingsTabKey {
class DesktopSettingPage extends StatefulWidget {
final SettingsTabKey initialTabkey;
static final List<SettingsTabKey> tabKeys = [
SettingsTabKey.general,
if (bind.mainGetBuildinOption(key: kOptionHideGeneralSetting) != 'Y')
SettingsTabKey.general,
if (!isWeb &&
!bind.isOutgoingOnly() &&
!bind.isDisableSettings() &&
@@ -74,10 +72,9 @@ class DesktopSettingPage extends StatefulWidget {
bind.mainGetBuildinOption(key: kOptionHideNetworkSetting) != 'Y')
SettingsTabKey.network,
if (!bind.isIncomingOnly()) SettingsTabKey.display,
if (!isWeb && !bind.isIncomingOnly() && bind.pluginFeatureIsEnabled())
SettingsTabKey.plugin,
if (!bind.isDisableAccount()) SettingsTabKey.account,
if (isWindows &&
!bind.isDisableSettings() &&
bind.mainGetBuildinOption(key: kOptionHideRemotePrinterSetting) != 'Y')
SettingsTabKey.printer,
SettingsTabKey.about,
@@ -95,7 +92,8 @@ class DesktopSettingPage extends StatefulWidget {
if (index == -1) {
return;
}
if (Get.isRegistered<PageController>(tag: _kSettingPageControllerTag)) {
if (Get.isRegistered<PageController>(tag: _kSettingPageControllerTag) &&
Get.isRegistered<Rx<SettingsTabKey>>(tag: _kSettingPageTabKeyTag)) {
DesktopTabPage.onAddSetting(initialPage: page);
PageController controller =
Get.find<PageController>(tag: _kSettingPageControllerTag);
@@ -163,17 +161,23 @@ class _DesktopSettingPageState extends State<DesktopSettingPage>
if (!mounted) {
return;
}
_canBeBlocked.value = await canBeBlocked();
final blocked = await canBeBlocked();
if (!mounted) {
return;
}
_canBeBlocked.value = blocked;
});
}
@override
void dispose() {
super.dispose();
Get.delete<PageController>(tag: _kSettingPageControllerTag);
Get.delete<RxInt>(tag: _kSettingPageTabKeyTag);
WidgetsBinding.instance.removeObserver(this);
_videoConnTimer?.cancel();
WidgetsBinding.instance.removeObserver(this);
Get.delete<PageController>(tag: _kSettingPageControllerTag);
Get.delete<Rx<SettingsTabKey>>(tag: _kSettingPageTabKeyTag);
// Get.delete does not dispose a plain ChangeNotifier.
controller.dispose();
super.dispose();
}
List<_TabInfo> _settingTabs() {
@@ -196,10 +200,6 @@ class _DesktopSettingPageState extends State<DesktopSettingPage>
settingTabs.add(_TabInfo(tab, 'Display',
Icons.desktop_windows_outlined, Icons.desktop_windows));
break;
case SettingsTabKey.plugin:
settingTabs.add(_TabInfo(
tab, 'Plugin', Icons.extension_outlined, Icons.extension));
break;
case SettingsTabKey.account:
settingTabs.add(
_TabInfo(tab, 'Account', Icons.person_outline, Icons.person));
@@ -233,9 +233,6 @@ class _DesktopSettingPageState extends State<DesktopSettingPage>
case SettingsTabKey.display:
children.add(const _Display());
break;
case SettingsTabKey.plugin:
children.add(const _Plugin());
break;
case SettingsTabKey.account:
children.add(const _Account());
break;
@@ -578,6 +575,15 @@ class _GeneralState extends State<_General> {
kOptionEnableIpv6Punch,
isServer: false,
),
Tooltip(
message: translate('sync-clipboard-between-sessions-tip'),
child: _OptionCheckBox(
context,
'Sync clipboard between sessions',
kOptionAllowSyncClipboardBetweenSessions,
isServer: false,
),
),
],
];
@@ -591,10 +597,6 @@ class _GeneralState extends State<_General> {
));
}
if (!isWeb && bind.mainShowOption(key: kOptionAllowLinuxHeadless)) {
children.add(_OptionCheckBox(
context, 'Allow linux headless', kOptionAllowLinuxHeadless));
}
if (!bind.isDisableAccount()) {
children.add(_OptionCheckBox(
context,
@@ -2255,51 +2257,6 @@ class _CheckboxState extends State<_Checkbox> {
}
}
class _Plugin extends StatefulWidget {
const _Plugin({Key? key}) : super(key: key);
@override
State<_Plugin> createState() => _PluginState();
}
class _PluginState extends State<_Plugin> {
@override
Widget build(BuildContext context) {
bind.pluginListReload();
final scrollController = ScrollController();
return ChangeNotifierProvider.value(
value: pluginManager,
child: Consumer<PluginManager>(builder: (context, model, child) {
return ListView(
controller: scrollController,
children: model.plugins.map((entry) => pluginCard(entry)).toList(),
).marginOnly(bottom: _kListViewBottomMargin);
}),
);
}
Widget pluginCard(PluginInfo plugin) {
return ChangeNotifierProvider.value(
value: plugin,
child: Consumer<PluginInfo>(
builder: (context, model, child) => DesktopSettingsCard(plugin: model),
),
);
}
Widget accountAction() {
return Obx(() => _Button(
gFFI.userModel.userName.value.isEmpty
? 'Login'
: '${translate('Logout')} (${gFFI.userModel.accountLabelWithHandle})',
() => {
gFFI.userModel.userName.value.isEmpty
? loginDialog()
: logOutConfirmDialog()
}));
}
}
class _Printer extends StatefulWidget {
const _Printer({super.key});

View File

@@ -278,7 +278,39 @@ class _FileManagerPageState extends State<FileManagerPage>
item.state != JobState.inProgress,
child: LinearPercentIndicator(
animateFromLastPercent: true,
center: Text(item.percentText),
center: SizedBox.expand(
child: ShaderMask(
blendMode: BlendMode.srcATop,
shaderCallback: (bounds) =>
LinearGradient(
colors: [
Colors.white,
Colors.transparent,
],
stops: [item.percent, item.percent],
).createShader(bounds),
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text.rich(
TextSpan(
text: item.percentText,
children: [
if (item.recvJobRes)
TextSpan(
text:
' ${readableFileSize(item.speed)}/s',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w300,
color: MyTheme.darkGray,
),
),
],
),
),
),
),
),
barRadius: Radius.circular(15),
percent: item.percent,
progressColor: MyTheme.accent,
@@ -1094,6 +1126,7 @@ class _FileManagerViewState extends State<FileManagerView> {
return element.name.contains(_searchText.value);
}).toList(growable: false)
: entries;
// Keep rows lazy so large directories only build visible list items.
final rows = filteredEntries.map((entry) {
final sizeStr =
entry.isFile ? readableFileSize(entry.size.toDouble()) : "";
@@ -1276,7 +1309,7 @@ class _FileManagerViewState extends State<FileManagerView> {
],
))),
);
}).toList(growable: false);
});
return Column(
children: [
@@ -1292,7 +1325,7 @@ class _FileManagerViewState extends State<FileManagerView> {
controller: scrollController,
itemExtent: kDesktopFileTransferRowHeight,
itemBuilder: (context, index) {
return rows[index];
return rows.elementAt(index);
},
itemCount: rows.length,
),

View File

@@ -182,7 +182,6 @@ class _RemotePageState extends State<RemotePage>
WakelockManager.enable(_uniqueKey);
_ffi.ffiModel.updateEventListener(sessionId, widget.id);
if (!isWeb) bind.pluginSyncUi(syncTo: kAppTypeDesktopRemote);
_ffi.qualityMonitorModel.checkShowQualityMonitor(sessionId);
_ffi.dialogManager.loadMobileActionsOverlayVisible();
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -274,6 +273,11 @@ class _RemotePageState extends State<RemotePage>
tabState.tabs[selected].key == widget.id;
}
// Every Windows requestFocus() must pass this, or a blocking dialog or an
// inactive tab could hand remote input to this page.
bool get _windowsCanFocusRemoteInput =>
_isSelectedTab && _blockableOverlayState.middleBlocked.isFalse;
bool get _isMacOSKeyboardContextActive {
return stateGlobal.isFocused.value && !_isWindowBlur && _isSelectedTab;
}
@@ -514,6 +518,15 @@ class _RemotePageState extends State<RemotePage>
_queueMacOSKeyboardAfterFullScreen(allowHiddenLifecycle: true);
}
// Refocus without PointerEnter: the cursor already hovers the image when
// focus returns (Alt+Tab, taskbar), so enterView() never fires again.
if (isWindows &&
_cursorOverImage.value &&
_windowsCanFocusRemoteInput &&
!_rawKeyFocusNode.hasFocus) {
_rawKeyFocusNode.requestFocus();
}
// Restore relative mouse mode constraints when window regains focus.
if (_ffi.inputModel.relativeMouseMode.value) {
if (isMacOS) {
@@ -524,7 +537,7 @@ class _RemotePageState extends State<RemotePage>
_cursorOverImage.value = true;
_macOSLocalFocusLost = false;
}
} else {
} else if (!isWindows || _windowsCanFocusRemoteInput) {
_rawKeyFocusNode.requestFocus();
}
_ffi.inputModel.onWindowFocus();
@@ -836,7 +849,16 @@ class _RemotePageState extends State<RemotePage>
_macOSLocalFocusLost = false;
stateGlobal.getInputSource(force: true);
_syncMacOSKeyboardGrab(reassert: true, allowInactiveLifecycle: true);
} else if (!isWindows) {
} else if (isWindows) {
// Blur unfocuses this node and nothing restores it, so the keyboard stayed
// dead until a click. Focus only while the window is really active, or a
// background window would grab system keys. onFocusChange does enterOrLeave.
if (!_isWindowBlur &&
_windowsCanFocusRemoteInput &&
!_rawKeyFocusNode.hasFocus) {
_rawKeyFocusNode.requestFocus();
}
} else {
if (!_rawKeyFocusNode.hasFocus) {
_rawKeyFocusNode.requestFocus();
}

View File

@@ -22,6 +22,14 @@ import '../../models/file_model.dart';
import '../../models/platform_model.dart';
import '../../models/server_model.dart';
/// Set only by this window's own close control, and only once the user has confirmed. Any other
/// way the window can go - a session logout closing every window, the window manager, a native
/// title-bar button this app does not draw - leaves it false, which is the honest answer:
/// nothing in that close says who asked for it. It lives at file scope because the control that
/// sets it (`ConnectionManagerState`) and the handler that reads it (`_DesktopServerPageState`)
/// are different widgets.
bool _cmClosedByOperator = false;
class DesktopServerPage extends StatefulWidget {
const DesktopServerPage({Key? key}) : super(key: key);
@@ -55,7 +63,10 @@ class _DesktopServerPageState extends State<DesktopServerPage>
@override
void onWindowClose() {
Future.wait([gFFI.serverModel.closeAll(), gFFI.close()]).then((_) {
// Other platforms keep the old behaviour exactly: the ambiguity this guards against is a
// Linux session logout, which closes every window in the session.
final byOperator = _cmClosedByOperator || !isLinux;
Future.wait([gFFI.serverModel.closeAll(byOperator: byOperator), gFFI.close()]).then((_) {
if (isMacOS) {
RdPlatformChannel.instance.terminate();
} else {
@@ -327,6 +338,7 @@ class ConnectionManagerState extends State<ConnectionManager>
var tabController = gFFI.serverModel.tabController;
final connLength = tabController.length;
if (connLength <= 1) {
_cmClosedByOperator = true;
windowManager.close();
return true;
} else {
@@ -338,6 +350,9 @@ class ConnectionManagerState extends State<ConnectionManager>
res = await closeConfirmDialog();
}
if (res) {
// After the dialog, never before it: an external close while it is open must not
// inherit an intent the user had not expressed yet.
_cmClosedByOperator = true;
windowManager.close();
}
return res;

View File

@@ -5,7 +5,7 @@ import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
import 'package:flutter_hbb/models/model.dart';
import 'package:flutter_hbb/models/terminal_model.dart';
import 'package:xterm/xterm.dart';
import 'package:flutter_hbb/models/terminal_mouse_handler.dart';
import 'terminal_connection_manager.dart';
class TerminalPage extends StatefulWidget {
@@ -197,7 +197,7 @@ class _TerminalPageState extends State<TerminalPage>
body: LayoutBuilder(
builder: (context, constraints) {
final heightPx = constraints.maxHeight;
return TerminalView(
return TerminalMouseInteraction(
_terminalModel.terminal,
controller: _terminalModel.terminalController,
focusNode: _terminalFocusNode,

View File

@@ -127,7 +127,6 @@ class _ViewCameraPageState extends State<ViewCameraPage>
WakelockManager.enable(_uniqueKey);
_ffi.ffiModel.updateEventListener(sessionId, widget.id);
if (!isWeb) bind.pluginSyncUi(syncTo: kAppTypeDesktopRemote);
_ffi.qualityMonitorModel.checkShowQualityMonitor(sessionId);
_ffi.dialogManager.loadMobileActionsOverlayVisible();
DesktopMultiWindow.addListener(this);

View File

@@ -9,9 +9,6 @@ import 'package:flutter_hbb/common/widgets/toolbar.dart';
import 'package:flutter_hbb/models/chat_model.dart';
import 'package:flutter_hbb/models/state_model.dart';
import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/utils/multi_window_manager.dart';
import 'package:flutter_hbb/plugin/widgets/desc_ui.dart';
import 'package:flutter_hbb/plugin/common.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:get/get.dart';
import 'package:provider/provider.dart';
@@ -1336,6 +1333,12 @@ class ScreenAdjustor {
final FFI ffi;
final VoidCallback cbExitFullscreen;
window_size.Screen? _screen;
Size? _waylandMaximizedWorkAreaSize;
Rect? _waylandWorkAreaScreenFrame;
double? _waylandWorkAreaScaleFactor;
Rect? _x11WorkArea;
Rect? _x11WorkAreaScreenFrame;
double? _x11WorkAreaScaleFactor;
ScreenAdjustor({
required this.id,
@@ -1346,9 +1349,18 @@ class ScreenAdjustor {
bool get isFullscreen => stateGlobal.fullscreen.isTrue;
int get windowId => stateGlobal.windowId;
Future<bool?> isWindowMaximized() async {
try {
return await WindowController.fromWindowId(windowId).isMaximized();
} catch (_) {
// The delayed resolution callback may run after the window is disposed.
return null;
}
}
adjustWindow(BuildContext context) {
return futureBuilder(
future: isWindowCanBeAdjusted(),
future: isWindowCanBeAdjusted(context),
hasData: (data) {
final visible = data as bool;
if (!visible) return Offstage();
@@ -1364,36 +1376,201 @@ class ScreenAdjustor {
});
}
doAdjustWindow(BuildContext context) async {
await updateScreen();
if (_screen != null) {
cbExitFullscreen();
double scale = _screen!.scaleFactor;
final wndRect = await WindowController.fromWindowId(windowId).getFrame();
final mediaSize = MediaQueryData.fromView(View.of(context)).size;
// On windows, wndRect is equal to GetWindowRect and mediaSize is equal to GetClientRect.
// Linux screen and work-area coordinates can use different units or become
// unreliable across Wayland/X11 state changes, so normalize reported frames
// and cache usable work-area measurements before sizing the window.
Future<void> _updateLinuxWorkAreaCache({
required window_size.Screen screen,
required Rect wndRect,
required bool isWayland,
required bool isX11,
required bool forMenu,
}) async {
if (isWayland &&
(_waylandWorkAreaScreenFrame != screen.frame ||
_waylandWorkAreaScaleFactor != screen.scaleFactor)) {
_waylandMaximizedWorkAreaSize = null;
_waylandWorkAreaScreenFrame = screen.frame;
_waylandWorkAreaScaleFactor = screen.scaleFactor;
}
if (isWayland &&
forMenu &&
!isFullscreen &&
await isWindowMaximized() == true) {
_waylandMaximizedWorkAreaSize = wndRect.size;
}
if (isX11 &&
(_x11WorkAreaScreenFrame != screen.frame ||
_x11WorkAreaScaleFactor != screen.scaleFactor)) {
_x11WorkArea = null;
_x11WorkAreaScreenFrame = screen.frame;
_x11WorkAreaScaleFactor = screen.scaleFactor;
}
if (isX11 && forMenu && !isFullscreen) {
_x11WorkArea = screen.visibleFrame;
}
}
Future<Rect?> _getEffectiveScreenFrame({
required window_size.Screen screen,
required bool isWayland,
required bool isX11,
required bool forMenu,
}) async {
Rect frameRect = screen.visibleFrame;
if (isMacOS && forMenu && isFullscreen) {
List<double>? workArea;
try {
workArea = await kMacOSPermChannel
.invokeListMethod<double>('getMacOSWorkAreaSize');
} catch (_) {
return null;
}
if (workArea == null || workArea.length != 2) {
return null;
}
frameRect = Rect.fromLTWH(
frameRect.left,
frameRect.top,
workArea[0] < frameRect.width ? workArea[0] : frameRect.width,
workArea[1] < frameRect.height ? workArea[1] : frameRect.height,
);
}
final x11WorkArea = _x11WorkArea;
if (isX11 &&
forMenu &&
isFullscreen &&
x11WorkArea != null &&
(x11WorkArea.width < frameRect.width ||
x11WorkArea.height < frameRect.height)) {
frameRect = x11WorkArea;
}
final screenScale = screen.scaleFactor;
if (isWayland && screenScale > 1.01) {
String monitorLayoutMode;
try {
monitorLayoutMode =
await bind.mainGetCommon(key: 'gnome-monitor-layout-mode');
} catch (_) {
monitorLayoutMode = '';
}
if (monitorLayoutMode == 'physical') {
frameRect = Rect.fromLTRB(
frameRect.left / screenScale,
frameRect.top / screenScale,
frameRect.right / screenScale,
frameRect.bottom / screenScale,
);
}
}
return frameRect;
}
Future<Rect?> _getAdjustedWindowFrame(Size mediaSize,
{bool forMenu = false}) async {
final screen = _screen;
if (screen != null) {
// Windows window frames use physical pixels while Flutter view sizes are
// logical. macOS and Linux window frames use the same units as Flutter.
double scale = isWindows ? screen.scaleFactor : 1.0;
final Rect wndRect;
try {
wndRect = await WindowController.fromWindowId(windowId).getFrame();
} catch (e) {
debugPrint("Failed to get frame of window $windowId, it may be hidden");
return null;
}
// On Windows, wndRect is GetWindowRect while mediaSize is GetClientRect.
// https://stackoverflow.com/a/7561083
double magicWidth =
wndRect.right - wndRect.left - mediaSize.width * scale;
double magicHeight =
wndRect.bottom - wndRect.top - mediaSize.height * scale;
final canvasModel = ffi.canvasModel;
// canvasModel.scale is the rendered scale and already applies kIgnoreDpi.
// Use it instead of the remote source resolution.
final isWayland = isLinux && bind.mainCurrentIsWayland();
final isX11 = isLinux && !isWayland;
await _updateLinuxWorkAreaCache(
screen: screen,
wndRect: wndRect,
isWayland: isWayland,
isX11: isX11,
forMenu: forMenu,
);
if (isWindows && forMenu && isFullscreen) {
// desktop_multi_window's hidden title bar keeps 8 physical pixels on
// each horizontal edge and at the bottom, plus up to 1px at the top.
// Fullscreen removes these in WM_NCCALCSIZE, so predict the restored
// frame's worst-case padding when deciding whether to show the menu.
magicWidth = 16.0;
magicHeight = 9.0;
}
double horizontalEdges;
double verticalEdges;
if (forMenu && (isLinux || ((isMacOS || isWindows) && isFullscreen))) {
// Linux Adjust Window unmaximizes; macOS and Windows exit fullscreen
// before resizing. Predict the restored normal-window edges when
// deciding whether to show the menu item.
final resizePadding = isLinux && !kUseCompatibleUiMode
? kDragToResizeAreaPaddingSize
: 0.0;
final windowEdge = kWindowBorderWidth + resizePadding;
horizontalEdges = windowEdge * 2;
verticalEdges = kDesktopRemoteTabBarHeight + windowEdge * 2;
} else {
horizontalEdges = CanvasModel.leftToEdge + CanvasModel.rightToEdge;
verticalEdges = CanvasModel.topToEdge + CanvasModel.bottomToEdge;
}
final width = (canvasModel.getDisplayWidth() * canvasModel.scale +
CanvasModel.leftToEdge +
CanvasModel.rightToEdge) *
horizontalEdges) *
scale +
magicWidth;
final height = (canvasModel.getDisplayHeight() * canvasModel.scale +
CanvasModel.topToEdge +
CanvasModel.bottomToEdge) *
scale +
magicHeight;
final height =
(canvasModel.getDisplayHeight() * canvasModel.scale + verticalEdges) *
scale +
magicHeight;
double left = wndRect.left + (wndRect.width - width) / 2;
double top = wndRect.top + (wndRect.height - height) / 2;
Rect frameRect = _screen!.frame;
if (!isFullscreen) {
frameRect = _screen!.visibleFrame;
final frameRect = await _getEffectiveScreenFrame(
screen: screen,
isWayland: isWayland,
isX11: isX11,
forMenu: forMenu,
);
if (frameRect == null) {
return null;
}
var availableSize = frameRect.size;
if (isWayland && forMenu && _waylandMaximizedWorkAreaSize != null) {
final cachedSize = _waylandMaximizedWorkAreaSize!;
availableSize = Size(
cachedSize.width < availableSize.width
? cachedSize.width
: availableSize.width,
cachedSize.height < availableSize.height
? cachedSize.height
: availableSize.height,
);
}
// A window frame cannot be smaller than its client area. Tolerate small
// floating-point differences; larger negative values mean the native
// frame and Flutter view metrics are not synchronized.
if (magicWidth < -0.1 || magicHeight < -0.1) {
return null;
}
// Reject implausibly small targets to avoid hiding the window.
if (width < 300 || height < 300) {
return null;
}
// The remote size may change after the menu is built. Reject targets
// that exceed the available area.
final exceedsScreen =
width > availableSize.width || height > availableSize.height;
if (exceedsScreen) {
return null;
}
if (left < frameRect.left) {
left = frameRect.left;
@@ -1407,69 +1584,101 @@ class ScreenAdjustor {
if ((top + height) > frameRect.bottom) {
top = frameRect.bottom - height;
}
await WindowController.fromWindowId(windowId)
.setFrame(Rect.fromLTWH(left, top, width, height));
return Rect.fromLTWH(left, top, width, height);
}
return null;
}
doAdjustWindow([BuildContext? context]) async {
// A resolution change is adjusted after a delay, when the menu context may
// already be disposed. Each desktop_multi_window window has its own engine,
// so that engine's first view is the current window.
final views = WidgetsBinding.instance.platformDispatcher.views;
if (context == null && views.isEmpty) {
return;
}
final view = context != null ? View.of(context) : views.first;
await updateScreen();
if (_screen != null) {
final wc = WindowController.fromWindowId(windowId);
final wasFullscreen = isFullscreen;
cbExitFullscreen();
if (wasFullscreen) {
// Wait for the native fullscreen exit to update the window frame.
await Future.delayed(Duration(milliseconds: 700));
await updateScreen();
}
if (isLinux) {
final isMaximized = await isWindowMaximized();
if (isMaximized == null) {
return;
}
if (isMaximized == true) {
// setFrame may be ignored while the native window is maximized.
try {
await wc.unmaximize();
} catch (_) {
return;
}
stateGlobal.setMaximized(false);
// Wait for the window manager and Flutter view metrics to reflect
// the restored window before calculating and setting its frame.
await Future.delayed(Duration(milliseconds: 300));
await updateScreen();
}
}
final mediaSize = MediaQueryData.fromView(view).size;
final frame = await _getAdjustedWindowFrame(mediaSize);
if (frame == null) {
return;
}
try {
await wc.setFrame(frame);
} catch (_) {
return;
}
stateGlobal.setMaximized(false);
}
}
updateScreen() async {
final String info =
isWeb ? screenInfo : await _getScreenInfoDesktop() ?? '';
if (info.isEmpty) {
_screen = null;
} else {
final screenMap = jsonDecode(info);
_screen = window_size.Screen(
Rect.fromLTRB(screenMap['frame']['l'], screenMap['frame']['t'],
screenMap['frame']['r'], screenMap['frame']['b']),
Rect.fromLTRB(
screenMap['visibleFrame']['l'],
screenMap['visibleFrame']['t'],
screenMap['visibleFrame']['r'],
screenMap['visibleFrame']['b']),
screenMap['scaleFactor']);
_screen = await _getCurrentScreen();
}
Future<window_size.Screen?> _getCurrentScreen() async {
try {
return (await window_size.getWindowInfo()).screen;
} catch (e) {
debugPrint('Failed to get current window screen: $e');
return null;
}
}
_getScreenInfoDesktop() async {
final v = await rustDeskWinManager.call(
WindowType.Main, kWindowGetWindowInfo, '');
return v.result;
}
Future<bool> isWindowCanBeAdjusted() async {
Future<bool> isWindowCanBeAdjusted([BuildContext? context]) async {
if (isWeb) {
return false;
}
// Capture the view before awaiting because the menu context may be disposed.
final views = WidgetsBinding.instance.platformDispatcher.views;
if (context == null && views.isEmpty) {
return false;
}
final view = context != null ? View.of(context) : views.first;
final mediaSize = MediaQueryData.fromView(view).size;
final viewStyle =
await bind.sessionGetViewStyle(sessionId: ffi.sessionId) ?? '';
if (viewStyle != kRemoteViewStyleOriginal) {
return false;
}
if (!isWeb) {
final remoteCount = RemoteCountState.find().value;
if (remoteCount != 1) {
return false;
}
final remoteCount = RemoteCountState.find().value;
if (remoteCount != 1) {
return false;
}
await updateScreen();
if (_screen == null) {
return false;
}
final scale = kIgnoreDpi ? 1.0 : _screen!.scaleFactor;
double selfWidth = _screen!.visibleFrame.width;
double selfHeight = _screen!.visibleFrame.height;
if (isFullscreen) {
selfWidth = _screen!.frame.width;
selfHeight = _screen!.frame.height;
}
final canvasModel = ffi.canvasModel;
final displayWidth = canvasModel.getDisplayWidth();
final displayHeight = canvasModel.getDisplayHeight();
final requiredWidth =
CanvasModel.leftToEdge + displayWidth + CanvasModel.rightToEdge;
final requiredHeight =
CanvasModel.topToEdge + displayHeight + CanvasModel.bottomToEdge;
return selfWidth > (requiredWidth * scale) &&
selfHeight > (requiredHeight * scale);
return await _getAdjustedWindowFrame(mediaSize, forMenu: true) != null;
}
}
@@ -1478,20 +1687,11 @@ class _DisplayMenu extends StatefulWidget {
final FFI ffi;
final ToolbarState state;
final Function(bool) setFullscreen;
final Widget pluginItem;
_DisplayMenu(
{Key? key,
required this.id,
const _DisplayMenu(
{required this.id,
required this.ffi,
required this.state,
required this.setFullscreen})
: pluginItem = LocationItem.createLocationItem(
id,
ffi,
kLocationClientRemoteToolbarDisplay,
true,
),
super(key: key);
required this.setFullscreen});
@override
State<_DisplayMenu> createState() => _DisplayMenuState();
@@ -1529,7 +1729,6 @@ class _DisplayMenuState extends State<_DisplayMenu> {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
_screenAdjustor.updateScreen();
menuChildrenGetter(_IconSubmenuButtonState state) {
final menuChildren = <Widget>[
_screenAdjustor.adjustWindow(context),
@@ -1582,9 +1781,6 @@ class _DisplayMenuState extends State<_DisplayMenu> {
]);
}
}
if (ffi.connType == ConnType.defaultConn) {
menuChildren.add(widget.pluginItem);
}
return menuChildren;
}
@@ -2096,15 +2292,19 @@ class _ResolutionsMenuState extends State<_ResolutionsMenu> {
Future<void> _getLocalResolutionWayland() async {
if (!isWayland) return _getLocalResolution();
final window = await window_size.getWindowInfo();
final screen = window.screen;
if (screen != null) {
setState(() {
_localResolution = Resolution(
screen.frame.width.toInt(),
screen.frame.height.toInt(),
);
});
try {
final window = await window_size.getWindowInfo();
final screen = window.screen;
if (screen != null) {
setState(() {
_localResolution = Resolution(
screen.frame.width.toInt(),
screen.frame.height.toInt(),
);
});
}
} catch (e) {
debugPrint('Failed to get local resolution on Wayland: $e');
}
}
@@ -2176,8 +2376,16 @@ class _ResolutionsMenuState extends State<_ResolutionsMenu> {
return;
}
if (w == rect.width.toInt() && h == rect.height.toInt()) {
if (await widget.screenAdjustor.isWindowCanBeAdjusted()) {
widget.screenAdjustor.doAdjustWindow(context);
if (!await widget.screenAdjustor.isWindowCanBeAdjusted()) {
return;
}
if (widget.screenAdjustor.isFullscreen) {
return;
}
if ((await widget.screenAdjustor.isWindowMaximized()) == false) {
// This delayed callback can outlive the menu State, so its context
// is unsafe.
widget.screenAdjustor.doAdjustWindow();
}
}
});

View File

@@ -30,9 +30,6 @@ import 'mobile/pages/server_page.dart';
import 'mobile/widgets/deploy_dialog.dart';
import 'models/platform_model.dart';
import 'package:flutter_hbb/plugin/handlers.dart'
if (dart.library.html) 'package:flutter_hbb/web/plugin/handlers.dart';
/// Basic window and launch properties.
int? kWindowId;
WindowType? kWindowType;
@@ -141,8 +138,6 @@ void runMainApp(bool startService) async {
await bind.mainCheckConnectStatus();
if (startService) {
gFFI.serverModel.startService();
bind.pluginSyncUi(syncTo: kAppTypeMain);
bind.pluginListReload();
}
await Future.wait([gFFI.abModel.loadCache(), gFFI.groupModel.loadCache()]);
gFFI.userModel.refreshCurrentUser();
@@ -570,12 +565,6 @@ _registerEventHandler() {
reloadAllWindows();
});
}
// Register native handlers.
if (isDesktop) {
platformFFI.registerEventHandler('native_ui', 'native_ui', (evt) async {
NativeUiHandler.instance.onEvent(evt);
});
}
if (isAndroid) {
platformFFI.registerEventHandler(
'android_needs_deploy', 'android_needs_deploy', (_) async {
@@ -588,7 +577,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) {

View File

@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_breadcrumb/flutter_breadcrumb.dart';
@@ -8,6 +9,7 @@ import 'package:toggle_switch/toggle_switch.dart';
import '../../common.dart';
import '../../common/widgets/dialog.dart';
import '../../consts.dart';
class FileManagerPage extends StatefulWidget {
FileManagerPage(
@@ -73,6 +75,173 @@ class _FileManagerPageState extends State<FileManagerPage> {
DirectoryOptions get currentOptions => currentFileController.options.value;
final _uniqueKey = UniqueKey();
Future<T> _runAndroidDocumentPicker<T>(Future<T> Function() action) async {
gFFI.ffiModel.beginAndroidDocumentPicker();
try {
return await action();
} finally {
gFFI.ffiModel.endAndroidDocumentPicker();
}
}
Future<void> _importFiles() async {
var imported = 0;
var failed = false;
final importController = currentFileController;
final importDirectory = currentDir.path;
final importIsWindows = currentOptions.isWindows;
try {
final selectedFiles = await _runAndroidDocumentPicker(() =>
gFFI.invokeMethodWithResult<List<dynamic>>(
AndroidChannel.kPickImportFiles));
if (selectedFiles == null || selectedFiles.isEmpty) return;
for (final selected in selectedFiles) {
final uri = (selected as Map<dynamic, dynamic>)['uri'] as String?;
final selectedName = selected['name'] as String?;
final name = selectedName?.replaceAll('\\', '/').split('/').last;
if (uri == null ||
name == null ||
!PathUtil.validName(name, importIsWindows)) {
failed = true;
continue;
}
final destination =
PathUtil.join(importDirectory, name, importIsWindows);
var overwrite = false;
if (await File(destination).exists()) {
final overwriteResult = await model.showFileConfirmDialog(
translate('Overwrite'), destination, false, false);
if (overwriteResult == false) break;
if (overwriteResult != true) continue;
overwrite = true;
}
try {
final success = await gFFI.invokeMethod(
AndroidChannel.kImportFile,
{'uri': uri, 'path': destination, 'overwrite': overwrite});
if (success == true) {
imported++;
} else {
failed = true;
}
} catch (e) {
failed = true;
debugPrint('Failed to import $name: $e');
}
}
} catch (e) {
failed = true;
debugPrint('Failed to select files for import: $e');
}
await importController.refresh();
if (failed) {
showToast(translate('Failed'));
} else if (imported > 0) {
showToast(translate('Successful'));
}
}
Future<void> _exportFile(Entry entry) async {
try {
final exported = await _runAndroidDocumentPicker(() => gFFI
.invokeMethod(AndroidChannel.kExportFile, {'path': entry.path}));
if (exported == true) {
showToast(translate('Successful'));
}
} catch (e) {
debugPrint('Failed to export ${entry.name}: $e');
showToast(translate('Failed'));
}
}
Future<void> _importFolder() async {
final importController = currentFileController;
final importDirectory = currentDir.path;
final importIsWindows = currentOptions.isWindows;
try {
final picked = await _runAndroidDocumentPicker(() =>
gFFI.invokeMethodWithResult<Map<dynamic, dynamic>>(
AndroidChannel.kPickImportDirectory));
if (picked == null || picked.isEmpty) return;
final uri = picked['uri'] as String?;
final name =
(picked['name'] as String?)?.replaceAll('\\', '/').split('/').last;
if (uri == null ||
name == null ||
name == '.' ||
name == '..' ||
!PathUtil.validName(name, importIsWindows)) {
showToast(translate('Failed'));
return;
}
final destination = PathUtil.join(importDirectory, name, importIsWindows);
final destinationType = await FileSystemEntity.type(destination);
var overwrite = false;
if (destinationType == FileSystemEntityType.directory) {
final overwriteResult = await model.showFileConfirmDialog(
translate('Overwrite'), destination, false, false);
if (overwriteResult != true) return;
overwrite = true;
} else if (destinationType != FileSystemEntityType.notFound) {
showToast(translate('Failed'));
return;
}
final success = await gFFI.invokeMethod(AndroidChannel.kImportDirectory,
{'uri': uri, 'path': destination, 'overwrite': overwrite});
if (success == true) {
showToast(translate('Successful'));
} else {
showToast(translate('Failed'));
}
} catch (e) {
debugPrint('Failed to import folder: $e');
showToast(translate('Failed'));
}
await importController.refresh();
}
Future<void> _exportItems(SelectedItems items) async {
await _exportPaths(items.items.map((e) => e.path));
}
Future<void> _exportLogs() async {
final home = currentFileController.homePath;
if (home.isEmpty) {
showToast(translate('Failed'));
return;
}
final appDir = PathUtil.join(home, appName, false);
final paths = [
PathUtil.join(appDir, 'Logs', false),
PathUtil.join(appDir, 'ScreenRecord', false),
].where((p) => File(p).existsSync() || Directory(p).existsSync()).toList();
if (paths.isEmpty) {
showToast(translate('Failed'));
return;
}
await _exportPaths(paths);
}
Future<void> _exportPaths(Iterable<String> paths) async {
try {
final result = await _runAndroidDocumentPicker(() =>
gFFI.invokeMethodWithResult<Map<dynamic, dynamic>>(
AndroidChannel.kExportFiles, {'paths': paths.toList()}));
if (result == null) return;
final exported = result['exported'] as int? ?? 0;
final failed = result['failed'] as int? ?? 0;
if (failed > 0) {
showToast(translate('Failed'));
} else if (exported > 0) {
showToast(translate('Successful'));
}
} catch (e) {
debugPrint('Failed to export paths: $e');
showToast(translate('Failed'));
}
}
@override
void initState() {
super.initState();
@@ -159,6 +328,45 @@ class _FileManagerPageState extends State<FileManagerPage> {
),
value: "refresh",
),
if (isAndroid)
PopupMenuItem(
enabled: showLocal && currentDir.path.isNotEmpty,
value: "import",
child: Row(
children: [
Icon(Icons.add_to_drive,
color: Theme.of(context).iconTheme.color),
SizedBox(width: 5),
Text(translate("Add"))
],
),
),
if (isAndroid)
PopupMenuItem(
enabled: showLocal && currentDir.path.isNotEmpty,
value: "import_folder",
child: Row(
children: [
Icon(Icons.create_new_folder_outlined,
color: Theme.of(context).iconTheme.color),
SizedBox(width: 5),
Text(translate("Import Folder"))
],
),
),
if (isAndroid)
PopupMenuItem(
enabled: showLocal && currentDir.path.isNotEmpty,
value: "export_logs",
child: Row(
children: [
Icon(Icons.article_outlined,
color: Theme.of(context).iconTheme.color),
SizedBox(width: 5),
Text(translate("Export Logs"))
],
),
),
PopupMenuItem(
enabled: currentDir.path != "/",
child: Row(
@@ -203,6 +411,12 @@ class _FileManagerPageState extends State<FileManagerPage> {
onSelected: (v) {
if (v == "refresh") {
currentFileController.refresh();
} else if (v == "import") {
_importFiles();
} else if (v == "import_folder") {
_importFolder();
} else if (v == "export_logs") {
_exportLogs();
} else if (v == "select") {
model.localController.selectedItems.clear();
model.remoteController.selectedItems.clear();
@@ -300,6 +514,24 @@ class _FileManagerPageState extends State<FileManagerPage> {
setState(() {});
},
actions: [
if (isAndroid &&
selectedItems?.isLocal == true &&
selectedItems?.items.isNotEmpty == true) ...[
if (selectedItems!.items.length == 1 &&
selectedItems!.items.single.isFile)
IconButton(
tooltip: translate("Save as"),
icon: Icon(Icons.save_alt),
onPressed: () =>
_exportFile(selectedItems!.items.single),
)
else
IconButton(
tooltip: translate("Export"),
icon: Icon(Icons.drive_folder_upload),
onPressed: () => _exportItems(selectedItems!),
),
],
IconButton(
icon: Icon(Icons.compare_arrows),
onPressed: () => setState(() => showLocal = !showLocal),
@@ -366,8 +598,7 @@ class _FileManagerPageState extends State<FileManagerPage> {
return BottomSheetBody(
leading: CircularProgressIndicator(),
title: translate("Waiting"),
text:
"${translate("Speed")}: ${readableFileSize(activeJob.speed)}/s",
text: "${readableFileSize(activeJob.speed)}/s",
onCanceled: () {
model.jobController.cancelJob(activeJob.id);
jobTable.clear();

View File

@@ -1276,6 +1276,14 @@ void showOptions(
List<TToggleMenu> cursorToggles = await toolbarCursor(context, id, gFFI);
List<TToggleMenu> displayToggles =
await toolbarDisplayToggle(context, id, gFFI);
if (isMobile) {
displayToggles.insert(
0,
TToggleMenu(
child: Text(translate('Lock canvas')),
value: gFFI.canvasModel.locked,
onChanged: (value) => gFFI.canvasModel.setLocked(value == true)));
}
List<TToggleMenu> privacyModeList = [];
if ((gFFI.ffiModel.pi.features.privacyMode && gFFI.ffiModel.keyboard) ||

View File

@@ -225,12 +225,6 @@ class _ServerPageState extends State<ServerPage> {
void checkService() async {
gFFI.invokeMethod("check_service");
// for Android 10/11, request MANAGE_EXTERNAL_STORAGE permission from system setting page
if (AndroidPermissionManager.isWaitingFile() && !gFFI.serverModel.fileOk) {
AndroidPermissionManager.complete(kManageExternalStorage,
await AndroidPermissionManager.check(kManageExternalStorage));
debugPrint("file permission finished");
}
}
class ServiceNotRunningNotification extends StatelessWidget {

View File

@@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -8,8 +9,11 @@ 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_copy_shortcut.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';
@@ -67,6 +71,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}');
@@ -184,6 +192,7 @@ class _TerminalPageState extends State<TerminalPage>
KeyEventResult _handleTerminalKeyEvent(FocusNode _, KeyEvent event) {
final hardwareKeyboard = HardwareKeyboard.instance;
final shouldPaste = shouldHandleTerminalPasteShortcut(
platform: defaultTargetPlatform,
logicalKey: event.logicalKey,
isKeyDown: event is KeyDownEvent,
isKeyRepeat: event is KeyRepeatEvent,
@@ -238,7 +247,12 @@ class _TerminalPageState extends State<TerminalPage>
//
// Android works fine without this workaround.
deleteDetection: isIOS,
onKeyEvent: _handleTerminalKeyEvent,
shortcuts: platformTerminalShortcuts(),
onKeyEvent: terminalCopyHandler(
_terminalModel.terminal,
_terminalModel.terminalController,
fallback: _handleTerminalKeyEvent,
),
padding: _calculatePadding(heightPx),
onSecondaryTapDown: (details, offset) async {
final selection = _terminalModel.terminalController.selection;

View File

@@ -46,6 +46,12 @@ class JobID {
typedef GetSessionID = SessionID Function();
typedef GetDialogManager = OverlayDialogManager? Function();
typedef ReadRemoteDirectory = Future<void> Function(
SessionID sessionId, String path, bool includeHidden);
const _kRemoteReadDirTimeout = Duration(seconds: 30);
const _kRemoteSessionChangedError =
'Remote directory read cancelled because the session changed';
class FileModel {
final WeakReference<FFI> parent;
@@ -84,6 +90,7 @@ class FileModel {
}
Future<void> onReady() async {
fileFetcher.beginRemoteSession();
await evtLoop.onReady();
if (!isWeb) await localController.onReady();
await remoteController.onReady();
@@ -133,7 +140,11 @@ class FileModel {
final id = int.tryParse(evt['id']?.toString() ?? '');
if (id != null) {
final err = evt['err']?.toString() ?? 'Unknown error';
fileFetcher.tryCompleteRecursiveTaskWithError(id, err);
if (id == 0) {
fileFetcher.tryCompleteRemoteTaskWithError(err);
} else {
fileFetcher.tryCompleteRecursiveTaskWithError(id, err);
}
}
// Always call jobController.jobError(evt) to ensure all error events are processed,
// even if the event does not have a valid job ID. This allows for generic error handling
@@ -350,6 +361,8 @@ class FileController {
final history = RxList<String>.empty(growable: true);
final sortBy = SortBy.name.obs;
var sortAscending = true;
// Incremented for each navigation; only the latest generation applies results.
int _directoryRequestGeneration = 0;
final JobController jobController;
final WeakReference<FFI> rootState;
@@ -368,6 +381,14 @@ class FileController {
void set homePath(String path) => options.value.home = path;
OverlayDialogManager? get dialogManager => rootState.target?.dialogManager;
bool _isPathAllowed(String candidate) {
if (!isAndroid || !isLocal) return true;
if (homePath.isEmpty || candidate.isEmpty) return false;
final home = PathUtil.posixContext.normalize(homePath);
final target = PathUtil.posixContext.normalize(candidate);
return target == home || PathUtil.posixContext.isWithin(home, target);
}
String get shortPath {
final dirPath = directory.value.path;
if (dirPath.startsWith(homePath)) {
@@ -401,8 +422,13 @@ class FileController {
await Future.delayed(Duration(milliseconds: 100));
final savedDir = (await bind.sessionGetPeerOption(
var savedDir = (await bind.sessionGetPeerOption(
sessionId: sessionId, name: isLocal ? "local_dir" : "remote_dir"));
if (savedDir.isNotEmpty && !_isPathAllowed(savedDir)) {
savedDir = options.value.home;
await bind.sessionPeerOption(
sessionId: sessionId, name: "local_dir", value: savedDir);
}
Future<bool> tryOpenReadyDirs() async {
final dirs = <String>{
if (directory.value.path.isNotEmpty) directory.value.path,
@@ -472,6 +498,9 @@ class FileController {
}
Future<bool> _openDirectoryPath(String path, {bool isBack = false}) async {
if (!_isPathAllowed(path)) {
return false;
}
if (!isBack) {
pushHistory();
}
@@ -484,12 +513,20 @@ class FileController {
path = "$path\\";
}
}
final requestGeneration = ++_directoryRequestGeneration;
try {
final fd = await fileFetcher.fetchDirectory(path, isLocal, showHidden);
if (requestGeneration != _directoryRequestGeneration) {
return true;
}
fd.format(isWindows, sort: sortBy.value);
selectedItems.reconcile(fd.entries);
directory.value = fd;
return true;
} catch (e) {
if (requestGeneration != _directoryRequestGeneration) {
return true;
}
debugPrint("Failed to openDirectory $path: $e");
return false;
}
@@ -530,6 +567,9 @@ class FileController {
final isWindows = options.value.isWindows;
final dirPath = directory.value.path;
var parent = PathUtil.dirname(dirPath, isWindows);
if (!_isPathAllowed(parent)) {
return true;
}
// specially for C:\, D:\, goto '/'
if (parent == dirPath && isWindows) {
return await _openDirectoryPath('/', isBack: isBack);
@@ -541,6 +581,7 @@ class FileController {
void initDirAndHome(Map<String, dynamic> evt) {
try {
final fd = FileDirectory.fromJson(jsonDecode(evt['value']));
final isHomeResponse = fileFetcher.isLikelyRemoteHomeResponse(fd.path);
fd.format(options.value.isWindows, sort: sortBy.value);
if (fd.id > 0) {
final jobIndex = jobController.getJob(fd.id);
@@ -556,10 +597,12 @@ class FileController {
debugPrint("update receive details: ${fd.path}");
jobController.jobTable.refresh();
}
} else if (options.value.home.isEmpty) {
} else if (options.value.home.isEmpty && isHomeResponse) {
options.value.home = fd.path;
debugPrint("init remote home: ${fd.path}");
directory.value = fd;
if (_directoryRequestGeneration == 0) {
directory.value = fd;
}
}
} catch (e) {
debugPrint("initDirAndHome err=$e");
@@ -1362,16 +1405,78 @@ class JobResultListener<T> {
}
}
class _RemoteReadTask {
final bool includeHidden;
final Completer<FileDirectory> completer = Completer<FileDirectory>();
final Completer<void> released = Completer<void>();
late final Timer timer;
_RemoteReadTask(this.includeHidden);
}
class FileFetcher {
// Map<String,Completer<FileDirectory>> localTasks = {}; // now we only use read local dir sync
Map<String, Completer<FileDirectory>> remoteTasks = {};
final Map<String, _RemoteReadTask> _remoteReadTasks = {};
Map<String, Completer<List<FileDirectory>>> remoteEmptyDirsTasks = {};
Map<int, Completer<FileDirectory>> readRecursiveTasks = {};
int _remoteSessionGeneration = 0;
final GetSessionID getSessionID;
final ReadRemoteDirectory _readRemoteDirectory;
SessionID get sessionId => getSessionID();
FileFetcher(this.getSessionID);
FileFetcher(this.getSessionID, {ReadRemoteDirectory? readRemoteDirectory})
: _readRemoteDirectory = readRemoteDirectory ??
((sessionId, path, includeHidden) => bind.sessionReadRemoteDir(
sessionId: sessionId,
path: path,
includeHidden: includeHidden));
bool hasPendingRemoteRead(String path) => _remoteReadTasks.containsKey(path);
bool isLikelyRemoteHomeResponse(String path) =>
_remoteReadTasks.isEmpty ||
(_remoteReadTasks.length == 1 &&
hasPendingRemoteRead("") &&
!hasPendingRemoteRead(path));
void beginRemoteSession() {
_remoteSessionGeneration++;
final pendingTasks = _remoteReadTasks.entries.toList(growable: false);
for (final entry in pendingTasks) {
final task = entry.value;
if (!_removeRemoteReadTask(entry.key, task)) continue;
task.completer.completeError(StateError(_kRemoteSessionChangedError));
}
}
_RemoteReadTask _registerRemoteReadTask(String path, bool includeHidden) {
if (hasPendingRemoteRead(path)) {
throw "Failed to registerReadTask, already have same read job";
}
final task = _RemoteReadTask(includeHidden);
_remoteReadTasks[path] = task;
task.timer = Timer(_kRemoteReadDirTimeout, () {
if (!_removeRemoteReadTask(path, task)) return;
task.completer.completeError("Failed to read dir, timeout");
});
return task;
}
bool _removeRemoteReadTask(String path, _RemoteReadTask task) {
if (!identical(_remoteReadTasks[path], task)) return false;
_remoteReadTasks.remove(path);
task.timer.cancel();
task.released.complete();
return true;
}
bool _completeRemoteReadTask(String path, FileDirectory directory) {
final task = _remoteReadTasks[path];
if (task == null || !_removeRemoteReadTask(path, task)) return false;
task.completer.complete(directory);
return true;
}
Future<List<FileDirectory>> registerReadEmptyDirsTask(
bool isLocal, String path) {
@@ -1391,23 +1496,6 @@ class FileFetcher {
return c.future;
}
Future<FileDirectory> registerReadTask(bool isLocal, String path) {
// final jobs = isLocal?localJobs:remoteJobs; // maybe we will use read local dir async later
final tasks = remoteTasks; // bypass now
if (tasks.containsKey(path)) {
throw "Failed to registerReadTask, already have same read job";
}
final c = Completer<FileDirectory>();
tasks[path] = c;
Timer(Duration(seconds: 2), () {
tasks.remove(path);
if (c.isCompleted) return;
c.completeError("Failed to read dir, timeout");
});
return c.future;
}
Future<FileDirectory> registerReadRecursiveTask(int actID) {
final tasks = readRecursiveTasks;
if (tasks.containsKey(actID)) {
@@ -1445,27 +1533,37 @@ class FileFetcher {
tryCompleteTask(String? msg, String? isLocalStr) {
if (msg == null || isLocalStr == null) return;
late final Map<Object, Completer<FileDirectory>> tasks;
try {
final fd = FileDirectory.fromJson(jsonDecode(msg));
if (fd.id > 0) {
// fd.id > 0 is result for read recursive
// to-do later,will be better if every fetch use ID,so that there will only one task map for read and recursive read
tasks = readRecursiveTasks;
final completer = tasks.remove(fd.id);
completer?.complete(fd);
} else if (fd.path.isNotEmpty) {
// result for normal read dir
// final jobs = isLocal?localJobs:remoteJobs; // maybe we will use read local dir async later
tasks = remoteTasks; // bypass now
final completer = tasks.remove(fd.path);
final completer = readRecursiveTasks.remove(fd.id);
completer?.complete(fd);
return;
}
if (isLocalStr == "false" && fd.path.isNotEmpty) {
if (_completeRemoteReadTask(fd.path, fd)) {
return;
}
// A Home request uses an empty path but returns its resolved path.
if (isLikelyRemoteHomeResponse(fd.path)) {
_completeRemoteReadTask("", fd);
}
}
} catch (e) {
debugPrint("tryCompleteJob err: $e");
}
}
bool tryCompleteRemoteTaskWithError(String error) {
if (_remoteReadTasks.length != 1) return false;
final entry = _remoteReadTasks.entries.single;
final task = entry.value;
if (!_removeRemoteReadTask(entry.key, task)) return false;
task.completer.completeError(error);
return true;
}
// Complete a pending recursive read task with an error.
// See FileModel.handleJobError() for why this is necessary.
void tryCompleteRecursiveTaskWithError(int id, String error) {
@@ -1506,9 +1604,26 @@ class FileFetcher {
final fd = FileDirectory.fromJson(jsonDecode(res));
return fd;
} else {
await bind.sessionReadRemoteDir(
sessionId: sessionId, path: path, includeHidden: showHidden);
return registerReadTask(isLocal, path);
final remoteSessionGeneration = _remoteSessionGeneration;
final pendingTask = _remoteReadTasks[path];
if (pendingTask != null) {
if (pendingTask.includeHidden == showHidden) {
return pendingTask.completer.future;
}
await pendingTask.released.future;
if (remoteSessionGeneration != _remoteSessionGeneration) {
throw StateError(_kRemoteSessionChangedError);
}
return fetchDirectory(path, isLocal, showHidden);
}
final task = _registerRemoteReadTask(path, showHidden);
unawaited(Future<void>.sync(
() => _readRemoteDirectory(sessionId, path, showHidden))
.catchError((Object error, StackTrace stackTrace) {
if (!_removeRemoteReadTask(path, task)) return;
task.completer.completeError(error, stackTrace);
}));
return task.completer.future;
}
} catch (e) {
return Future.error(e);
@@ -1790,7 +1905,7 @@ class PathUtil {
}
static bool validName(String name, bool isWindows) {
final unixFileNamePattern = RegExp(r'^[^/\0]+$');
final unixFileNamePattern = RegExp(r'^[^/\x00]+$');
final windowsFileNamePattern = RegExp(r'^[^<>:"/\\|?*]+$');
final reg = isWindows ? windowsFileNamePattern : unixFileNamePattern;
return reg.hasMatch(name);
@@ -1833,6 +1948,21 @@ class SelectedItems {
items.clear();
}
void reconcile(List<Entry> entries) {
if (items.isEmpty) return;
final currentByPath = {for (final entry in entries) entry.path: entry};
final reconciled = <Entry>[];
for (final item in items) {
final current = currentByPath[item.path];
if (current != null && current.entryType == item.entryType) {
reconciled.add(current);
}
}
items
..clear()
..addAll(reconciled);
}
void selectAll(List<Entry> entries) {
items.clear();
items.addAll(entries);

View File

@@ -1787,6 +1787,11 @@ class InputModel {
}
bool _checkPeerControlProtected(double x, double y) {
if (isViewOnly && showMyCursor) {
lastMousePos = ui.Offset(x, y);
return false;
}
final cursorModel = parent.target!.cursorModel;
if (cursorModel.isPeerControlProtected) {
lastMousePos = ui.Offset(x, y);

View File

@@ -117,10 +117,11 @@ String prepareTerminalInputPayload(
/// 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.
/// xterm already handles each platform's paste shortcut 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 off.
bool shouldHandleTerminalPasteShortcut({
required TargetPlatform platform,
required LogicalKeyboardKey logicalKey,
required bool isKeyDown,
required bool isKeyRepeat,
@@ -133,8 +134,18 @@ bool shouldHandleTerminalPasteShortcut({
if (!modifierLockActive) return false;
if (!isKeyDown && !isKeyRepeat) return false;
if (logicalKey != LogicalKeyboardKey.keyV) return false;
if (altPressed || shiftPressed) return false;
return controlPressed != metaPressed;
if (altPressed) return false;
switch (platform) {
case TargetPlatform.linux:
return controlPressed && !metaPressed && shiftPressed;
case TargetPlatform.iOS:
case TargetPlatform.macOS:
return !controlPressed && metaPressed && !shiftPressed;
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.windows:
return controlPressed && !metaPressed && !shiftPressed;
}
}
/// Returns true when collapsing Row3 should also clear hidden modifier state.

View File

@@ -25,9 +25,6 @@ import 'package:flutter_hbb/models/user_model.dart';
import 'package:flutter_hbb/models/state_model.dart';
import 'package:flutter_hbb/models/desktop_render_texture.dart';
import 'package:flutter_hbb/models/terminal_model.dart';
import 'package:flutter_hbb/plugin/event.dart';
import 'package:flutter_hbb/plugin/manager.dart';
import 'package:flutter_hbb/plugin/widgets/desc_ui.dart';
import 'package:flutter_hbb/common/shared_state.dart';
import 'package:flutter_hbb/utils/multi_window_manager.dart';
import 'package:flutter_hbb/utils/http_service.dart' as http;
@@ -127,6 +124,8 @@ class FfiModel with ChangeNotifier {
Timer? _restartReconnectDelayTimer;
var _reconnects = 1;
DateTime? _offlineReconnectStartTime;
bool _androidDocumentPickerActive = false;
bool _androidDocumentPickerInterruptedConnection = false;
bool _viewOnly = false;
bool _showMyCursor = false;
WeakReference<FFI> parent;
@@ -258,6 +257,8 @@ class FfiModel with ChangeNotifier {
_inputBlocked = false;
_timer?.cancel();
_timer = null;
_androidDocumentPickerActive = false;
_androidDocumentPickerInterruptedConnection = false;
resetRestartReconnectState();
clearPermissions();
waitForImageTimer?.cancel();
@@ -437,15 +438,6 @@ class FfiModel with ChangeNotifier {
parent.target?.serverModel.updateVoiceCallState(evt);
} else if (name == 'fingerprint') {
FingerprintState.find(peerId).value = evt['fingerprint'] ?? '';
} else if (name == 'plugin_manager') {
pluginManager.handleEvent(evt);
} else if (name == 'plugin_event') {
handlePluginEvent(evt,
(Map<String, dynamic> e) => handleMsgBox(e, sessionId, peerId));
} else if (name == 'plugin_reload') {
handleReloading(evt);
} else if (name == 'plugin_option') {
handleOption(evt);
} else if (name == "sync_peer_hash_password_to_personal_ab") {
if (desktopType == DesktopType.main || isWeb || isMobile) {
final id = evt['id'];
@@ -904,6 +896,13 @@ class FfiModel with ChangeNotifier {
final text = evt['text'];
final link = evt['link'];
if (isAndroid &&
_androidDocumentPickerActive &&
title == 'Connection Error') {
_androidDocumentPickerInterruptedConnection = true;
return;
}
// Disable relative mouse mode on any error-type message to ensure cursor is released.
// This includes connection errors, session-ending messages, elevation errors, etc.
// Safety: releasing pointer lock on errors prevents the user from being stuck.
@@ -920,17 +919,12 @@ class FfiModel with ChangeNotifier {
enter2FaDialog(sessionId, dialogManager);
} else if (type == 'input-password') {
enterPasswordDialog(sessionId, dialogManager);
} else if (type == 'session-login' || type == 'session-re-login') {
enterUserLoginDialog(sessionId, dialogManager, 'login_linux_tip', true);
} else if (type == 'session-login-password') {
enterUserLoginAndPasswordDialog(
sessionId, dialogManager, 'login_linux_tip', true);
} else if (type == 'terminal-admin-login') {
enterUserLoginDialog(
sessionId, dialogManager, 'terminal-admin-login-tip', false);
sessionId, dialogManager, 'terminal-admin-login-tip');
} else if (type == 'terminal-admin-login-password') {
enterUserLoginAndPasswordDialog(
sessionId, dialogManager, 'terminal-admin-login-tip', false);
sessionId, dialogManager, 'terminal-admin-login-tip');
} else if (type == 'restarting') {
// Treat restart messages as reconnect control events. Rust still sends
// title/text for legacy UI and translation reuse; Flutter keeps the last
@@ -985,6 +979,23 @@ class FfiModel with ChangeNotifier {
_restartReconnectDelayTimer = null;
}
void beginAndroidDocumentPicker() {
if (!isAndroid) return;
_androidDocumentPickerActive = true;
_androidDocumentPickerInterruptedConnection = false;
}
void endAndroidDocumentPicker() {
if (!isAndroid) return;
_androidDocumentPickerActive = false;
if (!_androidDocumentPickerInterruptedConnection ||
parent.target?.closed == true) {
return;
}
_androidDocumentPickerInterruptedConnection = false;
reconnect(parent.target!.dialogManager, sessionId, false);
}
/// Auto-retry check for "Remote desktop is offline" error.
/// returns true to auto-retry, false otherwise.
bool shouldAutoRetryOnOffline(
@@ -1952,6 +1963,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 +1980,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 +2000,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;
@@ -2206,6 +2236,7 @@ class CanvasModel with ChangeNotifier {
double _y = 0;
// image scale
double _scale = 1.0;
bool _locked = false;
double _devicePixelRatio = 1.0;
Size _size = Size.zero;
// the tabbar over the image
@@ -2254,12 +2285,19 @@ class CanvasModel with ChangeNotifier {
double get x => _x;
double get y => _y;
double get scale => _scale;
bool get locked => _locked;
double get devicePixelRatio => _devicePixelRatio;
Size get size => _size;
ScrollStyle get scrollStyle => _scrollStyle;
ViewStyle get viewStyle => _lastViewStyle;
RxBool get imageOverflow => _imageOverflow;
void setLocked(bool value) {
if (_locked == value) return;
_locked = value;
notifyListeners();
}
_resetScroll() => setScrollPercent(0.0, 0.0);
void setScrollPercent(double x, double y) {
@@ -2488,6 +2526,7 @@ class CanvasModel with ChangeNotifier {
}
void updateLocalCursor(double x, double y) {
if (parent.target?.ffiModel.viewOnly == true) return;
// If keyboard is not permitted, do not move cursor when mouse is moving.
if (parent.target != null && parent.target!.ffiModel.keyboard) {
// Draw cursor if is not desktop.
@@ -2720,6 +2759,7 @@ class CanvasModel with ChangeNotifier {
_x = 0;
_y = 0;
_scale = 1.0;
_locked = false;
_lastViewStyle = ViewStyle.defaultViewStyle();
_timerMobileFocusCanvasCursor?.cancel();
_timerMobileRestoreCanvasOffset?.cancel();
@@ -2831,7 +2871,7 @@ class CursorData {
required this.width,
required this.height,
}) : hotx = hotxOrigin * scale,
hoty = hotxOrigin * scale;
hoty = hotyOrigin * scale;
int _doubleToInt(double v) => (v * 10e6).round().toInt();
@@ -3853,6 +3893,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 +3989,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 +4045,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) {
@@ -4036,6 +4088,11 @@ class FFI {
return await platformFFI.invokeMethod(method, arguments);
}
Future<T?> invokeMethodWithResult<T>(String method,
[dynamic arguments]) async {
return await platformFFI.invokeMethodWithResult<T>(method, arguments);
}
// Terminal model management
void registerTerminalModel(int terminalId, TerminalModel model) {
debugPrint('[FFI] Registering terminal model for terminal $terminalId');
@@ -4140,7 +4197,6 @@ class PeerInfo with ChangeNotifier {
RxBool isSet = false.obs;
bool get isWayland => platformAdditions[kPlatformAdditionsIsWayland] == true;
bool get isHeadless => platformAdditions[kPlatformAdditionsHeadless] == true;
bool get isInstalled =>
platform != kPeerPlatformWindows ||
platformAdditions[kPlatformAdditionsIsInstalled] == true;

View File

@@ -1,9 +1,9 @@
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';
import 'package:ffi/ffi.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
@@ -170,8 +170,10 @@ class PlatformFFI {
_startListenEvent(_ffiBind); // global event
try {
if (isAndroid) {
// only support for android
_homeDir = (await ExternalPath.getExternalStorageDirectories())[0];
// Android file transfer uses app-specific storage. User-selected
// files enter and leave this workspace through the system picker.
_homeDir = (await getExternalStorageDirectory())?.path ??
(await getApplicationSupportDirectory()).path;
} else if (isIOS) {
// The previous code was `_homeDir = (await getDownloadsDirectory())?.path ?? '';`,
// which provided the `downloads` path in the sandbox.
@@ -283,6 +285,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() {}
@@ -299,6 +307,12 @@ class PlatformFFI {
return await _toAndroidChannel.invokeMethod(method, arguments);
}
Future<T?> invokeMethodWithResult<T>(String method,
[dynamic arguments]) async {
if (!isAndroid) return null;
return await _toAndroidChannel.invokeMethod<T>(method, arguments);
}
void syncAndroidServiceAppDirConfigPath() {
invokeMethod(AndroidChannel.kSyncAppDirConfigPath, _dir);
}

View 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);
}
}

View File

@@ -210,15 +210,10 @@ class ServerModel with ChangeNotifier {
_audioOk = audioOption != 'N';
}
// file
if (!await AndroidPermissionManager.check(kManageExternalStorage)) {
_fileOk = false;
bind.mainSetOption(key: kOptionEnableFileTransfer, value: "N");
} else {
final fileOption =
await bind.mainGetOption(key: kOptionEnableFileTransfer);
_fileOk = fileOption != 'N';
}
// Android file transfer is confined to app-specific storage. Files enter
// and leave the workspace through Android's system document picker.
final fileOption = await bind.mainGetOption(key: kOptionEnableFileTransfer);
_fileOk = fileOption != 'N';
// clipboard
final clipOption = await bind.mainGetOption(key: kOptionEnableClipboard);
@@ -319,16 +314,6 @@ class ServerModel with ChangeNotifier {
if (clients.any((c) => !c.disconnected)) {
await showClientsMayNotBeChangedAlert(parent.target);
}
if (!_fileOk &&
!await AndroidPermissionManager.check(kManageExternalStorage)) {
final res =
await AndroidPermissionManager.request(kManageExternalStorage);
if (!res) {
showToast(translate('Failed'));
return;
}
}
_fileOk = !_fileOk;
bind.mainSetOption(
key: kOptionEnableFileTransfer,
@@ -418,9 +403,6 @@ class ServerModel with ChangeNotifier {
if (bind.mainGetLocalOption(key: kOptionDisableFloatingWindow) != 'Y') {
await checkFloatingWindowPermission();
}
if (!await AndroidPermissionManager.check(kManageExternalStorage)) {
await AndroidPermissionManager.request(kManageExternalStorage);
}
final res = await parent.target?.dialogManager
.show<bool>((setState, close, context) {
submit() => close(true);
@@ -738,9 +720,13 @@ class ServerModel with ChangeNotifier {
}
}
Future<void> closeAll() async {
await Future.wait(
_clients.map((client) => bind.cmCloseConnection(connId: client.id)));
/// `byOperator` false means the CM's window went away rather than a person asking for the
/// peers to go. The sessions end either way; only the close reason differs, and with it
/// whether the peer is allowed to reconnect. See `ipc::Data::CmWindowClosed`.
Future<void> closeAll({bool byOperator = true}) async {
await Future.wait(_clients.map((client) => byOperator
? bind.cmCloseConnection(connId: client.id)
: bind.cmCloseConnectionWindow(connId: client.id)));
_clients.clear();
tabController.state.value.tabs.clear();
if (isAndroid) androidUpdatekeepScreenOn();

View File

@@ -0,0 +1,91 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:xterm/xterm.dart';
const _controlShiftVPasteShortcut = SingleActivator(
LogicalKeyboardKey.keyV,
control: true,
shift: true,
);
Future<void> writeTerminalClipboard(String text) async {
try {
await Clipboard.setData(ClipboardData(text: text));
} catch (error) {
debugPrint('[Terminal] Failed to write clipboard: $error');
}
}
Map<ShortcutActivator, Intent>? platformTerminalShortcuts() {
final platform = defaultTargetPlatform;
if (platform == TargetPlatform.linux) {
return {
for (final entry in defaultTerminalShortcuts.entries)
if (!_isControlShortcut(entry.key, LogicalKeyboardKey.keyV))
entry.key: entry.value,
_controlShiftVPasteShortcut:
const PasteTextIntent(SelectionChangedCause.keyboard),
};
}
if (platform != TargetPlatform.windows &&
platform != TargetPlatform.android) {
return null;
}
return {
for (final entry in defaultTerminalShortcuts.entries)
if (!_isControlShortcut(
entry.key,
LogicalKeyboardKey.keyC,
shift: true,
))
entry.key: entry.value,
};
}
bool _isControlShortcut(
ShortcutActivator shortcut,
LogicalKeyboardKey key, {
bool shift = false,
}) =>
shortcut is SingleActivator &&
shortcut.trigger == key &&
shortcut.control &&
shortcut.shift == shift &&
!shortcut.alt &&
!shortcut.meta;
FocusOnKeyEventCallback terminalCopyHandler(
Terminal terminal,
TerminalController controller, {
FocusOnKeyEventCallback? fallback,
}) =>
(focusNode, event) {
if (_isSelectionCopyShortcut(event)) {
final selection = controller.selection;
if (selection != null && !selection.isCollapsed) {
if (event is KeyDownEvent) {
final text = terminal.buffer.getText(selection);
unawaited(writeTerminalClipboard(text));
}
return KeyEventResult.handled;
}
}
return fallback?.call(focusNode, event) ?? KeyEventResult.ignored;
};
bool _isSelectionCopyShortcut(KeyEvent event) {
final keyboard = HardwareKeyboard.instance;
final platform = defaultTargetPlatform;
final usesControlCopy =
platform == TargetPlatform.windows || platform == TargetPlatform.android;
return usesControlCopy &&
(event is KeyDownEvent || event is KeyRepeatEvent) &&
event.logicalKey == LogicalKeyboardKey.keyC &&
keyboard.isControlPressed &&
!keyboard.isShiftPressed &&
!keyboard.isAltPressed &&
!keyboard.isMetaPressed;
}

View File

@@ -10,6 +10,8 @@ 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
@@ -128,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

View File

@@ -0,0 +1,235 @@
import 'dart:async';
import 'package:flutter/gestures.dart';
import 'package:flutter/services.dart';
import 'package:xterm/xterm.dart';
const _cellIndexOffset = 1;
const _legacyCodeOffset = 32;
const _leftButtonCode = 0;
const _motionButtonCode = 32;
const _releaseButtonCode = 3;
const _shiftModifierCode = 4;
const _metaModifierCode = 8;
const _controlModifierCode = 16;
const _modifierCodeMask =
_shiftModifierCode | _metaModifierCode | _controlModifierCode;
const _normalCoordinateLimit = 223;
const _utfCoordinateLimit = 2015;
String encodeTerminalMouseReport(
MouseReportMode mode,
int button,
CellOffset position, {
bool release = false,
}) {
final x = position.x + _cellIndexOffset;
final y = position.y + _cellIndexOffset;
final reportedButton =
release ? _releaseButtonCode | (button & _modifierCodeMask) : button;
switch (mode) {
case MouseReportMode.normal:
case MouseReportMode.utf:
final limit = mode == MouseReportMode.normal
? _normalCoordinateLimit
: _utfCoordinateLimit;
final encodedButton =
String.fromCharCode(_legacyCodeOffset + reportedButton);
return '\x1b[M$encodedButton${_legacyCoordinate(x, limit)}'
'${_legacyCoordinate(y, limit)}';
case MouseReportMode.sgr:
final suffix = release ? 'm' : 'M';
return '\x1b[<$button;$x;$y$suffix';
case MouseReportMode.urxvt:
return '\x1b[${_legacyCodeOffset + reportedButton};$x;${y}M';
}
}
String _legacyCoordinate(int value, int limit) =>
value > limit ? '\x00' : String.fromCharCode(_legacyCodeOffset + value);
int _activeModifierCode() {
final keyboard = HardwareKeyboard.instance;
return (keyboard.isShiftPressed ? _shiftModifierCode : 0) |
(keyboard.isAltPressed ? _metaModifierCode : 0) |
(keyboard.isControlPressed ? _controlModifierCode : 0);
}
class TerminalMouseDragReporter {
int? _pointerId;
TerminalController? _controller;
late CellOffset _lastReportedPosition;
var _ownsControllerSuspension = false;
var _releasePending = false;
var _reporting = false;
bool handleDown(
PointerDownEvent event,
Terminal terminal,
TerminalViewState? terminalView,
) {
if (!_isPrimaryMouse(event) || !_reportsDrag(terminal.mouseMode)) {
return false;
}
if (terminalView == null || terminalView.widget.readOnly) return false;
final controller = terminalView.widget.controller;
if (controller == null ||
controller.suspendedPointerInputs ||
!controller.pointerInput.inputs.contains(PointerInput.tap)) {
return false;
}
cancel();
_pointerId = event.pointer;
_controller = controller;
_ownsControllerSuspension = true;
_releasePending = true;
_reporting = true;
controller.setSuspendPointerInput(true);
_clearSelection(controller);
final position = _cellAt(event, terminalView);
_lastReportedPosition = position;
terminal.textInput(
_report(terminal.mouseReportMode, position),
);
return true;
}
bool handleMove(
PointerMoveEvent event,
Terminal terminal,
TerminalViewState? terminalView,
) {
if (event.pointer != _pointerId) return false;
if (terminalView == null) {
cancel();
return true;
}
final reportsDrag = _reportsDrag(terminal.mouseMode);
if (!_isPrimaryMouse(event)) {
if (_releasePending && reportsDrag) {
_reportRelease(
terminal,
_reporting ? _cellAt(event, terminalView) : _lastReportedPosition,
);
}
cancel();
return true;
}
if (!_reporting || !reportsDrag) {
if (!reportsDrag) _releasePending = false;
_reporting = false;
// Keep ownership until the matching end event to suppress local selection.
final controller = _controller;
scheduleMicrotask(() => _clearSelection(controller));
return true;
}
final position = _cellAt(event, terminalView);
_lastReportedPosition = position;
terminal.textInput(
_report(terminal.mouseReportMode, position, motion: true),
);
final controller = _controller;
scheduleMicrotask(() => _clearSelection(controller));
return true;
}
bool handleEnd(
PointerEvent event,
Terminal terminal,
TerminalViewState? terminalView,
) {
if (event.pointer != _pointerId) return false;
if (terminalView != null &&
_releasePending &&
_reportsDrag(terminal.mouseMode)) {
_reportRelease(
terminal,
_reporting ? _cellAt(event, terminalView) : _lastReportedPosition,
);
}
_clearSelection(_controller);
final controller = _controller;
_pointerId = null;
// Keep xterm's tap recognizer suspended for this pointer event.
scheduleMicrotask(() {
if (_pointerId == null && identical(_controller, controller)) {
_clearSelection(controller);
cancel();
}
});
return true;
}
void cancel() {
final controller = _controller;
if (_ownsControllerSuspension) {
controller?.setSuspendPointerInput(false);
}
_pointerId = null;
_controller = null;
_ownsControllerSuspension = false;
_releasePending = false;
_reporting = false;
}
void updateController(TerminalController controller) {
final oldController = _controller;
if (_pointerId == null || oldController == null) {
cancel();
return;
}
if (identical(oldController, controller)) return;
if (_ownsControllerSuspension) {
oldController.setSuspendPointerInput(false);
}
final acceptsPointerInput = !controller.suspendedPointerInputs &&
controller.pointerInput.inputs.contains(PointerInput.tap);
_controller = controller;
_ownsControllerSuspension = acceptsPointerInput;
_reporting = _reporting && acceptsPointerInput;
if (_ownsControllerSuspension) controller.setSuspendPointerInput(true);
_clearSelection(controller);
}
void _reportRelease(Terminal terminal, CellOffset position) {
terminal.textInput(
_report(
terminal.mouseReportMode,
position,
release: true,
),
);
}
CellOffset _cellAt(PointerEvent event, TerminalViewState terminalView) {
final renderTerminal = terminalView.renderTerminal;
return renderTerminal.getCellOffset(
renderTerminal.globalToLocal(event.position),
);
}
bool _isPrimaryMouse(PointerEvent event) =>
event.kind == PointerDeviceKind.mouse &&
(event.buttons & kPrimaryMouseButton) == kPrimaryMouseButton;
bool _reportsDrag(MouseMode mode) =>
mode == MouseMode.upDownScrollDrag || mode == MouseMode.upDownScrollMove;
void _clearSelection(TerminalController? controller) {
if (controller == null || controller.selection == null) return;
controller.clearSelection();
}
String _report(
MouseReportMode mode,
CellOffset position, {
bool release = false,
bool motion = false,
}) {
final baseButton = motion ? _motionButtonCode : _leftButtonCode;
final button = baseButton | _activeModifierCode();
return encodeTerminalMouseReport(mode, button, position, release: release);
}
}

View File

@@ -0,0 +1,301 @@
import 'dart:async';
import 'package:flutter/gestures.dart';
import 'package:flutter/widgets.dart';
import 'package:xterm/xterm.dart';
import 'terminal_copy_shortcut.dart';
import 'terminal_mouse_drag_reporter.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({this.positionProvider});
final CellOffset? Function()? positionProvider;
@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 position = positionProvider?.call() ?? event.position;
return encodeTerminalMouseReport(
event.state.mouseReportMode,
button,
position,
);
}
}
class TerminalMouseInteraction extends StatefulWidget {
const TerminalMouseInteraction(
this.terminal, {
super.key,
required this.controller,
this.focusNode,
this.backgroundOpacity = 1,
this.padding,
this.onSecondaryTapDown,
});
final Terminal terminal;
final TerminalController controller;
final FocusNode? focusNode;
final double backgroundOpacity;
final EdgeInsets? padding;
final void Function(TapDownDetails, CellOffset)? onSecondaryTapDown;
@override
State<TerminalMouseInteraction> createState() =>
_TerminalMouseInteractionState();
}
class _TerminalMouseInteractionState extends State<TerminalMouseInteraction> {
static const _selectionScrollInterval = Duration(milliseconds: 50);
static const _noScroll = 0;
static const _scrollUp = -1;
static const _scrollDown = 1;
final _terminalViewKey = GlobalKey<TerminalViewState>();
final _scrollController = ScrollController();
final _mouseDrag = TerminalMouseDragReporter();
late final WheelButtonFixMouseHandler _mouseHandler;
TerminalMouseHandler? _previousMouseHandler;
Offset? _pointerPosition;
Offset? _selectionPointer;
CellAnchor? _selectionBase;
Buffer? _selectionBuffer;
int? _selectionPointerId;
Timer? _selectionScrollTimer;
var _selectionHasScrolled = false;
var _scrollDirection = _noScroll;
TerminalViewState? get _terminalView => _terminalViewKey.currentState;
@override
void initState() {
super.initState();
_mouseHandler = WheelButtonFixMouseHandler(
positionProvider: _cellAtPointer,
);
_installMouseHandler(widget.terminal);
}
@override
void didUpdateWidget(TerminalMouseInteraction oldWidget) {
super.didUpdateWidget(oldWidget);
final terminalChanged = !identical(oldWidget.terminal, widget.terminal);
final controllerChanged =
!identical(oldWidget.controller, widget.controller);
if (!terminalChanged && !controllerChanged) return;
if (controllerChanged && !terminalChanged) {
_mouseDrag.updateController(widget.controller);
} else {
_mouseDrag.cancel();
}
_clearSelectionDrag();
if (!terminalChanged) return;
_restoreMouseHandler(oldWidget.terminal);
_installMouseHandler(widget.terminal);
}
void _installMouseHandler(Terminal terminal) {
_previousMouseHandler = terminal.mouseHandler;
terminal.mouseHandler = _mouseHandler;
}
void _restoreMouseHandler(Terminal terminal) {
if (identical(terminal.mouseHandler, _mouseHandler)) {
terminal.mouseHandler = _previousMouseHandler;
}
}
CellOffset? _cellAtPointer() {
final terminalView = _terminalView;
final pointerPosition = _pointerPosition;
if (terminalView == null || pointerPosition == null) return null;
final renderTerminal = terminalView.renderTerminal;
return renderTerminal.getCellOffset(
renderTerminal.globalToLocal(pointerPosition),
);
}
void _updatePointerPosition(PointerEvent event) =>
_pointerPosition = event.position;
void _handlePointerDown(PointerDownEvent event) {
_updatePointerPosition(event);
if (_mouseDrag.handleDown(event, widget.terminal, _terminalView)) {
_clearSelectionDrag();
return;
}
if (event.kind != PointerDeviceKind.mouse ||
(event.buttons & kPrimaryMouseButton) != kPrimaryMouseButton) {
return;
}
_clearSelectionDrag();
final terminalView = _terminalView;
if (terminalView == null) return;
final renderTerminal = terminalView.renderTerminal;
final localPosition = renderTerminal.globalToLocal(event.position);
final selectionBuffer = widget.terminal.buffer;
_selectionPointerId = event.pointer;
_selectionBase = selectionBuffer.createAnchorFromOffset(
renderTerminal.getCellOffset(localPosition),
);
_selectionBuffer = selectionBuffer;
_selectionPointer = localPosition;
}
void _handlePointerMove(PointerMoveEvent event) {
_updatePointerPosition(event);
if (_mouseDrag.handleMove(event, widget.terminal, _terminalView)) return;
if (event.pointer != _selectionPointerId) return;
if (event.kind != PointerDeviceKind.mouse ||
(event.buttons & kPrimaryMouseButton) != kPrimaryMouseButton) {
_clearSelectionDrag();
return;
}
final terminalView = _terminalView;
if (terminalView == null || _selectionBase == null) return;
final renderTerminal = terminalView.renderTerminal;
final localPosition = renderTerminal.globalToLocal(event.position);
_selectionPointer = localPosition;
_setScrollDirection(
_directionFor(localPosition, renderTerminal.paintBounds),
);
if (_selectionHasScrolled) {
scheduleMicrotask(() => _scrollSelection(scroll: false));
}
}
int _directionFor(Offset position, Rect bounds) {
if (position.dy < bounds.top) return _scrollUp;
if (position.dy >= bounds.bottom) return _scrollDown;
return _noScroll;
}
void _setScrollDirection(int direction) {
if (_scrollDirection == direction) return;
_stopAutoScroll();
_scrollDirection = direction;
if (direction == _noScroll) return;
_scrollSelection();
if (_scrollDirection != _noScroll) {
_selectionScrollTimer = Timer.periodic(
_selectionScrollInterval,
(_) => _scrollSelection(),
);
}
}
void _scrollSelection({bool scroll = true}) {
final terminalView = _terminalView;
final selectionBase = _selectionBase;
final selectionBuffer = _selectionBuffer;
final selectionPointer = _selectionPointer;
if (terminalView == null ||
selectionBase == null ||
selectionBuffer == null ||
selectionPointer == null ||
!_scrollController.hasClients) {
return;
}
if (!identical(selectionBuffer, widget.terminal.buffer) ||
!selectionBase.attached) {
_clearSelectionDrag();
return;
}
final renderTerminal = terminalView.renderTerminal;
if (scroll) {
final position = _scrollController.position;
final target =
(position.pixels + renderTerminal.lineHeight * _scrollDirection)
.clamp(position.minScrollExtent, position.maxScrollExtent)
.toDouble();
if (target == position.pixels) {
_stopAutoScroll();
} else {
position.jumpTo(target);
_selectionHasScrolled = true;
}
}
renderTerminal.selectCharacters(
renderTerminal.getOffset(selectionBase.offset),
selectionPointer,
);
}
void _handlePointerEnd(PointerEvent event) {
_updatePointerPosition(event);
if (!_mouseDrag.handleEnd(event, widget.terminal, _terminalView) &&
event.pointer != _selectionPointerId) return;
if (_selectionHasScrolled) _scrollSelection(scroll: false);
_clearSelectionDrag();
}
void _clearSelectionDrag() {
_selectionPointerId = null;
_selectionBase?.dispose();
_selectionBase = null;
_selectionBuffer = null;
_selectionPointer = null;
_selectionHasScrolled = false;
_stopAutoScroll();
}
void _stopAutoScroll() {
_selectionScrollTimer?.cancel();
_selectionScrollTimer = null;
_scrollDirection = _noScroll;
}
@override
void dispose() {
_mouseDrag.cancel();
_clearSelectionDrag();
_restoreMouseHandler(widget.terminal);
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Listener(
onPointerDown: _handlePointerDown,
onPointerMove: _handlePointerMove,
onPointerUp: _handlePointerEnd,
onPointerHover: _updatePointerPosition,
onPointerCancel: _handlePointerEnd,
onPointerSignal: _updatePointerPosition,
onPointerPanZoomStart: _updatePointerPosition,
onPointerPanZoomUpdate: _updatePointerPosition,
onPointerPanZoomEnd: _updatePointerPosition,
child: TerminalView(
widget.terminal,
key: _terminalViewKey,
controller: widget.controller,
scrollController: _scrollController,
focusNode: widget.focusNode,
backgroundOpacity: widget.backgroundOpacity,
padding: widget.padding,
shortcuts: platformTerminalShortcuts(),
onKeyEvent: terminalCopyHandler(widget.terminal, widget.controller),
onSecondaryTapDown: widget.onSecondaryTapDown,
),
);
}
}

View File

@@ -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) => {
@@ -162,6 +189,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()));
@@ -184,6 +251,11 @@ class PlatformFFI {
return true;
}
Future<T?> invokeMethodWithResult<T>(String method,
[dynamic arguments]) async {
return null;
}
// just for compilation
void syncAndroidServiceAppDirConfigPath() {}

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

View File

@@ -10,8 +10,6 @@ final isWebDesktop_ = false;
final isDesktop_ = Platform.isWindows || Platform.isMacOS || Platform.isLinux;
String get screenInfo_ => '';
final isWebOnWindows_ = false;
final isWebOnLinux_ = false;
final isWebOnMacOS_ = false;

View File

@@ -1,42 +0,0 @@
import 'dart:convert';
typedef PluginId = String;
// ui location
const String kLocationHostMainPlugin = 'host|main|settings|plugin';
const String kLocationClientRemoteToolbarDisplay =
'client|remote|toolbar|display';
class MsgFromUi {
String id;
String name;
String location;
String key;
String value;
String action;
MsgFromUi({
required this.id,
required this.name,
required this.location,
required this.key,
required this.value,
required this.action,
});
Map<String, dynamic> toJson() {
return <String, dynamic>{
'id': id,
'name': name,
'location': location,
'key': key,
'value': value,
'action': action,
};
}
@override
String toString() {
return jsonEncode(toJson());
}
}

View File

@@ -1,18 +0,0 @@
import 'dart:convert';
import 'package:flutter/material.dart';
void handlePluginEvent(
Map<String, dynamic> evt,
Function(Map<String, dynamic> e) handleMsgBox,
) {
Map<String, dynamic>? content;
try {
content = json.decode(evt['content']);
} catch (e) {
debugPrint(
'Json decode plugin event content failed: $e, ${evt['content']}');
}
if (content?['t'] == 'MsgBox') {
handleMsgBox(content?['c']);
}
}

View File

@@ -1,79 +0,0 @@
import 'dart:convert';
import 'dart:ffi';
import 'package:ffi/ffi.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hbb/plugin/ui_manager.dart';
import 'package:flutter_hbb/plugin/utils/dialogs.dart';
abstract class NativeHandler {
bool onEvent(Map<String, dynamic> evt);
}
typedef OnSelectPeersCallback = Bool Function(Int returnCode,
Pointer<Void> data, Uint64 dataLength, Pointer<Void> userData);
typedef OnSelectPeersCallbackDart = bool Function(
int returnCode, Pointer<Void> data, int dataLength, Pointer<Void> userData);
class NativeUiHandler extends NativeHandler {
NativeUiHandler._();
static NativeUiHandler instance = NativeUiHandler._();
@override
bool onEvent(Map<String, dynamic> evt) {
final name = evt['name'];
final action = evt['action'];
if (name != "native_ui") {
return false;
}
switch (action) {
case "select_peers":
int cb = evt['cb'];
int userData = evt['user_data'] ?? 0;
final cbFuncNative = Pointer.fromAddress(cb)
.cast<NativeFunction<OnSelectPeersCallback>>();
final cbFuncDart = cbFuncNative.asFunction<OnSelectPeersCallbackDart>();
onSelectPeers(cbFuncDart, userData);
break;
case "register_ui_entry":
int cb = evt['on_tap_cb'];
int userData = evt['user_data'] ?? 0;
String title = evt['title'] ?? "";
final cbFuncNative = Pointer.fromAddress(cb)
.cast<NativeFunction<OnSelectPeersCallback>>();
final cbFuncDart = cbFuncNative.asFunction<OnSelectPeersCallbackDart>();
onRegisterUiEntry(title, cbFuncDart, userData);
break;
default:
return false;
}
return true;
}
void onSelectPeers(OnSelectPeersCallbackDart cb, int userData) async {
showPeerSelectionDialog(onPeersCallback: (peers) {
String json = jsonEncode(<String, dynamic> {
"peers": peers
});
final native = json.toNativeUtf8();
cb(0, native.cast(), native.length, Pointer.fromAddress(userData));
malloc.free(native);
});
}
void onRegisterUiEntry(String title, OnSelectPeersCallbackDart cbFuncDart, int userData) {
Widget widget = InkWell(
child: Container(
height: 25.0,
child: Row(
children: [
Expanded(child: Text(title)),
Icon(Icons.chevron_right_rounded, size: 12.0,)
],
),
),
);
PluginUiManager.instance.registerEntry(title, widget);
}
}

View File

@@ -1,319 +0,0 @@
// The plugin manager is a singleton class that manages the plugins.
// 1. It merge metadata and the desc of plugins.
import 'dart:convert';
import 'dart:collection';
import 'package:flutter/material.dart';
const String kValueTrue = '1';
const String kValueFalse = '0';
class ConfigItem {
String key;
String description;
String defaultValue;
ConfigItem(this.key, this.defaultValue, this.description);
ConfigItem.fromJson(Map<String, dynamic> json)
: key = json['key'] ?? '',
description = json['description'] ?? '',
defaultValue = json['default'] ?? '';
static String get trueValue => kValueTrue;
static String get falseValue => kValueFalse;
static bool isTrue(String value) => value == kValueTrue;
static bool isFalse(String value) => value == kValueFalse;
}
class UiType {
String key;
String text;
String tooltip;
String action;
UiType(this.key, this.text, this.tooltip, this.action);
UiType.fromJson(Map<String, dynamic> json)
: key = json['key'] ?? '',
text = json['text'] ?? '',
tooltip = json['tooltip'] ?? '',
action = json['action'] ?? '';
static UiType? create(Map<String, dynamic> json) {
if (json['t'] == 'Button') {
return UiButton.fromJson(json['c']);
} else if (json['t'] == 'Checkbox') {
return UiCheckbox.fromJson(json['c']);
} else {
return null;
}
}
}
class UiButton extends UiType {
String icon;
UiButton(
{required String key,
required String text,
required this.icon,
required String tooltip,
required String action})
: super(key, text, tooltip, action);
UiButton.fromJson(Map<String, dynamic> json)
: icon = json['icon'] ?? '',
super.fromJson(json);
}
class UiCheckbox extends UiType {
UiCheckbox(
{required String key,
required String text,
required String tooltip,
required String action})
: super(key, text, tooltip, action);
UiCheckbox.fromJson(Map<String, dynamic> json) : super.fromJson(json);
}
class Location {
// location key:
// host|main|settings|plugin
// client|remote|toolbar|display
HashMap<String, UiType> ui;
Location(this.ui);
Location.fromJson(Map<String, dynamic> json) : ui = HashMap() {
(json['ui'] as Map<String, dynamic>).forEach((key, value) {
var ui = UiType.create(value);
if (ui != null) {
this.ui[ui.key] = ui;
}
});
}
}
class PublishInfo {
PublishInfo({
required this.lastReleased,
required this.published,
});
final DateTime lastReleased;
final DateTime published;
}
class Meta {
Meta({
required this.id,
required this.name,
required this.version,
required this.description,
required this.author,
required this.home,
required this.license,
required this.publishInfo,
required this.source,
});
final String id;
final String name;
final String version;
final String description;
final String author;
final String home;
final String license;
final PublishInfo publishInfo;
final String source;
}
class SourceInfo {
String name; // 1. RustDesk github 2. Local
String url;
String description;
SourceInfo({
required this.name,
required this.url,
required this.description,
});
}
class PluginInfo with ChangeNotifier {
SourceInfo sourceInfo;
Meta meta;
String installedVersion; // It is empty if not installed.
String failedMsg;
String invalidReason; // It is empty if valid.
PluginInfo({
required this.sourceInfo,
required this.meta,
required this.installedVersion,
required this.invalidReason,
this.failedMsg = '',
});
bool get installed => installedVersion.isNotEmpty;
bool get needUpdate => installed && installedVersion != meta.version;
void setInstall(String msg) {
if (msg == "finished") {
msg = '';
}
failedMsg = msg;
if (msg.isEmpty) {
installedVersion = meta.version;
}
notifyListeners();
}
void setUninstall(String msg) {
failedMsg = msg;
if (msg.isEmpty) {
installedVersion = '';
}
notifyListeners();
}
}
class PluginManager with ChangeNotifier {
String failedReason = ''; // The reason of failed to load plugins.
final List<PluginInfo> _plugins = [];
PluginManager._();
static final PluginManager _instance = PluginManager._();
static PluginManager get instance => _instance;
List<PluginInfo> get plugins => _plugins;
PluginInfo? getPlugin(String id) {
for (var p in _plugins) {
if (p.meta.id == id) {
return p;
}
}
return null;
}
void handleEvent(Map<String, dynamic> evt) {
if (evt['plugin_list'] != null) {
_handlePluginList(evt['plugin_list']);
} else if (evt['plugin_install'] != null && evt['id'] != null) {
_handlePluginInstall(evt['id'], evt['plugin_install']);
} else if (evt['plugin_uninstall'] != null && evt['id'] != null) {
_handlePluginUninstall(evt['id'], evt['plugin_uninstall']);
} else {
debugPrint('Failed to handle manager event: $evt');
}
}
void _sortPlugins() {
plugins.sort((a, b) {
if (a.installed) {
return -1;
} else if (b.installed) {
return 1;
} else {
return 0;
}
});
}
void _handlePluginList(String pluginList) {
_plugins.clear();
try {
for (var p in json.decode(pluginList) as List<dynamic>) {
final plugin = _getPluginFromEvent(p);
if (plugin == null) {
continue;
}
_plugins.add(plugin);
}
} catch (e) {
debugPrint('Failed to decode $e, plugin list \'$pluginList\'');
}
_sortPlugins();
notifyListeners();
}
void _handlePluginInstall(String id, String msg) {
debugPrint('Plugin \'$id\' install msg $msg');
for (var i = 0; i < _plugins.length; i++) {
if (_plugins[i].meta.id == id) {
_plugins[i].setInstall(msg);
_sortPlugins();
notifyListeners();
return;
}
}
}
void _handlePluginUninstall(String id, String msg) {
debugPrint('Plugin \'$id\' uninstall msg $msg');
for (var i = 0; i < _plugins.length; i++) {
if (_plugins[i].meta.id == id) {
_plugins[i].setUninstall(msg);
_sortPlugins();
notifyListeners();
return;
}
}
}
PluginInfo? _getPluginFromEvent(Map<String, dynamic> evt) {
final s = evt['source'];
assert(s != null, 'Source is null');
if (s == null) {
return null;
}
final source = SourceInfo(
name: s['name'],
url: s['url'] ?? '',
description: s['description'] ?? '',
);
final m = evt['meta'];
assert(m != null, 'Meta is null');
if (m == null) {
return null;
}
late DateTime lastReleased;
late DateTime published;
try {
lastReleased = DateTime.parse(
m['publish_info']?['last_released'] ?? '1970-01-01T00+00:00');
} catch (e) {
lastReleased = DateTime.utc(1970);
}
try {
published = DateTime.parse(
m['publish_info']?['published'] ?? '1970-01-01T00+00:00');
} catch (e) {
published = DateTime.utc(1970);
}
final meta = Meta(
id: m['id'],
name: m['name'],
version: m['version'],
description: m['description'] ?? '',
author: m['author'],
home: m['home'] ?? '',
license: m['license'] ?? '',
source: m['source'] ?? '',
publishInfo:
PublishInfo(lastReleased: lastReleased, published: published),
);
return PluginInfo(
sourceInfo: source,
meta: meta,
installedVersion: evt['installed_version'],
invalidReason: evt['invalid_reason'] ?? '',
);
}
}
PluginManager get pluginManager => PluginManager.instance;

View File

@@ -1,110 +0,0 @@
import 'package:flutter/material.dart';
import './common.dart';
import './manager.dart';
final Map<String, LocationModel> _locationModels = {};
final Map<String, OptionModel> _optionModels = {};
class OptionModel with ChangeNotifier {
String? v;
String? get value => v;
set value(String? v) {
this.v = v;
notifyListeners();
}
static String key(String location, PluginId id, String peer, String k) =>
'$location|$id|$peer|$k';
}
class PluginModel with ChangeNotifier {
final List<UiType> uiList = [];
final Map<String, String> opts = {};
void add(List<UiType> uiList) {
bool found = false;
for (var ui in uiList) {
for (int i = 0; i < this.uiList.length; i++) {
if (this.uiList[i].key == ui.key) {
this.uiList[i] = ui;
found = true;
}
}
if (!found) {
this.uiList.add(ui);
}
}
notifyListeners();
}
String? getOpt(String key) => opts.remove(key);
bool get isEmpty => uiList.isEmpty;
}
class LocationModel with ChangeNotifier {
final Map<PluginId, PluginModel> pluginModels = {};
void add(PluginId id, List<UiType> uiList) {
if (pluginModels[id] != null) {
pluginModels[id]!.add(uiList);
} else {
var model = PluginModel();
model.add(uiList);
pluginModels[id] = model;
notifyListeners();
}
}
void clear() {
pluginModels.clear();
notifyListeners();
}
void remove(PluginId id) {
pluginModels.remove(id);
notifyListeners();
}
bool get isEmpty => pluginModels.isEmpty;
}
void addLocationUi(String location, PluginId id, List<UiType> uiList) {
if (_locationModels[location] == null) {
_locationModels[location] = LocationModel();
}
_locationModels[location]?.add(id, uiList);
}
LocationModel? getLocationModel(String location) => _locationModels[location];
PluginModel? getPluginModel(String location, PluginId id) =>
_locationModels[location]?.pluginModels[id];
void clearPlugin(PluginId pluginId) {
for (var element in _locationModels.values) {
element.remove(pluginId);
}
}
void clearLocations() {
for (var element in _locationModels.values) {
element.clear();
}
}
OptionModel getOptionModel(
String location, PluginId pluginId, String peer, String key) {
final k = OptionModel.key(location, pluginId, peer, key);
if (_optionModels[k] == null) {
_optionModels[k] = OptionModel();
}
return _optionModels[k]!;
}
void updateOption(
String location, PluginId id, String peer, String key, String value) {
final k = OptionModel.key(location, id, peer, key);
_optionModels[k]?.value = value;
}

View File

@@ -1,17 +0,0 @@
import 'package:flutter/material.dart';
class PluginUiManager {
PluginUiManager._();
static PluginUiManager instance = PluginUiManager._();
Map<String, Widget> entries = <String, Widget>{};
void registerEntry(String key, Widget widget) {
entries[key] = widget;
}
void unregisterEntry(String key) {
entries.remove(key);
}
}

View File

@@ -1,86 +0,0 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common.dart';
void showPeerSelectionDialog(
{bool singleSelection = false,
required Function(List<String>) onPeersCallback}) async {
// load recent peers, we can directly use the peers in `gFFI.recentPeersModel`.
// The plugin is not used for now, so just left it empty here.
final peers = '';
if (peers.isEmpty) {
// debugPrint("load recent peers failed.");
return;
}
Map<String, dynamic> map = jsonDecode(peers);
List<dynamic> peersList = map['peers'] ?? [];
final selected = List<String>.empty(growable: true);
submit() async {
onPeersCallback.call(selected);
}
gFFI.dialogManager.show((setState, close, context) {
return CustomAlertDialog(
title:
Text(translate(singleSelection ? "Select peers" : "Select a peer")),
content: SizedBox(
height: 300.0,
child: ListView.builder(
itemBuilder: (context, index) {
final Map<String, dynamic> peer = peersList[index];
final String platform = peer['platform'] ?? "";
final String id = peer['id'] ?? "";
final String alias = peer['alias'] ?? "";
return GestureDetector(
onTap: () {
setState(() {
if (selected.contains(id)) {
selected.remove(id);
} else {
selected.add(id);
}
});
},
child: Container(
key: ValueKey(index),
height: 50.0,
decoration: BoxDecoration(
color: Theme.of(context).highlightColor,
borderRadius: BorderRadius.circular(12.0)),
padding: EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0),
margin: EdgeInsets.symmetric(vertical: 4.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.max,
children: [
// platform
SizedBox(
width: 8.0,
),
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
getPlatformImage(platform, size: 34.0),
],
),
SizedBox(
width: 8.0,
),
// id/alias
Expanded(child: Text(alias.isEmpty ? id : alias)),
],
),
),
);
},
itemCount: peersList.length,
itemExtent: 50.0,
),
),
onSubmit: submit,
);
});
}

View File

@@ -1,301 +0,0 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/models/model.dart';
import 'package:provider/provider.dart';
import 'package:get/get.dart';
// to-do: do not depend on desktop
import 'package:flutter_hbb/desktop/widgets/remote_toolbar.dart';
import 'package:flutter_hbb/models/platform_model.dart';
import '../manager.dart';
import '../model.dart';
import '../common.dart';
// dup to flutter\lib\desktop\pages\desktop_setting_page.dart
const double _kCheckBoxLeftMargin = 10;
class LocationItem extends StatelessWidget {
final String peerId;
final FFI ffi;
final String location;
final LocationModel locationModel;
final bool isMenu;
LocationItem({
Key? key,
required this.peerId,
required this.ffi,
required this.location,
required this.locationModel,
required this.isMenu,
}) : super(key: key);
bool get isEmpty => locationModel.isEmpty;
static Widget createLocationItem(
String peerId, FFI ffi, String location, bool isMenu) {
final model = getLocationModel(location);
return model == null
? Container()
: LocationItem(
peerId: peerId,
ffi: ffi,
location: location,
locationModel: model,
isMenu: isMenu,
);
}
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider.value(
value: locationModel,
child: Consumer<LocationModel>(builder: (context, model, child) {
return Column(
children: model.pluginModels.entries
.map((entry) => _buildPluginItem(entry.key, entry.value))
.toList(),
);
}),
);
}
Widget _buildPluginItem(PluginId id, PluginModel model) => PluginItem(
pluginId: id,
peerId: peerId,
ffi: ffi,
location: location,
pluginModel: model,
isMenu: isMenu,
);
}
class PluginItem extends StatelessWidget {
final PluginId pluginId;
final String peerId;
final FFI? ffi;
final String location;
final PluginModel pluginModel;
final bool isMenu;
PluginItem({
Key? key,
required this.pluginId,
required this.peerId,
this.ffi,
required this.location,
required this.pluginModel,
required this.isMenu,
}) : super(key: key);
bool get isEmpty => pluginModel.isEmpty;
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider.value(
value: pluginModel,
child: Consumer<PluginModel>(
builder: (context, pluginModel, child) {
return Column(
children: pluginModel.uiList.map((ui) => _buildItem(ui)).toList(),
);
},
),
);
}
Widget _buildItem(UiType ui) {
Widget? child;
switch (ui.runtimeType) {
case UiButton:
if (isMenu) {
if (ffi != null) {
child = _buildMenuButton(ui as UiButton, ffi!);
}
} else {
child = _buildButton(ui as UiButton);
}
break;
case UiCheckbox:
if (isMenu) {
if (ffi != null) {
child = _buildCheckboxMenuButton(ui as UiCheckbox, ffi!);
}
} else {
child = _buildCheckbox(ui as UiCheckbox);
}
break;
default:
break;
}
// to-do: add plugin icon and tooltip
return child ?? Container();
}
Widget _buildButton(UiButton ui) {
return TextButton(
onPressed: () => bind.pluginEvent(
id: pluginId,
peer: peerId,
event: _makeEvent(ui.key),
),
child: Text(ui.text),
);
}
Widget _buildCheckbox(UiCheckbox ui) {
getChild(OptionModel model) {
final v = _getOption(model, ui.key);
if (v == null) {
// session or plugin not found
return Container();
}
onChanged(bool value) {
bind.pluginEvent(
id: pluginId,
peer: peerId,
event: _makeEvent(ui.key, v: value),
);
}
final value = ConfigItem.isTrue(v);
return GestureDetector(
child: Row(
children: [
Checkbox(
value: value,
onChanged: (_) => onChanged(!value),
).marginOnly(right: 5),
Expanded(
child: Text(translate(ui.text)),
)
],
).marginOnly(left: _kCheckBoxLeftMargin),
onTap: () => onChanged(!value),
);
}
return ChangeNotifierProvider.value(
value: getOptionModel(location, pluginId, peerId, ui.key),
child: Consumer<OptionModel>(
builder: (context, model, child) => getChild(model),
),
);
}
Widget _buildCheckboxMenuButton(UiCheckbox ui, FFI ffi) {
getChild(OptionModel model) {
final v = _getOption(model, ui.key);
if (v == null) {
// session or plugin not found
return Container();
}
return CkbMenuButton(
value: ConfigItem.isTrue(v),
onChanged: (v) {
if (v != null) {
bind.pluginEvent(
id: pluginId,
peer: peerId,
event: _makeEvent(ui.key, v: v),
);
}
},
// to-do: RustDesk translate or plugin translate ?
child: Text(ui.text),
ffi: ffi,
);
}
return ChangeNotifierProvider.value(
value: getOptionModel(location, pluginId, peerId, ui.key),
child: Consumer<OptionModel>(
builder: (context, model, child) => getChild(model),
),
);
}
Widget _buildMenuButton(UiButton ui, FFI ffi) {
return MenuButton(
onPressed: () => bind.pluginEvent(
id: pluginId,
peer: peerId,
event: _makeEvent(ui.key),
),
// to-do: support trailing icon, but it will cause tree shake error.
// ```
// This application cannot tree shake icons fonts. It has non-constant instances of IconData at the following locations:
// Target release_macos_bundle_flutter_assets failed: Exception: Avoid non-constant invocations of IconData or try to build again with --no-tree-shake-icons.
// ```
//
// trailingIcon: Icon(
// IconData(int.parse(ui.icon, radix: 16), fontFamily: 'MaterialIcons')),
//
// to-do: RustDesk translate or plugin translate ?
child: Text(ui.text),
ffi: ffi,
);
}
Uint8List _makeEvent(
String key, {
bool? v,
}) {
final event = MsgFromUi(
id: pluginId,
name: pluginManager.getPlugin(pluginId)?.meta.name ?? '',
location: location,
key: key,
value:
v != null ? (v ? ConfigItem.trueValue : ConfigItem.falseValue) : '',
action: '',
);
return Uint8List.fromList(event.toString().codeUnits);
}
String? _getOption(OptionModel model, String key) {
var v = model.value;
if (v == null) {
try {
if (peerId.isEmpty) {
v = bind.pluginGetSharedOption(id: pluginId, key: key);
} else {
v = bind.pluginGetSessionOption(id: pluginId, peer: peerId, key: key);
}
} catch (e) {
debugPrint('Failed to get option "$key", $e');
v = null;
}
}
return v;
}
}
void handleReloading(Map<String, dynamic> evt) {
if (evt['id'] == null || evt['location'] == null) {
return;
}
try {
final uiList = <UiType>[];
for (var e in json.decode(evt['ui'] as String)) {
final ui = UiType.create(e);
if (ui != null) {
uiList.add(ui);
}
}
if (uiList.isNotEmpty) {
addLocationUi(evt['location']!, evt['id']!, uiList);
}
} catch (e) {
debugPrint('Failed handleReloading, json decode of ui, $e ');
}
}
void handleOption(Map<String, dynamic> evt) {
updateOption(
evt['location'], evt['id'], evt['peer'] ?? '', evt['key'], evt['value']);
}

View File

@@ -1,202 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/models/platform_model.dart';
import 'package:flutter_hbb/plugin/model.dart';
import 'package:flutter_hbb/plugin/common.dart';
import 'package:get/get.dart';
import '../manager.dart';
import './desc_ui.dart';
// to-do: use settings from desktop_setting_page.dart
const double _kCardFixedWidth = 540;
const double _kCardLeftMargin = 15;
const double _kContentHMargin = 15;
const double _kTitleFontSize = 20;
const double _kVersionFontSize = 12;
class DesktopSettingsCard extends StatefulWidget {
final PluginInfo plugin;
DesktopSettingsCard({
Key? key,
required this.plugin,
}) : super(key: key);
@override
State<DesktopSettingsCard> createState() => _DesktopSettingsCardState();
}
class _DesktopSettingsCardState extends State<DesktopSettingsCard> {
PluginInfo get plugin => widget.plugin;
bool get installed => plugin.installed;
bool isEnabled = false;
@override
Widget build(BuildContext context) {
isEnabled = bind.pluginIsEnabled(id: plugin.meta.id);
return Row(
children: [
Flexible(
child: SizedBox(
width: _kCardFixedWidth,
child: Card(
child: Column(
children: [
header(),
body(),
],
).marginOnly(bottom: 10),
).marginOnly(left: _kCardLeftMargin, top: 15),
),
),
],
);
}
Widget header() {
return Row(
children: [
headerNameVersion(),
headerInstallEnable(),
],
).marginOnly(
left: _kContentHMargin,
top: 10,
bottom: 10,
right: _kContentHMargin,
);
}
Widget headerNameVersion() {
return Expanded(
child: Row(
children: [
Text(
widget.plugin.meta.name,
textAlign: TextAlign.start,
style: const TextStyle(
fontSize: _kTitleFontSize,
),
),
SizedBox(
width: 5,
),
Text(
plugin.meta.version,
textAlign: TextAlign.start,
style: const TextStyle(
fontSize: _kVersionFontSize,
),
)
],
),
);
}
Widget headerButton(String label, VoidCallback onPressed) {
return Container(
child: ElevatedButton(
onPressed: onPressed,
child: Text(translate(label)),
),
);
}
Widget headerInstallEnable() {
final installButton = headerButton(
installed ? 'Uninstall' : 'Install',
() {
bind.pluginInstall(
id: plugin.meta.id,
b: !installed,
);
},
);
if (installed) {
final updateButton = plugin.needUpdate
? headerButton('Update', () {
bind.pluginInstall(
id: plugin.meta.id,
b: !installed,
);
})
: Container();
final enableButton = !installed
? Container()
: headerButton(isEnabled ? 'Disable' : 'Enable', () {
if (isEnabled) {
clearPlugin(plugin.meta.id);
}
bind.pluginEnable(id: plugin.meta.id, v: !isEnabled);
setState(() {});
});
return Row(
children: [
updateButton,
SizedBox(
width: 10,
),
installButton,
SizedBox(
width: 10,
),
enableButton,
],
);
} else {
return installButton;
}
}
Widget body() {
return Column(children: [
author(),
description(),
more(),
]).marginOnly(
left: _kCardLeftMargin,
top: 4,
right: _kContentHMargin,
);
}
Widget author() {
return Align(
alignment: Alignment.centerLeft,
child: Text(plugin.meta.author),
);
}
Widget description() {
return Align(
alignment: Alignment.centerLeft,
child: Text(plugin.meta.description),
);
}
Widget more() {
if (!(installed && isEnabled)) {
return Container();
}
final List<Widget> children = [];
final model = getPluginModel(kLocationHostMainPlugin, plugin.meta.id);
if (model != null) {
children.add(PluginItem(
pluginId: plugin.meta.id,
peerId: '',
location: kLocationHostMainPlugin,
pluginModel: model,
isMenu: false,
));
}
return ExpansionTile(
title: Text('Options'),
controlAffinity: ListTileControlAffinity.leading,
children: children,
);
}
}

View File

@@ -762,10 +762,6 @@ class RustdeskImpl {
throw UnimplementedError("mainGetError");
}
bool mainShowOption({required String key, dynamic hint}) {
throw UnimplementedError("mainShowOption");
}
Future<void> mainSetOption(
{required String key, required String value, dynamic hint}) {
js.context.callMethod('setByName', [
@@ -1377,6 +1373,10 @@ class RustdeskImpl {
throw UnimplementedError("cmLoginRes");
}
Future<void> cmCloseConnectionWindow({required int connId, dynamic hint}) {
throw UnimplementedError("cmCloseConnectionWindow");
}
Future<void> cmCloseConnection({required int connId, dynamic hint}) {
throw UnimplementedError("cmCloseConnection");
}
@@ -1644,78 +1644,6 @@ class RustdeskImpl {
throw UnimplementedError("sendUrlScheme");
}
Future<void> pluginEvent(
{required String id,
required String peer,
required Uint8List event,
dynamic hint}) {
throw UnimplementedError("pluginEvent");
}
Stream<EventToUI> pluginRegisterEventStream(
{required String id, dynamic hint}) {
throw UnimplementedError("pluginRegisterEventStream");
}
String? pluginGetSessionOption(
{required String id,
required String peer,
required String key,
dynamic hint}) {
throw UnimplementedError("pluginGetSessionOption");
}
Future<void> pluginSetSessionOption(
{required String id,
required String peer,
required String key,
required String value,
dynamic hint}) {
throw UnimplementedError("pluginSetSessionOption");
}
String? pluginGetSharedOption(
{required String id, required String key, dynamic hint}) {
throw UnimplementedError("pluginGetSharedOption");
}
Future<void> pluginSetSharedOption(
{required String id,
required String key,
required String value,
dynamic hint}) {
throw UnimplementedError("pluginSetSharedOption");
}
Future<void> pluginReload({required String id, dynamic hint}) {
throw UnimplementedError("pluginReload");
}
void pluginEnable({required String id, required bool v, dynamic hint}) {
throw UnimplementedError("pluginEnable");
}
bool pluginIsEnabled({required String id, dynamic hint}) {
throw UnimplementedError("pluginIsEnabled");
}
bool pluginFeatureIsEnabled({dynamic hint}) {
throw UnimplementedError("pluginFeatureIsEnabled");
}
Future<void> pluginSyncUi({required String syncTo, dynamic hint}) {
throw UnimplementedError("pluginSyncUi");
}
Future<void> pluginListReload({dynamic hint}) {
throw UnimplementedError("pluginListReload");
}
Future<void> pluginInstall(
{required String id, required bool b, dynamic hint}) {
throw UnimplementedError("pluginInstall");
}
bool isSupportMultiUiSession({required String version, dynamic hint}) {
return versionToNumber(v: version) > versionToNumber(v: '1.2.4');
}

View File

@@ -1,5 +1,4 @@
import 'dart:js' as js;
import 'dart:html' as html;
// cycle imports, maybe we can improve this
import 'package:flutter_hbb/consts.dart';
@@ -13,8 +12,6 @@ final isWebDesktop_ = !js.context.callMethod('isMobile');
final isDesktop_ = false;
String get screenInfo_ => js.context.callMethod('getByName', ['screen_info']);
final _localOs = js.context.callMethod('getByName', ['local_os', '']);
final isWebOnWindows_ = _localOs == kPeerPlatformWindows;
final isWebOnLinux_ = _localOs == kPeerPlatformLinux;

View File

@@ -12,3 +12,5 @@ Future<void> webSendLocalFiles(
required bool isRemote}) {
throw UnimplementedError("webSendLocalFiles");
}
Future<void> loadLocalTerminalFontIfNeeded() async {}

View File

@@ -1,14 +0,0 @@
abstract class NativeHandler {
bool onEvent(Map<String, dynamic> evt);
}
class NativeUiHandler extends NativeHandler {
NativeUiHandler._();
static NativeUiHandler instance = NativeUiHandler._();
@override
bool onEvent(Map<String, dynamic> evt) {
throw UnimplementedError();
}
}

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