mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-09-10 14:31:02 +03:00
Compare commits
3 Commits
hdr-tonema
...
svt-av1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e17efa9474 | ||
|
|
8eda19247c | ||
|
|
1ac684db0d |
11
.github/dependabot.yml
vendored
Normal file
11
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "gitsubmodule"
|
||||
directory: "/"
|
||||
target-branch: "master"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
commit-message:
|
||||
prefix: "Git submodule"
|
||||
labels:
|
||||
- "dependencies"
|
||||
115
.github/patches/apply_flutter_3.44_source_patches.sh
vendored
115
.github/patches/apply_flutter_3.44_source_patches.sh
vendored
@@ -17,109 +17,6 @@
|
||||
# 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
|
||||
@@ -131,10 +28,12 @@ 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 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
|
||||
# Fail loudly if any expected string drifted, so we never silently build unpatched:
|
||||
grep -qF 'dialogTheme: DialogThemeData(' flutter/lib/common.dart
|
||||
grep -qF 'tabBarTheme: const TabBarThemeData(' flutter/lib/common.dart
|
||||
grep -qF 'backgroundColor: Colors.white,' flutter/lib/common.dart
|
||||
grep -qF 'backgroundColor: Color(0xFF18191E),' flutter/lib/common.dart
|
||||
grep -qF 'extended_text: 15.0.2' flutter/pubspec.yaml
|
||||
grep -qF 'google_fonts: ^8.1.0' flutter/pubspec.yaml
|
||||
|
||||
git --no-pager diff -- flutter/lib/common.dart flutter/pubspec.yaml
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Prepares a web build on Flutter 3.44.x. Companion to
|
||||
# apply_flutter_3.44_source_patches.sh (which it runs first): the web target
|
||||
# additionally needs qr_code_scanner's web implementation patched for the
|
||||
# dart:ui platformViewRegistry removal, and flutter/web/fonts refreshed with
|
||||
# the font paths the 3.44 engine requests for offline/air-gapped support
|
||||
# (rustdesk-server-pro#996; see flutter/web/fonts/sync_fonts.py).
|
||||
#
|
||||
# Run from the repository root with Flutter 3.44.x on PATH, then build:
|
||||
# bash .github/patches/apply_flutter_3.44_web_patches.sh
|
||||
# (cd flutter && flutter build web --release) # or ./web/js/flutter_build.py
|
||||
#
|
||||
# Idempotent. To undo the source changes locally:
|
||||
# git checkout -- flutter/lib/common.dart flutter/pubspec.yaml flutter/pubspec.lock
|
||||
set -euo pipefail
|
||||
|
||||
flutter --version | grep -q "Flutter 3\.44\." || {
|
||||
echo "Flutter 3.44.x must be on PATH; found:" >&2
|
||||
flutter --version | grep "^Flutter" >&2 || true
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Shared 3.44 source/pubspec patches own their complete-state validation.
|
||||
bash .github/patches/apply_flutter_3.44_source_patches.sh
|
||||
|
||||
# Populate the pub cache with the 3.44 dependency resolution.
|
||||
(cd flutter && flutter pub get)
|
||||
|
||||
# qr_code_scanner 1.0.1 (unmaintained) reads platformViewRegistry from
|
||||
# dart:ui, which Flutter 3.44 removed; point it at dart:ui_web instead. The
|
||||
# patched file also compiles on Flutter 3.24 (dart:ui_web exists there), so
|
||||
# mutating the shared pub cache is safe for other local builds.
|
||||
QR_WEB="${PUB_CACHE:-$HOME/.pub-cache}/hosted/pub.dev/qr_code_scanner-1.0.1/lib/src/web/flutter_qr_web.dart"
|
||||
if ! grep -qF "dart:ui_web" "$QR_WEB"; then
|
||||
sed -i.bak "s|import 'dart:ui' as ui;|import 'dart:ui' as ui; import 'dart:ui_web' as ui_web;|" "$QR_WEB"
|
||||
rm -f "$QR_WEB.bak"
|
||||
fi
|
||||
if grep -qF "ui.platformViewRegistry" "$QR_WEB"; then
|
||||
sed -i.bak "s|ui\.platformViewRegistry|ui_web.platformViewRegistry|g" "$QR_WEB"
|
||||
rm -f "$QR_WEB.bak"
|
||||
fi
|
||||
|
||||
# Mirror the fonts this engine version requests into flutter/web/fonts.
|
||||
python3 flutter/web/fonts/sync_fonts.py
|
||||
|
||||
# Fail loudly if any expected state is missing:
|
||||
grep -qF "import 'dart:ui' as ui; import 'dart:ui_web' as ui_web;" "$QR_WEB"
|
||||
grep -qF "ui_web.platformViewRegistry" "$QR_WEB"
|
||||
grep -qF 'google_fonts: ^8.1.0' flutter/pubspec.yaml
|
||||
|
||||
echo "Flutter 3.44 web patches applied."
|
||||
2
.github/workflows/bridge.yml
vendored
2
.github/workflows/bridge.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
target: x86_64-unknown-linux-gnu,
|
||||
os: ubuntu-22.04,
|
||||
extra-build-args: "",
|
||||
flutter-version: "3.44.8",
|
||||
flutter-version: "3.44.0",
|
||||
artifact-name: "bridge-artifact-flutter-3.44",
|
||||
}
|
||||
steps:
|
||||
|
||||
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
@@ -5,7 +5,7 @@ env:
|
||||
# CICD_INTERMEDIATES_DIR: "_cicd-intermediates"
|
||||
VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite"
|
||||
# for multiarch gcc compatibility
|
||||
VCPKG_COMMIT_ID: "9e593bb18ea69cc5095e012465dcd675a822ed0d"
|
||||
VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -124,6 +124,7 @@ jobs:
|
||||
gcc \
|
||||
git \
|
||||
g++ \
|
||||
libpam0g-dev \
|
||||
libasound2-dev \
|
||||
libunwind-dev \
|
||||
libgstreamer1.0-dev \
|
||||
|
||||
344
.github/workflows/flutter-build.yml
vendored
344
.github/workflows/flutter-build.yml
vendored
@@ -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.9"
|
||||
FLUTTER_WINDOWS_ARM_VERSION: "3.44.0"
|
||||
# for arm64 linux because official Dart SDK does not work
|
||||
FLUTTER_ELINUX_VERSION: "3.16.9"
|
||||
TAG_NAME: "${{ inputs.upload-tag }}"
|
||||
VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite"
|
||||
# vcpkg version: 2026.07.29
|
||||
# vcpkg version: 2025.08.27
|
||||
# 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: "9e593bb18ea69cc5095e012465dcd675a822ed0d"
|
||||
VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b"
|
||||
ARMV7_VCPKG_COMMIT_ID: "6f29f12e82a8293156836ad81cc9bf5af41fe836" # 2025.01.13, got "/opt/artifacts/vcpkg/vcpkg: No such file or directory" with latest version
|
||||
VERSION: "1.5.0"
|
||||
VERSION: "1.4.9"
|
||||
NDK_VERSION: "r28c"
|
||||
#signing keys env variable checks
|
||||
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"
|
||||
@@ -53,34 +53,6 @@ env:
|
||||
SIGN_BASE_URL: "${{ secrets.SIGN_BASE_URL }}-2"
|
||||
|
||||
jobs:
|
||||
generate-sbom:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Install Syft
|
||||
uses: anchore/sbom-action/download-syft@v0
|
||||
|
||||
- name: Generate SBOM
|
||||
run: |
|
||||
syft dir:. \
|
||||
-o cyclonedx-json=rustdesk.sbom.json
|
||||
|
||||
- name: Publish Release
|
||||
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
if: env.UPLOAD_ARTIFACT == 'true'
|
||||
with:
|
||||
prerelease: true
|
||||
tag_name: ${{ env.TAG_NAME }}
|
||||
files: |
|
||||
rustdesk.sbom.json
|
||||
|
||||
generate-bridge:
|
||||
uses: ./.github/workflows/bridge.yml
|
||||
|
||||
@@ -224,9 +196,7 @@ jobs:
|
||||
run: |
|
||||
cp .github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff $(dirname $(dirname $(which flutter)))
|
||||
cd $(dirname $(dirname $(which flutter)))
|
||||
if [[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]]; then
|
||||
git apply flutter_3.24.4_dropdown_menu_enableFilter.diff
|
||||
fi
|
||||
[[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]] && git apply flutter_3.24.4_dropdown_menu_enableFilter.diff
|
||||
|
||||
- name: Patch RustDesk sources for Flutter 3.44 (arm64)
|
||||
# arm64 is the only target on Flutter 3.44; apply its source/pubspec deltas on the fly
|
||||
@@ -597,9 +567,7 @@ jobs:
|
||||
- name: Patch flutter
|
||||
run: |
|
||||
cd $(dirname $(dirname $(which flutter)))
|
||||
if [[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]]; then
|
||||
git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff
|
||||
fi
|
||||
[[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]] && git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff
|
||||
|
||||
- name: Setup vcpkg with Github Actions binary cache
|
||||
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
|
||||
@@ -778,9 +746,7 @@ jobs:
|
||||
- name: Patch flutter
|
||||
run: |
|
||||
cd $(dirname $(dirname $(which flutter)))
|
||||
if [[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]]; then
|
||||
git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff
|
||||
fi
|
||||
[[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]] && git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff
|
||||
|
||||
- name: Workaround for flutter issue
|
||||
shell: bash
|
||||
@@ -1009,6 +975,7 @@ jobs:
|
||||
libgstreamer1.0-dev \
|
||||
libgstreamer-plugins-base1.0-dev \
|
||||
libgtk-3-dev \
|
||||
libpam0g-dev \
|
||||
libpulse-dev \
|
||||
libva-dev \
|
||||
libxcb-randr0-dev \
|
||||
@@ -1038,9 +1005,7 @@ jobs:
|
||||
- name: Patch flutter
|
||||
run: |
|
||||
cd $(dirname $(dirname $(which flutter)))
|
||||
if [[ "3.24.5" == ${{env.ANDROID_FLUTTER_VERSION}} ]]; then
|
||||
git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff
|
||||
fi
|
||||
[[ "3.24.5" == ${{env.ANDROID_FLUTTER_VERSION}} ]] && git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff
|
||||
|
||||
- uses: nttld/setup-ndk@ed92fe6cadad69be94a966a7ee3271275e62f779 # v1
|
||||
id: setup-ndk
|
||||
@@ -1282,6 +1247,7 @@ jobs:
|
||||
libgstreamer1.0-dev \
|
||||
libgstreamer-plugins-base1.0-dev \
|
||||
libgtk-3-dev \
|
||||
libpam0g-dev \
|
||||
libpulse-dev \
|
||||
libva-dev \
|
||||
libxcb-randr0-dev \
|
||||
@@ -1311,9 +1277,7 @@ jobs:
|
||||
- name: Patch flutter
|
||||
run: |
|
||||
cd $(dirname $(dirname $(which flutter)))
|
||||
if [[ "3.24.5" == ${{env.ANDROID_FLUTTER_VERSION}} ]]; then
|
||||
git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff
|
||||
fi
|
||||
[[ "3.24.5" == ${{env.ANDROID_FLUTTER_VERSION}} ]] && git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff
|
||||
|
||||
- name: Restore bridge files
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
@@ -1572,6 +1536,7 @@ jobs:
|
||||
libgstreamer1.0-dev \
|
||||
libgstreamer-plugins-base1.0-dev \
|
||||
libgtk-3-dev \
|
||||
libpam0g-dev \
|
||||
libpulse-dev \
|
||||
libva-dev \
|
||||
libxcb-randr0-dev \
|
||||
@@ -1756,275 +1721,6 @@ 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 }}
|
||||
@@ -2122,6 +1818,7 @@ jobs:
|
||||
libgstreamer-plugins-base1.0-dev \
|
||||
libgtk-3-dev \
|
||||
liblzma-dev \
|
||||
libpam0g-dev \
|
||||
libpulse-dev \
|
||||
libva-dev \
|
||||
libxcb-randr0-dev \
|
||||
@@ -2198,7 +1895,7 @@ jobs:
|
||||
mkdir -p ~/.cargo/
|
||||
echo """
|
||||
[source.crates-io]
|
||||
registry = 'sparse+https://index.crates.io/'
|
||||
registry = 'https://github.com/rust-lang/crates.io-index'
|
||||
""" > ~/.cargo/config
|
||||
cat ~/.cargo/config
|
||||
# install dependencies from vcpkg
|
||||
@@ -2418,18 +2115,7 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
cd $(dirname $(dirname $(which flutter)))
|
||||
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
|
||||
[[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]] && git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff
|
||||
|
||||
# https://rustdesk.com/docs/en/dev/build/web/
|
||||
- name: Build web
|
||||
|
||||
11
.github/workflows/playground.yml
vendored
11
.github/workflows/playground.yml
vendored
@@ -16,8 +16,8 @@ env:
|
||||
FLUTTER_ELINUX_VERSION: "3.16.9"
|
||||
TAG_NAME: "nightly"
|
||||
VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite"
|
||||
VCPKG_COMMIT_ID: "9e593bb18ea69cc5095e012465dcd675a822ed0d"
|
||||
VERSION: "1.5.0"
|
||||
VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b"
|
||||
VERSION: "1.4.9"
|
||||
NDK_VERSION: "r26d"
|
||||
#signing keys env variable checks
|
||||
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"
|
||||
@@ -271,6 +271,7 @@ jobs:
|
||||
libgstreamer1.0-dev \
|
||||
libgstreamer-plugins-base1.0-dev \
|
||||
libgtk-3-dev \
|
||||
libpam0g-dev \
|
||||
libpulse-dev \
|
||||
libva-dev \
|
||||
libvdpau-dev \
|
||||
@@ -283,7 +284,7 @@ jobs:
|
||||
nasm \
|
||||
yasm \
|
||||
ninja-build \
|
||||
openjdk-17-jdk-headless \
|
||||
openjdk-11-jdk-headless \
|
||||
pkg-config \
|
||||
tree \
|
||||
wget
|
||||
@@ -365,9 +366,9 @@ jobs:
|
||||
- name: Build rustdesk
|
||||
shell: bash
|
||||
env:
|
||||
JAVA_HOME: /usr/lib/jvm/java-17-openjdk-amd64
|
||||
JAVA_HOME: /usr/lib/jvm/java-11-openjdk-amd64
|
||||
run: |
|
||||
export PATH=/usr/lib/jvm/java-17-openjdk-amd64/bin:$PATH
|
||||
export PATH=/usr/lib/jvm/java-11-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
|
||||
|
||||
75
.github/workflows/update-webpki-roots.yml
vendored
75
.github/workflows/update-webpki-roots.yml
vendored
@@ -1,75 +0,0 @@
|
||||
name: Update webpki-roots
|
||||
|
||||
# Weekly refresh of the compiled-in TLS root certificates (the webpki-roots
|
||||
# crate, a snapshot of the Mozilla root store). Roots are otherwise frozen at
|
||||
# whatever Cargo.lock pins, so old builds miss newly added CAs and keep
|
||||
# removed (distrusted) ones. Changes go through a PR on purpose: added or
|
||||
# removed roots should be reviewed, not silently baked into releases.
|
||||
#
|
||||
# Note: PRs created with the default GITHUB_TOKEN do not trigger other
|
||||
# workflows (GitHub limitation). Close and reopen the PR, or push to its
|
||||
# branch, to run CI on it.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 3 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
# A manual dispatch overlapping the weekly run would race it force-pushing
|
||||
# the same branch; queue instead of overlapping, and never cancel a run
|
||||
# that may have already pushed.
|
||||
concurrency:
|
||||
group: update-webpki-roots
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
update:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
env:
|
||||
BRANCH: auto-update-webpki-roots
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
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
|
||||
run: |
|
||||
set -e
|
||||
git ls-files -z '*Cargo.lock' | while IFS= read -r -d '' lock; do
|
||||
dir=$(dirname "$lock")
|
||||
for v in $(sed -n '/name = "webpki-roots"/{n;s/.*version = "\(.*\)"/\1/p;}' "$lock" | sort -u); do
|
||||
echo "updating webpki-roots@$v in $dir"
|
||||
(cd "$dir" && cargo update -p "webpki-roots@$v")
|
||||
done
|
||||
done
|
||||
if git diff --quiet -- '*Cargo.lock'; then
|
||||
echo "changed=0" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changed=1" >> "$GITHUB_OUTPUT"
|
||||
git --no-pager diff -- '*Cargo.lock'
|
||||
fi
|
||||
|
||||
- name: Create pull request
|
||||
if: steps.update.outputs.changed == '1'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -e
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git checkout -B "$BRANCH"
|
||||
git add -- '*Cargo.lock'
|
||||
git commit -m "chore: update webpki-roots to latest Mozilla root store"
|
||||
git push -f origin "$BRANCH"
|
||||
if [ -z "$(gh pr list --head "$BRANCH" --state open --json number --jq '.[].number')" ]; then
|
||||
gh pr create \
|
||||
--title "chore: update webpki-roots to latest Mozilla root store" \
|
||||
--body "Automated weekly refresh of the compiled-in TLS root certificates (webpki-roots). Please review the added/removed roots. CI does not run automatically on PRs created by GITHUB_TOKEN; close and reopen this PR to trigger it."
|
||||
fi
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -55,6 +55,4 @@ examples/**/target/
|
||||
vcpkg_installed
|
||||
flutter/lib/generated_plugin_registrant.dart
|
||||
libsciter.dylib
|
||||
flutter/web/
|
||||
# libdrmtap is cloned at build time by build.py (not a submodule)
|
||||
/third_party/libdrmtap/
|
||||
flutter/web/
|
||||
26
AGENTS.md
26
AGENTS.md
@@ -61,26 +61,6 @@
|
||||
* 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:
|
||||
@@ -104,9 +84,3 @@ Then translate that source into the file's target language (infer the language f
|
||||
* Preserve placeholders (`{}`) and escape sequences (`\n`, `\"`) exactly as in the source.
|
||||
* Do not translate brand or technical tokens: `RustDesk`, `Socks5`, `TLS`, `UAC`, `Wayland`, `X11`, `TCP`, `UDP`, `2FA`, `RDP`, `D3D`, etc.
|
||||
* Copy URL values (e.g. `doc_*` keys) verbatim from `en.rs`.
|
||||
|
||||
### Adding new keys (feature work)
|
||||
|
||||
* New English-text keys use sentence case, not Title Case: `Use ID whitelisting`, **not** `Use ID Whitelisting`. Acronyms (ID, IP, 2FA…) stay uppercase. Legacy Title-Case keys (e.g. `Use IP Whitelisting`) stay as-is — do not rename them.
|
||||
* Since the key itself is the English display text, a sentence-case key usually needs **no** `en.rs` entry; add one only when the display text must differ from the key (e.g. `*_tip` keys).
|
||||
* Append each new key to `template.rs` (with `""`) and to every `src/lang/*.rs` file (translated, or `""` if unsure), at the end of the list.
|
||||
|
||||
186
Cargo.lock
generated
186
Cargo.lock
generated
@@ -986,6 +986,27 @@ 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"
|
||||
@@ -1456,8 +1477,6 @@ dependencies = [
|
||||
"compression-core",
|
||||
"flate2",
|
||||
"memchr",
|
||||
"zstd",
|
||||
"zstd-safe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1528,6 +1547,12 @@ 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"
|
||||
@@ -3047,8 +3072,9 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
||||
|
||||
[[package]]
|
||||
name = "fuser"
|
||||
version = "0.16.0"
|
||||
source = "git+https://github.com/rustdesk-org/fuser?branch=refact/tag-0.16.0-cargo-1.75.0#a3c0babe4a533f8dbcff5bce59ae7f2424b8d877"
|
||||
version = "0.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53274f494609e77794b627b1a3cddfe45d675a6b2e9ba9c0fdc8d8eee2184369"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
@@ -3791,14 +3817,14 @@ dependencies = [
|
||||
"toml 0.7.8",
|
||||
"tungstenite",
|
||||
"url",
|
||||
"users",
|
||||
"users 0.11.0",
|
||||
"uuid",
|
||||
"webpki-roots 1.0.9",
|
||||
"webpki-roots 1.0.4",
|
||||
"webrtc",
|
||||
"whoami",
|
||||
"winapi 0.3.9",
|
||||
"x11 2.21.0",
|
||||
"zstd",
|
||||
"zstd 0.13.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3992,7 +4018,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots 1.0.9",
|
||||
"webpki-roots 1.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5932,6 +5958,37 @@ 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"
|
||||
@@ -5999,12 +6056,35 @@ 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"
|
||||
@@ -6860,7 +6940,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "rdev"
|
||||
version = "0.5.0-2"
|
||||
source = "git+https://github.com/rustdesk-org/rdev#23e24dd6b35452a495dae0ae6d99395e9755ab0f"
|
||||
source = "git+https://github.com/rustdesk-org/rdev#871bf1c856d6a30af2f56ab8848396a025140855"
|
||||
dependencies = [
|
||||
"cocoa 0.24.1",
|
||||
"core-foundation 0.9.4",
|
||||
@@ -7030,7 +7110,7 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"webpki-roots 1.0.9",
|
||||
"webpki-roots 1.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7130,6 +7210,18 @@ dependencies = [
|
||||
"realfft",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "runas"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b96d6b6c505282b007a9b009f2aa38b2fd0359b81a0430ceacc60f69ade4c6a0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
"which",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-ini"
|
||||
version = "0.18.0"
|
||||
@@ -7177,7 +7269,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustdesk"
|
||||
version = "1.5.0"
|
||||
version = "1.4.9"
|
||||
dependencies = [
|
||||
"android-wakelock",
|
||||
"android_logger",
|
||||
@@ -7235,6 +7327,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"openssl",
|
||||
"os-version",
|
||||
"pam",
|
||||
"parity-tokio-ipc",
|
||||
"percent-encoding",
|
||||
"piet",
|
||||
@@ -7247,6 +7340,7 @@ dependencies = [
|
||||
"reqwest",
|
||||
"ringbuf",
|
||||
"rubato",
|
||||
"runas",
|
||||
"rust-pulsectl",
|
||||
"samplerate",
|
||||
"sciter-rs",
|
||||
@@ -7283,11 +7377,12 @@ dependencies = [
|
||||
"wol-rs",
|
||||
"x11-clipboard 0.8.1",
|
||||
"x11rb 0.12.0",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustdesk-portable-packer"
|
||||
version = "1.5.0"
|
||||
version = "1.4.9"
|
||||
dependencies = [
|
||||
"brotli",
|
||||
"dirs 5.0.1",
|
||||
@@ -8729,7 +8824,7 @@ dependencies = [
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls",
|
||||
"tungstenite",
|
||||
"webpki-roots 0.26.11",
|
||||
"webpki-roots 0.26.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8824,7 +8919,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c4ae9724c5888c0417d2396037ed3b60665925624766416e3e342b6ba5dbd3f"
|
||||
dependencies = [
|
||||
"base32",
|
||||
"constant_time_eq",
|
||||
"constant_time_eq 0.2.6",
|
||||
"hmac",
|
||||
"rand 0.8.5",
|
||||
"sha1",
|
||||
@@ -9043,7 +9138,7 @@ dependencies = [
|
||||
"sha1",
|
||||
"thiserror 2.0.17",
|
||||
"utf-8",
|
||||
"webpki-roots 0.26.11",
|
||||
"webpki-roots 0.26.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9269,6 +9364,16 @@ 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"
|
||||
@@ -9706,18 +9811,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.26.11"
|
||||
version = "0.26.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
||||
checksum = "29aad86cec885cafd03e8305fd727c418e970a521322c91688414d5b8efba16b"
|
||||
dependencies = [
|
||||
"webpki-roots 1.0.9",
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.9"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
|
||||
checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
@@ -11064,13 +11169,52 @@ 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",
|
||||
"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",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
21
Cargo.toml
21
Cargo.toml
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rustdesk"
|
||||
version = "1.5.0"
|
||||
version = "1.4.9"
|
||||
authors = ["rustdesk <info@rustdesk.com>"]
|
||||
edition = "2021"
|
||||
build= "build.rs"
|
||||
@@ -30,13 +30,7 @@ default = ["use_dasp"]
|
||||
hwcodec = ["scrap/hwcodec"]
|
||||
vram = ["scrap/vram"]
|
||||
mediacodec = ["scrap/mediacodec"]
|
||||
drm = ["scrap/drm"]
|
||||
# The display wake, as its OWN compile gate on top of `drm`. Everything else in the drm backend
|
||||
# READS (it captures a scanout); the wake WRITES, injecting one synthetic pointer event from the
|
||||
# root service so a compositor that idle-disabled its outputs re-enables them. That is a different
|
||||
# kind of operation and deserves a switch that can remove it from the binary entirely, without
|
||||
# giving up DRM capture: `--features drm` builds the capture path with no wake code compiled in.
|
||||
drm-wake = ["drm"]
|
||||
plugin_framework = []
|
||||
linux-pkg-config = ["magnum-opus/linux-pkg-config", "scrap/linux-pkg-config"]
|
||||
unix-file-copy-paste = [
|
||||
"dep:x11-clipboard",
|
||||
@@ -80,11 +74,12 @@ 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"
|
||||
kcp-sys= { git = "https://github.com/rustdesk-org/kcp-sys"}
|
||||
reqwest = { version = "0.12", features = ["blocking", "socks", "json", "native-tls", "rustls-tls", "rustls-tls-native-roots", "gzip", "zstd"], default-features=false }
|
||||
reqwest = { version = "0.12", features = ["blocking", "socks", "json", "native-tls", "rustls-tls", "rustls-tls-native-roots", "gzip"], default-features=false }
|
||||
|
||||
[target.'cfg(not(target_os = "linux"))'.dependencies]
|
||||
# https://github.com/rustdesk/rustdesk/discussions/10197, not use cpal on linux
|
||||
@@ -129,18 +124,14 @@ windows = { version = "0.61", features = [
|
||||
"Win32_Security_Authorization",
|
||||
"Win32_Storage_FileSystem",
|
||||
"Win32_System",
|
||||
"Win32_System_Com",
|
||||
"Win32_System_Diagnostics",
|
||||
"Win32_System_Diagnostics_ToolHelp",
|
||||
"Win32_System_Environment",
|
||||
"Win32_System_IO",
|
||||
"Win32_System_Memory",
|
||||
"Win32_System_Pipes",
|
||||
"Win32_System_Registry",
|
||||
"Win32_System_SystemInformation",
|
||||
"Win32_System_Threading",
|
||||
"Win32_UI_Shell",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
winreg = "0.11"
|
||||
windows-service = "0.6"
|
||||
@@ -149,6 +140,7 @@ remote_printer = { path = "libs/remote_printer" }
|
||||
impersonate_system = { git = "https://github.com/rustdesk-org/impersonate-system" }
|
||||
shared_memory = "0.12"
|
||||
tauri-winrt-notification = "0.1"
|
||||
runas = "1.2"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc = "0.2"
|
||||
@@ -189,6 +181,7 @@ 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}
|
||||
@@ -209,7 +202,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"]
|
||||
exclude = ["vdi/host", "examples/custom_plugin"]
|
||||
|
||||
# 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)
|
||||
|
||||
@@ -19,6 +19,7 @@ RUN apt update -y && \
|
||||
libxcb-shape0-dev \
|
||||
libxcb-xfixes0-dev \
|
||||
libasound2-dev \
|
||||
libpam0g-dev \
|
||||
libpulse-dev \
|
||||
make \
|
||||
wget \
|
||||
|
||||
11
README.md
11
README.md
@@ -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="#screenshots">Screenshots</a><br>
|
||||
<a href="#snapshot">Snapshot</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 the 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 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
|
||||
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev
|
||||
```
|
||||
|
||||
### openSUSE Tumbleweed
|
||||
|
||||
```sh
|
||||
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel
|
||||
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel
|
||||
```
|
||||
|
||||
### Fedora 28 (CentOS 8)
|
||||
|
||||
```sh
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel
|
||||
```
|
||||
|
||||
### Arch (Manjaro)
|
||||
@@ -168,6 +168,7 @@ 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
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ AppDir:
|
||||
id: rustdesk
|
||||
name: rustdesk
|
||||
icon: rustdesk
|
||||
version: 1.5.0
|
||||
version: 1.4.9
|
||||
exec: usr/share/rustdesk/rustdesk
|
||||
exec_args: $@
|
||||
apt:
|
||||
@@ -58,6 +58,7 @@ AppDir:
|
||||
- libpulse0
|
||||
- packagekit-gtk3-module
|
||||
- libcanberra-gtk3-module
|
||||
- libpam0g
|
||||
- libdrm2
|
||||
exclude:
|
||||
- humanity-icon-theme
|
||||
@@ -76,13 +77,6 @@ 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
|
||||
|
||||
@@ -18,7 +18,7 @@ AppDir:
|
||||
id: rustdesk
|
||||
name: rustdesk
|
||||
icon: rustdesk
|
||||
version: 1.5.0
|
||||
version: 1.4.9
|
||||
exec: usr/share/rustdesk/rustdesk
|
||||
exec_args: $@
|
||||
apt:
|
||||
@@ -61,6 +61,7 @@ AppDir:
|
||||
- libpulse0
|
||||
- packagekit-gtk3-module
|
||||
- libcanberra-gtk3-module
|
||||
- libpam0g
|
||||
- libdrm2
|
||||
exclude:
|
||||
- humanity-icon-theme
|
||||
@@ -79,13 +80,6 @@ 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
530
build.py
@@ -1,25 +1,16 @@
|
||||
#!/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")
|
||||
@@ -139,19 +130,6 @@ 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',
|
||||
@@ -294,24 +272,6 @@ 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:
|
||||
@@ -322,30 +282,6 @@ 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')
|
||||
@@ -364,7 +300,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, gstreamer1.0-pipewire%s
|
||||
Depends: libgtk-3-0t64 | libgtk-3-0, libxcb-randr0, libxdo3 | libxdo4, libxfixes3, libxcb-shape0, libxcb-xfixes0, libasound2t64 | libasound2, libsystemd0, curl, libva2, libva-drm2, libva-x11-2, libgstreamer-plugins-base1.0-0, libpam0g, gstreamer1.0-pipewire%s
|
||||
Recommends: libayatana-appindicator3-1
|
||||
Description: A remote control software.
|
||||
|
||||
@@ -380,322 +316,6 @@ 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')
|
||||
@@ -704,6 +324,8 @@ 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/')
|
||||
@@ -722,24 +344,17 @@ 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;')
|
||||
@@ -747,68 +362,10 @@ 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("..")
|
||||
|
||||
|
||||
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):
|
||||
def build_deb_from_folder(version, binary_folder):
|
||||
os.chdir('flutter')
|
||||
system2('mkdir -p tmpdeb/usr/bin/')
|
||||
system2('mkdir -p tmpdeb/usr/share/rustdesk')
|
||||
@@ -832,53 +389,9 @@ def build_deb_from_folder(version, binary_folder, want_drm=False):
|
||||
'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;')
|
||||
@@ -886,8 +399,6 @@ def build_deb_from_folder(version, binary_folder, want_drm=False):
|
||||
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("..")
|
||||
|
||||
|
||||
@@ -962,19 +473,6 @@ 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'):
|
||||
@@ -990,7 +488,7 @@ def main():
|
||||
portable = args.portable
|
||||
package = args.package
|
||||
if package:
|
||||
build_deb_from_folder(version, package, args.drm)
|
||||
build_deb_from_folder(version, package)
|
||||
return
|
||||
res_dir = 'resources'
|
||||
external_resources(flutter, args, res_dir)
|
||||
@@ -1124,7 +622,13 @@ 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/')
|
||||
|
||||
1
build.rs
1
build.rs
@@ -72,6 +72,7 @@ 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");
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ Violating these terms may lead to a permanent ban.
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
|
||||
@@ -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.md>
|
||||
<https://github.com/rustdesk/rustdesk/blob/master/docs/CODE_OF_CONDUCT-ID.md>
|
||||
|
||||
## Komunikasi
|
||||
|
||||
|
||||
@@ -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.md
|
||||
https://github.com/rustdesk/rustdesk/blob/master/docs/CODE_OF_CONDUCT-IT.md
|
||||
|
||||
## Comunicazioni
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Contributing to RustDesk
|
||||
|
||||
RustDesk welcomes contributions from everyone. Here are the guidelines if you are
|
||||
RustDesk welcomes contribution from everyone. Here are the guidelines if you are
|
||||
thinking of helping us:
|
||||
|
||||
## Contributions
|
||||
|
||||
@@ -160,6 +160,7 @@ 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
|
||||
|
||||
## لقطات
|
||||
|
||||
|
||||
@@ -144,6 +144,7 @@ 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
|
||||
|
||||
|
||||
@@ -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
|
||||
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev
|
||||
```
|
||||
|
||||
### openSUSE Tumbleweed
|
||||
|
||||
```sh
|
||||
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel
|
||||
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel
|
||||
```
|
||||
|
||||
### Fedora 28 (CentOS 8)
|
||||
|
||||
```sh
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel
|
||||
```
|
||||
|
||||
### Arch (Manjaro)
|
||||
@@ -168,6 +168,7 @@ 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
|
||||
|
||||
|
||||
@@ -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
|
||||
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev
|
||||
```
|
||||
|
||||
### openSUSE Tumbleweed
|
||||
|
||||
```sh
|
||||
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel
|
||||
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel
|
||||
```
|
||||
|
||||
### Fedora 28 (CentOS 8)
|
||||
|
||||
```sh
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel
|
||||
```
|
||||
|
||||
### Arch (Manjaro)
|
||||
@@ -163,6 +163,7 @@ 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>
|
||||
|
||||
@@ -146,6 +146,7 @@ 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
|
||||
|
||||
## تصاویر محیط نرمافزار
|
||||
|
||||
|
||||
@@ -158,6 +158,7 @@ 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
|
||||
|
||||
## Στιγμιότυπα
|
||||
|
||||
|
||||
@@ -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/en/dev/build/)
|
||||
## [Építés](https://rustdesk.com/docs/hu/dev/build/)
|
||||
|
||||
## Hogyan építs Linuxon
|
||||
|
||||
@@ -150,6 +150,7 @@ 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
|
||||
|
||||
|
||||
@@ -162,6 +162,7 @@ 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>
|
||||
|
||||
@@ -166,6 +166,7 @@ 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>
|
||||
|
||||
@@ -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
|
||||
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev
|
||||
```
|
||||
|
||||
### openSUSE Tumbleweed
|
||||
|
||||
```sh
|
||||
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel
|
||||
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel
|
||||
```
|
||||
|
||||
### Fedora 28 (CentOS 8)
|
||||
|
||||
```sh
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel
|
||||
```
|
||||
|
||||
### Arch (Manjaro)
|
||||
@@ -168,6 +168,7 @@ 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
|
||||
|
||||
## 스크린샷
|
||||
|
||||
|
||||
@@ -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
|
||||
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev
|
||||
```
|
||||
|
||||
### openSUSE Tumbleweed
|
||||
|
||||
```sh
|
||||
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel
|
||||
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel
|
||||
```
|
||||
|
||||
### Fedora 28 (CentOS 8)
|
||||
|
||||
```sh
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel
|
||||
```
|
||||
|
||||
### Arch (Manjaro)
|
||||
@@ -163,6 +163,7 @@ 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
|
||||
|
||||
|
||||
@@ -155,6 +155,7 @@ 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
|
||||
|
||||
|
||||
@@ -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
|
||||
sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev
|
||||
```
|
||||
|
||||
### openSUSE Tumbleweed
|
||||
|
||||
```sh
|
||||
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel
|
||||
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel
|
||||
```
|
||||
|
||||
### Fedora 28 (CentOS 8)
|
||||
|
||||
```sh
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel
|
||||
```
|
||||
|
||||
### Arch (Manjaro)
|
||||
@@ -166,6 +166,7 @@ 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
|
||||
|
||||
|
||||
@@ -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
|
||||
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev
|
||||
```
|
||||
|
||||
### openSUSE Tumbleweed
|
||||
|
||||
```sh
|
||||
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel
|
||||
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel
|
||||
```
|
||||
|
||||
### Fedora 28 (CentOS 8)
|
||||
|
||||
```sh
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel
|
||||
```
|
||||
|
||||
### Arch (Manjaro)
|
||||
@@ -168,6 +168,7 @@ 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
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ RustDesk приветствует вклад каждого. Ознакомьт
|
||||
|
||||
- Выполните команду `cargo run`
|
||||
|
||||
## [Сборка](https://rustdesk.com/docs/en/dev/build/)
|
||||
## [Сборка](https://rustdesk.com/docs/ru/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
|
||||
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev
|
||||
```
|
||||
|
||||
### openSUSE Tumbleweed
|
||||
|
||||
```sh
|
||||
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel
|
||||
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel
|
||||
```
|
||||
|
||||
### Fedora 28 (CentOS 8)
|
||||
|
||||
```sh
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel
|
||||
```
|
||||
|
||||
### Arch (Manjaro)
|
||||
@@ -170,6 +170,7 @@ 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
|
||||
|
||||
## Скриншоты
|
||||
|
||||
@@ -179,4 +180,4 @@ target/release/rustdesk
|
||||
|
||||

|
||||
|
||||

|
||||

|
||||
@@ -166,6 +166,7 @@ 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
|
||||
|
||||
@@ -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
|
||||
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev
|
||||
```
|
||||
|
||||
### openSUSE Tumbleweed
|
||||
|
||||
```sh
|
||||
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel
|
||||
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel
|
||||
```
|
||||
|
||||
### Fedora 28 (CentOS 8)
|
||||
|
||||
```sh
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel
|
||||
```
|
||||
|
||||
### Arch (Manjaro)
|
||||
@@ -160,6 +160,7 @@ 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
|
||||
|
||||
## Знімки екрана
|
||||
|
||||
|
||||
@@ -148,6 +148,7 @@ 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
|
||||
|
||||
|
||||
@@ -220,6 +220,7 @@ 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代码
|
||||
|
||||
## 截图
|
||||
|
||||
|
||||
@@ -21,6 +21,18 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"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",
|
||||
@@ -51,4 +63,4 @@
|
||||
"--socket=pulseaudio",
|
||||
"--talk-name=org.freedesktop.Flatpak"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -82,8 +82,7 @@ protobuf {
|
||||
}
|
||||
|
||||
android {
|
||||
namespace "com.carriez.flutter_hbb"
|
||||
compileSdkVersion 36
|
||||
compileSdkVersion 34
|
||||
sourceSets {
|
||||
main.java.srcDirs += 'src/main/kotlin'
|
||||
|
||||
@@ -92,7 +91,6 @@ android {
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
coreLibraryDesugaringEnabled true
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
@@ -101,7 +99,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 36
|
||||
targetSdkVersion 33
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
}
|
||||
@@ -130,7 +128,6 @@ 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'
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
<?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" 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.MANAGE_EXTERNAL_STORAGE" />
|
||||
<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" />
|
||||
@@ -30,6 +26,7 @@
|
||||
android:name=".MainApplication"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="RustDesk"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:roundIcon="@mipmap/ic_launcher"
|
||||
android:supportsRtl="true">
|
||||
|
||||
@@ -91,12 +88,7 @@
|
||||
<service
|
||||
android:name=".MainService"
|
||||
android:enabled="true"
|
||||
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>
|
||||
android:foregroundServiceType="mediaProjection" />
|
||||
|
||||
<service
|
||||
android:name=".FloatingWindowService"
|
||||
|
||||
@@ -18,33 +18,7 @@ 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) {
|
||||
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 val logTag = "LOG_AUDIO_RECORD_HANDLE"
|
||||
|
||||
private var audioRecorder: AudioRecord? = null
|
||||
private var audioReader: AudioReader? = null
|
||||
@@ -105,94 +79,48 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart:
|
||||
return
|
||||
}
|
||||
// read f32 to byte , length * 4
|
||||
val bufferSize = 2 * 4 * AudioRecord.getMinBufferSize(
|
||||
minBufferSize = 2 * 4 * AudioRecord.getMinBufferSize(
|
||||
AUDIO_SAMPLE_RATE,
|
||||
AUDIO_CHANNEL_MASK,
|
||||
AUDIO_ENCODING
|
||||
)
|
||||
if (bufferSize <= 0) {
|
||||
if (minBufferSize == 0) {
|
||||
Log.d(logTag, "get min buffer size fail!")
|
||||
return
|
||||
}
|
||||
audioReader = AudioReader(bufferSize, 4)
|
||||
minBufferSize = bufferSize
|
||||
audioReader = AudioReader(minBufferSize, 4)
|
||||
Log.d(logTag, "init audioData len:$minBufferSize")
|
||||
}
|
||||
|
||||
private fun releaseRecorder(recorder: AudioRecord) {
|
||||
try {
|
||||
recorder.release()
|
||||
} finally {
|
||||
if (audioRecorder === recorder) {
|
||||
audioRecorder = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
fun startAudioRecorder() {
|
||||
checkAudioReader()
|
||||
if (audioReader != null && audioRecorder != null && minBufferSize != 0) {
|
||||
try {
|
||||
releaseRecorder(recorder)
|
||||
} finally {
|
||||
if (audioFramePublisherAcquired) {
|
||||
releaseAudioFramePublisher()
|
||||
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")
|
||||
}
|
||||
false
|
||||
} else {
|
||||
Log.d(logTag, "startAudioRecorder fail")
|
||||
}
|
||||
}
|
||||
|
||||
fun isVoiceCallActive(): Boolean {
|
||||
return audioRecorder?.audioSource == MediaRecorder.AudioSource.VOICE_COMMUNICATION
|
||||
}
|
||||
|
||||
fun onVoiceCallStarted(mediaProjection: MediaProjection?): Boolean {
|
||||
if (!isSupportVoiceCall()) {
|
||||
return false
|
||||
@@ -209,9 +137,11 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart:
|
||||
if (!isSupportVoiceCall()) {
|
||||
return true
|
||||
}
|
||||
val switched = !isVideoStart() || switchOutVoiceCall(mediaProjection)
|
||||
if (isVideoStart()) {
|
||||
switchOutVoiceCall(mediaProjection)
|
||||
}
|
||||
tryReleaseAudio()
|
||||
return switched
|
||||
return true
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
@@ -229,7 +159,8 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart:
|
||||
Log.e(logTag, "createAudioRecorder fail")
|
||||
return false
|
||||
}
|
||||
return startAudioRecorder()
|
||||
startAudioRecorder()
|
||||
return true
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
@@ -246,7 +177,8 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart:
|
||||
Log.e(logTag, "createAudioRecorder fail")
|
||||
return false
|
||||
}
|
||||
return startAudioRecorder()
|
||||
startAudioRecorder()
|
||||
return true
|
||||
}
|
||||
|
||||
fun tryReleaseAudio() {
|
||||
|
||||
@@ -9,7 +9,6 @@ package com.carriez.flutter_hbb
|
||||
|
||||
import ffi.FFI
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
@@ -25,10 +24,6 @@ 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
|
||||
@@ -38,9 +33,6 @@ 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() {
|
||||
@@ -54,23 +46,6 @@ 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 })
|
||||
@@ -116,108 +91,6 @@ 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)
|
||||
}
|
||||
@@ -233,16 +106,6 @@ class MainActivity : FlutterActivity() {
|
||||
|
||||
override fun onDestroy() {
|
||||
Log.e(logTag, "onDestroy")
|
||||
// The process can outlive the UI whenever something keeps it alive:
|
||||
// MainService, or the accessibility InputService on its own. Only the
|
||||
// former gets onTaskRemoved, so close outgoing sessions here too,
|
||||
// otherwise a session survives with no UI left to close it.
|
||||
// `isFinishing` distinguishes the user really leaving from a destroy
|
||||
// for recreation (configuration change, "don't keep activities"),
|
||||
// which must not tear down a live session.
|
||||
if (isFinishing) {
|
||||
FFI.closeAllSessions()
|
||||
}
|
||||
mainService?.let {
|
||||
unbindService(serviceConnection)
|
||||
}
|
||||
@@ -394,242 +257,6 @@ 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) {
|
||||
@@ -654,228 +281,6 @@ 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
|
||||
|
||||
@@ -17,7 +17,6 @@ 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
|
||||
@@ -151,7 +150,7 @@ class MainService : Service() {
|
||||
if (incomingVoiceCall) {
|
||||
voiceCallRequestNotification(id, "Voice Call Request", username, peerId)
|
||||
} else {
|
||||
if (!switchOutVoiceCall()) {
|
||||
if (!audioRecordHandle.switchOutVoiceCall(mediaProjection)) {
|
||||
Log.e(logTag, "switchOutVoiceCall fail")
|
||||
MainActivity.flutterMethodChannel?.invokeMethod("msgbox", mapOf(
|
||||
"type" to "custom-nook-nocancel-hasclose-error",
|
||||
@@ -160,7 +159,7 @@ class MainService : Service() {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (!switchToVoiceCall()) {
|
||||
if (!audioRecordHandle.switchToVoiceCall(mediaProjection)) {
|
||||
Log.e(logTag, "switchToVoiceCall fail")
|
||||
MainActivity.flutterMethodChannel?.invokeMethod("msgbox", mapOf(
|
||||
"type" to "custom-nook-nocancel-hasclose-error",
|
||||
@@ -215,19 +214,6 @@ 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
|
||||
@@ -257,9 +243,7 @@ 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, "") ?: ""
|
||||
val homePath = applicationContext.getExternalFilesDir(null)?.absolutePath
|
||||
?: applicationContext.filesDir.absolutePath
|
||||
FFI.startServer(configPath, homePath, "")
|
||||
FFI.startServer(configPath, "")
|
||||
|
||||
createForegroundNotification()
|
||||
}
|
||||
@@ -270,16 +254,6 @@ class MainService : Service() {
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
// Swiping the app away from recents destroys the UI but this service keeps
|
||||
// the process alive, so outgoing sessions would stay connected with no way
|
||||
// to close them. Incoming connections are unaffected: the service keeps
|
||||
// running so the device stays reachable.
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
Log.d(logTag, "onTaskRemoved, closing outgoing sessions")
|
||||
FFI.closeAllSessions()
|
||||
super.onTaskRemoved(rootIntent)
|
||||
}
|
||||
|
||||
private var isHalfScale: Boolean? = null;
|
||||
private fun updateScreenInfo(orientation: Int) {
|
||||
var w: Int
|
||||
@@ -353,6 +327,8 @@ 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()
|
||||
}
|
||||
@@ -361,7 +337,10 @@ class MainService : Service() {
|
||||
getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
|
||||
|
||||
intent.getParcelableExtra<Intent>(EXT_MEDIA_PROJECTION_RES_INTENT)?.let {
|
||||
replaceMediaProjection(mediaProjectionManager, it)
|
||||
mediaProjection =
|
||||
mediaProjectionManager.getMediaProjection(Activity.RESULT_OK, it)
|
||||
checkMediaPermission()
|
||||
_isReady = true
|
||||
} ?: let {
|
||||
Log.d(logTag, "getParcelableExtra intent null, invoke requestMediaProjection")
|
||||
requestMediaProjection()
|
||||
@@ -375,23 +354,14 @@ class MainService : Service() {
|
||||
updateScreenInfo(newConfig.orientation)
|
||||
}
|
||||
|
||||
private fun requestMediaProjection(recovery: Boolean = false) {
|
||||
private fun requestMediaProjection() {
|
||||
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) {
|
||||
@@ -425,149 +395,15 @@ class MainService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
return audioRecordHandle.onVoiceCallStarted(mediaProjection)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun onVoiceCallClosed(): Boolean {
|
||||
captureRestartInVoiceCall = false
|
||||
return stopMicrophoneCapture {
|
||||
audioRecordHandle.onVoiceCallClosed(mediaProjection)
|
||||
}
|
||||
return audioRecordHandle.onVoiceCallClosed(mediaProjection)
|
||||
}
|
||||
|
||||
fun startCapture(): Boolean {
|
||||
return startCapture(false)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun startCapture(inVoiceCall: Boolean): Boolean {
|
||||
if (isStart) {
|
||||
return true
|
||||
}
|
||||
@@ -575,35 +411,25 @@ 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()
|
||||
|
||||
val videoStarted = if (useVP9) {
|
||||
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) {
|
||||
val audioStarted = if (inVoiceCall) {
|
||||
switchToVoiceCall()
|
||||
if (!audioRecordHandle.createAudioRecorder(false, mediaProjection)) {
|
||||
Log.d(logTag, "createAudioRecorder fail")
|
||||
} else {
|
||||
audioRecordHandle.createAudioRecorder(false, mediaProjection) &&
|
||||
audioRecordHandle.startAudioRecorder()
|
||||
Log.d(logTag, "audio recorder start")
|
||||
audioRecordHandle.startAudioRecorder()
|
||||
}
|
||||
Log.d(logTag, if (audioStarted) "audio recorder start" else "audio recorder start failed")
|
||||
}
|
||||
captureRestartInVoiceCall = false
|
||||
checkMediaPermission()
|
||||
_isStart = true
|
||||
FFI.setFrameRawEnable("video",true)
|
||||
@@ -611,24 +437,9 @@ 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)
|
||||
@@ -659,11 +470,8 @@ class MainService : Service() {
|
||||
surface?.release()
|
||||
|
||||
// release audio
|
||||
stopMicrophoneCapture {
|
||||
_isAudioStart = false
|
||||
audioRecordHandle.tryReleaseAudio()
|
||||
true
|
||||
}
|
||||
_isAudioStart = false
|
||||
audioRecordHandle.tryReleaseAudio()
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
@@ -678,9 +486,7 @@ class MainService : Service() {
|
||||
virtualDisplay = null
|
||||
}
|
||||
|
||||
releaseMediaProjection()
|
||||
mediaProjectionForegroundService = false
|
||||
microphoneForegroundService = false
|
||||
mediaProjection = null
|
||||
checkMediaPermission()
|
||||
stopForeground(true)
|
||||
stopService(Intent(this, FloatingWindowService::class.java))
|
||||
@@ -703,70 +509,49 @@ class MainService : Service() {
|
||||
return isReady
|
||||
}
|
||||
|
||||
private fun startRawVideoRecorder(mp: MediaProjection): Boolean {
|
||||
private fun startRawVideoRecorder(mp: MediaProjection) {
|
||||
Log.d(logTag, "startRawVideoRecorder,screen info:$SCREEN_INFO")
|
||||
val captureSurface = surface
|
||||
if (captureSurface == null) {
|
||||
if (surface == null) {
|
||||
Log.d(logTag, "startRawVideoRecorder failed,surface is null")
|
||||
return false
|
||||
return
|
||||
}
|
||||
return createOrSetVirtualDisplay(mp, captureSurface)
|
||||
createOrSetVirtualDisplay(mp, surface!!)
|
||||
}
|
||||
|
||||
private fun startVP9VideoRecorder(mp: MediaProjection): Boolean {
|
||||
private fun startVP9VideoRecorder(mp: MediaProjection) {
|
||||
createMediaCodec()
|
||||
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)
|
||||
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!!)
|
||||
}
|
||||
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): 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(
|
||||
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(
|
||||
"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", e)
|
||||
handleVirtualDisplayFailure()
|
||||
Log.w(logTag, "createOrSetVirtualDisplay: got SecurityException, re-requesting confirmation");
|
||||
// This initiates a prompt dialog for the user to confirm screen projection.
|
||||
requestMediaProjection()
|
||||
}
|
||||
}
|
||||
|
||||
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) {}
|
||||
@@ -857,63 +642,7 @@ class MainService : Service() {
|
||||
.setColor(ContextCompat.getColor(this, R.color.primary))
|
||||
.setWhen(System.currentTimeMillis())
|
||||
.build()
|
||||
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
|
||||
}
|
||||
startForeground(DEFAULT_NOTIFY_ID, notification)
|
||||
}
|
||||
|
||||
private fun loginRequestNotification(
|
||||
|
||||
@@ -5,7 +5,6 @@ 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() {
|
||||
@@ -32,13 +31,7 @@ class PermissionRequestTransparentActivity: Activity() {
|
||||
if (resultCode == RESULT_OK && data != null) {
|
||||
launchService(data)
|
||||
} else {
|
||||
val resultReceiver =
|
||||
intent.getParcelableExtra<ResultReceiver>(EXT_MEDIA_PROJECTION_RESULT_RECEIVER)
|
||||
if (resultReceiver != null) {
|
||||
resultReceiver.send(RES_FAILED, null)
|
||||
} else {
|
||||
setResult(RES_FAILED)
|
||||
}
|
||||
setResult(RES_FAILED)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,4 +51,4 @@ class PermissionRequestTransparentActivity: Activity() {
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -33,16 +33,11 @@ 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
|
||||
@@ -52,12 +47,6 @@ 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"
|
||||
@@ -165,4 +154,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)
|
||||
}
|
||||
}
|
||||
@@ -15,13 +15,12 @@ object FFI {
|
||||
external fun init(ctx: Context)
|
||||
external fun onAppStart(ctx: Context)
|
||||
external fun setClipboardManager(clipboardManager: RdClipboardManager)
|
||||
external fun startServer(app_dir: String, home_dir: String, custom_client_config: String)
|
||||
external fun startServer(app_dir: String, custom_client_config: String)
|
||||
external fun startService()
|
||||
external fun onVideoFrameUpdate(buf: ByteBuffer)
|
||||
external fun onAudioFrameUpdate(buf: ByteBuffer)
|
||||
external fun translateLocale(localeName: String, input: String): String
|
||||
external fun refreshScreen()
|
||||
external fun closeAllSessions()
|
||||
external fun setFrameRawEnable(name: String, value: Boolean)
|
||||
external fun setCodecInfo(info: String)
|
||||
external fun getLocalOption(key: String): String
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<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>
|
||||
|
||||
@@ -1,29 +1,3 @@
|
||||
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()
|
||||
@@ -35,16 +9,6 @@ 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')
|
||||
|
||||
@@ -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-8.11.1-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.4-all.zip
|
||||
|
||||
@@ -18,7 +18,7 @@ pluginManagement {
|
||||
|
||||
plugins {
|
||||
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
||||
id "com.android.application" version "8.10.1" apply false
|
||||
id "com.android.application" version "7.3.1" apply false
|
||||
id "org.jetbrains.kotlin.android" version "2.1.21" apply false
|
||||
}
|
||||
|
||||
|
||||
36
flutter/ios/Runner/GoogleService-Info.plist
Normal file
36
flutter/ios/Runner/GoogleService-Info.plist
Normal file
@@ -0,0 +1,36 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CLIENT_ID</key>
|
||||
<string>768133699366-k1rn3ls1u2n3nklmgd9t4cmpdob0c8bn.apps.googleusercontent.com</string>
|
||||
<key>REVERSED_CLIENT_ID</key>
|
||||
<string>com.googleusercontent.apps.768133699366-k1rn3ls1u2n3nklmgd9t4cmpdob0c8bn</string>
|
||||
<key>API_KEY</key>
|
||||
<string>AIzaSyCf57HjCwSokt91CqFI0Mwf8D--ek0jvfc</string>
|
||||
<key>GCM_SENDER_ID</key>
|
||||
<string>768133699366</string>
|
||||
<key>PLIST_VERSION</key>
|
||||
<string>1</string>
|
||||
<key>BUNDLE_ID</key>
|
||||
<string>com.carriez.flutterHbb</string>
|
||||
<key>PROJECT_ID</key>
|
||||
<string>rustdesk</string>
|
||||
<key>STORAGE_BUCKET</key>
|
||||
<string>rustdesk.appspot.com</string>
|
||||
<key>IS_ADS_ENABLED</key>
|
||||
<false></false>
|
||||
<key>IS_ANALYTICS_ENABLED</key>
|
||||
<false></false>
|
||||
<key>IS_APPINVITE_ENABLED</key>
|
||||
<true></true>
|
||||
<key>IS_GCM_ENABLED</key>
|
||||
<true></true>
|
||||
<key>IS_SIGNIN_ENABLED</key>
|
||||
<true></true>
|
||||
<key>GOOGLE_APP_ID</key>
|
||||
<string>1:768133699366:ios:c33078a6181b9d507993e7</string>
|
||||
<key>DATABASE_URL</key>
|
||||
<string>https://rustdesk.firebaseio.com</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -84,6 +84,8 @@ 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;
|
||||
@@ -1519,6 +1521,13 @@ 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);
|
||||
@@ -2627,6 +2636,13 @@ 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,
|
||||
@@ -3108,15 +3124,6 @@ void onCopyFingerprint(String value) {
|
||||
}
|
||||
}
|
||||
|
||||
void onCopyId(String value) {
|
||||
if (value.isNotEmpty) {
|
||||
Clipboard.setData(ClipboardData(text: value));
|
||||
showToast('$value\n${translate("Copied")}');
|
||||
} else {
|
||||
showToast(translate("Invalid ID"));
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> callMainCheckSuperUserPermission() async {
|
||||
bool checked = await bind.mainCheckSuperUserPermission();
|
||||
if (isMacOS) {
|
||||
@@ -3997,11 +4004,6 @@ bool whitelistNotEmpty() {
|
||||
return v != '' && v != ',';
|
||||
}
|
||||
|
||||
bool idWhitelistNotEmpty() {
|
||||
final v = bind.mainGetOptionSync(key: kOptionIdWhitelist);
|
||||
return v != '' && v != ',';
|
||||
}
|
||||
|
||||
// `setMovable()` is only supported on macOS.
|
||||
//
|
||||
// On macOS, the window can be dragged by the tab bar by default.
|
||||
@@ -4032,8 +4034,7 @@ Widget netWorkErrorWidget() {
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
if (!gFFI.userModel.networkErrorFromServer.value)
|
||||
Text(translate("network_error_tip")),
|
||||
Text(translate("network_error_tip")),
|
||||
ElevatedButton(
|
||||
onPressed: gFFI.userModel.refreshCurrentUser,
|
||||
child: Text(translate("Retry")))
|
||||
|
||||
@@ -205,10 +205,6 @@ void changeWhiteList({Function()? callback}) async {
|
||||
const SizedBox(
|
||||
height: 8.0,
|
||||
),
|
||||
Text(translate("whitelist_cidr_tip")),
|
||||
const SizedBox(
|
||||
height: 8.0,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
@@ -286,111 +282,6 @@ void changeWhiteList({Function()? callback}) async {
|
||||
});
|
||||
}
|
||||
|
||||
void changeIdWhiteList({Function()? callback}) async {
|
||||
final curIdWhiteList = await bind.mainGetOption(key: kOptionIdWhitelist);
|
||||
var newIdWhiteListField = curIdWhiteList == defaultOptionWhitelist
|
||||
? ''
|
||||
: curIdWhiteList.split(',').join('\n');
|
||||
var controller = TextEditingController(text: newIdWhiteListField);
|
||||
var msg = "";
|
||||
var isInProgress = false;
|
||||
final isOptFixed = isOptionFixed(kOptionIdWhitelist);
|
||||
gFFI.dialogManager.show((setState, close, context) {
|
||||
return CustomAlertDialog(
|
||||
title: Text(translate("ID whitelisting")),
|
||||
content: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(translate("whitelist_sep")),
|
||||
const SizedBox(
|
||||
height: 8.0,
|
||||
),
|
||||
Text(translate("id_whitelist_wildcard_tip")),
|
||||
const SizedBox(
|
||||
height: 8.0,
|
||||
),
|
||||
Text(translate("id_whitelist_caveat_tip")),
|
||||
const SizedBox(
|
||||
height: 8.0,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
maxLines: null,
|
||||
decoration: InputDecoration(
|
||||
errorText: msg.isEmpty ? null : translate(msg),
|
||||
),
|
||||
controller: controller,
|
||||
enabled: !isOptFixed,
|
||||
autofocus: true)
|
||||
.workaroundFreezeLinuxMint(),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(
|
||||
height: 4.0,
|
||||
),
|
||||
// NOT use Offstage to wrap LinearProgressIndicator
|
||||
if (isInProgress) const LinearProgressIndicator(),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
dialogButton("Cancel", onPressed: close, isOutline: true),
|
||||
if (!isOptFixed)
|
||||
dialogButton("Clear", onPressed: () async {
|
||||
await bind.mainSetOption(
|
||||
key: kOptionIdWhitelist, value: defaultOptionWhitelist);
|
||||
callback?.call();
|
||||
close();
|
||||
}, isOutline: true),
|
||||
if (!isOptFixed)
|
||||
dialogButton(
|
||||
"OK",
|
||||
onPressed: () async {
|
||||
setState(() {
|
||||
msg = "";
|
||||
isInProgress = true;
|
||||
});
|
||||
newIdWhiteListField = controller.text.trim();
|
||||
var newIdWhiteList = "";
|
||||
if (newIdWhiteListField.isEmpty) {
|
||||
// pass
|
||||
} else {
|
||||
final ids = newIdWhiteListField
|
||||
.trim()
|
||||
.split(RegExp(r"[\s,;\n]+"))
|
||||
.where((e) => e.isNotEmpty)
|
||||
.toList();
|
||||
// Separators are handled above; allow all other Unicode characters.
|
||||
for (final id in ids) {
|
||||
final hasControlCharacters = id.runes.any(
|
||||
(char) => char <= 0x1f || (char >= 0x7f && char <= 0x9f));
|
||||
if (hasControlCharacters) {
|
||||
msg = "${translate("Invalid ID")} $id";
|
||||
setState(() {
|
||||
isInProgress = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
newIdWhiteList = ids.join(',');
|
||||
}
|
||||
if (newIdWhiteList.trim().isEmpty) {
|
||||
newIdWhiteList = defaultOptionWhitelist;
|
||||
}
|
||||
await bind.mainSetOption(
|
||||
key: kOptionIdWhitelist, value: newIdWhiteList);
|
||||
callback?.call();
|
||||
close();
|
||||
},
|
||||
),
|
||||
],
|
||||
onCancel: close,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<String> changeDirectAccessPort(
|
||||
String currentIP, String currentPort) async {
|
||||
final controller = TextEditingController(text: currentPort);
|
||||
@@ -936,19 +827,26 @@ void enterPasswordDialog(
|
||||
);
|
||||
}
|
||||
|
||||
void enterUserLoginDialog(SessionID sessionId,
|
||||
OverlayDialogManager dialogManager, String osAccountDescTip) async {
|
||||
void enterUserLoginDialog(
|
||||
SessionID sessionId,
|
||||
OverlayDialogManager dialogManager,
|
||||
String osAccountDescTip,
|
||||
bool canRememberAccount) async {
|
||||
await _connectDialog(
|
||||
sessionId,
|
||||
dialogManager,
|
||||
osUsernameController: TextEditingController(),
|
||||
osPasswordController: TextEditingController(),
|
||||
osAccountDescTip: osAccountDescTip,
|
||||
canRememberAccount: canRememberAccount,
|
||||
);
|
||||
}
|
||||
|
||||
void enterUserLoginAndPasswordDialog(SessionID sessionId,
|
||||
OverlayDialogManager dialogManager, String osAccountDescTip) async {
|
||||
void enterUserLoginAndPasswordDialog(
|
||||
SessionID sessionId,
|
||||
OverlayDialogManager dialogManager,
|
||||
String osAccountDescTip,
|
||||
bool canRememberAccount) async {
|
||||
await _connectDialog(
|
||||
sessionId,
|
||||
dialogManager,
|
||||
@@ -956,6 +854,7 @@ void enterUserLoginAndPasswordDialog(SessionID sessionId,
|
||||
osPasswordController: TextEditingController(),
|
||||
passwordController: TextEditingController(),
|
||||
osAccountDescTip: osAccountDescTip,
|
||||
canRememberAccount: canRememberAccount,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -966,6 +865,7 @@ _connectDialog(
|
||||
TextEditingController? osPasswordController,
|
||||
TextEditingController? passwordController,
|
||||
String? osAccountDescTip,
|
||||
bool canRememberAccount = true,
|
||||
}) async {
|
||||
final errUsername = ''.obs;
|
||||
var rememberPassword = false;
|
||||
@@ -973,6 +873,11 @@ _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) {
|
||||
@@ -1000,6 +905,12 @@ _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,
|
||||
@@ -1076,6 +987,16 @@ _connectDialog(
|
||||
controller: osPasswordController,
|
||||
autoFocus: false,
|
||||
),
|
||||
if (canRememberAccount)
|
||||
rememberWidget(
|
||||
translate('remember_account_tip'),
|
||||
rememberAccount,
|
||||
(v) {
|
||||
if (v != null) {
|
||||
setState(() => rememberAccount = v);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -1512,6 +1433,91 @@ 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,
|
||||
@@ -1899,110 +1905,26 @@ customImageQualityDialog(SessionID sessionId, String id, FFI ffi) async {
|
||||
msgBoxCommon(ffi.dialogManager, 'Custom Image Quality', content, [btnClose]);
|
||||
}
|
||||
|
||||
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;
|
||||
trackpadSpeedDialog(SessionID sessionId, FFI ffi) async {
|
||||
int initSpeed = ffi.inputModel.trackpadSpeed;
|
||||
final curSpeed = SimpleWrapper(initSpeed);
|
||||
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);
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
return CustomAlertDialog(
|
||||
title: Text(
|
||||
translate('Trackpad speed'),
|
||||
style: TextStyle(fontSize: 21),
|
||||
),
|
||||
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,
|
||||
);
|
||||
ffi.dialogManager.dismissAll();
|
||||
});
|
||||
msgBoxCommon(
|
||||
ffi.dialogManager,
|
||||
'Trackpad speed',
|
||||
TrackpadSpeedWidget(
|
||||
value: curSpeed,
|
||||
),
|
||||
[btnClose]);
|
||||
}
|
||||
|
||||
void deleteConfirmDialog(Function onSubmit, String title) async {
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hbb/common/hbbs/hbbs.dart';
|
||||
import 'package:flutter_hbb/models/platform_model.dart';
|
||||
import 'package:flutter_hbb/models/user_model.dart';
|
||||
@@ -12,7 +11,6 @@ import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../common.dart';
|
||||
import './dialog.dart';
|
||||
import './oidc_auth_status.dart';
|
||||
|
||||
const kOpSvgList = [
|
||||
'github',
|
||||
@@ -25,8 +23,6 @@ const kOpSvgList = [
|
||||
'auth0',
|
||||
'microsoft'
|
||||
];
|
||||
const _requestingAccountAuth = 'Requesting account auth';
|
||||
const _waitingAccountAuth = 'Waiting account auth';
|
||||
|
||||
class _OidcProviderBranding {
|
||||
final String label;
|
||||
@@ -94,7 +90,6 @@ class ButtonOP extends StatelessWidget {
|
||||
final Color primaryColor;
|
||||
final double height;
|
||||
final Function() onTap;
|
||||
final bool Function() canStartAuth;
|
||||
|
||||
const ButtonOP({
|
||||
Key? key,
|
||||
@@ -104,7 +99,6 @@ class ButtonOP extends StatelessWidget {
|
||||
required this.primaryColor,
|
||||
required this.height,
|
||||
required this.onTap,
|
||||
required this.canStartAuth,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
@@ -117,10 +111,11 @@ class ButtonOP extends StatelessWidget {
|
||||
width: 200,
|
||||
child: Obx(() => ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: primaryColor,
|
||||
backgroundColor: curOP.value.isEmpty || curOP.value == op
|
||||
? primaryColor
|
||||
: Colors.grey,
|
||||
).copyWith(elevation: ButtonStyleButton.allOrNull(0.0)),
|
||||
onPressed:
|
||||
curOP.value == 'rustdesk' || !canStartAuth() ? null : onTap,
|
||||
onPressed: curOP.value.isEmpty || curOP.value == op ? onTap : null,
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
@@ -150,120 +145,15 @@ class ConfigOP {
|
||||
ConfigOP({required this.op, required this.icon});
|
||||
}
|
||||
|
||||
class _OidcAuthController {
|
||||
final RxString curOP = ''.obs;
|
||||
Future<void> _pendingOperation = Future<void>.value();
|
||||
int _authAttempt = 0;
|
||||
bool _closed = false;
|
||||
final _cancelInProgress = false.obs;
|
||||
|
||||
bool _isCurrent(int authAttempt, String op) {
|
||||
return !_closed && authAttempt == _authAttempt && curOP.value == op;
|
||||
}
|
||||
|
||||
Future<bool> start(String op) {
|
||||
if (!canStart()) {
|
||||
return Future<bool>.value(false);
|
||||
}
|
||||
final authAttempt = ++_authAttempt;
|
||||
curOP.value = op;
|
||||
// Web auth must start during the original user gesture so popups are allowed.
|
||||
if (isWeb) {
|
||||
return _startWeb(authAttempt, op);
|
||||
}
|
||||
final completer = Completer<bool>();
|
||||
_pendingOperation = _pendingOperation.then((_) async {
|
||||
if (!_isCurrent(authAttempt, op)) {
|
||||
completer.complete(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await bind.mainAccountAuthCancel();
|
||||
if (!_isCurrent(authAttempt, op)) {
|
||||
completer.complete(false);
|
||||
return;
|
||||
}
|
||||
await bind.mainAccountAuth(op: op, rememberMe: true);
|
||||
completer.complete(_isCurrent(authAttempt, op));
|
||||
} catch (error, stackTrace) {
|
||||
completer.completeError(error, stackTrace);
|
||||
}
|
||||
});
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
Future<bool> _startWeb(int authAttempt, String op) async {
|
||||
await bind.mainAccountAuth(op: op, rememberMe: true);
|
||||
return _isCurrent(authAttempt, op);
|
||||
}
|
||||
|
||||
bool canStart() {
|
||||
return !_closed && !_cancelInProgress.value;
|
||||
}
|
||||
|
||||
Future<bool> cancelCurrent(String op) {
|
||||
if (!canStart() || curOP.value != op) {
|
||||
return Future<bool>.value(false);
|
||||
}
|
||||
final authAttempt = ++_authAttempt;
|
||||
final completer = Completer<bool>();
|
||||
_cancelInProgress.value = true;
|
||||
_pendingOperation = _pendingOperation.then((_) async {
|
||||
try {
|
||||
await bind.mainAccountAuthCancel();
|
||||
completer.complete(_isCurrent(authAttempt, op));
|
||||
} catch (error, stackTrace) {
|
||||
completer.completeError(error, stackTrace);
|
||||
} finally {
|
||||
_cancelInProgress.value = false;
|
||||
}
|
||||
});
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
Future<void> _cancelBackend() async {
|
||||
try {
|
||||
await bind.mainAccountAuthCancel();
|
||||
} catch (error, stackTrace) {
|
||||
debugPrint('Failed to cancel account authentication $error');
|
||||
debugPrintStack(stackTrace: stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> close() async {
|
||||
if (_closed) {
|
||||
return;
|
||||
}
|
||||
final hasActiveOidcAuth =
|
||||
curOP.value.isNotEmpty && curOP.value != 'rustdesk';
|
||||
_closed = true;
|
||||
_authAttempt++;
|
||||
curOP.value = '';
|
||||
if (hasActiveOidcAuth) {
|
||||
await _cancelBackend();
|
||||
}
|
||||
await _pendingOperation;
|
||||
if (hasActiveOidcAuth) {
|
||||
await _cancelBackend();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class WidgetOP extends StatefulWidget {
|
||||
final ConfigOP config;
|
||||
final RxString curOP;
|
||||
final Function(Map<String, dynamic>) cbLogin;
|
||||
final Future<bool> Function(String) startAuth;
|
||||
final Future<bool> Function(String) cancelAuth;
|
||||
final bool Function() canStartAuth;
|
||||
const WidgetOP({
|
||||
Key? key,
|
||||
required this.config,
|
||||
required this.curOP,
|
||||
required this.cbLogin,
|
||||
required this.startAuth,
|
||||
required this.cancelAuth,
|
||||
required this.canStartAuth,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
@@ -274,8 +164,6 @@ class WidgetOP extends StatefulWidget {
|
||||
|
||||
class _WidgetOPState extends State<WidgetOP> {
|
||||
Timer? _updateTimer;
|
||||
bool _isAuthStatusQueryInFlight = false;
|
||||
int _authAttempt = 0;
|
||||
String _stateMsg = '';
|
||||
String _failedMsg = '';
|
||||
String _url = '';
|
||||
@@ -286,180 +174,55 @@ class _WidgetOPState extends State<WidgetOP> {
|
||||
_updateTimer?.cancel();
|
||||
}
|
||||
|
||||
_beginQueryState(int authAttempt) {
|
||||
_updateTimer?.cancel();
|
||||
unawaited(_runAuthStatusQuery(() => _updateState(authAttempt)));
|
||||
_beginQueryState() {
|
||||
_updateTimer = Timer.periodic(Duration(seconds: 1), (timer) {
|
||||
unawaited(_runAuthStatusQuery(() => _updateState(authAttempt)));
|
||||
_updateState();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _runAuthStatusQuery(Future<void> Function() query) async {
|
||||
if (_isAuthStatusQueryInFlight) {
|
||||
return;
|
||||
}
|
||||
_isAuthStatusQueryInFlight = true;
|
||||
try {
|
||||
await query();
|
||||
} finally {
|
||||
_isAuthStatusQueryInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _launchAuthUrl(String url) async {
|
||||
try {
|
||||
final launched = await launchUrl(
|
||||
Uri.parse(url),
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
if (!launched) {
|
||||
debugPrint('Failed to open OIDC authentication URL');
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
debugPrint(
|
||||
'Failed to open OIDC authentication URL (${error.runtimeType})');
|
||||
debugPrintStack(stackTrace: stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _copyAuthUrl(String url) async {
|
||||
try {
|
||||
await Clipboard.setData(ClipboardData(text: url));
|
||||
showToast(
|
||||
translate('Copied'),
|
||||
);
|
||||
} catch (error, stackTrace) {
|
||||
debugPrint(
|
||||
'Failed to copy OIDC authentication URL (${error.runtimeType})');
|
||||
debugPrintStack(stackTrace: stackTrace);
|
||||
showToast(translate('Failed'));
|
||||
}
|
||||
}
|
||||
|
||||
void _runCurrentAuthUrlAction(
|
||||
int authAttempt,
|
||||
String authUrl,
|
||||
Future<void> Function(String) action,
|
||||
) {
|
||||
if (!mounted ||
|
||||
authAttempt != _authAttempt ||
|
||||
widget.curOP.value != widget.config.op ||
|
||||
authUrl.isEmpty ||
|
||||
_url != authUrl) {
|
||||
return;
|
||||
}
|
||||
unawaited(action(authUrl));
|
||||
}
|
||||
|
||||
void _invalidateAuthAttempt() {
|
||||
_authAttempt++;
|
||||
_url = '';
|
||||
}
|
||||
|
||||
bool _isCurrentAuthAttempt(int authAttempt) {
|
||||
return mounted &&
|
||||
authAttempt == _authAttempt &&
|
||||
widget.curOP.value == widget.config.op;
|
||||
}
|
||||
|
||||
Future<void> _handleAuthFailure(
|
||||
int authAttempt,
|
||||
Object error,
|
||||
String operation,
|
||||
) async {
|
||||
debugPrint('Failed to $operation $error');
|
||||
if (!_isCurrentAuthAttempt(authAttempt)) {
|
||||
return;
|
||||
}
|
||||
_updateTimer?.cancel();
|
||||
setState(() => _failedMsg = 'Failed');
|
||||
try {
|
||||
final canceled = await widget.cancelAuth(widget.config.op);
|
||||
if (!canceled || !_isCurrentAuthAttempt(authAttempt)) {
|
||||
return;
|
||||
}
|
||||
} catch (cancelError, stackTrace) {
|
||||
debugPrint('Failed to cancel account authentication $cancelError');
|
||||
debugPrintStack(stackTrace: stackTrace);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_invalidateAuthAttempt();
|
||||
widget.curOP.value = '';
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _updateState(int authAttempt) {
|
||||
if (!mounted ||
|
||||
authAttempt != _authAttempt ||
|
||||
widget.curOP.value != widget.config.op) {
|
||||
_updateTimer?.cancel();
|
||||
return Future<void>.value();
|
||||
}
|
||||
return bind.mainAccountAuthResult().then<void>((result) {
|
||||
if (!mounted ||
|
||||
authAttempt != _authAttempt ||
|
||||
widget.curOP.value != widget.config.op ||
|
||||
result.isEmpty) {
|
||||
_updateState() {
|
||||
bind.mainAccountAuthResult().then((result) {
|
||||
if (result.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final resultMap = jsonDecode(result);
|
||||
if (resultMap == null) {
|
||||
return;
|
||||
}
|
||||
final String backendStateMsg = resultMap['state_msg'];
|
||||
final String stateMsg = resultMap['state_msg'];
|
||||
String failedMsg = resultMap['failed_msg'];
|
||||
final String? url = resultMap['url'];
|
||||
final stateMsg = backendStateMsg == _requestingAccountAuth &&
|
||||
(url == null || url.isEmpty)
|
||||
? _waitingAccountAuth
|
||||
: backendStateMsg;
|
||||
final bool urlLaunched = (resultMap['url_launched'] as bool?) ?? false;
|
||||
final authBody = resultMap['auth_body'];
|
||||
if (authBody != null) {
|
||||
_updateTimer?.cancel();
|
||||
_invalidateAuthAttempt();
|
||||
widget.curOP.value = '';
|
||||
widget.cbLogin(authBody as Map<String, dynamic>);
|
||||
return;
|
||||
}
|
||||
final stateChanged = _stateMsg != stateMsg || _failedMsg != failedMsg;
|
||||
final newUrl = _url.isEmpty && url != null && url.isNotEmpty ? url : null;
|
||||
if (!stateChanged && newUrl == null) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_stateMsg = stateMsg;
|
||||
_failedMsg = failedMsg;
|
||||
if (newUrl != null) {
|
||||
_url = newUrl;
|
||||
if (_stateMsg != stateMsg || _failedMsg != failedMsg) {
|
||||
if (_url.isEmpty && url != null && url.isNotEmpty) {
|
||||
if (!urlLaunched) {
|
||||
launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
|
||||
}
|
||||
_url = url;
|
||||
}
|
||||
if (failedMsg.isNotEmpty) {
|
||||
_invalidateAuthAttempt();
|
||||
widget.curOP.value = '';
|
||||
if (authBody != null) {
|
||||
_updateTimer?.cancel();
|
||||
widget.curOP.value = '';
|
||||
widget.cbLogin(authBody as Map<String, dynamic>);
|
||||
}
|
||||
});
|
||||
if (newUrl != null && failedMsg.isEmpty && !urlLaunched) {
|
||||
unawaited(_launchAuthUrl(newUrl));
|
||||
|
||||
setState(() {
|
||||
_stateMsg = stateMsg;
|
||||
_failedMsg = failedMsg;
|
||||
if (failedMsg.isNotEmpty) {
|
||||
widget.curOP.value = '';
|
||||
_updateTimer?.cancel();
|
||||
}
|
||||
});
|
||||
}
|
||||
}).catchError(
|
||||
(e) => _handleAuthFailure(
|
||||
authAttempt,
|
||||
e,
|
||||
'query account authentication',
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
int _resetState() {
|
||||
_updateTimer?.cancel();
|
||||
setState(() {
|
||||
_invalidateAuthAttempt();
|
||||
_stateMsg = _waitingAccountAuth;
|
||||
_failedMsg = '';
|
||||
});
|
||||
return _authAttempt;
|
||||
_resetState() {
|
||||
_stateMsg = '';
|
||||
_failedMsg = '';
|
||||
_url = '';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -472,31 +235,11 @@ class _WidgetOPState extends State<WidgetOP> {
|
||||
icon: widget.config.icon,
|
||||
primaryColor: str2color(widget.config.op, 0x7f),
|
||||
height: 36,
|
||||
canStartAuth: widget.canStartAuth,
|
||||
onTap: () async {
|
||||
if (!widget.canStartAuth()) {
|
||||
return;
|
||||
}
|
||||
final authAttempt = _resetState();
|
||||
try {
|
||||
final started = await widget.startAuth(widget.config.op);
|
||||
if (!started) {
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
await _handleAuthFailure(
|
||||
authAttempt,
|
||||
e,
|
||||
'start account authentication',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!mounted ||
|
||||
authAttempt != _authAttempt ||
|
||||
widget.curOP.value != widget.config.op) {
|
||||
return;
|
||||
}
|
||||
_beginQueryState(authAttempt);
|
||||
_resetState();
|
||||
widget.curOP.value = widget.config.op;
|
||||
await bind.mainAccountAuth(op: widget.config.op, rememberMe: true);
|
||||
_beginQueryState();
|
||||
},
|
||||
),
|
||||
Obx(() {
|
||||
@@ -504,8 +247,6 @@ class _WidgetOPState extends State<WidgetOP> {
|
||||
widget.curOP.value != widget.config.op) {
|
||||
_failedMsg = '';
|
||||
}
|
||||
final authAttempt = _authAttempt;
|
||||
final authUrl = _url;
|
||||
return Offstage(
|
||||
offstage:
|
||||
_failedMsg.isEmpty && widget.curOP.value != widget.config.op,
|
||||
@@ -515,27 +256,19 @@ class _WidgetOPState extends State<WidgetOP> {
|
||||
if (_stateMsg.isNotEmpty && _failedMsg.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: OidcAuthStatus(
|
||||
message: translate(_stateMsg),
|
||||
browserFallbackPrompt: translate(
|
||||
"Browser didn't open? Use the url below to sign in.",
|
||||
),
|
||||
authUrl: authUrl,
|
||||
copyLabel: translate('Copy to clipboard'),
|
||||
onCopy: authUrl.isEmpty
|
||||
? null
|
||||
: () => _runCurrentAuthUrlAction(
|
||||
authAttempt,
|
||||
authUrl,
|
||||
_copyAuthUrl,
|
||||
),
|
||||
child: SelectableText(
|
||||
translate(_stateMsg),
|
||||
style: DefaultTextStyle.of(context)
|
||||
.style
|
||||
.copyWith(fontSize: 12),
|
||||
),
|
||||
),
|
||||
if (_failedMsg.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Builder(builder: (context) {
|
||||
final errorColor = Theme.of(context).colorScheme.error;
|
||||
final errorColor =
|
||||
Theme.of(context).colorScheme.error;
|
||||
final bgColor = Theme.of(context)
|
||||
.colorScheme
|
||||
.errorContainer
|
||||
@@ -556,11 +289,12 @@ class _WidgetOPState extends State<WidgetOP> {
|
||||
Flexible(
|
||||
child: SelectableText(
|
||||
translate(_failedMsg),
|
||||
style:
|
||||
DefaultTextStyle.of(context).style.copyWith(
|
||||
fontSize: 13,
|
||||
color: errorColor,
|
||||
),
|
||||
style: DefaultTextStyle.of(context)
|
||||
.style
|
||||
.copyWith(
|
||||
fontSize: 13,
|
||||
color: errorColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -572,6 +306,34 @@ class _WidgetOPState extends State<WidgetOP> {
|
||||
),
|
||||
);
|
||||
}),
|
||||
Obx(
|
||||
() => Offstage(
|
||||
offstage: widget.curOP.value != widget.config.op,
|
||||
child: const SizedBox(
|
||||
height: 5.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
Obx(
|
||||
() => Offstage(
|
||||
offstage: widget.curOP.value != widget.config.op,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: 20),
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
widget.curOP.value = '';
|
||||
_updateTimer?.cancel();
|
||||
_resetState();
|
||||
bind.mainAccountAuthCancel();
|
||||
},
|
||||
child: Text(
|
||||
translate('Cancel'),
|
||||
style: TextStyle(fontSize: 15),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -581,18 +343,12 @@ class LoginWidgetOP extends StatelessWidget {
|
||||
final List<ConfigOP> ops;
|
||||
final RxString curOP;
|
||||
final Function(Map<String, dynamic>) cbLogin;
|
||||
final Future<bool> Function(String) startAuth;
|
||||
final Future<bool> Function(String) cancelAuth;
|
||||
final bool Function() canStartAuth;
|
||||
|
||||
LoginWidgetOP({
|
||||
Key? key,
|
||||
required this.ops,
|
||||
required this.curOP,
|
||||
required this.cbLogin,
|
||||
required this.startAuth,
|
||||
required this.cancelAuth,
|
||||
required this.canStartAuth,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
@@ -603,9 +359,6 @@ class LoginWidgetOP extends StatelessWidget {
|
||||
config: op,
|
||||
curOP: curOP,
|
||||
cbLogin: cbLogin,
|
||||
startAuth: startAuth,
|
||||
cancelAuth: cancelAuth,
|
||||
canStartAuth: canStartAuth,
|
||||
),
|
||||
const Divider(
|
||||
indent: 5,
|
||||
@@ -683,11 +436,12 @@ class LoginWidgetUserPass extends StatelessWidget {
|
||||
translate('Login'),
|
||||
style: TextStyle(fontSize: 16),
|
||||
),
|
||||
onPressed: curOP.value.isEmpty && !isInProgress
|
||||
? () {
|
||||
onLogin();
|
||||
}
|
||||
: null,
|
||||
onPressed:
|
||||
curOP.value.isEmpty || curOP.value == 'rustdesk'
|
||||
? () {
|
||||
onLogin();
|
||||
}
|
||||
: null,
|
||||
)),
|
||||
),
|
||||
])),
|
||||
@@ -698,28 +452,8 @@ class LoginWidgetUserPass extends StatelessWidget {
|
||||
|
||||
const kAuthReqTypeOidc = 'oidc/';
|
||||
|
||||
Future<bool?>? _activeLoginDialog;
|
||||
|
||||
// call this directly
|
||||
Future<bool?> loginDialog() {
|
||||
final activeDialog = _activeLoginDialog;
|
||||
if (activeDialog != null) {
|
||||
return activeDialog;
|
||||
}
|
||||
final dialog = _openLoginDialogOnce();
|
||||
_activeLoginDialog = dialog;
|
||||
return dialog;
|
||||
}
|
||||
|
||||
Future<bool?> _openLoginDialogOnce() async {
|
||||
try {
|
||||
return await _openLoginDialog();
|
||||
} finally {
|
||||
_activeLoginDialog = null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool?> _openLoginDialog() async {
|
||||
Future<bool?> loginDialog() async {
|
||||
var username =
|
||||
TextEditingController(text: UserModel.getLocalUserInfo()?['name'] ?? '');
|
||||
var password = TextEditingController();
|
||||
@@ -729,28 +463,14 @@ Future<bool?> _openLoginDialog() async {
|
||||
String? usernameMsg;
|
||||
String? passwordMsg;
|
||||
var isInProgress = false;
|
||||
final oidcAuth = _OidcAuthController();
|
||||
final curOP = oidcAuth.curOP;
|
||||
final RxString curOP = ''.obs;
|
||||
// Track hover state for the close icon
|
||||
bool isCloseHovered = false;
|
||||
|
||||
final loginOptions = [].obs;
|
||||
final loginOptionsError = Rxn<Object>();
|
||||
final loginOptionsInProgress = false.obs;
|
||||
fetchLoginOptions() async {
|
||||
loginOptionsInProgress.value = true;
|
||||
try {
|
||||
loginOptions.value = await UserModel.queryOidcLoginOptions();
|
||||
loginOptionsError.value = null;
|
||||
} catch (e) {
|
||||
debugPrint("queryOidcLoginOptions failed: $e");
|
||||
loginOptionsError.value = e;
|
||||
} finally {
|
||||
loginOptionsInProgress.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future.delayed(Duration.zero, fetchLoginOptions);
|
||||
Future.delayed(Duration.zero, () async {
|
||||
loginOptions.value = await UserModel.queryOidcLoginOptions();
|
||||
});
|
||||
|
||||
final res = await gFFI.dialogManager.show<bool>((setState, close, context) {
|
||||
username.addListener(() {
|
||||
@@ -824,9 +544,6 @@ Future<bool?> _openLoginDialog() async {
|
||||
}
|
||||
|
||||
onLogin() async {
|
||||
if (curOP.value.isNotEmpty || isInProgress) {
|
||||
return;
|
||||
}
|
||||
// validate
|
||||
if (username.text.isEmpty) {
|
||||
setState(() => usernameMsg = translate('Username missed'));
|
||||
@@ -857,36 +574,6 @@ Future<bool?> _openLoginDialog() async {
|
||||
}
|
||||
|
||||
thirdAuthWidget() => Obx(() {
|
||||
final error = loginOptionsError.value;
|
||||
final inProgress = loginOptionsInProgress.value;
|
||||
if (error != null) {
|
||||
return Column(
|
||||
children: [
|
||||
const SizedBox(height: 8.0),
|
||||
// NOT use Offstage to wrap LinearProgressIndicator
|
||||
if (inProgress) const LinearProgressIndicator(),
|
||||
if (!inProgress && error is! RequestException)
|
||||
Text(
|
||||
translate('network_error_tip'),
|
||||
style: const TextStyle(fontSize: 12),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
onPressed: inProgress ? null : fetchLoginOptions,
|
||||
child: Text(translate('Retry')),
|
||||
),
|
||||
if (!inProgress)
|
||||
SelectableText(
|
||||
error.toString(),
|
||||
style: const TextStyle(fontSize: 11, color: Colors.red),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return Offstage(
|
||||
offstage: loginOptions.isEmpty,
|
||||
child: Column(
|
||||
@@ -907,9 +594,6 @@ Future<bool?> _openLoginDialog() async {
|
||||
.map((e) => ConfigOP(op: e['name'], icon: e['icon']))
|
||||
.toList(),
|
||||
curOP: curOP,
|
||||
startAuth: oidcAuth.start,
|
||||
cancelAuth: oidcAuth.cancelCurrent,
|
||||
canStartAuth: oidcAuth.canStart,
|
||||
cbLogin: (Map<String, dynamic> authBody) async {
|
||||
LoginResponse? resp;
|
||||
try {
|
||||
@@ -991,7 +675,7 @@ Future<bool?> _openLoginDialog() async {
|
||||
onCancel: onDialogCancel,
|
||||
onSubmit: onLogin,
|
||||
);
|
||||
}).whenComplete(oidcAuth.close);
|
||||
});
|
||||
|
||||
if (res != null) {
|
||||
await UserModel.updateOtherModels();
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
const _statusFontSize = 12.0;
|
||||
const _statusSpacing = 4.0;
|
||||
const _messageActionSpacing = 8.0;
|
||||
const _desktopActionSize = 28.0;
|
||||
const _touchPlatforms = <TargetPlatform>{
|
||||
TargetPlatform.android,
|
||||
TargetPlatform.iOS,
|
||||
TargetPlatform.fuchsia,
|
||||
};
|
||||
|
||||
class OidcAuthStatus extends StatelessWidget {
|
||||
final String message;
|
||||
final String browserFallbackPrompt;
|
||||
final String authUrl;
|
||||
final String copyLabel;
|
||||
final VoidCallback? onCopy;
|
||||
|
||||
const OidcAuthStatus({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.browserFallbackPrompt,
|
||||
required this.authUrl,
|
||||
required this.copyLabel,
|
||||
this.onCopy,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final messageStyle =
|
||||
DefaultTextStyle.of(context).style.copyWith(fontSize: _statusFontSize);
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SelectableText(message, style: messageStyle),
|
||||
if (authUrl.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: _messageActionSpacing),
|
||||
child: _OidcAuthFallback(
|
||||
browserFallbackPrompt: browserFallbackPrompt,
|
||||
authUrl: authUrl,
|
||||
copyLabel: copyLabel,
|
||||
onCopy: onCopy,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OidcAuthFallback extends StatefulWidget {
|
||||
final String browserFallbackPrompt;
|
||||
final String authUrl;
|
||||
final String copyLabel;
|
||||
final VoidCallback? onCopy;
|
||||
|
||||
const _OidcAuthFallback({
|
||||
required this.browserFallbackPrompt,
|
||||
required this.authUrl,
|
||||
required this.copyLabel,
|
||||
required this.onCopy,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_OidcAuthFallback> createState() => _OidcAuthFallbackState();
|
||||
}
|
||||
|
||||
class _OidcAuthFallbackState extends State<_OidcAuthFallback> {
|
||||
bool _expanded = false;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _OidcAuthFallback oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.authUrl != widget.authUrl) {
|
||||
_expanded = false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final helperStyle = DefaultTextStyle.of(context).style.copyWith(
|
||||
fontSize: _statusFontSize,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
);
|
||||
final linkColor = theme.brightness == Brightness.dark
|
||||
? Colors.blue.shade300
|
||||
: Colors.blue.shade800;
|
||||
final isTouchPlatform = _touchPlatforms.contains(theme.platform);
|
||||
final actionSize =
|
||||
isTouchPlatform ? kMinInteractiveDimension : _desktopActionSize;
|
||||
final urlStyle =
|
||||
DefaultTextStyle.of(context).style.copyWith(fontSize: _statusFontSize);
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
widget.browserFallbackPrompt,
|
||||
style: helperStyle,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: _statusSpacing),
|
||||
child: _buildUrl(urlStyle, linkColor, actionSize),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _copyAndExpand() {
|
||||
setState(() => _expanded = true);
|
||||
widget.onCopy?.call();
|
||||
}
|
||||
|
||||
Widget _buildUrl(TextStyle urlStyle, Color linkColor, double actionSize) {
|
||||
final collapsedUrl = SizedBox(
|
||||
width: double.infinity,
|
||||
child: TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: linkColor,
|
||||
minimumSize: Size(0, actionSize),
|
||||
padding: EdgeInsets.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity: VisualDensity.standard,
|
||||
),
|
||||
onPressed: _copyAndExpand,
|
||||
child: Text(
|
||||
widget.authUrl,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
softWrap: false,
|
||||
style: urlStyle.copyWith(
|
||||
color: linkColor,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
final collapsedChild = widget.onCopy == null
|
||||
? collapsedUrl
|
||||
: Tooltip(message: widget.copyLabel, child: collapsedUrl);
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
constraints: BoxConstraints(minHeight: actionSize),
|
||||
alignment: Alignment.centerLeft,
|
||||
padding: const EdgeInsets.symmetric(horizontal: _messageActionSpacing),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Theme.of(context).dividerColor),
|
||||
borderRadius: BorderRadius.circular(_statusSpacing),
|
||||
),
|
||||
child: _expanded
|
||||
? SelectableText(widget.authUrl, style: urlStyle)
|
||||
: collapsedChild,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -115,7 +115,6 @@ 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) {
|
||||
@@ -472,8 +471,6 @@ class _RawTouchGestureDetectorRegionState
|
||||
return;
|
||||
}
|
||||
|
||||
if (canvasLocked) return;
|
||||
|
||||
if ((isDesktop || isWebDesktop)) {
|
||||
final scale = ((d.scale - _scale) * 1000).toInt();
|
||||
_scale = d.scale;
|
||||
|
||||
@@ -253,18 +253,8 @@ 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,
|
||||
this.onTextChanged,
|
||||
this.onTextSubmitted,
|
||||
});
|
||||
TrackpadSpeedWidget({Key? key, required this.value, this.onDebouncer});
|
||||
|
||||
@override
|
||||
TrackpadSpeedWidgetState createState() => TrackpadSpeedWidgetState();
|
||||
@@ -286,34 +276,6 @@ 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
|
||||
@@ -353,8 +315,12 @@ class TrackpadSpeedWidgetState extends State<TrackpadSpeedWidget> {
|
||||
controller: _controller,
|
||||
keyboardType: TextInputType.number,
|
||||
textAlign: TextAlign.center,
|
||||
onChanged: updateTextValue,
|
||||
onSubmitted: submitTextValue,
|
||||
onSubmitted: (text) {
|
||||
int? v = int.tryParse(text);
|
||||
if (v != null) {
|
||||
updateValue(v);
|
||||
}
|
||||
},
|
||||
style: const TextStyle(fontSize: 13),
|
||||
decoration: InputDecoration(
|
||||
contentPadding:
|
||||
|
||||
@@ -349,12 +349,12 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
||||
showRequestElevationDialog(sessionId, ffi.dialogManager)),
|
||||
);
|
||||
}
|
||||
// osPassword
|
||||
// osAccount / osPassword
|
||||
if (isDefaultConn && perms['keyboard'] != false) {
|
||||
v.add(
|
||||
TTextMenu(
|
||||
child: Row(children: [
|
||||
Text(translate('OS Password')),
|
||||
Text(translate(pi.isHeadless ? 'OS Account' : 'OS Password')),
|
||||
]),
|
||||
trailingIcon: Transform.scale(
|
||||
scale: (isDesktop || isWebDesktop) ? 0.8 : 1,
|
||||
@@ -363,12 +363,18 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
||||
if (isMobile && Navigator.canPop(context)) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
handleOsPasswordEditIcon(sessionId, ffi.dialogManager);
|
||||
if (pi.isHeadless) {
|
||||
showSetOSAccount(sessionId, ffi.dialogManager);
|
||||
} else {
|
||||
handleOsPasswordEditIcon(sessionId, ffi.dialogManager);
|
||||
}
|
||||
},
|
||||
icon: Icon(Icons.edit, color: isMobile ? MyTheme.accent : null),
|
||||
),
|
||||
),
|
||||
onPressed: () => handleOsPasswordAction(sessionId, ffi.dialogManager),
|
||||
onPressed: () => pi.isHeadless
|
||||
? showSetOSAccount(sessionId, ffi.dialogManager)
|
||||
: handleOsPasswordAction(sessionId, ffi.dialogManager),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -577,7 +583,6 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
||||
}
|
||||
// record
|
||||
if (!(isDesktop || isWeb) &&
|
||||
bind.mainGetLocalOption(key: kOptionHideRecordingButton) != 'Y' &&
|
||||
(ffi.recordingModel.start || (perms["recording"] != false))) {
|
||||
v.add(TTextMenu(
|
||||
child: Row(
|
||||
|
||||
@@ -18,6 +18,7 @@ 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 =
|
||||
@@ -54,6 +55,7 @@ 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";
|
||||
@@ -93,7 +95,6 @@ const String kOptionForceAlwaysRelay = "force-always-relay";
|
||||
const String kOptionViewOnly = "view_only";
|
||||
const String kOptionEnableLanDiscovery = "enable-lan-discovery";
|
||||
const String kOptionWhitelist = "whitelist";
|
||||
const String kOptionIdWhitelist = "id-whitelist";
|
||||
const String kOptionEnableAbr = "enable-abr";
|
||||
const String kOptionEnableRecordSession = "enable-record-session";
|
||||
const String kOptionDirectServer = "direct-server";
|
||||
@@ -103,7 +104,6 @@ const String kOptionAutoDisconnectTimeout = "auto-disconnect-timeout";
|
||||
const String kOptionEnableHwcodec = "enable-hwcodec";
|
||||
const String kOptionAllowAutoRecordIncoming = "allow-auto-record-incoming";
|
||||
const String kOptionAllowAutoRecordOutgoing = "allow-auto-record-outgoing";
|
||||
const String kOptionHideRecordingButton = "hide-recording-button";
|
||||
const String kOptionVideoSaveDirectory = "video-save-directory";
|
||||
const String kOptionAccessMode = "access-mode";
|
||||
const String kOptionEnableKeyboard = "enable-keyboard";
|
||||
@@ -162,14 +162,13 @@ 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";
|
||||
@@ -178,7 +177,6 @@ const String kOptionAllowAskForNoteAtEndOfConnection = "allow-ask-for-note";
|
||||
const String kOptionAllowMonitorSwitchMainToolbar = "allow-monitor-switch-main-toolbar";
|
||||
const String kOptionAllowMonitorSwitchMinToolbar = "allow-monitor-switch-min-toolbar";
|
||||
const String kOptionEnableShowTerminalExtraKeys = "enable-show-terminal-extra-keys";
|
||||
const String kOptionShowTerminalCtrlKeys = "show-terminal-extra-ctrl-keys";
|
||||
|
||||
// network options
|
||||
const String kOptionAllowWebSocket = "allow-websocket";
|
||||
@@ -192,7 +190,6 @@ 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,11 +322,10 @@ 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(kDragToResizeAreaPaddingSize)
|
||||
: EdgeInsets.all(5.0)
|
||||
: EdgeInsets.zero;
|
||||
// https://en.wikipedia.org/wiki/Non-breaking_space
|
||||
const int $nbsp = 0x00A0;
|
||||
@@ -441,6 +437,7 @@ 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,12 +449,6 @@ 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
|
||||
|
||||
@@ -16,6 +16,7 @@ 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';
|
||||
@@ -110,6 +111,7 @@ class _DesktopHomePageState extends State<DesktopHomePage>
|
||||
}
|
||||
},
|
||||
),
|
||||
buildPluginEntry(),
|
||||
];
|
||||
if (isIncomingOnly) {
|
||||
children.addAll([
|
||||
@@ -780,6 +782,13 @@ 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());
|
||||
@@ -881,6 +890,21 @@ 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 {
|
||||
|
||||
@@ -17,6 +17,8 @@ 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';
|
||||
@@ -53,6 +55,7 @@ enum SettingsTabKey {
|
||||
safety,
|
||||
network,
|
||||
display,
|
||||
plugin,
|
||||
account,
|
||||
printer,
|
||||
about,
|
||||
@@ -61,8 +64,7 @@ enum SettingsTabKey {
|
||||
class DesktopSettingPage extends StatefulWidget {
|
||||
final SettingsTabKey initialTabkey;
|
||||
static final List<SettingsTabKey> tabKeys = [
|
||||
if (bind.mainGetBuildinOption(key: kOptionHideGeneralSetting) != 'Y')
|
||||
SettingsTabKey.general,
|
||||
SettingsTabKey.general,
|
||||
if (!isWeb &&
|
||||
!bind.isOutgoingOnly() &&
|
||||
!bind.isDisableSettings() &&
|
||||
@@ -72,9 +74,10 @@ 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,
|
||||
@@ -92,8 +95,7 @@ class DesktopSettingPage extends StatefulWidget {
|
||||
if (index == -1) {
|
||||
return;
|
||||
}
|
||||
if (Get.isRegistered<PageController>(tag: _kSettingPageControllerTag) &&
|
||||
Get.isRegistered<Rx<SettingsTabKey>>(tag: _kSettingPageTabKeyTag)) {
|
||||
if (Get.isRegistered<PageController>(tag: _kSettingPageControllerTag)) {
|
||||
DesktopTabPage.onAddSetting(initialPage: page);
|
||||
PageController controller =
|
||||
Get.find<PageController>(tag: _kSettingPageControllerTag);
|
||||
@@ -161,23 +163,17 @@ class _DesktopSettingPageState extends State<DesktopSettingPage>
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
final blocked = await canBeBlocked();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
_canBeBlocked.value = blocked;
|
||||
_canBeBlocked.value = await canBeBlocked();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_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();
|
||||
Get.delete<PageController>(tag: _kSettingPageControllerTag);
|
||||
Get.delete<RxInt>(tag: _kSettingPageTabKeyTag);
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_videoConnTimer?.cancel();
|
||||
}
|
||||
|
||||
List<_TabInfo> _settingTabs() {
|
||||
@@ -200,6 +196,10 @@ 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,6 +233,9 @@ 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;
|
||||
@@ -482,8 +485,7 @@ class _GeneralState extends State<_General> {
|
||||
Widget other() {
|
||||
final incomingOnly = bind.isIncomingOnly();
|
||||
final outgoingOnly = bind.isOutgoingOnly();
|
||||
final showAutoUpdate = (isWindows && bind.mainIsInstalled()) ||
|
||||
(isMacOS && bind.mainIsInstalled() && bind.mainIsInstalledDaemon(prompt: false) && !bind.isCustomClient());
|
||||
final showAutoUpdate = isWindows && bind.mainIsInstalled();
|
||||
final children = <Widget>[
|
||||
if (!isWeb && !incomingOnly)
|
||||
_OptionCheckBox(context, 'Confirm before closing multiple tabs',
|
||||
@@ -575,15 +577,6 @@ 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
];
|
||||
|
||||
@@ -597,6 +590,10 @@ 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,
|
||||
@@ -1300,7 +1297,6 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
|
||||
reverse: true, enabled: enabled),
|
||||
...directIp(context),
|
||||
whitelist(),
|
||||
idWhitelist(),
|
||||
...autoDisconnect(context),
|
||||
_OptionCheckBox(context, 'keep-awake-during-incoming-sessions-label',
|
||||
kOptionKeepAwakeDuringIncomingSessions,
|
||||
@@ -1458,52 +1454,6 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
|
||||
return tmpWrapper();
|
||||
}
|
||||
|
||||
Widget idWhitelist() {
|
||||
bool enabled = !locked;
|
||||
RxBool hasIdWhitelist = idWhitelistNotEmpty().obs;
|
||||
update() async {
|
||||
hasIdWhitelist.value = idWhitelistNotEmpty();
|
||||
}
|
||||
|
||||
onChanged(bool? checked) async {
|
||||
changeIdWhiteList(callback: update);
|
||||
}
|
||||
|
||||
final isOptFixed = isOptionFixed(kOptionIdWhitelist);
|
||||
return GestureDetector(
|
||||
child: Tooltip(
|
||||
message: translate('id_whitelist_tip'),
|
||||
child: Obx(() => Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: hasIdWhitelist.value,
|
||||
onChanged: enabled && !isOptFixed ? onChanged : null)
|
||||
.marginOnly(right: 5),
|
||||
Offstage(
|
||||
offstage: !hasIdWhitelist.value,
|
||||
child: MouseRegion(
|
||||
child: const Icon(Icons.warning_amber_rounded,
|
||||
color: Color.fromARGB(255, 255, 204, 0))
|
||||
.marginOnly(right: 5),
|
||||
cursor: SystemMouseCursors.click,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
translate('Use ID whitelisting'),
|
||||
style: TextStyle(color: disabledTextColor(context, enabled)),
|
||||
))
|
||||
],
|
||||
)),
|
||||
),
|
||||
onTap: enabled
|
||||
? () {
|
||||
onChanged(!hasIdWhitelist.value);
|
||||
}
|
||||
: null,
|
||||
).marginOnly(left: _kCheckBoxLeftMargin);
|
||||
}
|
||||
|
||||
Widget hide_cm(bool enabled) {
|
||||
return ChangeNotifierProvider.value(
|
||||
value: gFFI.serverModel,
|
||||
@@ -2257,6 +2207,51 @@ 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});
|
||||
|
||||
@@ -2419,20 +2414,17 @@ class _AboutState extends State<_About> {
|
||||
final version = await bind.mainGetVersion();
|
||||
final buildDate = await bind.mainGetBuildDate();
|
||||
final fingerprint = await bind.mainGetFingerprint();
|
||||
final myId = await bind.mainGetMyId();
|
||||
return {
|
||||
'license': license,
|
||||
'version': version,
|
||||
'buildDate': buildDate,
|
||||
'fingerprint': fingerprint,
|
||||
'myId': myId
|
||||
'fingerprint': fingerprint
|
||||
};
|
||||
}(), hasData: (data) {
|
||||
final license = data['license'].toString();
|
||||
final version = data['version'].toString();
|
||||
final buildDate = data['buildDate'].toString();
|
||||
final fingerprint = data['fingerprint'].toString();
|
||||
final myId = data['myId'].toString();
|
||||
const linkStyle = TextStyle(decoration: TextDecoration.underline);
|
||||
final scrollController = ScrollController();
|
||||
return SingleChildScrollView(
|
||||
@@ -2454,9 +2446,6 @@ class _AboutState extends State<_About> {
|
||||
SelectionArea(
|
||||
child: Text('${translate('Fingerprint')}: $fingerprint')
|
||||
.marginSymmetric(vertical: 4.0)),
|
||||
SelectionArea(
|
||||
child: Text('${translate('ID')}: $myId')
|
||||
.marginSymmetric(vertical: 4.0)),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
launchUrlString('https://rustdesk.com/privacy.html');
|
||||
|
||||
@@ -278,39 +278,7 @@ class _FileManagerPageState extends State<FileManagerPage>
|
||||
item.state != JobState.inProgress,
|
||||
child: LinearPercentIndicator(
|
||||
animateFromLastPercent: true,
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
center: Text(item.percentText),
|
||||
barRadius: Radius.circular(15),
|
||||
percent: item.percent,
|
||||
progressColor: MyTheme.accent,
|
||||
@@ -1126,7 +1094,6 @@ 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()) : "";
|
||||
@@ -1309,7 +1276,7 @@ class _FileManagerViewState extends State<FileManagerView> {
|
||||
],
|
||||
))),
|
||||
);
|
||||
});
|
||||
}).toList(growable: false);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
@@ -1325,7 +1292,7 @@ class _FileManagerViewState extends State<FileManagerView> {
|
||||
controller: scrollController,
|
||||
itemExtent: kDesktopFileTransferRowHeight,
|
||||
itemBuilder: (context, index) {
|
||||
return rows.elementAt(index);
|
||||
return rows[index];
|
||||
},
|
||||
itemCount: rows.length,
|
||||
),
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
class MacOSFullScreenFocusRecovery {
|
||||
int _generation = 0;
|
||||
int? _pendingGeneration;
|
||||
|
||||
int? get pendingGeneration => _pendingGeneration;
|
||||
|
||||
int queue() {
|
||||
_generation += 1;
|
||||
_pendingGeneration = _generation;
|
||||
return _generation;
|
||||
}
|
||||
|
||||
void cancel() {
|
||||
_pendingGeneration = null;
|
||||
}
|
||||
|
||||
bool isCurrent(int generation) => _pendingGeneration == generation;
|
||||
|
||||
bool consume(int generation) {
|
||||
if (!isCurrent(generation)) return false;
|
||||
_pendingGeneration = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,6 @@ import '../../utils/image.dart';
|
||||
import '../widgets/remote_toolbar.dart';
|
||||
import '../widgets/kb_layout_type_chooser.dart';
|
||||
import '../widgets/tabbar_widget.dart';
|
||||
import 'macos_full_screen_focus_recovery.dart';
|
||||
|
||||
import 'package:flutter_hbb/native/custom_cursor.dart'
|
||||
if (dart.library.html) 'package:flutter_hbb/web/custom_cursor.dart';
|
||||
@@ -65,13 +64,6 @@ class RemotePage extends StatefulWidget {
|
||||
|
||||
FFI get ffi => (_lastState.value! as _RemotePageState)._ffi;
|
||||
|
||||
void releaseMacOSInputForTabTransfer() {
|
||||
if (!isMacOS) return;
|
||||
// Release before removing the source tab. Its delayed disposal must not
|
||||
// disable a native keyboard hook already acquired by the destination page.
|
||||
(_lastState.value! as _RemotePageState)._releaseMacOSRemoteInput();
|
||||
}
|
||||
|
||||
@override
|
||||
State<RemotePage> createState() {
|
||||
final state = _RemotePageState(id);
|
||||
@@ -84,28 +76,10 @@ class _RemotePageState extends State<RemotePage>
|
||||
with
|
||||
AutomaticKeepAliveClientMixin,
|
||||
MultiWindowListener,
|
||||
WidgetsBindingObserver,
|
||||
TickerProviderStateMixin {
|
||||
Timer? _timer;
|
||||
String keyboardMode = "legacy";
|
||||
bool _isWindowBlur = false;
|
||||
// Known macOS remote-input trade-offs (kept simple intentionally):
|
||||
// 1. Dialogs rely on FocusNode loss plus middleBlocked, not mirrored dialog
|
||||
// state. Reproduce: activate remote input, open a dialog, then type.
|
||||
// 2. Delayed fullscreen recovery can race a local-control focus change; no
|
||||
// owner state is added. Reproduce: focus the toolbar during a Space switch.
|
||||
// 3. Input-source switching releases native input without updating this
|
||||
// page's cache. Reproduce: switch sources, then type before and after
|
||||
// clicking the remote image; the click reasserts input.
|
||||
// These latches compensate for out-of-order macOS focus events. Treat them
|
||||
// as coupled when changing a transition or _syncMacOSKeyboardGrab().
|
||||
AppLifecycleState? _macOSLifecycleState;
|
||||
bool _macOSLocalFocusLost = false;
|
||||
bool _macOSInputActive = false;
|
||||
bool _macOSInputSuppressed = false;
|
||||
final _macOSFullScreenFocusRecovery = MacOSFullScreenFocusRecovery();
|
||||
bool _macOSExplicitFocusRequestPending = false;
|
||||
StreamSubscription<DesktopTabState>? _tabStateSubscription;
|
||||
final _cursorOverImage = false.obs;
|
||||
late RxBool _showRemoteCursor;
|
||||
late RxBool _zoomCursor;
|
||||
@@ -148,13 +122,6 @@ class _RemotePageState extends State<RemotePage>
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ffi = FFI(widget.sessionId);
|
||||
if (isMacOS) {
|
||||
// SchedulerBinding.instance.lifecycleState is null in the first connection in a new window.
|
||||
_macOSLifecycleState = SchedulerBinding.instance.lifecycleState;
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_tabStateSubscription =
|
||||
widget.tabController?.state.listen(_onMacOSTabStateChanged);
|
||||
}
|
||||
Get.put<FFI>(_ffi, tag: widget.id);
|
||||
_ffi.imageModel.addCallbackOnFirstImage((String peerId) {
|
||||
_ffi.canvasModel.activateLocalCursor();
|
||||
@@ -182,6 +149,7 @@ 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((_) {
|
||||
@@ -263,229 +231,19 @@ class _RemotePageState extends State<RemotePage>
|
||||
_pointerLockCenterDebounceTimer = null;
|
||||
}
|
||||
|
||||
bool get _isSelectedTab {
|
||||
final controller = widget.tabController;
|
||||
if (controller == null) return true;
|
||||
final tabState = controller.state.value;
|
||||
final selected = tabState.selected;
|
||||
return selected >= 0 &&
|
||||
selected < tabState.tabs.length &&
|
||||
tabState.tabs[selected].key == widget.id;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
void _onMacOSTabStateChanged(DesktopTabState _) {
|
||||
if (!_isSelectedTab) {
|
||||
_macOSFullScreenFocusRecovery.cancel();
|
||||
_syncMacOSKeyboardGrab();
|
||||
return;
|
||||
}
|
||||
// Tab listeners run synchronously. Defer the selected page so the previous
|
||||
// page releases first; a late leave from it can disable the new session.
|
||||
scheduleMicrotask(() {
|
||||
if (mounted) {
|
||||
_syncMacOSKeyboardGrab(reassert: true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _releaseMacOSRemoteInput() {
|
||||
_macOSFullScreenFocusRecovery.cancel();
|
||||
_macOSExplicitFocusRequestPending = false;
|
||||
_macOSInputSuppressed = true;
|
||||
_macOSLocalFocusLost = true;
|
||||
_ffi.inputModel.enterOrLeave(false);
|
||||
_macOSInputActive = false;
|
||||
_rawKeyFocusNode.unfocus();
|
||||
}
|
||||
|
||||
void _onMacOSFocusChange() {
|
||||
// requestFocus() notifies later; only a recorded explicit request may clear
|
||||
// the local-focus-loss latch.
|
||||
if (_rawKeyFocusNode.hasPrimaryFocus) {
|
||||
final explicitRequest = _macOSExplicitFocusRequestPending;
|
||||
_macOSExplicitFocusRequestPending = false;
|
||||
if (explicitRequest && _isMacOSKeyboardContextActive) {
|
||||
_macOSLocalFocusLost = false;
|
||||
}
|
||||
_syncMacOSKeyboardGrab(allowInactiveLifecycle: explicitRequest);
|
||||
} else {
|
||||
if (_macOSInputActive) {
|
||||
_ffi.inputModel.enterOrLeave(false);
|
||||
_macOSInputActive = false;
|
||||
}
|
||||
if (_isMacOSKeyboardContextActive) {
|
||||
_macOSLocalFocusLost = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Sync the keyboard grab state with the current context.
|
||||
// 2. Call enterOrLeave() to update the input state in the FFI layer.
|
||||
// 3. Request or unfocus the raw key focus node based on the current context.
|
||||
// Flutter focus and native input are separate; native input activates only
|
||||
// after the FocusNode has primary focus.
|
||||
void _syncMacOSKeyboardGrab({
|
||||
bool reassert = false,
|
||||
bool allowInactiveLifecycle = false,
|
||||
}) {
|
||||
if (!isMacOS) return;
|
||||
// A secondary engine may stay hidden while its window is visible, so
|
||||
// explicit pointer/fullscreen recovery must bypass the global lifecycle.
|
||||
final lifecycleAllowsInput = allowInactiveLifecycle ||
|
||||
_macOSLifecycleState == null ||
|
||||
_macOSLifecycleState == AppLifecycleState.resumed;
|
||||
// Input stays pointer-gated except for focused fullscreen recovery, which
|
||||
// compensates when macOS omits PointerEnter during a Space switch.
|
||||
final shouldFocus = lifecycleAllowsInput &&
|
||||
_isMacOSKeyboardContextActive &&
|
||||
!_macOSInputSuppressed &&
|
||||
_blockableOverlayState.middleBlocked.isFalse &&
|
||||
_cursorOverImage.value &&
|
||||
!_macOSLocalFocusLost;
|
||||
final hasFocus = _rawKeyFocusNode.hasPrimaryFocus;
|
||||
final shouldActivateInput = shouldFocus && hasFocus;
|
||||
|
||||
if (shouldActivateInput != _macOSInputActive ||
|
||||
(shouldActivateInput && reassert)) {
|
||||
_ffi.inputModel.enterOrLeave(shouldActivateInput);
|
||||
}
|
||||
_macOSInputActive = shouldActivateInput;
|
||||
|
||||
if (!shouldFocus) {
|
||||
_macOSExplicitFocusRequestPending = false;
|
||||
if (hasFocus) _rawKeyFocusNode.unfocus();
|
||||
} else if (!hasFocus) {
|
||||
_macOSExplicitFocusRequestPending = allowInactiveLifecycle;
|
||||
_rawKeyFocusNode.requestFocus();
|
||||
} else {
|
||||
_macOSExplicitFocusRequestPending = false;
|
||||
}
|
||||
}
|
||||
|
||||
void _restoreMacOSKeyboardAfterFullScreen({
|
||||
required int generation,
|
||||
bool allowHiddenLifecycle = false,
|
||||
}) {
|
||||
// Fullscreen callbacks preserve recovery while hidden. Native window focus
|
||||
// may bypass a stale hidden lifecycle for the newly visible Space.
|
||||
if (!_macOSFullScreenFocusRecovery.isCurrent(generation) ||
|
||||
(!allowHiddenLifecycle &&
|
||||
_macOSLifecycleState == AppLifecycleState.hidden)) {
|
||||
return;
|
||||
}
|
||||
final contextActive =
|
||||
stateGlobal.isFocused.value && !_isWindowBlur && _isSelectedTab;
|
||||
// macOS can focus a fullscreen Space without sending PointerEnter. Native
|
||||
// window focus is authoritative here; a later blur cancels this generation
|
||||
// before an off-screen window can restore input.
|
||||
final shouldInferPointerInside = !_cursorOverImage.value &&
|
||||
allowHiddenLifecycle &&
|
||||
stateGlobal.fullscreen.isTrue &&
|
||||
contextActive;
|
||||
final canRestore = contextActive &&
|
||||
_blockableOverlayState.middleBlocked.isFalse &&
|
||||
(_cursorOverImage.value || shouldInferPointerInside);
|
||||
if (!_macOSFullScreenFocusRecovery.consume(generation)) return;
|
||||
if (!canRestore) {
|
||||
// Consuming recovery here requires a later pointer/window/tab event.
|
||||
return;
|
||||
}
|
||||
if (shouldInferPointerInside) {
|
||||
_cursorOverImage.value = true;
|
||||
}
|
||||
_macOSLocalFocusLost = false;
|
||||
stateGlobal.getInputSource(force: true);
|
||||
_syncMacOSKeyboardGrab(reassert: true, allowInactiveLifecycle: true);
|
||||
}
|
||||
|
||||
void _scheduleMacOSKeyboardAfterFullScreen({
|
||||
required int generation,
|
||||
bool allowHiddenLifecycle = false,
|
||||
}) {
|
||||
// Fullscreen can deliver FocusNode loss after its callback; wait for frame
|
||||
// completion and then advance one event-loop turn before restoring.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
Timer.run(() {
|
||||
if (mounted) {
|
||||
_restoreMacOSKeyboardAfterFullScreen(
|
||||
generation: generation,
|
||||
allowHiddenLifecycle: allowHiddenLifecycle,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
WidgetsBinding.instance.ensureVisualUpdate();
|
||||
}
|
||||
|
||||
void _queueMacOSKeyboardAfterFullScreen({
|
||||
bool allowHiddenLifecycle = false,
|
||||
}) {
|
||||
final generation = _macOSFullScreenFocusRecovery.queue();
|
||||
if (_macOSLifecycleState == AppLifecycleState.paused ||
|
||||
_macOSLifecycleState == AppLifecycleState.detached) {
|
||||
_macOSFullScreenFocusRecovery.cancel();
|
||||
return;
|
||||
}
|
||||
_scheduleMacOSKeyboardAfterFullScreen(
|
||||
generation: generation,
|
||||
allowHiddenLifecycle: allowHiddenLifecycle,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
super.didChangeAppLifecycleState(state);
|
||||
if (!isMacOS || _macOSLifecycleState == state) return;
|
||||
_macOSLifecycleState = state;
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
_syncMacOSKeyboardGrab(reassert: true);
|
||||
} else if (_macOSInputActive) {
|
||||
_ffi.inputModel.enterOrLeave(false);
|
||||
_macOSInputActive = false;
|
||||
}
|
||||
|
||||
final generation = _macOSFullScreenFocusRecovery.pendingGeneration;
|
||||
if (generation == null) return;
|
||||
if (state == AppLifecycleState.inactive ||
|
||||
state == AppLifecycleState.resumed) {
|
||||
_scheduleMacOSKeyboardAfterFullScreen(generation: generation);
|
||||
} else if (state == AppLifecycleState.paused ||
|
||||
state == AppLifecycleState.detached) {
|
||||
_macOSFullScreenFocusRecovery.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowBlur() {
|
||||
super.onWindowBlur();
|
||||
// On windows, we use `focus` way to handle keyboard better.
|
||||
// Now on Linux, there's some rdev issues which will break the input.
|
||||
// We disable the `focus` way for Linux temporarily.
|
||||
if (isWindows || isMacOS) {
|
||||
_isWindowBlur = true;
|
||||
}
|
||||
if (isMacOS) {
|
||||
_macOSFullScreenFocusRecovery.cancel();
|
||||
// A blur or Space switch may not emit PointerExit, so cursor state alone
|
||||
// cannot prevent the old remote surface from reclaiming the keyboard.
|
||||
_macOSLocalFocusLost = true;
|
||||
}
|
||||
// We disable the `focus` way for non-Windows temporarily.
|
||||
if (isWindows) {
|
||||
_isWindowBlur = true;
|
||||
// unfocus the primary-focus when the whole window is lost focus,
|
||||
// and let OS to handle events instead.
|
||||
_rawKeyFocusNode.unfocus();
|
||||
}
|
||||
stateGlobal.isFocused.value = false;
|
||||
_syncMacOSKeyboardGrab();
|
||||
|
||||
// When window loses focus, temporarily release relative mouse mode constraints
|
||||
// to allow user to interact with other applications normally.
|
||||
@@ -499,50 +257,16 @@ class _RemotePageState extends State<RemotePage>
|
||||
void onWindowFocus() {
|
||||
super.onWindowFocus();
|
||||
// See [onWindowBlur].
|
||||
if (isWindows || isMacOS) {
|
||||
if (isWindows) {
|
||||
_isWindowBlur = false;
|
||||
}
|
||||
if (isMacOS) stateGlobal.getInputSource(force: true);
|
||||
stateGlobal.isFocused.value = true;
|
||||
|
||||
// Normal macOS windows wait for PointerEnter or PointerDown. A focused
|
||||
// fullscreen Space queues delayed recovery; if this window blurs again, the
|
||||
// pending recovery is cancelled before native input can reactivate.
|
||||
// Regression: switch directly between fullscreen remote Spaces without
|
||||
// moving or clicking; only the newly focused session may receive input.
|
||||
if (isMacOS &&
|
||||
stateGlobal.fullscreen.isTrue &&
|
||||
!_ffi.inputModel.relativeMouseMode.value) {
|
||||
// Native window focus is authoritative when a secondary engine retains a
|
||||
// stale hidden lifecycle state after its fullscreen Space becomes visible.
|
||||
_queueMacOSKeyboardAfterFullScreen(allowHiddenLifecycle: true);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// Native relative mode retains pointer capture and does not emit
|
||||
// PointerEnter after window focus returns. Restore both latches unless
|
||||
// a local overlay still owns input.
|
||||
if (_blockableOverlayState.middleBlocked.isFalse) {
|
||||
_cursorOverImage.value = true;
|
||||
_macOSLocalFocusLost = false;
|
||||
}
|
||||
} else if (!isWindows || _windowsCanFocusRemoteInput) {
|
||||
_rawKeyFocusNode.requestFocus();
|
||||
}
|
||||
_rawKeyFocusNode.requestFocus();
|
||||
_ffi.inputModel.onWindowFocus();
|
||||
}
|
||||
_syncMacOSKeyboardGrab(reassert: true, allowInactiveLifecycle: true);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -603,13 +327,6 @@ class _RemotePageState extends State<RemotePage>
|
||||
void onWindowMinimize() {
|
||||
super.onWindowMinimize();
|
||||
WakelockManager.disable(_uniqueKey);
|
||||
if (isMacOS) {
|
||||
_macOSFullScreenFocusRecovery.cancel();
|
||||
_isWindowBlur = true;
|
||||
_cursorOverImage.value = false;
|
||||
stateGlobal.isFocused.value = false;
|
||||
_syncMacOSKeyboardGrab();
|
||||
}
|
||||
// Release cursor constraints when minimized
|
||||
if (_ffi.inputModel.relativeMouseMode.value) {
|
||||
_ffi.inputModel.onWindowBlur();
|
||||
@@ -621,7 +338,6 @@ class _RemotePageState extends State<RemotePage>
|
||||
super.onWindowEnterFullScreen();
|
||||
if (isMacOS) {
|
||||
stateGlobal.setFullscreen(true);
|
||||
_queueMacOSKeyboardAfterFullScreen();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -630,7 +346,6 @@ class _RemotePageState extends State<RemotePage>
|
||||
super.onWindowLeaveFullScreen();
|
||||
if (isMacOS) {
|
||||
stateGlobal.setFullscreen(false);
|
||||
_queueMacOSKeyboardAfterFullScreen();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -639,14 +354,6 @@ class _RemotePageState extends State<RemotePage>
|
||||
final closeSession = closeSessionOnDispose.remove(widget.id) ?? true;
|
||||
|
||||
// https://github.com/flutter/flutter/issues/64935
|
||||
if (isMacOS) {
|
||||
// Tab moves release before transfer to avoid a late retained-session leave.
|
||||
if (closeSession) {
|
||||
_releaseMacOSRemoteInput();
|
||||
}
|
||||
_tabStateSubscription?.cancel();
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
}
|
||||
super.dispose();
|
||||
debugPrint("REMOTE PAGE dispose session $sessionId ${widget.id}");
|
||||
|
||||
@@ -661,9 +368,8 @@ class _RemotePageState extends State<RemotePage>
|
||||
_ffi.inputModel.onRelativeMouseModeDisabled = null;
|
||||
// Relative mouse mode cleanup is centralized in FFI.close(closeSession: ...).
|
||||
_ffi.textureModel.onRemotePageDispose(closeSession);
|
||||
if (closeSession && !isMacOS) {
|
||||
if (closeSession) {
|
||||
// ensure we leave this session, this is a double check
|
||||
// enterOrLeave() is already called previously in _releaseMacOSRemoteInput() for macOS.
|
||||
_ffi.inputModel.enterOrLeave(false);
|
||||
}
|
||||
DesktopMultiWindow.removeListener(this);
|
||||
@@ -738,8 +444,6 @@ class _RemotePageState extends State<RemotePage>
|
||||
} else {
|
||||
_ffi.inputModel.enterOrLeave(false);
|
||||
}
|
||||
} else if (isMacOS) {
|
||||
_onMacOSFocusChange();
|
||||
}
|
||||
},
|
||||
inputModel: _ffi.inputModel,
|
||||
@@ -845,20 +549,7 @@ class _RemotePageState extends State<RemotePage>
|
||||
}
|
||||
|
||||
// See [onWindowBlur].
|
||||
if (isMacOS) {
|
||||
_macOSLocalFocusLost = false;
|
||||
stateGlobal.getInputSource(force: true);
|
||||
_syncMacOSKeyboardGrab(reassert: true, allowInactiveLifecycle: true);
|
||||
} 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 (!isWindows) {
|
||||
if (!_rawKeyFocusNode.hasFocus) {
|
||||
_rawKeyFocusNode.requestFocus();
|
||||
}
|
||||
@@ -884,9 +575,7 @@ class _RemotePageState extends State<RemotePage>
|
||||
}
|
||||
|
||||
// See [onWindowBlur].
|
||||
if (isMacOS) {
|
||||
_syncMacOSKeyboardGrab();
|
||||
} else if (!isWindows) {
|
||||
if (!isWindows) {
|
||||
_ffi.inputModel.enterOrLeave(false);
|
||||
}
|
||||
}
|
||||
@@ -911,29 +600,17 @@ class _RemotePageState extends State<RemotePage>
|
||||
onEnter: onEnter,
|
||||
onExit: onExit,
|
||||
onPointerDown: (event) {
|
||||
// A double check for blur status on Windows and macOS.
|
||||
// A double check for blur status.
|
||||
// Note: If there's an `onPointerDown` event is triggered, `_isWindowBlur` is expected being false.
|
||||
// Sometimes the system does not send the necessary focus event to flutter. We should manually
|
||||
// handle this inconsistent status by setting `_isWindowBlur` to false. So we can
|
||||
// ensure the grab-key thread is running when our users are clicking the remote canvas.
|
||||
if ((isWindows || isMacOS) && _isWindowBlur) {
|
||||
if (_isWindowBlur) {
|
||||
debugPrint(
|
||||
"Unexpected status: onPointerDown is triggered while the remote window is in blur status");
|
||||
_isWindowBlur = false;
|
||||
}
|
||||
if (isMacOS) {
|
||||
// Regions without matching enter/exit callbacks cannot safely own
|
||||
// keyboard state.
|
||||
if (onEnter == null || onExit == null) return;
|
||||
if (!stateGlobal.isFocused.value) {
|
||||
stateGlobal.isFocused.value = true;
|
||||
}
|
||||
_cursorOverImage.value = true;
|
||||
_macOSLocalFocusLost = false;
|
||||
stateGlobal.getInputSource(force: true);
|
||||
_syncMacOSKeyboardGrab(
|
||||
reassert: !isInputSourceFlutter, allowInactiveLifecycle: true);
|
||||
} else if (!_rawKeyFocusNode.hasFocus) {
|
||||
if (!_rawKeyFocusNode.hasFocus) {
|
||||
_rawKeyFocusNode.requestFocus();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -513,17 +513,15 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
|
||||
final args = jsonDecode(call.arguments);
|
||||
final id = args['id'];
|
||||
final close = args['close'];
|
||||
RemotePage? remotePage;
|
||||
try {
|
||||
remotePage = tabController.state.value.tabs
|
||||
final remotePage = tabController.state.value.tabs
|
||||
.firstWhere((tab) => tab.key == id)
|
||||
.page as RemotePage;
|
||||
returnValue = remotePage.ffi.ffiModel.cachedPeerData.toString();
|
||||
} catch (e) {
|
||||
debugPrint('Failed to get cached session data: $e');
|
||||
}
|
||||
if (close && returnValue != null && remotePage != null) {
|
||||
remotePage.releaseMacOSInputForTabTransfer();
|
||||
if (close && returnValue != null) {
|
||||
closeSessionOnDispose[id] = false;
|
||||
tabController.closeBy(id);
|
||||
}
|
||||
|
||||
@@ -22,14 +22,6 @@ 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);
|
||||
|
||||
@@ -63,10 +55,7 @@ class _DesktopServerPageState extends State<DesktopServerPage>
|
||||
|
||||
@override
|
||||
void onWindowClose() {
|
||||
// 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((_) {
|
||||
Future.wait([gFFI.serverModel.closeAll(), gFFI.close()]).then((_) {
|
||||
if (isMacOS) {
|
||||
RdPlatformChannel.instance.terminate();
|
||||
} else {
|
||||
@@ -338,7 +327,6 @@ class ConnectionManagerState extends State<ConnectionManager>
|
||||
var tabController = gFFI.serverModel.tabController;
|
||||
final connLength = tabController.length;
|
||||
if (connLength <= 1) {
|
||||
_cmClosedByOperator = true;
|
||||
windowManager.close();
|
||||
return true;
|
||||
} else {
|
||||
@@ -350,9 +338,6 @@ 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;
|
||||
|
||||
@@ -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:flutter_hbb/models/terminal_mouse_handler.dart';
|
||||
import 'package:xterm/xterm.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 TerminalMouseInteraction(
|
||||
return TerminalView(
|
||||
_terminalModel.terminal,
|
||||
controller: _terminalModel.terminalController,
|
||||
focusNode: _terminalFocusNode,
|
||||
|
||||
@@ -127,6 +127,7 @@ 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);
|
||||
|
||||
@@ -9,6 +9,9 @@ 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';
|
||||
@@ -1333,12 +1336,6 @@ 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,
|
||||
@@ -1349,18 +1346,9 @@ 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(context),
|
||||
future: isWindowCanBeAdjusted(),
|
||||
hasData: (data) {
|
||||
final visible = data as bool;
|
||||
if (!visible) return Offstage();
|
||||
@@ -1376,201 +1364,36 @@ class ScreenAdjustor {
|
||||
});
|
||||
}
|
||||
|
||||
// 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.
|
||||
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.
|
||||
// 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 +
|
||||
horizontalEdges) *
|
||||
CanvasModel.leftToEdge +
|
||||
CanvasModel.rightToEdge) *
|
||||
scale +
|
||||
magicWidth;
|
||||
final height =
|
||||
(canvasModel.getDisplayHeight() * canvasModel.scale + verticalEdges) *
|
||||
scale +
|
||||
magicHeight;
|
||||
final height = (canvasModel.getDisplayHeight() * canvasModel.scale +
|
||||
CanvasModel.topToEdge +
|
||||
CanvasModel.bottomToEdge) *
|
||||
scale +
|
||||
magicHeight;
|
||||
double left = wndRect.left + (wndRect.width - width) / 2;
|
||||
double top = wndRect.top + (wndRect.height - height) / 2;
|
||||
|
||||
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;
|
||||
Rect frameRect = _screen!.frame;
|
||||
if (!isFullscreen) {
|
||||
frameRect = _screen!.visibleFrame;
|
||||
}
|
||||
if (left < frameRect.left) {
|
||||
left = frameRect.left;
|
||||
@@ -1584,101 +1407,69 @@ class ScreenAdjustor {
|
||||
if ((top + height) > frameRect.bottom) {
|
||||
top = frameRect.bottom - 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;
|
||||
}
|
||||
await WindowController.fromWindowId(windowId)
|
||||
.setFrame(Rect.fromLTWH(left, top, width, height));
|
||||
stateGlobal.setMaximized(false);
|
||||
}
|
||||
}
|
||||
|
||||
updateScreen() async {
|
||||
_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;
|
||||
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']);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
_getScreenInfoDesktop() async {
|
||||
final v = await rustDeskWinManager.call(
|
||||
WindowType.Main, kWindowGetWindowInfo, '');
|
||||
return v.result;
|
||||
}
|
||||
|
||||
Future<bool> isWindowCanBeAdjusted() async {
|
||||
final viewStyle =
|
||||
await bind.sessionGetViewStyle(sessionId: ffi.sessionId) ?? '';
|
||||
if (viewStyle != kRemoteViewStyleOriginal) {
|
||||
return false;
|
||||
}
|
||||
final remoteCount = RemoteCountState.find().value;
|
||||
if (remoteCount != 1) {
|
||||
return false;
|
||||
if (!isWeb) {
|
||||
final remoteCount = RemoteCountState.find().value;
|
||||
if (remoteCount != 1) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
await updateScreen();
|
||||
if (_screen == null) {
|
||||
return false;
|
||||
}
|
||||
return await _getAdjustedWindowFrame(mediaSize, forMenu: true) != null;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1687,11 +1478,20 @@ class _DisplayMenu extends StatefulWidget {
|
||||
final FFI ffi;
|
||||
final ToolbarState state;
|
||||
final Function(bool) setFullscreen;
|
||||
const _DisplayMenu(
|
||||
{required this.id,
|
||||
final Widget pluginItem;
|
||||
_DisplayMenu(
|
||||
{Key? key,
|
||||
required this.id,
|
||||
required this.ffi,
|
||||
required this.state,
|
||||
required this.setFullscreen});
|
||||
required this.setFullscreen})
|
||||
: pluginItem = LocationItem.createLocationItem(
|
||||
id,
|
||||
ffi,
|
||||
kLocationClientRemoteToolbarDisplay,
|
||||
true,
|
||||
),
|
||||
super(key: key);
|
||||
|
||||
@override
|
||||
State<_DisplayMenu> createState() => _DisplayMenuState();
|
||||
@@ -1729,6 +1529,7 @@ 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),
|
||||
@@ -1781,6 +1582,9 @@ class _DisplayMenuState extends State<_DisplayMenu> {
|
||||
]);
|
||||
}
|
||||
}
|
||||
if (ffi.connType == ConnType.defaultConn) {
|
||||
menuChildren.add(widget.pluginItem);
|
||||
}
|
||||
return menuChildren;
|
||||
}
|
||||
|
||||
@@ -2292,19 +2096,15 @@ class _ResolutionsMenuState extends State<_ResolutionsMenu> {
|
||||
|
||||
Future<void> _getLocalResolutionWayland() async {
|
||||
if (!isWayland) return _getLocalResolution();
|
||||
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');
|
||||
final window = await window_size.getWindowInfo();
|
||||
final screen = window.screen;
|
||||
if (screen != null) {
|
||||
setState(() {
|
||||
_localResolution = Resolution(
|
||||
screen.frame.width.toInt(),
|
||||
screen.frame.height.toInt(),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2376,16 +2176,8 @@ class _ResolutionsMenuState extends State<_ResolutionsMenu> {
|
||||
return;
|
||||
}
|
||||
if (w == rect.width.toInt() && h == rect.height.toInt()) {
|
||||
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();
|
||||
if (await widget.screenAdjustor.isWindowCanBeAdjusted()) {
|
||||
widget.screenAdjustor.doAdjustWindow(context);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -2692,8 +2484,6 @@ class _KeyboardMenu extends StatelessWidget {
|
||||
? (v) async {
|
||||
if (v != null) {
|
||||
await stateGlobal.setInputSource(ffi.sessionId, v);
|
||||
// Release native input; see the macOS trade-offs in RemotePage.
|
||||
if (isMacOS) ffi.inputModel.enterOrLeave(false);
|
||||
await ffi.ffiModel.checkDesktopKeyboardMode();
|
||||
await ffi.inputModel.updateKeyboardMode();
|
||||
}
|
||||
@@ -2950,9 +2740,7 @@ class _RecordMenu extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
var ffi = Provider.of<FfiModel>(context);
|
||||
var recordingModel = Provider.of<RecordingModel>(context);
|
||||
final hideRecordingButton =
|
||||
bind.mainGetLocalOption(key: kOptionHideRecordingButton) == 'Y';
|
||||
final visible = !hideRecordingButton &&
|
||||
final visible =
|
||||
(recordingModel.start || ffi.permissions['recording'] != false);
|
||||
if (!visible) return Offstage();
|
||||
return _IconMenuButton(
|
||||
|
||||
@@ -30,6 +30,9 @@ 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;
|
||||
@@ -138,6 +141,8 @@ 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();
|
||||
@@ -565,6 +570,12 @@ _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 {
|
||||
@@ -577,8 +588,7 @@ _registerEventHandler() {
|
||||
|
||||
Widget keyListenerBuilder(BuildContext context, Widget? child) {
|
||||
return RawKeyboardListener(
|
||||
// `skipTraversal: isWeb` is to fix "Bad state: RenderBox was not laid out: minified:aeL#c19e4"
|
||||
focusNode: FocusNode(skipTraversal: isWeb),
|
||||
focusNode: FocusNode(),
|
||||
child: child ?? Container(),
|
||||
onKey: (RawKeyEvent event) {
|
||||
if (event.logicalKey == LogicalKeyboardKey.shiftLeft) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_breadcrumb/flutter_breadcrumb.dart';
|
||||
@@ -9,7 +8,6 @@ import 'package:toggle_switch/toggle_switch.dart';
|
||||
|
||||
import '../../common.dart';
|
||||
import '../../common/widgets/dialog.dart';
|
||||
import '../../consts.dart';
|
||||
|
||||
class FileManagerPage extends StatefulWidget {
|
||||
FileManagerPage(
|
||||
@@ -75,173 +73,6 @@ 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();
|
||||
@@ -328,45 +159,6 @@ 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(
|
||||
@@ -411,12 +203,6 @@ 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();
|
||||
@@ -514,24 +300,6 @@ 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),
|
||||
@@ -598,7 +366,8 @@ class _FileManagerPageState extends State<FileManagerPage> {
|
||||
return BottomSheetBody(
|
||||
leading: CircularProgressIndicator(),
|
||||
title: translate("Waiting"),
|
||||
text: "${readableFileSize(activeJob.speed)}/s",
|
||||
text:
|
||||
"${translate("Speed")}: ${readableFileSize(activeJob.speed)}/s",
|
||||
onCanceled: () {
|
||||
model.jobController.cancelJob(activeJob.id);
|
||||
jobTable.clear();
|
||||
|
||||
@@ -1276,14 +1276,6 @@ 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) ||
|
||||
|
||||
@@ -225,6 +225,12 @@ 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 {
|
||||
|
||||
@@ -78,7 +78,6 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
||||
var _enableAbr = false;
|
||||
var _denyLANDiscovery = false;
|
||||
var _onlyWhiteList = false;
|
||||
var _onlyIdWhiteList = false;
|
||||
var _enableDirectIPAccess = false;
|
||||
var _enableRecordSession = false;
|
||||
var _enableHardwareCodec = false;
|
||||
@@ -90,7 +89,6 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
||||
var _directAccessPort = "";
|
||||
var _fingerprint = "";
|
||||
var _buildDate = "";
|
||||
var _myId = "";
|
||||
var _autoDisconnectTimeout = "";
|
||||
var _hideServer = false;
|
||||
var _hideProxy = false;
|
||||
@@ -111,7 +109,6 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
||||
_denyLANDiscovery = !option2bool(kOptionEnableLanDiscovery,
|
||||
bind.mainGetOptionSync(key: kOptionEnableLanDiscovery));
|
||||
_onlyWhiteList = whitelistNotEmpty();
|
||||
_onlyIdWhiteList = idWhitelistNotEmpty();
|
||||
_enableDirectIPAccess = option2bool(
|
||||
kOptionDirectServer, bind.mainGetOptionSync(key: kOptionDirectServer));
|
||||
_enableRecordSession = option2bool(kOptionEnableRecordSession,
|
||||
@@ -220,12 +217,6 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
||||
_buildDate = buildDate;
|
||||
}
|
||||
|
||||
final myId = await bind.mainGetMyId();
|
||||
if (_myId != myId) {
|
||||
update = true;
|
||||
_myId = myId;
|
||||
}
|
||||
|
||||
final isUsingPublicServer = await bind.mainIsUsingPublicServer();
|
||||
if (_isUsingPublicServer != isUsingPublicServer) {
|
||||
update = true;
|
||||
@@ -409,29 +400,6 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
||||
changeWhiteList(callback: update);
|
||||
},
|
||||
),
|
||||
SettingsTile.switchTile(
|
||||
title: Row(children: [
|
||||
Expanded(child: Text(translate('Use ID whitelisting'))),
|
||||
Offstage(
|
||||
offstage: !_onlyIdWhiteList,
|
||||
child: const Icon(Icons.warning_amber_rounded,
|
||||
color: Color.fromARGB(255, 255, 204, 0)))
|
||||
.marginOnly(left: 5)
|
||||
]),
|
||||
initialValue: _onlyIdWhiteList,
|
||||
onToggle: (_) async {
|
||||
update() async {
|
||||
final onlyIdWhiteList = idWhitelistNotEmpty();
|
||||
if (onlyIdWhiteList != _onlyIdWhiteList) {
|
||||
setState(() {
|
||||
_onlyIdWhiteList = onlyIdWhiteList;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
changeIdWhiteList(callback: update);
|
||||
},
|
||||
),
|
||||
SettingsTile.switchTile(
|
||||
title: Text(translate('Adaptive bitrate')),
|
||||
initialValue: _enableAbr,
|
||||
@@ -1014,14 +982,6 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
||||
child: Text(_fingerprint),
|
||||
),
|
||||
leading: Icon(Icons.fingerprint)),
|
||||
SettingsTile(
|
||||
onPressed: (context) => onCopyId(_myId),
|
||||
title: Text(translate("ID")),
|
||||
value: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8),
|
||||
child: Text(_myId),
|
||||
),
|
||||
leading: Icon(Icons.perm_identity)),
|
||||
SettingsTile(
|
||||
title: Text(translate("Privacy Statement")),
|
||||
onPressed: (context) =>
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
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';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/common/widgets/dialog.dart';
|
||||
import 'package:flutter_hbb/models/input_modifier_utils.dart';
|
||||
import 'package:flutter_hbb/models/model.dart';
|
||||
import 'package:flutter_hbb/models/platform_model.dart';
|
||||
import 'package:flutter_hbb/models/terminal_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';
|
||||
@@ -49,11 +42,6 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
final GlobalKey _keyboardKey = GlobalKey();
|
||||
double _keyboardHeight = 0;
|
||||
late bool _showTerminalExtraKeys;
|
||||
// Ctrl lock state for virtual keyboard: active key presses are mapped to control codes
|
||||
bool _ctrlLocked = false;
|
||||
bool _altLocked = false;
|
||||
// Row3 expand/collapse state for compact keyboard layout
|
||||
bool _row3Expanded = false;
|
||||
// For iOS edge swipe gesture
|
||||
double _swipeStartX = 0;
|
||||
double _swipeCurrentX = 0;
|
||||
@@ -71,10 +59,6 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
|
||||
if (isWeb) {
|
||||
loadLocalTerminalFontIfNeeded();
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
'[TerminalPage] Initializing terminal ${widget.terminalId} for peer ${widget.id}');
|
||||
|
||||
@@ -110,18 +94,6 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
// terminal extra keys bar is unnecessary and disabled.
|
||||
_showTerminalExtraKeys = !isWebDesktop &&
|
||||
mainGetLocalBoolOptionSync(kOptionEnableShowTerminalExtraKeys);
|
||||
_terminalModel.isCtrlLocked = () => _ctrlLocked;
|
||||
_terminalModel.clearCtrlLock = () {
|
||||
if (_ctrlLocked) setState(() => _ctrlLocked = false);
|
||||
};
|
||||
_terminalModel.isAltLocked = () => _altLocked;
|
||||
_terminalModel.clearAltLock = () {
|
||||
if (_altLocked) setState(() => _altLocked = false);
|
||||
};
|
||||
// Load Row3 expand/collapse state from persistent storage. The raw option
|
||||
// read keeps Row3 collapsed when no value has been saved yet.
|
||||
_row3Expanded =
|
||||
bind.mainGetLocalOption(key: kOptionShowTerminalCtrlKeys) == 'Y';
|
||||
// Initialize terminal connection
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_ffi.dialogManager
|
||||
@@ -176,40 +148,6 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
return EdgeInsets.only(left: 5.0, right: 5.0, top: topBottom, bottom: topBottom + _sysKeyboardHeight + _keyboardHeight);
|
||||
}
|
||||
|
||||
/// Pastes clipboard text through TerminalModel so keyboard-only modifiers and
|
||||
/// mobile Enter normalization never alter clipboard data.
|
||||
Future<void> _pasteClipboardText() async {
|
||||
final data = await Clipboard.getData(Clipboard.kTextPlain);
|
||||
final text = data?.text;
|
||||
if (text == null || !mounted) return;
|
||||
|
||||
await _terminalModel.pasteText(text);
|
||||
if (mounted) {
|
||||
_terminalModel.terminalController.clearSelection();
|
||||
}
|
||||
}
|
||||
|
||||
KeyEventResult _handleTerminalKeyEvent(FocusNode _, KeyEvent event) {
|
||||
final hardwareKeyboard = HardwareKeyboard.instance;
|
||||
final shouldPaste = shouldHandleTerminalPasteShortcut(
|
||||
platform: defaultTargetPlatform,
|
||||
logicalKey: event.logicalKey,
|
||||
isKeyDown: event is KeyDownEvent,
|
||||
isKeyRepeat: event is KeyRepeatEvent,
|
||||
controlPressed: hardwareKeyboard.isControlPressed,
|
||||
metaPressed: hardwareKeyboard.isMetaPressed,
|
||||
altPressed: hardwareKeyboard.isAltPressed,
|
||||
shiftPressed: hardwareKeyboard.isShiftPressed,
|
||||
modifierLockActive: _ctrlLocked || _altLocked,
|
||||
);
|
||||
if (!shouldPaste) return KeyEventResult.ignored;
|
||||
|
||||
// Only locked virtual modifiers need interception. Without a lock, keep
|
||||
// xterm's default hardware paste behavior, including bracketed paste mode.
|
||||
unawaited(_pasteClipboardText());
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
@@ -247,12 +185,6 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
//
|
||||
// Android works fine without this workaround.
|
||||
deleteDetection: isIOS,
|
||||
shortcuts: platformTerminalShortcuts(),
|
||||
onKeyEvent: terminalCopyHandler(
|
||||
_terminalModel.terminal,
|
||||
_terminalModel.terminalController,
|
||||
fallback: _handleTerminalKeyEvent,
|
||||
),
|
||||
padding: _calculatePadding(heightPx),
|
||||
onSecondaryTapDown: (details, offset) async {
|
||||
final selection = _terminalModel.terminalController.selection;
|
||||
@@ -261,7 +193,11 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
_terminalModel.terminalController.clearSelection();
|
||||
await Clipboard.setData(ClipboardData(text: text));
|
||||
} else {
|
||||
await _pasteClipboardText();
|
||||
final data = await Clipboard.getData('text/plain');
|
||||
final text = data?.text;
|
||||
if (text != null) {
|
||||
_terminalModel.terminal.paste(text);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -388,171 +324,66 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Row 1 follows the latest reviewed PR layout.
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: _buildKeyboardKeyButtons(terminalKeyboardRow1Keys),
|
||||
),
|
||||
// Row 2 ends with the full-width Row3 collapse/expand toggle.
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
..._buildKeyboardKeyButtons(terminalKeyboardRow2Keys),
|
||||
const SizedBox(width: terminalKeyboardKeySpacing),
|
||||
_buildCollapseButton(),
|
||||
_buildKeyButton('Esc'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('/'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('|'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('Home'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('↑'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('End'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('PgUp'),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_buildKeyButton('Tab'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('Ctrl+C'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('~'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('←'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('↓'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('→'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('PgDn'),
|
||||
],
|
||||
),
|
||||
// Row 3 restores paging keys and trailing alignment placeholders.
|
||||
if (_row3Expanded)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
..._buildKeyboardKeyButtons(terminalKeyboardRow3Keys),
|
||||
for (var i = 0;
|
||||
i < terminalKeyboardRow3TrailingPlaceholderCount;
|
||||
i++) ...[
|
||||
const SizedBox(width: terminalKeyboardKeySpacing),
|
||||
const SizedBox(width: terminalKeyboardKeyWidth),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Ctrl toggle button with highlighted locked state
|
||||
Widget _buildCtrlKeyButton() {
|
||||
return _buildModifierToggleButton(
|
||||
text: 'Ctrl',
|
||||
semanticsLabel: 'Ctrl',
|
||||
isLocked: _ctrlLocked,
|
||||
onPressed: () => setState(() => _ctrlLocked = !_ctrlLocked),
|
||||
);
|
||||
}
|
||||
|
||||
// Alt toggle button with highlighted locked state
|
||||
Widget _buildAltKeyButton() {
|
||||
return _buildModifierToggleButton(
|
||||
text: 'Alt',
|
||||
semanticsLabel: 'Alt',
|
||||
isLocked: _altLocked,
|
||||
onPressed: () => setState(() => _altLocked = !_altLocked),
|
||||
);
|
||||
}
|
||||
|
||||
// Collapse/expand toggle button for Row3
|
||||
void _toggleRow3Expanded() {
|
||||
final willExpand = !_row3Expanded;
|
||||
final shouldClearModifiers = shouldClearTerminalModifiersWhenRow3Collapses(
|
||||
wasExpanded: _row3Expanded,
|
||||
willExpand: willExpand,
|
||||
ctrlLocked: _ctrlLocked,
|
||||
altLocked: _altLocked,
|
||||
);
|
||||
setState(() {
|
||||
_row3Expanded = willExpand;
|
||||
if (shouldClearModifiers) {
|
||||
_ctrlLocked = false;
|
||||
_altLocked = false;
|
||||
}
|
||||
});
|
||||
mainSetLocalBoolOption(kOptionShowTerminalCtrlKeys, willExpand);
|
||||
|
||||
// The floating keyboard height changes after Row3 is inserted/removed.
|
||||
// Re-measure on the next frame so terminal padding uses the new height.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || !_showTerminalExtraKeys) return;
|
||||
setState(() {
|
||||
_updateKeyboardHeight();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildCollapseButton() {
|
||||
return Semantics(
|
||||
label: translate('Show terminal extra keys'),
|
||||
toggled: _row3Expanded,
|
||||
child: ElevatedButton(
|
||||
onPressed: _toggleRow3Expanded,
|
||||
child: Text(_row3Expanded ? '∧' : '∨'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(terminalKeyboardKeyWidth, 32),
|
||||
padding: EdgeInsets.zero,
|
||||
textStyle: const TextStyle(fontSize: 12),
|
||||
backgroundColor:
|
||||
Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
foregroundColor: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds a fixed-width key sequence with the reviewed 2dp spacing.
|
||||
List<Widget> _buildKeyboardKeyButtons(List<String> labels) {
|
||||
return [
|
||||
for (var i = 0; i < labels.length; i++) ...[
|
||||
_buildKeyButton(labels[i]),
|
||||
if (i < labels.length - 1)
|
||||
const SizedBox(width: terminalKeyboardKeySpacing),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/// Build a modifier toggle button (Ctrl/Alt) with one-shot behavior.
|
||||
/// When [isLocked] is true, the button highlights in blue and the next
|
||||
/// single-character input is mapped to its modified equivalent.
|
||||
Widget _buildModifierToggleButton({
|
||||
required String text,
|
||||
required String semanticsLabel,
|
||||
required bool isLocked,
|
||||
required VoidCallback onPressed,
|
||||
}) {
|
||||
return Semantics(
|
||||
// Ctrl and Alt are technical key names and intentionally stay unchanged.
|
||||
label: semanticsLabel,
|
||||
toggled: isLocked,
|
||||
child: ElevatedButton(
|
||||
onPressed: onPressed,
|
||||
child: Text(text),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(terminalKeyboardKeyWidth, 32),
|
||||
padding: EdgeInsets.zero,
|
||||
textStyle: const TextStyle(fontSize: 12),
|
||||
backgroundColor: isLocked
|
||||
? Colors.blue
|
||||
: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
foregroundColor: isLocked
|
||||
? Colors.white
|
||||
: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildKeyButton(String label) {
|
||||
if (label == 'Ctrl') return _buildCtrlKeyButton();
|
||||
if (label == 'Alt') return _buildAltKeyButton();
|
||||
|
||||
return ElevatedButton(
|
||||
onPressed: () {
|
||||
_sendKeyToTerminal(label);
|
||||
},
|
||||
child: Text(label),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(terminalKeyboardKeyWidth, 32),
|
||||
minimumSize: const Size(48, 32),
|
||||
padding: EdgeInsets.zero,
|
||||
textStyle: const TextStyle(fontSize: 12),
|
||||
backgroundColor:
|
||||
Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
backgroundColor: Theme.of(context).colorScheme.surfaceVariant,
|
||||
foregroundColor: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _sendKeyToTerminal(String key) {
|
||||
String send;
|
||||
String? send;
|
||||
|
||||
switch (key) {
|
||||
case 'Esc':
|
||||
@@ -596,7 +427,9 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
break;
|
||||
}
|
||||
|
||||
_terminalModel.sendVirtualKey(send);
|
||||
if (send != null) {
|
||||
_terminalModel.sendVirtualKey(send);
|
||||
}
|
||||
}
|
||||
|
||||
// https://github.com/TerminalStudio/xterm.dart/issues/42#issuecomment-877495472
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
/// Reviewed mobile terminal keyboard layout from PR #15532.
|
||||
///
|
||||
/// Keeping the key order outside the widget makes the intended layout explicit
|
||||
/// and prevents behavior fixes from silently moving keys between rows.
|
||||
const terminalKeyboardRow1Keys = ['Esc', '/', '|', 'Home', '↑', 'End', r'\'];
|
||||
const terminalKeyboardRow2Keys = ['Tab', 'Ctrl+C', '~', '←', '↓', '→'];
|
||||
const terminalKeyboardRow3Keys = ['Ctrl', 'Alt', '-', 'PgUp', 'PgDn'];
|
||||
|
||||
const terminalKeyboardKeyWidth = 48.0;
|
||||
const terminalKeyboardKeySpacing = 2.0;
|
||||
|
||||
/// Empty 48dp slots keep expanded Row3 aligned with the two rows above it.
|
||||
const terminalKeyboardRow3TrailingPlaceholderCount = 2;
|
||||
|
||||
/// Returns the fixed width occupied by a row of equally sized key slots.
|
||||
double terminalKeyboardRowWidth(int slotCount) {
|
||||
if (slotCount <= 0) return 0;
|
||||
return slotCount * terminalKeyboardKeyWidth +
|
||||
(slotCount - 1) * terminalKeyboardKeySpacing;
|
||||
}
|
||||
@@ -46,12 +46,6 @@ 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;
|
||||
@@ -90,7 +84,6 @@ class FileModel {
|
||||
}
|
||||
|
||||
Future<void> onReady() async {
|
||||
fileFetcher.beginRemoteSession();
|
||||
await evtLoop.onReady();
|
||||
if (!isWeb) await localController.onReady();
|
||||
await remoteController.onReady();
|
||||
@@ -140,11 +133,7 @@ class FileModel {
|
||||
final id = int.tryParse(evt['id']?.toString() ?? '');
|
||||
if (id != null) {
|
||||
final err = evt['err']?.toString() ?? 'Unknown error';
|
||||
if (id == 0) {
|
||||
fileFetcher.tryCompleteRemoteTaskWithError(err);
|
||||
} else {
|
||||
fileFetcher.tryCompleteRecursiveTaskWithError(id, err);
|
||||
}
|
||||
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
|
||||
@@ -361,8 +350,6 @@ 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;
|
||||
|
||||
@@ -381,14 +368,6 @@ 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)) {
|
||||
@@ -422,13 +401,8 @@ class FileController {
|
||||
|
||||
await Future.delayed(Duration(milliseconds: 100));
|
||||
|
||||
var savedDir = (await bind.sessionGetPeerOption(
|
||||
final 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,
|
||||
@@ -498,9 +472,6 @@ class FileController {
|
||||
}
|
||||
|
||||
Future<bool> _openDirectoryPath(String path, {bool isBack = false}) async {
|
||||
if (!_isPathAllowed(path)) {
|
||||
return false;
|
||||
}
|
||||
if (!isBack) {
|
||||
pushHistory();
|
||||
}
|
||||
@@ -513,20 +484,12 @@ 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;
|
||||
}
|
||||
@@ -567,9 +530,6 @@ 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);
|
||||
@@ -581,7 +541,6 @@ 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);
|
||||
@@ -597,12 +556,10 @@ class FileController {
|
||||
debugPrint("update receive details: ${fd.path}");
|
||||
jobController.jobTable.refresh();
|
||||
}
|
||||
} else if (options.value.home.isEmpty && isHomeResponse) {
|
||||
} else if (options.value.home.isEmpty) {
|
||||
options.value.home = fd.path;
|
||||
debugPrint("init remote home: ${fd.path}");
|
||||
if (_directoryRequestGeneration == 0) {
|
||||
directory.value = fd;
|
||||
}
|
||||
directory.value = fd;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("initDirAndHome err=$e");
|
||||
@@ -1405,78 +1362,16 @@ 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
|
||||
final Map<String, _RemoteReadTask> _remoteReadTasks = {};
|
||||
Map<String, Completer<FileDirectory>> remoteTasks = {};
|
||||
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, {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;
|
||||
}
|
||||
FileFetcher(this.getSessionID);
|
||||
|
||||
Future<List<FileDirectory>> registerReadEmptyDirsTask(
|
||||
bool isLocal, String path) {
|
||||
@@ -1496,6 +1391,23 @@ 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)) {
|
||||
@@ -1533,37 +1445,27 @@ 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
|
||||
final completer = readRecursiveTasks.remove(fd.id);
|
||||
// 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);
|
||||
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) {
|
||||
@@ -1604,26 +1506,9 @@ class FileFetcher {
|
||||
final fd = FileDirectory.fromJson(jsonDecode(res));
|
||||
return fd;
|
||||
} else {
|
||||
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;
|
||||
await bind.sessionReadRemoteDir(
|
||||
sessionId: sessionId, path: path, includeHidden: showHidden);
|
||||
return registerReadTask(isLocal, path);
|
||||
}
|
||||
} catch (e) {
|
||||
return Future.error(e);
|
||||
@@ -1905,7 +1790,7 @@ class PathUtil {
|
||||
}
|
||||
|
||||
static bool validName(String name, bool isWindows) {
|
||||
final unixFileNamePattern = RegExp(r'^[^/\x00]+$');
|
||||
final unixFileNamePattern = RegExp(r'^[^/\0]+$');
|
||||
final windowsFileNamePattern = RegExp(r'^[^<>:"/\\|?*]+$');
|
||||
final reg = isWindows ? windowsFileNamePattern : unixFileNamePattern;
|
||||
return reg.hasMatch(name);
|
||||
@@ -1948,21 +1833,6 @@ 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);
|
||||
|
||||
@@ -1787,11 +1787,6 @@ 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);
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Identifies where terminal input originated so paste data can bypass all
|
||||
/// keyboard-only transformations.
|
||||
enum TerminalInputSource {
|
||||
keyboard,
|
||||
paste,
|
||||
}
|
||||
|
||||
/// Returns true when a stale mobile one-shot Shift state should be released
|
||||
/// by replaying a tracked Shift key-down as a synthesized key-up.
|
||||
@@ -44,158 +36,3 @@ bool shouldReleaseStaleMobileShift({
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Applies the terminal Ctrl/Alt one-shot modifiers to a single input payload.
|
||||
///
|
||||
String applyTerminalInputModifiers(
|
||||
String data, {
|
||||
required bool ctrlLocked,
|
||||
required bool altLocked,
|
||||
}) {
|
||||
var result = data;
|
||||
if (ctrlLocked) {
|
||||
result = _applyTerminalCtrlModifier(result);
|
||||
}
|
||||
if (altLocked) {
|
||||
result = '\x1B$result';
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Builds the exact payload xterm sends for paste, without applying modifiers.
|
||||
String terminalPastePayload(String text, {required bool bracketedPasteMode}) {
|
||||
if (!bracketedPasteMode) {
|
||||
return text;
|
||||
}
|
||||
return '\x1B[200~$text\x1B[201~';
|
||||
}
|
||||
|
||||
/// Returns whether one-shot Ctrl/Alt may transform and consume this input.
|
||||
///
|
||||
/// xterm emits terminal control keys as either one control byte or a longer
|
||||
/// escape sequence. Neither form is ordinary text input, so a pending modifier
|
||||
/// must survive until the user enters a printable character.
|
||||
bool shouldApplyTerminalInputModifiers(String data) {
|
||||
if (data.characters.length != 1) return false;
|
||||
final codeUnit = data.codeUnitAt(0);
|
||||
return codeUnit >= 0x20 && codeUnit != 0x7F;
|
||||
}
|
||||
|
||||
/// Builds the payload sent to the remote terminal for keyboard and paste input.
|
||||
///
|
||||
/// Keyboard input keeps the mobile Enter workaround and one-shot Ctrl/Alt
|
||||
/// mapping. Paste input deliberately bypasses both transformations so even a
|
||||
/// one-character clipboard payload is preserved exactly.
|
||||
String prepareTerminalInputPayload(
|
||||
String data, {
|
||||
required TerminalInputSource source,
|
||||
required bool isMobileOrWebMobile,
|
||||
required bool bracketedPasteMode,
|
||||
required bool ctrlLocked,
|
||||
required bool altLocked,
|
||||
}) {
|
||||
if (source == TerminalInputSource.paste) {
|
||||
return terminalPastePayload(
|
||||
data,
|
||||
bracketedPasteMode: bracketedPasteMode,
|
||||
);
|
||||
}
|
||||
|
||||
var result = data;
|
||||
if (isMobileOrWebMobile && result == '\n') {
|
||||
result = '\r';
|
||||
}
|
||||
if ((ctrlLocked || altLocked) && shouldApplyTerminalInputModifiers(result)) {
|
||||
result = applyTerminalInputModifiers(
|
||||
result,
|
||||
ctrlLocked: ctrlLocked,
|
||||
altLocked: altLocked,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Returns true when a hardware paste shortcut must bypass keyboard modifiers.
|
||||
///
|
||||
/// xterm already handles 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,
|
||||
required bool controlPressed,
|
||||
required bool metaPressed,
|
||||
required bool altPressed,
|
||||
required bool shiftPressed,
|
||||
required bool modifierLockActive,
|
||||
}) {
|
||||
if (!modifierLockActive) return false;
|
||||
if (!isKeyDown && !isKeyRepeat) return false;
|
||||
if (logicalKey != LogicalKeyboardKey.keyV) return false;
|
||||
if (altPressed) 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.
|
||||
bool shouldClearTerminalModifiersWhenRow3Collapses({
|
||||
required bool wasExpanded,
|
||||
required bool willExpand,
|
||||
required bool ctrlLocked,
|
||||
required bool altLocked,
|
||||
}) {
|
||||
return wasExpanded && !willExpand && (ctrlLocked || altLocked);
|
||||
}
|
||||
|
||||
String _applyTerminalCtrlModifier(String data) {
|
||||
// Ctrl mappings are defined only for ASCII scalars. A visible character can
|
||||
// be multiple scalars (for example, a decomposed accent), so leave those
|
||||
// graphemes untouched instead of rewriting only their ASCII base letter.
|
||||
final graphemes = data.characters.toList(growable: false);
|
||||
if (graphemes.length != 1) {
|
||||
return data;
|
||||
}
|
||||
|
||||
final runes = graphemes.single.runes.toList(growable: false);
|
||||
if (runes.length != 1) {
|
||||
return data;
|
||||
}
|
||||
|
||||
final code = runes.single;
|
||||
if (code >= 0x61 && code <= 0x7A) {
|
||||
return String.fromCharCode(code - 0x60);
|
||||
}
|
||||
if (code >= 0x41 && code <= 0x5A) {
|
||||
return String.fromCharCode(code - 0x40);
|
||||
}
|
||||
if (code == 0x20) {
|
||||
return String.fromCharCode(0);
|
||||
}
|
||||
if (code == 0x5B) {
|
||||
return String.fromCharCode(27);
|
||||
}
|
||||
if (code == 0x5C) {
|
||||
return String.fromCharCode(28);
|
||||
}
|
||||
if (code == 0x5D) {
|
||||
return String.fromCharCode(29);
|
||||
}
|
||||
if (code == 0x5E) {
|
||||
return String.fromCharCode(30);
|
||||
}
|
||||
if (code == 0x5F || code == 0x2F) {
|
||||
return String.fromCharCode(31);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,9 @@ 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;
|
||||
@@ -124,8 +127,6 @@ 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;
|
||||
@@ -257,8 +258,6 @@ class FfiModel with ChangeNotifier {
|
||||
_inputBlocked = false;
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
_androidDocumentPickerActive = false;
|
||||
_androidDocumentPickerInterruptedConnection = false;
|
||||
resetRestartReconnectState();
|
||||
clearPermissions();
|
||||
waitForImageTimer?.cancel();
|
||||
@@ -438,6 +437,15 @@ 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'];
|
||||
@@ -896,13 +904,6 @@ 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.
|
||||
@@ -919,12 +920,17 @@ 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');
|
||||
sessionId, dialogManager, 'terminal-admin-login-tip', false);
|
||||
} else if (type == 'terminal-admin-login-password') {
|
||||
enterUserLoginAndPasswordDialog(
|
||||
sessionId, dialogManager, 'terminal-admin-login-tip');
|
||||
sessionId, dialogManager, 'terminal-admin-login-tip', false);
|
||||
} else if (type == 'restarting') {
|
||||
// Treat restart messages as reconnect control events. Rust still sends
|
||||
// title/text for legacy UI and translation reuse; Flutter keeps the last
|
||||
@@ -979,23 +985,6 @@ 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(
|
||||
@@ -1963,12 +1952,6 @@ 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);
|
||||
@@ -1980,16 +1963,11 @@ class ImageModel with ChangeNotifier {
|
||||
? ui.PixelFormat.rgba8888
|
||||
: ui.PixelFormat.bgra8888,
|
||||
);
|
||||
if (parent.target?.id != pid) {
|
||||
image?.dispose();
|
||||
return;
|
||||
}
|
||||
if (parent.target?.id != pid) return;
|
||||
await update(image);
|
||||
}
|
||||
|
||||
Future<void> update(ui.Image? image,
|
||||
{bool Function()? isCurrentSession}) async {
|
||||
if (_disposeIfStale(image, isCurrentSession)) return;
|
||||
update(ui.Image? image) async {
|
||||
if (_image == null && image != null) {
|
||||
if (isDesktop || isWebDesktop) {
|
||||
await parent.target?.canvasModel.updateViewStyle();
|
||||
@@ -2000,19 +1978,11 @@ 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;
|
||||
@@ -2236,7 +2206,6 @@ 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
|
||||
@@ -2285,19 +2254,12 @@ 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) {
|
||||
@@ -2526,7 +2488,6 @@ 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.
|
||||
@@ -2759,7 +2720,6 @@ class CanvasModel with ChangeNotifier {
|
||||
_x = 0;
|
||||
_y = 0;
|
||||
_scale = 1.0;
|
||||
_locked = false;
|
||||
_lastViewStyle = ViewStyle.defaultViewStyle();
|
||||
_timerMobileFocusCanvasCursor?.cancel();
|
||||
_timerMobileRestoreCanvasOffset?.cancel();
|
||||
@@ -2871,7 +2831,7 @@ class CursorData {
|
||||
required this.width,
|
||||
required this.height,
|
||||
}) : hotx = hotxOrigin * scale,
|
||||
hoty = hotyOrigin * scale;
|
||||
hoty = hotxOrigin * scale;
|
||||
|
||||
int _doubleToInt(double v) => (v * 10e6).round().toInt();
|
||||
|
||||
@@ -3893,15 +3853,6 @@ 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;
|
||||
}
|
||||
@@ -3989,7 +3940,7 @@ class FFI {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
Future<void> onEvent2UIRgba() async {
|
||||
void onEvent2UIRgba() async {
|
||||
if (ffiModel.waitForImageDialogShow.isTrue) {
|
||||
ffiModel.waitForImageDialogShow.value = false;
|
||||
ffiModel.waitForImageTimer?.cancel();
|
||||
@@ -4045,9 +3996,6 @@ 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) {
|
||||
@@ -4088,11 +4036,6 @@ 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');
|
||||
@@ -4197,6 +4140,7 @@ 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;
|
||||
|
||||
@@ -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';
|
||||
@@ -25,23 +25,6 @@ typedef F3 = Pointer<Uint8> Function(Pointer<Utf8>, int);
|
||||
typedef F3Dart = Pointer<Uint8> Function(Pointer<Utf8>, Int32);
|
||||
typedef HandleEvent = Future<void> Function(Map<String, dynamic> evt);
|
||||
|
||||
/// The Linux bundle keeps the core library at lib/librustdesk.so next to the
|
||||
/// executable. Prefer that copy, mirroring flutter/linux/main.cc: the plain
|
||||
/// name relies on the loader search path, which repackaged installs may not
|
||||
/// cover. https://github.com/rustdesk/rustdesk/discussions/14407
|
||||
DynamicLibrary _openLinuxCoreLib() {
|
||||
final bundled =
|
||||
'${File(Platform.resolvedExecutable).parent.path}/lib/librustdesk.so';
|
||||
try {
|
||||
if (File(bundled).existsSync()) {
|
||||
return DynamicLibrary.open(bundled);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Failed to load '$bundled': $e");
|
||||
}
|
||||
return DynamicLibrary.open('librustdesk.so');
|
||||
}
|
||||
|
||||
/// FFI wrapper around the native Rust core.
|
||||
/// Hides the platform differences.
|
||||
class PlatformFFI {
|
||||
@@ -137,7 +120,7 @@ class PlatformFFI {
|
||||
final dylib = isAndroid
|
||||
? DynamicLibrary.open('librustdesk.so')
|
||||
: isLinux
|
||||
? _openLinuxCoreLib()
|
||||
? DynamicLibrary.open('librustdesk.so')
|
||||
: isWindows
|
||||
? DynamicLibrary.open('librustdesk.dll')
|
||||
:
|
||||
@@ -170,10 +153,8 @@ class PlatformFFI {
|
||||
_startListenEvent(_ffiBind); // global event
|
||||
try {
|
||||
if (isAndroid) {
|
||||
// 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;
|
||||
// only support for android
|
||||
_homeDir = (await ExternalPath.getExternalStorageDirectories())[0];
|
||||
} else if (isIOS) {
|
||||
// The previous code was `_homeDir = (await getDownloadsDirectory())?.path ?? '';`,
|
||||
// which provided the `downloads` path in the sandbox.
|
||||
@@ -285,12 +266,6 @@ 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() {}
|
||||
@@ -307,12 +282,6 @@ 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);
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -210,10 +210,15 @@ class ServerModel with ChangeNotifier {
|
||||
_audioOk = audioOption != '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';
|
||||
// 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';
|
||||
}
|
||||
|
||||
// clipboard
|
||||
final clipOption = await bind.mainGetOption(key: kOptionEnableClipboard);
|
||||
@@ -314,6 +319,16 @@ 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,
|
||||
@@ -403,6 +418,9 @@ 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);
|
||||
@@ -720,13 +738,9 @@ class ServerModel with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// `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)));
|
||||
Future<void> closeAll() async {
|
||||
await Future.wait(
|
||||
_clients.map((client) => bind.cmCloseConnection(connId: client.id)));
|
||||
_clients.clear();
|
||||
tabController.state.value.tabs.clear();
|
||||
if (isAndroid) androidUpdatekeepScreenOn();
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -7,11 +7,8 @@ import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/main.dart';
|
||||
import 'package:xterm/xterm.dart';
|
||||
|
||||
import 'input_modifier_utils.dart';
|
||||
import 'model.dart';
|
||||
import 'platform_model.dart';
|
||||
import 'rustdesk_terminal.dart';
|
||||
import 'terminal_mouse_handler.dart';
|
||||
|
||||
class TerminalModel with ChangeNotifier {
|
||||
final String id; // peer id
|
||||
@@ -25,25 +22,7 @@ class TerminalModel with ChangeNotifier {
|
||||
|
||||
bool _disposed = false;
|
||||
|
||||
/// Callback to check whether Ctrl modifier lock is currently active.
|
||||
/// When active, keyboard input is mapped to control codes (e.g. 'b' → \x02).
|
||||
bool Function()? isCtrlLocked;
|
||||
|
||||
/// Callback to clear Ctrl lock after a key is pressed (one-shot mode).
|
||||
void Function()? clearCtrlLock;
|
||||
|
||||
/// Callback to check whether Alt modifier lock is currently active.
|
||||
bool Function()? isAltLocked;
|
||||
|
||||
/// Callback to clear Alt lock after a key is pressed (one-shot mode).
|
||||
void Function()? clearAltLock;
|
||||
|
||||
final _inputBuffer = <String>[];
|
||||
|
||||
/// Exposes buffered input only for lifecycle regression tests.
|
||||
@visibleForTesting
|
||||
int get debugBufferedInputCount => _inputBuffer.length;
|
||||
|
||||
// Buffer for output data received before terminal view has valid dimensions.
|
||||
// This prevents NaN errors when writing to terminal before layout is complete.
|
||||
final _pendingOutputChunks = <String>[];
|
||||
@@ -63,10 +42,6 @@ class TerminalModel with ChangeNotifier {
|
||||
VoidCallback? onClosed;
|
||||
|
||||
Future<void> _handleInput(String data) async {
|
||||
// xterm can complete asynchronous input after the Flutter page has gone
|
||||
// away. Stop before reading or clearing widget-owned modifier state.
|
||||
if (_disposed) return;
|
||||
|
||||
// Soft keyboards (notably iOS) emit '\n' when Enter is pressed, while a
|
||||
// real keyboard's Enter sends '\r'. Some Android keyboards also emit '\n'.
|
||||
// - Peer Windows: '\r' works, '\n' is just a newline.
|
||||
@@ -74,44 +49,13 @@ class TerminalModel with ChangeNotifier {
|
||||
// (readline, prompt_toolkit, vim, TUI frameworks) expect '\r'.
|
||||
// - Peer macOS: same as Linux, raw-mode apps expect '\r'
|
||||
// (https://github.com/rustdesk/rustdesk/issues/14907).
|
||||
// So on mobile / web-mobile, normalize the original lone '\n' to '\r'
|
||||
// before modifier mappings. This keeps Ctrl+J mapped to LF instead of
|
||||
// having the generated control code rewritten to CR afterward.
|
||||
// Multi-character keyboard payloads, such as terminal escape sequences,
|
||||
// remain unchanged. Paste input follows a separate preprocessing path.
|
||||
final ctrlLocked = isCtrlLocked?.call() ?? false;
|
||||
final altLocked = isAltLocked?.call() ?? false;
|
||||
final modifiersActive = ctrlLocked || altLocked;
|
||||
// Use the same predicate for transformation and consumption. Control keys
|
||||
// and escape sequences must not silently consume a pending one-shot lock.
|
||||
final shouldConsumeModifiers =
|
||||
modifiersActive && shouldApplyTerminalInputModifiers(data);
|
||||
data = prepareTerminalInputPayload(
|
||||
data,
|
||||
// IME soft-keyboard paste prompts currently arrive from xterm as normal
|
||||
// text input with no paste-origin metadata. Keep them on the keyboard path;
|
||||
// clipboard-content heuristics can misclassify ordinary typing.
|
||||
source: TerminalInputSource.keyboard,
|
||||
isMobileOrWebMobile: isMobile || (isWeb && !isWebDesktop),
|
||||
bracketedPasteMode: terminal.bracketedPasteMode,
|
||||
ctrlLocked: ctrlLocked,
|
||||
altLocked: altLocked,
|
||||
);
|
||||
if (shouldConsumeModifiers) {
|
||||
if (ctrlLocked) clearCtrlLock?.call();
|
||||
if (altLocked) clearAltLock?.call();
|
||||
// So on mobile / web-mobile, always normalize a lone '\n' to '\r'.
|
||||
// We deliberately do not touch multi-character payloads (e.g. pasted text)
|
||||
// so embedded newlines in pasted content are preserved.
|
||||
final isMobileOrWebMobile = (isMobile || (isWeb && !isWebDesktop));
|
||||
if (isMobileOrWebMobile && data == '\n') {
|
||||
data = '\r';
|
||||
}
|
||||
return _sendInputPayload(data);
|
||||
}
|
||||
|
||||
/// Sends an already prepared payload without applying keyboard semantics.
|
||||
/// Both normal input and paste use this transport path after their source-
|
||||
/// specific preprocessing has completed.
|
||||
Future<void> _sendInputPayload(String data) async {
|
||||
// Clipboard reads and native sends may complete after the terminal page has
|
||||
// closed. Never send or re-buffer input once this model is disposed.
|
||||
if (_disposed) return;
|
||||
|
||||
if (_terminalOpened) {
|
||||
// Send user input to remote terminal
|
||||
try {
|
||||
@@ -130,8 +74,7 @@ class TerminalModel with ChangeNotifier {
|
||||
}
|
||||
|
||||
TerminalModel(this.parent, [this.terminalId = 0]) : id = parent.id {
|
||||
terminal = RustDeskTerminal(maxLines: 10000);
|
||||
terminal.mouseHandler = const WheelButtonFixMouseHandler();
|
||||
terminal = Terminal(maxLines: 10000);
|
||||
terminalController = TerminalController();
|
||||
|
||||
// Setup terminal callbacks
|
||||
@@ -233,18 +176,6 @@ class TerminalModel with ChangeNotifier {
|
||||
return _handleInput(data);
|
||||
}
|
||||
|
||||
Future<void> pasteText(String data) async {
|
||||
final payload = prepareTerminalInputPayload(
|
||||
data,
|
||||
source: TerminalInputSource.paste,
|
||||
isMobileOrWebMobile: false,
|
||||
bracketedPasteMode: terminal.bracketedPasteMode,
|
||||
ctrlLocked: false,
|
||||
altLocked: false,
|
||||
);
|
||||
return _sendInputPayload(payload);
|
||||
}
|
||||
|
||||
Future<void> closeTerminal() async {
|
||||
if (_terminalOpened) {
|
||||
try {
|
||||
@@ -585,14 +516,6 @@ class TerminalModel with ChangeNotifier {
|
||||
void dispose() {
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
terminal.onOutput = null;
|
||||
terminal.onResize = null;
|
||||
isCtrlLocked = null;
|
||||
clearCtrlLock = null;
|
||||
isAltLocked = null;
|
||||
clearAltLock = null;
|
||||
onResizeExternal = null;
|
||||
onClosed = null;
|
||||
// Clear buffers to free memory
|
||||
_inputBuffer.clear();
|
||||
_pendingOutputChunks.clear();
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,301 +0,0 @@
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -20,9 +20,6 @@ class UserModel {
|
||||
final RxString avatar = ''.obs;
|
||||
final RxBool isAdmin = false.obs;
|
||||
final RxString networkError = ''.obs;
|
||||
// True when networkError carries a server-reported error rather than a
|
||||
// connectivity failure; netWorkErrorWidget hides the network tip then.
|
||||
final RxBool networkErrorFromServer = false.obs;
|
||||
bool get isLogin => userName.isNotEmpty;
|
||||
String get displayNameOrUserName =>
|
||||
displayName.value.trim().isEmpty ? userName.value : displayName.value;
|
||||
@@ -53,7 +50,6 @@ class UserModel {
|
||||
void refreshCurrentUser() async {
|
||||
if (bind.isDisableAccount()) return;
|
||||
networkError.value = '';
|
||||
networkErrorFromServer.value = false;
|
||||
final token = bind.mainGetLocalOption(key: 'access_token');
|
||||
if (token == '') {
|
||||
await updateOtherModels();
|
||||
@@ -89,10 +85,6 @@ class UserModel {
|
||||
final data = json.decode(decode_http_response(response));
|
||||
final error = data['error'];
|
||||
if (error != null) {
|
||||
// The only failure known to come from the server itself, so the
|
||||
// check-your-network tip does not apply. Flag before the message is
|
||||
// set in the catch below so rebuilds read a consistent pair.
|
||||
networkErrorFromServer.value = true;
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -100,13 +92,6 @@ class UserModel {
|
||||
_parseAndUpdateUser(user);
|
||||
} catch (e) {
|
||||
debugPrint('Failed to refreshCurrentUser: $e');
|
||||
// Surface failures in the address book / group tabs, which offer a
|
||||
// retry. Anything not flagged above -- transport errors, non-JSON or
|
||||
// unexpected-schema bodies (e.g. a filter's block page) -- keeps the
|
||||
// check-your-network tip.
|
||||
if (networkError.value.isEmpty) {
|
||||
networkError.value = e.toString();
|
||||
}
|
||||
} finally {
|
||||
refreshingUser = false;
|
||||
await updateOtherModels();
|
||||
@@ -234,32 +219,28 @@ class UserModel {
|
||||
return loginResponse;
|
||||
}
|
||||
|
||||
/// Throws on network failures, non-success responses, and invalid response
|
||||
/// data. Returns an empty list when no API server is configured or a
|
||||
/// successful response contains no third-party login options.
|
||||
static Future<List<dynamic>> queryOidcLoginOptions() async {
|
||||
final url = await bind.mainGetApiServer();
|
||||
if (url.trim().isEmpty) return [];
|
||||
final resp = await http.get(Uri.parse('$url/api/login-options'));
|
||||
const successStatusCodeStart = 200;
|
||||
const successStatusCodeEnd = 300;
|
||||
if (resp.statusCode < successStatusCodeStart ||
|
||||
resp.statusCode >= successStatusCodeEnd) {
|
||||
throw RequestException(
|
||||
resp.statusCode, resp.reasonPhrase ?? 'Request failed');
|
||||
}
|
||||
final List<String> ops = [];
|
||||
for (final item in jsonDecode(resp.body)) {
|
||||
ops.add(item as String);
|
||||
}
|
||||
for (final item in ops) {
|
||||
if (item.startsWith('common-oidc/')) {
|
||||
return jsonDecode(item.substring('common-oidc/'.length));
|
||||
try {
|
||||
final url = await bind.mainGetApiServer();
|
||||
if (url.trim().isEmpty) return [];
|
||||
final resp = await http.get(Uri.parse('$url/api/login-options'));
|
||||
final List<String> ops = [];
|
||||
for (final item in jsonDecode(resp.body)) {
|
||||
ops.add(item as String);
|
||||
}
|
||||
for (final item in ops) {
|
||||
if (item.startsWith('common-oidc/')) {
|
||||
return jsonDecode(item.substring('common-oidc/'.length));
|
||||
}
|
||||
}
|
||||
return ops
|
||||
.where((item) => item.startsWith('oidc/'))
|
||||
.map((item) => {'name': item.substring('oidc/'.length)})
|
||||
.toList();
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
"queryOidcLoginOptions: jsonDecode resp body failed: ${e.toString()}");
|
||||
return [];
|
||||
}
|
||||
return ops
|
||||
.where((item) => item.startsWith('oidc/'))
|
||||
.map((item) => {'name': item.substring('oidc/'.length)})
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,18 +2,14 @@
|
||||
|
||||
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';
|
||||
@@ -22,22 +18,6 @@ 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 {
|
||||
@@ -53,13 +33,6 @@ class PlatformFFI {
|
||||
}
|
||||
|
||||
PlatformFFI._() {
|
||||
_videoFrameQueue = WebVideoFrameQueue(
|
||||
importFrame: _importVideoFrame,
|
||||
closeFrame: _closeVideoFrame,
|
||||
disposeImage: (image) => image.dispose(),
|
||||
onImportError: _handleVideoFrameImportError,
|
||||
onCallbackError: _handleVideoImageCallbackError,
|
||||
);
|
||||
window.document.addEventListener(
|
||||
'visibilitychange',
|
||||
(event) => {
|
||||
@@ -189,46 +162,6 @@ 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()));
|
||||
@@ -251,11 +184,6 @@ class PlatformFFI {
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<T?> invokeMethodWithResult<T>(String method,
|
||||
[dynamic arguments]) async {
|
||||
return null;
|
||||
}
|
||||
|
||||
// just for compilation
|
||||
void syncAndroidServiceAppDirConfigPath() {}
|
||||
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -10,6 +10,8 @@ final isWebDesktop_ = false;
|
||||
|
||||
final isDesktop_ = Platform.isWindows || Platform.isMacOS || Platform.isLinux;
|
||||
|
||||
String get screenInfo_ => '';
|
||||
|
||||
final isWebOnWindows_ = false;
|
||||
final isWebOnLinux_ = false;
|
||||
final isWebOnMacOS_ = false;
|
||||
|
||||
42
flutter/lib/plugin/common.dart
Normal file
42
flutter/lib/plugin/common.dart
Normal file
@@ -0,0 +1,42 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
18
flutter/lib/plugin/event.dart
Normal file
18
flutter/lib/plugin/event.dart
Normal file
@@ -0,0 +1,18 @@
|
||||
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']);
|
||||
}
|
||||
}
|
||||
79
flutter/lib/plugin/handlers.dart
Normal file
79
flutter/lib/plugin/handlers.dart
Normal file
@@ -0,0 +1,79 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
319
flutter/lib/plugin/manager.dart
Normal file
319
flutter/lib/plugin/manager.dart
Normal file
@@ -0,0 +1,319 @@
|
||||
// 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;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user