Compare commits

..

3 Commits

Author SHA1 Message Date
21pages
22e72cea69 improve display name handling
- Append (@username) when multiple users share the same display name
  - Trim whitespace from display_name before comparison and display
  - Add missing translate() for Logout button on desktop

Signed-off-by: 21pages <sunboeasy@gmail.com>
2026-02-21 20:11:17 +08:00
rustdesk
5fc0367abd 1. GUID key (...Uninstall\{GUID}) is MSI-native metadata generated by Windows Installer.
2. Non-GUID key (...Uninstall\RustDesk) is explicitly written by RustDesk’s MSI compatibility component in res/msi/Package/Components/Regs.wxs:44, populated by preprocess.py --arp from .github/workflows/
     flutter-build.yml:262.

  So they were not using the same EstimatedSize logic:

  - MSI GUID key: MSI-calculated size (KB).
  - RustDesk key: custom script value from res/msi/preprocess.py:339 (previously bytes, now fixed to KB).

  That mismatch is exactly why you saw different sizes.
2026-02-19 17:52:17 +08:00
rustdesk
9111bfc1de - UI display: display_name first
- Fallback: name
  - Technical identity: still name

  ### What changed

  - Added account display helpers and display_name state in user model:
      - flutter/lib/models/user_model.dart:16
  - Account/logout label now uses display_name (@name) when both exist:
      - flutter/lib/mobile/pages/settings_page.dart:689
      - flutter/lib/desktop/pages/desktop_setting_page.dart:2016
      - flutter/lib/desktop/pages/desktop_setting_page.dart:2135
  - Desktop Account info now shows both when applicable:
      - Display Name: ...
      - Username: ...
      - flutter/lib/desktop/pages/desktop_setting_page.dart:2039
  - Previously done group-list behavior remains:
      - group user list displays display_name with name fallback
      - flutter/lib/common/widgets/my_group.dart:187
  - Persistence path for display_name remains enabled (including group cache/submodule field):
      - libs/hbb_common/src/config.rs:2347
  - src/client.rs:2630
  - LoginRequest.my_name now resolves as:
      1. OPTION_DISPLAY_NAME (manual override)
      2. user_info.display_name
      3. user_info.name
      4. OS username fallback
2026-02-19 09:43:55 +08:00
309 changed files with 10026 additions and 44368 deletions

View File

@@ -2,8 +2,6 @@
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",

View File

@@ -0,0 +1,56 @@
You are an expert in prompt engineering, specializing in optimizing AI code assistant instructions. Your task is to analyze and improve the instructions for Claude Code.
Follow these steps carefully:
1. Analysis Phase:
Review the chat history in your context window.
Then, examine the current Claude instructions, commands and config
<claude_instructions>
/CLAUDE.md
/.claude/commands/*
**/CLAUDE.md
.claude/settings.json
.claude/settings.local.json
</claude_instructions>
Analyze the chat history, instructions, commands and config to identify areas that could be improved. Look for:
- Inconsistencies in Claude's responses
- Misunderstandings of user requests
- Areas where Claude could provide more detailed or accurate information
- Opportunities to enhance Claude's ability to handle specific types of queries or tasks
- New commands or improvements to a commands name, function or response
- Permissions and MCPs we've approved locally that we should add to the config, especially if we've added new tools or require them for the command to work
2. Interaction Phase:
Present your findings and improvement ideas to the human. For each suggestion:
a) Explain the current issue you've identified
b) Propose a specific change or addition to the instructions
c) Describe how this change would improve Claude's performance
Wait for feedback from the human on each suggestion before proceeding. If the human approves a change, move it to the implementation phase. If not, refine your suggestion or move on to the next idea.
3. Implementation Phase:
For each approved change:
a) Clearly state the section of the instructions you're modifying
b) Present the new or modified text for that section
c) Explain how this change addresses the issue identified in the analysis phase
4. Output Format:
Present your final output in the following structure:
<analysis>
[List the issues identified and potential improvements]
</analysis>
<improvements>
[For each approved improvement:
1. Section being modified
2. New or modified instruction text
3. Explanation of how this addresses the identified issue]
</improvements>
<final_instructions>
[Present the complete, updated set of instructions for Claude, incorporating all approved changes]
</final_instructions>
Remember, your goal is to enhance Claude's performance and consistency while maintaining the core functionality and purpose of the AI assistant. Be thorough in your analysis, clear in your explanations, and precise in your implementations.

View File

@@ -1,140 +0,0 @@
#!/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

View File

@@ -1,51 +0,0 @@
#!/usr/bin/env bash
# Prepares a web build on Flutter 3.44.x. Companion to
# apply_flutter_3.44_source_patches.sh (which it runs first): the web target
# additionally needs qr_code_scanner's web implementation patched for the
# dart:ui platformViewRegistry removal, and flutter/web/fonts refreshed with
# the font paths the 3.44 engine requests for offline/air-gapped support
# (rustdesk-server-pro#996; see flutter/web/fonts/sync_fonts.py).
#
# Run from the repository root with Flutter 3.44.x on PATH, then build:
# bash .github/patches/apply_flutter_3.44_web_patches.sh
# (cd flutter && flutter build web --release) # or ./web/js/flutter_build.py
#
# Idempotent. To undo the source changes locally:
# git checkout -- flutter/lib/common.dart flutter/pubspec.yaml flutter/pubspec.lock
set -euo pipefail
flutter --version | grep -q "Flutter 3\.44\." || {
echo "Flutter 3.44.x must be on PATH; found:" >&2
flutter --version | grep "^Flutter" >&2 || true
exit 1
}
# Shared 3.44 source/pubspec patches own their complete-state validation.
bash .github/patches/apply_flutter_3.44_source_patches.sh
# Populate the pub cache with the 3.44 dependency resolution.
(cd flutter && flutter pub get)
# qr_code_scanner 1.0.1 (unmaintained) reads platformViewRegistry from
# dart:ui, which Flutter 3.44 removed; point it at dart:ui_web instead. The
# patched file also compiles on Flutter 3.24 (dart:ui_web exists there), so
# mutating the shared pub cache is safe for other local builds.
QR_WEB="${PUB_CACHE:-$HOME/.pub-cache}/hosted/pub.dev/qr_code_scanner-1.0.1/lib/src/web/flutter_qr_web.dart"
if ! grep -qF "dart:ui_web" "$QR_WEB"; then
sed -i.bak "s|import 'dart:ui' as ui;|import 'dart:ui' as ui; import 'dart:ui_web' as ui_web;|" "$QR_WEB"
rm -f "$QR_WEB.bak"
fi
if grep -qF "ui.platformViewRegistry" "$QR_WEB"; then
sed -i.bak "s|ui\.platformViewRegistry|ui_web.platformViewRegistry|g" "$QR_WEB"
rm -f "$QR_WEB.bak"
fi
# Mirror the fonts this engine version requests into flutter/web/fonts.
python3 flutter/web/fonts/sync_fonts.py
# Fail loudly if any expected state is missing:
grep -qF "import 'dart:ui' as ui; import 'dart:ui_web' as ui_web;" "$QR_WEB"
grep -qF "ui_web.platformViewRegistry" "$QR_WEB"
grep -qF 'google_fonts: ^8.1.0' flutter/pubspec.yaml
echo "Flutter 3.44 web patches applied."

View File

@@ -7,6 +7,7 @@ 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
@@ -17,25 +18,14 @@ 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
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: actions/checkout@v4
with:
submodules: recursive
@@ -59,28 +49,28 @@ jobs:
wget
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
uses: dtolnay/rust-toolchain@v1
with:
toolchain: ${{ env.RUST_VERSION }}
targets: ${{ matrix.job.target }}
components: "rustfmt"
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
- uses: Swatinem/rust-cache@v2
with:
prefix-key: bridge-${{ matrix.job.os }}
- name: Cache Bridge
id: cache-bridge
uses: actions/cache@6f8efc29b200d32929f49075959781ed54ec270c # v3
uses: actions/cache@v3
with:
path: /tmp/flutter_rust_bridge
key: bridge-${{ matrix.job.flutter-version }}
key: vcpkg-${{ matrix.job.arch }}
- name: Install flutter
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version: ${{ matrix.job.flutter-version }}
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
- name: Install flutter rust bridge deps
@@ -88,15 +78,7 @@ 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
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
pushd flutter && sed -i -e 's/extended_text: 14.0.0/extended_text: 13.0.0/g' pubspec.yaml && flutter pub get && popd
- name: Run flutter rust bridge
run: |
@@ -104,9 +86,9 @@ jobs:
cp ./flutter/macos/Runner/bridge_generated.h ./flutter/ios/Runner/bridge_generated.h
- name: Upload Artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@master
with:
name: ${{ matrix.job.artifact-name }}
name: bridge-artifact
path: |
./src/bridge_generated.rs
./src/bridge_generated.io.rs

View File

@@ -29,13 +29,13 @@ jobs:
# name: Ensure 'cargo fmt' has been run
# runs-on: ubuntu-20.04
# steps:
# - uses: actions-rs/toolchain@16499b5e05bf2e26879000db0c1d13f7e13fa3af # v1
# - uses: actions-rs/toolchain@v1
# with:
# toolchain: stable
# default: true
# profile: minimal
# components: rustfmt
# - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
# - uses: actions/checkout@v3
# - run: cargo fmt -- --check
# min_version:
@@ -43,24 +43,24 @@ jobs:
# runs-on: ubuntu-20.04
# steps:
# - name: Checkout source code
# uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
# uses: actions/checkout@v3
# with:
# submodules: recursive
# - name: Install rust toolchain (v${{ env.MIN_SUPPORTED_RUST_VERSION }})
# uses: actions-rs/toolchain@16499b5e05bf2e26879000db0c1d13f7e13fa3af # v1
# uses: actions-rs/toolchain@v1
# with:
# toolchain: ${{ env.MIN_SUPPORTED_RUST_VERSION }}
# default: true
# profile: minimal # minimal component installation (ie, no documentation)
# components: clippy
# - name: Run clippy (on minimum supported rust version to prevent warnings we can't fix)
# uses: actions-rs/cargo@844f36862e911db73fe0815f00a4a2602c279505 # v1
# uses: actions-rs/cargo@v1
# with:
# command: clippy
# args: --locked --all-targets --all-features -- --allow clippy::unknown_clippy_lints
# - name: Run tests
# uses: actions-rs/cargo@844f36862e911db73fe0815f00a4a2602c279505 # v1
# uses: actions-rs/cargo@v1
# with:
# command: test
# args: --locked
@@ -81,15 +81,14 @@ 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:
- name: Free Disk Space (Ubuntu)
if: runner.os == 'Linux'
# jlumbroso/free-disk-space@v1.3.1 is used in .github\workflows\flutter-build.yml
# jlumbroso/free-disk-space@main is used in .github\workflows\flutter-build.yml
# But pinning to a specific version to avoid unexpected issues is preferred.
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1
uses: jlumbroso/free-disk-space@v1.3.1
with:
tool-cache: false
android: true
@@ -100,14 +99,14 @@ jobs:
swap-storage: false
- name: Export GitHub Actions cache environment variables
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
uses: actions/github-script@v6
with:
script: |
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
- name: Checkout source code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: actions/checkout@v4
with:
submodules: recursive
@@ -146,7 +145,7 @@ jobs:
esac
- name: Setup vcpkg with Github Actions binary cache
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
uses: lukka/run-vcpkg@v11
with:
vcpkgDirectory: /opt/artifacts/vcpkg
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
@@ -157,7 +156,7 @@ jobs:
shell: bash
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
uses: dtolnay/rust-toolchain@v1
with:
toolchain: stable
targets: ${{ matrix.job.target }}
@@ -173,10 +172,10 @@ jobs:
cargo -V
rustc -V
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
- uses: Swatinem/rust-cache@v2
- name: Build
uses: actions-rs/cargo@844f36862e911db73fe0815f00a4a2602c279505 # v1
uses: actions-rs/cargo@v1
with:
use-cross: ${{ matrix.job.use-cross }}
command: build
@@ -244,7 +243,7 @@ jobs:
echo "CARGO_TEST_OPTIONS=${CARGO_TEST_OPTIONS}" >> $GITHUB_OUTPUT
- name: Run tests
uses: actions-rs/cargo@844f36862e911db73fe0815f00a4a2602c279505 # v1
uses: actions-rs/cargo@v1
with:
use-cross: ${{ matrix.job.use-cross }}
command: test

View File

@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Clear cache
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
uses: actions/github-script@v7
with:
script: |
console.log("About to clear")
@@ -30,7 +30,7 @@ jobs:
console.log("Clear completed")
- name: Purge cache # Above seems not clear thouroughly, so add this to double clear
uses: MyAlbum/purge-cache@881eb5957687193fa612bf74c0042adc78ea5e54 # v2
uses: MyAlbum/purge-cache@v2
with:
accessed: true # Purge caches by their last accessed time (default)
created: false # Purge caches by their created time (default)

View File

@@ -31,7 +31,7 @@ jobs:
shell: bash
- name: Publish RustDesk version file
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
uses: softprops/action-gh-release@v1
with:
prerelease: true
tag_name: "fdroid-version"

File diff suppressed because it is too large Load Diff

View File

@@ -17,7 +17,7 @@ env:
TAG_NAME: "nightly"
VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite"
VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b"
VERSION: "1.4.9"
VERSION: "1.4.5"
NDK_VERSION: "r26d"
#signing keys env variable checks
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"
@@ -79,21 +79,21 @@ jobs:
}
steps:
- name: Export GitHub Actions cache environment variables
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
uses: actions/github-script@v6
with:
script: |
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
- name: Checkout source code
uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
uses: actions/checkout@v3
with:
ref: ${{ matrix.job.ref }}
submodules: recursive
- name: Import the codesign cert
if: env.MACOS_P12_BASE64 != null
uses: apple-actions/import-codesign-certs@253ddeeac23f2bdad1646faac5c8c2832e800071 # v1
uses: apple-actions/import-codesign-certs@v1
with:
p12-file-base64: ${{ secrets.MACOS_P12_BASE64 }}
p12-password: ${{ secrets.MACOS_P12_PASSWORD }}
@@ -107,7 +107,7 @@ jobs:
- name: Import notarize key
if: env.MACOS_P12_BASE64 != null
uses: timheuer/base64-to-file@adaa40c0c581f276132199d4cf60afa07ce60eac # v1.2
uses: timheuer/base64-to-file@v1.2
with:
# https://gregoryszorc.com/docs/apple-codesign/stable/apple_codesign_rcodesign.html#notarizing-and-stapling
fileName: rustdesk.json
@@ -129,19 +129,19 @@ jobs:
brew install llvm create-dmg nasm pkg-config
- name: Install flutter
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version: ${{ matrix.job.flutter }}
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
uses: dtolnay/rust-toolchain@v1
with:
toolchain: ${{ env.RUST_VERSION }}
targets: ${{ matrix.job.target }}
components: "rustfmt"
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
- uses: Swatinem/rust-cache@v2
with:
prefix-key: ${{ matrix.job.os }}
@@ -156,7 +156,7 @@ jobs:
~/.cargo/bin/flutter_rust_bridge_codegen --rust-input ./src/flutter_ffi.rs --dart-output ./flutter/lib/generated_bridge.dart --c-output ./flutter/macos/Runner/bridge_generated.h
- name: Setup vcpkg with Github Actions binary cache
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
uses: lukka/run-vcpkg@v11
with:
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
@@ -165,7 +165,7 @@ jobs:
$VCPKG_ROOT/vcpkg install --x-install-root="$VCPKG_ROOT/installed"
- name: Restore from cache and install vcpkg
uses: lukka/run-vcpkg@8a5116de2b552d6fc8894e9774aacaf2e5db4823 # v7 2026-05-26
uses: lukka/run-vcpkg@v7
if: false
with:
setupOnly: true
@@ -222,7 +222,7 @@ jobs:
done
- name: Publish DMG package
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
uses: softprops/action-gh-release@v1
with:
prerelease: true
tag_name: ${{ env.TAG_NAME }}
@@ -247,7 +247,7 @@ jobs:
}
steps:
- name: Checkout source code
uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
uses: actions/checkout@v3
with:
ref: ${{ matrix.job.ref }}
submodules: recursive
@@ -290,13 +290,13 @@ jobs:
wget
- name: Install flutter
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version: ${{ env.FLUTTER_VERSION }}
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
uses: dtolnay/rust-toolchain@v1
with:
toolchain: ${{ env.RUST_VERSION }}
components: "rustfmt"
@@ -310,14 +310,14 @@ jobs:
pushd flutter ; flutter pub get ; popd
~/.cargo/bin/flutter_rust_bridge_codegen --rust-input ./src/flutter_ffi.rs --dart-output ./flutter/lib/generated_bridge.dart
- uses: nttld/setup-ndk@ed92fe6cadad69be94a966a7ee3271275e62f779 # v1
- uses: nttld/setup-ndk@v1
id: setup-ndk
with:
ndk-version: ${{ env.NDK_VERSION }}
add-to-path: true
- name: Setup vcpkg with Github Actions binary cache
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
uses: lukka/run-vcpkg@v11
with:
vcpkgDirectory: /opt/artifacts/vcpkg
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
@@ -395,7 +395,7 @@ jobs:
mkdir -p signed-apk; pushd signed-apk
mv ../rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.apk ./rustdesk-test-${{ matrix.job.ref }}-${{ matrix.job.ndk }}.apk
- uses: r0adkll/sign-android-release@349ebdef58775b1e0d8099458af0816dc79b6407 # v1
- uses: r0adkll/sign-android-release@v1
name: Sign app APK
if: env.ANDROID_SIGNING_KEY != null
id: sign-rustdesk
@@ -410,7 +410,7 @@ jobs:
BUILD_TOOLS_VERSION: "30.0.2"
- name: Publish signed apk package
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
uses: softprops/action-gh-release@v1
with:
prerelease: true
tag_name: ${{ env.TAG_NAME }}

View File

@@ -39,21 +39,22 @@ jobs:
build_output_dir: RustDeskTempTopMostWindow/WindowInjection/${{ inputs.platform }}/${{ inputs.configuration }}
steps:
- name: Add MSBuild to PATH
uses: microsoft/setup-msbuild@6fb02220983dee41ce7ae257b6f4d8f9bf5ed4ce # v2
uses: microsoft/setup-msbuild@v2
- name: Download the source code
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 ecd8d6a139eee76845ea66423fb739af450fda90
cd RustDeskTempTopMostWindow && git checkout 53b548a5398624f7149a382000397993542ad796
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
uses: actions/upload-artifact@master
if: ${{ inputs.upload-artifact }}
with:
name: topmostwindow-artifacts-${{ inputs.platform }}
name: topmostwindow-artifacts
path: |
./${{ env.build_output_dir }}/WindowInjection.dll

View File

@@ -1,71 +0,0 @@
name: Update webpki-roots
# Weekly refresh of the compiled-in TLS root certificates (the webpki-roots
# crate, a snapshot of the Mozilla root store). Roots are otherwise frozen at
# whatever Cargo.lock pins, so old builds miss newly added CAs and keep
# removed (distrusted) ones. Changes go through a PR on purpose: added or
# removed roots should be reviewed, not silently baked into releases.
#
# Note: PRs created with the default GITHUB_TOKEN do not trigger other
# workflows (GitHub limitation). Close and reopen the PR, or push to its
# branch, to run CI on it.
on:
schedule:
- cron: "0 3 * * 1"
workflow_dispatch:
# A manual dispatch overlapping the weekly run would race it force-pushing
# the same branch; queue instead of overlapping, and never cancel a run
# that may have already pushed.
concurrency:
group: update-webpki-roots
cancel-in-progress: false
jobs:
update:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
env:
BRANCH: auto-update-webpki-roots
steps:
- name: Checkout source code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- 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

15
.github/workflows/winget.yml vendored Normal file
View File

@@ -0,0 +1,15 @@
name: Publish to WinGet
on:
release:
types: [released]
workflow_dispatch:
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: vedantmgoyal9/winget-releaser@main
with:
identifier: RustDesk.RustDesk
version: "1.4.5"
release-tag: "1.4.5"
token: ${{ secrets.WINGET_TOKEN }}

5
.gitignore vendored
View File

@@ -3,7 +3,6 @@
.vscode
.idea
.DS_Store
.env
libsciter-gtk.so
src/ui/inline.rs
extractor
@@ -55,6 +54,4 @@ examples/**/target/
vcpkg_installed
flutter/lib/generated_plugin_registrant.dart
libsciter.dylib
flutter/web/
# libdrmtap is cloned at build time by build.py (not a submodule)
/third_party/libdrmtap/
flutter/web/

105
AGENTS.md
View File

@@ -1,105 +0,0 @@
# RustDesk Guide
## Project Layout
### Directory Structure
* `src/` Rust app
* `src/server/` audio / clipboard / input / video / network
* `src/platform/` platform-specific code
* `src/ui/` legacy Sciter UI (deprecated)
* `flutter/` current UI
* `libs/hbb_common/` config / proto / shared utils
* `libs/scrap/` screen capture
* `libs/enigo/` input control
* `libs/clipboard/` clipboard
* `libs/hbb_common/src/config.rs` all options
### Key Components
- **Remote Desktop Protocol**: Custom protocol implemented in `src/rendezvous_mediator.rs` for communicating with rustdesk-server
- **Screen Capture**: Platform-specific screen capture in `libs/scrap/`
- **Input Handling**: Cross-platform input simulation in `libs/enigo/`
- **Audio/Video Services**: Real-time audio/video streaming in `src/server/`
- **File Transfer**: Secure file transfer implementation in `libs/hbb_common/`
### UI Architecture
- **Legacy UI**: Sciter-based (deprecated) - files in `src/ui/`
- **Modern UI**: Flutter-based - files in `flutter/`
- Desktop: `flutter/lib/desktop/`
- Mobile: `flutter/lib/mobile/`
- Shared: `flutter/lib/common/` and `flutter/lib/models/`
## Rust Rules
* Avoid `unwrap()` / `expect()` in production code.
* Exceptions:
* tests;
* lock acquisition where failure means poisoning, not normal control flow.
* Otherwise prefer `Result` + `?` or explicit handling.
* Do not ignore errors silently.
* Avoid unnecessary `.clone()`.
* Prefer borrowing when practical.
* Do not add dependencies unless needed.
* Keep code simple and idiomatic.
## Tokio Rules
* Assume a Tokio runtime already exists.
* Never create nested runtimes.
* Never call `Runtime::block_on()` inside Tokio / async code.
* Do not hide runtime creation inside helpers or libraries.
* Do not hold locks across `.await`.
* Prefer `.await`, `tokio::spawn`, channels.
* Use `spawn_blocking` or dedicated threads for blocking work.
* Do not use `std::thread::sleep()` in async code.
## Editing Hygiene
* Change only what is required.
* Prefer the smallest valid diff.
* Do not refactor unrelated code.
* Do not make formatting-only changes.
* Keep naming/style consistent with nearby code.
### Comments
* Keep them short: one line by default, three at most.
* Say **why**, never what. If the code already says it, delete the comment.
* A comment must never be longer than the code it describes.
* Applies to YAML, shell and Python too, not just Rust.
### Be minimally invasive
* Prefer purely additive changes: layer new (`#[cfg]`-gated) blocks or new functions around existing code instead of restructuring it. The ideal diff for a fix adds lines and modifies/deletes none.
* Do not extract or reshape existing code just to enable your new code; look for a mechanism that leaves existing lines untouched (e.g. hide/show an existing object instead of refactoring its construction into a helper for rebuilding).
* Put new logic in self-contained functions in the module it belongs to (platform-specific logic in `src/platform/`, with `use` inside the function body to avoid churning shared import blocks). Call sites in shared files (`src/tray.rs`, `src/core_main.rs`, `src/server/connection.rs`, …) should be thin one-line hooks.
## Localization (`src/lang/*.rs`)
Each file is a `HashMap<key, translation>`. Layout:
* `template.rs` is the master list of every key. **Never edit it** as part of translation work.
* `en.rs` holds only the keys whose English display text differs from the key itself.
* Every other file (`de.rs`, `fr.rs`, …) carries the full key set; an untranslated entry has an empty value: `("key", "")`.
### Finding the English source for a key
When filling an empty entry, determine the source English text with this rule:
* If `key` exists in `en.rs` **with a non-empty value**, that value is the source text (look it up in `en.rs`).
* Otherwise the **key string itself is the source text** (the key is already plain English).
Then translate that source into the file's target language (infer the language from the file's existing non-empty entries / filename).
### Translation hygiene
* Only fill empty values. Never change keys, and never touch existing non-empty translations.
* 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.

View File

@@ -1 +1,91 @@
AGENTS.md
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Development Commands
### Build Commands
- `cargo run` - Build and run the desktop application (requires libsciter library)
- `python3 build.py --flutter` - Build Flutter version (desktop)
- `python3 build.py --flutter --release` - Build Flutter version in release mode
- `python3 build.py --hwcodec` - Build with hardware codec support
- `python3 build.py --vram` - Build with VRAM feature (Windows only)
- `cargo build --release` - Build Rust binary in release mode
- `cargo build --features hwcodec` - Build with specific features
### Flutter Mobile Commands
- `cd flutter && flutter build android` - Build Android APK
- `cd flutter && flutter build ios` - Build iOS app
- `cd flutter && flutter run` - Run Flutter app in development mode
- `cd flutter && flutter test` - Run Flutter tests
### Testing
- `cargo test` - Run Rust tests
- `cd flutter && flutter test` - Run Flutter tests
### Platform-Specific Build Scripts
- `flutter/build_android.sh` - Android build script
- `flutter/build_ios.sh` - iOS build script
- `flutter/build_fdroid.sh` - F-Droid build script
## Project Architecture
### Directory Structure
- **`src/`** - Main Rust application code
- `src/ui/` - Legacy Sciter UI (deprecated, use Flutter instead)
- `src/server/` - Audio/clipboard/input/video services and network connections
- `src/client.rs` - Peer connection handling
- `src/platform/` - Platform-specific code
- **`flutter/`** - Flutter UI code for desktop and mobile
- **`libs/`** - Core libraries
- `libs/hbb_common/` - Video codec, config, network wrapper, protobuf, file transfer utilities
- `libs/scrap/` - Screen capture functionality
- `libs/enigo/` - Platform-specific keyboard/mouse control
- `libs/clipboard/` - Cross-platform clipboard implementation
### Key Components
- **Remote Desktop Protocol**: Custom protocol implemented in `src/rendezvous_mediator.rs` for communicating with rustdesk-server
- **Screen Capture**: Platform-specific screen capture in `libs/scrap/`
- **Input Handling**: Cross-platform input simulation in `libs/enigo/`
- **Audio/Video Services**: Real-time audio/video streaming in `src/server/`
- **File Transfer**: Secure file transfer implementation in `libs/hbb_common/`
### UI Architecture
- **Legacy UI**: Sciter-based (deprecated) - files in `src/ui/`
- **Modern UI**: Flutter-based - files in `flutter/`
- Desktop: `flutter/lib/desktop/`
- Mobile: `flutter/lib/mobile/`
- Shared: `flutter/lib/common/` and `flutter/lib/models/`
## Important Build Notes
### Dependencies
- Requires vcpkg for C++ dependencies: `libvpx`, `libyuv`, `opus`, `aom`
- Set `VCPKG_ROOT` environment variable
- Download appropriate Sciter library for legacy UI support
### Ignore Patterns
When working with files, ignore these directories:
- `target/` - Rust build artifacts
- `flutter/build/` - Flutter build output
- `flutter/.dart_tool/` - Flutter tooling files
### Cross-Platform Considerations
- Windows builds require additional DLLs and virtual display drivers
- macOS builds need proper signing and notarization for distribution
- Linux builds support multiple package formats (deb, rpm, AppImage)
- Mobile builds require platform-specific toolchains (Android SDK, Xcode)
### Feature Flags
- `hwcodec` - Hardware video encoding/decoding
- `vram` - VRAM optimization (Windows only)
- `flutter` - Enable Flutter UI
- `unix-file-copy-paste` - Unix file clipboard support
- `screencapturekit` - macOS ScreenCaptureKit (macOS only)
### Config
All configurations or options are under `libs/hbb_common/src/config.rs` file, 4 types:
- Settings
- Local
- Display
- Built-in

480
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "rustdesk"
version = "1.4.9"
version = "1.4.5"
authors = ["rustdesk <info@rustdesk.com>"]
edition = "2021"
build= "build.rs"
@@ -22,6 +22,7 @@ path = "src/service.rs"
[features]
inline = []
cli = []
use_samplerate = ["samplerate"]
use_rubato = ["rubato"]
use_dasp = ["dasp"]
@@ -30,13 +31,6 @@ default = ["use_dasp"]
hwcodec = ["scrap/hwcodec"]
vram = ["scrap/vram"]
mediacodec = ["scrap/mediacodec"]
drm = ["scrap/drm"]
# The display wake, as its OWN compile gate on top of `drm`. Everything else in the drm backend
# READS (it captures a scanout); the wake WRITES, injecting one synthetic pointer event from the
# root service so a compositor that idle-disabled its outputs re-enables them. That is a different
# kind of operation and deserves a switch that can remove it from the binary entirely, without
# giving up DRM capture: `--features drm` builds the capture path with no wake code compiled in.
drm-wake = ["drm"]
plugin_framework = []
linux-pkg-config = ["magnum-opus/linux-pkg-config", "scrap/linux-pkg-config"]
unix-file-copy-paste = [
@@ -68,6 +62,8 @@ 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"
@@ -86,7 +82,7 @@ shutdown_hooks = "0.1"
totp-rs = { version = "5.4", default-features = false, features = ["gen_secret", "otpauth"] }
stunclient = "0.4"
kcp-sys= { git = "https://github.com/rustdesk-org/kcp-sys"}
reqwest = { version = "0.12", features = ["blocking", "socks", "json", "native-tls", "rustls-tls", "rustls-tls-native-roots", "gzip", "zstd"], default-features=false }
reqwest = { version = "0.12", features = ["blocking", "socks", "json", "native-tls", "rustls-tls", "rustls-tls-native-roots", "gzip"], default-features=false }
[target.'cfg(not(target_os = "linux"))'.dependencies]
# https://github.com/rustdesk/rustdesk/discussions/10197, not use cpal on linux
@@ -131,18 +127,14 @@ windows = { version = "0.61", features = [
"Win32_Security_Authorization",
"Win32_Storage_FileSystem",
"Win32_System",
"Win32_System_Com",
"Win32_System_Diagnostics",
"Win32_System_Diagnostics_ToolHelp",
"Win32_System_Environment",
"Win32_System_IO",
"Win32_System_Memory",
"Win32_System_Pipes",
"Win32_System_Registry",
"Win32_System_SystemInformation",
"Win32_System_Threading",
"Win32_UI_Shell",
"Win32_UI_WindowsAndMessaging",
] }
winreg = "0.11"
windows-service = "0.6"
@@ -151,6 +143,7 @@ remote_printer = { path = "libs/remote_printer" }
impersonate_system = { git = "https://github.com/rustdesk-org/impersonate-system" }
shared_memory = "0.12"
tauri-winrt-notification = "0.1"
runas = "1.2"
[target.'cfg(target_os = "macos")'.dependencies]
objc = "0.2"
@@ -167,7 +160,7 @@ piet-coregraphics = "0.6"
foreign-types = "0.3"
[target.'cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))'.dependencies]
tray-icon = { git = "https://github.com/tauri-apps/tray-icon", version = "0.21.3" }
tray-icon = { git = "https://github.com/tauri-apps/tray-icon" }
tao = { git = "https://github.com/rustdesk-org/tao", branch = "dev" }
image = "0.24"
@@ -220,7 +213,7 @@ exclude = ["vdi/host", "examples/custom_plugin"]
libxdo-sys = { path = "libs/libxdo-sys-stub" }
[package.metadata.winres]
LegalCopyright = "Copyright © 2026 Purslane Tech Pte. Ltd. All rights reserved."
LegalCopyright = "Copyright © 2025 Purslane Ltd. All rights reserved."
ProductName = "RustDesk"
FileDescription = "RustDesk Remote Desktop"
OriginalFilename = "rustdesk.exe"
@@ -252,6 +245,3 @@ panic = 'abort'
strip = true
#opt-level = 'z' # only have smaller size after strip
rpath = true
[profile.dev]
debug = 1

View File

@@ -1 +0,0 @@
AGENTS.md

View File

@@ -38,7 +38,7 @@ RustDesk welcomes contribution from everyone. See [CONTRIBUTING.md](docs/CONTRIB
## Dependencies
Desktop versions use Flutter or Sciter (deprecated) for GUI. This tutorial is for Sciter only, since it is easier and more friendly to start. Check out our [CI](https://github.com/rustdesk/rustdesk/blob/master/.github/workflows/flutter-build.yml) for building the Flutter version.
Desktop versions use Flutter or Sciter (deprecated) for GUI, this tutorial is for Sciter only, since it is easier and more friendly to start. Check out our [CI](https://github.com/rustdesk/rustdesk/blob/master/.github/workflows/flutter-build.yml) for building Flutter version.
Please download Sciter dynamic library yourself.

View File

@@ -18,7 +18,7 @@ AppDir:
id: rustdesk
name: rustdesk
icon: rustdesk
version: 1.4.9
version: 1.4.5
exec: usr/share/rustdesk/rustdesk
exec_args: $@
apt:

View File

@@ -18,7 +18,7 @@ AppDir:
id: rustdesk
name: rustdesk
icon: rustdesk
version: 1.4.9
version: 1.4.5
exec: usr/share/rustdesk/rustdesk
exec_args: $@
apt:

551
build.py
View File

@@ -1,33 +1,23 @@
#!/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:
win_arch = 'arm64' if platform.machine().lower() in ('arm64', 'aarch64') else 'x64'
flutter_build_dir = f'build/windows/{win_arch}/runner/Release/'
flutter_build_dir = 'build/windows/x64/runner/Release/'
elif osx:
flutter_build_dir = 'build/macos/Build/Products/Release/'
else:
@@ -139,19 +129,6 @@ def make_parser():
action='store_true',
help='Build with unix file copy paste feature'
)
parser.add_argument(
'--drm',
action='store_true',
help='Linux only: build the DRM/KMS capture backend (bundles libdrmtap.so, '
'dlopen-ed in-process by the root service). Off by default.'
)
parser.add_argument(
'--print-features',
action='store_true',
help='Print the cargo feature list these flags select, and exit without building. For a '
'caller that runs its own cargo line and then packages with --skip-cargo: it can ask '
'for the list rather than repeat it, so the two cannot drift.'
)
parser.add_argument(
'--skip-cargo',
action='store_true',
@@ -195,7 +172,7 @@ def generate_build_script_for_docker():
# flutter_rust_bridge
dart pub global activate ffigen --version 5.0.1
pushd /tmp && git clone https://github.com/SoLongAndThanksForAllThePizza/flutter_rust_bridge --depth=1 && popd
pushd /tmp/flutter_rust_bridge/frb_codegen && cargo install --path . --locked && popd
pushd /tmp/flutter_rust_bridge/frb_codegen && cargo install --path . && popd
pushd flutter && flutter pub get && popd
~/.cargo/bin/flutter_rust_bridge_codegen --rust-input ./src/flutter_ffi.rs --dart-output ./flutter/lib/generated_bridge.dart
# install vcpkg
@@ -294,24 +271,6 @@ def external_resources(flutter, args, res_dir):
shutil.copytree(f, f'{flutter_build_dir_2}{f.stem}')
def linux_packaging_branch():
"""Which packaging path `main()` will take on THIS host.
MUST mirror the elif chain in main() (pacman / yum / zypper / else), and exists so `--drm` can
refuse a branch that is not drm-aware instead of silently producing a stock-named package with
the capture backend compiled in. Only the final `deb` branch reaches `build_flutter_deb`, which
is what bundles libdrmtap, renames the package, adds Conflicts/Provides and asserts the staged
binary really is a drm build.
"""
if os.path.isfile('/usr/bin/pacman'):
return 'pacman'
if os.path.isfile('/usr/bin/yum'):
return 'yum'
if os.path.isfile('/usr/bin/zypper'):
return 'zypper'
return 'deb'
def get_features(args):
features = ['inline'] if not args.flutter else []
if args.hwcodec:
@@ -322,30 +281,6 @@ def get_features(args):
features.append('flutter')
if args.unix_file_copy_paste:
features.append('unix-file-copy-paste')
if args.drm:
# Say so rather than quietly handing back a stock build: the backend is Linux-only, so on
# any other host the flag cannot be honoured and the resulting binary would look like a
# DRM build without being one.
if windows or osx:
raise Exception('--drm is Linux only')
# And only on the deb branch. The other three Linux paths (pacman/yum/zypper) package
# straight from `target/release` without bundling libdrmtap, without the rename, without
# Conflicts/Provides and without assert_staged_binary_is_drm() -- so they would emit a
# package NAMED `rustdesk` carrying the consent-bypass backend and the root-side uinput
# injection. The separate package name is the informed consent this feature rests on, so
# refuse rather than ship a stock-named build of it.
branch = linux_packaging_branch()
if branch != 'deb':
raise Exception(
f'--drm is only supported on the deb packaging path; this host would package via '
f'{branch}, which cannot bundle libdrmtap or name the package distinctly')
features.append('drm')
# The display wake is its own compile gate on top of `drm`, and the unattended package is
# exactly where it belongs: that variant exists to reach a machine nobody is sitting at,
# and a machine whose screen went dark is the case it is for. Dropping `drm-wake` from
# this line builds the same capture backend with no wake code in the binary at all.
# It is ALSO switchable at runtime; see OPTION_ENABLE_DRM_DISPLAY_WAKE.
features.append('drm-wake')
if osx:
if args.screencapturekit:
features.append('screencapturekit')
@@ -364,7 +299,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-0, libxcb-randr0, libxdo3 | libxdo4, libxfixes3, libxcb-shape0, libxcb-xfixes0, libasound2, libsystemd0, curl, libva2, libva-drm2, libva-x11-2, libgstreamer-plugins-base1.0-0, libpam0g, gstreamer1.0-pipewire%s
Recommends: libayatana-appindicator3-1
Description: A remote control software.
@@ -380,325 +315,9 @@ def ffi_bindgen_function_refactor():
'sed -i "s/ffi.NativeFunction<ffi.Bool Function(DartPort/ffi.NativeFunction<ffi.Uint8 Function(DartPort/g" flutter/lib/generated_bridge.dart')
# libdrmtap is fetched at build time from the rustdesk-org fork at a pinned
# commit — the same way rustdesk sources its other native build deps (vcpkg,
# flutter_rust_bridge, ...), rather than carrying a git submodule. It is the ONLY
# pin for the drm backend: rustdesk dlopens this .so at runtime and does not depend on
# the libdrmtap-sys crate (whose build.rs would statically link the C tree, a helper and
# libdrm/seccomp/cap). DRMTAP_REPO, DRMTAP_SHA and DRMTAP_PREBUILT_DIR override it for local testing
# or another fork, and each requires DRMTAP_ALLOW_UNPINNED=1 alongside it (see below).
# The commit is fetched directly by sha, so no branch or tag name takes part in the build: see
# build_libdrmtap_so(). This is the SINGLE source of truth for the pin, deliberately not duplicated in
# any workflow, so a bump is one edit here (plus the informational version comment in
# libs/scrap/Cargo.toml). This commit is libdrmtap v0.5.2.
LIBDRMTAP_REPO_PINNED = 'https://github.com/rustdesk-org/libdrmtap'
LIBDRMTAP_SHA_PINNED = '653de8c774bc245eaf960611ca7a136f7a544d21'
LIBDRMTAP_REPO = os.environ.get('DRMTAP_REPO', LIBDRMTAP_REPO_PINNED)
LIBDRMTAP_SHA = os.environ.get('DRMTAP_SHA', LIBDRMTAP_SHA_PINNED)
# Every way of getting a different .so than the pin needs the same explicit opt-in. Otherwise the
# claim this feature rests on -- that the privileged capture library is the reviewed object at
# LIBDRMTAP_SHA_PINNED -- would hold only as long as nobody happened to have one of these set, and a
# build that silently used something else would be indistinguishable from one that did not.
# DRMTAP_PREBUILT_DIR is in the list because it is the widest of the three: it skips both the fetch
# and the sha verification and hands over an object built from nothing this script can see.
DRMTAP_UNPINNED_OK = os.environ.get('DRMTAP_ALLOW_UNPINNED') == '1'
def _prebuilt_dir_is_the_pinned_checkout(prebuilt_dir):
# A .so built from this repo's own third_party/libdrmtap at the pinned sha is the pinned object,
# not an override, so it must not need the opt-in. This is how CI hands the library from a step
# that has meson to a packaging container that does not.
src = os.path.join(REPO_ROOT, 'third_party', 'libdrmtap')
try:
inside = os.path.commonpath([os.path.abspath(prebuilt_dir), src]) == src
except ValueError:
return False
if not inside or not os.path.isdir(os.path.join(src, '.git')):
return False
try:
head = subprocess.check_output(['git', '-C', src, 'rev-parse', 'HEAD']).decode().strip()
except (subprocess.SubprocessError, OSError):
return False
return head == LIBDRMTAP_SHA
def _validate_libdrmtap_pin():
# Called from build_libdrmtap_so(), NOT at import: a stock (non --drm) build must stay
# byte-identical to upstream in behaviour too, and leftover DRMTAP_* variables in the
# environment (or a malformed sha) must not be able to fail a build that never touches
# libdrmtap.
# `or None` so an empty value reads as unset here exactly as it does in build_libdrmtap_so(),
# which tests it for truthiness.
prebuilt = os.environ.get('DRMTAP_PREBUILT_DIR') or None
if prebuilt and _prebuilt_dir_is_the_pinned_checkout(prebuilt):
prebuilt = None
overridden = [
name
for name, value, pinned in (
('DRMTAP_REPO', LIBDRMTAP_REPO, LIBDRMTAP_REPO_PINNED),
('DRMTAP_SHA', LIBDRMTAP_SHA, LIBDRMTAP_SHA_PINNED),
('DRMTAP_PREBUILT_DIR', prebuilt, None),
)
if value != pinned
]
if overridden and not DRMTAP_UNPINNED_OK:
raise Exception(
f'{", ".join(overridden)} would build libdrmtap from something other than the pinned '
f'{LIBDRMTAP_REPO_PINNED} at {LIBDRMTAP_SHA_PINNED}. That is supported for local work and '
'cross-builds, but it has to be deliberate: set DRMTAP_ALLOW_UNPINNED=1 as well.')
if overridden:
print(f'WARNING: libdrmtap is NOT the pinned build ({", ".join(overridden)} set)')
# Both are interpolated into shell commands below, and both are env-overridable, so validate
# their SHAPE before they get there. This is not only about a hostile environment: a truncated
# or abbreviated sha would otherwise reach `git fetch` and fail with something far less obvious
# than saying so here, and an abbreviated one would defeat the point of pinning.
if not re.fullmatch(r'[0-9a-f]{40}', LIBDRMTAP_SHA):
raise Exception(
f'DRMTAP_SHA must be a full 40-character commit sha, got {LIBDRMTAP_SHA!r}')
if not re.fullmatch(r'(https://|git@)[A-Za-z0-9._~:/@-]+', LIBDRMTAP_REPO):
raise Exception(f'DRMTAP_REPO does not look like a git remote url: {LIBDRMTAP_REPO!r}')
def _single_real_so(paths, where):
# Return the one real libdrmtap.so.0.* object among `paths`, failing if there are zero or several.
# glob order is arbitrary, so silently taking [0] could ship a stale or wrong-arch object left
# over from an earlier build; a mismatch should fail the build loudly instead.
real = sorted(p for p in paths if os.path.isfile(p) and not os.path.islink(p))
if len(real) != 1:
raise Exception(
f'expected exactly one real libdrmtap.so.0.* in {where}, found {len(real)}: {real}')
return real[0]
def build_libdrmtap_so():
# Build libdrmtap.so from the rustdesk-org fork, fetched at the pinned LIBDRMTAP_SHA. The
# pivot dlopen-s this .so in-process in the root service (which already holds
# CAP_SYS_ADMIN) — no setcap helper, no privileged child. Only the shared
# library target is built (the source also carries a helper binary we do not
# ship). Returns the path to the built versioned .so (e.g. libdrmtap.so.0.4.x).
_validate_libdrmtap_pin()
# Allow a caller (e.g. CI) to build the .so ahead of time and hand it in via
# DRMTAP_PREBUILT_DIR (must contain the real libdrmtap.so.0.* object).
prebuilt_dir = os.environ.get('DRMTAP_PREBUILT_DIR')
if prebuilt_dir:
# DRMTAP_PREBUILT_DIR explicitly names the artifact source, so honor it strictly: fail
# (rather than silently falling back to a source build) if it holds no single real .so.
prebuilt = glob.glob(os.path.join(prebuilt_dir, 'libdrmtap.so.0.*'))
so = _single_real_so(prebuilt, f'DRMTAP_PREBUILT_DIR={prebuilt_dir}')
# Check the stub case HERE too, not only on the source path below. This is the widest
# override of the three -- no fetch, no sha verification, an object built by something this
# script cannot see -- so it is the likeliest to hand over a CPU-only build, and skipping the
# assertion on exactly this path would leave the check guarding only the case that was
# already trustworthy.
_assert_so_has_egl(so)
return so
# Fetch the pinned source if it is not already present. third_party/libdrmtap is not a submodule
# anymore; it is git-ignored. The commit is fetched BY SHA rather than by cloning a branch:
# `clone --depth 1 --branch main` only ever fetches the tip, so the moment upstream pushes to
# `main` the pinned commit is not in the shallow clone at all and the build fails on an unreachable
# object. Fetching the sha needs no branch name, so it keeps working across every upstream push and
# is immune to a ref being moved or repointed.
src = os.path.join(REPO_ROOT, 'third_party', 'libdrmtap')
if not os.path.exists(os.path.join(src, 'meson.build')):
if os.path.isdir(src):
shutil.rmtree(src)
os.makedirs(src, exist_ok=True)
system2(f'git -C "{src}" init -q')
system2(f'git -C "{src}" remote add origin {LIBDRMTAP_REPO}')
system2(f'git -C "{src}" fetch --depth 1 origin {LIBDRMTAP_SHA}')
system2(f'git -C "{src}" checkout -q FETCH_HEAD')
# Verify the pin whenever the source is a GIT checkout. A fetch by sha cannot resolve to anything
# else, so this now guards the OTHER case: a reused checkout left by an earlier build at a
# different pin, which is what a bump leaves behind. Reject and remove it so the next run re-fetches
# cleanly. A NON-git tree placed here on purpose (a developer building unreleased local libdrmtap
# source) has nothing to verify and is used as-is.
if os.path.isdir(os.path.join(src, '.git')):
got_sha = subprocess.check_output(
['git', '-C', src, 'rev-parse', 'HEAD']).decode().strip()
if got_sha != LIBDRMTAP_SHA:
shutil.rmtree(src, ignore_errors=True)
raise Exception(
f'libdrmtap at {src} is {got_sha}, expected {LIBDRMTAP_SHA} '
f'(stale checkout from a different pin; removed, re-run to re-fetch)')
build_dir = os.path.join(src, 'build-pkg')
if not os.path.exists(os.path.join(build_dir, 'build.ninja')):
system2(f'meson setup "{build_dir}" "{src}" --buildtype=release')
# Build only the shared library, not the bundled helper binary or the static archive. Since
# libdrmtap 0.4.11 the project is `both_libraries` (a version-scripted .so + a static .a), so the
# bare `drmtap` target is ambiguous ("drmtap:shared_library" vs "drmtap:static_library"); ask for
# the shared one explicitly (rustdesk dlopens the .so and never needs the archive).
system2(f'meson compile -C "{build_dir}" drmtap:shared_library')
sos = glob.glob(os.path.join(build_dir, 'libdrmtap.so.0.*'))
# keep the real object (libdrmtap.so.0.4.x), not the .so/.so.0 symlinks or meson's .p dir, and
# require exactly one so a stale object from an earlier build is never silently picked.
so = _single_real_so(sos, f'the libdrmtap meson build dir {build_dir}')
_assert_so_has_egl(so)
return so
def _assert_so_has_egl(so_path):
# libdrmtap treats egl/glesv2 as OPTIONAL dependencies: without their headers and pkg-config
# files, meson silently builds a CPU-only stub. That stub still exports every symbol the loader
# checks for, so nothing downstream notices -- and the split architecture depends entirely on the
# unprivileged side EGL-detiling the scanout it receives. The result is a build where DRM capture
# quietly degrades to PipeWire on every tiled-scanout host, which is most of them.
#
# Assert on the ARTIFACT rather than passing an option that demands it: `-Degl=enabled` exists
# only in libdrmtap past 0.4.15, and checking what was actually produced also catches a stale or
# hand-substituted object, which a build flag cannot.
#
# EGL is reached by lazy dlopen, on purpose, so that the privileged service never links the GPU
# stack. That means there is no DT_NEEDED to look for and an ELF-level check reports "no EGL" on a
# perfectly good library; the dlopen name and an extension symbol are what a CPU-only stub really
# lacks.
try:
with open(so_path, 'rb') as f:
blob = f.read()
except OSError as err:
raise Exception(f'cannot read the built libdrmtap at {so_path}: {err}') from err
missing = [m for m in (b'libEGL.so.1', b'eglCreateImageKHR') if m not in blob]
if missing:
raise Exception(
f'{so_path} looks like a CPU-only libdrmtap stub (missing '
f'{", ".join(m.decode() for m in missing)}): the EGL detile path the split capture '
'depends on is not in it, and DRM capture would silently fall back to PipeWire. '
'Install the EGL development packages and rebuild (Debian/Ubuntu: libegl-dev '
'libgles2-mesa-dev; Arch: mesa libglvnd).')
DRM_PACKAGE_NAME = 'rustdesk-unattended-wayland'
def assert_so_satisfies_the_runtime_abi_gate(so_path):
"""The .so we are about to ship must be one the RUNTIME will actually accept.
`abi_accepted` in libs/scrap/src/common/drmtap_dl.rs is the only place the pinned library's
version is ever validated, and it runs at dlopen time on the USER's machine. Nothing in the
build or in CI compared the two, so the pin and the gate could drift apart and every existing
assertion would still pass: the EGL check does not look at the version, the CI symbol contract
does not call drmtap_version(), and the deb-contents regex matches any `libdrmtap.so.0.X.Y`.
A green pipeline could therefore produce a deb in which DRM capture can never start, and the
only symptom on the host is one log line before it falls back to the portal.
So parse the gate out of the Rust and apply it here, to the object being staged. This is the
same rule, not a copy of the numbers: if someone bumps the constants, this reads the new ones.
"""
m = re.search(r'libdrmtap\.so\.(\d+)\.(\d+)\.(\d+)', os.path.basename(so_path))
if not m:
# Not a versioned soname (a local dev build, say). The gate cannot be evaluated, and
# inventing a verdict would be worse than saying so.
print(f'[drm] cannot read a version out of {so_path}; skipping the ABI-gate cross-check')
return
so_ver = tuple(int(g) for g in m.groups())
# REPO_ROOT, not abspath(__file__): both callers have chdir'd into flutter/ by now.
gate_path = os.path.join(REPO_ROOT, 'libs', 'scrap', 'src', 'common', 'drmtap_dl.rs')
with open(gate_path) as f:
gate_src = f.read()
def _const(name):
mm = re.search(rf'const {name}: c_int = (\d+);', gate_src)
return int(mm.group(1)) if mm else None
major, minor = _const('DRMTAP_ABI_MAJOR'), _const('DRMTAP_ABI_MINOR')
mm = re.search(r'const DRMTAP_MIN_MINOR_PATCH: \(c_int, c_int\) = \((\d+), (\d+)\);', gate_src)
floor = (int(mm.group(1)), int(mm.group(2))) if mm else None
if major is None or minor is None or floor is None:
raise Exception(
'could not parse the libdrmtap ABI gate out of drmtap_dl.rs (DRMTAP_ABI_MAJOR / '
'DRMTAP_ABI_MINOR / DRMTAP_MIN_MINOR_PATCH). The gate moved and this check did not; '
'fix the check rather than removing it, or the pin and the gate can drift silently.')
accepted = so_ver[0] == major and so_ver[1] == minor and (so_ver[1], so_ver[2]) >= floor
if not accepted:
raise Exception(
f'the libdrmtap being packaged is {so_ver[0]}.{so_ver[1]}.{so_ver[2]}, which the '
f'runtime loader would REFUSE: drmtap_dl.rs accepts exactly major {major}, minor '
f'{minor}, patch >= {floor[1]}. Shipping it produces a deb whose DRM capture can never '
'start. Move the build pin and the gate together, or fix whichever one is wrong.')
print(f'[drm] libdrmtap {so_ver[0]}.{so_ver[1]}.{so_ver[2]} satisfies the runtime ABI gate '
f'(major {major}, minor {minor}, patch >= {floor[1]})')
def stage_libdrmtap_into_deb(so_path):
# Put the built libdrmtap object plus its soname symlink into the staged deb. Only the soname
# symlink is needed: libdrmtap is resolved by ABSOLUTE path (/usr/lib/rustdesk/libdrmtap.so.0) at
# the in-process dlopen site (drmtap_dl.rs), so the deb does NOT drop /usr/lib/rustdesk into the
# system-wide /etc/ld.so.conf.d search path, which would let this private library shadow a system
# library for every binary on the host (Debian Policy 10.2 forbids that). No ld.so.conf.d drop-in
# and no ldconfig trigger are shipped, so the stock postinst is used unchanged.
assert_so_satisfies_the_runtime_abi_gate(so_path)
so_basename = os.path.basename(so_path)
system2('mkdir -p tmpdeb/usr/lib/rustdesk')
# Quoted: so_path comes from the repo root or from DRMTAP_PREBUILT_DIR, either of which can
# contain a space, and an unquoted interpolation would split the argument and fail obscurely.
system2(f'cp "{so_path}" tmpdeb/usr/lib/rustdesk/')
system2(f'ln -sf "{so_basename}" tmpdeb/usr/lib/rustdesk/libdrmtap.so.0')
def _max_glibc_minor(path):
# Read from .dynstr rather than via objdump so packaging needs no binutils; chunked because
# librustdesk.so is ~45 MB.
best = 0
with open(path, 'rb') as f:
tail = b''
while True:
chunk = f.read(1 << 20)
if not chunk:
return best
for m in re.finditer(rb'GLIBC_2\.(\d+)', tail + chunk):
best = max(best, int(m.group(1)))
tail = chunk[-16:]
def measured_glibc_floor():
# libdrmtap is built on a newer base than the rest of the deb, so the floor is whichever staged
# object is higher -- and it moves whenever either base does.
paths = [p for p in glob.glob('tmpdeb/usr/lib/rustdesk/libdrmtap.so.0.*')
+ glob.glob('tmpdeb/usr/share/rustdesk/lib/librustdesk.so')
+ glob.glob('tmpdeb/usr/share/rustdesk/rustdesk')
if os.path.isfile(p) and not os.path.islink(p)]
minor = max((_max_glibc_minor(p) for p in paths), default=0)
if not minor:
raise Exception(
f'could not measure a GLIBC_2.x floor from any staged object ({paths or "none found"}); '
'refusing to ship the unattended-wayland variant with an undeclared libc6 floor, which '
'is what lets it install on a host where libdrmtap can never load')
return f'2.{minor}'
def retarget_control_to_drm_variant():
# Rewrite the control file that generate_control_file just produced, instead of parameterizing that
# function: the stock packaging path stays exactly as upstream wrote it, and everything specific to
# this variant lives here. The variant installs the same files as the stock package, so it must
# conflict with and replace it: you install one or the other, never both. It also needs libdrmtap's
# own runtime deps, which the stock package has no reason to carry.
path = '../res/DEBIAN/control'
floor = measured_glibc_floor()
print(f'[drm] {DRM_PACKAGE_NAME} libc6 floor measured at {floor}')
with open(path) as f:
lines = f.readlines()
out = []
for line in lines:
if line.startswith('Package: rustdesk'):
out.append(f'Package: {DRM_PACKAGE_NAME}\n')
out.append('Conflicts: rustdesk\nReplaces: rustdesk\nProvides: rustdesk\n')
elif line.startswith('Depends:'):
# 2.4.101 is where drmModeGetFB2 landed; below it libdrmtap loads and can never capture.
out.append(line.rstrip('\n') + ', libdrm2 (>= 2.4.101), libegl1, libgles2, '
f'libc6 (>= {floor})\n')
else:
out.append(line)
body = ''.join(out)
# Fail loudly rather than silently shipping a package that says `rustdesk`: a stock control file
# that stopped matching either anchor would otherwise produce a variant deb wearing the stock name.
if f'Package: {DRM_PACKAGE_NAME}\n' not in body or 'libegl1' not in body:
raise Exception(f'could not retarget {path} to the drm variant; upstream control layout changed')
with open(path, 'w') as f:
f.write(body)
def build_flutter_deb(version, features):
if not skip_cargo:
system2(f'cargo build --locked --features {features} --lib --release')
system2(f'cargo build --features {features} --lib --release')
ffi_bindgen_function_refactor()
os.chdir('flutter')
system2('flutter build linux --release')
@@ -732,22 +351,9 @@ def build_flutter_deb(version, features):
'cp ../res/pam.d/rustdesk.debian tmpdeb/etc/pam.d/rustdesk')
system2(
"echo \"#!/bin/sh\" >> tmpdeb/usr/share/rustdesk/files/polkit && chmod a+x tmpdeb/usr/share/rustdesk/files/polkit")
# Bundle libdrmtap.so only when this build actually enabled the `drm` feature, so stock packages
# stay exactly what they were. The root service dlopens it in-process by absolute path.
# `features` is the comma-joined string, so split it: a bare substring test would also match any
# future feature merely containing "drm" (drm-lease, vaapi-drm) and rename the deb to the
# consent-bypass variant without --drm ever being passed.
ships_so = 'drm' in features.split(',')
if ships_so:
# Same artifact assertion as the --package path. Under --skip-cargo nothing here rebuilt the
# binary, so `features` says what was ASKED for while the staged bundle can be anything.
assert_staged_binary_is_drm()
stage_libdrmtap_into_deb(build_libdrmtap_so())
system2('mkdir -p tmpdeb/DEBIAN')
generate_control_file(version)
if ships_so:
retarget_control_to_drm_variant()
system2('cp -a ../res/DEBIAN/* tmpdeb/DEBIAN/')
md5_file_folder("tmpdeb/")
system2('dpkg-deb -b tmpdeb rustdesk.deb;')
@@ -755,68 +361,10 @@ def build_flutter_deb(version, features):
system2('/bin/rm -rf tmpdeb/')
system2('/bin/rm -rf ../res/DEBIAN/control')
os.rename('rustdesk.deb', '../rustdesk-%s.deb' % version)
if ships_so:
# Named apart from the stock package so installing the consent-free variant is a deliberate act.
os.rename('../rustdesk-%s.deb' % version, f'../{DRM_PACKAGE_NAME}-{version}.deb')
os.chdir("..")
DRMTAP_DLOPEN_MARKER = b'/usr/lib/rustdesk/libdrmtap.so.0'
# Present only when `drm-wake` is compiled in: the runtime option constant is itself
# #[cfg(feature = "drm-wake")] (src/ipc/drm.rs). The dlopen marker above cannot stand in for it -
# `--features drm` alone produces a binary that carries the dlopen path and NO wake code, and that
# is exactly the deb this assertion is here to refuse.
DRMTAP_WAKE_MARKER = b'enable-drm-display-wake'
def _carries_drmtap_marker(path, marker=DRMTAP_DLOPEN_MARKER):
# Chunked, with an overlap of len(marker)-1 so the marker cannot be missed at a chunk boundary:
# librustdesk.so is ~45 MB and there is no reason to hold it all in memory, and the `with`
# closes deterministically instead of relying on refcounting.
with open(path, 'rb') as f:
tail = b''
while True:
chunk = f.read(1 << 20)
if not chunk:
return False
if marker in tail + chunk:
return True
tail = chunk[-(len(marker) - 1):]
def assert_staged_binary_is_drm():
"""The staged BINARY must really be a drm build before it is named the unattended-wayland
variant. That package conflicts with and replaces the stock one, so shipping a stock binary
under that name produces something that can never capture and cannot be installed alongside
what it replaced. The marker is the absolute dlopen path from drmtap_dl.rs, present only when
the feature is compiled in -- assert what was produced, not what was asked for.
Called from BOTH packaging paths. It used to guard only one of them, and `--skip-cargo` (which
is how CI packages) reaches the other, where nothing had rebuilt the binary at all.
"""
binaries = [p for p in glob.glob('tmpdeb/usr/share/rustdesk/lib/librustdesk.so')
+ glob.glob('tmpdeb/usr/share/rustdesk/rustdesk') if os.path.isfile(p)]
if not any(_carries_drmtap_marker(p) for p in binaries):
raise Exception(
f'--drm was requested but the staged bundle does not look like a drm build (no '
f'{DRMTAP_DLOPEN_MARKER.decode()} dlopen path in {binaries or "any staged binary"}); '
'refusing to package it as the unattended-wayland variant, which conflicts with and '
'replaces the stock package but could never capture')
# And the WAKE half. `--drm` enables `drm-wake` too (see get_features), and the deb is named and
# documented as the variant that can reach a machine whose screen has gone dark. The dlopen
# marker above does not distinguish them: `--features drm` alone carries it and has no wake code
# at all. Asserting only the first half is how a deb can be named for a feature it does not have.
if not any(_carries_drmtap_marker(p, DRMTAP_WAKE_MARKER) for p in binaries):
raise Exception(
f'--drm was requested but the staged binary has no {DRMTAP_WAKE_MARKER.decode()} '
f'marker in {binaries or "any staged binary"}, so it was built without `drm-wake`; '
'refusing to package it as the unattended-wayland variant, which is named and '
'documented as the build that can wake an idle-disabled display. If this fired under '
'--skip-cargo, the cargo line that produced the bundle is missing the feature: '
'--features ...,drm,drm-wake')
def build_deb_from_folder(version, binary_folder, want_drm=False):
def build_deb_from_folder(version, binary_folder):
os.chdir('flutter')
system2('mkdir -p tmpdeb/usr/bin/')
system2('mkdir -p tmpdeb/usr/share/rustdesk')
@@ -840,53 +388,9 @@ def build_deb_from_folder(version, binary_folder, want_drm=False):
'cp ../res/rustdesk-link.desktop tmpdeb/usr/share/applications/rustdesk-link.desktop')
system2(
"echo \"#!/bin/sh\" >> tmpdeb/usr/share/rustdesk/files/polkit && chmod a+x tmpdeb/usr/share/rustdesk/files/polkit")
# Where the capture library comes from for a `--package <folder> --drm` build. Two shapes are
# supported, because two exist in practice: a bundle that already carries libdrmtap.so.0.*
# (someone staged it, e.g. a CI artifact), and a plain bundle, which is what every build path
# here actually produces -- the flutter deb builds the library straight into the staged deb, so
# nothing ever puts it inside the bundle folder. Demanding it in the bundle made this flag
# combination impossible to satisfy.
bundled_glob = glob.glob('tmpdeb/usr/share/rustdesk/libdrmtap.so.0.*')
bundle_carries_so = any(os.path.isfile(p) and not os.path.islink(p) for p in bundled_glob)
# The variant must be decided by the EXPLICIT --drm request, not merely by what happens to be
# staged: a bundle that carries the .so must NOT be shipped as the consent-bypass variant when
# --drm was never passed.
if bundle_carries_so and not want_drm:
raise Exception(
'the staged bundle carries libdrmtap.so.0.* but --drm was not passed; refusing '
'to silently ship the consent-bypass unattended-wayland variant (pass --drm to '
'build it deliberately)')
if want_drm:
# Whichever shape we are in, the staged BINARY must really be a drm build. This is the
# property the old presence-of-the-.so test stood in for, badly: a stock binary packaged as
# the unattended-wayland variant would carry the consent-bypass name, conflict with and
# replace the stock package, and never be able to capture. The marker is the absolute
# dlopen path from drmtap_dl.rs, present only when the feature is compiled in -- the same
# kind of artifact assertion as _assert_so_has_egl, and for the same reason: assert what
# was produced, not what was asked for.
assert_staged_binary_is_drm()
if bundle_carries_so:
so = _single_real_so(bundled_glob, 'the staged --drm bundle')
# The THIRD artifact source, and the last one that was missing the check: --package
# takes the .so straight out of a bundle somebody else produced, so it has the same
# exposure as DRMTAP_PREBUILT_DIR (see the comment on that branch). A CPU-only stub
# would ship, the loader would accept it, and capture would degrade to PipeWire
# without a word.
_assert_so_has_egl(so)
stage_libdrmtap_into_deb(so)
system2(f'rm -f "{so}"')
system2('rm -f tmpdeb/usr/share/rustdesk/libdrmtap.so tmpdeb/usr/share/rustdesk/libdrmtap.so.0')
else:
# Build it here, exactly as the flutter deb path does (build_libdrmtap_so asserts the
# EGL backend itself). The library is independent of the staged binary.
stage_libdrmtap_into_deb(build_libdrmtap_so())
system2('mkdir -p tmpdeb/DEBIAN')
generate_control_file(version)
# Keyed on the EXPLICIT request, not on what happened to be staged: by here a --drm build has
# its library in tmpdeb whichever of the two shapes it came from.
if want_drm:
retarget_control_to_drm_variant()
system2('cp -a ../res/DEBIAN/* tmpdeb/DEBIAN/')
md5_file_folder("tmpdeb/")
system2('dpkg-deb -b tmpdeb rustdesk.deb;')
@@ -894,8 +398,6 @@ def build_deb_from_folder(version, binary_folder, want_drm=False):
system2('/bin/rm -rf tmpdeb/')
system2('/bin/rm -rf ../res/DEBIAN/control')
os.rename('rustdesk.deb', '../rustdesk-%s.deb' % version)
if want_drm:
os.rename('../rustdesk-%s.deb' % version, f'../{DRM_PACKAGE_NAME}-{version}.deb')
os.chdir("..")
@@ -903,17 +405,12 @@ def build_flutter_dmg(version, features):
if not skip_cargo:
# set minimum osx build target, now is 10.14, which is the same as the flutter xcode project
system2(
f'MACOSX_DEPLOYMENT_TARGET=10.14 cargo build --locked --features {features} --release')
f'MACOSX_DEPLOYMENT_TARGET=10.14 cargo build --features {features} --release')
# copy dylib
system2(
"cp target/release/liblibrustdesk.dylib target/release/librustdesk.dylib")
os.chdir('flutter')
# 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('flutter build macos --release')
system2('cp -rf ../target/release/service ./build/macos/Build/Products/Release/RustDesk.app/Contents/MacOS/')
'''
system2(
@@ -925,7 +422,7 @@ def build_flutter_dmg(version, features):
def build_flutter_arch_manjaro(version, features):
if not skip_cargo:
system2(f'cargo build --locked --features {features} --lib --release')
system2(f'cargo build --features {features} --lib --release')
ffi_bindgen_function_refactor()
os.chdir('flutter')
system2('flutter build linux --release')
@@ -936,7 +433,7 @@ def build_flutter_arch_manjaro(version, features):
def build_flutter_windows(version, features, skip_portable_pack):
if not skip_cargo:
system2(f'cargo build --locked --features {features} --lib --release')
system2(f'cargo build --features {features} --lib --release')
if not os.path.exists("target/release/librustdesk.dll"):
print("cargo build failed, please check rust source code.")
exit(-1)
@@ -970,19 +467,6 @@ def main():
parser = make_parser()
args = parser.parse_args()
# Before anything with a side effect: this is a query, and a caller uses it to build the very
# binary it will then package. `get_features` stays the single definition of what a flag
# combination means; a caller that hardcodes the list instead is one edit away from compiling
# something other than what it ships.
if args.print_features:
# stdout carries the list and nothing else, so a caller can use it directly in a command
# substitution. `get_features` prints a human-readable line of its own; send that to stderr
# for this call rather than silencing it, which would change what every other path prints.
with contextlib.redirect_stdout(sys.stderr):
feats = ','.join(get_features(args))
print(feats)
return
if os.path.exists(exe_path):
os.unlink(exe_path)
if os.path.isfile('/usr/bin/pacman'):
@@ -998,20 +482,20 @@ def main():
portable = args.portable
package = args.package
if package:
build_deb_from_folder(version, package, args.drm)
build_deb_from_folder(version, package)
return
res_dir = 'resources'
external_resources(flutter, args, res_dir)
if windows:
# build virtual display dynamic library
os.chdir('libs/virtual_display/dylib')
system2('cargo build --locked --release')
system2('cargo build --release')
os.chdir('../../..')
if flutter:
build_flutter_windows(version, features, args.skip_portable_pack)
return
system2('cargo build --locked --release --features ' + features)
system2('cargo build --release --features ' + features)
# system2('upx.exe target/release/rustdesk.exe')
system2('mv target/release/rustdesk.exe target/release/RustDesk.exe')
pa = os.environ.get('P')
@@ -1022,21 +506,20 @@ 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')
system2('pip3 install -r requirements.txt')
system2(
f'python3 ./generate.py -f ../../{res_dir} -o . -e ../../{res_dir}/rustdesk-{version}-win7-install.exe')
system2(f'mv ../../{res_dir}/rustdesk-{version}-win7-install.exe ../..')
system2('mv ../../{res_dir}/rustdesk-{version}-win7-install.exe ../..')
elif os.path.isfile('/usr/bin/pacman'):
# pacman -S -needed base-devel
system2("sed -i 's/pkgver=.*/pkgver=%s/g' res/PKGBUILD" % version)
if flutter:
build_flutter_arch_manjaro(version, features)
else:
system2('cargo build --locked --release --features ' + features)
system2('cargo build --release --features ' + features)
system2('git checkout src/ui/common.tis')
system2('strip target/release/rustdesk')
system2('ln -s res/pacman_install && ln -s res/PKGBUILD')
@@ -1045,7 +528,7 @@ def main():
version, version))
# pacman -U ./rustdesk.pkg.tar.zst
elif os.path.isfile('/usr/bin/yum'):
system2('cargo build --locked --release --features ' + features)
system2('cargo build --release --features ' + features)
system2('strip target/release/rustdesk')
system2(
"sed -i 's/Version: .*/Version: %s/g' res/rpm.spec" % version)
@@ -1055,7 +538,7 @@ def main():
version, version))
# yum localinstall rustdesk.rpm
elif os.path.isfile('/usr/bin/zypper'):
system2('cargo build --locked --release --features ' + features)
system2('cargo build --release --features ' + features)
system2('strip target/release/rustdesk')
system2(
"sed -i 's/Version: .*/Version: %s/g' res/rpm-suse.spec" % version)
@@ -1074,7 +557,7 @@ def main():
# 'mv target/release/bundle/deb/rustdesk*.deb ./flutter/rustdesk.deb')
build_flutter_deb(version, features)
else:
system2('cargo --locked bundle --release --features ' + features)
system2('cargo bundle --release --features ' + features)
if osx:
system2(
'strip target/release/bundle/osx/RustDesk.app/Contents/MacOS/rustdesk')

View File

@@ -1,143 +0,0 @@
# Code de conduite des contributeurs
## Notre engagement
En tant que membres, contributeurs et responsables, nous nous engageons à faire
de la participation à notre communauté une expérience exempte de harcèlement pour
tous, indépendamment de l'âge, de la taille corporelle, du handicap visible ou
invisible, de l'origine ethnique, des caractéristiques sexuelles, de l'identité
et de l'expression de genre, du niveau d'expérience, de l'éducation, du statut
socio-économique, de la nationalité, de l'apparence personnelle, de la race, de
la religion ou de l'identité et de l'orientation sexuelle.
Nous nous engageons à agir et à interagir de manière à contribuer à une
communauté ouverte, accueillante, diversifiée, inclusive et saine.
## Nos standards
Exemples de comportements qui contribuent à un environnement positif pour notre
communauté :
* Faire preuve d'empathie et de bienveillance envers les autres
* Respecter les opinions, les points de vue et les expériences différents
* Donner et accepter gracieusement les retours constructifs
* Assumer ses responsabilités, s'excuser auprès des personnes affectées par nos
erreurs et apprendre de l'expérience
* Se concentrer sur ce qui est le mieux non seulement pour nous en tant
qu'individus, mais pour l'ensemble de la communauté
Exemples de comportements inacceptables :
* L'utilisation de langage ou d'images à caractère sexuel, et les attentions ou
avances sexuelles de quelque nature que ce soit
* Le trolling, les commentaires insultants ou désobligeants, et les attaques
personnelles ou politiques
* Le harcèlement public ou privé
* La publication d'informations privées d'autrui, telles qu'une adresse physique
ou électronique, sans autorisation explicite
* Tout autre comportement qui pourrait raisonnablement être considéré comme
inapproprié dans un cadre professionnel
## Responsabilités en matière d'application
Les responsables de la communauté sont chargés de clarifier et d'appliquer nos
standards de comportement acceptable et prendront des mesures correctives
appropriées et équitables en réponse à tout comportement qu'ils jugent
inapproprié, menaçant, offensant ou nuisible.
Les responsables de la communauté ont le droit et la responsabilité de
supprimer, modifier ou rejeter les commentaires, commits, code, modifications
du wiki, issues et autres contributions qui ne sont pas conformes à ce Code de
conduite, et communiqueront les raisons de leurs décisions de modération le cas
échéant.
## Portée
Ce Code de conduite s'applique dans tous les espaces communautaires, et
s'applique également lorsqu'une personne représente officiellement la communauté
dans les espaces publics. Les exemples de représentation de notre communauté
incluent l'utilisation d'une adresse e-mail officielle, la publication via un
compte de réseau social officiel, ou le fait d'agir en tant que représentant
désigné lors d'un événement en ligne ou hors ligne.
## Application
Les cas de comportements abusifs, harcelants ou autrement inacceptables peuvent
être signalés aux responsables de la communauté chargés de l'application à
[info@rustdesk.com](mailto:info@rustdesk.com).
Toutes les plaintes seront examinées et feront l'objet d'une enquête rapide et
équitable.
Tous les responsables de la communauté sont tenus de respecter la vie privée et
la sécurité de la personne ayant signalé un incident.
## Directives d'application
Les responsables de la communauté suivront ces Directives d'impact communautaire
pour déterminer les conséquences de toute action qu'ils jugent en violation de ce
Code de conduite :
### 1. Correction
**Impact communautaire** : Utilisation d'un langage inapproprié ou autre
comportement jugé non professionnel ou indésirable dans la communauté.
**Conséquence** : Un avertissement écrit et privé de la part des responsables de
la communauté, expliquant la nature de la violation et pourquoi le comportement
était inapproprié. Des excuses publiques peuvent être demandées.
### 2. Avertissement
**Impact communautaire** : Une violation par un incident isolé ou une série
d'actions.
**Conséquence** : Un avertissement avec des conséquences en cas de comportement
répété. Aucune interaction avec les personnes impliquées, y compris les
interactions non sollicitées avec les personnes chargées d'appliquer le Code de
conduite, pendant une période déterminée. Cela inclut d'éviter les interactions
dans les espaces communautaires ainsi que dans les canaux externes comme les
réseaux sociaux. Le non-respect de ces conditions peut entraîner une exclusion
temporaire ou permanente.
### 3. Exclusion temporaire
**Impact communautaire** : Une violation grave des standards communautaires, y
compris un comportement inapproprié persistant.
**Conséquence** : Une exclusion temporaire de toute interaction ou communication
publique avec la communauté pendant une période déterminée. Aucune interaction
publique ou privée avec les personnes impliquées, y compris les interactions non
sollicitées avec les personnes chargées d'appliquer le Code de conduite, n'est
autorisée pendant cette période. Le non-respect de ces conditions peut entraîner
une exclusion permanente.
### 4. Exclusion permanente
**Impact communautaire** : Démontrer un schéma de violation des standards
communautaires, y compris un comportement inapproprié persistant, le harcèlement
d'une personne, ou une agression envers des catégories de personnes ou leur
dénigrement.
**Conséquence** : Une exclusion permanente de toute interaction publique au sein
de la communauté.
## Attribution
Ce Code de conduite est adapté du [Contributor Covenant][homepage], version 2.0,
disponible à l'adresse
[https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0].
Les Directives d'impact communautaire ont été inspirées par
[l'échelle d'application du code de conduite de Mozilla][Mozilla CoC].
Pour des réponses aux questions fréquentes sur ce code de conduite, consultez la
FAQ à l'adresse [https://www.contributor-covenant.org/faq][FAQ]. Des traductions
sont disponibles à l'adresse
[https://www.contributor-covenant.org/translations][translations].
[homepage]: https://www.contributor-covenant.org
[v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html
[Mozilla CoC]: https://github.com/mozilla/diversity
[FAQ]: https://www.contributor-covenant.org/faq
[translations]: https://www.contributor-covenant.org/translations

View File

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

View File

@@ -1,55 +0,0 @@
# Contribuer à RustDesk
RustDesk accueille les contributions de tous. Voici les directives si vous
envisagez de nous aider :
## Contributions
Les contributions à RustDesk ou à ses dépendances doivent être soumises sous
forme de pull requests GitHub. Chaque pull request sera examinée par un
contributeur principal (une personne ayant la permission d'intégrer des
correctifs) et sera soit intégrée dans la branche principale, soit accompagnée
de retours sur les modifications requises. Toutes les contributions doivent
suivre ce format, même celles des contributeurs principaux.
Si vous souhaitez travailler sur une issue, veuillez d'abord la revendiquer en
commentant sur l'issue GitHub indiquant que vous souhaitez la traiter. Cela
permet d'éviter les efforts en double de la part des contributeurs sur la même
issue.
## Liste de vérification pour les pull requests
- Partez de la branche master et, si nécessaire, effectuez un rebase sur la
branche master actuelle avant de soumettre votre pull request. Si elle ne
fusionne pas proprement avec master, il vous sera peut-être demandé de
rebaser vos modifications.
- Les commits doivent être aussi petits que possible, tout en s'assurant que
chaque commit est correct de manière indépendante (c.-à-d. que chaque commit
doit compiler et passer les tests).
- Les commits doivent être accompagnés d'une signature Developer Certificate of
Origin (http://developercertificate.org), indiquant que vous (et votre
employeur le cas échéant) acceptez d'être liés par les termes de la
[licence du projet](../LICENCE). Dans git, il s'agit de l'option `-s` de
`git commit`.
- Si votre correctif n'est pas examiné ou si vous avez besoin qu'une personne
spécifique l'examine, vous pouvez @-mentionner un relecteur pour demander une
revue dans la pull request ou un commentaire, ou vous pouvez demander une
revue par [e-mail](mailto:info@rustdesk.com).
- Ajoutez des tests relatifs au bug corrigé ou à la nouvelle fonctionnalité.
Pour des instructions git spécifiques, consultez le
[GitHub workflow 101](https://github.com/servo/servo/wiki/GitHub-workflow).
## Conduite
https://github.com/rustdesk/rustdesk/blob/master/docs/CODE_OF_CONDUCT.md
## Communication
Les contributeurs de RustDesk se retrouvent fréquemment sur
[Discord](https://discord.gg/nDceKgxnkV).

View File

@@ -1,6 +1,6 @@
# Contributing to RustDesk
RustDesk welcomes contributions from everyone. Here are the guidelines if you are
RustDesk welcomes contribution from everyone. Here are the guidelines if you are
thinking of helping us:
## Contributions

View File

@@ -34,9 +34,9 @@ Les versions de bureau utilisent [sciter](https://sciter.com/) pour l'interface
- Installez [vcpkg](https://github.com/microsoft/vcpkg), et définissez correctement la variable d'environnement `VCPKG_ROOT`.
- Windows : vcpkg install libvpx:x64-windows-static libyuv:x64-windows-static opus:x64-windows-static aom:x64-windows-static
- Linux/macOS : vcpkg install libvpx libyuv opus aom
- Linux/Osx : vcpkg install libvpx libyuv opus aom
- Exécutez `cargo run`
- Exécuter `cargo run`
## Comment compiler/build sous Linux
@@ -93,7 +93,7 @@ cd rustdesk
mkdir -p target/debug
wget https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so
mv libsciter-gtk.so target/debug
cargo run
Exécution du cargo
```
## Comment construire avec Docker

View File

@@ -1,10 +1,10 @@
<p align="center">
<img src="../res/logo-header.svg" alt="RustDesk - Your remote desktop"><br>
<a href="#빌드를_위한_원시_단계">빌드</a> •
<a href="#Docker로_빌드하는_방법">Docker</a> •
<a href="#파일_구조">구조</a> •
<a href="#스크린샷">스샷</a><br>
[<a href="../README.md">English</a>] | [<a href="README-UA.md">Українська</a>] | [<a href="README-CS.md">česky</a>] | [<a href="README-ZH.md">中文</a>] | [<a href="README-HU.md">Magyar</a>] | [<a href="README-ES.md">Español</a>] | [<a href="README-FA.md">فارسی</a>] | [<a href="README-FR.md">Français</a>] | [<a href="README-DE.md">Deutsch</a>] | [<a href="README-PL.md">Polski</a>] | [<a href="README-ID.md">Indonesian</a>] | [<a href="README-FI.md">Suomi</a>] | [<a href="README-ML.md">മലയാളം</a>] | [<a href="README-JP.md">日本語</a>] | [<a href="README-NL.md">Nederlands</a>] | [<a href="README-IT.md">Italiano</a>] | [<a href="README-RU.md">Русский</a>] | [<a href="README-PTBR.md">Português (Brasil)</a>] | [<a href="README-EO.md">Esperanto</a>] | [<a href="README-KR.md">한국어</a>] | [<a href="README-AR.md">العربي</a>] | [<a href="README-VN.md">Tiếng Việt</a>] | [<a href="README-DA.md">Dansk</a>] | [<a href="README-GR.md">Ελληνικά</a>] | [<a href="README-TR.md">Türkçe</a>] | [<a href="README-NO.md">Norsk</a>] | [<a href="README-RO.md">Română</a>]<br>
<a href="#빌드를 위한 원시 단계">빌드</a> •
<a href="#Docker로 빌드하는 방법">Docker</a> •
<a href="#파일 구조">구조</a> •
<a href="#스크린샷">스샷</a><br>
[<a href="../README.md">English</a>] | [<a href="README-UA.md">Українська</a>] | [<a href="README-CS.md">česky</a>] | [<a href="README-ZH.md">中文</a>] | [<a href="README-HU.md">Magyar</a>] | [<a href="README-ES.md">Español</a>] | [<a href="README-FA.md">فارسی</a>] | [<a href="README-FR.md">Français</a>] | [<a href="README-DE.md">Deutsch</a>] | [<a href="README-PL.md">Polski</a>] | [<a href="README-ID.md">Indonesian</a>] | [<a href="README-FI.md">Suomi</a>] | [<a href="README-ML.md">മലയാളം</a>] | [<a href="README-JP.md">日本語</a>] | [<a href="README-NL.md">Nederlands</a>] | [<a href="README-IT.md">Italiano</a>] | [<a href="README-RU.md">Русский</a>] | [<a href="README-PTBR.md">Português (Brasil)</a>] | [<a href="README-EO.md">Esperanto</a>] | [<a href="README-KR.md">한국어</a>] | [<a href="README-AR.md">العربي</a>] | [<a href="README-VN.md">Tiếng Việt</a>] | [<a href="README-DA.md">Dansk</a>] | [<a href="README-GR.md">Ελληνικά</a>] | [<a href="README-TR.md">Türkçe</a>] | [<a href="README-NO.md">Norsk</a>]<br>
<b>이 README, <a href="https://github.com/rustdesk/rustdesk/tree/master/src/lang">RustDesk UI</a> 및 <a href="https://github.com/rustdesk/doc.rustdesk.com">RustDesk 문서</a>를 귀하의 모국어로 번역하는 데 도움이 필요합니다</b>
</p>
@@ -46,9 +46,9 @@ Sciter 동적 라이브러리를 직접 다운로드하세요.
[Linux](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so) |
[macOS](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.osx/libsciter.dylib)
## 빌드를_위한_원시_단계
## 빌드를 위한 원시 단계
- Rust 개발 환경과 C++ 빌드 환경 준비
- Rust 개발 환경과 C++ 빌드 환경 준비합니다
- [vcpkg](https://github.com/microsoft/vcpkg)를 설치하고 `VCPKG_ROOT` 환경 변수를 올바르게 설정합니다
@@ -125,7 +125,7 @@ mv libsciter-gtk.so target/debug
VCPKG_ROOT=$HOME/vcpkg cargo run
```
## Docker로_빌드하는_방법
## Docker로 빌드하는 방법
먼저 리포지토리를 복제하고 Docker 컨테이너를 빌드합니다:
@@ -156,7 +156,7 @@ target/release/rustdesk
RustDesk 리포지토리의 루트에서 이러한 명령을 실행하고 있는지 확인하세요. 그렇지 않으면 응용 프로그램이 필요한 리소스를 찾지 못할 수 있습니다. 또한 `install` 또는 `run` 과 같은 다른 cargo 하위 명령은 호스트가 아닌 컨테이너 내부에 프로그램을 설치하거나 실행하므로 현재 이 방법을 통해 지원되지 않는다는 점에 유의하세요.
## 파일_구조
## 파일 구조
- **[libs/hbb_common](https://github.com/rustdesk/rustdesk/tree/master/libs/hbb_common)**: 비디오 코덱, 구성, tcp/udp wrapper, protobuf, 파일 전송을 위한 fs 함수 및 기타 유틸리티 함수
- **[libs/scrap](https://github.com/rustdesk/rustdesk/tree/master/libs/scrap)**: 화면 캡쳐

View File

@@ -1,82 +1,55 @@
<p align="center">
<img src="../res/logo-header.svg" alt="RustDesk - Seu desktop remoto"><br>
<a href="#compilar">Compilar</a> •
<a href="#como-compilar-com-o-docker">Docker</a> •
<a href="#servidores-públicos-grátis">Servidores</a> •
<a href="#compilação-crua">Compilar</a> •
<a href="#como-compilar-com-docker">Docker</a> •
<a href="#estrutura-de-arquivos">Estrutura</a> •
<a href="#capturas-de-tela">Capturas de Tela</a><br>
[<a href="../README.md">Inglês</a>] | [<a href="docs/README-UA.md">Ucraniano</a>] | [<a href="docs/README-CS.md">Tcheco</a>] | [<a href="docs/README-ZH.md">Chinês</a>] | [<a href="docs/README-HU.md">Húngaro</a>] | [<a href="docs/README-ES.md">Espanhol</a>] | [<a href="docs/README-FA.md">Persa</a>] | [<a href="docs/README-FR.md">Frans</a>] | [<a href="docs/README-DE.md">Alemão</a>] | [<a href="docs/README-PL.md">Polonês</a>] | [<a href="docs/README-ID.md">Indonésio</a>] | [<a href="docs/README-FI.md">Finlandês</a>] | [<a href="docs/README-ML.md">Malaiala</a>] | [<a href="docs/README-JP.md">Japonês</a>] | [<a href="docs/README-NL.md">Holandês</a>] | [<a href="docs/README-IT.md">Italiano</a>] | [<a href="docs/README-RU.md">Russo</a>] | [<a href="docs/README-EO.md">Esperanto</a>] | [<a href="docs/README-KR.md">Coreano</a>] | [<a href="docs/README-AR.md">Árabe</a>] | [<a href="docs/README-VN.md">Vietnamita</a>] | [<a href="docs/README-DA.md">Dinamarquês</a>] | [<a href="docs/README-GR.md">Grego</a>] | [<a href="docs/README-TR.md">Turco</a>] | [<a href="docs/README-NO.md">Norueguês</a>] | [<a href="docs/README-RO.md">Romeno</a>]<br>
<b>Precisamos da sua ajuda para traduzir este README, a <a href="https://github.com/rustdesk/rustdesk/tree/master/src/lang">Interface do RustDesk</a> e a <a href="https://github.com/rustdesk/doc.rustdesk.com">Documentação do RustDesk</a> para o seu idioma nativo</b>
<a href="#screenshots">Screenshots</a><br>
[<a href="../README.md">English</a>] | [<a href="README-UA.md">Українська</a>] | [<a href="README-CS.md">česky</a>] | [<a href="README-ZH.md">中文</a>] | [<a href="README-HU.md">Magyar</a>] | [<a href="README-ES.md">Español</a>] | [<a href="README-FA.md">فارسی</a>] | [<a href="README-FR.md">Français</a>] | [<a href="README-DE.md">Deutsch</a>] | [<a href="README-PL.md">Polski</a>] | [<a href="README-ID.md">Indonesian</a>] | [<a href="README-FI.md">Suomi</a>] | [<a href="README-ML.md">മലയാളം</a>] | [<a href="README-JP.md">日本語</a>] | [<a href="README-NL.md">Nederlands</a>] | [<a href="README-IT.md">Italiano</a>] | [<a href="README-RU.md">Русский</a>] | [<a href="README-EO.md">Esperanto</a>] | [<a href="README-KR.md">한국어</a>] | [<a href="README-AR.md">العربي</a>] | [<a href="README-VN.md">Tiếng Việt</a>] | [<a href="README-GR.md">Ελληνικά</a>]<br>
<b>Precisamos de sua ajuda para traduzir este README e a <a href="https://github.com/rustdesk/rustdesk/tree/master/src/lang">UI do RustDesk</a> para sua língua nativa</b>
</p>
> [!Caution]
> **Aviso de Isenção de Responsabilidade por Uso Indevido:** <br>
> Os desenvolvedores do RustDesk não toleram ou apoiam qualquer uso antiético ou ilegal deste software. O uso indevido, como acesso não autorizado, controle ou invasão de privacidade, viola estritamente nossas diretrizes. Os autores não são responsáveis por qualquer uso indevido do aplicativo.
Converse conosco: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk) | [YouTube](https://www.youtube.com/@rustdesk)
[![RustDesk Server Pro](https://img.shields.io/badge/RustDesk%20Server%20Pro-Advanced%20Features-blue)](https://rustdesk.com/pricing.html)
[![RustDesk Server Pro](https://img.shields.io/badge/RustDesk%20Server%20Pro-Recursos%20Avan%C3%A7ados-blue)](https://rustdesk.com/pricing.html)
Mais uma solução de desktop remoto, escrita em Rust. Funciona imediatamente, sem necessidade de configuração. Você tem controle total dos seus dados, sem preocupações com segurança. Você pode usar nosso servidor de conexão/retransmissão (rendezvous/relay), [configurar o seu próprio](https://rustdesk.com/server) ou [escrever seu próprio servidor de conexão/retransmissão](https://github.com/rustdesk/rustdesk-server-demo).
Mais um software de desktop remoto, escrito em Rust. Funciona por padrão, sem necessidade de configuração. Você tem completo controle de seus dados, sem se preocupar com segurança. Você pode usar nossos servidores de rendezvous/relay, [configurar seu próprio](https://rustdesk.com/server), ou [escrever seu próprio servidor de rendezvous/relay](https://github.com/rustdesk/rustdesk-server-demo).
![image](https://user-images.githubusercontent.com/71636191/171661982-430285f0-2e12-4b1d-9957-4a58e375304d.png)
RustDesk acolhe contribuições de todos. Leia [`docs/CONTRIBUTING.md`](CONTRIBUTING.md) para ver como começar.
O RustDesk acolhe a contribuição de todos. Veja [CONTRIBUTING.md](docs/CONTRIBUTING.md) para ajuda em como começar.
[**Perguntas Frequentes (FAQ)**](https://github.com/rustdesk/rustdesk/wiki/FAQ)
[**DOWNLOAD DOS BINÁRIOS**](https://github.com/rustdesk/rustdesk/releases)
[**VERSÕES NIGHTLY (EM DESENVOLVIMENTO)**](https://github.com/rustdesk/rustdesk/releases/tag/nightly)
[<img src="https://f-droid.org/badge/get-it-on.png"
alt="Baixe no F-Droid"
height="80">](https://f-droid.org/en/packages/com.carriez.flutter_hbb)
[<img src="https://flathub.org/api/badge?svg&locale=en"
alt="Baixe no Flathub"
height="80">](https://flathub.org/apps/com.rustdesk.RustDesk)
[**DOWNLOAD DE BINÁRIOS**](https://github.com/rustdesk/rustdesk/releases)
## Dependências
As versões de desktop usam Flutter ou Sciter (descontinuado) para a interface gráfica (GUI). Este tutorial é apenas para o Sciter, por ser mais fácil e amigável para começar. Verifique nosso [CI](https://github.com/rustdesk/rustdesk/blob/master/.github/workflows/flutter-build.yml) para instruções de compilação da versão em Flutter.
Por favor, faça o download da biblioteca dinâmica do Sciter por conta própria.
Versões de desktop utilizam [sciter](https://sciter.com/) para a GUI, por favor baixe a biblioteca dinâmica sciter por conta própria.
[Windows](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.win/x64/sciter.dll) |
[Linux](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so) |
[macOS](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.osx/libsciter.dylib)
[MacOS](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.osx/libsciter.dylib)
## Passos básicos para compilar
## Compilação crua
- Prepare seu ambiente de desenvolvimento Rust e o ambiente de compilação C++
- Prepare seu ambiente de desenvolvimento Rust e ambiente de compilação C++
- Instale o [vcpkg](https://github.com/microsoft/vcpkg) e configure a variável de ambiente `VCPKG_ROOT` corretamente
- Instale [vcpkg](https://github.com/microsoft/vcpkg), e configure a variável de ambiente `VCPKG_ROOT` corretamente
- Windows: `vcpkg install libvpx:x64-windows-static libyuv:x64-windows-static opus:x64-windows-static aom:x64-windows-static`
- Linux/macOS: `vcpkg install libvpx libyuv opus aom`
- Windows: vcpkg install libvpx:x64-windows-static libyuv:x64-windows-static opus:x64-windows-static aom:x64-windows-static
- Linux/MacOS: vcpkg install libvpx libyuv opus aom
- Execute `cargo run`
## [Compilar](https://rustdesk.com/docs/en/dev/build/)
## Como Compilar no Linux
## Como compilar no Linux
### 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
```
### 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 apt install -y 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
```
### 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
```
### Arch (Manjaro)
@@ -85,7 +58,7 @@ sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-
sudo pacman -Syu --needed unzip git cmake gcc curl wget yasm nasm zip make pkg-config clang gtk3 xdotool libxcb libxfixes alsa-lib pipewire
```
### Instalar o vcpkg
### Instale vcpkg
```sh
git clone https://github.com/microsoft/vcpkg
@@ -97,7 +70,7 @@ export VCPKG_ROOT=$HOME/vcpkg
vcpkg/vcpkg install libvpx libyuv opus aom
```
### Corrigir o libvpx (Para Fedora)
### Conserte libvpx (Para o Fedora)
```sh
cd vcpkg/buildtrees/libvpx/src
@@ -110,12 +83,12 @@ cp libvpx.a $HOME/vcpkg/installed/x64-linux/lib/
cd
```
### Compilar
### Compile
```sh
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source $HOME/.cargo/env
git clone --recurse-submodules https://github.com/rustdesk/rustdesk
git clone https://github.com/rustdesk/rustdesk
cd rustdesk
mkdir -p target/debug
wget https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so
@@ -123,57 +96,57 @@ mv libsciter-gtk.so target/debug
VCPKG_ROOT=$HOME/vcpkg cargo run
```
## Como compilar com o Docker
## Como compilar com Docker
Comece clonando o repositório e construindo o contêiner Docker:
Comece clonando o repositório e montando o container docker:
```sh
git clone https://github.com/rustdesk/rustdesk
cd rustdesk
git submodule update --init --recursive
docker build -t "rustdesk-builder" .
```
Depois, cada vez que precisar compilar o aplicativo, execute o seguinte comando:
Então, sempre que precisar compilar a aplicação, execute este comando:
```sh
docker run --rm -it -v $PWD:/home/user/rustdesk -v rustdesk-git-cache:/home/user/.cargo/git -v rustdesk-registry-cache:/home/user/.cargo/registry -e PUID="$(id -u)" -e PGID="$(id -g)" rustdesk-builder
```
Note que a primeira compilação pode demorar mais a que as dependências sejam armazenadas em cache; as compilações subsequentes serão mais rápidas. Além disso, se você precisar especificar argumentos diferentes para o comando de compilação, pode fazê-lo ao final do comando na posição `<ARGUMENTOS-OPCIONAIS>`. Por exemplo, se você quiser compilar uma versão de lançamento (release) otimizada, executaria o comando acima seguido de `--release`. O executável resultante estará disponível na pasta `target` do seu sistema e pode ser executado com:
Note que a primeira compilação pode demorar mais antes que as dependências sejam armazenadas em cache, as compilações subsequentes serão mais rápidas. Adicionalmente, se você precisar especificar argumentos diferentes para o comando de compilação, você pode fazê-lo ao final do comando na posição do `<OPTIONAL-ARGS>`. Por exemplo, se você gostaria de compilar uma versão de release otimizada, você executaria o comando acima seguido de `--release`. O executável gerado estará disponível no diretório alvo no seu sistema, e pode ser executado com:
```sh
target/debug/rustdesk
```
Ou, se estiver executando o executável de lançamento:
Ou, se estiver rodando um executável de release:
```sh
target/release/rustdesk
```
Certifique-se de executar esses comandos a partir da raiz do repositório do RustDesk, do contrário o aplicativo pode não encontrar os recursos necessários. Note também que outros subcomandos do cargo, como `install` ou `run`, não são suportados atualmente por este método, pois instalariam ou executariam o programa dentro do contêiner em vez de no sistema hospedeiro.
Por favor verifique que está executando estes comandos da raiz do repositório do RustDesk, senão a aplicação pode não encontrar os recursos necessários. Note também que outros subcomandos do cargo como `install` ou `run` não são suportados atualmente via este método, já que eles iriam instalar ou rodar o programa dentro do container ao invés do host.
## Estrutura de Arquivos
## Estrutura de arquivos
- **[libs/hbb_common](https://github.com/rustdesk/rustdesk/tree/master/libs/hbb_common)**: codec de vídeo, configuração, encapsulador (wrapper) tcp/udp, protobuf, funções de sistema de arquivos para transferência de arquivos e algumas outras funções utilitárias.
- **[libs/scrap](https://github.com/rustdesk/rustdesk/tree/master/libs/scrap)**: captura de tela.
- **[libs/enigo](https://github.com/rustdesk/rustdesk/tree/master/libs/enigo)**: controle de teclado/mouse específico de cada plataforma.
- **[libs/clipboard](https://github.com/rustdesk/rustdesk/tree/master/libs/clipboard)**: implementação de copiar e colar arquivos para Windows, Linux e macOS.
- **[src/ui](https://github.com/rustdesk/rustdesk/tree/master/src/ui)**: interface Sciter antiga (descontinuada).
- **[src/server](https://github.com/rustdesk/rustdesk/tree/master/src/server)**: serviços de áudio/área de transferência/entrada/vídeo e conexões de rede.
- **[src/client.rs](https://github.com/rustdesk/rustdesk/tree/master/src/client.rs)**: inicia uma conexão direta (peer connection).
- **[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.
- **[libs/hbb_common](https://github.com/rustdesk/rustdesk/tree/master/libs/hbb_common)**: codec de vídeo, configurações, wrapper de tcp/udp, protobuf, funções de sistema de arquivos para transferência de arquivos, e outras funções utilitárias
- **[libs/scrap](https://github.com/rustdesk/rustdesk/tree/master/libs/scrap)**: captura de tela
- **[libs/enigo](https://github.com/rustdesk/rustdesk/tree/master/libs/enigo)**: controle de teclado/mouse específico a cada plataforma
- **[src/ui](https://github.com/rustdesk/rustdesk/tree/master/src/ui)**: GUI
- **[src/server](https://github.com/rustdesk/rustdesk/tree/master/src/server)**: serviços de áudio/área de transferência/entrada/vídeo, e conexões de rede
- **[src/client.rs](https://github.com/rustdesk/rustdesk/tree/master/src/client.rs)**: iniciar uma conexão "peer to peer"
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Comunicação com [rustdesk-server](https://github.com/rustdesk/rustdesk-server), aguardar pela conexão remota direta (TCP hole punching) ou conexão indireta (relayed)
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: código específico a cada plataforma
## Capturas de Tela
> [!Cuidadob]
> **Aviso de uso indevido:** <br>
> Os desenvolvedores do RustDesk não aprovam nem apoiam qualquer uso antiético ou ilegal deste software. O uso indevido, como acesso não autorizado, controle ou invasão de privacidade, é estritamente contra nossas diretrizes. Os autores não são responsáveis por qualquer uso indevido da aplicação.
![Gerenciador de Conexões](https://github.com/rustdesk/rustdesk/assets/28412477/db82d4e7-c4bc-4823-8e6f-6af7eadf7651)
## Screenshots
![Conectado a um PC Windows](https://github.com/rustdesk/rustdesk/assets/28412477/9baa91e9-3362-4d06-aa1a-7518edcbd7ea)
![image](https://user-images.githubusercontent.com/71636191/113112362-ae4deb80-923b-11eb-957d-ff88daad4f06.png)
![Transferência de Arquivos](https://github.com/rustdesk/rustdesk/assets/28412477/39511ad3-aa9a-4f8c-8947-1cce286a46ad)
![image](https://user-images.githubusercontent.com/71636191/113112619-f705a480-923b-11eb-911d-97e984ef52b6.png)
![Tunelamento TCP](https://github.com/rustdesk/rustdesk/assets/28412477/78e8708f-e87e-4570-8373-1360033ea6c5)
![image](https://user-images.githubusercontent.com/71636191/113112857-3fbd5d80-923c-11eb-9836-768325faf906.png)
![image](https://user-images.githubusercontent.com/71636191/135385039-38fdbd72-379a-422d-b97f-33df71fb1cec.png)

View File

@@ -167,7 +167,7 @@ target/release/rustdesk
- **[src/ui](https://github.com/rustdesk/rustdesk/tree/master/src/ui)**: графический пользовательский интерфейс на Sciter (устаревшее)
- **[src/server](https://github.com/rustdesk/rustdesk/tree/master/src/server)**: сервисы аудио, буфера обмена, ввода, видео и сетевых подключений
- **[src/client.rs](https://github.com/rustdesk/rustdesk/tree/master/src/client.rs)**: одноранговое соединение
- **[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/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

View File

@@ -1,16 +0,0 @@
# Politique de sécurité
## Signaler une vulnérabilité
Nous accordons une très grande importance à la sécurité du projet. Nous
encourageons tous les utilisateurs à nous signaler toute vulnérabilité qu'ils
découvrent.
Si vous trouvez une vulnérabilité de sécurité dans le projet RustDesk, veuillez
la signaler de manière responsable en envoyant un e-mail à info@rustdesk.com.
À ce stade, nous n'avons pas de programme de bug bounty. Nous sommes une petite
équipe qui s'attaque à un grand défi. Nous vous encourageons vivement à signaler
toute vulnérabilité de manière responsable afin que nous puissions continuer à
développer une application sécurisée pour l'ensemble de la communauté.

View File

@@ -33,4 +33,4 @@ if [ -z $release ]; then
fi
set -f
#shellcheck disable=2086
VCPKG_ROOT=/vcpkg cargo build --locked $argv
VCPKG_ROOT=/vcpkg cargo build $argv

View File

@@ -18,7 +18,7 @@
<li> Supports VP8 / VP9 / AV1 software codecs, and H264 / H265 hardware codecs. </li>
<li> Own your data, easily set up self-hosting solution on your infrastructure. </li>
<li> P2P connection with end-to-end encryption based on NaCl. </li>
<li> No administrative privileges or installation needed for Windows, elevate privilege locally or from remote on demand. </li>
<li> No administrative privileges or installation needed for Windows, elevate priviledge locally or from remote on demand. </li>
<li> We like to keep things simple and will strive to make simpler where possible. </li>
</ul>
<p>
@@ -56,4 +56,4 @@
<control>pointing</control>
</supports>
<content_rating type="oars-1.1"/>
</component>
</component>

View File

@@ -62,13 +62,7 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart:
return false
}
}
val recorder = try {
builder.build()
} catch (e: Exception) {
Log.e(logTag, "createAudioRecorder failed", e)
return false
}
audioRecorder = recorder
audioRecorder = builder.build()
Log.d(logTag, "createAudioRecorder done,minBufferSize:$minBufferSize")
return true
}

View File

@@ -311,10 +311,7 @@ class FloatingWindowService : Service(), View.OnTouchListener {
popupMenu.menu.add(0, idSyncClipboard, 0, translate("Update client clipboard"))
}
val idStopService = 2
val hideStopService = FFI.getBuildinOption("hide-stop-service") == "Y"
if (!hideStopService) {
popupMenu.menu.add(0, idStopService, 0, translate("Stop service"))
}
popupMenu.menu.add(0, idStopService, 0, translate("Stop service"))
popupMenu.setOnMenuItemClickListener { menuItem ->
when (menuItem.itemId) {
idShowRustDesk -> {
@@ -392,3 +389,4 @@ class FloatingWindowService : Service(), View.OnTouchListener {
return false
}
}

View File

@@ -8,7 +8,6 @@ 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
@@ -69,16 +68,6 @@ 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()
@@ -727,7 +716,6 @@ 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
@@ -746,16 +734,8 @@ 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() {}
}

View File

@@ -106,16 +106,6 @@ class MainActivity : FlutterActivity() {
override fun onDestroy() {
Log.e(logTag, "onDestroy")
// The process can outlive the UI whenever something keeps it alive:
// MainService, or the accessibility InputService on its own. Only the
// former gets onTaskRemoved, so close outgoing sessions here too,
// otherwise a session survives with no UI left to close it.
// `isFinishing` distinguishes the user really leaving from a destroy
// for recreation (configuration change, "don't keep activities"),
// which must not tear down a live session.
if (isFinishing) {
FFI.closeAllSessions()
}
mainService?.let {
unbindService(serviceConnection)
}
@@ -210,13 +200,12 @@ 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" -> {

View File

@@ -254,16 +254,6 @@ class MainService : Service() {
super.onDestroy()
}
// Swiping the app away from recents destroys the UI but this service keeps
// the process alive, so outgoing sessions would stay connected with no way
// to close them. Incoming connections are unaffected: the service keeps
// running so the device stays reachable.
override fun onTaskRemoved(rootIntent: Intent?) {
Log.d(logTag, "onTaskRemoved, closing outgoing sessions")
FFI.closeAllSessions()
super.onTaskRemoved(rootIntent)
}
private var isHalfScale: Boolean? = null;
private fun updateScreenInfo(orientation: Int) {
var w: Int

View File

@@ -21,11 +21,9 @@ 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
external fun getBuildinOption(key: String): String
external fun onClipboardUpdate(clips: ByteBuffer)
external fun isServiceClipboardEnabled(): Boolean
}

View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 604 B

View File

@@ -1 +0,0 @@
<svg width="48" height="48" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg"><rect x="4" y="4" width="19" height="19" fill="#f25022"/><rect x="25" y="4" width="19" height="19" fill="#7fba00"/><rect x="4" y="25" width="19" height="19" fill="#00a4ef"/><rect x="25" y="25" width="19" height="19" fill="#ffb900"/></svg>

Before

Width:  |  Height:  |  Size: 321 B

View File

@@ -1,7 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 303 B

View File

@@ -6,82 +6,83 @@ ANDROID_ABI=$1
# Build RustDesk dependencies for Android using vcpkg.json
# Required:
# 1. set VCPKG_ROOT / ANDROID_NDK_HOME path environment variables
# 1. set VCPKG_ROOT / ANDROID_NDK path environment variables
# 2. vcpkg initialized
# 3. ndk, version: r25c or newer
if [ -z "${ANDROID_NDK_HOME}" ]; then
echo "ERROR: Please set ANDROID_NDK_HOME environment variable" 1>&2
exit 1
if [ -z "$ANDROID_NDK_HOME" ]; then
echo "Failed! Please set ANDROID_NDK_HOME"
exit 1
fi
if [ -z "${VCPKG_ROOT}" ]; then
echo "ERROR: Please set VCPKG_ROOT environment variable" 1>&2
exit 1
if [ -z "$VCPKG_ROOT" ]; then
echo "Failed! Please set VCPKG_ROOT"
exit 1
fi
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
API_LEVEL="21"
# 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 "ERROR: Can not find vcpkg.json in RustDesk top-level directory" 1>&2
exit 1
if [ ! -f "$SCRIPTDIR/../vcpkg.json" ]; then
echo "Failed! Please check where vcpkg.json is!"
exit 1
fi
echo "INFO: Building and install vcpkg dependencies for Android ${ANDROID_ABI} ..."
# NDK llvm toolchain
pushd "${SCRIPTDIR}/.."
HOST_TAG="linux-x86_64" # current platform, set as `ls $ANDROID_NDK/toolchains/llvm/prebuilt/`
TOOLCHAIN=$ANDROID_NDK/toolchains/llvm/prebuilt/$HOST_TAG
"${VCPKG_ROOT}/vcpkg" install \
--triplet "${VCPKG_TARGET}" \
--x-install-root="${VCPKG_ROOT}/installed"
function build {
ANDROID_ABI=$1
popd
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
echo "INFO: Completed building vcpkg dependencies for Android ${ANDROID_ABI}"
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"
if [ "${ANDROID_ABI}" = 'armeabi-v7a' ]; then
# Symlink arm-neon-android to arm-android because cargo-ndk does not
# understand NEON suffix.
if [ -d "$VCPKG_ROOT/installed/arm-neon-android" ]; then
echo "*** [Start] Move arm-neon-android to arm-android"
if [ -d "${VCPKG_ROOT}/installed/arm-neon-android" ]; then
echo 'INFO: Symlinking arm-neon-android to arm-android'
mv "$VCPKG_ROOT/installed/arm-neon-android" "$VCPKG_ROOT/installed/arm-android"
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
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
fi

View File

@@ -7,7 +7,7 @@
# 2024, Vasyl Gello <vasek.gello@gmail.com>
#
# The script is invoked by F-Droid builder system step-by-step.
# The script is invoked by F-Droid builder system ste-by-step.
#
# It accepts the following arguments:
#
@@ -16,6 +16,7 @@
# - Android architecture to build APK for: armeabi-v7a arm64-v8av x86 x86_64
# - The build step to execute:
#
# + sudo-deps: as root, install needed Debian packages into builder VM
# + prebuild: patch sources and do other stuff before the build
# + build: perform actual build of APK file
#
@@ -183,9 +184,13 @@ prebuild)
fi
# Map NDK version to revision
NDK_VERSION="$(curl https://gitlab.com/fdroid/android-sdk-transparency-log/-/raw/master/signed/checksums.json |
jq -r ".\"https://dl.google.com/android/repository/android-ndk-${NDK_VERSION}-linux.zip\"[0].\"source.properties\"" |
sed -n -E 's/.*Pkg.Revision = ([0-9.]+).*/\1/p')"
NDK_VERSION="$(wget \
-qO- \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
'https://api.github.com/repos/android/ndk/releases' |
jq -r ".[] | select(.tag_name == \"${NDK_VERSION}\") | .body | match(\"ndkVersion \\\"(.*)\\\"\").captures[0].string")"
if [ -z "${NDK_VERSION}" ]; then
echo "ERROR: Can not map Android NDK codename to revision!" >&2
@@ -311,18 +316,6 @@ prebuild)
# `FLUTTER_BRIDGE_VERSION` an restore the pubspec later
if [ "${FLUTTER_VERSION}" != "${FLUTTER_BRIDGE_VERSION}" ]; then
# Find first libclang.so and set BRIDGE_LLVM_PATH
BRIDGE_LLVM_PATH="$(find /usr/lib/ -name libclang.so | head -n1)"
if [ -z "${BRIDGE_LLVM_PATH}" ]; then
echo 'ERROR: Can not find libclang.so for bridge generator!' >&2
exit 1
fi
BRIDGE_LLVM_PATH="$(dirname "${BRIDGE_LLVM_PATH}")"
BRIDGE_LLVM_PATH="$(dirname "${BRIDGE_LLVM_PATH}")"
# Install Flutter bridge version
prepare_flutter "${FLUTTER_BRIDGE_VERSION}" "${HOME}/flutter"
@@ -351,8 +344,7 @@ prebuild)
flutter_rust_bridge_codegen \
--rust-input ./src/flutter_ffi.rs \
--dart-output ./flutter/lib/generated_bridge.dart \
--llvm-path "${BRIDGE_LLVM_PATH}"
--dart-output ./flutter/lib/generated_bridge.dart
# Add bridge files to save-list
@@ -363,15 +355,13 @@ prebuild)
git checkout '*'
git clean -dffx
git reset
unset BRIDGE_LLVM_PATH
fi
# Install Flutter version for RustDesk library build
prepare_flutter "${FLUTTER_VERSION}" "${HOME}/flutter"
# gms is not in these files now, but we still keep the following line for future reference(maybe).
# gms is not in thoes files now, but we still keep the following line for future reference(maybe).
sed \
-i \
@@ -424,9 +414,13 @@ build)
.github/workflows/flutter-build.yml)"
# Map NDK version to revision
NDK_VERSION="$(curl https://gitlab.com/fdroid/android-sdk-transparency-log/-/raw/master/signed/checksums.json |
jq -r ".\"https://dl.google.com/android/repository/android-ndk-${NDK_VERSION}-linux.zip\"[0].\"source.properties\"" |
sed -n -E 's/.*Pkg.Revision = ([0-9.]+).*/\1/p')"
NDK_VERSION="$(wget \
-qO- \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
'https://api.github.com/repos/android/ndk/releases' |
jq -r ".[] | select(.tag_name == \"${NDK_VERSION}\") | .body | match(\"ndkVersion \\\"(.*)\\\"\").captures[0].string")"
if [ -z "${NDK_VERSION}" ]; then
echo "ERROR: Can not map Android NDK codename to revision!" >&2
@@ -460,7 +454,6 @@ build)
--target "${RUST_TARGET}" \
--bindgen \
build \
--locked \
--release \
--features "${RUSTDESK_FEATURES}"

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CLIENT_ID</key>
<string>768133699366-k1rn3ls1u2n3nklmgd9t4cmpdob0c8bn.apps.googleusercontent.com</string>
<key>REVERSED_CLIENT_ID</key>
<string>com.googleusercontent.apps.768133699366-k1rn3ls1u2n3nklmgd9t4cmpdob0c8bn</string>
<key>API_KEY</key>
<string>AIzaSyCf57HjCwSokt91CqFI0Mwf8D--ek0jvfc</string>
<key>GCM_SENDER_ID</key>
<string>768133699366</string>
<key>PLIST_VERSION</key>
<string>1</string>
<key>BUNDLE_ID</key>
<string>com.carriez.flutterHbb</string>
<key>PROJECT_ID</key>
<string>rustdesk</string>
<key>STORAGE_BUCKET</key>
<string>rustdesk.appspot.com</string>
<key>IS_ADS_ENABLED</key>
<false></false>
<key>IS_ANALYTICS_ENABLED</key>
<false></false>
<key>IS_APPINVITE_ENABLED</key>
<true></true>
<key>IS_GCM_ENABLED</key>
<true></true>
<key>IS_SIGNIN_ENABLED</key>
<true></true>
<key>GOOGLE_APP_ID</key>
<string>1:768133699366:ios:c33078a6181b9d507993e7</string>
<key>DATABASE_URL</key>
<string>https://rustdesk.firebaseio.com</string>
</dict>
</plist>

View File

@@ -1,2 +1,2 @@
#!/usr/bin/env bash
cargo build --locked --features flutter,hwcodec --release --target aarch64-apple-ios --lib
cargo build --features flutter,hwcodec --release --target aarch64-apple-ios --lib

View File

@@ -1,2 +1,2 @@
#!/usr/bin/env bash
cargo build --locked --features flutter --release --target x86_64-apple-ios --lib
cargo build --features flutter --release --target x86_64-apple-ios --lib

View File

@@ -716,17 +716,6 @@ closeConnection({String? id}) {
stateGlobal.isInMainPage = true;
} else {
final controller = Get.find<DesktopTabController>();
if (controller.tabType == DesktopTabType.terminal &&
controller.onCloseWindow != null) {
// Terminal windows are scoped to one peer. The optional id passed to
// closeConnection() is that peer id, not a terminal tab key
// (${peerId}_${terminalId}). Closing from terminal dialogs should close
// the peer's whole terminal window, including all terminal tabs.
unawaited(controller.onCloseWindow!().catchError((e, _) {
debugPrint('[closeConnection] Failed to close terminal window: $e');
}));
return;
}
controller.closeBy(id);
}
}
@@ -1185,48 +1174,6 @@ 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() {
@@ -2418,19 +2365,6 @@ List<String>? urlLinkToCmdArgs(Uri uri) {
id = uri.path.substring("/new/".length);
} else if (uri.authority == "config") {
if (isAndroid || isIOS) {
final allowDeepLinkServerSettings =
bind.mainGetBuildinOption(key: kOptionAllowDeepLinkServerSettings) ==
'Y';
if (!allowDeepLinkServerSettings) {
debugPrint(
"Ignore rustdesk://config because $kOptionAllowDeepLinkServerSettings is not enabled.");
// Keep the user-facing error generic; detailed rejection reason is in debug logs.
// Delay toast to avoid missing overlay during cold-start deeplink handling.
Timer(Duration(seconds: 1), () {
showToast(translate('Failed'));
});
return null;
}
final config = uri.path.substring("/".length);
// add a timer to make showToast work
Timer(Duration(seconds: 1), () {
@@ -2440,24 +2374,11 @@ List<String>? urlLinkToCmdArgs(Uri uri) {
return null;
} else if (uri.authority == "password") {
if (isAndroid || isIOS) {
final allowDeepLinkPassword =
bind.mainGetBuildinOption(key: kOptionAllowDeepLinkPassword) == 'Y';
if (!allowDeepLinkPassword) {
debugPrint(
"Ignore rustdesk://password because $kOptionAllowDeepLinkPassword is not enabled.");
// Keep the user-facing error generic; detailed rejection reason is in debug logs.
// Delay toast to avoid missing overlay during cold-start deeplink handling.
Timer(Duration(seconds: 1), () {
showToast(translate('Failed'));
});
return null;
}
final password = uri.path.substring("/".length);
if (password.isNotEmpty) {
Timer(Duration(seconds: 1), () async {
final ok =
await bind.mainSetPermanentPasswordWithResult(password: password);
showToast(translate(ok ? 'Successful' : 'Failed'));
await bind.mainSetPermanentPassword(password: password);
showToast(translate('Successful'));
});
}
}
@@ -3124,15 +3045,6 @@ void onCopyFingerprint(String value) {
}
}
void onCopyId(String value) {
if (value.isNotEmpty) {
Clipboard.setData(ClipboardData(text: value));
showToast('$value\n${translate("Copied")}');
} else {
showToast(translate("Invalid ID"));
}
}
Future<bool> callMainCheckSuperUserPermission() async {
bool checked = await bind.mainCheckSuperUserPermission();
if (isMacOS) {
@@ -3151,11 +3063,6 @@ Future<void> start_service(bool is_start) async {
}
Future<bool> canBeBlocked() async {
if (isWeb) {
// Web can only act as a controller, never as a controlled side,
// so it should never be blocked by a remote session.
return false;
}
// First check control permission
final controlPermission = await bind.mainGetCommon(
key: "is-remote-modify-enabled-by-control-permissions");
@@ -3401,12 +3308,7 @@ Future<List<Rect>> getScreenRectList() async {
}
openMonitorInTheSameTab(int i, FFI ffi, PeerInfo pi,
{bool updateCursorPos = true, bool recordSelection = true}) {
if (recordSelection) {
ffi.ffiModel.lastUserDisplay = i;
ffi.ffiModel.cancelPendingRestoreTimer();
ffi.ffiModel.pendingMonitorRestore = null;
}
{bool updateCursorPos = true}) {
final displays = i == kAllDisplayValue
? List.generate(pi.displays.length, (index) => index)
: [i];
@@ -3769,54 +3671,14 @@ Widget loadPowered(BuildContext context) {
).marginOnly(top: 6);
}
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) {
// max 300 x 60
Widget loadLogo() {
return FutureBuilder<ByteData>(
future: rootBundle.load('assets/logo.png'),
builder: (BuildContext context, AsyncSnapshot<ByteData> snapshot) {
if (snapshot.hasData) {
final image = Image.asset(
asset,
'assets/logo.png',
fit: BoxFit.contain,
errorBuilder: (ctx, error, stackTrace) {
return Container();
@@ -3828,14 +3690,9 @@ class _LogoState extends State<_Logo> {
).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,
@@ -4013,11 +3870,6 @@ bool whitelistNotEmpty() {
return v != '' && v != ',';
}
bool idWhitelistNotEmpty() {
final v = bind.mainGetOptionSync(key: kOptionIdWhitelist);
return v != '' && v != ',';
}
// `setMovable()` is only supported on macOS.
//
// On macOS, the window can be dragged by the tab bar by default.
@@ -4048,8 +3900,7 @@ Widget netWorkErrorWidget() {
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (!gFFI.userModel.networkErrorFromServer.value)
Text(translate("network_error_tip")),
Text(translate("network_error_tip")),
ElevatedButton(
onPressed: gFFI.userModel.refreshCurrentUser,
child: Text(translate("Retry")))
@@ -4262,42 +4113,3 @@ String mouseButtonsToPeer(int buttons) {
return '';
}
}
/// Build an avatar widget from an avatar URL or data URI string.
/// Returns [fallback] if avatar is empty or cannot be decoded.
/// [borderRadius] defaults to [size]/2 (circle).
Widget? buildAvatarWidget({
required String avatar,
required double size,
double? borderRadius,
Widget? fallback,
}) {
final trimmed = avatar.trim();
if (trimmed.isEmpty) return fallback;
ImageProvider? imageProvider;
if (trimmed.startsWith('data:image/')) {
final comma = trimmed.indexOf(',');
if (comma > 0) {
try {
imageProvider = MemoryImage(base64Decode(trimmed.substring(comma + 1)));
} catch (_) {}
}
} else if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
imageProvider = NetworkImage(trimmed);
}
if (imageProvider == null) return fallback;
final radius = borderRadius ?? size / 2;
return ClipRRect(
borderRadius: BorderRadius.circular(radius),
child: Image(
image: imageProvider,
width: size,
height: size,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => fallback ?? SizedBox.shrink(),
),
);
}

View File

@@ -26,7 +26,6 @@ enum UserStatus { kDisabled, kNormal, kUnverified }
class UserPayload {
String name = '';
String displayName = '';
String avatar = '';
String email = '';
String note = '';
String? verifier;
@@ -36,7 +35,6 @@ class UserPayload {
UserPayload.fromJson(Map<String, dynamic> json)
: name = json['name'] ?? '',
displayName = json['display_name'] ?? '',
avatar = json['avatar'] ?? '',
email = json['email'] ?? '',
note = json['note'] ?? '',
verifier = json['verifier'],
@@ -51,7 +49,6 @@ class UserPayload {
final Map<String, dynamic> map = {
'name': name,
'display_name': displayName,
'avatar': avatar,
'status': status == UserStatus.kDisabled
? 0
: status == UserStatus.kUnverified

View File

@@ -54,9 +54,9 @@ class _AddressBookState extends State<AddressBook> {
const LinearProgressIndicator(),
buildErrorBanner(context,
loading: gFFI.abModel.currentAbLoading,
err: gFFI.abModel.abPullError,
err: gFFI.abModel.currentAbPullError,
retry: null,
close: gFFI.abModel.clearPullErrors),
close: () => gFFI.abModel.currentAbPullError.value = ''),
buildErrorBanner(context,
loading: gFFI.abModel.currentAbLoading,
err: gFFI.abModel.currentAbPushError,

View File

@@ -1,6 +1,3 @@
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';
@@ -8,136 +5,27 @@ 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';
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;
late void Function(VoidCallback) setState;
bool get needLoad => !_isPeersLoaded && !_isPeersLoading;
bool get isPeersLoaded => _isPeersLoaded;
AllPeersLoader({
@visibleForTesting Future<void> Function(List<String> ids)? queryOnlines,
@visibleForTesting Duration? queryOnlineDebounce,
}) : _queryOnlines = queryOnlines ?? ((ids) => bind.queryOnlines(ids: ids)),
_queryOnlineDebounce =
queryOnlineDebounce ?? _defaultQueryOnlineDebounce;
AllPeersLoader();
void init(void Function(VoidCallback) setState) {
_setState = setState;
_isCleared = false;
this.setState = setState;
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() {
@@ -145,11 +33,6 @@ 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 {
@@ -176,106 +59,50 @@ class AllPeersLoader {
}
void _mergeAllPeers() {
if (_isCleared) {
return;
Map<String, dynamic> combinedPeers = {};
for (var p in gFFI.abModel.allPeers()) {
if (!combinedPeers.containsKey(p.id)) {
combinedPeers[p.id] = p.toJson();
}
}
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(() {
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(() {
_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 {

View File

@@ -205,10 +205,6 @@ void changeWhiteList({Function()? callback}) async {
const SizedBox(
height: 8.0,
),
Text(translate("whitelist_cidr_tip")),
const SizedBox(
height: 8.0,
),
Row(
children: [
Expanded(
@@ -286,111 +282,6 @@ void changeWhiteList({Function()? callback}) async {
});
}
void changeIdWhiteList({Function()? callback}) async {
final curIdWhiteList = await bind.mainGetOption(key: kOptionIdWhitelist);
var newIdWhiteListField = curIdWhiteList == defaultOptionWhitelist
? ''
: curIdWhiteList.split(',').join('\n');
var controller = TextEditingController(text: newIdWhiteListField);
var msg = "";
var isInProgress = false;
final isOptFixed = isOptionFixed(kOptionIdWhitelist);
gFFI.dialogManager.show((setState, close, context) {
return CustomAlertDialog(
title: Text(translate("ID whitelisting")),
content: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(translate("whitelist_sep")),
const SizedBox(
height: 8.0,
),
Text(translate("id_whitelist_wildcard_tip")),
const SizedBox(
height: 8.0,
),
Text(translate("id_whitelist_caveat_tip")),
const SizedBox(
height: 8.0,
),
Row(
children: [
Expanded(
child: TextField(
maxLines: null,
decoration: InputDecoration(
errorText: msg.isEmpty ? null : translate(msg),
),
controller: controller,
enabled: !isOptFixed,
autofocus: true)
.workaroundFreezeLinuxMint(),
),
],
),
const SizedBox(
height: 4.0,
),
// NOT use Offstage to wrap LinearProgressIndicator
if (isInProgress) const LinearProgressIndicator(),
],
),
actions: [
dialogButton("Cancel", onPressed: close, isOutline: true),
if (!isOptFixed)
dialogButton("Clear", onPressed: () async {
await bind.mainSetOption(
key: kOptionIdWhitelist, value: defaultOptionWhitelist);
callback?.call();
close();
}, isOutline: true),
if (!isOptFixed)
dialogButton(
"OK",
onPressed: () async {
setState(() {
msg = "";
isInProgress = true;
});
newIdWhiteListField = controller.text.trim();
var newIdWhiteList = "";
if (newIdWhiteListField.isEmpty) {
// pass
} else {
final ids = newIdWhiteListField
.trim()
.split(RegExp(r"[\s,;\n]+"))
.where((e) => e.isNotEmpty)
.toList();
// Separators are handled above; allow all other Unicode characters.
for (final id in ids) {
final hasControlCharacters = id.runes.any(
(char) => char <= 0x1f || (char >= 0x7f && char <= 0x9f));
if (hasControlCharacters) {
msg = "${translate("Invalid ID")} $id";
setState(() {
isInProgress = false;
});
return;
}
}
newIdWhiteList = ids.join(',');
}
if (newIdWhiteList.trim().isEmpty) {
newIdWhiteList = defaultOptionWhitelist;
}
await bind.mainSetOption(
key: kOptionIdWhitelist, value: newIdWhiteList);
callback?.call();
close();
},
),
],
onCancel: close,
);
});
}
Future<String> changeDirectAccessPort(
String currentIP, String currentPort) async {
final controller = TextEditingController(text: currentPort);

View File

@@ -2,7 +2,6 @@ import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hbb/common/hbbs/hbbs.dart';
import 'package:flutter_hbb/models/platform_model.dart';
import 'package:flutter_hbb/models/user_model.dart';
@@ -12,7 +11,6 @@ import 'package:url_launcher/url_launcher.dart';
import '../../common.dart';
import './dialog.dart';
import './oidc_auth_status.dart';
const kOpSvgList = [
'github',
@@ -22,40 +20,8 @@ const kOpSvgList = [
'okta',
'facebook',
'azure',
'auth0',
'microsoft'
'auth0'
];
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;
@@ -94,7 +60,6 @@ class ButtonOP extends StatelessWidget {
final Color primaryColor;
final double height;
final Function() onTap;
final bool Function() canStartAuth;
const ButtonOP({
Key? key,
@@ -104,29 +69,32 @@ 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 branding = _oidcProviderBranding(op);
final buttonLabel = translate("Continue with {${branding.label}}");
final opLabel = {
'github': 'GitHub',
'gitlab': 'GitLab'
}[op.toLowerCase()] ??
toCapitalized(op);
return Row(children: [
Container(
height: height,
width: 200,
child: Obx(() => ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: primaryColor,
backgroundColor: curOP.value.isEmpty || curOP.value == op
? primaryColor
: Colors.grey,
).copyWith(elevation: ButtonStyleButton.allOrNull(0.0)),
onPressed:
curOP.value == 'rustdesk' || !canStartAuth() ? null : onTap,
onPressed: curOP.value.isEmpty || curOP.value == op ? onTap : null,
child: Row(
children: [
SizedBox(
width: 30,
child: _IconOP(
op: branding.iconKey,
op: op,
icon: icon,
margin: EdgeInsets.only(right: 5),
),
@@ -134,7 +102,8 @@ class ButtonOP extends StatelessWidget {
Expanded(
child: FittedBox(
fit: BoxFit.scaleDown,
child: Center(child: Text(buttonLabel)),
child: Center(
child: Text(translate("Continue with {$opLabel}"))),
),
),
],
@@ -150,120 +119,15 @@ class ConfigOP {
ConfigOP({required this.op, required this.icon});
}
class _OidcAuthController {
final RxString curOP = ''.obs;
Future<void> _pendingOperation = Future<void>.value();
int _authAttempt = 0;
bool _closed = false;
final _cancelInProgress = false.obs;
bool _isCurrent(int authAttempt, String op) {
return !_closed && authAttempt == _authAttempt && curOP.value == op;
}
Future<bool> start(String op) {
if (!canStart()) {
return Future<bool>.value(false);
}
final authAttempt = ++_authAttempt;
curOP.value = op;
// Web auth must start during the original user gesture so popups are allowed.
if (isWeb) {
return _startWeb(authAttempt, op);
}
final completer = Completer<bool>();
_pendingOperation = _pendingOperation.then((_) async {
if (!_isCurrent(authAttempt, op)) {
completer.complete(false);
return;
}
try {
await bind.mainAccountAuthCancel();
if (!_isCurrent(authAttempt, op)) {
completer.complete(false);
return;
}
await bind.mainAccountAuth(op: op, rememberMe: true);
completer.complete(_isCurrent(authAttempt, op));
} catch (error, stackTrace) {
completer.completeError(error, stackTrace);
}
});
return completer.future;
}
Future<bool> _startWeb(int authAttempt, String op) async {
await bind.mainAccountAuth(op: op, rememberMe: true);
return _isCurrent(authAttempt, op);
}
bool canStart() {
return !_closed && !_cancelInProgress.value;
}
Future<bool> cancelCurrent(String op) {
if (!canStart() || curOP.value != op) {
return Future<bool>.value(false);
}
final authAttempt = ++_authAttempt;
final completer = Completer<bool>();
_cancelInProgress.value = true;
_pendingOperation = _pendingOperation.then((_) async {
try {
await bind.mainAccountAuthCancel();
completer.complete(_isCurrent(authAttempt, op));
} catch (error, stackTrace) {
completer.completeError(error, stackTrace);
} finally {
_cancelInProgress.value = false;
}
});
return completer.future;
}
Future<void> _cancelBackend() async {
try {
await bind.mainAccountAuthCancel();
} catch (error, stackTrace) {
debugPrint('Failed to cancel account authentication $error');
debugPrintStack(stackTrace: stackTrace);
}
}
Future<void> close() async {
if (_closed) {
return;
}
final hasActiveOidcAuth =
curOP.value.isNotEmpty && curOP.value != 'rustdesk';
_closed = true;
_authAttempt++;
curOP.value = '';
if (hasActiveOidcAuth) {
await _cancelBackend();
}
await _pendingOperation;
if (hasActiveOidcAuth) {
await _cancelBackend();
}
}
}
class WidgetOP extends StatefulWidget {
final ConfigOP config;
final RxString curOP;
final Function(Map<String, dynamic>) cbLogin;
final Future<bool> Function(String) startAuth;
final Future<bool> Function(String) cancelAuth;
final bool Function() canStartAuth;
const WidgetOP({
Key? key,
required this.config,
required this.curOP,
required this.cbLogin,
required this.startAuth,
required this.cancelAuth,
required this.canStartAuth,
}) : super(key: key);
@override
@@ -274,8 +138,6 @@ class WidgetOP extends StatefulWidget {
class _WidgetOPState extends State<WidgetOP> {
Timer? _updateTimer;
bool _isAuthStatusQueryInFlight = false;
int _authAttempt = 0;
String _stateMsg = '';
String _failedMsg = '';
String _url = '';
@@ -286,180 +148,55 @@ class _WidgetOPState extends State<WidgetOP> {
_updateTimer?.cancel();
}
_beginQueryState(int authAttempt) {
_updateTimer?.cancel();
unawaited(_runAuthStatusQuery(() => _updateState(authAttempt)));
_beginQueryState() {
_updateTimer = Timer.periodic(Duration(seconds: 1), (timer) {
unawaited(_runAuthStatusQuery(() => _updateState(authAttempt)));
_updateState();
});
}
Future<void> _runAuthStatusQuery(Future<void> Function() query) async {
if (_isAuthStatusQueryInFlight) {
return;
}
_isAuthStatusQueryInFlight = true;
try {
await query();
} finally {
_isAuthStatusQueryInFlight = false;
}
}
Future<void> _launchAuthUrl(String url) async {
try {
final launched = await launchUrl(
Uri.parse(url),
mode: LaunchMode.externalApplication,
);
if (!launched) {
debugPrint('Failed to open OIDC authentication URL');
}
} catch (error, stackTrace) {
debugPrint(
'Failed to open OIDC authentication URL (${error.runtimeType})');
debugPrintStack(stackTrace: stackTrace);
}
}
Future<void> _copyAuthUrl(String url) async {
try {
await Clipboard.setData(ClipboardData(text: url));
showToast(
translate('Copied'),
);
} catch (error, stackTrace) {
debugPrint(
'Failed to copy OIDC authentication URL (${error.runtimeType})');
debugPrintStack(stackTrace: stackTrace);
showToast(translate('Failed'));
}
}
void _runCurrentAuthUrlAction(
int authAttempt,
String authUrl,
Future<void> Function(String) action,
) {
if (!mounted ||
authAttempt != _authAttempt ||
widget.curOP.value != widget.config.op ||
authUrl.isEmpty ||
_url != authUrl) {
return;
}
unawaited(action(authUrl));
}
void _invalidateAuthAttempt() {
_authAttempt++;
_url = '';
}
bool _isCurrentAuthAttempt(int authAttempt) {
return mounted &&
authAttempt == _authAttempt &&
widget.curOP.value == widget.config.op;
}
Future<void> _handleAuthFailure(
int authAttempt,
Object error,
String operation,
) async {
debugPrint('Failed to $operation $error');
if (!_isCurrentAuthAttempt(authAttempt)) {
return;
}
_updateTimer?.cancel();
setState(() => _failedMsg = 'Failed');
try {
final canceled = await widget.cancelAuth(widget.config.op);
if (!canceled || !_isCurrentAuthAttempt(authAttempt)) {
return;
}
} catch (cancelError, stackTrace) {
debugPrint('Failed to cancel account authentication $cancelError');
debugPrintStack(stackTrace: stackTrace);
return;
}
setState(() {
_invalidateAuthAttempt();
widget.curOP.value = '';
});
}
Future<void> _updateState(int authAttempt) {
if (!mounted ||
authAttempt != _authAttempt ||
widget.curOP.value != widget.config.op) {
_updateTimer?.cancel();
return Future<void>.value();
}
return bind.mainAccountAuthResult().then<void>((result) {
if (!mounted ||
authAttempt != _authAttempt ||
widget.curOP.value != widget.config.op ||
result.isEmpty) {
_updateState() {
bind.mainAccountAuthResult().then((result) {
if (result.isEmpty) {
return;
}
final resultMap = jsonDecode(result);
if (resultMap == null) {
return;
}
final String backendStateMsg = resultMap['state_msg'];
final String stateMsg = resultMap['state_msg'];
String failedMsg = resultMap['failed_msg'];
final String? url = resultMap['url'];
final stateMsg = backendStateMsg == _requestingAccountAuth &&
(url == null || url.isEmpty)
? _waitingAccountAuth
: backendStateMsg;
final bool urlLaunched = (resultMap['url_launched'] as bool?) ?? false;
final authBody = resultMap['auth_body'];
if (authBody != null) {
_updateTimer?.cancel();
_invalidateAuthAttempt();
widget.curOP.value = '';
widget.cbLogin(authBody as Map<String, dynamic>);
return;
}
final stateChanged = _stateMsg != stateMsg || _failedMsg != failedMsg;
final newUrl = _url.isEmpty && url != null && url.isNotEmpty ? url : null;
if (!stateChanged && newUrl == null) {
return;
}
setState(() {
_stateMsg = stateMsg;
_failedMsg = failedMsg;
if (newUrl != null) {
_url = newUrl;
if (_stateMsg != stateMsg || _failedMsg != failedMsg) {
if (_url.isEmpty && url != null && url.isNotEmpty) {
if (!urlLaunched) {
launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
}
_url = url;
}
if (failedMsg.isNotEmpty) {
_invalidateAuthAttempt();
widget.curOP.value = '';
if (authBody != null) {
_updateTimer?.cancel();
widget.curOP.value = '';
widget.cbLogin(authBody as Map<String, dynamic>);
}
});
if (newUrl != null && failedMsg.isEmpty && !urlLaunched) {
unawaited(_launchAuthUrl(newUrl));
setState(() {
_stateMsg = stateMsg;
_failedMsg = failedMsg;
if (failedMsg.isNotEmpty) {
widget.curOP.value = '';
_updateTimer?.cancel();
}
});
}
}).catchError(
(e) => _handleAuthFailure(
authAttempt,
e,
'query account authentication',
),
);
});
}
int _resetState() {
_updateTimer?.cancel();
setState(() {
_invalidateAuthAttempt();
_stateMsg = _waitingAccountAuth;
_failedMsg = '';
});
return _authAttempt;
_resetState() {
_stateMsg = '';
_failedMsg = '';
_url = '';
}
@override
@@ -472,31 +209,11 @@ class _WidgetOPState extends State<WidgetOP> {
icon: widget.config.icon,
primaryColor: str2color(widget.config.op, 0x7f),
height: 36,
canStartAuth: widget.canStartAuth,
onTap: () async {
if (!widget.canStartAuth()) {
return;
}
final authAttempt = _resetState();
try {
final started = await widget.startAuth(widget.config.op);
if (!started) {
return;
}
} catch (e) {
await _handleAuthFailure(
authAttempt,
e,
'start account authentication',
);
return;
}
if (!mounted ||
authAttempt != _authAttempt ||
widget.curOP.value != widget.config.op) {
return;
}
_beginQueryState(authAttempt);
_resetState();
widget.curOP.value = widget.config.op;
await bind.mainAccountAuth(op: widget.config.op, rememberMe: true);
_beginQueryState();
},
),
Obx(() {
@@ -504,74 +221,55 @@ 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,
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (_stateMsg.isNotEmpty && _failedMsg.isEmpty)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: OidcAuthStatus(
message: translate(_stateMsg),
browserFallbackPrompt: translate(
"Browser didn't open? Use the url below to sign in.",
),
authUrl: authUrl,
copyLabel: translate('Copy to clipboard'),
onCopy: authUrl.isEmpty
? null
: () => _runCurrentAuthUrlAction(
authAttempt,
authUrl,
_copyAuthUrl,
),
),
),
if (_failedMsg.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Builder(builder: (context) {
final errorColor = Theme.of(context).colorScheme.error;
final bgColor = Theme.of(context)
.colorScheme
.errorContainer
.withOpacity(0.3);
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 8.0, vertical: 6.0),
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(4.0),
child: RichText(
text: TextSpan(
text: '$_stateMsg ',
style:
DefaultTextStyle.of(context).style.copyWith(fontSize: 12),
children: <TextSpan>[
TextSpan(
text: _failedMsg,
style: DefaultTextStyle.of(context).style.copyWith(
fontSize: 14,
color: Colors.red,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline,
color: errorColor, size: 16),
const SizedBox(width: 6),
Flexible(
child: SelectableText(
translate(_failedMsg),
style:
DefaultTextStyle.of(context).style.copyWith(
fontSize: 13,
color: errorColor,
),
),
),
],
),
);
}),
),
],
],
),
),
);
}),
Obx(
() => Offstage(
offstage: widget.curOP.value != widget.config.op,
child: const SizedBox(
height: 5.0,
),
),
),
Obx(
() => Offstage(
offstage: widget.curOP.value != widget.config.op,
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: 20),
child: ElevatedButton(
onPressed: () {
widget.curOP.value = '';
_updateTimer?.cancel();
_resetState();
bind.mainAccountAuthCancel();
},
child: Text(
translate('Cancel'),
style: TextStyle(fontSize: 15),
),
),
),
),
),
],
);
}
@@ -581,18 +279,12 @@ class LoginWidgetOP extends StatelessWidget {
final List<ConfigOP> ops;
final RxString curOP;
final Function(Map<String, dynamic>) cbLogin;
final Future<bool> Function(String) startAuth;
final Future<bool> Function(String) cancelAuth;
final bool Function() canStartAuth;
LoginWidgetOP({
Key? key,
required this.ops,
required this.curOP,
required this.cbLogin,
required this.startAuth,
required this.cancelAuth,
required this.canStartAuth,
}) : super(key: key);
@override
@@ -603,9 +295,6 @@ class LoginWidgetOP extends StatelessWidget {
config: op,
curOP: curOP,
cbLogin: cbLogin,
startAuth: startAuth,
cancelAuth: cancelAuth,
canStartAuth: canStartAuth,
),
const Divider(
indent: 5,
@@ -683,11 +372,12 @@ class LoginWidgetUserPass extends StatelessWidget {
translate('Login'),
style: TextStyle(fontSize: 16),
),
onPressed: curOP.value.isEmpty && !isInProgress
? () {
onLogin();
}
: null,
onPressed:
curOP.value.isEmpty || curOP.value == 'rustdesk'
? () {
onLogin();
}
: null,
)),
),
])),
@@ -698,28 +388,8 @@ class LoginWidgetUserPass extends StatelessWidget {
const kAuthReqTypeOidc = 'oidc/';
Future<bool?>? _activeLoginDialog;
// call this directly
Future<bool?> loginDialog() {
final activeDialog = _activeLoginDialog;
if (activeDialog != null) {
return activeDialog;
}
final dialog = _openLoginDialogOnce();
_activeLoginDialog = dialog;
return dialog;
}
Future<bool?> _openLoginDialogOnce() async {
try {
return await _openLoginDialog();
} finally {
_activeLoginDialog = null;
}
}
Future<bool?> _openLoginDialog() async {
Future<bool?> loginDialog() async {
var username =
TextEditingController(text: UserModel.getLocalUserInfo()?['name'] ?? '');
var password = TextEditingController();
@@ -729,28 +399,14 @@ Future<bool?> _openLoginDialog() async {
String? usernameMsg;
String? passwordMsg;
var isInProgress = false;
final oidcAuth = _OidcAuthController();
final curOP = oidcAuth.curOP;
final RxString curOP = ''.obs;
// Track hover state for the close icon
bool isCloseHovered = false;
final loginOptions = [].obs;
final loginOptionsError = Rxn<Object>();
final loginOptionsInProgress = false.obs;
fetchLoginOptions() async {
loginOptionsInProgress.value = true;
try {
loginOptions.value = await UserModel.queryOidcLoginOptions();
loginOptionsError.value = null;
} catch (e) {
debugPrint("queryOidcLoginOptions failed: $e");
loginOptionsError.value = e;
} finally {
loginOptionsInProgress.value = false;
}
}
Future.delayed(Duration.zero, fetchLoginOptions);
Future.delayed(Duration.zero, () async {
loginOptions.value = await UserModel.queryOidcLoginOptions();
});
final res = await gFFI.dialogManager.show<bool>((setState, close, context) {
username.addListener(() {
@@ -824,9 +480,6 @@ Future<bool?> _openLoginDialog() async {
}
onLogin() async {
if (curOP.value.isNotEmpty || isInProgress) {
return;
}
// validate
if (username.text.isEmpty) {
setState(() => usernameMsg = translate('Username missed'));
@@ -857,36 +510,6 @@ Future<bool?> _openLoginDialog() async {
}
thirdAuthWidget() => Obx(() {
final error = loginOptionsError.value;
final inProgress = loginOptionsInProgress.value;
if (error != null) {
return Column(
children: [
const SizedBox(height: 8.0),
// NOT use Offstage to wrap LinearProgressIndicator
if (inProgress) const LinearProgressIndicator(),
if (!inProgress && error is! RequestException)
Text(
translate('network_error_tip'),
style: const TextStyle(fontSize: 12),
textAlign: TextAlign.center,
),
TextButton(
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.primary,
),
onPressed: inProgress ? null : fetchLoginOptions,
child: Text(translate('Retry')),
),
if (!inProgress)
SelectableText(
error.toString(),
style: const TextStyle(fontSize: 11, color: Colors.red),
textAlign: TextAlign.center,
),
],
);
}
return Offstage(
offstage: loginOptions.isEmpty,
child: Column(
@@ -907,9 +530,6 @@ Future<bool?> _openLoginDialog() async {
.map((e) => ConfigOP(op: e['name'], icon: e['icon']))
.toList(),
curOP: curOP,
startAuth: oidcAuth.start,
cancelAuth: oidcAuth.cancelCurrent,
canStartAuth: oidcAuth.canStart,
cbLogin: (Map<String, dynamic> authBody) async {
LoginResponse? resp;
try {
@@ -991,7 +611,7 @@ Future<bool?> _openLoginDialog() async {
onCancel: onDialogCancel,
onSubmit: onLogin,
);
}).whenComplete(oidcAuth.close);
});
if (res != null) {
await UserModel.updateOtherModels();

View File

@@ -1,157 +0,0 @@
import 'package:flutter/material.dart';
const _statusFontSize = 12.0;
const _statusSpacing = 4.0;
const _messageActionSpacing = 8.0;
const _desktopActionSize = 28.0;
const _touchPlatforms = <TargetPlatform>{
TargetPlatform.android,
TargetPlatform.iOS,
TargetPlatform.fuchsia,
};
class OidcAuthStatus extends StatelessWidget {
final String message;
final String browserFallbackPrompt;
final String authUrl;
final String copyLabel;
final VoidCallback? onCopy;
const OidcAuthStatus({
super.key,
required this.message,
required this.browserFallbackPrompt,
required this.authUrl,
required this.copyLabel,
this.onCopy,
});
@override
Widget build(BuildContext context) {
final messageStyle =
DefaultTextStyle.of(context).style.copyWith(fontSize: _statusFontSize);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
SelectableText(message, style: messageStyle),
if (authUrl.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: _messageActionSpacing),
child: _OidcAuthFallback(
browserFallbackPrompt: browserFallbackPrompt,
authUrl: authUrl,
copyLabel: copyLabel,
onCopy: onCopy,
),
),
],
);
}
}
class _OidcAuthFallback extends StatefulWidget {
final String browserFallbackPrompt;
final String authUrl;
final String copyLabel;
final VoidCallback? onCopy;
const _OidcAuthFallback({
required this.browserFallbackPrompt,
required this.authUrl,
required this.copyLabel,
required this.onCopy,
});
@override
State<_OidcAuthFallback> createState() => _OidcAuthFallbackState();
}
class _OidcAuthFallbackState extends State<_OidcAuthFallback> {
bool _expanded = false;
@override
void didUpdateWidget(covariant _OidcAuthFallback oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.authUrl != widget.authUrl) {
_expanded = false;
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final helperStyle = DefaultTextStyle.of(context).style.copyWith(
fontSize: _statusFontSize,
color: theme.colorScheme.onSurfaceVariant,
);
final linkColor = theme.brightness == Brightness.dark
? Colors.blue.shade300
: Colors.blue.shade800;
final isTouchPlatform = _touchPlatforms.contains(theme.platform);
final actionSize =
isTouchPlatform ? kMinInteractiveDimension : _desktopActionSize;
final urlStyle =
DefaultTextStyle.of(context).style.copyWith(fontSize: _statusFontSize);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.browserFallbackPrompt,
style: helperStyle,
textAlign: TextAlign.center,
),
Padding(
padding: const EdgeInsets.only(top: _statusSpacing),
child: _buildUrl(urlStyle, linkColor, actionSize),
),
],
);
}
void _copyAndExpand() {
setState(() => _expanded = true);
widget.onCopy?.call();
}
Widget _buildUrl(TextStyle urlStyle, Color linkColor, double actionSize) {
final collapsedUrl = SizedBox(
width: double.infinity,
child: TextButton(
style: TextButton.styleFrom(
foregroundColor: linkColor,
minimumSize: Size(0, actionSize),
padding: EdgeInsets.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.standard,
),
onPressed: _copyAndExpand,
child: Text(
widget.authUrl,
maxLines: 1,
overflow: TextOverflow.ellipsis,
softWrap: false,
style: urlStyle.copyWith(
color: linkColor,
decoration: TextDecoration.underline,
),
),
),
);
final collapsedChild = widget.onCopy == null
? collapsedUrl
: Tooltip(message: widget.copyLabel, child: collapsedUrl);
return Container(
width: double.infinity,
constraints: BoxConstraints(minHeight: actionSize),
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: _messageActionSpacing),
decoration: BoxDecoration(
border: Border.all(color: Theme.of(context).dividerColor),
borderRadius: BorderRadius.circular(_statusSpacing),
),
child: _expanded
? SelectableText(widget.authUrl, style: urlStyle)
: collapsedChild,
);
}
}

View File

@@ -570,14 +570,11 @@ class MyGroupPeerView extends BasePeersView {
static bool filter(Peer peer) {
final model = gFFI.groupModel;
if (model.searchAccessibleItemNameText.isNotEmpty) {
final text = model.searchAccessibleItemNameText.value.toLowerCase();
final searchPeersOfUser = model.users.any((user) =>
user.name == peer.loginName &&
(user.name.toLowerCase().contains(text) ||
user.displayNameOrName.toLowerCase().contains(text)));
final searchPeersOfDeviceGroup =
peer.device_group_name.toLowerCase().contains(text) &&
model.deviceGroups.any((g) => g.name == peer.device_group_name);
final text = model.searchAccessibleItemNameText.value;
final searchPeersOfUser = peer.loginName.contains(text) &&
model.users.any((user) => user.name == peer.loginName);
final searchPeersOfDeviceGroup = peer.device_group_name.contains(text) &&
model.deviceGroups.any((g) => g.name == peer.device_group_name);
if (!searchPeersOfUser && !searchPeersOfDeviceGroup) {
return false;
}

View File

@@ -31,7 +31,7 @@ class RawKeyFocusScope extends StatelessWidget {
// https://github.com/flutter/flutter/issues/154053
final useRawKeyEvents = isLinux && !isWeb;
// FIXME: On Windows, `AltGr` will generate `Alt` and `Control` key events,
// while `Alt` and `Control` are separated key events for en-US input method.
// while `Alt` and `Control` are seperated key events for en-US input method.
return FocusScope(
autofocus: true,
child: Focus(

View File

@@ -13,84 +13,8 @@ import 'package:flutter_hbb/models/model.dart';
import 'package:flutter_hbb/models/platform_model.dart';
import 'package:flutter_hbb/utils/multi_window_manager.dart';
import 'package:get/get.dart';
import 'package:url_launcher/url_launcher.dart';
bool isEditOsPassword = false;
const String kPeerOptionAllowWaylandKeyboard = 'allow-wayland-keyboard';
const String kWaylandKeyboardIssueUrl =
'https://github.com/rustdesk/rustdesk/issues/14586';
final Set<String> _waylandKeyboardPromptSuppressedConnectionIds = <String>{};
Future<bool> openWaylandKeyboardIssueUrl() {
return launchUrl(
Uri.parse(kWaylandKeyboardIssueUrl),
mode: LaunchMode.externalApplication,
);
}
bool isWaylandKeyboardPromptSuppressedForConnection(String connectionId) {
return _waylandKeyboardPromptSuppressedConnectionIds.contains(connectionId);
}
void setWaylandKeyboardPromptSuppressedForConnection(
String connectionId, bool suppressed) {
if (suppressed) {
_waylandKeyboardPromptSuppressedConnectionIds.add(connectionId);
} else {
_waylandKeyboardPromptSuppressedConnectionIds.remove(connectionId);
}
}
void clearWaylandKeyboardPromptSuppressedForConnection(String connectionId) {
_waylandKeyboardPromptSuppressedConnectionIds.remove(connectionId);
}
bool shouldShowWaylandKeyboardPrompt({
required String connectionId,
required bool isWaylandPeer,
required bool allowWaylandKeyboardRemembered,
}) {
return isWaylandPeer &&
!allowWaylandKeyboardRemembered &&
!isWaylandKeyboardPromptSuppressedForConnection(connectionId);
}
Widget waylandKeyboardScopeChip(BuildContext context, String text) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(999),
border: Border.all(color: colorScheme.primary.withOpacity(0.35)),
),
child: Text(
text,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600),
),
);
}
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 {
final Widget child;
@@ -163,179 +87,12 @@ handleOsPasswordAction(
}
}
void showWaylandKeyboardInputWarningDialog(
{required String id,
required String connectionId,
required FFI ffi,
required Future<void> Function() onEnable}) {
bool remember = false;
bool consentInProgress = false;
bool dialogClosed = false;
final dialogFuture = ffi.dialogManager.show((setState, close, context) {
void safeSetState(VoidCallback fn) {
if (dialogClosed) {
return;
}
try {
setState(fn);
} catch (e) {
debugPrint('Ignore setState after dialog disposal: $e');
}
}
void closeDialog() {
if (dialogClosed) {
return;
}
dialogClosed = true;
close();
}
Future<void> enableAndContinue() async {
if (consentInProgress || dialogClosed) {
return;
}
consentInProgress = true;
safeSetState(() {});
try {
await onEnable();
} catch (e, st) {
debugPrint('Failed to enable Wayland keyboard input consent: $e');
debugPrintStack(stackTrace: st);
consentInProgress = false;
safeSetState(() {});
return;
}
ffi.inputModel.keyboardInputAllowed = true;
var rememberPersisted = true;
if (remember) {
try {
await bind.mainSetPeerOption(
id: id,
key: kPeerOptionAllowWaylandKeyboard,
value: bool2option(kPeerOptionAllowWaylandKeyboard, true));
} catch (e) {
rememberPersisted = false;
debugPrint('Failed to persist Wayland keyboard input consent: $e');
}
}
// Always suppress prompt for current connection after explicit consent.
setWaylandKeyboardPromptSuppressedForConnection(connectionId, true);
closeDialog();
if (remember && !rememberPersisted) {
// It's a rare edge case that persisting the user's choice fails.
// Failed to persist the user's choice, but still allow keyboard input for current session.
showToast(translate('Failed'));
}
}
void cancel() {
if (consentInProgress) {
return;
}
closeDialog();
}
return CustomAlertDialog(
title: null,
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
msgboxContent(
'',
'wayland-keyboard-input-disabled-tip',
'wayland-keyboard-input-consent-tip',
),
SizedBox(height: isMobile ? 2 : 6),
if (isMobile) ...[
Text(
translate('wayland-keyboard-input-applies-to-tip'),
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
).marginOnly(bottom: 6),
Wrap(
spacing: 6,
runSpacing: 6,
children: [
waylandKeyboardScopeChip(
context, translate('Send clipboard keystrokes')),
waylandKeyboardScopeChip(
context, translate('wayland-soft-keyboard-input-label')),
],
).marginOnly(bottom: 10),
],
TextButton(
onPressed: consentInProgress
? null
: () async {
try {
final opened = await openWaylandKeyboardIssueUrl();
if (!opened) {
// Opening this optional help link almost never fails in
// normal desktop environments. Keep the result handled
// for review hygiene, but avoid a low-value user toast.
debugPrint('Failed to open Wayland keyboard issue URL');
}
} catch (e) {
debugPrint(
'Failed to open Wayland keyboard issue URL: $e');
}
},
style: TextButton.styleFrom(
foregroundColor: Colors.blue,
padding: EdgeInsets.zero,
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Text(
translate('Why this happens'),
style: const TextStyle(decoration: TextDecoration.underline),
),
).marginOnly(bottom: 6),
CheckboxListTile(
value: remember,
dense: true,
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
title: Text(translate('remember-wayland-keyboard-choice-tip')),
onChanged: consentInProgress
? null
: (v) {
safeSetState(() => remember = v == true);
},
),
],
),
actions: [
dialogButton(
'Cancel',
onPressed: consentInProgress ? null : cancel,
isOutline: true,
),
dialogButton(
'OK',
onPressed:
consentInProgress ? null : () => unawaited(enableAndContinue()),
),
],
onCancel: consentInProgress ? null : cancel,
onSubmit: consentInProgress ? null : () => unawaited(enableAndContinue()),
);
}, clickMaskDismiss: false, backDismiss: false);
unawaited(dialogFuture.whenComplete(() => dialogClosed = true));
}
List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
final ffiModel = ffi.ffiModel;
final pi = ffiModel.pi;
final perms = ffiModel.permissions;
final sessionId = ffi.sessionId;
final isDefaultConn = ffi.connType == ConnType.defaultConn;
final isWaylandPeer = pi.platform == kPeerPlatformLinux && pi.isWayland;
List<TTextMenu> v = [];
// elevation
@@ -385,60 +142,11 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
v.add(TTextMenu(
child: Text(translate('Send clipboard keystrokes')),
onPressed: () async {
Future<void> sendClipboardKeystrokes() async {
ClipboardData? data = await Clipboard.getData(Clipboard.kTextPlain);
if (data != null && data.text != null) {
bind.sessionInputString(
sessionId: sessionId, value: data.text ?? "");
}
ClipboardData? data = await Clipboard.getData(Clipboard.kTextPlain);
if (data != null && data.text != null) {
bind.sessionInputString(
sessionId: sessionId, value: data.text ?? "");
}
final allowWaylandKeyboard =
mainGetPeerBoolOptionSync(id, kPeerOptionAllowWaylandKeyboard);
if (shouldShowWaylandKeyboardPrompt(
connectionId: sessionId.toString(),
isWaylandPeer: isWaylandPeer,
allowWaylandKeyboardRemembered: allowWaylandKeyboard,
)) {
ffi.inputModel.keyboardInputAllowed = false;
showWaylandKeyboardInputWarningDialog(
id: id,
connectionId: sessionId.toString(),
ffi: ffi,
onEnable: sendClipboardKeystrokes,
);
return;
}
await sendClipboardKeystrokes();
}));
}
if (isDefaultConn &&
isWaylandPeer &&
(mainGetPeerBoolOptionSync(id, kPeerOptionAllowWaylandKeyboard) ||
isWaylandKeyboardPromptSuppressedForConnection(
sessionId.toString()))) {
v.add(TTextMenu(
child: Text(translate('wayland-keyboard-input-reset-choice-tip')),
onPressed: () async {
var persistedCleared = false;
try {
await bind.mainSetPeerOption(
id: id,
key: kPeerOptionAllowWaylandKeyboard,
value: bool2option(kPeerOptionAllowWaylandKeyboard, false));
persistedCleared = true;
} catch (e) {
debugPrint(
'Failed to clear persisted Wayland keyboard permission: $e');
} finally {
clearWaylandKeyboardPromptSuppressedForConnection(
sessionId.toString());
ffi.inputModel.keyboardInputAllowed = false;
if (isMobile) {
await ffi.invokeMethod("enable_soft_keyboard", false);
}
}
showToast(translate(persistedCleared ? 'Successful' : 'Failed'));
}));
}
// reset canvas
@@ -567,6 +275,7 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
isDesktop &&
ffiModel.keyboard &&
pi.platform != kPeerPlatformAndroid &&
pi.platform != kPeerPlatformMacOS &&
versionCmp(pi.version, '1.2.0') >= 0 &&
bind.peerGetSessionsCount(id: id, connType: ffi.connType.index) == 1) {
v.add(TTextMenu(
@@ -583,7 +292,6 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
}
// record
if (!(isDesktop || isWeb) &&
bind.mainGetLocalOption(key: kOptionHideRecordingButton) != 'Y' &&
(ffi.recordingModel.start || (perms["recording"] != false))) {
v.add(TTextMenu(
child: Row(
@@ -607,7 +315,7 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
// to-do:
// 1. Web desktop
// 2. Mobile, copy the image to the clipboard
if ((isDefaultConn || ffi.connType == ConnType.viewCamera) && isDesktop) {
if (isDesktop) {
final isScreenshotSupported = bind.sessionGetCommonSync(
sessionId: sessionId, key: 'is_screenshot_supported', param: '');
if ('true' == isScreenshotSupported) {
@@ -977,10 +685,8 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
child: Text(translate('Lock after session end'))));
}
final privacyModeState = PrivacyModeState.find(id);
if (pi.isSupportMultiDisplay &&
(privacyModeState.isEmpty ||
allowDisplaySwitchInPrivacyMode(pi, privacyModeState.value)) &&
PrivacyModeState.find(id).isEmpty &&
pi.displaysCount.value > 1 &&
bind.mainGetUserDefaultOption(key: kKeyShowMonitorsToolbar) == 'Y') {
final value =
@@ -1054,38 +760,23 @@ List<TToggleMenu> toolbarPrivacyMode(
final ffiModel = ffi.ffiModel;
final pi = ffiModel.pi;
final sessionId = ffi.sessionId;
final hasPrivacyModePermission =
ffiModel.permissions['privacy_mode'] != false;
// Backend revocation already attempts to turn privacy mode off.
// Still keep this menu when privacy mode is active, so users can turn it off
// if there is a sync delay, version mismatch, or off attempt failure.
if (!hasPrivacyModePermission && privacyModeState.isEmpty) {
return []; // No permission and not active, hide options.
}
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);
getDefaultMenu(Future<void> Function(SessionID sid, String opt) toggleFunc) {
final enabled = !ffi.ffiModel.viewOnly;
return TToggleMenu(
value: privacyModeState.isNotEmpty,
onChanged: enabled
? (value) {
if (value == null) return;
if (!checkDisplayAllowedForPrivacyMode(targetImplKey, value)) {
if (ffiModel.pi.currentDisplay != 0 &&
ffiModel.pi.currentDisplay != kAllDisplayValue) {
msgBox(
sessionId,
'custom-nook-nocancel-hasclose',
'info',
'Please switch to Display 1 first',
'',
ffi.dialogManager);
return;
}
final option = 'privacy-mode';
@@ -1103,7 +794,7 @@ List<TToggleMenu> toolbarPrivacyMode(
getDefaultMenu((sid, opt) async {
bind.sessionToggleOption(sessionId: sid, value: opt);
togglePrivacyModeTime = DateTime.now();
}, kPrivacyModeImplMag)
})
];
}
if (privacyModeImpls.isEmpty) {
@@ -1117,35 +808,21 @@ List<TToggleMenu> toolbarPrivacyMode(
bind.sessionTogglePrivacyMode(
sessionId: sid, implKey: implKey, on: privacyModeState.isEmpty);
togglePrivacyModeTime = DateTime.now();
}, implKey)
})
];
} else {
final visibleImpls = hasPrivacyModePermission
? privacyModeImpls
: privacyModeImpls.where((e) {
final implKey = (e as List<dynamic>)[0] as String;
return privacyModeState.value == implKey;
}).toList();
return visibleImpls.map((e) {
return privacyModeImpls.map((e) {
final implKey = (e as List<dynamic>)[0] as String;
final implName = (e)[1] as String;
final enabled = !ffiModel.viewOnly &&
(hasPrivacyModePermission || privacyModeState.value == implKey);
return TToggleMenu(
child: Text(translate(implName)),
value: privacyModeState.value == implKey,
onChanged: enabled
? (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);
}
: null);
onChanged: (value) {
if (value == null) return;
togglePrivacyModeTime = DateTime.now();
bind.sessionTogglePrivacyMode(
sessionId: sessionId, implKey: implKey, on: value);
});
}).toList();
}
}

View File

@@ -29,10 +29,6 @@ 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";
@@ -88,7 +84,6 @@ const String kOptionEdgeScrollEdgeThickness = "edge-scroll-edge-thickness";
const String kOptionImageQuality = "image_quality";
const String kOptionOpenNewConnInTabs = "enable-open-new-connections-in-tabs";
const String kOptionTextureRender = "use-texture-render";
const String kOptionTextureRenderHealth = "texture-render-health";
const String kOptionD3DRender = "allow-d3d-render";
const String kOptionOpenInTabs = "allow-open-in-tabs";
const String kOptionOpenInWindows = "allow-open-in-windows";
@@ -96,7 +91,6 @@ const String kOptionForceAlwaysRelay = "force-always-relay";
const String kOptionViewOnly = "view_only";
const String kOptionEnableLanDiscovery = "enable-lan-discovery";
const String kOptionWhitelist = "whitelist";
const String kOptionIdWhitelist = "id-whitelist";
const String kOptionEnableAbr = "enable-abr";
const String kOptionEnableRecordSession = "enable-record-session";
const String kOptionDirectServer = "direct-server";
@@ -106,7 +100,6 @@ const String kOptionAutoDisconnectTimeout = "auto-disconnect-timeout";
const String kOptionEnableHwcodec = "enable-hwcodec";
const String kOptionAllowAutoRecordIncoming = "allow-auto-record-incoming";
const String kOptionAllowAutoRecordOutgoing = "allow-auto-record-outgoing";
const String kOptionHideRecordingButton = "hide-recording-button";
const String kOptionVideoSaveDirectory = "video-save-directory";
const String kOptionAccessMode = "access-mode";
const String kOptionEnableKeyboard = "enable-keyboard";
@@ -121,9 +114,6 @@ const String kOptionTerminalPersistent = "terminal-persistent";
const String kOptionEnableTunnel = "enable-tunnel";
const String kOptionEnableRemoteRestart = "enable-remote-restart";
const String kOptionEnableBlockInput = "enable-block-input";
const String kOptionEnablePrivacyMode = "enable-privacy-mode";
const String kOptionEnablePermChangeInAcceptWindow =
"enable-perm-change-in-accept-window";
const String kOptionAllowRemoteConfigModification =
"allow-remote-config-modification";
const String kOptionVerificationMethod = "verification-method";
@@ -149,10 +139,6 @@ const String kOptionSwapLeftRightMouse = "swap-left-right-mouse";
const String kOptionCodecPreference = "codec-preference";
const String kOptionRemoteMenubarDragLeft = "remote-menubar-drag-left";
const String kOptionRemoteMenubarDragRight = "remote-menubar-drag-right";
const String kOptionRemoteMenubarEdge = "remote-menubar-edge";
const String kOptionRemoteMenubarFraction = "remote-menubar-frac";
const String kOptionAllowMultiEdgeToolbarDock =
"allow-multi-edge-toolbar-dock";
const String kOptionHideAbTagsPanel = "hideAbTagsPanel";
const String kOptionRemoteMenubarState = "remoteMenubarState";
const String kOptionPeerSorting = "peer-sorting";
@@ -177,10 +163,7 @@ 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";
@@ -192,7 +175,6 @@ const String kOptionEnableFlutterHttpOnRust = "enable-flutter-http-on-rust";
const String kOptionHideServerSetting = "hide-server-settings";
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 kOptionHideSecuritySetting = "hide-security-settings";
const String kOptionHideNetworkSetting = "hide-network-settings";
@@ -204,9 +186,6 @@ const String kOptionDisableChangeId = "disable-change-id";
const String kOptionDisableUnlockPin = "disable-unlock-pin";
const kHideUsernameOnCard = "hide-username-on-card";
const String kOptionHideHelpCards = "hide-help-cards";
const String kOptionAllowDeepLinkPassword = "allow-deep-link-password";
const String kOptionAllowDeepLinkServerSettings =
"allow-deep-link-server-settings";
const String kOptionToggleViewOnly = "view-only";
const String kOptionToggleShowMyCursor = "show-my-cursor";

View File

@@ -398,7 +398,6 @@ class _ConnectionPageState extends State<ConnectionPage>
.contains(textToFind) ||
peer.alias.toLowerCase().contains(textToFind))
.toList();
_allPeersLoader.queryOnlines(_autocompleteOpts);
}
return _autocompleteOpts;
},

View File

@@ -25,7 +25,6 @@ import 'package:url_launcher/url_launcher.dart';
import 'package:window_manager/window_manager.dart';
import 'package:window_size/window_size.dart' as window_size;
import '../widgets/button.dart';
import '../widgets/texture_render_probe.dart';
class DesktopHomePage extends StatefulWidget {
const DesktopHomePage({Key? key}) : super(key: key);
@@ -61,20 +60,15 @@ class _DesktopHomePageState extends State<DesktopHomePage>
Widget build(BuildContext context) {
super.build(context);
final isIncomingOnly = bind.isIncomingOnly();
return Stack(
return _buildBlock(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildBlock(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
buildLeftPane(context),
if (!isIncomingOnly) const VerticalDivider(width: 1),
if (!isIncomingOnly) Expanded(child: buildRightPane(context)),
],
)),
const Positioned(left: 0, top: 0, child: TextureRenderProbe()),
buildLeftPane(context),
if (!isIncomingOnly) const VerticalDivider(width: 1),
if (!isIncomingOnly) Expanded(child: buildRightPane(context)),
],
);
));
}
Widget _buildBlock({required Widget child}) {
@@ -914,17 +908,12 @@ class _DesktopHomePageState extends State<DesktopHomePage>
}
void setPasswordDialog({VoidCallback? notEmptyCallback}) async {
final p0 = TextEditingController(text: "");
final p1 = TextEditingController(text: "");
final pw = await bind.mainGetPermanentPassword();
final p0 = TextEditingController(text: pw);
final p1 = TextEditingController(text: pw);
var errMsg0 = "";
var errMsg1 = "";
final localPasswordSet =
(await bind.mainGetCommon(key: "local-permanent-password-set")) == "true";
final permanentPasswordSet =
(await bind.mainGetCommon(key: "permanent-password-set")) == "true";
final presetPassword = permanentPasswordSet && !localPasswordSet;
var canSubmit = false;
final RxString rxPass = "".obs;
final RxString rxPass = pw.trim().obs;
final rules = [
DigitValidationRule(),
UppercaseValidationRule(),
@@ -933,21 +922,9 @@ void setPasswordDialog({VoidCallback? notEmptyCallback}) async {
MinCharactersValidationRule(8),
];
final maxLength = bind.mainMaxEncryptLen();
final statusTip = localPasswordSet
? translate('password-hidden-tip')
: (presetPassword ? translate('preset-password-in-use-tip') : '');
final showStatusTipOnMobile =
statusTip.isNotEmpty && !isDesktop && !isWebDesktop;
gFFI.dialogManager.show((setState, close, context) {
updateCanSubmit() {
canSubmit = p0.text.trim().isNotEmpty || p1.text.trim().isNotEmpty;
}
submit() async {
if (!canSubmit) {
return;
}
submit() {
setState(() {
errMsg0 = "";
errMsg1 = "";
@@ -970,13 +947,7 @@ void setPasswordDialog({VoidCallback? notEmptyCallback}) async {
});
return;
}
final ok = await bind.mainSetPermanentPasswordWithResult(password: pass);
if (!ok) {
setState(() {
errMsg0 = '${translate('Prompt')}: ${translate("Failed")}';
});
return;
}
bind.mainSetPermanentPassword(password: pass);
if (pass.isNotEmpty) {
notEmptyCallback?.call();
}
@@ -984,20 +955,14 @@ void setPasswordDialog({VoidCallback? notEmptyCallback}) async {
}
return CustomAlertDialog(
title: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.key, color: MyTheme.accent),
Text(translate("Set Password")).paddingOnly(left: 10),
],
),
title: Text(translate("Set Password")),
content: ConstrainedBox(
constraints: const BoxConstraints(minWidth: 500),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: showStatusTipOnMobile ? 0.0 : 6.0,
const SizedBox(
height: 8.0,
),
Row(
children: [
@@ -1013,7 +978,6 @@ void setPasswordDialog({VoidCallback? notEmptyCallback}) async {
rxPass.value = value.trim();
setState(() {
errMsg0 = '';
updateCanSubmit();
});
},
maxLength: maxLength,
@@ -1025,9 +989,9 @@ void setPasswordDialog({VoidCallback? notEmptyCallback}) async {
children: [
Expanded(child: PasswordStrengthIndicator(password: rxPass)),
],
).marginOnly(top: 2, bottom: showStatusTipOnMobile ? 2 : 8),
SizedBox(
height: showStatusTipOnMobile ? 0.0 : 8.0,
).marginSymmetric(vertical: 8),
const SizedBox(
height: 8.0,
),
Row(
children: [
@@ -1041,7 +1005,6 @@ void setPasswordDialog({VoidCallback? notEmptyCallback}) async {
onChanged: (value) {
setState(() {
errMsg1 = '';
updateCanSubmit();
});
},
maxLength: maxLength,
@@ -1049,23 +1012,11 @@ void setPasswordDialog({VoidCallback? notEmptyCallback}) async {
),
],
),
if (statusTip.isNotEmpty)
Row(
children: [
Icon(Icons.info, color: Colors.amber, size: 18)
.marginOnly(right: 6),
Expanded(
child: Text(
statusTip,
style: const TextStyle(fontSize: 13, height: 1.1),
))
],
).marginOnly(top: 6, bottom: 2),
SizedBox(
height: showStatusTipOnMobile ? 0.0 : 8.0,
const SizedBox(
height: 8.0,
),
Obx(() => Wrap(
runSpacing: showStatusTipOnMobile ? 2.0 : 8.0,
runSpacing: 8,
spacing: 4,
children: rules.map((e) {
var checked = e.validate(rxPass.value.trim());
@@ -1085,67 +1036,11 @@ void setPasswordDialog({VoidCallback? notEmptyCallback}) async {
],
),
),
actions: (() {
final cancelButton = dialogButton(
"Cancel",
icon: Icon(Icons.close_rounded),
onPressed: close,
isOutline: true,
);
final removeButton = dialogButton(
"Remove",
icon: Icon(Icons.delete_outline_rounded),
onPressed: () async {
setState(() {
errMsg0 = "";
errMsg1 = "";
});
final ok =
await bind.mainSetPermanentPasswordWithResult(password: "");
if (!ok) {
setState(() {
errMsg0 = '${translate('Prompt')}: ${translate("Failed")}';
});
return;
}
close();
},
buttonStyle: ButtonStyle(
backgroundColor: MaterialStatePropertyAll(Colors.red)),
);
final okButton = dialogButton(
"OK",
icon: Icon(Icons.done_rounded),
onPressed: canSubmit ? submit : null,
);
if (!isDesktop && !isWebDesktop && localPasswordSet) {
return [
Align(
alignment: Alignment.centerRight,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerRight,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
cancelButton,
const SizedBox(width: 4),
removeButton,
const SizedBox(width: 4),
okButton,
],
),
),
),
];
}
return [
cancelButton,
if (localPasswordSet) removeButton,
okButton,
];
})(),
onSubmit: canSubmit ? submit : null,
actions: [
dialogButton("Cancel", onPressed: close, isOutline: true),
dialogButton("OK", onPressed: submit),
],
onSubmit: submit,
onCancel: close,
);
});

View File

@@ -407,7 +407,6 @@ 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) {
@@ -459,53 +458,31 @@ class _GeneralState extends State<_General> {
return const Offstage();
}
final hideStopService =
bind.mainGetBuildinOption(key: kOptionHideStopService) == 'Y';
return Obx(() {
if (hideStopService && !serviceStop.value) {
return const Offstage();
}
return _Card(title: 'Service', children: [
_Button(serviceStop.value ? 'Start' : 'Stop', () {
() async {
serviceBtnEnabled.value = false;
await start_service(serviceStop.value);
// enable the button after 1 second
Future.delayed(const Duration(seconds: 1), () {
serviceBtnEnabled.value = true;
});
}();
}, enabled: serviceBtnEnabled.value)
]);
});
return _Card(title: 'Service', children: [
Obx(() => _Button(serviceStop.value ? 'Start' : 'Stop', () {
() async {
serviceBtnEnabled.value = false;
await start_service(serviceStop.value);
// enable the button after 1 second
Future.delayed(const Duration(seconds: 1), () {
serviceBtnEnabled.value = true;
});
}();
}, enabled: serviceBtnEnabled.value))
]);
}
Widget other() {
final incomingOnly = bind.isIncomingOnly();
final outgoingOnly = bind.isOutgoingOnly();
final showAutoUpdate = (isWindows && bind.mainIsInstalled()) ||
(isMacOS && bind.mainIsInstalled() && bind.mainIsInstalledDaemon(prompt: false) && !bind.isCustomClient());
final showAutoUpdate =
isWindows && bind.mainIsInstalled() && !bind.isCustomClient();
final children = <Widget>[
if (!isWeb && !incomingOnly)
if (!isWeb && !bind.isIncomingOnly())
_OptionCheckBox(context, 'Confirm before closing multiple tabs',
kOptionEnableConfirmClosingTabs,
isServer: false),
if (!incomingOnly)
_OptionCheckBox(
context,
'allow-remote-toolbar-docking-any-edge',
kOptionAllowMultiEdgeToolbarDock,
isServer: false,
update: (_) {
reloadAllWindows();
},
),
if (!isWeb && !outgoingOnly)
_OptionCheckBox(context, 'Adaptive bitrate', kOptionEnableAbr),
_OptionCheckBox(context, 'Adaptive bitrate', kOptionEnableAbr),
if (!isWeb) wallpaper(),
if (!isWeb && !incomingOnly) ...[
if (!isWeb && !bind.isIncomingOnly()) ...[
_OptionCheckBox(
context,
'Open connection in new tab',
@@ -544,40 +521,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 && !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,
),
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,
),
],
],
];
@@ -610,47 +587,6 @@ 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);
}
@@ -1118,10 +1054,6 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
_OptionCheckBox(context, 'Enable blocking user input',
kOptionEnableBlockInput,
enabled: enabled, fakeValue: fakeValue),
if (bind.mainSupportedPrivacyModeImpls() != '[]')
_OptionCheckBox(
context, 'Enable privacy mode', kOptionEnablePrivacyMode,
enabled: enabled, fakeValue: fakeValue),
_OptionCheckBox(context, 'Enable remote configuration modification',
kOptionAllowRemoteConfigModification,
enabled: enabled, fakeValue: fakeValue),
@@ -1169,9 +1101,8 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
if (value ==
passwordValues[passwordKeys
.indexOf(kUsePermanentPassword)] &&
(await bind.mainGetCommon(
key: "permanent-password-set")) !=
"true") {
(await bind.mainGetPermanentPassword())
.isEmpty) {
if (isChangePermanentPasswordDisabled()) {
await callback();
return;
@@ -1298,7 +1229,6 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
reverse: true, enabled: enabled),
...directIp(context),
whitelist(),
idWhitelist(),
...autoDisconnect(context),
_OptionCheckBox(context, 'keep-awake-during-incoming-sessions-label',
kOptionKeepAwakeDuringIncomingSessions,
@@ -1456,52 +1386,6 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
return tmpWrapper();
}
Widget idWhitelist() {
bool enabled = !locked;
RxBool hasIdWhitelist = idWhitelistNotEmpty().obs;
update() async {
hasIdWhitelist.value = idWhitelistNotEmpty();
}
onChanged(bool? checked) async {
changeIdWhiteList(callback: update);
}
final isOptFixed = isOptionFixed(kOptionIdWhitelist);
return GestureDetector(
child: Tooltip(
message: translate('id_whitelist_tip'),
child: Obx(() => Row(
children: [
Checkbox(
value: hasIdWhitelist.value,
onChanged: enabled && !isOptFixed ? onChanged : null)
.marginOnly(right: 5),
Offstage(
offstage: !hasIdWhitelist.value,
child: MouseRegion(
child: const Icon(Icons.warning_amber_rounded,
color: Color.fromARGB(255, 255, 204, 0))
.marginOnly(right: 5),
cursor: SystemMouseCursors.click,
),
),
Expanded(
child: Text(
translate('Use ID whitelisting'),
style: TextStyle(color: disabledTextColor(context, enabled)),
))
],
)),
),
onTap: enabled
? () {
onChanged(!hasIdWhitelist.value);
}
: null,
).marginOnly(left: _kCheckBoxLeftMargin);
}
Widget hide_cm(bool enabled) {
return ChangeNotifierProvider.value(
value: gFFI.serverModel,
@@ -2143,65 +2027,28 @@ class _AccountState extends State<_Account> {
}
Widget useInfo() {
text(String key, String value) {
return Align(
alignment: Alignment.centerLeft,
child: SelectionArea(child: Text('${translate(key)}: $value'))
.marginSymmetric(vertical: 4),
);
}
return Obx(() => Offstage(
offstage: gFFI.userModel.userName.value.isEmpty,
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(10),
),
child: Builder(builder: (context) {
final avatarWidget = _buildUserAvatar();
return Row(
children: [
if (avatarWidget != null) avatarWidget,
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
gFFI.userModel.displayNameOrUserName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
SelectionArea(
child: Text(
'@${gFFI.userModel.userName.value}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
color:
Theme.of(context).textTheme.bodySmall?.color,
),
),
),
],
),
),
],
);
}),
child: Column(
children: [
if (gFFI.userModel.displayName.value.trim().isNotEmpty &&
gFFI.userModel.displayName.value.trim() !=
gFFI.userModel.userName.value.trim())
text('Display Name', gFFI.userModel.displayName.value.trim()),
text('Username', gFFI.userModel.userName.value),
// text('Group', gFFI.groupModel.groupName.value),
],
),
)).marginOnly(left: 18, top: 16);
}
Widget? _buildUserAvatar() {
// Resolve relative avatar path at display time
final avatar =
bind.mainResolveAvatarUrl(avatar: gFFI.userModel.avatar.value);
return buildAvatarWidget(
avatar: avatar,
size: 44,
);
}
}
class _Checkbox extends StatefulWidget {
@@ -2462,20 +2309,17 @@ class _AboutState extends State<_About> {
final version = await bind.mainGetVersion();
final buildDate = await bind.mainGetBuildDate();
final fingerprint = await bind.mainGetFingerprint();
final myId = await bind.mainGetMyId();
return {
'license': license,
'version': version,
'buildDate': buildDate,
'fingerprint': fingerprint,
'myId': myId
'fingerprint': fingerprint
};
}(), hasData: (data) {
final license = data['license'].toString();
final version = data['version'].toString();
final buildDate = data['buildDate'].toString();
final fingerprint = data['fingerprint'].toString();
final myId = data['myId'].toString();
const linkStyle = TextStyle(decoration: TextDecoration.underline);
final scrollController = ScrollController();
return SingleChildScrollView(
@@ -2497,9 +2341,6 @@ class _AboutState extends State<_About> {
SelectionArea(
child: Text('${translate('Fingerprint')}: $fingerprint')
.marginSymmetric(vertical: 4.0)),
SelectionArea(
child: Text('${translate('ID')}: $myId')
.marginSymmetric(vertical: 4.0)),
InkWell(
onTap: () {
launchUrlString('https://rustdesk.com/privacy.html');
@@ -2528,7 +2369,7 @@ class _AboutState extends State<_About> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Copyright © ${DateTime.now().toString().substring(0, 4)} Purslane Tech Pte. Ltd.\n$license',
'Copyright © ${DateTime.now().toString().substring(0, 4)} Purslane Ltd.\n$license',
style: const TextStyle(color: Colors.white),
),
Text(

View File

@@ -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 = false.obs;
final RxBool printer = true.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'] == '1';
printer.value = installOptions['PRINTER'] != '0';
}
@override

View File

@@ -1,24 +0,0 @@
class MacOSFullScreenFocusRecovery {
int _generation = 0;
int? _pendingGeneration;
int? get pendingGeneration => _pendingGeneration;
int queue() {
_generation += 1;
_pendingGeneration = _generation;
return _generation;
}
void cancel() {
_pendingGeneration = null;
}
bool isCurrent(int generation) => _pendingGeneration == generation;
bool consume(int generation) {
if (!isCurrent(generation)) return false;
_pendingGeneration = null;
return true;
}
}

View File

@@ -21,9 +21,7 @@ import '../../common/shared_state.dart';
import '../../utils/image.dart';
import '../widgets/remote_toolbar.dart';
import '../widgets/kb_layout_type_chooser.dart';
import '../widgets/raster_stall_monitor.dart';
import '../widgets/tabbar_widget.dart';
import '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';
@@ -66,13 +64,6 @@ class RemotePage extends StatefulWidget {
FFI get ffi => (_lastState.value! as _RemotePageState)._ffi;
void releaseMacOSInputForTabTransfer() {
if (!isMacOS) return;
// Release before removing the source tab. Its delayed disposal must not
// disable a native keyboard hook already acquired by the destination page.
(_lastState.value! as _RemotePageState)._releaseMacOSRemoteInput();
}
@override
State<RemotePage> createState() {
final state = _RemotePageState(id);
@@ -85,28 +76,10 @@ class _RemotePageState extends State<RemotePage>
with
AutomaticKeepAliveClientMixin,
MultiWindowListener,
WidgetsBindingObserver,
TickerProviderStateMixin {
Timer? _timer;
String keyboardMode = "legacy";
bool _isWindowBlur = false;
// Known macOS remote-input trade-offs (kept simple intentionally):
// 1. Dialogs rely on FocusNode loss plus middleBlocked, not mirrored dialog
// state. Reproduce: activate remote input, open a dialog, then type.
// 2. Delayed fullscreen recovery can race a local-control focus change; no
// owner state is added. Reproduce: focus the toolbar during a Space switch.
// 3. Input-source switching releases native input without updating this
// page's cache. Reproduce: switch sources, then type before and after
// clicking the remote image; the click reasserts input.
// These latches compensate for out-of-order macOS focus events. Treat them
// as coupled when changing a transition or _syncMacOSKeyboardGrab().
AppLifecycleState? _macOSLifecycleState;
bool _macOSLocalFocusLost = false;
bool _macOSInputActive = false;
bool _macOSInputSuppressed = false;
final _macOSFullScreenFocusRecovery = MacOSFullScreenFocusRecovery();
bool _macOSExplicitFocusRequestPending = false;
StreamSubscription<DesktopTabState>? _tabStateSubscription;
final _cursorOverImage = false.obs;
late RxBool _showRemoteCursor;
late RxBool _zoomCursor;
@@ -128,9 +101,6 @@ class _RemotePageState extends State<RemotePage>
Function(bool)? _onEnterOrLeaveImage4Toolbar;
late FFI _ffi;
Worker? _waylandKeyboardModeWorker;
bool _waylandKeyboardModeNormalized = false;
bool _waylandKeyboardModeNormalizing = false;
SessionID get sessionId => _ffi.sessionId;
@@ -149,15 +119,7 @@ class _RemotePageState extends State<RemotePage>
void initState() {
super.initState();
_ffi = FFI(widget.sessionId);
if (isMacOS) {
// SchedulerBinding.instance.lifecycleState is null in the first connection in a new window.
_macOSLifecycleState = SchedulerBinding.instance.lifecycleState;
WidgetsBinding.instance.addObserver(this);
_tabStateSubscription =
widget.tabController?.state.listen(_onMacOSTabStateChanged);
}
Get.put<FFI>(_ffi, tag: widget.id);
RasterStallMonitor.start();
_ffi.imageModel.addCallbackOnFirstImage((String peerId) {
_ffi.canvasModel.activateLocalCursor();
showKBLayoutTypeChooserIfNeeded(
@@ -216,48 +178,6 @@ class _RemotePageState extends State<RemotePage>
// Register callback to cancel debounce timer when relative mouse mode is disabled
_ffi.inputModel.onRelativeMouseModeDisabled =
_cancelPointerLockCenterDebounceTimer;
_waylandKeyboardModeWorker = ever(_ffi.ffiModel.pi.isSet, (bool isSet) {
if (isSet) {
unawaited(_normalizeWaylandKeyboardModeIfNeeded());
}
});
if (_ffi.ffiModel.pi.isSet.value) {
unawaited(_normalizeWaylandKeyboardModeIfNeeded());
}
}
Future<void> _normalizeWaylandKeyboardModeIfNeeded() async {
if (!mounted ||
_waylandKeyboardModeNormalized ||
_waylandKeyboardModeNormalizing) {
return;
}
_waylandKeyboardModeNormalizing = true;
try {
final pi = _ffi.ffiModel.pi;
if (pi.platform != kPeerPlatformLinux || !pi.isWayland) return;
final mapSupported = bind.sessionIsKeyboardModeSupported(
sessionId: sessionId, mode: kKeyMapMode);
if (!mapSupported) return;
final current = await bind.sessionGetKeyboardMode(sessionId: sessionId);
if (!mounted) return;
if (current == kKeyMapMode) {
_waylandKeyboardModeNormalized = true;
return;
}
await bind.sessionSetKeyboardMode(
sessionId: sessionId, value: kKeyMapMode);
if (!mounted) return;
await _ffi.inputModel.updateKeyboardMode();
if (!mounted) return;
_waylandKeyboardModeNormalized = true;
} catch (e, st) {
debugPrint('Failed to normalize Wayland keyboard mode: $e');
debugPrintStack(stackTrace: st);
} finally {
_waylandKeyboardModeNormalizing = false;
}
}
/// Cancel the pointer lock center debounce timer
@@ -266,224 +186,19 @@ class _RemotePageState extends State<RemotePage>
_pointerLockCenterDebounceTimer = null;
}
bool get _isSelectedTab {
final controller = widget.tabController;
if (controller == null) return true;
final tabState = controller.state.value;
final selected = tabState.selected;
return selected >= 0 &&
selected < tabState.tabs.length &&
tabState.tabs[selected].key == widget.id;
}
bool get _isMacOSKeyboardContextActive {
return stateGlobal.isFocused.value && !_isWindowBlur && _isSelectedTab;
}
void _onMacOSTabStateChanged(DesktopTabState _) {
if (!_isSelectedTab) {
_macOSFullScreenFocusRecovery.cancel();
_syncMacOSKeyboardGrab();
return;
}
// Tab listeners run synchronously. Defer the selected page so the previous
// page releases first; a late leave from it can disable the new session.
scheduleMicrotask(() {
if (mounted) {
_syncMacOSKeyboardGrab(reassert: true);
}
});
}
void _releaseMacOSRemoteInput() {
_macOSFullScreenFocusRecovery.cancel();
_macOSExplicitFocusRequestPending = false;
_macOSInputSuppressed = true;
_macOSLocalFocusLost = true;
_ffi.inputModel.enterOrLeave(false);
_macOSInputActive = false;
_rawKeyFocusNode.unfocus();
}
void _onMacOSFocusChange() {
// requestFocus() notifies later; only a recorded explicit request may clear
// the local-focus-loss latch.
if (_rawKeyFocusNode.hasPrimaryFocus) {
final explicitRequest = _macOSExplicitFocusRequestPending;
_macOSExplicitFocusRequestPending = false;
if (explicitRequest && _isMacOSKeyboardContextActive) {
_macOSLocalFocusLost = false;
}
_syncMacOSKeyboardGrab(allowInactiveLifecycle: explicitRequest);
} else {
if (_macOSInputActive) {
_ffi.inputModel.enterOrLeave(false);
_macOSInputActive = false;
}
if (_isMacOSKeyboardContextActive) {
_macOSLocalFocusLost = true;
}
}
}
// 1. Sync the keyboard grab state with the current context.
// 2. Call enterOrLeave() to update the input state in the FFI layer.
// 3. Request or unfocus the raw key focus node based on the current context.
// Flutter focus and native input are separate; native input activates only
// after the FocusNode has primary focus.
void _syncMacOSKeyboardGrab({
bool reassert = false,
bool allowInactiveLifecycle = false,
}) {
if (!isMacOS) return;
// A secondary engine may stay hidden while its window is visible, so
// explicit pointer/fullscreen recovery must bypass the global lifecycle.
final lifecycleAllowsInput = allowInactiveLifecycle ||
_macOSLifecycleState == null ||
_macOSLifecycleState == AppLifecycleState.resumed;
// Input stays pointer-gated except for focused fullscreen recovery, which
// compensates when macOS omits PointerEnter during a Space switch.
final shouldFocus = lifecycleAllowsInput &&
_isMacOSKeyboardContextActive &&
!_macOSInputSuppressed &&
_blockableOverlayState.middleBlocked.isFalse &&
_cursorOverImage.value &&
!_macOSLocalFocusLost;
final hasFocus = _rawKeyFocusNode.hasPrimaryFocus;
final shouldActivateInput = shouldFocus && hasFocus;
if (shouldActivateInput != _macOSInputActive ||
(shouldActivateInput && reassert)) {
_ffi.inputModel.enterOrLeave(shouldActivateInput);
}
_macOSInputActive = shouldActivateInput;
if (!shouldFocus) {
_macOSExplicitFocusRequestPending = false;
if (hasFocus) _rawKeyFocusNode.unfocus();
} else if (!hasFocus) {
_macOSExplicitFocusRequestPending = allowInactiveLifecycle;
_rawKeyFocusNode.requestFocus();
} else {
_macOSExplicitFocusRequestPending = false;
}
}
void _restoreMacOSKeyboardAfterFullScreen({
required int generation,
bool allowHiddenLifecycle = false,
}) {
// Fullscreen callbacks preserve recovery while hidden. Native window focus
// may bypass a stale hidden lifecycle for the newly visible Space.
if (!_macOSFullScreenFocusRecovery.isCurrent(generation) ||
(!allowHiddenLifecycle &&
_macOSLifecycleState == AppLifecycleState.hidden)) {
return;
}
final contextActive =
stateGlobal.isFocused.value && !_isWindowBlur && _isSelectedTab;
// macOS can focus a fullscreen Space without sending PointerEnter. Native
// window focus is authoritative here; a later blur cancels this generation
// before an off-screen window can restore input.
final shouldInferPointerInside = !_cursorOverImage.value &&
allowHiddenLifecycle &&
stateGlobal.fullscreen.isTrue &&
contextActive;
final canRestore = contextActive &&
_blockableOverlayState.middleBlocked.isFalse &&
(_cursorOverImage.value || shouldInferPointerInside);
if (!_macOSFullScreenFocusRecovery.consume(generation)) return;
if (!canRestore) {
// Consuming recovery here requires a later pointer/window/tab event.
return;
}
if (shouldInferPointerInside) {
_cursorOverImage.value = true;
}
_macOSLocalFocusLost = false;
stateGlobal.getInputSource(force: true);
_syncMacOSKeyboardGrab(reassert: true, allowInactiveLifecycle: true);
}
void _scheduleMacOSKeyboardAfterFullScreen({
required int generation,
bool allowHiddenLifecycle = false,
}) {
// Fullscreen can deliver FocusNode loss after its callback; wait for frame
// completion and then advance one event-loop turn before restoring.
WidgetsBinding.instance.addPostFrameCallback((_) {
Timer.run(() {
if (mounted) {
_restoreMacOSKeyboardAfterFullScreen(
generation: generation,
allowHiddenLifecycle: allowHiddenLifecycle,
);
}
});
});
WidgetsBinding.instance.ensureVisualUpdate();
}
void _queueMacOSKeyboardAfterFullScreen({
bool allowHiddenLifecycle = false,
}) {
final generation = _macOSFullScreenFocusRecovery.queue();
if (_macOSLifecycleState == AppLifecycleState.paused ||
_macOSLifecycleState == AppLifecycleState.detached) {
_macOSFullScreenFocusRecovery.cancel();
return;
}
_scheduleMacOSKeyboardAfterFullScreen(
generation: generation,
allowHiddenLifecycle: allowHiddenLifecycle,
);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
if (!isMacOS || _macOSLifecycleState == state) return;
_macOSLifecycleState = state;
if (state == AppLifecycleState.resumed) {
_syncMacOSKeyboardGrab(reassert: true);
} else if (_macOSInputActive) {
_ffi.inputModel.enterOrLeave(false);
_macOSInputActive = false;
}
final generation = _macOSFullScreenFocusRecovery.pendingGeneration;
if (generation == null) return;
if (state == AppLifecycleState.inactive ||
state == AppLifecycleState.resumed) {
_scheduleMacOSKeyboardAfterFullScreen(generation: generation);
} else if (state == AppLifecycleState.paused ||
state == AppLifecycleState.detached) {
_macOSFullScreenFocusRecovery.cancel();
}
}
@override
void onWindowBlur() {
super.onWindowBlur();
// On windows, we use `focus` way to handle keyboard better.
// Now on Linux, there's some rdev issues which will break the input.
// We disable the `focus` way for Linux temporarily.
if (isWindows || isMacOS) {
_isWindowBlur = true;
}
if (isMacOS) {
_macOSFullScreenFocusRecovery.cancel();
// A blur or Space switch may not emit PointerExit, so cursor state alone
// cannot prevent the old remote surface from reclaiming the keyboard.
_macOSLocalFocusLost = true;
}
// We disable the `focus` way for non-Windows temporarily.
if (isWindows) {
_isWindowBlur = true;
// unfocus the primary-focus when the whole window is lost focus,
// and let OS to handle events instead.
_rawKeyFocusNode.unfocus();
}
stateGlobal.isFocused.value = false;
_syncMacOSKeyboardGrab();
// When window loses focus, temporarily release relative mouse mode constraints
// to allow user to interact with other applications normally.
@@ -497,41 +212,16 @@ class _RemotePageState extends State<RemotePage>
void onWindowFocus() {
super.onWindowFocus();
// See [onWindowBlur].
if (isWindows || isMacOS) {
if (isWindows) {
_isWindowBlur = false;
}
if (isMacOS) stateGlobal.getInputSource(force: true);
stateGlobal.isFocused.value = true;
// Normal macOS windows wait for PointerEnter or PointerDown. A focused
// fullscreen Space queues delayed recovery; if this window blurs again, the
// pending recovery is cancelled before native input can reactivate.
// Regression: switch directly between fullscreen remote Spaces without
// moving or clicking; only the newly focused session may receive input.
if (isMacOS &&
stateGlobal.fullscreen.isTrue &&
!_ffi.inputModel.relativeMouseMode.value) {
// Native window focus is authoritative when a secondary engine retains a
// stale hidden lifecycle state after its fullscreen Space becomes visible.
_queueMacOSKeyboardAfterFullScreen(allowHiddenLifecycle: true);
}
// Restore relative mouse mode constraints when window regains focus.
if (_ffi.inputModel.relativeMouseMode.value) {
if (isMacOS) {
// Native relative mode retains pointer capture and does not emit
// PointerEnter after window focus returns. Restore both latches unless
// a local overlay still owns input.
if (_blockableOverlayState.middleBlocked.isFalse) {
_cursorOverImage.value = true;
_macOSLocalFocusLost = false;
}
} else {
_rawKeyFocusNode.requestFocus();
}
_rawKeyFocusNode.requestFocus();
_ffi.inputModel.onWindowFocus();
}
_syncMacOSKeyboardGrab(reassert: true, allowInactiveLifecycle: true);
}
@override
@@ -592,13 +282,6 @@ class _RemotePageState extends State<RemotePage>
void onWindowMinimize() {
super.onWindowMinimize();
WakelockManager.disable(_uniqueKey);
if (isMacOS) {
_macOSFullScreenFocusRecovery.cancel();
_isWindowBlur = true;
_cursorOverImage.value = false;
stateGlobal.isFocused.value = false;
_syncMacOSKeyboardGrab();
}
// Release cursor constraints when minimized
if (_ffi.inputModel.relativeMouseMode.value) {
_ffi.inputModel.onWindowBlur();
@@ -610,7 +293,6 @@ class _RemotePageState extends State<RemotePage>
super.onWindowEnterFullScreen();
if (isMacOS) {
stateGlobal.setFullscreen(true);
_queueMacOSKeyboardAfterFullScreen();
}
}
@@ -619,7 +301,6 @@ class _RemotePageState extends State<RemotePage>
super.onWindowLeaveFullScreen();
if (isMacOS) {
stateGlobal.setFullscreen(false);
_queueMacOSKeyboardAfterFullScreen();
}
}
@@ -628,14 +309,6 @@ class _RemotePageState extends State<RemotePage>
final closeSession = closeSessionOnDispose.remove(widget.id) ?? true;
// https://github.com/flutter/flutter/issues/64935
if (isMacOS) {
// Tab moves release before transfer to avoid a late retained-session leave.
if (closeSession) {
_releaseMacOSRemoteInput();
}
_tabStateSubscription?.cancel();
WidgetsBinding.instance.removeObserver(this);
}
super.dispose();
debugPrint("REMOTE PAGE dispose session $sessionId ${widget.id}");
@@ -645,14 +318,12 @@ class _RemotePageState extends State<RemotePage>
_pointerLockCenterDebounceTimer?.cancel();
_pointerLockCenterDebounceTimer = null;
_waylandKeyboardModeWorker?.dispose();
// Clear callback reference to prevent memory leaks and stale references
_ffi.inputModel.onRelativeMouseModeDisabled = null;
// Relative mouse mode cleanup is centralized in FFI.close(closeSession: ...).
_ffi.textureModel.onRemotePageDispose();
if (closeSession && !isMacOS) {
_ffi.textureModel.onRemotePageDispose(closeSession);
if (closeSession) {
// ensure we leave this session, this is a double check
// enterOrLeave() is already called previously in _releaseMacOSRemoteInput() for macOS.
_ffi.inputModel.enterOrLeave(false);
}
DesktopMultiWindow.removeListener(this);
@@ -660,9 +331,6 @@ class _RemotePageState extends State<RemotePage>
_ffi.imageModel.disposeImage();
_ffi.cursorModel.disposeImages();
_rawKeyFocusNode.dispose();
if (closeSession) {
clearWaylandKeyboardPromptSuppressedForConnection(sessionId.toString());
}
await _ffi.close(closeSession: closeSession);
_timer?.cancel();
_ffi.dialogManager.dismissAll();
@@ -727,8 +395,6 @@ class _RemotePageState extends State<RemotePage>
} else {
_ffi.inputModel.enterOrLeave(false);
}
} else if (isMacOS) {
_onMacOSFocusChange();
}
},
inputModel: _ffi.inputModel,
@@ -834,11 +500,7 @@ class _RemotePageState extends State<RemotePage>
}
// See [onWindowBlur].
if (isMacOS) {
_macOSLocalFocusLost = false;
stateGlobal.getInputSource(force: true);
_syncMacOSKeyboardGrab(reassert: true, allowInactiveLifecycle: true);
} else if (!isWindows) {
if (!isWindows) {
if (!_rawKeyFocusNode.hasFocus) {
_rawKeyFocusNode.requestFocus();
}
@@ -864,9 +526,7 @@ class _RemotePageState extends State<RemotePage>
}
// See [onWindowBlur].
if (isMacOS) {
_syncMacOSKeyboardGrab();
} else if (!isWindows) {
if (!isWindows) {
_ffi.inputModel.enterOrLeave(false);
}
}
@@ -891,29 +551,17 @@ class _RemotePageState extends State<RemotePage>
onEnter: onEnter,
onExit: onExit,
onPointerDown: (event) {
// A double check for blur status on Windows and macOS.
// A double check for blur status.
// Note: If there's an `onPointerDown` event is triggered, `_isWindowBlur` is expected being false.
// Sometimes the system does not send the necessary focus event to flutter. We should manually
// handle this inconsistent status by setting `_isWindowBlur` to false. So we can
// ensure the grab-key thread is running when our users are clicking the remote canvas.
if ((isWindows || isMacOS) && _isWindowBlur) {
if (_isWindowBlur) {
debugPrint(
"Unexpected status: onPointerDown is triggered while the remote window is in blur status");
_isWindowBlur = false;
}
if (isMacOS) {
// Regions without matching enter/exit callbacks cannot safely own
// keyboard state.
if (onEnter == null || onExit == null) return;
if (!stateGlobal.isFocused.value) {
stateGlobal.isFocused.value = true;
}
_cursorOverImage.value = true;
_macOSLocalFocusLost = false;
stateGlobal.getInputSource(force: true);
_syncMacOSKeyboardGrab(
reassert: !isInputSourceFlutter, allowInactiveLifecycle: true);
} else if (!_rawKeyFocusNode.hasFocus) {
if (!_rawKeyFocusNode.hasFocus) {
_rawKeyFocusNode.requestFocus();
}
},
@@ -1404,4 +1052,3 @@ class CursorPaint extends StatelessWidget {
);
}
}

View File

@@ -513,17 +513,15 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
final args = jsonDecode(call.arguments);
final id = args['id'];
final close = args['close'];
RemotePage? remotePage;
try {
remotePage = tabController.state.value.tabs
final remotePage = tabController.state.value.tabs
.firstWhere((tab) => tab.key == id)
.page as RemotePage;
returnValue = remotePage.ffi.ffiModel.cachedPeerData.toString();
} catch (e) {
debugPrint('Failed to get cached session data: $e');
}
if (close && returnValue != null && remotePage != null) {
remotePage.releaseMacOSInputForTabTransfer();
if (close && returnValue != null) {
closeSessionOnDispose[id] = false;
tabController.closeBy(id);
}

View File

@@ -462,7 +462,23 @@ class _CmHeaderState extends State<_CmHeader>
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildClientAvatar().marginOnly(right: 10.0),
Container(
width: 70,
height: 70,
alignment: Alignment.center,
decoration: BoxDecoration(
color: str2color(client.name),
borderRadius: BorderRadius.circular(15.0),
),
child: Text(
client.name[0],
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.white,
fontSize: 55,
),
),
).marginOnly(right: 10.0),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
@@ -495,14 +511,14 @@ class _CmHeaderState extends State<_CmHeader>
if (client.type_() == ClientType.file)
FittedBox(
child: Text(
translate("Transfer file"),
translate("File Transfer"),
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),
),
),
@@ -566,36 +582,6 @@ class _CmHeaderState extends State<_CmHeader>
@override
bool get wantKeepAlive => true;
Widget _buildClientAvatar() {
return buildAvatarWidget(
avatar: client.avatar,
size: 70,
borderRadius: 15,
fallback: _buildInitialAvatar(),
) ??
_buildInitialAvatar();
}
Widget _buildInitialAvatar() {
return Container(
width: 70,
height: 70,
alignment: Alignment.center,
decoration: BoxDecoration(
color: str2color(client.name),
borderRadius: BorderRadius.circular(15.0),
),
child: Text(
client.name.isNotEmpty ? client.name[0] : '?',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.white,
fontSize: 55,
),
),
);
}
}
class _PrivilegeBoard extends StatefulWidget {
@@ -610,24 +596,19 @@ class _PrivilegeBoard extends StatefulWidget {
class _PrivilegeBoardState extends State<_PrivilegeBoard> {
late final client = widget.client;
Widget buildPermissionIcon(bool enabled, IconData iconData,
Function(bool)? onTap, String tooltipText,
{required bool canModify}) {
Function(bool)? onTap, String tooltipText) {
return Tooltip(
message: "$tooltipText: ${enabled ? "ON" : "OFF"}",
waitDuration: Duration.zero,
child: Container(
decoration: BoxDecoration(
color: enabled
? (canModify ? MyTheme.accent : MyTheme.accent.withOpacity(0.6))
: Colors.grey[700],
color: enabled ? MyTheme.accent : Colors.grey[700],
borderRadius: BorderRadius.circular(10.0),
),
padding: EdgeInsets.all(8.0),
child: InkWell(
onTap: canModify
? () =>
checkClickTime(widget.client.id, () => onTap?.call(!enabled))
: null,
onTap: () =>
checkClickTime(widget.client.id, () => onTap?.call(!enabled)),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
@@ -648,9 +629,6 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
Widget build(BuildContext context) {
final crossAxisCount = 4;
final spacing = 10.0;
final canModifyPermission =
bind.mainGetBuildinOption(key: kOptionEnablePermChangeInAcceptWindow) !=
'N';
return Container(
width: double.infinity,
height: 160.0,
@@ -697,7 +675,6 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
});
},
translate('Enable audio'),
canModify: canModifyPermission,
),
buildPermissionIcon(
client.recording,
@@ -712,7 +689,6 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
});
},
translate('Enable recording session'),
canModify: canModifyPermission,
),
]
: [
@@ -729,7 +705,6 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
});
},
translate('Enable keyboard/mouse'),
canModify: canModifyPermission,
),
buildPermissionIcon(
client.clipboard,
@@ -744,7 +719,6 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
});
},
translate('Enable clipboard'),
canModify: canModifyPermission,
),
buildPermissionIcon(
client.audio,
@@ -759,7 +733,6 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
});
},
translate('Enable audio'),
canModify: canModifyPermission,
),
buildPermissionIcon(
client.file,
@@ -774,7 +747,6 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
});
},
translate('Enable file copy and paste'),
canModify: canModifyPermission,
),
buildPermissionIcon(
client.restart,
@@ -789,7 +761,6 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
});
},
translate('Enable remote restart'),
canModify: canModifyPermission,
),
buildPermissionIcon(
client.recording,
@@ -804,7 +775,6 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
});
},
translate('Enable recording session'),
canModify: canModifyPermission,
),
// only windows support block input
if (isWindows)
@@ -821,23 +791,6 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
});
},
translate('Enable blocking user input'),
canModify: canModifyPermission,
),
if (bind.mainSupportedPrivacyModeImpls() != '[]')
buildPermissionIcon(
client.privacyMode,
Icons.visibility_off,
(enabled) {
bind.cmSwitchPermission(
connId: client.id,
name: "privacy_mode",
enabled: enabled);
setState(() {
client.privacyMode = enabled;
});
},
translate('Enable privacy mode'),
canModify: canModifyPermission,
)
],
),

View File

@@ -1,4 +1,3 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hbb/common.dart';
@@ -16,7 +15,6 @@ class TerminalPage extends StatefulWidget {
required this.tabController,
required this.isSharedPassword,
required this.terminalId,
required this.tabKey,
this.forceRelay,
this.connToken,
}) : super(key: key);
@@ -27,9 +25,6 @@ class TerminalPage extends StatefulWidget {
final bool? isSharedPassword;
final String? connToken;
final int terminalId;
/// Tab key for focus management, passed from parent to avoid duplicate construction
final String tabKey;
final SimpleWrapper<State<TerminalPage>?> _lastState = SimpleWrapper(null);
FFI get ffi => (_lastState.value! as _TerminalPageState)._ffi;
@@ -44,22 +39,14 @@ class TerminalPage extends StatefulWidget {
class _TerminalPageState extends State<TerminalPage>
with AutomaticKeepAliveClientMixin {
static const EdgeInsets _defaultTerminalPadding =
EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0);
late FFI _ffi;
late TerminalModel _terminalModel;
double? _cellHeight;
final FocusNode _terminalFocusNode = FocusNode(canRequestFocus: false);
StreamSubscription<DesktopTabState>? _tabStateSubscription;
@override
void initState() {
super.initState();
// Listen for tab selection changes to request focus
_tabStateSubscription = widget.tabController.state.listen(_onTabStateChanged);
// Use shared FFI instance from connection manager
_ffi = TerminalConnectionManager.getConnection(
peerId: widget.id,
@@ -77,13 +64,6 @@ class _TerminalPageState extends State<TerminalPage>
_terminalModel.onResizeExternal = (w, h, pw, ph) {
_cellHeight = ph * 1.0;
// Enable focus once terminal has valid dimensions (first valid resize)
if (!_terminalFocusNode.canRequestFocus && w > 0 && h > 0) {
_terminalFocusNode.canRequestFocus = true;
// Auto-focus if this tab is currently selected
_requestFocusIfSelected();
}
// Schedule the setState for the next frame
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
@@ -95,13 +75,6 @@ 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);
@@ -126,67 +99,25 @@ class _TerminalPageState extends State<TerminalPage>
@override
void dispose() {
// Cancel tab state subscription to prevent memory leak
_tabStateSubscription?.cancel();
// Unregister terminal model from FFI
_ffi.unregisterTerminalModel(widget.terminalId);
_terminalModel.dispose();
_terminalFocusNode.dispose();
// Release connection reference instead of closing directly
TerminalConnectionManager.releaseConnection(widget.id);
super.dispose();
}
void _onTabStateChanged(DesktopTabState state) {
// Check if this tab is now selected and request focus
if (state.selected >= 0 && state.selected < state.tabs.length) {
final selectedTab = state.tabs[state.selected];
if (selectedTab.key == widget.tabKey && mounted) {
_requestFocusIfSelected();
}
}
}
void _requestFocusIfSelected() {
if (!mounted || !_terminalFocusNode.canRequestFocus) return;
// Use post-frame callback to ensure widget is fully laid out in focus tree
WidgetsBinding.instance.addPostFrameCallback((_) {
// Re-check conditions after frame: mounted, focusable, still selected, not already focused
if (!mounted || !_terminalFocusNode.canRequestFocus || _terminalFocusNode.hasFocus) return;
final state = widget.tabController.state.value;
if (state.selected >= 0 && state.selected < state.tabs.length) {
if (state.tabs[state.selected].key == widget.tabKey) {
_terminalFocusNode.requestFocus();
}
}
});
}
// This method ensures that the number of visible rows is an integer by computing the
// extra space left after dividing the available height by the height of a single
// terminal row (`_cellHeight`) and distributing it evenly as top and bottom padding.
EdgeInsets _calculatePadding(double heightPx) {
final cellHeight = _cellHeight;
if (!heightPx.isFinite ||
heightPx <= 0 ||
cellHeight == null ||
!cellHeight.isFinite ||
cellHeight <= 0) {
return _defaultTerminalPadding;
}
final rows = (heightPx / cellHeight).floor();
if (rows <= 0) {
return _defaultTerminalPadding;
}
final extraSpace = heightPx - rows * cellHeight;
if (!extraSpace.isFinite || extraSpace < 0) {
return _defaultTerminalPadding;
if (_cellHeight == null) {
return const EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0);
}
final rows = (heightPx / _cellHeight!).floor();
final extraSpace = heightPx - rows * _cellHeight!;
final topBottom = extraSpace / 2.0;
return EdgeInsets.symmetric(
horizontal: _defaultTerminalPadding.horizontal / 2,
vertical: topBottom,
);
return EdgeInsets.symmetric(horizontal: 5.0, vertical: topBottom);
}
@override
@@ -200,9 +131,7 @@ class _TerminalPageState extends State<TerminalPage>
return TerminalView(
_terminalModel.terminal,
controller: _terminalModel.terminalController,
focusNode: _terminalFocusNode,
// Note: autofocus is not used here because focus is managed manually
// via _onTabStateChanged() to handle tab switching properly.
autofocus: true,
backgroundOpacity: 0.7,
padding: _calculatePadding(heightPx),
onSecondaryTapDown: (details, offset) async {

View File

@@ -34,10 +34,6 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
static const IconData selectedIcon = Icons.terminal;
static const IconData unselectedIcon = Icons.terminal_outlined;
int _nextTerminalId = 1;
// Lightweight idempotency guard for async close operations
final Set<String> _closingTabs = {};
// When true, all session cleanup should persist (window-level close in progress)
bool _windowClosing = false;
_TerminalTabPageState(Map<String, dynamic> params) {
Get.put(DesktopTabController(tabType: DesktopTabType.terminal));
@@ -46,7 +42,6 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
.setTitle(getWindowNameWithId(id));
};
tabController.onRemoved = (_, id) => onRemoveId(id);
tabController.onCloseWindow = _closeWindowFromConnection;
final terminalId = params['terminalId'] ?? _nextTerminalId++;
tabController.add(_createTerminalTab(
peerId: params['id'],
@@ -75,12 +70,28 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
label: tabLabel,
selectedIcon: selectedIcon,
unselectedIcon: unselectedIcon,
onTabCloseButton: () => _closeTab(tabKey),
onTabCloseButton: () async {
if (await desktopTryShowTabAuditDialogCloseCancelled(
id: tabKey,
tabController: tabController,
)) {
return;
}
// Close the terminal session first
final ffi = TerminalConnectionManager.getExistingConnection(peerId);
if (ffi != null) {
final terminalModel = ffi.terminalModels[terminalId];
if (terminalModel != null) {
await terminalModel.closeTerminal();
}
}
// Then close the tab
tabController.closeBy(tabKey);
},
page: TerminalPage(
key: ValueKey(tabKey),
id: peerId,
terminalId: terminalId,
tabKey: tabKey,
password: password,
isSharedPassword: isSharedPassword,
tabController: tabController,
@@ -90,161 +101,6 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
);
}
/// Unified tab close handler for all close paths (button, shortcut, programmatic).
/// Shows audit dialog, cleans up session if not persistent, then removes the UI tab.
Future<void> _closeTab(String tabKey) async {
// Idempotency guard: skip if already closing this tab
if (_closingTabs.contains(tabKey)) return;
_closingTabs.add(tabKey);
try {
// Snapshot peerTabCount BEFORE any await to avoid race with concurrent
// _closeAllTabs clearing tabController (which would make the live count
// drop to 0 and incorrectly trigger session persistence).
// Note: the snapshot may become stale if other individual tabs are closed
// during the audit dialog, but this is an acceptable trade-off.
int? snapshotPeerTabCount;
final parsed = _parseTabKey(tabKey);
if (parsed != null) {
final (peerId, _) = parsed;
snapshotPeerTabCount = tabController.state.value.tabs.where((t) {
final p = _parseTabKey(t.key);
return p != null && p.$1 == peerId;
}).length;
}
if (await desktopTryShowTabAuditDialogCloseCancelled(
id: tabKey,
tabController: tabController,
)) {
return;
}
// Close terminal session if not in persistent mode.
// Wrapped separately so session cleanup failure never blocks UI tab removal.
try {
await _closeTerminalSessionIfNeeded(tabKey,
peerTabCount: snapshotPeerTabCount);
} catch (e) {
debugPrint('[TerminalTabPage] Session cleanup failed for $tabKey: $e');
}
// Always close the tab from UI, regardless of session cleanup result
tabController.closeBy(tabKey);
} catch (e) {
debugPrint('[TerminalTabPage] Error closing tab $tabKey: $e');
} finally {
_closingTabs.remove(tabKey);
}
}
/// Close all tabs with session cleanup.
/// Used for window-level close operations (onDestroy, handleWindowCloseButton).
/// UI tabs are removed immediately; session cleanup runs in parallel with a
/// bounded timeout so window close is not blocked indefinitely.
Future<void> _closeAllTabs() async {
_windowClosing = true;
final tabKeys = tabController.state.value.tabs.map((t) => t.key).toList();
// Remove all UI tabs immediately (same instant behavior as the old tabController.clear())
// Keep the cleanup target lookup below synchronous before its first await:
// it relies on the current frame still retaining each TerminalPage's FFI/model.
tabController.clear();
// Run session cleanup in parallel with bounded timeout (closeTerminal() has internal 3s timeout).
// Skip tabs already being closed by a concurrent _closeTab() to avoid duplicate FFI calls.
final futures = tabKeys
.where((tabKey) => !_closingTabs.contains(tabKey))
.map((tabKey) async {
try {
await _closeTerminalSessionIfNeeded(tabKey, persistAll: true);
} catch (e) {
debugPrint('[TerminalTabPage] Session cleanup failed for $tabKey: $e');
}
}).toList();
if (futures.isNotEmpty) {
await Future.wait(futures).timeout(
const Duration(seconds: 4),
onTimeout: () {
debugPrint(
'[TerminalTabPage] Session cleanup timed out for batch close');
return [];
},
);
}
}
/// Close the terminal session on server side based on persistent mode.
///
/// [persistAll] controls behavior when persistent mode is enabled:
/// - `true` (window close): persist all sessions, don't close any.
/// - `false` (tab close): only persist the last session for the peer,
/// close others so only the most recent disconnected session survives.
///
/// Note: if [_windowClosing] is true, persistAll is forced to true so that
/// in-flight _closeTab() calls don't accidentally close sessions that the
/// window-close flow intends to preserve.
Future<void> _closeTerminalSessionIfNeeded(String tabKey,
{bool persistAll = false, int? peerTabCount}) async {
// If window close is in progress, override to persist all sessions
// even if this call originated from an individual tab close.
if (_windowClosing) {
persistAll = true;
}
final parsed = _parseTabKey(tabKey);
if (parsed == null) return;
final (peerId, terminalId) = parsed;
final ffi = TerminalConnectionManager.getExistingConnection(peerId);
if (ffi == null) return;
final isPersistent = bind.sessionGetToggleOptionSync(
sessionId: ffi.sessionId,
arg: kOptionTerminalPersistent,
);
if (isPersistent) {
if (persistAll) {
// Window close: persist all sessions
return;
}
// Tab close: only persist if this is the last tab for this peer.
// Use the snapshot value if provided (avoids race with concurrent tab removal).
final effectivePeerTabCount = peerTabCount ??
tabController.state.value.tabs.where((t) {
final p = _parseTabKey(t.key);
return p != null && p.$1 == peerId;
}).length;
if (effectivePeerTabCount <= 1) {
// Last tab for this peer — persist the session
return;
}
// Not the last tab — fall through to close the session
}
final terminalModel = ffi.terminalModels[terminalId];
if (terminalModel != null) {
// closeTerminal() has internal 3s timeout, no need for external timeout
await terminalModel.closeTerminal();
}
}
/// Parse tabKey (format: "peerId_terminalId") into its components.
/// Note: peerId may contain underscores, so we use lastIndexOf('_').
/// Returns null if tabKey format is invalid.
(String peerId, int terminalId)? _parseTabKey(String tabKey) {
final lastUnderscore = tabKey.lastIndexOf('_');
if (lastUnderscore <= 0) {
debugPrint('[TerminalTabPage] Invalid tabKey format: $tabKey');
return null;
}
final terminalIdStr = tabKey.substring(lastUnderscore + 1);
final terminalId = int.tryParse(terminalIdStr);
if (terminalId == null) {
debugPrint('[TerminalTabPage] Invalid terminalId in tabKey: $tabKey');
return null;
}
final peerId = tabKey.substring(0, lastUnderscore);
return (peerId, terminalId);
}
Widget _tabMenuBuilder(String peerId, CancelFunc cancelFunc) {
final List<MenuEntryBase<String>> menu = [];
const EdgeInsets padding = EdgeInsets.only(left: 8.0, right: 5.0);
@@ -328,8 +184,7 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
} else if (call.method == kWindowEventRestoreTerminalSessions) {
_restoreSessions(call.arguments);
} else if (call.method == "onDestroy") {
// Clean up sessions before window destruction (bounded wait)
await _closeAllTabs();
tabController.clear();
} else if (call.method == kWindowActionRebuild) {
reloadCurrentWindow();
} else if (call.method == kWindowEventActiveSession) {
@@ -339,10 +194,7 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
final currentTab = tabController.state.value.selectedTabInfo;
assert(call.arguments is String,
"Expected String arguments for kWindowEventActiveSession, got ${call.arguments.runtimeType}");
// Use lastIndexOf to handle peerIds containing underscores
final lastUnderscore = currentTab.key.lastIndexOf('_');
if (lastUnderscore > 0 &&
currentTab.key.substring(0, lastUnderscore) == call.arguments) {
if (currentTab.key.startsWith(call.arguments)) {
windowOnTop(windowId());
return true;
}
@@ -371,34 +223,8 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
final persistentSessions =
args['persistent_sessions'] as List<dynamic>? ?? [];
final sortedSessions = persistentSessions.whereType<int>().toList()..sort();
var peerId = args['peer_id'] as String? ?? '';
if (peerId.isEmpty) {
if (tabController.state.value.tabs.isEmpty ||
tabController.state.value.selected >=
tabController.state.value.tabs.length) {
debugPrint('[TerminalTabPage] Skip restore: no selected tab');
return;
}
final currentTab = tabController.state.value.selectedTabInfo;
final parsed = _parseTabKey(currentTab.key);
if (parsed == null) return;
peerId = parsed.$1;
}
final existingTerminalIds = tabController.state.value.tabs
.map((tab) => _parseTabKey(tab.key))
.where((parsed) => parsed != null && parsed.$1 == peerId)
.map((parsed) => parsed!.$2)
.toSet();
if (existingTerminalIds.isEmpty) {
debugPrint(
'[TerminalTabPage] Skip restore: no seed tab for peer $peerId');
return;
}
for (final terminalId in sortedSessions) {
if (!existingTerminalIds.add(terminalId)) {
continue;
}
_addNewTerminal(peerId, terminalId: terminalId);
_addNewTerminalForCurrentPeer(terminalId: terminalId);
// A delay is required to ensure the UI has sufficient time to update
// before adding the next terminal. Without this delay, `_TerminalPageState::dispose()`
// may be called prematurely while the tab widget is still in the tab controller.
@@ -439,7 +265,7 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
// macOS: Cmd+W (standard for close tab)
final currentTab = tabController.state.value.selectedTabInfo;
if (tabController.state.value.tabs.length > 1) {
_closeTab(currentTab.key);
tabController.closeBy(currentTab.key);
return true;
}
} else if (!isMacOS &&
@@ -448,7 +274,7 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
// Other platforms: Ctrl+Shift+W (to avoid conflict with Ctrl+W word delete)
final currentTab = tabController.state.value.selectedTabInfo;
if (tabController.state.value.tabs.length > 1) {
_closeTab(currentTab.key);
tabController.closeBy(currentTab.key);
return true;
}
}
@@ -503,10 +329,7 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
void _addNewTerminal(String peerId, {int? terminalId}) {
// Find first tab for this peer to get connection parameters
final firstTab = tabController.state.value.tabs.firstWhere(
(tab) {
final last = tab.key.lastIndexOf('_');
return last > 0 && tab.key.substring(0, last) == peerId;
},
(tab) => tab.key.startsWith('$peerId\_'),
);
if (firstTab.page is TerminalPage) {
final page = firstTab.page as TerminalPage;
@@ -527,10 +350,11 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
void _addNewTerminalForCurrentPeer({int? terminalId}) {
final currentTab = tabController.state.value.selectedTabInfo;
final parsed = _parseTabKey(currentTab.key);
if (parsed == null) return;
final (peerId, _) = parsed;
_addNewTerminal(peerId, terminalId: terminalId);
final parts = currentTab.key.split('_');
if (parts.isNotEmpty) {
final peerId = parts[0];
_addNewTerminal(peerId, terminalId: terminalId);
}
}
@override
@@ -544,9 +368,10 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
selectedBorderColor: MyTheme.accent,
labelGetter: DesktopTab.tablabelGetter,
tabMenuBuilder: (key) {
final parsed = _parseTabKey(key);
if (parsed == null) return Container();
final (peerId, _) = parsed;
// Extract peerId from tab key (format: "peerId_terminalId")
final parts = key.split('_');
if (parts.isEmpty) return Container();
final peerId = parts[0];
return _tabMenuBuilder(peerId, () {});
},
));
@@ -575,11 +400,6 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
}
}
Future<void> _closeWindowFromConnection() async {
await _closeAllTabs();
await WindowController.fromWindowId(windowId()).close();
}
int windowId() {
return widget.params["windowId"];
}
@@ -606,7 +426,7 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
}
}
if (connLength <= 1) {
await _closeAllTabs();
tabController.clear();
return true;
} else {
final bool res;
@@ -617,7 +437,7 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
res = await closeConfirmDialog();
}
if (res) {
await _closeAllTabs();
tabController.clear();
}
return res;
}

View File

@@ -19,7 +19,6 @@ import '../../common/shared_state.dart';
import '../../utils/image.dart';
import '../widgets/remote_toolbar.dart';
import '../widgets/kb_layout_type_chooser.dart';
import '../widgets/raster_stall_monitor.dart';
import '../widgets/tabbar_widget.dart';
import 'package:flutter_hbb/native/custom_cursor.dart'
@@ -103,7 +102,6 @@ class _ViewCameraPageState extends State<ViewCameraPage>
super.initState();
_ffi = FFI(widget.sessionId);
Get.put<FFI>(_ffi, tag: widget.id);
RasterStallMonitor.start();
_ffi.imageModel.addCallbackOnFirstImage((String peerId) {
showKBLayoutTypeChooserIfNeeded(
_ffi.ffiModel.pi.platform, _ffi.dialogManager);
@@ -224,7 +222,7 @@ class _ViewCameraPageState extends State<ViewCameraPage>
// https://github.com/flutter/flutter/issues/64935
super.dispose();
debugPrint("VIEW CAMERA PAGE dispose session $sessionId ${widget.id}");
_ffi.textureModel.onViewCameraPageDispose();
_ffi.textureModel.onViewCameraPageDispose(closeSession);
if (closeSession) {
// ensure we leave this session, this is a double check
_ffi.inputModel.enterOrLeave(false);

View File

@@ -1,52 +0,0 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/scheduler.dart';
import '../../common.dart';
import '../../consts.dart';
import '../../models/platform_model.dart';
import '../../models/state_model.dart';
/// Records a hung raster thread (frames continuously scheduled but no frame
/// timings delivered for 30s) in `texture-render-health`; a hang cannot be
/// rescued in-process, so the next launch defaults texture rendering off.
class RasterStallMonitor {
static bool _started = false;
static bool _reported = false;
static DateTime? _lastTimings;
static DateTime _lastQuiet = DateTime.now();
static void start() {
if (_started || isWeb) return;
_started = true;
SchedulerBinding.instance.addTimingsCallback((_) {
_lastTimings = DateTime.now();
});
Timer.periodic(const Duration(seconds: 2), (_) {
if (_reported) return;
final now = DateTime.now();
final lifecycle = SchedulerBinding.instance.lifecycleState;
// Minimized/inactive (incl. screen lock) or idle (nothing scheduled):
// no timings is legitimate, keep moving the quiet anchor forward.
if (stateGlobal.isMinimized ||
(lifecycle != null && lifecycle != AppLifecycleState.resumed) ||
!SchedulerBinding.instance.hasScheduledFrame) {
_lastQuiet = now;
return;
}
var ref = _lastQuiet;
final lastTimings = _lastTimings;
if (lastTimings != null && lastTimings.isAfter(ref)) {
ref = lastTimings;
}
if (now.difference(ref) > const Duration(seconds: 30)) {
_reported = true;
bind.mainSetLocalOption(
key: kOptionTextureRenderHealth, value: 'failed-raster-stall');
debugPrint(
'raster thread stall detected, texture rendering disabled for next launch');
}
});
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,6 @@ import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/desktop/pages/remote_page.dart';
import 'package:flutter_hbb/desktop/pages/view_camera_page.dart';
import 'package:flutter_hbb/main.dart';
import 'package:flutter_hbb/models/model.dart';
import 'package:flutter_hbb/models/platform_model.dart';
import 'package:flutter_hbb/models/state_model.dart';
import 'package:get/get.dart';
@@ -100,7 +99,6 @@ class DesktopTabController {
/// index, key
Function(int, String)? onRemoved;
Function(String)? onSelected;
Future<void> Function()? onCloseWindow;
DesktopTabController(
{required this.tabType, this.onRemoved, this.onSelected});
@@ -389,7 +387,6 @@ class _DesktopTabState extends State<DesktopTab>
void onWindowMinimize() {
stateGlobal.setMinimized(true);
stateGlobal.setMaximized(false);
_updateSessionsRenderVisible(false);
super.onWindowMinimize();
}
@@ -397,7 +394,6 @@ class _DesktopTabState extends State<DesktopTab>
void onWindowMaximize() {
stateGlobal.setMinimized(false);
_setMaximized(true);
_updateSessionsRenderVisible(true);
super.onWindowMaximize();
}
@@ -405,34 +401,9 @@ class _DesktopTabState extends State<DesktopTab>
void onWindowUnmaximize() {
stateGlobal.setMinimized(false);
_setMaximized(false);
_updateSessionsRenderVisible(true);
super.onWindowUnmaximize();
}
@override
void onWindowRestore() {
// A plain restore (no maximize involved) must clear the minimized flag.
stateGlobal.setMinimized(false);
_updateSessionsRenderVisible(true);
super.onWindowRestore();
}
// A hidden window composites nothing; pause the Rust-side texture watchdog
// for its sessions so it cannot record a false failure.
void _updateSessionsRenderVisible(bool visible) {
if (tabType != DesktopTabType.remoteScreen &&
tabType != DesktopTabType.viewCamera) {
return;
}
for (final tab in controller.state.value.tabs) {
try {
final ffi = Get.find<FFI>(tag: tab.key);
bind.sessionSetRenderVisible(
sessionId: ffi.sessionId, visible: visible);
} catch (_) {}
}
}
_saveFrame({bool? flush}) async {
try {
if (tabType == DesktopTabType.main) {
@@ -621,13 +592,13 @@ class _DesktopTabState extends State<DesktopTab>
}
Widget _buildBar() {
final isIncomingHomePage = bind.isIncomingOnly() && isInHomePage();
return Row(
children: [
Expanded(
child: GestureDetector(
// custom double tap handler
onTap: !isIncomingHomePage && showMaximize
onTap: !(bind.isIncomingOnly() && isInHomePage()) &&
showMaximize
? () {
final current = DateTime.now().millisecondsSinceEpoch;
final elapsed = current - _lastClickTime;
@@ -638,7 +609,7 @@ class _DesktopTabState extends State<DesktopTab>
.then((value) => stateGlobal.setMaximized(value));
}
}
: (isIncomingHomePage ? () {} : null), // Keep tap recognizer for Windows touch.
: null,
onPanStart: (_) => startDragging(isMainWindow),
onPanCancel: () {
// We want to disable dragging of the tab area in the tab bar.

View File

@@ -1,172 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import '../../common.dart';
import '../../consts.dart';
import '../../models/platform_model.dart';
import '../../models/state_model.dart';
import 'package:texture_rgba_renderer/texture_rgba_renderer.dart'
if (dart.library.html) 'package:flutter_hbb/web/texture_rgba_renderer.dart';
/// Startup probe: renders one frame through a 1x1 external texture and
/// records in `texture-render-health` whether the engine consumed it, so a
/// broken environment is detected before the first session goes black.
class TextureRenderProbe extends StatefulWidget {
const TextureRenderProbe({Key? key}) : super(key: key);
@override
State<TextureRenderProbe> createState() => _TextureRenderProbeState();
}
class _TextureRenderProbeState extends State<TextureRenderProbe> {
static bool _ranThisLaunch = false;
final _renderer = TextureRgbaRenderer();
int _textureId = -1;
int _textureKey = -1;
int _ptr = 0;
Timer? _timer;
int _ticks = 0;
bool _sawTimings = false;
bool _wasEffectiveOn = false;
DateTime? _lastTimings;
DateTime? _firstPush;
@override
void initState() {
super.initState();
if (_ranThisLaunch || isWeb || !isDesktop) return;
_ranThisLaunch = true;
if (bind.isIncomingOnly()) return;
// An old plugin without the consumed counter cannot be judged, and a
// recorded raster-stall means compositing a texture may hang this window.
if (!bind.mainTextureRenderProbeSupported()) return;
if (bind
.mainGetLocalOption(key: kOptionTextureRenderHealth)
.startsWith('failed-raster-stall')) {
return;
}
_wasEffectiveOn = bind.mainGetUseTextureRender();
// Only probe after the window has really rendered a frame: a hidden
// window (silent/tray start) must not record a false failure.
SchedulerBinding.instance.addTimingsCallback(_onTimings);
Future.delayed(const Duration(seconds: 5), () {
if (!_sawTimings) {
SchedulerBinding.instance.removeTimingsCallback(_onTimings);
_finish(null);
}
});
}
void _onTimings(List<FrameTiming> timings) {
_lastTimings = DateTime.now();
if (_sawTimings) return;
_sawTimings = true;
_start();
}
void _start() async {
if (!mounted) return;
_textureKey = bind.getNextTextureKey();
final id = await _renderer.createTexture(_textureKey);
if (!mounted || id == -1) {
_finish(!mounted ? null : false);
return;
}
_ptr = await _renderer.getTexturePtr(_textureKey);
if (!mounted || _ptr <= 0) {
_finish(!mounted ? null : false);
return;
}
setState(() => _textureId = id);
_timer = Timer.periodic(const Duration(milliseconds: 100), (_) {
_ticks += 1;
_firstPush ??= DateTime.now();
bind.mainPushTextureProbeFrame(ptr: _ptr);
final consumed = bind.mainGetTextureProbeConsumed(ptr: _ptr) > 0;
// "Consumed" advances inside the plugin callback, before the GL/Metal
// upload; only a frame timing after the push proves a completed frame.
final frameCompleted = consumed &&
_lastTimings != null &&
_lastTimings!.isAfter(_firstPush!);
if (frameCompleted) {
_finish(true);
} else if (_ticks >= 10) {
if (consumed) {
_finish(null);
return;
}
// Only a window that is visibly compositing can prove a failure.
final lifecycle = SchedulerBinding.instance.lifecycleState;
final active =
lifecycle == null || lifecycle == AppLifecycleState.resumed;
final timingsFresh = _lastTimings != null &&
DateTime.now().difference(_lastTimings!) <
const Duration(milliseconds: 1500);
_finish(!stateGlobal.isMinimized && active && timingsFresh
? false
: null);
}
});
}
void _finish(bool? ok) {
_timer?.cancel();
_timer = null;
SchedulerBinding.instance.removeTimingsCallback(_onTimings);
if (ok != null) {
final old = bind.mainGetLocalOption(key: kOptionTextureRenderHealth);
if (ok) {
// This rgba probe disproves only the rgba black-texture class: gpu
// failures and raster stalls clear via the option toggle alone.
final clearable = old.isEmpty ||
old.startsWith('failed-probe') ||
old.startsWith('failed-watchdog-rgba');
if (clearable) {
bind.mainSetLocalOption(key: kOptionTextureRenderHealth, value: 'ok');
}
} else if (!old.startsWith('failed')) {
debugPrint('texture render probe failed, disabling texture rendering');
bind.mainSetLocalOption(
key: kOptionTextureRenderHealth, value: 'failed-probe');
if (_wasEffectiveOn) {
showToast(translate('texture-render-fallback-tip'));
}
}
}
if (_textureKey != -1) {
_renderer.closeTexture(_textureKey);
_textureKey = -1;
}
_ptr = 0;
if (mounted && _textureId != -1) {
setState(() => _textureId = -1);
} else {
_textureId = -1;
}
}
@override
void dispose() {
_timer?.cancel();
SchedulerBinding.instance.removeTimingsCallback(_onTimings);
if (_textureKey != -1) {
_renderer.closeTexture(_textureKey);
_textureKey = -1;
}
super.dispose();
}
@override
Widget build(BuildContext context) {
if (_textureId == -1) return const SizedBox.shrink();
// Must actually composite for the engine to sample the texture; the
// pushed pixel is fully transparent.
return IgnorePointer(
child: SizedBox(
width: 1, height: 1, child: Texture(textureId: _textureId)),
);
}
}

View File

@@ -27,7 +27,6 @@ import 'common.dart';
import 'consts.dart';
import 'mobile/pages/home_page.dart';
import 'mobile/pages/server_page.dart';
import 'mobile/widgets/deploy_dialog.dart';
import 'models/platform_model.dart';
import 'package:flutter_hbb/plugin/handlers.dart'
@@ -576,20 +575,11 @@ _registerEventHandler() {
NativeUiHandler.instance.onEvent(evt);
});
}
if (isAndroid) {
platformFFI.registerEventHandler(
'android_needs_deploy', 'android_needs_deploy', (_) async {
WidgetsBinding.instance.addPostFrameCallback((_) {
showDeployPromptDialog();
});
});
}
}
Widget keyListenerBuilder(BuildContext context, Widget? child) {
return RawKeyboardListener(
// `skipTraversal: isWeb` is to fix "Bad state: RenderBox was not laid out: minified:aeL#c19e4"
focusNode: FocusNode(skipTraversal: isWeb),
focusNode: FocusNode(),
child: child ?? Container(),
onKey: (RawKeyEvent event) {
if (event.logicalKey == LogicalKeyboardKey.shiftLeft) {

View File

@@ -207,7 +207,6 @@ class _ConnectionPageState extends State<ConnectionPage> {
.contains(textToFind) ||
peer.alias.toLowerCase().contains(textToFind))
.toList();
_allPeersLoader.queryOnlines(_autocompleteOpts);
}
return _autocompleteOpts;
},

View File

@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -64,7 +65,9 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
bool _showGestureHelp = false;
String _value = '';
Orientation? _currentOrientation;
double _viewInsetsBottom = 0;
final _uniqueKey = UniqueKey();
Timer? _timerDidChangeMetrics;
Timer? _iosKeyboardWorkaroundTimer;
final _blockableOverlayState = BlockableOverlayState();
@@ -75,9 +78,6 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
final FocusNode _physicalFocusNode = FocusNode();
var _showEdit = false; // use soft keyboard
Worker? _waylandKeyboardGateWorker;
bool _waylandKeyboardGateInitialized = false;
InputModel get inputModel => gFFI.inputModel;
SessionID get sessionId => gFFI.sessionId;
@@ -124,33 +124,11 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
isKeyboardVisible: keyboardVisibilityController.isVisible);
});
WidgetsBinding.instance.addObserver(this);
inputModel.keyboardInputAllowed = true;
// Wayland sessions may use clipboard-based text input on the controlled side.
// Require explicit user confirmation before allowing soft-keyboard and
// clipboard-assisted text input. Physical keyboard events are not gated here.
_waylandKeyboardGateWorker = ever(gFFI.ffiModel.pi.isSet, (bool isSet) {
if (isSet) {
_initWaylandKeyboardGateIfNeeded();
}
});
if (gFFI.ffiModel.pi.isSet.value) {
_initWaylandKeyboardGateIfNeeded();
}
}
@override
Future<void> dispose() async {
WidgetsBinding.instance.removeObserver(this);
// Close the session up-front. `gFFI.close()` below only calls `sessionClose`
// after several awaits (canvas save, image update, the `enable_soft_keyboard`
// platform call), so if the app is backgrounded while this page is disposing,
// dispose can be suspended before reaching it and the connection is never torn
// down. The reconnect then re-attaches to the leaked session and is stuck on
// "Connecting...". Dispatching it here makes teardown happen synchronously on
// pop; the `sessionClose` in `gFFI.close()` becomes a no-op once removed.
unawaited(bind.sessionClose(sessionId: sessionId));
// https://github.com/flutter/flutter/issues/64935
super.dispose();
gFFI.dialogManager.hideMobileActionsOverlay(store: false);
@@ -160,11 +138,9 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
await gFFI.invokeMethod("enable_soft_keyboard", true);
_mobileFocusNode.dispose();
_physicalFocusNode.dispose();
clearWaylandKeyboardPromptSuppressedForConnection(sessionId.toString());
_waylandKeyboardGateWorker?.dispose();
inputModel.keyboardInputAllowed = true;
await gFFI.close();
_timer?.cancel();
_timerDidChangeMetrics?.cancel();
_iosKeyboardWorkaroundTimer?.cancel();
gFFI.dialogManager.dismissAll();
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
@@ -191,38 +167,24 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
gFFI.invokeMethod("try_sync_clipboard");
}
bool _shouldGateKeyboardForWayland() {
if (!(isAndroid || isIOS)) return false;
final pi = gFFI.ffiModel.pi;
return pi.platform == kPeerPlatformLinux && pi.isWayland;
}
void _initWaylandKeyboardGateIfNeeded() {
if (!mounted) return;
if (_waylandKeyboardGateInitialized) return;
if (!_shouldGateKeyboardForWayland()) return;
_waylandKeyboardGateInitialized = true;
final allowWaylandKeyboard =
mainGetPeerBoolOptionSync(widget.id, kPeerOptionAllowWaylandKeyboard);
if (!shouldShowWaylandKeyboardPrompt(
connectionId: sessionId.toString(),
isWaylandPeer: _shouldGateKeyboardForWayland(),
allowWaylandKeyboardRemembered: allowWaylandKeyboard,
)) {
inputModel.keyboardInputAllowed = true;
@override
void didChangeMetrics() {
// If the soft keyboard is visible and the canvas has been changed(panned or scaled)
// Don't try reset the view style and focus the cursor.
if (gFFI.cursorModel.lastKeyboardIsVisible &&
gFFI.canvasModel.isMobileCanvasChanged) {
return;
}
inputModel.keyboardInputAllowed = false;
// Ensure soft keyboard is not active before user confirms.
_showEdit = false;
gFFI.invokeMethod("enable_soft_keyboard", false);
_mobileFocusNode.unfocus();
_physicalFocusNode.requestFocus();
setState(() {});
final newBottom = MediaQueryData.fromView(ui.window).viewInsets.bottom;
_timerDidChangeMetrics?.cancel();
_timerDidChangeMetrics = Timer(Duration(milliseconds: 100), () async {
// We need this comparation because poping up the floating action will also trigger `didChangeMetrics()`.
if (newBottom != _viewInsetsBottom) {
gFFI.canvasModel.mobileFocusCanvasCursor();
_viewInsetsBottom = newBottom;
}
});
}
// to-do: It should be better to use transparent color instead of the bgColor.
@@ -356,7 +318,7 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
content == '【】')) {
// can not only input content[0], because when input ], [ are also auo insert, which cause ] never be input
bind.sessionInputString(sessionId: sessionId, value: content);
_openKeyboardUnlocked();
openKeyboard();
return;
}
bind.sessionInputString(sessionId: sessionId, value: content);
@@ -368,9 +330,6 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
// handle mobile virtual keyboard
void handleSoftKeyboardInput(String newValue) {
if (!inputModel.keyboardInputAllowed) {
return;
}
if (isIOS) {
_handleIOSSoftKeyboardInput(newValue);
} else {
@@ -379,9 +338,6 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
}
void inputChar(String char) {
if (!inputModel.keyboardInputAllowed) {
return;
}
if (char == '\n') {
char = 'VK_RETURN';
} else if (char == ' ') {
@@ -391,29 +347,6 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
}
void openKeyboard() {
final allowWaylandKeyboard =
mainGetPeerBoolOptionSync(widget.id, kPeerOptionAllowWaylandKeyboard);
if (shouldShowWaylandKeyboardPrompt(
connectionId: sessionId.toString(),
isWaylandPeer: _shouldGateKeyboardForWayland(),
allowWaylandKeyboardRemembered: allowWaylandKeyboard,
)) {
inputModel.keyboardInputAllowed = false;
showWaylandKeyboardInputWarningDialog(
id: widget.id,
connectionId: sessionId.toString(),
ffi: gFFI,
onEnable: () async {
_openKeyboardUnlocked();
},
);
return;
}
_openKeyboardUnlocked();
}
void _openKeyboardUnlocked() {
inputModel.keyboardInputAllowed = true;
gFFI.invokeMethod("enable_soft_keyboard", true);
// destroy first, so that our _value trick can work
_value = initText;
@@ -1220,11 +1153,7 @@ void showOptions(
if (image != null) {
displays.add(Padding(padding: const EdgeInsets.only(top: 8), child: image));
}
final privacyModeState = PrivacyModeState.find(id);
if (pi.displays.length > 1 &&
pi.currentDisplay != kAllDisplayValue &&
(privacyModeState.isEmpty ||
allowDisplaySwitchInPrivacyMode(pi, privacyModeState.value))) {
if (pi.displays.length > 1 && pi.currentDisplay != kAllDisplayValue) {
final cur = pi.currentDisplay;
final children = <Widget>[];
final isDarkTheme = MyTheme.currentThemeMode() == ThemeMode.dark;
@@ -1278,8 +1207,9 @@ void showOptions(
await toolbarDisplayToggle(context, id, gFFI);
List<TToggleMenu> privacyModeList = [];
if ((gFFI.ffiModel.pi.features.privacyMode && gFFI.ffiModel.keyboard) ||
privacyModeState.isNotEmpty) {
// privacy mode
final privacyModeState = PrivacyModeState.find(id);
if (gFFI.ffiModel.keyboard && gFFI.ffiModel.pi.features.privacyMode) {
privacyModeList = toolbarPrivacyMode(privacyModeState, context, id, gFFI);
if (privacyModeList.length == 1) {
displayToggles.add(privacyModeList[0]);

View File

@@ -150,8 +150,7 @@ class _DropDownAction extends StatelessWidget {
}
if (value == kUsePermanentPassword &&
(await bind.mainGetCommon(key: "permanent-password-set")) !=
"true") {
(await bind.mainGetPermanentPassword()).isEmpty) {
if (isChangePermanentPasswordDisabled()) {
callback();
return;
@@ -583,20 +582,10 @@ class _PermissionCheckerState extends State<PermissionChecker> {
Widget build(BuildContext context) {
final serverModel = Provider.of<ServerModel>(context);
final hasAudioPermission = androidVersion >= 30;
final hideStopService = isAndroid &&
bind.mainGetBuildinOption(key: kOptionHideStopService) == 'Y';
final allowPermChangeInAcceptWindow = option2bool(
kOptionEnablePermChangeInAcceptWindow,
bind.mainGetBuildinOption(
key: kOptionEnablePermChangeInAcceptWindow,
));
final permissionChangeLocked = isAndroid &&
serverModel.clients.any((c) => !c.disconnected) &&
!allowPermChangeInAcceptWindow;
return PaddingCard(
title: translate("Permissions"),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
serverModel.mediaOk && !hideStopService
serverModel.mediaOk
? ElevatedButton.icon(
style: ButtonStyle(
backgroundColor:
@@ -606,30 +595,21 @@ class _PermissionCheckerState extends State<PermissionChecker> {
label: Text(translate("Stop service")))
.marginOnly(bottom: 8)
: SizedBox.shrink(),
if (!hideStopService || !serverModel.mediaOk)
PermissionRow(
translate("Screen Capture"),
serverModel.mediaOk,
!serverModel.mediaOk &&
gFFI.userModel.userName.value.isEmpty &&
bind.mainGetLocalOption(key: "show-scam-warning") != "N"
? () => showScamWarning(context, serverModel)
: serverModel.toggleService),
PermissionRow(
translate("Input Control"),
serverModel.inputOk,
serverModel.toggleInput,
),
PermissionRow(
translate("Transfer file"),
serverModel.fileOk,
serverModel.toggleFile,
enabled: !permissionChangeLocked,
),
translate("Screen Capture"),
serverModel.mediaOk,
!serverModel.mediaOk &&
gFFI.userModel.userName.value.isEmpty &&
bind.mainGetLocalOption(key: "show-scam-warning") != "N"
? () => showScamWarning(context, serverModel)
: serverModel.toggleService),
PermissionRow(translate("Input Control"), serverModel.inputOk,
serverModel.toggleInput),
PermissionRow(translate("Transfer file"), serverModel.fileOk,
serverModel.toggleFile),
hasAudioPermission
? PermissionRow(translate("Audio Capture"), serverModel.audioOk,
serverModel.toggleAudio,
enabled: !permissionChangeLocked)
serverModel.toggleAudio)
: Row(children: [
Icon(Icons.info_outline).marginOnly(right: 15),
Expanded(
@@ -638,25 +618,19 @@ class _PermissionCheckerState extends State<PermissionChecker> {
style: const TextStyle(color: MyTheme.darkGray),
))
]),
PermissionRow(
translate("Enable clipboard"),
serverModel.clipboardOk,
serverModel.toggleClipboard,
enabled: !permissionChangeLocked,
),
PermissionRow(translate("Enable clipboard"), serverModel.clipboardOk,
serverModel.toggleClipboard),
]));
}
}
class PermissionRow extends StatelessWidget {
const PermissionRow(this.name, this.isOk, this.onPressed,
{Key? key, this.enabled = true})
const PermissionRow(this.name, this.isOk, this.onPressed, {Key? key})
: super(key: key);
final String name;
final bool isOk;
final VoidCallback onPressed;
final bool enabled;
@override
Widget build(BuildContext context) {
@@ -665,11 +639,9 @@ class PermissionRow extends StatelessWidget {
contentPadding: EdgeInsets.all(0),
title: Text(name),
value: isOk,
onChanged: enabled
? (bool value) {
onPressed();
}
: null);
onChanged: (bool value) {
onPressed();
});
}
}
@@ -869,7 +841,13 @@ class ClientInfo extends StatelessWidget {
flex: -1,
child: Padding(
padding: const EdgeInsets.only(right: 12),
child: _buildAvatar(context))),
child: CircleAvatar(
backgroundColor: str2color(
client.name,
Theme.of(context).brightness == Brightness.light
? 255
: 150),
child: Text(client.name[0])))),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -882,20 +860,6 @@ class ClientInfo extends StatelessWidget {
),
]));
}
Widget _buildAvatar(BuildContext context) {
final fallback = CircleAvatar(
backgroundColor: str2color(client.name,
Theme.of(context).brightness == Brightness.light ? 255 : 150),
child: Text(client.name.isNotEmpty ? client.name[0] : '?'),
);
return buildAvatarWidget(
avatar: client.avatar,
size: 40,
fallback: fallback,
) ??
fallback;
}
}
void androidChannelInit() {

View File

@@ -17,7 +17,6 @@ import '../../common/widgets/login.dart';
import '../../consts.dart';
import '../../models/model.dart';
import '../../models/platform_model.dart';
import '../widgets/deploy_dialog.dart';
import '../widgets/dialog.dart';
import 'home_page.dart';
import 'scan_page.dart';
@@ -78,7 +77,6 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
var _enableAbr = false;
var _denyLANDiscovery = false;
var _onlyWhiteList = false;
var _onlyIdWhiteList = false;
var _enableDirectIPAccess = false;
var _enableRecordSession = false;
var _enableHardwareCodec = false;
@@ -90,7 +88,6 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
var _directAccessPort = "";
var _fingerprint = "";
var _buildDate = "";
var _myId = "";
var _autoDisconnectTimeout = "";
var _hideServer = false;
var _hideProxy = false;
@@ -111,7 +108,6 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
_denyLANDiscovery = !option2bool(kOptionEnableLanDiscovery,
bind.mainGetOptionSync(key: kOptionEnableLanDiscovery));
_onlyWhiteList = whitelistNotEmpty();
_onlyIdWhiteList = idWhitelistNotEmpty();
_enableDirectIPAccess = option2bool(
kOptionDirectServer, bind.mainGetOptionSync(key: kOptionDirectServer));
_enableRecordSession = option2bool(kOptionEnableRecordSession,
@@ -220,12 +216,6 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
_buildDate = buildDate;
}
final myId = await bind.mainGetMyId();
if (_myId != myId) {
update = true;
_myId = myId;
}
final isUsingPublicServer = await bind.mainIsUsingPublicServer();
if (_isUsingPublicServer != isUsingPublicServer) {
update = true;
@@ -409,29 +399,6 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
changeWhiteList(callback: update);
},
),
SettingsTile.switchTile(
title: Row(children: [
Expanded(child: Text(translate('Use ID whitelisting'))),
Offstage(
offstage: !_onlyIdWhiteList,
child: const Icon(Icons.warning_amber_rounded,
color: Color.fromARGB(255, 255, 204, 0)))
.marginOnly(left: 5)
]),
initialValue: _onlyIdWhiteList,
onToggle: (_) async {
update() async {
final onlyIdWhiteList = idWhitelistNotEmpty();
if (onlyIdWhiteList != _onlyIdWhiteList) {
setState(() {
_onlyIdWhiteList = onlyIdWhiteList;
});
}
}
changeIdWhiteList(callback: update);
},
),
SettingsTile.switchTile(
title: Text(translate('Adaptive bitrate')),
initialValue: _enableAbr,
@@ -650,7 +617,7 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
onToggle: (bool v) async {
await mainSetLocalBoolOption(kOptionEnableShowTerminalExtraKeys, v);
final newValue =
mainGetLocalBoolOptionSync(kOptionEnableShowTerminalExtraKeys);
mainGetLocalBoolOptionSync(kOptionEnableShowTerminalExtraKeys);
setState(() {
_showTerminalExtraKeys = newValue;
});
@@ -722,17 +689,7 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
title: Obx(() => Text(gFFI.userModel.userName.value.isEmpty
? translate('Login')
: '${translate('Logout')} (${gFFI.userModel.accountLabelWithHandle})')),
leading: Obx(() {
final avatar = bind.mainResolveAvatarUrl(
avatar: gFFI.userModel.avatar.value);
return buildAvatarWidget(
avatar: avatar,
size: 28,
borderRadius: null,
fallback: Icon(Icons.person),
) ??
Icon(Icons.person);
}),
leading: Icon(Icons.person),
onPressed: (context) {
if (gFFI.userModel.userName.value.isEmpty) {
loginDialog();
@@ -761,13 +718,6 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
onPressed: (context) {
changeSocks5Proxy();
}),
if (isAndroid && !bind.isOutgoingOnly())
SettingsTile(
title: Text(translate('Deploy')),
leading: Icon(Icons.cloud_upload),
onPressed: (context) {
showDeployDialog();
}),
if (!disabledSettings && !_hideNetwork && !_hideWebSocket)
SettingsTile.switchTile(
title: Text(translate('Use WebSocket')),
@@ -879,12 +829,10 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
),
if (!incomingOnly)
SettingsTile.switchTile(
title:
Text(translate('keep-awake-during-outgoing-sessions-label')),
title: Text(translate('keep-awake-during-outgoing-sessions-label')),
initialValue: _preventSleepWhileConnected,
onToggle: (v) async {
await mainSetLocalBoolOption(
kOptionKeepAwakeDuringOutgoingSessions, v);
await mainSetLocalBoolOption(kOptionKeepAwakeDuringOutgoingSessions, v);
setState(() {
_preventSleepWhileConnected = v;
});
@@ -1014,14 +962,6 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
child: Text(_fingerprint),
),
leading: Icon(Icons.fingerprint)),
SettingsTile(
onPressed: (context) => onCopyId(_myId),
title: Text(translate("ID")),
value: Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: Text(_myId),
),
leading: Icon(Icons.perm_identity)),
SettingsTile(
title: Text(translate("Privacy Statement")),
onPressed: (context) =>

View File

@@ -5,13 +5,8 @@ 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';
@@ -47,11 +42,6 @@ class _TerminalPageState extends State<TerminalPage>
final GlobalKey _keyboardKey = GlobalKey();
double _keyboardHeight = 0;
late bool _showTerminalExtraKeys;
// Ctrl lock state for virtual keyboard: active key presses are mapped to control codes
bool _ctrlLocked = false;
bool _altLocked = false;
// Row3 expand/collapse state for compact keyboard layout
bool _row3Expanded = false;
// For iOS edge swipe gesture
double _swipeStartX = 0;
double _swipeCurrentX = 0;
@@ -69,10 +59,6 @@ class _TerminalPageState extends State<TerminalPage>
super.initState();
WidgetsBinding.instance.addObserver(this);
if (isWeb) {
loadLocalTerminalFontIfNeeded();
}
debugPrint(
'[TerminalPage] Initializing terminal ${widget.terminalId} for peer ${widget.id}');
@@ -97,29 +83,7 @@ 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';
_showTerminalExtraKeys = mainGetLocalBoolOptionSync(kOptionEnableShowTerminalExtraKeys);
// Initialize terminal connection
WidgetsBinding.instance.addPostFrameCallback((_) {
_ffi.dialogManager
@@ -174,39 +138,6 @@ class _TerminalPageState extends State<TerminalPage>
return EdgeInsets.only(left: 5.0, right: 5.0, top: topBottom, bottom: topBottom + _sysKeyboardHeight + _keyboardHeight);
}
/// Pastes clipboard text through TerminalModel so keyboard-only modifiers and
/// mobile Enter normalization never alter clipboard data.
Future<void> _pasteClipboardText() async {
final data = await Clipboard.getData(Clipboard.kTextPlain);
final text = data?.text;
if (text == null || !mounted) return;
await _terminalModel.pasteText(text);
if (mounted) {
_terminalModel.terminalController.clearSelection();
}
}
KeyEventResult _handleTerminalKeyEvent(FocusNode _, KeyEvent event) {
final hardwareKeyboard = HardwareKeyboard.instance;
final shouldPaste = shouldHandleTerminalPasteShortcut(
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);
@@ -244,7 +175,6 @@ 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;
@@ -253,7 +183,11 @@ class _TerminalPageState extends State<TerminalPage>
_terminalModel.terminalController.clearSelection();
await Clipboard.setData(ClipboardData(text: text));
} else {
await _pasteClipboardText();
final data = await Clipboard.getData('text/plain');
final text = data?.text;
if (text != null) {
_terminalModel.terminal.paste(text);
}
}
},
);
@@ -380,171 +314,66 @@ class _TerminalPageState extends State<TerminalPage>
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Row 1 follows the latest reviewed PR layout.
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: _buildKeyboardKeyButtons(terminalKeyboardRow1Keys),
),
// Row 2 ends with the full-width Row3 collapse/expand toggle.
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
..._buildKeyboardKeyButtons(terminalKeyboardRow2Keys),
const SizedBox(width: terminalKeyboardKeySpacing),
_buildCollapseButton(),
_buildKeyButton('Esc'),
const SizedBox(width: 2),
_buildKeyButton('/'),
const SizedBox(width: 2),
_buildKeyButton('|'),
const SizedBox(width: 2),
_buildKeyButton('Home'),
const SizedBox(width: 2),
_buildKeyButton(''),
const SizedBox(width: 2),
_buildKeyButton('End'),
const SizedBox(width: 2),
_buildKeyButton('PgUp'),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildKeyButton('Tab'),
const SizedBox(width: 2),
_buildKeyButton('Ctrl+C'),
const SizedBox(width: 2),
_buildKeyButton('~'),
const SizedBox(width: 2),
_buildKeyButton(''),
const SizedBox(width: 2),
_buildKeyButton(''),
const SizedBox(width: 2),
_buildKeyButton(''),
const SizedBox(width: 2),
_buildKeyButton('PgDn'),
],
),
// Row 3 restores paging keys and trailing alignment placeholders.
if (_row3Expanded)
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
..._buildKeyboardKeyButtons(terminalKeyboardRow3Keys),
for (var i = 0;
i < terminalKeyboardRow3TrailingPlaceholderCount;
i++) ...[
const SizedBox(width: terminalKeyboardKeySpacing),
const SizedBox(width: terminalKeyboardKeyWidth),
],
],
),
],
),
),
);
}
// Ctrl toggle button with highlighted locked state
Widget _buildCtrlKeyButton() {
return _buildModifierToggleButton(
text: 'Ctrl',
semanticsLabel: 'Ctrl',
isLocked: _ctrlLocked,
onPressed: () => setState(() => _ctrlLocked = !_ctrlLocked),
);
}
// Alt toggle button with highlighted locked state
Widget _buildAltKeyButton() {
return _buildModifierToggleButton(
text: 'Alt',
semanticsLabel: 'Alt',
isLocked: _altLocked,
onPressed: () => setState(() => _altLocked = !_altLocked),
);
}
// Collapse/expand toggle button for Row3
void _toggleRow3Expanded() {
final willExpand = !_row3Expanded;
final shouldClearModifiers = shouldClearTerminalModifiersWhenRow3Collapses(
wasExpanded: _row3Expanded,
willExpand: willExpand,
ctrlLocked: _ctrlLocked,
altLocked: _altLocked,
);
setState(() {
_row3Expanded = willExpand;
if (shouldClearModifiers) {
_ctrlLocked = false;
_altLocked = false;
}
});
mainSetLocalBoolOption(kOptionShowTerminalCtrlKeys, willExpand);
// The floating keyboard height changes after Row3 is inserted/removed.
// Re-measure on the next frame so terminal padding uses the new height.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_showTerminalExtraKeys) return;
setState(() {
_updateKeyboardHeight();
});
});
}
Widget _buildCollapseButton() {
return Semantics(
label: translate('Show terminal extra keys'),
toggled: _row3Expanded,
child: ElevatedButton(
onPressed: _toggleRow3Expanded,
child: Text(_row3Expanded ? '' : ''),
style: ElevatedButton.styleFrom(
minimumSize: const Size(terminalKeyboardKeyWidth, 32),
padding: EdgeInsets.zero,
textStyle: const TextStyle(fontSize: 12),
backgroundColor:
Theme.of(context).colorScheme.surfaceContainerHighest,
foregroundColor: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
);
}
/// Builds a fixed-width key sequence with the reviewed 2dp spacing.
List<Widget> _buildKeyboardKeyButtons(List<String> labels) {
return [
for (var i = 0; i < labels.length; i++) ...[
_buildKeyButton(labels[i]),
if (i < labels.length - 1)
const SizedBox(width: terminalKeyboardKeySpacing),
],
];
}
/// Build a modifier toggle button (Ctrl/Alt) with one-shot behavior.
/// When [isLocked] is true, the button highlights in blue and the next
/// single-character input is mapped to its modified equivalent.
Widget _buildModifierToggleButton({
required String text,
required String semanticsLabel,
required bool isLocked,
required VoidCallback onPressed,
}) {
return Semantics(
// Ctrl and Alt are technical key names and intentionally stay unchanged.
label: semanticsLabel,
toggled: isLocked,
child: ElevatedButton(
onPressed: onPressed,
child: Text(text),
style: ElevatedButton.styleFrom(
minimumSize: const Size(terminalKeyboardKeyWidth, 32),
padding: EdgeInsets.zero,
textStyle: const TextStyle(fontSize: 12),
backgroundColor: isLocked
? Colors.blue
: Theme.of(context).colorScheme.surfaceContainerHighest,
foregroundColor: isLocked
? Colors.white
: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
);
}
Widget _buildKeyButton(String label) {
if (label == 'Ctrl') return _buildCtrlKeyButton();
if (label == 'Alt') return _buildAltKeyButton();
return ElevatedButton(
onPressed: () {
_sendKeyToTerminal(label);
},
child: Text(label),
style: ElevatedButton.styleFrom(
minimumSize: const Size(terminalKeyboardKeyWidth, 32),
minimumSize: const Size(48, 32),
padding: EdgeInsets.zero,
textStyle: const TextStyle(fontSize: 12),
backgroundColor:
Theme.of(context).colorScheme.surfaceContainerHighest,
backgroundColor: Theme.of(context).colorScheme.surfaceVariant,
foregroundColor: Theme.of(context).colorScheme.onSurfaceVariant,
),
);
}
void _sendKeyToTerminal(String key) {
String send;
String? send;
switch (key) {
case 'Esc':
@@ -588,7 +417,9 @@ class _TerminalPageState extends State<TerminalPage>
break;
}
_terminalModel.sendVirtualKey(send);
if (send != null) {
_terminalModel.sendVirtualKey(send);
}
}
// https://github.com/TerminalStudio/xterm.dart/issues/42#issuecomment-877495472

View File

@@ -1,20 +0,0 @@
/// Reviewed mobile terminal keyboard layout from PR #15532.
///
/// Keeping the key order outside the widget makes the intended layout explicit
/// and prevents behavior fixes from silently moving keys between rows.
const terminalKeyboardRow1Keys = ['Esc', '/', '|', 'Home', '', 'End', r'\'];
const terminalKeyboardRow2Keys = ['Tab', 'Ctrl+C', '~', '', '', ''];
const terminalKeyboardRow3Keys = ['Ctrl', 'Alt', '-', 'PgUp', 'PgDn'];
const terminalKeyboardKeyWidth = 48.0;
const terminalKeyboardKeySpacing = 2.0;
/// Empty 48dp slots keep expanded Row3 aligned with the two rows above it.
const terminalKeyboardRow3TrailingPlaceholderCount = 2;
/// Returns the fixed width occupied by a row of equally sized key slots.
double terminalKeyboardRowWidth(int slotCount) {
if (slotCount <= 0) return 0;
return slotCount * terminalKeyboardKeyWidth +
(slotCount - 1) * terminalKeyboardKeySpacing;
}

View File

@@ -1,114 +0,0 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../common.dart';
import '../../models/platform_model.dart';
const _deployDialogTag = 'android-deploy-device';
void showDeployPromptDialog() {
gFFI.dialogManager.dismissByTag(_deployDialogTag);
gFFI.dialogManager.show<bool>((setState, close, context) {
submit() => close(true);
return CustomAlertDialog(
title: Text(translate("Deploy")),
content: Text(translate("server_requires_deployment_tip")),
actions: [
dialogButton("Cancel", onPressed: close, isOutline: true),
dialogButton("OK", onPressed: submit),
],
onSubmit: submit,
onCancel: close,
);
}, tag: _deployDialogTag).then((deploy) {
if (deploy == true) {
showDeployDialog();
}
});
}
void showDeployDialog() {
gFFI.dialogManager.dismissByTag(_deployDialogTag);
final tokenController = TextEditingController();
final idController = TextEditingController();
var errorText = "";
var isInProgress = false;
gFFI.dialogManager.show((setState, close, context) {
submit() async {
if (isInProgress) return;
final token = tokenController.text.trim();
if (token.isEmpty) {
setState(() {
errorText = translate("token is required!");
});
return;
}
setState(() {
errorText = "";
isInProgress = true;
});
String res;
try {
res = await bind.mainDeployDevice(
token: token, id: idController.text.trim());
} catch (e) {
setState(() {
errorText = translate(e.toString());
isInProgress = false;
});
return;
}
if (res.isEmpty) {
close();
await gFFI.serverModel.fetchID();
showToast(translate("Successful"));
} else {
setState(() {
errorText = translate(res.toString());
isInProgress = false;
});
}
}
return CustomAlertDialog(
title: Text(translate("Deploy")),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: tokenController,
decoration: InputDecoration(labelText: translate("API Token")),
obscureText: true,
enableSuggestions: false,
autocorrect: false,
autofocus: true,
).workaroundFreezeLinuxMint(),
TextField(
controller: idController,
decoration:
InputDecoration(labelText: translate("Custom ID (optional)")),
).workaroundFreezeLinuxMint(),
if (errorText.isNotEmpty)
Align(
alignment: Alignment.centerLeft,
child: SelectableText(
errorText,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 12,
),
).paddingOnly(top: 8),
),
if (isInProgress) const LinearProgressIndicator().paddingOnly(top: 8),
],
),
actions: [
dialogButton("Cancel",
onPressed: isInProgress ? null : close, isOutline: true),
dialogButton("OK", onPressed: isInProgress ? null : submit),
],
onSubmit: submit,
onCancel: isInProgress ? null : close,
);
}, tag: _deployDialogTag);
}

View File

@@ -12,6 +12,100 @@ void _showSuccess() {
showToast(translate("Successful"));
}
void _showError() {
showToast(translate("Error"));
}
void setPermanentPasswordDialog(OverlayDialogManager dialogManager) async {
final pw = await bind.mainGetPermanentPassword();
final p0 = TextEditingController(text: pw);
final p1 = TextEditingController(text: pw);
var validateLength = false;
var validateSame = false;
dialogManager.show((setState, close, context) {
submit() async {
close();
dialogManager.showLoading(translate("Waiting"));
if (await gFFI.serverModel.setPermanentPassword(p0.text)) {
dialogManager.dismissAll();
_showSuccess();
} else {
dialogManager.dismissAll();
_showError();
}
}
return CustomAlertDialog(
title: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.password_rounded, color: MyTheme.accent),
Text(translate('Set your own password')).paddingOnly(left: 10),
],
),
content: Form(
autovalidateMode: AutovalidateMode.onUserInteraction,
child: Column(mainAxisSize: MainAxisSize.min, children: [
TextFormField(
autofocus: true,
obscureText: true,
keyboardType: TextInputType.visiblePassword,
decoration: InputDecoration(
labelText: translate('Password'),
),
controller: p0,
validator: (v) {
if (v == null) return null;
final val = v.trim().length > 5;
if (validateLength != val) {
// use delay to make setState success
Future.delayed(Duration(microseconds: 1),
() => setState(() => validateLength = val));
}
return val
? null
: translate('Too short, at least 6 characters.');
},
).workaroundFreezeLinuxMint(),
TextFormField(
obscureText: true,
keyboardType: TextInputType.visiblePassword,
decoration: InputDecoration(
labelText: translate('Confirmation'),
),
controller: p1,
validator: (v) {
if (v == null) return null;
final val = p0.text == v;
if (validateSame != val) {
Future.delayed(Duration(microseconds: 1),
() => setState(() => validateSame = val));
}
return val
? null
: translate('The confirmation is not identical.');
},
).workaroundFreezeLinuxMint(),
])),
onCancel: close,
onSubmit: (validateLength && validateSame) ? submit : null,
actions: [
dialogButton(
'Cancel',
icon: Icon(Icons.close_rounded),
onPressed: close,
isOutline: true,
),
dialogButton(
'OK',
icon: Icon(Icons.done_rounded),
onPressed: (validateLength && validateSame) ? submit : null,
),
],
);
});
}
void setTemporaryPasswordLengthDialog(
OverlayDialogManager dialogManager) async {
List<String> lengths = ['6', '8', '10'];
@@ -117,13 +211,13 @@ void showServerSettingsWithValue(
),
SizedBox(width: 8),
Expanded(
child: serverSettingsTextFormField(
label: label,
child: TextFormField(
controller: controller,
errorMsg: errorMsg,
contentPadding:
EdgeInsets.symmetric(horizontal: 8, vertical: 12),
showLabelText: false,
decoration: InputDecoration(
errorText: errorMsg.isEmpty ? null : errorMsg,
contentPadding:
EdgeInsets.symmetric(horizontal: 8, vertical: 12),
),
validator: validator,
autofocus: autofocus,
).workaroundFreezeLinuxMint(),
@@ -132,10 +226,12 @@ void showServerSettingsWithValue(
);
}
return serverSettingsTextFormField(
label: label,
return TextFormField(
controller: controller,
errorMsg: errorMsg,
decoration: InputDecoration(
labelText: label,
errorText: errorMsg.isEmpty ? null : errorMsg,
),
validator: validator,
).workaroundFreezeLinuxMint();
}
@@ -207,35 +303,6 @@ 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,

View File

@@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common/hbbs/hbbs.dart';
@@ -52,9 +53,7 @@ class AbModel {
RxBool get currentAbLoading => current.abLoading;
bool get currentAbEmpty => current.peers.isEmpty && current.tags.isEmpty;
final _listPullError = ''.obs;
RxString get abPullError =>
_listPullError.value.isNotEmpty ? _listPullError : current.pullError;
RxString get currentAbPullError => current.pullError;
RxString get currentAbPushError => current.pushError;
String? _personalAbGuid;
RxBool legacyMode = false.obs;
@@ -69,7 +68,6 @@ class AbModel {
var _syncFromRecentLock = false;
var _timerCounter = 0;
var _cacheLoadOnceFlag = false;
var _pulledOnce = false;
var listInitialized = false;
var _maxPeerOneAb = 0;
@@ -99,17 +97,10 @@ class AbModel {
print("reset ab model");
addressbooks.clear();
_currentName.value = '';
_listPullError.value = '';
_pulledOnce = false;
await bind.mainClearAb();
listInitialized = false;
}
void clearPullErrors() {
_listPullError.value = '';
current.pullError.value = '';
}
// #region ab
/// Pulls the address book data from the server.
///
@@ -119,41 +110,31 @@ class AbModel {
var _pulling = false;
Future<void> pullAb(
{required ForcePullAb? force, required bool quiet}) async {
if (bind.isDisableAb()) return;
if (!gFFI.userModel.isLogin) return;
if (gFFI.userModel.networkError.isNotEmpty) return;
if (_pulling) return;
if (force == null && _pulledOnce) {
return;
}
_pulling = true;
if (!quiet) {
_listPullError.value = '';
current.pullError.value = '';
}
try {
await _pullAb(force: force, quiet: quiet);
_refreshTab();
} catch (_) {}
_pulling = false;
_pulledOnce = true;
}
Future<void> _pullAb(
{required ForcePullAb? force, required bool quiet}) async {
if (bind.isDisableAb()) return;
if (!gFFI.userModel.isLogin) return;
if (gFFI.userModel.networkError.isNotEmpty) return;
if (force == null && listInitialized && current.initialized) return;
debugPrint("pullAb, force: $force, quiet: $quiet");
if (!listInitialized || force == ForcePullAb.listAndCurrent) {
try {
// Read personal guid every time to avoid upgrading the server without closing the main window
_personalAbGuid = null;
// `true`: continue init. `false`: stop, error already recorded.
if (!await _getPersonalAbGuid(quiet: quiet)) {
return;
}
await _getPersonalAbGuid();
// Determine legacy mode based on whether _personalAbGuid is null
legacyMode.value = _personalAbGuid == null;
if (!legacyMode.value && _maxPeerOneAb == 0) {
await _getAbSettings(quiet: quiet);
await _getAbSettings();
}
if (_personalAbGuid != null) {
debugPrint("pull ab list");
@@ -161,7 +142,7 @@ class AbModel {
abProfiles.add(AbProfile(_personalAbGuid!, _personalAddressBookName,
gFFI.userModel.userName.value, null, ShareRule.read.value, null));
// get all address book name
await _getSharedAbProfiles(abProfiles, quiet: quiet);
await _getSharedAbProfiles(abProfiles);
addressbooks.removeWhere((key, value) =>
abProfiles.firstWhereOrNull((e) => e.name == key) == null);
for (int i = 0; i < abProfiles.length; i++) {
@@ -201,7 +182,6 @@ class AbModel {
}
} catch (e) {
debugPrint("pull ab list error: $e");
_setListPullError(e, quiet: quiet);
}
} else if (listInitialized &&
(!current.initialized || force == ForcePullAb.current)) {
@@ -217,26 +197,14 @@ class AbModel {
}
}
void _setListPullError(Object err, {required bool quiet, int? statusCode}) {
if (!quiet) {
_listPullError.value =
'${translate('pull_ab_failed_tip')}: ${translate(err.toString())}';
}
if (statusCode == 401) {
gFFI.userModel.reset(resetOther: true);
}
}
Future<bool> _getAbSettings({required bool quiet}) async {
int? statusCode;
Future<bool> _getAbSettings() async {
try {
final api = "${await bind.mainGetApiServer()}/api/ab/settings";
var headers = getHttpHeaders();
headers['Content-Type'] = "application/json";
_setEmptyBody(headers);
final resp = await http.post(Uri.parse(api), headers: headers);
statusCode = resp.statusCode;
if (statusCode == 404) {
if (resp.statusCode == 404) {
debugPrint("HTTP 404, api server doesn't support shared address book");
return false;
}
@@ -245,57 +213,46 @@ class AbModel {
if (json.containsKey('error')) {
throw json['error'];
}
if (statusCode != 200) {
throw 'HTTP $statusCode';
if (resp.statusCode != 200) {
throw 'HTTP ${resp.statusCode}';
}
_maxPeerOneAb = json['max_peer_one_ab'] ?? 0;
return true;
} catch (err) {
debugPrint('get ab settings err: ${err.toString()}');
_setListPullError(err, quiet: quiet, statusCode: statusCode);
}
return false;
}
/// Loads `/api/ab/personal`.
/// Returns `true` to continue init, `false` to stop after a real error.
Future<bool> _getPersonalAbGuid({required bool quiet}) async {
int? statusCode;
Future<bool> _getPersonalAbGuid() async {
try {
final api = "${await bind.mainGetApiServer()}/api/ab/personal";
var headers = getHttpHeaders();
headers['Content-Type'] = "application/json";
_setEmptyBody(headers);
final resp = await http.post(Uri.parse(api), headers: headers);
statusCode = resp.statusCode;
if (statusCode == 404) {
if (resp.statusCode == 404) {
debugPrint("HTTP 404, current api server is legacy mode");
// Old server: keep `_personalAbGuid` null and continue in legacy mode.
return true;
return false;
}
Map<String, dynamic> json =
_jsonDecodeRespMap(decode_http_response(resp), resp.statusCode);
if (json.containsKey('error')) {
throw json['error'];
}
if (statusCode != 200) {
throw 'HTTP $statusCode';
if (resp.statusCode != 200) {
throw 'HTTP ${resp.statusCode}';
}
_personalAbGuid = json['guid'];
// New server: guid is available, continue in non-legacy mode.
return true;
} catch (err) {
debugPrint('get personal ab err: ${err.toString()}');
_setListPullError(err, quiet: quiet, statusCode: statusCode);
}
// Real error: stop the current pull.
return false;
}
Future<bool> _getSharedAbProfiles(List<AbProfile> profiles,
{required bool quiet}) async {
Future<bool> _getSharedAbProfiles(List<AbProfile> profiles) async {
final api = "${await bind.mainGetApiServer()}/api/ab/shared/profiles";
int? statusCode;
try {
var uri0 = Uri.parse(api);
final pageSize = 100;
@@ -316,19 +273,13 @@ class AbModel {
headers['Content-Type'] = "application/json";
_setEmptyBody(headers);
final resp = await http.post(uri, headers: headers);
statusCode = resp.statusCode;
if (statusCode == 404) {
debugPrint(
"HTTP 404, api server doesn't support shared address book");
return false;
}
Map<String, dynamic> json =
_jsonDecodeRespMap(decode_http_response(resp), resp.statusCode);
if (json.containsKey('error')) {
throw json['error'];
}
if (statusCode != 200) {
throw 'HTTP $statusCode';
if (resp.statusCode != 200) {
throw 'HTTP ${resp.statusCode}';
}
if (json.containsKey('total')) {
if (total == 0) total = json['total'];
@@ -351,7 +302,6 @@ class AbModel {
return true;
} catch (err) {
debugPrint('_getSharedAbProfiles err: ${err.toString()}');
_setListPullError(err, quiet: quiet, statusCode: statusCode);
}
return false;
}

View File

@@ -16,8 +16,6 @@ class _PixelbufferTexture {
int _display = 0;
SessionID? _sessionId;
bool _destroying = false;
bool _closed = false;
int _ptr = 0;
int? _id;
final textureRenderer = TextureRgbaRenderer();
@@ -29,22 +27,11 @@ class _PixelbufferTexture {
_textureKey = bind.getNextTextureKey();
_sessionId = sessionId;
final textureKey = _textureKey;
textureRenderer.createTexture(textureKey).then((id) async {
textureRenderer.createTexture(_textureKey).then((id) async {
_id = id;
if (id != -1) {
if (_closed) {
// Destroyed while creation was still in flight (rapid
// connect/disconnect); nobody else will close this texture.
await textureRenderer.closeTexture(textureKey);
return;
}
ffi.textureModel.setRgbaTextureId(display: d, id: id);
final ptr = await textureRenderer.getTexturePtr(textureKey);
if (_closed) {
return;
}
_ptr = ptr;
final ptr = await textureRenderer.getTexturePtr(_textureKey);
platformFFI.registerPixelbufferTexture(sessionId, display, ptr);
debugPrint(
"create pixelbuffer texture: peerId: ${ffi.id} display:$_display, textureId:$id, texturePtr:$ptr");
@@ -52,16 +39,13 @@ class _PixelbufferTexture {
});
}
destroy(FFI ffi) async {
_closed = true;
destroy(bool unregisterTexture, FFI ffi) async {
if (!_destroying && _textureKey != -1 && _sessionId != null) {
_destroying = true;
if (_ptr != 0) {
// Compare-and-clear: only clears if Rust still holds this pointer
// (#8016-safe); returning from this synchronous call also means no
// push through the old pointer is still in flight.
platformFFI.unregisterPixelbufferTexture(_sessionId!, display, _ptr);
_ptr = 0;
if (unregisterTexture) {
platformFFI.registerPixelbufferTexture(_sessionId!, display, 0);
// sleep for a while to avoid the texture is used after it's unregistered.
await Future.delayed(Duration(milliseconds: 100));
}
await textureRenderer.closeTexture(_textureKey);
_textureKey = -1;
@@ -77,7 +61,6 @@ class _GpuTexture {
SessionID? _sessionId;
final support = bind.mainHasGpuTextureRender();
bool _destroying = false;
bool _closed = false;
int _display = 0;
int? _id;
int? _output;
@@ -96,18 +79,9 @@ class _GpuTexture {
gpuTextureRenderer.registerTexture().then((id) async {
_id = id;
if (id != null) {
if (_closed) {
// Destroyed while creation was still in flight (rapid
// connect/disconnect); nobody else will unregister this texture.
await gpuTextureRenderer.unregisterTexture(id);
return;
}
_textureId = id;
ffi.textureModel.setGpuTextureId(display: d, id: id);
final output = await gpuTextureRenderer.output(id);
if (_closed) {
return;
}
_output = output;
if (output != null) {
platformFFI.registerGpuTexture(sessionId, d, output);
@@ -121,22 +95,20 @@ class _GpuTexture {
}
}
destroy(FFI ffi) async {
destroy(bool unregisterTexture, FFI ffi) async {
// must stop texture render, render unregistered texture cause crash
_closed = true;
if (!_destroying && support && _sessionId != null && _textureId != -1) {
_destroying = true;
final output = _output;
if (output != null) {
// Compare-and-clear, see _PixelbufferTexture.destroy.
platformFFI.unregisterGpuTexture(_sessionId!, _display, output);
_output = null;
if (unregisterTexture) {
platformFFI.registerGpuTexture(_sessionId!, _display, 0);
// sleep for a while to avoid the texture is used after it's unregistered.
await Future.delayed(Duration(milliseconds: 100));
}
await gpuTextureRenderer.unregisterTexture(_textureId);
_textureId = -1;
_destroying = false;
debugPrint(
"destroy gpu texture: peerId: ${ffi.id} display:$_display, textureId:$_id, output:$output");
"destroy gpu texture: peerId: ${ffi.id} display:$_display, textureId:$_id, output:$_output");
}
}
}
@@ -228,11 +200,11 @@ class TextureModel {
tryRemoveTexture(int idx) {
_control.remove(idx);
if (_pixelbufferRenderTextures.containsKey(idx)) {
_pixelbufferRenderTextures[idx]!.destroy(ffi);
_pixelbufferRenderTextures[idx]!.destroy(true, ffi);
_pixelbufferRenderTextures.remove(idx);
}
if (_gpuRenderTextures.containsKey(idx)) {
_gpuRenderTextures[idx]!.destroy(ffi);
_gpuRenderTextures[idx]!.destroy(true, ffi);
_gpuRenderTextures.remove(idx);
}
}
@@ -252,25 +224,25 @@ class TextureModel {
}
}
onRemotePageDispose() async {
onRemotePageDispose(bool closeSession) async {
final ffi = parent.target;
if (ffi == null) return;
for (final texture in _pixelbufferRenderTextures.values) {
await texture.destroy(ffi);
await texture.destroy(closeSession, ffi);
}
for (final texture in _gpuRenderTextures.values) {
await texture.destroy(ffi);
await texture.destroy(closeSession, ffi);
}
}
onViewCameraPageDispose() async {
onViewCameraPageDispose(bool closeSession) async {
final ffi = parent.target;
if (ffi == null) return;
for (final texture in _pixelbufferRenderTextures.values) {
await texture.destroy(ffi);
await texture.destroy(closeSession, ffi);
}
for (final texture in _gpuRenderTextures.values) {
await texture.destroy(ffi);
await texture.destroy(closeSession, ffi);
}
}

View File

@@ -142,22 +142,12 @@ 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.
@@ -166,12 +156,15 @@ class FileModel {
? await showFileConfirmDialog(translate("Overwrite"),
"${evt['read_path']}", true, evt['is_identical'] == "true")
: null);
if (!jobController.hasTransferConflictJob(id)) {
debugPrint("Ignore override confirm result for inactive job: $evt");
return;
}
final id = int.tryParse(evt['id']) ?? 0;
if (false == resp) {
await jobController.cancelTransferConflictBatch(id);
final jobIndex = jobController.getJob(id);
if (jobIndex != -1) {
await jobController.cancelJob(id);
final job = jobController.jobTable[jobIndex];
job.state = JobState.done;
jobController.jobTable.refresh();
}
} else {
var need_override = false;
if (resp == null) {
@@ -183,7 +176,6 @@ class FileModel {
}
// Update the loop config.
if (fileConfirmCheckboxRemember) {
jobController.rememberTransferConflictBatch(id, resp);
evtLoop.setSkip(!need_override);
}
await bind.sessionSetConfirmOverrideFile(
@@ -293,8 +285,6 @@ 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,
@@ -401,30 +391,14 @@ class FileController {
await Future.delayed(Duration(milliseconds: 100));
final savedDir = (await bind.sessionGetPeerOption(
final dir = (await bind.sessionGetPeerOption(
sessionId: sessionId, name: isLocal ? "local_dir" : "remote_dir"));
Future<bool> tryOpenReadyDirs() async {
final dirs = <String>{
if (directory.value.path.isNotEmpty) directory.value.path,
if (savedDir.isNotEmpty) savedDir,
options.value.home,
};
for (final dir in dirs) {
if (await _openDirectoryPath(dir, isBack: true)) {
return true;
}
}
return false;
}
var opened = await tryOpenReadyDirs();
openDirectory(dir.isEmpty ? options.value.home : dir);
await Future.delayed(Duration(seconds: 1));
if (!opened) {
// The peer may become ready during the reconnect delay, so retry the
// same candidates instead of only retrying the default home directory.
await tryOpenReadyDirs();
if (directory.value.path.isEmpty) {
openDirectory(options.value.home);
}
}
@@ -455,23 +429,19 @@ class FileController {
});
}
Future<bool> refresh() async {
// "." can be both a refresh command and a real remote directory path.
// Refresh must bypass openDirectory's command dispatch to avoid recursion.
return await _openDirectoryPath(directory.value.path, isBack: true);
Future<void> refresh() async {
await openDirectory(directory.value.path);
}
Future<bool> openDirectory(String path, {bool isBack = false}) async {
if (!isBack && path == ".") {
return await refresh();
Future<void> openDirectory(String path, {bool isBack = false}) async {
if (path == ".") {
refresh();
return;
}
if (!isBack && path == "..") {
return await _goToParentDirectory(isBack: isBack);
if (path == "..") {
goToParentDirectory();
return;
}
return await _openDirectoryPath(path, isBack: isBack);
}
Future<bool> _openDirectoryPath(String path, {bool isBack = false}) async {
if (!isBack) {
pushHistory();
}
@@ -488,10 +458,8 @@ class FileController {
final fd = await fileFetcher.fetchDirectory(path, isLocal, showHidden);
fd.format(isWindows, sort: sortBy.value);
directory.value = fd;
return true;
} catch (e) {
debugPrint("Failed to openDirectory $path: $e");
return false;
}
}
@@ -519,22 +487,19 @@ class FileController {
goBack();
return;
}
unawaited(_openDirectoryPath(path, isBack: true).then<void>((_) {}));
openDirectory(path, isBack: true);
}
void goToParentDirectory() {
unawaited(_goToParentDirectory().then<void>((_) {}));
}
Future<bool> _goToParentDirectory({bool isBack = false}) async {
final isWindows = options.value.isWindows;
final dirPath = directory.value.path;
var parent = PathUtil.dirname(dirPath, isWindows);
// specially for C:\, D:\, goto '/'
if (parent == dirPath && isWindows) {
return await _openDirectoryPath('/', isBack: isBack);
openDirectory('/');
return;
}
return await _openDirectoryPath(parent, isBack: isBack);
openDirectory(parent);
}
// TODO deprecated this
@@ -580,15 +545,8 @@ 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,
@@ -934,10 +892,6 @@ 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();
@@ -950,57 +904,6 @@ 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();
@@ -1072,10 +975,7 @@ class JobController {
id = int.parse(evt['id']);
} catch (_) {}
final jobIndex = getJob(id);
if (jobIndex == -1) {
unregisterTransferConflictJob(id);
return true;
}
if (jobIndex == -1) return true;
final job = jobTable[jobIndex];
job.recvJobRes = true;
if (job.type == JobType.deleteFile) {
@@ -1101,9 +1001,6 @@ 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 {
@@ -1113,15 +1010,9 @@ class JobController {
void jobError(Map<String, dynamic> evt) {
final err = evt['err'].toString();
final id = int.tryParse(evt['id']?.toString() ?? '');
if (id == null) {
debugPrint("Ignore job error with invalid id: $evt");
return;
}
int jobIndex = getJob(id);
int jobIndex = getJob(int.parse(evt['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;
@@ -1144,11 +1035,6 @@ 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) {
@@ -1185,42 +1071,9 @@ 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']);
@@ -1267,7 +1120,7 @@ class JobController {
..state = JobState.paused;
jobTable.add(jobProgress);
}
registerTransferConflictBatch([currJobId]);
await bind.sessionAddJob(
sessionId: sessionId,
isRemote: isRemote,
@@ -1315,9 +1168,6 @@ class JobController {
void clear() {
jobTable.clear();
_transferConflictJobToBatch.clear();
_transferConflictRememberBatchId = null;
_transferConflictRememberOverrideConfirm = null;
jobResultListener.clear();
}
}
@@ -1660,9 +1510,6 @@ 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");
}
@@ -1972,44 +1819,21 @@ 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 {
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;
}
var event = evt as _FileDialogEvent;
event.setOverrideConfirm(_overrideConfirm);
event.setSkip(_skip);
debugPrint(
"FileDialogEventLoop: consuming<jobId: ${evt.data['id']} batchId: $_batchId overrideConfirm: $_overrideConfirm, skip: $_skip>");
"FileDialogEventLoop: consuming<jobId: ${evt.data['id']} overrideConfirm: $_overrideConfirm, skip: $_skip>");
}
@override
Future<void> onEventsClear() {
_batchId = null;
_overrideConfirm = null;
_skip = false;
return super.onEventsClear();

View File

@@ -343,7 +343,6 @@ class GroupModel {
}
reset() async {
initialized = false;
groupLoadError.value = '';
deviceGroups.clear();
users.clear();

View File

@@ -2,7 +2,6 @@ import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'dart:ui' as ui;
import 'package:desktop_multi_window/desktop_multi_window.dart';
@@ -16,13 +15,12 @@ import 'package:get/get.dart';
import '../../models/model.dart';
import '../../models/platform_model.dart';
import '../../models/state_model.dart';
import 'input_modifier_utils.dart';
import 'relative_mouse_model.dart';
import '../common.dart';
import '../consts.dart';
/// Mouse button enum.
enum MouseButtons { left, right, wheel, back, forward }
enum MouseButtons { left, right, wheel, back }
const _kMouseEventDown = 'mousedown';
const _kMouseEventUp = 'mouseup';
@@ -159,8 +157,6 @@ extension ToString on MouseButtons {
return 'wheel';
case MouseButtons.back:
return 'back';
case MouseButtons.forward:
return 'forward';
}
}
}
@@ -331,80 +327,6 @@ class ToReleaseKeys {
}
class InputModel {
// Side mouse button support for Linux.
// Flutter's Linux embedder drops X11 button 8/9 events, so we capture them
// natively via GDK and forward through the platform channel.
static InputModel? _activeSideButtonModel;
// Tracks per-button which model received a side button down event, so the
// matching up event is routed there even if the pointer has left the view
// or a different button was pressed in between.
static final Map<MouseButtons, InputModel> _sideButtonDownModels = {};
static bool _sideButtonChannelInitialized = false;
/// Each Flutter engine (main window + sub-windows from desktop_multi_window)
/// runs its own Dart isolate with its own statics. Called from initEnv()
/// which runs per-engine, so each isolate registers its own handler tied
/// to its own set of InputModels.
static void initSideButtonChannel() {
if (!isLinux) return;
if (_sideButtonChannelInitialized) return;
_sideButtonChannelInitialized = true;
const channel = MethodChannel('org.rustdesk.rustdesk/side_buttons');
channel.setMethodCallHandler((call) async {
if (call.method == 'onSideMouseButton') {
final args = call.arguments as Map<dynamic, dynamic>;
final button = args['button'] as String;
final type = args['type'] as String;
final mb = button == 'back' ? MouseButtons.back : MouseButtons.forward;
if (type == 'down') {
final model = _activeSideButtonModel;
if (model != null &&
!(model.isViewOnly && !model.showMyCursor) &&
model.keyboardPerm &&
!model.isViewCamera) {
_sideButtonDownModels[mb] = model;
// Fire-and-forget to avoid blocking the platform channel handler.
unawaited(model._sendMouseUnchecked(type, mb).catchError((Object e) {
debugPrint('[InputModel] failed to send side button $type for $mb: $e');
}));
}
} else {
// Only route 'up' when we recorded the matching 'down';
// dropping avoids sending unpaired 'up' to an unrelated session.
// Use _sendMouseUnchecked to bypass permission checks so the
// release always goes through even if permissions changed.
final model = _sideButtonDownModels.remove(mb);
if (model != null) {
unawaited(model._sendMouseUnchecked(type, mb).catchError((Object e) {
debugPrint('[InputModel] failed to send side button $type for $mb: $e');
}));
}
}
}
return null;
});
}
/// Clear any static references to this model (prevents stale routing).
/// Releases any held side buttons on the peer so closing a session
/// mid-press does not leave a stuck button.
void disposeSideButtonTracking() {
if (_activeSideButtonModel == this) _activeSideButtonModel = null;
final held = _sideButtonDownModels.entries
.where((e) => e.value == this)
.map((e) => e.key)
.toList();
for (final mb in held) {
_sideButtonDownModels.remove(mb);
// Best-effort release; session may already be tearing down.
unawaited(_sendMouseUnchecked('up', mb).catchError((Object e) {
debugPrint('[InputModel] failed to release side button $mb: $e');
}));
}
}
final WeakReference<FFI> parent;
String keyboardMode = '';
@@ -426,12 +348,6 @@ class InputModel {
final _trackpadAdjustPeerLinux = 0.06;
// This is an experience value.
final _trackpadAdjustMacToWin = 2.50;
// Ignore directional locking for very small deltas on both axes (including
// tiny single-axis movement) to avoid over-filtering near zero.
static const double _trackpadAxisNoiseThreshold = 0.2;
// Lock to dominant axis only when one axis is clearly stronger.
// 1.6 means the dominant axis must be >= 60% larger than the other.
static const double _trackpadAxisLockRatio = 1.6;
int _trackpadSpeed = kDefaultTrackpadSpeed;
double _trackpadSpeedInner = kDefaultTrackpadSpeed / 100.0;
var _trackpadScrollUnsent = Offset.zero;
@@ -449,16 +365,6 @@ class InputModel {
final isPhysicalMouse = false.obs;
int _lastButtons = 0;
Offset lastMousePos = Offset.zero;
int _lastWheelTsUs = 0;
// Wheel acceleration thresholds.
static const int _wheelAccelFastThresholdUs = 40000; // 40ms
static const int _wheelAccelMediumThresholdUs = 80000; // 80ms
static const double _wheelBurstVelocityThreshold =
0.002; // delta units per microsecond
// Wheel burst acceleration (empirical tuning).
// Applies only to fast, non-smooth bursts to preserve single-step scrolling.
// Flutter uses microseconds for dt, so velocity is in delta/us.
// Relative mouse mode (for games/3D apps).
final relativeMouseMode = false.obs;
@@ -474,10 +380,6 @@ class InputModel {
late final SessionID sessionId;
// Local gate for clipboard-assisted input flows on mobile Wayland dialogs.
// It should not block physical keyboard events.
bool keyboardInputAllowed = true;
bool get keyboardPerm => parent.target!.ffiModel.keyboard;
String get id => parent.target?.id ?? '';
String? get peerPlatform => parent.target?.ffiModel.pi.platform;
@@ -494,7 +396,6 @@ class InputModel {
bool get isRelativeMouseModeSupported => _relativeMouse.isSupported;
InputModel(this.parent) {
initSideButtonChannel();
sessionId = parent.target!.sessionId;
_relativeMouse = RelativeMouseModel(
sessionId: sessionId,
@@ -703,38 +604,6 @@ class InputModel {
}
}
// Safe: this only re-dispatches synthesized Shift key-up events.
// The key-up path clears the tracked Shift state so this does not loop.
void _releaseTrackedShiftKeyEventIfNeeded() {
final leftShift = toReleaseKeys.lastLShiftKeyEvent;
final rightShift = toReleaseKeys.lastRShiftKeyEvent;
if (leftShift != null) {
handleKeyEvent(leftShift);
}
if (rightShift != null) {
handleKeyEvent(rightShift);
}
}
// Safe: this only re-dispatches synthesized Shift key-up events.
// The raw key-up path clears the tracked Shift state so this does not loop.
void _releaseTrackedRawShiftKeyEventIfNeeded() {
final leftShift = toReleaseRawKeys.lastLShiftKeyEvent;
final rightShift = toReleaseRawKeys.lastRShiftKeyEvent;
if (leftShift != null) {
handleRawKeyEvent(RawKeyUpEvent(
data: leftShift.data,
character: leftShift.character,
));
}
if (rightShift != null) {
handleRawKeyEvent(RawKeyUpEvent(
data: rightShift.data,
character: rightShift.character,
));
}
}
KeyEventResult handleRawKeyEvent(RawKeyEvent e) {
if (isViewOnly) return KeyEventResult.handled;
if (isViewCamera) return KeyEventResult.handled;
@@ -789,27 +658,6 @@ class InputModel {
toReleaseRawKeys.updateKeyUp(key, e);
}
// On some mobile soft-keyboard paths, Flutter may leave cached Shift state
// set even though the current raw key event is not shifted anymore.
if (e is RawKeyDownEvent &&
shouldReleaseStaleMobileShift(
isMobile: isMobile,
cachedShiftPressed: shift,
actualShiftPressed: e.isShiftPressed,
logicalKey: e.logicalKey,
hasTrackedShiftKeyDown: toReleaseRawKeys.lastLShiftKeyEvent != null ||
toReleaseRawKeys.lastRShiftKeyEvent != null,
)) {
if (kDebugMode) {
debugPrint(
'input: releasing stale mobile Shift before replaying tracked raw '
'key-up (logicalKey=${e.logicalKey.keyLabel}, '
'actualShiftPressed=${e.isShiftPressed}, cachedShiftPressed=$shift)',
);
}
_releaseTrackedRawShiftKeyEventIfNeeded();
}
// * Currently mobile does not enable map mode
if ((isDesktop || isWebDesktop) && keyboardMode == kKeyMapMode) {
mapKeyboardModeRaw(e, iosCapsLock);
@@ -853,8 +701,6 @@ class InputModel {
iosCapsLock = _getIosCapsFromCharacter(e);
}
// Update cached modifier state before sending the event. The stale mobile
// Shift release check below relies on this cached state.
if (e is KeyUpEvent) {
handleKeyUpEventModifiers(e);
} else if (e is KeyDownEvent) {
@@ -892,21 +738,6 @@ class InputModel {
}
}
}
// On some mobile soft-keyboard paths, Flutter may leave cached Shift state
// set even though the current key event is not shifted anymore.
if (e is KeyDownEvent &&
shouldReleaseStaleMobileShift(
isMobile: isMobile,
cachedShiftPressed: shift,
actualShiftPressed: HardwareKeyboard.instance.isShiftPressed,
logicalKey: e.logicalKey,
hasTrackedShiftKeyDown: toReleaseKeys.lastLShiftKeyEvent != null ||
toReleaseKeys.lastRShiftKeyEvent != null,
)) {
_releaseTrackedShiftKeyEventIfNeeded();
}
final isDesktopAndMapMode =
isDesktop || (isWebDesktop && keyboardMode == kKeyMapMode);
if (isMobileAndMapMode || isDesktopAndMapMode) {
@@ -1119,20 +950,13 @@ class InputModel {
return evt;
}
/// Send mouse event unconditionally (no permission checks).
/// Used for side button releases that must go through even if permissions
/// changed after the matching down was sent.
Future<void> _sendMouseUnchecked(String type, MouseButtons button) async {
await bind.sessionSendMouse(
sessionId: sessionId,
msg: json.encode(modify({'type': type, 'buttons': button.value})));
}
/// Send mouse press event.
Future<void> sendMouse(String type, MouseButtons button) async {
if (!keyboardPerm) return;
if (isViewCamera) return;
await _sendMouseUnchecked(type, button);
await bind.sessionSendMouse(
sessionId: sessionId,
msg: json.encode(modify({'type': type, 'buttons': button.value})));
}
void enterOrLeave(bool enter) {
@@ -1140,14 +964,6 @@ class InputModel {
toReleaseRawKeys.release(handleRawKeyEvent);
_pointerMovedAfterEnter = false;
_pointerInsideImage = enter;
_lastWheelTsUs = 0;
// Track active model for side button events (Linux).
if (enter) {
_activeSideButtonModel = this;
} else if (_activeSideButtonModel == this) {
_activeSideButtonModel = null;
}
// Fix status
if (!enter) {
@@ -1307,8 +1123,7 @@ class InputModel {
}
if (isPhysicalMouse.value) {
if (!_relativeMouse.handleRelativeMouseMove(e.localPosition)) {
final canvasPosition = _pointerPositionForRemoteCanvas(e);
handleMouse(_getMouseEvent(e, _kMouseEventMove), canvasPosition,
handleMouse(_getMouseEvent(e, _kMouseEventMove), e.position,
edgeScroll: useEdgeScroll);
}
}
@@ -1346,7 +1161,6 @@ class InputModel {
if (isMacOS && peerPlatform == kPeerPlatformWindows) {
delta *= _trackpadAdjustMacToWin;
}
delta = _filterTrackpadDeltaAxis(delta);
_trackpadLastDelta = delta;
var x = delta.dx.toInt();
@@ -1379,24 +1193,6 @@ class InputModel {
}
}
Offset _filterTrackpadDeltaAxis(Offset delta) {
final absDx = delta.dx.abs();
final absDy = delta.dy.abs();
// Keep diagonal intent when movement is tiny on both axes.
if (absDx < _trackpadAxisNoiseThreshold &&
absDy < _trackpadAxisNoiseThreshold) {
return delta;
}
// Dominant-axis lock to reduce accidental cross-axis scrolling noise.
if (absDy >= absDx * _trackpadAxisLockRatio) {
return Offset(0, delta.dy);
}
if (absDx >= absDy * _trackpadAxisLockRatio) {
return Offset(delta.dx, 0);
}
return delta;
}
void _scheduleFling(double x, double y, int delay) {
if (isViewCamera) return;
if ((x == 0 && y == 0) || _stopFling) {
@@ -1500,16 +1296,6 @@ class InputModel {
return false;
}
/// iOS may emit a synthesized touch event after a real mouse click.
/// This helper ignores touch-down events that arrive shortly after a mouse down,
/// even when the position is far (e.g., near the top edge).
bool _shouldIgnoreTouchAfterMouse(int nowMs) {
if (!isIOS) return false;
const int kTouchAfterMouseWindowMs = 700;
final dt = nowMs - _lastMouseDownTimeMs;
return dt >= 0 && dt < kTouchAfterMouseWindowMs;
}
void onPointDownImage(PointerDownEvent e) {
debugPrint("onPointDownImage ${e.kind}");
_stopFling = true;
@@ -1522,9 +1308,6 @@ class InputModel {
// Track mouse down events for duplicate detection on iOS.
final nowMs = DateTime.now().millisecondsSinceEpoch;
if (e.kind == ui.PointerDeviceKind.mouse) {
if (!isPhysicalMouse.value) {
isPhysicalMouse.value = true;
}
_lastMouseDownTimeMs = nowMs;
_lastMouseDownPos = e.position;
}
@@ -1534,10 +1317,6 @@ class InputModel {
}
if (e.kind != ui.PointerDeviceKind.mouse) {
// Ignore duplicate touch events that follow a recent mouse click (iOS Magic Mouse issue).
if (isPhysicalMouse.value && _shouldIgnoreTouchAfterMouse(nowMs)) {
return;
}
if (isPhysicalMouse.value) {
isPhysicalMouse.value = false;
}
@@ -1549,8 +1328,7 @@ class InputModel {
_relativeMouse
.sendRelativeMouseButton(_getMouseEvent(e, _kMouseEventDown));
} else {
final canvasPosition = _pointerPositionForRemoteCanvas(e);
handleMouse(_getMouseEvent(e, _kMouseEventDown), canvasPosition);
handleMouse(_getMouseEvent(e, _kMouseEventDown), e.position);
}
}
}
@@ -1572,8 +1350,7 @@ class InputModel {
_relativeMouse
.sendRelativeMouseButton(_getMouseEvent(e, _kMouseEventUp));
} else {
final canvasPosition = _pointerPositionForRemoteCanvas(e);
handleMouse(_getMouseEvent(e, _kMouseEventUp), canvasPosition);
handleMouse(_getMouseEvent(e, _kMouseEventUp), e.position);
}
}
}
@@ -1595,40 +1372,12 @@ class InputModel {
}
if (isPhysicalMouse.value) {
if (!_relativeMouse.handleRelativeMouseMove(e.localPosition)) {
final canvasPosition = _pointerPositionForRemoteCanvas(e);
handleMouse(_getMouseEvent(e, _kMouseEventMove), canvasPosition,
handleMouse(_getMouseEvent(e, _kMouseEventMove), e.position,
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 =
@@ -1658,44 +1407,17 @@ class InputModel {
if (isViewOnly) return;
if (isViewCamera) return;
if (e is PointerScrollEvent) {
final rawDx = e.scrollDelta.dx;
final rawDy = e.scrollDelta.dy;
final dominantDelta = rawDx.abs() > rawDy.abs() ? rawDx.abs() : rawDy.abs();
final isSmooth = dominantDelta < 1;
final nowUs = DateTime.now().microsecondsSinceEpoch;
final dtUs = _lastWheelTsUs == 0 ? 0 : nowUs - _lastWheelTsUs;
_lastWheelTsUs = nowUs;
int accel = 1;
if (!isSmooth &&
dtUs > 0 &&
dtUs <= _wheelAccelMediumThresholdUs &&
(isWindows || isLinux) &&
peerPlatform == kPeerPlatformMacOS) {
final velocity = dominantDelta / dtUs;
if (velocity >= _wheelBurstVelocityThreshold) {
if (dtUs < _wheelAccelFastThresholdUs) {
accel = 3;
} else {
accel = 2;
}
}
}
var dx = rawDx.toInt();
var dy = rawDy.toInt();
if (rawDx.abs() > rawDy.abs()) {
dy = 0;
} else {
dx = 0;
}
var dx = e.scrollDelta.dx.toInt();
var dy = e.scrollDelta.dy.toInt();
if (dx > 0) {
dx = -accel;
dx = -1;
} else if (dx < 0) {
dx = accel;
dx = 1;
}
if (dy > 0) {
dy = -accel;
dy = -1;
} else if (dy < 0) {
dy = accel;
dy = 1;
}
bind.sessionSendMouse(
sessionId: sessionId,

View File

@@ -1,190 +0,0 @@
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.
///
/// This is only valid on mobile when Flutter's cached Shift state is still on
/// (`cachedShiftPressed == true`) but the current hardware/raw event reports
/// Shift as off (`actualShiftPressed == false`).
///
/// A tracked Shift key-down is required so the caller can safely synthesize the
/// matching key-up. Both `shiftLeft` and `shiftRight` are excluded because the
/// Shift key event itself must be processed first; otherwise we could release
/// the tracked key while still handling the original Shift press/release.
/// Callers should evaluate this only after their cached modifier state has been
/// updated for the current event.
///
/// When this returns true, the caller logs a line like:
/// `input: releasing stale mobile Shift before replaying tracked raw key-up`
/// immediately before calling `_releaseTrackedRawShiftKeyEventIfNeeded()`.
bool shouldReleaseStaleMobileShift({
required bool isMobile,
required bool cachedShiftPressed,
required bool actualShiftPressed,
required LogicalKeyboardKey logicalKey,
required bool hasTrackedShiftKeyDown,
}) {
if (!isMobile || !cachedShiftPressed || actualShiftPressed) {
return false;
}
if (!hasTrackedShiftKeyDown) {
return false;
}
if (logicalKey == LogicalKeyboardKey.shiftLeft ||
logicalKey == LogicalKeyboardKey.shiftRight) {
return false;
}
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;
}

View File

@@ -55,8 +55,6 @@ 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 = {};
@@ -112,9 +110,6 @@ class CachedPeerData {
class FfiModel with ChangeNotifier {
CachedPeerData cachedPeerData = CachedPeerData();
PeerInfo _pi = PeerInfo();
int? lastUserDisplay;
int? pendingMonitorRestore;
Timer? _pendingRestoreTimer;
Rect? _rect;
var _inputBlocked = false;
@@ -124,7 +119,6 @@ class FfiModel with ChangeNotifier {
bool _touchMode = false;
late VirtualMouseMode virtualMouseMode;
Timer? _timer;
Timer? _restartReconnectDelayTimer;
var _reconnects = 1;
DateTime? _offlineReconnectStartTime;
bool _viewOnly = false;
@@ -251,14 +245,11 @@ 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();
@@ -350,7 +341,6 @@ 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);
@@ -735,11 +725,6 @@ class FfiModel with ChangeNotifier {
_handleUseTextureRender(
Map<String, dynamic> evt, SessionID sessionId, String peerId) {
parent.target?.imageModel.setUseTextureRender(evt['v'] == 'Y');
if (evt['reason'] == 'fallback') {
// The Rust watchdog detected that pushed frames were never rendered
// and switched this session to software rendering.
showToast(translate('texture-render-fallback-tip'));
}
waitForFirstImage.value = true;
isRefreshing = true;
showConnectedWaitingForImage(parent.target!.dialogManager, sessionId,
@@ -937,29 +922,8 @@ class FfiModel with ChangeNotifier {
enterUserLoginAndPasswordDialog(
sessionId, dialogManager, 'terminal-admin-login-tip', false);
} else if (type == 'restarting') {
// Treat restart messages as reconnect control events. Rust still sends
// title/text for legacy UI and translation reuse; Flutter keeps the last
// 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);
showMsgBox(sessionId, type, title, text, link, false, dialogManager,
hasCancel: false);
} else if (type == 'wait-remote-accept-nook') {
showWaitAcceptDialog(sessionId, type, title, text, dialogManager);
} else if (type == 'on-uac' || type == 'on-foreground-elevated') {
@@ -985,11 +949,6 @@ 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(
@@ -1057,31 +1016,19 @@ class FfiModel with ChangeNotifier {
showMsgBox(SessionID sessionId, String type, String title, String text,
String link, bool hasRetry, OverlayDialogManager dialogManager,
{bool? hasCancel}) async {
final noteAllowed = parent.target != null &&
final showNoteEdit = parent.target != null &&
allowAskForNoteAtEndOfConnection(parent.target, false) &&
(title == "Connection Error" || type == "restarting");
final showNoteEdit = noteAllowed && !hasRetry;
(title == "Connection Error" || type == "restarting") &&
!hasRetry;
if (showNoteEdit) {
await showConnEndAuditDialogCloseCanceled(
ffi: parent.target!, type: type, title: title, text: text);
closeConnection();
} else {
VoidCallback? onSubmit;
if (noteAllowed && hasRetry) {
final ffi = parent.target!;
onSubmit = () async {
_timer?.cancel();
_timer = null;
await showConnEndAuditDialogCloseCanceled(
ffi: ffi, type: type, title: title, text: text);
closeConnection();
};
}
msgBox(sessionId, type, title, text, link, dialogManager,
hasCancel: hasCancel,
reconnect: hasRetry ? reconnect : null,
reconnectTimeout: hasRetry ? _reconnects : null,
onSubmit: onSubmit);
reconnectTimeout: hasRetry ? _reconnects : null);
}
_timer?.cancel();
if (hasRetry) {
@@ -1095,22 +1042,10 @@ 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();
@@ -1424,29 +1359,9 @@ 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;
}
@@ -1957,12 +1872,6 @@ class ImageModel with ChangeNotifier {
platformFFI.nextRgba(sessionId, display);
}
// web only: image already created from a decoded WebCodecs frame
Future<void> onImage(
int display, ui.Image image, bool Function() isCurrentSession) async {
await update(image, isCurrentSession: isCurrentSession);
}
decodeAndUpdate(int display, Uint8List rgba) async {
final pid = parent.target?.id;
final rect = parent.target?.ffiModel.pi.getDisplayRect(display);
@@ -1974,16 +1883,11 @@ class ImageModel with ChangeNotifier {
? ui.PixelFormat.rgba8888
: ui.PixelFormat.bgra8888,
);
if (parent.target?.id != pid) {
image?.dispose();
return;
}
if (parent.target?.id != pid) return;
await update(image);
}
Future<void> update(ui.Image? image,
{bool Function()? isCurrentSession}) async {
if (_disposeIfStale(image, isCurrentSession)) return;
update(ui.Image? image) async {
if (_image == null && image != null) {
if (isDesktop || isWebDesktop) {
await parent.target?.canvasModel.updateViewStyle();
@@ -1994,19 +1898,11 @@ class ImageModel with ChangeNotifier {
await initializeCursorAndCanvas(parent.target!);
}
}
if (_disposeIfStale(image, isCurrentSession)) return;
_image?.dispose();
_image = image;
if (image != null) notifyListeners();
}
bool _disposeIfStale(ui.Image? image, bool Function()? isCurrentSession) {
if (image == null || isCurrentSession == null) return false;
if (isCurrentSession()) return false;
image.dispose();
return true;
}
// mobile only
double get maxScale {
if (_image == null) return 1.5;
@@ -2256,9 +2152,6 @@ class CanvasModel with ChangeNotifier {
ViewStyle _lastViewStyle = ViewStyle.defaultViewStyle();
Timer? _timerMobileFocusCanvasCursor;
Timer? _timerMobileRestoreCanvasOffset;
Offset? _offsetBeforeMobileSoftKeyboard;
double? _scaleBeforeMobileSoftKeyboard;
// `isMobileCanvasChanged` is used to avoid canvas reset when changing the input method
// after showing the soft keyboard.
@@ -2746,9 +2639,6 @@ class CanvasModel with ChangeNotifier {
_scale = 1.0;
_lastViewStyle = ViewStyle.defaultViewStyle();
_timerMobileFocusCanvasCursor?.cancel();
_timerMobileRestoreCanvasOffset?.cancel();
_offsetBeforeMobileSoftKeyboard = null;
_scaleBeforeMobileSoftKeyboard = null;
}
updateScrollPercent() {
@@ -2777,31 +2667,6 @@ class CanvasModel with ChangeNotifier {
});
}
void saveMobileOffsetBeforeSoftKeyboard() {
_timerMobileRestoreCanvasOffset?.cancel();
_offsetBeforeMobileSoftKeyboard = Offset(_x, _y);
_scaleBeforeMobileSoftKeyboard = _scale;
}
void restoreMobileOffsetAfterSoftKeyboard() {
_timerMobileRestoreCanvasOffset?.cancel();
_timerMobileFocusCanvasCursor?.cancel();
final targetOffset = _offsetBeforeMobileSoftKeyboard;
final targetScale = _scaleBeforeMobileSoftKeyboard;
if (targetOffset == null || targetScale == null) {
return;
}
_timerMobileRestoreCanvasOffset = Timer(Duration(milliseconds: 100), () {
updateSize();
_x = targetOffset.dx;
_y = targetOffset.dy;
_scale = targetScale;
_offsetBeforeMobileSoftKeyboard = null;
_scaleBeforeMobileSoftKeyboard = null;
notifyListeners();
});
}
// mobile only
// Move the canvas to make the cursor visible(center) on the screen.
void _moveToCenterCursor() {
@@ -3054,13 +2919,8 @@ class CursorModel with ChangeNotifier {
_lastIsBlocked = true;
}
if (isMobile && _lastKeyboardIsVisible != keyboardIsVisible) {
if (keyboardIsVisible) {
parent.target?.canvasModel.saveMobileOffsetBeforeSoftKeyboard();
parent.target?.canvasModel.mobileFocusCanvasCursor();
parent.target?.canvasModel.isMobileCanvasChanged = false;
} else {
parent.target?.canvasModel.restoreMobileOffsetAfterSoftKeyboard();
}
parent.target?.canvasModel.mobileFocusCanvasCursor();
parent.target?.canvasModel.isMobileCanvasChanged = false;
}
_lastKeyboardIsVisible = keyboardIsVisible;
}
@@ -3758,7 +3618,6 @@ class FFI {
/// Mobile reuse FFI
void mobileReset() {
ffiModel.resetRestartReconnectState();
ffiModel.waitForFirstImage.value = true;
ffiModel.isRefreshing = false;
ffiModel.waitForImageDialogShow.value = true;
@@ -3877,15 +3736,6 @@ class FFI {
onEvent2UIRgba();
imageModel.onRgba(display, data);
});
platformFFI.setVideoFrameCallback((int display, ui.Image image,
bool Function() isCurrentSession) async {
if (!isCurrentSession()) {
image.dispose();
return;
}
await onEvent2UIRgba();
await imageModel.onImage(display, image, isCurrentSession);
});
this.id = id;
return;
}
@@ -3973,7 +3823,7 @@ class FFI {
this.id = id;
}
Future<void> onEvent2UIRgba() async {
void onEvent2UIRgba() async {
if (ffiModel.waitForImageDialogShow.isTrue) {
ffiModel.waitForImageDialogShow.value = false;
ffiModel.waitForImageTimer?.cancel();
@@ -3981,35 +3831,16 @@ class FFI {
}
if (ffiModel.waitForFirstImage.value == true) {
ffiModel.waitForFirstImage.value = false;
ffiModel.cancelPendingRestoreTimer();
ffiModel.resetRestartReconnectState();
dialogManager.dismissAll();
try {
await canvasModel.updateViewStyle();
await canvasModel.updateScrollStyle();
await canvasModel.initializeEdgeScrollEdgeThickness();
for (final cb in imageModel.callbacksOnFirstImage) {
cb(id);
}
} finally {
_applyPendingMonitorRestore();
await canvasModel.updateViewStyle();
await canvasModel.updateScrollStyle();
await canvasModel.initializeEdgeScrollEdgeThickness();
for (final cb in imageModel.callbacksOnFirstImage) {
cb(id);
}
}
}
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) {
@@ -4029,9 +3860,6 @@ class FFI {
/// Close the remote session.
Future<void> close({bool closeSession = true}) async {
closed = true;
if (isWeb) {
platformFFI.clearVideoFrameCallback();
}
chatModel.close();
// Close all terminal models
for (final model in _terminalModels.values) {
@@ -4056,7 +3884,6 @@ class FFI {
inputModel.resetModifiers();
// Dispose relative mouse mode resources to ensure cursor is restored
inputModel.disposeRelativeMouseMode();
inputModel.disposeSideButtonTracking();
if (closeSession) {
await bind.sessionClose(sessionId: sessionId);
}

View File

@@ -1,7 +1,6 @@
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';
@@ -26,23 +25,6 @@ typedef F3 = Pointer<Uint8> Function(Pointer<Utf8>, int);
typedef F3Dart = Pointer<Uint8> Function(Pointer<Utf8>, Int32);
typedef HandleEvent = Future<void> Function(Map<String, dynamic> evt);
/// The Linux bundle keeps the core library at lib/librustdesk.so next to the
/// executable. Prefer that copy, mirroring flutter/linux/main.cc: the plain
/// name relies on the loader search path, which repackaged installs may not
/// cover. https://github.com/rustdesk/rustdesk/discussions/14407
DynamicLibrary _openLinuxCoreLib() {
final bundled =
'${File(Platform.resolvedExecutable).parent.path}/lib/librustdesk.so';
try {
if (File(bundled).existsSync()) {
return DynamicLibrary.open(bundled);
}
} catch (e) {
debugPrint("Failed to load '$bundled': $e");
}
return DynamicLibrary.open('librustdesk.so');
}
/// FFI wrapper around the native Rust core.
/// Hides the platform differences.
class PlatformFFI {
@@ -131,12 +113,6 @@ class PlatformFFI {
void registerGpuTexture(SessionID sessionId, int display, int ptr) =>
_ffiBind.sessionRegisterGpuTexture(
sessionId: sessionId, display: display, ptr: ptr);
void unregisterPixelbufferTexture(SessionID sessionId, int display, int ptr) =>
_ffiBind.sessionUnregisterPixelbufferTexture(
sessionId: sessionId, display: display, ptr: ptr);
void unregisterGpuTexture(SessionID sessionId, int display, int ptr) =>
_ffiBind.sessionUnregisterGpuTexture(
sessionId: sessionId, display: display, ptr: ptr);
/// Init the FFI class, loads the native Rust core library.
Future<void> init(String appType) async {
@@ -144,7 +120,7 @@ class PlatformFFI {
final dylib = isAndroid
? DynamicLibrary.open('librustdesk.so')
: isLinux
? _openLinuxCoreLib()
? DynamicLibrary.open('librustdesk.so')
: isWindows
? DynamicLibrary.open('librustdesk.dll')
:
@@ -290,12 +266,6 @@ class PlatformFFI {
void setRgbaCallback(void Function(int, Uint8List) fun) async {}
// web only, decoded WebCodecs frames arriving as ready-made images
void setVideoFrameCallback(
Future<void> Function(int, ui.Image, bool Function()) fun) {}
void clearVideoFrameCallback() {}
void startDesktopWebListener() {}
void stopDesktopWebListener() {}

View File

@@ -145,26 +145,23 @@ class Peer {
note == other.note;
}
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;
}
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);
}
enum UpdateEvent { online, load }

View File

@@ -1,14 +0,0 @@
import 'package:xterm/xterm.dart';
class RustDeskTerminal extends Terminal {
RustDeskTerminal({super.maxLines});
@override
void eraseScrollbackOnly() {
final scrollBack = buffer.scrollBack;
if (scrollBack == 0) return;
// Selection anchors require retained buffer lines to be reindexed.
buffer.lines.remove(0, scrollBack);
}
}

View File

@@ -298,7 +298,7 @@ class ServerModel with ChangeNotifier {
}
toggleAudio() async {
if (clients.any((c) => !c.disconnected)) {
if (clients.isNotEmpty) {
await showClientsMayNotBeChangedAlert(parent.target);
}
if (!_audioOk && !await AndroidPermissionManager.check(kRecordAudio)) {
@@ -316,7 +316,7 @@ class ServerModel with ChangeNotifier {
}
toggleFile() async {
if (clients.any((c) => !c.disconnected)) {
if (clients.isNotEmpty) {
await showClientsMayNotBeChangedAlert(parent.target);
}
if (!_fileOk &&
@@ -345,7 +345,7 @@ class ServerModel with ChangeNotifier {
}
toggleInput() async {
if (clients.any((c) => !c.disconnected)) {
if (clients.isNotEmpty) {
await showClientsMayNotBeChangedAlert(parent.target);
}
if (_inputOk) {
@@ -471,6 +471,17 @@ class ServerModel with ChangeNotifier {
WakelockManager.disable(_wakelockKey);
}
Future<bool> setPermanentPassword(String newPW) async {
await bind.mainSetPermanentPassword(password: newPW);
await Future.delayed(Duration(milliseconds: 500));
final pw = await bind.mainGetPermanentPassword();
if (newPW == pw) {
return true;
} else {
return false;
}
}
fetchID() async {
final id = await bind.mainGetMyId();
if (id != _serverId.id) {
@@ -549,19 +560,10 @@ class ServerModel with ChangeNotifier {
if (index < 0) {
_clients.add(client);
} else {
if (_clients[index].authorized) {
_clients[index].privacyMode = client.privacyMode;
notifyListeners();
return;
}
_clients[index].authorized = true;
_clients[index].privacyMode = client.privacyMode;
}
} else {
final index = _clients.indexWhere((c) => c.id == client.id);
if (index >= 0) {
_clients[index].privacyMode = client.privacyMode;
notifyListeners();
if (_clients.any((c) => c.id == client.id)) {
return;
}
_clients.add(client);
@@ -818,7 +820,6 @@ class Client {
bool isTerminal = false;
String portForward = "";
String name = "";
String avatar = "";
String peerId = ""; // peer user's id,show at app
bool keyboard = false;
bool clipboard = false;
@@ -827,7 +828,6 @@ class Client {
bool restart = false;
bool recording = false;
bool blockInput = false;
bool privacyMode = false;
bool disconnected = false;
bool fromSwitch = false;
bool inVoiceCall = false;
@@ -847,7 +847,6 @@ class Client {
isTerminal = json['is_terminal'] ?? false;
portForward = json['port_forward'];
name = json['name'];
avatar = json['avatar'] ?? '';
peerId = json['peer_id'];
keyboard = json['keyboard'];
clipboard = json['clipboard'];
@@ -856,7 +855,6 @@ class Client {
restart = json['restart'];
recording = json['recording'];
blockInput = json['block_input'];
privacyMode = json['privacy_mode'] ?? privacyMode;
disconnected = json['disconnected'];
fromSwitch = json['from_switch'];
inVoiceCall = json['in_voice_call'];
@@ -872,7 +870,6 @@ class Client {
data['is_terminal'] = isTerminal;
data['port_forward'] = portForward;
data['name'] = name;
data['avatar'] = avatar;
data['peer_id'] = peerId;
data['keyboard'] = keyboard;
data['clipboard'] = clipboard;
@@ -881,7 +878,6 @@ class Client {
data['restart'] = restart;
data['recording'] = recording;
data['block_input'] = blockInput;
data['privacy_mode'] = privacyMode;
data['disconnected'] = disconnected;
data['from_switch'] = fromSwitch;
data['in_voice_call'] = inVoiceCall;

View File

@@ -1,17 +1,15 @@
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
@@ -25,93 +23,24 @@ 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>[];
final _pendingOutputSuppressFlags = <bool>[];
int _pendingOutputSize = 0;
static const int _kMaxOutputBufferChars = 8 * 1024;
// View ready state: true when terminal has valid dimensions, safe to write
bool _terminalViewReady = false;
bool _markViewReadyScheduled = false;
bool _suppressTerminalOutput = false;
bool _suppressNextTerminalDataOutput = false;
bool get isPeerWindows => parent.ffiModel.pi.platform == kPeerPlatformWindows;
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.
// - Peer Linux: canonical-mode shells accept both, but raw-mode apps
// (readline, prompt_toolkit, vim, TUI frameworks) expect '\r'.
// - Peer macOS: same as Linux, raw-mode apps expect '\r'
// (https://github.com/rustdesk/rustdesk/issues/14907).
// So on mobile / web-mobile, normalize the original lone '\n' to '\r'
// before modifier mappings. This keeps Ctrl+J mapped to LF instead of
// having the generated control code rewritten to CR afterward.
// Multi-character keyboard payloads, such as terminal escape sequences,
// remain unchanged. Paste input follows a separate preprocessing path.
final ctrlLocked = isCtrlLocked?.call() ?? false;
final altLocked = isAltLocked?.call() ?? false;
final modifiersActive = ctrlLocked || altLocked;
// Use the same predicate for transformation and consumption. Control keys
// and escape sequences must not silently consume a pending one-shot lock.
final shouldConsumeModifiers =
modifiersActive && shouldApplyTerminalInputModifiers(data);
data = prepareTerminalInputPayload(
data,
// IME soft-keyboard paste prompts currently arrive from xterm as normal
// text input with no paste-origin metadata. Keep them on the keyboard path;
// clipboard-content heuristics can misclassify ordinary typing.
source: TerminalInputSource.keyboard,
isMobileOrWebMobile: isMobile || (isWeb && !isWebDesktop),
bracketedPasteMode: terminal.bracketedPasteMode,
ctrlLocked: ctrlLocked,
altLocked: altLocked,
);
if (shouldConsumeModifiers) {
if (ctrlLocked) clearCtrlLock?.call();
if (altLocked) clearAltLock?.call();
// If we press the `Enter` button on Android,
// `data` can be '\r' or '\n' when using different keyboards.
// Android -> Windows. '\r' works, but '\n' does not. '\n' is just a newline.
// Android -> Linux. Both '\r' and '\n' work as expected (execute a command).
// So when we receive '\n', we may need to convert it to '\r' to ensure compatibility.
// Desktop -> Desktop works fine.
// Check if we are on mobile or web(mobile), and convert '\n' to '\r'.
final isMobileOrWebMobile = (isMobile || (isWeb && !isWebDesktop));
if (isMobileOrWebMobile && isPeerWindows && data == '\n') {
data = '\r';
}
return _sendInputPayload(data);
}
/// Sends an already prepared payload without applying keyboard semantics.
/// Both normal input and paste use this transport path after their source-
/// specific preprocessing has completed.
Future<void> _sendInputPayload(String data) async {
// Clipboard reads and native sends may complete after the terminal page has
// closed. Never send or re-buffer input once this model is disposed.
if (_disposed) return;
if (_terminalOpened) {
// Send user input to remote terminal
try {
@@ -130,15 +59,11 @@ class TerminalModel with ChangeNotifier {
}
TerminalModel(this.parent, [this.terminalId = 0]) : id = parent.id {
terminal = RustDeskTerminal(maxLines: 10000);
terminal.mouseHandler = const WheelButtonFixMouseHandler();
terminal = Terminal(maxLines: 10000);
terminalController = TerminalController();
// Setup terminal callbacks
terminal.onOutput = (data) {
if (_suppressTerminalOutput) return;
_handleInput(data);
};
terminal.onOutput = _handleInput;
terminal.onResize = (w, h, pw, ph) async {
// Validate all dimensions before using them
@@ -149,12 +74,6 @@ class TerminalModel with ChangeNotifier {
// This piece of code must be placed before the conditional check in order to initialize properly.
onResizeExternal?.call(w, h, pw, ph);
// Mark terminal view as ready and flush any buffered output on first valid resize.
// Must be after onResizeExternal so the view layer has valid dimensions before flushing.
if (!_terminalViewReady) {
_scheduleMarkViewReady();
}
if (_terminalOpened) {
// Notify remote terminal of resize
try {
@@ -178,16 +97,14 @@ class TerminalModel with ChangeNotifier {
void onReady() {
parent.dialogManager.dismissAll();
// Fire and forget - don't block onReady. If the transport reconnects while
// this model is still open, re-send OpenTerminal so the remote service marks
// the persistent session active again and resumes output streaming.
openTerminal(force: _terminalOpened).catchError((e) {
// Fire and forget - don't block onReady
openTerminal().catchError((e) {
debugPrint('[TerminalModel] Error opening terminal: $e');
});
}
Future<void> openTerminal({bool force = false}) async {
if (_terminalOpened && !force) return;
Future<void> openTerminal() async {
if (_terminalOpened) return;
// Request the remote side to open a terminal with default shell
// The remote side will decide which shell to use based on its OS
@@ -224,7 +141,7 @@ class TerminalModel with ChangeNotifier {
debugPrint('[TerminalModel] Error calling sessionOpenTerminal: $e');
// Optionally show error to user
if (e is TimeoutException) {
_writeToTerminal('Failed to open terminal: Connection timeout\r\n');
terminal.write('Failed to open terminal: Connection timeout\r\n');
}
}
}
@@ -233,18 +150,6 @@ class TerminalModel with ChangeNotifier {
return _handleInput(data);
}
Future<void> pasteText(String data) async {
final payload = prepareTerminalInputPayload(
data,
source: TerminalInputSource.paste,
isMobileOrWebMobile: false,
bracketedPasteMode: terminal.bracketedPasteMode,
ctrlLocked: false,
altLocked: false,
);
return _sendInputPayload(payload);
}
Future<void> closeTerminal() async {
if (_terminalOpened) {
try {
@@ -319,33 +224,6 @@ 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);
@@ -375,8 +253,8 @@ class TerminalModel with ChangeNotifier {
void _handleTerminalOpened(Map<String, dynamic> evt) {
final bool success = getSuccessFromEvt(evt);
final String message = evt['message']?.toString() ?? '';
final String? serviceId = evt['service_id']?.toString();
final String message = evt['message'] ?? '';
final String? serviceId = evt['service_id'];
debugPrint(
'[TerminalModel] Terminal opened response: success=$success, message=$message, service_id=$serviceId');
@@ -384,21 +262,7 @@ class TerminalModel with ChangeNotifier {
if (success) {
_terminalOpened = true;
// On reconnect, the server may replay recent output. That replay can include
// terminal queries like DSR/DA; xterm answers them through onOutput as
// "^[[1;1R^[[2;2R^[[>0;0;0c", which must not be sent back to the peer.
final replayTerminalOutput = evt['replay_terminal_output'];
_suppressNextTerminalDataOutput = replayTerminalOutput == true ||
message == 'Reconnected to existing terminal with pending output';
// Fallback: if terminal view is not yet ready but already has valid
// dimensions (e.g. layout completed before open response arrived),
// mark view ready now to avoid output stuck in buffer indefinitely.
if (!_terminalViewReady &&
terminal.viewWidth > 0 &&
terminal.viewHeight > 0) {
_scheduleMarkViewReady();
}
// Service ID is now saved on the Rust side in handle_terminal_response
// Process any buffered input
_processBufferedInputAsync().then((_) {
@@ -409,21 +273,17 @@ class TerminalModel with ChangeNotifier {
});
final persistentSessions =
(evt['persistent_sessions'] as List<dynamic>? ?? [])
.whereType<int>()
.where((id) => !parent.terminalModels.containsKey(id))
.toList();
evt['persistent_sessions'] as List<dynamic>? ?? [];
if (kWindowId != null && persistentSessions.isNotEmpty) {
DesktopMultiWindow.invokeMethod(
kWindowId!,
kWindowEventRestoreTerminalSessions,
jsonEncode({
'peer_id': id,
'persistent_sessions': persistentSessions,
}));
}
} else {
_writeToTerminal('Failed to open terminal: $message\r\n');
terminal.write('Failed to open terminal: $message\r\n');
}
}
@@ -448,8 +308,6 @@ class TerminalModel with ChangeNotifier {
final data = evt['data'];
if (data != null) {
final suppressTerminalOutput = _suppressNextTerminalDataOutput;
_suppressNextTerminalDataOutput = false;
try {
String text = '';
if (data is String) {
@@ -469,137 +327,29 @@ class TerminalModel with ChangeNotifier {
return;
}
_writeToTerminal(text, suppressTerminalOutput: suppressTerminalOutput);
terminal.write(text);
} catch (e) {
debugPrint('[TerminalModel] Failed to process terminal data: $e');
}
}
}
/// Write text to terminal, buffering if the view is not yet ready.
/// All terminal output should go through this method to avoid NaN errors
/// from writing before the terminal view has valid layout dimensions.
void _writeToTerminal(
String text, {
bool suppressTerminalOutput = false,
}) {
if (!_terminalViewReady) {
// If a single chunk exceeds the cap, keep only its tail.
// Note: truncation may split a multi-byte ANSI escape sequence,
// which can cause a brief visual glitch on flush. This is acceptable
// because it only affects the pre-layout buffering window and the
// terminal will self-correct on subsequent output.
if (text.length >= _kMaxOutputBufferChars) {
final truncated = text.substring(text.length - _kMaxOutputBufferChars);
_pendingOutputChunks
..clear()
..add(truncated);
_pendingOutputSuppressFlags
..clear()
..add(suppressTerminalOutput);
_pendingOutputSize = truncated.length;
} else {
_pendingOutputChunks.add(text);
_pendingOutputSuppressFlags.add(suppressTerminalOutput);
_pendingOutputSize += text.length;
// Drop oldest chunks if exceeds limit (whole chunks to preserve ANSI sequences)
while (_pendingOutputSize > _kMaxOutputBufferChars &&
_pendingOutputChunks.length > 1) {
final removed = _pendingOutputChunks.removeAt(0);
_pendingOutputSuppressFlags.removeAt(0);
_pendingOutputSize -= removed.length;
}
}
return;
}
_writeTerminalChunk(text, suppressTerminalOutput: suppressTerminalOutput);
}
void _flushOutputBuffer() {
if (_pendingOutputChunks.isEmpty) return;
debugPrint(
'[TerminalModel] Flushing $_pendingOutputSize buffered chars (${_pendingOutputChunks.length} chunks)');
for (var i = 0; i < _pendingOutputChunks.length; i++) {
_writeTerminalChunk(
_pendingOutputChunks[i],
suppressTerminalOutput: _pendingOutputSuppressFlags[i],
);
}
_pendingOutputChunks.clear();
_pendingOutputSuppressFlags.clear();
_pendingOutputSize = 0;
}
void _writeTerminalChunk(
String text, {
required bool suppressTerminalOutput,
}) {
if (!suppressTerminalOutput) {
terminal.write(text);
return;
}
final previous = _suppressTerminalOutput;
_suppressTerminalOutput = true;
try {
terminal.write(text);
} finally {
_suppressTerminalOutput = previous;
}
}
/// Mark terminal view as ready and flush buffered output.
void _scheduleMarkViewReady() {
if (_disposed || _terminalViewReady || _markViewReadyScheduled) return;
_markViewReadyScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_markViewReadyScheduled = false;
if (_disposed || _terminalViewReady) return;
if (terminal.viewWidth > 0 && terminal.viewHeight > 0) {
_markViewReady();
}
});
WidgetsBinding.instance.ensureVisualUpdate();
}
void _markViewReady() {
if (_terminalViewReady) return;
_terminalViewReady = true;
_flushOutputBuffer();
}
void _handleTerminalClosed(Map<String, dynamic> evt) {
final int exitCode = getExitCodeFromEvt(evt);
_writeToTerminal('\r\nTerminal closed with exit code: $exitCode\r\n');
final int exitCode = evt['exit_code'] ?? 0;
terminal.write('\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) {
final String message = evt['message'] ?? 'Unknown error';
_writeToTerminal('\r\nTerminal error: $message\r\n');
terminal.write('\r\nTerminal error: $message\r\n');
}
@override
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();
_pendingOutputSuppressFlags.clear();
_pendingOutputSize = 0;
_markViewReadyScheduled = false;
_suppressNextTerminalDataOutput = false;
// Terminal cleanup is handled server-side when service closes
super.dispose();
}

View File

@@ -1,42 +0,0 @@
import 'package:xterm/xterm.dart';
/// xterm 4.0.0 encodes wheel buttons as 68..71; the extra bit reads as a Shift
/// modifier, so strict full-screen apps ignore the report and never scroll.
/// Upstream fix: TerminalStudio/xterm.dart#238.
class WheelButtonFixMouseHandler implements TerminalMouseHandler {
const WheelButtonFixMouseHandler();
@override
String? call(TerminalMouseEvent event) {
if (!event.button.isWheel) {
return defaultMouseHandler(event);
}
// Same gate as UpDownMouseHandler: only the scroll modes report a wheel,
// and a wheel release is never reported, so the report is always a press.
if (!event.state.mouseMode.reportScroll ||
event.buttonState == TerminalMouseButtonState.up) {
return null;
}
return _reportWheel(event);
}
String _reportWheel(TerminalMouseEvent event) {
// Wheel buttons 4..7 go on the wire as 64..67, but `id` is 64 + 4..7.
final button = event.button.id - 4;
final x = event.position.x + 1;
final y = event.position.y + 1;
switch (event.state.mouseReportMode) {
case MouseReportMode.normal:
case MouseReportMode.utf:
final limit =
event.state.mouseReportMode == MouseReportMode.normal ? 223 : 2015;
final col = x > limit ? '\x00' : String.fromCharCode(32 + x);
final row = y > limit ? '\x00' : String.fromCharCode(32 + y);
return '\x1b[M${String.fromCharCode(32 + button)}$col$row';
case MouseReportMode.sgr:
return '\x1b[<$button;$x;${y}M';
case MouseReportMode.urxvt:
return '\x1b[${32 + button};$x;${y}M';
}
}
}

View File

@@ -17,12 +17,8 @@ bool refreshingUser = false;
class UserModel {
final RxString userName = ''.obs;
final RxString displayName = ''.obs;
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;
@@ -37,7 +33,6 @@ class UserModel {
}
return '$preferred (@$username)';
}
WeakReference<FFI> parent;
UserModel(this.parent) {
@@ -53,7 +48,6 @@ class UserModel {
void refreshCurrentUser() async {
if (bind.isDisableAccount()) return;
networkError.value = '';
networkErrorFromServer.value = false;
final token = bind.mainGetLocalOption(key: 'access_token');
if (token == '') {
await updateOtherModels();
@@ -89,10 +83,6 @@ class UserModel {
final data = json.decode(decode_http_response(response));
final error = data['error'];
if (error != null) {
// The only failure known to come from the server itself, so the
// check-your-network tip does not apply. Flag before the message is
// set in the catch below so rebuilds read a consistent pair.
networkErrorFromServer.value = true;
throw error;
}
@@ -100,13 +90,6 @@ class UserModel {
_parseAndUpdateUser(user);
} catch (e) {
debugPrint('Failed to refreshCurrentUser: $e');
// Surface failures in the address book / group tabs, which offer a
// retry. Anything not flagged above -- transport errors, non-JSON or
// unexpected-schema bodies (e.g. a filter's block page) -- keeps the
// check-your-network tip.
if (networkError.value.isEmpty) {
networkError.value = e.toString();
}
} finally {
refreshingUser = false;
await updateOtherModels();
@@ -131,7 +114,6 @@ class UserModel {
if (userInfo != null) {
userName.value = (userInfo['name'] ?? '').toString();
displayName.value = (userInfo['display_name'] ?? '').toString();
avatar.value = (userInfo['avatar'] ?? '').toString();
}
}
@@ -144,13 +126,11 @@ class UserModel {
}
userName.value = '';
displayName.value = '';
avatar.value = '';
}
_parseAndUpdateUser(UserPayload user) {
userName.value = user.name;
displayName.value = user.displayName;
avatar.value = user.avatar;
isAdmin.value = user.isAdmin;
bind.mainSetLocalOption(key: 'user_info', value: jsonEncode(user));
if (isWeb) {
@@ -234,32 +214,28 @@ class UserModel {
return loginResponse;
}
/// Throws on network failures, non-success responses, and invalid response
/// data. Returns an empty list when no API server is configured or a
/// successful response contains no third-party login options.
static Future<List<dynamic>> queryOidcLoginOptions() async {
final url = await bind.mainGetApiServer();
if (url.trim().isEmpty) return [];
final resp = await http.get(Uri.parse('$url/api/login-options'));
const successStatusCodeStart = 200;
const successStatusCodeEnd = 300;
if (resp.statusCode < successStatusCodeStart ||
resp.statusCode >= successStatusCodeEnd) {
throw RequestException(
resp.statusCode, resp.reasonPhrase ?? 'Request failed');
}
final List<String> ops = [];
for (final item in jsonDecode(resp.body)) {
ops.add(item as String);
}
for (final item in ops) {
if (item.startsWith('common-oidc/')) {
return jsonDecode(item.substring('common-oidc/'.length));
try {
final url = await bind.mainGetApiServer();
if (url.trim().isEmpty) return [];
final resp = await http.get(Uri.parse('$url/api/login-options'));
final List<String> ops = [];
for (final item in jsonDecode(resp.body)) {
ops.add(item as String);
}
for (final item in ops) {
if (item.startsWith('common-oidc/')) {
return jsonDecode(item.substring('common-oidc/'.length));
}
}
return ops
.where((item) => item.startsWith('oidc/'))
.map((item) => {'name': item.substring('oidc/'.length)})
.toList();
} catch (e) {
debugPrint(
"queryOidcLoginOptions: jsonDecode resp body failed: ${e.toString()}");
return [];
}
return ops
.where((item) => item.startsWith('oidc/'))
.map((item) => {'name': item.substring('oidc/'.length)})
.toList();
}
}

View File

@@ -2,18 +2,14 @@
import 'dart:convert';
import 'dart:js_interop';
import 'dart:js_interop_unsafe';
import 'dart:typed_data';
import 'dart:js';
import 'dart:html';
import 'dart:async';
import 'dart:ui' as ui;
import 'dart:ui_web' as ui_web;
import 'package:flutter/foundation.dart';
import 'package:flutter_hbb/common/widgets/login.dart';
import 'package:flutter_hbb/models/state_model.dart';
import 'package:flutter_hbb/models/web_video_frame_queue.dart';
import 'package:flutter_hbb/web/bridge.dart';
import 'package:flutter_hbb/common.dart';
@@ -22,22 +18,6 @@ import 'package:uuid/uuid.dart';
final List<StreamSubscription<MouseEvent>> mouseListeners = [];
final List<StreamSubscription<KeyboardEvent>> keyListeners = [];
// WebCodecs VideoFrames handed over from js/src/webcodecs.js arrive as plain
// interop objects (the package language version predates extension types).
// This side owns each frame and must close it quickly: hardware decoders
// stall once their small output frame pool is exhausted.
int _videoFrameWidth(JSObject frame) =>
frame.getProperty<JSNumber>('displayWidth'.toJS).toDartInt;
int _videoFrameHeight(JSObject frame) =>
frame.getProperty<JSNumber>('displayHeight'.toJS).toDartInt;
void _closeVideoFrame(JSObject frame) {
try {
frame.callMethod<JSAny?>('close'.toJS);
} catch (error) {
debugPrint('VideoFrame.close failed: $error');
}
}
typedef HandleEvent = Future<void> Function(Map<String, dynamic> evt);
class PlatformFFI {
@@ -53,13 +33,6 @@ class PlatformFFI {
}
PlatformFFI._() {
_videoFrameQueue = WebVideoFrameQueue(
importFrame: _importVideoFrame,
closeFrame: _closeVideoFrame,
disposeImage: (image) => image.dispose(),
onImportError: _handleVideoFrameImportError,
onCallbackError: _handleVideoImageCallbackError,
);
window.document.addEventListener(
'visibilitychange',
(event) => {
@@ -136,12 +109,6 @@ class PlatformFFI {
void registerGpuTexture(SessionID sessionId, int display, int ptr) =>
_ffiBind.sessionRegisterGpuTexture(
sessionId: sessionId, display: display, ptr: ptr);
void unregisterPixelbufferTexture(SessionID sessionId, int display, int ptr) =>
_ffiBind.sessionUnregisterPixelbufferTexture(
sessionId: sessionId, display: display, ptr: ptr);
void unregisterGpuTexture(SessionID sessionId, int display, int ptr) =>
_ffiBind.sessionUnregisterGpuTexture(
sessionId: sessionId, display: display, ptr: ptr);
Future<void> init(String appType) async {
Completer completer = Completer();
@@ -195,46 +162,6 @@ class PlatformFFI {
};
}
late final WebVideoFrameQueue<JSObject, ui.Image> _videoFrameQueue;
// Zero-readback video path: the JS decoder hands decoded VideoFrames here
// (checking typeof window.onVideoFrame before every frame), and the engine
// imports them GPU-to-GPU via createImageBitmap. Unregistering the JS global
// reverts the JS side to the RGBA readback path.
void setVideoFrameCallback(
Future<void> Function(int, ui.Image, bool Function()) fun) {
_videoFrameQueue.beginSession(fun);
if (!_videoFrameQueue.isEnabled) return;
globalContext.setProperty(
'onVideoFrame'.toJS,
((JSNumber display, JSObject frame) {
_videoFrameQueue.submit(display.toDartInt, frame);
}).toJS,
);
}
void clearVideoFrameCallback() {
_videoFrameQueue.endSession();
globalContext.setProperty('onVideoFrame'.toJS, null);
}
Future<ui.Image> _importVideoFrame(JSObject frame) async {
return await ui_web.createImageFromTextureSource(frame,
width: _videoFrameWidth(frame), height: _videoFrameHeight(frame));
}
void _handleVideoFrameImportError(Object error, StackTrace stackTrace) {
debugPrintStack(
label: 'createImageFromTextureSource failed, using RGBA path: $error',
stackTrace: stackTrace);
globalContext.setProperty('onVideoFrame'.toJS, null);
}
void _handleVideoImageCallbackError(Object error, StackTrace stackTrace) {
debugPrintStack(
label: 'video image callback error: $error', stackTrace: stackTrace);
}
void startDesktopWebListener() {
mouseListeners.add(
window.document.onContextMenu.listen((evt) => evt.preventDefault()));

View File

@@ -1,133 +0,0 @@
import 'dart:async';
typedef VideoFrameImporter<Frame, Image> = Future<Image> Function(Frame frame);
typedef VideoFrameCloser<Frame> = void Function(Frame frame);
typedef VideoImageDisposer<Image> = void Function(Image image);
typedef VideoSessionValidator = bool Function();
typedef VideoImageCallback<Image> = Future<void> Function(
int display, Image image, VideoSessionValidator isCurrentSession);
typedef VideoQueueErrorCallback = void Function(
Object error, StackTrace stackTrace);
class WebVideoFrameQueue<Frame, Image> {
WebVideoFrameQueue({
required VideoFrameImporter<Frame, Image> importFrame,
required VideoFrameCloser<Frame> closeFrame,
required VideoImageDisposer<Image> disposeImage,
required VideoQueueErrorCallback onImportError,
required VideoQueueErrorCallback onCallbackError,
}) : _importFrame = importFrame,
_closeFrame = closeFrame,
_disposeImage = disposeImage,
_onImportError = onImportError,
_onCallbackError = onCallbackError;
final VideoFrameImporter<Frame, Image> _importFrame;
final VideoFrameCloser<Frame> _closeFrame;
final VideoImageDisposer<Image> _disposeImage;
final VideoQueueErrorCallback _onImportError;
final VideoQueueErrorCallback _onCallbackError;
final Map<int, _QueuedFrame<Frame>> _pending = {};
VideoImageCallback<Image>? _callback;
int _generation = 0;
bool _processing = false;
bool _enabled = true;
bool get isEnabled => _enabled;
void beginSession(VideoImageCallback<Image> callback) {
_invalidateSession();
_enabled = true;
_callback = callback;
}
void endSession() {
_invalidateSession();
_callback = null;
}
void _invalidateSession() {
_generation++;
for (final queued in _pending.values) {
_closeFrame(queued.frame);
}
_pending.clear();
}
bool submit(int display, Frame frame) {
if (!_enabled || _callback == null) {
_closeFrame(frame);
return false;
}
final replaced = _pending.remove(display);
if (replaced != null) {
_closeFrame(replaced.frame);
}
_pending[display] = _QueuedFrame(display, frame, _generation);
_startProcessing();
return true;
}
void _startProcessing() {
if (_processing) return;
_processing = true;
unawaited(Future<void>(_process));
}
Future<void> _process() async {
while (_pending.isNotEmpty) {
final display = _pending.keys.first;
final queued = _pending.remove(display)!;
if (!_enabled || queued.generation != _generation) {
_closeFrame(queued.frame);
continue;
}
await _importAndDeliver(queued);
}
_processing = false;
}
Future<void> _importAndDeliver(_QueuedFrame<Frame> queued) async {
Image? image;
try {
image = await _importFrame(queued.frame);
} catch (error, stackTrace) {
if (queued.generation == _generation) {
_enabled = false;
_onImportError(error, stackTrace);
}
} finally {
_closeFrame(queued.frame);
}
if (image != null) {
await _deliver(queued, image);
}
}
Future<void> _deliver(_QueuedFrame<Frame> queued, Image image) async {
final callback = _callback;
bool isCurrentSession() =>
_enabled &&
queued.generation == _generation &&
identical(callback, _callback);
if (!isCurrentSession() || callback == null) {
_disposeImage(image);
return;
}
try {
await callback(queued.display, image, isCurrentSession);
} catch (error, stackTrace) {
_disposeImage(image);
_onCallbackError(error, stackTrace);
}
}
}
class _QueuedFrame<Frame> {
const _QueuedFrame(this.display, this.frame, this.generation);
final int display;
final Frame frame;
final int generation;
}

View File

@@ -44,51 +44,32 @@ class HttpService {
return _parseHttpResponse(resJson);
}
// Bounds only the pure-Dart branch below, which the OS would otherwise
// let hang forever (e.g. a black-holed TLS handshake), see #15700.
// The Rust branch has its own 12s-per-attempt timeouts and must be
// awaited to completion: a Dart-side timeout there would race the
// URL-keyed ASYNC_HTTP_STATUS entry of the abandoned request.
static const _requestTimeout = Duration(seconds: 30);
Future<http.Response> _pollFlutterHttp(
Uri url,
HttpMethod method, {
Map<String, String>? headers,
dynamic body,
}) async {
final client = http.Client();
try {
var response = http.Response('', 400);
var response = http.Response('', 400);
switch (method) {
case HttpMethod.get:
response =
await client.get(url, headers: headers).timeout(_requestTimeout);
break;
case HttpMethod.post:
response = await client
.post(url, headers: headers, body: body)
.timeout(_requestTimeout);
break;
case HttpMethod.put:
response = await client
.put(url, headers: headers, body: body)
.timeout(_requestTimeout);
break;
case HttpMethod.delete:
response = await client
.delete(url, headers: headers, body: body)
.timeout(_requestTimeout);
break;
default:
throw Exception('Unsupported HTTP method');
}
return response;
} finally {
client.close();
switch (method) {
case HttpMethod.get:
response = await http.get(url, headers: headers);
break;
case HttpMethod.post:
response = await http.post(url, headers: headers, body: body);
break;
case HttpMethod.put:
response = await http.put(url, headers: headers, body: body);
break;
case HttpMethod.delete:
response = await http.delete(url, headers: headers, body: body);
break;
default:
throw Exception('Unsupported HTTP method');
}
return response;
}
Future<String> _pollForResponse(String url) async {

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