mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-09-11 06:51:00 +03:00
Compare commits
197 Commits
1.4.7
...
dxgi-reuse
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9343affe0b | ||
|
|
7c830e76c6 | ||
|
|
1fe451c2e8 | ||
|
|
0b08a83d4b | ||
|
|
e9b81e3475 | ||
|
|
7220f00410 | ||
|
|
fd471fcf02 | ||
|
|
7c6e661fcc | ||
|
|
3f207e91f6 | ||
|
|
cec4085238 | ||
|
|
0d917c6fa1 | ||
|
|
893dc27798 | ||
|
|
7cc82c1575 | ||
|
|
f07b6e2338 | ||
|
|
a3bab27a2a | ||
|
|
7423dced37 | ||
|
|
a7deef02a2 | ||
|
|
d5a7f67999 | ||
|
|
e266380ee9 | ||
|
|
cbf9440281 | ||
|
|
6eaac17ac5 | ||
|
|
92eb137178 | ||
|
|
61ddade049 | ||
|
|
c78bdefc44 | ||
|
|
c45f7d2dd2 | ||
|
|
798b73beb1 | ||
|
|
f1a06f6765 | ||
|
|
0a4b431ea2 | ||
|
|
5679670506 | ||
|
|
630b531108 | ||
|
|
1984678785 | ||
|
|
b0008edcb5 | ||
|
|
0c00d576dd | ||
|
|
9b1b810d3a | ||
|
|
6a27910f34 | ||
|
|
14a4b197ad | ||
|
|
8ffe3117a5 | ||
|
|
5a78be03e3 | ||
|
|
edd0e5fbd4 | ||
|
|
3871c47855 | ||
|
|
8d52d48b24 | ||
|
|
7aa98d43cf | ||
|
|
d1da05c4db | ||
|
|
d829d1410a | ||
|
|
c4fd7d692d | ||
|
|
dfca2c1b8f | ||
|
|
10bcf976f7 | ||
|
|
63822048df | ||
|
|
1d09760ef7 | ||
|
|
23256e6ac1 | ||
|
|
ff07ff7f13 | ||
|
|
947cb3f17b | ||
|
|
d407db9fae | ||
|
|
594e63805c | ||
|
|
7c23fd3073 | ||
|
|
2915076642 | ||
|
|
11190fa54e | ||
|
|
d057fe14b2 | ||
|
|
4234b99029 | ||
|
|
6fd96dda6e | ||
|
|
429c8c6711 | ||
|
|
9a81c8a138 | ||
|
|
ddad47925c | ||
|
|
f5ab01f8bd | ||
|
|
cc85685b96 | ||
|
|
7eb9150116 | ||
|
|
ef3a57580f | ||
|
|
402ed07b0c | ||
|
|
3cf32e7066 | ||
|
|
6f1eb164d6 | ||
|
|
4389687d9d | ||
|
|
a84bad4639 | ||
|
|
e6dd925ab0 | ||
|
|
d752823b8c | ||
|
|
2f8822ec7a | ||
|
|
ffe20bb297 | ||
|
|
a5018a022b | ||
|
|
6c69faaa1c | ||
|
|
b19f1ef76f | ||
|
|
807e05ea9a | ||
|
|
e0254d997e | ||
|
|
006b9737e4 | ||
|
|
5aeb4cf945 | ||
|
|
c6c53f094a | ||
|
|
e63df74715 | ||
|
|
9aeb54cf33 | ||
|
|
3442648afe | ||
|
|
8545b5ed98 | ||
|
|
72c052cb9a | ||
|
|
12f2de5959 | ||
|
|
a6708f40e7 | ||
|
|
85a5fefab8 | ||
|
|
d412d19872 | ||
|
|
4dd8e20392 | ||
|
|
dabdbf73bb | ||
|
|
d6ea170061 | ||
|
|
eefd22b205 | ||
|
|
5882346caa | ||
|
|
b1fad7bbed | ||
|
|
57456f0b52 | ||
|
|
cefff781d4 | ||
|
|
ad9dac1001 | ||
|
|
b4af82157b | ||
|
|
beaa754299 | ||
|
|
929e989f17 | ||
|
|
1c2dd71891 | ||
|
|
5b4d6baf47 | ||
|
|
7696b0ee51 | ||
|
|
20ab5ab0ad | ||
|
|
c01300be20 | ||
|
|
5f015c9da1 | ||
|
|
082a5a2a4e | ||
|
|
61f0944990 | ||
|
|
96e2a330b8 | ||
|
|
5abf4e9724 | ||
|
|
cf2b28faf9 | ||
|
|
bdb38c4730 | ||
|
|
fa418cace6 | ||
|
|
94a2a2bb4a | ||
|
|
865fe71c46 | ||
|
|
137298e05a | ||
|
|
685a89a171 | ||
|
|
12b5cc7f72 | ||
|
|
480e9e8234 | ||
|
|
29e1852a68 | ||
|
|
8314335b31 | ||
|
|
acb9f63e1d | ||
|
|
005a8b4a04 | ||
|
|
e2149974cc | ||
|
|
6c578292e8 | ||
|
|
28930c0463 | ||
|
|
37141afece | ||
|
|
493b14ba78 | ||
|
|
cf1de4de62 | ||
|
|
9fdb8410d3 | ||
|
|
a2b79462ab | ||
|
|
dce221be5a | ||
|
|
9d1ab3fba3 | ||
|
|
b3bd18845d | ||
|
|
435f6ec61d | ||
|
|
0497814004 | ||
|
|
4b1ef9e20d | ||
|
|
10d5250d23 | ||
|
|
2ee580d49d | ||
|
|
4a54029cac | ||
|
|
001848bf2f | ||
|
|
989bf80fe8 | ||
|
|
78b5f47668 | ||
|
|
97e9e44faa | ||
|
|
ff226f6d80 | ||
|
|
0cbdb6ffb3 | ||
|
|
b8117c5c34 | ||
|
|
a69614d464 | ||
|
|
58ee593e26 | ||
|
|
09bc9056c9 | ||
|
|
0c6df924d1 | ||
|
|
456817b4f4 | ||
|
|
16570ee34f | ||
|
|
2b40c61d8e | ||
|
|
dcc64cdeae | ||
|
|
2747d3d8b4 | ||
|
|
3c574a4182 | ||
|
|
311d4708e5 | ||
|
|
5cf4323d07 | ||
|
|
3976701ac6 | ||
|
|
9ded8d6ab2 | ||
|
|
cd2fff0655 | ||
|
|
10f61ffdc2 | ||
|
|
d72952bf93 | ||
|
|
a7c55db9ac | ||
|
|
bff47e2b81 | ||
|
|
3d478c4935 | ||
|
|
a658e987b7 | ||
|
|
7c8b0adc1e | ||
|
|
a732ebc3e1 | ||
|
|
30c0867e40 | ||
|
|
8f50ea64dc | ||
|
|
0797ebb695 | ||
|
|
c9391fb894 | ||
|
|
8a955888bf | ||
|
|
36e812e550 | ||
|
|
8baa995c7a | ||
|
|
f4a0535289 | ||
|
|
6665242edf | ||
|
|
3cdf1cce54 | ||
|
|
88ae00ba73 | ||
|
|
7c26575dbd | ||
|
|
93d064a9b0 | ||
|
|
bf206dc309 | ||
|
|
6d116cf1c9 | ||
|
|
1fc33218dc | ||
|
|
b73e5bbfa0 | ||
|
|
78533e428e | ||
|
|
cc7fe4efdc | ||
|
|
84af60c07e | ||
|
|
6426269d41 | ||
|
|
7c41f993fe |
@@ -2,6 +2,8 @@
|
||||
rustflags = ["-Ctarget-feature=+crt-static"]
|
||||
[target.i686-pc-windows-msvc]
|
||||
rustflags = ["-C", "target-feature=+crt-static", "-C", "link-args=/NODEFAULTLIB:MSVCRT"]
|
||||
[target.aarch64-pc-windows-msvc]
|
||||
rustflags = ["-Ctarget-feature=+crt-static"]
|
||||
[target.'cfg(target_os="macos")']
|
||||
rustflags = [
|
||||
"-C", "link-args=-sectcreate __CGPreLoginApp __cgpreloginapp /dev/null",
|
||||
|
||||
11
.github/dependabot.yml
vendored
11
.github/dependabot.yml
vendored
@@ -1,11 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "gitsubmodule"
|
||||
directory: "/"
|
||||
target-branch: "master"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
commit-message:
|
||||
prefix: "Git submodule"
|
||||
labels:
|
||||
- "dependencies"
|
||||
140
.github/patches/apply_flutter_3.44_source_patches.sh
vendored
Normal file
140
.github/patches/apply_flutter_3.44_source_patches.sh
vendored
Normal file
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env bash
|
||||
# Applies the Flutter 3.44-only source/pubspec changes on the fly, in CI only.
|
||||
#
|
||||
# Windows arm64 needs Flutter >= 3.44 (the first stable release shipping an arm64 Dart SDK +
|
||||
# engine), which renamed DialogTheme/TabBarTheme -> *Data and needs newer extended_text/
|
||||
# google_fonts. Every other platform is still on Flutter 3.24.5, where the old names/versions
|
||||
# are required, so these changes are kept OUT of the committed sources and applied here instead.
|
||||
#
|
||||
# Used by BOTH the Windows arm64 build (flutter-build.yml) and its dedicated bridge artifact
|
||||
# (bridge.yml) so they share an identical 3.44 source state -- the generated *.freezed.dart must
|
||||
# compile against the same Flutter/freezed version the arm64 build resolves.
|
||||
#
|
||||
# Remove this script (and commit the changes) once upstream bumps Flutter across the board.
|
||||
#
|
||||
# Run from the repository root. sed is used (not a git-apply patch) because the checked-out
|
||||
# sources are CRLF on the windows-11-arm runner; the substitutions below are anchor-free and
|
||||
# therefore CRLF-safe.
|
||||
set -euo pipefail
|
||||
|
||||
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
|
||||
sed -i '/static ThemeData lightTheme = ThemeData(/,/static ThemeData darkTheme = ThemeData(/s/dialogTheme: DialogThemeData(/dialogTheme: DialogThemeData(\
|
||||
backgroundColor: Colors.white,/' flutter/lib/common.dart
|
||||
sed -i '/static ThemeData darkTheme = ThemeData(/,/scrollbarTheme: scrollbarThemeDark,/s/dialogTheme: DialogThemeData(/dialogTheme: DialogThemeData(\
|
||||
backgroundColor: Color(0xFF18191E),/' flutter/lib/common.dart
|
||||
# Dependency bumps required by the newer Dart/Flutter:
|
||||
sed -i 's/extended_text: 14.0.0/extended_text: 15.0.2/' flutter/pubspec.yaml
|
||||
sed -i 's/google_fonts: \^6.2.1/google_fonts: ^8.1.0/' flutter/pubspec.yaml
|
||||
|
||||
# Fail loudly if any expected substitution did not produce the complete state.
|
||||
if ! is_complete_patch_state; then
|
||||
echo "Flutter 3.44 source patches did not produce the expected state." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git --no-pager diff -- flutter/lib/common.dart flutter/pubspec.yaml
|
||||
51
.github/patches/apply_flutter_3.44_web_patches.sh
vendored
Executable file
51
.github/patches/apply_flutter_3.44_web_patches.sh
vendored
Executable file
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
# Prepares a web build on Flutter 3.44.x. Companion to
|
||||
# apply_flutter_3.44_source_patches.sh (which it runs first): the web target
|
||||
# additionally needs qr_code_scanner's web implementation patched for the
|
||||
# dart:ui platformViewRegistry removal, and flutter/web/fonts refreshed with
|
||||
# the font paths the 3.44 engine requests for offline/air-gapped support
|
||||
# (rustdesk-server-pro#996; see flutter/web/fonts/sync_fonts.py).
|
||||
#
|
||||
# Run from the repository root with Flutter 3.44.x on PATH, then build:
|
||||
# bash .github/patches/apply_flutter_3.44_web_patches.sh
|
||||
# (cd flutter && flutter build web --release) # or ./web/js/flutter_build.py
|
||||
#
|
||||
# Idempotent. To undo the source changes locally:
|
||||
# git checkout -- flutter/lib/common.dart flutter/pubspec.yaml flutter/pubspec.lock
|
||||
set -euo pipefail
|
||||
|
||||
flutter --version | grep -q "Flutter 3\.44\." || {
|
||||
echo "Flutter 3.44.x must be on PATH; found:" >&2
|
||||
flutter --version | grep "^Flutter" >&2 || true
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Shared 3.44 source/pubspec patches own their complete-state validation.
|
||||
bash .github/patches/apply_flutter_3.44_source_patches.sh
|
||||
|
||||
# Populate the pub cache with the 3.44 dependency resolution.
|
||||
(cd flutter && flutter pub get)
|
||||
|
||||
# qr_code_scanner 1.0.1 (unmaintained) reads platformViewRegistry from
|
||||
# dart:ui, which Flutter 3.44 removed; point it at dart:ui_web instead. The
|
||||
# patched file also compiles on Flutter 3.24 (dart:ui_web exists there), so
|
||||
# mutating the shared pub cache is safe for other local builds.
|
||||
QR_WEB="${PUB_CACHE:-$HOME/.pub-cache}/hosted/pub.dev/qr_code_scanner-1.0.1/lib/src/web/flutter_qr_web.dart"
|
||||
if ! grep -qF "dart:ui_web" "$QR_WEB"; then
|
||||
sed -i.bak "s|import 'dart:ui' as ui;|import 'dart:ui' as ui; import 'dart:ui_web' as ui_web;|" "$QR_WEB"
|
||||
rm -f "$QR_WEB.bak"
|
||||
fi
|
||||
if grep -qF "ui.platformViewRegistry" "$QR_WEB"; then
|
||||
sed -i.bak "s|ui\.platformViewRegistry|ui_web.platformViewRegistry|g" "$QR_WEB"
|
||||
rm -f "$QR_WEB.bak"
|
||||
fi
|
||||
|
||||
# Mirror the fonts this engine version requests into flutter/web/fonts.
|
||||
python3 flutter/web/fonts/sync_fonts.py
|
||||
|
||||
# Fail loudly if any expected state is missing:
|
||||
grep -qF "import 'dart:ui' as ui; import 'dart:ui_web' as ui_web;" "$QR_WEB"
|
||||
grep -qF "ui_web.platformViewRegistry" "$QR_WEB"
|
||||
grep -qF 'google_fonts: ^8.1.0' flutter/pubspec.yaml
|
||||
|
||||
echo "Flutter 3.44 web patches applied."
|
||||
28
.github/workflows/bridge.yml
vendored
28
.github/workflows/bridge.yml
vendored
@@ -7,7 +7,6 @@ on:
|
||||
|
||||
env:
|
||||
CARGO_EXPAND_VERSION: "1.0.95"
|
||||
FLUTTER_VERSION: "3.22.3"
|
||||
FLUTTER_RUST_BRIDGE_VERSION: "1.80.1"
|
||||
RUST_VERSION: "1.75" # https://github.com/rustdesk/rustdesk/discussions/7503
|
||||
|
||||
@@ -18,10 +17,21 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
job:
|
||||
# Default bridge for every platform still on Flutter 3.24.5 (generated with 3.22.3).
|
||||
- {
|
||||
target: x86_64-unknown-linux-gnu,
|
||||
os: ubuntu-22.04,
|
||||
extra-build-args: "",
|
||||
flutter-version: "3.22.3",
|
||||
artifact-name: "bridge-artifact",
|
||||
}
|
||||
# Dedicated bridge for the Windows arm64 build (Flutter 3.44); runs in parallel.
|
||||
- {
|
||||
target: x86_64-unknown-linux-gnu,
|
||||
os: ubuntu-22.04,
|
||||
extra-build-args: "",
|
||||
flutter-version: "3.44.8",
|
||||
artifact-name: "bridge-artifact-flutter-3.44",
|
||||
}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
@@ -64,13 +74,13 @@ jobs:
|
||||
uses: actions/cache@6f8efc29b200d32929f49075959781ed54ec270c # v3
|
||||
with:
|
||||
path: /tmp/flutter_rust_bridge
|
||||
key: vcpkg-${{ matrix.job.arch }}
|
||||
key: bridge-${{ matrix.job.flutter-version }}
|
||||
|
||||
- name: Install flutter
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
flutter-version: ${{ matrix.job.flutter-version }}
|
||||
cache: true
|
||||
|
||||
- name: Install flutter rust bridge deps
|
||||
@@ -78,7 +88,15 @@ jobs:
|
||||
run: |
|
||||
cargo install cargo-expand --version ${{ env.CARGO_EXPAND_VERSION }} --locked
|
||||
cargo install flutter_rust_bridge_codegen --version ${{ env.FLUTTER_RUST_BRIDGE_VERSION }} --features "uuid" --locked
|
||||
pushd flutter && sed -i -e 's/extended_text: 14.0.0/extended_text: 13.0.0/g' pubspec.yaml && flutter pub get && popd
|
||||
if [[ "${{ matrix.job.flutter-version }}" == "3.22.3" ]]; then
|
||||
# Default Flutter 3.22.3: extended_text 14 needs a newer Dart, so downgrade for resolution.
|
||||
sed -i -e 's/extended_text: 14.0.0/extended_text: 13.0.0/g' flutter/pubspec.yaml
|
||||
else
|
||||
# Flutter 3.44 bridge for Windows arm64: match that build's source/pubspec state so the
|
||||
# generated *.freezed.dart compiles against the same Flutter/freezed it resolves.
|
||||
bash .github/patches/apply_flutter_3.44_source_patches.sh
|
||||
fi
|
||||
pushd flutter && flutter pub get && popd
|
||||
|
||||
- name: Run flutter rust bridge
|
||||
run: |
|
||||
@@ -88,7 +106,7 @@ jobs:
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: bridge-artifact
|
||||
name: ${{ matrix.job.artifact-name }}
|
||||
path: |
|
||||
./src/bridge_generated.rs
|
||||
./src/bridge_generated.io.rs
|
||||
|
||||
4
.github/workflows/ci.yml
vendored
4
.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: "120deac3062162151622ca4860575a33844ba10b"
|
||||
VCPKG_COMMIT_ID: "9e593bb18ea69cc5095e012465dcd675a822ed0d"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -81,6 +81,7 @@ jobs:
|
||||
# - { target: x86_64-apple-darwin , os: macos-10.15 }
|
||||
# - { target: x86_64-pc-windows-gnu , os: windows-2022 }
|
||||
# - { target: x86_64-pc-windows-msvc , os: windows-2022 }
|
||||
# - { target: aarch64-pc-windows-msvc , os: windows-11-arm }
|
||||
- { target: x86_64-unknown-linux-gnu , os: ubuntu-24.04 }
|
||||
# - { target: x86_64-unknown-linux-musl , os: ubuntu-20.04, use-cross: true }
|
||||
steps:
|
||||
@@ -123,7 +124,6 @@ jobs:
|
||||
gcc \
|
||||
git \
|
||||
g++ \
|
||||
libpam0g-dev \
|
||||
libasound2-dev \
|
||||
libunwind-dev \
|
||||
libgstreamer1.0-dev \
|
||||
|
||||
469
.github/workflows/flutter-build.yml
vendored
469
.github/workflows/flutter-build.yml
vendored
@@ -27,19 +27,24 @@ env:
|
||||
LLVM_VERSION: "15.0.6"
|
||||
FLUTTER_VERSION: "3.24.5"
|
||||
ANDROID_FLUTTER_VERSION: "3.24.5"
|
||||
# Windows arm64 only: the first stable Flutter to ship a native arm64 Windows Dart SDK +
|
||||
# engine is 3.44. Every other platform stays on FLUTTER_VERSION (3.24.5) until Windows 7
|
||||
# support is restored after the upstream-wide Flutter bump. The arm64 job patches the few
|
||||
# 3.44-only source/pubspec changes on the fly (see "Patch RustDesk sources for Flutter 3.44").
|
||||
FLUTTER_WINDOWS_ARM_VERSION: "3.44.8"
|
||||
# for arm64 linux because official Dart SDK does not work
|
||||
FLUTTER_ELINUX_VERSION: "3.16.9"
|
||||
TAG_NAME: "${{ inputs.upload-tag }}"
|
||||
VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite"
|
||||
# vcpkg version: 2025.08.27
|
||||
# vcpkg version: 2026.07.29
|
||||
# If we change the `VCPKG COMMIT_ID`, please remember:
|
||||
# 1. Call `$VCPKG_ROOT/vcpkg x-update-baseline` to update the baseline in `vcpkg.json`.
|
||||
# Or we may face build issue like
|
||||
# https://github.com/rustdesk/rustdesk/actions/runs/14414119794/job/40427970174
|
||||
# 2. Update the `VCPKG_COMMIT_ID` in `ci.yml` and `playground.yml`.
|
||||
VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b"
|
||||
VCPKG_COMMIT_ID: "9e593bb18ea69cc5095e012465dcd675a822ed0d"
|
||||
ARMV7_VCPKG_COMMIT_ID: "6f29f12e82a8293156836ad81cc9bf5af41fe836" # 2025.01.13, got "/opt/artifacts/vcpkg/vcpkg: No such file or directory" with latest version
|
||||
VERSION: "1.4.7"
|
||||
VERSION: "1.4.9"
|
||||
NDK_VERSION: "r28c"
|
||||
#signing keys env variable checks
|
||||
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"
|
||||
@@ -48,19 +53,57 @@ 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
|
||||
|
||||
build-RustDeskTempTopMostWindow:
|
||||
uses: ./.github/workflows/third-party-RustDeskTempTopMostWindow.yml
|
||||
with:
|
||||
upload-artifact: ${{ inputs.upload-artifact }}
|
||||
target: windows-2022
|
||||
configuration: Release
|
||||
platform: x64
|
||||
target_version: Windows10
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
job:
|
||||
- {
|
||||
target: windows-2022,
|
||||
platform: x64,
|
||||
}
|
||||
- {
|
||||
target: windows-11-arm,
|
||||
platform: ARM64,
|
||||
}
|
||||
with:
|
||||
upload-artifact: ${{ inputs.upload-artifact }}
|
||||
target: ${{ matrix.job.target }}
|
||||
configuration: Release
|
||||
platform: ${{ matrix.job.platform }}
|
||||
target_version: Windows10
|
||||
|
||||
build-for-windows-flutter:
|
||||
name: ${{ matrix.job.target }}
|
||||
@@ -76,9 +119,20 @@ jobs:
|
||||
target: x86_64-pc-windows-msvc,
|
||||
os: windows-2022,
|
||||
arch: x86_64,
|
||||
flutter-arch: x64,
|
||||
vcpkg-triplet: x64-windows-static,
|
||||
build-args: "--vram",
|
||||
}
|
||||
- {
|
||||
target: aarch64-pc-windows-msvc,
|
||||
os: windows-11-arm,
|
||||
arch: aarch64,
|
||||
flutter-arch: arm64,
|
||||
vcpkg-triplet: arm64-windows-static,
|
||||
# vram is x86/x64-only (NVENC needs CUDA, Intel MediaSDK needs __rdtsc);
|
||||
# no NV/Intel/AMD hardware exists on Windows-on-ARM, so vram stays disabled here.
|
||||
build-args: "",
|
||||
}
|
||||
# - { target: aarch64-pc-windows-msvc, os: windows-2022, arch: aarch64 }
|
||||
steps:
|
||||
- name: Export GitHub Actions cache environment variables
|
||||
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
|
||||
@@ -95,35 +149,92 @@ jobs:
|
||||
- name: Restore bridge files
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: bridge-artifact
|
||||
# arm64 is on Flutter 3.44, so it needs the bridge generated with the same Flutter
|
||||
# (its *.freezed.dart must match the freezed the arm64 build resolves). x64 and every
|
||||
# other platform keep the default 3.22.3-generated bridge.
|
||||
name: ${{ matrix.job.arch == 'aarch64' && 'bridge-artifact-flutter-3.44' || 'bridge-artifact' }}
|
||||
path: ./
|
||||
|
||||
- name: Install LLVM and Clang
|
||||
uses: KyleMayes/install-llvm-action@1a3da29f56261a1e1f937ec88f0856a9b8321d7e # v1
|
||||
uses: KyleMayes/install-llvm-action@ebc0426251bc40c7cd31162802432c68818ab8f0 # v2.0.9
|
||||
with:
|
||||
version: ${{ env.LLVM_VERSION }}
|
||||
|
||||
- name: Install flutter
|
||||
id: flutter
|
||||
# arm64 builds with FLUTTER_WINDOWS_ARM_VERSION (>=3.44); x64 stays on FLUTTER_VERSION.
|
||||
# subosito only ships an x64 Windows SDK (Flutter's release manifest lists x64 only),
|
||||
# so it installs x64 on both arches. The arm64 runner re-bootstraps Dart to arm64 in
|
||||
# the next step.
|
||||
uses: subosito/flutter-action@2783a3f08e1baf891508463f8c6653c258246225 # v2.12.0; https://github.com/subosito/flutter-action/issues/277
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
flutter-version: ${{ matrix.job.arch == 'aarch64' && env.FLUTTER_WINDOWS_ARM_VERSION || env.FLUTTER_VERSION }}
|
||||
architecture: x64
|
||||
|
||||
- name: Force arm64 Dart SDK + engine
|
||||
# The x64 SDK subosito installs bundles an x64 Dart with a matching engine-dart-sdk.stamp,
|
||||
# so update_dart_sdk.ps1 short-circuits (stamp matches -> return) and keeps x64 Dart;
|
||||
# `flutter build windows` then targets the Dart VM's arch = x64, even on this arm64 host.
|
||||
# On this native-arm64 runner (PROCESSOR_ARCHITECTURE=ARM64), deleting the stamp and
|
||||
# re-running update_dart_sdk.ps1 pulls the arm64 Dart (available since Flutter 3.44.0).
|
||||
# https://github.com/flutter/flutter/issues/186730#issuecomment-4573214964
|
||||
if: ${{ matrix.job.arch == 'aarch64' }}
|
||||
run: |
|
||||
$flutterRoot = "${{ steps.flutter.outputs['CACHE-PATH'] }}"
|
||||
Write-Host "PROCESSOR_ARCHITECTURE=$env:PROCESSOR_ARCHITECTURE"
|
||||
Write-Host "Flutter root: $flutterRoot"
|
||||
Remove-Item -Force "$flutterRoot\bin\cache\engine-dart-sdk.stamp" -ErrorAction SilentlyContinue
|
||||
& "$flutterRoot\bin\internal\update_dart_sdk.ps1"
|
||||
# Confirm the Dart we ended up with is arm64 ("on windows_arm64"); fail loudly if not.
|
||||
$dartVer = & "$flutterRoot\bin\dart.bat" --version 2>&1 | Out-String
|
||||
Write-Host $dartVer
|
||||
if ($dartVer -notmatch "windows_arm64") {
|
||||
Write-Error "Expected an arm64 Dart SDK but got: $dartVer"
|
||||
exit 1
|
||||
}
|
||||
& "$flutterRoot\bin\flutter.bat" precache --windows
|
||||
# Fail fast if precache pulled the wrong-arch Windows engine: an arm64 Dart should
|
||||
# fetch windows-arm64 engine artifacts. Bailing here saves the ~25min Rust build.
|
||||
$engineDir = "$flutterRoot\bin\cache\artifacts\engine"
|
||||
Write-Host "Engine artifacts present:"
|
||||
Get-ChildItem $engineDir -Directory | Select-Object -ExpandProperty Name | Write-Host
|
||||
if (-not (Test-Path "$engineDir\windows-arm64-release")) {
|
||||
Write-Error "Expected windows-arm64-release engine artifacts but they are missing (wrong-arch SDK)."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# https://github.com/flutter/flutter/issues/155685
|
||||
# x64 only: arm64 uses the stock native arm64 Windows engine, and the rustdesk/engine
|
||||
# windows-x64-release.zip is built for the 3.24-era x64 engine (matches FLUTTER_VERSION).
|
||||
- name: Replace engine with rustdesk custom flutter engine
|
||||
if: ${{ matrix.job.arch == 'x86_64' }}
|
||||
run: |
|
||||
flutter doctor -v
|
||||
flutter precache --windows
|
||||
Invoke-WebRequest -Uri https://github.com/rustdesk/engine/releases/download/main/windows-x64-release.zip -OutFile windows-x64-release.zip
|
||||
Expand-Archive -Path windows-x64-release.zip -DestinationPath windows-x64-release
|
||||
mv -Force windows-x64-release/* C:/hostedtoolcache/windows/flutter/stable-${{ env.FLUTTER_VERSION }}-x64/bin/cache/artifacts/engine/windows-x64-release/
|
||||
mv -Force windows-x64-release/* C:/hostedtoolcache/windows/flutter/stable-${{ env.FLUTTER_VERSION }}-x64/bin/cache/artifacts/engine/windows-x64-release/
|
||||
|
||||
- name: Patch flutter
|
||||
# x64 stays on Flutter 3.24.5, which needs the dropdown filter patch.
|
||||
# arm64 is on Flutter 3.44 (patched separately below) and does not use this patch.
|
||||
if: ${{ matrix.job.arch == 'x86_64' }}
|
||||
shell: bash
|
||||
run: |
|
||||
cp .github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff $(dirname $(dirname $(which flutter)))
|
||||
cd $(dirname $(dirname $(which flutter)))
|
||||
[[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]] && git apply flutter_3.24.4_dropdown_menu_enableFilter.diff
|
||||
if [[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]]; then
|
||||
git apply flutter_3.24.4_dropdown_menu_enableFilter.diff
|
||||
fi
|
||||
|
||||
- name: Patch RustDesk sources for Flutter 3.44 (arm64)
|
||||
# arm64 is the only target on Flutter 3.44; apply its source/pubspec deltas on the fly
|
||||
# (shared with the 3.44 bridge job) so the committed sources stay on Flutter 3.24.5.
|
||||
# `flutter build` then runs `pub get`, regenerating pubspec.lock for the bumped deps.
|
||||
if: ${{ matrix.job.arch == 'aarch64' }}
|
||||
shell: bash
|
||||
run: bash .github/patches/apply_flutter_3.44_source_patches.sh
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
|
||||
@@ -163,11 +274,19 @@ jobs:
|
||||
head -n 100 "${VCPKG_ROOT}/buildtrees/ffmpeg/build-${{ matrix.job.vcpkg-triplet }}-rel-out.log" || true
|
||||
shell: bash
|
||||
|
||||
- name: Set SODIUM_LIB_DIR (arm64)
|
||||
# libsodium-sys ships no arm64 Windows prebuilt lib; point it at the vcpkg-built one
|
||||
# (only for arm64 — leaving it unset lets x64 use the crate's bundled lib).
|
||||
if: ${{ matrix.job.arch == 'aarch64' }}
|
||||
shell: bash
|
||||
run: echo "SODIUM_LIB_DIR=$VCPKG_ROOT/installed/${{ matrix.job.vcpkg-triplet }}/lib" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build rustdesk
|
||||
run: |
|
||||
# Windows: build RustDesk
|
||||
python3 .\build.py --portable --hwcodec --flutter --vram --skip-portable-pack
|
||||
mv ./flutter/build/windows/x64/runner/Release ./rustdesk
|
||||
# --hwcodec is shared by all Windows targets; per-target extras (e.g. --vram) come from the matrix
|
||||
python3 .\build.py --portable --flutter --skip-portable-pack --hwcodec ${{ matrix.job.build-args }}
|
||||
mv ./flutter/build/windows/${{ matrix.job.flutter-arch }}/runner/Release ./rustdesk
|
||||
|
||||
# Download usbmmidd_v2.zip and extract it to ./rustdesk
|
||||
Invoke-WebRequest -Uri https://github.com/rustdesk-org/rdev/releases/download/usbmmidd_v2/usbmmidd_v2.zip -OutFile usbmmidd_v2.zip
|
||||
@@ -223,7 +342,7 @@ jobs:
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
if: ${{ inputs.upload-artifact }}
|
||||
with:
|
||||
name: topmostwindow-artifacts
|
||||
name: ${{ matrix.job.arch == 'aarch64' && 'topmostwindow-artifacts-ARM64' || 'topmostwindow-artifacts-x64' }}
|
||||
path: "./rustdesk"
|
||||
|
||||
- name: Upload unsigned
|
||||
@@ -256,13 +375,18 @@ jobs:
|
||||
uses: microsoft/setup-msbuild@6fb02220983dee41ce7ae257b6f4d8f9bf5ed4ce # v2
|
||||
|
||||
- name: Build msi
|
||||
# Builds the MSI for the matrix arch. res/msi (WiX v4 + native CustomActions) carries
|
||||
# both x64 and ARM64 platform configs; WcaUtil/DUtil ship arm64 libs. msbuild platform
|
||||
# is x64 / ARM64; the produced Package.msi is globbed since its bin/<platform>/ dir varies.
|
||||
if: env.UPLOAD_ARTIFACT == 'true'
|
||||
run: |
|
||||
pushd ./res/msi
|
||||
python preprocess.py --arp -d ../../rustdesk
|
||||
nuget restore msi.sln
|
||||
msbuild msi.sln -p:Configuration=Release -p:Platform=x64 /p:TargetVersion=Windows10
|
||||
mv ./Package/bin/x64/Release/en-us/Package.msi ../../SignOutput/rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.msi
|
||||
$msiPlatform = if ('${{ matrix.job.arch }}' -eq 'aarch64') { 'ARM64' } else { 'x64' }
|
||||
msbuild msi.sln -p:Configuration=Release -p:Platform=$msiPlatform /p:TargetVersion=Windows10
|
||||
$msi = Get-ChildItem ./Package/bin/*/Release/en-us/Package.msi | Select-Object -First 1
|
||||
mv $msi.FullName ../../SignOutput/rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.msi
|
||||
sha256sum ../../SignOutput/rustdesk-*.msi
|
||||
|
||||
- name: Sign rustdesk self-extracted file
|
||||
@@ -473,7 +597,9 @@ jobs:
|
||||
- name: Patch flutter
|
||||
run: |
|
||||
cd $(dirname $(dirname $(which flutter)))
|
||||
[[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]] && git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff
|
||||
if [[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]]; then
|
||||
git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff
|
||||
fi
|
||||
|
||||
- name: Setup vcpkg with Github Actions binary cache
|
||||
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
|
||||
@@ -652,7 +778,9 @@ jobs:
|
||||
- name: Patch flutter
|
||||
run: |
|
||||
cd $(dirname $(dirname $(which flutter)))
|
||||
[[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]] && git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff
|
||||
if [[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]]; then
|
||||
git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff
|
||||
fi
|
||||
|
||||
- name: Workaround for flutter issue
|
||||
shell: bash
|
||||
@@ -881,7 +1009,6 @@ jobs:
|
||||
libgstreamer1.0-dev \
|
||||
libgstreamer-plugins-base1.0-dev \
|
||||
libgtk-3-dev \
|
||||
libpam0g-dev \
|
||||
libpulse-dev \
|
||||
libva-dev \
|
||||
libxcb-randr0-dev \
|
||||
@@ -911,7 +1038,9 @@ jobs:
|
||||
- name: Patch flutter
|
||||
run: |
|
||||
cd $(dirname $(dirname $(which flutter)))
|
||||
[[ "3.24.5" == ${{env.ANDROID_FLUTTER_VERSION}} ]] && git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff
|
||||
if [[ "3.24.5" == ${{env.ANDROID_FLUTTER_VERSION}} ]]; then
|
||||
git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff
|
||||
fi
|
||||
|
||||
- uses: nttld/setup-ndk@ed92fe6cadad69be94a966a7ee3271275e62f779 # v1
|
||||
id: setup-ndk
|
||||
@@ -1153,7 +1282,6 @@ jobs:
|
||||
libgstreamer1.0-dev \
|
||||
libgstreamer-plugins-base1.0-dev \
|
||||
libgtk-3-dev \
|
||||
libpam0g-dev \
|
||||
libpulse-dev \
|
||||
libva-dev \
|
||||
libxcb-randr0-dev \
|
||||
@@ -1183,7 +1311,9 @@ jobs:
|
||||
- name: Patch flutter
|
||||
run: |
|
||||
cd $(dirname $(dirname $(which flutter)))
|
||||
[[ "3.24.5" == ${{env.ANDROID_FLUTTER_VERSION}} ]] && git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff
|
||||
if [[ "3.24.5" == ${{env.ANDROID_FLUTTER_VERSION}} ]]; then
|
||||
git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff
|
||||
fi
|
||||
|
||||
- name: Restore bridge files
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
@@ -1442,7 +1572,6 @@ jobs:
|
||||
libgstreamer1.0-dev \
|
||||
libgstreamer-plugins-base1.0-dev \
|
||||
libgtk-3-dev \
|
||||
libpam0g-dev \
|
||||
libpulse-dev \
|
||||
libva-dev \
|
||||
libxcb-randr0-dev \
|
||||
@@ -1627,6 +1756,275 @@ jobs:
|
||||
files: |
|
||||
res/rustdesk-${{ env.VERSION }}*.zst
|
||||
|
||||
# Same build as build-rustdesk-linux x86_64 -- same vcpkg/ffmpeg, same ubuntu18.04 container, same
|
||||
# rust and flutter -- only with the drm feature on, so it ships as the separate
|
||||
# rustdesk-unattended-wayland deb. libdrmtap is built on the runner because bionic's meson is too
|
||||
# old for it. A separate job rather than a matrix entry of build-rustdesk-linux: appimage and
|
||||
# flatpak need that job, and a failure here must not skip them.
|
||||
build-rustdesk-linux-drm:
|
||||
needs: [generate-bridge]
|
||||
name: build rustdesk linux drm x86_64
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Export GitHub Actions cache environment variables
|
||||
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
|
||||
with:
|
||||
script: |
|
||||
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
|
||||
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
|
||||
|
||||
- name: Maximize build space
|
||||
run: |
|
||||
sudo rm -rf /opt/ghc
|
||||
sudo rm -rf /usr/local/lib/android
|
||||
sudo rm -rf /usr/share/dotnet
|
||||
sudo apt-get update -y
|
||||
sudo apt-get install -y nasm
|
||||
sudo apt-get install -y qemu-user-static
|
||||
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Set Swap Space
|
||||
uses: pierotofy/set-swap-space@49819abfb41bd9b44fb781159c033dba90353a7c # v1.0
|
||||
with:
|
||||
swap-size-gb: 12
|
||||
|
||||
- name: Free Space
|
||||
run: |
|
||||
df -h
|
||||
free -m
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
|
||||
with:
|
||||
toolchain: ${{ env.RUST_VERSION }}
|
||||
targets: x86_64-unknown-linux-gnu
|
||||
components: "rustfmt"
|
||||
|
||||
- name: Save Rust toolchain version
|
||||
run: |
|
||||
RUST_TOOLCHAIN_VERSION=$(cargo --version | awk '{print $2}')
|
||||
echo "RUST_TOOLCHAIN_VERSION=$RUST_TOOLCHAIN_VERSION" >> $GITHUB_ENV
|
||||
|
||||
- name: Disable rust bridge build
|
||||
run: |
|
||||
# only build cdylib
|
||||
sed -i "s/\[\"cdylib\", \"staticlib\", \"rlib\"\]/\[\"cdylib\"\]/g" Cargo.toml
|
||||
|
||||
- name: Restore bridge files
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: bridge-artifact
|
||||
path: ./
|
||||
|
||||
- name: Setup vcpkg with Github Actions binary cache
|
||||
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
|
||||
with:
|
||||
vcpkgDirectory: /opt/artifacts/vcpkg
|
||||
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
|
||||
doNotCache: false
|
||||
|
||||
- name: Install vcpkg dependencies
|
||||
run: |
|
||||
sudo apt install -y libva-dev && apt show libva-dev
|
||||
if ! $VCPKG_ROOT/vcpkg \
|
||||
install \
|
||||
--triplet x64-linux \
|
||||
--x-install-root="$VCPKG_ROOT/installed"; then
|
||||
find "${VCPKG_ROOT}/" -name "*.log" | while read -r _1; do
|
||||
echo "$_1:"
|
||||
echo "======"
|
||||
cat "$_1"
|
||||
echo "======"
|
||||
echo ""
|
||||
done
|
||||
exit 1
|
||||
fi
|
||||
head -n 100 "${VCPKG_ROOT}/buildtrees/ffmpeg/build-x64-linux-rel-out.log" || true
|
||||
shell: bash
|
||||
|
||||
# The container's meson is too old to build libdrmtap, so build it here from the pin in
|
||||
# build.py and hand the .so to the container below via DRMTAP_PREBUILT_DIR.
|
||||
- name: Build libdrmtap
|
||||
run: |
|
||||
sudo apt-get install -y meson ninja-build pkg-config \
|
||||
libdrm-dev libegl1-mesa-dev libgles2-mesa-dev
|
||||
python3 - <<'PY'
|
||||
import importlib.util, sys
|
||||
spec = importlib.util.spec_from_file_location("b", "build.py")
|
||||
b = importlib.util.module_from_spec(spec)
|
||||
sys.argv = ["build.py"]
|
||||
spec.loader.exec_module(b)
|
||||
print(f"::notice::built {b.build_libdrmtap_so()}")
|
||||
PY
|
||||
shell: bash
|
||||
|
||||
- uses: rustdesk-org/run-on-arch-action@d3fcfbb632b84cf7f6bc772bfaaa2c2f4f8789a8 # no release tag; commit 2026-05-26
|
||||
name: Build rustdesk
|
||||
id: vcpkg
|
||||
with:
|
||||
arch: x86_64
|
||||
distro: ubuntu18.04
|
||||
githubToken: ${{ github.token }}
|
||||
setup: |
|
||||
ls -l "${PWD}"
|
||||
ls -l /opt/artifacts/vcpkg/installed
|
||||
dockerRunArgs: |
|
||||
--volume "${PWD}:/workspace"
|
||||
--volume "/opt/artifacts:/opt/artifacts"
|
||||
shell: /bin/bash
|
||||
install: |
|
||||
apt-get update -y
|
||||
echo -e "installing deps"
|
||||
apt-get install -y \
|
||||
build-essential \
|
||||
clang \
|
||||
cmake \
|
||||
curl \
|
||||
gcc \
|
||||
git \
|
||||
g++ \
|
||||
libayatana-appindicator3-dev \
|
||||
libasound2-dev \
|
||||
libclang-10-dev \
|
||||
libgstreamer1.0-dev \
|
||||
libgstreamer-plugins-base1.0-dev \
|
||||
libgtk-3-dev \
|
||||
libpulse-dev \
|
||||
libva-dev \
|
||||
libxcb-randr0-dev \
|
||||
libxcb-shape0-dev \
|
||||
libxcb-xfixes0-dev \
|
||||
libxdo-dev \
|
||||
libxfixes-dev \
|
||||
llvm-10-dev \
|
||||
nasm \
|
||||
ninja-build \
|
||||
pkg-config \
|
||||
tree \
|
||||
python3 \
|
||||
rpm \
|
||||
unzip \
|
||||
wget \
|
||||
xz-utils \
|
||||
libssl-dev
|
||||
# we have libopus compiled by us.
|
||||
apt-get remove -y libopus-dev || true
|
||||
# output devs
|
||||
ls -l ./
|
||||
tree -L 3 /opt/artifacts/vcpkg/installed
|
||||
run: |
|
||||
# disable git safe.directory
|
||||
git config --global --add safe.directory "*"
|
||||
# rust
|
||||
pushd /opt
|
||||
# do not use rustup, because memory overflow in qemu
|
||||
wget -O rust.tar.gz https://static.rust-lang.org/dist/rust-${{env.RUST_TOOLCHAIN_VERSION}}-x86_64-unknown-linux-gnu.tar.gz
|
||||
tar -zxvf rust.tar.gz > /dev/null && rm rust.tar.gz
|
||||
cd rust-${{env.RUST_TOOLCHAIN_VERSION}}-x86_64-unknown-linux-gnu && ./install.sh
|
||||
rm -rf rust-${{env.RUST_TOOLCHAIN_VERSION}}-x86_64-unknown-linux-gnu
|
||||
# edit config
|
||||
mkdir -p ~/.cargo/
|
||||
echo """
|
||||
[source.crates-io]
|
||||
registry = 'https://github.com/rust-lang/crates.io-index'
|
||||
""" > ~/.cargo/config
|
||||
cat ~/.cargo/config
|
||||
# start build
|
||||
pushd /workspace
|
||||
export VCPKG_ROOT=/opt/artifacts/vcpkg
|
||||
# use the .so built on the runner; build.py checks it is the pinned checkout
|
||||
export DRMTAP_PREBUILT_DIR=/workspace/third_party/libdrmtap/build-pkg
|
||||
# ask build.py for the features so this line and the packaging line cannot drift
|
||||
FEATURES=$(python3 ./build.py --flutter --drm --hwcodec --unix-file-copy-paste --print-features)
|
||||
# an empty or error-shaped value would silently build a stock binary
|
||||
for want in drm drm-wake; do
|
||||
case ",$FEATURES," in
|
||||
*",$want,"*) ;;
|
||||
*) echo "::error::build.py returned no '$want' feature: $FEATURES"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
cargo build --locked --lib --features "$FEATURES" --release
|
||||
rm -rf target/release/deps target/release/build
|
||||
rm -rf ~/.cargo
|
||||
|
||||
# Setup Flutter
|
||||
# disable git safe.directory
|
||||
git config --global --add safe.directory "*"
|
||||
export PATH=/opt/flutter/bin:$PATH
|
||||
pushd /opt
|
||||
wget https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_${{ env.FLUTTER_VERSION }}-stable.tar.xz
|
||||
tar xf flutter_linux_${{ env.FLUTTER_VERSION }}-stable.tar.xz
|
||||
flutter doctor -v
|
||||
|
||||
if [[ "3.24.5" == ${{ env.FLUTTER_VERSION }} ]]; then
|
||||
pushd /opt/flutter
|
||||
git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff
|
||||
popd
|
||||
fi
|
||||
|
||||
# build flutter
|
||||
pushd /workspace
|
||||
export CARGO_INCREMENTAL=0
|
||||
export DEB_ARCH=amd64
|
||||
python3 ./build.py --flutter --drm --hwcodec --unix-file-copy-paste --skip-cargo
|
||||
for name in rustdesk*??.deb; do
|
||||
mv "$name" "${name%%.deb}-x86_64.deb"
|
||||
done
|
||||
|
||||
# build.py can exit 0 on some inner failures, so check the artifact rather than the status.
|
||||
# The package name is the informed consent for consent-free capture, so a stock binary must
|
||||
# never ship under it: assert the bundled library AND the dlopen path in the binary.
|
||||
- name: Check the deb is a drm build
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Resolve by glob, not from env.VERSION: build.py names the deb from Cargo.toml, so a
|
||||
# hardcoded name fails with a bare exit 1 the first time those two drift.
|
||||
shopt -s nullglob
|
||||
debs=(rustdesk-unattended-wayland-*-x86_64.deb)
|
||||
if [ "${#debs[@]}" -ne 1 ]; then
|
||||
echo "::error::expected one rustdesk-unattended-wayland-*-x86_64.deb, found ${#debs[@]}: ${debs[*]-none}"
|
||||
exit 1
|
||||
fi
|
||||
deb="${debs[0]}"
|
||||
echo "DRM_DEB=$deb" >> "$GITHUB_ENV"
|
||||
contents="$(dpkg -c "$deb")"
|
||||
if [[ ! "$contents" =~ usr/lib/rustdesk/libdrmtap\.so\.0\.[0-9]+\.[0-9]+ ]]; then
|
||||
echo "::error::$deb has no versioned libdrmtap.so.0.x.y"
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$contents" != *"usr/lib/rustdesk/libdrmtap.so.0 ->"* ]]; then
|
||||
echo "::error::$deb has no libdrmtap.so.0 soname symlink"
|
||||
exit 1
|
||||
fi
|
||||
rm -rf /tmp/deb && dpkg-deb -R "$deb" /tmp/deb
|
||||
if ! grep -qF /usr/lib/rustdesk/libdrmtap.so.0 /tmp/deb/usr/share/rustdesk/lib/librustdesk.so; then
|
||||
echo "::error::$deb was not built with the drm feature"
|
||||
exit 1
|
||||
fi
|
||||
shell: bash
|
||||
|
||||
- name: Publish debian package
|
||||
if: env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
with:
|
||||
prerelease: true
|
||||
tag_name: ${{ env.TAG_NAME }}
|
||||
files: |
|
||||
${{ env.DRM_DEB }}
|
||||
|
||||
# No UPLOAD_ARTIFACT gate: on a PR this is the only way to get at the deb that was just built.
|
||||
# always(), because a deb that failed the check above is the one most worth downloading.
|
||||
- name: Upload deb
|
||||
if: always() && env.DRM_DEB != ''
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: ${{ env.DRM_DEB }}
|
||||
path: ${{ env.DRM_DEB }}
|
||||
|
||||
build-rustdesk-linux-sciter:
|
||||
if: ${{ inputs.upload-artifact }}
|
||||
runs-on: ${{ matrix.job.on }}
|
||||
@@ -1724,7 +2122,6 @@ jobs:
|
||||
libgstreamer-plugins-base1.0-dev \
|
||||
libgtk-3-dev \
|
||||
liblzma-dev \
|
||||
libpam0g-dev \
|
||||
libpulse-dev \
|
||||
libva-dev \
|
||||
libxcb-randr0-dev \
|
||||
@@ -1801,7 +2198,7 @@ jobs:
|
||||
mkdir -p ~/.cargo/
|
||||
echo """
|
||||
[source.crates-io]
|
||||
registry = 'https://github.com/rust-lang/crates.io-index'
|
||||
registry = 'sparse+https://index.crates.io/'
|
||||
""" > ~/.cargo/config
|
||||
cat ~/.cargo/config
|
||||
# install dependencies from vcpkg
|
||||
@@ -1890,6 +2287,7 @@ jobs:
|
||||
sudo apt-get install -y libarchive-tools libfuse2
|
||||
# set-up appimage-builder
|
||||
# https://github.com/AppImage/AppImageKit/issues/1395
|
||||
sudo pip3 install "setuptools_scm<10"
|
||||
sudo pip3 install git+https://github.com/rustdesk-org/appimage-builder.git
|
||||
# run appimage-builder
|
||||
pushd appimage
|
||||
@@ -2020,7 +2418,18 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
cd $(dirname $(dirname $(which flutter)))
|
||||
[[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]] && git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff
|
||||
if [[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]]; then
|
||||
git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff
|
||||
fi
|
||||
|
||||
- name: Patch sources for Flutter 3.44 web
|
||||
# No-op while the web stays on Flutter 3.24.5; makes this job work as-is
|
||||
# once FLUTTER_VERSION moves to 3.44.x (qr_code_scanner + fonts, see script).
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "${{ env.FLUTTER_VERSION }}" == 3.44.* ]]; then
|
||||
bash .github/patches/apply_flutter_3.44_web_patches.sh
|
||||
fi
|
||||
|
||||
# https://rustdesk.com/docs/en/dev/build/web/
|
||||
- name: Build web
|
||||
|
||||
5
.github/workflows/playground.yml
vendored
5
.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: "120deac3062162151622ca4860575a33844ba10b"
|
||||
VERSION: "1.4.7"
|
||||
VCPKG_COMMIT_ID: "9e593bb18ea69cc5095e012465dcd675a822ed0d"
|
||||
VERSION: "1.4.9"
|
||||
NDK_VERSION: "r26d"
|
||||
#signing keys env variable checks
|
||||
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"
|
||||
@@ -271,7 +271,6 @@ jobs:
|
||||
libgstreamer1.0-dev \
|
||||
libgstreamer-plugins-base1.0-dev \
|
||||
libgtk-3-dev \
|
||||
libpam0g-dev \
|
||||
libpulse-dev \
|
||||
libva-dev \
|
||||
libvdpau-dev \
|
||||
|
||||
@@ -45,16 +45,15 @@ jobs:
|
||||
run: |
|
||||
git clone https://github.com/rustdesk-org/RustDeskTempTopMostWindow RustDeskTempTopMostWindow
|
||||
|
||||
# Build. commit 53b548a5398624f7149a382000397993542ad796 is tag v0.3
|
||||
- name: Build the project
|
||||
run: |
|
||||
cd RustDeskTempTopMostWindow && git checkout 53b548a5398624f7149a382000397993542ad796
|
||||
cd RustDeskTempTopMostWindow && git checkout ecd8d6a139eee76845ea66423fb739af450fda90
|
||||
msbuild ${{ env.project_path }} -p:Configuration=${{ inputs.configuration }} -p:Platform=${{ inputs.platform }} /p:TargetVersion=${{ inputs.target_version }}
|
||||
|
||||
- name: Archive build artifacts
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: ${{ inputs.upload-artifact }}
|
||||
with:
|
||||
name: topmostwindow-artifacts
|
||||
name: topmostwindow-artifacts-${{ inputs.platform }}
|
||||
path: |
|
||||
./${{ env.build_output_dir }}/WindowInjection.dll
|
||||
|
||||
71
.github/workflows/update-webpki-roots.yml
vendored
Normal file
71
.github/workflows/update-webpki-roots.yml
vendored
Normal file
@@ -0,0 +1,71 @@
|
||||
name: Update webpki-roots
|
||||
|
||||
# Weekly refresh of the compiled-in TLS root certificates (the webpki-roots
|
||||
# crate, a snapshot of the Mozilla root store). Roots are otherwise frozen at
|
||||
# whatever Cargo.lock pins, so old builds miss newly added CAs and keep
|
||||
# removed (distrusted) ones. Changes go through a PR on purpose: added or
|
||||
# removed roots should be reviewed, not silently baked into releases.
|
||||
#
|
||||
# Note: PRs created with the default GITHUB_TOKEN do not trigger other
|
||||
# workflows (GitHub limitation). Close and reopen the PR, or push to its
|
||||
# branch, to run CI on it.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 3 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
# A manual dispatch overlapping the weekly run would race it force-pushing
|
||||
# the same branch; queue instead of overlapping, and never cancel a run
|
||||
# that may have already pushed.
|
||||
concurrency:
|
||||
group: update-webpki-roots
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
update:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
env:
|
||||
BRANCH: auto-update-webpki-roots
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Update webpki-roots in all lockfiles
|
||||
id: update
|
||||
run: |
|
||||
set -e
|
||||
git ls-files -z '*Cargo.lock' | while IFS= read -r -d '' lock; do
|
||||
dir=$(dirname "$lock")
|
||||
for v in $(sed -n '/name = "webpki-roots"/{n;s/.*version = "\(.*\)"/\1/p;}' "$lock" | sort -u); do
|
||||
echo "updating webpki-roots@$v in $dir"
|
||||
(cd "$dir" && cargo update -p "webpki-roots@$v")
|
||||
done
|
||||
done
|
||||
if git diff --quiet -- '*Cargo.lock'; then
|
||||
echo "changed=0" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changed=1" >> "$GITHUB_OUTPUT"
|
||||
git --no-pager diff -- '*Cargo.lock'
|
||||
fi
|
||||
|
||||
- name: Create pull request
|
||||
if: steps.update.outputs.changed == '1'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -e
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git checkout -B "$BRANCH"
|
||||
git add -- '*Cargo.lock'
|
||||
git commit -m "chore: update webpki-roots to latest Mozilla root store"
|
||||
git push -f origin "$BRANCH"
|
||||
if [ -z "$(gh pr list --head "$BRANCH" --state open --json number --jq '.[].number')" ]; then
|
||||
gh pr create \
|
||||
--title "chore: update webpki-roots to latest Mozilla root store" \
|
||||
--body "Automated weekly refresh of the compiled-in TLS root certificates (webpki-roots). Please review the added/removed roots. CI does not run automatically on PRs created by GITHUB_TOKEN; close and reopen this PR to trigger it."
|
||||
fi
|
||||
85
.github/workflows/wf-cliprdr-ci.yml
vendored
85
.github/workflows/wf-cliprdr-ci.yml
vendored
@@ -1,85 +0,0 @@
|
||||
name: wf-cliprdr CI
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
paths:
|
||||
- "libs/clipboard/src/windows/**"
|
||||
- "tests/test_invariant_wf_cliprdr.c"
|
||||
- ".github/workflows/wf-cliprdr-ci.yml"
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- "libs/clipboard/src/windows/**"
|
||||
- "tests/test_invariant_wf_cliprdr.c"
|
||||
- ".github/workflows/wf-cliprdr-ci.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: wf_cliprdr invariant test
|
||||
runs-on: windows-2022
|
||||
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up MSVC
|
||||
uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756
|
||||
with:
|
||||
arch: x64
|
||||
|
||||
- name: Setup vcpkg with GitHub Actions binary cache
|
||||
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
|
||||
with:
|
||||
vcpkgDirectory: C:\vcpkg
|
||||
doNotCache: false
|
||||
|
||||
- name: Install vcpkg dependency
|
||||
shell: pwsh
|
||||
run: |
|
||||
& "$env:VCPKG_ROOT\vcpkg.exe" install check:x64-windows --classic --x-install-root="$env:VCPKG_ROOT\installed"
|
||||
|
||||
- name: Build test
|
||||
shell: pwsh
|
||||
run: |
|
||||
$testRoot = Join-Path $env:GITHUB_WORKSPACE 'build\wf-cliprdr'
|
||||
New-Item -ItemType Directory -Force $testRoot | Out-Null
|
||||
|
||||
$testSource = (($env:GITHUB_WORKSPACE -replace '\\', '/') + '/tests/test_invariant_wf_cliprdr.c')
|
||||
$cmakeLists = @(
|
||||
'cmake_minimum_required(VERSION 3.20)'
|
||||
'project(test_invariant_wf_cliprdr C)'
|
||||
''
|
||||
'set(CMAKE_C_STANDARD 11)'
|
||||
'set(CMAKE_C_STANDARD_REQUIRED ON)'
|
||||
'set(CMAKE_C_EXTENSIONS OFF)'
|
||||
''
|
||||
'find_package(check CONFIG REQUIRED)'
|
||||
''
|
||||
'add_executable(test_invariant_wf_cliprdr'
|
||||
' "TEST_SOURCE"'
|
||||
')'
|
||||
''
|
||||
'target_link_libraries(test_invariant_wf_cliprdr PRIVATE'
|
||||
' $<$<TARGET_EXISTS:Check::check>:Check::check>'
|
||||
' $<$<NOT:$<TARGET_EXISTS:Check::check>>:Check::checkShared>'
|
||||
')'
|
||||
) -join [Environment]::NewLine
|
||||
$cmakeLists.Replace('TEST_SOURCE', $testSource) | Set-Content -NoNewline (Join-Path $testRoot 'CMakeLists.txt')
|
||||
|
||||
cmake -S $testRoot -B (Join-Path $testRoot 'out') -G "Visual Studio 17 2022" -A x64 -DCMAKE_TOOLCHAIN_FILE="$env:VCPKG_ROOT\scripts\buildsystems\vcpkg.cmake" -DVCPKG_TARGET_TRIPLET=x64-windows
|
||||
cmake --build (Join-Path $testRoot 'out') --config Release
|
||||
|
||||
- name: Run test
|
||||
shell: pwsh
|
||||
run: .\build\wf-cliprdr\out\Release\test_invariant_wf_cliprdr.exe
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -55,4 +55,6 @@ examples/**/target/
|
||||
vcpkg_installed
|
||||
flutter/lib/generated_plugin_registrant.dart
|
||||
libsciter.dylib
|
||||
flutter/web/
|
||||
flutter/web/
|
||||
# libdrmtap is cloned at build time by build.py (not a submodule)
|
||||
/third_party/libdrmtap/
|
||||
|
||||
26
AGENTS.md
26
AGENTS.md
@@ -61,6 +61,26 @@
|
||||
* Do not make formatting-only changes.
|
||||
* Keep naming/style consistent with nearby code.
|
||||
|
||||
### Comments
|
||||
|
||||
* Avoid comments unless they explain a non-obvious reason, constraint, or workaround.
|
||||
* Never restate what the code does; prefer clearer code instead.
|
||||
* If the code is self-explanatory, add no comment.
|
||||
|
||||
### Be minimally invasive
|
||||
|
||||
* Prefer purely additive changes: layer new (`#[cfg]`-gated) blocks or new functions around existing code instead of restructuring it. The ideal diff for a fix adds lines and modifies/deletes none.
|
||||
* Do not extract or reshape existing code just to enable your new code; look for a mechanism that leaves existing lines untouched (e.g. hide/show an existing object instead of refactoring its construction into a helper for rebuilding).
|
||||
* Accept a little duplication over a restructure. A new function that repeats a few lines of an existing one is a better diff than reshaping the original so both can share it.
|
||||
* Put new logic in self-contained functions in the module it belongs to (platform-specific logic in `src/platform/`, with `use` inside the function body to avoid churning shared import blocks). Call sites in shared files (`src/tray.rs`, `src/core_main.rs`, `src/server/connection.rs`, …) should be thin one-line hooks.
|
||||
|
||||
## Reviewing a PR
|
||||
|
||||
* Review only what the diff introduces. Verify ownership with `gh pr diff` before reporting a finding — if the offending lines are untouched context, it is a pre-existing problem, not this PR's.
|
||||
* List pre-existing problems in a separate section at the end, or leave out the ones that are not fatal. Never mix them into the findings the author has to fix.
|
||||
* Before re-reviewing, read the author's reply comments. Do not re-raise items they declined on scope grounds.
|
||||
* State a finding's consequence exactly: distinguish "the value is lost" from "the shortcut is inert but the value still saves".
|
||||
|
||||
## Localization (`src/lang/*.rs`)
|
||||
|
||||
Each file is a `HashMap<key, translation>`. Layout:
|
||||
@@ -84,3 +104,9 @@ Then translate that source into the file's target language (infer the language f
|
||||
* Preserve placeholders (`{}`) and escape sequences (`\n`, `\"`) exactly as in the source.
|
||||
* Do not translate brand or technical tokens: `RustDesk`, `Socks5`, `TLS`, `UAC`, `Wayland`, `X11`, `TCP`, `UDP`, `2FA`, `RDP`, `D3D`, etc.
|
||||
* Copy URL values (e.g. `doc_*` keys) verbatim from `en.rs`.
|
||||
|
||||
### Adding new keys (feature work)
|
||||
|
||||
* New English-text keys use sentence case, not Title Case: `Use ID whitelisting`, **not** `Use ID Whitelisting`. Acronyms (ID, IP, 2FA…) stay uppercase. Legacy Title-Case keys (e.g. `Use IP Whitelisting`) stay as-is — do not rename them.
|
||||
* Since the key itself is the English display text, a sentence-case key usually needs **no** `en.rs` entry; add one only when the display text must differ from the key (e.g. `*_tip` keys).
|
||||
* Append each new key to `template.rs` (with `""`) and to every `src/lang/*.rs` file (translated, or `""` if unsure), at the end of the list.
|
||||
|
||||
262
Cargo.lock
generated
262
Cargo.lock
generated
@@ -292,7 +292,7 @@ checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487"
|
||||
[[package]]
|
||||
name = "arboard"
|
||||
version = "3.4.0"
|
||||
source = "git+https://github.com/rustdesk-org/arboard#85be1218668ff218a7b170c9d424fde73e069914"
|
||||
source = "git+https://github.com/rustdesk-org/arboard#c7d5781f563176df9efd8df6287e823fb1b9bed5"
|
||||
dependencies = [
|
||||
"clipboard-win",
|
||||
"core-graphics 0.23.2",
|
||||
@@ -771,6 +771,26 @@ dependencies = [
|
||||
"syn 2.0.98",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bindgen"
|
||||
version = "0.72.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"cexpr",
|
||||
"clang-sys",
|
||||
"itertools 0.12.1",
|
||||
"log",
|
||||
"prettyplease",
|
||||
"proc-macro2 1.0.93",
|
||||
"quote 1.0.36",
|
||||
"regex",
|
||||
"rustc-hash 2.1.1",
|
||||
"shlex",
|
||||
"syn 2.0.98",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit_field"
|
||||
version = "0.10.2"
|
||||
@@ -966,27 +986,6 @@ dependencies = [
|
||||
"serde 1.0.228",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bzip2"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8"
|
||||
dependencies = [
|
||||
"bzip2-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bzip2-sys"
|
||||
version = "0.1.11+1.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "736a955f3fa7875102d57c82b8cac37ec45224a07fd32d58f9f7a186b6cd4cdc"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cacao"
|
||||
version = "0.4.0-beta2"
|
||||
@@ -1324,7 +1323,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "clipboard-master"
|
||||
version = "4.0.0-beta.6"
|
||||
source = "git+https://github.com/rustdesk-org/clipboard-master#ddc39f00a6211959489ae683aa6ae6eedf03a809"
|
||||
source = "git+https://github.com/rustdesk-org/clipboard-master#7762d74e38db37cfeb6ded88c964b9cdbddfb6db"
|
||||
dependencies = [
|
||||
"objc",
|
||||
"objc-foundation",
|
||||
@@ -1457,6 +1456,8 @@ dependencies = [
|
||||
"compression-core",
|
||||
"flate2",
|
||||
"memchr",
|
||||
"zstd",
|
||||
"zstd-safe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1527,12 +1528,6 @@ dependencies = [
|
||||
"unicode-xid 0.2.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "constant_time_eq"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc"
|
||||
|
||||
[[package]]
|
||||
name = "constant_time_eq"
|
||||
version = "0.2.6"
|
||||
@@ -2694,7 +2689,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3052,9 +3047,8 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
||||
|
||||
[[package]]
|
||||
name = "fuser"
|
||||
version = "0.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53274f494609e77794b627b1a3cddfe45d675a6b2e9ba9c0fdc8d8eee2184369"
|
||||
version = "0.16.0"
|
||||
source = "git+https://github.com/rustdesk-org/fuser?branch=refact/tag-0.16.0-cargo-1.75.0#a3c0babe4a533f8dbcff5bce59ae7f2424b8d877"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
@@ -3797,14 +3791,14 @@ dependencies = [
|
||||
"toml 0.7.8",
|
||||
"tungstenite",
|
||||
"url",
|
||||
"users 0.11.0",
|
||||
"users",
|
||||
"uuid",
|
||||
"webpki-roots 1.0.4",
|
||||
"webpki-roots 1.0.9",
|
||||
"webrtc",
|
||||
"whoami",
|
||||
"winapi 0.3.9",
|
||||
"x11 2.21.0",
|
||||
"zstd 0.13.1",
|
||||
"zstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3952,7 +3946,7 @@ checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4"
|
||||
[[package]]
|
||||
name = "hwcodec"
|
||||
version = "0.7.1"
|
||||
source = "git+https://github.com/rustdesk-org/hwcodec#398e5a8938dd8768ade0fcdc27ea80e8b4b38738"
|
||||
source = "git+https://github.com/rustdesk-org/hwcodec#778df1f99597722473b29443bac22ae6c23946fe"
|
||||
dependencies = [
|
||||
"bindgen 0.59.2",
|
||||
"cc",
|
||||
@@ -3998,7 +3992,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots 1.0.4",
|
||||
"webpki-roots 1.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4695,7 +4689,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "magnum-opus"
|
||||
version = "0.4.0"
|
||||
source = "git+https://github.com/rustdesk-org/magnum-opus#5cd2bf989c148662fa3a2d9d539a71d71fd1d256"
|
||||
source = "git+https://github.com/rustdesk-org/magnum-opus#588c6e1f9ed50c3a01fa64f3bd3e7cdb0378a114"
|
||||
dependencies = [
|
||||
"bindgen 0.59.2",
|
||||
"pkg-config",
|
||||
@@ -5938,37 +5932,6 @@ dependencies = [
|
||||
"winapi 0.3.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pam"
|
||||
version = "0.7.0"
|
||||
source = "git+https://github.com/rustdesk-org/pam#7bfd25510202cd269292cbdd7c71f3977a6fd762"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"pam-macros",
|
||||
"pam-sys",
|
||||
"users 0.10.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pam-macros"
|
||||
version = "0.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c94f3b9b97df3c6d4e51a14916639b24e02c7d15d1dba686ce9b1118277cb811"
|
||||
dependencies = [
|
||||
"proc-macro2 1.0.93",
|
||||
"quote 1.0.36",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pam-sys"
|
||||
version = "1.0.0-alpha4"
|
||||
source = "git+https://github.com/rustdesk-org/pam-sys?branch=fix/v1.0.0-alpha4_gnuc_va_list#3337c9bb9a9c68d7497ec8c93cad2368c26091b7"
|
||||
dependencies = [
|
||||
"bindgen 0.59.2",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pango"
|
||||
version = "0.18.3"
|
||||
@@ -6036,35 +5999,12 @@ dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "password-hash"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "paste"
|
||||
version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "pbkdf2"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"hmac",
|
||||
"password-hash",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "peeking_take_while"
|
||||
version = "0.1.2"
|
||||
@@ -6588,7 +6528,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "556af5f5c953a2ee13f45753e581a38f9778e6551bc3ccc56d90b14628fe59d8"
|
||||
dependencies = [
|
||||
"cfg-if 0.1.10",
|
||||
"rpassword 2.1.0",
|
||||
"rpassword",
|
||||
"tempfile",
|
||||
"termios 0.3.3",
|
||||
"winapi 0.3.9",
|
||||
@@ -6673,7 +6613,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2 0.5.10",
|
||||
"tracing",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6920,7 +6860,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "rdev"
|
||||
version = "0.5.0-2"
|
||||
source = "git+https://github.com/rustdesk-org/rdev#f9b60b1dd0f3300a1b797d7a74c116683cd232c8"
|
||||
source = "git+https://github.com/rustdesk-org/rdev#23e24dd6b35452a495dae0ae6d99395e9755ab0f"
|
||||
dependencies = [
|
||||
"cocoa 0.24.1",
|
||||
"core-foundation 0.9.4",
|
||||
@@ -7090,7 +7030,7 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"webpki-roots 1.0.4",
|
||||
"webpki-roots 1.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7152,17 +7092,6 @@ dependencies = [
|
||||
"winapi 0.2.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rpassword"
|
||||
version = "7.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "80472be3c897911d0137b2d2b9055faf6eeac5b14e324073d83bc17b191d7e3f"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rtoolbox",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rtcp"
|
||||
version = "0.14.0"
|
||||
@@ -7174,16 +7103,6 @@ dependencies = [
|
||||
"webrtc-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rtoolbox"
|
||||
version = "0.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c247d24e63230cdb56463ae328478bd5eac8b8faa8c69461a77e8e323afac90e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rtp"
|
||||
version = "0.14.0"
|
||||
@@ -7211,18 +7130,6 @@ dependencies = [
|
||||
"realfft",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "runas"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b96d6b6c505282b007a9b009f2aa38b2fd0359b81a0430ceacc60f69ade4c6a0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
"which",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-ini"
|
||||
version = "0.18.0"
|
||||
@@ -7270,7 +7177,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustdesk"
|
||||
version = "1.4.7"
|
||||
version = "1.4.9"
|
||||
dependencies = [
|
||||
"android-wakelock",
|
||||
"android_logger",
|
||||
@@ -7283,7 +7190,6 @@ dependencies = [
|
||||
"cfg-if 1.0.0",
|
||||
"chrono",
|
||||
"cidr-utils",
|
||||
"clap 4.5.53",
|
||||
"clipboard",
|
||||
"clipboard-master",
|
||||
"cocoa 0.24.1",
|
||||
@@ -7329,7 +7235,6 @@ dependencies = [
|
||||
"once_cell",
|
||||
"openssl",
|
||||
"os-version",
|
||||
"pam",
|
||||
"parity-tokio-ipc",
|
||||
"percent-encoding",
|
||||
"piet",
|
||||
@@ -7341,9 +7246,7 @@ dependencies = [
|
||||
"repng",
|
||||
"reqwest",
|
||||
"ringbuf",
|
||||
"rpassword 7.3.1",
|
||||
"rubato",
|
||||
"runas",
|
||||
"rust-pulsectl",
|
||||
"samplerate",
|
||||
"sciter-rs",
|
||||
@@ -7380,12 +7283,11 @@ dependencies = [
|
||||
"wol-rs",
|
||||
"x11-clipboard 0.8.1",
|
||||
"x11rb 0.12.0",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustdesk-portable-packer"
|
||||
version = "1.4.7"
|
||||
version = "1.4.9"
|
||||
dependencies = [
|
||||
"brotli",
|
||||
"dirs 5.0.1",
|
||||
@@ -7457,7 +7359,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.11.0",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7514,7 +7416,7 @@ dependencies = [
|
||||
"security-framework 3.5.1",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7601,7 +7503,7 @@ name = "scrap"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"bindgen 0.65.1",
|
||||
"bindgen 0.72.1",
|
||||
"block",
|
||||
"cfg-if 1.0.0",
|
||||
"dbus",
|
||||
@@ -8827,7 +8729,7 @@ dependencies = [
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls",
|
||||
"tungstenite",
|
||||
"webpki-roots 0.26.9",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8922,7 +8824,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c4ae9724c5888c0417d2396037ed3b60665925624766416e3e342b6ba5dbd3f"
|
||||
dependencies = [
|
||||
"base32",
|
||||
"constant_time_eq 0.2.6",
|
||||
"constant_time_eq",
|
||||
"hmac",
|
||||
"rand 0.8.5",
|
||||
"sha1",
|
||||
@@ -9141,7 +9043,7 @@ dependencies = [
|
||||
"sha1",
|
||||
"thiserror 2.0.17",
|
||||
"utf-8",
|
||||
"webpki-roots 0.26.9",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9367,16 +9269,6 @@ version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||
|
||||
[[package]]
|
||||
name = "users"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa4227e95324a443c9fcb06e03d4d85e91aabe9a5a02aa818688b6918b6af486"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "users"
|
||||
version = "0.11.0"
|
||||
@@ -9733,9 +9625,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wayland-protocols-wlr"
|
||||
version = "0.3.3"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd993de54a40a40fbe5601d9f1fbcaef0aebcc5fda447d7dc8f6dcbaae4f8953"
|
||||
checksum = "efd94963ed43cf9938a090ca4f7da58eb55325ec8200c3848963e98dc25b78ec"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"wayland-backend",
|
||||
@@ -9814,18 +9706,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.26.9"
|
||||
version = "0.26.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29aad86cec885cafd03e8305fd727c418e970a521322c91688414d5b8efba16b"
|
||||
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
"webpki-roots 1.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.4"
|
||||
version = "1.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e"
|
||||
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
@@ -10838,16 +10730,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wl-clipboard-rs"
|
||||
version = "0.9.0"
|
||||
version = "0.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4de22eebb1d1e2bad2d970086e96da0e12cde0b411321e5b0f7b2a1f876aa26f"
|
||||
checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"os_pipe",
|
||||
"rustix 0.38.34",
|
||||
"tempfile",
|
||||
"thiserror 1.0.61",
|
||||
"rustix 1.1.2",
|
||||
"thiserror 2.0.17",
|
||||
"tree_magic_mini",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
@@ -11173,52 +11064,13 @@ dependencies = [
|
||||
"syn 2.0.98",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "0.6.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"byteorder",
|
||||
"bzip2",
|
||||
"constant_time_eq 0.1.5",
|
||||
"crc32fast",
|
||||
"crossbeam-utils",
|
||||
"flate2",
|
||||
"hmac",
|
||||
"pbkdf2",
|
||||
"sha1",
|
||||
"time 0.3.36",
|
||||
"zstd 0.11.2+zstd.1.5.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd"
|
||||
version = "0.11.2+zstd.1.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4"
|
||||
dependencies = [
|
||||
"zstd-safe 5.0.2+zstd.1.5.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d789b1514203a1120ad2429eae43a7bd32b90976a7bb8a05f7ec02fa88cc23a"
|
||||
dependencies = [
|
||||
"zstd-safe 7.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd-safe"
|
||||
version = "5.0.2+zstd.1.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d2a5585e04f9eea4b2a3d1eca508c4dee9592a89ef6f450c11719da0726f4db"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"zstd-sys",
|
||||
"zstd-safe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
26
Cargo.toml
26
Cargo.toml
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rustdesk"
|
||||
version = "1.4.7"
|
||||
version = "1.4.9"
|
||||
authors = ["rustdesk <info@rustdesk.com>"]
|
||||
edition = "2021"
|
||||
build= "build.rs"
|
||||
@@ -22,7 +22,6 @@ path = "src/service.rs"
|
||||
|
||||
[features]
|
||||
inline = []
|
||||
cli = []
|
||||
use_samplerate = ["samplerate"]
|
||||
use_rubato = ["rubato"]
|
||||
use_dasp = ["dasp"]
|
||||
@@ -31,7 +30,13 @@ default = ["use_dasp"]
|
||||
hwcodec = ["scrap/hwcodec"]
|
||||
vram = ["scrap/vram"]
|
||||
mediacodec = ["scrap/mediacodec"]
|
||||
plugin_framework = []
|
||||
drm = ["scrap/drm"]
|
||||
# The display wake, as its OWN compile gate on top of `drm`. Everything else in the drm backend
|
||||
# READS (it captures a scanout); the wake WRITES, injecting one synthetic pointer event from the
|
||||
# root service so a compositor that idle-disabled its outputs re-enables them. That is a different
|
||||
# kind of operation and deserves a switch that can remove it from the binary entirely, without
|
||||
# giving up DRM capture: `--features drm` builds the capture path with no wake code compiled in.
|
||||
drm-wake = ["drm"]
|
||||
linux-pkg-config = ["magnum-opus/linux-pkg-config", "scrap/linux-pkg-config"]
|
||||
unix-file-copy-paste = [
|
||||
"dep:x11-clipboard",
|
||||
@@ -62,8 +67,6 @@ dasp = { version = "0.11", features = ["signal", "interpolate-linear", "interpol
|
||||
rubato = { version = "0.12", optional = true }
|
||||
samplerate = { version = "0.2", optional = true }
|
||||
uuid = { version = "1.3", features = ["v4"] }
|
||||
clap = "4.2"
|
||||
rpassword = "7.2"
|
||||
num_cpus = "1.15"
|
||||
bytes = { version = "1.4", features = ["serde"] }
|
||||
default-net = "0.14"
|
||||
@@ -77,12 +80,11 @@ 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"], default-features=false }
|
||||
reqwest = { version = "0.12", features = ["blocking", "socks", "json", "native-tls", "rustls-tls", "rustls-tls-native-roots", "gzip", "zstd"], default-features=false }
|
||||
|
||||
[target.'cfg(not(target_os = "linux"))'.dependencies]
|
||||
# https://github.com/rustdesk/rustdesk/discussions/10197, not use cpal on linux
|
||||
@@ -127,14 +129,18 @@ windows = { version = "0.61", features = [
|
||||
"Win32_Security_Authorization",
|
||||
"Win32_Storage_FileSystem",
|
||||
"Win32_System",
|
||||
"Win32_System_Com",
|
||||
"Win32_System_Diagnostics",
|
||||
"Win32_System_Diagnostics_ToolHelp",
|
||||
"Win32_System_Environment",
|
||||
"Win32_System_IO",
|
||||
"Win32_System_Memory",
|
||||
"Win32_System_Pipes",
|
||||
"Win32_System_Registry",
|
||||
"Win32_System_SystemInformation",
|
||||
"Win32_System_Threading",
|
||||
"Win32_UI_Shell",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
winreg = "0.11"
|
||||
windows-service = "0.6"
|
||||
@@ -143,7 +149,6 @@ remote_printer = { path = "libs/remote_printer" }
|
||||
impersonate_system = { git = "https://github.com/rustdesk-org/impersonate-system" }
|
||||
shared_memory = "0.12"
|
||||
tauri-winrt-notification = "0.1"
|
||||
runas = "1.2"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc = "0.2"
|
||||
@@ -184,7 +189,6 @@ async-process = "1.7"
|
||||
evdev = { git="https://github.com/rustdesk-org/evdev" }
|
||||
dbus = "0.9"
|
||||
dbus-crossroads = "0.5"
|
||||
pam = { git="https://github.com/rustdesk-org/pam" }
|
||||
x11-clipboard = {git="https://github.com/clslaid/x11-clipboard", branch = "feat/store-batch", optional = true}
|
||||
x11rb = {version = "0.12", features = ["all-extensions"], optional = true}
|
||||
percent-encoding = {version = "2.3", optional = true}
|
||||
@@ -205,7 +209,7 @@ android-wakelock = { git = "https://github.com/rustdesk-org/android-wakelock" }
|
||||
|
||||
[workspace]
|
||||
members = ["libs/scrap", "libs/hbb_common", "libs/enigo", "libs/clipboard", "libs/virtual_display", "libs/virtual_display/dylib", "libs/portable", "libs/remote_printer"]
|
||||
exclude = ["vdi/host", "examples/custom_plugin"]
|
||||
exclude = ["vdi/host"]
|
||||
|
||||
# Patch libxdo-sys to use a stub implementation that doesn't require libxdo
|
||||
# This allows building and running on systems without libxdo installed (e.g., Wayland-only)
|
||||
@@ -213,7 +217,7 @@ exclude = ["vdi/host", "examples/custom_plugin"]
|
||||
libxdo-sys = { path = "libs/libxdo-sys-stub" }
|
||||
|
||||
[package.metadata.winres]
|
||||
LegalCopyright = "Copyright © 2025 Purslane Ltd. All rights reserved."
|
||||
LegalCopyright = "Copyright © 2026 Purslane Tech Pte. Ltd. All rights reserved."
|
||||
ProductName = "RustDesk"
|
||||
FileDescription = "RustDesk Remote Desktop"
|
||||
OriginalFilename = "rustdesk.exe"
|
||||
|
||||
@@ -19,7 +19,6 @@ 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="#snapshot">Snapshot</a><br>
|
||||
<a href="#screenshots">Screenshots</a><br>
|
||||
[<a href="docs/README-UA.md">Українська</a>] | [<a href="docs/README-CS.md">česky</a>] | [<a href="docs/README-ZH.md">中文</a>] | [<a href="docs/README-HU.md">Magyar</a>] | [<a href="docs/README-ES.md">Español</a>] | [<a href="docs/README-FA.md">فارسی</a>] | [<a href="docs/README-FR.md">Français</a>] | [<a href="docs/README-DE.md">Deutsch</a>] | [<a href="docs/README-PL.md">Polski</a>] | [<a href="docs/README-ID.md">Indonesian</a>] | [<a href="docs/README-FI.md">Suomi</a>] | [<a href="docs/README-ML.md">മലയാളം</a>] | [<a href="docs/README-JP.md">日本語</a>] | [<a href="docs/README-NL.md">Nederlands</a>] | [<a href="docs/README-IT.md">Italiano</a>] | [<a href="docs/README-RU.md">Русский</a>] | [<a href="docs/README-PTBR.md">Português (Brasil)</a>] | [<a href="docs/README-EO.md">Esperanto</a>] | [<a href="docs/README-KR.md">한국어</a>] | [<a href="docs/README-AR.md">العربي</a>] | [<a href="docs/README-VN.md">Tiếng Việt</a>] | [<a href="docs/README-DA.md">Dansk</a>] | [<a href="docs/README-GR.md">Ελληνικά</a>] | [<a href="docs/README-TR.md">Türkçe</a>] | [<a href="docs/README-NO.md">Norsk</a>] | [<a href="docs/README-RO.md">Română</a>]<br>
|
||||
<b>We need your help to translate this README, <a href="https://github.com/rustdesk/rustdesk/tree/master/src/lang">RustDesk UI</a> and <a href="https://github.com/rustdesk/doc.rustdesk.com">RustDesk Doc</a> to your native language</b>
|
||||
</p>
|
||||
@@ -38,7 +38,7 @@ RustDesk welcomes contribution from everyone. See [CONTRIBUTING.md](docs/CONTRIB
|
||||
|
||||
## Dependencies
|
||||
|
||||
Desktop versions use Flutter or Sciter (deprecated) for GUI, this tutorial is for Sciter only, since it is easier and more friendly to start. Check out our [CI](https://github.com/rustdesk/rustdesk/blob/master/.github/workflows/flutter-build.yml) for building Flutter version.
|
||||
Desktop versions use Flutter or Sciter (deprecated) for GUI. This tutorial is for Sciter only, since it is easier and more friendly to start. Check out our [CI](https://github.com/rustdesk/rustdesk/blob/master/.github/workflows/flutter-build.yml) for building the Flutter version.
|
||||
|
||||
Please download Sciter dynamic library yourself.
|
||||
|
||||
@@ -66,19 +66,19 @@ Please download Sciter dynamic library yourself.
|
||||
```sh
|
||||
sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \
|
||||
libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \
|
||||
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev
|
||||
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
|
||||
```
|
||||
|
||||
### openSUSE Tumbleweed
|
||||
|
||||
```sh
|
||||
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel
|
||||
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel
|
||||
```
|
||||
|
||||
### Fedora 28 (CentOS 8)
|
||||
|
||||
```sh
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel
|
||||
```
|
||||
|
||||
### Arch (Manjaro)
|
||||
@@ -168,7 +168,6 @@ Please ensure that you run these commands from the root of the RustDesk reposito
|
||||
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Communicate with [rustdesk-server](https://github.com/rustdesk/rustdesk-server), wait for remote direct (TCP hole punching) or relayed connection
|
||||
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: platform specific code
|
||||
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Flutter code for desktop and mobile
|
||||
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/v1/js)**: JavaScript for Flutter web client
|
||||
|
||||
## Screenshots
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ AppDir:
|
||||
id: rustdesk
|
||||
name: rustdesk
|
||||
icon: rustdesk
|
||||
version: 1.4.7
|
||||
version: 1.4.9
|
||||
exec: usr/share/rustdesk/rustdesk
|
||||
exec_args: $@
|
||||
apt:
|
||||
@@ -58,7 +58,6 @@ AppDir:
|
||||
- libpulse0
|
||||
- packagekit-gtk3-module
|
||||
- libcanberra-gtk3-module
|
||||
- libpam0g
|
||||
- libdrm2
|
||||
exclude:
|
||||
- humanity-icon-theme
|
||||
@@ -77,6 +76,13 @@ AppDir:
|
||||
env:
|
||||
GIO_MODULE_DIR: /lib64/gio/modules:/usr/lib/aarch64-linux-gnu/gio/modules:$APPDIR/usr/lib/aarch64-linux-gnu/gio/modules
|
||||
GDK_BACKEND: x11
|
||||
# AppRun sets these to "$APPDIR/...:$XDG_DATA_DIRS", and setting them at all suppresses the XDG
|
||||
# defaults, so a host that leaves them unset loses /usr/share and /etc/xdg. gdk-pixbuf 2.43+
|
||||
# (Arch, Fedora) then finds no glycin loaders and every PNG decode fails, aborting on the first
|
||||
# remote cursor. The host value goes last: unset it expands to an empty element, which GLib
|
||||
# resolves against the CWD, and that must not outrank the defaults below.
|
||||
XDG_DATA_DIRS: $APPDIR/usr/local/share:$APPDIR/usr/share:/usr/local/share:/usr/share:$XDG_DATA_DIRS
|
||||
XDG_CONFIG_DIRS: $APPDIR/etc/xdg:/etc/xdg:$XDG_CONFIG_DIRS
|
||||
APPDIR_LIBRARY_PATH: /lib64:/usr/lib/aarch64-linux-gnu:$APPDIR/lib/aarch64-linux-gnu:$APPDIR/lib/aarch64-linux-gnu/security:$APPDIR/lib/systemd:$APPDIR/usr/lib/aarch64-linux-gnu:$APPDIR/usr/lib/aarch64-linux-gnu/gdk-pixbuf-2.0/2.10.0/loaders:$APPDIR/usr/lib/aarch64-linux-gnu/gstreamer-1.0:$APPDIR/usr/lib/aarch64-linux-gnu/gtk-3.0/3.0.0/immodules:$APPDIR/usr/lib/aarch64-linux-gnu/gtk-3.0/3.0.0/printbackends:$APPDIR/usr/lib/aarch64-linux-gnu/krb5/plugins/preauth:$APPDIR/usr/lib/aarch64-linux-gnu/libcanberra-0.30:$APPDIR/usr/lib/aarch64-linux-gnu/pulseaudio:$APPDIR/usr/lib/aarch64-linux-gnu/sasl2:$APPDIR/usr/lib/aarch64-linux-gnu/vdpau:$APPDIR/usr/share/rustdesk/lib:$APPDIR/lib/aarch64
|
||||
GST_PLUGIN_PATH: /lib64/gstreamer-1.0:/usr/lib/aarch64-linux-gnu/gstreamer-1.0:$APPDIR/usr/lib/aarch64-linux-gnu/gstreamer-1.0
|
||||
GST_PLUGIN_SYSTEM_PATH: /lib64/gstreamer-1.0:/usr/lib/aarch64-linux-gnu/gstreamer-1.0:$APPDIR/usr/lib/aarch64-linux-gnu/gstreamer-1.0
|
||||
|
||||
@@ -18,7 +18,7 @@ AppDir:
|
||||
id: rustdesk
|
||||
name: rustdesk
|
||||
icon: rustdesk
|
||||
version: 1.4.7
|
||||
version: 1.4.9
|
||||
exec: usr/share/rustdesk/rustdesk
|
||||
exec_args: $@
|
||||
apt:
|
||||
@@ -61,7 +61,6 @@ AppDir:
|
||||
- libpulse0
|
||||
- packagekit-gtk3-module
|
||||
- libcanberra-gtk3-module
|
||||
- libpam0g
|
||||
- libdrm2
|
||||
exclude:
|
||||
- humanity-icon-theme
|
||||
@@ -80,6 +79,13 @@ AppDir:
|
||||
env:
|
||||
GIO_MODULE_DIR: /lib64/gio/modules:/usr/lib/x86_64-linux-gnu/gio/modules:$APPDIR/usr/lib/x86_64-linux-gnu/gio/modules
|
||||
GDK_BACKEND: x11
|
||||
# AppRun sets these to "$APPDIR/...:$XDG_DATA_DIRS", and setting them at all suppresses the XDG
|
||||
# defaults, so a host that leaves them unset loses /usr/share and /etc/xdg. gdk-pixbuf 2.43+
|
||||
# (Arch, Fedora) then finds no glycin loaders and every PNG decode fails, aborting on the first
|
||||
# remote cursor. The host value goes last: unset it expands to an empty element, which GLib
|
||||
# resolves against the CWD, and that must not outrank the defaults below.
|
||||
XDG_DATA_DIRS: $APPDIR/usr/local/share:$APPDIR/usr/share:/usr/local/share:/usr/share:$XDG_DATA_DIRS
|
||||
XDG_CONFIG_DIRS: $APPDIR/etc/xdg:/etc/xdg:$XDG_CONFIG_DIRS
|
||||
APPDIR_LIBRARY_PATH: /lib64:/usr/lib/x86_64-linux-gnu:$APPDIR/lib/x86_64-linux-gnu:$APPDIR/lib/x86_64-linux-gnu/security:$APPDIR/lib/systemd:$APPDIR/usr/lib/x86_64-linux-gnu:$APPDIR/usr/lib/x86_64-linux-gnu/gdk-pixbuf-2.0/2.10.0/loaders:$APPDIR/usr/lib/x86_64-linux-gnu/gstreamer-1.0:$APPDIR/usr/lib/x86_64-linux-gnu/gtk-3.0/3.0.0/immodules:$APPDIR/usr/lib/x86_64-linux-gnu/gtk-3.0/3.0.0/printbackends:$APPDIR/usr/lib/x86_64-linux-gnu/krb5/plugins/preauth:$APPDIR/usr/lib/x86_64-linux-gnu/libcanberra-0.30:$APPDIR/usr/lib/x86_64-linux-gnu/pulseaudio:$APPDIR/usr/lib/x86_64-linux-gnu/sasl2:$APPDIR/usr/lib/x86_64-linux-gnu/vdpau:$APPDIR/usr/share/rustdesk/lib:$APPDIR/lib/x86_64
|
||||
GST_PLUGIN_PATH: /lib64/gstreamer-1.0:/usr/lib/x86_64-linux-gnu/gstreamer-1.0:$APPDIR/usr/lib/x86_64-linux-gnu/gstreamer-1.0
|
||||
GST_PLUGIN_SYSTEM_PATH: /lib64/gstreamer-1.0:/usr/lib/x86_64-linux-gnu/gstreamer-1.0:$APPDIR/usr/lib/x86_64-linux-gnu/gstreamer-1.0
|
||||
|
||||
541
build.py
541
build.py
@@ -1,23 +1,33 @@
|
||||
#!/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")
|
||||
hbb_name = 'rustdesk' + ('.exe' if windows else '')
|
||||
exe_path = 'target/release/' + hbb_name
|
||||
if windows:
|
||||
flutter_build_dir = 'build/windows/x64/runner/Release/'
|
||||
win_arch = 'arm64' if platform.machine().lower() in ('arm64', 'aarch64') else 'x64'
|
||||
flutter_build_dir = f'build/windows/{win_arch}/runner/Release/'
|
||||
elif osx:
|
||||
flutter_build_dir = 'build/macos/Build/Products/Release/'
|
||||
else:
|
||||
@@ -129,6 +139,19 @@ def make_parser():
|
||||
action='store_true',
|
||||
help='Build with unix file copy paste feature'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--drm',
|
||||
action='store_true',
|
||||
help='Linux only: build the DRM/KMS capture backend (bundles libdrmtap.so, '
|
||||
'dlopen-ed in-process by the root service). Off by default.'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--print-features',
|
||||
action='store_true',
|
||||
help='Print the cargo feature list these flags select, and exit without building. For a '
|
||||
'caller that runs its own cargo line and then packages with --skip-cargo: it can ask '
|
||||
'for the list rather than repeat it, so the two cannot drift.'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--skip-cargo',
|
||||
action='store_true',
|
||||
@@ -271,6 +294,24 @@ def external_resources(flutter, args, res_dir):
|
||||
shutil.copytree(f, f'{flutter_build_dir_2}{f.stem}')
|
||||
|
||||
|
||||
def linux_packaging_branch():
|
||||
"""Which packaging path `main()` will take on THIS host.
|
||||
|
||||
MUST mirror the elif chain in main() (pacman / yum / zypper / else), and exists so `--drm` can
|
||||
refuse a branch that is not drm-aware instead of silently producing a stock-named package with
|
||||
the capture backend compiled in. Only the final `deb` branch reaches `build_flutter_deb`, which
|
||||
is what bundles libdrmtap, renames the package, adds Conflicts/Provides and asserts the staged
|
||||
binary really is a drm build.
|
||||
"""
|
||||
if os.path.isfile('/usr/bin/pacman'):
|
||||
return 'pacman'
|
||||
if os.path.isfile('/usr/bin/yum'):
|
||||
return 'yum'
|
||||
if os.path.isfile('/usr/bin/zypper'):
|
||||
return 'zypper'
|
||||
return 'deb'
|
||||
|
||||
|
||||
def get_features(args):
|
||||
features = ['inline'] if not args.flutter else []
|
||||
if args.hwcodec:
|
||||
@@ -281,6 +322,30 @@ def get_features(args):
|
||||
features.append('flutter')
|
||||
if args.unix_file_copy_paste:
|
||||
features.append('unix-file-copy-paste')
|
||||
if args.drm:
|
||||
# Say so rather than quietly handing back a stock build: the backend is Linux-only, so on
|
||||
# any other host the flag cannot be honoured and the resulting binary would look like a
|
||||
# DRM build without being one.
|
||||
if windows or osx:
|
||||
raise Exception('--drm is Linux only')
|
||||
# And only on the deb branch. The other three Linux paths (pacman/yum/zypper) package
|
||||
# straight from `target/release` without bundling libdrmtap, without the rename, without
|
||||
# Conflicts/Provides and without assert_staged_binary_is_drm() -- so they would emit a
|
||||
# package NAMED `rustdesk` carrying the consent-bypass backend and the root-side uinput
|
||||
# injection. The separate package name is the informed consent this feature rests on, so
|
||||
# refuse rather than ship a stock-named build of it.
|
||||
branch = linux_packaging_branch()
|
||||
if branch != 'deb':
|
||||
raise Exception(
|
||||
f'--drm is only supported on the deb packaging path; this host would package via '
|
||||
f'{branch}, which cannot bundle libdrmtap or name the package distinctly')
|
||||
features.append('drm')
|
||||
# The display wake is its own compile gate on top of `drm`, and the unattended package is
|
||||
# exactly where it belongs: that variant exists to reach a machine nobody is sitting at,
|
||||
# and a machine whose screen went dark is the case it is for. Dropping `drm-wake` from
|
||||
# this line builds the same capture backend with no wake code in the binary at all.
|
||||
# It is ALSO switchable at runtime; see OPTION_ENABLE_DRM_DISPLAY_WAKE.
|
||||
features.append('drm-wake')
|
||||
if osx:
|
||||
if args.screencapturekit:
|
||||
features.append('screencapturekit')
|
||||
@@ -299,7 +364,7 @@ Version: %s
|
||||
Architecture: %s
|
||||
Maintainer: rustdesk <info@rustdesk.com>
|
||||
Homepage: https://rustdesk.com
|
||||
Depends: libgtk-3-0t64 | libgtk-3-0, libxcb-randr0, libxdo3 | libxdo4, libxfixes3, libxcb-shape0, libxcb-xfixes0, libasound2t64 | libasound2, libsystemd0, curl, libva2, libva-drm2, libva-x11-2, libgstreamer-plugins-base1.0-0, libpam0g, gstreamer1.0-pipewire%s
|
||||
Depends: libgtk-3-0t64 | libgtk-3-0, libxcb-randr0, libxdo3 | libxdo4, libxfixes3, libxcb-shape0, libxcb-xfixes0, libasound2t64 | libasound2, libsystemd0, curl, libva2, libva-drm2, libva-x11-2, libgstreamer-plugins-base1.0-0, gstreamer1.0-pipewire%s
|
||||
Recommends: libayatana-appindicator3-1
|
||||
Description: A remote control software.
|
||||
|
||||
@@ -315,6 +380,322 @@ def ffi_bindgen_function_refactor():
|
||||
'sed -i "s/ffi.NativeFunction<ffi.Bool Function(DartPort/ffi.NativeFunction<ffi.Uint8 Function(DartPort/g" flutter/lib/generated_bridge.dart')
|
||||
|
||||
|
||||
# libdrmtap is fetched at build time from the rustdesk-org fork at a pinned
|
||||
# commit — the same way rustdesk sources its other native build deps (vcpkg,
|
||||
# flutter_rust_bridge, ...), rather than carrying a git submodule. It is the ONLY
|
||||
# pin for the drm backend: rustdesk dlopens this .so at runtime and does not depend on
|
||||
# the libdrmtap-sys crate (whose build.rs would statically link the C tree, a helper and
|
||||
# libdrm/seccomp/cap). DRMTAP_REPO, DRMTAP_SHA and DRMTAP_PREBUILT_DIR override it for local testing
|
||||
# or another fork, and each requires DRMTAP_ALLOW_UNPINNED=1 alongside it (see below).
|
||||
# The commit is fetched directly by sha, so no branch or tag name takes part in the build: see
|
||||
# build_libdrmtap_so(). This is the SINGLE source of truth for the pin, deliberately not duplicated in
|
||||
# any workflow, so a bump is one edit here (plus the informational version comment in
|
||||
# libs/scrap/Cargo.toml). This commit is libdrmtap v0.5.4.
|
||||
LIBDRMTAP_REPO_PINNED = 'https://github.com/rustdesk-org/libdrmtap'
|
||||
LIBDRMTAP_SHA_PINNED = '5da68a3a368db569716d0d0f11cefacbb11b2290'
|
||||
LIBDRMTAP_REPO = os.environ.get('DRMTAP_REPO', LIBDRMTAP_REPO_PINNED)
|
||||
LIBDRMTAP_SHA = os.environ.get('DRMTAP_SHA', LIBDRMTAP_SHA_PINNED)
|
||||
# Every way of getting a different .so than the pin needs the same explicit opt-in. Otherwise the
|
||||
# claim this feature rests on -- that the privileged capture library is the reviewed object at
|
||||
# LIBDRMTAP_SHA_PINNED -- would hold only as long as nobody happened to have one of these set, and a
|
||||
# build that silently used something else would be indistinguishable from one that did not.
|
||||
# DRMTAP_PREBUILT_DIR is in the list because it is the widest of the three: it skips both the fetch
|
||||
# and the sha verification and hands over an object built from nothing this script can see.
|
||||
DRMTAP_UNPINNED_OK = os.environ.get('DRMTAP_ALLOW_UNPINNED') == '1'
|
||||
|
||||
|
||||
def _prebuilt_dir_is_the_pinned_checkout(prebuilt_dir):
|
||||
# A .so built from this repo's own third_party/libdrmtap at the pinned sha is the pinned object,
|
||||
# not an override, so it must not need the opt-in. This is how CI hands the library from a step
|
||||
# that has meson to a packaging container that does not.
|
||||
src = os.path.join(REPO_ROOT, 'third_party', 'libdrmtap')
|
||||
try:
|
||||
inside = os.path.commonpath([os.path.abspath(prebuilt_dir), src]) == src
|
||||
except ValueError:
|
||||
return False
|
||||
if not inside or not os.path.isdir(os.path.join(src, '.git')):
|
||||
return False
|
||||
try:
|
||||
head = subprocess.check_output(['git', '-C', src, 'rev-parse', 'HEAD']).decode().strip()
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
return False
|
||||
return head == LIBDRMTAP_SHA
|
||||
|
||||
|
||||
def _validate_libdrmtap_pin():
|
||||
# Called from build_libdrmtap_so(), NOT at import: a stock (non --drm) build must stay
|
||||
# byte-identical to upstream in behaviour too, and leftover DRMTAP_* variables in the
|
||||
# environment (or a malformed sha) must not be able to fail a build that never touches
|
||||
# libdrmtap.
|
||||
# `or None` so an empty value reads as unset here exactly as it does in build_libdrmtap_so(),
|
||||
# which tests it for truthiness.
|
||||
prebuilt = os.environ.get('DRMTAP_PREBUILT_DIR') or None
|
||||
if prebuilt and _prebuilt_dir_is_the_pinned_checkout(prebuilt):
|
||||
prebuilt = None
|
||||
overridden = [
|
||||
name
|
||||
for name, value, pinned in (
|
||||
('DRMTAP_REPO', LIBDRMTAP_REPO, LIBDRMTAP_REPO_PINNED),
|
||||
('DRMTAP_SHA', LIBDRMTAP_SHA, LIBDRMTAP_SHA_PINNED),
|
||||
('DRMTAP_PREBUILT_DIR', prebuilt, None),
|
||||
)
|
||||
if value != pinned
|
||||
]
|
||||
if overridden and not DRMTAP_UNPINNED_OK:
|
||||
raise Exception(
|
||||
f'{", ".join(overridden)} would build libdrmtap from something other than the pinned '
|
||||
f'{LIBDRMTAP_REPO_PINNED} at {LIBDRMTAP_SHA_PINNED}. That is supported for local work and '
|
||||
'cross-builds, but it has to be deliberate: set DRMTAP_ALLOW_UNPINNED=1 as well.')
|
||||
if overridden:
|
||||
print(f'WARNING: libdrmtap is NOT the pinned build ({", ".join(overridden)} set)')
|
||||
# Both are interpolated into shell commands below, and both are env-overridable, so validate
|
||||
# their SHAPE before they get there. This is not only about a hostile environment: a truncated
|
||||
# or abbreviated sha would otherwise reach `git fetch` and fail with something far less obvious
|
||||
# than saying so here, and an abbreviated one would defeat the point of pinning.
|
||||
if not re.fullmatch(r'[0-9a-f]{40}', LIBDRMTAP_SHA):
|
||||
raise Exception(
|
||||
f'DRMTAP_SHA must be a full 40-character commit sha, got {LIBDRMTAP_SHA!r}')
|
||||
if not re.fullmatch(r'(https://|git@)[A-Za-z0-9._~:/@-]+', LIBDRMTAP_REPO):
|
||||
raise Exception(f'DRMTAP_REPO does not look like a git remote url: {LIBDRMTAP_REPO!r}')
|
||||
|
||||
|
||||
def _single_real_so(paths, where):
|
||||
# Return the one real libdrmtap.so.0.* object among `paths`, failing if there are zero or several.
|
||||
# glob order is arbitrary, so silently taking [0] could ship a stale or wrong-arch object left
|
||||
# over from an earlier build; a mismatch should fail the build loudly instead.
|
||||
real = sorted(p for p in paths if os.path.isfile(p) and not os.path.islink(p))
|
||||
if len(real) != 1:
|
||||
raise Exception(
|
||||
f'expected exactly one real libdrmtap.so.0.* in {where}, found {len(real)}: {real}')
|
||||
return real[0]
|
||||
|
||||
|
||||
def build_libdrmtap_so():
|
||||
# Build libdrmtap.so from the rustdesk-org fork, fetched at the pinned LIBDRMTAP_SHA. The
|
||||
# pivot dlopen-s this .so in-process in the root service (which already holds
|
||||
# CAP_SYS_ADMIN) — no setcap helper, no privileged child. Only the shared
|
||||
# library target is built (the source also carries a helper binary we do not
|
||||
# ship). Returns the path to the built versioned .so (e.g. libdrmtap.so.0.4.x).
|
||||
_validate_libdrmtap_pin()
|
||||
# Allow a caller (e.g. CI) to build the .so ahead of time and hand it in via
|
||||
# DRMTAP_PREBUILT_DIR (must contain the real libdrmtap.so.0.* object).
|
||||
prebuilt_dir = os.environ.get('DRMTAP_PREBUILT_DIR')
|
||||
if prebuilt_dir:
|
||||
# DRMTAP_PREBUILT_DIR explicitly names the artifact source, so honor it strictly: fail
|
||||
# (rather than silently falling back to a source build) if it holds no single real .so.
|
||||
prebuilt = glob.glob(os.path.join(prebuilt_dir, 'libdrmtap.so.0.*'))
|
||||
so = _single_real_so(prebuilt, f'DRMTAP_PREBUILT_DIR={prebuilt_dir}')
|
||||
# Check the stub case HERE too, not only on the source path below. This is the widest
|
||||
# override of the three -- no fetch, no sha verification, an object built by something this
|
||||
# script cannot see -- so it is the likeliest to hand over a CPU-only build, and skipping the
|
||||
# assertion on exactly this path would leave the check guarding only the case that was
|
||||
# already trustworthy.
|
||||
_assert_so_has_egl(so)
|
||||
return so
|
||||
# Fetch the pinned source if it is not already present. third_party/libdrmtap is not a submodule
|
||||
# anymore; it is git-ignored. The commit is fetched BY SHA rather than by cloning a branch:
|
||||
# `clone --depth 1 --branch main` only ever fetches the tip, so the moment upstream pushes to
|
||||
# `main` the pinned commit is not in the shallow clone at all and the build fails on an unreachable
|
||||
# object. Fetching the sha needs no branch name, so it keeps working across every upstream push and
|
||||
# is immune to a ref being moved or repointed.
|
||||
src = os.path.join(REPO_ROOT, 'third_party', 'libdrmtap')
|
||||
if not os.path.exists(os.path.join(src, 'meson.build')):
|
||||
if os.path.isdir(src):
|
||||
shutil.rmtree(src)
|
||||
os.makedirs(src, exist_ok=True)
|
||||
system2(f'git -C "{src}" init -q')
|
||||
system2(f'git -C "{src}" remote add origin {LIBDRMTAP_REPO}')
|
||||
system2(f'git -C "{src}" fetch --depth 1 origin {LIBDRMTAP_SHA}')
|
||||
system2(f'git -C "{src}" checkout -q FETCH_HEAD')
|
||||
# Verify the pin whenever the source is a GIT checkout. A fetch by sha cannot resolve to anything
|
||||
# else, so this now guards the OTHER case: a reused checkout left by an earlier build at a
|
||||
# different pin, which is what a bump leaves behind. Reject and remove it so the next run re-fetches
|
||||
# cleanly. A NON-git tree placed here on purpose (a developer building unreleased local libdrmtap
|
||||
# source) has nothing to verify and is used as-is.
|
||||
if os.path.isdir(os.path.join(src, '.git')):
|
||||
got_sha = subprocess.check_output(
|
||||
['git', '-C', src, 'rev-parse', 'HEAD']).decode().strip()
|
||||
if got_sha != LIBDRMTAP_SHA:
|
||||
shutil.rmtree(src, ignore_errors=True)
|
||||
raise Exception(
|
||||
f'libdrmtap at {src} is {got_sha}, expected {LIBDRMTAP_SHA} '
|
||||
f'(stale checkout from a different pin; removed, re-run to re-fetch)')
|
||||
build_dir = os.path.join(src, 'build-pkg')
|
||||
if not os.path.exists(os.path.join(build_dir, 'build.ninja')):
|
||||
system2(f'meson setup "{build_dir}" "{src}" --buildtype=release')
|
||||
# Build only the shared library, not the bundled helper binary or the static archive. Since
|
||||
# libdrmtap 0.4.11 the project is `both_libraries` (a version-scripted .so + a static .a), so the
|
||||
# bare `drmtap` target is ambiguous ("drmtap:shared_library" vs "drmtap:static_library"); ask for
|
||||
# the shared one explicitly (rustdesk dlopens the .so and never needs the archive).
|
||||
system2(f'meson compile -C "{build_dir}" drmtap:shared_library')
|
||||
sos = glob.glob(os.path.join(build_dir, 'libdrmtap.so.0.*'))
|
||||
# keep the real object (libdrmtap.so.0.4.x), not the .so/.so.0 symlinks or meson's .p dir, and
|
||||
# require exactly one so a stale object from an earlier build is never silently picked.
|
||||
so = _single_real_so(sos, f'the libdrmtap meson build dir {build_dir}')
|
||||
_assert_so_has_egl(so)
|
||||
return so
|
||||
|
||||
|
||||
def _assert_so_has_egl(so_path):
|
||||
# libdrmtap treats egl/glesv2 as OPTIONAL dependencies: without their headers and pkg-config
|
||||
# files, meson silently builds a CPU-only stub. That stub still exports every symbol the loader
|
||||
# checks for, so nothing downstream notices -- and the split architecture depends entirely on the
|
||||
# unprivileged side EGL-detiling the scanout it receives. The result is a build where DRM capture
|
||||
# quietly degrades to PipeWire on every tiled-scanout host, which is most of them.
|
||||
#
|
||||
# Assert on the ARTIFACT rather than passing an option that demands it: `-Degl=enabled` exists
|
||||
# only in libdrmtap past 0.4.15, and checking what was actually produced also catches a stale or
|
||||
# hand-substituted object, which a build flag cannot.
|
||||
#
|
||||
# EGL is reached by lazy dlopen, on purpose, so that the privileged service never links the GPU
|
||||
# stack. That means there is no DT_NEEDED to look for and an ELF-level check reports "no EGL" on a
|
||||
# perfectly good library; the dlopen name and an extension symbol are what a CPU-only stub really
|
||||
# lacks.
|
||||
try:
|
||||
with open(so_path, 'rb') as f:
|
||||
blob = f.read()
|
||||
except OSError as err:
|
||||
raise Exception(f'cannot read the built libdrmtap at {so_path}: {err}') from err
|
||||
missing = [m for m in (b'libEGL.so.1', b'eglCreateImageKHR') if m not in blob]
|
||||
if missing:
|
||||
raise Exception(
|
||||
f'{so_path} looks like a CPU-only libdrmtap stub (missing '
|
||||
f'{", ".join(m.decode() for m in missing)}): the EGL detile path the split capture '
|
||||
'depends on is not in it, and DRM capture would silently fall back to PipeWire. '
|
||||
'Install the EGL development packages and rebuild (Debian/Ubuntu: libegl-dev '
|
||||
'libgles2-mesa-dev; Arch: mesa libglvnd).')
|
||||
|
||||
|
||||
DRM_PACKAGE_NAME = 'rustdesk-unattended-wayland'
|
||||
|
||||
|
||||
def assert_so_satisfies_the_runtime_abi_gate(so_path):
|
||||
"""The .so we are about to ship must be one the RUNTIME will actually accept.
|
||||
|
||||
`abi_accepted` in libs/scrap/src/common/drmtap_dl.rs is the only place the pinned library's
|
||||
version is ever validated, and it runs at dlopen time on the USER's machine. Nothing in the
|
||||
build or in CI compared the two, so the pin and the gate could drift apart and every existing
|
||||
assertion would still pass: the EGL check does not look at the version, the CI symbol contract
|
||||
does not call drmtap_version(), and the deb-contents regex matches any `libdrmtap.so.0.X.Y`.
|
||||
A green pipeline could therefore produce a deb in which DRM capture can never start, and the
|
||||
only symptom on the host is one log line before it falls back to the portal.
|
||||
|
||||
So parse the gate out of the Rust and apply it here, to the object being staged. This is the
|
||||
same rule, not a copy of the numbers: if someone bumps the constants, this reads the new ones.
|
||||
"""
|
||||
m = re.search(r'libdrmtap\.so\.(\d+)\.(\d+)\.(\d+)', os.path.basename(so_path))
|
||||
if not m:
|
||||
# Not a versioned soname (a local dev build, say). The gate cannot be evaluated, and
|
||||
# inventing a verdict would be worse than saying so.
|
||||
print(f'[drm] cannot read a version out of {so_path}; skipping the ABI-gate cross-check')
|
||||
return
|
||||
so_ver = tuple(int(g) for g in m.groups())
|
||||
# REPO_ROOT, not abspath(__file__): both callers have chdir'd into flutter/ by now.
|
||||
gate_path = os.path.join(REPO_ROOT, 'libs', 'scrap', 'src', 'common', 'drmtap_dl.rs')
|
||||
with open(gate_path) as f:
|
||||
gate_src = f.read()
|
||||
|
||||
def _const(name):
|
||||
mm = re.search(rf'const {name}: c_int = (\d+);', gate_src)
|
||||
return int(mm.group(1)) if mm else None
|
||||
|
||||
major, minor = _const('DRMTAP_ABI_MAJOR'), _const('DRMTAP_ABI_MINOR')
|
||||
mm = re.search(r'const DRMTAP_MIN_MINOR_PATCH: \(c_int, c_int\) = \((\d+), (\d+)\);', gate_src)
|
||||
floor = (int(mm.group(1)), int(mm.group(2))) if mm else None
|
||||
if major is None or minor is None or floor is None:
|
||||
raise Exception(
|
||||
'could not parse the libdrmtap ABI gate out of drmtap_dl.rs (DRMTAP_ABI_MAJOR / '
|
||||
'DRMTAP_ABI_MINOR / DRMTAP_MIN_MINOR_PATCH). The gate moved and this check did not; '
|
||||
'fix the check rather than removing it, or the pin and the gate can drift silently.')
|
||||
accepted = so_ver[0] == major and so_ver[1] == minor and (so_ver[1], so_ver[2]) >= floor
|
||||
if not accepted:
|
||||
raise Exception(
|
||||
f'the libdrmtap being packaged is {so_ver[0]}.{so_ver[1]}.{so_ver[2]}, which the '
|
||||
f'runtime loader would REFUSE: drmtap_dl.rs accepts exactly major {major}, minor '
|
||||
f'{minor}, patch >= {floor[1]}. Shipping it produces a deb whose DRM capture can never '
|
||||
'start. Move the build pin and the gate together, or fix whichever one is wrong.')
|
||||
print(f'[drm] libdrmtap {so_ver[0]}.{so_ver[1]}.{so_ver[2]} satisfies the runtime ABI gate '
|
||||
f'(major {major}, minor {minor}, patch >= {floor[1]})')
|
||||
|
||||
|
||||
def stage_libdrmtap_into_deb(so_path):
|
||||
# Put the built libdrmtap object plus its soname symlink into the staged deb. Only the soname
|
||||
# symlink is needed: libdrmtap is resolved by ABSOLUTE path (/usr/lib/rustdesk/libdrmtap.so.0) at
|
||||
# the in-process dlopen site (drmtap_dl.rs), so the deb does NOT drop /usr/lib/rustdesk into the
|
||||
# system-wide /etc/ld.so.conf.d search path, which would let this private library shadow a system
|
||||
# library for every binary on the host (Debian Policy 10.2 forbids that). No ld.so.conf.d drop-in
|
||||
# and no ldconfig trigger are shipped, so the stock postinst is used unchanged.
|
||||
assert_so_satisfies_the_runtime_abi_gate(so_path)
|
||||
so_basename = os.path.basename(so_path)
|
||||
system2('mkdir -p tmpdeb/usr/lib/rustdesk')
|
||||
# Quoted: so_path comes from the repo root or from DRMTAP_PREBUILT_DIR, either of which can
|
||||
# contain a space, and an unquoted interpolation would split the argument and fail obscurely.
|
||||
system2(f'cp "{so_path}" tmpdeb/usr/lib/rustdesk/')
|
||||
system2(f'ln -sf "{so_basename}" tmpdeb/usr/lib/rustdesk/libdrmtap.so.0')
|
||||
|
||||
|
||||
def _max_glibc_minor(path):
|
||||
# Read from .dynstr rather than via objdump so packaging needs no binutils; chunked because
|
||||
# librustdesk.so is ~45 MB.
|
||||
best = 0
|
||||
with open(path, 'rb') as f:
|
||||
tail = b''
|
||||
while True:
|
||||
chunk = f.read(1 << 20)
|
||||
if not chunk:
|
||||
return best
|
||||
for m in re.finditer(rb'GLIBC_2\.(\d+)', tail + chunk):
|
||||
best = max(best, int(m.group(1)))
|
||||
tail = chunk[-16:]
|
||||
|
||||
|
||||
def measured_glibc_floor():
|
||||
# libdrmtap is built on a newer base than the rest of the deb, so the floor is whichever staged
|
||||
# object is higher -- and it moves whenever either base does.
|
||||
paths = [p for p in glob.glob('tmpdeb/usr/lib/rustdesk/libdrmtap.so.0.*')
|
||||
+ glob.glob('tmpdeb/usr/share/rustdesk/lib/librustdesk.so')
|
||||
+ glob.glob('tmpdeb/usr/share/rustdesk/rustdesk')
|
||||
if os.path.isfile(p) and not os.path.islink(p)]
|
||||
minor = max((_max_glibc_minor(p) for p in paths), default=0)
|
||||
if not minor:
|
||||
raise Exception(
|
||||
f'could not measure a GLIBC_2.x floor from any staged object ({paths or "none found"}); '
|
||||
'refusing to ship the unattended-wayland variant with an undeclared libc6 floor, which '
|
||||
'is what lets it install on a host where libdrmtap can never load')
|
||||
return f'2.{minor}'
|
||||
|
||||
|
||||
def retarget_control_to_drm_variant():
|
||||
# Rewrite the control file that generate_control_file just produced, instead of parameterizing that
|
||||
# function: the stock packaging path stays exactly as upstream wrote it, and everything specific to
|
||||
# this variant lives here. The variant installs the same files as the stock package, so it must
|
||||
# conflict with and replace it: you install one or the other, never both. It also needs libdrmtap's
|
||||
# own runtime deps, which the stock package has no reason to carry.
|
||||
path = '../res/DEBIAN/control'
|
||||
floor = measured_glibc_floor()
|
||||
print(f'[drm] {DRM_PACKAGE_NAME} libc6 floor measured at {floor}')
|
||||
with open(path) as f:
|
||||
lines = f.readlines()
|
||||
out = []
|
||||
for line in lines:
|
||||
if line.startswith('Package: rustdesk'):
|
||||
out.append(f'Package: {DRM_PACKAGE_NAME}\n')
|
||||
out.append('Conflicts: rustdesk\nReplaces: rustdesk\nProvides: rustdesk\n')
|
||||
elif line.startswith('Depends:'):
|
||||
# 2.4.101 is where drmModeGetFB2 landed; below it libdrmtap loads and can never capture.
|
||||
out.append(line.rstrip('\n') + ', libdrm2 (>= 2.4.101), libegl1, libgles2, '
|
||||
f'libc6 (>= {floor})\n')
|
||||
else:
|
||||
out.append(line)
|
||||
body = ''.join(out)
|
||||
# Fail loudly rather than silently shipping a package that says `rustdesk`: a stock control file
|
||||
# that stopped matching either anchor would otherwise produce a variant deb wearing the stock name.
|
||||
if f'Package: {DRM_PACKAGE_NAME}\n' not in body or 'libegl1' not in body:
|
||||
raise Exception(f'could not retarget {path} to the drm variant; upstream control layout changed')
|
||||
with open(path, 'w') as f:
|
||||
f.write(body)
|
||||
|
||||
|
||||
def build_flutter_deb(version, features):
|
||||
if not skip_cargo:
|
||||
system2(f'cargo build --locked --features {features} --lib --release')
|
||||
@@ -323,8 +704,6 @@ def build_flutter_deb(version, features):
|
||||
system2('flutter build linux --release')
|
||||
system2('mkdir -p tmpdeb/usr/bin/')
|
||||
system2('mkdir -p tmpdeb/usr/share/rustdesk')
|
||||
system2('mkdir -p tmpdeb/etc/rustdesk/')
|
||||
system2('mkdir -p tmpdeb/etc/pam.d/')
|
||||
system2('mkdir -p tmpdeb/usr/share/rustdesk/files/systemd/')
|
||||
system2('mkdir -p tmpdeb/usr/share/icons/hicolor/256x256/apps/')
|
||||
system2('mkdir -p tmpdeb/usr/share/icons/hicolor/scalable/apps/')
|
||||
@@ -343,17 +722,24 @@ def build_flutter_deb(version, features):
|
||||
'cp ../res/rustdesk.desktop tmpdeb/usr/share/applications/rustdesk.desktop')
|
||||
system2(
|
||||
'cp ../res/rustdesk-link.desktop tmpdeb/usr/share/applications/rustdesk-link.desktop')
|
||||
system2(
|
||||
'cp ../res/startwm.sh tmpdeb/etc/rustdesk/')
|
||||
system2(
|
||||
'cp ../res/xorg.conf tmpdeb/etc/rustdesk/')
|
||||
system2(
|
||||
'cp ../res/pam.d/rustdesk.debian tmpdeb/etc/pam.d/rustdesk')
|
||||
system2(
|
||||
"echo \"#!/bin/sh\" >> tmpdeb/usr/share/rustdesk/files/polkit && chmod a+x tmpdeb/usr/share/rustdesk/files/polkit")
|
||||
# Bundle libdrmtap.so only when this build actually enabled the `drm` feature, so stock packages
|
||||
# stay exactly what they were. The root service dlopens it in-process by absolute path.
|
||||
# `features` is the comma-joined string, so split it: a bare substring test would also match any
|
||||
# future feature merely containing "drm" (drm-lease, vaapi-drm) and rename the deb to the
|
||||
# consent-bypass variant without --drm ever being passed.
|
||||
ships_so = 'drm' in features.split(',')
|
||||
if ships_so:
|
||||
# Same artifact assertion as the --package path. Under --skip-cargo nothing here rebuilt the
|
||||
# binary, so `features` says what was ASKED for while the staged bundle can be anything.
|
||||
assert_staged_binary_is_drm()
|
||||
stage_libdrmtap_into_deb(build_libdrmtap_so())
|
||||
|
||||
system2('mkdir -p tmpdeb/DEBIAN')
|
||||
generate_control_file(version)
|
||||
if ships_so:
|
||||
retarget_control_to_drm_variant()
|
||||
system2('cp -a ../res/DEBIAN/* tmpdeb/DEBIAN/')
|
||||
md5_file_folder("tmpdeb/")
|
||||
system2('dpkg-deb -b tmpdeb rustdesk.deb;')
|
||||
@@ -361,10 +747,68 @@ def build_flutter_deb(version, features):
|
||||
system2('/bin/rm -rf tmpdeb/')
|
||||
system2('/bin/rm -rf ../res/DEBIAN/control')
|
||||
os.rename('rustdesk.deb', '../rustdesk-%s.deb' % version)
|
||||
if ships_so:
|
||||
# Named apart from the stock package so installing the consent-free variant is a deliberate act.
|
||||
os.rename('../rustdesk-%s.deb' % version, f'../{DRM_PACKAGE_NAME}-{version}.deb')
|
||||
os.chdir("..")
|
||||
|
||||
|
||||
def build_deb_from_folder(version, binary_folder):
|
||||
DRMTAP_DLOPEN_MARKER = b'/usr/lib/rustdesk/libdrmtap.so.0'
|
||||
# Present only when `drm-wake` is compiled in: the runtime option constant is itself
|
||||
# #[cfg(feature = "drm-wake")] (src/ipc/drm.rs). The dlopen marker above cannot stand in for it -
|
||||
# `--features drm` alone produces a binary that carries the dlopen path and NO wake code, and that
|
||||
# is exactly the deb this assertion is here to refuse.
|
||||
DRMTAP_WAKE_MARKER = b'enable-drm-display-wake'
|
||||
|
||||
|
||||
def _carries_drmtap_marker(path, marker=DRMTAP_DLOPEN_MARKER):
|
||||
# Chunked, with an overlap of len(marker)-1 so the marker cannot be missed at a chunk boundary:
|
||||
# librustdesk.so is ~45 MB and there is no reason to hold it all in memory, and the `with`
|
||||
# closes deterministically instead of relying on refcounting.
|
||||
with open(path, 'rb') as f:
|
||||
tail = b''
|
||||
while True:
|
||||
chunk = f.read(1 << 20)
|
||||
if not chunk:
|
||||
return False
|
||||
if marker in tail + chunk:
|
||||
return True
|
||||
tail = chunk[-(len(marker) - 1):]
|
||||
|
||||
|
||||
def assert_staged_binary_is_drm():
|
||||
"""The staged BINARY must really be a drm build before it is named the unattended-wayland
|
||||
variant. That package conflicts with and replaces the stock one, so shipping a stock binary
|
||||
under that name produces something that can never capture and cannot be installed alongside
|
||||
what it replaced. The marker is the absolute dlopen path from drmtap_dl.rs, present only when
|
||||
the feature is compiled in -- assert what was produced, not what was asked for.
|
||||
|
||||
Called from BOTH packaging paths. It used to guard only one of them, and `--skip-cargo` (which
|
||||
is how CI packages) reaches the other, where nothing had rebuilt the binary at all.
|
||||
"""
|
||||
binaries = [p for p in glob.glob('tmpdeb/usr/share/rustdesk/lib/librustdesk.so')
|
||||
+ glob.glob('tmpdeb/usr/share/rustdesk/rustdesk') if os.path.isfile(p)]
|
||||
if not any(_carries_drmtap_marker(p) for p in binaries):
|
||||
raise Exception(
|
||||
f'--drm was requested but the staged bundle does not look like a drm build (no '
|
||||
f'{DRMTAP_DLOPEN_MARKER.decode()} dlopen path in {binaries or "any staged binary"}); '
|
||||
'refusing to package it as the unattended-wayland variant, which conflicts with and '
|
||||
'replaces the stock package but could never capture')
|
||||
# And the WAKE half. `--drm` enables `drm-wake` too (see get_features), and the deb is named and
|
||||
# documented as the variant that can reach a machine whose screen has gone dark. The dlopen
|
||||
# marker above does not distinguish them: `--features drm` alone carries it and has no wake code
|
||||
# at all. Asserting only the first half is how a deb can be named for a feature it does not have.
|
||||
if not any(_carries_drmtap_marker(p, DRMTAP_WAKE_MARKER) for p in binaries):
|
||||
raise Exception(
|
||||
f'--drm was requested but the staged binary has no {DRMTAP_WAKE_MARKER.decode()} '
|
||||
f'marker in {binaries or "any staged binary"}, so it was built without `drm-wake`; '
|
||||
'refusing to package it as the unattended-wayland variant, which is named and '
|
||||
'documented as the build that can wake an idle-disabled display. If this fired under '
|
||||
'--skip-cargo, the cargo line that produced the bundle is missing the feature: '
|
||||
'--features ...,drm,drm-wake')
|
||||
|
||||
|
||||
def build_deb_from_folder(version, binary_folder, want_drm=False):
|
||||
os.chdir('flutter')
|
||||
system2('mkdir -p tmpdeb/usr/bin/')
|
||||
system2('mkdir -p tmpdeb/usr/share/rustdesk')
|
||||
@@ -388,9 +832,53 @@ def build_deb_from_folder(version, binary_folder):
|
||||
'cp ../res/rustdesk-link.desktop tmpdeb/usr/share/applications/rustdesk-link.desktop')
|
||||
system2(
|
||||
"echo \"#!/bin/sh\" >> tmpdeb/usr/share/rustdesk/files/polkit && chmod a+x tmpdeb/usr/share/rustdesk/files/polkit")
|
||||
# Where the capture library comes from for a `--package <folder> --drm` build. Two shapes are
|
||||
# supported, because two exist in practice: a bundle that already carries libdrmtap.so.0.*
|
||||
# (someone staged it, e.g. a CI artifact), and a plain bundle, which is what every build path
|
||||
# here actually produces -- the flutter deb builds the library straight into the staged deb, so
|
||||
# nothing ever puts it inside the bundle folder. Demanding it in the bundle made this flag
|
||||
# combination impossible to satisfy.
|
||||
bundled_glob = glob.glob('tmpdeb/usr/share/rustdesk/libdrmtap.so.0.*')
|
||||
bundle_carries_so = any(os.path.isfile(p) and not os.path.islink(p) for p in bundled_glob)
|
||||
# The variant must be decided by the EXPLICIT --drm request, not merely by what happens to be
|
||||
# staged: a bundle that carries the .so must NOT be shipped as the consent-bypass variant when
|
||||
# --drm was never passed.
|
||||
if bundle_carries_so and not want_drm:
|
||||
raise Exception(
|
||||
'the staged bundle carries libdrmtap.so.0.* but --drm was not passed; refusing '
|
||||
'to silently ship the consent-bypass unattended-wayland variant (pass --drm to '
|
||||
'build it deliberately)')
|
||||
if want_drm:
|
||||
# Whichever shape we are in, the staged BINARY must really be a drm build. This is the
|
||||
# property the old presence-of-the-.so test stood in for, badly: a stock binary packaged as
|
||||
# the unattended-wayland variant would carry the consent-bypass name, conflict with and
|
||||
# replace the stock package, and never be able to capture. The marker is the absolute
|
||||
# dlopen path from drmtap_dl.rs, present only when the feature is compiled in -- the same
|
||||
# kind of artifact assertion as _assert_so_has_egl, and for the same reason: assert what
|
||||
# was produced, not what was asked for.
|
||||
assert_staged_binary_is_drm()
|
||||
if bundle_carries_so:
|
||||
so = _single_real_so(bundled_glob, 'the staged --drm bundle')
|
||||
# The THIRD artifact source, and the last one that was missing the check: --package
|
||||
# takes the .so straight out of a bundle somebody else produced, so it has the same
|
||||
# exposure as DRMTAP_PREBUILT_DIR (see the comment on that branch). A CPU-only stub
|
||||
# would ship, the loader would accept it, and capture would degrade to PipeWire
|
||||
# without a word.
|
||||
_assert_so_has_egl(so)
|
||||
stage_libdrmtap_into_deb(so)
|
||||
system2(f'rm -f "{so}"')
|
||||
system2('rm -f tmpdeb/usr/share/rustdesk/libdrmtap.so tmpdeb/usr/share/rustdesk/libdrmtap.so.0')
|
||||
else:
|
||||
# Build it here, exactly as the flutter deb path does (build_libdrmtap_so asserts the
|
||||
# EGL backend itself). The library is independent of the staged binary.
|
||||
stage_libdrmtap_into_deb(build_libdrmtap_so())
|
||||
|
||||
system2('mkdir -p tmpdeb/DEBIAN')
|
||||
generate_control_file(version)
|
||||
# Keyed on the EXPLICIT request, not on what happened to be staged: by here a --drm build has
|
||||
# its library in tmpdeb whichever of the two shapes it came from.
|
||||
if want_drm:
|
||||
retarget_control_to_drm_variant()
|
||||
system2('cp -a ../res/DEBIAN/* tmpdeb/DEBIAN/')
|
||||
md5_file_folder("tmpdeb/")
|
||||
system2('dpkg-deb -b tmpdeb rustdesk.deb;')
|
||||
@@ -398,6 +886,8 @@ def build_deb_from_folder(version, binary_folder):
|
||||
system2('/bin/rm -rf tmpdeb/')
|
||||
system2('/bin/rm -rf ../res/DEBIAN/control')
|
||||
os.rename('rustdesk.deb', '../rustdesk-%s.deb' % version)
|
||||
if want_drm:
|
||||
os.rename('../rustdesk-%s.deb' % version, f'../{DRM_PACKAGE_NAME}-{version}.deb')
|
||||
os.chdir("..")
|
||||
|
||||
|
||||
@@ -410,7 +900,12 @@ def build_flutter_dmg(version, features):
|
||||
system2(
|
||||
"cp target/release/liblibrustdesk.dylib target/release/librustdesk.dylib")
|
||||
os.chdir('flutter')
|
||||
system2('flutter build macos --release')
|
||||
# cargo builds a single-arch dylib for the host; restrict Xcode to the same arch
|
||||
# so the universal-by-default ARCHS_STANDARD doesn't try to link a missing slice.
|
||||
# FLUTTER_XCODE_* env vars are forwarded to xcodebuild as build settings.
|
||||
mac_arch = 'arm64' if platform.machine().lower() in ('arm64', 'aarch64') else 'x86_64'
|
||||
system2(
|
||||
f'FLUTTER_XCODE_ARCHS={mac_arch} FLUTTER_XCODE_ONLY_ACTIVE_ARCH=YES flutter build macos --release')
|
||||
system2('cp -rf ../target/release/service ./build/macos/Build/Products/Release/RustDesk.app/Contents/MacOS/')
|
||||
'''
|
||||
system2(
|
||||
@@ -467,6 +962,19 @@ def main():
|
||||
parser = make_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
# Before anything with a side effect: this is a query, and a caller uses it to build the very
|
||||
# binary it will then package. `get_features` stays the single definition of what a flag
|
||||
# combination means; a caller that hardcodes the list instead is one edit away from compiling
|
||||
# something other than what it ships.
|
||||
if args.print_features:
|
||||
# stdout carries the list and nothing else, so a caller can use it directly in a command
|
||||
# substitution. `get_features` prints a human-readable line of its own; send that to stderr
|
||||
# for this call rather than silencing it, which would change what every other path prints.
|
||||
with contextlib.redirect_stdout(sys.stderr):
|
||||
feats = ','.join(get_features(args))
|
||||
print(feats)
|
||||
return
|
||||
|
||||
if os.path.exists(exe_path):
|
||||
os.unlink(exe_path)
|
||||
if os.path.isfile('/usr/bin/pacman'):
|
||||
@@ -482,7 +990,7 @@ def main():
|
||||
portable = args.portable
|
||||
package = args.package
|
||||
if package:
|
||||
build_deb_from_folder(version, package)
|
||||
build_deb_from_folder(version, package, args.drm)
|
||||
return
|
||||
res_dir = 'resources'
|
||||
external_resources(flutter, args, res_dir)
|
||||
@@ -506,6 +1014,7 @@ def main():
|
||||
'target\\release\\rustdesk.exe')
|
||||
else:
|
||||
print('Not signed')
|
||||
os.makedirs(res_dir, exist_ok=True)
|
||||
system2(
|
||||
f'cp -rf target/release/RustDesk.exe {res_dir}')
|
||||
os.chdir('libs/portable')
|
||||
@@ -615,13 +1124,7 @@ def main():
|
||||
'cp res/rustdesk.desktop tmpdeb/usr/share/applications/rustdesk.desktop')
|
||||
system2(
|
||||
'cp res/rustdesk-link.desktop tmpdeb/usr/share/applications/rustdesk-link.desktop')
|
||||
os.system('mkdir -p tmpdeb/etc/rustdesk/')
|
||||
os.system('cp -a res/startwm.sh tmpdeb/etc/rustdesk/')
|
||||
os.system('mkdir -p tmpdeb/etc/X11/rustdesk/')
|
||||
os.system('cp res/xorg.conf tmpdeb/etc/X11/rustdesk/')
|
||||
os.system('cp -a DEBIAN/* tmpdeb/DEBIAN/')
|
||||
os.system('mkdir -p tmpdeb/etc/pam.d/')
|
||||
os.system('cp pam.d/rustdesk.debian tmpdeb/etc/pam.d/rustdesk')
|
||||
system2('strip tmpdeb/usr/bin/rustdesk')
|
||||
system2('mkdir -p tmpdeb/usr/share/rustdesk')
|
||||
system2('mv tmpdeb/usr/bin/rustdesk tmpdeb/usr/share/rustdesk/')
|
||||
|
||||
1
build.rs
1
build.rs
@@ -72,7 +72,6 @@ fn install_android_deps() {
|
||||
path.join("lib").to_str().unwrap()
|
||||
);
|
||||
println!("cargo:rustc-link-lib=ndk_compat");
|
||||
println!("cargo:rustc-link-lib=oboe");
|
||||
println!("cargo:rustc-link-lib=c++");
|
||||
println!("cargo:rustc-link-lib=OpenSLES");
|
||||
}
|
||||
|
||||
@@ -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-ID.md>
|
||||
<https://github.com/rustdesk/rustdesk/blob/master/docs/CODE_OF_CONDUCT.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-IT.md
|
||||
https://github.com/rustdesk/rustdesk/blob/master/docs/CODE_OF_CONDUCT.md
|
||||
|
||||
## Comunicazioni
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Contributing to RustDesk
|
||||
|
||||
RustDesk welcomes contribution from everyone. Here are the guidelines if you are
|
||||
RustDesk welcomes contributions from everyone. Here are the guidelines if you are
|
||||
thinking of helping us:
|
||||
|
||||
## Contributions
|
||||
|
||||
@@ -160,7 +160,6 @@ RustDesk يرجى التأكد من أنك تنفذ هذه الأوامر من
|
||||
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: أو المنقول عن بُعد (TCP hole punching) انتظر الاتصال المباشر [rustdesk-server](https://github.com/rustdesk/rustdesk-server) الإتصال ب
|
||||
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: رمز خاص بكل منصة
|
||||
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: رمز الهاتف المحمول
|
||||
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**:Flutter لعميل الويب الخاص ب Javascript
|
||||
|
||||
## لقطات
|
||||
|
||||
|
||||
@@ -144,7 +144,6 @@ Ujistěte se, že tyto příkazy spouštíte z kořenového adresáře RustDesk,
|
||||
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: komunikace s [rustdesk-server](https://github.com/rustdesk/rustdesk-server), očekávání vzdálených příméhých („proděrováváním“ TCP) nebo předávaných (relay) spojení
|
||||
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: zdrojové kódy, specifické pro jednotlivé platformy
|
||||
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: zdrojové kódy pro použití s aplikačním rámcem (framework) Flutter pro mobilní platformy
|
||||
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Javascript pro Flutter webový klient
|
||||
|
||||
## Ukázky
|
||||
|
||||
|
||||
@@ -66,19 +66,19 @@ Bitte laden Sie die dynamische Bibliothek Sciter selbst herunter.
|
||||
```sh
|
||||
sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \
|
||||
libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \
|
||||
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev
|
||||
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
|
||||
```
|
||||
|
||||
### openSUSE Tumbleweed
|
||||
|
||||
```sh
|
||||
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel
|
||||
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel
|
||||
```
|
||||
|
||||
### Fedora 28 (CentOS 8)
|
||||
|
||||
```sh
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel
|
||||
```
|
||||
|
||||
### Arch (Manjaro)
|
||||
@@ -168,7 +168,6 @@ Bitte stellen Sie sicher, dass Sie diese Befehle im Stammverzeichnis des RustDes
|
||||
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Mit [rustdesk-server](https://github.com/rustdesk/rustdesk-server) kommunizieren, warten auf direkte (TCP hole punching) oder weitergeleitete Verbindung
|
||||
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: Plattformspezifischer Code
|
||||
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Flutter-Code für Handys
|
||||
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: JavaScript für Flutter-Webclient
|
||||
|
||||
## Screenshots
|
||||
|
||||
|
||||
@@ -62,19 +62,19 @@ Por favor descarga la librería dinámica de Sciter tú mismo.
|
||||
```sh
|
||||
sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \
|
||||
libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \
|
||||
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev
|
||||
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
|
||||
```
|
||||
|
||||
### openSUSE Tumbleweed
|
||||
|
||||
```sh
|
||||
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel
|
||||
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel
|
||||
```
|
||||
|
||||
### Fedora 28 (CentOS 8)
|
||||
|
||||
```sh
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel
|
||||
```
|
||||
|
||||
### Arch (Manjaro)
|
||||
@@ -163,7 +163,6 @@ Por favor, asegurate de que estás ejecutando estos comandos desde la raíz del
|
||||
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Comunicación con [rustdesk-server](https://github.com/rustdesk/rustdesk-server), esperar la conexión remota directa ("TCP hole punching") o conexión indirecta ("relayed")
|
||||
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: código específico de cada plataforma
|
||||
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Flutter, código para moviles
|
||||
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Javascript para el cliente web Flutter
|
||||
|
||||
> [!Precaución]
|
||||
> **Descargo de responsabilidad por uso indebido:** <br>
|
||||
|
||||
@@ -146,7 +146,6 @@ target/release/rustdesk
|
||||
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Communicate with [rustdesk-server](https://github.com/rustdesk/rustdesk-server), wait for remote direct (TCP hole punching) or relayed connection
|
||||
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: platform specific code
|
||||
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Flutter code for mobile
|
||||
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Javascript for Flutter web client
|
||||
|
||||
## تصاویر محیط نرمافزار
|
||||
|
||||
|
||||
@@ -158,7 +158,6 @@ target/release/rustdesk
|
||||
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Communicate with [rustdesk-server](https://github.com/rustdesk/rustdesk-server), wait for remote direct (TCP hole punching) or relayed connection
|
||||
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: platform specific code
|
||||
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Flutter code for mobile
|
||||
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: JavaScript for Flutter web client
|
||||
|
||||
## Στιγμιότυπα
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ A telefonos verziók Flutter-t hasznának. Később lehetséges hogy Sciterről
|
||||
|
||||
- Futtasd a `cargo run` parancsot
|
||||
|
||||
## [Építés](https://rustdesk.com/docs/hu/dev/build/)
|
||||
## [Építés](https://rustdesk.com/docs/en/dev/build/)
|
||||
|
||||
## Hogyan építs Linuxon
|
||||
|
||||
@@ -150,7 +150,6 @@ Kérlek mindenképpen nézd meg hogy ezeket a parancsokat a root RustDesk mappá
|
||||
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Communicate with [rustdesk-server](https://github.com/rustdesk/rustdesk-server), wait for remote direct (TCP hole punching) or relayed connection
|
||||
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: platform specific code
|
||||
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Flutter code for mobile
|
||||
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Javascript for Flutter web client
|
||||
|
||||
## Képernyőképek
|
||||
|
||||
|
||||
@@ -162,7 +162,6 @@ Assicurati di eseguire questi comandi dalla radice del repository RustDesk, altr
|
||||
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: comunica con [rustdesk-server](https://github.com/rustdesk/rustdesk-server), attende la connessione remota diretta (TCP hole punching) oppure indiretta (relayed)
|
||||
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: codice specifico della piattaforma
|
||||
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: codice Flutter per desktop e mobile
|
||||
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: JavaScript per client web Flutter
|
||||
|
||||
> [!Attenzione]
|
||||
> **Dichiarazione di non responsabilità per uso improprio:** <br>
|
||||
|
||||
@@ -166,7 +166,6 @@ target/release/rustdesk
|
||||
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: [rustdesk-server](https://github.com/rustdesk/rustdesk-server)と通信し、リモートの直接接続(TCPホールパンチング)や中継接続を担う。
|
||||
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: プラットフォーム固有のコード
|
||||
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: デスクトップとモバイル向けのFlutterコード
|
||||
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Flutterウェブクライアント向けのJavaScript
|
||||
|
||||
> [!注意]
|
||||
> **:不正使用に関する免責事項** <br>
|
||||
|
||||
@@ -66,19 +66,19 @@ Sciter 동적 라이브러리를 직접 다운로드하세요.
|
||||
```sh
|
||||
sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \
|
||||
libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \
|
||||
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev
|
||||
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
|
||||
```
|
||||
|
||||
### openSUSE Tumbleweed
|
||||
|
||||
```sh
|
||||
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel
|
||||
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel
|
||||
```
|
||||
|
||||
### Fedora 28 (CentOS 8)
|
||||
|
||||
```sh
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel
|
||||
```
|
||||
|
||||
### Arch (Manjaro)
|
||||
@@ -168,7 +168,6 @@ RustDesk 리포지토리의 루트에서 이러한 명령을 실행하고 있는
|
||||
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: [rustdesk-server](https://github.com/rustdesk/rustdesk-server)와 통신, 원격 다이렉트 (TCP 홀 펀칭) 또는 릴레이 연결 대기
|
||||
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: 플랫폼별 코드
|
||||
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: 데스크톱 및 모바일용 Flutter 코드
|
||||
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/v1/js)**: Flutter 웹 클라이언트용 JavaScript
|
||||
|
||||
## 스크린샷
|
||||
|
||||
|
||||
@@ -62,19 +62,19 @@ Venligst last ned Sciters dynamiske bibliotek selv.
|
||||
```sh
|
||||
sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \
|
||||
libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \
|
||||
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev
|
||||
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
|
||||
```
|
||||
|
||||
### openSUSE Tumbleweed
|
||||
|
||||
```sh
|
||||
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel
|
||||
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel
|
||||
```
|
||||
|
||||
### Fedora 28 (CentOS 8)
|
||||
|
||||
```sh
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel
|
||||
```
|
||||
|
||||
### Arch (Manjaro)
|
||||
@@ -163,7 +163,6 @@ Venligst pass på att du kjører disse kommandoene fra roten av RustDesk reposit
|
||||
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Kommunikasjon med [rustdesk-server](https://github.com/rustdesk/rustdesk-server), vent på direkte fjernstyring (TCP hulling) eller vidresendt tilkobling
|
||||
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: platform spesefik kode
|
||||
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Flutter kode for desktop og mobil
|
||||
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: JavaScript for Flutter nettsted klient
|
||||
|
||||
## Skjermbilder
|
||||
|
||||
|
||||
@@ -155,7 +155,6 @@ Upewnij się, że uruchamiasz te polecenia z katalogu głównego repozytorium Ru
|
||||
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Komunikacja z [rustdesk-server](https://github.com/rustdesk/rustdesk-server), czekanie na bezpośrednie (odpytywanie TCP) lub przekazywane połączenie
|
||||
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: kod specyficzny dla danej platformy
|
||||
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: kod Flutter dla urządzeń mobilnych
|
||||
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: JavaScript dla Flutter - klient web
|
||||
|
||||
## Zrzuty ekranu
|
||||
|
||||
|
||||
@@ -64,19 +64,19 @@ Por favor, faça o download da biblioteca dinâmica do Sciter por conta própria
|
||||
### Ubuntu 18 (Debian 10)
|
||||
|
||||
```sh
|
||||
sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev
|
||||
sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
|
||||
```
|
||||
|
||||
### openSUSE Tumbleweed
|
||||
|
||||
```sh
|
||||
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel
|
||||
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel
|
||||
```
|
||||
|
||||
### Fedora 28 (CentOS 8)
|
||||
|
||||
```sh
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel
|
||||
```
|
||||
|
||||
### Arch (Manjaro)
|
||||
@@ -166,7 +166,6 @@ Certifique-se de executar esses comandos a partir da raiz do repositório do Rus
|
||||
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Comunica-se com o [rustdesk-server](https://github.com/rustdesk/rustdesk-server), aguarda por conexão remota direta (perfuração de túnel TCP / hole punching) ou retransmitida.
|
||||
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: código específico de cada plataforma.
|
||||
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: código Flutter para desktop e dispositivos móveis.
|
||||
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/v1/js)**: JavaScript para o cliente web do Flutter.
|
||||
|
||||
## Capturas de Tela
|
||||
|
||||
|
||||
@@ -66,19 +66,19 @@ Te rugăm să descarci singur librăria dinamică Sciter.
|
||||
```sh
|
||||
sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \
|
||||
libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \
|
||||
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev
|
||||
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
|
||||
```
|
||||
|
||||
### openSUSE Tumbleweed
|
||||
|
||||
```sh
|
||||
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel
|
||||
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel
|
||||
```
|
||||
|
||||
### Fedora 28 (CentOS 8)
|
||||
|
||||
```sh
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel
|
||||
```
|
||||
|
||||
### Arch (Manjaro)
|
||||
@@ -168,7 +168,6 @@ Asigură-te că rulezi aceste comenzi din rădăcina repository-ului RustDesk, a
|
||||
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: comunică cu [rustdesk-server](https://github.com/rustdesk/rustdesk-server), așteaptă conexiune directă remote (TCP hole punching) sau prin relay
|
||||
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: cod specific platformei
|
||||
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: cod Flutter pentru desktop și mobil
|
||||
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/v1/js)**: JavaScript pentru clientul Flutter web
|
||||
|
||||
## Capturi de ecran
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ RustDesk приветствует вклад каждого. Ознакомьт
|
||||
|
||||
- Выполните команду `cargo run`
|
||||
|
||||
## [Сборка](https://rustdesk.com/docs/ru/dev/build/)
|
||||
## [Сборка](https://rustdesk.com/docs/en/dev/build/)
|
||||
|
||||
## Как собрать на Linux
|
||||
|
||||
@@ -68,19 +68,19 @@ RustDesk приветствует вклад каждого. Ознакомьт
|
||||
```sh
|
||||
sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \
|
||||
libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \
|
||||
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev
|
||||
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
|
||||
```
|
||||
|
||||
### openSUSE Tumbleweed
|
||||
|
||||
```sh
|
||||
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel
|
||||
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel
|
||||
```
|
||||
|
||||
### Fedora 28 (CentOS 8)
|
||||
|
||||
```sh
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel
|
||||
```
|
||||
|
||||
### Arch (Manjaro)
|
||||
@@ -170,7 +170,6 @@ target/release/rustdesk
|
||||
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: связь с [сервером RustDesk](https://github.com/rustdesk/rustdesk-server), ожидает удаленного прямого (через TCP hole punching) или ретранслируемого соединения
|
||||
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: специфичный для платформы код
|
||||
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: код Flutter для ПК-версии и мобильных устройств
|
||||
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/v1/js)**: JavaScript для Web-клиента Flutter
|
||||
|
||||
## Скриншоты
|
||||
|
||||
@@ -180,4 +179,4 @@ target/release/rustdesk
|
||||
|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
@@ -166,7 +166,6 @@ Lütfen bu komutları RustDesk reposunun root klasöründe çalıştırdığın
|
||||
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: [rustdesk-server](https://github.com/rustdesk/rustdesk-server) ile iletişime gir, remote direct(TCP delik açma) yada relay bağlantısı için bekle
|
||||
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: platforma özgü kod
|
||||
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Masaüstü ve mobil için Flutter kodu
|
||||
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/v1/js)**: Flutter web istemcisi için JavaScript
|
||||
|
||||
|
||||
## Ekran Görüntüleri
|
||||
|
||||
@@ -59,19 +59,19 @@ RustDesk вітає внесок кожного. Ознайомтеся з [CONT
|
||||
```sh
|
||||
sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \
|
||||
libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \
|
||||
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev
|
||||
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
|
||||
```
|
||||
|
||||
### openSUSE Tumbleweed
|
||||
|
||||
```sh
|
||||
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel
|
||||
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel
|
||||
```
|
||||
|
||||
### Fedora 28 (CentOS 8)
|
||||
|
||||
```sh
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel
|
||||
```
|
||||
|
||||
### Arch (Manjaro)
|
||||
@@ -160,7 +160,6 @@ target/release/rustdesk
|
||||
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: комунікація з [rustdesk-server](https://github.com/rustdesk/rustdesk-server), очікування віддаленого прямого (обхід TCP NAT) або ретрансльованого зʼєднання
|
||||
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: специфічний для платформи код
|
||||
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: код Flutter для мобільних пристроїв
|
||||
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: JavaScript для веб клієнта на Flutter
|
||||
|
||||
## Знімки екрана
|
||||
|
||||
|
||||
@@ -148,7 +148,6 @@ Hãy đảm bảo rằng bạn đang chạy các lệnh này từ gốc của th
|
||||
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: giao tiếp với [rustdesk-server](https://github.com/rustdesk/rustdesk-server), đợi kết nối trực tiếp (TCP hole punching) hoặc kết nối được chuyển tiếp.
|
||||
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: mã nguồn riêng cho mỗi nền tảng
|
||||
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Mã Flutter dành máy tính và điện thoại
|
||||
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Mã JavaScript dành cho giao diện trên web bằng Flutter
|
||||
|
||||
## Snapshot
|
||||
|
||||
|
||||
@@ -220,7 +220,6 @@ target/release/rustdesk
|
||||
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: 与[rustdesk-server](https://github.com/rustdesk/rustdesk-server)保持UDP通讯, 等待远程连接(通过打洞直连或者中继)
|
||||
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: 平台服务相关代码
|
||||
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: 适用于桌面和移动设备的 Flutter 代码
|
||||
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Flutter Web版本中的Javascript代码
|
||||
|
||||
## 截图
|
||||
|
||||
|
||||
@@ -21,18 +21,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "pam",
|
||||
"buildsystem": "autotools",
|
||||
"config-opts": ["--disable-selinux"],
|
||||
"sources": [
|
||||
{
|
||||
"type": "archive",
|
||||
"url": "https://github.com/linux-pam/linux-pam/releases/download/v1.3.1/Linux-PAM-1.3.1.tar.xz",
|
||||
"sha256": "eff47a4ecd833fbf18de9686632a70ee8d0794b79aecb217ebd0ce11db4cd0db"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "rustdesk",
|
||||
"buildsystem": "simple",
|
||||
@@ -63,4 +51,4 @@
|
||||
"--socket=pulseaudio",
|
||||
"--talk-name=org.freedesktop.Flatpak"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ package com.carriez.flutter_hbb
|
||||
|
||||
import android.accessibilityservice.AccessibilityService
|
||||
import android.accessibilityservice.GestureDescription
|
||||
import android.content.Intent
|
||||
import android.graphics.Path
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
@@ -68,6 +69,16 @@ class InputService : AccessibilityService() {
|
||||
get() = ctx != null
|
||||
}
|
||||
|
||||
private fun notifyInputState() {
|
||||
val inputState = isOpen.toString()
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
MainActivity.flutterMethodChannel?.invokeMethod(
|
||||
"on_state_changed",
|
||||
mapOf("name" to "input", "value" to inputState)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val logTag = "input service"
|
||||
private var leftIsDown = false
|
||||
private var touchPath = Path()
|
||||
@@ -716,6 +727,7 @@ class InputService : AccessibilityService() {
|
||||
override fun onServiceConnected() {
|
||||
super.onServiceConnected()
|
||||
ctx = this
|
||||
notifyInputState()
|
||||
val info = AccessibilityServiceInfo()
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
info.flags = FLAG_INPUT_METHOD_EDITOR or FLAG_RETRIEVE_INTERACTIVE_WINDOWS
|
||||
@@ -734,8 +746,16 @@ class InputService : AccessibilityService() {
|
||||
|
||||
override fun onDestroy() {
|
||||
ctx = null
|
||||
// Keep this fallback even though onUnbind usually notifies first.
|
||||
notifyInputState()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onUnbind(intent: Intent?): Boolean {
|
||||
ctx = null
|
||||
notifyInputState()
|
||||
return super.onUnbind(intent)
|
||||
}
|
||||
|
||||
override fun onInterrupt() {}
|
||||
}
|
||||
|
||||
@@ -106,6 +106,16 @@ class MainActivity : FlutterActivity() {
|
||||
|
||||
override fun onDestroy() {
|
||||
Log.e(logTag, "onDestroy")
|
||||
// The process can outlive the UI whenever something keeps it alive:
|
||||
// MainService, or the accessibility InputService on its own. Only the
|
||||
// former gets onTaskRemoved, so close outgoing sessions here too,
|
||||
// otherwise a session survives with no UI left to close it.
|
||||
// `isFinishing` distinguishes the user really leaving from a destroy
|
||||
// for recreation (configuration change, "don't keep activities"),
|
||||
// which must not tear down a live session.
|
||||
if (isFinishing) {
|
||||
FFI.closeAllSessions()
|
||||
}
|
||||
mainService?.let {
|
||||
unbindService(serviceConnection)
|
||||
}
|
||||
@@ -200,12 +210,13 @@ class MainActivity : FlutterActivity() {
|
||||
"stop_input" -> {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
InputService.ctx?.disableSelf()
|
||||
} else {
|
||||
InputService.ctx = null
|
||||
Companion.flutterMethodChannel?.invokeMethod(
|
||||
"on_state_changed",
|
||||
mapOf("name" to "input", "value" to InputService.isOpen.toString())
|
||||
)
|
||||
}
|
||||
InputService.ctx = null
|
||||
Companion.flutterMethodChannel?.invokeMethod(
|
||||
"on_state_changed",
|
||||
mapOf("name" to "input", "value" to InputService.isOpen.toString())
|
||||
)
|
||||
result.success(true)
|
||||
}
|
||||
"cancel_notification" -> {
|
||||
|
||||
@@ -254,6 +254,16 @@ class MainService : Service() {
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
// Swiping the app away from recents destroys the UI but this service keeps
|
||||
// the process alive, so outgoing sessions would stay connected with no way
|
||||
// to close them. Incoming connections are unaffected: the service keeps
|
||||
// running so the device stays reachable.
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
Log.d(logTag, "onTaskRemoved, closing outgoing sessions")
|
||||
FFI.closeAllSessions()
|
||||
super.onTaskRemoved(rootIntent)
|
||||
}
|
||||
|
||||
private var isHalfScale: Boolean? = null;
|
||||
private fun updateScreenInfo(orientation: Int) {
|
||||
var w: Int
|
||||
|
||||
@@ -21,6 +21,7 @@ object FFI {
|
||||
external fun onAudioFrameUpdate(buf: ByteBuffer)
|
||||
external fun translateLocale(localeName: String, input: String): String
|
||||
external fun refreshScreen()
|
||||
external fun closeAllSessions()
|
||||
external fun setFrameRawEnable(name: String, value: Boolean)
|
||||
external fun setCodecInfo(info: String)
|
||||
external fun getLocalOption(key: String): String
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="199"><path fill="#0089d6" d="M118.432 187.698c32.89-5.81 60.055-10.618 60.367-10.684l.568-.12-31.052-36.935c-17.078-20.314-31.051-37.014-31.051-37.11 0-.182 32.063-88.477 32.243-88.792.06-.105 21.88 37.567 52.893 91.32 29.035 50.323 52.973 91.815 53.195 92.203l.405.707-98.684-.012-98.684-.013 59.8-10.564zM0 176.435c0-.052 14.631-25.451 32.514-56.442l32.514-56.347 37.891-31.799C123.76 14.358 140.867.027 140.935.001c.069-.026-.205.664-.609 1.534s-18.919 40.582-41.145 88.25l-40.41 86.67-29.386.037c-16.162.02-29.385-.005-29.385-.057z"/></svg>
|
||||
|
Before Width: | Height: | Size: 604 B |
7
flutter/assets/display_switcher.svg
Normal file
7
flutter/assets/display_switcher.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||
<g fill="#000000" fill-rule="evenodd">
|
||||
<rect x="4" y="6" width="24" height="16" rx="3"/>
|
||||
<rect x="14.5" y="22" width="3" height="2"/>
|
||||
<rect x="9.5" y="24" width="13" height="2.5" rx="1.25"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 303 B |
@@ -6,83 +6,82 @@ ANDROID_ABI=$1
|
||||
|
||||
# Build RustDesk dependencies for Android using vcpkg.json
|
||||
# Required:
|
||||
# 1. set VCPKG_ROOT / ANDROID_NDK path environment variables
|
||||
# 1. set VCPKG_ROOT / ANDROID_NDK_HOME path environment variables
|
||||
# 2. vcpkg initialized
|
||||
# 3. ndk, version: r25c or newer
|
||||
|
||||
if [ -z "$ANDROID_NDK_HOME" ]; then
|
||||
echo "Failed! Please set ANDROID_NDK_HOME"
|
||||
exit 1
|
||||
if [ -z "${ANDROID_NDK_HOME}" ]; then
|
||||
echo "ERROR: Please set ANDROID_NDK_HOME environment variable" 1>&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$VCPKG_ROOT" ]; then
|
||||
echo "Failed! Please set VCPKG_ROOT"
|
||||
exit 1
|
||||
if [ -z "${VCPKG_ROOT}" ]; then
|
||||
echo "ERROR: Please set VCPKG_ROOT environment variable" 1>&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
API_LEVEL="21"
|
||||
case "${ANDROID_ABI}" in
|
||||
arm64-v8a)
|
||||
VCPKG_TARGET=arm64-android
|
||||
;;
|
||||
armeabi-v7a)
|
||||
VCPKG_TARGET=arm-neon-android
|
||||
;;
|
||||
x86_64)
|
||||
VCPKG_TARGET=x64-android
|
||||
;;
|
||||
x86)
|
||||
VCPKG_TARGET=x86-android
|
||||
;;
|
||||
*)
|
||||
echo "Usage: build_android_deps.sh <arm64-v8a|armeabi-v7a|x86_64|x86>" 1>&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Get directory of this script
|
||||
|
||||
SCRIPTDIR="$(readlink -f "$0")"
|
||||
SCRIPTDIR="$(dirname "$SCRIPTDIR")"
|
||||
SCRIPTDIR="$(dirname "${SCRIPTDIR}")"
|
||||
|
||||
# Check if vcpkg.json is one level up - in root directory of RD
|
||||
|
||||
if [ ! -f "$SCRIPTDIR/../vcpkg.json" ]; then
|
||||
echo "Failed! Please check where vcpkg.json is!"
|
||||
exit 1
|
||||
if [ ! -f "${SCRIPTDIR}/../vcpkg.json" ]; then
|
||||
echo "ERROR: Can not find vcpkg.json in RustDesk top-level directory" 1>&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# NDK llvm toolchain
|
||||
echo "INFO: Building and install vcpkg dependencies for Android ${ANDROID_ABI} ..."
|
||||
|
||||
HOST_TAG="linux-x86_64" # current platform, set as `ls $ANDROID_NDK/toolchains/llvm/prebuilt/`
|
||||
TOOLCHAIN=$ANDROID_NDK/toolchains/llvm/prebuilt/$HOST_TAG
|
||||
pushd "${SCRIPTDIR}/.."
|
||||
|
||||
function build {
|
||||
ANDROID_ABI=$1
|
||||
"${VCPKG_ROOT}/vcpkg" install \
|
||||
--triplet "${VCPKG_TARGET}" \
|
||||
--x-install-root="${VCPKG_ROOT}/installed"
|
||||
|
||||
case "$ANDROID_ABI" in
|
||||
arm64-v8a)
|
||||
ABI=aarch64-linux-android$API_LEVEL
|
||||
VCPKG_TARGET=arm64-android
|
||||
;;
|
||||
armeabi-v7a)
|
||||
ABI=armv7a-linux-androideabi$API_LEVEL
|
||||
VCPKG_TARGET=arm-neon-android
|
||||
;;
|
||||
x86_64)
|
||||
ABI=x86_64-linux-android$API_LEVEL
|
||||
VCPKG_TARGET=x64-android
|
||||
;;
|
||||
x86)
|
||||
ABI=i686-linux-android$API_LEVEL
|
||||
VCPKG_TARGET=x86-android
|
||||
;;
|
||||
*)
|
||||
echo "ERROR: ANDROID_ABI must be one of: arm64-v8a, armeabi-v7a, x86_64, x86" >&2
|
||||
return 1
|
||||
esac
|
||||
popd
|
||||
|
||||
echo "*** [$ANDROID_ABI][Start] Build and install vcpkg dependencies"
|
||||
pushd "$SCRIPTDIR/.."
|
||||
$VCPKG_ROOT/vcpkg install --triplet $VCPKG_TARGET --x-install-root="$VCPKG_ROOT/installed"
|
||||
popd
|
||||
head -n 100 "${VCPKG_ROOT}/buildtrees/ffmpeg/build-$VCPKG_TARGET-rel-out.log" || true
|
||||
echo "*** [$ANDROID_ABI][Finished] Build and install vcpkg dependencies"
|
||||
echo "INFO: Completed building vcpkg dependencies for Android ${ANDROID_ABI}"
|
||||
|
||||
if [ -d "$VCPKG_ROOT/installed/arm-neon-android" ]; then
|
||||
echo "*** [Start] Move arm-neon-android to arm-android"
|
||||
if [ "${ANDROID_ABI}" = 'armeabi-v7a' ]; then
|
||||
# Symlink arm-neon-android to arm-android because cargo-ndk does not
|
||||
# understand NEON suffix.
|
||||
|
||||
mv "$VCPKG_ROOT/installed/arm-neon-android" "$VCPKG_ROOT/installed/arm-android"
|
||||
if [ -d "${VCPKG_ROOT}/installed/arm-neon-android" ]; then
|
||||
echo 'INFO: Symlinking arm-neon-android to arm-android'
|
||||
|
||||
echo "*** [Finished] Move arm-neon-android to arm-android"
|
||||
fi
|
||||
}
|
||||
|
||||
if [ ! -z "$ANDROID_ABI" ]; then
|
||||
build "$ANDROID_ABI"
|
||||
else
|
||||
echo "Usage: build-android-deps.sh <ANDROID-ABI>" >&2
|
||||
exit 1
|
||||
ln -sf \
|
||||
"${VCPKG_ROOT}/installed/arm-neon-android" \
|
||||
"${VCPKG_ROOT}/installed/arm-android"
|
||||
|
||||
echo 'INFO: Symlinked arm-neon-android to arm-android'
|
||||
else
|
||||
cat 0<<.a
|
||||
ERROR: 'vcpkg install' seem to complete successfully but
|
||||
directory '${VCPKG_ROOT}/installed/arm-neon-android' is missing!
|
||||
|
||||
.a
|
||||
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CLIENT_ID</key>
|
||||
<string>768133699366-k1rn3ls1u2n3nklmgd9t4cmpdob0c8bn.apps.googleusercontent.com</string>
|
||||
<key>REVERSED_CLIENT_ID</key>
|
||||
<string>com.googleusercontent.apps.768133699366-k1rn3ls1u2n3nklmgd9t4cmpdob0c8bn</string>
|
||||
<key>API_KEY</key>
|
||||
<string>AIzaSyCf57HjCwSokt91CqFI0Mwf8D--ek0jvfc</string>
|
||||
<key>GCM_SENDER_ID</key>
|
||||
<string>768133699366</string>
|
||||
<key>PLIST_VERSION</key>
|
||||
<string>1</string>
|
||||
<key>BUNDLE_ID</key>
|
||||
<string>com.carriez.flutterHbb</string>
|
||||
<key>PROJECT_ID</key>
|
||||
<string>rustdesk</string>
|
||||
<key>STORAGE_BUCKET</key>
|
||||
<string>rustdesk.appspot.com</string>
|
||||
<key>IS_ADS_ENABLED</key>
|
||||
<false></false>
|
||||
<key>IS_ANALYTICS_ENABLED</key>
|
||||
<false></false>
|
||||
<key>IS_APPINVITE_ENABLED</key>
|
||||
<true></true>
|
||||
<key>IS_GCM_ENABLED</key>
|
||||
<true></true>
|
||||
<key>IS_SIGNIN_ENABLED</key>
|
||||
<true></true>
|
||||
<key>GOOGLE_APP_ID</key>
|
||||
<string>1:768133699366:ios:c33078a6181b9d507993e7</string>
|
||||
<key>DATABASE_URL</key>
|
||||
<string>https://rustdesk.firebaseio.com</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -84,8 +84,6 @@ const double _kPositionEpsilon = 1e-6;
|
||||
bool get isMainDesktopWindow =>
|
||||
desktopType == DesktopType.main || desktopType == DesktopType.cm;
|
||||
|
||||
String get screenInfo => screenInfo_;
|
||||
|
||||
/// Check if the app is running with single view mode.
|
||||
bool isSingleViewApp() {
|
||||
return desktopType == DesktopType.cm;
|
||||
@@ -1185,6 +1183,48 @@ void msgBox(SessionID sessionId, String type, String title, String text,
|
||||
VoidCallback? onSubmit,
|
||||
int? submitTimeout}) {
|
||||
dialogManager.dismissAll();
|
||||
if (type.contains('insecure-connection')) {
|
||||
Future<void> closeSession() async {
|
||||
await bind.sessionSetCommon(
|
||||
sessionId: sessionId,
|
||||
key: 'continue-insecure-connection',
|
||||
value: 'N',
|
||||
);
|
||||
dialogManager.dismissAll();
|
||||
closeConnection();
|
||||
}
|
||||
|
||||
void continueSession() {
|
||||
unawaited(
|
||||
bind.sessionSetCommon(
|
||||
sessionId: sessionId,
|
||||
key: 'continue-insecure-connection',
|
||||
value: 'Y',
|
||||
),
|
||||
);
|
||||
dialogManager.dismissAll();
|
||||
}
|
||||
|
||||
dialogManager.show(
|
||||
(setState, close, context) => CustomAlertDialog(
|
||||
title: null,
|
||||
content: SelectionArea(child: msgboxContent(type, title, text)),
|
||||
actions: [
|
||||
dialogButton(
|
||||
'Continue',
|
||||
onPressed: continueSession,
|
||||
isOutline: true,
|
||||
),
|
||||
dialogButton('Disconnect', onPressed: closeSession),
|
||||
],
|
||||
onSubmit: closeSession,
|
||||
onCancel: closeSession,
|
||||
),
|
||||
tag: '$sessionId-$type-$title-$text-$link',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
List<Widget> buttons = [];
|
||||
bool hasOk = false;
|
||||
submit() {
|
||||
@@ -3082,6 +3122,15 @@ void onCopyFingerprint(String value) {
|
||||
}
|
||||
}
|
||||
|
||||
void onCopyId(String value) {
|
||||
if (value.isNotEmpty) {
|
||||
Clipboard.setData(ClipboardData(text: value));
|
||||
showToast('$value\n${translate("Copied")}');
|
||||
} else {
|
||||
showToast(translate("Invalid ID"));
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> callMainCheckSuperUserPermission() async {
|
||||
bool checked = await bind.mainCheckSuperUserPermission();
|
||||
if (isMacOS) {
|
||||
@@ -3350,7 +3399,12 @@ Future<List<Rect>> getScreenRectList() async {
|
||||
}
|
||||
|
||||
openMonitorInTheSameTab(int i, FFI ffi, PeerInfo pi,
|
||||
{bool updateCursorPos = true}) {
|
||||
{bool updateCursorPos = true, bool recordSelection = true}) {
|
||||
if (recordSelection) {
|
||||
ffi.ffiModel.lastUserDisplay = i;
|
||||
ffi.ffiModel.cancelPendingRestoreTimer();
|
||||
ffi.ffiModel.pendingMonitorRestore = null;
|
||||
}
|
||||
final displays = i == kAllDisplayValue
|
||||
? List.generate(pi.displays.length, (index) => index)
|
||||
: [i];
|
||||
@@ -3713,14 +3767,54 @@ Widget loadPowered(BuildContext context) {
|
||||
).marginOnly(top: 6);
|
||||
}
|
||||
|
||||
// max 300 x 60
|
||||
Widget loadLogo() {
|
||||
return FutureBuilder<ByteData>(
|
||||
future: rootBundle.load('assets/logo.png'),
|
||||
builder: (BuildContext context, AsyncSnapshot<ByteData> snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
const _kDefaultLogoAsset = 'assets/logo.png';
|
||||
const _kLightLogoAsset = 'assets/logo_light.png';
|
||||
const _kDarkLogoAsset = 'assets/logo_dark.png';
|
||||
|
||||
List<String> _logoAssetCandidatesForBrightness(Brightness brightness) {
|
||||
return brightness == Brightness.dark
|
||||
? [_kDarkLogoAsset, _kDefaultLogoAsset]
|
||||
: [_kLightLogoAsset, _kDefaultLogoAsset];
|
||||
}
|
||||
|
||||
Future<String?> _resolveLogoAsset(Brightness brightness) async {
|
||||
for (final asset in _logoAssetCandidatesForBrightness(brightness)) {
|
||||
try {
|
||||
await rootBundle.load(asset);
|
||||
return asset;
|
||||
} on FlutterError {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
class _Logo extends StatefulWidget {
|
||||
const _Logo();
|
||||
|
||||
@override
|
||||
State<_Logo> createState() => _LogoState();
|
||||
}
|
||||
|
||||
class _LogoState extends State<_Logo> {
|
||||
final Map<Brightness, Future<String?>> _logoFutures = {};
|
||||
|
||||
Future<String?> _logoFutureFor(Brightness brightness) {
|
||||
return _logoFutures.putIfAbsent(
|
||||
brightness,
|
||||
() => _resolveLogoAsset(brightness),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<String?>(
|
||||
future: _logoFutureFor(Theme.of(context).brightness),
|
||||
builder: (BuildContext context, AsyncSnapshot<String?> snapshot) {
|
||||
final asset = snapshot.data;
|
||||
if (asset != null) {
|
||||
final image = Image.asset(
|
||||
'assets/logo.png',
|
||||
asset,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (ctx, error, stackTrace) {
|
||||
return Container();
|
||||
@@ -3732,9 +3826,14 @@ Widget loadLogo() {
|
||||
).marginOnly(left: 12, right: 12, top: 12);
|
||||
}
|
||||
return const Offstage();
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// max 300 x 60
|
||||
Widget loadLogo() => const _Logo();
|
||||
|
||||
Widget loadIcon(double size) {
|
||||
return Image.asset('assets/icon.png',
|
||||
width: size,
|
||||
@@ -3912,6 +4011,11 @@ bool whitelistNotEmpty() {
|
||||
return v != '' && v != ',';
|
||||
}
|
||||
|
||||
bool idWhitelistNotEmpty() {
|
||||
final v = bind.mainGetOptionSync(key: kOptionIdWhitelist);
|
||||
return v != '' && v != ',';
|
||||
}
|
||||
|
||||
// `setMovable()` is only supported on macOS.
|
||||
//
|
||||
// On macOS, the window can be dragged by the tab bar by default.
|
||||
@@ -3942,7 +4046,8 @@ Widget netWorkErrorWidget() {
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(translate("network_error_tip")),
|
||||
if (!gFFI.userModel.networkErrorFromServer.value)
|
||||
Text(translate("network_error_tip")),
|
||||
ElevatedButton(
|
||||
onPressed: gFFI.userModel.refreshCurrentUser,
|
||||
child: Text(translate("Retry")))
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hbb/common/formatter/id_formatter.dart';
|
||||
import '../../../models/platform_model.dart';
|
||||
@@ -5,27 +8,136 @@ import 'package:flutter_hbb/models/peer_model.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/common/widgets/peer_card.dart';
|
||||
|
||||
@visibleForTesting
|
||||
List<Peer> mergeAutocompletePeers({
|
||||
Iterable<Peer> addressBookPeers = const [],
|
||||
Iterable<Peer> groupPeers = const [],
|
||||
Iterable<Peer> lanPeers = const [],
|
||||
Iterable<Peer> recentPeers = const [],
|
||||
Iterable<String> restRecentPeerIds = const [],
|
||||
}) {
|
||||
final combinedPeers = <String, Peer>{};
|
||||
|
||||
void addPeer(Peer peer) {
|
||||
if (peer.id.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final existingPeer = combinedPeers[peer.id];
|
||||
if (existingPeer == null) {
|
||||
combinedPeers[peer.id] = Peer.copy(peer);
|
||||
} else if (peer.online) {
|
||||
existingPeer.online = true;
|
||||
}
|
||||
}
|
||||
|
||||
for (final peer in addressBookPeers) {
|
||||
addPeer(peer);
|
||||
}
|
||||
for (final peer in groupPeers) {
|
||||
addPeer(peer);
|
||||
}
|
||||
for (final peer in lanPeers) {
|
||||
addPeer(peer);
|
||||
}
|
||||
for (final peer in recentPeers) {
|
||||
addPeer(peer);
|
||||
}
|
||||
for (final id in restRecentPeerIds) {
|
||||
if (id.isNotEmpty && !combinedPeers.containsKey(id)) {
|
||||
combinedPeers[id] = Peer.fromJson({'id': id});
|
||||
}
|
||||
}
|
||||
|
||||
return combinedPeers.values.toList(growable: false);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
bool updateAutocompletePeerOnlineStates(
|
||||
List<Peer> peers, {
|
||||
required Set<String> onlines,
|
||||
required Set<String> offlines,
|
||||
}) {
|
||||
var changed = false;
|
||||
for (final peer in peers) {
|
||||
if (onlines.contains(peer.id)) {
|
||||
if (!peer.online) {
|
||||
peer.online = true;
|
||||
changed = true;
|
||||
}
|
||||
} else if (offlines.contains(peer.id)) {
|
||||
if (peer.online) {
|
||||
peer.online = false;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
List<String> autocompleteOnlineQueryIds(
|
||||
Iterable<Peer> options, {
|
||||
required int limit,
|
||||
}) {
|
||||
final ids = <String>[];
|
||||
final seenIds = <String>{};
|
||||
for (final peer in options) {
|
||||
if (peer.id.isEmpty || seenIds.contains(peer.id)) {
|
||||
continue;
|
||||
}
|
||||
seenIds.add(peer.id);
|
||||
ids.add(peer.id);
|
||||
if (ids.length >= limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
class AllPeersLoader {
|
||||
List<Peer> peers = [];
|
||||
|
||||
bool _isPeersLoading = false;
|
||||
bool _isPeersLoaded = false;
|
||||
Set<String> _lastQueryOnlineIds = {};
|
||||
DateTime _lastQueryOnlineTime = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
Timer? _queryOnlineTimer;
|
||||
List<Peer> _lastQueryOnlineOptions = const [];
|
||||
Set<String> _lastOnlineIds = {};
|
||||
Set<String> _lastOfflineIds = {};
|
||||
final Future<void> Function(List<String> ids) _queryOnlines;
|
||||
final Duration _queryOnlineDebounce;
|
||||
void Function(VoidCallback)? _setState;
|
||||
bool _isCleared = false;
|
||||
|
||||
final String _listenerKey = 'AllPeersLoader';
|
||||
|
||||
late void Function(VoidCallback) setState;
|
||||
static const String _cbQueryOnlines = 'callback_query_onlines';
|
||||
static const Duration _queryOnlineInterval = Duration(seconds: 5);
|
||||
static const Duration _defaultQueryOnlineDebounce =
|
||||
Duration(milliseconds: 300);
|
||||
static const int _maxQueryOnlineOptions = 20;
|
||||
|
||||
bool get needLoad => !_isPeersLoaded && !_isPeersLoading;
|
||||
bool get isPeersLoaded => _isPeersLoaded;
|
||||
|
||||
AllPeersLoader();
|
||||
AllPeersLoader({
|
||||
@visibleForTesting Future<void> Function(List<String> ids)? queryOnlines,
|
||||
@visibleForTesting Duration? queryOnlineDebounce,
|
||||
}) : _queryOnlines = queryOnlines ?? ((ids) => bind.queryOnlines(ids: ids)),
|
||||
_queryOnlineDebounce =
|
||||
queryOnlineDebounce ?? _defaultQueryOnlineDebounce;
|
||||
|
||||
void init(void Function(VoidCallback) setState) {
|
||||
this.setState = setState;
|
||||
_setState = setState;
|
||||
_isCleared = false;
|
||||
gFFI.recentPeersModel.addListener(_mergeAllPeers);
|
||||
gFFI.lanPeersModel.addListener(_mergeAllPeers);
|
||||
gFFI.abModel.addPeerUpdateListener(_listenerKey, _mergeAllPeers);
|
||||
gFFI.groupModel.addPeerUpdateListener(_listenerKey, _mergeAllPeers);
|
||||
platformFFI.registerEventHandler(_cbQueryOnlines, _listenerKey,
|
||||
(evt) async {
|
||||
_updateOnlineState(evt);
|
||||
});
|
||||
}
|
||||
|
||||
void clear() {
|
||||
@@ -33,6 +145,11 @@ class AllPeersLoader {
|
||||
gFFI.lanPeersModel.removeListener(_mergeAllPeers);
|
||||
gFFI.abModel.removePeerUpdateListener(_listenerKey);
|
||||
gFFI.groupModel.removePeerUpdateListener(_listenerKey);
|
||||
platformFFI.unregisterEventHandler(_cbQueryOnlines, _listenerKey);
|
||||
_queryOnlineTimer?.cancel();
|
||||
_lastQueryOnlineOptions = const [];
|
||||
_setState = null;
|
||||
_isCleared = true;
|
||||
}
|
||||
|
||||
Future<void> getAllPeers() async {
|
||||
@@ -59,50 +176,106 @@ class AllPeersLoader {
|
||||
}
|
||||
|
||||
void _mergeAllPeers() {
|
||||
Map<String, dynamic> combinedPeers = {};
|
||||
for (var p in gFFI.abModel.allPeers()) {
|
||||
if (!combinedPeers.containsKey(p.id)) {
|
||||
combinedPeers[p.id] = p.toJson();
|
||||
}
|
||||
if (_isCleared) {
|
||||
return;
|
||||
}
|
||||
for (var p in gFFI.groupModel.peers.map((e) => Peer.copy(e)).toList()) {
|
||||
if (!combinedPeers.containsKey(p.id)) {
|
||||
combinedPeers[p.id] = p.toJson();
|
||||
}
|
||||
}
|
||||
|
||||
List<Peer> parsedPeers = [];
|
||||
for (var peer in combinedPeers.values) {
|
||||
parsedPeers.add(Peer.fromJson(peer));
|
||||
}
|
||||
|
||||
Set<String> peerIds = combinedPeers.keys.toSet();
|
||||
for (final peer in gFFI.lanPeersModel.peers) {
|
||||
if (!peerIds.contains(peer.id)) {
|
||||
parsedPeers.add(peer);
|
||||
peerIds.add(peer.id);
|
||||
}
|
||||
}
|
||||
|
||||
for (final peer in gFFI.recentPeersModel.peers) {
|
||||
if (!peerIds.contains(peer.id)) {
|
||||
parsedPeers.add(peer);
|
||||
peerIds.add(peer.id);
|
||||
}
|
||||
}
|
||||
for (final id in gFFI.recentPeersModel.restPeerIds) {
|
||||
if (!peerIds.contains(id)) {
|
||||
parsedPeers.add(Peer.fromJson({'id': id}));
|
||||
peerIds.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
peers = parsedPeers;
|
||||
setState(() {
|
||||
peers = mergeAutocompletePeers(
|
||||
addressBookPeers: gFFI.abModel.allPeers(),
|
||||
groupPeers: gFFI.groupModel.peers,
|
||||
lanPeers: gFFI.lanPeersModel.peers,
|
||||
recentPeers: gFFI.recentPeersModel.peers,
|
||||
restRecentPeerIds: gFFI.recentPeersModel.restPeerIds,
|
||||
);
|
||||
_applyLastOnlineState(peers);
|
||||
_scheduleSetState(() {
|
||||
_isPeersLoading = false;
|
||||
_isPeersLoaded = true;
|
||||
});
|
||||
}
|
||||
|
||||
void _updateOnlineState(Map<String, dynamic> evt) {
|
||||
if (_isCleared) {
|
||||
return;
|
||||
}
|
||||
_lastOnlineIds = _splitPeerIds(evt['onlines']);
|
||||
_lastOfflineIds = _splitPeerIds(evt['offlines']);
|
||||
final peersChanged = _applyLastOnlineState(peers);
|
||||
final optionsChanged = _applyLastOnlineState(_lastQueryOnlineOptions);
|
||||
if (peersChanged || optionsChanged) {
|
||||
_scheduleSetState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
void _scheduleSetState(VoidCallback callback) {
|
||||
if (_isCleared) {
|
||||
return;
|
||||
}
|
||||
final setState = _setState;
|
||||
if (setState == null) {
|
||||
callback();
|
||||
} else {
|
||||
setState(callback);
|
||||
}
|
||||
}
|
||||
|
||||
bool _applyLastOnlineState(List<Peer> peers) {
|
||||
return updateAutocompletePeerOnlineStates(
|
||||
peers,
|
||||
onlines: _lastOnlineIds,
|
||||
offlines: _lastOfflineIds,
|
||||
);
|
||||
}
|
||||
|
||||
Set<String> _splitPeerIds(dynamic ids) {
|
||||
if (ids is! String || ids.isEmpty) {
|
||||
return {};
|
||||
}
|
||||
return ids.split(',').where((id) => id.isNotEmpty).toSet();
|
||||
}
|
||||
|
||||
void queryOnlines(Iterable<Peer> options) {
|
||||
if (_isCleared) {
|
||||
return;
|
||||
}
|
||||
_lastQueryOnlineOptions = options.toList(growable: false);
|
||||
final ids = autocompleteOnlineQueryIds(
|
||||
_lastQueryOnlineOptions,
|
||||
limit: _maxQueryOnlineOptions,
|
||||
).toSet();
|
||||
_queryOnlineTimer?.cancel();
|
||||
_queryOnlineTimer = null;
|
||||
if (ids.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final now = DateTime.now();
|
||||
if (setEquals(ids, _lastQueryOnlineIds) &&
|
||||
now.difference(_lastQueryOnlineTime) < _queryOnlineInterval) {
|
||||
return;
|
||||
}
|
||||
|
||||
_queryOnlineTimer = Timer(_queryOnlineDebounce, () async {
|
||||
try {
|
||||
await _queryOnlines(ids.toList(growable: false));
|
||||
if (_isCleared) {
|
||||
return;
|
||||
}
|
||||
_lastQueryOnlineIds = ids;
|
||||
_lastQueryOnlineTime = DateTime.now();
|
||||
} catch (e) {
|
||||
debugPrint('query autocomplete online state failed: $e');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
void updateOnlineStateForTesting(Map<String, dynamic> evt) {
|
||||
_updateOnlineState(evt);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
bool applyLastOnlineStateForTesting(List<Peer> peers) {
|
||||
return _applyLastOnlineState(peers);
|
||||
}
|
||||
}
|
||||
|
||||
class AutocompletePeerTile extends StatefulWidget {
|
||||
|
||||
@@ -205,6 +205,10 @@ void changeWhiteList({Function()? callback}) async {
|
||||
const SizedBox(
|
||||
height: 8.0,
|
||||
),
|
||||
Text(translate("whitelist_cidr_tip")),
|
||||
const SizedBox(
|
||||
height: 8.0,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
@@ -282,6 +286,111 @@ void changeWhiteList({Function()? callback}) async {
|
||||
});
|
||||
}
|
||||
|
||||
void changeIdWhiteList({Function()? callback}) async {
|
||||
final curIdWhiteList = await bind.mainGetOption(key: kOptionIdWhitelist);
|
||||
var newIdWhiteListField = curIdWhiteList == defaultOptionWhitelist
|
||||
? ''
|
||||
: curIdWhiteList.split(',').join('\n');
|
||||
var controller = TextEditingController(text: newIdWhiteListField);
|
||||
var msg = "";
|
||||
var isInProgress = false;
|
||||
final isOptFixed = isOptionFixed(kOptionIdWhitelist);
|
||||
gFFI.dialogManager.show((setState, close, context) {
|
||||
return CustomAlertDialog(
|
||||
title: Text(translate("ID whitelisting")),
|
||||
content: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(translate("whitelist_sep")),
|
||||
const SizedBox(
|
||||
height: 8.0,
|
||||
),
|
||||
Text(translate("id_whitelist_wildcard_tip")),
|
||||
const SizedBox(
|
||||
height: 8.0,
|
||||
),
|
||||
Text(translate("id_whitelist_caveat_tip")),
|
||||
const SizedBox(
|
||||
height: 8.0,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
maxLines: null,
|
||||
decoration: InputDecoration(
|
||||
errorText: msg.isEmpty ? null : translate(msg),
|
||||
),
|
||||
controller: controller,
|
||||
enabled: !isOptFixed,
|
||||
autofocus: true)
|
||||
.workaroundFreezeLinuxMint(),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(
|
||||
height: 4.0,
|
||||
),
|
||||
// NOT use Offstage to wrap LinearProgressIndicator
|
||||
if (isInProgress) const LinearProgressIndicator(),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
dialogButton("Cancel", onPressed: close, isOutline: true),
|
||||
if (!isOptFixed)
|
||||
dialogButton("Clear", onPressed: () async {
|
||||
await bind.mainSetOption(
|
||||
key: kOptionIdWhitelist, value: defaultOptionWhitelist);
|
||||
callback?.call();
|
||||
close();
|
||||
}, isOutline: true),
|
||||
if (!isOptFixed)
|
||||
dialogButton(
|
||||
"OK",
|
||||
onPressed: () async {
|
||||
setState(() {
|
||||
msg = "";
|
||||
isInProgress = true;
|
||||
});
|
||||
newIdWhiteListField = controller.text.trim();
|
||||
var newIdWhiteList = "";
|
||||
if (newIdWhiteListField.isEmpty) {
|
||||
// pass
|
||||
} else {
|
||||
final ids = newIdWhiteListField
|
||||
.trim()
|
||||
.split(RegExp(r"[\s,;\n]+"))
|
||||
.where((e) => e.isNotEmpty)
|
||||
.toList();
|
||||
// Separators are handled above; allow all other Unicode characters.
|
||||
for (final id in ids) {
|
||||
final hasControlCharacters = id.runes.any(
|
||||
(char) => char <= 0x1f || (char >= 0x7f && char <= 0x9f));
|
||||
if (hasControlCharacters) {
|
||||
msg = "${translate("Invalid ID")} $id";
|
||||
setState(() {
|
||||
isInProgress = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
newIdWhiteList = ids.join(',');
|
||||
}
|
||||
if (newIdWhiteList.trim().isEmpty) {
|
||||
newIdWhiteList = defaultOptionWhitelist;
|
||||
}
|
||||
await bind.mainSetOption(
|
||||
key: kOptionIdWhitelist, value: newIdWhiteList);
|
||||
callback?.call();
|
||||
close();
|
||||
},
|
||||
),
|
||||
],
|
||||
onCancel: close,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<String> changeDirectAccessPort(
|
||||
String currentIP, String currentPort) async {
|
||||
final controller = TextEditingController(text: currentPort);
|
||||
@@ -827,26 +936,19 @@ void enterPasswordDialog(
|
||||
);
|
||||
}
|
||||
|
||||
void enterUserLoginDialog(
|
||||
SessionID sessionId,
|
||||
OverlayDialogManager dialogManager,
|
||||
String osAccountDescTip,
|
||||
bool canRememberAccount) async {
|
||||
void enterUserLoginDialog(SessionID sessionId,
|
||||
OverlayDialogManager dialogManager, String osAccountDescTip) async {
|
||||
await _connectDialog(
|
||||
sessionId,
|
||||
dialogManager,
|
||||
osUsernameController: TextEditingController(),
|
||||
osPasswordController: TextEditingController(),
|
||||
osAccountDescTip: osAccountDescTip,
|
||||
canRememberAccount: canRememberAccount,
|
||||
);
|
||||
}
|
||||
|
||||
void enterUserLoginAndPasswordDialog(
|
||||
SessionID sessionId,
|
||||
OverlayDialogManager dialogManager,
|
||||
String osAccountDescTip,
|
||||
bool canRememberAccount) async {
|
||||
void enterUserLoginAndPasswordDialog(SessionID sessionId,
|
||||
OverlayDialogManager dialogManager, String osAccountDescTip) async {
|
||||
await _connectDialog(
|
||||
sessionId,
|
||||
dialogManager,
|
||||
@@ -854,7 +956,6 @@ void enterUserLoginAndPasswordDialog(
|
||||
osPasswordController: TextEditingController(),
|
||||
passwordController: TextEditingController(),
|
||||
osAccountDescTip: osAccountDescTip,
|
||||
canRememberAccount: canRememberAccount,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -865,7 +966,6 @@ _connectDialog(
|
||||
TextEditingController? osPasswordController,
|
||||
TextEditingController? passwordController,
|
||||
String? osAccountDescTip,
|
||||
bool canRememberAccount = true,
|
||||
}) async {
|
||||
final errUsername = ''.obs;
|
||||
var rememberPassword = false;
|
||||
@@ -873,11 +973,6 @@ _connectDialog(
|
||||
rememberPassword =
|
||||
await bind.sessionGetRemember(sessionId: sessionId) ?? false;
|
||||
}
|
||||
var rememberAccount = false;
|
||||
if (canRememberAccount && osUsernameController != null) {
|
||||
rememberAccount =
|
||||
await bind.sessionGetRemember(sessionId: sessionId) ?? false;
|
||||
}
|
||||
if (osUsernameController != null) {
|
||||
osUsernameController.addListener(() {
|
||||
if (errUsername.value.isNotEmpty) {
|
||||
@@ -905,12 +1000,6 @@ _connectDialog(
|
||||
final osPassword = osPasswordController?.text.trim() ?? '';
|
||||
final password = passwordController?.text.trim() ?? '';
|
||||
if (passwordController != null && password.isEmpty) return;
|
||||
if (rememberAccount) {
|
||||
bind.sessionPeerOption(
|
||||
sessionId: sessionId, name: 'os-username', value: osUsername);
|
||||
bind.sessionPeerOption(
|
||||
sessionId: sessionId, name: 'os-password', value: osPassword);
|
||||
}
|
||||
gFFI.login(
|
||||
osUsername,
|
||||
osPassword,
|
||||
@@ -987,16 +1076,6 @@ _connectDialog(
|
||||
controller: osPasswordController,
|
||||
autoFocus: false,
|
||||
),
|
||||
if (canRememberAccount)
|
||||
rememberWidget(
|
||||
translate('remember_account_tip'),
|
||||
rememberAccount,
|
||||
(v) {
|
||||
if (v != null) {
|
||||
setState(() => rememberAccount = v);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -1433,91 +1512,6 @@ showSetOSPassword(
|
||||
});
|
||||
}
|
||||
|
||||
showSetOSAccount(
|
||||
SessionID sessionId,
|
||||
OverlayDialogManager dialogManager,
|
||||
) async {
|
||||
final usernameController = TextEditingController();
|
||||
final passwdController = TextEditingController();
|
||||
var username =
|
||||
await bind.sessionGetOption(sessionId: sessionId, arg: 'os-username') ??
|
||||
'';
|
||||
var password =
|
||||
await bind.sessionGetOption(sessionId: sessionId, arg: 'os-password') ??
|
||||
'';
|
||||
usernameController.text = username;
|
||||
passwdController.text = password;
|
||||
dialogManager.show((setState, close, context) {
|
||||
submit() {
|
||||
final username = usernameController.text.trim();
|
||||
final password = usernameController.text.trim();
|
||||
bind.sessionPeerOption(
|
||||
sessionId: sessionId, name: 'os-username', value: username);
|
||||
bind.sessionPeerOption(
|
||||
sessionId: sessionId, name: 'os-password', value: password);
|
||||
close();
|
||||
}
|
||||
|
||||
descWidget(String text) {
|
||||
return Column(
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
text,
|
||||
maxLines: 3,
|
||||
softWrap: true,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
height: 8,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return CustomAlertDialog(
|
||||
title: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.password_rounded, color: MyTheme.accent),
|
||||
Text(translate('OS Account')).paddingOnly(left: 10),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
descWidget(translate("os_account_desk_tip")),
|
||||
DialogTextField(
|
||||
title: translate(DialogTextField.kUsernameTitle),
|
||||
controller: usernameController,
|
||||
prefixIcon: DialogTextField.kUsernameIcon,
|
||||
errorText: null,
|
||||
),
|
||||
PasswordWidget(controller: passwdController),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
dialogButton(
|
||||
"Cancel",
|
||||
icon: Icon(Icons.close_rounded),
|
||||
onPressed: close,
|
||||
isOutline: true,
|
||||
),
|
||||
dialogButton(
|
||||
"OK",
|
||||
icon: Icon(Icons.done_rounded),
|
||||
onPressed: submit,
|
||||
),
|
||||
],
|
||||
onSubmit: submit,
|
||||
onCancel: close,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Widget buildNoteTextField({
|
||||
required TextEditingController controller,
|
||||
required VoidCallback onEscape,
|
||||
@@ -1905,26 +1899,110 @@ customImageQualityDialog(SessionID sessionId, String id, FFI ffi) async {
|
||||
msgBoxCommon(ffi.dialogManager, 'Custom Image Quality', content, [btnClose]);
|
||||
}
|
||||
|
||||
trackpadSpeedDialog(SessionID sessionId, FFI ffi) async {
|
||||
int initSpeed = ffi.inputModel.trackpadSpeed;
|
||||
int? _validateTrackpadSpeed(String text) {
|
||||
final speed = int.tryParse(text);
|
||||
if (speed == null || speed < kMinTrackpadSpeed || speed > kMaxTrackpadSpeed) {
|
||||
BotToast.showText(
|
||||
text:
|
||||
'${translate('Invalid format')}: $kMinTrackpadSpeed-$kMaxTrackpadSpeed',
|
||||
contentColor: Colors.red,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return speed;
|
||||
}
|
||||
|
||||
Future<void> _saveTrackpadSpeed({
|
||||
required SessionID sessionId,
|
||||
required FFI ffi,
|
||||
required int initSpeed,
|
||||
required int speed,
|
||||
}) async {
|
||||
if (speed == initSpeed) {
|
||||
return;
|
||||
}
|
||||
await bind.sessionSetTrackpadSpeed(sessionId: sessionId, value: speed);
|
||||
await ffi.inputModel.updateTrackpadSpeed();
|
||||
}
|
||||
|
||||
void _showTrackpadSpeedSaveError(Object error, StackTrace stackTrace) {
|
||||
debugPrint('Failed to save trackpad speed: $error');
|
||||
debugPrintStack(stackTrace: stackTrace);
|
||||
BotToast.showText(
|
||||
text: translate('Failed'),
|
||||
contentColor: Colors.red,
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _trackpadSpeedDialogActions({
|
||||
required bool isSubmitting,
|
||||
required VoidCallback close,
|
||||
required VoidCallback submit,
|
||||
}) {
|
||||
return [
|
||||
dialogButton(
|
||||
'Cancel',
|
||||
icon: Icon(Icons.close_rounded),
|
||||
onPressed: isSubmitting ? null : close,
|
||||
isOutline: true,
|
||||
),
|
||||
dialogButton(
|
||||
'OK',
|
||||
icon: Icon(Icons.done_rounded),
|
||||
onPressed: isSubmitting ? null : submit,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
void trackpadSpeedDialog(SessionID sessionId, FFI ffi) {
|
||||
final initSpeed = ffi.inputModel.trackpadSpeed;
|
||||
final curSpeed = SimpleWrapper(initSpeed);
|
||||
final btnClose = dialogButton('Close', onPressed: () async {
|
||||
if (curSpeed.value <= kMaxTrackpadSpeed &&
|
||||
curSpeed.value >= kMinTrackpadSpeed &&
|
||||
curSpeed.value != initSpeed) {
|
||||
await bind.sessionSetTrackpadSpeed(
|
||||
sessionId: sessionId, value: curSpeed.value);
|
||||
await ffi.inputModel.updateTrackpadSpeed();
|
||||
var speedText = initSpeed.toString();
|
||||
var isSubmitting = false;
|
||||
ffi.dialogManager.show((setState, close, context) {
|
||||
Future<void> submit([String? submittedText]) async {
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
}
|
||||
speedText = submittedText ?? speedText;
|
||||
final speed = _validateTrackpadSpeed(speedText);
|
||||
if (speed == null) {
|
||||
return;
|
||||
}
|
||||
setState(() => isSubmitting = true);
|
||||
try {
|
||||
await _saveTrackpadSpeed(
|
||||
sessionId: sessionId,
|
||||
ffi: ffi,
|
||||
initSpeed: initSpeed,
|
||||
speed: speed,
|
||||
);
|
||||
close();
|
||||
} catch (error, stackTrace) {
|
||||
_showTrackpadSpeedSaveError(error, stackTrace);
|
||||
setState(() => isSubmitting = false);
|
||||
}
|
||||
}
|
||||
ffi.dialogManager.dismissAll();
|
||||
});
|
||||
msgBoxCommon(
|
||||
ffi.dialogManager,
|
||||
'Trackpad speed',
|
||||
TrackpadSpeedWidget(
|
||||
value: curSpeed,
|
||||
|
||||
return CustomAlertDialog(
|
||||
title: Text(
|
||||
translate('Trackpad speed'),
|
||||
style: TextStyle(fontSize: 21),
|
||||
),
|
||||
[btnClose]);
|
||||
content: TrackpadSpeedWidget(
|
||||
value: curSpeed,
|
||||
onTextChanged: (text) => speedText = text,
|
||||
onTextSubmitted: submit,
|
||||
),
|
||||
actions: _trackpadSpeedDialogActions(
|
||||
isSubmitting: isSubmitting,
|
||||
close: close,
|
||||
submit: submit,
|
||||
),
|
||||
onSubmit: isSubmitting ? null : submit,
|
||||
onCancel: isSubmitting ? null : close,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void deleteConfirmDialog(Function onSubmit, String title) async {
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hbb/common/hbbs/hbbs.dart';
|
||||
import 'package:flutter_hbb/models/platform_model.dart';
|
||||
import 'package:flutter_hbb/models/user_model.dart';
|
||||
@@ -11,6 +12,7 @@ import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../common.dart';
|
||||
import './dialog.dart';
|
||||
import './oidc_auth_status.dart';
|
||||
|
||||
const kOpSvgList = [
|
||||
'github',
|
||||
@@ -23,6 +25,37 @@ const kOpSvgList = [
|
||||
'auth0',
|
||||
'microsoft'
|
||||
];
|
||||
const _requestingAccountAuth = 'Requesting account auth';
|
||||
const _waitingAccountAuth = 'Waiting account auth';
|
||||
|
||||
class _OidcProviderBranding {
|
||||
final String label;
|
||||
final String iconKey;
|
||||
|
||||
const _OidcProviderBranding({
|
||||
required this.label,
|
||||
required this.iconKey,
|
||||
});
|
||||
}
|
||||
|
||||
_OidcProviderBranding _oidcProviderBranding(String op) {
|
||||
switch (op.toLowerCase()) {
|
||||
case 'azure':
|
||||
return _OidcProviderBranding(
|
||||
label: 'Microsoft',
|
||||
iconKey: 'microsoft',
|
||||
);
|
||||
default:
|
||||
return _OidcProviderBranding(
|
||||
label: {
|
||||
'github': 'GitHub',
|
||||
'gitlab': 'GitLab',
|
||||
}[op.toLowerCase()] ??
|
||||
toCapitalized(op),
|
||||
iconKey: op.toLowerCase(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _IconOP extends StatelessWidget {
|
||||
final String op;
|
||||
@@ -61,6 +94,7 @@ class ButtonOP extends StatelessWidget {
|
||||
final Color primaryColor;
|
||||
final double height;
|
||||
final Function() onTap;
|
||||
final bool Function() canStartAuth;
|
||||
|
||||
const ButtonOP({
|
||||
Key? key,
|
||||
@@ -70,32 +104,29 @@ class ButtonOP extends StatelessWidget {
|
||||
required this.primaryColor,
|
||||
required this.height,
|
||||
required this.onTap,
|
||||
required this.canStartAuth,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final opLabel = {
|
||||
'github': 'GitHub',
|
||||
'gitlab': 'GitLab'
|
||||
}[op.toLowerCase()] ??
|
||||
toCapitalized(op);
|
||||
final branding = _oidcProviderBranding(op);
|
||||
final buttonLabel = translate("Continue with {${branding.label}}");
|
||||
return Row(children: [
|
||||
Container(
|
||||
height: height,
|
||||
width: 200,
|
||||
child: Obx(() => ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: curOP.value.isEmpty || curOP.value == op
|
||||
? primaryColor
|
||||
: Colors.grey,
|
||||
backgroundColor: primaryColor,
|
||||
).copyWith(elevation: ButtonStyleButton.allOrNull(0.0)),
|
||||
onPressed: curOP.value.isEmpty || curOP.value == op ? onTap : null,
|
||||
onPressed:
|
||||
curOP.value == 'rustdesk' || !canStartAuth() ? null : onTap,
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 30,
|
||||
child: _IconOP(
|
||||
op: op,
|
||||
op: branding.iconKey,
|
||||
icon: icon,
|
||||
margin: EdgeInsets.only(right: 5),
|
||||
),
|
||||
@@ -103,8 +134,7 @@ class ButtonOP extends StatelessWidget {
|
||||
Expanded(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Center(
|
||||
child: Text(translate("Continue with {$opLabel}"))),
|
||||
child: Center(child: Text(buttonLabel)),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -120,15 +150,120 @@ class ConfigOP {
|
||||
ConfigOP({required this.op, required this.icon});
|
||||
}
|
||||
|
||||
class _OidcAuthController {
|
||||
final RxString curOP = ''.obs;
|
||||
Future<void> _pendingOperation = Future<void>.value();
|
||||
int _authAttempt = 0;
|
||||
bool _closed = false;
|
||||
final _cancelInProgress = false.obs;
|
||||
|
||||
bool _isCurrent(int authAttempt, String op) {
|
||||
return !_closed && authAttempt == _authAttempt && curOP.value == op;
|
||||
}
|
||||
|
||||
Future<bool> start(String op) {
|
||||
if (!canStart()) {
|
||||
return Future<bool>.value(false);
|
||||
}
|
||||
final authAttempt = ++_authAttempt;
|
||||
curOP.value = op;
|
||||
// Web auth must start during the original user gesture so popups are allowed.
|
||||
if (isWeb) {
|
||||
return _startWeb(authAttempt, op);
|
||||
}
|
||||
final completer = Completer<bool>();
|
||||
_pendingOperation = _pendingOperation.then((_) async {
|
||||
if (!_isCurrent(authAttempt, op)) {
|
||||
completer.complete(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await bind.mainAccountAuthCancel();
|
||||
if (!_isCurrent(authAttempt, op)) {
|
||||
completer.complete(false);
|
||||
return;
|
||||
}
|
||||
await bind.mainAccountAuth(op: op, rememberMe: true);
|
||||
completer.complete(_isCurrent(authAttempt, op));
|
||||
} catch (error, stackTrace) {
|
||||
completer.completeError(error, stackTrace);
|
||||
}
|
||||
});
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
Future<bool> _startWeb(int authAttempt, String op) async {
|
||||
await bind.mainAccountAuth(op: op, rememberMe: true);
|
||||
return _isCurrent(authAttempt, op);
|
||||
}
|
||||
|
||||
bool canStart() {
|
||||
return !_closed && !_cancelInProgress.value;
|
||||
}
|
||||
|
||||
Future<bool> cancelCurrent(String op) {
|
||||
if (!canStart() || curOP.value != op) {
|
||||
return Future<bool>.value(false);
|
||||
}
|
||||
final authAttempt = ++_authAttempt;
|
||||
final completer = Completer<bool>();
|
||||
_cancelInProgress.value = true;
|
||||
_pendingOperation = _pendingOperation.then((_) async {
|
||||
try {
|
||||
await bind.mainAccountAuthCancel();
|
||||
completer.complete(_isCurrent(authAttempt, op));
|
||||
} catch (error, stackTrace) {
|
||||
completer.completeError(error, stackTrace);
|
||||
} finally {
|
||||
_cancelInProgress.value = false;
|
||||
}
|
||||
});
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
Future<void> _cancelBackend() async {
|
||||
try {
|
||||
await bind.mainAccountAuthCancel();
|
||||
} catch (error, stackTrace) {
|
||||
debugPrint('Failed to cancel account authentication $error');
|
||||
debugPrintStack(stackTrace: stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> close() async {
|
||||
if (_closed) {
|
||||
return;
|
||||
}
|
||||
final hasActiveOidcAuth =
|
||||
curOP.value.isNotEmpty && curOP.value != 'rustdesk';
|
||||
_closed = true;
|
||||
_authAttempt++;
|
||||
curOP.value = '';
|
||||
if (hasActiveOidcAuth) {
|
||||
await _cancelBackend();
|
||||
}
|
||||
await _pendingOperation;
|
||||
if (hasActiveOidcAuth) {
|
||||
await _cancelBackend();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class WidgetOP extends StatefulWidget {
|
||||
final ConfigOP config;
|
||||
final RxString curOP;
|
||||
final Function(Map<String, dynamic>) cbLogin;
|
||||
final Future<bool> Function(String) startAuth;
|
||||
final Future<bool> Function(String) cancelAuth;
|
||||
final bool Function() canStartAuth;
|
||||
const WidgetOP({
|
||||
Key? key,
|
||||
required this.config,
|
||||
required this.curOP,
|
||||
required this.cbLogin,
|
||||
required this.startAuth,
|
||||
required this.cancelAuth,
|
||||
required this.canStartAuth,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
@@ -139,6 +274,8 @@ class WidgetOP extends StatefulWidget {
|
||||
|
||||
class _WidgetOPState extends State<WidgetOP> {
|
||||
Timer? _updateTimer;
|
||||
bool _isAuthStatusQueryInFlight = false;
|
||||
int _authAttempt = 0;
|
||||
String _stateMsg = '';
|
||||
String _failedMsg = '';
|
||||
String _url = '';
|
||||
@@ -149,55 +286,180 @@ class _WidgetOPState extends State<WidgetOP> {
|
||||
_updateTimer?.cancel();
|
||||
}
|
||||
|
||||
_beginQueryState() {
|
||||
_beginQueryState(int authAttempt) {
|
||||
_updateTimer?.cancel();
|
||||
unawaited(_runAuthStatusQuery(() => _updateState(authAttempt)));
|
||||
_updateTimer = Timer.periodic(Duration(seconds: 1), (timer) {
|
||||
_updateState();
|
||||
unawaited(_runAuthStatusQuery(() => _updateState(authAttempt)));
|
||||
});
|
||||
}
|
||||
|
||||
_updateState() {
|
||||
bind.mainAccountAuthResult().then((result) {
|
||||
if (result.isEmpty) {
|
||||
Future<void> _runAuthStatusQuery(Future<void> Function() query) async {
|
||||
if (_isAuthStatusQueryInFlight) {
|
||||
return;
|
||||
}
|
||||
_isAuthStatusQueryInFlight = true;
|
||||
try {
|
||||
await query();
|
||||
} finally {
|
||||
_isAuthStatusQueryInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _launchAuthUrl(String url) async {
|
||||
try {
|
||||
final launched = await launchUrl(
|
||||
Uri.parse(url),
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
if (!launched) {
|
||||
debugPrint('Failed to open OIDC authentication URL');
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
debugPrint(
|
||||
'Failed to open OIDC authentication URL (${error.runtimeType})');
|
||||
debugPrintStack(stackTrace: stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _copyAuthUrl(String url) async {
|
||||
try {
|
||||
await Clipboard.setData(ClipboardData(text: url));
|
||||
showToast(
|
||||
translate('Copied'),
|
||||
);
|
||||
} catch (error, stackTrace) {
|
||||
debugPrint(
|
||||
'Failed to copy OIDC authentication URL (${error.runtimeType})');
|
||||
debugPrintStack(stackTrace: stackTrace);
|
||||
showToast(translate('Failed'));
|
||||
}
|
||||
}
|
||||
|
||||
void _runCurrentAuthUrlAction(
|
||||
int authAttempt,
|
||||
String authUrl,
|
||||
Future<void> Function(String) action,
|
||||
) {
|
||||
if (!mounted ||
|
||||
authAttempt != _authAttempt ||
|
||||
widget.curOP.value != widget.config.op ||
|
||||
authUrl.isEmpty ||
|
||||
_url != authUrl) {
|
||||
return;
|
||||
}
|
||||
unawaited(action(authUrl));
|
||||
}
|
||||
|
||||
void _invalidateAuthAttempt() {
|
||||
_authAttempt++;
|
||||
_url = '';
|
||||
}
|
||||
|
||||
bool _isCurrentAuthAttempt(int authAttempt) {
|
||||
return mounted &&
|
||||
authAttempt == _authAttempt &&
|
||||
widget.curOP.value == widget.config.op;
|
||||
}
|
||||
|
||||
Future<void> _handleAuthFailure(
|
||||
int authAttempt,
|
||||
Object error,
|
||||
String operation,
|
||||
) async {
|
||||
debugPrint('Failed to $operation $error');
|
||||
if (!_isCurrentAuthAttempt(authAttempt)) {
|
||||
return;
|
||||
}
|
||||
_updateTimer?.cancel();
|
||||
setState(() => _failedMsg = 'Failed');
|
||||
try {
|
||||
final canceled = await widget.cancelAuth(widget.config.op);
|
||||
if (!canceled || !_isCurrentAuthAttempt(authAttempt)) {
|
||||
return;
|
||||
}
|
||||
} catch (cancelError, stackTrace) {
|
||||
debugPrint('Failed to cancel account authentication $cancelError');
|
||||
debugPrintStack(stackTrace: stackTrace);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_invalidateAuthAttempt();
|
||||
widget.curOP.value = '';
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _updateState(int authAttempt) {
|
||||
if (!mounted ||
|
||||
authAttempt != _authAttempt ||
|
||||
widget.curOP.value != widget.config.op) {
|
||||
_updateTimer?.cancel();
|
||||
return Future<void>.value();
|
||||
}
|
||||
return bind.mainAccountAuthResult().then<void>((result) {
|
||||
if (!mounted ||
|
||||
authAttempt != _authAttempt ||
|
||||
widget.curOP.value != widget.config.op ||
|
||||
result.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final resultMap = jsonDecode(result);
|
||||
if (resultMap == null) {
|
||||
return;
|
||||
}
|
||||
final String stateMsg = resultMap['state_msg'];
|
||||
final String backendStateMsg = resultMap['state_msg'];
|
||||
String failedMsg = resultMap['failed_msg'];
|
||||
final String? url = resultMap['url'];
|
||||
final stateMsg = backendStateMsg == _requestingAccountAuth &&
|
||||
(url == null || url.isEmpty)
|
||||
? _waitingAccountAuth
|
||||
: backendStateMsg;
|
||||
final bool urlLaunched = (resultMap['url_launched'] as bool?) ?? false;
|
||||
final authBody = resultMap['auth_body'];
|
||||
if (_stateMsg != stateMsg || _failedMsg != failedMsg) {
|
||||
if (_url.isEmpty && url != null && url.isNotEmpty) {
|
||||
if (!urlLaunched) {
|
||||
launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
|
||||
}
|
||||
_url = url;
|
||||
}
|
||||
if (authBody != null) {
|
||||
_updateTimer?.cancel();
|
||||
widget.curOP.value = '';
|
||||
widget.cbLogin(authBody as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_stateMsg = stateMsg;
|
||||
_failedMsg = failedMsg;
|
||||
if (failedMsg.isNotEmpty) {
|
||||
widget.curOP.value = '';
|
||||
_updateTimer?.cancel();
|
||||
}
|
||||
});
|
||||
if (authBody != null) {
|
||||
_updateTimer?.cancel();
|
||||
_invalidateAuthAttempt();
|
||||
widget.curOP.value = '';
|
||||
widget.cbLogin(authBody as Map<String, dynamic>);
|
||||
return;
|
||||
}
|
||||
});
|
||||
final stateChanged = _stateMsg != stateMsg || _failedMsg != failedMsg;
|
||||
final newUrl = _url.isEmpty && url != null && url.isNotEmpty ? url : null;
|
||||
if (!stateChanged && newUrl == null) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_stateMsg = stateMsg;
|
||||
_failedMsg = failedMsg;
|
||||
if (newUrl != null) {
|
||||
_url = newUrl;
|
||||
}
|
||||
if (failedMsg.isNotEmpty) {
|
||||
_invalidateAuthAttempt();
|
||||
widget.curOP.value = '';
|
||||
_updateTimer?.cancel();
|
||||
}
|
||||
});
|
||||
if (newUrl != null && failedMsg.isEmpty && !urlLaunched) {
|
||||
unawaited(_launchAuthUrl(newUrl));
|
||||
}
|
||||
}).catchError(
|
||||
(e) => _handleAuthFailure(
|
||||
authAttempt,
|
||||
e,
|
||||
'query account authentication',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_resetState() {
|
||||
_stateMsg = '';
|
||||
_failedMsg = '';
|
||||
_url = '';
|
||||
int _resetState() {
|
||||
_updateTimer?.cancel();
|
||||
setState(() {
|
||||
_invalidateAuthAttempt();
|
||||
_stateMsg = _waitingAccountAuth;
|
||||
_failedMsg = '';
|
||||
});
|
||||
return _authAttempt;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -210,11 +472,31 @@ class _WidgetOPState extends State<WidgetOP> {
|
||||
icon: widget.config.icon,
|
||||
primaryColor: str2color(widget.config.op, 0x7f),
|
||||
height: 36,
|
||||
canStartAuth: widget.canStartAuth,
|
||||
onTap: () async {
|
||||
_resetState();
|
||||
widget.curOP.value = widget.config.op;
|
||||
await bind.mainAccountAuth(op: widget.config.op, rememberMe: true);
|
||||
_beginQueryState();
|
||||
if (!widget.canStartAuth()) {
|
||||
return;
|
||||
}
|
||||
final authAttempt = _resetState();
|
||||
try {
|
||||
final started = await widget.startAuth(widget.config.op);
|
||||
if (!started) {
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
await _handleAuthFailure(
|
||||
authAttempt,
|
||||
e,
|
||||
'start account authentication',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!mounted ||
|
||||
authAttempt != _authAttempt ||
|
||||
widget.curOP.value != widget.config.op) {
|
||||
return;
|
||||
}
|
||||
_beginQueryState(authAttempt);
|
||||
},
|
||||
),
|
||||
Obx(() {
|
||||
@@ -222,6 +504,8 @@ class _WidgetOPState extends State<WidgetOP> {
|
||||
widget.curOP.value != widget.config.op) {
|
||||
_failedMsg = '';
|
||||
}
|
||||
final authAttempt = _authAttempt;
|
||||
final authUrl = _url;
|
||||
return Offstage(
|
||||
offstage:
|
||||
_failedMsg.isEmpty && widget.curOP.value != widget.config.op,
|
||||
@@ -231,19 +515,27 @@ class _WidgetOPState extends State<WidgetOP> {
|
||||
if (_stateMsg.isNotEmpty && _failedMsg.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: SelectableText(
|
||||
translate(_stateMsg),
|
||||
style: DefaultTextStyle.of(context)
|
||||
.style
|
||||
.copyWith(fontSize: 12),
|
||||
child: OidcAuthStatus(
|
||||
message: translate(_stateMsg),
|
||||
browserFallbackPrompt: translate(
|
||||
"Browser didn't open? Use the url below to sign in.",
|
||||
),
|
||||
authUrl: authUrl,
|
||||
copyLabel: translate('Copy to clipboard'),
|
||||
onCopy: authUrl.isEmpty
|
||||
? null
|
||||
: () => _runCurrentAuthUrlAction(
|
||||
authAttempt,
|
||||
authUrl,
|
||||
_copyAuthUrl,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_failedMsg.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Builder(builder: (context) {
|
||||
final errorColor =
|
||||
Theme.of(context).colorScheme.error;
|
||||
final errorColor = Theme.of(context).colorScheme.error;
|
||||
final bgColor = Theme.of(context)
|
||||
.colorScheme
|
||||
.errorContainer
|
||||
@@ -264,12 +556,11 @@ class _WidgetOPState extends State<WidgetOP> {
|
||||
Flexible(
|
||||
child: SelectableText(
|
||||
translate(_failedMsg),
|
||||
style: DefaultTextStyle.of(context)
|
||||
.style
|
||||
.copyWith(
|
||||
fontSize: 13,
|
||||
color: errorColor,
|
||||
),
|
||||
style:
|
||||
DefaultTextStyle.of(context).style.copyWith(
|
||||
fontSize: 13,
|
||||
color: errorColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -281,34 +572,6 @@ class _WidgetOPState extends State<WidgetOP> {
|
||||
),
|
||||
);
|
||||
}),
|
||||
Obx(
|
||||
() => Offstage(
|
||||
offstage: widget.curOP.value != widget.config.op,
|
||||
child: const SizedBox(
|
||||
height: 5.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
Obx(
|
||||
() => Offstage(
|
||||
offstage: widget.curOP.value != widget.config.op,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: 20),
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
widget.curOP.value = '';
|
||||
_updateTimer?.cancel();
|
||||
_resetState();
|
||||
bind.mainAccountAuthCancel();
|
||||
},
|
||||
child: Text(
|
||||
translate('Cancel'),
|
||||
style: TextStyle(fontSize: 15),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -318,12 +581,18 @@ class LoginWidgetOP extends StatelessWidget {
|
||||
final List<ConfigOP> ops;
|
||||
final RxString curOP;
|
||||
final Function(Map<String, dynamic>) cbLogin;
|
||||
final Future<bool> Function(String) startAuth;
|
||||
final Future<bool> Function(String) cancelAuth;
|
||||
final bool Function() canStartAuth;
|
||||
|
||||
LoginWidgetOP({
|
||||
Key? key,
|
||||
required this.ops,
|
||||
required this.curOP,
|
||||
required this.cbLogin,
|
||||
required this.startAuth,
|
||||
required this.cancelAuth,
|
||||
required this.canStartAuth,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
@@ -334,6 +603,9 @@ class LoginWidgetOP extends StatelessWidget {
|
||||
config: op,
|
||||
curOP: curOP,
|
||||
cbLogin: cbLogin,
|
||||
startAuth: startAuth,
|
||||
cancelAuth: cancelAuth,
|
||||
canStartAuth: canStartAuth,
|
||||
),
|
||||
const Divider(
|
||||
indent: 5,
|
||||
@@ -411,12 +683,11 @@ class LoginWidgetUserPass extends StatelessWidget {
|
||||
translate('Login'),
|
||||
style: TextStyle(fontSize: 16),
|
||||
),
|
||||
onPressed:
|
||||
curOP.value.isEmpty || curOP.value == 'rustdesk'
|
||||
? () {
|
||||
onLogin();
|
||||
}
|
||||
: null,
|
||||
onPressed: curOP.value.isEmpty && !isInProgress
|
||||
? () {
|
||||
onLogin();
|
||||
}
|
||||
: null,
|
||||
)),
|
||||
),
|
||||
])),
|
||||
@@ -427,8 +698,28 @@ class LoginWidgetUserPass extends StatelessWidget {
|
||||
|
||||
const kAuthReqTypeOidc = 'oidc/';
|
||||
|
||||
Future<bool?>? _activeLoginDialog;
|
||||
|
||||
// call this directly
|
||||
Future<bool?> loginDialog() async {
|
||||
Future<bool?> loginDialog() {
|
||||
final activeDialog = _activeLoginDialog;
|
||||
if (activeDialog != null) {
|
||||
return activeDialog;
|
||||
}
|
||||
final dialog = _openLoginDialogOnce();
|
||||
_activeLoginDialog = dialog;
|
||||
return dialog;
|
||||
}
|
||||
|
||||
Future<bool?> _openLoginDialogOnce() async {
|
||||
try {
|
||||
return await _openLoginDialog();
|
||||
} finally {
|
||||
_activeLoginDialog = null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool?> _openLoginDialog() async {
|
||||
var username =
|
||||
TextEditingController(text: UserModel.getLocalUserInfo()?['name'] ?? '');
|
||||
var password = TextEditingController();
|
||||
@@ -438,14 +729,28 @@ Future<bool?> loginDialog() async {
|
||||
String? usernameMsg;
|
||||
String? passwordMsg;
|
||||
var isInProgress = false;
|
||||
final RxString curOP = ''.obs;
|
||||
final oidcAuth = _OidcAuthController();
|
||||
final curOP = oidcAuth.curOP;
|
||||
// Track hover state for the close icon
|
||||
bool isCloseHovered = false;
|
||||
|
||||
final loginOptions = [].obs;
|
||||
Future.delayed(Duration.zero, () async {
|
||||
loginOptions.value = await UserModel.queryOidcLoginOptions();
|
||||
});
|
||||
final loginOptionsError = Rxn<Object>();
|
||||
final loginOptionsInProgress = false.obs;
|
||||
fetchLoginOptions() async {
|
||||
loginOptionsInProgress.value = true;
|
||||
try {
|
||||
loginOptions.value = await UserModel.queryOidcLoginOptions();
|
||||
loginOptionsError.value = null;
|
||||
} catch (e) {
|
||||
debugPrint("queryOidcLoginOptions failed: $e");
|
||||
loginOptionsError.value = e;
|
||||
} finally {
|
||||
loginOptionsInProgress.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future.delayed(Duration.zero, fetchLoginOptions);
|
||||
|
||||
final res = await gFFI.dialogManager.show<bool>((setState, close, context) {
|
||||
username.addListener(() {
|
||||
@@ -519,6 +824,9 @@ Future<bool?> loginDialog() async {
|
||||
}
|
||||
|
||||
onLogin() async {
|
||||
if (curOP.value.isNotEmpty || isInProgress) {
|
||||
return;
|
||||
}
|
||||
// validate
|
||||
if (username.text.isEmpty) {
|
||||
setState(() => usernameMsg = translate('Username missed'));
|
||||
@@ -549,6 +857,36 @@ Future<bool?> loginDialog() async {
|
||||
}
|
||||
|
||||
thirdAuthWidget() => Obx(() {
|
||||
final error = loginOptionsError.value;
|
||||
final inProgress = loginOptionsInProgress.value;
|
||||
if (error != null) {
|
||||
return Column(
|
||||
children: [
|
||||
const SizedBox(height: 8.0),
|
||||
// NOT use Offstage to wrap LinearProgressIndicator
|
||||
if (inProgress) const LinearProgressIndicator(),
|
||||
if (!inProgress && error is! RequestException)
|
||||
Text(
|
||||
translate('network_error_tip'),
|
||||
style: const TextStyle(fontSize: 12),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
onPressed: inProgress ? null : fetchLoginOptions,
|
||||
child: Text(translate('Retry')),
|
||||
),
|
||||
if (!inProgress)
|
||||
SelectableText(
|
||||
error.toString(),
|
||||
style: const TextStyle(fontSize: 11, color: Colors.red),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return Offstage(
|
||||
offstage: loginOptions.isEmpty,
|
||||
child: Column(
|
||||
@@ -569,6 +907,9 @@ Future<bool?> loginDialog() async {
|
||||
.map((e) => ConfigOP(op: e['name'], icon: e['icon']))
|
||||
.toList(),
|
||||
curOP: curOP,
|
||||
startAuth: oidcAuth.start,
|
||||
cancelAuth: oidcAuth.cancelCurrent,
|
||||
canStartAuth: oidcAuth.canStart,
|
||||
cbLogin: (Map<String, dynamic> authBody) async {
|
||||
LoginResponse? resp;
|
||||
try {
|
||||
@@ -650,7 +991,7 @@ Future<bool?> loginDialog() async {
|
||||
onCancel: onDialogCancel,
|
||||
onSubmit: onLogin,
|
||||
);
|
||||
});
|
||||
}).whenComplete(oidcAuth.close);
|
||||
|
||||
if (res != null) {
|
||||
await UserModel.updateOtherModels();
|
||||
|
||||
157
flutter/lib/common/widgets/oidc_auth_status.dart
Normal file
157
flutter/lib/common/widgets/oidc_auth_status.dart
Normal file
@@ -0,0 +1,157 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
const _statusFontSize = 12.0;
|
||||
const _statusSpacing = 4.0;
|
||||
const _messageActionSpacing = 8.0;
|
||||
const _desktopActionSize = 28.0;
|
||||
const _touchPlatforms = <TargetPlatform>{
|
||||
TargetPlatform.android,
|
||||
TargetPlatform.iOS,
|
||||
TargetPlatform.fuchsia,
|
||||
};
|
||||
|
||||
class OidcAuthStatus extends StatelessWidget {
|
||||
final String message;
|
||||
final String browserFallbackPrompt;
|
||||
final String authUrl;
|
||||
final String copyLabel;
|
||||
final VoidCallback? onCopy;
|
||||
|
||||
const OidcAuthStatus({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.browserFallbackPrompt,
|
||||
required this.authUrl,
|
||||
required this.copyLabel,
|
||||
this.onCopy,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final messageStyle =
|
||||
DefaultTextStyle.of(context).style.copyWith(fontSize: _statusFontSize);
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SelectableText(message, style: messageStyle),
|
||||
if (authUrl.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: _messageActionSpacing),
|
||||
child: _OidcAuthFallback(
|
||||
browserFallbackPrompt: browserFallbackPrompt,
|
||||
authUrl: authUrl,
|
||||
copyLabel: copyLabel,
|
||||
onCopy: onCopy,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OidcAuthFallback extends StatefulWidget {
|
||||
final String browserFallbackPrompt;
|
||||
final String authUrl;
|
||||
final String copyLabel;
|
||||
final VoidCallback? onCopy;
|
||||
|
||||
const _OidcAuthFallback({
|
||||
required this.browserFallbackPrompt,
|
||||
required this.authUrl,
|
||||
required this.copyLabel,
|
||||
required this.onCopy,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_OidcAuthFallback> createState() => _OidcAuthFallbackState();
|
||||
}
|
||||
|
||||
class _OidcAuthFallbackState extends State<_OidcAuthFallback> {
|
||||
bool _expanded = false;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _OidcAuthFallback oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.authUrl != widget.authUrl) {
|
||||
_expanded = false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final helperStyle = DefaultTextStyle.of(context).style.copyWith(
|
||||
fontSize: _statusFontSize,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
);
|
||||
final linkColor = theme.brightness == Brightness.dark
|
||||
? Colors.blue.shade300
|
||||
: Colors.blue.shade800;
|
||||
final isTouchPlatform = _touchPlatforms.contains(theme.platform);
|
||||
final actionSize =
|
||||
isTouchPlatform ? kMinInteractiveDimension : _desktopActionSize;
|
||||
final urlStyle =
|
||||
DefaultTextStyle.of(context).style.copyWith(fontSize: _statusFontSize);
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
widget.browserFallbackPrompt,
|
||||
style: helperStyle,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: _statusSpacing),
|
||||
child: _buildUrl(urlStyle, linkColor, actionSize),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _copyAndExpand() {
|
||||
setState(() => _expanded = true);
|
||||
widget.onCopy?.call();
|
||||
}
|
||||
|
||||
Widget _buildUrl(TextStyle urlStyle, Color linkColor, double actionSize) {
|
||||
final collapsedUrl = SizedBox(
|
||||
width: double.infinity,
|
||||
child: TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: linkColor,
|
||||
minimumSize: Size(0, actionSize),
|
||||
padding: EdgeInsets.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity: VisualDensity.standard,
|
||||
),
|
||||
onPressed: _copyAndExpand,
|
||||
child: Text(
|
||||
widget.authUrl,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
softWrap: false,
|
||||
style: urlStyle.copyWith(
|
||||
color: linkColor,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
final collapsedChild = widget.onCopy == null
|
||||
? collapsedUrl
|
||||
: Tooltip(message: widget.copyLabel, child: collapsedUrl);
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
constraints: BoxConstraints(minHeight: actionSize),
|
||||
alignment: Alignment.centerLeft,
|
||||
padding: const EdgeInsets.symmetric(horizontal: _messageActionSpacing),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Theme.of(context).dividerColor),
|
||||
borderRadius: BorderRadius.circular(_statusSpacing),
|
||||
),
|
||||
child: _expanded
|
||||
? SelectableText(widget.authUrl, style: urlStyle)
|
||||
: collapsedChild,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -115,6 +115,7 @@ class _RawTouchGestureDetectorRegionState
|
||||
InputModel get inputModel => widget.inputModel;
|
||||
bool get handleTouch => (isDesktop || isWebDesktop) || ffiModel.touchMode;
|
||||
SessionID get sessionId => ffi.sessionId;
|
||||
bool get canvasLocked => isMobile && ffi.canvasModel.locked;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -471,6 +472,8 @@ class _RawTouchGestureDetectorRegionState
|
||||
return;
|
||||
}
|
||||
|
||||
if (canvasLocked) return;
|
||||
|
||||
if ((isDesktop || isWebDesktop)) {
|
||||
final scale = ((d.scale - _scale) * 1000).toInt();
|
||||
_scale = d.scale;
|
||||
@@ -532,9 +535,7 @@ class _RawTouchGestureDetectorRegionState
|
||||
// Official
|
||||
TapGestureRecognizer:
|
||||
GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
|
||||
() => TapGestureRecognizer(
|
||||
supportedDevices: kTouchBasedDeviceKinds,
|
||||
), (instance) {
|
||||
() => TapGestureRecognizer(), (instance) {
|
||||
instance
|
||||
..onTapDown = onTapDown
|
||||
..onTapUp = onTapUp
|
||||
@@ -542,18 +543,14 @@ class _RawTouchGestureDetectorRegionState
|
||||
}),
|
||||
DoubleTapGestureRecognizer:
|
||||
GestureRecognizerFactoryWithHandlers<DoubleTapGestureRecognizer>(
|
||||
() => DoubleTapGestureRecognizer(
|
||||
supportedDevices: kTouchBasedDeviceKinds,
|
||||
), (instance) {
|
||||
() => DoubleTapGestureRecognizer(), (instance) {
|
||||
instance
|
||||
..onDoubleTapDown = onDoubleTapDown
|
||||
..onDoubleTap = onDoubleTap;
|
||||
}),
|
||||
LongPressGestureRecognizer:
|
||||
GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>(
|
||||
() => LongPressGestureRecognizer(
|
||||
supportedDevices: kTouchBasedDeviceKinds,
|
||||
), (instance) {
|
||||
() => LongPressGestureRecognizer(), (instance) {
|
||||
instance
|
||||
..onLongPressDown = onLongPressDown
|
||||
..onLongPressUp = onLongPressUp
|
||||
@@ -563,9 +560,7 @@ class _RawTouchGestureDetectorRegionState
|
||||
// Customized
|
||||
HoldTapMoveGestureRecognizer:
|
||||
GestureRecognizerFactoryWithHandlers<HoldTapMoveGestureRecognizer>(
|
||||
() => HoldTapMoveGestureRecognizer(
|
||||
supportedDevices: kTouchBasedDeviceKinds,
|
||||
),
|
||||
() => HoldTapMoveGestureRecognizer(),
|
||||
(instance) => instance
|
||||
..onHoldDragStart = onHoldDragStart
|
||||
..onHoldDragUpdate = onHoldDragUpdate
|
||||
@@ -573,18 +568,14 @@ class _RawTouchGestureDetectorRegionState
|
||||
..onHoldDragEnd = onHoldDragEnd),
|
||||
DoubleFinerTapGestureRecognizer:
|
||||
GestureRecognizerFactoryWithHandlers<DoubleFinerTapGestureRecognizer>(
|
||||
() => DoubleFinerTapGestureRecognizer(
|
||||
supportedDevices: kTouchBasedDeviceKinds,
|
||||
), (instance) {
|
||||
() => DoubleFinerTapGestureRecognizer(), (instance) {
|
||||
instance
|
||||
..onDoubleFinerTap = onDoubleFinerTap
|
||||
..onDoubleFinerTapDown = onDoubleFinerTapDown;
|
||||
}),
|
||||
CustomTouchGestureRecognizer:
|
||||
GestureRecognizerFactoryWithHandlers<CustomTouchGestureRecognizer>(
|
||||
() => CustomTouchGestureRecognizer(
|
||||
supportedDevices: kTouchBasedDeviceKinds,
|
||||
), (instance) {
|
||||
() => CustomTouchGestureRecognizer(), (instance) {
|
||||
instance.onOneFingerPanStart =
|
||||
(DragStartDetails d) => onOneFingerPanStart(context, d);
|
||||
instance
|
||||
|
||||
@@ -253,8 +253,18 @@ class TrackpadSpeedWidget extends StatefulWidget {
|
||||
final SimpleWrapper<int> value;
|
||||
// If null, no debouncer will be applied.
|
||||
final Function(int)? onDebouncer;
|
||||
final ValueChanged<String>? onTextChanged;
|
||||
// IME actions call TextField.onSubmitted without reaching the dialog's
|
||||
// raw Enter handler, so the dialog needs a separate submission callback.
|
||||
final ValueChanged<String>? onTextSubmitted;
|
||||
|
||||
TrackpadSpeedWidget({Key? key, required this.value, this.onDebouncer});
|
||||
TrackpadSpeedWidget({
|
||||
Key? key,
|
||||
required this.value,
|
||||
this.onDebouncer,
|
||||
this.onTextChanged,
|
||||
this.onTextSubmitted,
|
||||
});
|
||||
|
||||
@override
|
||||
TrackpadSpeedWidgetState createState() => TrackpadSpeedWidgetState();
|
||||
@@ -276,6 +286,34 @@ class TrackpadSpeedWidgetState extends State<TrackpadSpeedWidget> {
|
||||
debouncerSpeed.setValue(value);
|
||||
}
|
||||
});
|
||||
widget.onTextChanged?.call(_controller.text);
|
||||
}
|
||||
|
||||
void updateTextValue(String text) {
|
||||
widget.onTextChanged?.call(text);
|
||||
final newValue = int.tryParse(text);
|
||||
if (newValue == null ||
|
||||
newValue < kMinTrackpadSpeed ||
|
||||
newValue > kMaxTrackpadSpeed) {
|
||||
return;
|
||||
}
|
||||
setState(() => value = newValue);
|
||||
}
|
||||
|
||||
void submitTextValue(String text) {
|
||||
final onTextSubmitted = widget.onTextSubmitted;
|
||||
if (onTextSubmitted != null) {
|
||||
onTextSubmitted(text);
|
||||
return;
|
||||
}
|
||||
if (widget.onTextChanged != null) {
|
||||
return;
|
||||
}
|
||||
final newValue = int.tryParse(text);
|
||||
if (newValue == null) {
|
||||
return;
|
||||
}
|
||||
updateValue(newValue);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -315,12 +353,8 @@ class TrackpadSpeedWidgetState extends State<TrackpadSpeedWidget> {
|
||||
controller: _controller,
|
||||
keyboardType: TextInputType.number,
|
||||
textAlign: TextAlign.center,
|
||||
onSubmitted: (text) {
|
||||
int? v = int.tryParse(text);
|
||||
if (v != null) {
|
||||
updateValue(v);
|
||||
}
|
||||
},
|
||||
onChanged: updateTextValue,
|
||||
onSubmitted: submitTextValue,
|
||||
style: const TextStyle(fontSize: 13),
|
||||
decoration: InputDecoration(
|
||||
contentPadding:
|
||||
|
||||
@@ -72,10 +72,24 @@ Widget waylandKeyboardScopeChip(BuildContext context, String text) {
|
||||
);
|
||||
}
|
||||
|
||||
// macOS privacy mode blacks out all online displays, so switching the remote
|
||||
// display does not weaken the local privacy protection.
|
||||
bool allowDisplaySwitchInPrivacyMode(PeerInfo pi) {
|
||||
return pi.platform == kPeerPlatformMacOS;
|
||||
bool _isWindowsMode1PrivacyImpl(String privacyModeImpl) {
|
||||
return privacyModeImpl == kPrivacyModeImplMag ||
|
||||
privacyModeImpl == kPrivacyModeImplExcludeFromCapture;
|
||||
}
|
||||
|
||||
// macOS privacy mode blacks out all online displays. Windows Mode 1 also
|
||||
// covers every local monitor with privacy overlay windows, so remote display
|
||||
// switching does not weaken local privacy protection.
|
||||
//
|
||||
// Keep this separate from the capture backend capability. The legacy Windows
|
||||
// magnifier capturer is not reliable for multi-monitor capture; WebRTC's
|
||||
// screen_capturer_win_magnifier also disables it when SM_CMONITORS != 1:
|
||||
// https://webrtc.googlesource.com/src/+/1845922d5a1bf9c27deeffb4a8c8daea124434c1/modules/desktop_capture/win/screen_capturer_win_magnifier.cc
|
||||
bool allowDisplaySwitchInPrivacyMode(PeerInfo pi, String privacyModeImpl) {
|
||||
return pi.platform == kPeerPlatformMacOS ||
|
||||
(pi.platform == kPeerPlatformWindows &&
|
||||
_isWindowsMode1PrivacyImpl(privacyModeImpl) &&
|
||||
versionCmp(pi.version, '1.4.8') >= 0);
|
||||
}
|
||||
|
||||
class TTextMenu {
|
||||
@@ -335,12 +349,12 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
||||
showRequestElevationDialog(sessionId, ffi.dialogManager)),
|
||||
);
|
||||
}
|
||||
// osAccount / osPassword
|
||||
// osPassword
|
||||
if (isDefaultConn && perms['keyboard'] != false) {
|
||||
v.add(
|
||||
TTextMenu(
|
||||
child: Row(children: [
|
||||
Text(translate(pi.isHeadless ? 'OS Account' : 'OS Password')),
|
||||
Text(translate('OS Password')),
|
||||
]),
|
||||
trailingIcon: Transform.scale(
|
||||
scale: (isDesktop || isWebDesktop) ? 0.8 : 1,
|
||||
@@ -349,18 +363,12 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
||||
if (isMobile && Navigator.canPop(context)) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
if (pi.isHeadless) {
|
||||
showSetOSAccount(sessionId, ffi.dialogManager);
|
||||
} else {
|
||||
handleOsPasswordEditIcon(sessionId, ffi.dialogManager);
|
||||
}
|
||||
handleOsPasswordEditIcon(sessionId, ffi.dialogManager);
|
||||
},
|
||||
icon: Icon(Icons.edit, color: isMobile ? MyTheme.accent : null),
|
||||
),
|
||||
),
|
||||
onPressed: () => pi.isHeadless
|
||||
? showSetOSAccount(sessionId, ffi.dialogManager)
|
||||
: handleOsPasswordAction(sessionId, ffi.dialogManager),
|
||||
onPressed: () => handleOsPasswordAction(sessionId, ffi.dialogManager),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -569,6 +577,7 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
||||
}
|
||||
// record
|
||||
if (!(isDesktop || isWeb) &&
|
||||
bind.mainGetLocalOption(key: kOptionHideRecordingButton) != 'Y' &&
|
||||
(ffi.recordingModel.start || (perms["recording"] != false))) {
|
||||
v.add(TTextMenu(
|
||||
child: Row(
|
||||
@@ -592,7 +601,7 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
||||
// to-do:
|
||||
// 1. Web desktop
|
||||
// 2. Mobile, copy the image to the clipboard
|
||||
if (isDesktop) {
|
||||
if ((isDefaultConn || ffi.connType == ConnType.viewCamera) && isDesktop) {
|
||||
final isScreenshotSupported = bind.sessionGetCommonSync(
|
||||
sessionId: sessionId, key: 'is_screenshot_supported', param: '');
|
||||
if ('true' == isScreenshotSupported) {
|
||||
@@ -964,7 +973,8 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
|
||||
|
||||
final privacyModeState = PrivacyModeState.find(id);
|
||||
if (pi.isSupportMultiDisplay &&
|
||||
(privacyModeState.isEmpty || allowDisplaySwitchInPrivacyMode(pi)) &&
|
||||
(privacyModeState.isEmpty ||
|
||||
allowDisplaySwitchInPrivacyMode(pi, privacyModeState.value)) &&
|
||||
pi.displaysCount.value > 1 &&
|
||||
bind.mainGetUserDefaultOption(key: kKeyShowMonitorsToolbar) == 'Y') {
|
||||
final value =
|
||||
@@ -1048,7 +1058,20 @@ List<TToggleMenu> toolbarPrivacyMode(
|
||||
return []; // No permission and not active, hide options.
|
||||
}
|
||||
|
||||
getDefaultMenu(Future<void> Function(SessionID sid, String opt) toggleFunc) {
|
||||
bool checkDisplayAllowedForPrivacyMode(String targetImplKey, bool turnOn) {
|
||||
if (!turnOn ||
|
||||
allowDisplaySwitchInPrivacyMode(pi, targetImplKey) ||
|
||||
(ffiModel.pi.currentDisplay == 0 &&
|
||||
!bind.sessionIsMultiUiSession(sessionId: sessionId))) {
|
||||
return true;
|
||||
}
|
||||
msgBox(sessionId, 'custom-nook-nocancel-hasclose', 'info',
|
||||
'Please switch to Display 1 first', '', ffi.dialogManager);
|
||||
return false;
|
||||
}
|
||||
|
||||
getDefaultMenu(Future<void> Function(SessionID sid, String opt) toggleFunc,
|
||||
String targetImplKey) {
|
||||
final enabled = !ffiModel.viewOnly &&
|
||||
(hasPrivacyModePermission || privacyModeState.isNotEmpty);
|
||||
return TToggleMenu(
|
||||
@@ -1056,16 +1079,7 @@ List<TToggleMenu> toolbarPrivacyMode(
|
||||
onChanged: enabled
|
||||
? (value) {
|
||||
if (value == null) return;
|
||||
if (!allowDisplaySwitchInPrivacyMode(pi) &&
|
||||
ffiModel.pi.currentDisplay != 0 &&
|
||||
ffiModel.pi.currentDisplay != kAllDisplayValue) {
|
||||
msgBox(
|
||||
sessionId,
|
||||
'custom-nook-nocancel-hasclose',
|
||||
'info',
|
||||
'Please switch to Display 1 first',
|
||||
'',
|
||||
ffi.dialogManager);
|
||||
if (!checkDisplayAllowedForPrivacyMode(targetImplKey, value)) {
|
||||
return;
|
||||
}
|
||||
final option = 'privacy-mode';
|
||||
@@ -1083,7 +1097,7 @@ List<TToggleMenu> toolbarPrivacyMode(
|
||||
getDefaultMenu((sid, opt) async {
|
||||
bind.sessionToggleOption(sessionId: sid, value: opt);
|
||||
togglePrivacyModeTime = DateTime.now();
|
||||
})
|
||||
}, kPrivacyModeImplMag)
|
||||
];
|
||||
}
|
||||
if (privacyModeImpls.isEmpty) {
|
||||
@@ -1097,7 +1111,7 @@ List<TToggleMenu> toolbarPrivacyMode(
|
||||
bind.sessionTogglePrivacyMode(
|
||||
sessionId: sid, implKey: implKey, on: privacyModeState.isEmpty);
|
||||
togglePrivacyModeTime = DateTime.now();
|
||||
})
|
||||
}, implKey)
|
||||
];
|
||||
} else {
|
||||
final visibleImpls = hasPrivacyModePermission
|
||||
@@ -1118,6 +1132,9 @@ List<TToggleMenu> toolbarPrivacyMode(
|
||||
? (value) {
|
||||
if (value == null) return;
|
||||
if (value && !hasPrivacyModePermission) return;
|
||||
if (!checkDisplayAllowedForPrivacyMode(implKey, value)) {
|
||||
return;
|
||||
}
|
||||
togglePrivacyModeTime = DateTime.now();
|
||||
bind.sessionTogglePrivacyMode(
|
||||
sessionId: sessionId, implKey: implKey, on: value);
|
||||
|
||||
@@ -18,7 +18,6 @@ const kKeyMapMode = 'map';
|
||||
const kKeyTranslateMode = 'translate';
|
||||
|
||||
const String kPlatformAdditionsIsWayland = "is_wayland";
|
||||
const String kPlatformAdditionsHeadless = "headless";
|
||||
const String kPlatformAdditionsIsInstalled = "is_installed";
|
||||
const String kPlatformAdditionsIddImpl = "idd_impl";
|
||||
const String kPlatformAdditionsRustDeskVirtualDisplays =
|
||||
@@ -29,6 +28,10 @@ const String kPlatformAdditionsHasFileClipboard = "has_file_clipboard";
|
||||
const String kPlatformAdditionsSupportedPrivacyModeImpl =
|
||||
"supported_privacy_mode_impl";
|
||||
|
||||
const String kPrivacyModeImplMag = 'privacy_mode_impl_mag';
|
||||
const String kPrivacyModeImplExcludeFromCapture =
|
||||
'privacy_mode_impl_exclude_from_capture';
|
||||
|
||||
const String kPeerPlatformWindows = "Windows";
|
||||
const String kPeerPlatformLinux = "Linux";
|
||||
const String kPeerPlatformMacOS = "Mac OS";
|
||||
@@ -51,7 +54,6 @@ const String kAppTypeDesktopTerminal = "terminal";
|
||||
|
||||
const String kWindowMainWindowOnTop = "main_window_on_top";
|
||||
const String kWindowRefreshCurrentUser = "refresh_current_user";
|
||||
const String kWindowGetWindowInfo = "get_window_info";
|
||||
const String kWindowGetScreenList = "get_screen_list";
|
||||
// This method is not used, maybe it can be removed.
|
||||
const String kWindowDisableGrabKeyboard = "disable_grab_keyboard";
|
||||
@@ -91,6 +93,7 @@ const String kOptionForceAlwaysRelay = "force-always-relay";
|
||||
const String kOptionViewOnly = "view_only";
|
||||
const String kOptionEnableLanDiscovery = "enable-lan-discovery";
|
||||
const String kOptionWhitelist = "whitelist";
|
||||
const String kOptionIdWhitelist = "id-whitelist";
|
||||
const String kOptionEnableAbr = "enable-abr";
|
||||
const String kOptionEnableRecordSession = "enable-record-session";
|
||||
const String kOptionDirectServer = "direct-server";
|
||||
@@ -100,6 +103,7 @@ const String kOptionAutoDisconnectTimeout = "auto-disconnect-timeout";
|
||||
const String kOptionEnableHwcodec = "enable-hwcodec";
|
||||
const String kOptionAllowAutoRecordIncoming = "allow-auto-record-incoming";
|
||||
const String kOptionAllowAutoRecordOutgoing = "allow-auto-record-outgoing";
|
||||
const String kOptionHideRecordingButton = "hide-recording-button";
|
||||
const String kOptionVideoSaveDirectory = "video-save-directory";
|
||||
const String kOptionAccessMode = "access-mode";
|
||||
const String kOptionEnableKeyboard = "enable-keyboard";
|
||||
@@ -158,7 +162,6 @@ 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";
|
||||
@@ -170,7 +173,10 @@ const String kOptionShowVirtualMouse = "show-virtual-mouse";
|
||||
const String kOptionVirtualMouseScale = "virtual-mouse-scale";
|
||||
const String kOptionShowVirtualJoystick = "show-virtual-joystick";
|
||||
const String kOptionAllowAskForNoteAtEndOfConnection = "allow-ask-for-note";
|
||||
const String kOptionAllowMonitorSwitchMainToolbar = "allow-monitor-switch-main-toolbar";
|
||||
const String kOptionAllowMonitorSwitchMinToolbar = "allow-monitor-switch-min-toolbar";
|
||||
const String kOptionEnableShowTerminalExtraKeys = "enable-show-terminal-extra-keys";
|
||||
const String kOptionShowTerminalCtrlKeys = "show-terminal-extra-ctrl-keys";
|
||||
|
||||
// network options
|
||||
const String kOptionAllowWebSocket = "allow-websocket";
|
||||
@@ -184,6 +190,7 @@ const String kOptionHideProxySetting = "hide-proxy-settings";
|
||||
const String kOptionHideWebSocketSetting = "hide-websocket-settings";
|
||||
const String kOptionHideStopService = "hide-stop-service";
|
||||
const String kOptionHideRemotePrinterSetting = "hide-remote-printer-settings";
|
||||
const String kOptionHideGeneralSetting = "hide-general-settings";
|
||||
const String kOptionHideSecuritySetting = "hide-security-settings";
|
||||
const String kOptionHideNetworkSetting = "hide-network-settings";
|
||||
const String kOptionRemovePresetPasswordWarning =
|
||||
@@ -316,10 +323,11 @@ double kNewWindowOffset = isWindows
|
||||
? 30.0
|
||||
: 50.0;
|
||||
|
||||
const kDragToResizeAreaPaddingSize = 5.0;
|
||||
EdgeInsets get kDragToResizeAreaPadding => !kUseCompatibleUiMode && isLinux
|
||||
? stateGlobal.fullscreen.isTrue || stateGlobal.isMaximized.value
|
||||
? EdgeInsets.zero
|
||||
: EdgeInsets.all(5.0)
|
||||
: EdgeInsets.all(kDragToResizeAreaPaddingSize)
|
||||
: EdgeInsets.zero;
|
||||
// https://en.wikipedia.org/wiki/Non-breaking_space
|
||||
const int $nbsp = 0x00A0;
|
||||
|
||||
@@ -398,6 +398,7 @@ class _ConnectionPageState extends State<ConnectionPage>
|
||||
.contains(textToFind) ||
|
||||
peer.alias.toLowerCase().contains(textToFind))
|
||||
.toList();
|
||||
_allPeersLoader.queryOnlines(_autocompleteOpts);
|
||||
}
|
||||
return _autocompleteOpts;
|
||||
},
|
||||
|
||||
@@ -16,7 +16,6 @@ import 'package:flutter_hbb/desktop/widgets/update_progress.dart';
|
||||
import 'package:flutter_hbb/models/platform_model.dart';
|
||||
import 'package:flutter_hbb/models/server_model.dart';
|
||||
import 'package:flutter_hbb/models/state_model.dart';
|
||||
import 'package:flutter_hbb/plugin/ui_manager.dart';
|
||||
import 'package:flutter_hbb/utils/multi_window_manager.dart';
|
||||
import 'package:flutter_hbb/utils/platform_channel.dart';
|
||||
import 'package:get/get.dart';
|
||||
@@ -111,7 +110,6 @@ class _DesktopHomePageState extends State<DesktopHomePage>
|
||||
}
|
||||
},
|
||||
),
|
||||
buildPluginEntry(),
|
||||
];
|
||||
if (isIncomingOnly) {
|
||||
children.addAll([
|
||||
@@ -782,13 +780,6 @@ class _DesktopHomePageState extends State<DesktopHomePage>
|
||||
windowOnTop(null);
|
||||
} else if (call.method == kWindowRefreshCurrentUser) {
|
||||
gFFI.userModel.refreshCurrentUser();
|
||||
} else if (call.method == kWindowGetWindowInfo) {
|
||||
final screen = (await window_size.getWindowInfo()).screen;
|
||||
if (screen == null) {
|
||||
return '';
|
||||
} else {
|
||||
return jsonEncode(screenToMap(screen));
|
||||
}
|
||||
} else if (call.method == kWindowGetScreenList) {
|
||||
return jsonEncode(
|
||||
(await window_size.getScreenList()).map(screenToMap).toList());
|
||||
@@ -890,21 +881,6 @@ class _DesktopHomePageState extends State<DesktopHomePage>
|
||||
shouldBeBlocked(_block, canBeBlocked);
|
||||
}
|
||||
}
|
||||
|
||||
Widget buildPluginEntry() {
|
||||
final entries = PluginUiManager.instance.entries.entries;
|
||||
return Offstage(
|
||||
offstage: entries.isEmpty,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
...entries.map((entry) {
|
||||
return entry.value;
|
||||
})
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void setPasswordDialog({VoidCallback? notEmptyCallback}) async {
|
||||
|
||||
@@ -17,8 +17,6 @@ import 'package:flutter_hbb/models/platform_model.dart';
|
||||
import 'package:flutter_hbb/models/printer_model.dart';
|
||||
import 'package:flutter_hbb/models/server_model.dart';
|
||||
import 'package:flutter_hbb/models/state_model.dart';
|
||||
import 'package:flutter_hbb/plugin/manager.dart';
|
||||
import 'package:flutter_hbb/plugin/widgets/desktop_settings.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
@@ -55,7 +53,6 @@ enum SettingsTabKey {
|
||||
safety,
|
||||
network,
|
||||
display,
|
||||
plugin,
|
||||
account,
|
||||
printer,
|
||||
about,
|
||||
@@ -64,7 +61,8 @@ enum SettingsTabKey {
|
||||
class DesktopSettingPage extends StatefulWidget {
|
||||
final SettingsTabKey initialTabkey;
|
||||
static final List<SettingsTabKey> tabKeys = [
|
||||
SettingsTabKey.general,
|
||||
if (bind.mainGetBuildinOption(key: kOptionHideGeneralSetting) != 'Y')
|
||||
SettingsTabKey.general,
|
||||
if (!isWeb &&
|
||||
!bind.isOutgoingOnly() &&
|
||||
!bind.isDisableSettings() &&
|
||||
@@ -74,10 +72,9 @@ class DesktopSettingPage extends StatefulWidget {
|
||||
bind.mainGetBuildinOption(key: kOptionHideNetworkSetting) != 'Y')
|
||||
SettingsTabKey.network,
|
||||
if (!bind.isIncomingOnly()) SettingsTabKey.display,
|
||||
if (!isWeb && !bind.isIncomingOnly() && bind.pluginFeatureIsEnabled())
|
||||
SettingsTabKey.plugin,
|
||||
if (!bind.isDisableAccount()) SettingsTabKey.account,
|
||||
if (isWindows &&
|
||||
!bind.isDisableSettings() &&
|
||||
bind.mainGetBuildinOption(key: kOptionHideRemotePrinterSetting) != 'Y')
|
||||
SettingsTabKey.printer,
|
||||
SettingsTabKey.about,
|
||||
@@ -95,7 +92,8 @@ class DesktopSettingPage extends StatefulWidget {
|
||||
if (index == -1) {
|
||||
return;
|
||||
}
|
||||
if (Get.isRegistered<PageController>(tag: _kSettingPageControllerTag)) {
|
||||
if (Get.isRegistered<PageController>(tag: _kSettingPageControllerTag) &&
|
||||
Get.isRegistered<Rx<SettingsTabKey>>(tag: _kSettingPageTabKeyTag)) {
|
||||
DesktopTabPage.onAddSetting(initialPage: page);
|
||||
PageController controller =
|
||||
Get.find<PageController>(tag: _kSettingPageControllerTag);
|
||||
@@ -163,17 +161,23 @@ class _DesktopSettingPageState extends State<DesktopSettingPage>
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
_canBeBlocked.value = await canBeBlocked();
|
||||
final blocked = await canBeBlocked();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
_canBeBlocked.value = blocked;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
Get.delete<PageController>(tag: _kSettingPageControllerTag);
|
||||
Get.delete<RxInt>(tag: _kSettingPageTabKeyTag);
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_videoConnTimer?.cancel();
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
Get.delete<PageController>(tag: _kSettingPageControllerTag);
|
||||
Get.delete<Rx<SettingsTabKey>>(tag: _kSettingPageTabKeyTag);
|
||||
// Get.delete does not dispose a plain ChangeNotifier.
|
||||
controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
List<_TabInfo> _settingTabs() {
|
||||
@@ -196,10 +200,6 @@ class _DesktopSettingPageState extends State<DesktopSettingPage>
|
||||
settingTabs.add(_TabInfo(tab, 'Display',
|
||||
Icons.desktop_windows_outlined, Icons.desktop_windows));
|
||||
break;
|
||||
case SettingsTabKey.plugin:
|
||||
settingTabs.add(_TabInfo(
|
||||
tab, 'Plugin', Icons.extension_outlined, Icons.extension));
|
||||
break;
|
||||
case SettingsTabKey.account:
|
||||
settingTabs.add(
|
||||
_TabInfo(tab, 'Account', Icons.person_outline, Icons.person));
|
||||
@@ -233,9 +233,6 @@ class _DesktopSettingPageState extends State<DesktopSettingPage>
|
||||
case SettingsTabKey.display:
|
||||
children.add(const _Display());
|
||||
break;
|
||||
case SettingsTabKey.plugin:
|
||||
children.add(const _Plugin());
|
||||
break;
|
||||
case SettingsTabKey.account:
|
||||
children.add(const _Account());
|
||||
break;
|
||||
@@ -407,6 +404,7 @@ class _GeneralState extends State<_General> {
|
||||
final RxBool serviceStop =
|
||||
isWeb ? RxBool(false) : Get.find<RxBool>(tag: 'stop-service');
|
||||
RxBool serviceBtnEnabled = true.obs;
|
||||
final GlobalKey _minToolbarOptionKey = GlobalKey();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -482,13 +480,16 @@ class _GeneralState extends State<_General> {
|
||||
}
|
||||
|
||||
Widget other() {
|
||||
final showAutoUpdate = isWindows && bind.mainIsInstalled();
|
||||
final incomingOnly = bind.isIncomingOnly();
|
||||
final outgoingOnly = bind.isOutgoingOnly();
|
||||
final showAutoUpdate = (isWindows && bind.mainIsInstalled()) ||
|
||||
(isMacOS && bind.mainIsInstalled() && bind.mainIsInstalledDaemon(prompt: false) && !bind.isCustomClient());
|
||||
final children = <Widget>[
|
||||
if (!isWeb && !bind.isIncomingOnly())
|
||||
if (!isWeb && !incomingOnly)
|
||||
_OptionCheckBox(context, 'Confirm before closing multiple tabs',
|
||||
kOptionEnableConfirmClosingTabs,
|
||||
isServer: false),
|
||||
if (!bind.isIncomingOnly())
|
||||
if (!incomingOnly)
|
||||
_OptionCheckBox(
|
||||
context,
|
||||
'allow-remote-toolbar-docking-any-edge',
|
||||
@@ -498,9 +499,10 @@ class _GeneralState extends State<_General> {
|
||||
reloadAllWindows();
|
||||
},
|
||||
),
|
||||
_OptionCheckBox(context, 'Adaptive bitrate', kOptionEnableAbr),
|
||||
if (!isWeb && !outgoingOnly)
|
||||
_OptionCheckBox(context, 'Adaptive bitrate', kOptionEnableAbr),
|
||||
if (!isWeb) wallpaper(),
|
||||
if (!isWeb && !bind.isIncomingOnly()) ...[
|
||||
if (!isWeb && !incomingOnly) ...[
|
||||
_OptionCheckBox(
|
||||
context,
|
||||
'Open connection in new tab',
|
||||
@@ -539,40 +541,40 @@ class _GeneralState extends State<_General> {
|
||||
isServer: false,
|
||||
),
|
||||
),
|
||||
if (!isWeb && !bind.isCustomClient())
|
||||
_OptionCheckBox(
|
||||
context,
|
||||
'Check for software update on startup',
|
||||
kOptionEnableCheckUpdate,
|
||||
isServer: false,
|
||||
),
|
||||
if (showAutoUpdate)
|
||||
_OptionCheckBox(
|
||||
context,
|
||||
'Auto update',
|
||||
kOptionAllowAutoUpdate,
|
||||
isServer: true,
|
||||
),
|
||||
if (isWindows && !bind.isOutgoingOnly())
|
||||
_OptionCheckBox(
|
||||
context,
|
||||
'Capture screen using DirectX',
|
||||
kOptionDirectxCapture,
|
||||
),
|
||||
if (!bind.isIncomingOnly()) ...[
|
||||
_OptionCheckBox(
|
||||
context,
|
||||
'Enable UDP hole punching',
|
||||
kOptionEnableUdpPunch,
|
||||
isServer: false,
|
||||
),
|
||||
_OptionCheckBox(
|
||||
context,
|
||||
'Enable IPv6 P2P connection',
|
||||
kOptionEnableIpv6Punch,
|
||||
isServer: false,
|
||||
),
|
||||
],
|
||||
],
|
||||
if (!isWeb && !bind.isCustomClient())
|
||||
_OptionCheckBox(
|
||||
context,
|
||||
'Check for software update on startup',
|
||||
kOptionEnableCheckUpdate,
|
||||
isServer: false,
|
||||
),
|
||||
if (showAutoUpdate)
|
||||
_OptionCheckBox(
|
||||
context,
|
||||
'Auto update',
|
||||
kOptionAllowAutoUpdate,
|
||||
isServer: true,
|
||||
),
|
||||
if (isWindows && !outgoingOnly)
|
||||
_OptionCheckBox(
|
||||
context,
|
||||
'Capture screen using DirectX',
|
||||
kOptionDirectxCapture,
|
||||
),
|
||||
if (!isWeb && !incomingOnly) ...[
|
||||
_OptionCheckBox(
|
||||
context,
|
||||
'Enable UDP hole punching',
|
||||
kOptionEnableUdpPunch,
|
||||
isServer: false,
|
||||
),
|
||||
_OptionCheckBox(
|
||||
context,
|
||||
'Enable IPv6 P2P connection',
|
||||
kOptionEnableIpv6Punch,
|
||||
isServer: false,
|
||||
),
|
||||
],
|
||||
];
|
||||
|
||||
@@ -586,10 +588,6 @@ class _GeneralState extends State<_General> {
|
||||
));
|
||||
}
|
||||
|
||||
if (!isWeb && bind.mainShowOption(key: kOptionAllowLinuxHeadless)) {
|
||||
children.add(_OptionCheckBox(
|
||||
context, 'Allow linux headless', kOptionAllowLinuxHeadless));
|
||||
}
|
||||
if (!bind.isDisableAccount()) {
|
||||
children.add(_OptionCheckBox(
|
||||
context,
|
||||
@@ -605,6 +603,47 @@ class _GeneralState extends State<_General> {
|
||||
},
|
||||
));
|
||||
}
|
||||
children.add(_OptionCheckBox(
|
||||
context,
|
||||
'Show monitor switch button on the main toolbar',
|
||||
kOptionAllowMonitorSwitchMainToolbar,
|
||||
isServer: false,
|
||||
update: (enabled) async {
|
||||
if (!enabled) {
|
||||
await mainSetLocalBoolOption(
|
||||
kOptionAllowMonitorSwitchMinToolbar, false);
|
||||
}
|
||||
if (mounted) setState(() {});
|
||||
reloadAllWindows();
|
||||
if (enabled) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final ctx = _minToolbarOptionKey.currentContext;
|
||||
if (ctx != null) {
|
||||
Scrollable.ensureVisible(
|
||||
ctx,
|
||||
alignment: 0.5,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
));
|
||||
if (mainGetLocalBoolOptionSync(kOptionAllowMonitorSwitchMainToolbar)) {
|
||||
children.add(KeyedSubtree(
|
||||
key: _minToolbarOptionKey,
|
||||
child: _OptionCheckBox(
|
||||
context,
|
||||
'Show on the minimized toolbar',
|
||||
kOptionAllowMonitorSwitchMinToolbar,
|
||||
isServer: false,
|
||||
update: (_) {
|
||||
reloadAllWindows();
|
||||
},
|
||||
).marginOnly(left: _kCheckBoxLeftMargin * 3),
|
||||
));
|
||||
}
|
||||
return _Card(title: 'Other', children: children);
|
||||
}
|
||||
|
||||
@@ -1252,6 +1291,7 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
|
||||
reverse: true, enabled: enabled),
|
||||
...directIp(context),
|
||||
whitelist(),
|
||||
idWhitelist(),
|
||||
...autoDisconnect(context),
|
||||
_OptionCheckBox(context, 'keep-awake-during-incoming-sessions-label',
|
||||
kOptionKeepAwakeDuringIncomingSessions,
|
||||
@@ -1409,6 +1449,52 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
|
||||
return tmpWrapper();
|
||||
}
|
||||
|
||||
Widget idWhitelist() {
|
||||
bool enabled = !locked;
|
||||
RxBool hasIdWhitelist = idWhitelistNotEmpty().obs;
|
||||
update() async {
|
||||
hasIdWhitelist.value = idWhitelistNotEmpty();
|
||||
}
|
||||
|
||||
onChanged(bool? checked) async {
|
||||
changeIdWhiteList(callback: update);
|
||||
}
|
||||
|
||||
final isOptFixed = isOptionFixed(kOptionIdWhitelist);
|
||||
return GestureDetector(
|
||||
child: Tooltip(
|
||||
message: translate('id_whitelist_tip'),
|
||||
child: Obx(() => Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: hasIdWhitelist.value,
|
||||
onChanged: enabled && !isOptFixed ? onChanged : null)
|
||||
.marginOnly(right: 5),
|
||||
Offstage(
|
||||
offstage: !hasIdWhitelist.value,
|
||||
child: MouseRegion(
|
||||
child: const Icon(Icons.warning_amber_rounded,
|
||||
color: Color.fromARGB(255, 255, 204, 0))
|
||||
.marginOnly(right: 5),
|
||||
cursor: SystemMouseCursors.click,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
translate('Use ID whitelisting'),
|
||||
style: TextStyle(color: disabledTextColor(context, enabled)),
|
||||
))
|
||||
],
|
||||
)),
|
||||
),
|
||||
onTap: enabled
|
||||
? () {
|
||||
onChanged(!hasIdWhitelist.value);
|
||||
}
|
||||
: null,
|
||||
).marginOnly(left: _kCheckBoxLeftMargin);
|
||||
}
|
||||
|
||||
Widget hide_cm(bool enabled) {
|
||||
return ChangeNotifierProvider.value(
|
||||
value: gFFI.serverModel,
|
||||
@@ -2162,51 +2248,6 @@ class _CheckboxState extends State<_Checkbox> {
|
||||
}
|
||||
}
|
||||
|
||||
class _Plugin extends StatefulWidget {
|
||||
const _Plugin({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<_Plugin> createState() => _PluginState();
|
||||
}
|
||||
|
||||
class _PluginState extends State<_Plugin> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
bind.pluginListReload();
|
||||
final scrollController = ScrollController();
|
||||
return ChangeNotifierProvider.value(
|
||||
value: pluginManager,
|
||||
child: Consumer<PluginManager>(builder: (context, model, child) {
|
||||
return ListView(
|
||||
controller: scrollController,
|
||||
children: model.plugins.map((entry) => pluginCard(entry)).toList(),
|
||||
).marginOnly(bottom: _kListViewBottomMargin);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Widget pluginCard(PluginInfo plugin) {
|
||||
return ChangeNotifierProvider.value(
|
||||
value: plugin,
|
||||
child: Consumer<PluginInfo>(
|
||||
builder: (context, model, child) => DesktopSettingsCard(plugin: model),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget accountAction() {
|
||||
return Obx(() => _Button(
|
||||
gFFI.userModel.userName.value.isEmpty
|
||||
? 'Login'
|
||||
: '${translate('Logout')} (${gFFI.userModel.accountLabelWithHandle})',
|
||||
() => {
|
||||
gFFI.userModel.userName.value.isEmpty
|
||||
? loginDialog()
|
||||
: logOutConfirmDialog()
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
class _Printer extends StatefulWidget {
|
||||
const _Printer({super.key});
|
||||
|
||||
@@ -2369,17 +2410,20 @@ class _AboutState extends State<_About> {
|
||||
final version = await bind.mainGetVersion();
|
||||
final buildDate = await bind.mainGetBuildDate();
|
||||
final fingerprint = await bind.mainGetFingerprint();
|
||||
final myId = await bind.mainGetMyId();
|
||||
return {
|
||||
'license': license,
|
||||
'version': version,
|
||||
'buildDate': buildDate,
|
||||
'fingerprint': fingerprint
|
||||
'fingerprint': fingerprint,
|
||||
'myId': myId
|
||||
};
|
||||
}(), hasData: (data) {
|
||||
final license = data['license'].toString();
|
||||
final version = data['version'].toString();
|
||||
final buildDate = data['buildDate'].toString();
|
||||
final fingerprint = data['fingerprint'].toString();
|
||||
final myId = data['myId'].toString();
|
||||
const linkStyle = TextStyle(decoration: TextDecoration.underline);
|
||||
final scrollController = ScrollController();
|
||||
return SingleChildScrollView(
|
||||
@@ -2401,6 +2445,9 @@ class _AboutState extends State<_About> {
|
||||
SelectionArea(
|
||||
child: Text('${translate('Fingerprint')}: $fingerprint')
|
||||
.marginSymmetric(vertical: 4.0)),
|
||||
SelectionArea(
|
||||
child: Text('${translate('ID')}: $myId')
|
||||
.marginSymmetric(vertical: 4.0)),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
launchUrlString('https://rustdesk.com/privacy.html');
|
||||
@@ -2429,7 +2476,7 @@ class _AboutState extends State<_About> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Copyright © ${DateTime.now().toString().substring(0, 4)} Purslane Ltd.\n$license',
|
||||
'Copyright © ${DateTime.now().toString().substring(0, 4)} Purslane Tech Pte. Ltd.\n$license',
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
Text(
|
||||
|
||||
@@ -278,7 +278,39 @@ class _FileManagerPageState extends State<FileManagerPage>
|
||||
item.state != JobState.inProgress,
|
||||
child: LinearPercentIndicator(
|
||||
animateFromLastPercent: true,
|
||||
center: Text(item.percentText),
|
||||
center: SizedBox.expand(
|
||||
child: ShaderMask(
|
||||
blendMode: BlendMode.srcATop,
|
||||
shaderCallback: (bounds) =>
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Colors.white,
|
||||
Colors.transparent,
|
||||
],
|
||||
stops: [item.percent, item.percent],
|
||||
).createShader(bounds),
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
text: item.percentText,
|
||||
children: [
|
||||
if (item.recvJobRes)
|
||||
TextSpan(
|
||||
text:
|
||||
' ${readableFileSize(item.speed)}/s',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w300,
|
||||
color: MyTheme.darkGray,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
barRadius: Radius.circular(15),
|
||||
percent: item.percent,
|
||||
progressColor: MyTheme.accent,
|
||||
@@ -1094,6 +1126,7 @@ class _FileManagerViewState extends State<FileManagerView> {
|
||||
return element.name.contains(_searchText.value);
|
||||
}).toList(growable: false)
|
||||
: entries;
|
||||
// Keep rows lazy so large directories only build visible list items.
|
||||
final rows = filteredEntries.map((entry) {
|
||||
final sizeStr =
|
||||
entry.isFile ? readableFileSize(entry.size.toDouble()) : "";
|
||||
@@ -1276,7 +1309,7 @@ class _FileManagerViewState extends State<FileManagerView> {
|
||||
],
|
||||
))),
|
||||
);
|
||||
}).toList(growable: false);
|
||||
});
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
@@ -1292,7 +1325,7 @@ class _FileManagerViewState extends State<FileManagerView> {
|
||||
controller: scrollController,
|
||||
itemExtent: kDesktopFileTransferRowHeight,
|
||||
itemBuilder: (context, index) {
|
||||
return rows[index];
|
||||
return rows.elementAt(index);
|
||||
},
|
||||
itemCount: rows.length,
|
||||
),
|
||||
|
||||
@@ -65,7 +65,7 @@ class _InstallPageBodyState extends State<_InstallPageBody>
|
||||
late final TextEditingController controller;
|
||||
final RxBool startmenu = true.obs;
|
||||
final RxBool desktopicon = true.obs;
|
||||
final RxBool printer = true.obs;
|
||||
final RxBool printer = false.obs;
|
||||
final RxBool showProgress = false.obs;
|
||||
final RxBool btnEnabled = true.obs;
|
||||
|
||||
@@ -80,7 +80,7 @@ class _InstallPageBodyState extends State<_InstallPageBody>
|
||||
final installOptions = jsonDecode(bind.installInstallOptions());
|
||||
startmenu.value = installOptions['STARTMENUSHORTCUTS'] != '0';
|
||||
desktopicon.value = installOptions['DESKTOPSHORTCUTS'] != '0';
|
||||
printer.value = installOptions['PRINTER'] != '0';
|
||||
printer.value = installOptions['PRINTER'] == '1';
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
class MacOSFullScreenFocusRecovery {
|
||||
int _generation = 0;
|
||||
int? _pendingGeneration;
|
||||
|
||||
int? get pendingGeneration => _pendingGeneration;
|
||||
|
||||
int queue() {
|
||||
_generation += 1;
|
||||
_pendingGeneration = _generation;
|
||||
return _generation;
|
||||
}
|
||||
|
||||
void cancel() {
|
||||
_pendingGeneration = null;
|
||||
}
|
||||
|
||||
bool isCurrent(int generation) => _pendingGeneration == generation;
|
||||
|
||||
bool consume(int generation) {
|
||||
if (!isCurrent(generation)) return false;
|
||||
_pendingGeneration = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ 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';
|
||||
@@ -64,6 +65,13 @@ class RemotePage extends StatefulWidget {
|
||||
|
||||
FFI get ffi => (_lastState.value! as _RemotePageState)._ffi;
|
||||
|
||||
void releaseMacOSInputForTabTransfer() {
|
||||
if (!isMacOS) return;
|
||||
// Release before removing the source tab. Its delayed disposal must not
|
||||
// disable a native keyboard hook already acquired by the destination page.
|
||||
(_lastState.value! as _RemotePageState)._releaseMacOSRemoteInput();
|
||||
}
|
||||
|
||||
@override
|
||||
State<RemotePage> createState() {
|
||||
final state = _RemotePageState(id);
|
||||
@@ -76,10 +84,28 @@ class _RemotePageState extends State<RemotePage>
|
||||
with
|
||||
AutomaticKeepAliveClientMixin,
|
||||
MultiWindowListener,
|
||||
WidgetsBindingObserver,
|
||||
TickerProviderStateMixin {
|
||||
Timer? _timer;
|
||||
String keyboardMode = "legacy";
|
||||
bool _isWindowBlur = false;
|
||||
// Known macOS remote-input trade-offs (kept simple intentionally):
|
||||
// 1. Dialogs rely on FocusNode loss plus middleBlocked, not mirrored dialog
|
||||
// state. Reproduce: activate remote input, open a dialog, then type.
|
||||
// 2. Delayed fullscreen recovery can race a local-control focus change; no
|
||||
// owner state is added. Reproduce: focus the toolbar during a Space switch.
|
||||
// 3. Input-source switching releases native input without updating this
|
||||
// page's cache. Reproduce: switch sources, then type before and after
|
||||
// clicking the remote image; the click reasserts input.
|
||||
// These latches compensate for out-of-order macOS focus events. Treat them
|
||||
// as coupled when changing a transition or _syncMacOSKeyboardGrab().
|
||||
AppLifecycleState? _macOSLifecycleState;
|
||||
bool _macOSLocalFocusLost = false;
|
||||
bool _macOSInputActive = false;
|
||||
bool _macOSInputSuppressed = false;
|
||||
final _macOSFullScreenFocusRecovery = MacOSFullScreenFocusRecovery();
|
||||
bool _macOSExplicitFocusRequestPending = false;
|
||||
StreamSubscription<DesktopTabState>? _tabStateSubscription;
|
||||
final _cursorOverImage = false.obs;
|
||||
late RxBool _showRemoteCursor;
|
||||
late RxBool _zoomCursor;
|
||||
@@ -122,6 +148,13 @@ 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();
|
||||
@@ -149,7 +182,6 @@ class _RemotePageState extends State<RemotePage>
|
||||
WakelockManager.enable(_uniqueKey);
|
||||
|
||||
_ffi.ffiModel.updateEventListener(sessionId, widget.id);
|
||||
if (!isWeb) bind.pluginSyncUi(syncTo: kAppTypeDesktopRemote);
|
||||
_ffi.qualityMonitorModel.checkShowQualityMonitor(sessionId);
|
||||
_ffi.dialogManager.loadMobileActionsOverlayVisible();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
@@ -231,19 +263,229 @@ 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 non-Windows temporarily.
|
||||
if (isWindows) {
|
||||
// We disable the `focus` way for Linux temporarily.
|
||||
if (isWindows || isMacOS) {
|
||||
_isWindowBlur = true;
|
||||
}
|
||||
if (isMacOS) {
|
||||
_macOSFullScreenFocusRecovery.cancel();
|
||||
// A blur or Space switch may not emit PointerExit, so cursor state alone
|
||||
// cannot prevent the old remote surface from reclaiming the keyboard.
|
||||
_macOSLocalFocusLost = true;
|
||||
}
|
||||
if (isWindows) {
|
||||
// unfocus the primary-focus when the whole window is lost focus,
|
||||
// and let OS to handle events instead.
|
||||
_rawKeyFocusNode.unfocus();
|
||||
}
|
||||
stateGlobal.isFocused.value = false;
|
||||
_syncMacOSKeyboardGrab();
|
||||
|
||||
// When window loses focus, temporarily release relative mouse mode constraints
|
||||
// to allow user to interact with other applications normally.
|
||||
@@ -257,16 +499,50 @@ class _RemotePageState extends State<RemotePage>
|
||||
void onWindowFocus() {
|
||||
super.onWindowFocus();
|
||||
// See [onWindowBlur].
|
||||
if (isWindows) {
|
||||
if (isWindows || isMacOS) {
|
||||
_isWindowBlur = false;
|
||||
}
|
||||
if (isMacOS) stateGlobal.getInputSource(force: true);
|
||||
stateGlobal.isFocused.value = true;
|
||||
|
||||
// Normal macOS windows wait for PointerEnter or PointerDown. A focused
|
||||
// fullscreen Space queues delayed recovery; if this window blurs again, the
|
||||
// pending recovery is cancelled before native input can reactivate.
|
||||
// Regression: switch directly between fullscreen remote Spaces without
|
||||
// moving or clicking; only the newly focused session may receive input.
|
||||
if (isMacOS &&
|
||||
stateGlobal.fullscreen.isTrue &&
|
||||
!_ffi.inputModel.relativeMouseMode.value) {
|
||||
// Native window focus is authoritative when a secondary engine retains a
|
||||
// stale hidden lifecycle state after its fullscreen Space becomes visible.
|
||||
_queueMacOSKeyboardAfterFullScreen(allowHiddenLifecycle: true);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
_rawKeyFocusNode.requestFocus();
|
||||
if (isMacOS) {
|
||||
// Native relative mode retains pointer capture and does not emit
|
||||
// PointerEnter after window focus returns. Restore both latches unless
|
||||
// a local overlay still owns input.
|
||||
if (_blockableOverlayState.middleBlocked.isFalse) {
|
||||
_cursorOverImage.value = true;
|
||||
_macOSLocalFocusLost = false;
|
||||
}
|
||||
} else if (!isWindows || _windowsCanFocusRemoteInput) {
|
||||
_rawKeyFocusNode.requestFocus();
|
||||
}
|
||||
_ffi.inputModel.onWindowFocus();
|
||||
}
|
||||
_syncMacOSKeyboardGrab(reassert: true, allowInactiveLifecycle: true);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -327,6 +603,13 @@ class _RemotePageState extends State<RemotePage>
|
||||
void onWindowMinimize() {
|
||||
super.onWindowMinimize();
|
||||
WakelockManager.disable(_uniqueKey);
|
||||
if (isMacOS) {
|
||||
_macOSFullScreenFocusRecovery.cancel();
|
||||
_isWindowBlur = true;
|
||||
_cursorOverImage.value = false;
|
||||
stateGlobal.isFocused.value = false;
|
||||
_syncMacOSKeyboardGrab();
|
||||
}
|
||||
// Release cursor constraints when minimized
|
||||
if (_ffi.inputModel.relativeMouseMode.value) {
|
||||
_ffi.inputModel.onWindowBlur();
|
||||
@@ -338,6 +621,7 @@ class _RemotePageState extends State<RemotePage>
|
||||
super.onWindowEnterFullScreen();
|
||||
if (isMacOS) {
|
||||
stateGlobal.setFullscreen(true);
|
||||
_queueMacOSKeyboardAfterFullScreen();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,6 +630,7 @@ class _RemotePageState extends State<RemotePage>
|
||||
super.onWindowLeaveFullScreen();
|
||||
if (isMacOS) {
|
||||
stateGlobal.setFullscreen(false);
|
||||
_queueMacOSKeyboardAfterFullScreen();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -354,6 +639,14 @@ class _RemotePageState extends State<RemotePage>
|
||||
final closeSession = closeSessionOnDispose.remove(widget.id) ?? true;
|
||||
|
||||
// https://github.com/flutter/flutter/issues/64935
|
||||
if (isMacOS) {
|
||||
// Tab moves release before transfer to avoid a late retained-session leave.
|
||||
if (closeSession) {
|
||||
_releaseMacOSRemoteInput();
|
||||
}
|
||||
_tabStateSubscription?.cancel();
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
}
|
||||
super.dispose();
|
||||
debugPrint("REMOTE PAGE dispose session $sessionId ${widget.id}");
|
||||
|
||||
@@ -368,8 +661,9 @@ 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) {
|
||||
if (closeSession && !isMacOS) {
|
||||
// ensure we leave this session, this is a double check
|
||||
// enterOrLeave() is already called previously in _releaseMacOSRemoteInput() for macOS.
|
||||
_ffi.inputModel.enterOrLeave(false);
|
||||
}
|
||||
DesktopMultiWindow.removeListener(this);
|
||||
@@ -444,6 +738,8 @@ class _RemotePageState extends State<RemotePage>
|
||||
} else {
|
||||
_ffi.inputModel.enterOrLeave(false);
|
||||
}
|
||||
} else if (isMacOS) {
|
||||
_onMacOSFocusChange();
|
||||
}
|
||||
},
|
||||
inputModel: _ffi.inputModel,
|
||||
@@ -549,7 +845,20 @@ class _RemotePageState extends State<RemotePage>
|
||||
}
|
||||
|
||||
// See [onWindowBlur].
|
||||
if (!isWindows) {
|
||||
if (isMacOS) {
|
||||
_macOSLocalFocusLost = false;
|
||||
stateGlobal.getInputSource(force: true);
|
||||
_syncMacOSKeyboardGrab(reassert: true, allowInactiveLifecycle: true);
|
||||
} else if (isWindows) {
|
||||
// Blur unfocuses this node and nothing restores it, so the keyboard stayed
|
||||
// dead until a click. Focus only while the window is really active, or a
|
||||
// background window would grab system keys. onFocusChange does enterOrLeave.
|
||||
if (!_isWindowBlur &&
|
||||
_windowsCanFocusRemoteInput &&
|
||||
!_rawKeyFocusNode.hasFocus) {
|
||||
_rawKeyFocusNode.requestFocus();
|
||||
}
|
||||
} else {
|
||||
if (!_rawKeyFocusNode.hasFocus) {
|
||||
_rawKeyFocusNode.requestFocus();
|
||||
}
|
||||
@@ -575,7 +884,9 @@ class _RemotePageState extends State<RemotePage>
|
||||
}
|
||||
|
||||
// See [onWindowBlur].
|
||||
if (!isWindows) {
|
||||
if (isMacOS) {
|
||||
_syncMacOSKeyboardGrab();
|
||||
} else if (!isWindows) {
|
||||
_ffi.inputModel.enterOrLeave(false);
|
||||
}
|
||||
}
|
||||
@@ -600,17 +911,29 @@ class _RemotePageState extends State<RemotePage>
|
||||
onEnter: onEnter,
|
||||
onExit: onExit,
|
||||
onPointerDown: (event) {
|
||||
// A double check for blur status.
|
||||
// A double check for blur status on Windows and macOS.
|
||||
// Note: If there's an `onPointerDown` event is triggered, `_isWindowBlur` is expected being false.
|
||||
// Sometimes the system does not send the necessary focus event to flutter. We should manually
|
||||
// handle this inconsistent status by setting `_isWindowBlur` to false. So we can
|
||||
// ensure the grab-key thread is running when our users are clicking the remote canvas.
|
||||
if (_isWindowBlur) {
|
||||
if ((isWindows || isMacOS) && _isWindowBlur) {
|
||||
debugPrint(
|
||||
"Unexpected status: onPointerDown is triggered while the remote window is in blur status");
|
||||
_isWindowBlur = false;
|
||||
}
|
||||
if (!_rawKeyFocusNode.hasFocus) {
|
||||
if (isMacOS) {
|
||||
// Regions without matching enter/exit callbacks cannot safely own
|
||||
// keyboard state.
|
||||
if (onEnter == null || onExit == null) return;
|
||||
if (!stateGlobal.isFocused.value) {
|
||||
stateGlobal.isFocused.value = true;
|
||||
}
|
||||
_cursorOverImage.value = true;
|
||||
_macOSLocalFocusLost = false;
|
||||
stateGlobal.getInputSource(force: true);
|
||||
_syncMacOSKeyboardGrab(
|
||||
reassert: !isInputSourceFlutter, allowInactiveLifecycle: true);
|
||||
} else if (!_rawKeyFocusNode.hasFocus) {
|
||||
_rawKeyFocusNode.requestFocus();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -513,15 +513,17 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
|
||||
final args = jsonDecode(call.arguments);
|
||||
final id = args['id'];
|
||||
final close = args['close'];
|
||||
RemotePage? remotePage;
|
||||
try {
|
||||
final remotePage = tabController.state.value.tabs
|
||||
remotePage = tabController.state.value.tabs
|
||||
.firstWhere((tab) => tab.key == id)
|
||||
.page as RemotePage;
|
||||
returnValue = remotePage.ffi.ffiModel.cachedPeerData.toString();
|
||||
} catch (e) {
|
||||
debugPrint('Failed to get cached session data: $e');
|
||||
}
|
||||
if (close && returnValue != null) {
|
||||
if (close && returnValue != null && remotePage != null) {
|
||||
remotePage.releaseMacOSInputForTabTransfer();
|
||||
closeSessionOnDispose[id] = false;
|
||||
tabController.closeBy(id);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,14 @@ import '../../models/file_model.dart';
|
||||
import '../../models/platform_model.dart';
|
||||
import '../../models/server_model.dart';
|
||||
|
||||
/// Set only by this window's own close control, and only once the user has confirmed. Any other
|
||||
/// way the window can go - a session logout closing every window, the window manager, a native
|
||||
/// title-bar button this app does not draw - leaves it false, which is the honest answer:
|
||||
/// nothing in that close says who asked for it. It lives at file scope because the control that
|
||||
/// sets it (`ConnectionManagerState`) and the handler that reads it (`_DesktopServerPageState`)
|
||||
/// are different widgets.
|
||||
bool _cmClosedByOperator = false;
|
||||
|
||||
class DesktopServerPage extends StatefulWidget {
|
||||
const DesktopServerPage({Key? key}) : super(key: key);
|
||||
|
||||
@@ -55,7 +63,10 @@ class _DesktopServerPageState extends State<DesktopServerPage>
|
||||
|
||||
@override
|
||||
void onWindowClose() {
|
||||
Future.wait([gFFI.serverModel.closeAll(), gFFI.close()]).then((_) {
|
||||
// Other platforms keep the old behaviour exactly: the ambiguity this guards against is a
|
||||
// Linux session logout, which closes every window in the session.
|
||||
final byOperator = _cmClosedByOperator || !isLinux;
|
||||
Future.wait([gFFI.serverModel.closeAll(byOperator: byOperator), gFFI.close()]).then((_) {
|
||||
if (isMacOS) {
|
||||
RdPlatformChannel.instance.terminate();
|
||||
} else {
|
||||
@@ -327,6 +338,7 @@ class ConnectionManagerState extends State<ConnectionManager>
|
||||
var tabController = gFFI.serverModel.tabController;
|
||||
final connLength = tabController.length;
|
||||
if (connLength <= 1) {
|
||||
_cmClosedByOperator = true;
|
||||
windowManager.close();
|
||||
return true;
|
||||
} else {
|
||||
@@ -338,6 +350,9 @@ class ConnectionManagerState extends State<ConnectionManager>
|
||||
res = await closeConfirmDialog();
|
||||
}
|
||||
if (res) {
|
||||
// After the dialog, never before it: an external close while it is open must not
|
||||
// inherit an intent the user had not expressed yet.
|
||||
_cmClosedByOperator = true;
|
||||
windowManager.close();
|
||||
}
|
||||
return res;
|
||||
@@ -495,14 +510,14 @@ class _CmHeaderState extends State<_CmHeader>
|
||||
if (client.type_() == ClientType.file)
|
||||
FittedBox(
|
||||
child: Text(
|
||||
translate("File Transfer"),
|
||||
translate("Transfer file"),
|
||||
style: TextStyle(color: Colors.white70, fontSize: 12),
|
||||
),
|
||||
),
|
||||
if (client.type_() == ClientType.camera)
|
||||
FittedBox(
|
||||
child: Text(
|
||||
translate("View Camera"),
|
||||
translate("View camera"),
|
||||
style: TextStyle(color: Colors.white70, fontSize: 12),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -5,7 +5,7 @@ import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
|
||||
import 'package:flutter_hbb/models/model.dart';
|
||||
import 'package:flutter_hbb/models/terminal_model.dart';
|
||||
import 'package:xterm/xterm.dart';
|
||||
import 'package:flutter_hbb/models/terminal_mouse_handler.dart';
|
||||
import 'terminal_connection_manager.dart';
|
||||
|
||||
class TerminalPage extends StatefulWidget {
|
||||
@@ -95,6 +95,13 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
// Register this terminal model with FFI for event routing
|
||||
_ffi.registerTerminalModel(widget.terminalId, _terminalModel);
|
||||
|
||||
// Auto-close tab when shell exits
|
||||
_terminalModel.onClosed = () {
|
||||
if (mounted) {
|
||||
widget.tabController.closeBy(widget.tabKey);
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize terminal connection
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
widget.tabController.onSelected?.call(widget.id);
|
||||
@@ -190,7 +197,7 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final heightPx = constraints.maxHeight;
|
||||
return TerminalView(
|
||||
return TerminalMouseInteraction(
|
||||
_terminalModel.terminal,
|
||||
controller: _terminalModel.terminalController,
|
||||
focusNode: _terminalFocusNode,
|
||||
|
||||
@@ -127,7 +127,6 @@ class _ViewCameraPageState extends State<ViewCameraPage>
|
||||
WakelockManager.enable(_uniqueKey);
|
||||
|
||||
_ffi.ffiModel.updateEventListener(sessionId, widget.id);
|
||||
if (!isWeb) bind.pluginSyncUi(syncTo: kAppTypeDesktopRemote);
|
||||
_ffi.qualityMonitorModel.checkShowQualityMonitor(sessionId);
|
||||
_ffi.dialogManager.loadMobileActionsOverlayVisible();
|
||||
DesktopMultiWindow.addListener(this);
|
||||
|
||||
@@ -9,9 +9,6 @@ import 'package:flutter_hbb/common/widgets/toolbar.dart';
|
||||
import 'package:flutter_hbb/models/chat_model.dart';
|
||||
import 'package:flutter_hbb/models/state_model.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/utils/multi_window_manager.dart';
|
||||
import 'package:flutter_hbb/plugin/widgets/desc_ui.dart';
|
||||
import 'package:flutter_hbb/plugin/common.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
@@ -779,6 +776,7 @@ class _RemoteToolbarState extends State<RemoteToolbar> {
|
||||
borderRadius: borderRadius,
|
||||
child: _DraggableShowHide(
|
||||
id: widget.id,
|
||||
ffi: widget.ffi,
|
||||
sessionId: widget.ffi.sessionId,
|
||||
dragging: _dragging,
|
||||
fraction: _fraction,
|
||||
@@ -805,13 +803,25 @@ class _RemoteToolbarState extends State<RemoteToolbar> {
|
||||
BuildContext context, _ToolbarEdge edge, bool isHorizontal) {
|
||||
final List<Widget> toolbarItems = [];
|
||||
toolbarItems.add(_PinMenu(state: widget.state));
|
||||
toolbarItems.add(Obx(() {
|
||||
final privacyModeState = PrivacyModeState.find(widget.id);
|
||||
if ((privacyModeState.isEmpty ||
|
||||
allowDisplaySwitchInPrivacyMode(pi, privacyModeState.value)) &&
|
||||
pi.displaysCount.value > 1 &&
|
||||
mainGetLocalBoolOptionSync(kOptionAllowMonitorSwitchMainToolbar)) {
|
||||
return _MainMonitorSwitchButton(id: widget.id, ffi: widget.ffi);
|
||||
} else {
|
||||
return const Offstage();
|
||||
}
|
||||
}));
|
||||
if (!isWebDesktop) {
|
||||
toolbarItems.add(_MobileActionMenu(ffi: widget.ffi));
|
||||
}
|
||||
|
||||
toolbarItems.add(Obx(() {
|
||||
if ((PrivacyModeState.find(widget.id).isEmpty ||
|
||||
allowDisplaySwitchInPrivacyMode(pi)) &&
|
||||
final privacyModeState = PrivacyModeState.find(widget.id);
|
||||
if ((privacyModeState.isEmpty ||
|
||||
allowDisplaySwitchInPrivacyMode(pi, privacyModeState.value)) &&
|
||||
pi.displaysCount.value > 1) {
|
||||
return _MonitorMenu(
|
||||
id: widget.id,
|
||||
@@ -964,6 +974,88 @@ class _MobileActionMenu extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _MonitorCycle {
|
||||
final String id;
|
||||
final FFI ffi;
|
||||
const _MonitorCycle(this.id, this.ffi);
|
||||
|
||||
PeerInfo get _pi => ffi.ffiModel.pi;
|
||||
int get total => _pi.displays.length;
|
||||
int get _current => CurrentDisplayState.find(id).value;
|
||||
bool get _inRange => _current >= 0 && _current < total;
|
||||
|
||||
String get label => _inRange ? '${_current + 1}' : '*';
|
||||
String get tooltip => '${translate('Switch display')} ($label/$total)';
|
||||
|
||||
void next() {
|
||||
final t = total;
|
||||
if (t < 2) return;
|
||||
final from = _inRange ? _current : -1;
|
||||
final target = (from + 1) % t;
|
||||
final isChooseDisplayToOpenInNewWindow = _pi.isSupportMultiDisplay &&
|
||||
bind.sessionGetDisplaysAsIndividualWindows(sessionId: ffi.sessionId) ==
|
||||
'Y';
|
||||
if (isChooseDisplayToOpenInNewWindow) {
|
||||
openMonitorInNewTabOrWindow(target, ffi.id, _pi);
|
||||
} else {
|
||||
openMonitorInTheSameTab(target, ffi, _pi, updateCursorPos: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _MainMonitorSwitchButton extends StatelessWidget {
|
||||
final String id;
|
||||
final FFI ffi;
|
||||
|
||||
const _MainMonitorSwitchButton({
|
||||
Key? key,
|
||||
required this.id,
|
||||
required this.ffi,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cycle = _MonitorCycle(id, ffi);
|
||||
return Obx(() {
|
||||
if (cycle.total < 2) return const Offstage();
|
||||
final label = cycle.label;
|
||||
|
||||
return _IconMenuButton(
|
||||
tooltip: cycle.tooltip,
|
||||
color: _ToolbarTheme.blueColor,
|
||||
hoverColor: _ToolbarTheme.hoverBlueColor,
|
||||
onPressed: cycle.next,
|
||||
icon: SizedBox(
|
||||
width: _ToolbarTheme.buttonSize,
|
||||
height: _ToolbarTheme.buttonSize,
|
||||
child: Stack(
|
||||
alignment: const Alignment(0, -0.125),
|
||||
children: [
|
||||
SvgPicture.asset(
|
||||
'assets/display_switcher.svg',
|
||||
colorFilter:
|
||||
const ColorFilter.mode(Colors.white, BlendMode.srcIn),
|
||||
width: _ToolbarTheme.buttonSize,
|
||||
height: _ToolbarTheme.buttonSize,
|
||||
),
|
||||
Text(
|
||||
label,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Colors.black,
|
||||
fontSize: 11,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _MonitorMenu extends StatelessWidget {
|
||||
final String id;
|
||||
final FFI ffi;
|
||||
@@ -1072,8 +1164,8 @@ class _MonitorMenu extends StatelessWidget {
|
||||
tooltip: isMulti
|
||||
? ''
|
||||
: isAllMonitors
|
||||
? 'all monitors'
|
||||
: '#${i + 1} monitor',
|
||||
? 'All monitors'
|
||||
: '#{${i + 1}} monitor',
|
||||
hMargin: isMulti ? null : 6,
|
||||
vMargin: isMulti ? null : 12,
|
||||
topLevel: false,
|
||||
@@ -1241,6 +1333,12 @@ class ScreenAdjustor {
|
||||
final FFI ffi;
|
||||
final VoidCallback cbExitFullscreen;
|
||||
window_size.Screen? _screen;
|
||||
Size? _waylandMaximizedWorkAreaSize;
|
||||
Rect? _waylandWorkAreaScreenFrame;
|
||||
double? _waylandWorkAreaScaleFactor;
|
||||
Rect? _x11WorkArea;
|
||||
Rect? _x11WorkAreaScreenFrame;
|
||||
double? _x11WorkAreaScaleFactor;
|
||||
|
||||
ScreenAdjustor({
|
||||
required this.id,
|
||||
@@ -1251,9 +1349,18 @@ class ScreenAdjustor {
|
||||
bool get isFullscreen => stateGlobal.fullscreen.isTrue;
|
||||
int get windowId => stateGlobal.windowId;
|
||||
|
||||
Future<bool?> isWindowMaximized() async {
|
||||
try {
|
||||
return await WindowController.fromWindowId(windowId).isMaximized();
|
||||
} catch (_) {
|
||||
// The delayed resolution callback may run after the window is disposed.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
adjustWindow(BuildContext context) {
|
||||
return futureBuilder(
|
||||
future: isWindowCanBeAdjusted(),
|
||||
future: isWindowCanBeAdjusted(context),
|
||||
hasData: (data) {
|
||||
final visible = data as bool;
|
||||
if (!visible) return Offstage();
|
||||
@@ -1269,36 +1376,201 @@ class ScreenAdjustor {
|
||||
});
|
||||
}
|
||||
|
||||
doAdjustWindow(BuildContext context) async {
|
||||
await updateScreen();
|
||||
if (_screen != null) {
|
||||
cbExitFullscreen();
|
||||
double scale = _screen!.scaleFactor;
|
||||
final wndRect = await WindowController.fromWindowId(windowId).getFrame();
|
||||
final mediaSize = MediaQueryData.fromView(View.of(context)).size;
|
||||
// On windows, wndRect is equal to GetWindowRect and mediaSize is equal to GetClientRect.
|
||||
// Linux screen and work-area coordinates can use different units or become
|
||||
// unreliable across Wayland/X11 state changes, so normalize reported frames
|
||||
// and cache usable work-area measurements before sizing the window.
|
||||
|
||||
Future<void> _updateLinuxWorkAreaCache({
|
||||
required window_size.Screen screen,
|
||||
required Rect wndRect,
|
||||
required bool isWayland,
|
||||
required bool isX11,
|
||||
required bool forMenu,
|
||||
}) async {
|
||||
if (isWayland &&
|
||||
(_waylandWorkAreaScreenFrame != screen.frame ||
|
||||
_waylandWorkAreaScaleFactor != screen.scaleFactor)) {
|
||||
_waylandMaximizedWorkAreaSize = null;
|
||||
_waylandWorkAreaScreenFrame = screen.frame;
|
||||
_waylandWorkAreaScaleFactor = screen.scaleFactor;
|
||||
}
|
||||
if (isWayland &&
|
||||
forMenu &&
|
||||
!isFullscreen &&
|
||||
await isWindowMaximized() == true) {
|
||||
_waylandMaximizedWorkAreaSize = wndRect.size;
|
||||
}
|
||||
if (isX11 &&
|
||||
(_x11WorkAreaScreenFrame != screen.frame ||
|
||||
_x11WorkAreaScaleFactor != screen.scaleFactor)) {
|
||||
_x11WorkArea = null;
|
||||
_x11WorkAreaScreenFrame = screen.frame;
|
||||
_x11WorkAreaScaleFactor = screen.scaleFactor;
|
||||
}
|
||||
if (isX11 && forMenu && !isFullscreen) {
|
||||
_x11WorkArea = screen.visibleFrame;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Rect?> _getEffectiveScreenFrame({
|
||||
required window_size.Screen screen,
|
||||
required bool isWayland,
|
||||
required bool isX11,
|
||||
required bool forMenu,
|
||||
}) async {
|
||||
Rect frameRect = screen.visibleFrame;
|
||||
if (isMacOS && forMenu && isFullscreen) {
|
||||
List<double>? workArea;
|
||||
try {
|
||||
workArea = await kMacOSPermChannel
|
||||
.invokeListMethod<double>('getMacOSWorkAreaSize');
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
if (workArea == null || workArea.length != 2) {
|
||||
return null;
|
||||
}
|
||||
frameRect = Rect.fromLTWH(
|
||||
frameRect.left,
|
||||
frameRect.top,
|
||||
workArea[0] < frameRect.width ? workArea[0] : frameRect.width,
|
||||
workArea[1] < frameRect.height ? workArea[1] : frameRect.height,
|
||||
);
|
||||
}
|
||||
final x11WorkArea = _x11WorkArea;
|
||||
if (isX11 &&
|
||||
forMenu &&
|
||||
isFullscreen &&
|
||||
x11WorkArea != null &&
|
||||
(x11WorkArea.width < frameRect.width ||
|
||||
x11WorkArea.height < frameRect.height)) {
|
||||
frameRect = x11WorkArea;
|
||||
}
|
||||
final screenScale = screen.scaleFactor;
|
||||
if (isWayland && screenScale > 1.01) {
|
||||
String monitorLayoutMode;
|
||||
try {
|
||||
monitorLayoutMode =
|
||||
await bind.mainGetCommon(key: 'gnome-monitor-layout-mode');
|
||||
} catch (_) {
|
||||
monitorLayoutMode = '';
|
||||
}
|
||||
if (monitorLayoutMode == 'physical') {
|
||||
frameRect = Rect.fromLTRB(
|
||||
frameRect.left / screenScale,
|
||||
frameRect.top / screenScale,
|
||||
frameRect.right / screenScale,
|
||||
frameRect.bottom / screenScale,
|
||||
);
|
||||
}
|
||||
}
|
||||
return frameRect;
|
||||
}
|
||||
|
||||
Future<Rect?> _getAdjustedWindowFrame(Size mediaSize,
|
||||
{bool forMenu = false}) async {
|
||||
final screen = _screen;
|
||||
if (screen != null) {
|
||||
// Windows window frames use physical pixels while Flutter view sizes are
|
||||
// logical. macOS and Linux window frames use the same units as Flutter.
|
||||
double scale = isWindows ? screen.scaleFactor : 1.0;
|
||||
final Rect wndRect;
|
||||
try {
|
||||
wndRect = await WindowController.fromWindowId(windowId).getFrame();
|
||||
} catch (e) {
|
||||
debugPrint("Failed to get frame of window $windowId, it may be hidden");
|
||||
return null;
|
||||
}
|
||||
// On Windows, wndRect is GetWindowRect while mediaSize is GetClientRect.
|
||||
// https://stackoverflow.com/a/7561083
|
||||
double magicWidth =
|
||||
wndRect.right - wndRect.left - mediaSize.width * scale;
|
||||
double magicHeight =
|
||||
wndRect.bottom - wndRect.top - mediaSize.height * scale;
|
||||
final canvasModel = ffi.canvasModel;
|
||||
// canvasModel.scale is the rendered scale and already applies kIgnoreDpi.
|
||||
// Use it instead of the remote source resolution.
|
||||
final isWayland = isLinux && bind.mainCurrentIsWayland();
|
||||
final isX11 = isLinux && !isWayland;
|
||||
await _updateLinuxWorkAreaCache(
|
||||
screen: screen,
|
||||
wndRect: wndRect,
|
||||
isWayland: isWayland,
|
||||
isX11: isX11,
|
||||
forMenu: forMenu,
|
||||
);
|
||||
if (isWindows && forMenu && isFullscreen) {
|
||||
// desktop_multi_window's hidden title bar keeps 8 physical pixels on
|
||||
// each horizontal edge and at the bottom, plus up to 1px at the top.
|
||||
// Fullscreen removes these in WM_NCCALCSIZE, so predict the restored
|
||||
// frame's worst-case padding when deciding whether to show the menu.
|
||||
magicWidth = 16.0;
|
||||
magicHeight = 9.0;
|
||||
}
|
||||
double horizontalEdges;
|
||||
double verticalEdges;
|
||||
if (forMenu && (isLinux || ((isMacOS || isWindows) && isFullscreen))) {
|
||||
// Linux Adjust Window unmaximizes; macOS and Windows exit fullscreen
|
||||
// before resizing. Predict the restored normal-window edges when
|
||||
// deciding whether to show the menu item.
|
||||
final resizePadding = isLinux && !kUseCompatibleUiMode
|
||||
? kDragToResizeAreaPaddingSize
|
||||
: 0.0;
|
||||
final windowEdge = kWindowBorderWidth + resizePadding;
|
||||
horizontalEdges = windowEdge * 2;
|
||||
verticalEdges = kDesktopRemoteTabBarHeight + windowEdge * 2;
|
||||
} else {
|
||||
horizontalEdges = CanvasModel.leftToEdge + CanvasModel.rightToEdge;
|
||||
verticalEdges = CanvasModel.topToEdge + CanvasModel.bottomToEdge;
|
||||
}
|
||||
final width = (canvasModel.getDisplayWidth() * canvasModel.scale +
|
||||
CanvasModel.leftToEdge +
|
||||
CanvasModel.rightToEdge) *
|
||||
horizontalEdges) *
|
||||
scale +
|
||||
magicWidth;
|
||||
final height = (canvasModel.getDisplayHeight() * canvasModel.scale +
|
||||
CanvasModel.topToEdge +
|
||||
CanvasModel.bottomToEdge) *
|
||||
scale +
|
||||
magicHeight;
|
||||
final height =
|
||||
(canvasModel.getDisplayHeight() * canvasModel.scale + verticalEdges) *
|
||||
scale +
|
||||
magicHeight;
|
||||
double left = wndRect.left + (wndRect.width - width) / 2;
|
||||
double top = wndRect.top + (wndRect.height - height) / 2;
|
||||
|
||||
Rect frameRect = _screen!.frame;
|
||||
if (!isFullscreen) {
|
||||
frameRect = _screen!.visibleFrame;
|
||||
final frameRect = await _getEffectiveScreenFrame(
|
||||
screen: screen,
|
||||
isWayland: isWayland,
|
||||
isX11: isX11,
|
||||
forMenu: forMenu,
|
||||
);
|
||||
if (frameRect == null) {
|
||||
return null;
|
||||
}
|
||||
var availableSize = frameRect.size;
|
||||
if (isWayland && forMenu && _waylandMaximizedWorkAreaSize != null) {
|
||||
final cachedSize = _waylandMaximizedWorkAreaSize!;
|
||||
availableSize = Size(
|
||||
cachedSize.width < availableSize.width
|
||||
? cachedSize.width
|
||||
: availableSize.width,
|
||||
cachedSize.height < availableSize.height
|
||||
? cachedSize.height
|
||||
: availableSize.height,
|
||||
);
|
||||
}
|
||||
// A window frame cannot be smaller than its client area. Tolerate small
|
||||
// floating-point differences; larger negative values mean the native
|
||||
// frame and Flutter view metrics are not synchronized.
|
||||
if (magicWidth < -0.1 || magicHeight < -0.1) {
|
||||
return null;
|
||||
}
|
||||
// Reject implausibly small targets to avoid hiding the window.
|
||||
if (width < 300 || height < 300) {
|
||||
return null;
|
||||
}
|
||||
// The remote size may change after the menu is built. Reject targets
|
||||
// that exceed the available area.
|
||||
final exceedsScreen =
|
||||
width > availableSize.width || height > availableSize.height;
|
||||
if (exceedsScreen) {
|
||||
return null;
|
||||
}
|
||||
if (left < frameRect.left) {
|
||||
left = frameRect.left;
|
||||
@@ -1312,69 +1584,101 @@ class ScreenAdjustor {
|
||||
if ((top + height) > frameRect.bottom) {
|
||||
top = frameRect.bottom - height;
|
||||
}
|
||||
await WindowController.fromWindowId(windowId)
|
||||
.setFrame(Rect.fromLTWH(left, top, width, height));
|
||||
return Rect.fromLTWH(left, top, width, height);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
doAdjustWindow([BuildContext? context]) async {
|
||||
// A resolution change is adjusted after a delay, when the menu context may
|
||||
// already be disposed. Each desktop_multi_window window has its own engine,
|
||||
// so that engine's first view is the current window.
|
||||
final views = WidgetsBinding.instance.platformDispatcher.views;
|
||||
if (context == null && views.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final view = context != null ? View.of(context) : views.first;
|
||||
await updateScreen();
|
||||
if (_screen != null) {
|
||||
final wc = WindowController.fromWindowId(windowId);
|
||||
final wasFullscreen = isFullscreen;
|
||||
cbExitFullscreen();
|
||||
if (wasFullscreen) {
|
||||
// Wait for the native fullscreen exit to update the window frame.
|
||||
await Future.delayed(Duration(milliseconds: 700));
|
||||
await updateScreen();
|
||||
}
|
||||
if (isLinux) {
|
||||
final isMaximized = await isWindowMaximized();
|
||||
if (isMaximized == null) {
|
||||
return;
|
||||
}
|
||||
if (isMaximized == true) {
|
||||
// setFrame may be ignored while the native window is maximized.
|
||||
try {
|
||||
await wc.unmaximize();
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
stateGlobal.setMaximized(false);
|
||||
// Wait for the window manager and Flutter view metrics to reflect
|
||||
// the restored window before calculating and setting its frame.
|
||||
await Future.delayed(Duration(milliseconds: 300));
|
||||
await updateScreen();
|
||||
}
|
||||
}
|
||||
final mediaSize = MediaQueryData.fromView(view).size;
|
||||
final frame = await _getAdjustedWindowFrame(mediaSize);
|
||||
if (frame == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await wc.setFrame(frame);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
stateGlobal.setMaximized(false);
|
||||
}
|
||||
}
|
||||
|
||||
updateScreen() async {
|
||||
final String info =
|
||||
isWeb ? screenInfo : await _getScreenInfoDesktop() ?? '';
|
||||
if (info.isEmpty) {
|
||||
_screen = null;
|
||||
} else {
|
||||
final screenMap = jsonDecode(info);
|
||||
_screen = window_size.Screen(
|
||||
Rect.fromLTRB(screenMap['frame']['l'], screenMap['frame']['t'],
|
||||
screenMap['frame']['r'], screenMap['frame']['b']),
|
||||
Rect.fromLTRB(
|
||||
screenMap['visibleFrame']['l'],
|
||||
screenMap['visibleFrame']['t'],
|
||||
screenMap['visibleFrame']['r'],
|
||||
screenMap['visibleFrame']['b']),
|
||||
screenMap['scaleFactor']);
|
||||
_screen = await _getCurrentScreen();
|
||||
}
|
||||
|
||||
Future<window_size.Screen?> _getCurrentScreen() async {
|
||||
try {
|
||||
return (await window_size.getWindowInfo()).screen;
|
||||
} catch (e) {
|
||||
debugPrint('Failed to get current window screen: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
_getScreenInfoDesktop() async {
|
||||
final v = await rustDeskWinManager.call(
|
||||
WindowType.Main, kWindowGetWindowInfo, '');
|
||||
return v.result;
|
||||
}
|
||||
|
||||
Future<bool> isWindowCanBeAdjusted() async {
|
||||
Future<bool> isWindowCanBeAdjusted([BuildContext? context]) async {
|
||||
if (isWeb) {
|
||||
return false;
|
||||
}
|
||||
// Capture the view before awaiting because the menu context may be disposed.
|
||||
final views = WidgetsBinding.instance.platformDispatcher.views;
|
||||
if (context == null && views.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
final view = context != null ? View.of(context) : views.first;
|
||||
final mediaSize = MediaQueryData.fromView(view).size;
|
||||
final viewStyle =
|
||||
await bind.sessionGetViewStyle(sessionId: ffi.sessionId) ?? '';
|
||||
if (viewStyle != kRemoteViewStyleOriginal) {
|
||||
return false;
|
||||
}
|
||||
if (!isWeb) {
|
||||
final remoteCount = RemoteCountState.find().value;
|
||||
if (remoteCount != 1) {
|
||||
return false;
|
||||
}
|
||||
final remoteCount = RemoteCountState.find().value;
|
||||
if (remoteCount != 1) {
|
||||
return false;
|
||||
}
|
||||
await updateScreen();
|
||||
if (_screen == null) {
|
||||
return false;
|
||||
}
|
||||
final scale = kIgnoreDpi ? 1.0 : _screen!.scaleFactor;
|
||||
double selfWidth = _screen!.visibleFrame.width;
|
||||
double selfHeight = _screen!.visibleFrame.height;
|
||||
if (isFullscreen) {
|
||||
selfWidth = _screen!.frame.width;
|
||||
selfHeight = _screen!.frame.height;
|
||||
}
|
||||
|
||||
final canvasModel = ffi.canvasModel;
|
||||
final displayWidth = canvasModel.getDisplayWidth();
|
||||
final displayHeight = canvasModel.getDisplayHeight();
|
||||
final requiredWidth =
|
||||
CanvasModel.leftToEdge + displayWidth + CanvasModel.rightToEdge;
|
||||
final requiredHeight =
|
||||
CanvasModel.topToEdge + displayHeight + CanvasModel.bottomToEdge;
|
||||
return selfWidth > (requiredWidth * scale) &&
|
||||
selfHeight > (requiredHeight * scale);
|
||||
return await _getAdjustedWindowFrame(mediaSize, forMenu: true) != null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1383,20 +1687,11 @@ class _DisplayMenu extends StatefulWidget {
|
||||
final FFI ffi;
|
||||
final ToolbarState state;
|
||||
final Function(bool) setFullscreen;
|
||||
final Widget pluginItem;
|
||||
_DisplayMenu(
|
||||
{Key? key,
|
||||
required this.id,
|
||||
const _DisplayMenu(
|
||||
{required this.id,
|
||||
required this.ffi,
|
||||
required this.state,
|
||||
required this.setFullscreen})
|
||||
: pluginItem = LocationItem.createLocationItem(
|
||||
id,
|
||||
ffi,
|
||||
kLocationClientRemoteToolbarDisplay,
|
||||
true,
|
||||
),
|
||||
super(key: key);
|
||||
required this.setFullscreen});
|
||||
|
||||
@override
|
||||
State<_DisplayMenu> createState() => _DisplayMenuState();
|
||||
@@ -1434,7 +1729,6 @@ class _DisplayMenuState extends State<_DisplayMenu> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
_screenAdjustor.updateScreen();
|
||||
menuChildrenGetter(_IconSubmenuButtonState state) {
|
||||
final menuChildren = <Widget>[
|
||||
_screenAdjustor.adjustWindow(context),
|
||||
@@ -1487,9 +1781,6 @@ class _DisplayMenuState extends State<_DisplayMenu> {
|
||||
]);
|
||||
}
|
||||
}
|
||||
if (ffi.connType == ConnType.defaultConn) {
|
||||
menuChildren.add(widget.pluginItem);
|
||||
}
|
||||
return menuChildren;
|
||||
}
|
||||
|
||||
@@ -2001,15 +2292,19 @@ class _ResolutionsMenuState extends State<_ResolutionsMenu> {
|
||||
|
||||
Future<void> _getLocalResolutionWayland() async {
|
||||
if (!isWayland) return _getLocalResolution();
|
||||
final window = await window_size.getWindowInfo();
|
||||
final screen = window.screen;
|
||||
if (screen != null) {
|
||||
setState(() {
|
||||
_localResolution = Resolution(
|
||||
screen.frame.width.toInt(),
|
||||
screen.frame.height.toInt(),
|
||||
);
|
||||
});
|
||||
try {
|
||||
final window = await window_size.getWindowInfo();
|
||||
final screen = window.screen;
|
||||
if (screen != null) {
|
||||
setState(() {
|
||||
_localResolution = Resolution(
|
||||
screen.frame.width.toInt(),
|
||||
screen.frame.height.toInt(),
|
||||
);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Failed to get local resolution on Wayland: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2081,8 +2376,16 @@ class _ResolutionsMenuState extends State<_ResolutionsMenu> {
|
||||
return;
|
||||
}
|
||||
if (w == rect.width.toInt() && h == rect.height.toInt()) {
|
||||
if (await widget.screenAdjustor.isWindowCanBeAdjusted()) {
|
||||
widget.screenAdjustor.doAdjustWindow(context);
|
||||
if (!await widget.screenAdjustor.isWindowCanBeAdjusted()) {
|
||||
return;
|
||||
}
|
||||
if (widget.screenAdjustor.isFullscreen) {
|
||||
return;
|
||||
}
|
||||
if ((await widget.screenAdjustor.isWindowMaximized()) == false) {
|
||||
// This delayed callback can outlive the menu State, so its context
|
||||
// is unsafe.
|
||||
widget.screenAdjustor.doAdjustWindow();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -2389,6 +2692,8 @@ class _KeyboardMenu extends StatelessWidget {
|
||||
? (v) async {
|
||||
if (v != null) {
|
||||
await stateGlobal.setInputSource(ffi.sessionId, v);
|
||||
// Release native input; see the macOS trade-offs in RemotePage.
|
||||
if (isMacOS) ffi.inputModel.enterOrLeave(false);
|
||||
await ffi.ffiModel.checkDesktopKeyboardMode();
|
||||
await ffi.inputModel.updateKeyboardMode();
|
||||
}
|
||||
@@ -2645,7 +2950,9 @@ class _RecordMenu extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
var ffi = Provider.of<FfiModel>(context);
|
||||
var recordingModel = Provider.of<RecordingModel>(context);
|
||||
final visible =
|
||||
final hideRecordingButton =
|
||||
bind.mainGetLocalOption(key: kOptionHideRecordingButton) == 'Y';
|
||||
final visible = !hideRecordingButton &&
|
||||
(recordingModel.start || ffi.permissions['recording'] != false);
|
||||
if (!visible) return Offstage();
|
||||
return _IconMenuButton(
|
||||
@@ -2757,7 +3064,7 @@ class _IconMenuButtonState extends State<_IconMenuButton> {
|
||||
horizontal: widget.hMargin ?? _ToolbarTheme.buttonHMargin,
|
||||
vertical: widget.vMargin ?? _ToolbarTheme.buttonVMargin);
|
||||
button = Tooltip(
|
||||
message: widget.tooltip,
|
||||
message: translate(widget.tooltip),
|
||||
child: button,
|
||||
);
|
||||
if (widget.topLevel) {
|
||||
@@ -2970,6 +3277,7 @@ class RdoMenuButton<T> extends StatelessWidget {
|
||||
|
||||
class _DraggableShowHide extends StatefulWidget {
|
||||
final String id;
|
||||
final FFI ffi;
|
||||
final SessionID sessionId;
|
||||
final RxDouble fraction;
|
||||
final Rx<_ToolbarEdge> edge;
|
||||
@@ -2993,6 +3301,7 @@ class _DraggableShowHide extends StatefulWidget {
|
||||
const _DraggableShowHide({
|
||||
Key? key,
|
||||
required this.id,
|
||||
required this.ffi,
|
||||
required this.sessionId,
|
||||
required this.fraction,
|
||||
required this.edge,
|
||||
@@ -3249,6 +3558,9 @@ class _DraggableShowHideState extends State<_DraggableShowHide> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildDraggable(context),
|
||||
Obx(() => collapse.isTrue
|
||||
? _MinimizedMonitorSwitchButton(id: widget.id, ffi: widget.ffi)
|
||||
: const Offstage()),
|
||||
Obx(() => buttonWrapper(
|
||||
() {
|
||||
widget.setFullscreen(!isFullscreen.value);
|
||||
@@ -3409,3 +3721,73 @@ class EdgeThicknessControl extends StatelessWidget {
|
||||
return slider;
|
||||
}
|
||||
}
|
||||
|
||||
class _MinimizedMonitorSwitchButton extends StatelessWidget {
|
||||
final String id;
|
||||
final FFI ffi;
|
||||
|
||||
const _MinimizedMonitorSwitchButton({
|
||||
Key? key,
|
||||
required this.id,
|
||||
required this.ffi,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const double iconSize = 20;
|
||||
final cycle = _MonitorCycle(id, ffi);
|
||||
|
||||
return Obx(() {
|
||||
final label = cycle.label;
|
||||
if (!mainGetLocalBoolOptionSync(kOptionAllowMonitorSwitchMainToolbar) ||
|
||||
!mainGetLocalBoolOptionSync(kOptionAllowMonitorSwitchMinToolbar)) {
|
||||
return const Offstage();
|
||||
}
|
||||
if (cycle.total < 2) return const Offstage();
|
||||
final privacyModeState = PrivacyModeState.find(id);
|
||||
if (privacyModeState.isNotEmpty &&
|
||||
!allowDisplaySwitchInPrivacyMode(
|
||||
ffi.ffiModel.pi, privacyModeState.value)) {
|
||||
return const Offstage();
|
||||
}
|
||||
|
||||
return Tooltip(
|
||||
message: cycle.tooltip,
|
||||
child: TextButton(
|
||||
onPressed: cycle.next,
|
||||
style: ButtonStyle(
|
||||
minimumSize: MaterialStateProperty.all(const Size(0, 0)),
|
||||
padding: MaterialStateProperty.all(EdgeInsets.zero),
|
||||
backgroundColor: MaterialStateProperty.resolveWith((states) {
|
||||
if (states.contains(MaterialState.hovered)) {
|
||||
return _ToolbarTheme.blueColor.withOpacity(0.15);
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
),
|
||||
child: Stack(
|
||||
alignment: const Alignment(0, -0.125),
|
||||
children: [
|
||||
SvgPicture.asset(
|
||||
'assets/display_switcher.svg',
|
||||
colorFilter:
|
||||
ColorFilter.mode(_ToolbarTheme.blueColor, BlendMode.srcIn),
|
||||
width: iconSize,
|
||||
height: iconSize,
|
||||
),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 9,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,9 +30,6 @@ import 'mobile/pages/server_page.dart';
|
||||
import 'mobile/widgets/deploy_dialog.dart';
|
||||
import 'models/platform_model.dart';
|
||||
|
||||
import 'package:flutter_hbb/plugin/handlers.dart'
|
||||
if (dart.library.html) 'package:flutter_hbb/web/plugin/handlers.dart';
|
||||
|
||||
/// Basic window and launch properties.
|
||||
int? kWindowId;
|
||||
WindowType? kWindowType;
|
||||
@@ -141,8 +138,6 @@ void runMainApp(bool startService) async {
|
||||
await bind.mainCheckConnectStatus();
|
||||
if (startService) {
|
||||
gFFI.serverModel.startService();
|
||||
bind.pluginSyncUi(syncTo: kAppTypeMain);
|
||||
bind.pluginListReload();
|
||||
}
|
||||
await Future.wait([gFFI.abModel.loadCache(), gFFI.groupModel.loadCache()]);
|
||||
gFFI.userModel.refreshCurrentUser();
|
||||
@@ -570,12 +565,6 @@ _registerEventHandler() {
|
||||
reloadAllWindows();
|
||||
});
|
||||
}
|
||||
// Register native handlers.
|
||||
if (isDesktop) {
|
||||
platformFFI.registerEventHandler('native_ui', 'native_ui', (evt) async {
|
||||
NativeUiHandler.instance.onEvent(evt);
|
||||
});
|
||||
}
|
||||
if (isAndroid) {
|
||||
platformFFI.registerEventHandler(
|
||||
'android_needs_deploy', 'android_needs_deploy', (_) async {
|
||||
@@ -588,7 +577,8 @@ _registerEventHandler() {
|
||||
|
||||
Widget keyListenerBuilder(BuildContext context, Widget? child) {
|
||||
return RawKeyboardListener(
|
||||
focusNode: FocusNode(),
|
||||
// `skipTraversal: isWeb` is to fix "Bad state: RenderBox was not laid out: minified:aeL#c19e4"
|
||||
focusNode: FocusNode(skipTraversal: isWeb),
|
||||
child: child ?? Container(),
|
||||
onKey: (RawKeyEvent event) {
|
||||
if (event.logicalKey == LogicalKeyboardKey.shiftLeft) {
|
||||
|
||||
@@ -207,6 +207,7 @@ class _ConnectionPageState extends State<ConnectionPage> {
|
||||
.contains(textToFind) ||
|
||||
peer.alias.toLowerCase().contains(textToFind))
|
||||
.toList();
|
||||
_allPeersLoader.queryOnlines(_autocompleteOpts);
|
||||
}
|
||||
return _autocompleteOpts;
|
||||
},
|
||||
|
||||
@@ -366,8 +366,7 @@ class _FileManagerPageState extends State<FileManagerPage> {
|
||||
return BottomSheetBody(
|
||||
leading: CircularProgressIndicator(),
|
||||
title: translate("Waiting"),
|
||||
text:
|
||||
"${translate("Speed")}: ${readableFileSize(activeJob.speed)}/s",
|
||||
text: "${readableFileSize(activeJob.speed)}/s",
|
||||
onCanceled: () {
|
||||
model.jobController.cancelJob(activeJob.id);
|
||||
jobTable.clear();
|
||||
|
||||
@@ -517,10 +517,12 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
||||
}
|
||||
return Container(
|
||||
color: MyTheme.canvasColor,
|
||||
child: RawTouchGestureDetectorRegion(
|
||||
child: getBodyForMobile(),
|
||||
ffi: gFFI,
|
||||
),
|
||||
child: inputModel.isPhysicalMouse.value
|
||||
? getBodyForMobile()
|
||||
: RawTouchGestureDetectorRegion(
|
||||
child: getBodyForMobile(),
|
||||
ffi: gFFI,
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
@@ -1218,7 +1220,11 @@ void showOptions(
|
||||
if (image != null) {
|
||||
displays.add(Padding(padding: const EdgeInsets.only(top: 8), child: image));
|
||||
}
|
||||
if (pi.displays.length > 1 && pi.currentDisplay != kAllDisplayValue) {
|
||||
final privacyModeState = PrivacyModeState.find(id);
|
||||
if (pi.displays.length > 1 &&
|
||||
pi.currentDisplay != kAllDisplayValue &&
|
||||
(privacyModeState.isEmpty ||
|
||||
allowDisplaySwitchInPrivacyMode(pi, privacyModeState.value))) {
|
||||
final cur = pi.currentDisplay;
|
||||
final children = <Widget>[];
|
||||
final isDarkTheme = MyTheme.currentThemeMode() == ThemeMode.dark;
|
||||
@@ -1270,10 +1276,16 @@ 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 = [];
|
||||
// privacy mode
|
||||
final privacyModeState = PrivacyModeState.find(id);
|
||||
if ((gFFI.ffiModel.pi.features.privacyMode && gFFI.ffiModel.keyboard) ||
|
||||
privacyModeState.isNotEmpty) {
|
||||
privacyModeList = toolbarPrivacyMode(privacyModeState, context, id, gFFI);
|
||||
|
||||
@@ -78,6 +78,7 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
||||
var _enableAbr = false;
|
||||
var _denyLANDiscovery = false;
|
||||
var _onlyWhiteList = false;
|
||||
var _onlyIdWhiteList = false;
|
||||
var _enableDirectIPAccess = false;
|
||||
var _enableRecordSession = false;
|
||||
var _enableHardwareCodec = false;
|
||||
@@ -89,6 +90,7 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
||||
var _directAccessPort = "";
|
||||
var _fingerprint = "";
|
||||
var _buildDate = "";
|
||||
var _myId = "";
|
||||
var _autoDisconnectTimeout = "";
|
||||
var _hideServer = false;
|
||||
var _hideProxy = false;
|
||||
@@ -109,6 +111,7 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
||||
_denyLANDiscovery = !option2bool(kOptionEnableLanDiscovery,
|
||||
bind.mainGetOptionSync(key: kOptionEnableLanDiscovery));
|
||||
_onlyWhiteList = whitelistNotEmpty();
|
||||
_onlyIdWhiteList = idWhitelistNotEmpty();
|
||||
_enableDirectIPAccess = option2bool(
|
||||
kOptionDirectServer, bind.mainGetOptionSync(key: kOptionDirectServer));
|
||||
_enableRecordSession = option2bool(kOptionEnableRecordSession,
|
||||
@@ -217,6 +220,12 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
||||
_buildDate = buildDate;
|
||||
}
|
||||
|
||||
final myId = await bind.mainGetMyId();
|
||||
if (_myId != myId) {
|
||||
update = true;
|
||||
_myId = myId;
|
||||
}
|
||||
|
||||
final isUsingPublicServer = await bind.mainIsUsingPublicServer();
|
||||
if (_isUsingPublicServer != isUsingPublicServer) {
|
||||
update = true;
|
||||
@@ -400,6 +409,29 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
||||
changeWhiteList(callback: update);
|
||||
},
|
||||
),
|
||||
SettingsTile.switchTile(
|
||||
title: Row(children: [
|
||||
Expanded(child: Text(translate('Use ID whitelisting'))),
|
||||
Offstage(
|
||||
offstage: !_onlyIdWhiteList,
|
||||
child: const Icon(Icons.warning_amber_rounded,
|
||||
color: Color.fromARGB(255, 255, 204, 0)))
|
||||
.marginOnly(left: 5)
|
||||
]),
|
||||
initialValue: _onlyIdWhiteList,
|
||||
onToggle: (_) async {
|
||||
update() async {
|
||||
final onlyIdWhiteList = idWhitelistNotEmpty();
|
||||
if (onlyIdWhiteList != _onlyIdWhiteList) {
|
||||
setState(() {
|
||||
_onlyIdWhiteList = onlyIdWhiteList;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
changeIdWhiteList(callback: update);
|
||||
},
|
||||
),
|
||||
SettingsTile.switchTile(
|
||||
title: Text(translate('Adaptive bitrate')),
|
||||
initialValue: _enableAbr,
|
||||
@@ -982,6 +1014,14 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
||||
child: Text(_fingerprint),
|
||||
),
|
||||
leading: Icon(Icons.fingerprint)),
|
||||
SettingsTile(
|
||||
onPressed: (context) => onCopyId(_myId),
|
||||
title: Text(translate("ID")),
|
||||
value: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8),
|
||||
child: Text(_myId),
|
||||
),
|
||||
leading: Icon(Icons.perm_identity)),
|
||||
SettingsTile(
|
||||
title: Text(translate("Privacy Statement")),
|
||||
onPressed: (context) =>
|
||||
|
||||
@@ -5,8 +5,13 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/common/widgets/dialog.dart';
|
||||
import 'package:flutter_hbb/models/input_modifier_utils.dart';
|
||||
import 'package:flutter_hbb/models/model.dart';
|
||||
import 'package:flutter_hbb/models/platform_model.dart';
|
||||
import 'package:flutter_hbb/models/terminal_model.dart';
|
||||
import 'package:flutter_hbb/mobile/terminal_keyboard_utils.dart';
|
||||
import 'package:flutter_hbb/web/dummy.dart'
|
||||
if (dart.library.html) 'package:flutter_hbb/web/terminal_font.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:xterm/xterm.dart';
|
||||
import '../../desktop/pages/terminal_connection_manager.dart';
|
||||
@@ -42,6 +47,11 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
final GlobalKey _keyboardKey = GlobalKey();
|
||||
double _keyboardHeight = 0;
|
||||
late bool _showTerminalExtraKeys;
|
||||
// Ctrl lock state for virtual keyboard: active key presses are mapped to control codes
|
||||
bool _ctrlLocked = false;
|
||||
bool _altLocked = false;
|
||||
// Row3 expand/collapse state for compact keyboard layout
|
||||
bool _row3Expanded = false;
|
||||
// For iOS edge swipe gesture
|
||||
double _swipeStartX = 0;
|
||||
double _swipeCurrentX = 0;
|
||||
@@ -59,6 +69,10 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
|
||||
if (isWeb) {
|
||||
loadLocalTerminalFontIfNeeded();
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
'[TerminalPage] Initializing terminal ${widget.terminalId} for peer ${widget.id}');
|
||||
|
||||
@@ -83,10 +97,29 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
// Register this terminal model with FFI for event routing
|
||||
_ffi.registerTerminalModel(widget.terminalId, _terminalModel);
|
||||
|
||||
// Auto-close connection when shell exits
|
||||
_terminalModel.onClosed = () {
|
||||
if (mounted) {
|
||||
closeConnection(id: widget.id);
|
||||
}
|
||||
};
|
||||
|
||||
// Web desktop users have full hardware keyboard access, so the on-screen
|
||||
// terminal extra keys bar is unnecessary and disabled.
|
||||
_showTerminalExtraKeys = !isWebDesktop &&
|
||||
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
|
||||
@@ -141,6 +174,39 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
return EdgeInsets.only(left: 5.0, right: 5.0, top: topBottom, bottom: topBottom + _sysKeyboardHeight + _keyboardHeight);
|
||||
}
|
||||
|
||||
/// Pastes clipboard text through TerminalModel so keyboard-only modifiers and
|
||||
/// mobile Enter normalization never alter clipboard data.
|
||||
Future<void> _pasteClipboardText() async {
|
||||
final data = await Clipboard.getData(Clipboard.kTextPlain);
|
||||
final text = data?.text;
|
||||
if (text == null || !mounted) return;
|
||||
|
||||
await _terminalModel.pasteText(text);
|
||||
if (mounted) {
|
||||
_terminalModel.terminalController.clearSelection();
|
||||
}
|
||||
}
|
||||
|
||||
KeyEventResult _handleTerminalKeyEvent(FocusNode _, KeyEvent event) {
|
||||
final hardwareKeyboard = HardwareKeyboard.instance;
|
||||
final shouldPaste = shouldHandleTerminalPasteShortcut(
|
||||
logicalKey: event.logicalKey,
|
||||
isKeyDown: event is KeyDownEvent,
|
||||
isKeyRepeat: event is KeyRepeatEvent,
|
||||
controlPressed: hardwareKeyboard.isControlPressed,
|
||||
metaPressed: hardwareKeyboard.isMetaPressed,
|
||||
altPressed: hardwareKeyboard.isAltPressed,
|
||||
shiftPressed: hardwareKeyboard.isShiftPressed,
|
||||
modifierLockActive: _ctrlLocked || _altLocked,
|
||||
);
|
||||
if (!shouldPaste) return KeyEventResult.ignored;
|
||||
|
||||
// Only locked virtual modifiers need interception. Without a lock, keep
|
||||
// xterm's default hardware paste behavior, including bracketed paste mode.
|
||||
unawaited(_pasteClipboardText());
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
@@ -178,6 +244,7 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
//
|
||||
// Android works fine without this workaround.
|
||||
deleteDetection: isIOS,
|
||||
onKeyEvent: _handleTerminalKeyEvent,
|
||||
padding: _calculatePadding(heightPx),
|
||||
onSecondaryTapDown: (details, offset) async {
|
||||
final selection = _terminalModel.terminalController.selection;
|
||||
@@ -186,11 +253,7 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
_terminalModel.terminalController.clearSelection();
|
||||
await Clipboard.setData(ClipboardData(text: text));
|
||||
} else {
|
||||
final data = await Clipboard.getData('text/plain');
|
||||
final text = data?.text;
|
||||
if (text != null) {
|
||||
_terminalModel.terminal.paste(text);
|
||||
}
|
||||
await _pasteClipboardText();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -317,66 +380,171 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Row 1 follows the latest reviewed PR layout.
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: _buildKeyboardKeyButtons(terminalKeyboardRow1Keys),
|
||||
),
|
||||
// Row 2 ends with the full-width Row3 collapse/expand toggle.
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_buildKeyButton('Esc'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('/'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('|'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('Home'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('↑'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('End'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('PgUp'),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_buildKeyButton('Tab'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('Ctrl+C'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('~'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('←'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('↓'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('→'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('PgDn'),
|
||||
..._buildKeyboardKeyButtons(terminalKeyboardRow2Keys),
|
||||
const SizedBox(width: terminalKeyboardKeySpacing),
|
||||
_buildCollapseButton(),
|
||||
],
|
||||
),
|
||||
// Row 3 restores paging keys and trailing alignment placeholders.
|
||||
if (_row3Expanded)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
..._buildKeyboardKeyButtons(terminalKeyboardRow3Keys),
|
||||
for (var i = 0;
|
||||
i < terminalKeyboardRow3TrailingPlaceholderCount;
|
||||
i++) ...[
|
||||
const SizedBox(width: terminalKeyboardKeySpacing),
|
||||
const SizedBox(width: terminalKeyboardKeyWidth),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Ctrl toggle button with highlighted locked state
|
||||
Widget _buildCtrlKeyButton() {
|
||||
return _buildModifierToggleButton(
|
||||
text: 'Ctrl',
|
||||
semanticsLabel: 'Ctrl',
|
||||
isLocked: _ctrlLocked,
|
||||
onPressed: () => setState(() => _ctrlLocked = !_ctrlLocked),
|
||||
);
|
||||
}
|
||||
|
||||
// Alt toggle button with highlighted locked state
|
||||
Widget _buildAltKeyButton() {
|
||||
return _buildModifierToggleButton(
|
||||
text: 'Alt',
|
||||
semanticsLabel: 'Alt',
|
||||
isLocked: _altLocked,
|
||||
onPressed: () => setState(() => _altLocked = !_altLocked),
|
||||
);
|
||||
}
|
||||
|
||||
// Collapse/expand toggle button for Row3
|
||||
void _toggleRow3Expanded() {
|
||||
final willExpand = !_row3Expanded;
|
||||
final shouldClearModifiers = shouldClearTerminalModifiersWhenRow3Collapses(
|
||||
wasExpanded: _row3Expanded,
|
||||
willExpand: willExpand,
|
||||
ctrlLocked: _ctrlLocked,
|
||||
altLocked: _altLocked,
|
||||
);
|
||||
setState(() {
|
||||
_row3Expanded = willExpand;
|
||||
if (shouldClearModifiers) {
|
||||
_ctrlLocked = false;
|
||||
_altLocked = false;
|
||||
}
|
||||
});
|
||||
mainSetLocalBoolOption(kOptionShowTerminalCtrlKeys, willExpand);
|
||||
|
||||
// The floating keyboard height changes after Row3 is inserted/removed.
|
||||
// Re-measure on the next frame so terminal padding uses the new height.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || !_showTerminalExtraKeys) return;
|
||||
setState(() {
|
||||
_updateKeyboardHeight();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildCollapseButton() {
|
||||
return Semantics(
|
||||
label: translate('Show terminal extra keys'),
|
||||
toggled: _row3Expanded,
|
||||
child: ElevatedButton(
|
||||
onPressed: _toggleRow3Expanded,
|
||||
child: Text(_row3Expanded ? '∧' : '∨'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(terminalKeyboardKeyWidth, 32),
|
||||
padding: EdgeInsets.zero,
|
||||
textStyle: const TextStyle(fontSize: 12),
|
||||
backgroundColor:
|
||||
Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
foregroundColor: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds a fixed-width key sequence with the reviewed 2dp spacing.
|
||||
List<Widget> _buildKeyboardKeyButtons(List<String> labels) {
|
||||
return [
|
||||
for (var i = 0; i < labels.length; i++) ...[
|
||||
_buildKeyButton(labels[i]),
|
||||
if (i < labels.length - 1)
|
||||
const SizedBox(width: terminalKeyboardKeySpacing),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/// Build a modifier toggle button (Ctrl/Alt) with one-shot behavior.
|
||||
/// When [isLocked] is true, the button highlights in blue and the next
|
||||
/// single-character input is mapped to its modified equivalent.
|
||||
Widget _buildModifierToggleButton({
|
||||
required String text,
|
||||
required String semanticsLabel,
|
||||
required bool isLocked,
|
||||
required VoidCallback onPressed,
|
||||
}) {
|
||||
return Semantics(
|
||||
// Ctrl and Alt are technical key names and intentionally stay unchanged.
|
||||
label: semanticsLabel,
|
||||
toggled: isLocked,
|
||||
child: ElevatedButton(
|
||||
onPressed: onPressed,
|
||||
child: Text(text),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(terminalKeyboardKeyWidth, 32),
|
||||
padding: EdgeInsets.zero,
|
||||
textStyle: const TextStyle(fontSize: 12),
|
||||
backgroundColor: isLocked
|
||||
? Colors.blue
|
||||
: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
foregroundColor: isLocked
|
||||
? Colors.white
|
||||
: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildKeyButton(String label) {
|
||||
if (label == 'Ctrl') return _buildCtrlKeyButton();
|
||||
if (label == 'Alt') return _buildAltKeyButton();
|
||||
|
||||
return ElevatedButton(
|
||||
onPressed: () {
|
||||
_sendKeyToTerminal(label);
|
||||
},
|
||||
child: Text(label),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(48, 32),
|
||||
minimumSize: const Size(terminalKeyboardKeyWidth, 32),
|
||||
padding: EdgeInsets.zero,
|
||||
textStyle: const TextStyle(fontSize: 12),
|
||||
backgroundColor: Theme.of(context).colorScheme.surfaceVariant,
|
||||
backgroundColor:
|
||||
Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
foregroundColor: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _sendKeyToTerminal(String key) {
|
||||
String? send;
|
||||
String send;
|
||||
|
||||
switch (key) {
|
||||
case 'Esc':
|
||||
@@ -420,9 +588,7 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
break;
|
||||
}
|
||||
|
||||
if (send != null) {
|
||||
_terminalModel.sendVirtualKey(send);
|
||||
}
|
||||
_terminalModel.sendVirtualKey(send);
|
||||
}
|
||||
|
||||
// https://github.com/TerminalStudio/xterm.dart/issues/42#issuecomment-877495472
|
||||
|
||||
@@ -259,11 +259,13 @@ class _ViewCameraPageState extends State<ViewCameraPage>
|
||||
}
|
||||
return Container(
|
||||
color: MyTheme.canvasColor,
|
||||
child: RawTouchGestureDetectorRegion(
|
||||
child: getBodyForMobile(),
|
||||
ffi: gFFI,
|
||||
isCamera: true,
|
||||
),
|
||||
child: inputModel.isPhysicalMouse.value
|
||||
? getBodyForMobile()
|
||||
: RawTouchGestureDetectorRegion(
|
||||
child: getBodyForMobile(),
|
||||
ffi: gFFI,
|
||||
isCamera: true,
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
|
||||
20
flutter/lib/mobile/terminal_keyboard_utils.dart
Normal file
20
flutter/lib/mobile/terminal_keyboard_utils.dart
Normal file
@@ -0,0 +1,20 @@
|
||||
/// Reviewed mobile terminal keyboard layout from PR #15532.
|
||||
///
|
||||
/// Keeping the key order outside the widget makes the intended layout explicit
|
||||
/// and prevents behavior fixes from silently moving keys between rows.
|
||||
const terminalKeyboardRow1Keys = ['Esc', '/', '|', 'Home', '↑', 'End', r'\'];
|
||||
const terminalKeyboardRow2Keys = ['Tab', 'Ctrl+C', '~', '←', '↓', '→'];
|
||||
const terminalKeyboardRow3Keys = ['Ctrl', 'Alt', '-', 'PgUp', 'PgDn'];
|
||||
|
||||
const terminalKeyboardKeyWidth = 48.0;
|
||||
const terminalKeyboardKeySpacing = 2.0;
|
||||
|
||||
/// Empty 48dp slots keep expanded Row3 aligned with the two rows above it.
|
||||
const terminalKeyboardRow3TrailingPlaceholderCount = 2;
|
||||
|
||||
/// Returns the fixed width occupied by a row of equally sized key slots.
|
||||
double terminalKeyboardRowWidth(int slotCount) {
|
||||
if (slotCount <= 0) return 0;
|
||||
return slotCount * terminalKeyboardKeyWidth +
|
||||
(slotCount - 1) * terminalKeyboardKeySpacing;
|
||||
}
|
||||
@@ -117,13 +117,13 @@ void showServerSettingsWithValue(
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
child: serverSettingsTextFormField(
|
||||
label: label,
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
errorText: errorMsg.isEmpty ? null : errorMsg,
|
||||
contentPadding:
|
||||
EdgeInsets.symmetric(horizontal: 8, vertical: 12),
|
||||
),
|
||||
errorMsg: errorMsg,
|
||||
contentPadding:
|
||||
EdgeInsets.symmetric(horizontal: 8, vertical: 12),
|
||||
showLabelText: false,
|
||||
validator: validator,
|
||||
autofocus: autofocus,
|
||||
).workaroundFreezeLinuxMint(),
|
||||
@@ -132,12 +132,10 @@ void showServerSettingsWithValue(
|
||||
);
|
||||
}
|
||||
|
||||
return TextFormField(
|
||||
return serverSettingsTextFormField(
|
||||
label: label,
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
errorText: errorMsg.isEmpty ? null : errorMsg,
|
||||
),
|
||||
errorMsg: errorMsg,
|
||||
validator: validator,
|
||||
).workaroundFreezeLinuxMint();
|
||||
}
|
||||
@@ -209,6 +207,35 @@ void showServerSettingsWithValue(
|
||||
});
|
||||
}
|
||||
|
||||
TextFormField serverSettingsTextFormField({
|
||||
required String label,
|
||||
required TextEditingController controller,
|
||||
required String errorMsg,
|
||||
String? Function(String?)? validator,
|
||||
bool autofocus = false,
|
||||
bool showLabelText = true,
|
||||
EdgeInsetsGeometry? contentPadding,
|
||||
}) {
|
||||
return TextFormField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
labelText: showLabelText ? label : null,
|
||||
errorText: errorMsg.isEmpty ? null : errorMsg,
|
||||
contentPadding: contentPadding,
|
||||
),
|
||||
validator: validator,
|
||||
autofocus: autofocus,
|
||||
keyboardType: TextInputType.visiblePassword,
|
||||
textCapitalization: TextCapitalization.none,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
smartDashesType: SmartDashesType.disabled,
|
||||
smartQuotesType: SmartQuotesType.disabled,
|
||||
enableIMEPersonalizedLearning: false,
|
||||
spellCheckConfiguration: const SpellCheckConfiguration.disabled(),
|
||||
);
|
||||
}
|
||||
|
||||
void setPrivacyModeDialog(
|
||||
OverlayDialogManager dialogManager,
|
||||
List<TToggleMenu> privacyModeList,
|
||||
|
||||
@@ -46,6 +46,12 @@ class JobID {
|
||||
|
||||
typedef GetSessionID = SessionID Function();
|
||||
typedef GetDialogManager = OverlayDialogManager? Function();
|
||||
typedef ReadRemoteDirectory = Future<void> Function(
|
||||
SessionID sessionId, String path, bool includeHidden);
|
||||
|
||||
const _kRemoteReadDirTimeout = Duration(seconds: 30);
|
||||
const _kRemoteSessionChangedError =
|
||||
'Remote directory read cancelled because the session changed';
|
||||
|
||||
class FileModel {
|
||||
final WeakReference<FFI> parent;
|
||||
@@ -84,6 +90,7 @@ class FileModel {
|
||||
}
|
||||
|
||||
Future<void> onReady() async {
|
||||
fileFetcher.beginRemoteSession();
|
||||
await evtLoop.onReady();
|
||||
if (!isWeb) await localController.onReady();
|
||||
await remoteController.onReady();
|
||||
@@ -133,7 +140,11 @@ class FileModel {
|
||||
final id = int.tryParse(evt['id']?.toString() ?? '');
|
||||
if (id != null) {
|
||||
final err = evt['err']?.toString() ?? 'Unknown error';
|
||||
fileFetcher.tryCompleteRecursiveTaskWithError(id, err);
|
||||
if (id == 0) {
|
||||
fileFetcher.tryCompleteRemoteTaskWithError(err);
|
||||
} else {
|
||||
fileFetcher.tryCompleteRecursiveTaskWithError(id, err);
|
||||
}
|
||||
}
|
||||
// Always call jobController.jobError(evt) to ensure all error events are processed,
|
||||
// even if the event does not have a valid job ID. This allows for generic error handling
|
||||
@@ -142,12 +153,22 @@ class FileModel {
|
||||
}
|
||||
|
||||
Future<void> postOverrideFileConfirm(Map<String, dynamic> evt) async {
|
||||
final id = int.tryParse(evt['id']?.toString() ?? '');
|
||||
if (id == null || !jobController.hasTransferConflictJob(id)) {
|
||||
debugPrint("Ignore stale override confirm event: $evt");
|
||||
return;
|
||||
}
|
||||
evtLoop.pushEvent(
|
||||
_FileDialogEvent(WeakReference(this), FileDialogType.overwrite, evt));
|
||||
}
|
||||
|
||||
Future<void> overrideFileConfirm(Map<String, dynamic> evt,
|
||||
{bool? overrideConfirm, bool skip = false}) async {
|
||||
final id = int.tryParse(evt['id']?.toString() ?? '') ?? 0;
|
||||
if (id == 0 || !jobController.hasTransferConflictJob(id)) {
|
||||
debugPrint("Ignore override confirm for inactive job: $evt");
|
||||
return;
|
||||
}
|
||||
// If `skip == true`, it means to skip this file without showing dialog.
|
||||
// Because `resp` may be null after the user operation or the last remembered operation,
|
||||
// and we should distinguish them.
|
||||
@@ -156,15 +177,12 @@ class FileModel {
|
||||
? await showFileConfirmDialog(translate("Overwrite"),
|
||||
"${evt['read_path']}", true, evt['is_identical'] == "true")
|
||||
: null);
|
||||
final id = int.tryParse(evt['id']) ?? 0;
|
||||
if (!jobController.hasTransferConflictJob(id)) {
|
||||
debugPrint("Ignore override confirm result for inactive job: $evt");
|
||||
return;
|
||||
}
|
||||
if (false == resp) {
|
||||
final jobIndex = jobController.getJob(id);
|
||||
if (jobIndex != -1) {
|
||||
await jobController.cancelJob(id);
|
||||
final job = jobController.jobTable[jobIndex];
|
||||
job.state = JobState.done;
|
||||
jobController.jobTable.refresh();
|
||||
}
|
||||
await jobController.cancelTransferConflictBatch(id);
|
||||
} else {
|
||||
var need_override = false;
|
||||
if (resp == null) {
|
||||
@@ -176,6 +194,7 @@ class FileModel {
|
||||
}
|
||||
// Update the loop config.
|
||||
if (fileConfirmCheckboxRemember) {
|
||||
jobController.rememberTransferConflictBatch(id, resp);
|
||||
evtLoop.setSkip(!need_override);
|
||||
}
|
||||
await bind.sessionSetConfirmOverrideFile(
|
||||
@@ -285,6 +304,8 @@ class FileModel {
|
||||
final isWindows = otherSideData.options.isWindows;
|
||||
final showHidden = otherSideData.options.showHidden;
|
||||
final jobID = jobController.addTransferJob(entry, false);
|
||||
jobController.registerTransferConflictBatch([jobID],
|
||||
batchId: int.tryParse(obj['batchId']?.toString() ?? ''));
|
||||
webSendLocalFiles(
|
||||
handleIndex: handleIndex,
|
||||
actId: jobID,
|
||||
@@ -340,6 +361,8 @@ class FileController {
|
||||
final history = RxList<String>.empty(growable: true);
|
||||
final sortBy = SortBy.name.obs;
|
||||
var sortAscending = true;
|
||||
// Incremented for each navigation; only the latest generation applies results.
|
||||
int _directoryRequestGeneration = 0;
|
||||
final JobController jobController;
|
||||
final WeakReference<FFI> rootState;
|
||||
|
||||
@@ -474,12 +497,19 @@ 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);
|
||||
directory.value = fd;
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (requestGeneration != _directoryRequestGeneration) {
|
||||
return true;
|
||||
}
|
||||
debugPrint("Failed to openDirectory $path: $e");
|
||||
return false;
|
||||
}
|
||||
@@ -531,6 +561,7 @@ class FileController {
|
||||
void initDirAndHome(Map<String, dynamic> evt) {
|
||||
try {
|
||||
final fd = FileDirectory.fromJson(jsonDecode(evt['value']));
|
||||
final isHomeResponse = fileFetcher.isLikelyRemoteHomeResponse(fd.path);
|
||||
fd.format(options.value.isWindows, sort: sortBy.value);
|
||||
if (fd.id > 0) {
|
||||
final jobIndex = jobController.getJob(fd.id);
|
||||
@@ -546,10 +577,12 @@ class FileController {
|
||||
debugPrint("update receive details: ${fd.path}");
|
||||
jobController.jobTable.refresh();
|
||||
}
|
||||
} else if (options.value.home.isEmpty) {
|
||||
} else if (options.value.home.isEmpty && isHomeResponse) {
|
||||
options.value.home = fd.path;
|
||||
debugPrint("init remote home: ${fd.path}");
|
||||
directory.value = fd;
|
||||
if (_directoryRequestGeneration == 0) {
|
||||
directory.value = fd;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("initDirAndHome err=$e");
|
||||
@@ -570,8 +603,15 @@ class FileController {
|
||||
final toPath = otherSideData.directory.path;
|
||||
final isWindows = otherSideData.options.isWindows;
|
||||
final showHidden = otherSideData.options.showHidden;
|
||||
final transferJobs = <(Entry, int)>[];
|
||||
final transferJobIds = <int>[];
|
||||
for (var from in items.items) {
|
||||
final jobID = jobController.addTransferJob(from, isRemoteToLocal);
|
||||
transferJobs.add((from, jobID));
|
||||
transferJobIds.add(jobID);
|
||||
}
|
||||
jobController.registerTransferConflictBatch(transferJobIds);
|
||||
for (final (from, jobID) in transferJobs) {
|
||||
bind.sessionSendFiles(
|
||||
sessionId: sessionId,
|
||||
actId: jobID,
|
||||
@@ -917,6 +957,10 @@ class JobController {
|
||||
static final JobID jobID = JobID();
|
||||
final jobTable = List<JobProgress>.empty(growable: true).obs;
|
||||
final jobResultListener = JobResultListener<Map<String, dynamic>>();
|
||||
int _nextTransferConflictBatchId = 1;
|
||||
final Map<int, int> _transferConflictJobToBatch = {};
|
||||
int? _transferConflictRememberBatchId;
|
||||
bool? _transferConflictRememberOverrideConfirm;
|
||||
final GetSessionID getSessionID;
|
||||
final GetDialogManager getDialogManager;
|
||||
SessionID get sessionId => getSessionID();
|
||||
@@ -929,6 +973,57 @@ class JobController {
|
||||
return jobTable.indexWhere((element) => element.id == id);
|
||||
}
|
||||
|
||||
void registerTransferConflictBatch(Iterable<int> jobIds, {int? batchId}) {
|
||||
final ids = jobIds.toList(growable: false);
|
||||
if (ids.isEmpty) {
|
||||
return;
|
||||
}
|
||||
batchId ??= _nextTransferConflictBatchId++;
|
||||
if (batchId >= _nextTransferConflictBatchId) {
|
||||
_nextTransferConflictBatchId = batchId + 1;
|
||||
}
|
||||
for (final jobId in ids) {
|
||||
_transferConflictJobToBatch[jobId] = batchId;
|
||||
}
|
||||
}
|
||||
|
||||
int? transferConflictBatchId(int jobId) {
|
||||
return _transferConflictJobToBatch[jobId];
|
||||
}
|
||||
|
||||
bool hasTransferConflictJob(int jobId) {
|
||||
return transferConflictBatchId(jobId) != null;
|
||||
}
|
||||
|
||||
bool isTransferConflictRememberBatch(int? batchId) {
|
||||
return batchId != null && batchId == _transferConflictRememberBatchId;
|
||||
}
|
||||
|
||||
bool? transferConflictRememberOverrideConfirm(int? batchId) {
|
||||
if (!isTransferConflictRememberBatch(batchId)) {
|
||||
return null;
|
||||
}
|
||||
return _transferConflictRememberOverrideConfirm;
|
||||
}
|
||||
|
||||
void rememberTransferConflictBatch(int jobId, bool? overrideConfirm) {
|
||||
_transferConflictRememberBatchId = _transferConflictJobToBatch[jobId];
|
||||
_transferConflictRememberOverrideConfirm = overrideConfirm;
|
||||
}
|
||||
|
||||
void unregisterTransferConflictJob(int jobId) {
|
||||
final batchId = _transferConflictJobToBatch.remove(jobId);
|
||||
if (batchId == null) {
|
||||
return;
|
||||
}
|
||||
if (!_transferConflictJobToBatch.containsValue(batchId)) {
|
||||
if (_transferConflictRememberBatchId == batchId) {
|
||||
_transferConflictRememberBatchId = null;
|
||||
_transferConflictRememberOverrideConfirm = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// return jobID
|
||||
int addTransferJob(Entry from, bool isRemoteToLocal) {
|
||||
final jobID = JobController.jobID.next();
|
||||
@@ -1000,7 +1095,10 @@ class JobController {
|
||||
id = int.parse(evt['id']);
|
||||
} catch (_) {}
|
||||
final jobIndex = getJob(id);
|
||||
if (jobIndex == -1) return true;
|
||||
if (jobIndex == -1) {
|
||||
unregisterTransferConflictJob(id);
|
||||
return true;
|
||||
}
|
||||
final job = jobTable[jobIndex];
|
||||
job.recvJobRes = true;
|
||||
if (job.type == JobType.deleteFile) {
|
||||
@@ -1026,6 +1124,9 @@ class JobController {
|
||||
job.state = JobState.done;
|
||||
}
|
||||
jobTable.refresh();
|
||||
if (job.state == JobState.done || job.state == JobState.error) {
|
||||
unregisterTransferConflictJob(id);
|
||||
}
|
||||
if (job.type == JobType.deleteDir) {
|
||||
return job.state == JobState.done;
|
||||
} else {
|
||||
@@ -1035,9 +1136,15 @@ class JobController {
|
||||
|
||||
void jobError(Map<String, dynamic> evt) {
|
||||
final err = evt['err'].toString();
|
||||
int jobIndex = getJob(int.parse(evt['id']));
|
||||
final id = int.tryParse(evt['id']?.toString() ?? '');
|
||||
if (id == null) {
|
||||
debugPrint("Ignore job error with invalid id: $evt");
|
||||
return;
|
||||
}
|
||||
int jobIndex = getJob(id);
|
||||
if (jobIndex != -1) {
|
||||
final job = jobTable[jobIndex];
|
||||
if (job.state == JobState.done && job.err == "cancel") return;
|
||||
job.state = JobState.error;
|
||||
job.err = err;
|
||||
job.recvJobRes = true;
|
||||
@@ -1060,6 +1167,11 @@ class JobController {
|
||||
}
|
||||
}
|
||||
jobTable.refresh();
|
||||
if (job.state == JobState.done || job.state == JobState.error) {
|
||||
unregisterTransferConflictJob(job.id);
|
||||
}
|
||||
} else {
|
||||
unregisterTransferConflictJob(id);
|
||||
}
|
||||
if (err == _kOneWayFileTransferError) {
|
||||
if (DateTime.now().millisecondsSinceEpoch - _lastTimeShowMsgbox > 3000) {
|
||||
@@ -1096,9 +1208,42 @@ class JobController {
|
||||
}
|
||||
|
||||
Future<void> cancelJob(int id) async {
|
||||
unregisterTransferConflictJob(id);
|
||||
await bind.sessionCancelJob(sessionId: sessionId, actId: id);
|
||||
}
|
||||
|
||||
Future<void> cancelTransferConflictBatch(int jobId) async {
|
||||
final batchId = _transferConflictJobToBatch[jobId];
|
||||
final batchJobIds = batchId == null ? [jobId] : <int>[];
|
||||
if (batchId != null) {
|
||||
for (final entry in _transferConflictJobToBatch.entries) {
|
||||
if (entry.value == batchId) {
|
||||
batchJobIds.add(entry.key);
|
||||
}
|
||||
}
|
||||
for (final id in batchJobIds) {
|
||||
unregisterTransferConflictJob(id);
|
||||
}
|
||||
}
|
||||
final jobIdsToCancel = batchJobIds.toSet();
|
||||
for (final job in jobTable) {
|
||||
if (!jobIdsToCancel.contains(job.id) || job.state == JobState.done) {
|
||||
continue;
|
||||
}
|
||||
job.state = JobState.done;
|
||||
job.err = "cancel";
|
||||
job.recvJobRes = true;
|
||||
}
|
||||
jobTable.refresh();
|
||||
for (final id in batchJobIds) {
|
||||
try {
|
||||
await bind.sessionCancelJob(sessionId: sessionId, actId: id);
|
||||
} catch (e) {
|
||||
debugPrint("Failed to cancel transfer job $id in conflict batch: $e");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadLastJob(Map<String, dynamic> evt) async {
|
||||
debugPrint("load last job: $evt");
|
||||
Map<String, dynamic> jobDetail = json.decode(evt['value']);
|
||||
@@ -1145,7 +1290,7 @@ class JobController {
|
||||
..state = JobState.paused;
|
||||
jobTable.add(jobProgress);
|
||||
}
|
||||
|
||||
registerTransferConflictBatch([currJobId]);
|
||||
await bind.sessionAddJob(
|
||||
sessionId: sessionId,
|
||||
isRemote: isRemote,
|
||||
@@ -1193,6 +1338,9 @@ class JobController {
|
||||
|
||||
void clear() {
|
||||
jobTable.clear();
|
||||
_transferConflictJobToBatch.clear();
|
||||
_transferConflictRememberBatchId = null;
|
||||
_transferConflictRememberOverrideConfirm = null;
|
||||
jobResultListener.clear();
|
||||
}
|
||||
}
|
||||
@@ -1237,16 +1385,78 @@ class JobResultListener<T> {
|
||||
}
|
||||
}
|
||||
|
||||
class _RemoteReadTask {
|
||||
final bool includeHidden;
|
||||
final Completer<FileDirectory> completer = Completer<FileDirectory>();
|
||||
final Completer<void> released = Completer<void>();
|
||||
late final Timer timer;
|
||||
|
||||
_RemoteReadTask(this.includeHidden);
|
||||
}
|
||||
|
||||
class FileFetcher {
|
||||
// Map<String,Completer<FileDirectory>> localTasks = {}; // now we only use read local dir sync
|
||||
Map<String, Completer<FileDirectory>> remoteTasks = {};
|
||||
final Map<String, _RemoteReadTask> _remoteReadTasks = {};
|
||||
Map<String, Completer<List<FileDirectory>>> remoteEmptyDirsTasks = {};
|
||||
Map<int, Completer<FileDirectory>> readRecursiveTasks = {};
|
||||
int _remoteSessionGeneration = 0;
|
||||
|
||||
final GetSessionID getSessionID;
|
||||
final ReadRemoteDirectory _readRemoteDirectory;
|
||||
SessionID get sessionId => getSessionID();
|
||||
|
||||
FileFetcher(this.getSessionID);
|
||||
FileFetcher(this.getSessionID, {ReadRemoteDirectory? readRemoteDirectory})
|
||||
: _readRemoteDirectory = readRemoteDirectory ??
|
||||
((sessionId, path, includeHidden) => bind.sessionReadRemoteDir(
|
||||
sessionId: sessionId,
|
||||
path: path,
|
||||
includeHidden: includeHidden));
|
||||
|
||||
bool hasPendingRemoteRead(String path) => _remoteReadTasks.containsKey(path);
|
||||
|
||||
bool isLikelyRemoteHomeResponse(String path) =>
|
||||
_remoteReadTasks.isEmpty ||
|
||||
(_remoteReadTasks.length == 1 &&
|
||||
hasPendingRemoteRead("") &&
|
||||
!hasPendingRemoteRead(path));
|
||||
|
||||
void beginRemoteSession() {
|
||||
_remoteSessionGeneration++;
|
||||
final pendingTasks = _remoteReadTasks.entries.toList(growable: false);
|
||||
for (final entry in pendingTasks) {
|
||||
final task = entry.value;
|
||||
if (!_removeRemoteReadTask(entry.key, task)) continue;
|
||||
task.completer.completeError(StateError(_kRemoteSessionChangedError));
|
||||
}
|
||||
}
|
||||
|
||||
_RemoteReadTask _registerRemoteReadTask(String path, bool includeHidden) {
|
||||
if (hasPendingRemoteRead(path)) {
|
||||
throw "Failed to registerReadTask, already have same read job";
|
||||
}
|
||||
final task = _RemoteReadTask(includeHidden);
|
||||
_remoteReadTasks[path] = task;
|
||||
task.timer = Timer(_kRemoteReadDirTimeout, () {
|
||||
if (!_removeRemoteReadTask(path, task)) return;
|
||||
task.completer.completeError("Failed to read dir, timeout");
|
||||
});
|
||||
return task;
|
||||
}
|
||||
|
||||
bool _removeRemoteReadTask(String path, _RemoteReadTask task) {
|
||||
if (!identical(_remoteReadTasks[path], task)) return false;
|
||||
_remoteReadTasks.remove(path);
|
||||
task.timer.cancel();
|
||||
task.released.complete();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool _completeRemoteReadTask(String path, FileDirectory directory) {
|
||||
final task = _remoteReadTasks[path];
|
||||
if (task == null || !_removeRemoteReadTask(path, task)) return false;
|
||||
task.completer.complete(directory);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<List<FileDirectory>> registerReadEmptyDirsTask(
|
||||
bool isLocal, String path) {
|
||||
@@ -1266,23 +1476,6 @@ class FileFetcher {
|
||||
return c.future;
|
||||
}
|
||||
|
||||
Future<FileDirectory> registerReadTask(bool isLocal, String path) {
|
||||
// final jobs = isLocal?localJobs:remoteJobs; // maybe we will use read local dir async later
|
||||
final tasks = remoteTasks; // bypass now
|
||||
if (tasks.containsKey(path)) {
|
||||
throw "Failed to registerReadTask, already have same read job";
|
||||
}
|
||||
final c = Completer<FileDirectory>();
|
||||
tasks[path] = c;
|
||||
|
||||
Timer(Duration(seconds: 2), () {
|
||||
tasks.remove(path);
|
||||
if (c.isCompleted) return;
|
||||
c.completeError("Failed to read dir, timeout");
|
||||
});
|
||||
return c.future;
|
||||
}
|
||||
|
||||
Future<FileDirectory> registerReadRecursiveTask(int actID) {
|
||||
final tasks = readRecursiveTasks;
|
||||
if (tasks.containsKey(actID)) {
|
||||
@@ -1320,27 +1513,37 @@ class FileFetcher {
|
||||
|
||||
tryCompleteTask(String? msg, String? isLocalStr) {
|
||||
if (msg == null || isLocalStr == null) return;
|
||||
late final Map<Object, Completer<FileDirectory>> tasks;
|
||||
try {
|
||||
final fd = FileDirectory.fromJson(jsonDecode(msg));
|
||||
if (fd.id > 0) {
|
||||
// fd.id > 0 is result for read recursive
|
||||
// to-do later,will be better if every fetch use ID,so that there will only one task map for read and recursive read
|
||||
tasks = readRecursiveTasks;
|
||||
final completer = tasks.remove(fd.id);
|
||||
completer?.complete(fd);
|
||||
} else if (fd.path.isNotEmpty) {
|
||||
// result for normal read dir
|
||||
// final jobs = isLocal?localJobs:remoteJobs; // maybe we will use read local dir async later
|
||||
tasks = remoteTasks; // bypass now
|
||||
final completer = tasks.remove(fd.path);
|
||||
final completer = readRecursiveTasks.remove(fd.id);
|
||||
completer?.complete(fd);
|
||||
return;
|
||||
}
|
||||
if (isLocalStr == "false" && fd.path.isNotEmpty) {
|
||||
if (_completeRemoteReadTask(fd.path, fd)) {
|
||||
return;
|
||||
}
|
||||
// A Home request uses an empty path but returns its resolved path.
|
||||
if (isLikelyRemoteHomeResponse(fd.path)) {
|
||||
_completeRemoteReadTask("", fd);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("tryCompleteJob err: $e");
|
||||
}
|
||||
}
|
||||
|
||||
bool tryCompleteRemoteTaskWithError(String error) {
|
||||
if (_remoteReadTasks.length != 1) return false;
|
||||
final entry = _remoteReadTasks.entries.single;
|
||||
final task = entry.value;
|
||||
if (!_removeRemoteReadTask(entry.key, task)) return false;
|
||||
task.completer.completeError(error);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Complete a pending recursive read task with an error.
|
||||
// See FileModel.handleJobError() for why this is necessary.
|
||||
void tryCompleteRecursiveTaskWithError(int id, String error) {
|
||||
@@ -1381,9 +1584,26 @@ class FileFetcher {
|
||||
final fd = FileDirectory.fromJson(jsonDecode(res));
|
||||
return fd;
|
||||
} else {
|
||||
await bind.sessionReadRemoteDir(
|
||||
sessionId: sessionId, path: path, includeHidden: showHidden);
|
||||
return registerReadTask(isLocal, path);
|
||||
final remoteSessionGeneration = _remoteSessionGeneration;
|
||||
final pendingTask = _remoteReadTasks[path];
|
||||
if (pendingTask != null) {
|
||||
if (pendingTask.includeHidden == showHidden) {
|
||||
return pendingTask.completer.future;
|
||||
}
|
||||
await pendingTask.released.future;
|
||||
if (remoteSessionGeneration != _remoteSessionGeneration) {
|
||||
throw StateError(_kRemoteSessionChangedError);
|
||||
}
|
||||
return fetchDirectory(path, isLocal, showHidden);
|
||||
}
|
||||
final task = _registerRemoteReadTask(path, showHidden);
|
||||
unawaited(Future<void>.sync(
|
||||
() => _readRemoteDirectory(sessionId, path, showHidden))
|
||||
.catchError((Object error, StackTrace stackTrace) {
|
||||
if (!_removeRemoteReadTask(path, task)) return;
|
||||
task.completer.completeError(error, stackTrace);
|
||||
}));
|
||||
return task.completer.future;
|
||||
}
|
||||
} catch (e) {
|
||||
return Future.error(e);
|
||||
@@ -1535,6 +1755,9 @@ class JobProgress {
|
||||
|
||||
String display() {
|
||||
if (type == JobType.transfer) {
|
||||
if (state == JobState.done && err == "cancel") {
|
||||
return translate("Cancel");
|
||||
}
|
||||
if (state == JobState.done && err == "skipped") {
|
||||
return translate("Skipped");
|
||||
}
|
||||
@@ -1844,21 +2067,44 @@ class _FileDialogEvent extends BaseEvent<FileDialogType, Map<String, dynamic>> {
|
||||
|
||||
class FileDialogEventLoop
|
||||
extends BaseEventLoop<FileDialogType, Map<String, dynamic>> {
|
||||
int? _batchId;
|
||||
bool? _overrideConfirm;
|
||||
bool _skip = false;
|
||||
|
||||
@override
|
||||
Future<void> onPreConsume(
|
||||
BaseEvent<FileDialogType, Map<String, dynamic>> evt) async {
|
||||
var event = evt as _FileDialogEvent;
|
||||
final event = evt as _FileDialogEvent;
|
||||
final model = event.fileModel.target;
|
||||
final jobId = int.tryParse(evt.data['id']?.toString() ?? '');
|
||||
final batchId = model == null || jobId == null
|
||||
? null
|
||||
: model.jobController.transferConflictBatchId(jobId);
|
||||
final keepRemembered = model != null &&
|
||||
model.jobController.isTransferConflictRememberBatch(batchId);
|
||||
// The loop only preloads the remembered batch choice. The model updates it
|
||||
// after the user answers the current overwrite dialog.
|
||||
if (_batchId != batchId && !keepRemembered) {
|
||||
_batchId = batchId;
|
||||
_overrideConfirm = null;
|
||||
_skip = false;
|
||||
} else {
|
||||
_batchId = batchId;
|
||||
}
|
||||
if (keepRemembered) {
|
||||
_overrideConfirm =
|
||||
model.jobController.transferConflictRememberOverrideConfirm(batchId);
|
||||
_skip = _overrideConfirm == null;
|
||||
}
|
||||
event.setOverrideConfirm(_overrideConfirm);
|
||||
event.setSkip(_skip);
|
||||
debugPrint(
|
||||
"FileDialogEventLoop: consuming<jobId: ${evt.data['id']} overrideConfirm: $_overrideConfirm, skip: $_skip>");
|
||||
"FileDialogEventLoop: consuming<jobId: ${evt.data['id']} batchId: $_batchId overrideConfirm: $_overrideConfirm, skip: $_skip>");
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onEventsClear() {
|
||||
_batchId = null;
|
||||
_overrideConfirm = null;
|
||||
_skip = false;
|
||||
return super.onEventsClear();
|
||||
|
||||
@@ -1307,7 +1307,8 @@ class InputModel {
|
||||
}
|
||||
if (isPhysicalMouse.value) {
|
||||
if (!_relativeMouse.handleRelativeMouseMove(e.localPosition)) {
|
||||
handleMouse(_getMouseEvent(e, _kMouseEventMove), e.position,
|
||||
final canvasPosition = _pointerPositionForRemoteCanvas(e);
|
||||
handleMouse(_getMouseEvent(e, _kMouseEventMove), canvasPosition,
|
||||
edgeScroll: useEdgeScroll);
|
||||
}
|
||||
}
|
||||
@@ -1548,7 +1549,8 @@ class InputModel {
|
||||
_relativeMouse
|
||||
.sendRelativeMouseButton(_getMouseEvent(e, _kMouseEventDown));
|
||||
} else {
|
||||
handleMouse(_getMouseEvent(e, _kMouseEventDown), e.position);
|
||||
final canvasPosition = _pointerPositionForRemoteCanvas(e);
|
||||
handleMouse(_getMouseEvent(e, _kMouseEventDown), canvasPosition);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1570,7 +1572,8 @@ class InputModel {
|
||||
_relativeMouse
|
||||
.sendRelativeMouseButton(_getMouseEvent(e, _kMouseEventUp));
|
||||
} else {
|
||||
handleMouse(_getMouseEvent(e, _kMouseEventUp), e.position);
|
||||
final canvasPosition = _pointerPositionForRemoteCanvas(e);
|
||||
handleMouse(_getMouseEvent(e, _kMouseEventUp), canvasPosition);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1592,12 +1595,40 @@ class InputModel {
|
||||
}
|
||||
if (isPhysicalMouse.value) {
|
||||
if (!_relativeMouse.handleRelativeMouseMove(e.localPosition)) {
|
||||
handleMouse(_getMouseEvent(e, _kMouseEventMove), e.position,
|
||||
final canvasPosition = _pointerPositionForRemoteCanvas(e);
|
||||
handleMouse(_getMouseEvent(e, _kMouseEventMove), canvasPosition,
|
||||
edgeScroll: useEdgeScroll);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert pointer coordinates into the visible remote canvas space.
|
||||
///
|
||||
/// On mobile, the remote page body is wrapped in `SafeArea`, but the pointer
|
||||
/// listener that feeds these events sits outside that subtree. As a result,
|
||||
/// `event.localPosition` still includes the top/left safe-area inset.
|
||||
///
|
||||
/// When the keyboard-visible path shows `KeyHelpTools`, the remote canvas is
|
||||
/// also shifted downward by `CanvasModel.getAdjustY()`. The downstream mouse
|
||||
/// mapping logic expects coordinates relative to the visible canvas area, so
|
||||
/// we subtract both the mobile safe-area padding and the current canvas
|
||||
/// adjustment before passing the position into mouse mapping.
|
||||
///
|
||||
/// Desktop and web desktop continue to use the global position directly
|
||||
/// because their pointer mapping is window-based.
|
||||
Offset _pointerPositionForRemoteCanvas(PointerEvent event) {
|
||||
if (isDesktop || isWebDesktop) {
|
||||
return event.position;
|
||||
}
|
||||
final mediaData = MediaQueryData.fromView(
|
||||
WidgetsBinding.instance.platformDispatcher.views.first);
|
||||
final adjustY = parent.target?.canvasModel.getAdjustY() ?? 0.0;
|
||||
return Offset(
|
||||
event.localPosition.dx - mediaData.padding.left,
|
||||
event.localPosition.dy - mediaData.padding.top - adjustY,
|
||||
);
|
||||
}
|
||||
|
||||
static Future<Rect?> fillRemoteCoordsAndGetCurFrame(
|
||||
List<RemoteWindowCoords> remoteWindowCoords) async {
|
||||
final coords =
|
||||
@@ -1756,6 +1787,11 @@ class InputModel {
|
||||
}
|
||||
|
||||
bool _checkPeerControlProtected(double x, double y) {
|
||||
if (isViewOnly && showMyCursor) {
|
||||
lastMousePos = ui.Offset(x, y);
|
||||
return false;
|
||||
}
|
||||
|
||||
final cursorModel = parent.target!.cursorModel;
|
||||
if (cursorModel.isPeerControlProtected) {
|
||||
lastMousePos = ui.Offset(x, y);
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Identifies where terminal input originated so paste data can bypass all
|
||||
/// keyboard-only transformations.
|
||||
enum TerminalInputSource {
|
||||
keyboard,
|
||||
paste,
|
||||
}
|
||||
|
||||
/// Returns true when a stale mobile one-shot Shift state should be released
|
||||
/// by replaying a tracked Shift key-down as a synthesized key-up.
|
||||
@@ -36,3 +44,147 @@ bool shouldReleaseStaleMobileShift({
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Applies the terminal Ctrl/Alt one-shot modifiers to a single input payload.
|
||||
///
|
||||
String applyTerminalInputModifiers(
|
||||
String data, {
|
||||
required bool ctrlLocked,
|
||||
required bool altLocked,
|
||||
}) {
|
||||
var result = data;
|
||||
if (ctrlLocked) {
|
||||
result = _applyTerminalCtrlModifier(result);
|
||||
}
|
||||
if (altLocked) {
|
||||
result = '\x1B$result';
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Builds the exact payload xterm sends for paste, without applying modifiers.
|
||||
String terminalPastePayload(String text, {required bool bracketedPasteMode}) {
|
||||
if (!bracketedPasteMode) {
|
||||
return text;
|
||||
}
|
||||
return '\x1B[200~$text\x1B[201~';
|
||||
}
|
||||
|
||||
/// Returns whether one-shot Ctrl/Alt may transform and consume this input.
|
||||
///
|
||||
/// xterm emits terminal control keys as either one control byte or a longer
|
||||
/// escape sequence. Neither form is ordinary text input, so a pending modifier
|
||||
/// must survive until the user enters a printable character.
|
||||
bool shouldApplyTerminalInputModifiers(String data) {
|
||||
if (data.characters.length != 1) return false;
|
||||
final codeUnit = data.codeUnitAt(0);
|
||||
return codeUnit >= 0x20 && codeUnit != 0x7F;
|
||||
}
|
||||
|
||||
/// Builds the payload sent to the remote terminal for keyboard and paste input.
|
||||
///
|
||||
/// Keyboard input keeps the mobile Enter workaround and one-shot Ctrl/Alt
|
||||
/// mapping. Paste input deliberately bypasses both transformations so even a
|
||||
/// one-character clipboard payload is preserved exactly.
|
||||
String prepareTerminalInputPayload(
|
||||
String data, {
|
||||
required TerminalInputSource source,
|
||||
required bool isMobileOrWebMobile,
|
||||
required bool bracketedPasteMode,
|
||||
required bool ctrlLocked,
|
||||
required bool altLocked,
|
||||
}) {
|
||||
if (source == TerminalInputSource.paste) {
|
||||
return terminalPastePayload(
|
||||
data,
|
||||
bracketedPasteMode: bracketedPasteMode,
|
||||
);
|
||||
}
|
||||
|
||||
var result = data;
|
||||
if (isMobileOrWebMobile && result == '\n') {
|
||||
result = '\r';
|
||||
}
|
||||
if ((ctrlLocked || altLocked) && shouldApplyTerminalInputModifiers(result)) {
|
||||
result = applyTerminalInputModifiers(
|
||||
result,
|
||||
ctrlLocked: ctrlLocked,
|
||||
altLocked: altLocked,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Returns true when a hardware paste shortcut must bypass keyboard modifiers.
|
||||
///
|
||||
/// xterm already handles hardware Ctrl/Cmd+V correctly in the common case. Only
|
||||
/// intercept while a virtual Ctrl/Alt lock is active, because xterm can emit a
|
||||
/// one-character paste as normal text when bracketed paste mode is disabled.
|
||||
bool shouldHandleTerminalPasteShortcut({
|
||||
required LogicalKeyboardKey logicalKey,
|
||||
required bool isKeyDown,
|
||||
required bool isKeyRepeat,
|
||||
required bool controlPressed,
|
||||
required bool metaPressed,
|
||||
required bool altPressed,
|
||||
required bool shiftPressed,
|
||||
required bool modifierLockActive,
|
||||
}) {
|
||||
if (!modifierLockActive) return false;
|
||||
if (!isKeyDown && !isKeyRepeat) return false;
|
||||
if (logicalKey != LogicalKeyboardKey.keyV) return false;
|
||||
if (altPressed || shiftPressed) return false;
|
||||
return controlPressed != metaPressed;
|
||||
}
|
||||
|
||||
/// Returns true when collapsing Row3 should also clear hidden modifier state.
|
||||
bool shouldClearTerminalModifiersWhenRow3Collapses({
|
||||
required bool wasExpanded,
|
||||
required bool willExpand,
|
||||
required bool ctrlLocked,
|
||||
required bool altLocked,
|
||||
}) {
|
||||
return wasExpanded && !willExpand && (ctrlLocked || altLocked);
|
||||
}
|
||||
|
||||
String _applyTerminalCtrlModifier(String data) {
|
||||
// Ctrl mappings are defined only for ASCII scalars. A visible character can
|
||||
// be multiple scalars (for example, a decomposed accent), so leave those
|
||||
// graphemes untouched instead of rewriting only their ASCII base letter.
|
||||
final graphemes = data.characters.toList(growable: false);
|
||||
if (graphemes.length != 1) {
|
||||
return data;
|
||||
}
|
||||
|
||||
final runes = graphemes.single.runes.toList(growable: false);
|
||||
if (runes.length != 1) {
|
||||
return data;
|
||||
}
|
||||
|
||||
final code = runes.single;
|
||||
if (code >= 0x61 && code <= 0x7A) {
|
||||
return String.fromCharCode(code - 0x60);
|
||||
}
|
||||
if (code >= 0x41 && code <= 0x5A) {
|
||||
return String.fromCharCode(code - 0x40);
|
||||
}
|
||||
if (code == 0x20) {
|
||||
return String.fromCharCode(0);
|
||||
}
|
||||
if (code == 0x5B) {
|
||||
return String.fromCharCode(27);
|
||||
}
|
||||
if (code == 0x5C) {
|
||||
return String.fromCharCode(28);
|
||||
}
|
||||
if (code == 0x5D) {
|
||||
return String.fromCharCode(29);
|
||||
}
|
||||
if (code == 0x5E) {
|
||||
return String.fromCharCode(30);
|
||||
}
|
||||
if (code == 0x5F || code == 0x2F) {
|
||||
return String.fromCharCode(31);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -25,9 +25,6 @@ import 'package:flutter_hbb/models/user_model.dart';
|
||||
import 'package:flutter_hbb/models/state_model.dart';
|
||||
import 'package:flutter_hbb/models/desktop_render_texture.dart';
|
||||
import 'package:flutter_hbb/models/terminal_model.dart';
|
||||
import 'package:flutter_hbb/plugin/event.dart';
|
||||
import 'package:flutter_hbb/plugin/manager.dart';
|
||||
import 'package:flutter_hbb/plugin/widgets/desc_ui.dart';
|
||||
import 'package:flutter_hbb/common/shared_state.dart';
|
||||
import 'package:flutter_hbb/utils/multi_window_manager.dart';
|
||||
import 'package:flutter_hbb/utils/http_service.dart' as http;
|
||||
@@ -55,6 +52,8 @@ import 'package:flutter_hbb/native/custom_cursor.dart'
|
||||
typedef HandleMsgBox = Function(Map<String, dynamic> evt, String id);
|
||||
typedef ReconnectHandle = Function(OverlayDialogManager, SessionID, bool);
|
||||
final _constSessionId = Uuid().v4obj();
|
||||
// Empirical restart reconnect cadence: keep the last frame briefly and retry quickly.
|
||||
const _restartReconnectSilentDelaySecs = 5;
|
||||
|
||||
class CachedPeerData {
|
||||
Map<String, dynamic> updatePrivacyMode = {};
|
||||
@@ -110,6 +109,9 @@ class CachedPeerData {
|
||||
class FfiModel with ChangeNotifier {
|
||||
CachedPeerData cachedPeerData = CachedPeerData();
|
||||
PeerInfo _pi = PeerInfo();
|
||||
int? lastUserDisplay;
|
||||
int? pendingMonitorRestore;
|
||||
Timer? _pendingRestoreTimer;
|
||||
Rect? _rect;
|
||||
|
||||
var _inputBlocked = false;
|
||||
@@ -119,6 +121,7 @@ class FfiModel with ChangeNotifier {
|
||||
bool _touchMode = false;
|
||||
late VirtualMouseMode virtualMouseMode;
|
||||
Timer? _timer;
|
||||
Timer? _restartReconnectDelayTimer;
|
||||
var _reconnects = 1;
|
||||
DateTime? _offlineReconnectStartTime;
|
||||
bool _viewOnly = false;
|
||||
@@ -245,11 +248,14 @@ class FfiModel with ChangeNotifier {
|
||||
|
||||
clear() {
|
||||
_pi = PeerInfo();
|
||||
lastUserDisplay = null;
|
||||
_cancelPendingMonitorRestore();
|
||||
_secure = null;
|
||||
_direct = null;
|
||||
_inputBlocked = false;
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
resetRestartReconnectState();
|
||||
clearPermissions();
|
||||
waitForImageTimer?.cancel();
|
||||
timerScreenshot?.cancel();
|
||||
@@ -341,6 +347,7 @@ class FfiModel with ChangeNotifier {
|
||||
} else if (name == 'connection_ready') {
|
||||
setConnectionType(peerId, evt['secure'] == 'true',
|
||||
evt['direct'] == 'true', evt['stream_type'] ?? '');
|
||||
resetRestartReconnectState();
|
||||
} else if (name == 'switch_display') {
|
||||
// switch display is kept for backward compatibility
|
||||
handleSwitchDisplay(evt, sessionId, peerId);
|
||||
@@ -427,15 +434,6 @@ class FfiModel with ChangeNotifier {
|
||||
parent.target?.serverModel.updateVoiceCallState(evt);
|
||||
} else if (name == 'fingerprint') {
|
||||
FingerprintState.find(peerId).value = evt['fingerprint'] ?? '';
|
||||
} else if (name == 'plugin_manager') {
|
||||
pluginManager.handleEvent(evt);
|
||||
} else if (name == 'plugin_event') {
|
||||
handlePluginEvent(evt,
|
||||
(Map<String, dynamic> e) => handleMsgBox(e, sessionId, peerId));
|
||||
} else if (name == 'plugin_reload') {
|
||||
handleReloading(evt);
|
||||
} else if (name == 'plugin_option') {
|
||||
handleOption(evt);
|
||||
} else if (name == "sync_peer_hash_password_to_personal_ab") {
|
||||
if (desktopType == DesktopType.main || isWeb || isMobile) {
|
||||
final id = evt['id'];
|
||||
@@ -910,20 +908,36 @@ class FfiModel with ChangeNotifier {
|
||||
enter2FaDialog(sessionId, dialogManager);
|
||||
} else if (type == 'input-password') {
|
||||
enterPasswordDialog(sessionId, dialogManager);
|
||||
} else if (type == 'session-login' || type == 'session-re-login') {
|
||||
enterUserLoginDialog(sessionId, dialogManager, 'login_linux_tip', true);
|
||||
} else if (type == 'session-login-password') {
|
||||
enterUserLoginAndPasswordDialog(
|
||||
sessionId, dialogManager, 'login_linux_tip', true);
|
||||
} else if (type == 'terminal-admin-login') {
|
||||
enterUserLoginDialog(
|
||||
sessionId, dialogManager, 'terminal-admin-login-tip', false);
|
||||
sessionId, dialogManager, 'terminal-admin-login-tip');
|
||||
} else if (type == 'terminal-admin-login-password') {
|
||||
enterUserLoginAndPasswordDialog(
|
||||
sessionId, dialogManager, 'terminal-admin-login-tip', false);
|
||||
sessionId, dialogManager, 'terminal-admin-login-tip');
|
||||
} else if (type == 'restarting') {
|
||||
showMsgBox(sessionId, type, title, text, link, false, dialogManager,
|
||||
hasCancel: false);
|
||||
// Treat restart messages as reconnect control events. Rust still sends
|
||||
// title/text for legacy UI and translation reuse; Flutter keeps the last
|
||||
// frame briefly, then shows the Connecting overlay.
|
||||
if (_restartReconnectDelayTimer == null) {
|
||||
parent.target?.inputModel.setRelativeMouseMode(false);
|
||||
_cancelPendingMonitorRestore();
|
||||
bind.sessionReconnect(sessionId: sessionId, forceRelay: false);
|
||||
clearPermissions();
|
||||
// Retry once more after the silent window so restart reconnect attempts
|
||||
// are spaced by the empirical short cadence instead of only updating UI.
|
||||
_restartReconnectDelayTimer =
|
||||
Timer(Duration(seconds: _restartReconnectSilentDelaySecs), () {
|
||||
_restartReconnectDelayTimer = null;
|
||||
if (parent.target?.closed == true) {
|
||||
return;
|
||||
}
|
||||
reconnect(dialogManager, sessionId, false);
|
||||
});
|
||||
}
|
||||
} else if (type == 'restarting-show') {
|
||||
_restartReconnectDelayTimer?.cancel();
|
||||
_restartReconnectDelayTimer = null;
|
||||
reconnect(dialogManager, sessionId, false);
|
||||
} else if (type == 'wait-remote-accept-nook') {
|
||||
showWaitAcceptDialog(sessionId, type, title, text, dialogManager);
|
||||
} else if (type == 'on-uac' || type == 'on-foreground-elevated') {
|
||||
@@ -949,6 +963,11 @@ class FfiModel with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
void resetRestartReconnectState() {
|
||||
_restartReconnectDelayTimer?.cancel();
|
||||
_restartReconnectDelayTimer = null;
|
||||
}
|
||||
|
||||
/// Auto-retry check for "Remote desktop is offline" error.
|
||||
/// returns true to auto-retry, false otherwise.
|
||||
bool shouldAutoRetryOnOffline(
|
||||
@@ -1054,10 +1073,22 @@ class FfiModel with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
void _cancelPendingMonitorRestore() {
|
||||
_pendingRestoreTimer?.cancel();
|
||||
_pendingRestoreTimer = null;
|
||||
pendingMonitorRestore = null;
|
||||
}
|
||||
|
||||
void cancelPendingRestoreTimer() {
|
||||
_pendingRestoreTimer?.cancel();
|
||||
_pendingRestoreTimer = null;
|
||||
}
|
||||
|
||||
void reconnect(OverlayDialogManager dialogManager, SessionID sessionId,
|
||||
bool forceRelay) {
|
||||
// Disable relative mouse mode before reconnecting to ensure cursor is released.
|
||||
parent.target?.inputModel.setRelativeMouseMode(false);
|
||||
_cancelPendingMonitorRestore();
|
||||
bind.sessionReconnect(sessionId: sessionId, forceRelay: forceRelay);
|
||||
clearPermissions();
|
||||
dialogManager.dismissAll();
|
||||
@@ -1371,9 +1402,29 @@ class FfiModel with ChangeNotifier {
|
||||
// now replaced to _updateCurDisplay
|
||||
updateCurDisplay(sessionId);
|
||||
}
|
||||
// After reconnecting, restore the last selected monitor once the canvas is ready.
|
||||
// Switching earlier can offset the view if the monitor sizes differ.
|
||||
final last = lastUserDisplay;
|
||||
pendingMonitorRestore = (!isCache &&
|
||||
last != null &&
|
||||
last != currentDisplay &&
|
||||
bind.sessionGetUseAllMyDisplaysForTheRemoteSession(
|
||||
sessionId: sessionId) !=
|
||||
'Y' &&
|
||||
((last == kAllDisplayValue && _pi.displays.isNotEmpty) ||
|
||||
(last >= 0 && last < _pi.displays.length)))
|
||||
? last
|
||||
: null;
|
||||
// Fallback if the first image event never reaches this tab (multi-UI).
|
||||
_pendingRestoreTimer?.cancel();
|
||||
if (pendingMonitorRestore != null) {
|
||||
_pendingRestoreTimer = Timer(const Duration(milliseconds: 1500),
|
||||
() => parent.target?._applyPendingMonitorRestore());
|
||||
}
|
||||
if (displays.isNotEmpty) {
|
||||
_reconnects = 1;
|
||||
_offlineReconnectStartTime = null;
|
||||
resetRestartReconnectState();
|
||||
waitForFirstImage.value = true;
|
||||
isRefreshing = false;
|
||||
}
|
||||
@@ -1884,6 +1935,12 @@ class ImageModel with ChangeNotifier {
|
||||
platformFFI.nextRgba(sessionId, display);
|
||||
}
|
||||
|
||||
// web only: image already created from a decoded WebCodecs frame
|
||||
Future<void> onImage(
|
||||
int display, ui.Image image, bool Function() isCurrentSession) async {
|
||||
await update(image, isCurrentSession: isCurrentSession);
|
||||
}
|
||||
|
||||
decodeAndUpdate(int display, Uint8List rgba) async {
|
||||
final pid = parent.target?.id;
|
||||
final rect = parent.target?.ffiModel.pi.getDisplayRect(display);
|
||||
@@ -1895,11 +1952,16 @@ class ImageModel with ChangeNotifier {
|
||||
? ui.PixelFormat.rgba8888
|
||||
: ui.PixelFormat.bgra8888,
|
||||
);
|
||||
if (parent.target?.id != pid) return;
|
||||
if (parent.target?.id != pid) {
|
||||
image?.dispose();
|
||||
return;
|
||||
}
|
||||
await update(image);
|
||||
}
|
||||
|
||||
update(ui.Image? image) async {
|
||||
Future<void> update(ui.Image? image,
|
||||
{bool Function()? isCurrentSession}) async {
|
||||
if (_disposeIfStale(image, isCurrentSession)) return;
|
||||
if (_image == null && image != null) {
|
||||
if (isDesktop || isWebDesktop) {
|
||||
await parent.target?.canvasModel.updateViewStyle();
|
||||
@@ -1910,11 +1972,19 @@ class ImageModel with ChangeNotifier {
|
||||
await initializeCursorAndCanvas(parent.target!);
|
||||
}
|
||||
}
|
||||
if (_disposeIfStale(image, isCurrentSession)) return;
|
||||
_image?.dispose();
|
||||
_image = image;
|
||||
if (image != null) notifyListeners();
|
||||
}
|
||||
|
||||
bool _disposeIfStale(ui.Image? image, bool Function()? isCurrentSession) {
|
||||
if (image == null || isCurrentSession == null) return false;
|
||||
if (isCurrentSession()) return false;
|
||||
image.dispose();
|
||||
return true;
|
||||
}
|
||||
|
||||
// mobile only
|
||||
double get maxScale {
|
||||
if (_image == null) return 1.5;
|
||||
@@ -2138,6 +2208,7 @@ class CanvasModel with ChangeNotifier {
|
||||
double _y = 0;
|
||||
// image scale
|
||||
double _scale = 1.0;
|
||||
bool _locked = false;
|
||||
double _devicePixelRatio = 1.0;
|
||||
Size _size = Size.zero;
|
||||
// the tabbar over the image
|
||||
@@ -2186,12 +2257,19 @@ class CanvasModel with ChangeNotifier {
|
||||
double get x => _x;
|
||||
double get y => _y;
|
||||
double get scale => _scale;
|
||||
bool get locked => _locked;
|
||||
double get devicePixelRatio => _devicePixelRatio;
|
||||
Size get size => _size;
|
||||
ScrollStyle get scrollStyle => _scrollStyle;
|
||||
ViewStyle get viewStyle => _lastViewStyle;
|
||||
RxBool get imageOverflow => _imageOverflow;
|
||||
|
||||
void setLocked(bool value) {
|
||||
if (_locked == value) return;
|
||||
_locked = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
_resetScroll() => setScrollPercent(0.0, 0.0);
|
||||
|
||||
void setScrollPercent(double x, double y) {
|
||||
@@ -2420,6 +2498,7 @@ class CanvasModel with ChangeNotifier {
|
||||
}
|
||||
|
||||
void updateLocalCursor(double x, double y) {
|
||||
if (parent.target?.ffiModel.viewOnly == true) return;
|
||||
// If keyboard is not permitted, do not move cursor when mouse is moving.
|
||||
if (parent.target != null && parent.target!.ffiModel.keyboard) {
|
||||
// Draw cursor if is not desktop.
|
||||
@@ -2652,6 +2731,7 @@ class CanvasModel with ChangeNotifier {
|
||||
_x = 0;
|
||||
_y = 0;
|
||||
_scale = 1.0;
|
||||
_locked = false;
|
||||
_lastViewStyle = ViewStyle.defaultViewStyle();
|
||||
_timerMobileFocusCanvasCursor?.cancel();
|
||||
_timerMobileRestoreCanvasOffset?.cancel();
|
||||
@@ -2763,7 +2843,7 @@ class CursorData {
|
||||
required this.width,
|
||||
required this.height,
|
||||
}) : hotx = hotxOrigin * scale,
|
||||
hoty = hotxOrigin * scale;
|
||||
hoty = hotyOrigin * scale;
|
||||
|
||||
int _doubleToInt(double v) => (v * 10e6).round().toInt();
|
||||
|
||||
@@ -3666,6 +3746,7 @@ class FFI {
|
||||
|
||||
/// Mobile reuse FFI
|
||||
void mobileReset() {
|
||||
ffiModel.resetRestartReconnectState();
|
||||
ffiModel.waitForFirstImage.value = true;
|
||||
ffiModel.isRefreshing = false;
|
||||
ffiModel.waitForImageDialogShow.value = true;
|
||||
@@ -3784,6 +3865,15 @@ class FFI {
|
||||
onEvent2UIRgba();
|
||||
imageModel.onRgba(display, data);
|
||||
});
|
||||
platformFFI.setVideoFrameCallback((int display, ui.Image image,
|
||||
bool Function() isCurrentSession) async {
|
||||
if (!isCurrentSession()) {
|
||||
image.dispose();
|
||||
return;
|
||||
}
|
||||
await onEvent2UIRgba();
|
||||
await imageModel.onImage(display, image, isCurrentSession);
|
||||
});
|
||||
this.id = id;
|
||||
return;
|
||||
}
|
||||
@@ -3871,7 +3961,7 @@ class FFI {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
void onEvent2UIRgba() async {
|
||||
Future<void> onEvent2UIRgba() async {
|
||||
if (ffiModel.waitForImageDialogShow.isTrue) {
|
||||
ffiModel.waitForImageDialogShow.value = false;
|
||||
ffiModel.waitForImageTimer?.cancel();
|
||||
@@ -3879,16 +3969,35 @@ class FFI {
|
||||
}
|
||||
if (ffiModel.waitForFirstImage.value == true) {
|
||||
ffiModel.waitForFirstImage.value = false;
|
||||
ffiModel.cancelPendingRestoreTimer();
|
||||
ffiModel.resetRestartReconnectState();
|
||||
dialogManager.dismissAll();
|
||||
await canvasModel.updateViewStyle();
|
||||
await canvasModel.updateScrollStyle();
|
||||
await canvasModel.initializeEdgeScrollEdgeThickness();
|
||||
for (final cb in imageModel.callbacksOnFirstImage) {
|
||||
cb(id);
|
||||
try {
|
||||
await canvasModel.updateViewStyle();
|
||||
await canvasModel.updateScrollStyle();
|
||||
await canvasModel.initializeEdgeScrollEdgeThickness();
|
||||
for (final cb in imageModel.callbacksOnFirstImage) {
|
||||
cb(id);
|
||||
}
|
||||
} finally {
|
||||
_applyPendingMonitorRestore();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _applyPendingMonitorRestore() {
|
||||
final restore = ffiModel.pendingMonitorRestore;
|
||||
ffiModel._cancelPendingMonitorRestore();
|
||||
if (restore == null || closed) return;
|
||||
// The display list may have changed since the restore was queued.
|
||||
final displays = ffiModel.pi.displays;
|
||||
if ((restore == kAllDisplayValue && displays.isNotEmpty) ||
|
||||
(restore >= 0 && restore < displays.length)) {
|
||||
openMonitorInTheSameTab(restore, this, ffiModel.pi,
|
||||
recordSelection: false, updateCursorPos: false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Login with [password], choose if the client should [remember] it.
|
||||
void login(String osUsername, String osPassword, SessionID sessionId,
|
||||
String password, bool remember) {
|
||||
@@ -3908,6 +4017,9 @@ class FFI {
|
||||
/// Close the remote session.
|
||||
Future<void> close({bool closeSession = true}) async {
|
||||
closed = true;
|
||||
if (isWeb) {
|
||||
platformFFI.clearVideoFrameCallback();
|
||||
}
|
||||
chatModel.close();
|
||||
// Close all terminal models
|
||||
for (final model in _terminalModels.values) {
|
||||
@@ -4052,7 +4164,6 @@ class PeerInfo with ChangeNotifier {
|
||||
RxBool isSet = false.obs;
|
||||
|
||||
bool get isWayland => platformAdditions[kPlatformAdditionsIsWayland] == true;
|
||||
bool get isHeadless => platformAdditions[kPlatformAdditionsHeadless] == true;
|
||||
bool get isInstalled =>
|
||||
platform != kPeerPlatformWindows ||
|
||||
platformAdditions[kPlatformAdditionsIsInstalled] == true;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:ffi';
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:external_path/external_path.dart';
|
||||
@@ -25,6 +26,23 @@ typedef F3 = Pointer<Uint8> Function(Pointer<Utf8>, int);
|
||||
typedef F3Dart = Pointer<Uint8> Function(Pointer<Utf8>, Int32);
|
||||
typedef HandleEvent = Future<void> Function(Map<String, dynamic> evt);
|
||||
|
||||
/// The Linux bundle keeps the core library at lib/librustdesk.so next to the
|
||||
/// executable. Prefer that copy, mirroring flutter/linux/main.cc: the plain
|
||||
/// name relies on the loader search path, which repackaged installs may not
|
||||
/// cover. https://github.com/rustdesk/rustdesk/discussions/14407
|
||||
DynamicLibrary _openLinuxCoreLib() {
|
||||
final bundled =
|
||||
'${File(Platform.resolvedExecutable).parent.path}/lib/librustdesk.so';
|
||||
try {
|
||||
if (File(bundled).existsSync()) {
|
||||
return DynamicLibrary.open(bundled);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Failed to load '$bundled': $e");
|
||||
}
|
||||
return DynamicLibrary.open('librustdesk.so');
|
||||
}
|
||||
|
||||
/// FFI wrapper around the native Rust core.
|
||||
/// Hides the platform differences.
|
||||
class PlatformFFI {
|
||||
@@ -120,7 +138,7 @@ class PlatformFFI {
|
||||
final dylib = isAndroid
|
||||
? DynamicLibrary.open('librustdesk.so')
|
||||
: isLinux
|
||||
? DynamicLibrary.open('librustdesk.so')
|
||||
? _openLinuxCoreLib()
|
||||
: isWindows
|
||||
? DynamicLibrary.open('librustdesk.dll')
|
||||
:
|
||||
@@ -266,6 +284,12 @@ class PlatformFFI {
|
||||
|
||||
void setRgbaCallback(void Function(int, Uint8List) fun) async {}
|
||||
|
||||
// web only, decoded WebCodecs frames arriving as ready-made images
|
||||
void setVideoFrameCallback(
|
||||
Future<void> Function(int, ui.Image, bool Function()) fun) {}
|
||||
|
||||
void clearVideoFrameCallback() {}
|
||||
|
||||
void startDesktopWebListener() {}
|
||||
|
||||
void stopDesktopWebListener() {}
|
||||
|
||||
@@ -145,23 +145,26 @@ class Peer {
|
||||
note == other.note;
|
||||
}
|
||||
|
||||
Peer.copy(Peer other)
|
||||
: this(
|
||||
id: other.id,
|
||||
hash: other.hash,
|
||||
password: other.password,
|
||||
username: other.username,
|
||||
hostname: other.hostname,
|
||||
platform: other.platform,
|
||||
alias: other.alias,
|
||||
tags: other.tags.toList(),
|
||||
forceAlwaysRelay: other.forceAlwaysRelay,
|
||||
rdpPort: other.rdpPort,
|
||||
rdpUsername: other.rdpUsername,
|
||||
loginName: other.loginName,
|
||||
device_group_name: other.device_group_name,
|
||||
note: other.note,
|
||||
sameServer: other.sameServer);
|
||||
factory Peer.copy(Peer other) {
|
||||
final peer = Peer(
|
||||
id: other.id,
|
||||
hash: other.hash,
|
||||
password: other.password,
|
||||
username: other.username,
|
||||
hostname: other.hostname,
|
||||
platform: other.platform,
|
||||
alias: other.alias,
|
||||
tags: other.tags.toList(),
|
||||
forceAlwaysRelay: other.forceAlwaysRelay,
|
||||
rdpPort: other.rdpPort,
|
||||
rdpUsername: other.rdpUsername,
|
||||
loginName: other.loginName,
|
||||
device_group_name: other.device_group_name,
|
||||
note: other.note,
|
||||
sameServer: other.sameServer);
|
||||
peer.online = other.online;
|
||||
return peer;
|
||||
}
|
||||
}
|
||||
|
||||
enum UpdateEvent { online, load }
|
||||
|
||||
14
flutter/lib/models/rustdesk_terminal.dart
Normal file
14
flutter/lib/models/rustdesk_terminal.dart
Normal file
@@ -0,0 +1,14 @@
|
||||
import 'package:xterm/xterm.dart';
|
||||
|
||||
class RustDeskTerminal extends Terminal {
|
||||
RustDeskTerminal({super.maxLines});
|
||||
|
||||
@override
|
||||
void eraseScrollbackOnly() {
|
||||
final scrollBack = buffer.scrollBack;
|
||||
if (scrollBack == 0) return;
|
||||
|
||||
// Selection anchors require retained buffer lines to be reindexed.
|
||||
buffer.lines.remove(0, scrollBack);
|
||||
}
|
||||
}
|
||||
@@ -738,9 +738,13 @@ class ServerModel with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> closeAll() async {
|
||||
await Future.wait(
|
||||
_clients.map((client) => bind.cmCloseConnection(connId: client.id)));
|
||||
/// `byOperator` false means the CM's window went away rather than a person asking for the
|
||||
/// peers to go. The sessions end either way; only the close reason differs, and with it
|
||||
/// whether the peer is allowed to reconnect. See `ipc::Data::CmWindowClosed`.
|
||||
Future<void> closeAll({bool byOperator = true}) async {
|
||||
await Future.wait(_clients.map((client) => byOperator
|
||||
? bind.cmCloseConnection(connId: client.id)
|
||||
: bind.cmCloseConnectionWindow(connId: client.id)));
|
||||
_clients.clear();
|
||||
tabController.state.value.tabs.clear();
|
||||
if (isAndroid) androidUpdatekeepScreenOn();
|
||||
|
||||
66
flutter/lib/models/terminal_copy_shortcut.dart
Normal file
66
flutter/lib/models/terminal_copy_shortcut.dart
Normal file
@@ -0,0 +1,66 @@
|
||||
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() {
|
||||
if (defaultTargetPlatform != TargetPlatform.linux) return null;
|
||||
return {
|
||||
for (final entry in defaultTerminalShortcuts.entries)
|
||||
if (!_isControlVShortcut(entry.key)) entry.key: entry.value,
|
||||
_controlShiftVPasteShortcut:
|
||||
const PasteTextIntent(SelectionChangedCause.keyboard),
|
||||
};
|
||||
}
|
||||
|
||||
bool _isControlVShortcut(ShortcutActivator shortcut) =>
|
||||
shortcut is SingleActivator &&
|
||||
shortcut.trigger == LogicalKeyboardKey.keyV &&
|
||||
shortcut.control &&
|
||||
!shortcut.shift &&
|
||||
!shortcut.alt &&
|
||||
!shortcut.meta;
|
||||
|
||||
FocusOnKeyEventCallback terminalCopyHandler(
|
||||
Terminal terminal,
|
||||
TerminalController controller,
|
||||
) =>
|
||||
(_, event) {
|
||||
if (!_isWindowsCopyShortcut(event)) return KeyEventResult.ignored;
|
||||
final selection = controller.selection;
|
||||
if (selection == null || selection.isCollapsed) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
if (event is KeyDownEvent) {
|
||||
final text = terminal.buffer.getText(selection);
|
||||
unawaited(writeTerminalClipboard(text));
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
};
|
||||
|
||||
bool _isWindowsCopyShortcut(KeyEvent event) {
|
||||
final keyboard = HardwareKeyboard.instance;
|
||||
return defaultTargetPlatform == TargetPlatform.windows &&
|
||||
(event is KeyDownEvent || event is KeyRepeatEvent) &&
|
||||
event.logicalKey == LogicalKeyboardKey.keyC &&
|
||||
keyboard.isControlPressed &&
|
||||
!keyboard.isShiftPressed &&
|
||||
!keyboard.isAltPressed &&
|
||||
!keyboard.isMetaPressed;
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:desktop_multi_window/desktop_multi_window.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
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
|
||||
@@ -23,7 +25,25 @@ class TerminalModel with ChangeNotifier {
|
||||
|
||||
bool _disposed = false;
|
||||
|
||||
/// Callback to check whether Ctrl modifier lock is currently active.
|
||||
/// When active, keyboard input is mapped to control codes (e.g. 'b' → \x02).
|
||||
bool Function()? isCtrlLocked;
|
||||
|
||||
/// Callback to clear Ctrl lock after a key is pressed (one-shot mode).
|
||||
void Function()? clearCtrlLock;
|
||||
|
||||
/// Callback to check whether Alt modifier lock is currently active.
|
||||
bool Function()? isAltLocked;
|
||||
|
||||
/// Callback to clear Alt lock after a key is pressed (one-shot mode).
|
||||
void Function()? clearAltLock;
|
||||
|
||||
final _inputBuffer = <String>[];
|
||||
|
||||
/// Exposes buffered input only for lifecycle regression tests.
|
||||
@visibleForTesting
|
||||
int get debugBufferedInputCount => _inputBuffer.length;
|
||||
|
||||
// Buffer for output data received before terminal view has valid dimensions.
|
||||
// This prevents NaN errors when writing to terminal before layout is complete.
|
||||
final _pendingOutputChunks = <String>[];
|
||||
@@ -38,7 +58,15 @@ class TerminalModel with ChangeNotifier {
|
||||
|
||||
void Function(int w, int h, int pw, int ph)? onResizeExternal;
|
||||
|
||||
/// Called when the terminal session ends (shell exits).
|
||||
/// The listener (typically TerminalPage) can use this to auto-close the tab/page.
|
||||
VoidCallback? onClosed;
|
||||
|
||||
Future<void> _handleInput(String data) async {
|
||||
// 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.
|
||||
@@ -46,13 +74,44 @@ class TerminalModel with ChangeNotifier {
|
||||
// (readline, prompt_toolkit, vim, TUI frameworks) expect '\r'.
|
||||
// - Peer macOS: same as Linux, raw-mode apps expect '\r'
|
||||
// (https://github.com/rustdesk/rustdesk/issues/14907).
|
||||
// So on mobile / web-mobile, always normalize a lone '\n' to '\r'.
|
||||
// We deliberately do not touch multi-character payloads (e.g. pasted text)
|
||||
// so embedded newlines in pasted content are preserved.
|
||||
final isMobileOrWebMobile = (isMobile || (isWeb && !isWebDesktop));
|
||||
if (isMobileOrWebMobile && data == '\n') {
|
||||
data = '\r';
|
||||
// So on mobile / web-mobile, normalize the original lone '\n' to '\r'
|
||||
// before modifier mappings. This keeps Ctrl+J mapped to LF instead of
|
||||
// having the generated control code rewritten to CR afterward.
|
||||
// Multi-character keyboard payloads, such as terminal escape sequences,
|
||||
// remain unchanged. Paste input follows a separate preprocessing path.
|
||||
final ctrlLocked = isCtrlLocked?.call() ?? false;
|
||||
final altLocked = isAltLocked?.call() ?? false;
|
||||
final modifiersActive = ctrlLocked || altLocked;
|
||||
// Use the same predicate for transformation and consumption. Control keys
|
||||
// and escape sequences must not silently consume a pending one-shot lock.
|
||||
final shouldConsumeModifiers =
|
||||
modifiersActive && shouldApplyTerminalInputModifiers(data);
|
||||
data = prepareTerminalInputPayload(
|
||||
data,
|
||||
// IME soft-keyboard paste prompts currently arrive from xterm as normal
|
||||
// text input with no paste-origin metadata. Keep them on the keyboard path;
|
||||
// clipboard-content heuristics can misclassify ordinary typing.
|
||||
source: TerminalInputSource.keyboard,
|
||||
isMobileOrWebMobile: isMobile || (isWeb && !isWebDesktop),
|
||||
bracketedPasteMode: terminal.bracketedPasteMode,
|
||||
ctrlLocked: ctrlLocked,
|
||||
altLocked: altLocked,
|
||||
);
|
||||
if (shouldConsumeModifiers) {
|
||||
if (ctrlLocked) clearCtrlLock?.call();
|
||||
if (altLocked) clearAltLock?.call();
|
||||
}
|
||||
return _sendInputPayload(data);
|
||||
}
|
||||
|
||||
/// Sends an already prepared payload without applying keyboard semantics.
|
||||
/// Both normal input and paste use this transport path after their source-
|
||||
/// specific preprocessing has completed.
|
||||
Future<void> _sendInputPayload(String data) async {
|
||||
// Clipboard reads and native sends may complete after the terminal page has
|
||||
// closed. Never send or re-buffer input once this model is disposed.
|
||||
if (_disposed) return;
|
||||
|
||||
if (_terminalOpened) {
|
||||
// Send user input to remote terminal
|
||||
try {
|
||||
@@ -71,7 +130,8 @@ class TerminalModel with ChangeNotifier {
|
||||
}
|
||||
|
||||
TerminalModel(this.parent, [this.terminalId = 0]) : id = parent.id {
|
||||
terminal = Terminal(maxLines: 10000);
|
||||
terminal = RustDeskTerminal(maxLines: 10000);
|
||||
terminal.mouseHandler = const WheelButtonFixMouseHandler();
|
||||
terminalController = TerminalController();
|
||||
|
||||
// Setup terminal callbacks
|
||||
@@ -173,6 +233,18 @@ class TerminalModel with ChangeNotifier {
|
||||
return _handleInput(data);
|
||||
}
|
||||
|
||||
Future<void> pasteText(String data) async {
|
||||
final payload = prepareTerminalInputPayload(
|
||||
data,
|
||||
source: TerminalInputSource.paste,
|
||||
isMobileOrWebMobile: false,
|
||||
bracketedPasteMode: terminal.bracketedPasteMode,
|
||||
ctrlLocked: false,
|
||||
altLocked: false,
|
||||
);
|
||||
return _sendInputPayload(payload);
|
||||
}
|
||||
|
||||
Future<void> closeTerminal() async {
|
||||
if (_terminalOpened) {
|
||||
try {
|
||||
@@ -247,6 +319,33 @@ class TerminalModel with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
static int getExitCodeFromEvt(Map<String, dynamic> evt) {
|
||||
if (evt.containsKey('exit_code')) {
|
||||
final v = evt['exit_code'];
|
||||
if (v is int) {
|
||||
// Desktop and mobile send exit_code as an int
|
||||
return v;
|
||||
} else if (v is String) {
|
||||
// Web sends exit_code as a string
|
||||
final parsed = int.tryParse(v);
|
||||
if (parsed != null) {
|
||||
return parsed;
|
||||
} else {
|
||||
debugPrint(
|
||||
'[TerminalModel] Failed to parse exit_code as integer: $v. Expected a numeric string.');
|
||||
return 0;
|
||||
}
|
||||
} else {
|
||||
debugPrint(
|
||||
'[TerminalModel] Unexpected exit_code type: ${v.runtimeType}, value: $v. Expected int or String.');
|
||||
return 0;
|
||||
}
|
||||
} else {
|
||||
debugPrint('[TerminalModel] Event does not contain exit_code');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
void handleTerminalResponse(Map<String, dynamic> evt) {
|
||||
final String? type = evt['type'];
|
||||
final int evtTerminalId = getTerminalIdFromEvt(evt);
|
||||
@@ -469,10 +568,12 @@ class TerminalModel with ChangeNotifier {
|
||||
}
|
||||
|
||||
void _handleTerminalClosed(Map<String, dynamic> evt) {
|
||||
final int exitCode = evt['exit_code'] ?? 0;
|
||||
final int exitCode = getExitCodeFromEvt(evt);
|
||||
_writeToTerminal('\r\nTerminal closed with exit code: $exitCode\r\n');
|
||||
_terminalOpened = false;
|
||||
notifyListeners();
|
||||
// Auto-close the tab/page
|
||||
onClosed?.call();
|
||||
}
|
||||
|
||||
void _handleTerminalError(Map<String, dynamic> evt) {
|
||||
@@ -484,6 +585,14 @@ class TerminalModel with ChangeNotifier {
|
||||
void dispose() {
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
terminal.onOutput = null;
|
||||
terminal.onResize = null;
|
||||
isCtrlLocked = null;
|
||||
clearCtrlLock = null;
|
||||
isAltLocked = null;
|
||||
clearAltLock = null;
|
||||
onResizeExternal = null;
|
||||
onClosed = null;
|
||||
// Clear buffers to free memory
|
||||
_inputBuffer.clear();
|
||||
_pendingOutputChunks.clear();
|
||||
|
||||
235
flutter/lib/models/terminal_mouse_drag_reporter.dart
Normal file
235
flutter/lib/models/terminal_mouse_drag_reporter.dart
Normal file
@@ -0,0 +1,235 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:xterm/xterm.dart';
|
||||
|
||||
const _cellIndexOffset = 1;
|
||||
const _legacyCodeOffset = 32;
|
||||
const _leftButtonCode = 0;
|
||||
const _motionButtonCode = 32;
|
||||
const _releaseButtonCode = 3;
|
||||
const _shiftModifierCode = 4;
|
||||
const _metaModifierCode = 8;
|
||||
const _controlModifierCode = 16;
|
||||
const _modifierCodeMask =
|
||||
_shiftModifierCode | _metaModifierCode | _controlModifierCode;
|
||||
const _normalCoordinateLimit = 223;
|
||||
const _utfCoordinateLimit = 2015;
|
||||
|
||||
String encodeTerminalMouseReport(
|
||||
MouseReportMode mode,
|
||||
int button,
|
||||
CellOffset position, {
|
||||
bool release = false,
|
||||
}) {
|
||||
final x = position.x + _cellIndexOffset;
|
||||
final y = position.y + _cellIndexOffset;
|
||||
final reportedButton =
|
||||
release ? _releaseButtonCode | (button & _modifierCodeMask) : button;
|
||||
switch (mode) {
|
||||
case MouseReportMode.normal:
|
||||
case MouseReportMode.utf:
|
||||
final limit = mode == MouseReportMode.normal
|
||||
? _normalCoordinateLimit
|
||||
: _utfCoordinateLimit;
|
||||
final encodedButton =
|
||||
String.fromCharCode(_legacyCodeOffset + reportedButton);
|
||||
return '\x1b[M$encodedButton${_legacyCoordinate(x, limit)}'
|
||||
'${_legacyCoordinate(y, limit)}';
|
||||
case MouseReportMode.sgr:
|
||||
final suffix = release ? 'm' : 'M';
|
||||
return '\x1b[<$button;$x;$y$suffix';
|
||||
case MouseReportMode.urxvt:
|
||||
return '\x1b[${_legacyCodeOffset + reportedButton};$x;${y}M';
|
||||
}
|
||||
}
|
||||
|
||||
String _legacyCoordinate(int value, int limit) =>
|
||||
value > limit ? '\x00' : String.fromCharCode(_legacyCodeOffset + value);
|
||||
|
||||
int _activeModifierCode() {
|
||||
final keyboard = HardwareKeyboard.instance;
|
||||
return (keyboard.isShiftPressed ? _shiftModifierCode : 0) |
|
||||
(keyboard.isAltPressed ? _metaModifierCode : 0) |
|
||||
(keyboard.isControlPressed ? _controlModifierCode : 0);
|
||||
}
|
||||
|
||||
class TerminalMouseDragReporter {
|
||||
int? _pointerId;
|
||||
TerminalController? _controller;
|
||||
late CellOffset _lastReportedPosition;
|
||||
var _ownsControllerSuspension = false;
|
||||
var _releasePending = false;
|
||||
var _reporting = false;
|
||||
|
||||
bool handleDown(
|
||||
PointerDownEvent event,
|
||||
Terminal terminal,
|
||||
TerminalViewState? terminalView,
|
||||
) {
|
||||
if (!_isPrimaryMouse(event) || !_reportsDrag(terminal.mouseMode)) {
|
||||
return false;
|
||||
}
|
||||
if (terminalView == null || terminalView.widget.readOnly) return false;
|
||||
final controller = terminalView.widget.controller;
|
||||
if (controller == null ||
|
||||
controller.suspendedPointerInputs ||
|
||||
!controller.pointerInput.inputs.contains(PointerInput.tap)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
cancel();
|
||||
_pointerId = event.pointer;
|
||||
_controller = controller;
|
||||
_ownsControllerSuspension = true;
|
||||
_releasePending = true;
|
||||
_reporting = true;
|
||||
controller.setSuspendPointerInput(true);
|
||||
_clearSelection(controller);
|
||||
final position = _cellAt(event, terminalView);
|
||||
_lastReportedPosition = position;
|
||||
terminal.textInput(
|
||||
_report(terminal.mouseReportMode, position),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool handleMove(
|
||||
PointerMoveEvent event,
|
||||
Terminal terminal,
|
||||
TerminalViewState? terminalView,
|
||||
) {
|
||||
if (event.pointer != _pointerId) return false;
|
||||
if (terminalView == null) {
|
||||
cancel();
|
||||
return true;
|
||||
}
|
||||
final reportsDrag = _reportsDrag(terminal.mouseMode);
|
||||
if (!_isPrimaryMouse(event)) {
|
||||
if (_releasePending && reportsDrag) {
|
||||
_reportRelease(
|
||||
terminal,
|
||||
_reporting ? _cellAt(event, terminalView) : _lastReportedPosition,
|
||||
);
|
||||
}
|
||||
cancel();
|
||||
return true;
|
||||
}
|
||||
if (!_reporting || !reportsDrag) {
|
||||
if (!reportsDrag) _releasePending = false;
|
||||
_reporting = false;
|
||||
// Keep ownership until the matching end event to suppress local selection.
|
||||
final controller = _controller;
|
||||
scheduleMicrotask(() => _clearSelection(controller));
|
||||
return true;
|
||||
}
|
||||
|
||||
final position = _cellAt(event, terminalView);
|
||||
_lastReportedPosition = position;
|
||||
terminal.textInput(
|
||||
_report(terminal.mouseReportMode, position, motion: true),
|
||||
);
|
||||
final controller = _controller;
|
||||
scheduleMicrotask(() => _clearSelection(controller));
|
||||
return true;
|
||||
}
|
||||
|
||||
bool handleEnd(
|
||||
PointerEvent event,
|
||||
Terminal terminal,
|
||||
TerminalViewState? terminalView,
|
||||
) {
|
||||
if (event.pointer != _pointerId) return false;
|
||||
if (terminalView != null &&
|
||||
_releasePending &&
|
||||
_reportsDrag(terminal.mouseMode)) {
|
||||
_reportRelease(
|
||||
terminal,
|
||||
_reporting ? _cellAt(event, terminalView) : _lastReportedPosition,
|
||||
);
|
||||
}
|
||||
_clearSelection(_controller);
|
||||
final controller = _controller;
|
||||
_pointerId = null;
|
||||
// Keep xterm's tap recognizer suspended for this pointer event.
|
||||
scheduleMicrotask(() {
|
||||
if (_pointerId == null && identical(_controller, controller)) {
|
||||
_clearSelection(controller);
|
||||
cancel();
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
void cancel() {
|
||||
final controller = _controller;
|
||||
if (_ownsControllerSuspension) {
|
||||
controller?.setSuspendPointerInput(false);
|
||||
}
|
||||
_pointerId = null;
|
||||
_controller = null;
|
||||
_ownsControllerSuspension = false;
|
||||
_releasePending = false;
|
||||
_reporting = false;
|
||||
}
|
||||
|
||||
void updateController(TerminalController controller) {
|
||||
final oldController = _controller;
|
||||
if (_pointerId == null || oldController == null) {
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
if (identical(oldController, controller)) return;
|
||||
if (_ownsControllerSuspension) {
|
||||
oldController.setSuspendPointerInput(false);
|
||||
}
|
||||
final acceptsPointerInput = !controller.suspendedPointerInputs &&
|
||||
controller.pointerInput.inputs.contains(PointerInput.tap);
|
||||
_controller = controller;
|
||||
_ownsControllerSuspension = acceptsPointerInput;
|
||||
_reporting = _reporting && acceptsPointerInput;
|
||||
if (_ownsControllerSuspension) controller.setSuspendPointerInput(true);
|
||||
_clearSelection(controller);
|
||||
}
|
||||
|
||||
void _reportRelease(Terminal terminal, CellOffset position) {
|
||||
terminal.textInput(
|
||||
_report(
|
||||
terminal.mouseReportMode,
|
||||
position,
|
||||
release: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
CellOffset _cellAt(PointerEvent event, TerminalViewState terminalView) {
|
||||
final renderTerminal = terminalView.renderTerminal;
|
||||
return renderTerminal.getCellOffset(
|
||||
renderTerminal.globalToLocal(event.position),
|
||||
);
|
||||
}
|
||||
|
||||
bool _isPrimaryMouse(PointerEvent event) =>
|
||||
event.kind == PointerDeviceKind.mouse &&
|
||||
(event.buttons & kPrimaryMouseButton) == kPrimaryMouseButton;
|
||||
|
||||
bool _reportsDrag(MouseMode mode) =>
|
||||
mode == MouseMode.upDownScrollDrag || mode == MouseMode.upDownScrollMove;
|
||||
|
||||
void _clearSelection(TerminalController? controller) {
|
||||
if (controller == null || controller.selection == null) return;
|
||||
controller.clearSelection();
|
||||
}
|
||||
|
||||
String _report(
|
||||
MouseReportMode mode,
|
||||
CellOffset position, {
|
||||
bool release = false,
|
||||
bool motion = false,
|
||||
}) {
|
||||
final baseButton = motion ? _motionButtonCode : _leftButtonCode;
|
||||
final button = baseButton | _activeModifierCode();
|
||||
return encodeTerminalMouseReport(mode, button, position, release: release);
|
||||
}
|
||||
}
|
||||
301
flutter/lib/models/terminal_mouse_handler.dart
Normal file
301
flutter/lib/models/terminal_mouse_handler.dart
Normal file
@@ -0,0 +1,301 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:xterm/xterm.dart';
|
||||
|
||||
import 'terminal_copy_shortcut.dart';
|
||||
import 'terminal_mouse_drag_reporter.dart';
|
||||
|
||||
/// xterm 4.0.0 encodes wheel buttons as 68..71; the extra bit reads as a Shift
|
||||
/// modifier, so strict full-screen apps ignore the report and never scroll.
|
||||
/// Upstream fix: TerminalStudio/xterm.dart#238.
|
||||
class WheelButtonFixMouseHandler implements TerminalMouseHandler {
|
||||
const WheelButtonFixMouseHandler({this.positionProvider});
|
||||
|
||||
final CellOffset? Function()? positionProvider;
|
||||
|
||||
@override
|
||||
String? call(TerminalMouseEvent event) {
|
||||
if (!event.button.isWheel) {
|
||||
return defaultMouseHandler(event);
|
||||
}
|
||||
// Same gate as UpDownMouseHandler: only the scroll modes report a wheel,
|
||||
// and a wheel release is never reported, so the report is always a press.
|
||||
if (!event.state.mouseMode.reportScroll ||
|
||||
event.buttonState == TerminalMouseButtonState.up) {
|
||||
return null;
|
||||
}
|
||||
return _reportWheel(event);
|
||||
}
|
||||
|
||||
String _reportWheel(TerminalMouseEvent event) {
|
||||
// Wheel buttons 4..7 go on the wire as 64..67, but `id` is 64 + 4..7.
|
||||
final button = event.button.id - 4;
|
||||
final position = positionProvider?.call() ?? event.position;
|
||||
return encodeTerminalMouseReport(
|
||||
event.state.mouseReportMode,
|
||||
button,
|
||||
position,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class TerminalMouseInteraction extends StatefulWidget {
|
||||
const TerminalMouseInteraction(
|
||||
this.terminal, {
|
||||
super.key,
|
||||
required this.controller,
|
||||
this.focusNode,
|
||||
this.backgroundOpacity = 1,
|
||||
this.padding,
|
||||
this.onSecondaryTapDown,
|
||||
});
|
||||
|
||||
final Terminal terminal;
|
||||
final TerminalController controller;
|
||||
final FocusNode? focusNode;
|
||||
final double backgroundOpacity;
|
||||
final EdgeInsets? padding;
|
||||
final void Function(TapDownDetails, CellOffset)? onSecondaryTapDown;
|
||||
|
||||
@override
|
||||
State<TerminalMouseInteraction> createState() =>
|
||||
_TerminalMouseInteractionState();
|
||||
}
|
||||
|
||||
class _TerminalMouseInteractionState extends State<TerminalMouseInteraction> {
|
||||
static const _selectionScrollInterval = Duration(milliseconds: 50);
|
||||
static const _noScroll = 0;
|
||||
static const _scrollUp = -1;
|
||||
static const _scrollDown = 1;
|
||||
|
||||
final _terminalViewKey = GlobalKey<TerminalViewState>();
|
||||
final _scrollController = ScrollController();
|
||||
final _mouseDrag = TerminalMouseDragReporter();
|
||||
late final WheelButtonFixMouseHandler _mouseHandler;
|
||||
TerminalMouseHandler? _previousMouseHandler;
|
||||
Offset? _pointerPosition;
|
||||
Offset? _selectionPointer;
|
||||
CellAnchor? _selectionBase;
|
||||
Buffer? _selectionBuffer;
|
||||
int? _selectionPointerId;
|
||||
Timer? _selectionScrollTimer;
|
||||
var _selectionHasScrolled = false;
|
||||
var _scrollDirection = _noScroll;
|
||||
TerminalViewState? get _terminalView => _terminalViewKey.currentState;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_mouseHandler = WheelButtonFixMouseHandler(
|
||||
positionProvider: _cellAtPointer,
|
||||
);
|
||||
_installMouseHandler(widget.terminal);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(TerminalMouseInteraction oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
final terminalChanged = !identical(oldWidget.terminal, widget.terminal);
|
||||
final controllerChanged =
|
||||
!identical(oldWidget.controller, widget.controller);
|
||||
if (!terminalChanged && !controllerChanged) return;
|
||||
if (controllerChanged && !terminalChanged) {
|
||||
_mouseDrag.updateController(widget.controller);
|
||||
} else {
|
||||
_mouseDrag.cancel();
|
||||
}
|
||||
_clearSelectionDrag();
|
||||
if (!terminalChanged) return;
|
||||
_restoreMouseHandler(oldWidget.terminal);
|
||||
_installMouseHandler(widget.terminal);
|
||||
}
|
||||
|
||||
void _installMouseHandler(Terminal terminal) {
|
||||
_previousMouseHandler = terminal.mouseHandler;
|
||||
terminal.mouseHandler = _mouseHandler;
|
||||
}
|
||||
|
||||
void _restoreMouseHandler(Terminal terminal) {
|
||||
if (identical(terminal.mouseHandler, _mouseHandler)) {
|
||||
terminal.mouseHandler = _previousMouseHandler;
|
||||
}
|
||||
}
|
||||
|
||||
CellOffset? _cellAtPointer() {
|
||||
final terminalView = _terminalView;
|
||||
final pointerPosition = _pointerPosition;
|
||||
if (terminalView == null || pointerPosition == null) return null;
|
||||
final renderTerminal = terminalView.renderTerminal;
|
||||
return renderTerminal.getCellOffset(
|
||||
renderTerminal.globalToLocal(pointerPosition),
|
||||
);
|
||||
}
|
||||
|
||||
void _updatePointerPosition(PointerEvent event) =>
|
||||
_pointerPosition = event.position;
|
||||
|
||||
void _handlePointerDown(PointerDownEvent event) {
|
||||
_updatePointerPosition(event);
|
||||
if (_mouseDrag.handleDown(event, widget.terminal, _terminalView)) {
|
||||
_clearSelectionDrag();
|
||||
return;
|
||||
}
|
||||
if (event.kind != PointerDeviceKind.mouse ||
|
||||
(event.buttons & kPrimaryMouseButton) != kPrimaryMouseButton) {
|
||||
return;
|
||||
}
|
||||
_clearSelectionDrag();
|
||||
final terminalView = _terminalView;
|
||||
if (terminalView == null) return;
|
||||
final renderTerminal = terminalView.renderTerminal;
|
||||
final localPosition = renderTerminal.globalToLocal(event.position);
|
||||
final selectionBuffer = widget.terminal.buffer;
|
||||
_selectionPointerId = event.pointer;
|
||||
_selectionBase = selectionBuffer.createAnchorFromOffset(
|
||||
renderTerminal.getCellOffset(localPosition),
|
||||
);
|
||||
_selectionBuffer = selectionBuffer;
|
||||
_selectionPointer = localPosition;
|
||||
}
|
||||
|
||||
void _handlePointerMove(PointerMoveEvent event) {
|
||||
_updatePointerPosition(event);
|
||||
if (_mouseDrag.handleMove(event, widget.terminal, _terminalView)) return;
|
||||
if (event.pointer != _selectionPointerId) return;
|
||||
if (event.kind != PointerDeviceKind.mouse ||
|
||||
(event.buttons & kPrimaryMouseButton) != kPrimaryMouseButton) {
|
||||
_clearSelectionDrag();
|
||||
return;
|
||||
}
|
||||
final terminalView = _terminalView;
|
||||
if (terminalView == null || _selectionBase == null) return;
|
||||
final renderTerminal = terminalView.renderTerminal;
|
||||
final localPosition = renderTerminal.globalToLocal(event.position);
|
||||
_selectionPointer = localPosition;
|
||||
_setScrollDirection(
|
||||
_directionFor(localPosition, renderTerminal.paintBounds),
|
||||
);
|
||||
if (_selectionHasScrolled) {
|
||||
scheduleMicrotask(() => _scrollSelection(scroll: false));
|
||||
}
|
||||
}
|
||||
|
||||
int _directionFor(Offset position, Rect bounds) {
|
||||
if (position.dy < bounds.top) return _scrollUp;
|
||||
if (position.dy >= bounds.bottom) return _scrollDown;
|
||||
return _noScroll;
|
||||
}
|
||||
|
||||
void _setScrollDirection(int direction) {
|
||||
if (_scrollDirection == direction) return;
|
||||
_stopAutoScroll();
|
||||
_scrollDirection = direction;
|
||||
if (direction == _noScroll) return;
|
||||
_scrollSelection();
|
||||
if (_scrollDirection != _noScroll) {
|
||||
_selectionScrollTimer = Timer.periodic(
|
||||
_selectionScrollInterval,
|
||||
(_) => _scrollSelection(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _scrollSelection({bool scroll = true}) {
|
||||
final terminalView = _terminalView;
|
||||
final selectionBase = _selectionBase;
|
||||
final selectionBuffer = _selectionBuffer;
|
||||
final selectionPointer = _selectionPointer;
|
||||
if (terminalView == null ||
|
||||
selectionBase == null ||
|
||||
selectionBuffer == null ||
|
||||
selectionPointer == null ||
|
||||
!_scrollController.hasClients) {
|
||||
return;
|
||||
}
|
||||
if (!identical(selectionBuffer, widget.terminal.buffer) ||
|
||||
!selectionBase.attached) {
|
||||
_clearSelectionDrag();
|
||||
return;
|
||||
}
|
||||
final renderTerminal = terminalView.renderTerminal;
|
||||
if (scroll) {
|
||||
final position = _scrollController.position;
|
||||
final target =
|
||||
(position.pixels + renderTerminal.lineHeight * _scrollDirection)
|
||||
.clamp(position.minScrollExtent, position.maxScrollExtent)
|
||||
.toDouble();
|
||||
if (target == position.pixels) {
|
||||
_stopAutoScroll();
|
||||
} else {
|
||||
position.jumpTo(target);
|
||||
_selectionHasScrolled = true;
|
||||
}
|
||||
}
|
||||
renderTerminal.selectCharacters(
|
||||
renderTerminal.getOffset(selectionBase.offset),
|
||||
selectionPointer,
|
||||
);
|
||||
}
|
||||
|
||||
void _handlePointerEnd(PointerEvent event) {
|
||||
_updatePointerPosition(event);
|
||||
if (!_mouseDrag.handleEnd(event, widget.terminal, _terminalView) &&
|
||||
event.pointer != _selectionPointerId) return;
|
||||
if (_selectionHasScrolled) _scrollSelection(scroll: false);
|
||||
_clearSelectionDrag();
|
||||
}
|
||||
|
||||
void _clearSelectionDrag() {
|
||||
_selectionPointerId = null;
|
||||
_selectionBase?.dispose();
|
||||
_selectionBase = null;
|
||||
_selectionBuffer = null;
|
||||
_selectionPointer = null;
|
||||
_selectionHasScrolled = false;
|
||||
_stopAutoScroll();
|
||||
}
|
||||
|
||||
void _stopAutoScroll() {
|
||||
_selectionScrollTimer?.cancel();
|
||||
_selectionScrollTimer = null;
|
||||
_scrollDirection = _noScroll;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_mouseDrag.cancel();
|
||||
_clearSelectionDrag();
|
||||
_restoreMouseHandler(widget.terminal);
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Listener(
|
||||
onPointerDown: _handlePointerDown,
|
||||
onPointerMove: _handlePointerMove,
|
||||
onPointerUp: _handlePointerEnd,
|
||||
onPointerHover: _updatePointerPosition,
|
||||
onPointerCancel: _handlePointerEnd,
|
||||
onPointerSignal: _updatePointerPosition,
|
||||
onPointerPanZoomStart: _updatePointerPosition,
|
||||
onPointerPanZoomUpdate: _updatePointerPosition,
|
||||
onPointerPanZoomEnd: _updatePointerPosition,
|
||||
child: TerminalView(
|
||||
widget.terminal,
|
||||
key: _terminalViewKey,
|
||||
controller: widget.controller,
|
||||
scrollController: _scrollController,
|
||||
focusNode: widget.focusNode,
|
||||
backgroundOpacity: widget.backgroundOpacity,
|
||||
padding: widget.padding,
|
||||
shortcuts: platformTerminalShortcuts(),
|
||||
onKeyEvent: terminalCopyHandler(widget.terminal, widget.controller),
|
||||
onSecondaryTapDown: widget.onSecondaryTapDown,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,9 @@ class UserModel {
|
||||
final RxString avatar = ''.obs;
|
||||
final RxBool isAdmin = false.obs;
|
||||
final RxString networkError = ''.obs;
|
||||
// True when networkError carries a server-reported error rather than a
|
||||
// connectivity failure; netWorkErrorWidget hides the network tip then.
|
||||
final RxBool networkErrorFromServer = false.obs;
|
||||
bool get isLogin => userName.isNotEmpty;
|
||||
String get displayNameOrUserName =>
|
||||
displayName.value.trim().isEmpty ? userName.value : displayName.value;
|
||||
@@ -50,6 +53,7 @@ class UserModel {
|
||||
void refreshCurrentUser() async {
|
||||
if (bind.isDisableAccount()) return;
|
||||
networkError.value = '';
|
||||
networkErrorFromServer.value = false;
|
||||
final token = bind.mainGetLocalOption(key: 'access_token');
|
||||
if (token == '') {
|
||||
await updateOtherModels();
|
||||
@@ -85,6 +89,10 @@ class UserModel {
|
||||
final data = json.decode(decode_http_response(response));
|
||||
final error = data['error'];
|
||||
if (error != null) {
|
||||
// The only failure known to come from the server itself, so the
|
||||
// check-your-network tip does not apply. Flag before the message is
|
||||
// set in the catch below so rebuilds read a consistent pair.
|
||||
networkErrorFromServer.value = true;
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -92,6 +100,13 @@ class UserModel {
|
||||
_parseAndUpdateUser(user);
|
||||
} catch (e) {
|
||||
debugPrint('Failed to refreshCurrentUser: $e');
|
||||
// Surface failures in the address book / group tabs, which offer a
|
||||
// retry. Anything not flagged above -- transport errors, non-JSON or
|
||||
// unexpected-schema bodies (e.g. a filter's block page) -- keeps the
|
||||
// check-your-network tip.
|
||||
if (networkError.value.isEmpty) {
|
||||
networkError.value = e.toString();
|
||||
}
|
||||
} finally {
|
||||
refreshingUser = false;
|
||||
await updateOtherModels();
|
||||
@@ -219,28 +234,32 @@ class UserModel {
|
||||
return loginResponse;
|
||||
}
|
||||
|
||||
/// Throws on network failures, non-success responses, and invalid response
|
||||
/// data. Returns an empty list when no API server is configured or a
|
||||
/// successful response contains no third-party login options.
|
||||
static Future<List<dynamic>> queryOidcLoginOptions() async {
|
||||
try {
|
||||
final url = await bind.mainGetApiServer();
|
||||
if (url.trim().isEmpty) return [];
|
||||
final resp = await http.get(Uri.parse('$url/api/login-options'));
|
||||
final List<String> ops = [];
|
||||
for (final item in jsonDecode(resp.body)) {
|
||||
ops.add(item as String);
|
||||
}
|
||||
for (final item in ops) {
|
||||
if (item.startsWith('common-oidc/')) {
|
||||
return jsonDecode(item.substring('common-oidc/'.length));
|
||||
}
|
||||
}
|
||||
return ops
|
||||
.where((item) => item.startsWith('oidc/'))
|
||||
.map((item) => {'name': item.substring('oidc/'.length)})
|
||||
.toList();
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
"queryOidcLoginOptions: jsonDecode resp body failed: ${e.toString()}");
|
||||
return [];
|
||||
final url = await bind.mainGetApiServer();
|
||||
if (url.trim().isEmpty) return [];
|
||||
final resp = await http.get(Uri.parse('$url/api/login-options'));
|
||||
const successStatusCodeStart = 200;
|
||||
const successStatusCodeEnd = 300;
|
||||
if (resp.statusCode < successStatusCodeStart ||
|
||||
resp.statusCode >= successStatusCodeEnd) {
|
||||
throw RequestException(
|
||||
resp.statusCode, resp.reasonPhrase ?? 'Request failed');
|
||||
}
|
||||
final List<String> ops = [];
|
||||
for (final item in jsonDecode(resp.body)) {
|
||||
ops.add(item as String);
|
||||
}
|
||||
for (final item in ops) {
|
||||
if (item.startsWith('common-oidc/')) {
|
||||
return jsonDecode(item.substring('common-oidc/'.length));
|
||||
}
|
||||
}
|
||||
return ops
|
||||
.where((item) => item.startsWith('oidc/'))
|
||||
.map((item) => {'name': item.substring('oidc/'.length)})
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,18 @@
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:js_interop';
|
||||
import 'dart:js_interop_unsafe';
|
||||
import 'dart:typed_data';
|
||||
import 'dart:js';
|
||||
import 'dart:html';
|
||||
import 'dart:async';
|
||||
import 'dart:ui' as ui;
|
||||
import 'dart:ui_web' as ui_web;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_hbb/common/widgets/login.dart';
|
||||
import 'package:flutter_hbb/models/state_model.dart';
|
||||
import 'package:flutter_hbb/models/web_video_frame_queue.dart';
|
||||
|
||||
import 'package:flutter_hbb/web/bridge.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
@@ -18,6 +22,22 @@ import 'package:uuid/uuid.dart';
|
||||
final List<StreamSubscription<MouseEvent>> mouseListeners = [];
|
||||
final List<StreamSubscription<KeyboardEvent>> keyListeners = [];
|
||||
|
||||
// WebCodecs VideoFrames handed over from js/src/webcodecs.js arrive as plain
|
||||
// interop objects (the package language version predates extension types).
|
||||
// This side owns each frame and must close it quickly: hardware decoders
|
||||
// stall once their small output frame pool is exhausted.
|
||||
int _videoFrameWidth(JSObject frame) =>
|
||||
frame.getProperty<JSNumber>('displayWidth'.toJS).toDartInt;
|
||||
int _videoFrameHeight(JSObject frame) =>
|
||||
frame.getProperty<JSNumber>('displayHeight'.toJS).toDartInt;
|
||||
void _closeVideoFrame(JSObject frame) {
|
||||
try {
|
||||
frame.callMethod<JSAny?>('close'.toJS);
|
||||
} catch (error) {
|
||||
debugPrint('VideoFrame.close failed: $error');
|
||||
}
|
||||
}
|
||||
|
||||
typedef HandleEvent = Future<void> Function(Map<String, dynamic> evt);
|
||||
|
||||
class PlatformFFI {
|
||||
@@ -33,6 +53,13 @@ class PlatformFFI {
|
||||
}
|
||||
|
||||
PlatformFFI._() {
|
||||
_videoFrameQueue = WebVideoFrameQueue(
|
||||
importFrame: _importVideoFrame,
|
||||
closeFrame: _closeVideoFrame,
|
||||
disposeImage: (image) => image.dispose(),
|
||||
onImportError: _handleVideoFrameImportError,
|
||||
onCallbackError: _handleVideoImageCallbackError,
|
||||
);
|
||||
window.document.addEventListener(
|
||||
'visibilitychange',
|
||||
(event) => {
|
||||
@@ -162,6 +189,46 @@ class PlatformFFI {
|
||||
};
|
||||
}
|
||||
|
||||
late final WebVideoFrameQueue<JSObject, ui.Image> _videoFrameQueue;
|
||||
|
||||
// Zero-readback video path: the JS decoder hands decoded VideoFrames here
|
||||
// (checking typeof window.onVideoFrame before every frame), and the engine
|
||||
// imports them GPU-to-GPU via createImageBitmap. Unregistering the JS global
|
||||
// reverts the JS side to the RGBA readback path.
|
||||
void setVideoFrameCallback(
|
||||
Future<void> Function(int, ui.Image, bool Function()) fun) {
|
||||
_videoFrameQueue.beginSession(fun);
|
||||
if (!_videoFrameQueue.isEnabled) return;
|
||||
globalContext.setProperty(
|
||||
'onVideoFrame'.toJS,
|
||||
((JSNumber display, JSObject frame) {
|
||||
_videoFrameQueue.submit(display.toDartInt, frame);
|
||||
}).toJS,
|
||||
);
|
||||
}
|
||||
|
||||
void clearVideoFrameCallback() {
|
||||
_videoFrameQueue.endSession();
|
||||
globalContext.setProperty('onVideoFrame'.toJS, null);
|
||||
}
|
||||
|
||||
Future<ui.Image> _importVideoFrame(JSObject frame) async {
|
||||
return await ui_web.createImageFromTextureSource(frame,
|
||||
width: _videoFrameWidth(frame), height: _videoFrameHeight(frame));
|
||||
}
|
||||
|
||||
void _handleVideoFrameImportError(Object error, StackTrace stackTrace) {
|
||||
debugPrintStack(
|
||||
label: 'createImageFromTextureSource failed, using RGBA path: $error',
|
||||
stackTrace: stackTrace);
|
||||
globalContext.setProperty('onVideoFrame'.toJS, null);
|
||||
}
|
||||
|
||||
void _handleVideoImageCallbackError(Object error, StackTrace stackTrace) {
|
||||
debugPrintStack(
|
||||
label: 'video image callback error: $error', stackTrace: stackTrace);
|
||||
}
|
||||
|
||||
void startDesktopWebListener() {
|
||||
mouseListeners.add(
|
||||
window.document.onContextMenu.listen((evt) => evt.preventDefault()));
|
||||
|
||||
133
flutter/lib/models/web_video_frame_queue.dart
Normal file
133
flutter/lib/models/web_video_frame_queue.dart
Normal file
@@ -0,0 +1,133 @@
|
||||
import 'dart:async';
|
||||
|
||||
typedef VideoFrameImporter<Frame, Image> = Future<Image> Function(Frame frame);
|
||||
typedef VideoFrameCloser<Frame> = void Function(Frame frame);
|
||||
typedef VideoImageDisposer<Image> = void Function(Image image);
|
||||
typedef VideoSessionValidator = bool Function();
|
||||
typedef VideoImageCallback<Image> = Future<void> Function(
|
||||
int display, Image image, VideoSessionValidator isCurrentSession);
|
||||
typedef VideoQueueErrorCallback = void Function(
|
||||
Object error, StackTrace stackTrace);
|
||||
|
||||
class WebVideoFrameQueue<Frame, Image> {
|
||||
WebVideoFrameQueue({
|
||||
required VideoFrameImporter<Frame, Image> importFrame,
|
||||
required VideoFrameCloser<Frame> closeFrame,
|
||||
required VideoImageDisposer<Image> disposeImage,
|
||||
required VideoQueueErrorCallback onImportError,
|
||||
required VideoQueueErrorCallback onCallbackError,
|
||||
}) : _importFrame = importFrame,
|
||||
_closeFrame = closeFrame,
|
||||
_disposeImage = disposeImage,
|
||||
_onImportError = onImportError,
|
||||
_onCallbackError = onCallbackError;
|
||||
|
||||
final VideoFrameImporter<Frame, Image> _importFrame;
|
||||
final VideoFrameCloser<Frame> _closeFrame;
|
||||
final VideoImageDisposer<Image> _disposeImage;
|
||||
final VideoQueueErrorCallback _onImportError;
|
||||
final VideoQueueErrorCallback _onCallbackError;
|
||||
final Map<int, _QueuedFrame<Frame>> _pending = {};
|
||||
|
||||
VideoImageCallback<Image>? _callback;
|
||||
int _generation = 0;
|
||||
bool _processing = false;
|
||||
bool _enabled = true;
|
||||
|
||||
bool get isEnabled => _enabled;
|
||||
|
||||
void beginSession(VideoImageCallback<Image> callback) {
|
||||
_invalidateSession();
|
||||
_enabled = true;
|
||||
_callback = callback;
|
||||
}
|
||||
|
||||
void endSession() {
|
||||
_invalidateSession();
|
||||
_callback = null;
|
||||
}
|
||||
|
||||
void _invalidateSession() {
|
||||
_generation++;
|
||||
for (final queued in _pending.values) {
|
||||
_closeFrame(queued.frame);
|
||||
}
|
||||
_pending.clear();
|
||||
}
|
||||
|
||||
bool submit(int display, Frame frame) {
|
||||
if (!_enabled || _callback == null) {
|
||||
_closeFrame(frame);
|
||||
return false;
|
||||
}
|
||||
final replaced = _pending.remove(display);
|
||||
if (replaced != null) {
|
||||
_closeFrame(replaced.frame);
|
||||
}
|
||||
_pending[display] = _QueuedFrame(display, frame, _generation);
|
||||
_startProcessing();
|
||||
return true;
|
||||
}
|
||||
|
||||
void _startProcessing() {
|
||||
if (_processing) return;
|
||||
_processing = true;
|
||||
unawaited(Future<void>(_process));
|
||||
}
|
||||
|
||||
Future<void> _process() async {
|
||||
while (_pending.isNotEmpty) {
|
||||
final display = _pending.keys.first;
|
||||
final queued = _pending.remove(display)!;
|
||||
if (!_enabled || queued.generation != _generation) {
|
||||
_closeFrame(queued.frame);
|
||||
continue;
|
||||
}
|
||||
await _importAndDeliver(queued);
|
||||
}
|
||||
_processing = false;
|
||||
}
|
||||
|
||||
Future<void> _importAndDeliver(_QueuedFrame<Frame> queued) async {
|
||||
Image? image;
|
||||
try {
|
||||
image = await _importFrame(queued.frame);
|
||||
} catch (error, stackTrace) {
|
||||
if (queued.generation == _generation) {
|
||||
_enabled = false;
|
||||
_onImportError(error, stackTrace);
|
||||
}
|
||||
} finally {
|
||||
_closeFrame(queued.frame);
|
||||
}
|
||||
if (image != null) {
|
||||
await _deliver(queued, image);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deliver(_QueuedFrame<Frame> queued, Image image) async {
|
||||
final callback = _callback;
|
||||
bool isCurrentSession() =>
|
||||
_enabled &&
|
||||
queued.generation == _generation &&
|
||||
identical(callback, _callback);
|
||||
if (!isCurrentSession() || callback == null) {
|
||||
_disposeImage(image);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await callback(queued.display, image, isCurrentSession);
|
||||
} catch (error, stackTrace) {
|
||||
_disposeImage(image);
|
||||
_onCallbackError(error, stackTrace);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _QueuedFrame<Frame> {
|
||||
const _QueuedFrame(this.display, this.frame, this.generation);
|
||||
|
||||
final int display;
|
||||
final Frame frame;
|
||||
final int generation;
|
||||
}
|
||||
@@ -10,8 +10,6 @@ final isWebDesktop_ = false;
|
||||
|
||||
final isDesktop_ = Platform.isWindows || Platform.isMacOS || Platform.isLinux;
|
||||
|
||||
String get screenInfo_ => '';
|
||||
|
||||
final isWebOnWindows_ = false;
|
||||
final isWebOnLinux_ = false;
|
||||
final isWebOnMacOS_ = false;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user