Compare commits

..

9 Commits

Author SHA1 Message Date
21pages
ce968c02dd scrap: load the D3D compiler securely from System32
Signed-off-by: 21pages <sunboeasy@gmail.com>
2026-09-03 10:36:12 +08:00
21pages
89d444b950 scrap: avoid null duplication after HDR fallback failure
Signed-off-by: 21pages <sunboeasy@gmail.com>
2026-09-02 23:08:34 +08:00
21pages
792618e59a scrap: log DuplicateOutput1 failures before fallback
Signed-off-by: 21pages <sunboeasy@gmail.com>
2026-09-02 21:02:09 +08:00
rustdesk
2f28a96f6a scrap: keep enumerate_output6's both-or-neither promise when IDXGIOutput6 is missing
Review nit: a matched output whose IDXGIOutput6 query fails returned the
fresh factory next to a null output, which let the constructor pair a
current factory with the capturer's fallback output. Return neither in
that case so the next refresh enumerates again.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLb1dNdhFqUQExJPANjo1F
2026-09-02 16:14:22 +08:00
rustdesk
2f3a6789de scrap: replace the DXGI factory and output together when re-reading HDR state
Review follow-up: when the fresh factory did not find the output by name,
the refresh kept the new, current factory next to the old output, and
because IsCurrent then reported TRUE it never enumerated again, so the
stale output could stay forever. enumerate_output6 now returns both or
neither, and the refresh only replaces the pair together; a null factory
forces another enumeration on the next refresh while the old output is
still read in the meantime. The constructor gets the same guarantee for
free, since a failed lookup leaves the factory null there too.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLb1dNdhFqUQExJPANjo1F
2026-09-02 16:00:47 +08:00
rustdesk
6e502cde2e scrap: re-read the Advanced Color state while capturing, gate FP16 on IDXGIOutput6
Review follow-up:

- The HDR/WCG decision was taken once when the conversion was created.
  HDR can be switched on or off, and a WCG desktop can turn into an HDR
  one, without the duplication being invalidated, so the choice between
  "divide by the SDR white level" and "no scaling" could go stale. The
  once-a-second refresh now re-reads it: it keeps an IDXGIFactory1,
  and when IsCurrent reports FALSE it creates a fresh factory and
  re-finds the output by GDI name, since a stale factory's outputs keep
  stale descriptions (this is the procedure the GetDesc1 docs require).
  On a change it switches modes, re-reads the white level and updates
  the shader constant; an unreadable level keeps the last known one.

- Float frames are only requested when IDXGIOutput6 exists, as
  Microsoft's duplication sample does. That interface is also what tells
  HDR from WCG, so there is no longer a state where FP16 is requested
  without being able to interpret it. IDXGIOutput6 dates from Windows 10
  1703, not 1803 as the previous commit said; 1803 added IDXGIFactory6.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLb1dNdhFqUQExJPANjo1F
2026-09-02 15:44:23 +08:00
rustdesk
3730ab0733 scrap: only apply the SDR white level to outputs DXGI reports as HDR
Review follow-up:

- An FP16 desktop is not necessarily HDR. Since Windows 11 22H2 an
  Advanced Color (WCG) SDR display is composed in FP16 scRGB too, and
  there 1.0 is the display's reference white, not 80 nits, so the
  SDRWhiteLevel scaling does not apply (Microsoft: "Reference white
  applies only to HDR displays"). Query IDXGIOutput6::GetDesc1 and treat
  the output as HDR only for DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020,
  the documented Win32 check; a non-HDR FP16 frame now gets just the
  scRGB -> sRGB transfer. Without IDXGIOutput6 (before Windows 10 1803)
  FP16 can only mean HDR.

- The SDR white level is now Option: a failed query or a reported 0 is
  "unknown" on both the initial and the refresh path, an HDR output with
  an unknown level logs a warning and keeps the 80-nit assumption until
  the periodic re-query succeeds. DISPLAYCONFIG_DEVICE_INFO_GET_SDR_WHITE_LEVEL
  needs Windows 10 1709, not 1703 as an earlier message said.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLb1dNdhFqUQExJPANjo1F
2026-09-02 15:02:57 +08:00
rustdesk
696451b08b scrap: stay on DXGI when the HDR conversion fails, load the compiler once
Review follow-up:

- A tone-map failure surfaced as a capture error, and the capture loop
  answers any DXGI error by switching the capturer to GDI for the rest
  of its life. The capturer now drops the tone-map, re-creates the
  duplication with the legacy DuplicateOutput (DXGI's clipped BGRA8,
  the pre-HDR behaviour) and returns WouldBlock, so the session stays
  on DXGI and simply fetches the next frame.

- Only failures that cannot succeed anywhere in the process (no
  d3dcompiler_47.dll, shaders that do not compile) set the global
  UNAVAILABLE flag; D3D object creation failures stay with the capturer
  that hit them, so another adapter or a recreated capturer tries again.

- D3DCompile is resolved once through a OnceLock instead of a
  LoadLibrary per capturer that was never freed.

- The module doc now states what this pass is: normalization of an HDR
  desktop to SDR, with everything above SDR white clipping, not a tone
  map, and why a roll-off is deliberately not applied. The earlier
  claim that clipping matches what the local user sees was wrong for
  HDR content on an HDR display.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLb1dNdhFqUQExJPANjo1F
2026-09-02 13:04:23 +08:00
rustdesk
a7df138fc7 scrap: tone-map HDR desktops to SDR in the Windows capturer
With HDR enabled, Windows composes the desktop as linear scRGB in
R16G16B16A16_FLOAT and places SDR white at the user's "SDR content
brightness" (DISPLAYCONFIG_SDR_WHITE_LEVEL / 1000, e.g. 2.5 for 200
nits) rather than at 1.0. The legacy IDXGIOutput1::DuplicateOutput we
used always converts that surface to BGRA8, and it does so by clipping,
so everything above ~40% linear brightness lands on white. That is the
washed-out / overexposed / high-contrast picture reported for HDR hosts
in #5368, #9951, #12707 and discussion #7652.

Ask for the desktop with IDXGIOutput5::DuplicateOutput1 and the format
list [R16G16B16A16_FLOAT, B8G8R8A8_UNORM]. An SDR desktop still yields
BGRA8 and takes the unchanged path. A float frame is converted on the
GPU by a small pixel shader: divide by the SDR white level, clamp, apply
the sRGB transfer. SDR content round-trips exactly, HDR highlights clip
at white, the same result the local user sees. The white level is read
per output through DisplayConfigGetDeviceInfo and refreshed once a
second, so dragging the Windows slider tracks remotely. The converted
BGRA8 texture feeds both the staging-copy (CPU) path and the vram
(hwcodec texture) path; rotation is unchanged. Toggling HDR mid-session
invalidates the duplication as before, and the recreated capturer
re-detects the format.

The shaders are compiled at runtime from a dynamically loaded
d3dcompiler_47.dll so no new import is added to the binary. If the DLL,
the compile or any D3D object creation fails, a process-wide flag makes
later capturers fall back to the legacy DuplicateOutput, i.e. today's
behaviour.

This deliberately stops at SDR on the controlled side, with no option
and no protocol change, which is how OBS, Sunshine (client without HDR)
and macOS screen capture handle it. Real HDR pass-through would need
10-bit capture, Main10/AV1-10 encoders in the hwcodec fork, colour
metadata on the wire and, decisively, an HDR output path on the
controller: Flutter's desktop external textures are BGRA8888/RGBA8888
only on every platform, so nothing would be visible. When that changes
it should follow the Sunshine/Moonlight pattern: an hdr capability bit
advertised by the controller behind an explicit user toggle, negotiated
like i444.

Type-checked against x86_64-pc-windows-msvc (with and without the vram
feature); not yet run on an HDR machine, which needs Windows 10 1703+
with HDR on.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLb1dNdhFqUQExJPANjo1F
(cherry picked from commit ca3c2f8ac7d1549a40855411b0539a69c82d7223)
2026-09-02 12:36:48 +08:00
209 changed files with 2567 additions and 25122 deletions

View File

@@ -1,42 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
app_path=$1
identity=$2
entitlements=$3
sign_args=(--force --options runtime --sign "$identity")
if [[ "$identity" != "-" ]]; then
sign_args+=(--timestamp)
fi
frameworks_path="$app_path/Contents/Frameworks"
if [[ -d "$frameworks_path" ]]; then
while IFS= read -r -d '' code; do
if file -b "$code" | grep -q 'Mach-O'; then
codesign "${sign_args[@]}" "$code"
fi
done < <(find "$frameworks_path" -type f -print0)
while IFS= read -r -d '' framework; do
codesign "${sign_args[@]}" "$framework"
done < <(find "$frameworks_path" -depth -type d -name '*.framework' -print0)
fi
service_path="$app_path/Contents/MacOS/service"
if [[ -f "$service_path" ]]; then
codesign "${sign_args[@]}" "$service_path"
fi
codesign "${sign_args[@]}" --generate-entitlement-der \
--entitlements "$entitlements" "$app_path"
codesign --verify --deep --strict --verbose=2 "$app_path"
actual_entitlements=$(codesign -d --entitlements :- "$app_path" 2>/dev/null)
audio_input=$(plutil -extract 'com\.apple\.security\.device\.audio-input' raw - \
<<<"$actual_entitlements")
if [[ "$audio_input" != "true" ]]; then
echo "Missing com.apple.security.device.audio-input entitlement" >&2
exit 1
fi

View File

@@ -43,7 +43,6 @@ 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.5.0"
NDK_VERSION: "r28c"
@@ -390,54 +389,6 @@ 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
@@ -926,11 +877,7 @@ jobs:
security unlock-keychain -p ${{ secrets.MACOS_P12_PASSWORD }} rustdesk.keychain
# start sign the rustdesk.app and dmg
rm -rf *.dmg || true
# the identity secret carries its own shell quoting, so expand it inline like the dmg codesign below
bash ./.github/scripts/sign-macos-app.sh \
./flutter/build/macos/Build/Products/Release/RustDesk.app \
${{ secrets.MACOS_CODESIGN_IDENTITY }} \
./flutter/macos/Runner/Release.entitlements
codesign --force --options runtime -s ${{ secrets.MACOS_CODESIGN_IDENTITY }} --deep --strict ./flutter/build/macos/Build/Products/Release/RustDesk.app -vvv
create-dmg --icon "RustDesk.app" 200 190 --hide-extension "RustDesk.app" --window-size 800 400 --app-drop-link 600 185 rustdesk-${{ env.VERSION }}.dmg ./flutter/build/macos/Build/Products/Release/RustDesk.app
codesign --force --options runtime -s ${{ secrets.MACOS_CODESIGN_IDENTITY }} --deep --strict rustdesk-${{ env.VERSION }}.dmg -vvv
# notarize the rustdesk-${{ env.VERSION }}.dmg
@@ -978,33 +925,15 @@ 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-aarch64 windows-x86 msi-template
tar czf rustdesk-${{ env.VERSION }}-unsigned.tar.gz *.dmg windows-x86_64 windows-x86
- name: Publish unsigned app
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
@@ -1541,6 +1470,7 @@ 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
@@ -1575,15 +1505,6 @@ 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
@@ -2154,12 +2075,6 @@ 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
@@ -2304,16 +2219,6 @@ jobs:
# build rustdesk
python3 ./res/inline-sciter.py
export CARGO_INCREMENTAL=0
# armv7 is the only 32-bit target in this job that links the whole binary, and the
# release profile uses fat LTO with codegen-units=1. LLVM then merges every module
# into a single unit and runs past the ~3GB address space a 32-bit process gets,
# aborting rustc with "Rust cannot catch foreign exceptions" (a C++ bad_alloc from
# LLVM unwinding into rustc's Rust frames). Thin LTO keeps peak memory bounded and
# still allows cross-crate inlining; 64-bit targets keep fat LTO untouched.
if [ "${{ matrix.job.arch }}" = "armv7" ]; then
export CARGO_PROFILE_RELEASE_LTO=thin
export CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16
fi
cargo build --locked --features inline${{ matrix.job.extra_features }} --release --bins --jobs 1
# make debian package
mkdir -p ./Release

View File

@@ -8,24 +8,18 @@
* `src/platform/` platform-specific code
* `src/ui/` legacy Sciter UI (deprecated)
* `flutter/` current UI
* `libs/hbb_common/` shared with the server: rendezvous proto, sockets, `Config` core
* `libs/base/` (crate `base`) client-only: option keys, message proto, file transfer, platform code
* `libs/hbb_common/` config / proto / shared utils
* `libs/scrap/` screen capture
* `libs/enigo/` input control
* `libs/clipboard/` clipboard
* `libs/base/src/config/keys.rs` the single import path for all options
* `libs/hbb_common/src/config.rs` all options
### Key Components
- **Remote Desktop Protocol**: Custom protocol implemented in `src/rendezvous_mediator.rs` for communicating with rustdesk-server
- **Screen Capture**: Platform-specific screen capture in `libs/scrap/`
- **Input Handling**: Cross-platform input simulation in `libs/enigo/`
- **Audio/Video Services**: Real-time audio/video streaming in `src/server/`
- **File Transfer**: Secure file transfer implementation in `libs/base/src/fs.rs`
`hbb_common` is a git submodule shared with the server, so changing it costs a
round-trip. Put client-only code in `libs/base` instead; it is a normal
workspace member. `base::config::keys` re-exports the handful of keys
`hbb_common` still reads, so callers get the whole set from that one path.
- **File Transfer**: Secure file transfer implementation in `libs/hbb_common/`
### UI Architecture
- **Legacy UI**: Sciter-based (deprecated) - files in `src/ui/`
@@ -67,34 +61,6 @@ workspace member. `base::config::keys` re-exports the handful of keys
* Do not make formatting-only changes.
* Keep naming/style consistent with nearby code.
### Imports
* One `use` per crate. Everything a file takes from the same crate goes in a
single braced block, not one statement per item:
```rust
// no
use base::fs;
use base::message_proto::*;
// yes
use base::{fs, message_proto::*};
```
* The only reason to split is a `#[cfg(...)]` that does not apply to the whole
block -- an attribute binds to one item, so a differently-gated import has to
stand on its own. A `pub use` re-export likewise cannot join a plain `use`.
```rust
#[cfg(not(feature = "flutter"))]
use base::fs;
use base::message_proto::*;
```
* When splitting an existing `use` because some of its items moved to another
crate, fold each side into that crate's existing block rather than leaving a
second statement behind.
### Comments
* Avoid comments unless they explain a non-obvious reason, constraint, or workaround.
@@ -108,25 +74,6 @@ workspace member. `base::config::keys` re-exports the handful of keys
* 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.
@@ -141,7 +88,6 @@ Each file is a `HashMap<key, translation>`. Layout:
* `template.rs` is the master list of every key. **Never edit it** as part of translation work.
* `en.rs` holds only the keys whose English display text differs from the key itself.
* Every other file (`de.rs`, `fr.rs`, …) carries the full key set; an untranslated entry has an empty value: `("key", "")`.
* `it.rs` is maintained by hand by its translator. Never fill or change its entries; when adding new keys, append them to it with `""` and leave the translation to the maintainer.
### Finding the English source for a key
@@ -163,4 +109,4 @@ Then translate that source into the file's target language (infer the language f
* New English-text keys use sentence case, not Title Case: `Use ID whitelisting`, **not** `Use ID Whitelisting`. Acronyms (ID, IP, 2FA…) stay uppercase. Legacy Title-Case keys (e.g. `Use IP Whitelisting`) stay as-is — do not rename them.
* Since the key itself is the English display text, a sentence-case key usually needs **no** `en.rs` entry; add one only when the display text must differ from the key (e.g. `*_tip` keys).
* Append each new key to `template.rs` (with `""`) and to every `src/lang/*.rs` file (translated, or `""` if unsure; always `""` for `it.rs`), at the end of the list.
* Append each new key to `template.rs` (with `""`) and to every `src/lang/*.rs` file (translated, or `""` if unsure), at the end of the list.

348
Cargo.lock generated
View File

@@ -648,30 +648,6 @@ dependencies = [
"rustc-demangle",
]
[[package]]
name = "base"
version = "0.1.0"
dependencies = [
"anyhow",
"backtrace",
"bytes",
"filetime",
"hbb_common",
"lazy_static",
"libc",
"log",
"osascript",
"protobuf",
"protobuf-codegen",
"serde 1.0.228",
"serde_derive",
"serde_json 1.0.118",
"smithay-client-toolkit 0.20.0",
"tokio",
"users",
"winapi 0.3.9",
]
[[package]]
name = "base16ct"
version = "0.2.0"
@@ -777,6 +753,24 @@ 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"
@@ -985,9 +979,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.11.1"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
dependencies = [
"serde 1.0.228",
]
@@ -1167,6 +1161,30 @@ 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"
@@ -1216,6 +1234,7 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
"zeroize",
]
[[package]]
@@ -1275,7 +1294,6 @@ checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
name = "clipboard"
version = "0.1.0"
dependencies = [
"base",
"cacao",
"cc",
"dashmap 5.5.3",
@@ -1717,7 +1735,7 @@ dependencies = [
[[package]]
name = "cpal"
version = "0.15.3"
source = "git+https://github.com/rustdesk-org/cpal?branch=osx-screencapturekit#69ad2578adc9200093fc81cdfbdad63dbc4274f9"
source = "git+https://github.com/rustdesk-org/cpal?branch=osx-screencapturekit#6b374bcaed076750ca8fce6da518ab39b882e14a"
dependencies = [
"alsa",
"cidre",
@@ -1791,9 +1809,9 @@ dependencies = [
[[package]]
name = "crossbeam-epoch"
version = "0.9.20"
version = "0.9.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
dependencies = [
"crossbeam-utils",
]
@@ -2306,7 +2324,7 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412"
dependencies = [
"libloading 0.7.4",
"libloading 0.8.4",
]
[[package]]
@@ -2424,6 +2442,42 @@ 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"
@@ -2502,7 +2556,6 @@ dependencies = [
name = "enigo"
version = "0.0.14"
dependencies = [
"base",
"core-graphics 0.22.3",
"hbb_common",
"libxdo-sys",
@@ -2810,7 +2863,7 @@ dependencies = [
"is-terminal",
"lazy_static",
"log",
"nu-ansi-term",
"nu-ansi-term 0.49.0",
"regex",
"thiserror 1.0.61",
]
@@ -3690,6 +3743,7 @@ version = "0.1.0"
dependencies = [
"anyhow",
"async-recursion",
"backtrace",
"base64 0.22.1",
"bytes",
"chrono",
@@ -3700,6 +3754,7 @@ dependencies = [
"dirs-next",
"dlopen",
"env_logger 0.11.6",
"filetime",
"flexi_logger",
"futures",
"futures-util",
@@ -3710,7 +3765,7 @@ dependencies = [
"log",
"mac_address",
"machine-uid",
"percent-encoding",
"osascript",
"protobuf",
"protobuf-codegen",
"rand 0.8.5",
@@ -3722,6 +3777,7 @@ dependencies = [
"serde_derive",
"serde_json 1.0.118",
"sha2",
"smithay-client-toolkit 0.20.0",
"socket2 0.3.19",
"sodiumoxide",
"sysinfo",
@@ -3740,6 +3796,7 @@ dependencies = [
"webpki-roots 1.0.9",
"webrtc",
"whoami",
"winapi 0.3.9",
"x11 2.21.0",
"zstd",
]
@@ -4120,15 +4177,16 @@ dependencies = [
[[package]]
name = "interceptor"
version = "0.14.0"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ac0781c825d602095113772e389ef0607afcb869ae0e68a590d8e0799cdcef8"
checksum = "ea51375727680dc15f06e8ad90fa31df75d79dd030100e8ad60eef1c27fe2c98"
dependencies = [
"async-trait",
"bytes",
"futures",
"log",
"portable-atomic",
"rand 0.8.5",
"rand 0.9.2",
"rtcp",
"rtp",
"thiserror 1.0.61",
@@ -4280,11 +4338,11 @@ dependencies = [
[[package]]
name = "kcp-sys"
version = "0.1.0"
source = "git+https://github.com/rustdesk-org/kcp-sys?branch=rustdesk-patches#938eda3e5e9757a612385503af7a6cb1189b2cdd"
source = "git+https://github.com/rustdesk-org/kcp-sys#32a6c09fc6223f54aea83981a6aa8995931d29be"
dependencies = [
"anyhow",
"auto_impl",
"bindgen 0.72.1",
"bindgen 0.71.1",
"bitflags 2.9.1",
"bytes",
"cc",
@@ -4295,6 +4353,8 @@ dependencies = [
"thiserror 2.0.17",
"tokio",
"tokio-util",
"tracing",
"tracing-subscriber",
"zerocopy 0.7.34",
]
@@ -4428,7 +4488,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e310b3a6b5907f99202fcdb4960ff45b93735d7c7d96b760fcff8db2dc0e103d"
dependencies = [
"cfg-if 1.0.0",
"windows-targets 0.48.5",
"windows-targets 0.52.6",
]
[[package]]
@@ -5150,6 +5210,16 @@ 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"
@@ -5813,6 +5883,12 @@ 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"
@@ -6201,6 +6277,17 @@ 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"
@@ -6496,9 +6583,9 @@ dependencies = [
[[package]]
name = "quinn-proto"
version = "0.11.15"
version = "0.11.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e"
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
dependencies = [
"bytes",
"getrandom 0.3.2",
@@ -7007,9 +7094,9 @@ dependencies = [
[[package]]
name = "rtcp"
version = "0.13.0"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9689528bf3a9eb311fd938d05516dd546412f9ce4fffc8acfc1db27cc3dbf72"
checksum = "81d30d1c4091644431c22acf9f8be6191b56805e0e977f15ca7104b4a6d6eaec"
dependencies = [
"bytes",
"thiserror 1.0.61",
@@ -7018,14 +7105,14 @@ dependencies = [
[[package]]
name = "rtp"
version = "0.13.0"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c54733451a67d76caf9caa07a7a2cec6871ea9dda92a7847f98063d459200f4b"
checksum = "2f126f38ea84c02480e32e547c1459a939052f74fb92117ac3eef23fdac6b023"
dependencies = [
"bytes",
"memchr",
"portable-atomic",
"rand 0.8.5",
"rand 0.9.2",
"serde 1.0.228",
"thiserror 1.0.61",
"webrtc-util",
@@ -7097,7 +7184,6 @@ dependencies = [
"arboard",
"async-process",
"async-trait",
"base",
"bytemuck",
"bytes",
"cc",
@@ -7139,7 +7225,6 @@ dependencies = [
"lazy_static",
"libpulse-binding",
"libpulse-simple-binding",
"libsamplerate-sys",
"libxdo-sys",
"mac_address",
"magnum-opus",
@@ -7182,7 +7267,6 @@ dependencies = [
"terminfo",
"termios 0.3.3",
"tiny-skia",
"tokio",
"totp-rs",
"tray-icon",
"ttf-parser",
@@ -7419,7 +7503,6 @@ name = "scrap"
version = "0.5.0"
dependencies = [
"android_logger",
"base",
"bindgen 0.72.1",
"block",
"cfg-if 1.0.0",
@@ -7464,11 +7547,11 @@ dependencies = [
[[package]]
name = "sdp"
version = "0.8.0"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cd277015eada44a0bb810a4b84d3bf6e810573fa62fb442f457edf6a1087a69"
checksum = "32c374dceda16965d541c8800ce9cc4e1c14acfd661ddf7952feeedc3411e5c6"
dependencies = [
"rand 0.8.5",
"rand 0.9.2",
"substring",
"thiserror 1.0.61",
"url",
@@ -7698,6 +7781,15 @@ 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"
@@ -8037,15 +8129,15 @@ dependencies = [
[[package]]
name = "stun"
version = "0.8.0"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dbc2bab375524093c143dc362a03fb6a1fb79e938391cdb21665688f88a088a"
checksum = "1a512c5d501e3e3b5a4bb3e8e31462d56d54a66b95a28b8596e14422bf21c32b"
dependencies = [
"base64 0.22.1",
"crc",
"lazy_static",
"md-5",
"rand 0.8.5",
"rand 0.9.2",
"ring",
"subtle",
"thiserror 1.0.61",
@@ -8427,6 +8519,16 @@ 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"
@@ -8806,6 +8908,32 @@ 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]]
@@ -8920,9 +9048,9 @@ dependencies = [
[[package]]
name = "turn"
version = "0.10.0"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f5aea1116456e1da71c45586b87c72e3b43164fbf435eb93ff6aa475416a9a4"
checksum = "5ed995882f66ab94238de77c62e5e778389698ab700afa4696f4754da8f457cb"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -8930,7 +9058,7 @@ dependencies = [
"log",
"md-5",
"portable-atomic",
"rand 0.8.5",
"rand 0.9.2",
"ring",
"stun",
"thiserror 1.0.61",
@@ -9056,6 +9184,12 @@ 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"
@@ -9201,6 +9335,12 @@ 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"
@@ -9584,26 +9724,25 @@ dependencies = [
[[package]]
name = "webrtc"
version = "0.13.0"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24bab7195998d605c862772f90a452ba655b90a2f463c850ac032038890e367a"
checksum = "08fd686c0920ac08f3a57eacc48e31f0e4ca1ffefba4478784606f78c14e83ad"
dependencies = [
"arc-swap",
"async-trait",
"bytes",
"cfg-if 1.0.0",
"dtls",
"hex",
"interceptor",
"lazy_static",
"log",
"portable-atomic",
"rand 0.8.5",
"rand 0.9.2",
"rcgen",
"regex",
"ring",
"rtcp",
"rtp",
"rustls",
"sdp",
"serde 1.0.228",
"serde_json 1.0.118",
@@ -9611,13 +9750,12 @@ 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",
@@ -9628,9 +9766,9 @@ dependencies = [
[[package]]
name = "webrtc-data"
version = "0.11.0"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e97b932854da633a767eff0cc805425a2222fc6481e96f463e57b015d949d1d"
checksum = "062a5438d63bb0756a221693d76cc0dd6119affee1dfdfe57abe3a2a8c8b3eea"
dependencies = [
"bytes",
"log",
@@ -9641,55 +9779,18 @@ dependencies = [
"webrtc-util",
]
[[package]]
name = "webrtc-dtls"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
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"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb51bde0d790f109a15bfe4d04f1b56fb51d567da231643cb3f21bb74d678997"
checksum = "69cb13fd1a373e68addc4bba0c8ca058627518e54342583d024bdcbb8ae5d97d"
dependencies = [
"arc-swap",
"async-trait",
"crc",
"log",
"portable-atomic",
"rand 0.8.5",
"rand 0.9.2",
"serde 1.0.228",
"serde_json 1.0.118",
"stun",
@@ -9705,9 +9806,9 @@ dependencies = [
[[package]]
name = "webrtc-mdns"
version = "0.9.0"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "979cc85259c53b7b620803509d10d35e2546fa505d228850cbe3f08765ea6ea8"
checksum = "a17279a067e75df72ce923fdeb7f04cd808f6f5aa4910dc6bcb4fbe66b396ace"
dependencies = [
"log",
"socket2 0.5.10",
@@ -9718,21 +9819,22 @@ dependencies = [
[[package]]
name = "webrtc-media"
version = "0.10.0"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80041211deccda758a3e19aa93d6b10bc1d37c9183b519054b40a83691d13810"
checksum = "94a84c910fec0848fd5a0d8a5651e0ddbdedaf25a7d3ae3f0b15f71ac73a1773"
dependencies = [
"byteorder",
"bytes",
"rand 0.8.5",
"rand 0.9.2",
"rtp",
"thiserror 1.0.61",
]
[[package]]
name = "webrtc-sctp"
version = "0.12.0"
source = "git+https://github.com/rustdesk-org/webrtc?rev=db3b07a9dd8f195916c89c2e62a8911402b11d27#db3b07a9dd8f195916c89c2e62a8911402b11d27"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f985465467d8910c1f8ac4382cd64f83b1f6a1a75021a82b221546f6fb3b856f"
dependencies = [
"arc-swap",
"async-trait",
@@ -9740,7 +9842,7 @@ dependencies = [
"crc",
"log",
"portable-atomic",
"rand 0.8.5",
"rand 0.9.2",
"thiserror 1.0.61",
"tokio",
"webrtc-util",
@@ -9748,9 +9850,9 @@ dependencies = [
[[package]]
name = "webrtc-srtp"
version = "0.15.0"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01e773f79b09b057ffbda6b03fe7b43403b012a240cf8d05d630674c3723b5bb"
checksum = "66d8cdc33413f1d0192670a80ce93d17cb78d57fe3a2414be30d6f6dff121123"
dependencies = [
"aead",
"aes",
@@ -9771,19 +9873,19 @@ dependencies = [
[[package]]
name = "webrtc-util"
version = "0.11.0"
source = "git+https://github.com/rustdesk-org/webrtc?rev=db3b07a9dd8f195916c89c2e62a8911402b11d27#db3b07a9dd8f195916c89c2e62a8911402b11d27"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1c0c7e0c8f280f2bbfae442701465777ac07adaf46ce0c5863cd58e13fe472a"
dependencies = [
"async-trait",
"bitflags 1.3.2",
"bytes",
"ipnet",
"lazy_static",
"libc",
"log",
"nix 0.26.4",
"portable-atomic",
"rand 0.8.5",
"rand 0.9.2",
"thiserror 1.0.61",
"tokio",
"winapi 0.3.9",

View File

@@ -22,7 +22,7 @@ path = "src/service.rs"
[features]
inline = []
use_samplerate = ["samplerate", "libsamplerate-sys"]
use_samplerate = ["samplerate"]
use_rubato = ["rubato"]
use_dasp = ["dasp"]
flutter = ["flutter_rust_bridge"]
@@ -52,8 +52,7 @@ screencapturekit = ["cpal/screencapturekit"]
[dependencies]
async-trait = "0.1"
scrap = { path = "libs/scrap", features = ["wayland"] }
hbb_common = { path = "libs/hbb_common", features = ["webrtc"] }
base = { path = "libs/base" }
hbb_common = { path = "libs/hbb_common" }
serde_derive = "1.0"
serde = "1.0"
serde_json = "1.0"
@@ -67,7 +66,6 @@ magnum-opus = { git = "https://github.com/rustdesk-org/magnum-opus" }
dasp = { version = "0.11", features = ["signal", "interpolate-linear", "interpolate"], optional = true }
rubato = { version = "0.12", optional = true }
samplerate = { version = "0.2", optional = true }
libsamplerate-sys = { version = "0.1.12", optional = true }
uuid = { version = "1.3", features = ["v4"] }
num_cpus = "1.15"
bytes = { version = "1.4", features = ["serde"] }
@@ -85,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", branch = "rustdesk-patches" }
kcp-sys= { git = "https://github.com/rustdesk-org/kcp-sys"}
reqwest = { version = "0.12", features = ["blocking", "socks", "json", "native-tls", "rustls-tls", "rustls-tls-native-roots", "gzip", "zstd"], default-features=false }
[target.'cfg(not(target_os = "linux"))'.dependencies]
@@ -210,29 +208,13 @@ jni = "0.21"
android-wakelock = { git = "https://github.com/rustdesk-org/android-wakelock" }
[workspace]
members = ["libs/scrap", "libs/hbb_common", "libs/base", "libs/enigo", "libs/clipboard", "libs/virtual_display", "libs/virtual_display/dylib", "libs/portable", "libs/remote_printer"]
members = ["libs/scrap", "libs/hbb_common", "libs/enigo", "libs/clipboard", "libs/virtual_display", "libs/virtual_display/dylib", "libs/portable", "libs/remote_printer"]
exclude = ["vdi/host"]
# Patch libxdo-sys to use a stub implementation that doesn't require libxdo
# This allows building and running on systems without libxdo installed (e.g., Wayland-only)
[patch.crates-io]
libxdo-sys = { path = "libs/libxdo-sys-stub" }
# 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.
# Sending that way, a reordering window keeps a chunk that is merely late from being resent on a
# path that jitters, every DATA chunk asks for its SACK at once so a lost tail is back within an
# RTT at KCP's RTO floors, and bundles of small chunks stay within the MTU. A T3-rtx resends
# everything outstanding when it packs into four packets and otherwise probes with one and lets
# the SACK settle the rest (F-RTO), timed from the latest send, so a stall no longer resends the
# whole backlog behind itself while a short lost tail still comes back at once.
# 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 = "db3b07a9dd8f195916c89c2e62a8911402b11d27" }
webrtc-sctp = { git = "https://github.com/rustdesk-org/webrtc", rev = "db3b07a9dd8f195916c89c2e62a8911402b11d27" }
[package.metadata.winres]
LegalCopyright = "Copyright © 2026 Purslane Tech Pte. Ltd. All rights reserved."
@@ -252,7 +234,6 @@ os-version = "0.2"
[dev-dependencies]
hound = "3.5"
docopt = "1.1"
tokio = { version = "1.44", features = ["test-util"] }
[package.metadata.bundle]
name = "RustDesk"

View File

@@ -158,8 +158,7 @@ Please ensure that you run these commands from the root of the RustDesk reposito
## File Structure
- **[libs/hbb_common](https://github.com/rustdesk/rustdesk/tree/master/libs/hbb_common)**: video codec, config, tcp/udp wrapper, and some other utility functions shared with the server
- **[libs/base](https://github.com/rustdesk/rustdesk/tree/master/libs/base)**: protobuf, fs functions for file transfer, keyboard and platform code used only by this app
- **[libs/hbb_common](https://github.com/rustdesk/rustdesk/tree/master/libs/hbb_common)**: video codec, config, tcp/udp wrapper, protobuf, fs functions for file transfer, and some other utility functions
- **[libs/scrap](https://github.com/rustdesk/rustdesk/tree/master/libs/scrap)**: screen capture
- **[libs/enigo](https://github.com/rustdesk/rustdesk/tree/master/libs/enigo)**: platform specific keyboard/mouse control
- **[libs/clipboard](https://github.com/rustdesk/rustdesk/tree/master/libs/clipboard)**: file copy and paste implementation for Windows, Linux, macOS.

View File

@@ -43,15 +43,6 @@ 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" {
@@ -98,8 +89,5 @@ 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");
}

View File

@@ -1,11 +0,0 @@
Aplicación de escritorio remoto de código abierto, la alternativa open source a TeamViewer.
Código fuente: https://github.com/rustdesk/rustdesk
Documentación: https://rustdesk.com/docs/en/manual/mobile/
Para que un dispositivo remoto controle tu Android mediante el ratón o el tacto, debes permitir que RustDesk utilice el servicio de "Accesibilidad". RustDesk utiliza la API AccessibilityService para implementar el control remoto en Android.
Además del control remoto, también puedes transferir archivos fácilmente entre dispositivos Android y ordenadores mediante RustDesk.
Tienes control total de tus datos, sin preocupaciones de seguridad. Puedes utilizar nuestro servidor rendezvous/relay, optar por el autoalojamiento o escribir tu propio servidor rendezvous/relay. El servidor autoalojado es gratuito y de código abierto: https://github.com/rustdesk/rustdesk-server
Descarga e instala la versión de escritorio desde: https://rustdesk.com — entonces podrás acceder y controlar tu ordenador desde tu teléfono, o controlar tu teléfono desde tu ordenador.

View File

@@ -1 +0,0 @@
Aplicación de acceso remoto de código abierto, alternativa a TeamViewer.

View File

@@ -1,11 +0,0 @@
Aplicativo de desktop remoto de código aberto, a alternativa open source ao TeamViewer.
Código-fonte: https://github.com/rustdesk/rustdesk
Documentação: https://rustdesk.com/docs/pt/client/android/
Para que um dispositivo remoto controle seu Android via mouse ou toque, você precisa permitir que o RustDesk utilize o serviço de "Acessibilidade". O RustDesk usa a API AccessibilityService para implementar o controle remoto no Android.
Além do controle remoto, você também pode transferir arquivos entre dispositivos Android e PCs com facilidade usando o RustDesk.
Você tem controle total dos seus dados, sem preocupações com a segurança. Você pode usar nosso servidor rendezvous/relay, optar pela auto-hospedagem ou criar seu próprio servidor de rendezvous/relay. O servidor auto-hospedado é gratuito e open source: https://github.com/rustdesk/rustdesk-server
Baixe e instale a versão para desktop em: https://rustdesk.com — então você poderá acessar e controlar seu computador pelo celular ou controlar seu celular pelo computador.

View File

@@ -1 +0,0 @@
Aplicativo de acesso remoto open source, alternativa ao TeamViewer.

View File

@@ -87,7 +87,7 @@ android {
sourceSets {
main.java.srcDirs += 'src/main/kotlin'
main.proto.srcDirs += '../../../libs/base/protos'
main.proto.srcDirs += '../../../libs/hbb_common/protos'
main.proto.includes += "message.proto"
}

View File

@@ -1633,8 +1633,7 @@ String bool2option(String option, bool b) {
String res;
if (option.startsWith('enable-') &&
option != kOptionEnableUdpPunch &&
option != kOptionEnableIpv6Punch &&
option != kOptionEnableWebrtc) {
option != kOptionEnableIpv6Punch) {
res = b ? defaultOptionYes : 'N';
} else if (option.startsWith('allow-') ||
option == kOptionStopService ||

View File

@@ -606,9 +606,6 @@ class QualityMonitor extends StatelessWidget {
_row(
"Codec", qualityMonitorModel.data.codecFormat ?? '-'),
_row("Chroma", qualityMonitorModel.data.chroma ?? '-'),
if (qualityMonitorModel.webrtcTransport != null)
_row("Transport",
qualityMonitorModel.webrtcTransport!),
],
),
)

View File

@@ -244,38 +244,11 @@ List<(String, String)> otherDefaultSettings() {
kKeyUseAllMyDisplaysForTheRemoteSession
),
('Keep terminal sessions on disconnect', kOptionTerminalPersistent),
(
'Allow terminal apps to copy to clipboard',
kOptionAllowTerminalClipboardWrite
),
];
return v;
}
String getOtherDefaultSettingOption(String key) {
if (key == kOptionAllowTerminalClipboardWrite) {
return bind.mainGetLocalOption(key: key);
}
return bind.mainGetUserDefaultOption(key: key);
}
Future<void> setOtherDefaultSettingOption(String key, String value) {
if (key == kOptionAllowTerminalClipboardWrite) {
return bind.mainSetLocalOption(
key: key,
value: value == kTerminalClipboardWriteAllowed
? kTerminalClipboardWriteAllowed
: kTerminalClipboardWriteDenied,
);
}
return bind.mainSetUserDefaultOption(key: key, value: value);
}
bool isOtherDefaultSettingReadOnly(String key) =>
isOptionFixed(key) ||
(key == kOptionAllowTerminalClipboardWrite && bind.isDisableSettings());
class TrackpadSpeedWidget extends StatefulWidget {
final SimpleWrapper<int> value;
// If null, no debouncer will be applied.

View File

@@ -115,11 +115,6 @@ const String kOptionEnableAudio = "enable-audio";
const String kOptionEnableCamera = "enable-camera";
const String kOptionEnableTerminal = "enable-terminal";
const String kOptionTerminalPersistent = "terminal-persistent";
const String kOptionAllowTerminalClipboardWrite =
"allow-terminal-clipboard-write";
const String kTerminalClipboardWriteUnconfigured = "";
const String kTerminalClipboardWriteAllowed = "Y";
const String kTerminalClipboardWriteDenied = "N";
const String kOptionEnableTunnel = "enable-tunnel";
const String kOptionEnableRemoteRestart = "enable-remote-restart";
const String kOptionEnableBlockInput = "enable-block-input";
@@ -164,7 +159,6 @@ const String kOptionPeerTabVisible = "peer-tab-visible";
const String kOptionPeerCardUiType = "peer-card-ui-type";
const String kOptionCurrentAbName = "current-ab-name";
const String kOptionEnableConfirmClosingTabs = "enable-confirm-closing-tabs";
const String kOptionEnablePortForwardMux = "enable-port-forward-mux";
const String kOptionAllowAlwaysSoftwareRender = "allow-always-software-render";
const String kOptionEnableCheckUpdate = "enable-check-update";
const String kOptionAllowAutoUpdate = "allow-auto-update";
@@ -172,12 +166,10 @@ 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";

View File

@@ -330,14 +330,12 @@ class _ConnectionPageState extends State<ConnectionPage>
void onConnect(
{bool isFileTransfer = false,
bool isViewCamera = false,
bool isTerminal = false,
bool isTcpTunneling = false}) {
bool isTerminal = false}) {
var id = _idController.id;
connect(context, id,
isFileTransfer: isFileTransfer,
isViewCamera: isViewCamera,
isTerminal: isTerminal,
isTcpTunneling: isTcpTunneling);
isTerminal: isTerminal);
}
/// UI for the remote ID TextField.
@@ -570,14 +568,6 @@ class _ConnectionPageState extends State<ConnectionPage>
'${translate('Terminal')} (beta)',
() => onConnect(isTerminal: true)
),
// `connect` routes this through the
// desktop path only; the peer card gates
// it the same way.
if (isDesktop)
(
'TCP tunneling',
() => onConnect(isTcpTunneling: true)
),
]
.map((e) => MenuEntryButton<String>(
childBuilder: (TextStyle? style) =>

View File

@@ -509,15 +509,6 @@ class _GeneralState extends State<_General> {
kOptionOpenNewConnInTabs,
isServer: false,
),
Tooltip(
message: translate('port-forward-mux-tip'),
child: _OptionCheckBox(
context,
'Reuse one connection for port forwarding',
kOptionEnablePortForwardMux,
isServer: false,
),
),
// though this is related to GUI, but opengl problem affects all users, so put in config rather than local
if (isLinux)
Tooltip(
@@ -572,12 +563,6 @@ class _GeneralState extends State<_General> {
kOptionDirectxCapture,
),
if (!isWeb && !incomingOnly) ...[
_OptionCheckBox(
context,
'Enable TCP hole punching',
kOptionEnableTcpPunch,
isServer: false,
),
_OptionCheckBox(
context,
'Enable UDP hole punching',
@@ -590,15 +575,6 @@ class _GeneralState extends State<_General> {
kOptionEnableIpv6Punch,
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(
@@ -608,6 +584,7 @@ class _GeneralState extends State<_General> {
isServer: false,
),
),
],
];
// Add client-side wakelock option for desktop platforms
@@ -2103,13 +2080,14 @@ class _DisplayState extends State<_Display> {
}
Widget otherRow(String label, String key) {
final value = getOtherDefaultSettingOption(key) == 'Y';
final isOptFixed = isOtherDefaultSettingReadOnly(key);
final value = bind.mainGetUserDefaultOption(key: key) == 'Y';
final isOptFixed = isOptionFixed(key);
onChanged(bool b) async {
await setOtherDefaultSettingOption(
key,
b ? 'Y' : (key == kOptionEnableFileCopyPaste ? 'N' : defaultOptionNo),
);
await bind.mainSetUserDefaultOption(
key: key,
value: b
? 'Y'
: (key == kOptionEnableFileCopyPaste ? 'N' : defaultOptionNo));
setState(() {});
}

View File

@@ -19,8 +19,6 @@ class TerminalPage extends StatefulWidget {
required this.tabKey,
this.forceRelay,
this.connToken,
this.onClipboardWriteBlocked,
this.onClipboardWriteSucceeded,
}) : super(key: key);
final String id;
final String? password;
@@ -28,8 +26,6 @@ class TerminalPage extends StatefulWidget {
final bool? forceRelay;
final bool? isSharedPassword;
final String? connToken;
final ValueChanged<String>? onClipboardWriteBlocked;
final ValueChanged<String>? onClipboardWriteSucceeded;
final int terminalId;
/// Tab key for focus management, passed from parent to avoid duplicate construction
@@ -75,8 +71,6 @@ class _TerminalPageState extends State<TerminalPage>
// Create terminal model with specific terminal ID
_terminalModel = TerminalModel(_ffi, widget.terminalId);
_terminalModel.onClipboardWriteBlocked = widget.onClipboardWriteBlocked;
_terminalModel.onClipboardWriteSucceeded = widget.onClipboardWriteSucceeded;
debugPrint(
'[TerminalPage] Terminal model created for terminal ${widget.terminalId}');

View File

@@ -1,4 +1,3 @@
import 'dart:async';
import 'dart:convert';
import 'package:desktop_multi_window/desktop_multi_window.dart';
@@ -11,8 +10,6 @@ import 'package:flutter_hbb/models/state_model.dart';
import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
import 'package:flutter_hbb/utils/multi_window_manager.dart';
import 'package:flutter_hbb/models/model.dart';
import 'package:flutter_hbb/models/terminal_copy_shortcut.dart';
import 'package:flutter_hbb/models/terminal_model.dart';
import 'package:get/get.dart';
import '../../models/platform_model.dart';
@@ -22,12 +19,6 @@ import '../widgets/material_mod_popup_menu.dart' as mod_menu;
import '../widgets/popup_menu.dart';
import 'package:bot_toast/bot_toast.dart';
typedef _TerminalClipboardSource = ({
String peerId,
int terminalId,
String tabKey,
});
class TerminalTabPage extends StatefulWidget {
final Map<String, dynamic> params;
@@ -39,18 +30,6 @@ class TerminalTabPage extends StatefulWidget {
class _TerminalTabPageState extends State<TerminalTabPage> {
DesktopTabController get tabController => Get.find<DesktopTabController>();
bool get _canConfigureTerminalClipboardPermission =>
canConfigureTerminalClipboardPermission(
settingsDisabled: bind.isDisableSettings(),
optionFixed: isOptionFixed(kOptionAllowTerminalClipboardWrite),
);
bool get _canHandleTerminalClipboardWriteRequest =>
canHandleTerminalClipboardWriteRequest(
localOption: bind.mainGetLocalOption(
key: kOptionAllowTerminalClipboardWrite,
),
canConfigurePermission: _canConfigureTerminalClipboardPermission,
);
static const IconData selectedIcon = Icons.terminal;
static const IconData unselectedIcon = Icons.terminal_outlined;
@@ -59,9 +38,6 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
final Set<String> _closingTabs = {};
// When true, all session cleanup should persist (window-level close in progress)
bool _windowClosing = false;
CancelFunc? _terminalClipboardNoticeCancel;
final _terminalClipboardNotice =
TerminalClipboardNoticeCoordinator<_TerminalClipboardSource>();
_TerminalTabPageState(Map<String, dynamic> params) {
Get.put(DesktopTabController(tabType: DesktopTabType.terminal));
@@ -69,10 +45,7 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
WindowController.fromWindowId(windowId())
.setTitle(getWindowNameWithId(id));
};
tabController.onRemoved = (_, id) {
_closeTerminalClipboardNoticeForTab(id);
onRemoveId(id);
};
tabController.onRemoved = (_, id) => onRemoveId(id);
tabController.onCloseWindow = _closeWindowFromConnection;
final terminalId = params['terminalId'] ?? _nextTerminalId++;
tabController.add(_createTerminalTab(
@@ -97,11 +70,6 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
final alias = bind.mainGetPeerOptionSync(id: peerId, key: 'alias');
final tabLabel =
alias.isNotEmpty ? '$alias #$terminalId' : '$peerId #$terminalId';
final clipboardSource = (
peerId: peerId,
terminalId: terminalId,
tabKey: tabKey,
);
return TabInfo(
key: tabKey,
label: tabLabel,
@@ -118,169 +86,10 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
tabController: tabController,
forceRelay: forceRelay,
connToken: connToken,
onClipboardWriteBlocked: _canHandleTerminalClipboardWriteRequest
? (text) => _handleTerminalClipboardWriteBlocked(
clipboardSource,
text,
)
: null,
onClipboardWriteSucceeded: (_) {
_handleTerminalClipboardWriteSucceeded(clipboardSource);
},
),
);
}
void _handleTerminalClipboardWriteBlocked(
_TerminalClipboardSource source,
String clipboardText,
) {
if (!mounted) return;
final option = bind.mainGetLocalOption(
key: kOptionAllowTerminalClipboardWrite,
);
final request = _terminalClipboardNotice.recordBlocked(
source: source,
text: clipboardText,
option: option,
canWrite: _canWriteTerminalClipboard,
);
if (request != null) _showTerminalClipboardNotice(request);
}
void _showTerminalClipboardNotice(
TerminalClipboardNoticeRequest<_TerminalClipboardSource> request,
) {
_terminalClipboardNoticeCancel = BotToast.showCustomNotification(
duration: null,
enableSlideOff: false,
onlyOne: true,
onClose: _handleTerminalClipboardNoticeClosed,
toastBuilder: (_) => AnimatedBuilder(
animation: _terminalClipboardNotice,
builder: (_, __) => MaterialBanner(
leading: const Icon(Icons.content_copy_outlined),
content: Text(translate(kTerminalClipboardNoticeMessageKey)),
actions: [
TextButton(
onPressed: _terminalClipboardNotice.canClaimAction
? _handleTerminalClipboardNegativeAction
: null,
child: Text(translate(request.negativeActionKey)),
),
TextButton(
onPressed: _terminalClipboardNotice.canClaimAction
? _handleTerminalClipboardPositiveAction
: null,
child: Text(translate(request.actionKey)),
),
],
),
),
);
}
void _handleTerminalClipboardNegativeAction() {
final request = _terminalClipboardNotice.claimCurrentAction();
if (request == null) return;
if (request.persistAllowed) {
unawaited(_declineTerminalClipboardWrite());
} else {
_closeTerminalClipboardNotice();
}
}
void _handleTerminalClipboardPositiveAction() {
final request = _terminalClipboardNotice.claimCurrentAction();
if (request == null) return;
unawaited(_completeTerminalClipboardWrite(request));
}
void _handleTerminalClipboardNoticeClosed() {
_terminalClipboardNoticeCancel = null;
_terminalClipboardNotice.noticeClosed();
}
bool _canWriteTerminalClipboard(
_TerminalClipboardSource source,
) {
if (!_canHandleTerminalClipboardWriteRequest) return false;
final ffi = TerminalConnectionManager.getExistingConnection(source.peerId);
return ffi != null &&
!ffi.closed &&
ffi.ffiModel.permissions['clipboard'] != false &&
tabController.state.value.tabs.any((tab) => tab.key == source.tabKey) &&
ffi.terminalModels.containsKey(source.terminalId);
}
void _handleTerminalClipboardWriteSucceeded(
_TerminalClipboardSource source,
) {
final request = _terminalClipboardNotice.currentForSource(source);
if (request == null) return;
_closeTerminalClipboardNotice();
}
Future<void> _declineTerminalClipboardWrite() async {
try {
await bind.mainSetLocalOption(
key: kOptionAllowTerminalClipboardWrite,
value: kTerminalClipboardWriteDenied,
);
} catch (error) {
debugPrint(
'[TerminalTabPage] Failed to save terminal clipboard permission: $error');
return;
} finally {
_terminalClipboardNotice.releaseAction();
}
_closeTerminalClipboardNotice();
}
Future<void> _completeTerminalClipboardWrite(
TerminalClipboardNoticeRequest<_TerminalClipboardSource> request,
) async {
final source = request.source;
var completed = false;
try {
completed = await completeTerminalClipboardWrite(
clipboardText: request.text,
canWrite: () => _canWriteTerminalClipboard(source),
writeClipboard: writeTerminalClipboard,
persistAllowed: request.persistAllowed
? () => bind.mainSetLocalOption(
key: kOptionAllowTerminalClipboardWrite,
value: kTerminalClipboardWriteAllowed,
)
: null,
);
} catch (error) {
debugPrint(
'[TerminalTabPage] Failed to complete terminal clipboard write: $error');
} finally {
_terminalClipboardNotice.releaseAction();
}
if (!completed) return;
_closeTerminalClipboardNotice();
}
void _closeTerminalClipboardNoticeForTab(String tabKey) {
final current = _terminalClipboardNotice.current;
if (current?.source.tabKey != tabKey) return;
_closeTerminalClipboardNotice();
}
void _closeTerminalClipboardNotice() {
if (!_terminalClipboardNotice.beginClose()) return;
final cancel = _terminalClipboardNoticeCancel;
if (cancel == null) {
debugPrint('[TerminalTabPage] Clipboard notice controller is missing');
_terminalClipboardNotice.noticeClosed();
return;
}
cancel();
}
/// Unified tab close handler for all close paths (button, shortcut, programmatic).
/// Shows audit dialog, cleans up session if not persistent, then removes the UI tab.
Future<void> _closeTab(String tabKey) async {
@@ -338,8 +147,6 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
// Remove all UI tabs immediately (same instant behavior as the old tabController.clear())
// Keep the cleanup target lookup below synchronous before its first await:
// it relies on the current frame still retaining each TerminalPage's FFI/model.
_terminalClipboardNotice.clear();
_terminalClipboardNoticeCancel?.call();
tabController.clear();
// Run session cleanup in parallel with bounded timeout (closeTerminal() has internal 3s timeout).
// Skip tabs already being closed by a concurrent _closeTab() to avoid duplicate FFI calls.
@@ -550,8 +357,6 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
@override
void dispose() {
HardwareKeyboard.instance.removeHandler(_handleKeyEvent);
_terminalClipboardNotice.clear();
_terminalClipboardNoticeCancel?.call();
super.dispose();
}

View File

@@ -97,12 +97,10 @@ 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;
@@ -143,10 +141,8 @@ 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 =
@@ -819,65 +815,31 @@ 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: isOptionFixed(kOptionEnableUdpPunch)
? null
: (v) async {
await mainSetLocalBoolOption(kOptionEnableUdpPunch, v);
final newValue =
mainGetLocalBoolOptionSync(kOptionEnableUdpPunch);
setState(() {
_enableUdpPunch = newValue;
});
},
onToggle: (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: 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;
});
},
onToggle: (v) async {
await mainSetLocalBoolOption(kOptionEnableIpv6Punch, v);
final newValue =
mainGetLocalBoolOptionSync(kOptionEnableIpv6Punch);
setState(() {
_enableIpv6Punch = newValue;
});
},
),
SettingsTile(
title: Text(translate('Language')),
@@ -1307,18 +1269,16 @@ class __DisplayPageState extends State<_DisplayPage> {
}
SettingsTile otherRow(String label, String key) {
final value = getOtherDefaultSettingOption(key) == 'Y';
final isOptFixed = isOtherDefaultSettingReadOnly(key);
final value = bind.mainGetUserDefaultOption(key: key) == 'Y';
final isOptFixed = isOptionFixed(key);
return SettingsTile.switchTile(
initialValue: value,
title: Text(translate(label)),
onToggle: isOptFixed
? null
: (b) async {
await setOtherDefaultSettingOption(
key,
b ? 'Y' : defaultOptionNo,
);
await bind.mainSetUserDefaultOption(
key: key, value: b ? 'Y' : defaultOptionNo);
setState(() {});
},
);

View File

@@ -11,7 +11,6 @@ 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/models/terminal_mouse_handler.dart';
import 'package:flutter_hbb/mobile/terminal_keyboard_utils.dart';
import 'package:flutter_hbb/web/dummy.dart'
if (dart.library.html) 'package:flutter_hbb/web/terminal_font.dart';
@@ -20,49 +19,6 @@ import 'package:xterm/xterm.dart';
import '../../desktop/pages/terminal_connection_manager.dart';
import '../../consts.dart';
const _terminalBackgroundOpacity = 0.7;
Widget _buildTerminalViewForPlatform({
required bool reportMouseInput,
required bool reportTouchInput,
required Terminal terminal,
required TerminalController controller,
required TerminalStyle textStyle,
required EdgeInsets padding,
required bool deleteDetection,
required Map<ShortcutActivator, Intent>? shortcuts,
required FocusOnKeyEventCallback onKeyEvent,
required void Function(TapDownDetails, CellOffset) onSecondaryTapDown,
}) {
if (reportMouseInput || reportTouchInput) {
return TerminalMouseInteraction(
terminal,
controller: controller,
autofocus: true,
textStyle: textStyle,
deleteDetection: deleteDetection,
reportTouchInput: reportTouchInput,
shortcuts: shortcuts,
onKeyEvent: onKeyEvent,
backgroundOpacity: _terminalBackgroundOpacity,
padding: padding,
onSecondaryTapDown: onSecondaryTapDown,
);
}
return TerminalView(
terminal,
controller: controller,
autofocus: true,
textStyle: textStyle,
deleteDetection: deleteDetection,
shortcuts: shortcuts,
onKeyEvent: onKeyEvent,
backgroundOpacity: _terminalBackgroundOpacity,
padding: padding,
onSecondaryTapDown: onSecondaryTapDown,
);
}
class TerminalPage extends StatefulWidget {
const TerminalPage({
Key? key,
@@ -85,19 +41,6 @@ class TerminalPage extends StatefulWidget {
class _TerminalPageState extends State<TerminalPage>
with AutomaticKeepAliveClientMixin, WidgetsBindingObserver {
bool get _canConfigureTerminalClipboardPermission =>
canConfigureTerminalClipboardPermission(
settingsDisabled: bind.isDisableSettings(),
optionFixed: isOptionFixed(kOptionAllowTerminalClipboardWrite),
);
bool get _canHandleTerminalClipboardWriteRequest =>
canHandleTerminalClipboardWriteRequest(
localOption: bind.mainGetLocalOption(
key: kOptionAllowTerminalClipboardWrite,
),
canConfigurePermission: _canConfigureTerminalClipboardPermission,
);
late FFI _ffi;
late TerminalModel _terminalModel;
double? _cellHeight;
@@ -114,9 +57,6 @@ class _TerminalPageState extends State<TerminalPage>
// For iOS edge swipe gesture
double _swipeStartX = 0;
double _swipeCurrentX = 0;
ScaffoldFeatureController<MaterialBanner, MaterialBannerClosedReason>?
_terminalClipboardNoticeController;
final _terminalClipboardNotice = TerminalClipboardNoticeCoordinator<int>();
// For web only.
// 'monospace' does not work on web, use Google Fonts, `??` is only for null safety.
@@ -149,12 +89,6 @@ class _TerminalPageState extends State<TerminalPage>
// Create terminal model with specific terminal ID
_terminalModel = TerminalModel(_ffi, widget.terminalId);
if (_canHandleTerminalClipboardWriteRequest) {
_terminalModel.onClipboardWriteBlocked =
_handleTerminalClipboardWriteBlocked;
_terminalModel.onClipboardWriteSucceeded =
_handleTerminalClipboardWriteSucceeded;
}
debugPrint(
'[TerminalPage] Terminal model created for terminal ${widget.terminalId}');
@@ -200,144 +134,12 @@ class _TerminalPageState extends State<TerminalPage>
_ffi.ffiModel.updateEventListener(_ffi.sessionId, widget.id);
}
void _handleTerminalClipboardWriteBlocked(String clipboardText) {
if (!mounted) return;
final option = bind.mainGetLocalOption(
key: kOptionAllowTerminalClipboardWrite,
);
final request = _terminalClipboardNotice.recordBlocked(
source: widget.terminalId,
text: clipboardText,
option: option,
canWrite: (_) => _canWriteTerminalClipboard,
);
if (request != null) _showTerminalClipboardNotice(request);
}
void _showTerminalClipboardNotice(
TerminalClipboardNoticeRequest<int> request,
) {
final controller = ScaffoldMessenger.of(context).showMaterialBanner(
MaterialBanner(
leading: const Icon(Icons.content_copy_outlined),
content: Text(translate(kTerminalClipboardNoticeMessageKey)),
actions: [
AnimatedBuilder(
animation: _terminalClipboardNotice,
builder: (_, __) => TextButton(
onPressed: _terminalClipboardNotice.canClaimAction
? _handleTerminalClipboardNegativeAction
: null,
child: Text(translate(request.negativeActionKey)),
),
),
AnimatedBuilder(
animation: _terminalClipboardNotice,
builder: (_, __) => TextButton(
onPressed: _terminalClipboardNotice.canClaimAction
? _handleTerminalClipboardPositiveAction
: null,
child: Text(translate(request.actionKey)),
),
),
],
),
);
_terminalClipboardNoticeController = controller;
unawaited(controller.closed.then<void>((_) {
if (identical(_terminalClipboardNoticeController, controller)) {
_terminalClipboardNoticeController = null;
_terminalClipboardNotice.noticeClosed();
}
}));
}
void _handleTerminalClipboardNegativeAction() {
final request = _terminalClipboardNotice.claimCurrentAction();
if (request == null) return;
if (request.persistAllowed) {
unawaited(_declineTerminalClipboardWrite());
} else {
_closeTerminalClipboardNotice();
}
}
void _handleTerminalClipboardPositiveAction() {
final request = _terminalClipboardNotice.claimCurrentAction();
if (request == null) return;
unawaited(_completeTerminalClipboardWrite(request));
}
bool get _canWriteTerminalClipboard =>
_canHandleTerminalClipboardWriteRequest &&
!_ffi.closed &&
_ffi.ffiModel.permissions['clipboard'] != false;
void _handleTerminalClipboardWriteSucceeded(String _) {
_closeTerminalClipboardNotice();
}
Future<void> _declineTerminalClipboardWrite() async {
try {
await bind.mainSetLocalOption(
key: kOptionAllowTerminalClipboardWrite,
value: kTerminalClipboardWriteDenied,
);
} catch (error) {
debugPrint(
'[TerminalPage] Failed to save terminal clipboard permission: $error');
return;
} finally {
_terminalClipboardNotice.releaseAction();
}
_closeTerminalClipboardNotice();
}
Future<void> _completeTerminalClipboardWrite(
TerminalClipboardNoticeRequest<int> request,
) async {
var completed = false;
try {
completed = await completeTerminalClipboardWrite(
clipboardText: request.text,
canWrite: () => _canWriteTerminalClipboard,
writeClipboard: writeTerminalClipboard,
persistAllowed: request.persistAllowed
? () => bind.mainSetLocalOption(
key: kOptionAllowTerminalClipboardWrite,
value: kTerminalClipboardWriteAllowed,
)
: null,
);
} catch (error) {
debugPrint(
'[TerminalPage] Failed to complete terminal clipboard write: $error');
} finally {
_terminalClipboardNotice.releaseAction();
}
if (!completed) return;
_closeTerminalClipboardNotice();
}
void _closeTerminalClipboardNotice() {
if (!_terminalClipboardNotice.beginClose()) return;
final controller = _terminalClipboardNoticeController;
if (controller == null) {
debugPrint('[TerminalPage] Clipboard notice controller is missing');
_terminalClipboardNotice.noticeClosed();
return;
}
controller.close();
}
@override
void dispose() {
// Unregister terminal model from FFI
_ffi.unregisterTerminalModel(widget.terminalId);
_terminalModel.dispose();
_keyboardDebounce?.cancel();
_terminalClipboardNotice.clear();
_terminalClipboardNoticeController?.close();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
TerminalConnectionManager.releaseConnection(widget.id);
@@ -432,12 +234,12 @@ class _TerminalPageState extends State<TerminalPage>
child: LayoutBuilder(
builder: (context, constraints) {
final heightPx = constraints.maxHeight;
return _buildTerminalViewForPlatform(
reportMouseInput: isWebDesktop || isAndroid,
reportTouchInput: isIOS,
terminal: _terminalModel.terminal,
return TerminalView(
_terminalModel.terminal,
controller: _terminalModel.terminalController,
autofocus: true,
textStyle: _getTerminalStyle(),
backgroundOpacity: 0.7,
// The following comment is from xterm.dart source code:
// Workaround to detect delete key for platforms and IMEs that do not
// emit a hardware delete event. Preferred on mobile platforms. [false] by

View File

@@ -896,13 +896,9 @@ class FfiModel with ChangeNotifier {
final text = evt['text'];
final link = evt['link'];
// The peer-gone detector reconnects under `restarting-show` rather than an error title, so
// it needs naming here too. By its own title, not the type: an explicitly restarted remote
// device reaches the same type from a path this change does not touch.
if (isAndroid &&
_androidDocumentPickerActive &&
(title == 'Connection Error' ||
(type == 'restarting-show' && title == 'Connecting...'))) {
title == 'Connection Error') {
_androidDocumentPickerInterruptedConnection = true;
return;
}
@@ -3601,16 +3597,6 @@ 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') ==

View File

@@ -1,108 +1,7 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:xterm/xterm.dart';
enum TerminalClipboardWritePermission { denied, unconfigured, allowed }
class RustDeskTerminal extends Terminal {
RustDeskTerminal({
super.maxLines,
required TerminalClipboardWritePermission Function()
clipboardWritePermission,
required Future<bool> Function(String) onClipboardWrite,
ValueChanged<String>? onClipboardWriteBlocked,
ValueChanged<String>? onClipboardWriteSucceeded,
}) : _clipboardWritePermission = clipboardWritePermission,
_onClipboardWrite = onClipboardWrite,
_onClipboardWriteBlocked = onClipboardWriteBlocked,
_onClipboardWriteSucceeded = onClipboardWriteSucceeded {
onPrivateOSC = _handlePrivateOsc;
}
static const _clipboardOscCode = '52';
static const _systemClipboardSelection = 'c';
// Match the terminal helper's existing payload safety ceiling.
static const _maxClipboardWriteBytes = 16 * 1024 * 1024;
static const _base64InputBytesPerBlock = 3;
static const _base64EncodedCharsPerBlock = 4;
static final _osc52Selection = RegExp(r'^[cpqs0-7]*$');
final TerminalClipboardWritePermission Function() _clipboardWritePermission;
final Future<bool> Function(String) _onClipboardWrite;
final ValueChanged<String>? _onClipboardWriteBlocked;
final ValueChanged<String>? _onClipboardWriteSucceeded;
bool get isClipboardWriteAllowed =>
_clipboardWritePermission() == TerminalClipboardWritePermission.allowed;
void _handlePrivateOsc(String code, List<String> args) {
if (code != _clipboardOscCode) return;
if (args.length != 2 || !_osc52Selection.hasMatch(args.first)) {
debugPrint('[RustDeskTerminal] Rejected malformed OSC 52 command');
return;
}
if (args.last == '?') {
debugPrint('[RustDeskTerminal] Rejected OSC 52 clipboard query');
return;
}
final permission = _clipboardWritePermission();
if (permission == TerminalClipboardWritePermission.denied) {
debugPrint('[RustDeskTerminal] Rejected unauthorized OSC 52 write');
return;
}
final selection = args.first;
if (selection.isNotEmpty &&
!selection.contains(_systemClipboardSelection)) {
debugPrint('[RustDeskTerminal] Ignored unsupported OSC 52 selection');
return;
}
if (selection.replaceAll(_systemClipboardSelection, '').isNotEmpty) {
debugPrint('[RustDeskTerminal] Ignored unsupported OSC 52 selections');
}
final text = _decodeClipboardPayload(args.last);
if (text == null) return;
if (permission == TerminalClipboardWritePermission.unconfigured) {
debugPrint('[RustDeskTerminal] Blocked OSC 52 write pending consent');
_onClipboardWriteBlocked?.call(text);
return;
}
unawaited(_writeClipboard(text));
}
Future<void> _writeClipboard(String text) async {
final succeeded = await _onClipboardWrite(text);
if (succeeded) {
_onClipboardWriteSucceeded?.call(text);
return;
}
debugPrint(
'[RustDeskTerminal] OSC 52 clipboard write requires interaction');
_onClipboardWriteBlocked?.call(text);
}
String? _decodeClipboardPayload(String payload) {
if (payload.length > _maxBase64EncodedLength(_maxClipboardWriteBytes)) {
debugPrint('[RustDeskTerminal] Rejected oversized OSC 52 payload');
return null;
}
try {
final bytes = base64.decode(payload);
if (bytes.length > _maxClipboardWriteBytes) {
debugPrint('[RustDeskTerminal] Rejected oversized OSC 52 payload');
return null;
}
return utf8.decode(bytes);
} on FormatException {
debugPrint('[RustDeskTerminal] Rejected malformed OSC 52 payload');
return null;
}
}
static int _maxBase64EncodedLength(int maxBytes) =>
((maxBytes + _base64InputBytesPerBlock - 1) ~/
_base64InputBytesPerBlock) *
_base64EncodedCharsPerBlock;
RustDeskTerminal({super.maxLines});
@override
void eraseScrollbackOnly() {

View File

@@ -1,15 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
Future<bool> writeTerminalClipboardPlatform(
String text, {
bool userInitiated = false,
}) async {
try {
await Clipboard.setData(ClipboardData(text: text));
return true;
} catch (error) {
debugPrint('[Terminal] Failed to write clipboard: $error');
return false;
}
}

View File

@@ -1,29 +0,0 @@
import 'dart:js_interop';
import 'package:flutter/foundation.dart';
const _writeTerminalClipboardCommand = 'write_terminal_clipboard';
@JS('setByName')
external JSPromise<JSBoolean> _setByName(
JSString name,
JSString value,
JSBoolean userInitiated,
);
Future<bool> writeTerminalClipboardPlatform(
String text, {
bool userInitiated = false,
}) async {
try {
final result = await _setByName(
_writeTerminalClipboardCommand.toJS,
text.toJS,
userInitiated.toJS,
).toDart;
return result.toDart;
} catch (error) {
debugPrint('[Terminal] Failed to write Web clipboard: $error');
return false;
}
}

View File

@@ -3,130 +3,20 @@ import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_hbb/consts.dart';
import 'package:xterm/xterm.dart';
import 'terminal_clipboard_writer.dart'
if (dart.library.html) 'terminal_clipboard_writer_web.dart';
const _controlShiftVPasteShortcut = SingleActivator(
LogicalKeyboardKey.keyV,
control: true,
shift: true,
);
typedef TerminalClipboardWriter = Future<bool> Function(
String text, {
required bool userInitiated,
});
class TerminalClipboardNoticeRequest<T> {
const TerminalClipboardNoticeRequest({
required this.source,
required this.text,
required this.persistAllowed,
});
final T source;
final String text;
final bool persistAllowed;
String get actionKey => persistAllowed ? 'Enable' : 'Copy to clipboard';
String get negativeActionKey => persistAllowed ? 'Decline' : 'Dismiss';
}
const kTerminalClipboardNoticeMessageKey = 'terminal-clipboard-write-tip';
class TerminalClipboardNoticeCoordinator<T> extends ChangeNotifier {
TerminalClipboardNoticeRequest<T>? _current;
bool _noticeVisible = false;
bool _actionInProgress = false;
TerminalClipboardNoticeRequest<T>? get current => _current;
bool get canClaimAction =>
_noticeVisible && !_actionInProgress && _current != null;
TerminalClipboardNoticeRequest<T>? currentForSource(T source) {
final current = _current;
if (current == null || current.source != source) return null;
return current;
Future<void> writeTerminalClipboard(String text) async {
try {
await Clipboard.setData(ClipboardData(text: text));
} catch (error) {
debugPrint('[Terminal] Failed to write clipboard: $error');
}
TerminalClipboardNoticeRequest<T>? recordBlocked({
required T source,
required String text,
required String option,
required bool Function(T source) canWrite,
}) {
if (!canWrite(source)) return null;
final requestAllowsPersistence =
option == kTerminalClipboardWriteUnconfigured;
if (option != kTerminalClipboardWriteAllowed && !requestAllowsPersistence) {
return null;
}
if (_noticeVisible && _actionInProgress) return null;
final wasVisible = _noticeVisible;
final persistAllowed =
wasVisible ? _current?.persistAllowed : requestAllowsPersistence;
final request = TerminalClipboardNoticeRequest(
source: source,
text: text,
persistAllowed: persistAllowed ?? requestAllowsPersistence,
);
_current = request;
if (wasVisible) return null;
_noticeVisible = true;
return request;
}
TerminalClipboardNoticeRequest<T>? claimCurrentAction() {
if (!canClaimAction) return null;
final current = _current;
if (current == null) return null;
_actionInProgress = true;
notifyListeners();
return current;
}
void releaseAction() {
if (!_actionInProgress) return;
_actionInProgress = false;
notifyListeners();
}
bool beginClose() {
if (!_noticeVisible) return false;
_actionInProgress = true;
notifyListeners();
return true;
}
void noticeClosed() => clear();
void clear() {
_current = null;
_noticeVisible = false;
_actionInProgress = false;
}
}
Future<bool> writeTerminalClipboard(
String text, {
bool userInitiated = false,
}) =>
writeTerminalClipboardPlatform(text, userInitiated: userInitiated);
Future<bool> completeTerminalClipboardWrite({
required String clipboardText,
required bool Function() canWrite,
required TerminalClipboardWriter writeClipboard,
Future<void> Function()? persistAllowed,
}) async {
if (!canWrite()) return false;
if (!await writeClipboard(clipboardText, userInitiated: true)) return false;
await persistAllowed?.call();
return true;
}
Map<ShortcutActivator, Intent>? platformTerminalShortcuts() {
@@ -178,7 +68,7 @@ FocusOnKeyEventCallback terminalCopyHandler(
if (selection != null && !selection.isCollapsed) {
if (event is KeyDownEvent) {
final text = terminal.buffer.getText(selection);
unawaited(writeTerminalClipboard(text, userInitiated: true));
unawaited(writeTerminalClipboard(text));
}
return KeyEventResult.handled;
}

View File

@@ -11,38 +11,8 @@ import 'input_modifier_utils.dart';
import 'model.dart';
import 'platform_model.dart';
import 'rustdesk_terminal.dart';
import 'terminal_copy_shortcut.dart';
import 'terminal_mouse_handler.dart';
bool canConfigureTerminalClipboardPermission({
required bool settingsDisabled,
required bool optionFixed,
}) =>
!settingsDisabled && !optionFixed;
bool canHandleTerminalClipboardWriteRequest({
required String localOption,
required bool canConfigurePermission,
}) =>
canConfigurePermission || localOption == kTerminalClipboardWriteAllowed;
TerminalClipboardWritePermission terminalClipboardWritePermission(
String localOption, {
required bool remoteClipboardEnabled,
bool canRequestConsent = true,
}) {
if (!remoteClipboardEnabled) {
return TerminalClipboardWritePermission.denied;
}
if (localOption == kTerminalClipboardWriteAllowed) {
return TerminalClipboardWritePermission.allowed;
}
if (localOption == kTerminalClipboardWriteUnconfigured && canRequestConsent) {
return TerminalClipboardWritePermission.unconfigured;
}
return TerminalClipboardWritePermission.denied;
}
class TerminalModel with ChangeNotifier {
final String id; // peer id
final FFI parent;
@@ -92,9 +62,6 @@ class TerminalModel with ChangeNotifier {
/// The listener (typically TerminalPage) can use this to auto-close the tab/page.
VoidCallback? onClosed;
ValueChanged<String>? onClipboardWriteBlocked;
ValueChanged<String>? onClipboardWriteSucceeded;
Future<void> _handleInput(String data) async {
// xterm can complete asynchronous input after the Flutter page has gone
// away. Stop before reading or clearing widget-owned modifier state.
@@ -163,19 +130,7 @@ class TerminalModel with ChangeNotifier {
}
TerminalModel(this.parent, [this.terminalId = 0]) : id = parent.id {
terminal = RustDeskTerminal(
maxLines: 10000,
onClipboardWrite: writeTerminalClipboard,
clipboardWritePermission: () => terminalClipboardWritePermission(
bind.mainGetLocalOption(key: kOptionAllowTerminalClipboardWrite),
remoteClipboardEnabled:
parent.ffiModel.permissions['clipboard'] != false,
canRequestConsent: onClipboardWriteBlocked != null,
),
onClipboardWriteBlocked: (text) => onClipboardWriteBlocked?.call(text),
onClipboardWriteSucceeded: (text) =>
onClipboardWriteSucceeded?.call(text),
);
terminal = RustDeskTerminal(maxLines: 10000);
terminal.mouseHandler = const WheelButtonFixMouseHandler();
terminalController = TerminalController();
@@ -638,8 +593,6 @@ class TerminalModel with ChangeNotifier {
clearAltLock = null;
onResizeExternal = null;
onClosed = null;
onClipboardWriteBlocked = null;
onClipboardWriteSucceeded = null;
// Clear buffers to free memory
_inputBuffer.clear();
_pendingOutputChunks.clear();

View File

@@ -62,17 +62,13 @@ class TerminalMouseDragReporter {
var _ownsControllerSuspension = false;
var _releasePending = false;
var _reporting = false;
var _dragged = false;
bool handleDown(
PointerDownEvent event,
Terminal terminal,
TerminalViewState? terminalView, {
bool reportTouchInput = false,
bool deferReport = false,
}) {
if (!_isPrimaryPointer(event, reportTouchInput) ||
!_reportsDrag(terminal.mouseMode)) {
TerminalViewState? terminalView,
) {
if (!_isPrimaryMouse(event) || !_reportsDrag(terminal.mouseMode)) {
return false;
}
if (terminalView == null || terminalView.widget.readOnly) return false;
@@ -87,33 +83,14 @@ class TerminalMouseDragReporter {
_pointerId = event.pointer;
_controller = controller;
_ownsControllerSuspension = true;
_releasePending = !deferReport;
_reporting = !deferReport;
_dragged = false;
_releasePending = true;
_reporting = true;
controller.setSuspendPointerInput(true);
_clearSelection(controller);
final position = _cellAt(event, terminalView);
_lastReportedPosition = position;
if (!deferReport) {
terminal.textInput(
_report(terminal.mouseReportMode, position),
);
}
return true;
}
bool activateDeferredDown(Terminal terminal) {
if (_pointerId == null ||
_controller == null ||
_releasePending ||
!_reportsDrag(terminal.mouseMode)) {
return false;
}
_releasePending = true;
_reporting = true;
_clearSelection(_controller);
terminal.textInput(
_report(terminal.mouseReportMode, _lastReportedPosition),
_report(terminal.mouseReportMode, position),
);
return true;
}
@@ -121,36 +98,26 @@ class TerminalMouseDragReporter {
bool handleMove(
PointerMoveEvent event,
Terminal terminal,
TerminalViewState? terminalView, {
void Function(bool dragged)? beforeRelease,
void Function()? onCancel,
}) {
TerminalViewState? terminalView,
) {
if (event.pointer != _pointerId) return false;
if (terminalView == null) {
onCancel?.call();
cancel();
return true;
}
final reportsDrag = _reportsDrag(terminal.mouseMode);
if (!_hasPrimaryButton(event)) {
if (!_isPrimaryMouse(event)) {
if (_releasePending && reportsDrag) {
_finishRelease(
event,
_reportRelease(
terminal,
terminalView,
beforeRelease: beforeRelease,
_reporting ? _cellAt(event, terminalView) : _lastReportedPosition,
);
} else {
onCancel?.call();
}
cancel();
return true;
}
if (!_reporting || !reportsDrag) {
if (!reportsDrag && _releasePending) {
_releasePending = false;
onCancel?.call();
}
if (!reportsDrag) _releasePending = false;
_reporting = false;
// Keep ownership until the matching end event to suppress local selection.
final controller = _controller;
@@ -159,7 +126,7 @@ class TerminalMouseDragReporter {
}
final position = _cellAt(event, terminalView);
_recordPosition(position);
_lastReportedPosition = position;
terminal.textInput(
_report(terminal.mouseReportMode, position, motion: true),
);
@@ -171,22 +138,16 @@ class TerminalMouseDragReporter {
bool handleEnd(
PointerEvent event,
Terminal terminal,
TerminalViewState? terminalView, {
void Function(bool dragged)? beforeRelease,
void Function()? onCancel,
}) {
TerminalViewState? terminalView,
) {
if (event.pointer != _pointerId) return false;
if (terminalView != null &&
_releasePending &&
_reportsDrag(terminal.mouseMode)) {
_finishRelease(
event,
_reportRelease(
terminal,
terminalView,
beforeRelease: beforeRelease,
_reporting ? _cellAt(event, terminalView) : _lastReportedPosition,
);
} else {
onCancel?.call();
}
_clearSelection(_controller);
final controller = _controller;
@@ -211,7 +172,6 @@ class TerminalMouseDragReporter {
_ownsControllerSuspension = false;
_releasePending = false;
_reporting = false;
_dragged = false;
}
void updateController(TerminalController controller) {
@@ -243,24 +203,6 @@ class TerminalMouseDragReporter {
);
}
void _finishRelease(
PointerEvent event,
Terminal terminal,
TerminalViewState terminalView, {
void Function(bool dragged)? beforeRelease,
}) {
final position =
_reporting ? _cellAt(event, terminalView) : _lastReportedPosition;
if (_reporting) _recordPosition(position);
beforeRelease?.call(_dragged);
_reportRelease(terminal, position);
}
void _recordPosition(CellOffset position) {
_dragged = _dragged || position != _lastReportedPosition;
_lastReportedPosition = position;
}
CellOffset _cellAt(PointerEvent event, TerminalViewState terminalView) {
final renderTerminal = terminalView.renderTerminal;
return renderTerminal.getCellOffset(
@@ -268,13 +210,9 @@ class TerminalMouseDragReporter {
);
}
bool _isPrimaryPointer(PointerEvent event, bool reportTouchInput) =>
(event.kind == PointerDeviceKind.mouse ||
reportTouchInput && event.kind == PointerDeviceKind.touch) &&
_hasPrimaryButton(event);
bool _hasPrimaryButton(PointerEvent event) =>
(event.buttons & kPrimaryButton) == kPrimaryButton;
bool _isPrimaryMouse(PointerEvent event) =>
event.kind == PointerDeviceKind.mouse &&
(event.buttons & kPrimaryMouseButton) == kPrimaryMouseButton;
bool _reportsDrag(MouseMode mode) =>
mode == MouseMode.upDownScrollDrag || mode == MouseMode.upDownScrollMove;

View File

@@ -1,17 +1,45 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/widgets.dart';
import 'package:xterm/xterm.dart';
import 'platform_model.dart';
import 'rustdesk_terminal.dart';
import 'terminal_copy_shortcut.dart';
import 'terminal_mouse_drag_reporter.dart';
part 'terminal_mouse_handler_input.dart';
part 'terminal_web_clipboard_gesture.dart';
/// xterm 4.0.0 encodes wheel buttons as 68..71; the extra bit reads as a Shift
/// modifier, so strict full-screen apps ignore the report and never scroll.
/// Upstream fix: TerminalStudio/xterm.dart#238.
class WheelButtonFixMouseHandler implements TerminalMouseHandler {
const WheelButtonFixMouseHandler({this.positionProvider});
final CellOffset? Function()? positionProvider;
@override
String? call(TerminalMouseEvent event) {
if (!event.button.isWheel) {
return defaultMouseHandler(event);
}
// Same gate as UpDownMouseHandler: only the scroll modes report a wheel,
// and a wheel release is never reported, so the report is always a press.
if (!event.state.mouseMode.reportScroll ||
event.buttonState == TerminalMouseButtonState.up) {
return null;
}
return _reportWheel(event);
}
String _reportWheel(TerminalMouseEvent event) {
// Wheel buttons 4..7 go on the wire as 64..67, but `id` is 64 + 4..7.
final button = event.button.id - 4;
final position = positionProvider?.call() ?? event.position;
return encodeTerminalMouseReport(
event.state.mouseReportMode,
button,
position,
);
}
}
class TerminalMouseInteraction extends StatefulWidget {
const TerminalMouseInteraction(
@@ -19,12 +47,6 @@ class TerminalMouseInteraction extends StatefulWidget {
super.key,
required this.controller,
this.focusNode,
this.autofocus = false,
this.textStyle = const TerminalStyle(),
this.deleteDetection = false,
this.reportTouchInput = false,
this.shortcuts,
this.onKeyEvent,
this.backgroundOpacity = 1,
this.padding,
this.onSecondaryTapDown,
@@ -33,12 +55,6 @@ class TerminalMouseInteraction extends StatefulWidget {
final Terminal terminal;
final TerminalController controller;
final FocusNode? focusNode;
final bool autofocus;
final TerminalStyle textStyle;
final bool deleteDetection;
final bool reportTouchInput;
final Map<ShortcutActivator, Intent>? shortcuts;
final FocusOnKeyEventCallback? onKeyEvent;
final double backgroundOpacity;
final EdgeInsets? padding;
final void Function(TapDownDetails, CellOffset)? onSecondaryTapDown;
@@ -65,13 +81,8 @@ class _TerminalMouseInteractionState extends State<TerminalMouseInteraction> {
Buffer? _selectionBuffer;
int? _selectionPointerId;
Timer? _selectionScrollTimer;
Timer? _pendingTouchMouseTimer;
PointerDownEvent? _pendingTouchMouseDown;
var _selectionHasScrolled = false;
var _scrollDirection = _noScroll;
// xterm can finish its tap callbacks after the raw drag was reported.
var _suppressXtermLeftButton = false;
var _terminalClipboardGesturePrepared = false;
TerminalViewState? get _terminalView => _terminalViewKey.currentState;
@override
@@ -79,7 +90,6 @@ class _TerminalMouseInteractionState extends State<TerminalMouseInteraction> {
super.initState();
_mouseHandler = WheelButtonFixMouseHandler(
positionProvider: _cellAtPointer,
suppressLeftButton: kIsWeb ? _consumeXtermLeftButtonSuppression : null,
);
_installMouseHandler(widget.terminal);
}
@@ -90,15 +100,10 @@ class _TerminalMouseInteractionState extends State<TerminalMouseInteraction> {
final terminalChanged = !identical(oldWidget.terminal, widget.terminal);
final controllerChanged =
!identical(oldWidget.controller, widget.controller);
final touchInputChanged =
oldWidget.reportTouchInput != widget.reportTouchInput;
if (!terminalChanged && !controllerChanged && !touchInputChanged) return;
_cancelPendingTouchMouseDrag();
if (!terminalChanged && !controllerChanged) return;
if (controllerChanged && !terminalChanged) {
_mouseDrag.updateController(widget.controller);
} else {
_discardPendingTerminalClipboardWrites();
_mouseDrag.cancel();
}
_clearSelectionDrag();
@@ -118,18 +123,46 @@ class _TerminalMouseInteractionState extends State<TerminalMouseInteraction> {
}
}
void _handlePointerMove(PointerMoveEvent event) {
CellOffset? _cellAtPointer() {
final terminalView = _terminalView;
final pointerPosition = _pointerPosition;
if (terminalView == null || pointerPosition == null) return null;
final renderTerminal = terminalView.renderTerminal;
return renderTerminal.getCellOffset(
renderTerminal.globalToLocal(pointerPosition),
);
}
void _updatePointerPosition(PointerEvent event) =>
_pointerPosition = event.position;
void _handlePointerDown(PointerDownEvent event) {
_updatePointerPosition(event);
if (_handlePendingTouchMove(event)) return;
if (_mouseDrag.handleMove(
event,
widget.terminal,
_terminalView,
beforeRelease: _finishTerminalClipboardWrite,
onCancel: _cancelTerminalClipboardWrite,
)) {
if (_mouseDrag.handleDown(event, widget.terminal, _terminalView)) {
_clearSelectionDrag();
return;
}
if (event.kind != PointerDeviceKind.mouse ||
(event.buttons & kPrimaryMouseButton) != kPrimaryMouseButton) {
return;
}
_clearSelectionDrag();
final terminalView = _terminalView;
if (terminalView == null) return;
final renderTerminal = terminalView.renderTerminal;
final localPosition = renderTerminal.globalToLocal(event.position);
final selectionBuffer = widget.terminal.buffer;
_selectionPointerId = event.pointer;
_selectionBase = selectionBuffer.createAnchorFromOffset(
renderTerminal.getCellOffset(localPosition),
);
_selectionBuffer = selectionBuffer;
_selectionPointer = localPosition;
}
void _handlePointerMove(PointerMoveEvent event) {
_updatePointerPosition(event);
if (_mouseDrag.handleMove(event, widget.terminal, _terminalView)) return;
if (event.pointer != _selectionPointerId) return;
if (event.kind != PointerDeviceKind.mouse ||
(event.buttons & kPrimaryMouseButton) != kPrimaryMouseButton) {
@@ -208,28 +241,8 @@ class _TerminalMouseInteractionState extends State<TerminalMouseInteraction> {
void _handlePointerEnd(PointerEvent event) {
_updatePointerPosition(event);
final pendingTouch = _pendingTouchMouseDown;
if (pendingTouch != null && pendingTouch.pointer == event.pointer) {
final movedBeyondSlop =
(event.position - pendingTouch.position).distance > kTouchSlop;
if (event is PointerUpEvent && !movedBeyondSlop) {
_activatePendingTouchMouseDrag(cancelOnFailure: false);
} else {
_takePendingTouchMouseDrag(pointer: event.pointer);
}
}
final handledByMouseDrag = _mouseDrag.handleEnd(
event,
widget.terminal,
_terminalView,
beforeRelease: event is PointerUpEvent
? _finishTerminalClipboardWrite
: (_) => _cancelTerminalClipboardWrite(),
onCancel: _cancelTerminalClipboardWrite,
);
if (!handledByMouseDrag && event.pointer != _selectionPointerId) {
return;
}
if (!_mouseDrag.handleEnd(event, widget.terminal, _terminalView) &&
event.pointer != _selectionPointerId) return;
if (_selectionHasScrolled) _scrollSelection(scroll: false);
_clearSelectionDrag();
}
@@ -252,8 +265,6 @@ class _TerminalMouseInteractionState extends State<TerminalMouseInteraction> {
@override
void dispose() {
_discardPendingTerminalClipboardWrites();
_cancelPendingTouchMouseDrag();
_mouseDrag.cancel();
_clearSelectionDrag();
_restoreMouseHandler(widget.terminal);
@@ -279,14 +290,10 @@ class _TerminalMouseInteractionState extends State<TerminalMouseInteraction> {
controller: widget.controller,
scrollController: _scrollController,
focusNode: widget.focusNode,
autofocus: widget.autofocus,
textStyle: widget.textStyle,
deleteDetection: widget.deleteDetection,
backgroundOpacity: widget.backgroundOpacity,
padding: widget.padding,
shortcuts: widget.shortcuts ?? platformTerminalShortcuts(),
onKeyEvent: widget.onKeyEvent ??
terminalCopyHandler(widget.terminal, widget.controller),
shortcuts: platformTerminalShortcuts(),
onKeyEvent: terminalCopyHandler(widget.terminal, widget.controller),
onSecondaryTapDown: widget.onSecondaryTapDown,
),
);

View File

@@ -1,162 +0,0 @@
part of 'terminal_mouse_handler.dart';
/// xterm 4.0.0 encodes wheel buttons as 68..71; the extra bit reads as a Shift
/// modifier, so strict full-screen apps ignore the report and never scroll.
/// Upstream fix: TerminalStudio/xterm.dart#238.
class WheelButtonFixMouseHandler implements TerminalMouseHandler {
const WheelButtonFixMouseHandler({
this.positionProvider,
this.suppressLeftButton,
});
final CellOffset? Function()? positionProvider;
final bool Function(TerminalMouseButtonState)? suppressLeftButton;
@override
String? call(TerminalMouseEvent event) {
if (!event.button.isWheel) {
if (event.button == TerminalMouseButton.left &&
suppressLeftButton?.call(event.buttonState) == true) {
return null;
}
return defaultMouseHandler(event);
}
// Same gate as UpDownMouseHandler: only the scroll modes report a wheel,
// and a wheel release is never reported, so the report is always a press.
if (!event.state.mouseMode.reportScroll ||
event.buttonState == TerminalMouseButtonState.up) {
return null;
}
return _reportWheel(event);
}
String _reportWheel(TerminalMouseEvent event) {
// Wheel buttons 4..7 go on the wire as 64..67, but `id` is 64 + 4..7.
final button = event.button.id - 4;
final position = positionProvider?.call() ?? event.position;
return encodeTerminalMouseReport(
event.state.mouseReportMode,
button,
position,
);
}
}
extension _TerminalMouseInput on _TerminalMouseInteractionState {
CellOffset? _cellAtPointer() {
final terminalView = _terminalView;
final pointerPosition = _pointerPosition;
if (terminalView == null || pointerPosition == null) return null;
final renderTerminal = terminalView.renderTerminal;
return renderTerminal.getCellOffset(
renderTerminal.globalToLocal(pointerPosition),
);
}
void _updatePointerPosition(PointerEvent event) =>
_pointerPosition = event.position;
void _handlePointerDown(PointerDownEvent event) {
_updatePointerPosition(event);
_suppressXtermLeftButton = false;
if (_startPendingTouchMouseDrag(event)) return;
if (_mouseDrag.handleDown(event, widget.terminal, _terminalView)) {
_prepareTerminalClipboardWrite();
if (kIsWeb) _suppressXtermLeftButton = true;
_clearSelectionDrag();
return;
}
if (event.kind != PointerDeviceKind.mouse ||
(event.buttons & kPrimaryMouseButton) != kPrimaryMouseButton) {
return;
}
_clearSelectionDrag();
final terminalView = _terminalView;
if (terminalView == null) return;
final renderTerminal = terminalView.renderTerminal;
final localPosition = renderTerminal.globalToLocal(event.position);
final selectionBuffer = widget.terminal.buffer;
_selectionPointerId = event.pointer;
_selectionBase = selectionBuffer.createAnchorFromOffset(
renderTerminal.getCellOffset(localPosition),
);
_selectionBuffer = selectionBuffer;
_selectionPointer = localPosition;
}
bool _startPendingTouchMouseDrag(PointerDownEvent event) {
if (!widget.reportTouchInput ||
event.kind != PointerDeviceKind.touch ||
!_mouseDrag.handleDown(
event,
widget.terminal,
_terminalView,
reportTouchInput: true,
deferReport: true,
)) {
return false;
}
_pendingTouchMouseDown = event;
_pendingTouchMouseTimer = Timer(
kLongPressTimeout,
_activatePendingTouchMouseDrag,
);
return true;
}
bool _activatePendingTouchMouseDrag({
bool cancelOnFailure = true,
}) {
if (_takePendingTouchMouseDrag() == null) return false;
if (_mouseDrag.activateDeferredDown(widget.terminal)) {
_prepareTerminalClipboardWrite();
_clearSelectionDrag();
return true;
}
if (cancelOnFailure) _mouseDrag.cancel();
return false;
}
PointerDownEvent? _takePendingTouchMouseDrag({int? pointer}) {
final pending = _pendingTouchMouseDown;
if (pending == null || pointer != null && pointer != pending.pointer) {
return null;
}
_pendingTouchMouseTimer?.cancel();
_pendingTouchMouseTimer = null;
_pendingTouchMouseDown = null;
return pending;
}
void _cancelPendingTouchMouseDrag({
int? pointer,
bool deferCancel = false,
}) {
if (_takePendingTouchMouseDrag(pointer: pointer) == null) return;
if (deferCancel) {
scheduleMicrotask(_mouseDrag.cancel);
} else {
_mouseDrag.cancel();
}
}
bool _handlePendingTouchMove(PointerMoveEvent event) {
final pending = _pendingTouchMouseDown;
if (pending == null || pending.pointer != event.pointer) return false;
if ((event.position - pending.position).distance > kTouchSlop) {
_cancelPendingTouchMouseDrag(
pointer: event.pointer,
deferCancel: true,
);
}
return true;
}
bool _consumeXtermLeftButtonSuppression(TerminalMouseButtonState state) {
final suppress = _suppressXtermLeftButton;
if (state == TerminalMouseButtonState.up) {
_suppressXtermLeftButton = false;
}
return suppress;
}
}

View File

@@ -1,56 +0,0 @@
part of 'terminal_mouse_handler.dart';
const _prepareTerminalClipboardCommand = 'prepare_terminal_clipboard';
const _finishTerminalClipboardCommand = 'finish_terminal_clipboard';
const _cancelTerminalClipboardCommand = 'cancel_terminal_clipboard';
extension _TerminalWebClipboardGesture on _TerminalMouseInteractionState {
void _prepareTerminalClipboardWrite() {
if (!kIsWeb) return;
_cancelTerminalClipboardWrite();
final terminal = widget.terminal;
if (terminal is! RustDeskTerminal || !terminal.isClipboardWriteAllowed) {
return;
}
try {
ffiSetByName(_prepareTerminalClipboardCommand);
_terminalClipboardGesturePrepared = true;
} catch (error) {
debugPrint('[Terminal] Failed to prepare Web clipboard write: $error');
}
}
void _finishTerminalClipboardWrite(bool responseExpected) {
if (!_terminalClipboardGesturePrepared) return;
_terminalClipboardGesturePrepared = false;
if (!kIsWeb) return;
try {
ffiSetByName(
_finishTerminalClipboardCommand,
responseExpected ? 'true' : 'false',
);
} catch (error) {
debugPrint('[Terminal] Failed to finish Web clipboard write: $error');
}
}
void _cancelTerminalClipboardWrite() {
if (!_terminalClipboardGesturePrepared) return;
_terminalClipboardGesturePrepared = false;
_sendTerminalClipboardCancel();
}
void _discardPendingTerminalClipboardWrites() {
_cancelTerminalClipboardWrite();
_sendTerminalClipboardCancel();
}
void _sendTerminalClipboardCancel() {
if (!kIsWeb) return;
try {
ffiSetByName(_cancelTerminalClipboardCommand);
} catch (error) {
debugPrint('[Terminal] Failed to cancel Web clipboard write: $error');
}
}
}

View File

@@ -306,7 +306,7 @@ packages:
dependency: "direct main"
description:
path: "."
ref: bd6b5b41254e57c5bcece202ebfb234de63e6487
ref: HEAD
resolved-ref: bd6b5b41254e57c5bcece202ebfb234de63e6487
url: "https://github.com/rustdesk-org/Dash-Chat-2"
source: git
@@ -339,8 +339,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: "8b774a66671cbb9bcb2631af6ac28f9bdd469ce3"
resolved-ref: "8b774a66671cbb9bcb2631af6ac28f9bdd469ce3"
ref: HEAD
resolved-ref: 8b774a66671cbb9bcb2631af6ac28f9bdd469ce3
url: "https://github.com/rustdesk-org/rustdesk_desktop_multi_window"
source: git
version: "0.1.0"
@@ -1581,7 +1581,7 @@ packages:
dependency: "direct main"
description:
path: "."
ref: cf4aef0512092fad9344a27ffe1c47ad83269dfc
ref: HEAD
resolved-ref: cf4aef0512092fad9344a27ffe1c47ad83269dfc
url: "https://github.com/rustdesk-org/window_manager"
source: git

View File

@@ -40,7 +40,6 @@ dependencies:
dash_chat_2:
git:
url: https://github.com/rustdesk-org/Dash-Chat-2
ref: bd6b5b41254e57c5bcece202ebfb234de63e6487
draggable_float_widget: ^0.1.0
settings_ui: ^2.0.2
flutter_breadcrumb: ^1.0.1
@@ -54,11 +53,9 @@ dependencies:
window_manager:
git:
url: https://github.com/rustdesk-org/window_manager
ref: cf4aef0512092fad9344a27ffe1c47ad83269dfc
desktop_multi_window:
git:
url: https://github.com/rustdesk-org/rustdesk_desktop_multi_window
ref: 8b774a66671cbb9bcb2631af6ac28f9bdd469ce3
freezed_annotation: ^2.0.3
flutter_custom_cursor:
git:

View File

@@ -1,57 +0,0 @@
[package]
name = "base"
version = "0.1.0"
authors = ["rustdesk <info@rustdesk.com>"]
edition = "2018"
# Code that only RustDesk itself uses. `hbb_common` stays the crate shared with
# the server, so anything the server never touches belongs here instead.
[features]
default = []
# The isolated Wayland socket-probe fallback (src/platform/linux/wayland_probe.rs).
# Off by default so the base Wayland enumeration is untouched; the DRM login-screen
# build (scrap/drm) turns it on.
wayland_probe = []
[dependencies]
hbb_common = { path = "../hbb_common" }
protobuf = { version = "3.7", features = ["with-bytes"] }
# the generated protobuf code refers to `::bytes::Bytes` (tokio_bytes codegen)
bytes = { version = "1.10", features = ["serde"] }
tokio = { version = "1.44", features = ["full"] }
serde_derive = "1.0"
serde = "1.0"
serde_json = "1.0"
filetime = "0.2"
libc = "0.2"
backtrace = "0.3"
log = "0.4"
lazy_static = "1.5"
anyhow = "1.0"
[build-dependencies]
protobuf-codegen = { version = "3.7" }
[target.'cfg(target_os = "windows")'.dependencies]
# Every module the moved sources name, spelled out rather than left to feature
# unification with the root crate.
winapi = { version = "0.3", features = [
"fileapi",
"handleapi",
"minwindef",
"pdh",
"synchapi",
"sysinfoapi",
"winbase",
"winnt",
] }
[target.'cfg(target_os = "macos")'.dependencies]
osascript = "0.3"
[target.'cfg(target_os = "linux")'.dependencies]
sctk = { package = "smithay-client-toolkit", version = "0.20.0", default-features = false, features = [
"calloop",
] }
users = { version = "0.11" }

View File

@@ -1,14 +0,0 @@
fn main() {
let out_dir = format!("{}/protos", std::env::var("OUT_DIR").unwrap());
std::fs::create_dir_all(&out_dir).unwrap();
protobuf_codegen::Codegen::new()
.pure()
.out_dir(out_dir)
.inputs(["protos/message.proto"])
.include("protos")
.customize(protobuf_codegen::Customize::default().tokio_bytes(true))
.run()
.expect("Codegen failed.");
}

View File

@@ -1,20 +0,0 @@
extern crate base;
#[cfg(target_os = "linux")]
use base::platform::linux;
#[cfg(target_os = "macos")]
use base::platform::macos;
fn main() {
#[cfg(target_os = "linux")]
let res = linux::system_message("test title", "test message", true);
#[cfg(target_os = "macos")]
let res = macos::alert(
"System Preferences".to_owned(),
"warning".to_owned(),
"test title".to_owned(),
"test message".to_owned(),
["Ok".to_owned()].to_vec(),
);
#[cfg(any(target_os = "linux", target_os = "macos"))]
println!("result {:?}", &res);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,403 +0,0 @@
//! Option keys shared across the app.
//!
//! The handful that `hbb_common` itself reads stay defined there and are
//! re-exported here, so callers always use this one path.
pub use hbb_common::config::keys::*;
pub const OPTION_VIEW_ONLY: &str = "view_only";
pub const OPTION_SHOW_MONITORS_TOOLBAR: &str = "show_monitors_toolbar";
pub const OPTION_SHOW_REMOTE_CURSOR: &str = "show_remote_cursor";
pub const OPTION_FOLLOW_REMOTE_CURSOR: &str = "follow_remote_cursor";
pub const OPTION_FOLLOW_REMOTE_WINDOW: &str = "follow_remote_window";
pub const OPTION_SHOW_QUALITY_MONITOR: &str = "show_quality_monitor";
pub const OPTION_DISABLE_AUDIO: &str = "disable_audio";
pub const OPTION_ENABLE_REMOTE_PRINTER: &str = "enable-remote-printer";
pub const OPTION_DISABLE_CLIPBOARD: &str = "disable_clipboard";
pub const OPTION_LOCK_AFTER_SESSION_END: &str = "lock_after_session_end";
pub const OPTION_PRIVACY_MODE: &str = "privacy_mode";
pub const OPTION_TOUCH_MODE: &str = "touch-mode";
pub const OPTION_SYNC_INIT_CLIPBOARD: &str = "sync-init-clipboard";
pub const OPTION_THEME: &str = "theme";
pub const OPTION_REMOTE_MENUBAR_DRAG_LEFT: &str = "remote-menubar-drag-left";
pub const OPTION_REMOTE_MENUBAR_DRAG_RIGHT: &str = "remote-menubar-drag-right";
pub const OPTION_HIDE_AB_TAGS_PANEL: &str = "hideAbTagsPanel";
pub const OPTION_ENABLE_CONFIRM_CLOSING_TABS: &str = "enable-confirm-closing-tabs";
pub const OPTION_ENABLE_OPEN_NEW_CONNECTIONS_IN_TABS: &str = "enable-open-new-connections-in-tabs";
pub const OPTION_TEXTURE_RENDER: &str = "use-texture-render";
// Internal health record written by the texture-render watchdog/probe;
// "failed-*" flips the texture-render default to opt-in on this machine.
pub const OPTION_TEXTURE_RENDER_HEALTH: &str = "texture-render-health";
pub const OPTION_ALLOW_D3D_RENDER: &str = "allow-d3d-render";
pub const OPTION_ENABLE_CHECK_UPDATE: &str = "enable-check-update";
pub const OPTION_ALLOW_AUTO_UPDATE: &str = "allow-auto-update";
pub const OPTION_SYNC_AB_WITH_RECENT_SESSIONS: &str = "sync-ab-with-recent-sessions";
pub const OPTION_SYNC_AB_TAGS: &str = "sync-ab-tags";
pub const OPTION_FILTER_AB_BY_INTERSECTION: &str = "filter-ab-by-intersection";
pub const OPTION_ACCESS_MODE: &str = "access-mode";
pub const OPTION_ENABLE_KEYBOARD: &str = "enable-keyboard";
pub const OPTION_ENABLE_CLIPBOARD: &str = "enable-clipboard";
pub const OPTION_ENABLE_FILE_TRANSFER: &str = "enable-file-transfer";
pub const OPTION_ENABLE_CAMERA: &str = "enable-camera";
pub const OPTION_ENABLE_TERMINAL: &str = "enable-terminal";
pub const OPTION_TERMINAL_PERSISTENT: &str = "terminal-persistent";
pub const OPTION_ENABLE_AUDIO: &str = "enable-audio";
pub const OPTION_ENABLE_TUNNEL: &str = "enable-tunnel";
pub const OPTION_ENABLE_REMOTE_RESTART: &str = "enable-remote-restart";
pub const OPTION_ENABLE_RECORD_SESSION: &str = "enable-record-session";
pub const OPTION_ENABLE_BLOCK_INPUT: &str = "enable-block-input";
pub const OPTION_ENABLE_PRIVACY_MODE: &str = "enable-privacy-mode";
pub const OPTION_ENABLE_PERM_CHANGE_IN_ACCEPT_WINDOW: &str = "enable-perm-change-in-accept-window";
pub const OPTION_ALLOW_SCOPE_VIOLATION_CLOSE: &str = "allow-scope-violation-close";
pub const OPTION_ALLOW_SCOPE_VIOLATION_ALARM: &str = "allow-scope-violation-alarm";
pub const OPTION_ALLOW_REMOTE_CONFIG_MODIFICATION: &str = "allow-remote-config-modification";
pub const OPTION_ENABLE_LAN_DISCOVERY: &str = "enable-lan-discovery";
pub const OPTION_DIRECT_ACCESS_PORT: &str = "direct-access-port";
pub const OPTION_WHITELIST: &str = "whitelist";
pub const OPTION_ID_WHITELIST: &str = "id-whitelist";
pub const OPTION_ALLOW_AUTO_DISCONNECT: &str = "allow-auto-disconnect";
pub const OPTION_AUTO_DISCONNECT_TIMEOUT: &str = "auto-disconnect-timeout";
pub const OPTION_ALLOW_ONLY_CONN_WINDOW_OPEN: &str = "allow-only-conn-window-open";
pub const OPTION_ALLOW_AUTO_RECORD_INCOMING: &str = "allow-auto-record-incoming";
pub const OPTION_ALLOW_AUTO_RECORD_OUTGOING: &str = "allow-auto-record-outgoing";
pub const OPTION_HIDE_RECORDING_BUTTON: &str = "hide-recording-button";
pub const OPTION_WINDOWS_SERVICE_VIDEO_SAVE_DIRECTORY: &str =
"windows-service-video-save-directory";
pub const OPTION_VIDEO_SAVE_DIRECTORY: &str = "video-save-directory";
pub const OPTION_ENABLE_ABR: &str = "enable-abr";
pub const OPTION_ALLOW_REMOVE_WALLPAPER: &str = "allow-remove-wallpaper";
pub const OPTION_ALLOW_ALWAYS_SOFTWARE_RENDER: &str = "allow-always-software-render";
pub const OPTION_ENABLE_HWCODEC: &str = "enable-hwcodec";
pub const OPTION_APPROVE_MODE: &str = "approve-mode";
pub const OPTION_VERIFICATION_METHOD: &str = "verification-method";
pub const OPTION_TEMPORARY_PASSWORD_LENGTH: &str = "temporary-password-length";
pub const OPTION_CUSTOM_RENDEZVOUS_SERVER: &str = "custom-rendezvous-server";
pub const OPTION_API_SERVER: &str = "api-server";
pub const OPTION_KEY: &str = "key";
pub const OPTION_PRESET_ADDRESS_BOOK_NAME: &str = "preset-address-book-name";
pub const OPTION_PRESET_ADDRESS_BOOK_TAG: &str = "preset-address-book-tag";
pub const OPTION_PRESET_ADDRESS_BOOK_ALIAS: &str = "preset-address-book-alias";
pub const OPTION_PRESET_ADDRESS_BOOK_PASSWORD: &str = "preset-address-book-password";
pub const OPTION_PRESET_ADDRESS_BOOK_NOTE: &str = "preset-address-book-note";
pub const OPTION_PRESET_DEVICE_USERNAME: &str = "preset-device-username";
pub const OPTION_PRESET_DEVICE_NAME: &str = "preset-device-name";
pub const OPTION_PRESET_NOTE: &str = "preset-note";
pub const OPTION_ENABLE_DIRECTX_CAPTURE: &str = "enable-directx-capture";
pub const OPTION_ENABLE_ANDROID_SOFTWARE_ENCODING_HALF_SCALE: &str =
"enable-android-software-encoding-half-scale";
pub const OPTION_ENABLE_TRUSTED_DEVICES: &str = "enable-trusted-devices";
pub const OPTION_AV1_TEST: &str = "av1-test";
/// Maximum number of files allowed during a single file transfer request.
///
/// Key: `file-transfer-max-files`.
/// Unit: number of files (not bytes).
///
/// Behaviour:
/// - If set to a positive integer N, at most N files are allowed.
/// - If set to 0, a safe built-in default is used (see DEFAULT_MAX_VALIDATED_FILES).
/// - If unset, negative, or non-integer, no explicit limit is enforced for backward compatibility.
pub const OPTION_FILE_TRANSFER_MAX_FILES: &str = "file-transfer-max-files";
pub const OPTION_DISABLE_UDP: &str = "disable-udp";
pub const OPTION_SHOW_VIRTUAL_MOUSE: &str = "show-virtual-mouse";
// joystick is the virtual mouse.
// So `OPTION_SHOW_VIRTUAL_MOUSE` should also be set if `OPTION_SHOW_VIRTUAL_JOYSTICK` is set.
pub const OPTION_SHOW_VIRTUAL_JOYSTICK: &str = "show-virtual-joystick";
pub const OPTION_ENABLE_FLUTTER_HTTP_ON_RUST: &str = "enable-flutter-http-on-rust";
pub const OPTION_ALLOW_ASK_FOR_NOTE: &str = "allow-ask-for-note";
// built-in options
pub const OPTION_DISPLAY_NAME: &str = "display-name";
pub const OPTION_AVATAR: &str = "avatar";
pub const OPTION_PRESET_DEVICE_GROUP_NAME: &str = "preset-device-group-name";
pub const OPTION_PRESET_USERNAME: &str = "preset-user-name";
pub const OPTION_PRESET_STRATEGY_NAME: &str = "preset-strategy-name";
pub const OPTION_REMOVE_PRESET_PASSWORD_WARNING: &str = "remove-preset-password-warning";
pub const OPTION_HIDE_GENERAL_SETTINGS: &str = "hide-general-settings";
pub const OPTION_HIDE_SECURITY_SETTINGS: &str = "hide-security-settings";
pub const OPTION_HIDE_NETWORK_SETTINGS: &str = "hide-network-settings";
pub const OPTION_HIDE_SERVER_SETTINGS: &str = "hide-server-settings";
pub const OPTION_HIDE_PROXY_SETTINGS: &str = "hide-proxy-settings";
pub const OPTION_HIDE_REMOTE_PRINTER_SETTINGS: &str = "hide-remote-printer-settings";
pub const OPTION_HIDE_WEBSOCKET_SETTINGS: &str = "hide-websocket-settings";
pub const OPTION_HIDE_STOP_SERVICE: &str = "hide-stop-service";
pub const OPTION_ALLOW_COMMAND_LINE_SETTINGS_WHEN_SETTINGS_DISABLED: &str =
"allow-command-line-settings-when-settings-disabled";
// Connection punch-through / port-forward options
pub const OPTION_ENABLE_TCP_PUNCH: &str = "enable-tcp-punch";
pub const OPTION_ENABLE_UDP_PUNCH: &str = "enable-udp-punch";
pub const OPTION_ENABLE_IPV6_PUNCH: &str = "enable-ipv6-punch";
pub const OPTION_ENABLE_PORT_FORWARD_MUX: &str = "enable-port-forward-mux";
pub const OPTION_ENABLE_WEBRTC: &str = "enable-webrtc";
pub const OPTION_ALLOW_KCP_CC: &str = "allow-kcp-congestion-control";
pub const OPTION_HIDE_USERNAME_ON_CARD: &str = "hide-username-on-card";
pub const OPTION_HIDE_HELP_CARDS: &str = "hide-help-cards";
pub const OPTION_DEFAULT_CONNECT_PASSWORD: &str = "default-connect-password";
pub const OPTION_HIDE_TRAY: &str = "hide-tray";
pub const OPTION_ONE_WAY_CLIPBOARD_REDIRECTION: &str = "one-way-clipboard-redirection";
pub const OPTION_ALLOW_LOGON_SCREEN_PASSWORD: &str = "allow-logon-screen-password";
pub const OPTION_ALLOW_DEEP_LINK_PASSWORD: &str = "allow-deep-link-password";
pub const OPTION_ALLOW_DEEP_LINK_SERVER_SETTINGS: &str = "allow-deep-link-server-settings";
pub const OPTION_ONE_WAY_FILE_TRANSFER: &str = "one-way-file-transfer";
pub const OPTION_ALLOW_HTTPS_21114: &str = "allow-https-21114";
pub const OPTION_USE_RAW_TCP_FOR_API: &str = "use-raw-tcp-for-api";
pub const OPTION_HIDE_POWERED_BY_ME: &str = "hide-powered-by-me";
pub const OPTION_MAIN_WINDOW_ALWAYS_ON_TOP: &str = "main-window-always-on-top";
// flutter local options
pub const OPTION_FLUTTER_REMOTE_MENUBAR_STATE: &str = "remoteMenubarState";
pub const OPTION_FLUTTER_PEER_SORTING: &str = "peer-sorting";
pub const OPTION_FLUTTER_PEER_TAB_INDEX: &str = "peer-tab-index";
pub const OPTION_FLUTTER_PEER_TAB_ORDER: &str = "peer-tab-order";
pub const OPTION_FLUTTER_PEER_TAB_VISIBLE: &str = "peer-tab-visible";
pub const OPTION_FLUTTER_PEER_CARD_UI_TYLE: &str = "peer-card-ui-type";
pub const OPTION_FLUTTER_CURRENT_AB_NAME: &str = "current-ab-name";
pub const OPTION_ALLOW_REMOTE_CM_MODIFICATION: &str = "allow-remote-cm-modification";
pub const OPTION_ALLOW_SYNC_CLIPBOARD_BETWEEN_SESSIONS: &str =
"allow-sync-clipboard-between-sessions";
pub const OPTION_PRINTER_INCOMING_JOB_ACTION: &str = "printer-incomming-job-action";
pub const OPTION_PRINTER_ALLOW_AUTO_PRINT: &str = "allow-printer-auto-print";
pub const OPTION_PRINTER_SELECTED_NAME: &str = "printer-selected-name";
// android floating window options
pub const OPTION_DISABLE_FLOATING_WINDOW: &str = "disable-floating-window";
pub const OPTION_FLOATING_WINDOW_SIZE: &str = "floating-window-size";
pub const OPTION_FLOATING_WINDOW_UNTOUCHABLE: &str = "floating-window-untouchable";
pub const OPTION_FLOATING_WINDOW_TRANSPARENCY: &str = "floating-window-transparency";
pub const OPTION_FLOATING_WINDOW_SVG: &str = "floating-window-svg";
// android keep screen on
pub const OPTION_KEEP_SCREEN_ON: &str = "keep-screen-on";
// Server-side: keep host system awake during incoming sessions (Security setting)
pub const OPTION_KEEP_AWAKE_DURING_INCOMING_SESSIONS: &str = "keep-awake-during-incoming-sessions";
// Client-side: keep client system awake during outgoing sessions (General setting)
pub const OPTION_KEEP_AWAKE_DURING_OUTGOING_SESSIONS: &str = "keep-awake-during-outgoing-sessions";
pub const OPTION_DISABLE_GROUP_PANEL: &str = "disable-group-panel";
pub const OPTION_DISABLE_DISCOVERY_PANEL: &str = "disable-discovery-panel";
pub const OPTION_PRE_ELEVATE_SERVICE: &str = "pre-elevate-service";
// DEFAULT_DISPLAY_SETTINGS, OVERWRITE_DISPLAY_SETTINGS
pub const KEYS_DISPLAY_SETTINGS: &[&str] = &[
OPTION_VIEW_ONLY,
OPTION_SHOW_MONITORS_TOOLBAR,
OPTION_COLLAPSE_TOOLBAR,
OPTION_SHOW_REMOTE_CURSOR,
OPTION_FOLLOW_REMOTE_CURSOR,
OPTION_FOLLOW_REMOTE_WINDOW,
OPTION_ZOOM_CURSOR,
OPTION_SHOW_QUALITY_MONITOR,
OPTION_DISABLE_AUDIO,
OPTION_ENABLE_FILE_COPY_PASTE,
OPTION_DISABLE_CLIPBOARD,
OPTION_LOCK_AFTER_SESSION_END,
OPTION_PRIVACY_MODE,
OPTION_TOUCH_MODE,
OPTION_I444,
OPTION_REVERSE_MOUSE_WHEEL,
OPTION_SWAP_LEFT_RIGHT_MOUSE,
OPTION_DISPLAYS_AS_INDIVIDUAL_WINDOWS,
OPTION_USE_ALL_MY_DISPLAYS_FOR_THE_REMOTE_SESSION,
OPTION_VIEW_STYLE,
OPTION_TERMINAL_PERSISTENT,
OPTION_SCROLL_STYLE,
OPTION_EDGE_SCROLL_EDGE_THICKNESS,
OPTION_IMAGE_QUALITY,
OPTION_CUSTOM_IMAGE_QUALITY,
OPTION_CUSTOM_FPS,
OPTION_CODEC_PREFERENCE,
OPTION_SYNC_INIT_CLIPBOARD,
OPTION_TRACKPAD_SPEED,
];
// DEFAULT_LOCAL_SETTINGS, OVERWRITE_LOCAL_SETTINGS
pub const KEYS_LOCAL_SETTINGS: &[&str] = &[
OPTION_THEME,
OPTION_LANGUAGE,
OPTION_ENABLE_CONFIRM_CLOSING_TABS,
OPTION_ENABLE_OPEN_NEW_CONNECTIONS_IN_TABS,
OPTION_TEXTURE_RENDER,
OPTION_ALLOW_D3D_RENDER,
OPTION_SYNC_AB_WITH_RECENT_SESSIONS,
OPTION_SYNC_AB_TAGS,
OPTION_FILTER_AB_BY_INTERSECTION,
OPTION_REMOTE_MENUBAR_DRAG_LEFT,
OPTION_REMOTE_MENUBAR_DRAG_RIGHT,
OPTION_HIDE_AB_TAGS_PANEL,
OPTION_FLUTTER_REMOTE_MENUBAR_STATE,
OPTION_FLUTTER_PEER_SORTING,
OPTION_FLUTTER_PEER_TAB_INDEX,
OPTION_FLUTTER_PEER_TAB_ORDER,
OPTION_FLUTTER_PEER_TAB_VISIBLE,
OPTION_FLUTTER_PEER_CARD_UI_TYLE,
OPTION_FLUTTER_CURRENT_AB_NAME,
OPTION_DISABLE_FLOATING_WINDOW,
OPTION_FLOATING_WINDOW_SIZE,
OPTION_FLOATING_WINDOW_UNTOUCHABLE,
OPTION_FLOATING_WINDOW_TRANSPARENCY,
OPTION_FLOATING_WINDOW_SVG,
OPTION_KEEP_SCREEN_ON,
// Client-side: keep client system awake during outgoing sessions (General setting)
OPTION_KEEP_AWAKE_DURING_OUTGOING_SESSIONS,
OPTION_DISABLE_GROUP_PANEL,
OPTION_DISABLE_DISCOVERY_PANEL,
OPTION_PRE_ELEVATE_SERVICE,
OPTION_ALLOW_REMOTE_CM_MODIFICATION,
OPTION_ALLOW_SYNC_CLIPBOARD_BETWEEN_SESSIONS,
OPTION_ENABLE_CHECK_UPDATE,
OPTION_PRINTER_INCOMING_JOB_ACTION,
OPTION_PRINTER_ALLOW_AUTO_PRINT,
OPTION_PRINTER_SELECTED_NAME,
OPTION_ALLOW_AUTO_RECORD_OUTGOING,
OPTION_HIDE_RECORDING_BUTTON,
OPTION_VIDEO_SAVE_DIRECTORY,
OPTION_ENABLE_TCP_PUNCH,
OPTION_ENABLE_UDP_PUNCH,
OPTION_ENABLE_IPV6_PUNCH,
OPTION_ENABLE_PORT_FORWARD_MUX,
OPTION_ENABLE_WEBRTC,
OPTION_TOUCH_MODE,
OPTION_SHOW_VIRTUAL_MOUSE,
OPTION_SHOW_VIRTUAL_JOYSTICK,
OPTION_ENABLE_FLUTTER_HTTP_ON_RUST,
OPTION_ALLOW_ASK_FOR_NOTE,
];
// DEFAULT_SETTINGS, OVERWRITE_SETTINGS
pub const KEYS_SETTINGS: &[&str] = &[
OPTION_ACCESS_MODE,
OPTION_ENABLE_KEYBOARD,
OPTION_ENABLE_CLIPBOARD,
OPTION_ENABLE_FILE_TRANSFER,
OPTION_ENABLE_CAMERA,
OPTION_ENABLE_TERMINAL,
OPTION_ENABLE_REMOTE_PRINTER,
OPTION_ENABLE_AUDIO,
OPTION_ENABLE_TUNNEL,
OPTION_ENABLE_REMOTE_RESTART,
OPTION_ENABLE_RECORD_SESSION,
OPTION_ENABLE_BLOCK_INPUT,
OPTION_ENABLE_PRIVACY_MODE,
OPTION_ALLOW_SCOPE_VIOLATION_CLOSE,
OPTION_ALLOW_SCOPE_VIOLATION_ALARM,
OPTION_ALLOW_REMOTE_CONFIG_MODIFICATION,
OPTION_ALLOW_NUMERNIC_ONE_TIME_PASSWORD,
OPTION_ENABLE_LAN_DISCOVERY,
OPTION_DIRECT_SERVER,
OPTION_DIRECT_ACCESS_PORT,
OPTION_WHITELIST,
OPTION_ID_WHITELIST,
OPTION_ALLOW_AUTO_DISCONNECT,
OPTION_AUTO_DISCONNECT_TIMEOUT,
OPTION_ALLOW_ONLY_CONN_WINDOW_OPEN,
OPTION_ALLOW_AUTO_RECORD_INCOMING,
OPTION_WINDOWS_SERVICE_VIDEO_SAVE_DIRECTORY,
OPTION_ENABLE_ABR,
OPTION_ALLOW_REMOVE_WALLPAPER,
OPTION_ALLOW_ALWAYS_SOFTWARE_RENDER,
OPTION_ENABLE_HWCODEC,
OPTION_APPROVE_MODE,
OPTION_VERIFICATION_METHOD,
OPTION_TEMPORARY_PASSWORD_LENGTH,
OPTION_PROXY_URL,
OPTION_PROXY_USERNAME,
OPTION_PROXY_PASSWORD,
OPTION_CUSTOM_RENDEZVOUS_SERVER,
OPTION_API_SERVER,
OPTION_KEY,
OPTION_ALLOW_WEBSOCKET,
OPTION_PRESET_ADDRESS_BOOK_NAME,
OPTION_PRESET_ADDRESS_BOOK_TAG,
OPTION_PRESET_ADDRESS_BOOK_ALIAS,
OPTION_PRESET_ADDRESS_BOOK_PASSWORD,
OPTION_PRESET_ADDRESS_BOOK_NOTE,
OPTION_PRESET_DEVICE_USERNAME,
OPTION_PRESET_DEVICE_NAME,
OPTION_PRESET_NOTE,
OPTION_ENABLE_DIRECTX_CAPTURE,
OPTION_ENABLE_ANDROID_SOFTWARE_ENCODING_HALF_SCALE,
OPTION_ENABLE_TRUSTED_DEVICES,
OPTION_RELAY_SERVER,
OPTION_ICE_SERVERS,
OPTION_DISABLE_UDP,
OPTION_ALLOW_INSECURE_TLS_FALLBACK,
OPTION_KEEP_AWAKE_DURING_INCOMING_SESSIONS,
OPTION_ALLOW_AUTO_UPDATE,
OPTION_ALLOW_KCP_CC,
OPTION_ALLOW_WEBRTC_CC,
];
// BUILDIN_SETTINGS
pub const KEYS_BUILDIN_SETTINGS: &[&str] = &[
OPTION_DISPLAY_NAME,
OPTION_AVATAR,
OPTION_PRESET_DEVICE_GROUP_NAME,
OPTION_PRESET_USERNAME,
OPTION_PRESET_STRATEGY_NAME,
OPTION_REMOVE_PRESET_PASSWORD_WARNING,
OPTION_HIDE_GENERAL_SETTINGS,
OPTION_HIDE_SECURITY_SETTINGS,
OPTION_HIDE_NETWORK_SETTINGS,
OPTION_HIDE_SERVER_SETTINGS,
OPTION_HIDE_PROXY_SETTINGS,
OPTION_HIDE_REMOTE_PRINTER_SETTINGS,
OPTION_HIDE_WEBSOCKET_SETTINGS,
OPTION_HIDE_STOP_SERVICE,
OPTION_HIDE_USERNAME_ON_CARD,
OPTION_HIDE_HELP_CARDS,
OPTION_DEFAULT_CONNECT_PASSWORD,
OPTION_HIDE_TRAY,
OPTION_ONE_WAY_CLIPBOARD_REDIRECTION,
OPTION_ALLOW_LOGON_SCREEN_PASSWORD,
OPTION_ALLOW_DEEP_LINK_PASSWORD,
OPTION_ALLOW_DEEP_LINK_SERVER_SETTINGS,
OPTION_ONE_WAY_FILE_TRANSFER,
OPTION_ALLOW_HTTPS_21114,
OPTION_ALLOW_HOSTNAME_AS_ID,
OPTION_REGISTER_DEVICE,
OPTION_HIDE_POWERED_BY_ME,
OPTION_MAIN_WINDOW_ALWAYS_ON_TOP,
OPTION_FILE_TRANSFER_MAX_FILES,
OPTION_DISABLE_CHANGE_PERMANENT_PASSWORD,
OPTION_DISABLE_CHANGE_ID,
OPTION_DISABLE_UNLOCK_PIN,
OPTION_USE_RAW_TCP_FOR_API,
OPTION_ENABLE_PERM_CHANGE_IN_ACCEPT_WINDOW,
OPTION_ALLOW_COMMAND_LINE_SETTINGS_WHEN_SETTINGS_DISABLED,
];
#[cfg(test)]
mod tests {
/// The glob above and the constants below share one namespace, and Rust
/// silently prefers the explicit item over a glob import. A key defined on
/// both sides would therefore compile, with the client and the server
/// disagreeing about its string value and nothing to signal it. Keep the
/// two sets apart.
#[test]
fn key_names_do_not_collide_with_hbb_common() {
fn names(src: &str) -> Vec<&str> {
src.lines()
.filter_map(|l| l.trim().strip_prefix("pub const "))
.filter_map(|l| l.split(':').next())
.map(str::trim)
.filter(|n| n.starts_with("OPTION_") || n.starts_with("KEYS_"))
.collect()
}
let here = names(include_str!("keys.rs"));
let there = names(include_str!("../../../hbb_common/src/config.rs"));
assert!(
!here.is_empty() && !there.is_empty(),
"key parsing found nothing"
);
let both: Vec<_> = here.iter().filter(|n| there.contains(n)).collect();
assert!(
both.is_empty(),
"defined in both crates, so the local one shadows hbb_common's \
with no diagnostic: {:?}",
both
);
}
}

View File

@@ -1 +0,0 @@
pub mod keys;

File diff suppressed because it is too large Load Diff

View File

@@ -1,39 +0,0 @@
use std::{fmt, slice::Iter, str::FromStr};
use crate::protos::message::KeyboardMode;
impl fmt::Display for KeyboardMode {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
KeyboardMode::Legacy => write!(f, "legacy"),
KeyboardMode::Map => write!(f, "map"),
KeyboardMode::Translate => write!(f, "translate"),
KeyboardMode::Auto => write!(f, "auto"),
}
}
}
impl FromStr for KeyboardMode {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"legacy" => Ok(KeyboardMode::Legacy),
"map" => Ok(KeyboardMode::Map),
"translate" => Ok(KeyboardMode::Translate),
"auto" => Ok(KeyboardMode::Auto),
_ => Err(()),
}
}
}
impl KeyboardMode {
pub fn iter() -> Iter<'static, KeyboardMode> {
static KEYBOARD_MODES: [KeyboardMode; 4] = [
KeyboardMode::Legacy,
KeyboardMode::Map,
KeyboardMode::Translate,
KeyboardMode::Auto,
];
KEYBOARD_MODES.iter()
}
}

View File

@@ -1,7 +0,0 @@
pub mod config;
pub mod fs;
pub mod keyboard;
pub mod platform;
pub mod protos;
pub use protos::message as message_proto;

View File

@@ -1,618 +0,0 @@
use hbb_common::ResultType;
// Kept in hbb_common because `config::patch()` needs the shell lookup; re-exported
// here so the long-standing `platform::linux::CMD_SH` paths are unchanged.
pub use hbb_common::sh::{run_cmds_trim_newline, CMD_LOGINCTL, CMD_PS, CMD_SH};
use std::{
collections::HashMap,
path::{Path, PathBuf},
process::Command,
};
use users::{get_current_uid, get_user_by_uid, os::unix::UserExt};
use sctk::{
output::OutputData,
output::{OutputHandler, OutputState},
reexports::client::protocol::wl_output::WlOutput,
reexports::client::{globals, Proxy},
reexports::client::{Connection, QueueHandle},
registry::{ProvidesRegistryState, RegistryState},
};
lazy_static::lazy_static! {
pub static ref DISTRO: Distro = Distro::new();
}
pub const DISPLAY_SERVER_WAYLAND: &str = "wayland";
pub const DISPLAY_SERVER_X11: &str = "x11";
pub const DISPLAY_DESKTOP_KDE: &str = "KDE";
pub const XDG_CURRENT_DESKTOP: &str = "XDG_CURRENT_DESKTOP";
pub struct Distro {
pub name: String,
pub version_id: String,
}
impl Distro {
fn new() -> Self {
let name = run_cmds("awk -F'=' '/^NAME=/ {print $2}' /etc/os-release")
.unwrap_or_default()
.trim()
.trim_matches('"')
.to_string();
let version_id = run_cmds("awk -F'=' '/^VERSION_ID=/ {print $2}' /etc/os-release")
.unwrap_or_default()
.trim()
.trim_matches('"')
.to_string();
Self { name, version_id }
}
}
// Deprecated. Use `base::platform::linux::is_kde_session()` instead for now.
// Or we need to set the correct environment variable in the server process.
#[inline]
pub fn is_kde() -> bool {
if let Ok(env) = std::env::var(XDG_CURRENT_DESKTOP) {
env == DISPLAY_DESKTOP_KDE
} else {
false
}
}
// Don't use `base::platform::linux::is_kde()` here.
// It's not correct in the server process.
pub fn is_kde_session() -> bool {
std::process::Command::new(CMD_SH.as_str())
.arg("-c")
.arg("pgrep -f kded[0-9]+")
.stdout(std::process::Stdio::piped())
.output()
.map(|o| !o.stdout.is_empty())
.unwrap_or(false)
}
#[inline]
pub fn is_gdm_user(username: &str) -> bool {
username == "gdm" || username == "sddm"
// || username == "lightgdm"
}
#[inline]
pub fn is_desktop_wayland() -> bool {
get_display_server() == DISPLAY_SERVER_WAYLAND
}
#[inline]
pub fn is_x11_or_headless() -> bool {
!is_desktop_wayland()
}
// -1
const INVALID_SESSION: &str = "4294967295";
pub fn get_display_server() -> String {
// Check for forced display server environment variable first
if let Ok(forced_display) = std::env::var("RUSTDESK_FORCED_DISPLAY_SERVER") {
return forced_display;
}
// Check if `loginctl` can be called successfully
if run_loginctl(None).is_err() {
return DISPLAY_SERVER_X11.to_owned();
}
let mut session = get_values_of_seat0(&[0])[0].clone();
if session.is_empty() {
// loginctl has not given the expected output. try something else.
if let Ok(sid) = std::env::var("XDG_SESSION_ID") {
// could also execute "cat /proc/self/sessionid"
session = sid;
}
if session.is_empty() {
session = run_cmds("cat /proc/self/sessionid").unwrap_or_default();
if session == INVALID_SESSION {
session = "".to_owned();
}
}
}
if session.is_empty() {
std::env::var("XDG_SESSION_TYPE").unwrap_or("x11".to_owned())
} else {
get_display_server_of_session(&session)
}
}
pub fn get_display_server_of_session(session: &str) -> String {
let mut display_server = if let Ok(output) =
run_loginctl(Some(vec!["show-session", "-p", "Type", session]))
// Check session type of the session
{
String::from_utf8_lossy(&output.stdout)
.replace("Type=", "")
.trim_end()
.into()
} else {
"".to_owned()
};
if display_server.is_empty() || display_server == "tty" || display_server == "unspecified" {
if let Ok(sestype) = std::env::var("XDG_SESSION_TYPE") {
if !sestype.is_empty() {
return sestype.to_lowercase();
}
}
display_server = "x11".to_owned();
}
display_server.to_lowercase()
}
#[inline]
fn line_values(indices: &[usize], line: &str) -> Vec<String> {
indices
.into_iter()
.map(|idx| line.split_whitespace().nth(*idx).unwrap_or("").to_owned())
.collect::<Vec<String>>()
}
#[inline]
pub fn get_values_of_seat0(indices: &[usize]) -> Vec<String> {
_get_values_of_seat0(indices, true)
}
#[inline]
pub fn get_values_of_seat0_with_gdm_wayland(indices: &[usize]) -> Vec<String> {
_get_values_of_seat0(indices, false)
}
// Ignore "3 sessions listed."
fn ignore_loginctl_line(line: &str) -> bool {
line.contains("sessions") || line.split(" ").count() < 4
}
fn _get_values_of_seat0(indices: &[usize], ignore_gdm_wayland: bool) -> Vec<String> {
if let Ok(output) = run_loginctl(None) {
for line in String::from_utf8_lossy(&output.stdout).lines() {
if ignore_loginctl_line(line) {
continue;
}
if line.contains("seat0") {
if let Some(sid) = line.split_whitespace().next() {
if is_active(sid) {
if ignore_gdm_wayland {
if is_gdm_user(line.split_whitespace().nth(2).unwrap_or(""))
&& get_display_server_of_session(sid) == DISPLAY_SERVER_WAYLAND
{
continue;
}
}
return line_values(indices, line);
}
}
}
}
// some case, there is no seat0 https://github.com/rustdesk/rustdesk/issues/73
for line in String::from_utf8_lossy(&output.stdout).lines() {
if ignore_loginctl_line(line) {
continue;
}
if let Some(sid) = line.split_whitespace().next() {
if is_active(sid) {
let d = get_display_server_of_session(sid);
if ignore_gdm_wayland {
if is_gdm_user(line.split_whitespace().nth(2).unwrap_or(""))
&& d == DISPLAY_SERVER_WAYLAND
{
continue;
}
}
if d == "tty" || d == "unspecified" {
continue;
}
return line_values(indices, line);
}
}
}
}
line_values(indices, "")
}
pub fn is_active(sid: &str) -> bool {
if let Ok(output) = run_loginctl(Some(vec!["show-session", "-p", "State", sid])) {
String::from_utf8_lossy(&output.stdout).contains("active")
} else {
false
}
}
pub fn is_active_and_seat0(sid: &str) -> bool {
if let Ok(output) = run_loginctl(Some(vec!["show-session", sid])) {
String::from_utf8_lossy(&output.stdout).contains("State=active")
&& String::from_utf8_lossy(&output.stdout).contains("Seat=seat0")
} else {
false
}
}
// Check both "Lock" and "Switch user"
pub fn is_session_locked(sid: &str) -> bool {
if let Ok(output) = run_loginctl(Some(vec!["show-session", sid, "--property=LockedHint"])) {
String::from_utf8_lossy(&output.stdout).contains("LockedHint=yes")
} else {
false
}
}
// **Note** that the return value here, the last character is '\n'.
// Use `run_cmds_trim_newline()` if you want to remove '\n' at the end.
pub fn run_cmds(cmds: &str) -> ResultType<String> {
let output = std::process::Command::new(CMD_SH.as_str())
.args(vec!["-c", cmds])
.output()?;
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
fn run_loginctl(args: Option<Vec<&str>>) -> std::io::Result<std::process::Output> {
if std::env::var("FLATPAK_ID").is_ok() {
let mut l_args = CMD_LOGINCTL.to_string();
if let Some(a) = args.as_ref() {
l_args = format!("{} {}", l_args, a.join(" "));
}
let res = std::process::Command::new("flatpak-spawn")
.args(vec![String::from("--host"), l_args])
.output();
if res.is_ok() {
return res;
}
}
let mut cmd = std::process::Command::new(CMD_LOGINCTL.as_str());
if let Some(a) = args {
return cmd.args(a).output();
}
cmd.output()
}
/// forever: may not work
#[cfg(target_os = "linux")]
pub fn system_message(title: &str, msg: &str, forever: bool) -> ResultType<()> {
let cmds: HashMap<&str, Vec<&str>> = HashMap::from([
("notify-send", [title, msg].to_vec()),
(
"zenity",
[
"--info",
"--timeout",
if forever { "0" } else { "3" },
"--title",
title,
"--text",
msg,
]
.to_vec(),
),
("kdialog", ["--title", title, "--msgbox", msg].to_vec()),
(
"xmessage",
[
"-center",
"-timeout",
if forever { "0" } else { "3" },
title,
msg,
]
.to_vec(),
),
]);
for (k, v) in cmds {
if Command::new(k).args(v).spawn().is_ok() {
return Ok(());
}
}
hbb_common::bail!("failed to post system message");
}
#[derive(Debug, Clone, serde_derive::Serialize, serde_derive::Deserialize)]
pub struct WaylandDisplayInfo {
pub name: String,
pub x: i32,
pub y: i32,
pub width: i32,
pub height: i32,
pub logical_size: Option<(i32, i32)>,
pub refresh_rate: i32,
/// Output rotation in degrees (0/90/180/270), from `wl_output.geometry`. The mode keeps its
/// unrotated dimensions and `logical_size` arrives already swapped, so without this field a
/// rotated output is indistinguishable from a scaled one. Flipped variants map to their
/// rotation. Defaulted so a serialized snapshot from an older probe child still deserializes.
#[serde(default)]
pub transform: i32,
}
/// The isolated socket-probe fallback, in its own file and behind the `wayland_probe` feature so
/// the base Wayland path never compiles it. The DRM login-screen build turns it on.
#[cfg(feature = "wayland_probe")]
pub mod wayland_probe;
#[cfg(feature = "wayland_probe")]
pub use wayland_probe::{wayland_display_probe_child_main, WAYLAND_DISPLAY_PROBE_ARG};
// Retrieves information about all connected displays via the Wayland protocol.
pub fn get_wayland_displays() -> ResultType<Vec<WaylandDisplayInfo>> {
// Read before connecting: `connect_to_env` consumes `WAYLAND_SOCKET`. Only the probe fallback
// needs this, so it is computed only when that feature is compiled in.
#[cfg(feature = "wayland_probe")]
let named_endpoint = wayland_probe::env_names_wayland_endpoint();
match Connection::connect_to_env() {
Ok(conn) => collect_wayland_displays(&conn),
// Without the feature, the connect error is final, exactly as before this fallback existed.
#[cfg(not(feature = "wayland_probe"))]
Err(err) => Err(err.into()),
#[cfg(feature = "wayland_probe")]
Err(err) => wayland_probe::wayland_displays_from_runtime_dir(named_endpoint)
.map_err(|fallback_err| anyhow::anyhow!("{err}; {fallback_err}")),
}
}
/// `wl_output::Transform` as degrees. Flipped variants report their rotation ONLY: wayland
/// defines them as a vertical-axis mirror followed by the rotation, and the mirror half is
/// dropped here - a consumer correcting frames by this value serves a flipped output mirrored.
/// Said once in the log rather than silently, because no compositor of ours produces a flipped
/// output to measure the mirror half against; carrying it must wait for a measured producer.
fn transform_degrees(t: sctk::reexports::client::protocol::wl_output::Transform) -> i32 {
use sctk::reexports::client::protocol::wl_output::Transform;
match t {
Transform::Normal => 0,
Transform::_90 => 90,
Transform::_180 => 180,
Transform::_270 => 270,
Transform::Flipped | Transform::Flipped90 | Transform::Flipped180
| Transform::Flipped270 => {
static FLIPPED_WARNED: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
if !FLIPPED_WARNED.swap(true, std::sync::atomic::Ordering::Relaxed) {
log::warn!(
"an output reports a flipped transform ({t:?}); only its rotation is \
corrected, the mirror is not"
);
}
match t {
Transform::Flipped90 => 90,
Transform::Flipped180 => 180,
Transform::Flipped270 => 270,
_ => 0,
}
}
_ => 0,
}
}
fn collect_wayland_displays(conn: &Connection) -> ResultType<Vec<WaylandDisplayInfo>> {
struct WaylandEnv {
registry_state: RegistryState,
output_state: OutputState,
}
impl OutputHandler for WaylandEnv {
fn output_state(&mut self) -> &mut OutputState {
&mut self.output_state
}
fn new_output(&mut self, _: &Connection, _: &QueueHandle<Self>, _: WlOutput) {}
fn update_output(&mut self, _: &Connection, _: &QueueHandle<Self>, _: WlOutput) {}
fn output_destroyed(&mut self, _: &Connection, _: &QueueHandle<Self>, _: WlOutput) {}
}
impl ProvidesRegistryState for WaylandEnv {
fn registry(&mut self) -> &mut RegistryState {
&mut self.registry_state
}
sctk::registry_handlers![OutputState];
}
sctk::delegate_output!(WaylandEnv);
sctk::delegate_registry!(WaylandEnv);
let (globals, mut event_queue) = globals::registry_queue_init(conn)?;
let queue_handle = event_queue.handle();
let registry_state = RegistryState::new(&globals);
let output_state = OutputState::new(&globals, &queue_handle);
let mut environment = WaylandEnv {
registry_state,
output_state,
};
event_queue.roundtrip(&mut environment)?;
let outputs: Vec<_> = environment.output_state.outputs().collect();
let mut display_infos = Vec::new();
for output in outputs {
if let Some(output_data) = output.data::<OutputData>() {
output_data.with_output_info(|info| {
if let Some(mode) = info.modes.iter().find(|m| m.current) {
// wlroots compositors leave wl_output.geometry at (0, 0) for every output and
// publish the real layout only through xdg-output, so taking `location` there
// stacks the whole desktop on the origin. Mutter fills both, so this stays a
// no-op on GNOME.
let (x, y) = info.logical_position.unwrap_or(info.location);
let (width, height) = mode.dimensions;
let refresh_rate = mode.refresh_rate;
let name = info.name.clone().unwrap_or_default();
let logical_size = info.logical_size;
let transform = transform_degrees(info.transform);
display_infos.push(WaylandDisplayInfo {
name,
x,
y,
width,
height,
logical_size,
refresh_rate,
transform,
});
}
});
}
}
Ok(display_infos)
}
/// Escape a string for safe use in shell commands by wrapping in single quotes.
///
/// This function handles the edge case of single quotes within the string by:
/// 1. Ending the current single-quoted section
/// 2. Adding an escaped single quote
/// 3. Starting a new single-quoted section
///
/// Example: "it's here" -> "'it'\''s here'"
#[inline]
pub fn shell_quote(s: &str) -> String {
format!("'{}'", s.replace("'", "'\\''"))
}
/// Get the current user's home directory via getpwuid (trusted source).
///
/// This function uses the system's password database (via `getpwuid`) to retrieve
/// the home directory, avoiding the security risk of relying on the `HOME`
/// environment variable which can be manipulated by untrusted input.
///
/// # Returns
/// - `Some(PathBuf)` if the home directory was found and exists
/// - `None` if the user lookup failed or the directory doesn't exist
///
/// # Security
/// This function is designed to be safe against confused-deputy attacks where
/// an attacker might manipulate environment variables to influence privileged
/// operations.
pub fn get_home_dir_trusted() -> Option<PathBuf> {
let uid = get_current_uid();
match get_user_by_uid(uid) {
Some(user) => {
let home = user.home_dir();
if Path::is_dir(home) {
Some(PathBuf::from(home))
} else {
log::warn!(
"Home directory for uid {} does not exist or is not a directory: {:?}",
uid,
home
);
None
}
}
None => {
log::warn!("Failed to get user info for uid {}", uid);
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_transform_degrees_maps_all_eight_variants() {
use sctk::reexports::client::protocol::wl_output::Transform;
// Flipped variants report their rotation: the frame still needs that turn to read
// upright, and the mirror half has no producer among desktop compositors to test.
for (t, deg) in [
(Transform::Normal, 0),
(Transform::_90, 90),
(Transform::_180, 180),
(Transform::_270, 270),
(Transform::Flipped, 0),
(Transform::Flipped90, 90),
(Transform::Flipped180, 180),
(Transform::Flipped270, 270),
] {
assert_eq!(transform_degrees(t), deg, "{t:?}");
}
}
#[test]
fn test_display_info_without_transform_defaults_to_zero() {
// A snapshot serialized by an older probe child carries no transform field; it must
// deserialize with 0 rather than fail, or a greeter-side child update becomes a
// lockstep upgrade.
let old = r#"{"name":"HDMI-1","x":0,"y":0,"width":1920,"height":1080,"logical_size":null,"refresh_rate":60}"#;
let info: WaylandDisplayInfo = serde_json::from_str(old).unwrap();
assert_eq!(info.transform, 0);
let roundtrip: WaylandDisplayInfo =
serde_json::from_str(&serde_json::to_string(&info).unwrap()).unwrap();
assert_eq!(roundtrip.transform, 0);
}
#[test]
fn test_run_cmds_trim_newline() {
assert_eq!(run_cmds_trim_newline("echo -n 123").unwrap(), "123");
assert_eq!(run_cmds_trim_newline("echo 123").unwrap(), "123");
assert_eq!(
run_cmds_trim_newline("whoami").unwrap() + "\n",
run_cmds("whoami").unwrap()
);
}
/// Test get_home_dir_trusted: returns valid path and ignores HOME env var
#[test]
fn test_get_home_dir_trusted() {
let original_home = std::env::var("HOME").ok();
// Set HOME to a fake/malicious path
std::env::set_var("HOME", "/tmp/fake_malicious_home");
let result = get_home_dir_trusted();
// Restore original HOME
match original_home {
Some(home) => std::env::set_var("HOME", home),
None => std::env::remove_var("HOME"),
}
// Verify: returns valid path that is NOT the fake HOME
if let Some(path) = result {
assert!(path.is_absolute(), "Path should be absolute: {:?}", path);
assert!(path.is_dir(), "Path should be a directory: {:?}", path);
assert_ne!(
path.to_string_lossy(),
"/tmp/fake_malicious_home",
"Should not use HOME env var"
);
}
}
/// Test shell_quote with normal strings
#[test]
fn test_shell_quote_normal() {
assert_eq!(shell_quote("hello"), "'hello'");
assert_eq!(shell_quote("/home/user"), "'/home/user'");
}
/// Test shell_quote with spaces
#[test]
fn test_shell_quote_spaces() {
assert_eq!(shell_quote("/home/my user/file"), "'/home/my user/file'");
assert_eq!(shell_quote("path with spaces"), "'path with spaces'");
}
/// Test shell_quote with single quotes (the tricky case)
#[test]
fn test_shell_quote_single_quotes() {
assert_eq!(shell_quote("it's"), "'it'\\''s'");
assert_eq!(shell_quote("don't stop"), "'don'\\''t stop'");
}
/// Test shell_quote with shell metacharacters
#[test]
fn test_shell_quote_metacharacters() {
// These should all be safely quoted
assert_eq!(shell_quote("test;rm -rf /"), "'test;rm -rf /'");
assert_eq!(shell_quote("$(whoami)"), "'$(whoami)'");
assert_eq!(shell_quote("`id`"), "'`id`'");
assert_eq!(shell_quote("a && b"), "'a && b'");
assert_eq!(shell_quote("a | b"), "'a | b'");
}
}

View File

@@ -1,349 +0,0 @@
//! Isolated Wayland display probe: enumerates a compositor over a runtime-directory socket when
//! the environment names no endpoint (a greeter's `--server` and the root service are given no
//! compositor variables). Gated behind the `wayland_probe` feature so the base Wayland path is
//! untouched — a consumer that does not build the DRM login-screen backend never compiles this,
//! and `get_wayland_displays` keeps its original behavior of returning the connect error.
use super::{collect_wayland_displays, get_values_of_seat0_with_gdm_wayland, WaylandDisplayInfo};
use hbb_common::{bail, ResultType};
use sctk::reexports::client::Connection;
use std::path::{Path, PathBuf};
const RUNTIME_DIR_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
/// The argument the consumer binary must dispatch to `wayland_display_probe_child_main` before
/// any other startup work; see that function for why the probe is its own process.
pub const WAYLAND_DISPLAY_PROBE_ARG: &str = "--wayland-display-probe";
/// First stdout line of a probe child. A binary that does not dispatch the arg never prints it.
const WAYLAND_PROBE_MAGIC: &str = "wayland-display-probe-v1";
/// Latched on a failed handshake: a consumer that does not dispatch the probe arg runs its NORMAL
/// startup instead, and this path re-enters every enumeration cycle.
static PROBE_UNSUPPORTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
static RUNTIME_DIR_PROBE_BUSY: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
/// Clears the in-flight flag on every exit path of the parent, error arms included.
struct ProbeBusyGuard;
impl Drop for ProbeBusyGuard {
fn drop(&mut self) {
RUNTIME_DIR_PROBE_BUSY.store(false, std::sync::atomic::Ordering::Release);
}
}
/// Entry point of the isolated probe process. The consumer binary dispatches
/// `WAYLAND_DISPLAY_PROBE_ARG` here first, before config, logging or any other startup work.
///
/// Its own process because the release profile builds with panic=abort: sctk panics on malformed
/// protocol bytes, and in-process that abort takes the whole server down. Here it takes down only
/// this child, which the parent reports as a failed probe. The seat0 lookup also runs in here, so
/// the parent's single deadline bounds the loginctl reads too.
pub fn wayland_display_probe_child_main() -> ! {
use std::io::Write;
// The handshake first, so the parent can tell this entry point ran and not a consumer binary
// that fell through to its normal startup.
println!("{WAYLAND_PROBE_MAGIC}");
let _ = std::io::stdout().flush();
let code = match seat0_runtime_dir()
.and_then(|dir| {
drop_to_dir_owner(&dir)?;
probe_runtime_dir(&dir)
})
.and_then(|displays| serde_json::to_string(&displays).map_err(anyhow::Error::from))
{
Ok(json) => {
println!("{json}");
0
}
Err(err) => {
eprintln!("{err:#}");
1
}
};
let _ = std::io::stdout().flush();
std::process::exit(code)
}
static ENDPOINT_WAS_NAMED: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
/// Whether the environment ever named a wayland endpoint in this process. Empty is not a name.
///
/// Read before `connect_to_env`, which removes `WAYLAND_SOCKET` from the environment on both its
/// success and its bad-fd path; and latched, so a consumed variable cannot turn a process that WAS
/// pointed at a compositor into one that is free to go looking for another.
pub(super) fn env_names_wayland_endpoint() -> bool {
use std::sync::atomic::Ordering;
let named = ["WAYLAND_DISPLAY", "WAYLAND_SOCKET"]
.iter()
.any(|key| std::env::var_os(key).is_some_and(|value| !value.is_empty()));
if named {
ENDPOINT_WAS_NAMED.store(true, Ordering::Release);
}
ENDPOINT_WAS_NAMED.load(Ordering::Acquire)
}
/// The probe parses compositor-controlled protocol data; a root service must not do that as
/// root. Before touching the socket, become the runtime directory's owner — and refuse to probe
/// at all if the drop fails, since staying root is the one unacceptable outcome.
fn drop_to_dir_owner(dir: &Path) -> ResultType<()> {
if unsafe { libc::geteuid() } != 0 {
return Ok(());
}
use std::os::unix::fs::MetadataExt;
let meta = std::fs::metadata(dir)?;
let (uid, gid) = (meta.uid(), meta.gid());
if uid == 0 {
// Root's own session: there is no boundary to cross and nothing to drop to.
return Ok(());
}
unsafe {
if libc::setgroups(0, std::ptr::null()) != 0
|| libc::setgid(gid) != 0
|| libc::setuid(uid) != 0
|| libc::setuid(0) == 0
{
bail!("could not drop privileges for the socket probe");
}
}
Ok(())
}
/// `/run/user/<uid>` of the active seat0 session, a greeter included.
///
/// Derived from the uid rather than read from `XDG_RUNTIME_DIR`: the root service is given no such
/// variable, and `get_home_dir_trusted` refuses to trust the environment for the same reason.
fn seat0_runtime_dir() -> ResultType<PathBuf> {
let uid = get_values_of_seat0_with_gdm_wayland(&[1]).remove(0);
if uid.is_empty() || !uid.bytes().all(|b| b.is_ascii_digit()) {
bail!("no active seat0 session to take a runtime directory from");
}
Ok(PathBuf::from(format!("/run/user/{uid}")))
}
/// The wayland sockets present in `dir`, lowest display number first.
///
/// Scanned rather than guessed: `wl_display_add_socket_auto` takes the first FREE name up to
/// `wayland-32`, and a greeter is where leftovers accumulate across compositor restarts. Only that
/// name pattern, because the same directory holds pipewire and dbus sockets.
fn wayland_sockets_in(dir: &Path) -> Vec<PathBuf> {
use std::os::unix::fs::FileTypeExt;
let mut paths: Vec<PathBuf> = match std::fs::read_dir(dir) {
Ok(entries) => entries
.flatten()
.filter(|entry| {
let name = entry.file_name();
let name = name.to_string_lossy();
name.starts_with("wayland-")
&& !name.ends_with(".lock")
&& entry.file_type().map(|t| t.is_socket()).unwrap_or(false)
})
.map(|entry| entry.path())
.collect(),
Err(_) => Vec::new(),
};
paths.sort_by_key(|path| {
path.file_name()
.and_then(|name| name.to_str())
.and_then(|name| name.strip_prefix("wayland-"))
.and_then(|number| number.parse::<u32>().ok())
.unwrap_or(u32::MAX)
});
paths
}
/// Enumerate through a socket in the seat0 runtime directory, for the case where nothing named an
/// endpoint: a greeter's `--server` and the root service are given no compositor variables, so
/// nothing tells the enumerator where a compositor that IS running lives. An endpoint that WAS
/// named and failed must not silently reattach to a different compositor.
///
/// In a subprocess and bounded, because the caller holds a process-wide lock across the call while
/// `connect(2)` parks on a full backlog and sctk's roundtrip polls without a deadline; and because
/// sctk panics on malformed output events, which the release profile's panic=abort turns into an
/// abort of the whole server. A child dies alone, and on the deadline it is killed instead of
/// leaking a thread. The seat0 lookup runs inside the child, under the same deadline.
pub(super) fn wayland_displays_from_runtime_dir(
named_endpoint: bool,
) -> ResultType<Vec<WaylandDisplayInfo>> {
use std::sync::atomic::Ordering;
if named_endpoint {
bail!("an explicit wayland endpoint is set and did not connect");
}
if PROBE_UNSUPPORTED.load(Ordering::Acquire) {
bail!("this binary does not dispatch {WAYLAND_DISPLAY_PROBE_ARG}");
}
if RUNTIME_DIR_PROBE_BUSY.swap(true, Ordering::AcqRel) {
bail!("an earlier probe has not returned");
}
let _busy = ProbeBusyGuard;
let exe = std::env::current_exe()?;
// Its own process group, so the deadline can kill loginctl descendants along with the child,
// and so no surviving descendant can hold the pipes open past the reads below.
use std::os::unix::process::CommandExt;
let mut child = std::process::Command::new(exe)
.arg(WAYLAND_DISPLAY_PROBE_ARG)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.process_group(0)
.spawn()?;
let probe_pgid = child.id() as libc::pid_t;
let kill_probe_group = || unsafe {
let _ = libc::kill(-probe_pgid, libc::SIGKILL);
};
let deadline = std::time::Instant::now() + RUNTIME_DIR_PROBE_TIMEOUT;
let status = loop {
match child.try_wait()? {
Some(status) => {
kill_probe_group();
break status;
}
None if std::time::Instant::now() >= deadline => {
kill_probe_group();
// The direct pid too, not only its group: if the child left the group its own
// kill would miss it, and the wait below would then block on a live child. A
// pid-targeted SIGKILL is uncatchable, so wait() is bounded either way.
let _ = child.kill();
let _ = child.wait();
// An unwired binary runs its normal startup, and a long-running one (the
// server itself) lands HERE rather than at the handshake check below — latch
// on this path too, or every enumeration cycle spawns a full consumer
// process. Judged by what the child already wrote: a real probe prints the
// magic line first and flushes, so its absence after a whole deadline means
// this is not a probe. Only buffered bytes are read — a blocking read could
// hang on a grandchild that inherited the write end.
match first_buffered_line(child.stdout.take()) {
// The pipe could not be inspected at all: no evidence, no latch.
None => {
bail!("the wayland socket probe timed out and its output was uninspectable")
}
Some(head) if head.as_deref() == Some(WAYLAND_PROBE_MAGIC) => {
bail!("the wayland socket probe did not answer and was killed");
}
Some(_) => {
PROBE_UNSUPPORTED.store(true, Ordering::Release);
bail!("the wayland socket probe timed out without the handshake; probe disabled");
}
}
}
None => std::thread::sleep(std::time::Duration::from_millis(25)),
}
};
// Drained non-blocking, not read_to_string: the child exited so its output is already
// buffered, but a descendant that escaped the process group could still hold a write end open
// and an EOF-seeking read would then hang here forever.
let stdout = drain_nonblocking(child.stdout.take()).unwrap_or_default();
let stderr = drain_nonblocking(child.stderr.take()).unwrap_or_default();
let mut lines = stdout.lines();
if lines.next() != Some(WAYLAND_PROBE_MAGIC) {
// Not a probe: the binary ran its normal startup. Latch, or this path would spawn one
// full consumer process per enumeration cycle.
PROBE_UNSUPPORTED.store(true, Ordering::Release);
bail!("this binary does not dispatch {WAYLAND_DISPLAY_PROBE_ARG}; probe disabled");
}
if !status.success() {
let detail = stderr.trim();
if detail.is_empty() {
// panic=abort or a signal leaves stderr empty; the status is then the only cause.
bail!("wayland socket probe failed: {status}");
}
bail!("wayland socket probe failed ({status}): {detail}");
}
let displays: Vec<WaylandDisplayInfo> =
match serde_json::from_str(lines.next().unwrap_or_default()) {
Ok(displays) => displays,
Err(err) => bail!("wayland socket probe answered a malformed list: {err}"),
};
// The child already refuses an empty list; refuse it here too, so a truncated pipe cannot
// become a cached-for-life empty enumeration.
if displays.is_empty() {
bail!("wayland socket probe returned no outputs");
}
log::debug!(
"wayland: {} output(s) via the probe subprocess",
displays.len()
);
Ok(displays)
}
/// Everything already buffered in the pipe, read strictly non-blocking and capped: a descendant
/// that escaped the probe's process group can hold a write end open, so a blocking read (even
/// after the child exits) could hang the enumeration forever. `None` means the pipe could not be
/// INSPECTED (missing handle or fcntl failure) and must not be read as evidence of anything;
/// `Some` is whatever bytes were buffered, whether or not EOF arrived.
fn drain_nonblocking<R: std::io::Read + std::os::fd::AsRawFd>(pipe: Option<R>) -> Option<String> {
let mut pipe = pipe?;
let fd = pipe.as_raw_fd();
unsafe {
let flags = libc::fcntl(fd, libc::F_GETFL);
if flags < 0 || libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) < 0 {
return None;
}
}
// Capped so a descendant that keeps writing cannot spin this read forever.
const CAP: usize = 64 * 1024;
let mut out = Vec::new();
let mut buf = [0u8; 4096];
loop {
match pipe.read(&mut buf) {
Ok(0) => break, // EOF: the write end is fully closed
Ok(n) => {
out.extend_from_slice(&buf[..n]);
if out.len() >= CAP {
break;
}
}
Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue,
// WouldBlock: what is buffered is drained (a descendant may still hold the writer).
// Any other error: stop with what we have.
Err(_) => break,
}
}
Some(String::from_utf8_lossy(&out).into_owned())
}
/// The first line the child buffered, for the timeout latch decision. `Some(None)` is an
/// inspected-but-empty buffer (genuine absence of the handshake); outer `None` is uninspectable.
fn first_buffered_line(pipe: Option<std::process::ChildStdout>) -> Option<Option<String>> {
drain_nonblocking(pipe).map(|s| s.lines().next().map(str::to_owned))
}
fn probe_runtime_dir(dir: &Path) -> ResultType<Vec<WaylandDisplayInfo>> {
use std::os::unix::net::UnixStream;
let mut errs = Vec::new();
for path in wayland_sockets_in(dir) {
match UnixStream::connect(&path)
.map_err(anyhow::Error::from)
.and_then(|s| Connection::from_socket(s).map_err(anyhow::Error::from))
.and_then(|conn| collect_wayland_displays(&conn))
{
// The caller caches an empty list as ground truth for the process lifetime, and a
// compositor still probing its monitors is exactly what this path connects to.
Ok(displays) if displays.is_empty() => {
errs.push(format!("{}: no outputs yet", path.display()))
}
Ok(displays) => {
// Which socket answered, when nothing in the environment named one.
log::debug!(
"wayland: {} output(s) from {}, found by scanning",
displays.len(),
path.display()
);
return Ok(displays);
}
Err(err) => errs.push(format!("{}: {err}", path.display())),
}
}
bail!(
"no usable wayland socket in {} ({})",
dir.display(),
if errs.is_empty() {
"none present".to_owned()
} else {
errs.join("; ")
}
)
}

View File

@@ -1,55 +0,0 @@
use hbb_common::ResultType;
use osascript;
use serde_derive::{Deserialize, Serialize};
#[derive(Serialize)]
struct AlertParams {
title: String,
message: String,
alert_type: String,
buttons: Vec<String>,
}
#[derive(Deserialize)]
struct AlertResult {
#[serde(rename = "buttonReturned")]
button: String,
}
/// Firstly run the specified app, then alert a dialog. Return the clicked button value.
///
/// # Arguments
///
/// * `app` - The app to execute the script.
/// * `alert_type` - Alert type. . informational, warning, critical
/// * `title` - The alert title.
/// * `message` - The alert message.
/// * `buttons` - The buttons to show.
pub fn alert(
app: String,
alert_type: String,
title: String,
message: String,
buttons: Vec<String>,
) -> ResultType<String> {
let script = osascript::JavaScript::new(&format!(
"
var App = Application('{}');
App.includeStandardAdditions = true;
return App.displayAlert($params.title, {{
message: $params.message,
'as': $params.alert_type,
buttons: $params.buttons,
}});
",
app
));
let result: AlertResult = script.execute_with_params(AlertParams {
title,
message,
alert_type,
buttons,
})?;
Ok(result.button)
}

View File

@@ -1,82 +0,0 @@
#[cfg(target_os = "linux")]
pub mod linux;
#[cfg(target_os = "macos")]
pub mod macos;
#[cfg(target_os = "windows")]
pub mod windows;
#[cfg(not(debug_assertions))]
use hbb_common::{config::Config, log};
#[cfg(not(debug_assertions))]
use std::process::exit;
#[cfg(not(debug_assertions))]
static mut GLOBAL_CALLBACK: Option<Box<dyn Fn()>> = None;
#[cfg(not(debug_assertions))]
extern "C" fn breakdown_signal_handler(sig: i32) {
let mut stack = vec![];
backtrace::trace(|frame| {
backtrace::resolve_frame(frame, |symbol| {
if let Some(name) = symbol.name() {
stack.push(name.to_string());
}
});
true // keep going to the next frame
});
let mut info = String::default();
if stack.iter().any(|s| {
s.contains(&"nouveau_pushbuf_kick")
|| s.to_lowercase().contains("nvidia")
|| s.contains("gdk_window_end_draw_frame")
|| s.contains("glGetString")
}) {
Config::set_option("allow-always-software-render".to_string(), "Y".to_string());
info = "Always use software rendering will be set.".to_string();
log::info!("{}", info);
}
if stack.iter().any(|s| {
s.to_lowercase().contains("nvidia")
|| s.to_lowercase().contains("amf")
|| s.to_lowercase().contains("mfx")
|| s.contains("cuProfilerStop")
}) {
Config::set_option("enable-hwcodec".to_string(), "N".to_string());
info = "Perhaps hwcodec causing the crash, disable it first".to_string();
log::info!("{}", info);
}
log::error!(
"Got signal {} and exit. stack:\n{}",
sig,
stack.join("\n").to_string()
);
if !info.is_empty() {
#[cfg(target_os = "linux")]
linux::system_message(
"RustDesk",
&format!("Got signal {} and exit.{}", sig, info),
true,
)
.ok();
}
unsafe {
#[allow(static_mut_refs)]
if let Some(callback) = &GLOBAL_CALLBACK {
callback()
}
}
exit(0);
}
#[cfg(not(debug_assertions))]
pub fn register_breakdown_handler<T>(callback: T)
where
T: Fn() + 'static,
{
unsafe {
GLOBAL_CALLBACK = Some(Box::new(callback));
libc::signal(libc::SIGSEGV, breakdown_signal_handler as _);
}
}

View File

@@ -1,198 +0,0 @@
use std::{
collections::VecDeque,
sync::{Arc, Mutex},
time::Instant,
};
use winapi::{
shared::minwindef::{DWORD, FALSE, TRUE},
um::{
handleapi::CloseHandle,
pdh::{
PdhAddEnglishCounterA, PdhCloseQuery, PdhCollectQueryData, PdhCollectQueryDataEx,
PdhGetFormattedCounterValue, PdhOpenQueryA, PDH_FMT_COUNTERVALUE, PDH_FMT_DOUBLE,
PDH_HCOUNTER, PDH_HQUERY,
},
synchapi::{CreateEventA, WaitForSingleObject},
sysinfoapi::VerSetConditionMask,
winbase::{VerifyVersionInfoW, INFINITE, WAIT_OBJECT_0},
winnt::{
HANDLE, OSVERSIONINFOEXW, VER_BUILDNUMBER, VER_GREATER_EQUAL, VER_MAJORVERSION,
VER_MINORVERSION, VER_SERVICEPACKMAJOR, VER_SERVICEPACKMINOR,
},
},
};
lazy_static::lazy_static! {
static ref CPU_USAGE_ONE_MINUTE: Arc<Mutex<Option<(f64, Instant)>>> = Arc::new(Mutex::new(None));
}
// https://github.com/mgostIH/process_list/blob/master/src/windows/mod.rs
#[repr(transparent)]
pub struct RAIIHandle(pub HANDLE);
impl Drop for RAIIHandle {
fn drop(&mut self) {
// This never gives problem except when running under a debugger.
unsafe { CloseHandle(self.0) };
}
}
#[repr(transparent)]
pub(self) struct RAIIPDHQuery(pub PDH_HQUERY);
impl Drop for RAIIPDHQuery {
fn drop(&mut self) {
unsafe { PdhCloseQuery(self.0) };
}
}
pub fn start_cpu_performance_monitor() {
// Code from:
// https://learn.microsoft.com/en-us/windows/win32/perfctrs/collecting-performance-data
// https://learn.microsoft.com/en-us/windows/win32/api/pdh/nf-pdh-pdhcollectquerydataex
// Why value lower than taskManager:
// https://aaron-margosis.medium.com/task-managers-cpu-numbers-are-all-but-meaningless-2d165b421e43
// Therefore we should compare with Precess Explorer rather than taskManager
let f = || unsafe {
// load avg or cpu usage, test with prime95.
// Prefer cpu usage because we can get accurate value from Precess Explorer.
// const COUNTER_PATH: &'static str = "\\System\\Processor Queue Length\0";
const COUNTER_PATH: &'static str = "\\Processor(_total)\\% Processor Time\0";
const SAMPLE_INTERVAL: DWORD = 2; // 2 second
let mut ret;
let mut query: PDH_HQUERY = std::mem::zeroed();
ret = PdhOpenQueryA(std::ptr::null() as _, 0, &mut query);
if ret != 0 {
log::error!("PdhOpenQueryA failed: 0x{:X}", ret);
return;
}
let _query = RAIIPDHQuery(query);
let mut counter: PDH_HCOUNTER = std::mem::zeroed();
ret = PdhAddEnglishCounterA(query, COUNTER_PATH.as_ptr() as _, 0, &mut counter);
if ret != 0 {
log::error!("PdhAddEnglishCounterA failed: 0x{:X}", ret);
return;
}
ret = PdhCollectQueryData(query);
if ret != 0 {
log::error!("PdhCollectQueryData failed: 0x{:X}", ret);
return;
}
let mut _counter_type: DWORD = 0;
let mut counter_value: PDH_FMT_COUNTERVALUE = std::mem::zeroed();
let event = CreateEventA(std::ptr::null_mut(), FALSE, FALSE, std::ptr::null() as _);
if event.is_null() {
log::error!("CreateEventA failed");
return;
}
let _event: RAIIHandle = RAIIHandle(event);
ret = PdhCollectQueryDataEx(query, SAMPLE_INTERVAL, event);
if ret != 0 {
log::error!("PdhCollectQueryDataEx failed: 0x{:X}", ret);
return;
}
let mut queue: VecDeque<f64> = VecDeque::new();
let mut recent_valid: VecDeque<bool> = VecDeque::new();
loop {
// latest one minute
if queue.len() == 31 {
queue.pop_front();
}
if recent_valid.len() == 31 {
recent_valid.pop_front();
}
// allow get value within one minute
if queue.len() > 0 && recent_valid.iter().filter(|v| **v).count() > queue.len() / 2 {
let sum: f64 = queue.iter().map(|f| f.to_owned()).sum();
let avg = sum / (queue.len() as f64);
*CPU_USAGE_ONE_MINUTE.lock().unwrap() = Some((avg, Instant::now()));
} else {
*CPU_USAGE_ONE_MINUTE.lock().unwrap() = None;
}
if WAIT_OBJECT_0 != WaitForSingleObject(event, INFINITE) {
recent_valid.push_back(false);
continue;
}
if PdhGetFormattedCounterValue(
counter,
PDH_FMT_DOUBLE,
&mut _counter_type,
&mut counter_value,
) != 0
|| counter_value.CStatus != 0
{
recent_valid.push_back(false);
continue;
}
queue.push_back(counter_value.u.doubleValue().clone());
recent_valid.push_back(true);
}
};
use std::sync::Once;
static ONCE: Once = Once::new();
ONCE.call_once(|| {
std::thread::spawn(f);
});
}
pub fn cpu_uage_one_minute() -> Option<f64> {
let v = CPU_USAGE_ONE_MINUTE.lock().unwrap().clone();
if let Some((v, instant)) = v {
if instant.elapsed().as_secs() < 30 {
return Some(v);
}
}
None
}
pub fn sync_cpu_usage(cpu_usage: Option<f64>) {
let v = match cpu_usage {
Some(cpu_usage) => Some((cpu_usage, Instant::now())),
None => None,
};
*CPU_USAGE_ONE_MINUTE.lock().unwrap() = v;
log::info!("cpu usage synced: {:?}", cpu_usage);
}
// https://learn.microsoft.com/en-us/windows/win32/sysinfo/targeting-your-application-at-windows-8-1
// https://github.com/nodejs/node-convergence-archive/blob/e11fe0c2777561827cdb7207d46b0917ef3c42a7/deps/uv/src/win/util.c#L780
pub fn is_windows_version_or_greater(
os_major: u32,
os_minor: u32,
build_number: u32,
service_pack_major: u32,
service_pack_minor: u32,
) -> bool {
let mut osvi: OSVERSIONINFOEXW = unsafe { std::mem::zeroed() };
osvi.dwOSVersionInfoSize = std::mem::size_of::<OSVERSIONINFOEXW>() as DWORD;
osvi.dwMajorVersion = os_major as _;
osvi.dwMinorVersion = os_minor as _;
osvi.dwBuildNumber = build_number as _;
osvi.wServicePackMajor = service_pack_major as _;
osvi.wServicePackMinor = service_pack_minor as _;
let result = unsafe {
let mut condition_mask = 0;
let op = VER_GREATER_EQUAL;
condition_mask = VerSetConditionMask(condition_mask, VER_MAJORVERSION, op);
condition_mask = VerSetConditionMask(condition_mask, VER_MINORVERSION, op);
condition_mask = VerSetConditionMask(condition_mask, VER_BUILDNUMBER, op);
condition_mask = VerSetConditionMask(condition_mask, VER_SERVICEPACKMAJOR, op);
condition_mask = VerSetConditionMask(condition_mask, VER_SERVICEPACKMINOR, op);
VerifyVersionInfoW(
&mut osvi as *mut OSVERSIONINFOEXW,
VER_MAJORVERSION
| VER_MINORVERSION
| VER_BUILDNUMBER
| VER_SERVICEPACKMAJOR
| VER_SERVICEPACKMINOR,
condition_mask,
)
};
result == TRUE
}

View File

@@ -1 +0,0 @@
include!(concat!(env!("OUT_DIR"), "/protos/mod.rs"));

View File

@@ -30,7 +30,6 @@ lazy_static = "1.4"
serde = "1.0"
serde_derive = "1.0"
hbb_common = { path = "../hbb_common" }
base = { path = "../base" }
parking_lot = {version = "0.12"}
[target.'cfg(any(target_os = "linux", target_os = "macos"))'.dependencies]

View File

@@ -60,8 +60,10 @@ pub(super) fn validate_file_name(name: &str) -> Result<(), CliprdrError> {
description: "clipboard file name is not a normalized relative path".to_string(),
});
}
base::fs::validate_file_name_no_traversal(name).map_err(|error| CliprdrError::InvalidRequest {
description: error.to_string(),
hbb_common::fs::validate_file_name_no_traversal(name).map_err(|error| {
CliprdrError::InvalidRequest {
description: error.to_string(),
}
})
}

View File

@@ -2,8 +2,7 @@ use crate::{
platform::unix::{FileDescription, FileType, BLOCK_SIZE},
send_data, ClipboardFile, CliprdrError, ProgressPercent,
};
use base::fs::join_validated_path;
use hbb_common::{allow_err, log, tokio::time::Instant};
use hbb_common::{allow_err, fs::join_validated_path, log, tokio::time::Instant};
use std::{
cmp::min,
fs::{File, FileTimes, OpenOptions},

View File

@@ -25,7 +25,6 @@ log = "0.4"
rdev = { git = "https://github.com/rustdesk-org/rdev" }
tfc = { git = "https://github.com/rustdesk-org/The-Fat-Controller", branch = "history/rebase_upstream_20240722" }
hbb_common = { path = "../hbb_common" }
base = { path = "../base" }
[features]
with_serde = ["serde", "serde_derive"]

View File

@@ -122,7 +122,7 @@ impl Enigo {
impl Default for Enigo {
fn default() -> Self {
let is_x11 = base::platform::linux::is_x11_or_headless();
let is_x11 = hbb_common::platform::linux::is_x11_or_headless();
Self {
is_x11,
tfc: if is_x11 {

View File

@@ -12,7 +12,7 @@ build = "build.rs"
brotli = "3.4"
dirs = "5.0"
md5 = "0.7"
winapi = { version = "0.3", features = ["winbase", "libloaderapi"] }
winapi = { version = "0.3", features = ["winbase"] }
[target.'cfg(target_os = "windows")'.dependencies]
windows = { version = "0.61", features = [

View File

@@ -15,29 +15,15 @@ encoding = 'utf-8'
# output: {path: (compressed_data, file_md5)}
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:
def generate_md5_table(folder: str, level) -> dict:
res: dict = dict()
skip = normalize(exclude) if exclude else None
excluded = False
# os.curdir is the literal ".", so restoring it left us inside `folder`.
curdir = os.getcwd()
curdir = os.curdir
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()
@@ -47,16 +33,11 @@ def generate_md5_table(folder: str, level, exclude: str = None) -> 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):
write_blob(md5_table, os.path.join(output_folder, "data.bin"), exe)
def write_blob(md5_table: dict, output_path: str, exe: str):
output_path = os.path.join(output_folder, "data.bin")
with open(output_path, "wb") as f:
f.write("rustdesk".encode(encoding=encoding))
for path in md5_table.keys():
@@ -111,14 +92,6 @@ 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 './')
@@ -127,29 +100,14 @@ 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)
try:
in_source_folder = os.path.commonpath([folder_path, exe]) == folder_path
except ValueError:
in_source_folder = False
if not in_source_folder:
if not exe.startswith(os.path.abspath(folder)):
print("The executable must locate in source folder")
exit(-1)
exe = '.' + exe[len(folder_path):]
exe = '.' + exe[len(os.path.abspath(folder)):]
print("Executable path: " + exe)
print("Compression level: " + str(options.level))
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)
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)

View File

@@ -1,22 +1,15 @@
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");
// 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";
#[cfg(not(windows))]
const BIN_DATA: &[u8] = &[];
// 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;
@@ -31,172 +24,12 @@ 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 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))
impl Default for BinaryReader {
fn default() -> Self {
let (files, exe) = BinaryReader::read();
Self { files, exe }
}
}
@@ -235,6 +68,59 @@ 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;
@@ -251,155 +137,3 @@ 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());
}
}

View File

@@ -5,7 +5,7 @@ use std::{
process::{Command, Stdio},
};
use bin_reader::{normalize_path, BinaryReader};
use bin_reader::BinaryReader;
pub mod bin_reader;
#[cfg(windows)]
@@ -17,24 +17,11 @@ 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;
@@ -63,93 +50,13 @@ fn is_timestamp_matches(dir: &Path, ts: &mut u64) -> bool {
false
}
fn write_meta(dir: &Path, ts: u64, package_paths: &[String]) {
fn write_meta(dir: &Path, ts: u64) {
let meta_file = dir.join(APP_METADATA_CONFIG);
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));
if ts != 0 {
let content = format!("{}{}", META_LINE_PREFIX_TIMESTAMP, ts);
// Ignore is ok here
let _ = std::fs::write(meta_file, content);
}
// 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(
@@ -164,7 +71,7 @@ fn setup(
} else {
// home dir
if let Some(dir) = dirs::data_local_dir() {
dir.join(app_dir_name(&reader.exe))
dir.join(APP_PREFIX)
} else {
eprintln!("not found data local dir");
return None;
@@ -180,12 +87,10 @@ 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, &metadata_paths);
write_meta(&dir, ts);
#[cfg(windows)]
win::copy_runtime_broker(&dir);
#[cfg(linux)]
@@ -269,7 +174,7 @@ fn execute(path: PathBuf, args: Vec<String>, _ui: bool) {
}
}
fn main() -> Result<(), String> {
fn main() {
let mut args = Vec::new();
let mut arg_exe = Default::default();
let mut i = 0;
@@ -288,7 +193,7 @@ fn main() -> Result<(), String> {
let quick_support = false;
let mut ui = false;
let reader = BinaryReader::new()?;
let reader = BinaryReader::default();
if let Some(exe) = setup(
reader,
None,
@@ -303,7 +208,6 @@ fn main() -> Result<(), String> {
}
execute(exe, args, ui);
}
Ok(())
}
#[cfg(windows)]
@@ -342,27 +246,3 @@ 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);
}
}

View File

@@ -20,7 +20,7 @@ wayland = ["gstreamer", "gstreamer-app", "gstreamer-video", "dbus", "tracing", "
# Depends on `wayland`: the three drm modules live inside the `#[cfg(feature = "wayland")]` arm of
# common/mod.rs, so `scrap/drm` on its own would compile nothing. The root crate happens to always
# enable `scrap/wayland`, which is what hid this.
drm = ["wayland", "base/wayland_probe"]
drm = ["wayland", "hbb_common/wayland_probe"]
mediacodec = ["ndk"]
linux-pkg-config = ["dep:pkg-config"]
hwcodec = ["dep:hwcodec"]
@@ -31,14 +31,13 @@ cfg-if = "1.0"
num_cpus = "1.15"
lazy_static = "1.4"
hbb_common = { path = "../hbb_common" }
base = { path = "../base" }
webm = { git = "https://github.com/rustdesk-org/rust-webm" }
serde = {version="1.0", features=["derive"]}
[dependencies.winapi]
version = "0.3"
default-features = true
features = ["dxgi", "dxgi1_2", "dxgi1_5", "d3d11", "winuser", "winerror", "errhandlingapi", "libloaderapi"]
features = ["dxgi", "dxgi1_2", "dxgi1_5", "dxgi1_6", "d3d11", "winuser", "winerror", "errhandlingapi", "libloaderapi"]
[target.'cfg(target_os = "macos")'.dependencies]
block = "0.1"

View File

@@ -143,7 +143,7 @@ fn test_vpx(
println!(
"{:?} encode: {:?}, {} byte",
codec_id,
time_sum / yuv_count as u32,
time_sum / yuv_count as _,
size / yuv_count
);
@@ -156,7 +156,7 @@ fn test_vpx(
println!(
"{:?} decode: {:?}",
codec_id,
start.elapsed() / yuv_count as u32
start.elapsed() / yuv_count as _
);
}
@@ -212,7 +212,7 @@ fn test_av1(
assert_eq!(av1s.len(), yuv_count);
println!(
"AV1 encode: {:?}, {} byte",
time_sum / yuv_count as u32,
time_sum / yuv_count as _,
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 u32);
println!("AV1 decode: {:?}", start.elapsed() / yuv_count as _);
}
#[cfg(feature = "hwcodec")]

View File

@@ -9,8 +9,7 @@ use jni::{
JavaVM,
};
use base::message_proto::MultiClipboards;
use hbb_common::protobuf::Message;
use hbb_common::{message_proto::MultiClipboards, protobuf::Message};
use jni::errors::{Error as JniError, Result as JniResult};
use lazy_static::lazy_static;
use serde::Deserialize;

View File

@@ -13,9 +13,10 @@ use crate::{EncodeInput, EncodeYuvFormat, Pixfmt};
use hbb_common::{
anyhow::{anyhow, Context},
bytes::Bytes,
log, ResultType,
log,
message_proto::{Chroma, EncodedVideoFrame, EncodedVideoFrames, VideoFrame},
ResultType,
};
use base::message_proto::{Chroma, EncodedVideoFrame, EncodedVideoFrames, VideoFrame};
use std::{ptr, slice};
generate_call_macro!(call_aom, false);

View File

@@ -11,7 +11,7 @@ use nokhwa::{
Camera,
};
use base::message_proto::{DisplayInfo, Resolution};
use hbb_common::message_proto::{DisplayInfo, Resolution};
#[cfg(feature = "vram")]
use crate::AdapterDevice;

View File

@@ -18,10 +18,6 @@ use crate::{
CodecFormat, EncodeInput, EncodeYuvFormat, ImageRgb, ImageTexture,
};
use base::message_proto::{
supported_decoding::PreferCodec, video_frame, Chroma, CodecAbility, EncodedVideoFrames,
SupportedDecoding, SupportedEncoding, VideoFrame,
};
#[cfg(any(
feature = "hwcodec",
feature = "mediacodec",
@@ -34,6 +30,10 @@ use hbb_common::{
bail,
config::{Config, PeerConfig},
lazy_static, log,
message_proto::{
supported_decoding::PreferCodec, video_frame, Chroma, CodecAbility, EncodedVideoFrames,
SupportedDecoding, SupportedEncoding, VideoFrame,
},
sysinfo::System,
ResultType,
};
@@ -269,7 +269,7 @@ impl Encoder {
let preference = most_frequent.enum_value_or(PreferCodec::Auto);
// auto: h265 > h264 > av1/vp9/vp8
let av1_test = Config::get_option(base::config::keys::OPTION_AV1_TEST) != "N";
let av1_test = Config::get_option(hbb_common::config::keys::OPTION_AV1_TEST) != "N";
let mut auto_codec = if av1_useable && av1_test {
CodecFormat::AV1
} else {
@@ -849,7 +849,7 @@ impl Decoder {
#[cfg(any(feature = "hwcodec", feature = "mediacodec"))]
pub fn enable_hwcodec_option() -> bool {
use base::config::keys::OPTION_ENABLE_HWCODEC;
use hbb_common::config::keys::OPTION_ENABLE_HWCODEC;
if !cfg!(target_os = "ios") {
return option2bool(
@@ -861,7 +861,7 @@ pub fn enable_hwcodec_option() -> bool {
}
#[cfg(feature = "vram")]
pub fn enable_vram_option(encode: bool) -> bool {
use base::config::keys::OPTION_ENABLE_HWCODEC;
use hbb_common::config::keys::OPTION_ENABLE_HWCODEC;
if cfg!(windows) {
let enable = option2bool(
@@ -880,13 +880,13 @@ pub fn enable_vram_option(encode: bool) -> bool {
#[cfg(windows)]
pub fn enable_directx_capture() -> bool {
use base::config::keys::OPTION_ENABLE_DIRECTX_CAPTURE as OPTION;
use hbb_common::config::keys::OPTION_ENABLE_DIRECTX_CAPTURE as OPTION;
option2bool(OPTION, &Config::get_option(OPTION))
}
#[cfg(windows)]
pub fn allow_d3d_render() -> bool {
use base::config::keys::OPTION_ALLOW_D3D_RENDER as OPTION;
use hbb_common::config::keys::OPTION_ALLOW_D3D_RENDER as OPTION;
option2bool(OPTION, &hbb_common::config::LocalConfig::get_option(OPTION))
}
@@ -980,7 +980,7 @@ pub fn codec_thread_num(limit: usize) -> usize {
#[cfg(windows)]
{
res = 0;
let percent = base::platform::windows::cpu_uage_one_minute();
let percent = hbb_common::platform::windows::cpu_uage_one_minute();
info = format!("cpu usage: {:?}", percent);
if let Some(pecent) = percent {
if pecent < 100.0 {
@@ -1038,7 +1038,7 @@ fn disable_av1() -> bool {
#[cfg(not(target_os = "ios"))]
pub fn test_av1() {
use base::config::keys::OPTION_AV1_TEST;
use hbb_common::config::keys::OPTION_AV1_TEST;
use hbb_common::rand::Rng;
use std::{sync::Once, time::Duration};

View File

@@ -132,15 +132,7 @@ impl Display {
.map(Display)
.collect::<Vec<_>>();
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);
let displays_dxgi = Self::all_().unwrap_or(Default::default());
// Return gdi displays if dxgi is not supported
if displays_dxgi.is_empty() {
@@ -163,6 +155,7 @@ 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) {
@@ -183,11 +176,11 @@ impl Display {
}
pub fn width(&self) -> usize {
self.0.width().max(0) as usize
self.0.width() as usize
}
pub fn height(&self) -> usize {
self.0.height().max(0) as usize
self.0.height() as usize
}
pub fn name(&self) -> String {
@@ -208,8 +201,7 @@ impl Display {
pub fn is_primary(&self) -> bool {
// https://docs.microsoft.com/en-us/windows/win32/api/wingdi/ns-wingdi-devmodea
// Detached outputs can still report origin (0,0) with a zero size.
self.origin() == (0, 0) && self.width() > 0 && self.height() > 0
self.origin() == (0, 0)
}
#[cfg(feature = "vram")]

View File

@@ -3,11 +3,11 @@ use crate::{
convert::*,
CodecFormat, EncodeInput, ImageFormat, ImageRgb, Pixfmt, HW_STRIDE_ALIGN,
};
use base::message_proto::{EncodedVideoFrame, EncodedVideoFrames, VideoFrame};
use hbb_common::{
anyhow::{anyhow, bail, Context},
bytes::Bytes,
log,
message_proto::{EncodedVideoFrame, EncodedVideoFrames, VideoFrame},
serde_derive::{Deserialize, Serialize},
serde_json, ResultType,
};

View File

@@ -1,6 +1,9 @@
pub use self::vpxcodec::*;
use base::message_proto::{video_frame, Chroma, VideoFrame};
use hbb_common::{bail, log, ResultType};
use hbb_common::{
bail, log,
message_proto::{video_frame, Chroma, VideoFrame},
ResultType,
};
use std::{ffi::c_void, slice};
cfg_if! {
@@ -265,7 +268,7 @@ pub struct EncodeYuvFormat {
#[cfg(x11)]
#[inline]
pub fn is_x11() -> bool {
base::platform::linux::is_x11_or_headless()
hbb_common::platform::linux::is_x11_or_headless()
}
#[cfg(x11)]

View File

@@ -1,8 +1,11 @@
use crate::CodecFormat;
use base::message_proto::{message, video_frame, EncodedVideoFrame, Message};
#[cfg(feature = "hwcodec")]
use hbb_common::anyhow::anyhow;
use hbb_common::{bail, chrono, log, ResultType};
use hbb_common::{
bail, chrono, log,
message_proto::{message, video_frame, EncodedVideoFrame, Message},
ResultType,
};
#[cfg(feature = "hwcodec")]
use hwcodec::mux::{MuxContext, Muxer};
use std::{

View File

@@ -5,8 +5,8 @@
use hbb_common::anyhow::{anyhow, Context};
use hbb_common::log;
use hbb_common::message_proto::{Chroma, EncodedVideoFrame, EncodedVideoFrames, VideoFrame};
use hbb_common::ResultType;
use base::message_proto::{Chroma, EncodedVideoFrame, EncodedVideoFrames, VideoFrame};
use crate::codec::{base_bitrate, codec_thread_num, EncoderApi};
use crate::{EncodeInput, EncodeYuvFormat, GoogleImage, Pixfmt, STRIDE_ALIGN};

View File

@@ -9,11 +9,12 @@ use crate::{
hwcodec::HwCodecConfig,
AdapterDevice, CodecFormat, EncodeInput, EncodeYuvFormat, Pixfmt,
};
use base::message_proto::{EncodedVideoFrame, EncodedVideoFrames, VideoFrame};
use hbb_common::{
anyhow::{anyhow, bail, Context},
bytes::Bytes,
log, ResultType,
log,
message_proto::{EncodedVideoFrame, EncodedVideoFrames, VideoFrame},
ResultType,
};
use hwcodec::{
common::{DataFormat, Driver, MAX_GOP},
@@ -97,7 +98,7 @@ impl EncoderApi for VRamEncoder {
&mut self,
frame: EncodeInput,
ms: i64,
) -> ResultType<base::message_proto::VideoFrame> {
) -> ResultType<hbb_common::message_proto::VideoFrame> {
let (texture, rotation) = frame.texture()?;
if rotation != 0 {
// to-do: support rotation

629
libs/scrap/src/dxgi/hdr.rs Normal file
View File

@@ -0,0 +1,629 @@
//! HDR desktop -> SDR normalization for Desktop Duplication frames.
//!
//! With HDR enabled Windows composes the desktop as linear scRGB in
//! R16G16B16A16_FLOAT, and SDR "white" sits at the user's SDR content
//! brightness (DISPLAYCONFIG_SDR_WHITE_LEVEL) rather than at 1.0. The legacy
//! DuplicateOutput converts that to BGRA8 by clipping, which is the washed-out
//! picture reported for HDR hosts. This pass divides by the SDR white level,
//! clamps, and applies the sRGB transfer, so SDR content comes out exactly as
//! it would from an SDR desktop.
//!
//! It is a normalization, not a tone map: anything brighter than SDR white
//! (HDR video, HDR games) clips to white on the SDR viewer, where the local
//! HDR display would show it brighter than white. A roll-off would have to
//! move SDR white below 1.0 to make headroom, trading the accuracy of the SDR
//! content this pass exists for, so it is deliberately not done.
//!
//! Windows 11 22H2 also composes Advanced Color SDR (WCG) desktops in FP16,
//! but there 1.0 is the display's reference white rather than 80 nits and no
//! SDR white level applies. IDXGIOutput6 tells the two apart, and for a
//! non-HDR output the pass only applies the scRGB -> sRGB transfer.
//!
//! The conversion is automatic and stays on the controlled side on purpose:
//! the controller renders through Flutter external textures, which are 8-bit
//! on every desktop platform, so there is nothing to gain from sending HDR.
//! Real HDR pass-through, if the renderer ever supports it, should follow the
//! Sunshine/Moonlight pattern instead: an `hdr` capability bit advertised by
//! the controller behind an explicit user toggle, negotiated like i444.
use super::ComPtr;
use hbb_common::log;
use std::{
io, mem, ptr,
sync::{atomic::AtomicBool, OnceLock},
time::{Duration, Instant},
};
use winapi::{
ctypes::c_void,
shared::{
basetsd::SIZE_T,
dxgi::{CreateDXGIFactory1, IDXGIFactory1, IID_IDXGIFactory1, DXGI_OUTPUT_DESC},
dxgi1_2::IDXGIOutput1,
dxgi1_6::{IDXGIOutput6, IID_IDXGIOutput6, DXGI_OUTPUT_DESC1},
dxgiformat::DXGI_FORMAT_B8G8R8A8_UNORM,
dxgitype::{DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020, DXGI_SAMPLE_DESC},
minwindef::{FALSE, LPCVOID, UINT, ULONG},
ntdef::{LONG, LPCSTR, WCHAR},
winerror::S_OK,
},
um::{
d3d11::*,
d3dcommon::{ID3DBlob, ID3DInclude, D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST, D3D_SHADER_MACRO},
libloaderapi::{GetProcAddress, LoadLibraryExW, LOAD_LIBRARY_SEARCH_SYSTEM32},
unknwnbase::IUnknown,
wingdi::{
DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME, DISPLAYCONFIG_DEVICE_INFO_HEADER,
DISPLAYCONFIG_MODE_INFO, DISPLAYCONFIG_PATH_INFO, DISPLAYCONFIG_SOURCE_DEVICE_NAME,
DISPLAYCONFIG_TOPOLOGY_ID,
},
winnt::HRESULT,
},
};
/// Set once the tone-map can never work in this process (no d3dcompiler, the
/// shaders do not compile). Capturers then stop asking DXGI for float frames.
/// Device-specific failures are not recorded here; the capturer that hit one
/// re-duplicates without the tone-map on its own.
pub static UNAVAILABLE: AtomicBool = AtomicBool::new(false);
/// Failures no capturer on this machine can recover from, as opposed to
/// device-specific ones that a recreated capturer may not hit again.
pub fn is_permanent(err: &io::Error) -> bool {
err.kind() == io::ErrorKind::Unsupported
}
const VS_SRC: &str = "\
float4 main(uint id : SV_VertexID) : SV_Position {
float2 uv = float2((id << 1) & 2, id & 2);
return float4(uv * float2(2.0, -2.0) + float2(-1.0, 1.0), 0.0, 1.0);
}";
const PS_SRC: &str = "\
Texture2D<float4> src : register(t0);
cbuffer Params : register(b0) { float inv_sdr_white; float3 pad; };
float4 main(float4 pos : SV_Position) : SV_Target {
float3 lin = saturate(src.Load(int3(pos.xy, 0)).rgb * inv_sdr_white);
float3 lo = lin * 12.92;
float3 hi = 1.055 * pow(lin, 1.0 / 2.4) - 0.055;
return float4(lerp(hi, lo, step(lin, 0.0031308)), 1.0);
}";
const OUTPUT_STATE_REFRESH: Duration = Duration::from_secs(1);
pub struct HdrToSdr {
device: ComPtr<ID3D11Device>,
context: ComPtr<ID3D11DeviceContext>,
vs: ComPtr<ID3D11VertexShader>,
ps: ComPtr<ID3D11PixelShader>,
params: ComPtr<ID3D11Buffer>,
target: ComPtr<ID3D11Texture2D>,
rtv: ComPtr<ID3D11RenderTargetView>,
srv: ComPtr<ID3D11ShaderResourceView>,
// Texture `srv` was created for. The view keeps it alive, so the address
// cannot be recycled behind our back.
srv_source: *mut ID3D11Texture2D,
width: u32,
height: u32,
device_name: [WCHAR; 32],
// Advanced Color state is read from `output6`, which is re-enumerated from a
// fresh factory whenever `factory` stops being current.
factory: ComPtr<IDXGIFactory1>,
output6: ComPtr<IDXGIOutput6>,
is_hdr: bool,
// DISPLAYCONFIG units (1000 == 80 nits == scRGB 1.0). `None` when it could
// not be read, in which case 80 nits is assumed until it can.
sdr_white_level: Option<u32>,
queried_at: Instant,
}
impl HdrToSdr {
pub fn new(
device: *mut ID3D11Device,
context: *mut ID3D11DeviceContext,
output: *mut IDXGIOutput1,
device_name: &[WCHAR; 32],
) -> io::Result<Self> {
unsafe {
if device.is_null() || context.is_null() {
return Err(other("no d3d11 device"));
}
(*device).AddRef();
let device = ComPtr(device);
(*context).AddRef();
let context = ComPtr(context);
let compile = load_d3d_compile()?;
let vs_code = compile_shader(compile, VS_SRC, b"vs_4_0\0")?;
let ps_code = compile_shader(compile, PS_SRC, b"ps_4_0\0")?;
let mut vs = ptr::null_mut();
check(
(*device.0).CreateVertexShader(
(*vs_code.0).GetBufferPointer(),
(*vs_code.0).GetBufferSize(),
ptr::null_mut(),
&mut vs,
),
"CreateVertexShader",
)?;
let vs = ComPtr(vs);
let mut ps = ptr::null_mut();
check(
(*device.0).CreatePixelShader(
(*ps_code.0).GetBufferPointer(),
(*ps_code.0).GetBufferSize(),
ptr::null_mut(),
&mut ps,
),
"CreatePixelShader",
)?;
let ps = ComPtr(ps);
// Not found leaves the factory null, so the first refresh enumerates
// again instead of trusting the capturer's possibly stale output.
let (factory, mut output6) = enumerate_output6(device_name);
if output6.is_null() {
output6 = query_output6(output as *mut IUnknown);
}
// Float frames are only requested where IDXGIOutput6 exists, so an
// unreadable description still comes from an HDR-capable stack.
let is_hdr = output_is_hdr(output6.0).unwrap_or(true);
let sdr_white_level = if is_hdr {
query_sdr_white_level(device_name)
} else {
None
};
if is_hdr && sdr_white_level.is_none() {
log::warn!(
"HDR output but the SDR white level cannot be read (needs Windows 10 1709+), \
assuming 80 nits until it can"
);
}
let init = params_data(sdr_white_level);
let desc = D3D11_BUFFER_DESC {
ByteWidth: mem::size_of_val(&init) as _,
Usage: D3D11_USAGE_DEFAULT,
BindFlags: D3D11_BIND_CONSTANT_BUFFER,
CPUAccessFlags: 0,
MiscFlags: 0,
StructureByteStride: 0,
};
let data = D3D11_SUBRESOURCE_DATA {
pSysMem: init.as_ptr() as _,
SysMemPitch: 0,
SysMemSlicePitch: 0,
};
let mut params = ptr::null_mut();
check(
(*device.0).CreateBuffer(&desc, &data, &mut params),
"CreateBuffer",
)?;
let params = ComPtr(params);
log::info!(
"scRGB desktop conversion ready, hdr {is_hdr}, sdr white level {sdr_white_level:?}"
);
Ok(Self {
device,
context,
vs,
ps,
params,
target: ComPtr(ptr::null_mut()),
rtv: ComPtr(ptr::null_mut()),
srv: ComPtr(ptr::null_mut()),
srv_source: ptr::null_mut(),
width: 0,
height: 0,
device_name: *device_name,
factory,
output6,
is_hdr,
sdr_white_level,
queried_at: Instant::now(),
})
}
}
/// Renders `source` (R16G16B16A16_FLOAT) into an owned B8G8R8A8_UNORM
/// texture of the same size and returns it. The texture stays valid until
/// the next call.
pub fn convert(
&mut self,
source: *mut ID3D11Texture2D,
desc: &D3D11_TEXTURE2D_DESC,
) -> io::Result<*mut ID3D11Texture2D> {
unsafe {
self.refresh_output_state();
self.ensure_target(desc.Width, desc.Height)?;
self.ensure_source_view(source)?;
let ctx = self.context.0;
let rtv = self.rtv.0;
let srv = self.srv.0;
let params = self.params.0;
let viewport = D3D11_VIEWPORT {
TopLeftX: 0.0,
TopLeftY: 0.0,
Width: self.width as f32,
Height: self.height as f32,
MinDepth: 0.0,
MaxDepth: 1.0,
};
(*ctx).OMSetRenderTargets(1, &rtv, ptr::null_mut());
(*ctx).OMSetBlendState(ptr::null_mut(), &[0.0; 4], 0xffff_ffff);
(*ctx).OMSetDepthStencilState(ptr::null_mut(), 0);
(*ctx).RSSetState(ptr::null_mut());
(*ctx).RSSetViewports(1, &viewport);
(*ctx).IASetInputLayout(ptr::null_mut());
(*ctx).IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
(*ctx).VSSetShader(self.vs.0, ptr::null(), 0);
(*ctx).PSSetShader(self.ps.0, ptr::null(), 0);
(*ctx).PSSetConstantBuffers(0, 1, &params);
(*ctx).PSSetShaderResources(0, 1, &srv);
(*ctx).Draw(3, 0);
// Unbind so the next frame's copy and the encoder never see the
// target as a live render target or the desktop image as a bound
// shader input.
let no_srv: *mut ID3D11ShaderResourceView = ptr::null_mut();
(*ctx).PSSetShaderResources(0, 1, &no_srv);
(*ctx).OMSetRenderTargets(0, ptr::null(), ptr::null_mut());
Ok(self.target.0)
}
}
unsafe fn ensure_target(&mut self, width: u32, height: u32) -> io::Result<()> {
if !self.target.is_null() && self.width == width && self.height == height {
return Ok(());
}
let desc = D3D11_TEXTURE2D_DESC {
Width: width,
Height: height,
MipLevels: 1,
ArraySize: 1,
Format: DXGI_FORMAT_B8G8R8A8_UNORM,
SampleDesc: DXGI_SAMPLE_DESC {
Count: 1,
Quality: 0,
},
Usage: D3D11_USAGE_DEFAULT,
BindFlags: D3D11_BIND_RENDER_TARGET | D3D11_BIND_SHADER_RESOURCE,
CPUAccessFlags: 0,
MiscFlags: D3D11_RESOURCE_MISC_SHARED,
};
let mut target = ptr::null_mut();
check(
(*self.device.0).CreateTexture2D(&desc, ptr::null(), &mut target),
"CreateTexture2D",
)?;
let target = ComPtr(target);
let mut rtv = ptr::null_mut();
check(
(*self.device.0).CreateRenderTargetView(target.0 as *mut _, ptr::null(), &mut rtv),
"CreateRenderTargetView",
)?;
self.rtv = ComPtr(rtv);
self.target = target;
self.width = width;
self.height = height;
Ok(())
}
unsafe fn ensure_source_view(&mut self, source: *mut ID3D11Texture2D) -> io::Result<()> {
if !self.srv.is_null() && self.srv_source == source {
return Ok(());
}
let mut srv = ptr::null_mut();
check(
(*self.device.0).CreateShaderResourceView(source as *mut _, ptr::null(), &mut srv),
"CreateShaderResourceView",
)?;
self.srv = ComPtr(srv);
self.srv_source = source;
Ok(())
}
// Advanced Color state is dynamic: HDR can be switched on or off, or a WCG
// desktop can turn into an HDR one, without the duplication being lost. An
// output's description is a snapshot, so once the factory is no longer
// current a new factory and output are needed to see the new state, as the
// GetDesc1 docs require.
unsafe fn refresh_output_state(&mut self) {
if self.queried_at.elapsed() < OUTPUT_STATE_REFRESH {
return;
}
self.queried_at = Instant::now();
if self.factory.is_null() || (*self.factory.0).IsCurrent() == FALSE {
let (factory, output6) = enumerate_output6(&self.device_name);
if !output6.is_null() {
self.factory = factory;
self.output6 = output6;
} else {
// Keep reading the old output, but enumerate again next time.
self.factory = ComPtr(ptr::null_mut());
}
}
let is_hdr = output_is_hdr(self.output6.0).unwrap_or(self.is_hdr);
let level = if is_hdr {
query_sdr_white_level(&self.device_name)
} else {
None
};
// A transiently unreadable level keeps the last known one.
if is_hdr == self.is_hdr && (level.is_none() || level == self.sdr_white_level) {
return;
}
log::info!(
"output changed: hdr {} -> {is_hdr}, sdr white level {:?} -> {level:?}",
self.is_hdr,
self.sdr_white_level
);
if is_hdr && level.is_none() {
log::warn!(
"HDR output but the SDR white level cannot be read, assuming 80 nits until it can"
);
}
self.is_hdr = is_hdr;
self.sdr_white_level = level;
let data = params_data(level);
(*self.context.0).UpdateSubresource(
self.params.0 as *mut _,
0,
ptr::null(),
data.as_ptr() as _,
0,
0,
);
}
}
// `None` is either a non-HDR (WCG) output, where 1.0 already is the display's
// reference white, or an HDR output whose level is unknown; both use 1.0.
fn params_data(sdr_white_level: Option<u32>) -> [f32; 4] {
[
1000.0 / sdr_white_level.unwrap_or(1000) as f32,
0.0,
0.0,
0.0,
]
}
// FP16 desktop composition means either HDR (scene-referred, 1.0 == 80 nits)
// or, since Windows 11 22H2, Advanced Color SDR (display-referred), and only
// IDXGIOutput6 (Windows 10 1703) tells them apart. `None` when it cannot be
// read right now.
unsafe fn output_is_hdr(output6: *mut IDXGIOutput6) -> Option<bool> {
if output6.is_null() {
return None;
}
let mut desc: DXGI_OUTPUT_DESC1 = mem::zeroed();
if (*output6).GetDesc1(&mut desc) != S_OK {
return None;
}
Some(desc.ColorSpace == DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020)
}
unsafe fn query_output6(object: *mut IUnknown) -> ComPtr<IDXGIOutput6> {
let mut output6: *mut IDXGIOutput6 = ptr::null_mut();
if !object.is_null() {
(*object).QueryInterface(
&IID_IDXGIOutput6,
&mut output6 as *mut *mut _ as *mut *mut _,
);
}
ComPtr(output6)
}
// A fresh factory sees the current display configuration; the output is found
// by its GDI name because the outputs of a stale factory keep stale descriptions.
// Returns both or neither: a non-null factory guarantees the output came from
// its topology, so a caller that sees a null factory knows to enumerate again.
unsafe fn enumerate_output6(
device_name: &[WCHAR; 32],
) -> (ComPtr<IDXGIFactory1>, ComPtr<IDXGIOutput6>) {
let mut factory: *mut c_void = ptr::null_mut();
if CreateDXGIFactory1(&IID_IDXGIFactory1, &mut factory) != S_OK {
return (ComPtr(ptr::null_mut()), ComPtr(ptr::null_mut()));
}
let factory = ComPtr(factory as *mut IDXGIFactory1);
let mut adapter_index = 0;
loop {
let mut adapter = ptr::null_mut();
if (*factory.0).EnumAdapters1(adapter_index, &mut adapter) != S_OK {
break;
}
let adapter = ComPtr(adapter);
adapter_index += 1;
let mut output_index = 0;
loop {
let mut output = ptr::null_mut();
if (*adapter.0).EnumOutputs(output_index, &mut output) != S_OK {
break;
}
let output = ComPtr(output);
output_index += 1;
let mut desc: DXGI_OUTPUT_DESC = mem::zeroed();
if (*output.0).GetDesc(&mut desc) == S_OK && wide_eq(&desc.DeviceName, device_name) {
let output6 = query_output6(output.0 as *mut IUnknown);
if output6.is_null() {
return (ComPtr(ptr::null_mut()), ComPtr(ptr::null_mut()));
}
return (factory, output6);
}
}
}
(ComPtr(ptr::null_mut()), ComPtr(ptr::null_mut()))
}
fn other(msg: impl Into<String>) -> io::Error {
io::Error::new(io::ErrorKind::Other, msg.into())
}
fn check(hr: HRESULT, what: &str) -> io::Result<()> {
if hr == S_OK {
Ok(())
} else {
Err(other(format!("{what} failed: {hr:#x}")))
}
}
// D3DCompile(pSrcData, SrcDataSize, pSourceName, pDefines, pInclude,
// pEntrypoint, pTarget, Flags1, Flags2, ppCode, ppErrorMsgs)
type D3DCompileFn = unsafe extern "system" fn(
LPCVOID,
SIZE_T,
LPCSTR,
*const D3D_SHADER_MACRO,
*mut ID3DInclude,
LPCSTR,
LPCSTR,
UINT,
UINT,
*mut *mut ID3DBlob,
*mut *mut ID3DBlob,
) -> HRESULT;
static D3D_COMPILE: OnceLock<Result<D3DCompileFn, String>> = OnceLock::new();
// Loaded once per process and kept: the compiler DLL is only needed on HDR
// desktops, and an import-time link would make every install depend on it.
fn load_d3d_compile() -> io::Result<D3DCompileFn> {
D3D_COMPILE
.get_or_init(|| unsafe { find_d3d_compile() })
.clone()
.map_err(|e| io::Error::new(io::ErrorKind::Unsupported, e))
}
unsafe fn find_d3d_compile() -> Result<D3DCompileFn, String> {
let name: Vec<u16> = "d3dcompiler_47.dll\0".encode_utf16().collect();
let module = LoadLibraryExW(name.as_ptr(), ptr::null_mut(), LOAD_LIBRARY_SEARCH_SYSTEM32);
if module.is_null() {
return Err("d3dcompiler_47.dll not available".into());
}
let f = GetProcAddress(module, b"D3DCompile\0".as_ptr() as _);
if f.is_null() {
return Err("D3DCompile not exported".into());
}
Ok(mem::transmute::<_, D3DCompileFn>(f))
}
unsafe fn compile_shader(
compile: D3DCompileFn,
src: &str,
target: &[u8],
) -> io::Result<ComPtr<ID3DBlob>> {
let mut code = ptr::null_mut();
let mut errors = ptr::null_mut();
let hr = compile(
src.as_ptr() as _,
src.len(),
ptr::null(),
ptr::null(),
ptr::null_mut(),
b"main\0".as_ptr() as _,
target.as_ptr() as _,
0,
0,
&mut code,
&mut errors,
);
let errors = ComPtr(errors);
if hr != S_OK || code.is_null() {
let msg = if errors.is_null() {
String::new()
} else {
let bytes = std::slice::from_raw_parts(
(*errors.0).GetBufferPointer() as *const u8,
(*errors.0).GetBufferSize(),
);
String::from_utf8_lossy(bytes).into_owned()
};
if !code.is_null() {
(*(code as *mut IUnknown)).Release();
}
return Err(io::Error::new(
io::ErrorKind::Unsupported,
format!("D3DCompile failed: {hr:#x} {msg}"),
));
}
Ok(ComPtr(code))
}
const DISPLAYCONFIG_DEVICE_INFO_GET_SDR_WHITE_LEVEL: u32 = 11;
const QDC_ONLY_ACTIVE_PATHS: u32 = 2;
#[repr(C)]
#[allow(non_snake_case)]
struct DISPLAYCONFIG_SDR_WHITE_LEVEL {
header: DISPLAYCONFIG_DEVICE_INFO_HEADER,
SDRWhiteLevel: ULONG,
}
#[link(name = "user32")]
extern "system" {
fn GetDisplayConfigBufferSizes(
flags: u32,
numPathArrayElements: *mut u32,
numModeInfoArrayElements: *mut u32,
) -> LONG;
fn QueryDisplayConfig(
flags: u32,
numPathArrayElements: *mut u32,
pathArray: *mut DISPLAYCONFIG_PATH_INFO,
numModeInfoArrayElements: *mut u32,
modeInfoArray: *mut DISPLAYCONFIG_MODE_INFO,
currentTopologyId: *mut DISPLAYCONFIG_TOPOLOGY_ID,
) -> LONG;
fn DisplayConfigGetDeviceInfo(requestPacket: *mut DISPLAYCONFIG_DEVICE_INFO_HEADER) -> LONG;
}
/// SDR white level of the output whose GDI name is `device_name`
/// (e.g. `\\.\DISPLAY1`), in DISPLAYCONFIG units (1000 == 80 nits). `None`
/// when the query fails (before Windows 10 1709) or reports 0.
fn query_sdr_white_level(device_name: &[WCHAR; 32]) -> Option<u32> {
unsafe {
let mut n_paths = 0u32;
let mut n_modes = 0u32;
if GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &mut n_paths, &mut n_modes) != 0 {
return None;
}
let mut paths: Vec<DISPLAYCONFIG_PATH_INFO> = vec![mem::zeroed(); n_paths as usize];
let mut modes: Vec<DISPLAYCONFIG_MODE_INFO> = vec![mem::zeroed(); n_modes as usize];
if QueryDisplayConfig(
QDC_ONLY_ACTIVE_PATHS,
&mut n_paths,
paths.as_mut_ptr(),
&mut n_modes,
modes.as_mut_ptr(),
ptr::null_mut(),
) != 0
{
return None;
}
for path in &paths[..n_paths as usize] {
let mut source: DISPLAYCONFIG_SOURCE_DEVICE_NAME = mem::zeroed();
source.header._type = DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME;
source.header.size = mem::size_of::<DISPLAYCONFIG_SOURCE_DEVICE_NAME>() as _;
source.header.adapterId = path.sourceInfo.adapterId;
source.header.id = path.sourceInfo.id;
if DisplayConfigGetDeviceInfo(&mut source.header) != 0
|| !wide_eq(&source.viewGdiDeviceName, device_name)
{
continue;
}
let mut white: DISPLAYCONFIG_SDR_WHITE_LEVEL = mem::zeroed();
white.header._type = DISPLAYCONFIG_DEVICE_INFO_GET_SDR_WHITE_LEVEL;
white.header.size = mem::size_of::<DISPLAYCONFIG_SDR_WHITE_LEVEL>() as _;
white.header.adapterId = path.targetInfo.adapterId;
white.header.id = path.targetInfo.id;
if DisplayConfigGetDeviceInfo(&mut white.header) == 0 && white.SDRWhiteLevel != 0 {
return Some(white.SDRWhiteLevel);
}
}
None
}
}
fn wide_eq(a: &[WCHAR], b: &[WCHAR]) -> bool {
let end = |s: &[WCHAR]| s.iter().position(|&c| c == 0).unwrap_or(s.len());
a[..end(a)] == b[..end(b)]
}

View File

@@ -1,18 +1,20 @@
use std::{io, mem, ptr, slice};
pub mod gdi;
pub use gdi::CapturerGDI;
pub mod hdr;
pub mod mag;
use winapi::{
shared::{
dxgi::*,
dxgi1_2::*,
dxgi1_6::*,
dxgiformat::{DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_R16G16B16A16_FLOAT},
dxgitype::*,
minwindef::{DWORD, FALSE, TRUE, UINT},
ntdef::LONG,
windef::{HMONITOR, RECT},
winerror::*,
// dxgiformat::{DXGI_FORMAT, DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_420_OPAQUE},
},
um::{
d3d11::*, d3dcommon::D3D_DRIVER_TYPE_UNKNOWN, unknwnbase::IUnknown, wingdi::*,
@@ -58,6 +60,7 @@ pub struct Capturer {
output_texture: bool,
adapter_desc1: DXGI_ADAPTER_DESC1,
rotate: Rotate,
hdr: Option<hdr::HdrToSdr>,
}
impl Capturer {
@@ -105,7 +108,7 @@ impl Capturer {
}
} else {
res = wrap_hresult(unsafe {
let hres = (*display.inner.0).DuplicateOutput(device.0 as *mut _, &mut duplication);
let hres = Self::duplicate_output(&display, device.0, &mut duplication);
if hres != S_OK {
gdi_capturer = display.create_gdi();
println!("Fallback to GDI");
@@ -161,7 +164,8 @@ impl Capturer {
device,
context,
duplication: ComPtr(duplication),
fastlane: desc.DesktopImageInSystemMemory == TRUE,
fastlane: desc.DesktopImageInSystemMemory == TRUE
&& desc.ModeDesc.Format != DXGI_FORMAT_R16G16B16A16_FLOAT,
surface: ComPtr(ptr::null_mut()),
texture: ComPtr(ptr::null_mut()),
width: display.width() as usize,
@@ -174,9 +178,102 @@ impl Capturer {
output_texture: false,
adapter_desc1,
rotate,
hdr: None,
})
}
// Asks for the float desktop that HDR mode composes so it can be tone-mapped;
// the legacy call would hand back DXGI's clipped BGRA8 conversion instead.
// Only where IDXGIOutput6 (Windows 10 1703) exists, since that is what later
// tells an HDR desktop from a WCG one; Microsoft's duplication sample gates
// the float request the same way.
unsafe fn duplicate_output(
display: &Display,
device: *mut ID3D11Device,
duplication: &mut *mut IDXGIOutputDuplication,
) -> HRESULT {
if !hdr::UNAVAILABLE.load(std::sync::atomic::Ordering::Relaxed) {
let mut output6: *mut IDXGIOutput6 = ptr::null_mut();
(*display.inner.0).QueryInterface(
&IID_IDXGIOutput6,
&mut output6 as *mut *mut _ as *mut *mut _,
);
if !output6.is_null() {
let output6 = ComPtr(output6);
let formats = [DXGI_FORMAT_R16G16B16A16_FLOAT, DXGI_FORMAT_B8G8R8A8_UNORM];
let hres = (*output6.0).DuplicateOutput1(
device as *mut _,
0,
formats.len() as UINT,
formats.as_ptr(),
duplication,
);
if hres == S_OK {
return hres;
}
hbb_common::log::warn!(
"HDR DuplicateOutput1 failed: hr={:#x}, fallback=DuplicateOutput",
hres as u32
);
}
}
(*display.inner.0).DuplicateOutput(device as *mut _, duplication)
}
unsafe fn tonemap(
&mut self,
source: *mut ID3D11Texture2D,
desc: &D3D11_TEXTURE2D_DESC,
) -> io::Result<*mut ID3D11Texture2D> {
if self.hdr.is_none() {
match hdr::HdrToSdr::new(
self.device.0,
self.context.0,
self.display.inner.0,
&self.display.desc.DeviceName,
) {
Ok(hdr) => self.hdr = Some(hdr),
Err(err) => return self.abandon_tonemap(err),
}
}
let converted = match self.hdr.as_mut() {
Some(hdr) => hdr.convert(source, desc),
None => Err(io::Error::new(io::ErrorKind::Other, "no tone-map")),
};
match converted {
Ok(texture) => Ok(texture),
Err(err) => self.abandon_tonemap(err),
}
}
// Drops the tone-map and re-duplicates the output the legacy way, so DXGI
// hands over clipped BGRA8 (the pre-HDR behaviour). If re-duplication fails,
// switch to GDI before returning. The caller sees WouldBlock and asks again.
unsafe fn abandon_tonemap<T>(&mut self, err: io::Error) -> io::Result<T> {
if hdr::is_permanent(&err) {
hdr::UNAVAILABLE.store(true, std::sync::atomic::Ordering::Relaxed);
}
hbb_common::log::error!("HDR tone-map failed, re-duplicating without it: {err}");
self.hdr = None;
(*self.duplication.0).ReleaseFrame();
self.duplication = ComPtr(ptr::null_mut());
let mut duplication = ptr::null_mut();
let result = wrap_hresult(
(*self.display.inner.0).DuplicateOutput(self.device.0 as *mut _, &mut duplication),
);
if let Err(err) = result {
if self.set_gdi() {
return Err(io::ErrorKind::WouldBlock.into());
}
return Err(err);
}
self.duplication = ComPtr(duplication);
let mut desc: DXGI_OUTDUPL_DESC = mem::zeroed();
(*duplication).GetDesc(&mut desc);
self.fastlane = desc.DesktopImageInSystemMemory == TRUE;
Err(io::ErrorKind::WouldBlock.into())
}
fn create_rotations(
device: *mut ID3D11Device,
context: *mut ID3D11DeviceContext,
@@ -330,6 +427,9 @@ impl Capturer {
}
unsafe fn load_frame(&mut self, timeout: UINT) -> io::Result<(*const u8, i32)> {
if self.duplication.0.is_null() {
return Err(io::ErrorKind::AddrNotAvailable.into());
}
let mut frame = ptr::null_mut();
#[allow(invalid_value)]
let mut info = mem::MaybeUninit::uninit().assume_init();
@@ -365,6 +465,12 @@ impl Capturer {
let mut texture_desc = mem::MaybeUninit::uninit().assume_init();
(*texture.0).GetDesc(&mut texture_desc);
let mut source = texture.0;
if texture_desc.Format == DXGI_FORMAT_R16G16B16A16_FLOAT {
source = self.tonemap(texture.0, &texture_desc)?;
(*source).GetDesc(&mut texture_desc);
}
texture_desc.Usage = D3D11_USAGE_STAGING;
texture_desc.BindFlags = 0;
texture_desc.CPUAccessFlags = D3D11_CPU_ACCESS_READ;
@@ -385,7 +491,7 @@ impl Capturer {
&mut surface as *mut *mut _ as *mut *mut _,
);
(*self.context.0).CopyResource(readable.0 as *mut _, texture.0 as *mut _);
(*self.context.0).CopyResource(readable.0 as *mut _, source as *mut _);
Ok(surface)
}
@@ -492,6 +598,14 @@ impl Capturer {
let texture = ComPtr(texture);
self.texture = texture;
let mut frame_desc: D3D11_TEXTURE2D_DESC = mem::zeroed();
(*self.texture.0).GetDesc(&mut frame_desc);
if frame_desc.Format == DXGI_FORMAT_R16G16B16A16_FLOAT {
let converted = self.tonemap(self.texture.0, &frame_desc)?;
(*converted).AddRef();
self.texture = ComPtr(converted);
}
let mut final_texture = self.texture.0 as *mut c_void;
let mut rotation = match self.display.rotation() {
DXGI_MODE_ROTATION_ROTATE90 => 90,
@@ -575,6 +689,9 @@ impl Capturer {
}
fn unmap(&self) {
if self.duplication.0.is_null() {
return;
}
unsafe {
(*self.duplication.0).ReleaseFrame();
if self.fastlane {

View File

@@ -8,7 +8,7 @@ use std::{
};
use tracing::warn;
use base::platform::linux::{get_wayland_displays, WaylandDisplayInfo};
use hbb_common::platform::linux::{get_wayland_displays, WaylandDisplayInfo};
lazy_static! {
static ref DISPLAYS: Mutex<Option<Arc<Displays>>> = Mutex::new(None);
@@ -105,7 +105,7 @@ fn try_xrandr_primary() -> Option<String> {
}
fn try_kscreen_primary() -> Option<String> {
if !base::platform::linux::is_kde_session() {
if !hbb_common::platform::linux::is_kde_session() {
return None;
}
@@ -297,30 +297,6 @@ 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();
@@ -356,8 +332,7 @@ 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];
let (w, h) = oriented_physical(d);
return Some((d.x, d.x + w, d.y, d.y + h));
return Some((d.x, d.x + d.width, d.y, d.y + d.height));
}
let mut min_x = i32::MAX;
@@ -369,8 +344,6 @@ 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,
@@ -401,24 +374,6 @@ 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> {
@@ -431,9 +386,9 @@ fn logical_rects_of(displays: &[WaylandDisplayInfo]) -> Vec<DisplayRect> {
.iter()
.map(|d| {
let (w, h) = if single {
oriented_physical(d)
(d.width, d.height)
} else {
d.logical_size.unwrap_or_else(|| oriented_physical(d))
d.logical_size.unwrap_or((d.width, d.height))
};
DisplayRect {
name: d.name.clone(),
@@ -441,7 +396,6 @@ fn logical_rects_of(displays: &[WaylandDisplayInfo]) -> Vec<DisplayRect> {
y: d.y,
w,
h,
transform: d.transform,
}
})
.collect()
@@ -541,8 +495,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. The generation test also
// calls clear now; both only assert monotonic/unchanged state, so they can interleave.
// and dropping the stamp with it would defeat the backoff. Sole test touching these
// statics; serialize before adding another.
*LAST_FAILED_LOOKUP.lock().unwrap() = Some(Instant::now());
clear_wayland_displays_cache();
let stamp = *LAST_FAILED_LOOKUP.lock().unwrap();
@@ -565,7 +519,6 @@ mod tests {
height,
logical_size,
refresh_rate: 60,
transform: 0,
}
}
@@ -600,42 +553,6 @@ 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(),
@@ -643,7 +560,6 @@ mod tests {
y,
w,
h,
transform: 0,
}
}

View File

@@ -23,8 +23,7 @@ use gstreamer_app::AppSink;
use lazy_static::lazy_static;
use serde::{Deserialize, Serialize};
use base::platform::linux::CMD_SH;
use hbb_common::{anyhow::anyhow, bail, config, serde_json, tokio, ResultType};
use hbb_common::{bail, config, platform::linux::CMD_SH, serde_json, tokio, ResultType};
use super::capturable::PixelProvider;
use super::capturable::{Capturable, Recorder};
@@ -264,21 +263,11 @@ pub struct PipeWireRecorder {
saved_raw_data: Vec<u8>, // for faster compare and copy
}
// Element creation fails the same way for a plugin that is not installed as for one that is
// broken, so the tag does not claim which. Only the name travels to the peer -- it is what
// says which package to look at -- and the factory's own error stays here in the log.
fn gst_element(name: &str) -> ResultType<gst::Element> {
gst::ElementFactory::make(name, None).map_err(|e| {
error!("Failed to create GStreamer element {}: {}", name, e);
anyhow!(stage_err("gst-plugin", "unavailable", name))
})
}
impl PipeWireRecorder {
pub fn new(capturable: PipeWireCapturable) -> ResultType<Self> {
let pipeline = gst::Pipeline::new(None);
let src = gst_element("pipewiresrc")?;
let src = gst::ElementFactory::make("pipewiresrc", None)?;
src.set_property("fd", &capturable.fd.as_raw_fd())?;
src.set_property("path", &format!("{}", capturable.path))?;
src.set_property("keepalive_time", &1_000.as_raw_fd())?;
@@ -293,9 +282,9 @@ impl PipeWireRecorder {
// "no more output formats" / not-negotiated (-4). videoconvert accepts any
// system-memory video/x-raw format, widening negotiation so the portal can
// settle on a format it can deliver via its SHM path.
let convert = gst_element("videoconvert")?;
let convert = gst::ElementFactory::make("videoconvert", None)?;
let sink = gst_element("appsink")?;
let sink = gst::ElementFactory::make("appsink", None)?;
sink.set_property("drop", &true)?;
sink.set_property("max-buffers", &1u32)?;
@@ -474,125 +463,11 @@ impl Drop for PipeWireRecorder {
}
}
// The portal handshake is four sequential requests whose outcomes arrive as asynchronous
// `Response` signals, so where and why it failed is known only inside the signal handler.
// Recording it here, instead of collapsing every outcome into one `failed` flag, is what lets
// the app side name the real cause rather than guess it from the error text.
#[derive(Clone, Copy)]
enum PortalStage {
CreateSession = 1,
SelectDevices = 2,
SelectSources = 3,
Start = 4,
OpenPipeWireRemote = 5,
}
impl PortalStage {
fn as_str(&self) -> &'static str {
match self {
Self::CreateSession => "create-session",
Self::SelectDevices => "select-devices",
Self::SelectSources => "select-sources",
Self::Start => "start",
Self::OpenPipeWireRemote => "open-pipewire-remote",
}
}
fn from_u8(v: u8) -> Self {
match v {
2 => Self::SelectDevices,
3 => Self::SelectSources,
4 => Self::Start,
5 => Self::OpenPipeWireRemote,
_ => Self::CreateSession,
}
}
}
// `wl-stage:<stage>:<kind>:<detail>`, parsed by `map_err_scrap` on the app side. The detail
// reaches the user through a `{}` placeholder in a translated string, so it must not bring
// braces, control characters or unbounded length of its own.
const STAGE_TAG: &str = "wl-stage:";
fn stage_err(stage: &str, kind: &str, detail: &str) -> String {
let detail: String = detail
.chars()
.map(|c| if c.is_control() { ' ' } else { c })
.filter(|c| *c != '{' && *c != '}')
.take(200)
.collect();
format!("{}{}:{}:{}", STAGE_TAG, stage, kind, detail.trim())
}
// The name alone is usually the generic `org.freedesktop.DBus.Error.Failed`; the message is
// where a backend says what it objected to. This ends up in the log, so carry both.
fn dbus_stage_err(stage: &str, err: &dbus::Error) -> String {
let detail = match (err.name(), err.message()) {
(Some(name), Some(message)) if !name.is_empty() && !message.is_empty() => {
format!("{}: {}", name, message)
}
(Some(name), _) if !name.is_empty() => name.to_owned(),
(_, message) => message.unwrap_or_default().to_owned(),
};
let kind = match err.name().unwrap_or_default() {
"org.freedesktop.DBus.Error.UnknownMethod"
| "org.freedesktop.DBus.Error.UnknownInterface" => "unsupported",
_ => "dbus",
};
stage_err(stage, kind, &detail)
}
#[derive(Clone)]
struct PortalTrace {
failed: Arc<AtomicBool>,
reason: Arc<Mutex<Option<String>>>,
// The stage whose `Response` we are still waiting for, so the polling loop can tell a
// non-interactive step apart from the one that waits for a human.
waiting_for: Arc<AtomicU8>,
}
impl PortalTrace {
fn new() -> Self {
Self {
failed: Arc::new(AtomicBool::new(false)),
reason: Arc::new(Mutex::new(None)),
waiting_for: Arc::new(AtomicU8::new(PortalStage::CreateSession as u8)),
}
}
fn fail(&self, stage: PortalStage, kind: &str, detail: &str) {
self.record(stage_err(stage.as_str(), kind, detail));
self.failed.store(true, Ordering::SeqCst);
}
// The first failure is the cause; whatever follows it is a consequence.
fn record(&self, tag: String) {
if let Ok(mut reason) = self.reason.lock() {
if reason.is_none() {
*reason = Some(tag);
}
}
}
fn waiting(&self, stage: PortalStage) {
self.waiting_for.store(stage as u8, Ordering::SeqCst);
}
fn waiting_stage(&self) -> PortalStage {
PortalStage::from_u8(self.waiting_for.load(Ordering::SeqCst))
}
fn take_reason(&self) -> Option<String> {
self.reason.lock().ok().and_then(|mut r| r.take())
}
}
fn handle_response<F>(
conn: &SyncConnection,
path: dbus::Path<'static>,
mut f: F,
trace: PortalTrace,
stage: PortalStage,
failure_out: Arc<AtomicBool>,
) -> Result<dbus::channel::Token, dbus::Error>
where
F: FnMut(
@@ -615,29 +490,18 @@ where
0 => {}
1 => {
warn!("DBus response: User cancelled interaction.");
trace.fail(stage, "declined", "");
return true;
}
2 => {
warn!("DBus response: User interaction ended in some other way.");
trace.fail(stage, "ended", "");
failure_out.store(true, Ordering::SeqCst);
return true;
}
c => {
warn!("DBus response: Unknown error, code: {}.", c);
trace.fail(stage, "portal-error", &c.to_string());
failure_out.store(true, Ordering::SeqCst);
return true;
}
}
if let Err(err) = f(r, c, m) {
let text = err.to_string();
warn!("Error requesting screen capture via dbus: {}", text);
if text.starts_with(STAGE_TAG) {
trace.record(text);
trace.failed.store(true, Ordering::SeqCst);
} else {
trace.fail(trace.waiting_stage(), "internal", &text);
}
warn!("Error requesting screen capture via dbus: {}", err);
failure_out.store(true, Ordering::SeqCst);
}
true
})
@@ -773,16 +637,15 @@ pub fn request_remote_desktop(
INIT = true;
}
}
let conn =
SyncConnection::new_session().map_err(|e| anyhow!(dbus_stage_err("session-bus", &e)))?;
let conn = SyncConnection::new_session()?;
let portal = get_portal(&conn);
let mut args: PropMap = HashMap::new();
let fd: Arc<Mutex<Option<OwnedFd>>> = Arc::new(Mutex::new(None));
let fd_res = fd.clone();
let streams: Arc<Mutex<Vec<PwStreamInfo>>> = Arc::new(Mutex::new(Vec::new()));
let streams_res = streams.clone();
let trace = PortalTrace::new();
let trace_res = trace.clone();
let failure = Arc::new(AtomicBool::new(false));
let failure_res = failure.clone();
let session: Arc<Mutex<Option<dbus::Path>>> = Arc::new(Mutex::new(None));
let session_res = session.clone();
let create_session_handle_token = "u1";
@@ -810,45 +673,38 @@ pub fn request_remote_desktop(
// the caller to subscribe to the signal before making the method call.
handle_response(
&conn,
get_request_path(&conn, create_session_handle_token)
.map_err(|e| anyhow!(dbus_stage_err("create-session", &e)))?,
get_request_path(&conn, create_session_handle_token)?,
on_create_session_response(
fd.clone(),
streams.clone(),
session.clone(),
trace.clone(),
failure.clone(),
is_support_restore_token,
capture_cursor,
),
trace.clone(),
PortalStage::CreateSession,
)
.map_err(|e| anyhow!(dbus_stage_err("create-session", &e)))?;
failure_res.clone(),
)?;
if is_server_running() {
let _ = screencast_portal::create_session(&portal, args)
.map_err(|e| anyhow!(dbus_stage_err("create-session", &e)))?;
let _ = screencast_portal::create_session(&portal, args)?;
} else {
let _ = remote_desktop_portal::create_session(&portal, args)
.map_err(|e| anyhow!(dbus_stage_err("create-session", &e)))?;
let _ = remote_desktop_portal::create_session(&portal, args)?;
}
// wait 3 minutes for user interaction
for _ in 0..1800 {
conn.process(Duration::from_millis(100))
.map_err(|e| anyhow!(dbus_stage_err(trace_res.waiting_stage().as_str(), &e)))?;
conn.process(Duration::from_millis(100))?;
// Once we got a file descriptor we are done!
if fd_res.lock().unwrap().is_some() {
break;
}
if trace_res.failed.load(Ordering::SeqCst) {
if failure_res.load(Ordering::SeqCst) {
break;
}
}
let fd_res = fd_res.lock().unwrap();
let streams_res = streams_res.lock().unwrap();
let session_res = session_res.lock().unwrap();
let have_fd = fd_res.is_some();
if let Some(fd_res) = fd_res.clone() {
if let Some(session) = session_res.clone() {
@@ -863,20 +719,14 @@ pub fn request_remote_desktop(
}
}
}
bail!(trace_res.take_reason().unwrap_or_else(|| {
if have_fd {
stage_err("streams", "empty", "")
} else {
stage_err(trace_res.waiting_stage().as_str(), "no-response", "")
}
}))
bail!("Failed to obtain screen capture. You may need to upgrade the PipeWire library for better compatibility. Please check https://github.com/rustdesk/rustdesk/issues/8600#issuecomment-2254720954 for more details.")
}
fn on_create_session_response(
fd: Arc<Mutex<Option<OwnedFd>>>,
streams: Arc<Mutex<Vec<PwStreamInfo>>>,
session: Arc<Mutex<Option<dbus::Path<'static>>>>,
trace: PortalTrace,
failure: Arc<AtomicBool>,
is_support_restore_token: bool,
capture_cursor: bool,
) -> impl Fn(
@@ -936,23 +786,19 @@ fn on_create_session_response(
});
}
trace.waiting(PortalStage::SelectSources);
handle_response(
c,
get_request_path(c, select_sources_handle_token)?,
on_select_sources_response(
fd.clone(),
streams.clone(),
trace.clone(),
failure.clone(),
ses.clone(),
is_support_restore_token,
),
trace.clone(),
PortalStage::SelectSources,
failure.clone(),
)?;
let _ = portal
.select_sources(ses.clone(), args)
.map_err(|e| DBusError(dbus_stage_err("select-sources", &e)))?;
let _ = portal.select_sources(ses.clone(), args)?;
} else {
// TODO: support persist_mode for remote_desktop_portal
// https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.RemoteDesktop.html
@@ -964,23 +810,19 @@ fn on_create_session_response(
);
args.insert("types".to_string(), Variant(Box::new(7u32)));
trace.waiting(PortalStage::SelectDevices);
handle_response(
c,
get_request_path(c, select_devices_handle_token)?,
on_select_devices_response(
fd.clone(),
streams.clone(),
trace.clone(),
failure.clone(),
ses.clone(),
is_support_restore_token,
),
trace.clone(),
PortalStage::SelectDevices,
failure.clone(),
)?;
let _ = portal
.select_devices(ses.clone(), args)
.map_err(|e| DBusError(dbus_stage_err("select-devices", &e)))?;
let _ = portal.select_devices(ses.clone(), args)?;
}
Ok(())
@@ -990,7 +832,7 @@ fn on_create_session_response(
fn on_select_devices_response(
fd: Arc<Mutex<Option<OwnedFd>>>,
streams: Arc<Mutex<Vec<PwStreamInfo>>>,
trace: PortalTrace,
failure: Arc<AtomicBool>,
session: dbus::Path<'static>,
is_support_restore_token: bool,
) -> impl Fn(
@@ -1013,23 +855,19 @@ fn on_select_devices_response(
args.insert("types".into(), Variant(Box::new(1u32))); //| 2u32)));
let session = session.clone();
trace.waiting(PortalStage::SelectSources);
handle_response(
c,
get_request_path(c, select_sources_handle_token)?,
on_select_sources_response(
fd.clone(),
streams.clone(),
trace.clone(),
failure.clone(),
session.clone(),
is_support_restore_token,
),
trace.clone(),
PortalStage::SelectSources,
failure.clone(),
)?;
let _ = portal
.select_sources(session.clone(), args)
.map_err(|e| DBusError(dbus_stage_err("select-sources", &e)))?;
let _ = portal.select_sources(session.clone(), args)?;
Ok(())
}
@@ -1038,7 +876,7 @@ fn on_select_devices_response(
fn on_select_sources_response(
fd: Arc<Mutex<Option<OwnedFd>>>,
streams: Arc<Mutex<Vec<PwStreamInfo>>>,
trace: PortalTrace,
failure: Arc<AtomicBool>,
session: dbus::Path<'static>,
is_support_restore_token: bool,
) -> impl Fn(
@@ -1054,7 +892,6 @@ fn on_select_sources_response(
"handle_token".to_string(),
Variant(Box::new(start_handle_token.to_string())),
);
trace.waiting(PortalStage::Start);
handle_response(
c,
get_request_path(c, start_handle_token)?,
@@ -1062,18 +899,14 @@ fn on_select_sources_response(
fd.clone(),
streams.clone(),
session.clone(),
trace.clone(),
is_support_restore_token,
),
trace.clone(),
PortalStage::Start,
failure.clone(),
)?;
if is_server_running() {
let _ = screencast_portal::start(&portal, session.clone(), "", args)
.map_err(|e| DBusError(dbus_stage_err("start", &e)))?;
let _ = screencast_portal::start(&portal, session.clone(), "", args)?;
} else {
let _ = remote_desktop_portal::start(&portal, session.clone(), "", args)
.map_err(|e| DBusError(dbus_stage_err("start", &e)))?;
let _ = remote_desktop_portal::start(&portal, session.clone(), "", args)?;
}
Ok(())
@@ -1084,7 +917,6 @@ fn on_start_response(
fd: Arc<Mutex<Option<OwnedFd>>>,
streams: Arc<Mutex<Vec<PwStreamInfo>>>,
session: dbus::Path<'static>,
trace: PortalTrace,
is_support_restore_token: bool,
) -> impl Fn(
OrgFreedesktopPortalRequestResponse,
@@ -1112,14 +944,10 @@ fn on_start_response(
.lock()
.unwrap()
.append(&mut streams_from_response(r));
// Past this point the user has granted the request; anything that fails now is the
// hand-over of the PipeWire fd, which is a different thing to go looking at.
trace.waiting(PortalStage::OpenPipeWireRemote);
fd.clone().lock().unwrap().replace(
portal
.open_pipe_wire_remote(session.clone(), HashMap::new())
.map_err(|e| DBusError(dbus_stage_err("open-pipewire-remote", &e)))?,
);
fd.clone()
.lock()
.unwrap()
.replace(portal.open_pipe_wire_remote(session.clone(), HashMap::new())?);
Ok(())
}
@@ -1726,29 +1554,3 @@ fn sort_streams(
*streams = sorted_streams;
*shared_displays = sorted_shared_displays;
}
#[cfg(test)]
mod tests {
use super::stage_err;
#[test]
fn stage_err_keeps_the_detail_safe_for_a_placeholder() {
assert_eq!(
stage_err("start", "declined", ""),
"wl-stage:start:declined:"
);
// Braces of its own would break the placeholder lookup on the peer.
assert_eq!(
stage_err("create-session", "dbus", "org.freedesktop.{Error}"),
"wl-stage:create-session:dbus:org.freedesktop.Error"
);
assert_eq!(
stage_err("select-sources", "internal", "one\ntwo"),
"wl-stage:select-sources:internal:one two"
);
assert_eq!(
stage_err("start", "internal", &"x".repeat(300)),
format!("wl-stage:start:internal:{}", "x".repeat(200))
);
}
}

View File

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

View File

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

View File

@@ -18,9 +18,6 @@ void UninstallDriver(LPCWSTR hardwareId, BOOL &rebootRequired);
namespace RemotePrinter
{
// `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);
VOID installUpdatePrinter(const std::wstring& installFolder);
VOID uninstallPrinter();
}

View File

@@ -300,7 +300,7 @@ bool TerminateProcessesByNameW(LPCWSTR processName, LPCWSTR excludeParam)
{
do
{
if (lstrcmpiW(processName, processEntry.szExeFile) == 0)
if (lstrcmpW(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,27 +1031,12 @@ UINT __stdcall InstallPrinter(
hr = WcaGetProperty(L"CustomActionData", &pwzData);
ExitOnFailure(hr, "failed to get CustomActionData");
// "<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);
}
}
pwz = pwzData;
hr = WcaReadStringFromCaData(&pwz, &installFolder);
ExitOnFailure(hr, "failed to read database key from custom action data: %ls", pwz);
WcaLog(LOGMSG_STANDARD, "Try to install RD printer in : %ls", installFolderValue.c_str());
RemotePrinter::installUpdatePrinter(installFolderValue, appNameValue);
WcaLog(LOGMSG_STANDARD, "Try to install RD printer in : %ls", installFolder);
RemotePrinter::installUpdatePrinter(installFolder);
WcaLog(LOGMSG_STANDARD, "Install RD printer done");
LExit:
@@ -1069,30 +1054,14 @@ 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(appNameValue);
RemotePrinter::uninstallPrinter();
WcaLog(LOGMSG_STANDARD, "Uninstall RD printer done");
LExit:
if (pwzData) {
ReleaseStr(pwzData);
}
er = SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE;
return WcaFinalize(er);
}

View File

@@ -18,19 +18,12 @@ 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 &)>;
@@ -465,12 +458,8 @@ 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, const std::wstring &appName)
VOID installUpdatePrinter(const std::wstring &installFolder)
{
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))
{
@@ -516,15 +505,13 @@ namespace RemotePrinter
}
}
VOID uninstallPrinter(const std::wstring &appName)
VOID uninstallPrinter()
{
const std::wstring printerName = printerNameOf(appName);
deletePrinter(printerName.c_str());
deletePrinter(RD_PRINTER_NAME);
WcaLog(LOGMSG_STANDARD, "Deleted the printer\n");
uninstallDriver(RD_PRINTER_DRIVER_NAME);
WcaLog(LOGMSG_STANDARD, "Uninstalled the printer driver\n");
checkDeleteLocalPort(printerName.c_str());
checkDeleteLocalPort(RD_PRINTER_PORT);
WcaLog(LOGMSG_STANDARD, "Deleted the local port\n");
}
}

View File

@@ -30,14 +30,7 @@
<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]" />
<!-- 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]" />
<CustomAction Id="InstallPrinter.SetParam" Return="check" Property="InstallPrinter" Value="[INSTALLFOLDER_INNER]" />
<InstallExecuteSequence>
<Custom Action="SetPropertyIsServiceRunning" After="InstallInitialize" Condition="Installed" />
@@ -93,7 +86,6 @@
<Custom Action="RemoveFirewallRules.SetParam" Before="RemoveFirewallRules"/>
<Custom Action="UninstallPrinter" Before="RemoveRuntimeGeneratedFiles" Condition="VersionNT &gt;= 603" />
<Custom Action="UninstallPrinter.SetParam" Before="UninstallPrinter" Condition="VersionNT &gt;= 603" />
<Custom Action="TerminateProcesses" Before="RemoveRuntimeGeneratedFiles"/>
<Custom Action="TerminateProcesses.SetParam" Before="TerminateProcesses"/>

View File

@@ -13,12 +13,6 @@
<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)" />

View File

@@ -10,6 +10,7 @@ import subprocess
import re
import platform
from pathlib import Path
from itertools import chain
import shutil
from xml.sax.saxutils import quoteattr
@@ -66,14 +67,6 @@ 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,
@@ -99,43 +92,6 @@ 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()
@@ -156,7 +112,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, template=False):
def insert_components_between_tags(lines, index_start, app_name, dist_dir):
indent = g_indent_unit * 3
path = Path(dist_dir)
idx = 1
@@ -170,23 +126,12 @@ def insert_components_between_tags(lines, index_start, app_name, dist_dir, templ
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}{condition_attr}>
{indent}{g_indent_unit}<File Source="{file_path.as_posix()}" KeyPath="yes" Checksum="yes"{disk_attr} />
{indent}<Component Guid="{uuid.uuid4()}" {dir_attr}>
{indent}{g_indent_unit}<File Source="{file_path.as_posix()}" KeyPath="yes" Checksum="yes" />
{indent}</Component>
"""
lines.insert(index_start + 1, to_insert_lines[1:])
@@ -195,52 +140,17 @@ def insert_components_between_tags(lines, index_start, app_name, dist_dir, templ
return True
def gen_auto_component(app_name, dist_dir, template=False):
def gen_auto_component(app_name, dist_dir):
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, template
lines, index_start, app_name, dist_dir
),
)
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")
@@ -280,6 +190,18 @@ 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
@@ -556,16 +478,11 @@ if __name__ == "__main__":
if not gen_conn_type(args):
sys.exit(-1)
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):
if not gen_auto_component(app_name, dist_dir):
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)

View File

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

View File

@@ -1,242 +0,0 @@
use hbb_common::thiserror;
#[cfg(test)]
pub(crate) mod allocation_tests;
#[cfg(all(feature = "use_samplerate", not(feature = "use_dasp")))]
mod sinc;
const INTERPOLATION_MARGIN_FRAMES: usize = 2;
const PENDING_PACKET_CAPACITY: usize = 2;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct AudioResamplerConfig {
pub input_rate: u32,
pub output_rate: u32,
pub channels: u16,
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub(crate) enum AudioResamplerError {
#[error(
"invalid audio resampler configuration: input_rate={}, output_rate={}, channels={}",
.0.input_rate, .0.output_rate, .0.channels
)]
InvalidConfig(AudioResamplerConfig),
#[error("invalid resampler output frame size: {output_frames}")]
InvalidOutputFrameSize { output_frames: usize },
#[error("audio resampler input length {samples} is not divisible by channel count {channels}")]
IncompleteFrame { samples: usize, channels: usize },
#[error("audio resampler output capacity overflow")]
CapacityOverflow,
#[cfg(all(feature = "use_samplerate", not(feature = "use_dasp")))]
#[error("audio resampler backend failed: {0}")]
Backend(String),
}
pub(crate) struct FixedFrameAudioResampler {
resampler: AudioResampler,
output_samples: usize,
pending_samples: Vec<f32>,
}
#[cfg(all(feature = "use_samplerate", not(feature = "use_dasp")))]
// SAFETY: libsamplerate's src_new state owns heap data and has no thread affinity.
// This wrapper never exposes or shares that state; processing requires &mut self.
unsafe impl Send for FixedFrameAudioResampler {}
impl FixedFrameAudioResampler {
pub(crate) fn new(
config: AudioResamplerConfig,
output_frames: usize,
) -> Result<Self, AudioResamplerError> {
if output_frames == 0 {
return Err(AudioResamplerError::InvalidOutputFrameSize { output_frames });
}
let channels = validate_config(config)?;
let output_samples = output_frames
.checked_mul(channels)
.ok_or(AudioResamplerError::CapacityOverflow)?;
let input_frames = output_frames
.checked_mul(config.input_rate as usize)
.ok_or(AudioResamplerError::CapacityOverflow)?
.div_ceil(config.output_rate as usize);
let capacity = output_samples
.checked_mul(PENDING_PACKET_CAPACITY)
.and_then(|samples| samples.checked_add(channels * INTERPOLATION_MARGIN_FRAMES))
.ok_or(AudioResamplerError::CapacityOverflow)?;
let mut resampler = AudioResampler::new(config)?;
resampler.reserve_input(input_frames)?;
Ok(Self {
resampler,
output_samples,
pending_samples: Vec::with_capacity(capacity),
})
}
pub(crate) fn process_with(
&mut self,
input: &[f32],
mut on_packet: impl FnMut(&[f32]),
) -> Result<(), AudioResamplerError> {
self.resampler
.process_into(input, &mut self.pending_samples)?;
let complete_samples =
self.pending_samples.len() / self.output_samples * self.output_samples;
for packet in self.pending_samples[..complete_samples].chunks_exact(self.output_samples) {
on_packet(packet);
}
self.pending_samples.drain(..complete_samples);
Ok(())
}
#[cfg(test)]
pub(crate) fn process(&mut self, input: &[f32]) -> Result<Vec<Vec<f32>>, AudioResamplerError> {
let mut packets = Vec::new();
self.process_with(input, |packet| packets.push(packet.to_owned()))?;
Ok(packets)
}
}
pub(crate) struct AudioResampler {
#[cfg(not(all(feature = "use_samplerate", not(feature = "use_dasp"))))]
backend: StreamingLinearAudioResampler,
#[cfg(all(feature = "use_samplerate", not(feature = "use_dasp")))]
backend: sinc::SincAudioResampler,
}
impl AudioResampler {
pub(crate) fn new(config: AudioResamplerConfig) -> Result<Self, AudioResamplerError> {
Ok(Self {
#[cfg(all(feature = "use_samplerate", not(feature = "use_dasp")))]
backend: sinc::SincAudioResampler::new(config)?,
#[cfg(not(all(feature = "use_samplerate", not(feature = "use_dasp"))))]
backend: StreamingLinearAudioResampler::new(config)?,
})
}
pub(crate) fn process(&mut self, input: &[f32]) -> Result<Vec<f32>, AudioResamplerError> {
let mut output = Vec::new();
self.process_into(input, &mut output)?;
Ok(output)
}
// Append samples so capture can retain an incomplete output packet in the same buffer.
fn process_into(
&mut self,
input: &[f32],
output: &mut Vec<f32>,
) -> Result<(), AudioResamplerError> {
self.backend.process_into(input, output)
}
fn reserve_input(&mut self, _frames: usize) -> Result<(), AudioResamplerError> {
#[cfg(not(all(feature = "use_samplerate", not(feature = "use_dasp"))))]
{
let capacity = _frames
.checked_add(INTERPOLATION_MARGIN_FRAMES)
.and_then(|frames| frames.checked_mul(self.backend.channels))
.ok_or(AudioResamplerError::CapacityOverflow)?;
self.backend.buffered_samples.reserve(capacity);
}
Ok(())
}
}
#[cfg(not(all(feature = "use_samplerate", not(feature = "use_dasp"))))]
struct StreamingLinearAudioResampler {
config: AudioResamplerConfig,
channels: usize,
buffered_samples: Vec<f32>,
next_position: u64,
}
#[cfg(not(all(feature = "use_samplerate", not(feature = "use_dasp"))))]
impl StreamingLinearAudioResampler {
fn new(config: AudioResamplerConfig) -> Result<Self, AudioResamplerError> {
Ok(Self {
config,
channels: validate_config(config)?,
buffered_samples: Vec::new(),
next_position: 0,
})
}
fn process_into(
&mut self,
input: &[f32],
output: &mut Vec<f32>,
) -> Result<(), AudioResamplerError> {
validate_input(input, self.channels)?;
let capacity = self.output_capacity(input.len())?;
output.reserve(capacity);
self.buffered_samples.extend_from_slice(input);
while self.write_next_frame(output) {
self.next_position += self.config.input_rate as u64;
}
self.discard_consumed_frames();
Ok(())
}
fn output_capacity(&self, input_samples: usize) -> Result<usize, AudioResamplerError> {
let input_frames = input_samples / self.channels;
let scaled_frames = input_frames
.checked_mul(self.config.output_rate as usize)
.ok_or(AudioResamplerError::CapacityOverflow)?
/ self.config.input_rate as usize;
scaled_frames
.checked_add(INTERPOLATION_MARGIN_FRAMES)
.and_then(|frames| frames.checked_mul(self.channels))
.ok_or(AudioResamplerError::CapacityOverflow)
}
fn write_next_frame(&self, output: &mut Vec<f32>) -> bool {
let output_rate = self.config.output_rate as u64;
let frame_count = self.buffered_samples.len() / self.channels;
let frame = (self.next_position / output_rate) as usize;
let fraction = self.next_position % output_rate;
if frame >= frame_count || (fraction != 0 && frame + 1 >= frame_count) {
return false;
}
let weight = fraction as f32 / output_rate as f32;
for channel in 0..self.channels {
let current = self.buffered_samples[frame * self.channels + channel];
let next_frame = frame + usize::from(fraction != 0);
let next = self.buffered_samples[next_frame * self.channels + channel];
output.push(current + (next - current) * weight);
}
true
}
fn discard_consumed_frames(&mut self) {
let output_rate = self.config.output_rate as u64;
let available_frames = self.buffered_samples.len() / self.channels;
let consumed_frames = ((self.next_position / output_rate) as usize).min(available_frames);
self.buffered_samples
.drain(0..consumed_frames * self.channels);
self.next_position -= consumed_frames as u64 * output_rate;
}
}
fn validate_config(config: AudioResamplerConfig) -> Result<usize, AudioResamplerError> {
if config.input_rate == 0 || config.output_rate == 0 || config.channels == 0 {
return Err(AudioResamplerError::InvalidConfig(config));
}
Ok(config.channels as usize)
}
fn validate_input(input: &[f32], channels: usize) -> Result<(), AudioResamplerError> {
if input.len() % channels != 0 {
return Err(AudioResamplerError::IncompleteFrame {
samples: input.len(),
channels,
});
}
Ok(())
}
#[cfg(all(test, not(all(feature = "use_samplerate", not(feature = "use_dasp")))))]
mod tests;
#[cfg(all(test, feature = "use_samplerate", not(feature = "use_dasp")))]
mod samplerate_tests;

View File

@@ -1,142 +0,0 @@
use super::{AudioResamplerConfig, FixedFrameAudioResampler};
use std::alloc::{GlobalAlloc, Layout, System};
use std::cell::Cell;
struct CountingAllocator;
thread_local! {
static ALLOCATIONS: Cell<Option<usize>> = const { Cell::new(None) };
}
fn record_allocation() {
let _ = ALLOCATIONS.try_with(|count| {
if let Some(value) = count.get() {
count.set(Some(value + 1));
}
});
}
unsafe impl GlobalAlloc for CountingAllocator {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
record_allocation();
unsafe { System.alloc(layout) }
}
unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
record_allocation();
unsafe { System.alloc_zeroed(layout) }
}
unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, size: usize) -> *mut u8 {
record_allocation();
unsafe { System.realloc(ptr, layout, size) }
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
unsafe { System.dealloc(ptr, layout) }
}
}
#[global_allocator]
static ALLOCATOR: CountingAllocator = CountingAllocator;
pub(crate) fn assert_no_allocations(process: impl FnOnce()) {
struct ResetCounter;
impl Drop for ResetCounter {
fn drop(&mut self) {
ALLOCATIONS.with(|count| count.set(None));
}
}
ALLOCATIONS.with(|count| assert!(count.replace(Some(0)).is_none()));
let reset = ResetCounter;
process();
let allocations = ALLOCATIONS.with(|count| count.get().unwrap());
drop(reset);
assert_eq!(
allocations, 0,
"PCM processing allocated on the capture thread"
);
}
#[test]
fn capture_resampling_reuses_buffers() {
const PACKETS_PER_SECOND: usize = 100;
const PACKET_COUNT: usize = 100;
const MAX_STARTUP_DELAY_PACKETS: usize = 1;
const SIGNAL_LEVEL: f32 = 0.25;
const RATE_PAIRS: [(u32, u32); 6] = [
(32_000, 24_000),
(44_100, 24_000),
(44_100, 48_000),
(48_000, 24_000),
(96_000, 48_000),
(192_000, 48_000),
];
for (input_rate, output_rate) in RATE_PAIRS {
for channels in [1, 2, 4, 6, 8] {
let config = AudioResamplerConfig {
input_rate,
output_rate,
channels,
};
let input =
vec![SIGNAL_LEVEL; input_rate as usize / PACKETS_PER_SECOND * channels as usize];
let frames = output_rate as usize / PACKETS_PER_SECOND;
let mut resampler = FixedFrameAudioResampler::new(config, frames).unwrap();
let mut packets = 0;
let mut energy = 0.0;
assert_no_allocations(|| {
for _ in 0..PACKET_COUNT {
resampler
.process_with(&input, |packet| {
assert_eq!(packet.len(), frames * channels as usize);
energy += packet.iter().map(|sample| sample * sample).sum::<f32>();
packets += 1;
})
.unwrap();
}
});
assert!((PACKET_COUNT - MAX_STARTUP_DELAY_PACKETS..=PACKET_COUNT).contains(&packets));
assert!(energy > SIGNAL_LEVEL);
}
}
}
#[cfg(all(feature = "use_samplerate", not(feature = "use_dasp")))]
#[test]
fn sinc_output_matches_the_existing_backend() {
use super::AudioResampler;
const INPUT_FRAMES: usize = 2_048;
const CHUNK_FRAMES: usize = 73;
const SIGNAL_STEP: f32 = 0.07;
for (input_rate, output_rate) in [(44_100, 24_000), (44_100, 48_000), (96_000, 48_000)] {
for channels in [1, 2, 4, 6, 8] {
let config = AudioResamplerConfig {
input_rate,
output_rate,
channels,
};
let input: Vec<_> = (0..INPUT_FRAMES * channels as usize)
.map(|sample| (sample as f32 * SIGNAL_STEP).sin())
.collect();
let mut actual = AudioResampler::new(config).unwrap();
let expected = samplerate::Samplerate::new(
samplerate::ConverterType::SincBestQuality,
input_rate,
output_rate,
channels as usize,
)
.unwrap();
for chunk in input.chunks(CHUNK_FRAMES * channels as usize) {
assert_eq!(
actual.process(chunk).unwrap(),
expected.process(chunk).unwrap()
);
assert_eq!(actual.process(&[]).unwrap(), expected.process(&[]).unwrap());
}
}
}
}

View File

@@ -1,157 +0,0 @@
use super::{AudioResampler, AudioResamplerConfig, AudioResamplerError, FixedFrameAudioResampler};
const INPUT_RATE: u32 = 44_100;
const OUTPUT_RATE: u32 = 48_000;
const CHANNELS: u16 = 2;
const INPUT_PACKET_FRAMES: usize = INPUT_RATE as usize / PACKETS_PER_SECOND;
const OUTPUT_PACKET_FRAMES: usize = OUTPUT_RATE as usize / PACKETS_PER_SECOND;
const PACKET_COUNT: usize = 20;
const PACKETS_PER_SECOND: usize = 100;
const MIN_CONTINUITY_PACKETS: usize = 2;
const TONE_FREQUENCY_HZ: f32 = 997.0;
const TONE_AMPLITUDE: f32 = 0.5;
const MAX_BOUNDARY_RESIDUAL: f32 = 0.02;
const INCOMPLETE_SAMPLE_COUNT: usize = 1;
const DOWNSAMPLE_RATE: u32 = 24_000;
const REJECTED_TONE_HZ: f64 = 18_000.0;
const MAX_ALIAS_RMS: f64 = 0.01;
const MIN_PASSBAND_RMS: f64 = 0.3;
fn stereo_tone(frames: usize) -> Vec<f32> {
(0..frames)
.flat_map(|frame| {
let phase =
std::f32::consts::TAU * TONE_FREQUENCY_HZ * frame as f32 / INPUT_RATE as f32;
let sample = TONE_AMPLITUDE * phase.sin();
[sample, sample]
})
.collect()
}
fn maximum_boundary_residual(packets: &[Vec<f32>]) -> f32 {
packets.windows(2).fold(0.0, |maximum, pair| {
let previous = &pair[0];
let current = &pair[1];
let last = previous.len() - CHANNELS as usize;
let penultimate = last - CHANNELS as usize;
(0..CHANNELS as usize).fold(maximum, |maximum, channel| {
let predicted = previous[last + channel]
+ (previous[last + channel] - previous[penultimate + channel]);
maximum.max((current[channel] - predicted).abs())
})
})
}
fn stereo_config() -> AudioResamplerConfig {
AudioResamplerConfig {
input_rate: INPUT_RATE,
output_rate: OUTPUT_RATE,
channels: CHANNELS,
}
}
#[test]
fn moving_capture_resampler_preserves_pending_audio() {
let input = stereo_tone(INPUT_PACKET_FRAMES * PACKET_COUNT);
let packet_samples = INPUT_PACKET_FRAMES * CHANNELS as usize;
let mut expected_resampler =
FixedFrameAudioResampler::new(stereo_config(), OUTPUT_PACKET_FRAMES).unwrap();
let expected: Vec<_> = input
.chunks(packet_samples)
.flat_map(|packet| expected_resampler.process(packet).unwrap())
.collect();
let mut moved_resampler =
FixedFrameAudioResampler::new(stereo_config(), OUTPUT_PACKET_FRAMES).unwrap();
let mut output = moved_resampler.process(&input[..packet_samples]).unwrap();
let remaining = std::thread::spawn(move || {
input[packet_samples..]
.chunks(packet_samples)
.flat_map(|packet| moved_resampler.process(packet).unwrap())
.collect::<Vec<_>>()
})
.join()
.unwrap();
output.extend(remaining);
assert!(output.len() >= MIN_CONTINUITY_PACKETS);
assert!(output
.iter()
.all(|packet| packet.len() == OUTPUT_PACKET_FRAMES * CHANNELS as usize));
assert!(maximum_boundary_residual(&output) <= MAX_BOUNDARY_RESIDUAL);
assert_eq!(output, expected);
}
fn downsampled_rms(input: &[f32]) -> f64 {
let config = AudioResamplerConfig {
output_rate: DOWNSAMPLE_RATE,
..stereo_config()
};
let output_frames = DOWNSAMPLE_RATE as usize / PACKETS_PER_SECOND;
let mut resampler = FixedFrameAudioResampler::new(config, output_frames).unwrap();
let output: Vec<f32> = input
.chunks(INPUT_PACKET_FRAMES * CHANNELS as usize)
.flat_map(|packet| resampler.process(packet).unwrap().into_iter().flatten())
.collect();
assert!(output.len() >= output_frames * CHANNELS as usize * MIN_CONTINUITY_PACKETS);
let mean_square = output
.iter()
.map(|sample| f64::from(*sample).powi(2))
.sum::<f64>()
/ output.len() as f64;
mean_square.sqrt()
}
#[test]
fn capture_downsampling_filters_out_of_band_audio() {
let input: Vec<_> = (0..INPUT_PACKET_FRAMES * PACKET_COUNT)
.flat_map(|frame| {
let phase =
std::f64::consts::TAU * REJECTED_TONE_HZ * frame as f64 / f64::from(INPUT_RATE);
let sample = (f64::from(TONE_AMPLITUDE) * phase.sin()) as f32;
[sample, sample]
})
.collect();
let rms = downsampled_rms(&input);
assert!(
rms < MAX_ALIAS_RMS,
"out-of-band output RMS {rms} exceeded {MAX_ALIAS_RMS}"
);
let input = stereo_tone(INPUT_PACKET_FRAMES * PACKET_COUNT);
let rms = downsampled_rms(&input);
assert!(
rms > MIN_PASSBAND_RMS,
"in-band output RMS {rms} fell below {MIN_PASSBAND_RMS}"
);
}
#[test]
fn samplerate_backend_preserves_streaming_continuity() {
let input = stereo_tone(INPUT_PACKET_FRAMES * PACKET_COUNT);
let mut resampler = AudioResampler::new(stereo_config()).unwrap();
let packets: Vec<_> = input
.chunks(INPUT_PACKET_FRAMES * CHANNELS as usize)
.map(|packet| resampler.process(packet).unwrap())
.filter(|packet| !packet.is_empty())
.collect();
assert!(packets.len() >= MIN_CONTINUITY_PACKETS);
assert!(packets
.iter()
.all(|packet| packet.len() % CHANNELS as usize == 0));
assert!(maximum_boundary_residual(&packets) <= MAX_BOUNDARY_RESIDUAL);
}
#[test]
fn samplerate_backend_reports_incomplete_frame_context() {
let mut resampler = AudioResampler::new(stereo_config()).unwrap();
assert_eq!(
resampler.process(&[0.0]).unwrap_err(),
AudioResamplerError::IncompleteFrame {
samples: INCOMPLETE_SAMPLE_COUNT,
channels: CHANNELS as usize,
}
);
}

View File

@@ -1,112 +0,0 @@
use super::{AudioResamplerConfig, AudioResamplerError};
use libsamplerate_sys as sys;
use std::ptr::NonNull;
const OUTPUT_MARGIN_FRAMES: usize = 1;
pub(super) struct SincAudioResampler {
state: NonNull<sys::SRC_STATE>,
config: AudioResamplerConfig,
}
impl SincAudioResampler {
pub(super) fn new(config: AudioResamplerConfig) -> Result<Self, AudioResamplerError> {
super::validate_config(config)?;
let ratio = f64::from(config.output_rate) / f64::from(config.input_rate);
if unsafe { sys::src_is_valid_ratio(ratio) } == 0 {
return Err(backend_error(
config,
samplerate::ErrorCode::BadSrcRatio as _,
));
}
let mut error = 0;
// SAFETY: src_new allocates independent state; this owner releases it in Drop.
let state = unsafe {
sys::src_new(
sys::SRC_SINC_BEST_QUALITY as _,
config.channels.into(),
&mut error,
)
};
let state = NonNull::new(state).ok_or_else(|| backend_error(config, error))?;
Ok(Self { state, config })
}
pub(super) fn process_into(
&mut self,
input: &[f32],
output: &mut Vec<f32>,
) -> Result<(), AudioResamplerError> {
super::validate_input(input, self.config.channels as usize)?;
let mut consumed = 0;
loop {
let (used, generated) = self.process_block(&input[consumed..], output)?;
consumed += used;
if consumed == input.len() {
return Ok(());
}
if used == 0 && generated == 0 {
return Err(AudioResamplerError::Backend(
"libsamplerate made no progress while input remained".to_owned(),
));
}
}
}
fn process_block(
&mut self,
input: &[f32],
output: &mut Vec<f32>,
) -> Result<(usize, usize), AudioResamplerError> {
let channels = self.config.channels as usize;
let input_frames = input.len() / channels;
let output_frames = input_frames
.checked_mul(self.config.output_rate as usize)
.map(|frames| frames / self.config.input_rate as usize)
.and_then(|frames| frames.checked_add(OUTPUT_MARGIN_FRAMES))
.ok_or(AudioResamplerError::CapacityOverflow)?;
let start = output.len();
let end = output_frames
.checked_mul(channels)
.and_then(|samples| start.checked_add(samples))
.ok_or(AudioResamplerError::CapacityOverflow)?;
let mut data = sys::SRC_DATA {
data_in: input.as_ptr(),
input_frames: input_frames
.try_into()
.map_err(|_| AudioResamplerError::CapacityOverflow)?,
output_frames: output_frames
.try_into()
.map_err(|_| AudioResamplerError::CapacityOverflow)?,
src_ratio: f64::from(self.config.output_rate) / f64::from(self.config.input_rate),
..Default::default()
};
output.resize(end, 0.0);
data.data_out = output[start..].as_mut_ptr();
// SAFETY: state is exclusively owned; disjoint slices cover the declared frame counts.
let error = unsafe { sys::src_process(self.state.as_ptr(), &mut data) };
let generated = data.output_frames_gen as usize * channels;
output.truncate(start + generated);
if error != 0 {
return Err(backend_error(self.config, error));
}
Ok((data.input_frames_used as usize * channels, generated))
}
}
impl Drop for SincAudioResampler {
fn drop(&mut self) {
// SAFETY: this owner holds the only handle returned by src_new.
unsafe { sys::src_delete(self.state.as_ptr()) };
}
}
fn backend_error(config: AudioResamplerConfig, code: i32) -> AudioResamplerError {
AudioResamplerError::Backend(format!(
"input_rate={}, output_rate={}, channels={}: {:?}",
config.input_rate,
config.output_rate,
config.channels,
samplerate::Error::from_int(code)
))
}

View File

@@ -1,178 +0,0 @@
use super::{AudioResampler, AudioResamplerConfig, FixedFrameAudioResampler};
const INPUT_RATE: u32 = 24_000;
const OUTPUT_RATE: u32 = 48_000;
const CHANNELS: u16 = 2;
const CHUNK_FRAMES: usize = 240;
const CHUNK_COUNT: usize = 4;
const TONE_FREQUENCY_HZ: f32 = 997.0;
const TONE_AMPLITUDE: f32 = 0.5;
const MAX_BOUNDARY_RESIDUAL: f32 = 0.02;
const LOOK_AHEAD_OUTPUT_FRAMES: usize = 1;
const UNEVEN_CHUNK_FRAMES: usize = 73;
const MONO_CHANNELS: u16 = 1;
const UNIT_RATE: u32 = 1;
const DOUBLE_RATE: u32 = 2;
const FIRST_DOWNSAMPLE_PACKET: [f32; 3] = [0.0, 1.0, 2.0];
const SECOND_DOWNSAMPLE_PACKET: [f32; 4] = [3.0, 4.0, 5.0, 6.0];
const EXPECTED_DOWNSAMPLED_OUTPUT: [f32; 4] = [0.0, 2.0, 4.0, 6.0];
const PACKETS_PER_SECOND: usize = 100;
const OUTPUT_PACKET_FRAMES: usize = OUTPUT_RATE as usize / PACKETS_PER_SECOND;
const RATE_44_1_KHZ: u32 = 44_100;
const FLOAT_TOLERANCE: f32 = 0.000_001;
const MIN_CONTINUITY_PACKETS: usize = 2;
fn stereo_tone_at_rate(frames: usize, sample_rate: u32) -> Vec<f32> {
(0..frames)
.flat_map(|frame| {
let phase =
std::f32::consts::TAU * TONE_FREQUENCY_HZ * frame as f32 / sample_rate as f32;
let sample = TONE_AMPLITUDE * phase.sin();
[sample, sample]
})
.collect()
}
fn stereo_tone(frames: usize) -> Vec<f32> {
stereo_tone_at_rate(frames, INPUT_RATE)
}
fn maximum_tone_prediction_residual(sample_rate: u32) -> f32 {
let half_step = std::f32::consts::PI * TONE_FREQUENCY_HZ / sample_rate as f32;
4.0 * TONE_AMPLITUDE * half_step.sin().powi(2)
}
fn maximum_boundary_residual(chunks: &[Vec<f32>]) -> f32 {
chunks.windows(2).fold(0.0, |maximum, pair| {
let previous = &pair[0];
let current = &pair[1];
let last = previous.len() - CHANNELS as usize;
let penultimate = last - CHANNELS as usize;
(0..CHANNELS as usize).fold(maximum, |maximum, channel| {
let predicted = previous[last + channel]
+ (previous[last + channel] - previous[penultimate + channel]);
maximum.max((current[channel] - predicted).abs())
})
})
}
fn stereo_config() -> AudioResamplerConfig {
AudioResamplerConfig {
input_rate: INPUT_RATE,
output_rate: OUTPUT_RATE,
channels: CHANNELS,
}
}
#[test]
fn preserves_decoded_packet_continuity_and_output_ratio() {
let input = stereo_tone(CHUNK_FRAMES * CHUNK_COUNT);
let mut whole_resampler = AudioResampler::new(stereo_config()).unwrap();
let whole_output = whole_resampler.process(&input).unwrap();
let expected_frames = CHUNK_FRAMES * CHUNK_COUNT * OUTPUT_RATE as usize / INPUT_RATE as usize
- LOOK_AHEAD_OUTPUT_FRAMES;
for chunk_frames in [CHUNK_FRAMES, UNEVEN_CHUNK_FRAMES] {
let mut resampler = AudioResampler::new(stereo_config()).unwrap();
let output: Vec<_> = input
.chunks(chunk_frames * CHANNELS as usize)
.map(|chunk| resampler.process(chunk).unwrap())
.collect();
let residual = maximum_boundary_residual(&output);
assert!(
residual <= MAX_BOUNDARY_RESIDUAL,
"packet boundary residual {residual} exceeded {MAX_BOUNDARY_RESIDUAL}, chunk_frames={chunk_frames}"
);
let output_frames = output.iter().map(Vec::len).sum::<usize>() / CHANNELS as usize;
assert_eq!(output_frames, expected_frames);
assert_eq!(output.concat(), whole_output, "chunk_frames={chunk_frames}");
}
}
#[test]
fn rejects_incomplete_interleaved_frames() {
let mut resampler = AudioResampler::new(stereo_config()).unwrap();
assert!(resampler.process(&[TONE_AMPLITUDE]).is_err());
}
#[test]
fn interpolates_mono_samples() {
let config = AudioResamplerConfig {
input_rate: UNIT_RATE,
output_rate: DOUBLE_RATE,
channels: MONO_CHANNELS,
};
let mut resampler = AudioResampler::new(config).unwrap();
assert_eq!(
resampler.process(&[0.0, 1.0, 2.0]).unwrap(),
[0.0, 0.5, 1.0, 1.5, 2.0]
);
}
#[test]
fn rejects_zero_rate_configuration() {
let config = AudioResamplerConfig {
input_rate: 0,
output_rate: OUTPUT_RATE,
channels: CHANNELS,
};
assert!(AudioResampler::new(config).is_err());
}
#[test]
fn downsamples_across_packet_boundaries() {
let config = AudioResamplerConfig {
input_rate: DOUBLE_RATE,
output_rate: UNIT_RATE,
channels: MONO_CHANNELS,
};
let mut resampler = AudioResampler::new(config).unwrap();
let mut output = resampler.process(&FIRST_DOWNSAMPLE_PACKET).unwrap();
output.extend(resampler.process(&SECOND_DOWNSAMPLE_PACKET).unwrap());
assert_eq!(output, EXPECTED_DOWNSAMPLED_OUTPUT);
}
#[test]
fn sender_resampler_emits_only_complete_continuous_frames() {
let input = stereo_tone(CHUNK_FRAMES * CHUNK_COUNT);
let mut resampler =
FixedFrameAudioResampler::new(stereo_config(), OUTPUT_PACKET_FRAMES).unwrap();
let output: Vec<_> = input
.chunks(CHUNK_FRAMES * CHANNELS as usize)
.flat_map(|chunk| resampler.process(chunk).unwrap())
.collect();
assert!(output.len() >= MIN_CONTINUITY_PACKETS);
assert!(output
.iter()
.all(|packet| packet.len() == OUTPUT_PACKET_FRAMES * CHANNELS as usize));
assert!(maximum_boundary_residual(&output) <= MAX_BOUNDARY_RESIDUAL);
}
#[test]
fn sender_downsampling_preserves_packet_continuity() {
let input_packet_frames = RATE_44_1_KHZ as usize / PACKETS_PER_SECOND;
let input = stereo_tone_at_rate(input_packet_frames * CHUNK_COUNT, RATE_44_1_KHZ);
let config = AudioResamplerConfig {
input_rate: RATE_44_1_KHZ,
output_rate: INPUT_RATE,
channels: CHANNELS,
};
let mut resampler =
FixedFrameAudioResampler::new(config, INPUT_RATE as usize / PACKETS_PER_SECOND).unwrap();
let packets: Vec<_> = input
.chunks(input_packet_frames * CHANNELS as usize)
.flat_map(|packet| resampler.process(packet).unwrap())
.collect();
let residual = maximum_boundary_residual(&packets);
assert_eq!(packets.len(), CHUNK_COUNT);
assert!(
residual <= maximum_tone_prediction_residual(INPUT_RATE) + FLOAT_TOLERANCE,
"sender packet boundary residual {residual} exceeded the tone curvature"
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,218 +0,0 @@
use hbb_common::{log, thiserror};
use ringbuf::{ring_buffer::RbBase, Rb};
use std::sync::{
atomic::{AtomicBool, AtomicUsize, Ordering},
TryLockError,
};
pub(super) const UNDERRUN_DECLICK_MS: usize = 5;
const MILLISECONDS_PER_SECOND: usize = 1_000;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) struct AudioPlaybackConfig {
pub sample_rate: u32,
pub channels: usize,
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub(super) enum AudioPlaybackError {
#[error(
"invalid audio playback configuration: sample_rate={}, channels={}",
.0.sample_rate, .0.channels
)]
InvalidConfig(AudioPlaybackConfig),
#[error("audio playback frame has {samples} samples for {channels} channels")]
IncompleteFrame { samples: usize, channels: usize },
#[error("audio playback transition frame count overflow")]
FrameCountOverflow,
}
pub(super) struct AudioPlaybackRecovery {
channels: usize,
transition_frames: usize,
transition_frame: usize,
had_input: bool,
transition_start: Vec<f32>,
output_frame: Vec<f32>,
}
#[derive(Default)]
pub(super) struct AudioPlaybackStatus {
pub(super) ready: AtomicBool,
contentions: AtomicUsize,
buffer_poisoned: AtomicBool,
}
impl AudioPlaybackStatus {
pub(super) fn report_errors(&self) {
let contentions = self.contentions.swap(0, Ordering::Relaxed);
if contentions != 0 {
log::debug!("Audio playback PCM buffer contention: callbacks={contentions}");
}
if self.buffer_poisoned.swap(false, Ordering::Relaxed) {
log::error!("Audio playback stopped reading a poisoned PCM buffer");
}
}
}
pub(super) struct AudioPlaybackWriter {
audio_buffer: std::sync::Arc<std::sync::Mutex<ringbuf::HeapRb<f32>>>,
discontinuity_generation: std::sync::Arc<AtomicUsize>,
observed_discontinuity_generation: usize,
buffered_input: Vec<f32>,
recovery: AudioPlaybackRecovery,
pub(super) status: std::sync::Arc<AudioPlaybackStatus>,
buffer_failed: bool,
}
impl AudioPlaybackWriter {
pub(super) fn new(
config: AudioPlaybackConfig,
audio_buffer: std::sync::Arc<std::sync::Mutex<ringbuf::HeapRb<f32>>>,
discontinuity_generation: std::sync::Arc<AtomicUsize>,
) -> Result<Self, AudioPlaybackError> {
let recovery = AudioPlaybackRecovery::new(config)?;
let buffer_capacity = audio_buffer.lock().unwrap().capacity();
let observed_discontinuity_generation = discontinuity_generation.load(Ordering::Relaxed);
Ok(Self {
audio_buffer,
discontinuity_generation,
observed_discontinuity_generation,
buffered_input: vec![0.0; buffer_capacity],
recovery,
status: Default::default(),
buffer_failed: false,
})
}
fn read_buffer(&mut self, requested_samples: usize) -> usize {
if self.buffer_failed {
return 0;
}
let mut buffer = match self.audio_buffer.try_lock() {
Ok(buffer) => buffer,
Err(TryLockError::WouldBlock) => {
// Keep queued PCM and its generation for the next successful read.
self.status.contentions.fetch_add(1, Ordering::Relaxed);
return 0;
}
Err(TryLockError::Poisoned(_)) => {
self.buffer_failed = true;
self.status.ready.store(false, Ordering::Release);
self.status.buffer_poisoned.store(true, Ordering::Relaxed);
return 0;
}
};
let generation = self.discontinuity_generation.load(Ordering::Relaxed);
let channels = self.recovery.channels;
let samples = buffer.occupied_len().min(requested_samples) / channels * channels;
buffer.pop_slice(&mut self.buffered_input[..samples]);
drop(buffer);
if generation != self.observed_discontinuity_generation {
self.recovery.begin_discontinuity();
self.observed_discontinuity_generation = generation;
}
samples
}
pub(super) fn write_output<T>(&mut self, output: &mut [T])
where
T: cpal::Sample + cpal::FromSample<f32>,
{
self.status
.ready
.store(!self.buffer_failed, Ordering::Release);
let requested_samples = output.len().min(self.buffered_input.len());
let channel_count = self.recovery.channels;
let available_samples = self.read_buffer(requested_samples);
let available_frames = available_samples / channel_count;
for (frame_index, output_frame) in output.chunks_mut(channel_count).enumerate() {
let input = if frame_index < available_frames {
let start = frame_index * channel_count;
Some(&self.buffered_input[start..start + channel_count])
} else {
None
};
match self.recovery.process_frame(input) {
Ok(recovered) => {
for (output, sample) in output_frame.iter_mut().zip(recovered) {
*output = T::from_sample(*sample);
}
}
Err(error) => {
log::error!("Failed to recover audio underflow: {error}");
output_frame.fill(T::from_sample(0.0));
}
}
}
}
}
impl AudioPlaybackRecovery {
pub(super) fn new(config: AudioPlaybackConfig) -> Result<Self, AudioPlaybackError> {
if config.sample_rate == 0 || config.channels == 0 {
return Err(AudioPlaybackError::InvalidConfig(config));
}
let transition_frames = (config.sample_rate as usize)
.checked_mul(UNDERRUN_DECLICK_MS)
.ok_or(AudioPlaybackError::FrameCountOverflow)?
/ MILLISECONDS_PER_SECOND;
if transition_frames == 0 {
return Err(AudioPlaybackError::InvalidConfig(config));
}
Ok(Self {
channels: config.channels,
transition_frames,
transition_frame: transition_frames,
had_input: false,
transition_start: vec![0.0; config.channels],
output_frame: vec![0.0; config.channels],
})
}
pub(super) fn process_frame(
&mut self,
input: Option<&[f32]>,
) -> Result<&[f32], AudioPlaybackError> {
if input.is_some_and(|frame| frame.len() != self.channels) {
return Err(AudioPlaybackError::IncompleteFrame {
samples: input.map_or(0, <[f32]>::len),
channels: self.channels,
});
}
self.begin_transition(input.is_some());
let target_weight = self.advance_transition();
for channel in 0..self.channels {
let target = input.map_or(0.0, |frame| frame[channel]);
self.output_frame[channel] =
self.transition_start[channel] * (1.0 - target_weight) + target * target_weight;
}
Ok(&self.output_frame)
}
pub(super) fn begin_discontinuity(&mut self) {
self.transition_start.copy_from_slice(&self.output_frame);
self.transition_frame = 0;
}
fn begin_transition(&mut self, has_input: bool) {
if has_input == self.had_input {
return;
}
self.transition_start.copy_from_slice(&self.output_frame);
self.transition_frame = 0;
self.had_input = has_input;
}
fn advance_transition(&mut self) -> f32 {
if self.transition_frame >= self.transition_frames {
return 1.0;
}
self.transition_frame += 1;
self.transition_frame as f32 / self.transition_frames as f32
}
}
#[cfg(test)]
#[path = "audio_playback_tests.rs"]
mod tests;

View File

@@ -1,218 +0,0 @@
use super::{AudioPlaybackConfig, AudioPlaybackError, AudioPlaybackRecovery, AudioPlaybackWriter};
use ringbuf::{ring_buffer::RbBase, Rb};
use std::{
sync::{atomic::Ordering, mpsc, Arc, Mutex},
time::Duration,
};
const SAMPLE_RATE: u32 = 48_000;
const CHANNELS: usize = 2;
const ACTIVE_FRAME: [f32; CHANNELS] = [0.8, -0.8];
const OPPOSITE_ACTIVE_FRAME: [f32; CHANNELS] = [-0.8, 0.8];
const ACTIVE_FRAMES: usize = 300;
const SILENT_FRAMES: usize = 300;
const TRANSITION_FRAMES: usize =
SAMPLE_RATE as usize * super::UNDERRUN_DECLICK_MS / super::MILLISECONDS_PER_SECOND;
const MAX_SAMPLE_STEP: f32 = 0.01;
#[test]
fn writing_audio_observes_discard_and_releases_buffer_lock() {
const INPUT: [f32; 4] = [0.1, 0.2, 0.3, 0.4];
const GENERATION: usize = 7;
let buffer = Arc::new(Mutex::new(ringbuf::HeapRb::new(INPUT.len())));
let generation = Arc::new(super::AtomicUsize::new(0));
let config = AudioPlaybackConfig {
sample_rate: SAMPLE_RATE,
channels: CHANNELS,
};
let mut writer = AudioPlaybackWriter::new(config, buffer.clone(), generation.clone()).unwrap();
{
let mut buffer = buffer.lock().unwrap();
buffer.push_slice(&INPUT);
generation.store(GENERATION, Ordering::Relaxed);
}
let mut output = [0.0_f32; INPUT.len()];
writer.write_output(&mut output);
assert_eq!(writer.buffered_input, INPUT);
assert_eq!(writer.observed_discontinuity_generation, GENERATION);
assert_eq!(buffer.try_lock().unwrap().occupied_len(), 0);
}
fn maximum_sample_step(samples: &[f32]) -> f32 {
samples
.windows(CHANNELS + 1)
.map(|window| (window[CHANNELS] - window[0]).abs())
.fold(0.0, f32::max)
}
#[test]
fn smooths_underflow_and_explicit_audio_discontinuities() {
for explicit_discontinuity in [false, true] {
let config = AudioPlaybackConfig {
sample_rate: SAMPLE_RATE,
channels: CHANNELS,
};
let mut recovery = AudioPlaybackRecovery::new(config).unwrap();
let mut output = Vec::new();
for _ in 0..ACTIVE_FRAMES {
output.extend_from_slice(recovery.process_frame(Some(&ACTIVE_FRAME)).unwrap());
}
let transition_end = TRANSITION_FRAMES * CHANNELS;
assert_eq!(
&output[transition_end - CHANNELS..transition_end],
ACTIVE_FRAME.as_slice(),
"explicit_discontinuity={explicit_discontinuity}"
);
let resumed_frame = if explicit_discontinuity {
recovery.begin_discontinuity();
&OPPOSITE_ACTIVE_FRAME
} else {
for _ in 0..SILENT_FRAMES {
output.extend_from_slice(recovery.process_frame(None).unwrap());
}
&ACTIVE_FRAME
};
for _ in 0..ACTIVE_FRAMES {
output.extend_from_slice(recovery.process_frame(Some(resumed_frame)).unwrap());
}
let maximum = maximum_sample_step(&output);
assert!(
maximum <= MAX_SAMPLE_STEP,
"step {maximum} exceeded {MAX_SAMPLE_STEP}, explicit={explicit_discontinuity}"
);
assert_eq!(
&output[output.len() - CHANNELS..],
resumed_frame,
"explicit_discontinuity={explicit_discontinuity}"
);
}
}
#[test]
fn validates_configuration_and_frame_size() {
let invalid_config = AudioPlaybackConfig {
sample_rate: 0,
channels: CHANNELS,
};
assert_eq!(
AudioPlaybackRecovery::new(invalid_config).err(),
Some(AudioPlaybackError::InvalidConfig(invalid_config))
);
let config = AudioPlaybackConfig {
sample_rate: SAMPLE_RATE,
channels: CHANNELS,
};
let mut recovery = AudioPlaybackRecovery::new(config).unwrap();
assert_eq!(
recovery.process_frame(Some(&[0.5])).err(),
Some(AudioPlaybackError::IncompleteFrame {
samples: 1,
channels: CHANNELS,
})
);
}
const CALLBACK_SAMPLES: usize = 64;
const CALLBACK_TIMEOUT: Duration = Duration::from_secs(2);
const DISCARD_GENERATION: usize = 1;
fn write_while_buffer_is_locked(
mut writer: AudioPlaybackWriter,
buffer: &Arc<Mutex<ringbuf::HeapRb<f32>>>,
generation: &Arc<super::AtomicUsize>,
) -> (AudioPlaybackWriter, [f32; CALLBACK_SAMPLES]) {
let mut guard = buffer.lock().unwrap();
let queued = OPPOSITE_ACTIVE_FRAME.repeat(ACTIVE_FRAMES);
guard.push_slice(&queued);
generation.store(DISCARD_GENERATION, Ordering::Relaxed);
let (completed_tx, completed_rx) = mpsc::channel();
let callback = std::thread::spawn(move || {
let mut output = [0.0; CALLBACK_SAMPLES];
crate::audio_resampler::allocation_tests::assert_no_allocations(|| {
writer.write_output(&mut output);
});
completed_tx.send((writer, output)).unwrap();
});
let completed = completed_rx.recv_timeout(CALLBACK_TIMEOUT);
let retained = guard.occupied_len();
drop(guard);
callback.join().unwrap();
let result = completed.expect("playback callback waited for the buffer owner");
assert_eq!(retained, queued.len());
result
}
#[test]
fn playback_contention_preserves_queued_audio_and_recovers_after_release() {
let samples = ACTIVE_FRAMES * CHANNELS;
let buffer = Arc::new(Mutex::new(ringbuf::HeapRb::new(samples)));
let generation = Arc::new(super::AtomicUsize::new(0));
let config = AudioPlaybackConfig {
sample_rate: SAMPLE_RATE,
channels: CHANNELS,
};
let mut writer = AudioPlaybackWriter::new(config, buffer.clone(), generation.clone()).unwrap();
buffer
.lock()
.unwrap()
.push_slice(&ACTIVE_FRAME.repeat(ACTIVE_FRAMES));
let mut output = vec![0.0; samples];
writer.write_output(&mut output);
assert_eq!(&output[samples - CHANNELS..], &ACTIVE_FRAME);
let (mut writer, gap) = write_while_buffer_is_locked(writer, &buffer, &generation);
assert_eq!(writer.status.contentions.load(Ordering::Relaxed), 1);
assert!(writer.status.ready.load(Ordering::Acquire));
assert_eq!(writer.observed_discontinuity_generation, 0);
assert!(maximum_sample_step(&gap) <= MAX_SAMPLE_STEP);
assert!(gap[0] > 0.0 && gap[0] < ACTIVE_FRAME[0]);
assert_eq!(gap[1], -gap[0]);
assert!(gap[CALLBACK_SAMPLES - CHANNELS] > 0.0);
writer.write_output(&mut output);
let mut transition = gap[gap.len() - CHANNELS..].to_vec();
transition.extend_from_slice(&output[..TRANSITION_FRAMES * CHANNELS]);
assert!(maximum_sample_step(&transition) <= MAX_SAMPLE_STEP);
assert_eq!(
writer.buffered_input,
OPPOSITE_ACTIVE_FRAME.repeat(ACTIVE_FRAMES)
);
assert_eq!(writer.observed_discontinuity_generation, DISCARD_GENERATION);
assert_eq!(&output[samples - CHANNELS..], &OPPOSITE_ACTIVE_FRAME);
assert_eq!(buffer.lock().unwrap().occupied_len(), 0);
}
#[test]
fn poisoned_playback_buffer_reports_once_without_panicking_in_the_callback() {
let buffer = Arc::new(Mutex::new(ringbuf::HeapRb::new(CALLBACK_SAMPLES)));
let config = AudioPlaybackConfig {
sample_rate: SAMPLE_RATE,
channels: CHANNELS,
};
let mut writer =
AudioPlaybackWriter::new(config, buffer.clone(), Arc::new(super::AtomicUsize::new(0)))
.unwrap();
assert!(std::thread::spawn(move || {
let _guard = buffer.lock().unwrap();
panic!("Injected PCM buffer failure");
})
.join()
.is_err());
let mut output = [ACTIVE_FRAME[0]; CALLBACK_SAMPLES];
crate::audio_resampler::allocation_tests::assert_no_allocations(|| {
writer.write_output(&mut output);
});
assert_eq!(output, [0.0; CALLBACK_SAMPLES]);
assert!(!writer.status.ready.load(Ordering::Acquire));
assert!(writer.status.buffer_poisoned.load(Ordering::Relaxed));
writer.status.report_errors();
writer.write_output(&mut output);
assert!(!writer.status.buffer_poisoned.load(Ordering::Relaxed));
assert_eq!(writer.status.contentions.load(Ordering::Relaxed), 0);
assert!(!writer.status.ready.load(Ordering::Acquire));
}

View File

@@ -1,113 +0,0 @@
use super::{create_audio_resampler, AudioDecoder, AudioFrame, AudioHandler, Stereo};
use cpal::traits::StreamTrait;
use hbb_common::anyhow::anyhow;
use magnum_opus::{Application::LowDelay, Encoder};
use ringbuf::{ring_buffer::RbBase, Rb};
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
const INPUT_RATE: u32 = 24_000;
const OUTPUT_RATE: u32 = 48_000;
const CHANNELS: u16 = 2;
const PACKETS_PER_SECOND: usize = 100;
const MAX_PACKET_BYTES: usize = 4_096;
const SAMPLE_VALUE: f32 = 0.25;
struct TrackedAudioStream(Arc<AtomicBool>);
impl StreamTrait for TrackedAudioStream {
fn play(&self) -> Result<(), cpal::PlayStreamError> {
Ok(())
}
fn pause(&self) -> Result<(), cpal::PauseStreamError> {
Ok(())
}
}
impl Drop for TrackedAudioStream {
fn drop(&mut self) {
self.0.store(true, Ordering::SeqCst);
}
}
fn decoder(sample_rate: u32) -> (AudioDecoder, Vec<f32>) {
(
AudioDecoder::new(sample_rate, Stereo).unwrap(),
vec![0.0; sample_rate as usize * CHANNELS as usize],
)
}
fn active_handler(input_rate: u32) -> (AudioHandler, Arc<AtomicBool>) {
let dropped = Arc::new(AtomicBool::new(false));
let handler = AudioHandler {
audio_decoder: Some(decoder(input_rate)),
audio_resampler: create_audio_resampler(input_rate, OUTPUT_RATE, CHANNELS).unwrap(),
sample_rate: (input_rate, OUTPUT_RATE),
audio_stream: Some(Box::new(TrackedAudioStream(dropped.clone()))),
channels: CHANNELS,
device_channel: CHANNELS,
..Default::default()
};
handler.playback_status.ready.store(true, Ordering::Release);
(handler, dropped)
}
fn audio_frame() -> AudioFrame {
let samples = OUTPUT_RATE as usize / PACKETS_PER_SECOND * CHANNELS as usize;
let mut encoder = Encoder::new(OUTPUT_RATE, Stereo, LowDelay).unwrap();
AudioFrame {
data: encoder
.encode_vec_float(&vec![SAMPLE_VALUE; samples], MAX_PACKET_BYTES)
.unwrap()
.into(),
..Default::default()
}
}
#[test]
fn failed_format_change_discards_old_playback_state() {
let (mut handler, dropped) = active_handler(INPUT_RATE);
handler
.audio_buffer
.0
.lock()
.unwrap()
.push_slice(&[SAMPLE_VALUE; CHANNELS as usize]);
handler.audio_decoder = Some(decoder(OUTPUT_RATE));
handler.sample_rate = (OUTPUT_RATE, OUTPUT_RATE);
handler.handle_audio_start_result(
Err(anyhow!("Injected output stream startup failure")),
false,
);
assert!(dropped.load(Ordering::SeqCst));
assert!(handler.audio_stream.is_none());
assert!(handler.audio_resampler.is_none());
assert!(handler.audio_decoder.is_none());
assert!(!handler.playback_status.ready.load(Ordering::Acquire));
handler.handle_frame(audio_frame());
assert_eq!(handler.audio_buffer.0.lock().unwrap().occupied_len(), 0);
}
#[test]
fn successful_start_or_compatible_failure_preserves_audio_packet_duration() {
for result in [
Ok(()),
Err(anyhow!("Injected compatible stream replacement failure")),
] {
let (mut handler, dropped) = active_handler(OUTPUT_RATE);
handler.handle_audio_start_result(result, true);
handler.handle_frame(audio_frame());
assert!(!dropped.load(Ordering::SeqCst));
assert_eq!(
handler.audio_buffer.0.lock().unwrap().occupied_len(),
OUTPUT_RATE as usize / PACKETS_PER_SECOND * CHANNELS as usize
);
}
}

View File

@@ -1,5 +1,4 @@
use hbb_common::log;
use base::{fs, message_proto::*};
use hbb_common::{fs, log, message_proto::*};
use super::{Data, Interface};

View File

@@ -1,5 +1,7 @@
use base::message_proto::{Message, VoiceCallRequest, VoiceCallResponse};
use hbb_common::get_time;
use hbb_common::{
get_time,
message_proto::{Message, VoiceCallRequest, VoiceCallResponse},
};
use scrap::CodecFormat;
use std::collections::HashMap;

View File

@@ -15,26 +15,8 @@ use crate::{
// Restart msgbox text is kept as a legacy UI fallback; Flutter handles the type as a control event.
const RESTART_REMOTE_DEVICE_NO_DATA_TIMEOUT: Duration = Duration::from_secs(5);
const KCP_CLOSE_REASON_FLUSH_DELAY: Duration = Duration::from_millis(30);
// Deadline for the parting close-reason send once the peer is presumed gone; KCP waits for send
// capacity with no deadline of its own.
const KCP_CLOSE_REASON_GONE_DEADLINE: Duration = Duration::from_millis(500);
// Grace after ICE reports Disconnected, which it does ~5s after it stops hearing from the peer,
// for ~8s in total. Disconnected is transient by design, so this waits out a Wi-Fi roam or a
// sleep/wake rather than acting on the first hint.
const WEBRTC_SUSPECT_GRACE: Duration = Duration::from_secs(3);
// KCP gets no such hint, only how long since a packet arrived; its endpoint pings an idle peer
// about every 2s, so this is several missed pings, and matches the 8s WebRTC arrives at.
const KCP_PEER_SILENCE_LIMIT: Duration = Duration::from_secs(8);
#[cfg(feature = "unix-file-copy-paste")]
use crate::{clipboard::try_empty_clipboard_files, clipboard_file::unix_file_clip};
use base::{
config::keys,
fs::{
self, can_enable_overwrite_detection, get_job, get_string, new_send_confirm,
DigestCheckResult, RemoveJobMeta,
},
message_proto::{permission_info::Permission, *},
};
#[cfg(any(
target_os = "windows",
all(target_os = "macos", feature = "unix-file-copy-paste")
@@ -46,7 +28,12 @@ use hbb_common::tokio::sync::mpsc::error::TryRecvError;
use hbb_common::{
allow_err,
config::{self, LocalConfig, PeerConfig, TransferSerde},
fs::{
self, can_enable_overwrite_detection, get_job, get_string, new_send_confirm,
DigestCheckResult, RemoveJobMeta,
},
get_time, log,
message_proto::{permission_info::Permission, *},
protobuf::Message as _,
rendezvous_proto::ConnType,
timeout,
@@ -198,14 +185,6 @@ 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
@@ -257,9 +236,6 @@ impl<T: InvokeUiSession> Remote<T> {
let _keep_it = client::hc_connection(feedback, rendezvous_server, token).await;
let mut last_recv_time = Instant::now();
let mut webrtc_suspect_since: Option<Instant> = None;
let mut last_rx_progress = peer.rx_progress();
let mut peer_gone = false;
loop {
tokio::select! {
@@ -326,37 +302,6 @@ impl<T: InvokeUiSession> Remote<T> {
self.handler.msgbox("restarting-show", "Restarting remote device", "Connection in progress. Please wait.", "");
break;
}
let rx_progress = peer.rx_progress();
// `None` for transports that report none, and it never changes for a
// given one, so they are inert here.
let progressed = rx_progress != last_rx_progress;
last_rx_progress = rx_progress;
if peer.webrtc_disconnected() && !progressed {
webrtc_suspect_since.get_or_insert_with(Instant::now);
} else {
webrtc_suspect_since = None;
}
// Neither limit is a hard upper bound. A send is awaited inline in
// this loop, so one in progress delays this tick - bounded on WebRTC
// by the timeout the stream was built with, not bounded at all on
// KCP. The 30s watchdog above shares the loop and the same delay.
peer_gone = webrtc_suspect_since
.map_or(false, |since| since.elapsed() >= WEBRTC_SUSPECT_GRACE)
|| kcp
.as_ref()
.and_then(|k| k.peer_silent_for())
.map_or(false, |silent| silent >= KCP_PEER_SILENCE_LIMIT);
if peer_gone {
log::info!("Peer stopped answering, reconnecting");
#[cfg(feature = "flutter")]
self.handler.msgbox("restarting-show", "Connecting...", "Connection in progress. Please wait.", "");
// Sciter knows no `restarting-show` and would show a dialog that
// waits for a click, where the timeout this arrives ahead of is
// retryable and reconnects on its own. Keep that message for it.
#[cfg(not(feature = "flutter"))]
self.handler.msgbox("error", "Connection Error", "Timeout", "");
break;
}
let elapsed = fps_instant.elapsed().as_millis();
if elapsed < 1000 {
continue;
@@ -402,11 +347,6 @@ impl<T: InvokeUiSession> Remote<T> {
s.send(()).ok();
}
if kcp.is_some() {
// Attempted rather than skipped even here: if the loss was one-way the peer
// does get it, and drops its side instead of waiting out its own timeout.
if peer_gone {
peer.set_send_timeout(KCP_CLOSE_REASON_GONE_DEADLINE.as_millis() as u64);
}
// Send the close reason if it hasn't been sent yet, as KCP cannot detect the socket close event.
self.send_close_reason(&mut peer, "kcp").await;
// KCP does not send messages immediately, so wait to ensure the last message is sent.
@@ -2088,8 +2028,9 @@ impl<T: InvokeUiSession> Remote<T> {
#[cfg(target_os = "windows")]
Ok(file_transfer_send_request::FileType::Printer) => {
#[cfg(feature = "flutter")]
let action =
LocalConfig::get_option(keys::OPTION_PRINTER_INCOMING_JOB_ACTION);
let action = LocalConfig::get_option(
config::keys::OPTION_PRINTER_INCOMING_JOB_ACTION,
);
#[cfg(not(feature = "flutter"))]
let action = "";
if action == "dismiss" {
@@ -2098,7 +2039,7 @@ impl<T: InvokeUiSession> Remote<T> {
let id = fs::get_next_job_id();
#[cfg(feature = "flutter")]
let allow_auto_print = LocalConfig::get_bool_option(
keys::OPTION_PRINTER_ALLOW_AUTO_PRINT,
config::keys::OPTION_PRINTER_ALLOW_AUTO_PRINT,
);
#[cfg(not(feature = "flutter"))]
let allow_auto_print = false;
@@ -2106,7 +2047,9 @@ impl<T: InvokeUiSession> Remote<T> {
let printer_name = if action == "" {
"".to_string()
} else {
LocalConfig::get_option(keys::OPTION_PRINTER_SELECTED_NAME)
LocalConfig::get_option(
config::keys::OPTION_PRINTER_SELECTED_NAME,
)
};
self.handler.printer_response(id, _s.path, printer_name);
} else {
@@ -2172,7 +2115,7 @@ impl<T: InvokeUiSession> Remote<T> {
.handle_screenshot_resp(response.sid, response.msg);
}
Some(message::Union::TerminalResponse(response)) => {
use base::message_proto::terminal_response::Union;
use hbb_common::message_proto::terminal_response::Union;
if let Some(Union::Opened(opened)) = &response.union {
if opened.success && !opened.service_id.is_empty() {
let mut lc = self.handler.lc.write().unwrap();
@@ -2396,10 +2339,14 @@ impl<T: InvokeUiSession> Remote<T> {
}
#[cfg(any(target_os = "windows", feature = "unix-file-copy-paste"))]
async fn handle_cliprdr_msg(&mut self, clip: base::message_proto::Cliprdr, _peer: &mut Stream) {
async fn handle_cliprdr_msg(
&mut self,
clip: hbb_common::message_proto::Cliprdr,
_peer: &mut Stream,
) {
log::debug!("handling cliprdr msg from server peer");
#[cfg(feature = "flutter")]
if let Some(base::message_proto::cliprdr::Union::FormatList(_)) = &clip.union {
if let Some(hbb_common::message_proto::cliprdr::Union::FormatList(_)) = &clip.union {
if self.client_conn_id
!= clipboard::get_client_conn_id(&crate::flutter::get_cur_peer_id()).unwrap_or(0)
{
@@ -2509,7 +2456,8 @@ impl<T: InvokeUiSession> Remote<T> {
);
self.video_threads.insert(display, video_thread);
if self.video_threads.len() == 1 {
let auto_record = LocalConfig::get_bool_option(keys::OPTION_ALLOW_AUTO_RECORD_OUTGOING);
let auto_record =
LocalConfig::get_bool_option(config::keys::OPTION_ALLOW_AUTO_RECORD_OUTGOING);
self.handler.lc.write().unwrap().record_state = auto_record;
self.update_record_state();
}

View File

@@ -1,7 +1,6 @@
#[cfg(not(any(target_os = "android", target_os = "ios")))]
use crate::clipboard::{update_clipboard, ClipboardSide};
use base::message_proto::*;
use hbb_common::ResultType;
use hbb_common::{message_proto::*, ResultType};
use std::sync::Mutex;
lazy_static::lazy_static! {
@@ -59,14 +58,11 @@ impl Screenshot {
}
fn handle_screenshot(&mut self, action: String) -> String {
let Some(data) = self.data.as_ref().cloned() else {
let Some(data) = self.data.take() else {
return "No cached screenshot".to_owned();
};
match Self::handle_screenshot_(data, action) {
Ok(()) => {
self.data = None;
"".to_owned()
}
Ok(()) => "".to_owned(),
Err(e) => e.to_string(),
}
}
@@ -101,37 +97,3 @@ pub fn set_screenshot(data: bytes::Bytes) {
pub fn handle_screenshot(action: String) -> String {
SCREENSHOT.lock().unwrap().handle_screenshot(action)
}
#[cfg(test)]
mod tests {
use super::Screenshot;
use std::time::{SystemTime, UNIX_EPOCH};
#[test]
fn preserves_cached_screenshot_when_save_fails() {
let data = bytes::Bytes::from_static(b"screenshot data");
let mut screenshot = Screenshot {
data: Some(data.clone()),
};
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let missing_parent = std::env::temp_dir()
.join(format!("rustdesk-screenshot-missing-parent-{unique}"))
.join("screenshot.png");
let valid_path = std::env::temp_dir().join(format!("rustdesk-screenshot-{unique}.png"));
let error = screenshot.handle_screenshot(format!("0:{}", missing_parent.display()));
assert!(!error.is_empty());
assert_eq!(screenshot.data.as_deref(), Some(data.as_ref()));
assert_eq!(
screenshot.handle_screenshot(format!("0:{}", valid_path.display())),
""
);
assert!(screenshot.data.is_none());
assert_eq!(std::fs::read(&valid_path).unwrap(), data.as_ref());
std::fs::remove_file(valid_path).unwrap();
}
}

View File

@@ -2,8 +2,7 @@
use arboard::{ClipboardData, ClipboardFormat};
#[cfg(target_os = "linux")]
use arboard::{LinuxClipboardKind, SetExtLinux};
use hbb_common::{bail, log, ResultType};
use base::message_proto::*;
use hbb_common::{bail, log, message_proto::*, ResultType};
use std::{
sync::{Arc, Mutex},
time::Duration,
@@ -516,10 +515,10 @@ impl ClipboardContext {
// The host-side clear file clipboard `let _ = self.inner.clear();`,
// does not work on KDE Plasma for the installed version.
// Don't use `base::platform::linux::is_kde()` here.
// Don't use `hbb_common::platform::linux::is_kde()` here.
// It's not correct in the server process.
#[cfg(target_os = "linux")]
let is_kde_x11 = base::platform::linux::is_kde_session()
let is_kde_x11 = hbb_common::platform::linux::is_kde_session()
&& crate::platform::linux::is_x11();
#[cfg(target_os = "macos")]
let is_kde_x11 = false;
@@ -582,7 +581,7 @@ pub fn get_current_clipboard_msg(
multi_clipboards
.clipboards
.iter()
.find(|c| c.format.enum_value() == Ok(base::message_proto::ClipboardFormat::Text))
.find(|c| c.format.enum_value() == Ok(hbb_common::message_proto::ClipboardFormat::Text))
.map(|c| {
let mut msg = Message::new();
msg.set_clipboard(c.clone());
@@ -630,8 +629,8 @@ mod proto {
use arboard::ClipboardData;
use hbb_common::{
compress::{compress as compress_func, decompress},
message_proto::{Clipboard, ClipboardFormat, Message, MultiClipboards},
};
use base::message_proto::{Clipboard, ClipboardFormat, Message, MultiClipboards};
fn plain_to_proto(s: String, format: ClipboardFormat) -> Clipboard {
let compressed = compress_func(s.as_bytes());
@@ -699,7 +698,7 @@ mod proto {
let content = if compress {
compressed
} else {
d
s.bytes().collect::<Vec<u8>>()
};
Clipboard {
compress,
@@ -795,29 +794,6 @@ mod proto {
msg
})
}
#[cfg(all(test, not(target_os = "android")))]
mod tests {
use super::{from_clipboard, special_to_proto};
use arboard::ClipboardData;
#[test]
fn preserves_uncompressed_special_clipboard_data() {
let data = vec![0x01, 0x02, 0x03];
let name = "custom-format".to_owned();
let clipboard = special_to_proto(data.clone(), name.clone());
assert!(!clipboard.compress);
assert_eq!(clipboard.content.as_ref(), data.as_slice());
assert_eq!(clipboard.special_name, name);
assert!(matches!(
from_clipboard(clipboard),
Some(ClipboardData::Special((restored_name, restored_data)))
if restored_name == name && restored_data == data
));
}
}
}
#[cfg(all(test, not(target_os = "android")))]

View File

@@ -1,5 +1,5 @@
use clipboard::ClipboardFile;
use base::message_proto::*;
use hbb_common::message_proto::*;
pub fn clip_2_msg(clip: ClipboardFile) -> Message {
match clip {

View File

@@ -1,14 +1,13 @@
use std::{
collections::HashMap,
future::Future,
net::SocketAddr,
net::{SocketAddr, ToSocketAddrs},
sync::{Arc, Mutex, RwLock},
task::Poll,
};
use serde_json::{json, Map, Value};
use base::{config::keys, message_proto::*};
#[cfg(not(target_os = "ios"))]
use hbb_common::whoami;
use hbb_common::{
@@ -17,10 +16,13 @@ use hbb_common::{
async_recursion::async_recursion,
bail, base64,
bytes::Bytes,
config::{self, use_ws, Config, LocalConfig, CONNECT_TIMEOUT, READ_TIMEOUT, RENDEZVOUS_PORT},
config::{
self, keys, use_ws, Config, LocalConfig, CONNECT_TIMEOUT, READ_TIMEOUT, RENDEZVOUS_PORT,
},
futures::future::join_all,
futures_util::future::poll_fn,
get_version_number, log,
message_proto::*,
protobuf::{Enum, Message as _},
rendezvous_proto::*,
socket_client,
@@ -408,11 +410,6 @@ pub fn resample_channels(
}
}
#[cfg(all(feature = "use_dasp", feature = "use_samplerate"))]
compile_error!(
"features `use_dasp` and `use_samplerate` are mutually exclusive; disable default features before selecting `use_samplerate`"
);
#[cfg(feature = "use_dasp")]
pub fn audio_resample(
data: &[f32],
@@ -449,7 +446,7 @@ pub fn audio_resample(
}
}
#[cfg(all(feature = "use_samplerate", not(feature = "use_dasp")))]
#[cfg(feature = "use_samplerate")]
pub fn audio_resample(
data: &[f32],
sample_rate0: u32,
@@ -1156,13 +1153,6 @@ 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,
@@ -1177,19 +1167,9 @@ 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
|| key == keys::OPTION_ENABLE_WEBRTC
{
if key == keys::OPTION_ENABLE_UDP_PUNCH || key == keys::OPTION_ENABLE_IPV6_PUNCH {
if v.is_empty() {
if !is_public(&Config::get_rendezvous_server()) {
return "N".to_owned();
@@ -2146,21 +2126,11 @@ 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, res.dtls_fingerprint))
Ok((res.id, pk))
} else {
bail!("Wrong their public length");
}
@@ -2462,26 +2432,16 @@ 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: String) -> ResultType<(SocketAddr, String)> {
async fn stun_ipv6_test(stun_server: &str) -> ResultType<(SocketAddr, String)> {
use std::net::ToSocketAddrs;
use stunclient::StunClient;
let local_addr = SocketAddr::from(([0u16; 8], 0)); // [::]:0
let socket = UdpSocket::bind(&local_addr).await?;
// 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())
let Some(stun_addr) = stun_server
.to_socket_addrs()?
.filter(|x| x.is_ipv6())
.next()
else {
bail!(
"Failed to resolve STUN ipv6 server address: {}",
@@ -2491,36 +2451,81 @@ async fn stun_ipv6_test(stun_server: String) -> 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)
(addr, stun_server.to_owned())
} 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?;
// 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?;
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]
)
})?;
socket.connect(addr).await?;
Ok(socket.local_addr()?)
}
@@ -2587,9 +2592,9 @@ pub async fn test_ipv6() -> Option<tokio::task::JoinHandle<()>> {
Some(tokio::spawn(async {
use hbb_common::futures::future::{select_ok, FutureExt};
let tests = hbb_common::webrtc::WebRTCStream::default_stun_servers()
.into_iter()
.map(|stun| stun_ipv6_test(stun).boxed())
let tests = STUNS_V6
.iter()
.map(|&stun| stun_ipv6_test(stun).boxed())
.collect::<Vec<_>>();
match select_ok(tests).await {
@@ -2610,117 +2615,51 @@ 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);
// 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;
const MAX_TIME: Duration = Duration::from_secs(20);
let mut packets_sent = 0;
socket.send(&[]).await.ok();
packets_sent += 1;
let mut last_send_time = Instant::now();
let tm = Instant::now();
// Absolute instants, not relative sleeps: `select!` rebuilds every arm each iteration, so a
// peer that keeps the receive side ready restarts a relative timer before it can fire. That
// both defeats MAX_TIME and starves the retransmit, and the peer decides the rate - an
// old-build peer's empty datagrams match no arm below and loop without even a pause.
let deadline = tm + MAX_TIME;
let mut next_probe = tm + retry_interval;
let mut data = [0u8; 1500];
loop {
tokio::select! {
_ = tokio::time::sleep_until(deadline) => {
bail!("UDP punch is timed out, {probes_sent} probes sent, {probes_seen} probes received, acked: {acked}, {recv_errors} recv errors absorbed");
}
_ = tokio::time::sleep_until(next_probe) => {
socket.send(&probe).await.ok();
probes_sent += 1;
retry_interval = std::cmp::min(retry_interval.mul_f64(1.5), MAX_INTERVAL);
next_probe = Instant::now() + retry_interval;
_ = 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);
}
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
);
last_send_time = Instant::now();
}
}
res = socket.recv(&mut data) => match res {
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;
}
Err(e) => bail!("UDP punch failed, {packets_sent} packets sent: {e}"),
Ok(n) => {
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);
// log::debug!("UDP punch succeeded after sending {} packets after {:?}", packets_sent, tm.elapsed());
if listen {
if n == 0 {
continue;
}
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);
}
}
}
@@ -2844,38 +2783,6 @@ mod tests {
)
}
// The deadline must hold against a peer that keeps the receive side ready. `select!` rebuilds
// its arms every iteration, so a relative sleep would be restarted by every datagram and the
// punch would run for as long as the peer keeps talking, with no outer timeout to stop it.
#[tokio::test]
async fn test_udp_punch_deadline_survives_a_talkative_peer() {
let a = UdpSocket::bind("127.0.0.1:0").await.unwrap();
let b = UdpSocket::bind("127.0.0.1:0").await.unwrap();
let (a_addr, b_addr) = (a.local_addr().unwrap(), b.local_addr().unwrap());
a.connect(b_addr).await.unwrap();
b.connect(a_addr).await.unwrap();
// Empty datagrams answer no probe and match no return branch, so they only feed the loop.
// Sent well past the punch deadline so a restarted timer would show up as a long run.
let flooder = tokio::spawn(async move {
let end = Instant::now() + Duration::from_secs(12);
while Instant::now() < end {
if b.send(&[]).await.is_err() {
break;
}
sleep(Duration::from_millis(5)).await;
}
});
let start = Instant::now();
let res = punch_udp(Arc::new(a), false).await;
let elapsed = start.elapsed();
flooder.abort();
assert!(res.is_err(), "the punch should have timed out");
assert!(
elapsed < Duration::from_secs(6),
"the punch ran for {elapsed:?}; its deadline did not hold"
);
}
#[test]
fn untrusted_peer_id_validation() {
let cases = [

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