mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-09-10 14:31:02 +03:00
Compare commits
85 Commits
dxgi-reuse
...
c4df2e6da2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4df2e6da2 | ||
|
|
b473977aa8 | ||
|
|
81b55dff24 | ||
|
|
9eae353ee5 | ||
|
|
a02063ece8 | ||
|
|
e677fd510a | ||
|
|
7280238993 | ||
|
|
019dd20b30 | ||
|
|
98c0401134 | ||
|
|
3432b8bc30 | ||
|
|
fe1300584c | ||
|
|
6ffb506961 | ||
|
|
8f81561918 | ||
|
|
093790875b | ||
|
|
c99ccdcb0d | ||
|
|
e3ef5317c5 | ||
|
|
59b7987b1d | ||
|
|
00005f6af5 | ||
|
|
6926ad290f | ||
|
|
ef642ae329 | ||
|
|
fa48b3f88d | ||
|
|
d6a670e764 | ||
|
|
39a266cfd5 | ||
|
|
1e00d09f84 | ||
|
|
7a0705bdad | ||
|
|
bfd3a9d7d9 | ||
|
|
2786d12a28 | ||
|
|
cf9c94a677 | ||
|
|
db3830a781 | ||
|
|
2befaa5b6a | ||
|
|
b199024082 | ||
|
|
f578fa189f | ||
|
|
ed09f3cc76 | ||
|
|
2657143712 | ||
|
|
51bbaa3f17 | ||
|
|
09e27370a3 | ||
|
|
0412726a20 | ||
|
|
b319cef200 | ||
|
|
c81c85fb78 | ||
|
|
553d66035e | ||
|
|
4ed2a60e35 | ||
|
|
305b3e5e48 | ||
|
|
5a790443a8 | ||
|
|
47b2c05e1d | ||
|
|
120fc4f4c5 | ||
|
|
55cbd1a218 | ||
|
|
0e61153a94 | ||
|
|
65a819f9e2 | ||
|
|
3d5406aa1c | ||
|
|
c40cb2a93e | ||
|
|
c631cdcf22 | ||
|
|
2f8c8704bb | ||
|
|
b591eeaa76 | ||
|
|
21cdf4d0a1 | ||
|
|
5ce320d1cd | ||
|
|
32ee0c19e3 | ||
|
|
0a135e0fae | ||
|
|
d3d5690bbf | ||
|
|
7e80399689 | ||
|
|
777d6347e4 | ||
|
|
9a1c8da143 | ||
|
|
978c901f49 | ||
|
|
d453a19601 | ||
|
|
50c4e435de | ||
|
|
d5c6d0f6b7 | ||
|
|
b6ff62c74b | ||
|
|
ba6de7990f | ||
|
|
a59ad333fc | ||
|
|
82aa28f129 | ||
|
|
3f93005be2 | ||
|
|
23a147b0dc | ||
|
|
e4539fc304 | ||
|
|
6dbd810454 | ||
|
|
0fd1a0eecb | ||
|
|
dfb5804dd0 | ||
|
|
957dfe8c96 | ||
|
|
c312385ffd | ||
|
|
f28ac38ccf | ||
|
|
28cf1836e6 | ||
|
|
1ec1b9e7e3 | ||
|
|
2c84c8fb13 | ||
|
|
66ab0b87f6 | ||
|
|
169f74f8d9 | ||
|
|
d4b06a6c5c | ||
|
|
03a7fc5992 |
89
.github/workflows/flutter-build.yml
vendored
89
.github/workflows/flutter-build.yml
vendored
@@ -31,7 +31,7 @@ env:
|
||||
# engine is 3.44. Every other platform stays on FLUTTER_VERSION (3.24.5) until Windows 7
|
||||
# support is restored after the upstream-wide Flutter bump. The arm64 job patches the few
|
||||
# 3.44-only source/pubspec changes on the fly (see "Patch RustDesk sources for Flutter 3.44").
|
||||
FLUTTER_WINDOWS_ARM_VERSION: "3.44.8"
|
||||
FLUTTER_WINDOWS_ARM_VERSION: "3.44.9"
|
||||
# for arm64 linux because official Dart SDK does not work
|
||||
FLUTTER_ELINUX_VERSION: "3.16.9"
|
||||
TAG_NAME: "${{ inputs.upload-tag }}"
|
||||
@@ -43,8 +43,9 @@ env:
|
||||
# https://github.com/rustdesk/rustdesk/actions/runs/14414119794/job/40427970174
|
||||
# 2. Update the `VCPKG_COMMIT_ID` in `ci.yml` and `playground.yml`.
|
||||
VCPKG_COMMIT_ID: "9e593bb18ea69cc5095e012465dcd675a822ed0d"
|
||||
VCPKG_CMAKE_VERSION: "4.3.0"
|
||||
ARMV7_VCPKG_COMMIT_ID: "6f29f12e82a8293156836ad81cc9bf5af41fe836" # 2025.01.13, got "/opt/artifacts/vcpkg/vcpkg: No such file or directory" with latest version
|
||||
VERSION: "1.4.9"
|
||||
VERSION: "1.5.0"
|
||||
NDK_VERSION: "r28c"
|
||||
#signing keys env variable checks
|
||||
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"
|
||||
@@ -389,6 +390,54 @@ jobs:
|
||||
mv $msi.FullName ../../SignOutput/rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.msi
|
||||
sha256sum ../../SignOutput/rustdesk-*.msi
|
||||
|
||||
- name: Build pre-built MSI template
|
||||
# Two things this works around: preprocess.py rewrites res/msi in place, so the
|
||||
# tree is reset around this second variant; and it locates the app as
|
||||
# <app-name>.exe inside the dist, so the dist copy is renamed to match.
|
||||
#
|
||||
# The placeholder is chosen to keep this template as close to the shipped msi as
|
||||
# possible: eight characters like "RustDesk", and a valid 8.3 name, so WiX
|
||||
# derives no short name for it. A longer placeholder would get one, and a patch
|
||||
# cannot rewrite a truncated placeholder, leaving short names pointing at it.
|
||||
#
|
||||
# It still has to be unique, which is why "RustDesk" itself cannot be used:
|
||||
# it also names payload that must never be renamed, such as librustdesk.dll
|
||||
# and drivers\RustDeskPrinterDriver.
|
||||
#
|
||||
#
|
||||
# Building the arm64 template on the native arm64 runner makes the ARM
|
||||
# package available: the build agents are x64 and cannot run
|
||||
# preprocess.py against an ARM exe.
|
||||
if: env.UPLOAD_ARTIFACT == 'true'
|
||||
run: |
|
||||
git checkout -- res/msi
|
||||
cp -r ./rustdesk ./rustdesk-msi-template
|
||||
mv ./rustdesk-msi-template/rustdesk.exe ./rustdesk-msi-template/RDAPPNAM.exe
|
||||
Set-Content -Path ./rustdesk-msi-template/custom.txt -Value 'placeholder' -NoNewline
|
||||
$assets = './rustdesk-msi-template/data/flutter_assets/assets'
|
||||
New-Item -ItemType Directory -Force -Path $assets | Out-Null
|
||||
foreach ($a in 'icon.ico','icon.png','logo.png','logo_light.png','logo_dark.png') {
|
||||
Set-Content -Path "$assets/$a" -Value 'placeholder' -NoNewline
|
||||
}
|
||||
pushd ./res/msi
|
||||
python preprocess.py --arp --template --revision-version 0 -d ../../rustdesk-msi-template --app-name RDAPPNAM
|
||||
$msiPlatform = if ('${{ matrix.job.arch }}' -eq 'aarch64') { 'ARM64' } else { 'x64' }
|
||||
msbuild msi.sln -t:clean -p:Configuration=Release -p:Platform=$msiPlatform
|
||||
msbuild msi.sln -p:Configuration=Release -p:Platform=$msiPlatform /p:TargetVersion=Windows10
|
||||
$msi = Get-ChildItem ./Package/bin/*/Release/en-us/Package.msi | Select-Object -First 1
|
||||
popd
|
||||
mkdir ./msi-template
|
||||
mv $msi.FullName ./msi-template/rustdesk-template-${{ matrix.job.arch }}.msi
|
||||
git checkout -- res/msi
|
||||
rm -r -fo ./rustdesk-msi-template
|
||||
|
||||
- name: Upload unsigned msi template
|
||||
if: env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: rustdesk-unsigned-msi-template-${{ matrix.job.arch }}
|
||||
path: ./msi-template
|
||||
|
||||
- name: Sign rustdesk self-extracted file
|
||||
if: env.UPLOAD_ARTIFACT == 'true' && env.SIGN_BASE_URL != '-2'
|
||||
shell: bash
|
||||
@@ -925,15 +974,33 @@ jobs:
|
||||
name: rustdesk-unsigned-windows-x86_64
|
||||
path: ./windows-x86_64/
|
||||
|
||||
- name: Download Artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: rustdesk-unsigned-windows-aarch64
|
||||
path: ./windows-aarch64/
|
||||
|
||||
- name: Download Artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: rustdesk-unsigned-windows-x86
|
||||
path: ./windows-x86/
|
||||
|
||||
- name: Download Artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: rustdesk-unsigned-msi-template-x86_64
|
||||
path: ./msi-template/
|
||||
|
||||
- name: Download Artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: rustdesk-unsigned-msi-template-aarch64
|
||||
path: ./msi-template/
|
||||
|
||||
- name: Combine unsigned app
|
||||
run: |
|
||||
tar czf rustdesk-${{ env.VERSION }}-unsigned.tar.gz *.dmg windows-x86_64 windows-x86
|
||||
tar czf rustdesk-${{ env.VERSION }}-unsigned.tar.gz *.dmg windows-x86_64 windows-aarch64 windows-x86 msi-template
|
||||
|
||||
- name: Publish unsigned app
|
||||
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
@@ -1470,7 +1537,6 @@ jobs:
|
||||
submodules: recursive
|
||||
|
||||
- name: Set Swap Space
|
||||
if: ${{ matrix.job.arch == 'x86_64' }}
|
||||
uses: pierotofy/set-swap-space@49819abfb41bd9b44fb781159c033dba90353a7c # v1.0
|
||||
with:
|
||||
swap-size-gb: 12
|
||||
@@ -1505,6 +1571,15 @@ jobs:
|
||||
name: bridge-artifact
|
||||
path: ./
|
||||
|
||||
# vcpkg 2026.07.29's SPDX scripts require CMake 4.3+, but this ARM64 runner selects CMake 3.31.
|
||||
- name: Install CMake for vcpkg on Linux ARM64
|
||||
if: matrix.job.arch == 'aarch64' && env.UPLOAD_ARTIFACT == 'true'
|
||||
run: |
|
||||
python3 -m pip install --user "cmake==${VCPKG_CMAKE_VERSION}"
|
||||
user_base="$(python3 -m site --user-base)"
|
||||
"${user_base}/bin/cmake" --version
|
||||
echo "${user_base}/bin" >> "${GITHUB_PATH}"
|
||||
|
||||
- name: Setup vcpkg with Github Actions binary cache
|
||||
if: matrix.job.arch == 'x86_64' || env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
|
||||
@@ -2075,6 +2150,12 @@ jobs:
|
||||
echo "Modified vcpkg.json for armv7 build:"
|
||||
grep -A 2 -B 2 '"baseline"' vcpkg.json
|
||||
|
||||
- name: Set Swap Space
|
||||
if: matrix.job.arch == 'armv7'
|
||||
uses: pierotofy/set-swap-space@49819abfb41bd9b44fb781159c033dba90353a7c # v1.0
|
||||
with:
|
||||
swap-size-gb: 12
|
||||
|
||||
- name: Free Space
|
||||
run: |
|
||||
df -h
|
||||
|
||||
8
.github/workflows/playground.yml
vendored
8
.github/workflows/playground.yml
vendored
@@ -17,7 +17,7 @@ env:
|
||||
TAG_NAME: "nightly"
|
||||
VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite"
|
||||
VCPKG_COMMIT_ID: "9e593bb18ea69cc5095e012465dcd675a822ed0d"
|
||||
VERSION: "1.4.9"
|
||||
VERSION: "1.5.0"
|
||||
NDK_VERSION: "r26d"
|
||||
#signing keys env variable checks
|
||||
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"
|
||||
@@ -283,7 +283,7 @@ jobs:
|
||||
nasm \
|
||||
yasm \
|
||||
ninja-build \
|
||||
openjdk-11-jdk-headless \
|
||||
openjdk-17-jdk-headless \
|
||||
pkg-config \
|
||||
tree \
|
||||
wget
|
||||
@@ -365,9 +365,9 @@ jobs:
|
||||
- name: Build rustdesk
|
||||
shell: bash
|
||||
env:
|
||||
JAVA_HOME: /usr/lib/jvm/java-11-openjdk-amd64
|
||||
JAVA_HOME: /usr/lib/jvm/java-17-openjdk-amd64
|
||||
run: |
|
||||
export PATH=/usr/lib/jvm/java-11-openjdk-amd64/bin:$PATH
|
||||
export PATH=/usr/lib/jvm/java-17-openjdk-amd64/bin:$PATH
|
||||
# temporary use debug sign config
|
||||
sed -i "s/signingConfigs.release/signingConfigs.debug/g" ./flutter/android/app/build.gradle
|
||||
case ${{ matrix.job.target }} in
|
||||
|
||||
4
.github/workflows/update-webpki-roots.yml
vendored
4
.github/workflows/update-webpki-roots.yml
vendored
@@ -33,6 +33,10 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
# The root workspace lists libs/hbb_common as a member; without the
|
||||
# submodule its manifest is missing and cargo cannot load the workspace.
|
||||
submodules: recursive
|
||||
|
||||
- name: Update webpki-roots in all lockfiles
|
||||
id: update
|
||||
|
||||
19
AGENTS.md
19
AGENTS.md
@@ -74,6 +74,25 @@
|
||||
* Accept a little duplication over a restructure. A new function that repeats a few lines of an existing one is a better diff than reshaping the original so both can share it.
|
||||
* Put new logic in self-contained functions in the module it belongs to (platform-specific logic in `src/platform/`, with `use` inside the function body to avoid churning shared import blocks). Call sites in shared files (`src/tray.rs`, `src/core_main.rs`, `src/server/connection.rs`, …) should be thin one-line hooks.
|
||||
|
||||
### Scope check before touching shared code
|
||||
|
||||
* Before changing a shared trait, a shared struct, or the signature of a widely used function, check whether the bug or feature is specific to one path. If it is, keep the change inside that path unless that is impossible, and say in the PR why it was.
|
||||
* If an unrelated caller needs `Default::default()`, `None`, or another placeholder solely to satisfy a signature you changed, the diff is too broad: stop and redesign.
|
||||
* The expected shape of a fix is a new function in the feature's own module, plus at most a new field or a thin hook in the shared code it needs. Feature-specific state belongs beside the feature's existing state, not in a new abstraction every caller has to learn.
|
||||
|
||||
### Mandatory regression-surface check
|
||||
|
||||
Before considering any implementation complete, perform a minimization pass over the final diff.
|
||||
|
||||
* Inspect every modified existing file and every modified existing code path. Each must be strictly necessary for the requested change. Revert changes that are merely cleanup, refactoring, consistency improvements, or fixes for pre-existing issues.
|
||||
* For new features, preserve the existing implementation path when the feature is disabled or unsupported whenever practical. `feature off` should run the old code, not a rewritten equivalent.
|
||||
* Do not route existing behavior through a new abstraction merely to share code with the new feature. Prefer a parallel new function or a small amount of duplication over changing a proven existing path.
|
||||
* Keep new implementation logic in new or feature-specific modules. Changes to shared/core files should normally be thin hooks, capability checks, or protocol plumbing.
|
||||
* Do not fix unrelated pre-existing bugs in the same PR. Put them in a separate change unless they directly block correctness or security of the requested work.
|
||||
* For submodule bumps, inspect the exact commit range and ensure unrelated changes are not being pulled into the parent PR.
|
||||
* Before finalizing, explicitly report the regression surface: list the existing files and existing runtime paths whose behavior changed, and explain why each change is unavoidable.
|
||||
* During review, treat an unnecessarily modified legacy path as a review finding even if tests pass and the rewritten behavior appears equivalent.
|
||||
|
||||
## Reviewing a PR
|
||||
|
||||
* Review only what the diff introduces. Verify ownership with `gh pr diff` before reporting a finding — if the offending lines are untouched context, it is a pre-existing problem, not this PR's.
|
||||
|
||||
306
Cargo.lock
generated
306
Cargo.lock
generated
@@ -753,24 +753,6 @@ dependencies = [
|
||||
"syn 2.0.98",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bindgen"
|
||||
version = "0.71.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"cexpr",
|
||||
"clang-sys",
|
||||
"itertools 0.12.1",
|
||||
"proc-macro2 1.0.93",
|
||||
"quote 1.0.36",
|
||||
"regex",
|
||||
"rustc-hash 2.1.1",
|
||||
"shlex",
|
||||
"syn 2.0.98",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bindgen"
|
||||
version = "0.72.1"
|
||||
@@ -1161,30 +1143,6 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
|
||||
dependencies = [
|
||||
"cfg-if 1.0.0",
|
||||
"cipher",
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chacha20poly1305"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"chacha20",
|
||||
"cipher",
|
||||
"poly1305",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.41"
|
||||
@@ -1234,7 +1192,6 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"inout",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2324,7 +2281,7 @@ version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412"
|
||||
dependencies = [
|
||||
"libloading 0.8.4",
|
||||
"libloading 0.7.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2442,42 +2399,6 @@ dependencies = [
|
||||
"linux-raw-sys 0.6.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dtls"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f531dd7c181beaf3cebab3716afa4d0d41ab888be85232583f56bbaf07ca208a"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
"async-trait",
|
||||
"bincode",
|
||||
"byteorder",
|
||||
"cbc",
|
||||
"ccm",
|
||||
"chacha20poly1305",
|
||||
"der-parser",
|
||||
"hmac",
|
||||
"log",
|
||||
"p256",
|
||||
"p384",
|
||||
"portable-atomic",
|
||||
"rand 0.9.2",
|
||||
"rand_core 0.6.4",
|
||||
"rcgen",
|
||||
"ring",
|
||||
"rustls",
|
||||
"sec1",
|
||||
"serde 1.0.228",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"thiserror 1.0.61",
|
||||
"tokio",
|
||||
"webrtc-util",
|
||||
"x25519-dalek",
|
||||
"x509-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dtoa"
|
||||
version = "0.4.8"
|
||||
@@ -2863,7 +2784,7 @@ dependencies = [
|
||||
"is-terminal",
|
||||
"lazy_static",
|
||||
"log",
|
||||
"nu-ansi-term 0.49.0",
|
||||
"nu-ansi-term",
|
||||
"regex",
|
||||
"thiserror 1.0.61",
|
||||
]
|
||||
@@ -3766,6 +3687,7 @@ dependencies = [
|
||||
"mac_address",
|
||||
"machine-uid",
|
||||
"osascript",
|
||||
"percent-encoding",
|
||||
"protobuf",
|
||||
"protobuf-codegen",
|
||||
"rand 0.8.5",
|
||||
@@ -4177,16 +4099,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "interceptor"
|
||||
version = "0.15.0"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea51375727680dc15f06e8ad90fa31df75d79dd030100e8ad60eef1c27fe2c98"
|
||||
checksum = "1ac0781c825d602095113772e389ef0607afcb869ae0e68a590d8e0799cdcef8"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"futures",
|
||||
"log",
|
||||
"portable-atomic",
|
||||
"rand 0.9.2",
|
||||
"rand 0.8.5",
|
||||
"rtcp",
|
||||
"rtp",
|
||||
"thiserror 1.0.61",
|
||||
@@ -4338,11 +4259,11 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "kcp-sys"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/rustdesk-org/kcp-sys#32a6c09fc6223f54aea83981a6aa8995931d29be"
|
||||
source = "git+https://github.com/rustdesk-org/kcp-sys?branch=rustdesk-patches#023a0065398968989f2ddfcf5cc72bb886d02675"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"auto_impl",
|
||||
"bindgen 0.71.1",
|
||||
"bindgen 0.72.1",
|
||||
"bitflags 2.9.1",
|
||||
"bytes",
|
||||
"cc",
|
||||
@@ -4353,8 +4274,6 @@ dependencies = [
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"zerocopy 0.7.34",
|
||||
]
|
||||
|
||||
@@ -4488,7 +4407,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e310b3a6b5907f99202fcdb4960ff45b93735d7c7d96b760fcff8db2dc0e103d"
|
||||
dependencies = [
|
||||
"cfg-if 1.0.0",
|
||||
"windows-targets 0.52.6",
|
||||
"windows-targets 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5210,16 +5129,6 @@ dependencies = [
|
||||
"winapi 0.3.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.46.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84"
|
||||
dependencies = [
|
||||
"overload",
|
||||
"winapi 0.3.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.49.0"
|
||||
@@ -5883,12 +5792,6 @@ dependencies = [
|
||||
"serde_json 1.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "overload"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
|
||||
|
||||
[[package]]
|
||||
name = "owned_ttf_parser"
|
||||
version = "0.25.1"
|
||||
@@ -6277,17 +6180,6 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "poly1305"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
|
||||
dependencies = [
|
||||
"cpufeatures",
|
||||
"opaque-debug",
|
||||
"universal-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polyval"
|
||||
version = "0.6.2"
|
||||
@@ -7094,9 +6986,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rtcp"
|
||||
version = "0.14.0"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81d30d1c4091644431c22acf9f8be6191b56805e0e977f15ca7104b4a6d6eaec"
|
||||
checksum = "e9689528bf3a9eb311fd938d05516dd546412f9ce4fffc8acfc1db27cc3dbf72"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"thiserror 1.0.61",
|
||||
@@ -7105,14 +6997,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rtp"
|
||||
version = "0.14.0"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f126f38ea84c02480e32e547c1459a939052f74fb92117ac3eef23fdac6b023"
|
||||
checksum = "c54733451a67d76caf9caa07a7a2cec6871ea9dda92a7847f98063d459200f4b"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"memchr",
|
||||
"portable-atomic",
|
||||
"rand 0.9.2",
|
||||
"rand 0.8.5",
|
||||
"serde 1.0.228",
|
||||
"thiserror 1.0.61",
|
||||
"webrtc-util",
|
||||
@@ -7177,7 +7069,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustdesk"
|
||||
version = "1.4.9"
|
||||
version = "1.5.0"
|
||||
dependencies = [
|
||||
"android-wakelock",
|
||||
"android_logger",
|
||||
@@ -7267,6 +7159,7 @@ dependencies = [
|
||||
"terminfo",
|
||||
"termios 0.3.3",
|
||||
"tiny-skia",
|
||||
"tokio",
|
||||
"totp-rs",
|
||||
"tray-icon",
|
||||
"ttf-parser",
|
||||
@@ -7287,7 +7180,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustdesk-portable-packer"
|
||||
version = "1.4.9"
|
||||
version = "1.5.0"
|
||||
dependencies = [
|
||||
"brotli",
|
||||
"dirs 5.0.1",
|
||||
@@ -7547,11 +7440,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sdp"
|
||||
version = "0.10.0"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32c374dceda16965d541c8800ce9cc4e1c14acfd661ddf7952feeedc3411e5c6"
|
||||
checksum = "4cd277015eada44a0bb810a4b84d3bf6e810573fa62fb442f457edf6a1087a69"
|
||||
dependencies = [
|
||||
"rand 0.9.2",
|
||||
"rand 0.8.5",
|
||||
"substring",
|
||||
"thiserror 1.0.61",
|
||||
"url",
|
||||
@@ -7781,15 +7674,6 @@ dependencies = [
|
||||
"tzdb 0.5.10",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shared_library"
|
||||
version = "0.1.9"
|
||||
@@ -8129,15 +8013,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "stun"
|
||||
version = "0.9.0"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a512c5d501e3e3b5a4bb3e8e31462d56d54a66b95a28b8596e14422bf21c32b"
|
||||
checksum = "7dbc2bab375524093c143dc362a03fb6a1fb79e938391cdb21665688f88a088a"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"crc",
|
||||
"lazy_static",
|
||||
"md-5",
|
||||
"rand 0.9.2",
|
||||
"rand 0.8.5",
|
||||
"ring",
|
||||
"subtle",
|
||||
"thiserror 1.0.61",
|
||||
@@ -8519,16 +8403,6 @@ dependencies = [
|
||||
"syn 2.0.98",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thread_local"
|
||||
version = "1.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c"
|
||||
dependencies = [
|
||||
"cfg-if 1.0.0",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "threadpool"
|
||||
version = "1.8.1"
|
||||
@@ -8908,32 +8782,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"valuable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-log"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-subscriber"
|
||||
version = "0.3.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008"
|
||||
dependencies = [
|
||||
"nu-ansi-term 0.46.0",
|
||||
"sharded-slab",
|
||||
"smallvec",
|
||||
"thread_local",
|
||||
"tracing-core",
|
||||
"tracing-log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9048,9 +8896,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "turn"
|
||||
version = "0.11.0"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ed995882f66ab94238de77c62e5e778389698ab700afa4696f4754da8f457cb"
|
||||
checksum = "3f5aea1116456e1da71c45586b87c72e3b43164fbf435eb93ff6aa475416a9a4"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -9058,7 +8906,7 @@ dependencies = [
|
||||
"log",
|
||||
"md-5",
|
||||
"portable-atomic",
|
||||
"rand 0.9.2",
|
||||
"rand 0.8.5",
|
||||
"ring",
|
||||
"stun",
|
||||
"thiserror 1.0.61",
|
||||
@@ -9184,12 +9032,6 @@ dependencies = [
|
||||
"unic-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-bidi"
|
||||
version = "0.3.15"
|
||||
@@ -9335,12 +9177,6 @@ dependencies = [
|
||||
"bindgen 0.65.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
@@ -9724,25 +9560,26 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "webrtc"
|
||||
version = "0.14.0"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08fd686c0920ac08f3a57eacc48e31f0e4ca1ffefba4478784606f78c14e83ad"
|
||||
checksum = "24bab7195998d605c862772f90a452ba655b90a2f463c850ac032038890e367a"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"dtls",
|
||||
"cfg-if 1.0.0",
|
||||
"hex",
|
||||
"interceptor",
|
||||
"lazy_static",
|
||||
"log",
|
||||
"portable-atomic",
|
||||
"rand 0.9.2",
|
||||
"rand 0.8.5",
|
||||
"rcgen",
|
||||
"regex",
|
||||
"ring",
|
||||
"rtcp",
|
||||
"rtp",
|
||||
"rustls",
|
||||
"sdp",
|
||||
"serde 1.0.228",
|
||||
"serde_json 1.0.118",
|
||||
@@ -9750,12 +9587,13 @@ dependencies = [
|
||||
"smol_str",
|
||||
"stun",
|
||||
"thiserror 1.0.61",
|
||||
"time 0.3.36",
|
||||
"tokio",
|
||||
"turn",
|
||||
"unicase",
|
||||
"url",
|
||||
"waitgroup",
|
||||
"webrtc-data",
|
||||
"webrtc-dtls",
|
||||
"webrtc-ice",
|
||||
"webrtc-mdns",
|
||||
"webrtc-media",
|
||||
@@ -9766,9 +9604,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "webrtc-data"
|
||||
version = "0.12.0"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "062a5438d63bb0756a221693d76cc0dd6119affee1dfdfe57abe3a2a8c8b3eea"
|
||||
checksum = "4e97b932854da633a767eff0cc805425a2222fc6481e96f463e57b015d949d1d"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"log",
|
||||
@@ -9780,17 +9618,54 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webrtc-ice"
|
||||
version = "0.14.0"
|
||||
name = "webrtc-dtls"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69cb13fd1a373e68addc4bba0c8ca058627518e54342583d024bdcbb8ae5d97d"
|
||||
checksum = "5ccbe4d9049390ab52695c3646c1395c877e16c15fb05d3bda8eee0c7351711c"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
"async-trait",
|
||||
"bincode",
|
||||
"byteorder",
|
||||
"cbc",
|
||||
"ccm",
|
||||
"der-parser",
|
||||
"hkdf",
|
||||
"hmac",
|
||||
"log",
|
||||
"p256",
|
||||
"p384",
|
||||
"portable-atomic",
|
||||
"rand 0.8.5",
|
||||
"rand_core 0.6.4",
|
||||
"rcgen",
|
||||
"ring",
|
||||
"rustls",
|
||||
"sec1",
|
||||
"serde 1.0.228",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"subtle",
|
||||
"thiserror 1.0.61",
|
||||
"tokio",
|
||||
"webrtc-util",
|
||||
"x25519-dalek",
|
||||
"x509-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webrtc-ice"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb51bde0d790f109a15bfe4d04f1b56fb51d567da231643cb3f21bb74d678997"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"async-trait",
|
||||
"crc",
|
||||
"log",
|
||||
"portable-atomic",
|
||||
"rand 0.9.2",
|
||||
"rand 0.8.5",
|
||||
"serde 1.0.228",
|
||||
"serde_json 1.0.118",
|
||||
"stun",
|
||||
@@ -9806,9 +9681,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "webrtc-mdns"
|
||||
version = "0.10.0"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a17279a067e75df72ce923fdeb7f04cd808f6f5aa4910dc6bcb4fbe66b396ace"
|
||||
checksum = "979cc85259c53b7b620803509d10d35e2546fa505d228850cbe3f08765ea6ea8"
|
||||
dependencies = [
|
||||
"log",
|
||||
"socket2 0.5.10",
|
||||
@@ -9819,22 +9694,21 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "webrtc-media"
|
||||
version = "0.11.0"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94a84c910fec0848fd5a0d8a5651e0ddbdedaf25a7d3ae3f0b15f71ac73a1773"
|
||||
checksum = "80041211deccda758a3e19aa93d6b10bc1d37c9183b519054b40a83691d13810"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"rand 0.9.2",
|
||||
"rand 0.8.5",
|
||||
"rtp",
|
||||
"thiserror 1.0.61",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webrtc-sctp"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f985465467d8910c1f8ac4382cd64f83b1f6a1a75021a82b221546f6fb3b856f"
|
||||
version = "0.12.0"
|
||||
source = "git+https://github.com/rustdesk-org/webrtc?rev=825a0a4862818f74406d8e1cc25be72259228235#825a0a4862818f74406d8e1cc25be72259228235"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"async-trait",
|
||||
@@ -9842,7 +9716,7 @@ dependencies = [
|
||||
"crc",
|
||||
"log",
|
||||
"portable-atomic",
|
||||
"rand 0.9.2",
|
||||
"rand 0.8.5",
|
||||
"thiserror 1.0.61",
|
||||
"tokio",
|
||||
"webrtc-util",
|
||||
@@ -9850,9 +9724,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "webrtc-srtp"
|
||||
version = "0.16.0"
|
||||
version = "0.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66d8cdc33413f1d0192670a80ce93d17cb78d57fe3a2414be30d6f6dff121123"
|
||||
checksum = "01e773f79b09b057ffbda6b03fe7b43403b012a240cf8d05d630674c3723b5bb"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"aes",
|
||||
@@ -9873,19 +9747,19 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "webrtc-util"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1c0c7e0c8f280f2bbfae442701465777ac07adaf46ce0c5863cd58e13fe472a"
|
||||
version = "0.11.0"
|
||||
source = "git+https://github.com/rustdesk-org/webrtc?rev=825a0a4862818f74406d8e1cc25be72259228235#825a0a4862818f74406d8e1cc25be72259228235"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bitflags 1.3.2",
|
||||
"bytes",
|
||||
"ipnet",
|
||||
"lazy_static",
|
||||
"libc",
|
||||
"log",
|
||||
"nix 0.26.4",
|
||||
"portable-atomic",
|
||||
"rand 0.9.2",
|
||||
"rand 0.8.5",
|
||||
"thiserror 1.0.61",
|
||||
"tokio",
|
||||
"winapi 0.3.9",
|
||||
|
||||
17
Cargo.toml
17
Cargo.toml
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rustdesk"
|
||||
version = "1.4.9"
|
||||
version = "1.5.0"
|
||||
authors = ["rustdesk <info@rustdesk.com>"]
|
||||
edition = "2021"
|
||||
build= "build.rs"
|
||||
@@ -52,7 +52,7 @@ screencapturekit = ["cpal/screencapturekit"]
|
||||
[dependencies]
|
||||
async-trait = "0.1"
|
||||
scrap = { path = "libs/scrap", features = ["wayland"] }
|
||||
hbb_common = { path = "libs/hbb_common" }
|
||||
hbb_common = { path = "libs/hbb_common", features = ["webrtc"] }
|
||||
serde_derive = "1.0"
|
||||
serde = "1.0"
|
||||
serde_json = "1.0"
|
||||
@@ -83,7 +83,7 @@ fon = "0.6"
|
||||
shutdown_hooks = "0.1"
|
||||
totp-rs = { version = "5.4", default-features = false, features = ["gen_secret", "otpauth"] }
|
||||
stunclient = "0.4"
|
||||
kcp-sys= { git = "https://github.com/rustdesk-org/kcp-sys"}
|
||||
kcp-sys= { git = "https://github.com/rustdesk-org/kcp-sys", branch = "rustdesk-patches" }
|
||||
reqwest = { version = "0.12", features = ["blocking", "socks", "json", "native-tls", "rustls-tls", "rustls-tls-native-roots", "gzip", "zstd"], default-features=false }
|
||||
|
||||
[target.'cfg(not(target_os = "linux"))'.dependencies]
|
||||
@@ -215,6 +215,16 @@ exclude = ["vdi/host"]
|
||||
# This allows building and running on systems without libxdo installed (e.g., Wayland-only)
|
||||
[patch.crates-io]
|
||||
libxdo-sys = { path = "libs/libxdo-sys-stub" }
|
||||
# One branch off upstream v0.13.0, the tag whose crate versions match this stack.
|
||||
# webrtc-util: reads the Windows adapter list's IPv6 addresses as host-order u16 groups, so every
|
||||
# one comes out byte-swapped, fails to bind, and ICE gathers no IPv6 host candidate on Windows.
|
||||
# webrtc-sctp: RFC 4960's 1s RTO floor makes a single loss cost 1-3s on a link whose RTT is 24-64ms,
|
||||
# and fast retransmit cannot cover a request/response exchange; INITIAL_MTU 1228 also fragments on
|
||||
# IPv6; and its AIMD pins a lossy long-haul link to MSS/(RTT*sqrt(p)), so a switch sends without
|
||||
# a congestion window, as KCP does - on by default, `allow-webrtc-congestion-control` opts back in.
|
||||
# Pinned by rev, not branch: a fork branch can be rewritten out from under the lockfile.
|
||||
webrtc-util = { git = "https://github.com/rustdesk-org/webrtc", rev = "825a0a4862818f74406d8e1cc25be72259228235" }
|
||||
webrtc-sctp = { git = "https://github.com/rustdesk-org/webrtc", rev = "825a0a4862818f74406d8e1cc25be72259228235" }
|
||||
|
||||
[package.metadata.winres]
|
||||
LegalCopyright = "Copyright © 2026 Purslane Tech Pte. Ltd. All rights reserved."
|
||||
@@ -234,6 +244,7 @@ os-version = "0.2"
|
||||
[dev-dependencies]
|
||||
hound = "3.5"
|
||||
docopt = "1.1"
|
||||
tokio = { version = "1.44", features = ["test-util"] }
|
||||
|
||||
[package.metadata.bundle]
|
||||
name = "RustDesk"
|
||||
|
||||
@@ -18,7 +18,7 @@ AppDir:
|
||||
id: rustdesk
|
||||
name: rustdesk
|
||||
icon: rustdesk
|
||||
version: 1.4.9
|
||||
version: 1.5.0
|
||||
exec: usr/share/rustdesk/rustdesk
|
||||
exec_args: $@
|
||||
apt:
|
||||
|
||||
@@ -18,7 +18,7 @@ AppDir:
|
||||
id: rustdesk
|
||||
name: rustdesk
|
||||
icon: rustdesk
|
||||
version: 1.4.9
|
||||
version: 1.5.0
|
||||
exec: usr/share/rustdesk/rustdesk
|
||||
exec_args: $@
|
||||
apt:
|
||||
|
||||
12
build.rs
12
build.rs
@@ -43,6 +43,15 @@ fn build_manifest() {
|
||||
}
|
||||
}
|
||||
|
||||
// bionic only exports getifaddrs()/freeifaddrs() from API 24, while the jniLibs
|
||||
// are built against the API 21 sysroot (flutter/ndk_*.sh). webrtc-util calls
|
||||
// them, so without this the android link fails on undefined symbols.
|
||||
fn build_android_ifaddrs() {
|
||||
let file = "src/platform/android_ifaddrs.c";
|
||||
cc::Build::new().file(file).compile("android_ifaddrs");
|
||||
println!("cargo:rerun-if-changed={}", file);
|
||||
}
|
||||
|
||||
fn install_android_deps() {
|
||||
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap();
|
||||
if target_os != "android" {
|
||||
@@ -89,5 +98,8 @@ fn main() {
|
||||
build_mac();
|
||||
println!("cargo:rustc-link-lib=framework=ApplicationServices");
|
||||
}
|
||||
if target_os == "android" {
|
||||
build_android_ifaddrs();
|
||||
}
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
}
|
||||
|
||||
@@ -82,7 +82,8 @@ protobuf {
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdkVersion 34
|
||||
namespace "com.carriez.flutter_hbb"
|
||||
compileSdkVersion 36
|
||||
sourceSets {
|
||||
main.java.srcDirs += 'src/main/kotlin'
|
||||
|
||||
@@ -91,6 +92,7 @@ android {
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
coreLibraryDesugaringEnabled true
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
@@ -99,7 +101,7 @@ android {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId "com.carriez.flutter_hbb"
|
||||
minSdkVersion 22
|
||||
targetSdkVersion 33
|
||||
targetSdkVersion 36
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
}
|
||||
@@ -128,6 +130,7 @@ flutter {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4'
|
||||
implementation 'com.google.protobuf:protobuf-javalite:3.20.1'
|
||||
implementation "androidx.media:media:1.6.0"
|
||||
implementation 'com.github.getActivity:XXPermissions:18.5'
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="com.carriez.flutter_hbb">
|
||||
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:node="remove" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" tools:node="remove" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" tools:node="remove" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
@@ -26,7 +30,6 @@
|
||||
android:name=".MainApplication"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="RustDesk"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:roundIcon="@mipmap/ic_launcher"
|
||||
android:supportsRtl="true">
|
||||
|
||||
@@ -88,7 +91,12 @@
|
||||
<service
|
||||
android:name=".MainService"
|
||||
android:enabled="true"
|
||||
android:foregroundServiceType="mediaProjection" />
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse|mediaProjection|microphone">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="@string/foreground_service_special_use_subtype" />
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".FloatingWindowService"
|
||||
|
||||
@@ -18,7 +18,33 @@ const val AUDIO_SAMPLE_RATE = 48000
|
||||
const val AUDIO_CHANNEL_MASK = AudioFormat.CHANNEL_IN_STEREO
|
||||
|
||||
class AudioRecordHandle(private var context: Context, private var isVideoStart: ()->Boolean, private var isAudioStart: ()->Boolean) {
|
||||
private val logTag = "LOG_AUDIO_RECORD_HANDLE"
|
||||
companion object {
|
||||
private const val LOG_TAG = "LOG_AUDIO_RECORD_HANDLE"
|
||||
private const val NO_ACTIVE_PUBLISHERS = 0
|
||||
private var activeAudioFramePublishers = NO_ACTIVE_PUBLISHERS
|
||||
|
||||
@Synchronized
|
||||
private fun acquireAudioFramePublisher() {
|
||||
if (activeAudioFramePublishers == NO_ACTIVE_PUBLISHERS) {
|
||||
FFI.setFrameRawEnable("audio", true)
|
||||
}
|
||||
activeAudioFramePublishers++
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun releaseAudioFramePublisher() {
|
||||
if (activeAudioFramePublishers == NO_ACTIVE_PUBLISHERS) {
|
||||
Log.e(LOG_TAG, "No active audio frame publisher to release")
|
||||
return
|
||||
}
|
||||
activeAudioFramePublishers--
|
||||
if (activeAudioFramePublishers == NO_ACTIVE_PUBLISHERS) {
|
||||
FFI.setFrameRawEnable("audio", false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val logTag = LOG_TAG
|
||||
|
||||
private var audioRecorder: AudioRecord? = null
|
||||
private var audioReader: AudioReader? = null
|
||||
@@ -79,48 +105,94 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart:
|
||||
return
|
||||
}
|
||||
// read f32 to byte , length * 4
|
||||
minBufferSize = 2 * 4 * AudioRecord.getMinBufferSize(
|
||||
val bufferSize = 2 * 4 * AudioRecord.getMinBufferSize(
|
||||
AUDIO_SAMPLE_RATE,
|
||||
AUDIO_CHANNEL_MASK,
|
||||
AUDIO_ENCODING
|
||||
)
|
||||
if (minBufferSize == 0) {
|
||||
if (bufferSize <= 0) {
|
||||
Log.d(logTag, "get min buffer size fail!")
|
||||
return
|
||||
}
|
||||
audioReader = AudioReader(minBufferSize, 4)
|
||||
audioReader = AudioReader(bufferSize, 4)
|
||||
minBufferSize = bufferSize
|
||||
Log.d(logTag, "init audioData len:$minBufferSize")
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
fun startAudioRecorder() {
|
||||
checkAudioReader()
|
||||
if (audioReader != null && audioRecorder != null && minBufferSize != 0) {
|
||||
try {
|
||||
FFI.setFrameRawEnable("audio", true)
|
||||
audioRecorder!!.startRecording()
|
||||
audioRecordStat = true
|
||||
audioThread = thread {
|
||||
while (audioRecordStat) {
|
||||
audioReader!!.readSync(audioRecorder!!)?.let {
|
||||
FFI.onAudioFrameUpdate(it)
|
||||
}
|
||||
}
|
||||
// let's release here rather than onDestroy to avoid threading issue
|
||||
audioRecorder?.release()
|
||||
audioRecorder = null
|
||||
minBufferSize = 0
|
||||
FFI.setFrameRawEnable("audio", false)
|
||||
Log.d(logTag, "Exit audio thread")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d(logTag, "startAudioRecorder fail:$e")
|
||||
private fun releaseRecorder(recorder: AudioRecord) {
|
||||
try {
|
||||
recorder.release()
|
||||
} finally {
|
||||
if (audioRecorder === recorder) {
|
||||
audioRecorder = null
|
||||
}
|
||||
} else {
|
||||
Log.d(logTag, "startAudioRecorder fail")
|
||||
}
|
||||
}
|
||||
|
||||
private fun captureAudio(reader: AudioReader, recorder: AudioRecord) {
|
||||
try {
|
||||
while (audioRecordStat) {
|
||||
reader.readSync(recorder)?.let {
|
||||
FFI.onAudioFrameUpdate(it)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
minBufferSize = 0
|
||||
try {
|
||||
releaseRecorder(recorder)
|
||||
} finally {
|
||||
releaseAudioFramePublisher()
|
||||
Log.d(logTag, "Exit audio thread")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
fun startAudioRecorder(): Boolean {
|
||||
val recorder = audioRecorder
|
||||
if (recorder == null) {
|
||||
Log.d(logTag, "startAudioRecorder fail")
|
||||
return false
|
||||
}
|
||||
var audioFramePublisherAcquired = false
|
||||
return try {
|
||||
checkAudioReader()
|
||||
val reader = audioReader
|
||||
if (reader == null || minBufferSize == 0) {
|
||||
releaseRecorder(recorder)
|
||||
Log.d(logTag, "startAudioRecorder fail")
|
||||
return false
|
||||
}
|
||||
recorder.startRecording()
|
||||
if (recorder.recordingState != AudioRecord.RECORDSTATE_RECORDING) {
|
||||
throw IllegalStateException("AudioRecord failed to enter recording state")
|
||||
}
|
||||
audioRecordStat = true
|
||||
val captureThread = thread(start = false) { captureAudio(reader, recorder) }
|
||||
acquireAudioFramePublisher()
|
||||
audioFramePublisherAcquired = true
|
||||
audioThread = captureThread
|
||||
captureThread.start()
|
||||
true
|
||||
} catch (error: Exception) {
|
||||
audioRecordStat = false
|
||||
audioThread = null
|
||||
Log.e(logTag, "startAudioRecorder fail", error)
|
||||
try {
|
||||
releaseRecorder(recorder)
|
||||
} finally {
|
||||
if (audioFramePublisherAcquired) {
|
||||
releaseAudioFramePublisher()
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun isVoiceCallActive(): Boolean {
|
||||
return audioRecorder?.audioSource == MediaRecorder.AudioSource.VOICE_COMMUNICATION
|
||||
}
|
||||
|
||||
fun onVoiceCallStarted(mediaProjection: MediaProjection?): Boolean {
|
||||
if (!isSupportVoiceCall()) {
|
||||
return false
|
||||
@@ -137,11 +209,9 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart:
|
||||
if (!isSupportVoiceCall()) {
|
||||
return true
|
||||
}
|
||||
if (isVideoStart()) {
|
||||
switchOutVoiceCall(mediaProjection)
|
||||
}
|
||||
val switched = !isVideoStart() || switchOutVoiceCall(mediaProjection)
|
||||
tryReleaseAudio()
|
||||
return true
|
||||
return switched
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
@@ -159,8 +229,7 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart:
|
||||
Log.e(logTag, "createAudioRecorder fail")
|
||||
return false
|
||||
}
|
||||
startAudioRecorder()
|
||||
return true
|
||||
return startAudioRecorder()
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
@@ -177,8 +246,7 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart:
|
||||
Log.e(logTag, "createAudioRecorder fail")
|
||||
return false
|
||||
}
|
||||
startAudioRecorder()
|
||||
return true
|
||||
return startAudioRecorder()
|
||||
}
|
||||
|
||||
fun tryReleaseAudio() {
|
||||
|
||||
@@ -9,6 +9,7 @@ package com.carriez.flutter_hbb
|
||||
|
||||
import ffi.FFI
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
@@ -24,6 +25,10 @@ import android.media.MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface
|
||||
import android.media.MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar
|
||||
import android.media.MediaCodecList
|
||||
import android.media.MediaFormat
|
||||
import android.net.Uri
|
||||
import android.provider.DocumentsContract
|
||||
import android.provider.OpenableColumns
|
||||
import android.webkit.MimeTypeMap
|
||||
import android.util.DisplayMetrics
|
||||
import androidx.annotation.RequiresApi
|
||||
import org.json.JSONArray
|
||||
@@ -33,6 +38,9 @@ import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import kotlin.concurrent.thread
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
|
||||
|
||||
class MainActivity : FlutterActivity() {
|
||||
@@ -46,6 +54,23 @@ class MainActivity : FlutterActivity() {
|
||||
private val channelTag = "mChannel"
|
||||
private val logTag = "mMainActivity"
|
||||
private var mainService: MainService? = null
|
||||
private sealed class PendingPicker {
|
||||
data class ImportFiles(val result: MethodChannel.Result) : PendingPicker()
|
||||
data class ExportFile(val source: File, val result: MethodChannel.Result) : PendingPicker()
|
||||
data class ImportDirectory(val result: MethodChannel.Result) : PendingPicker()
|
||||
data class ExportFiles(
|
||||
val sources: List<File>,
|
||||
val rejected: Int,
|
||||
val result: MethodChannel.Result
|
||||
) : PendingPicker()
|
||||
}
|
||||
|
||||
private data class ExportSource(
|
||||
val file: File,
|
||||
val children: List<ExportSource>?
|
||||
)
|
||||
|
||||
private var pendingPicker: PendingPicker? = null
|
||||
|
||||
private var isAudioStart = false
|
||||
private val audioRecordHandle = AudioRecordHandle(this, { false }, { isAudioStart })
|
||||
@@ -91,6 +116,108 @@ class MainActivity : FlutterActivity() {
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
if (requestCode == REQ_IMPORT_FILES) {
|
||||
val pending = pendingPicker as? PendingPicker.ImportFiles ?: return
|
||||
pendingPicker = null
|
||||
if (resultCode != Activity.RESULT_OK || data == null) {
|
||||
pending.result.success(emptyList<Map<String, String>>())
|
||||
return
|
||||
}
|
||||
|
||||
val uris = linkedSetOf<Uri>()
|
||||
data.data?.let { uris.add(it) }
|
||||
data.clipData?.let { clipData ->
|
||||
for (index in 0 until clipData.itemCount) {
|
||||
uris.add(clipData.getItemAt(index).uri)
|
||||
}
|
||||
}
|
||||
thread {
|
||||
val files = uris.map { uri ->
|
||||
mapOf(
|
||||
"uri" to uri.toString(),
|
||||
"name" to (displayName(uri) ?: uri.lastPathSegment.orEmpty())
|
||||
)
|
||||
}
|
||||
runOnUiThread { pending.result.success(files) }
|
||||
}
|
||||
return
|
||||
}
|
||||
if (requestCode == REQ_EXPORT_FILE) {
|
||||
val pending = pendingPicker as? PendingPicker.ExportFile ?: return
|
||||
pendingPicker = null
|
||||
val destination = data?.data
|
||||
|
||||
if (resultCode != Activity.RESULT_OK || destination == null) {
|
||||
pending.result.success(false)
|
||||
return
|
||||
}
|
||||
|
||||
thread {
|
||||
try {
|
||||
FileInputStream(pending.source).use { input ->
|
||||
contentResolver.openOutputStream(destination, "wt")?.use { output ->
|
||||
input.copyTo(output)
|
||||
} ?: throw IllegalStateException("Unable to open the selected destination")
|
||||
}
|
||||
runOnUiThread { pending.result.success(true) }
|
||||
} catch (e: Exception) {
|
||||
Log.e(logTag, "Failed to export file", e)
|
||||
runOnUiThread {
|
||||
pending.result.error("export_failed", e.message, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (requestCode == REQ_IMPORT_DIRECTORY) {
|
||||
val pending = pendingPicker as? PendingPicker.ImportDirectory ?: return
|
||||
pendingPicker = null
|
||||
val treeUri = data?.data
|
||||
if (resultCode != Activity.RESULT_OK || treeUri == null) {
|
||||
pending.result.success(null)
|
||||
return
|
||||
}
|
||||
thread {
|
||||
val selected = mapOf(
|
||||
"uri" to treeUri.toString(),
|
||||
"name" to (treeDisplayName(treeUri) ?: "Imported")
|
||||
)
|
||||
runOnUiThread { pending.result.success(selected) }
|
||||
}
|
||||
return
|
||||
}
|
||||
if (requestCode == REQ_EXPORT_FILES) {
|
||||
val pending = pendingPicker as? PendingPicker.ExportFiles ?: return
|
||||
pendingPicker = null
|
||||
val treeUri = data?.data
|
||||
if (resultCode != Activity.RESULT_OK || treeUri == null) {
|
||||
pending.result.success(null)
|
||||
return
|
||||
}
|
||||
thread {
|
||||
var exported = 0
|
||||
var failed = pending.rejected
|
||||
var processed = 0
|
||||
try {
|
||||
val sources = pending.sources.map { snapshotExportSource(it) }
|
||||
val rootDocId = DocumentsContract.getTreeDocumentId(treeUri)
|
||||
sources.forEach { source ->
|
||||
val ok = source?.let {
|
||||
copyExportSourceToTree(treeUri, rootDocId, it)
|
||||
} ?: false
|
||||
if (ok) exported++ else failed++
|
||||
processed++
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(logTag, "Failed to export selected files", e)
|
||||
failed += pending.sources.size - processed
|
||||
}
|
||||
runOnUiThread {
|
||||
pending.result.success(mapOf("exported" to exported, "failed" to failed))
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (requestCode == REQ_INVOKE_PERMISSION_ACTIVITY_MEDIA_PROJECTION && resultCode == RES_FAILED) {
|
||||
flutterMethodChannel?.invokeMethod("on_media_projection_canceled", null)
|
||||
}
|
||||
@@ -267,6 +394,242 @@ class MainActivity : FlutterActivity() {
|
||||
result.success(false)
|
||||
}
|
||||
}
|
||||
PICK_IMPORT_FILES -> {
|
||||
if (pendingPicker != null) {
|
||||
result.error("picker_in_progress", "Another document picker is already open", null)
|
||||
} else {
|
||||
pendingPicker = PendingPicker.ImportFiles(result)
|
||||
try {
|
||||
startActivityForResult(
|
||||
Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
type = "*/*"
|
||||
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
|
||||
},
|
||||
REQ_IMPORT_FILES
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
pendingPicker = null
|
||||
result.error("picker_unavailable", e.message, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
IMPORT_FILE -> {
|
||||
val arguments = call.arguments as? Map<*, *>
|
||||
val uri = (arguments?.get("uri") as? String)?.let {
|
||||
runCatching { Uri.parse(it) }.getOrNull()
|
||||
}
|
||||
val path = arguments?.get("path") as? String
|
||||
val overwrite = arguments?.get("overwrite") as? Boolean ?: false
|
||||
val destination = path?.let { canonicalAppScopedFile(it) }
|
||||
|
||||
if (uri?.scheme != "content") {
|
||||
result.error("invalid_uri", "The selected document URI is invalid", null)
|
||||
} else if (destination == null ||
|
||||
destination.isDirectory ||
|
||||
destination.parentFile?.isDirectory != true) {
|
||||
result.error("invalid_destination", "The destination is outside app-scoped storage", null)
|
||||
} else {
|
||||
thread {
|
||||
var temporary: File? = null
|
||||
var reservedDestination = false
|
||||
var errorCode = "import_failed"
|
||||
try {
|
||||
val temporaryFile = File.createTempFile(
|
||||
".rustdesk-import-",
|
||||
".tmp",
|
||||
destination.parentFile
|
||||
)
|
||||
temporary = temporaryFile
|
||||
contentResolver.openInputStream(uri)?.use { input ->
|
||||
FileOutputStream(temporaryFile).use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
} ?: throw IllegalStateException("Unable to open the selected document")
|
||||
if (!overwrite) {
|
||||
reservedDestination = destination.createNewFile()
|
||||
if (!reservedDestination) {
|
||||
throw IllegalStateException("The destination already exists")
|
||||
}
|
||||
}
|
||||
if (!temporaryFile.renameTo(destination)) {
|
||||
if (reservedDestination) {
|
||||
destination.delete()
|
||||
}
|
||||
errorCode = "rename_failed"
|
||||
throw IllegalStateException("Unable to replace the destination")
|
||||
}
|
||||
runOnUiThread { result.success(true) }
|
||||
} catch (e: Exception) {
|
||||
Log.e(logTag, "Failed to import file", e)
|
||||
runOnUiThread {
|
||||
result.error(errorCode, e.message, null)
|
||||
}
|
||||
} finally {
|
||||
temporary?.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
EXPORT_FILE -> {
|
||||
val path = (call.arguments as? Map<*, *>)?.get("path") as? String
|
||||
val source = path?.let { canonicalExportSource(it) }
|
||||
|
||||
if (source?.isFile != true) {
|
||||
result.error("invalid_source", "The file is outside app-scoped storage", null)
|
||||
} else if (pendingPicker != null) {
|
||||
result.error("picker_in_progress", "Another document picker is already open", null)
|
||||
} else {
|
||||
val mimeType = MimeTypeMap.getSingleton()
|
||||
.getMimeTypeFromExtension(source.extension.lowercase())
|
||||
?: "application/octet-stream"
|
||||
pendingPicker = PendingPicker.ExportFile(source, result)
|
||||
try {
|
||||
startActivityForResult(
|
||||
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
type = mimeType
|
||||
putExtra(Intent.EXTRA_TITLE, source.name)
|
||||
},
|
||||
REQ_EXPORT_FILE
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
pendingPicker = null
|
||||
result.error("picker_unavailable", e.message, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
PICK_IMPORT_DIRECTORY -> {
|
||||
if (pendingPicker != null) {
|
||||
result.error("picker_in_progress", "Another document picker is already open", null)
|
||||
} else {
|
||||
pendingPicker = PendingPicker.ImportDirectory(result)
|
||||
try {
|
||||
startActivityForResult(
|
||||
Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
|
||||
putExtra(Intent.EXTRA_TITLE, "Select the folder to import")
|
||||
},
|
||||
REQ_IMPORT_DIRECTORY
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
pendingPicker = null
|
||||
result.error("picker_unavailable", e.message, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
IMPORT_DIRECTORY -> {
|
||||
val arguments = call.arguments as? Map<*, *>
|
||||
val uri = (arguments?.get("uri") as? String)?.let {
|
||||
runCatching { Uri.parse(it) }.getOrNull()
|
||||
}
|
||||
val path = arguments?.get("path") as? String
|
||||
val overwrite = arguments?.get("overwrite") as? Boolean ?: false
|
||||
val destination = path?.let { canonicalAppScopedFile(it) }
|
||||
|
||||
if (uri?.scheme != "content") {
|
||||
result.error("invalid_uri", "The selected document URI is invalid", null)
|
||||
} else if (destination == null ||
|
||||
destination.parentFile?.isDirectory != true ||
|
||||
(destination.exists() && !destination.isDirectory)) {
|
||||
result.error("invalid_destination", "The destination is outside app-scoped storage", null)
|
||||
} else {
|
||||
thread {
|
||||
var temporary: File? = null
|
||||
var backup: File? = null
|
||||
val ok = try {
|
||||
val parent = destination.parentFile
|
||||
?: throw IllegalStateException("The destination has no parent")
|
||||
temporary = File.createTempFile(
|
||||
".rustdesk-import-dir-",
|
||||
".tmp",
|
||||
parent
|
||||
).also {
|
||||
if (!it.delete() || !it.mkdir()) {
|
||||
throw IllegalStateException("Unable to create a temporary folder")
|
||||
}
|
||||
}
|
||||
if (!copyDocumentTreeToFile(uri, temporary!!)) {
|
||||
throw IllegalStateException("Unable to read all folder contents")
|
||||
}
|
||||
if (destination.exists()) {
|
||||
if (!overwrite) {
|
||||
throw IllegalStateException("The destination already exists")
|
||||
}
|
||||
val backupFile = File.createTempFile(
|
||||
".rustdesk-import-backup-",
|
||||
".tmp",
|
||||
parent
|
||||
)
|
||||
if (!backupFile.delete()) {
|
||||
throw IllegalStateException("Unable to prepare the destination backup")
|
||||
}
|
||||
backup = backupFile
|
||||
if (!destination.renameTo(backupFile)) {
|
||||
throw IllegalStateException("Unable to replace the destination")
|
||||
}
|
||||
}
|
||||
if (!temporary!!.renameTo(destination)) {
|
||||
val destinationBackup = backup
|
||||
if (destinationBackup != null &&
|
||||
!destinationBackup.renameTo(destination)
|
||||
) {
|
||||
throw IllegalStateException(
|
||||
"Unable to move the imported folder and restore " +
|
||||
"the destination from $destinationBackup"
|
||||
)
|
||||
}
|
||||
throw IllegalStateException("Unable to move the imported folder")
|
||||
}
|
||||
temporary = null
|
||||
val destinationBackup = backup
|
||||
if (destinationBackup != null &&
|
||||
!destinationBackup.deleteRecursively()
|
||||
) {
|
||||
throw IllegalStateException(
|
||||
"Unable to remove the destination backup: $destinationBackup"
|
||||
)
|
||||
}
|
||||
backup = null
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(logTag, "Failed to import directory", e)
|
||||
false
|
||||
} finally {
|
||||
temporary?.deleteRecursively()
|
||||
}
|
||||
runOnUiThread { result.success(ok) }
|
||||
}
|
||||
}
|
||||
}
|
||||
EXPORT_FILES -> {
|
||||
val paths = (call.arguments as? Map<*, *>)?.get("paths") as? List<*>
|
||||
if (paths.isNullOrEmpty()) {
|
||||
result.error("invalid_source", "The selected files are outside app-scoped storage", null)
|
||||
} else {
|
||||
val sources = paths.mapNotNull {
|
||||
(it as? String)?.let(::canonicalExportSource)
|
||||
}
|
||||
val rejected = paths.size - sources.size
|
||||
if (sources.isEmpty()) {
|
||||
result.success(mapOf("exported" to 0, "failed" to rejected))
|
||||
} else if (pendingPicker != null) {
|
||||
result.error("picker_in_progress", "Another document picker is already open", null)
|
||||
} else {
|
||||
pendingPicker = PendingPicker.ExportFiles(sources, rejected, result)
|
||||
try {
|
||||
startActivityForResult(
|
||||
Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
|
||||
putExtra(Intent.EXTRA_TITLE, "Select the destination folder")
|
||||
},
|
||||
REQ_EXPORT_FILES
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
pendingPicker = null
|
||||
result.error("picker_unavailable", e.message, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
GET_VALUE -> {
|
||||
if (call.arguments is String) {
|
||||
if (call.arguments == KEY_IS_SUPPORT_VOICE_CALL) {
|
||||
@@ -291,6 +654,228 @@ class MainActivity : FlutterActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun canonicalAppScopedFile(path: String): File? {
|
||||
val file = runCatching { File(path).canonicalFile }.getOrNull() ?: return null
|
||||
val allowedRoots = listOfNotNull(filesDir, getExternalFilesDir(null)).mapNotNull {
|
||||
runCatching { it.canonicalFile }.getOrNull()
|
||||
}
|
||||
return file.takeIf { candidate ->
|
||||
allowedRoots.any { root ->
|
||||
candidate == root || candidate.path.startsWith(root.path + File.separator)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun canonicalExportSource(path: String): File? {
|
||||
val original = File(path).absoluteFile
|
||||
val canonical = canonicalAppScopedFile(path) ?: return null
|
||||
return canonical.takeIf {
|
||||
original.path == canonical.path && (canonical.isFile || canonical.isDirectory)
|
||||
}
|
||||
}
|
||||
|
||||
private fun snapshotExportSource(source: File): ExportSource? {
|
||||
val safeSource = canonicalExportSource(source.path) ?: return null
|
||||
if (safeSource.isFile) return ExportSource(safeSource, null)
|
||||
val sourceChildren = safeSource.listFiles() ?: return null
|
||||
val children = ArrayList<ExportSource>(sourceChildren.size)
|
||||
for (child in sourceChildren) {
|
||||
val snapshot = snapshotExportSource(child) ?: return null
|
||||
children.add(snapshot)
|
||||
}
|
||||
return ExportSource(safeSource, children)
|
||||
}
|
||||
|
||||
private fun copyExportSourceToTree(
|
||||
treeUri: Uri,
|
||||
parentDocId: String,
|
||||
source: ExportSource
|
||||
): Boolean {
|
||||
val children = source.children
|
||||
return if (children == null) {
|
||||
copyFileToTree(treeUri, parentDocId, source.file)
|
||||
} else {
|
||||
copyDirToTree(treeUri, parentDocId, source)
|
||||
}
|
||||
}
|
||||
|
||||
private fun treeDisplayName(treeUri: Uri): String? {
|
||||
return try {
|
||||
val rootDocId = DocumentsContract.getTreeDocumentId(treeUri)
|
||||
val docUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, rootDocId)
|
||||
contentResolver.query(
|
||||
docUri,
|
||||
arrayOf(DocumentsContract.Document.COLUMN_DISPLAY_NAME),
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)?.use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null }
|
||||
} catch (e: Exception) {
|
||||
Log.w(logTag, "Failed to read selected folder name", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyDocumentTreeToFile(treeUri: Uri, destinationDir: File): Boolean {
|
||||
val rootDocId = DocumentsContract.getTreeDocumentId(treeUri)
|
||||
return copyChildrenToFile(treeUri, rootDocId, destinationDir)
|
||||
}
|
||||
|
||||
private fun copyChildrenToFile(
|
||||
treeUri: Uri,
|
||||
parentDocId: String,
|
||||
destinationDir: File
|
||||
): Boolean {
|
||||
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, parentDocId)
|
||||
var ok = true
|
||||
val destinationNames = HashSet<String>()
|
||||
val cursor = contentResolver.query(childrenUri, childColumns, null, null, null)
|
||||
?: return false
|
||||
cursor.use {
|
||||
while (cursor.moveToNext()) {
|
||||
val docId = cursor.getString(0)
|
||||
val name = cursor.getString(1)
|
||||
val mime = cursor.getString(2)
|
||||
val docUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, docId)
|
||||
if (name != null && !destinationNames.add(name)) {
|
||||
ok = false
|
||||
continue
|
||||
}
|
||||
val destination = safeDestinationChild(destinationDir, name)
|
||||
if (destination == null || destination.exists()) {
|
||||
ok = false
|
||||
continue
|
||||
}
|
||||
if (mime == DocumentsContract.Document.MIME_TYPE_DIR) {
|
||||
if (!destination.mkdirs() && !destination.isDirectory) {
|
||||
ok = false
|
||||
continue
|
||||
}
|
||||
if (!copyChildrenToFile(treeUri, docId, destination)) {
|
||||
ok = false
|
||||
}
|
||||
} else if (!copyDocumentToFile(docUri, destination)) {
|
||||
ok = false
|
||||
}
|
||||
}
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
private fun safeDestinationChild(destinationDir: File, name: String?): File? {
|
||||
if (name.isNullOrEmpty() || name == "." || name == ".." ||
|
||||
name.indexOf('\u0000') >= 0 || name.contains('/') || name.contains('\\')) {
|
||||
return null
|
||||
}
|
||||
val parent = runCatching { destinationDir.canonicalFile }.getOrNull() ?: return null
|
||||
val child = runCatching { File(parent, name).canonicalFile }.getOrNull() ?: return null
|
||||
return child.takeIf { it.path.startsWith(parent.path + File.separator) }
|
||||
}
|
||||
|
||||
private fun copyDocumentToFile(uri: Uri, destination: File): Boolean {
|
||||
return try {
|
||||
destination.parentFile?.mkdirs()
|
||||
if (destination.exists() && !destination.delete()) {
|
||||
return false
|
||||
}
|
||||
contentResolver.openInputStream(uri)?.use { input ->
|
||||
FileOutputStream(destination).use { output -> input.copyTo(output) }
|
||||
} != null
|
||||
} catch (e: Exception) {
|
||||
Log.e(logTag, "Failed to copy document to $destination", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyFileToTree(treeUri: Uri, parentDocId: String, source: File): Boolean {
|
||||
val safeSource = canonicalExportSource(source.path)?.takeIf { it.isFile } ?: return false
|
||||
return try {
|
||||
val mime = MimeTypeMap.getSingleton()
|
||||
.getMimeTypeFromExtension(safeSource.extension.lowercase())
|
||||
?: "application/octet-stream"
|
||||
val parentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, parentDocId)
|
||||
val docUri = DocumentsContract.createDocument(
|
||||
contentResolver,
|
||||
parentUri,
|
||||
mime,
|
||||
safeSource.name
|
||||
) ?: return false
|
||||
contentResolver.openOutputStream(docUri, "wt")?.use { output ->
|
||||
FileInputStream(safeSource).use { input -> input.copyTo(output) }
|
||||
} ?: return false
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(logTag, "Failed to export file $safeSource", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyDirToTree(
|
||||
treeUri: Uri,
|
||||
parentDocId: String,
|
||||
source: ExportSource
|
||||
): Boolean {
|
||||
val children = source.children ?: return false
|
||||
val safeSource = canonicalExportSource(source.file.path)?.takeIf { it.isDirectory }
|
||||
?: return false
|
||||
val parentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, parentDocId)
|
||||
var dirDocId = findChildDocId(treeUri, parentDocId, safeSource.name)
|
||||
if (dirDocId == null) {
|
||||
dirDocId = try {
|
||||
DocumentsContract.createDocument(
|
||||
contentResolver,
|
||||
parentUri,
|
||||
DocumentsContract.Document.MIME_TYPE_DIR,
|
||||
safeSource.name
|
||||
)?.let { DocumentsContract.getDocumentId(it) }
|
||||
} catch (e: Exception) {
|
||||
Log.e(logTag, "Failed to create folder ${safeSource.name}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
if (dirDocId == null) return false
|
||||
|
||||
var ok = true
|
||||
children.forEach { child ->
|
||||
val childOk = copyExportSourceToTree(treeUri, dirDocId, child)
|
||||
if (!childOk) ok = false
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
private fun findChildDocId(treeUri: Uri, parentDocId: String, name: String): String? {
|
||||
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, parentDocId)
|
||||
val cursor = contentResolver.query(childrenUri, childColumns, null, null, null)
|
||||
?: throw IllegalStateException("Unable to query destination folder")
|
||||
cursor.use {
|
||||
while (cursor.moveToNext()) {
|
||||
if (cursor.getString(1) == name &&
|
||||
cursor.getString(2) == DocumentsContract.Document.MIME_TYPE_DIR
|
||||
) {
|
||||
return cursor.getString(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private val childColumns = arrayOf(
|
||||
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
|
||||
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
|
||||
DocumentsContract.Document.COLUMN_MIME_TYPE
|
||||
)
|
||||
|
||||
private fun displayName(uri: Uri): String? {
|
||||
return try {
|
||||
contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) cursor.getString(0) else null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(logTag, "Failed to read selected document name", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun setCodecInfo() {
|
||||
val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS)
|
||||
val codecs = codecList.codecInfos
|
||||
|
||||
@@ -17,6 +17,7 @@ import android.app.PendingIntent.FLAG_UPDATE_CURRENT
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.content.res.Configuration
|
||||
import android.content.res.Configuration.ORIENTATION_LANDSCAPE
|
||||
import android.graphics.Color
|
||||
@@ -150,7 +151,7 @@ class MainService : Service() {
|
||||
if (incomingVoiceCall) {
|
||||
voiceCallRequestNotification(id, "Voice Call Request", username, peerId)
|
||||
} else {
|
||||
if (!audioRecordHandle.switchOutVoiceCall(mediaProjection)) {
|
||||
if (!switchOutVoiceCall()) {
|
||||
Log.e(logTag, "switchOutVoiceCall fail")
|
||||
MainActivity.flutterMethodChannel?.invokeMethod("msgbox", mapOf(
|
||||
"type" to "custom-nook-nocancel-hasclose-error",
|
||||
@@ -159,7 +160,7 @@ class MainService : Service() {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (!audioRecordHandle.switchToVoiceCall(mediaProjection)) {
|
||||
if (!switchToVoiceCall()) {
|
||||
Log.e(logTag, "switchToVoiceCall fail")
|
||||
MainActivity.flutterMethodChannel?.invokeMethod("msgbox", mapOf(
|
||||
"type" to "custom-nook-nocancel-hasclose-error",
|
||||
@@ -214,6 +215,19 @@ class MainService : Service() {
|
||||
|
||||
// video
|
||||
private var mediaProjection: MediaProjection? = null
|
||||
private var mediaProjectionCallback: MediaProjection.Callback? = null
|
||||
private var captureRestartPending = false
|
||||
private var captureRestartInVoiceCall = false
|
||||
private val mediaProjectionResultReceiver =
|
||||
object : ResultReceiver(Handler(Looper.getMainLooper())) {
|
||||
override fun onReceiveResult(resultCode: Int, resultData: Bundle?) {
|
||||
if (resultCode == RES_FAILED) {
|
||||
cancelMediaProjectionRecovery()
|
||||
}
|
||||
}
|
||||
}
|
||||
private var mediaProjectionForegroundService = false
|
||||
private var microphoneForegroundService = false
|
||||
private var surface: Surface? = null
|
||||
private val sendVP9Thread = Executors.newSingleThreadExecutor()
|
||||
private var videoEncoder: MediaCodec? = null
|
||||
@@ -243,7 +257,9 @@ class MainService : Service() {
|
||||
// keep the config dir same with flutter
|
||||
val prefs = applicationContext.getSharedPreferences(KEY_SHARED_PREFERENCES, FlutterActivity.MODE_PRIVATE)
|
||||
val configPath = prefs.getString(KEY_APP_DIR_CONFIG_PATH, "") ?: ""
|
||||
FFI.startServer(configPath, "")
|
||||
val homePath = applicationContext.getExternalFilesDir(null)?.absolutePath
|
||||
?: applicationContext.filesDir.absolutePath
|
||||
FFI.startServer(configPath, homePath, "")
|
||||
|
||||
createForegroundNotification()
|
||||
}
|
||||
@@ -337,8 +353,6 @@ class MainService : Service() {
|
||||
Log.d("whichService", "this service: ${Thread.currentThread()}")
|
||||
super.onStartCommand(intent, flags, startId)
|
||||
if (intent?.action == ACT_INIT_MEDIA_PROJECTION_AND_SERVICE) {
|
||||
createForegroundNotification()
|
||||
|
||||
if (intent.getBooleanExtra(EXT_INIT_FROM_BOOT, false)) {
|
||||
FFI.startService()
|
||||
}
|
||||
@@ -347,10 +361,7 @@ class MainService : Service() {
|
||||
getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
|
||||
|
||||
intent.getParcelableExtra<Intent>(EXT_MEDIA_PROJECTION_RES_INTENT)?.let {
|
||||
mediaProjection =
|
||||
mediaProjectionManager.getMediaProjection(Activity.RESULT_OK, it)
|
||||
checkMediaPermission()
|
||||
_isReady = true
|
||||
replaceMediaProjection(mediaProjectionManager, it)
|
||||
} ?: let {
|
||||
Log.d(logTag, "getParcelableExtra intent null, invoke requestMediaProjection")
|
||||
requestMediaProjection()
|
||||
@@ -364,14 +375,23 @@ class MainService : Service() {
|
||||
updateScreenInfo(newConfig.orientation)
|
||||
}
|
||||
|
||||
private fun requestMediaProjection() {
|
||||
private fun requestMediaProjection(recovery: Boolean = false) {
|
||||
val intent = Intent(this, PermissionRequestTransparentActivity::class.java).apply {
|
||||
action = ACT_REQUEST_MEDIA_PROJECTION
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
if (recovery) {
|
||||
putExtra(EXT_MEDIA_PROJECTION_RESULT_RECEIVER, mediaProjectionResultReceiver)
|
||||
}
|
||||
}
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun cancelMediaProjectionRecovery() {
|
||||
captureRestartPending = false
|
||||
captureRestartInVoiceCall = false
|
||||
}
|
||||
|
||||
@SuppressLint("WrongConstant")
|
||||
private fun createSurface(): Surface? {
|
||||
return if (useVP9) {
|
||||
@@ -405,15 +425,149 @@ class MainService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
fun onVoiceCallStarted(): Boolean {
|
||||
return audioRecordHandle.onVoiceCallStarted(mediaProjection)
|
||||
private fun releaseMediaProjection() {
|
||||
val projection = mediaProjection
|
||||
val callback = mediaProjectionCallback
|
||||
mediaProjection = null
|
||||
mediaProjectionCallback = null
|
||||
if (projection != null && callback != null) {
|
||||
projection.unregisterCallback(callback)
|
||||
}
|
||||
projection?.stop()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun handleMediaProjectionStopped(stoppedProjection: MediaProjection) {
|
||||
if (mediaProjection !== stoppedProjection) {
|
||||
return
|
||||
}
|
||||
Log.d(logTag, "MediaProjection stopped")
|
||||
setMediaProjectionForegroundService(false)
|
||||
stopCapture()
|
||||
virtualDisplay?.release()
|
||||
virtualDisplay = null
|
||||
mediaProjection = null
|
||||
mediaProjectionCallback = null
|
||||
_isReady = false
|
||||
checkMediaPermission()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun replaceMediaProjection(
|
||||
mediaProjectionManager: MediaProjectionManager,
|
||||
resultIntent: Intent,
|
||||
) {
|
||||
val wasCapturing = isStart
|
||||
val restartCapture = wasCapturing || captureRestartPending
|
||||
val restartInVoiceCall = if (wasCapturing) {
|
||||
audioRecordHandle.isVoiceCallActive()
|
||||
} else {
|
||||
captureRestartInVoiceCall
|
||||
}
|
||||
val hadProjection = mediaProjection != null
|
||||
if (!setMediaProjectionForegroundService(true)) {
|
||||
if (!hadProjection) {
|
||||
cancelMediaProjectionRecovery()
|
||||
_isReady = false
|
||||
checkMediaPermission()
|
||||
}
|
||||
return
|
||||
}
|
||||
val projection =
|
||||
mediaProjectionManager.getMediaProjection(Activity.RESULT_OK, resultIntent)
|
||||
if (projection == null) {
|
||||
if (!hadProjection) {
|
||||
cancelMediaProjectionRecovery()
|
||||
_isReady = false
|
||||
setMediaProjectionForegroundService(false)
|
||||
checkMediaPermission()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (wasCapturing) {
|
||||
stopCapture()
|
||||
}
|
||||
captureRestartPending = restartCapture
|
||||
virtualDisplay?.release()
|
||||
virtualDisplay = null
|
||||
releaseMediaProjection()
|
||||
val callback = object : MediaProjection.Callback() {
|
||||
override fun onStop() {
|
||||
handleMediaProjectionStopped(projection)
|
||||
}
|
||||
}
|
||||
projection.registerCallback(callback, Handler(Looper.getMainLooper()))
|
||||
mediaProjection = projection
|
||||
mediaProjectionCallback = callback
|
||||
_isReady = true
|
||||
checkMediaPermission()
|
||||
if (restartCapture) {
|
||||
captureRestartPending = false
|
||||
startCapture(restartInVoiceCall)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun startMicrophoneCapture(startAudio: () -> Boolean): Boolean {
|
||||
if (!setMicrophoneForegroundService(true)) {
|
||||
return false
|
||||
}
|
||||
if (startAudio()) {
|
||||
return true
|
||||
}
|
||||
setMicrophoneForegroundService(false)
|
||||
return false
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun stopMicrophoneCapture(stopAudio: () -> Boolean): Boolean {
|
||||
val stopped = stopAudio()
|
||||
val foregroundServiceUpdated = setMicrophoneForegroundService(false)
|
||||
return stopped && foregroundServiceUpdated
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun switchToVoiceCall(): Boolean {
|
||||
if (captureRestartPending) {
|
||||
captureRestartInVoiceCall = true
|
||||
}
|
||||
return startMicrophoneCapture {
|
||||
audioRecordHandle.switchToVoiceCall(mediaProjection)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun switchOutVoiceCall(): Boolean {
|
||||
captureRestartInVoiceCall = false
|
||||
val switched = audioRecordHandle.switchOutVoiceCall(mediaProjection)
|
||||
val foregroundServiceUpdated = setMicrophoneForegroundService(false)
|
||||
return switched && foregroundServiceUpdated
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun onVoiceCallStarted(): Boolean {
|
||||
if (captureRestartPending) {
|
||||
captureRestartInVoiceCall = true
|
||||
}
|
||||
return startMicrophoneCapture {
|
||||
audioRecordHandle.onVoiceCallStarted(mediaProjection)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun onVoiceCallClosed(): Boolean {
|
||||
return audioRecordHandle.onVoiceCallClosed(mediaProjection)
|
||||
captureRestartInVoiceCall = false
|
||||
return stopMicrophoneCapture {
|
||||
audioRecordHandle.onVoiceCallClosed(mediaProjection)
|
||||
}
|
||||
}
|
||||
|
||||
fun startCapture(): Boolean {
|
||||
return startCapture(false)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun startCapture(inVoiceCall: Boolean): Boolean {
|
||||
if (isStart) {
|
||||
return true
|
||||
}
|
||||
@@ -421,25 +575,35 @@ class MainService : Service() {
|
||||
Log.w(logTag, "startCapture fail,mediaProjection is null")
|
||||
return false
|
||||
}
|
||||
captureRestartInVoiceCall = inVoiceCall
|
||||
|
||||
updateScreenInfo(resources.configuration.orientation)
|
||||
Log.d(logTag, "Start Capture")
|
||||
surface = createSurface()
|
||||
|
||||
if (useVP9) {
|
||||
val videoStarted = if (useVP9) {
|
||||
startVP9VideoRecorder(mediaProjection!!)
|
||||
} else {
|
||||
startRawVideoRecorder(mediaProjection!!)
|
||||
}
|
||||
if (!videoStarted) {
|
||||
if (!captureRestartPending) {
|
||||
captureRestartInVoiceCall = false
|
||||
}
|
||||
releaseFailedVideoCapture()
|
||||
return false
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
if (!audioRecordHandle.createAudioRecorder(false, mediaProjection)) {
|
||||
Log.d(logTag, "createAudioRecorder fail")
|
||||
val audioStarted = if (inVoiceCall) {
|
||||
switchToVoiceCall()
|
||||
} else {
|
||||
Log.d(logTag, "audio recorder start")
|
||||
audioRecordHandle.startAudioRecorder()
|
||||
audioRecordHandle.createAudioRecorder(false, mediaProjection) &&
|
||||
audioRecordHandle.startAudioRecorder()
|
||||
}
|
||||
Log.d(logTag, if (audioStarted) "audio recorder start" else "audio recorder start failed")
|
||||
}
|
||||
captureRestartInVoiceCall = false
|
||||
checkMediaPermission()
|
||||
_isStart = true
|
||||
FFI.setFrameRawEnable("video",true)
|
||||
@@ -447,9 +611,24 @@ class MainService : Service() {
|
||||
return true
|
||||
}
|
||||
|
||||
private fun releaseFailedVideoCapture() {
|
||||
imageReader?.close()
|
||||
imageReader = null
|
||||
videoEncoder?.let {
|
||||
it.signalEndOfInputStream()
|
||||
it.stop()
|
||||
it.release()
|
||||
}
|
||||
videoEncoder = null
|
||||
surface?.release()
|
||||
surface = null
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun stopCapture() {
|
||||
Log.d(logTag, "Stop Capture")
|
||||
captureRestartPending = false
|
||||
captureRestartInVoiceCall = false
|
||||
FFI.setFrameRawEnable("video",false)
|
||||
_isStart = false
|
||||
MainActivity.rdClipboardManager?.setCaptureStarted(_isStart)
|
||||
@@ -480,8 +659,11 @@ class MainService : Service() {
|
||||
surface?.release()
|
||||
|
||||
// release audio
|
||||
_isAudioStart = false
|
||||
audioRecordHandle.tryReleaseAudio()
|
||||
stopMicrophoneCapture {
|
||||
_isAudioStart = false
|
||||
audioRecordHandle.tryReleaseAudio()
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
@@ -496,7 +678,9 @@ class MainService : Service() {
|
||||
virtualDisplay = null
|
||||
}
|
||||
|
||||
mediaProjection = null
|
||||
releaseMediaProjection()
|
||||
mediaProjectionForegroundService = false
|
||||
microphoneForegroundService = false
|
||||
checkMediaPermission()
|
||||
stopForeground(true)
|
||||
stopService(Intent(this, FloatingWindowService::class.java))
|
||||
@@ -519,49 +703,70 @@ class MainService : Service() {
|
||||
return isReady
|
||||
}
|
||||
|
||||
private fun startRawVideoRecorder(mp: MediaProjection) {
|
||||
private fun startRawVideoRecorder(mp: MediaProjection): Boolean {
|
||||
Log.d(logTag, "startRawVideoRecorder,screen info:$SCREEN_INFO")
|
||||
if (surface == null) {
|
||||
val captureSurface = surface
|
||||
if (captureSurface == null) {
|
||||
Log.d(logTag, "startRawVideoRecorder failed,surface is null")
|
||||
return
|
||||
return false
|
||||
}
|
||||
createOrSetVirtualDisplay(mp, surface!!)
|
||||
return createOrSetVirtualDisplay(mp, captureSurface)
|
||||
}
|
||||
|
||||
private fun startVP9VideoRecorder(mp: MediaProjection) {
|
||||
private fun startVP9VideoRecorder(mp: MediaProjection): Boolean {
|
||||
createMediaCodec()
|
||||
videoEncoder?.let {
|
||||
surface = it.createInputSurface()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
surface!!.setFrameRate(1F, FRAME_RATE_COMPATIBILITY_DEFAULT)
|
||||
}
|
||||
it.setCallback(cb)
|
||||
it.start()
|
||||
createOrSetVirtualDisplay(mp, surface!!)
|
||||
val encoder = videoEncoder ?: return false
|
||||
val inputSurface = encoder.createInputSurface()
|
||||
surface = inputSurface
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
inputSurface.setFrameRate(1F, FRAME_RATE_COMPATIBILITY_DEFAULT)
|
||||
}
|
||||
encoder.setCallback(cb)
|
||||
encoder.start()
|
||||
return createOrSetVirtualDisplay(mp, inputSurface)
|
||||
}
|
||||
|
||||
// https://github.com/bk138/droidVNC-NG/blob/b79af62db5a1c08ed94e6a91464859ffed6f4e97/app/src/main/java/net/christianbeier/droidvnc_ng/MediaProjectionService.java#L250
|
||||
// Reuse virtualDisplay if it exists, to avoid media projection confirmation dialog every connection.
|
||||
private fun createOrSetVirtualDisplay(mp: MediaProjection, s: Surface) {
|
||||
try {
|
||||
virtualDisplay?.let {
|
||||
it.resize(SCREEN_INFO.width, SCREEN_INFO.height, SCREEN_INFO.dpi)
|
||||
it.setSurface(s)
|
||||
} ?: let {
|
||||
virtualDisplay = mp.createVirtualDisplay(
|
||||
private fun createOrSetVirtualDisplay(mp: MediaProjection, s: Surface): Boolean {
|
||||
return try {
|
||||
val existingDisplay = virtualDisplay
|
||||
if (existingDisplay != null) {
|
||||
existingDisplay.resize(SCREEN_INFO.width, SCREEN_INFO.height, SCREEN_INFO.dpi)
|
||||
existingDisplay.setSurface(s)
|
||||
true
|
||||
} else {
|
||||
val display = mp.createVirtualDisplay(
|
||||
"RustDeskVD",
|
||||
SCREEN_INFO.width, SCREEN_INFO.height, SCREEN_INFO.dpi, VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
|
||||
s, null, null
|
||||
)
|
||||
if (display == null) {
|
||||
Log.e(logTag, "createOrSetVirtualDisplay failed")
|
||||
handleVirtualDisplayFailure()
|
||||
} else {
|
||||
virtualDisplay = display
|
||||
true
|
||||
}
|
||||
}
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(logTag, "createOrSetVirtualDisplay: got SecurityException, re-requesting confirmation");
|
||||
// This initiates a prompt dialog for the user to confirm screen projection.
|
||||
requestMediaProjection()
|
||||
Log.w(logTag, "createOrSetVirtualDisplay: got SecurityException", e)
|
||||
handleVirtualDisplayFailure()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleVirtualDisplayFailure(): Boolean {
|
||||
captureRestartPending = true
|
||||
virtualDisplay?.release()
|
||||
virtualDisplay = null
|
||||
releaseMediaProjection()
|
||||
setMediaProjectionForegroundService(false)
|
||||
_isReady = false
|
||||
checkMediaPermission()
|
||||
requestMediaProjection(true)
|
||||
return false
|
||||
}
|
||||
|
||||
private val cb: MediaCodec.Callback = object : MediaCodec.Callback() {
|
||||
override fun onInputBufferAvailable(codec: MediaCodec, index: Int) {}
|
||||
override fun onOutputFormatChanged(codec: MediaCodec, format: MediaFormat) {}
|
||||
@@ -652,7 +857,63 @@ class MainService : Service() {
|
||||
.setColor(ContextCompat.getColor(this, R.color.primary))
|
||||
.setWhen(System.currentTimeMillis())
|
||||
.build()
|
||||
startForeground(DEFAULT_NOTIFY_ID, notification)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(DEFAULT_NOTIFY_ID, notification, foregroundServiceType())
|
||||
} else {
|
||||
startForeground(DEFAULT_NOTIFY_ID, notification)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
private fun foregroundServiceType(): Int {
|
||||
var serviceType = ServiceInfo.FOREGROUND_SERVICE_TYPE_NONE
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
// Keep a valid FGS type while the unattended host is idle and no capture type is active.
|
||||
serviceType = serviceType or ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
|
||||
}
|
||||
if (mediaProjectionForegroundService) {
|
||||
serviceType = serviceType or ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && microphoneForegroundService) {
|
||||
serviceType = serviceType or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
|
||||
}
|
||||
return serviceType
|
||||
}
|
||||
|
||||
private fun setMediaProjectionForegroundService(enabled: Boolean): Boolean {
|
||||
return updateForegroundServiceTypes(enabled, microphoneForegroundService)
|
||||
}
|
||||
|
||||
private fun setMicrophoneForegroundService(enabled: Boolean): Boolean {
|
||||
return updateForegroundServiceTypes(mediaProjectionForegroundService, enabled)
|
||||
}
|
||||
|
||||
private fun updateForegroundServiceTypes(
|
||||
mediaProjectionEnabled: Boolean,
|
||||
microphoneEnabled: Boolean,
|
||||
): Boolean {
|
||||
if (mediaProjectionForegroundService == mediaProjectionEnabled &&
|
||||
microphoneForegroundService == microphoneEnabled) {
|
||||
return true
|
||||
}
|
||||
val previousMediaProjection = mediaProjectionForegroundService
|
||||
val previousMicrophone = microphoneForegroundService
|
||||
mediaProjectionForegroundService = mediaProjectionEnabled
|
||||
microphoneForegroundService = microphoneEnabled
|
||||
return try {
|
||||
createForegroundNotification()
|
||||
true
|
||||
} catch (error: SecurityException) {
|
||||
mediaProjectionForegroundService = previousMediaProjection
|
||||
microphoneForegroundService = previousMicrophone
|
||||
Log.e(logTag, "Failed to update foreground service types", error)
|
||||
false
|
||||
} catch (error: IllegalStateException) {
|
||||
mediaProjectionForegroundService = previousMediaProjection
|
||||
microphoneForegroundService = previousMicrophone
|
||||
Log.e(logTag, "Failed to update foreground service types", error)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun loginRequestNotification(
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.content.Intent
|
||||
import android.media.projection.MediaProjectionManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.ResultReceiver
|
||||
import android.util.Log
|
||||
|
||||
class PermissionRequestTransparentActivity: Activity() {
|
||||
@@ -31,7 +32,13 @@ class PermissionRequestTransparentActivity: Activity() {
|
||||
if (resultCode == RESULT_OK && data != null) {
|
||||
launchService(data)
|
||||
} else {
|
||||
setResult(RES_FAILED)
|
||||
val resultReceiver =
|
||||
intent.getParcelableExtra<ResultReceiver>(EXT_MEDIA_PROJECTION_RESULT_RECEIVER)
|
||||
if (resultReceiver != null) {
|
||||
resultReceiver.send(RES_FAILED, null)
|
||||
} else {
|
||||
setResult(RES_FAILED)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,4 +58,4 @@ class PermissionRequestTransparentActivity: Activity() {
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,11 +33,16 @@ const val ACT_INIT_MEDIA_PROJECTION_AND_SERVICE = "INIT_MEDIA_PROJECTION_AND_SER
|
||||
const val ACT_LOGIN_REQ_NOTIFY = "LOGIN_REQ_NOTIFY"
|
||||
const val EXT_INIT_FROM_BOOT = "EXT_INIT_FROM_BOOT"
|
||||
const val EXT_MEDIA_PROJECTION_RES_INTENT = "MEDIA_PROJECTION_RES_INTENT"
|
||||
const val EXT_MEDIA_PROJECTION_RESULT_RECEIVER = "MEDIA_PROJECTION_RESULT_RECEIVER"
|
||||
const val EXT_LOGIN_REQ_NOTIFY = "LOGIN_REQ_NOTIFY"
|
||||
|
||||
// Activity requestCode
|
||||
const val REQ_INVOKE_PERMISSION_ACTIVITY_MEDIA_PROJECTION = 101
|
||||
const val REQ_REQUEST_MEDIA_PROJECTION = 201
|
||||
const val REQ_EXPORT_FILE = 301
|
||||
const val REQ_IMPORT_FILES = 302
|
||||
const val REQ_IMPORT_DIRECTORY = 303
|
||||
const val REQ_EXPORT_FILES = 304
|
||||
|
||||
// Activity responseCode
|
||||
const val RES_FAILED = -100
|
||||
@@ -47,6 +52,12 @@ const val START_ACTION = "start_action"
|
||||
const val GET_START_ON_BOOT_OPT = "get_start_on_boot_opt"
|
||||
const val SET_START_ON_BOOT_OPT = "set_start_on_boot_opt"
|
||||
const val SYNC_APP_DIR_CONFIG_PATH = "sync_app_dir"
|
||||
const val PICK_IMPORT_FILES = "pick_import_files"
|
||||
const val IMPORT_FILE = "import_file"
|
||||
const val EXPORT_FILE = "export_file"
|
||||
const val PICK_IMPORT_DIRECTORY = "pick_import_directory"
|
||||
const val IMPORT_DIRECTORY = "import_directory"
|
||||
const val EXPORT_FILES = "export_files"
|
||||
const val GET_VALUE = "get_value"
|
||||
|
||||
const val KEY_IS_SUPPORT_VOICE_CALL = "KEY_IS_SUPPORT_VOICE_CALL"
|
||||
@@ -154,4 +165,4 @@ fun getScreenSize(windowManager: WindowManager) : Pair<Int, Int>{
|
||||
fun translate(input: String): String {
|
||||
Log.d("common", "translate:$LOCAL_NAME")
|
||||
return FFI.translateLocale(LOCAL_NAME, input)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ object FFI {
|
||||
external fun init(ctx: Context)
|
||||
external fun onAppStart(ctx: Context)
|
||||
external fun setClipboardManager(clipboardManager: RdClipboardManager)
|
||||
external fun startServer(app_dir: String, custom_client_config: String)
|
||||
external fun startServer(app_dir: String, home_dir: String, custom_client_config: String)
|
||||
external fun startService()
|
||||
external fun onVideoFrameUpdate(buf: ByteBuffer)
|
||||
external fun onAudioFrameUpdate(buf: ByteBuffer)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<resources>
|
||||
<string name="app_name">RustDesk</string>
|
||||
<string name="accessibility_service_description">Allow other devices to control your phone using virtual touch, when RustDesk screen sharing is established</string>
|
||||
<string name="foreground_service_special_use_subtype">Keeps the RustDesk remote desktop host available for authorized unattended connections and foreground notifications without starting screen capture before user approval.</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,3 +1,29 @@
|
||||
def legacyPluginNamespaces = [
|
||||
external_path: 'com.pinciat.external_path',
|
||||
flutter_keyboard_visibility: 'com.jrai.flutter_keyboard_visibility',
|
||||
qr_code_scanner: 'net.touchcapture.qr.flutterqr',
|
||||
sqflite: 'com.tekartik.sqflite',
|
||||
uni_links: 'name.avioli.unilinks',
|
||||
]
|
||||
|
||||
def java8JvmTarget = JavaVersion.VERSION_1_8.toString()
|
||||
def java8KotlinJvmTargets = [
|
||||
app: java8JvmTarget,
|
||||
external_path: java8JvmTarget,
|
||||
qr_code_scanner: java8JvmTarget,
|
||||
]
|
||||
|
||||
def configureKotlinJvmTarget = { Project project, String kotlinJvmTarget ->
|
||||
project.plugins.withId('kotlin-android') {
|
||||
project.tasks.configureEach { task ->
|
||||
if (!task.hasProperty('kotlinOptions')) {
|
||||
return
|
||||
}
|
||||
task.kotlinOptions.jvmTarget = kotlinJvmTarget
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
@@ -9,6 +35,16 @@ allprojects {
|
||||
rootProject.buildDir = '../build'
|
||||
subprojects {
|
||||
project.buildDir = "${rootProject.buildDir}/${project.name}"
|
||||
def legacyNamespace = legacyPluginNamespaces[project.name]
|
||||
if (legacyNamespace != null) {
|
||||
project.plugins.withId('com.android.library') {
|
||||
project.android.namespace = legacyNamespace
|
||||
}
|
||||
}
|
||||
def kotlinJvmTarget = java8KotlinJvmTargets[project.name]
|
||||
if (kotlinJvmTarget != null) {
|
||||
configureKotlinJvmTarget(project, kotlinJvmTarget)
|
||||
}
|
||||
}
|
||||
subprojects {
|
||||
project.evaluationDependsOn(':app')
|
||||
|
||||
@@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.4-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
|
||||
|
||||
@@ -18,7 +18,7 @@ pluginManagement {
|
||||
|
||||
plugins {
|
||||
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
||||
id "com.android.application" version "7.3.1" apply false
|
||||
id "com.android.application" version "8.10.1" apply false
|
||||
id "org.jetbrains.kotlin.android" version "2.1.21" apply false
|
||||
}
|
||||
|
||||
|
||||
@@ -1519,13 +1519,6 @@ class AndroidPermissionManager {
|
||||
static Timer? _timer;
|
||||
static var _current = "";
|
||||
|
||||
static bool isWaitingFile() {
|
||||
if (_completer != null) {
|
||||
return !_completer!.isCompleted && _current == kManageExternalStorage;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static Future<bool> check(String type) {
|
||||
if (isDesktop || isWeb) {
|
||||
return Future.value(true);
|
||||
@@ -1640,7 +1633,8 @@ String bool2option(String option, bool b) {
|
||||
String res;
|
||||
if (option.startsWith('enable-') &&
|
||||
option != kOptionEnableUdpPunch &&
|
||||
option != kOptionEnableIpv6Punch) {
|
||||
option != kOptionEnableIpv6Punch &&
|
||||
option != kOptionEnableWebrtc) {
|
||||
res = b ? defaultOptionYes : 'N';
|
||||
} else if (option.startsWith('allow-') ||
|
||||
option == kOptionStopService ||
|
||||
@@ -2634,13 +2628,6 @@ connect(BuildContext context, String id,
|
||||
}
|
||||
} else {
|
||||
if (isFileTransfer) {
|
||||
if (isAndroid) {
|
||||
if (!await AndroidPermissionManager.check(kManageExternalStorage)) {
|
||||
if (!await AndroidPermissionManager.request(kManageExternalStorage)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isWeb) {
|
||||
Navigator.push(
|
||||
context,
|
||||
|
||||
@@ -606,6 +606,9 @@ class QualityMonitor extends StatelessWidget {
|
||||
_row(
|
||||
"Codec", qualityMonitorModel.data.codecFormat ?? '-'),
|
||||
_row("Chroma", qualityMonitorModel.data.chroma ?? '-'),
|
||||
if (qualityMonitorModel.webrtcTransport != null)
|
||||
_row("Transport",
|
||||
qualityMonitorModel.webrtcTransport!),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -166,8 +166,12 @@ const String kOptionAllowRemoveWallpaper = "allow-remove-wallpaper";
|
||||
const String kOptionStopService = "stop-service";
|
||||
const String kOptionDirectxCapture = "enable-directx-capture";
|
||||
const String kOptionAllowRemoteCmModification = "allow-remote-cm-modification";
|
||||
const String kOptionEnableTcpPunch = "enable-tcp-punch";
|
||||
const String kOptionEnableUdpPunch = "enable-udp-punch";
|
||||
const String kOptionEnableIpv6Punch = "enable-ipv6-punch";
|
||||
const String kOptionAllowSyncClipboardBetweenSessions =
|
||||
"allow-sync-clipboard-between-sessions";
|
||||
const String kOptionEnableWebrtc = "enable-webrtc";
|
||||
const String kOptionEnableTrustedDevices = "enable-trusted-devices";
|
||||
const String kOptionShowVirtualMouse = "show-virtual-mouse";
|
||||
const String kOptionVirtualMouseScale = "virtual-mouse-scale";
|
||||
@@ -439,7 +443,6 @@ const kActionApplicationDetailsSettings =
|
||||
const kActionAccessibilitySettings = "android.settings.ACCESSIBILITY_SETTINGS";
|
||||
|
||||
const kRecordAudio = "android.permission.RECORD_AUDIO";
|
||||
const kManageExternalStorage = "android.permission.MANAGE_EXTERNAL_STORAGE";
|
||||
const kRequestIgnoreBatteryOptimizations =
|
||||
"android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS";
|
||||
const kSystemAlertWindow = "android.permission.SYSTEM_ALERT_WINDOW";
|
||||
@@ -451,6 +454,12 @@ class AndroidChannel {
|
||||
static final kGetStartOnBootOpt = "get_start_on_boot_opt";
|
||||
static final kSetStartOnBootOpt = "set_start_on_boot_opt";
|
||||
static final kSyncAppDirConfigPath = "sync_app_dir";
|
||||
static final kPickImportFiles = "pick_import_files";
|
||||
static final kImportFile = "import_file";
|
||||
static final kExportFile = "export_file";
|
||||
static final kPickImportDirectory = "pick_import_directory";
|
||||
static final kImportDirectory = "import_directory";
|
||||
static final kExportFiles = "export_files";
|
||||
}
|
||||
|
||||
/// flutter/packages/flutter/lib/src/services/keyboard_key.dart -> _keyLabels
|
||||
|
||||
@@ -563,6 +563,12 @@ class _GeneralState extends State<_General> {
|
||||
kOptionDirectxCapture,
|
||||
),
|
||||
if (!isWeb && !incomingOnly) ...[
|
||||
_OptionCheckBox(
|
||||
context,
|
||||
'Enable TCP hole punching',
|
||||
kOptionEnableTcpPunch,
|
||||
isServer: false,
|
||||
),
|
||||
_OptionCheckBox(
|
||||
context,
|
||||
'Enable UDP hole punching',
|
||||
@@ -576,6 +582,23 @@ class _GeneralState extends State<_General> {
|
||||
isServer: false,
|
||||
),
|
||||
],
|
||||
if (!incomingOnly)
|
||||
_OptionCheckBox(
|
||||
context,
|
||||
'Enable WebRTC P2P connection',
|
||||
kOptionEnableWebrtc,
|
||||
isServer: false,
|
||||
),
|
||||
if (!isWeb && !incomingOnly)
|
||||
Tooltip(
|
||||
message: translate('sync-clipboard-between-sessions-tip'),
|
||||
child: _OptionCheckBox(
|
||||
context,
|
||||
'Sync clipboard between sessions',
|
||||
kOptionAllowSyncClipboardBetweenSessions,
|
||||
isServer: false,
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
// Add client-side wakelock option for desktop platforms
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_breadcrumb/flutter_breadcrumb.dart';
|
||||
@@ -8,6 +9,7 @@ import 'package:toggle_switch/toggle_switch.dart';
|
||||
|
||||
import '../../common.dart';
|
||||
import '../../common/widgets/dialog.dart';
|
||||
import '../../consts.dart';
|
||||
|
||||
class FileManagerPage extends StatefulWidget {
|
||||
FileManagerPage(
|
||||
@@ -73,6 +75,173 @@ class _FileManagerPageState extends State<FileManagerPage> {
|
||||
DirectoryOptions get currentOptions => currentFileController.options.value;
|
||||
final _uniqueKey = UniqueKey();
|
||||
|
||||
Future<T> _runAndroidDocumentPicker<T>(Future<T> Function() action) async {
|
||||
gFFI.ffiModel.beginAndroidDocumentPicker();
|
||||
try {
|
||||
return await action();
|
||||
} finally {
|
||||
gFFI.ffiModel.endAndroidDocumentPicker();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _importFiles() async {
|
||||
var imported = 0;
|
||||
var failed = false;
|
||||
final importController = currentFileController;
|
||||
final importDirectory = currentDir.path;
|
||||
final importIsWindows = currentOptions.isWindows;
|
||||
try {
|
||||
final selectedFiles = await _runAndroidDocumentPicker(() =>
|
||||
gFFI.invokeMethodWithResult<List<dynamic>>(
|
||||
AndroidChannel.kPickImportFiles));
|
||||
if (selectedFiles == null || selectedFiles.isEmpty) return;
|
||||
|
||||
for (final selected in selectedFiles) {
|
||||
final uri = (selected as Map<dynamic, dynamic>)['uri'] as String?;
|
||||
final selectedName = selected['name'] as String?;
|
||||
final name = selectedName?.replaceAll('\\', '/').split('/').last;
|
||||
if (uri == null ||
|
||||
name == null ||
|
||||
!PathUtil.validName(name, importIsWindows)) {
|
||||
failed = true;
|
||||
continue;
|
||||
}
|
||||
final destination =
|
||||
PathUtil.join(importDirectory, name, importIsWindows);
|
||||
var overwrite = false;
|
||||
if (await File(destination).exists()) {
|
||||
final overwriteResult = await model.showFileConfirmDialog(
|
||||
translate('Overwrite'), destination, false, false);
|
||||
if (overwriteResult == false) break;
|
||||
if (overwriteResult != true) continue;
|
||||
overwrite = true;
|
||||
}
|
||||
try {
|
||||
final success = await gFFI.invokeMethod(
|
||||
AndroidChannel.kImportFile,
|
||||
{'uri': uri, 'path': destination, 'overwrite': overwrite});
|
||||
if (success == true) {
|
||||
imported++;
|
||||
} else {
|
||||
failed = true;
|
||||
}
|
||||
} catch (e) {
|
||||
failed = true;
|
||||
debugPrint('Failed to import $name: $e');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
failed = true;
|
||||
debugPrint('Failed to select files for import: $e');
|
||||
}
|
||||
await importController.refresh();
|
||||
if (failed) {
|
||||
showToast(translate('Failed'));
|
||||
} else if (imported > 0) {
|
||||
showToast(translate('Successful'));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _exportFile(Entry entry) async {
|
||||
try {
|
||||
final exported = await _runAndroidDocumentPicker(() => gFFI
|
||||
.invokeMethod(AndroidChannel.kExportFile, {'path': entry.path}));
|
||||
if (exported == true) {
|
||||
showToast(translate('Successful'));
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Failed to export ${entry.name}: $e');
|
||||
showToast(translate('Failed'));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _importFolder() async {
|
||||
final importController = currentFileController;
|
||||
final importDirectory = currentDir.path;
|
||||
final importIsWindows = currentOptions.isWindows;
|
||||
try {
|
||||
final picked = await _runAndroidDocumentPicker(() =>
|
||||
gFFI.invokeMethodWithResult<Map<dynamic, dynamic>>(
|
||||
AndroidChannel.kPickImportDirectory));
|
||||
if (picked == null || picked.isEmpty) return;
|
||||
final uri = picked['uri'] as String?;
|
||||
final name =
|
||||
(picked['name'] as String?)?.replaceAll('\\', '/').split('/').last;
|
||||
if (uri == null ||
|
||||
name == null ||
|
||||
name == '.' ||
|
||||
name == '..' ||
|
||||
!PathUtil.validName(name, importIsWindows)) {
|
||||
showToast(translate('Failed'));
|
||||
return;
|
||||
}
|
||||
final destination = PathUtil.join(importDirectory, name, importIsWindows);
|
||||
final destinationType = await FileSystemEntity.type(destination);
|
||||
var overwrite = false;
|
||||
if (destinationType == FileSystemEntityType.directory) {
|
||||
final overwriteResult = await model.showFileConfirmDialog(
|
||||
translate('Overwrite'), destination, false, false);
|
||||
if (overwriteResult != true) return;
|
||||
overwrite = true;
|
||||
} else if (destinationType != FileSystemEntityType.notFound) {
|
||||
showToast(translate('Failed'));
|
||||
return;
|
||||
}
|
||||
final success = await gFFI.invokeMethod(AndroidChannel.kImportDirectory,
|
||||
{'uri': uri, 'path': destination, 'overwrite': overwrite});
|
||||
if (success == true) {
|
||||
showToast(translate('Successful'));
|
||||
} else {
|
||||
showToast(translate('Failed'));
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Failed to import folder: $e');
|
||||
showToast(translate('Failed'));
|
||||
}
|
||||
await importController.refresh();
|
||||
}
|
||||
|
||||
Future<void> _exportItems(SelectedItems items) async {
|
||||
await _exportPaths(items.items.map((e) => e.path));
|
||||
}
|
||||
|
||||
Future<void> _exportLogs() async {
|
||||
final home = currentFileController.homePath;
|
||||
if (home.isEmpty) {
|
||||
showToast(translate('Failed'));
|
||||
return;
|
||||
}
|
||||
final appDir = PathUtil.join(home, appName, false);
|
||||
final paths = [
|
||||
PathUtil.join(appDir, 'Logs', false),
|
||||
PathUtil.join(appDir, 'ScreenRecord', false),
|
||||
].where((p) => File(p).existsSync() || Directory(p).existsSync()).toList();
|
||||
if (paths.isEmpty) {
|
||||
showToast(translate('Failed'));
|
||||
return;
|
||||
}
|
||||
await _exportPaths(paths);
|
||||
}
|
||||
|
||||
Future<void> _exportPaths(Iterable<String> paths) async {
|
||||
try {
|
||||
final result = await _runAndroidDocumentPicker(() =>
|
||||
gFFI.invokeMethodWithResult<Map<dynamic, dynamic>>(
|
||||
AndroidChannel.kExportFiles, {'paths': paths.toList()}));
|
||||
if (result == null) return;
|
||||
final exported = result['exported'] as int? ?? 0;
|
||||
final failed = result['failed'] as int? ?? 0;
|
||||
if (failed > 0) {
|
||||
showToast(translate('Failed'));
|
||||
} else if (exported > 0) {
|
||||
showToast(translate('Successful'));
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Failed to export paths: $e');
|
||||
showToast(translate('Failed'));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -159,6 +328,45 @@ class _FileManagerPageState extends State<FileManagerPage> {
|
||||
),
|
||||
value: "refresh",
|
||||
),
|
||||
if (isAndroid)
|
||||
PopupMenuItem(
|
||||
enabled: showLocal && currentDir.path.isNotEmpty,
|
||||
value: "import",
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.add_to_drive,
|
||||
color: Theme.of(context).iconTheme.color),
|
||||
SizedBox(width: 5),
|
||||
Text(translate("Add"))
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isAndroid)
|
||||
PopupMenuItem(
|
||||
enabled: showLocal && currentDir.path.isNotEmpty,
|
||||
value: "import_folder",
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.create_new_folder_outlined,
|
||||
color: Theme.of(context).iconTheme.color),
|
||||
SizedBox(width: 5),
|
||||
Text(translate("Import Folder"))
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isAndroid)
|
||||
PopupMenuItem(
|
||||
enabled: showLocal && currentDir.path.isNotEmpty,
|
||||
value: "export_logs",
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.article_outlined,
|
||||
color: Theme.of(context).iconTheme.color),
|
||||
SizedBox(width: 5),
|
||||
Text(translate("Export Logs"))
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
enabled: currentDir.path != "/",
|
||||
child: Row(
|
||||
@@ -203,6 +411,12 @@ class _FileManagerPageState extends State<FileManagerPage> {
|
||||
onSelected: (v) {
|
||||
if (v == "refresh") {
|
||||
currentFileController.refresh();
|
||||
} else if (v == "import") {
|
||||
_importFiles();
|
||||
} else if (v == "import_folder") {
|
||||
_importFolder();
|
||||
} else if (v == "export_logs") {
|
||||
_exportLogs();
|
||||
} else if (v == "select") {
|
||||
model.localController.selectedItems.clear();
|
||||
model.remoteController.selectedItems.clear();
|
||||
@@ -300,6 +514,24 @@ class _FileManagerPageState extends State<FileManagerPage> {
|
||||
setState(() {});
|
||||
},
|
||||
actions: [
|
||||
if (isAndroid &&
|
||||
selectedItems?.isLocal == true &&
|
||||
selectedItems?.items.isNotEmpty == true) ...[
|
||||
if (selectedItems!.items.length == 1 &&
|
||||
selectedItems!.items.single.isFile)
|
||||
IconButton(
|
||||
tooltip: translate("Save as"),
|
||||
icon: Icon(Icons.save_alt),
|
||||
onPressed: () =>
|
||||
_exportFile(selectedItems!.items.single),
|
||||
)
|
||||
else
|
||||
IconButton(
|
||||
tooltip: translate("Export"),
|
||||
icon: Icon(Icons.drive_folder_upload),
|
||||
onPressed: () => _exportItems(selectedItems!),
|
||||
),
|
||||
],
|
||||
IconButton(
|
||||
icon: Icon(Icons.compare_arrows),
|
||||
onPressed: () => setState(() => showLocal = !showLocal),
|
||||
|
||||
@@ -225,12 +225,6 @@ class _ServerPageState extends State<ServerPage> {
|
||||
|
||||
void checkService() async {
|
||||
gFFI.invokeMethod("check_service");
|
||||
// for Android 10/11, request MANAGE_EXTERNAL_STORAGE permission from system setting page
|
||||
if (AndroidPermissionManager.isWaitingFile() && !gFFI.serverModel.fileOk) {
|
||||
AndroidPermissionManager.complete(kManageExternalStorage,
|
||||
await AndroidPermissionManager.check(kManageExternalStorage));
|
||||
debugPrint("file permission finished");
|
||||
}
|
||||
}
|
||||
|
||||
class ServiceNotRunningNotification extends StatelessWidget {
|
||||
|
||||
@@ -97,10 +97,12 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
||||
var _hideNetwork = false;
|
||||
var _hideWebSocket = false;
|
||||
var _enableTrustedDevices = false;
|
||||
var _enableTcpPunch = false;
|
||||
var _enableUdpPunch = false;
|
||||
var _allowInsecureTlsFallback = false;
|
||||
var _disableUdp = false;
|
||||
var _enableIpv6Punch = false;
|
||||
var _enableWebrtc = false;
|
||||
var _isUsingPublicServer = false;
|
||||
var _allowAskForNoteAtEndOfConnection = false;
|
||||
var _preventSleepWhileConnected = true;
|
||||
@@ -141,8 +143,10 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
||||
bind.mainGetBuildinOption(key: kOptionHideWebSocketSetting) == 'Y' ||
|
||||
isWeb;
|
||||
_enableTrustedDevices = mainGetBoolOptionSync(kOptionEnableTrustedDevices);
|
||||
_enableTcpPunch = mainGetLocalBoolOptionSync(kOptionEnableTcpPunch);
|
||||
_enableUdpPunch = mainGetLocalBoolOptionSync(kOptionEnableUdpPunch);
|
||||
_enableIpv6Punch = mainGetLocalBoolOptionSync(kOptionEnableIpv6Punch);
|
||||
_enableWebrtc = mainGetLocalBoolOptionSync(kOptionEnableWebrtc);
|
||||
_allowAskForNoteAtEndOfConnection =
|
||||
mainGetLocalBoolOptionSync(kOptionAllowAskForNoteAtEndOfConnection);
|
||||
_preventSleepWhileConnected =
|
||||
@@ -815,31 +819,65 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
||||
});
|
||||
},
|
||||
),
|
||||
if (!incomingOnly)
|
||||
SettingsTile.switchTile(
|
||||
title: Text(translate('Enable TCP hole punching')),
|
||||
initialValue: _enableTcpPunch,
|
||||
onToggle: isOptionFixed(kOptionEnableTcpPunch)
|
||||
? null
|
||||
: (v) async {
|
||||
await mainSetLocalBoolOption(kOptionEnableTcpPunch, v);
|
||||
final newValue =
|
||||
mainGetLocalBoolOptionSync(kOptionEnableTcpPunch);
|
||||
setState(() {
|
||||
_enableTcpPunch = newValue;
|
||||
});
|
||||
},
|
||||
),
|
||||
if (!incomingOnly)
|
||||
SettingsTile.switchTile(
|
||||
title: Text(translate('Enable UDP hole punching')),
|
||||
initialValue: _enableUdpPunch,
|
||||
onToggle: (v) async {
|
||||
await mainSetLocalBoolOption(kOptionEnableUdpPunch, v);
|
||||
final newValue =
|
||||
mainGetLocalBoolOptionSync(kOptionEnableUdpPunch);
|
||||
setState(() {
|
||||
_enableUdpPunch = newValue;
|
||||
});
|
||||
},
|
||||
onToggle: isOptionFixed(kOptionEnableUdpPunch)
|
||||
? null
|
||||
: (v) async {
|
||||
await mainSetLocalBoolOption(kOptionEnableUdpPunch, v);
|
||||
final newValue =
|
||||
mainGetLocalBoolOptionSync(kOptionEnableUdpPunch);
|
||||
setState(() {
|
||||
_enableUdpPunch = newValue;
|
||||
});
|
||||
},
|
||||
),
|
||||
if (!incomingOnly)
|
||||
SettingsTile.switchTile(
|
||||
title: Text(translate('Enable IPv6 P2P connection')),
|
||||
initialValue: _enableIpv6Punch,
|
||||
onToggle: (v) async {
|
||||
await mainSetLocalBoolOption(kOptionEnableIpv6Punch, v);
|
||||
final newValue =
|
||||
mainGetLocalBoolOptionSync(kOptionEnableIpv6Punch);
|
||||
setState(() {
|
||||
_enableIpv6Punch = newValue;
|
||||
});
|
||||
},
|
||||
onToggle: isOptionFixed(kOptionEnableIpv6Punch)
|
||||
? null
|
||||
: (v) async {
|
||||
await mainSetLocalBoolOption(kOptionEnableIpv6Punch, v);
|
||||
final newValue =
|
||||
mainGetLocalBoolOptionSync(kOptionEnableIpv6Punch);
|
||||
setState(() {
|
||||
_enableIpv6Punch = newValue;
|
||||
});
|
||||
},
|
||||
),
|
||||
if (!incomingOnly)
|
||||
SettingsTile.switchTile(
|
||||
title: Text(translate('Enable WebRTC P2P connection')),
|
||||
initialValue: _enableWebrtc,
|
||||
onToggle: isOptionFixed(kOptionEnableWebrtc)
|
||||
? null
|
||||
: (v) async {
|
||||
await mainSetLocalBoolOption(kOptionEnableWebrtc, v);
|
||||
final newValue =
|
||||
mainGetLocalBoolOptionSync(kOptionEnableWebrtc);
|
||||
setState(() {
|
||||
_enableWebrtc = newValue;
|
||||
});
|
||||
},
|
||||
),
|
||||
SettingsTile(
|
||||
title: Text(translate('Language')),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -8,6 +9,7 @@ import 'package:flutter_hbb/common/widgets/dialog.dart';
|
||||
import 'package:flutter_hbb/models/input_modifier_utils.dart';
|
||||
import 'package:flutter_hbb/models/model.dart';
|
||||
import 'package:flutter_hbb/models/platform_model.dart';
|
||||
import 'package:flutter_hbb/models/terminal_copy_shortcut.dart';
|
||||
import 'package:flutter_hbb/models/terminal_model.dart';
|
||||
import 'package:flutter_hbb/mobile/terminal_keyboard_utils.dart';
|
||||
import 'package:flutter_hbb/web/dummy.dart'
|
||||
@@ -190,6 +192,7 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
KeyEventResult _handleTerminalKeyEvent(FocusNode _, KeyEvent event) {
|
||||
final hardwareKeyboard = HardwareKeyboard.instance;
|
||||
final shouldPaste = shouldHandleTerminalPasteShortcut(
|
||||
platform: defaultTargetPlatform,
|
||||
logicalKey: event.logicalKey,
|
||||
isKeyDown: event is KeyDownEvent,
|
||||
isKeyRepeat: event is KeyRepeatEvent,
|
||||
@@ -244,7 +247,12 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
//
|
||||
// Android works fine without this workaround.
|
||||
deleteDetection: isIOS,
|
||||
onKeyEvent: _handleTerminalKeyEvent,
|
||||
shortcuts: platformTerminalShortcuts(),
|
||||
onKeyEvent: terminalCopyHandler(
|
||||
_terminalModel.terminal,
|
||||
_terminalModel.terminalController,
|
||||
fallback: _handleTerminalKeyEvent,
|
||||
),
|
||||
padding: _calculatePadding(heightPx),
|
||||
onSecondaryTapDown: (details, offset) async {
|
||||
final selection = _terminalModel.terminalController.selection;
|
||||
|
||||
@@ -381,6 +381,14 @@ class FileController {
|
||||
void set homePath(String path) => options.value.home = path;
|
||||
OverlayDialogManager? get dialogManager => rootState.target?.dialogManager;
|
||||
|
||||
bool _isPathAllowed(String candidate) {
|
||||
if (!isAndroid || !isLocal) return true;
|
||||
if (homePath.isEmpty || candidate.isEmpty) return false;
|
||||
final home = PathUtil.posixContext.normalize(homePath);
|
||||
final target = PathUtil.posixContext.normalize(candidate);
|
||||
return target == home || PathUtil.posixContext.isWithin(home, target);
|
||||
}
|
||||
|
||||
String get shortPath {
|
||||
final dirPath = directory.value.path;
|
||||
if (dirPath.startsWith(homePath)) {
|
||||
@@ -414,8 +422,13 @@ class FileController {
|
||||
|
||||
await Future.delayed(Duration(milliseconds: 100));
|
||||
|
||||
final savedDir = (await bind.sessionGetPeerOption(
|
||||
var savedDir = (await bind.sessionGetPeerOption(
|
||||
sessionId: sessionId, name: isLocal ? "local_dir" : "remote_dir"));
|
||||
if (savedDir.isNotEmpty && !_isPathAllowed(savedDir)) {
|
||||
savedDir = options.value.home;
|
||||
await bind.sessionPeerOption(
|
||||
sessionId: sessionId, name: "local_dir", value: savedDir);
|
||||
}
|
||||
Future<bool> tryOpenReadyDirs() async {
|
||||
final dirs = <String>{
|
||||
if (directory.value.path.isNotEmpty) directory.value.path,
|
||||
@@ -485,6 +498,9 @@ class FileController {
|
||||
}
|
||||
|
||||
Future<bool> _openDirectoryPath(String path, {bool isBack = false}) async {
|
||||
if (!_isPathAllowed(path)) {
|
||||
return false;
|
||||
}
|
||||
if (!isBack) {
|
||||
pushHistory();
|
||||
}
|
||||
@@ -504,6 +520,7 @@ class FileController {
|
||||
return true;
|
||||
}
|
||||
fd.format(isWindows, sort: sortBy.value);
|
||||
selectedItems.reconcile(fd.entries);
|
||||
directory.value = fd;
|
||||
return true;
|
||||
} catch (e) {
|
||||
@@ -550,6 +567,9 @@ class FileController {
|
||||
final isWindows = options.value.isWindows;
|
||||
final dirPath = directory.value.path;
|
||||
var parent = PathUtil.dirname(dirPath, isWindows);
|
||||
if (!_isPathAllowed(parent)) {
|
||||
return true;
|
||||
}
|
||||
// specially for C:\, D:\, goto '/'
|
||||
if (parent == dirPath && isWindows) {
|
||||
return await _openDirectoryPath('/', isBack: isBack);
|
||||
@@ -1885,7 +1905,7 @@ class PathUtil {
|
||||
}
|
||||
|
||||
static bool validName(String name, bool isWindows) {
|
||||
final unixFileNamePattern = RegExp(r'^[^/\0]+$');
|
||||
final unixFileNamePattern = RegExp(r'^[^/\x00]+$');
|
||||
final windowsFileNamePattern = RegExp(r'^[^<>:"/\\|?*]+$');
|
||||
final reg = isWindows ? windowsFileNamePattern : unixFileNamePattern;
|
||||
return reg.hasMatch(name);
|
||||
@@ -1928,6 +1948,21 @@ class SelectedItems {
|
||||
items.clear();
|
||||
}
|
||||
|
||||
void reconcile(List<Entry> entries) {
|
||||
if (items.isEmpty) return;
|
||||
final currentByPath = {for (final entry in entries) entry.path: entry};
|
||||
final reconciled = <Entry>[];
|
||||
for (final item in items) {
|
||||
final current = currentByPath[item.path];
|
||||
if (current != null && current.entryType == item.entryType) {
|
||||
reconciled.add(current);
|
||||
}
|
||||
}
|
||||
items
|
||||
..clear()
|
||||
..addAll(reconciled);
|
||||
}
|
||||
|
||||
void selectAll(List<Entry> entries) {
|
||||
items.clear();
|
||||
items.addAll(entries);
|
||||
|
||||
@@ -117,10 +117,11 @@ String prepareTerminalInputPayload(
|
||||
|
||||
/// Returns true when a hardware paste shortcut must bypass keyboard modifiers.
|
||||
///
|
||||
/// xterm already handles hardware Ctrl/Cmd+V correctly in the common case. Only
|
||||
/// intercept while a virtual Ctrl/Alt lock is active, because xterm can emit a
|
||||
/// one-character paste as normal text when bracketed paste mode is disabled.
|
||||
/// xterm already handles each platform's paste shortcut in the common case.
|
||||
/// Only intercept while a virtual Ctrl/Alt lock is active, because xterm can
|
||||
/// emit a one-character paste as normal text when bracketed paste mode is off.
|
||||
bool shouldHandleTerminalPasteShortcut({
|
||||
required TargetPlatform platform,
|
||||
required LogicalKeyboardKey logicalKey,
|
||||
required bool isKeyDown,
|
||||
required bool isKeyRepeat,
|
||||
@@ -133,8 +134,18 @@ bool shouldHandleTerminalPasteShortcut({
|
||||
if (!modifierLockActive) return false;
|
||||
if (!isKeyDown && !isKeyRepeat) return false;
|
||||
if (logicalKey != LogicalKeyboardKey.keyV) return false;
|
||||
if (altPressed || shiftPressed) return false;
|
||||
return controlPressed != metaPressed;
|
||||
if (altPressed) return false;
|
||||
switch (platform) {
|
||||
case TargetPlatform.linux:
|
||||
return controlPressed && !metaPressed && shiftPressed;
|
||||
case TargetPlatform.iOS:
|
||||
case TargetPlatform.macOS:
|
||||
return !controlPressed && metaPressed && !shiftPressed;
|
||||
case TargetPlatform.android:
|
||||
case TargetPlatform.fuchsia:
|
||||
case TargetPlatform.windows:
|
||||
return controlPressed && !metaPressed && !shiftPressed;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true when collapsing Row3 should also clear hidden modifier state.
|
||||
|
||||
@@ -124,6 +124,8 @@ class FfiModel with ChangeNotifier {
|
||||
Timer? _restartReconnectDelayTimer;
|
||||
var _reconnects = 1;
|
||||
DateTime? _offlineReconnectStartTime;
|
||||
bool _androidDocumentPickerActive = false;
|
||||
bool _androidDocumentPickerInterruptedConnection = false;
|
||||
bool _viewOnly = false;
|
||||
bool _showMyCursor = false;
|
||||
WeakReference<FFI> parent;
|
||||
@@ -255,6 +257,8 @@ class FfiModel with ChangeNotifier {
|
||||
_inputBlocked = false;
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
_androidDocumentPickerActive = false;
|
||||
_androidDocumentPickerInterruptedConnection = false;
|
||||
resetRestartReconnectState();
|
||||
clearPermissions();
|
||||
waitForImageTimer?.cancel();
|
||||
@@ -892,6 +896,13 @@ class FfiModel with ChangeNotifier {
|
||||
final text = evt['text'];
|
||||
final link = evt['link'];
|
||||
|
||||
if (isAndroid &&
|
||||
_androidDocumentPickerActive &&
|
||||
title == 'Connection Error') {
|
||||
_androidDocumentPickerInterruptedConnection = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable relative mouse mode on any error-type message to ensure cursor is released.
|
||||
// This includes connection errors, session-ending messages, elevation errors, etc.
|
||||
// Safety: releasing pointer lock on errors prevents the user from being stuck.
|
||||
@@ -968,6 +979,23 @@ class FfiModel with ChangeNotifier {
|
||||
_restartReconnectDelayTimer = null;
|
||||
}
|
||||
|
||||
void beginAndroidDocumentPicker() {
|
||||
if (!isAndroid) return;
|
||||
_androidDocumentPickerActive = true;
|
||||
_androidDocumentPickerInterruptedConnection = false;
|
||||
}
|
||||
|
||||
void endAndroidDocumentPicker() {
|
||||
if (!isAndroid) return;
|
||||
_androidDocumentPickerActive = false;
|
||||
if (!_androidDocumentPickerInterruptedConnection ||
|
||||
parent.target?.closed == true) {
|
||||
return;
|
||||
}
|
||||
_androidDocumentPickerInterruptedConnection = false;
|
||||
reconnect(parent.target!.dialogManager, sessionId, false);
|
||||
}
|
||||
|
||||
/// Auto-retry check for "Remote desktop is offline" error.
|
||||
/// returns true to auto-retry, false otherwise.
|
||||
bool shouldAutoRetryOnOffline(
|
||||
@@ -3569,6 +3597,16 @@ class QualityMonitorModel with ChangeNotifier {
|
||||
bool get show => _show;
|
||||
QualityMonitorData get data => _data;
|
||||
|
||||
// Only a WebRTC session names its transport here: web has no session tab
|
||||
// to show it on, and WebRTC is the one path that can be direct or TURN.
|
||||
String? get webrtcTransport {
|
||||
final ffiModel = parent.target?.ffiModel;
|
||||
if (ffiModel == null) return null;
|
||||
final streamType = ffiModel.cachedPeerData.streamType;
|
||||
if (!streamType.startsWith('WebRTC')) return null;
|
||||
return ffiModel.direct == false ? '$streamType (TURN)' : streamType;
|
||||
}
|
||||
|
||||
checkShowQualityMonitor(SessionID sessionId) async {
|
||||
final show = await bind.sessionGetToggleOption(
|
||||
sessionId: sessionId, arg: 'show-quality-monitor') ==
|
||||
@@ -4060,6 +4098,11 @@ class FFI {
|
||||
return await platformFFI.invokeMethod(method, arguments);
|
||||
}
|
||||
|
||||
Future<T?> invokeMethodWithResult<T>(String method,
|
||||
[dynamic arguments]) async {
|
||||
return await platformFFI.invokeMethodWithResult<T>(method, arguments);
|
||||
}
|
||||
|
||||
// Terminal model management
|
||||
void registerTerminalModel(int terminalId, TerminalModel model) {
|
||||
debugPrint('[FFI] Registering terminal model for terminal $terminalId');
|
||||
|
||||
@@ -4,7 +4,6 @@ import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:external_path/external_path.dart';
|
||||
import 'package:ffi/ffi.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -171,8 +170,10 @@ class PlatformFFI {
|
||||
_startListenEvent(_ffiBind); // global event
|
||||
try {
|
||||
if (isAndroid) {
|
||||
// only support for android
|
||||
_homeDir = (await ExternalPath.getExternalStorageDirectories())[0];
|
||||
// Android file transfer uses app-specific storage. User-selected
|
||||
// files enter and leave this workspace through the system picker.
|
||||
_homeDir = (await getExternalStorageDirectory())?.path ??
|
||||
(await getApplicationSupportDirectory()).path;
|
||||
} else if (isIOS) {
|
||||
// The previous code was `_homeDir = (await getDownloadsDirectory())?.path ?? '';`,
|
||||
// which provided the `downloads` path in the sandbox.
|
||||
@@ -306,6 +307,12 @@ class PlatformFFI {
|
||||
return await _toAndroidChannel.invokeMethod(method, arguments);
|
||||
}
|
||||
|
||||
Future<T?> invokeMethodWithResult<T>(String method,
|
||||
[dynamic arguments]) async {
|
||||
if (!isAndroid) return null;
|
||||
return await _toAndroidChannel.invokeMethod<T>(method, arguments);
|
||||
}
|
||||
|
||||
void syncAndroidServiceAppDirConfigPath() {
|
||||
invokeMethod(AndroidChannel.kSyncAppDirConfigPath, _dir);
|
||||
}
|
||||
|
||||
@@ -210,15 +210,10 @@ class ServerModel with ChangeNotifier {
|
||||
_audioOk = audioOption != 'N';
|
||||
}
|
||||
|
||||
// file
|
||||
if (!await AndroidPermissionManager.check(kManageExternalStorage)) {
|
||||
_fileOk = false;
|
||||
bind.mainSetOption(key: kOptionEnableFileTransfer, value: "N");
|
||||
} else {
|
||||
final fileOption =
|
||||
await bind.mainGetOption(key: kOptionEnableFileTransfer);
|
||||
_fileOk = fileOption != 'N';
|
||||
}
|
||||
// Android file transfer is confined to app-specific storage. Files enter
|
||||
// and leave the workspace through Android's system document picker.
|
||||
final fileOption = await bind.mainGetOption(key: kOptionEnableFileTransfer);
|
||||
_fileOk = fileOption != 'N';
|
||||
|
||||
// clipboard
|
||||
final clipOption = await bind.mainGetOption(key: kOptionEnableClipboard);
|
||||
@@ -319,16 +314,6 @@ class ServerModel with ChangeNotifier {
|
||||
if (clients.any((c) => !c.disconnected)) {
|
||||
await showClientsMayNotBeChangedAlert(parent.target);
|
||||
}
|
||||
if (!_fileOk &&
|
||||
!await AndroidPermissionManager.check(kManageExternalStorage)) {
|
||||
final res =
|
||||
await AndroidPermissionManager.request(kManageExternalStorage);
|
||||
if (!res) {
|
||||
showToast(translate('Failed'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_fileOk = !_fileOk;
|
||||
bind.mainSetOption(
|
||||
key: kOptionEnableFileTransfer,
|
||||
@@ -418,9 +403,6 @@ class ServerModel with ChangeNotifier {
|
||||
if (bind.mainGetLocalOption(key: kOptionDisableFloatingWindow) != 'Y') {
|
||||
await checkFloatingWindowPermission();
|
||||
}
|
||||
if (!await AndroidPermissionManager.check(kManageExternalStorage)) {
|
||||
await AndroidPermissionManager.request(kManageExternalStorage);
|
||||
}
|
||||
final res = await parent.target?.dialogManager
|
||||
.show<bool>((setState, close, context) {
|
||||
submit() => close(true);
|
||||
|
||||
@@ -20,43 +20,68 @@ Future<void> writeTerminalClipboard(String text) async {
|
||||
}
|
||||
|
||||
Map<ShortcutActivator, Intent>? platformTerminalShortcuts() {
|
||||
if (defaultTargetPlatform != TargetPlatform.linux) return null;
|
||||
final platform = defaultTargetPlatform;
|
||||
if (platform == TargetPlatform.linux) {
|
||||
return {
|
||||
for (final entry in defaultTerminalShortcuts.entries)
|
||||
if (!_isControlShortcut(entry.key, LogicalKeyboardKey.keyV))
|
||||
entry.key: entry.value,
|
||||
_controlShiftVPasteShortcut:
|
||||
const PasteTextIntent(SelectionChangedCause.keyboard),
|
||||
};
|
||||
}
|
||||
if (platform != TargetPlatform.windows &&
|
||||
platform != TargetPlatform.android) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
for (final entry in defaultTerminalShortcuts.entries)
|
||||
if (!_isControlVShortcut(entry.key)) entry.key: entry.value,
|
||||
_controlShiftVPasteShortcut:
|
||||
const PasteTextIntent(SelectionChangedCause.keyboard),
|
||||
if (!_isControlShortcut(
|
||||
entry.key,
|
||||
LogicalKeyboardKey.keyC,
|
||||
shift: true,
|
||||
))
|
||||
entry.key: entry.value,
|
||||
};
|
||||
}
|
||||
|
||||
bool _isControlVShortcut(ShortcutActivator shortcut) =>
|
||||
bool _isControlShortcut(
|
||||
ShortcutActivator shortcut,
|
||||
LogicalKeyboardKey key, {
|
||||
bool shift = false,
|
||||
}) =>
|
||||
shortcut is SingleActivator &&
|
||||
shortcut.trigger == LogicalKeyboardKey.keyV &&
|
||||
shortcut.trigger == key &&
|
||||
shortcut.control &&
|
||||
!shortcut.shift &&
|
||||
shortcut.shift == shift &&
|
||||
!shortcut.alt &&
|
||||
!shortcut.meta;
|
||||
|
||||
FocusOnKeyEventCallback terminalCopyHandler(
|
||||
Terminal terminal,
|
||||
TerminalController controller,
|
||||
) =>
|
||||
(_, event) {
|
||||
if (!_isWindowsCopyShortcut(event)) return KeyEventResult.ignored;
|
||||
final selection = controller.selection;
|
||||
if (selection == null || selection.isCollapsed) {
|
||||
return KeyEventResult.ignored;
|
||||
TerminalController controller, {
|
||||
FocusOnKeyEventCallback? fallback,
|
||||
}) =>
|
||||
(focusNode, event) {
|
||||
if (_isSelectionCopyShortcut(event)) {
|
||||
final selection = controller.selection;
|
||||
if (selection != null && !selection.isCollapsed) {
|
||||
if (event is KeyDownEvent) {
|
||||
final text = terminal.buffer.getText(selection);
|
||||
unawaited(writeTerminalClipboard(text));
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
}
|
||||
if (event is KeyDownEvent) {
|
||||
final text = terminal.buffer.getText(selection);
|
||||
unawaited(writeTerminalClipboard(text));
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
return fallback?.call(focusNode, event) ?? KeyEventResult.ignored;
|
||||
};
|
||||
|
||||
bool _isWindowsCopyShortcut(KeyEvent event) {
|
||||
bool _isSelectionCopyShortcut(KeyEvent event) {
|
||||
final keyboard = HardwareKeyboard.instance;
|
||||
return defaultTargetPlatform == TargetPlatform.windows &&
|
||||
final platform = defaultTargetPlatform;
|
||||
final usesControlCopy =
|
||||
platform == TargetPlatform.windows || platform == TargetPlatform.android;
|
||||
return usesControlCopy &&
|
||||
(event is KeyDownEvent || event is KeyRepeatEvent) &&
|
||||
event.logicalKey == LogicalKeyboardKey.keyC &&
|
||||
keyboard.isControlPressed &&
|
||||
|
||||
@@ -251,6 +251,11 @@ class PlatformFFI {
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<T?> invokeMethodWithResult<T>(String method,
|
||||
[dynamic arguments]) async {
|
||||
return null;
|
||||
}
|
||||
|
||||
// just for compilation
|
||||
void syncAndroidServiceAppDirConfigPath() {}
|
||||
|
||||
|
||||
@@ -409,14 +409,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "12.0.1"
|
||||
external_path:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: external_path
|
||||
sha256: "2095c626fbbefe70d5a4afc9b1137172a68ee2c276e51c3c1283394485bea8f4"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.3"
|
||||
ffi:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
|
||||
# Read more about iOS versioning at
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# 1.1.9-1 works for android, but for ios it becomes 1.1.91, need to set it to 1.1.9-a.1 for iOS, will get 1.1.9.1, but iOS store not allow 4 numbers
|
||||
version: 1.4.9+67
|
||||
version: 1.5.0+68
|
||||
|
||||
environment:
|
||||
sdk: '^3.1.0'
|
||||
@@ -29,7 +29,6 @@ dependencies:
|
||||
|
||||
ffi: ^2.1.0
|
||||
path_provider: ^2.1.1
|
||||
external_path: ^1.0.3
|
||||
provider: ^6.0.5
|
||||
tuple: ^2.0.0
|
||||
wakelock_plus: ^1.1.3
|
||||
|
||||
@@ -342,11 +342,43 @@ void main() {
|
||||
});
|
||||
|
||||
group('shouldHandleTerminalPasteShortcut', () {
|
||||
test('handles only Ctrl+Shift+V on Linux with a virtual lock', () {
|
||||
expect(
|
||||
shouldHandleTerminalPasteShortcut(
|
||||
platform: TargetPlatform.linux,
|
||||
logicalKey: LogicalKeyboardKey.keyV,
|
||||
isKeyDown: true,
|
||||
isKeyRepeat: false,
|
||||
controlPressed: true,
|
||||
metaPressed: false,
|
||||
altPressed: false,
|
||||
shiftPressed: true,
|
||||
modifierLockActive: true,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
shouldHandleTerminalPasteShortcut(
|
||||
platform: TargetPlatform.linux,
|
||||
logicalKey: LogicalKeyboardKey.keyV,
|
||||
isKeyDown: true,
|
||||
isKeyRepeat: false,
|
||||
controlPressed: true,
|
||||
metaPressed: false,
|
||||
altPressed: false,
|
||||
shiftPressed: false,
|
||||
modifierLockActive: true,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'keeps default xterm paste behavior when virtual modifiers are inactive',
|
||||
() {
|
||||
expect(
|
||||
shouldHandleTerminalPasteShortcut(
|
||||
platform: TargetPlatform.windows,
|
||||
logicalKey: LogicalKeyboardKey.keyV,
|
||||
isKeyDown: true,
|
||||
isKeyRepeat: false,
|
||||
@@ -364,6 +396,7 @@ void main() {
|
||||
() {
|
||||
expect(
|
||||
shouldHandleTerminalPasteShortcut(
|
||||
platform: TargetPlatform.windows,
|
||||
logicalKey: LogicalKeyboardKey.keyV,
|
||||
isKeyDown: true,
|
||||
isKeyRepeat: false,
|
||||
@@ -377,6 +410,7 @@ void main() {
|
||||
);
|
||||
expect(
|
||||
shouldHandleTerminalPasteShortcut(
|
||||
platform: TargetPlatform.macOS,
|
||||
logicalKey: LogicalKeyboardKey.keyV,
|
||||
isKeyDown: true,
|
||||
isKeyRepeat: false,
|
||||
@@ -393,6 +427,7 @@ void main() {
|
||||
test('handles paste shortcut repeats while a virtual lock is active', () {
|
||||
expect(
|
||||
shouldHandleTerminalPasteShortcut(
|
||||
platform: TargetPlatform.windows,
|
||||
logicalKey: LogicalKeyboardKey.keyV,
|
||||
isKeyDown: false,
|
||||
isKeyRepeat: true,
|
||||
@@ -409,6 +444,7 @@ void main() {
|
||||
test('ignores key-up and unmodified V events', () {
|
||||
expect(
|
||||
shouldHandleTerminalPasteShortcut(
|
||||
platform: TargetPlatform.windows,
|
||||
logicalKey: LogicalKeyboardKey.keyV,
|
||||
isKeyDown: false,
|
||||
isKeyRepeat: false,
|
||||
@@ -422,6 +458,7 @@ void main() {
|
||||
);
|
||||
expect(
|
||||
shouldHandleTerminalPasteShortcut(
|
||||
platform: TargetPlatform.windows,
|
||||
logicalKey: LogicalKeyboardKey.keyV,
|
||||
isKeyDown: true,
|
||||
isKeyRepeat: false,
|
||||
@@ -444,6 +481,7 @@ void main() {
|
||||
]) {
|
||||
expect(
|
||||
shouldHandleTerminalPasteShortcut(
|
||||
platform: TargetPlatform.windows,
|
||||
logicalKey: LogicalKeyboardKey.keyV,
|
||||
isKeyDown: true,
|
||||
isKeyRepeat: false,
|
||||
@@ -461,6 +499,7 @@ void main() {
|
||||
test('ignores non-V key events', () {
|
||||
expect(
|
||||
shouldHandleTerminalPasteShortcut(
|
||||
platform: TargetPlatform.windows,
|
||||
logicalKey: LogicalKeyboardKey.keyC,
|
||||
isKeyDown: true,
|
||||
isKeyRepeat: false,
|
||||
|
||||
Submodule libs/hbb_common updated: b2b1ac453d...470612bdfb
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rustdesk-portable-packer"
|
||||
version = "1.4.9"
|
||||
version = "1.5.0"
|
||||
edition = "2021"
|
||||
description = "RustDesk Remote Desktop"
|
||||
|
||||
@@ -12,7 +12,7 @@ build = "build.rs"
|
||||
brotli = "3.4"
|
||||
dirs = "5.0"
|
||||
md5 = "0.7"
|
||||
winapi = { version = "0.3", features = ["winbase"] }
|
||||
winapi = { version = "0.3", features = ["winbase", "libloaderapi"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows = { version = "0.61", features = [
|
||||
|
||||
@@ -15,15 +15,29 @@ encoding = 'utf-8'
|
||||
# output: {path: (compressed_data, file_md5)}
|
||||
|
||||
|
||||
def generate_md5_table(folder: str, level) -> dict:
|
||||
def normalize(path: str) -> str:
|
||||
path = path.replace('\\', '/')
|
||||
while path.startswith('./'):
|
||||
path = path[2:]
|
||||
return path.lower()
|
||||
|
||||
|
||||
def generate_md5_table(folder: str, level, exclude: str = None) -> dict:
|
||||
res: dict = dict()
|
||||
curdir = os.curdir
|
||||
skip = normalize(exclude) if exclude else None
|
||||
excluded = False
|
||||
# os.curdir is the literal ".", so restoring it left us inside `folder`.
|
||||
curdir = os.getcwd()
|
||||
os.chdir(folder)
|
||||
for root, _, files in os.walk('.'):
|
||||
# remove ./
|
||||
for f in files:
|
||||
md5_generator = md5()
|
||||
full_path = os.path.join(root, f)
|
||||
if skip and normalize(full_path) == skip:
|
||||
print(f"Excluding {full_path}...")
|
||||
excluded = True
|
||||
continue
|
||||
print(f"Processing {full_path}...")
|
||||
f = open(full_path, "rb")
|
||||
content = f.read()
|
||||
@@ -33,11 +47,16 @@ def generate_md5_table(folder: str, level) -> dict:
|
||||
md5_code = md5_generator.hexdigest().encode(encoding=encoding)
|
||||
res[full_path] = (content_compressed, md5_code)
|
||||
os.chdir(curdir)
|
||||
if skip and not excluded:
|
||||
raise ValueError(f"excluded file was not found in {folder}: {exclude}")
|
||||
return res
|
||||
|
||||
|
||||
def write_package_metadata(md5_table: dict, output_folder: str, exe: str):
|
||||
output_path = os.path.join(output_folder, "data.bin")
|
||||
write_blob(md5_table, os.path.join(output_folder, "data.bin"), exe)
|
||||
|
||||
|
||||
def write_blob(md5_table: dict, output_path: str, exe: str):
|
||||
with open(output_path, "wb") as f:
|
||||
f.write("rustdesk".encode(encoding=encoding))
|
||||
for path in md5_table.keys():
|
||||
@@ -92,6 +111,14 @@ if __name__ == '__main__':
|
||||
help="the target used by cargo")
|
||||
parser.add_option("-l", "--level", dest="level", type="int",
|
||||
help="compression level, default is 11, highest", default=11)
|
||||
parser.add_option("--package", dest="package",
|
||||
help="write the per-customer blob to this path instead of "
|
||||
"data.bin, and skip the cargo build. Injected into the "
|
||||
"template's RDPKG resource so customizing needs no rebuild")
|
||||
parser.add_option("--exclude-exe", dest="exclude_exe", action="store_true",
|
||||
default=False,
|
||||
help="omit the executable from the blob, for a template whose "
|
||||
"executable ships in the package instead")
|
||||
(options, args) = parser.parse_args()
|
||||
folder = options.folder or './rustdesk'
|
||||
output_folder = os.path.abspath(options.output_folder or './')
|
||||
@@ -100,14 +127,29 @@ if __name__ == '__main__':
|
||||
options.executable = 'rustdesk.exe'
|
||||
if not options.executable.startswith(folder):
|
||||
options.executable = folder + '/' + options.executable
|
||||
# Note: the simple check `options.executable.startswith(folder)` is incorrect.
|
||||
# `python generate.py -f rustdesk -e rustdesk.exe` or `python generate.py -f rustdesk`
|
||||
# will result the print "Executable path: ..exe".
|
||||
# So we need to check if the executable is in the folder, and if so, concat again.
|
||||
if os.path.exists(os.path.join(folder, options.executable)):
|
||||
options.executable = os.path.join(folder, options.executable)
|
||||
folder_path = os.path.abspath(folder)
|
||||
exe: str = os.path.abspath(options.executable)
|
||||
if not exe.startswith(os.path.abspath(folder)):
|
||||
try:
|
||||
in_source_folder = os.path.commonpath([folder_path, exe]) == folder_path
|
||||
except ValueError:
|
||||
in_source_folder = False
|
||||
if not in_source_folder:
|
||||
print("The executable must locate in source folder")
|
||||
exit(-1)
|
||||
exe = '.' + exe[len(os.path.abspath(folder)):]
|
||||
exe = '.' + exe[len(folder_path):]
|
||||
print("Executable path: " + exe)
|
||||
print("Compression level: " + str(options.level))
|
||||
md5_table = generate_md5_table(folder, options.level)
|
||||
write_package_metadata(md5_table, output_folder, exe)
|
||||
write_app_metadata(output_folder)
|
||||
build_portable(output_folder, options.target)
|
||||
md5_table = generate_md5_table(
|
||||
folder, options.level, exe if options.exclude_exe else None)
|
||||
if options.package:
|
||||
write_blob(md5_table, os.path.abspath(options.package), exe)
|
||||
else:
|
||||
write_package_metadata(md5_table, output_folder, exe)
|
||||
write_app_metadata(output_folder)
|
||||
build_portable(output_folder, options.target)
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
fs::{self},
|
||||
io::{Cursor, Read},
|
||||
path::Path,
|
||||
};
|
||||
|
||||
// The generic payload, shared by every customer and compiled in once per release.
|
||||
#[cfg(windows)]
|
||||
const BIN_DATA: &[u8] = include_bytes!("../data.bin");
|
||||
#[cfg(not(windows))]
|
||||
const BIN_DATA: &[u8] = &[];
|
||||
|
||||
// The per-customer payload, injected into the RCDATA resource after the template
|
||||
// has been built, so that customizing a client needs no recompilation.
|
||||
#[cfg(windows)]
|
||||
const PACKAGE_RESOURCE_NAME: &str = "RDPKG";
|
||||
|
||||
// 4bytes
|
||||
const LENGTH: usize = 4;
|
||||
const IDENTIFIER: &[u8] = b"rustdesk";
|
||||
const IDENTIFIER_LENGTH: usize = 8;
|
||||
const MD5_LENGTH: usize = 32;
|
||||
const BUF_SIZE: usize = 4096;
|
||||
@@ -24,12 +31,172 @@ pub(crate) struct BinaryData {
|
||||
pub(crate) struct BinaryReader {
|
||||
pub files: Vec<BinaryData>,
|
||||
pub exe: String,
|
||||
// Paths supplied by the per-customer package. Recorded so that a file dropped
|
||||
// from a later package -- a logo the customer removed, say -- can be deleted
|
||||
// from an existing extraction, which the timestamp wipe no longer covers now
|
||||
// that the packer is built once per release rather than once per customer.
|
||||
pub package_paths: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for BinaryReader {
|
||||
fn default() -> Self {
|
||||
let (files, exe) = BinaryReader::read();
|
||||
Self { files, exe }
|
||||
impl BinaryReader {
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let package = read_package()?;
|
||||
let package_paths = package.0.iter().map(|f| f.path.clone()).collect();
|
||||
let (files, exe) = merge(read_embedded()?, package);
|
||||
Ok(Self {
|
||||
files,
|
||||
exe,
|
||||
package_paths,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Folds the per-customer package into the generic payload.
|
||||
fn merge(
|
||||
embedded: (Vec<BinaryData>, String),
|
||||
package: (Vec<BinaryData>, String),
|
||||
) -> (Vec<BinaryData>, String) {
|
||||
let (mut files, generic_exe) = embedded;
|
||||
let (package_files, package_exe) = package;
|
||||
|
||||
let exe = if package_exe.is_empty() {
|
||||
generic_exe.clone()
|
||||
} else {
|
||||
package_exe
|
||||
};
|
||||
|
||||
// The generic payload ships the executable under its stock name, the package
|
||||
// decides the final one. Rename on extraction so the process is always
|
||||
// `<appname>.exe`, which the app itself relies on to find its own sessions.
|
||||
if !generic_exe.is_empty() && normalize_path(&exe) != normalize_path(&generic_exe) {
|
||||
let generic_key = normalize_path(&generic_exe);
|
||||
for file in files.iter_mut() {
|
||||
if normalize_path(&file.path) == generic_key {
|
||||
file.path = exe.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Per-customer entries replace the generic ones they shadow.
|
||||
if !package_files.is_empty() {
|
||||
let overridden: HashSet<String> = package_files
|
||||
.iter()
|
||||
.map(|file| normalize_path(&file.path))
|
||||
.collect();
|
||||
files.retain(|file| !overridden.contains(&normalize_path(&file.path)));
|
||||
files.extend(package_files);
|
||||
}
|
||||
|
||||
(files, exe)
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_path(path: &str) -> String {
|
||||
path.replace('\\', "/")
|
||||
.trim_start_matches("./")
|
||||
.to_lowercase()
|
||||
}
|
||||
|
||||
fn read_u32(blob: &[u8], at: usize) -> Option<u32> {
|
||||
let bytes = blob.get(at..at + LENGTH)?;
|
||||
Some(u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
|
||||
}
|
||||
|
||||
// Returns the files and the executable to launch, or None if the blob is absent or malformed.
|
||||
fn parse(blob: &'static [u8]) -> Option<(Vec<BinaryData>, String)> {
|
||||
let mut base = 0usize;
|
||||
let mut parsed = Vec::new();
|
||||
if blob.get(base..base + IDENTIFIER_LENGTH)? != IDENTIFIER {
|
||||
return None;
|
||||
}
|
||||
base += IDENTIFIER_LENGTH;
|
||||
loop {
|
||||
if blob.get(base..base + IDENTIFIER_LENGTH)? == IDENTIFIER {
|
||||
base += IDENTIFIER_LENGTH;
|
||||
break;
|
||||
}
|
||||
let path_length = read_u32(blob, base)? as usize;
|
||||
base += LENGTH;
|
||||
let path = std::str::from_utf8(blob.get(base..base + path_length)?)
|
||||
.ok()?
|
||||
.to_owned();
|
||||
base += path_length;
|
||||
let file_length = read_u32(blob, base)? as usize;
|
||||
base += LENGTH;
|
||||
let raw = blob.get(base..base + file_length)?;
|
||||
base += file_length;
|
||||
let md5_code = blob.get(base..base + MD5_LENGTH)?;
|
||||
base += MD5_LENGTH;
|
||||
parsed.push(BinaryData {
|
||||
md5_code,
|
||||
raw,
|
||||
path,
|
||||
});
|
||||
}
|
||||
let executable = std::str::from_utf8(blob.get(base..)?).ok()?.to_owned();
|
||||
Some((parsed, executable))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn read_embedded() -> Result<(Vec<BinaryData>, String), String> {
|
||||
parse(BIN_DATA).ok_or_else(|| "bin file is not valid!".to_owned())
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn read_embedded() -> Result<(Vec<BinaryData>, String), String> {
|
||||
Ok(Default::default())
|
||||
}
|
||||
|
||||
fn parse_package_blob(blob: Option<&'static [u8]>) -> Result<(Vec<BinaryData>, String), String> {
|
||||
let Some(blob) = blob else {
|
||||
return Ok(Default::default());
|
||||
};
|
||||
let package = parse(blob).ok_or_else(|| "RDPKG resource is invalid".to_owned())?;
|
||||
if package.1.trim().is_empty() {
|
||||
return Err("RDPKG resource has no executable".to_owned());
|
||||
}
|
||||
Ok(package)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn read_package() -> Result<(Vec<BinaryData>, String), String> {
|
||||
parse_package_blob(read_resource(PACKAGE_RESOURCE_NAME))
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn read_package() -> Result<(Vec<BinaryData>, String), String> {
|
||||
Ok(Default::default())
|
||||
}
|
||||
|
||||
// Reads an RCDATA resource out of the running image. Resources live in the mapped
|
||||
// image for the lifetime of the process, so the slice is genuinely 'static and no
|
||||
// copy is needed.
|
||||
#[cfg(windows)]
|
||||
fn read_resource(name: &str) -> Option<&'static [u8]> {
|
||||
use std::ptr::null_mut;
|
||||
use winapi::um::libloaderapi::{FindResourceW, LoadResource, LockResource, SizeofResource};
|
||||
|
||||
// MAKEINTRESOURCEW(10), avoids depending on the winuser feature for RT_RCDATA.
|
||||
const RT_RCDATA: *const u16 = 10 as _;
|
||||
|
||||
let name: Vec<u16> = name.encode_utf16().chain(std::iter::once(0)).collect();
|
||||
unsafe {
|
||||
let info = FindResourceW(null_mut(), name.as_ptr(), RT_RCDATA);
|
||||
if info.is_null() {
|
||||
return None;
|
||||
}
|
||||
let size = SizeofResource(null_mut(), info) as usize;
|
||||
if size == 0 {
|
||||
return None;
|
||||
}
|
||||
let handle = LoadResource(null_mut(), info);
|
||||
if handle.is_null() {
|
||||
return None;
|
||||
}
|
||||
let data = LockResource(handle) as *const u8;
|
||||
if data.is_null() {
|
||||
return None;
|
||||
}
|
||||
Some(std::slice::from_raw_parts(data, size))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,59 +235,6 @@ impl BinaryData {
|
||||
}
|
||||
|
||||
impl BinaryReader {
|
||||
fn read() -> (Vec<BinaryData>, String) {
|
||||
let mut base: usize = 0;
|
||||
let mut parsed = vec![];
|
||||
assert!(BIN_DATA.len() > IDENTIFIER_LENGTH, "bin data invalid!");
|
||||
let mut iden = String::from_utf8_lossy(&BIN_DATA[base..base + IDENTIFIER_LENGTH]);
|
||||
if iden != "rustdesk" {
|
||||
panic!("bin file is not valid!");
|
||||
}
|
||||
base += IDENTIFIER_LENGTH;
|
||||
loop {
|
||||
iden = String::from_utf8_lossy(&BIN_DATA[base..base + IDENTIFIER_LENGTH]);
|
||||
if iden == "rustdesk" {
|
||||
base += IDENTIFIER_LENGTH;
|
||||
break;
|
||||
}
|
||||
// start reading
|
||||
let mut offset = 0;
|
||||
let path_length = u32::from_be_bytes([
|
||||
BIN_DATA[base + offset],
|
||||
BIN_DATA[base + offset + 1],
|
||||
BIN_DATA[base + offset + 2],
|
||||
BIN_DATA[base + offset + 3],
|
||||
]) as usize;
|
||||
offset += LENGTH;
|
||||
let path =
|
||||
String::from_utf8_lossy(&BIN_DATA[base + offset..base + offset + path_length])
|
||||
.to_string();
|
||||
offset += path_length;
|
||||
// file sz
|
||||
let file_length = u32::from_be_bytes([
|
||||
BIN_DATA[base + offset],
|
||||
BIN_DATA[base + offset + 1],
|
||||
BIN_DATA[base + offset + 2],
|
||||
BIN_DATA[base + offset + 3],
|
||||
]) as usize;
|
||||
offset += LENGTH;
|
||||
let raw = &BIN_DATA[base + offset..base + offset + file_length];
|
||||
offset += file_length;
|
||||
// md5
|
||||
let md5 = &BIN_DATA[base + offset..base + offset + MD5_LENGTH];
|
||||
offset += MD5_LENGTH;
|
||||
parsed.push(BinaryData {
|
||||
md5_code: md5,
|
||||
raw: raw,
|
||||
path: path,
|
||||
});
|
||||
base += offset;
|
||||
}
|
||||
// executable
|
||||
let executable = String::from_utf8_lossy(&BIN_DATA[base..]).to_string();
|
||||
(parsed, executable)
|
||||
}
|
||||
|
||||
#[cfg(linux)]
|
||||
pub fn configure_permission(&self, prefix: &Path) {
|
||||
use std::os::unix::prelude::PermissionsExt;
|
||||
@@ -137,3 +251,155 @@ impl BinaryReader {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Builds a blob in the same layout generate.py writes, so these tests pin the
|
||||
// cross-language format contract as well as the merge rules.
|
||||
fn blob(files: &[(&str, &[u8])], exe: &str) -> &'static [u8] {
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(IDENTIFIER);
|
||||
for (path, data) in files {
|
||||
out.extend_from_slice(&(path.len() as u32).to_be_bytes());
|
||||
out.extend_from_slice(path.as_bytes());
|
||||
out.extend_from_slice(&(data.len() as u32).to_be_bytes());
|
||||
out.extend_from_slice(data);
|
||||
out.extend_from_slice(&[b'a'; MD5_LENGTH]);
|
||||
}
|
||||
out.extend_from_slice(IDENTIFIER);
|
||||
out.extend_from_slice(exe.as_bytes());
|
||||
Box::leak(out.into_boxed_slice())
|
||||
}
|
||||
|
||||
fn entry<'a>(files: &'a [BinaryData], path: &str) -> Option<&'a BinaryData> {
|
||||
files
|
||||
.iter()
|
||||
.find(|file| normalize_path(&file.path) == normalize_path(path))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_the_generate_py_layout() {
|
||||
let (files, exe) = parse(blob(
|
||||
&[("./rustdesk.exe", b"app"), ("./custom.txt", b"cfg")],
|
||||
"./rustdesk.exe",
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(exe, "./rustdesk.exe");
|
||||
assert_eq!(files.len(), 2);
|
||||
assert_eq!(entry(&files, "./custom.txt").unwrap().raw, b"cfg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_blobs() {
|
||||
assert!(parse(b"".as_slice()).is_none());
|
||||
assert!(parse(b"notrustd".as_slice()).is_none());
|
||||
// Truncated mid-record rather than panicking on a slice out of range.
|
||||
assert!(parse(b"rustdesk\x00\x00\x00\x40partial".as_slice()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinguishes_an_absent_package_from_a_malformed_one() {
|
||||
assert!(parse_package_blob(None).unwrap().0.is_empty());
|
||||
assert!(parse_package_blob(Some(b"damaged")).is_err());
|
||||
assert!(parse_package_blob(Some(blob(&[("./custom.txt", b"cfg")], ""))).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_package_the_stock_payload_is_untouched() {
|
||||
let embedded = parse(blob(&[("./rustdesk.exe", b"app")], "./rustdesk.exe")).unwrap();
|
||||
let (files, exe) = merge(embedded, Default::default());
|
||||
assert_eq!(exe, "./rustdesk.exe");
|
||||
assert!(entry(&files, "./rustdesk.exe").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renames_the_stock_executable_to_the_package_name() {
|
||||
// x86: the big executable stays in the generic payload and only gets renamed.
|
||||
let embedded = parse(blob(
|
||||
&[("./rustdesk.exe", b"app"), ("./sciter.dll", b"dll")],
|
||||
"./rustdesk.exe",
|
||||
))
|
||||
.unwrap();
|
||||
let package = parse(blob(&[("./custom.txt", b"cfg")], "./acme.exe")).unwrap();
|
||||
|
||||
let (files, exe) = merge(embedded, package);
|
||||
|
||||
assert_eq!(exe, "./acme.exe");
|
||||
assert!(entry(&files, "./acme.exe").is_some());
|
||||
assert!(entry(&files, "./rustdesk.exe").is_none());
|
||||
// Untouched neighbours survive.
|
||||
assert_eq!(entry(&files, "./sciter.dll").unwrap().raw, b"dll");
|
||||
assert_eq!(entry(&files, "./custom.txt").unwrap().raw, b"cfg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_entries_win_over_the_generic_payload() {
|
||||
// x64: the customized executable and icons ship in the package instead.
|
||||
let embedded = parse(blob(
|
||||
&[
|
||||
("./data/flutter_assets/assets/icon.ico", b"stock-icon"),
|
||||
("./librustdesk.dll", b"core"),
|
||||
],
|
||||
"./rustdesk.exe",
|
||||
))
|
||||
.unwrap();
|
||||
let package = parse(blob(
|
||||
&[
|
||||
("./acme.exe", b"branded"),
|
||||
("./data/flutter_assets/assets/icon.ico", b"acme-icon"),
|
||||
],
|
||||
"./acme.exe",
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let (files, exe) = merge(embedded, package);
|
||||
|
||||
assert_eq!(exe, "./acme.exe");
|
||||
assert_eq!(
|
||||
entry(&files, "./data/flutter_assets/assets/icon.ico")
|
||||
.unwrap()
|
||||
.raw,
|
||||
b"acme-icon"
|
||||
);
|
||||
assert_eq!(
|
||||
files
|
||||
.iter()
|
||||
.filter(|f| normalize_path(&f.path) == "data/flutter_assets/assets/icon.ico")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(entry(&files, "./librustdesk.dll").unwrap().raw, b"core");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_paths_are_recorded_for_the_dropped_file_sweep() {
|
||||
let package = parse(blob(
|
||||
&[("./custom.txt", b"cfg"), ("./data/logo.png", b"img")],
|
||||
"./acme.exe",
|
||||
))
|
||||
.unwrap();
|
||||
let mut paths: Vec<String> = package.0.iter().map(|f| f.path.clone()).collect();
|
||||
paths.sort();
|
||||
assert_eq!(paths, vec!["./custom.txt", "./data/logo.png"]);
|
||||
|
||||
// Merging must not disturb them: the generic payload contributes none.
|
||||
let embedded = parse(blob(&[("./librustdesk.dll", b"core")], "./rustdesk.exe")).unwrap();
|
||||
let (files, _) = merge(embedded, package);
|
||||
assert!(entry(&files, "./data/logo.png").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_paths_across_separator_styles() {
|
||||
// generate.py emits backslashes when it runs on Windows.
|
||||
let embedded = parse(blob(&[(".\\rustdesk.exe", b"app")], ".\\rustdesk.exe")).unwrap();
|
||||
let package = parse(blob(&[("./custom.txt", b"cfg")], "./acme.exe")).unwrap();
|
||||
|
||||
let (files, exe) = merge(embedded, package);
|
||||
|
||||
assert_eq!(exe, "./acme.exe");
|
||||
assert!(entry(&files, "./acme.exe").is_some());
|
||||
assert!(entry(&files, ".\\rustdesk.exe").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::{
|
||||
process::{Command, Stdio},
|
||||
};
|
||||
|
||||
use bin_reader::BinaryReader;
|
||||
use bin_reader::{normalize_path, BinaryReader};
|
||||
|
||||
pub mod bin_reader;
|
||||
#[cfg(windows)]
|
||||
@@ -17,11 +17,24 @@ const APP_METADATA: &[u8] = include_bytes!("../app_metadata.toml");
|
||||
const APP_METADATA: &[u8] = &[];
|
||||
const APP_METADATA_CONFIG: &str = "meta.toml";
|
||||
const META_LINE_PREFIX_TIMESTAMP: &str = "timestamp = ";
|
||||
const META_LINE_PREFIX_FILE: &str = "file = ";
|
||||
const APP_PREFIX: &str = "rustdesk";
|
||||
const APPNAME_RUNTIME_ENV_KEY: &str = "RUSTDESK_APPNAME";
|
||||
#[cfg(windows)]
|
||||
const SET_FOREGROUND_WINDOW_ENV_KEY: &str = "SET_FOREGROUND_WINDOW";
|
||||
|
||||
// The extraction directory follows whatever executable the payload asks for, so a
|
||||
// custom client gets its own directory instead of sharing RustDesk's. Falls back to
|
||||
// APP_PREFIX when no package is injected, which keeps stock builds unchanged.
|
||||
fn app_dir_name(exe: &str) -> String {
|
||||
Path::new(&exe.replace('\\', "/"))
|
||||
.file_stem()
|
||||
.and_then(|stem| stem.to_str())
|
||||
.map(|stem| stem.trim().to_lowercase())
|
||||
.filter(|stem| !stem.is_empty())
|
||||
.unwrap_or_else(|| APP_PREFIX.to_owned())
|
||||
}
|
||||
|
||||
fn is_timestamp_matches(dir: &Path, ts: &mut u64) -> bool {
|
||||
let Ok(app_metadata) = std::str::from_utf8(APP_METADATA) else {
|
||||
return true;
|
||||
@@ -50,13 +63,93 @@ fn is_timestamp_matches(dir: &Path, ts: &mut u64) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn write_meta(dir: &Path, ts: u64) {
|
||||
fn write_meta(dir: &Path, ts: u64, package_paths: &[String]) {
|
||||
let meta_file = dir.join(APP_METADATA_CONFIG);
|
||||
if ts != 0 {
|
||||
let content = format!("{}{}", META_LINE_PREFIX_TIMESTAMP, ts);
|
||||
// Ignore is ok here
|
||||
let _ = std::fs::write(meta_file, content);
|
||||
let mut content = format!("{}{}\n", META_LINE_PREFIX_TIMESTAMP, ts);
|
||||
for path in package_paths {
|
||||
content.push_str(&format!("{}{}\n", META_LINE_PREFIX_FILE, path));
|
||||
}
|
||||
// Ignore is ok here
|
||||
let _ = std::fs::write(meta_file, content);
|
||||
}
|
||||
|
||||
fn previous_package_files(dir: &Path) -> Vec<String> {
|
||||
let Ok(content) = std::fs::read_to_string(dir.join(APP_METADATA_CONFIG)) else {
|
||||
return Vec::new();
|
||||
};
|
||||
content
|
||||
.lines()
|
||||
.filter_map(|line| line.strip_prefix(META_LINE_PREFIX_FILE))
|
||||
.map(|path| path.trim().to_owned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
// meta.toml is plain text in a user-writable directory, and it now drives deletion,
|
||||
// so the path is rebuilt from plain components rather than joined as written. A
|
||||
// prefix, root or parent component would otherwise escape the extraction directory:
|
||||
// Path::join replaces the base entirely when given an absolute path.
|
||||
fn resolve_within(dir: &Path, relative: &str) -> Option<PathBuf> {
|
||||
use std::path::Component;
|
||||
let mut path = dir.to_path_buf();
|
||||
let mut any = false;
|
||||
for component in Path::new(&relative.replace('\\', "/")).components() {
|
||||
match component {
|
||||
Component::Normal(part) => {
|
||||
// A drive-relative name like "C:x" parses as Normal, and only a
|
||||
// Windows host would classify "C:/..." as a Prefix, so the colon is
|
||||
// rejected outright rather than relying on the host's parser.
|
||||
if part.to_string_lossy().contains(':') {
|
||||
return None;
|
||||
}
|
||||
path.push(part);
|
||||
any = true;
|
||||
}
|
||||
Component::CurDir => {}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
if any {
|
||||
Some(path)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// A customer who drops a branding asset gets a package without it, and the file
|
||||
// would otherwise linger in an existing extraction and keep being used. The wipe
|
||||
// cannot cover this: it is keyed on the packer's build timestamp, which is now the
|
||||
// same for every customer of a release.
|
||||
fn remove_dropped_package_files_with<F>(
|
||||
dir: &Path,
|
||||
current: &[String],
|
||||
mut remove_file: F,
|
||||
) -> Vec<String>
|
||||
where
|
||||
F: FnMut(&Path) -> std::io::Result<()>,
|
||||
{
|
||||
let keep: std::collections::HashSet<String> =
|
||||
current.iter().map(|p| normalize_path(p)).collect();
|
||||
let mut failed = Vec::new();
|
||||
for previous in previous_package_files(dir) {
|
||||
if keep.contains(&normalize_path(&previous)) {
|
||||
continue;
|
||||
}
|
||||
let Some(path) = resolve_within(dir, &previous) else {
|
||||
continue;
|
||||
};
|
||||
if path.is_file() {
|
||||
println!("removing dropped {}", previous);
|
||||
if let Err(error) = remove_file(&path) {
|
||||
eprintln!("failed to remove dropped {}: {}", previous, error);
|
||||
failed.push(previous);
|
||||
}
|
||||
}
|
||||
}
|
||||
failed
|
||||
}
|
||||
|
||||
fn remove_dropped_package_files(dir: &Path, current: &[String]) -> Vec<String> {
|
||||
remove_dropped_package_files_with(dir, current, |path| std::fs::remove_file(path))
|
||||
}
|
||||
|
||||
fn setup(
|
||||
@@ -71,7 +164,7 @@ fn setup(
|
||||
} else {
|
||||
// home dir
|
||||
if let Some(dir) = dirs::data_local_dir() {
|
||||
dir.join(APP_PREFIX)
|
||||
dir.join(app_dir_name(&reader.exe))
|
||||
} else {
|
||||
eprintln!("not found data local dir");
|
||||
return None;
|
||||
@@ -87,10 +180,12 @@ fn setup(
|
||||
}
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
let mut metadata_paths = reader.package_paths.clone();
|
||||
metadata_paths.extend(remove_dropped_package_files(&dir, &reader.package_paths));
|
||||
for file in reader.files.iter() {
|
||||
file.write_to_file(&dir);
|
||||
}
|
||||
write_meta(&dir, ts);
|
||||
write_meta(&dir, ts, &metadata_paths);
|
||||
#[cfg(windows)]
|
||||
win::copy_runtime_broker(&dir);
|
||||
#[cfg(linux)]
|
||||
@@ -174,7 +269,7 @@ fn execute(path: PathBuf, args: Vec<String>, _ui: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
fn main() -> Result<(), String> {
|
||||
let mut args = Vec::new();
|
||||
let mut arg_exe = Default::default();
|
||||
let mut i = 0;
|
||||
@@ -193,7 +288,7 @@ fn main() {
|
||||
let quick_support = false;
|
||||
|
||||
let mut ui = false;
|
||||
let reader = BinaryReader::default();
|
||||
let reader = BinaryReader::new()?;
|
||||
if let Some(exe) = setup(
|
||||
reader,
|
||||
None,
|
||||
@@ -208,6 +303,7 @@ fn main() {
|
||||
}
|
||||
execute(exe, args, ui);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
@@ -246,3 +342,27 @@ mod win {
|
||||
exe.contains("-qs-") || exe.contains("-qs.exe") || exe.contains("_qs.exe")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod meta_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resolve_within_rejects_paths_that_escape() {
|
||||
let base = Path::new("/base");
|
||||
assert_eq!(
|
||||
resolve_within(base, "./data/logo.png"),
|
||||
Some(base.join("data").join("logo.png"))
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_within(base, ".\\data\\logo.png"),
|
||||
Some(base.join("data").join("logo.png"))
|
||||
);
|
||||
// meta.toml is user-writable, so these must not reach remove_file.
|
||||
assert_eq!(resolve_within(base, "../../etc/passwd"), None);
|
||||
assert_eq!(resolve_within(base, "/etc/passwd"), None);
|
||||
assert_eq!(resolve_within(base, "C:\\Windows\\System32\\x.dll"), None);
|
||||
assert_eq!(resolve_within(base, "."), None);
|
||||
assert_eq!(resolve_within(base, ""), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ fn test_vpx(
|
||||
println!(
|
||||
"{:?} encode: {:?}, {} byte",
|
||||
codec_id,
|
||||
time_sum / yuv_count as _,
|
||||
time_sum / yuv_count as u32,
|
||||
size / yuv_count
|
||||
);
|
||||
|
||||
@@ -156,7 +156,7 @@ fn test_vpx(
|
||||
println!(
|
||||
"{:?} decode: {:?}",
|
||||
codec_id,
|
||||
start.elapsed() / yuv_count as _
|
||||
start.elapsed() / yuv_count as u32
|
||||
);
|
||||
}
|
||||
|
||||
@@ -212,7 +212,7 @@ fn test_av1(
|
||||
assert_eq!(av1s.len(), yuv_count);
|
||||
println!(
|
||||
"AV1 encode: {:?}, {} byte",
|
||||
time_sum / yuv_count as _,
|
||||
time_sum / yuv_count as u32,
|
||||
size / yuv_count
|
||||
);
|
||||
let mut decoder = AomDecoder::new().unwrap();
|
||||
@@ -221,7 +221,7 @@ fn test_av1(
|
||||
let _ = decoder.decode(&av1);
|
||||
let _ = decoder.flush();
|
||||
}
|
||||
println!("AV1 decode: {:?}", start.elapsed() / yuv_count as _);
|
||||
println!("AV1 decode: {:?}", start.elapsed() / yuv_count as u32);
|
||||
}
|
||||
|
||||
#[cfg(feature = "hwcodec")]
|
||||
|
||||
@@ -132,7 +132,15 @@ impl Display {
|
||||
.map(Display)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let displays_dxgi = Self::all_().unwrap_or(Default::default());
|
||||
let mut displays_dxgi = match Self::all_() {
|
||||
Ok(displays) => displays,
|
||||
Err(e) => {
|
||||
hbb_common::log::error!("DXGI display enumeration failed: {e}");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
// Win+P "Show only on 1/2" still enumerates detached DXGI outputs.
|
||||
displays_dxgi.retain(|d| d.is_online() && d.width() > 0 && d.height() > 0);
|
||||
|
||||
// Return gdi displays if dxgi is not supported
|
||||
if displays_dxgi.is_empty() {
|
||||
@@ -155,7 +163,6 @@ impl Display {
|
||||
}
|
||||
|
||||
// Reorder displays from dxgi
|
||||
let mut displays_dxgi = displays_dxgi;
|
||||
let mut displays_dxgi_ordered = Vec::new();
|
||||
for name in names_gdi.iter() {
|
||||
let pos = match displays_dxgi.iter().position(|d| d.name() == *name) {
|
||||
@@ -176,11 +183,11 @@ impl Display {
|
||||
}
|
||||
|
||||
pub fn width(&self) -> usize {
|
||||
self.0.width() as usize
|
||||
self.0.width().max(0) as usize
|
||||
}
|
||||
|
||||
pub fn height(&self) -> usize {
|
||||
self.0.height() as usize
|
||||
self.0.height().max(0) as usize
|
||||
}
|
||||
|
||||
pub fn name(&self) -> String {
|
||||
@@ -201,7 +208,8 @@ impl Display {
|
||||
|
||||
pub fn is_primary(&self) -> bool {
|
||||
// https://docs.microsoft.com/en-us/windows/win32/api/wingdi/ns-wingdi-devmodea
|
||||
self.origin() == (0, 0)
|
||||
// Detached outputs can still report origin (0,0) with a zero size.
|
||||
self.origin() == (0, 0) && self.width() > 0 && self.height() > 0
|
||||
}
|
||||
|
||||
#[cfg(feature = "vram")]
|
||||
|
||||
@@ -297,6 +297,30 @@ pub fn clear_wayland_displays_cache() {
|
||||
// capturer rebuild loop clears about once a second.
|
||||
}
|
||||
|
||||
// Bumped ONLY by the layout-drift edge in display_service (its single owner), never by cache
|
||||
// clears: session inits and hotplug workers clear the cache too, and a bump there tears down
|
||||
// every OTHER live capturer on a multi-display session. A capturer records this at build and
|
||||
// treats a later bump as "the layout changed under me, rebuild" — the only trigger a rotation
|
||||
// has, since it changes neither the CRTC mode nor the framebuffer size (rustdesk#15886).
|
||||
static SNAPSHOT_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
|
||||
/// Whether no snapshot has been cached: the signature of an enumeration that failed at session
|
||||
/// build (an `Err` is deliberately not cached), as opposed to a session that started healthy.
|
||||
#[cfg(feature = "drm")]
|
||||
pub fn wayland_snapshot_missing() -> bool {
|
||||
DISPLAYS.lock().unwrap().is_none()
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "drm"))]
|
||||
pub fn bump_layout_generation() {
|
||||
SNAPSHOT_GENERATION.fetch_add(1, std::sync::atomic::Ordering::Release);
|
||||
}
|
||||
|
||||
#[cfg(feature = "drm")]
|
||||
pub fn wayland_snapshot_generation() -> u64 {
|
||||
SNAPSHOT_GENERATION.load(std::sync::atomic::Ordering::Acquire)
|
||||
}
|
||||
|
||||
// Return (min_x, max_x, min_y, max_y)
|
||||
pub fn get_desktop_rect_for_uinput() -> Option<(i32, i32, i32, i32)> {
|
||||
let wayland_displays = get_displays();
|
||||
@@ -332,7 +356,8 @@ fn desktop_rect_of(displays: &[WaylandDisplayInfo]) -> Option<(i32, i32, i32, i3
|
||||
// Otherwise, we use the logical size for `uinput`.
|
||||
if displays.len() == 1 {
|
||||
let d = &displays[0];
|
||||
return Some((d.x, d.x + d.width, d.y, d.y + d.height));
|
||||
let (w, h) = oriented_physical(d);
|
||||
return Some((d.x, d.x + w, d.y, d.y + h));
|
||||
}
|
||||
|
||||
let mut min_x = i32::MAX;
|
||||
@@ -344,6 +369,8 @@ fn desktop_rect_of(displays: &[WaylandDisplayInfo]) -> Option<(i32, i32, i32, i3
|
||||
min_y = min_y.min(d.y);
|
||||
let size = if let Some(logical_size) = d.logical_size {
|
||||
logical_size
|
||||
} else if d.transform == 90 || d.transform == 270 {
|
||||
oriented_physical(d)
|
||||
} else {
|
||||
// When `logical_size` is None, we cannot obtain the correct desktop rectangle.
|
||||
// This may occur if the Wayland compositor does not provide logical size information,
|
||||
@@ -374,6 +401,24 @@ pub struct DisplayRect {
|
||||
pub y: i32,
|
||||
pub w: i32,
|
||||
pub h: i32,
|
||||
// Carried so the drift comparison sees 0<->180 and 90<->270 flips, whose rects are
|
||||
// otherwise identical; the remap itself matches by name and containment, never by this.
|
||||
pub transform: i32,
|
||||
}
|
||||
|
||||
/// Physical size in delivered orientation: a 90/270 output scans out WxH but is captured,
|
||||
/// advertised and pointed at as HxW.
|
||||
fn oriented_physical(d: &WaylandDisplayInfo) -> (i32, i32) {
|
||||
if d.transform == 90 || d.transform == 270 {
|
||||
(d.height, d.width)
|
||||
} else {
|
||||
(d.width, d.height)
|
||||
}
|
||||
}
|
||||
|
||||
/// The logical rectangles of a display list, for a caller that already has the list.
|
||||
pub fn logical_rects_of_displays(displays: &[WaylandDisplayInfo]) -> Vec<DisplayRect> {
|
||||
logical_rects_of(displays)
|
||||
}
|
||||
|
||||
fn logical_rects_of(displays: &[WaylandDisplayInfo]) -> Vec<DisplayRect> {
|
||||
@@ -386,9 +431,9 @@ fn logical_rects_of(displays: &[WaylandDisplayInfo]) -> Vec<DisplayRect> {
|
||||
.iter()
|
||||
.map(|d| {
|
||||
let (w, h) = if single {
|
||||
(d.width, d.height)
|
||||
oriented_physical(d)
|
||||
} else {
|
||||
d.logical_size.unwrap_or((d.width, d.height))
|
||||
d.logical_size.unwrap_or_else(|| oriented_physical(d))
|
||||
};
|
||||
DisplayRect {
|
||||
name: d.name.clone(),
|
||||
@@ -396,6 +441,7 @@ fn logical_rects_of(displays: &[WaylandDisplayInfo]) -> Vec<DisplayRect> {
|
||||
y: d.y,
|
||||
w,
|
||||
h,
|
||||
transform: d.transform,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
@@ -495,8 +541,8 @@ mod tests {
|
||||
#[test]
|
||||
fn test_clear_keeps_the_failure_stamp() {
|
||||
// The stamp describes the seat, not the cache: the ~1/s capturer rebuild loop clears,
|
||||
// and dropping the stamp with it would defeat the backoff. Sole test touching these
|
||||
// statics; serialize before adding another.
|
||||
// and dropping the stamp with it would defeat the backoff. The generation test also
|
||||
// calls clear now; both only assert monotonic/unchanged state, so they can interleave.
|
||||
*LAST_FAILED_LOOKUP.lock().unwrap() = Some(Instant::now());
|
||||
clear_wayland_displays_cache();
|
||||
let stamp = *LAST_FAILED_LOOKUP.lock().unwrap();
|
||||
@@ -519,6 +565,7 @@ mod tests {
|
||||
height,
|
||||
logical_size,
|
||||
refresh_rate: 60,
|
||||
transform: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -553,6 +600,42 @@ mod tests {
|
||||
assert_eq!(desktop_rect_of(&displays), Some((0, 5120, 0, 1440)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_rotated_display_swaps_the_uinput_rect() {
|
||||
// Review finding 1 on rustdesk#15889: the single-display branch served the unrotated
|
||||
// mode, so the pointer could not reach ~44% of a portrait screen.
|
||||
let mut d = display(0, 0, 1920, 1080, None);
|
||||
d.transform = 90;
|
||||
assert_eq!(desktop_rect_of(&[d.clone()]), Some((0, 1080, 0, 1920)));
|
||||
let rects = logical_rects_of(&[d]);
|
||||
assert_eq!((rects[0].w, rects[0].h), (1080, 1920));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_transform_flip_is_visible_to_the_drift_comparison() {
|
||||
// Review finding 5: 0<->180 and 90<->270 leave every rect identical; the transform
|
||||
// field is what lets `baseline != live` fire on them.
|
||||
let mut a = display(0, 0, 1920, 1080, Some((1920, 1080)));
|
||||
let mut b = a.clone();
|
||||
a.transform = 90;
|
||||
b.transform = 270;
|
||||
assert_ne!(logical_rects_of(&[a.clone(), a.clone()]), logical_rects_of(&[b.clone(), b]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_explicit_bump_moves_the_generation() {
|
||||
// A cache clear must NOT bump: session inits clear too, and a bump there rebuilds
|
||||
// every other live capturer (adversarial finding on the first version of this).
|
||||
let before = SNAPSHOT_GENERATION.load(std::sync::atomic::Ordering::Acquire);
|
||||
clear_wayland_displays_cache();
|
||||
assert_eq!(
|
||||
SNAPSHOT_GENERATION.load(std::sync::atomic::Ordering::Acquire),
|
||||
before
|
||||
);
|
||||
bump_layout_generation();
|
||||
assert!(SNAPSHOT_GENERATION.load(std::sync::atomic::Ordering::Acquire) > before);
|
||||
}
|
||||
|
||||
fn rect(name: &str, x: i32, y: i32, w: i32, h: i32) -> DisplayRect {
|
||||
DisplayRect {
|
||||
name: name.to_owned(),
|
||||
@@ -560,6 +643,7 @@ mod tests {
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
transform: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
pkgname=rustdesk
|
||||
pkgver=1.4.9
|
||||
pkgver=1.5.0
|
||||
pkgrel=0
|
||||
epoch=
|
||||
pkgdesc=""
|
||||
|
||||
417
res/admin-roles.py
Executable file
417
res/admin-roles.py
Executable file
@@ -0,0 +1,417 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import argparse
|
||||
import json
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
ROLE_TYPES = {
|
||||
"global": 1,
|
||||
"individual": 2,
|
||||
"group": 3,
|
||||
}
|
||||
|
||||
PERMISSION_IDS = {
|
||||
"users.view": 0x0101,
|
||||
"users.create": 0x0103,
|
||||
"users.invite": 0x0104,
|
||||
"users.delete": 0x0105,
|
||||
"users.enable_disable": 0x0106,
|
||||
"users.edit_email": 0x0107,
|
||||
"users.edit_password": 0x0108,
|
||||
"users.edit_note": 0x0109,
|
||||
"users.manage_2fa": 0x010A,
|
||||
"users.force_logout": 0x010B,
|
||||
"users.change_group": 0x010C,
|
||||
"users.change_strategy": 0x010D,
|
||||
"users.change_control_role": 0x010E,
|
||||
"users.edit_display_name": 0x010F,
|
||||
"devices.view": 0x0201,
|
||||
"devices.enable_disable": 0x0203,
|
||||
"devices.delete": 0x0204,
|
||||
"devices.edit_info": 0x0205,
|
||||
"devices.assign_to_user": 0x0206,
|
||||
"devices.change_group": 0x0207,
|
||||
"devices.change_strategy": 0x0208,
|
||||
"user_groups.view": 0x0301,
|
||||
"user_groups.edit": 0x0302,
|
||||
"device_groups.view": 0x0401,
|
||||
"device_groups.edit": 0x0402,
|
||||
"device_groups.change_strategy": 0x0403,
|
||||
"audits.view": 0x0501,
|
||||
"audits.edit": 0x0502,
|
||||
"strategies.view": 0x0601,
|
||||
"strategies.edit": 0x0602,
|
||||
"custom_clients.view": 0x0701,
|
||||
"custom_clients.edit": 0x0702,
|
||||
"control_roles.view": 0x0801,
|
||||
"control_roles.edit": 0x0802,
|
||||
}
|
||||
|
||||
PERMISSION_NAMES = {permission_id: name for name, permission_id in PERMISSION_IDS.items()}
|
||||
|
||||
|
||||
def check_response(response):
|
||||
if response.status_code != 200:
|
||||
print(f"Error: HTTP {response.status_code}: {response.text}")
|
||||
exit(1)
|
||||
|
||||
if response.text and response.text.strip():
|
||||
try:
|
||||
data = response.json()
|
||||
except ValueError:
|
||||
return response.text
|
||||
if isinstance(data, dict) and "error" in data:
|
||||
print(f"Error: {data['error']}")
|
||||
exit(1)
|
||||
return data
|
||||
return None
|
||||
|
||||
|
||||
def headers_with(token):
|
||||
return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
||||
|
||||
|
||||
def split_csv(value):
|
||||
if value is None:
|
||||
return None
|
||||
return [item.strip() for item in value.split(",") if item.strip()]
|
||||
|
||||
|
||||
def parse_permissions(value):
|
||||
permissions = []
|
||||
for item in split_csv(value) or []:
|
||||
permission = PERMISSION_IDS.get(item.lower())
|
||||
if permission is None:
|
||||
try:
|
||||
permission = int(item, 0)
|
||||
except ValueError:
|
||||
print(f"Error: Invalid permission name or ID '{item}'")
|
||||
exit(1)
|
||||
if permission < 0 or permission > 65535:
|
||||
print(f"Error: Permission ID '{item}' is outside the 0-65535 range")
|
||||
exit(1)
|
||||
permissions.append(permission)
|
||||
return permissions
|
||||
|
||||
|
||||
def format_role_permissions(role):
|
||||
permissions = role.get("permissions")
|
||||
if isinstance(permissions, list):
|
||||
role["permissions"] = [
|
||||
PERMISSION_NAMES.get(permission, permission) for permission in permissions
|
||||
]
|
||||
return role
|
||||
|
||||
|
||||
def list_roles(url, token, name=None, role_type=None, page_size=50):
|
||||
params = {"pageSize": page_size}
|
||||
if name is not None:
|
||||
params["name"] = name
|
||||
if role_type is not None:
|
||||
params["type"] = ROLE_TYPES[role_type]
|
||||
|
||||
roles = []
|
||||
current = 0
|
||||
while True:
|
||||
current += 1
|
||||
params["current"] = current
|
||||
response = requests.get(
|
||||
f"{url}/api/admin-roles", headers=headers_with(token), params=params
|
||||
)
|
||||
data = check_response(response)
|
||||
if not isinstance(data, dict):
|
||||
print("Error: Unexpected response while listing admin roles")
|
||||
exit(1)
|
||||
rows = data.get("data", [])
|
||||
roles.extend(format_role_permissions(role) for role in rows)
|
||||
total = data.get("total", 0)
|
||||
if len(rows) < page_size or current * page_size >= total:
|
||||
break
|
||||
return roles
|
||||
|
||||
|
||||
def get_role(url, token, name=None, guid=None):
|
||||
if guid:
|
||||
response = requests.get(
|
||||
f"{url}/api/admin-roles/{guid}", headers=headers_with(token)
|
||||
)
|
||||
role = check_response(response)
|
||||
if isinstance(role, dict):
|
||||
return format_role_permissions(role)
|
||||
return role
|
||||
|
||||
roles = list_roles(url, token, name=name)
|
||||
for role in roles:
|
||||
if role.get("name") == name:
|
||||
return role
|
||||
return None
|
||||
|
||||
|
||||
def resolve_role(url, token, name=None, guid=None):
|
||||
role = get_role(url, token, name=name, guid=guid)
|
||||
if role:
|
||||
return role
|
||||
target = guid if guid else name
|
||||
print(f"Error: Admin role '{target}' not found")
|
||||
exit(1)
|
||||
|
||||
|
||||
def get_user_guid(url, token, name):
|
||||
response = requests.get(
|
||||
f"{url}/api/users",
|
||||
headers=headers_with(token),
|
||||
params={"name": name, "pageSize": 50, "current": 1},
|
||||
)
|
||||
data = check_response(response)
|
||||
users = data.get("data", []) if isinstance(data, dict) else []
|
||||
for user in users:
|
||||
if user.get("name") == name:
|
||||
return user.get("guid")
|
||||
return None
|
||||
|
||||
|
||||
def resolve_users(url, token, users):
|
||||
guids = []
|
||||
for user in users:
|
||||
if len(user) == 36 and user.count("-") == 4:
|
||||
guids.append(user)
|
||||
continue
|
||||
guid = get_user_guid(url, token, user)
|
||||
if not guid:
|
||||
print(f"Error: User '{user}' not found")
|
||||
exit(1)
|
||||
guids.append(guid)
|
||||
return guids
|
||||
|
||||
|
||||
def create_role(
|
||||
url,
|
||||
token,
|
||||
name,
|
||||
role_type,
|
||||
permissions,
|
||||
note=None,
|
||||
user_groups=None,
|
||||
device_groups=None,
|
||||
unassigned=None,
|
||||
):
|
||||
payload = {
|
||||
"name": name,
|
||||
"type": ROLE_TYPES[role_type],
|
||||
"permissions": permissions,
|
||||
}
|
||||
if note is not None:
|
||||
payload["note"] = note
|
||||
if user_groups:
|
||||
payload["user_groups"] = user_groups
|
||||
if device_groups:
|
||||
payload["device_groups"] = device_groups
|
||||
if unassigned is not None:
|
||||
payload["unassigned"] = unassigned
|
||||
response = requests.post(
|
||||
f"{url}/api/admin-roles", headers=headers_with(token), json=payload
|
||||
)
|
||||
check_response(response)
|
||||
|
||||
|
||||
def update_role(
|
||||
url,
|
||||
token,
|
||||
guid,
|
||||
new_name=None,
|
||||
note=None,
|
||||
permissions=None,
|
||||
user_groups=None,
|
||||
device_groups=None,
|
||||
unassigned=None,
|
||||
):
|
||||
payload = {}
|
||||
if new_name is not None:
|
||||
payload["name"] = new_name
|
||||
if note is not None:
|
||||
payload["note"] = note
|
||||
if permissions is not None:
|
||||
payload["permissions"] = permissions
|
||||
if user_groups is not None:
|
||||
payload["user_groups"] = user_groups
|
||||
if device_groups is not None:
|
||||
payload["device_groups"] = device_groups
|
||||
if unassigned is not None:
|
||||
payload["unassigned"] = unassigned
|
||||
response = requests.put(
|
||||
f"{url}/api/admin-roles/{guid}", headers=headers_with(token), json=payload
|
||||
)
|
||||
check_response(response)
|
||||
|
||||
|
||||
def delete_roles(url, token, guids):
|
||||
response = requests.delete(
|
||||
f"{url}/api/admin-roles",
|
||||
headers=headers_with(token),
|
||||
json={"guids": guids},
|
||||
)
|
||||
check_response(response)
|
||||
|
||||
|
||||
def change_users(url, token, guid, users, remove=False):
|
||||
method = requests.delete if remove else requests.post
|
||||
response = method(
|
||||
f"{url}/api/admin-roles/{guid}/users",
|
||||
headers=headers_with(token),
|
||||
json={"users": users},
|
||||
)
|
||||
check_response(response)
|
||||
|
||||
|
||||
def view_users(url, token, role_guid, page_size=50):
|
||||
params = {"admin_role_guid": role_guid, "pageSize": page_size}
|
||||
users = []
|
||||
current = 0
|
||||
while True:
|
||||
current += 1
|
||||
params["current"] = current
|
||||
response = requests.get(
|
||||
f"{url}/api/users", headers=headers_with(token), params=params
|
||||
)
|
||||
data = check_response(response)
|
||||
if not isinstance(data, dict):
|
||||
print("Error: Unexpected response while listing users")
|
||||
exit(1)
|
||||
rows = data.get("data", [])
|
||||
users.extend(rows)
|
||||
total = data.get("total", 0)
|
||||
if len(rows) < page_size or current * page_size >= total:
|
||||
break
|
||||
return users
|
||||
|
||||
|
||||
def require_role_target(parser, args):
|
||||
if not args.name and not args.guid:
|
||||
parser.error("one of --name or --guid is required")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Admin role manager")
|
||||
parser.add_argument(
|
||||
"command",
|
||||
choices=["view", "add", "update", "delete", "view-users", "add-users", "remove-users"],
|
||||
)
|
||||
parser.add_argument("--url", required=True, help="Server URL")
|
||||
parser.add_argument("--token", required=True, help="API token")
|
||||
parser.add_argument("--name", help="Admin role name")
|
||||
parser.add_argument("--guid", help="Admin role GUID")
|
||||
parser.add_argument("--new-name", help="New admin role name")
|
||||
parser.add_argument("--note", help="Role note; use an empty value to clear it")
|
||||
parser.add_argument("--type", choices=ROLE_TYPES, help="Role type")
|
||||
parser.add_argument(
|
||||
"--permissions",
|
||||
help="Comma-separated permission names or numeric IDs; use an empty value to clear",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--user-groups",
|
||||
help="Comma-separated user group names; use an empty value to clear",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--device-groups",
|
||||
help="Comma-separated device group names; use an empty value to clear",
|
||||
)
|
||||
parser.add_argument("--users", help="Comma-separated user names or GUIDs")
|
||||
unassigned = parser.add_mutually_exclusive_group()
|
||||
unassigned.add_argument(
|
||||
"--unassigned", dest="unassigned", action="store_true", help="Include unassigned devices"
|
||||
)
|
||||
unassigned.add_argument(
|
||||
"--no-unassigned",
|
||||
dest="unassigned",
|
||||
action="store_false",
|
||||
help="Exclude unassigned devices",
|
||||
)
|
||||
parser.set_defaults(unassigned=None)
|
||||
args = parser.parse_args()
|
||||
args.url = args.url.rstrip("/")
|
||||
|
||||
if args.command == "view":
|
||||
if args.guid:
|
||||
result = resolve_role(args.url, args.token, guid=args.guid)
|
||||
else:
|
||||
result = list_roles(args.url, args.token, args.name, args.type)
|
||||
print(json.dumps(result, indent=2))
|
||||
return
|
||||
|
||||
if args.command == "add":
|
||||
if not args.name or not args.type or args.permissions is None:
|
||||
parser.error("--name, --type, and --permissions are required for add")
|
||||
if args.type != "group" and (
|
||||
args.user_groups is not None
|
||||
or args.device_groups is not None
|
||||
or args.unassigned is not None
|
||||
):
|
||||
parser.error("group scope options can only be used with --type group")
|
||||
create_role(
|
||||
args.url,
|
||||
args.token,
|
||||
args.name,
|
||||
args.type,
|
||||
parse_permissions(args.permissions),
|
||||
args.note,
|
||||
split_csv(args.user_groups),
|
||||
split_csv(args.device_groups),
|
||||
args.unassigned,
|
||||
)
|
||||
print(f"Success: Created admin role '{args.name}'")
|
||||
return
|
||||
|
||||
require_role_target(parser, args)
|
||||
role = resolve_role(args.url, args.token, args.name, args.guid)
|
||||
role_guid = role.get("guid")
|
||||
role_name = role.get("name")
|
||||
|
||||
if args.command == "update":
|
||||
updates = [
|
||||
args.new_name,
|
||||
args.note,
|
||||
args.permissions,
|
||||
args.user_groups,
|
||||
args.device_groups,
|
||||
args.unassigned,
|
||||
]
|
||||
if all(value is None for value in updates):
|
||||
parser.error("at least one update option is required")
|
||||
if role.get("type") != ROLE_TYPES["group"] and (
|
||||
args.user_groups is not None
|
||||
or args.device_groups is not None
|
||||
or args.unassigned is not None
|
||||
):
|
||||
parser.error("group scope options can only be used with a group role")
|
||||
update_role(
|
||||
args.url,
|
||||
args.token,
|
||||
role_guid,
|
||||
args.new_name,
|
||||
args.note,
|
||||
parse_permissions(args.permissions) if args.permissions is not None else None,
|
||||
split_csv(args.user_groups),
|
||||
split_csv(args.device_groups),
|
||||
args.unassigned,
|
||||
)
|
||||
print(f"Success: Updated admin role '{role_name}'")
|
||||
elif args.command == "delete":
|
||||
delete_roles(args.url, args.token, [role_guid])
|
||||
print(f"Success: Deleted admin role '{role_name}'")
|
||||
elif args.command == "view-users":
|
||||
print(json.dumps(view_users(args.url, args.token, role_guid), indent=2))
|
||||
elif args.command in ("add-users", "remove-users"):
|
||||
users = split_csv(args.users)
|
||||
if not users:
|
||||
parser.error("--users is required for add-users and remove-users")
|
||||
user_guids = resolve_users(args.url, args.token, users)
|
||||
remove = args.command == "remove-users"
|
||||
change_users(args.url, args.token, role_guid, user_guids, remove=remove)
|
||||
action = "Removed users from" if remove else "Added users to"
|
||||
print(f"Success: {action} admin role '{role_name}'")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
292
res/control-roles.py
Executable file
292
res/control-roles.py
Executable file
@@ -0,0 +1,292 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import argparse
|
||||
import json
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
STATUSES = {
|
||||
"disabled": 0,
|
||||
"enabled": 1,
|
||||
}
|
||||
|
||||
|
||||
def check_response(response):
|
||||
if response.status_code != 200:
|
||||
print(f"Error: HTTP {response.status_code}: {response.text}")
|
||||
exit(1)
|
||||
|
||||
if response.text and response.text.strip():
|
||||
try:
|
||||
data = response.json()
|
||||
except ValueError:
|
||||
return response.text
|
||||
if isinstance(data, dict) and "error" in data:
|
||||
print(f"Error: {data['error']}")
|
||||
exit(1)
|
||||
return data
|
||||
return None
|
||||
|
||||
|
||||
def headers_with(token):
|
||||
return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
||||
|
||||
|
||||
def split_csv(value):
|
||||
if value is None:
|
||||
return None
|
||||
return [item.strip() for item in value.split(",") if item.strip()]
|
||||
|
||||
|
||||
def list_roles(url, token, name=None, status=None, page_size=50):
|
||||
params = {"pageSize": page_size}
|
||||
if name is not None:
|
||||
params["name"] = name
|
||||
if status is not None:
|
||||
params["status"] = STATUSES[status]
|
||||
|
||||
roles = []
|
||||
current = 0
|
||||
while True:
|
||||
current += 1
|
||||
params["current"] = current
|
||||
response = requests.get(
|
||||
f"{url}/api/control-roles", headers=headers_with(token), params=params
|
||||
)
|
||||
data = check_response(response)
|
||||
if not isinstance(data, dict):
|
||||
print("Error: Unexpected response while listing control roles")
|
||||
exit(1)
|
||||
rows = data.get("data", [])
|
||||
for role in rows:
|
||||
role.pop("info", None)
|
||||
roles.extend(rows)
|
||||
total = data.get("total", 0)
|
||||
if len(rows) < page_size or current * page_size >= total:
|
||||
break
|
||||
return roles
|
||||
|
||||
|
||||
def get_role(url, token, name=None, guid=None):
|
||||
if guid:
|
||||
response = requests.get(
|
||||
f"{url}/api/control-roles/{guid}", headers=headers_with(token)
|
||||
)
|
||||
role = check_response(response)
|
||||
if isinstance(role, dict):
|
||||
role.pop("info", None)
|
||||
return role
|
||||
|
||||
roles = list_roles(url, token, name=name)
|
||||
for role in roles:
|
||||
if role.get("name") == name:
|
||||
return role
|
||||
return None
|
||||
|
||||
|
||||
def resolve_role(url, token, name=None, guid=None):
|
||||
role = get_role(url, token, name=name, guid=guid)
|
||||
if role:
|
||||
return role
|
||||
target = guid if guid else name
|
||||
print(f"Error: Control role '{target}' not found")
|
||||
exit(1)
|
||||
|
||||
|
||||
def get_user_guid(url, token, name):
|
||||
response = requests.get(
|
||||
f"{url}/api/users",
|
||||
headers=headers_with(token),
|
||||
params={"name": name, "pageSize": 50, "current": 1},
|
||||
)
|
||||
data = check_response(response)
|
||||
users = data.get("data", []) if isinstance(data, dict) else []
|
||||
for user in users:
|
||||
if user.get("name") == name:
|
||||
return user.get("guid")
|
||||
return None
|
||||
|
||||
|
||||
def resolve_users(url, token, users):
|
||||
guids = []
|
||||
for user in users:
|
||||
if len(user) == 36 and user.count("-") == 4:
|
||||
guids.append(user)
|
||||
continue
|
||||
guid = get_user_guid(url, token, user)
|
||||
if not guid:
|
||||
print(f"Error: User '{user}' not found")
|
||||
exit(1)
|
||||
guids.append(guid)
|
||||
return guids
|
||||
|
||||
|
||||
def create_role(url, token, name, note=None):
|
||||
payload = {"name": name}
|
||||
if note is not None:
|
||||
payload["note"] = note
|
||||
response = requests.post(
|
||||
f"{url}/api/control-roles", headers=headers_with(token), json=payload
|
||||
)
|
||||
check_response(response)
|
||||
|
||||
|
||||
def update_role(url, token, guid, new_name=None, note=None):
|
||||
payload = {}
|
||||
if new_name is not None:
|
||||
payload["name"] = new_name
|
||||
if note is not None:
|
||||
payload["note"] = note
|
||||
response = requests.put(
|
||||
f"{url}/api/control-roles/{guid}", headers=headers_with(token), json=payload
|
||||
)
|
||||
check_response(response)
|
||||
|
||||
|
||||
def delete_roles(url, token, guids):
|
||||
response = requests.delete(
|
||||
f"{url}/api/control-roles",
|
||||
headers=headers_with(token),
|
||||
json={"guids": guids},
|
||||
)
|
||||
check_response(response)
|
||||
|
||||
|
||||
def set_status(url, token, guids, disable):
|
||||
response = requests.put(
|
||||
f"{url}/api/control-roles/enable",
|
||||
headers=headers_with(token),
|
||||
json={"guids": guids, "disable": disable},
|
||||
)
|
||||
check_response(response)
|
||||
|
||||
|
||||
def change_users(url, token, guid, users, remove=False):
|
||||
if remove:
|
||||
endpoint = f"{url}/api/control-roles/users"
|
||||
response = requests.delete(
|
||||
endpoint,
|
||||
headers=headers_with(token),
|
||||
json={"user_guids": users},
|
||||
)
|
||||
else:
|
||||
endpoint = f"{url}/api/control-roles/{guid}/users"
|
||||
response = requests.post(
|
||||
endpoint,
|
||||
headers=headers_with(token),
|
||||
json={"user_guids": users},
|
||||
)
|
||||
check_response(response)
|
||||
|
||||
|
||||
def view_users(url, token, role_guid, page_size=50):
|
||||
params = {"control_role_guid": role_guid, "pageSize": page_size}
|
||||
users = []
|
||||
current = 0
|
||||
while True:
|
||||
current += 1
|
||||
params["current"] = current
|
||||
response = requests.get(
|
||||
f"{url}/api/users", headers=headers_with(token), params=params
|
||||
)
|
||||
data = check_response(response)
|
||||
if not isinstance(data, dict):
|
||||
print("Error: Unexpected response while listing users")
|
||||
exit(1)
|
||||
rows = data.get("data", [])
|
||||
users.extend(rows)
|
||||
total = data.get("total", 0)
|
||||
if len(rows) < page_size or current * page_size >= total:
|
||||
break
|
||||
return users
|
||||
|
||||
|
||||
def require_role_target(parser, args):
|
||||
if not args.name and not args.guid:
|
||||
parser.error("one of --name or --guid is required")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Control role manager (configure control permissions in the web console)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"command",
|
||||
choices=[
|
||||
"view",
|
||||
"add",
|
||||
"update",
|
||||
"delete",
|
||||
"enable",
|
||||
"disable",
|
||||
"view-users",
|
||||
"assign-users",
|
||||
"remove-users",
|
||||
],
|
||||
)
|
||||
parser.add_argument("--url", required=True, help="Server URL")
|
||||
parser.add_argument("--token", required=True, help="API token")
|
||||
parser.add_argument("--name", help="Control role name")
|
||||
parser.add_argument("--guid", help="Control role GUID")
|
||||
parser.add_argument("--new-name", help="New control role name")
|
||||
parser.add_argument("--note", help="Role note; use an empty value to clear it")
|
||||
parser.add_argument("--status", choices=STATUSES, help="Status filter for view")
|
||||
parser.add_argument("--users", help="Comma-separated user names or GUIDs")
|
||||
args = parser.parse_args()
|
||||
args.url = args.url.rstrip("/")
|
||||
|
||||
if args.command == "view":
|
||||
if args.guid:
|
||||
result = resolve_role(args.url, args.token, guid=args.guid)
|
||||
else:
|
||||
result = list_roles(args.url, args.token, args.name, args.status)
|
||||
print(json.dumps(result, indent=2))
|
||||
return
|
||||
|
||||
if args.command == "add":
|
||||
if not args.name:
|
||||
parser.error("--name is required for add")
|
||||
create_role(args.url, args.token, args.name, args.note)
|
||||
print(f"Success: Created control role '{args.name}'")
|
||||
return
|
||||
|
||||
if args.command == "remove-users":
|
||||
users = split_csv(args.users)
|
||||
if not users:
|
||||
parser.error("--users is required for remove-users")
|
||||
user_guids = resolve_users(args.url, args.token, users)
|
||||
change_users(args.url, args.token, None, user_guids, remove=True)
|
||||
print("Success: Removed users from their control roles")
|
||||
return
|
||||
|
||||
require_role_target(parser, args)
|
||||
role = resolve_role(args.url, args.token, args.name, args.guid)
|
||||
role_guid = role.get("guid")
|
||||
role_name = role.get("name")
|
||||
|
||||
if args.command == "update":
|
||||
if args.new_name is None and args.note is None:
|
||||
parser.error("--new-name or --note is required for update")
|
||||
update_role(args.url, args.token, role_guid, args.new_name, args.note)
|
||||
print(f"Success: Updated control role '{role_name}'")
|
||||
elif args.command == "delete":
|
||||
delete_roles(args.url, args.token, [role_guid])
|
||||
print(f"Success: Deleted control role '{role_name}'")
|
||||
elif args.command in ("enable", "disable"):
|
||||
disable = args.command == "disable"
|
||||
set_status(args.url, args.token, [role_guid], disable)
|
||||
print(f"Success: {args.command.title()}d control role '{role_name}'")
|
||||
elif args.command == "view-users":
|
||||
print(json.dumps(view_users(args.url, args.token, role_guid), indent=2))
|
||||
elif args.command == "assign-users":
|
||||
users = split_csv(args.users)
|
||||
if not users:
|
||||
parser.error("--users is required for assign-users")
|
||||
user_guids = resolve_users(args.url, args.token, users)
|
||||
change_users(args.url, args.token, role_guid, user_guids)
|
||||
print(f"Success: Assigned users to control role '{role_name}'")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -18,6 +18,9 @@ void UninstallDriver(LPCWSTR hardwareId, BOOL &rebootRequired);
|
||||
|
||||
namespace RemotePrinter
|
||||
{
|
||||
VOID installUpdatePrinter(const std::wstring& installFolder);
|
||||
VOID uninstallPrinter();
|
||||
// `appName` names the printer and its port. It is passed in rather than compiled
|
||||
// in so that a single dll serves every custom client; an empty value keeps the
|
||||
// stock "RustDesk Printer" name.
|
||||
VOID installUpdatePrinter(const std::wstring& installFolder, const std::wstring& appName);
|
||||
VOID uninstallPrinter(const std::wstring& appName);
|
||||
}
|
||||
|
||||
@@ -300,7 +300,7 @@ bool TerminateProcessesByNameW(LPCWSTR processName, LPCWSTR excludeParam)
|
||||
{
|
||||
do
|
||||
{
|
||||
if (lstrcmpW(processName, processEntry.szExeFile) == 0)
|
||||
if (lstrcmpiW(processName, processEntry.szExeFile) == 0)
|
||||
{
|
||||
HANDLE process = OpenProcess(PROCESS_TERMINATE | PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, FALSE, processEntry.th32ProcessID);
|
||||
if (process != NULL)
|
||||
@@ -1021,9 +1021,9 @@ UINT __stdcall InstallPrinter(
|
||||
DWORD er = ERROR_SUCCESS;
|
||||
|
||||
int nResult = 0;
|
||||
LPWSTR installFolder = NULL;
|
||||
LPWSTR pwz = NULL;
|
||||
LPWSTR pwzData = NULL;
|
||||
std::wstring appNameValue;
|
||||
std::wstring installFolderValue;
|
||||
|
||||
hr = WcaInitialize(hInstall, "InstallPrinter");
|
||||
ExitOnFailure(hr, "Failed to initialize");
|
||||
@@ -1031,12 +1031,27 @@ UINT __stdcall InstallPrinter(
|
||||
hr = WcaGetProperty(L"CustomActionData", &pwzData);
|
||||
ExitOnFailure(hr, "failed to get CustomActionData");
|
||||
|
||||
pwz = pwzData;
|
||||
hr = WcaReadStringFromCaData(&pwz, &installFolder);
|
||||
ExitOnFailure(hr, "failed to read database key from custom action data: %ls", pwz);
|
||||
// "<app name>|<install folder>". Split here rather than through
|
||||
// WcaReadStringFromCaData, whose delimiter is a literal wide char 128 that a
|
||||
// Formatted property value cannot carry.
|
||||
{
|
||||
std::wstring data(pwzData);
|
||||
size_t separator = data.find(L'|');
|
||||
if (separator == std::wstring::npos)
|
||||
{
|
||||
// A package built before the name was passed in; keep the stock name.
|
||||
appNameValue.clear();
|
||||
installFolderValue = data;
|
||||
}
|
||||
else
|
||||
{
|
||||
appNameValue = data.substr(0, separator);
|
||||
installFolderValue = data.substr(separator + 1);
|
||||
}
|
||||
}
|
||||
|
||||
WcaLog(LOGMSG_STANDARD, "Try to install RD printer in : %ls", installFolder);
|
||||
RemotePrinter::installUpdatePrinter(installFolder);
|
||||
WcaLog(LOGMSG_STANDARD, "Try to install RD printer in : %ls", installFolderValue.c_str());
|
||||
RemotePrinter::installUpdatePrinter(installFolderValue, appNameValue);
|
||||
WcaLog(LOGMSG_STANDARD, "Install RD printer done");
|
||||
|
||||
LExit:
|
||||
@@ -1054,14 +1069,30 @@ UINT __stdcall UninstallPrinter(
|
||||
HRESULT hr = S_OK;
|
||||
DWORD er = ERROR_SUCCESS;
|
||||
|
||||
LPWSTR pwzData = NULL;
|
||||
std::wstring appNameValue;
|
||||
|
||||
hr = WcaInitialize(hInstall, "UninstallPrinter");
|
||||
ExitOnFailure(hr, "Failed to initialize");
|
||||
|
||||
// Must match the name install used, otherwise the printer is left behind. Absent
|
||||
// on packages built before this was passed in, where it was the stock name.
|
||||
hr = WcaGetProperty(L"CustomActionData", &pwzData);
|
||||
ExitOnFailure(hr, "failed to get CustomActionData");
|
||||
if (pwzData)
|
||||
{
|
||||
appNameValue = pwzData;
|
||||
}
|
||||
|
||||
WcaLog(LOGMSG_STANDARD, "Try to uninstall RD printer");
|
||||
RemotePrinter::uninstallPrinter();
|
||||
RemotePrinter::uninstallPrinter(appNameValue);
|
||||
WcaLog(LOGMSG_STANDARD, "Uninstall RD printer done");
|
||||
|
||||
LExit:
|
||||
if (pwzData) {
|
||||
ReleaseStr(pwzData);
|
||||
}
|
||||
|
||||
er = SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE;
|
||||
return WcaFinalize(er);
|
||||
}
|
||||
|
||||
@@ -18,12 +18,19 @@ namespace RemotePrinter
|
||||
{
|
||||
#define HRESULT_ERR_ELEMENT_NOT_FOUND 0x80070490
|
||||
|
||||
// The driver files and the driver name ship with the app under their stock names
|
||||
// and stay fixed for every custom client. Only the printer and its port carry the
|
||||
// app name, and that arrives at runtime so one dll serves every custom client.
|
||||
LPCWCH RD_DRIVER_INF_PATH = L"drivers\\RustDeskPrinterDriver\\RustDeskPrinterDriver.inf";
|
||||
LPCWCH RD_PRINTER_PORT = L"RustDesk Printer";
|
||||
LPCWCH RD_PRINTER_NAME = L"RustDesk Printer";
|
||||
LPCWCH RD_PRINTER_DRIVER_NAME = L"RustDesk v4 Printer Driver";
|
||||
LPCWCH RD_DEFAULT_APP_NAME = L"RustDesk";
|
||||
LPCWCH XCV_MONITOR_LOCAL_PORT = L",XcvMonitor Local Port";
|
||||
|
||||
static std::wstring printerNameOf(const std::wstring &appName)
|
||||
{
|
||||
return (appName.empty() ? std::wstring(RD_DEFAULT_APP_NAME) : appName) + L" Printer";
|
||||
}
|
||||
|
||||
using FuncEnum = std::function<BOOL(DWORD level, LPBYTE pDriverInfo, DWORD cbBuf, LPDWORD pcbNeeded, LPDWORD pcReturned)>;
|
||||
template <typename T, typename R>
|
||||
using FuncOnData = std::function<std::shared_ptr<R>(const T &)>;
|
||||
@@ -458,8 +465,12 @@ namespace RemotePrinter
|
||||
// We should not check the driver version because the driver is deployed with the application.
|
||||
// It's better to uninstall the existing driver and install the driver from the application.
|
||||
// 3. Add the printer.
|
||||
VOID installUpdatePrinter(const std::wstring &installFolder)
|
||||
VOID installUpdatePrinter(const std::wstring &installFolder, const std::wstring &appName)
|
||||
{
|
||||
const std::wstring printerName = printerNameOf(appName);
|
||||
const LPCWCH RD_PRINTER_NAME = printerName.c_str();
|
||||
const LPCWCH RD_PRINTER_PORT = printerName.c_str();
|
||||
|
||||
const std::wstring infFile = installFolder + L"\\" + RemotePrinter::RD_DRIVER_INF_PATH;
|
||||
if (!FileExists(infFile))
|
||||
{
|
||||
@@ -505,13 +516,15 @@ namespace RemotePrinter
|
||||
}
|
||||
}
|
||||
|
||||
VOID uninstallPrinter()
|
||||
VOID uninstallPrinter(const std::wstring &appName)
|
||||
{
|
||||
deletePrinter(RD_PRINTER_NAME);
|
||||
const std::wstring printerName = printerNameOf(appName);
|
||||
|
||||
deletePrinter(printerName.c_str());
|
||||
WcaLog(LOGMSG_STANDARD, "Deleted the printer\n");
|
||||
uninstallDriver(RD_PRINTER_DRIVER_NAME);
|
||||
WcaLog(LOGMSG_STANDARD, "Uninstalled the printer driver\n");
|
||||
checkDeleteLocalPort(RD_PRINTER_PORT);
|
||||
checkDeleteLocalPort(printerName.c_str());
|
||||
WcaLog(LOGMSG_STANDARD, "Deleted the local port\n");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,14 @@
|
||||
<CustomAction Id="SetPropertyServiceStop.SetParam.PropertyName" Return="check" Property="PropertyName" Value="STOP_SERVICE" />
|
||||
<CustomAction Id="TryDeleteStartupShortcut.SetParam" Return="check" Property="ShortcutName" Value="$(var.Product) Tray" />
|
||||
<CustomAction Id="RemoveAmyuniIdd.SetParam" Return="check" Property="RemoveAmyuniIdd" Value="[INSTALLFOLDER_INNER]" />
|
||||
<CustomAction Id="InstallPrinter.SetParam" Return="check" Property="InstallPrinter" Value="[INSTALLFOLDER_INNER]" />
|
||||
<!-- The app name comes first and is separated by '|', which cannot occur in a
|
||||
Windows path nor in a validated app name. wcautil's own delimiter is a
|
||||
literal wide char 128 that a Formatted value cannot carry, and [~] is
|
||||
MSI's NUL escape rather than that delimiter, so the action parses this
|
||||
itself. Passing the name keeps the dll free of it, so one build serves
|
||||
every custom client. -->
|
||||
<CustomAction Id="InstallPrinter.SetParam" Return="check" Property="InstallPrinter" Value="[ProductName]|[INSTALLFOLDER_INNER]" />
|
||||
<CustomAction Id="UninstallPrinter.SetParam" Return="check" Property="UninstallPrinter" Value="[ProductName]" />
|
||||
<InstallExecuteSequence>
|
||||
|
||||
<Custom Action="SetPropertyIsServiceRunning" After="InstallInitialize" Condition="Installed" />
|
||||
@@ -86,6 +93,7 @@
|
||||
<Custom Action="RemoveFirewallRules.SetParam" Before="RemoveFirewallRules"/>
|
||||
|
||||
<Custom Action="UninstallPrinter" Before="RemoveRuntimeGeneratedFiles" Condition="VersionNT >= 603" />
|
||||
<Custom Action="UninstallPrinter.SetParam" Before="UninstallPrinter" Condition="VersionNT >= 603" />
|
||||
|
||||
<Custom Action="TerminateProcesses" Before="RemoveRuntimeGeneratedFiles"/>
|
||||
<Custom Action="TerminateProcesses.SetParam" Before="TerminateProcesses"/>
|
||||
|
||||
@@ -13,6 +13,12 @@
|
||||
<PropertyRef Id="AddRemovePropertiesFile" />
|
||||
|
||||
<Media Id="1" Cabinet="cab1.cab" EmbedCab="yes" CompressionLevel="high" />
|
||||
<!--$Media2Start$-->
|
||||
<!-- preprocess.py in template mode adds a second cabinet here, holding only
|
||||
the files that differ per customer, so a custom client can be produced by
|
||||
rebuilding that small cabinet instead of the whole package. The shipped
|
||||
msi is built without template mode and keeps a single cabinet. -->
|
||||
<!--$Media2End$-->
|
||||
<Icon Id="AppIcon" SourceFile="Resources\icon.ico" />
|
||||
<CustomAction Id="BlockSelfInstalledApp" Error="!(loc.AnotherAppDialogDescription)" />
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import subprocess
|
||||
import re
|
||||
import platform
|
||||
from pathlib import Path
|
||||
from itertools import chain
|
||||
import shutil
|
||||
from xml.sax.saxutils import quoteattr
|
||||
|
||||
@@ -67,6 +66,14 @@ def make_parser():
|
||||
parser.add_argument(
|
||||
"-c", "--custom", action="store_true", help="Is custom client", default=False
|
||||
)
|
||||
parser.add_argument(
|
||||
"--template",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Build a template to be patched per customer rather than a finished "
|
||||
"package: puts the files a custom client replaces in their own cabinet, so "
|
||||
"rebranding rebuilds a few hundred KB instead of the whole payload.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--conn-type",
|
||||
type=str,
|
||||
@@ -92,6 +99,43 @@ def make_parser():
|
||||
return parser
|
||||
|
||||
|
||||
# Files a custom client replaces. Kept in their own cabinet by --template so that
|
||||
# rebranding rebuilds a few hundred KB instead of recompressing the whole payload.
|
||||
# The app executable is handled separately: it has its own component in RustDesk.wxs.
|
||||
#
|
||||
# A template has to ship a placeholder for each of these so there is a File row to
|
||||
# patch, but the branding assets are optional for a customer and a stock build has
|
||||
# none of them at all. So each optional one installs only when its property is set,
|
||||
# which the patcher does for the files a customer actually supplied. Otherwise a
|
||||
# customer without a logo would install the placeholder, where today they get no
|
||||
# logo at all -- the client treats a missing asset as "no logo".
|
||||
PER_CUSTOMER_DISK_ID = 2
|
||||
PER_CUSTOMER_FILES = {
|
||||
# relative path -> property gating installation, or None if always installed
|
||||
"custom.txt": None,
|
||||
"data/flutter_assets/assets/icon.ico": "CC_HAS_ICON_ICO",
|
||||
"data/flutter_assets/assets/icon.png": "CC_HAS_ICON_PNG",
|
||||
"data/flutter_assets/assets/logo.png": "CC_HAS_LOGO",
|
||||
"data/flutter_assets/assets/logo_light.png": "CC_HAS_LOGO_LIGHT",
|
||||
"data/flutter_assets/assets/logo_dark.png": "CC_HAS_LOGO_DARK",
|
||||
}
|
||||
|
||||
|
||||
def normalize_relative(relative_path):
|
||||
path = relative_path.replace("\\", "/")
|
||||
while path.startswith("./"):
|
||||
path = path[2:]
|
||||
return path.lower()
|
||||
|
||||
|
||||
def is_per_customer(relative_path):
|
||||
return normalize_relative(relative_path) in PER_CUSTOMER_FILES
|
||||
|
||||
|
||||
def per_customer_condition(relative_path):
|
||||
return PER_CUSTOMER_FILES.get(normalize_relative(relative_path))
|
||||
|
||||
|
||||
def read_lines_and_start_index(file_path, tag_start, tag_end):
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
lines = f.readlines()
|
||||
@@ -112,7 +156,7 @@ def read_lines_and_start_index(file_path, tag_start, tag_end):
|
||||
return lines, index_start
|
||||
|
||||
|
||||
def insert_components_between_tags(lines, index_start, app_name, dist_dir):
|
||||
def insert_components_between_tags(lines, index_start, app_name, dist_dir, template=False):
|
||||
indent = g_indent_unit * 3
|
||||
path = Path(dist_dir)
|
||||
idx = 1
|
||||
@@ -126,12 +170,23 @@ def insert_components_between_tags(lines, index_start, app_name, dist_dir):
|
||||
if subdir != ".":
|
||||
dir_attr = f'Subdirectory="{subdir}"'
|
||||
|
||||
relative = file_path.relative_to(path).as_posix()
|
||||
disk_attr = ""
|
||||
condition_attr = ""
|
||||
if template and is_per_customer(relative):
|
||||
disk_attr = f' DiskId="{PER_CUSTOMER_DISK_ID}"'
|
||||
# Branding assets are optional, and the template only carries a
|
||||
# placeholder, so install one only when the customer supplied it.
|
||||
condition = per_customer_condition(relative)
|
||||
if condition:
|
||||
condition_attr = f' Condition="{condition} = 1"'
|
||||
|
||||
# Don't generate Component Id and File Id like 'Component_{idx}' and 'File_{idx}'
|
||||
# because it will cause error
|
||||
# "Error WIX0130 The primary key 'xxxx' is duplicated in table 'Directory'"
|
||||
to_insert_lines = f"""
|
||||
{indent}<Component Guid="{uuid.uuid4()}" {dir_attr}>
|
||||
{indent}{g_indent_unit}<File Source="{file_path.as_posix()}" KeyPath="yes" Checksum="yes" />
|
||||
{indent}<Component Guid="{uuid.uuid4()}" {dir_attr}{condition_attr}>
|
||||
{indent}{g_indent_unit}<File Source="{file_path.as_posix()}" KeyPath="yes" Checksum="yes"{disk_attr} />
|
||||
{indent}</Component>
|
||||
"""
|
||||
lines.insert(index_start + 1, to_insert_lines[1:])
|
||||
@@ -140,17 +195,52 @@ def insert_components_between_tags(lines, index_start, app_name, dist_dir):
|
||||
return True
|
||||
|
||||
|
||||
def gen_auto_component(app_name, dist_dir):
|
||||
def gen_auto_component(app_name, dist_dir, template=False):
|
||||
return gen_content_between_tags(
|
||||
"Package/Components/RustDesk.wxs",
|
||||
"<!--$AutoComonentStart$-->",
|
||||
"<!--$AutoComponentEnd$-->",
|
||||
lambda lines, index_start: insert_components_between_tags(
|
||||
lines, index_start, app_name, dist_dir
|
||||
lines, index_start, app_name, dist_dir, template
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def gen_media2():
|
||||
"""Second cabinet holding only what a custom client replaces."""
|
||||
|
||||
def func(lines, index_start):
|
||||
indent = g_indent_unit * 2
|
||||
lines.insert(
|
||||
index_start + 1,
|
||||
f'{indent}<Media Id="{PER_CUSTOMER_DISK_ID}" Cabinet="cab2.cab"'
|
||||
' EmbedCab="yes" CompressionLevel="high" />\n',
|
||||
)
|
||||
return lines
|
||||
|
||||
return gen_content_between_tags(
|
||||
"Package/Package.wxs", "<!--$Media2Start$-->", "<!--$Media2End$-->", func
|
||||
)
|
||||
|
||||
|
||||
def put_app_exe_on_media2():
|
||||
"""The app executable has its own component, so it is moved by name."""
|
||||
target = Path(sys.argv[0]).parent.joinpath("Package/Components/RustDesk.wxs")
|
||||
with open(target, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
old = '<File Id="App.exe" Name="$(var.Product).exe" KeyPath="yes" Checksum="yes">'
|
||||
new = (
|
||||
'<File Id="App.exe" Name="$(var.Product).exe" KeyPath="yes" Checksum="yes"'
|
||||
f' DiskId="{PER_CUSTOMER_DISK_ID}">'
|
||||
)
|
||||
if content.count(old) != 1:
|
||||
print(f"Error: expected exactly one App.exe File element, found {content.count(old)}")
|
||||
return False
|
||||
with open(target, "w", encoding="utf-8") as f:
|
||||
f.write(content.replace(old, new))
|
||||
return True
|
||||
|
||||
|
||||
def gen_pre_vars(args, dist_dir):
|
||||
def func(lines, index_start):
|
||||
upgrade_code = uuid.uuid5(uuid.NAMESPACE_OID, app_name + ".exe")
|
||||
@@ -190,18 +280,6 @@ def replace_app_name_in_langs(app_name):
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.writelines(lines)
|
||||
|
||||
def replace_app_name_in_custom_actions(app_name):
|
||||
custion_actions_dir = Path(sys.argv[0]).parent.joinpath("CustomActions")
|
||||
for file_path in chain(custion_actions_dir.glob("*.cpp"), custion_actions_dir.glob("*.h")):
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
lines = f.readlines()
|
||||
for i, line in enumerate(lines):
|
||||
line = re.sub(r"\bRustDesk\b", app_name, line)
|
||||
line = line.replace(f"{app_name} v4 Printer Driver", "RustDesk v4 Printer Driver")
|
||||
lines[i] = line
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.writelines(lines)
|
||||
|
||||
def gen_upgrade_info():
|
||||
def func(lines, index_start):
|
||||
indent = g_indent_unit * 3
|
||||
@@ -478,11 +556,16 @@ if __name__ == "__main__":
|
||||
if not gen_conn_type(args):
|
||||
sys.exit(-1)
|
||||
|
||||
if not gen_auto_component(app_name, dist_dir):
|
||||
if args.template:
|
||||
if not gen_media2():
|
||||
sys.exit(-1)
|
||||
if not put_app_exe_on_media2():
|
||||
sys.exit(-1)
|
||||
|
||||
if not gen_auto_component(app_name, dist_dir, args.template):
|
||||
sys.exit(-1)
|
||||
|
||||
if not gen_custom_dialog_bitmaps():
|
||||
sys.exit(-1)
|
||||
|
||||
replace_app_name_in_langs(args.app_name)
|
||||
replace_app_name_in_custom_actions(args.app_name)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
Name: rustdesk
|
||||
Version: 1.4.9
|
||||
Version: 1.5.0
|
||||
Release: 0
|
||||
Summary: RPM package
|
||||
License: GPL-3.0
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
Name: rustdesk
|
||||
Version: 1.4.9
|
||||
Version: 1.5.0
|
||||
Release: 0
|
||||
Summary: RPM package
|
||||
License: GPL-3.0
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
Name: rustdesk
|
||||
Version: 1.4.9
|
||||
Version: 1.5.0
|
||||
Release: 0
|
||||
Summary: RPM package
|
||||
License: GPL-3.0
|
||||
|
||||
@@ -87,7 +87,7 @@ if(VCPKG_HOST_IS_WINDOWS)
|
||||
vcpkg_acquire_msys(MSYS_ROOT PACKAGES automake1.16)
|
||||
set(SHELL "${MSYS_ROOT}/usr/bin/bash.exe")
|
||||
vcpkg_add_to_path("${MSYS_ROOT}/usr/share/automake-1.16")
|
||||
string(APPEND OPTIONS " --pkg-config=${CURRENT_HOST_INSTALLED_DIR}/tools/pkgconf/pkgconf${VCPKG_HOST_EXECUTABLE_SUFFIX}")
|
||||
string(APPEND OPTIONS " --pkg-config=${CURRENT_HOST_INSTALLED_DIR}/tools/pkgconf/pkgconf${VCPKG_HOST_EXECUTABLE_SUFFIX} ")
|
||||
else()
|
||||
find_program(SHELL bash)
|
||||
endif()
|
||||
|
||||
1328
src/client.rs
1328
src/client.rs
File diff suppressed because it is too large
Load Diff
@@ -185,6 +185,14 @@ impl<T: InvokeUiSession> Remote<T> {
|
||||
.unwrap()
|
||||
.set_connected();
|
||||
let is_secured = peer.is_secured();
|
||||
// Only WebRTC needs refining: its label names the transport that won the race,
|
||||
// not the family ICE ended up nominating, and it is the one path where the two
|
||||
// can disagree with the address the rendezvous observed.
|
||||
let stream_type = if peer.webrtc_remote_ipv6().await.unwrap_or(false) {
|
||||
"WebRTC/IPv6"
|
||||
} else {
|
||||
stream_type
|
||||
};
|
||||
self.handler
|
||||
.set_connection_type(is_secured, direct, stream_type); // flutter -> connection_ready
|
||||
if !is_secured
|
||||
@@ -1462,6 +1470,18 @@ impl<T: InvokeUiSession> Remote<T> {
|
||||
!lc.disable_clipboard.v && !lc.view_only.v
|
||||
};
|
||||
if clipboard_allowed {
|
||||
#[cfg(all(
|
||||
feature = "flutter",
|
||||
not(any(target_os = "android", target_os = "ios"))
|
||||
))]
|
||||
if self.handler.is_text_clipboard_required()
|
||||
&& crate::clipboard::is_sync_clipboard_between_sessions_enabled()
|
||||
{
|
||||
let mut msg = Message::new();
|
||||
msg.set_clipboard(cb.clone());
|
||||
let session_id = self.handler.lc.read().unwrap().session_id;
|
||||
crate::flutter::send_clipboard_msg_to_other_sessions(msg, session_id);
|
||||
}
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
update_clipboard(vec![cb], ClipboardSide::Client);
|
||||
#[cfg(target_os = "ios")]
|
||||
@@ -1485,6 +1505,18 @@ impl<T: InvokeUiSession> Remote<T> {
|
||||
!lc.disable_clipboard.v && !lc.view_only.v
|
||||
};
|
||||
if clipboard_allowed {
|
||||
#[cfg(all(
|
||||
feature = "flutter",
|
||||
not(any(target_os = "android", target_os = "ios"))
|
||||
))]
|
||||
if self.handler.is_text_clipboard_required()
|
||||
&& crate::clipboard::is_sync_clipboard_between_sessions_enabled()
|
||||
{
|
||||
let mut msg = Message::new();
|
||||
msg.set_multi_clipboards(_mcb.clone());
|
||||
let session_id = self.handler.lc.read().unwrap().session_id;
|
||||
crate::flutter::send_clipboard_msg_to_other_sessions(msg, session_id);
|
||||
}
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
update_clipboard(_mcb.clipboards, ClipboardSide::Client);
|
||||
#[cfg(target_os = "ios")]
|
||||
|
||||
@@ -13,6 +13,17 @@ pub const CLIPBOARD_NAME: &'static str = "clipboard";
|
||||
pub const FILE_CLIPBOARD_NAME: &'static str = "file-clipboard";
|
||||
pub const CLIPBOARD_INTERVAL: u64 = 333;
|
||||
|
||||
pub const OPTION_ALLOW_SYNC_CLIPBOARD_BETWEEN_SESSIONS: &str =
|
||||
"allow-sync-clipboard-between-sessions";
|
||||
|
||||
#[cfg(all(feature = "flutter", not(any(target_os = "android", target_os = "ios"))))]
|
||||
pub fn is_sync_clipboard_between_sessions_enabled() -> bool {
|
||||
hbb_common::config::option2bool(
|
||||
OPTION_ALLOW_SYNC_CLIPBOARD_BETWEEN_SESSIONS,
|
||||
&hbb_common::config::LocalConfig::get_option(OPTION_ALLOW_SYNC_CLIPBOARD_BETWEEN_SESSIONS),
|
||||
)
|
||||
}
|
||||
|
||||
// This format is used to store the flag in the clipboard.
|
||||
const RUSTDESK_CLIPBOARD_OWNER_FORMAT: &'static str = "dyn.com.rustdesk.owner";
|
||||
|
||||
|
||||
314
src/common.rs
314
src/common.rs
@@ -1,7 +1,7 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
future::Future,
|
||||
net::{SocketAddr, ToSocketAddrs},
|
||||
net::SocketAddr,
|
||||
sync::{Arc, Mutex, RwLock},
|
||||
task::Poll,
|
||||
};
|
||||
@@ -222,6 +222,61 @@ pub fn need_fs_cm_send_files() -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Android is scoped-storage only: the peer may never touch anything outside the app
|
||||
/// workspace (`Config::get_home()`, i.e. the app-specific external files directory).
|
||||
///
|
||||
/// Every peer supplied path must be validated with this before it reaches the
|
||||
/// filesystem, for reads, writes, renames, creations and deletions alike. The path is
|
||||
/// resolved to its canonical form (of the deepest existing ancestor, so paths that are
|
||||
/// about to be created are handled too) so symlinks cannot escape the workspace.
|
||||
///
|
||||
/// Only the `ReadDir` protocol action treats an empty path as the home directory.
|
||||
/// Callers must opt in to that protocol-specific behavior with `allow_empty`.
|
||||
#[cfg(target_os = "android")]
|
||||
pub fn is_peer_path_allowed(path: &str, allow_empty: bool) -> bool {
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
// Canonicalize the deepest existing ancestor and re-append the missing tail.
|
||||
fn resolve(path: &Path) -> Option<PathBuf> {
|
||||
let mut tail: Vec<std::ffi::OsString> = Vec::new();
|
||||
let mut base = path.to_path_buf();
|
||||
loop {
|
||||
if let Ok(mut resolved) = base.canonicalize() {
|
||||
while let Some(component) = tail.pop() {
|
||||
resolved.push(component);
|
||||
}
|
||||
return Some(resolved);
|
||||
}
|
||||
tail.push(base.file_name()?.to_os_string());
|
||||
if !base.pop() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if path.is_empty() {
|
||||
return allow_empty;
|
||||
}
|
||||
let path = Path::new(path);
|
||||
// `..` is never needed by the protocol and would defeat the prefix check below.
|
||||
if !path.is_absolute() || path.components().any(|c| c == Component::ParentDir) {
|
||||
return false;
|
||||
}
|
||||
let home = Config::get_home();
|
||||
let home = home.canonicalize().unwrap_or(home);
|
||||
if home.as_os_str().is_empty() {
|
||||
return false;
|
||||
}
|
||||
// `Path::starts_with` compares whole components, and is true for equal paths.
|
||||
resolve(path).map_or(false, |target| target.starts_with(&home))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[cfg(not(target_os = "android"))]
|
||||
pub fn is_peer_path_allowed(_path: &str, _allow_empty: bool) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn is_main() -> bool {
|
||||
*IS_MAIN
|
||||
@@ -1098,6 +1153,13 @@ pub fn is_public(url: &str) -> bool {
|
||||
host == "rustdesk.com" || host.ends_with(".rustdesk.com")
|
||||
}
|
||||
|
||||
pub fn get_tcp_punch_enabled() -> bool {
|
||||
config::option2bool(
|
||||
keys::OPTION_ENABLE_TCP_PUNCH,
|
||||
&get_local_option(keys::OPTION_ENABLE_TCP_PUNCH),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn get_udp_punch_enabled() -> bool {
|
||||
config::option2bool(
|
||||
keys::OPTION_ENABLE_UDP_PUNCH,
|
||||
@@ -1112,9 +1174,19 @@ pub fn get_ipv6_punch_enabled() -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
pub fn get_webrtc_enabled() -> bool {
|
||||
config::option2bool(
|
||||
keys::OPTION_ENABLE_WEBRTC,
|
||||
&get_local_option(keys::OPTION_ENABLE_WEBRTC),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn get_local_option(key: &str) -> String {
|
||||
let v = LocalConfig::get_option(key);
|
||||
if key == keys::OPTION_ENABLE_UDP_PUNCH || key == keys::OPTION_ENABLE_IPV6_PUNCH {
|
||||
if key == keys::OPTION_ENABLE_UDP_PUNCH
|
||||
|| key == keys::OPTION_ENABLE_IPV6_PUNCH
|
||||
|| key == keys::OPTION_ENABLE_WEBRTC
|
||||
{
|
||||
if v.is_empty() {
|
||||
if !is_public(&Config::get_rendezvous_server()) {
|
||||
return "N".to_owned();
|
||||
@@ -2071,11 +2143,21 @@ pub fn get_rs_pk(str_base64: &str) -> Option<sign::PublicKey> {
|
||||
}
|
||||
|
||||
pub fn decode_id_pk(signed: &[u8], key: &sign::PublicKey) -> ResultType<(String, [u8; 32])> {
|
||||
let (id, pk, _) = decode_id_pk_dtls(signed, key)?;
|
||||
Ok((id, pk))
|
||||
}
|
||||
|
||||
/// Like [`decode_id_pk`] but also returns the signed DTLS certificate fingerprint (empty string
|
||||
/// for non-WebRTC peers), used to bind a WebRTC DTLS channel to the verified peer identity.
|
||||
pub fn decode_id_pk_dtls(
|
||||
signed: &[u8],
|
||||
key: &sign::PublicKey,
|
||||
) -> ResultType<(String, [u8; 32], String)> {
|
||||
let res = IdPk::parse_from_bytes(
|
||||
&sign::verify(signed, key).map_err(|_| anyhow!("Signature mismatch"))?,
|
||||
)?;
|
||||
if let Some(pk) = get_pk(&res.pk) {
|
||||
Ok((res.id, pk))
|
||||
Ok((res.id, pk, res.dtls_fingerprint))
|
||||
} else {
|
||||
bail!("Wrong their public length");
|
||||
}
|
||||
@@ -2377,16 +2459,26 @@ pub fn is_udp_disabled() -> bool {
|
||||
Config::get_option(keys::OPTION_DISABLE_UDP) == "Y"
|
||||
}
|
||||
|
||||
/// Run KCP with its congestion window (nc=0) instead of the turbo profile it has always shipped.
|
||||
///
|
||||
/// Opt-in: which profile wins depends on why packets are lost — nc=1 deepens real congestion,
|
||||
/// while nc=0 reads random loss as congestion and its RTO backoff drops cwnd to 1. Undecidable
|
||||
/// without a shaped link, so keep what users run today.
|
||||
#[inline]
|
||||
pub fn get_kcp_cc_enabled() -> bool {
|
||||
let k = keys::OPTION_ALLOW_KCP_CC;
|
||||
config::option2bool(k, &Config::get_option(k))
|
||||
}
|
||||
|
||||
// this crate https://github.com/yoshd/stun-client supports nat type
|
||||
async fn stun_ipv6_test(stun_server: &str) -> ResultType<(SocketAddr, String)> {
|
||||
use std::net::ToSocketAddrs;
|
||||
async fn stun_ipv6_test(stun_server: String) -> ResultType<(SocketAddr, String)> {
|
||||
use stunclient::StunClient;
|
||||
let local_addr = SocketAddr::from(([0u16; 8], 0)); // [::]:0
|
||||
let socket = UdpSocket::bind(&local_addr).await?;
|
||||
let Some(stun_addr) = stun_server
|
||||
.to_socket_addrs()?
|
||||
.filter(|x| x.is_ipv6())
|
||||
.next()
|
||||
// Resolve via tokio so DNS never blocks the async runtime worker.
|
||||
let Some(stun_addr) = tokio::net::lookup_host(&stun_server)
|
||||
.await?
|
||||
.find(|x| x.is_ipv6())
|
||||
else {
|
||||
bail!(
|
||||
"Failed to resolve STUN ipv6 server address: {}",
|
||||
@@ -2396,81 +2488,36 @@ async fn stun_ipv6_test(stun_server: &str) -> ResultType<(SocketAddr, String)> {
|
||||
let client = StunClient::new(stun_addr);
|
||||
let addr = client.query_external_address_async(&socket).await?;
|
||||
Ok(if addr.ip().is_ipv6() {
|
||||
(addr, stun_server.to_owned())
|
||||
(addr, stun_server)
|
||||
} else {
|
||||
bail!("STUN server returned non-IPv6 address: {}", addr)
|
||||
})
|
||||
}
|
||||
|
||||
async fn stun_ipv4_test(stun_server: &str) -> ResultType<(SocketAddr, String)> {
|
||||
use std::net::ToSocketAddrs;
|
||||
use stunclient::StunClient;
|
||||
let local_addr = SocketAddr::from(([0u8; 4], 0));
|
||||
let socket = UdpSocket::bind(&local_addr).await?;
|
||||
let Some(stun_addr) = stun_server
|
||||
.to_socket_addrs()?
|
||||
.filter(|x| x.is_ipv4())
|
||||
.next()
|
||||
else {
|
||||
bail!(
|
||||
"Failed to resolve STUN ipv4 server address: {}",
|
||||
stun_server
|
||||
);
|
||||
};
|
||||
let client = StunClient::new(stun_addr);
|
||||
let addr = client.query_external_address_async(&socket).await?;
|
||||
Ok(if addr.ip().is_ipv4() {
|
||||
(addr, stun_server.to_owned())
|
||||
} else {
|
||||
bail!("STUN server returned non-IPv6 address: {}", addr)
|
||||
})
|
||||
}
|
||||
|
||||
static STUNS_V4: [&str; 3] = [
|
||||
"stun.l.google.com:19302",
|
||||
"stun.cloudflare.com:3478",
|
||||
"stun.nextcloud.com:3478",
|
||||
];
|
||||
|
||||
static STUNS_V6: [&str; 3] = [
|
||||
"stun.l.google.com:19302",
|
||||
"stun.cloudflare.com:3478",
|
||||
"stun.nextcloud.com:3478",
|
||||
];
|
||||
|
||||
pub async fn test_nat_ipv4() -> ResultType<(SocketAddr, String)> {
|
||||
use hbb_common::futures::future::{select_ok, FutureExt};
|
||||
let tests = STUNS_V4
|
||||
.iter()
|
||||
.map(|&stun| stun_ipv4_test(stun).boxed())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
match select_ok(tests).await {
|
||||
Ok(res) => {
|
||||
return Ok(res.0);
|
||||
}
|
||||
Err(e) => {
|
||||
bail!(
|
||||
"Failed to get public IPv4 address via public STUN servers: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async fn test_bind_ipv6() -> ResultType<SocketAddr> {
|
||||
use hbb_common::futures::future::FutureExt;
|
||||
let local_addr = SocketAddr::from(([0u16; 8], 0)); // [::]:0
|
||||
let socket = UdpSocket::bind(local_addr).await?;
|
||||
let addr = STUNS_V6[0]
|
||||
.to_socket_addrs()?
|
||||
.filter(|x| x.is_ipv6())
|
||||
.next()
|
||||
.ok_or_else(|| {
|
||||
anyhow!(
|
||||
"Failed to resolve STUN ipv6 server address: {}",
|
||||
STUNS_V6[0]
|
||||
)
|
||||
})?;
|
||||
// Nothing is sent - `connect` only makes the kernel pick a route and a source address - so any
|
||||
// resolvable target answers equally and the whole cost is DNS. Race the lookups rather than
|
||||
// walk them: this is awaited inline on the connection path, not every STUN host publishes a
|
||||
// AAAA, and one resolver that hangs must not decide whether this host has v6.
|
||||
let lookups = hbb_common::webrtc::WebRTCStream::default_stun_servers()
|
||||
.into_iter()
|
||||
.map(|stun| {
|
||||
(async move {
|
||||
let addr = tokio::net::lookup_host(&stun)
|
||||
.await?
|
||||
.find(|x| x.is_ipv6())
|
||||
.ok_or_else(|| {
|
||||
anyhow!("Failed to resolve STUN ipv6 server address: {}", stun)
|
||||
})?;
|
||||
Ok::<SocketAddr, hbb_common::anyhow::Error>(addr)
|
||||
})
|
||||
.boxed()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let (addr, _) = hbb_common::futures::future::select_ok(lookups).await?;
|
||||
socket.connect(addr).await?;
|
||||
Ok(socket.local_addr()?)
|
||||
}
|
||||
@@ -2537,9 +2584,9 @@ pub async fn test_ipv6() -> Option<tokio::task::JoinHandle<()>> {
|
||||
|
||||
Some(tokio::spawn(async {
|
||||
use hbb_common::futures::future::{select_ok, FutureExt};
|
||||
let tests = STUNS_V6
|
||||
.iter()
|
||||
.map(|&stun| stun_ipv6_test(stun).boxed())
|
||||
let tests = hbb_common::webrtc::WebRTCStream::default_stun_servers()
|
||||
.into_iter()
|
||||
.map(|stun| stun_ipv6_test(stun).boxed())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
match select_ok(tests).await {
|
||||
@@ -2560,51 +2607,114 @@ pub async fn test_ipv6() -> Option<tokio::task::JoinHandle<()>> {
|
||||
}))
|
||||
}
|
||||
|
||||
// A punch packet carries a magic and a transaction id so a reply can be *proven* to answer this
|
||||
// probe. The punch it replaces sent a zero-length datagram and called the hole open on whatever
|
||||
// arrived next - which the rendezvous NAT test's own leftover replies satisfied instantly, so the
|
||||
// retry loop below never actually ran and its success meant nothing.
|
||||
const PUNCH_PROBE: [u8; 4] = *b"RDP?";
|
||||
const PUNCH_ACK: [u8; 4] = *b"RDP!";
|
||||
const PUNCH_PACKET_LEN: usize = 12;
|
||||
|
||||
fn punch_packet(tag: &[u8; 4], tid: u64) -> [u8; PUNCH_PACKET_LEN] {
|
||||
let mut packet = [0u8; PUNCH_PACKET_LEN];
|
||||
packet[..4].copy_from_slice(tag);
|
||||
packet[4..].copy_from_slice(&tid.to_le_bytes());
|
||||
packet
|
||||
}
|
||||
|
||||
fn punch_tid(packet: &[u8], tag: &[u8; 4]) -> Option<u64> {
|
||||
if packet.len() != PUNCH_PACKET_LEN || packet[..4] != tag[..] {
|
||||
return None;
|
||||
}
|
||||
packet[4..].try_into().ok().map(u64::from_le_bytes)
|
||||
}
|
||||
|
||||
/// Punch until one of our own probes is acknowledged. Both ends run this identically - each
|
||||
/// probes, each answers the other's probes - and each returns only once a reply carrying its own
|
||||
/// transaction id comes back, the one thing that proves the pair carries traffic both ways.
|
||||
///
|
||||
/// Returning is therefore a fact rather than a guess, which is what lets the caller stop instead
|
||||
/// of handing a dead socket to a transport whose only way to discover the truth is to time out.
|
||||
///
|
||||
/// A datagram that is neither probe nor acknowledgement is returned rather than dropped: it means
|
||||
/// the peer finished first and is already speaking KCP, whose SYN is never retransmitted.
|
||||
///
|
||||
/// Only the connector stops on its own acknowledgement, because only it has something to send
|
||||
/// next. An acknowledgement proves our probe came back, not that the peer's probe was answered -
|
||||
/// and after this returns nothing answers probes any more, since KCP's io loop drops anything
|
||||
/// shorter than its header. A listener that stopped here would go mute while a peer whose own
|
||||
/// probe or answer was lost - the normal state of a hole that is still opening - kept probing an
|
||||
/// endpoint that works, until it timed out. So the listener stops on the peer's first real packet.
|
||||
pub async fn punch_udp(
|
||||
socket: Arc<UdpSocket>,
|
||||
listen: bool,
|
||||
) -> ResultType<Option<bytes::BytesMut>> {
|
||||
let tid = ((hbb_common::time_based_rand() as u64) << 32) | hbb_common::time_based_rand() as u64;
|
||||
let probe = punch_packet(&PUNCH_PROBE, tid);
|
||||
let mut data = [0u8; 1500];
|
||||
// `connect` does not flush the receive queue, so the NAT test's extra replies are still in it.
|
||||
while socket.try_recv(&mut data).is_ok() {}
|
||||
|
||||
let mut retry_interval = Duration::from_millis(20);
|
||||
const MAX_INTERVAL: Duration = Duration::from_millis(200);
|
||||
const MAX_TIME: Duration = Duration::from_secs(20);
|
||||
let mut packets_sent = 0;
|
||||
socket.send(&[]).await.ok();
|
||||
packets_sent += 1;
|
||||
// Both ends start within one rendezvous round trip of each other and the acknowledgement is
|
||||
// one peer round trip, so a pair that has not answered in this long is not going to. The old
|
||||
// 20s came from having no way to tell "not yet" from "never".
|
||||
const MAX_TIME: Duration = Duration::from_secs(3);
|
||||
let mut probes_sent = 0u32;
|
||||
let mut probes_seen = 0u32;
|
||||
let mut acked = false;
|
||||
let mut recv_errors = 0u32;
|
||||
socket.send(&probe).await.ok();
|
||||
probes_sent += 1;
|
||||
let mut last_send_time = Instant::now();
|
||||
let tm = Instant::now();
|
||||
let mut data = [0u8; 1500];
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = hbb_common::sleep(retry_interval.as_secs_f32()) => {
|
||||
if tm.elapsed() > MAX_TIME {
|
||||
bail!("UDP punch is timed out, stop sending packets after {:?} packets", packets_sent);
|
||||
bail!("UDP punch is timed out, {probes_sent} probes sent, {probes_seen} probes received, acked: {acked}, {recv_errors} recv errors absorbed");
|
||||
}
|
||||
let elapsed = last_send_time.elapsed();
|
||||
|
||||
if elapsed >= retry_interval {
|
||||
socket.send(&[]).await.ok();
|
||||
packets_sent += 1;
|
||||
|
||||
// Exponentially increase interval to reduce network pressure
|
||||
retry_interval = std::cmp::min(
|
||||
Duration::from_millis((retry_interval.as_millis() as f64 * 1.5) as u64),
|
||||
MAX_INTERVAL
|
||||
);
|
||||
if last_send_time.elapsed() >= retry_interval {
|
||||
socket.send(&probe).await.ok();
|
||||
probes_sent += 1;
|
||||
retry_interval = std::cmp::min(retry_interval.mul_f64(1.5), MAX_INTERVAL);
|
||||
last_send_time = Instant::now();
|
||||
}
|
||||
}
|
||||
res = socket.recv(&mut data) => match res {
|
||||
Err(e) => bail!("UDP punch failed, {packets_sent} packets sent: {e}"),
|
||||
Err(e) => {
|
||||
// ICMP unreachable from the peer's NAT is expected while the hole forms and
|
||||
// surfaces here as ConnectionReset/Refused; treat it as loss, MAX_TIME bounds
|
||||
// the attempt. Log only the first - this retries every 10ms.
|
||||
recv_errors += 1;
|
||||
if recv_errors == 1 {
|
||||
log::debug!("UDP punch recv error (treated as loss): {e}");
|
||||
}
|
||||
hbb_common::sleep(0.01).await;
|
||||
}
|
||||
Ok(n) => {
|
||||
// log::debug!("UDP punch succeeded after sending {} packets after {:?}", packets_sent, tm.elapsed());
|
||||
if listen {
|
||||
if n == 0 {
|
||||
continue;
|
||||
let ack = punch_tid(&data[..n], &PUNCH_ACK);
|
||||
if ack == Some(tid) {
|
||||
if !listen {
|
||||
log::debug!(
|
||||
"UDP punch confirmed in {:?}, {probes_sent} probes sent, {probes_seen} received",
|
||||
tm.elapsed()
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
acked = true;
|
||||
} else if let Some(peer_tid) = punch_tid(&data[..n], &PUNCH_PROBE) {
|
||||
probes_seen += 1;
|
||||
socket.send(&punch_packet(&PUNCH_ACK, peer_tid)).await.ok();
|
||||
} else if ack.is_none() && n > 0 {
|
||||
log::debug!(
|
||||
"UDP punch confirmed by {n} bytes of peer data in {:?}, {probes_sent} probes sent",
|
||||
tm.elapsed()
|
||||
);
|
||||
return Ok(Some(bytes::BytesMut::from(&data[..n])));
|
||||
}
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1422,10 +1422,26 @@ pub fn update_file_clipboard_required() {
|
||||
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
pub fn send_clipboard_msg(msg: Message, _is_file: bool) {
|
||||
send_clipboard_msg_impl(msg, _is_file, None);
|
||||
}
|
||||
|
||||
// `except_session_id` is the session the content came from, to avoid sending it back.
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
pub fn send_clipboard_msg_to_other_sessions(msg: Message, except_session_id: u64) {
|
||||
send_clipboard_msg_impl(msg, false, Some(except_session_id));
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
fn send_clipboard_msg_impl(msg: Message, _is_file: bool, except_session_id: Option<u64>) {
|
||||
for s in sessions::get_sessions() {
|
||||
if !s.is_default() {
|
||||
continue;
|
||||
}
|
||||
if let Some(except_session_id) = except_session_id {
|
||||
if s.lc.read().unwrap().session_id == except_session_id {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "unix-file-copy-paste")]
|
||||
if _is_file {
|
||||
if crate::is_support_file_copy_paste_num(s.lc.read().unwrap().version)
|
||||
|
||||
@@ -2912,6 +2912,7 @@ pub mod server_side {
|
||||
env: JNIEnv,
|
||||
_class: JClass,
|
||||
app_dir: JString,
|
||||
home_dir: JString,
|
||||
custom_client_config: JString,
|
||||
) {
|
||||
log::debug!("startServer from jvm");
|
||||
@@ -2919,6 +2920,9 @@ pub mod server_side {
|
||||
if let Ok(app_dir) = env.get_string(&app_dir) {
|
||||
*config::APP_DIR.write().unwrap() = app_dir.into();
|
||||
}
|
||||
if let Ok(home_dir) = env.get_string(&home_dir) {
|
||||
*config::APP_HOME_DIR.write().unwrap() = home_dir.into();
|
||||
}
|
||||
if let Ok(custom_client_config) = env.get_string(&custom_client_config) {
|
||||
if !custom_client_config.is_empty() {
|
||||
let custom_client_config: String = custom_client_config.into();
|
||||
|
||||
129
src/ipc/auth.rs
129
src/ipc/auth.rs
@@ -24,7 +24,6 @@ use std::os::windows::io::AsRawHandle;
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Mutex, OnceLock},
|
||||
};
|
||||
#[cfg(windows)]
|
||||
use windows::Win32::{Foundation::HANDLE, System::Pipes::GetNamedPipeClientProcessId};
|
||||
@@ -520,66 +519,17 @@ pub(crate) fn ensure_peer_executable_matches_current_by_fd(
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
|
||||
const UNAUTHORIZED_IPC_LOG_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
|
||||
#[derive(Default)]
|
||||
struct UnauthorizedIpcLogThrottle {
|
||||
last_log_at: Option<std::time::Instant>,
|
||||
suppressed: u64,
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
|
||||
impl UnauthorizedIpcLogThrottle {
|
||||
#[inline]
|
||||
fn on_reject(&mut self, now: std::time::Instant) -> Option<u64> {
|
||||
if let Some(last) = self.last_log_at {
|
||||
if now.saturating_duration_since(last) < UNAUTHORIZED_IPC_LOG_INTERVAL {
|
||||
self.suppressed += 1;
|
||||
return None;
|
||||
}
|
||||
}
|
||||
self.last_log_at = Some(now);
|
||||
Some(std::mem::take(&mut self.suppressed))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
|
||||
#[inline]
|
||||
fn throttled_unauthorized_ipc_log(
|
||||
throttle_cell: &OnceLock<Mutex<UnauthorizedIpcLogThrottle>>,
|
||||
emit: impl FnOnce(u64),
|
||||
) {
|
||||
let throttle = throttle_cell.get_or_init(|| Mutex::new(UnauthorizedIpcLogThrottle::default()));
|
||||
let should_log = match throttle.lock() {
|
||||
Ok(mut throttle) => throttle.on_reject(std::time::Instant::now()),
|
||||
Err(_) => Some(0),
|
||||
};
|
||||
if let Some(suppressed) = should_log {
|
||||
emit(suppressed);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
#[inline]
|
||||
fn log_rejected_service_connection(postfix: &str, peer_uid: Option<u32>, active_uid: Option<u32>) {
|
||||
static LOG_THROTTLE: OnceLock<Mutex<UnauthorizedIpcLogThrottle>> = OnceLock::new();
|
||||
throttled_unauthorized_ipc_log(&LOG_THROTTLE, |suppressed| {
|
||||
if suppressed > 0 {
|
||||
log::warn!(
|
||||
"Rejected unauthorized connection on protected service-scoped IPC channel: postfix={}, peer_uid={:?}, active_uid={:?} (suppressed {} similar events)",
|
||||
postfix,
|
||||
peer_uid,
|
||||
active_uid,
|
||||
suppressed
|
||||
);
|
||||
} else {
|
||||
log::warn!(
|
||||
"Rejected unauthorized connection on protected service-scoped IPC channel: postfix={}, peer_uid={:?}, active_uid={:?}",
|
||||
postfix,
|
||||
peer_uid,
|
||||
active_uid
|
||||
);
|
||||
}
|
||||
});
|
||||
hbb_common::throttled_log!(
|
||||
UNAUTHORIZED_IPC_LOG_INTERVAL,
|
||||
warn,
|
||||
"Rejected unauthorized connection on protected service-scoped IPC channel: postfix={}, peer_uid={:?}, active_uid={:?}",
|
||||
postfix,
|
||||
peer_uid,
|
||||
active_uid
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -589,25 +539,14 @@ pub(crate) fn log_rejected_uinput_connection(
|
||||
peer_uid: Option<u32>,
|
||||
active_uid: Option<u32>,
|
||||
) {
|
||||
static LOG_THROTTLE: OnceLock<Mutex<UnauthorizedIpcLogThrottle>> = OnceLock::new();
|
||||
throttled_unauthorized_ipc_log(&LOG_THROTTLE, |suppressed| {
|
||||
if suppressed > 0 {
|
||||
log::warn!(
|
||||
"Rejected unauthorized connection on uinput ipc channel: postfix={}, peer_uid={:?}, active_uid={:?} (suppressed {} similar events)",
|
||||
postfix,
|
||||
peer_uid,
|
||||
active_uid,
|
||||
suppressed
|
||||
);
|
||||
} else {
|
||||
log::warn!(
|
||||
"Rejected unauthorized connection on uinput ipc channel: postfix={}, peer_uid={:?}, active_uid={:?}",
|
||||
postfix,
|
||||
peer_uid,
|
||||
active_uid
|
||||
);
|
||||
}
|
||||
});
|
||||
hbb_common::throttled_log!(
|
||||
UNAUTHORIZED_IPC_LOG_INTERVAL,
|
||||
warn,
|
||||
"Rejected unauthorized connection on uinput ipc channel: postfix={}, peer_uid={:?}, active_uid={:?}",
|
||||
postfix,
|
||||
peer_uid,
|
||||
active_uid
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
@@ -620,31 +559,17 @@ pub(crate) fn log_rejected_windows_ipc_connection(
|
||||
peer_is_system: Option<bool>,
|
||||
peer_is_elevated: Option<bool>,
|
||||
) {
|
||||
static LOG_THROTTLE: OnceLock<Mutex<UnauthorizedIpcLogThrottle>> = OnceLock::new();
|
||||
throttled_unauthorized_ipc_log(&LOG_THROTTLE, |suppressed| {
|
||||
if suppressed > 0 {
|
||||
log::warn!(
|
||||
"Rejected unauthorized connection on ipc channel: postfix={}, peer_pid={:?}, peer_session_id={:?}, expected_session_id={:?}, peer_is_system={:?}, peer_is_elevated={:?} (suppressed {} similar events)",
|
||||
postfix,
|
||||
peer_pid,
|
||||
peer_session_id,
|
||||
expected_session_id,
|
||||
peer_is_system,
|
||||
peer_is_elevated,
|
||||
suppressed
|
||||
);
|
||||
} else {
|
||||
log::warn!(
|
||||
"Rejected unauthorized connection on ipc channel: postfix={}, peer_pid={:?}, peer_session_id={:?}, expected_session_id={:?}, peer_is_system={:?}, peer_is_elevated={:?}",
|
||||
postfix,
|
||||
peer_pid,
|
||||
peer_session_id,
|
||||
expected_session_id,
|
||||
peer_is_system,
|
||||
peer_is_elevated
|
||||
);
|
||||
}
|
||||
});
|
||||
hbb_common::throttled_log!(
|
||||
UNAUTHORIZED_IPC_LOG_INTERVAL,
|
||||
warn,
|
||||
"Rejected unauthorized connection on ipc channel: postfix={}, peer_pid={:?}, peer_session_id={:?}, expected_session_id={:?}, peer_is_system={:?}, peer_is_elevated={:?}",
|
||||
postfix,
|
||||
peer_pid,
|
||||
peer_session_id,
|
||||
expected_session_id,
|
||||
peer_is_system,
|
||||
peer_is_elevated
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
|
||||
@@ -19,7 +19,28 @@ pub struct KcpStream {
|
||||
stop_sender: Option<oneshot::Sender<()>>,
|
||||
}
|
||||
|
||||
const KCP_IO_ERR_LOG_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
static KCP_SEND_ERR_LOG: hbb_common::log_throttle::LogThrottle =
|
||||
hbb_common::log_throttle::LogThrottle::new(KCP_IO_ERR_LOG_INTERVAL);
|
||||
static KCP_RECV_ERR_LOG: hbb_common::log_throttle::LogThrottle =
|
||||
hbb_common::log_throttle::LogThrottle::new(KCP_IO_ERR_LOG_INTERVAL);
|
||||
|
||||
impl KcpStream {
|
||||
// Opt in to KCP's built-in congestion window (nc=0) instead of the pure turbo profile
|
||||
// (nc=1) that has always shipped; see `get_kcp_cc_enabled` for why this is not the default.
|
||||
// Sender-side only, so no wire negotiation is needed and either peer may run either profile.
|
||||
// Requires kcp-sys from the `rustdesk-patches` branch, which wires the config factory into
|
||||
// connection setup (on older revs the factory was stored but never consulted).
|
||||
fn apply_kcp_config(endpoint: &mut KcpEndpoint) {
|
||||
if crate::get_kcp_cc_enabled() {
|
||||
endpoint.set_kcp_config_factory(Box::new(|conv| {
|
||||
let mut config = kcp_sys::ffi_safe::KcpConfig::new_turbo(conv);
|
||||
config.nc = Some(0);
|
||||
config
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
fn create_framed(stream: stream::KcpStream, local_addr: Option<SocketAddr>) -> Stream {
|
||||
Stream::Tcp(FramedStream(
|
||||
tokio_util::codec::Framed::new(DynTcpStream(Box::new(stream)), BytesCodec::new()),
|
||||
@@ -35,6 +56,7 @@ impl KcpStream {
|
||||
init_packet: Option<BytesMut>,
|
||||
) -> ResultType<(Self, Stream)> {
|
||||
let mut endpoint = KcpEndpoint::new();
|
||||
Self::apply_kcp_config(&mut endpoint);
|
||||
endpoint.run().await;
|
||||
|
||||
let (input, output) = (
|
||||
@@ -70,6 +92,7 @@ impl KcpStream {
|
||||
timeout: std::time::Duration,
|
||||
) -> ResultType<(Self, Stream)> {
|
||||
let mut endpoint = KcpEndpoint::new();
|
||||
Self::apply_kcp_config(&mut endpoint);
|
||||
endpoint.run().await;
|
||||
|
||||
let (input, output) = (
|
||||
@@ -104,6 +127,10 @@ impl KcpStream {
|
||||
let udp = udp_socket.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut buf = vec![0; 1500];
|
||||
// Socket errors are ICMP unreachable on a connected UDP socket — advisory, and
|
||||
// routine while a hole forms — so treat them as loss and let KCP's pong timeout reap
|
||||
// a link that is really dead. One throttle PER DIRECTION: the error is reported once
|
||||
// and cleared, so send-ok/recv-err alternates and a shared counter never fires.
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = &mut stop_receiver => {
|
||||
@@ -112,8 +139,10 @@ impl KcpStream {
|
||||
}
|
||||
Some(data) = output.recv() => {
|
||||
if let Err(e) = udp.send(&data.inner()).await {
|
||||
log::debug!("KCP send error: {:?}", e);
|
||||
break;
|
||||
if let Some(n) = KCP_SEND_ERR_LOG.due() {
|
||||
log::debug!("KCP send error x{n} (treated as loss), last: {e}");
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
}
|
||||
result = udp.recv_from(&mut buf) => {
|
||||
@@ -127,8 +156,10 @@ impl KcpStream {
|
||||
.await.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
log::debug!("KCP recv_from error: {:?}", e);
|
||||
break;
|
||||
if let Some(n) = KCP_RECV_ERR_LOG.due() {
|
||||
log::debug!("KCP recv error x{n} (treated as loss), last: {e}");
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -149,3 +180,124 @@ impl Drop for KcpStream {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
|
||||
async fn connected_pair() -> (Arc<UdpSocket>, Arc<UdpSocket>) {
|
||||
let a = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
let b = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
a.connect(b.local_addr().unwrap()).await.unwrap();
|
||||
b.connect(a.local_addr().unwrap()).await.unwrap();
|
||||
(Arc::new(a), Arc::new(b))
|
||||
}
|
||||
|
||||
async fn establish() -> ((KcpStream, Stream), (KcpStream, Stream)) {
|
||||
let (a, b) = connected_pair().await;
|
||||
let (accept_res, connect_res) = tokio::join!(
|
||||
KcpStream::accept(b, Duration::from_secs(5), None),
|
||||
KcpStream::connect(a, Duration::from_secs(5))
|
||||
);
|
||||
(
|
||||
connect_res.expect("connect over loopback"),
|
||||
accept_res.expect("accept over loopback"),
|
||||
)
|
||||
}
|
||||
|
||||
// The full client path over real loopback sockets: handshake through the kcp_io
|
||||
// pumps, framed data both ways, then a graceful close. The endpoint guard stays
|
||||
// alive across the stream drop so the FIN can go out, and the peer's framed
|
||||
// stream must end (BrokenPipe from the kcp reader) instead of hanging.
|
||||
#[tokio::test]
|
||||
async fn test_kcp_stream_loopback_roundtrip_and_close() {
|
||||
let ((_guard_a, mut stream_a), (_guard_b, mut stream_b)) = establish().await;
|
||||
|
||||
stream_a
|
||||
.send_bytes(Bytes::from_static(b"ping"))
|
||||
.await
|
||||
.unwrap();
|
||||
let got = stream_b.next_timeout(5000).await.unwrap().unwrap();
|
||||
assert_eq!(&got[..], b"ping");
|
||||
|
||||
stream_b
|
||||
.send_bytes(Bytes::from_static(b"pong"))
|
||||
.await
|
||||
.unwrap();
|
||||
let got = stream_a.next_timeout(5000).await.unwrap().unwrap();
|
||||
assert_eq!(&got[..], b"pong");
|
||||
|
||||
drop(stream_a);
|
||||
match stream_b.next_timeout(10_000).await {
|
||||
None | Some(Err(_)) => {}
|
||||
Some(Ok(data)) => panic!("unexpected data after close: {:?}", data),
|
||||
}
|
||||
}
|
||||
|
||||
// A writer that queues many frames and closes immediately must not cost the
|
||||
// reader any of them: every frame arrives intact, in order, before end-of-stream.
|
||||
// This is the client-side pin for the kcp-sys close-tail-drain semantics, through
|
||||
// the real BytesCodec framing rustdesk sessions use.
|
||||
#[tokio::test]
|
||||
async fn test_kcp_stream_close_delivers_all_frames() {
|
||||
let ((_guard_a, mut tx), (_guard_b, mut rx)) = establish().await;
|
||||
|
||||
const N: usize = 50;
|
||||
let payload = vec![7u8; 32 * 1024];
|
||||
for _ in 0..N {
|
||||
tx.send_bytes(Bytes::from(payload.clone())).await.unwrap();
|
||||
}
|
||||
drop(tx);
|
||||
|
||||
let mut got = 0usize;
|
||||
loop {
|
||||
match rx.next_timeout(10_000).await {
|
||||
Some(Ok(data)) => {
|
||||
assert_eq!(data.len(), payload.len(), "frame boundary broken");
|
||||
assert!(data.iter().all(|&b| b == 7), "frame content corrupted");
|
||||
got += 1;
|
||||
}
|
||||
// BrokenPipe (kcp reader end) or timeout-None both end the stream.
|
||||
None | Some(Err(_)) => break,
|
||||
}
|
||||
}
|
||||
assert_eq!(got, N, "graceful close lost frames");
|
||||
}
|
||||
|
||||
// Socket errors on the connected UDP socket (ICMP unreachable after the peer
|
||||
// vanishes) are advisory: the io loop must treat them as loss - keep accepting
|
||||
// writes, keep running - rather than tearing the session down. Whether the OS
|
||||
// actually surfaces ECONNREFUSED here is platform-dependent; either way the
|
||||
// session must stay alive for this window.
|
||||
#[tokio::test]
|
||||
async fn test_kcp_io_treats_socket_errors_as_loss() {
|
||||
let ((_guard_a, mut stream_a), (guard_b, stream_b)) = establish().await;
|
||||
|
||||
// Kill the peer entirely: endpoint stops, socket closes.
|
||||
drop(stream_b);
|
||||
drop(guard_b);
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
|
||||
for _ in 0..10 {
|
||||
stream_a
|
||||
.send_bytes(Bytes::from_static(b"into the void"))
|
||||
.await
|
||||
.expect("socket errors must be treated as loss, not stream failure");
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
}
|
||||
|
||||
// The connect deadline must hold when nothing answers: no hang, prompt error.
|
||||
#[tokio::test]
|
||||
async fn test_kcp_connect_timeout_without_peer() {
|
||||
let (a, _b) = connected_pair().await;
|
||||
let start = tokio::time::Instant::now();
|
||||
let res = KcpStream::connect(a, Duration::from_millis(600)).await;
|
||||
assert!(res.is_err(), "connect must fail with no peer endpoint");
|
||||
assert!(
|
||||
start.elapsed() < Duration::from_secs(5),
|
||||
"connect did not honor its deadline"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "لقطة الشاشة للشاشات المدمجة غير مدعومة"),
|
||||
("screenshot-action-tip", "إجراء لقطة الشاشة"),
|
||||
("Save as", "حفظ باسم"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Copy to clipboard", "نسخ إلى الحافظة"),
|
||||
("Enable remote printer", "تمكين الطابعة عن بُعد"),
|
||||
("Downloading {}", "جارٍ تنزيل {}"),
|
||||
@@ -758,5 +761,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "متابعة"),
|
||||
("Browser didn't open? Use the url below to sign in.", "لم يفتح المتصفح؟ استخدم الرابط أدناه لتسجيل الدخول."),
|
||||
("Lock canvas", "قفل اللوحة"),
|
||||
("Sync clipboard between sessions", "مزامنة الحافظة بين الجلسات"),
|
||||
("sync-clipboard-between-sessions-tip", "النص أو الصور المنسوخة في جلسة بعيدة واحدة تُرسَل أيضًا إلى حافظة جلساتك المتصلة الأخرى."),
|
||||
("Enable WebRTC P2P connection", "تمكين اتصال نظير إلى نظير عبر WebRTC"),
|
||||
("Enable TCP hole punching", "تمكين تقنية حفر الثغرات عبر TCP"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "Аб’яднанне здымкаў экранаў з некалькіх дысплэяў у дадзены момант не падтрымліваецца. Пераключыцеся на адзін з дысплэяў і паўтарыце дзеянне."),
|
||||
("screenshot-action-tip", "Выберыце, што рабіць з атрыманым здымкам экрана."),
|
||||
("Save as", "Захаваць у файл"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Copy to clipboard", "Скапіяваць у буфер абмену"),
|
||||
("Enable remote printer", "Выкарыстоўваць аддалены прынтар"),
|
||||
("Downloading {}", "Ідзе спампоўванне {}"),
|
||||
@@ -758,5 +761,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "Працягнуць"),
|
||||
("Browser didn't open? Use the url below to sign in.", "Браўзер не адкрыўся? Скарыстайцеся спасылкай ніжэй, каб увайсці."),
|
||||
("Lock canvas", "Заблакіраваць палатно"),
|
||||
("Sync clipboard between sessions", "Сінхранізаваць буфер абмену паміж сеансамі"),
|
||||
("sync-clipboard-between-sessions-tip", "Тэкст або відарысы, скапіяваныя ў адным аддаленым сеансе, таксама адпраўляюцца ў буфер абмену іншых вашых падключаных сеансаў."),
|
||||
("Enable WebRTC P2P connection", "Выкарыстоўваць падключэнне WebRTC P2P"),
|
||||
("Enable TCP hole punching", "Выкарыстоўваць TCP hole punching"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "Обединяването на снимки от няколко екрана в момента не се поддържа. Моля, превключете към един екран и опитайте отново."),
|
||||
("screenshot-action-tip", "Моля, изберете как да продължите със снимката на екрана."),
|
||||
("Save as", "Запазване като"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Copy to clipboard", "Копиране в клипборда"),
|
||||
("Enable remote printer", "Позволяване на отдалечен принтер"),
|
||||
("Downloading {}", "Изтегляне на {}"),
|
||||
@@ -758,5 +761,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "Продължи"),
|
||||
("Browser didn't open? Use the url below to sign in.", "Браузърът не се отвори? Използвайте URL адреса по-долу, за да се впишете."),
|
||||
("Lock canvas", "Заключване на платното"),
|
||||
("Sync clipboard between sessions", "Синхронизиране на клипборда между сесиите"),
|
||||
("sync-clipboard-between-sessions-tip", "Текст или изображения, копирани в една отдалечена сесия, се изпращат и към клипборда на другите ви свързани сесии."),
|
||||
("Enable WebRTC P2P connection", "Позволяване на WebRTC P2P връзка"),
|
||||
("Enable TCP hole punching", "Позволяване на TCP hole punching"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "Actualment no és possible combinar captures de pantalla de diverses pantalles. Canvieu a una sola pantalla i torneu a provar."),
|
||||
("screenshot-action-tip", "Seleccioneu com voleu continuar amb la captura de pantalla."),
|
||||
("Save as", "Anomena i desa"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Copy to clipboard", "Copia al porta-retalls"),
|
||||
("Enable remote printer", "Habilita l'impressora remota"),
|
||||
("Downloading {}", "Descarregant {}"),
|
||||
@@ -758,5 +761,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "Continua"),
|
||||
("Browser didn't open? Use the url below to sign in.", "No s'ha obert el navegador? Utilitzeu l'URL de sota per iniciar la sessió."),
|
||||
("Lock canvas", "Bloca el llenç"),
|
||||
("Sync clipboard between sessions", "Sincronitza el porta-retalls entre sessions"),
|
||||
("sync-clipboard-between-sessions-tip", "El text o les imatges copiats en una sessió remota també s'envien al porta-retalls de les altres sessions connectades."),
|
||||
("Enable WebRTC P2P connection", "Habilita la connexió WebRTC P2P"),
|
||||
("Enable TCP hole punching", "Activa la perforació TCP"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "当前不支持多个屏幕的合并截屏,请切换到单个屏幕重试。"),
|
||||
("screenshot-action-tip", "请选择如何继续截屏。"),
|
||||
("Save as", "另存为"),
|
||||
("Export", "导出"),
|
||||
("Export Logs", "导出日志"),
|
||||
("Import Folder", "导入文件夹"),
|
||||
("Copy to clipboard", "复制到剪贴板"),
|
||||
("Enable remote printer", "启用远程打印机"),
|
||||
("Downloading {}", "正在下载 {}"),
|
||||
@@ -758,5 +761,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "继续"),
|
||||
("Browser didn't open? Use the url below to sign in.", "浏览器未打开?请使用下方网址登录。"),
|
||||
("Lock canvas", "锁定画布"),
|
||||
("Sync clipboard between sessions", "在会话间同步剪贴板"),
|
||||
("sync-clipboard-between-sessions-tip", "在一个远程会话中复制的文本或图片也会发送到其他已连接会话的剪贴板。"),
|
||||
("Enable WebRTC P2P connection", "启用 WebRTC P2P 连接"),
|
||||
("Enable TCP hole punching", "启用 TCP 打洞"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "Sloučení snímků obrazovky z více displejů aktuálně není podporováno. Přepněte na jeden displej a zkuste to znovu."),
|
||||
("screenshot-action-tip", "Vyberte, jak pokračovat se snímkem obrazovky."),
|
||||
("Save as", "Uložit jako"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Copy to clipboard", "Kopírovat do schránky"),
|
||||
("Enable remote printer", "Povolit vzdálenou tiskárnu"),
|
||||
("Downloading {}", "Stahuje se {}"),
|
||||
@@ -758,5 +761,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "Pokračovat"),
|
||||
("Browser didn't open? Use the url below to sign in.", "Neotevřel se prohlížeč? Pro přihlášení použijte URL níže."),
|
||||
("Lock canvas", "Zamknout zobrazení"),
|
||||
("Sync clipboard between sessions", "Synchronizovat schránku mezi relacemi"),
|
||||
("sync-clipboard-between-sessions-tip", "Text nebo obrázky zkopírované v jedné vzdálené relaci se odešlou i do schránky ostatních připojených relací."),
|
||||
("Enable WebRTC P2P connection", "Povolit připojení WebRTC P2P"),
|
||||
("Enable TCP hole punching", "Povolit TCP hole punching"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "Sammenfletning af skærmbilleder fra flere skærme understøttes ikke i øjeblikket. Skift venligst til en enkelt skærm og prøv igen."),
|
||||
("screenshot-action-tip", "Vælg venligst, hvordan du vil fortsætte med skærmbilledet."),
|
||||
("Save as", "Gem som"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Copy to clipboard", "Kopiér til udklipsholder"),
|
||||
("Enable remote printer", "Aktivér fjernprinter"),
|
||||
("Downloading {}", "Downloader {}"),
|
||||
@@ -758,5 +761,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "Fortsæt"),
|
||||
("Browser didn't open? Use the url below to sign in.", "Åbnede browseren ikke? Brug URL'en nedenfor til at logge ind."),
|
||||
("Lock canvas", "Lås lærred"),
|
||||
("Sync clipboard between sessions", "Synkroniser udklipsholder mellem sessioner"),
|
||||
("sync-clipboard-between-sessions-tip", "Tekst eller billeder, der kopieres i én fjernsession, sendes også til udklipsholderen i dine andre forbundne sessioner."),
|
||||
("Enable WebRTC P2P connection", "Aktivér WebRTC P2P-forbindelse"),
|
||||
("Enable TCP hole punching", "Aktivér TCP hole punching"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "Das Zusammenführen von Screenshots von mehreren Bildschirmen wird derzeit nicht unterstützt. Bitte wechseln Sie zu einem einzelnen Bildschirm und versuchen Sie es erneut."),
|
||||
("screenshot-action-tip", "Bitte wählen Sie aus, wie Sie mit dem Screenshot fortfahren möchten."),
|
||||
("Save as", "Speichern unter"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Copy to clipboard", "In Zwischenablage kopieren"),
|
||||
("Enable remote printer", "Entfernten Drucker aktivieren"),
|
||||
("Downloading {}", "{} herunterladen"),
|
||||
@@ -758,5 +761,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "Weiter"),
|
||||
("Browser didn't open? Use the url below to sign in.", "Hat sich der Browser nicht geöffnet? Melden Sie sich über die untenstehende URL an."),
|
||||
("Lock canvas", "Sichtfeld sperren"),
|
||||
("Sync clipboard between sessions", "Zwischenablage zwischen Sitzungen synchronisieren"),
|
||||
("sync-clipboard-between-sessions-tip", "In einer Remote-Sitzung kopierter Text oder kopierte Bilder werden auch an die Zwischenablage Ihrer anderen verbundenen Sitzungen gesendet."),
|
||||
("Enable WebRTC P2P connection", "WebRTC-P2P-Verbindung aktivieren"),
|
||||
("Enable TCP hole punching", "TCP-Hole-Punching aktivieren"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "Η συγχώνευση στιγμιότυπων οθόνης από πολλές οθόνες δεν υποστηρίζεται προς το παρόν. Αλλάξτε σε μία μόνο οθόνη και δοκιμάστε ξανά."),
|
||||
("screenshot-action-tip", "Επιλέξτε πώς θα συνεχίσετε με το στιγμιότυπο οθόνης."),
|
||||
("Save as", "Αποθήκευση ως"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Copy to clipboard", "Αντιγραφή στο πρόχειρο"),
|
||||
("Enable remote printer", "Ενεργοποίηση απομακρυσμένου εκτυπωτή"),
|
||||
("Downloading {}", "Γίνεται Λήψη {}"),
|
||||
@@ -758,5 +761,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "Συνέχεια"),
|
||||
("Browser didn't open? Use the url below to sign in.", "Δεν άνοιξε το πρόγραμμα περιήγησης; Χρησιμοποιήστε τον παρακάτω σύνδεσμο για να συνδεθείτε."),
|
||||
("Lock canvas", "Κλείδωμα καμβά"),
|
||||
("Sync clipboard between sessions", "Συγχρονισμός προχείρου μεταξύ συνεδριών"),
|
||||
("sync-clipboard-between-sessions-tip", "Κείμενο ή εικόνες που αντιγράφονται σε μία απομακρυσμένη συνεδρία αποστέλλονται και στο πρόχειρο των άλλων συνδεδεμένων συνεδριών σας."),
|
||||
("Enable WebRTC P2P connection", "Ενεργοποίηση σύνδεσης WebRTC P2P"),
|
||||
("Enable TCP hole punching", "Ενεργοποίηση διάτρησης οπών TCP"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -275,5 +275,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("id_whitelist_caveat_tip", "The ID is reported by the connecting client. This whitelist reduces exposure and does not replace the password or 2FA."),
|
||||
("whitelist_cidr_tip", "CIDR notation is supported, e.g. 192.168.1.0/24"),
|
||||
("Your ip is blocked by the peer", "Your IP is blocked by the peer"),
|
||||
("sync-clipboard-between-sessions-tip", "Text or images copied in one remote session are also sent to the clipboard of your other connected sessions."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "Kunfandi ekrankopiojn de pluraj ekranoj aktuale ne estas subtenata. Bonvolu ŝanĝi al unu ekrano kaj reprovi."),
|
||||
("screenshot-action-tip", "Bonvolu elekti kiel daŭrigi kun la ekrankopio."),
|
||||
("Save as", "Konservi kiel"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Copy to clipboard", "Kopii al la poŝo"),
|
||||
("Enable remote printer", "Ebligi foran presilon"),
|
||||
("Downloading {}", "Elŝutas {}"),
|
||||
@@ -758,5 +761,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "Daŭrigi"),
|
||||
("Browser didn't open? Use the url below to sign in.", "Ĉu la retumilo ne malfermiĝis? Uzu la suban ligilon por ensaluti."),
|
||||
("Lock canvas", "Ŝlosi kanvason"),
|
||||
("Sync clipboard between sessions", "Sinkronigi poŝon inter seancoj"),
|
||||
("sync-clipboard-between-sessions-tip", "Teksto aŭ bildoj kopiitaj en unu fora seanco ankaŭ sendiĝas al la poŝo de viaj aliaj konektitaj seancoj."),
|
||||
("Enable WebRTC P2P connection", "Ebligi WebRTC P2P-konekton"),
|
||||
("Enable TCP hole punching", "Ebligi TCP-trapikadon"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "La fusión de capturas de pantalla de múltiples monitores no está soportada. Por favor, cambie a un monitor e inténtelo de nuevo."),
|
||||
("screenshot-action-tip", "Por favor, seleccione cómo continuar con la captura de pantalla."),
|
||||
("Save as", "Guardar como"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Copy to clipboard", "Copiar al portapapeles"),
|
||||
("Enable remote printer", "Habilitar impresora remota"),
|
||||
("Downloading {}", "Descargando {}"),
|
||||
@@ -758,5 +761,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "Continuar"),
|
||||
("Browser didn't open? Use the url below to sign in.", "¿No se abrió el navegador? Usa la URL de abajo para iniciar sesión."),
|
||||
("Lock canvas", "Bloquear lienzo"),
|
||||
("Sync clipboard between sessions", "Sincronizar portapapeles entre sesiones"),
|
||||
("sync-clipboard-between-sessions-tip", "El texto o las imágenes copiados en una sesión remota también se envían al portapapeles de tus otras sesiones conectadas."),
|
||||
("Enable WebRTC P2P connection", "Habilitar conexión WebRTC P2P"),
|
||||
("Enable TCP hole punching", "Habilitar perforación de agujero TCP"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "Mitme kuva kuvatõmmiste ühendamine pole praegu toetatud. Palun lülitu ühele kuvale ja proovi uuesti."),
|
||||
("screenshot-action-tip", "Palun vali, kuidas kuvatõmmisega jätkata."),
|
||||
("Save as", "Salvesta kui"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Copy to clipboard", "Kopeeri lõikelauale"),
|
||||
("Enable remote printer", "Luba kaugprinter"),
|
||||
("Downloading {}", "Allalaadimine: {}"),
|
||||
@@ -758,5 +761,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "Jätka"),
|
||||
("Browser didn't open? Use the url below to sign in.", "Brauser ei avanenud? Sisselogimiseks kasuta allolevat URL-i."),
|
||||
("Lock canvas", "Lukusta lõuend"),
|
||||
("Sync clipboard between sessions", "Sünkrooni lõikelaud seansside vahel"),
|
||||
("sync-clipboard-between-sessions-tip", "Ühes kaugseansis kopeeritud tekst või pildid saadetakse ka teiste ühendatud seansside lõikelauale."),
|
||||
("Enable WebRTC P2P connection", "Luba WebRTC P2P-ühendus"),
|
||||
("Enable TCP hole punching", "Luba TCP-augustamine"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "Pantaila anitzen pantaila-argazkiak bateratzea ez da onartzen une honetan. Aldatu pantaila bakarrera eta saiatu berriro."),
|
||||
("screenshot-action-tip", "Hautatu pantaila-argazkiarekin nola jarraitu."),
|
||||
("Save as", "Gorde honela"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Copy to clipboard", "Kopiatu arbelera"),
|
||||
("Enable remote printer", "Gaitu urruneko inprimagailua"),
|
||||
("Downloading {}", "{} deskargatzen"),
|
||||
@@ -758,5 +761,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "Jarraitu"),
|
||||
("Browser didn't open? Use the url below to sign in.", "Nabigatzailea ez da ireki? Erabili beheko URLa saioa hasteko."),
|
||||
("Lock canvas", "Blokeatu oihala"),
|
||||
("Sync clipboard between sessions", "Sinkronizatu arbela saioen artean"),
|
||||
("sync-clipboard-between-sessions-tip", "Urruneko saio batean kopiatutako testua edo irudiak konektatutako beste saioen arbelera ere bidaltzen dira."),
|
||||
("Enable WebRTC P2P connection", "Gaitu WebRTC P2P konexioa"),
|
||||
("Enable TCP hole punching", "Gaitu TCP zulo-egitea"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "ادغام تصاویر از نمایشگرهای متعدد در حال حاضر پشتیبانی نمی شود. لطفاً به یک صفحه نمایش واحد تغییر دهید و دوباره امتحان کنید."),
|
||||
("screenshot-action-tip", "لطفاً نحوه ادامه با تصویر را انتخاب کنید."),
|
||||
("Save as", "ذخیره به عنوان"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Copy to clipboard", "در کلیپ بورد کپی کنید"),
|
||||
("Enable remote printer", "چاپگر از راه دور را فعال کنید"),
|
||||
("Downloading {}", "بارگیری {}"),
|
||||
@@ -758,5 +761,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "ادامه"),
|
||||
("Browser didn't open? Use the url below to sign in.", "مرورگر باز نشد؟ برای ورود از نشانی زیر استفاده کنید."),
|
||||
("Lock canvas", "قفل کردن صفحه"),
|
||||
("Sync clipboard between sessions", "همگامسازی کلیپبورد بین نشستها"),
|
||||
("sync-clipboard-between-sessions-tip", "متن یا تصاویری که در یک نشست راه دور کپی میشوند به کلیپبورد سایر نشستهای متصل شما نیز ارسال میشوند."),
|
||||
("Enable WebRTC P2P connection", "فعالسازی اتصال همتابههمتای WebRTC"),
|
||||
("Enable TCP hole punching", "فعالسازی تکنیک TCP hole punching"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "Yhdistetyn näytön kuvakaappaus ei ole tuettu"),
|
||||
("screenshot-action-tip", "Valitse, mitä haluat tehdä kuvakaappaukselle"),
|
||||
("Save as", "Tallenna nimellä"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Copy to clipboard", "Kopioi leikepöydälle"),
|
||||
("Enable remote printer", "Ota etätulostin käyttöön"),
|
||||
("Downloading {}", "Ladataan {}"),
|
||||
@@ -758,5 +761,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "Jatka"),
|
||||
("Browser didn't open? Use the url below to sign in.", "Eikö selain avautunut? Kirjaudu sisään alla olevan osoitteen kautta."),
|
||||
("Lock canvas", "Lukitse näkymä"),
|
||||
("Sync clipboard between sessions", "Synkronoi leikepöytä istuntojen välillä"),
|
||||
("sync-clipboard-between-sessions-tip", "Yhdessä etäistunnossa kopioitu teksti tai kuvat lähetetään myös muiden yhdistettyjen istuntojen leikepöydälle."),
|
||||
("Enable WebRTC P2P connection", "Ota WebRTC P2P yhteys käyttöön"),
|
||||
("Enable TCP hole punching", "Ota käyttöön TCP hole punching tekniikka"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "Actuellement, la prise de capture d’écran ne prend pas en charge les affichages multiples. Veuillez réessayer après avoir sélectionné un seul affichage."),
|
||||
("screenshot-action-tip", "Veuillez choisir l’action à effectuer avec la capture d’écran."),
|
||||
("Save as", "Enregistrer sous"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Copy to clipboard", "Copier dans le presse-papier"),
|
||||
("Enable remote printer", "Activer l’impression à distance"),
|
||||
("Downloading {}", "Téléchargement de {}"),
|
||||
@@ -758,5 +761,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "Continuer"),
|
||||
("Browser didn't open? Use the url below to sign in.", "Le navigateur ne s’est pas ouvert ? Utilisez l’URL ci-dessous pour vous connecter."),
|
||||
("Lock canvas", "Verrouiller la vue"),
|
||||
("Sync clipboard between sessions", "Synchroniser le presse-papiers entre les sessions"),
|
||||
("sync-clipboard-between-sessions-tip", "Le texte ou les images copiés dans une session distante sont également envoyés au presse-papiers de vos autres sessions connectées."),
|
||||
("Enable WebRTC P2P connection", "Activer la connexion P2P WebRTC"),
|
||||
("Enable TCP hole punching", "Activer le « hole punching » TCP"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "რამდენიმე ეკრანის სურათის გაერთიანება ამჟამად მხარდაჭერილი არ არის. გადართეთ ერთ ეკრანზე და სცადეთ ხელახლა."),
|
||||
("screenshot-action-tip", "აირჩიეთ, როგორ გავაგრძელოთ ეკრანის სურათთან მუშაობა."),
|
||||
("Save as", "შენახვა როგორც"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Copy to clipboard", "ბუფერში კოპირება"),
|
||||
("Enable remote printer", "დისტანციური პრინტერის ჩართვა"),
|
||||
("Downloading {}", "მიმდინარეობს {}-ის ჩამოტვირთვა"),
|
||||
@@ -758,5 +761,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "გაგრძელება"),
|
||||
("Browser didn't open? Use the url below to sign in.", "ბრაუზერი არ გაიხსნა? შესასვლელად გამოიყენეთ ქვემოთ მოცემული ბმული."),
|
||||
("Lock canvas", "ტილოს დაბლოკვა"),
|
||||
("Sync clipboard between sessions", "გაცვლის ბუფერის სინქრონიზაცია სესიებს შორის"),
|
||||
("sync-clipboard-between-sessions-tip", "ერთ დაშორებულ სესიაში დაკოპირებული ტექსტი ან სურათები ასევე იგზავნება თქვენი სხვა დაკავშირებული სესიების გაცვლის ბუფერში."),
|
||||
("Enable WebRTC P2P connection", "WebRTC P2P კავშირის ჩართვა"),
|
||||
("Enable TCP hole punching", "TCP hole punching-ის ჩართვა"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "મર્જ કરેલ સ્ક્રીનશોટ સપોર્ટેડ નથી."),
|
||||
("screenshot-action-tip", "સ્ક્રીનશોટ પછીની ક્રિયા"),
|
||||
("Save as", "તરીકે સાચવો"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Copy to clipboard", "ક્લિપબોર્ડમાં કોપી કરો"),
|
||||
("Enable remote printer", "રિમોટ પ્રિન્ટર સક્ષમ કરો"),
|
||||
("Downloading {}", "{} ડાઉનલોડ થઈ રહ્યું છે"),
|
||||
@@ -758,5 +761,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "ચાલુ રાખો"),
|
||||
("Browser didn't open? Use the url below to sign in.", "બ્રાઉઝર ખૂલ્યું નથી? લોગિન કરવા માટે નીચે આપેલ URL નો ઉપયોગ કરો."),
|
||||
("Lock canvas", "કેનવાસ લોક કરો"),
|
||||
("Sync clipboard between sessions", "સત્રો વચ્ચે ક્લિપબોર્ડ સિંક કરો"),
|
||||
("sync-clipboard-between-sessions-tip", "એક રિમોટ સત્રમાં કૉપિ કરેલ ટેક્સ્ટ કે છબીઓ તમારા અન્ય જોડાયેલા સત્રોના ક્લિપબોર્ડ પર પણ મોકલવામાં આવે છે."),
|
||||
("Enable WebRTC P2P connection", "WebRTC P2P કનેક્શન સક્ષમ કરો"),
|
||||
("Enable TCP hole punching", "TCP હોલ પંચિંગ સક્ષમ કરો"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "צילום מסך משולב מכל המסכים אינו נתמך"),
|
||||
("screenshot-action-tip", "בחר פעולה לאחר צילום המסך"),
|
||||
("Save as", "שמור בשם"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Copy to clipboard", "העתק ללוח"),
|
||||
("Enable remote printer", "אפשר מדפסת מרוחקת"),
|
||||
("Downloading {}", "מוריד את {}"),
|
||||
@@ -758,5 +761,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "המשך"),
|
||||
("Browser didn't open? Use the url below to sign in.", "הדפדפן לא נפתח? השתמש בכתובת שלמטה כדי להתחבר."),
|
||||
("Lock canvas", "נעל לוח ציור"),
|
||||
("Sync clipboard between sessions", "סנכרן לוח בין סשנים"),
|
||||
("sync-clipboard-between-sessions-tip", "טקסט או תמונות שהועתקו בסשן מרוחק אחד נשלחים גם ללוח של שאר הסשנים המחוברים שלך."),
|
||||
("Enable WebRTC P2P connection", "אפשר חיבור WebRTC P2P"),
|
||||
("Enable TCP hole punching", "אפשר TCP hole punching"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "मर्ज की गई स्क्रीन के स्क्रीनशॉट समर्थित नहीं हैं।"),
|
||||
("screenshot-action-tip", "स्क्रीनशॉट लेने के बाद की कार्रवाई"),
|
||||
("Save as", "इस रूप में सहेजें"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Copy to clipboard", "क्लिपबोर्ड पर कॉपी करें"),
|
||||
("Enable remote printer", "रिमोट प्रिंटर सक्षम करें"),
|
||||
("Downloading {}", "{} डाउनलोड हो रहा है"),
|
||||
@@ -758,5 +761,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "जारी रखें"),
|
||||
("Browser didn't open? Use the url below to sign in.", "ब्राउज़र नहीं खुला? लॉगिन करने के लिए नीचे दिए गए URL का उपयोग करें।"),
|
||||
("Lock canvas", "कैनवास लॉक करें"),
|
||||
("Sync clipboard between sessions", "सत्रों के बीच क्लिपबोर्ड सिंक करें"),
|
||||
("sync-clipboard-between-sessions-tip", "एक रिमोट सत्र में कॉपी किए गए टेक्स्ट या चित्र आपके अन्य जुड़े सत्रों के क्लिपबोर्ड पर भी भेजे जाते हैं।"),
|
||||
("Enable WebRTC P2P connection", "WebRTC P2P कनेक्शन सक्षम करें"),
|
||||
("Enable TCP hole punching", "TCP होल पंचिंग सक्षम करें"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "Spajanje snimaka zaslona s više zaslona trenutačno nije podržano. Prebacite se na jedan zaslon i pokušajte ponovno."),
|
||||
("screenshot-action-tip", "Odaberite kako nastaviti sa snimkom zaslona."),
|
||||
("Save as", "Spremi kao"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Copy to clipboard", "Kopiraj u međuspremnik"),
|
||||
("Enable remote printer", "Omogući udaljeni pisač"),
|
||||
("Downloading {}", "Preuzimanje {}"),
|
||||
@@ -758,5 +761,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "Nastavi"),
|
||||
("Browser didn't open? Use the url below to sign in.", "Preglednik se nije otvorio? Za prijavu upotrijebite URL u nastavku."),
|
||||
("Lock canvas", "Zaključaj pozadinu"),
|
||||
("Sync clipboard between sessions", "Sinkroniziraj međuspremnik između sesija"),
|
||||
("sync-clipboard-between-sessions-tip", "Tekst ili slike kopirani u jednoj udaljenoj sesiji šalju se i u međuspremnik vaših ostalih povezanih sesija."),
|
||||
("Enable WebRTC P2P connection", "Omogući WebRTC P2P vezu"),
|
||||
("Enable TCP hole punching", "Omogući TCP hole punching"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "Egyesített képernyőről nem támogatott a képernyőkép készítése"),
|
||||
("screenshot-action-tip", "Képernyőkép-művelet"),
|
||||
("Save as", "Mentés másként"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Copy to clipboard", "Másolás a vágólapra"),
|
||||
("Enable remote printer", "Távoli nyomtatók engedélyezése"),
|
||||
("Downloading {}", "{} letöltése"),
|
||||
@@ -758,5 +761,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "Folytatás"),
|
||||
("Browser didn't open? Use the url below to sign in.", "Nem nyílt meg a böngésző? A belépéshez használja az alábbi URL-címet."),
|
||||
("Lock canvas", "Nézet zárolása"),
|
||||
("Sync clipboard between sessions", "Vágólap szinkronizálása a munkamenetek között"),
|
||||
("sync-clipboard-between-sessions-tip", "Az egyik távoli munkamenetben másolt szöveg vagy kép a többi csatlakoztatott munkamenet vágólapjára is elküldésre kerül."),
|
||||
("Enable WebRTC P2P connection", "WebRTC P2P kapcsolat engedélyezése"),
|
||||
("Enable TCP hole punching", "TCP résszűrés engedélyezése"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "Menggabungkan tangkapan layar dari beberapa tampilan saat ini tidak didukung. Silakan beralih ke satu tampilan dan coba lagi."),
|
||||
("screenshot-action-tip", "Silakan pilih cara melanjutkan dengan tangkapan layar."),
|
||||
("Save as", "Simpan sebagai"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Copy to clipboard", "Salin ke papan klip"),
|
||||
("Enable remote printer", "Aktifkan printer jarak jauh"),
|
||||
("Downloading {}", "Mendownload {}"),
|
||||
@@ -758,5 +761,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "Lanjutkan"),
|
||||
("Browser didn't open? Use the url below to sign in.", "Browser tidak terbuka? Gunakan URL di bawah ini untuk masuk."),
|
||||
("Lock canvas", "Kunci kanvas"),
|
||||
("Sync clipboard between sessions", "Sinkronkan papan klip antar sesi"),
|
||||
("sync-clipboard-between-sessions-tip", "Teks atau gambar yang disalin di satu sesi jarak jauh juga dikirim ke papan klip sesi terhubung Anda yang lain."),
|
||||
("Enable WebRTC P2P connection", "Aktifkan koneksi P2P WebRTC"),
|
||||
("Enable TCP hole punching", "Aktifkan TCP hole punching"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "L'unione della cattura di schermate di più display non è attualmente supportata.\nPassa ad un singolo display e riprova."),
|
||||
("screenshot-action-tip", "Seleziona come continuare con la schermata."),
|
||||
("Save as", "Salva come"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Copy to clipboard", "Copia negli appunti"),
|
||||
("Enable remote printer", "Abilita stampante remota"),
|
||||
("Downloading {}", "Download {}"),
|
||||
@@ -758,5 +761,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "Continua"),
|
||||
("Browser didn't open? Use the url below to sign in.", "Il browser non si è aperto? Usa l'URL qui sotto per accedere."),
|
||||
("Lock canvas", "Blocca tela"),
|
||||
("Sync clipboard between sessions", "Sincronizza gli appunti tra le sessioni"),
|
||||
("sync-clipboard-between-sessions-tip", "Il testo o le immagini copiati in una sessione remota vengono inviati anche agli appunti delle altre sessioni connesse."),
|
||||
("Enable WebRTC P2P connection", "Abilita connessione P2P WebRTC"),
|
||||
("Enable TCP hole punching", "Abilita hole punching TCP"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "複数のディスプレイのスクリーンショットの結合は、現在非対応です。単一のディスプレイに切り替えてもう一度お試しください。"),
|
||||
("screenshot-action-tip", "スクリーンショットを続行する方法を選択してください。"),
|
||||
("Save as", "保存先"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Copy to clipboard", "クリップボードにコピー"),
|
||||
("Enable remote printer", "リモートプリンターを有効化する"),
|
||||
("Downloading {}", "{} をダウンロード中"),
|
||||
@@ -758,5 +761,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "続行"),
|
||||
("Browser didn't open? Use the url below to sign in.", "ブラウザが開きませんでしたか?下記の URL からログインしてください。"),
|
||||
("Lock canvas", "キャンバスをロック"),
|
||||
("Sync clipboard between sessions", "セッション間でクリップボードを同期"),
|
||||
("sync-clipboard-between-sessions-tip", "1つのリモートセッションでコピーしたテキストや画像は、接続中の他のセッションのクリップボードにも送信されます。"),
|
||||
("Enable WebRTC P2P connection", "WebRTC P2P 接続を有効化する"),
|
||||
("Enable TCP hole punching", "TCP ホールパンチを有効化する"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "현재 다중 디스플레이의 스크린샷 병합이 지원되지 않습니다. 단일 디스플레이로 전환한 후 다시 시도해 주세요."),
|
||||
("screenshot-action-tip", "스크린샷을 계속 진행할 방법을 선택해 주세요."),
|
||||
("Save as", "다른 이름으로 저장"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Copy to clipboard", "클립보드에 복사"),
|
||||
("Enable remote printer", "원격 프린터 허용"),
|
||||
("Downloading {}", "{} 다운로드 중"),
|
||||
@@ -758,5 +761,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "계속"),
|
||||
("Browser didn't open? Use the url below to sign in.", "브라우저가 열리지 않았나요? 아래 URL로 로그인하세요."),
|
||||
("Lock canvas", "캔버스 잠금"),
|
||||
("Sync clipboard between sessions", "세션 간 클립보드 동기화"),
|
||||
("sync-clipboard-between-sessions-tip", "하나의 원격 세션에서 복사한 텍스트나 이미지는 연결된 다른 세션의 클립보드에도 전송됩니다."),
|
||||
("Enable WebRTC P2P connection", "WebRTC P2P 연결 사용"),
|
||||
("Enable TCP hole punching", "TCP 홀 펀칭 사용"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "Бірнеше дисплейдің скриншоттарын біріктіруге қазір қолдау көрсетілмейді. Жеке дисплейге ауысып, қайталап көруді өтінеміз."),
|
||||
("screenshot-action-tip", "Скриншотпен қалай жалғастыру керектігін таңдауды өтінеміз."),
|
||||
("Save as", "Басқаша сақтау"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Copy to clipboard", "Көшіру-тақтаға көшіру"),
|
||||
("Enable remote printer", "Қашықтағы принтерді іске қосу"),
|
||||
("Downloading {}", "{} жүктелуде"),
|
||||
@@ -758,5 +761,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "Жалғастыру"),
|
||||
("Browser didn't open? Use the url below to sign in.", "Браузер ашылмады ма? Кіру үшін төмендегі сілтемені пайдаланыңыз."),
|
||||
("Lock canvas", "Кенепті құлыптау"),
|
||||
("Sync clipboard between sessions", "Сеанстар арасында көшіру-тақтасын синхрондау"),
|
||||
("sync-clipboard-between-sessions-tip", "Бір қашықтағы сеанста көшірілген мәтін немесе суреттер басқа қосылған сеанстардың көшіру-тақтасына да жіберіледі."),
|
||||
("Enable WebRTC P2P connection", "WebRTC P2P қосылымын іске қосу"),
|
||||
("Enable TCP hole punching", "TCP hole punching'ті іске қосу"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "Kelių ekranų nuotraukų sujungimas šiuo metu nepalaikomas. Perjunkite į vieną ekraną ir bandykite dar kartą."),
|
||||
("screenshot-action-tip", "Pasirinkite, ką daryti su ekrano nuotrauka."),
|
||||
("Save as", "Įrašyti kaip"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Copy to clipboard", "Kopijuoti į iškarpinę"),
|
||||
("Enable remote printer", "Įgalinti nuotolinį spausdintuvą"),
|
||||
("Downloading {}", "Atsisiunčiama {}"),
|
||||
@@ -758,5 +761,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "Tęsti"),
|
||||
("Browser didn't open? Use the url below to sign in.", "Naršyklė neatsidarė? Prisijunkite naudodami toliau pateiktą URL."),
|
||||
("Lock canvas", "Užrakinti drobę"),
|
||||
("Sync clipboard between sessions", "Sinchronizuoti iškarpinę tarp seansų"),
|
||||
("sync-clipboard-between-sessions-tip", "Viename nuotoliniame seanse nukopijuotas tekstas ar vaizdai taip pat siunčiami į kitų prijungtų seansų iškarpinę."),
|
||||
("Enable WebRTC P2P connection", "Įgalinti WebRTC P2P ryšį"),
|
||||
("Enable TCP hole punching", "Įgalinti TCP gręžimą (hole punching)"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "Vairāku displeju ekrānuzņēmumu apvienošana pašlaik netiek atbalstīta. Lūdzu, pārslēdzieties uz vienu displeju un mēģiniet vēlreiz."),
|
||||
("screenshot-action-tip", "Lūdzu, atlasiet, kā turpināt darbu ar ekrānuzņēmumu."),
|
||||
("Save as", "Saglabāt kā"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Copy to clipboard", "Kopēt starpliktuvē"),
|
||||
("Enable remote printer", "Iespējot attālo printeri"),
|
||||
("Downloading {}", "Notiek {} lejupielāde"),
|
||||
@@ -758,5 +761,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "Turpināt"),
|
||||
("Browser didn't open? Use the url below to sign in.", "Pārlūkprogramma neatvērās? Izmantojiet tālāk norādīto URL, lai pieslēgtos."),
|
||||
("Lock canvas", "Bloķēt audeklu"),
|
||||
("Sync clipboard between sessions", "Sinhronizēt starpliktuvi starp sesijām"),
|
||||
("sync-clipboard-between-sessions-tip", "Vienā attālajā sesijā nokopētais teksts vai attēli tiek nosūtīti arī uz pārējo pievienoto sesiju starpliktuvi."),
|
||||
("Enable WebRTC P2P connection", "Iespējot WebRTC P2P savienojumu"),
|
||||
("Enable TCP hole punching", "Iespējot TCP caurumu veidošanu"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "മെർജ് ചെയ്ത സ്ക്രീൻഷോട്ട് പിന്തുണയ്ക്കുന്നില്ല."),
|
||||
("screenshot-action-tip", "സ്ക്രീൻഷോട്ടിന് ശേഷമുള്ള നടപടി"),
|
||||
("Save as", "പേരിൽ സേവ് ചെയ്യുക"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Copy to clipboard", "ക്ലിപ്പ്ബോർഡിലേക്ക് കോപ്പി ചെയ്യുക"),
|
||||
("Enable remote printer", "റിമോട്ട് പ്രിന്റർ അനുവദിക്കുക"),
|
||||
("Downloading {}", "{} ഡൗൺലോഡ് ചെയ്യുന്നു"),
|
||||
@@ -758,5 +761,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "തുടരുക"),
|
||||
("Browser didn't open? Use the url below to sign in.", "ബ്രൗസർ തുറന്നില്ലേ? ലോഗിൻ ചെയ്യാൻ താഴെയുള്ള URL ഉപയോഗിക്കുക."),
|
||||
("Lock canvas", "ക്യാൻവാസ് ലോക്ക് ചെയ്യുക"),
|
||||
("Sync clipboard between sessions", "സെഷനുകൾക്കിടയിൽ ക്ലിപ്പ്ബോർഡ് സമന്വയിപ്പിക്കുക"),
|
||||
("sync-clipboard-between-sessions-tip", "ഒരു റിമോട്ട് സെഷനിൽ പകർത്തിയ ടെക്സ്റ്റോ ചിത്രങ്ങളോ നിങ്ങളുടെ മറ്റ് കണക്റ്റുചെയ്ത സെഷനുകളുടെ ക്ലിപ്പ്ബോർഡിലേക്കും അയയ്ക്കപ്പെടും."),
|
||||
("Enable WebRTC P2P connection", "WebRTC P2P കണക്ഷൻ അനുവദിക്കുക"),
|
||||
("Enable TCP hole punching", "TCP ഹോൾ പഞ്ചിംഗ് അനുവദിക്കുക"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("screenshot-merged-screen-not-supported-tip", "Sammenslåing av skjermbilder fra flere skjermer støttes for øyeblikket ikke. Bytt til én enkelt skjerm og prøv igjen."),
|
||||
("screenshot-action-tip", "Velg hvordan du vil fortsette med skjermbildet."),
|
||||
("Save as", "Lagre som"),
|
||||
("Export", ""),
|
||||
("Export Logs", ""),
|
||||
("Import Folder", ""),
|
||||
("Copy to clipboard", "Kopier til utklipstavlen"),
|
||||
("Enable remote printer", "Aktiver fjernskriver"),
|
||||
("Downloading {}", "Laster ned {}"),
|
||||
@@ -758,5 +761,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Continue", "Fortsett"),
|
||||
("Browser didn't open? Use the url below to sign in.", "Åpnet ikke nettleseren? Bruk URL-en nedenfor for å logge inn."),
|
||||
("Lock canvas", "Lås lerret"),
|
||||
("Sync clipboard between sessions", "Synkroniser utklippstavlen mellom økter"),
|
||||
("sync-clipboard-between-sessions-tip", "Tekst eller bilder som kopieres i én ekstern økt, sendes også til utklippstavlen i de andre tilkoblede øktene dine."),
|
||||
("Enable WebRTC P2P connection", "Aktiver WebRTC P2P-tilkobling"),
|
||||
("Enable TCP hole punching", "Aktiver TCP hole punching"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user