mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-02-17 14:07:28 +08:00
Compare commits
182 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
779b7aaf02 | ||
|
|
b268aa1061 | ||
|
|
40f86fa639 | ||
|
|
980bc11e68 | ||
|
|
85db677982 | ||
|
|
2842315b1d | ||
|
|
6c541f7bfd | ||
|
|
067fab2b73 | ||
|
|
de6bf9dc7e | ||
|
|
54eae37038 | ||
|
|
0118e16132 | ||
|
|
626a091f55 | ||
|
|
4fa5e99e65 | ||
|
|
5ee9dcf42d | ||
|
|
6306f83316 | ||
|
|
96075fdf49 | ||
|
|
8c6dcf53a6 | ||
|
|
e1b1a927b8 | ||
|
|
1e6bfa7bb1 | ||
|
|
79ef4c4501 | ||
|
|
5f3ceef592 | ||
|
|
1a90e6b6c7 | ||
|
|
f112d097dc | ||
|
|
45cab7f808 | ||
|
|
216ec9d52b | ||
|
|
56a8f6b97b | ||
|
|
c76d10a438 | ||
|
|
f05f2178e5 | ||
|
|
226d7417b2 | ||
|
|
b0c8e65c6e | ||
|
|
4ae577c3c4 | ||
|
|
204e81a700 | ||
|
|
1f35830570 | ||
|
|
6b334f2977 | ||
|
|
0dc3c12aa5 | ||
|
|
ceffcce20e | ||
|
|
e4b06dadf5 | ||
|
|
087eb55299 | ||
|
|
341eb0c671 | ||
|
|
43b39102a4 | ||
|
|
be4bbd018d | ||
|
|
21a7cef98a | ||
|
|
a6724b1c07 | ||
|
|
7437593ee7 | ||
|
|
f21829b075 | ||
|
|
b4f60e6057 | ||
|
|
b9ebddff0c | ||
|
|
a2243484a3 | ||
|
|
c4a9835ae5 | ||
|
|
92ad279324 | ||
|
|
7276025cf9 | ||
|
|
9808d585cf | ||
|
|
dab9ed711c | ||
|
|
b27a93fc77 | ||
|
|
e3f66973b7 | ||
|
|
21529d6ca2 | ||
|
|
775b0a3c93 | ||
|
|
070d4d029f | ||
|
|
5355702e9c | ||
|
|
a97997952d | ||
|
|
b0c12bd86b | ||
|
|
82fcab26b1 | ||
|
|
f3bbcc4f55 | ||
|
|
98362eaca0 | ||
|
|
998b75856d | ||
|
|
3a9084006f | ||
|
|
4d3ccc62e8 | ||
|
|
8fe10d61ea | ||
|
|
5a183490dc | ||
|
|
9dd4fa8646 | ||
|
|
a05b619563 | ||
|
|
7f9506b476 | ||
|
|
f65952cf1c | ||
|
|
7ac03ffefc | ||
|
|
f6d6c3afb5 | ||
|
|
419703d2ea | ||
|
|
9301edef06 | ||
|
|
7e3f0a607b | ||
|
|
dec0e7c56d | ||
|
|
0758e10ae2 | ||
|
|
19ae785fa2 | ||
|
|
918ce865ca | ||
|
|
d27a21feee | ||
|
|
d8932b69a3 | ||
|
|
5af580f44d | ||
|
|
3384eda8b7 | ||
|
|
969ea28d06 | ||
|
|
5b2101e17d | ||
|
|
ec2d7f0519 | ||
|
|
656ce93d6e | ||
|
|
b69e871f9a | ||
|
|
bba57069a8 | ||
|
|
6a701f1420 | ||
|
|
eba847e62e | ||
|
|
b80eb2dc6c | ||
|
|
1f9689dc00 | ||
|
|
84eb75d5b6 | ||
|
|
4f2aea65ab | ||
|
|
d6463f95b9 | ||
|
|
3e0688ab63 | ||
|
|
692e90f779 | ||
|
|
e4faedcb62 | ||
|
|
a32d36a97b | ||
|
|
da2c678fb3 | ||
|
|
7bdfa121f3 | ||
|
|
b9a1369c6f | ||
|
|
0112b3387e | ||
|
|
de9d86621d | ||
|
|
735862d1fd | ||
|
|
a0537759b1 | ||
|
|
a79776c1c4 | ||
|
|
822b6d1baf | ||
|
|
0065085ba2 | ||
|
|
4f4da20fc0 | ||
|
|
eb0174ea53 | ||
|
|
20ce626654 | ||
|
|
a342941ec1 | ||
|
|
a78a803a22 | ||
|
|
23754630e8 | ||
|
|
8e6e91eb4a | ||
|
|
9cfa551163 | ||
|
|
5b21441898 | ||
|
|
4ed8696d1d | ||
|
|
ae06f27372 | ||
|
|
33e1493932 | ||
|
|
22b1dcaf7b | ||
|
|
426a68775f | ||
|
|
3c0be4e40e | ||
|
|
3787b45b49 | ||
|
|
7d06de00fb | ||
|
|
6f8af9d114 | ||
|
|
0a672f092a | ||
|
|
ef62f1db29 | ||
|
|
7f804a0e45 | ||
|
|
b2dff336ce | ||
|
|
a6571e71e4 | ||
|
|
81f711eb00 | ||
|
|
c8a8e06558 | ||
|
|
2c079f53a9 | ||
|
|
322ffe288e | ||
|
|
c340eb0e57 | ||
|
|
4e953291ed | ||
|
|
1dea5fee0e | ||
|
|
9f24b46fee | ||
|
|
0808c41a1c | ||
|
|
296c6df462 | ||
|
|
13ee3e907d | ||
|
|
ce7d794b4c | ||
|
|
fb10069632 | ||
|
|
43a7677644 | ||
|
|
58fa32d7ea | ||
|
|
934d6c3987 | ||
|
|
2d7c6ef21f | ||
|
|
99a97e6a6c | ||
|
|
017a10e8c8 | ||
|
|
41ffa8ba08 | ||
|
|
e029d00cfa | ||
|
|
268534d5e7 | ||
|
|
a7d2bc63f9 | ||
|
|
559115c43c | ||
|
|
1277c7d60c | ||
|
|
9b69c7e972 | ||
|
|
a903f710ea | ||
|
|
b75f4daa47 | ||
|
|
fef44ffa57 | ||
|
|
5a812e3b2f | ||
|
|
910dcf2036 | ||
|
|
44a28aa5bd | ||
|
|
f7f947beb9 | ||
|
|
d03a9e2baf | ||
|
|
ca22316e95 | ||
|
|
ef99c479aa | ||
|
|
fa9260c763 | ||
|
|
fab11c8ffa | ||
|
|
9bd9658a92 | ||
|
|
213880c14d | ||
|
|
0550397046 | ||
|
|
f7a5a506f6 | ||
|
|
0f34c50bd2 | ||
|
|
055826e26f | ||
|
|
a30582c840 | ||
|
|
d106d97b99 |
16
.github/workflows/ci.yml
vendored
16
.github/workflows/ci.yml
vendored
@@ -81,9 +81,23 @@ 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: x86_64-unknown-linux-gnu , os: ubuntu-22.04 }
|
||||
- { 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@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@v1.3.1
|
||||
with:
|
||||
tool-cache: false
|
||||
android: true
|
||||
dotnet: true
|
||||
haskell: true
|
||||
large-packages: false
|
||||
docker-images: true
|
||||
swap-storage: false
|
||||
|
||||
- name: Export GitHub Actions cache environment variables
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
|
||||
49
.github/workflows/flutter-build.yml
vendored
49
.github/workflows/flutter-build.yml
vendored
@@ -39,13 +39,13 @@ env:
|
||||
# 2. Update the `VCPKG_COMMIT_ID` in `ci.yml` and `playground.yml`.
|
||||
VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b"
|
||||
ARMV7_VCPKG_COMMIT_ID: "6f29f12e82a8293156836ad81cc9bf5af41fe836" # 2025.01.13, got "/opt/artifacts/vcpkg/vcpkg: No such file or directory" with latest version
|
||||
VERSION: "1.4.3"
|
||||
VERSION: "1.4.5"
|
||||
NDK_VERSION: "r27c"
|
||||
#signing keys env variable checks
|
||||
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"
|
||||
MACOS_P12_BASE64: "${{ secrets.MACOS_P12_BASE64 }}"
|
||||
UPLOAD_ARTIFACT: "${{ inputs.upload-artifact }}"
|
||||
SIGN_BASE_URL: "${{ secrets.SIGN_BASE_URL }}"
|
||||
SIGN_BASE_URL: "${{ secrets.SIGN_BASE_URL }}-2"
|
||||
|
||||
jobs:
|
||||
generate-bridge:
|
||||
@@ -234,11 +234,11 @@ jobs:
|
||||
path: rustdesk
|
||||
|
||||
- name: Sign rustdesk files
|
||||
if: env.UPLOAD_ARTIFACT == 'true' && env.SIGN_BASE_URL != ''
|
||||
if: env.UPLOAD_ARTIFACT == 'true' && env.SIGN_BASE_URL != '-2'
|
||||
shell: bash
|
||||
run: |
|
||||
pip3 install requests argparse
|
||||
BASE_URL=${{ secrets.SIGN_BASE_URL }} SECRET_KEY=${{ secrets.SIGN_SECRET_KEY }} python3 res/job.py sign_files ./rustdesk/
|
||||
BASE_URL=${{ env.SIGN_BASE_URL }} SECRET_KEY=${{ secrets.SIGN_SECRET_KEY }} python3 res/job.py sign_files ./rustdesk/
|
||||
|
||||
- name: Build self-extracted executable
|
||||
shell: bash
|
||||
@@ -266,10 +266,10 @@ jobs:
|
||||
sha256sum ../../SignOutput/rustdesk-*.msi
|
||||
|
||||
- name: Sign rustdesk self-extracted file
|
||||
if: env.UPLOAD_ARTIFACT == 'true' && env.SIGN_BASE_URL != ''
|
||||
if: env.UPLOAD_ARTIFACT == 'true' && env.SIGN_BASE_URL != '-2'
|
||||
shell: bash
|
||||
run: |
|
||||
BASE_URL=${{ secrets.SIGN_BASE_URL }} SECRET_KEY=${{ secrets.SIGN_SECRET_KEY }} python3 res/job.py sign_files ./SignOutput
|
||||
BASE_URL=${{ env.SIGN_BASE_URL }} SECRET_KEY=${{ secrets.SIGN_SECRET_KEY }} python3 res/job.py sign_files ./SignOutput
|
||||
|
||||
- name: Publish Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
@@ -400,11 +400,11 @@ jobs:
|
||||
path: Release
|
||||
|
||||
- name: Sign rustdesk files
|
||||
if: env.UPLOAD_ARTIFACT == 'true' && env.SIGN_BASE_URL != ''
|
||||
if: env.UPLOAD_ARTIFACT == 'true' && env.SIGN_BASE_URL != '-2'
|
||||
shell: bash
|
||||
run: |
|
||||
pip3 install requests argparse
|
||||
BASE_URL=${{ secrets.SIGN_BASE_URL }} SECRET_KEY=${{ secrets.SIGN_SECRET_KEY }} python3 res/job.py sign_files ./Release/
|
||||
BASE_URL=${{ env.SIGN_BASE_URL }} SECRET_KEY=${{ secrets.SIGN_SECRET_KEY }} python3 res/job.py sign_files ./Release/
|
||||
|
||||
- name: Build self-extracted executable
|
||||
shell: bash
|
||||
@@ -418,10 +418,10 @@ jobs:
|
||||
mv ./target/release/rustdesk-portable-packer.exe ./SignOutput/rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}-sciter.exe
|
||||
|
||||
- name: Sign rustdesk self-extracted file
|
||||
if: env.UPLOAD_ARTIFACT == 'true' && env.SIGN_BASE_URL != ''
|
||||
if: env.UPLOAD_ARTIFACT == 'true' && env.SIGN_BASE_URL != '-2'
|
||||
shell: bash
|
||||
run: |
|
||||
BASE_URL=${{ secrets.SIGN_BASE_URL }} SECRET_KEY=${{ secrets.SIGN_SECRET_KEY }} python3 res/job.py sign_files ./SignOutput/
|
||||
BASE_URL=${{ env.SIGN_BASE_URL }} SECRET_KEY=${{ secrets.SIGN_SECRET_KEY }} python3 res/job.py sign_files ./SignOutput/
|
||||
|
||||
- name: Publish Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
@@ -444,7 +444,7 @@ jobs:
|
||||
- {
|
||||
arch: aarch64,
|
||||
target: aarch64-apple-ios,
|
||||
os: macos-13,
|
||||
os: macos-latest,
|
||||
vcpkg-triplet: arm64-ios,
|
||||
}
|
||||
steps:
|
||||
@@ -562,7 +562,7 @@ jobs:
|
||||
job:
|
||||
- {
|
||||
target: x86_64-apple-darwin,
|
||||
os: macos-13, #macos-latest or macos-14 use M1 now, https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners/about-github-hosted-runners#:~:text=14%20GB-,macos%2Dlatest%20or%20macos%2D14,-The%20macos%2Dlatestlabel
|
||||
os: macos-15-intel, #macos-latest or macos-14 use M1 now, https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners/about-github-hosted-runners#:~:text=14%20GB-,macos%2Dlatest%20or%20macos%2D14,-The%20macos%2Dlatestlabel
|
||||
extra-build-args: "",
|
||||
arch: x86_64,
|
||||
vcpkg-triplet: x64-osx,
|
||||
@@ -623,7 +623,7 @@ jobs:
|
||||
|
||||
- name: Install build runtime
|
||||
run: |
|
||||
brew install llvm create-dmg nasm
|
||||
brew install llvm create-dmg
|
||||
# pkg-config is handled in a separate step, because it may be already installed by `macos-latest`(14.7.1) runner
|
||||
if command -v pkg-config &>/dev/null; then
|
||||
echo "pkg-config is already installed"
|
||||
@@ -631,6 +631,17 @@ jobs:
|
||||
brew install pkg-config
|
||||
fi
|
||||
|
||||
- name: Install NASM
|
||||
run: |
|
||||
# Install NASM 2.16.x from official release.
|
||||
# Do NOT use `brew install nasm` which installs NASM 3.x.
|
||||
# NASM 3.x is a complete rewrite with incompatible CLI options and removed features.
|
||||
# aom and other multimedia libraries require NASM 2.x for x86/x86_64 assembly.
|
||||
wget https://www.nasm.us/pub/nasm/releasebuilds/2.16.03/macosx/nasm-2.16.03-macosx.zip
|
||||
unzip nasm-2.16.03-macosx.zip
|
||||
sudo cp nasm-2.16.03/nasm /usr/local/bin/nasm
|
||||
nasm --version
|
||||
|
||||
- name: Install flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
@@ -1001,6 +1012,8 @@ jobs:
|
||||
JAVA_HOME: /usr/lib/jvm/java-17-openjdk-amd64
|
||||
run: |
|
||||
export PATH=/usr/lib/jvm/java-17-openjdk-amd64/bin:$PATH
|
||||
# Increase Gradle JVM memory for CI builds
|
||||
sed -i "s/org.gradle.jvmargs=-Xmx1024M/org.gradle.jvmargs=-Xmx2g/g" ./flutter/android/gradle.properties
|
||||
# temporary use debug sign config
|
||||
sed -i "s/signingConfigs.release/signingConfigs.debug/g" ./flutter/android/app/build.gradle
|
||||
case ${{ matrix.job.target }} in
|
||||
@@ -1208,6 +1221,8 @@ jobs:
|
||||
JAVA_HOME: /usr/lib/jvm/java-17-openjdk-amd64
|
||||
run: |
|
||||
export PATH=/usr/lib/jvm/java-17-openjdk-amd64/bin:$PATH
|
||||
# Increase Gradle JVM memory for CI builds
|
||||
sed -i "s/org.gradle.jvmargs=-Xmx1024M/org.gradle.jvmargs=-Xmx2g/g" ./flutter/android/gradle.properties
|
||||
# temporary use debug sign config
|
||||
sed -i "s/signingConfigs.release/signingConfigs.debug/g" ./flutter/android/app/build.gradle
|
||||
mv ./flutter/android/app/src/main/jniLibs/arm64-v8a/liblibrustdesk.so ./flutter/android/app/src/main/jniLibs/arm64-v8a/librustdesk.so
|
||||
@@ -1443,7 +1458,8 @@ jobs:
|
||||
rpm \
|
||||
unzip \
|
||||
wget \
|
||||
xz-utils
|
||||
xz-utils \
|
||||
libssl-dev
|
||||
# we have libopus compiled by us.
|
||||
apt-get remove -y libopus-dev || true
|
||||
# output devs
|
||||
@@ -1723,12 +1739,13 @@ jobs:
|
||||
unzip \
|
||||
wget \
|
||||
xz-utils \
|
||||
zip
|
||||
zip \
|
||||
libssl-dev
|
||||
# arm-linux needs CMake and vcokg built from source as there
|
||||
# are no prebuilts available from Kitware and Microsoft
|
||||
if [ "${{ matrix.job.vcpkg-triplet }}" = "arm-linux" ]; then
|
||||
# install gcc/g++ 8 for vcpkg and OpenSSL headers for CMake
|
||||
apt-get install -y gcc-8 g++-8 libssl-dev
|
||||
apt-get install -y gcc-8 g++-8
|
||||
# bootstrap CMake amd add it to PATH
|
||||
git clone --depth 1 https://github.com/kitware/cmake -b "v${{ env.SCITER_ARMV7_CMAKE_VERSION }}" /tmp/cmake
|
||||
pushd /tmp/cmake
|
||||
|
||||
2
.github/workflows/playground.yml
vendored
2
.github/workflows/playground.yml
vendored
@@ -17,7 +17,7 @@ env:
|
||||
TAG_NAME: "nightly"
|
||||
VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite"
|
||||
VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b"
|
||||
VERSION: "1.4.3"
|
||||
VERSION: "1.4.5"
|
||||
NDK_VERSION: "r26d"
|
||||
#signing keys env variable checks
|
||||
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"
|
||||
|
||||
4
.github/workflows/winget.yml
vendored
4
.github/workflows/winget.yml
vendored
@@ -10,6 +10,6 @@ jobs:
|
||||
- uses: vedantmgoyal9/winget-releaser@main
|
||||
with:
|
||||
identifier: RustDesk.RustDesk
|
||||
version: "1.4.3"
|
||||
release-tag: "1.4.3"
|
||||
version: "1.4.5"
|
||||
release-tag: "1.4.5"
|
||||
token: ${{ secrets.WINGET_TOKEN }}
|
||||
|
||||
1469
Cargo.lock
generated
1469
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
33
Cargo.toml
33
Cargo.toml
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rustdesk"
|
||||
version = "1.4.3"
|
||||
version = "1.4.5"
|
||||
authors = ["rustdesk <info@rustdesk.com>"]
|
||||
edition = "2021"
|
||||
build= "build.rs"
|
||||
@@ -76,13 +76,14 @@ crossbeam-queue = "0.3"
|
||||
hex = "0.4"
|
||||
chrono = "0.4"
|
||||
cidr-utils = "0.5"
|
||||
libloading = "0.8"
|
||||
fon = "0.6"
|
||||
zip = "0.6"
|
||||
shutdown_hooks = "0.1"
|
||||
totp-rs = { version = "5.4", default-features = false, features = ["gen_secret", "otpauth"] }
|
||||
stunclient = "0.4"
|
||||
kcp-sys= { git = "https://github.com/rustdesk-org/kcp-sys"}
|
||||
reqwest = { version = "0.12", features = ["blocking", "socks", "json", "native-tls", "rustls-tls", "rustls-tls-native-roots", "gzip"], default-features=false }
|
||||
|
||||
[target.'cfg(not(target_os = "linux"))'.dependencies]
|
||||
# https://github.com/rustdesk/rustdesk/discussions/10197, not use cpal on linux
|
||||
cpal = { git = "https://github.com/rustdesk-org/cpal", branch = "osx-screencapturekit" }
|
||||
@@ -121,10 +122,19 @@ winapi = { version = "0.3", features = [
|
||||
] }
|
||||
windows = { version = "0.61", features = [
|
||||
"Win32",
|
||||
"Win32_Foundation",
|
||||
"Win32_Security",
|
||||
"Win32_Security_Authorization",
|
||||
"Win32_Storage_FileSystem",
|
||||
"Win32_System",
|
||||
"Win32_System_Diagnostics",
|
||||
"Win32_System_Threading",
|
||||
"Win32_System_Diagnostics_ToolHelp",
|
||||
"Win32_System_Environment",
|
||||
"Win32_System_IO",
|
||||
"Win32_System_Memory",
|
||||
"Win32_System_Pipes",
|
||||
"Win32_System_Threading",
|
||||
"Win32_UI_Shell",
|
||||
] }
|
||||
winreg = "0.11"
|
||||
windows-service = "0.6"
|
||||
@@ -165,14 +175,8 @@ fontdb = "0.23"
|
||||
bytemuck = "1.23"
|
||||
ttf-parser = "0.25"
|
||||
|
||||
[target.'cfg(any(target_os = "macos", target_os = "windows"))'.dependencies]
|
||||
# https://github.com/rustdesk/rustdesk-server-pro/issues/189, using native-tls for better tls support
|
||||
reqwest = { git = "https://github.com/rustdesk-org/reqwest", features = ["blocking", "socks", "json", "native-tls", "gzip"], default-features=false }
|
||||
|
||||
[target.'cfg(not(any(target_os = "macos", target_os = "windows")))'.dependencies]
|
||||
reqwest = { git = "https://github.com/rustdesk-org/reqwest", features = ["blocking", "socks", "json", "rustls-tls", "rustls-tls-native-roots", "gzip"], default-features=false }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
libxdo-sys = "0.11"
|
||||
psimple = { package = "libpulse-simple-binding", version = "2.27" }
|
||||
pulse = { package = "libpulse-binding", version = "2.27" }
|
||||
rust-pulsectl = { git = "https://github.com/rustdesk-org/pulsectl" }
|
||||
@@ -181,7 +185,6 @@ evdev = { git="https://github.com/rustdesk-org/evdev" }
|
||||
dbus = "0.9"
|
||||
dbus-crossroads = "0.5"
|
||||
pam = { git="https://github.com/rustdesk-org/pam" }
|
||||
users = { version = "0.11" }
|
||||
x11-clipboard = {git="https://github.com/clslaid/x11-clipboard", branch = "feat/store-batch", optional = true}
|
||||
x11rb = {version = "0.12", features = ["all-extensions"], optional = true}
|
||||
percent-encoding = {version = "2.3", optional = true}
|
||||
@@ -192,6 +195,9 @@ termios = "0.3"
|
||||
terminfo = "0.8"
|
||||
winit = "0.30"
|
||||
|
||||
[target.'cfg(any(target_os = "linux", target_os = "android"))'.dependencies]
|
||||
openssl = { version = "0.10", features = ["vendored"] }
|
||||
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
android_logger = "0.13"
|
||||
jni = "0.21"
|
||||
@@ -201,6 +207,11 @@ android-wakelock = { git = "https://github.com/rustdesk-org/android-wakelock" }
|
||||
members = ["libs/scrap", "libs/hbb_common", "libs/enigo", "libs/clipboard", "libs/virtual_display", "libs/virtual_display/dylib", "libs/portable", "libs/remote_printer"]
|
||||
exclude = ["vdi/host", "examples/custom_plugin"]
|
||||
|
||||
# Patch libxdo-sys to use a stub implementation that doesn't require libxdo
|
||||
# This allows building and running on systems without libxdo installed (e.g., Wayland-only)
|
||||
[patch.crates-io]
|
||||
libxdo-sys = { path = "libs/libxdo-sys-stub" }
|
||||
|
||||
[package.metadata.winres]
|
||||
LegalCopyright = "Copyright © 2025 Purslane Ltd. All rights reserved."
|
||||
ProductName = "RustDesk"
|
||||
|
||||
@@ -18,7 +18,7 @@ AppDir:
|
||||
id: rustdesk
|
||||
name: rustdesk
|
||||
icon: rustdesk
|
||||
version: 1.4.3
|
||||
version: 1.4.5
|
||||
exec: usr/share/rustdesk/rustdesk
|
||||
exec_args: $@
|
||||
apt:
|
||||
|
||||
@@ -18,7 +18,7 @@ AppDir:
|
||||
id: rustdesk
|
||||
name: rustdesk
|
||||
icon: rustdesk
|
||||
version: 1.4.3
|
||||
version: 1.4.5
|
||||
exec: usr/share/rustdesk/rustdesk
|
||||
exec_args: $@
|
||||
apt:
|
||||
|
||||
2
build.py
2
build.py
@@ -299,7 +299,7 @@ Version: %s
|
||||
Architecture: %s
|
||||
Maintainer: rustdesk <info@rustdesk.com>
|
||||
Homepage: https://rustdesk.com
|
||||
Depends: libgtk-3-0, libxcb-randr0, libxdo3, libxfixes3, libxcb-shape0, libxcb-xfixes0, 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.
|
||||
|
||||
|
||||
2
build.rs
2
build.rs
@@ -18,7 +18,7 @@ fn build_mac() {
|
||||
b.flag("-DNO_InputMonitoringAuthStatus=1");
|
||||
}
|
||||
}
|
||||
b.file(file).compile("macos");
|
||||
b.flag("-std=c++17").file(file).compile("macos");
|
||||
println!("cargo:rerun-if-changed={}", file);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
<p align="center">
|
||||
<img src="../res/logo-header.svg" alt="RustDesk - Your remote desktop"><br>
|
||||
<a href="#freie-öffentliche-server">Server</a> •
|
||||
<img src="../res/logo-header.svg" alt="RustDesk - Dein Remote-Desktop"><br>
|
||||
<a href="#grobe-schritte-zum-kompilieren">Kompilieren</a> •
|
||||
<a href="#auf-docker-kompilieren">Docker</a> •
|
||||
<a href="#dateistruktur">Dateistruktur</a> •
|
||||
<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-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>]<br>
|
||||
[<a href="docs/README-UA.md">Українська</a>] | [<a href="docs/README-CS.md">česky</a>] | [<a href="docs/README-ZH.md">中文</a>] | [<a href="docs/README-HU.md">Magyar</a>] | [<a href="docs/README-ES.md">Español</a>] | [<a href="docs/README-FA.md">فارسی</a>] | [<a href="docs/README-FR.md">Français</a>] | [<a href="docs/README-DE.md">Deutsch</a>] | [<a href="docs/README-PL.md">Polski</a>] | [<a href="docs/README-ID.md">Indonesian</a>] | [<a href="docs/README-FI.md">Suomi</a>] | [<a href="docs/README-ML.md">മലയാളം</a>] | [<a href="docs/README-JP.md">日本語</a>] | [<a href="docs/README-NL.md">Nederlands</a>] | [<a href="docs/README-IT.md">Italiano</a>] | [<a href="docs/README-RU.md">Русский</a>] | [<a href="docs/README-PTBR.md">Português (Brasil)</a>] | [<a href="docs/README-EO.md">Esperanto</a>] | [<a href="docs/README-KR.md">한국어</a>] | [<a href="docs/README-AR.md">العربي</a>] | [<a href="docs/README-VN.md">Tiếng Việt</a>] | [<a href="docs/README-DA.md">Dansk</a>] | [<a href="docs/README-GR.md">Ελληνικά</a>] | [<a href="docs/README-TR.md">Türkçe</a>] | [<a href="docs/README-NO.md">Norsk</a>] | [<a href="docs/README-RO.md">Română</a>]<br>
|
||||
<b>Wir brauchen Ihre Hilfe, um dieses README, die <a href="https://github.com/rustdesk/rustdesk/tree/master/src/lang">RustDesk-Benutzeroberfläche</a> und die <a href="https://github.com/rustdesk/doc.rustdesk.com">Dokumentation</a> in Ihre Muttersprache zu übersetzen.</b>
|
||||
</p>
|
||||
|
||||
> [!Vorsicht]
|
||||
> [!Caution]
|
||||
> **Haftungsausschluss bei Missbrauch::** <br>
|
||||
> Die Entwickler von RustDesk billigen oder unterstützen keine unethische oder illegale Nutzung dieser Software. Missbrauch, wie unbefugter Zugriff, unbefugte Kontrolle oder Verletzung der Privatsphäre, verstößt strikt gegen unsere Richtlinien. Die Autoren sind nicht verantwortlich für jeglichen Missbrauch der Anwendung.
|
||||
|
||||
@@ -28,11 +27,14 @@ RustDesk heißt jegliche Mitarbeit willkommen. Schauen Sie sich [CONTRIBUTING-DE
|
||||
|
||||
[**Programm herunterladen**](https://github.com/rustdesk/rustdesk/releases)
|
||||
|
||||
[**Nächtliche Erstellung**](https://github.com/rustdesk/rustdesk/releases/tag/nightly)
|
||||
[**Nightly Builds**](https://github.com/rustdesk/rustdesk/releases/tag/nightly)
|
||||
|
||||
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png"
|
||||
[<img src="https://f-droid.org/badge/get-it-on.png"
|
||||
alt="Get it on 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="Get it on Flathub"
|
||||
height="80">](https://flathub.org/apps/com.rustdesk.RustDesk)
|
||||
|
||||
## Abhängigkeiten
|
||||
|
||||
@@ -64,18 +66,19 @@ Bitte laden Sie die dynamische Bibliothek Sciter selbst herunter.
|
||||
```sh
|
||||
sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \
|
||||
libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \
|
||||
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
|
||||
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev
|
||||
```
|
||||
|
||||
### openSUSE Tumbleweed
|
||||
|
||||
```sh
|
||||
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel
|
||||
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel
|
||||
```
|
||||
|
||||
### Fedora 28 (CentOS 8)
|
||||
|
||||
```sh
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel
|
||||
```
|
||||
|
||||
### Arch (Manjaro)
|
||||
@@ -114,7 +117,7 @@ cd
|
||||
```sh
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
source $HOME/.cargo/env
|
||||
git clone https://github.com/rustdesk/rustdesk
|
||||
git clone --recurse-submodules 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
|
||||
@@ -129,6 +132,7 @@ Beginnen Sie damit, das Repository zu klonen und den Docker-Container zu bauen:
|
||||
```sh
|
||||
git clone https://github.com/rustdesk/rustdesk
|
||||
cd rustdesk
|
||||
git submodule update --init --recursive
|
||||
docker build -t "rustdesk-builder" .
|
||||
```
|
||||
|
||||
@@ -157,6 +161,7 @@ Bitte stellen Sie sicher, dass Sie diese Befehle im Stammverzeichnis des RustDes
|
||||
- **[libs/hbb_common](https://github.com/rustdesk/rustdesk/tree/master/libs/hbb_common)**: Video-Codec, Konfiguration, TCP/UDP-Wrapper, Protokoll-Puffer, fs-Funktionen für Dateitransfer und ein paar andere nützliche Funktionen
|
||||
- **[libs/scrap](https://github.com/rustdesk/rustdesk/tree/master/libs/scrap)**: Bildschirmaufnahme
|
||||
- **[libs/enigo](https://github.com/rustdesk/rustdesk/tree/master/libs/enigo)**: Plattformspezifische Maus- und Tastatursteuerung
|
||||
- **[libs/clipboard](https://github.com/rustdesk/rustdesk/tree/master/libs/clipboard)**: Datei kopieren und einfügen Implementierung für Windows, Linux, macOS.
|
||||
- **[src/ui](https://github.com/rustdesk/rustdesk/tree/master/src/ui)**: GUI
|
||||
- **[src/server](https://github.com/rustdesk/rustdesk/tree/master/src/server)**: Audio/Zwischenablage/Eingabe/Videodienste und Netzwerkverbindungen
|
||||
- **[src/client.rs](https://github.com/rustdesk/rustdesk/tree/master/src/client.rs)**: Starten einer Peer-Verbindung
|
||||
@@ -167,10 +172,11 @@ Bitte stellen Sie sicher, dass Sie diese Befehle im Stammverzeichnis des RustDes
|
||||
|
||||
## Screenshots
|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
@@ -13,7 +13,9 @@ Porozmawiaj z nami na: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](http
|
||||
|
||||
[](https://rustdesk.com/pricing.html)
|
||||
|
||||
Kolejny program do zdalnego pulpitu, napisany w Rust. Działa od samego początku, nie wymaga konfiguracji. Masz pełną kontrolę nad swoimi danymi, bez obaw o bezpieczeństwo. Możesz skorzystać z naszego darmowego serwera publicznego, [skonfigurować własny](https://rustdesk.com/server), lub [napisać własny serwer](https://github.com/rustdesk/rustdesk-server-demo).
|
||||
## O projekcie
|
||||
|
||||
RustDesk to wieloplatformowe oprogramowanie do zdalnego pulpitu, napisane w języku Rust, zaprojektowane z myślą o prostocie wdrożenia, bezpieczeństwie i pełnej kontroli użytkownika nad danymi. Aplikacja działa od razu po uruchomieniu i nie wymaga skomplikowanej konfiguracji. Możesz skorzystać z naszego darmowego serwera publicznego, [skonfigurować własny](https://rustdesk.com/server), lub [napisać własny serwer](https://github.com/rustdesk/rustdesk-server-demo).
|
||||
|
||||

|
||||
|
||||
@@ -31,7 +33,7 @@ RustDesk zaprasza do współpracy każdego. Zobacz [`docs/CONTRIBUTING-PL.md`](C
|
||||
|
||||
## Zależności
|
||||
|
||||
Wersje desktopowe używają [sciter](https://sciter.com/) dla GUI, proszę pobrać samodzielnie bibliotekę sciter.
|
||||
Wersje desktopowe korzystają z biblioteki [sciter](https://sciter.com/) jako silnika GUI. Bibliotekę Sciter należy pobrać i zainstalować samodzielnie.
|
||||
|
||||
[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) |
|
||||
|
||||
@@ -55,8 +55,8 @@
|
||||
],
|
||||
"finish-args": [
|
||||
"--share=ipc",
|
||||
"--socket=fallback-x11",
|
||||
"--socket=wayland",
|
||||
"--socket=x11",
|
||||
"--share=network",
|
||||
"--filesystem=home",
|
||||
"--device=dri",
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import com.google.protobuf.gradle.*
|
||||
import groovy.json.JsonSlurper
|
||||
|
||||
plugins {
|
||||
id "com.google.protobuf" version "0.9.4"
|
||||
id "com.android.application"
|
||||
@@ -30,8 +32,37 @@ if (flutterVersionName == null) {
|
||||
flutterVersionName = '1.0'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'com.google.protobuf:protobuf-javalite:3.20.1'
|
||||
// Add rustls-platform-verifier Android support
|
||||
String findRustlsPlatformVerifierMavenDir() {
|
||||
def dependencyText = providers.exec {
|
||||
it.workingDir = new File("../..")
|
||||
commandLine("cargo", "metadata", "--format-version", "1")
|
||||
}.standardOutput.asText.get()
|
||||
|
||||
def dependencyJson = new JsonSlurper().parseText(dependencyText)
|
||||
def pkg = dependencyJson.packages.find { it.name == "rustls-platform-verifier-android" }
|
||||
|
||||
if (pkg == null) {
|
||||
throw new GradleException("rustls-platform-verifier-android package not found in cargo metadata!")
|
||||
}
|
||||
|
||||
def manifestPath = file(pkg.manifest_path)
|
||||
def mavenDir = new File(manifestPath.parentFile, "maven")
|
||||
|
||||
if (!mavenDir.exists()) {
|
||||
throw new GradleException("Maven directory not found at: ${mavenDir.path}")
|
||||
}
|
||||
|
||||
println("✓ Found rustls-platform-verifier maven repo at: ${mavenDir.path}")
|
||||
return mavenDir.path
|
||||
}
|
||||
|
||||
|
||||
repositories {
|
||||
maven {
|
||||
url = findRustlsPlatformVerifierMavenDir()
|
||||
metadataSources.artifact()
|
||||
}
|
||||
}
|
||||
|
||||
protobuf {
|
||||
@@ -67,7 +98,7 @@ android {
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId "com.carriez.flutter_hbb"
|
||||
minSdkVersion 21
|
||||
minSdkVersion 22
|
||||
targetSdkVersion 33
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
@@ -97,8 +128,10 @@ flutter {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'com.google.protobuf:protobuf-javalite:3.20.1'
|
||||
implementation "androidx.media:media:1.6.0"
|
||||
implementation 'com.github.getActivity:XXPermissions:18.5'
|
||||
implementation("org.jetbrains.kotlin:kotlin-stdlib") { version { strictly("1.9.10") } }
|
||||
implementation 'com.caverock:androidsvg-aar:1.4'
|
||||
implementation "rustls:rustls-platform-verifier:0.1.1"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
# Keep class members from protobuf generated code.
|
||||
-keepclassmembers class * extends com.google.protobuf.GeneratedMessageLite {
|
||||
<fields>;
|
||||
}
|
||||
}
|
||||
|
||||
# Keep rustls-platform-verifier classes for JNI
|
||||
-keep, includedescriptorclasses class org.rustls.platformverifier.** { *; }
|
||||
@@ -23,6 +23,7 @@
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:name=".MainApplication"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="RustDesk"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.carriez.flutter_hbb
|
||||
|
||||
import android.app.Application
|
||||
import android.util.Log
|
||||
import ffi.FFI
|
||||
|
||||
class MainApplication : Application() {
|
||||
companion object {
|
||||
private const val TAG = "MainApplication"
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Log.d(TAG, "App start")
|
||||
FFI.onAppStart(applicationContext)
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ object FFI {
|
||||
}
|
||||
|
||||
external fun init(ctx: Context)
|
||||
external fun onAppStart(ctx: Context)
|
||||
external fun setClipboardManager(clipboardManager: RdClipboardManager)
|
||||
external fun startServer(app_dir: String, custom_client_config: String)
|
||||
external fun startService()
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" style="isolation:isolate" viewBox="541.937 521.772 32 32"><path fill="none" d="M541.937 521.772h32v32h-32v-32Z"/><path fill-rule="evenodd" d="M552.145 539.981h11.584c.446 0 .808.362.808.808v.536c0 .786-.639 1.425-1.425 1.425h-10.35a1.426 1.426 0 0 1-1.425-1.425v-.536c0-.446.362-.808.808-.808Zm-1.761-3.511h.899c.536 0 .971.435.971.971v.899a.971.971 0 0 1-.971.971h-.899a.971.971 0 0 1-.971-.971v-.899c0-.536.435-.971.971-.971Zm3.552 0h.899c.536 0 .971.435.971.971v.899a.971.971 0 0 1-.971.971h-.899a.972.972 0 0 1-.972-.971v-.899c0-.536.436-.971.972-.971Zm3.551 0h.9c.536 0 .971.435.971.971v.899a.971.971 0 0 1-.971.971h-.9a.971.971 0 0 1-.971-.971v-.899c0-.536.435-.971.971-.971Zm3.552 0h.899c.536 0 .972.435.972.971v.899a.972.972 0 0 1-.972.971h-.899a.971.971 0 0 1-.971-.971v-.899c0-.536.435-.971.971-.971Zm3.552 0h.899c.536 0 .971.435.971.971v.899a.971.971 0 0 1-.971.971h-.899a.971.971 0 0 1-.971-.971v-.899c0-.536.435-.971.971-.971Zm-14.383-3.512h1.25c.44 0 .796.357.796.796v1.25a.796.796 0 0 1-.796.796h-1.25a.796.796 0 0 1-.795-.796v-1.25c0-.439.356-.796.795-.796Zm3.552 0h1.25c.439 0 .796.357.796.796v1.25a.797.797 0 0 1-.796.796h-1.25a.797.797 0 0 1-.796-.796v-1.25c0-.439.357-.796.796-.796Zm3.552 0h1.25c.439 0 .796.357.796.796v1.25a.797.797 0 0 1-.796.796h-1.25a.797.797 0 0 1-.796-.796v-1.25c0-.439.357-.796.796-.796Zm3.552 0h1.25c.439 0 .796.357.796.796v1.25a.797.797 0 0 1-.796.796h-1.25a.797.797 0 0 1-.796-.796v-1.25c0-.439.357-.796.796-.796Zm-9.553-3.85h13.252c1.407 0 2.755.507 3.748 1.409.993.902 1.552 2.127 1.552 3.404v7.702c0 1.277-.559 2.501-1.552 3.403-.993.902-2.341 1.409-3.748 1.409h-13.252c-1.407 0-2.755-.507-3.748-1.409-.993-.902-1.552-2.126-1.552-3.403v-7.702c0-1.277.559-2.502 1.552-3.404.993-.902 2.341-1.409 3.748-1.409Zm13.105 3.85h1.25c.439 0 .795.357.795.796v1.25a.796.796 0 0 1-.795.796h-1.25a.796.796 0 0 1-.796-.796v-1.25c0-.439.356-.796.796-.796Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.9 KiB |
1
flutter/assets/keyboard_mouse.svg
Normal file
1
flutter/assets/keyboard_mouse.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 5.4 KiB |
@@ -24,6 +24,7 @@ import 'package:provider/provider.dart';
|
||||
import 'package:uni_links/uni_links.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
import 'package:window_size/window_size.dart' as window_size;
|
||||
|
||||
@@ -44,7 +45,7 @@ import 'package:flutter_hbb/native/win32.dart'
|
||||
if (dart.library.html) 'package:flutter_hbb/web/win32.dart';
|
||||
import 'package:flutter_hbb/native/common.dart'
|
||||
if (dart.library.html) 'package:flutter_hbb/web/common.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:flutter_hbb/utils/http_service.dart' as http;
|
||||
|
||||
final globalKey = GlobalKey<NavigatorState>();
|
||||
final navigationBarKey = GlobalKey();
|
||||
@@ -1010,13 +1011,15 @@ makeMobileActionsOverlayEntry(VoidCallback? onHide, {FFI? ffi}) {
|
||||
});
|
||||
}
|
||||
|
||||
void showToast(String text, {Duration timeout = const Duration(seconds: 3)}) {
|
||||
void showToast(String text,
|
||||
{Duration timeout = const Duration(seconds: 3),
|
||||
Alignment alignment = const Alignment(0.0, 0.8)}) {
|
||||
final overlayState = globalKey.currentState?.overlay;
|
||||
if (overlayState == null) return;
|
||||
final entry = OverlayEntry(builder: (context) {
|
||||
return IgnorePointer(
|
||||
child: Align(
|
||||
alignment: const Alignment(0.0, 0.8),
|
||||
alignment: alignment,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: MyTheme.color(context).toastBg,
|
||||
@@ -1121,18 +1124,23 @@ class CustomAlertDialog extends StatelessWidget {
|
||||
|
||||
Widget createDialogContent(String text) {
|
||||
final RegExp linkRegExp = RegExp(r'(https?://[^\s]+)');
|
||||
bool hasLink = linkRegExp.hasMatch(text);
|
||||
|
||||
// Early return: no link, use default theme color
|
||||
if (!hasLink) {
|
||||
return SelectableText(text, style: const TextStyle(fontSize: 15));
|
||||
}
|
||||
|
||||
final List<TextSpan> spans = [];
|
||||
int start = 0;
|
||||
bool hasLink = false;
|
||||
|
||||
linkRegExp.allMatches(text).forEach((match) {
|
||||
hasLink = true;
|
||||
if (match.start > start) {
|
||||
spans.add(TextSpan(text: text.substring(start, match.start)));
|
||||
}
|
||||
spans.add(TextSpan(
|
||||
text: match.group(0) ?? '',
|
||||
style: TextStyle(
|
||||
style: const TextStyle(
|
||||
color: Colors.blue,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
@@ -1150,13 +1158,9 @@ Widget createDialogContent(String text) {
|
||||
spans.add(TextSpan(text: text.substring(start)));
|
||||
}
|
||||
|
||||
if (!hasLink) {
|
||||
return SelectableText(text, style: const TextStyle(fontSize: 15));
|
||||
}
|
||||
|
||||
return SelectableText.rich(
|
||||
TextSpan(
|
||||
style: TextStyle(color: Colors.black, fontSize: 15),
|
||||
style: const TextStyle(fontSize: 15),
|
||||
children: spans,
|
||||
),
|
||||
);
|
||||
@@ -1575,7 +1579,7 @@ bool option2bool(String option, String value) {
|
||||
option == kOptionForceAlwaysRelay) {
|
||||
res = value == "Y";
|
||||
} else {
|
||||
assert(false);
|
||||
// "" is true
|
||||
res = value != "N";
|
||||
}
|
||||
return res;
|
||||
@@ -1593,9 +1597,6 @@ String bool2option(String option, bool b) {
|
||||
option == kOptionForceAlwaysRelay) {
|
||||
res = b ? 'Y' : defaultOptionNo;
|
||||
} else {
|
||||
if (option != kOptionEnableUdpPunch && option != kOptionEnableIpv6Punch) {
|
||||
assert(false);
|
||||
}
|
||||
res = b ? 'Y' : 'N';
|
||||
}
|
||||
return res;
|
||||
@@ -1681,13 +1682,12 @@ class LastWindowPosition {
|
||||
this.offsetHeight, this.isMaximized, this.isFullscreen);
|
||||
|
||||
bool equals(LastWindowPosition other) {
|
||||
return (
|
||||
(width == other.width) &&
|
||||
(height == other.height) &&
|
||||
(offsetWidth == other.offsetWidth) &&
|
||||
(offsetHeight == other.offsetHeight) &&
|
||||
(isMaximized == other.isMaximized) &&
|
||||
(isFullscreen == other.isFullscreen));
|
||||
return ((width == other.width) &&
|
||||
(height == other.height) &&
|
||||
(offsetWidth == other.offsetWidth) &&
|
||||
(offsetHeight == other.offsetHeight) &&
|
||||
(isMaximized == other.isMaximized) &&
|
||||
(isFullscreen == other.isFullscreen));
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
@@ -1815,7 +1815,8 @@ Future<void> saveWindowPosition(WindowType type,
|
||||
|
||||
final WindowKey key = (type: type, windowId: windowId);
|
||||
|
||||
final bool haveNewWindowPosition = (_lastWindowPosition == null) || !pos.equals(_lastWindowPosition!);
|
||||
final bool haveNewWindowPosition =
|
||||
(_lastWindowPosition == null) || !pos.equals(_lastWindowPosition!);
|
||||
final bool isPreviousNewWindowPositionPending = _saveWindowDebounce.isRunning;
|
||||
|
||||
if (haveNewWindowPosition || isPreviousNewWindowPositionPending) {
|
||||
@@ -1841,10 +1842,11 @@ Future<void> _saveWindowPositionActual(WindowKey key) async {
|
||||
await bind.setLocalFlutterOption(
|
||||
k: windowFramePrefix + key.type.name, v: pos.toString());
|
||||
|
||||
if ((key.type == WindowType.RemoteDesktop || key.type == WindowType.ViewCamera) &&
|
||||
if ((key.type == WindowType.RemoteDesktop ||
|
||||
key.type == WindowType.ViewCamera) &&
|
||||
key.windowId != null) {
|
||||
await _saveSessionWindowPosition(
|
||||
key.type, key.windowId!, pos.isMaximized ?? false, pos.isFullscreen ?? false, pos);
|
||||
await _saveSessionWindowPosition(key.type, key.windowId!,
|
||||
pos.isMaximized ?? false, pos.isFullscreen ?? false, pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1931,44 +1933,41 @@ Future<Offset?> _adjustRestoreMainWindowOffset(
|
||||
return null;
|
||||
}
|
||||
|
||||
double? frameLeft;
|
||||
double? frameTop;
|
||||
double? frameRight;
|
||||
double? frameBottom;
|
||||
|
||||
if (isDesktop || isWebDesktop) {
|
||||
for (final screen in await window_size.getScreenList()) {
|
||||
frameLeft = frameLeft == null
|
||||
? screen.visibleFrame.left
|
||||
: min(screen.visibleFrame.left, frameLeft);
|
||||
frameTop = frameTop == null
|
||||
? screen.visibleFrame.top
|
||||
: min(screen.visibleFrame.top, frameTop);
|
||||
frameRight = frameRight == null
|
||||
? screen.visibleFrame.right
|
||||
: max(screen.visibleFrame.right, frameRight);
|
||||
frameBottom = frameBottom == null
|
||||
? screen.visibleFrame.bottom
|
||||
: max(screen.visibleFrame.bottom, frameBottom);
|
||||
final screens = await window_size.getScreenList();
|
||||
if (screens.isNotEmpty) {
|
||||
final windowRect = Rect.fromLTWH(left, top, width, height);
|
||||
bool isVisible = false;
|
||||
for (final screen in screens) {
|
||||
final intersection = windowRect.intersect(screen.visibleFrame);
|
||||
if (intersection.width >= 10.0 && intersection.height >= 10.0) {
|
||||
isVisible = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
return Offset(left, top);
|
||||
}
|
||||
}
|
||||
if (frameLeft == null) {
|
||||
frameLeft = 0.0;
|
||||
frameTop = 0.0;
|
||||
frameRight = ((isDesktop || isWebDesktop)
|
||||
? kDesktopMaxDisplaySize
|
||||
: kMobileMaxDisplaySize)
|
||||
.toDouble();
|
||||
frameBottom = ((isDesktop || isWebDesktop)
|
||||
? kDesktopMaxDisplaySize
|
||||
: kMobileMaxDisplaySize)
|
||||
.toDouble();
|
||||
}
|
||||
|
||||
double frameLeft = 0.0;
|
||||
double frameTop = 0.0;
|
||||
double frameRight = ((isDesktop || isWebDesktop)
|
||||
? kDesktopMaxDisplaySize
|
||||
: kMobileMaxDisplaySize)
|
||||
.toDouble();
|
||||
double frameBottom = ((isDesktop || isWebDesktop)
|
||||
? kDesktopMaxDisplaySize
|
||||
: kMobileMaxDisplaySize)
|
||||
.toDouble();
|
||||
|
||||
final minWidth = 10.0;
|
||||
if ((left + minWidth) > frameRight! ||
|
||||
(top + minWidth) > frameBottom! ||
|
||||
if ((left + minWidth) > frameRight ||
|
||||
(top + minWidth) > frameBottom ||
|
||||
(left + width - minWidth) < frameLeft ||
|
||||
top < frameTop!) {
|
||||
top < frameTop) {
|
||||
return null;
|
||||
} else {
|
||||
return Offset(left, top);
|
||||
@@ -2675,6 +2674,55 @@ class SimpleWrapper<T> {
|
||||
SimpleWrapper(this.value);
|
||||
}
|
||||
|
||||
/// Wakelock manager with reference counting for desktop.
|
||||
/// Ensures wakelock is only disabled when all sessions are closed/minimized.
|
||||
///
|
||||
/// Note: Each isolate has its own WakelockPlus instance with independent assertion.
|
||||
/// As long as one isolate has wakelock enabled, the screen stays awake.
|
||||
/// This manager handles multiple tabs within the same isolate.
|
||||
class WakelockManager {
|
||||
static final Set<UniqueKey> _enabledKeys = {};
|
||||
// Don't use WakelockPlus.enabled, it causes error on Android:
|
||||
// Unhandled Exception: FormatException: Message corrupted
|
||||
//
|
||||
// On Linux, multiple enable() calls create only one inhibit, but each disable()
|
||||
// only releases if _cookie != null. So we need our own _enabled state to avoid
|
||||
// calling disable() when not enabled.
|
||||
// See: https://github.com/fluttercommunity/wakelock_plus/blob/0c74e5bbc6aefac57b6c96bb7ef987705ed559ec/wakelock_plus/lib/src/wakelock_plus_linux_plugin.dart#L48
|
||||
static bool _enabled = false;
|
||||
|
||||
static void enable(UniqueKey key, {bool isServer = false}) {
|
||||
// Check if we should keep awake during outgoing sessions
|
||||
if (!isServer) {
|
||||
final keepAwake =
|
||||
mainGetLocalBoolOptionSync(kOptionKeepAwakeDuringOutgoingSessions);
|
||||
if (!keepAwake) {
|
||||
return; // Don't enable wakelock if user disabled keep awake
|
||||
}
|
||||
}
|
||||
if (isDesktop) {
|
||||
_enabledKeys.add(key);
|
||||
}
|
||||
if (!_enabled) {
|
||||
_enabled = true;
|
||||
WakelockPlus.enable();
|
||||
}
|
||||
}
|
||||
|
||||
static void disable(UniqueKey key) {
|
||||
if (isDesktop) {
|
||||
_enabledKeys.remove(key);
|
||||
if (_enabledKeys.isNotEmpty) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (_enabled) {
|
||||
WakelockPlus.disable();
|
||||
_enabled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// call this to reload current window.
|
||||
///
|
||||
/// [Note]
|
||||
@@ -2948,7 +2996,7 @@ Future<void> updateSystemWindowTheme() async {
|
||||
///
|
||||
/// Note: not found a general solution for rust based AVFoundation bingding.
|
||||
/// [AVFoundation] crate has compile error.
|
||||
const kMacOSPermChannel = MethodChannel("org.rustdesk.rustdesk/macos");
|
||||
const kMacOSPermChannel = MethodChannel("org.rustdesk.rustdesk/host");
|
||||
|
||||
enum PermissionAuthorizeType {
|
||||
undetermined,
|
||||
@@ -3015,10 +3063,21 @@ Future<void> start_service(bool is_start) async {
|
||||
}
|
||||
|
||||
Future<bool> canBeBlocked() async {
|
||||
var access_mode = await bind.mainGetOption(key: kOptionAccessMode);
|
||||
// First check control permission
|
||||
final controlPermission = await bind.mainGetCommon(
|
||||
key: "is-remote-modify-enabled-by-control-permissions");
|
||||
if (controlPermission == "true") {
|
||||
return false;
|
||||
} else if (controlPermission == "false") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check local settings
|
||||
var accessMode = await bind.mainGetOption(key: kOptionAccessMode);
|
||||
var isCustomAccessMode = accessMode != 'full' && accessMode != 'view';
|
||||
var option = option2bool(kOptionAllowRemoteConfigModification,
|
||||
await bind.mainGetOption(key: kOptionAllowRemoteConfigModification));
|
||||
return access_mode == 'view' || (access_mode.isEmpty && !option);
|
||||
return accessMode == 'view' || (isCustomAccessMode && !option);
|
||||
}
|
||||
|
||||
// to-do: web not implemented
|
||||
@@ -3781,6 +3840,16 @@ setResizable(bool resizable) {
|
||||
|
||||
isOptionFixed(String key) => bind.mainIsOptionFixed(key: key);
|
||||
|
||||
bool isChangePermanentPasswordDisabled() =>
|
||||
bind.mainGetBuildinOption(key: kOptionDisableChangePermanentPassword) ==
|
||||
'Y';
|
||||
|
||||
bool isChangeIdDisabled() =>
|
||||
bind.mainGetBuildinOption(key: kOptionDisableChangeId) == 'Y';
|
||||
|
||||
bool isUnlockPinDisabled() =>
|
||||
bind.mainGetBuildinOption(key: kOptionDisableUnlockPin) == 'Y';
|
||||
|
||||
bool? _isCustomClient;
|
||||
bool get isCustomClient {
|
||||
_isCustomClient ??= bind.isCustomClient();
|
||||
@@ -4024,3 +4093,23 @@ String decode_http_response(http.Response resp) {
|
||||
bool peerTabShowNote(PeerTabIndex peerTabIndex) {
|
||||
return peerTabIndex == PeerTabIndex.ab || peerTabIndex == PeerTabIndex.group;
|
||||
}
|
||||
|
||||
// TODO: We should support individual bits combinations in the future.
|
||||
// But for now, just keep it simple, because the old code only supports single button.
|
||||
// No users have requested multi-button support yet.
|
||||
String mouseButtonsToPeer(int buttons) {
|
||||
switch (buttons) {
|
||||
case kPrimaryMouseButton:
|
||||
return 'left';
|
||||
case kSecondaryMouseButton:
|
||||
return 'right';
|
||||
case kMiddleMouseButton:
|
||||
return 'wheel';
|
||||
case kBackMouseButton:
|
||||
return 'back';
|
||||
case kForwardMouseButton:
|
||||
return 'forward';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
156
flutter/lib/common/widgets/custom_scale_base.dart
Normal file
156
flutter/lib/common/widgets/custom_scale_base.dart
Normal file
@@ -0,0 +1,156 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:debounce_throttle/debounce_throttle.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/models/model.dart';
|
||||
import 'package:flutter_hbb/models/platform_model.dart';
|
||||
import 'package:flutter_hbb/utils/scale.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
|
||||
/// Base class providing shared custom scale control logic for both mobile and desktop widgets.
|
||||
/// Implementations must provide [ffi] and [onScaleChanged] getters.
|
||||
abstract class CustomScaleControls<T extends StatefulWidget> extends State<T> {
|
||||
/// FFI instance for session interaction
|
||||
FFI get ffi;
|
||||
|
||||
/// Callback invoked when scale value changes
|
||||
ValueChanged<int>? get onScaleChanged;
|
||||
|
||||
late int _scaleValue;
|
||||
late final Debouncer<int> _debouncerScale;
|
||||
// Normalized slider position in [0, 1]. We map it nonlinearly to percent.
|
||||
double _scalePos = 0.0;
|
||||
|
||||
int get scaleValue => _scaleValue;
|
||||
double get scalePos => _scalePos;
|
||||
|
||||
int mapPosToPercent(double p) => _mapPosToPercent(p);
|
||||
|
||||
static const int minPercent = kScaleCustomMinPercent;
|
||||
static const int pivotPercent = kScaleCustomPivotPercent; // 100% should be at 1/3 of track
|
||||
static const int maxPercent = kScaleCustomMaxPercent;
|
||||
static const double pivotPos = kScaleCustomPivotPos; // first 1/3 → up to 100%
|
||||
static const double detentEpsilon = kScaleCustomDetentEpsilon; // snap range around pivot (~0.6%)
|
||||
|
||||
// Clamp helper for local use
|
||||
int _clampScale(int v) => clampCustomScalePercent(v);
|
||||
|
||||
// Map normalized position [0,1] → percent [5,1000] with 100 at 1/3 width.
|
||||
int _mapPosToPercent(double p) {
|
||||
if (p <= 0.0) return minPercent;
|
||||
if (p >= 1.0) return maxPercent;
|
||||
if (p <= pivotPos) {
|
||||
final q = p / pivotPos; // 0..1
|
||||
final v = minPercent + q * (pivotPercent - minPercent);
|
||||
return _clampScale(v.round());
|
||||
} else {
|
||||
final q = (p - pivotPos) / (1.0 - pivotPos); // 0..1
|
||||
final v = pivotPercent + q * (maxPercent - pivotPercent);
|
||||
return _clampScale(v.round());
|
||||
}
|
||||
}
|
||||
|
||||
// Map percent [5,1000] → normalized position [0,1]
|
||||
double _mapPercentToPos(int percent) {
|
||||
final p = _clampScale(percent);
|
||||
if (p <= pivotPercent) {
|
||||
final q = (p - minPercent) / (pivotPercent - minPercent);
|
||||
return q * pivotPos;
|
||||
} else {
|
||||
final q = (p - pivotPercent) / (maxPercent - pivotPercent);
|
||||
return pivotPos + q * (1.0 - pivotPos);
|
||||
}
|
||||
}
|
||||
|
||||
// Snap normalized position to the pivot when close to it
|
||||
double _snapNormalizedPos(double p) {
|
||||
if ((p - pivotPos).abs() <= detentEpsilon) return pivotPos;
|
||||
if (p < 0.0) return 0.0;
|
||||
if (p > 1.0) return 1.0;
|
||||
return p;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scaleValue = 100;
|
||||
_debouncerScale = Debouncer<int>(
|
||||
kDebounceCustomScaleDuration,
|
||||
onChanged: (v) async {
|
||||
await _applyScale(v);
|
||||
},
|
||||
initialValue: _scaleValue,
|
||||
);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
try {
|
||||
final v = await getSessionCustomScalePercent(ffi.sessionId);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_scaleValue = v;
|
||||
_scalePos = _mapPercentToPos(v);
|
||||
});
|
||||
}
|
||||
} catch (e, st) {
|
||||
debugPrint('[CustomScale] Failed to get initial value: $e');
|
||||
debugPrintStack(stackTrace: st);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _applyScale(int v) async {
|
||||
v = clampCustomScalePercent(v);
|
||||
setState(() {
|
||||
_scaleValue = v;
|
||||
});
|
||||
try {
|
||||
await bind.sessionSetFlutterOption(
|
||||
sessionId: ffi.sessionId,
|
||||
k: kCustomScalePercentKey,
|
||||
v: v.toString());
|
||||
final curStyle = await bind.sessionGetViewStyle(sessionId: ffi.sessionId);
|
||||
if (curStyle != kRemoteViewStyleCustom) {
|
||||
await bind.sessionSetViewStyle(
|
||||
sessionId: ffi.sessionId, value: kRemoteViewStyleCustom);
|
||||
}
|
||||
await ffi.canvasModel.updateViewStyle();
|
||||
if (isMobile) {
|
||||
HapticFeedback.selectionClick();
|
||||
}
|
||||
onScaleChanged?.call(v);
|
||||
} catch (e, st) {
|
||||
debugPrint('[CustomScale] Apply failed: $e');
|
||||
debugPrintStack(stackTrace: st);
|
||||
}
|
||||
}
|
||||
|
||||
void nudgeScale(int delta) {
|
||||
final next = _clampScale(_scaleValue + delta);
|
||||
setState(() {
|
||||
_scaleValue = next;
|
||||
_scalePos = _mapPercentToPos(next);
|
||||
});
|
||||
onScaleChanged?.call(next);
|
||||
_debouncerScale.value = next;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debouncerScale.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void onSliderChanged(double v) {
|
||||
final snapped = _snapNormalizedPos(v);
|
||||
final next = _mapPosToPercent(snapped);
|
||||
if (next != _scaleValue || snapped != _scalePos) {
|
||||
setState(() {
|
||||
_scalePos = snapped;
|
||||
_scaleValue = next;
|
||||
});
|
||||
onScaleChanged?.call(next);
|
||||
_debouncerScale.value = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,20 +7,29 @@ import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hbb/common/shared_state.dart';
|
||||
import 'package:flutter_hbb/common/widgets/setting_widgets.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
|
||||
import 'package:flutter_hbb/models/peer_model.dart';
|
||||
import 'package:flutter_hbb/models/peer_tab_model.dart';
|
||||
import 'package:flutter_hbb/models/state_model.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
import 'package:flutter_hbb/utils/http_service.dart' as http;
|
||||
|
||||
import '../../common.dart';
|
||||
import '../../models/model.dart';
|
||||
import '../../models/platform_model.dart';
|
||||
import 'address_book.dart';
|
||||
|
||||
void clientClose(SessionID sessionId, OverlayDialogManager dialogManager) {
|
||||
msgBox(sessionId, 'info', 'Close', 'Are you sure to close the connection?',
|
||||
'', dialogManager);
|
||||
void clientClose(SessionID sessionId, FFI ffi) async {
|
||||
if (allowAskForNoteAtEndOfConnection(ffi, true)) {
|
||||
if (await showConnEndAuditDialogCloseCanceled(ffi: ffi)) {
|
||||
return;
|
||||
}
|
||||
closeConnection();
|
||||
} else {
|
||||
msgBox(sessionId, 'info', 'Close', 'Are you sure to close the connection?',
|
||||
'', ffi.dialogManager);
|
||||
}
|
||||
}
|
||||
|
||||
abstract class ValidationRule {
|
||||
@@ -1509,56 +1518,71 @@ showSetOSAccount(
|
||||
});
|
||||
}
|
||||
|
||||
Widget buildNoteTextField({
|
||||
required TextEditingController controller,
|
||||
required VoidCallback onEscape,
|
||||
}) {
|
||||
final focusNode = FocusNode(
|
||||
onKey: (FocusNode node, RawKeyEvent evt) {
|
||||
if (evt.logicalKey.keyLabel == 'Enter') {
|
||||
if (evt is RawKeyDownEvent) {
|
||||
int pos = controller.selection.base.offset;
|
||||
controller.text =
|
||||
'${controller.text.substring(0, pos)}\n${controller.text.substring(pos)}';
|
||||
controller.selection =
|
||||
TextSelection.fromPosition(TextPosition(offset: pos + 1));
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (evt.logicalKey.keyLabel == 'Esc') {
|
||||
if (evt is RawKeyDownEvent) {
|
||||
onEscape();
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
} else {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return TextField(
|
||||
autofocus: true,
|
||||
keyboardType: TextInputType.multiline,
|
||||
textInputAction: TextInputAction.newline,
|
||||
decoration: InputDecoration(
|
||||
hintText: translate('input note here'),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
contentPadding: EdgeInsets.all(12),
|
||||
),
|
||||
minLines: 5,
|
||||
maxLines: null,
|
||||
maxLength: 256,
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
).workaroundFreezeLinuxMint();
|
||||
}
|
||||
|
||||
showAuditDialog(FFI ffi) async {
|
||||
final controller = TextEditingController(text: ffi.auditNote);
|
||||
final controller = TextEditingController(
|
||||
text: bind.sessionGetLastAuditNote(sessionId: ffi.sessionId));
|
||||
ffi.dialogManager.show((setState, close, context) {
|
||||
submit() {
|
||||
var text = controller.text;
|
||||
bind.sessionSendNote(sessionId: ffi.sessionId, note: text);
|
||||
ffi.auditNote = text;
|
||||
close();
|
||||
}
|
||||
|
||||
late final focusNode = FocusNode(
|
||||
onKey: (FocusNode node, RawKeyEvent evt) {
|
||||
if (evt.logicalKey.keyLabel == 'Enter') {
|
||||
if (evt is RawKeyDownEvent) {
|
||||
int pos = controller.selection.base.offset;
|
||||
controller.text =
|
||||
'${controller.text.substring(0, pos)}\n${controller.text.substring(pos)}';
|
||||
controller.selection =
|
||||
TextSelection.fromPosition(TextPosition(offset: pos + 1));
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (evt.logicalKey.keyLabel == 'Esc') {
|
||||
if (evt is RawKeyDownEvent) {
|
||||
close();
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
} else {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return CustomAlertDialog(
|
||||
title: Text(translate('Note')),
|
||||
content: SizedBox(
|
||||
width: 250,
|
||||
height: 120,
|
||||
child: TextField(
|
||||
autofocus: true,
|
||||
keyboardType: TextInputType.multiline,
|
||||
textInputAction: TextInputAction.newline,
|
||||
decoration: const InputDecoration.collapsed(
|
||||
hintText: 'input note here',
|
||||
),
|
||||
maxLines: null,
|
||||
maxLength: 256,
|
||||
child: buildNoteTextField(
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
).workaroundFreezeLinuxMint()),
|
||||
onEscape: close,
|
||||
)),
|
||||
actions: [
|
||||
dialogButton('Cancel', onPressed: close, isOutline: true),
|
||||
dialogButton('OK', onPressed: submit)
|
||||
@@ -1569,6 +1593,223 @@ showAuditDialog(FFI ffi) async {
|
||||
});
|
||||
}
|
||||
|
||||
bool allowAskForNoteAtEndOfConnection(FFI? ffi, bool closedByControlling) {
|
||||
if (ffi == null) {
|
||||
return false;
|
||||
}
|
||||
return mainGetLocalBoolOptionSync(kOptionAllowAskForNoteAtEndOfConnection) &&
|
||||
bind
|
||||
.sessionGetAuditServerSync(sessionId: ffi.sessionId, typ: "conn")
|
||||
.isNotEmpty &&
|
||||
bind.sessionGetAuditGuid(sessionId: ffi.sessionId).isNotEmpty &&
|
||||
bind.sessionGetLastAuditNote(sessionId: ffi.sessionId).isEmpty &&
|
||||
(!closedByControlling ||
|
||||
bind.willSessionCloseCloseSession(sessionId: ffi.sessionId));
|
||||
}
|
||||
|
||||
// return value: close canceled
|
||||
// true: return
|
||||
// false: go on
|
||||
Future<bool> desktopTryShowTabAuditDialogCloseCancelled(
|
||||
{required String id, required DesktopTabController tabController}) async {
|
||||
try {
|
||||
final page =
|
||||
tabController.state.value.tabs.firstWhere((tab) => tab.key == id).page;
|
||||
final ffi = (page as dynamic).ffi;
|
||||
final res = await showConnEndAuditDialogCloseCanceled(ffi: ffi);
|
||||
return res;
|
||||
} catch (e) {
|
||||
debugPrint('Failed to show audit dialog: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// return value:
|
||||
// true: return
|
||||
// false: go on
|
||||
Future<bool> showConnEndAuditDialogCloseCanceled(
|
||||
{required FFI ffi, String? type, String? title, String? text}) async {
|
||||
final res = await _showConnEndAuditDialogCloseCanceled(
|
||||
ffi: ffi, type: type, title: title, text: text);
|
||||
if (res == true) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// return value:
|
||||
// true: return
|
||||
// false / null: go on
|
||||
Future<bool?> _showConnEndAuditDialogCloseCanceled({
|
||||
required FFI ffi,
|
||||
String? type,
|
||||
String? title,
|
||||
String? text,
|
||||
}) async {
|
||||
final closedByControlling = type == null;
|
||||
final showDialog = allowAskForNoteAtEndOfConnection(ffi, closedByControlling);
|
||||
if (!showDialog) {
|
||||
return false;
|
||||
}
|
||||
ffi.dialogManager.dismissAll();
|
||||
|
||||
Future<void> updateAuditNoteByGuid(String auditGuid, String note) async {
|
||||
debugPrint('Updating audit note for GUID: $auditGuid, note: $note');
|
||||
try {
|
||||
final apiServer = await bind.mainGetApiServer();
|
||||
if (apiServer.isEmpty) {
|
||||
debugPrint('API server is empty, cannot update audit note');
|
||||
return;
|
||||
}
|
||||
final url = '$apiServer/api/audit';
|
||||
var headers = getHttpHeaders();
|
||||
headers['Content-Type'] = "application/json";
|
||||
final body = jsonEncode({
|
||||
'guid': auditGuid,
|
||||
'note': note,
|
||||
});
|
||||
|
||||
final response = await http.put(
|
||||
Uri.parse(url),
|
||||
headers: headers,
|
||||
body: body,
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
debugPrint('Successfully updated audit note for GUID: $auditGuid');
|
||||
} else {
|
||||
debugPrint(
|
||||
'Failed to update audit note. Status: ${response.statusCode}, Body: ${response.body}');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Error updating audit note: $e');
|
||||
}
|
||||
}
|
||||
|
||||
final controller = TextEditingController();
|
||||
bool askForNote =
|
||||
mainGetLocalBoolOptionSync(kOptionAllowAskForNoteAtEndOfConnection);
|
||||
final isOptFixed = isOptionFixed(kOptionAllowAskForNoteAtEndOfConnection);
|
||||
bool isInProgress = false;
|
||||
|
||||
return await ffi.dialogManager.show<bool>((setState, close, context) {
|
||||
cancel() {
|
||||
close(true);
|
||||
}
|
||||
|
||||
set() async {
|
||||
if (isInProgress) return;
|
||||
setState(() {
|
||||
isInProgress = true;
|
||||
});
|
||||
var text = controller.text;
|
||||
if (text.isNotEmpty) {
|
||||
await updateAuditNoteByGuid(
|
||||
bind.sessionGetAuditGuid(sessionId: ffi.sessionId), text)
|
||||
.timeout(const Duration(seconds: 6), onTimeout: () {
|
||||
debugPrint('updateAuditNoteByGuid timeout after 6s');
|
||||
});
|
||||
}
|
||||
// Save the "ask for note" preference
|
||||
if (!isOptFixed) {
|
||||
await mainSetLocalBoolOption(
|
||||
kOptionAllowAskForNoteAtEndOfConnection, askForNote);
|
||||
}
|
||||
}
|
||||
|
||||
submit() async {
|
||||
await set();
|
||||
close(false);
|
||||
}
|
||||
|
||||
final buttons = [
|
||||
dialogButton('OK', onPressed: isInProgress ? null : submit)
|
||||
];
|
||||
if (type == 'relay-hint' || type == 'relay-hint2') {
|
||||
buttons.add(dialogButton('Retry', onPressed: () async {
|
||||
await set();
|
||||
close(true);
|
||||
ffi.ffiModel.reconnect(ffi.dialogManager, ffi.sessionId, false);
|
||||
}));
|
||||
if (type == 'relay-hint2') {
|
||||
buttons.add(dialogButton('Connect via relay', onPressed: () async {
|
||||
await set();
|
||||
close(true);
|
||||
ffi.ffiModel.reconnect(ffi.dialogManager, ffi.sessionId, true);
|
||||
}));
|
||||
}
|
||||
}
|
||||
if (closedByControlling) {
|
||||
buttons.add(dialogButton('Cancel',
|
||||
onPressed: isInProgress ? null : cancel, isOutline: true));
|
||||
}
|
||||
|
||||
Widget content;
|
||||
if (closedByControlling) {
|
||||
content = SelectionArea(
|
||||
child: msgboxContent(
|
||||
'info', 'Close', 'Are you sure to close the connection?'));
|
||||
} else {
|
||||
content =
|
||||
SelectionArea(child: msgboxContent(type, title ?? '', text ?? ''));
|
||||
}
|
||||
|
||||
return CustomAlertDialog(
|
||||
title: null,
|
||||
content: SizedBox(
|
||||
width: 350,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
content,
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
height: 120,
|
||||
child: buildNoteTextField(
|
||||
controller: controller,
|
||||
onEscape: cancel,
|
||||
),
|
||||
),
|
||||
if (!isOptFixed) ...[
|
||||
const SizedBox(height: 8),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
askForNote = !askForNote;
|
||||
});
|
||||
},
|
||||
child: Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: askForNote,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
askForNote = value ?? false;
|
||||
});
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
translate('note-at-conn-end-tip'),
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
if (isInProgress)
|
||||
const LinearProgressIndicator().marginOnly(top: 4),
|
||||
],
|
||||
)),
|
||||
actions: buttons,
|
||||
onSubmit: submit,
|
||||
onCancel: cancel,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void showConfirmSwitchSidesDialog(
|
||||
SessionID sessionId, String id, OverlayDialogManager dialogManager) async {
|
||||
dialogManager.show((setState, close, context) {
|
||||
|
||||
@@ -25,6 +25,7 @@ class CustomTouchGestureRecognizer extends ScaleGestureRecognizer {
|
||||
GestureDragStartCallback? onOneFingerPanStart;
|
||||
GestureDragUpdateCallback? onOneFingerPanUpdate;
|
||||
GestureDragEndCallback? onOneFingerPanEnd;
|
||||
GestureDragCancelCallback? onOneFingerPanCancel;
|
||||
|
||||
// twoFingerScale : scale + pan event
|
||||
GestureScaleStartCallback? onTwoFingerScaleStart;
|
||||
@@ -169,6 +170,27 @@ class CustomTouchGestureRecognizer extends ScaleGestureRecognizer {
|
||||
|
||||
DragEndDetails _getDragEndDetails(ScaleEndDetails d) =>
|
||||
DragEndDetails(velocity: d.velocity);
|
||||
|
||||
@override
|
||||
void rejectGesture(int pointer) {
|
||||
super.rejectGesture(pointer);
|
||||
switch (_currentState) {
|
||||
case GestureState.oneFingerPan:
|
||||
if (onOneFingerPanCancel != null) {
|
||||
onOneFingerPanCancel!();
|
||||
}
|
||||
break;
|
||||
case GestureState.twoFingerScale:
|
||||
// Reset scale state if needed, currently self-contained
|
||||
break;
|
||||
case GestureState.threeFingerVerticalDrag:
|
||||
// Reset drag state if needed, currently self-contained
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
_currentState = GestureState.none;
|
||||
}
|
||||
}
|
||||
|
||||
class HoldTapMoveGestureRecognizer extends GestureRecognizer {
|
||||
@@ -717,6 +739,7 @@ RawGestureDetector getMixinGestureDetector({
|
||||
GestureDragStartCallback? onOneFingerPanStart,
|
||||
GestureDragUpdateCallback? onOneFingerPanUpdate,
|
||||
GestureDragEndCallback? onOneFingerPanEnd,
|
||||
GestureDragCancelCallback? onOneFingerPanCancel,
|
||||
GestureScaleUpdateCallback? onTwoFingerScaleUpdate,
|
||||
GestureScaleEndCallback? onTwoFingerScaleEnd,
|
||||
GestureDragUpdateCallback? onThreeFingerVerticalDragUpdate,
|
||||
@@ -765,6 +788,7 @@ RawGestureDetector getMixinGestureDetector({
|
||||
..onOneFingerPanStart = onOneFingerPanStart
|
||||
..onOneFingerPanUpdate = onOneFingerPanUpdate
|
||||
..onOneFingerPanEnd = onOneFingerPanEnd
|
||||
..onOneFingerPanCancel = onOneFingerPanCancel
|
||||
..onTwoFingerScaleUpdate = onTwoFingerScaleUpdate
|
||||
..onTwoFingerScaleEnd = onTwoFingerScaleEnd
|
||||
..onThreeFingerVerticalDragUpdate = onThreeFingerVerticalDragUpdate;
|
||||
|
||||
@@ -103,7 +103,7 @@ class ButtonOP extends StatelessWidget {
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Center(
|
||||
child: Text('${translate("Continue with")} $opLabel')),
|
||||
child: Text(translate("Continue with {$opLabel}"))),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -400,6 +400,8 @@ Future<bool?> loginDialog() async {
|
||||
String? passwordMsg;
|
||||
var isInProgress = false;
|
||||
final RxString curOP = ''.obs;
|
||||
// Track hover state for the close icon
|
||||
bool isCloseHovered = false;
|
||||
|
||||
final loginOptions = [].obs;
|
||||
Future.delayed(Duration.zero, () async {
|
||||
@@ -557,21 +559,27 @@ Future<bool?> loginDialog() async {
|
||||
Text(
|
||||
translate('Login'),
|
||||
).marginOnly(top: MyTheme.dialogPadding),
|
||||
InkWell(
|
||||
child: Icon(
|
||||
Icons.close,
|
||||
size: 25,
|
||||
// No need to handle the branch of null.
|
||||
// Because we can ensure the color is not null when debug.
|
||||
color: Theme.of(context)
|
||||
.textTheme
|
||||
.titleLarge
|
||||
?.color
|
||||
?.withOpacity(0.55),
|
||||
MouseRegion(
|
||||
onEnter: (_) => setState(() => isCloseHovered = true),
|
||||
onExit: (_) => setState(() => isCloseHovered = false),
|
||||
child: InkWell(
|
||||
child: Icon(
|
||||
Icons.close,
|
||||
size: 25,
|
||||
// No need to handle the branch of null.
|
||||
// Because we can ensure the color is not null when debug.
|
||||
color: isCloseHovered
|
||||
? Colors.white
|
||||
: Theme.of(context)
|
||||
.textTheme
|
||||
.titleLarge
|
||||
?.color
|
||||
?.withOpacity(0.55),
|
||||
),
|
||||
onTap: onDialogCancel,
|
||||
hoverColor: Colors.red,
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
onTap: onDialogCancel,
|
||||
hoverColor: Colors.red,
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
).marginOnly(top: 10, right: 15),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -50,6 +50,7 @@ class DraggableChatWindow extends StatelessWidget {
|
||||
)
|
||||
: Draggable(
|
||||
checkKeyboard: true,
|
||||
checkScreenSize: true,
|
||||
position: draggablePositions.chatWindow,
|
||||
width: width,
|
||||
height: height,
|
||||
@@ -395,7 +396,10 @@ class _DraggableState extends State<Draggable> {
|
||||
_chatModel?.setChatWindowPosition(position);
|
||||
}
|
||||
|
||||
checkScreenSize() {}
|
||||
checkScreenSize() {
|
||||
// Ensure the draggable always stays within current screen bounds
|
||||
widget.position.tryAdjust(widget.width, widget.height, 1);
|
||||
}
|
||||
|
||||
checkKeyboard() {
|
||||
final bottomHeight = MediaQuery.of(context).viewInsets.bottom;
|
||||
@@ -517,6 +521,12 @@ class IOSDraggableState extends State<IOSDraggable> {
|
||||
_lastBottomHeight = bottomHeight;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
position.tryAdjust(_width, _height, 1);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
checkKeyboard();
|
||||
|
||||
@@ -107,6 +107,8 @@ class _RawTouchGestureDetectorRegionState
|
||||
// For mouse mode, we need to block the events when the cursor is in a blocked area.
|
||||
// So we need to cache the last tap down position.
|
||||
Offset? _lastTapDownPositionForMouseMode;
|
||||
// Cache global position for onTap (which lacks position info).
|
||||
Offset? _lastTapDownGlobalPosition;
|
||||
|
||||
FFI get ffi => widget.ffi;
|
||||
FfiModel get ffiModel => widget.ffiModel;
|
||||
@@ -136,6 +138,7 @@ class _RawTouchGestureDetectorRegionState
|
||||
|
||||
onTapDown(TapDownDetails d) async {
|
||||
lastDeviceKind = d.kind;
|
||||
_lastTapDownGlobalPosition = d.globalPosition;
|
||||
if (isNotTouchBasedDevice()) {
|
||||
return;
|
||||
}
|
||||
@@ -154,11 +157,16 @@ class _RawTouchGestureDetectorRegionState
|
||||
if (isNotTouchBasedDevice()) {
|
||||
return;
|
||||
}
|
||||
// Filter duplicate touch tap events on iOS (Magic Mouse issue).
|
||||
if (inputModel.shouldIgnoreTouchTap(d.globalPosition)) {
|
||||
return;
|
||||
}
|
||||
if (handleTouch) {
|
||||
final isMoved =
|
||||
await ffi.cursorModel.move(d.localPosition.dx, d.localPosition.dy);
|
||||
if (isMoved) {
|
||||
if (lastTapDownDetails != null) {
|
||||
// If pan already handled 'down', don't send it again.
|
||||
if (lastTapDownDetails != null && !_touchModePanStarted) {
|
||||
await inputModel.tapDown(MouseButtons.left);
|
||||
}
|
||||
await inputModel.tapUp(MouseButtons.left);
|
||||
@@ -170,6 +178,11 @@ class _RawTouchGestureDetectorRegionState
|
||||
if (isNotTouchBasedDevice()) {
|
||||
return;
|
||||
}
|
||||
// Filter duplicate touch tap events on iOS (Magic Mouse issue).
|
||||
final lastPos = _lastTapDownGlobalPosition;
|
||||
if (lastPos != null && inputModel.shouldIgnoreTouchTap(lastPos)) {
|
||||
return;
|
||||
}
|
||||
if (!handleTouch) {
|
||||
// Cannot use `_lastTapDownDetails` because Flutter calls `onTapUp` before `onTap`, clearing the cached details.
|
||||
// Using `_lastTapDownPositionForMouseMode` instead.
|
||||
@@ -372,7 +385,10 @@ class _RawTouchGestureDetectorRegionState
|
||||
await ffi.cursorModel
|
||||
.move(_cacheLongPressPosition.dx, _cacheLongPressPosition.dy);
|
||||
}
|
||||
await inputModel.sendMouse('down', MouseButtons.left);
|
||||
// In relative mouse mode, skip mouse down - only send movement via sendMobileRelativeMouseMove
|
||||
if (!inputModel.relativeMouseMode.value) {
|
||||
await inputModel.sendMouse('down', MouseButtons.left);
|
||||
}
|
||||
await ffi.cursorModel.move(d.localPosition.dx, d.localPosition.dy);
|
||||
} else {
|
||||
final offset = ffi.cursorModel.offset;
|
||||
@@ -397,7 +413,12 @@ class _RawTouchGestureDetectorRegionState
|
||||
if (handleTouch && !_touchModePanStarted) {
|
||||
return;
|
||||
}
|
||||
await ffi.cursorModel.updatePan(d.delta, d.localPosition, handleTouch);
|
||||
// In relative mouse mode, send delta directly without position tracking.
|
||||
if (inputModel.relativeMouseMode.value) {
|
||||
await inputModel.sendMobileRelativeMouseMove(d.delta.dx, d.delta.dy);
|
||||
} else {
|
||||
await ffi.cursorModel.updatePan(d.delta, d.localPosition, handleTouch);
|
||||
}
|
||||
}
|
||||
|
||||
onOneFingerPanEnd(DragEndDetails d) async {
|
||||
@@ -409,10 +430,21 @@ class _RawTouchGestureDetectorRegionState
|
||||
ffi.cursorModel.clearRemoteWindowCoords();
|
||||
}
|
||||
if (handleTouch) {
|
||||
await inputModel.sendMouse('up', MouseButtons.left);
|
||||
// In relative mouse mode, skip mouse up - matches the skipped mouse down in onOneFingerPanStart
|
||||
if (!inputModel.relativeMouseMode.value) {
|
||||
await inputModel.sendMouse('up', MouseButtons.left);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reset `_touchModePanStarted` if the one-finger pan gesture is cancelled
|
||||
// or rejected by the gesture arena. Without this, the flag can remain
|
||||
// stuck in the "started" state and cause issues such as the Magic Mouse
|
||||
// double-click problem on iPad with magic mouse.
|
||||
onOneFingerPanCancel() {
|
||||
_touchModePanStarted = false;
|
||||
}
|
||||
|
||||
// scale + pan event
|
||||
onTwoFingerScaleStart(ScaleStartDetails d) {
|
||||
_lastTapDownDetails = null;
|
||||
@@ -546,6 +578,7 @@ class _RawTouchGestureDetectorRegionState
|
||||
instance
|
||||
..onOneFingerPanUpdate = onOneFingerPanUpdate
|
||||
..onOneFingerPanEnd = onOneFingerPanEnd
|
||||
..onOneFingerPanCancel = onOneFingerPanCancel
|
||||
..onTwoFingerScaleStart = onTwoFingerScaleStart
|
||||
..onTwoFingerScaleUpdate = onTwoFingerScaleUpdate
|
||||
..onTwoFingerScaleEnd = onTwoFingerScaleEnd
|
||||
|
||||
@@ -6,10 +6,12 @@ import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/common/shared_state.dart';
|
||||
import 'package:flutter_hbb/common/widgets/dialog.dart';
|
||||
import 'package:flutter_hbb/common/widgets/login.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/desktop/widgets/remote_toolbar.dart';
|
||||
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';
|
||||
|
||||
bool isEditOsPassword = false;
|
||||
@@ -193,14 +195,26 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
||||
);
|
||||
}
|
||||
// note
|
||||
if (isDefaultConn &&
|
||||
bind
|
||||
.sessionGetAuditServerSync(sessionId: sessionId, typ: "conn")
|
||||
.isNotEmpty) {
|
||||
if (isDefaultConn && !bind.isDisableAccount()) {
|
||||
v.add(
|
||||
TTextMenu(
|
||||
child: Text(translate('Note')),
|
||||
onPressed: () => showAuditDialog(ffi)),
|
||||
onPressed: () async {
|
||||
bool isLogin =
|
||||
bind.mainGetLocalOption(key: 'access_token').isNotEmpty;
|
||||
if (!isLogin) {
|
||||
final res = await loginDialog();
|
||||
if (res != true) return;
|
||||
// Desktop: send message to main window to refresh login status
|
||||
// Web: login is required before connection, so no need to refresh
|
||||
// Mobile: same isolate, no need to send message
|
||||
if (isDesktop) {
|
||||
rustDeskWinManager.call(
|
||||
WindowType.Main, kWindowRefreshCurrentUser, "");
|
||||
}
|
||||
}
|
||||
showAuditDialog(ffi);
|
||||
}),
|
||||
);
|
||||
}
|
||||
// divider
|
||||
@@ -364,12 +378,11 @@ Future<List<TRadioMenu<String>>> toolbarViewStyle(
|
||||
value: kRemoteViewStyleAdaptive,
|
||||
groupValue: groupValue,
|
||||
onChanged: onChanged),
|
||||
if (isDesktop || isWebDesktop)
|
||||
TRadioMenu<String>(
|
||||
child: Text(translate('Scale custom')),
|
||||
value: kRemoteViewStyleCustom,
|
||||
groupValue: groupValue,
|
||||
onChanged: onChanged)
|
||||
TRadioMenu<String>(
|
||||
child: Text(translate('Scale custom')),
|
||||
value: kRemoteViewStyleCustom,
|
||||
groupValue: groupValue,
|
||||
onChanged: onChanged)
|
||||
];
|
||||
}
|
||||
|
||||
@@ -818,6 +831,7 @@ List<TToggleMenu> toolbarKeyboardToggles(FFI ffi) {
|
||||
final ffiModel = ffi.ffiModel;
|
||||
final pi = ffiModel.pi;
|
||||
final sessionId = ffi.sessionId;
|
||||
final isDefaultConn = ffi.connType == ConnType.defaultConn;
|
||||
List<TToggleMenu> v = [];
|
||||
|
||||
// swap key
|
||||
@@ -839,6 +853,34 @@ List<TToggleMenu> toolbarKeyboardToggles(FFI ffi) {
|
||||
child: Text(translate('Swap control-command key'))));
|
||||
}
|
||||
|
||||
// Relative mouse mode (gaming mode).
|
||||
// Only show when server supports MOUSE_TYPE_MOVE_RELATIVE (version >= 1.4.5)
|
||||
// Note: This feature is only available in Flutter client. Sciter client does not support this.
|
||||
// Web client is not supported yet due to Pointer Lock API integration complexity with Flutter's input system.
|
||||
// Wayland is not supported due to cursor warping limitations.
|
||||
// Mobile: This option is now in GestureHelp widget, shown only when joystick is visible.
|
||||
final isWayland = isDesktop && isLinux && bind.mainCurrentIsWayland();
|
||||
if (isDesktop &&
|
||||
isDefaultConn &&
|
||||
!isWeb &&
|
||||
!isWayland &&
|
||||
ffiModel.keyboard &&
|
||||
!ffiModel.viewOnly &&
|
||||
ffi.inputModel.isRelativeMouseModeSupported) {
|
||||
v.add(TToggleMenu(
|
||||
value: ffi.inputModel.relativeMouseMode.value,
|
||||
onChanged: (value) {
|
||||
if (value == null) return;
|
||||
final previousValue = ffi.inputModel.relativeMouseMode.value;
|
||||
final success = ffi.inputModel.setRelativeMouseMode(value);
|
||||
if (!success) {
|
||||
// Revert the observable toggle to reflect the actual state
|
||||
ffi.inputModel.relativeMouseMode.value = previousValue;
|
||||
}
|
||||
},
|
||||
child: Text(translate('Relative mouse mode'))));
|
||||
}
|
||||
|
||||
// reverse mouse wheel
|
||||
if (ffiModel.keyboard) {
|
||||
var optionValue =
|
||||
|
||||
@@ -50,6 +50,7 @@ const String kAppTypeDesktopPortForward = "port forward";
|
||||
const String kAppTypeDesktopTerminal = "terminal";
|
||||
|
||||
const String kWindowMainWindowOnTop = "main_window_on_top";
|
||||
const String kWindowRefreshCurrentUser = "refresh_current_user";
|
||||
const String kWindowGetWindowInfo = "get_window_info";
|
||||
const String kWindowGetScreenList = "get_screen_list";
|
||||
// This method is not used, maybe it can be removed.
|
||||
@@ -58,6 +59,7 @@ const String kWindowActionRebuild = "rebuild";
|
||||
const String kWindowEventHide = "hide";
|
||||
const String kWindowEventShow = "show";
|
||||
const String kWindowConnect = "connect";
|
||||
const String kWindowBumpMouse = "bump_mouse";
|
||||
|
||||
const String kWindowEventNewRemoteDesktop = "new_remote_desktop";
|
||||
const String kWindowEventNewFileTransfer = "new_file_transfer";
|
||||
@@ -78,6 +80,7 @@ const String kWindowEventOpenMonitorSession = "open_monitor_session";
|
||||
|
||||
const String kOptionViewStyle = "view_style";
|
||||
const String kOptionScrollStyle = "scroll_style";
|
||||
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";
|
||||
@@ -118,6 +121,7 @@ const String kOptionApproveMode = "approve-mode";
|
||||
const String kOptionAllowNumericOneTimePassword =
|
||||
"allow-numeric-one-time-password";
|
||||
const String kOptionCollapseToolbar = "collapse_toolbar";
|
||||
const String kOptionHideToolbar = "hide-toolbar";
|
||||
const String kOptionShowRemoteCursor = "show_remote_cursor";
|
||||
const String kOptionFollowRemoteCursor = "follow_remote_cursor";
|
||||
const String kOptionFollowRemoteWindow = "follow_remote_window";
|
||||
@@ -158,11 +162,16 @@ const String kOptionEnableTrustedDevices = "enable-trusted-devices";
|
||||
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 kOptionEnableShowTerminalExtraKeys = "enable-show-terminal-extra-keys";
|
||||
|
||||
// network options
|
||||
const String kOptionAllowWebSocket = "allow-websocket";
|
||||
const String kOptionAllowInsecureTLSFallback = "allow-insecure-tls-fallback";
|
||||
const String kOptionDisableUdp = "disable-udp";
|
||||
const String kOptionEnableFlutterHttpOnRust = "enable-flutter-http-on-rust";
|
||||
|
||||
// buildin opitons
|
||||
// builtin options
|
||||
const String kOptionHideServerSetting = "hide-server-settings";
|
||||
const String kOptionHideProxySetting = "hide-proxy-settings";
|
||||
const String kOptionHideWebSocketSetting = "hide-websocket-settings";
|
||||
@@ -171,6 +180,10 @@ const String kOptionHideSecuritySetting = "hide-security-settings";
|
||||
const String kOptionHideNetworkSetting = "hide-network-settings";
|
||||
const String kOptionRemovePresetPasswordWarning =
|
||||
"remove-preset-password-warning";
|
||||
const String kOptionDisableChangePermanentPassword =
|
||||
"disable-change-permanent-password";
|
||||
const String kOptionDisableChangeId = "disable-change-id";
|
||||
const String kOptionDisableUnlockPin = "disable-unlock-pin";
|
||||
const kHideUsernameOnCard = "hide-username-on-card";
|
||||
const String kOptionHideHelpCards = "hide-help-cards";
|
||||
|
||||
@@ -181,6 +194,9 @@ const String kOptionDisableFloatingWindow = "disable-floating-window";
|
||||
|
||||
const String kOptionKeepScreenOn = "keep-screen-on";
|
||||
|
||||
const String kOptionKeepAwakeDuringIncomingSessions = "keep-awake-during-incoming-sessions";
|
||||
const String kOptionKeepAwakeDuringOutgoingSessions = "keep-awake-during-outgoing-sessions";
|
||||
|
||||
const String kOptionShowMobileAction = "showMobileActions";
|
||||
|
||||
const String kUrlActionClose = "close";
|
||||
@@ -245,6 +261,33 @@ const int kMinTrackpadSpeed = 10;
|
||||
const int kDefaultTrackpadSpeed = 100;
|
||||
const int kMaxTrackpadSpeed = 1000;
|
||||
|
||||
// relative mouse mode
|
||||
/// Throttle duration (in milliseconds) for updating pointer lock center during
|
||||
/// window move/resize events. Lower values provide more responsive updates but
|
||||
/// may cause performance issues during rapid window operations.
|
||||
const int kDefaultPointerLockCenterThrottleMs = 100;
|
||||
|
||||
/// Minimum server version required for relative mouse mode (MOUSE_TYPE_MOVE_RELATIVE).
|
||||
/// Servers older than this version will ignore relative mouse events.
|
||||
///
|
||||
/// IMPORTANT: This value must be kept in sync with the Rust constant
|
||||
/// `MIN_VERSION_RELATIVE_MOUSE_MODE` in `src/common.rs`.
|
||||
const String kMinVersionForRelativeMouseMode = '1.4.5';
|
||||
|
||||
/// Maximum delta value for relative mouse movement.
|
||||
/// Large values could cause issues with i32 overflow on server side,
|
||||
/// and no reasonable mouse movement should exceed this bound.
|
||||
///
|
||||
/// IMPORTANT: This value must be kept in sync with the Rust constant
|
||||
/// `MAX_RELATIVE_MOUSE_DELTA` in `src/server/input_service.rs`.
|
||||
const int kMaxRelativeMouseDelta = 10000;
|
||||
|
||||
/// Debounce duration (in milliseconds) for relative mouse mode toggle.
|
||||
/// This prevents double-toggle from race condition between Rust rdev grab loop
|
||||
/// and Flutter keyboard handling. Value should be small enough to allow
|
||||
/// intentional quick toggles but large enough to prevent accidental double-triggers.
|
||||
const int kRelativeMouseModeToggleDebounceMs = 150;
|
||||
|
||||
// incomming (should be incoming) is kept, because change it will break the previous setting.
|
||||
const String kKeyPrinterIncomingJobAction = 'printer-incomming-job-action';
|
||||
const String kValuePrinterIncomingJobDismiss = 'dismiss';
|
||||
@@ -319,13 +362,15 @@ const kRemoteViewStyleAdaptive = 'adaptive';
|
||||
/// [kRemoteViewStyleCustom] Show remote image at a user-defined scale percent.
|
||||
const kRemoteViewStyleCustom = 'custom';
|
||||
|
||||
|
||||
/// [kRemoteScrollStyleAuto] Scroll image auto by position.
|
||||
const kRemoteScrollStyleAuto = 'scrollauto';
|
||||
|
||||
/// [kRemoteScrollStyleBar] Scroll image with scroll bar.
|
||||
const kRemoteScrollStyleBar = 'scrollbar';
|
||||
|
||||
/// [kRemoteScrollStyleEdge] Scroll image auto at edges.
|
||||
const kRemoteScrollStyleEdge = 'scrolledge';
|
||||
|
||||
/// [kScrollModeDefault] Mouse or touchpad, the default scroll mode.
|
||||
const kScrollModeDefault = 'default';
|
||||
|
||||
@@ -353,12 +398,14 @@ const Set<PointerDeviceKind> kTouchBasedDeviceKinds = {
|
||||
};
|
||||
|
||||
// Scale custom related constants
|
||||
const String kCustomScalePercentKey = 'custom_scale_percent'; // Flutter option key for storing custom scale percent (integer 5-1000)
|
||||
const String kCustomScalePercentKey =
|
||||
'custom_scale_percent'; // Flutter option key for storing custom scale percent (integer 5-1000)
|
||||
const int kScaleCustomMinPercent = 5;
|
||||
const int kScaleCustomPivotPercent = 100; // 100% should be at 1/3 of track
|
||||
const int kScaleCustomMaxPercent = 1000;
|
||||
const double kScaleCustomPivotPos = 1.0 / 3.0; // first 1/3 → up to 100%
|
||||
const double kScaleCustomDetentEpsilon = 0.006; // snap range around pivot (~0.6%)
|
||||
const double kScaleCustomDetentEpsilon =
|
||||
0.006; // snap range around pivot (~0.6%)
|
||||
const Duration kDebounceCustomScaleDuration = Duration(milliseconds: 300);
|
||||
|
||||
// ================================ mobile ================================
|
||||
|
||||
@@ -18,6 +18,7 @@ import 'package:flutter_hbb/models/server_model.dart';
|
||||
import 'package:flutter_hbb/models/state_model.dart';
|
||||
import 'package:flutter_hbb/plugin/ui_manager.dart';
|
||||
import 'package:flutter_hbb/utils/multi_window_manager.dart';
|
||||
import 'package:flutter_hbb/utils/platform_channel.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
@@ -449,7 +450,11 @@ class _DesktopHomePageState extends State<DesktopHomePage>
|
||||
"${translate("new-version-of-{${bind.mainGetAppNameSync()}}-tip")} (${bind.mainGetNewVersion()}).",
|
||||
btnText,
|
||||
onPressed,
|
||||
closeButton: true);
|
||||
closeButton: true,
|
||||
help: isToUpdate ? 'Changelog' : null,
|
||||
link: isToUpdate
|
||||
? 'https://github.com/rustdesk/rustdesk/releases/tag/${bind.mainGetNewVersion()}'
|
||||
: null);
|
||||
}
|
||||
if (systemError.isNotEmpty) {
|
||||
return buildInstallCard("", systemError, "", () {});
|
||||
@@ -760,11 +765,23 @@ class _DesktopHomePageState extends State<DesktopHomePage>
|
||||
'scaleFactor': screen.scaleFactor,
|
||||
};
|
||||
|
||||
bool isChattyMethod(String methodName) {
|
||||
switch (methodName) {
|
||||
case kWindowBumpMouse: return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
rustDeskWinManager.setMethodHandler((call, fromWindowId) async {
|
||||
debugPrint(
|
||||
if (!isChattyMethod(call.method)) {
|
||||
debugPrint(
|
||||
"[Main] call ${call.method} with args ${call.arguments} from window $fromWindowId");
|
||||
}
|
||||
if (call.method == kWindowMainWindowOnTop) {
|
||||
windowOnTop(null);
|
||||
} else if (call.method == kWindowRefreshCurrentUser) {
|
||||
gFFI.userModel.refreshCurrentUser();
|
||||
} else if (call.method == kWindowGetWindowInfo) {
|
||||
final screen = (await window_size.getWindowInfo()).screen;
|
||||
if (screen == null) {
|
||||
@@ -793,6 +810,10 @@ class _DesktopHomePageState extends State<DesktopHomePage>
|
||||
forceRelay: call.arguments['forceRelay'],
|
||||
connToken: call.arguments['connToken'],
|
||||
);
|
||||
} else if (call.method == kWindowBumpMouse) {
|
||||
return RdPlatformChannel.instance.bumpMouse(
|
||||
dx: call.arguments['dx'],
|
||||
dy: call.arguments['dy']);
|
||||
} else if (call.method == kWindowEventMoveTabToNewWindow) {
|
||||
final args = call.arguments.split(',');
|
||||
int? windowId;
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'package:flutter_hbb/common/widgets/setting_widgets.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/desktop/pages/desktop_home_page.dart';
|
||||
import 'package:flutter_hbb/desktop/pages/desktop_tab_page.dart';
|
||||
import 'package:flutter_hbb/desktop/widgets/remote_toolbar.dart';
|
||||
import 'package:flutter_hbb/mobile/widgets/dialog.dart';
|
||||
import 'package:flutter_hbb/models/platform_model.dart';
|
||||
import 'package:flutter_hbb/models/printer_model.dart';
|
||||
@@ -556,10 +557,36 @@ class _GeneralState extends State<_General> {
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
// Add client-side wakelock option for desktop platforms
|
||||
if (!bind.isIncomingOnly()) {
|
||||
children.add(_OptionCheckBox(
|
||||
context,
|
||||
'keep-awake-during-outgoing-sessions-label',
|
||||
kOptionKeepAwakeDuringOutgoingSessions,
|
||||
isServer: false,
|
||||
));
|
||||
}
|
||||
|
||||
if (!isWeb && bind.mainShowOption(key: kOptionAllowLinuxHeadless)) {
|
||||
children.add(_OptionCheckBox(
|
||||
context, 'Allow linux headless', kOptionAllowLinuxHeadless));
|
||||
}
|
||||
if (!bind.isDisableAccount()) {
|
||||
children.add(_OptionCheckBox(
|
||||
context,
|
||||
'note-at-conn-end-tip',
|
||||
kOptionAllowAskForNoteAtEndOfConnection,
|
||||
isServer: false,
|
||||
optSetter: (key, value) async {
|
||||
if (value && !gFFI.userModel.isLogin) {
|
||||
final res = await loginDialog();
|
||||
if (res != true) return;
|
||||
}
|
||||
await mainSetLocalBoolOption(key, value);
|
||||
},
|
||||
));
|
||||
}
|
||||
return _Card(title: 'Other', children: children);
|
||||
}
|
||||
|
||||
@@ -809,7 +836,8 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
|
||||
permissions(context),
|
||||
password(context),
|
||||
_Card(title: '2FA', children: [tfa()]),
|
||||
_Card(title: 'ID', children: [changeId()]),
|
||||
if (!isChangeIdDisabled())
|
||||
_Card(title: 'ID', children: [changeId()]),
|
||||
more(context),
|
||||
]),
|
||||
),
|
||||
@@ -1075,6 +1103,10 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
|
||||
.indexOf(kUsePermanentPassword)] &&
|
||||
(await bind.mainGetPermanentPassword())
|
||||
.isEmpty) {
|
||||
if (isChangePermanentPasswordDisabled()) {
|
||||
await callback();
|
||||
return;
|
||||
}
|
||||
setPasswordDialog(notEmptyCallback: callback);
|
||||
} else {
|
||||
await callback();
|
||||
@@ -1177,9 +1209,9 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
|
||||
],
|
||||
),
|
||||
enabled: tmpEnabled && !locked),
|
||||
numericOneTimePassword,
|
||||
if (usePassword) numericOneTimePassword,
|
||||
if (usePassword) radios[1],
|
||||
if (usePassword)
|
||||
if (usePassword && !isChangePermanentPasswordDisabled())
|
||||
_SubButton('Set permanent password', setPasswordDialog,
|
||||
permEnabled && !locked),
|
||||
// if (usePassword)
|
||||
@@ -1198,11 +1230,14 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
|
||||
...directIp(context),
|
||||
whitelist(),
|
||||
...autoDisconnect(context),
|
||||
_OptionCheckBox(context, 'keep-awake-during-incoming-sessions-label',
|
||||
kOptionKeepAwakeDuringIncomingSessions,
|
||||
reverse: false, enabled: enabled),
|
||||
if (bind.mainIsInstalled())
|
||||
_OptionCheckBox(context, 'allow-only-conn-window-open-tip',
|
||||
'allow-only-conn-window-open',
|
||||
reverse: false, enabled: enabled),
|
||||
if (bind.mainIsInstalled()) unlockPin()
|
||||
if (bind.mainIsInstalled() && !isUnlockPinDisabled()) unlockPin()
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1585,6 +1620,27 @@ class _NetworkState extends State<_Network> with AutomaticKeepAliveClientMixin {
|
||||
);
|
||||
}
|
||||
|
||||
Widget switchWidget(IconData icon, String title, String tooltipMessage,
|
||||
String optionKey) =>
|
||||
listTile(
|
||||
icon: icon,
|
||||
title: title,
|
||||
showTooltip: true,
|
||||
tooltipMessage: tooltipMessage,
|
||||
trailing: Switch(
|
||||
value: mainGetBoolOptionSync(optionKey),
|
||||
onChanged: locked || isOptionFixed(optionKey)
|
||||
? null
|
||||
: (value) {
|
||||
mainSetBoolOption(optionKey, value);
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final outgoingOnly = bind.isOutgoingOnly();
|
||||
|
||||
final divider = const Divider(height: 1, indent: 16, endIndent: 16);
|
||||
return _Card(
|
||||
title: 'Network',
|
||||
children: [
|
||||
@@ -1596,33 +1652,65 @@ class _NetworkState extends State<_Network> with AutomaticKeepAliveClientMixin {
|
||||
listTile(
|
||||
icon: Icons.dns_outlined,
|
||||
title: 'ID/Relay Server',
|
||||
onTap: () => showServerSettings(gFFI.dialogManager),
|
||||
onTap: () => showServerSettings(gFFI.dialogManager, setState),
|
||||
),
|
||||
if (!hideServer && (!hideProxy || !hideWebSocket))
|
||||
Divider(height: 1, indent: 16, endIndent: 16),
|
||||
if (!hideProxy && !hideServer) divider,
|
||||
if (!hideProxy)
|
||||
listTile(
|
||||
icon: Icons.network_ping_outlined,
|
||||
title: 'Socks5/Http(s) Proxy',
|
||||
onTap: changeSocks5Proxy,
|
||||
),
|
||||
if (!hideProxy && !hideWebSocket)
|
||||
Divider(height: 1, indent: 16, endIndent: 16),
|
||||
if (!hideWebSocket && (!hideServer || !hideProxy)) divider,
|
||||
if (!hideWebSocket)
|
||||
listTile(
|
||||
icon: Icons.web_asset_outlined,
|
||||
title: 'Use WebSocket',
|
||||
showTooltip: true,
|
||||
tooltipMessage: 'websocket_tip',
|
||||
trailing: Switch(
|
||||
value: mainGetBoolOptionSync(kOptionAllowWebSocket),
|
||||
onChanged: locked
|
||||
? null
|
||||
: (value) {
|
||||
mainSetBoolOption(kOptionAllowWebSocket, value);
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
switchWidget(
|
||||
Icons.web_asset_outlined,
|
||||
'Use WebSocket',
|
||||
'${translate('websocket_tip')}\n\n${translate('server-oss-not-support-tip')}',
|
||||
kOptionAllowWebSocket),
|
||||
if (!isWeb)
|
||||
futureBuilder(
|
||||
future: bind.mainIsUsingPublicServer(),
|
||||
hasData: (isUsingPublicServer) {
|
||||
if (isUsingPublicServer) {
|
||||
return Offstage();
|
||||
} else {
|
||||
return Column(
|
||||
children: [
|
||||
if (!hideServer || !hideProxy || !hideWebSocket)
|
||||
divider,
|
||||
switchWidget(
|
||||
Icons.no_encryption_outlined,
|
||||
'Allow insecure TLS fallback',
|
||||
'allow-insecure-tls-fallback-tip',
|
||||
kOptionAllowInsecureTLSFallback),
|
||||
if (!outgoingOnly) divider,
|
||||
if (!outgoingOnly)
|
||||
listTile(
|
||||
icon: Icons.lan_outlined,
|
||||
title: 'Disable UDP',
|
||||
showTooltip: true,
|
||||
tooltipMessage:
|
||||
'${translate('disable-udp-tip')}\n\n${translate('server-oss-not-support-tip')}',
|
||||
trailing: Switch(
|
||||
value: bind.mainGetOptionSync(
|
||||
key: kOptionDisableUdp) ==
|
||||
'Y',
|
||||
onChanged:
|
||||
locked || isOptionFixed(kOptionDisableUdp)
|
||||
? null
|
||||
: (value) async {
|
||||
await bind.mainSetOption(
|
||||
key: kOptionDisableUdp,
|
||||
value: value ? 'Y' : 'N');
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -1685,6 +1773,13 @@ class _DisplayState extends State<_Display> {
|
||||
}
|
||||
|
||||
final groupValue = bind.mainGetUserDefaultOption(key: kOptionScrollStyle);
|
||||
|
||||
onEdgeScrollEdgeThicknessChanged(double value) async {
|
||||
await bind.mainSetUserDefaultOption(
|
||||
key: kOptionEdgeScrollEdgeThickness, value: value.round().toString());
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
return _Card(title: 'Default Scroll Style', children: [
|
||||
_Radio(context,
|
||||
value: kRemoteScrollStyleAuto,
|
||||
@@ -1696,6 +1791,23 @@ class _DisplayState extends State<_Display> {
|
||||
groupValue: groupValue,
|
||||
label: 'Scrollbar',
|
||||
onChanged: isOptFixed ? null : onChanged),
|
||||
if (!isWeb) ...[
|
||||
_Radio(context,
|
||||
value: kRemoteScrollStyleEdge,
|
||||
groupValue: groupValue,
|
||||
label: 'ScrollEdge',
|
||||
onChanged: isOptFixed ? null : onChanged),
|
||||
Offstage(
|
||||
offstage: groupValue != kRemoteScrollStyleEdge,
|
||||
child: EdgeThicknessControl(
|
||||
value: double.tryParse(bind.mainGetUserDefaultOption(
|
||||
key: kOptionEdgeScrollEdgeThickness)) ??
|
||||
100.0,
|
||||
onChanged: isOptionFixed(kOptionEdgeScrollEdgeThickness)
|
||||
? null
|
||||
: onEdgeScrollEdgeThicknessChanged,
|
||||
)),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1737,9 +1849,9 @@ class _DisplayState extends State<_Display> {
|
||||
}
|
||||
|
||||
Widget trackpadSpeed(BuildContext context) {
|
||||
final initSpeed = (int.tryParse(
|
||||
bind.mainGetUserDefaultOption(key: kKeyTrackpadSpeed)) ??
|
||||
kDefaultTrackpadSpeed);
|
||||
final initSpeed =
|
||||
(int.tryParse(bind.mainGetUserDefaultOption(key: kKeyTrackpadSpeed)) ??
|
||||
kDefaultTrackpadSpeed);
|
||||
final curSpeed = SimpleWrapper(initSpeed);
|
||||
void onDebouncer(int v) {
|
||||
bind.mainSetUserDefaultOption(
|
||||
@@ -2426,6 +2538,49 @@ class WaylandCard extends StatefulWidget {
|
||||
|
||||
class _WaylandCardState extends State<WaylandCard> {
|
||||
final restoreTokenKey = 'wayland-restore-token';
|
||||
static const _kClearShortcutsInhibitorEventKey =
|
||||
'clear-gnome-shortcuts-inhibitor-permission-res';
|
||||
final _clearShortcutsInhibitorFailedMsg = ''.obs;
|
||||
// Don't show the shortcuts permission reset button for now.
|
||||
// Users can change it manually:
|
||||
// "Settings" -> "Apps" -> "RustDesk" -> "Permissions" -> "Inhibit Shortcuts".
|
||||
// For resetting(clearing) the permission from the portal permission store, you can
|
||||
// use (replace <desktop-id> with the RustDesk desktop file ID):
|
||||
// busctl --user call org.freedesktop.impl.portal.PermissionStore \
|
||||
// /org/freedesktop/impl/portal/PermissionStore org.freedesktop.impl.portal.PermissionStore \
|
||||
// DeletePermission sss "gnome" "shortcuts-inhibitor" "<desktop-id>"
|
||||
// On a native install this is typically "rustdesk.desktop"; on Flatpak it is usually
|
||||
// the exported desktop ID derived from the Flatpak app-id (e.g. "com.rustdesk.RustDesk.desktop").
|
||||
//
|
||||
// We may add it back in the future if needed.
|
||||
final showResetInhibitorPermission = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (showResetInhibitorPermission) {
|
||||
platformFFI.registerEventHandler(
|
||||
_kClearShortcutsInhibitorEventKey, _kClearShortcutsInhibitorEventKey,
|
||||
(evt) async {
|
||||
if (!mounted) return;
|
||||
if (evt['success'] == true) {
|
||||
setState(() {});
|
||||
} else {
|
||||
_clearShortcutsInhibitorFailedMsg.value =
|
||||
evt['msg'] as String? ?? 'Unknown error';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (showResetInhibitorPermission) {
|
||||
platformFFI.unregisterEventHandler(
|
||||
_kClearShortcutsInhibitorEventKey, _kClearShortcutsInhibitorEventKey);
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -2433,9 +2588,16 @@ class _WaylandCardState extends State<WaylandCard> {
|
||||
future: bind.mainHandleWaylandScreencastRestoreToken(
|
||||
key: restoreTokenKey, value: "get"),
|
||||
hasData: (restoreToken) {
|
||||
final hasShortcutsPermission = showResetInhibitorPermission &&
|
||||
bind.mainGetCommonSync(
|
||||
key: "has-gnome-shortcuts-inhibitor-permission") ==
|
||||
"true";
|
||||
|
||||
final children = [
|
||||
if (restoreToken.isNotEmpty)
|
||||
_buildClearScreenSelection(context, restoreToken),
|
||||
if (hasShortcutsPermission)
|
||||
_buildClearShortcutsInhibitorPermission(context),
|
||||
];
|
||||
return Offstage(
|
||||
offstage: children.isEmpty,
|
||||
@@ -2480,6 +2642,50 @@ class _WaylandCardState extends State<WaylandCard> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildClearShortcutsInhibitorPermission(BuildContext context) {
|
||||
onConfirm() {
|
||||
_clearShortcutsInhibitorFailedMsg.value = '';
|
||||
bind.mainSetCommon(
|
||||
key: "clear-gnome-shortcuts-inhibitor-permission", value: "");
|
||||
gFFI.dialogManager.dismissAll();
|
||||
}
|
||||
|
||||
showConfirmMsgBox() => msgBoxCommon(
|
||||
gFFI.dialogManager,
|
||||
'Confirmation',
|
||||
Text(
|
||||
translate('confirm-clear-shortcuts-inhibitor-permission-tip'),
|
||||
),
|
||||
[
|
||||
dialogButton('OK', onPressed: onConfirm),
|
||||
dialogButton('Cancel',
|
||||
onPressed: () => gFFI.dialogManager.dismissAll())
|
||||
]);
|
||||
|
||||
return Column(children: [
|
||||
Obx(
|
||||
() => _clearShortcutsInhibitorFailedMsg.value.isEmpty
|
||||
? Offstage()
|
||||
: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: Text(_clearShortcutsInhibitorFailedMsg.value,
|
||||
style: DefaultTextStyle.of(context)
|
||||
.style
|
||||
.copyWith(color: Colors.red))
|
||||
.marginOnly(bottom: 10.0)),
|
||||
),
|
||||
_Button(
|
||||
'Reset keyboard shortcuts permission',
|
||||
showConfirmMsgBox,
|
||||
tip: 'clear-shortcuts-inhibitor-permission-tip',
|
||||
style: ButtonStyle(
|
||||
backgroundColor: MaterialStateProperty.all<Color>(
|
||||
Theme.of(context).colorScheme.error.withOpacity(0.75)),
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// ignore: non_constant_identifier_names
|
||||
@@ -2561,7 +2767,7 @@ Widget _lock(
|
||||
]).marginSymmetric(vertical: 2)),
|
||||
onPressed: () async {
|
||||
final unlockPin = bind.mainGetUnlockPin();
|
||||
if (unlockPin.isEmpty) {
|
||||
if (unlockPin.isEmpty || isUnlockPinDisabled()) {
|
||||
bool checked = await callMainCheckSuperUserPermission();
|
||||
if (checked) {
|
||||
onUnlock();
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:extended_text/extended_text.dart';
|
||||
import 'package:flutter_hbb/common/widgets/dialog.dart';
|
||||
import 'package:flutter_hbb/desktop/widgets/dragable_divider.dart';
|
||||
import 'package:percent_indicator/percent_indicator.dart';
|
||||
import 'package:desktop_drop/desktop_drop.dart';
|
||||
@@ -16,7 +17,6 @@ import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
|
||||
import 'package:flutter_hbb/models/file_model.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
import 'package:flutter_hbb/web/dummy.dart'
|
||||
if (dart.library.html) 'package:flutter_hbb/web/web_unique.dart';
|
||||
|
||||
@@ -52,7 +52,7 @@ enum MouseFocusScope {
|
||||
}
|
||||
|
||||
class FileManagerPage extends StatefulWidget {
|
||||
const FileManagerPage(
|
||||
FileManagerPage(
|
||||
{Key? key,
|
||||
required this.id,
|
||||
required this.password,
|
||||
@@ -67,9 +67,16 @@ class FileManagerPage extends StatefulWidget {
|
||||
final bool? forceRelay;
|
||||
final String? connToken;
|
||||
final DesktopTabController? tabController;
|
||||
final SimpleWrapper<State<FileManagerPage>?> _lastState = SimpleWrapper(null);
|
||||
|
||||
FFI get ffi => (_lastState.value! as _FileManagerPageState)._ffi;
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _FileManagerPageState();
|
||||
State<StatefulWidget> createState() {
|
||||
final state = _FileManagerPageState();
|
||||
_lastState.value = state;
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
class _FileManagerPageState extends State<FileManagerPage>
|
||||
@@ -78,6 +85,7 @@ class _FileManagerPageState extends State<FileManagerPage>
|
||||
|
||||
final _dropMaskVisible = false.obs; // TODO impl drop mask
|
||||
final _overlayKeyState = OverlayKeyState();
|
||||
final _uniqueKey = UniqueKey();
|
||||
|
||||
late FFI _ffi;
|
||||
|
||||
@@ -99,9 +107,7 @@ class _FileManagerPageState extends State<FileManagerPage>
|
||||
.showLoading(translate('Connecting...'), onCancel: closeConnection);
|
||||
});
|
||||
Get.put<FFI>(_ffi, tag: 'ft_${widget.id}');
|
||||
if (!isLinux) {
|
||||
WakelockPlus.enable();
|
||||
}
|
||||
WakelockManager.enable(_uniqueKey);
|
||||
if (isWeb) {
|
||||
_ffi.ffiModel.updateEventListener(_ffi.sessionId, widget.id);
|
||||
}
|
||||
@@ -119,9 +125,7 @@ class _FileManagerPageState extends State<FileManagerPage>
|
||||
model.close().whenComplete(() {
|
||||
_ffi.close();
|
||||
_ffi.dialogManager.dismissAll();
|
||||
if (!isLinux) {
|
||||
WakelockPlus.disable();
|
||||
}
|
||||
WakelockManager.disable(_uniqueKey);
|
||||
Get.delete<FFI>(tag: 'ft_${widget.id}');
|
||||
});
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
@@ -139,12 +143,26 @@ class _FileManagerPageState extends State<FileManagerPage>
|
||||
}
|
||||
}
|
||||
|
||||
Widget willPopScope(Widget child) {
|
||||
if (isWeb) {
|
||||
return WillPopScope(
|
||||
onWillPop: () async {
|
||||
clientClose(_ffi.sessionId, _ffi);
|
||||
return false;
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
} else {
|
||||
return child;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
return Overlay(key: _overlayKeyState.key, initialEntries: [
|
||||
OverlayEntry(builder: (_) {
|
||||
return Scaffold(
|
||||
return willPopScope(Scaffold(
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
body: Row(
|
||||
children: [
|
||||
@@ -160,7 +178,7 @@ class _FileManagerPageState extends State<FileManagerPage>
|
||||
Flexible(flex: 2, child: statusList())
|
||||
],
|
||||
),
|
||||
);
|
||||
));
|
||||
})
|
||||
]);
|
||||
}
|
||||
@@ -260,11 +278,9 @@ class _FileManagerPageState extends State<FileManagerPage>
|
||||
item.state != JobState.inProgress,
|
||||
child: LinearPercentIndicator(
|
||||
animateFromLastPercent: true,
|
||||
center: Text(
|
||||
'${(item.finishedSize / item.totalSize * 100).toStringAsFixed(0)}%',
|
||||
),
|
||||
center: Text(item.percentText),
|
||||
barRadius: Radius.circular(15),
|
||||
percent: item.finishedSize / item.totalSize,
|
||||
percent: item.percent,
|
||||
progressColor: MyTheme.accent,
|
||||
backgroundColor: Theme.of(context).hoverColor,
|
||||
lineHeight: kDesktopFileTransferRowHeight,
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:convert';
|
||||
import 'package:desktop_multi_window/desktop_multi_window.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/common/widgets/dialog.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/models/state_model.dart';
|
||||
import 'package:flutter_hbb/desktop/pages/file_manager_page.dart';
|
||||
@@ -40,7 +41,15 @@ class _FileManagerTabPageState extends State<FileManagerTabPage> {
|
||||
label: params['id'],
|
||||
selectedIcon: selectedIcon,
|
||||
unselectedIcon: unselectedIcon,
|
||||
onTabCloseButton: () => tabController.closeBy(params['id']),
|
||||
onTabCloseButton: () async {
|
||||
if (await desktopTryShowTabAuditDialogCloseCancelled(
|
||||
id: params['id'],
|
||||
tabController: tabController,
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
tabController.closeBy(params['id']);
|
||||
},
|
||||
page: FileManagerPage(
|
||||
key: ValueKey(params['id']),
|
||||
id: params['id'],
|
||||
@@ -69,7 +78,15 @@ class _FileManagerTabPageState extends State<FileManagerTabPage> {
|
||||
label: id,
|
||||
selectedIcon: selectedIcon,
|
||||
unselectedIcon: unselectedIcon,
|
||||
onTabCloseButton: () => tabController.closeBy(id),
|
||||
onTabCloseButton: () async {
|
||||
if (await desktopTryShowTabAuditDialogCloseCancelled(
|
||||
id: id,
|
||||
tabController: tabController,
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
tabController.closeBy(id);
|
||||
},
|
||||
page: FileManagerPage(
|
||||
key: ValueKey(id),
|
||||
id: id,
|
||||
@@ -132,6 +149,14 @@ class _FileManagerTabPageState extends State<FileManagerTabPage> {
|
||||
|
||||
Future<bool> handleWindowCloseButton() async {
|
||||
final connLength = tabController.state.value.tabs.length;
|
||||
if (connLength == 1) {
|
||||
if (await desktopTryShowTabAuditDialogCloseCancelled(
|
||||
id: tabController.state.value.tabs[0].key,
|
||||
tabController: tabController,
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (connLength <= 1) {
|
||||
tabController.clear();
|
||||
return true;
|
||||
|
||||
@@ -25,7 +25,7 @@ class _PortForward {
|
||||
}
|
||||
|
||||
class PortForwardPage extends StatefulWidget {
|
||||
const PortForwardPage({
|
||||
PortForwardPage({
|
||||
Key? key,
|
||||
required this.id,
|
||||
required this.password,
|
||||
@@ -42,9 +42,16 @@ class PortForwardPage extends StatefulWidget {
|
||||
final bool? forceRelay;
|
||||
final bool? isSharedPassword;
|
||||
final String? connToken;
|
||||
final SimpleWrapper<State<PortForwardPage>?> _lastState = SimpleWrapper(null);
|
||||
|
||||
FFI get ffi => (_lastState.value! as _PortForwardPageState)._ffi;
|
||||
|
||||
@override
|
||||
State<PortForwardPage> createState() => _PortForwardPageState();
|
||||
State<PortForwardPage> createState() {
|
||||
final state = _PortForwardPageState();
|
||||
_lastState.value = state;
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
class _PortForwardPageState extends State<PortForwardPage>
|
||||
|
||||
@@ -3,9 +3,9 @@ import 'dart:async';
|
||||
import 'package:desktop_multi_window/desktop_multi_window.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
import 'package:flutter_hbb/models/state_model.dart';
|
||||
|
||||
import '../../consts.dart';
|
||||
@@ -15,6 +15,7 @@ import '../../common.dart';
|
||||
import '../../common/widgets/dialog.dart';
|
||||
import '../../common/widgets/toolbar.dart';
|
||||
import '../../models/model.dart';
|
||||
import '../../models/input_model.dart';
|
||||
import '../../models/platform_model.dart';
|
||||
import '../../common/shared_state.dart';
|
||||
import '../../utils/image.dart';
|
||||
@@ -72,7 +73,10 @@ class RemotePage extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _RemotePageState extends State<RemotePage>
|
||||
with AutomaticKeepAliveClientMixin, MultiWindowListener {
|
||||
with
|
||||
AutomaticKeepAliveClientMixin,
|
||||
MultiWindowListener,
|
||||
TickerProviderStateMixin {
|
||||
Timer? _timer;
|
||||
String keyboardMode = "legacy";
|
||||
bool _isWindowBlur = false;
|
||||
@@ -81,11 +85,16 @@ class _RemotePageState extends State<RemotePage>
|
||||
late RxBool _zoomCursor;
|
||||
late RxBool _remoteCursorMoved;
|
||||
late RxBool _keyboardEnabled;
|
||||
final _uniqueKey = UniqueKey();
|
||||
|
||||
var _blockableOverlayState = BlockableOverlayState();
|
||||
|
||||
final FocusNode _rawKeyFocusNode = FocusNode(debugLabel: "rawkeyFocusNode");
|
||||
|
||||
// Debounce timer for pointer lock center updates during window events.
|
||||
// Uses kDefaultPointerLockCenterThrottleMs from consts.dart for the duration.
|
||||
Timer? _pointerLockCenterDebounceTimer;
|
||||
|
||||
// We need `_instanceIdOnEnterOrLeaveImage4Toolbar` together with `_onEnterOrLeaveImage4Toolbar`
|
||||
// to identify the toolbar instance and its callback function.
|
||||
int? _instanceIdOnEnterOrLeaveImage4Toolbar;
|
||||
@@ -112,11 +121,13 @@ class _RemotePageState extends State<RemotePage>
|
||||
_ffi = FFI(widget.sessionId);
|
||||
Get.put<FFI>(_ffi, tag: widget.id);
|
||||
_ffi.imageModel.addCallbackOnFirstImage((String peerId) {
|
||||
_ffi.canvasModel.activateLocalCursor();
|
||||
showKBLayoutTypeChooserIfNeeded(
|
||||
_ffi.ffiModel.pi.platform, _ffi.dialogManager);
|
||||
_ffi.recordingModel
|
||||
.updateStatus(bind.sessionGetIsRecording(sessionId: _ffi.sessionId));
|
||||
});
|
||||
_ffi.canvasModel.initializeEdgeScrollFallback(this);
|
||||
_ffi.start(
|
||||
widget.id,
|
||||
password: widget.password,
|
||||
@@ -132,9 +143,7 @@ class _RemotePageState extends State<RemotePage>
|
||||
_ffi.dialogManager
|
||||
.showLoading(translate('Connecting...'), onCancel: closeConnection);
|
||||
});
|
||||
if (!isLinux) {
|
||||
WakelockPlus.enable();
|
||||
}
|
||||
WakelockManager.enable(_uniqueKey);
|
||||
|
||||
_ffi.ffiModel.updateEventListener(sessionId, widget.id);
|
||||
if (!isWeb) bind.pluginSyncUi(syncTo: kAppTypeDesktopRemote);
|
||||
@@ -165,6 +174,16 @@ class _RemotePageState extends State<RemotePage>
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
widget.tabController?.onSelected?.call(widget.id);
|
||||
});
|
||||
|
||||
// Register callback to cancel debounce timer when relative mouse mode is disabled
|
||||
_ffi.inputModel.onRelativeMouseModeDisabled =
|
||||
_cancelPointerLockCenterDebounceTimer;
|
||||
}
|
||||
|
||||
/// Cancel the pointer lock center debounce timer
|
||||
void _cancelPointerLockCenterDebounceTimer() {
|
||||
_pointerLockCenterDebounceTimer?.cancel();
|
||||
_pointerLockCenterDebounceTimer = null;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -180,6 +199,13 @@ class _RemotePageState extends State<RemotePage>
|
||||
_rawKeyFocusNode.unfocus();
|
||||
}
|
||||
stateGlobal.isFocused.value = false;
|
||||
|
||||
// When window loses focus, temporarily release relative mouse mode constraints
|
||||
// to allow user to interact with other applications normally.
|
||||
// The cursor will be re-hidden and re-centered when window regains focus.
|
||||
if (_ffi.inputModel.relativeMouseMode.value) {
|
||||
_ffi.inputModel.onWindowBlur();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -190,6 +216,12 @@ class _RemotePageState extends State<RemotePage>
|
||||
_isWindowBlur = false;
|
||||
}
|
||||
stateGlobal.isFocused.value = true;
|
||||
|
||||
// Restore relative mouse mode constraints when window regains focus.
|
||||
if (_ffi.inputModel.relativeMouseMode.value) {
|
||||
_rawKeyFocusNode.requestFocus();
|
||||
_ffi.inputModel.onWindowFocus();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -200,25 +232,59 @@ class _RemotePageState extends State<RemotePage>
|
||||
if (isWindows) {
|
||||
_isWindowBlur = false;
|
||||
}
|
||||
if (!isLinux) {
|
||||
WakelockPlus.enable();
|
||||
}
|
||||
WakelockManager.enable(_uniqueKey);
|
||||
// Update pointer lock center when window is restored
|
||||
_updatePointerLockCenterIfNeeded();
|
||||
}
|
||||
|
||||
// When the window is unminimized, onWindowMaximize or onWindowRestore can be called when the old state was maximized or not.
|
||||
@override
|
||||
void onWindowMaximize() {
|
||||
super.onWindowMaximize();
|
||||
if (!isLinux) {
|
||||
WakelockPlus.enable();
|
||||
}
|
||||
WakelockManager.enable(_uniqueKey);
|
||||
// Update pointer lock center when window is maximized
|
||||
_updatePointerLockCenterIfNeeded();
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowResize() {
|
||||
super.onWindowResize();
|
||||
// Update pointer lock center when window is resized
|
||||
_updatePointerLockCenterIfNeeded();
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowMove() {
|
||||
super.onWindowMove();
|
||||
// Update pointer lock center when window is moved
|
||||
_updatePointerLockCenterIfNeeded();
|
||||
}
|
||||
|
||||
/// Update pointer lock center with debouncing to avoid excessive updates
|
||||
/// during rapid window move/resize events.
|
||||
void _updatePointerLockCenterIfNeeded() {
|
||||
if (!_ffi.inputModel.relativeMouseMode.value) return;
|
||||
|
||||
// Cancel any pending update and schedule a new one (debounce pattern)
|
||||
_pointerLockCenterDebounceTimer?.cancel();
|
||||
_pointerLockCenterDebounceTimer = Timer(
|
||||
const Duration(milliseconds: kDefaultPointerLockCenterThrottleMs),
|
||||
() {
|
||||
if (!mounted) return;
|
||||
if (_ffi.inputModel.relativeMouseMode.value) {
|
||||
_ffi.inputModel.updatePointerLockCenter();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowMinimize() {
|
||||
super.onWindowMinimize();
|
||||
if (!isLinux) {
|
||||
WakelockPlus.disable();
|
||||
WakelockManager.disable(_uniqueKey);
|
||||
// Release cursor constraints when minimized
|
||||
if (_ffi.inputModel.relativeMouseMode.value) {
|
||||
_ffi.inputModel.onWindowBlur();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,6 +311,16 @@ class _RemotePageState extends State<RemotePage>
|
||||
// https://github.com/flutter/flutter/issues/64935
|
||||
super.dispose();
|
||||
debugPrint("REMOTE PAGE dispose session $sessionId ${widget.id}");
|
||||
|
||||
// Defensive cleanup: ensure host system-key propagation is reset even if
|
||||
// MouseRegion.onExit never fired (e.g., tab closed while cursor inside).
|
||||
if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: true);
|
||||
|
||||
_pointerLockCenterDebounceTimer?.cancel();
|
||||
_pointerLockCenterDebounceTimer = null;
|
||||
// Clear callback reference to prevent memory leaks and stale references
|
||||
_ffi.inputModel.onRelativeMouseModeDisabled = null;
|
||||
// Relative mouse mode cleanup is centralized in FFI.close(closeSession: ...).
|
||||
_ffi.textureModel.onRemotePageDispose(closeSession);
|
||||
if (closeSession) {
|
||||
// ensure we leave this session, this is a double check
|
||||
@@ -262,9 +338,7 @@ class _RemotePageState extends State<RemotePage>
|
||||
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
|
||||
overlays: SystemUiOverlay.values);
|
||||
}
|
||||
if (!isLinux) {
|
||||
await WakelockPlus.disable();
|
||||
}
|
||||
WakelockManager.disable(_uniqueKey);
|
||||
await Get.delete<FFI>(tag: widget.id);
|
||||
removeSharedStates(widget.id);
|
||||
}
|
||||
@@ -348,10 +422,15 @@ class _RemotePageState extends State<RemotePage>
|
||||
}
|
||||
}(),
|
||||
// Use Overlay to enable rebuild every time on menu button click.
|
||||
_ffi.ffiModel.pi.isSet.isTrue
|
||||
? Overlay(
|
||||
initialEntries: [OverlayEntry(builder: remoteToolbar)])
|
||||
: remoteToolbar(context),
|
||||
// Hide toolbar when relative mouse mode is active to prevent
|
||||
// cursor from escaping to toolbar area.
|
||||
Obx(() => _ffi.inputModel.relativeMouseMode.value
|
||||
? const Offstage()
|
||||
: _ffi.ffiModel.pi.isSet.isTrue
|
||||
? Overlay(initialEntries: [
|
||||
OverlayEntry(builder: remoteToolbar)
|
||||
])
|
||||
: remoteToolbar(context)),
|
||||
_ffi.ffiModel.pi.isSet.isFalse ? emptyOverlay() : Offstage(),
|
||||
],
|
||||
),
|
||||
@@ -395,7 +474,7 @@ class _RemotePageState extends State<RemotePage>
|
||||
super.build(context);
|
||||
return WillPopScope(
|
||||
onWillPop: () async {
|
||||
clientClose(sessionId, _ffi.dialogManager);
|
||||
clientClose(sessionId, _ffi);
|
||||
return false;
|
||||
},
|
||||
child: MultiProvider(providers: [
|
||||
@@ -408,6 +487,8 @@ class _RemotePageState extends State<RemotePage>
|
||||
}
|
||||
|
||||
void enterView(PointerEnterEvent evt) {
|
||||
_ffi.canvasModel.rearmEdgeScroll();
|
||||
|
||||
_cursorOverImage.value = true;
|
||||
_firstEnterImage.value = true;
|
||||
if (_onEnterOrLeaveImage4Toolbar != null) {
|
||||
@@ -417,6 +498,7 @@ class _RemotePageState extends State<RemotePage>
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
// See [onWindowBlur].
|
||||
if (!isWindows) {
|
||||
if (!_rawKeyFocusNode.hasFocus) {
|
||||
@@ -427,6 +509,8 @@ class _RemotePageState extends State<RemotePage>
|
||||
}
|
||||
|
||||
void leaveView(PointerExitEvent evt) {
|
||||
_ffi.canvasModel.disableEdgeScroll();
|
||||
|
||||
if (_ffi.ffiModel.keyboard) {
|
||||
_ffi.inputModel.tryMoveEdgeOnExit(evt.position);
|
||||
}
|
||||
@@ -440,6 +524,7 @@ class _RemotePageState extends State<RemotePage>
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
// See [onWindowBlur].
|
||||
if (!isWindows) {
|
||||
_ffi.inputModel.enterOrLeave(false);
|
||||
@@ -487,33 +572,39 @@ class _RemotePageState extends State<RemotePage>
|
||||
|
||||
Widget getBodyForDesktop(BuildContext context) {
|
||||
var paints = <Widget>[
|
||||
MouseRegion(onEnter: (evt) {
|
||||
if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: false);
|
||||
}, onExit: (evt) {
|
||||
if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: true);
|
||||
}, child: LayoutBuilder(builder: (context, constraints) {
|
||||
final c = Provider.of<CanvasModel>(context, listen: false);
|
||||
Future.delayed(Duration.zero, () => c.updateViewStyle());
|
||||
final peerDisplay = CurrentDisplayState.find(widget.id);
|
||||
return Obx(
|
||||
() => _ffi.ffiModel.pi.isSet.isFalse
|
||||
? Container(color: Colors.transparent)
|
||||
: Obx(() {
|
||||
widget.toolbarState.initShow(sessionId);
|
||||
_ffi.textureModel.updateCurrentDisplay(peerDisplay.value);
|
||||
return ImagePaint(
|
||||
id: widget.id,
|
||||
zoomCursor: _zoomCursor,
|
||||
cursorOverImage: _cursorOverImage,
|
||||
keyboardEnabled: _keyboardEnabled,
|
||||
remoteCursorMoved: _remoteCursorMoved,
|
||||
listenerBuilder: (child) => _buildRawTouchAndPointerRegion(
|
||||
child, enterView, leaveView),
|
||||
ffi: _ffi,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}))
|
||||
MouseRegion(
|
||||
onEnter: (evt) {
|
||||
if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: false);
|
||||
},
|
||||
onExit: (evt) {
|
||||
if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: true);
|
||||
},
|
||||
child: _ViewStyleUpdater(
|
||||
canvasModel: _ffi.canvasModel,
|
||||
inputModel: _ffi.inputModel,
|
||||
child: Builder(builder: (context) {
|
||||
final peerDisplay = CurrentDisplayState.find(widget.id);
|
||||
return Obx(
|
||||
() => _ffi.ffiModel.pi.isSet.isFalse
|
||||
? Container(color: Colors.transparent)
|
||||
: Obx(() {
|
||||
_ffi.textureModel.updateCurrentDisplay(peerDisplay.value);
|
||||
return ImagePaint(
|
||||
id: widget.id,
|
||||
zoomCursor: _zoomCursor,
|
||||
cursorOverImage: _cursorOverImage,
|
||||
keyboardEnabled: _keyboardEnabled,
|
||||
remoteCursorMoved: _remoteCursorMoved,
|
||||
listenerBuilder: (child) =>
|
||||
_buildRawTouchAndPointerRegion(
|
||||
child, enterView, leaveView),
|
||||
ffi: _ffi,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}),
|
||||
),
|
||||
)
|
||||
];
|
||||
|
||||
if (!_ffi.canvasModel.cursorEmbedded) {
|
||||
@@ -542,6 +633,63 @@ class _RemotePageState extends State<RemotePage>
|
||||
bool get wantKeepAlive => true;
|
||||
}
|
||||
|
||||
/// A widget that tracks the view size and updates CanvasModel.updateViewStyle()
|
||||
/// and InputModel.updateImageWidgetSize() only when size actually changes.
|
||||
/// This avoids scheduling post-frame callbacks on every LayoutBuilder rebuild.
|
||||
class _ViewStyleUpdater extends StatefulWidget {
|
||||
final CanvasModel canvasModel;
|
||||
final InputModel inputModel;
|
||||
final Widget child;
|
||||
|
||||
const _ViewStyleUpdater({
|
||||
Key? key,
|
||||
required this.canvasModel,
|
||||
required this.inputModel,
|
||||
required this.child,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<_ViewStyleUpdater> createState() => _ViewStyleUpdaterState();
|
||||
}
|
||||
|
||||
class _ViewStyleUpdaterState extends State<_ViewStyleUpdater> {
|
||||
Size? _lastSize;
|
||||
bool _callbackScheduled = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxWidth = constraints.maxWidth;
|
||||
final maxHeight = constraints.maxHeight;
|
||||
// Guard against infinite constraints (e.g., unconstrained ancestor).
|
||||
if (!maxWidth.isFinite || !maxHeight.isFinite) {
|
||||
return widget.child;
|
||||
}
|
||||
final newSize = Size(maxWidth, maxHeight);
|
||||
if (_lastSize != newSize) {
|
||||
_lastSize = newSize;
|
||||
// Schedule the update for after the current frame to avoid setState during build.
|
||||
// Use _callbackScheduled flag to prevent accumulating multiple callbacks
|
||||
// when size changes rapidly before any callback executes.
|
||||
if (!_callbackScheduled) {
|
||||
_callbackScheduled = true;
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||
_callbackScheduled = false;
|
||||
final currentSize = _lastSize;
|
||||
if (mounted && currentSize != null) {
|
||||
widget.canvasModel.updateViewStyle();
|
||||
widget.inputModel.updateImageWidgetSize(currentSize);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
return widget.child;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ImagePaint extends StatefulWidget {
|
||||
final FFI ffi;
|
||||
final String id;
|
||||
@@ -606,26 +754,29 @@ class _ImagePaintState extends State<ImagePaint> {
|
||||
cursor: cursorOverImage.isTrue
|
||||
? c.cursorEmbedded
|
||||
? SystemMouseCursors.none
|
||||
: keyboardEnabled.isTrue
|
||||
? (() {
|
||||
if (remoteCursorMoved.isTrue) {
|
||||
_lastRemoteCursorMoved = true;
|
||||
return SystemMouseCursors.none;
|
||||
} else {
|
||||
if (_lastRemoteCursorMoved) {
|
||||
_lastRemoteCursorMoved = false;
|
||||
_firstEnterImage.value = true;
|
||||
}
|
||||
return _buildCustomCursor(
|
||||
context, getCursorScale());
|
||||
}
|
||||
}())
|
||||
: _buildDisabledCursor(context, getCursorScale())
|
||||
// Hide cursor when relative mouse mode is active
|
||||
: widget.ffi.inputModel.relativeMouseMode.value
|
||||
? SystemMouseCursors.none
|
||||
: keyboardEnabled.isTrue
|
||||
? (() {
|
||||
if (remoteCursorMoved.isTrue) {
|
||||
_lastRemoteCursorMoved = true;
|
||||
return SystemMouseCursors.none;
|
||||
} else {
|
||||
if (_lastRemoteCursorMoved) {
|
||||
_lastRemoteCursorMoved = false;
|
||||
_firstEnterImage.value = true;
|
||||
}
|
||||
return _buildCustomCursor(
|
||||
context, getCursorScale());
|
||||
}
|
||||
}())
|
||||
: _buildDisabledCursor(context, getCursorScale())
|
||||
: MouseCursor.defer,
|
||||
onHover: (evt) {},
|
||||
child: child);
|
||||
});
|
||||
if (c.imageOverflow.isTrue && c.scrollStyle == ScrollStyle.scrollbar) {
|
||||
if (c.imageOverflow.isTrue && c.scrollStyle != ScrollStyle.scrollauto) {
|
||||
final paintWidth = c.getDisplayWidth() * s;
|
||||
final paintHeight = c.getDisplayHeight() * s;
|
||||
final paintSize = Size(paintWidth, paintHeight);
|
||||
@@ -680,9 +831,20 @@ class _ImagePaintState extends State<ImagePaint> {
|
||||
|
||||
Widget _buildScrollAutoNonTextureRender(
|
||||
ImageModel m, CanvasModel c, double s) {
|
||||
double sizeScale = s;
|
||||
if (widget.ffi.ffiModel.isPeerLinux) {
|
||||
final displays = widget.ffi.ffiModel.pi.getCurDisplays();
|
||||
if (displays.isNotEmpty) {
|
||||
sizeScale = s / displays[0].scale;
|
||||
}
|
||||
}
|
||||
return CustomPaint(
|
||||
size: Size(c.size.width, c.size.height),
|
||||
painter: ImagePainter(image: m.image, x: c.x / s, y: c.y / s, scale: s),
|
||||
painter: ImagePainter(
|
||||
image: m.image,
|
||||
x: c.x / sizeScale,
|
||||
y: c.y / sizeScale,
|
||||
scale: sizeScale),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -695,17 +857,19 @@ class _ImagePaintState extends State<ImagePaint> {
|
||||
if (rect == null) {
|
||||
return Container();
|
||||
}
|
||||
final isPeerLinux = ffiModel.isPeerLinux;
|
||||
final curDisplay = ffiModel.pi.currentDisplay;
|
||||
for (var i = 0; i < displays.length; i++) {
|
||||
final textureId = widget.ffi.textureModel
|
||||
.getTextureId(curDisplay == kAllDisplayValue ? i : curDisplay);
|
||||
if (true) {
|
||||
// both "textureId.value != -1" and "true" seems ok
|
||||
final sizeScale = isPeerLinux ? s / displays[i].scale : s;
|
||||
children.add(Positioned(
|
||||
left: (displays[i].x - rect.left) * s + offset.dx,
|
||||
top: (displays[i].y - rect.top) * s + offset.dy,
|
||||
width: displays[i].width * s,
|
||||
height: displays[i].height * s,
|
||||
width: displays[i].width * sizeScale,
|
||||
height: displays[i].height * sizeScale,
|
||||
child: Obx(() => Texture(
|
||||
textureId: textureId.value,
|
||||
filterQuality:
|
||||
|
||||
@@ -80,7 +80,15 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
|
||||
label: peerId!,
|
||||
selectedIcon: selectedIcon,
|
||||
unselectedIcon: unselectedIcon,
|
||||
onTabCloseButton: () => tabController.closeBy(peerId),
|
||||
onTabCloseButton: () async {
|
||||
if (await desktopTryShowTabAuditDialogCloseCancelled(
|
||||
id: peerId!,
|
||||
tabController: tabController,
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
tabController.closeBy(peerId!);
|
||||
},
|
||||
page: RemotePage(
|
||||
key: ValueKey(peerId),
|
||||
id: peerId!,
|
||||
@@ -127,7 +135,13 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
|
||||
body: DesktopTab(
|
||||
controller: tabController,
|
||||
onWindowCloseButton: handleWindowCloseButton,
|
||||
tail: const AddButton(),
|
||||
tail: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_RelativeMouseModeHint(tabController: tabController),
|
||||
const AddButton(),
|
||||
],
|
||||
),
|
||||
selectedBorderColor: MyTheme.accent,
|
||||
pageViewBuilder: (pageView) => pageView,
|
||||
labelGetter: DesktopTab.tablabelGetter,
|
||||
@@ -243,11 +257,11 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
|
||||
MenuEntryButton<String>(
|
||||
childBuilder: (TextStyle? style) => Obx(() => Text(
|
||||
translate(
|
||||
toolbarState.show.isTrue ? 'Hide Toolbar' : 'Show Toolbar'),
|
||||
toolbarState.hide.isTrue ? 'Show Toolbar' : 'Hide Toolbar'),
|
||||
style: style,
|
||||
)),
|
||||
proc: () {
|
||||
toolbarState.switchShow(sessionId);
|
||||
toolbarState.switchHide(sessionId);
|
||||
cancelFunc();
|
||||
},
|
||||
padding: padding,
|
||||
@@ -316,7 +330,13 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
|
||||
translate('Close'),
|
||||
style: style,
|
||||
),
|
||||
proc: () {
|
||||
proc: () async {
|
||||
if (await desktopTryShowTabAuditDialogCloseCancelled(
|
||||
id: key,
|
||||
tabController: tabController,
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
tabController.closeBy(key);
|
||||
cancelFunc();
|
||||
},
|
||||
@@ -360,6 +380,8 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
|
||||
loopCloseWindow();
|
||||
}
|
||||
ConnectionTypeState.delete(id);
|
||||
// Clean up relative mouse mode state for this peer.
|
||||
stateGlobal.relativeMouseModeState.remove(id);
|
||||
_update_remote_count();
|
||||
}
|
||||
|
||||
@@ -369,6 +391,14 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
|
||||
|
||||
Future<bool> handleWindowCloseButton() async {
|
||||
final connLength = tabController.length;
|
||||
if (connLength == 1) {
|
||||
if (await desktopTryShowTabAuditDialogCloseCancelled(
|
||||
id: tabController.state.value.tabs[0].key,
|
||||
tabController: tabController,
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (connLength <= 1) {
|
||||
tabController.clear();
|
||||
return true;
|
||||
@@ -423,7 +453,15 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
|
||||
label: id,
|
||||
selectedIcon: selectedIcon,
|
||||
unselectedIcon: unselectedIcon,
|
||||
onTabCloseButton: () => tabController.closeBy(id),
|
||||
onTabCloseButton: () async {
|
||||
if (await desktopTryShowTabAuditDialogCloseCancelled(
|
||||
id: id,
|
||||
tabController: tabController,
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
tabController.closeBy(id);
|
||||
},
|
||||
page: RemotePage(
|
||||
key: ValueKey(id),
|
||||
id: id,
|
||||
@@ -518,3 +556,69 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
|
||||
return returnValue;
|
||||
}
|
||||
}
|
||||
|
||||
/// A widget that displays a hint in the tab bar when relative mouse mode is active.
|
||||
/// This helps users remember how to exit relative mouse mode.
|
||||
class _RelativeMouseModeHint extends StatelessWidget {
|
||||
final DesktopTabController tabController;
|
||||
|
||||
const _RelativeMouseModeHint({Key? key, required this.tabController})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Obx(() {
|
||||
// Check if there are any tabs
|
||||
if (tabController.state.value.tabs.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
// Get current selected tab's RemotePage
|
||||
final selectedTabInfo = tabController.state.value.selectedTabInfo;
|
||||
if (selectedTabInfo.page is! RemotePage) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final remotePage = selectedTabInfo.page as RemotePage;
|
||||
final String peerId = remotePage.id;
|
||||
|
||||
// Use global state to check relative mouse mode (synced from InputModel).
|
||||
// This avoids timing issues with FFI registration.
|
||||
final isRelativeMouseMode =
|
||||
stateGlobal.relativeMouseModeState[peerId] ?? false;
|
||||
|
||||
if (!isRelativeMouseMode) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: Colors.orange.withOpacity(0.5)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.mouse,
|
||||
size: 14,
|
||||
color: Colors.orange[700],
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
translate(
|
||||
'rel-mouse-exit-{${isMacOS ? "Cmd+G" : "Ctrl+Alt"}}-tip'),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.orange[700],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import 'package:xterm/xterm.dart';
|
||||
import 'terminal_connection_manager.dart';
|
||||
|
||||
class TerminalPage extends StatefulWidget {
|
||||
const TerminalPage({
|
||||
TerminalPage({
|
||||
Key? key,
|
||||
required this.id,
|
||||
required this.password,
|
||||
@@ -25,15 +25,23 @@ class TerminalPage extends StatefulWidget {
|
||||
final bool? isSharedPassword;
|
||||
final String? connToken;
|
||||
final int terminalId;
|
||||
final SimpleWrapper<State<TerminalPage>?> _lastState = SimpleWrapper(null);
|
||||
|
||||
FFI get ffi => (_lastState.value! as _TerminalPageState)._ffi;
|
||||
|
||||
@override
|
||||
State<TerminalPage> createState() => _TerminalPageState();
|
||||
State<TerminalPage> createState() {
|
||||
final state = _TerminalPageState();
|
||||
_lastState.value = state;
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
class _TerminalPageState extends State<TerminalPage>
|
||||
with AutomaticKeepAliveClientMixin {
|
||||
late FFI _ffi;
|
||||
late TerminalModel _terminalModel;
|
||||
double? _cellHeight;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -53,18 +61,30 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
debugPrint(
|
||||
'[TerminalPage] Terminal model created for terminal ${widget.terminalId}');
|
||||
|
||||
_terminalModel.onResizeExternal = (w, h, pw, ph) {
|
||||
_cellHeight = ph * 1.0;
|
||||
|
||||
// Schedule the setState for the next frame
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Register this terminal model with FFI for event routing
|
||||
_ffi.registerTerminalModel(widget.terminalId, _terminalModel);
|
||||
|
||||
// Initialize terminal connection
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
widget.tabController.onSelected?.call(widget.id);
|
||||
|
||||
|
||||
// Check if this is a new connection or additional terminal
|
||||
// Note: When a connection exists, the ref count will be > 1 after this terminal is added
|
||||
final isExistingConnection = TerminalConnectionManager.hasConnection(widget.id) &&
|
||||
TerminalConnectionManager.getTerminalCount(widget.id) > 1;
|
||||
|
||||
final isExistingConnection =
|
||||
TerminalConnectionManager.hasConnection(widget.id) &&
|
||||
TerminalConnectionManager.getTerminalCount(widget.id) > 1;
|
||||
|
||||
if (!isExistingConnection) {
|
||||
// First terminal - show loading dialog, wait for onReady
|
||||
_ffi.dialogManager
|
||||
@@ -87,30 +107,48 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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: 5.0, vertical: topBottom);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
return Scaffold(
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
body: TerminalView(
|
||||
_terminalModel.terminal,
|
||||
controller: _terminalModel.terminalController,
|
||||
autofocus: true,
|
||||
backgroundOpacity: 0.7,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0),
|
||||
onSecondaryTapDown: (details, offset) async {
|
||||
final selection = _terminalModel.terminalController.selection;
|
||||
if (selection != null) {
|
||||
final text = _terminalModel.terminal.buffer.getText(selection);
|
||||
_terminalModel.terminalController.clearSelection();
|
||||
await Clipboard.setData(ClipboardData(text: text));
|
||||
} else {
|
||||
final data = await Clipboard.getData('text/plain');
|
||||
final text = data?.text;
|
||||
if (text != null) {
|
||||
_terminalModel.terminal.paste(text);
|
||||
}
|
||||
}
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final heightPx = constraints.maxHeight;
|
||||
return TerminalView(
|
||||
_terminalModel.terminal,
|
||||
controller: _terminalModel.terminalController,
|
||||
autofocus: true,
|
||||
backgroundOpacity: 0.7,
|
||||
padding: _calculatePadding(heightPx),
|
||||
onSecondaryTapDown: (details, offset) async {
|
||||
final selection = _terminalModel.terminalController.selection;
|
||||
if (selection != null) {
|
||||
final text = _terminalModel.terminal.buffer.getText(selection);
|
||||
_terminalModel.terminalController.clearSelection();
|
||||
await Clipboard.setData(ClipboardData(text: text));
|
||||
} else {
|
||||
final data = await Clipboard.getData('text/plain');
|
||||
final text = data?.text;
|
||||
if (text != null) {
|
||||
_terminalModel.terminal.paste(text);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:desktop_multi_window/desktop_multi_window.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/common/widgets/dialog.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/models/state_model.dart';
|
||||
import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
|
||||
@@ -62,13 +63,20 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
||||
}) {
|
||||
final tabKey = '${peerId}_$terminalId';
|
||||
final alias = bind.mainGetPeerOptionSync(id: peerId, key: 'alias');
|
||||
final tabLabel = alias.isNotEmpty ? '$alias #$terminalId' : '$peerId #$terminalId';
|
||||
final tabLabel =
|
||||
alias.isNotEmpty ? '$alias #$terminalId' : '$peerId #$terminalId';
|
||||
return TabInfo(
|
||||
key: tabKey,
|
||||
label: tabLabel,
|
||||
selectedIcon: selectedIcon,
|
||||
unselectedIcon: unselectedIcon,
|
||||
onTabCloseButton: () async {
|
||||
if (await desktopTryShowTabAuditDialogCloseCancelled(
|
||||
id: tabKey,
|
||||
tabController: tabController,
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
// Close the terminal session first
|
||||
final ffi = TerminalConnectionManager.getExistingConnection(peerId);
|
||||
if (ffi != null) {
|
||||
@@ -409,6 +417,14 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
||||
|
||||
Future<bool> handleWindowCloseButton() async {
|
||||
final connLength = tabController.state.value.tabs.length;
|
||||
if (connLength == 1) {
|
||||
if (await desktopTryShowTabAuditDialogCloseCancelled(
|
||||
id: tabController.state.value.tabs[0].key,
|
||||
tabController: tabController,
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (connLength <= 1) {
|
||||
tabController.clear();
|
||||
return true;
|
||||
|
||||
@@ -6,7 +6,6 @@ import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hbb/common/widgets/remote_input.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
import 'package:flutter_hbb/models/state_model.dart';
|
||||
|
||||
import '../../consts.dart';
|
||||
@@ -77,6 +76,7 @@ class _ViewCameraPageState extends State<ViewCameraPage>
|
||||
String keyboardMode = "legacy";
|
||||
bool _isWindowBlur = false;
|
||||
final _cursorOverImage = false.obs;
|
||||
final _uniqueKey = UniqueKey();
|
||||
|
||||
var _blockableOverlayState = BlockableOverlayState();
|
||||
|
||||
@@ -124,9 +124,7 @@ class _ViewCameraPageState extends State<ViewCameraPage>
|
||||
_ffi.dialogManager
|
||||
.showLoading(translate('Connecting...'), onCancel: closeConnection);
|
||||
});
|
||||
if (!isLinux) {
|
||||
WakelockPlus.enable();
|
||||
}
|
||||
WakelockManager.enable(_uniqueKey);
|
||||
|
||||
_ffi.ffiModel.updateEventListener(sessionId, widget.id);
|
||||
if (!isWeb) bind.pluginSyncUi(syncTo: kAppTypeDesktopRemote);
|
||||
@@ -185,26 +183,20 @@ class _ViewCameraPageState extends State<ViewCameraPage>
|
||||
if (isWindows) {
|
||||
_isWindowBlur = false;
|
||||
}
|
||||
if (!isLinux) {
|
||||
WakelockPlus.enable();
|
||||
}
|
||||
WakelockManager.enable(_uniqueKey);
|
||||
}
|
||||
|
||||
// When the window is unminimized, onWindowMaximize or onWindowRestore can be called when the old state was maximized or not.
|
||||
@override
|
||||
void onWindowMaximize() {
|
||||
super.onWindowMaximize();
|
||||
if (!isLinux) {
|
||||
WakelockPlus.enable();
|
||||
}
|
||||
WakelockManager.enable(_uniqueKey);
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowMinimize() {
|
||||
super.onWindowMinimize();
|
||||
if (!isLinux) {
|
||||
WakelockPlus.disable();
|
||||
}
|
||||
WakelockManager.disable(_uniqueKey);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -247,9 +239,7 @@ class _ViewCameraPageState extends State<ViewCameraPage>
|
||||
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
|
||||
overlays: SystemUiOverlay.values);
|
||||
}
|
||||
if (!isLinux) {
|
||||
await WakelockPlus.disable();
|
||||
}
|
||||
WakelockManager.disable(_uniqueKey);
|
||||
await Get.delete<FFI>(tag: widget.id);
|
||||
removeSharedStates(widget.id);
|
||||
}
|
||||
@@ -360,7 +350,7 @@ class _ViewCameraPageState extends State<ViewCameraPage>
|
||||
super.build(context);
|
||||
return WillPopScope(
|
||||
onWillPop: () async {
|
||||
clientClose(sessionId, _ffi.dialogManager);
|
||||
clientClose(sessionId, _ffi);
|
||||
return false;
|
||||
},
|
||||
child: MultiProvider(providers: [
|
||||
@@ -465,7 +455,6 @@ class _ViewCameraPageState extends State<ViewCameraPage>
|
||||
() => _ffi.ffiModel.pi.isSet.isFalse
|
||||
? Container(color: Colors.transparent)
|
||||
: Obx(() {
|
||||
widget.toolbarState.initShow(sessionId);
|
||||
_ffi.textureModel.updateCurrentDisplay(peerDisplay.value);
|
||||
return ImagePaint(
|
||||
id: widget.id,
|
||||
@@ -527,7 +516,7 @@ class _ImagePaintState extends State<ImagePaint> {
|
||||
|
||||
bool isViewOriginal() => c.viewStyle.style == kRemoteViewStyleOriginal;
|
||||
|
||||
if (c.imageOverflow.isTrue && c.scrollStyle == ScrollStyle.scrollbar) {
|
||||
if (c.imageOverflow.isTrue && c.scrollStyle != ScrollStyle.scrollauto) {
|
||||
final paintWidth = c.getDisplayWidth() * s;
|
||||
final paintHeight = c.getDisplayHeight() * s;
|
||||
final paintSize = Size(paintWidth, paintHeight);
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:desktop_multi_window/desktop_multi_window.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/common/shared_state.dart';
|
||||
import 'package:flutter_hbb/common/widgets/dialog.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/models/input_model.dart';
|
||||
import 'package:flutter_hbb/models/state_model.dart';
|
||||
@@ -79,7 +80,15 @@ class _ViewCameraTabPageState extends State<ViewCameraTabPage> {
|
||||
label: peerId!,
|
||||
selectedIcon: selectedIcon,
|
||||
unselectedIcon: unselectedIcon,
|
||||
onTabCloseButton: () => tabController.closeBy(peerId),
|
||||
onTabCloseButton: () async {
|
||||
if (await desktopTryShowTabAuditDialogCloseCancelled(
|
||||
id: peerId!,
|
||||
tabController: tabController,
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
tabController.closeBy(peerId!);
|
||||
},
|
||||
page: ViewCameraPage(
|
||||
key: ValueKey(peerId),
|
||||
id: peerId!,
|
||||
@@ -241,11 +250,11 @@ class _ViewCameraTabPageState extends State<ViewCameraTabPage> {
|
||||
MenuEntryButton<String>(
|
||||
childBuilder: (TextStyle? style) => Obx(() => Text(
|
||||
translate(
|
||||
toolbarState.show.isTrue ? 'Hide Toolbar' : 'Show Toolbar'),
|
||||
toolbarState.hide.isTrue ? 'Show Toolbar' : 'Hide Toolbar'),
|
||||
style: style,
|
||||
)),
|
||||
proc: () {
|
||||
toolbarState.switchShow(sessionId);
|
||||
toolbarState.switchHide(sessionId);
|
||||
cancelFunc();
|
||||
},
|
||||
padding: padding,
|
||||
@@ -287,7 +296,13 @@ class _ViewCameraTabPageState extends State<ViewCameraTabPage> {
|
||||
translate('Close'),
|
||||
style: style,
|
||||
),
|
||||
proc: () {
|
||||
proc: () async {
|
||||
if (await desktopTryShowTabAuditDialogCloseCancelled(
|
||||
id: key,
|
||||
tabController: tabController,
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
tabController.closeBy(key);
|
||||
cancelFunc();
|
||||
},
|
||||
@@ -340,6 +355,14 @@ class _ViewCameraTabPageState extends State<ViewCameraTabPage> {
|
||||
|
||||
Future<bool> handleWindowCloseButton() async {
|
||||
final connLength = tabController.length;
|
||||
if (connLength == 1) {
|
||||
if (await desktopTryShowTabAuditDialogCloseCancelled(
|
||||
id: tabController.state.value.tabs[0].key,
|
||||
tabController: tabController,
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (connLength <= 1) {
|
||||
tabController.clear();
|
||||
return true;
|
||||
@@ -393,7 +416,15 @@ class _ViewCameraTabPageState extends State<ViewCameraTabPage> {
|
||||
label: id,
|
||||
selectedIcon: selectedIcon,
|
||||
unselectedIcon: unselectedIcon,
|
||||
onTabCloseButton: () => tabController.closeBy(id),
|
||||
onTabCloseButton: () async {
|
||||
if (await desktopTryShowTabAuditDialogCloseCancelled(
|
||||
id: id,
|
||||
tabController: tabController,
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
tabController.closeBy(id);
|
||||
},
|
||||
page: ViewCameraPage(
|
||||
key: ValueKey(id),
|
||||
id: id,
|
||||
|
||||
@@ -26,12 +26,17 @@ import '../../common/shared_state.dart';
|
||||
import './popup_menu.dart';
|
||||
import './kb_layout_type_chooser.dart';
|
||||
import 'package:flutter_hbb/utils/scale.dart';
|
||||
import 'package:flutter_hbb/common/widgets/custom_scale_base.dart';
|
||||
|
||||
class ToolbarState {
|
||||
late RxBool _pin;
|
||||
|
||||
bool isShowInited = false;
|
||||
RxBool show = false.obs;
|
||||
RxBool collapse = false.obs;
|
||||
RxBool hide = false.obs;
|
||||
|
||||
// Track initialization state to prevent flickering
|
||||
final RxBool initialized = false.obs;
|
||||
bool _isInitializing = false;
|
||||
|
||||
ToolbarState() {
|
||||
_pin = RxBool(false);
|
||||
@@ -52,19 +57,39 @@ class ToolbarState {
|
||||
|
||||
bool get pin => _pin.value;
|
||||
|
||||
switchShow(SessionID sessionId) async {
|
||||
bind.sessionToggleOption(
|
||||
sessionId: sessionId, value: kOptionCollapseToolbar);
|
||||
show.value = !show.value;
|
||||
/// Initialize all toolbar states from session options.
|
||||
/// This should be called once when the toolbar is first created.
|
||||
Future<void> init(SessionID sessionId) async {
|
||||
if (initialized.value || _isInitializing) return;
|
||||
_isInitializing = true;
|
||||
|
||||
try {
|
||||
// Load both states in parallel for better performance
|
||||
final results = await Future.wait([
|
||||
bind.sessionGetToggleOption(
|
||||
sessionId: sessionId, arg: kOptionCollapseToolbar),
|
||||
bind.sessionGetToggleOption(
|
||||
sessionId: sessionId, arg: kOptionHideToolbar),
|
||||
]);
|
||||
|
||||
collapse.value = results[0] ?? false;
|
||||
hide.value = results[1] ?? false;
|
||||
} finally {
|
||||
_isInitializing = false;
|
||||
initialized.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
initShow(SessionID sessionId) async {
|
||||
if (!isShowInited) {
|
||||
show.value = !(await bind.sessionGetToggleOption(
|
||||
sessionId: sessionId, arg: kOptionCollapseToolbar) ??
|
||||
false);
|
||||
isShowInited = true;
|
||||
}
|
||||
switchCollapse(SessionID sessionId) async {
|
||||
bind.sessionToggleOption(
|
||||
sessionId: sessionId, value: kOptionCollapseToolbar);
|
||||
collapse.value = !collapse.value;
|
||||
}
|
||||
|
||||
// Switch hide state for entire toolbar visibility
|
||||
switchHide(SessionID sessionId) async {
|
||||
bind.sessionToggleOption(sessionId: sessionId, value: kOptionHideToolbar);
|
||||
hide.value = !hide.value;
|
||||
}
|
||||
|
||||
switchPin() async {
|
||||
@@ -236,7 +261,8 @@ class _RemoteToolbarState extends State<RemoteToolbar> {
|
||||
// setState(() {});
|
||||
}
|
||||
|
||||
RxBool get show => widget.state.show;
|
||||
RxBool get collapse => widget.state.collapse;
|
||||
RxBool get hide => widget.state.hide;
|
||||
bool get pin => widget.state.pin;
|
||||
|
||||
PeerInfo get pi => widget.ffi.ffiModel.pi;
|
||||
@@ -257,6 +283,8 @@ class _RemoteToolbarState extends State<RemoteToolbar> {
|
||||
arg: 'remote-menubar-drag-x') ??
|
||||
'0.5') ??
|
||||
0.5;
|
||||
// Initialize toolbar states (collapse, hide) from session options
|
||||
widget.state.init(widget.ffi.sessionId);
|
||||
});
|
||||
|
||||
_debouncerHide = Debouncer<int>(
|
||||
@@ -276,8 +304,8 @@ class _RemoteToolbarState extends State<RemoteToolbar> {
|
||||
}
|
||||
|
||||
_debouncerHideProc(int v) {
|
||||
if (!pin && show.isTrue && _isCursorOverImage && _dragging.isFalse) {
|
||||
show.value = false;
|
||||
if (!pin && collapse.isFalse && _isCursorOverImage && _dragging.isFalse) {
|
||||
collapse.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,17 +318,27 @@ class _RemoteToolbarState extends State<RemoteToolbar> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: Obx(() => show.value
|
||||
? _buildToolbar(context)
|
||||
: _buildDraggableShowHide(context)),
|
||||
);
|
||||
return Obx(() {
|
||||
// Wait for initialization to complete to prevent flickering
|
||||
if (!widget.state.initialized.value) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
// If toolbar is hidden, return empty widget
|
||||
if (hide.value) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: collapse.isFalse
|
||||
? _buildToolbar(context)
|
||||
: _buildDraggableCollapse(context),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildDraggableShowHide(BuildContext context) {
|
||||
Widget _buildDraggableCollapse(BuildContext context) {
|
||||
return Obx(() {
|
||||
if (show.isTrue && _dragging.isFalse) {
|
||||
if (collapse.isFalse && _dragging.isFalse) {
|
||||
triggerAutoHide();
|
||||
}
|
||||
final borderRadius = BorderRadius.vertical(
|
||||
@@ -397,7 +435,7 @@ class _RemoteToolbarState extends State<RemoteToolbar> {
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildDraggableShowHide(context),
|
||||
_buildDraggableCollapse(context),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -510,7 +548,7 @@ class _MonitorMenu extends StatelessWidget {
|
||||
menuStyle: MenuStyle(
|
||||
padding:
|
||||
MaterialStatePropertyAll(EdgeInsets.symmetric(horizontal: 6))),
|
||||
menuChildrenGetter: () => [buildMonitorSubmenuWidget(context)]);
|
||||
menuChildrenGetter: (_) => [buildMonitorSubmenuWidget(context)]);
|
||||
}
|
||||
|
||||
Widget buildMultiMonitorMenu(BuildContext context) {
|
||||
@@ -721,7 +759,7 @@ class _ControlMenu extends StatelessWidget {
|
||||
color: _ToolbarTheme.blueColor,
|
||||
hoverColor: _ToolbarTheme.hoverBlueColor,
|
||||
ffi: ffi,
|
||||
menuChildrenGetter: () => toolbarControls(context, id, ffi).map((e) {
|
||||
menuChildrenGetter: (_) => toolbarControls(context, id, ffi).map((e) {
|
||||
if (e.divider) {
|
||||
return Divider();
|
||||
} else {
|
||||
@@ -932,12 +970,13 @@ class _DisplayMenuState extends State<_DisplayMenu> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
_screenAdjustor.updateScreen();
|
||||
menuChildrenGetter() {
|
||||
menuChildrenGetter(_IconSubmenuButtonState state) {
|
||||
final menuChildren = <Widget>[
|
||||
_screenAdjustor.adjustWindow(context),
|
||||
viewStyle(customPercent: _customPercent),
|
||||
scrollStyle(),
|
||||
scrollStyle(state, colorScheme),
|
||||
imageQuality(),
|
||||
codec(),
|
||||
if (ffi.connType == ConnType.defaultConn)
|
||||
@@ -1012,14 +1051,14 @@ class _DisplayMenuState extends State<_DisplayMenu> {
|
||||
return Column(children: [
|
||||
...v.map((e) {
|
||||
final isCustom = e.value == kRemoteViewStyleCustom;
|
||||
final child = isCustom
|
||||
? Text(translate('Scale custom'))
|
||||
: e.child;
|
||||
final child =
|
||||
isCustom ? Text(translate('Scale custom')) : e.child;
|
||||
// Whether the current selection is already custom
|
||||
final bool isGroupCustomSelected =
|
||||
e.groupValue == kRemoteViewStyleCustom;
|
||||
// Keep menu open when switching INTO custom so the slider is visible immediately
|
||||
final bool keepOpenForThisItem = isCustom && !isGroupCustomSelected;
|
||||
final bool keepOpenForThisItem =
|
||||
isCustom && !isGroupCustomSelected;
|
||||
return RdoMenuButton<String>(
|
||||
value: e.value,
|
||||
groupValue: e.groupValue,
|
||||
@@ -1038,7 +1077,8 @@ class _DisplayMenuState extends State<_DisplayMenu> {
|
||||
}).toList(),
|
||||
// Only show a divider when custom is NOT selected
|
||||
if (!isCustomSelected) Divider(),
|
||||
_customControlsIfCustomSelected(onChanged: (v) => customPercent.value = v),
|
||||
_customControlsIfCustomSelected(
|
||||
onChanged: (v) => customPercent.value = v),
|
||||
]);
|
||||
});
|
||||
}
|
||||
@@ -1053,12 +1093,14 @@ class _DisplayMenuState extends State<_DisplayMenu> {
|
||||
duration: Duration(milliseconds: 220),
|
||||
switchInCurve: Curves.easeOut,
|
||||
switchOutCurve: Curves.easeIn,
|
||||
child: isCustom ? _CustomScaleMenuControls(ffi: ffi, onChanged: onChanged) : SizedBox.shrink(),
|
||||
child: isCustom
|
||||
? _CustomScaleMenuControls(ffi: ffi, onChanged: onChanged)
|
||||
: SizedBox.shrink(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
scrollStyle() {
|
||||
scrollStyle(_IconSubmenuButtonState state, ColorScheme colorScheme) {
|
||||
return futureBuilder(future: () async {
|
||||
final viewStyle =
|
||||
await bind.sessionGetViewStyle(sessionId: ffi.sessionId) ?? '';
|
||||
@@ -1066,16 +1108,34 @@ class _DisplayMenuState extends State<_DisplayMenu> {
|
||||
viewStyle == kRemoteViewStyleCustom;
|
||||
final scrollStyle =
|
||||
await bind.sessionGetScrollStyle(sessionId: ffi.sessionId) ?? '';
|
||||
return {'visible': visible, 'scrollStyle': scrollStyle};
|
||||
final edgeScrollEdgeThickness = await bind
|
||||
.sessionGetEdgeScrollEdgeThickness(sessionId: ffi.sessionId);
|
||||
return {
|
||||
'visible': visible,
|
||||
'scrollStyle': scrollStyle,
|
||||
'edgeScrollEdgeThickness': edgeScrollEdgeThickness,
|
||||
};
|
||||
}(), hasData: (data) {
|
||||
final visible = data['visible'] as bool;
|
||||
if (!visible) return Offstage();
|
||||
final groupValue = data['scrollStyle'] as String;
|
||||
onChange(String? value) async {
|
||||
final edgeScrollEdgeThickness = data['edgeScrollEdgeThickness'] as int;
|
||||
|
||||
onChangeScrollStyle(String? value) async {
|
||||
if (value == null) return;
|
||||
await bind.sessionSetScrollStyle(
|
||||
sessionId: ffi.sessionId, value: value);
|
||||
widget.ffi.canvasModel.updateScrollStyle();
|
||||
state.setState(() {});
|
||||
}
|
||||
|
||||
onChangeEdgeScrollEdgeThickness(double? value) async {
|
||||
if (value == null) return;
|
||||
final newThickness = value.round();
|
||||
await bind.sessionSetEdgeScrollEdgeThickness(
|
||||
sessionId: ffi.sessionId, value: newThickness);
|
||||
widget.ffi.canvasModel.updateEdgeScrollEdgeThickness(newThickness);
|
||||
state.setState(() {});
|
||||
}
|
||||
|
||||
return Obx(() => Column(children: [
|
||||
@@ -1084,8 +1144,9 @@ class _DisplayMenuState extends State<_DisplayMenu> {
|
||||
value: kRemoteScrollStyleAuto,
|
||||
groupValue: groupValue,
|
||||
onChanged: widget.ffi.canvasModel.imageOverflow.value
|
||||
? (value) => onChange(value)
|
||||
? (value) => onChangeScrollStyle(value)
|
||||
: null,
|
||||
closeOnActivate: groupValue != kRemoteScrollStyleEdge,
|
||||
ffi: widget.ffi,
|
||||
),
|
||||
RdoMenuButton<String>(
|
||||
@@ -1093,10 +1154,30 @@ class _DisplayMenuState extends State<_DisplayMenu> {
|
||||
value: kRemoteScrollStyleBar,
|
||||
groupValue: groupValue,
|
||||
onChanged: widget.ffi.canvasModel.imageOverflow.value
|
||||
? (value) => onChange(value)
|
||||
? (value) => onChangeScrollStyle(value)
|
||||
: null,
|
||||
closeOnActivate: groupValue != kRemoteScrollStyleEdge,
|
||||
ffi: widget.ffi,
|
||||
),
|
||||
if (!isWeb) ...[
|
||||
RdoMenuButton<String>(
|
||||
child: Text(translate('ScrollEdge')),
|
||||
value: kRemoteScrollStyleEdge,
|
||||
groupValue: groupValue,
|
||||
closeOnActivate: false,
|
||||
onChanged: widget.ffi.canvasModel.imageOverflow.value
|
||||
? (value) => onChangeScrollStyle(value)
|
||||
: null,
|
||||
ffi: widget.ffi,
|
||||
),
|
||||
Offstage(
|
||||
offstage: groupValue != kRemoteScrollStyleEdge,
|
||||
child: EdgeThicknessControl(
|
||||
value: edgeScrollEdgeThickness.toDouble(),
|
||||
onChanged: onChangeEdgeScrollEdgeThickness,
|
||||
colorScheme: colorScheme,
|
||||
)),
|
||||
],
|
||||
Divider(),
|
||||
]));
|
||||
});
|
||||
@@ -1183,132 +1264,21 @@ class _DisplayMenuState extends State<_DisplayMenu> {
|
||||
class _CustomScaleMenuControls extends StatefulWidget {
|
||||
final FFI ffi;
|
||||
final ValueChanged<int>? onChanged;
|
||||
const _CustomScaleMenuControls({Key? key, required this.ffi, this.onChanged}) : super(key: key);
|
||||
const _CustomScaleMenuControls({Key? key, required this.ffi, this.onChanged})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
State<_CustomScaleMenuControls> createState() => _CustomScaleMenuControlsState();
|
||||
State<_CustomScaleMenuControls> createState() =>
|
||||
_CustomScaleMenuControlsState();
|
||||
}
|
||||
|
||||
class _CustomScaleMenuControlsState extends State<_CustomScaleMenuControls> {
|
||||
late int _value;
|
||||
late final Debouncer<int> _debouncerScale;
|
||||
// Normalized slider position in [0, 1]. We map it nonlinearly to percent.
|
||||
double _pos = 0.0;
|
||||
|
||||
// Piecewise mapping constants (moved to consts.dart)
|
||||
static const int _minPercent = kScaleCustomMinPercent;
|
||||
static const int _pivotPercent = kScaleCustomPivotPercent; // 100% should be at 1/3 of track
|
||||
static const int _maxPercent = kScaleCustomMaxPercent;
|
||||
static const double _pivotPos = kScaleCustomPivotPos; // first 1/3 → up to 100%
|
||||
static const double _detentEpsilon = kScaleCustomDetentEpsilon; // snap range around pivot (~0.6%)
|
||||
|
||||
// Clamp helper for local use
|
||||
int _clamp(int v) => clampCustomScalePercent(v);
|
||||
|
||||
// Map normalized position [0,1] → percent [5,1000] with 100 at 1/3 width.
|
||||
int _mapPosToPercent(double p) {
|
||||
if (p <= 0.0) return _minPercent;
|
||||
if (p >= 1.0) return _maxPercent;
|
||||
if (p <= _pivotPos) {
|
||||
final q = p / _pivotPos; // 0..1
|
||||
final v = _minPercent + q * (_pivotPercent - _minPercent);
|
||||
return _clamp(v.round());
|
||||
} else {
|
||||
final q = (p - _pivotPos) / (1.0 - _pivotPos); // 0..1
|
||||
final v = _pivotPercent + q * (_maxPercent - _pivotPercent);
|
||||
return _clamp(v.round());
|
||||
}
|
||||
}
|
||||
|
||||
// Map percent [5,1000] → normalized position [0,1]
|
||||
double _mapPercentToPos(int percent) {
|
||||
final p = _clamp(percent);
|
||||
if (p <= _pivotPercent) {
|
||||
final q = (p - _minPercent) / (_pivotPercent - _minPercent);
|
||||
return q * _pivotPos;
|
||||
} else {
|
||||
final q = (p - _pivotPercent) / (_maxPercent - _pivotPercent);
|
||||
return _pivotPos + q * (1.0 - _pivotPos);
|
||||
}
|
||||
}
|
||||
|
||||
// Snap normalized position to the pivot when close to it
|
||||
double _snapNormalizedPos(double p) {
|
||||
if ((p - _pivotPos).abs() <= _detentEpsilon) return _pivotPos;
|
||||
if (p < 0.0) return 0.0;
|
||||
if (p > 1.0) return 1.0;
|
||||
return p;
|
||||
}
|
||||
class _CustomScaleMenuControlsState
|
||||
extends CustomScaleControls<_CustomScaleMenuControls> {
|
||||
@override
|
||||
FFI get ffi => widget.ffi;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_value = 100;
|
||||
_debouncerScale = Debouncer<int>(
|
||||
kDebounceCustomScaleDuration,
|
||||
onChanged: (v) async {
|
||||
await _apply(v);
|
||||
},
|
||||
initialValue: _value,
|
||||
);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
try {
|
||||
final v = await getSessionCustomScalePercent(widget.ffi.sessionId);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_value = v;
|
||||
_pos = _mapPercentToPos(v);
|
||||
});
|
||||
}
|
||||
} catch (e, st) {
|
||||
debugPrint('[CustomScale] Failed to get initial value: $e');
|
||||
debugPrintStack(stackTrace: st);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Future<void> _apply(int v) async {
|
||||
v = clampCustomScalePercent(v);
|
||||
setState(() {
|
||||
_value = v;
|
||||
});
|
||||
try {
|
||||
await bind.sessionSetFlutterOption(
|
||||
sessionId: widget.ffi.sessionId,
|
||||
k: kCustomScalePercentKey,
|
||||
v: v.toString());
|
||||
final curStyle = await bind.sessionGetViewStyle(sessionId: widget.ffi.sessionId);
|
||||
if (curStyle != kRemoteViewStyleCustom) {
|
||||
await bind.sessionSetViewStyle(
|
||||
sessionId: widget.ffi.sessionId, value: kRemoteViewStyleCustom);
|
||||
}
|
||||
await widget.ffi.canvasModel.updateViewStyle();
|
||||
if (isMobile) {
|
||||
HapticFeedback.selectionClick();
|
||||
}
|
||||
widget.onChanged?.call(v);
|
||||
} catch (e, st) {
|
||||
debugPrint('[CustomScale] Apply failed: $e');
|
||||
debugPrintStack(stackTrace: st);
|
||||
}
|
||||
}
|
||||
|
||||
void _nudge(int delta) {
|
||||
final next = _clamp(_value + delta);
|
||||
setState(() {
|
||||
_value = next;
|
||||
_pos = _mapPercentToPos(next);
|
||||
});
|
||||
widget.onChanged?.call(next);
|
||||
_debouncerScale.value = next;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debouncerScale.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
ValueChanged<int>? get onScaleChanged => widget.onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -1317,7 +1287,7 @@ class _CustomScaleMenuControlsState extends State<_CustomScaleMenuControls> {
|
||||
|
||||
final sliderControl = Semantics(
|
||||
label: translate('Custom scale slider'),
|
||||
value: '$_value%',
|
||||
value: '$scaleValue%',
|
||||
child: SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
activeTrackColor: colorScheme.primary,
|
||||
@@ -1325,34 +1295,24 @@ class _CustomScaleMenuControlsState extends State<_CustomScaleMenuControls> {
|
||||
overlayColor: colorScheme.primary.withOpacity(0.1),
|
||||
showValueIndicator: ShowValueIndicator.never,
|
||||
thumbShape: _RectValueThumbShape(
|
||||
min: _minPercent.toDouble(),
|
||||
max: _maxPercent.toDouble(),
|
||||
min: CustomScaleControls.minPercent.toDouble(),
|
||||
max: CustomScaleControls.maxPercent.toDouble(),
|
||||
width: 52,
|
||||
height: 24,
|
||||
radius: 4,
|
||||
// Display the mapped percent for the current normalized value
|
||||
displayValueForNormalized: (t) => _mapPosToPercent(t),
|
||||
displayValueForNormalized: (t) => mapPosToPercent(t),
|
||||
),
|
||||
),
|
||||
child: Slider(
|
||||
value: _pos,
|
||||
value: scalePos,
|
||||
min: 0.0,
|
||||
max: 1.0,
|
||||
// Use a wide range of divisions (calculated as (_maxPercent - _minPercent)) to provide ~1% precision increments.
|
||||
// Use a wide range of divisions (calculated as (CustomScaleControls.maxPercent - CustomScaleControls.minPercent)) to provide ~1% precision increments.
|
||||
// This allows users to set precise scale values. Lower values would require more fine-tuning via the +/- buttons, which is undesirable for big ranges.
|
||||
divisions: (_maxPercent - _minPercent).round(),
|
||||
onChanged: (v) {
|
||||
final snapped = _snapNormalizedPos(v);
|
||||
final next = _mapPosToPercent(snapped);
|
||||
if (next != _value || snapped != _pos) {
|
||||
setState(() {
|
||||
_pos = snapped;
|
||||
_value = next;
|
||||
});
|
||||
widget.onChanged?.call(next);
|
||||
_debouncerScale.value = next;
|
||||
}
|
||||
},
|
||||
divisions:
|
||||
(CustomScaleControls.maxPercent - CustomScaleControls.minPercent)
|
||||
.round(),
|
||||
onChanged: onSliderChanged,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -1368,7 +1328,7 @@ class _CustomScaleMenuControlsState extends State<_CustomScaleMenuControls> {
|
||||
padding: EdgeInsets.all(1),
|
||||
constraints: smallBtnConstraints,
|
||||
icon: const Icon(Icons.remove),
|
||||
onPressed: () => _nudge(-1),
|
||||
onPressed: () => nudgeScale(-1),
|
||||
),
|
||||
),
|
||||
Expanded(child: sliderControl),
|
||||
@@ -1379,7 +1339,7 @@ class _CustomScaleMenuControlsState extends State<_CustomScaleMenuControls> {
|
||||
padding: EdgeInsets.all(1),
|
||||
constraints: smallBtnConstraints,
|
||||
icon: const Icon(Icons.add),
|
||||
onPressed: () => _nudge(1),
|
||||
onPressed: () => nudgeScale(1),
|
||||
),
|
||||
),
|
||||
]),
|
||||
@@ -1397,6 +1357,7 @@ class _RectValueThumbShape extends SliderComponentShape {
|
||||
final double width;
|
||||
final double height;
|
||||
final double radius;
|
||||
final String unit;
|
||||
// Optional mapper to compute display value from normalized position [0,1]
|
||||
// If null, falls back to linear interpolation between min and max.
|
||||
final int Function(double normalized)? displayValueForNormalized;
|
||||
@@ -1408,6 +1369,7 @@ class _RectValueThumbShape extends SliderComponentShape {
|
||||
required this.height,
|
||||
required this.radius,
|
||||
this.displayValueForNormalized,
|
||||
this.unit = '%',
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -1448,12 +1410,12 @@ class _RectValueThumbShape extends SliderComponentShape {
|
||||
final Paint paint = Paint()..color = fillColor;
|
||||
canvas.drawRRect(rrect, paint);
|
||||
|
||||
// Compute displayed percent from normalized slider value.
|
||||
final int percent = displayValueForNormalized != null
|
||||
// Compute displayed value from normalized slider value.
|
||||
final int displayValue = displayValueForNormalized != null
|
||||
? displayValueForNormalized!(value)
|
||||
: (min + value * (max - min)).round();
|
||||
final TextSpan span = TextSpan(
|
||||
text: '$percent%',
|
||||
text: '$displayValue$unit',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
@@ -1466,7 +1428,8 @@ class _RectValueThumbShape extends SliderComponentShape {
|
||||
textDirection: textDirection,
|
||||
);
|
||||
tp.layout(maxWidth: width - 4);
|
||||
tp.paint(canvas, Offset(center.dx - tp.width / 2, center.dy - tp.height / 2));
|
||||
tp.paint(
|
||||
canvas, Offset(center.dx - tp.width / 2, center.dy - tp.height / 2));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1802,17 +1765,27 @@ class _KeyboardMenu extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
var ffiModel = Provider.of<FfiModel>(context);
|
||||
if (!ffiModel.keyboard) return Offstage();
|
||||
toolbarToggles() => toolbarKeyboardToggles(ffi)
|
||||
.map((e) => CkbMenuButton(
|
||||
value: e.value, onChanged: e.onChanged, child: e.child, ffi: ffi))
|
||||
.toList();
|
||||
toolbarToggles() {
|
||||
final toggles = toolbarKeyboardToggles(ffi)
|
||||
.map((e) => CkbMenuButton(
|
||||
value: e.value,
|
||||
onChanged: e.onChanged,
|
||||
child: e.child,
|
||||
ffi: ffi) as Widget)
|
||||
.toList();
|
||||
if (toggles.isNotEmpty) {
|
||||
toggles.add(Divider());
|
||||
}
|
||||
return toggles;
|
||||
}
|
||||
|
||||
return _IconSubmenuButton(
|
||||
tooltip: 'Keyboard Settings',
|
||||
svg: "assets/keyboard.svg",
|
||||
svg: "assets/keyboard_mouse.svg",
|
||||
ffi: ffi,
|
||||
color: _ToolbarTheme.blueColor,
|
||||
hoverColor: _ToolbarTheme.hoverBlueColor,
|
||||
menuChildrenGetter: () => [
|
||||
menuChildrenGetter: (_) => [
|
||||
keyboardMode(),
|
||||
localKeyboardType(),
|
||||
inputSource(),
|
||||
@@ -1888,8 +1861,18 @@ class _KeyboardMenu extends StatelessWidget {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (pi.isWayland && mode.key != kKeyMapMode) {
|
||||
continue;
|
||||
if (pi.isWayland) {
|
||||
// Legacy mode is hidden on desktop control side because dead keys
|
||||
// don't work properly on Wayland. When the control side is mobile,
|
||||
// Legacy mode is used automatically (mobile always sends Legacy events).
|
||||
if (mode.key == kKeyLegacyMode) {
|
||||
continue;
|
||||
}
|
||||
// Translate mode requires server >= 1.4.6.
|
||||
if (mode.key == kKeyTranslateMode &&
|
||||
versionCmp(pi.version, '1.4.6') < 0) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
var text = translate(mode.menu);
|
||||
@@ -2077,7 +2060,7 @@ class _ChatMenuState extends State<_ChatMenu> {
|
||||
ffi: widget.ffi,
|
||||
color: _ToolbarTheme.blueColor,
|
||||
hoverColor: _ToolbarTheme.hoverBlueColor,
|
||||
menuChildrenGetter: () => [textChat(), voiceCall()]);
|
||||
menuChildrenGetter: (_) => [textChat(), voiceCall()]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2133,7 +2116,7 @@ class _VoiceCallMenu extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
menuChildrenGetter() {
|
||||
menuChildrenGetter(_IconSubmenuButtonState state) {
|
||||
final audioInput = AudioInput(
|
||||
builder: (devices, currentDevice, setDevice) {
|
||||
return Column(
|
||||
@@ -2239,7 +2222,12 @@ class _CloseMenu extends StatelessWidget {
|
||||
return _IconMenuButton(
|
||||
assetName: 'assets/close.svg',
|
||||
tooltip: 'Close',
|
||||
onPressed: () => closeConnection(id: id),
|
||||
onPressed: () async {
|
||||
if (await showConnEndAuditDialogCloseCanceled(ffi: ffi)) {
|
||||
return;
|
||||
}
|
||||
closeConnection(id: id);
|
||||
},
|
||||
color: _ToolbarTheme.redColor,
|
||||
hoverColor: _ToolbarTheme.hoverRedColor,
|
||||
);
|
||||
@@ -2333,7 +2321,7 @@ class _IconSubmenuButton extends StatefulWidget {
|
||||
final Widget? icon;
|
||||
final Color color;
|
||||
final Color hoverColor;
|
||||
final List<Widget> Function() menuChildrenGetter;
|
||||
final List<Widget> Function(_IconSubmenuButtonState state) menuChildrenGetter;
|
||||
final MenuStyle? menuStyle;
|
||||
final FFI? ffi;
|
||||
final double? width;
|
||||
@@ -2358,6 +2346,11 @@ class _IconSubmenuButton extends StatefulWidget {
|
||||
class _IconSubmenuButtonState extends State<_IconSubmenuButton> {
|
||||
bool hover = false;
|
||||
|
||||
@override // discard @protected
|
||||
void setState(VoidCallback fn) {
|
||||
super.setState(fn);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
assert(widget.svg != null || widget.icon != null);
|
||||
@@ -2390,7 +2383,7 @@ class _IconSubmenuButtonState extends State<_IconSubmenuButton> {
|
||||
),
|
||||
child: icon))),
|
||||
menuChildren: widget
|
||||
.menuChildrenGetter()
|
||||
.menuChildrenGetter(this)
|
||||
.map((e) => _buildPointerTrackWidget(e, widget.ffi))
|
||||
.toList()));
|
||||
return MenuBar(children: [
|
||||
@@ -2555,7 +2548,7 @@ class _DraggableShowHideState extends State<_DraggableShowHide> {
|
||||
double left = 0.0;
|
||||
double right = 1.0;
|
||||
|
||||
RxBool get show => widget.toolbarState.show;
|
||||
RxBool get collapse => widget.toolbarState.collapse;
|
||||
|
||||
@override
|
||||
initState() {
|
||||
@@ -2678,20 +2671,20 @@ class _DraggableShowHideState extends State<_DraggableShowHide> {
|
||||
)),
|
||||
buttonWrapper(
|
||||
() => setState(() {
|
||||
widget.toolbarState.switchShow(widget.sessionId);
|
||||
widget.toolbarState.switchCollapse(widget.sessionId);
|
||||
}),
|
||||
Obx((() => Tooltip(
|
||||
message:
|
||||
translate(show.isTrue ? 'Hide Toolbar' : 'Show Toolbar'),
|
||||
message: translate(
|
||||
collapse.isFalse ? 'Hide Toolbar' : 'Show Toolbar'),
|
||||
child: Icon(
|
||||
show.isTrue ? Icons.expand_less : Icons.expand_more,
|
||||
collapse.isFalse ? Icons.expand_less : Icons.expand_more,
|
||||
size: iconSize,
|
||||
),
|
||||
))),
|
||||
),
|
||||
if (isWebDesktop)
|
||||
Obx(() {
|
||||
if (show.isTrue) {
|
||||
if (collapse.isFalse) {
|
||||
return Offstage();
|
||||
} else {
|
||||
return buttonWrapper(
|
||||
@@ -2753,3 +2746,56 @@ Widget _buildPointerTrackWidget(Widget child, FFI? ffi) {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class EdgeThicknessControl extends StatelessWidget {
|
||||
final double value;
|
||||
final ValueChanged<double>? onChanged;
|
||||
final ColorScheme? colorScheme;
|
||||
|
||||
const EdgeThicknessControl({
|
||||
Key? key,
|
||||
required this.value,
|
||||
this.onChanged,
|
||||
this.colorScheme,
|
||||
}) : super(key: key);
|
||||
|
||||
static const double kMin = 20;
|
||||
static const double kMax = 150;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = this.colorScheme ?? Theme.of(context).colorScheme;
|
||||
|
||||
final slider = SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
activeTrackColor: colorScheme.primary,
|
||||
thumbColor: colorScheme.primary,
|
||||
overlayColor: colorScheme.primary.withOpacity(0.1),
|
||||
showValueIndicator: ShowValueIndicator.never,
|
||||
thumbShape: _RectValueThumbShape(
|
||||
min: EdgeThicknessControl.kMin,
|
||||
max: EdgeThicknessControl.kMax,
|
||||
width: 52,
|
||||
height: 24,
|
||||
radius: 4,
|
||||
unit: 'px',
|
||||
),
|
||||
),
|
||||
child: Semantics(
|
||||
value: value.toInt().toString(),
|
||||
child: Slider(
|
||||
value: value,
|
||||
min: EdgeThicknessControl.kMin,
|
||||
max: EdgeThicknessControl.kMax,
|
||||
divisions:
|
||||
(EdgeThicknessControl.kMax - EdgeThicknessControl.kMin).round(),
|
||||
semanticFormatterCallback: (double newValue) =>
|
||||
"${newValue.round()}px",
|
||||
onChanged: onChanged,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return slider;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -593,7 +593,6 @@ class _DesktopTabState extends State<DesktopTab>
|
||||
|
||||
Widget _buildBar() {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
|
||||
@@ -5,14 +5,17 @@ import 'package:flutter_breadcrumb/flutter_breadcrumb.dart';
|
||||
import 'package:flutter_hbb/models/file_model.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:toggle_switch/toggle_switch.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
import '../../common.dart';
|
||||
import '../../common/widgets/dialog.dart';
|
||||
|
||||
class FileManagerPage extends StatefulWidget {
|
||||
FileManagerPage(
|
||||
{Key? key, required this.id, this.password, this.isSharedPassword, this.forceRelay})
|
||||
{Key? key,
|
||||
required this.id,
|
||||
this.password,
|
||||
this.isSharedPassword,
|
||||
this.forceRelay})
|
||||
: super(key: key);
|
||||
final String id;
|
||||
final String? password;
|
||||
@@ -68,6 +71,7 @@ class _FileManagerPageState extends State<FileManagerPage> {
|
||||
showLocal ? model.localController : model.remoteController;
|
||||
FileDirectory get currentDir => currentFileController.directory.value;
|
||||
DirectoryOptions get currentOptions => currentFileController.options.value;
|
||||
final _uniqueKey = UniqueKey();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -82,7 +86,7 @@ class _FileManagerPageState extends State<FileManagerPage> {
|
||||
.showLoading(translate('Connecting...'), onCancel: closeConnection);
|
||||
});
|
||||
gFFI.ffiModel.updateEventListener(gFFI.sessionId, widget.id);
|
||||
WakelockPlus.enable();
|
||||
WakelockManager.enable(_uniqueKey);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -90,7 +94,7 @@ class _FileManagerPageState extends State<FileManagerPage> {
|
||||
model.close().whenComplete(() {
|
||||
gFFI.close();
|
||||
gFFI.dialogManager.dismissAll();
|
||||
WakelockPlus.disable();
|
||||
WakelockManager.disable(_uniqueKey);
|
||||
});
|
||||
model.jobController.clear();
|
||||
super.dispose();
|
||||
@@ -113,8 +117,7 @@ class _FileManagerPageState extends State<FileManagerPage> {
|
||||
leading: Row(children: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.close),
|
||||
onPressed: () =>
|
||||
clientClose(gFFI.sessionId, gFFI.dialogManager)),
|
||||
onPressed: () => clientClose(gFFI.sessionId, gFFI)),
|
||||
]),
|
||||
centerTitle: true,
|
||||
title: ToggleSwitch(
|
||||
@@ -352,15 +355,21 @@ class _FileManagerPageState extends State<FileManagerPage> {
|
||||
return Offstage();
|
||||
}
|
||||
|
||||
switch (jobTable.last.state) {
|
||||
// Find the first job that is in progress (the one actually transferring data)
|
||||
// Rust backend processes jobs sequentially, so the first inProgress job is the active one
|
||||
final activeJob = jobTable
|
||||
.firstWhereOrNull((job) => job.state == JobState.inProgress) ??
|
||||
jobTable.last;
|
||||
|
||||
switch (activeJob.state) {
|
||||
case JobState.inProgress:
|
||||
return BottomSheetBody(
|
||||
leading: CircularProgressIndicator(),
|
||||
title: translate("Waiting"),
|
||||
text:
|
||||
"${translate("Speed")}: ${readableFileSize(jobTable.last.speed)}/s",
|
||||
"${translate("Speed")}: ${readableFileSize(activeJob.speed)}/s",
|
||||
onCanceled: () {
|
||||
model.jobController.cancelJob(jobTable.last.id);
|
||||
model.jobController.cancelJob(activeJob.id);
|
||||
jobTable.clear();
|
||||
},
|
||||
);
|
||||
@@ -368,7 +377,7 @@ class _FileManagerPageState extends State<FileManagerPage> {
|
||||
return BottomSheetBody(
|
||||
leading: Icon(Icons.check),
|
||||
title: "${translate("Successful")}!",
|
||||
text: jobTable.last.display(),
|
||||
text: activeJob.display(),
|
||||
onCanceled: () => jobTable.clear(),
|
||||
);
|
||||
case JobState.error:
|
||||
@@ -591,67 +600,67 @@ class _FileManagerViewState extends State<FileManagerView> {
|
||||
|
||||
Widget headTools() => Container(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: Obx(() {
|
||||
final home = controller.options.value.home;
|
||||
final isWindows = controller.options.value.isWindows;
|
||||
return BreadCrumb(
|
||||
items: getPathBreadCrumbItems(controller.shortPath, isWindows,
|
||||
() => controller.goToHomeDirectory(), (list) {
|
||||
var path = "";
|
||||
if (home.startsWith(list[0])) {
|
||||
// absolute path
|
||||
for (var item in list) {
|
||||
path = PathUtil.join(path, item, isWindows);
|
||||
}
|
||||
} else {
|
||||
path += home;
|
||||
for (var item in list) {
|
||||
path = PathUtil.join(path, item, isWindows);
|
||||
}
|
||||
}
|
||||
controller.openDirectory(path);
|
||||
}),
|
||||
divider: Icon(Icons.chevron_right),
|
||||
overflow: ScrollableOverflow(controller: _breadCrumbScroller),
|
||||
);
|
||||
})),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: Obx(() {
|
||||
final home = controller.options.value.home;
|
||||
final isWindows = controller.options.value.isWindows;
|
||||
return BreadCrumb(
|
||||
items: getPathBreadCrumbItems(controller.shortPath, isWindows,
|
||||
() => controller.goToHomeDirectory(), (list) {
|
||||
var path = "";
|
||||
if (home.startsWith(list[0])) {
|
||||
// absolute path
|
||||
for (var item in list) {
|
||||
path = PathUtil.join(path, item, isWindows);
|
||||
}
|
||||
IconButton(
|
||||
icon: Icon(Icons.arrow_back),
|
||||
onPressed: controller.goBack,
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.arrow_upward),
|
||||
onPressed: controller.goToParentDirectory,
|
||||
),
|
||||
PopupMenuButton<SortBy>(
|
||||
tooltip: "",
|
||||
icon: Icon(Icons.sort),
|
||||
itemBuilder: (context) {
|
||||
return SortBy.values
|
||||
.map((e) => PopupMenuItem(
|
||||
child: Text(translate(e.toString())),
|
||||
value: e,
|
||||
))
|
||||
.toList();
|
||||
},
|
||||
onSelected: (sortBy) {
|
||||
// If selecting the same sort option, flip the order
|
||||
// If selecting a different sort option, use ascending order
|
||||
if (controller.sortBy.value == sortBy) {
|
||||
ascending.value = !controller.sortAscending;
|
||||
} else {
|
||||
path += home;
|
||||
for (var item in list) {
|
||||
path = PathUtil.join(path, item, isWindows);
|
||||
}
|
||||
ascending.value = true;
|
||||
}
|
||||
controller.openDirectory(path);
|
||||
controller.changeSortStyle(sortBy,
|
||||
ascending: ascending.value);
|
||||
}),
|
||||
divider: Icon(Icons.chevron_right),
|
||||
overflow: ScrollableOverflow(controller: _breadCrumbScroller),
|
||||
);
|
||||
})),
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.arrow_back),
|
||||
onPressed: controller.goBack,
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.arrow_upward),
|
||||
onPressed: controller.goToParentDirectory,
|
||||
),
|
||||
PopupMenuButton<SortBy>(
|
||||
tooltip: "",
|
||||
icon: Icon(Icons.sort),
|
||||
itemBuilder: (context) {
|
||||
return SortBy.values
|
||||
.map((e) => PopupMenuItem(
|
||||
child: Text(translate(e.toString())),
|
||||
value: e,
|
||||
))
|
||||
.toList();
|
||||
},
|
||||
onSelected: (sortBy) {
|
||||
// If selecting the same sort option, flip the order
|
||||
// If selecting a different sort option, use ascending order
|
||||
if (controller.sortBy.value == sortBy) {
|
||||
ascending.value = !controller.sortAscending;
|
||||
} else {
|
||||
ascending.value = true;
|
||||
}
|
||||
controller.changeSortStyle(sortBy, ascending: ascending.value);
|
||||
}
|
||||
),
|
||||
],
|
||||
)
|
||||
],
|
||||
));
|
||||
)
|
||||
],
|
||||
));
|
||||
|
||||
Widget listTail() => Obx(() => Container(
|
||||
height: 100,
|
||||
|
||||
@@ -14,7 +14,6 @@ import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
|
||||
import 'package:flutter_svg/svg.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
import '../../common.dart';
|
||||
import '../../common/widgets/overlay.dart';
|
||||
@@ -25,6 +24,7 @@ import '../../models/model.dart';
|
||||
import '../../models/platform_model.dart';
|
||||
import '../../utils/image.dart';
|
||||
import '../widgets/dialog.dart';
|
||||
import '../widgets/custom_scale_widget.dart';
|
||||
|
||||
final initText = '1' * 1024;
|
||||
|
||||
@@ -66,8 +66,9 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
||||
String _value = '';
|
||||
Orientation? _currentOrientation;
|
||||
double _viewInsetsBottom = 0;
|
||||
|
||||
final _uniqueKey = UniqueKey();
|
||||
Timer? _timerDidChangeMetrics;
|
||||
Timer? _iosKeyboardWorkaroundTimer;
|
||||
|
||||
final _blockableOverlayState = BlockableOverlayState();
|
||||
|
||||
@@ -104,9 +105,7 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
||||
gFFI.dialogManager
|
||||
.showLoading(translate('Connecting...'), onCancel: closeConnection);
|
||||
});
|
||||
if (!isWeb) {
|
||||
WakelockPlus.enable();
|
||||
}
|
||||
WakelockManager.enable(_uniqueKey);
|
||||
_physicalFocusNode.requestFocus();
|
||||
gFFI.inputModel.listenToMouse(true);
|
||||
gFFI.qualityMonitorModel.checkShowQualityMonitor(sessionId);
|
||||
@@ -142,12 +141,11 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
||||
await gFFI.close();
|
||||
_timer?.cancel();
|
||||
_timerDidChangeMetrics?.cancel();
|
||||
_iosKeyboardWorkaroundTimer?.cancel();
|
||||
gFFI.dialogManager.dismissAll();
|
||||
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
|
||||
overlays: SystemUiOverlay.values);
|
||||
if (!isWeb) {
|
||||
await WakelockPlus.disable();
|
||||
}
|
||||
WakelockManager.disable(_uniqueKey);
|
||||
await keyboardSubscription.cancel();
|
||||
removeSharedStates(widget.id);
|
||||
// `on_voice_call_closed` should be called when the connection is ended.
|
||||
@@ -210,7 +208,24 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
||||
gFFI.ffiModel.pi.version.isNotEmpty) {
|
||||
gFFI.invokeMethod("enable_soft_keyboard", false);
|
||||
}
|
||||
|
||||
// Workaround for iOS: physical keyboard input fails after virtual keyboard is hidden
|
||||
// https://github.com/flutter/flutter/issues/39900
|
||||
// https://github.com/rustdesk/rustdesk/discussions/11843#discussioncomment-13499698 - Virtual keyboard issue
|
||||
if (isIOS) {
|
||||
_iosKeyboardWorkaroundTimer?.cancel();
|
||||
_iosKeyboardWorkaroundTimer = Timer(Duration(milliseconds: 100), () {
|
||||
if (!mounted) return;
|
||||
_physicalFocusNode.unfocus();
|
||||
_iosKeyboardWorkaroundTimer = Timer(Duration(milliseconds: 50), () {
|
||||
if (!mounted) return;
|
||||
_physicalFocusNode.requestFocus();
|
||||
});
|
||||
});
|
||||
}
|
||||
} else {
|
||||
_iosKeyboardWorkaroundTimer?.cancel();
|
||||
_iosKeyboardWorkaroundTimer = null;
|
||||
_timer?.cancel();
|
||||
_timer = Timer(kMobileDelaySoftKeyboardFocus, () {
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
|
||||
@@ -365,7 +380,7 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
||||
|
||||
return WillPopScope(
|
||||
onWillPop: () async {
|
||||
clientClose(sessionId, gFFI.dialogManager);
|
||||
clientClose(sessionId, gFFI);
|
||||
return false;
|
||||
},
|
||||
child: Scaffold(
|
||||
@@ -483,7 +498,7 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
||||
color: Colors.white,
|
||||
icon: Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
clientClose(sessionId, gFFI.dialogManager);
|
||||
clientClose(sessionId, gFFI);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
@@ -568,7 +583,9 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
||||
}
|
||||
|
||||
bool get showCursorPaint =>
|
||||
!gFFI.ffiModel.isPeerAndroid && !gFFI.canvasModel.cursorEmbedded;
|
||||
!gFFI.ffiModel.isPeerAndroid &&
|
||||
!gFFI.canvasModel.cursorEmbedded &&
|
||||
!gFFI.inputModel.relativeMouseMode.value;
|
||||
|
||||
Widget getBodyForMobile() {
|
||||
final keyboardIsVisible = keyboardVisibilityController.isVisible;
|
||||
@@ -576,7 +593,7 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
||||
color: MyTheme.canvasColor,
|
||||
child: Stack(children: () {
|
||||
final paints = [
|
||||
ImagePaint(),
|
||||
ImagePaint(ffiModel: gFFI.ffiModel),
|
||||
Positioned(
|
||||
top: 10,
|
||||
right: 10,
|
||||
@@ -634,7 +651,7 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
||||
|
||||
Widget getBodyForDesktopWithListener() {
|
||||
final ffiModel = Provider.of<FfiModel>(context);
|
||||
var paints = <Widget>[ImagePaint()];
|
||||
var paints = <Widget>[ImagePaint(ffiModel: ffiModel)];
|
||||
if (showCursorPaint) {
|
||||
final cursor = bind.sessionGetToggleOptionSync(
|
||||
sessionId: sessionId, arg: 'show-remote-cursor');
|
||||
@@ -807,6 +824,7 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
||||
bind.mainSetLocalOption(key: kOptionTouchMode, value: v);
|
||||
},
|
||||
virtualMouseMode: gFFI.ffiModel.virtualMouseMode,
|
||||
inputModel: gFFI.inputModel,
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -1054,11 +1072,20 @@ class _KeyHelpToolsState extends State<KeyHelpTools> {
|
||||
}
|
||||
|
||||
class ImagePaint extends StatelessWidget {
|
||||
final FfiModel ffiModel;
|
||||
ImagePaint({Key? key, required this.ffiModel}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final m = Provider.of<ImageModel>(context);
|
||||
final c = Provider.of<CanvasModel>(context);
|
||||
var s = c.scale;
|
||||
if (ffiModel.isPeerLinux) {
|
||||
final displays = ffiModel.pi.getCurDisplays();
|
||||
if (displays.isNotEmpty) {
|
||||
s = s / displays[0].scale;
|
||||
}
|
||||
}
|
||||
final adjust = c.getAdjustY();
|
||||
return CustomPaint(
|
||||
painter: ImagePainter(
|
||||
@@ -1129,6 +1156,14 @@ void showOptions(
|
||||
if (pi.displays.length > 1 && pi.currentDisplay != kAllDisplayValue) {
|
||||
final cur = pi.currentDisplay;
|
||||
final children = <Widget>[];
|
||||
final isDarkTheme = MyTheme.currentThemeMode() == ThemeMode.dark;
|
||||
final numColorSelected = Colors.white;
|
||||
final numColorUnselected = isDarkTheme ? Colors.grey : Colors.black87;
|
||||
// We can't use `Theme.of(context).primaryColor` here, the color is:
|
||||
// - light theme: 0xff2196f3 (Colors.blue)
|
||||
// - dark theme: 0xff212121 (the canvas color?)
|
||||
final numBgSelected =
|
||||
Theme.of(context).colorScheme.primary.withOpacity(0.6);
|
||||
for (var i = 0; i < pi.displays.length; ++i) {
|
||||
children.add(InkWell(
|
||||
onTap: () {
|
||||
@@ -1142,13 +1177,12 @@ void showOptions(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Theme.of(context).hintColor),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
color: i == cur
|
||||
? Theme.of(context).primaryColor.withOpacity(0.6)
|
||||
: null),
|
||||
color: i == cur ? numBgSelected : null),
|
||||
child: Center(
|
||||
child: Text((i + 1).toString(),
|
||||
style: TextStyle(
|
||||
color: i == cur ? Colors.white : Colors.black87,
|
||||
color:
|
||||
i == cur ? numColorSelected : numColorUnselected,
|
||||
fontWeight: FontWeight.bold))))));
|
||||
}
|
||||
displays.add(Padding(
|
||||
@@ -1201,6 +1235,10 @@ void showOptions(
|
||||
if (v != null) viewStyle.value = v;
|
||||
}
|
||||
: null)),
|
||||
// Show custom scale controls when custom view style is selected
|
||||
Obx(() => viewStyle.value == kRemoteViewStyleCustom
|
||||
? MobileCustomScaleControls(ffi: gFFI)
|
||||
: const SizedBox.shrink()),
|
||||
const Divider(color: MyTheme.border),
|
||||
for (var e in imageQualityRadios)
|
||||
Obx(() => getRadio<String>(
|
||||
|
||||
@@ -156,7 +156,7 @@ class _ScanPageState extends State<ScanPage> {
|
||||
try {
|
||||
final sc = ServerConfig.decode(data.substring(7));
|
||||
Timer(Duration(milliseconds: 60), () {
|
||||
showServerSettingsWithValue(sc, gFFI.dialogManager);
|
||||
showServerSettingsWithValue(sc, gFFI.dialogManager, null);
|
||||
});
|
||||
} catch (e) {
|
||||
showToast('Invalid QR code');
|
||||
|
||||
@@ -61,12 +61,13 @@ class _DropDownAction extends StatelessWidget {
|
||||
final isAllowNumericOneTimePassword =
|
||||
gFFI.serverModel.allowNumericOneTimePassword;
|
||||
return [
|
||||
PopupMenuItem(
|
||||
enabled: gFFI.serverModel.connectStatus > 0,
|
||||
value: "changeID",
|
||||
child: Text(translate("Change ID")),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
if (!isChangeIdDisabled())
|
||||
PopupMenuItem(
|
||||
enabled: gFFI.serverModel.connectStatus > 0,
|
||||
value: "changeID",
|
||||
child: Text(translate("Change ID")),
|
||||
),
|
||||
if (!isChangeIdDisabled()) const PopupMenuDivider(),
|
||||
PopupMenuItem(
|
||||
value: 'AcceptSessionsViaPassword',
|
||||
child: listTile(
|
||||
@@ -87,7 +88,8 @@ class _DropDownAction extends StatelessWidget {
|
||||
),
|
||||
if (showPasswordOption) const PopupMenuDivider(),
|
||||
if (showPasswordOption &&
|
||||
verificationMethod != kUseTemporaryPassword)
|
||||
verificationMethod != kUseTemporaryPassword &&
|
||||
!isChangePermanentPasswordDisabled())
|
||||
PopupMenuItem(
|
||||
value: "setPermanentPassword",
|
||||
child: Text(translate("Set permanent password")),
|
||||
@@ -149,6 +151,10 @@ class _DropDownAction extends StatelessWidget {
|
||||
|
||||
if (value == kUsePermanentPassword &&
|
||||
(await bind.mainGetPermanentPassword()).isEmpty) {
|
||||
if (isChangePermanentPasswordDisabled()) {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
setPasswordDialog(notEmptyCallback: callback);
|
||||
} else {
|
||||
callback();
|
||||
@@ -648,9 +654,8 @@ class ConnectionManager extends StatelessWidget {
|
||||
return Column(
|
||||
children: serverModel.clients
|
||||
.map((client) => PaddingCard(
|
||||
title: translate(client.isFileTransfer
|
||||
? "Transfer file"
|
||||
: "Share screen"),
|
||||
title: translate(
|
||||
client.isFileTransfer ? "Transfer file" : "Share screen"),
|
||||
titleIcon: client.isFileTransfer
|
||||
? Icon(Icons.folder_outlined)
|
||||
: Icon(Icons.mobile_screen_share),
|
||||
|
||||
@@ -71,6 +71,7 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
||||
var _ignoreBatteryOpt = false;
|
||||
var _enableStartOnBoot = false;
|
||||
var _checkUpdateOnStartup = false;
|
||||
var _showTerminalExtraKeys = false;
|
||||
var _floatingWindowDisabled = false;
|
||||
var _keepScreenOn = KeepScreenOn.duringControlled; // relay on floating window
|
||||
var _enableAbr = false;
|
||||
@@ -94,7 +95,12 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
||||
var _hideWebSocket = false;
|
||||
var _enableTrustedDevices = false;
|
||||
var _enableUdpPunch = false;
|
||||
var _allowInsecureTlsFallback = false;
|
||||
var _disableUdp = false;
|
||||
var _enableIpv6Punch = false;
|
||||
var _isUsingPublicServer = false;
|
||||
var _allowAskForNoteAtEndOfConnection = false;
|
||||
var _preventSleepWhileConnected = true;
|
||||
|
||||
_SettingsState() {
|
||||
_enableAbr = option2bool(
|
||||
@@ -109,6 +115,9 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
||||
_enableHardwareCodec = option2bool(kOptionEnableHwcodec,
|
||||
bind.mainGetOptionSync(key: kOptionEnableHwcodec));
|
||||
_allowWebSocket = mainGetBoolOptionSync(kOptionAllowWebSocket);
|
||||
_allowInsecureTlsFallback =
|
||||
mainGetBoolOptionSync(kOptionAllowInsecureTLSFallback);
|
||||
_disableUdp = bind.mainGetOptionSync(key: kOptionDisableUdp) == 'Y';
|
||||
_autoRecordIncomingSession = option2bool(kOptionAllowAutoRecordIncoming,
|
||||
bind.mainGetOptionSync(key: kOptionAllowAutoRecordIncoming));
|
||||
_autoRecordOutgoingSession = option2bool(kOptionAllowAutoRecordOutgoing,
|
||||
@@ -130,6 +139,12 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
||||
_enableTrustedDevices = mainGetBoolOptionSync(kOptionEnableTrustedDevices);
|
||||
_enableUdpPunch = mainGetLocalBoolOptionSync(kOptionEnableUdpPunch);
|
||||
_enableIpv6Punch = mainGetLocalBoolOptionSync(kOptionEnableIpv6Punch);
|
||||
_allowAskForNoteAtEndOfConnection =
|
||||
mainGetLocalBoolOptionSync(kOptionAllowAskForNoteAtEndOfConnection);
|
||||
_preventSleepWhileConnected =
|
||||
mainGetLocalBoolOptionSync(kOptionKeepAwakeDuringOutgoingSessions);
|
||||
_showTerminalExtraKeys =
|
||||
mainGetLocalBoolOptionSync(kOptionEnableShowTerminalExtraKeys);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -200,6 +215,13 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
||||
update = true;
|
||||
_buildDate = buildDate;
|
||||
}
|
||||
|
||||
final isUsingPublicServer = await bind.mainIsUsingPublicServer();
|
||||
if (_isUsingPublicServer != isUsingPublicServer) {
|
||||
update = true;
|
||||
_isUsingPublicServer = isUsingPublicServer;
|
||||
}
|
||||
|
||||
if (update) {
|
||||
setState(() {});
|
||||
}
|
||||
@@ -586,6 +608,23 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
||||
);
|
||||
}
|
||||
|
||||
enhancementsTiles.add(
|
||||
SettingsTile.switchTile(
|
||||
initialValue: _showTerminalExtraKeys,
|
||||
title: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(translate('Show terminal extra keys')),
|
||||
]),
|
||||
onToggle: (bool v) async {
|
||||
await mainSetLocalBoolOption(kOptionEnableShowTerminalExtraKeys, v);
|
||||
final newValue =
|
||||
mainGetLocalBoolOptionSync(kOptionEnableShowTerminalExtraKeys);
|
||||
setState(() {
|
||||
_showTerminalExtraKeys = newValue;
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
onFloatingWindowChanged(bool toValue) async {
|
||||
if (toValue) {
|
||||
if (!await AndroidPermissionManager.check(kSystemAlertWindow)) {
|
||||
@@ -667,9 +706,12 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
||||
title: Text(translate('ID/Relay Server')),
|
||||
leading: Icon(Icons.cloud),
|
||||
onPressed: (context) {
|
||||
showServerSettings(gFFI.dialogManager);
|
||||
showServerSettings(gFFI.dialogManager, (callback) async {
|
||||
_isUsingPublicServer = await bind.mainIsUsingPublicServer();
|
||||
setState(callback);
|
||||
});
|
||||
}),
|
||||
if (!isIOS && !_hideNetwork && !_hideProxy)
|
||||
if (!_hideNetwork && !_hideProxy)
|
||||
SettingsTile(
|
||||
title: Text(translate('Socks5/Http(s) Proxy')),
|
||||
leading: Icon(Icons.network_ping),
|
||||
@@ -691,6 +733,38 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
||||
});
|
||||
},
|
||||
),
|
||||
if (!_isUsingPublicServer)
|
||||
SettingsTile.switchTile(
|
||||
title: Text(translate('Allow insecure TLS fallback')),
|
||||
initialValue: _allowInsecureTlsFallback,
|
||||
onToggle: isOptionFixed(kOptionAllowInsecureTLSFallback)
|
||||
? null
|
||||
: (v) async {
|
||||
await mainSetBoolOption(
|
||||
kOptionAllowInsecureTLSFallback, v);
|
||||
final newValue = mainGetBoolOptionSync(
|
||||
kOptionAllowInsecureTLSFallback);
|
||||
setState(() {
|
||||
_allowInsecureTlsFallback = newValue;
|
||||
});
|
||||
},
|
||||
),
|
||||
if (isAndroid && !outgoingOnly && !_isUsingPublicServer)
|
||||
SettingsTile.switchTile(
|
||||
title: Text(translate('Disable UDP')),
|
||||
initialValue: _disableUdp,
|
||||
onToggle: isOptionFixed(kOptionDisableUdp)
|
||||
? null
|
||||
: (v) async {
|
||||
await bind.mainSetOption(
|
||||
key: kOptionDisableUdp, value: v ? 'Y' : 'N');
|
||||
final newValue =
|
||||
bind.mainGetOptionSync(key: kOptionDisableUdp) == 'Y';
|
||||
setState(() {
|
||||
_disableUdp = newValue;
|
||||
});
|
||||
},
|
||||
),
|
||||
if (!incomingOnly)
|
||||
SettingsTile.switchTile(
|
||||
title: Text(translate('Enable UDP hole punching')),
|
||||
@@ -734,7 +808,36 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
||||
onPressed: (context) {
|
||||
showThemeSettings(gFFI.dialogManager);
|
||||
},
|
||||
)
|
||||
),
|
||||
if (!bind.isDisableAccount())
|
||||
SettingsTile.switchTile(
|
||||
title: Text(translate('note-at-conn-end-tip')),
|
||||
initialValue: _allowAskForNoteAtEndOfConnection,
|
||||
onToggle: (v) async {
|
||||
if (v && !gFFI.userModel.isLogin) {
|
||||
final res = await loginDialog();
|
||||
if (res != true) return;
|
||||
}
|
||||
await mainSetLocalBoolOption(
|
||||
kOptionAllowAskForNoteAtEndOfConnection, v);
|
||||
final newValue = mainGetLocalBoolOptionSync(
|
||||
kOptionAllowAskForNoteAtEndOfConnection);
|
||||
setState(() {
|
||||
_allowAskForNoteAtEndOfConnection = newValue;
|
||||
});
|
||||
},
|
||||
),
|
||||
if (!incomingOnly)
|
||||
SettingsTile.switchTile(
|
||||
title: Text(translate('keep-awake-during-outgoing-sessions-label')),
|
||||
initialValue: _preventSleepWhileConnected,
|
||||
onToggle: (v) async {
|
||||
await mainSetLocalBoolOption(kOptionKeepAwakeDuringOutgoingSessions, v);
|
||||
setState(() {
|
||||
_preventSleepWhileConnected = v;
|
||||
});
|
||||
},
|
||||
),
|
||||
]),
|
||||
if (isAndroid)
|
||||
SettingsSection(title: Text(translate('Hardware Codec')), tiles: [
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/common/widgets/dialog.dart';
|
||||
import 'package:flutter_hbb/models/model.dart';
|
||||
import 'package:flutter_hbb/models/terminal_model.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:xterm/xterm.dart';
|
||||
import '../../desktop/pages/terminal_connection_manager.dart';
|
||||
import '../../consts.dart';
|
||||
|
||||
class TerminalPage extends StatefulWidget {
|
||||
const TerminalPage({
|
||||
@@ -28,9 +33,18 @@ class TerminalPage extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _TerminalPageState extends State<TerminalPage>
|
||||
with AutomaticKeepAliveClientMixin {
|
||||
with AutomaticKeepAliveClientMixin, WidgetsBindingObserver {
|
||||
late FFI _ffi;
|
||||
late TerminalModel _terminalModel;
|
||||
double? _cellHeight;
|
||||
double _sysKeyboardHeight = 0;
|
||||
Timer? _keyboardDebounce;
|
||||
final GlobalKey _keyboardKey = GlobalKey();
|
||||
double _keyboardHeight = 0;
|
||||
late bool _showTerminalExtraKeys;
|
||||
// For iOS edge swipe gesture
|
||||
double _swipeStartX = 0;
|
||||
double _swipeCurrentX = 0;
|
||||
|
||||
// For web only.
|
||||
// 'monospace' does not work on web, use Google Fonts, `??` is only for null safety.
|
||||
@@ -38,9 +52,12 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
? (GoogleFonts.robotoMono().fontFamily ?? 'monospace')
|
||||
: 'monospace';
|
||||
|
||||
SessionID get sessionId => _ffi.sessionId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
|
||||
debugPrint(
|
||||
'[TerminalPage] Initializing terminal ${widget.terminalId} for peer ${widget.id}');
|
||||
@@ -59,13 +76,22 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
debugPrint(
|
||||
'[TerminalPage] Terminal model created for terminal ${widget.terminalId}');
|
||||
|
||||
_terminalModel.onResizeExternal = (w, h, pw, ph) {
|
||||
_cellHeight = ph * 1.0;
|
||||
};
|
||||
|
||||
// Register this terminal model with FFI for event routing
|
||||
_ffi.registerTerminalModel(widget.terminalId, _terminalModel);
|
||||
|
||||
_showTerminalExtraKeys = mainGetLocalBoolOptionSync(kOptionEnableShowTerminalExtraKeys);
|
||||
// Initialize terminal connection
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_ffi.dialogManager
|
||||
.showLoading(translate('Connecting...'), onCancel: closeConnection);
|
||||
|
||||
if (_showTerminalExtraKeys) {
|
||||
_updateKeyboardHeight();
|
||||
}
|
||||
});
|
||||
_ffi.ffiModel.updateEventListener(_ffi.sessionId, widget.id);
|
||||
}
|
||||
@@ -75,38 +101,325 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
// Unregister terminal model from FFI
|
||||
_ffi.unregisterTerminalModel(widget.terminalId);
|
||||
_terminalModel.dispose();
|
||||
_keyboardDebounce?.cancel();
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
TerminalConnectionManager.releaseConnection(widget.id);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeMetrics() {
|
||||
super.didChangeMetrics();
|
||||
|
||||
_keyboardDebounce?.cancel();
|
||||
_keyboardDebounce = Timer(const Duration(milliseconds: 20), () {
|
||||
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
|
||||
setState(() {
|
||||
_sysKeyboardHeight = bottomInset;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void _updateKeyboardHeight() {
|
||||
if (_keyboardKey.currentContext != null) {
|
||||
final renderBox = _keyboardKey.currentContext!.findRenderObject() as RenderBox;
|
||||
_keyboardHeight = renderBox.size.height;
|
||||
}
|
||||
}
|
||||
|
||||
EdgeInsets _calculatePadding(double heightPx) {
|
||||
if (_cellHeight == null) {
|
||||
return const EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0);
|
||||
}
|
||||
final realHeight = heightPx - _sysKeyboardHeight - _keyboardHeight;
|
||||
final rows = (realHeight / _cellHeight!).floor();
|
||||
final extraSpace = realHeight - rows * _cellHeight!;
|
||||
final topBottom = max(0.0, extraSpace / 2.0);
|
||||
return EdgeInsets.only(left: 5.0, right: 5.0, top: topBottom, bottom: topBottom + _sysKeyboardHeight + _keyboardHeight);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
return Scaffold(
|
||||
return WillPopScope(
|
||||
onWillPop: () async {
|
||||
clientClose(sessionId, _ffi);
|
||||
return false; // Prevent default back behavior
|
||||
},
|
||||
child: buildBody(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildBody() {
|
||||
final scaffold = Scaffold(
|
||||
resizeToAvoidBottomInset: false, // Disable automatic layout adjustment; manually control UI updates to prevent flickering when the keyboard shows/hides
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
body: TerminalView(
|
||||
_terminalModel.terminal,
|
||||
controller: _terminalModel.terminalController,
|
||||
autofocus: true,
|
||||
textStyle: _getTerminalStyle(),
|
||||
backgroundOpacity: 0.7,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0),
|
||||
onSecondaryTapDown: (details, offset) async {
|
||||
final selection = _terminalModel.terminalController.selection;
|
||||
if (selection != null) {
|
||||
final text = _terminalModel.terminal.buffer.getText(selection);
|
||||
_terminalModel.terminalController.clearSelection();
|
||||
await Clipboard.setData(ClipboardData(text: text));
|
||||
} else {
|
||||
final data = await Clipboard.getData('text/plain');
|
||||
final text = data?.text;
|
||||
if (text != null) {
|
||||
_terminalModel.terminal.paste(text);
|
||||
}
|
||||
}
|
||||
},
|
||||
body: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: SafeArea(
|
||||
top: true,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final heightPx = constraints.maxHeight;
|
||||
return TerminalView(
|
||||
_terminalModel.terminal,
|
||||
controller: _terminalModel.terminalController,
|
||||
autofocus: true,
|
||||
textStyle: _getTerminalStyle(),
|
||||
backgroundOpacity: 0.7,
|
||||
// The following comment is from xterm.dart source code:
|
||||
// Workaround to detect delete key for platforms and IMEs that do not
|
||||
// emit a hardware delete event. Preferred on mobile platforms. [false] by
|
||||
// default.
|
||||
//
|
||||
// Android works fine without this workaround.
|
||||
deleteDetection: isIOS,
|
||||
padding: _calculatePadding(heightPx),
|
||||
onSecondaryTapDown: (details, offset) async {
|
||||
final selection = _terminalModel.terminalController.selection;
|
||||
if (selection != null) {
|
||||
final text = _terminalModel.terminal.buffer.getText(selection);
|
||||
_terminalModel.terminalController.clearSelection();
|
||||
await Clipboard.setData(ClipboardData(text: text));
|
||||
} else {
|
||||
final data = await Clipboard.getData('text/plain');
|
||||
final text = data?.text;
|
||||
if (text != null) {
|
||||
_terminalModel.terminal.paste(text);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_showTerminalExtraKeys) _buildFloatingKeyboard(),
|
||||
// iOS-style circular close button in top-right corner
|
||||
if (isIOS) _buildCloseButton(),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
// Add iOS edge swipe gesture to exit (similar to Android back button)
|
||||
if (isIOS) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final screenWidth = constraints.maxWidth;
|
||||
// Base thresholds on screen width but clamp to reasonable logical pixel ranges
|
||||
// Edge detection region: ~10% of width, clamped between 20 and 80 logical pixels
|
||||
final edgeThreshold = (screenWidth * 0.1).clamp(20.0, 80.0);
|
||||
// Required horizontal movement: ~25% of width, clamped between 80 and 300 logical pixels
|
||||
final swipeThreshold = (screenWidth * 0.25).clamp(80.0, 300.0);
|
||||
|
||||
return RawGestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
gestures: <Type, GestureRecognizerFactory>{
|
||||
HorizontalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers<HorizontalDragGestureRecognizer>(
|
||||
() => HorizontalDragGestureRecognizer(
|
||||
debugOwner: this,
|
||||
// Only respond to touch input, exclude mouse/trackpad
|
||||
supportedDevices: kTouchBasedDeviceKinds,
|
||||
),
|
||||
(HorizontalDragGestureRecognizer instance) {
|
||||
instance
|
||||
// Capture initial touch-down position (before touch slop)
|
||||
..onDown = (details) {
|
||||
_swipeStartX = details.localPosition.dx;
|
||||
_swipeCurrentX = details.localPosition.dx;
|
||||
}
|
||||
..onUpdate = (details) {
|
||||
_swipeCurrentX = details.localPosition.dx;
|
||||
}
|
||||
..onEnd = (details) {
|
||||
// Check if swipe started from left edge and moved right
|
||||
if (_swipeStartX < edgeThreshold && (_swipeCurrentX - _swipeStartX) > swipeThreshold) {
|
||||
clientClose(sessionId, _ffi);
|
||||
}
|
||||
_swipeStartX = 0;
|
||||
_swipeCurrentX = 0;
|
||||
}
|
||||
..onCancel = () {
|
||||
_swipeStartX = 0;
|
||||
_swipeCurrentX = 0;
|
||||
};
|
||||
},
|
||||
),
|
||||
},
|
||||
child: scaffold,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return scaffold;
|
||||
}
|
||||
|
||||
Widget _buildCloseButton() {
|
||||
return Positioned(
|
||||
top: 0,
|
||||
right: 0,
|
||||
child: SafeArea(
|
||||
minimum: const EdgeInsets.only(
|
||||
top: 16, // iOS standard margin
|
||||
right: 16, // iOS standard margin
|
||||
),
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: translate('Close'),
|
||||
child: Container(
|
||||
width: 44, // iOS standard tap target size
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.5), // Half transparency
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
shape: const CircleBorder(),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
customBorder: const CircleBorder(),
|
||||
onTap: () {
|
||||
clientClose(sessionId, _ffi);
|
||||
},
|
||||
child: Tooltip(
|
||||
message: translate('Close'),
|
||||
child: const Icon(
|
||||
Icons.chevron_left, // iOS-style back arrow
|
||||
color: Colors.white,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFloatingKeyboard() {
|
||||
return AnimatedPositioned(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: _sysKeyboardHeight,
|
||||
child: Container(
|
||||
key: _keyboardKey,
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
padding: EdgeInsets.zero,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_buildKeyButton('Esc'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('/'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('|'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('Home'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('↑'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('End'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('PgUp'),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_buildKeyButton('Tab'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('Ctrl+C'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('~'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('←'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('↓'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('→'),
|
||||
const SizedBox(width: 2),
|
||||
_buildKeyButton('PgDn'),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildKeyButton(String label) {
|
||||
return ElevatedButton(
|
||||
onPressed: () {
|
||||
_sendKeyToTerminal(label);
|
||||
},
|
||||
child: Text(label),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(48, 32),
|
||||
padding: EdgeInsets.zero,
|
||||
textStyle: const TextStyle(fontSize: 12),
|
||||
backgroundColor: Theme.of(context).colorScheme.surfaceVariant,
|
||||
foregroundColor: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _sendKeyToTerminal(String key) {
|
||||
String? send;
|
||||
|
||||
switch (key) {
|
||||
case 'Esc':
|
||||
send = '\x1B';
|
||||
break;
|
||||
case 'Tab':
|
||||
send = '\t';
|
||||
break;
|
||||
case 'Ctrl+C':
|
||||
send = '\x03';
|
||||
break;
|
||||
|
||||
case '↑':
|
||||
send = '\x1B[A';
|
||||
break;
|
||||
case '↓':
|
||||
send = '\x1B[B';
|
||||
break;
|
||||
case '→':
|
||||
send = '\x1B[C';
|
||||
break;
|
||||
case '←':
|
||||
send = '\x1B[D';
|
||||
break;
|
||||
|
||||
case 'Home':
|
||||
send = '\x1B[H';
|
||||
break;
|
||||
case 'End':
|
||||
send = '\x1B[F';
|
||||
break;
|
||||
case 'PgUp':
|
||||
send = '\x1B[5~';
|
||||
break;
|
||||
case 'PgDn':
|
||||
send = '\x1B[6~';
|
||||
break;
|
||||
|
||||
default:
|
||||
send = key;
|
||||
break;
|
||||
}
|
||||
|
||||
if (send != null) {
|
||||
_terminalModel.sendVirtualKey(send);
|
||||
}
|
||||
}
|
||||
|
||||
// https://github.com/TerminalStudio/xterm.dart/issues/42#issuecomment-877495472
|
||||
|
||||
@@ -11,7 +11,6 @@ import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
|
||||
import 'package:flutter_svg/svg.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
import '../../common.dart';
|
||||
import '../../common/widgets/overlay.dart';
|
||||
@@ -62,7 +61,7 @@ class _ViewCameraPageState extends State<ViewCameraPage>
|
||||
bool _showGestureHelp = false;
|
||||
Orientation? _currentOrientation;
|
||||
double _viewInsetsBottom = 0;
|
||||
|
||||
final _uniqueKey = UniqueKey();
|
||||
Timer? _timerDidChangeMetrics;
|
||||
|
||||
final _blockableOverlayState = BlockableOverlayState();
|
||||
@@ -100,9 +99,7 @@ class _ViewCameraPageState extends State<ViewCameraPage>
|
||||
gFFI.dialogManager
|
||||
.showLoading(translate('Connecting...'), onCancel: closeConnection);
|
||||
});
|
||||
if (!isWeb) {
|
||||
WakelockPlus.enable();
|
||||
}
|
||||
WakelockManager.enable(_uniqueKey);
|
||||
_physicalFocusNode.requestFocus();
|
||||
gFFI.inputModel.listenToMouse(true);
|
||||
gFFI.qualityMonitorModel.checkShowQualityMonitor(sessionId);
|
||||
@@ -139,9 +136,7 @@ class _ViewCameraPageState extends State<ViewCameraPage>
|
||||
gFFI.dialogManager.dismissAll();
|
||||
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
|
||||
overlays: SystemUiOverlay.values);
|
||||
if (!isWeb) {
|
||||
await WakelockPlus.disable();
|
||||
}
|
||||
WakelockManager.disable(_uniqueKey);
|
||||
removeSharedStates(widget.id);
|
||||
// `on_voice_call_closed` should be called when the connection is ended.
|
||||
// The inner logic of `on_voice_call_closed` will check if the voice call is active.
|
||||
@@ -197,7 +192,7 @@ class _ViewCameraPageState extends State<ViewCameraPage>
|
||||
|
||||
return WillPopScope(
|
||||
onWillPop: () async {
|
||||
clientClose(sessionId, gFFI.dialogManager);
|
||||
clientClose(sessionId, gFFI);
|
||||
return false;
|
||||
},
|
||||
child: Scaffold(
|
||||
@@ -310,7 +305,7 @@ class _ViewCameraPageState extends State<ViewCameraPage>
|
||||
color: Colors.white,
|
||||
icon: Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
clientClose(sessionId, gFFI.dialogManager);
|
||||
clientClose(sessionId, gFFI);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
@@ -590,6 +585,14 @@ void showOptions(
|
||||
if (pi.displays.length > 1 && pi.currentDisplay != kAllDisplayValue) {
|
||||
final cur = pi.currentDisplay;
|
||||
final children = <Widget>[];
|
||||
final isDarkTheme = MyTheme.currentThemeMode() == ThemeMode.dark;
|
||||
final numColorSelected = Colors.white;
|
||||
final numColorUnselected = isDarkTheme ? Colors.grey : Colors.black87;
|
||||
// We can't use `Theme.of(context).primaryColor` here, the color is:
|
||||
// - light theme: 0xff2196f3 (Colors.blue)
|
||||
// - dark theme: 0xff212121 (the canvas color?)
|
||||
final numBgSelected =
|
||||
Theme.of(context).colorScheme.primary.withOpacity(0.6);
|
||||
for (var i = 0; i < pi.displays.length; ++i) {
|
||||
children.add(InkWell(
|
||||
onTap: () {
|
||||
@@ -603,13 +606,12 @@ void showOptions(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Theme.of(context).hintColor),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
color: i == cur
|
||||
? Theme.of(context).primaryColor.withOpacity(0.6)
|
||||
: null),
|
||||
color: i == cur ? numBgSelected : null),
|
||||
child: Center(
|
||||
child: Text((i + 1).toString(),
|
||||
style: TextStyle(
|
||||
color: i == cur ? Colors.white : Colors.black87,
|
||||
color:
|
||||
i == cur ? numColorSelected : numColorUnselected,
|
||||
fontWeight: FontWeight.bold))))));
|
||||
}
|
||||
displays.add(Padding(
|
||||
|
||||
71
flutter/lib/mobile/widgets/custom_scale_widget.dart
Normal file
71
flutter/lib/mobile/widgets/custom_scale_widget.dart
Normal file
@@ -0,0 +1,71 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hbb/models/model.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/common/widgets/custom_scale_base.dart';
|
||||
|
||||
class MobileCustomScaleControls extends StatefulWidget {
|
||||
final FFI ffi;
|
||||
final ValueChanged<int>? onChanged;
|
||||
const MobileCustomScaleControls({super.key, required this.ffi, this.onChanged});
|
||||
|
||||
@override
|
||||
State<MobileCustomScaleControls> createState() => _MobileCustomScaleControlsState();
|
||||
}
|
||||
|
||||
class _MobileCustomScaleControlsState extends CustomScaleControls<MobileCustomScaleControls> {
|
||||
@override
|
||||
FFI get ffi => widget.ffi;
|
||||
|
||||
@override
|
||||
ValueChanged<int>? get onScaleChanged => widget.onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Smaller button size for mobile
|
||||
const smallBtnConstraints = BoxConstraints(minWidth: 32, minHeight: 32);
|
||||
|
||||
final sliderControl = Slider(
|
||||
value: scalePos,
|
||||
min: 0.0,
|
||||
max: 1.0,
|
||||
divisions: (CustomScaleControls.maxPercent - CustomScaleControls.minPercent).round(),
|
||||
label: '$scaleValue%',
|
||||
onChanged: onSliderChanged,
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 8.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'${translate("Scale custom")}: $scaleValue%',
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
iconSize: 20,
|
||||
padding: const EdgeInsets.all(4),
|
||||
constraints: smallBtnConstraints,
|
||||
icon: const Icon(Icons.remove),
|
||||
tooltip: translate('Decrease'),
|
||||
onPressed: () => nudgeScale(-1),
|
||||
),
|
||||
Expanded(child: sliderControl),
|
||||
IconButton(
|
||||
iconSize: 20,
|
||||
padding: const EdgeInsets.all(4),
|
||||
constraints: smallBtnConstraints,
|
||||
icon: const Icon(Icons.add),
|
||||
tooltip: translate('Increase'),
|
||||
onPressed: () => nudgeScale(1),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -147,18 +147,22 @@ void setTemporaryPasswordLengthDialog(
|
||||
}, backDismiss: true, clickMaskDismiss: true);
|
||||
}
|
||||
|
||||
void showServerSettings(OverlayDialogManager dialogManager) async {
|
||||
void showServerSettings(OverlayDialogManager dialogManager,
|
||||
void Function(VoidCallback) setState) async {
|
||||
Map<String, dynamic> options = {};
|
||||
try {
|
||||
options = jsonDecode(await bind.mainGetOptions());
|
||||
} catch (e) {
|
||||
print("Invalid server config: $e");
|
||||
}
|
||||
showServerSettingsWithValue(ServerConfig.fromOptions(options), dialogManager);
|
||||
showServerSettingsWithValue(
|
||||
ServerConfig.fromOptions(options), dialogManager, setState);
|
||||
}
|
||||
|
||||
void showServerSettingsWithValue(
|
||||
ServerConfig serverConfig, OverlayDialogManager dialogManager) async {
|
||||
ServerConfig serverConfig,
|
||||
OverlayDialogManager dialogManager,
|
||||
void Function(VoidCallback)? upSetState) async {
|
||||
var isInProgress = false;
|
||||
final idCtrl = TextEditingController(text: serverConfig.idServer);
|
||||
final relayCtrl = TextEditingController(text: serverConfig.relayServer);
|
||||
@@ -288,6 +292,7 @@ void showServerSettingsWithValue(
|
||||
if (await submit()) {
|
||||
close();
|
||||
showToast(translate('Successful'));
|
||||
upSetState?.call(() {});
|
||||
} else {
|
||||
showToast(translate('Failed'));
|
||||
}
|
||||
|
||||
@@ -83,7 +83,10 @@ class _FloatingMouseWidgetsState extends State<FloatingMouseWidgets> {
|
||||
cursorModel: _cursorModel,
|
||||
),
|
||||
if (virtualMouseMode.showVirtualJoystick)
|
||||
VirtualJoystick(cursorModel: _cursorModel),
|
||||
VirtualJoystick(
|
||||
cursorModel: _cursorModel,
|
||||
inputModel: _inputModel,
|
||||
),
|
||||
FloatingLeftRightButton(
|
||||
isLeft: true,
|
||||
inputModel: _inputModel,
|
||||
@@ -674,12 +677,18 @@ class _QuarterCirclePainter extends CustomPainter {
|
||||
bool shouldRepaint(CustomPainter oldDelegate) => false;
|
||||
}
|
||||
|
||||
// Virtual joystick sends the absolute movement for now.
|
||||
// Maybe we need to change it to relative movement in the future.
|
||||
// Virtual joystick can send either absolute movement (via updatePan)
|
||||
// or relative movement (via sendMobileRelativeMouseMove) depending on the
|
||||
// InputModel.relativeMouseMode setting.
|
||||
class VirtualJoystick extends StatefulWidget {
|
||||
final CursorModel cursorModel;
|
||||
final InputModel inputModel;
|
||||
|
||||
const VirtualJoystick({super.key, required this.cursorModel});
|
||||
const VirtualJoystick({
|
||||
super.key,
|
||||
required this.cursorModel,
|
||||
required this.inputModel,
|
||||
});
|
||||
|
||||
@override
|
||||
State<VirtualJoystick> createState() => _VirtualJoystickState();
|
||||
@@ -694,6 +703,10 @@ class _VirtualJoystickState extends State<VirtualJoystick> {
|
||||
final double _moveStep = 3.0;
|
||||
final double _speed = 1.0;
|
||||
|
||||
/// Scale factor for relative mouse movement sensitivity.
|
||||
/// Higher values result in faster cursor movement on the remote machine.
|
||||
static const double _kRelativeMouseScale = 3.0;
|
||||
|
||||
// One-shot timer to detect a drag gesture
|
||||
Timer? _dragStartTimer;
|
||||
// Periodic timer for continuous movement
|
||||
@@ -701,6 +714,9 @@ class _VirtualJoystickState extends State<VirtualJoystick> {
|
||||
Size? _lastScreenSize;
|
||||
bool _isPressed = false;
|
||||
|
||||
/// Check if relative mouse mode is enabled.
|
||||
bool get _useRelativeMouse => widget.inputModel.relativeMouseMode.value;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -746,6 +762,18 @@ class _VirtualJoystickState extends State<VirtualJoystick> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Send movement delta to remote machine.
|
||||
/// Uses relative mouse mode if enabled, otherwise uses absolute updatePan.
|
||||
void _sendMovement(Offset delta) {
|
||||
if (_useRelativeMouse) {
|
||||
widget.inputModel.sendMobileRelativeMouseMove(
|
||||
delta.dx * _kRelativeMouseScale, delta.dy * _kRelativeMouseScale);
|
||||
} else {
|
||||
// In absolute mode, use cursorModel.updatePan which tracks position.
|
||||
widget.cursorModel.updatePan(delta, Offset.zero, false);
|
||||
}
|
||||
}
|
||||
|
||||
void _stopSendEventTimer() {
|
||||
_dragStartTimer?.cancel();
|
||||
_continuousMoveTimer?.cancel();
|
||||
@@ -773,7 +801,7 @@ class _VirtualJoystickState extends State<VirtualJoystick> {
|
||||
// The movement is small for a gentle start.
|
||||
final initialDelta = _offsetToPanDelta(_offset);
|
||||
if (initialDelta.distance > 0) {
|
||||
widget.cursorModel.updatePan(initialDelta, Offset.zero, false);
|
||||
_sendMovement(initialDelta);
|
||||
}
|
||||
|
||||
// 2. Start a one-shot timer to check if the user is holding for a drag.
|
||||
@@ -784,10 +812,7 @@ class _VirtualJoystickState extends State<VirtualJoystick> {
|
||||
_continuousMoveTimer =
|
||||
periodic_immediate(const Duration(milliseconds: 20), () async {
|
||||
if (_offset != Offset.zero) {
|
||||
widget.cursorModel.updatePan(
|
||||
_offsetToPanDelta(_offset) * _moveStep * _speed,
|
||||
Offset.zero,
|
||||
false);
|
||||
_sendMovement(_offsetToPanDelta(_offset) * _moveStep * _speed);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/models/input_model.dart';
|
||||
import 'package:flutter_hbb/models/model.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:toggle_switch/toggle_switch.dart';
|
||||
|
||||
class GestureIcons {
|
||||
@@ -39,11 +41,13 @@ class GestureHelp extends StatefulWidget {
|
||||
{Key? key,
|
||||
required this.touchMode,
|
||||
required this.onTouchModeChange,
|
||||
required this.virtualMouseMode})
|
||||
required this.virtualMouseMode,
|
||||
this.inputModel})
|
||||
: super(key: key);
|
||||
final bool touchMode;
|
||||
final OnTouchModeChange onTouchModeChange;
|
||||
final VirtualMouseMode virtualMouseMode;
|
||||
final InputModel? inputModel;
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() =>
|
||||
@@ -61,6 +65,14 @@ class _GestureHelpState extends State<GestureHelp> {
|
||||
_selectedIndex = _touchMode ? 1 : 0;
|
||||
}
|
||||
|
||||
/// Helper to exit relative mouse mode when certain conditions are met.
|
||||
/// This reduces code duplication across multiple UI callbacks.
|
||||
void _exitRelativeMouseModeIf(bool condition) {
|
||||
if (condition) {
|
||||
widget.inputModel?.setRelativeMouseMode(false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final size = MediaQuery.of(context).size;
|
||||
@@ -103,6 +115,8 @@ class _GestureHelpState extends State<GestureHelp> {
|
||||
_selectedIndex = index ?? 0;
|
||||
_touchMode = index == 0 ? false : true;
|
||||
widget.onTouchModeChange(_touchMode);
|
||||
// Exit relative mouse mode when switching to touch mode
|
||||
_exitRelativeMouseModeIf(_touchMode);
|
||||
}
|
||||
});
|
||||
},
|
||||
@@ -117,12 +131,18 @@ class _GestureHelpState extends State<GestureHelp> {
|
||||
onChanged: (value) async {
|
||||
if (value == null) return;
|
||||
await _virtualMouseMode.toggleVirtualMouse();
|
||||
// Exit relative mouse mode when virtual mouse is hidden
|
||||
_exitRelativeMouseModeIf(
|
||||
!_virtualMouseMode.showVirtualMouse);
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
InkWell(
|
||||
onTap: () async {
|
||||
await _virtualMouseMode.toggleVirtualMouse();
|
||||
// Exit relative mouse mode when virtual mouse is hidden
|
||||
_exitRelativeMouseModeIf(
|
||||
!_virtualMouseMode.showVirtualMouse);
|
||||
setState(() {});
|
||||
},
|
||||
child: Text(translate('Show virtual mouse')),
|
||||
@@ -196,6 +216,10 @@ class _GestureHelpState extends State<GestureHelp> {
|
||||
if (value == null) return;
|
||||
await _virtualMouseMode
|
||||
.toggleVirtualJoystick();
|
||||
// Exit relative mouse mode when joystick is hidden
|
||||
_exitRelativeMouseModeIf(
|
||||
!_virtualMouseMode
|
||||
.showVirtualJoystick);
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
@@ -203,6 +227,10 @@ class _GestureHelpState extends State<GestureHelp> {
|
||||
onTap: () async {
|
||||
await _virtualMouseMode
|
||||
.toggleVirtualJoystick();
|
||||
// Exit relative mouse mode when joystick is hidden
|
||||
_exitRelativeMouseModeIf(
|
||||
!_virtualMouseMode
|
||||
.showVirtualJoystick);
|
||||
setState(() {});
|
||||
},
|
||||
child: Text(
|
||||
@@ -211,6 +239,39 @@ class _GestureHelpState extends State<GestureHelp> {
|
||||
],
|
||||
)),
|
||||
),
|
||||
// Relative mouse mode option - only visible when joystick is shown
|
||||
if (!_touchMode &&
|
||||
_virtualMouseMode.showVirtualMouse &&
|
||||
_virtualMouseMode.showVirtualJoystick &&
|
||||
widget.inputModel != null)
|
||||
Obx(() => Transform.translate(
|
||||
offset: const Offset(-10.0, -24.0),
|
||||
child: Padding(
|
||||
// Indent further for 'Relative mouse mode'
|
||||
padding: const EdgeInsets.only(left: 48.0),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Checkbox(
|
||||
value: widget.inputModel!
|
||||
.relativeMouseMode.value,
|
||||
onChanged: (value) {
|
||||
if (value == null) return;
|
||||
widget.inputModel!
|
||||
.setRelativeMouseMode(value);
|
||||
},
|
||||
),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
widget.inputModel!
|
||||
.toggleRelativeMouseMode();
|
||||
},
|
||||
child: Text(
|
||||
translate('Relative mouse mode')),
|
||||
),
|
||||
],
|
||||
)),
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -202,6 +202,7 @@ class AbModel {
|
||||
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);
|
||||
if (resp.statusCode == 404) {
|
||||
debugPrint("HTTP 404, api server doesn't support shared address book");
|
||||
@@ -228,6 +229,7 @@ class AbModel {
|
||||
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);
|
||||
if (resp.statusCode == 404) {
|
||||
debugPrint("HTTP 404, current api server is legacy mode");
|
||||
@@ -269,6 +271,7 @@ class AbModel {
|
||||
});
|
||||
var headers = getHttpHeaders();
|
||||
headers['Content-Type'] = "application/json";
|
||||
_setEmptyBody(headers);
|
||||
final resp = await http.post(uri, headers: headers);
|
||||
Map<String, dynamic> json =
|
||||
_jsonDecodeRespMap(decode_http_response(resp), resp.statusCode);
|
||||
@@ -1012,16 +1015,8 @@ class LegacyAb extends BaseAb {
|
||||
var authHeaders = getHttpHeaders();
|
||||
authHeaders['Content-Type'] = "application/json";
|
||||
final body = jsonEncode({"data": jsonEncode(_serialize())});
|
||||
http.Response resp;
|
||||
// support compression
|
||||
if (licensedDevices > 0 && body.length > 1024) {
|
||||
authHeaders['Content-Encoding'] = "gzip";
|
||||
resp = await http.post(Uri.parse(api),
|
||||
headers: authHeaders, body: GZipCodec().encode(utf8.encode(body)));
|
||||
} else {
|
||||
resp =
|
||||
await http.post(Uri.parse(api), headers: authHeaders, body: body);
|
||||
}
|
||||
http.Response resp =
|
||||
await http.post(Uri.parse(api), headers: authHeaders, body: body);
|
||||
if (resp.statusCode == 200 &&
|
||||
(resp.body.isEmpty || resp.body.toLowerCase() == 'null')) {
|
||||
ret = true;
|
||||
@@ -1406,6 +1401,7 @@ class Ab extends BaseAb {
|
||||
});
|
||||
var headers = getHttpHeaders();
|
||||
headers['Content-Type'] = "application/json";
|
||||
_setEmptyBody(headers);
|
||||
final resp = await http.post(uri, headers: headers);
|
||||
statusCode = resp.statusCode;
|
||||
Map<String, dynamic> json =
|
||||
@@ -1463,6 +1459,7 @@ class Ab extends BaseAb {
|
||||
);
|
||||
var headers = getHttpHeaders();
|
||||
headers['Content-Type'] = "application/json";
|
||||
_setEmptyBody(headers);
|
||||
final resp = await http.post(uri, headers: headers);
|
||||
statusCode = resp.statusCode;
|
||||
List<dynamic> json =
|
||||
@@ -1977,3 +1974,8 @@ String _jsonDecodeActionResp(http.Response resp) {
|
||||
}
|
||||
return errMsg;
|
||||
}
|
||||
|
||||
// https://github.com/seanmonstar/reqwest/issues/838
|
||||
void _setEmptyBody(Map<String, String> headers) {
|
||||
headers['Content-Length'] = '0';
|
||||
}
|
||||
|
||||
@@ -275,7 +275,7 @@ class TransferJobSerdeData {
|
||||
: this(
|
||||
connId: d['connId'] ?? 0,
|
||||
id: int.tryParse(d['id'].toString()) ?? 0,
|
||||
path: d['path'] ?? '',
|
||||
path: d['dataSource'] ?? '',
|
||||
isRemote: d['isRemote'] ?? false,
|
||||
totalSize: d['totalSize'] ?? 0,
|
||||
finishedSize: d['finishedSize'] ?? 0,
|
||||
|
||||
@@ -113,6 +113,34 @@ class FileModel {
|
||||
fileFetcher.tryCompleteEmptyDirsTask(evt['value'], evt['is_local']);
|
||||
}
|
||||
|
||||
// This method fixes a deadlock that occurred when the previous code directly
|
||||
// called jobController.jobError(evt) in the job_error event handler.
|
||||
//
|
||||
// The problem with directly calling jobController.jobError():
|
||||
// 1. fetchDirectoryRecursiveToRemove(jobID) registers readRecursiveTasks[jobID]
|
||||
// and waits for completion
|
||||
// 2. If the remote has no permission (or some other errors), it returns a FileTransferError
|
||||
// 3. The error triggers job_error event, which called jobController.jobError()
|
||||
// 4. jobController.jobError() calls getJob(jobID) to find the job in jobTable
|
||||
// 5. But addDeleteDirJob() is called AFTER fetchDirectoryRecursiveToRemove(),
|
||||
// so the job doesn't exist yet in jobTable
|
||||
// 6. Result: jobController.jobError() does nothing useful, and
|
||||
// readRecursiveTasks[jobID] never completes, causing a 2s timeout
|
||||
//
|
||||
// Solution: Before calling jobController.jobError(), we first check if there's
|
||||
// a pending readRecursiveTasks with this ID and complete it with the error.
|
||||
void handleJobError(Map<String, dynamic> evt) {
|
||||
final id = int.tryParse(evt['id']?.toString() ?? '');
|
||||
if (id != null) {
|
||||
final err = evt['err']?.toString() ?? 'Unknown error';
|
||||
fileFetcher.tryCompleteRecursiveTaskWithError(id, err);
|
||||
}
|
||||
// Always call jobController.jobError(evt) to ensure all error events are processed,
|
||||
// even if the event does not have a valid job ID. This allows for generic error handling
|
||||
// or logging of unexpected errors.
|
||||
jobController.jobError(evt);
|
||||
}
|
||||
|
||||
Future<void> postOverrideFileConfirm(Map<String, dynamic> evt) async {
|
||||
evtLoop.pushEvent(
|
||||
_FileDialogEvent(WeakReference(this), FileDialogType.overwrite, evt));
|
||||
@@ -591,8 +619,21 @@ class FileController {
|
||||
} else if (item.isDirectory) {
|
||||
title = translate("Not an empty directory");
|
||||
dialogManager?.showLoading(translate("Waiting"));
|
||||
final fd = await fileFetcher.fetchDirectoryRecursiveToRemove(
|
||||
jobID, item.path, items.isLocal, true);
|
||||
final FileDirectory fd;
|
||||
try {
|
||||
fd = await fileFetcher.fetchDirectoryRecursiveToRemove(
|
||||
jobID, item.path, items.isLocal, true);
|
||||
} catch (e) {
|
||||
dialogManager?.dismissAll();
|
||||
final dm = dialogManager;
|
||||
if (dm != null) {
|
||||
msgBox(sessionId, 'custom-error-nook-nocancel-hasclose',
|
||||
translate("Error"), e.toString(), '', dm);
|
||||
} else {
|
||||
debugPrint("removeAction error msgbox failed: $e");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (fd.path.isEmpty) {
|
||||
fd.path = item.path;
|
||||
}
|
||||
@@ -606,7 +647,7 @@ class FileController {
|
||||
item.name,
|
||||
false);
|
||||
if (confirm == true) {
|
||||
sendRemoveEmptyDir(
|
||||
await sendRemoveEmptyDir(
|
||||
item.path,
|
||||
0,
|
||||
deleteJobId,
|
||||
@@ -647,7 +688,7 @@ class FileController {
|
||||
// handle remove res;
|
||||
if (item.isDirectory &&
|
||||
res['file_num'] == (entries.length - 1).toString()) {
|
||||
sendRemoveEmptyDir(item.path, i, deleteJobId);
|
||||
await sendRemoveEmptyDir(item.path, i, deleteJobId);
|
||||
}
|
||||
} else {
|
||||
jobController.updateJobStatus(deleteJobId,
|
||||
@@ -660,7 +701,7 @@ class FileController {
|
||||
final res = await jobController.jobResultListener.start();
|
||||
if (item.isDirectory &&
|
||||
res['file_num'] == (entries.length - 1).toString()) {
|
||||
sendRemoveEmptyDir(item.path, i, deleteJobId);
|
||||
await sendRemoveEmptyDir(item.path, i, deleteJobId);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -755,9 +796,9 @@ class FileController {
|
||||
fileNum: fileNum);
|
||||
}
|
||||
|
||||
void sendRemoveEmptyDir(String path, int fileNum, int actId) {
|
||||
Future<void> sendRemoveEmptyDir(String path, int fileNum, int actId) async {
|
||||
history.removeWhere((element) => element.contains(path));
|
||||
bind.sessionRemoveAllEmptyDirs(
|
||||
await bind.sessionRemoveAllEmptyDirs(
|
||||
sessionId: sessionId, actId: actId, path: path, isRemote: !isLocal);
|
||||
}
|
||||
|
||||
@@ -1275,6 +1316,15 @@ class FileFetcher {
|
||||
}
|
||||
}
|
||||
|
||||
// Complete a pending recursive read task with an error.
|
||||
// See FileModel.handleJobError() for why this is necessary.
|
||||
void tryCompleteRecursiveTaskWithError(int id, String error) {
|
||||
final completer = readRecursiveTasks.remove(id);
|
||||
if (completer != null && !completer.isCompleted) {
|
||||
completer.completeError(error);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<FileDirectory>> readEmptyDirs(
|
||||
String path, bool isLocal, bool showHidden) async {
|
||||
try {
|
||||
@@ -1438,6 +1488,10 @@ class JobProgress {
|
||||
var err = "";
|
||||
int lastTransferredSize = 0;
|
||||
|
||||
double get percent =>
|
||||
totalSize > 0 ? (finishedSize.toDouble() / totalSize) : 0.0;
|
||||
String get percentText => '${(percent * 100).toStringAsFixed(0)}%';
|
||||
|
||||
clear() {
|
||||
type = JobType.none;
|
||||
state = JobState.none;
|
||||
|
||||
@@ -14,6 +14,8 @@ import 'package:get/get.dart';
|
||||
|
||||
import '../../models/model.dart';
|
||||
import '../../models/platform_model.dart';
|
||||
import '../../models/state_model.dart';
|
||||
import 'relative_mouse_model.dart';
|
||||
import '../common.dart';
|
||||
import '../consts.dart';
|
||||
|
||||
@@ -42,8 +44,7 @@ class CanvasCoords {
|
||||
'scale': scale,
|
||||
'scrollX': scrollX,
|
||||
'scrollY': scrollY,
|
||||
'scrollStyle':
|
||||
scrollStyle == ScrollStyle.scrollauto ? 'scrollauto' : 'scrollbar',
|
||||
'scrollStyle': scrollStyle.toJson(),
|
||||
'size': {
|
||||
'w': size.width,
|
||||
'h': size.height,
|
||||
@@ -58,9 +59,8 @@ class CanvasCoords {
|
||||
model.scale = json['scale'];
|
||||
model.scrollX = json['scrollX'];
|
||||
model.scrollY = json['scrollY'];
|
||||
model.scrollStyle = json['scrollStyle'] == 'scrollauto'
|
||||
? ScrollStyle.scrollauto
|
||||
: ScrollStyle.scrollbar;
|
||||
model.scrollStyle =
|
||||
ScrollStyle.fromJson(json['scrollStyle'], ScrollStyle.scrollauto);
|
||||
model.size = Size(json['size']['w'], json['size']['h']);
|
||||
return model;
|
||||
}
|
||||
@@ -352,15 +352,28 @@ class InputModel {
|
||||
double _trackpadSpeedInner = kDefaultTrackpadSpeed / 100.0;
|
||||
var _trackpadScrollUnsent = Offset.zero;
|
||||
|
||||
// Mobile relative mouse delta accumulators (for slow/fine movements).
|
||||
double _mobileDeltaRemainderX = 0.0;
|
||||
double _mobileDeltaRemainderY = 0.0;
|
||||
|
||||
var _lastScale = 1.0;
|
||||
|
||||
bool _pointerMovedAfterEnter = false;
|
||||
bool _pointerInsideImage = false;
|
||||
|
||||
// mouse
|
||||
final isPhysicalMouse = false.obs;
|
||||
int _lastButtons = 0;
|
||||
Offset lastMousePos = Offset.zero;
|
||||
|
||||
// Relative mouse mode (for games/3D apps).
|
||||
final relativeMouseMode = false.obs;
|
||||
late final RelativeMouseModel _relativeMouse;
|
||||
// Callback to cancel external throttle timer when relative mouse mode is disabled.
|
||||
VoidCallback? onRelativeMouseModeDisabled;
|
||||
// Disposer for the relativeMouseMode observer (to prevent memory leaks).
|
||||
Worker? _relativeMouseModeDisposer;
|
||||
|
||||
bool _queryOtherWindowCoords = false;
|
||||
Rect? _windowRect;
|
||||
List<RemoteWindowCoords> _remoteWindowCoords = [];
|
||||
@@ -370,14 +383,108 @@ class InputModel {
|
||||
bool get keyboardPerm => parent.target!.ffiModel.keyboard;
|
||||
String get id => parent.target?.id ?? '';
|
||||
String? get peerPlatform => parent.target?.ffiModel.pi.platform;
|
||||
String get peerVersion => parent.target?.ffiModel.pi.version ?? '';
|
||||
bool get isViewOnly => parent.target!.ffiModel.viewOnly;
|
||||
bool get showMyCursor => parent.target!.ffiModel.showMyCursor;
|
||||
double get devicePixelRatio => parent.target!.canvasModel.devicePixelRatio;
|
||||
bool get isViewCamera => parent.target!.connType == ConnType.viewCamera;
|
||||
int get trackpadSpeed => _trackpadSpeed;
|
||||
bool get useEdgeScroll =>
|
||||
parent.target!.canvasModel.scrollStyle == ScrollStyle.scrolledge;
|
||||
|
||||
/// Check if the connected server supports relative mouse mode.
|
||||
bool get isRelativeMouseModeSupported => _relativeMouse.isSupported;
|
||||
|
||||
InputModel(this.parent) {
|
||||
sessionId = parent.target!.sessionId;
|
||||
_relativeMouse = RelativeMouseModel(
|
||||
sessionId: sessionId,
|
||||
enabled: relativeMouseMode,
|
||||
keyboardPerm: () => keyboardPerm,
|
||||
isViewCamera: () => isViewCamera,
|
||||
peerVersion: () => peerVersion,
|
||||
peerPlatform: () => peerPlatform,
|
||||
modify: (msg) => modify(msg),
|
||||
getPointerInsideImage: () => _pointerInsideImage,
|
||||
setPointerInsideImage: (inside) => _pointerInsideImage = inside,
|
||||
);
|
||||
_relativeMouse.onDisabled = () => onRelativeMouseModeDisabled?.call();
|
||||
|
||||
// Sync relative mouse mode state to global state for UI components (e.g., tab bar hint).
|
||||
_relativeMouseModeDisposer = ever(relativeMouseMode, (bool value) {
|
||||
final peerId = id;
|
||||
if (peerId.isNotEmpty) {
|
||||
stateGlobal.relativeMouseModeState[peerId] = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// https://github.com/flutter/flutter/issues/157241
|
||||
// Infer CapsLock state from the character output.
|
||||
// This is needed because Flutter's HardwareKeyboard.lockModesEnabled may report
|
||||
// incorrect CapsLock state on iOS.
|
||||
bool _getIosCapsFromCharacter(KeyEvent e) {
|
||||
if (!isIOS) return false;
|
||||
final ch = e.character;
|
||||
return _getIosCapsFromCharacterImpl(
|
||||
ch, HardwareKeyboard.instance.isShiftPressed);
|
||||
}
|
||||
|
||||
// RawKeyEvent version of _getIosCapsFromCharacter.
|
||||
bool _getIosCapsFromRawCharacter(RawKeyEvent e) {
|
||||
if (!isIOS) return false;
|
||||
final ch = e.character;
|
||||
return _getIosCapsFromCharacterImpl(ch, e.isShiftPressed);
|
||||
}
|
||||
|
||||
// Shared implementation for inferring CapsLock state from character.
|
||||
// Uses Unicode-aware case detection to support non-ASCII letters (e.g., ü/Ü, é/É).
|
||||
//
|
||||
// Limitations:
|
||||
// 1. This inference assumes the client and server use the same keyboard layout.
|
||||
// If layouts differ (e.g., client uses EN, server uses DE), the character output
|
||||
// may not match expectations. For example, ';' on EN layout maps to 'ö' on DE
|
||||
// layout, making it impossible to correctly infer CapsLock state from the
|
||||
// character alone.
|
||||
// 2. On iOS, CapsLock+Shift produces uppercase letters (unlike desktop where it
|
||||
// produces lowercase). This method cannot handle that case correctly.
|
||||
bool _getIosCapsFromCharacterImpl(String? ch, bool shiftPressed) {
|
||||
if (ch == null || ch.length != 1) return false;
|
||||
// Use Dart's built-in Unicode-aware case detection
|
||||
final upper = ch.toUpperCase();
|
||||
final lower = ch.toLowerCase();
|
||||
final isUpper = upper == ch && lower != ch;
|
||||
final isLower = lower == ch && upper != ch;
|
||||
// Skip non-letter characters (e.g., numbers, symbols, CJK characters without case)
|
||||
if (!isUpper && !isLower) return false;
|
||||
return isUpper != shiftPressed;
|
||||
}
|
||||
|
||||
int _buildLockModes(bool iosCapsLock) {
|
||||
const capslock = 1;
|
||||
const numlock = 2;
|
||||
const scrolllock = 3;
|
||||
int lockModes = 0;
|
||||
if (isIOS) {
|
||||
if (iosCapsLock) {
|
||||
lockModes |= (1 << capslock);
|
||||
}
|
||||
// Ignore "NumLock/ScrollLock" on iOS for now.
|
||||
} else {
|
||||
if (HardwareKeyboard.instance.lockModesEnabled
|
||||
.contains(KeyboardLockMode.capsLock)) {
|
||||
lockModes |= (1 << capslock);
|
||||
}
|
||||
if (HardwareKeyboard.instance.lockModesEnabled
|
||||
.contains(KeyboardLockMode.numLock)) {
|
||||
lockModes |= (1 << numlock);
|
||||
}
|
||||
if (HardwareKeyboard.instance.lockModesEnabled
|
||||
.contains(KeyboardLockMode.scrollLock)) {
|
||||
lockModes |= (1 << scrolllock);
|
||||
}
|
||||
}
|
||||
return lockModes;
|
||||
}
|
||||
|
||||
// This function must be called after the peer info is received.
|
||||
@@ -508,6 +615,15 @@ class InputModel {
|
||||
}
|
||||
}
|
||||
|
||||
if (_relativeMouse.handleRawKeyEvent(e)) {
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
bool iosCapsLock = false;
|
||||
if (isIOS && e is RawKeyDownEvent) {
|
||||
iosCapsLock = _getIosCapsFromRawCharacter(e);
|
||||
}
|
||||
|
||||
final key = e.logicalKey;
|
||||
if (e is RawKeyDownEvent) {
|
||||
if (!e.repeat) {
|
||||
@@ -544,7 +660,7 @@ class InputModel {
|
||||
|
||||
// * Currently mobile does not enable map mode
|
||||
if ((isDesktop || isWebDesktop) && keyboardMode == kKeyMapMode) {
|
||||
mapKeyboardModeRaw(e);
|
||||
mapKeyboardModeRaw(e, iosCapsLock);
|
||||
} else {
|
||||
legacyKeyboardModeRaw(e);
|
||||
}
|
||||
@@ -570,6 +686,21 @@ class InputModel {
|
||||
}
|
||||
}
|
||||
|
||||
if (_relativeMouse.handleKeyEvent(
|
||||
e,
|
||||
ctrlPressed: ctrl,
|
||||
shiftPressed: shift,
|
||||
altPressed: alt,
|
||||
commandPressed: command,
|
||||
)) {
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
bool iosCapsLock = false;
|
||||
if (isIOS && (e is KeyDownEvent || e is KeyRepeatEvent)) {
|
||||
iosCapsLock = _getIosCapsFromCharacter(e);
|
||||
}
|
||||
|
||||
if (e is KeyUpEvent) {
|
||||
handleKeyUpEventModifiers(e);
|
||||
} else if (e is KeyDownEvent) {
|
||||
@@ -615,7 +746,8 @@ class InputModel {
|
||||
e.character ?? '',
|
||||
e.physicalKey.usbHidUsage & 0xFFFF,
|
||||
// Show repeat event be converted to "release+press" events?
|
||||
e is KeyDownEvent || e is KeyRepeatEvent);
|
||||
e is KeyDownEvent || e is KeyRepeatEvent,
|
||||
iosCapsLock);
|
||||
} else {
|
||||
legacyKeyboardMode(e);
|
||||
}
|
||||
@@ -624,23 +756,9 @@ class InputModel {
|
||||
}
|
||||
|
||||
/// Send Key Event
|
||||
void newKeyboardMode(String character, int usbHid, bool down) {
|
||||
const capslock = 1;
|
||||
const numlock = 2;
|
||||
const scrolllock = 3;
|
||||
int lockModes = 0;
|
||||
if (HardwareKeyboard.instance.lockModesEnabled
|
||||
.contains(KeyboardLockMode.capsLock)) {
|
||||
lockModes |= (1 << capslock);
|
||||
}
|
||||
if (HardwareKeyboard.instance.lockModesEnabled
|
||||
.contains(KeyboardLockMode.numLock)) {
|
||||
lockModes |= (1 << numlock);
|
||||
}
|
||||
if (HardwareKeyboard.instance.lockModesEnabled
|
||||
.contains(KeyboardLockMode.scrollLock)) {
|
||||
lockModes |= (1 << scrolllock);
|
||||
}
|
||||
void newKeyboardMode(
|
||||
String character, int usbHid, bool down, bool iosCapsLock) {
|
||||
final lockModes = _buildLockModes(iosCapsLock);
|
||||
bind.sessionHandleFlutterKeyEvent(
|
||||
sessionId: sessionId,
|
||||
character: character,
|
||||
@@ -649,7 +767,7 @@ class InputModel {
|
||||
downOrUp: down);
|
||||
}
|
||||
|
||||
void mapKeyboardModeRaw(RawKeyEvent e) {
|
||||
void mapKeyboardModeRaw(RawKeyEvent e, bool iosCapsLock) {
|
||||
int positionCode = -1;
|
||||
int platformCode = -1;
|
||||
bool down;
|
||||
@@ -680,27 +798,14 @@ class InputModel {
|
||||
} else {
|
||||
down = false;
|
||||
}
|
||||
inputRawKey(e.character ?? '', platformCode, positionCode, down);
|
||||
inputRawKey(
|
||||
e.character ?? '', platformCode, positionCode, down, iosCapsLock);
|
||||
}
|
||||
|
||||
/// Send raw Key Event
|
||||
void inputRawKey(String name, int platformCode, int positionCode, bool down) {
|
||||
const capslock = 1;
|
||||
const numlock = 2;
|
||||
const scrolllock = 3;
|
||||
int lockModes = 0;
|
||||
if (HardwareKeyboard.instance.lockModesEnabled
|
||||
.contains(KeyboardLockMode.capsLock)) {
|
||||
lockModes |= (1 << capslock);
|
||||
}
|
||||
if (HardwareKeyboard.instance.lockModesEnabled
|
||||
.contains(KeyboardLockMode.numLock)) {
|
||||
lockModes |= (1 << numlock);
|
||||
}
|
||||
if (HardwareKeyboard.instance.lockModesEnabled
|
||||
.contains(KeyboardLockMode.scrollLock)) {
|
||||
lockModes |= (1 << scrolllock);
|
||||
}
|
||||
void inputRawKey(String name, int platformCode, int positionCode, bool down,
|
||||
bool iosCapsLock) {
|
||||
final lockModes = _buildLockModes(iosCapsLock);
|
||||
bind.sessionHandleFlutterRawKeyEvent(
|
||||
sessionId: sessionId,
|
||||
name: name,
|
||||
@@ -774,6 +879,9 @@ class InputModel {
|
||||
Map<String, dynamic> _getMouseEvent(PointerEvent evt, String type) {
|
||||
final Map<String, dynamic> out = {};
|
||||
|
||||
bool hasStaleButtonsOnMouseUp =
|
||||
type == _kMouseEventUp && evt.buttons == _lastButtons;
|
||||
|
||||
// Check update event type and set buttons to be sent.
|
||||
int buttons = _lastButtons;
|
||||
if (type == _kMouseEventMove) {
|
||||
@@ -798,7 +906,7 @@ class InputModel {
|
||||
buttons = evt.buttons;
|
||||
}
|
||||
}
|
||||
_lastButtons = evt.buttons;
|
||||
_lastButtons = hasStaleButtonsOnMouseUp ? 0 : evt.buttons;
|
||||
|
||||
out['buttons'] = buttons;
|
||||
out['type'] = type;
|
||||
@@ -855,11 +963,13 @@ class InputModel {
|
||||
toReleaseKeys.release(handleKeyEvent);
|
||||
toReleaseRawKeys.release(handleRawKeyEvent);
|
||||
_pointerMovedAfterEnter = false;
|
||||
_pointerInsideImage = enter;
|
||||
|
||||
// Fix status
|
||||
if (!enter) {
|
||||
resetModifiers();
|
||||
}
|
||||
_relativeMouse.onEnterOrLeaveImage(enter);
|
||||
_flingTimer?.cancel();
|
||||
if (!isInputSourceFlutter) {
|
||||
bind.sessionEnterOrLeave(sessionId: sessionId, enter: enter);
|
||||
@@ -880,15 +990,142 @@ class InputModel {
|
||||
msg: json.encode(modify({'x': '$x2', 'y': '$y2'})));
|
||||
}
|
||||
|
||||
/// Send relative mouse movement for mobile clients (virtual joystick).
|
||||
/// This method is for touch-based controls that want to send delta values.
|
||||
/// Uses the 'move_relative' type which bypasses absolute position tracking.
|
||||
///
|
||||
/// Accumulates fractional deltas to avoid losing slow/fine movements.
|
||||
/// Only sends events when relative mouse mode is enabled and supported.
|
||||
Future<void> sendMobileRelativeMouseMove(double dx, double dy) async {
|
||||
if (!keyboardPerm) return;
|
||||
if (isViewCamera) return;
|
||||
// Only send relative mouse events when relative mode is enabled and supported.
|
||||
if (!isRelativeMouseModeSupported || !relativeMouseMode.value) return;
|
||||
_mobileDeltaRemainderX += dx;
|
||||
_mobileDeltaRemainderY += dy;
|
||||
final x = _mobileDeltaRemainderX.truncate();
|
||||
final y = _mobileDeltaRemainderY.truncate();
|
||||
_mobileDeltaRemainderX -= x;
|
||||
_mobileDeltaRemainderY -= y;
|
||||
if (x == 0 && y == 0) return;
|
||||
await bind.sessionSendMouse(
|
||||
sessionId: sessionId,
|
||||
msg: json.encode(modify({
|
||||
'type': 'move_relative',
|
||||
'x': '$x',
|
||||
'y': '$y',
|
||||
})));
|
||||
}
|
||||
|
||||
/// Update the pointer lock center position based on current window frame.
|
||||
Future<void> updatePointerLockCenter({Offset? localCenter}) {
|
||||
return _relativeMouse.updatePointerLockCenter(localCenter: localCenter);
|
||||
}
|
||||
|
||||
/// Get the current image widget size (for comparison to avoid unnecessary updates).
|
||||
Size? get imageWidgetSize => _relativeMouse.imageWidgetSize;
|
||||
|
||||
/// Update the image widget size for center calculation.
|
||||
void updateImageWidgetSize(Size size) {
|
||||
_relativeMouse.updateImageWidgetSize(size);
|
||||
}
|
||||
|
||||
void toggleRelativeMouseMode() {
|
||||
_relativeMouse.toggleRelativeMouseMode();
|
||||
}
|
||||
|
||||
bool setRelativeMouseMode(bool enabled) {
|
||||
return _relativeMouse.setRelativeMouseMode(enabled);
|
||||
}
|
||||
|
||||
/// Exit relative mouse mode and release all modifier keys to the remote.
|
||||
/// This is called when the user presses the exit shortcut (Ctrl+Alt on Win/Linux, Cmd+G on macOS).
|
||||
/// We need to send key-up events for all modifiers because the shortcut itself may have
|
||||
/// blocked some key events, leaving the remote in a state where modifiers are stuck.
|
||||
void exitRelativeMouseModeWithKeyRelease() {
|
||||
if (!_relativeMouse.enabled.value) return;
|
||||
|
||||
// First, send release events for all modifier keys to the remote.
|
||||
// This ensures the remote doesn't have stuck modifier keys after exiting.
|
||||
// Use press: false, down: false to send key-up events without modifiers attached.
|
||||
final modifiersToRelease = [
|
||||
'Control_L',
|
||||
'Control_R',
|
||||
'Alt_L',
|
||||
'Alt_R',
|
||||
'Shift_L',
|
||||
'Shift_R',
|
||||
'Meta_L', // Command/Super left
|
||||
'Meta_R', // Command/Super right
|
||||
];
|
||||
|
||||
for (final key in modifiersToRelease) {
|
||||
bind.sessionInputKey(
|
||||
sessionId: sessionId,
|
||||
name: key,
|
||||
down: false,
|
||||
press: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
shift: false,
|
||||
command: false,
|
||||
);
|
||||
}
|
||||
|
||||
// Reset local modifier state
|
||||
resetModifiers();
|
||||
|
||||
// Now exit relative mouse mode
|
||||
_relativeMouse.setRelativeMouseMode(false);
|
||||
}
|
||||
|
||||
void disposeRelativeMouseMode() {
|
||||
_relativeMouse.dispose();
|
||||
onRelativeMouseModeDisabled = null;
|
||||
// Cancel the relative mouse mode observer and clean up global state.
|
||||
_relativeMouseModeDisposer?.dispose();
|
||||
_relativeMouseModeDisposer = null;
|
||||
final peerId = id;
|
||||
if (peerId.isNotEmpty) {
|
||||
stateGlobal.relativeMouseModeState.remove(peerId);
|
||||
}
|
||||
}
|
||||
|
||||
void onWindowBlur() {
|
||||
_relativeMouse.onWindowBlur();
|
||||
}
|
||||
|
||||
void onWindowFocus() {
|
||||
_relativeMouse.onWindowFocus();
|
||||
}
|
||||
|
||||
void onPointHoverImage(PointerHoverEvent e) {
|
||||
_stopFling = true;
|
||||
if (isViewOnly && !showMyCursor) return;
|
||||
if (e.kind != ui.PointerDeviceKind.mouse) return;
|
||||
|
||||
// May fix https://github.com/rustdesk/rustdesk/issues/13009
|
||||
if (isIOS && e.synthesized && e.position == Offset.zero && e.buttons == 0) {
|
||||
// iOS may emit a synthesized hover event at (0,0) when the mouse is disconnected.
|
||||
// Ignore this event to prevent cursor jumping.
|
||||
debugPrint('Ignored synthesized hover at (0,0) on iOS');
|
||||
return;
|
||||
}
|
||||
|
||||
// Only update pointer region when relative mouse mode is enabled.
|
||||
// This avoids unnecessary tracking when not in relative mode.
|
||||
if (_relativeMouse.enabled.value) {
|
||||
_relativeMouse.updatePointerRegionTopLeftGlobal(e);
|
||||
}
|
||||
|
||||
if (!isPhysicalMouse.value) {
|
||||
isPhysicalMouse.value = true;
|
||||
}
|
||||
if (isPhysicalMouse.value) {
|
||||
handleMouse(_getMouseEvent(e, _kMouseEventMove), e.position);
|
||||
if (!_relativeMouse.handleRelativeMouseMove(e.localPosition)) {
|
||||
handleMouse(_getMouseEvent(e, _kMouseEventMove), e.position,
|
||||
edgeScroll: useEdgeScroll);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1037,6 +1274,28 @@ class InputModel {
|
||||
_trackpadLastDelta = Offset.zero;
|
||||
}
|
||||
|
||||
// iOS Magic Mouse duplicate event detection.
|
||||
// When using Magic Mouse on iPad, iOS may emit both mouse and touch events
|
||||
// for the same click in certain areas (like top-left corner).
|
||||
int _lastMouseDownTimeMs = 0;
|
||||
ui.Offset _lastMouseDownPos = ui.Offset.zero;
|
||||
|
||||
/// Check if a touch tap event should be ignored because it's a duplicate
|
||||
/// of a recent mouse event (iOS Magic Mouse issue).
|
||||
bool shouldIgnoreTouchTap(ui.Offset pos) {
|
||||
if (!isIOS) return false;
|
||||
final nowMs = DateTime.now().millisecondsSinceEpoch;
|
||||
final dt = nowMs - _lastMouseDownTimeMs;
|
||||
final distance = (_lastMouseDownPos - pos).distance;
|
||||
// If touch tap is within 2000ms and 80px of the last mouse down,
|
||||
// it's likely a duplicate event from the same Magic Mouse click.
|
||||
if (dt >= 0 && dt < 2000 && distance < 80.0) {
|
||||
debugPrint("shouldIgnoreTouchTap: IGNORED (dt=$dt, dist=$distance)");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void onPointDownImage(PointerDownEvent e) {
|
||||
debugPrint("onPointDownImage ${e.kind}");
|
||||
_stopFling = true;
|
||||
@@ -1045,13 +1304,32 @@ class InputModel {
|
||||
_windowRect = null;
|
||||
if (isViewOnly && !showMyCursor) return;
|
||||
if (isViewCamera) return;
|
||||
|
||||
// Track mouse down events for duplicate detection on iOS.
|
||||
final nowMs = DateTime.now().millisecondsSinceEpoch;
|
||||
if (e.kind == ui.PointerDeviceKind.mouse) {
|
||||
_lastMouseDownTimeMs = nowMs;
|
||||
_lastMouseDownPos = e.position;
|
||||
}
|
||||
|
||||
if (_relativeMouse.enabled.value) {
|
||||
_relativeMouse.updatePointerRegionTopLeftGlobal(e);
|
||||
}
|
||||
|
||||
if (e.kind != ui.PointerDeviceKind.mouse) {
|
||||
if (isPhysicalMouse.value) {
|
||||
isPhysicalMouse.value = false;
|
||||
}
|
||||
}
|
||||
if (isPhysicalMouse.value) {
|
||||
handleMouse(_getMouseEvent(e, _kMouseEventDown), e.position);
|
||||
// In relative mouse mode, send button events without position.
|
||||
// Use _relativeMouse.enabled.value consistently with the guard above.
|
||||
if (_relativeMouse.enabled.value) {
|
||||
_relativeMouse
|
||||
.sendRelativeMouseButton(_getMouseEvent(e, _kMouseEventDown));
|
||||
} else {
|
||||
handleMouse(_getMouseEvent(e, _kMouseEventDown), e.position);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1059,9 +1337,21 @@ class InputModel {
|
||||
if (isDesktop) _queryOtherWindowCoords = false;
|
||||
if (isViewOnly && !showMyCursor) return;
|
||||
if (isViewCamera) return;
|
||||
|
||||
if (_relativeMouse.enabled.value) {
|
||||
_relativeMouse.updatePointerRegionTopLeftGlobal(e);
|
||||
}
|
||||
|
||||
if (e.kind != ui.PointerDeviceKind.mouse) return;
|
||||
if (isPhysicalMouse.value) {
|
||||
handleMouse(_getMouseEvent(e, _kMouseEventUp), e.position);
|
||||
// In relative mouse mode, send button events without position.
|
||||
// Use _relativeMouse.enabled.value consistently with the guard above.
|
||||
if (_relativeMouse.enabled.value) {
|
||||
_relativeMouse
|
||||
.sendRelativeMouseButton(_getMouseEvent(e, _kMouseEventUp));
|
||||
} else {
|
||||
handleMouse(_getMouseEvent(e, _kMouseEventUp), e.position);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1069,6 +1359,11 @@ class InputModel {
|
||||
if (isViewOnly && !showMyCursor) return;
|
||||
if (isViewCamera) return;
|
||||
if (e.kind != ui.PointerDeviceKind.mouse) return;
|
||||
|
||||
if (_relativeMouse.enabled.value) {
|
||||
_relativeMouse.updatePointerRegionTopLeftGlobal(e);
|
||||
}
|
||||
|
||||
if (_queryOtherWindowCoords) {
|
||||
Future.delayed(Duration.zero, () async {
|
||||
_windowRect = await fillRemoteCoordsAndGetCurFrame(_remoteWindowCoords);
|
||||
@@ -1076,7 +1371,10 @@ class InputModel {
|
||||
_queryOtherWindowCoords = false;
|
||||
}
|
||||
if (isPhysicalMouse.value) {
|
||||
handleMouse(_getMouseEvent(e, _kMouseEventMove), e.position);
|
||||
if (!_relativeMouse.handleRelativeMouseMove(e.localPosition)) {
|
||||
handleMouse(_getMouseEvent(e, _kMouseEventMove), e.position,
|
||||
edgeScroll: useEdgeScroll);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1100,6 +1398,11 @@ class InputModel {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Handle scroll/wheel events.
|
||||
/// Note: Scroll events intentionally use absolute positioning even in relative mouse mode.
|
||||
/// This is because scroll events don't need relative positioning - they represent
|
||||
/// scroll deltas that are independent of cursor position. Games and 3D applications
|
||||
/// handle scroll events the same way regardless of mouse mode.
|
||||
void onPointerSignalImage(PointerSignalEvent e) {
|
||||
if (isViewOnly) return;
|
||||
if (isViewCamera) return;
|
||||
@@ -1125,7 +1428,7 @@ class InputModel {
|
||||
void refreshMousePos() => handleMouse({
|
||||
'buttons': 0,
|
||||
'type': _kMouseEventMove,
|
||||
}, lastMousePos);
|
||||
}, lastMousePos, edgeScroll: useEdgeScroll);
|
||||
|
||||
void tryMoveEdgeOnExit(Offset pos) => handleMouse(
|
||||
{
|
||||
@@ -1232,6 +1535,7 @@ class InputModel {
|
||||
Offset offset, {
|
||||
bool onExit = false,
|
||||
bool moveCanvas = true,
|
||||
bool edgeScroll = false,
|
||||
}) {
|
||||
if (isViewCamera) return null;
|
||||
double x = offset.dx;
|
||||
@@ -1273,6 +1577,7 @@ class InputModel {
|
||||
onExit: onExit,
|
||||
buttons: evt['buttons'],
|
||||
moveCanvas: moveCanvas,
|
||||
edgeScroll: edgeScroll,
|
||||
);
|
||||
if (pos == null) {
|
||||
return null;
|
||||
@@ -1285,14 +1590,18 @@ class InputModel {
|
||||
evt['y'] = '${pos.y.toInt()}';
|
||||
}
|
||||
|
||||
Map<int, String> mapButtons = {
|
||||
kPrimaryMouseButton: 'left',
|
||||
kSecondaryMouseButton: 'right',
|
||||
kMiddleMouseButton: 'wheel',
|
||||
kBackMouseButton: 'back',
|
||||
kForwardMouseButton: 'forward'
|
||||
};
|
||||
evt['buttons'] = mapButtons[evt['buttons']] ?? '';
|
||||
final buttons = evt['buttons'];
|
||||
if (buttons is int) {
|
||||
evt['buttons'] = mouseButtonsToPeer(buttons);
|
||||
} else {
|
||||
// Log warning if buttons exists but is not an int (unexpected caller).
|
||||
// Keep empty string fallback for missing buttons to preserve move/hover behavior.
|
||||
if (buttons != null) {
|
||||
debugPrint(
|
||||
'[InputModel] processEventToPeer: unexpected buttons type: ${buttons.runtimeType}, value: $buttons');
|
||||
}
|
||||
evt['buttons'] = '';
|
||||
}
|
||||
return evt;
|
||||
}
|
||||
|
||||
@@ -1301,9 +1610,10 @@ class InputModel {
|
||||
Offset offset, {
|
||||
bool onExit = false,
|
||||
bool moveCanvas = true,
|
||||
bool edgeScroll = false,
|
||||
}) {
|
||||
final evtToPeer =
|
||||
processEventToPeer(evt, offset, onExit: onExit, moveCanvas: moveCanvas);
|
||||
final evtToPeer = processEventToPeer(evt, offset,
|
||||
onExit: onExit, moveCanvas: moveCanvas, edgeScroll: edgeScroll);
|
||||
if (evtToPeer != null) {
|
||||
bind.sessionSendMouse(
|
||||
sessionId: sessionId, msg: json.encode(modify(evtToPeer)));
|
||||
@@ -1320,6 +1630,7 @@ class InputModel {
|
||||
bool onExit = false,
|
||||
int buttons = kPrimaryMouseButton,
|
||||
bool moveCanvas = true,
|
||||
bool edgeScroll = false,
|
||||
}) {
|
||||
final ffiModel = parent.target!.ffiModel;
|
||||
CanvasCoords canvas =
|
||||
@@ -1348,8 +1659,16 @@ class InputModel {
|
||||
|
||||
y -= CanvasModel.topToEdge;
|
||||
x -= CanvasModel.leftToEdge;
|
||||
if (isMove && moveCanvas) {
|
||||
parent.target!.canvasModel.moveDesktopMouse(x, y);
|
||||
if (isMove) {
|
||||
final canvasModel = parent.target!.canvasModel;
|
||||
|
||||
if (edgeScroll) {
|
||||
canvasModel.edgeScrollMouse(x, y);
|
||||
} else if (moveCanvas) {
|
||||
canvasModel.moveDesktopMouse(x, y);
|
||||
}
|
||||
|
||||
canvasModel.updateLocalCursor(x, y);
|
||||
}
|
||||
|
||||
return _handlePointerDevicePos(
|
||||
@@ -1412,7 +1731,7 @@ class InputModel {
|
||||
var nearBottom = (canvas.size.height - y) < nearThr;
|
||||
final imageWidth = rect.width * canvas.scale;
|
||||
final imageHeight = rect.height * canvas.scale;
|
||||
if (canvas.scrollStyle == ScrollStyle.scrollbar) {
|
||||
if (canvas.scrollStyle != ScrollStyle.scrollauto) {
|
||||
x += imageWidth * canvas.scrollX;
|
||||
y += imageHeight * canvas.scrollY;
|
||||
|
||||
@@ -1534,9 +1853,9 @@ class InputModel {
|
||||
// Simulate a key press event.
|
||||
// `usbHidUsage` is the USB HID usage code of the key.
|
||||
Future<void> tapHidKey(int usbHidUsage) async {
|
||||
newKeyboardMode(kKeyFlutterKey, usbHidUsage, true);
|
||||
newKeyboardMode(kKeyFlutterKey, usbHidUsage, true, false);
|
||||
await Future.delayed(Duration(milliseconds: 100));
|
||||
newKeyboardMode(kKeyFlutterKey, usbHidUsage, false);
|
||||
newKeyboardMode(kKeyFlutterKey, usbHidUsage, false, false);
|
||||
}
|
||||
|
||||
Future<void> onMobileVolumeUp() async =>
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:desktop_multi_window/desktop_multi_window.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter_hbb/common/widgets/peers_view.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/models/ab_model.dart';
|
||||
@@ -29,6 +30,7 @@ import 'package:flutter_hbb/plugin/manager.dart';
|
||||
import 'package:flutter_hbb/plugin/widgets/desc_ui.dart';
|
||||
import 'package:flutter_hbb/common/shared_state.dart';
|
||||
import 'package:flutter_hbb/utils/multi_window_manager.dart';
|
||||
import 'package:flutter_hbb/utils/http_service.dart' as http;
|
||||
import 'package:tuple/tuple.dart';
|
||||
import 'package:image/image.dart' as img2;
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
@@ -36,6 +38,7 @@ import 'package:get/get.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:vector_math/vector_math.dart' show Vector2;
|
||||
|
||||
import '../common.dart';
|
||||
import '../utils/image.dart' as img;
|
||||
@@ -117,6 +120,7 @@ class FfiModel with ChangeNotifier {
|
||||
late VirtualMouseMode virtualMouseMode;
|
||||
Timer? _timer;
|
||||
var _reconnects = 1;
|
||||
DateTime? _offlineReconnectStartTime;
|
||||
bool _viewOnly = false;
|
||||
bool _showMyCursor = false;
|
||||
WeakReference<FFI> parent;
|
||||
@@ -156,6 +160,8 @@ class FfiModel with ChangeNotifier {
|
||||
bool get isPeerAndroid => _pi.platform == kPeerPlatformAndroid;
|
||||
bool get isPeerMobile => isPeerAndroid;
|
||||
|
||||
bool get isPeerLinux => _pi.platform == kPeerPlatformLinux;
|
||||
|
||||
bool get viewOnly => _viewOnly;
|
||||
bool get showMyCursor => _showMyCursor;
|
||||
|
||||
@@ -176,6 +182,9 @@ class FfiModel with ChangeNotifier {
|
||||
if (displays.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
if (isPeerLinux) {
|
||||
useDisplayScale = true;
|
||||
}
|
||||
int scale(int len, double s) {
|
||||
if (useDisplayScale) {
|
||||
return len.toDouble() ~/ s;
|
||||
@@ -205,6 +214,9 @@ class FfiModel with ChangeNotifier {
|
||||
}
|
||||
|
||||
updatePermission(Map<String, dynamic> evt, String id) {
|
||||
// Track previous keyboard permission to detect revocation.
|
||||
final hadKeyboardPerm = _permissions['keyboard'] != false;
|
||||
|
||||
evt.forEach((k, v) {
|
||||
if (k == 'name' || k.isEmpty) return;
|
||||
_permissions[k] = v == 'true';
|
||||
@@ -213,6 +225,18 @@ class FfiModel with ChangeNotifier {
|
||||
if (parent.target?.connType == ConnType.defaultConn) {
|
||||
KeyboardEnabledState.find(id).value = _permissions['keyboard'] != false;
|
||||
}
|
||||
|
||||
// If keyboard permission was revoked while relative mouse mode is active,
|
||||
// forcefully disable relative mouse mode to prevent the user from being trapped.
|
||||
final hasKeyboardPerm = _permissions['keyboard'] != false;
|
||||
if (hadKeyboardPerm && !hasKeyboardPerm) {
|
||||
final inputModel = parent.target?.inputModel;
|
||||
if (inputModel != null && inputModel.relativeMouseMode.value) {
|
||||
inputModel.setRelativeMouseMode(false);
|
||||
showToast(translate('rel-mouse-permission-lost-tip'));
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('updatePermission: $_permissions');
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -355,7 +379,7 @@ class FfiModel with ChangeNotifier {
|
||||
parent.target?.fileModel.refreshAll();
|
||||
}
|
||||
} else if (name == 'job_error') {
|
||||
parent.target?.fileModel.jobController.jobError(evt);
|
||||
parent.target?.fileModel.handleJobError(evt);
|
||||
} else if (name == 'override_file_confirm') {
|
||||
parent.target?.fileModel.postOverrideFileConfirm(evt);
|
||||
} else if (name == 'load_last_job') {
|
||||
@@ -449,6 +473,9 @@ class FfiModel with ChangeNotifier {
|
||||
_handlePrinterRequest(evt, sessionId, peerId);
|
||||
} else if (name == 'screenshot') {
|
||||
_handleScreenshot(evt, sessionId, peerId);
|
||||
} else if (name == 'exit_relative_mouse_mode') {
|
||||
// Handle exit shortcut from rdev grab loop (Ctrl+Alt on Win/Linux, Cmd+G on macOS)
|
||||
parent.target?.inputModel.exitRelativeMouseModeWithKeyRelease();
|
||||
} else {
|
||||
debugPrint('Event is not handled in the fixed branch: $name');
|
||||
}
|
||||
@@ -757,7 +784,8 @@ class FfiModel with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
updateCurDisplay(SessionID sessionId, {updateCursorPos = false}) {
|
||||
Future<void> updateCurDisplay(SessionID sessionId,
|
||||
{updateCursorPos = false}) async {
|
||||
final newRect = displaysRect();
|
||||
if (newRect == null) {
|
||||
return;
|
||||
@@ -769,9 +797,19 @@ class FfiModel with ChangeNotifier {
|
||||
updateCursorPos: updateCursorPos);
|
||||
}
|
||||
_rect = newRect;
|
||||
parent.target?.canvasModel
|
||||
// Await updateViewStyle to ensure view geometry is fully updated before
|
||||
// updating pointer lock center. This prevents stale center calculations.
|
||||
await parent.target?.canvasModel
|
||||
.updateViewStyle(refreshMousePos: updateCursorPos);
|
||||
_updateSessionWidthHeight(sessionId);
|
||||
|
||||
// Keep pointer lock center in sync when using relative mouse mode.
|
||||
// Note: updatePointerLockCenter is async-safe (handles errors internally),
|
||||
// so we fire-and-forget here.
|
||||
final inputModel = parent.target?.inputModel;
|
||||
if (inputModel != null && inputModel.relativeMouseMode.value) {
|
||||
inputModel.updatePointerLockCenter();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -855,6 +893,17 @@ class FfiModel with ChangeNotifier {
|
||||
final title = evt['title'];
|
||||
final text = evt['text'];
|
||||
final link = evt['link'];
|
||||
|
||||
// Disable relative mouse mode on any error-type message to ensure cursor is released.
|
||||
// This includes connection errors, session-ending messages, elevation errors, etc.
|
||||
// Safety: releasing pointer lock on errors prevents the user from being stuck.
|
||||
if (title == 'Connection Error' ||
|
||||
type == 'error' ||
|
||||
type == 'restarting' ||
|
||||
(type is String && type.contains('error'))) {
|
||||
parent.target?.inputModel.setRelativeMouseMode(false);
|
||||
}
|
||||
|
||||
if (type == 're-input-password') {
|
||||
wrongPasswordDialog(sessionId, dialogManager, type, title, text);
|
||||
} else if (type == 'input-2fa') {
|
||||
@@ -892,11 +941,46 @@ class FfiModel with ChangeNotifier {
|
||||
showPrivacyFailedDialog(
|
||||
sessionId, type, title, text, link, hasRetry, dialogManager);
|
||||
} else {
|
||||
final hasRetry = evt['hasRetry'] == 'true';
|
||||
var hasRetry = evt['hasRetry'] == 'true';
|
||||
if (!hasRetry) {
|
||||
hasRetry = shouldAutoRetryOnOffline(type, title, text);
|
||||
}
|
||||
showMsgBox(sessionId, type, title, text, link, hasRetry, dialogManager);
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto-retry check for "Remote desktop is offline" error.
|
||||
/// returns true to auto-retry, false otherwise.
|
||||
bool shouldAutoRetryOnOffline(
|
||||
String type,
|
||||
String title,
|
||||
String text,
|
||||
) {
|
||||
if (type == 'error' &&
|
||||
title == 'Connection Error' &&
|
||||
text == 'Remote desktop is offline' &&
|
||||
_pi.isSet.isTrue) {
|
||||
// Auto retry for ~30s (server's peer offline threshold) when controlled peer's account changes
|
||||
// (e.g., signout, switch user, login into OS) causes temporary offline via websocket/tcp connection.
|
||||
// The actual wait may exceed 30s (e.g., 20s elapsed + 16s next retry = 36s), which is acceptable
|
||||
// since the controlled side reconnects quickly after account changes.
|
||||
// Uses time-based check instead of _reconnects count because user can manually retry.
|
||||
// https://github.com/rustdesk/rustdesk/discussions/14048
|
||||
if (_offlineReconnectStartTime == null) {
|
||||
// First offline, record time and start retry
|
||||
_offlineReconnectStartTime = DateTime.now();
|
||||
return true;
|
||||
} else {
|
||||
final elapsed =
|
||||
DateTime.now().difference(_offlineReconnectStartTime!).inSeconds;
|
||||
if (elapsed < 30) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
handleToast(Map<String, dynamic> evt, SessionID sessionId, String peerId) {
|
||||
final type = evt['type'] ?? 'info';
|
||||
final text = evt['text'] ?? '';
|
||||
@@ -931,11 +1015,21 @@ class FfiModel with ChangeNotifier {
|
||||
/// Show a message box with [type], [title] and [text].
|
||||
showMsgBox(SessionID sessionId, String type, String title, String text,
|
||||
String link, bool hasRetry, OverlayDialogManager dialogManager,
|
||||
{bool? hasCancel}) {
|
||||
msgBox(sessionId, type, title, text, link, dialogManager,
|
||||
hasCancel: hasCancel,
|
||||
reconnect: hasRetry ? reconnect : null,
|
||||
reconnectTimeout: hasRetry ? _reconnects : null);
|
||||
{bool? hasCancel}) async {
|
||||
final showNoteEdit = parent.target != null &&
|
||||
allowAskForNoteAtEndOfConnection(parent.target, false) &&
|
||||
(title == "Connection Error" || type == "restarting") &&
|
||||
!hasRetry;
|
||||
if (showNoteEdit) {
|
||||
await showConnEndAuditDialogCloseCanceled(
|
||||
ffi: parent.target!, type: type, title: title, text: text);
|
||||
closeConnection();
|
||||
} else {
|
||||
msgBox(sessionId, type, title, text, link, dialogManager,
|
||||
hasCancel: hasCancel,
|
||||
reconnect: hasRetry ? reconnect : null,
|
||||
reconnectTimeout: hasRetry ? _reconnects : null);
|
||||
}
|
||||
_timer?.cancel();
|
||||
if (hasRetry) {
|
||||
_timer = Timer(Duration(seconds: _reconnects), () {
|
||||
@@ -944,11 +1038,14 @@ class FfiModel with ChangeNotifier {
|
||||
_reconnects *= 2;
|
||||
} else {
|
||||
_reconnects = 1;
|
||||
_offlineReconnectStartTime = 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);
|
||||
bind.sessionReconnect(sessionId: sessionId, forceRelay: forceRelay);
|
||||
clearPermissions();
|
||||
dialogManager.dismissAll();
|
||||
@@ -956,8 +1053,30 @@ class FfiModel with ChangeNotifier {
|
||||
onCancel: closeConnection);
|
||||
}
|
||||
|
||||
void showRelayHintDialog(SessionID sessionId, String type, String title,
|
||||
String text, OverlayDialogManager dialogManager, String peerId) {
|
||||
Future<void> showRelayHintDialog(
|
||||
SessionID sessionId,
|
||||
String type,
|
||||
String title,
|
||||
String text,
|
||||
OverlayDialogManager dialogManager,
|
||||
String peerId) async {
|
||||
var hint = "\n\n${translate('relay_hint_tip')}";
|
||||
if (text.contains("10054") || text.contains("104")) {
|
||||
hint = "";
|
||||
}
|
||||
final text2 = "${translate(text)}$hint";
|
||||
|
||||
if (parent.target != null &&
|
||||
allowAskForNoteAtEndOfConnection(parent.target, false) &&
|
||||
pi.isSet.isTrue) {
|
||||
if (await showConnEndAuditDialogCloseCanceled(
|
||||
ffi: parent.target!, type: type, title: title, text: text2)) {
|
||||
return;
|
||||
}
|
||||
closeConnection();
|
||||
return;
|
||||
}
|
||||
|
||||
dialogManager.show(tag: '$sessionId-$type', (setState, close, context) {
|
||||
onClose() {
|
||||
closeConnection();
|
||||
@@ -966,13 +1085,10 @@ class FfiModel with ChangeNotifier {
|
||||
|
||||
final style =
|
||||
ElevatedButton.styleFrom(backgroundColor: Colors.green[700]);
|
||||
var hint = "\n\n${translate('relay_hint_tip')}";
|
||||
if (text.contains("10054") || text.contains("104")) {
|
||||
hint = "";
|
||||
}
|
||||
|
||||
return CustomAlertDialog(
|
||||
title: null,
|
||||
content: msgboxContent(type, title, "${translate(text)}$hint"),
|
||||
content: msgboxContent(type, title, text2),
|
||||
actions: [
|
||||
dialogButton('Close', onPressed: onClose, isOutline: true),
|
||||
if (type == 'relay-hint')
|
||||
@@ -1046,28 +1162,114 @@ class FfiModel with ChangeNotifier {
|
||||
sessionId: sessionId,
|
||||
display:
|
||||
pi.currentDisplay == kAllDisplayValue ? 0 : pi.currentDisplay,
|
||||
width: _rect!.width.toInt(),
|
||||
height: _rect!.height.toInt(),
|
||||
width: displays[0].width,
|
||||
height: displays[0].height,
|
||||
);
|
||||
} else {
|
||||
for (int i = 0; i < displays.length; ++i) {
|
||||
bind.sessionSetSize(
|
||||
sessionId: sessionId,
|
||||
display: i,
|
||||
width: displays[i].width.toInt(),
|
||||
height: displays[i].height.toInt(),
|
||||
width: displays[i].width,
|
||||
height: displays[i].height,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _queryAuditGuid(String peerId) async {
|
||||
try {
|
||||
if (bind.isDisableAccount()) {
|
||||
return;
|
||||
}
|
||||
if (bind
|
||||
.sessionGetAuditServerSync(sessionId: sessionId, typ: "conn/active")
|
||||
.isEmpty) {
|
||||
return;
|
||||
}
|
||||
if (!mainGetLocalBoolOptionSync(
|
||||
kOptionAllowAskForNoteAtEndOfConnection)) {
|
||||
return;
|
||||
}
|
||||
if (bind.sessionGetAuditGuid(sessionId: sessionId).isNotEmpty) {
|
||||
debugPrint('Get cached audit GUID');
|
||||
return;
|
||||
}
|
||||
final url = bind.sessionGetAuditServerSync(
|
||||
sessionId: sessionId, typ: "conn/active");
|
||||
if (url.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final initialConnSessionId =
|
||||
bind.sessionGetConnSessionId(sessionId: sessionId);
|
||||
final connType = switch (parent.target?.connType) {
|
||||
ConnType.defaultConn => 0,
|
||||
ConnType.fileTransfer => 1,
|
||||
ConnType.portForward => 2,
|
||||
ConnType.rdp => 2,
|
||||
ConnType.viewCamera => 3,
|
||||
ConnType.terminal => 4,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
const retryIntervals = [1, 1, 2, 2, 3, 3];
|
||||
|
||||
for (int attempt = 1; attempt <= retryIntervals.length; attempt++) {
|
||||
final currentConnSessionId =
|
||||
bind.sessionGetConnSessionId(sessionId: sessionId);
|
||||
if (currentConnSessionId != initialConnSessionId) {
|
||||
debugPrint('connSessionId changed, stopping audit GUID query');
|
||||
return;
|
||||
}
|
||||
|
||||
final fullUrl =
|
||||
'$url?id=$peerId&session_id=$currentConnSessionId&conn_type=$connType';
|
||||
|
||||
debugPrint(
|
||||
'Querying audit GUID, attempt $attempt/${retryIntervals.length}');
|
||||
try {
|
||||
var headers = getHttpHeaders();
|
||||
headers['Content-Type'] = "application/json";
|
||||
|
||||
final response = await http.get(
|
||||
Uri.parse(fullUrl),
|
||||
headers: headers,
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final guid = jsonDecode(response.body) as String?;
|
||||
if (guid != null && guid.isNotEmpty) {
|
||||
bind.sessionSetAuditGuid(sessionId: sessionId, guid: guid);
|
||||
debugPrint('Successfully retrieved audit GUID');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
debugPrint(
|
||||
'Failed to query audit GUID. Status: ${response.statusCode}, Body: ${response.body}');
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Error querying audit GUID (attempt $attempt): $e');
|
||||
}
|
||||
|
||||
if (attempt < retryIntervals.length) {
|
||||
await Future.delayed(Duration(seconds: retryIntervals[attempt - 1]));
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
'Failed to retrieve audit GUID after ${retryIntervals.length} attempts');
|
||||
} catch (e) {
|
||||
debugPrint('Error in _queryAuditGuid: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle the peer info event based on [evt].
|
||||
handlePeerInfo(Map<String, dynamic> evt, String peerId, bool isCache) async {
|
||||
parent.target?.chatModel.voiceCallStatus.value = VoiceCallStatus.notStarted;
|
||||
|
||||
// This call is to ensuer the keyboard mode is updated depending on the peer version.
|
||||
parent.target?.inputModel.updateKeyboardMode();
|
||||
_queryAuditGuid(peerId);
|
||||
|
||||
// Map clone is required here, otherwise "evt" may be changed by other threads through the reference.
|
||||
// Because this function is asynchronous, there's an "await" in this function.
|
||||
@@ -1080,6 +1282,17 @@ class FfiModel with ChangeNotifier {
|
||||
|
||||
parent.target?.dialogManager.dismissAll();
|
||||
_pi.version = evt['version'];
|
||||
// Note: Relative mouse mode is NOT auto-enabled on connect.
|
||||
// Users must manually enable it via toolbar or keyboard shortcut (Ctrl+Alt+Shift+M).
|
||||
//
|
||||
// For desktop/webDesktop, keyboard mode initialization is handled later by
|
||||
// checkDesktopKeyboardMode() which may change the mode if not supported,
|
||||
// followed by updateKeyboardMode() to sync InputModel.keyboardMode.
|
||||
// For mobile, updateKeyboardMode() is currently a no-op (only executes on desktop/web),
|
||||
// but we call it here for consistency and future-proofing.
|
||||
if (isMobile) {
|
||||
parent.target?.inputModel.updateKeyboardMode();
|
||||
}
|
||||
_pi.isSupportMultiUiSession =
|
||||
bind.isSupportMultiUiSession(version: _pi.version);
|
||||
_pi.username = evt['username'];
|
||||
@@ -1148,6 +1361,7 @@ class FfiModel with ChangeNotifier {
|
||||
}
|
||||
if (displays.isNotEmpty) {
|
||||
_reconnects = 1;
|
||||
_offlineReconnectStartTime = null;
|
||||
waitForFirstImage.value = true;
|
||||
isRefreshing = false;
|
||||
}
|
||||
@@ -1181,7 +1395,11 @@ class FfiModel with ChangeNotifier {
|
||||
stateGlobal.resetLastResolutionGroupValues(peerId);
|
||||
|
||||
if (isDesktop || isWebDesktop) {
|
||||
checkDesktopKeyboardMode();
|
||||
// checkDesktopKeyboardMode may change the keyboard mode if the current
|
||||
// mode is not supported. Re-sync InputModel.keyboardMode afterwards.
|
||||
// Note: updateKeyboardMode() is a no-op on mobile (early-returns).
|
||||
await checkDesktopKeyboardMode();
|
||||
await parent.target?.inputModel.updateKeyboardMode();
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
@@ -1323,8 +1541,17 @@ class FfiModel with ChangeNotifier {
|
||||
d.cursorEmbedded = evt['cursor_embedded'] == 1;
|
||||
d.originalWidth = evt['original_width'] ?? kInvalidResolutionValue;
|
||||
d.originalHeight = evt['original_height'] ?? kInvalidResolutionValue;
|
||||
double v = (evt['scale']?.toDouble() ?? 100.0) / 100;
|
||||
d._scale = v > 1.0 ? v : 1.0;
|
||||
d._scale = 1.0;
|
||||
final scaledWidth = evt['scaled_width'];
|
||||
if (scaledWidth != null) {
|
||||
final sw = int.tryParse(scaledWidth.toString());
|
||||
if (sw != null && sw > 0 && d.width > 0) {
|
||||
d._scale = max(d.width.toDouble() / sw, 1.0);
|
||||
} else {
|
||||
debugPrint(
|
||||
"Invalid scaled_width ($scaledWidth) or width (${d.width}), using default scale 1.0");
|
||||
}
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
@@ -1665,6 +1892,7 @@ class ImageModel with ChangeNotifier {
|
||||
if (isDesktop || isWebDesktop) {
|
||||
await parent.target?.canvasModel.updateViewStyle();
|
||||
await parent.target?.canvasModel.updateScrollStyle();
|
||||
await parent.target?.canvasModel.initializeEdgeScrollEdgeThickness();
|
||||
}
|
||||
if (parent.target != null) {
|
||||
await initializeCursorAndCanvas(parent.target!);
|
||||
@@ -1713,8 +1941,56 @@ class ImageModel with ChangeNotifier {
|
||||
}
|
||||
|
||||
enum ScrollStyle {
|
||||
scrollbar,
|
||||
scrollauto,
|
||||
scrollbar(kRemoteScrollStyleBar),
|
||||
scrollauto(kRemoteScrollStyleAuto),
|
||||
scrolledge(kRemoteScrollStyleEdge);
|
||||
|
||||
const ScrollStyle(this.stringValue);
|
||||
|
||||
final String stringValue;
|
||||
|
||||
String toJson() {
|
||||
return name;
|
||||
}
|
||||
|
||||
static ScrollStyle fromJson(String json, [ScrollStyle? fallbackValue]) {
|
||||
switch (json) {
|
||||
case 'scrollbar':
|
||||
return scrollbar;
|
||||
case 'scrollauto':
|
||||
return scrollauto;
|
||||
case 'scrolledge':
|
||||
return scrolledge;
|
||||
}
|
||||
|
||||
if (fallbackValue != null) {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
throw ArgumentError("Unknown ScrollStyle JSON value: '$json'");
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return stringValue;
|
||||
}
|
||||
|
||||
static ScrollStyle fromString(String string, [ScrollStyle? fallbackValue]) {
|
||||
switch (string) {
|
||||
case kRemoteScrollStyleBar:
|
||||
return scrollbar;
|
||||
case kRemoteScrollStyleAuto:
|
||||
return scrollauto;
|
||||
case kRemoteScrollStyleEdge:
|
||||
return scrolledge;
|
||||
}
|
||||
|
||||
if (fallbackValue != null) {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
throw ArgumentError("Unknown ScrollStyle string value: '$string'");
|
||||
}
|
||||
}
|
||||
|
||||
class ViewStyle {
|
||||
@@ -1789,6 +2065,60 @@ class ViewStyle {
|
||||
}
|
||||
}
|
||||
|
||||
enum EdgeScrollState {
|
||||
inactive,
|
||||
armed,
|
||||
active,
|
||||
}
|
||||
|
||||
class EdgeScrollFallbackState {
|
||||
final CanvasModel _owner;
|
||||
|
||||
late Ticker _ticker;
|
||||
|
||||
Duration _lastTotalElapsed = Duration.zero;
|
||||
bool _nextEventIsFirst = true;
|
||||
Vector2 _encroachment = Vector2.zero();
|
||||
|
||||
EdgeScrollFallbackState(this._owner, TickerProvider tickerProvider) {
|
||||
_ticker = tickerProvider.createTicker(emitTick);
|
||||
}
|
||||
|
||||
void setEncroachment(Vector2 encroachment) {
|
||||
_encroachment = encroachment;
|
||||
}
|
||||
|
||||
void emitTick(Duration totalElapsed) {
|
||||
if (_nextEventIsFirst) {
|
||||
_lastTotalElapsed = totalElapsed;
|
||||
_nextEventIsFirst = false;
|
||||
} else {
|
||||
final thisTickElapsed = totalElapsed - _lastTotalElapsed;
|
||||
|
||||
const double kFrameTime = 1000.0 / 60.0;
|
||||
const double kSpeedFactor = 0.1;
|
||||
|
||||
var delta = _encroachment *
|
||||
(kSpeedFactor * thisTickElapsed.inMilliseconds / kFrameTime);
|
||||
|
||||
_owner.performEdgeScroll(delta);
|
||||
|
||||
_lastTotalElapsed = totalElapsed;
|
||||
}
|
||||
}
|
||||
|
||||
void start() {
|
||||
if (!_ticker.isActive) {
|
||||
_nextEventIsFirst = true;
|
||||
_ticker.start();
|
||||
}
|
||||
}
|
||||
|
||||
void stop() {
|
||||
_ticker.stop();
|
||||
}
|
||||
}
|
||||
|
||||
class CanvasModel with ChangeNotifier {
|
||||
// image offset of canvas
|
||||
double _x = 0;
|
||||
@@ -1810,6 +2140,15 @@ class CanvasModel with ChangeNotifier {
|
||||
// scroll offset y percent
|
||||
double _scrollY = 0.0;
|
||||
ScrollStyle _scrollStyle = ScrollStyle.scrollauto;
|
||||
// edge scroll mode: trigger scrolling when the cursor is close to the edge of the view
|
||||
int _edgeScrollEdgeThickness = 100;
|
||||
// tracks whether edge scroll should be active, prevents spurious
|
||||
// scrolling when the cursor enters the view from outside
|
||||
EdgeScrollState _edgeScrollState = EdgeScrollState.inactive;
|
||||
// fallback strategy for when Bump Mouse isn't available
|
||||
late EdgeScrollFallbackState _edgeScrollFallbackState;
|
||||
// to avoid hammering a non-functional Bump Mouse
|
||||
bool _bumpMouseIsWorking = true;
|
||||
ViewStyle _lastViewStyle = ViewStyle.defaultViewStyle();
|
||||
|
||||
Timer? _timerMobileFocusCanvasCursor;
|
||||
@@ -1840,9 +2179,18 @@ class CanvasModel with ChangeNotifier {
|
||||
|
||||
_resetScroll() => setScrollPercent(0.0, 0.0);
|
||||
|
||||
setScrollPercent(double x, double y) {
|
||||
_scrollX = x;
|
||||
_scrollY = y;
|
||||
void setScrollPercent(double x, double y) {
|
||||
_scrollX = x.isFinite ? x : 0.0;
|
||||
_scrollY = y.isFinite ? y : 0.0;
|
||||
}
|
||||
|
||||
void pushScrollPositionToUI(double scrollPixelX, double scrollPixelY) {
|
||||
if (_horizontal.hasClients) {
|
||||
_horizontal.jumpTo(scrollPixelX);
|
||||
}
|
||||
if (_vertical.hasClients) {
|
||||
_vertical.jumpTo(scrollPixelY);
|
||||
}
|
||||
}
|
||||
|
||||
ScrollController get scrollHorizontal => _horizontal;
|
||||
@@ -1867,10 +2215,32 @@ class CanvasModel with ChangeNotifier {
|
||||
double w = size.width - leftToEdge - rightToEdge;
|
||||
double h = size.height - topToEdge - bottomToEdge;
|
||||
if (isMobile) {
|
||||
// Account for horizontal safe area insets on both orientations.
|
||||
w = w - mediaData.padding.left - mediaData.padding.right;
|
||||
// Vertically, subtract the bottom keyboard inset (viewInsets.bottom) and any
|
||||
// bottom overlay (e.g. key-help tools) so the canvas is not covered.
|
||||
h = h -
|
||||
mediaData.viewInsets.bottom -
|
||||
(parent.target?.cursorModel.keyHelpToolsRectToAdjustCanvas?.bottom ??
|
||||
0);
|
||||
// Orientation-specific handling:
|
||||
// - Portrait: additionally subtract top padding (e.g. status bar / notch)
|
||||
// - Landscape: does not subtract mediaData.padding.top/bottom (home indicator auto-hides)
|
||||
final isPortrait = size.height > size.width;
|
||||
if (isPortrait) {
|
||||
// In portrait mode, subtract the top safe-area padding (e.g. status bar / notch)
|
||||
// so the remote image is not truncated, while keeping the bottom inset to avoid
|
||||
// introducing unnecessary blank space around the canvas.
|
||||
//
|
||||
// iOS -> Android, portrait, adjust mode:
|
||||
// h = h (no padding subtracted): top and bottom are truncated
|
||||
// https://github.com/user-attachments/assets/30ed4559-c27e-432b-847f-8fec23c9f998
|
||||
// h = h - top - bottom: extra blank spaces appear
|
||||
// https://github.com/user-attachments/assets/12a98817-3b4e-43aa-be0f-4b03cf364b7e
|
||||
// h = h - top (current): works fine
|
||||
// https://github.com/user-attachments/assets/95f047f2-7f47-4a36-8113-5023989a0c81
|
||||
h = h - mediaData.padding.top;
|
||||
}
|
||||
}
|
||||
return Size(w < 0 ? 0 : w, h < 0 ? 0 : h);
|
||||
}
|
||||
@@ -1957,30 +2327,47 @@ class CanvasModel with ChangeNotifier {
|
||||
}
|
||||
|
||||
tryUpdateScrollStyle(Duration duration, String? style) async {
|
||||
if (_scrollStyle != ScrollStyle.scrollbar) return;
|
||||
if (_scrollStyle == ScrollStyle.scrollauto) return;
|
||||
style ??= await bind.sessionGetViewStyle(sessionId: sessionId);
|
||||
if (style != kRemoteViewStyleOriginal && style != kRemoteViewStyleCustom) {
|
||||
return;
|
||||
}
|
||||
|
||||
_resetScroll();
|
||||
|
||||
Future.delayed(duration, () async {
|
||||
updateScrollPercent();
|
||||
});
|
||||
}
|
||||
|
||||
updateScrollStyle() async {
|
||||
Future<void> updateScrollStyle() async {
|
||||
final style = await bind.sessionGetScrollStyle(sessionId: sessionId);
|
||||
if (style == kRemoteScrollStyleBar) {
|
||||
_scrollStyle = ScrollStyle.scrollbar;
|
||||
|
||||
_scrollStyle =
|
||||
style != null ? ScrollStyle.fromString(style) : ScrollStyle.scrollauto;
|
||||
|
||||
if (_scrollStyle != ScrollStyle.scrollauto) {
|
||||
_resetScroll();
|
||||
} else {
|
||||
_scrollStyle = ScrollStyle.scrollauto;
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
update(double x, double y, double scale) {
|
||||
Future<void> initializeEdgeScrollEdgeThickness() async {
|
||||
final savedValue =
|
||||
await bind.sessionGetEdgeScrollEdgeThickness(sessionId: sessionId);
|
||||
|
||||
if (savedValue != null) {
|
||||
_edgeScrollEdgeThickness = savedValue;
|
||||
}
|
||||
}
|
||||
|
||||
void updateEdgeScrollEdgeThickness(int newThickness) {
|
||||
_edgeScrollEdgeThickness = newThickness;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void update(double x, double y, double scale) {
|
||||
_x = x;
|
||||
_y = y;
|
||||
_scale = scale;
|
||||
@@ -2007,7 +2394,33 @@ class CanvasModel with ChangeNotifier {
|
||||
static double get windowBorderWidth => stateGlobal.windowBorderWidth.value;
|
||||
static double get tabBarHeight => stateGlobal.tabBarHeight;
|
||||
|
||||
moveDesktopMouse(double x, double y) {
|
||||
void activateLocalCursor() {
|
||||
if (isDesktop || isWebDesktop) {
|
||||
try {
|
||||
RemoteCursorMovedState.find(id).value = false;
|
||||
} catch (e) {
|
||||
//
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void updateLocalCursor(double x, double y) {
|
||||
// If keyboard is not permitted, do not move cursor when mouse is moving.
|
||||
if (parent.target != null && parent.target!.ffiModel.keyboard) {
|
||||
// Draw cursor if is not desktop.
|
||||
if (!(isDesktop || isWebDesktop)) {
|
||||
parent.target!.cursorModel.moveLocal(x, y);
|
||||
} else {
|
||||
try {
|
||||
RemoteCursorMovedState.find(id).value = false;
|
||||
} catch (e) {
|
||||
//
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void moveDesktopMouse(double x, double y) {
|
||||
if (size.width == 0 || size.height == 0) {
|
||||
return;
|
||||
}
|
||||
@@ -2036,24 +2449,128 @@ class CanvasModel with ChangeNotifier {
|
||||
if (dxOffset != 0 || dyOffset != 0) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// If keyboard is not permitted, do not move cursor when mouse is moving.
|
||||
if (parent.target != null && parent.target!.ffiModel.keyboard) {
|
||||
// Draw cursor if is not desktop.
|
||||
if (!(isDesktop || isWebDesktop)) {
|
||||
parent.target!.cursorModel.moveLocal(x, y);
|
||||
void initializeEdgeScrollFallback(TickerProvider tickerProvider) {
|
||||
_edgeScrollFallbackState = EdgeScrollFallbackState(this, tickerProvider);
|
||||
}
|
||||
|
||||
void disableEdgeScroll() {
|
||||
_edgeScrollState = EdgeScrollState.inactive;
|
||||
cancelEdgeScroll();
|
||||
}
|
||||
|
||||
void rearmEdgeScroll() {
|
||||
_edgeScrollState = EdgeScrollState.armed;
|
||||
}
|
||||
|
||||
void cancelEdgeScroll() {
|
||||
_edgeScrollFallbackState.stop();
|
||||
}
|
||||
|
||||
(Vector2, Vector2) getScrollInfo() {
|
||||
final scrollPixel = Vector2(
|
||||
_horizontal.hasClients ? _horizontal.position.pixels : 0,
|
||||
_vertical.hasClients ? _vertical.position.pixels : 0);
|
||||
|
||||
final max = Vector2(
|
||||
_horizontal.hasClients ? _horizontal.position.maxScrollExtent : 0,
|
||||
_vertical.hasClients ? _vertical.position.maxScrollExtent : 0);
|
||||
|
||||
return (scrollPixel, max);
|
||||
}
|
||||
|
||||
void edgeScrollMouse(double x, double y) async {
|
||||
if ((_edgeScrollState == EdgeScrollState.inactive) ||
|
||||
(size.width == 0 || size.height == 0) ||
|
||||
!(_horizontal.hasClients || _vertical.hasClients)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_edgeScrollState == EdgeScrollState.armed) {
|
||||
// Edge scroll is armed to become active once the cursor
|
||||
// is observed within the rectangle interior to the
|
||||
// edge scroll regions. If the user has just moved the
|
||||
// cursor in from outside of the window, edge scrolling
|
||||
// doesn't happen yet.
|
||||
final clientArea = Rect.fromLTWH(0, 0, size.width, size.height);
|
||||
|
||||
final innerZone = clientArea.deflate(_edgeScrollEdgeThickness.toDouble());
|
||||
|
||||
if (innerZone.contains(Offset(x, y))) {
|
||||
_edgeScrollState = EdgeScrollState.active;
|
||||
} else {
|
||||
try {
|
||||
RemoteCursorMovedState.find(id).value = false;
|
||||
} catch (e) {
|
||||
//
|
||||
}
|
||||
// Not yet.
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var dxOffset = 0.0;
|
||||
var dyOffset = 0.0;
|
||||
|
||||
if (x < _edgeScrollEdgeThickness) {
|
||||
dxOffset = x - _edgeScrollEdgeThickness;
|
||||
} else if (x >= size.width - _edgeScrollEdgeThickness) {
|
||||
dxOffset = x - (size.width - _edgeScrollEdgeThickness);
|
||||
}
|
||||
|
||||
if (y < _edgeScrollEdgeThickness) {
|
||||
dyOffset = y - _edgeScrollEdgeThickness;
|
||||
} else if (y >= size.height - _edgeScrollEdgeThickness) {
|
||||
dyOffset = y - (size.height - _edgeScrollEdgeThickness);
|
||||
}
|
||||
|
||||
var encroachment = Vector2(dxOffset, dyOffset);
|
||||
|
||||
var (scrollPixel, max) = getScrollInfo();
|
||||
|
||||
encroachment.clamp(-scrollPixel, max - scrollPixel);
|
||||
|
||||
if (encroachment.length2 == 0) {
|
||||
_edgeScrollFallbackState.stop();
|
||||
} else {
|
||||
var bumpAmount = -encroachment;
|
||||
|
||||
// Round away from 0: this ensures that the mouse will be bumped clear of
|
||||
// whichever edge scroll zone(s) it is in
|
||||
bumpAmount.x += bumpAmount.x.sign * 0.5;
|
||||
bumpAmount.y += bumpAmount.y.sign * 0.5;
|
||||
|
||||
var bumpMouseSucceeded = _bumpMouseIsWorking &&
|
||||
(await rustDeskWinManager.call(WindowType.Main, kWindowBumpMouse,
|
||||
{"dx": bumpAmount.x.round(), "dy": bumpAmount.y.round()}))
|
||||
.result;
|
||||
|
||||
if (bumpMouseSucceeded) {
|
||||
performEdgeScroll(encroachment);
|
||||
} else {
|
||||
// If we can't BumpMouse, then we switch to slower scrolling with autorepeat
|
||||
|
||||
// Don't keep hammering BumpMouse if it's not working.
|
||||
_bumpMouseIsWorking = false;
|
||||
|
||||
// Keep scrolling as long as the user is overtop of an edge.
|
||||
_edgeScrollFallbackState.setEncroachment(encroachment);
|
||||
_edgeScrollFallbackState.start();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
set scale(v) {
|
||||
_scale = v;
|
||||
void performEdgeScroll(Vector2 delta) {
|
||||
var (scrollPixel, max) = getScrollInfo();
|
||||
|
||||
scrollPixel += delta;
|
||||
|
||||
scrollPixel.clamp(Vector2.zero(), max);
|
||||
|
||||
var scrollPixelPercent = scrollPixel.clone();
|
||||
|
||||
scrollPixelPercent.divide(max);
|
||||
scrollPixelPercent.scale(100.0);
|
||||
|
||||
setScrollPercent(scrollPixelPercent.x, scrollPixelPercent.y);
|
||||
pushScrollPositionToUI(scrollPixel.x, scrollPixel.y);
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -2590,9 +3107,10 @@ class CursorModel with ChangeNotifier {
|
||||
var cx = r.center.dx;
|
||||
var cy = r.center.dy;
|
||||
var tryMoveCanvasX = false;
|
||||
final displayRect = parent.target?.ffiModel.rect;
|
||||
if (dx > 0) {
|
||||
final maxCanvasCanMove = _displayOriginX +
|
||||
(parent.target?.imageModel.image!.width ?? 1280) -
|
||||
(displayRect?.width ?? 1280) -
|
||||
r.right.roundToDouble();
|
||||
tryMoveCanvasX = _x + dx > cx && maxCanvasCanMove > 0;
|
||||
if (tryMoveCanvasX) {
|
||||
@@ -2614,7 +3132,7 @@ class CursorModel with ChangeNotifier {
|
||||
var tryMoveCanvasY = false;
|
||||
if (dy > 0) {
|
||||
final mayCanvasCanMove = _displayOriginY +
|
||||
(parent.target?.imageModel.image!.height ?? 720) -
|
||||
(displayRect?.height ?? 720) -
|
||||
r.bottom.roundToDouble();
|
||||
tryMoveCanvasY = _y + dy > cy && mayCanvasCanMove > 0;
|
||||
if (tryMoveCanvasY) {
|
||||
@@ -3035,7 +3553,6 @@ class FFI {
|
||||
var version = '';
|
||||
var connType = ConnType.defaultConn;
|
||||
var closed = false;
|
||||
var auditNote = '';
|
||||
|
||||
/// dialogManager use late to ensure init after main page binding [globalKey]
|
||||
late final dialogManager = OverlayDialogManager();
|
||||
@@ -3126,7 +3643,6 @@ class FFI {
|
||||
List<int>? displays,
|
||||
}) {
|
||||
closed = false;
|
||||
auditNote = '';
|
||||
if (isMobile) mobileReset();
|
||||
assert(
|
||||
(!(isPortForward && isViewCamera)) &&
|
||||
@@ -3318,6 +3834,7 @@ class FFI {
|
||||
dialogManager.dismissAll();
|
||||
await canvasModel.updateViewStyle();
|
||||
await canvasModel.updateScrollStyle();
|
||||
await canvasModel.initializeEdgeScrollEdgeThickness();
|
||||
for (final cb in imageModel.callbacksOnFirstImage) {
|
||||
cb(id);
|
||||
}
|
||||
@@ -3365,6 +3882,8 @@ class FFI {
|
||||
ffiModel.clear();
|
||||
canvasModel.clear();
|
||||
inputModel.resetModifiers();
|
||||
// Dispose relative mouse mode resources to ensure cursor is restored
|
||||
inputModel.disposeRelativeMouseMode();
|
||||
if (closeSession) {
|
||||
await bind.sessionClose(sessionId: sessionId);
|
||||
}
|
||||
|
||||
1061
flutter/lib/models/relative_mouse_model.dart
Normal file
1061
flutter/lib/models/relative_mouse_model.dart
Normal file
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,6 @@ import 'package:flutter_hbb/mobile/pages/settings_page.dart';
|
||||
import 'package:flutter_hbb/models/chat_model.dart';
|
||||
import 'package:flutter_hbb/models/platform_model.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import '../common.dart';
|
||||
@@ -51,6 +50,8 @@ class ServerModel with ChangeNotifier {
|
||||
|
||||
Timer? cmHiddenTimer;
|
||||
|
||||
final _wakelockKey = UniqueKey();
|
||||
|
||||
bool get isStart => _isStart;
|
||||
|
||||
bool get mediaOk => _mediaOk;
|
||||
@@ -466,10 +467,8 @@ class ServerModel with ChangeNotifier {
|
||||
await parent.target?.invokeMethod("stop_service");
|
||||
await bind.mainStopService();
|
||||
notifyListeners();
|
||||
if (!isLinux) {
|
||||
// current linux is not supported
|
||||
WakelockPlus.disable();
|
||||
}
|
||||
// for androidUpdatekeepScreenOn only
|
||||
WakelockManager.disable(_wakelockKey);
|
||||
}
|
||||
|
||||
Future<bool> setPermanentPassword(String newPW) async {
|
||||
@@ -613,12 +612,12 @@ class ServerModel with ChangeNotifier {
|
||||
void showLoginDialog(Client client) {
|
||||
showClientDialog(
|
||||
client,
|
||||
client.isFileTransfer
|
||||
? "Transfer file"
|
||||
client.isFileTransfer
|
||||
? "Transfer file"
|
||||
: client.isViewCamera
|
||||
? "View camera"
|
||||
: client.isTerminal
|
||||
? "Terminal"
|
||||
: client.isTerminal
|
||||
? "Terminal"
|
||||
: "Share screen",
|
||||
'Do you accept?',
|
||||
'android_new_connection_tip',
|
||||
@@ -797,12 +796,10 @@ class ServerModel with ChangeNotifier {
|
||||
final on = ((keepScreenOn == KeepScreenOn.serviceOn) && _isStart) ||
|
||||
(keepScreenOn == KeepScreenOn.duringControlled &&
|
||||
_clients.map((e) => !e.disconnected).isNotEmpty);
|
||||
if (on != await WakelockPlus.enabled) {
|
||||
if (on) {
|
||||
WakelockPlus.enable();
|
||||
} else {
|
||||
WakelockPlus.disable();
|
||||
}
|
||||
if (on) {
|
||||
WakelockManager.enable(_wakelockKey, isServer: true);
|
||||
} else {
|
||||
WakelockManager.disable(_wakelockKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'package:desktop_multi_window/desktop_multi_window.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
@@ -30,6 +29,11 @@ class StateGlobal {
|
||||
|
||||
String _inputSource = '';
|
||||
|
||||
// Track relative mouse mode state for each peer connection.
|
||||
// Key: peerId, Value: true if relative mouse mode is active.
|
||||
// Note: This is session-only runtime state, NOT persisted to config.
|
||||
final RxMap<String, bool> relativeMouseModeState = <String, bool>{}.obs;
|
||||
|
||||
// Use for desktop -> remote toolbar -> resolution
|
||||
final Map<String, Map<int, String?>> _lastResolutionGroupValues = {};
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ class TerminalModel with ChangeNotifier {
|
||||
|
||||
bool get isPeerWindows => parent.ffiModel.pi.platform == kPeerPlatformWindows;
|
||||
|
||||
void Function(int w, int h, int pw, int ph)? onResizeExternal;
|
||||
|
||||
Future<void> _handleInput(String data) async {
|
||||
// If we press the `Enter` button on Android,
|
||||
// `data` can be '\r' or '\n' when using different keyboards.
|
||||
@@ -68,6 +70,10 @@ class TerminalModel with ChangeNotifier {
|
||||
if (w > 0 && h > 0 && pw > 0 && ph > 0) {
|
||||
debugPrint(
|
||||
'[TerminalModel] Terminal resized to ${w}x$h (pixel: ${pw}x$ph)');
|
||||
|
||||
// This piece of code must be placed before the conditional check in order to initialize properly.
|
||||
onResizeExternal?.call(w, h, pw, ph);
|
||||
|
||||
if (_terminalOpened) {
|
||||
// Notify remote terminal of resize
|
||||
try {
|
||||
@@ -140,6 +146,10 @@ class TerminalModel with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> sendVirtualKey(String data) async {
|
||||
return _handleInput(data);
|
||||
}
|
||||
|
||||
Future<void> closeTerminal() async {
|
||||
if (_terminalOpened) {
|
||||
try {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../models/platform_model.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
export 'package:http/http.dart' show Response;
|
||||
|
||||
enum HttpMethod { get, post, put, delete }
|
||||
@@ -15,11 +17,19 @@ class HttpService {
|
||||
}) async {
|
||||
headers ??= {'Content-Type': 'application/json'};
|
||||
|
||||
// Determine if there is currently a proxy setting, and if so, use FFI to call the Rust HTTP method.
|
||||
final isProxy = await bind.mainGetProxyStatus();
|
||||
// Use Rust HTTP implementation for non-web platforms for consistency.
|
||||
var useFlutterHttp = (isWeb || kIsWeb);
|
||||
if (!useFlutterHttp) {
|
||||
final enableFlutterHttpOnRust =
|
||||
mainGetLocalBoolOptionSync(kOptionEnableFlutterHttpOnRust);
|
||||
// Use flutter http if:
|
||||
// Not `enableFlutterHttpOnRust` and no proxy is set
|
||||
useFlutterHttp =
|
||||
!(enableFlutterHttpOnRust || await bind.mainGetProxyStatus());
|
||||
}
|
||||
|
||||
if (!isProxy) {
|
||||
return await _pollFultterHttp(url, method, headers: headers, body: body);
|
||||
if (useFlutterHttp) {
|
||||
return await _pollFlutterHttp(url, method, headers: headers, body: body);
|
||||
}
|
||||
|
||||
String headersJson = jsonEncode(headers);
|
||||
@@ -34,7 +44,7 @@ class HttpService {
|
||||
return _parseHttpResponse(resJson);
|
||||
}
|
||||
|
||||
Future<http.Response> _pollFultterHttp(
|
||||
Future<http.Response> _pollFlutterHttp(
|
||||
Uri url,
|
||||
HttpMethod method, {
|
||||
Map<String, String>? headers,
|
||||
@@ -87,7 +97,8 @@ class HttpService {
|
||||
int statusCode = parsedJson['status_code'];
|
||||
return http.Response(body, statusCode, headers: headers);
|
||||
} catch (e) {
|
||||
throw Exception('Failed to parse response: $e');
|
||||
print('Failed to parse response\n$responseJson\nError:\n$e');
|
||||
throw Exception('Failed to parse response.\n$responseJson');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,18 @@ class RdPlatformChannel {
|
||||
|
||||
static RdPlatformChannel get instance => _windowUtil;
|
||||
|
||||
final MethodChannel _osxMethodChannel =
|
||||
MethodChannel("org.rustdesk.rustdesk/macos");
|
||||
final MethodChannel _hostMethodChannel =
|
||||
MethodChannel("org.rustdesk.rustdesk/host");
|
||||
|
||||
/// Bump the position of the mouse cursor, if applicable
|
||||
Future<bool> bumpMouse({required int dx, required int dy}) async {
|
||||
// No debug output; this call is too chatty.
|
||||
|
||||
bool? result = await _hostMethodChannel
|
||||
.invokeMethod("bumpMouse", {"dx": dx, "dy": dy});
|
||||
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
/// Change the theme of the system window
|
||||
Future<void> changeSystemWindowTheme(SystemWindowTheme theme) {
|
||||
@@ -23,13 +33,13 @@ class RdPlatformChannel {
|
||||
print(
|
||||
"[Window ${kWindowId ?? 'Main'}] change system window theme to ${theme.name}");
|
||||
}
|
||||
return _osxMethodChannel
|
||||
return _hostMethodChannel
|
||||
.invokeMethod("setWindowTheme", {"themeName": theme.name});
|
||||
}
|
||||
|
||||
/// Terminate .app manually.
|
||||
Future<void> terminate() {
|
||||
assert(isMacOS);
|
||||
return _osxMethodChannel.invokeMethod("terminate");
|
||||
return _hostMethodChannel.invokeMethod("terminate");
|
||||
}
|
||||
}
|
||||
|
||||
58
flutter/lib/utils/relative_mouse_accumulator.dart
Normal file
58
flutter/lib/utils/relative_mouse_accumulator.dart
Normal file
@@ -0,0 +1,58 @@
|
||||
/// A small helper for accumulating fractional mouse deltas and emitting integer deltas.
|
||||
///
|
||||
/// Relative mouse mode uses integer deltas on the wire, but Flutter pointer deltas
|
||||
/// are doubles. This accumulator preserves sub-pixel movement by carrying the
|
||||
/// fractional remainder across events.
|
||||
class RelativeMouseDelta {
|
||||
final int x;
|
||||
final int y;
|
||||
|
||||
const RelativeMouseDelta(this.x, this.y);
|
||||
}
|
||||
|
||||
/// Accumulates fractional mouse deltas and returns integer deltas when available.
|
||||
class RelativeMouseAccumulator {
|
||||
double _fracX = 0.0;
|
||||
double _fracY = 0.0;
|
||||
|
||||
/// Adds a delta and returns an integer delta when at least one axis reaches a
|
||||
/// magnitude of 1px (after truncation towards zero).
|
||||
///
|
||||
/// If [maxDelta] is > 0, the returned integer delta is clamped to
|
||||
/// [-maxDelta, maxDelta] on each axis.
|
||||
RelativeMouseDelta? add(
|
||||
double dx,
|
||||
double dy, {
|
||||
required int maxDelta,
|
||||
}) {
|
||||
// Guard against misuse: negative maxDelta would silently disable clamping.
|
||||
assert(maxDelta >= 0, 'maxDelta must be non-negative');
|
||||
|
||||
_fracX += dx;
|
||||
_fracY += dy;
|
||||
|
||||
int intX = _fracX.truncate();
|
||||
int intY = _fracY.truncate();
|
||||
|
||||
if (intX == 0 && intY == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Clamp before subtracting so excess movement is preserved in the accumulator
|
||||
// rather than being permanently discarded during spikes.
|
||||
if (maxDelta > 0) {
|
||||
intX = intX.clamp(-maxDelta, maxDelta);
|
||||
intY = intY.clamp(-maxDelta, maxDelta);
|
||||
}
|
||||
|
||||
_fracX -= intX;
|
||||
_fracY -= intY;
|
||||
|
||||
return RelativeMouseDelta(intX, intY);
|
||||
}
|
||||
|
||||
void reset() {
|
||||
_fracX = 0.0;
|
||||
_fracY = 0.0;
|
||||
}
|
||||
}
|
||||
@@ -812,7 +812,7 @@ class RustdeskImpl {
|
||||
}
|
||||
|
||||
String mainGetAppNameSync({dynamic hint}) {
|
||||
return 'RustDesk';
|
||||
return js.context.callMethod('getByName', ['app-name']);
|
||||
}
|
||||
|
||||
String mainUriPrefixSync({dynamic hint}) {
|
||||
@@ -1609,23 +1609,28 @@ class RustdeskImpl {
|
||||
}
|
||||
|
||||
bool isCustomClient({dynamic hint}) {
|
||||
return false;
|
||||
// is_custom_client() checks if app name is not "RustDesk"
|
||||
return mainGetAppNameSync(hint: hint) != "RustDesk";
|
||||
}
|
||||
|
||||
bool isDisableSettings({dynamic hint}) {
|
||||
return false;
|
||||
// Checks HARD_SETTINGS["disable-settings"] == "Y"
|
||||
return mainGetHardOption(key: "disable-settings", hint: hint) == "Y";
|
||||
}
|
||||
|
||||
bool isDisableAb({dynamic hint}) {
|
||||
return false;
|
||||
// Checks HARD_SETTINGS["disable-ab"] == "Y"
|
||||
return mainGetHardOption(key: "disable-ab", hint: hint) == "Y";
|
||||
}
|
||||
|
||||
bool isDisableGroupPanel({dynamic hint}) {
|
||||
return false;
|
||||
// Checks LocalConfig::get_option("disable-group-panel") == "Y"
|
||||
return mainGetLocalOption(key: "disable-group-panel", hint: hint) == "Y";
|
||||
}
|
||||
|
||||
bool isDisableAccount({dynamic hint}) {
|
||||
return false;
|
||||
// Checks HARD_SETTINGS["disable-account"] == "Y"
|
||||
return mainGetHardOption(key: "disable-account", hint: hint) == "Y";
|
||||
}
|
||||
|
||||
bool isDisableInstallation({dynamic hint}) {
|
||||
@@ -1748,7 +1753,7 @@ class RustdeskImpl {
|
||||
}
|
||||
|
||||
String mainGetHardOption({required String key, dynamic hint}) {
|
||||
throw UnimplementedError("mainGetHardOption");
|
||||
return mainGetLocalOption(key: key, hint: hint);
|
||||
}
|
||||
|
||||
Future<void> mainCheckHwcodec({dynamic hint}) {
|
||||
@@ -1821,7 +1826,7 @@ class RustdeskImpl {
|
||||
}
|
||||
|
||||
String mainGetBuildinOption({required String key, dynamic hint}) {
|
||||
return '';
|
||||
return mainGetLocalOption(key: key, hint: hint);
|
||||
}
|
||||
|
||||
String installInstallOptions({dynamic hint}) {
|
||||
@@ -1979,5 +1984,55 @@ class RustdeskImpl {
|
||||
]));
|
||||
}
|
||||
|
||||
Future<int?> sessionGetEdgeScrollEdgeThickness(
|
||||
{required UuidValue sessionId, dynamic hint}) {
|
||||
final thickness = js.context.callMethod(
|
||||
'getByName', ['option:session', 'edge-scroll-edge-thickness']);
|
||||
return Future(() => int.tryParse(thickness) ?? 100);
|
||||
}
|
||||
|
||||
Future<void> sessionSetEdgeScrollEdgeThickness(
|
||||
{required UuidValue sessionId, required int value, dynamic hint}) {
|
||||
return Future(() => js.context.callMethod('setByName',
|
||||
['option:session', 'edge-scroll-edge-thickness', value.toString()]));
|
||||
}
|
||||
|
||||
String sessionGetConnSessionId({required UuidValue sessionId, dynamic hint}) {
|
||||
return js.context.callMethod('getByName', ['conn_session_id']);
|
||||
}
|
||||
|
||||
bool willSessionCloseCloseSession(
|
||||
{required UuidValue sessionId, dynamic hint}) {
|
||||
return true;
|
||||
}
|
||||
|
||||
String sessionGetLastAuditNote({required UuidValue sessionId, dynamic hint}) {
|
||||
return js.context.callMethod('getByName', ['last_audit_note']);
|
||||
}
|
||||
|
||||
Future<void> sessionSetAuditGuid(
|
||||
{required UuidValue sessionId, required String guid, dynamic hint}) {
|
||||
return Future(
|
||||
() => js.context.callMethod('setByName', ['audit_guid', guid]));
|
||||
}
|
||||
|
||||
String sessionGetAuditGuid({required UuidValue sessionId, dynamic hint}) {
|
||||
return js.context.callMethod('getByName', ['audit_guid']);
|
||||
}
|
||||
|
||||
bool mainSetCursorPosition({required int x, required int y, dynamic hint}) {
|
||||
return false;
|
||||
}
|
||||
|
||||
bool mainClipCursor(
|
||||
{required int left,
|
||||
required int top,
|
||||
required int right,
|
||||
required int bottom,
|
||||
required bool enable,
|
||||
dynamic hint}) {
|
||||
return false;
|
||||
}
|
||||
|
||||
void dispose() {}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Project-level configuration.
|
||||
cmake_minimum_required(VERSION 3.10)
|
||||
project(runner LANGUAGES CXX)
|
||||
project(runner LANGUAGES C CXX)
|
||||
|
||||
# The name of the executable created for the application. Change this to change
|
||||
# the on-disk name of your application.
|
||||
@@ -54,6 +54,55 @@ add_subdirectory(${FLUTTER_MANAGED_DIR})
|
||||
find_package(PkgConfig REQUIRED)
|
||||
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
|
||||
|
||||
# Wayland protocol for keyboard shortcuts inhibit
|
||||
pkg_check_modules(WAYLAND_CLIENT IMPORTED_TARGET wayland-client)
|
||||
pkg_check_modules(WAYLAND_PROTOCOLS_PKG QUIET wayland-protocols)
|
||||
pkg_check_modules(WAYLAND_SCANNER_PKG QUIET wayland-scanner)
|
||||
|
||||
if(WAYLAND_PROTOCOLS_PKG_FOUND)
|
||||
pkg_get_variable(WAYLAND_PROTOCOLS_DIR wayland-protocols pkgdatadir)
|
||||
endif()
|
||||
if(WAYLAND_SCANNER_PKG_FOUND)
|
||||
pkg_get_variable(WAYLAND_SCANNER wayland-scanner wayland_scanner)
|
||||
endif()
|
||||
|
||||
if(WAYLAND_CLIENT_FOUND AND WAYLAND_PROTOCOLS_DIR AND WAYLAND_SCANNER)
|
||||
set(KEYBOARD_SHORTCUTS_INHIBIT_PROTOCOL
|
||||
"${WAYLAND_PROTOCOLS_DIR}/unstable/keyboard-shortcuts-inhibit/keyboard-shortcuts-inhibit-unstable-v1.xml")
|
||||
|
||||
if(EXISTS ${KEYBOARD_SHORTCUTS_INHIBIT_PROTOCOL})
|
||||
set(WAYLAND_GENERATED_DIR "${CMAKE_CURRENT_BINARY_DIR}/wayland-protocols")
|
||||
file(MAKE_DIRECTORY ${WAYLAND_GENERATED_DIR})
|
||||
|
||||
# Generate client header
|
||||
add_custom_command(
|
||||
OUTPUT "${WAYLAND_GENERATED_DIR}/keyboard-shortcuts-inhibit-unstable-v1-client-protocol.h"
|
||||
COMMAND ${WAYLAND_SCANNER} client-header
|
||||
${KEYBOARD_SHORTCUTS_INHIBIT_PROTOCOL}
|
||||
"${WAYLAND_GENERATED_DIR}/keyboard-shortcuts-inhibit-unstable-v1-client-protocol.h"
|
||||
DEPENDS ${KEYBOARD_SHORTCUTS_INHIBIT_PROTOCOL}
|
||||
VERBATIM
|
||||
)
|
||||
|
||||
# Generate protocol code
|
||||
add_custom_command(
|
||||
OUTPUT "${WAYLAND_GENERATED_DIR}/keyboard-shortcuts-inhibit-unstable-v1-protocol.c"
|
||||
COMMAND ${WAYLAND_SCANNER} private-code
|
||||
${KEYBOARD_SHORTCUTS_INHIBIT_PROTOCOL}
|
||||
"${WAYLAND_GENERATED_DIR}/keyboard-shortcuts-inhibit-unstable-v1-protocol.c"
|
||||
DEPENDS ${KEYBOARD_SHORTCUTS_INHIBIT_PROTOCOL}
|
||||
VERBATIM
|
||||
)
|
||||
|
||||
set(WAYLAND_PROTOCOL_SOURCES
|
||||
"${WAYLAND_GENERATED_DIR}/keyboard-shortcuts-inhibit-unstable-v1-client-protocol.h"
|
||||
"${WAYLAND_GENERATED_DIR}/keyboard-shortcuts-inhibit-unstable-v1-protocol.c"
|
||||
)
|
||||
|
||||
set(HAS_KEYBOARD_SHORTCUTS_INHIBIT TRUE)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}")
|
||||
|
||||
# Define the application target. To change its name, change BINARY_NAME above,
|
||||
@@ -63,7 +112,11 @@ add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}")
|
||||
add_executable(${BINARY_NAME}
|
||||
"main.cc"
|
||||
"my_application.cc"
|
||||
"wayland_shortcuts_inhibit.cc"
|
||||
"bump_mouse.cc"
|
||||
"bump_mouse_x11.cc"
|
||||
"${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
|
||||
${WAYLAND_PROTOCOL_SOURCES}
|
||||
)
|
||||
|
||||
# Apply the standard set of build settings. This can be removed for applications
|
||||
@@ -76,6 +129,13 @@ target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK)
|
||||
target_link_libraries(${BINARY_NAME} PRIVATE ${CMAKE_DL_LIBS})
|
||||
# target_link_libraries(${BINARY_NAME} PRIVATE librustdesk)
|
||||
|
||||
# Wayland support for keyboard shortcuts inhibit
|
||||
if(HAS_KEYBOARD_SHORTCUTS_INHIBIT)
|
||||
target_compile_definitions(${BINARY_NAME} PRIVATE HAS_KEYBOARD_SHORTCUTS_INHIBIT)
|
||||
target_include_directories(${BINARY_NAME} PRIVATE ${WAYLAND_GENERATED_DIR})
|
||||
target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::WAYLAND_CLIENT)
|
||||
endif()
|
||||
|
||||
# Run the Flutter tool portions of the build. This must not be removed.
|
||||
add_dependencies(${BINARY_NAME} flutter_assemble)
|
||||
|
||||
|
||||
18
flutter/linux/bump_mouse.cc
Normal file
18
flutter/linux/bump_mouse.cc
Normal file
@@ -0,0 +1,18 @@
|
||||
#include "bump_mouse.h"
|
||||
|
||||
#include "bump_mouse_x11.h"
|
||||
|
||||
#include <gdk/gdkx.h>
|
||||
|
||||
bool bump_mouse(int dx, int dy)
|
||||
{
|
||||
GdkDisplay *display = gdk_display_get_default();
|
||||
|
||||
if (GDK_IS_X11_DISPLAY(display)) {
|
||||
return bump_mouse_x11(dx, dy);
|
||||
}
|
||||
else {
|
||||
// Don't know how to support this.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
3
flutter/linux/bump_mouse.h
Normal file
3
flutter/linux/bump_mouse.h
Normal file
@@ -0,0 +1,3 @@
|
||||
#pragma once
|
||||
|
||||
bool bump_mouse(int dx, int dy);
|
||||
30
flutter/linux/bump_mouse_x11.cc
Normal file
30
flutter/linux/bump_mouse_x11.cc
Normal file
@@ -0,0 +1,30 @@
|
||||
#include "bump_mouse.h"
|
||||
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
#include <gdk/gdkx.h>
|
||||
|
||||
#include <iostream>
|
||||
|
||||
bool bump_mouse_x11(int dx, int dy)
|
||||
{
|
||||
GdkDevice *mouse_device;
|
||||
|
||||
#if GTK_CHECK_VERSION(3, 20, 0)
|
||||
auto seat = gdk_display_get_default_seat(gdk_display_get_default());
|
||||
|
||||
mouse_device = gdk_seat_get_pointer(seat);
|
||||
#else
|
||||
auto devman = gdk_display_get_device_manager(gdk_display_get_default());
|
||||
|
||||
mouse_device = gdk_device_manager_get_client_pointer(devman);
|
||||
#endif
|
||||
|
||||
GdkScreen *screen;
|
||||
gint x, y;
|
||||
|
||||
gdk_device_get_position(mouse_device, &screen, &x, &y);
|
||||
gdk_device_warp(mouse_device, screen, x + dx, y + dy);
|
||||
|
||||
return true;
|
||||
}
|
||||
3
flutter/linux/bump_mouse_x11.h
Normal file
3
flutter/linux/bump_mouse_x11.h
Normal file
@@ -0,0 +1,3 @@
|
||||
#pragma once
|
||||
|
||||
bool bump_mouse_x11(int dx, int dy);
|
||||
@@ -1,19 +1,29 @@
|
||||
#include "my_application.h"
|
||||
|
||||
#include "bump_mouse.h"
|
||||
|
||||
#include <flutter_linux/flutter_linux.h>
|
||||
#ifdef GDK_WINDOWING_X11
|
||||
#include <gdk/gdkx.h>
|
||||
#endif
|
||||
#if defined(GDK_WINDOWING_WAYLAND) && defined(HAS_KEYBOARD_SHORTCUTS_INHIBIT)
|
||||
#include "wayland_shortcuts_inhibit.h"
|
||||
#endif
|
||||
|
||||
#include <desktop_multi_window/desktop_multi_window_plugin.h>
|
||||
|
||||
#include "flutter/generated_plugin_registrant.h"
|
||||
|
||||
struct _MyApplication {
|
||||
GtkApplication parent_instance;
|
||||
char** dart_entrypoint_arguments;
|
||||
FlMethodChannel* host_channel;
|
||||
};
|
||||
|
||||
G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION)
|
||||
|
||||
void host_channel_call_handler(FlMethodChannel* channel, FlMethodCall* method_call, gpointer user_data);
|
||||
|
||||
GtkWidget *find_gl_area(GtkWidget *widget);
|
||||
void try_set_transparent(GtkWindow* window, GdkScreen* screen, FlView* view);
|
||||
|
||||
@@ -24,10 +34,11 @@ GtkWidget *find_gl_area(GtkWidget *widget);
|
||||
// Implements GApplication::activate.
|
||||
static void my_application_activate(GApplication* application) {
|
||||
MyApplication* self = MY_APPLICATION(application);
|
||||
|
||||
GtkWindow* window =
|
||||
GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application)));
|
||||
gtk_window_set_decorated(window, FALSE);
|
||||
// try setting icon for rustdesk, which uses the system cache
|
||||
// try setting icon for rustdesk, which uses the system cache
|
||||
GtkIconTheme* theme = gtk_icon_theme_get_default();
|
||||
gint icons[4] = {256, 128, 64, 32};
|
||||
for (int i = 0; i < 4; i++) {
|
||||
@@ -85,8 +96,26 @@ static void my_application_activate(GApplication* application) {
|
||||
gtk_widget_show(GTK_WIDGET(window));
|
||||
gtk_widget_show(GTK_WIDGET(view));
|
||||
|
||||
#if defined(GDK_WINDOWING_WAYLAND) && defined(HAS_KEYBOARD_SHORTCUTS_INHIBIT)
|
||||
// Register callback for sub-windows created by desktop_multi_window plugin
|
||||
// Only sub-windows (remote windows) need keyboard shortcuts inhibition
|
||||
desktop_multi_window_plugin_set_window_created_callback(
|
||||
(WindowCreatedCallback)wayland_shortcuts_inhibit_init_for_subwindow);
|
||||
#endif
|
||||
|
||||
fl_register_plugins(FL_PLUGIN_REGISTRY(view));
|
||||
|
||||
g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new();
|
||||
self->host_channel = fl_method_channel_new(
|
||||
fl_engine_get_binary_messenger(fl_view_get_engine(view)),
|
||||
"org.rustdesk.rustdesk/host",
|
||||
FL_METHOD_CODEC(codec));
|
||||
fl_method_channel_set_method_call_handler(
|
||||
self->host_channel,
|
||||
host_channel_call_handler,
|
||||
self,
|
||||
nullptr);
|
||||
|
||||
gtk_widget_grab_focus(GTK_WIDGET(view));
|
||||
}
|
||||
|
||||
@@ -113,6 +142,7 @@ static gboolean my_application_local_command_line(GApplication* application, gch
|
||||
static void my_application_dispose(GObject* object) {
|
||||
MyApplication* self = MY_APPLICATION(object);
|
||||
g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev);
|
||||
g_clear_object(&self->host_channel);
|
||||
G_OBJECT_CLASS(my_application_parent_class)->dispose(object);
|
||||
}
|
||||
|
||||
@@ -131,6 +161,61 @@ MyApplication* my_application_new() {
|
||||
nullptr));
|
||||
}
|
||||
|
||||
void host_channel_call_handler(FlMethodChannel* channel, FlMethodCall* method_call, gpointer user_data)
|
||||
{
|
||||
if (strcmp(fl_method_call_get_name(method_call), "bumpMouse") == 0) {
|
||||
FlValue *args = fl_method_call_get_args(method_call);
|
||||
|
||||
FlValue *dxValue = nullptr;
|
||||
FlValue *dyValue = nullptr;
|
||||
|
||||
switch (fl_value_get_type(args))
|
||||
{
|
||||
case FL_VALUE_TYPE_MAP:
|
||||
{
|
||||
dxValue = fl_value_lookup_string(args, "dx");
|
||||
dyValue = fl_value_lookup_string(args, "dy");
|
||||
|
||||
break;
|
||||
}
|
||||
case FL_VALUE_TYPE_LIST:
|
||||
{
|
||||
int listSize = fl_value_get_length(args);
|
||||
|
||||
dxValue = (listSize >= 1) ? fl_value_get_list_value(args, 0) : nullptr;
|
||||
dyValue = (listSize >= 2) ? fl_value_get_list_value(args, 1) : nullptr;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
default: break;
|
||||
}
|
||||
|
||||
int dx = 0, dy = 0;
|
||||
|
||||
if (dxValue && (fl_value_get_type(dxValue) == FL_VALUE_TYPE_INT)) {
|
||||
dx = fl_value_get_int(dxValue);
|
||||
}
|
||||
|
||||
if (dyValue && (fl_value_get_type(dyValue) == FL_VALUE_TYPE_INT)) {
|
||||
dy = fl_value_get_int(dyValue);
|
||||
}
|
||||
|
||||
bool result = bump_mouse(dx, dy);
|
||||
|
||||
FlValue *result_value = fl_value_new_bool(result);
|
||||
|
||||
GError *error = nullptr;
|
||||
|
||||
if (!fl_method_call_respond_success(method_call, result_value, &error)) {
|
||||
g_warning("Failed to send Flutter Platform Channel response: %s", error->message);
|
||||
g_error_free(error);
|
||||
}
|
||||
|
||||
fl_value_unref(result_value);
|
||||
}
|
||||
}
|
||||
|
||||
GtkWidget *find_gl_area(GtkWidget *widget)
|
||||
{
|
||||
if (GTK_IS_GL_AREA(widget)) {
|
||||
@@ -160,7 +245,7 @@ void try_set_transparent(GtkWindow* window, GdkScreen* screen, FlView* view)
|
||||
GtkWidget *gl_area = NULL;
|
||||
|
||||
printf("Try setting transparent\n");
|
||||
|
||||
|
||||
gl_area = find_gl_area(GTK_WIDGET(view));
|
||||
if (gl_area != NULL) {
|
||||
gtk_gl_area_set_has_alpha(GTK_GL_AREA(gl_area), TRUE);
|
||||
|
||||
244
flutter/linux/wayland_shortcuts_inhibit.cc
Normal file
244
flutter/linux/wayland_shortcuts_inhibit.cc
Normal file
@@ -0,0 +1,244 @@
|
||||
// Wayland keyboard shortcuts inhibit implementation
|
||||
// Uses the zwp_keyboard_shortcuts_inhibit_manager_v1 protocol to request
|
||||
// the compositor to disable system shortcuts for specific windows.
|
||||
|
||||
#include "wayland_shortcuts_inhibit.h"
|
||||
|
||||
#if defined(GDK_WINDOWING_WAYLAND) && defined(HAS_KEYBOARD_SHORTCUTS_INHIBIT)
|
||||
|
||||
#include <cstring>
|
||||
#include <gdk/gdkwayland.h>
|
||||
#include <wayland-client.h>
|
||||
#include "keyboard-shortcuts-inhibit-unstable-v1-client-protocol.h"
|
||||
|
||||
// Data structure to hold inhibitor state for each window
|
||||
typedef struct {
|
||||
struct zwp_keyboard_shortcuts_inhibit_manager_v1* manager;
|
||||
struct zwp_keyboard_shortcuts_inhibitor_v1* inhibitor;
|
||||
} ShortcutsInhibitData;
|
||||
|
||||
// Cleanup function for ShortcutsInhibitData
|
||||
static void shortcuts_inhibit_data_free(gpointer data) {
|
||||
ShortcutsInhibitData* inhibit_data = static_cast<ShortcutsInhibitData*>(data);
|
||||
if (inhibit_data->inhibitor != NULL) {
|
||||
zwp_keyboard_shortcuts_inhibitor_v1_destroy(inhibit_data->inhibitor);
|
||||
}
|
||||
if (inhibit_data->manager != NULL) {
|
||||
zwp_keyboard_shortcuts_inhibit_manager_v1_destroy(inhibit_data->manager);
|
||||
}
|
||||
g_free(inhibit_data);
|
||||
}
|
||||
|
||||
// Wayland registry handler to find the shortcuts inhibit manager
|
||||
static void registry_handle_global(void* data, struct wl_registry* registry,
|
||||
uint32_t name, const char* interface,
|
||||
uint32_t /*version*/) {
|
||||
ShortcutsInhibitData* inhibit_data = static_cast<ShortcutsInhibitData*>(data);
|
||||
if (strcmp(interface,
|
||||
zwp_keyboard_shortcuts_inhibit_manager_v1_interface.name) == 0) {
|
||||
inhibit_data->manager =
|
||||
static_cast<zwp_keyboard_shortcuts_inhibit_manager_v1*>(wl_registry_bind(
|
||||
registry, name, &zwp_keyboard_shortcuts_inhibit_manager_v1_interface,
|
||||
1));
|
||||
}
|
||||
}
|
||||
|
||||
static void registry_handle_global_remove(void* /*data*/, struct wl_registry* /*registry*/,
|
||||
uint32_t /*name*/) {
|
||||
// Not needed for this use case
|
||||
}
|
||||
|
||||
static const struct wl_registry_listener registry_listener = {
|
||||
registry_handle_global,
|
||||
registry_handle_global_remove,
|
||||
};
|
||||
|
||||
// Inhibitor event handlers
|
||||
static void inhibitor_active(void* /*data*/,
|
||||
struct zwp_keyboard_shortcuts_inhibitor_v1* /*inhibitor*/) {
|
||||
// Inhibitor is now active, shortcuts are being captured
|
||||
}
|
||||
|
||||
static void inhibitor_inactive(void* /*data*/,
|
||||
struct zwp_keyboard_shortcuts_inhibitor_v1* /*inhibitor*/) {
|
||||
// Inhibitor is now inactive, shortcuts restored to compositor
|
||||
}
|
||||
|
||||
static const struct zwp_keyboard_shortcuts_inhibitor_v1_listener inhibitor_listener = {
|
||||
inhibitor_active,
|
||||
inhibitor_inactive,
|
||||
};
|
||||
|
||||
// Forward declaration
|
||||
static void uninhibit_keyboard_shortcuts(GtkWindow* window);
|
||||
|
||||
// Inhibit keyboard shortcuts on Wayland for a specific window
|
||||
static void inhibit_keyboard_shortcuts(GtkWindow* window) {
|
||||
GdkDisplay* display = gtk_widget_get_display(GTK_WIDGET(window));
|
||||
if (!GDK_IS_WAYLAND_DISPLAY(display)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if already inhibited for this window
|
||||
if (g_object_get_data(G_OBJECT(window), "shortcuts-inhibit-data") != NULL) {
|
||||
return;
|
||||
}
|
||||
|
||||
ShortcutsInhibitData* inhibit_data = g_new0(ShortcutsInhibitData, 1);
|
||||
|
||||
struct wl_display* wl_display = gdk_wayland_display_get_wl_display(display);
|
||||
if (wl_display == NULL) {
|
||||
shortcuts_inhibit_data_free(inhibit_data);
|
||||
return;
|
||||
}
|
||||
|
||||
struct wl_registry* registry = wl_display_get_registry(wl_display);
|
||||
if (registry == NULL) {
|
||||
shortcuts_inhibit_data_free(inhibit_data);
|
||||
return;
|
||||
}
|
||||
|
||||
wl_registry_add_listener(registry, ®istry_listener, inhibit_data);
|
||||
wl_display_roundtrip(wl_display);
|
||||
|
||||
if (inhibit_data->manager == NULL) {
|
||||
wl_registry_destroy(registry);
|
||||
shortcuts_inhibit_data_free(inhibit_data);
|
||||
return;
|
||||
}
|
||||
|
||||
GdkWindow* gdk_window = gtk_widget_get_window(GTK_WIDGET(window));
|
||||
if (gdk_window == NULL) {
|
||||
wl_registry_destroy(registry);
|
||||
shortcuts_inhibit_data_free(inhibit_data);
|
||||
return;
|
||||
}
|
||||
|
||||
struct wl_surface* surface = gdk_wayland_window_get_wl_surface(gdk_window);
|
||||
if (surface == NULL) {
|
||||
wl_registry_destroy(registry);
|
||||
shortcuts_inhibit_data_free(inhibit_data);
|
||||
return;
|
||||
}
|
||||
|
||||
GdkSeat* gdk_seat = gdk_display_get_default_seat(display);
|
||||
if (gdk_seat == NULL) {
|
||||
wl_registry_destroy(registry);
|
||||
shortcuts_inhibit_data_free(inhibit_data);
|
||||
return;
|
||||
}
|
||||
|
||||
struct wl_seat* seat = gdk_wayland_seat_get_wl_seat(gdk_seat);
|
||||
if (seat == NULL) {
|
||||
wl_registry_destroy(registry);
|
||||
shortcuts_inhibit_data_free(inhibit_data);
|
||||
return;
|
||||
}
|
||||
|
||||
inhibit_data->inhibitor =
|
||||
zwp_keyboard_shortcuts_inhibit_manager_v1_inhibit_shortcuts(
|
||||
inhibit_data->manager, surface, seat);
|
||||
|
||||
if (inhibit_data->inhibitor == NULL) {
|
||||
wl_registry_destroy(registry);
|
||||
shortcuts_inhibit_data_free(inhibit_data);
|
||||
return;
|
||||
}
|
||||
|
||||
// Add listener to monitor active/inactive state
|
||||
zwp_keyboard_shortcuts_inhibitor_v1_add_listener(
|
||||
inhibit_data->inhibitor, &inhibitor_listener, window);
|
||||
|
||||
wl_display_roundtrip(wl_display);
|
||||
wl_registry_destroy(registry);
|
||||
|
||||
// Associate the inhibit data with the window for cleanup on destroy
|
||||
g_object_set_data_full(G_OBJECT(window), "shortcuts-inhibit-data",
|
||||
inhibit_data, shortcuts_inhibit_data_free);
|
||||
}
|
||||
|
||||
// Remove keyboard shortcuts inhibitor from a window
|
||||
static void uninhibit_keyboard_shortcuts(GtkWindow* window) {
|
||||
ShortcutsInhibitData* inhibit_data = static_cast<ShortcutsInhibitData*>(
|
||||
g_object_get_data(G_OBJECT(window), "shortcuts-inhibit-data"));
|
||||
|
||||
if (inhibit_data == NULL) {
|
||||
return;
|
||||
}
|
||||
|
||||
// This will trigger shortcuts_inhibit_data_free via g_object_set_data
|
||||
g_object_set_data(G_OBJECT(window), "shortcuts-inhibit-data", NULL);
|
||||
}
|
||||
|
||||
// Focus event handlers for dynamic inhibitor management
|
||||
static gboolean on_window_focus_in(GtkWidget* widget, GdkEventFocus* /*event*/, gpointer /*user_data*/) {
|
||||
if (GTK_IS_WINDOW(widget)) {
|
||||
inhibit_keyboard_shortcuts(GTK_WINDOW(widget));
|
||||
}
|
||||
return FALSE; // Continue event propagation
|
||||
}
|
||||
|
||||
static gboolean on_window_focus_out(GtkWidget* widget, GdkEventFocus* /*event*/, gpointer /*user_data*/) {
|
||||
if (GTK_IS_WINDOW(widget)) {
|
||||
uninhibit_keyboard_shortcuts(GTK_WINDOW(widget));
|
||||
}
|
||||
return FALSE; // Continue event propagation
|
||||
}
|
||||
|
||||
// Key for marking window as having focus handlers connected
|
||||
static const char* const kFocusHandlersConnectedKey = "shortcuts-inhibit-focus-handlers-connected";
|
||||
// Key for marking window as having a pending realize handler
|
||||
static const char* const kRealizeHandlerConnectedKey = "shortcuts-inhibit-realize-handler-connected";
|
||||
|
||||
// Callback when window is realized (mapped to screen)
|
||||
// Sets up focus-based inhibitor management
|
||||
static void on_window_realize(GtkWidget* widget, gpointer /*user_data*/) {
|
||||
if (GTK_IS_WINDOW(widget)) {
|
||||
// Check if focus handlers are already connected to avoid duplicates
|
||||
if (g_object_get_data(G_OBJECT(widget), kFocusHandlersConnectedKey) != NULL) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Connect focus events for dynamic inhibitor management
|
||||
g_signal_connect(widget, "focus-in-event",
|
||||
G_CALLBACK(on_window_focus_in), NULL);
|
||||
g_signal_connect(widget, "focus-out-event",
|
||||
G_CALLBACK(on_window_focus_out), NULL);
|
||||
|
||||
// Mark as connected to prevent duplicate connections
|
||||
g_object_set_data(G_OBJECT(widget), kFocusHandlersConnectedKey, GINT_TO_POINTER(1));
|
||||
|
||||
// If window already has focus, create inhibitor now
|
||||
if (gtk_window_has_toplevel_focus(GTK_WINDOW(widget))) {
|
||||
inhibit_keyboard_shortcuts(GTK_WINDOW(widget));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Public API: Initialize shortcuts inhibit for a sub-window
|
||||
void wayland_shortcuts_inhibit_init_for_subwindow(void* view) {
|
||||
GtkWidget* widget = GTK_WIDGET(view);
|
||||
GtkWidget* toplevel = gtk_widget_get_toplevel(widget);
|
||||
|
||||
if (toplevel != NULL && GTK_IS_WINDOW(toplevel)) {
|
||||
// Check if already initialized to avoid duplicate realize handlers
|
||||
if (g_object_get_data(G_OBJECT(toplevel), kFocusHandlersConnectedKey) != NULL ||
|
||||
g_object_get_data(G_OBJECT(toplevel), kRealizeHandlerConnectedKey) != NULL) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (gtk_widget_get_realized(toplevel)) {
|
||||
// Window is already realized, set up focus handlers now
|
||||
on_window_realize(toplevel, NULL);
|
||||
} else {
|
||||
// Mark realize handler as connected to prevent duplicate connections
|
||||
// if called again before window is realized
|
||||
g_object_set_data(G_OBJECT(toplevel), kRealizeHandlerConnectedKey, GINT_TO_POINTER(1));
|
||||
// Wait for window to be realized
|
||||
g_signal_connect(toplevel, "realize",
|
||||
G_CALLBACK(on_window_realize), NULL);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif // defined(GDK_WINDOWING_WAYLAND) && defined(HAS_KEYBOARD_SHORTCUTS_INHIBIT)
|
||||
22
flutter/linux/wayland_shortcuts_inhibit.h
Normal file
22
flutter/linux/wayland_shortcuts_inhibit.h
Normal file
@@ -0,0 +1,22 @@
|
||||
// Wayland keyboard shortcuts inhibit support
|
||||
// This module provides functionality to inhibit system keyboard shortcuts
|
||||
// on Wayland compositors, allowing remote desktop windows to capture all
|
||||
// key events including Super, Alt+Tab, etc.
|
||||
|
||||
#ifndef WAYLAND_SHORTCUTS_INHIBIT_H_
|
||||
#define WAYLAND_SHORTCUTS_INHIBIT_H_
|
||||
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
#if defined(GDK_WINDOWING_WAYLAND) && defined(HAS_KEYBOARD_SHORTCUTS_INHIBIT)
|
||||
|
||||
// Initialize shortcuts inhibit for a sub-window created by desktop_multi_window plugin.
|
||||
// This sets up focus-based inhibitor management: inhibitor is created when
|
||||
// the window gains focus and destroyed when it loses focus.
|
||||
//
|
||||
// @param view The FlView of the sub-window
|
||||
void wayland_shortcuts_inhibit_init_for_subwindow(void* view);
|
||||
|
||||
#endif // defined(GDK_WINDOWING_WAYLAND) && defined(HAS_KEYBOARD_SHORTCUTS_INHIBIT)
|
||||
|
||||
#endif // WAYLAND_SHORTCUTS_INHIBIT_H_
|
||||
@@ -19,6 +19,22 @@ import window_manager
|
||||
import window_size
|
||||
import texture_rgba_renderer
|
||||
|
||||
// Global state for relative mouse mode
|
||||
// All properties and methods must be accessed on the main thread since they
|
||||
// interact with NSEvent monitors, CoreGraphics APIs, and Flutter channels.
|
||||
// Note: We avoid @MainActor to maintain macOS 10.14 compatibility.
|
||||
class RelativeMouseState {
|
||||
static let shared = RelativeMouseState()
|
||||
|
||||
var enabled = false
|
||||
var eventMonitor: Any?
|
||||
var deltaChannel: FlutterMethodChannel?
|
||||
var accumulatedDeltaX: CGFloat = 0
|
||||
var accumulatedDeltaY: CGFloat = 0
|
||||
|
||||
private init() {}
|
||||
}
|
||||
|
||||
class MainFlutterWindow: NSWindow {
|
||||
override func awakeFromNib() {
|
||||
rustdesk_core_main();
|
||||
@@ -29,7 +45,7 @@ class MainFlutterWindow: NSWindow {
|
||||
// register self method handler
|
||||
let registrar = flutterViewController.registrar(forPlugin: "RustDeskPlugin")
|
||||
setMethodHandler(registrar: registrar)
|
||||
|
||||
|
||||
RegisterGeneratedPlugins(registry: flutterViewController)
|
||||
|
||||
FlutterMultiWindowPlugin.setOnWindowCreatedCallback { controller in
|
||||
@@ -50,22 +66,120 @@ class MainFlutterWindow: NSWindow {
|
||||
WindowSizePlugin.register(with: controller.registrar(forPlugin: "WindowSizePlugin"))
|
||||
TextureRgbaRendererPlugin.register(with: controller.registrar(forPlugin: "TextureRgbaRendererPlugin"))
|
||||
}
|
||||
|
||||
|
||||
super.awakeFromNib()
|
||||
}
|
||||
|
||||
|
||||
override public func order(_ place: NSWindow.OrderingMode, relativeTo otherWin: Int) {
|
||||
super.order(place, relativeTo: otherWin)
|
||||
hiddenWindowAtLaunch()
|
||||
}
|
||||
|
||||
|
||||
/// Override window theme.
|
||||
public func setWindowInterfaceMode(window: NSWindow, themeName: String) {
|
||||
window.appearance = NSAppearance(named: themeName == "light" ? .aqua : .darkAqua)
|
||||
}
|
||||
|
||||
|
||||
private func enableNativeRelativeMouseMode(channel: FlutterMethodChannel) -> Bool {
|
||||
assert(Thread.isMainThread, "enableNativeRelativeMouseMode must be called on the main thread")
|
||||
let state = RelativeMouseState.shared
|
||||
if state.enabled {
|
||||
// Already enabled: update the channel so this caller receives deltas.
|
||||
state.deltaChannel = channel
|
||||
return true
|
||||
}
|
||||
|
||||
// Dissociate mouse from cursor position - this locks the cursor in place
|
||||
// Do this FIRST before setting any state
|
||||
let result = CGAssociateMouseAndMouseCursorPosition(0)
|
||||
if result != CGError.success {
|
||||
NSLog("[RustDesk] Failed to dissociate mouse from cursor position: %d", result.rawValue)
|
||||
return false
|
||||
}
|
||||
|
||||
// Only set state after CG call succeeds
|
||||
state.deltaChannel = channel
|
||||
state.accumulatedDeltaX = 0
|
||||
state.accumulatedDeltaY = 0
|
||||
|
||||
// Add local event monitor to capture mouse delta.
|
||||
// Note: Local event monitors are always called on the main thread,
|
||||
// so accessing main-thread-only state is safe here.
|
||||
state.eventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.mouseMoved, .leftMouseDragged, .rightMouseDragged, .otherMouseDragged]) { [weak state] event in
|
||||
guard let state = state else { return event }
|
||||
// Guard against race: mode may be disabled between weak capture and this check.
|
||||
guard state.enabled else { return event }
|
||||
let deltaX = event.deltaX
|
||||
let deltaY = event.deltaY
|
||||
|
||||
if deltaX != 0 || deltaY != 0 {
|
||||
// Accumulate delta (main thread only - NSEvent local monitors always run on main thread)
|
||||
state.accumulatedDeltaX += deltaX
|
||||
state.accumulatedDeltaY += deltaY
|
||||
|
||||
// Only send if we have integer movement
|
||||
let intX = Int(state.accumulatedDeltaX)
|
||||
let intY = Int(state.accumulatedDeltaY)
|
||||
|
||||
if intX != 0 || intY != 0 {
|
||||
state.accumulatedDeltaX -= CGFloat(intX)
|
||||
state.accumulatedDeltaY -= CGFloat(intY)
|
||||
|
||||
// Send delta to Flutter (already on main thread)
|
||||
state.deltaChannel?.invokeMethod("onMouseDelta", arguments: ["dx": intX, "dy": intY])
|
||||
}
|
||||
}
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
// Check if monitor was created successfully
|
||||
if state.eventMonitor == nil {
|
||||
NSLog("[RustDesk] Failed to create event monitor for relative mouse mode")
|
||||
// Re-associate mouse since we failed
|
||||
CGAssociateMouseAndMouseCursorPosition(1)
|
||||
state.deltaChannel = nil
|
||||
return false
|
||||
}
|
||||
|
||||
// Set enabled LAST after everything succeeds
|
||||
state.enabled = true
|
||||
return true
|
||||
}
|
||||
|
||||
private func disableNativeRelativeMouseMode() {
|
||||
assert(Thread.isMainThread, "disableNativeRelativeMouseMode must be called on the main thread")
|
||||
let state = RelativeMouseState.shared
|
||||
if !state.enabled { return }
|
||||
|
||||
state.enabled = false
|
||||
|
||||
// Remove event monitor
|
||||
if let monitor = state.eventMonitor {
|
||||
NSEvent.removeMonitor(monitor)
|
||||
state.eventMonitor = nil
|
||||
}
|
||||
|
||||
state.deltaChannel = nil
|
||||
state.accumulatedDeltaX = 0
|
||||
state.accumulatedDeltaY = 0
|
||||
|
||||
// Re-associate mouse with cursor position (non-blocking with async retry)
|
||||
let result = CGAssociateMouseAndMouseCursorPosition(1)
|
||||
if result != CGError.success {
|
||||
NSLog("[RustDesk] Failed to re-associate mouse with cursor position: %d, scheduling retry...", result.rawValue)
|
||||
// Non-blocking retry after 50ms
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||||
let retryResult = CGAssociateMouseAndMouseCursorPosition(1)
|
||||
if retryResult != CGError.success {
|
||||
NSLog("[RustDesk] Retry failed to re-associate mouse: %d. Cursor may remain locked.", retryResult.rawValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func setMethodHandler(registrar: FlutterPluginRegistrar) {
|
||||
let channel = FlutterMethodChannel(name: "org.rustdesk.rustdesk/macos", binaryMessenger: registrar.messenger)
|
||||
let channel = FlutterMethodChannel(name: "org.rustdesk.rustdesk/host", binaryMessenger: registrar.messenger)
|
||||
channel.setMethodCallHandler({
|
||||
(call, result) -> Void in
|
||||
switch call.method {
|
||||
@@ -96,9 +210,74 @@ class MainFlutterWindow: NSWindow {
|
||||
}
|
||||
case "requestRecordAudio":
|
||||
AVCaptureDevice.requestAccess(for: .audio, completionHandler: { granted in
|
||||
result(granted)
|
||||
DispatchQueue.main.async {
|
||||
result(granted)
|
||||
}
|
||||
})
|
||||
break
|
||||
case "bumpMouse":
|
||||
var dx = 0
|
||||
var dy = 0
|
||||
|
||||
if let argMap = call.arguments as? [String: Any] {
|
||||
dx = (argMap["dx"] as? Int) ?? 0
|
||||
dy = (argMap["dy"] as? Int) ?? 0
|
||||
}
|
||||
else if let argList = call.arguments as? [Any] {
|
||||
dx = argList.count >= 1 ? (argList[0] as? Int) ?? 0 : 0
|
||||
dy = argList.count >= 2 ? (argList[1] as? Int) ?? 0 : 0
|
||||
}
|
||||
|
||||
var mouseLoc: CGPoint
|
||||
|
||||
if let dummyEvent = CGEvent(source: nil) { // can this ever fail?
|
||||
mouseLoc = dummyEvent.location
|
||||
}
|
||||
else if let screenFrame = NSScreen.screens.first?.frame {
|
||||
// NeXTStep: Origin is lower-left of primary screen, positive is up
|
||||
// Cocoa Core Graphics: Origin is upper-left of primary screen, positive is down
|
||||
let nsMouseLoc = NSEvent.mouseLocation
|
||||
|
||||
mouseLoc = CGPoint(
|
||||
x: nsMouseLoc.x,
|
||||
y: NSHeight(screenFrame) - nsMouseLoc.y)
|
||||
}
|
||||
else {
|
||||
result(false)
|
||||
break
|
||||
}
|
||||
|
||||
let newLoc = CGPoint(x: mouseLoc.x + CGFloat(dx), y: mouseLoc.y + CGFloat(dy))
|
||||
|
||||
CGDisplayMoveCursorToPoint(0, newLoc)
|
||||
|
||||
// By default, Cocoa suppresses mouse events briefly after a call to warp the
|
||||
// cursor to a new location. This is good if you want to draw the user's
|
||||
// attention to the fact that the mouse is now in a particular location, but
|
||||
// it's bad in this case; we get called as part of the handling of edge
|
||||
// scrolling, which means the mouse is typically still in motion, and we want
|
||||
// the cursor to keep moving smoothly uninterrupted.
|
||||
//
|
||||
// This function's main action is to toggle whether the mouse cursor is
|
||||
// associated with the mouse position, but setting it to true when it's
|
||||
// already true has the side-effect of cancelling this motion suppression.
|
||||
//
|
||||
// However, we must NOT call this when relative mouse mode is active,
|
||||
// as it would break the pointer lock established by enableNativeRelativeMouseMode.
|
||||
if !RelativeMouseState.shared.enabled {
|
||||
CGAssociateMouseAndMouseCursorPosition(1 /* true */)
|
||||
}
|
||||
|
||||
result(true)
|
||||
|
||||
case "enableNativeRelativeMouseMode":
|
||||
let success = self.enableNativeRelativeMouseMode(channel: channel)
|
||||
result(success)
|
||||
|
||||
case "disableNativeRelativeMouseMode":
|
||||
self.disableNativeRelativeMouseMode()
|
||||
result(true)
|
||||
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
|
||||
@@ -1,2 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
#
|
||||
# Fix OpenSSL build with Android NDK clang on 32-bit architectures
|
||||
#
|
||||
|
||||
export CFLAGS="-DBROKEN_CLANG_ATOMICS"
|
||||
export CXXFLAGS="-DBROKEN_CLANG_ATOMICS"
|
||||
|
||||
cargo ndk --platform 21 --target i686-linux-android build --release --features flutter
|
||||
|
||||
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
|
||||
# Read more about iOS versioning at
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# 1.1.9-1 works for android, but for ios it becomes 1.1.91, need to set it to 1.1.9-a.1 for iOS, will get 1.1.9.1, but iOS store not allow 4 numbers
|
||||
version: 1.4.3+61
|
||||
version: 1.4.5+63
|
||||
|
||||
environment:
|
||||
sdk: '^3.1.0'
|
||||
@@ -109,6 +109,7 @@ dependencies:
|
||||
xterm: 4.0.0
|
||||
sqflite: 2.2.0
|
||||
google_fonts: ^6.2.1
|
||||
vector_math: ^2.1.4
|
||||
|
||||
dev_dependencies:
|
||||
icons_launcher: ^2.0.4
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
#include "flutter_window.h"
|
||||
|
||||
#include <optional>
|
||||
|
||||
#include <desktop_multi_window/desktop_multi_window_plugin.h>
|
||||
#include <texture_rgba_renderer/texture_rgba_renderer_plugin_c_api.h>
|
||||
#include <flutter_gpu_texture_renderer/flutter_gpu_texture_renderer_plugin_c_api.h>
|
||||
|
||||
#include "flutter/generated_plugin_registrant.h"
|
||||
|
||||
#include <flutter/event_channel.h>
|
||||
#include <flutter/event_sink.h>
|
||||
#include <flutter/event_stream_handler_functions.h>
|
||||
#include <flutter/method_channel.h>
|
||||
#include <flutter/standard_method_codec.h>
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include <optional>
|
||||
#include <memory>
|
||||
|
||||
#include "win32_desktop.h"
|
||||
|
||||
FlutterWindow::FlutterWindow(const flutter::DartProject& project)
|
||||
: project_(project) {}
|
||||
|
||||
@@ -29,6 +40,48 @@ bool FlutterWindow::OnCreate() {
|
||||
return false;
|
||||
}
|
||||
RegisterPlugins(flutter_controller_->engine());
|
||||
|
||||
flutter::MethodChannel<> channel(
|
||||
flutter_controller_->engine()->messenger(),
|
||||
"org.rustdesk.rustdesk/host",
|
||||
&flutter::StandardMethodCodec::GetInstance());
|
||||
|
||||
channel.SetMethodCallHandler(
|
||||
[](const flutter::MethodCall<>& call, std::unique_ptr<flutter::MethodResult<>> result) {
|
||||
if (call.method_name() == "bumpMouse") {
|
||||
auto arguments = call.arguments();
|
||||
|
||||
int dx = 0, dy = 0;
|
||||
|
||||
if (std::holds_alternative<flutter::EncodableMap>(*arguments)) {
|
||||
auto argsMap = std::get<flutter::EncodableMap>(*arguments);
|
||||
|
||||
auto dxIt = argsMap.find(flutter::EncodableValue("dx"));
|
||||
auto dyIt = argsMap.find(flutter::EncodableValue("dy"));
|
||||
|
||||
if ((dxIt != argsMap.end()) && std::holds_alternative<int>(dxIt->second)) {
|
||||
dx = std::get<int>(dxIt->second);
|
||||
}
|
||||
if ((dyIt != argsMap.end()) && std::holds_alternative<int>(dyIt->second)) {
|
||||
dy = std::get<int>(dyIt->second);
|
||||
}
|
||||
} else if (std::holds_alternative<flutter::EncodableList>(*arguments)) {
|
||||
auto argsList = std::get<flutter::EncodableList>(*arguments);
|
||||
|
||||
if ((argsList.size() >= 1) && std::holds_alternative<int>(argsList[0])) {
|
||||
dx = std::get<int>(argsList[0]);
|
||||
}
|
||||
if ((argsList.size() >= 2) && std::holds_alternative<int>(argsList[1])) {
|
||||
dy = std::get<int>(argsList[1]);
|
||||
}
|
||||
}
|
||||
|
||||
bool succeeded = Win32Desktop::BumpMouse(dx, dy);
|
||||
|
||||
result->Success(succeeded);
|
||||
}
|
||||
});
|
||||
|
||||
DesktopMultiWindowSetWindowCreatedCallback([](void *controller) {
|
||||
auto *flutter_view_controller =
|
||||
reinterpret_cast<flutter::FlutterViewController *>(controller);
|
||||
|
||||
@@ -66,4 +66,17 @@ namespace Win32Desktop
|
||||
size.width = std::min(size.width, workarea_bottom_right.x - origin.x);
|
||||
size.height = std::min(size.height, workarea_bottom_right.y - origin.y);
|
||||
}
|
||||
|
||||
bool BumpMouse(int dx, int dy)
|
||||
{
|
||||
POINT pos;
|
||||
|
||||
if (GetCursorPos(&pos))
|
||||
{
|
||||
SetCursorPos(pos.x + dx, pos.y + dy);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace Win32Desktop
|
||||
{
|
||||
void GetWorkArea(Win32Window::Point& origin, Win32Window::Size& size);
|
||||
void FitToWorkArea(Win32Window::Point& origin, Win32Window::Size& size);
|
||||
bool BumpMouse(int dx, int dy);
|
||||
}
|
||||
|
||||
#endif // RUNNER_WIN32_DESKTOP_H_
|
||||
|
||||
@@ -10,7 +10,7 @@ TODO: Move this lib to a separate project.
|
||||
|
||||
## How it works
|
||||
|
||||
Terminalogies:
|
||||
Terminologies:
|
||||
|
||||
- cliprdr: this module
|
||||
- local: the endpoint which initiates a file copy events
|
||||
@@ -50,7 +50,7 @@ sequenceDiagram
|
||||
r ->> l: Format List Response (notified)
|
||||
r ->> l: Format Data Request (requests file list)
|
||||
activate l
|
||||
note left of l: Retrive file list from system clipboard
|
||||
note left of l: Retrieve file list from system clipboard
|
||||
l ->> r: Format Data Response (containing file list)
|
||||
deactivate l
|
||||
note over r: Update system clipboard with received file list
|
||||
@@ -84,10 +84,10 @@ and copy files to remote.
|
||||
The protocol was originally designed as an extension of the Windows RDP,
|
||||
so the specific message packages fits windows well.
|
||||
|
||||
When starting cliprdr, a thread is spawn to create a invisible window
|
||||
When starting cliprdr, a thread is spawned to create an invisible window
|
||||
and to subscribe to OLE clipboard events.
|
||||
The window's callback (see `cliprdr_proc` in `src/windows/wf_cliprdr.c`) was
|
||||
set to handle a variaty of events.
|
||||
set to handle a variety of events.
|
||||
|
||||
Detailed implementation is shown in pictures above.
|
||||
|
||||
@@ -108,18 +108,18 @@ after filtering out those pointing to our FUSE directory or duplicated,
|
||||
send format list directly to remote.
|
||||
|
||||
The cliprdr server also uses clipboard client for setting clipboard,
|
||||
or retrive paths from system.
|
||||
or retrieve paths from system.
|
||||
|
||||
#### Local File List
|
||||
|
||||
The local file list is a temperary list of file metadata.
|
||||
The local file list is a temporary list of file metadata.
|
||||
When receiving file contents PDU from peer, the server picks
|
||||
out the file requested and open it for reading if necessary.
|
||||
|
||||
Also when receiving Format Data Request PDU from remote asking for file list,
|
||||
the local file list should be rebuilt from file list retrieved from Clipboard Client.
|
||||
|
||||
Some caching and preloading could done on it since applications are likely to read
|
||||
Some caching and preloading could be done on it since applications are likely to read
|
||||
on the list sequentially.
|
||||
|
||||
#### FUSE server
|
||||
|
||||
@@ -37,5 +37,8 @@ core-graphics = "0.22"
|
||||
objc = "0.2"
|
||||
unicode-segmentation = "1.10"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
libxdo-sys = "0.11"
|
||||
|
||||
[build-dependencies]
|
||||
pkg-config = "0.3"
|
||||
|
||||
@@ -261,6 +261,8 @@ impl KeyboardControllable for Enigo {
|
||||
} else {
|
||||
if let Some(keyboard) = &mut self.custom_keyboard {
|
||||
keyboard.key_sequence(sequence)
|
||||
} else {
|
||||
log::warn!("Enigo::key_sequence: no custom_keyboard set for Wayland!");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -277,6 +279,7 @@ impl KeyboardControllable for Enigo {
|
||||
if let Some(keyboard) = &mut self.custom_keyboard {
|
||||
keyboard.key_down(key)
|
||||
} else {
|
||||
log::warn!("Enigo::key_down: no custom_keyboard set for Wayland!");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -290,13 +293,24 @@ impl KeyboardControllable for Enigo {
|
||||
} else {
|
||||
if let Some(keyboard) = &mut self.custom_keyboard {
|
||||
keyboard.key_up(key)
|
||||
} else {
|
||||
log::warn!("Enigo::key_up: no custom_keyboard set for Wayland!");
|
||||
}
|
||||
}
|
||||
}
|
||||
fn key_click(&mut self, key: Key) {
|
||||
if self.tfc_key_click(key).is_err() {
|
||||
self.key_down(key).ok();
|
||||
self.key_up(key);
|
||||
if self.is_x11 {
|
||||
// X11: try tfc first, then fallback to key_down/key_up
|
||||
if self.tfc_key_click(key).is_err() {
|
||||
self.key_down(key).ok();
|
||||
self.key_up(key);
|
||||
}
|
||||
} else {
|
||||
if let Some(keyboard) = &mut self.custom_keyboard {
|
||||
keyboard.key_click(key);
|
||||
} else {
|
||||
log::warn!("Enigo::key_click: no custom_keyboard set for Wayland!");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +1,22 @@
|
||||
//! XDO-based input emulation for Linux.
|
||||
//!
|
||||
//! This module uses libxdo-sys (patched to use dynamic loading stub) for input emulation.
|
||||
//! The stub handles dynamic loading of libxdo, so we just call the functions directly.
|
||||
//!
|
||||
//! If libxdo is not available at runtime, all operations become no-ops.
|
||||
|
||||
use crate::{Key, KeyboardControllable, MouseButton, MouseControllable};
|
||||
|
||||
use hbb_common::libc::{c_char, c_int, c_void, useconds_t};
|
||||
use std::{borrow::Cow, ffi::CString, ptr};
|
||||
use hbb_common::libc::c_int;
|
||||
use libxdo_sys::{self, xdo_t, CURRENTWINDOW};
|
||||
use std::{borrow::Cow, ffi::CString};
|
||||
|
||||
const CURRENT_WINDOW: c_int = 0;
|
||||
/// Default delay per keypress in microseconds.
|
||||
/// This value is passed to libxdo functions and must fit in `useconds_t` (u32).
|
||||
const DEFAULT_DELAY: u64 = 12000;
|
||||
type Window = c_int;
|
||||
type Xdo = *const c_void;
|
||||
|
||||
#[link(name = "xdo")]
|
||||
extern "C" {
|
||||
fn xdo_free(xdo: Xdo);
|
||||
fn xdo_new(display: *const c_char) -> Xdo;
|
||||
|
||||
fn xdo_click_window(xdo: Xdo, window: Window, button: c_int) -> c_int;
|
||||
fn xdo_mouse_down(xdo: Xdo, window: Window, button: c_int) -> c_int;
|
||||
fn xdo_mouse_up(xdo: Xdo, window: Window, button: c_int) -> c_int;
|
||||
fn xdo_move_mouse(xdo: Xdo, x: c_int, y: c_int, screen: c_int) -> c_int;
|
||||
fn xdo_move_mouse_relative(xdo: Xdo, x: c_int, y: c_int) -> c_int;
|
||||
|
||||
fn xdo_enter_text_window(
|
||||
xdo: Xdo,
|
||||
window: Window,
|
||||
string: *const c_char,
|
||||
delay: useconds_t,
|
||||
) -> c_int;
|
||||
fn xdo_send_keysequence_window(
|
||||
xdo: Xdo,
|
||||
window: Window,
|
||||
string: *const c_char,
|
||||
delay: useconds_t,
|
||||
) -> c_int;
|
||||
fn xdo_send_keysequence_window_down(
|
||||
xdo: Xdo,
|
||||
window: Window,
|
||||
string: *const c_char,
|
||||
delay: useconds_t,
|
||||
) -> c_int;
|
||||
fn xdo_send_keysequence_window_up(
|
||||
xdo: Xdo,
|
||||
window: Window,
|
||||
string: *const c_char,
|
||||
delay: useconds_t,
|
||||
) -> c_int;
|
||||
fn xdo_get_input_state(xdo: Xdo) -> u32;
|
||||
}
|
||||
/// Maximum allowed delay value (u32::MAX as u64).
|
||||
const MAX_DELAY: u64 = u32::MAX as u64;
|
||||
|
||||
fn mousebutton(button: MouseButton) -> c_int {
|
||||
match button {
|
||||
@@ -62,7 +34,7 @@ fn mousebutton(button: MouseButton) -> c_int {
|
||||
|
||||
/// The main struct for handling the event emitting
|
||||
pub(super) struct EnigoXdo {
|
||||
xdo: Xdo,
|
||||
xdo: *mut xdo_t,
|
||||
delay: u64,
|
||||
}
|
||||
// This is safe, we have a unique pointer.
|
||||
@@ -70,37 +42,61 @@ pub(super) struct EnigoXdo {
|
||||
unsafe impl Send for EnigoXdo {}
|
||||
|
||||
impl Default for EnigoXdo {
|
||||
/// Create a new EnigoXdo instance
|
||||
/// Create a new EnigoXdo instance.
|
||||
///
|
||||
/// If libxdo is not available, the xdo pointer will be null and all
|
||||
/// input operations will be no-ops.
|
||||
fn default() -> Self {
|
||||
let xdo = unsafe { libxdo_sys::xdo_new(std::ptr::null()) };
|
||||
if xdo.is_null() {
|
||||
log::warn!("Failed to create xdo context, xdo functions will be disabled");
|
||||
} else {
|
||||
log::info!("xdo context created successfully");
|
||||
}
|
||||
Self {
|
||||
xdo: unsafe { xdo_new(ptr::null()) },
|
||||
xdo,
|
||||
delay: DEFAULT_DELAY,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EnigoXdo {
|
||||
/// Get the delay per keypress.
|
||||
/// Default value is 12000.
|
||||
/// This is Linux-specific.
|
||||
/// Get the delay per keypress in microseconds.
|
||||
///
|
||||
/// Default value is 12000 (12ms). This is Linux-specific.
|
||||
pub fn delay(&self) -> u64 {
|
||||
self.delay
|
||||
}
|
||||
/// Set the delay per keypress.
|
||||
/// This is Linux-specific.
|
||||
|
||||
/// Set the delay per keypress in microseconds.
|
||||
///
|
||||
/// This is Linux-specific. The value is clamped to `u32::MAX` (approximately
|
||||
/// 4295 seconds) because libxdo uses `useconds_t` which is typically `u32`.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `delay` - Delay in microseconds. Values exceeding `u32::MAX` will be clamped.
|
||||
pub fn set_delay(&mut self, delay: u64) {
|
||||
self.delay = delay;
|
||||
self.delay = delay.min(MAX_DELAY);
|
||||
if delay > MAX_DELAY {
|
||||
log::warn!(
|
||||
"delay value {} exceeds maximum {}, clamped",
|
||||
delay,
|
||||
MAX_DELAY
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnigoXdo {
|
||||
fn drop(&mut self) {
|
||||
if self.xdo.is_null() {
|
||||
return;
|
||||
}
|
||||
unsafe {
|
||||
xdo_free(self.xdo);
|
||||
if !self.xdo.is_null() {
|
||||
unsafe {
|
||||
libxdo_sys::xdo_free(self.xdo);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MouseControllable for EnigoXdo {
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
@@ -115,42 +111,47 @@ impl MouseControllable for EnigoXdo {
|
||||
return;
|
||||
}
|
||||
unsafe {
|
||||
xdo_move_mouse(self.xdo, x as c_int, y as c_int, 0);
|
||||
libxdo_sys::xdo_move_mouse(self.xdo as *const _, x, y, 0);
|
||||
}
|
||||
}
|
||||
|
||||
fn mouse_move_relative(&mut self, x: i32, y: i32) {
|
||||
if self.xdo.is_null() {
|
||||
return;
|
||||
}
|
||||
unsafe {
|
||||
xdo_move_mouse_relative(self.xdo, x as c_int, y as c_int);
|
||||
libxdo_sys::xdo_move_mouse_relative(self.xdo as *const _, x, y);
|
||||
}
|
||||
}
|
||||
|
||||
fn mouse_down(&mut self, button: MouseButton) -> crate::ResultType {
|
||||
if self.xdo.is_null() {
|
||||
return Ok(());
|
||||
}
|
||||
unsafe {
|
||||
xdo_mouse_down(self.xdo, CURRENT_WINDOW, mousebutton(button));
|
||||
libxdo_sys::xdo_mouse_down(self.xdo as *const _, CURRENTWINDOW, mousebutton(button));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mouse_up(&mut self, button: MouseButton) {
|
||||
if self.xdo.is_null() {
|
||||
return;
|
||||
}
|
||||
unsafe {
|
||||
xdo_mouse_up(self.xdo, CURRENT_WINDOW, mousebutton(button));
|
||||
libxdo_sys::xdo_mouse_up(self.xdo as *const _, CURRENTWINDOW, mousebutton(button));
|
||||
}
|
||||
}
|
||||
|
||||
fn mouse_click(&mut self, button: MouseButton) {
|
||||
if self.xdo.is_null() {
|
||||
return;
|
||||
}
|
||||
unsafe {
|
||||
xdo_click_window(self.xdo, CURRENT_WINDOW, mousebutton(button));
|
||||
libxdo_sys::xdo_click_window(self.xdo as *const _, CURRENTWINDOW, mousebutton(button));
|
||||
}
|
||||
}
|
||||
|
||||
fn mouse_scroll_x(&mut self, length: i32) {
|
||||
let button;
|
||||
let mut length = length;
|
||||
@@ -169,6 +170,7 @@ impl MouseControllable for EnigoXdo {
|
||||
self.mouse_click(button);
|
||||
}
|
||||
}
|
||||
|
||||
fn mouse_scroll_y(&mut self, length: i32) {
|
||||
let button;
|
||||
let mut length = length;
|
||||
@@ -188,6 +190,7 @@ impl MouseControllable for EnigoXdo {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn keysequence<'a>(key: Key) -> Cow<'a, str> {
|
||||
if let Key::Layout(c) = key {
|
||||
return Cow::Owned(format!("U{:X}", c as u32));
|
||||
@@ -284,6 +287,7 @@ fn keysequence<'a>(key: Key) -> Cow<'a, str> {
|
||||
_ => "",
|
||||
})
|
||||
}
|
||||
|
||||
impl KeyboardControllable for EnigoXdo {
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
@@ -314,7 +318,7 @@ impl KeyboardControllable for EnigoXdo {
|
||||
let mod_alt = 1 << 3;
|
||||
let mod_numlock = 1 << 4;
|
||||
let mod_meta = 1 << 6;
|
||||
let mask = unsafe { xdo_get_input_state(self.xdo) };
|
||||
let mask = unsafe { libxdo_sys::xdo_get_input_state(self.xdo as *const _) };
|
||||
match key {
|
||||
Key::Shift => mask & mod_shift != 0,
|
||||
Key::CapsLock => mask & mod_lock != 0,
|
||||
@@ -332,56 +336,59 @@ impl KeyboardControllable for EnigoXdo {
|
||||
}
|
||||
if let Ok(string) = CString::new(sequence) {
|
||||
unsafe {
|
||||
xdo_enter_text_window(
|
||||
self.xdo,
|
||||
CURRENT_WINDOW,
|
||||
libxdo_sys::xdo_enter_text_window(
|
||||
self.xdo as *const _,
|
||||
CURRENTWINDOW,
|
||||
string.as_ptr(),
|
||||
self.delay as useconds_t,
|
||||
self.delay as libxdo_sys::useconds_t,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn key_down(&mut self, key: Key) -> crate::ResultType {
|
||||
if self.xdo.is_null() {
|
||||
return Ok(());
|
||||
}
|
||||
let string = CString::new(&*keysequence(key))?;
|
||||
unsafe {
|
||||
xdo_send_keysequence_window_down(
|
||||
self.xdo,
|
||||
CURRENT_WINDOW,
|
||||
libxdo_sys::xdo_send_keysequence_window_down(
|
||||
self.xdo as *const _,
|
||||
CURRENTWINDOW,
|
||||
string.as_ptr(),
|
||||
self.delay as useconds_t,
|
||||
self.delay as libxdo_sys::useconds_t,
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn key_up(&mut self, key: Key) {
|
||||
if self.xdo.is_null() {
|
||||
return;
|
||||
}
|
||||
if let Ok(string) = CString::new(&*keysequence(key)) {
|
||||
unsafe {
|
||||
xdo_send_keysequence_window_up(
|
||||
self.xdo,
|
||||
CURRENT_WINDOW,
|
||||
libxdo_sys::xdo_send_keysequence_window_up(
|
||||
self.xdo as *const _,
|
||||
CURRENTWINDOW,
|
||||
string.as_ptr(),
|
||||
self.delay as useconds_t,
|
||||
self.delay as libxdo_sys::useconds_t,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn key_click(&mut self, key: Key) {
|
||||
if self.xdo.is_null() {
|
||||
return;
|
||||
}
|
||||
if let Ok(string) = CString::new(&*keysequence(key)) {
|
||||
unsafe {
|
||||
xdo_send_keysequence_window(
|
||||
self.xdo,
|
||||
CURRENT_WINDOW,
|
||||
libxdo_sys::xdo_send_keysequence_window(
|
||||
self.xdo as *const _,
|
||||
CURRENTWINDOW,
|
||||
string.as_ptr(),
|
||||
self.delay as useconds_t,
|
||||
self.delay as libxdo_sys::useconds_t,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,42 +208,56 @@ impl MouseControllable for Enigo {
|
||||
}
|
||||
|
||||
fn mouse_move_to(&mut self, x: i32, y: i32) {
|
||||
let pressed = Self::pressed_buttons();
|
||||
|
||||
let event_type = if pressed & 1 > 0 {
|
||||
CGEventType::LeftMouseDragged
|
||||
} else if pressed & 2 > 0 {
|
||||
CGEventType::RightMouseDragged
|
||||
} else {
|
||||
CGEventType::MouseMoved
|
||||
};
|
||||
|
||||
let dest = CGPoint::new(x as f64, y as f64);
|
||||
if let Some(src) = self.event_source.as_ref() {
|
||||
if let Ok(event) =
|
||||
CGEvent::new_mouse_event(src.clone(), event_type, dest, CGMouseButton::Left)
|
||||
{
|
||||
self.post(event, None);
|
||||
}
|
||||
}
|
||||
// For absolute movement, we don't set delta values
|
||||
// This maintains backward compatibility
|
||||
self.mouse_move_to_impl(x, y, None);
|
||||
}
|
||||
|
||||
fn mouse_move_relative(&mut self, x: i32, y: i32) {
|
||||
let (display_width, display_height) = Self::main_display_size();
|
||||
let (current_x, y_inv) = Self::mouse_location_raw_coords();
|
||||
let current_y = (display_height as i32) - y_inv;
|
||||
let new_x = current_x + x;
|
||||
let new_y = current_y + y;
|
||||
// Use saturating arithmetic to prevent overflow/wraparound
|
||||
let mut new_x = current_x.saturating_add(x);
|
||||
let mut new_y = current_y.saturating_add(y);
|
||||
|
||||
if new_x < 0
|
||||
|| new_x as usize > display_width
|
||||
|| new_y < 0
|
||||
|| new_y as usize > display_height
|
||||
{
|
||||
return;
|
||||
// Define screen center and edge margins for cursor reset
|
||||
let center_x = (display_width / 2) as i32;
|
||||
let center_y = (display_height / 2) as i32;
|
||||
// Margin calculation: 5% of the smaller screen dimension with a minimum of 50px.
|
||||
// This provides a comfortable buffer zone to detect when the cursor is approaching
|
||||
// screen edges, allowing us to reset it to center before it hits the boundary.
|
||||
// This ensures continuous relative mouse movement without getting stuck at edges.
|
||||
let margin = (display_width.min(display_height) / 20).max(50) as i32;
|
||||
|
||||
// Check if cursor is approaching screen boundaries
|
||||
// Use saturating_sub to prevent negative thresholds on very small displays
|
||||
let right = (display_width as i32).saturating_sub(margin);
|
||||
let bottom = (display_height as i32).saturating_sub(margin);
|
||||
let near_edge = new_x < margin
|
||||
|| new_x > right
|
||||
|| new_y < margin
|
||||
|| new_y > bottom;
|
||||
|
||||
if near_edge {
|
||||
// Reset cursor to screen center to allow continuous movement
|
||||
// The delta values are still passed correctly for games/apps
|
||||
new_x = center_x;
|
||||
new_y = center_y;
|
||||
}
|
||||
|
||||
self.mouse_move_to(new_x, new_y);
|
||||
// Clamp to screen bounds as a safety measure.
|
||||
// Use saturating_sub(1) to ensure coordinates don't exceed the last valid pixel.
|
||||
let max_x = (display_width as i32).saturating_sub(1).max(0);
|
||||
let max_y = (display_height as i32).saturating_sub(1).max(0);
|
||||
new_x = new_x.clamp(0, max_x);
|
||||
new_y = new_y.clamp(0, max_y);
|
||||
|
||||
// Pass delta values for relative movement
|
||||
// This is critical for browser Pointer Lock API support
|
||||
// The delta fields (MOUSE_EVENT_DELTA_X/Y) are used by browsers
|
||||
// to calculate movementX/Y in Pointer Lock mode
|
||||
self.mouse_move_to_impl(new_x, new_y, Some((x, y)));
|
||||
}
|
||||
|
||||
fn mouse_down(&mut self, button: MouseButton) -> crate::ResultType {
|
||||
@@ -473,6 +487,43 @@ impl Enigo {
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal implementation for mouse movement with optional delta values.
|
||||
///
|
||||
/// The `delta` parameter is crucial for browser Pointer Lock API support.
|
||||
/// When a browser enters Pointer Lock mode, it reads mouse delta values
|
||||
/// (MOUSE_EVENT_DELTA_X/Y) directly from CGEvent to calculate movementX/Y.
|
||||
/// Without setting these fields, the browser sees zero movement.
|
||||
fn mouse_move_to_impl(&mut self, x: i32, y: i32, delta: Option<(i32, i32)>) {
|
||||
let pressed = Self::pressed_buttons();
|
||||
|
||||
// Determine event type and corresponding mouse button based on pressed buttons.
|
||||
// The CGMouseButton must match the event type for drag events.
|
||||
let (event_type, button) = if pressed & 1 > 0 {
|
||||
(CGEventType::LeftMouseDragged, CGMouseButton::Left)
|
||||
} else if pressed & 2 > 0 {
|
||||
(CGEventType::RightMouseDragged, CGMouseButton::Right)
|
||||
} else if pressed & 4 > 0 {
|
||||
(CGEventType::OtherMouseDragged, CGMouseButton::Center)
|
||||
} else {
|
||||
(CGEventType::MouseMoved, CGMouseButton::Left) // Button doesn't matter for MouseMoved
|
||||
};
|
||||
|
||||
let dest = CGPoint::new(x as f64, y as f64);
|
||||
if let Some(src) = self.event_source.as_ref() {
|
||||
if let Ok(event) =
|
||||
CGEvent::new_mouse_event(src.clone(), event_type, dest, button)
|
||||
{
|
||||
// Set delta fields for relative mouse movement
|
||||
// This is essential for Pointer Lock API in browsers
|
||||
if let Some((dx, dy)) = delta {
|
||||
event.set_integer_value_field(EventField::MOUSE_EVENT_DELTA_X, dx as i64);
|
||||
event.set_integer_value_field(EventField::MOUSE_EVENT_DELTA_Y, dy as i64);
|
||||
}
|
||||
self.post(event, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches the `(width, height)` in pixels of the main display
|
||||
pub fn main_display_size() -> (usize, usize) {
|
||||
let display_id = unsafe { CGMainDisplayID() };
|
||||
|
||||
Submodule libs/hbb_common updated: 5ed0afde08...da339dca64
9
libs/libxdo-sys-stub/Cargo.toml
Normal file
9
libs/libxdo-sys-stub/Cargo.toml
Normal file
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "libxdo-sys"
|
||||
version = "0.11.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
description = "Dynamic loading wrapper for libxdo-sys that doesn't require libxdo at compile/link time"
|
||||
|
||||
[dependencies]
|
||||
hbb_common = { path = "../hbb_common" }
|
||||
505
libs/libxdo-sys-stub/src/lib.rs
Normal file
505
libs/libxdo-sys-stub/src/lib.rs
Normal file
@@ -0,0 +1,505 @@
|
||||
//! Dynamic loading wrapper for libxdo.
|
||||
//!
|
||||
//! Provides the same API as libxdo-sys but loads libxdo at runtime,
|
||||
//! allowing the program to run on systems without libxdo installed
|
||||
//! (e.g., Wayland-only environments).
|
||||
|
||||
use hbb_common::{
|
||||
libc::{c_char, c_int, c_uint},
|
||||
libloading::{Library, Symbol},
|
||||
log,
|
||||
};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
pub use hbb_common::x11::xlib::{Display, Screen, Window};
|
||||
|
||||
#[repr(C)]
|
||||
pub struct xdo_t {
|
||||
_private: [u8; 0],
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
pub struct charcodemap_t {
|
||||
_private: [u8; 0],
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
pub struct xdo_search_t {
|
||||
_private: [u8; 0],
|
||||
}
|
||||
|
||||
pub type useconds_t = c_uint;
|
||||
|
||||
pub const CURRENTWINDOW: Window = 0;
|
||||
|
||||
type FnXdoNew = unsafe extern "C" fn(*const c_char) -> *mut xdo_t;
|
||||
type FnXdoNewWithOpenedDisplay =
|
||||
unsafe extern "C" fn(*mut Display, *const c_char, c_int) -> *mut xdo_t;
|
||||
type FnXdoFree = unsafe extern "C" fn(*mut xdo_t);
|
||||
type FnXdoSendKeysequenceWindow =
|
||||
unsafe extern "C" fn(*const xdo_t, Window, *const c_char, useconds_t) -> c_int;
|
||||
type FnXdoSendKeysequenceWindowDown =
|
||||
unsafe extern "C" fn(*const xdo_t, Window, *const c_char, useconds_t) -> c_int;
|
||||
type FnXdoSendKeysequenceWindowUp =
|
||||
unsafe extern "C" fn(*const xdo_t, Window, *const c_char, useconds_t) -> c_int;
|
||||
type FnXdoEnterTextWindow =
|
||||
unsafe extern "C" fn(*const xdo_t, Window, *const c_char, useconds_t) -> c_int;
|
||||
type FnXdoClickWindow = unsafe extern "C" fn(*const xdo_t, Window, c_int) -> c_int;
|
||||
type FnXdoMouseDown = unsafe extern "C" fn(*const xdo_t, Window, c_int) -> c_int;
|
||||
type FnXdoMouseUp = unsafe extern "C" fn(*const xdo_t, Window, c_int) -> c_int;
|
||||
type FnXdoMoveMouse = unsafe extern "C" fn(*const xdo_t, c_int, c_int, c_int) -> c_int;
|
||||
type FnXdoMoveMouseRelative = unsafe extern "C" fn(*const xdo_t, c_int, c_int) -> c_int;
|
||||
type FnXdoMoveMouseRelativeToWindow =
|
||||
unsafe extern "C" fn(*const xdo_t, Window, c_int, c_int) -> c_int;
|
||||
type FnXdoGetMouseLocation =
|
||||
unsafe extern "C" fn(*const xdo_t, *mut c_int, *mut c_int, *mut c_int) -> c_int;
|
||||
type FnXdoGetMouseLocation2 =
|
||||
unsafe extern "C" fn(*const xdo_t, *mut c_int, *mut c_int, *mut c_int, *mut Window) -> c_int;
|
||||
type FnXdoGetActiveWindow = unsafe extern "C" fn(*const xdo_t, *mut Window) -> c_int;
|
||||
type FnXdoGetFocusedWindow = unsafe extern "C" fn(*const xdo_t, *mut Window) -> c_int;
|
||||
type FnXdoGetFocusedWindowSane = unsafe extern "C" fn(*const xdo_t, *mut Window) -> c_int;
|
||||
type FnXdoGetWindowLocation =
|
||||
unsafe extern "C" fn(*const xdo_t, Window, *mut c_int, *mut c_int, *mut *mut Screen) -> c_int;
|
||||
type FnXdoGetWindowSize =
|
||||
unsafe extern "C" fn(*const xdo_t, Window, *mut c_uint, *mut c_uint) -> c_int;
|
||||
type FnXdoGetInputState = unsafe extern "C" fn(*const xdo_t) -> c_uint;
|
||||
type FnXdoActivateWindow = unsafe extern "C" fn(*const xdo_t, Window) -> c_int;
|
||||
type FnXdoWaitForMouseMoveFrom = unsafe extern "C" fn(*const xdo_t, c_int, c_int) -> c_int;
|
||||
type FnXdoWaitForMouseMoveTo = unsafe extern "C" fn(*const xdo_t, c_int, c_int) -> c_int;
|
||||
type FnXdoSetWindowClass =
|
||||
unsafe extern "C" fn(*const xdo_t, Window, *const c_char, *const c_char) -> c_int;
|
||||
type FnXdoSearchWindows =
|
||||
unsafe extern "C" fn(*const xdo_t, *const xdo_search_t, *mut *mut Window, *mut c_uint) -> c_int;
|
||||
|
||||
struct XdoLib {
|
||||
_lib: Library,
|
||||
xdo_new: FnXdoNew,
|
||||
xdo_new_with_opened_display: Option<FnXdoNewWithOpenedDisplay>,
|
||||
xdo_free: FnXdoFree,
|
||||
xdo_send_keysequence_window: FnXdoSendKeysequenceWindow,
|
||||
xdo_send_keysequence_window_down: Option<FnXdoSendKeysequenceWindowDown>,
|
||||
xdo_send_keysequence_window_up: Option<FnXdoSendKeysequenceWindowUp>,
|
||||
xdo_enter_text_window: Option<FnXdoEnterTextWindow>,
|
||||
xdo_click_window: Option<FnXdoClickWindow>,
|
||||
xdo_mouse_down: Option<FnXdoMouseDown>,
|
||||
xdo_mouse_up: Option<FnXdoMouseUp>,
|
||||
xdo_move_mouse: Option<FnXdoMoveMouse>,
|
||||
xdo_move_mouse_relative: Option<FnXdoMoveMouseRelative>,
|
||||
xdo_move_mouse_relative_to_window: Option<FnXdoMoveMouseRelativeToWindow>,
|
||||
xdo_get_mouse_location: Option<FnXdoGetMouseLocation>,
|
||||
xdo_get_mouse_location2: Option<FnXdoGetMouseLocation2>,
|
||||
xdo_get_active_window: Option<FnXdoGetActiveWindow>,
|
||||
xdo_get_focused_window: Option<FnXdoGetFocusedWindow>,
|
||||
xdo_get_focused_window_sane: Option<FnXdoGetFocusedWindowSane>,
|
||||
xdo_get_window_location: Option<FnXdoGetWindowLocation>,
|
||||
xdo_get_window_size: Option<FnXdoGetWindowSize>,
|
||||
xdo_get_input_state: Option<FnXdoGetInputState>,
|
||||
xdo_activate_window: Option<FnXdoActivateWindow>,
|
||||
xdo_wait_for_mouse_move_from: Option<FnXdoWaitForMouseMoveFrom>,
|
||||
xdo_wait_for_mouse_move_to: Option<FnXdoWaitForMouseMoveTo>,
|
||||
xdo_set_window_class: Option<FnXdoSetWindowClass>,
|
||||
xdo_search_windows: Option<FnXdoSearchWindows>,
|
||||
}
|
||||
|
||||
impl XdoLib {
|
||||
fn load() -> Option<Self> {
|
||||
// https://github.com/rustdesk/rustdesk/issues/13711
|
||||
const LIB_NAMES: [&str; 3] = ["libxdo.so.4", "libxdo.so.3", "libxdo.so"];
|
||||
|
||||
unsafe {
|
||||
let (lib, lib_name) = LIB_NAMES
|
||||
.iter()
|
||||
.find_map(|name| Library::new(name).ok().map(|lib| (lib, *name)))?;
|
||||
|
||||
log::info!("libxdo-sys Loaded {}", lib_name);
|
||||
|
||||
let xdo_new: FnXdoNew = *lib.get(b"xdo_new").ok()?;
|
||||
let xdo_free: FnXdoFree = *lib.get(b"xdo_free").ok()?;
|
||||
let xdo_send_keysequence_window: FnXdoSendKeysequenceWindow =
|
||||
*lib.get(b"xdo_send_keysequence_window").ok()?;
|
||||
|
||||
let xdo_new_with_opened_display = lib
|
||||
.get(b"xdo_new_with_opened_display")
|
||||
.ok()
|
||||
.map(|s: Symbol<FnXdoNewWithOpenedDisplay>| *s);
|
||||
let xdo_send_keysequence_window_down = lib
|
||||
.get(b"xdo_send_keysequence_window_down")
|
||||
.ok()
|
||||
.map(|s: Symbol<FnXdoSendKeysequenceWindowDown>| *s);
|
||||
let xdo_send_keysequence_window_up = lib
|
||||
.get(b"xdo_send_keysequence_window_up")
|
||||
.ok()
|
||||
.map(|s: Symbol<FnXdoSendKeysequenceWindowUp>| *s);
|
||||
let xdo_enter_text_window = lib
|
||||
.get(b"xdo_enter_text_window")
|
||||
.ok()
|
||||
.map(|s: Symbol<FnXdoEnterTextWindow>| *s);
|
||||
let xdo_click_window = lib
|
||||
.get(b"xdo_click_window")
|
||||
.ok()
|
||||
.map(|s: Symbol<FnXdoClickWindow>| *s);
|
||||
let xdo_mouse_down = lib
|
||||
.get(b"xdo_mouse_down")
|
||||
.ok()
|
||||
.map(|s: Symbol<FnXdoMouseDown>| *s);
|
||||
let xdo_mouse_up = lib
|
||||
.get(b"xdo_mouse_up")
|
||||
.ok()
|
||||
.map(|s: Symbol<FnXdoMouseUp>| *s);
|
||||
let xdo_move_mouse = lib
|
||||
.get(b"xdo_move_mouse")
|
||||
.ok()
|
||||
.map(|s: Symbol<FnXdoMoveMouse>| *s);
|
||||
let xdo_move_mouse_relative = lib
|
||||
.get(b"xdo_move_mouse_relative")
|
||||
.ok()
|
||||
.map(|s: Symbol<FnXdoMoveMouseRelative>| *s);
|
||||
let xdo_move_mouse_relative_to_window = lib
|
||||
.get(b"xdo_move_mouse_relative_to_window")
|
||||
.ok()
|
||||
.map(|s: Symbol<FnXdoMoveMouseRelativeToWindow>| *s);
|
||||
let xdo_get_mouse_location = lib
|
||||
.get(b"xdo_get_mouse_location")
|
||||
.ok()
|
||||
.map(|s: Symbol<FnXdoGetMouseLocation>| *s);
|
||||
let xdo_get_mouse_location2 = lib
|
||||
.get(b"xdo_get_mouse_location2")
|
||||
.ok()
|
||||
.map(|s: Symbol<FnXdoGetMouseLocation2>| *s);
|
||||
let xdo_get_active_window = lib
|
||||
.get(b"xdo_get_active_window")
|
||||
.ok()
|
||||
.map(|s: Symbol<FnXdoGetActiveWindow>| *s);
|
||||
let xdo_get_focused_window = lib
|
||||
.get(b"xdo_get_focused_window")
|
||||
.ok()
|
||||
.map(|s: Symbol<FnXdoGetFocusedWindow>| *s);
|
||||
let xdo_get_focused_window_sane = lib
|
||||
.get(b"xdo_get_focused_window_sane")
|
||||
.ok()
|
||||
.map(|s: Symbol<FnXdoGetFocusedWindowSane>| *s);
|
||||
let xdo_get_window_location = lib
|
||||
.get(b"xdo_get_window_location")
|
||||
.ok()
|
||||
.map(|s: Symbol<FnXdoGetWindowLocation>| *s);
|
||||
let xdo_get_window_size = lib
|
||||
.get(b"xdo_get_window_size")
|
||||
.ok()
|
||||
.map(|s: Symbol<FnXdoGetWindowSize>| *s);
|
||||
let xdo_get_input_state = lib
|
||||
.get(b"xdo_get_input_state")
|
||||
.ok()
|
||||
.map(|s: Symbol<FnXdoGetInputState>| *s);
|
||||
let xdo_activate_window = lib
|
||||
.get(b"xdo_activate_window")
|
||||
.ok()
|
||||
.map(|s: Symbol<FnXdoActivateWindow>| *s);
|
||||
let xdo_wait_for_mouse_move_from = lib
|
||||
.get(b"xdo_wait_for_mouse_move_from")
|
||||
.ok()
|
||||
.map(|s: Symbol<FnXdoWaitForMouseMoveFrom>| *s);
|
||||
let xdo_wait_for_mouse_move_to = lib
|
||||
.get(b"xdo_wait_for_mouse_move_to")
|
||||
.ok()
|
||||
.map(|s: Symbol<FnXdoWaitForMouseMoveTo>| *s);
|
||||
let xdo_set_window_class = lib
|
||||
.get(b"xdo_set_window_class")
|
||||
.ok()
|
||||
.map(|s: Symbol<FnXdoSetWindowClass>| *s);
|
||||
let xdo_search_windows = lib
|
||||
.get(b"xdo_search_windows")
|
||||
.ok()
|
||||
.map(|s: Symbol<FnXdoSearchWindows>| *s);
|
||||
|
||||
Some(Self {
|
||||
_lib: lib,
|
||||
xdo_new,
|
||||
xdo_new_with_opened_display,
|
||||
xdo_free,
|
||||
xdo_send_keysequence_window,
|
||||
xdo_send_keysequence_window_down,
|
||||
xdo_send_keysequence_window_up,
|
||||
xdo_enter_text_window,
|
||||
xdo_click_window,
|
||||
xdo_mouse_down,
|
||||
xdo_mouse_up,
|
||||
xdo_move_mouse,
|
||||
xdo_move_mouse_relative,
|
||||
xdo_move_mouse_relative_to_window,
|
||||
xdo_get_mouse_location,
|
||||
xdo_get_mouse_location2,
|
||||
xdo_get_active_window,
|
||||
xdo_get_focused_window,
|
||||
xdo_get_focused_window_sane,
|
||||
xdo_get_window_location,
|
||||
xdo_get_window_size,
|
||||
xdo_get_input_state,
|
||||
xdo_activate_window,
|
||||
xdo_wait_for_mouse_move_from,
|
||||
xdo_wait_for_mouse_move_to,
|
||||
xdo_set_window_class,
|
||||
xdo_search_windows,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static XDO_LIB: OnceLock<Option<XdoLib>> = OnceLock::new();
|
||||
|
||||
fn get_lib() -> Option<&'static XdoLib> {
|
||||
XDO_LIB
|
||||
.get_or_init(|| {
|
||||
let lib = XdoLib::load();
|
||||
if lib.is_none() {
|
||||
log::info!("libxdo-sys libxdo not found, xdo functions will be disabled");
|
||||
}
|
||||
lib
|
||||
})
|
||||
.as_ref()
|
||||
}
|
||||
|
||||
pub unsafe extern "C" fn xdo_new(display: *const c_char) -> *mut xdo_t {
|
||||
get_lib().map_or(std::ptr::null_mut(), |lib| (lib.xdo_new)(display))
|
||||
}
|
||||
|
||||
pub unsafe extern "C" fn xdo_new_with_opened_display(
|
||||
xdpy: *mut Display,
|
||||
display: *const c_char,
|
||||
close_display_when_freed: c_int,
|
||||
) -> *mut xdo_t {
|
||||
get_lib()
|
||||
.and_then(|lib| lib.xdo_new_with_opened_display)
|
||||
.map_or(std::ptr::null_mut(), |f| {
|
||||
f(xdpy, display, close_display_when_freed)
|
||||
})
|
||||
}
|
||||
|
||||
pub unsafe extern "C" fn xdo_free(xdo: *mut xdo_t) {
|
||||
if xdo.is_null() {
|
||||
return;
|
||||
}
|
||||
if let Some(lib) = get_lib() {
|
||||
(lib.xdo_free)(xdo);
|
||||
}
|
||||
}
|
||||
|
||||
pub unsafe extern "C" fn xdo_send_keysequence_window(
|
||||
xdo: *const xdo_t,
|
||||
window: Window,
|
||||
keysequence: *const c_char,
|
||||
delay: useconds_t,
|
||||
) -> c_int {
|
||||
get_lib().map_or(1, |lib| {
|
||||
(lib.xdo_send_keysequence_window)(xdo, window, keysequence, delay)
|
||||
})
|
||||
}
|
||||
|
||||
pub unsafe extern "C" fn xdo_send_keysequence_window_down(
|
||||
xdo: *const xdo_t,
|
||||
window: Window,
|
||||
keysequence: *const c_char,
|
||||
delay: useconds_t,
|
||||
) -> c_int {
|
||||
get_lib()
|
||||
.and_then(|lib| lib.xdo_send_keysequence_window_down)
|
||||
.map_or(1, |f| f(xdo, window, keysequence, delay))
|
||||
}
|
||||
|
||||
pub unsafe extern "C" fn xdo_send_keysequence_window_up(
|
||||
xdo: *const xdo_t,
|
||||
window: Window,
|
||||
keysequence: *const c_char,
|
||||
delay: useconds_t,
|
||||
) -> c_int {
|
||||
get_lib()
|
||||
.and_then(|lib| lib.xdo_send_keysequence_window_up)
|
||||
.map_or(1, |f| f(xdo, window, keysequence, delay))
|
||||
}
|
||||
|
||||
pub unsafe extern "C" fn xdo_enter_text_window(
|
||||
xdo: *const xdo_t,
|
||||
window: Window,
|
||||
string: *const c_char,
|
||||
delay: useconds_t,
|
||||
) -> c_int {
|
||||
get_lib()
|
||||
.and_then(|lib| lib.xdo_enter_text_window)
|
||||
.map_or(1, |f| f(xdo, window, string, delay))
|
||||
}
|
||||
|
||||
pub unsafe extern "C" fn xdo_click_window(
|
||||
xdo: *const xdo_t,
|
||||
window: Window,
|
||||
button: c_int,
|
||||
) -> c_int {
|
||||
get_lib()
|
||||
.and_then(|lib| lib.xdo_click_window)
|
||||
.map_or(1, |f| f(xdo, window, button))
|
||||
}
|
||||
|
||||
pub unsafe extern "C" fn xdo_mouse_down(xdo: *const xdo_t, window: Window, button: c_int) -> c_int {
|
||||
get_lib()
|
||||
.and_then(|lib| lib.xdo_mouse_down)
|
||||
.map_or(1, |f| f(xdo, window, button))
|
||||
}
|
||||
|
||||
pub unsafe extern "C" fn xdo_mouse_up(xdo: *const xdo_t, window: Window, button: c_int) -> c_int {
|
||||
get_lib()
|
||||
.and_then(|lib| lib.xdo_mouse_up)
|
||||
.map_or(1, |f| f(xdo, window, button))
|
||||
}
|
||||
|
||||
pub unsafe extern "C" fn xdo_move_mouse(
|
||||
xdo: *const xdo_t,
|
||||
x: c_int,
|
||||
y: c_int,
|
||||
screen: c_int,
|
||||
) -> c_int {
|
||||
get_lib()
|
||||
.and_then(|lib| lib.xdo_move_mouse)
|
||||
.map_or(1, |f| f(xdo, x, y, screen))
|
||||
}
|
||||
|
||||
pub unsafe extern "C" fn xdo_move_mouse_relative(xdo: *const xdo_t, x: c_int, y: c_int) -> c_int {
|
||||
get_lib()
|
||||
.and_then(|lib| lib.xdo_move_mouse_relative)
|
||||
.map_or(1, |f| f(xdo, x, y))
|
||||
}
|
||||
|
||||
pub unsafe extern "C" fn xdo_move_mouse_relative_to_window(
|
||||
xdo: *const xdo_t,
|
||||
window: Window,
|
||||
x: c_int,
|
||||
y: c_int,
|
||||
) -> c_int {
|
||||
get_lib()
|
||||
.and_then(|lib| lib.xdo_move_mouse_relative_to_window)
|
||||
.map_or(1, |f| f(xdo, window, x, y))
|
||||
}
|
||||
|
||||
pub unsafe extern "C" fn xdo_get_mouse_location(
|
||||
xdo: *const xdo_t,
|
||||
x: *mut c_int,
|
||||
y: *mut c_int,
|
||||
screen_num: *mut c_int,
|
||||
) -> c_int {
|
||||
get_lib()
|
||||
.and_then(|lib| lib.xdo_get_mouse_location)
|
||||
.map_or(1, |f| f(xdo, x, y, screen_num))
|
||||
}
|
||||
|
||||
pub unsafe extern "C" fn xdo_get_mouse_location2(
|
||||
xdo: *const xdo_t,
|
||||
x: *mut c_int,
|
||||
y: *mut c_int,
|
||||
screen_num: *mut c_int,
|
||||
window: *mut Window,
|
||||
) -> c_int {
|
||||
get_lib()
|
||||
.and_then(|lib| lib.xdo_get_mouse_location2)
|
||||
.map_or(1, |f| f(xdo, x, y, screen_num, window))
|
||||
}
|
||||
|
||||
pub unsafe extern "C" fn xdo_get_active_window(
|
||||
xdo: *const xdo_t,
|
||||
window_ret: *mut Window,
|
||||
) -> c_int {
|
||||
get_lib()
|
||||
.and_then(|lib| lib.xdo_get_active_window)
|
||||
.map_or(1, |f| f(xdo, window_ret))
|
||||
}
|
||||
|
||||
pub unsafe extern "C" fn xdo_get_focused_window(
|
||||
xdo: *const xdo_t,
|
||||
window_ret: *mut Window,
|
||||
) -> c_int {
|
||||
get_lib()
|
||||
.and_then(|lib| lib.xdo_get_focused_window)
|
||||
.map_or(1, |f| f(xdo, window_ret))
|
||||
}
|
||||
|
||||
pub unsafe extern "C" fn xdo_get_focused_window_sane(
|
||||
xdo: *const xdo_t,
|
||||
window_ret: *mut Window,
|
||||
) -> c_int {
|
||||
get_lib()
|
||||
.and_then(|lib| lib.xdo_get_focused_window_sane)
|
||||
.map_or(1, |f| f(xdo, window_ret))
|
||||
}
|
||||
|
||||
pub unsafe extern "C" fn xdo_get_window_location(
|
||||
xdo: *const xdo_t,
|
||||
window: Window,
|
||||
x: *mut c_int,
|
||||
y: *mut c_int,
|
||||
screen_ret: *mut *mut Screen,
|
||||
) -> c_int {
|
||||
get_lib()
|
||||
.and_then(|lib| lib.xdo_get_window_location)
|
||||
.map_or(1, |f| f(xdo, window, x, y, screen_ret))
|
||||
}
|
||||
|
||||
pub unsafe extern "C" fn xdo_get_window_size(
|
||||
xdo: *const xdo_t,
|
||||
window: Window,
|
||||
width: *mut c_uint,
|
||||
height: *mut c_uint,
|
||||
) -> c_int {
|
||||
get_lib()
|
||||
.and_then(|lib| lib.xdo_get_window_size)
|
||||
.map_or(1, |f| f(xdo, window, width, height))
|
||||
}
|
||||
|
||||
pub unsafe extern "C" fn xdo_get_input_state(xdo: *const xdo_t) -> c_uint {
|
||||
get_lib()
|
||||
.and_then(|lib| lib.xdo_get_input_state)
|
||||
.map_or(0, |f| f(xdo))
|
||||
}
|
||||
|
||||
pub unsafe extern "C" fn xdo_activate_window(xdo: *const xdo_t, wid: Window) -> c_int {
|
||||
get_lib()
|
||||
.and_then(|lib| lib.xdo_activate_window)
|
||||
.map_or(1, |f| f(xdo, wid))
|
||||
}
|
||||
|
||||
pub unsafe extern "C" fn xdo_wait_for_mouse_move_from(
|
||||
xdo: *const xdo_t,
|
||||
origin_x: c_int,
|
||||
origin_y: c_int,
|
||||
) -> c_int {
|
||||
get_lib()
|
||||
.and_then(|lib| lib.xdo_wait_for_mouse_move_from)
|
||||
.map_or(1, |f| f(xdo, origin_x, origin_y))
|
||||
}
|
||||
|
||||
pub unsafe extern "C" fn xdo_wait_for_mouse_move_to(
|
||||
xdo: *const xdo_t,
|
||||
dest_x: c_int,
|
||||
dest_y: c_int,
|
||||
) -> c_int {
|
||||
get_lib()
|
||||
.and_then(|lib| lib.xdo_wait_for_mouse_move_to)
|
||||
.map_or(1, |f| f(xdo, dest_x, dest_y))
|
||||
}
|
||||
|
||||
pub unsafe extern "C" fn xdo_set_window_class(
|
||||
xdo: *const xdo_t,
|
||||
wid: Window,
|
||||
name: *const c_char,
|
||||
class: *const c_char,
|
||||
) -> c_int {
|
||||
get_lib()
|
||||
.and_then(|lib| lib.xdo_set_window_class)
|
||||
.map_or(1, |f| f(xdo, wid, name, class))
|
||||
}
|
||||
|
||||
pub unsafe extern "C" fn xdo_search_windows(
|
||||
xdo: *const xdo_t,
|
||||
search: *const xdo_search_t,
|
||||
windowlist_ret: *mut *mut Window,
|
||||
nwindows_ret: *mut c_uint,
|
||||
) -> c_int {
|
||||
get_lib()
|
||||
.and_then(|lib| lib.xdo_search_windows)
|
||||
.map_or(1, |f| f(xdo, search, windowlist_ret, nwindows_ret))
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rustdesk-portable-packer"
|
||||
version = "1.4.3"
|
||||
version = "1.4.5"
|
||||
edition = "2021"
|
||||
description = "RustDesk Remote Desktop"
|
||||
|
||||
|
||||
@@ -187,7 +187,10 @@ fn main() {
|
||||
i += 1;
|
||||
}
|
||||
let click_setup = args.is_empty() && arg_exe.to_lowercase().ends_with("install.exe");
|
||||
let quick_support = args.is_empty() && arg_exe.to_lowercase().ends_with("qs.exe");
|
||||
#[cfg(windows)]
|
||||
let quick_support = args.is_empty() && win::is_quick_support_exe(&arg_exe);
|
||||
#[cfg(not(windows))]
|
||||
let quick_support = false;
|
||||
|
||||
let mut ui = false;
|
||||
let reader = BinaryReader::default();
|
||||
@@ -234,4 +237,12 @@ mod win {
|
||||
.output();
|
||||
let _allow_err = std::fs::copy(src, &format!("{}\\{}", dir.to_string_lossy(), tgt));
|
||||
}
|
||||
|
||||
/// Check if the executable is a Quick Support version.
|
||||
/// Note: This function must be kept in sync with `src/core_main.rs`.
|
||||
#[inline]
|
||||
pub(super) fn is_quick_support_exe(exe: &str) -> bool {
|
||||
let exe = exe.to_lowercase();
|
||||
exe.contains("-qs-") || exe.contains("-qs.exe") || exe.contains("_qs.exe")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
use hbb_common::{bail, ResultType};
|
||||
use std::{io, ptr::null_mut};
|
||||
use winapi::{
|
||||
|
||||
@@ -10,7 +10,7 @@ authors = ["Ram <quadrupleslap@gmail.com>"]
|
||||
edition = "2018"
|
||||
|
||||
[features]
|
||||
wayland = ["gstreamer", "gstreamer-app", "gstreamer-video", "dbus", "tracing"]
|
||||
wayland = ["gstreamer", "gstreamer-app", "gstreamer-video", "dbus", "tracing", "zbus"]
|
||||
mediacodec = ["ndk"]
|
||||
linux-pkg-config = ["dep:pkg-config"]
|
||||
hwcodec = ["dep:hwcodec"]
|
||||
@@ -57,6 +57,7 @@ tracing = { version = "0.1", optional = true }
|
||||
gstreamer = { version = "0.16", optional = true }
|
||||
gstreamer-app = { version = "0.16", features = ["v1_10"], optional = true }
|
||||
gstreamer-video = { version = "0.16", optional = true }
|
||||
zbus = { version = "3.15", optional = true }
|
||||
|
||||
[dependencies.hwcodec]
|
||||
git = "https://github.com/rustdesk-org/hwcodec"
|
||||
|
||||
@@ -227,24 +227,12 @@ fn ffmpeg() {
|
||||
*/
|
||||
|
||||
fn main() {
|
||||
// in this crate, these are also valid configurations
|
||||
println!("cargo:rustc-check-cfg=cfg(dxgi,quartz,x11)");
|
||||
|
||||
// there is problem with cfg(target_os) in build.rs, so use our workaround
|
||||
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap();
|
||||
|
||||
// We check if is macos, because macos uses rust 1.8.1.
|
||||
// `cargo::rustc-check-cfg` is new with Cargo 1.80.
|
||||
// No need to run `cargo version` to get the version here, because:
|
||||
// The following lines are used to suppress the lint warnings.
|
||||
// warning: unexpected `cfg` condition name: `quartz`
|
||||
if cfg!(target_os = "macos") {
|
||||
if target_os != "ios" {
|
||||
println!("cargo::rustc-check-cfg=cfg(android)");
|
||||
println!("cargo::rustc-check-cfg=cfg(dxgi)");
|
||||
println!("cargo::rustc-check-cfg=cfg(quartz)");
|
||||
println!("cargo::rustc-check-cfg=cfg(x11)");
|
||||
// ^^^^^^^^^^^^^^^^^^^^^^ new with Cargo 1.80
|
||||
}
|
||||
}
|
||||
|
||||
// note: all link symbol names in x86 (32-bit) are prefixed wth "_".
|
||||
// run "rustup show" to show current default toolchain, if it is stable-x86-pc-windows-msvc,
|
||||
// please install x64 toolchain by "rustup toolchain install stable-x86_64-pc-windows-msvc",
|
||||
|
||||
@@ -22,6 +22,7 @@ use std::time::{Duration, Instant};
|
||||
lazy_static! {
|
||||
static ref JVM: RwLock<Option<JavaVM>> = RwLock::new(None);
|
||||
static ref MAIN_SERVICE_CTX: RwLock<Option<GlobalRef>> = RwLock::new(None); // MainService -> video service / audio service / info
|
||||
static ref APPLICATION_CONTEXT: RwLock<Option<GlobalRef>> = RwLock::new(None);
|
||||
static ref VIDEO_RAW: Mutex<FrameRaw> = Mutex::new(FrameRaw::new("video", MAX_VIDEO_FRAME_TIMEOUT));
|
||||
static ref AUDIO_RAW: Mutex<FrameRaw> = Mutex::new(FrameRaw::new("audio", MAX_AUDIO_FRAME_TIMEOUT));
|
||||
static ref NDK_CONTEXT_INITED: Mutex<bool> = Default::default();
|
||||
@@ -462,6 +463,23 @@ fn init_ndk_context(java_vm: *mut c_void, context_jobject: *mut c_void) {
|
||||
*lock = true;
|
||||
}
|
||||
|
||||
fn try_init_rustls_platform_verifier(env: &mut JNIEnv, context_jobject: *mut c_void) {
|
||||
use hbb_common::config::ANDROID_RUSTLS_PLATFORM_VERIFIER_INITIALIZED as INITIALIZED;
|
||||
use std::sync::atomic::Ordering;
|
||||
let initialized = INITIALIZED.load(Ordering::Relaxed);
|
||||
if !initialized {
|
||||
let ctx_for_rustls = unsafe { JObject::from_raw(context_jobject as jni::sys::jobject) };
|
||||
if let Err(e) =
|
||||
hbb_common::rustls_platform_verifier::android::init_hosted(env, ctx_for_rustls)
|
||||
{
|
||||
log::error!("Failed to initialize rustls-platform-verifier: {:?}", e);
|
||||
} else {
|
||||
INITIALIZED.store(true, Ordering::Relaxed);
|
||||
log::info!("rustls-platform-verifier initialized successfully");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// https://cjycode.com/flutter_rust_bridge/guides/how-to/ndk-init
|
||||
#[no_mangle]
|
||||
pub extern "C" fn JNI_OnLoad(vm: jni::JavaVM, res: *mut std::os::raw::c_void) -> jni::sys::jint {
|
||||
@@ -471,3 +489,23 @@ pub extern "C" fn JNI_OnLoad(vm: jni::JavaVM, res: *mut std::os::raw::c_void) ->
|
||||
}
|
||||
jni::JNIVersion::V6.into()
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_ffi_FFI_onAppStart(mut env: JNIEnv, _class: JClass, ctx: JObject) {
|
||||
if ctx.is_null() {
|
||||
log::error!("application context is null");
|
||||
return;
|
||||
}
|
||||
if APPLICATION_CONTEXT.read().unwrap().is_some() {
|
||||
log::info!("application context already initialized");
|
||||
return;
|
||||
}
|
||||
if let Ok(jvm) = env.get_java_vm() {
|
||||
if let Ok(context) = env.new_global_ref(ctx) {
|
||||
let java_vm = jvm.get_java_vm_pointer() as *mut c_void;
|
||||
let context_jobject = context.as_obj().as_raw() as *mut c_void;
|
||||
*APPLICATION_CONTEXT.write().unwrap() = Some(context);
|
||||
try_init_rustls_platform_verifier(&mut env, context_jobject);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,7 +287,7 @@ impl EncoderApi for AomEncoder {
|
||||
}
|
||||
|
||||
impl AomEncoder {
|
||||
pub fn encode(&mut self, ms: i64, data: &[u8], stride_align: usize) -> Result<EncodeFrames> {
|
||||
pub fn encode<'a>(&'a mut self, ms: i64, data: &[u8], stride_align: usize) -> Result<EncodeFrames<'a>> {
|
||||
let bpp = if self.i444 { 24 } else { 12 };
|
||||
if data.len() < self.width * self.height * bpp / 8 {
|
||||
return Err(Error::FailedCall("len not enough".to_string()));
|
||||
@@ -461,7 +461,7 @@ impl AomDecoder {
|
||||
Ok(Self { ctx })
|
||||
}
|
||||
|
||||
pub fn decode(&mut self, data: &[u8]) -> Result<DecodeFrames> {
|
||||
pub fn decode<'a>(&'a mut self, data: &[u8]) -> Result<DecodeFrames<'a>> {
|
||||
call_aom!(aom_codec_decode(
|
||||
&mut self.ctx,
|
||||
data.as_ptr(),
|
||||
@@ -476,7 +476,7 @@ impl AomDecoder {
|
||||
}
|
||||
|
||||
/// Notify the decoder to return any pending frame
|
||||
pub fn flush(&mut self) -> Result<DecodeFrames> {
|
||||
pub fn flush<'a>(&'a mut self) -> Result<DecodeFrames<'a>> {
|
||||
call_aom!(aom_codec_decode(
|
||||
&mut self.ctx,
|
||||
ptr::null(),
|
||||
|
||||
@@ -364,7 +364,7 @@ impl HwRamDecoder {
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn decode(&mut self, data: &[u8]) -> ResultType<Vec<HwRamDecoderImage>> {
|
||||
pub fn decode<'a>(&'a mut self, data: &[u8]) -> ResultType<Vec<HwRamDecoderImage<'a>>> {
|
||||
match self.decoder.decode(data) {
|
||||
Ok(v) => Ok(v.iter().map(|f| HwRamDecoderImage { frame: f }).collect()),
|
||||
Err(e) => Err(anyhow!(e)),
|
||||
|
||||
@@ -88,6 +88,27 @@ impl Display {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scale(&self) -> f64 {
|
||||
match self {
|
||||
Display::X11(_d) => 1.0,
|
||||
Display::WAYLAND(d) => d.scale(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn logical_width(&self) -> usize {
|
||||
match self {
|
||||
Display::X11(d) => d.width(),
|
||||
Display::WAYLAND(d) => d.logical_width(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn logical_height(&self) -> usize {
|
||||
match self {
|
||||
Display::X11(d) => d.height(),
|
||||
Display::WAYLAND(d) => d.logical_height(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn origin(&self) -> (i32, i32) {
|
||||
match self {
|
||||
Display::X11(d) => d.origin(),
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#![allow(non_upper_case_globals)]
|
||||
#![allow(improper_ctypes)]
|
||||
#![allow(dead_code)]
|
||||
#![allow(unused_imports)]
|
||||
|
||||
impl Default for vpx_codec_enc_cfg {
|
||||
fn default() -> Self {
|
||||
|
||||
@@ -231,7 +231,7 @@ impl EncoderApi for VpxEncoder {
|
||||
}
|
||||
|
||||
impl VpxEncoder {
|
||||
pub fn encode(&mut self, pts: i64, data: &[u8], stride_align: usize) -> Result<EncodeFrames> {
|
||||
pub fn encode<'a>(&'a mut self, pts: i64, data: &[u8], stride_align: usize) -> Result<EncodeFrames<'a>> {
|
||||
let bpp = if self.i444 { 24 } else { 12 };
|
||||
if data.len() < self.width * self.height * bpp / 8 {
|
||||
return Err(Error::FailedCall("len not enough".to_string()));
|
||||
@@ -268,7 +268,7 @@ impl VpxEncoder {
|
||||
}
|
||||
|
||||
/// Notify the encoder to return any pending packets
|
||||
pub fn flush(&mut self) -> Result<EncodeFrames> {
|
||||
pub fn flush<'a>(&'a mut self) -> Result<EncodeFrames<'a>> {
|
||||
call_vpx!(vpx_codec_encode(
|
||||
&mut self.ctx,
|
||||
ptr::null(),
|
||||
@@ -473,7 +473,7 @@ impl VpxDecoder {
|
||||
/// The `data` slice is sent to the decoder
|
||||
///
|
||||
/// It matches a call to `vpx_codec_decode`.
|
||||
pub fn decode(&mut self, data: &[u8]) -> Result<DecodeFrames> {
|
||||
pub fn decode<'a>(&'a mut self, data: &[u8]) -> Result<DecodeFrames<'a>> {
|
||||
call_vpx!(vpx_codec_decode(
|
||||
&mut self.ctx,
|
||||
data.as_ptr(),
|
||||
@@ -489,7 +489,7 @@ impl VpxDecoder {
|
||||
}
|
||||
|
||||
/// Notify the decoder to return any pending frame
|
||||
pub fn flush(&mut self) -> Result<DecodeFrames> {
|
||||
pub fn flush<'a>(&'a mut self) -> Result<DecodeFrames<'a>> {
|
||||
call_vpx!(vpx_codec_decode(
|
||||
&mut self.ctx,
|
||||
ptr::null(),
|
||||
|
||||
@@ -367,7 +367,7 @@ impl VRamDecoder {
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn decode(&mut self, data: &[u8]) -> ResultType<Vec<VRamDecoderImage>> {
|
||||
pub fn decode<'a>(&'a mut self, data: &[u8]) -> ResultType<Vec<VRamDecoderImage<'a>>> {
|
||||
match self.decoder.decode(data) {
|
||||
Ok(v) => Ok(v.iter().map(|f| VRamDecoderImage { frame: f }).collect()),
|
||||
Err(e) => Err(anyhow!(e)),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user