Compare commits

..

57 Commits
1.3.7 ... 1.3.8

Author SHA1 Message Date
princeyogesh
e191d11f74 before docker run command added git submoulde update command in docker build Updated README.md (#10878)
there is dependency on submodule libs/hbb_common, 
we need to initialize submodule after cloning repo and before running container
2025-02-22 13:17:11 +08:00
fufesou
fc396d2166 fix: check text editing controlling, if selection is valid (#10868)
Signed-off-by: fufesou <linlong1266@gmail.com>
2025-02-21 11:00:19 +08:00
Theofanis Sarmidis
2575e14811 Update el.rs (#10866) 2025-02-21 10:42:12 +08:00
fufesou
0b9a6a280e fix: remote id, update text and reserve selection (#10867)
Signed-off-by: fufesou <linlong1266@gmail.com>
2025-02-21 10:41:57 +08:00
fufesou
343f12b380 fix: load local peers, called two times on select tab (#10859)
Signed-off-by: fufesou <linlong1266@gmail.com>
2025-02-20 22:47:42 +08:00
fufesou
ce1e4863cb fix: load peers, always push event data (#10856)
Signed-off-by: fufesou <linlong1266@gmail.com>
2025-02-20 22:44:18 +08:00
fufesou
f631c1c28d refact: Remote ID editor, only select text on focus (#10854)
Signed-off-by: fufesou <linlong1266@gmail.com>
2025-02-20 19:35:04 +08:00
fufesou
8b9a7a3506 refact: optimize, ID search peers (#10853)
* refact: optimize, preload peers

Signed-off-by: fufesou <linlong1266@gmail.com>

* Update dialogs.dart

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
Co-authored-by: RustDesk <71636191+rustdesk@users.noreply.github.com>
2025-02-20 18:31:12 +08:00
fufesou
055b351164 refact: optimize, loading recent peers (#10847)
Signed-off-by: fufesou <linlong1266@gmail.com>
2025-02-20 11:53:36 +08:00
fufesou
2e89a33210 fix: android, back function (#10843)
Signed-off-by: fufesou <linlong1266@gmail.com>
2025-02-20 01:02:24 +08:00
Alex Rijckaert
965cc6af26 Update nl.rs (#10834) 2025-02-20 00:25:23 +08:00
rustdesk
16e191f913 fix de 2025-02-20 00:24:24 +08:00
fufesou
1d1e79c802 revert, peers card, sort by online status (#10829)
Signed-off-by: fufesou <linlong1266@gmail.com>
2025-02-19 14:14:02 +08:00
Mr-Update
9ffe516f54 Update de.rs (#10827) 2025-02-19 08:42:15 +08:00
solokot
ac20d2fb56 Update ru.rs (#10794) 2025-02-19 01:11:36 +08:00
bovirus
2217152216 Italian language update (#10796) 2025-02-19 01:11:22 +08:00
XLion
ee288280b3 Update tw.rs (#10799) 2025-02-19 01:11:08 +08:00
John Fowler
cccdb2f289 Update hu.rs (#10804)
Translate new strings.
Clarification of some translations.
2025-02-19 01:10:52 +08:00
ANB5Dev
1ddab27c0e NL lang further improvements (#10813) 2025-02-19 01:10:41 +08:00
Alex Rijckaert
451b6dc651 Update nl.rs (#10812) 2025-02-19 01:10:32 +08:00
rustdesk
86b327ee41 they always forget to remove :21114 for https, so I remove for them 2025-02-18 16:18:41 +08:00
rustdesk
6e305d4865 improve sysinfo update 2025-02-18 16:09:25 +08:00
21pages
77af6c4ce1 clear selected device group or user when search text changes (#10815)
Signed-off-by: 21pages <sunboeasy@gmail.com>
2025-02-18 09:08:38 +08:00
21pages
fa49c72835 fix, accessible peers filter considering device group name (#10809)
Signed-off-by: 21pages <sunboeasy@gmail.com>
2025-02-17 17:36:47 +08:00
rustdesk
c150143d86 device_group_name in devices.py 2025-02-17 16:28:47 +08:00
fufesou
023d46b48c refact: android, handle right click (#10806)
Signed-off-by: fufesou <linlong1266@gmail.com>
2025-02-16 20:45:09 +08:00
rustdesk
356adbcd8c more about allow - 2025-02-15 19:00:43 +08:00
rustdesk
33b47dd6e3 allow dash in id 2025-02-15 18:51:30 +08:00
fufesou
a548e9c94d fix: android, controlled side, gesture (#10792)
Signed-off-by: fufesou <linlong1266@gmail.com>
2025-02-15 18:33:26 +08:00
21pages
cefda0dec1 device group (#10781)
1. Rename `Group` tab to `Accessible devices`
2. Add accessible device groups at the top of search list
3. option `preset-device-group-name` and command line `--assign --device_group_name`

Signed-off-by: 21pages <sunboeasy@gmail.com>
2025-02-15 12:13:11 +08:00
rustdesk
8f545491a2 verify_login, but not eable yet 2025-02-14 16:39:09 +08:00
rustdesk
263bbfc66f missed clear 2025-02-12 17:04:56 +08:00
fufesou
a039741e5a fix: win10, border (#10753)
Signed-off-by: fufesou <shuanglongchen@yeah.net>
2025-02-10 13:50:28 +08:00
fufesou
2a0e8c109b fix: macos, main window, dark theme, border (#10749)
Signed-off-by: fufesou <linlong1266@gmail.com>
2025-02-10 00:25:11 +08:00
rustdesk
9614bf266a update READEME 2025-02-08 16:03:04 +08:00
rustdesk
aa63ebc7e5 Misuse Disclaimer 2025-02-05 15:28:30 +08:00
fufesou
fbba8f0b34 refact: file copy&paste, cross platform (no macOS) (#10671)
* feat: unix, file copy&paste

Signed-off-by: fufesou <linlong1266@gmail.com>

* refact: unix file c&p, check peer version

Signed-off-by: fufesou <linlong1266@gmail.com>

* Update pubspec.yaml

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
Co-authored-by: RustDesk <71636191+rustdesk@users.noreply.github.com>
2025-02-04 20:33:02 +08:00
ANB5Dev
a27fa43081 Update NL translation: spelling, capitalization, missing entries (#10661) 2025-02-03 22:18:20 +08:00
rustdesk
7a5941de98 remove devcontainer since not maintained yet 2025-02-02 20:31:44 +08:00
rustdesk
db3ca6a373 remove useless code 2025-02-01 13:07:27 +08:00
rustdesk
ce5f0d513f 2024 -> 2025 2025-01-31 16:54:57 +08:00
rustdesk
05b0f95b79 restore entrypoint.sh 2025-01-30 13:53:02 +08:00
rustdesk
8b24b195a2 remove useless files 2025-01-30 13:49:37 +08:00
rustdesk
5fc8e8c428 remove PUBLIC_RS_PUB_KE 2025-01-29 16:57:28 +08:00
RustDesk
25f917a7b4 misused by bad guys (#10614) 2025-01-28 16:16:00 +08:00
fufesou
55005f8129 fix: win, file clipboard, try empty (#10609)
Signed-off-by: fufesou <linlong1266@gmail.com>
2025-01-27 16:16:44 +08:00
fufesou
f08cb0412d fix: windows, dll, pre-loading attack (#10608)
Signed-off-by: fufesou <linlong1266@gmail.com>
2025-01-26 19:39:38 +08:00
XLion
fc2e27bcf0 Create dependabot.yml (#10593) 2025-01-26 14:18:26 +08:00
Theofanis Sarmidis
7aa4592669 Update and fixes el.rs (#10600) 2025-01-25 16:39:16 +08:00
Y-Ploni
d656ae2956 Update he.rs (#10594) 2025-01-24 15:09:36 +08:00
RustDesk
e4f00361f6 Update README.md (#10587) 2025-01-23 13:24:14 +08:00
RustDesk
1b49d49df2 Update README.md (#10586) 2025-01-23 13:23:20 +08:00
bjoernp
80f759c1ed norwegian translation (#10579)
Signed-off-by: bjoernp116 <bjoernpollen@gmail.com>
2025-01-23 13:22:25 +08:00
21pages
da80f3352a fix vaapi create 2 times at first (#10576)
Signed-off-by: 21pages <sunboeasy@gmail.com>
2025-01-22 20:27:00 +08:00
Vasyl Gello
ec3ba5be8e Fix issues spotted during 1.3.7 fdroid build (#10570)
* bridge.yml: Explicitly install cargo-expand of certain version

@linsui spotted this trying to fix the build failure of 1.3.7
on f-droid:

https://gitlab.com/fdroid/fdroiddata/-/merge_requests/18766

* flutter-build.yml: drop workarounds for flutter 3.13

@fufesou has removed them from build_fdroid.sh in #10040 but
forgot to remove them in main flutter_build.yml. flutter 3.13
is not used anymore, and those who want to build the old version
using flutter 3.13 can happily check out the appropriate commit
from Git history.

* Bump vcpkg baseline to 2025.01.13

@linsui addressed the missing vcpkg-tools.json file inside vcpkg
revision (microsoft side, not rustdesk's!) by updating the vcpkg
baseline.
2025-01-22 09:26:03 +08:00
21pages
d04756ad70 replace self-hosted arm64 linux with ubuntu-22.04-arm (#10555)
https://github.blog/changelog/2025-01-16-linux-arm64-hosted-runners-now-available-for-free-in-public-repositories-public-preview/

Signed-off-by: 21pages <sunboeasy@gmail.com>
2025-01-21 17:09:24 +08:00
21pages
0eba939cd6 fix windows crash (#10562)
Signed-off-by: 21pages <sunboeasy@gmail.com>
2025-01-21 16:57:07 +08:00
163 changed files with 3952 additions and 5595 deletions

11
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,11 @@
version: 2
updates:
- package-ecosystem: "gitsubmodule"
directory: "/"
target-branch: "master"
schedule:
interval: "daily"
commit-message:
prefix: "Git submodule"
labels:
- "dependencies"

View File

@@ -6,6 +6,7 @@ on:
workflow_call: workflow_call:
env: env:
CARGO_EXPAND_VERSION: "1.0.95"
FLUTTER_VERSION: "3.22.3" FLUTTER_VERSION: "3.22.3"
FLUTTER_RUST_BRIDGE_VERSION: "1.80.1" FLUTTER_RUST_BRIDGE_VERSION: "1.80.1"
RUST_VERSION: "1.75" # https://github.com/rustdesk/rustdesk/discussions/7503 RUST_VERSION: "1.75" # https://github.com/rustdesk/rustdesk/discussions/7503
@@ -75,6 +76,7 @@ jobs:
- name: Install flutter rust bridge deps - name: Install flutter rust bridge deps
shell: bash shell: bash
run: | run: |
cargo install cargo-expand --version ${{ env.CARGO_EXPAND_VERSION }} --locked
cargo install flutter_rust_bridge_codegen --version ${{ env.FLUTTER_RUST_BRIDGE_VERSION }} --features "uuid" --locked cargo install flutter_rust_bridge_codegen --version ${{ env.FLUTTER_RUST_BRIDGE_VERSION }} --features "uuid" --locked
pushd flutter && sed -i -e 's/extended_text: 14.0.0/extended_text: 13.0.0/g' pubspec.yaml && flutter pub get && popd pushd flutter && sed -i -e 's/extended_text: 14.0.0/extended_text: 13.0.0/g' pubspec.yaml && flutter pub get && popd

View File

@@ -31,17 +31,13 @@ env:
FLUTTER_ELINUX_VERSION: "3.16.9" FLUTTER_ELINUX_VERSION: "3.16.9"
TAG_NAME: "${{ inputs.upload-tag }}" TAG_NAME: "${{ inputs.upload-tag }}"
VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite" VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite"
# vcpkg version: 2024.11.16 # vcpkg version: 2025.01.13
VCPKG_COMMIT_ID: "b2cb0da531c2f1f740045bfe7c4dac59f0b2b69c" VCPKG_COMMIT_ID: "6f29f12e82a8293156836ad81cc9bf5af41fe836"
VERSION: "1.3.7" VERSION: "1.3.8"
NDK_VERSION: "r27c" NDK_VERSION: "r27c"
#signing keys env variable checks #signing keys env variable checks
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}" ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"
MACOS_P12_BASE64: "${{ secrets.MACOS_P12_BASE64 }}" MACOS_P12_BASE64: "${{ secrets.MACOS_P12_BASE64 }}"
# To make a custom build with your own servers set the below secret values
RS_PUB_KEY: "${{ secrets.RS_PUB_KEY }}"
RENDEZVOUS_SERVER: "${{ secrets.RENDEZVOUS_SERVER }}"
API_SERVER: "${{ secrets.API_SERVER }}"
UPLOAD_ARTIFACT: "${{ inputs.upload-artifact }}" UPLOAD_ARTIFACT: "${{ inputs.upload-artifact }}"
SIGN_BASE_URL: "${{ secrets.SIGN_BASE_URL }}" SIGN_BASE_URL: "${{ secrets.SIGN_BASE_URL }}"
@@ -419,7 +415,7 @@ jobs:
- name: Build rustdesk - name: Build rustdesk
run: | run: |
./build.py --flutter --hwcodec ./build.py --flutter --hwcodec --unix-file-copy-paste
- name: create unsigned dmg - name: create unsigned dmg
if: env.UPLOAD_ARTIFACT == 'true' if: env.UPLOAD_ARTIFACT == 'true'
@@ -800,7 +796,7 @@ jobs:
sed -i -e "s/osx_minimum_system_version = \"[0-9]*.[0-9]*\"/osx_minimum_system_version = \"${MIN_MACOS_VERSION}\"/" Cargo.toml sed -i -e "s/osx_minimum_system_version = \"[0-9]*.[0-9]*\"/osx_minimum_system_version = \"${MIN_MACOS_VERSION}\"/" Cargo.toml
sed -i -e "s/MACOSX_DEPLOYMENT_TARGET = [0-9]*.[0-9]*;/MACOSX_DEPLOYMENT_TARGET = ${MIN_MACOS_VERSION};/" flutter/macos/Runner.xcodeproj/project.pbxproj sed -i -e "s/MACOSX_DEPLOYMENT_TARGET = [0-9]*.[0-9]*;/MACOSX_DEPLOYMENT_TARGET = ${MIN_MACOS_VERSION};/" flutter/macos/Runner.xcodeproj/project.pbxproj
fi fi
./build.py --flutter --hwcodec ${{ matrix.job.extra-build-args }} ./build.py --flutter --hwcodec --unix-file-copy-paste ${{ matrix.job.extra-build-args }}
- name: create unsigned dmg - name: create unsigned dmg
if: env.UPLOAD_ARTIFACT == 'true' if: env.UPLOAD_ARTIFACT == 'true'
@@ -1043,16 +1039,6 @@ jobs:
prefix-key: rustdesk-lib-cache-android # TODO: drop '-android' part after caches are invalidated prefix-key: rustdesk-lib-cache-android # TODO: drop '-android' part after caches are invalidated
key: ${{ matrix.job.target }} key: ${{ matrix.job.target }}
- name: fix android for flutter 3.13
if: ${{ env.ANDROID_FLUTTER_VERSION == '3.13.9' }}
run: |
cd flutter
sed -i 's/uni_links_desktop/#uni_links_desktop/g' pubspec.yaml
sed -i 's/extended_text: .*/extended_text: 11.1.0/' pubspec.yaml
flutter pub get
cd lib
find . | grep dart | xargs sed -i 's/textScaler: TextScaler.linear(\(.*\)),/textScaleFactor: \1,/g'
- name: Build rustdesk lib - name: Build rustdesk lib
env: env:
ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }} ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }}
@@ -1295,16 +1281,6 @@ jobs:
name: librustdesk.so.i686-linux-android name: librustdesk.so.i686-linux-android
path: ./flutter/android/app/src/main/jniLibs/x86 path: ./flutter/android/app/src/main/jniLibs/x86
- name: fix android for flutter 3.13
if: ${{ env.ANDROID_FLUTTER_VERSION == '3.13.9' }}
run: |
cd flutter
sed -i 's/uni_links_desktop/#uni_links_desktop/g' pubspec.yaml
sed -i 's/extended_text: .*/extended_text: 11.1.0/' pubspec.yaml
flutter pub get
cd lib
find . | grep dart | xargs sed -i 's/textScaler: TextScaler.linear(\(.*\)),/textScaleFactor: \1,/g'
- name: Build rustdesk - name: Build rustdesk
shell: bash shell: bash
env: env:
@@ -1398,7 +1374,7 @@ jobs:
arch: aarch64, arch: aarch64,
target: aarch64-unknown-linux-gnu, target: aarch64-unknown-linux-gnu,
distro: ubuntu18.04, distro: ubuntu18.04,
on: [self-hosted, Linux, ARM64], on: ubuntu-22.04-arm,
deb_arch: arm64, deb_arch: arm64,
vcpkg-triplet: arm64-linux, vcpkg-triplet: arm64-linux,
} }
@@ -1411,13 +1387,15 @@ jobs:
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || ''); core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
- name: Maximize build space - name: Maximize build space
if: ${{ matrix.job.arch == 'x86_64' }}
run: | run: |
sudo rm -rf /opt/ghc sudo rm -rf /opt/ghc
sudo rm -rf /usr/local/lib/android sudo rm -rf /usr/local/lib/android
sudo rm -rf /usr/share/dotnet sudo rm -rf /usr/share/dotnet
sudo apt-get update -y sudo apt-get update -y
sudo apt-get install -y nasm qemu-user-static sudo apt-get install -y nasm
if [[ "${{ matrix.job.arch }}" == "x86_64" ]]; then
sudo apt-get install -y qemu-user-static
fi
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -1576,7 +1554,7 @@ jobs:
export JOBS="" export JOBS=""
fi fi
echo $JOBS echo $JOBS
cargo build --lib $JOBS --features hwcodec,flutter --release cargo build --lib $JOBS --features hwcodec,flutter,unix-file-copy-paste --release
rm -rf target/release/deps target/release/build rm -rf target/release/deps target/release/build
rm -rf ~/.cargo rm -rf ~/.cargo
@@ -1713,7 +1691,6 @@ jobs:
build-rustdesk-linux-sciter: build-rustdesk-linux-sciter:
if: ${{ inputs.upload-artifact }} if: ${{ inputs.upload-artifact }}
needs: build-rustdesk-linux # not for dep, just make it run later for parallelism
runs-on: ${{ matrix.job.on }} runs-on: ${{ matrix.job.on }}
name: build-rustdesk-linux-sciter ${{ matrix.job.target }} name: build-rustdesk-linux-sciter ${{ matrix.job.target }}
strategy: strategy:
@@ -1729,17 +1706,17 @@ jobs:
deb_arch: amd64, deb_arch: amd64,
sciter_arch: x64, sciter_arch: x64,
vcpkg-triplet: x64-linux, vcpkg-triplet: x64-linux,
extra_features: ",hwcodec", extra_features: ",hwcodec,unix-file-copy-paste",
} }
- { - {
arch: armv7, arch: armv7,
target: armv7-unknown-linux-gnueabihf, target: armv7-unknown-linux-gnueabihf,
on: [self-hosted, Linux, ARM64], on: ubuntu-22.04-arm,
distro: ubuntu18.04-rustdesk, distro: ubuntu18.04-rustdesk,
deb_arch: armhf, deb_arch: armhf,
sciter_arch: arm32, sciter_arch: arm32,
vcpkg-triplet: arm-linux, vcpkg-triplet: arm-linux,
extra_features: "", extra_features: ",unix-file-copy-paste",
} }
steps: steps:
- name: Export GitHub Actions cache environment variables - name: Export GitHub Actions cache environment variables
@@ -2010,7 +1987,7 @@ jobs:
target: aarch64-unknown-linux-gnu, target: aarch64-unknown-linux-gnu,
# try out newer flatpak since error of "error: Nothing matches org.freedesktop.Platform in remote flathub" # try out newer flatpak since error of "error: Nothing matches org.freedesktop.Platform in remote flathub"
distro: ubuntu22.04, distro: ubuntu22.04,
on: [self-hosted, Linux, ARM64], on: ubuntu-22.04-arm,
arch: aarch64, arch: aarch64,
suffix: "", suffix: "",
} }

View File

@@ -18,7 +18,7 @@ env:
VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite" VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite"
# vcpkg version: 2024.11.16 # vcpkg version: 2024.11.16
VCPKG_COMMIT_ID: "b2cb0da531c2f1f740045bfe7c4dac59f0b2b69c" VCPKG_COMMIT_ID: "b2cb0da531c2f1f740045bfe7c4dac59f0b2b69c"
VERSION: "1.3.7" VERSION: "1.3.8"
NDK_VERSION: "r26d" NDK_VERSION: "r26d"
#signing keys env variable checks #signing keys env variable checks
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}" ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"

56
Cargo.lock generated
View File

@@ -224,7 +224,7 @@ checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da"
[[package]] [[package]]
name = "arboard" name = "arboard"
version = "3.4.0" version = "3.4.0"
source = "git+https://github.com/rustdesk-org/arboard#747ab2d9b40a5c9c5102051cf3b0bb38b4845e60" source = "git+https://github.com/rustdesk-org/arboard#4e16bad260ea05dd7dcdb68cc7549dad3920b940"
dependencies = [ dependencies = [
"clipboard-win", "clipboard-win",
"core-graphics 0.23.2", "core-graphics 0.23.2",
@@ -234,6 +234,7 @@ dependencies = [
"objc2-app-kit", "objc2-app-kit",
"objc2-foundation", "objc2-foundation",
"parking_lot", "parking_lot",
"percent-encoding",
"serde 1.0.203", "serde 1.0.203",
"serde_derive", "serde_derive",
"windows-sys 0.48.0", "windows-sys 0.48.0",
@@ -1584,7 +1585,7 @@ dependencies = [
[[package]] [[package]]
name = "default_net" name = "default_net"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/rustdesk-org/default_net#a831d47bcacb4615b394968287697924a8f62be1" source = "git+https://github.com/rustdesk-org/default_net#78f8f70cd85151a3a2c4a3230d80d5272703c02e"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"regex", "regex",
@@ -1707,7 +1708,7 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412"
dependencies = [ dependencies = [
"libloading 0.7.4", "libloading 0.8.4",
] ]
[[package]] [[package]]
@@ -2231,17 +2232,17 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
[[package]] [[package]]
name = "fuser" name = "fuser"
version = "0.13.0" version = "0.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21370f84640642c8ea36dfb2a6bfc4c55941f476fcf431f6fef25a5ddcf0169b" checksum = "53274f494609e77794b627b1a3cddfe45d675a6b2e9ba9c0fdc8d8eee2184369"
dependencies = [ dependencies = [
"libc", "libc",
"log", "log",
"memchr", "memchr",
"nix 0.29.0",
"page_size", "page_size",
"pkg-config",
"smallvec", "smallvec",
"zerocopy 0.6.6", "zerocopy 0.8.14",
] ]
[[package]] [[package]]
@@ -3077,7 +3078,7 @@ checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4"
[[package]] [[package]]
name = "hwcodec" name = "hwcodec"
version = "0.7.1" version = "0.7.1"
source = "git+https://github.com/rustdesk-org/hwcodec#c4d6b1c5c4ddc7548868306004cf5d4eb614a36f" source = "git+https://github.com/rustdesk-org/hwcodec#0ea7e709d3c48bb6446e33a9cc8fd0e0da5709b9"
dependencies = [ dependencies = [
"bindgen 0.59.2", "bindgen 0.59.2",
"cc", "cc",
@@ -4175,7 +4176,7 @@ version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "681030a937600a36906c185595136d26abfebb4aa9c65701cefcaf8578bb982b" checksum = "681030a937600a36906c185595136d26abfebb4aa9c65701cefcaf8578bb982b"
dependencies = [ dependencies = [
"proc-macro-crate 1.3.1", "proc-macro-crate 2.0.2",
"proc-macro2 1.0.86", "proc-macro2 1.0.86",
"quote 1.0.36", "quote 1.0.36",
"syn 2.0.68", "syn 2.0.68",
@@ -4508,9 +4509,9 @@ dependencies = [
[[package]] [[package]]
name = "page_size" name = "page_size"
version = "0.5.0" version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b7663cbd190cfd818d08efa8497f6cd383076688c49a391ef7c0d03cd12b561" checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da"
dependencies = [ dependencies = [
"libc", "libc",
"winapi 0.3.9", "winapi 0.3.9",
@@ -5506,7 +5507,7 @@ dependencies = [
[[package]] [[package]]
name = "rustdesk" name = "rustdesk"
version = "1.3.7" version = "1.3.8"
dependencies = [ dependencies = [
"android-wakelock", "android-wakelock",
"android_logger", "android_logger",
@@ -5606,7 +5607,7 @@ dependencies = [
[[package]] [[package]]
name = "rustdesk-portable-packer" name = "rustdesk-portable-packer"
version = "1.3.7" version = "1.3.8"
dependencies = [ dependencies = [
"brotli", "brotli",
"dirs 5.0.1", "dirs 5.0.1",
@@ -8074,16 +8075,6 @@ dependencies = [
"zvariant", "zvariant",
] ]
[[package]]
name = "zerocopy"
version = "0.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "854e949ac82d619ee9a14c66a1b674ac730422372ccb759ce0c39cabcf2bf8e6"
dependencies = [
"byteorder",
"zerocopy-derive 0.6.6",
]
[[package]] [[package]]
name = "zerocopy" name = "zerocopy"
version = "0.7.34" version = "0.7.34"
@@ -8094,10 +8085,19 @@ dependencies = [
] ]
[[package]] [[package]]
name = "zerocopy-derive" name = "zerocopy"
version = "0.6.6" version = "0.8.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "125139de3f6b9d625c39e2efdd73d41bdac468ccd556556440e322be0e1bbd91" checksum = "a367f292d93d4eab890745e75a778da40909cab4d6ff8173693812f79c4a2468"
dependencies = [
"zerocopy-derive 0.8.14",
]
[[package]]
name = "zerocopy-derive"
version = "0.7.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15e934569e47891f7d9411f1a451d947a60e000ab3bd24fbb970f000387d1b3b"
dependencies = [ dependencies = [
"proc-macro2 1.0.86", "proc-macro2 1.0.86",
"quote 1.0.36", "quote 1.0.36",
@@ -8106,9 +8106,9 @@ dependencies = [
[[package]] [[package]]
name = "zerocopy-derive" name = "zerocopy-derive"
version = "0.7.34" version = "0.8.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15e934569e47891f7d9411f1a451d947a60e000ab3bd24fbb970f000387d1b3b" checksum = "d3931cb58c62c13adec22e38686b559c86a30565e16ad6e8510a337cedc611e1"
dependencies = [ dependencies = [
"proc-macro2 1.0.86", "proc-macro2 1.0.86",
"quote 1.0.36", "quote 1.0.36",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "rustdesk" name = "rustdesk"
version = "1.3.7" version = "1.3.8"
authors = ["rustdesk <info@rustdesk.com>"] authors = ["rustdesk <info@rustdesk.com>"]
edition = "2021" edition = "2021"
build= "build.rs" build= "build.rs"
@@ -181,7 +181,7 @@ members = ["libs/scrap", "libs/hbb_common", "libs/enigo", "libs/clipboard", "lib
exclude = ["vdi/host", "examples/custom_plugin"] exclude = ["vdi/host", "examples/custom_plugin"]
[package.metadata.winres] [package.metadata.winres]
LegalCopyright = "Copyright © 2024 Purslane Ltd. All rights reserved." LegalCopyright = "Copyright © 2025 Purslane Ltd. All rights reserved."
ProductName = "RustDesk" ProductName = "RustDesk"
FileDescription = "RustDesk Remote Desktop" FileDescription = "RustDesk Remote Desktop"
OriginalFilename = "rustdesk.exe" OriginalFilename = "rustdesk.exe"

View File

@@ -1,11 +1,10 @@
<p align="center"> <p align="center">
<img src="res/logo-header.svg" alt="RustDesk - Your remote desktop"><br> <img src="res/logo-header.svg" alt="RustDesk - Your remote desktop"><br>
<a href="#public-servers">Servers</a> •
<a href="#raw-steps-to-build">Build</a> • <a href="#raw-steps-to-build">Build</a> •
<a href="#how-to-build-with-docker">Docker</a> • <a href="#how-to-build-with-docker">Docker</a> •
<a href="#file-structure">Structure</a> • <a href="#file-structure">Structure</a> •
<a href="#snapshot">Snapshot</a><br> <a href="#snapshot">Snapshot</a><br>
[<a href="docs/README-UA.md">Українська</a>] | [<a href="docs/README-CS.md">česky</a>] | [<a href="docs/README-ZH.md">中文</a>] | [<a href="docs/README-HU.md">Magyar</a>] | [<a href="docs/README-ES.md">Español</a>] | [<a href="docs/README-FA.md">فارسی</a>] | [<a href="docs/README-FR.md">Français</a>] | [<a href="docs/README-DE.md">Deutsch</a>] | [<a href="docs/README-PL.md">Polski</a>] | [<a href="docs/README-ID.md">Indonesian</a>] | [<a href="docs/README-FI.md">Suomi</a>] | [<a href="docs/README-ML.md">മലയാളം</a>] | [<a href="docs/README-JP.md">日本語</a>] | [<a href="docs/README-NL.md">Nederlands</a>] | [<a href="docs/README-IT.md">Italiano</a>] | [<a href="docs/README-RU.md">Русский</a>] | [<a href="docs/README-PTBR.md">Português (Brasil)</a>] | [<a href="docs/README-EO.md">Esperanto</a>] | [<a href="docs/README-KR.md">한국어</a>] | [<a href="docs/README-AR.md">العربي</a>] | [<a href="docs/README-VN.md">Tiếng Việt</a>] | [<a href="docs/README-DA.md">Dansk</a>] | [<a href="docs/README-GR.md">Ελληνικά</a>] | [<a href="docs/README-TR.md">Türkçe</a>]<br> [<a href="docs/README-UA.md">Українська</a>] | [<a href="docs/README-CS.md">česky</a>] | [<a href="docs/README-ZH.md">中文</a>] | [<a href="docs/README-HU.md">Magyar</a>] | [<a href="docs/README-ES.md">Español</a>] | [<a href="docs/README-FA.md">فارسی</a>] | [<a href="docs/README-FR.md">Français</a>] | [<a href="docs/README-DE.md">Deutsch</a>] | [<a href="docs/README-PL.md">Polski</a>] | [<a href="docs/README-ID.md">Indonesian</a>] | [<a href="docs/README-FI.md">Suomi</a>] | [<a href="docs/README-ML.md">മലയാളം</a>] | [<a href="docs/README-JP.md">日本語</a>] | [<a href="docs/README-NL.md">Nederlands</a>] | [<a href="docs/README-IT.md">Italiano</a>] | [<a href="docs/README-RU.md">Русский</a>] | [<a href="docs/README-PTBR.md">Português (Brasil)</a>] | [<a href="docs/README-EO.md">Esperanto</a>] | [<a href="docs/README-KR.md">한국어</a>] | [<a href="docs/README-AR.md">العربي</a>] | [<a href="docs/README-VN.md">Tiếng Việt</a>] | [<a href="docs/README-DA.md">Dansk</a>] | [<a href="docs/README-GR.md">Ελληνικά</a>] | [<a href="docs/README-TR.md">Türkçe</a>] | [<a href="docs/README-NO.md">Norsk</a>]<br>
<b>We need your help to translate this README, <a href="https://github.com/rustdesk/rustdesk/tree/master/src/lang">RustDesk UI</a> and <a href="https://github.com/rustdesk/doc.rustdesk.com">RustDesk Doc</a> to your native language</b> <b>We need your help to translate this README, <a href="https://github.com/rustdesk/rustdesk/tree/master/src/lang">RustDesk UI</a> and <a href="https://github.com/rustdesk/doc.rustdesk.com">RustDesk Doc</a> to your native language</b>
</p> </p>
@@ -128,6 +127,7 @@ Begin by cloning the repository and building the Docker container:
```sh ```sh
git clone https://github.com/rustdesk/rustdesk git clone https://github.com/rustdesk/rustdesk
cd rustdesk cd rustdesk
git submodule update --init --recursive
docker build -t "rustdesk-builder" . docker build -t "rustdesk-builder" .
``` ```
@@ -165,6 +165,10 @@ Please ensure that you are running these commands from the root of the RustDesk
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Flutter code for desktop and mobile - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Flutter code for desktop and mobile
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: JavaScript for Flutter web client - **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: JavaScript for Flutter web client
> [!Caution]
> **Misuse Disclaimer:** <br>
> The developers of RustDesk do not condone or support any unethical or illegal use of this software. Misuse, such as unauthorized access, control or invasion of privacy, is strictly against our guidelines. The authors are not responsible for any misuse of the application.
## Screenshots ## Screenshots
![Connection Manager](https://github.com/rustdesk/rustdesk/assets/28412477/db82d4e7-c4bc-4823-8e6f-6af7eadf7651) ![Connection Manager](https://github.com/rustdesk/rustdesk/assets/28412477/db82d4e7-c4bc-4823-8e6f-6af7eadf7651)

View File

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

View File

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

125
docs/CODE_OF_CONDUCT-NO.md Normal file
View File

@@ -0,0 +1,125 @@
# Atferdskodeks for bidragsyterpaktern
## Hva Vi Står For
Vi som medlemer, bidragere, og ledere står for å skape ett hat-fritt felleskap,
uansett alder, kroppstørrelse, synlig eller usynlige funksjonsnedsettninger,
etnesitet, kjønns karaktertrekk, kjønnsidentitet, kunnskapsnivå, utdanning,
sosial-økonomisk status, nasjonalitet, utsende, rase, religion, eller seksual
identitet og orientasjon.
Vi står for åpen, velkommende, mangfold, inklusiv og sunn oppførsel i vårt felleskap.
## Våre Standarer
Eksempler på oppførsel som hjelper ett positivt felleskap inkluderer:
* Vise empati og vennlighet mot andre mennesker
* Være respektfull ovenfor ulike meninger, synspunkter og erfaringer
* Gi og ta konstruktiv kritikk i beste mening
* Akseptere ansvar og unskylde seg for de som er utsatt av våre feil,
og lære av disse
* Fokusere på det som er best ikke bare for individer, men for felleskapet
Eksempler på uakseptabel oppførsel inkluderer:
* Bruk av seksualisert språk eller bilder, og seksual oppmerksomhet.
* Troll-ene, fornermende og nedsettende kommentarer, og personlig eller politiske angrep
* Offentlig eller privat trakassering
* Publisering av andres private informasjon, sånn som bosteds- og epost-addresser,
uten deres godskjenning.
* Andre rettningslinjer som kan bli sett på som upassende i en profesjonell setting.
## Håndhevingsansvar
Felleskapets ledere har ansvar for å klarifisere og håndheve våre standarer av
akseptert oppførsel og vill ta rimelige og rettferdige handliger som respons på
oppførsel de anser som upassende, truende, fornermende eller skadelig.
Felleskapets ledere har retten og ansvaret til å fjerne, redigere, eller avslå
kommentarer, commits, kode, wiki endringer, issues, og andre birag som ikke
samsvarer med disse etiske rettningslinjene, og vill kommunisere grunner for
moderatorenes valg når passende.
## Omfang
Disse etiske rettningslinjene gjelder innenfor alle platformene til felleskapet, og
de gjelder også når ett individ representerer felleskapet på offentlige medier.
Eksempler på representasjon av vårt felleskap inkluderer bruke av offisielle e-mail
addresser, publisering gjennom en offisiell sosial media bruker, eller oppførsel som en
utpekt representant på digitale og fysiske arrangsjemanger.
## Håndheving
Hendelser av misbruk, trakasserende eller på noen måte uakseptert oppførsel kann
bli raportert til felleskapets ledere med ansvar for håndheving på
[info@rustdesk.com](mailto:info@rustdesk.com).
All tilbakemelding vill bli sett gjennom og investigert rettferdig så fort som mulig.
Alle felleskapets ledere er obligert til å respektere privatlivet og sikkerhetet ovenfor
den som raporterer en hendelse.
## Håndhevings Guide
Felleskapets ledere vill følge disse Rettningslinjene for sammfunspåvirkning med
tanke på konsekvenser for en handling de anser i brudd med disse etiske rettningslinjene:
### 1. Korreksjon
**Sammfunspåvirkning**: Bruk av upassende språk eller annen oppførsel ansett som
uprofesjonelt eller uvelkommen i dette felleskapet.
**Konsekvens**: En privat, skrevet advarsel fra en leder av felleskapet, som
klarifiserer grunnlaget til hvorfor denne oppførselen var upassende. En offentlig
unskyldning kan bli forespurt.
### 2. Advarsel
**Sammfunspåvirkning**: Ett brudd på en singulær hendelse eller en serie handlinger.
**Konsekvens**: En advarsel med konsekvenser for kontinuerende oppførsel. Ingen
interaksjon med individene involvert, inkluderer uoppfordret interaksjoner med
de som håndhever disse etiske rettningslinjene, er tillat for en spesifisert tidsperiode.
Dette inkluderer å unngå interaksjoner i felleskapets platformer, samt eksterne
kanaler, som f.eks sosial media. Brudd av disse vilkårene kan føre til midlertidig
eller permanent bannlysning.
### 3. Midlertidig Bannlysning
**Sammfunspåvirkning**: Ett særiøst brudd på felleskapets standarer, inkludert
vedvarende upassende oppførsel.
**Konsekvens**: En midlertidig bannlysning fra noen som helst interaksjon eller
offentlig kommunikasjon med felleskapet for en spesifisert tidsperiode. Ingen
interaksjon med individene involvert, inkluderer uoppfordret interaksjoner med
de som håndhever disse etiske rettningslinjene, er tillat for denne perioden.
Brudd på disse vilkårene kan føre til permanent bannlysning.
### 4. Permanent Bannlysning
**Sammfunspåvirkning**: Demonstasjon av mønster i brudd på felleskapets standarer,
inklusivt vedvarende upassende oppførsel, trakassering av ett individ, eller
aggresjon mot eller nedsettelse av grupper individer.
**Konsekvens**: En permanent bannlysning fra alle offentlige interaksjoner i
felleskapet
## Attribusjon
Disse etiske rettningslinjene er adaptert fra [Contributor Covenant][homepage],
versjon 2.0, tilgjengelig ved
[https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0].
Sammfunspåvirknings guid inspirert av
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
For svar til vanlige spørsmål angående disse etiske rettningslinjene, se FAQ på
[https://www.contributor-covenant.org/faq][FAQ]. Oversettelse tilgjengelig
ved [https://www.contributor-covenant.org/translations][translations].
[homepage]: https://www.contributor-covenant.org
[v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html
[Mozilla CoC]: https://github.com/mozilla/diversity
[FAQ]: https://www.contributor-covenant.org/faq
[translations]: https://www.contributor-covenant.org/translations

46
docs/CONTRIBUTING-NO.md Normal file
View File

@@ -0,0 +1,46 @@
# Bidrag til RustDesk
RustDesk er åpene for bidrag fra alle. Her er reglene for de som har lyst til å
hjelpe oss:
## Bidrag
Bidrag til RustDesk eller deres avhengigheter burde være i form av GitHub pull requests.
Hver pull request vill bli sett igjennom av en kjerne bidrager (noen med autoritet til
å godkjenne endringene) og enten bli sendt til main treet eller respondert med
tilbakemelding på endringer som er nødvendig. Alle bidrag burde følge dette formate
også de fra kjerne bidragere.
Om du ønsker å jobbe på en issue må du huske å gjøre krav på den først. Dette
kann gjøres ved å kommentere på den GitHub issue-en du ønsker å jobbe på.
Dette er for å hindre duplikat innsats på samme problem.
## Pull Request Sjekkliste
- Lag en gren fra master grenen og, hvis det er nødvendig, rebase den til den nåværende
master grenen før du sender inn din pull request. Hvis ikke dette gjøres på rent
vis vill du bli spurt om å rebase dine endringer.
- Commits burde være så små som mulig, samtidig som de må være korrekt uavhenging av hverandre
(hver commit burde kompilere og bestå tester).
- Commits burde være akkopaniert med en Developer Certificate of Origin
(http://developercertificate.org), som indikerer att du (og din arbeidsgiver
i det tilfellet) godkjenner å bli knyttet til vilkårene av [prosjekt lisensen](../LICENCE).
Ved bruk av git er dette `-s` opsjonen til `git commit`.
- Hvis dine endringer ikke blir sett eller hvis du trenger en spesefik person til
å se på dem kan du @-svare en med autoritet til å godkjenne dine endringer.
Dette kann gjøres i en pull request, en kommentar eller via epost på [email](mailto:info@rustdesk.com).
- Legg til tester relevant til en fikset bug eller en ny tilgjengelighet.
For spesefike git instruksjoner, se [GitHub workflow 101](https://github.com/servo/servo/wiki/GitHub-workflow).
## Oppførsel
https://github.com/rustdesk/rustdesk/blob/master/docs/CODE_OF_CONDUCT.md
## Kommunikasjon
RustDesk bidragere burker [Discord](https://discord.gg/nDceKgxnkV).

14
docs/DEVCONTAINER-NO.md Normal file
View File

@@ -0,0 +1,14 @@
Etter start av devcontainer i docker konteineren, blir en linux binærfil i debug modus laget.
Nå tilbyr devcontainer linux og android builds i både debug og release modus.
Under er tabellen over kommandoer som kan kjøres fra rot-direktive for kreasjon av spesefike builds.
Kommando|Build Type|Modus
-|-|-|
`.devcontainer/build.sh --debug linux`|Linux|debug
`.devcontainer/build.sh --release linux`|Linux|release
`.devcontainer/build.sh --debug android`|android-arm64|debug
`.devcontainer/build.sh --release android`|android-arm64|release

View File

@@ -147,6 +147,10 @@ target/release/rustdesk
Bitte stellen Sie sicher, dass Sie diese Befehle im Stammverzeichnis des RustDesk-Repositorys nutzen. Ansonsten kann es passieren, dass das Programm die Ressourcen nicht finden kann. Bitte bedenken Sie auch, dass andere Cargo-Unterbefehle wie `install` oder `run` aktuell noch nicht unterstützt werden, da sie das Programm innerhalb des Containers starten oder installieren würden, anstatt auf Ihrem eigentlichen System. Bitte stellen Sie sicher, dass Sie diese Befehle im Stammverzeichnis des RustDesk-Repositorys nutzen. Ansonsten kann es passieren, dass das Programm die Ressourcen nicht finden kann. Bitte bedenken Sie auch, dass andere Cargo-Unterbefehle wie `install` oder `run` aktuell noch nicht unterstützt werden, da sie das Programm innerhalb des Containers starten oder installieren würden, anstatt auf Ihrem eigentlichen System.
> [!Vorsicht]
> **Haftungsausschluss bei Missbrauch::** <br>
> Die Entwickler von RustDesk billigen oder unterstützen keine unethische oder illegale Nutzung dieser Software. Missbrauch, wie unbefugter Zugriff, unbefugte Kontrolle oder Verletzung der Privatsphäre, verstößt strikt gegen unsere Richtlinien. Die Autoren sind nicht verantwortlich für jeglichen Missbrauch der Anwendung.
## Dateistruktur ## Dateistruktur
- **[libs/hbb_common](https://github.com/rustdesk/rustdesk/tree/master/libs/hbb_common)**: Video-Codec, Konfiguration, TCP/UDP-Wrapper, Protokoll-Puffer, fs-Funktionen für Dateitransfer und ein paar andere nützliche Funktionen - **[libs/hbb_common](https://github.com/rustdesk/rustdesk/tree/master/libs/hbb_common)**: Video-Codec, Konfiguration, TCP/UDP-Wrapper, Protokoll-Puffer, fs-Funktionen für Dateitransfer und ein paar andere nützliche Funktionen

View File

@@ -147,6 +147,10 @@ Por favor, asegurate de que estás ejecutando estos comandos desde la raíz del
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Flutter, código para moviles - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Flutter, código para moviles
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Javascript para el cliente web Flutter - **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Javascript para el cliente web Flutter
> [!Precaución]
> **Descargo de responsabilidad por uso indebido:** <br>
> Los desarrolladores de RustDesk no aprueban ni apoyan ningún uso no ético o ilegal de este software. El uso indebido, como el acceso no autorizado, el control o la invasión de la privacidad, está estrictamente en contra de nuestras directrices. Los autores no son responsables de ningún uso indebido de la aplicación.
## Capturas de pantalla ## Capturas de pantalla
![image](https://user-images.githubusercontent.com/71636191/113112362-ae4deb80-923b-11eb-957d-ff88daad4f06.png) ![image](https://user-images.githubusercontent.com/71636191/113112362-ae4deb80-923b-11eb-957d-ff88daad4f06.png)

View File

@@ -137,6 +137,10 @@ Veuillez vous assurer que vous exécutez ces commandes à partir de la racine du
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)** : Communiquer avec [rustdesk-server](https://github.com/rustdesk/rustdesk-server), attendre une connexion distante directe (TCP hole punching) ou relayée. - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)** : Communiquer avec [rustdesk-server](https://github.com/rustdesk/rustdesk-server), attendre une connexion distante directe (TCP hole punching) ou relayée.
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)** : code spécifique à la plateforme - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)** : code spécifique à la plateforme
> [!Attention]
> **Avertissement contre l'utilisation abusive:** <br>
> Les développeurs de RustDesk ne cautionnent ni ne soutiennent aucune utilisation non éthique ou illégale de ce logiciel. Toute utilisation abusive, telle que l'accès non autorisé, le contrôle ou l'invasion de la vie privée, est strictement contraire à nos directives. Les auteurs ne sont pas responsables de toute utilisation abusive de l'application.
## Images ## Images
![image](https://user-images.githubusercontent.com/71636191/113112362-ae4deb80-923b-11eb-957d-ff88daad4f06.png) ![image](https://user-images.githubusercontent.com/71636191/113112362-ae4deb80-923b-11eb-957d-ff88daad4f06.png)

View File

@@ -164,6 +164,10 @@ Assicurati di eseguire questi comandi dalla radice del repository RustDesk, altr
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: codice Flutter per desktop e mobile - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: codice Flutter per desktop e mobile
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: JavaScript per client web Flutter - **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: JavaScript per client web Flutter
> [!Attenzione]
> **Dichiarazione di non responsabilità per uso improprio:** <br>
> Gli sviluppatori di RustDesk non approvano né supportano alcun uso non etico o illegale di questo software. L'uso improprio, come l'accesso non autorizzato, il controllo o l'invasione della privacy, è strettamente contro le nostre linee guida. Gli autori non sono responsabili per qualsiasi uso improprio dell'applicazione.
## Schermate ## Schermate
![image](https://user-images.githubusercontent.com/71636191/113112362-ae4deb80-923b-11eb-957d-ff88daad4f06.png) ![image](https://user-images.githubusercontent.com/71636191/113112362-ae4deb80-923b-11eb-957d-ff88daad4f06.png)

View File

@@ -168,6 +168,10 @@ target/release/rustdesk
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: デスクトップとモバイル向けのFlutterコード - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: デスクトップとモバイル向けのFlutterコード
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Flutterウェブクライアント向けのJavaScript - **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Flutterウェブクライアント向けのJavaScript
> [!注意]
> **:不正使用に関する免責事項** <br>
> RustDeskの開発者は、このソフトウェアの非倫理的または違法な使用を容認または支持しません。不正アクセス、不正な制御、またはプライバシーの侵害などの不正使用は、当社のガイドラインに厳密に違反します。開発者は、アプリケーションの不正使用に対して一切の責任を負いません。
## スクリーンショット ## スクリーンショット
![Connection Manager](https://github.com/rustdesk/rustdesk/assets/28412477/db82d4e7-c4bc-4823-8e6f-6af7eadf7651) ![Connection Manager](https://github.com/rustdesk/rustdesk/assets/28412477/db82d4e7-c4bc-4823-8e6f-6af7eadf7651)

View File

@@ -148,6 +148,10 @@ target/release/rustdesk
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: 모바일용 Flutter 코드 - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: 모바일용 Flutter 코드
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Flutter 웹 클라이언트용 자바스크립트 - **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Flutter 웹 클라이언트용 자바스크립트
> [!주의]
> **오용에 대한 면책 조항:** <br>
> RustDesk의 개발자들은 이 소프트웨어의 비윤리적이거나 불법적인 사용을 용인하거나 지원하지 않습니다. 무단 접근, 제어 또는 개인정보 침해와 같은 오용은 우리의 지침을 엄격히 위반하는 것입니다. 개발자들은 애플리케이션의 오용에 대해 책임을 지지 않습니다.
## 스냅샷 ## 스냅샷
![image](https://user-images.githubusercontent.com/71636191/113112362-ae4deb80-923b-11eb-957d-ff88daad4f06.png) ![image](https://user-images.githubusercontent.com/71636191/113112362-ae4deb80-923b-11eb-957d-ff88daad4f06.png)

177
docs/README-NO.md Normal file
View File

@@ -0,0 +1,177 @@
<p align="center">
<img src="res/logo-header.svg" alt="RustDesk - Your remote desktop"><br>
<a href="#public-servers">Servere</a> •
<a href="#raw-steps-to-build">Build</a> •
<a href="#how-to-build-with-docker">Docker</a> •
<a href="#file-structure">Struktur</a> •
<a href="#snapshot">Snapshot</a><br>
[<a href="docs/README-UA.md">Українська</a>] | [<a href="docs/README-CS.md">česky</a>] | [<a href="docs/README-ZH.md">中文</a>] | [<a href="docs/README-HU.md">Magyar</a>] | [<a href="docs/README-ES.md">Español</a>] | [<a href="docs/README-FA.md">فارسی</a>] | [<a href="docs/README-FR.md">Français</a>] | [<a href="docs/README-DE.md">Deutsch</a>] | [<a href="docs/README-PL.md">Polski</a>] | [<a href="docs/README-ID.md">Indonesian</a>] | [<a href="docs/README-FI.md">Suomi</a>] | [<a href="docs/README-ML.md">മലയാളം</a>] | [<a href="docs/README-JP.md">日本語</a>] | [<a href="docs/README-NL.md">Nederlands</a>] | [<a href="docs/README-IT.md">Italiano</a>] | [<a href="docs/README-RU.md">Русский</a>] | [<a href="docs/README-PTBR.md">Português (Brasil)</a>] | [<a href="docs/README-EO.md">Esperanto</a>] | [<a href="docs/README-KR.md">한국어</a>] | [<a href="docs/README-AR.md">العربي</a>] | [<a href="docs/README-VN.md">Tiếng Việt</a>] | [<a href="docs/README-DA.md">Dansk</a>] | [<a href="docs/README-GR.md">Ελληνικά</a>] | [<a href="docs/README-TR.md">Türkçe</a>] | [<a href="docs/README-NO.md">Norsk</a><br>
<b>Vi trenger din hjelp til å oversette denne README-en, <a href="https://github.com/rustdesk/rustdesk/tree/master/src/lang">RustDesk UI</a> og <a href="https://github.com/rustdesk/doc.rustdesk.com">RustDesk Doc</a> tid ditt morsmål</b>
</p>
Snakk med oss: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk)
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/I2I04VU09)
Enda en annen fjernstyrt desktop programvare, skrevet i Rust. Virker rett ut av pakken, ingen konfigurasjon nødvendig. Du har full kontroll over din data, uten beskymring for sikkerhet. Du kan bruke vår rendezvous_mediator/relay server, [sett opp din egen](https://rustdesk.com/server), eller [skriv din egen rendezvous_mediator/relay server](https://github.com/rustdesk/rustdesk-server-demo).
![image](https://user-images.githubusercontent.com/71636191/171661982-430285f0-2e12-4b1d-9957-4a58e375304d.png)
RustDesk er velkommen for bidrag fra alle. Se [CONTRIBUTING.md](docs/CONTRIBUTING-NO.md) for hjelp med oppstart.
[**FAQ**](https://github.com/rustdesk/rustdesk/wiki/FAQ)
[**BINARY NEDLASTING**](https://github.com/rustdesk/rustdesk/releases)
[**NIGHTLY BUILD**](https://github.com/rustdesk/rustdesk/releases/tag/nightly)
[<img src="https://f-droid.org/badge/get-it-on.png"
alt="Få det på F-Droid"
height="80">](https://f-droid.org/en/packages/com.carriez.flutter_hbb)
[<img src="https://flathub.org/api/badge?svg&locale=en"
alt="Få det på Flathub"
height="80">](https://flathub.org/apps/com.rustdesk.RustDesk)
## Avhengigheter
Desktop versjoner bruker Flutter eller Sciter (avviklet) for GUI, denne veiledningen er bare for Sciter, grunnet att det er letter og en mer venlig start. Skjekk ut vår [CI](https://github.com/rustdesk/rustdesk/blob/master/.github/workflows/flutter-build.yml) for bygging av Flutter versjonen.
Venligst last ned Sciters dynamiske bibliotek selv.
[Windows](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.win/x64/sciter.dll) |
[Linux](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so) |
[macOS](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.osx/libsciter.dylib)
## Rå steg for bygging
- Klargjør ditt Rust development env og C++ build env
- Installer [vcpkg](https://github.com/microsoft/vcpkg), og koriger `VCPKG_ROOT` env vaiabelen
- Windows: vcpkg install libvpx:x64-windows-static libyuv:x64-windows-static opus:x64-windows-static aom:x64-windows-static
- Linux/macOS: vcpkg install libvpx libyuv opus aom
- Kjør `cargo run`
## [Bygg](https://rustdesk.com/docs/en/dev/build/)
## Hvordan Bygge til Linux
### Ubuntu 18 (Debian 10)
```sh
sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \
libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev
```
### openSUSE Tumbleweed
```sh
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel
```
### Fedora 28 (CentOS 8)
```sh
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel
```
### Arch (Manjaro)
```sh
sudo pacman -Syu --needed unzip git cmake gcc curl wget yasm nasm zip make pkg-config clang gtk3 xdotool libxcb libxfixes alsa-lib pipewire
```
### Installer vcpkg
```sh
git clone https://github.com/microsoft/vcpkg
cd vcpkg
git checkout 2023.04.15
cd ..
vcpkg/bootstrap-vcpkg.sh
export VCPKG_ROOT=$HOME/vcpkg
vcpkg/vcpkg install libvpx libyuv opus aom
```
### Fiks libvpx (For Fedora)
```sh
cd vcpkg/buildtrees/libvpx/src
cd *
./configure
sed -i 's/CFLAGS+=-I/CFLAGS+=-fPIC -I/g' Makefile
sed -i 's/CXXFLAGS+=-I/CXXFLAGS+=-fPIC -I/g' Makefile
make
cp libvpx.a $HOME/vcpkg/installed/x64-linux/lib/
cd
```
### Bygg
```sh
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source $HOME/.cargo/env
git clone https://github.com/rustdesk/rustdesk
cd rustdesk
mkdir -p target/debug
wget https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so
mv libsciter-gtk.so target/debug
VCPKG_ROOT=$HOME/vcpkg cargo run
```
## Hvordan bygge med Docker
Start med å klone repositoret og bygg Docker konteineren:
```sh
git clone https://github.com/rustdesk/rustdesk
cd rustdesk
docker build -t "rustdesk-builder" .
```
Deretter, hver gang du trenger å bygge applikasjonen, kjør følgene kommando:
```sh
docker run --rm -it -v $PWD:/home/user/rustdesk -v rustdesk-git-cache:/home/user/.cargo/git -v rustdesk-registry-cache:/home/user/.cargo/registry -e PUID="$(id -u)" -e PGID="$(id -g)" rustdesk-builder
```
Det kan ta lengere tid før avhengighetene blir bufret første gang du bygger, senere bygg er raskere. Hvis du trenger å spesifisere forkjellige argumenter til bygge kommandoen, kan du gjøre det på slutten av kommandoen ved `<OPTIONAL-ARGS>` feltet. For eksempel, hvis du ville bygge en optimalisert release versjon, ville du kjørt kommandoen over fulgt `--release`. Den kjørbare filen vill være tilgjengelig i mål direktive på ditt system, og kan bli kjørt med:
```sh
target/debug/rustdesk
```
Eller, hvis du kjører ett release program:
```sh
target/release/rustdesk
```
Venligst pass på att du kjører disse kommandoene fra roten av RustDesk repositoret, eller kan det hende att applikasjon ikke finner de riktige ressursene. Pass også på att andre cargo subkommandoer som for eksempel `install` eller `run` ikke støttes med denne metoden da de vill installere eller kjøre programmet i konteineren istedet for verten.
## Fil Struktur
- **[libs/hbb_common](https://github.com/rustdesk/rustdesk/tree/master/libs/hbb_common)**: video kodek, configurasjon, tcp/udp innpakning, protobuf, fs funksjon for fil overføring, og noen andre verktøy funksjoner
- **[libs/scrap](https://github.com/rustdesk/rustdesk/tree/master/libs/scrap)**: skjermfangst
- **[libs/enigo](https://github.com/rustdesk/rustdesk/tree/master/libs/enigo)**: platform spesefik keyboard/mus kontroll
- **[libs/clipboard](https://github.com/rustdesk/rustdesk/tree/master/libs/clipboard)**: fil kopi og innliming implementasjon for Windows, Linux, macOS.
- **[src/ui](https://github.com/rustdesk/rustdesk/tree/master/src/ui)**: foreldret Sciter UI (avviklet)
- **[src/server](https://github.com/rustdesk/rustdesk/tree/master/src/server)**: lyd/utklippstavle/input/video tjenester, og internett tilkobling
- **[src/client.rs](https://github.com/rustdesk/rustdesk/tree/master/src/client.rs)**: start en peer tilkobling
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Kommunikasjon med [rustdesk-server](https://github.com/rustdesk/rustdesk-server), vent på direkte fjernstyring (TCP hulling) eller vidresendt tilkobling
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: platform spesefik kode
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Flutter kode for desktop og mobil
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: JavaScript for Flutter nettsted klient
## Skjermbilder
![Tilkoblings Manager](https://github.com/rustdesk/rustdesk/assets/28412477/db82d4e7-c4bc-4823-8e6f-6af7eadf7651)
![Koble til Windows PC](https://github.com/rustdesk/rustdesk/assets/28412477/9baa91e9-3362-4d06-aa1a-7518edcbd7ea)
![Fil Overføring](https://github.com/rustdesk/rustdesk/assets/28412477/39511ad3-aa9a-4f8c-8947-1cce286a46ad)
![TCP Tunneling](https://github.com/rustdesk/rustdesk/assets/28412477/78e8708f-e87e-4570-8373-1360033ea6c5)

View File

@@ -137,6 +137,10 @@ Por favor verifique que está executando estes comandos da raiz do repositório
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Comunicação com [rustdesk-server](https://github.com/rustdesk/rustdesk-server), aguardar pela conexão remota direta (TCP hole punching) ou conexão indireta (relayed) - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Comunicação com [rustdesk-server](https://github.com/rustdesk/rustdesk-server), aguardar pela conexão remota direta (TCP hole punching) ou conexão indireta (relayed)
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: código específico a cada plataforma - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: código específico a cada plataforma
> [!Cuidadob]
> **Aviso de uso indevido:** <br>
> Os desenvolvedores do RustDesk não aprovam nem apoiam qualquer uso antiético ou ilegal deste software. O uso indevido, como acesso não autorizado, controle ou invasão de privacidade, é estritamente contra nossas diretrizes. Os autores não são responsáveis por qualquer uso indevido da aplicação.
## Screenshots ## Screenshots
![image](https://user-images.githubusercontent.com/71636191/113112362-ae4deb80-923b-11eb-957d-ff88daad4f06.png) ![image](https://user-images.githubusercontent.com/71636191/113112362-ae4deb80-923b-11eb-957d-ff88daad4f06.png)

View File

@@ -148,6 +148,10 @@ target/release/rustdesk
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: свяжитесь с [rustdesk-server](https://github.com/rustdesk/rustdesk-server), дождитесь удаленного прямого (обход TCP NAT) или ретранслируемого соединения - **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: свяжитесь с [rustdesk-server](https://github.com/rustdesk/rustdesk-server), дождитесь удаленного прямого (обход TCP NAT) или ретранслируемого соединения
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: специфичный для платформы код - **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: специфичный для платформы код
> [!Осторожно]
> **Отказ от ответственности за неправомерное использование:** <br>
> Разработчики RustDesk не одобряют и не поддерживают какое-либо неэтичное или незаконное использование данного программного обеспечения. Неправомерное использование, такое как несанкционированный доступ, контроль или вторжение в частную жизнь, строго противоречит нашим правилам. Авторы не несут ответственности за любое неправомерное использование приложения.
## Скриншоты ## Скриншоты
![image](https://user-images.githubusercontent.com/71636191/113112362-ae4deb80-923b-11eb-957d-ff88daad4f06.png) ![image](https://user-images.githubusercontent.com/71636191/113112362-ae4deb80-923b-11eb-957d-ff88daad4f06.png)

View File

@@ -166,6 +166,10 @@ Lütfen bu komutları RustDesk deposunun kökünden çalıştırdığınızdan e
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: mobil için Flutter kodu - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: mobil için Flutter kodu
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Flutter web istemcisi için JavaScript - **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Flutter web istemcisi için JavaScript
> [!Dikkat]
> **Yanlış Kullanım Uyarısı:** <br>
> RustDesk geliştiricileri, bu yazılımın etik olmayan veya yasa dışı kullanımını onaylamaz veya desteklemez. Yetkisiz erişim, kontrol veya gizlilik ihlali gibi kötüye kullanımlar kesinlikle yönergelerimize aykırıdır. Yazarlar, uygulamanın herhangi bir yanlış kullanımından sorumlu değildir.
## Ekran Görüntüleri ## Ekran Görüntüleri
![image](https://user-images.githubusercontent.com/71636191/113112362-ae4deb80-923b-11eb-957d-ff88daad4f06.png) ![image](https://user-images.githubusercontent.com/71636191/113112362-ae4deb80-923b-11eb-957d-ff88daad4f06.png)

View File

@@ -218,6 +218,10 @@ target/release/rustdesk
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: 适用于桌面和移动设备的 Flutter 代码 - **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: 适用于桌面和移动设备的 Flutter 代码
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Flutter Web版本中的Javascript代码 - **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Flutter Web版本中的Javascript代码
> [!警告]
> **免责声明:** <br>
> RustDesk 的开发人员不纵容或支持任何不道德或非法的软件使用行为。滥用行为,例如未经授权的访问、控制或侵犯隐私,严格违反我们的准则。作者对应用程序的任何滥用行为概不负责。
## 截图 ## 截图
![image](https://user-images.githubusercontent.com/71636191/113112362-ae4deb80-923b-11eb-957d-ff88daad4f06.png) ![image](https://user-images.githubusercontent.com/71636191/113112362-ae4deb80-923b-11eb-957d-ff88daad4f06.png)

9
docs/SECURITY-NO.md Normal file
View File

@@ -0,0 +1,9 @@
# Sikkerhets Rettningslinjer
## Reportering av en Sårbarhet
Vi verdsetter pris på sikkerhet for prosjektet høyt. Og oppmunterer alle brukere til å rapportere sårbarheter de oppdager til oss.
Om du finner en sikkerhets sårbarhet i RustDesk prosjektet, venligst raportere det ansvarsfult ved å sende oss en email til info@rustdesk.com.
På dette tidspunktet har vi ingen bug dusør program. Vi er ett lite team som prøver å løse ett stort problem. Vi trenger att du raporterer alle sårbarhetene
annsvarfult så vi kan fortsettte å bygge ett en sikker applikasjon for hele felleskapet.

View File

@@ -1,28 +0,0 @@
[package]
name = "custom_plugin"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
name = "custom_plugin"
path = "src/lib.rs"
crate-type = ["cdylib"]
[features]
default = ["flutter"]
flutter = []
[dependencies]
lazy_static = "1.4.0"
rustdesk = { path = "../../", version = "1.2.0", features = ["flutter"]}
[profile.release]
lto = true
codegen-units = 1
panic = 'abort'
strip = true
#opt-level = 'z' # only have smaller size after strip
rpath = true

View File

@@ -1,30 +0,0 @@
use librustdesk::api::RustDeskApiTable;
/// This file demonstrates how to write a custom plugin for RustDesk.
use std::ffi::{c_char, c_int, CString};
lazy_static::lazy_static! {
pub static ref PLUGIN_NAME: CString = CString::new("A Template Rust Plugin").unwrap();
pub static ref PLUGIN_ID: CString = CString::new("TemplatePlugin").unwrap();
// Do your own logic based on the API provided by RustDesk.
pub static ref API: RustDeskApiTable = RustDeskApiTable::default();
}
#[no_mangle]
fn plugin_name() -> *const c_char {
return PLUGIN_NAME.as_ptr();
}
#[no_mangle]
fn plugin_id() -> *const c_char {
return PLUGIN_ID.as_ptr();
}
#[no_mangle]
fn plugin_init() -> c_int {
return 0 as _;
}
#[no_mangle]
fn plugin_dispose() -> c_int {
return 0 as _;
}

View File

@@ -19,6 +19,7 @@ import android.view.accessibility.AccessibilityEvent
import android.view.ViewGroup.LayoutParams import android.view.ViewGroup.LayoutParams
import android.view.accessibility.AccessibilityNodeInfo import android.view.accessibility.AccessibilityNodeInfo
import android.view.KeyEvent as KeyEventAndroid import android.view.KeyEvent as KeyEventAndroid
import android.view.ViewConfiguration
import android.graphics.Rect import android.graphics.Rect
import android.media.AudioManager import android.media.AudioManager
import android.accessibilityservice.AccessibilityServiceInfo import android.accessibilityservice.AccessibilityServiceInfo
@@ -34,10 +35,15 @@ import hbb.MessageOuterClass.KeyEvent
import hbb.MessageOuterClass.KeyboardMode import hbb.MessageOuterClass.KeyboardMode
import hbb.KeyEventConverter import hbb.KeyEventConverter
const val LIFT_DOWN = 9 // const val BUTTON_UP = 2
const val LIFT_MOVE = 8 // const val BUTTON_BACK = 0x08
const val LIFT_UP = 10
const val LEFT_DOWN = 9
const val LEFT_MOVE = 8
const val LEFT_UP = 10
const val RIGHT_UP = 18 const val RIGHT_UP = 18
// (BUTTON_BACK << 3) | BUTTON_UP
const val BACK_UP = 66
const val WHEEL_BUTTON_DOWN = 33 const val WHEEL_BUTTON_DOWN = 33
const val WHEEL_BUTTON_UP = 34 const val WHEEL_BUTTON_UP = 34
const val WHEEL_DOWN = 523331 const val WHEEL_DOWN = 523331
@@ -64,12 +70,15 @@ class InputService : AccessibilityService() {
private val logTag = "input service" private val logTag = "input service"
private var leftIsDown = false private var leftIsDown = false
private var touchPath = Path() private val touchPath = Path()
private var stroke: GestureDescription.StrokeDescription? = null
private var lastTouchGestureStartTime = 0L private var lastTouchGestureStartTime = 0L
private var mouseX = 0 private var mouseX = 0
private var mouseY = 0 private var mouseY = 0
private var timer = Timer() private var timer = Timer()
private var recentActionTask: TimerTask? = null private var recentActionTask: TimerTask? = null
// 100(tap timeout) + 400(long press timeout)
private val longPressDuration = ViewConfiguration.getTapTimeout().toLong() + ViewConfiguration.getLongPressTimeout().toLong()
private val wheelActionsQueue = LinkedList<GestureDescription>() private val wheelActionsQueue = LinkedList<GestureDescription>()
private var isWheelActionsPolling = false private var isWheelActionsPolling = false
@@ -77,6 +86,9 @@ class InputService : AccessibilityService() {
private var fakeEditTextForTextStateCalculation: EditText? = null private var fakeEditTextForTextStateCalculation: EditText? = null
private var lastX = 0
private var lastY = 0
private val volumeController: VolumeController by lazy { VolumeController(applicationContext.getSystemService(AUDIO_SERVICE) as AudioManager) } private val volumeController: VolumeController by lazy { VolumeController(applicationContext.getSystemService(AUDIO_SERVICE) as AudioManager) }
@RequiresApi(Build.VERSION_CODES.N) @RequiresApi(Build.VERSION_CODES.N)
@@ -84,7 +96,7 @@ class InputService : AccessibilityService() {
val x = max(0, _x) val x = max(0, _x)
val y = max(0, _y) val y = max(0, _y)
if (mask == 0 || mask == LIFT_MOVE) { if (mask == 0 || mask == LEFT_MOVE) {
val oldX = mouseX val oldX = mouseX
val oldY = mouseY val oldY = mouseY
mouseX = x * SCREEN_INFO.scale mouseX = x * SCREEN_INFO.scale
@@ -98,31 +110,30 @@ class InputService : AccessibilityService() {
} }
} }
// left button down ,was up // left button down, was up
if (mask == LIFT_DOWN) { if (mask == LEFT_DOWN) {
isWaitingLongPress = true isWaitingLongPress = true
timer.schedule(object : TimerTask() { timer.schedule(object : TimerTask() {
override fun run() { override fun run() {
if (isWaitingLongPress) { if (isWaitingLongPress) {
isWaitingLongPress = false isWaitingLongPress = false
leftIsDown = false continueGesture(mouseX, mouseY)
endGesture(mouseX, mouseY)
} }
} }
}, LONG_TAP_DELAY * 4) }, longPressDuration)
leftIsDown = true leftIsDown = true
startGesture(mouseX, mouseY) startGesture(mouseX, mouseY)
return return
} }
// left down ,was down // left down, was down
if (leftIsDown) { if (leftIsDown) {
continueGesture(mouseX, mouseY) continueGesture(mouseX, mouseY)
} }
// left up ,was down // left up, was down
if (mask == LIFT_UP) { if (mask == LEFT_UP) {
if (leftIsDown) { if (leftIsDown) {
leftIsDown = false leftIsDown = false
isWaitingLongPress = false isWaitingLongPress = false
@@ -132,6 +143,11 @@ class InputService : AccessibilityService() {
} }
if (mask == RIGHT_UP) { if (mask == RIGHT_UP) {
longPress(mouseX, mouseY)
return
}
if (mask == BACK_UP) {
performGlobalAction(GLOBAL_ACTION_BACK) performGlobalAction(GLOBAL_ACTION_BACK)
return return
} }
@@ -241,36 +257,78 @@ class InputService : AccessibilityService() {
} }
} }
private fun startGesture(x: Int, y: Int) { @RequiresApi(Build.VERSION_CODES.N)
touchPath = Path() private fun performClick(x: Int, y: Int, duration: Long) {
touchPath.moveTo(x.toFloat(), y.toFloat()) val path = Path()
lastTouchGestureStartTime = System.currentTimeMillis() path.moveTo(x.toFloat(), y.toFloat())
try {
val longPressStroke = GestureDescription.StrokeDescription(path, 0, duration)
val builder = GestureDescription.Builder()
builder.addStroke(longPressStroke)
Log.d(logTag, "performClick x:$x y:$y time:$duration")
dispatchGesture(builder.build(), null, null)
} catch (e: Exception) {
Log.e(logTag, "performClick, error:$e")
}
} }
private fun continueGesture(x: Int, y: Int) { @RequiresApi(Build.VERSION_CODES.N)
private fun longPress(x: Int, y: Int) {
performClick(x, y, longPressDuration)
}
private fun startGesture(x: Int, y: Int) {
touchPath.reset()
touchPath.moveTo(x.toFloat(), y.toFloat())
lastTouchGestureStartTime = System.currentTimeMillis()
lastX = x
lastY = y
}
@RequiresApi(Build.VERSION_CODES.N)
private fun doDispatchGesture(x: Int, y: Int, willContinue: Boolean) {
touchPath.lineTo(x.toFloat(), y.toFloat()) touchPath.lineTo(x.toFloat(), y.toFloat())
var duration = System.currentTimeMillis() - lastTouchGestureStartTime
if (duration <= 0) {
duration = 1
}
try {
if (stroke == null) {
stroke = GestureDescription.StrokeDescription(
touchPath,
0,
duration,
willContinue
)
} else {
stroke = stroke?.continueStroke(touchPath, 0, duration, willContinue)
}
stroke?.let {
val builder = GestureDescription.Builder()
builder.addStroke(it)
Log.d(logTag, "doDispatchGesture x:$x y:$y time:$duration")
dispatchGesture(builder.build(), null, null)
}
} catch (e: Exception) {
Log.e(logTag, "doDispatchGesture, willContinue:$willContinue, error:$e")
}
}
@RequiresApi(Build.VERSION_CODES.N)
private fun continueGesture(x: Int, y: Int) {
doDispatchGesture(x, y, true)
touchPath.reset()
touchPath.moveTo(x.toFloat(), y.toFloat())
lastTouchGestureStartTime = System.currentTimeMillis()
lastX = x
lastY = y
} }
@RequiresApi(Build.VERSION_CODES.N) @RequiresApi(Build.VERSION_CODES.N)
private fun endGesture(x: Int, y: Int) { private fun endGesture(x: Int, y: Int) {
try { doDispatchGesture(x, y, false)
touchPath.lineTo(x.toFloat(), y.toFloat()) touchPath.reset()
var duration = System.currentTimeMillis() - lastTouchGestureStartTime stroke = null
if (duration <= 0) {
duration = 1
}
val stroke = GestureDescription.StrokeDescription(
touchPath,
0,
duration
)
val builder = GestureDescription.Builder()
builder.addStroke(stroke)
Log.d(logTag, "end gesture x:$x y:$y time:$duration")
dispatchGesture(builder.build(), null, null)
} catch (e: Exception) {
Log.e(logTag, "endGesture error:$e")
}
} }
@RequiresApi(Build.VERSION_CODES.N) @RequiresApi(Build.VERSION_CODES.N)

View File

@@ -65,8 +65,8 @@ class MainService : Service() {
@Keep @Keep
@RequiresApi(Build.VERSION_CODES.N) @RequiresApi(Build.VERSION_CODES.N)
fun rustPointerInput(kind: Int, mask: Int, x: Int, y: Int) { fun rustPointerInput(kind: Int, mask: Int, x: Int, y: Int) {
// turn on screen with LIFT_DOWN when screen off // turn on screen with LEFT_DOWN when screen off
if (!powerManager.isInteractive && (kind == 0 || mask == LIFT_DOWN)) { if (!powerManager.isInteractive && (kind == 0 || mask == LEFT_DOWN)) {
if (wakeLock.isHeld) { if (wakeLock.isHeld) {
Log.d(logTag, "Turn on Screen, WakeLock release") Log.d(logTag, "Turn on Screen, WakeLock release")
wakeLock.release() wakeLock.release()

Binary file not shown.

View File

@@ -150,6 +150,10 @@ prebuild)
# Flutter used to compile Flutter<->Rust bridge files # Flutter used to compile Flutter<->Rust bridge files
CARGO_EXPAND_VERSION="$(yq -r \
.env.CARGO_EXPAND_VERSION \
.github/workflows/bridge.yml)"
FLUTTER_BRIDGE_VERSION="$(yq -r \ FLUTTER_BRIDGE_VERSION="$(yq -r \
.env.FLUTTER_VERSION \ .env.FLUTTER_VERSION \
.github/workflows/bridge.yml)" .github/workflows/bridge.yml)"
@@ -239,6 +243,7 @@ prebuild)
cargo install \ cargo install \
cargo-expand \ cargo-expand \
--version "${CARGO_EXPAND_VERSION}" \
--locked --locked
cargo install flutter_rust_bridge_codegen \ cargo install flutter_rust_bridge_codegen \
--version "${FLUTTER_RUST_BRIDGE_VERSION}" \ --version "${FLUTTER_RUST_BRIDGE_VERSION}" \

View File

@@ -103,6 +103,8 @@ enum DesktopType {
class IconFont { class IconFont {
static const _family1 = 'Tabbar'; static const _family1 = 'Tabbar';
static const _family2 = 'PeerSearchbar'; static const _family2 = 'PeerSearchbar';
static const _family3 = 'AddressBook';
static const _family4 = 'DeviceGroup';
IconFont._(); IconFont._();
static const IconData max = IconData(0xe606, fontFamily: _family1); static const IconData max = IconData(0xe606, fontFamily: _family1);
@@ -113,8 +115,11 @@ class IconFont {
static const IconData menu = IconData(0xe628, fontFamily: _family1); static const IconData menu = IconData(0xe628, fontFamily: _family1);
static const IconData search = IconData(0xe6a4, fontFamily: _family2); static const IconData search = IconData(0xe6a4, fontFamily: _family2);
static const IconData roundClose = IconData(0xe6ed, fontFamily: _family2); static const IconData roundClose = IconData(0xe6ed, fontFamily: _family2);
static const IconData addressBook = static const IconData addressBook = IconData(0xe602, fontFamily: _family3);
IconData(0xe602, fontFamily: "AddressBook"); static const IconData deviceGroupOutline =
IconData(0xe623, fontFamily: _family4);
static const IconData deviceGroupFill =
IconData(0xe748, fontFamily: _family4);
} }
class ColorThemeExtension extends ThemeExtension<ColorThemeExtension> { class ColorThemeExtension extends ThemeExtension<ColorThemeExtension> {
@@ -817,7 +822,11 @@ class OverlayDialogManager {
close([res]) { close([res]) {
_dialogs.remove(dialogTag); _dialogs.remove(dialogTag);
dialog.complete(res); try {
dialog.complete(res);
} catch (e) {
debugPrint("Dialog complete catch error: $e");
}
BackButtonInterceptor.removeByName(dialogTag); BackButtonInterceptor.removeByName(dialogTag);
} }
@@ -2566,6 +2575,8 @@ bool get kUseCompatibleUiMode =>
isWindows && isWindows &&
const [WindowsTarget.w7].contains(windowsBuildNumber.windowsVersion); const [WindowsTarget.w7].contains(windowsBuildNumber.windowsVersion);
bool get isWin10 => windowsBuildNumber.windowsVersion == WindowsTarget.w10;
class ServerConfig { class ServerConfig {
late String idServer; late String idServer;
late String relayServer; late String relayServer;
@@ -3638,3 +3649,74 @@ extension WorkaroundFreezeLinuxMint on Widget {
} }
} }
} }
// Don't use `extension` here, the border looks weird if using `extension` in my test.
Widget workaroundWindowBorder(BuildContext context, Widget child) {
if (!isWin10) {
return child;
}
final isLight = Theme.of(context).brightness == Brightness.light;
final borderColor = isLight ? Colors.black87 : Colors.grey;
final width = isLight ? 0.5 : 0.1;
getBorderWidget(Widget child) {
return Obx(() =>
(stateGlobal.isMaximized.isTrue || stateGlobal.fullscreen.isTrue)
? Offstage()
: child);
}
final List<Widget> borders = [
getBorderWidget(Container(
color: borderColor,
height: width + 0.1,
))
];
if (kWindowType == WindowType.Main && !isLight) {
borders.addAll([
getBorderWidget(Align(
alignment: Alignment.topLeft,
child: Container(
color: borderColor,
width: width,
),
)),
getBorderWidget(Align(
alignment: Alignment.topRight,
child: Container(
color: borderColor,
width: width,
),
)),
getBorderWidget(Align(
alignment: Alignment.bottomCenter,
child: Container(
color: borderColor,
height: width,
),
)),
]);
}
return Stack(
children: [
child,
...borders,
],
);
}
void updateTextAndPreserveSelection(
TextEditingController controller, String text) {
// Only care about select all for now.
final isSelected = controller.selection.isValid &&
controller.selection.end > controller.selection.start;
// Set text will make the selection invalid.
controller.text = text;
if (isSelected) {
controller.selection = TextSelection(
baseOffset: 0, extentOffset: controller.value.text.length);
}
}

View File

@@ -67,6 +67,7 @@ class PeerPayload {
int? status; int? status;
String user = ''; String user = '';
String user_name = ''; String user_name = '';
String? device_group_name;
String note = ''; String note = '';
PeerPayload.fromJson(Map<String, dynamic> json) PeerPayload.fromJson(Map<String, dynamic> json)
@@ -75,6 +76,7 @@ class PeerPayload {
status = json['status'], status = json['status'],
user = json['user'] ?? '', user = json['user'] ?? '',
user_name = json['user_name'] ?? '', user_name = json['user_name'] ?? '',
device_group_name = json['device_group_name'] ?? '',
note = json['note'] ?? ''; note = json['note'] ?? '';
static Peer toPeer(PeerPayload p) { static Peer toPeer(PeerPayload p) {
@@ -84,6 +86,7 @@ class PeerPayload {
"username": p.info['username'] ?? '', "username": p.info['username'] ?? '',
"platform": _platform(p.info['os']), "platform": _platform(p.info['os']),
"hostname": p.info['device_name'], "hostname": p.info['device_name'],
"device_group_name": p.device_group_name,
}); });
} }
@@ -265,3 +268,19 @@ class AbTag {
: name = json['name'] ?? '', : name = json['name'] ?? '',
color = json['color'] ?? ''; color = json['color'] ?? '';
} }
class DeviceGroupPayload {
String name;
DeviceGroupPayload(this.name);
DeviceGroupPayload.fromJson(Map<String, dynamic> json)
: name = json['name'] ?? '';
Map<String, dynamic> toGroupCacheJson() {
final Map<String, dynamic> map = {
'name': name,
};
return map;
}
}

View File

@@ -1,4 +1,3 @@
import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hbb/common/formatter/id_formatter.dart'; import 'package:flutter_hbb/common/formatter/id_formatter.dart';
import '../../../models/platform_model.dart'; import '../../../models/platform_model.dart';
@@ -6,56 +5,104 @@ import 'package:flutter_hbb/models/peer_model.dart';
import 'package:flutter_hbb/common.dart'; import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/common/widgets/peer_card.dart'; import 'package:flutter_hbb/common/widgets/peer_card.dart';
Future<List<Peer>> getAllPeers() async { class AllPeersLoader {
Map<String, dynamic> recentPeers = jsonDecode(bind.mainLoadRecentPeersSync()); List<Peer> peers = [];
Map<String, dynamic> lanPeers = jsonDecode(bind.mainLoadLanPeersSync());
Map<String, dynamic> combinedPeers = {};
void mergePeers(Map<String, dynamic> peers) { bool _isPeersLoading = false;
if (peers.containsKey("peers")) { bool _isPeersLoaded = false;
dynamic peerData = peers["peers"];
if (peerData is String) { final String _listenerKey = 'AllPeersLoader';
try {
peerData = jsonDecode(peerData);
} catch (e) {
print("Error decoding peers: $e");
return;
}
}
if (peerData is List) { late void Function(VoidCallback) setState;
for (var peer in peerData) {
if (peer is Map && peer.containsKey("id")) { bool get needLoad => !_isPeersLoaded && !_isPeersLoading;
String id = peer["id"]; bool get isPeersLoaded => _isPeersLoaded;
if (!combinedPeers.containsKey(id)) {
combinedPeers[id] = peer; AllPeersLoader();
}
} void init(void Function(VoidCallback) setState) {
} this.setState = setState;
gFFI.recentPeersModel.addListener(_mergeAllPeers);
gFFI.lanPeersModel.addListener(_mergeAllPeers);
gFFI.abModel.addPeerUpdateListener(_listenerKey, _mergeAllPeers);
gFFI.groupModel.addPeerUpdateListener(_listenerKey, _mergeAllPeers);
}
void clear() {
gFFI.recentPeersModel.removeListener(_mergeAllPeers);
gFFI.lanPeersModel.removeListener(_mergeAllPeers);
gFFI.abModel.removePeerUpdateListener(_listenerKey);
gFFI.groupModel.removePeerUpdateListener(_listenerKey);
}
Future<void> getAllPeers() async {
if (!needLoad) {
return;
}
_isPeersLoading = true;
if (gFFI.recentPeersModel.peers.isEmpty) {
bind.mainLoadRecentPeers();
}
if (gFFI.lanPeersModel.peers.isEmpty) {
bind.mainLoadLanPeers();
}
// No need to care about peers from abModel, and group model.
// Because they will pull data in `refreshCurrentUser()` on startup.
final startTime = DateTime.now();
_mergeAllPeers();
final diffTime = DateTime.now().difference(startTime).inMilliseconds;
if (diffTime < 100) {
await Future.delayed(Duration(milliseconds: diffTime));
}
}
void _mergeAllPeers() {
Map<String, dynamic> combinedPeers = {};
for (var p in gFFI.abModel.allPeers()) {
if (!combinedPeers.containsKey(p.id)) {
combinedPeers[p.id] = p.toJson();
} }
} }
} for (var p in gFFI.groupModel.peers.map((e) => Peer.copy(e)).toList()) {
if (!combinedPeers.containsKey(p.id)) {
mergePeers(recentPeers); combinedPeers[p.id] = p.toJson();
mergePeers(lanPeers); }
for (var p in gFFI.abModel.allPeers()) {
if (!combinedPeers.containsKey(p.id)) {
combinedPeers[p.id] = p.toJson();
} }
}
for (var p in gFFI.groupModel.peers.map((e) => Peer.copy(e)).toList()) { List<Peer> parsedPeers = [];
if (!combinedPeers.containsKey(p.id)) { for (var peer in combinedPeers.values) {
combinedPeers[p.id] = p.toJson(); parsedPeers.add(Peer.fromJson(peer));
} }
}
List<Peer> parsedPeers = []; Set<String> peerIds = combinedPeers.keys.toSet();
for (final peer in gFFI.lanPeersModel.peers) {
if (!peerIds.contains(peer.id)) {
parsedPeers.add(peer);
peerIds.add(peer.id);
}
}
for (var peer in combinedPeers.values) { for (final peer in gFFI.recentPeersModel.peers) {
parsedPeers.add(Peer.fromJson(peer)); if (!peerIds.contains(peer.id)) {
parsedPeers.add(peer);
peerIds.add(peer.id);
}
}
for (final id in gFFI.recentPeersModel.restPeerIds) {
if (!peerIds.contains(id)) {
parsedPeers.add(Peer.fromJson({'id': id}));
peerIds.add(id);
}
}
peers = parsedPeers;
setState(() {
_isPeersLoading = false;
_isPeersLoaded = true;
});
} }
return parsedPeers;
} }
class AutocompletePeerTile extends StatefulWidget { class AutocompletePeerTile extends StatefulWidget {

View File

@@ -71,7 +71,7 @@ void changeIdDialog() {
final rules = [ final rules = [
RegexValidationRule('starts with a letter', RegExp(r'^[a-zA-Z]')), RegexValidationRule('starts with a letter', RegExp(r'^[a-zA-Z]')),
LengthRangeValidationRule(6, 16), LengthRangeValidationRule(6, 16),
RegexValidationRule('allowed characters', RegExp(r'^\w*$')) RegexValidationRule('allowed characters', RegExp(r'^[\w-]*$'))
]; ];
gFFI.dialogManager.show((setState, close, context) { gFFI.dialogManager.show((setState, close, context) {

View File

@@ -20,8 +20,11 @@ class MyGroup extends StatefulWidget {
} }
class _MyGroupState extends State<MyGroup> { class _MyGroupState extends State<MyGroup> {
RxString get selectedUser => gFFI.groupModel.selectedUser; RxBool get isSelectedDeviceGroup => gFFI.groupModel.isSelectedDeviceGroup;
RxString get searchUserText => gFFI.groupModel.searchUserText; RxString get selectedAccessibleItemName =>
gFFI.groupModel.selectedAccessibleItemName;
RxString get searchAccessibleItemNameText =>
gFFI.groupModel.searchAccessibleItemNameText;
static TextEditingController searchUserController = TextEditingController(); static TextEditingController searchUserController = TextEditingController();
@override @override
@@ -72,7 +75,7 @@ class _MyGroupState extends State<MyGroup> {
child: Container( child: Container(
width: double.infinity, width: double.infinity,
height: double.infinity, height: double.infinity,
child: _buildUserContacts(), child: _buildLeftList(),
), ),
) )
], ],
@@ -105,7 +108,7 @@ class _MyGroupState extends State<MyGroup> {
_buildLeftHeader(), _buildLeftHeader(),
Container( Container(
width: double.infinity, width: double.infinity,
child: _buildUserContacts(), child: _buildLeftList(),
) )
], ],
), ),
@@ -130,7 +133,8 @@ class _MyGroupState extends State<MyGroup> {
child: TextField( child: TextField(
controller: searchUserController, controller: searchUserController,
onChanged: (value) { onChanged: (value) {
searchUserText.value = value; searchAccessibleItemNameText.value = value;
selectedAccessibleItemName.value = '';
}, },
textAlignVertical: TextAlignVertical.center, textAlignVertical: TextAlignVertical.center,
style: TextStyle(fontSize: fontSize), style: TextStyle(fontSize: fontSize),
@@ -150,20 +154,30 @@ class _MyGroupState extends State<MyGroup> {
); );
} }
Widget _buildUserContacts() { Widget _buildLeftList() {
return Obx(() { return Obx(() {
final items = gFFI.groupModel.users.where((p0) { final userItems = gFFI.groupModel.users.where((p0) {
if (searchUserText.isNotEmpty) { if (searchAccessibleItemNameText.isNotEmpty) {
return p0.name return p0.name
.toLowerCase() .toLowerCase()
.contains(searchUserText.value.toLowerCase()); .contains(searchAccessibleItemNameText.value.toLowerCase());
}
return true;
}).toList();
final deviceGroupItems = gFFI.groupModel.deviceGroups.where((p0) {
if (searchAccessibleItemNameText.isNotEmpty) {
return p0.name
.toLowerCase()
.contains(searchAccessibleItemNameText.value.toLowerCase());
} }
return true; return true;
}).toList(); }).toList();
listView(bool isPortrait) => ListView.builder( listView(bool isPortrait) => ListView.builder(
shrinkWrap: isPortrait, shrinkWrap: isPortrait,
itemCount: items.length, itemCount: deviceGroupItems.length + userItems.length,
itemBuilder: (context, index) => _buildUserItem(items[index])); itemBuilder: (context, index) => index < deviceGroupItems.length
? _buildDeviceGroupItem(deviceGroupItems[index])
: _buildUserItem(userItems[index - deviceGroupItems.length]));
var maxHeight = max(MediaQuery.of(context).size.height / 6, 100.0); var maxHeight = max(MediaQuery.of(context).size.height / 6, 100.0);
return Obx(() => stateGlobal.isPortrait.isFalse return Obx(() => stateGlobal.isPortrait.isFalse
? listView(false) ? listView(false)
@@ -174,14 +188,16 @@ class _MyGroupState extends State<MyGroup> {
Widget _buildUserItem(UserPayload user) { Widget _buildUserItem(UserPayload user) {
final username = user.name; final username = user.name;
return InkWell(onTap: () { return InkWell(onTap: () {
if (selectedUser.value != username) { isSelectedDeviceGroup.value = false;
selectedUser.value = username; if (selectedAccessibleItemName.value != username) {
selectedAccessibleItemName.value = username;
} else { } else {
selectedUser.value = ''; selectedAccessibleItemName.value = '';
} }
}, child: Obx( }, child: Obx(
() { () {
bool selected = selectedUser.value == username; bool selected = !isSelectedDeviceGroup.value &&
selectedAccessibleItemName.value == username;
final isMe = username == gFFI.userModel.userName.value; final isMe = username == gFFI.userModel.userName.value;
final colorMe = MyTheme.color(context).me!; final colorMe = MyTheme.color(context).me!;
return Container( return Container(
@@ -238,4 +254,43 @@ class _MyGroupState extends State<MyGroup> {
}, },
)).marginSymmetric(horizontal: 12).marginOnly(bottom: 6); )).marginSymmetric(horizontal: 12).marginOnly(bottom: 6);
} }
Widget _buildDeviceGroupItem(DeviceGroupPayload deviceGroup) {
final name = deviceGroup.name;
return InkWell(onTap: () {
isSelectedDeviceGroup.value = true;
if (selectedAccessibleItemName.value != name) {
selectedAccessibleItemName.value = name;
} else {
selectedAccessibleItemName.value = '';
}
}, child: Obx(
() {
bool selected = isSelectedDeviceGroup.value &&
selectedAccessibleItemName.value == name;
return Container(
decoration: BoxDecoration(
color: selected ? MyTheme.color(context).highlight : null,
border: Border(
bottom: BorderSide(
width: 0.7,
color: Theme.of(context).dividerColor.withOpacity(0.1))),
),
child: Container(
child: Row(
children: [
Container(
width: 20,
height: 20,
child: Icon(IconFont.deviceGroupOutline,
color: MyTheme.accent, size: 19),
).marginOnly(right: 4),
Expanded(child: Text(name)),
],
).paddingSymmetric(vertical: 4),
),
);
},
)).marginSymmetric(horizontal: 12).marginOnly(bottom: 6);
}
} }

View File

@@ -716,18 +716,18 @@ abstract class BasePeerCard extends StatelessWidget {
switch (tab) { switch (tab) {
case PeerTabIndex.recent: case PeerTabIndex.recent:
await bind.mainRemovePeer(id: id); await bind.mainRemovePeer(id: id);
await bind.mainLoadRecentPeers(); bind.mainLoadRecentPeers();
break; break;
case PeerTabIndex.fav: case PeerTabIndex.fav:
final favs = (await bind.mainGetFav()).toList(); final favs = (await bind.mainGetFav()).toList();
if (favs.remove(id)) { if (favs.remove(id)) {
await bind.mainStoreFav(favs: favs); await bind.mainStoreFav(favs: favs);
await bind.mainLoadFavPeers(); bind.mainLoadFavPeers();
} }
break; break;
case PeerTabIndex.lan: case PeerTabIndex.lan:
await bind.mainRemoveDiscovered(id: id); await bind.mainRemoveDiscovered(id: id);
await bind.mainLoadLanPeers(); bind.mainLoadLanPeers();
break; break;
case PeerTabIndex.ab: case PeerTabIndex.ab:
await gFFI.abModel.deletePeers([id]); await gFFI.abModel.deletePeers([id]);

View File

@@ -33,8 +33,8 @@ class PeerTabPage extends StatefulWidget {
class _TabEntry { class _TabEntry {
final Widget widget; final Widget widget;
final Function({dynamic hint}) load; final Function({dynamic hint})? load;
_TabEntry(this.widget, this.load); _TabEntry(this.widget, [this.load]);
} }
EdgeInsets? _menuPadding() { EdgeInsets? _menuPadding() {
@@ -44,21 +44,15 @@ EdgeInsets? _menuPadding() {
class _PeerTabPageState extends State<PeerTabPage> class _PeerTabPageState extends State<PeerTabPage>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
final List<_TabEntry> entries = [ final List<_TabEntry> entries = [
_TabEntry( _TabEntry(RecentPeersView(
RecentPeersView( menuPadding: _menuPadding(),
menuPadding: _menuPadding(), )),
), _TabEntry(FavoritePeersView(
bind.mainLoadRecentPeers), menuPadding: _menuPadding(),
_TabEntry( )),
FavoritePeersView( _TabEntry(DiscoveredPeersView(
menuPadding: _menuPadding(), menuPadding: _menuPadding(),
), )),
bind.mainLoadFavPeers),
_TabEntry(
DiscoveredPeersView(
menuPadding: _menuPadding(),
),
bind.mainDiscover),
_TabEntry( _TabEntry(
AddressBook( AddressBook(
menuPadding: _menuPadding(), menuPadding: _menuPadding(),
@@ -100,7 +94,7 @@ class _PeerTabPageState extends State<PeerTabPage>
gFFI.peerTabModel.setCurrentTabCachedPeers([]); gFFI.peerTabModel.setCurrentTabCachedPeers([]);
} }
gFFI.peerTabModel.setCurrentTab(tabIndex); gFFI.peerTabModel.setCurrentTab(tabIndex);
entries[tabIndex].load(hint: false); entries[tabIndex].load?.call(hint: false);
} }
} }
@@ -225,7 +219,7 @@ class _PeerTabPageState extends State<PeerTabPage>
child: RefreshWidget( child: RefreshWidget(
onPressed: () { onPressed: () {
if (gFFI.peerTabModel.currentTab < entries.length) { if (gFFI.peerTabModel.currentTab < entries.length) {
entries[gFFI.peerTabModel.currentTab].load(); entries[gFFI.peerTabModel.currentTab].load?.call();
} }
}, },
spinning: loading, spinning: loading,
@@ -404,7 +398,7 @@ class _PeerTabPageState extends State<PeerTabPage>
for (var p in peers) { for (var p in peers) {
await bind.mainRemovePeer(id: p.id); await bind.mainRemovePeer(id: p.id);
} }
await bind.mainLoadRecentPeers(); bind.mainLoadRecentPeers();
break; break;
case 1: case 1:
final favs = (await bind.mainGetFav()).toList(); final favs = (await bind.mainGetFav()).toList();
@@ -412,13 +406,13 @@ class _PeerTabPageState extends State<PeerTabPage>
favs.remove(p.id); favs.remove(p.id);
}).toList(); }).toList();
await bind.mainStoreFav(favs: favs); await bind.mainStoreFav(favs: favs);
await bind.mainLoadFavPeers(); bind.mainLoadFavPeers();
break; break;
case 2: case 2:
for (var p in peers) { for (var p in peers) {
await bind.mainRemoveDiscovered(id: p.id); await bind.mainRemoveDiscovered(id: p.id);
} }
await bind.mainLoadLanPeers(); bind.mainLoadLanPeers();
break; break;
case 3: case 3:
await gFFI.abModel.deletePeers(peers.map((p) => p.id).toList()); await gFFI.abModel.deletePeers(peers.map((p) => p.id).toList());

View File

@@ -25,13 +25,13 @@ class PeerSortType {
static const String remoteId = 'Remote ID'; static const String remoteId = 'Remote ID';
static const String remoteHost = 'Remote Host'; static const String remoteHost = 'Remote Host';
static const String username = 'Username'; static const String username = 'Username';
// static const String status = 'Status'; static const String status = 'Status';
static List<String> values = [ static List<String> values = [
PeerSortType.remoteId, PeerSortType.remoteId,
PeerSortType.remoteHost, PeerSortType.remoteHost,
PeerSortType.username, PeerSortType.username,
// PeerSortType.status PeerSortType.status
]; ];
} }
@@ -384,9 +384,9 @@ class _PeersViewState extends State<_PeersView>
peers.sort((p1, p2) => peers.sort((p1, p2) =>
p1.username.toLowerCase().compareTo(p2.username.toLowerCase())); p1.username.toLowerCase().compareTo(p2.username.toLowerCase()));
break; break;
// case PeerSortType.status: case PeerSortType.status:
// peers.sort((p1, p2) => p1.online ? -1 : 1); peers.sort((p1, p2) => p1.online ? -1 : 1);
// break; break;
} }
} }
@@ -562,14 +562,26 @@ class MyGroupPeerView extends BasePeersView {
); );
static bool filter(Peer peer) { static bool filter(Peer peer) {
if (gFFI.groupModel.searchUserText.isNotEmpty) { final model = gFFI.groupModel;
if (!peer.loginName.contains(gFFI.groupModel.searchUserText)) { if (model.searchAccessibleItemNameText.isNotEmpty) {
final text = model.searchAccessibleItemNameText.value;
final searchPeersOfUser = peer.loginName.contains(text) &&
model.users.any((user) => user.name == peer.loginName);
final searchPeersOfDeviceGroup = peer.device_group_name.contains(text) &&
model.deviceGroups.any((g) => g.name == peer.device_group_name);
if (!searchPeersOfUser && !searchPeersOfDeviceGroup) {
return false; return false;
} }
} }
if (gFFI.groupModel.selectedUser.isNotEmpty) { if (model.selectedAccessibleItemName.isNotEmpty) {
if (gFFI.groupModel.selectedUser.value != peer.loginName) { if (model.isSelectedDeviceGroup.value) {
return false; if (model.selectedAccessibleItemName.value != peer.device_group_name) {
return false;
}
} else {
if (model.selectedAccessibleItemName.value != peer.loginName) {
return false;
}
} }
} }
return true; return true;

View File

@@ -187,6 +187,11 @@ class _RawTouchGestureDetectorRegionState
return; return;
} }
_cacheLongPressPositionTs = DateTime.now().millisecondsSinceEpoch; _cacheLongPressPositionTs = DateTime.now().millisecondsSinceEpoch;
if (ffiModel.isPeerMobile) {
await ffi.cursorModel
.move(_cacheLongPressPosition.dx, _cacheLongPressPosition.dy);
await inputModel.tapDown(MouseButtons.left);
}
} }
} }
@@ -204,15 +209,31 @@ class _RawTouchGestureDetectorRegionState
if (lastDeviceKind != PointerDeviceKind.touch) { if (lastDeviceKind != PointerDeviceKind.touch) {
return; return;
} }
if (!ffi.ffiModel.isPeerMobile) {
if (handleTouch) {
final isMoved = await ffi.cursorModel
.move(_cacheLongPressPosition.dx, _cacheLongPressPosition.dy);
if (!isMoved) {
return;
}
}
await inputModel.tap(MouseButtons.right);
} else {
// It's better to send a message to tell the controlled device that the long press event is triggered.
// We're now using a `TimerTask` in `InputService.kt` to decide whether to trigger the long press event.
// It's not accurate and it's better to use the same detection logic in the controlling side.
}
}
onLongPressMoveUpdate(LongPressMoveUpdateDetails d) async {
if (!ffiModel.isPeerMobile || lastDeviceKind != PointerDeviceKind.touch) {
return;
}
if (handleTouch) { if (handleTouch) {
final isMoved = await ffi.cursorModel if (!ffi.cursorModel.isInRemoteRect(d.localPosition)) {
.move(_cacheLongPressPosition.dx, _cacheLongPressPosition.dy);
if (!isMoved) {
return; return;
} }
} await ffi.cursorModel.move(d.localPosition.dx, d.localPosition.dy);
if (!ffi.ffiModel.isPeerMobile) {
await inputModel.tap(MouseButtons.right);
} }
} }
@@ -340,7 +361,7 @@ class _RawTouchGestureDetectorRegionState
ffi.cursorModel.clearRemoteWindowCoords(); ffi.cursorModel.clearRemoteWindowCoords();
} }
if (handleTouch) { if (handleTouch) {
await inputModel.sendMouse('up', MouseButtons.left); await inputModel.sendMouse('up', MouseButtons.left);
} }
} }
@@ -432,7 +453,8 @@ class _RawTouchGestureDetectorRegionState
instance instance
..onLongPressDown = onLongPressDown ..onLongPressDown = onLongPressDown
..onLongPressUp = onLongPressUp ..onLongPressUp = onLongPressUp
..onLongPress = onLongPress; ..onLongPress = onLongPress
..onLongPressMoveUpdate = onLongPressMoveUpdate;
}), }),
// Customized // Customized
HoldTapMoveGestureRecognizer: HoldTapMoveGestureRecognizer:

View File

@@ -248,7 +248,7 @@ const kFullScreenEdgeSize = 0.0;
const kMaximizeEdgeSize = 0.0; const kMaximizeEdgeSize = 0.0;
// Do not use kWindowResizeEdgeSize directly. Use `windowResizeEdgeSize` in `common.dart` instead. // Do not use kWindowResizeEdgeSize directly. Use `windowResizeEdgeSize` in `common.dart` instead.
const kWindowResizeEdgeSize = 5.0; const kWindowResizeEdgeSize = 5.0;
const kWindowBorderWidth = 1.0; final kWindowBorderWidth = isWindows ? 0.0 : 1.0;
const kDesktopMenuPadding = EdgeInsets.only(left: 12.0, right: 3.0); const kDesktopMenuPadding = EdgeInsets.only(left: 12.0, right: 3.0);
const kFrameBorderRadius = 12.0; const kFrameBorderRadius = 12.0;
const kFrameClipRRectBorderRadius = 12.0; const kFrameClipRRectBorderRadius = 12.0;

View File

@@ -200,18 +200,21 @@ class _ConnectionPageState extends State<ConnectionPage>
final _idController = IDTextEditingController(); final _idController = IDTextEditingController();
final RxBool _idInputFocused = false.obs; final RxBool _idInputFocused = false.obs;
final FocusNode _idFocusNode = FocusNode();
final TextEditingController _idEditingController = TextEditingController();
bool isWindowMinimized = false; bool isWindowMinimized = false;
List<Peer> peers = [];
bool isPeersLoading = false; final AllPeersLoader _allPeersLoader = AllPeersLoader();
bool isPeersLoaded = false;
// https://github.com/flutter/flutter/issues/157244 // https://github.com/flutter/flutter/issues/157244
Iterable<Peer> _autocompleteOpts = []; Iterable<Peer> _autocompleteOpts = [];
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_allPeersLoader.init(setState);
_idFocusNode.addListener(onFocusChanged);
if (_idController.text.isEmpty) { if (_idController.text.isEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) async { WidgetsBinding.instance.addPostFrameCallback((_) async {
final lastRemoteId = await bind.mainGetLastRemoteId(); final lastRemoteId = await bind.mainGetLastRemoteId();
@@ -222,6 +225,7 @@ class _ConnectionPageState extends State<ConnectionPage>
} }
}); });
} }
Get.put<TextEditingController>(_idEditingController);
Get.put<IDTextEditingController>(_idController); Get.put<IDTextEditingController>(_idController);
windowManager.addListener(this); windowManager.addListener(this);
} }
@@ -230,6 +234,10 @@ class _ConnectionPageState extends State<ConnectionPage>
void dispose() { void dispose() {
_idController.dispose(); _idController.dispose();
windowManager.removeListener(this); windowManager.removeListener(this);
_allPeersLoader.clear();
_idFocusNode.removeListener(onFocusChanged);
_idFocusNode.dispose();
_idEditingController.dispose();
if (Get.isRegistered<IDTextEditingController>()) { if (Get.isRegistered<IDTextEditingController>()) {
Get.delete<IDTextEditingController>(); Get.delete<IDTextEditingController>();
} }
@@ -273,6 +281,20 @@ class _ConnectionPageState extends State<ConnectionPage>
bind.mainOnMainWindowClose(); bind.mainOnMainWindowClose();
} }
void onFocusChanged() {
_idInputFocused.value = _idFocusNode.hasFocus;
if (_idFocusNode.hasFocus) {
if (_allPeersLoader.needLoad) {
_allPeersLoader.getAllPeers();
}
final textLength = _idEditingController.value.text.length;
// Select all to facilitate removing text, just following the behavior of address input of chrome.
_idEditingController.selection =
TextSelection(baseOffset: 0, extentOffset: textLength);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isOutgoingOnly = bind.isOutgoingOnly(); final isOutgoingOnly = bind.isOutgoingOnly();
@@ -304,18 +326,6 @@ class _ConnectionPageState extends State<ConnectionPage>
connect(context, id, isFileTransfer: isFileTransfer); connect(context, id, isFileTransfer: isFileTransfer);
} }
Future<void> _fetchPeers() async {
setState(() {
isPeersLoading = true;
});
await Future.delayed(Duration(milliseconds: 100));
peers = await getAllPeers();
setState(() {
isPeersLoading = false;
isPeersLoaded = true;
});
}
/// UI for the remote ID TextField. /// UI for the remote ID TextField.
/// Search for a peer. /// Search for a peer.
Widget _buildRemoteIDTextField(BuildContext context) { Widget _buildRemoteIDTextField(BuildContext context) {
@@ -332,11 +342,12 @@ class _ConnectionPageState extends State<ConnectionPage>
Row( Row(
children: [ children: [
Expanded( Expanded(
child: Autocomplete<Peer>( child: RawAutocomplete<Peer>(
optionsBuilder: (TextEditingValue textEditingValue) { optionsBuilder: (TextEditingValue textEditingValue) {
if (textEditingValue.text == '') { if (textEditingValue.text == '') {
_autocompleteOpts = const Iterable<Peer>.empty(); _autocompleteOpts = const Iterable<Peer>.empty();
} else if (peers.isEmpty && !isPeersLoaded) { } else if (_allPeersLoader.peers.isEmpty &&
!_allPeersLoader.isPeersLoaded) {
Peer emptyPeer = Peer( Peer emptyPeer = Peer(
id: '', id: '',
username: '', username: '',
@@ -350,6 +361,7 @@ class _ConnectionPageState extends State<ConnectionPage>
rdpPort: '', rdpPort: '',
rdpUsername: '', rdpUsername: '',
loginName: '', loginName: '',
device_group_name: '',
); );
_autocompleteOpts = [emptyPeer]; _autocompleteOpts = [emptyPeer];
} else { } else {
@@ -362,7 +374,7 @@ class _ConnectionPageState extends State<ConnectionPage>
); );
} }
String textToFind = textEditingValue.text.toLowerCase(); String textToFind = textEditingValue.text.toLowerCase();
_autocompleteOpts = peers _autocompleteOpts = _allPeersLoader.peers
.where((peer) => .where((peer) =>
peer.id.toLowerCase().contains(textToFind) || peer.id.toLowerCase().contains(textToFind) ||
peer.username peer.username
@@ -376,25 +388,16 @@ class _ConnectionPageState extends State<ConnectionPage>
} }
return _autocompleteOpts; return _autocompleteOpts;
}, },
focusNode: _idFocusNode,
textEditingController: _idEditingController,
fieldViewBuilder: ( fieldViewBuilder: (
BuildContext context, BuildContext context,
TextEditingController fieldTextEditingController, TextEditingController fieldTextEditingController,
FocusNode fieldFocusNode, FocusNode fieldFocusNode,
VoidCallback onFieldSubmitted, VoidCallback onFieldSubmitted,
) { ) {
fieldTextEditingController.text = _idController.text; updateTextAndPreserveSelection(
Get.put<TextEditingController>(fieldTextEditingController); fieldTextEditingController, _idController.text);
fieldFocusNode.addListener(() async {
_idInputFocused.value = fieldFocusNode.hasFocus;
if (fieldFocusNode.hasFocus && !isPeersLoading) {
_fetchPeers();
}
});
final textLength =
fieldTextEditingController.value.text.length;
// select all to facilitate removing text, just following the behavior of address input of chrome
fieldTextEditingController.selection =
TextSelection(baseOffset: 0, extentOffset: textLength);
return Obx(() => TextField( return Obx(() => TextField(
autocorrect: false, autocorrect: false,
enableSuggestions: false, enableSuggestions: false,
@@ -467,7 +470,8 @@ class _ConnectionPageState extends State<ConnectionPage>
maxHeight: maxHeight, maxHeight: maxHeight,
maxWidth: 319, maxWidth: 319,
), ),
child: peers.isEmpty && isPeersLoading child: _allPeersLoader.peers.isEmpty &&
!_allPeersLoader.isPeersLoaded
? Container( ? Container(
height: 80, height: 80,
child: Center( child: Center(

View File

@@ -103,11 +103,13 @@ class _FileManagerTabPageState extends State<FileManagerTabPage> {
)); ));
final tabWidget = isLinux final tabWidget = isLinux
? buildVirtualWindowFrame(context, child) ? buildVirtualWindowFrame(context, child)
: Container( : workaroundWindowBorder(
decoration: BoxDecoration( context,
border: Border.all(color: MyTheme.color(context).border!)), Container(
child: child, decoration: BoxDecoration(
); border: Border.all(color: MyTheme.color(context).border!)),
child: child,
));
return isMacOS || kUseCompatibleUiMode return isMacOS || kUseCompatibleUiMode
? tabWidget ? tabWidget
: SubWindowDragToResizeArea( : SubWindowDragToResizeArea(

View File

@@ -118,11 +118,13 @@ class _PortForwardTabPageState extends State<PortForwardTabPage> {
backgroundColor: Theme.of(context).colorScheme.background, backgroundColor: Theme.of(context).colorScheme.background,
body: child), body: child),
) )
: Container( : workaroundWindowBorder(
decoration: BoxDecoration( context,
border: Border.all(color: MyTheme.color(context).border!)), Container(
child: child, decoration: BoxDecoration(
); border: Border.all(color: MyTheme.color(context).border!)),
child: child,
));
return isMacOS || kUseCompatibleUiMode return isMacOS || kUseCompatibleUiMode
? tabWidget ? tabWidget
: Obx( : Obx(

View File

@@ -212,14 +212,16 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
); );
final tabWidget = isLinux final tabWidget = isLinux
? buildVirtualWindowFrame(context, child) ? buildVirtualWindowFrame(context, child)
: Obx(() => Container( : workaroundWindowBorder(
decoration: BoxDecoration( context,
border: Border.all( Obx(() => Container(
color: MyTheme.color(context).border!, decoration: BoxDecoration(
width: stateGlobal.windowBorderWidth.value), border: Border.all(
), color: MyTheme.color(context).border!,
child: child, width: stateGlobal.windowBorderWidth.value),
)); ),
child: child,
)));
return isMacOS || kUseCompatibleUiMode return isMacOS || kUseCompatibleUiMode
? tabWidget ? tabWidget
: Obx(() => SubWindowDragToResizeArea( : Obx(() => SubWindowDragToResizeArea(

View File

@@ -88,12 +88,14 @@ class _DesktopServerPageState extends State<DesktopServerPage>
); );
return isLinux return isLinux
? buildVirtualWindowFrame(context, body) ? buildVirtualWindowFrame(context, body)
: Container( : workaroundWindowBorder(
decoration: BoxDecoration( context,
border: Container(
Border.all(color: MyTheme.color(context).border!)), decoration: BoxDecoration(
child: body, border:
); Border.all(color: MyTheme.color(context).border!)),
child: body,
));
}, },
), ),
); );

View File

@@ -133,7 +133,8 @@ void runMainApp(bool startService) async {
runApp(App()); runApp(App());
// Set window option. // Set window option.
WindowOptions windowOptions = getHiddenTitleBarWindowOptions(); WindowOptions windowOptions =
getHiddenTitleBarWindowOptions(isMainWindow: true);
windowManager.waitUntilReadyToShow(windowOptions, () async { windowManager.waitUntilReadyToShow(windowOptions, () async {
// Restore the location of the main window before window hide or show. // Restore the location of the main window before window hide or show.
await restoreWindowPosition(WindowType.Main); await restoreWindowPosition(WindowType.Main);
@@ -354,7 +355,10 @@ void runInstallPage() async {
} }
WindowOptions getHiddenTitleBarWindowOptions( WindowOptions getHiddenTitleBarWindowOptions(
{Size? size, bool center = false, bool? alwaysOnTop}) { {bool isMainWindow = false,
Size? size,
bool center = false,
bool? alwaysOnTop}) {
var defaultTitleBarStyle = TitleBarStyle.hidden; var defaultTitleBarStyle = TitleBarStyle.hidden;
// we do not hide titlebar on win7 because of the frame overflow. // we do not hide titlebar on win7 because of the frame overflow.
if (kUseCompatibleUiMode) { if (kUseCompatibleUiMode) {
@@ -363,7 +367,7 @@ WindowOptions getHiddenTitleBarWindowOptions(
return WindowOptions( return WindowOptions(
size: size, size: size,
center: center, center: center,
backgroundColor: Colors.transparent, backgroundColor: (isMacOS && isMainWindow) ? null : Colors.transparent,
skipTaskbar: false, skipTaskbar: false,
titleBarStyle: defaultTitleBarStyle, titleBarStyle: defaultTitleBarStyle,
alwaysOnTop: alwaysOnTop, alwaysOnTop: alwaysOnTop,
@@ -485,9 +489,10 @@ class _AppState extends State<App> with WidgetsBindingObserver {
child = keyListenerBuilder(context, child); child = keyListenerBuilder(context, child);
} }
if (isLinux) { if (isLinux) {
child = buildVirtualWindowFrame(context, child); return buildVirtualWindowFrame(context, child);
} else {
return workaroundWindowBorder(context, child);
} }
return child;
}, },
), ),
); );

View File

@@ -41,10 +41,11 @@ class _ConnectionPageState extends State<ConnectionPage> {
final _idController = IDTextEditingController(); final _idController = IDTextEditingController();
final RxBool _idEmpty = true.obs; final RxBool _idEmpty = true.obs;
List<Peer> peers = []; final FocusNode _idFocusNode = FocusNode();
final TextEditingController _idEditingController = TextEditingController();
final AllPeersLoader _allPeersLoader = AllPeersLoader();
bool isPeersLoading = false;
bool isPeersLoaded = false;
StreamSubscription? _uniLinksSubscription; StreamSubscription? _uniLinksSubscription;
// https://github.com/flutter/flutter/issues/157244 // https://github.com/flutter/flutter/issues/157244
@@ -61,6 +62,8 @@ class _ConnectionPageState extends State<ConnectionPage> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_allPeersLoader.init(setState);
_idFocusNode.addListener(onFocusChanged);
if (_idController.text.isEmpty) { if (_idController.text.isEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) async { WidgetsBinding.instance.addPostFrameCallback((_) async {
final lastRemoteId = await bind.mainGetLastRemoteId(); final lastRemoteId = await bind.mainGetLastRemoteId();
@@ -71,6 +74,7 @@ class _ConnectionPageState extends State<ConnectionPage> {
} }
}); });
} }
Get.put<TextEditingController>(_idEditingController);
} }
@override @override
@@ -99,6 +103,20 @@ class _ConnectionPageState extends State<ConnectionPage> {
connect(context, id); connect(context, id);
} }
void onFocusChanged() {
_idEmpty.value = _idEditingController.text.isEmpty;
if (_idFocusNode.hasFocus) {
if (_allPeersLoader.needLoad) {
_allPeersLoader.getAllPeers();
}
final textLength = _idEditingController.value.text.length;
// Select all to facilitate removing text, just following the behavior of address input of chrome.
_idEditingController.selection =
TextSelection(baseOffset: 0, extentOffset: textLength);
}
}
/// UI for software update. /// UI for software update.
/// If _updateUrl] is not empty, shows a button to update the software. /// If _updateUrl] is not empty, shows a button to update the software.
Widget _buildUpdateUI(String updateUrl) { Widget _buildUpdateUI(String updateUrl) {
@@ -127,18 +145,6 @@ class _ConnectionPageState extends State<ConnectionPage> {
color: Colors.white, fontWeight: FontWeight.bold)))); color: Colors.white, fontWeight: FontWeight.bold))));
} }
Future<void> _fetchPeers() async {
setState(() {
isPeersLoading = true;
});
await Future.delayed(Duration(milliseconds: 100));
peers = await getAllPeers();
setState(() {
isPeersLoading = false;
isPeersLoaded = true;
});
}
/// UI for the remote ID TextField. /// UI for the remote ID TextField.
/// Search for a peer and connect to it if the id exists. /// Search for a peer and connect to it if the id exists.
Widget _buildRemoteIDTextField() { Widget _buildRemoteIDTextField() {
@@ -156,11 +162,12 @@ class _ConnectionPageState extends State<ConnectionPage> {
Expanded( Expanded(
child: Container( child: Container(
padding: const EdgeInsets.only(left: 16, right: 16), padding: const EdgeInsets.only(left: 16, right: 16),
child: Autocomplete<Peer>( child: RawAutocomplete<Peer>(
optionsBuilder: (TextEditingValue textEditingValue) { optionsBuilder: (TextEditingValue textEditingValue) {
if (textEditingValue.text == '') { if (textEditingValue.text == '') {
_autocompleteOpts = const Iterable<Peer>.empty(); _autocompleteOpts = const Iterable<Peer>.empty();
} else if (peers.isEmpty && !isPeersLoaded) { } else if (_allPeersLoader.peers.isEmpty &&
!_allPeersLoader.isPeersLoaded) {
Peer emptyPeer = Peer( Peer emptyPeer = Peer(
id: '', id: '',
username: '', username: '',
@@ -174,6 +181,7 @@ class _ConnectionPageState extends State<ConnectionPage> {
rdpPort: '', rdpPort: '',
rdpUsername: '', rdpUsername: '',
loginName: '', loginName: '',
device_group_name: '',
); );
_autocompleteOpts = [emptyPeer]; _autocompleteOpts = [emptyPeer];
} else { } else {
@@ -187,7 +195,7 @@ class _ConnectionPageState extends State<ConnectionPage> {
} }
String textToFind = textEditingValue.text.toLowerCase(); String textToFind = textEditingValue.text.toLowerCase();
_autocompleteOpts = peers _autocompleteOpts = _allPeersLoader.peers
.where((peer) => .where((peer) =>
peer.id.toLowerCase().contains(textToFind) || peer.id.toLowerCase().contains(textToFind) ||
peer.username peer.username
@@ -201,25 +209,14 @@ class _ConnectionPageState extends State<ConnectionPage> {
} }
return _autocompleteOpts; return _autocompleteOpts;
}, },
focusNode: _idFocusNode,
textEditingController: _idEditingController,
fieldViewBuilder: (BuildContext context, fieldViewBuilder: (BuildContext context,
TextEditingController fieldTextEditingController, TextEditingController fieldTextEditingController,
FocusNode fieldFocusNode, FocusNode fieldFocusNode,
VoidCallback onFieldSubmitted) { VoidCallback onFieldSubmitted) {
fieldTextEditingController.text = _idController.text; updateTextAndPreserveSelection(
Get.put<TextEditingController>( fieldTextEditingController, _idController.text);
fieldTextEditingController);
fieldFocusNode.addListener(() async {
_idEmpty.value =
fieldTextEditingController.text.isEmpty;
if (fieldFocusNode.hasFocus && !isPeersLoading) {
_fetchPeers();
}
});
final textLength =
fieldTextEditingController.value.text.length;
// select all to facilitate removing text, just following the behavior of address input of chrome
fieldTextEditingController.selection = TextSelection(
baseOffset: 0, extentOffset: textLength);
return AutoSizeTextField( return AutoSizeTextField(
controller: fieldTextEditingController, controller: fieldTextEditingController,
focusNode: fieldFocusNode, focusNode: fieldFocusNode,
@@ -299,7 +296,9 @@ class _ConnectionPageState extends State<ConnectionPage> {
maxHeight: maxHeight, maxHeight: maxHeight,
maxWidth: 320, maxWidth: 320,
), ),
child: peers.isEmpty && isPeersLoading child: _allPeersLoader
.peers.isEmpty &&
!_allPeersLoader.isPeersLoaded
? Container( ? Container(
height: 80, height: 80,
child: Center( child: Center(
@@ -362,6 +361,10 @@ class _ConnectionPageState extends State<ConnectionPage> {
void dispose() { void dispose() {
_uniLinksSubscription?.cancel(); _uniLinksSubscription?.cancel();
_idController.dispose(); _idController.dispose();
_idFocusNode.removeListener(onFocusChanged);
_allPeersLoader.clear();
_idFocusNode.dispose();
_idEditingController.dispose();
if (Get.isRegistered<IDTextEditingController>()) { if (Get.isRegistered<IDTextEditingController>()) {
Get.delete<IDTextEditingController>(); Get.delete<IDTextEditingController>();
} }

View File

@@ -58,6 +58,9 @@ class AbModel {
String? _personalAbGuid; String? _personalAbGuid;
RxBool legacyMode = false.obs; RxBool legacyMode = false.obs;
// Only handles peers add/remove
final Map<String, VoidCallback> _peerIdUpdateListeners = {};
final sortTags = shouldSortTags().obs; final sortTags = shouldSortTags().obs;
final filterByIntersection = filterAbTagByIntersection().obs; final filterByIntersection = filterAbTagByIntersection().obs;
@@ -188,6 +191,7 @@ class AbModel {
debugPrint("pull current Ab error: $e"); debugPrint("pull current Ab error: $e");
} }
} }
_callbackPeerUpdate();
if (listInitialized && current.initialized) { if (listInitialized && current.initialized) {
_saveCache(); _saveCache();
} }
@@ -419,6 +423,7 @@ class AbModel {
} }
}); });
} }
_callbackPeerUpdate();
return ret; return ret;
} }
@@ -620,6 +625,9 @@ class AbModel {
} }
} }
} }
if (abEntries.isNotEmpty) {
_callbackPeerUpdate();
}
} }
} }
@@ -742,6 +750,20 @@ class AbModel {
} }
} }
void _callbackPeerUpdate() {
for (var listener in _peerIdUpdateListeners.values) {
listener();
}
}
void addPeerUpdateListener(String key, VoidCallback listener) {
_peerIdUpdateListeners[key] = listener;
}
void removePeerUpdateListener(String key) {
_peerIdUpdateListeners.remove(key);
}
// #endregion // #endregion
} }

View File

@@ -12,16 +12,20 @@ import '../utils/http_service.dart' as http;
class GroupModel { class GroupModel {
final RxBool groupLoading = false.obs; final RxBool groupLoading = false.obs;
final RxString groupLoadError = "".obs; final RxString groupLoadError = "".obs;
final RxList<DeviceGroupPayload> deviceGroups = RxList.empty(growable: true);
final RxList<UserPayload> users = RxList.empty(growable: true); final RxList<UserPayload> users = RxList.empty(growable: true);
final RxList<Peer> peers = RxList.empty(growable: true); final RxList<Peer> peers = RxList.empty(growable: true);
final RxString selectedUser = ''.obs; final RxBool isSelectedDeviceGroup = false.obs;
final RxString searchUserText = ''.obs; final RxString selectedAccessibleItemName = ''.obs;
final RxString searchAccessibleItemNameText = ''.obs;
WeakReference<FFI> parent; WeakReference<FFI> parent;
var initialized = false; var initialized = false;
var _cacheLoadOnceFlag = false; var _cacheLoadOnceFlag = false;
var _statusCode = 200; var _statusCode = 200;
bool get emtpy => users.isEmpty && peers.isEmpty; final Map<String, VoidCallback> _peerIdUpdateListeners = {};
bool get emtpy => deviceGroups.isEmpty && users.isEmpty && peers.isEmpty;
late final Peers peersModel; late final Peers peersModel;
@@ -55,6 +59,12 @@ class GroupModel {
} }
Future<void> _pull() async { Future<void> _pull() async {
List<DeviceGroupPayload> tmpDeviceGroups = List.empty(growable: true);
if (!await _getDeviceGroups(tmpDeviceGroups)) {
// old hbbs doesn't support this api
// return;
}
tmpDeviceGroups.sort((a, b) => a.name.compareTo(b.name));
List<UserPayload> tmpUsers = List.empty(growable: true); List<UserPayload> tmpUsers = List.empty(growable: true);
if (!await _getUsers(tmpUsers)) { if (!await _getUsers(tmpUsers)) {
return; return;
@@ -63,6 +73,7 @@ class GroupModel {
if (!await _getPeers(tmpPeers)) { if (!await _getPeers(tmpPeers)) {
return; return;
} }
deviceGroups.value = tmpDeviceGroups;
// me first // me first
var index = tmpUsers var index = tmpUsers
.indexWhere((user) => user.name == gFFI.userModel.userName.value); .indexWhere((user) => user.name == gFFI.userModel.userName.value);
@@ -71,8 +82,9 @@ class GroupModel {
tmpUsers.insert(0, user); tmpUsers.insert(0, user);
} }
users.value = tmpUsers; users.value = tmpUsers;
if (!users.any((u) => u.name == selectedUser.value)) { if (!users.any((u) => u.name == selectedAccessibleItemName.value) &&
selectedUser.value = ''; !deviceGroups.any((d) => d.name == selectedAccessibleItemName.value)) {
selectedAccessibleItemName.value = '';
} }
// recover online // recover online
final oldOnlineIDs = peers.where((e) => e.online).map((e) => e.id).toList(); final oldOnlineIDs = peers.where((e) => e.online).map((e) => e.id).toList();
@@ -82,6 +94,64 @@ class GroupModel {
.map((e) => e.online = true) .map((e) => e.online = true)
.toList(); .toList();
groupLoadError.value = ''; groupLoadError.value = '';
_callbackPeerUpdate();
}
Future<bool> _getDeviceGroups(
List<DeviceGroupPayload> tmpDeviceGroups) async {
final api = "${await bind.mainGetApiServer()}/api/device-group/accessible";
try {
var uri0 = Uri.parse(api);
final pageSize = 100;
var total = 0;
int current = 0;
do {
current += 1;
var uri = Uri(
scheme: uri0.scheme,
host: uri0.host,
path: uri0.path,
port: uri0.port,
queryParameters: {
'current': current.toString(),
'pageSize': pageSize.toString(),
});
final resp = await http.get(uri, headers: getHttpHeaders());
_statusCode = resp.statusCode;
Map<String, dynamic> json =
_jsonDecodeResp(utf8.decode(resp.bodyBytes), resp.statusCode);
if (json.containsKey('error')) {
throw json['error'];
}
if (resp.statusCode != 200) {
throw 'HTTP ${resp.statusCode}';
}
if (json.containsKey('total')) {
if (total == 0) total = json['total'];
if (json.containsKey('data')) {
final data = json['data'];
if (data is List) {
for (final user in data) {
final u = DeviceGroupPayload.fromJson(user);
int index = tmpDeviceGroups.indexWhere((e) => e.name == u.name);
if (index < 0) {
tmpDeviceGroups.add(u);
} else {
tmpDeviceGroups[index] = u;
}
}
}
}
}
} while (current * pageSize < total);
return true;
} catch (err) {
debugPrint('get accessible device groups: $err');
// old hbbs doesn't support this api
// groupLoadError.value =
// '${translate('pull_group_failed_tip')}: ${translate(err.toString())}';
}
return false;
} }
Future<bool> _getUsers(List<UserPayload> tmpUsers) async { Future<bool> _getUsers(List<UserPayload> tmpUsers) async {
@@ -225,6 +295,7 @@ class GroupModel {
try { try {
final map = (<String, dynamic>{ final map = (<String, dynamic>{
"access_token": bind.mainGetLocalOption(key: 'access_token'), "access_token": bind.mainGetLocalOption(key: 'access_token'),
"device_groups": deviceGroups.map((e) => e.toGroupCacheJson()).toList(),
"users": users.map((e) => e.toGroupCacheJson()).toList(), "users": users.map((e) => e.toGroupCacheJson()).toList(),
'peers': peers.map((e) => e.toGroupCacheJson()).toList() 'peers': peers.map((e) => e.toGroupCacheJson()).toList()
}); });
@@ -244,8 +315,14 @@ class GroupModel {
if (groupLoading.value) return; if (groupLoading.value) return;
final data = jsonDecode(cache); final data = jsonDecode(cache);
if (data == null || data['access_token'] != access_token) return; if (data == null || data['access_token'] != access_token) return;
deviceGroups.clear();
users.clear(); users.clear();
peers.clear(); peers.clear();
if (data['device_groups'] is List) {
for (var u in data['device_groups']) {
deviceGroups.add(DeviceGroupPayload.fromJson(u));
}
}
if (data['users'] is List) { if (data['users'] is List) {
for (var u in data['users']) { for (var u in data['users']) {
users.add(UserPayload.fromJson(u)); users.add(UserPayload.fromJson(u));
@@ -255,6 +332,7 @@ class GroupModel {
for (final peer in data['peers']) { for (final peer in data['peers']) {
peers.add(Peer.fromJson(peer)); peers.add(Peer.fromJson(peer));
} }
_callbackPeerUpdate();
} }
} catch (e) { } catch (e) {
debugPrint("load group cache: $e"); debugPrint("load group cache: $e");
@@ -263,9 +341,24 @@ class GroupModel {
reset() async { reset() async {
groupLoadError.value = ''; groupLoadError.value = '';
deviceGroups.clear();
users.clear(); users.clear();
peers.clear(); peers.clear();
selectedUser.value = ''; selectedAccessibleItemName.value = '';
await bind.mainClearGroup(); await bind.mainClearGroup();
} }
void _callbackPeerUpdate() {
for (var listener in _peerIdUpdateListeners.values) {
listener();
}
}
void addPeerUpdateListener(String key, VoidCallback listener) {
_peerIdUpdateListeners[key] = listener;
}
void removePeerUpdateListener(String key) {
_peerIdUpdateListeners.remove(key);
}
} }

View File

@@ -18,7 +18,7 @@ import '../common.dart';
import '../consts.dart'; import '../consts.dart';
/// Mouse button enum. /// Mouse button enum.
enum MouseButtons { left, right, wheel } enum MouseButtons { left, right, wheel, back }
const _kMouseEventDown = 'mousedown'; const _kMouseEventDown = 'mousedown';
const _kMouseEventUp = 'mouseup'; const _kMouseEventUp = 'mouseup';
@@ -155,6 +155,8 @@ extension ToString on MouseButtons {
return 'right'; return 'right';
case MouseButtons.wheel: case MouseButtons.wheel:
return 'wheel'; return 'wheel';
case MouseButtons.back:
return 'back';
} }
} }
} }
@@ -1426,7 +1428,18 @@ class InputModel {
} }
} }
void onMobileBack() => tap(MouseButtons.right); void onMobileBack() {
final minBackButtonVersion = "1.3.8";
final peerVersion =
parent.target?.ffiModel.pi.version ?? minBackButtonVersion;
var btn = MouseButtons.back;
// For compatibility with old versions
if (versionCmp(peerVersion, minBackButtonVersion) < 0) {
btn = MouseButtons.right;
}
tap(btn);
}
void onMobileHome() => tap(MouseButtons.wheel); void onMobileHome() => tap(MouseButtons.wheel);
Future<void> onMobileApps() async { Future<void> onMobileApps() async {
sendMouse('down', MouseButtons.wheel); sendMouse('down', MouseButtons.wheel);

View File

@@ -2430,6 +2430,8 @@ class CursorModel with ChangeNotifier {
_x = -10000; _x = -10000;
_x = -10000; _x = -10000;
_image = null; _image = null;
_firstUpdateMouseTime = null;
gotMouseControl = true;
disposeImages(); disposeImages();
_clearCache(); _clearCache();

View File

@@ -19,6 +19,7 @@ class Peer {
String rdpUsername; String rdpUsername;
bool online = false; bool online = false;
String loginName; //login username String loginName; //login username
String device_group_name;
bool? sameServer; bool? sameServer;
String getId() { String getId() {
@@ -41,6 +42,7 @@ class Peer {
rdpPort = json['rdpPort'] ?? '', rdpPort = json['rdpPort'] ?? '',
rdpUsername = json['rdpUsername'] ?? '', rdpUsername = json['rdpUsername'] ?? '',
loginName = json['loginName'] ?? '', loginName = json['loginName'] ?? '',
device_group_name = json['device_group_name'] ?? '',
sameServer = json['same_server']; sameServer = json['same_server'];
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
@@ -57,6 +59,7 @@ class Peer {
"rdpPort": rdpPort, "rdpPort": rdpPort,
"rdpUsername": rdpUsername, "rdpUsername": rdpUsername,
'loginName': loginName, 'loginName': loginName,
'device_group_name': device_group_name,
'same_server': sameServer, 'same_server': sameServer,
}; };
} }
@@ -83,6 +86,7 @@ class Peer {
"hostname": hostname, "hostname": hostname,
"platform": platform, "platform": platform,
"login_name": loginName, "login_name": loginName,
"device_group_name": device_group_name,
}; };
} }
@@ -99,6 +103,7 @@ class Peer {
required this.rdpPort, required this.rdpPort,
required this.rdpUsername, required this.rdpUsername,
required this.loginName, required this.loginName,
required this.device_group_name,
this.sameServer, this.sameServer,
}); });
@@ -116,6 +121,7 @@ class Peer {
rdpPort: '', rdpPort: '',
rdpUsername: '', rdpUsername: '',
loginName: '', loginName: '',
device_group_name: '',
); );
bool equal(Peer other) { bool equal(Peer other) {
return id == other.id && return id == other.id &&
@@ -129,6 +135,7 @@ class Peer {
forceAlwaysRelay == other.forceAlwaysRelay && forceAlwaysRelay == other.forceAlwaysRelay &&
rdpPort == other.rdpPort && rdpPort == other.rdpPort &&
rdpUsername == other.rdpUsername && rdpUsername == other.rdpUsername &&
device_group_name == other.device_group_name &&
loginName == other.loginName; loginName == other.loginName;
} }
@@ -146,6 +153,7 @@ class Peer {
rdpPort: other.rdpPort, rdpPort: other.rdpPort,
rdpUsername: other.rdpUsername, rdpUsername: other.rdpUsername,
loginName: other.loginName, loginName: other.loginName,
device_group_name: other.device_group_name,
sameServer: other.sameServer); sameServer: other.sameServer);
} }
@@ -157,6 +165,11 @@ class Peers extends ChangeNotifier {
final String name; final String name;
final String loadEvent; final String loadEvent;
List<Peer> peers = List.empty(growable: true); List<Peer> peers = List.empty(growable: true);
// Part of the peers that are not in the rest peers list.
// When there're too many peers, we may want to load the front 100 peers first,
// so we can see peers in UI quickly. `restPeerIds` is the rest peers' ids.
// And then load all peers later.
List<String> restPeerIds = List.empty(growable: true);
final GetInitPeers? getInitPeers; final GetInitPeers? getInitPeers;
UpdateEvent event = UpdateEvent.load; UpdateEvent event = UpdateEvent.load;
static const _cbQueryOnlines = 'callback_query_onlines'; static const _cbQueryOnlines = 'callback_query_onlines';
@@ -230,6 +243,12 @@ class Peers extends ChangeNotifier {
} else { } else {
peers = _decodePeers(evt['peers']); peers = _decodePeers(evt['peers']);
} }
restPeerIds = [];
if (evt['ids'] != null) {
restPeerIds = (evt['ids'] as String).split(',');
}
for (var peer in peers) { for (var peer in peers) {
final state = onlineStates[peer.id]; final state = onlineStates[peer.id];
peer.online = state != null && state != false; peer.online = state != null && state != false;

View File

@@ -28,14 +28,14 @@ class PeerTabModel with ChangeNotifier {
'Favorites', 'Favorites',
'Discovered', 'Discovered',
'Address book', 'Address book',
'Group', 'Accessible devices',
]; ];
static const List<IconData> icons = [ static const List<IconData> icons = [
Icons.access_time_filled, Icons.access_time_filled,
Icons.star, Icons.star,
Icons.explore, Icons.explore,
IconFont.addressBook, IconFont.addressBook,
Icons.group, IconFont.deviceGroupFill,
]; ];
List<bool> isEnabled = List.from([ List<bool> isEnabled = List.from([
true, true,

View File

@@ -2,16 +2,18 @@ import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hbb/common.dart'; import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/models/platform_model.dart';
void showPeerSelectionDialog( void showPeerSelectionDialog(
{bool singleSelection = false, {bool singleSelection = false,
required Function(List<String>) onPeersCallback}) { required Function(List<String>) onPeersCallback}) async {
final peers = bind.mainLoadRecentPeersSync(); // load recent peers, we can directly use the peers in `gFFI.recentPeersModel`.
// The plugin is not used for now, so just left it empty here.
final peers = '';
if (peers.isEmpty) { if (peers.isEmpty) {
debugPrint("load recent peers sync failed."); // debugPrint("load recent peers failed.");
return; return;
} }
Map<String, dynamic> map = jsonDecode(peers); Map<String, dynamic> map = jsonDecode(peers);
List<dynamic> peersList = map['peers'] ?? []; List<dynamic> peersList = map['peers'] ?? [];
final selected = List<String>.empty(growable: true); final selected = List<String>.empty(growable: true);

View File

@@ -11,4 +11,4 @@ PRODUCT_NAME = RustDesk
PRODUCT_BUNDLE_IDENTIFIER = com.carriez.flutterHbb PRODUCT_BUNDLE_IDENTIFIER = com.carriez.flutterHbb
// The copyright displayed in application information // The copyright displayed in application information
PRODUCT_COPYRIGHT = Copyright © 2024 Purslane Ltd. All rights reserved. PRODUCT_COPYRIGHT = Copyright © 2025 Purslane Ltd. All rights reserved.

View File

@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# Read more about iOS versioning at # Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # 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 # 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.3.7+56 version: 1.3.8+57
environment: environment:
sdk: '^3.1.0' sdk: '^3.1.0'
@@ -161,6 +161,9 @@ flutter:
- family: AddressBook - family: AddressBook
fonts: fonts:
- asset: assets/address_book.ttf - asset: assets/address_book.ttf
- family: DeviceGroup
fonts:
- asset: assets/device_group.ttf
# An image asset can refer to one or more resolution-specific "variants", see # An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/assets-and-images/#resolution-aware. # https://flutter.dev/assets-and-images/#resolution-aware.

View File

@@ -93,7 +93,7 @@ BEGIN
VALUE "FileDescription", "RustDesk Remote Desktop" "\0" VALUE "FileDescription", "RustDesk Remote Desktop" "\0"
VALUE "FileVersion", VERSION_AS_STRING "\0" VALUE "FileVersion", VERSION_AS_STRING "\0"
VALUE "InternalName", "rustdesk" "\0" VALUE "InternalName", "rustdesk" "\0"
VALUE "LegalCopyright", "Copyright © 2024 Purslane Ltd. All rights reserved." "\0" VALUE "LegalCopyright", "Copyright © 2025 Purslane Ltd. All rights reserved." "\0"
VALUE "OriginalFilename", "rustdesk.exe" "\0" VALUE "OriginalFilename", "rustdesk.exe" "\0"
VALUE "ProductName", "RustDesk" "\0" VALUE "ProductName", "RustDesk" "\0"
VALUE "ProductVersion", VERSION_AS_STRING "\0" VALUE "ProductVersion", VERSION_AS_STRING "\0"

View File

@@ -34,7 +34,6 @@ parking_lot = {version = "0.12"}
[target.'cfg(any(target_os = "linux", target_os = "macos"))'.dependencies] [target.'cfg(any(target_os = "linux", target_os = "macos"))'.dependencies]
rand = {version = "0.8", optional = true} rand = {version = "0.8", optional = true}
fuser = {version = "0.13", optional = true}
libc = {version = "0.2", optional = true} libc = {version = "0.2", optional = true}
dashmap = {version ="5.5", optional = true} dashmap = {version ="5.5", optional = true}
utf16string = {version = "0.2", optional = true} utf16string = {version = "0.2", optional = true}
@@ -44,6 +43,7 @@ once_cell = {version = "1.18", optional = true}
percent-encoding = {version ="2.3", optional = true} percent-encoding = {version ="2.3", optional = true}
x11-clipboard = {git="https://github.com/clslaid/x11-clipboard", branch = "feat/store-batch", optional = true} x11-clipboard = {git="https://github.com/clslaid/x11-clipboard", branch = "feat/store-batch", optional = true}
x11rb = {version = "0.12", features = ["all-extensions"], optional = true} x11rb = {version = "0.12", features = ["all-extensions"], optional = true}
fuser = {version = "0.15", default-features = false, optional = true}
[target.'cfg(target_os = "macos")'.dependencies] [target.'cfg(target_os = "macos")'.dependencies]
cacao = {git="https://github.com/clslaid/cacao", branch = "feat/set-file-urls", optional = true} cacao = {git="https://github.com/clslaid/cacao", branch = "feat/set-file-urls", optional = true}

View File

@@ -1,24 +1,23 @@
#[allow(dead_code)] use std::sync::{Arc, Mutex, RwLock};
use std::{
path::PathBuf,
sync::{Arc, Mutex, RwLock},
};
#[cfg(any(target_os = "windows", feature = "unix-file-copy-paste",))] #[cfg(target_os = "windows")]
use hbb_common::{allow_err, bail}; use hbb_common::ResultType;
#[cfg(any(target_os = "windows", feature = "unix-file-copy-paste"))]
use hbb_common::{allow_err, log};
use hbb_common::{ use hbb_common::{
lazy_static, lazy_static,
tokio::sync::{ tokio::sync::{
mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}, mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender},
Mutex as TokioMutex, Mutex as TokioMutex,
}, },
ResultType,
}; };
use serde_derive::{Deserialize, Serialize}; use serde_derive::{Deserialize, Serialize};
use thiserror::Error; use thiserror::Error;
#[cfg(target_os = "windows")]
pub mod context_send; pub mod context_send;
pub mod platform; pub mod platform;
#[cfg(target_os = "windows")]
pub use context_send::*; pub use context_send::*;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
@@ -28,8 +27,10 @@ const ERR_CODE_INVALID_PARAMETER: u32 = 0x00000002;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
const ERR_CODE_SEND_MSG: u32 = 0x00000003; const ERR_CODE_SEND_MSG: u32 = 0x00000003;
#[cfg(target_os = "windows")]
pub(crate) use platform::create_cliprdr_context; pub(crate) use platform::create_cliprdr_context;
// to-do: This trait may be removed, because unix file copy paste does not need it.
/// Ability to handle Clipboard File from remote rustdesk client /// Ability to handle Clipboard File from remote rustdesk client
/// ///
/// # Note /// # Note
@@ -41,7 +42,6 @@ pub trait CliprdrServiceContext: Send + Sync {
fn set_is_stopped(&mut self) -> Result<(), CliprdrError>; fn set_is_stopped(&mut self) -> Result<(), CliprdrError>;
/// clear the content on clipboard /// clear the content on clipboard
fn empty_clipboard(&mut self, conn_id: i32) -> Result<bool, CliprdrError>; fn empty_clipboard(&mut self, conn_id: i32) -> Result<bool, CliprdrError>;
/// run as a server for clipboard RPC /// run as a server for clipboard RPC
fn server_clip_file(&mut self, conn_id: i32, msg: ClipboardFile) -> Result<(), CliprdrError>; fn server_clip_file(&mut self, conn_id: i32, msg: ClipboardFile) -> Result<(), CliprdrError>;
} }
@@ -63,9 +63,11 @@ pub enum CliprdrError {
#[error("failure to read clipboard")] #[error("failure to read clipboard")]
OpenClipboard, OpenClipboard,
#[error("failure to read file metadata or content")] #[error("failure to read file metadata or content")]
FileError { path: PathBuf, err: std::io::Error }, FileError { path: String, err: std::io::Error },
#[error("invalid request")] #[error("invalid request")]
InvalidRequest { description: String }, InvalidRequest { description: String },
#[error("common request")]
CommonError { description: String },
#[error("unknown cliprdr error")] #[error("unknown cliprdr error")]
Unknown(u32), Unknown(u32),
} }
@@ -107,6 +109,7 @@ pub enum ClipboardFile {
stream_id: i32, stream_id: i32,
requested_data: Vec<u8>, requested_data: Vec<u8>,
}, },
TryEmpty,
} }
struct MsgChannel { struct MsgChannel {
@@ -198,42 +201,67 @@ pub fn get_rx_cliprdr_server(conn_id: i32) -> Arc<TokioMutex<UnboundedReceiver<C
} }
} }
#[cfg(any(target_os = "windows", feature = "unix-file-copy-paste",))] pub fn remove_channel_by_conn_id(conn_id: i32) {
let mut lock = VEC_MSG_CHANNEL.write().unwrap();
if let Some(index) = lock.iter().position(|x| x.conn_id == conn_id) {
lock.remove(index);
}
}
#[cfg(any(target_os = "windows", feature = "unix-file-copy-paste"))]
#[inline] #[inline]
fn send_data(conn_id: i32, data: ClipboardFile) -> ResultType<()> { pub fn send_data(conn_id: i32, data: ClipboardFile) -> Result<(), CliprdrError> {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
return send_data_to_channel(conn_id, data); return send_data_to_channel(conn_id, data);
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
if conn_id == 0 { if conn_id == 0 {
send_data_to_all(data); let _ = send_data_to_all(data);
Ok(())
} else { } else {
send_data_to_channel(conn_id, data); send_data_to_channel(conn_id, data)
} }
} }
#[cfg(any(target_os = "windows", feature = "unix-file-copy-paste",))]
#[inline] #[inline]
fn send_data_to_channel(conn_id: i32, data: ClipboardFile) -> ResultType<()> { #[cfg(any(target_os = "windows", feature = "unix-file-copy-paste"))]
fn send_data_to_channel(conn_id: i32, data: ClipboardFile) -> Result<(), CliprdrError> {
if let Some(msg_channel) = VEC_MSG_CHANNEL if let Some(msg_channel) = VEC_MSG_CHANNEL
.read() .read()
.unwrap() .unwrap()
.iter() .iter()
.find(|x| x.conn_id == conn_id) .find(|x| x.conn_id == conn_id)
{ {
msg_channel.sender.send(data)?; msg_channel
Ok(()) .sender
.send(data)
.map_err(|e| CliprdrError::CommonError {
description: e.to_string(),
})
} else { } else {
bail!("conn_id not found"); Err(CliprdrError::InvalidRequest {
description: "conn_id not found".to_string(),
})
} }
} }
#[cfg(feature = "unix-file-copy-paste")]
#[inline] #[inline]
fn send_data_to_all(data: ClipboardFile) -> ResultType<()> { #[cfg(target_os = "windows")]
pub fn send_data_exclude(conn_id: i32, data: ClipboardFile) {
// Need more tests to see if it's necessary to handle the error.
for msg_channel in VEC_MSG_CHANNEL.read().unwrap().iter() {
if msg_channel.conn_id != conn_id {
allow_err!(msg_channel.sender.send(data.clone()));
}
}
}
#[inline]
#[cfg(feature = "unix-file-copy-paste")]
fn send_data_to_all(data: ClipboardFile) {
// Need more tests to see if it's necessary to handle the error. // Need more tests to see if it's necessary to handle the error.
for msg_channel in VEC_MSG_CHANNEL.read().unwrap().iter() { for msg_channel in VEC_MSG_CHANNEL.read().unwrap().iter() {
allow_err!(msg_channel.sender.send(data.clone())); allow_err!(msg_channel.sender.send(data.clone()));
} }
Ok(())
} }
#[cfg(test)] #[cfg(test)]

View File

@@ -1,6 +1,3 @@
#[cfg(any(target_os = "linux", target_os = "macos"))]
use crate::{CliprdrError, CliprdrServiceContext};
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
pub mod windows; pub mod windows;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
@@ -16,76 +13,4 @@ pub fn create_cliprdr_context(
} }
#[cfg(feature = "unix-file-copy-paste")] #[cfg(feature = "unix-file-copy-paste")]
#[cfg(any(target_os = "linux", target_os = "macos"))]
/// use FUSE for file pasting on these platforms
pub mod fuse;
#[cfg(feature = "unix-file-copy-paste")]
#[cfg(any(target_os = "linux", target_os = "macos"))]
pub mod unix; pub mod unix;
#[cfg(any(target_os = "linux", target_os = "macos"))]
pub fn create_cliprdr_context(
_enable_files: bool,
_enable_others: bool,
_response_wait_timeout_secs: u32,
) -> crate::ResultType<Box<dyn crate::CliprdrServiceContext>> {
#[cfg(feature = "unix-file-copy-paste")]
{
use std::{fs::Permissions, os::unix::prelude::PermissionsExt};
use hbb_common::{config::APP_NAME, log};
if !_enable_files {
return Ok(Box::new(DummyCliprdrContext {}) as Box<_>);
}
let timeout = std::time::Duration::from_secs(_response_wait_timeout_secs as u64);
let app_name = APP_NAME.read().unwrap().clone();
let mnt_path = format!("/tmp/{}/{}", app_name, "cliprdr");
// this function must be called after the main IPC is up
std::fs::create_dir(&mnt_path).ok();
std::fs::set_permissions(&mnt_path, Permissions::from_mode(0o777)).ok();
log::info!("clear previously mounted cliprdr FUSE");
if let Err(e) = std::process::Command::new("umount").arg(&mnt_path).status() {
log::warn!("umount {:?} may fail: {:?}", mnt_path, e);
}
let unix_ctx = unix::ClipboardContext::new(timeout, mnt_path.parse()?)?;
log::debug!("start cliprdr FUSE");
unix_ctx.run()?;
Ok(Box::new(unix_ctx) as Box<_>)
}
#[cfg(not(feature = "unix-file-copy-paste"))]
return Ok(Box::new(DummyCliprdrContext {}) as Box<_>);
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
struct DummyCliprdrContext {}
#[cfg(any(target_os = "linux", target_os = "macos"))]
impl CliprdrServiceContext for DummyCliprdrContext {
fn set_is_stopped(&mut self) -> Result<(), CliprdrError> {
Ok(())
}
fn empty_clipboard(&mut self, _conn_id: i32) -> Result<bool, CliprdrError> {
Ok(true)
}
fn server_clip_file(
&mut self,
_conn_id: i32,
_msg: crate::ClipboardFile,
) -> Result<(), crate::CliprdrError> {
Ok(())
}
}
#[cfg(feature = "unix-file-copy-paste")]
#[cfg(any(target_os = "linux", target_os = "macos"))]
// begin of epoch used by microsoft
// 1601-01-01 00:00:00 + LDAP_EPOCH_DELTA*(100 ns) = 1970-01-01 00:00:00
const LDAP_EPOCH_DELTA: u64 = 116444772610000000;

View File

@@ -0,0 +1,188 @@
use super::{FLAGS_FD_ATTRIBUTES, FLAGS_FD_LAST_WRITE, FLAGS_FD_UNIX_MODE, LDAP_EPOCH_DELTA};
use crate::CliprdrError;
use hbb_common::{
bytes::{Buf, Bytes},
log,
};
use std::{
path::PathBuf,
time::{Duration, SystemTime},
};
use utf16string::WStr;
pub type Inode = u64;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileType {
File,
Directory,
// todo: support symlink
Symlink,
}
/// read only permission
pub const PERM_READ: u16 = 0o444;
/// read and write permission
pub const PERM_RW: u16 = 0o644;
/// only self can read and readonly
pub const PERM_SELF_RO: u16 = 0o400;
/// rwx
pub const PERM_RWX: u16 = 0o755;
/// max length of file name
pub const MAX_NAME_LEN: usize = 255;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileDescription {
pub conn_id: i32,
pub name: PathBuf,
pub kind: FileType,
pub atime: SystemTime,
pub last_modified: SystemTime,
pub last_metadata_changed: SystemTime,
pub creation_time: SystemTime,
pub size: u64,
pub perm: u16,
}
impl FileDescription {
fn parse_file_descriptor(
bytes: &mut Bytes,
conn_id: i32,
) -> Result<FileDescription, CliprdrError> {
let flags = bytes.get_u32_le();
// skip reserved 32 bytes
bytes.advance(32);
let attributes = bytes.get_u32_le();
// in original specification, this is 16 bytes reserved
// we use the last 4 bytes to store the file mode
// skip reserved 12 bytes
bytes.advance(12);
let perm = bytes.get_u32_le() as u16;
// last write time from 1601-01-01 00:00:00, in 100ns
let last_write_time = bytes.get_u64_le();
// file size
let file_size_high = bytes.get_u32_le();
let file_size_low = bytes.get_u32_le();
// utf16 file name, double \0 terminated, in 520 bytes block
// read with another pointer, and advance the main pointer
let block = bytes.clone();
bytes.advance(520);
let block = &block[..520];
let wstr = WStr::from_utf16le(block).map_err(|e| {
log::error!("cannot convert file descriptor path: {:?}", e);
CliprdrError::ConversionFailure
})?;
let from_unix = flags & FLAGS_FD_UNIX_MODE != 0;
let valid_attributes = flags & FLAGS_FD_ATTRIBUTES != 0;
if !valid_attributes {
return Err(CliprdrError::InvalidRequest {
description: "file description must have valid attributes".to_string(),
});
}
// todo: check normal, hidden, system, readonly, archive...
let directory = attributes & 0x10 != 0;
let normal = attributes == 0x80;
let hidden = attributes & 0x02 != 0;
let readonly = attributes & 0x01 != 0;
let perm = if from_unix {
// as is
perm
// cannot set as is...
} else if normal {
PERM_RWX
} else if readonly {
PERM_READ
} else if hidden {
PERM_SELF_RO
} else if directory {
PERM_RWX
} else {
PERM_RW
};
let kind = if directory {
FileType::Directory
} else {
FileType::File
};
// to-do: use `let valid_size = flags & FLAGS_FD_SIZE != 0;`
// We use `true` to for compatibility with Windows.
// let valid_size = flags & FLAGS_FD_SIZE != 0;
let valid_size = true;
let size = if valid_size {
((file_size_high as u64) << 32) + file_size_low as u64
} else {
0
};
let valid_write_time = flags & FLAGS_FD_LAST_WRITE != 0;
let last_modified = if valid_write_time && last_write_time >= LDAP_EPOCH_DELTA {
let last_write_time = (last_write_time - LDAP_EPOCH_DELTA) * 100;
let last_write_time = Duration::from_nanos(last_write_time);
SystemTime::UNIX_EPOCH + last_write_time
} else {
SystemTime::UNIX_EPOCH
};
let name = wstr.to_utf8().replace('\\', "/");
let name = PathBuf::from(name.trim_end_matches('\0'));
let desc = FileDescription {
conn_id,
name,
kind,
atime: last_modified,
last_modified,
last_metadata_changed: last_modified,
creation_time: last_modified,
size,
perm,
};
Ok(desc)
}
/// parse file descriptions from a format data response PDU
/// which containing a CSPTR_FILEDESCRIPTORW indicated format data
pub fn parse_file_descriptors(
file_descriptor_pdu: Vec<u8>,
conn_id: i32,
) -> Result<Vec<Self>, CliprdrError> {
let mut data = Bytes::from(file_descriptor_pdu);
if data.remaining() < 4 {
return Err(CliprdrError::InvalidRequest {
description: "file descriptor request with infficient length".to_string(),
});
}
let count = data.get_u32_le() as usize;
if data.remaining() == 0 && count == 0 {
return Ok(Vec::new());
}
if data.remaining() != 592 * count {
return Err(CliprdrError::InvalidRequest {
description: "file descriptor request with invalid length".to_string(),
});
}
let mut files = Vec::with_capacity(count);
for _ in 0..count {
let desc = Self::parse_file_descriptor(&mut data, conn_id)?;
files.push(desc);
}
Ok(files)
}
}

View File

@@ -31,33 +31,29 @@ use std::{
}; };
use fuser::{ReplyDirectory, FUSE_ROOT_ID}; use fuser::{ReplyDirectory, FUSE_ROOT_ID};
use hbb_common::{ use hbb_common::log;
bytes::{Buf, Bytes},
log,
};
use parking_lot::{Condvar, Mutex}; use parking_lot::{Condvar, Mutex};
use utf16string::WStr;
use crate::{send_data, ClipboardFile, CliprdrError}; use crate::{
platform::unix::{
use super::LDAP_EPOCH_DELTA; filetype::{FileDescription, FileType, Inode, MAX_NAME_LEN, PERM_RWX},
BLOCK_SIZE,
},
send_data, ClipboardFile, CliprdrError,
};
/// fuse server ready retry max times /// fuse server ready retry max times
const READ_RETRY: i32 = 3; const READ_RETRY: i32 = 3;
/// block size for fuse, align to our asynchronic request size over FileContentsRequest. impl From<FileType> for fuser::FileType {
pub const BLOCK_SIZE: u32 = 4 * 1024 * 1024; fn from(value: FileType) -> Self {
match value {
/// read only permission FileType::File => Self::RegularFile,
const PERM_READ: u16 = 0o444; FileType::Directory => Self::Directory,
/// read and write permission FileType::Symlink => Self::Symlink,
const PERM_RW: u16 = 0o644; }
/// only self can read and readonly }
const PERM_SELF_RO: u16 = 0o400; }
/// rwx
const PERM_RWX: u16 = 0o755;
/// max length of file name
const MAX_NAME_LEN: usize = 255;
/// fuse client /// fuse client
/// this is a proxy to the fuse server /// this is a proxy to the fuse server
@@ -150,9 +146,15 @@ impl fuser::Filesystem for FuseClient {
server.release(req, ino, fh, _flags, _lock_owner, _flush, reply) server.release(req, ino, fh, _flags, _lock_owner, _flush, reply)
} }
fn getattr(&mut self, req: &fuser::Request<'_>, ino: u64, reply: fuser::ReplyAttr) { fn getattr(
&mut self,
req: &fuser::Request<'_>,
ino: u64,
fh: Option<u64>,
reply: fuser::ReplyAttr,
) {
let mut server = self.server.lock(); let mut server = self.server.lock();
server.getattr(req, ino, reply) server.getattr(req, ino, fh, reply)
} }
fn statfs(&mut self, req: &fuser::Request<'_>, ino: u64, reply: fuser::ReplyStatfs) { fn statfs(&mut self, req: &fuser::Request<'_>, ino: u64, reply: fuser::ReplyStatfs) {
@@ -247,7 +249,6 @@ impl fuser::Filesystem for FuseServer {
if parent_entry.attributes.kind != FileType::Directory { if parent_entry.attributes.kind != FileType::Directory {
log::error!("fuse: parent is not a directory"); log::error!("fuse: parent is not a directory");
reply.error(libc::ENOTDIR); reply.error(libc::ENOTDIR);
return; return;
} }
@@ -480,7 +481,13 @@ impl fuser::Filesystem for FuseServer {
reply.ok(); reply.ok();
} }
fn getattr(&mut self, _req: &fuser::Request<'_>, ino: u64, reply: fuser::ReplyAttr) { fn getattr(
&mut self,
_req: &fuser::Request<'_>,
ino: u64,
_fh: Option<u64>,
reply: fuser::ReplyAttr,
) {
let files = &self.files; let files = &self.files;
let Some(entry) = files.get(ino as usize - 1) else { let Some(entry) = files.get(ino as usize - 1) else {
reply.error(libc::ENOENT); reply.error(libc::ENOENT);
@@ -527,14 +534,6 @@ impl FuseServer {
size: u32, size: u32,
) -> Result<Vec<u8>, std::io::Error> { ) -> Result<Vec<u8>, std::io::Error> {
// todo: async and concurrent read, generate stream_id per request // todo: async and concurrent read, generate stream_id per request
log::debug!(
"reading {:?} offset {} size {} on stream: {}",
node.name,
offset,
size,
node.stream_id
);
let cb_requested = unsafe { let cb_requested = unsafe {
// convert `size` from u32 to i32 // convert `size` from u32 to i32
// yet with same bit representation // yet with same bit representation
@@ -554,16 +553,14 @@ impl FuseServer {
clip_data_id: 0, clip_data_id: 0,
}; };
send_data(node.conn_id, request.clone()); send_data(node.conn_id, request.clone()).map_err(|e| {
log::error!("failed to send file list to channel: {:?}", e);
log::debug!( std::io::Error::new(std::io::ErrorKind::Other, e)
"waiting for read reply for {:?} on stream: {}", })?;
node.name,
node.stream_id
);
let mut retry_times = 0; let mut retry_times = 0;
// to-do: more tests needed
loop { loop {
let reply = self.rx.recv_timeout(self.timeout).map_err(|e| { let reply = self.rx.recv_timeout(self.timeout).map_err(|e| {
log::error!("failed to receive file list from channel: {:?}", e); log::error!("failed to receive file list from channel: {:?}", e);
@@ -590,7 +587,10 @@ impl FuseServer {
)); ));
} }
send_data(node.conn_id, request.clone()); send_data(node.conn_id, request.clone()).map_err(|e| {
log::error!("failed to send file list to channel: {:?}", e);
std::io::Error::new(std::io::ErrorKind::Other, e)
})?;
continue; continue;
} }
return Ok(requested_data); return Ok(requested_data);
@@ -605,160 +605,6 @@ impl FuseServer {
} }
} }
} }
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileDescription {
pub conn_id: i32,
pub name: PathBuf,
pub kind: FileType,
pub atime: SystemTime,
pub last_modified: SystemTime,
pub last_metadata_changed: SystemTime,
pub creation_time: SystemTime,
pub size: u64,
pub perm: u16,
}
impl FileDescription {
fn parse_file_descriptor(
bytes: &mut Bytes,
conn_id: i32,
) -> Result<FileDescription, CliprdrError> {
let flags = bytes.get_u32_le();
// skip reserved 32 bytes
bytes.advance(32);
let attributes = bytes.get_u32_le();
// in original specification, this is 16 bytes reserved
// we use the last 4 bytes to store the file mode
// skip reserved 12 bytes
bytes.advance(12);
let perm = bytes.get_u32_le() as u16;
// last write time from 1601-01-01 00:00:00, in 100ns
let last_write_time = bytes.get_u64_le();
// file size
let file_size_high = bytes.get_u32_le();
let file_size_low = bytes.get_u32_le();
// utf16 file name, double \0 terminated, in 520 bytes block
// read with another pointer, and advance the main pointer
let block = bytes.clone();
bytes.advance(520);
let block = &block[..520];
let wstr = WStr::from_utf16le(block).map_err(|e| {
log::error!("cannot convert file descriptor path: {:?}", e);
CliprdrError::ConversionFailure
})?;
let from_unix = flags & 0x08 != 0;
let valid_attributes = flags & 0x04 != 0;
if !valid_attributes {
return Err(CliprdrError::InvalidRequest {
description: "file description must have valid attributes".to_string(),
});
}
// todo: check normal, hidden, system, readonly, archive...
let directory = attributes & 0x10 != 0;
let normal = attributes == 0x80;
let hidden = attributes & 0x02 != 0;
let readonly = attributes & 0x01 != 0;
let perm = if from_unix {
// as is
perm
// cannot set as is...
} else if normal {
PERM_RWX
} else if readonly {
PERM_READ
} else if hidden {
PERM_SELF_RO
} else if directory {
PERM_RWX
} else {
PERM_RW
};
let kind = if directory {
FileType::Directory
} else {
FileType::File
};
let valid_size = flags & 0x40 != 0;
let size = if valid_size {
((file_size_high as u64) << 32) + file_size_low as u64
} else {
0
};
let valid_write_time = flags & 0x20 != 0;
let last_modified = if valid_write_time && last_write_time >= LDAP_EPOCH_DELTA {
let last_write_time = (last_write_time - LDAP_EPOCH_DELTA) * 100;
let last_write_time = Duration::from_nanos(last_write_time);
SystemTime::UNIX_EPOCH + last_write_time
} else {
SystemTime::UNIX_EPOCH
};
let name = wstr.to_utf8().replace('\\', "/");
let name = PathBuf::from(name.trim_end_matches('\0'));
let desc = FileDescription {
conn_id,
name,
kind,
atime: last_modified,
last_modified,
last_metadata_changed: last_modified,
creation_time: last_modified,
size,
perm,
};
Ok(desc)
}
/// parse file descriptions from a format data response PDU
/// which containing a CSPTR_FILEDESCRIPTORW indicated format data
pub fn parse_file_descriptors(
file_descriptor_pdu: Vec<u8>,
conn_id: i32,
) -> Result<Vec<Self>, CliprdrError> {
let mut data = Bytes::from(file_descriptor_pdu);
if data.remaining() < 4 {
return Err(CliprdrError::InvalidRequest {
description: "file descriptor request with infficient length".to_string(),
});
}
let count = data.get_u32_le() as usize;
if data.remaining() == 0 && count == 0 {
return Ok(Vec::new());
}
if data.remaining() != 592 * count {
return Err(CliprdrError::InvalidRequest {
description: "file descriptor request with invalid length".to_string(),
});
}
let mut files = Vec::with_capacity(count);
for _ in 0..count {
let desc = Self::parse_file_descriptor(&mut data, conn_id)?;
files.push(desc);
}
Ok(files)
}
}
/// a node in the FUSE file tree /// a node in the FUSE file tree
#[derive(Debug)] #[derive(Debug)]
struct FuseNode { struct FuseNode {
@@ -881,7 +727,7 @@ impl FuseNode {
format!("invalid file name {}", file.name.display()), format!("invalid file name {}", file.name.display()),
); );
CliprdrError::FileError { CliprdrError::FileError {
path: file.name.clone(), path: file.name.to_string_lossy().to_string(),
err, err,
} }
})?; })?;
@@ -902,26 +748,6 @@ impl FuseNode {
} }
} }
pub type Inode = u64;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileType {
File,
Directory,
// todo: support symlink
Symlink,
}
impl From<FileType> for fuser::FileType {
fn from(value: FileType) -> Self {
match value {
FileType::File => Self::RegularFile,
FileType::Directory => Self::Directory,
FileType::Symlink => Self::Symlink,
}
}
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct InodeAttributes { pub struct InodeAttributes {
inode: Inode, inode: Inode,
@@ -1064,8 +890,6 @@ impl FileHandles {
#[cfg(test)] #[cfg(test)]
mod fuse_test { mod fuse_test {
use std::str::FromStr;
use super::*; use super::*;
// todo: more tests needed! // todo: more tests needed!

View File

@@ -0,0 +1,225 @@
mod cs;
use super::filetype::FileDescription;
use crate::{ClipboardFile, CliprdrError};
use cs::FuseServer;
use fuser::MountOption;
use hbb_common::{config::APP_NAME, log};
use parking_lot::Mutex;
use std::{
path::PathBuf,
sync::{mpsc::Sender, Arc},
time::Duration,
};
lazy_static::lazy_static! {
static ref FUSE_MOUNT_POINT_CLIENT: Arc<String> = {
let mnt_path = format!("/tmp/{}/{}", APP_NAME.read().unwrap(), "cliprdr-client");
// No need to run `canonicalize()` here.
Arc::new(mnt_path)
};
static ref FUSE_MOUNT_POINT_SERVER: Arc<String> = {
let mnt_path = format!("/tmp/{}/{}", APP_NAME.read().unwrap(), "cliprdr-server");
// No need to run `canonicalize()` here.
Arc::new(mnt_path)
};
static ref FUSE_CONTEXT_CLIENT: Arc<Mutex<Option<FuseContext>>> = Arc::new(Mutex::new(None));
static ref FUSE_CONTEXT_SERVER: Arc<Mutex<Option<FuseContext>>> = Arc::new(Mutex::new(None));
}
static FUSE_TIMEOUT: Duration = Duration::from_secs(3);
pub fn get_exclude_paths(is_client: bool) -> Arc<String> {
if is_client {
FUSE_MOUNT_POINT_CLIENT.clone()
} else {
FUSE_MOUNT_POINT_SERVER.clone()
}
}
pub fn is_fuse_context_inited(is_client: bool) -> bool {
if is_client {
FUSE_CONTEXT_CLIENT.lock().is_some()
} else {
FUSE_CONTEXT_SERVER.lock().is_some()
}
}
pub fn init_fuse_context(is_client: bool) -> Result<(), CliprdrError> {
let mut fuse_context_lock = if is_client {
FUSE_CONTEXT_CLIENT.lock()
} else {
FUSE_CONTEXT_SERVER.lock()
};
if fuse_context_lock.is_some() {
return Ok(());
}
let mount_point = if is_client {
FUSE_MOUNT_POINT_CLIENT.clone()
} else {
FUSE_MOUNT_POINT_SERVER.clone()
};
let mount_point = std::path::PathBuf::from(&*mount_point);
let (server, tx) = FuseServer::new(FUSE_TIMEOUT);
let server = Arc::new(Mutex::new(server));
prepare_fuse_mount_point(&mount_point);
let mnt_opts = [
MountOption::FSName("rustdesk-cliprdr-fs".to_string()),
MountOption::NoAtime,
MountOption::RO,
];
log::info!("mounting clipboard FUSE to {}", mount_point.display());
// to-do: ignore the error if the mount point is already mounted
// Because the sciter version uses separate processes as the controlling side.
let session = fuser::spawn_mount2(
FuseServer::client(server.clone()),
mount_point.clone(),
&mnt_opts,
)
.map_err(|e| {
log::error!("failed to mount cliprdr fuse: {:?}", e);
CliprdrError::CliprdrInit
})?;
let session = Mutex::new(Some(session));
let ctx = FuseContext {
server,
tx,
mount_point,
session,
conn_id: 0,
};
*fuse_context_lock = Some(ctx);
Ok(())
}
pub fn uninit_fuse_context(is_client: bool) {
uninit_fuse_context_(is_client)
}
pub fn format_data_response_to_urls(
is_client: bool,
format_data: Vec<u8>,
conn_id: i32,
) -> Result<Vec<String>, CliprdrError> {
let mut ctx = if is_client {
FUSE_CONTEXT_CLIENT.lock()
} else {
FUSE_CONTEXT_SERVER.lock()
};
ctx.as_mut()
.ok_or(CliprdrError::CliprdrInit)?
.format_data_response_to_urls(format_data, conn_id)
}
pub fn handle_file_content_response(
is_client: bool,
clip: ClipboardFile,
) -> Result<(), CliprdrError> {
// we don't know its corresponding request, no resend can be performed
let ctx = if is_client {
FUSE_CONTEXT_CLIENT.lock()
} else {
FUSE_CONTEXT_SERVER.lock()
};
ctx.as_ref()
.ok_or(CliprdrError::CliprdrInit)?
.tx
.send(clip)
.map_err(|e| {
log::error!("failed to send file contents response to fuse: {:?}", e);
CliprdrError::ClipboardInternalError
})?;
Ok(())
}
pub fn empty_local_files(is_client: bool, conn_id: i32) -> bool {
let ctx = if is_client {
FUSE_CONTEXT_CLIENT.lock()
} else {
FUSE_CONTEXT_SERVER.lock()
};
ctx.as_ref()
.map(|c| c.empty_local_files(conn_id))
.unwrap_or(false)
}
struct FuseContext {
server: Arc<Mutex<FuseServer>>,
tx: Sender<ClipboardFile>,
mount_point: PathBuf,
// stores fuse background session handle
session: Mutex<Option<fuser::BackgroundSession>>,
// Indicates the connection ID of that set the clipboard content
conn_id: i32,
}
// this function must be called after the main IPC is up
fn prepare_fuse_mount_point(mount_point: &PathBuf) {
use std::{
fs::{self, Permissions},
os::unix::prelude::PermissionsExt,
};
fs::create_dir(mount_point).ok();
fs::set_permissions(mount_point, Permissions::from_mode(0o777)).ok();
if let Err(e) = std::process::Command::new("umount")
.arg(mount_point)
.status()
{
log::warn!("umount {:?} may fail: {:?}", mount_point, e);
}
}
fn uninit_fuse_context_(is_client: bool) {
if is_client {
let _ = FUSE_CONTEXT_CLIENT.lock().take();
} else {
let _ = FUSE_CONTEXT_SERVER.lock().take();
}
}
impl Drop for FuseContext {
fn drop(&mut self) {
self.session.lock().take().map(|s| s.join());
log::info!("unmounting clipboard FUSE from {}", self.mount_point.display());
}
}
impl FuseContext {
pub fn empty_local_files(&self, conn_id: i32) -> bool {
if conn_id != 0 && self.conn_id != conn_id {
return false;
}
let mut fuse_guard = self.server.lock();
let _ = fuse_guard.load_file_list(vec![]);
true
}
pub fn format_data_response_to_urls(
&mut self,
format_data: Vec<u8>,
conn_id: i32,
) -> Result<Vec<String>, CliprdrError> {
let files = FileDescription::parse_file_descriptors(format_data, conn_id)?;
let paths = {
let mut fuse_guard = self.server.lock();
fuse_guard.load_file_list(files)?;
self.conn_id = conn_id;
fuse_guard.list_root()
};
let prefix = self.mount_point.clone();
Ok(paths
.into_iter()
.map(|p| prefix.join(p).to_string_lossy().to_string())
.collect())
}
}

View File

@@ -1,3 +1,15 @@
use super::{BLOCK_SIZE, LDAP_EPOCH_DELTA};
use crate::{
platform::unix::{
FLAGS_FD_ATTRIBUTES, FLAGS_FD_LAST_WRITE, FLAGS_FD_PROGRESSUI, FLAGS_FD_SIZE,
FLAGS_FD_UNIX_MODE,
},
CliprdrError,
};
use hbb_common::{
bytes::{BufMut, BytesMut},
log,
};
use std::{ use std::{
collections::HashSet, collections::HashSet,
fs::File, fs::File,
@@ -7,32 +19,11 @@ use std::{
sync::atomic::{AtomicU64, Ordering}, sync::atomic::{AtomicU64, Ordering},
time::SystemTime, time::SystemTime,
}; };
use hbb_common::{
bytes::{BufMut, BytesMut},
log,
};
use utf16string::WString; use utf16string::WString;
use crate::{
platform::{fuse::BLOCK_SIZE, LDAP_EPOCH_DELTA},
CliprdrError,
};
/// has valid file attributes
const FLAGS_FD_ATTRIBUTES: u32 = 0x04;
/// has valid file size
const FLAGS_FD_SIZE: u32 = 0x40;
/// has valid last write time
const FLAGS_FD_LAST_WRITE: u32 = 0x20;
/// show progress
const FLAGS_FD_PROGRESSUI: u32 = 0x4000;
/// transferred from unix, contains file mode
/// P.S. this flag is not used in windows
const FLAGS_FD_UNIX_MODE: u32 = 0x08;
#[derive(Debug)] #[derive(Debug)]
pub(super) struct LocalFile { pub(super) struct LocalFile {
pub relative_root: PathBuf,
pub path: PathBuf, pub path: PathBuf,
pub handle: Option<BufReader<File>>, pub handle: Option<BufReader<File>>,
@@ -51,9 +42,9 @@ pub(super) struct LocalFile {
} }
impl LocalFile { impl LocalFile {
pub fn try_open(path: &Path) -> Result<Self, CliprdrError> { pub fn try_open(relative_root: &Path, path: &Path) -> Result<Self, CliprdrError> {
let mt = std::fs::metadata(path).map_err(|e| CliprdrError::FileError { let mt = std::fs::metadata(path).map_err(|e| CliprdrError::FileError {
path: path.clone(), path: path.to_string_lossy().to_string(),
err: e, err: e,
})?; })?;
let size = mt.len() as u64; let size = mt.len() as u64;
@@ -79,7 +70,8 @@ impl LocalFile {
Ok(Self { Ok(Self {
name, name,
path: path.clone(), relative_root: relative_root.to_path_buf(),
path: path.to_path_buf(),
handle, handle,
offset, offset,
size, size,
@@ -121,7 +113,12 @@ impl LocalFile {
let size_high = (self.size >> 32) as u32; let size_high = (self.size >> 32) as u32;
let size_low = (self.size & (u32::MAX as u64)) as u32; let size_low = (self.size & (u32::MAX as u64)) as u32;
let path = self.path.to_string_lossy().to_string(); let path = self
.path
.strip_prefix(&self.relative_root)
.unwrap_or(&self.path)
.to_string_lossy()
.into_owned();
let wstr: WString<utf16string::LE> = WString::from(&path); let wstr: WString<utf16string::LE> = WString::from(&path);
let name = wstr.as_bytes(); let name = wstr.as_bytes();
@@ -172,12 +169,12 @@ impl LocalFile {
pub fn load_handle(&mut self) -> Result<(), CliprdrError> { pub fn load_handle(&mut self) -> Result<(), CliprdrError> {
if !self.is_dir && self.handle.is_none() { if !self.is_dir && self.handle.is_none() {
let handle = std::fs::File::open(&self.path).map_err(|e| CliprdrError::FileError { let handle = std::fs::File::open(&self.path).map_err(|e| CliprdrError::FileError {
path: self.path.clone(), path: self.path.to_string_lossy().to_string(),
err: e, err: e,
})?; })?;
let mut reader = BufReader::with_capacity(BLOCK_SIZE as usize * 2, handle); let mut reader = BufReader::with_capacity(BLOCK_SIZE as usize * 2, handle);
reader.fill_buf().map_err(|e| CliprdrError::FileError { reader.fill_buf().map_err(|e| CliprdrError::FileError {
path: self.path.clone(), path: self.path.to_string_lossy().to_string(),
err: e, err: e,
})?; })?;
self.handle = Some(reader); self.handle = Some(reader);
@@ -188,20 +185,25 @@ impl LocalFile {
pub fn read_exact_at(&mut self, buf: &mut [u8], offset: u64) -> Result<(), CliprdrError> { pub fn read_exact_at(&mut self, buf: &mut [u8], offset: u64) -> Result<(), CliprdrError> {
self.load_handle()?; self.load_handle()?;
let handle = self.handle.as_mut()?; let Some(handle) = self.handle.as_mut() else {
return Err(CliprdrError::FileError {
path: self.path.to_string_lossy().to_string(),
err: std::io::Error::new(std::io::ErrorKind::NotFound, "file handle not found"),
});
};
if offset != self.offset.load(Ordering::Relaxed) { if offset != self.offset.load(Ordering::Relaxed) {
handle handle
.seek(std::io::SeekFrom::Start(offset)) .seek(std::io::SeekFrom::Start(offset))
.map_err(|e| CliprdrError::FileError { .map_err(|e| CliprdrError::FileError {
path: self.path.clone(), path: self.path.to_string_lossy().to_string(),
err: e, err: e,
})?; })?;
} }
handle handle
.read_exact(buf) .read_exact(buf)
.map_err(|e| CliprdrError::FileError { .map_err(|e| CliprdrError::FileError {
path: self.path.clone(), path: self.path.to_string_lossy().to_string(),
err: e, err: e,
})?; })?;
let new_offset = offset + (buf.len() as u64); let new_offset = offset + (buf.len() as u64);
@@ -219,6 +221,7 @@ impl LocalFile {
pub(super) fn construct_file_list(paths: &[PathBuf]) -> Result<Vec<LocalFile>, CliprdrError> { pub(super) fn construct_file_list(paths: &[PathBuf]) -> Result<Vec<LocalFile>, CliprdrError> {
fn constr_file_lst( fn constr_file_lst(
relative_root: &Path,
path: &Path, path: &Path,
file_list: &mut Vec<LocalFile>, file_list: &mut Vec<LocalFile>,
visited: &mut HashSet<PathBuf>, visited: &mut HashSet<PathBuf>,
@@ -227,22 +230,28 @@ pub(super) fn construct_file_list(paths: &[PathBuf]) -> Result<Vec<LocalFile>, C
if visited.contains(path) { if visited.contains(path) {
return Ok(()); return Ok(());
} }
visited.insert(path.clone()); visited.insert(path.to_path_buf());
let local_file = LocalFile::try_open(path)?; let local_file = LocalFile::try_open(relative_root, path)?;
file_list.push(local_file); file_list.push(local_file);
let mt = std::fs::metadata(path).map_err(|e| CliprdrError::FileError { let mt = std::fs::metadata(path).map_err(|e| CliprdrError::FileError {
path: path.clone(), path: path.to_string_lossy().to_string(),
err: e, err: e,
})?; })?;
if mt.is_dir() { if mt.is_dir() {
let dir = std::fs::read_dir(path)?; let dir = std::fs::read_dir(path).map_err(|e| CliprdrError::FileError {
path: path.to_string_lossy().to_string(),
err: e,
})?;
for entry in dir { for entry in dir {
let entry = entry?; let entry = entry.map_err(|e| CliprdrError::FileError {
path: path.to_string_lossy().to_string(),
err: e,
})?;
let path = entry.path(); let path = entry.path();
constr_file_lst(&path, file_list, visited)?; constr_file_lst(relative_root, &path, file_list, visited)?;
} }
} }
Ok(()) Ok(())
@@ -251,8 +260,18 @@ pub(super) fn construct_file_list(paths: &[PathBuf]) -> Result<Vec<LocalFile>, C
let mut file_list = Vec::new(); let mut file_list = Vec::new();
let mut visited = HashSet::new(); let mut visited = HashSet::new();
let relative_root = paths
.first()
.ok_or(CliprdrError::InvalidRequest {
description: "empty file list".to_string(),
})?
.parent()
.ok_or(CliprdrError::InvalidRequest {
description: "empty parent".to_string(),
})?
.to_path_buf();
for path in paths { for path in paths {
constr_file_lst(path, &mut file_list, &mut visited)?; constr_file_lst(&relative_root, path, &mut file_list, &mut visited)?;
} }
Ok(file_list) Ok(file_list)
} }
@@ -263,7 +282,7 @@ mod file_list_test {
use hbb_common::bytes::{BufMut, BytesMut}; use hbb_common::bytes::{BufMut, BytesMut};
use crate::{platform::fuse::FileDescription, CliprdrError}; use crate::{platform::unix::filetype::FileDescription, CliprdrError};
use super::LocalFile; use super::LocalFile;
@@ -277,6 +296,7 @@ mod file_list_test {
#[inline] #[inline]
fn generate_file(path: &str, name: &str, is_dir: bool) -> LocalFile { fn generate_file(path: &str, name: &str, is_dir: bool) -> LocalFile {
LocalFile { LocalFile {
relative_root: PathBuf::from("."),
path: PathBuf::from(path), path: PathBuf::from(path),
handle: None, handle: None,
name: name.to_string(), name: name.to_string(),

View File

@@ -1,48 +1,38 @@
use std::{
path::{Path, PathBuf},
sync::{mpsc::Sender, Arc},
time::Duration,
};
use dashmap::DashMap; use dashmap::DashMap;
use fuser::MountOption;
use hbb_common::{
bytes::{BufMut, BytesMut},
log,
};
use lazy_static::lazy_static; use lazy_static::lazy_static;
use parking_lot::Mutex;
use crate::{ mod filetype;
platform::{fuse::FileDescription, unix::local_file::construct_file_list}, /// use FUSE for file pasting on these platforms
send_data, ClipboardFile, CliprdrError, CliprdrServiceContext,
};
use self::local_file::LocalFile;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
use self::url::{encode_path_to_uri, parse_plain_uri_list}; pub mod fuse;
use super::fuse::FuseServer;
#[cfg(target_os = "linux")]
/// clipboard implementation of x11
pub mod x11;
#[cfg(target_os = "macos")]
/// clipboard implementation of macos
pub mod ns_clipboard;
pub mod local_file; pub mod local_file;
pub mod serv_files;
#[cfg(target_os = "linux")] /// has valid file attributes
pub mod url; pub const FLAGS_FD_ATTRIBUTES: u32 = 0x04;
/// has valid file size
pub const FLAGS_FD_SIZE: u32 = 0x40;
/// has valid last write time
pub const FLAGS_FD_LAST_WRITE: u32 = 0x20;
/// show progress
pub const FLAGS_FD_PROGRESSUI: u32 = 0x4000;
/// transferred from unix, contains file mode
/// P.S. this flag is not used in windows
pub const FLAGS_FD_UNIX_MODE: u32 = 0x08;
// not actual format id, just a placeholder // not actual format id, just a placeholder
const FILEDESCRIPTOR_FORMAT_ID: i32 = 49334; pub const FILEDESCRIPTOR_FORMAT_ID: i32 = 49334;
const FILEDESCRIPTORW_FORMAT_NAME: &str = "FileGroupDescriptorW"; pub const FILEDESCRIPTORW_FORMAT_NAME: &str = "FileGroupDescriptorW";
// not actual format id, just a placeholder // not actual format id, just a placeholder
const FILECONTENTS_FORMAT_ID: i32 = 49267; pub const FILECONTENTS_FORMAT_ID: i32 = 49267;
const FILECONTENTS_FORMAT_NAME: &str = "FileContents"; pub const FILECONTENTS_FORMAT_NAME: &str = "FileContents";
/// block size for fuse, align to our asynchronic request size over FileContentsRequest.
pub(crate) const BLOCK_SIZE: u32 = 4 * 1024 * 1024;
// begin of epoch used by microsoft
// 1601-01-01 00:00:00 + LDAP_EPOCH_DELTA*(100 ns) = 1970-01-01 00:00:00
const LDAP_EPOCH_DELTA: u64 = 116444772610000000;
lazy_static! { lazy_static! {
static ref REMOTE_FORMAT_MAP: DashMap<i32, String> = DashMap::from_iter( static ref REMOTE_FORMAT_MAP: DashMap<i32, String> = DashMap::from_iter(
@@ -58,541 +48,7 @@ lazy_static! {
); );
} }
fn get_local_format(remote_id: i32) -> Option<String> { #[inline]
pub fn get_local_format(remote_id: i32) -> Option<String> {
REMOTE_FORMAT_MAP.get(&remote_id).map(|s| s.clone()) REMOTE_FORMAT_MAP.get(&remote_id).map(|s| s.clone())
} }
fn add_remote_format(local_name: &str, remote_id: i32) {
REMOTE_FORMAT_MAP.insert(remote_id, local_name.to_string());
}
trait SysClipboard: Send + Sync {
fn start(&self);
fn set_file_list(&self, paths: &[PathBuf]) -> Result<(), CliprdrError>;
fn get_file_list(&self) -> Vec<PathBuf>;
}
#[cfg(target_os = "linux")]
fn get_sys_clipboard(ignore_path: &Path) -> Result<Box<dyn SysClipboard>, CliprdrError> {
#[cfg(feature = "wayland")]
{
unimplemented!()
}
#[cfg(not(feature = "wayland"))]
{
use x11::*;
let x11_clip = X11Clipboard::new(ignore_path)?;
Ok(Box::new(x11_clip) as Box<_>)
}
}
#[cfg(target_os = "macos")]
fn get_sys_clipboard(ignore_path: &Path) -> Result<Box<dyn SysClipboard>, CliprdrError> {
use ns_clipboard::*;
let ns_pb = NsPasteboard::new(ignore_path)?;
Ok(Box::new(ns_pb) as Box<_>)
}
#[derive(Debug)]
enum FileContentsRequest {
Size {
stream_id: i32,
file_idx: usize,
},
Range {
stream_id: i32,
file_idx: usize,
offset: u64,
length: u64,
},
}
pub struct ClipboardContext {
pub fuse_mount_point: PathBuf,
/// stores fuse background session handle
fuse_handle: Mutex<Option<fuser::BackgroundSession>>,
/// a sender of clipboard file contents pdu to fuse server
fuse_tx: Sender<ClipboardFile>,
fuse_server: Arc<Mutex<FuseServer>>,
clipboard: Arc<dyn SysClipboard>,
local_files: Mutex<Vec<LocalFile>>,
}
impl ClipboardContext {
pub fn new(timeout: Duration, mount_path: PathBuf) -> Result<Self, CliprdrError> {
// assert mount path exists
let fuse_mount_point = mount_path.canonicalize().map_err(|e| {
log::error!("failed to canonicalize mount path: {:?}", e);
CliprdrError::CliprdrInit
})?;
let (fuse_server, fuse_tx) = FuseServer::new(timeout);
let fuse_server = Arc::new(Mutex::new(fuse_server));
let clipboard = get_sys_clipboard(&fuse_mount_point)?;
let clipboard = Arc::from(clipboard) as Arc<_>;
let local_files = Mutex::new(vec![]);
Ok(Self {
fuse_mount_point,
fuse_server,
fuse_tx,
fuse_handle: Mutex::new(None),
clipboard,
local_files,
})
}
pub fn run(&self) -> Result<(), CliprdrError> {
if !self.is_stopped() {
return Ok(());
}
let mut fuse_handle = self.fuse_handle.lock();
let mount_path = &self.fuse_mount_point;
let mnt_opts = [
MountOption::FSName("rustdesk-cliprdr-fs".to_string()),
MountOption::NoAtime,
MountOption::RO,
];
log::info!(
"mounting clipboard FUSE to {}",
self.fuse_mount_point.display()
);
let new_handle = fuser::spawn_mount2(
FuseServer::client(self.fuse_server.clone()),
mount_path,
&mnt_opts,
)
.map_err(|e| {
log::error!("failed to mount cliprdr fuse: {:?}", e);
CliprdrError::CliprdrInit
})?;
*fuse_handle = Some(new_handle);
let clipboard = self.clipboard.clone();
std::thread::spawn(move || {
log::debug!("start listening clipboard");
clipboard.start();
});
Ok(())
}
/// set clipboard data from file list
pub fn set_clipboard(&self, paths: &[PathBuf]) -> Result<(), CliprdrError> {
let prefix = self.fuse_mount_point.clone();
let paths: Vec<PathBuf> = paths.iter().cloned().map(|p| prefix.join(p)).collect();
log::debug!("setting clipboard with paths: {:?}", paths);
self.clipboard.set_file_list(&paths)?;
log::debug!("clipboard set, paths: {:?}", paths);
Ok(())
}
fn serve_file_contents(
&self,
conn_id: i32,
request: FileContentsRequest,
) -> Result<(), CliprdrError> {
let mut file_list = self.local_files.lock();
let (file_idx, file_contents_resp) = match request {
FileContentsRequest::Size {
stream_id,
file_idx,
} => {
log::debug!("file contents (size) requested from conn: {}", conn_id);
let Some(file) = file_list.get(file_idx) else {
log::error!(
"invalid file index {} requested from conn: {}",
file_idx,
conn_id
);
resp_file_contents_fail(conn_id, stream_id);
return Err(CliprdrError::InvalidRequest {
description: format!(
"invalid file index {} requested from conn: {}",
file_idx, conn_id
),
});
};
log::debug!(
"conn {} requested file-{}: {}",
conn_id,
file_idx,
file.name
);
let size = file.size;
(
file_idx,
ClipboardFile::FileContentsResponse {
msg_flags: 0x1,
stream_id,
requested_data: size.to_le_bytes().to_vec(),
},
)
}
FileContentsRequest::Range {
stream_id,
file_idx,
offset,
length,
} => {
log::debug!(
"file contents (range from {} length {}) request from conn: {}",
offset,
length,
conn_id
);
let Some(file) = file_list.get_mut(file_idx) else {
log::error!(
"invalid file index {} requested from conn: {}",
file_idx,
conn_id
);
resp_file_contents_fail(conn_id, stream_id);
return Err(CliprdrError::InvalidRequest {
description: format!(
"invalid file index {} requested from conn: {}",
file_idx, conn_id
),
});
};
log::debug!(
"conn {} requested file-{}: {}",
conn_id,
file_idx,
file.name
);
if offset > file.size {
log::error!("invalid reading offset requested from conn: {}", conn_id);
resp_file_contents_fail(conn_id, stream_id);
return Err(CliprdrError::InvalidRequest {
description: format!(
"invalid reading offset requested from conn: {}",
conn_id
),
});
}
let read_size = if offset + length > file.size {
file.size - offset
} else {
length
};
let mut buf = vec![0u8; read_size as usize];
file.read_exact_at(&mut buf, offset)?;
(
file_idx,
ClipboardFile::FileContentsResponse {
msg_flags: 0x1,
stream_id,
requested_data: buf,
},
)
}
};
send_data(conn_id, file_contents_resp);
log::debug!("file contents sent to conn: {}", conn_id);
// hot reload next file
for next_file in file_list.iter_mut().skip(file_idx + 1) {
if !next_file.is_dir {
next_file.load_handle()?;
break;
}
}
Ok(())
}
}
fn resp_file_contents_fail(conn_id: i32, stream_id: i32) {
let resp = ClipboardFile::FileContentsResponse {
msg_flags: 0x2,
stream_id,
requested_data: vec![],
};
send_data(conn_id, resp)
}
impl ClipboardContext {
pub fn is_stopped(&self) -> bool {
self.fuse_handle.lock().is_none()
}
pub fn sync_local_files(&self) -> Result<(), CliprdrError> {
let mut local_files = self.local_files.lock();
let clipboard_files = self.clipboard.get_file_list();
let local_file_list: Vec<PathBuf> = local_files.iter().map(|f| f.path.clone()).collect();
if local_file_list == clipboard_files {
return Ok(());
}
let new_files = construct_file_list(&clipboard_files)?;
*local_files = new_files;
Ok(())
}
pub fn serve(&self, conn_id: i32, msg: ClipboardFile) -> Result<(), CliprdrError> {
log::debug!("serve clipboard file from conn: {}", conn_id);
if self.is_stopped() {
log::debug!("cliprdr stopped, restart it");
self.run()?;
}
match msg {
ClipboardFile::NotifyCallback { .. } => {
unreachable!()
}
ClipboardFile::MonitorReady => {
log::debug!("server_monitor_ready called");
self.send_file_list(conn_id)?;
Ok(())
}
ClipboardFile::FormatList { format_list } => {
log::debug!("server_format_list called");
// filter out "FileGroupDescriptorW" and "FileContents"
let fmt_lst: Vec<(i32, String)> = format_list
.into_iter()
.filter(|(_, name)| {
name == FILEDESCRIPTORW_FORMAT_NAME || name == FILECONTENTS_FORMAT_NAME
})
.collect();
if fmt_lst.len() != 2 {
log::debug!("no supported formats");
return Ok(());
}
log::debug!("supported formats: {:?}", fmt_lst);
let file_contents_id = fmt_lst
.iter()
.find(|(_, name)| name == FILECONTENTS_FORMAT_NAME)
.map(|(id, _)| *id)?;
let file_descriptor_id = fmt_lst
.iter()
.find(|(_, name)| name == FILEDESCRIPTORW_FORMAT_NAME)
.map(|(id, _)| *id)?;
add_remote_format(FILECONTENTS_FORMAT_NAME, file_contents_id);
add_remote_format(FILEDESCRIPTORW_FORMAT_NAME, file_descriptor_id);
// sync file system from peer
let data = ClipboardFile::FormatDataRequest {
requested_format_id: file_descriptor_id,
};
send_data(conn_id, data);
Ok(())
}
ClipboardFile::FormatListResponse { msg_flags } => {
log::debug!("server_format_list_response called");
if msg_flags != 0x1 {
send_format_list(conn_id)
} else {
Ok(())
}
}
ClipboardFile::FormatDataRequest {
requested_format_id,
} => {
log::debug!("server_format_data_request called");
let Some(format) = get_local_format(requested_format_id) else {
log::error!(
"got unsupported format data request: id={} from conn={}",
requested_format_id,
conn_id
);
resp_format_data_failure(conn_id);
return Ok(());
};
if format == FILEDESCRIPTORW_FORMAT_NAME {
self.send_file_list(conn_id)?;
} else if format == FILECONTENTS_FORMAT_NAME {
log::error!(
"try to read file contents with FormatDataRequest from conn={}",
conn_id
);
resp_format_data_failure(conn_id);
} else {
log::error!(
"got unsupported format data request: id={} from conn={}",
requested_format_id,
conn_id
);
resp_format_data_failure(conn_id);
}
Ok(())
}
ClipboardFile::FormatDataResponse {
msg_flags,
format_data,
} => {
log::debug!(
"server_format_data_response called, msg_flags={}",
msg_flags
);
if msg_flags != 0x1 {
resp_format_data_failure(conn_id);
return Ok(());
}
log::debug!("parsing file descriptors");
// this must be a file descriptor format data
let files = FileDescription::parse_file_descriptors(format_data, conn_id)?;
let paths = {
let mut fuse_guard = self.fuse_server.lock();
fuse_guard.load_file_list(files)?;
fuse_guard.list_root()
};
log::debug!("load file list: {:?}", paths);
self.set_clipboard(&paths)?;
Ok(())
}
ClipboardFile::FileContentsResponse { .. } => {
log::debug!("server_file_contents_response called");
// we don't know its corresponding request, no resend can be performed
self.fuse_tx.send(msg).map_err(|e| {
log::error!("failed to send file contents response to fuse: {:?}", e);
CliprdrError::ClipboardInternalError
})?;
Ok(())
}
ClipboardFile::FileContentsRequest {
stream_id,
list_index,
dw_flags,
n_position_low,
n_position_high,
cb_requested,
..
} => {
log::debug!("server_file_contents_request called");
let fcr = if dw_flags == 0x1 {
FileContentsRequest::Size {
stream_id,
file_idx: list_index as usize,
}
} else if dw_flags == 0x2 {
let offset = (n_position_high as u64) << 32 | n_position_low as u64;
let length = cb_requested as u64;
FileContentsRequest::Range {
stream_id,
file_idx: list_index as usize,
offset,
length,
}
} else {
log::error!("got invalid FileContentsRequest from conn={}", conn_id);
resp_file_contents_fail(conn_id, stream_id);
return Ok(());
};
self.serve_file_contents(conn_id, fcr)
}
}
}
fn send_file_list(&self, conn_id: i32) -> Result<(), CliprdrError> {
self.sync_local_files()?;
let file_list = self.local_files.lock();
send_file_list(&*file_list, conn_id)
}
}
impl CliprdrServiceContext for ClipboardContext {
fn set_is_stopped(&mut self) -> Result<(), CliprdrError> {
// unmount the fuse
if let Some(fuse_handle) = self.fuse_handle.lock().take() {
fuse_handle.join();
}
// we don't stop the clipboard, keep listening in case of restart
Ok(())
}
fn empty_clipboard(&mut self, _conn_id: i32) -> Result<bool, CliprdrError> {
self.clipboard.set_file_list(&[])?;
Ok(true)
}
fn server_clip_file(&mut self, conn_id: i32, msg: ClipboardFile) -> Result<(), CliprdrError> {
self.serve(conn_id, msg)
}
}
fn resp_format_data_failure(conn_id: i32) {
let data = ClipboardFile::FormatDataResponse {
msg_flags: 0x2,
format_data: vec![],
};
send_data(conn_id, data)
}
fn send_format_list(conn_id: i32) -> Result<(), CliprdrError> {
log::debug!("send format list to remote, conn={}", conn_id);
let fd_format_name = get_local_format(FILEDESCRIPTOR_FORMAT_ID)
.unwrap_or(FILEDESCRIPTORW_FORMAT_NAME.to_string());
let fc_format_name =
get_local_format(FILECONTENTS_FORMAT_ID).unwrap_or(FILECONTENTS_FORMAT_NAME.to_string());
let format_list = ClipboardFile::FormatList {
format_list: vec![
(FILEDESCRIPTOR_FORMAT_ID, fd_format_name),
(FILECONTENTS_FORMAT_ID, fc_format_name),
],
};
send_data(conn_id, format_list);
log::debug!("format list to remote dispatched, conn={}", conn_id);
Ok(())
}
fn build_file_list_pdu(files: &[LocalFile]) -> Vec<u8> {
let mut data = BytesMut::with_capacity(4 + 592 * files.len());
data.put_u32_le(files.len() as u32);
for file in files.iter() {
data.put(file.as_bin().as_slice());
}
data.to_vec()
}
fn send_file_list(files: &[LocalFile], conn_id: i32) -> Result<(), CliprdrError> {
log::debug!(
"send file list to remote, conn={}, list={:?}",
conn_id,
files.iter().map(|f| f.path.display()).collect::<Vec<_>>()
);
let format_data = build_file_list_pdu(files);
send_data(
conn_id,
ClipboardFile::FormatDataResponse {
msg_flags: 1,
format_data,
},
);
Ok(())
}

View File

@@ -1,100 +0,0 @@
use std::{
collections::BTreeSet,
path::{Path, PathBuf},
};
use cacao::pasteboard::{Pasteboard, PasteboardName};
use hbb_common::log;
use parking_lot::Mutex;
use crate::{platform::unix::send_format_list, CliprdrError};
use super::SysClipboard;
#[inline]
fn wait_file_list() -> Option<Vec<PathBuf>> {
let pb = Pasteboard::named(PasteboardName::General);
pb.get_file_urls()
.ok()
.map(|v| v.into_iter().map(|nsurl| nsurl.pathbuf()).collect())
}
#[inline]
fn set_file_list(file_list: &[PathBuf]) -> Result<(), CliprdrError> {
let pb = Pasteboard::named(PasteboardName::General);
pb.set_files(file_list.to_vec())
.map_err(|_| CliprdrError::ClipboardInternalError)
}
pub struct NsPasteboard {
ignore_path: PathBuf,
former_file_list: Mutex<Vec<PathBuf>>,
}
impl NsPasteboard {
pub fn new(ignore_path: &Path) -> Result<Self, CliprdrError> {
Ok(Self {
ignore_path: ignore_path.to_owned(),
former_file_list: Mutex::new(vec![]),
})
}
}
impl SysClipboard for NsPasteboard {
fn set_file_list(&self, paths: &[PathBuf]) -> Result<(), CliprdrError> {
*self.former_file_list.lock() = paths.to_vec();
set_file_list(paths)
}
fn start(&self) {
{
*self.former_file_list.lock() = vec![];
}
loop {
let file_list = match wait_file_list() {
Some(v) => v,
None => {
std::thread::sleep(std::time::Duration::from_millis(100));
continue;
}
};
let filtered = file_list
.into_iter()
.filter(|pb| !pb.starts_with(&self.ignore_path))
.collect::<Vec<_>>();
if filtered.is_empty() {
std::thread::sleep(std::time::Duration::from_millis(100));
continue;
}
{
let mut former = self.former_file_list.lock();
let filtered_st: BTreeSet<_> = filtered.iter().collect();
let former_st = former.iter().collect::<BTreeSet<_>>();
if filtered_st == former_st {
std::thread::sleep(std::time::Duration::from_millis(100));
continue;
}
*former = filtered;
}
if let Err(e) = send_format_list(0) {
log::warn!("failed to send format list: {}", e);
break;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
log::debug!("stop listening file related atoms on clipboard");
}
fn get_file_list(&self) -> Vec<PathBuf> {
self.former_file_list.lock().clone()
}
}

View File

@@ -0,0 +1,231 @@
use super::local_file::LocalFile;
use crate::{platform::unix::local_file::construct_file_list, ClipboardFile, CliprdrError};
use hbb_common::{
bytes::{BufMut, BytesMut},
log,
};
use parking_lot::Mutex;
use std::{path::PathBuf, sync::Arc};
lazy_static::lazy_static! {
// local files are cached, this value should not be changed when copying files
// Because `CliprdrFileContentsRequest` only contains the index of the file in the list.
// We need to keep the file list in the same order as the remote side.
// We may add a `FileId` field to `CliprdrFileContentsRequest` in the future.
static ref CLIP_FILES: Arc<Mutex<ClipFiles>> = Default::default();
}
#[derive(Debug)]
enum FileContentsRequest {
Size {
stream_id: i32,
file_idx: usize,
},
Range {
stream_id: i32,
file_idx: usize,
offset: u64,
length: u64,
},
}
#[derive(Default)]
struct ClipFiles {
files: Vec<String>,
file_list: Vec<LocalFile>,
files_pdu: Vec<u8>,
}
impl ClipFiles {
fn clear(&mut self) {
self.files.clear();
self.file_list.clear();
self.files_pdu.clear();
}
fn sync_files(&mut self, clipboard_files: &[String]) -> Result<(), CliprdrError> {
let clipboard_paths = clipboard_files
.iter()
.map(|s| PathBuf::from(s))
.collect::<Vec<_>>();
self.file_list = construct_file_list(&clipboard_paths)?;
self.files = clipboard_files.to_vec();
Ok(())
}
fn build_file_list_pdu(&mut self) {
let mut data = BytesMut::with_capacity(4 + 592 * self.file_list.len());
data.put_u32_le(self.file_list.len() as u32);
for file in self.file_list.iter() {
data.put(file.as_bin().as_slice());
}
self.files_pdu = data.to_vec()
}
fn serve_file_contents(
&mut self,
conn_id: i32,
request: FileContentsRequest,
) -> Result<ClipboardFile, CliprdrError> {
let (file_idx, file_contents_resp) = match request {
FileContentsRequest::Size {
stream_id,
file_idx,
} => {
log::debug!("file contents (size) requested from conn: {}", conn_id);
let Some(file) = self.file_list.get(file_idx) else {
log::error!(
"invalid file index {} requested from conn: {}",
file_idx,
conn_id
);
return Err(CliprdrError::InvalidRequest {
description: format!(
"invalid file index {} requested from conn: {}",
file_idx, conn_id
),
});
};
log::debug!(
"conn {} requested file-{}: {}",
conn_id,
file_idx,
file.name
);
let size = file.size;
(
file_idx,
ClipboardFile::FileContentsResponse {
msg_flags: 0x1,
stream_id,
requested_data: size.to_le_bytes().to_vec(),
},
)
}
FileContentsRequest::Range {
stream_id,
file_idx,
offset,
length,
} => {
log::debug!(
"file contents (range from {} length {}) request from conn: {}",
offset,
length,
conn_id
);
let Some(file) = self.file_list.get_mut(file_idx) else {
log::error!(
"invalid file index {} requested from conn: {}",
file_idx,
conn_id
);
return Err(CliprdrError::InvalidRequest {
description: format!(
"invalid file index {} requested from conn: {}",
file_idx, conn_id
),
});
};
log::debug!(
"conn {} requested file-{}: {}",
conn_id,
file_idx,
file.name
);
if offset > file.size {
log::error!("invalid reading offset requested from conn: {}", conn_id);
return Err(CliprdrError::InvalidRequest {
description: format!(
"invalid reading offset requested from conn: {}",
conn_id
),
});
}
let read_size = if offset + length > file.size {
file.size - offset
} else {
length
};
let mut buf = vec![0u8; read_size as usize];
file.read_exact_at(&mut buf, offset)?;
(
file_idx,
ClipboardFile::FileContentsResponse {
msg_flags: 0x1,
stream_id,
requested_data: buf,
},
)
}
};
log::debug!("file contents sent to conn: {}", conn_id);
// hot reload next file
for next_file in self.file_list.iter_mut().skip(file_idx + 1) {
if !next_file.is_dir {
next_file.load_handle()?;
break;
}
}
Ok(file_contents_resp)
}
}
#[inline]
pub fn clear_files() {
CLIP_FILES.lock().clear();
}
pub fn read_file_contents(
conn_id: i32,
stream_id: i32,
list_index: i32,
dw_flags: i32,
n_position_low: i32,
n_position_high: i32,
cb_requested: i32,
) -> Result<ClipboardFile, CliprdrError> {
let fcr = if dw_flags == 0x1 {
FileContentsRequest::Size {
stream_id,
file_idx: list_index as usize,
}
} else if dw_flags == 0x2 {
let offset = (n_position_high as u64) << 32 | n_position_low as u64;
let length = cb_requested as u64;
FileContentsRequest::Range {
stream_id,
file_idx: list_index as usize,
offset,
length,
}
} else {
return Err(CliprdrError::InvalidRequest {
description: format!("got invalid FileContentsRequest, dw_flats: {dw_flags}"),
});
};
CLIP_FILES.lock().serve_file_contents(conn_id, fcr)
}
pub fn sync_files(files: &[String]) -> Result<(), CliprdrError> {
let mut files_lock = CLIP_FILES.lock();
if files_lock.files == files {
return Ok(());
}
files_lock.sync_files(files)?;
Ok(files_lock.build_file_list_pdu())
}
pub fn get_file_list_pdu() -> Vec<u8> {
CLIP_FILES.lock().files_pdu.clone()
}

View File

@@ -1,75 +0,0 @@
use std::path::{Path, PathBuf};
use crate::CliprdrError;
// on x11, path will be encode as
// "/home/rustdesk/pictures/🖼️.png" -> "file:///home/rustdesk/pictures/%F0%9F%96%BC%EF%B8%8F.png"
// url encode and decode is needed
const ENCODE_SET: percent_encoding::AsciiSet = percent_encoding::CONTROLS.add(b' ').remove(b'/');
pub(super) fn encode_path_to_uri(path: &Path) -> io::Result<String> {
let encoded =
percent_encoding::percent_encode(path.to_str()?.as_bytes(), &ENCODE_SET).to_string();
format!("file://{}", encoded)
}
pub(super) fn parse_uri_to_path(encoded_uri: &str) -> Result<PathBuf, CliprdrError> {
let encoded_path = encoded_uri.trim_start_matches("file://");
let path_str = percent_encoding::percent_decode_str(encoded_path)
.decode_utf8()
.map_err(|_| CliprdrError::ConversionFailure)?;
let path_str = path_str.to_string();
Ok(Path::new(&path_str).to_path_buf())
}
// helper parse function
// convert 'text/uri-list' data to a list of valid Paths
// # Note
// - none utf8 data will lead to error
pub(super) fn parse_plain_uri_list(v: Vec<u8>) -> Result<Vec<PathBuf>, CliprdrError> {
let text = String::from_utf8(v).map_err(|_| CliprdrError::ConversionFailure)?;
parse_uri_list(&text)
}
// helper parse function
// convert 'text/uri-list' data to a list of valid Paths
// # Note
// - none utf8 data will lead to error
pub(super) fn parse_uri_list(text: &str) -> Result<Vec<PathBuf>, CliprdrError> {
let mut list = Vec::new();
for line in text.lines() {
if !line.starts_with("file://") {
continue;
}
let decoded = parse_uri_to_path(line)?;
list.push(decoded)
}
Ok(list)
}
#[cfg(test)]
mod uri_test {
#[test]
fn test_conversion() {
let path = std::path::PathBuf::from("/home/rustdesk/pictures/🖼️.png");
let uri = super::encode_path_to_uri(&path).unwrap();
assert_eq!(
uri,
"file:///home/rustdesk/pictures/%F0%9F%96%BC%EF%B8%8F.png"
);
let convert_back = super::parse_uri_to_path(&uri).unwrap();
assert_eq!(path, convert_back);
}
#[test]
fn parse_list() {
let uri_list = r#"file:///home/rustdesk/pictures/%F0%9F%96%BC%EF%B8%8F.png
file:///home/rustdesk/pictures/%F0%9F%96%BC%EF%B8%8F.png
"#;
let list = super::parse_uri_list(uri_list.into()).unwrap();
assert!(list.len() == 2);
assert_eq!(list[0], list[1]);
}
}

View File

@@ -1,171 +0,0 @@
use std::{
collections::BTreeSet,
path::{Path, PathBuf},
};
use hbb_common::log;
use once_cell::sync::OnceCell;
use parking_lot::Mutex;
use x11_clipboard::Clipboard;
use x11rb::protocol::xproto::Atom;
use crate::{platform::unix::send_format_list, CliprdrError};
use super::{encode_path_to_uri, parse_plain_uri_list, SysClipboard};
static X11_CLIPBOARD: OnceCell<Clipboard> = OnceCell::new();
fn get_clip() -> Result<&'static Clipboard, CliprdrError> {
X11_CLIPBOARD.get_or_try_init(|| Clipboard::new().map_err(|_| CliprdrError::CliprdrInit))
}
pub struct X11Clipboard {
ignore_path: PathBuf,
text_uri_list: Atom,
gnome_copied_files: Atom,
nautilus_clipboard: Atom,
former_file_list: Mutex<Vec<PathBuf>>,
}
impl X11Clipboard {
pub fn new(ignore_path: &Path) -> Result<Self, CliprdrError> {
let clipboard = get_clip()?;
let text_uri_list = clipboard
.setter
.get_atom("text/uri-list")
.map_err(|_| CliprdrError::CliprdrInit)?;
let gnome_copied_files = clipboard
.setter
.get_atom("x-special/gnome-copied-files")
.map_err(|_| CliprdrError::CliprdrInit)?;
let nautilus_clipboard = clipboard
.setter
.get_atom("x-special/nautilus-clipboard")
.map_err(|_| CliprdrError::CliprdrInit)?;
Ok(Self {
ignore_path: ignore_path.to_owned(),
text_uri_list,
gnome_copied_files,
nautilus_clipboard,
former_file_list: Mutex::new(vec![]),
})
}
fn load(&self, target: Atom) -> Result<Vec<u8>, CliprdrError> {
let clip = get_clip()?.setter.atoms.clipboard;
let prop = get_clip()?.setter.atoms.property;
// NOTE:
// # why not use `load_wait`
// load_wait is likely to wait forever, which is not what we want
let res = get_clip()?.load_wait(clip, target, prop);
match res {
Ok(res) => Ok(res),
Err(x11_clipboard::error::Error::UnexpectedType(_)) => Ok(vec![]),
Err(x11_clipboard::error::Error::Timeout) => {
log::debug!("x11 clipboard get content timeout.");
Err(CliprdrError::ClipboardInternalError)
}
Err(e) => {
log::debug!("x11 clipboard get content fail: {:?}", e);
Err(CliprdrError::ClipboardInternalError)
}
}
}
fn store_batch(&self, batch: Vec<(Atom, Vec<u8>)>) -> Result<(), CliprdrError> {
let clip = get_clip()?.setter.atoms.clipboard;
log::debug!("try to store clipboard content");
get_clip()?
.store_batch(clip, batch)
.map_err(|_| CliprdrError::ClipboardInternalError)
}
fn wait_file_list(&self) -> Result<Option<Vec<PathBuf>>, CliprdrError> {
let v = self.load(self.text_uri_list)?;
let p = parse_plain_uri_list(v)?;
Ok(Some(p))
}
}
impl SysClipboard for X11Clipboard {
fn set_file_list(&self, paths: &[PathBuf]) -> Result<(), CliprdrError> {
*self.former_file_list.lock() = paths.to_vec();
let uri_list: Vec<String> = {
let mut v = Vec::new();
for path in paths {
v.push(encode_path_to_uri(path)?);
}
v
};
let uri_list = uri_list.join("\n");
let text_uri_list_data = uri_list.as_bytes().to_vec();
let gnome_copied_files_data = ["copy\n".as_bytes(), uri_list.as_bytes()].concat();
let batch = vec![
(self.text_uri_list, text_uri_list_data),
(self.gnome_copied_files, gnome_copied_files_data.clone()),
(self.nautilus_clipboard, gnome_copied_files_data),
];
self.store_batch(batch)
.map_err(|_| CliprdrError::ClipboardInternalError)
}
fn start(&self) {
{
// clear cached file list
*self.former_file_list.lock() = vec![];
}
loop {
let sth = match self.wait_file_list() {
Ok(sth) => sth,
Err(e) => {
log::warn!("failed to get file list from clipboard: {}", e);
std::thread::sleep(std::time::Duration::from_millis(100));
continue;
}
};
let Some(paths) = sth else {
// just sleep
std::thread::sleep(std::time::Duration::from_millis(100));
continue;
};
let filtered = paths
.into_iter()
.filter(|pb| !pb.starts_with(&self.ignore_path))
.collect::<Vec<_>>();
if filtered.is_empty() {
std::thread::sleep(std::time::Duration::from_millis(100));
continue;
}
{
let mut former = self.former_file_list.lock();
let filtered_st: BTreeSet<_> = filtered.iter().collect();
let former_st = former.iter().collect::<BTreeSet<_>>();
if filtered_st == former_st {
std::thread::sleep(std::time::Duration::from_millis(100));
continue;
}
*former = filtered;
}
if let Err(e) = send_format_list(0) {
log::warn!("failed to send format list: {}", e);
break;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
log::debug!("stop listening file related atoms on clipboard");
}
fn get_file_list(&self) -> Vec<PathBuf> {
self.former_file_list.lock().clone()
}
}

View File

@@ -6,10 +6,10 @@
#![allow(deref_nullptr)] #![allow(deref_nullptr)]
use crate::{ use crate::{
allow_err, send_data, ClipboardFile, CliprdrError, CliprdrServiceContext, ResultType, send_data, send_data_exclude, ClipboardFile, CliprdrError, CliprdrServiceContext, ResultType,
ERR_CODE_INVALID_PARAMETER, ERR_CODE_SEND_MSG, ERR_CODE_SERVER_FUNCTION_NONE, VEC_MSG_CHANNEL, ERR_CODE_INVALID_PARAMETER, ERR_CODE_SEND_MSG, ERR_CODE_SERVER_FUNCTION_NONE, VEC_MSG_CHANNEL,
}; };
use hbb_common::log; use hbb_common::{allow_err, log};
use std::{ use std::{
boxed::Box, boxed::Box,
ffi::{CStr, CString}, ffi::{CStr, CString},
@@ -614,6 +614,7 @@ fn ret_to_result(ret: u32) -> Result<(), CliprdrError> {
e => Err(CliprdrError::Unknown(e)), e => Err(CliprdrError::Unknown(e)),
} }
} }
pub fn empty_clipboard(context: &mut CliprdrClientContext, conn_id: i32) -> bool { pub fn empty_clipboard(context: &mut CliprdrClientContext, conn_id: i32) -> bool {
unsafe { TRUE == empty_cliprdr(context, conn_id as u32) } unsafe { TRUE == empty_cliprdr(context, conn_id as u32) }
} }
@@ -643,6 +644,7 @@ pub fn server_clip_file(
conn_id, conn_id,
&format_list &format_list
); );
send_data_exclude(conn_id as _, ClipboardFile::TryEmpty);
ret = server_format_list(context, conn_id, format_list); ret = server_format_list(context, conn_id, format_list);
log::debug!( log::debug!(
"server_format_list called, conn_id {}, return {}", "server_format_list called, conn_id {}, return {}",
@@ -740,6 +742,11 @@ pub fn server_clip_file(
ret ret
); );
} }
ClipboardFile::TryEmpty => {
log::debug!("empty_clipboard called");
let ret = empty_clipboard(context, conn_id);
log::debug!("empty_clipboard called, conn_id {}, return {}", conn_id, ret);
}
} }
ret ret
} }

View File

@@ -269,6 +269,7 @@ static UINT cliprdr_send_request_filecontents(wfClipboard *clipboard, UINT32 con
DWORD positionlow, ULONG request); DWORD positionlow, ULONG request);
static BOOL is_file_descriptor_from_remote(); static BOOL is_file_descriptor_from_remote();
static BOOL is_set_by_instance(wfClipboard *clipboard);
static void CliprdrDataObject_Delete(CliprdrDataObject *instance); static void CliprdrDataObject_Delete(CliprdrDataObject *instance);
@@ -600,8 +601,11 @@ static CliprdrStream *CliprdrStream_New(UINT32 connID, ULONG index, void *pData,
clipboard->req_fdata = NULL; clipboard->req_fdata = NULL;
} }
} }
else else {
instance->m_lSize.QuadPart =
((UINT64)instance->m_Dsc.nFileSizeHigh << 32) | instance->m_Dsc.nFileSizeLow;
success = TRUE; success = TRUE;
}
} }
} }
@@ -1745,8 +1749,7 @@ static LRESULT CALLBACK cliprdr_proc(HWND hWnd, UINT Msg, WPARAM wParam, LPARAM
DEBUG_CLIPRDR("info: WM_CLIPBOARDUPDATE"); DEBUG_CLIPRDR("info: WM_CLIPBOARDUPDATE");
// if (clipboard->sync) // if (clipboard->sync)
{ {
if ((GetClipboardOwner() != clipboard->hwnd) && if (!is_set_by_instance(clipboard))
(S_FALSE == OleIsCurrentClipboard(clipboard->data_obj)))
{ {
if (clipboard->hmem) if (clipboard->hmem)
{ {
@@ -2086,6 +2089,8 @@ static FILEDESCRIPTORW *wf_cliprdr_get_file_descriptor(WCHAR *file_name, size_t
return NULL; return NULL;
} }
// to-do: use `fd->dwFlags = FD_ATTRIBUTES | FD_FILESIZE | FD_WRITESTIME | FD_PROGRESSUI`.
// We keep `fd->dwFlags = FD_ATTRIBUTES | FD_WRITESTIME | FD_PROGRESSUI` for compatibility.
// fd->dwFlags = FD_ATTRIBUTES | FD_FILESIZE | FD_WRITESTIME | FD_PROGRESSUI; // fd->dwFlags = FD_ATTRIBUTES | FD_FILESIZE | FD_WRITESTIME | FD_PROGRESSUI;
fd->dwFlags = FD_ATTRIBUTES | FD_WRITESTIME | FD_PROGRESSUI; fd->dwFlags = FD_ATTRIBUTES | FD_WRITESTIME | FD_PROGRESSUI;
fd->dwFileAttributes = GetFileAttributesW(file_name); fd->dwFileAttributes = GetFileAttributesW(file_name);
@@ -2849,6 +2854,31 @@ wf_cliprdr_server_file_contents_request(CliprdrClientContext *context,
goto exit; goto exit;
} }
// If the clipboard is set by the instance, or the file descriptor is from remote,
// we should not process the request.
// Because this may be the following cases:
// 1. `A` -> `B`, `C`
// 2. Copy in `A`
// 3. Copy in `B`
// 4. Paste in `C`
// In this case, `C` should not get the file content from `A`. The clipboard is set by `B`.
//
// Or
// 1. `B` -> `A` -> `C`
// 2. Copy in `A`
// 2. Copy in `B`
// 3. Paste in `C`
// In this case, `C` should not get the file content from `A`. The clipboard is set by `B`.
//
// We can simply notify `C` to clear the clipboard when `A` received copy message from `B`,
// if connections are in the same process.
// But if connections are in different processes, it is not easy to notify the other process.
// So we just ignore the request from `C` in this case.
if (is_set_by_instance(clipboard) || is_file_descriptor_from_remote()) {
rc = ERROR_INTERNAL_ERROR;
goto exit;
}
cbRequested = fileContentsRequest->cbRequested; cbRequested = fileContentsRequest->cbRequested;
if (fileContentsRequest->dwFlags == FILECONTENTS_SIZE) if (fileContentsRequest->dwFlags == FILECONTENTS_SIZE)
cbRequested = sizeof(UINT64); cbRequested = sizeof(UINT64);
@@ -3089,6 +3119,14 @@ wf_cliprdr_server_file_contents_response(CliprdrClientContext *context,
return rc; return rc;
} }
BOOL is_set_by_instance(wfClipboard *clipboard)
{
if (GetClipboardOwner() == clipboard->hwnd || S_OK == OleIsCurrentClipboard(clipboard->data_obj)) {
return TRUE;
}
return FALSE;
}
BOOL is_file_descriptor_from_remote() BOOL is_file_descriptor_from_remote()
{ {
UINT fsid = 0; UINT fsid = 0;
@@ -3175,7 +3213,7 @@ BOOL wf_cliprdr_uninit(wfClipboard *clipboard, CliprdrClientContext *cliprdr)
/* discard all contexts in clipboard */ /* discard all contexts in clipboard */
if (try_open_clipboard(clipboard->hwnd)) if (try_open_clipboard(clipboard->hwnd))
{ {
if (is_file_descriptor_from_remote()) if (is_set_by_instance(clipboard) || is_file_descriptor_from_remote())
{ {
if (!EmptyClipboard()) if (!EmptyClipboard())
{ {

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "rustdesk-portable-packer" name = "rustdesk-portable-packer"
version = "1.3.7" version = "1.3.8"
edition = "2021" edition = "2021"
description = "RustDesk Remote Desktop" description = "RustDesk Remote Desktop"
@@ -18,7 +18,7 @@ winapi = { version = "0.3", features = ["winbase"] }
native-windows-gui = {version = "1.0", default-features = false, features = ["animation-timer", "image-decoder"]} native-windows-gui = {version = "1.0", default-features = false, features = ["animation-timer", "image-decoder"]}
[package.metadata.winres] [package.metadata.winres]
LegalCopyright = "Copyright © 2024 Purslane Ltd. All rights reserved." LegalCopyright = "Copyright © 2025 Purslane Ltd. All rights reserved."
ProductName = "RustDesk" ProductName = "RustDesk"
OriginalFilename = "rustdesk.exe" OriginalFilename = "rustdesk.exe"
FileDescription = "RustDesk Remote Desktop" FileDescription = "RustDesk Remote Desktop"

View File

@@ -133,7 +133,7 @@ impl MagInterface {
s.lib_handle = LoadLibraryExA( s.lib_handle = LoadLibraryExA(
lib_file_name_c.as_ptr() as _, lib_file_name_c.as_ptr() as _,
NULL, NULL,
LOAD_WITH_ALTERED_SEARCH_PATH, LOAD_LIBRARY_SEARCH_SYSTEM32,
); );
if s.lib_handle.is_null() { if s.lib_handle.is_null() {
return Err(Error::new( return Err(Error::new(

View File

@@ -1,50 +0,0 @@
FROM mcr.microsoft.com/devcontainers/base:ubuntu-22.04
ENV HOME=/home/vscode
ENV WORKDIR=$HOME/rustdesk
WORKDIR $HOME
RUN sudo apt update -y && sudo apt install -y g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake unzip zip sudo libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
WORKDIR /
RUN git clone https://github.com/microsoft/vcpkg
WORKDIR vcpkg
RUN git checkout 2023.04.15
RUN /vcpkg/bootstrap-vcpkg.sh -disableMetrics
ENV VCPKG_ROOT=/vcpkg
RUN $VCPKG_ROOT/vcpkg --disable-metrics install libvpx libyuv opus aom
WORKDIR /
RUN wget https://github.com/rustdesk/doc.rustdesk.com/releases/download/console/dep.tar.gz && tar xzf dep.tar.gz
USER vscode
WORKDIR $HOME
RUN wget https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs > rustup.sh
RUN chmod +x rustup.sh
RUN $HOME/rustup.sh -y
RUN $HOME/.cargo/bin/rustup target add aarch64-linux-android
RUN $HOME/.cargo/bin/cargo install cargo-ndk
# Install Flutter
RUN wget https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.10.1-stable.tar.xz
RUN tar xf flutter_linux_3.10.1-stable.tar.xz && rm flutter_linux_3.10.1-stable.tar.xz
ENV PATH="$PATH:$HOME/flutter/bin"
RUN dart pub global activate ffigen 5.0.1
# Install packages
RUN sudo apt-get install -y libclang-dev
RUN sudo apt install -y gcc-multilib
WORKDIR $WORKDIR
ENV ANDROID_NDK_HOME=/opt/android/ndk/22.1.7171670
# Somehow try to automate flutter pub get
# https://rustdesk.com/docs/en/dev/build/android/
# Put below steps in entrypoint.sh
# cd flutter
# wget https://github.com/rustdesk/doc.rustdesk.com/releases/download/console/so.tar.gz
# tar xzf so.tar.gz
# own /opt/android

View File

@@ -1,75 +0,0 @@
#!/bin/bash
set -e
MODE=${1:---debug}
TYPE=${2:-linux}
MODE=${MODE/*-/}
build(){
pwd
$WORKDIR/entrypoint $1
}
build_arm64(){
CWD=$(pwd)
cd $WORKDIR/flutter
flutter pub get
cd $WORKDIR
$WORKDIR/flutter/ndk_arm64.sh
cp $WORKDIR/target/aarch64-linux-android/release/liblibrustdesk.so $WORKDIR/flutter/android/app/src/main/jniLibs/arm64-v8a/librustdesk.so
cd $CWD
}
build_apk(){
cd $WORKDIR/flutter
MODE=$1 $WORKDIR/flutter/build_android.sh
cd $WORKDIR
}
key_gen(){
if [ ! -f $WORKDIR/flutter/android/key.properties ]
then
if [ ! -f $HOME/upload-keystore.jks ]
then
$WORKDIR/.devcontainer/setup.sh key
fi
read -r -p "enter the password used to generate $HOME/upload-keystore.jks\n" password
echo -e "storePassword=${password}\nkeyPassword=${password}\nkeyAlias=upload\nstoreFile=$HOME/upload-keystore.jks" > $WORKDIR/flutter/android/key.properties
else
echo "Believing storeFile is created ref: $WORKDIR/flutter/android/key.properties"
fi
}
android_build(){
if [ ! -d $WORKDIR/flutter/android/app/src/main/jniLibs/arm64-v8a ]
then
$WORKDIR/.devcontainer/setup.sh android
fi
build_arm64
case $1 in
debug)
build_apk debug
;;
release)
key_gen
build_apk release
;;
esac
}
case "$MODE:$TYPE" in
"debug:linux")
build
;;
"release:linux")
build --release
;;
"debug:android")
android_build debug
;;
"release:android")
android_build release
;;
esac

View File

@@ -1,34 +0,0 @@
{
"name": "rustdesk",
"build": {
"dockerfile": "./Dockerfile",
"context": "."
},
"workspaceMount": "source=${localWorkspaceFolder},target=/home/vscode/rustdesk,type=bind,consistency=cache",
"workspaceFolder": "/home/vscode/rustdesk",
"postStartCommand": ".devcontainer/build.sh",
"features": {
"ghcr.io/devcontainers/features/java:1": {},
"ghcr.io/akhildevelops/devcontainer-features/android-cli:latest": {
"PACKAGES": "platform-tools,ndk;23.2.8568313"
}
},
"customizations": {
"vscode": {
"extensions": [
"vadimcn.vscode-lldb",
"mutantdino.resourcemonitor",
"rust-lang.rust-analyzer",
"tamasfe.even-better-toml",
"serayuzgur.crates",
"mhutchie.git-graph",
"eamodio.gitlens"
],
"settings": {
"files.watcherExclude": {
"**/target/**": true
}
}
}
}
}

View File

@@ -1,23 +0,0 @@
#!/bin/bash
set -e
case $1 in
android)
# install deps
cd $WORKDIR/flutter
flutter pub get
wget https://github.com/rustdesk/doc.rustdesk.com/releases/download/console/so.tar.gz
tar xzf so.tar.gz
rm so.tar.gz
sudo chown -R $(whoami) $ANDROID_HOME
echo "Setup is Done."
;;
linux)
echo "Linux Setup"
;;
key)
echo -e "\n$HOME/upload-keystore.jks is not created.\nLet's create it.\nRemember the password you enter in keytool!"
keytool -genkey -v -keystore $HOME/upload-keystore.jks -keyalg RSA -keysize 2048 -validity 10000 -alias upload
;;
esac

View File

@@ -1,5 +1,5 @@
pkgname=rustdesk pkgname=rustdesk
pkgver=1.3.7 pkgver=1.3.8
pkgrel=0 pkgrel=0
epoch= epoch=
pkgdesc="" pkgdesc=""

View File

@@ -12,6 +12,7 @@ def view(
device_name=None, device_name=None,
user_name=None, user_name=None,
group_name=None, group_name=None,
device_group_name=None,
offline_days=None, offline_days=None,
): ):
headers = {"Authorization": f"Bearer {token}"} headers = {"Authorization": f"Bearer {token}"}
@@ -21,6 +22,7 @@ def view(
"device_name": device_name, "device_name": device_name,
"user_name": user_name, "user_name": user_name,
"group_name": group_name, "group_name": group_name,
"device_group_name": device_group_name,
} }
params = { params = {
@@ -118,7 +120,8 @@ def main():
parser.add_argument("--id", help="Device ID") parser.add_argument("--id", help="Device ID")
parser.add_argument("--device_name", help="Device name") parser.add_argument("--device_name", help="Device name")
parser.add_argument("--user_name", help="User name") parser.add_argument("--user_name", help="User name")
parser.add_argument("--group_name", help="Group name") parser.add_argument("--group_name", help="User group name")
parser.add_argument("--device_group_name", help="Device group name")
parser.add_argument( parser.add_argument(
"--assign_to", "--assign_to",
help="<type>=<value>, e.g. user_name=mike, strategy_name=test, ab=ab1, ab=ab1,tag1", help="<type>=<value>, e.g. user_name=mike, strategy_name=test, ab=ab1, ab=ab1,tag1",
@@ -138,6 +141,7 @@ def main():
args.device_name, args.device_name,
args.user_name, args.user_name,
args.group_name, args.group_name,
args.device_group_name,
args.offline_days, args.offline_days,
) )

View File

@@ -1,5 +1,5 @@
Name: rustdesk Name: rustdesk
Version: 1.3.7 Version: 1.3.8
Release: 0 Release: 0
Summary: RPM package Summary: RPM package
License: GPL-3.0 License: GPL-3.0

View File

@@ -1,5 +1,5 @@
Name: rustdesk Name: rustdesk
Version: 1.3.7 Version: 1.3.8
Release: 0 Release: 0
Summary: RPM package Summary: RPM package
License: GPL-3.0 License: GPL-3.0

View File

@@ -1,5 +1,5 @@
Name: rustdesk Name: rustdesk
Version: 1.3.7 Version: 1.3.8
Release: 0 Release: 0
Summary: RPM package Summary: RPM package
License: GPL-3.0 License: GPL-3.0

View File

@@ -0,0 +1,31 @@
diff --git a/compat/w32dlfcn.h b/compat/w32dlfcn.h
index ac20e83..1e83aa6 100644
--- a/compat/w32dlfcn.h
+++ b/compat/w32dlfcn.h
@@ -76,6 +76,7 @@ static inline HMODULE win32_dlopen(const char *name)
if (!name_w)
goto exit;
namelen = wcslen(name_w);
+ /*
// Try local directory first
path = get_module_filename(NULL);
if (!path)
@@ -91,6 +92,7 @@ static inline HMODULE win32_dlopen(const char *name)
path = new_path;
wcscpy(path + pathlen + 1, name_w);
module = LoadLibraryExW(path, NULL, LOAD_WITH_ALTERED_SEARCH_PATH);
+ */
if (module == NULL) {
// Next try System32 directory
pathlen = GetSystemDirectoryW(path, pathsize);
@@ -131,7 +133,9 @@ exit:
return NULL;
module = LoadPackagedLibrary(name_w, 0);
#else
-#define LOAD_FLAGS (LOAD_LIBRARY_SEARCH_APPLICATION_DIR | LOAD_LIBRARY_SEARCH_SYSTEM32)
+// #define LOAD_FLAGS (LOAD_LIBRARY_SEARCH_APPLICATION_DIR | LOAD_LIBRARY_SEARCH_SYSTEM32)
+// Don't dynamic-link libraries from the application directory.
+ #define LOAD_FLAGS LOAD_LIBRARY_SEARCH_SYSTEM32
/* filename may be be in CP_ACP */
if (!name_w)
return LoadLibraryExA(name, NULL, LOAD_FLAGS);

View File

@@ -25,6 +25,7 @@ vcpkg_from_github(
patch/0007-fix-linux-configure.patch patch/0007-fix-linux-configure.patch
patch/0008-remove-amf-loop-query.patch patch/0008-remove-amf-loop-query.patch
patch/0009-fix-nvenc-reconfigure-blur.patch patch/0009-fix-nvenc-reconfigure-blur.patch
patch/0010.disable-loading-DLLs-from-app-dir.patch
) )
if(SOURCE_PATH MATCHES " ") if(SOURCE_PATH MATCHES " ")

View File

@@ -1,7 +1,9 @@
#[cfg(not(any(target_os = "android", target_os = "ios")))]
use crate::clipboard::clipboard_listener;
use async_trait::async_trait; use async_trait::async_trait;
use bytes::Bytes; use bytes::Bytes;
#[cfg(not(any(target_os = "android", target_os = "ios")))] #[cfg(not(any(target_os = "android", target_os = "ios")))]
use clipboard_master::{CallbackResult, ClipboardHandler}; use clipboard_master::CallbackResult;
#[cfg(not(target_os = "linux"))] #[cfg(not(target_os = "linux"))]
use cpal::{ use cpal::{
traits::{DeviceTrait, HostTrait, StreamTrait}, traits::{DeviceTrait, HostTrait, StreamTrait},
@@ -15,17 +17,25 @@ use serde::{Deserialize, Serialize};
use std::{ use std::{
collections::HashMap, collections::HashMap,
ffi::c_void, ffi::c_void,
io,
net::SocketAddr, net::SocketAddr,
ops::Deref, ops::Deref,
str::FromStr, str::FromStr,
sync::{ sync::{
mpsc::{self, RecvTimeoutError, Sender}, mpsc::{self, RecvTimeoutError},
Arc, Mutex, RwLock, Arc, Mutex, RwLock,
}, },
}; };
use uuid::Uuid; use uuid::Uuid;
use crate::{
check_port,
common::input::{MOUSE_BUTTON_LEFT, MOUSE_BUTTON_RIGHT, MOUSE_TYPE_DOWN, MOUSE_TYPE_UP},
create_symmetric_key_msg, decode_id_pk, get_rs_pk, is_keyboard_mode_supported, secure_tcp,
ui_interface::{get_builtin_option, use_texture_render},
ui_session_interface::{InvokeUiSession, Session},
};
#[cfg(feature = "unix-file-copy-paste")]
use crate::{clipboard::check_clipboard_files, clipboard_file::unix_file_clip};
pub use file_trait::FileManager; pub use file_trait::FileManager;
#[cfg(not(feature = "flutter"))] #[cfg(not(feature = "flutter"))]
#[cfg(not(any(target_os = "android", target_os = "ios")))] #[cfg(not(any(target_os = "android", target_os = "ios")))]
@@ -37,7 +47,7 @@ use hbb_common::{
bail, bail,
config::{ config::{
self, Config, LocalConfig, PeerConfig, PeerInfoSerde, Resolution, CONNECT_TIMEOUT, self, Config, LocalConfig, PeerConfig, PeerInfoSerde, Resolution, CONNECT_TIMEOUT,
PUBLIC_RS_PUB_KEY, READ_TIMEOUT, RELAY_PORT, RENDEZVOUS_PORT, RENDEZVOUS_SERVERS, READ_TIMEOUT, RELAY_PORT, RENDEZVOUS_PORT, RENDEZVOUS_SERVERS,
}, },
get_version_number, log, get_version_number, log,
message_proto::{option_message::BoolOption, *}, message_proto::{option_message::BoolOption, *},
@@ -62,14 +72,6 @@ use scrap::{
CodecFormat, ImageFormat, ImageRgb, ImageTexture, CodecFormat, ImageFormat, ImageRgb, ImageTexture,
}; };
use crate::{
check_port,
common::input::{MOUSE_BUTTON_LEFT, MOUSE_BUTTON_RIGHT, MOUSE_TYPE_DOWN, MOUSE_TYPE_UP},
create_symmetric_key_msg, decode_id_pk, get_rs_pk, is_keyboard_mode_supported, secure_tcp,
ui_interface::{get_builtin_option, use_texture_render},
ui_session_interface::{InvokeUiSession, Session},
};
#[cfg(not(target_os = "ios"))] #[cfg(not(target_os = "ios"))]
use crate::clipboard::CLIPBOARD_INTERVAL; use crate::clipboard::CLIPBOARD_INTERVAL;
#[cfg(not(any(target_os = "android", target_os = "ios")))] #[cfg(not(any(target_os = "android", target_os = "ios")))]
@@ -128,14 +130,19 @@ pub(crate) struct ClientClipboardContext;
pub(crate) struct ClientClipboardContext { pub(crate) struct ClientClipboardContext {
pub cfg: SessionPermissionConfig, pub cfg: SessionPermissionConfig,
pub tx: UnboundedSender<Data>, pub tx: UnboundedSender<Data>,
#[cfg(feature = "unix-file-copy-paste")]
pub is_file_supported: bool,
} }
/// Client of the remote desktop. /// Client of the remote desktop.
pub struct Client; pub struct Client;
#[cfg(not(target_os = "ios"))] #[cfg(not(target_os = "ios"))]
struct TextClipboardState { struct ClipboardState {
is_required: bool, #[cfg(feature = "flutter")]
is_text_required: bool,
#[cfg(all(feature = "flutter", feature = "unix-file-copy-paste"))]
is_file_required: bool,
running: bool, running: bool,
} }
@@ -151,7 +158,7 @@ lazy_static::lazy_static! {
#[cfg(not(target_os = "ios"))] #[cfg(not(target_os = "ios"))]
lazy_static::lazy_static! { lazy_static::lazy_static! {
static ref TEXT_CLIPBOARD_STATE: Arc<Mutex<TextClipboardState>> = Arc::new(Mutex::new(TextClipboardState::new())); static ref CLIPBOARD_STATE: Arc<Mutex<ClipboardState>> = Arc::new(Mutex::new(ClipboardState::new()));
} }
const PUBLIC_SERVER: &str = "public"; const PUBLIC_SERVER: &str = "public";
@@ -167,6 +174,8 @@ pub fn get_key_state(key: enigo::Key) -> bool {
} }
impl Client { impl Client {
const CLIENT_CLIPBOARD_NAME: &'static str = "client-clipboard";
/// Start a new connection. /// Start a new connection.
pub async fn start( pub async fn start(
peer: &str, peer: &str,
@@ -657,7 +666,13 @@ impl Client {
#[cfg(feature = "flutter")] #[cfg(feature = "flutter")]
#[cfg(not(target_os = "ios"))] #[cfg(not(target_os = "ios"))]
pub fn set_is_text_clipboard_required(b: bool) { pub fn set_is_text_clipboard_required(b: bool) {
TEXT_CLIPBOARD_STATE.lock().unwrap().is_required = b; CLIPBOARD_STATE.lock().unwrap().is_text_required = b;
}
#[inline]
#[cfg(all(feature = "flutter", feature = "unix-file-copy-paste"))]
pub fn set_is_file_clipboard_required(b: bool) {
CLIPBOARD_STATE.lock().unwrap().is_file_required = b;
} }
#[cfg(not(target_os = "ios"))] #[cfg(not(target_os = "ios"))]
@@ -673,68 +688,55 @@ impl Client {
if crate::flutter::sessions::has_sessions_running(ConnType::DEFAULT_CONN) { if crate::flutter::sessions::has_sessions_running(ConnType::DEFAULT_CONN) {
return; return;
} }
TEXT_CLIPBOARD_STATE.lock().unwrap().running = false; #[cfg(not(target_os = "android"))]
clipboard_listener::unsubscribe(Self::CLIENT_CLIPBOARD_NAME);
CLIPBOARD_STATE.lock().unwrap().running = false;
#[cfg(all(feature = "unix-file-copy-paste", target_os = "linux"))]
clipboard::platform::unix::fuse::uninit_fuse_context(true);
} }
// `try_start_clipboard` is called by all session when connection is established. (When handling peer info). // `try_start_clipboard` is called by all session when connection is established. (When handling peer info).
// This function only create one thread with a loop, the loop is shared by all sessions. // This function only create one thread with a loop, the loop is shared by all sessions.
// After all sessions are end, the loop exists. // After all sessions are end, the loop exists.
// //
// If clipboard update is detected, the text will be sent to all sessions by `send_text_clipboard_msg`. // If clipboard update is detected, the text will be sent to all sessions by `send_clipboard_msg`.
#[cfg(not(any(target_os = "android", target_os = "ios")))] #[cfg(not(any(target_os = "android", target_os = "ios")))]
fn try_start_clipboard( fn try_start_clipboard(
_client_clip_ctx: Option<ClientClipboardContext>, _client_clip_ctx: Option<ClientClipboardContext>,
) -> Option<UnboundedReceiver<()>> { ) -> Option<UnboundedReceiver<()>> {
let mut clipboard_lock = TEXT_CLIPBOARD_STATE.lock().unwrap(); let mut clipboard_lock = CLIPBOARD_STATE.lock().unwrap();
if clipboard_lock.running { if clipboard_lock.running {
return None; return None;
} }
let (tx_cb_result, rx_cb_result) = mpsc::channel(); let (tx_cb_result, rx_cb_result) = mpsc::channel();
let handler = ClientClipboardHandler { if let Err(e) =
ctx: None, clipboard_listener::subscribe(Self::CLIENT_CLIPBOARD_NAME.to_owned(), tx_cb_result)
tx_cb_result, {
#[cfg(not(feature = "flutter"))] log::error!("Failed to subscribe clipboard listener: {}", e);
client_clip_ctx: _client_clip_ctx, return None;
}; }
let (tx_start_res, rx_start_res) = mpsc::channel();
let h = crate::clipboard::start_clipbard_master_thread(handler, tx_start_res);
let shutdown = match rx_start_res.recv() {
Ok((Some(s), _)) => s,
Ok((None, err)) => {
log::error!("{}", err);
return None;
}
Err(e) => {
log::error!("Failed to create clipboard listener: {}", e);
return None;
}
};
clipboard_lock.running = true; clipboard_lock.running = true;
let (tx_started, rx_started) = unbounded_channel(); let (tx_started, rx_started) = unbounded_channel();
log::info!("Start text clipboard loop"); log::info!("Start client clipboard loop");
std::thread::spawn(move || { std::thread::spawn(move || {
let mut is_sent = false; let mut handler = ClientClipboardHandler {
ctx: None,
#[cfg(not(feature = "flutter"))]
client_clip_ctx: _client_clip_ctx,
};
tx_started.send(()).ok();
loop { loop {
if !TEXT_CLIPBOARD_STATE.lock().unwrap().running { if !CLIPBOARD_STATE.lock().unwrap().running {
break; break;
} }
if !TEXT_CLIPBOARD_STATE.lock().unwrap().is_required {
std::thread::sleep(Duration::from_millis(CLIPBOARD_INTERVAL));
continue;
}
if !is_sent {
is_sent = true;
tx_started.send(()).ok();
}
match rx_cb_result.recv_timeout(Duration::from_millis(CLIPBOARD_INTERVAL)) { match rx_cb_result.recv_timeout(Duration::from_millis(CLIPBOARD_INTERVAL)) {
Ok(CallbackResult::Next) => {
handler.check_clipboard();
}
Ok(CallbackResult::Stop) => { Ok(CallbackResult::Stop) => {
log::debug!("Clipboard listener stopped"); log::debug!("Clipboard listener stopped");
break; break;
@@ -744,13 +746,14 @@ impl Client {
break; break;
} }
Err(RecvTimeoutError::Timeout) => {} Err(RecvTimeoutError::Timeout) => {}
_ => {} Err(RecvTimeoutError::Disconnected) => {
log::error!("Clipboard listener disconnected");
break;
}
} }
} }
log::info!("Stop text clipboard loop"); log::info!("Stop client clipboard loop");
shutdown.signal(); CLIPBOARD_STATE.lock().unwrap().running = false;
h.join().ok();
TEXT_CLIPBOARD_STATE.lock().unwrap().running = false;
}); });
Some(rx_started) Some(rx_started)
@@ -758,31 +761,31 @@ impl Client {
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
fn try_start_clipboard(_p: Option<()>) -> Option<UnboundedReceiver<()>> { fn try_start_clipboard(_p: Option<()>) -> Option<UnboundedReceiver<()>> {
let mut clipboard_lock = TEXT_CLIPBOARD_STATE.lock().unwrap(); let mut clipboard_lock = CLIPBOARD_STATE.lock().unwrap();
if clipboard_lock.running { if clipboard_lock.running {
return None; return None;
} }
clipboard_lock.running = true; clipboard_lock.running = true;
log::info!("Start text clipboard loop"); log::info!("Start client clipboard loop");
std::thread::spawn(move || { std::thread::spawn(move || {
loop { loop {
if !TEXT_CLIPBOARD_STATE.lock().unwrap().running { if !CLIPBOARD_STATE.lock().unwrap().running {
break; break;
} }
if !TEXT_CLIPBOARD_STATE.lock().unwrap().is_required { if !CLIPBOARD_STATE.lock().unwrap().is_text_required {
std::thread::sleep(Duration::from_millis(CLIPBOARD_INTERVAL)); std::thread::sleep(Duration::from_millis(CLIPBOARD_INTERVAL));
continue; continue;
} }
if let Some(msg) = crate::clipboard::get_clipboards_msg(true) { if let Some(msg) = crate::clipboard::get_clipboards_msg(true) {
crate::flutter::send_text_clipboard_msg(msg); crate::flutter::send_clipboard_msg(msg, false);
} }
std::thread::sleep(Duration::from_millis(CLIPBOARD_INTERVAL)); std::thread::sleep(Duration::from_millis(CLIPBOARD_INTERVAL));
} }
log::info!("Stop text clipboard loop"); log::info!("Stop client clipboard loop");
TEXT_CLIPBOARD_STATE.lock().unwrap().running = false; CLIPBOARD_STATE.lock().unwrap().running = false;
}); });
None None
@@ -790,10 +793,13 @@ impl Client {
} }
#[cfg(not(target_os = "ios"))] #[cfg(not(target_os = "ios"))]
impl TextClipboardState { impl ClipboardState {
fn new() -> Self { fn new() -> Self {
Self { Self {
is_required: true, #[cfg(feature = "flutter")]
is_text_required: true,
#[cfg(all(feature = "flutter", feature = "unix-file-copy-paste"))]
is_file_required: true,
running: false, running: false,
} }
} }
@@ -802,62 +808,105 @@ impl TextClipboardState {
#[cfg(not(any(target_os = "android", target_os = "ios")))] #[cfg(not(any(target_os = "android", target_os = "ios")))]
struct ClientClipboardHandler { struct ClientClipboardHandler {
ctx: Option<crate::clipboard::ClipboardContext>, ctx: Option<crate::clipboard::ClipboardContext>,
tx_cb_result: Sender<CallbackResult>,
#[cfg(not(feature = "flutter"))] #[cfg(not(feature = "flutter"))]
client_clip_ctx: Option<ClientClipboardContext>, client_clip_ctx: Option<ClientClipboardContext>,
} }
#[cfg(not(any(target_os = "android", target_os = "ios")))] #[cfg(not(any(target_os = "android", target_os = "ios")))]
impl ClientClipboardHandler { impl ClientClipboardHandler {
fn is_text_required(&self) -> bool {
#[cfg(feature = "flutter")]
{
CLIPBOARD_STATE.lock().unwrap().is_text_required
}
#[cfg(not(feature = "flutter"))]
{
self.client_clip_ctx
.as_ref()
.map(|ctx| ctx.cfg.is_text_clipboard_required())
.unwrap_or(false)
}
}
#[cfg(feature = "unix-file-copy-paste")]
fn is_file_required(&self) -> bool {
#[cfg(feature = "flutter")]
{
CLIPBOARD_STATE.lock().unwrap().is_file_required
}
#[cfg(not(feature = "flutter"))]
{
self.client_clip_ctx
.as_ref()
.map(|ctx| ctx.cfg.is_file_clipboard_required())
.unwrap_or(false)
}
}
fn check_clipboard(&mut self) {
if CLIPBOARD_STATE.lock().unwrap().running {
#[cfg(feature = "unix-file-copy-paste")]
if let Some(urls) = check_clipboard_files(&mut self.ctx, ClipboardSide::Client, false) {
if !urls.is_empty() {
if self.is_file_required() {
match clipboard::platform::unix::serv_files::sync_files(&urls) {
Ok(()) => {
let msg = crate::clipboard_file::clip_2_msg(
unix_file_clip::get_format_list(),
);
self.send_msg(msg, true);
}
Err(e) => {
log::error!("Failed to sync clipboard files: {}", e);
}
}
return;
}
}
}
if let Some(msg) = check_clipboard(&mut self.ctx, ClipboardSide::Client, false) {
if self.is_text_required() {
self.send_msg(msg, false);
}
}
}
}
#[inline] #[inline]
#[cfg(feature = "flutter")] #[cfg(feature = "flutter")]
fn send_msg(&self, msg: Message) { fn send_msg(&self, msg: Message, _is_file: bool) {
crate::flutter::send_text_clipboard_msg(msg); crate::flutter::send_clipboard_msg(msg, _is_file);
} }
#[cfg(not(feature = "flutter"))] #[cfg(not(feature = "flutter"))]
fn send_msg(&self, msg: Message) { fn send_msg(&self, msg: Message, _is_file: bool) {
if let Some(ctx) = &self.client_clip_ctx { if let Some(ctx) = &self.client_clip_ctx {
if ctx.cfg.is_text_clipboard_required() { #[cfg(feature = "unix-file-copy-paste")]
if let Some(pi) = ctx.cfg.lc.read().unwrap().peer_info.as_ref() { if _is_file {
if let Some(message::Union::MultiClipboards(multi_clipboards)) = &msg.union { if ctx.is_file_supported {
if let Some(msg_out) = crate::clipboard::get_msg_if_not_support_multi_clip( let _ = ctx.tx.send(Data::Message(msg));
&pi.version, }
&pi.platform, return;
multi_clipboards, }
) {
let _ = ctx.tx.send(Data::Message(msg_out)); if let Some(pi) = ctx.cfg.lc.read().unwrap().peer_info.as_ref() {
return; if let Some(message::Union::MultiClipboards(multi_clipboards)) = &msg.union {
} if let Some(msg_out) = crate::clipboard::get_msg_if_not_support_multi_clip(
&pi.version,
&pi.platform,
multi_clipboards,
) {
let _ = ctx.tx.send(Data::Message(msg_out));
return;
} }
} }
let _ = ctx.tx.send(Data::Message(msg));
} }
let _ = ctx.tx.send(Data::Message(msg));
} }
} }
} }
#[cfg(not(any(target_os = "android", target_os = "ios")))]
impl ClipboardHandler for ClientClipboardHandler {
fn on_clipboard_change(&mut self) -> CallbackResult {
if TEXT_CLIPBOARD_STATE.lock().unwrap().running
&& TEXT_CLIPBOARD_STATE.lock().unwrap().is_required
{
if let Some(msg) = check_clipboard(&mut self.ctx, ClipboardSide::Client, false) {
self.send_msg(msg);
}
}
CallbackResult::Next
}
fn on_clipboard_error(&mut self, error: io::Error) -> CallbackResult {
self.tx_cb_result
.send(CallbackResult::StopWithError(error))
.ok();
CallbackResult::Next
}
}
/// Audio handler for the [`Client`]. /// Audio handler for the [`Client`].
#[derive(Default)] #[derive(Default)]
pub struct AudioHandler { pub struct AudioHandler {
@@ -1475,7 +1524,7 @@ impl LoginConfigHandler {
let server = server_key.next().unwrap_or_default(); let server = server_key.next().unwrap_or_default();
let args = server_key.next().unwrap_or_default(); let args = server_key.next().unwrap_or_default();
let key = if server == PUBLIC_SERVER { let key = if server == PUBLIC_SERVER {
PUBLIC_RS_PUB_KEY.to_owned() config::RS_PUB_KEY.to_owned()
} else { } else {
let mut args_map: HashMap<String, &str> = HashMap::new(); let mut args_map: HashMap<String, &str> = HashMap::new();
for arg in args.split('&') { for arg in args.split('&') {
@@ -1813,6 +1862,12 @@ impl LoginConfigHandler {
self.config.store(&self.id); self.config.store(&self.id);
return None; return None;
} }
#[cfg(feature = "unix-file-copy-paste")]
if option.enable_file_transfer.enum_value() == Ok(BoolOption::No) {
crate::clipboard::try_empty_clipboard_files(crate::clipboard::ClipboardSide::Client, 0);
}
if !name.contains("block-input") { if !name.contains("block-input") {
self.save_config(config); self.save_config(config);
} }
@@ -2338,6 +2393,10 @@ impl LoginConfigHandler {
}) })
.ok() .ok()
} }
pub fn get_id(&self) -> &str {
&self.id
}
} }
/// Media data. /// Media data.
@@ -3240,7 +3299,7 @@ pub enum Data {
CancelJob(i32), CancelJob(i32),
RemovePortForward(i32), RemovePortForward(i32),
AddPortForward((i32, String, i32)), AddPortForward((i32, String, i32)),
#[cfg(not(feature = "flutter"))] #[cfg(all(target_os = "windows", not(feature = "flutter")))]
ToggleClipboardFile, ToggleClipboardFile,
NewRDP, NewRDP,
SetConfirmOverrideFile((i32, i32, bool, bool, bool)), SetConfirmOverrideFile((i32, i32, bool, bool, bool)),

View File

@@ -1,13 +1,3 @@
use std::{
collections::HashMap,
ffi::c_void,
num::NonZeroI64,
sync::{
atomic::{AtomicUsize, Ordering},
Arc, RwLock,
},
};
#[cfg(not(any(target_os = "android", target_os = "ios")))] #[cfg(not(any(target_os = "android", target_os = "ios")))]
use crate::clipboard::{update_clipboard, ClipboardSide}; use crate::clipboard::{update_clipboard, ClipboardSide};
#[cfg(not(any(target_os = "ios")))] #[cfg(not(any(target_os = "ios")))]
@@ -20,7 +10,9 @@ use crate::{
common::get_default_sound_input, common::get_default_sound_input,
ui_session_interface::{InvokeUiSession, Session}, ui_session_interface::{InvokeUiSession, Session},
}; };
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] #[cfg(feature = "unix-file-copy-paste")]
use crate::{clipboard::try_empty_clipboard_files, clipboard_file::unix_file_clip};
#[cfg(target_os = "windows")]
use clipboard::ContextSend; use clipboard::ContextSend;
use crossbeam_queue::ArrayQueue; use crossbeam_queue::ArrayQueue;
#[cfg(not(target_os = "ios"))] #[cfg(not(target_os = "ios"))]
@@ -44,9 +36,18 @@ use hbb_common::{
}, },
Stream, Stream,
}; };
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] #[cfg(any(target_os = "windows", feature = "unix-file-copy-paste"))]
use hbb_common::{tokio::sync::Mutex as TokioMutex, ResultType}; use hbb_common::{tokio::sync::Mutex as TokioMutex, ResultType};
use scrap::CodecFormat; use scrap::CodecFormat;
use std::{
collections::HashMap,
ffi::c_void,
num::NonZeroI64,
sync::{
atomic::{AtomicUsize, Ordering},
Arc, RwLock,
},
};
pub struct Remote<T: InvokeUiSession> { pub struct Remote<T: InvokeUiSession> {
handler: Session<T>, handler: Session<T>,
@@ -63,7 +64,7 @@ pub struct Remote<T: InvokeUiSession> {
last_update_jobs_status: (Instant, HashMap<i32, u64>), last_update_jobs_status: (Instant, HashMap<i32, u64>),
is_connected: bool, is_connected: bool,
first_frame: bool, first_frame: bool,
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] #[cfg(any(target_os = "windows", feature = "unix-file-copy-paste"))]
client_conn_id: i32, // used for file clipboard client_conn_id: i32, // used for file clipboard
data_count: Arc<AtomicUsize>, data_count: Arc<AtomicUsize>,
video_format: CodecFormat, video_format: CodecFormat,
@@ -107,7 +108,7 @@ impl<T: InvokeUiSession> Remote<T> {
last_update_jobs_status: (Instant::now(), Default::default()), last_update_jobs_status: (Instant::now(), Default::default()),
is_connected: false, is_connected: false,
first_frame: false, first_frame: false,
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] #[cfg(any(target_os = "windows", feature = "unix-file-copy-paste"))]
client_conn_id: 0, client_conn_id: 0,
data_count: Arc::new(AtomicUsize::new(0)), data_count: Arc::new(AtomicUsize::new(0)),
video_format: CodecFormat::Unknown, video_format: CodecFormat::Unknown,
@@ -122,7 +123,7 @@ impl<T: InvokeUiSession> Remote<T> {
} }
pub async fn io_loop(&mut self, key: &str, token: &str, round: u32) { pub async fn io_loop(&mut self, key: &str, token: &str, round: u32) {
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] #[cfg(target_os = "windows")]
let _file_clip_context_holder = { let _file_clip_context_holder = {
// `is_port_forward()` will not reach here, but we still check it for clarity. // `is_port_forward()` will not reach here, but we still check it for clarity.
if !self.handler.is_file_transfer() && !self.handler.is_port_forward() { if !self.handler.is_file_transfer() && !self.handler.is_port_forward() {
@@ -175,26 +176,33 @@ impl<T: InvokeUiSession> Remote<T> {
} }
// just build for now // just build for now
#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))] #[cfg(not(any(target_os = "windows", feature = "unix-file-copy-paste")))]
let (_tx_holder, mut rx_clip_client) = mpsc::unbounded_channel::<i32>(); let (_tx_holder, mut rx_clip_client) = mpsc::unbounded_channel::<i32>();
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] #[cfg(any(target_os = "windows", feature = "unix-file-copy-paste"))]
let (_tx_holder, rx) = mpsc::unbounded_channel(); let (_tx_holder, rx) = mpsc::unbounded_channel();
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] #[cfg(any(target_os = "windows", feature = "unix-file-copy-paste"))]
let mut rx_clip_client_lock = Arc::new(TokioMutex::new(rx)); let mut rx_clip_client_holder = (Arc::new(TokioMutex::new(rx)), None);
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] #[cfg(any(target_os = "windows", feature = "unix-file-copy-paste"))]
{ {
let is_conn_not_default = self.handler.is_file_transfer() let is_conn_not_default = self.handler.is_file_transfer()
|| self.handler.is_port_forward() || self.handler.is_port_forward()
|| self.handler.is_rdp(); || self.handler.is_rdp();
if !is_conn_not_default { if !is_conn_not_default {
log::debug!("get cliprdr client for conn_id {}", self.client_conn_id); (self.client_conn_id, rx_clip_client_holder.0) =
(self.client_conn_id, rx_clip_client_lock) =
clipboard::get_rx_cliprdr_client(&self.handler.get_id()); clipboard::get_rx_cliprdr_client(&self.handler.get_id());
log::debug!("get cliprdr client for conn_id {}", self.client_conn_id);
let client_conn_id = self.client_conn_id;
rx_clip_client_holder.1 = Some(crate::SimpleCallOnReturn {
b: true,
f: Box::new(move || {
clipboard::remove_channel_by_conn_id(client_conn_id);
}),
});
}; };
} }
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] #[cfg(any(target_os = "windows", feature = "unix-file-copy-paste"))]
let mut rx_clip_client = rx_clip_client_lock.lock().await; let mut rx_clip_client = rx_clip_client_holder.0.lock().await;
let mut status_timer = let mut status_timer =
crate::rustdesk_interval(time::interval(Duration::new(1, 0))); crate::rustdesk_interval(time::interval(Duration::new(1, 0)));
@@ -242,8 +250,8 @@ impl<T: InvokeUiSession> Remote<T> {
} }
} }
_msg = rx_clip_client.recv() => { _msg = rx_clip_client.recv() => {
#[cfg(any(target_os="windows", target_os="linux", target_os = "macos"))] #[cfg(any(target_os = "windows", feature = "unix-file-copy-paste"))]
self.handle_local_clipboard_msg(&mut peer, _msg).await; self.handle_local_clipboard_msg(&mut peer, _msg).await;
} }
_ = self.timer.tick() => { _ = self.timer.tick() => {
if last_recv_time.elapsed() >= SEC30 { if last_recv_time.elapsed() >= SEC30 {
@@ -323,18 +331,13 @@ impl<T: InvokeUiSession> Remote<T> {
Client::try_stop_clipboard(); Client::try_stop_clipboard();
} }
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] #[cfg(any(target_os = "windows", feature = "unix-file-copy-paste"))]
if _set_disconnected_ok { if _set_disconnected_ok {
let conn_id = self.client_conn_id; crate::clipboard::try_empty_clipboard_files(ClipboardSide::Client, self.client_conn_id);
log::debug!("try empty cliprdr for conn_id {}", conn_id);
let _ = ContextSend::proc(|context| -> ResultType<()> {
context.empty_clipboard(conn_id)?;
Ok(())
});
} }
} }
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] #[cfg(any(target_os = "windows", feature = "unix-file-copy-paste"))]
async fn handle_local_clipboard_msg( async fn handle_local_clipboard_msg(
&self, &self,
peer: &mut crate::client::FramedStream, peer: &mut crate::client::FramedStream,
@@ -365,8 +368,12 @@ impl<T: InvokeUiSession> Remote<T> {
view_only, stop, is_stopping_allowed, server_file_transfer_enabled, file_transfer_enabled view_only, stop, is_stopping_allowed, server_file_transfer_enabled, file_transfer_enabled
); );
if stop { if stop {
ContextSend::set_is_stopped(); #[cfg(target_os = "windows")]
{
ContextSend::set_is_stopped();
}
} else { } else {
#[cfg(target_os = "windows")]
if let Err(e) = ContextSend::make_sure_enabled() { if let Err(e) = ContextSend::make_sure_enabled() {
log::error!("failed to restart clipboard context: {}", e); log::error!("failed to restart clipboard context: {}", e);
// to-do: Show msgbox with "Don't show again" option // to-do: Show msgbox with "Don't show again" option
@@ -509,7 +516,7 @@ impl<T: InvokeUiSession> Remote<T> {
.handle_login_from_ui(os_username, os_password, password, remember, peer) .handle_login_from_ui(os_username, os_password, password, remember, peer)
.await; .await;
} }
#[cfg(not(feature = "flutter"))] #[cfg(all(target_os = "windows", not(feature = "flutter")))]
Data::ToggleClipboardFile => { Data::ToggleClipboardFile => {
self.check_clipboard_file_context(); self.check_clipboard_file_context();
} }
@@ -1221,7 +1228,7 @@ impl<T: InvokeUiSession> Remote<T> {
let peer_platform = pi.platform.clone(); let peer_platform = pi.platform.clone();
self.set_peer_info(&pi); self.set_peer_info(&pi);
self.handler.handle_peer_info(pi); self.handler.handle_peer_info(pi);
#[cfg(not(feature = "flutter"))] #[cfg(all(target_os = "windows", not(feature = "flutter")))]
self.check_clipboard_file_context(); self.check_clipboard_file_context();
if !(self.handler.is_file_transfer() || self.handler.is_port_forward()) { if !(self.handler.is_file_transfer() || self.handler.is_port_forward()) {
#[cfg(feature = "flutter")] #[cfg(feature = "flutter")]
@@ -1233,6 +1240,10 @@ impl<T: InvokeUiSession> Remote<T> {
crate::client::ClientClipboardContext { crate::client::ClientClipboardContext {
cfg: self.handler.get_permission_config(), cfg: self.handler.get_permission_config(),
tx: self.sender.clone(), tx: self.sender.clone(),
#[cfg(feature = "unix-file-copy-paste")]
is_file_supported: crate::is_support_file_copy_paste(
&peer_version,
),
}, },
)); ));
// To make sure current text clipboard data is updated. // To make sure current text clipboard data is updated.
@@ -1264,6 +1275,9 @@ impl<T: InvokeUiSession> Remote<T> {
#[cfg(not(target_os = "ios"))] #[cfg(not(target_os = "ios"))]
crate::flutter::update_text_clipboard_required(); crate::flutter::update_text_clipboard_required();
#[cfg(all(feature = "flutter", feature = "unix-file-copy-paste"))]
crate::flutter::update_file_clipboard_required();
// on connection established client // on connection established client
#[cfg(all(feature = "flutter", feature = "plugin_framework"))] #[cfg(all(feature = "flutter", feature = "plugin_framework"))]
#[cfg(not(any(target_os = "android", target_os = "ios")))] #[cfg(not(any(target_os = "android", target_os = "ios")))]
@@ -1317,9 +1331,9 @@ impl<T: InvokeUiSession> Remote<T> {
crate::clipboard::handle_msg_multi_clipboards(_mcb); crate::clipboard::handle_msg_multi_clipboards(_mcb);
} }
} }
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] #[cfg(any(target_os = "windows", feature = "unix-file-copy-paste"))]
Some(message::Union::Cliprdr(clip)) => { Some(message::Union::Cliprdr(clip)) => {
self.handle_cliprdr_msg(clip); self.handle_cliprdr_msg(clip, peer).await;
} }
Some(message::Union::FileResponse(fr)) => { Some(message::Union::FileResponse(fr)) => {
match fr.union { match fr.union {
@@ -1484,6 +1498,8 @@ impl<T: InvokeUiSession> Remote<T> {
#[cfg(feature = "flutter")] #[cfg(feature = "flutter")]
#[cfg(not(target_os = "ios"))] #[cfg(not(target_os = "ios"))]
crate::flutter::update_text_clipboard_required(); crate::flutter::update_text_clipboard_required();
#[cfg(all(feature = "flutter", feature = "unix-file-copy-paste"))]
crate::flutter::update_file_clipboard_required();
self.handler.set_permission("keyboard", p.enabled); self.handler.set_permission("keyboard", p.enabled);
} }
Ok(Permission::Clipboard) => { Ok(Permission::Clipboard) => {
@@ -1502,7 +1518,16 @@ impl<T: InvokeUiSession> Remote<T> {
if !p.enabled && self.handler.is_file_transfer() { if !p.enabled && self.handler.is_file_transfer() {
return true; return true;
} }
#[cfg(all(feature = "flutter", feature = "unix-file-copy-paste"))]
crate::flutter::update_file_clipboard_required();
self.handler.set_permission("file", p.enabled); self.handler.set_permission("file", p.enabled);
#[cfg(feature = "unix-file-copy-paste")]
if !p.enabled {
try_empty_clipboard_files(
ClipboardSide::Client,
self.client_conn_id,
);
}
} }
Ok(Permission::Restart) => { Ok(Permission::Restart) => {
self.handler.set_permission("restart", p.enabled); self.handler.set_permission("restart", p.enabled);
@@ -1922,24 +1947,19 @@ impl<T: InvokeUiSession> Remote<T> {
true true
} }
#[cfg(not(feature = "flutter"))] #[cfg(all(target_os = "windows", not(feature = "flutter")))]
fn check_clipboard_file_context(&self) { fn check_clipboard_file_context(&self) {
#[cfg(any( let enabled = *self.handler.server_file_transfer_enabled.read().unwrap()
target_os = "windows", && self.handler.lc.read().unwrap().enable_file_copy_paste.v;
all( ContextSend::enable(enabled);
feature = "unix-file-copy-paste",
any(target_os = "linux", target_os = "macos")
)
))]
{
let enabled = *self.handler.server_file_transfer_enabled.read().unwrap()
&& self.handler.lc.read().unwrap().enable_file_copy_paste.v;
ContextSend::enable(enabled);
}
} }
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] #[cfg(any(target_os = "windows", feature = "unix-file-copy-paste"))]
fn handle_cliprdr_msg(&self, clip: hbb_common::message_proto::Cliprdr) { async fn handle_cliprdr_msg(
&self,
clip: hbb_common::message_proto::Cliprdr,
_peer: &mut Stream,
) {
log::debug!("handling cliprdr msg from server peer"); log::debug!("handling cliprdr msg from server peer");
#[cfg(feature = "flutter")] #[cfg(feature = "flutter")]
if let Some(hbb_common::message_proto::cliprdr::Union::FormatList(_)) = &clip.union { if let Some(hbb_common::message_proto::cliprdr::Union::FormatList(_)) = &clip.union {
@@ -1956,20 +1976,34 @@ impl<T: InvokeUiSession> Remote<T> {
}; };
let is_stopping_allowed = clip.is_beginning_message(); let is_stopping_allowed = clip.is_beginning_message();
let file_transfer_enabled = self.handler.lc.read().unwrap().enable_file_copy_paste.v; let file_transfer_enabled = self.handler.is_file_clipboard_required();
let stop = is_stopping_allowed && !file_transfer_enabled; let stop = is_stopping_allowed && !file_transfer_enabled;
log::debug!( log::debug!(
"Process clipboard message from server peer, stop: {}, is_stopping_allowed: {}, file_transfer_enabled: {}", "Process clipboard message from server peer, stop: {}, is_stopping_allowed: {}, file_transfer_enabled: {}",
stop, is_stopping_allowed, file_transfer_enabled); stop, is_stopping_allowed, file_transfer_enabled);
if !stop { if !stop {
#[cfg(target_os = "windows")]
if let Err(e) = ContextSend::make_sure_enabled() { if let Err(e) = ContextSend::make_sure_enabled() {
log::error!("failed to restart clipboard context: {}", e); log::error!("failed to restart clipboard context: {}", e);
}; };
let _ = ContextSend::proc(|context| -> ResultType<()> { #[cfg(target_os = "windows")]
context {
.server_clip_file(self.client_conn_id, clip) let _ = ContextSend::proc(|context| -> ResultType<()> {
.map_err(|e| e.into()) context
}); .server_clip_file(self.client_conn_id, clip)
.map_err(|e| e.into())
});
}
#[cfg(feature = "unix-file-copy-paste")]
if crate::is_support_file_copy_paste_num(self.handler.lc.read().unwrap().version) {
if let Some(msg) = unix_file_clip::serve_clip_messages(
ClipboardSide::Client,
clip,
self.client_conn_id,
) {
allow_err!(_peer.send(&msg).await);
}
}
} }
} }

View File

@@ -1,15 +1,14 @@
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
use arboard::{ClipboardData, ClipboardFormat}; use arboard::{ClipboardData, ClipboardFormat};
#[cfg(not(target_os = "android"))]
use clipboard_master::{ClipboardHandler, Master, Shutdown};
use hbb_common::{bail, log, message_proto::*, ResultType}; use hbb_common::{bail, log, message_proto::*, ResultType};
use std::{ use std::{
sync::{mpsc::Sender, Arc, Mutex}, sync::{Arc, Mutex},
thread::JoinHandle,
time::Duration, time::Duration,
}; };
pub const CLIPBOARD_NAME: &'static str = "clipboard"; pub const CLIPBOARD_NAME: &'static str = "clipboard";
#[cfg(feature = "unix-file-copy-paste")]
pub const FILE_CLIPBOARD_NAME: &'static str = "file-clipboard";
pub const CLIPBOARD_INTERVAL: u64 = 333; pub const CLIPBOARD_INTERVAL: u64 = 333;
// This format is used to store the flag in the clipboard. // This format is used to store the flag in the clipboard.
@@ -43,115 +42,12 @@ const SUPPORTED_FORMATS: &[ClipboardFormat] = &[
ClipboardFormat::ImageRgba, ClipboardFormat::ImageRgba,
ClipboardFormat::ImagePng, ClipboardFormat::ImagePng,
ClipboardFormat::ImageSvg, ClipboardFormat::ImageSvg,
#[cfg(feature = "unix-file-copy-paste")]
ClipboardFormat::FileUrl,
ClipboardFormat::Special(CLIPBOARD_FORMAT_EXCEL_XML_SPREADSHEET), ClipboardFormat::Special(CLIPBOARD_FORMAT_EXCEL_XML_SPREADSHEET),
ClipboardFormat::Special(RUSTDESK_CLIPBOARD_OWNER_FORMAT), ClipboardFormat::Special(RUSTDESK_CLIPBOARD_OWNER_FORMAT),
]; ];
#[cfg(all(target_os = "linux", feature = "unix-file-copy-paste"))]
static X11_CLIPBOARD: once_cell::sync::OnceCell<x11_clipboard::Clipboard> =
once_cell::sync::OnceCell::new();
#[cfg(all(target_os = "linux", feature = "unix-file-copy-paste"))]
fn get_clipboard() -> Result<&'static x11_clipboard::Clipboard, String> {
X11_CLIPBOARD
.get_or_try_init(|| x11_clipboard::Clipboard::new())
.map_err(|e| e.to_string())
}
#[cfg(all(target_os = "linux", feature = "unix-file-copy-paste"))]
pub struct ClipboardContext {
string_setter: x11rb::protocol::xproto::Atom,
string_getter: x11rb::protocol::xproto::Atom,
text_uri_list: x11rb::protocol::xproto::Atom,
clip: x11rb::protocol::xproto::Atom,
prop: x11rb::protocol::xproto::Atom,
}
#[cfg(all(target_os = "linux", feature = "unix-file-copy-paste"))]
fn parse_plain_uri_list(v: Vec<u8>) -> Result<String, String> {
let text = String::from_utf8(v).map_err(|_| "ConversionFailure".to_owned())?;
let mut list = String::new();
for line in text.lines() {
if !line.starts_with("file://") {
continue;
}
let decoded = percent_encoding::percent_decode_str(line)
.decode_utf8()
.map_err(|_| "ConversionFailure".to_owned())?;
list = list + "\n" + decoded.trim_start_matches("file://");
}
list = list.trim().to_owned();
Ok(list)
}
#[cfg(all(target_os = "linux", feature = "unix-file-copy-paste"))]
impl ClipboardContext {
pub fn new() -> Result<Self, String> {
let clipboard = get_clipboard()?;
let string_getter = clipboard
.getter
.get_atom("UTF8_STRING")
.map_err(|e| e.to_string())?;
let string_setter = clipboard
.setter
.get_atom("UTF8_STRING")
.map_err(|e| e.to_string())?;
let text_uri_list = clipboard
.getter
.get_atom("text/uri-list")
.map_err(|e| e.to_string())?;
let prop = clipboard.getter.atoms.property;
let clip = clipboard.getter.atoms.clipboard;
Ok(Self {
text_uri_list,
string_setter,
string_getter,
clip,
prop,
})
}
pub fn get_text(&mut self) -> Result<String, String> {
let clip = self.clip;
let prop = self.prop;
const TIMEOUT: std::time::Duration = std::time::Duration::from_millis(120);
let text_content = get_clipboard()?
.load(clip, self.string_getter, prop, TIMEOUT)
.map_err(|e| e.to_string())?;
let file_urls = get_clipboard()?.load(clip, self.text_uri_list, prop, TIMEOUT)?;
if file_urls.is_err() || file_urls.as_ref().is_empty() {
log::trace!("clipboard get text, no file urls");
return String::from_utf8(text_content).map_err(|e| e.to_string());
}
let file_urls = parse_plain_uri_list(file_urls)?;
let text_content = String::from_utf8(text_content).map_err(|e| e.to_string())?;
if text_content.trim() == file_urls.trim() {
log::trace!("clipboard got text but polluted");
return Err(String::from("polluted text"));
}
Ok(text_content)
}
pub fn set_text(&mut self, content: String) -> Result<(), String> {
let clip = self.clip;
let value = content.clone().into_bytes();
get_clipboard()?
.store(clip, self.string_setter, value)
.map_err(|e| e.to_string())?;
Ok(())
}
}
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
pub fn check_clipboard( pub fn check_clipboard(
ctx: &mut Option<ClipboardContext>, ctx: &mut Option<ClipboardContext>,
@@ -179,6 +75,73 @@ pub fn check_clipboard(
None None
} }
#[cfg(feature = "unix-file-copy-paste")]
pub fn check_clipboard_files(
ctx: &mut Option<ClipboardContext>,
side: ClipboardSide,
force: bool,
) -> Option<Vec<String>> {
if ctx.is_none() {
*ctx = ClipboardContext::new().ok();
}
let ctx2 = ctx.as_mut()?;
match ctx2.get_files(side, force) {
Ok(Some(urls)) => {
if !urls.is_empty() {
return Some(urls);
}
}
Err(e) => {
log::error!("Failed to get clipboard file urls. {}", e);
}
_ => {}
}
None
}
#[cfg(feature = "unix-file-copy-paste")]
pub fn update_clipboard_files(files: Vec<String>, side: ClipboardSide) {
if !files.is_empty() {
std::thread::spawn(move || {
do_update_clipboard_(vec![ClipboardData::FileUrl(files)], side);
});
}
}
#[cfg(feature = "unix-file-copy-paste")]
pub fn try_empty_clipboard_files(_side: ClipboardSide, _conn_id: i32) {
#[cfg(target_os = "linux")]
std::thread::spawn(move || {
let mut ctx = CLIPBOARD_CTX.lock().unwrap();
if ctx.is_none() {
match ClipboardContext::new() {
Ok(x) => {
*ctx = Some(x);
}
Err(e) => {
log::error!("Failed to create clipboard context: {}", e);
return;
}
}
}
if let Some(mut ctx) = ctx.as_mut() {
use clipboard::platform::unix;
if unix::fuse::empty_local_files(_side == ClipboardSide::Client, _conn_id) {
ctx.try_empty_clipboard_files(_side);
}
}
});
}
#[cfg(target_os = "windows")]
pub fn try_empty_clipboard_files(side: ClipboardSide, conn_id: i32) {
log::debug!("try to empty {} cliprdr for conn_id {}", side, conn_id);
let _ = clipboard::ContextSend::proc(|context| -> ResultType<()> {
context.empty_clipboard(conn_id)?;
Ok(())
});
}
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
pub fn check_clipboard_cm() -> ResultType<MultiClipboards> { pub fn check_clipboard_cm() -> ResultType<MultiClipboards> {
let mut ctx = CLIPBOARD_CTX.lock().unwrap(); let mut ctx = CLIPBOARD_CTX.lock().unwrap();
@@ -203,10 +166,15 @@ pub fn check_clipboard_cm() -> ResultType<MultiClipboards> {
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
fn update_clipboard_(multi_clipboards: Vec<Clipboard>, side: ClipboardSide) { fn update_clipboard_(multi_clipboards: Vec<Clipboard>, side: ClipboardSide) {
let mut to_update_data = proto::from_multi_clipbards(multi_clipboards); let to_update_data = proto::from_multi_clipbards(multi_clipboards);
if to_update_data.is_empty() { if to_update_data.is_empty() {
return; return;
} }
do_update_clipboard_(to_update_data, side);
}
#[cfg(not(target_os = "android"))]
fn do_update_clipboard_(mut to_update_data: Vec<ClipboardData>, side: ClipboardSide) {
let mut ctx = CLIPBOARD_CTX.lock().unwrap(); let mut ctx = CLIPBOARD_CTX.lock().unwrap();
if ctx.is_none() { if ctx.is_none() {
match ClipboardContext::new() { match ClipboardContext::new() {
@@ -240,13 +208,11 @@ pub fn update_clipboard(multi_clipboards: Vec<Clipboard>, side: ClipboardSide) {
} }
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
#[cfg(not(any(all(target_os = "linux", feature = "unix-file-copy-paste"))))]
pub struct ClipboardContext { pub struct ClipboardContext {
inner: arboard::Clipboard, inner: arboard::Clipboard,
} }
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
#[cfg(not(any(all(target_os = "linux", feature = "unix-file-copy-paste"))))]
#[allow(unreachable_code)] #[allow(unreachable_code)]
impl ClipboardContext { impl ClipboardContext {
pub fn new() -> ResultType<ClipboardContext> { pub fn new() -> ResultType<ClipboardContext> {
@@ -293,7 +259,7 @@ impl ClipboardContext {
// https://github.com/rustdesk/rustdesk/issues/9263 // https://github.com/rustdesk/rustdesk/issues/9263
// https://github.com/rustdesk/rustdesk/issues/9222#issuecomment-2329233175 // https://github.com/rustdesk/rustdesk/issues/9222#issuecomment-2329233175
for i in 0..CLIPBOARD_GET_MAX_RETRY { for i in 0..CLIPBOARD_GET_MAX_RETRY {
match self.inner.get_formats(SUPPORTED_FORMATS) { match self.inner.get_formats(formats) {
Ok(data) => { Ok(data) => {
return Ok(data return Ok(data
.into_iter() .into_iter()
@@ -316,8 +282,26 @@ impl ClipboardContext {
} }
pub fn get(&mut self, side: ClipboardSide, force: bool) -> ResultType<Vec<ClipboardData>> { pub fn get(&mut self, side: ClipboardSide, force: bool) -> ResultType<Vec<ClipboardData>> {
let data = self.get_formats_filter(SUPPORTED_FORMATS, side, force)?;
// We have a seperate service named `file-clipboard` to handle file copy-paste.
// We need to read the file urls because file copy may set the other clipboard formats such as text.
#[cfg(feature = "unix-file-copy-paste")]
{
if data.iter().any(|c| matches!(c, ClipboardData::FileUrl(_))) {
return Ok(vec![]);
}
}
Ok(data)
}
fn get_formats_filter(
&mut self,
formats: &[ClipboardFormat],
side: ClipboardSide,
force: bool,
) -> ResultType<Vec<ClipboardData>> {
let _lock = ARBOARD_MTX.lock().unwrap(); let _lock = ARBOARD_MTX.lock().unwrap();
let data = self.get_formats(SUPPORTED_FORMATS)?; let data = self.get_formats(formats)?;
if data.is_empty() { if data.is_empty() {
return Ok(data); return Ok(data);
} }
@@ -334,16 +318,98 @@ impl ClipboardContext {
.into_iter() .into_iter()
.filter(|c| match c { .filter(|c| match c {
ClipboardData::Special((s, _)) => s != RUSTDESK_CLIPBOARD_OWNER_FORMAT, ClipboardData::Special((s, _)) => s != RUSTDESK_CLIPBOARD_OWNER_FORMAT,
// Skip synchronizing empty text to the remote clipboard
ClipboardData::Text(text) => !text.is_empty(),
_ => true, _ => true,
}) })
.collect()) .collect())
} }
#[cfg(feature = "unix-file-copy-paste")]
pub fn get_files(
&mut self,
side: ClipboardSide,
force: bool,
) -> ResultType<Option<Vec<String>>> {
let data = self.get_formats_filter(
&[
ClipboardFormat::FileUrl,
ClipboardFormat::Special(RUSTDESK_CLIPBOARD_OWNER_FORMAT),
],
side,
force,
)?;
Ok(data.into_iter().find_map(|c| match c {
ClipboardData::FileUrl(urls) => Some(urls),
_ => None,
}))
}
fn set(&mut self, data: &[ClipboardData]) -> ResultType<()> { fn set(&mut self, data: &[ClipboardData]) -> ResultType<()> {
let _lock = ARBOARD_MTX.lock().unwrap(); let _lock = ARBOARD_MTX.lock().unwrap();
self.inner.set_formats(data)?; self.inner.set_formats(data)?;
Ok(()) Ok(())
} }
#[cfg(feature = "unix-file-copy-paste")]
fn try_empty_clipboard_files(&mut self, side: ClipboardSide) {
let _lock = ARBOARD_MTX.lock().unwrap();
if let Ok(data) = self.get_formats(&[ClipboardFormat::FileUrl]) {
#[cfg(target_os = "linux")]
let exclude_path =
clipboard::platform::unix::fuse::get_exclude_paths(side == ClipboardSide::Client);
#[cfg(target_os = "macos")]
let exclude_path: Arc<String> = Default::default();
let urls = data
.into_iter()
.filter_map(|c| match c {
ClipboardData::FileUrl(urls) => Some(
urls.into_iter()
.filter(|s| s.starts_with(&*exclude_path))
.collect::<Vec<_>>(),
),
_ => None,
})
.flatten()
.collect::<Vec<_>>();
if !urls.is_empty() {
// FIXME:
// The host-side clear file clipboard `let _ = self.inner.clear();`,
// does not work on KDE Plasma for the installed version.
// 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 = {
let is_kde = std::process::Command::new("sh")
.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()
};
#[cfg(target_os = "macos")]
let is_kde_x11 = false;
let clear_holder_text = if is_kde_x11 {
"RustDesk placeholder to clear the file clipbard"
} else {
""
}
.to_string();
self.inner
.set_formats(&[
ClipboardData::Text(clear_holder_text),
ClipboardData::Special((
RUSTDESK_CLIPBOARD_OWNER_FORMAT.to_owned(),
side.get_owner_data(),
)),
])
.ok();
}
}
}
} }
pub fn is_support_multi_clipboard(peer_version: &str, peer_platform: &str) -> bool { pub fn is_support_multi_clipboard(peer_version: &str, peer_platform: &str) -> bool {
@@ -427,36 +493,6 @@ impl std::fmt::Display for ClipboardSide {
} }
} }
#[cfg(not(target_os = "android"))]
pub fn start_clipbard_master_thread(
handler: impl ClipboardHandler + Send + 'static,
tx_start_res: Sender<(Option<Shutdown>, String)>,
) -> JoinHandle<()> {
// https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getmessage#:~:text=The%20window%20must%20belong%20to%20the%20current%20thread.
let h = std::thread::spawn(move || match Master::new(handler) {
Ok(mut master) => {
tx_start_res
.send((Some(master.shutdown_channel()), "".to_owned()))
.ok();
log::debug!("Clipboard listener started");
if let Err(err) = master.run() {
log::error!("Failed to run clipboard listener: {}", err);
} else {
log::debug!("Clipboard listener stopped");
}
}
Err(err) => {
tx_start_res
.send((
None,
format!("Failed to create clipboard listener: {}", err),
))
.ok();
}
});
h
}
pub use proto::get_msg_if_not_support_multi_clip; pub use proto::get_msg_if_not_support_multi_clip;
mod proto { mod proto {
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
@@ -671,3 +707,140 @@ pub fn get_clipboards_msg(client: bool) -> Option<Message> {
msg.set_multi_clipboards(clipboards); msg.set_multi_clipboards(clipboards);
Some(msg) Some(msg)
} }
// We need this mod to notify multiple subscribers when the clipboard changes.
// Because only one clipboard master(listener) can tigger the clipboard change event multiple listeners are created on Linux(x11).
// https://github.com/rustdesk-org/clipboard-master/blob/4fb62e5b62fb6350d82b571ec7ba94b3cd466695/src/master/x11.rs#L226
#[cfg(not(target_os = "android"))]
pub mod clipboard_listener {
use clipboard_master::{CallbackResult, ClipboardHandler, Master, Shutdown};
use hbb_common::{bail, log, ResultType};
use std::{
collections::HashMap,
io,
sync::mpsc::{channel, Sender},
sync::{Arc, Mutex},
thread::JoinHandle,
};
lazy_static::lazy_static! {
pub static ref CLIPBOARD_LISTENER: Arc<Mutex<ClipboardListener>> = Default::default();
}
struct Handler {
subscribers: Arc<Mutex<HashMap<String, Sender<CallbackResult>>>>,
}
impl ClipboardHandler for Handler {
fn on_clipboard_change(&mut self) -> CallbackResult {
let sub_lock = self.subscribers.lock().unwrap();
for tx in sub_lock.values() {
tx.send(CallbackResult::Next).ok();
}
CallbackResult::Next
}
fn on_clipboard_error(&mut self, error: io::Error) -> CallbackResult {
let msg = format!("Clipboard listener error: {}", error);
let sub_lock = self.subscribers.lock().unwrap();
for tx in sub_lock.values() {
tx.send(CallbackResult::StopWithError(io::Error::new(
io::ErrorKind::Other,
msg.clone(),
)))
.ok();
}
CallbackResult::Next
}
}
#[derive(Default)]
pub struct ClipboardListener {
subscribers: Arc<Mutex<HashMap<String, Sender<CallbackResult>>>>,
handle: Option<(Shutdown, JoinHandle<()>)>,
}
pub fn subscribe(name: String, tx: Sender<CallbackResult>) -> ResultType<()> {
log::info!("Subscribe clipboard listener: {}", &name);
let mut listener_lock = CLIPBOARD_LISTENER.lock().unwrap();
listener_lock
.subscribers
.lock()
.unwrap()
.insert(name.clone(), tx);
if listener_lock.handle.is_none() {
log::info!("Start clipboard listener thread");
let handler = Handler {
subscribers: listener_lock.subscribers.clone(),
};
let (tx_start_res, rx_start_res) = channel();
let h = start_clipbard_master_thread(handler, tx_start_res);
let shutdown = match rx_start_res.recv() {
Ok((Some(s), _)) => s,
Ok((None, err)) => {
bail!(err);
}
Err(e) => {
bail!("Failed to create clipboard listener: {}", e);
}
};
listener_lock.handle = Some((shutdown, h));
log::info!("Clipboard listener thread started");
}
log::info!("Clipboard listener subscribed: {}", name);
Ok(())
}
pub fn unsubscribe(name: &str) {
log::info!("Unsubscribe clipboard listener: {}", name);
let mut listener_lock = CLIPBOARD_LISTENER.lock().unwrap();
let is_empty = {
let mut sub_lock = listener_lock.subscribers.lock().unwrap();
if let Some(tx) = sub_lock.remove(name) {
tx.send(CallbackResult::Stop).ok();
}
sub_lock.is_empty()
};
if is_empty {
if let Some((shutdown, h)) = listener_lock.handle.take() {
log::info!("Stop clipboard listener thread");
shutdown.signal();
h.join().ok();
log::info!("Clipboard listener thread stopped");
}
}
log::info!("Clipboard listener unsubscribed: {}", name);
}
fn start_clipbard_master_thread(
handler: impl ClipboardHandler + Send + 'static,
tx_start_res: Sender<(Option<Shutdown>, String)>,
) -> JoinHandle<()> {
// https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getmessage#:~:text=The%20window%20must%20belong%20to%20the%20current%20thread.
let h = std::thread::spawn(move || match Master::new(handler) {
Ok(mut master) => {
tx_start_res
.send((Some(master.shutdown_channel()), "".to_owned()))
.ok();
log::debug!("Clipboard listener started");
if let Err(err) = master.run() {
log::error!("Failed to run clipboard listener: {}", err);
} else {
log::debug!("Clipboard listener stopped");
}
}
Err(err) => {
tx_start_res
.send((
None,
format!("Failed to create clipboard listener: {}", err),
))
.ok();
}
});
h
}
}

View File

@@ -134,6 +134,15 @@ pub fn clip_2_msg(clip: ClipboardFile) -> Message {
})), })),
..Default::default() ..Default::default()
}, },
ClipboardFile::TryEmpty => Message {
union: Some(message::Union::Cliprdr(Cliprdr {
union: Some(cliprdr::Union::TryEmpty(CliprdrTryEmpty {
..Default::default()
})),
..Default::default()
})),
..Default::default()
},
} }
} }
@@ -176,6 +185,210 @@ pub fn msg_2_clip(msg: Cliprdr) -> Option<ClipboardFile> {
requested_data: data.requested_data.into(), requested_data: data.requested_data.into(),
}) })
} }
Some(cliprdr::Union::TryEmpty(_)) => Some(ClipboardFile::TryEmpty),
_ => None, _ => None,
} }
} }
#[cfg(feature = "unix-file-copy-paste")]
pub mod unix_file_clip {
use crate::clipboard::try_empty_clipboard_files;
use super::{
super::clipboard::{update_clipboard_files, ClipboardSide},
*,
};
#[cfg(target_os = "linux")]
use clipboard::platform::unix::fuse;
use clipboard::platform::unix::{
get_local_format, serv_files, FILECONTENTS_FORMAT_ID, FILECONTENTS_FORMAT_NAME,
FILEDESCRIPTORW_FORMAT_NAME, FILEDESCRIPTOR_FORMAT_ID,
};
use hbb_common::log;
use std::sync::{Arc, Mutex};
lazy_static::lazy_static! {
static ref CLIPBOARD_CTX: Arc<Mutex<Option<crate::clipboard::ClipboardContext>>> = Arc::new(Mutex::new(None));
}
pub fn get_format_list() -> ClipboardFile {
let fd_format_name = get_local_format(FILEDESCRIPTOR_FORMAT_ID)
.unwrap_or(FILEDESCRIPTORW_FORMAT_NAME.to_string());
let fc_format_name = get_local_format(FILECONTENTS_FORMAT_ID)
.unwrap_or(FILECONTENTS_FORMAT_NAME.to_string());
ClipboardFile::FormatList {
format_list: vec![
(FILEDESCRIPTOR_FORMAT_ID, fd_format_name),
(FILECONTENTS_FORMAT_ID, fc_format_name),
],
}
}
#[inline]
fn msg_resp_format_data_failure() -> Message {
clip_2_msg(ClipboardFile::FormatDataResponse {
msg_flags: 0x2,
format_data: vec![],
})
}
#[inline]
fn resp_file_contents_fail(stream_id: i32) -> Message {
clip_2_msg(ClipboardFile::FileContentsResponse {
msg_flags: 0x2,
stream_id,
requested_data: vec![],
})
}
pub fn serve_clip_messages(
side: ClipboardSide,
clip: ClipboardFile,
conn_id: i32,
) -> Option<Message> {
log::debug!("got clipfile from client peer");
match clip {
ClipboardFile::MonitorReady => {
log::debug!("client is ready for clipboard");
}
ClipboardFile::FormatList { format_list } => {
if !format_list
.iter()
.find(|(_, name)| name == FILECONTENTS_FORMAT_NAME)
.map(|(id, _)| *id)
.is_some()
{
log::error!("no file contents format found");
return None;
};
let Some(file_descriptor_id) = format_list
.iter()
.find(|(_, name)| name == FILEDESCRIPTORW_FORMAT_NAME)
.map(|(id, _)| *id)
else {
log::error!("no file descriptor format found");
return None;
};
// sync file system from peer
let data = ClipboardFile::FormatDataRequest {
requested_format_id: file_descriptor_id,
};
return Some(clip_2_msg(data));
}
ClipboardFile::FormatListResponse {
msg_flags: _msg_flags,
} => {}
ClipboardFile::FormatDataRequest {
requested_format_id: _requested_format_id,
} => {
log::debug!("requested format id: {}", _requested_format_id);
let format_data = serv_files::get_file_list_pdu();
if !format_data.is_empty() {
return Some(clip_2_msg(ClipboardFile::FormatDataResponse {
msg_flags: 1,
format_data,
}));
}
// empty file list, send failure message
return Some(msg_resp_format_data_failure());
}
#[cfg(target_os = "linux")]
ClipboardFile::FormatDataResponse {
msg_flags,
format_data,
} => {
log::debug!("format data response: msg_flags: {}", msg_flags);
if msg_flags != 0x1 {
// return failure message?
}
log::debug!("parsing file descriptors");
if fuse::init_fuse_context(true).is_ok() {
match fuse::format_data_response_to_urls(
side == ClipboardSide::Client,
format_data,
conn_id,
) {
Ok(files) => {
update_clipboard_files(files, side);
}
Err(e) => {
log::error!("failed to parse file descriptors: {:?}", e);
}
}
} else {
// send error message to server
}
}
ClipboardFile::FileContentsRequest {
stream_id,
list_index,
dw_flags,
n_position_low,
n_position_high,
cb_requested,
..
} => {
log::debug!("file contents request: stream_id: {}, list_index: {}, dw_flags: {}, n_position_low: {}, n_position_high: {}, cb_requested: {}", stream_id, list_index, dw_flags, n_position_low, n_position_high, cb_requested);
match serv_files::read_file_contents(
conn_id,
stream_id,
list_index,
dw_flags,
n_position_low,
n_position_high,
cb_requested,
) {
Ok(data) => {
return Some(clip_2_msg(data));
}
Err(e) => {
log::error!("failed to read file contents: {:?}", e);
return Some(resp_file_contents_fail(stream_id));
}
}
}
#[cfg(target_os = "linux")]
ClipboardFile::FileContentsResponse {
msg_flags,
stream_id,
..
} => {
log::debug!(
"file contents response: msg_flags: {}, stream_id: {}",
msg_flags,
stream_id,
);
if fuse::init_fuse_context(true).is_ok() {
hbb_common::allow_err!(fuse::handle_file_content_response(
side == ClipboardSide::Client,
clip
));
} else {
// send error message to server
}
}
ClipboardFile::NotifyCallback {
r#type,
title,
text,
} => {
// unreachable, but still log it
log::debug!(
"notify callback: type: {}, title: {}, text: {}",
r#type,
title,
text
);
}
ClipboardFile::TryEmpty => {
try_empty_clipboard_files(side, conn_id);
}
_ => {
log::error!("unsupported clipboard file type");
}
}
None
}
}

View File

@@ -89,7 +89,7 @@ lazy_static::lazy_static! {
pub struct SimpleCallOnReturn { pub struct SimpleCallOnReturn {
pub b: bool, pub b: bool,
pub f: Box<dyn Fn() + 'static>, pub f: Box<dyn Fn() + Send + 'static>,
} }
impl Drop for SimpleCallOnReturn { impl Drop for SimpleCallOnReturn {
@@ -127,6 +127,18 @@ pub fn is_support_multi_ui_session_num(ver: i64) -> bool {
ver >= hbb_common::get_version_number(MIN_VER_MULTI_UI_SESSION) ver >= hbb_common::get_version_number(MIN_VER_MULTI_UI_SESSION)
} }
#[inline]
#[cfg(feature = "unix-file-copy-paste")]
pub fn is_support_file_copy_paste(ver: &str) -> bool {
is_support_file_copy_paste_num(hbb_common::get_version_number(ver))
}
#[inline]
#[cfg(feature = "unix-file-copy-paste")]
pub fn is_support_file_copy_paste_num(ver: i64) -> bool {
ver >= hbb_common::get_version_number("1.3.8")
}
// is server process, with "--server" args // is server process, with "--server" args
#[inline] #[inline]
pub fn is_server() -> bool { pub fn is_server() -> bool {
@@ -751,7 +763,6 @@ pub fn get_sysinfo() -> serde_json::Value {
os = format!("{os} - {}", system.os_version().unwrap_or_default()); os = format!("{os} - {}", system.os_version().unwrap_or_default());
} }
let hostname = hostname(); // sys.hostname() return localhost on android in my test let hostname = hostname(); // sys.hostname() return localhost on android in my test
use serde_json::json;
#[cfg(any(target_os = "android", target_os = "ios"))] #[cfg(any(target_os = "android", target_os = "ios"))]
let out; let out;
#[cfg(not(any(target_os = "android", target_os = "ios")))] #[cfg(not(any(target_os = "android", target_os = "ios")))]
@@ -887,7 +898,16 @@ pub fn get_custom_rendezvous_server(custom: String) -> String {
"".to_owned() "".to_owned()
} }
#[inline]
pub fn get_api_server(api: String, custom: String) -> String { pub fn get_api_server(api: String, custom: String) -> String {
let res = get_api_server_(api, custom);
if res.starts_with("https") && res.ends_with(":21114") {
return res.replace(":21114", "");
}
res
}
fn get_api_server_(api: String, custom: String) -> String {
#[cfg(windows)] #[cfg(windows)]
if let Ok(lic) = crate::platform::windows::get_license_from_exe_name() { if let Ok(lic) = crate::platform::windows::get_license_from_exe_name() {
if !lic.api.is_empty() { if !lic.api.is_empty() {
@@ -1057,7 +1077,6 @@ pub fn make_fd_to_json(id: i32, path: String, entries: &Vec<FileEntry>) -> Strin
} }
pub fn _make_fd_to_json(id: i32, path: String, entries: &Vec<FileEntry>) -> Map<String, Value> { pub fn _make_fd_to_json(id: i32, path: String, entries: &Vec<FileEntry>) -> Map<String, Value> {
use serde_json::json;
let mut fd_json = serde_json::Map::new(); let mut fd_json = serde_json::Map::new();
fd_json.insert("id".into(), json!(id)); fd_json.insert("id".into(), json!(id));
fd_json.insert("path".into(), json!(path)); fd_json.insert("path".into(), json!(path));
@@ -1701,3 +1720,27 @@ pub fn get_builtin_option(key: &str) -> String {
.cloned() .cloned()
.unwrap_or_default() .unwrap_or_default()
} }
pub fn verify_login(raw: &str, id: &str) -> bool {
true
/*
#[cfg(debug_assertions)]
return true;
let Ok(pk) = crate::decode64("IycjQd4TmWvjjLnYd796Rd+XkK+KG+7GU1Ia7u4+vSw=") else {
return false;
};
let Some(key) = get_pk(&pk).map(|x| sign::PublicKey(x)) else {
return false;
};
let Ok(v) = crate::decode64(raw) else {
return false;
};
let raw = sign::verify(&v, &key).unwrap_or_default();
let v_str = std::str::from_utf8(&raw)
.unwrap_or_default()
.split(":")
.next()
.unwrap_or_default();
v_str == id
*/
}

View File

@@ -31,7 +31,10 @@ macro_rules! my_println{
pub fn core_main() -> Option<Vec<String>> { pub fn core_main() -> Option<Vec<String>> {
crate::load_custom_client(); crate::load_custom_client();
#[cfg(windows)] #[cfg(windows)]
crate::platform::windows::bootstrap(); if !crate::platform::windows::bootstrap() {
// return None to terminate the process
return None;
}
let mut args = Vec::new(); let mut args = Vec::new();
let mut flutter_args = Vec::new(); let mut flutter_args = Vec::new();
let mut i = 0; let mut i = 0;
@@ -166,6 +169,8 @@ pub fn core_main() -> Option<Vec<String>> {
#[cfg(not(any(target_os = "android", target_os = "ios")))] #[cfg(not(any(target_os = "android", target_os = "ios")))]
init_plugins(&args); init_plugins(&args);
if args.is_empty() || crate::common::is_empty_uni_link(&args[0]) { if args.is_empty() || crate::common::is_empty_uni_link(&args[0]) {
#[cfg(windows)]
hbb_common::config::PeerConfig::preload_peers();
std::thread::spawn(move || crate::start_server(false, no_server)); std::thread::spawn(move || crate::start_server(false, no_server));
} else { } else {
#[cfg(windows)] #[cfg(windows)]
@@ -424,15 +429,26 @@ pub fn core_main() -> Option<Vec<String>> {
if pos < max { if pos < max {
address_book_tag = Some(args[pos + 1].to_owned()); address_book_tag = Some(args[pos + 1].to_owned());
} }
let mut device_group_name = None;
let pos = args
.iter()
.position(|x| x == "--device_group_name")
.unwrap_or(max);
if pos < max {
device_group_name = Some(args[pos + 1].to_owned());
}
let mut body = serde_json::json!({ let mut body = serde_json::json!({
"id": id, "id": id,
"uuid": uuid, "uuid": uuid,
}); });
let header = "Authorization: Bearer ".to_owned() + &token; let header = "Authorization: Bearer ".to_owned() + &token;
if user_name.is_none() && strategy_name.is_none() && address_book_name.is_none() if user_name.is_none()
&& strategy_name.is_none()
&& address_book_name.is_none()
&& device_group_name.is_none()
{ {
println!( println!(
"--user_name or --strategy_name or --address_book_name is required!" "--user_name or --strategy_name or --address_book_name or --device_group_name is required!"
); );
} else { } else {
if let Some(name) = user_name { if let Some(name) = user_name {
@@ -447,6 +463,9 @@ pub fn core_main() -> Option<Vec<String>> {
body["address_book_tag"] = serde_json::json!(name); body["address_book_tag"] = serde_json::json!(name);
} }
} }
if let Some(name) = device_group_name {
body["device_group_name"] = serde_json::json!(name);
}
let url = crate::ui_interface::get_api_server() + "/api/devices/cli"; let url = crate::ui_interface::get_api_server() + "/api/devices/cli";
match crate::post_request_sync(url, body.to_string(), &header) { match crate::post_request_sync(url, body.to_string(), &header) {
Err(err) => println!("{}", err), Err(err) => println!("{}", err),

View File

@@ -19,6 +19,7 @@ use serde_json::json;
use std::{ use std::{
collections::{HashMap, HashSet}, collections::{HashMap, HashSet},
ffi::CString, ffi::CString,
io::{Error as IoError, ErrorKind as IoErrorKind},
os::raw::{c_char, c_int, c_void}, os::raw::{c_char, c_int, c_void},
str::FromStr, str::FromStr,
sync::{ sync::{
@@ -50,7 +51,7 @@ lazy_static::lazy_static! {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
lazy_static::lazy_static! { lazy_static::lazy_static! {
pub static ref TEXTURE_RGBA_RENDERER_PLUGIN: Result<Library, LibError> = Library::open("texture_rgba_renderer_plugin.dll"); pub static ref TEXTURE_RGBA_RENDERER_PLUGIN: Result<Library, LibError> = load_plugin_in_app_path("texture_rgba_renderer_plugin.dll");
} }
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
@@ -65,7 +66,37 @@ lazy_static::lazy_static! {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
lazy_static::lazy_static! { lazy_static::lazy_static! {
pub static ref TEXTURE_GPU_RENDERER_PLUGIN: Result<Library, LibError> = Library::open("flutter_gpu_texture_renderer_plugin.dll"); pub static ref TEXTURE_GPU_RENDERER_PLUGIN: Result<Library, LibError> = load_plugin_in_app_path("flutter_gpu_texture_renderer_plugin.dll");
}
// Move this function into `src/platform/windows.rs` if there're more calls to load plugins.
// Load dll with full path.
#[cfg(target_os = "windows")]
fn load_plugin_in_app_path(dll_name: &str) -> Result<Library, LibError> {
match std::env::current_exe() {
Ok(exe_file) => {
if let Some(cur_dir) = exe_file.parent() {
let full_path = cur_dir.join(dll_name);
if !full_path.exists() {
Err(LibError::OpeningLibraryError(IoError::new(
IoErrorKind::NotFound,
format!("{} not found", dll_name),
)))
} else {
Library::open(full_path)
}
} else {
Err(LibError::OpeningLibraryError(IoError::new(
IoErrorKind::Other,
format!(
"Invalid exe parent for {}",
exe_file.to_string_lossy().as_ref()
),
)))
}
}
Err(e) => Err(LibError::OpeningLibraryError(e)),
}
} }
/// FFI for rustdesk core's main entry. /// FFI for rustdesk core's main entry.
@@ -1274,9 +1305,26 @@ pub fn update_text_clipboard_required() {
Client::set_is_text_clipboard_required(is_required); Client::set_is_text_clipboard_required(is_required);
} }
#[cfg(feature = "unix-file-copy-paste")]
pub fn update_file_clipboard_required() {
let is_required = sessions::get_sessions()
.iter()
.any(|s| s.is_file_clipboard_required());
Client::set_is_file_clipboard_required(is_required);
}
#[cfg(not(target_os = "ios"))] #[cfg(not(target_os = "ios"))]
pub fn send_text_clipboard_msg(msg: Message) { pub fn send_clipboard_msg(msg: Message, _is_file: bool) {
for s in sessions::get_sessions() { for s in sessions::get_sessions() {
#[cfg(feature = "unix-file-copy-paste")]
if _is_file {
if crate::is_support_file_copy_paste_num(s.lc.read().unwrap().version)
&& s.is_file_clipboard_required()
{
s.send(Data::Message(msg.clone()));
}
continue;
}
if s.is_text_clipboard_required() { if s.is_text_clipboard_required() {
// Check if the client supports multi clipboards // Check if the client supports multi clipboards
if let Some(message::Union::MultiClipboards(multi_clipboards)) = &msg.union { if let Some(message::Union::MultiClipboards(multi_clipboards)) = &msg.union {
@@ -2076,11 +2124,7 @@ pub mod sessions {
} }
pub(super) mod async_tasks { pub(super) mod async_tasks {
use hbb_common::{ use hbb_common::{bail, tokio, ResultType};
bail,
tokio::{self, select},
ResultType,
};
use std::{ use std::{
collections::HashMap, collections::HashMap,
sync::{ sync::{

View File

@@ -275,6 +275,12 @@ pub fn session_toggle_option(session_id: SessionID, value: String) {
if sessions::get_session_by_session_id(&session_id).is_some() && value == "disable-clipboard" { if sessions::get_session_by_session_id(&session_id).is_some() && value == "disable-clipboard" {
crate::flutter::update_text_clipboard_required(); crate::flutter::update_text_clipboard_required();
} }
#[cfg(feature = "unix-file-copy-paste")]
if sessions::get_session_by_session_id(&session_id).is_some()
&& value == config::keys::OPTION_ENABLE_FILE_COPY_PASTE
{
crate::flutter::update_file_clipboard_required();
}
} }
pub fn session_toggle_privacy_mode(session_id: SessionID, impl_key: String, on: bool) { pub fn session_toggle_privacy_mode(session_id: SessionID, impl_key: String, on: bool) {
@@ -1095,55 +1101,76 @@ pub fn main_peer_exists(id: String) -> bool {
peer_exists(&id) peer_exists(&id)
} }
pub fn main_load_recent_peers() { fn load_recent_peers(
if !config::APP_DIR.read().unwrap().is_empty() { vec_id_modified_time_path: &Vec<(String, SystemTime, std::path::PathBuf)>,
let peers: Vec<HashMap<&str, String>> = PeerConfig::peers(None) to_end: bool,
.drain(..) all_peers: &mut Vec<HashMap<&str, String>>,
.map(|(id, _, p)| peer_to_map(id, p)) from: usize,
.collect(); ) -> usize {
let to = if to_end {
Some(vec_id_modified_time_path.len())
} else {
None
};
let mut peers_next = PeerConfig::batch_peers(vec_id_modified_time_path, from, to);
// There may be less peers than the batch size.
// But no need to consider this case, because it is a rare case.
let peers = peers_next.0.drain(..).map(|(id, _, p)| peer_to_map(id, p));
all_peers.extend(peers);
peers_next.1
}
let data = HashMap::from([ pub fn main_load_recent_peers() {
("name", "load_recent_peers".to_owned()), let push_to_flutter = |peers, ids| {
( let mut data = HashMap::from([("name", "load_recent_peers".to_owned()), ("peers", peers)]);
"peers", if let Some(ids) = ids {
serde_json::ser::to_string(&peers).unwrap_or("".to_owned()), data.insert("ids", ids);
), }
]);
let _res = flutter::push_global_event( let _res = flutter::push_global_event(
flutter::APP_TYPE_MAIN, flutter::APP_TYPE_MAIN,
serde_json::ser::to_string(&data).unwrap_or("".to_owned()), serde_json::ser::to_string(&data).unwrap_or("".to_owned()),
); );
} };
}
pub fn main_load_recent_peers_sync() -> SyncReturn<String> {
if !config::APP_DIR.read().unwrap().is_empty() { if !config::APP_DIR.read().unwrap().is_empty() {
let peers: Vec<HashMap<&str, String>> = PeerConfig::peers(None) let vec_id_modified_time_path = PeerConfig::get_vec_id_modified_time_path(&None);
.drain(..) if vec_id_modified_time_path.is_empty() {
.map(|(id, _, p)| peer_to_map(id, p)) push_to_flutter("".to_owned(), None);
.collect(); return;
}
let data = HashMap::from([ let load_two_times = vec_id_modified_time_path.len() > PeerConfig::BATCH_LOADING_COUNT
("name", "load_recent_peers".to_owned()), && cfg!(target_os = "windows");
( let mut all_peers = vec![];
"peers", if load_two_times {
serde_json::ser::to_string(&peers).unwrap_or("".to_owned()), let next_from = load_recent_peers(&vec_id_modified_time_path, false, &mut all_peers, 0);
), let rest_ids = if next_from < vec_id_modified_time_path.len() {
]); Some(
return SyncReturn(serde_json::ser::to_string(&data).unwrap_or("".to_owned())); vec_id_modified_time_path[next_from..]
.iter()
.map(|(id, _, _)| id.clone())
.collect::<Vec<_>>()
.join(", "),
)
} else {
None
};
push_to_flutter(
serde_json::ser::to_string(&all_peers).unwrap_or("".to_owned()),
rest_ids,
);
let _ = load_recent_peers(&vec_id_modified_time_path, true, &mut all_peers, next_from);
} else {
let _ = load_recent_peers(&vec_id_modified_time_path, true, &mut all_peers, 0);
}
// Don't check if `all_peers` is empty, because we need this message to update the state in the flutter side.
push_to_flutter(
serde_json::ser::to_string(&all_peers).unwrap_or("".to_owned()),
None,
);
} else {
push_to_flutter("".to_owned(), None)
} }
SyncReturn("".to_string())
}
pub fn main_load_lan_peers_sync() -> SyncReturn<String> {
let data = HashMap::from([
("name", "load_lan_peers".to_owned()),
(
"peers",
serde_json::to_string(&get_lan_peers()).unwrap_or_default(),
),
]);
return SyncReturn(serde_json::ser::to_string(&data).unwrap_or("".to_owned()));
} }
pub fn main_load_recent_peers_for_ab(filter: String) -> String { pub fn main_load_recent_peers_for_ab(filter: String) -> String {
@@ -1164,13 +1191,20 @@ pub fn main_load_recent_peers_for_ab(filter: String) -> String {
} }
pub fn main_load_fav_peers() { pub fn main_load_fav_peers() {
let push_to_flutter = |peers| {
let data = HashMap::from([("name", "load_fav_peers".to_owned()), ("peers", peers)]);
let _res = flutter::push_global_event(
flutter::APP_TYPE_MAIN,
serde_json::ser::to_string(&data).unwrap_or("".to_owned()),
);
};
if !config::APP_DIR.read().unwrap().is_empty() { if !config::APP_DIR.read().unwrap().is_empty() {
let favs = get_fav(); let favs = get_fav();
let mut recent = PeerConfig::peers(None); let mut recent = PeerConfig::peers(Some(favs.clone()));
let mut lan = config::LanPeers::load() let mut lan = config::LanPeers::load()
.peers .peers
.iter() .iter()
.filter(|d| recent.iter().all(|r| r.0 != d.id)) .filter(|d| favs.contains(&d.id) && recent.iter().all(|r| r.0 != d.id))
.map(|d| { .map(|d| {
( (
d.id.clone(), d.id.clone(),
@@ -1189,26 +1223,12 @@ pub fn main_load_fav_peers() {
recent.append(&mut lan); recent.append(&mut lan);
let peers: Vec<HashMap<&str, String>> = recent let peers: Vec<HashMap<&str, String>> = recent
.into_iter() .into_iter()
.filter_map(|(id, _, p)| { .map(|(id, _, p)| peer_to_map(id, p))
if favs.contains(&id) {
Some(peer_to_map(id, p))
} else {
None
}
})
.collect(); .collect();
let data = HashMap::from([ push_to_flutter(serde_json::ser::to_string(&peers).unwrap_or("".to_owned()));
("name", "load_fav_peers".to_owned()), } else {
( push_to_flutter("".to_owned());
"peers",
serde_json::ser::to_string(&peers).unwrap_or("".to_owned()),
),
]);
let _res = flutter::push_global_event(
flutter::APP_TYPE_MAIN,
serde_json::ser::to_string(&data).unwrap_or("".to_owned()),
);
} }
} }
@@ -1948,13 +1968,7 @@ pub fn main_hide_dock() -> SyncReturn<bool> {
} }
pub fn main_has_file_clipboard() -> SyncReturn<bool> { pub fn main_has_file_clipboard() -> SyncReturn<bool> {
let ret = cfg!(any( let ret = cfg!(any(target_os = "windows", feature = "unix-file-copy-paste",));
target_os = "windows",
all(
feature = "unix-file-copy-paste",
any(target_os = "linux", target_os = "macos")
)
));
SyncReturn(ret) SyncReturn(ret)
} }

View File

@@ -7,7 +7,8 @@ use std::{
#[cfg(not(any(target_os = "ios")))] #[cfg(not(any(target_os = "ios")))]
use crate::{ui_interface::get_builtin_option, Connection}; use crate::{ui_interface::get_builtin_option, Connection};
use hbb_common::{ use hbb_common::{
config::{keys, Config, LocalConfig}, config::{self, keys, Config, LocalConfig},
log,
tokio::{self, sync::broadcast, time::Instant}, tokio::{self, sync::broadcast, time::Instant},
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -58,6 +59,7 @@ async fn start_hbbs_sync_async() {
let mut last_sent: Option<Instant> = None; let mut last_sent: Option<Instant> = None;
let mut info_uploaded: (bool, String, Option<Instant>, String) = let mut info_uploaded: (bool, String, Option<Instant>, String) =
(false, "".to_owned(), None, "".to_owned()); (false, "".to_owned(), None, "".to_owned());
let mut sysinfo_ver = "".to_owned();
loop { loop {
tokio::select! { tokio::select! {
_ = interval.tick() => { _ = interval.tick() => {
@@ -67,7 +69,7 @@ async fn start_hbbs_sync_async() {
*PRO.lock().unwrap() = false; *PRO.lock().unwrap() = false;
continue; continue;
} }
if hbb_common::config::option2bool("stop-service", &Config::get_option("stop-service")) { if config::option2bool("stop-service", &Config::get_option("stop-service")) {
continue; continue;
} }
let conns = Connection::alive_conns(); let conns = Connection::alive_conns();
@@ -99,11 +101,42 @@ async fn start_hbbs_sync_async() {
if !strategy_name.is_empty() { if !strategy_name.is_empty() {
v[keys::OPTION_PRESET_STRATEGY_NAME] = json!(strategy_name); v[keys::OPTION_PRESET_STRATEGY_NAME] = json!(strategy_name);
} }
match crate::post_request(url.replace("heartbeat", "sysinfo"), v.to_string(), "").await { let device_group_name = get_builtin_option(keys::OPTION_PRESET_DEVICE_GROUP_NAME);
if !device_group_name.is_empty() {
v[keys::OPTION_PRESET_DEVICE_GROUP_NAME] = json!(device_group_name);
}
let v = v.to_string();
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(url.as_bytes());
hasher.update(&v.as_bytes());
let res = hasher.finalize();
let hash = hbb_common::base64::encode(&res[..]);
let old_hash = config::Status::get("sysinfo_hash");
let ver = config::Status::get("sysinfo_ver"); // sysinfo_ver is the version of sysinfo on server's side
if hash == old_hash {
let samever = match crate::post_request(url.replace("heartbeat", "sysinfo_ver"), "".to_owned(), "").await {
Ok(x) => {
sysinfo_ver = x.clone();
x == ver
}
_ => {
true // if failed to get sysinfo_ver, we assume it's the same version
}
};
if samever {
info_uploaded = (true, url.clone(), None, id.clone());
log::info!("sysinfo not changed, skip upload");
continue;
}
}
match crate::post_request(url.replace("heartbeat", "sysinfo"), v, "").await {
Ok(x) => { Ok(x) => {
if x == "SYSINFO_UPDATED" { if x == "SYSINFO_UPDATED" {
info_uploaded = (true, url.clone(), None, id.clone()); info_uploaded = (true, url.clone(), None, id.clone());
hbb_common::log::info!("sysinfo updated"); log::info!("sysinfo updated");
config::Status::set("sysinfo_hash", hash);
config::Status::set("sysinfo_ver", sysinfo_ver.clone());
*PRO.lock().unwrap() = true; *PRO.lock().unwrap() = true;
} else if x == "ID_NOT_FOUND" { } else if x == "ID_NOT_FOUND" {
info_uploaded.2 = None; // next heartbeat will upload sysinfo again info_uploaded.2 = None; // next heartbeat will upload sysinfo again
@@ -132,6 +165,11 @@ async fn start_hbbs_sync_async() {
v["modified_at"] = json!(modified_at); v["modified_at"] = json!(modified_at);
if let Ok(s) = crate::post_request(url.clone(), v.to_string(), "").await { if let Ok(s) = crate::post_request(url.clone(), v.to_string(), "").await {
if let Ok(mut rsp) = serde_json::from_str::<HashMap::<&str, Value>>(&s) { if let Ok(mut rsp) = serde_json::from_str::<HashMap::<&str, Value>>(&s) {
if rsp.remove("sysinfo").is_some() {
info_uploaded.0 = false;
config::Status::set("sysinfo_hash", "".to_owned());
log::info!("sysinfo required to forcely update");
}
if let Some(conns) = rsp.remove("disconnect") { if let Some(conns) = rsp.remove("disconnect") {
if let Ok(conns) = serde_json::from_value::<Vec<i32>>(conns) { if let Ok(conns) = serde_json::from_value::<Vec<i32>>(conns) {
SENDER.lock().unwrap().send(conns).ok(); SENDER.lock().unwrap().send(conns).ok();
@@ -146,6 +184,7 @@ async fn start_hbbs_sync_async() {
} }
if let Some(strategy) = rsp.remove("strategy") { if let Some(strategy) = rsp.remove("strategy") {
if let Ok(strategy) = serde_json::from_value::<StrategyOptions>(strategy) { if let Ok(strategy) = serde_json::from_value::<StrategyOptions>(strategy) {
log::info!("strategy updated");
handle_config_options(strategy.config_options); handle_config_options(strategy.config_options);
} }
} }

View File

@@ -25,9 +25,7 @@ use hbb_common::{
config::{self, Config, Config2}, config::{self, Config, Config2},
futures::StreamExt as _, futures::StreamExt as _,
futures_util::sink::SinkExt, futures_util::sink::SinkExt,
log, password_security as password, log, password_security as password, timeout,
sodiumoxide::base64,
timeout,
tokio::{ tokio::{
self, self,
io::{AsyncRead, AsyncWrite}, io::{AsyncRead, AsyncWrite},
@@ -230,7 +228,7 @@ pub enum Data {
FS(FS), FS(FS),
Test, Test,
SyncConfig(Option<Box<(Config, Config2)>>), SyncConfig(Option<Box<(Config, Config2)>>),
#[cfg(not(any(target_os = "android", target_os = "ios")))] #[cfg(target_os = "windows")]
ClipboardFile(ClipboardFile), ClipboardFile(ClipboardFile),
ClipboardFileEnabled(bool), ClipboardFileEnabled(bool),
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]

View File

@@ -41,7 +41,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("length %min% to %max%", "الطول من %min% الى %max%"), ("length %min% to %max%", "الطول من %min% الى %max%"),
("starts with a letter", "يبدأ بحرف"), ("starts with a letter", "يبدأ بحرف"),
("allowed characters", "الحروف المسموح بها"), ("allowed characters", "الحروف المسموح بها"),
("id_change_tip", "فقط a-z, A-Z, 0-9 و _ مسموح بها. اول حرف يجب ان يكون a-z او A-Z. الطول بين 6 و 16."), ("id_change_tip", "فقط a-z, A-Z, 0-9, - (dash) و _ مسموح بها. اول حرف يجب ان يكون a-z او A-Z. الطول بين 6 و 16."),
("Website", "الموقع"), ("Website", "الموقع"),
("About", "عن"), ("About", "عن"),
("Slogan_tip", "صنع بحب في هذا العالم الفوضوي!"), ("Slogan_tip", "صنع بحب في هذا العالم الفوضوي!"),
@@ -656,5 +656,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Update client clipboard", ""), ("Update client clipboard", ""),
("Untagged", ""), ("Untagged", ""),
("new-version-of-{}-tip", ""), ("new-version-of-{}-tip", ""),
("Accessible devices", ""),
].iter().cloned().collect(); ].iter().cloned().collect();
} }

View File

@@ -41,7 +41,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("length %min% to %max%", "даўжыня %min%...%max%"), ("length %min% to %max%", "даўжыня %min%...%max%"),
("starts with a letter", "пачынаецца з літары"), ("starts with a letter", "пачынаецца з літары"),
("allowed characters", "дазволеныя сімвалы"), ("allowed characters", "дазволеныя сімвалы"),
("id_change_tip", "Дапускаюцца толькі сімвалы a-z, A-Z, 0-9 і _ (падкрэсліванне). Першай павінна быць літара a-z, A-Z. Даўжыня ад 6 да 16."), ("id_change_tip", "Дапускаюцца толькі сімвалы a-z, A-Z, 0-9, - (dash) і _ (падкрэсліванне). Першай павінна быць літара a-z, A-Z. Даўжыня ад 6 да 16."),
("Website", "Сайт"), ("Website", "Сайт"),
("About", "Пра праграму"), ("About", "Пра праграму"),
("Slogan_tip", "Зроблена з душой у гэтым вар'яцкім свеце!"), ("Slogan_tip", "Зроблена з душой у гэтым вар'яцкім свеце!"),
@@ -656,5 +656,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Update client clipboard", ""), ("Update client clipboard", ""),
("Untagged", ""), ("Untagged", ""),
("new-version-of-{}-tip", ""), ("new-version-of-{}-tip", ""),
("Accessible devices", ""),
].iter().cloned().collect(); ].iter().cloned().collect();
} }

View File

@@ -41,7 +41,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("length %min% to %max%", "дължина %min% до %max%"), ("length %min% to %max%", "дължина %min% до %max%"),
("starts with a letter", "започва с буква"), ("starts with a letter", "започва с буква"),
("allowed characters", "разрешени знаци"), ("allowed characters", "разрешени знаци"),
("id_change_tip", "Само a-z, A-Z, 0-9 и _ (долна черта) са сред позволени. Първа буква следва да е a-z, A-Z. С дължина мержу 6 и 16."), ("id_change_tip", "Само a-z, A-Z, 0-9, - (dash) и _ (долна черта) са сред позволени. Първа буква следва да е a-z, A-Z. С дължина мержу 6 и 16."),
("Website", "Уебсайт"), ("Website", "Уебсайт"),
("About", "Относно"), ("About", "Относно"),
("Slogan_tip", "Направено от сърце в този хаотичен свят!"), ("Slogan_tip", "Направено от сърце в този хаотичен свят!"),
@@ -656,5 +656,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Update client clipboard", ""), ("Update client clipboard", ""),
("Untagged", ""), ("Untagged", ""),
("new-version-of-{}-tip", ""), ("new-version-of-{}-tip", ""),
("Accessible devices", ""),
].iter().cloned().collect(); ].iter().cloned().collect();
} }

View File

@@ -41,7 +41,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("length %min% to %max%", "Entre %min% i %max% caràcters"), ("length %min% to %max%", "Entre %min% i %max% caràcters"),
("starts with a letter", "Comença amb una lletra"), ("starts with a letter", "Comença amb una lletra"),
("allowed characters", "Caràcters admesos"), ("allowed characters", "Caràcters admesos"),
("id_change_tip", "Els caràcters admesos són: a-z, A-Z, 0-9, _ (guió baix). El primer caràcter ha de ser a-z/A-Z, i una mida de 6 a 16 caràcters."), ("id_change_tip", "Els caràcters admesos són: a-z, A-Z, 0-9, - (dash), _ (guió baix). El primer caràcter ha de ser a-z/A-Z, i una mida de 6 a 16 caràcters."),
("Website", "Lloc web"), ("Website", "Lloc web"),
("About", "Quant al RustDesk"), ("About", "Quant al RustDesk"),
("Slogan_tip", "Fet de tot cor dins d'aquest món caòtic!\nTraducció: Benet R. i Camps (BennyBeat)."), ("Slogan_tip", "Fet de tot cor dins d'aquest món caòtic!\nTraducció: Benet R. i Camps (BennyBeat)."),
@@ -656,5 +656,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Update client clipboard", ""), ("Update client clipboard", ""),
("Untagged", ""), ("Untagged", ""),
("new-version-of-{}-tip", ""), ("new-version-of-{}-tip", ""),
("Accessible devices", ""),
].iter().cloned().collect(); ].iter().cloned().collect();
} }

View File

@@ -41,7 +41,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("length %min% to %max%", "长度在 %min% 与 %max% 之间"), ("length %min% to %max%", "长度在 %min% 与 %max% 之间"),
("starts with a letter", "以字母开头"), ("starts with a letter", "以字母开头"),
("allowed characters", "使用允许的字符"), ("allowed characters", "使用允许的字符"),
("id_change_tip", "只可以使用字母 a-z, A-Z, 0-9, _ (下划线)。首字母必须是 a-z, A-Z。长度在 6 与 16 之间。"), ("id_change_tip", "只可以使用字母 a-z, A-Z, 0-9, - (dash), _ (下划线)。首字母必须是 a-z, A-Z。长度在 6 与 16 之间。"),
("Website", "网站"), ("Website", "网站"),
("About", "关于"), ("About", "关于"),
("Slogan_tip", "在这个混乱的世界中,用心制作!"), ("Slogan_tip", "在这个混乱的世界中,用心制作!"),
@@ -656,5 +656,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Update client clipboard", "更新客户端的粘贴板"), ("Update client clipboard", "更新客户端的粘贴板"),
("Untagged", "无标签"), ("Untagged", "无标签"),
("new-version-of-{}-tip", "{} 版本更新"), ("new-version-of-{}-tip", "{} 版本更新"),
("Accessible devices", "可访问的设备"),
].iter().cloned().collect(); ].iter().cloned().collect();
} }

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