Compare commits
52 Commits
nightly
...
fix-mobile
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4423e0a1dc | ||
|
|
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 |
14
.github/workflows/flutter-build.yml
vendored
14
.github/workflows/flutter-build.yml
vendored
@@ -39,7 +39,7 @@ 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.4"
|
||||
NDK_VERSION: "r27c"
|
||||
#signing keys env variable checks
|
||||
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"
|
||||
@@ -1001,6 +1001,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 +1210,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 +1447,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 +1728,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.4"
|
||||
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.4"
|
||||
release-tag: "1.4.4"
|
||||
token: ${{ secrets.WINGET_TOKEN }}
|
||||
|
||||
439
Cargo.lock
generated
439
Cargo.lock
generated
@@ -328,13 +328,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "async-compression"
|
||||
version = "0.4.11"
|
||||
version = "0.4.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd066d0b4ef8ecb03a55319dc13aa6910616d0f44008a045bb1835af830abff5"
|
||||
checksum = "5a89bce6054c720275ac2432fbba080a66a2106a44a1b804553930ca6909f4e0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"compression-codecs",
|
||||
"compression-core",
|
||||
"futures-core",
|
||||
"memchr",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
@@ -936,18 +936,43 @@ dependencies = [
|
||||
"thiserror 1.0.61",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "calloop"
|
||||
version = "0.14.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb9f6e1368bd4621d2c86baa7e37de77a938adf5221e5dd3d6133340101b309e"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"polling 3.7.2",
|
||||
"rustix 1.1.2",
|
||||
"slab",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "calloop-wayland-source"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20"
|
||||
dependencies = [
|
||||
"calloop",
|
||||
"calloop 0.13.0",
|
||||
"rustix 0.38.34",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "calloop-wayland-source"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa"
|
||||
dependencies = [
|
||||
"calloop 0.14.3",
|
||||
"rustix 1.1.2",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.13"
|
||||
@@ -1269,6 +1294,23 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compression-codecs"
|
||||
version = "0.4.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef8a506ec4b81c460798f572caead636d57d3d7e940f998160f52bd254bf2d23"
|
||||
dependencies = [
|
||||
"compression-core",
|
||||
"flate2",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compression-core"
|
||||
version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e47641d3deaf41fb1538ac1f54735925e275eaf3bf4d55c81b137fba797e5cbb"
|
||||
|
||||
[[package]]
|
||||
name = "concurrent-queue"
|
||||
version = "2.5.0"
|
||||
@@ -2299,9 +2341,9 @@ checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.9"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
@@ -3320,6 +3362,7 @@ name = "hbb_common"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
"backtrace",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
@@ -3345,12 +3388,14 @@ dependencies = [
|
||||
"protobuf-codegen",
|
||||
"rand 0.8.5",
|
||||
"regex",
|
||||
"rustls-native-certs",
|
||||
"rustls-pki-types",
|
||||
"rustls-platform-verifier",
|
||||
"serde 1.0.203",
|
||||
"serde_derive",
|
||||
"serde_json 1.0.118",
|
||||
"sha2",
|
||||
"smithay-client-toolkit 0.20.0",
|
||||
"socket2 0.3.19",
|
||||
"sodiumoxide",
|
||||
"sysinfo",
|
||||
@@ -3358,13 +3403,14 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls",
|
||||
"tokio-socks 0.5.2-3",
|
||||
"tokio-socks",
|
||||
"tokio-tungstenite",
|
||||
"tokio-util",
|
||||
"toml 0.7.8",
|
||||
"tungstenite",
|
||||
"url",
|
||||
"uuid",
|
||||
"webpki-roots 1.0.4",
|
||||
"whoami",
|
||||
"winapi 0.3.9",
|
||||
"zstd 0.13.1",
|
||||
@@ -3506,7 +3552,7 @@ checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4"
|
||||
[[package]]
|
||||
name = "hwcodec"
|
||||
version = "0.7.1"
|
||||
source = "git+https://github.com/rustdesk-org/hwcodec#17c1dbb38450fe4a64aeba78fb50bec32f364a16"
|
||||
source = "git+https://github.com/rustdesk-org/hwcodec#398e5a8938dd8768ade0fcdc27ea80e8b4b38738"
|
||||
dependencies = [
|
||||
"bindgen 0.59.2",
|
||||
"cc",
|
||||
@@ -3518,18 +3564,20 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.6.0"
|
||||
version = "1.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80"
|
||||
checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"itoa 1.0.11",
|
||||
"pin-project-lite",
|
||||
"pin-utils",
|
||||
"smallvec",
|
||||
"tokio",
|
||||
"want",
|
||||
@@ -3537,9 +3585,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hyper-rustls"
|
||||
version = "0.27.6"
|
||||
version = "0.27.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03a01595e11bdcec50946522c32dde3fc6914743000a68b93000965f2f02406d"
|
||||
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
|
||||
dependencies = [
|
||||
"http",
|
||||
"hyper",
|
||||
@@ -3550,7 +3598,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots 1.0.0",
|
||||
"webpki-roots 1.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3571,17 +3619,21 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.12"
|
||||
version = "0.1.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf9f1e950e0d9d1d3c47184416723cf29c0d1f93bd8cccf37e4beb6b44f31710"
|
||||
checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"hyper",
|
||||
"ipnet",
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2 0.5.10",
|
||||
"tokio",
|
||||
@@ -3750,9 +3802,19 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.9.0"
|
||||
version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3"
|
||||
checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
|
||||
|
||||
[[package]]
|
||||
name = "iri-string"
|
||||
version = "0.7.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"serde 1.0.203",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-terminal"
|
||||
@@ -3872,7 +3934,7 @@ dependencies = [
|
||||
"log",
|
||||
"parking_lot",
|
||||
"rand 0.8.5",
|
||||
"thiserror 2.0.11",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
@@ -4154,6 +4216,12 @@ version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a385b1be4e5c3e362ad2ffa73c392e53f031eaa5b7d648e64cd87f27f6063d7"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.12"
|
||||
@@ -4299,12 +4367,6 @@ dependencies = [
|
||||
"objc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
@@ -4642,7 +4704,7 @@ dependencies = [
|
||||
"nokhwa-bindings-windows",
|
||||
"nokhwa-core",
|
||||
"paste",
|
||||
"thiserror 2.0.11",
|
||||
"thiserror 2.0.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4690,7 +4752,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"image 0.25.1",
|
||||
"mozjpeg",
|
||||
"thiserror 2.0.11",
|
||||
"thiserror 2.0.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5215,6 +5277,15 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-src"
|
||||
version = "300.5.3+3.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc6bad8cd0233b63971e232cc9c5e83039375b8586d2312f31fda85db8f888c2"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.104"
|
||||
@@ -5223,6 +5294,7 @@ checksum = "45abf306cbf99debc8195b66b7346498d7b10c210de50418b5ccd7ceba08c741"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"openssl-src",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
@@ -5927,18 +5999,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.34.0"
|
||||
version = "0.37.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f24d770aeca0eacb81ac29dfbc55ebcc09312fdd1f8bbecdc7e4a84e000e3b4"
|
||||
checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.8"
|
||||
version = "0.11.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "626214629cda6781b6dc1d316ba307189c85ba657213ce642d9c77670f8202c8"
|
||||
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases 0.2.1",
|
||||
@@ -5948,7 +6020,7 @@ dependencies = [
|
||||
"rustc-hash 2.1.1",
|
||||
"rustls",
|
||||
"socket2 0.5.10",
|
||||
"thiserror 2.0.11",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"web-time",
|
||||
@@ -5956,9 +6028,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.12"
|
||||
version = "0.11.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "49df843a9161c85bb8aae55f101bc0bac8bcafd637a620d9122fd7e0b2f7422e"
|
||||
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.3.2",
|
||||
@@ -5969,7 +6041,7 @@ dependencies = [
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror 2.0.11",
|
||||
"thiserror 2.0.17",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
@@ -5977,9 +6049,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quinn-udp"
|
||||
version = "0.5.12"
|
||||
version = "0.5.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee4e529991f949c5e25755532370b8af5d114acae52326361d68d47af64aa842"
|
||||
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
|
||||
dependencies = [
|
||||
"cfg_aliases 0.2.1",
|
||||
"libc",
|
||||
@@ -6338,8 +6410,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.12.15"
|
||||
source = "git+https://github.com/rustdesk-org/reqwest#9e859438203a71eb86ddc294fbebfde14cba7f7c"
|
||||
version = "0.12.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f"
|
||||
dependencies = [
|
||||
"async-compression",
|
||||
"base64 0.22.1",
|
||||
@@ -6354,18 +6427,14 @@ dependencies = [
|
||||
"hyper-rustls",
|
||||
"hyper-tls",
|
||||
"hyper-util",
|
||||
"ipnet",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"native-tls",
|
||||
"once_cell",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-pemfile",
|
||||
"rustls-pki-types",
|
||||
"serde 1.0.203",
|
||||
"serde_json 1.0.118",
|
||||
@@ -6374,16 +6443,15 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls",
|
||||
"tokio-socks 0.5.2",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"webpki-roots 0.26.9",
|
||||
"windows-registry",
|
||||
"webpki-roots 1.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6527,7 +6595,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustdesk"
|
||||
version = "1.4.3"
|
||||
version = "1.4.4"
|
||||
dependencies = [
|
||||
"android-wakelock",
|
||||
"android_logger",
|
||||
@@ -6584,6 +6652,7 @@ dependencies = [
|
||||
"objc",
|
||||
"objc_id",
|
||||
"once_cell",
|
||||
"openssl",
|
||||
"os-version",
|
||||
"pam",
|
||||
"parity-tokio-ipc",
|
||||
@@ -6642,7 +6711,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustdesk-portable-packer"
|
||||
version = "1.4.3"
|
||||
version = "1.4.4"
|
||||
dependencies = [
|
||||
"brotli",
|
||||
"dirs 5.0.1",
|
||||
@@ -6696,10 +6765,23 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.26"
|
||||
name = "rustix"
|
||||
version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df51b5869f3a441595eac5e8ff14d486ff285f7b8c0df8770e49c3b56351f0f0"
|
||||
checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.11.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7160e3e10bf4535308537f3c4e1641468cd0e485175d6163087c0393c7d46643"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
@@ -6719,16 +6801,7 @@ dependencies = [
|
||||
"openssl-probe",
|
||||
"rustls-pki-types",
|
||||
"schannel",
|
||||
"security-framework 3.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pemfile"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
"security-framework 3.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6742,9 +6815,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier"
|
||||
version = "0.5.1"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4a5467026f437b4cb2a533865eaa73eb840019a0916f4b9ec563c6e617e086c9"
|
||||
checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
|
||||
dependencies = [
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys 0.8.7",
|
||||
@@ -6755,7 +6828,7 @@ dependencies = [
|
||||
"rustls-native-certs",
|
||||
"rustls-platform-verifier-android",
|
||||
"rustls-webpki",
|
||||
"security-framework 3.2.0",
|
||||
"security-framework 3.5.1",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.52.0",
|
||||
@@ -6763,15 +6836,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier-android"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84e217e7fdc8466b5b35d30f8c0a30febd29173df4a3a0c2115d306b9c4117ad"
|
||||
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.1"
|
||||
version = "0.103.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fef8b8769aaccf73098557a87cd1816b4f9c7c16811c9c77142aa695c16f2c03"
|
||||
checksum = "e4a72fe2bcf7a6ac6fd7d0b9e5cb68aeb7d4c0a0271730218b3e92d43b4eb435"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
@@ -6871,6 +6944,7 @@ dependencies = [
|
||||
"tracing",
|
||||
"webm",
|
||||
"winapi 0.3.9",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6882,7 +6956,7 @@ dependencies = [
|
||||
"ab_glyph",
|
||||
"log",
|
||||
"memmap2",
|
||||
"smithay-client-toolkit",
|
||||
"smithay-client-toolkit 0.19.2",
|
||||
"tiny-skia",
|
||||
]
|
||||
|
||||
@@ -6901,9 +6975,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.2.0"
|
||||
version = "3.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "271720403f46ca04f7ba6f55d438f8bd878d6b8ca0a1046e8228c4145bcbb316"
|
||||
checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"core-foundation 0.10.1",
|
||||
@@ -6914,9 +6988,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "security-framework-sys"
|
||||
version = "2.14.0"
|
||||
version = "2.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32"
|
||||
checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0"
|
||||
dependencies = [
|
||||
"core-foundation-sys 0.8.7",
|
||||
"libc",
|
||||
@@ -7200,8 +7274,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"calloop",
|
||||
"calloop-wayland-source",
|
||||
"calloop 0.13.0",
|
||||
"calloop-wayland-source 0.3.0",
|
||||
"cursor-icon",
|
||||
"libc",
|
||||
"log",
|
||||
@@ -7218,6 +7292,33 @@ dependencies = [
|
||||
"xkeysym",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smithay-client-toolkit"
|
||||
version = "0.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"calloop 0.14.3",
|
||||
"calloop-wayland-source 0.4.1",
|
||||
"cursor-icon",
|
||||
"libc",
|
||||
"log",
|
||||
"memmap2",
|
||||
"rustix 1.1.2",
|
||||
"thiserror 2.0.17",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-csd-frame",
|
||||
"wayland-cursor",
|
||||
"wayland-protocols",
|
||||
"wayland-protocols-experimental",
|
||||
"wayland-protocols-misc",
|
||||
"wayland-protocols-wlr",
|
||||
"wayland-scanner",
|
||||
"xkeysym",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smol_str"
|
||||
version = "0.2.2"
|
||||
@@ -7709,11 +7810,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.11"
|
||||
version = "2.0.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d452f284b73e6d76dd36758a0c8684b1d5be31f92b89d07fd5822175732206fc"
|
||||
checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8"
|
||||
dependencies = [
|
||||
"thiserror-impl 2.0.11",
|
||||
"thiserror-impl 2.0.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7729,9 +7830,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.11"
|
||||
version = "2.0.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26afc1baea8a989337eeb52b6e72a039780ce45c3edfcc9c5b9d112feeb173c2"
|
||||
checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
|
||||
dependencies = [
|
||||
"proc-macro2 1.0.93",
|
||||
"quote 1.0.36",
|
||||
@@ -7927,23 +8028,11 @@ dependencies = [
|
||||
"futures-sink",
|
||||
"futures-util",
|
||||
"pin-project",
|
||||
"thiserror 2.0.11",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-socks"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d4770b8024672c1101b3f6733eab95b18007dbe0847a8afe341fcf79e06043f"
|
||||
dependencies = [
|
||||
"either",
|
||||
"futures-util",
|
||||
"thiserror 1.0.61",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.26.2"
|
||||
@@ -8079,6 +8168,24 @@ dependencies = [
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-http"
|
||||
version = "0.6.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"iri-string",
|
||||
"pin-project-lite",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-layer"
|
||||
version = "0.3.3"
|
||||
@@ -8097,6 +8204,7 @@ version = "0.1.41"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
@@ -8252,7 +8360,7 @@ dependencies = [
|
||||
"rustls-native-certs",
|
||||
"rustls-pki-types",
|
||||
"sha1",
|
||||
"thiserror 2.0.11",
|
||||
"thiserror 2.0.17",
|
||||
"utf-8",
|
||||
"webpki-roots 0.26.9",
|
||||
]
|
||||
@@ -8701,13 +8809,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wayland-backend"
|
||||
version = "0.3.6"
|
||||
version = "0.3.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f90e11ce2ca99c97b940ee83edbae9da2d56a08f9ea8158550fd77fa31722993"
|
||||
checksum = "673a33c33048a5ade91a6b139580fa174e19fb0d23f396dca9fa15f2e1e49b35"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"downcast-rs",
|
||||
"rustix 0.38.34",
|
||||
"rustix 1.1.2",
|
||||
"scoped-tls",
|
||||
"smallvec",
|
||||
"wayland-sys",
|
||||
@@ -8715,12 +8823,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wayland-client"
|
||||
version = "0.31.5"
|
||||
version = "0.31.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e321577a0a165911bdcfb39cf029302479d7527b517ee58ab0f6ad09edf0943"
|
||||
checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"rustix 0.38.34",
|
||||
"rustix 1.1.2",
|
||||
"wayland-backend",
|
||||
"wayland-scanner",
|
||||
]
|
||||
@@ -8749,9 +8857,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wayland-protocols"
|
||||
version = "0.32.3"
|
||||
version = "0.32.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "62989625a776e827cc0f15d41444a3cea5205b963c3a25be48ae1b52d6b4daaa"
|
||||
checksum = "efa790ed75fbfd71283bd2521a1cfdc022aabcc28bdcff00851f9e4ae88d9901"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"wayland-backend",
|
||||
@@ -8759,6 +8867,32 @@ dependencies = [
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-protocols-experimental"
|
||||
version = "20250721.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-protocols-misc"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2dfe33d551eb8bffd03ff067a8b44bb963919157841a99957151299a6307d19c"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-protocols-plasma"
|
||||
version = "0.3.3"
|
||||
@@ -8787,20 +8921,20 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wayland-scanner"
|
||||
version = "0.31.4"
|
||||
version = "0.31.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7b56f89937f1cf2ee1f1259cf2936a17a1f45d8f0aa1019fae6d470d304cfa6"
|
||||
checksum = "54cb1e9dc49da91950bdfd8b848c49330536d9d1fb03d4bfec8cae50caa50ae3"
|
||||
dependencies = [
|
||||
"proc-macro2 1.0.93",
|
||||
"quick-xml 0.34.0",
|
||||
"quick-xml 0.37.5",
|
||||
"quote 1.0.36",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-sys"
|
||||
version = "0.31.4"
|
||||
version = "0.31.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43676fe2daf68754ecf1d72026e4e6c15483198b5d24e888b74d3f22f887a148"
|
||||
checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142"
|
||||
dependencies = [
|
||||
"dlib",
|
||||
"log",
|
||||
@@ -8846,9 +8980,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "webpki-root-certs"
|
||||
version = "0.26.8"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09aed61f5e8d2c18344b3faa33a4c837855fe56642757754775548fee21386c4"
|
||||
checksum = "05d651ec480de84b762e7be71e6efa7461699c19d9e2c272c8d93455f567786e"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
@@ -8864,9 +8998,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.0"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2853738d1cc4f2da3a225c18ec6c3721abb31961096e9dbf5ab35fa88b19cfdb"
|
||||
checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
@@ -9191,17 +9325,6 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3"
|
||||
dependencies = [
|
||||
"windows-result 0.3.2",
|
||||
"windows-strings 0.3.1",
|
||||
"windows-targets 0.53.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.1.2"
|
||||
@@ -9315,29 +9438,13 @@ dependencies = [
|
||||
"windows_aarch64_gnullvm 0.52.6",
|
||||
"windows_aarch64_msvc 0.52.6",
|
||||
"windows_i686_gnu 0.52.6",
|
||||
"windows_i686_gnullvm 0.52.6",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc 0.52.6",
|
||||
"windows_x86_64_gnu 0.52.6",
|
||||
"windows_x86_64_gnullvm 0.52.6",
|
||||
"windows_x86_64_msvc 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1e4c7e8ceaaf9cb7d7507c974735728ab453b67ef8f18febdd7c11fe59dca8b"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.53.0",
|
||||
"windows_aarch64_msvc 0.53.0",
|
||||
"windows_i686_gnu 0.53.0",
|
||||
"windows_i686_gnullvm 0.53.0",
|
||||
"windows_i686_msvc 0.53.0",
|
||||
"windows_x86_64_gnu 0.53.0",
|
||||
"windows_x86_64_gnullvm 0.53.0",
|
||||
"windows_x86_64_msvc 0.53.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-version"
|
||||
version = "0.1.1"
|
||||
@@ -9374,12 +9481,6 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.32.0"
|
||||
@@ -9410,12 +9511,6 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.32.0"
|
||||
@@ -9446,24 +9541,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.32.0"
|
||||
@@ -9494,12 +9577,6 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.32.0"
|
||||
@@ -9530,12 +9607,6 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.42.2"
|
||||
@@ -9554,12 +9625,6 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.32.0"
|
||||
@@ -9590,12 +9655,6 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486"
|
||||
|
||||
[[package]]
|
||||
name = "winit"
|
||||
version = "0.30.9"
|
||||
@@ -9608,7 +9667,7 @@ dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"block2 0.5.1",
|
||||
"bytemuck",
|
||||
"calloop",
|
||||
"calloop 0.13.0",
|
||||
"cfg_aliases 0.2.1",
|
||||
"concurrent-queue",
|
||||
"core-foundation 0.9.4",
|
||||
@@ -9630,7 +9689,7 @@ dependencies = [
|
||||
"redox_syscall 0.4.1",
|
||||
"rustix 0.38.34",
|
||||
"sctk-adwaita",
|
||||
"smithay-client-toolkit",
|
||||
"smithay-client-toolkit 0.19.2",
|
||||
"smol_str",
|
||||
"tracing",
|
||||
"unicode-segmentation",
|
||||
|
||||
14
Cargo.toml
14
Cargo.toml
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rustdesk"
|
||||
version = "1.4.3"
|
||||
version = "1.4.4"
|
||||
authors = ["rustdesk <info@rustdesk.com>"]
|
||||
edition = "2021"
|
||||
build= "build.rs"
|
||||
@@ -83,6 +83,8 @@ 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" }
|
||||
@@ -165,13 +167,6 @@ 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]
|
||||
psimple = { package = "libpulse-simple-binding", version = "2.27" }
|
||||
pulse = { package = "libpulse-binding", version = "2.27" }
|
||||
@@ -192,6 +187,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"
|
||||
|
||||
@@ -18,7 +18,7 @@ AppDir:
|
||||
id: rustdesk
|
||||
name: rustdesk
|
||||
icon: rustdesk
|
||||
version: 1.4.3
|
||||
version: 1.4.4
|
||||
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.4
|
||||
exec: usr/share/rustdesk/rustdesk
|
||||
exec_args: $@
|
||||
apt:
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
],
|
||||
"finish-args": [
|
||||
"--share=ipc",
|
||||
"--socket=fallback-x11",
|
||||
"--socket=x11",
|
||||
"--socket=wayland",
|
||||
"--share=network",
|
||||
"--filesystem=home",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -44,7 +44,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();
|
||||
@@ -1681,13 +1681,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 +1814,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 +1841,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2948,7 +2949,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,
|
||||
|
||||
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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -364,12 +364,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)
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,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 +79,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";
|
||||
@@ -158,11 +160,15 @@ 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";
|
||||
|
||||
// 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";
|
||||
@@ -319,13 +325,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 +361,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';
|
||||
@@ -760,9 +761,19 @@ 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 == kWindowGetWindowInfo) {
|
||||
@@ -793,6 +804,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';
|
||||
@@ -560,6 +561,12 @@ class _GeneralState extends State<_General> {
|
||||
children.add(_OptionCheckBox(
|
||||
context, 'Allow linux headless', kOptionAllowLinuxHeadless));
|
||||
}
|
||||
children.add(_OptionCheckBox(
|
||||
context,
|
||||
'note-at-conn-end-tip',
|
||||
kOptionAllowAskForNoteAtEndOfConnection,
|
||||
isServer: false,
|
||||
));
|
||||
return _Card(title: 'Other', children: children);
|
||||
}
|
||||
|
||||
@@ -1177,7 +1184,7 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
|
||||
],
|
||||
),
|
||||
enabled: tmpEnabled && !locked),
|
||||
numericOneTimePassword,
|
||||
if (usePassword) numericOneTimePassword,
|
||||
if (usePassword) radios[1],
|
||||
if (usePassword)
|
||||
_SubButton('Set permanent password', setPasswordDialog,
|
||||
@@ -1585,6 +1592,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 +1624,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 +1745,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 +1763,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 +1821,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(
|
||||
|
||||
@@ -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';
|
||||
@@ -52,7 +53,7 @@ enum MouseFocusScope {
|
||||
}
|
||||
|
||||
class FileManagerPage extends StatefulWidget {
|
||||
const FileManagerPage(
|
||||
FileManagerPage(
|
||||
{Key? key,
|
||||
required this.id,
|
||||
required this.password,
|
||||
@@ -67,9 +68,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>
|
||||
@@ -139,12 +147,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 +182,7 @@ class _FileManagerPageState extends State<FileManagerPage>
|
||||
Flexible(flex: 2, child: statusList())
|
||||
],
|
||||
),
|
||||
);
|
||||
));
|
||||
})
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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,6 +3,7 @@ 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';
|
||||
@@ -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;
|
||||
@@ -112,11 +116,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,
|
||||
@@ -395,7 +401,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 +414,8 @@ class _RemotePageState extends State<RemotePage>
|
||||
}
|
||||
|
||||
void enterView(PointerEnterEvent evt) {
|
||||
_ffi.canvasModel.rearmEdgeScroll();
|
||||
|
||||
_cursorOverImage.value = true;
|
||||
_firstEnterImage.value = true;
|
||||
if (_onEnterOrLeaveImage4Toolbar != null) {
|
||||
@@ -427,6 +435,8 @@ class _RemotePageState extends State<RemotePage>
|
||||
}
|
||||
|
||||
void leaveView(PointerExitEvent evt) {
|
||||
_ffi.canvasModel.disableEdgeScroll();
|
||||
|
||||
if (_ffi.ffiModel.keyboard) {
|
||||
_ffi.inputModel.tryMoveEdgeOnExit(evt.position);
|
||||
}
|
||||
@@ -625,7 +635,7 @@ class _ImagePaintState extends State<ImagePaint> {
|
||||
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 +690,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 +716,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!,
|
||||
@@ -316,7 +324,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();
|
||||
},
|
||||
@@ -369,6 +383,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 +445,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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -360,7 +360,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: [
|
||||
@@ -527,7 +527,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!,
|
||||
@@ -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,6 +26,7 @@ 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;
|
||||
@@ -510,7 +511,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 +722,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 +933,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 +1014,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 +1040,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 +1056,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 +1071,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 +1107,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 +1117,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 +1227,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 +1250,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 +1258,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 +1291,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 +1302,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 +1320,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 +1332,7 @@ class _RectValueThumbShape extends SliderComponentShape {
|
||||
required this.height,
|
||||
required this.radius,
|
||||
this.displayValueForNormalized,
|
||||
this.unit = '%',
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -1448,12 +1373,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 +1391,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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1812,7 +1738,7 @@ class _KeyboardMenu extends StatelessWidget {
|
||||
ffi: ffi,
|
||||
color: _ToolbarTheme.blueColor,
|
||||
hoverColor: _ToolbarTheme.hoverBlueColor,
|
||||
menuChildrenGetter: () => [
|
||||
menuChildrenGetter: (_) => [
|
||||
keyboardMode(),
|
||||
localKeyboardType(),
|
||||
inputSource(),
|
||||
@@ -2077,7 +2003,7 @@ class _ChatMenuState extends State<_ChatMenu> {
|
||||
ffi: widget.ffi,
|
||||
color: _ToolbarTheme.blueColor,
|
||||
hoverColor: _ToolbarTheme.hoverBlueColor,
|
||||
menuChildrenGetter: () => [textChat(), voiceCall()]);
|
||||
menuChildrenGetter: (_) => [textChat(), voiceCall()]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2133,7 +2059,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 +2165,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 +2264,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 +2289,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 +2326,7 @@ class _IconSubmenuButtonState extends State<_IconSubmenuButton> {
|
||||
),
|
||||
child: icon))),
|
||||
menuChildren: widget
|
||||
.menuChildrenGetter()
|
||||
.menuChildrenGetter(this)
|
||||
.map((e) => _buildPointerTrackWidget(e, widget.ffi))
|
||||
.toList()));
|
||||
return MenuBar(children: [
|
||||
@@ -2753,3 +2689,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,11 @@ 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;
|
||||
@@ -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(
|
||||
@@ -591,67 +594,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,
|
||||
|
||||
@@ -25,6 +25,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;
|
||||
|
||||
@@ -365,7 +366,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 +484,7 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
||||
color: Colors.white,
|
||||
icon: Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
clientClose(sessionId, gFFI.dialogManager);
|
||||
clientClose(sessionId, gFFI);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
@@ -576,7 +577,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 +635,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');
|
||||
@@ -1054,11 +1055,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 +1139,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 +1160,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 +1218,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');
|
||||
|
||||
@@ -94,7 +94,11 @@ 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;
|
||||
|
||||
_SettingsState() {
|
||||
_enableAbr = option2bool(
|
||||
@@ -109,6 +113,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 +137,8 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
||||
_enableTrustedDevices = mainGetBoolOptionSync(kOptionEnableTrustedDevices);
|
||||
_enableUdpPunch = mainGetLocalBoolOptionSync(kOptionEnableUdpPunch);
|
||||
_enableIpv6Punch = mainGetLocalBoolOptionSync(kOptionEnableIpv6Punch);
|
||||
_allowAskForNoteAtEndOfConnection =
|
||||
mainGetLocalBoolOptionSync(kOptionAllowAskForNoteAtEndOfConnection);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -200,6 +209,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(() {});
|
||||
}
|
||||
@@ -667,9 +683,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 +710,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,6 +785,19 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
||||
onPressed: (context) {
|
||||
showThemeSettings(gFFI.dialogManager);
|
||||
},
|
||||
),
|
||||
SettingsTile.switchTile(
|
||||
title: Text(translate('note-at-conn-end-tip')),
|
||||
initialValue: _allowAskForNoteAtEndOfConnection,
|
||||
onToggle: (v) async {
|
||||
await mainSetLocalBoolOption(
|
||||
kOptionAllowAskForNoteAtEndOfConnection, v);
|
||||
final newValue = mainGetLocalBoolOptionSync(
|
||||
kOptionAllowAskForNoteAtEndOfConnection);
|
||||
setState(() {
|
||||
_allowAskForNoteAtEndOfConnection = newValue;
|
||||
});
|
||||
},
|
||||
)
|
||||
]),
|
||||
if (isAndroid)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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';
|
||||
@@ -38,6 +39,8 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
? (GoogleFonts.robotoMono().fontFamily ?? 'monospace')
|
||||
: 'monospace';
|
||||
|
||||
SessionID get sessionId => _ffi.sessionId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -82,6 +85,16 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
return WillPopScope(
|
||||
onWillPop: () async {
|
||||
clientClose(sessionId, _ffi);
|
||||
return false; // Prevent default back behavior
|
||||
},
|
||||
child: buildBody(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildBody() {
|
||||
return Scaffold(
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
body: TerminalView(
|
||||
|
||||
@@ -197,7 +197,7 @@ class _ViewCameraPageState extends State<ViewCameraPage>
|
||||
|
||||
return WillPopScope(
|
||||
onWillPop: () async {
|
||||
clientClose(sessionId, gFFI.dialogManager);
|
||||
clientClose(sessionId, gFFI);
|
||||
return false;
|
||||
},
|
||||
child: Scaffold(
|
||||
@@ -310,7 +310,7 @@ class _ViewCameraPageState extends State<ViewCameraPage>
|
||||
color: Colors.white,
|
||||
icon: Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
clientClose(sessionId, gFFI.dialogManager);
|
||||
clientClose(sessionId, gFFI);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
@@ -590,6 +590,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 +611,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'));
|
||||
}
|
||||
|
||||
@@ -42,8 +42,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 +57,7 @@ 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;
|
||||
}
|
||||
@@ -375,6 +372,7 @@ class InputModel {
|
||||
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;
|
||||
|
||||
InputModel(this.parent) {
|
||||
sessionId = parent.target!.sessionId;
|
||||
@@ -812,6 +810,10 @@ class InputModel {
|
||||
}
|
||||
|
||||
Future<void> tapDown(MouseButtons button) async {
|
||||
if (!_pointerMovedAfterEnter) {
|
||||
refreshMousePos();
|
||||
await Future.delayed(Duration(milliseconds: 10));
|
||||
}
|
||||
await sendMouse('down', button);
|
||||
}
|
||||
|
||||
@@ -888,7 +890,7 @@ class InputModel {
|
||||
isPhysicalMouse.value = true;
|
||||
}
|
||||
if (isPhysicalMouse.value) {
|
||||
handleMouse(_getMouseEvent(e, _kMouseEventMove), e.position);
|
||||
handleMouse(_getMouseEvent(e, _kMouseEventMove), e.position, edgeScroll: useEdgeScroll);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1076,7 +1078,7 @@ class InputModel {
|
||||
_queryOtherWindowCoords = false;
|
||||
}
|
||||
if (isPhysicalMouse.value) {
|
||||
handleMouse(_getMouseEvent(e, _kMouseEventMove), e.position);
|
||||
handleMouse(_getMouseEvent(e, _kMouseEventMove), e.position, edgeScroll: useEdgeScroll);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1125,7 +1127,7 @@ class InputModel {
|
||||
void refreshMousePos() => handleMouse({
|
||||
'buttons': 0,
|
||||
'type': _kMouseEventMove,
|
||||
}, lastMousePos);
|
||||
}, lastMousePos, edgeScroll: useEdgeScroll);
|
||||
|
||||
void tryMoveEdgeOnExit(Offset pos) => handleMouse(
|
||||
{
|
||||
@@ -1232,6 +1234,7 @@ class InputModel {
|
||||
Offset offset, {
|
||||
bool onExit = false,
|
||||
bool moveCanvas = true,
|
||||
bool edgeScroll = false,
|
||||
}) {
|
||||
if (isViewCamera) return null;
|
||||
double x = offset.dx;
|
||||
@@ -1273,6 +1276,7 @@ class InputModel {
|
||||
onExit: onExit,
|
||||
buttons: evt['buttons'],
|
||||
moveCanvas: moveCanvas,
|
||||
edgeScroll: edgeScroll,
|
||||
);
|
||||
if (pos == null) {
|
||||
return null;
|
||||
@@ -1301,9 +1305,10 @@ class InputModel {
|
||||
Offset offset, {
|
||||
bool onExit = false,
|
||||
bool moveCanvas = true,
|
||||
bool edgeScroll = false,
|
||||
}) {
|
||||
final evtToPeer =
|
||||
processEventToPeer(evt, offset, onExit: onExit, moveCanvas: moveCanvas);
|
||||
processEventToPeer(evt, offset, onExit: onExit, moveCanvas: moveCanvas, edgeScroll: edgeScroll);
|
||||
if (evtToPeer != null) {
|
||||
bind.sessionSendMouse(
|
||||
sessionId: sessionId, msg: json.encode(modify(evtToPeer)));
|
||||
@@ -1320,6 +1325,7 @@ class InputModel {
|
||||
bool onExit = false,
|
||||
int buttons = kPrimaryMouseButton,
|
||||
bool moveCanvas = true,
|
||||
bool edgeScroll = false,
|
||||
}) {
|
||||
final ffiModel = parent.target!.ffiModel;
|
||||
CanvasCoords canvas =
|
||||
@@ -1348,8 +1354,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 +1426,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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -156,6 +159,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 +181,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;
|
||||
@@ -931,11 +939,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), () {
|
||||
@@ -956,8 +974,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 +1006,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')
|
||||
@@ -1044,28 +1081,108 @@ class FfiModel with ChangeNotifier {
|
||||
if (displays.length == 1) {
|
||||
bind.sessionSetSize(
|
||||
sessionId: sessionId,
|
||||
display:
|
||||
pi.currentDisplay == kAllDisplayValue ? 0 : pi.currentDisplay,
|
||||
width: _rect!.width.toInt(),
|
||||
height: _rect!.height.toInt(),
|
||||
display: pi.currentDisplay == kAllDisplayValue ? 0 : pi.currentDisplay,
|
||||
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 (!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;
|
||||
|
||||
_queryAuditGuid(peerId);
|
||||
|
||||
// This call is to ensuer the keyboard mode is updated depending on the peer version.
|
||||
parent.target?.inputModel.updateKeyboardMode();
|
||||
|
||||
@@ -1323,8 +1440,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 +1791,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 +1840,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 +1964,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 +2039,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 +2078,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;
|
||||
@@ -1957,30 +2204,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 +2271,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 +2326,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 +2984,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 +3009,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 +3430,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 +3520,6 @@ class FFI {
|
||||
List<int>? displays,
|
||||
}) {
|
||||
closed = false;
|
||||
auditNote = '';
|
||||
if (isMobile) mobileReset();
|
||||
assert(
|
||||
(!(isPortForward && isViewCamera)) &&
|
||||
@@ -3318,6 +3711,7 @@ class FFI {
|
||||
dialogManager.dismissAll();
|
||||
await canvasModel.updateViewStyle();
|
||||
await canvasModel.updateScrollStyle();
|
||||
await canvasModel.initializeEdgeScrollEdgeThickness();
|
||||
for (final cb in imageModel.callbacksOnFirstImage) {
|
||||
cb(id);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,41 @@ 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']);
|
||||
}
|
||||
|
||||
void dispose() {}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,8 @@ add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}")
|
||||
add_executable(${BINARY_NAME}
|
||||
"main.cc"
|
||||
"my_application.cc"
|
||||
"bump_mouse.cc"
|
||||
"bump_mouse_x11.cc"
|
||||
"${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
|
||||
)
|
||||
|
||||
|
||||
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,5 +1,7 @@
|
||||
#include "my_application.h"
|
||||
|
||||
#include "bump_mouse.h"
|
||||
|
||||
#include <flutter_linux/flutter_linux.h>
|
||||
#ifdef GDK_WINDOWING_X11
|
||||
#include <gdk/gdkx.h>
|
||||
@@ -10,10 +12,13 @@
|
||||
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 +29,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++) {
|
||||
@@ -87,6 +93,17 @@ static void my_application_activate(GApplication* application) {
|
||||
|
||||
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 +130,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 +149,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 +233,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);
|
||||
|
||||
@@ -29,7 +29,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 +50,22 @@ 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)
|
||||
}
|
||||
|
||||
|
||||
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 {
|
||||
@@ -99,6 +99,58 @@ class MainFlutterWindow: NSWindow {
|
||||
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.
|
||||
CGAssociateMouseAndMouseCursorPosition(1 /* true */)
|
||||
|
||||
result(true)
|
||||
|
||||
break
|
||||
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
|
||||
@@ -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.4+62
|
||||
|
||||
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_
|
||||
|
||||
Submodule libs/hbb_common updated: 5ed0afde08...a86eda749e
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rustdesk-portable-packer"
|
||||
version = "1.4.3"
|
||||
version = "1.4.4"
|
||||
edition = "2021"
|
||||
description = "RustDesk Remote Desktop"
|
||||
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -8,7 +8,6 @@ use super::x11::PixelBuffer;
|
||||
|
||||
pub struct Capturer(Display, Box<dyn Recorder>, Vec<u8>);
|
||||
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref MAP_ERR: RwLock<Option<fn(err: String)-> io::Error>> = Default::default();
|
||||
}
|
||||
@@ -61,7 +60,7 @@ impl TraitCapturer for Capturer {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Display(pipewire::PipeWireCapturable);
|
||||
pub struct Display(pub(crate) pipewire::PipeWireCapturable);
|
||||
|
||||
impl Display {
|
||||
pub fn primary() -> io::Result<Display> {
|
||||
@@ -81,11 +80,35 @@ impl Display {
|
||||
}
|
||||
|
||||
pub fn width(&self) -> usize {
|
||||
self.0.size.0
|
||||
self.physical_width()
|
||||
}
|
||||
|
||||
pub fn height(&self) -> usize {
|
||||
self.0.size.1
|
||||
self.physical_height()
|
||||
}
|
||||
|
||||
pub fn physical_width(&self) -> usize {
|
||||
self.0.physical_size.0
|
||||
}
|
||||
|
||||
pub fn physical_height(&self) -> usize {
|
||||
self.0.physical_size.1
|
||||
}
|
||||
|
||||
pub fn logical_width(&self) -> usize {
|
||||
self.0.logical_size.0
|
||||
}
|
||||
|
||||
pub fn logical_height(&self) -> usize {
|
||||
self.0.logical_size.1
|
||||
}
|
||||
|
||||
pub fn scale(&self) -> f64 {
|
||||
if self.logical_width() == 0 {
|
||||
1.0
|
||||
} else {
|
||||
self.physical_width() as f64 / self.logical_width() as f64
|
||||
}
|
||||
}
|
||||
|
||||
pub fn origin(&self) -> (i32, i32) {
|
||||
@@ -97,7 +120,7 @@ impl Display {
|
||||
}
|
||||
|
||||
pub fn is_primary(&self) -> bool {
|
||||
false
|
||||
self.0.primary
|
||||
}
|
||||
|
||||
pub fn name(&self) -> String {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
// logic from webrtc -- https://github.com/shiguredo/libwebrtc/blob/main/modules/desktop_capture/win/screen_capturer_win_magnifier.cc
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
use lazy_static;
|
||||
use std::{
|
||||
ffi::CString,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod capturable;
|
||||
pub mod pipewire;
|
||||
pub mod display;
|
||||
mod screencast_portal;
|
||||
mod request_portal;
|
||||
pub mod remote_desktop_portal;
|
||||
|
||||
256
libs/scrap/src/wayland/display.rs
Normal file
256
libs/scrap/src/wayland/display.rs
Normal file
@@ -0,0 +1,256 @@
|
||||
use hbb_common::regex::Regex;
|
||||
use lazy_static::lazy_static;
|
||||
use std::sync::Mutex;
|
||||
use std::{
|
||||
process::{Command, Output, Stdio},
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tracing::warn;
|
||||
|
||||
use hbb_common::platform::linux::{get_wayland_displays, WaylandDisplayInfo};
|
||||
|
||||
lazy_static! {
|
||||
static ref DISPLAYS: Mutex<Option<Arc<Displays>>> = Mutex::new(None);
|
||||
}
|
||||
|
||||
const COMMAND_TIMEOUT: Duration = Duration::from_millis(1000);
|
||||
|
||||
pub struct Displays {
|
||||
pub primary: usize,
|
||||
pub displays: Vec<WaylandDisplayInfo>,
|
||||
}
|
||||
|
||||
// We need this helper to run commands with a timeout, as some commands may hang.
|
||||
// `kscreen-doctor -o` is known to hang when:
|
||||
// 1. On Archlinux, Both GNOME and KDE Plasma are installed.
|
||||
// 2. Run this command in a GNOME session.
|
||||
fn run_with_timeout(
|
||||
program: &str,
|
||||
args: &[&str],
|
||||
timeout: Duration,
|
||||
label: &str,
|
||||
) -> Option<Output> {
|
||||
let mut child = Command::new(program)
|
||||
.args(args)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.ok()?;
|
||||
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
if let Ok(Some(_)) = child.try_wait() {
|
||||
break;
|
||||
}
|
||||
if start.elapsed() >= timeout {
|
||||
warn!("{} command timed out after {:?}", label, timeout);
|
||||
if let Err(e) = child.kill() {
|
||||
warn!("Failed to kill child process for '{}': {}", label, e);
|
||||
}
|
||||
if let Err(e) = child.wait() {
|
||||
warn!("Failed to wait for child process for '{}': {}", label, e);
|
||||
}
|
||||
return None;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(30));
|
||||
}
|
||||
|
||||
match child.wait_with_output() {
|
||||
Ok(output) => {
|
||||
if !output.status.success() {
|
||||
warn!("{} command failed with status: {}", label, output.status);
|
||||
return None;
|
||||
}
|
||||
Some(output)
|
||||
}
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
// There are some limitations with xrandr method:
|
||||
// 1. It only works when XWayland is running.
|
||||
// 2. The distro may not have xrandr installed by default.
|
||||
// 3. xrandr may not report "primary" in its output. eg. openSUSE Leap 15.6 KDE Plasma.
|
||||
fn try_xrandr_primary() -> Option<String> {
|
||||
let output = Command::new("xrandr").output().ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
for line in text.lines() {
|
||||
if line.contains("primary") && line.contains("connected") {
|
||||
if let Some(name) = line.split_whitespace().next() {
|
||||
return Some(name.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn try_kscreen_primary() -> Option<String> {
|
||||
if !hbb_common::platform::linux::is_kde_session() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let output = run_with_timeout(
|
||||
"kscreen-doctor",
|
||||
&["-o"],
|
||||
COMMAND_TIMEOUT,
|
||||
"kscreen-doctor -o",
|
||||
)?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
// Remove ANSI color codes
|
||||
let re_ansi = Regex::new(r"\x1b\[[0-9;]*m").ok()?;
|
||||
let clean_text = re_ansi.replace_all(&text, "");
|
||||
|
||||
// Split the text into blocks, each starting with "Output:".
|
||||
// The first element of the split will be empty, so we skip it.
|
||||
for block in clean_text.split("Output:").skip(1) {
|
||||
// Check if this block describes the primary monitor.
|
||||
if block.contains("priority 1") {
|
||||
// The monitor name is the second piece of text in the block, after the ID.
|
||||
// e.g., " 1 eDP-1 enabled..." -> "eDP-1"
|
||||
if let Some(name) = block.split_whitespace().nth(1) {
|
||||
return Some(name.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn try_gdbus_primary() -> Option<String> {
|
||||
let output = run_with_timeout(
|
||||
"gdbus",
|
||||
&[
|
||||
"call",
|
||||
"--session",
|
||||
"--dest",
|
||||
"org.gnome.Mutter.DisplayConfig",
|
||||
"--object-path",
|
||||
"/org/gnome/Mutter/DisplayConfig",
|
||||
"--method",
|
||||
"org.gnome.Mutter.DisplayConfig.GetCurrentState",
|
||||
],
|
||||
COMMAND_TIMEOUT,
|
||||
"gdbus DisplayConfig.GetCurrentState",
|
||||
)?;
|
||||
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
// Match logical monitor entries with primary=true
|
||||
// Pattern: (x, y, scale, transform, true, [('connector-name', ...), ...], ...)
|
||||
// Use regex to find entries where 5th field is true, then extract connector name
|
||||
// Example matched text: "(0, 0, 1.5, 0, true, [('HDMI-1', 'MHH', 'Monitor', '0x00000000')], ...)"
|
||||
let re = Regex::new(r"\([^()]*,\s*true,\s*\[\('([^']+)'").ok()?;
|
||||
|
||||
if let Some(captures) = re.captures(&text) {
|
||||
return captures.get(1).map(|m| m.as_str().to_string());
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn get_primary_monitor() -> Option<String> {
|
||||
try_xrandr_primary()
|
||||
.or_else(try_kscreen_primary)
|
||||
.or_else(try_gdbus_primary)
|
||||
}
|
||||
|
||||
pub fn get_displays() -> Arc<Displays> {
|
||||
let mut lock = DISPLAYS.lock().unwrap();
|
||||
match lock.as_ref() {
|
||||
Some(displays) => displays.clone(),
|
||||
None => match get_wayland_displays() {
|
||||
Ok(displays) => {
|
||||
let mut primary_index = None;
|
||||
if let Some(name) = get_primary_monitor() {
|
||||
for (i, display) in displays.iter().enumerate() {
|
||||
if display.name == name {
|
||||
primary_index = Some(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
if primary_index.is_none() {
|
||||
for (i, display) in displays.iter().enumerate() {
|
||||
if display.x == 0 && display.y == 0 {
|
||||
primary_index = Some(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let displays = Arc::new(Displays {
|
||||
primary: primary_index.unwrap_or(0),
|
||||
displays,
|
||||
});
|
||||
*lock = Some(displays.clone());
|
||||
displays
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("Failed to get wayland displays: {}", err);
|
||||
Arc::new(Displays {
|
||||
primary: 0,
|
||||
displays: Vec::new(),
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn clear_wayland_displays_cache() {
|
||||
let _ = DISPLAYS.lock().unwrap().take();
|
||||
}
|
||||
|
||||
// Return (min_x, max_x, min_y, max_y)
|
||||
pub fn get_desktop_rect_for_uinput() -> Option<(i32, i32, i32, i32)> {
|
||||
let wayland_displays = get_displays();
|
||||
let displays = &wayland_displays.displays;
|
||||
if displays.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// For compatibility, if only one display, we use the physical size for `uinput`.
|
||||
// Otherwise, we use the logical size for `uinput`.
|
||||
if displays.len() == 1 {
|
||||
let d = &displays[0];
|
||||
return Some((d.x, d.x + d.width, d.y, d.y + d.height));
|
||||
}
|
||||
|
||||
let mut min_x = i32::MAX;
|
||||
let mut min_y = i32::MAX;
|
||||
let mut max_x = i32::MIN;
|
||||
let mut max_y = i32::MIN;
|
||||
for d in displays.iter() {
|
||||
min_x = min_x.min(d.x);
|
||||
min_y = min_y.min(d.y);
|
||||
let size = if let Some(logical_size) = d.logical_size {
|
||||
logical_size
|
||||
} else {
|
||||
// When `logical_size` is None, we cannot obtain the correct desktop rectangle.
|
||||
// This may occur if the Wayland compositor does not provide logical size information,
|
||||
// or if display information is incomplete. We fall back to physical size, which provides
|
||||
// usable dimensions, but may not always be correct depending on compositor behavior.
|
||||
warn!(
|
||||
"Display at ({}, {}) is missing logical_size; falling back to physical size ({}, {}).",
|
||||
d.x, d.y, d.width, d.height
|
||||
);
|
||||
(d.width, d.height)
|
||||
};
|
||||
max_x = max_x.max(d.x + size.0);
|
||||
max_y = max_y.max(d.y + size.1);
|
||||
}
|
||||
Some((min_x, max_x, min_y, max_y))
|
||||
}
|
||||
@@ -2,9 +2,12 @@ use std::collections::HashMap;
|
||||
use std::error::Error;
|
||||
use std::os::unix::io::AsRawFd;
|
||||
use std::process::Command;
|
||||
use std::sync::{atomic::AtomicBool, Arc, Mutex};
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, AtomicU8, Ordering},
|
||||
Arc, Mutex,
|
||||
};
|
||||
use std::time::Duration;
|
||||
use tracing::{debug, trace, warn};
|
||||
use tracing::{debug, error, trace, warn};
|
||||
|
||||
use dbus::{
|
||||
arg::{OwnedFd, PropMap, RefArg, Variant},
|
||||
@@ -17,23 +20,58 @@ use gstreamer as gst;
|
||||
use gstreamer::prelude::*;
|
||||
use gstreamer_app::AppSink;
|
||||
|
||||
use hbb_common::config;
|
||||
use lazy_static::lazy_static;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use hbb_common::{bail, config, platform::linux::CMD_SH, serde_json, tokio, ResultType};
|
||||
|
||||
use super::capturable::PixelProvider;
|
||||
use super::capturable::{Capturable, Recorder};
|
||||
use super::display::{clear_wayland_displays_cache, get_displays, Displays};
|
||||
use super::remote_desktop_portal::OrgFreedesktopPortalRemoteDesktop as remote_desktop_portal;
|
||||
use super::request_portal::OrgFreedesktopPortalRequestResponse;
|
||||
use super::screencast_portal::OrgFreedesktopPortalScreenCast as screencast_portal;
|
||||
use hbb_common::platform::linux::CMD_SH;
|
||||
use lazy_static::lazy_static;
|
||||
|
||||
lazy_static! {
|
||||
pub static ref RDP_SESSION_INFO: Mutex<Option<RdpSessionInfo>> = Mutex::new(None);
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
// For KDE Plasma only, because GNOME provides position info.
|
||||
struct PipewireDisplayOffsetCache {
|
||||
// We need to compare the displays, because:
|
||||
// 1. On Archlinux KDE Plasma
|
||||
// 2. One display, and connect, remember share choice.
|
||||
// 3. Plug in another monitor.
|
||||
// 4. The portal will reuse the restore token, no new share choice dialog, but the share screen is different.
|
||||
// The controlling side will see the new monitor.
|
||||
// All displays as one string for easy comparison
|
||||
// name1-x1-y1-width1-height1;name2-x2-y2-width2-height2;...
|
||||
display_key: String,
|
||||
restore_token: String,
|
||||
offsets: Vec<(i32, i32)>,
|
||||
}
|
||||
|
||||
// KDE Plasma may not provide position info
|
||||
static HAS_POSITION_ATTR: AtomicBool = AtomicBool::new(false);
|
||||
static IS_SERVER_RUNNING: AtomicU8 = AtomicU8::new(0); // 0: uninitialized, 1:true, 2: false
|
||||
|
||||
impl PipewireDisplayOffsetCache {
|
||||
fn displays_to_key(displays: &Arc<Displays>) -> String {
|
||||
displays
|
||||
.displays
|
||||
.iter()
|
||||
.map(|d| format!("{}-{}-{}-{}-{}", d.name, d.x, d.y, d.width, d.height))
|
||||
.collect::<Vec<String>>()
|
||||
.join(";")
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn close_session() {
|
||||
let _ = RDP_SESSION_INFO.lock().unwrap().take();
|
||||
clear_wayland_displays_cache();
|
||||
HAS_POSITION_ATTR.store(false, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
#[inline]
|
||||
@@ -52,6 +90,8 @@ pub fn try_close_session() {
|
||||
}
|
||||
if close {
|
||||
*rdp_info = None;
|
||||
clear_wayland_displays_cache();
|
||||
HAS_POSITION_ATTR.store(false, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +115,10 @@ impl PwStreamInfo {
|
||||
pub fn get_size(&self) -> (usize, usize) {
|
||||
self.size
|
||||
}
|
||||
|
||||
pub fn get_position(&self) -> (i32, i32) {
|
||||
self.position
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -108,8 +152,10 @@ pub struct PipeWireCapturable {
|
||||
fd: OwnedFd,
|
||||
path: u64,
|
||||
source_type: u64,
|
||||
pub primary: bool,
|
||||
pub position: (i32, i32),
|
||||
pub size: (usize, usize),
|
||||
pub logical_size: (usize, usize),
|
||||
pub physical_size: (usize, usize),
|
||||
}
|
||||
|
||||
impl PipeWireCapturable {
|
||||
@@ -117,27 +163,31 @@ impl PipeWireCapturable {
|
||||
conn: Arc<SyncConnection>,
|
||||
fd: OwnedFd,
|
||||
resolution: Arc<Mutex<Option<(usize, usize)>>>,
|
||||
stream: PwStreamInfo,
|
||||
stream: &PwStreamInfo,
|
||||
) -> Self {
|
||||
// alternative to get screen resolution as stream.size is not always correct ex: on fractional scaling
|
||||
// https://github.com/rustdesk/rustdesk/issues/6116#issuecomment-1817724244
|
||||
let size = get_res(Self {
|
||||
let physical_size = get_res(Self {
|
||||
dbus_conn: conn.clone(),
|
||||
fd: fd.clone(),
|
||||
path: stream.path,
|
||||
source_type: stream.source_type,
|
||||
primary: false,
|
||||
position: stream.position,
|
||||
size: stream.size,
|
||||
logical_size: stream.size,
|
||||
physical_size: (0, 0),
|
||||
})
|
||||
.unwrap_or(stream.size);
|
||||
*resolution.lock().unwrap() = Some(size);
|
||||
*resolution.lock().unwrap() = Some(physical_size);
|
||||
Self {
|
||||
dbus_conn: conn,
|
||||
fd,
|
||||
path: stream.path,
|
||||
source_type: stream.source_type,
|
||||
primary: false,
|
||||
position: stream.position,
|
||||
size,
|
||||
logical_size: stream.size,
|
||||
physical_size,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -214,7 +264,7 @@ pub struct PipeWireRecorder {
|
||||
}
|
||||
|
||||
impl PipeWireRecorder {
|
||||
pub fn new(capturable: PipeWireCapturable) -> Result<Self, Box<dyn Error>> {
|
||||
pub fn new(capturable: PipeWireCapturable) -> ResultType<Self> {
|
||||
let pipeline = gst::Pipeline::new(None);
|
||||
|
||||
let src = gst::ElementFactory::make("pipewiresrc", None)?;
|
||||
@@ -247,7 +297,40 @@ impl PipeWireRecorder {
|
||||
));
|
||||
appsink.set_caps(Some(&caps));
|
||||
|
||||
// [Workaround]
|
||||
// Crash may occur if there are multiple pipelines started at the same time.
|
||||
// `pipeline.get_state()` can significantly reduce the probability of crashes,
|
||||
// but cannot completely resolve this issue.
|
||||
// Adding a short sleep period can also reduce the probability of crashes.
|
||||
debug!(
|
||||
"[gstreamer] Setting pipeline {} to PLAYING state...",
|
||||
capturable.fd.as_raw_fd()
|
||||
);
|
||||
pipeline.set_state(gst::State::Playing)?;
|
||||
|
||||
// If `is_server_running()` is false, it means using remote_desktop_portal,
|
||||
// which does not use multiple streams, so no need to wait for state change.
|
||||
if is_server_running() {
|
||||
// Wait for the state change to actually complete before proceeding.
|
||||
// The 2000ms timeout for pipeline state change was chosen based on empirical testing.
|
||||
let state_change = pipeline.get_state(gst::ClockTime::from_mseconds(2000));
|
||||
match state_change {
|
||||
(Ok(_), gst::State::Playing, _) => {
|
||||
debug!(
|
||||
"[gstreamer] Pipeline {} state confirmed as PLAYING.",
|
||||
capturable.fd.as_raw_fd()
|
||||
);
|
||||
}
|
||||
(result, state, pending) => {
|
||||
warn!(
|
||||
"[gstreamer] Pipeline {} state change incomplete: result={:?}, state={:?}, pending={:?}",
|
||||
capturable.fd.as_raw_fd(), result, state, pending
|
||||
);
|
||||
}
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(150));
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
pipeline,
|
||||
appsink,
|
||||
@@ -366,6 +449,8 @@ impl Drop for PipeWireRecorder {
|
||||
if let Err(err) = self.pipeline.set_state(gst::State::Null) {
|
||||
warn!("Failed to stop GStreamer pipeline: {}.", err);
|
||||
}
|
||||
// Wait for state change to complete to avoid races during PipeWire teardown.
|
||||
let _ = self.pipeline.get_state(gst::ClockTime::from_mseconds(2000));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -396,18 +481,18 @@ where
|
||||
0 => {}
|
||||
1 => {
|
||||
warn!("DBus response: User cancelled interaction.");
|
||||
failure_out.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
failure_out.store(true, Ordering::SeqCst);
|
||||
return true;
|
||||
}
|
||||
c => {
|
||||
warn!("DBus response: Unknown error, code: {}.", c);
|
||||
failure_out.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
failure_out.store(true, Ordering::SeqCst);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if let Err(err) = f(r, c, m) {
|
||||
warn!("Error requesting screen capture via dbus: {}", err);
|
||||
failure_out.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
failure_out.store(true, Ordering::SeqCst);
|
||||
}
|
||||
true
|
||||
})
|
||||
@@ -488,6 +573,7 @@ fn streams_from_response(response: OrgFreedesktopPortalRequestResponse) -> Vec<P
|
||||
if v.len() == 2 {
|
||||
info.position.0 = v[0] as _;
|
||||
info.position.1 = v[1] as _;
|
||||
HAS_POSITION_ATTR.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -502,6 +588,7 @@ fn streams_from_response(response: OrgFreedesktopPortalRequestResponse) -> Vec<P
|
||||
static mut INIT: bool = false;
|
||||
const RESTORE_TOKEN: &str = "restore_token";
|
||||
const RESTORE_TOKEN_CONF_KEY: &str = "wayland-restore-token";
|
||||
const PIPEWIRE_DISPLAY_OFFSET_CONF_KEY: &str = "wayland-pipewire-display-offset";
|
||||
|
||||
pub fn get_available_cursor_modes() -> Result<u32, dbus::Error> {
|
||||
let conn = SyncConnection::new_session()?;
|
||||
@@ -510,16 +597,15 @@ pub fn get_available_cursor_modes() -> Result<u32, dbus::Error> {
|
||||
}
|
||||
|
||||
// mostly inspired by https://gitlab.gnome.org/-/snippets/39
|
||||
pub fn request_remote_desktop() -> Result<
|
||||
(
|
||||
SyncConnection,
|
||||
OwnedFd,
|
||||
Vec<PwStreamInfo>,
|
||||
dbus::Path<'static>,
|
||||
bool,
|
||||
),
|
||||
Box<dyn Error>,
|
||||
> {
|
||||
pub fn request_remote_desktop(
|
||||
capture_cursor: bool,
|
||||
) -> ResultType<(
|
||||
SyncConnection,
|
||||
OwnedFd,
|
||||
Vec<PwStreamInfo>,
|
||||
dbus::Path<'static>,
|
||||
bool,
|
||||
)> {
|
||||
unsafe {
|
||||
if !INIT {
|
||||
gstreamer::init()?;
|
||||
@@ -574,6 +660,7 @@ pub fn request_remote_desktop() -> Result<
|
||||
session.clone(),
|
||||
failure.clone(),
|
||||
is_support_restore_token,
|
||||
capture_cursor,
|
||||
),
|
||||
failure_res.clone(),
|
||||
)?;
|
||||
@@ -586,7 +673,7 @@ pub fn request_remote_desktop() -> Result<
|
||||
break;
|
||||
}
|
||||
|
||||
if failure_res.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
if failure_res.load(Ordering::SeqCst) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -607,9 +694,7 @@ pub fn request_remote_desktop() -> Result<
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(Box::new(DBusError(
|
||||
"Failed to obtain screen capture. You may need to upgrade the PipeWire library for better compatibility. Please check https://github.com/rustdesk/rustdesk/issues/8600#issuecomment-2254720954 for more details.".into()
|
||||
)))
|
||||
bail!("Failed to obtain screen capture. You may need to upgrade the PipeWire library for better compatibility. Please check https://github.com/rustdesk/rustdesk/issues/8600#issuecomment-2254720954 for more details.")
|
||||
}
|
||||
|
||||
fn on_create_session_response(
|
||||
@@ -618,6 +703,7 @@ fn on_create_session_response(
|
||||
session: Arc<Mutex<Option<dbus::Path<'static>>>>,
|
||||
failure: Arc<AtomicBool>,
|
||||
is_support_restore_token: bool,
|
||||
capture_cursor: bool,
|
||||
) -> impl Fn(
|
||||
OrgFreedesktopPortalRequestResponse,
|
||||
&SyncConnection,
|
||||
@@ -666,6 +752,14 @@ fn on_create_session_response(
|
||||
}
|
||||
args.insert("types".into(), Variant(Box::new(1u32))); //| 2u32)));
|
||||
|
||||
if capture_cursor {
|
||||
get_available_cursor_modes().ok().map(|modes| {
|
||||
if modes & 0x2 != 0 {
|
||||
args.insert("cursor_mode".to_string(), Variant(Box::new(2u32)));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let path = portal.select_sources(ses.clone(), args)?;
|
||||
handle_response(
|
||||
c,
|
||||
@@ -838,7 +932,7 @@ pub fn get_capturables() -> Result<Vec<PipeWireCapturable>, Box<dyn Error>> {
|
||||
};
|
||||
|
||||
if rdp_connection.is_none() {
|
||||
let (conn, fd, streams, session, is_support_restore_token) = request_remote_desktop()?;
|
||||
let (conn, fd, streams, session, is_support_restore_token) = request_remote_desktop(false)?;
|
||||
let conn = Arc::new(conn);
|
||||
|
||||
let rdp_info = RdpSessionInfo {
|
||||
@@ -852,7 +946,7 @@ pub fn get_capturables() -> Result<Vec<PipeWireCapturable>, Box<dyn Error>> {
|
||||
*rdp_connection = Some(rdp_info);
|
||||
}
|
||||
|
||||
let rdp_info = match rdp_connection.as_ref() {
|
||||
let rdp_info = match rdp_connection.as_mut() {
|
||||
Some(res) => res,
|
||||
None => {
|
||||
return Err(Box::new(DBusError("RDP response is None.".into())));
|
||||
@@ -861,8 +955,7 @@ pub fn get_capturables() -> Result<Vec<PipeWireCapturable>, Box<dyn Error>> {
|
||||
|
||||
Ok(rdp_info
|
||||
.streams
|
||||
.clone()
|
||||
.into_iter()
|
||||
.iter()
|
||||
.map(|s| {
|
||||
PipeWireCapturable::new(
|
||||
rdp_info.conn.clone(),
|
||||
@@ -883,7 +976,12 @@ pub fn get_capturables() -> Result<Vec<PipeWireCapturable>, Box<dyn Error>> {
|
||||
//
|
||||
// `screencast_portal` supports restore_token and persist_mode if the version is greater than or equal to 4.
|
||||
// `remote_desktop_portal` does not support restore_token and persist_mode.
|
||||
fn is_server_running() -> bool {
|
||||
pub(crate) fn is_server_running() -> bool {
|
||||
let v = IS_SERVER_RUNNING.load(Ordering::SeqCst);
|
||||
if v > 0 {
|
||||
return v == 1;
|
||||
}
|
||||
|
||||
let app_name = config::APP_NAME.read().unwrap().clone().to_lowercase();
|
||||
let output = match Command::new(CMD_SH.as_str())
|
||||
.arg("-c")
|
||||
@@ -898,5 +996,533 @@ fn is_server_running() -> bool {
|
||||
|
||||
let output_str = String::from_utf8_lossy(&output.stdout);
|
||||
let is_running = output_str.contains(&format!("{} --server", app_name));
|
||||
IS_SERVER_RUNNING.store(if is_running { 1 } else { 2 }, Ordering::SeqCst);
|
||||
is_running
|
||||
}
|
||||
|
||||
// The logical size reported by portal may be different from the size reported by `get_displays()`.
|
||||
// So we need to use the workaround here.
|
||||
// 1. openSUSE, KDE Plasma
|
||||
// 2. Kubuntu 24.04 TLS, after running `sudo apt install plasma-workspace-wayland`
|
||||
// Maybe it's a bug, and we can remove this workaround in the future.
|
||||
pub fn try_fix_logical_size(shared_displays: &mut Vec<crate::Display>) {
|
||||
if !is_server_running() {
|
||||
return;
|
||||
}
|
||||
|
||||
let wayland_displays = get_displays();
|
||||
if wayland_displays.displays.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
for sd in shared_displays.iter_mut() {
|
||||
if let crate::Display::WAYLAND(d) = sd {
|
||||
let capturable = &mut d.0;
|
||||
for wd in wayland_displays.displays.iter() {
|
||||
if capturable.position.0 == wd.x && capturable.position.1 == wd.y {
|
||||
if let Some(logical_size) = wd.logical_size {
|
||||
if capturable.physical_size.0 != wd.width as usize
|
||||
|| capturable.physical_size.1 != wd.height as usize
|
||||
{
|
||||
// If "Full Workspace" is selected in the portal dialog,
|
||||
// the physical size reported by portal may not match the display info.
|
||||
debug!(
|
||||
"Physical size of capturable ({:?}) does not match display info: ({:?}) - ({:?}). Skipping logical size fix.",
|
||||
capturable.position,
|
||||
capturable.physical_size,
|
||||
(wd.width as usize, wd.height as usize)
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
if capturable.logical_size.0 != logical_size.0 as usize
|
||||
|| capturable.logical_size.1 != logical_size.1 as usize
|
||||
{
|
||||
warn!(
|
||||
"Fixing logical size of capturable from {:?} to {:?} based on display info {:?}.",
|
||||
capturable.logical_size,
|
||||
logical_size,
|
||||
wd
|
||||
);
|
||||
capturable.logical_size =
|
||||
(logical_size.0 as usize, logical_size.1 as usize);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fill_displays(
|
||||
mouse_move_to: impl Fn(i32, i32),
|
||||
get_cursor_pos: fn() -> Option<(i32, i32)>,
|
||||
shared_displays: &mut Vec<crate::Display>,
|
||||
) -> ResultType<()> {
|
||||
if !is_server_running() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut rdp_connection = RDP_SESSION_INFO.lock().unwrap();
|
||||
let rdp_info = match rdp_connection.as_mut() {
|
||||
Some(res) => res,
|
||||
None => {
|
||||
// Unreachable
|
||||
bail!("RDP session info is None when filling display positions.");
|
||||
}
|
||||
};
|
||||
|
||||
let all_displays = get_displays();
|
||||
if !HAS_POSITION_ATTR.load(Ordering::SeqCst) {
|
||||
if all_displays.displays.len() > 1 {
|
||||
debug!("Multiple Wayland displays detected, adjusting stream positions accordingly.");
|
||||
try_fill_positions(
|
||||
mouse_move_to,
|
||||
get_cursor_pos,
|
||||
&all_displays,
|
||||
shared_displays,
|
||||
&mut rdp_info.streams,
|
||||
)?;
|
||||
}
|
||||
HAS_POSITION_ATTR.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
if all_displays.displays.len() > 1 {
|
||||
sort_streams(&all_displays, shared_displays, &mut rdp_info.streams);
|
||||
}
|
||||
|
||||
shared_displays.iter_mut().next().map(|d| {
|
||||
if let crate::Display::WAYLAND(d) = d {
|
||||
d.0.primary = true;
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn try_fill_positions(
|
||||
mouse_move_to: impl Fn(i32, i32),
|
||||
get_cursor_pos: fn() -> Option<(i32, i32)>,
|
||||
displays: &Arc<Displays>,
|
||||
shared_displays: &mut Vec<crate::Display>,
|
||||
streams: &mut Vec<PwStreamInfo>,
|
||||
) -> ResultType<()> {
|
||||
let pipewire_display_offset = config::LocalConfig::get_option(PIPEWIRE_DISPLAY_OFFSET_CONF_KEY);
|
||||
if !pipewire_display_offset.is_empty() {
|
||||
if try_fill_positions_from_cache(
|
||||
pipewire_display_offset,
|
||||
displays,
|
||||
shared_displays,
|
||||
streams,
|
||||
) {
|
||||
return Ok(());
|
||||
}
|
||||
config::LocalConfig::set_option(PIPEWIRE_DISPLAY_OFFSET_CONF_KEY.to_owned(), "".to_owned());
|
||||
}
|
||||
|
||||
let mut multi_matched_indices = Vec::new();
|
||||
for (i, sd) in shared_displays.iter_mut().enumerate() {
|
||||
if let crate::Display::WAYLAND(d) = sd {
|
||||
let capturable = &mut d.0;
|
||||
let mut match_count = 0;
|
||||
for wd in displays.displays.iter() {
|
||||
if capturable.physical_size.0 == wd.width as usize
|
||||
&& capturable.physical_size.1 == wd.height as usize
|
||||
{
|
||||
capturable.position = (wd.x, wd.y);
|
||||
if let Some(pw_stream) = streams.get_mut(i) {
|
||||
pw_stream.position = (wd.x, wd.y);
|
||||
}
|
||||
match_count += 1;
|
||||
}
|
||||
}
|
||||
if match_count == 0 {
|
||||
warn!(
|
||||
"No matching display found for capturable with size {:?}.",
|
||||
capturable.physical_size
|
||||
);
|
||||
} else if match_count > 1 {
|
||||
multi_matched_indices.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !multi_matched_indices.is_empty() {
|
||||
fill_multi_matched_positions(
|
||||
mouse_move_to,
|
||||
get_cursor_pos,
|
||||
displays,
|
||||
shared_displays,
|
||||
streams,
|
||||
multi_matched_indices,
|
||||
)?;
|
||||
}
|
||||
|
||||
save_positions_to_cache(displays, shared_displays);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn try_fill_positions_from_cache(
|
||||
cache_str: String,
|
||||
displays: &Arc<Displays>,
|
||||
shared_displays: &mut Vec<crate::Display>,
|
||||
streams: &mut Vec<PwStreamInfo>,
|
||||
) -> bool {
|
||||
let Ok(cache) = serde_json::from_str::<PipewireDisplayOffsetCache>(&cache_str) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if cache.offsets.len() != shared_displays.len() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let display_key = PipewireDisplayOffsetCache::displays_to_key(displays);
|
||||
if cache.display_key != display_key {
|
||||
return false;
|
||||
}
|
||||
|
||||
let restore_token = config::LocalConfig::get_option(RESTORE_TOKEN_CONF_KEY);
|
||||
if cache.restore_token != restore_token {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (i, sd) in shared_displays.iter_mut().enumerate() {
|
||||
if let crate::Display::WAYLAND(d) = sd {
|
||||
let capturable = &mut d.0;
|
||||
if let Some((x_off, y_off)) = cache.offsets.get(i) {
|
||||
capturable.position = (*x_off, *y_off);
|
||||
if let Some(pw_stream) = streams.get_mut(i) {
|
||||
pw_stream.position = (*x_off, *y_off);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn save_positions_to_cache(displays: &Arc<Displays>, shared_displays: &Vec<crate::Display>) {
|
||||
let restore_token = config::LocalConfig::get_option(RESTORE_TOKEN_CONF_KEY);
|
||||
if restore_token.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut offsets = Vec::new();
|
||||
for sd in shared_displays.iter() {
|
||||
if let crate::Display::WAYLAND(d) = sd {
|
||||
let capturable = &d.0;
|
||||
offsets.push((capturable.position.0, capturable.position.1));
|
||||
}
|
||||
}
|
||||
|
||||
let display_key = PipewireDisplayOffsetCache::displays_to_key(displays);
|
||||
let cache = PipewireDisplayOffsetCache {
|
||||
display_key,
|
||||
restore_token,
|
||||
offsets,
|
||||
};
|
||||
|
||||
if let Ok(s) = serde_json::to_string(&cache) {
|
||||
config::LocalConfig::set_option(PIPEWIRE_DISPLAY_OFFSET_CONF_KEY.to_owned(), s);
|
||||
}
|
||||
}
|
||||
|
||||
fn compare_left_up_corner(w: usize, d1: &[u8], d2: &[u8]) -> bool {
|
||||
if w == 0 {
|
||||
return false;
|
||||
}
|
||||
if d1.len() != d2.len() {
|
||||
return false;
|
||||
}
|
||||
let bpp = 4; // BGR0/RGB0
|
||||
let stride = w.saturating_mul(bpp);
|
||||
if stride == 0 || d1.len() < stride || d2.len() < stride {
|
||||
return false;
|
||||
}
|
||||
let h = d1.len() / stride;
|
||||
if h == 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let roi_w = std::cmp::min(36, w);
|
||||
let roi_h = std::cmp::min(36, h);
|
||||
let mut diff_px = 0usize;
|
||||
let total_px = roi_w * roi_h;
|
||||
// Minimum number of differing pixels required to consider images different.
|
||||
const MIN_DIFF_PIXELS: usize = 8;
|
||||
// Divisor for threshold calculation: allows up to 1/8 of ROI pixels to differ before returning true.
|
||||
const DIFF_THRESHOLD_DIVISOR: usize = 8;
|
||||
let threshold = std::cmp::max(MIN_DIFF_PIXELS, total_px / DIFF_THRESHOLD_DIVISOR);
|
||||
|
||||
for y in 0..roi_h {
|
||||
let row_off = y * stride;
|
||||
for x in 0..roi_w {
|
||||
let i = row_off + x * bpp;
|
||||
let a = &d1[i..i + bpp];
|
||||
let b = &d2[i..i + bpp];
|
||||
if a != b {
|
||||
diff_px += 1;
|
||||
if diff_px >= threshold {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn fill_multi_matched_positions(
|
||||
mouse_move_to: impl Fn(i32, i32),
|
||||
get_cursor_pos: fn() -> Option<(i32, i32)>,
|
||||
displays: &Arc<Displays>,
|
||||
shared_displays: &mut Vec<crate::Display>,
|
||||
streams: &mut Vec<PwStreamInfo>,
|
||||
multi_matched_indices: Vec<usize>,
|
||||
) -> ResultType<()> {
|
||||
debug!(
|
||||
"Multiple capturables ({:?}) match the same display size, attempting to disambiguate positions.",
|
||||
&multi_matched_indices);
|
||||
if multi_matched_indices.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let is_support_embeded_cursor = get_available_cursor_modes()
|
||||
.ok()
|
||||
.map(|modes| modes & 0x2 != 0)
|
||||
.unwrap_or(false);
|
||||
if is_support_embeded_cursor {
|
||||
fill_multi_matched_positions_cursor(
|
||||
mouse_move_to,
|
||||
get_cursor_pos,
|
||||
displays,
|
||||
shared_displays,
|
||||
streams,
|
||||
multi_matched_indices,
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mouse_move_to_(
|
||||
mouse_move_to: &impl Fn(i32, i32),
|
||||
get_cursor_pos: fn() -> Option<(i32, i32)>,
|
||||
x: i32,
|
||||
y: i32,
|
||||
) {
|
||||
const MOVE_MOUSE_TIMEOUT: Duration = Duration::from_millis(150);
|
||||
let start = std::time::Instant::now();
|
||||
while start.elapsed() < MOVE_MOUSE_TIMEOUT {
|
||||
mouse_move_to(x, y);
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
if let Some((x1, y1)) = get_cursor_pos() {
|
||||
if x1 == x && y1 == y {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
warn!(
|
||||
"Failed to move mouse to ({}, {}) within timeout: {:?}.",
|
||||
x, y, &MOVE_MOUSE_TIMEOUT
|
||||
);
|
||||
}
|
||||
|
||||
fn fill_multi_matched_positions_cursor(
|
||||
mouse_move_to: impl Fn(i32, i32),
|
||||
get_cursor_pos: fn() -> Option<(i32, i32)>,
|
||||
displays: &Arc<Displays>,
|
||||
shared_displays: &mut Vec<crate::Display>,
|
||||
streams: &mut Vec<PwStreamInfo>,
|
||||
multi_matched_indices: Vec<usize>,
|
||||
) -> ResultType<()> {
|
||||
// This creates a new remote desktop session for cursor-based position detection.
|
||||
// The session is temporary, used only for disambiguation, and is dropped after detection completes.
|
||||
let (conn, fd, streams_with_cursor, _session, _is_support_restore_token) =
|
||||
request_remote_desktop(true)?;
|
||||
let conn = Arc::new(conn);
|
||||
|
||||
let mut matched_indices = Vec::new();
|
||||
const CAPTURE_TIMEOUT_MS: u64 = 1_000;
|
||||
for idx in multi_matched_indices {
|
||||
match (
|
||||
shared_displays.get_mut(idx),
|
||||
streams.get_mut(idx),
|
||||
streams_with_cursor.get(idx),
|
||||
) {
|
||||
(Some(crate::Display::WAYLAND(d)), Some(pw_stream), Some(pw_stream_with_cursor)) => {
|
||||
// Check if only one display matches the size
|
||||
let mut match_count = 0;
|
||||
for (i, wd) in displays.displays.iter().enumerate() {
|
||||
if matched_indices.contains(&i) {
|
||||
continue;
|
||||
}
|
||||
if d.0.physical_size.0 == wd.width as usize
|
||||
&& d.0.physical_size.1 == wd.height as usize
|
||||
{
|
||||
match_count += 1;
|
||||
}
|
||||
}
|
||||
if match_count == 0 {
|
||||
error!(
|
||||
"No matching display found for capturable with size {:?}.",
|
||||
d.0.physical_size
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if match_count == 1 {
|
||||
for (i, wd) in displays.displays.iter().enumerate() {
|
||||
if matched_indices.contains(&i) {
|
||||
continue;
|
||||
}
|
||||
if d.0.physical_size.0 == wd.width as usize
|
||||
&& d.0.physical_size.1 == wd.height as usize
|
||||
{
|
||||
d.0.position = (wd.x, wd.y);
|
||||
pw_stream.position = (wd.x, wd.y);
|
||||
matched_indices.push(i);
|
||||
debug!(
|
||||
"Disambiguated position for capturable with size {:?} to ({}, {}).",
|
||||
d.0.physical_size, wd.x, wd.y
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Move the mouse to a neutral position first,
|
||||
// to avoid interference from previous position.
|
||||
mouse_move_to_(&mouse_move_to, get_cursor_pos, 300, 300);
|
||||
|
||||
let mut rec = PipeWireRecorder::new(PipeWireCapturable {
|
||||
dbus_conn: conn.clone(),
|
||||
fd: fd.clone(),
|
||||
path: pw_stream_with_cursor.path,
|
||||
source_type: pw_stream_with_cursor.source_type,
|
||||
primary: false,
|
||||
position: pw_stream_with_cursor.position,
|
||||
logical_size: pw_stream_with_cursor.size,
|
||||
physical_size: (0, 0),
|
||||
})?;
|
||||
// Take first frame and copy owned buffer to avoid borrow across second capture
|
||||
let (is_bgr, w, first_buf): (bool, usize, Vec<u8>) =
|
||||
match rec.capture(CAPTURE_TIMEOUT_MS) {
|
||||
Ok(PixelProvider::BGR0(w, _, data1)) => (true, w, data1.to_vec()),
|
||||
Ok(PixelProvider::RGB0(w, _, data1)) => (false, w, data1.to_vec()),
|
||||
Ok(_) => {
|
||||
error!("Unexpected pixel format on first capture.");
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
"Failed to capture screen for position disambiguation: {}",
|
||||
e
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let matched_len = matched_indices.len();
|
||||
for (i, wd) in displays.displays.iter().enumerate() {
|
||||
if matched_indices.contains(&i) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if wd.width as usize == d.0.physical_size.0
|
||||
&& wd.height as usize == d.0.physical_size.1
|
||||
{
|
||||
mouse_move_to_(&mouse_move_to, get_cursor_pos, wd.x + 8, wd.y + 8);
|
||||
rec.saved_raw_data.clear();
|
||||
match rec.capture(CAPTURE_TIMEOUT_MS) {
|
||||
Ok(PixelProvider::BGR0(_, _, data2)) if is_bgr => {
|
||||
if compare_left_up_corner(w, &first_buf, data2) {
|
||||
d.0.position = (wd.x, wd.y);
|
||||
pw_stream.position = (wd.x, wd.y);
|
||||
matched_indices.push(i);
|
||||
debug!(
|
||||
"Disambiguated position for capturable with size {:?} to ({}, {}).",
|
||||
d.0.physical_size, wd.x, wd.y
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(PixelProvider::RGB0(_, _, data2)) if !is_bgr => {
|
||||
if compare_left_up_corner(w, &first_buf, data2) {
|
||||
d.0.position = (wd.x, wd.y);
|
||||
pw_stream.position = (wd.x, wd.y);
|
||||
matched_indices.push(i);
|
||||
debug!(
|
||||
"Disambiguated position for capturable with size {:?} to ({}, {}).",
|
||||
d.0.physical_size, wd.x, wd.y
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(_) => {
|
||||
// unreachable
|
||||
error!("Pixel format changed between captures, cannot disambiguate position.");
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
"Failed to capture screen for position disambiguation: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if matched_len == matched_indices.len() {
|
||||
error!(
|
||||
"Failed to disambiguate position for capturable with size {:?}.",
|
||||
d.0.physical_size
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sort_streams(
|
||||
displays: &Arc<Displays>,
|
||||
shared_displays: &mut Vec<crate::Display>,
|
||||
streams: &mut Vec<PwStreamInfo>,
|
||||
) {
|
||||
if streams.is_empty() {
|
||||
// unreachable
|
||||
error!("No streams available to sort.");
|
||||
return;
|
||||
}
|
||||
|
||||
// put the main display first, then the rest by the order of displays
|
||||
let mut display_order: Vec<(i32, i32)> = Vec::new();
|
||||
if let Some(d) = displays.displays.get(displays.primary) {
|
||||
display_order.push((d.x, d.y));
|
||||
}
|
||||
for (i, d) in displays.displays.iter().enumerate() {
|
||||
if i != displays.primary {
|
||||
display_order.push((d.x, d.y));
|
||||
}
|
||||
}
|
||||
|
||||
let mut sorted_streams = Vec::new();
|
||||
let mut sorted_shared_displays = Vec::new();
|
||||
// Move matching items in order without cloning
|
||||
for (x, y) in display_order.into_iter() {
|
||||
for i in 0..streams.len() {
|
||||
if streams[i].position.0 == x && streams[i].position.1 == y {
|
||||
sorted_streams.push(streams.remove(i));
|
||||
// shared_displays.len() must be equal to streams.len()
|
||||
// But we still check the length to avoid panic
|
||||
if shared_displays.len() > i {
|
||||
sorted_shared_displays.push(shared_displays.remove(i));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
*streams = sorted_streams;
|
||||
*shared_displays = sorted_shared_displays;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
pkgname=rustdesk
|
||||
pkgver=1.4.3
|
||||
pkgver=1.4.4
|
||||
pkgrel=0
|
||||
epoch=
|
||||
pkgdesc=""
|
||||
|
||||
46
res/ab.py
46
res/ab.py
@@ -39,7 +39,14 @@ def view_shared_abs(url, token, name=None):
|
||||
while True:
|
||||
filtered_params["current"] = current
|
||||
response = requests.get(f"{url}/api/ab/shared/profiles", headers=headers, params=filtered_params)
|
||||
if response.status_code != 200:
|
||||
print(f"Error: HTTP {response.status_code} - {response.text}")
|
||||
exit(1)
|
||||
|
||||
response_json = response.json()
|
||||
if "error" in response_json:
|
||||
print(f"Error: {response_json['error']}")
|
||||
exit(1)
|
||||
|
||||
data = response_json.get("data", [])
|
||||
abs.extend(data)
|
||||
@@ -84,7 +91,14 @@ def view_ab_peers(url, token, ab_guid, peer_id=None, alias=None):
|
||||
while True:
|
||||
filtered_params["current"] = current
|
||||
response = requests.get(f"{url}/api/ab/peers", headers=headers, params=filtered_params)
|
||||
if response.status_code != 200:
|
||||
print(f"Error: HTTP {response.status_code} - {response.text}")
|
||||
exit(1)
|
||||
|
||||
response_json = response.json()
|
||||
if "error" in response_json:
|
||||
print(f"Error: {response_json['error']}")
|
||||
exit(1)
|
||||
|
||||
data = response_json.get("data", [])
|
||||
peers.extend(data)
|
||||
@@ -103,11 +117,6 @@ def view_ab_tags(url, token, ab_guid):
|
||||
response = requests.get(f"{url}/api/ab/tags/{ab_guid}", headers=headers)
|
||||
response_json = check_response(response)
|
||||
|
||||
# Handle error responses
|
||||
if isinstance(response_json, tuple) and response_json[0] == "Failed":
|
||||
print(f"Error: {response_json[1]} - {response_json[2]}")
|
||||
return []
|
||||
|
||||
# Format color values as hex
|
||||
if response_json:
|
||||
for tag in response_json:
|
||||
@@ -122,14 +131,18 @@ def view_ab_tags(url, token, ab_guid):
|
||||
|
||||
def check_response(response):
|
||||
"""Check API response and return result"""
|
||||
if response.status_code == 200:
|
||||
try:
|
||||
response_json = response.json()
|
||||
return response_json
|
||||
except ValueError:
|
||||
return response.text or "Success"
|
||||
else:
|
||||
return "Failed", response.status_code, response.text
|
||||
if response.status_code != 200:
|
||||
print(f"Error: HTTP {response.status_code} - {response.text}")
|
||||
exit(1)
|
||||
|
||||
try:
|
||||
response_json = response.json()
|
||||
if "error" in response_json:
|
||||
print(f"Error: {response_json['error']}")
|
||||
exit(1)
|
||||
return response_json
|
||||
except ValueError:
|
||||
return response.text or "Success"
|
||||
|
||||
|
||||
def add_peer(url, token, ab_guid, peer_id, alias=None, note=None, tags=None, password=None):
|
||||
@@ -395,7 +408,14 @@ def view_ab_rules(url, token, ab_guid):
|
||||
while True:
|
||||
params["current"] = current
|
||||
response = requests.get(f"{url}/api/ab/rules", headers=headers, params=params)
|
||||
if response.status_code != 200:
|
||||
print(f"Error: HTTP {response.status_code} - {response.text}")
|
||||
exit(1)
|
||||
|
||||
response_json = response.json()
|
||||
if "error" in response_json:
|
||||
print(f"Error: {response_json['error']}")
|
||||
exit(1)
|
||||
|
||||
data = response_json.get("data", [])
|
||||
rules.extend(data)
|
||||
|
||||
@@ -149,14 +149,18 @@ def enhance_audit_data(data, audit_type):
|
||||
|
||||
def check_response(response):
|
||||
"""Check API response and return result"""
|
||||
if response.status_code == 200:
|
||||
try:
|
||||
response_json = response.json()
|
||||
return response_json
|
||||
except ValueError:
|
||||
return response.text or "Success"
|
||||
else:
|
||||
return "Failed", response.status_code, response.text
|
||||
if response.status_code != 200:
|
||||
print(f"Error: HTTP {response.status_code} - {response.text}")
|
||||
exit(1)
|
||||
|
||||
try:
|
||||
response_json = response.json()
|
||||
if "error" in response_json:
|
||||
print(f"Error: {response_json['error']}")
|
||||
exit(1)
|
||||
return response_json
|
||||
except ValueError:
|
||||
return response.text or "Success"
|
||||
|
||||
|
||||
def view_audits_common(url, token, endpoint, filters=None, page_size=None, current=None,
|
||||
@@ -216,7 +220,7 @@ def view_audits_common(url, token, endpoint, filters=None, page_size=None, curre
|
||||
string_params[k] = v
|
||||
|
||||
response = requests.get(f"{url}/api/audits/{endpoint}", headers=headers, params=string_params)
|
||||
response_json = response.json()
|
||||
response_json = check_response(response)
|
||||
|
||||
# Enhance the data with readable formats
|
||||
data = enhance_audit_data(response_json.get("data", []), endpoint)
|
||||
|
||||
274
res/device_group.py
Executable file
274
res/device_group.py
Executable file
@@ -0,0 +1,274 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import requests
|
||||
import argparse
|
||||
import json
|
||||
|
||||
|
||||
def check_response(response):
|
||||
"""
|
||||
Check API response and handle errors.
|
||||
|
||||
Two error cases:
|
||||
1. Status code is not 200 -> exit with error
|
||||
2. Response contains {"error": "xxx"} -> exit with error
|
||||
"""
|
||||
if response.status_code != 200:
|
||||
print(f"Error: HTTP {response.status_code}: {response.text}")
|
||||
exit(1)
|
||||
|
||||
# Check for {"error": "xxx"} in response
|
||||
if response.text and response.text.strip():
|
||||
try:
|
||||
json_data = response.json()
|
||||
if isinstance(json_data, dict) and "error" in json_data:
|
||||
print(f"Error: {json_data['error']}")
|
||||
exit(1)
|
||||
return json_data
|
||||
except ValueError:
|
||||
return response.text
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def headers_with(token):
|
||||
return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
||||
|
||||
|
||||
# ---------- Device Group APIs ----------
|
||||
|
||||
def list_groups(url, token, name=None, page_size=50):
|
||||
headers = headers_with(token)
|
||||
params = {"pageSize": page_size}
|
||||
if name:
|
||||
params["name"] = name
|
||||
data, current = [], 1
|
||||
while True:
|
||||
params["current"] = current
|
||||
r = requests.get(f"{url}/api/device-groups", headers=headers, params=params)
|
||||
if r.status_code != 200:
|
||||
print(f"Error: HTTP {r.status_code} - {r.text}")
|
||||
exit(1)
|
||||
res = r.json()
|
||||
if "error" in res:
|
||||
print(f"Error: {res['error']}")
|
||||
exit(1)
|
||||
rows = res.get("data", [])
|
||||
data.extend(rows)
|
||||
total = res.get("total", 0)
|
||||
current += page_size
|
||||
if len(rows) < page_size or current > total:
|
||||
break
|
||||
return data
|
||||
|
||||
|
||||
def get_group_by_name(url, token, name):
|
||||
groups = list_groups(url, token, name)
|
||||
for g in groups:
|
||||
if str(g.get("name")) == name:
|
||||
return g
|
||||
return None
|
||||
|
||||
|
||||
def create_group(url, token, name, note=None, accessed_from=None):
|
||||
headers = headers_with(token)
|
||||
payload = {"name": name}
|
||||
if note:
|
||||
payload["note"] = note
|
||||
if accessed_from:
|
||||
payload["allowed_incomings"] = accessed_from
|
||||
r = requests.post(f"{url}/api/device-groups", headers=headers, json=payload)
|
||||
return check_response(r)
|
||||
|
||||
|
||||
def update_group(url, token, name, new_name=None, note=None, accessed_from=None):
|
||||
headers = headers_with(token)
|
||||
g = get_group_by_name(url, token, name)
|
||||
if not g:
|
||||
print(f"Error: Group '{name}' not found")
|
||||
exit(1)
|
||||
guid = g.get("guid")
|
||||
payload = {}
|
||||
if new_name is not None:
|
||||
payload["name"] = new_name
|
||||
if note is not None:
|
||||
payload["note"] = note
|
||||
if accessed_from is not None:
|
||||
payload["allowed_incomings"] = accessed_from
|
||||
r = requests.patch(f"{url}/api/device-groups/{guid}", headers=headers, json=payload)
|
||||
check_response(r)
|
||||
return "Success"
|
||||
|
||||
|
||||
def delete_groups(url, token, names):
|
||||
headers = headers_with(token)
|
||||
if isinstance(names, str):
|
||||
names = [names]
|
||||
for n in names:
|
||||
g = get_group_by_name(url, token, n)
|
||||
if not g:
|
||||
print(f"Error: Group '{n}' not found")
|
||||
exit(1)
|
||||
guid = g.get("guid")
|
||||
r = requests.delete(f"{url}/api/device-groups/{guid}", headers=headers)
|
||||
check_response(r)
|
||||
return "Success"
|
||||
|
||||
|
||||
# ---------- Device group assign APIs (name -> guid) ----------
|
||||
|
||||
def view_devices(url, token, group_name=None, id=None, device_name=None,
|
||||
user_name=None, device_username=None, page_size=50):
|
||||
"""View devices in a device group with filters"""
|
||||
headers = headers_with(token)
|
||||
|
||||
# Separate exact match and fuzzy match params
|
||||
params = {}
|
||||
fuzzy_params = {
|
||||
"id": id,
|
||||
"device_name": device_name,
|
||||
"user_name": user_name,
|
||||
"device_username": device_username,
|
||||
}
|
||||
|
||||
# Add device_group_name without wildcard (exact match)
|
||||
if group_name:
|
||||
params["device_group_name"] = group_name
|
||||
|
||||
# Add wildcard for fuzzy search to other params
|
||||
for k, v in fuzzy_params.items():
|
||||
if v is not None:
|
||||
params[k] = "%" + v + "%" if (v != "-" and "%" not in v) else v
|
||||
|
||||
params["pageSize"] = page_size
|
||||
|
||||
data, current = [], 1
|
||||
while True:
|
||||
params["current"] = current
|
||||
r = requests.get(f"{url}/api/devices", headers=headers, params=params)
|
||||
if r.status_code != 200:
|
||||
return check_response(r)
|
||||
res = r.json()
|
||||
rows = res.get("data", [])
|
||||
data.extend(rows)
|
||||
total = res.get("total", 0)
|
||||
current += page_size
|
||||
if len(rows) < page_size or current > total:
|
||||
break
|
||||
return data
|
||||
|
||||
|
||||
def add_devices(url, token, group_name, device_ids):
|
||||
headers = headers_with(token)
|
||||
g = get_group_by_name(url, token, group_name)
|
||||
if not g:
|
||||
return f"Group '{group_name}' not found"
|
||||
guid = g.get("guid")
|
||||
payload = device_ids if isinstance(device_ids, list) else [device_ids]
|
||||
r = requests.post(f"{url}/api/device-groups/{guid}", headers=headers, json=payload)
|
||||
return check_response(r)
|
||||
|
||||
|
||||
def remove_devices(url, token, group_name, device_ids):
|
||||
headers = headers_with(token)
|
||||
g = get_group_by_name(url, token, group_name)
|
||||
if not g:
|
||||
return f"Group '{group_name}' not found"
|
||||
guid = g.get("guid")
|
||||
payload = device_ids if isinstance(device_ids, list) else [device_ids]
|
||||
r = requests.delete(f"{url}/api/device-groups/{guid}/devices", headers=headers, json=payload)
|
||||
return check_response(r)
|
||||
|
||||
|
||||
def parse_rules(s):
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
v = json.loads(s)
|
||||
if isinstance(v, list):
|
||||
# expect list of {"type": number, "name": string}
|
||||
return v
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Device Group manager")
|
||||
parser.add_argument("command", choices=[
|
||||
"view", "add", "update", "delete",
|
||||
"view-devices", "add-devices", "remove-devices"
|
||||
], help=(
|
||||
"Command to execute. "
|
||||
"[view/add/update/delete/add-devices/remove-devices: require Device Group Permission] "
|
||||
"[view-devices: require Device Permission]"
|
||||
))
|
||||
parser.add_argument("--url", required=True)
|
||||
parser.add_argument("--token", required=True)
|
||||
|
||||
parser.add_argument("--name", help="Device group name (exact match)")
|
||||
parser.add_argument("--new-name", help="New device group name (for update)")
|
||||
parser.add_argument("--note", help="Note")
|
||||
|
||||
parser.add_argument("--accessed-from", help="JSON array: '[{\"type\":0|2,\"name\":\"...\"}]' (0=User Group, 2=User)")
|
||||
|
||||
parser.add_argument("--ids", help="Comma separated device IDs for add-devices/remove-devices")
|
||||
|
||||
# Filters for view-devices command
|
||||
parser.add_argument("--id", help="Device ID filter (for view-devices)")
|
||||
parser.add_argument("--device-name", help="Device name filter (for view-devices)")
|
||||
parser.add_argument("--user-name", help="User name filter (owner of device, for view-devices)")
|
||||
parser.add_argument("--device-username", help="Device username filter (logged in user on device, for view-devices)")
|
||||
|
||||
args = parser.parse_args()
|
||||
while args.url.endswith("/"): args.url = args.url[:-1]
|
||||
|
||||
if args.command == "view":
|
||||
res = list_groups(args.url, args.token, args.name)
|
||||
print(json.dumps(res, indent=2))
|
||||
elif args.command == "add":
|
||||
if not args.name:
|
||||
print("Error: --name is required")
|
||||
exit(1)
|
||||
print(create_group(
|
||||
args.url, args.token, args.name, args.note,
|
||||
parse_rules(args.accessed_from)
|
||||
))
|
||||
elif args.command == "update":
|
||||
if not args.name:
|
||||
print("Error: --name is required")
|
||||
exit(1)
|
||||
print(update_group(
|
||||
args.url, args.token, args.name, args.new_name, args.note,
|
||||
parse_rules(args.accessed_from)
|
||||
))
|
||||
elif args.command == "delete":
|
||||
if not args.name:
|
||||
print("Error: --name is required (supports comma separated)")
|
||||
exit(1)
|
||||
names = [x.strip() for x in args.name.split(",") if x.strip()]
|
||||
print(delete_groups(args.url, args.token, names))
|
||||
elif args.command == "view-devices":
|
||||
res = view_devices(
|
||||
args.url,
|
||||
args.token,
|
||||
group_name=args.name,
|
||||
id=args.id,
|
||||
device_name=args.device_name,
|
||||
user_name=args.user_name,
|
||||
device_username=args.device_username
|
||||
)
|
||||
print(json.dumps(res, indent=2))
|
||||
elif args.command in ("add-devices", "remove-devices"):
|
||||
if not args.name or not args.ids:
|
||||
print("Error: --name and --ids are required for add/remove devices")
|
||||
exit(1)
|
||||
ids = [x.strip() for x in args.ids.split(",") if x.strip()]
|
||||
if args.command == "add-devices":
|
||||
print(add_devices(args.url, args.token, args.name, ids))
|
||||
else:
|
||||
print(remove_devices(args.url, args.token, args.name, ids))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -39,7 +39,14 @@ def view(
|
||||
while True:
|
||||
params["current"] = current
|
||||
response = requests.get(f"{url}/api/devices", headers=headers, params=params)
|
||||
if response.status_code != 200:
|
||||
print(f"Error: HTTP {response.status_code} - {response.text}")
|
||||
exit(1)
|
||||
|
||||
response_json = response.json()
|
||||
if "error" in response_json:
|
||||
print(f"Error: {response_json['error']}")
|
||||
exit(1)
|
||||
|
||||
data = response_json.get("data", [])
|
||||
|
||||
@@ -62,14 +69,18 @@ def view(
|
||||
|
||||
|
||||
def check(response):
|
||||
if response.status_code == 200:
|
||||
try:
|
||||
response_json = response.json()
|
||||
return response_json
|
||||
except ValueError:
|
||||
return response.text or "Success"
|
||||
else:
|
||||
return "Failed", response.status_code, response.text
|
||||
if response.status_code != 200:
|
||||
print(f"Error: HTTP {response.status_code} - {response.text}")
|
||||
exit(1)
|
||||
|
||||
try:
|
||||
response_json = response.json()
|
||||
if "error" in response_json:
|
||||
print(f"Error: {response_json['error']}")
|
||||
exit(1)
|
||||
return response_json
|
||||
except ValueError:
|
||||
return response.text or "Success"
|
||||
|
||||
|
||||
def disable(url, token, guid, id):
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
Name: rustdesk
|
||||
Version: 1.4.3
|
||||
Version: 1.4.4
|
||||
Release: 0
|
||||
Summary: RPM package
|
||||
License: GPL-3.0
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
Name: rustdesk
|
||||
Version: 1.4.3
|
||||
Version: 1.4.4
|
||||
Release: 0
|
||||
Summary: RPM package
|
||||
License: GPL-3.0
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
Name: rustdesk
|
||||
Version: 1.4.3
|
||||
Version: 1.4.4
|
||||
Release: 0
|
||||
Summary: RPM package
|
||||
License: GPL-3.0
|
||||
|
||||
301
res/strategies.py
Executable file
301
res/strategies.py
Executable file
@@ -0,0 +1,301 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import requests
|
||||
import argparse
|
||||
import json
|
||||
|
||||
|
||||
def check_response(response):
|
||||
"""
|
||||
Check API response and handle errors.
|
||||
|
||||
Two error cases:
|
||||
1. Status code is not 200 -> exit with error
|
||||
2. Response contains {"error": "xxx"} -> exit with error
|
||||
"""
|
||||
if response.status_code != 200:
|
||||
print(f"Error: HTTP {response.status_code}: {response.text}")
|
||||
exit(1)
|
||||
|
||||
# Check for {"error": "xxx"} in response
|
||||
if response.text and response.text.strip():
|
||||
try:
|
||||
json_data = response.json()
|
||||
if isinstance(json_data, dict) and "error" in json_data:
|
||||
print(f"Error: {json_data['error']}")
|
||||
exit(1)
|
||||
return json_data
|
||||
except ValueError:
|
||||
return response.text
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def headers_with(token):
|
||||
return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
||||
|
||||
|
||||
# ---------- Strategies APIs ----------
|
||||
|
||||
def list_strategies(url, token):
|
||||
"""List all strategies"""
|
||||
headers = headers_with(token)
|
||||
r = requests.get(f"{url}/api/strategies", headers=headers)
|
||||
return check_response(r)
|
||||
|
||||
|
||||
def get_strategy_by_guid(url, token, guid):
|
||||
"""Get strategy by GUID"""
|
||||
headers = headers_with(token)
|
||||
r = requests.get(f"{url}/api/strategies/{guid}", headers=headers)
|
||||
return check_response(r)
|
||||
|
||||
|
||||
def get_strategy_by_name(url, token, name):
|
||||
"""Get strategy by name"""
|
||||
strategies = list_strategies(url, token)
|
||||
if not strategies:
|
||||
return None
|
||||
for s in strategies:
|
||||
if str(s.get("name")) == name:
|
||||
return s
|
||||
return None
|
||||
|
||||
|
||||
def enable_strategy(url, token, name):
|
||||
"""Enable a strategy"""
|
||||
headers = headers_with(token)
|
||||
strategy = get_strategy_by_name(url, token, name)
|
||||
if not strategy:
|
||||
print(f"Error: Strategy '{name}' not found")
|
||||
exit(1)
|
||||
guid = strategy.get("guid")
|
||||
r = requests.put(f"{url}/api/strategies/{guid}/status", headers=headers, json=True)
|
||||
check_response(r)
|
||||
return "Success"
|
||||
|
||||
|
||||
def disable_strategy(url, token, name):
|
||||
"""Disable a strategy"""
|
||||
headers = headers_with(token)
|
||||
strategy = get_strategy_by_name(url, token, name)
|
||||
if not strategy:
|
||||
print(f"Error: Strategy '{name}' not found")
|
||||
exit(1)
|
||||
guid = strategy.get("guid")
|
||||
r = requests.put(f"{url}/api/strategies/{guid}/status", headers=headers, json=False)
|
||||
check_response(r)
|
||||
return "Success"
|
||||
|
||||
|
||||
def get_device_guid_by_id(url, token, device_id):
|
||||
"""Get device GUID by device ID (exact match)"""
|
||||
headers = headers_with(token)
|
||||
params = {"id": device_id, "pageSize": 50}
|
||||
r = requests.get(f"{url}/api/devices", headers=headers, params=params)
|
||||
res = check_response(r)
|
||||
if not res:
|
||||
return None
|
||||
|
||||
devices_data = res.get("data", []) if isinstance(res, dict) else res
|
||||
for d in devices_data:
|
||||
if d.get("id") == device_id:
|
||||
return d.get("guid")
|
||||
return None
|
||||
|
||||
|
||||
def get_user_guid_by_name(url, token, name):
|
||||
"""Get user GUID by exact name match"""
|
||||
headers = headers_with(token)
|
||||
params = {"name": name, "pageSize": 50}
|
||||
r = requests.get(f"{url}/api/users", headers=headers, params=params)
|
||||
res = check_response(r)
|
||||
if not res:
|
||||
return None
|
||||
|
||||
users_data = res.get("data", []) if isinstance(res, dict) else res
|
||||
for u in users_data:
|
||||
if u.get("name") == name:
|
||||
return u.get("guid")
|
||||
return None
|
||||
|
||||
|
||||
def get_device_group_guid_by_name(url, token, name):
|
||||
"""Get device group GUID by exact name match"""
|
||||
headers = headers_with(token)
|
||||
params = {"pageSize": 50, "name": name}
|
||||
r = requests.get(f"{url}/api/device-groups", headers=headers, params=params)
|
||||
res = check_response(r)
|
||||
if not res:
|
||||
return None
|
||||
|
||||
groups_data = res.get("data", []) if isinstance(res, dict) else res
|
||||
for g in groups_data:
|
||||
if g.get("name") == name:
|
||||
return g.get("guid")
|
||||
return None
|
||||
|
||||
|
||||
def assign_strategy(url, token, strategy_name, peers=None, users=None, device_groups=None):
|
||||
"""
|
||||
Assign strategy to peers, users, or device groups
|
||||
|
||||
Args:
|
||||
strategy_name: Name of the strategy (or None to unassign)
|
||||
peers: List of device IDs or GUIDs
|
||||
users: List of user names or GUIDs
|
||||
device_groups: List of device group names or GUIDs
|
||||
"""
|
||||
headers = headers_with(token)
|
||||
|
||||
# Get strategy GUID if strategy_name is provided
|
||||
strategy_guid = None
|
||||
if strategy_name:
|
||||
strategy = get_strategy_by_name(url, token, strategy_name)
|
||||
if not strategy:
|
||||
print(f"Error: Strategy '{strategy_name}' not found")
|
||||
exit(1)
|
||||
strategy_guid = strategy.get("guid")
|
||||
|
||||
# Convert device IDs to GUIDs
|
||||
peer_guids = []
|
||||
if peers:
|
||||
for peer in peers:
|
||||
# Check if it's already a GUID format
|
||||
if len(peer) == 36 and peer.count('-') == 4:
|
||||
peer_guids.append(peer)
|
||||
else:
|
||||
# Treat as device ID, look it up
|
||||
guid = get_device_guid_by_id(url, token, peer)
|
||||
if not guid:
|
||||
print(f"Error: Device '{peer}' not found")
|
||||
exit(1)
|
||||
peer_guids.append(guid)
|
||||
|
||||
# Convert user names to GUIDs
|
||||
user_guids = []
|
||||
if users:
|
||||
for user in users:
|
||||
# Check if it's already a GUID format
|
||||
if len(user) == 36 and user.count('-') == 4:
|
||||
user_guids.append(user)
|
||||
else:
|
||||
# Treat as username, look it up
|
||||
guid = get_user_guid_by_name(url, token, user)
|
||||
if not guid:
|
||||
print(f"Error: User '{user}' not found")
|
||||
exit(1)
|
||||
user_guids.append(guid)
|
||||
|
||||
# Convert device group names to GUIDs
|
||||
device_group_guids = []
|
||||
if device_groups:
|
||||
for dg in device_groups:
|
||||
# Check if it's already a GUID format
|
||||
if len(dg) == 36 and dg.count('-') == 4:
|
||||
device_group_guids.append(dg)
|
||||
else:
|
||||
# Treat as device group name, look it up
|
||||
guid = get_device_group_guid_by_name(url, token, dg)
|
||||
if not guid:
|
||||
print(f"Error: Device group '{dg}' not found")
|
||||
exit(1)
|
||||
device_group_guids.append(guid)
|
||||
|
||||
# Build payload
|
||||
payload = {}
|
||||
if strategy_guid:
|
||||
payload["strategy"] = strategy_guid
|
||||
|
||||
payload["peers"] = peer_guids
|
||||
payload["users"] = user_guids
|
||||
payload["groups"] = device_group_guids
|
||||
|
||||
r = requests.post(f"{url}/api/strategies/assign", headers=headers, json=payload)
|
||||
check_response(r)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Strategy manager")
|
||||
parser.add_argument("command", choices=[
|
||||
"list", "view", "enable", "disable", "assign", "unassign"
|
||||
])
|
||||
parser.add_argument("--url", required=True, help="Server URL")
|
||||
parser.add_argument("--token", required=True, help="API token")
|
||||
|
||||
parser.add_argument("--name", help="Strategy name (for view/enable/disable/assign commands)")
|
||||
parser.add_argument("--guid", help="Strategy GUID (for view command, alternative to --name)")
|
||||
|
||||
# For assign/unassign commands
|
||||
parser.add_argument("--peers", help="Comma separated device IDs or GUIDs (requires Device Permission:r)")
|
||||
parser.add_argument("--users", help="Comma separated user names or GUIDs (requires User Permission:r)")
|
||||
parser.add_argument("--device-groups", help="Comma separated device group names or GUIDs (requires Device Group Permission:r)")
|
||||
|
||||
args = parser.parse_args()
|
||||
while args.url.endswith("/"): args.url = args.url[:-1]
|
||||
|
||||
if args.command == "list":
|
||||
res = list_strategies(args.url, args.token)
|
||||
print(json.dumps(res, indent=2))
|
||||
|
||||
elif args.command == "view":
|
||||
if args.guid:
|
||||
res = get_strategy_by_guid(args.url, args.token, args.guid)
|
||||
print(json.dumps(res, indent=2))
|
||||
elif args.name:
|
||||
strategy = get_strategy_by_name(args.url, args.token, args.name)
|
||||
if not strategy:
|
||||
print(f"Error: Strategy '{args.name}' not found")
|
||||
exit(1)
|
||||
# Get full details by GUID
|
||||
guid = strategy.get("guid")
|
||||
res = get_strategy_by_guid(args.url, args.token, guid)
|
||||
print(json.dumps(res, indent=2))
|
||||
else:
|
||||
print("Error: --name or --guid is required for view command")
|
||||
exit(1)
|
||||
|
||||
elif args.command == "enable":
|
||||
if not args.name:
|
||||
print("Error: --name is required")
|
||||
exit(1)
|
||||
print(enable_strategy(args.url, args.token, args.name))
|
||||
|
||||
elif args.command == "disable":
|
||||
if not args.name:
|
||||
print("Error: --name is required")
|
||||
exit(1)
|
||||
print(disable_strategy(args.url, args.token, args.name))
|
||||
|
||||
elif args.command == "assign":
|
||||
if not args.name:
|
||||
print("Error: --name is required")
|
||||
exit(1)
|
||||
if not args.peers and not args.users and not args.device_groups:
|
||||
print("Error: at least one of --peers, --users, or --device-groups is required")
|
||||
exit(1)
|
||||
|
||||
peers = [x.strip() for x in args.peers.split(",") if x.strip()] if args.peers else None
|
||||
users = [x.strip() for x in args.users.split(",") if x.strip()] if args.users else None
|
||||
device_groups = [x.strip() for x in args.device_groups.split(",") if x.strip()] if args.device_groups else None
|
||||
|
||||
assign_strategy(args.url, args.token, args.name, peers=peers, users=users, device_groups=device_groups)
|
||||
count = (len(peers) if peers else 0) + (len(users) if users else 0) + (len(device_groups) if device_groups else 0)
|
||||
print(f"Success: Assigned strategy '{args.name}' to {count} target(s)")
|
||||
|
||||
elif args.command == "unassign":
|
||||
if not args.peers and not args.users and not args.device_groups:
|
||||
print("Error: at least one of --peers, --users, or --device-groups is required")
|
||||
exit(1)
|
||||
|
||||
peers = [x.strip() for x in args.peers.split(",") if x.strip()] if args.peers else None
|
||||
users = [x.strip() for x in args.users.split(",") if x.strip()] if args.users else None
|
||||
device_groups = [x.strip() for x in args.device_groups.split(",") if x.strip()] if args.device_groups else None
|
||||
|
||||
assign_strategy(args.url, args.token, None, peers=peers, users=users, device_groups=device_groups)
|
||||
count = (len(peers) if peers else 0) + (len(users) if users else 0) + (len(device_groups) if device_groups else 0)
|
||||
print(f"Success: Unassigned strategy from {count} target(s)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
302
res/user_group.py
Executable file
302
res/user_group.py
Executable file
@@ -0,0 +1,302 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import requests
|
||||
import argparse
|
||||
import json
|
||||
|
||||
|
||||
def check_response(response):
|
||||
"""
|
||||
Check API response and handle errors.
|
||||
|
||||
Two error cases:
|
||||
1. Status code is not 200 -> exit with error
|
||||
2. Response contains {"error": "xxx"} -> exit with error
|
||||
"""
|
||||
if response.status_code != 200:
|
||||
print(f"Error: HTTP {response.status_code}: {response.text}")
|
||||
exit(1)
|
||||
|
||||
# Check for {"error": "xxx"} in response
|
||||
if response.text and response.text.strip():
|
||||
try:
|
||||
json_data = response.json()
|
||||
if isinstance(json_data, dict) and "error" in json_data:
|
||||
print(f"Error: {json_data['error']}")
|
||||
exit(1)
|
||||
return json_data
|
||||
except ValueError:
|
||||
return response.text
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def headers_with(token):
|
||||
return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
||||
|
||||
|
||||
# ---------- User Group APIs ----------
|
||||
|
||||
def list_groups(url, token, name=None, page_size=50):
|
||||
headers = headers_with(token)
|
||||
params = {"pageSize": page_size}
|
||||
if name:
|
||||
params["name"] = name
|
||||
data, current = [], 1
|
||||
while True:
|
||||
params["current"] = current
|
||||
r = requests.get(f"{url}/api/user-groups", headers=headers, params=params)
|
||||
if r.status_code != 200:
|
||||
print(f"Error: HTTP {r.status_code} - {r.text}")
|
||||
exit(1)
|
||||
res = r.json()
|
||||
if "error" in res:
|
||||
print(f"Error: {res['error']}")
|
||||
exit(1)
|
||||
rows = res.get("data", [])
|
||||
data.extend(rows)
|
||||
total = res.get("total", 0)
|
||||
current += page_size
|
||||
if len(rows) < page_size or current > total:
|
||||
break
|
||||
return data
|
||||
|
||||
|
||||
def get_group_by_name(url, token, name):
|
||||
groups = list_groups(url, token, name)
|
||||
for g in groups:
|
||||
if str(g.get("name")) == name:
|
||||
return g
|
||||
return None
|
||||
|
||||
|
||||
def create_group(url, token, name, note=None, accessed_from=None, access_to=None):
|
||||
headers = headers_with(token)
|
||||
payload = {"name": name}
|
||||
if note:
|
||||
payload["note"] = note
|
||||
if accessed_from:
|
||||
payload["allowed_incomings"] = accessed_from
|
||||
if access_to:
|
||||
payload["allowed_outgoings"] = access_to
|
||||
r = requests.post(f"{url}/api/user-groups", headers=headers, json=payload)
|
||||
return check_response(r)
|
||||
|
||||
|
||||
def update_group(url, token, name, new_name=None, note=None, accessed_from=None, access_to=None):
|
||||
headers = headers_with(token)
|
||||
g = get_group_by_name(url, token, name)
|
||||
if not g:
|
||||
print(f"Error: Group '{name}' not found")
|
||||
exit(1)
|
||||
guid = g.get("guid")
|
||||
payload = {}
|
||||
if new_name is not None:
|
||||
payload["name"] = new_name
|
||||
if note is not None:
|
||||
payload["note"] = note
|
||||
if accessed_from is not None:
|
||||
payload["allowed_incomings"] = accessed_from
|
||||
if access_to is not None:
|
||||
payload["allowed_outgoings"] = access_to
|
||||
r = requests.patch(f"{url}/api/user-groups/{guid}", headers=headers, json=payload)
|
||||
check_response(r)
|
||||
return "Success"
|
||||
|
||||
|
||||
def delete_groups(url, token, names):
|
||||
headers = headers_with(token)
|
||||
if isinstance(names, str):
|
||||
names = [names]
|
||||
for n in names:
|
||||
g = get_group_by_name(url, token, n)
|
||||
if not g:
|
||||
print(f"Error: Group '{n}' not found")
|
||||
exit(1)
|
||||
guid = g.get("guid")
|
||||
r = requests.delete(f"{url}/api/user-groups/{guid}", headers=headers)
|
||||
check_response(r)
|
||||
return "Success"
|
||||
|
||||
|
||||
# ---------- User management in group ----------
|
||||
|
||||
def view_users(url, token, group_name=None, name=None, page_size=50):
|
||||
"""View users in a user group with filters"""
|
||||
headers = headers_with(token)
|
||||
|
||||
# Separate exact match and fuzzy match params
|
||||
params = {}
|
||||
fuzzy_params = {
|
||||
"name": name,
|
||||
}
|
||||
|
||||
# Add group_name without wildcard (exact match)
|
||||
if group_name:
|
||||
params["group_name"] = group_name
|
||||
|
||||
# Add wildcard for fuzzy search to other params
|
||||
for k, v in fuzzy_params.items():
|
||||
if v is not None:
|
||||
params[k] = "%" + v + "%" if (v != "-" and "%" not in v) else v
|
||||
|
||||
params["pageSize"] = page_size
|
||||
|
||||
data, current = [], 1
|
||||
while True:
|
||||
params["current"] = current
|
||||
r = requests.get(f"{url}/api/users", headers=headers, params=params)
|
||||
if r.status_code != 200:
|
||||
return check_response(r)
|
||||
res = r.json()
|
||||
rows = res.get("data", [])
|
||||
data.extend(rows)
|
||||
total = res.get("total", 0)
|
||||
current += page_size
|
||||
if len(rows) < page_size or current > total:
|
||||
break
|
||||
return data
|
||||
|
||||
|
||||
def add_users(url, token, group_name, user_names):
|
||||
"""Add users to a user group"""
|
||||
headers = headers_with(token)
|
||||
if isinstance(user_names, str):
|
||||
user_names = [user_names]
|
||||
|
||||
# Get the user group guid
|
||||
g = get_group_by_name(url, token, group_name)
|
||||
if not g:
|
||||
print(f"Error: Group '{group_name}' not found")
|
||||
exit(1)
|
||||
guid = g.get("guid")
|
||||
|
||||
# Get user GUIDs
|
||||
user_guids = []
|
||||
errors = []
|
||||
|
||||
for user_name in user_names:
|
||||
# Get user by exact name match
|
||||
params = {"name": user_name, "pageSize": 50}
|
||||
r = requests.get(f"{url}/api/users", headers=headers, params=params)
|
||||
if r.status_code != 200:
|
||||
errors.append(f"{user_name}: HTTP {r.status_code}")
|
||||
continue
|
||||
|
||||
users_data = r.json()
|
||||
users_list = users_data.get("data", [])
|
||||
user = None
|
||||
for u in users_list:
|
||||
if u.get("name") == user_name:
|
||||
user = u
|
||||
break
|
||||
|
||||
if not user:
|
||||
errors.append(f"{user_name}: User not found")
|
||||
continue
|
||||
|
||||
user_guids.append(user["guid"])
|
||||
|
||||
if not user_guids:
|
||||
msg = "Error: No valid users found"
|
||||
if errors:
|
||||
msg += ". " + "; ".join(errors)
|
||||
print(msg)
|
||||
exit(1)
|
||||
|
||||
# Add users to group using POST /api/user-groups/:guid
|
||||
r = requests.post(f"{url}/api/user-groups/{guid}", headers=headers, json=user_guids)
|
||||
check_response(r)
|
||||
|
||||
success_msg = f"Success: Added {len(user_guids)} user(s) to group '{group_name}'"
|
||||
if errors:
|
||||
return success_msg + " (with errors: " + "; ".join(errors) + ")"
|
||||
return success_msg
|
||||
|
||||
|
||||
def parse_rules(s):
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
v = json.loads(s)
|
||||
if isinstance(v, list):
|
||||
# expect list of {"type": number, "name": string}
|
||||
return v
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="User Group manager")
|
||||
parser.add_argument("command", choices=[
|
||||
"view", "add", "update", "delete",
|
||||
"view-users", "add-users"
|
||||
], help=(
|
||||
"Command to execute. "
|
||||
"[view/add/update/delete/add-users: require User Group Permission] "
|
||||
"[view-users: require User Permission]"
|
||||
))
|
||||
parser.add_argument("--url", required=True)
|
||||
parser.add_argument("--token", required=True)
|
||||
|
||||
parser.add_argument("--name", help="User group name (exact match)")
|
||||
parser.add_argument("--new-name", help="New user group name (for update)")
|
||||
parser.add_argument("--note", help="Note")
|
||||
|
||||
parser.add_argument("--accessed-from", help="JSON array: '[{\"type\":0|2,\"name\":\"...\"}]' (0=User Group, 2=User)")
|
||||
parser.add_argument("--access-to", help="JSON array: '[{\"type\":0|1,\"name\":\"...\"}]' (0=User Group, 1=Device Group)")
|
||||
|
||||
parser.add_argument("--users", help="Comma separated usernames for add-users")
|
||||
|
||||
# Filters for view-users command
|
||||
parser.add_argument("--user-name", help="User name filter (for view-users, supports fuzzy search)")
|
||||
|
||||
args = parser.parse_args()
|
||||
while args.url.endswith("/"): args.url = args.url[:-1]
|
||||
|
||||
if args.command == "view":
|
||||
res = list_groups(args.url, args.token, args.name)
|
||||
print(json.dumps(res, indent=2))
|
||||
elif args.command == "add":
|
||||
if not args.name:
|
||||
print("Error: --name is required")
|
||||
exit(1)
|
||||
print(create_group(
|
||||
args.url, args.token, args.name, args.note,
|
||||
parse_rules(args.accessed_from),
|
||||
parse_rules(args.access_to)
|
||||
))
|
||||
elif args.command == "update":
|
||||
if not args.name:
|
||||
print("Error: --name is required")
|
||||
exit(1)
|
||||
print(update_group(
|
||||
args.url, args.token, args.name, args.new_name, args.note,
|
||||
parse_rules(args.accessed_from),
|
||||
parse_rules(args.access_to)
|
||||
))
|
||||
elif args.command == "delete":
|
||||
if not args.name:
|
||||
print("Error: --name is required (supports comma separated)")
|
||||
exit(1)
|
||||
names = [x.strip() for x in args.name.split(",") if x.strip()]
|
||||
print(delete_groups(args.url, args.token, names))
|
||||
elif args.command == "view-users":
|
||||
res = view_users(
|
||||
args.url,
|
||||
args.token,
|
||||
group_name=args.name,
|
||||
name=args.user_name
|
||||
)
|
||||
print(json.dumps(res, indent=2))
|
||||
elif args.command == "add-users":
|
||||
if not args.name or not args.users:
|
||||
print("Error: --name and --users are required")
|
||||
exit(1)
|
||||
users = [x.strip() for x in args.users.split(",") if x.strip()]
|
||||
print(add_users(args.url, args.token, args.name, users))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
231
res/users.py
231
res/users.py
@@ -5,6 +5,28 @@ import argparse
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
|
||||
def check_response(response):
|
||||
"""
|
||||
Check API response and handle errors properly.
|
||||
Exit with code 1 if there's an error.
|
||||
"""
|
||||
if response.status_code != 200:
|
||||
print(f"Error: HTTP {response.status_code}: {response.text}")
|
||||
exit(1)
|
||||
|
||||
if response.text and response.text.strip():
|
||||
try:
|
||||
json_data = response.json()
|
||||
if isinstance(json_data, dict) and "error" in json_data:
|
||||
print(f"Error: {json_data['error']}")
|
||||
exit(1)
|
||||
return json_data
|
||||
except ValueError:
|
||||
return response.text
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def view(
|
||||
url,
|
||||
token,
|
||||
@@ -32,7 +54,14 @@ def view(
|
||||
while True:
|
||||
params["current"] = current
|
||||
response = requests.get(f"{url}/api/users", headers=headers, params=params)
|
||||
if response.status_code != 200:
|
||||
print(f"Error: HTTP {response.status_code} - {response.text}")
|
||||
exit(1)
|
||||
|
||||
response_json = response.json()
|
||||
if "error" in response_json:
|
||||
print(f"Error: {response_json['error']}")
|
||||
exit(1)
|
||||
|
||||
data = response_json.get("data", [])
|
||||
users.extend(data)
|
||||
@@ -45,43 +74,122 @@ def view(
|
||||
return users
|
||||
|
||||
|
||||
def check(response):
|
||||
if response.status_code == 200:
|
||||
try:
|
||||
response_json = response.json()
|
||||
return response_json
|
||||
except ValueError:
|
||||
return response.text or "Success"
|
||||
else:
|
||||
return "Failed", response.status_code, response.text
|
||||
|
||||
|
||||
def disable(url, token, guid, name):
|
||||
print("Disable", name)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
response = requests.post(f"{url}/api/users/{guid}/disable", headers=headers)
|
||||
return check(response)
|
||||
check_response(response)
|
||||
|
||||
|
||||
def enable(url, token, guid, name):
|
||||
print("Enable", name)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
response = requests.post(f"{url}/api/users/{guid}/enable", headers=headers)
|
||||
return check(response)
|
||||
check_response(response)
|
||||
|
||||
|
||||
def delete(url, token, guid, name):
|
||||
def delete_user(url, token, guid, name):
|
||||
print("Delete", name)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
response = requests.delete(f"{url}/api/users/{guid}", headers=headers)
|
||||
return check(response)
|
||||
check_response(response)
|
||||
|
||||
|
||||
def new_user(url, token, name, password, group_name=None, email=None, note=None):
|
||||
"""Create a new user"""
|
||||
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
||||
payload = {
|
||||
"name": name,
|
||||
"password": password,
|
||||
}
|
||||
if group_name:
|
||||
payload["group_name"] = group_name
|
||||
if email:
|
||||
payload["email"] = email
|
||||
if note:
|
||||
payload["note"] = note
|
||||
response = requests.post(f"{url}/api/users", headers=headers, json=payload)
|
||||
check_response(response)
|
||||
|
||||
|
||||
def invite_user(url, token, email, name, group_name=None, note=None):
|
||||
"""Invite a user by email"""
|
||||
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
||||
payload = {
|
||||
"email": email,
|
||||
"name": name,
|
||||
}
|
||||
if group_name:
|
||||
payload["group_name"] = group_name
|
||||
if note:
|
||||
payload["note"] = note
|
||||
response = requests.post(f"{url}/api/users/invite", headers=headers, json=payload)
|
||||
check_response(response)
|
||||
|
||||
|
||||
def enable_2fa_enforce(url, token, user_guids, base_url):
|
||||
"""Enable 2FA enforcement for users"""
|
||||
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
||||
payload = {
|
||||
"user_guids": user_guids if isinstance(user_guids, list) else [user_guids],
|
||||
"enforce": True,
|
||||
"url": base_url
|
||||
}
|
||||
response = requests.put(f"{url}/api/users/tfa/totp/enforce", headers=headers, json=payload)
|
||||
check_response(response)
|
||||
|
||||
|
||||
def disable_2fa_enforce(url, token, user_guids, base_url=""):
|
||||
"""Disable 2FA enforcement for users"""
|
||||
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
||||
payload = {
|
||||
"user_guids": user_guids if isinstance(user_guids, list) else [user_guids],
|
||||
"enforce": False,
|
||||
"url": base_url
|
||||
}
|
||||
response = requests.put(f"{url}/api/users/tfa/totp/enforce", headers=headers, json=payload)
|
||||
check_response(response)
|
||||
|
||||
|
||||
def disable_email_verification(url, token, user_guids):
|
||||
"""Disable email login verification for users"""
|
||||
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
||||
payload = {
|
||||
"user_guids": user_guids if isinstance(user_guids, list) else [user_guids],
|
||||
"type": "email"
|
||||
}
|
||||
response = requests.put(f"{url}/api/users/disable_login_verification", headers=headers, json=payload)
|
||||
check_response(response)
|
||||
|
||||
|
||||
def reset_2fa(url, token, user_guids):
|
||||
"""Reset 2FA for users"""
|
||||
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
||||
payload = {
|
||||
"user_guids": user_guids if isinstance(user_guids, list) else [user_guids],
|
||||
"type": "2fa"
|
||||
}
|
||||
response = requests.put(f"{url}/api/users/disable_login_verification", headers=headers, json=payload)
|
||||
check_response(response)
|
||||
|
||||
|
||||
def force_logout(url, token, user_guids):
|
||||
"""Force logout users"""
|
||||
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
||||
payload = {
|
||||
"user_guids": user_guids if isinstance(user_guids, list) else [user_guids],
|
||||
}
|
||||
response = requests.post(f"{url}/api/users/force-logout", headers=headers, json=payload)
|
||||
check_response(response)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="User manager")
|
||||
parser.add_argument(
|
||||
"command",
|
||||
choices=["view", "disable", "enable", "delete"],
|
||||
choices=["view", "disable", "enable", "delete", "new", "invite",
|
||||
"enable-2fa-enforce", "disable-2fa-enforce",
|
||||
"disable-email-verification", "reset-2fa", "force-logout"],
|
||||
help="Command to execute",
|
||||
)
|
||||
parser.add_argument("--url", required=True, help="URL of the API")
|
||||
@@ -89,12 +197,32 @@ def main():
|
||||
"--token", required=True, help="Bearer token for authentication"
|
||||
)
|
||||
parser.add_argument("--name", help="User name")
|
||||
parser.add_argument("--group_name", help="Group name")
|
||||
parser.add_argument("--group_name", help="Group name (for filtering in view, or for new/invite command)")
|
||||
parser.add_argument("--password", help="User password (for new command)")
|
||||
parser.add_argument("--email", help="User email (for invite command)")
|
||||
parser.add_argument("--note", help="User note (for new/invite command)")
|
||||
parser.add_argument("--web-console-url", help="Web console URL (for 2FA enforce commands)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
while args.url.endswith("/"): args.url = args.url[:-1]
|
||||
|
||||
if args.command == "new":
|
||||
if not args.name or not args.password or not args.group_name:
|
||||
print("Error: --name and --password and --group_name are required for new command")
|
||||
exit(1)
|
||||
new_user(args.url, args.token, args.name, args.password, args.group_name, args.email, args.note)
|
||||
print("Success: User created")
|
||||
return
|
||||
|
||||
if args.command == "invite":
|
||||
if not args.email or not args.name or not args.group_name:
|
||||
print("Error: --email and --name and --group_name are required for invite command")
|
||||
exit(1)
|
||||
invite_user(args.url, args.token, args.email, args.name, args.group_name, args.note)
|
||||
print("Success: Invitation sent")
|
||||
return
|
||||
|
||||
users = view(
|
||||
args.url,
|
||||
args.token,
|
||||
@@ -103,20 +231,61 @@ def main():
|
||||
)
|
||||
|
||||
if args.command == "view":
|
||||
for user in users:
|
||||
print(user)
|
||||
elif args.command == "disable":
|
||||
for user in users:
|
||||
response = disable(args.url, args.token, user["guid"], user["name"])
|
||||
print(response)
|
||||
elif args.command == "enable":
|
||||
for user in users:
|
||||
response = enable(args.url, args.token, user["guid"], user["name"])
|
||||
print(response)
|
||||
elif args.command == "delete":
|
||||
for user in users:
|
||||
response = delete(args.url, args.token, user["guid"], user["name"])
|
||||
print(response)
|
||||
if len(users) == 0:
|
||||
print("Found 0 users")
|
||||
else:
|
||||
for user in users:
|
||||
print(user)
|
||||
elif args.command in ["disable", "enable", "delete", "enable-2fa-enforce",
|
||||
"disable-2fa-enforce", "disable-email-verification", "reset-2fa", "force-logout"]:
|
||||
if len(users) == 0:
|
||||
print("Found 0 users")
|
||||
return
|
||||
|
||||
# Check if we need user confirmation for multiple users
|
||||
if len(users) > 1:
|
||||
print(f"Found {len(users)} users. Do you want to proceed with {args.command} operation on the users? (Y/N)")
|
||||
confirmation = input("Type 'Y' to confirm: ").strip()
|
||||
if confirmation.upper() != 'Y':
|
||||
print("Operation cancelled.")
|
||||
return
|
||||
|
||||
if args.command == "disable":
|
||||
for user in users:
|
||||
disable(args.url, args.token, user["guid"], user["name"])
|
||||
print("Success")
|
||||
elif args.command == "enable":
|
||||
for user in users:
|
||||
enable(args.url, args.token, user["guid"], user["name"])
|
||||
print("Success")
|
||||
elif args.command == "delete":
|
||||
for user in users:
|
||||
delete_user(args.url, args.token, user["guid"], user["name"])
|
||||
print("Success")
|
||||
elif args.command == "enable-2fa-enforce":
|
||||
if not args.web_console_url:
|
||||
print("Error: --web-console-url is required for enable-2fa-enforce")
|
||||
exit(1)
|
||||
user_guids = [user["guid"] for user in users]
|
||||
enable_2fa_enforce(args.url, args.token, user_guids, args.web_console_url)
|
||||
print(f"Success: Enabled 2FA enforcement for {len(users)} user(s)")
|
||||
elif args.command == "disable-2fa-enforce":
|
||||
user_guids = [user["guid"] for user in users]
|
||||
web_url = args.web_console_url or ""
|
||||
disable_2fa_enforce(args.url, args.token, user_guids, web_url)
|
||||
print(f"Success: Disabled 2FA enforcement for {len(users)} user(s)")
|
||||
elif args.command == "disable-email-verification":
|
||||
user_guids = [user["guid"] for user in users]
|
||||
disable_email_verification(args.url, args.token, user_guids)
|
||||
print(f"Success: Disabled email verification for {len(users)} user(s)")
|
||||
elif args.command == "reset-2fa":
|
||||
user_guids = [user["guid"] for user in users]
|
||||
reset_2fa(args.url, args.token, user_guids)
|
||||
print(f"Success: Reset 2FA for {len(users)} user(s)")
|
||||
elif args.command == "force-logout":
|
||||
user_guids = [user["guid"] for user in users]
|
||||
force_logout(args.url, args.token, user_guids)
|
||||
print(f"Success: Force logout for {len(users)} user(s)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
|
||||
From: RustDesk <support@rustdesk.com>
|
||||
Date: Fri, 1 Nov 2025 08:00:00 +0000
|
||||
Subject: [PATCH] Fix CVBufferCopyAttachments crash on macOS Big Sur
|
||||
|
||||
Use weak linking for CVBufferCopyAttachments to avoid symbol resolution
|
||||
crash on macOS < 12. The function will be NULL on older systems and the
|
||||
code will fall back to the deprecated CVBufferGetAttachments.
|
||||
|
||||
This fixes a crash on macOS Big Sur (11.x) where CVBufferCopyAttachments
|
||||
is not available. The runtime check with __builtin_available is not enough
|
||||
because the symbol is still resolved at load time, causing a dyld error.
|
||||
|
||||
Fixes: https://github.com/rustdesk/rustdesk/issues/13377
|
||||
---
|
||||
libavutil/hwcontext_videotoolbox.c | 21 ++++++++++++++++++++-
|
||||
1 file changed, 20 insertions(+), 1 deletion(-)
|
||||
|
||||
diff --git a/libavutil/hwcontext_videotoolbox.c b/libavutil/hwcontext_videotoolbox.c
|
||||
index 0000000000..1111111111 100644
|
||||
--- a/libavutil/hwcontext_videotoolbox.c
|
||||
+++ b/libavutil/hwcontext_videotoolbox.c
|
||||
@@ -33,6 +33,25 @@
|
||||
#include "pixfmt.h"
|
||||
#include "pixdesc.h"
|
||||
|
||||
+// Weak import CVBufferCopyAttachments to support macOS < 12
|
||||
+// The runtime check with __builtin_available is not enough because
|
||||
+// the symbol is still resolved at load time, causing dyld errors on Big Sur.
|
||||
+// With weak_import, the function pointer will be NULL on older systems.
|
||||
+#if TARGET_OS_OSX && defined(__MAC_12_0) && __MAC_OS_X_VERSION_MAX_ALLOWED >= __MAC_12_0
|
||||
+extern CFDictionaryRef CVBufferCopyAttachments(CVBufferRef buffer, CVAttachmentMode mode)
|
||||
+ __attribute__((weak_import));
|
||||
+#endif
|
||||
+#if TARGET_OS_IOS && defined(__IPHONE_15_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_15_0
|
||||
+extern CFDictionaryRef CVBufferCopyAttachments(CVBufferRef buffer, CVAttachmentMode mode)
|
||||
+ __attribute__((weak_import));
|
||||
+#endif
|
||||
+#if TARGET_OS_TV && defined(__TVOS_15_0) && __TV_OS_VERSION_MAX_ALLOWED >= __TVOS_15_0
|
||||
+extern CFDictionaryRef CVBufferCopyAttachments(CVBufferRef buffer, CVAttachmentMode mode)
|
||||
+ __attribute__((weak_import));
|
||||
+#endif
|
||||
+
|
||||
+// End of weak import section
|
||||
+
|
||||
typedef struct VTFramesContext {
|
||||
/**
|
||||
* The public AVVTFramesContext. See hwcontext_videotoolbox.h for it.
|
||||
@@ -547,7 +566,7 @@ static CFDictionaryRef vt_cv_buffer_copy_attachments(CVBufferRef buffer,
|
||||
(TARGET_OS_TV && defined(__TVOS_15_0) && __TV_OS_VERSION_MAX_ALLOWED >= __TVOS_15_0)
|
||||
// On recent enough versions, just use the respective API
|
||||
if (__builtin_available(macOS 12.0, iOS 15.0, tvOS 15.0, *))
|
||||
- return CVBufferCopyAttachments(buffer, attachment_mode);
|
||||
+ if (CVBufferCopyAttachments != NULL) return CVBufferCopyAttachments(buffer, attachment_mode);
|
||||
#endif
|
||||
|
||||
// Check that the target is lower than macOS 12 / iOS 15 / tvOS 15
|
||||
--
|
||||
2.43.0
|
||||
|
||||
@@ -27,6 +27,7 @@ vcpkg_from_github(
|
||||
patch/0009-fix-nvenc-reconfigure-blur.patch
|
||||
patch/0010.disable-loading-DLLs-from-app-dir.patch
|
||||
patch/0011-android-mediacodec-encode-align-64.patch
|
||||
patch/0012-fix-macos-big-sur-CVBufferCopyAttachments.patch
|
||||
)
|
||||
|
||||
if(SOURCE_PATH MATCHES " ")
|
||||
|
||||
@@ -1976,13 +1976,24 @@ impl LoginConfigHandler {
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `value` - The view style to be saved.
|
||||
/// * `value` - The scroll style to be saved.
|
||||
pub fn save_scroll_style(&mut self, value: String) {
|
||||
let mut config = self.load_config();
|
||||
config.scroll_style = value;
|
||||
self.save_config(config);
|
||||
}
|
||||
|
||||
/// Save edge scroll edge thickness to the current config.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `value` - The edge thickness to be saved.
|
||||
pub fn save_edge_scroll_edge_thickness(&mut self, value: i32) {
|
||||
let mut config = self.load_config();
|
||||
config.edge_scroll_edge_thickness = value;
|
||||
self.save_config(config);
|
||||
}
|
||||
|
||||
/// Set a ui config of flutter for handler's [`PeerConfig`].
|
||||
///
|
||||
/// # Arguments
|
||||
|
||||
@@ -1755,6 +1755,13 @@ impl<T: InvokeUiSession> Remote<T> {
|
||||
thread.video_sender.send(MediaData::Reset).ok();
|
||||
}
|
||||
|
||||
let mut scale = 1.0;
|
||||
if let Some(pi) = &self.handler.lc.read().unwrap().peer_info {
|
||||
if let Some(d) = pi.displays.get(s.display as usize) {
|
||||
scale = d.scale;
|
||||
}
|
||||
}
|
||||
|
||||
if s.width > 0 && s.height > 0 {
|
||||
self.handler.set_display(
|
||||
s.x,
|
||||
@@ -1762,6 +1769,7 @@ impl<T: InvokeUiSession> Remote<T> {
|
||||
s.width,
|
||||
s.height,
|
||||
s.cursor_embedded,
|
||||
scale,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -427,17 +427,8 @@ impl ClipboardContext {
|
||||
// Don't use `hbb_common::platform::linux::is_kde()` here.
|
||||
// It's not correct in the server process.
|
||||
#[cfg(target_os = "linux")]
|
||||
let is_kde_x11 = {
|
||||
use hbb_common::platform::linux::CMD_SH;
|
||||
let is_kde = std::process::Command::new(CMD_SH.as_str())
|
||||
.arg("-c")
|
||||
.arg("ps -e | grep -E kded[0-9]+ | grep -v grep")
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.output()
|
||||
.map(|o| !o.stdout.is_empty())
|
||||
.unwrap_or(false);
|
||||
is_kde && crate::platform::linux::is_x11()
|
||||
};
|
||||
let is_kde_x11 = hbb_common::platform::linux::is_kde_session()
|
||||
&& crate::platform::linux::is_x11();
|
||||
#[cfg(target_os = "macos")]
|
||||
let is_kde_x11 = false;
|
||||
let clear_holder_text = if is_kde_x11 {
|
||||
|
||||
263
src/common.rs
263
src/common.rs
@@ -13,6 +13,7 @@ use hbb_common::whoami;
|
||||
use hbb_common::{
|
||||
allow_err,
|
||||
anyhow::{anyhow, Context},
|
||||
async_recursion::async_recursion,
|
||||
bail, base64,
|
||||
bytes::Bytes,
|
||||
config::{
|
||||
@@ -27,6 +28,7 @@ use hbb_common::{
|
||||
socket_client,
|
||||
sodiumoxide::crypto::{box_, secretbox, sign},
|
||||
timeout,
|
||||
tls::{get_cached_tls_accept_invalid_cert, get_cached_tls_type, upsert_tls_cache, TlsType},
|
||||
tokio::{
|
||||
self,
|
||||
net::UdpSocket,
|
||||
@@ -36,7 +38,7 @@ use hbb_common::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
hbbs_http::create_http_client_async,
|
||||
hbbs_http::{create_http_client_async, get_url_for_tls},
|
||||
ui_interface::{get_option, set_option},
|
||||
};
|
||||
|
||||
@@ -113,6 +115,10 @@ pub fn global_init() -> bool {
|
||||
crate::server::wayland::init();
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
crate::platform::macos::try_remove_temp_update_dir(None);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
@@ -908,15 +914,35 @@ pub fn check_software_update() {
|
||||
}
|
||||
}
|
||||
|
||||
// No need to check `danger_accept_invalid_cert` for now.
|
||||
// Because the url is always `https://api.rustdesk.com/version/latest`.
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
pub async fn do_check_software_update() -> hbb_common::ResultType<()> {
|
||||
let (request, url) =
|
||||
hbb_common::version_check_request(hbb_common::VER_TYPE_RUSTDESK_CLIENT.to_string());
|
||||
let latest_release_response = create_http_client_async()
|
||||
.post(url)
|
||||
.json(&request)
|
||||
.send()
|
||||
.await?;
|
||||
let proxy_conf = Config::get_socks();
|
||||
let tls_url = get_url_for_tls(&url, &proxy_conf);
|
||||
let tls_type = get_cached_tls_type(tls_url);
|
||||
let is_tls_not_cached = tls_type.is_none();
|
||||
let tls_type = tls_type.unwrap_or(TlsType::Rustls);
|
||||
let client = create_http_client_async(tls_type, false);
|
||||
let latest_release_response = match client.post(&url).json(&request).send().await {
|
||||
Ok(resp) => {
|
||||
upsert_tls_cache(tls_url, tls_type, false);
|
||||
resp
|
||||
}
|
||||
Err(err) => {
|
||||
if is_tls_not_cached && err.is_request() {
|
||||
let tls_type = TlsType::NativeTls;
|
||||
let client = create_http_client_async(tls_type, false);
|
||||
let resp = client.post(&url).json(&request).send().await?;
|
||||
upsert_tls_cache(tls_url, tls_type, false);
|
||||
resp
|
||||
} else {
|
||||
return Err(err.into());
|
||||
}
|
||||
}
|
||||
};
|
||||
let bytes = latest_release_response.bytes().await?;
|
||||
let resp: hbb_common::VersionCheckResponse = serde_json::from_slice(&bytes)?;
|
||||
let response_url = resp.url;
|
||||
@@ -1067,7 +1093,38 @@ pub fn get_audit_server(api: String, custom: String, typ: String) -> String {
|
||||
}
|
||||
|
||||
pub async fn post_request(url: String, body: String, header: &str) -> ResultType<String> {
|
||||
let mut req = create_http_client_async().post(url);
|
||||
let proxy_conf = Config::get_socks();
|
||||
let tls_url = get_url_for_tls(&url, &proxy_conf);
|
||||
let tls_type = get_cached_tls_type(tls_url);
|
||||
let danger_accept_invalid_cert = get_cached_tls_accept_invalid_cert(tls_url);
|
||||
let response = post_request_(
|
||||
&url,
|
||||
tls_url,
|
||||
body.clone(),
|
||||
header,
|
||||
tls_type,
|
||||
danger_accept_invalid_cert,
|
||||
danger_accept_invalid_cert,
|
||||
)
|
||||
.await?;
|
||||
Ok(response.text().await?)
|
||||
}
|
||||
|
||||
#[async_recursion]
|
||||
async fn post_request_(
|
||||
url: &str,
|
||||
tls_url: &str,
|
||||
body: String,
|
||||
header: &str,
|
||||
tls_type: Option<TlsType>,
|
||||
danger_accept_invalid_cert: Option<bool>,
|
||||
original_danger_accept_invalid_cert: Option<bool>,
|
||||
) -> ResultType<reqwest::Response> {
|
||||
let mut req = create_http_client_async(
|
||||
tls_type.unwrap_or(TlsType::Rustls),
|
||||
danger_accept_invalid_cert.unwrap_or(false),
|
||||
)
|
||||
.post(url);
|
||||
if !header.is_empty() {
|
||||
let tmp: Vec<&str> = header.split(": ").collect();
|
||||
if tmp.len() == 2 {
|
||||
@@ -1076,7 +1133,66 @@ pub async fn post_request(url: String, body: String, header: &str) -> ResultType
|
||||
}
|
||||
req = req.header("Content-Type", "application/json");
|
||||
let to = std::time::Duration::from_secs(12);
|
||||
Ok(req.body(body).timeout(to).send().await?.text().await?)
|
||||
if tls_type.is_some() && danger_accept_invalid_cert.is_some() {
|
||||
// This branch is used to reduce a `clone()` when both `tls_type` and
|
||||
// `danger_accept_invalid_cert` are cached.
|
||||
match req.body(body.clone()).timeout(to).send().await {
|
||||
Ok(resp) => {
|
||||
upsert_tls_cache(
|
||||
tls_url,
|
||||
tls_type.unwrap_or(TlsType::Rustls),
|
||||
danger_accept_invalid_cert.unwrap_or(false),
|
||||
);
|
||||
Ok(resp)
|
||||
}
|
||||
Err(e) => Err(anyhow!("{:?}", e)),
|
||||
}
|
||||
} else {
|
||||
match req.body(body.clone()).timeout(to).send().await {
|
||||
Ok(resp) => {
|
||||
upsert_tls_cache(
|
||||
tls_url,
|
||||
tls_type.unwrap_or(TlsType::Rustls),
|
||||
danger_accept_invalid_cert.unwrap_or(false),
|
||||
);
|
||||
Ok(resp)
|
||||
}
|
||||
Err(e) => {
|
||||
if (tls_type.is_none() || danger_accept_invalid_cert.is_none()) && e.is_request() {
|
||||
if danger_accept_invalid_cert.is_none() {
|
||||
log::warn!(
|
||||
"HTTP request failed: {:?}, try again, danger accept invalid cert",
|
||||
e
|
||||
);
|
||||
post_request_(
|
||||
url,
|
||||
tls_url,
|
||||
body,
|
||||
header,
|
||||
tls_type,
|
||||
Some(true),
|
||||
original_danger_accept_invalid_cert,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
log::warn!("HTTP request failed: {:?}, try again with native-tls", e);
|
||||
post_request_(
|
||||
url,
|
||||
tls_url,
|
||||
body,
|
||||
header,
|
||||
Some(TlsType::NativeTls),
|
||||
original_danger_accept_invalid_cert,
|
||||
original_danger_accept_invalid_cert,
|
||||
)
|
||||
.await
|
||||
}
|
||||
} else {
|
||||
Err(anyhow!("{:?}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
@@ -1084,22 +1200,29 @@ pub async fn post_request_sync(url: String, body: String, header: &str) -> Resul
|
||||
post_request(url, body, header).await
|
||||
}
|
||||
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
pub async fn http_request_sync(
|
||||
url: String,
|
||||
method: String,
|
||||
#[async_recursion]
|
||||
async fn get_http_response_async(
|
||||
url: &str,
|
||||
tls_url: &str,
|
||||
method: &str,
|
||||
body: Option<String>,
|
||||
header: String,
|
||||
) -> ResultType<String> {
|
||||
let http_client = create_http_client_async();
|
||||
let mut http_client = match method.as_str() {
|
||||
header: &str,
|
||||
tls_type: Option<TlsType>,
|
||||
danger_accept_invalid_cert: Option<bool>,
|
||||
original_danger_accept_invalid_cert: Option<bool>,
|
||||
) -> ResultType<reqwest::Response> {
|
||||
let http_client = create_http_client_async(
|
||||
tls_type.unwrap_or(TlsType::Rustls),
|
||||
danger_accept_invalid_cert.unwrap_or(false),
|
||||
);
|
||||
let mut http_client = match method {
|
||||
"get" => http_client.get(url),
|
||||
"post" => http_client.post(url),
|
||||
"put" => http_client.put(url),
|
||||
"delete" => http_client.delete(url),
|
||||
_ => return Err(anyhow!("The HTTP request method is not supported!")),
|
||||
};
|
||||
let v = serde_json::from_str(header.as_str())?;
|
||||
let v = serde_json::from_str(header)?;
|
||||
|
||||
if let Value::Object(obj) = v {
|
||||
for (key, value) in obj.iter() {
|
||||
@@ -1109,15 +1232,105 @@ pub async fn http_request_sync(
|
||||
return Err(anyhow!("HTTP header information parsing failed!"));
|
||||
}
|
||||
|
||||
if let Some(b) = body {
|
||||
http_client = http_client.body(b);
|
||||
if tls_type.is_some() && danger_accept_invalid_cert.is_some() {
|
||||
if let Some(b) = body {
|
||||
http_client = http_client.body(b);
|
||||
}
|
||||
match http_client
|
||||
.timeout(std::time::Duration::from_secs(12))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => {
|
||||
upsert_tls_cache(
|
||||
tls_url,
|
||||
tls_type.unwrap_or(TlsType::Rustls),
|
||||
danger_accept_invalid_cert.unwrap_or(false),
|
||||
);
|
||||
Ok(resp)
|
||||
}
|
||||
Err(e) => Err(anyhow!("{:?}", e)),
|
||||
}
|
||||
} else {
|
||||
if let Some(b) = body.clone() {
|
||||
http_client = http_client.body(b);
|
||||
}
|
||||
|
||||
match http_client
|
||||
.timeout(std::time::Duration::from_secs(12))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => {
|
||||
upsert_tls_cache(
|
||||
tls_url,
|
||||
tls_type.unwrap_or(TlsType::Rustls),
|
||||
danger_accept_invalid_cert.unwrap_or(false),
|
||||
);
|
||||
Ok(resp)
|
||||
}
|
||||
Err(e) => {
|
||||
if (tls_type.is_none() || danger_accept_invalid_cert.is_none()) && e.is_request() {
|
||||
if danger_accept_invalid_cert.is_none() {
|
||||
log::warn!(
|
||||
"HTTP request failed: {:?}, try again, danger accept invalid cert",
|
||||
e
|
||||
);
|
||||
get_http_response_async(
|
||||
url,
|
||||
tls_url,
|
||||
method,
|
||||
body,
|
||||
header,
|
||||
tls_type,
|
||||
Some(true),
|
||||
original_danger_accept_invalid_cert,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
log::warn!("HTTP request failed: {:?}, try again with native-tls", e);
|
||||
get_http_response_async(
|
||||
url,
|
||||
tls_url,
|
||||
method,
|
||||
body,
|
||||
header,
|
||||
Some(TlsType::NativeTls),
|
||||
original_danger_accept_invalid_cert,
|
||||
original_danger_accept_invalid_cert,
|
||||
)
|
||||
.await
|
||||
}
|
||||
} else {
|
||||
Err(anyhow!("{:?}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let response = http_client
|
||||
.timeout(std::time::Duration::from_secs(12))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
pub async fn http_request_sync(
|
||||
url: String,
|
||||
method: String,
|
||||
body: Option<String>,
|
||||
header: String,
|
||||
) -> ResultType<String> {
|
||||
let proxy_conf = Config::get_socks();
|
||||
let tls_url = get_url_for_tls(&url, &proxy_conf);
|
||||
let tls_type = get_cached_tls_type(tls_url);
|
||||
let danger_accept_invalid_cert = get_cached_tls_accept_invalid_cert(tls_url);
|
||||
let response = get_http_response_async(
|
||||
&url,
|
||||
tls_url,
|
||||
&method,
|
||||
body.clone(),
|
||||
&header,
|
||||
tls_type,
|
||||
danger_accept_invalid_cert,
|
||||
danger_accept_invalid_cert,
|
||||
)
|
||||
.await?;
|
||||
// Serialize response headers
|
||||
let mut response_headers = serde_json::map::Map::new();
|
||||
for (key, value) in response.headers() {
|
||||
@@ -1772,7 +1985,7 @@ pub fn verify_login(_raw: &str, _id: &str) -> bool {
|
||||
|
||||
#[inline]
|
||||
pub fn is_udp_disabled() -> bool {
|
||||
get_builtin_option(keys::OPTION_DISABLE_UDP) == "Y"
|
||||
Config::get_option(keys::OPTION_DISABLE_UDP) == "Y"
|
||||
}
|
||||
|
||||
// this crate https://github.com/yoshd/stun-client supports nat type
|
||||
|
||||
@@ -29,6 +29,9 @@ macro_rules! my_println{
|
||||
/// If it returns [`Some`], then the process will continue, and flutter gui will be started.
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
pub fn core_main() -> Option<Vec<String>> {
|
||||
if !crate::common::global_init() {
|
||||
return None;
|
||||
}
|
||||
crate::load_custom_client();
|
||||
#[cfg(windows)]
|
||||
if !crate::platform::windows::bootstrap() {
|
||||
@@ -297,14 +300,35 @@ pub fn core_main() -> Option<Vec<String>> {
|
||||
{
|
||||
use crate::platform;
|
||||
if args[0] == "--update" {
|
||||
let _text = match platform::update_me() {
|
||||
Ok(_) => {
|
||||
log::info!("{}", translate("Update successfully!".to_string()));
|
||||
if args.len() > 1 && args[1].ends_with(".dmg") {
|
||||
// Version check is unnecessary unless downgrading to an older version
|
||||
// that lacks "update dmg" support. This is a special case since we cannot
|
||||
// detect the version before extracting the DMG, so we skip the check.
|
||||
let dmg_path = &args[1];
|
||||
println!("Updating from DMG: {}", dmg_path);
|
||||
match platform::update_from_dmg(dmg_path) {
|
||||
Ok(_) => {
|
||||
println!("Update process from DMG started successfully.");
|
||||
// The new process will handle the rest. We can exit.
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("Failed to start update from DMG: {}", err);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!("Update failed with error: {err}");
|
||||
}
|
||||
};
|
||||
} else {
|
||||
println!("Starting update process...");
|
||||
log::info!("Starting update process...");
|
||||
let _text = match platform::update_me() {
|
||||
Ok(_) => {
|
||||
println!("{}", translate("Update successfully!".to_string()));
|
||||
log::info!("Update successfully!");
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("Update failed with error: {}", err);
|
||||
log::error!("Update failed with error: {err}");
|
||||
}
|
||||
};
|
||||
}
|
||||
return None;
|
||||
}
|
||||
}
|
||||
@@ -373,6 +397,10 @@ pub fn core_main() -> Option<Vec<String>> {
|
||||
}
|
||||
return None;
|
||||
} else if args[0] == "--password" {
|
||||
if config::is_disable_settings() {
|
||||
println!("Settings are disabled!");
|
||||
return None;
|
||||
}
|
||||
if args.len() == 2 {
|
||||
if crate::platform::is_installed() && is_root() {
|
||||
if let Err(err) = crate::ipc::set_permanent_password(args[1].to_owned()) {
|
||||
@@ -403,6 +431,10 @@ pub fn core_main() -> Option<Vec<String>> {
|
||||
println!("{}", crate::ipc::get_id());
|
||||
return None;
|
||||
} else if args[0] == "--set-id" {
|
||||
if config::is_disable_settings() {
|
||||
println!("Settings are disabled!");
|
||||
return None;
|
||||
}
|
||||
if args.len() == 2 {
|
||||
if crate::platform::is_installed() && is_root() {
|
||||
let old_id = crate::ipc::get_id();
|
||||
@@ -442,6 +474,10 @@ pub fn core_main() -> Option<Vec<String>> {
|
||||
}
|
||||
return None;
|
||||
} else if args[0] == "--option" {
|
||||
if config::is_disable_settings() {
|
||||
println!("Settings are disabled!");
|
||||
return None;
|
||||
}
|
||||
if crate::platform::is_installed() && is_root() {
|
||||
if args.len() == 2 {
|
||||
let options = crate::ipc::get_options();
|
||||
@@ -668,8 +704,8 @@ fn core_main_invoke_new_connection(mut args: std::env::Args) -> Option<Vec<Strin
|
||||
let mut param_array = vec![];
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--connect" | "--play" | "--file-transfer" | "--view-camera" | "--port-forward" | "--terminal"
|
||||
| "--rdp" => {
|
||||
"--connect" | "--play" | "--file-transfer" | "--view-camera" | "--port-forward"
|
||||
| "--terminal" | "--rdp" => {
|
||||
authority = Some((&arg.to_string()[2..]).to_owned());
|
||||
id = args.next();
|
||||
}
|
||||
|
||||
@@ -609,7 +609,22 @@ impl FlutterHandler {
|
||||
h.insert("original_width", original_resolution.width);
|
||||
h.insert("original_height", original_resolution.height);
|
||||
}
|
||||
h.insert("scale", (d.scale * 100.0f64) as i32);
|
||||
// Don't convert scale (x 100) to i32 directly.
|
||||
// (d.scale * 100.0f64) as i32 may produces inaccuracies.
|
||||
//
|
||||
// Example: GNOME Wayland with Fractional Scaling enabled:
|
||||
// - Physical resolution: 2560x1600
|
||||
// - Logical resolution: 1074x1065
|
||||
// - Scale factor: 150%
|
||||
// Passing physical dimensions and scale factor prevents accurate logical resolution calculation
|
||||
// since 2560/1.5 = 1706.666... (rounded to 1706.67) and 1600/1.5 = 1066.666... (rounded to 1066.67)
|
||||
// h.insert("scale", (d.scale * 100.0f64) as i32);
|
||||
|
||||
// Send scaled_width for accurate logical scale calculation.
|
||||
if d.scale > 0.0 {
|
||||
let scaled_width = (d.width as f64 / d.scale).round() as i32;
|
||||
h.insert("scaled_width", scaled_width);
|
||||
}
|
||||
msg_vec.push(h);
|
||||
}
|
||||
serde_json::ser::to_string(&msg_vec).unwrap_or("".to_owned())
|
||||
@@ -679,7 +694,7 @@ impl InvokeUiSession for FlutterHandler {
|
||||
}
|
||||
|
||||
/// unused in flutter, use switch_display or set_peer_info
|
||||
fn set_display(&self, _x: i32, _y: i32, _w: i32, _h: i32, _cursor_embedded: bool) {}
|
||||
fn set_display(&self, _x: i32, _y: i32, _w: i32, _h: i32, _cursor_embedded: bool, _scale: f64) {}
|
||||
|
||||
fn update_privacy_mode(&self) {
|
||||
self.push_event::<&str>("update_privacy_mode", &[], &[]);
|
||||
@@ -2103,6 +2118,26 @@ pub mod sessions {
|
||||
s
|
||||
}
|
||||
|
||||
/// Check if removing a session by session_id would result in removing the entire peer.
|
||||
///
|
||||
/// Returns:
|
||||
/// - `true`: The session exists and removing it would leave the peer with no other sessions,
|
||||
/// so the entire peer would be removed (equivalent to `remove_session_by_session_id` returning `Some`)
|
||||
/// - `false`: The session doesn't exist, or it exists but the peer has other sessions,
|
||||
/// so the peer would not be removed (equivalent to `remove_session_by_session_id` returning `None`)
|
||||
#[inline]
|
||||
pub fn would_remove_peer_by_session_id(id: &SessionID) -> bool {
|
||||
for (_peer_key, s) in SESSIONS.read().unwrap().iter() {
|
||||
let read_lock = s.ui_handler.session_handlers.read().unwrap();
|
||||
if read_lock.contains_key(id) {
|
||||
// Found the session, check if it's the only one for this peer
|
||||
return read_lock.len() == 1;
|
||||
}
|
||||
}
|
||||
// Session not found
|
||||
false
|
||||
}
|
||||
|
||||
fn check_remove_unused_displays(
|
||||
current: Option<usize>,
|
||||
session_id: &SessionID,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
use crate::keyboard::input_source::{change_input_source, get_cur_session_input_source};
|
||||
#[cfg(target_os = "linux")]
|
||||
use crate::platform::linux::is_x11;
|
||||
use crate::{
|
||||
client::file_trait::FileManager,
|
||||
common::{make_fd_to_json, make_vec_fd_to_json},
|
||||
@@ -70,6 +72,10 @@ fn initialize(app_dir: &str, custom_client_config: &str) {
|
||||
init_from_env(Env::default().filter_or(DEFAULT_FILTER_ENV, "debug"));
|
||||
crate::common::test_nat_type();
|
||||
}
|
||||
#[cfg(any(target_os = "android", target_os = "ios"))]
|
||||
{
|
||||
let _ = crate::common::global_init();
|
||||
}
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
{
|
||||
// core_main's init_log does not work for flutter since it is only applied to its load_library in main.c
|
||||
@@ -250,6 +256,10 @@ pub fn session_get_enable_trusted_devices(session_id: SessionID) -> SyncReturn<b
|
||||
SyncReturn(v)
|
||||
}
|
||||
|
||||
pub fn will_session_close_close_session(session_id: SessionID) -> SyncReturn<bool> {
|
||||
SyncReturn(sessions::would_remove_peer_by_session_id(&session_id))
|
||||
}
|
||||
|
||||
pub fn session_close(session_id: SessionID) {
|
||||
if let Some(session) = sessions::remove_session_by_session_id(&session_id) {
|
||||
// `release_remote_keys` is not required for mobile platforms in common cases.
|
||||
@@ -273,7 +283,10 @@ pub fn session_take_screenshot(session_id: SessionID, display: usize) {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn session_handle_screenshot(#[allow(unused_variables)] session_id: SessionID, action: String) -> String {
|
||||
pub fn session_handle_screenshot(
|
||||
#[allow(unused_variables)] session_id: SessionID,
|
||||
action: String,
|
||||
) -> String {
|
||||
crate::client::screenshot::handle_screenshot(action)
|
||||
}
|
||||
|
||||
@@ -393,6 +406,20 @@ pub fn session_set_scroll_style(session_id: SessionID, value: String) {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn session_get_edge_scroll_edge_thickness(session_id: SessionID) -> Option<i32> {
|
||||
if let Some(session) = sessions::get_session_by_session_id(&session_id) {
|
||||
Some(session.get_edge_scroll_edge_thickness())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn session_set_edge_scroll_edge_thickness(session_id: SessionID, value: i32) {
|
||||
if let Some(session) = sessions::get_session_by_session_id(&session_id) {
|
||||
session.save_edge_scroll_edge_thickness(value);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn session_get_image_quality(session_id: SessionID) -> Option<String> {
|
||||
if let Some(session) = sessions::get_session_by_session_id(&session_id) {
|
||||
Some(session.get_image_quality())
|
||||
@@ -951,10 +978,19 @@ pub fn main_set_option(key: String, value: String) {
|
||||
);
|
||||
}
|
||||
|
||||
if key.eq("custom-rendezvous-server")
|
||||
// If `is_allow_tls_fallback` and https proxy is used, we need to restart rendezvous mediator.
|
||||
// No need to check if https proxy is used, because this option does not change frequently
|
||||
// and restarting mediator is safe even https proxy is not used.
|
||||
let is_allow_tls_fallback = key.eq(config::keys::OPTION_ALLOW_INSECURE_TLS_FALLBACK);
|
||||
if is_allow_tls_fallback
|
||||
|| key.eq("custom-rendezvous-server")
|
||||
|| key.eq(config::keys::OPTION_ALLOW_WEBSOCKET)
|
||||
|| key.eq(config::keys::OPTION_DISABLE_UDP)
|
||||
|| key.eq("api-server")
|
||||
{
|
||||
if is_allow_tls_fallback {
|
||||
hbb_common::tls::reset_tls_cache();
|
||||
}
|
||||
set_option(key, value.clone());
|
||||
#[cfg(target_os = "android")]
|
||||
crate::rendezvous_mediator::RendezvousMediator::restart();
|
||||
@@ -1428,20 +1464,7 @@ pub fn main_handle_relay_id(id: String) -> String {
|
||||
}
|
||||
|
||||
pub fn main_is_option_fixed(key: String) -> SyncReturn<bool> {
|
||||
SyncReturn(
|
||||
config::OVERWRITE_DISPLAY_SETTINGS
|
||||
.read()
|
||||
.unwrap()
|
||||
.contains_key(&key)
|
||||
|| config::OVERWRITE_LOCAL_SETTINGS
|
||||
.read()
|
||||
.unwrap()
|
||||
.contains_key(&key)
|
||||
|| config::OVERWRITE_SETTINGS
|
||||
.read()
|
||||
.unwrap()
|
||||
.contains_key(&key),
|
||||
)
|
||||
SyncReturn(is_option_fixed(&key))
|
||||
}
|
||||
|
||||
pub fn main_get_main_display() -> SyncReturn<String> {
|
||||
@@ -1450,19 +1473,45 @@ pub fn main_get_main_display() -> SyncReturn<String> {
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
let mut display_info = "".to_owned();
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
if let Ok(displays) = crate::display_service::try_get_displays() {
|
||||
// to-do: Need to detect current display index.
|
||||
if let Some(display) = displays.iter().next() {
|
||||
display_info = serde_json::to_string(&HashMap::from([
|
||||
("w", display.width()),
|
||||
("h", display.height()),
|
||||
]))
|
||||
.unwrap_or_default();
|
||||
{
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let is_linux_wayland = false;
|
||||
#[cfg(target_os = "linux")]
|
||||
let is_linux_wayland = !is_x11();
|
||||
|
||||
if !is_linux_wayland {
|
||||
if let Ok(displays) = crate::display_service::try_get_displays() {
|
||||
// to-do: Need to detect current display index.
|
||||
if let Some(display) = displays.iter().next() {
|
||||
display_info = serde_json::to_string(&HashMap::from([
|
||||
("w", display.width()),
|
||||
("h", display.height()),
|
||||
]))
|
||||
.unwrap_or_default();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
if is_linux_wayland {
|
||||
let displays = scrap::wayland::display::get_displays();
|
||||
if let Some(display) = displays.displays.get(displays.primary) {
|
||||
let logical_size = display
|
||||
.logical_size
|
||||
.unwrap_or((display.width, display.height));
|
||||
display_info = serde_json::to_string(&HashMap::from([
|
||||
("w", logical_size.0),
|
||||
("h", logical_size.1),
|
||||
]))
|
||||
.unwrap_or_default();
|
||||
}
|
||||
}
|
||||
}
|
||||
SyncReturn(display_info)
|
||||
}
|
||||
|
||||
// No need to check if is on Wayland in this function.
|
||||
// The Flutter side gets display information on Wayland using a different method.
|
||||
pub fn main_get_displays() -> SyncReturn<String> {
|
||||
#[cfg(target_os = "ios")]
|
||||
let display_info = "".to_owned();
|
||||
@@ -1760,6 +1809,36 @@ pub fn session_send_note(session_id: SessionID, note: String) {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn session_get_last_audit_note(session_id: SessionID) -> SyncReturn<String> {
|
||||
if let Some(session) = sessions::get_session_by_session_id(&session_id) {
|
||||
SyncReturn(session.last_audit_note.lock().unwrap().clone())
|
||||
} else {
|
||||
SyncReturn("".to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn session_set_audit_guid(session_id: SessionID, guid: String) {
|
||||
if let Some(session) = sessions::get_session_by_session_id(&session_id) {
|
||||
*session.audit_guid.lock().unwrap() = guid;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn session_get_audit_guid(session_id: SessionID) -> SyncReturn<String> {
|
||||
if let Some(session) = sessions::get_session_by_session_id(&session_id) {
|
||||
SyncReturn(session.audit_guid.lock().unwrap().clone())
|
||||
} else {
|
||||
SyncReturn("".to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn session_get_conn_session_id(session_id: SessionID) -> SyncReturn<String> {
|
||||
if let Some(session) = sessions::get_session_by_session_id(&session_id) {
|
||||
SyncReturn(session.lc.read().unwrap().session_id.to_string())
|
||||
} else {
|
||||
SyncReturn("".to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn session_alternative_codecs(session_id: SessionID) -> String {
|
||||
if let Some(session) = sessions::get_session_by_session_id(&session_id) {
|
||||
let (vp8, av1, h264, h265) = session.alternative_codecs();
|
||||
@@ -2692,7 +2771,11 @@ pub fn session_get_common_sync(
|
||||
SyncReturn(session_get_common(session_id, key, param))
|
||||
}
|
||||
|
||||
pub fn session_get_common(session_id: SessionID, key: String, #[allow(unused_variables)] param: String) -> Option<String> {
|
||||
pub fn session_get_common(
|
||||
session_id: SessionID,
|
||||
key: String,
|
||||
#[allow(unused_variables)] param: String,
|
||||
) -> Option<String> {
|
||||
if let Some(s) = sessions::get_session_by_session_id(&session_id) {
|
||||
let v = if key == "is_screenshot_supported" {
|
||||
s.is_screenshot_supported().to_string()
|
||||
|
||||
@@ -4,12 +4,14 @@ use serde_json::{Map, Value};
|
||||
|
||||
#[cfg(feature = "flutter")]
|
||||
pub mod account;
|
||||
pub mod downloader;
|
||||
mod http_client;
|
||||
pub mod record_upload;
|
||||
pub mod sync;
|
||||
pub mod downloader;
|
||||
pub use http_client::create_http_client;
|
||||
pub use http_client::create_http_client_async;
|
||||
pub use http_client::{
|
||||
create_http_client_async, create_http_client_async_with_url, create_http_client_with_url,
|
||||
get_url_for_tls,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum HbbHttpResponse<T> {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::HbbHttpResponse;
|
||||
use crate::hbbs_http::create_http_client;
|
||||
use crate::hbbs_http::create_http_client_with_url;
|
||||
use hbb_common::{config::LocalConfig, log, ResultType};
|
||||
use reqwest::blocking::Client;
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
@@ -104,7 +104,7 @@ pub struct AuthBody {
|
||||
}
|
||||
|
||||
pub struct OidcSession {
|
||||
client: Client,
|
||||
client: Option<Client>,
|
||||
state_msg: &'static str,
|
||||
failed_msg: String,
|
||||
code_url: Option<OidcAuthUrl>,
|
||||
@@ -131,7 +131,7 @@ impl Default for UserStatus {
|
||||
impl OidcSession {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
client: create_http_client(),
|
||||
client: None,
|
||||
state_msg: REQUESTING_ACCOUNT_AUTH,
|
||||
failed_msg: "".to_owned(),
|
||||
code_url: None,
|
||||
@@ -142,24 +142,36 @@ impl OidcSession {
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_client(api_server: &str) {
|
||||
let mut write_guard = OIDC_SESSION.write().unwrap();
|
||||
if write_guard.client.is_none() {
|
||||
// This URL is used to detect the appropriate TLS implementation for the server.
|
||||
let login_option_url = format!("{}/api/login-options", &api_server);
|
||||
let client = create_http_client_with_url(&login_option_url);
|
||||
write_guard.client = Some(client);
|
||||
}
|
||||
}
|
||||
|
||||
fn auth(
|
||||
api_server: &str,
|
||||
op: &str,
|
||||
id: &str,
|
||||
uuid: &str,
|
||||
) -> ResultType<HbbHttpResponse<OidcAuthUrl>> {
|
||||
let resp = OIDC_SESSION
|
||||
.read()
|
||||
.unwrap()
|
||||
.client
|
||||
.post(format!("{}/api/oidc/auth", api_server))
|
||||
.json(&serde_json::json!({
|
||||
"op": op,
|
||||
"id": id,
|
||||
"uuid": uuid,
|
||||
"deviceInfo": crate::ui_interface::get_login_device_info(),
|
||||
}))
|
||||
.send()?;
|
||||
Self::ensure_client(api_server);
|
||||
let resp = if let Some(client) = &OIDC_SESSION.read().unwrap().client {
|
||||
client
|
||||
.post(format!("{}/api/oidc/auth", api_server))
|
||||
.json(&serde_json::json!({
|
||||
"op": op,
|
||||
"id": id,
|
||||
"uuid": uuid,
|
||||
"deviceInfo": crate::ui_interface::get_login_device_info(),
|
||||
}))
|
||||
.send()?
|
||||
} else {
|
||||
hbb_common::bail!("http client not initialized");
|
||||
};
|
||||
let status = resp.status();
|
||||
match resp.try_into() {
|
||||
Ok(v) => Ok(v),
|
||||
@@ -179,13 +191,12 @@ impl OidcSession {
|
||||
&format!("{}/api/oidc/auth-query", api_server),
|
||||
&[("code", code), ("id", id), ("uuid", uuid)],
|
||||
)?;
|
||||
Ok(OIDC_SESSION
|
||||
.read()
|
||||
.unwrap()
|
||||
.client
|
||||
.get(url)
|
||||
.send()?
|
||||
.try_into()?)
|
||||
Self::ensure_client(api_server);
|
||||
if let Some(client) = &OIDC_SESSION.read().unwrap().client {
|
||||
Ok(client.get(url).send()?.try_into()?)
|
||||
} else {
|
||||
hbb_common::bail!("http client not initialized")
|
||||
}
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::create_http_client_async;
|
||||
use super::create_http_client_async_with_url;
|
||||
use hbb_common::{
|
||||
bail,
|
||||
lazy_static::lazy_static,
|
||||
@@ -132,7 +132,7 @@ async fn do_download(
|
||||
auto_del_dur: Option<Duration>,
|
||||
mut rx_cancel: UnboundedReceiver<()>,
|
||||
) -> ResultType<bool> {
|
||||
let client = create_http_client_async();
|
||||
let client = create_http_client_async_with_url(&url).await;
|
||||
|
||||
let mut is_all_downloaded = false;
|
||||
tokio::select! {
|
||||
|
||||
@@ -1,42 +1,74 @@
|
||||
use hbb_common::config::Config;
|
||||
use hbb_common::log::info;
|
||||
use hbb_common::proxy::{Proxy, ProxyScheme};
|
||||
use reqwest::blocking::Client as SyncClient;
|
||||
use reqwest::Client as AsyncClient;
|
||||
use hbb_common::{
|
||||
async_recursion::async_recursion,
|
||||
config::{Config, Socks5Server},
|
||||
log::{self, info},
|
||||
proxy::{Proxy, ProxyScheme},
|
||||
tls::{
|
||||
get_cached_tls_accept_invalid_cert, get_cached_tls_type, is_plain, upsert_tls_cache,
|
||||
TlsType,
|
||||
},
|
||||
};
|
||||
use reqwest::{blocking::Client as SyncClient, Client as AsyncClient};
|
||||
|
||||
macro_rules! configure_http_client {
|
||||
($builder:expr, $Client: ty) => {{
|
||||
($builder:expr, $tls_type:expr, $danger_accept_invalid_cert:expr, $Client: ty) => {{
|
||||
// https://github.com/rustdesk/rustdesk/issues/11569
|
||||
// https://docs.rs/reqwest/latest/reqwest/struct.ClientBuilder.html#method.no_proxy
|
||||
let mut builder = $builder.no_proxy();
|
||||
|
||||
match $tls_type {
|
||||
TlsType::Plain => {}
|
||||
TlsType::NativeTls => {
|
||||
builder = builder.use_native_tls();
|
||||
if $danger_accept_invalid_cert {
|
||||
builder = builder.danger_accept_invalid_certs(true);
|
||||
}
|
||||
}
|
||||
TlsType::Rustls => {
|
||||
#[cfg(any(target_os = "android", target_os = "ios"))]
|
||||
match hbb_common::verifier::client_config($danger_accept_invalid_cert) {
|
||||
Ok(client_config) => {
|
||||
builder = builder.use_preconfigured_tls(client_config);
|
||||
}
|
||||
Err(e) => {
|
||||
hbb_common::log::error!("Failed to get client config: {}", e);
|
||||
}
|
||||
}
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
{
|
||||
builder = builder.use_rustls_tls();
|
||||
if $danger_accept_invalid_cert {
|
||||
builder = builder.danger_accept_invalid_certs(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let client = if let Some(conf) = Config::get_socks() {
|
||||
let proxy_result = Proxy::from_conf(&conf, None);
|
||||
|
||||
match proxy_result {
|
||||
Ok(proxy) => {
|
||||
let proxy_setup = match &proxy.intercept {
|
||||
ProxyScheme::Http { host, .. } =>{ reqwest::Proxy::all(format!("http://{}", host))},
|
||||
ProxyScheme::Https { host, .. } => {reqwest::Proxy::all(format!("https://{}", host))},
|
||||
ProxyScheme::Socks5 { addr, .. } => { reqwest::Proxy::all(&format!("socks5://{}", addr)) }
|
||||
ProxyScheme::Http { host, .. } => {
|
||||
reqwest::Proxy::all(format!("http://{}", host))
|
||||
}
|
||||
ProxyScheme::Https { host, .. } => {
|
||||
reqwest::Proxy::all(format!("https://{}", host))
|
||||
}
|
||||
ProxyScheme::Socks5 { addr, .. } => {
|
||||
reqwest::Proxy::all(&format!("socks5://{}", addr))
|
||||
}
|
||||
};
|
||||
|
||||
match proxy_setup {
|
||||
Ok(p) => {
|
||||
builder = builder.proxy(p);
|
||||
Ok(mut p) => {
|
||||
if let Some(auth) = proxy.intercept.maybe_auth() {
|
||||
let basic_auth =
|
||||
format!("Basic {}", auth.get_basic_authorization());
|
||||
if let Ok(auth) = basic_auth.parse() {
|
||||
builder = builder.default_headers(
|
||||
vec![(
|
||||
reqwest::header::PROXY_AUTHORIZATION,
|
||||
auth,
|
||||
)]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
);
|
||||
if !auth.username().is_empty() && !auth.password().is_empty() {
|
||||
p = p.basic_auth(auth.username(), auth.password());
|
||||
}
|
||||
}
|
||||
builder = builder.proxy(p);
|
||||
builder.build().unwrap_or_else(|e| {
|
||||
info!("Failed to create a proxied client: {}", e);
|
||||
<$Client>::new()
|
||||
@@ -64,12 +96,241 @@ macro_rules! configure_http_client {
|
||||
}};
|
||||
}
|
||||
|
||||
pub fn create_http_client() -> SyncClient {
|
||||
pub fn create_http_client(tls_type: TlsType, danger_accept_invalid_cert: bool) -> SyncClient {
|
||||
let builder = SyncClient::builder();
|
||||
configure_http_client!(builder, SyncClient)
|
||||
configure_http_client!(builder, tls_type, danger_accept_invalid_cert, SyncClient)
|
||||
}
|
||||
|
||||
pub fn create_http_client_async() -> AsyncClient {
|
||||
pub fn create_http_client_async(
|
||||
tls_type: TlsType,
|
||||
danger_accept_invalid_cert: bool,
|
||||
) -> AsyncClient {
|
||||
let builder = AsyncClient::builder();
|
||||
configure_http_client!(builder, AsyncClient)
|
||||
configure_http_client!(builder, tls_type, danger_accept_invalid_cert, AsyncClient)
|
||||
}
|
||||
|
||||
pub fn get_url_for_tls<'a>(url: &'a str, proxy_conf: &'a Option<Socks5Server>) -> &'a str {
|
||||
if is_plain(url) {
|
||||
if let Some(conf) = proxy_conf {
|
||||
if conf.proxy.starts_with("https://") {
|
||||
return &conf.proxy;
|
||||
}
|
||||
}
|
||||
}
|
||||
url
|
||||
}
|
||||
|
||||
pub fn create_http_client_with_url(url: &str) -> SyncClient {
|
||||
let proxy_conf = Config::get_socks();
|
||||
let tls_url = get_url_for_tls(url, &proxy_conf);
|
||||
let tls_type = get_cached_tls_type(tls_url);
|
||||
let is_tls_type_cached = tls_type.is_some();
|
||||
let tls_type = tls_type.unwrap_or(TlsType::Rustls);
|
||||
let tls_danger_accept_invalid_cert = get_cached_tls_accept_invalid_cert(tls_url);
|
||||
create_http_client_with_url_(
|
||||
url,
|
||||
tls_url,
|
||||
tls_type,
|
||||
is_tls_type_cached,
|
||||
tls_danger_accept_invalid_cert,
|
||||
tls_danger_accept_invalid_cert,
|
||||
)
|
||||
}
|
||||
|
||||
fn create_http_client_with_url_(
|
||||
url: &str,
|
||||
tls_url: &str,
|
||||
tls_type: TlsType,
|
||||
is_tls_type_cached: bool,
|
||||
danger_accept_invalid_cert: Option<bool>,
|
||||
original_danger_accept_invalid_cert: Option<bool>,
|
||||
) -> SyncClient {
|
||||
let mut client = create_http_client(tls_type, danger_accept_invalid_cert.unwrap_or(false));
|
||||
if is_tls_type_cached && original_danger_accept_invalid_cert.is_some() {
|
||||
return client;
|
||||
}
|
||||
if let Err(e) = client.head(url).send() {
|
||||
if e.is_request() {
|
||||
match (tls_type, is_tls_type_cached, danger_accept_invalid_cert) {
|
||||
(TlsType::Rustls, _, None) => {
|
||||
log::warn!(
|
||||
"Failed to connect to server {} with rustls-tls: {:?}, trying accept invalid cert",
|
||||
tls_url,
|
||||
e
|
||||
);
|
||||
client = create_http_client_with_url_(
|
||||
url,
|
||||
tls_url,
|
||||
tls_type,
|
||||
is_tls_type_cached,
|
||||
Some(true),
|
||||
original_danger_accept_invalid_cert,
|
||||
);
|
||||
}
|
||||
(TlsType::Rustls, false, Some(_)) => {
|
||||
log::warn!(
|
||||
"Failed to connect to server {} with rustls-tls: {:?}, trying native-tls",
|
||||
tls_url,
|
||||
e
|
||||
);
|
||||
client = create_http_client_with_url_(
|
||||
url,
|
||||
tls_url,
|
||||
TlsType::NativeTls,
|
||||
is_tls_type_cached,
|
||||
original_danger_accept_invalid_cert,
|
||||
original_danger_accept_invalid_cert,
|
||||
);
|
||||
}
|
||||
(TlsType::NativeTls, _, None) => {
|
||||
log::warn!(
|
||||
"Failed to connect to server {} with native-tls: {:?}, trying accept invalid cert",
|
||||
tls_url,
|
||||
e
|
||||
);
|
||||
client = create_http_client_with_url_(
|
||||
url,
|
||||
tls_url,
|
||||
tls_type,
|
||||
is_tls_type_cached,
|
||||
Some(true),
|
||||
original_danger_accept_invalid_cert,
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
log::error!(
|
||||
"Failed to connect to server {} with {:?}, err: {:?}.",
|
||||
tls_url,
|
||||
tls_type,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log::warn!(
|
||||
"Failed to connect to server {} with {:?}, err: {}.",
|
||||
tls_url,
|
||||
tls_type,
|
||||
e
|
||||
);
|
||||
}
|
||||
} else {
|
||||
log::info!(
|
||||
"Successfully connected to server {} with {:?}",
|
||||
tls_url,
|
||||
tls_type
|
||||
);
|
||||
upsert_tls_cache(
|
||||
tls_url,
|
||||
tls_type,
|
||||
danger_accept_invalid_cert.unwrap_or(false),
|
||||
);
|
||||
}
|
||||
client
|
||||
}
|
||||
|
||||
pub async fn create_http_client_async_with_url(url: &str) -> AsyncClient {
|
||||
let proxy_conf = Config::get_socks();
|
||||
let tls_url = get_url_for_tls(url, &proxy_conf);
|
||||
let tls_type = get_cached_tls_type(tls_url);
|
||||
let is_tls_type_cached = tls_type.is_some();
|
||||
let tls_type = tls_type.unwrap_or(TlsType::Rustls);
|
||||
let danger_accept_invalid_cert = get_cached_tls_accept_invalid_cert(tls_url);
|
||||
create_http_client_async_with_url_(
|
||||
url,
|
||||
tls_url,
|
||||
tls_type,
|
||||
is_tls_type_cached,
|
||||
danger_accept_invalid_cert,
|
||||
danger_accept_invalid_cert,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[async_recursion]
|
||||
async fn create_http_client_async_with_url_(
|
||||
url: &str,
|
||||
tls_url: &str,
|
||||
tls_type: TlsType,
|
||||
is_tls_type_cached: bool,
|
||||
danger_accept_invalid_cert: Option<bool>,
|
||||
original_danger_accept_invalid_cert: Option<bool>,
|
||||
) -> AsyncClient {
|
||||
let mut client =
|
||||
create_http_client_async(tls_type, danger_accept_invalid_cert.unwrap_or(false));
|
||||
if is_tls_type_cached && original_danger_accept_invalid_cert.is_some() {
|
||||
return client;
|
||||
}
|
||||
if let Err(e) = client.head(url).send().await {
|
||||
match (tls_type, is_tls_type_cached, danger_accept_invalid_cert) {
|
||||
(TlsType::Rustls, _, None) => {
|
||||
log::warn!(
|
||||
"Failed to connect to server {} with rustls-tls: {:?}, trying accept invalid cert",
|
||||
tls_url,
|
||||
e
|
||||
);
|
||||
client = create_http_client_async_with_url_(
|
||||
url,
|
||||
tls_url,
|
||||
tls_type,
|
||||
is_tls_type_cached,
|
||||
Some(true),
|
||||
original_danger_accept_invalid_cert,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
(TlsType::Rustls, false, Some(_)) => {
|
||||
log::warn!(
|
||||
"Failed to connect to server {} with rustls-tls: {:?}, trying native-tls",
|
||||
tls_url,
|
||||
e
|
||||
);
|
||||
client = create_http_client_async_with_url_(
|
||||
url,
|
||||
tls_url,
|
||||
TlsType::NativeTls,
|
||||
is_tls_type_cached,
|
||||
original_danger_accept_invalid_cert,
|
||||
original_danger_accept_invalid_cert,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
(TlsType::NativeTls, _, None) => {
|
||||
log::warn!(
|
||||
"Failed to connect to server {} with native-tls: {:?}, trying accept invalid cert",
|
||||
tls_url,
|
||||
e
|
||||
);
|
||||
client = create_http_client_async_with_url_(
|
||||
url,
|
||||
tls_url,
|
||||
tls_type,
|
||||
is_tls_type_cached,
|
||||
Some(true),
|
||||
original_danger_accept_invalid_cert,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
_ => {
|
||||
log::error!(
|
||||
"Failed to connect to server {} with {:?}, err: {:?}.",
|
||||
tls_url,
|
||||
tls_type,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log::info!(
|
||||
"Successfully connected to server {} with {:?}",
|
||||
tls_url,
|
||||
tls_type
|
||||
);
|
||||
upsert_tls_cache(
|
||||
tls_url,
|
||||
tls_type,
|
||||
danger_accept_invalid_cert.unwrap_or(false),
|
||||
);
|
||||
}
|
||||
client
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::hbbs_http::create_http_client;
|
||||
use crate::hbbs_http::create_http_client_with_url;
|
||||
use bytes::Bytes;
|
||||
use hbb_common::{bail, config::Config, lazy_static, log, ResultType};
|
||||
use reqwest::blocking::{Body, Client};
|
||||
@@ -25,51 +25,57 @@ pub fn is_enable() -> bool {
|
||||
}
|
||||
|
||||
pub fn run(rx: Receiver<RecordState>) {
|
||||
let mut uploader = RecordUploader {
|
||||
client: create_http_client(),
|
||||
api_server: crate::get_api_server(
|
||||
std::thread::spawn(move || {
|
||||
let api_server = crate::get_api_server(
|
||||
Config::get_option("api-server"),
|
||||
Config::get_option("custom-rendezvous-server"),
|
||||
),
|
||||
filepath: Default::default(),
|
||||
filename: Default::default(),
|
||||
upload_size: Default::default(),
|
||||
running: Default::default(),
|
||||
last_send: Instant::now(),
|
||||
};
|
||||
std::thread::spawn(move || loop {
|
||||
if let Err(e) = match rx.recv() {
|
||||
Ok(state) => match state {
|
||||
RecordState::NewFile(filepath) => uploader.handle_new_file(filepath),
|
||||
RecordState::NewFrame => {
|
||||
if uploader.running {
|
||||
uploader.handle_frame(false)
|
||||
} else {
|
||||
Ok(())
|
||||
);
|
||||
// This URL is used for TLS connectivity testing and fallback detection.
|
||||
let login_option_url = format!("{}/api/login-options", &api_server);
|
||||
let client = create_http_client_with_url(&login_option_url);
|
||||
let mut uploader = RecordUploader {
|
||||
client,
|
||||
api_server,
|
||||
filepath: Default::default(),
|
||||
filename: Default::default(),
|
||||
upload_size: Default::default(),
|
||||
running: Default::default(),
|
||||
last_send: Instant::now(),
|
||||
};
|
||||
loop {
|
||||
if let Err(e) = match rx.recv() {
|
||||
Ok(state) => match state {
|
||||
RecordState::NewFile(filepath) => uploader.handle_new_file(filepath),
|
||||
RecordState::NewFrame => {
|
||||
if uploader.running {
|
||||
uploader.handle_frame(false)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
RecordState::WriteTail => {
|
||||
if uploader.running {
|
||||
uploader.handle_tail()
|
||||
} else {
|
||||
Ok(())
|
||||
RecordState::WriteTail => {
|
||||
if uploader.running {
|
||||
uploader.handle_tail()
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
RecordState::RemoveFile => {
|
||||
if uploader.running {
|
||||
uploader.handle_remove()
|
||||
} else {
|
||||
Ok(())
|
||||
RecordState::RemoveFile => {
|
||||
if uploader.running {
|
||||
uploader.handle_remove()
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
log::trace!("upload thread stop: {}", e);
|
||||
break;
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
log::trace!("upload thread stop: {}", e);
|
||||
break;
|
||||
} {
|
||||
uploader.running = false;
|
||||
log::error!("upload stop: {}", e);
|
||||
}
|
||||
} {
|
||||
uploader.running = false;
|
||||
log::error!("upload stop: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
18
src/ipc.rs
18
src/ipc.rs
@@ -363,6 +363,8 @@ pub struct CheckIfRestart {
|
||||
audio_input: String,
|
||||
voice_call_input: String,
|
||||
ws: String,
|
||||
disable_udp: String,
|
||||
allow_insecure_tls_fallback: String,
|
||||
api_server: String,
|
||||
}
|
||||
|
||||
@@ -374,17 +376,31 @@ impl CheckIfRestart {
|
||||
audio_input: Config::get_option("audio-input"),
|
||||
voice_call_input: Config::get_option("voice-call-input"),
|
||||
ws: Config::get_option(OPTION_ALLOW_WEBSOCKET),
|
||||
disable_udp: Config::get_option(config::keys::OPTION_DISABLE_UDP),
|
||||
allow_insecure_tls_fallback: Config::get_option(
|
||||
config::keys::OPTION_ALLOW_INSECURE_TLS_FALLBACK,
|
||||
),
|
||||
api_server: Config::get_option("api-server"),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Drop for CheckIfRestart {
|
||||
fn drop(&mut self) {
|
||||
if self.stop_service != Config::get_option("stop-service")
|
||||
// If https proxy is used, we need to restart rendezvous mediator.
|
||||
// No need to check if https proxy is used, because this option does not change frequently
|
||||
// and restarting mediator is safe even https proxy is not used.
|
||||
let allow_insecure_tls_fallback_changed = self.allow_insecure_tls_fallback
|
||||
!= Config::get_option(config::keys::OPTION_ALLOW_INSECURE_TLS_FALLBACK);
|
||||
if allow_insecure_tls_fallback_changed
|
||||
|| self.stop_service != Config::get_option("stop-service")
|
||||
|| self.rendezvous_servers != Config::get_rendezvous_servers()
|
||||
|| self.ws != Config::get_option(OPTION_ALLOW_WEBSOCKET)
|
||||
|| self.disable_udp != Config::get_option(config::keys::OPTION_DISABLE_UDP)
|
||||
|| self.api_server != Config::get_option("api-server")
|
||||
{
|
||||
if allow_insecure_tls_fallback_changed {
|
||||
hbb_common::tls::reset_tls_cache();
|
||||
}
|
||||
RendezvousMediator::restart();
|
||||
}
|
||||
if self.audio_input != Config::get_option("audio-input") {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user