mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-07-08 16:24:57 +03:00
Compare commits
74 Commits
ce9ae753e2
...
harden_wf_
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8aeafd5401 | ||
|
|
e264283bf2 | ||
|
|
1053b53b57 | ||
|
|
233eb49e4d | ||
|
|
c974514710 | ||
|
|
52ff648411 | ||
|
|
55ae167394 | ||
|
|
ba5a535891 | ||
|
|
3ab9d4e46d | ||
|
|
bb8ffef7f0 | ||
|
|
1c2188f80b | ||
|
|
ff1ca85827 | ||
|
|
f695413ee7 | ||
|
|
b0555639a2 | ||
|
|
cd4ed15214 | ||
|
|
96ab03aa65 | ||
|
|
a30af8a321 | ||
|
|
ed9a423570 | ||
|
|
5cc7355dd2 | ||
|
|
a9eca51ab0 | ||
|
|
9fdb8410d3 | ||
|
|
a2b79462ab | ||
|
|
dce221be5a | ||
|
|
9d1ab3fba3 | ||
|
|
b3bd18845d | ||
|
|
435f6ec61d | ||
|
|
0497814004 | ||
|
|
4b1ef9e20d | ||
|
|
10d5250d23 | ||
|
|
2ee580d49d | ||
|
|
4a54029cac | ||
|
|
001848bf2f | ||
|
|
989bf80fe8 | ||
|
|
78b5f47668 | ||
|
|
97e9e44faa | ||
|
|
ff226f6d80 | ||
|
|
0cbdb6ffb3 | ||
|
|
b8117c5c34 | ||
|
|
a69614d464 | ||
|
|
58ee593e26 | ||
|
|
09bc9056c9 | ||
|
|
0c6df924d1 | ||
|
|
456817b4f4 | ||
|
|
16570ee34f | ||
|
|
2b40c61d8e | ||
|
|
dcc64cdeae | ||
|
|
2747d3d8b4 | ||
|
|
3c574a4182 | ||
|
|
311d4708e5 | ||
|
|
5cf4323d07 | ||
|
|
3976701ac6 | ||
|
|
9ded8d6ab2 | ||
|
|
cd2fff0655 | ||
|
|
10f61ffdc2 | ||
|
|
d72952bf93 | ||
|
|
a7c55db9ac | ||
|
|
bff47e2b81 | ||
|
|
3d478c4935 | ||
|
|
a658e987b7 | ||
|
|
7c8b0adc1e | ||
|
|
a732ebc3e1 | ||
|
|
30c0867e40 | ||
|
|
8f50ea64dc | ||
|
|
0797ebb695 | ||
|
|
c9391fb894 | ||
|
|
8a955888bf | ||
|
|
36e812e550 | ||
|
|
8baa995c7a | ||
|
|
f4a0535289 | ||
|
|
6665242edf | ||
|
|
3cdf1cce54 | ||
|
|
88ae00ba73 | ||
|
|
7c26575dbd | ||
|
|
93d064a9b0 |
@@ -2,6 +2,8 @@
|
||||
rustflags = ["-Ctarget-feature=+crt-static"]
|
||||
[target.i686-pc-windows-msvc]
|
||||
rustflags = ["-C", "target-feature=+crt-static", "-C", "link-args=/NODEFAULTLIB:MSVCRT"]
|
||||
[target.aarch64-pc-windows-msvc]
|
||||
rustflags = ["-Ctarget-feature=+crt-static"]
|
||||
[target.'cfg(target_os="macos")']
|
||||
rustflags = [
|
||||
"-C", "link-args=-sectcreate __CGPreLoginApp __cgpreloginapp /dev/null",
|
||||
|
||||
39
.github/patches/apply_flutter_3.44_source_patches.sh
vendored
Normal file
39
.github/patches/apply_flutter_3.44_source_patches.sh
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
# Applies the Flutter 3.44-only source/pubspec changes on the fly, in CI only.
|
||||
#
|
||||
# Windows arm64 needs Flutter >= 3.44 (the first stable release shipping an arm64 Dart SDK +
|
||||
# engine), which renamed DialogTheme/TabBarTheme -> *Data and needs newer extended_text/
|
||||
# google_fonts. Every other platform is still on Flutter 3.24.5, where the old names/versions
|
||||
# are required, so these changes are kept OUT of the committed sources and applied here instead.
|
||||
#
|
||||
# Used by BOTH the Windows arm64 build (flutter-build.yml) and its dedicated bridge artifact
|
||||
# (bridge.yml) so they share an identical 3.44 source state -- the generated *.freezed.dart must
|
||||
# compile against the same Flutter/freezed version the arm64 build resolves.
|
||||
#
|
||||
# Remove this script (and commit the changes) once upstream bumps Flutter across the board.
|
||||
#
|
||||
# Run from the repository root. sed is used (not a git-apply patch) because the checked-out
|
||||
# sources are CRLF on the windows-11-arm runner; the substitutions below are anchor-free and
|
||||
# therefore CRLF-safe.
|
||||
set -euo pipefail
|
||||
|
||||
# ThemeData API renames (Flutter 3.27+):
|
||||
sed -i 's/dialogTheme: DialogTheme(/dialogTheme: DialogThemeData(/g' flutter/lib/common.dart
|
||||
sed -i 's/tabBarTheme: const TabBarTheme(/tabBarTheme: const TabBarThemeData(/g' flutter/lib/common.dart
|
||||
sed -i '/static ThemeData lightTheme = ThemeData(/,/static ThemeData darkTheme = ThemeData(/s/dialogTheme: DialogThemeData(/dialogTheme: DialogThemeData(\
|
||||
backgroundColor: Colors.white,/' flutter/lib/common.dart
|
||||
sed -i '/static ThemeData darkTheme = ThemeData(/,/scrollbarTheme: scrollbarThemeDark,/s/dialogTheme: DialogThemeData(/dialogTheme: DialogThemeData(\
|
||||
backgroundColor: Color(0xFF18191E),/' flutter/lib/common.dart
|
||||
# Dependency bumps required by the newer Dart/Flutter:
|
||||
sed -i 's/extended_text: 14.0.0/extended_text: 15.0.2/' flutter/pubspec.yaml
|
||||
sed -i 's/google_fonts: \^6.2.1/google_fonts: ^8.1.0/' flutter/pubspec.yaml
|
||||
|
||||
# Fail loudly if any expected string drifted, so we never silently build unpatched:
|
||||
grep -qF 'dialogTheme: DialogThemeData(' flutter/lib/common.dart
|
||||
grep -qF 'tabBarTheme: const TabBarThemeData(' flutter/lib/common.dart
|
||||
grep -qF 'backgroundColor: Colors.white,' flutter/lib/common.dart
|
||||
grep -qF 'backgroundColor: Color(0xFF18191E),' flutter/lib/common.dart
|
||||
grep -qF 'extended_text: 15.0.2' flutter/pubspec.yaml
|
||||
grep -qF 'google_fonts: ^8.1.0' flutter/pubspec.yaml
|
||||
|
||||
git --no-pager diff -- flutter/lib/common.dart flutter/pubspec.yaml
|
||||
28
.github/workflows/bridge.yml
vendored
28
.github/workflows/bridge.yml
vendored
@@ -7,7 +7,6 @@ on:
|
||||
|
||||
env:
|
||||
CARGO_EXPAND_VERSION: "1.0.95"
|
||||
FLUTTER_VERSION: "3.22.3"
|
||||
FLUTTER_RUST_BRIDGE_VERSION: "1.80.1"
|
||||
RUST_VERSION: "1.75" # https://github.com/rustdesk/rustdesk/discussions/7503
|
||||
|
||||
@@ -18,10 +17,21 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
job:
|
||||
# Default bridge for every platform still on Flutter 3.24.5 (generated with 3.22.3).
|
||||
- {
|
||||
target: x86_64-unknown-linux-gnu,
|
||||
os: ubuntu-22.04,
|
||||
extra-build-args: "",
|
||||
flutter-version: "3.22.3",
|
||||
artifact-name: "bridge-artifact",
|
||||
}
|
||||
# Dedicated bridge for the Windows arm64 build (Flutter 3.44); runs in parallel.
|
||||
- {
|
||||
target: x86_64-unknown-linux-gnu,
|
||||
os: ubuntu-22.04,
|
||||
extra-build-args: "",
|
||||
flutter-version: "3.44.0",
|
||||
artifact-name: "bridge-artifact-flutter-3.44",
|
||||
}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
@@ -64,13 +74,13 @@ jobs:
|
||||
uses: actions/cache@6f8efc29b200d32929f49075959781ed54ec270c # v3
|
||||
with:
|
||||
path: /tmp/flutter_rust_bridge
|
||||
key: vcpkg-${{ matrix.job.arch }}
|
||||
key: bridge-${{ matrix.job.flutter-version }}
|
||||
|
||||
- name: Install flutter
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
flutter-version: ${{ matrix.job.flutter-version }}
|
||||
cache: true
|
||||
|
||||
- name: Install flutter rust bridge deps
|
||||
@@ -78,7 +88,15 @@ jobs:
|
||||
run: |
|
||||
cargo install cargo-expand --version ${{ env.CARGO_EXPAND_VERSION }} --locked
|
||||
cargo install flutter_rust_bridge_codegen --version ${{ env.FLUTTER_RUST_BRIDGE_VERSION }} --features "uuid" --locked
|
||||
pushd flutter && sed -i -e 's/extended_text: 14.0.0/extended_text: 13.0.0/g' pubspec.yaml && flutter pub get && popd
|
||||
if [[ "${{ matrix.job.flutter-version }}" == "3.22.3" ]]; then
|
||||
# Default Flutter 3.22.3: extended_text 14 needs a newer Dart, so downgrade for resolution.
|
||||
sed -i -e 's/extended_text: 14.0.0/extended_text: 13.0.0/g' flutter/pubspec.yaml
|
||||
else
|
||||
# Flutter 3.44 bridge for Windows arm64: match that build's source/pubspec state so the
|
||||
# generated *.freezed.dart compiles against the same Flutter/freezed it resolves.
|
||||
bash .github/patches/apply_flutter_3.44_source_patches.sh
|
||||
fi
|
||||
pushd flutter && flutter pub get && popd
|
||||
|
||||
- name: Run flutter rust bridge
|
||||
run: |
|
||||
@@ -88,7 +106,7 @@ jobs:
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: bridge-artifact
|
||||
name: ${{ matrix.job.artifact-name }}
|
||||
path: |
|
||||
./src/bridge_generated.rs
|
||||
./src/bridge_generated.io.rs
|
||||
|
||||
1
.github/workflows/ci.yml
vendored
1
.github/workflows/ci.yml
vendored
@@ -81,6 +81,7 @@ jobs:
|
||||
# - { target: x86_64-apple-darwin , os: macos-10.15 }
|
||||
# - { target: x86_64-pc-windows-gnu , os: windows-2022 }
|
||||
# - { target: x86_64-pc-windows-msvc , os: windows-2022 }
|
||||
# - { target: aarch64-pc-windows-msvc , os: windows-11-arm }
|
||||
- { target: x86_64-unknown-linux-gnu , os: ubuntu-24.04 }
|
||||
# - { target: x86_64-unknown-linux-musl , os: ubuntu-20.04, use-cross: true }
|
||||
steps:
|
||||
|
||||
129
.github/workflows/flutter-build.yml
vendored
129
.github/workflows/flutter-build.yml
vendored
@@ -27,6 +27,11 @@ env:
|
||||
LLVM_VERSION: "15.0.6"
|
||||
FLUTTER_VERSION: "3.24.5"
|
||||
ANDROID_FLUTTER_VERSION: "3.24.5"
|
||||
# Windows arm64 only: the first stable Flutter to ship a native arm64 Windows Dart SDK +
|
||||
# engine is 3.44. Every other platform stays on FLUTTER_VERSION (3.24.5) until Windows 7
|
||||
# support is restored after the upstream-wide Flutter bump. The arm64 job patches the few
|
||||
# 3.44-only source/pubspec changes on the fly (see "Patch RustDesk sources for Flutter 3.44").
|
||||
FLUTTER_WINDOWS_ARM_VERSION: "3.44.0"
|
||||
# for arm64 linux because official Dart SDK does not work
|
||||
FLUTTER_ELINUX_VERSION: "3.16.9"
|
||||
TAG_NAME: "${{ inputs.upload-tag }}"
|
||||
@@ -39,7 +44,7 @@ env:
|
||||
# 2. Update the `VCPKG_COMMIT_ID` in `ci.yml` and `playground.yml`.
|
||||
VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b"
|
||||
ARMV7_VCPKG_COMMIT_ID: "6f29f12e82a8293156836ad81cc9bf5af41fe836" # 2025.01.13, got "/opt/artifacts/vcpkg/vcpkg: No such file or directory" with latest version
|
||||
VERSION: "1.4.7"
|
||||
VERSION: "1.4.8"
|
||||
NDK_VERSION: "r28c"
|
||||
#signing keys env variable checks
|
||||
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"
|
||||
@@ -53,14 +58,24 @@ jobs:
|
||||
|
||||
build-RustDeskTempTopMostWindow:
|
||||
uses: ./.github/workflows/third-party-RustDeskTempTopMostWindow.yml
|
||||
with:
|
||||
upload-artifact: ${{ inputs.upload-artifact }}
|
||||
target: windows-2022
|
||||
configuration: Release
|
||||
platform: x64
|
||||
target_version: Windows10
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
job:
|
||||
- {
|
||||
target: windows-2022,
|
||||
platform: x64,
|
||||
}
|
||||
- {
|
||||
target: windows-11-arm,
|
||||
platform: ARM64,
|
||||
}
|
||||
with:
|
||||
upload-artifact: ${{ inputs.upload-artifact }}
|
||||
target: ${{ matrix.job.target }}
|
||||
configuration: Release
|
||||
platform: ${{ matrix.job.platform }}
|
||||
target_version: Windows10
|
||||
|
||||
build-for-windows-flutter:
|
||||
name: ${{ matrix.job.target }}
|
||||
@@ -76,9 +91,20 @@ jobs:
|
||||
target: x86_64-pc-windows-msvc,
|
||||
os: windows-2022,
|
||||
arch: x86_64,
|
||||
flutter-arch: x64,
|
||||
vcpkg-triplet: x64-windows-static,
|
||||
build-args: "--vram",
|
||||
}
|
||||
- {
|
||||
target: aarch64-pc-windows-msvc,
|
||||
os: windows-11-arm,
|
||||
arch: aarch64,
|
||||
flutter-arch: arm64,
|
||||
vcpkg-triplet: arm64-windows-static,
|
||||
# vram is x86/x64-only (NVENC needs CUDA, Intel MediaSDK needs __rdtsc);
|
||||
# no NV/Intel/AMD hardware exists on Windows-on-ARM, so vram stays disabled here.
|
||||
build-args: "",
|
||||
}
|
||||
# - { target: aarch64-pc-windows-msvc, os: windows-2022, arch: aarch64 }
|
||||
steps:
|
||||
- name: Export GitHub Actions cache environment variables
|
||||
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
|
||||
@@ -95,36 +121,91 @@ jobs:
|
||||
- name: Restore bridge files
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: bridge-artifact
|
||||
# arm64 is on Flutter 3.44, so it needs the bridge generated with the same Flutter
|
||||
# (its *.freezed.dart must match the freezed the arm64 build resolves). x64 and every
|
||||
# other platform keep the default 3.22.3-generated bridge.
|
||||
name: ${{ matrix.job.arch == 'aarch64' && 'bridge-artifact-flutter-3.44' || 'bridge-artifact' }}
|
||||
path: ./
|
||||
|
||||
- name: Install LLVM and Clang
|
||||
uses: KyleMayes/install-llvm-action@1a3da29f56261a1e1f937ec88f0856a9b8321d7e # v1
|
||||
uses: KyleMayes/install-llvm-action@ebc0426251bc40c7cd31162802432c68818ab8f0 # v2.0.9
|
||||
with:
|
||||
version: ${{ env.LLVM_VERSION }}
|
||||
|
||||
- name: Install flutter
|
||||
id: flutter
|
||||
# arm64 builds with FLUTTER_WINDOWS_ARM_VERSION (>=3.44); x64 stays on FLUTTER_VERSION.
|
||||
# subosito only ships an x64 Windows SDK (Flutter's release manifest lists x64 only),
|
||||
# so it installs x64 on both arches. The arm64 runner re-bootstraps Dart to arm64 in
|
||||
# the next step.
|
||||
uses: subosito/flutter-action@2783a3f08e1baf891508463f8c6653c258246225 # v2.12.0; https://github.com/subosito/flutter-action/issues/277
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
flutter-version: ${{ matrix.job.arch == 'aarch64' && env.FLUTTER_WINDOWS_ARM_VERSION || env.FLUTTER_VERSION }}
|
||||
architecture: x64
|
||||
|
||||
- name: Force arm64 Dart SDK + engine
|
||||
# The x64 SDK subosito installs bundles an x64 Dart with a matching engine-dart-sdk.stamp,
|
||||
# so update_dart_sdk.ps1 short-circuits (stamp matches -> return) and keeps x64 Dart;
|
||||
# `flutter build windows` then targets the Dart VM's arch = x64, even on this arm64 host.
|
||||
# On this native-arm64 runner (PROCESSOR_ARCHITECTURE=ARM64), deleting the stamp and
|
||||
# re-running update_dart_sdk.ps1 pulls the arm64 Dart (available since Flutter 3.44.0).
|
||||
# https://github.com/flutter/flutter/issues/186730#issuecomment-4573214964
|
||||
if: ${{ matrix.job.arch == 'aarch64' }}
|
||||
run: |
|
||||
$flutterRoot = "${{ steps.flutter.outputs['CACHE-PATH'] }}"
|
||||
Write-Host "PROCESSOR_ARCHITECTURE=$env:PROCESSOR_ARCHITECTURE"
|
||||
Write-Host "Flutter root: $flutterRoot"
|
||||
Remove-Item -Force "$flutterRoot\bin\cache\engine-dart-sdk.stamp" -ErrorAction SilentlyContinue
|
||||
& "$flutterRoot\bin\internal\update_dart_sdk.ps1"
|
||||
# Confirm the Dart we ended up with is arm64 ("on windows_arm64"); fail loudly if not.
|
||||
$dartVer = & "$flutterRoot\bin\dart.bat" --version 2>&1 | Out-String
|
||||
Write-Host $dartVer
|
||||
if ($dartVer -notmatch "windows_arm64") {
|
||||
Write-Error "Expected an arm64 Dart SDK but got: $dartVer"
|
||||
exit 1
|
||||
}
|
||||
& "$flutterRoot\bin\flutter.bat" precache --windows
|
||||
# Fail fast if precache pulled the wrong-arch Windows engine: an arm64 Dart should
|
||||
# fetch windows-arm64 engine artifacts. Bailing here saves the ~25min Rust build.
|
||||
$engineDir = "$flutterRoot\bin\cache\artifacts\engine"
|
||||
Write-Host "Engine artifacts present:"
|
||||
Get-ChildItem $engineDir -Directory | Select-Object -ExpandProperty Name | Write-Host
|
||||
if (-not (Test-Path "$engineDir\windows-arm64-release")) {
|
||||
Write-Error "Expected windows-arm64-release engine artifacts but they are missing (wrong-arch SDK)."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# https://github.com/flutter/flutter/issues/155685
|
||||
# x64 only: arm64 uses the stock native arm64 Windows engine, and the rustdesk/engine
|
||||
# windows-x64-release.zip is built for the 3.24-era x64 engine (matches FLUTTER_VERSION).
|
||||
- name: Replace engine with rustdesk custom flutter engine
|
||||
if: ${{ matrix.job.arch == 'x86_64' }}
|
||||
run: |
|
||||
flutter doctor -v
|
||||
flutter precache --windows
|
||||
Invoke-WebRequest -Uri https://github.com/rustdesk/engine/releases/download/main/windows-x64-release.zip -OutFile windows-x64-release.zip
|
||||
Expand-Archive -Path windows-x64-release.zip -DestinationPath windows-x64-release
|
||||
mv -Force windows-x64-release/* C:/hostedtoolcache/windows/flutter/stable-${{ env.FLUTTER_VERSION }}-x64/bin/cache/artifacts/engine/windows-x64-release/
|
||||
mv -Force windows-x64-release/* C:/hostedtoolcache/windows/flutter/stable-${{ env.FLUTTER_VERSION }}-x64/bin/cache/artifacts/engine/windows-x64-release/
|
||||
|
||||
- name: Patch flutter
|
||||
# x64 stays on Flutter 3.24.5, which needs the dropdown filter patch.
|
||||
# arm64 is on Flutter 3.44 (patched separately below) and does not use this patch.
|
||||
if: ${{ matrix.job.arch == 'x86_64' }}
|
||||
shell: bash
|
||||
run: |
|
||||
cp .github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff $(dirname $(dirname $(which flutter)))
|
||||
cd $(dirname $(dirname $(which flutter)))
|
||||
[[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]] && git apply flutter_3.24.4_dropdown_menu_enableFilter.diff
|
||||
|
||||
- name: Patch RustDesk sources for Flutter 3.44 (arm64)
|
||||
# arm64 is the only target on Flutter 3.44; apply its source/pubspec deltas on the fly
|
||||
# (shared with the 3.44 bridge job) so the committed sources stay on Flutter 3.24.5.
|
||||
# `flutter build` then runs `pub get`, regenerating pubspec.lock for the bumped deps.
|
||||
if: ${{ matrix.job.arch == 'aarch64' }}
|
||||
shell: bash
|
||||
run: bash .github/patches/apply_flutter_3.44_source_patches.sh
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
|
||||
with:
|
||||
@@ -163,11 +244,19 @@ jobs:
|
||||
head -n 100 "${VCPKG_ROOT}/buildtrees/ffmpeg/build-${{ matrix.job.vcpkg-triplet }}-rel-out.log" || true
|
||||
shell: bash
|
||||
|
||||
- name: Set SODIUM_LIB_DIR (arm64)
|
||||
# libsodium-sys ships no arm64 Windows prebuilt lib; point it at the vcpkg-built one
|
||||
# (only for arm64 — leaving it unset lets x64 use the crate's bundled lib).
|
||||
if: ${{ matrix.job.arch == 'aarch64' }}
|
||||
shell: bash
|
||||
run: echo "SODIUM_LIB_DIR=$VCPKG_ROOT/installed/${{ matrix.job.vcpkg-triplet }}/lib" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build rustdesk
|
||||
run: |
|
||||
# Windows: build RustDesk
|
||||
python3 .\build.py --portable --hwcodec --flutter --vram --skip-portable-pack
|
||||
mv ./flutter/build/windows/x64/runner/Release ./rustdesk
|
||||
# --hwcodec is shared by all Windows targets; per-target extras (e.g. --vram) come from the matrix
|
||||
python3 .\build.py --portable --flutter --skip-portable-pack --hwcodec ${{ matrix.job.build-args }}
|
||||
mv ./flutter/build/windows/${{ matrix.job.flutter-arch }}/runner/Release ./rustdesk
|
||||
|
||||
# Download usbmmidd_v2.zip and extract it to ./rustdesk
|
||||
Invoke-WebRequest -Uri https://github.com/rustdesk-org/rdev/releases/download/usbmmidd_v2/usbmmidd_v2.zip -OutFile usbmmidd_v2.zip
|
||||
@@ -223,7 +312,7 @@ jobs:
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
if: ${{ inputs.upload-artifact }}
|
||||
with:
|
||||
name: topmostwindow-artifacts
|
||||
name: ${{ matrix.job.arch == 'aarch64' && 'topmostwindow-artifacts-ARM64' || 'topmostwindow-artifacts-x64' }}
|
||||
path: "./rustdesk"
|
||||
|
||||
- name: Upload unsigned
|
||||
@@ -256,13 +345,18 @@ jobs:
|
||||
uses: microsoft/setup-msbuild@6fb02220983dee41ce7ae257b6f4d8f9bf5ed4ce # v2
|
||||
|
||||
- name: Build msi
|
||||
# Builds the MSI for the matrix arch. res/msi (WiX v4 + native CustomActions) carries
|
||||
# both x64 and ARM64 platform configs; WcaUtil/DUtil ship arm64 libs. msbuild platform
|
||||
# is x64 / ARM64; the produced Package.msi is globbed since its bin/<platform>/ dir varies.
|
||||
if: env.UPLOAD_ARTIFACT == 'true'
|
||||
run: |
|
||||
pushd ./res/msi
|
||||
python preprocess.py --arp -d ../../rustdesk
|
||||
nuget restore msi.sln
|
||||
msbuild msi.sln -p:Configuration=Release -p:Platform=x64 /p:TargetVersion=Windows10
|
||||
mv ./Package/bin/x64/Release/en-us/Package.msi ../../SignOutput/rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.msi
|
||||
$msiPlatform = if ('${{ matrix.job.arch }}' -eq 'aarch64') { 'ARM64' } else { 'x64' }
|
||||
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
|
||||
mv $msi.FullName ../../SignOutput/rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.msi
|
||||
sha256sum ../../SignOutput/rustdesk-*.msi
|
||||
|
||||
- name: Sign rustdesk self-extracted file
|
||||
@@ -1890,6 +1984,7 @@ jobs:
|
||||
sudo apt-get install -y libarchive-tools libfuse2
|
||||
# set-up appimage-builder
|
||||
# https://github.com/AppImage/AppImageKit/issues/1395
|
||||
sudo pip3 install "setuptools_scm<10"
|
||||
sudo pip3 install git+https://github.com/rustdesk-org/appimage-builder.git
|
||||
# run appimage-builder
|
||||
pushd appimage
|
||||
|
||||
2
.github/workflows/playground.yml
vendored
2
.github/workflows/playground.yml
vendored
@@ -17,7 +17,7 @@ env:
|
||||
TAG_NAME: "nightly"
|
||||
VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite"
|
||||
VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b"
|
||||
VERSION: "1.4.7"
|
||||
VERSION: "1.4.8"
|
||||
NDK_VERSION: "r26d"
|
||||
#signing keys env variable checks
|
||||
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"
|
||||
|
||||
@@ -45,16 +45,15 @@ jobs:
|
||||
run: |
|
||||
git clone https://github.com/rustdesk-org/RustDeskTempTopMostWindow RustDeskTempTopMostWindow
|
||||
|
||||
# Build. commit 53b548a5398624f7149a382000397993542ad796 is tag v0.3
|
||||
- name: Build the project
|
||||
run: |
|
||||
cd RustDeskTempTopMostWindow && git checkout 53b548a5398624f7149a382000397993542ad796
|
||||
cd RustDeskTempTopMostWindow && git checkout ecd8d6a139eee76845ea66423fb739af450fda90
|
||||
msbuild ${{ env.project_path }} -p:Configuration=${{ inputs.configuration }} -p:Platform=${{ inputs.platform }} /p:TargetVersion=${{ inputs.target_version }}
|
||||
|
||||
- name: Archive build artifacts
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: ${{ inputs.upload-artifact }}
|
||||
with:
|
||||
name: topmostwindow-artifacts
|
||||
name: topmostwindow-artifacts-${{ inputs.platform }}
|
||||
path: |
|
||||
./${{ env.build_output_dir }}/WindowInjection.dll
|
||||
|
||||
85
.github/workflows/wf-cliprdr-ci.yml
vendored
85
.github/workflows/wf-cliprdr-ci.yml
vendored
@@ -1,85 +0,0 @@
|
||||
name: wf-cliprdr CI
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
paths:
|
||||
- "libs/clipboard/src/windows/**"
|
||||
- "tests/test_invariant_wf_cliprdr.c"
|
||||
- ".github/workflows/wf-cliprdr-ci.yml"
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- "libs/clipboard/src/windows/**"
|
||||
- "tests/test_invariant_wf_cliprdr.c"
|
||||
- ".github/workflows/wf-cliprdr-ci.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: wf_cliprdr invariant test
|
||||
runs-on: windows-2022
|
||||
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up MSVC
|
||||
uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756
|
||||
with:
|
||||
arch: x64
|
||||
|
||||
- name: Setup vcpkg with GitHub Actions binary cache
|
||||
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
|
||||
with:
|
||||
vcpkgDirectory: C:\vcpkg
|
||||
doNotCache: false
|
||||
|
||||
- name: Install vcpkg dependency
|
||||
shell: pwsh
|
||||
run: |
|
||||
& "$env:VCPKG_ROOT\vcpkg.exe" install check:x64-windows --classic --x-install-root="$env:VCPKG_ROOT\installed"
|
||||
|
||||
- name: Build test
|
||||
shell: pwsh
|
||||
run: |
|
||||
$testRoot = Join-Path $env:GITHUB_WORKSPACE 'build\wf-cliprdr'
|
||||
New-Item -ItemType Directory -Force $testRoot | Out-Null
|
||||
|
||||
$testSource = (($env:GITHUB_WORKSPACE -replace '\\', '/') + '/tests/test_invariant_wf_cliprdr.c')
|
||||
$cmakeLists = @(
|
||||
'cmake_minimum_required(VERSION 3.20)'
|
||||
'project(test_invariant_wf_cliprdr C)'
|
||||
''
|
||||
'set(CMAKE_C_STANDARD 11)'
|
||||
'set(CMAKE_C_STANDARD_REQUIRED ON)'
|
||||
'set(CMAKE_C_EXTENSIONS OFF)'
|
||||
''
|
||||
'find_package(check CONFIG REQUIRED)'
|
||||
''
|
||||
'add_executable(test_invariant_wf_cliprdr'
|
||||
' "TEST_SOURCE"'
|
||||
')'
|
||||
''
|
||||
'target_link_libraries(test_invariant_wf_cliprdr PRIVATE'
|
||||
' $<$<TARGET_EXISTS:Check::check>:Check::check>'
|
||||
' $<$<NOT:$<TARGET_EXISTS:Check::check>>:Check::checkShared>'
|
||||
')'
|
||||
) -join [Environment]::NewLine
|
||||
$cmakeLists.Replace('TEST_SOURCE', $testSource) | Set-Content -NoNewline (Join-Path $testRoot 'CMakeLists.txt')
|
||||
|
||||
cmake -S $testRoot -B (Join-Path $testRoot 'out') -G "Visual Studio 17 2022" -A x64 -DCMAKE_TOOLCHAIN_FILE="$env:VCPKG_ROOT\scripts\buildsystems\vcpkg.cmake" -DVCPKG_TARGET_TRIPLET=x64-windows
|
||||
cmake --build (Join-Path $testRoot 'out') --config Release
|
||||
|
||||
- name: Run test
|
||||
shell: pwsh
|
||||
run: .\build\wf-cliprdr\out\Release\test_invariant_wf_cliprdr.exe
|
||||
37
Cargo.lock
generated
37
Cargo.lock
generated
@@ -1324,7 +1324,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "clipboard-master"
|
||||
version = "4.0.0-beta.6"
|
||||
source = "git+https://github.com/rustdesk-org/clipboard-master#ddc39f00a6211959489ae683aa6ae6eedf03a809"
|
||||
source = "git+https://github.com/rustdesk-org/clipboard-master#7762d74e38db37cfeb6ded88c964b9cdbddfb6db"
|
||||
dependencies = [
|
||||
"objc",
|
||||
"objc-foundation",
|
||||
@@ -2329,7 +2329,7 @@ version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412"
|
||||
dependencies = [
|
||||
"libloading 0.8.4",
|
||||
"libloading 0.7.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2694,7 +2694,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3952,7 +3952,7 @@ checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4"
|
||||
[[package]]
|
||||
name = "hwcodec"
|
||||
version = "0.7.1"
|
||||
source = "git+https://github.com/rustdesk-org/hwcodec#398e5a8938dd8768ade0fcdc27ea80e8b4b38738"
|
||||
source = "git+https://github.com/rustdesk-org/hwcodec#778df1f99597722473b29443bac22ae6c23946fe"
|
||||
dependencies = [
|
||||
"bindgen 0.59.2",
|
||||
"cc",
|
||||
@@ -4494,7 +4494,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e310b3a6b5907f99202fcdb4960ff45b93735d7c7d96b760fcff8db2dc0e103d"
|
||||
dependencies = [
|
||||
"cfg-if 1.0.0",
|
||||
"windows-targets 0.52.6",
|
||||
"windows-targets 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4695,7 +4695,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "magnum-opus"
|
||||
version = "0.4.0"
|
||||
source = "git+https://github.com/rustdesk-org/magnum-opus#5cd2bf989c148662fa3a2d9d539a71d71fd1d256"
|
||||
source = "git+https://github.com/rustdesk-org/magnum-opus#588c6e1f9ed50c3a01fa64f3bd3e7cdb0378a114"
|
||||
dependencies = [
|
||||
"bindgen 0.59.2",
|
||||
"pkg-config",
|
||||
@@ -6673,7 +6673,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2 0.5.10",
|
||||
"tracing",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6920,7 +6920,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "rdev"
|
||||
version = "0.5.0-2"
|
||||
source = "git+https://github.com/rustdesk-org/rdev#f9b60b1dd0f3300a1b797d7a74c116683cd232c8"
|
||||
source = "git+https://github.com/rustdesk-org/rdev#871bf1c856d6a30af2f56ab8848396a025140855"
|
||||
dependencies = [
|
||||
"cocoa 0.24.1",
|
||||
"core-foundation 0.9.4",
|
||||
@@ -7270,7 +7270,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustdesk"
|
||||
version = "1.4.7"
|
||||
version = "1.4.8"
|
||||
dependencies = [
|
||||
"android-wakelock",
|
||||
"android_logger",
|
||||
@@ -7385,7 +7385,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustdesk-portable-packer"
|
||||
version = "1.4.7"
|
||||
version = "1.4.8"
|
||||
dependencies = [
|
||||
"brotli",
|
||||
"dirs 5.0.1",
|
||||
@@ -7457,7 +7457,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.11.0",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7514,7 +7514,7 @@ dependencies = [
|
||||
"security-framework 3.5.1",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9733,9 +9733,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wayland-protocols-wlr"
|
||||
version = "0.3.3"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd993de54a40a40fbe5601d9f1fbcaef0aebcc5fda447d7dc8f6dcbaae4f8953"
|
||||
checksum = "efd94963ed43cf9938a090ca4f7da58eb55325ec8200c3848963e98dc25b78ec"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"wayland-backend",
|
||||
@@ -10838,16 +10838,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wl-clipboard-rs"
|
||||
version = "0.9.0"
|
||||
version = "0.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4de22eebb1d1e2bad2d970086e96da0e12cde0b411321e5b0f7b2a1f876aa26f"
|
||||
checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"os_pipe",
|
||||
"rustix 0.38.34",
|
||||
"tempfile",
|
||||
"thiserror 1.0.61",
|
||||
"rustix 1.1.2",
|
||||
"thiserror 2.0.17",
|
||||
"tree_magic_mini",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rustdesk"
|
||||
version = "1.4.7"
|
||||
version = "1.4.8"
|
||||
authors = ["rustdesk <info@rustdesk.com>"]
|
||||
edition = "2021"
|
||||
build= "build.rs"
|
||||
@@ -213,7 +213,7 @@ exclude = ["vdi/host", "examples/custom_plugin"]
|
||||
libxdo-sys = { path = "libs/libxdo-sys-stub" }
|
||||
|
||||
[package.metadata.winres]
|
||||
LegalCopyright = "Copyright © 2025 Purslane Ltd. All rights reserved."
|
||||
LegalCopyright = "Copyright © 2026 Purslane Tech Pte. Ltd. All rights reserved."
|
||||
ProductName = "RustDesk"
|
||||
FileDescription = "RustDesk Remote Desktop"
|
||||
OriginalFilename = "rustdesk.exe"
|
||||
|
||||
@@ -18,7 +18,7 @@ AppDir:
|
||||
id: rustdesk
|
||||
name: rustdesk
|
||||
icon: rustdesk
|
||||
version: 1.4.7
|
||||
version: 1.4.8
|
||||
exec: usr/share/rustdesk/rustdesk
|
||||
exec_args: $@
|
||||
apt:
|
||||
|
||||
@@ -18,7 +18,7 @@ AppDir:
|
||||
id: rustdesk
|
||||
name: rustdesk
|
||||
icon: rustdesk
|
||||
version: 1.4.7
|
||||
version: 1.4.8
|
||||
exec: usr/share/rustdesk/rustdesk
|
||||
exec_args: $@
|
||||
apt:
|
||||
|
||||
11
build.py
11
build.py
@@ -17,7 +17,8 @@ osx = platform.platform().startswith(
|
||||
hbb_name = 'rustdesk' + ('.exe' if windows else '')
|
||||
exe_path = 'target/release/' + hbb_name
|
||||
if windows:
|
||||
flutter_build_dir = 'build/windows/x64/runner/Release/'
|
||||
win_arch = 'arm64' if platform.machine().lower() in ('arm64', 'aarch64') else 'x64'
|
||||
flutter_build_dir = f'build/windows/{win_arch}/runner/Release/'
|
||||
elif osx:
|
||||
flutter_build_dir = 'build/macos/Build/Products/Release/'
|
||||
else:
|
||||
@@ -410,7 +411,12 @@ def build_flutter_dmg(version, features):
|
||||
system2(
|
||||
"cp target/release/liblibrustdesk.dylib target/release/librustdesk.dylib")
|
||||
os.chdir('flutter')
|
||||
system2('flutter build macos --release')
|
||||
# cargo builds a single-arch dylib for the host; restrict Xcode to the same arch
|
||||
# so the universal-by-default ARCHS_STANDARD doesn't try to link a missing slice.
|
||||
# FLUTTER_XCODE_* env vars are forwarded to xcodebuild as build settings.
|
||||
mac_arch = 'arm64' if platform.machine().lower() in ('arm64', 'aarch64') else 'x86_64'
|
||||
system2(
|
||||
f'FLUTTER_XCODE_ARCHS={mac_arch} FLUTTER_XCODE_ONLY_ACTIVE_ARCH=YES flutter build macos --release')
|
||||
system2('cp -rf ../target/release/service ./build/macos/Build/Products/Release/RustDesk.app/Contents/MacOS/')
|
||||
'''
|
||||
system2(
|
||||
@@ -506,6 +512,7 @@ def main():
|
||||
'target\\release\\rustdesk.exe')
|
||||
else:
|
||||
print('Not signed')
|
||||
os.makedirs(res_dir, exist_ok=True)
|
||||
system2(
|
||||
f'cp -rf target/release/RustDesk.exe {res_dir}')
|
||||
os.chdir('libs/portable')
|
||||
|
||||
@@ -8,6 +8,7 @@ package com.carriez.flutter_hbb
|
||||
|
||||
import android.accessibilityservice.AccessibilityService
|
||||
import android.accessibilityservice.GestureDescription
|
||||
import android.content.Intent
|
||||
import android.graphics.Path
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
@@ -68,6 +69,16 @@ class InputService : AccessibilityService() {
|
||||
get() = ctx != null
|
||||
}
|
||||
|
||||
private fun notifyInputState() {
|
||||
val inputState = isOpen.toString()
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
MainActivity.flutterMethodChannel?.invokeMethod(
|
||||
"on_state_changed",
|
||||
mapOf("name" to "input", "value" to inputState)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val logTag = "input service"
|
||||
private var leftIsDown = false
|
||||
private var touchPath = Path()
|
||||
@@ -716,6 +727,7 @@ class InputService : AccessibilityService() {
|
||||
override fun onServiceConnected() {
|
||||
super.onServiceConnected()
|
||||
ctx = this
|
||||
notifyInputState()
|
||||
val info = AccessibilityServiceInfo()
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
info.flags = FLAG_INPUT_METHOD_EDITOR or FLAG_RETRIEVE_INTERACTIVE_WINDOWS
|
||||
@@ -734,8 +746,16 @@ class InputService : AccessibilityService() {
|
||||
|
||||
override fun onDestroy() {
|
||||
ctx = null
|
||||
// Keep this fallback even though onUnbind usually notifies first.
|
||||
notifyInputState()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onUnbind(intent: Intent?): Boolean {
|
||||
ctx = null
|
||||
notifyInputState()
|
||||
return super.onUnbind(intent)
|
||||
}
|
||||
|
||||
override fun onInterrupt() {}
|
||||
}
|
||||
|
||||
@@ -200,12 +200,13 @@ class MainActivity : FlutterActivity() {
|
||||
"stop_input" -> {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
InputService.ctx?.disableSelf()
|
||||
} else {
|
||||
InputService.ctx = null
|
||||
Companion.flutterMethodChannel?.invokeMethod(
|
||||
"on_state_changed",
|
||||
mapOf("name" to "input", "value" to InputService.isOpen.toString())
|
||||
)
|
||||
}
|
||||
InputService.ctx = null
|
||||
Companion.flutterMethodChannel?.invokeMethod(
|
||||
"on_state_changed",
|
||||
mapOf("name" to "input", "value" to InputService.isOpen.toString())
|
||||
)
|
||||
result.success(true)
|
||||
}
|
||||
"cancel_notification" -> {
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="199"><path fill="#0089d6" d="M118.432 187.698c32.89-5.81 60.055-10.618 60.367-10.684l.568-.12-31.052-36.935c-17.078-20.314-31.051-37.014-31.051-37.11 0-.182 32.063-88.477 32.243-88.792.06-.105 21.88 37.567 52.893 91.32 29.035 50.323 52.973 91.815 53.195 92.203l.405.707-98.684-.012-98.684-.013 59.8-10.564zM0 176.435c0-.052 14.631-25.451 32.514-56.442l32.514-56.347 37.891-31.799C123.76 14.358 140.867.027 140.935.001c.069-.026-.205.664-.609 1.534s-18.919 40.582-41.145 88.25l-40.41 86.67-29.386.037c-16.162.02-29.385-.005-29.385-.057z"/></svg>
|
||||
|
Before Width: | Height: | Size: 604 B |
7
flutter/assets/display_switcher.svg
Normal file
7
flutter/assets/display_switcher.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||
<g fill="#000000" fill-rule="evenodd">
|
||||
<rect x="4" y="6" width="24" height="16" rx="3"/>
|
||||
<rect x="14.5" y="22" width="3" height="2"/>
|
||||
<rect x="9.5" y="24" width="13" height="2.5" rx="1.25"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 303 B |
@@ -1,3 +1,6 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hbb/common/formatter/id_formatter.dart';
|
||||
import '../../../models/platform_model.dart';
|
||||
@@ -5,27 +8,136 @@ import 'package:flutter_hbb/models/peer_model.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/common/widgets/peer_card.dart';
|
||||
|
||||
@visibleForTesting
|
||||
List<Peer> mergeAutocompletePeers({
|
||||
Iterable<Peer> addressBookPeers = const [],
|
||||
Iterable<Peer> groupPeers = const [],
|
||||
Iterable<Peer> lanPeers = const [],
|
||||
Iterable<Peer> recentPeers = const [],
|
||||
Iterable<String> restRecentPeerIds = const [],
|
||||
}) {
|
||||
final combinedPeers = <String, Peer>{};
|
||||
|
||||
void addPeer(Peer peer) {
|
||||
if (peer.id.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final existingPeer = combinedPeers[peer.id];
|
||||
if (existingPeer == null) {
|
||||
combinedPeers[peer.id] = Peer.copy(peer);
|
||||
} else if (peer.online) {
|
||||
existingPeer.online = true;
|
||||
}
|
||||
}
|
||||
|
||||
for (final peer in addressBookPeers) {
|
||||
addPeer(peer);
|
||||
}
|
||||
for (final peer in groupPeers) {
|
||||
addPeer(peer);
|
||||
}
|
||||
for (final peer in lanPeers) {
|
||||
addPeer(peer);
|
||||
}
|
||||
for (final peer in recentPeers) {
|
||||
addPeer(peer);
|
||||
}
|
||||
for (final id in restRecentPeerIds) {
|
||||
if (id.isNotEmpty && !combinedPeers.containsKey(id)) {
|
||||
combinedPeers[id] = Peer.fromJson({'id': id});
|
||||
}
|
||||
}
|
||||
|
||||
return combinedPeers.values.toList(growable: false);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
bool updateAutocompletePeerOnlineStates(
|
||||
List<Peer> peers, {
|
||||
required Set<String> onlines,
|
||||
required Set<String> offlines,
|
||||
}) {
|
||||
var changed = false;
|
||||
for (final peer in peers) {
|
||||
if (onlines.contains(peer.id)) {
|
||||
if (!peer.online) {
|
||||
peer.online = true;
|
||||
changed = true;
|
||||
}
|
||||
} else if (offlines.contains(peer.id)) {
|
||||
if (peer.online) {
|
||||
peer.online = false;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
List<String> autocompleteOnlineQueryIds(
|
||||
Iterable<Peer> options, {
|
||||
required int limit,
|
||||
}) {
|
||||
final ids = <String>[];
|
||||
final seenIds = <String>{};
|
||||
for (final peer in options) {
|
||||
if (peer.id.isEmpty || seenIds.contains(peer.id)) {
|
||||
continue;
|
||||
}
|
||||
seenIds.add(peer.id);
|
||||
ids.add(peer.id);
|
||||
if (ids.length >= limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
class AllPeersLoader {
|
||||
List<Peer> peers = [];
|
||||
|
||||
bool _isPeersLoading = false;
|
||||
bool _isPeersLoaded = false;
|
||||
Set<String> _lastQueryOnlineIds = {};
|
||||
DateTime _lastQueryOnlineTime = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
Timer? _queryOnlineTimer;
|
||||
List<Peer> _lastQueryOnlineOptions = const [];
|
||||
Set<String> _lastOnlineIds = {};
|
||||
Set<String> _lastOfflineIds = {};
|
||||
final Future<void> Function(List<String> ids) _queryOnlines;
|
||||
final Duration _queryOnlineDebounce;
|
||||
void Function(VoidCallback)? _setState;
|
||||
bool _isCleared = false;
|
||||
|
||||
final String _listenerKey = 'AllPeersLoader';
|
||||
|
||||
late void Function(VoidCallback) setState;
|
||||
static const String _cbQueryOnlines = 'callback_query_onlines';
|
||||
static const Duration _queryOnlineInterval = Duration(seconds: 5);
|
||||
static const Duration _defaultQueryOnlineDebounce =
|
||||
Duration(milliseconds: 300);
|
||||
static const int _maxQueryOnlineOptions = 20;
|
||||
|
||||
bool get needLoad => !_isPeersLoaded && !_isPeersLoading;
|
||||
bool get isPeersLoaded => _isPeersLoaded;
|
||||
|
||||
AllPeersLoader();
|
||||
AllPeersLoader({
|
||||
@visibleForTesting Future<void> Function(List<String> ids)? queryOnlines,
|
||||
@visibleForTesting Duration? queryOnlineDebounce,
|
||||
}) : _queryOnlines = queryOnlines ?? ((ids) => bind.queryOnlines(ids: ids)),
|
||||
_queryOnlineDebounce =
|
||||
queryOnlineDebounce ?? _defaultQueryOnlineDebounce;
|
||||
|
||||
void init(void Function(VoidCallback) setState) {
|
||||
this.setState = setState;
|
||||
_setState = setState;
|
||||
_isCleared = false;
|
||||
gFFI.recentPeersModel.addListener(_mergeAllPeers);
|
||||
gFFI.lanPeersModel.addListener(_mergeAllPeers);
|
||||
gFFI.abModel.addPeerUpdateListener(_listenerKey, _mergeAllPeers);
|
||||
gFFI.groupModel.addPeerUpdateListener(_listenerKey, _mergeAllPeers);
|
||||
platformFFI.registerEventHandler(_cbQueryOnlines, _listenerKey,
|
||||
(evt) async {
|
||||
_updateOnlineState(evt);
|
||||
});
|
||||
}
|
||||
|
||||
void clear() {
|
||||
@@ -33,6 +145,11 @@ class AllPeersLoader {
|
||||
gFFI.lanPeersModel.removeListener(_mergeAllPeers);
|
||||
gFFI.abModel.removePeerUpdateListener(_listenerKey);
|
||||
gFFI.groupModel.removePeerUpdateListener(_listenerKey);
|
||||
platformFFI.unregisterEventHandler(_cbQueryOnlines, _listenerKey);
|
||||
_queryOnlineTimer?.cancel();
|
||||
_lastQueryOnlineOptions = const [];
|
||||
_setState = null;
|
||||
_isCleared = true;
|
||||
}
|
||||
|
||||
Future<void> getAllPeers() async {
|
||||
@@ -59,50 +176,106 @@ class AllPeersLoader {
|
||||
}
|
||||
|
||||
void _mergeAllPeers() {
|
||||
Map<String, dynamic> combinedPeers = {};
|
||||
for (var p in gFFI.abModel.allPeers()) {
|
||||
if (!combinedPeers.containsKey(p.id)) {
|
||||
combinedPeers[p.id] = p.toJson();
|
||||
}
|
||||
if (_isCleared) {
|
||||
return;
|
||||
}
|
||||
for (var p in gFFI.groupModel.peers.map((e) => Peer.copy(e)).toList()) {
|
||||
if (!combinedPeers.containsKey(p.id)) {
|
||||
combinedPeers[p.id] = p.toJson();
|
||||
}
|
||||
}
|
||||
|
||||
List<Peer> parsedPeers = [];
|
||||
for (var peer in combinedPeers.values) {
|
||||
parsedPeers.add(Peer.fromJson(peer));
|
||||
}
|
||||
|
||||
Set<String> peerIds = combinedPeers.keys.toSet();
|
||||
for (final peer in gFFI.lanPeersModel.peers) {
|
||||
if (!peerIds.contains(peer.id)) {
|
||||
parsedPeers.add(peer);
|
||||
peerIds.add(peer.id);
|
||||
}
|
||||
}
|
||||
|
||||
for (final peer in gFFI.recentPeersModel.peers) {
|
||||
if (!peerIds.contains(peer.id)) {
|
||||
parsedPeers.add(peer);
|
||||
peerIds.add(peer.id);
|
||||
}
|
||||
}
|
||||
for (final id in gFFI.recentPeersModel.restPeerIds) {
|
||||
if (!peerIds.contains(id)) {
|
||||
parsedPeers.add(Peer.fromJson({'id': id}));
|
||||
peerIds.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
peers = parsedPeers;
|
||||
setState(() {
|
||||
peers = mergeAutocompletePeers(
|
||||
addressBookPeers: gFFI.abModel.allPeers(),
|
||||
groupPeers: gFFI.groupModel.peers,
|
||||
lanPeers: gFFI.lanPeersModel.peers,
|
||||
recentPeers: gFFI.recentPeersModel.peers,
|
||||
restRecentPeerIds: gFFI.recentPeersModel.restPeerIds,
|
||||
);
|
||||
_applyLastOnlineState(peers);
|
||||
_scheduleSetState(() {
|
||||
_isPeersLoading = false;
|
||||
_isPeersLoaded = true;
|
||||
});
|
||||
}
|
||||
|
||||
void _updateOnlineState(Map<String, dynamic> evt) {
|
||||
if (_isCleared) {
|
||||
return;
|
||||
}
|
||||
_lastOnlineIds = _splitPeerIds(evt['onlines']);
|
||||
_lastOfflineIds = _splitPeerIds(evt['offlines']);
|
||||
final peersChanged = _applyLastOnlineState(peers);
|
||||
final optionsChanged = _applyLastOnlineState(_lastQueryOnlineOptions);
|
||||
if (peersChanged || optionsChanged) {
|
||||
_scheduleSetState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
void _scheduleSetState(VoidCallback callback) {
|
||||
if (_isCleared) {
|
||||
return;
|
||||
}
|
||||
final setState = _setState;
|
||||
if (setState == null) {
|
||||
callback();
|
||||
} else {
|
||||
setState(callback);
|
||||
}
|
||||
}
|
||||
|
||||
bool _applyLastOnlineState(List<Peer> peers) {
|
||||
return updateAutocompletePeerOnlineStates(
|
||||
peers,
|
||||
onlines: _lastOnlineIds,
|
||||
offlines: _lastOfflineIds,
|
||||
);
|
||||
}
|
||||
|
||||
Set<String> _splitPeerIds(dynamic ids) {
|
||||
if (ids is! String || ids.isEmpty) {
|
||||
return {};
|
||||
}
|
||||
return ids.split(',').where((id) => id.isNotEmpty).toSet();
|
||||
}
|
||||
|
||||
void queryOnlines(Iterable<Peer> options) {
|
||||
if (_isCleared) {
|
||||
return;
|
||||
}
|
||||
_lastQueryOnlineOptions = options.toList(growable: false);
|
||||
final ids = autocompleteOnlineQueryIds(
|
||||
_lastQueryOnlineOptions,
|
||||
limit: _maxQueryOnlineOptions,
|
||||
).toSet();
|
||||
_queryOnlineTimer?.cancel();
|
||||
_queryOnlineTimer = null;
|
||||
if (ids.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final now = DateTime.now();
|
||||
if (setEquals(ids, _lastQueryOnlineIds) &&
|
||||
now.difference(_lastQueryOnlineTime) < _queryOnlineInterval) {
|
||||
return;
|
||||
}
|
||||
|
||||
_queryOnlineTimer = Timer(_queryOnlineDebounce, () async {
|
||||
try {
|
||||
await _queryOnlines(ids.toList(growable: false));
|
||||
if (_isCleared) {
|
||||
return;
|
||||
}
|
||||
_lastQueryOnlineIds = ids;
|
||||
_lastQueryOnlineTime = DateTime.now();
|
||||
} catch (e) {
|
||||
debugPrint('query autocomplete online state failed: $e');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
void updateOnlineStateForTesting(Map<String, dynamic> evt) {
|
||||
_updateOnlineState(evt);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
bool applyLastOnlineStateForTesting(List<Peer> peers) {
|
||||
return _applyLastOnlineState(peers);
|
||||
}
|
||||
}
|
||||
|
||||
class AutocompletePeerTile extends StatefulWidget {
|
||||
|
||||
@@ -24,6 +24,35 @@ const kOpSvgList = [
|
||||
'microsoft'
|
||||
];
|
||||
|
||||
class _OidcProviderBranding {
|
||||
final String label;
|
||||
final String iconKey;
|
||||
|
||||
const _OidcProviderBranding({
|
||||
required this.label,
|
||||
required this.iconKey,
|
||||
});
|
||||
}
|
||||
|
||||
_OidcProviderBranding _oidcProviderBranding(String op) {
|
||||
switch (op.toLowerCase()) {
|
||||
case 'azure':
|
||||
return _OidcProviderBranding(
|
||||
label: 'Microsoft',
|
||||
iconKey: 'microsoft',
|
||||
);
|
||||
default:
|
||||
return _OidcProviderBranding(
|
||||
label: {
|
||||
'github': 'GitHub',
|
||||
'gitlab': 'GitLab',
|
||||
}[op.toLowerCase()] ??
|
||||
toCapitalized(op),
|
||||
iconKey: op.toLowerCase(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _IconOP extends StatelessWidget {
|
||||
final String op;
|
||||
final String? icon;
|
||||
@@ -74,11 +103,8 @@ class ButtonOP extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final opLabel = {
|
||||
'github': 'GitHub',
|
||||
'gitlab': 'GitLab'
|
||||
}[op.toLowerCase()] ??
|
||||
toCapitalized(op);
|
||||
final branding = _oidcProviderBranding(op);
|
||||
final buttonLabel = translate("Continue with {${branding.label}}");
|
||||
return Row(children: [
|
||||
Container(
|
||||
height: height,
|
||||
@@ -95,7 +121,7 @@ class ButtonOP extends StatelessWidget {
|
||||
SizedBox(
|
||||
width: 30,
|
||||
child: _IconOP(
|
||||
op: op,
|
||||
op: branding.iconKey,
|
||||
icon: icon,
|
||||
margin: EdgeInsets.only(right: 5),
|
||||
),
|
||||
@@ -103,8 +129,7 @@ class ButtonOP extends StatelessWidget {
|
||||
Expanded(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Center(
|
||||
child: Text(translate("Continue with {$opLabel}"))),
|
||||
child: Center(child: Text(buttonLabel)),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -72,10 +72,24 @@ Widget waylandKeyboardScopeChip(BuildContext context, String text) {
|
||||
);
|
||||
}
|
||||
|
||||
// macOS privacy mode blacks out all online displays, so switching the remote
|
||||
// display does not weaken the local privacy protection.
|
||||
bool allowDisplaySwitchInPrivacyMode(PeerInfo pi) {
|
||||
return pi.platform == kPeerPlatformMacOS;
|
||||
bool _isWindowsMode1PrivacyImpl(String privacyModeImpl) {
|
||||
return privacyModeImpl == kPrivacyModeImplMag ||
|
||||
privacyModeImpl == kPrivacyModeImplExcludeFromCapture;
|
||||
}
|
||||
|
||||
// macOS privacy mode blacks out all online displays. Windows Mode 1 also
|
||||
// covers every local monitor with privacy overlay windows, so remote display
|
||||
// switching does not weaken local privacy protection.
|
||||
//
|
||||
// Keep this separate from the capture backend capability. The legacy Windows
|
||||
// magnifier capturer is not reliable for multi-monitor capture; WebRTC's
|
||||
// screen_capturer_win_magnifier also disables it when SM_CMONITORS != 1:
|
||||
// https://webrtc.googlesource.com/src/+/1845922d5a1bf9c27deeffb4a8c8daea124434c1/modules/desktop_capture/win/screen_capturer_win_magnifier.cc
|
||||
bool allowDisplaySwitchInPrivacyMode(PeerInfo pi, String privacyModeImpl) {
|
||||
return pi.platform == kPeerPlatformMacOS ||
|
||||
(pi.platform == kPeerPlatformWindows &&
|
||||
_isWindowsMode1PrivacyImpl(privacyModeImpl) &&
|
||||
versionCmp(pi.version, '1.4.8') >= 0);
|
||||
}
|
||||
|
||||
class TTextMenu {
|
||||
@@ -964,7 +978,8 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
|
||||
|
||||
final privacyModeState = PrivacyModeState.find(id);
|
||||
if (pi.isSupportMultiDisplay &&
|
||||
(privacyModeState.isEmpty || allowDisplaySwitchInPrivacyMode(pi)) &&
|
||||
(privacyModeState.isEmpty ||
|
||||
allowDisplaySwitchInPrivacyMode(pi, privacyModeState.value)) &&
|
||||
pi.displaysCount.value > 1 &&
|
||||
bind.mainGetUserDefaultOption(key: kKeyShowMonitorsToolbar) == 'Y') {
|
||||
final value =
|
||||
@@ -1048,7 +1063,20 @@ List<TToggleMenu> toolbarPrivacyMode(
|
||||
return []; // No permission and not active, hide options.
|
||||
}
|
||||
|
||||
getDefaultMenu(Future<void> Function(SessionID sid, String opt) toggleFunc) {
|
||||
bool checkDisplayAllowedForPrivacyMode(String targetImplKey, bool turnOn) {
|
||||
if (!turnOn ||
|
||||
allowDisplaySwitchInPrivacyMode(pi, targetImplKey) ||
|
||||
(ffiModel.pi.currentDisplay == 0 &&
|
||||
!bind.sessionIsMultiUiSession(sessionId: sessionId))) {
|
||||
return true;
|
||||
}
|
||||
msgBox(sessionId, 'custom-nook-nocancel-hasclose', 'info',
|
||||
'Please switch to Display 1 first', '', ffi.dialogManager);
|
||||
return false;
|
||||
}
|
||||
|
||||
getDefaultMenu(Future<void> Function(SessionID sid, String opt) toggleFunc,
|
||||
String targetImplKey) {
|
||||
final enabled = !ffiModel.viewOnly &&
|
||||
(hasPrivacyModePermission || privacyModeState.isNotEmpty);
|
||||
return TToggleMenu(
|
||||
@@ -1056,16 +1084,7 @@ List<TToggleMenu> toolbarPrivacyMode(
|
||||
onChanged: enabled
|
||||
? (value) {
|
||||
if (value == null) return;
|
||||
if (!allowDisplaySwitchInPrivacyMode(pi) &&
|
||||
ffiModel.pi.currentDisplay != 0 &&
|
||||
ffiModel.pi.currentDisplay != kAllDisplayValue) {
|
||||
msgBox(
|
||||
sessionId,
|
||||
'custom-nook-nocancel-hasclose',
|
||||
'info',
|
||||
'Please switch to Display 1 first',
|
||||
'',
|
||||
ffi.dialogManager);
|
||||
if (!checkDisplayAllowedForPrivacyMode(targetImplKey, value)) {
|
||||
return;
|
||||
}
|
||||
final option = 'privacy-mode';
|
||||
@@ -1083,7 +1102,7 @@ List<TToggleMenu> toolbarPrivacyMode(
|
||||
getDefaultMenu((sid, opt) async {
|
||||
bind.sessionToggleOption(sessionId: sid, value: opt);
|
||||
togglePrivacyModeTime = DateTime.now();
|
||||
})
|
||||
}, kPrivacyModeImplMag)
|
||||
];
|
||||
}
|
||||
if (privacyModeImpls.isEmpty) {
|
||||
@@ -1097,7 +1116,7 @@ List<TToggleMenu> toolbarPrivacyMode(
|
||||
bind.sessionTogglePrivacyMode(
|
||||
sessionId: sid, implKey: implKey, on: privacyModeState.isEmpty);
|
||||
togglePrivacyModeTime = DateTime.now();
|
||||
})
|
||||
}, implKey)
|
||||
];
|
||||
} else {
|
||||
final visibleImpls = hasPrivacyModePermission
|
||||
@@ -1118,6 +1137,9 @@ List<TToggleMenu> toolbarPrivacyMode(
|
||||
? (value) {
|
||||
if (value == null) return;
|
||||
if (value && !hasPrivacyModePermission) return;
|
||||
if (!checkDisplayAllowedForPrivacyMode(implKey, value)) {
|
||||
return;
|
||||
}
|
||||
togglePrivacyModeTime = DateTime.now();
|
||||
bind.sessionTogglePrivacyMode(
|
||||
sessionId: sessionId, implKey: implKey, on: value);
|
||||
|
||||
@@ -29,6 +29,10 @@ const String kPlatformAdditionsHasFileClipboard = "has_file_clipboard";
|
||||
const String kPlatformAdditionsSupportedPrivacyModeImpl =
|
||||
"supported_privacy_mode_impl";
|
||||
|
||||
const String kPrivacyModeImplMag = 'privacy_mode_impl_mag';
|
||||
const String kPrivacyModeImplExcludeFromCapture =
|
||||
'privacy_mode_impl_exclude_from_capture';
|
||||
|
||||
const String kPeerPlatformWindows = "Windows";
|
||||
const String kPeerPlatformLinux = "Linux";
|
||||
const String kPeerPlatformMacOS = "Mac OS";
|
||||
@@ -170,6 +174,8 @@ const String kOptionShowVirtualMouse = "show-virtual-mouse";
|
||||
const String kOptionVirtualMouseScale = "virtual-mouse-scale";
|
||||
const String kOptionShowVirtualJoystick = "show-virtual-joystick";
|
||||
const String kOptionAllowAskForNoteAtEndOfConnection = "allow-ask-for-note";
|
||||
const String kOptionAllowMonitorSwitchMainToolbar = "allow-monitor-switch-main-toolbar";
|
||||
const String kOptionAllowMonitorSwitchMinToolbar = "allow-monitor-switch-min-toolbar";
|
||||
const String kOptionEnableShowTerminalExtraKeys = "enable-show-terminal-extra-keys";
|
||||
|
||||
// network options
|
||||
|
||||
@@ -398,6 +398,7 @@ class _ConnectionPageState extends State<ConnectionPage>
|
||||
.contains(textToFind) ||
|
||||
peer.alias.toLowerCase().contains(textToFind))
|
||||
.toList();
|
||||
_allPeersLoader.queryOnlines(_autocompleteOpts);
|
||||
}
|
||||
return _autocompleteOpts;
|
||||
},
|
||||
|
||||
@@ -407,6 +407,7 @@ class _GeneralState extends State<_General> {
|
||||
final RxBool serviceStop =
|
||||
isWeb ? RxBool(false) : Get.find<RxBool>(tag: 'stop-service');
|
||||
RxBool serviceBtnEnabled = true.obs;
|
||||
final GlobalKey _minToolbarOptionKey = GlobalKey();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -482,13 +483,15 @@ class _GeneralState extends State<_General> {
|
||||
}
|
||||
|
||||
Widget other() {
|
||||
final incomingOnly = bind.isIncomingOnly();
|
||||
final outgoingOnly = bind.isOutgoingOnly();
|
||||
final showAutoUpdate = isWindows && bind.mainIsInstalled();
|
||||
final children = <Widget>[
|
||||
if (!isWeb && !bind.isIncomingOnly())
|
||||
if (!isWeb && !incomingOnly)
|
||||
_OptionCheckBox(context, 'Confirm before closing multiple tabs',
|
||||
kOptionEnableConfirmClosingTabs,
|
||||
isServer: false),
|
||||
if (!bind.isIncomingOnly())
|
||||
if (!incomingOnly)
|
||||
_OptionCheckBox(
|
||||
context,
|
||||
'allow-remote-toolbar-docking-any-edge',
|
||||
@@ -498,9 +501,10 @@ class _GeneralState extends State<_General> {
|
||||
reloadAllWindows();
|
||||
},
|
||||
),
|
||||
_OptionCheckBox(context, 'Adaptive bitrate', kOptionEnableAbr),
|
||||
if (!isWeb && !outgoingOnly)
|
||||
_OptionCheckBox(context, 'Adaptive bitrate', kOptionEnableAbr),
|
||||
if (!isWeb) wallpaper(),
|
||||
if (!isWeb && !bind.isIncomingOnly()) ...[
|
||||
if (!isWeb && !incomingOnly) ...[
|
||||
_OptionCheckBox(
|
||||
context,
|
||||
'Open connection in new tab',
|
||||
@@ -539,40 +543,40 @@ class _GeneralState extends State<_General> {
|
||||
isServer: false,
|
||||
),
|
||||
),
|
||||
if (!isWeb && !bind.isCustomClient())
|
||||
_OptionCheckBox(
|
||||
context,
|
||||
'Check for software update on startup',
|
||||
kOptionEnableCheckUpdate,
|
||||
isServer: false,
|
||||
),
|
||||
if (showAutoUpdate)
|
||||
_OptionCheckBox(
|
||||
context,
|
||||
'Auto update',
|
||||
kOptionAllowAutoUpdate,
|
||||
isServer: true,
|
||||
),
|
||||
if (isWindows && !bind.isOutgoingOnly())
|
||||
_OptionCheckBox(
|
||||
context,
|
||||
'Capture screen using DirectX',
|
||||
kOptionDirectxCapture,
|
||||
),
|
||||
if (!bind.isIncomingOnly()) ...[
|
||||
_OptionCheckBox(
|
||||
context,
|
||||
'Enable UDP hole punching',
|
||||
kOptionEnableUdpPunch,
|
||||
isServer: false,
|
||||
),
|
||||
_OptionCheckBox(
|
||||
context,
|
||||
'Enable IPv6 P2P connection',
|
||||
kOptionEnableIpv6Punch,
|
||||
isServer: false,
|
||||
),
|
||||
],
|
||||
],
|
||||
if (!isWeb && !bind.isCustomClient())
|
||||
_OptionCheckBox(
|
||||
context,
|
||||
'Check for software update on startup',
|
||||
kOptionEnableCheckUpdate,
|
||||
isServer: false,
|
||||
),
|
||||
if (showAutoUpdate)
|
||||
_OptionCheckBox(
|
||||
context,
|
||||
'Auto update',
|
||||
kOptionAllowAutoUpdate,
|
||||
isServer: true,
|
||||
),
|
||||
if (isWindows && !outgoingOnly)
|
||||
_OptionCheckBox(
|
||||
context,
|
||||
'Capture screen using DirectX',
|
||||
kOptionDirectxCapture,
|
||||
),
|
||||
if (!isWeb && !incomingOnly) ...[
|
||||
_OptionCheckBox(
|
||||
context,
|
||||
'Enable UDP hole punching',
|
||||
kOptionEnableUdpPunch,
|
||||
isServer: false,
|
||||
),
|
||||
_OptionCheckBox(
|
||||
context,
|
||||
'Enable IPv6 P2P connection',
|
||||
kOptionEnableIpv6Punch,
|
||||
isServer: false,
|
||||
),
|
||||
],
|
||||
];
|
||||
|
||||
@@ -605,6 +609,47 @@ class _GeneralState extends State<_General> {
|
||||
},
|
||||
));
|
||||
}
|
||||
children.add(_OptionCheckBox(
|
||||
context,
|
||||
'Show monitor switch button on the main toolbar',
|
||||
kOptionAllowMonitorSwitchMainToolbar,
|
||||
isServer: false,
|
||||
update: (enabled) async {
|
||||
if (!enabled) {
|
||||
await mainSetLocalBoolOption(
|
||||
kOptionAllowMonitorSwitchMinToolbar, false);
|
||||
}
|
||||
if (mounted) setState(() {});
|
||||
reloadAllWindows();
|
||||
if (enabled) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final ctx = _minToolbarOptionKey.currentContext;
|
||||
if (ctx != null) {
|
||||
Scrollable.ensureVisible(
|
||||
ctx,
|
||||
alignment: 0.5,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
));
|
||||
if (mainGetLocalBoolOptionSync(kOptionAllowMonitorSwitchMainToolbar)) {
|
||||
children.add(KeyedSubtree(
|
||||
key: _minToolbarOptionKey,
|
||||
child: _OptionCheckBox(
|
||||
context,
|
||||
'Show on the minimized toolbar',
|
||||
kOptionAllowMonitorSwitchMinToolbar,
|
||||
isServer: false,
|
||||
update: (_) {
|
||||
reloadAllWindows();
|
||||
},
|
||||
).marginOnly(left: _kCheckBoxLeftMargin * 3),
|
||||
));
|
||||
}
|
||||
return _Card(title: 'Other', children: children);
|
||||
}
|
||||
|
||||
@@ -2429,7 +2474,7 @@ class _AboutState extends State<_About> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Copyright © ${DateTime.now().toString().substring(0, 4)} Purslane Ltd.\n$license',
|
||||
'Copyright © ${DateTime.now().toString().substring(0, 4)} Purslane Tech Pte. Ltd.\n$license',
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
Text(
|
||||
|
||||
@@ -95,6 +95,13 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
// Register this terminal model with FFI for event routing
|
||||
_ffi.registerTerminalModel(widget.terminalId, _terminalModel);
|
||||
|
||||
// Auto-close tab when shell exits
|
||||
_terminalModel.onClosed = () {
|
||||
if (mounted) {
|
||||
widget.tabController.closeBy(widget.tabKey);
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize terminal connection
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
widget.tabController.onSelected?.call(widget.id);
|
||||
|
||||
@@ -779,6 +779,7 @@ class _RemoteToolbarState extends State<RemoteToolbar> {
|
||||
borderRadius: borderRadius,
|
||||
child: _DraggableShowHide(
|
||||
id: widget.id,
|
||||
ffi: widget.ffi,
|
||||
sessionId: widget.ffi.sessionId,
|
||||
dragging: _dragging,
|
||||
fraction: _fraction,
|
||||
@@ -805,13 +806,25 @@ class _RemoteToolbarState extends State<RemoteToolbar> {
|
||||
BuildContext context, _ToolbarEdge edge, bool isHorizontal) {
|
||||
final List<Widget> toolbarItems = [];
|
||||
toolbarItems.add(_PinMenu(state: widget.state));
|
||||
toolbarItems.add(Obx(() {
|
||||
final privacyModeState = PrivacyModeState.find(widget.id);
|
||||
if ((privacyModeState.isEmpty ||
|
||||
allowDisplaySwitchInPrivacyMode(pi, privacyModeState.value)) &&
|
||||
pi.displaysCount.value > 1 &&
|
||||
mainGetLocalBoolOptionSync(kOptionAllowMonitorSwitchMainToolbar)) {
|
||||
return _MainMonitorSwitchButton(id: widget.id, ffi: widget.ffi);
|
||||
} else {
|
||||
return const Offstage();
|
||||
}
|
||||
}));
|
||||
if (!isWebDesktop) {
|
||||
toolbarItems.add(_MobileActionMenu(ffi: widget.ffi));
|
||||
}
|
||||
|
||||
toolbarItems.add(Obx(() {
|
||||
if ((PrivacyModeState.find(widget.id).isEmpty ||
|
||||
allowDisplaySwitchInPrivacyMode(pi)) &&
|
||||
final privacyModeState = PrivacyModeState.find(widget.id);
|
||||
if ((privacyModeState.isEmpty ||
|
||||
allowDisplaySwitchInPrivacyMode(pi, privacyModeState.value)) &&
|
||||
pi.displaysCount.value > 1) {
|
||||
return _MonitorMenu(
|
||||
id: widget.id,
|
||||
@@ -964,6 +977,88 @@ class _MobileActionMenu extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _MonitorCycle {
|
||||
final String id;
|
||||
final FFI ffi;
|
||||
const _MonitorCycle(this.id, this.ffi);
|
||||
|
||||
PeerInfo get _pi => ffi.ffiModel.pi;
|
||||
int get total => _pi.displays.length;
|
||||
int get _current => CurrentDisplayState.find(id).value;
|
||||
bool get _inRange => _current >= 0 && _current < total;
|
||||
|
||||
String get label => _inRange ? '${_current + 1}' : '*';
|
||||
String get tooltip => '${translate('Switch display')} ($label/$total)';
|
||||
|
||||
void next() {
|
||||
final t = total;
|
||||
if (t < 2) return;
|
||||
final from = _inRange ? _current : -1;
|
||||
final target = (from + 1) % t;
|
||||
final isChooseDisplayToOpenInNewWindow = _pi.isSupportMultiDisplay &&
|
||||
bind.sessionGetDisplaysAsIndividualWindows(sessionId: ffi.sessionId) ==
|
||||
'Y';
|
||||
if (isChooseDisplayToOpenInNewWindow) {
|
||||
openMonitorInNewTabOrWindow(target, ffi.id, _pi);
|
||||
} else {
|
||||
openMonitorInTheSameTab(target, ffi, _pi, updateCursorPos: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _MainMonitorSwitchButton extends StatelessWidget {
|
||||
final String id;
|
||||
final FFI ffi;
|
||||
|
||||
const _MainMonitorSwitchButton({
|
||||
Key? key,
|
||||
required this.id,
|
||||
required this.ffi,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cycle = _MonitorCycle(id, ffi);
|
||||
return Obx(() {
|
||||
if (cycle.total < 2) return const Offstage();
|
||||
final label = cycle.label;
|
||||
|
||||
return _IconMenuButton(
|
||||
tooltip: cycle.tooltip,
|
||||
color: _ToolbarTheme.blueColor,
|
||||
hoverColor: _ToolbarTheme.hoverBlueColor,
|
||||
onPressed: cycle.next,
|
||||
icon: SizedBox(
|
||||
width: _ToolbarTheme.buttonSize,
|
||||
height: _ToolbarTheme.buttonSize,
|
||||
child: Stack(
|
||||
alignment: const Alignment(0, -0.125),
|
||||
children: [
|
||||
SvgPicture.asset(
|
||||
'assets/display_switcher.svg',
|
||||
colorFilter:
|
||||
const ColorFilter.mode(Colors.white, BlendMode.srcIn),
|
||||
width: _ToolbarTheme.buttonSize,
|
||||
height: _ToolbarTheme.buttonSize,
|
||||
),
|
||||
Text(
|
||||
label,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Colors.black,
|
||||
fontSize: 11,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _MonitorMenu extends StatelessWidget {
|
||||
final String id;
|
||||
final FFI ffi;
|
||||
@@ -1072,8 +1167,8 @@ class _MonitorMenu extends StatelessWidget {
|
||||
tooltip: isMulti
|
||||
? ''
|
||||
: isAllMonitors
|
||||
? 'all monitors'
|
||||
: '#${i + 1} monitor',
|
||||
? 'All monitors'
|
||||
: '#{${i + 1}} monitor',
|
||||
hMargin: isMulti ? null : 6,
|
||||
vMargin: isMulti ? null : 12,
|
||||
topLevel: false,
|
||||
@@ -2757,7 +2852,7 @@ class _IconMenuButtonState extends State<_IconMenuButton> {
|
||||
horizontal: widget.hMargin ?? _ToolbarTheme.buttonHMargin,
|
||||
vertical: widget.vMargin ?? _ToolbarTheme.buttonVMargin);
|
||||
button = Tooltip(
|
||||
message: widget.tooltip,
|
||||
message: translate(widget.tooltip),
|
||||
child: button,
|
||||
);
|
||||
if (widget.topLevel) {
|
||||
@@ -2970,6 +3065,7 @@ class RdoMenuButton<T> extends StatelessWidget {
|
||||
|
||||
class _DraggableShowHide extends StatefulWidget {
|
||||
final String id;
|
||||
final FFI ffi;
|
||||
final SessionID sessionId;
|
||||
final RxDouble fraction;
|
||||
final Rx<_ToolbarEdge> edge;
|
||||
@@ -2993,6 +3089,7 @@ class _DraggableShowHide extends StatefulWidget {
|
||||
const _DraggableShowHide({
|
||||
Key? key,
|
||||
required this.id,
|
||||
required this.ffi,
|
||||
required this.sessionId,
|
||||
required this.fraction,
|
||||
required this.edge,
|
||||
@@ -3249,6 +3346,9 @@ class _DraggableShowHideState extends State<_DraggableShowHide> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildDraggable(context),
|
||||
Obx(() => collapse.isTrue
|
||||
? _MinimizedMonitorSwitchButton(id: widget.id, ffi: widget.ffi)
|
||||
: const Offstage()),
|
||||
Obx(() => buttonWrapper(
|
||||
() {
|
||||
widget.setFullscreen(!isFullscreen.value);
|
||||
@@ -3409,3 +3509,73 @@ class EdgeThicknessControl extends StatelessWidget {
|
||||
return slider;
|
||||
}
|
||||
}
|
||||
|
||||
class _MinimizedMonitorSwitchButton extends StatelessWidget {
|
||||
final String id;
|
||||
final FFI ffi;
|
||||
|
||||
const _MinimizedMonitorSwitchButton({
|
||||
Key? key,
|
||||
required this.id,
|
||||
required this.ffi,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const double iconSize = 20;
|
||||
final cycle = _MonitorCycle(id, ffi);
|
||||
|
||||
return Obx(() {
|
||||
final label = cycle.label;
|
||||
if (!mainGetLocalBoolOptionSync(kOptionAllowMonitorSwitchMainToolbar) ||
|
||||
!mainGetLocalBoolOptionSync(kOptionAllowMonitorSwitchMinToolbar)) {
|
||||
return const Offstage();
|
||||
}
|
||||
if (cycle.total < 2) return const Offstage();
|
||||
final privacyModeState = PrivacyModeState.find(id);
|
||||
if (privacyModeState.isNotEmpty &&
|
||||
!allowDisplaySwitchInPrivacyMode(
|
||||
ffi.ffiModel.pi, privacyModeState.value)) {
|
||||
return const Offstage();
|
||||
}
|
||||
|
||||
return Tooltip(
|
||||
message: cycle.tooltip,
|
||||
child: TextButton(
|
||||
onPressed: cycle.next,
|
||||
style: ButtonStyle(
|
||||
minimumSize: MaterialStateProperty.all(const Size(0, 0)),
|
||||
padding: MaterialStateProperty.all(EdgeInsets.zero),
|
||||
backgroundColor: MaterialStateProperty.resolveWith((states) {
|
||||
if (states.contains(MaterialState.hovered)) {
|
||||
return _ToolbarTheme.blueColor.withOpacity(0.15);
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
),
|
||||
child: Stack(
|
||||
alignment: const Alignment(0, -0.125),
|
||||
children: [
|
||||
SvgPicture.asset(
|
||||
'assets/display_switcher.svg',
|
||||
colorFilter:
|
||||
ColorFilter.mode(_ToolbarTheme.blueColor, BlendMode.srcIn),
|
||||
width: iconSize,
|
||||
height: iconSize,
|
||||
),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 9,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,6 +207,7 @@ class _ConnectionPageState extends State<ConnectionPage> {
|
||||
.contains(textToFind) ||
|
||||
peer.alias.toLowerCase().contains(textToFind))
|
||||
.toList();
|
||||
_allPeersLoader.queryOnlines(_autocompleteOpts);
|
||||
}
|
||||
return _autocompleteOpts;
|
||||
},
|
||||
|
||||
@@ -1220,7 +1220,11 @@ void showOptions(
|
||||
if (image != null) {
|
||||
displays.add(Padding(padding: const EdgeInsets.only(top: 8), child: image));
|
||||
}
|
||||
if (pi.displays.length > 1 && pi.currentDisplay != kAllDisplayValue) {
|
||||
final privacyModeState = PrivacyModeState.find(id);
|
||||
if (pi.displays.length > 1 &&
|
||||
pi.currentDisplay != kAllDisplayValue &&
|
||||
(privacyModeState.isEmpty ||
|
||||
allowDisplaySwitchInPrivacyMode(pi, privacyModeState.value))) {
|
||||
final cur = pi.currentDisplay;
|
||||
final children = <Widget>[];
|
||||
final isDarkTheme = MyTheme.currentThemeMode() == ThemeMode.dark;
|
||||
@@ -1274,8 +1278,6 @@ void showOptions(
|
||||
await toolbarDisplayToggle(context, id, gFFI);
|
||||
|
||||
List<TToggleMenu> privacyModeList = [];
|
||||
// privacy mode
|
||||
final privacyModeState = PrivacyModeState.find(id);
|
||||
if ((gFFI.ffiModel.pi.features.privacyMode && gFFI.ffiModel.keyboard) ||
|
||||
privacyModeState.isNotEmpty) {
|
||||
privacyModeList = toolbarPrivacyMode(privacyModeState, context, id, gFFI);
|
||||
|
||||
@@ -83,6 +83,13 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
// Register this terminal model with FFI for event routing
|
||||
_ffi.registerTerminalModel(widget.terminalId, _terminalModel);
|
||||
|
||||
// Auto-close connection when shell exits
|
||||
_terminalModel.onClosed = () {
|
||||
if (mounted) {
|
||||
closeConnection(id: widget.id);
|
||||
}
|
||||
};
|
||||
|
||||
// Web desktop users have full hardware keyboard access, so the on-screen
|
||||
// terminal extra keys bar is unnecessary and disabled.
|
||||
_showTerminalExtraKeys = !isWebDesktop &&
|
||||
|
||||
@@ -142,12 +142,22 @@ class FileModel {
|
||||
}
|
||||
|
||||
Future<void> postOverrideFileConfirm(Map<String, dynamic> evt) async {
|
||||
final id = int.tryParse(evt['id']?.toString() ?? '');
|
||||
if (id == null || !jobController.hasTransferConflictJob(id)) {
|
||||
debugPrint("Ignore stale override confirm event: $evt");
|
||||
return;
|
||||
}
|
||||
evtLoop.pushEvent(
|
||||
_FileDialogEvent(WeakReference(this), FileDialogType.overwrite, evt));
|
||||
}
|
||||
|
||||
Future<void> overrideFileConfirm(Map<String, dynamic> evt,
|
||||
{bool? overrideConfirm, bool skip = false}) async {
|
||||
final id = int.tryParse(evt['id']?.toString() ?? '') ?? 0;
|
||||
if (id == 0 || !jobController.hasTransferConflictJob(id)) {
|
||||
debugPrint("Ignore override confirm for inactive job: $evt");
|
||||
return;
|
||||
}
|
||||
// If `skip == true`, it means to skip this file without showing dialog.
|
||||
// Because `resp` may be null after the user operation or the last remembered operation,
|
||||
// and we should distinguish them.
|
||||
@@ -156,15 +166,12 @@ class FileModel {
|
||||
? await showFileConfirmDialog(translate("Overwrite"),
|
||||
"${evt['read_path']}", true, evt['is_identical'] == "true")
|
||||
: null);
|
||||
final id = int.tryParse(evt['id']) ?? 0;
|
||||
if (!jobController.hasTransferConflictJob(id)) {
|
||||
debugPrint("Ignore override confirm result for inactive job: $evt");
|
||||
return;
|
||||
}
|
||||
if (false == resp) {
|
||||
final jobIndex = jobController.getJob(id);
|
||||
if (jobIndex != -1) {
|
||||
await jobController.cancelJob(id);
|
||||
final job = jobController.jobTable[jobIndex];
|
||||
job.state = JobState.done;
|
||||
jobController.jobTable.refresh();
|
||||
}
|
||||
await jobController.cancelTransferConflictBatch(id);
|
||||
} else {
|
||||
var need_override = false;
|
||||
if (resp == null) {
|
||||
@@ -176,6 +183,7 @@ class FileModel {
|
||||
}
|
||||
// Update the loop config.
|
||||
if (fileConfirmCheckboxRemember) {
|
||||
jobController.rememberTransferConflictBatch(id, resp);
|
||||
evtLoop.setSkip(!need_override);
|
||||
}
|
||||
await bind.sessionSetConfirmOverrideFile(
|
||||
@@ -285,6 +293,8 @@ class FileModel {
|
||||
final isWindows = otherSideData.options.isWindows;
|
||||
final showHidden = otherSideData.options.showHidden;
|
||||
final jobID = jobController.addTransferJob(entry, false);
|
||||
jobController.registerTransferConflictBatch([jobID],
|
||||
batchId: int.tryParse(obj['batchId']?.toString() ?? ''));
|
||||
webSendLocalFiles(
|
||||
handleIndex: handleIndex,
|
||||
actId: jobID,
|
||||
@@ -570,8 +580,15 @@ class FileController {
|
||||
final toPath = otherSideData.directory.path;
|
||||
final isWindows = otherSideData.options.isWindows;
|
||||
final showHidden = otherSideData.options.showHidden;
|
||||
final transferJobs = <(Entry, int)>[];
|
||||
final transferJobIds = <int>[];
|
||||
for (var from in items.items) {
|
||||
final jobID = jobController.addTransferJob(from, isRemoteToLocal);
|
||||
transferJobs.add((from, jobID));
|
||||
transferJobIds.add(jobID);
|
||||
}
|
||||
jobController.registerTransferConflictBatch(transferJobIds);
|
||||
for (final (from, jobID) in transferJobs) {
|
||||
bind.sessionSendFiles(
|
||||
sessionId: sessionId,
|
||||
actId: jobID,
|
||||
@@ -917,6 +934,10 @@ class JobController {
|
||||
static final JobID jobID = JobID();
|
||||
final jobTable = List<JobProgress>.empty(growable: true).obs;
|
||||
final jobResultListener = JobResultListener<Map<String, dynamic>>();
|
||||
int _nextTransferConflictBatchId = 1;
|
||||
final Map<int, int> _transferConflictJobToBatch = {};
|
||||
int? _transferConflictRememberBatchId;
|
||||
bool? _transferConflictRememberOverrideConfirm;
|
||||
final GetSessionID getSessionID;
|
||||
final GetDialogManager getDialogManager;
|
||||
SessionID get sessionId => getSessionID();
|
||||
@@ -929,6 +950,57 @@ class JobController {
|
||||
return jobTable.indexWhere((element) => element.id == id);
|
||||
}
|
||||
|
||||
void registerTransferConflictBatch(Iterable<int> jobIds, {int? batchId}) {
|
||||
final ids = jobIds.toList(growable: false);
|
||||
if (ids.isEmpty) {
|
||||
return;
|
||||
}
|
||||
batchId ??= _nextTransferConflictBatchId++;
|
||||
if (batchId >= _nextTransferConflictBatchId) {
|
||||
_nextTransferConflictBatchId = batchId + 1;
|
||||
}
|
||||
for (final jobId in ids) {
|
||||
_transferConflictJobToBatch[jobId] = batchId;
|
||||
}
|
||||
}
|
||||
|
||||
int? transferConflictBatchId(int jobId) {
|
||||
return _transferConflictJobToBatch[jobId];
|
||||
}
|
||||
|
||||
bool hasTransferConflictJob(int jobId) {
|
||||
return transferConflictBatchId(jobId) != null;
|
||||
}
|
||||
|
||||
bool isTransferConflictRememberBatch(int? batchId) {
|
||||
return batchId != null && batchId == _transferConflictRememberBatchId;
|
||||
}
|
||||
|
||||
bool? transferConflictRememberOverrideConfirm(int? batchId) {
|
||||
if (!isTransferConflictRememberBatch(batchId)) {
|
||||
return null;
|
||||
}
|
||||
return _transferConflictRememberOverrideConfirm;
|
||||
}
|
||||
|
||||
void rememberTransferConflictBatch(int jobId, bool? overrideConfirm) {
|
||||
_transferConflictRememberBatchId = _transferConflictJobToBatch[jobId];
|
||||
_transferConflictRememberOverrideConfirm = overrideConfirm;
|
||||
}
|
||||
|
||||
void unregisterTransferConflictJob(int jobId) {
|
||||
final batchId = _transferConflictJobToBatch.remove(jobId);
|
||||
if (batchId == null) {
|
||||
return;
|
||||
}
|
||||
if (!_transferConflictJobToBatch.containsValue(batchId)) {
|
||||
if (_transferConflictRememberBatchId == batchId) {
|
||||
_transferConflictRememberBatchId = null;
|
||||
_transferConflictRememberOverrideConfirm = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// return jobID
|
||||
int addTransferJob(Entry from, bool isRemoteToLocal) {
|
||||
final jobID = JobController.jobID.next();
|
||||
@@ -1000,7 +1072,10 @@ class JobController {
|
||||
id = int.parse(evt['id']);
|
||||
} catch (_) {}
|
||||
final jobIndex = getJob(id);
|
||||
if (jobIndex == -1) return true;
|
||||
if (jobIndex == -1) {
|
||||
unregisterTransferConflictJob(id);
|
||||
return true;
|
||||
}
|
||||
final job = jobTable[jobIndex];
|
||||
job.recvJobRes = true;
|
||||
if (job.type == JobType.deleteFile) {
|
||||
@@ -1026,6 +1101,9 @@ class JobController {
|
||||
job.state = JobState.done;
|
||||
}
|
||||
jobTable.refresh();
|
||||
if (job.state == JobState.done || job.state == JobState.error) {
|
||||
unregisterTransferConflictJob(id);
|
||||
}
|
||||
if (job.type == JobType.deleteDir) {
|
||||
return job.state == JobState.done;
|
||||
} else {
|
||||
@@ -1035,9 +1113,15 @@ class JobController {
|
||||
|
||||
void jobError(Map<String, dynamic> evt) {
|
||||
final err = evt['err'].toString();
|
||||
int jobIndex = getJob(int.parse(evt['id']));
|
||||
final id = int.tryParse(evt['id']?.toString() ?? '');
|
||||
if (id == null) {
|
||||
debugPrint("Ignore job error with invalid id: $evt");
|
||||
return;
|
||||
}
|
||||
int jobIndex = getJob(id);
|
||||
if (jobIndex != -1) {
|
||||
final job = jobTable[jobIndex];
|
||||
if (job.state == JobState.done && job.err == "cancel") return;
|
||||
job.state = JobState.error;
|
||||
job.err = err;
|
||||
job.recvJobRes = true;
|
||||
@@ -1060,6 +1144,11 @@ class JobController {
|
||||
}
|
||||
}
|
||||
jobTable.refresh();
|
||||
if (job.state == JobState.done || job.state == JobState.error) {
|
||||
unregisterTransferConflictJob(job.id);
|
||||
}
|
||||
} else {
|
||||
unregisterTransferConflictJob(id);
|
||||
}
|
||||
if (err == _kOneWayFileTransferError) {
|
||||
if (DateTime.now().millisecondsSinceEpoch - _lastTimeShowMsgbox > 3000) {
|
||||
@@ -1096,9 +1185,42 @@ class JobController {
|
||||
}
|
||||
|
||||
Future<void> cancelJob(int id) async {
|
||||
unregisterTransferConflictJob(id);
|
||||
await bind.sessionCancelJob(sessionId: sessionId, actId: id);
|
||||
}
|
||||
|
||||
Future<void> cancelTransferConflictBatch(int jobId) async {
|
||||
final batchId = _transferConflictJobToBatch[jobId];
|
||||
final batchJobIds = batchId == null ? [jobId] : <int>[];
|
||||
if (batchId != null) {
|
||||
for (final entry in _transferConflictJobToBatch.entries) {
|
||||
if (entry.value == batchId) {
|
||||
batchJobIds.add(entry.key);
|
||||
}
|
||||
}
|
||||
for (final id in batchJobIds) {
|
||||
unregisterTransferConflictJob(id);
|
||||
}
|
||||
}
|
||||
final jobIdsToCancel = batchJobIds.toSet();
|
||||
for (final job in jobTable) {
|
||||
if (!jobIdsToCancel.contains(job.id) || job.state == JobState.done) {
|
||||
continue;
|
||||
}
|
||||
job.state = JobState.done;
|
||||
job.err = "cancel";
|
||||
job.recvJobRes = true;
|
||||
}
|
||||
jobTable.refresh();
|
||||
for (final id in batchJobIds) {
|
||||
try {
|
||||
await bind.sessionCancelJob(sessionId: sessionId, actId: id);
|
||||
} catch (e) {
|
||||
debugPrint("Failed to cancel transfer job $id in conflict batch: $e");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadLastJob(Map<String, dynamic> evt) async {
|
||||
debugPrint("load last job: $evt");
|
||||
Map<String, dynamic> jobDetail = json.decode(evt['value']);
|
||||
@@ -1145,7 +1267,7 @@ class JobController {
|
||||
..state = JobState.paused;
|
||||
jobTable.add(jobProgress);
|
||||
}
|
||||
|
||||
registerTransferConflictBatch([currJobId]);
|
||||
await bind.sessionAddJob(
|
||||
sessionId: sessionId,
|
||||
isRemote: isRemote,
|
||||
@@ -1193,6 +1315,9 @@ class JobController {
|
||||
|
||||
void clear() {
|
||||
jobTable.clear();
|
||||
_transferConflictJobToBatch.clear();
|
||||
_transferConflictRememberBatchId = null;
|
||||
_transferConflictRememberOverrideConfirm = null;
|
||||
jobResultListener.clear();
|
||||
}
|
||||
}
|
||||
@@ -1535,6 +1660,9 @@ class JobProgress {
|
||||
|
||||
String display() {
|
||||
if (type == JobType.transfer) {
|
||||
if (state == JobState.done && err == "cancel") {
|
||||
return translate("Cancel");
|
||||
}
|
||||
if (state == JobState.done && err == "skipped") {
|
||||
return translate("Skipped");
|
||||
}
|
||||
@@ -1844,21 +1972,44 @@ class _FileDialogEvent extends BaseEvent<FileDialogType, Map<String, dynamic>> {
|
||||
|
||||
class FileDialogEventLoop
|
||||
extends BaseEventLoop<FileDialogType, Map<String, dynamic>> {
|
||||
int? _batchId;
|
||||
bool? _overrideConfirm;
|
||||
bool _skip = false;
|
||||
|
||||
@override
|
||||
Future<void> onPreConsume(
|
||||
BaseEvent<FileDialogType, Map<String, dynamic>> evt) async {
|
||||
var event = evt as _FileDialogEvent;
|
||||
final event = evt as _FileDialogEvent;
|
||||
final model = event.fileModel.target;
|
||||
final jobId = int.tryParse(evt.data['id']?.toString() ?? '');
|
||||
final batchId = model == null || jobId == null
|
||||
? null
|
||||
: model.jobController.transferConflictBatchId(jobId);
|
||||
final keepRemembered = model != null &&
|
||||
model.jobController.isTransferConflictRememberBatch(batchId);
|
||||
// The loop only preloads the remembered batch choice. The model updates it
|
||||
// after the user answers the current overwrite dialog.
|
||||
if (_batchId != batchId && !keepRemembered) {
|
||||
_batchId = batchId;
|
||||
_overrideConfirm = null;
|
||||
_skip = false;
|
||||
} else {
|
||||
_batchId = batchId;
|
||||
}
|
||||
if (keepRemembered) {
|
||||
_overrideConfirm =
|
||||
model.jobController.transferConflictRememberOverrideConfirm(batchId);
|
||||
_skip = _overrideConfirm == null;
|
||||
}
|
||||
event.setOverrideConfirm(_overrideConfirm);
|
||||
event.setSkip(_skip);
|
||||
debugPrint(
|
||||
"FileDialogEventLoop: consuming<jobId: ${evt.data['id']} overrideConfirm: $_overrideConfirm, skip: $_skip>");
|
||||
"FileDialogEventLoop: consuming<jobId: ${evt.data['id']} batchId: $_batchId overrideConfirm: $_overrideConfirm, skip: $_skip>");
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onEventsClear() {
|
||||
_batchId = null;
|
||||
_overrideConfirm = null;
|
||||
_skip = false;
|
||||
return super.onEventsClear();
|
||||
|
||||
@@ -1307,7 +1307,8 @@ class InputModel {
|
||||
}
|
||||
if (isPhysicalMouse.value) {
|
||||
if (!_relativeMouse.handleRelativeMouseMove(e.localPosition)) {
|
||||
handleMouse(_getMouseEvent(e, _kMouseEventMove), e.position,
|
||||
final canvasPosition = _pointerPositionForRemoteCanvas(e);
|
||||
handleMouse(_getMouseEvent(e, _kMouseEventMove), canvasPosition,
|
||||
edgeScroll: useEdgeScroll);
|
||||
}
|
||||
}
|
||||
@@ -1548,7 +1549,8 @@ class InputModel {
|
||||
_relativeMouse
|
||||
.sendRelativeMouseButton(_getMouseEvent(e, _kMouseEventDown));
|
||||
} else {
|
||||
handleMouse(_getMouseEvent(e, _kMouseEventDown), e.position);
|
||||
final canvasPosition = _pointerPositionForRemoteCanvas(e);
|
||||
handleMouse(_getMouseEvent(e, _kMouseEventDown), canvasPosition);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1570,7 +1572,8 @@ class InputModel {
|
||||
_relativeMouse
|
||||
.sendRelativeMouseButton(_getMouseEvent(e, _kMouseEventUp));
|
||||
} else {
|
||||
handleMouse(_getMouseEvent(e, _kMouseEventUp), e.position);
|
||||
final canvasPosition = _pointerPositionForRemoteCanvas(e);
|
||||
handleMouse(_getMouseEvent(e, _kMouseEventUp), canvasPosition);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1592,12 +1595,40 @@ class InputModel {
|
||||
}
|
||||
if (isPhysicalMouse.value) {
|
||||
if (!_relativeMouse.handleRelativeMouseMove(e.localPosition)) {
|
||||
handleMouse(_getMouseEvent(e, _kMouseEventMove), e.position,
|
||||
final canvasPosition = _pointerPositionForRemoteCanvas(e);
|
||||
handleMouse(_getMouseEvent(e, _kMouseEventMove), canvasPosition,
|
||||
edgeScroll: useEdgeScroll);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert pointer coordinates into the visible remote canvas space.
|
||||
///
|
||||
/// On mobile, the remote page body is wrapped in `SafeArea`, but the pointer
|
||||
/// listener that feeds these events sits outside that subtree. As a result,
|
||||
/// `event.localPosition` still includes the top/left safe-area inset.
|
||||
///
|
||||
/// When the keyboard-visible path shows `KeyHelpTools`, the remote canvas is
|
||||
/// also shifted downward by `CanvasModel.getAdjustY()`. The downstream mouse
|
||||
/// mapping logic expects coordinates relative to the visible canvas area, so
|
||||
/// we subtract both the mobile safe-area padding and the current canvas
|
||||
/// adjustment before passing the position into mouse mapping.
|
||||
///
|
||||
/// Desktop and web desktop continue to use the global position directly
|
||||
/// because their pointer mapping is window-based.
|
||||
Offset _pointerPositionForRemoteCanvas(PointerEvent event) {
|
||||
if (isDesktop || isWebDesktop) {
|
||||
return event.position;
|
||||
}
|
||||
final mediaData = MediaQueryData.fromView(
|
||||
WidgetsBinding.instance.platformDispatcher.views.first);
|
||||
final adjustY = parent.target?.canvasModel.getAdjustY() ?? 0.0;
|
||||
return Offset(
|
||||
event.localPosition.dx - mediaData.padding.left,
|
||||
event.localPosition.dy - mediaData.padding.top - adjustY,
|
||||
);
|
||||
}
|
||||
|
||||
static Future<Rect?> fillRemoteCoordsAndGetCurFrame(
|
||||
List<RemoteWindowCoords> remoteWindowCoords) async {
|
||||
final coords =
|
||||
|
||||
@@ -55,6 +55,8 @@ import 'package:flutter_hbb/native/custom_cursor.dart'
|
||||
typedef HandleMsgBox = Function(Map<String, dynamic> evt, String id);
|
||||
typedef ReconnectHandle = Function(OverlayDialogManager, SessionID, bool);
|
||||
final _constSessionId = Uuid().v4obj();
|
||||
// Empirical restart reconnect cadence: keep the last frame briefly and retry quickly.
|
||||
const _restartReconnectSilentDelaySecs = 5;
|
||||
|
||||
class CachedPeerData {
|
||||
Map<String, dynamic> updatePrivacyMode = {};
|
||||
@@ -119,6 +121,7 @@ class FfiModel with ChangeNotifier {
|
||||
bool _touchMode = false;
|
||||
late VirtualMouseMode virtualMouseMode;
|
||||
Timer? _timer;
|
||||
Timer? _restartReconnectDelayTimer;
|
||||
var _reconnects = 1;
|
||||
DateTime? _offlineReconnectStartTime;
|
||||
bool _viewOnly = false;
|
||||
@@ -250,6 +253,7 @@ class FfiModel with ChangeNotifier {
|
||||
_inputBlocked = false;
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
resetRestartReconnectState();
|
||||
clearPermissions();
|
||||
waitForImageTimer?.cancel();
|
||||
timerScreenshot?.cancel();
|
||||
@@ -341,6 +345,7 @@ class FfiModel with ChangeNotifier {
|
||||
} else if (name == 'connection_ready') {
|
||||
setConnectionType(peerId, evt['secure'] == 'true',
|
||||
evt['direct'] == 'true', evt['stream_type'] ?? '');
|
||||
resetRestartReconnectState();
|
||||
} else if (name == 'switch_display') {
|
||||
// switch display is kept for backward compatibility
|
||||
handleSwitchDisplay(evt, sessionId, peerId);
|
||||
@@ -922,8 +927,28 @@ class FfiModel with ChangeNotifier {
|
||||
enterUserLoginAndPasswordDialog(
|
||||
sessionId, dialogManager, 'terminal-admin-login-tip', false);
|
||||
} else if (type == 'restarting') {
|
||||
showMsgBox(sessionId, type, title, text, link, false, dialogManager,
|
||||
hasCancel: false);
|
||||
// Treat restart messages as reconnect control events. Rust still sends
|
||||
// title/text for legacy UI and translation reuse; Flutter keeps the last
|
||||
// frame briefly, then shows the Connecting overlay.
|
||||
if (_restartReconnectDelayTimer == null) {
|
||||
parent.target?.inputModel.setRelativeMouseMode(false);
|
||||
bind.sessionReconnect(sessionId: sessionId, forceRelay: false);
|
||||
clearPermissions();
|
||||
// Retry once more after the silent window so restart reconnect attempts
|
||||
// are spaced by the empirical short cadence instead of only updating UI.
|
||||
_restartReconnectDelayTimer =
|
||||
Timer(Duration(seconds: _restartReconnectSilentDelaySecs), () {
|
||||
_restartReconnectDelayTimer = null;
|
||||
if (parent.target?.closed == true) {
|
||||
return;
|
||||
}
|
||||
reconnect(dialogManager, sessionId, false);
|
||||
});
|
||||
}
|
||||
} else if (type == 'restarting-show') {
|
||||
_restartReconnectDelayTimer?.cancel();
|
||||
_restartReconnectDelayTimer = null;
|
||||
reconnect(dialogManager, sessionId, false);
|
||||
} else if (type == 'wait-remote-accept-nook') {
|
||||
showWaitAcceptDialog(sessionId, type, title, text, dialogManager);
|
||||
} else if (type == 'on-uac' || type == 'on-foreground-elevated') {
|
||||
@@ -949,6 +974,11 @@ class FfiModel with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
void resetRestartReconnectState() {
|
||||
_restartReconnectDelayTimer?.cancel();
|
||||
_restartReconnectDelayTimer = null;
|
||||
}
|
||||
|
||||
/// Auto-retry check for "Remote desktop is offline" error.
|
||||
/// returns true to auto-retry, false otherwise.
|
||||
bool shouldAutoRetryOnOffline(
|
||||
@@ -1374,6 +1404,7 @@ class FfiModel with ChangeNotifier {
|
||||
if (displays.isNotEmpty) {
|
||||
_reconnects = 1;
|
||||
_offlineReconnectStartTime = null;
|
||||
resetRestartReconnectState();
|
||||
waitForFirstImage.value = true;
|
||||
isRefreshing = false;
|
||||
}
|
||||
@@ -3666,6 +3697,7 @@ class FFI {
|
||||
|
||||
/// Mobile reuse FFI
|
||||
void mobileReset() {
|
||||
ffiModel.resetRestartReconnectState();
|
||||
ffiModel.waitForFirstImage.value = true;
|
||||
ffiModel.isRefreshing = false;
|
||||
ffiModel.waitForImageDialogShow.value = true;
|
||||
@@ -3879,6 +3911,7 @@ class FFI {
|
||||
}
|
||||
if (ffiModel.waitForFirstImage.value == true) {
|
||||
ffiModel.waitForFirstImage.value = false;
|
||||
ffiModel.resetRestartReconnectState();
|
||||
dialogManager.dismissAll();
|
||||
await canvasModel.updateViewStyle();
|
||||
await canvasModel.updateScrollStyle();
|
||||
|
||||
@@ -145,23 +145,26 @@ class Peer {
|
||||
note == other.note;
|
||||
}
|
||||
|
||||
Peer.copy(Peer other)
|
||||
: this(
|
||||
id: other.id,
|
||||
hash: other.hash,
|
||||
password: other.password,
|
||||
username: other.username,
|
||||
hostname: other.hostname,
|
||||
platform: other.platform,
|
||||
alias: other.alias,
|
||||
tags: other.tags.toList(),
|
||||
forceAlwaysRelay: other.forceAlwaysRelay,
|
||||
rdpPort: other.rdpPort,
|
||||
rdpUsername: other.rdpUsername,
|
||||
loginName: other.loginName,
|
||||
device_group_name: other.device_group_name,
|
||||
note: other.note,
|
||||
sameServer: other.sameServer);
|
||||
factory Peer.copy(Peer other) {
|
||||
final peer = Peer(
|
||||
id: other.id,
|
||||
hash: other.hash,
|
||||
password: other.password,
|
||||
username: other.username,
|
||||
hostname: other.hostname,
|
||||
platform: other.platform,
|
||||
alias: other.alias,
|
||||
tags: other.tags.toList(),
|
||||
forceAlwaysRelay: other.forceAlwaysRelay,
|
||||
rdpPort: other.rdpPort,
|
||||
rdpUsername: other.rdpUsername,
|
||||
loginName: other.loginName,
|
||||
device_group_name: other.device_group_name,
|
||||
note: other.note,
|
||||
sameServer: other.sameServer);
|
||||
peer.online = other.online;
|
||||
return peer;
|
||||
}
|
||||
}
|
||||
|
||||
enum UpdateEvent { online, load }
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:desktop_multi_window/desktop_multi_window.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
@@ -38,6 +37,10 @@ class TerminalModel with ChangeNotifier {
|
||||
|
||||
void Function(int w, int h, int pw, int ph)? onResizeExternal;
|
||||
|
||||
/// Called when the terminal session ends (shell exits).
|
||||
/// The listener (typically TerminalPage) can use this to auto-close the tab/page.
|
||||
VoidCallback? onClosed;
|
||||
|
||||
Future<void> _handleInput(String data) async {
|
||||
// Soft keyboards (notably iOS) emit '\n' when Enter is pressed, while a
|
||||
// real keyboard's Enter sends '\r'. Some Android keyboards also emit '\n'.
|
||||
@@ -247,6 +250,33 @@ class TerminalModel with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
static int getExitCodeFromEvt(Map<String, dynamic> evt) {
|
||||
if (evt.containsKey('exit_code')) {
|
||||
final v = evt['exit_code'];
|
||||
if (v is int) {
|
||||
// Desktop and mobile send exit_code as an int
|
||||
return v;
|
||||
} else if (v is String) {
|
||||
// Web sends exit_code as a string
|
||||
final parsed = int.tryParse(v);
|
||||
if (parsed != null) {
|
||||
return parsed;
|
||||
} else {
|
||||
debugPrint(
|
||||
'[TerminalModel] Failed to parse exit_code as integer: $v. Expected a numeric string.');
|
||||
return 0;
|
||||
}
|
||||
} else {
|
||||
debugPrint(
|
||||
'[TerminalModel] Unexpected exit_code type: ${v.runtimeType}, value: $v. Expected int or String.');
|
||||
return 0;
|
||||
}
|
||||
} else {
|
||||
debugPrint('[TerminalModel] Event does not contain exit_code');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
void handleTerminalResponse(Map<String, dynamic> evt) {
|
||||
final String? type = evt['type'];
|
||||
final int evtTerminalId = getTerminalIdFromEvt(evt);
|
||||
@@ -469,10 +499,12 @@ class TerminalModel with ChangeNotifier {
|
||||
}
|
||||
|
||||
void _handleTerminalClosed(Map<String, dynamic> evt) {
|
||||
final int exitCode = evt['exit_code'] ?? 0;
|
||||
final int exitCode = getExitCodeFromEvt(evt);
|
||||
_writeToTerminal('\r\nTerminal closed with exit code: $exitCode\r\n');
|
||||
_terminalOpened = false;
|
||||
notifyListeners();
|
||||
// Auto-close the tab/page
|
||||
onClosed?.call();
|
||||
}
|
||||
|
||||
void _handleTerminalError(Map<String, dynamic> evt) {
|
||||
|
||||
@@ -11,4 +11,4 @@ PRODUCT_NAME = RustDesk
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.carriez.flutterHbb
|
||||
|
||||
// The copyright displayed in application information
|
||||
PRODUCT_COPYRIGHT = Copyright © 2025 Purslane Ltd. All rights reserved.
|
||||
PRODUCT_COPYRIGHT = Copyright © 2026 Purslane Tech Pte. Ltd. All rights reserved.
|
||||
|
||||
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
|
||||
# Read more about iOS versioning at
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# 1.1.9-1 works for android, but for ios it becomes 1.1.91, need to set it to 1.1.9-a.1 for iOS, will get 1.1.9.1, but iOS store not allow 4 numbers
|
||||
version: 1.4.7+65
|
||||
version: 1.4.8+66
|
||||
|
||||
environment:
|
||||
sdk: '^3.1.0'
|
||||
|
||||
148
flutter/test/autocomplete_peer_merge_test.dart
Normal file
148
flutter/test/autocomplete_peer_merge_test.dart
Normal file
@@ -0,0 +1,148 @@
|
||||
import 'package:flutter_hbb/common/widgets/autocomplete.dart';
|
||||
import 'package:flutter_hbb/models/peer_model.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
Peer _peer({
|
||||
required String id,
|
||||
String alias = '',
|
||||
String username = '',
|
||||
String hostname = '',
|
||||
bool online = false,
|
||||
}) {
|
||||
final peer = Peer(
|
||||
id: id,
|
||||
username: username,
|
||||
hostname: hostname,
|
||||
alias: alias,
|
||||
platform: '',
|
||||
tags: [],
|
||||
hash: '',
|
||||
password: '',
|
||||
forceAlwaysRelay: false,
|
||||
rdpPort: '',
|
||||
rdpUsername: '',
|
||||
loginName: '',
|
||||
device_group_name: '',
|
||||
note: '',
|
||||
);
|
||||
peer.online = online;
|
||||
return peer;
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('merged autocomplete peers keep address book metadata and online state',
|
||||
() {
|
||||
final peers = mergeAutocompletePeers(
|
||||
addressBookPeers: [
|
||||
_peer(id: '123456789', alias: 'Office PC', username: 'ab-user'),
|
||||
],
|
||||
lanPeers: [
|
||||
_peer(id: '123456789', username: 'lan-user', online: true),
|
||||
],
|
||||
);
|
||||
|
||||
expect(peers, hasLength(1));
|
||||
expect(peers.single.id, '123456789');
|
||||
expect(peers.single.alias, 'Office PC');
|
||||
expect(peers.single.username, 'ab-user');
|
||||
expect(peers.single.online, isTrue);
|
||||
});
|
||||
|
||||
test('peer copies preserve online state', () {
|
||||
final peer = _peer(id: '987654321', online: true);
|
||||
|
||||
expect(Peer.copy(peer).online, isTrue);
|
||||
});
|
||||
|
||||
test('online callbacks update autocomplete-only peers', () {
|
||||
final peers = mergeAutocompletePeers(restRecentPeerIds: ['112233445']);
|
||||
|
||||
final changed = updateAutocompletePeerOnlineStates(
|
||||
peers,
|
||||
onlines: {'112233445'},
|
||||
offlines: {},
|
||||
);
|
||||
|
||||
expect(changed, isTrue);
|
||||
expect(peers.single.online, isTrue);
|
||||
});
|
||||
|
||||
test('online query ids are deduplicated and limited', () {
|
||||
final peers = List.generate(
|
||||
25,
|
||||
(index) => _peer(id: index.toString()),
|
||||
)..insert(1, _peer(id: '0'));
|
||||
|
||||
final ids = autocompleteOnlineQueryIds(peers, limit: 20);
|
||||
|
||||
expect(ids, hasLength(20));
|
||||
expect(ids.first, '0');
|
||||
expect(ids.where((id) => id == '0'), hasLength(1));
|
||||
expect(ids.last, '19');
|
||||
});
|
||||
|
||||
test('empty online query ids cancel pending debounce', () async {
|
||||
final queriedIds = <List<String>>[];
|
||||
final loader = AllPeersLoader(
|
||||
queryOnlines: (ids) async {
|
||||
queriedIds.add(ids);
|
||||
},
|
||||
queryOnlineDebounce: Duration(milliseconds: 1),
|
||||
);
|
||||
|
||||
loader.queryOnlines([_peer(id: '123456789')]);
|
||||
loader.queryOnlines([]);
|
||||
await Future.delayed(Duration(milliseconds: 2));
|
||||
|
||||
expect(queriedIds, isEmpty);
|
||||
});
|
||||
|
||||
test('failed online query enqueue does not suppress retry', () async {
|
||||
var queryCount = 0;
|
||||
final loader = AllPeersLoader(
|
||||
queryOnlines: (ids) {
|
||||
queryCount += 1;
|
||||
return Future<void>.error(Exception('queue full'));
|
||||
},
|
||||
queryOnlineDebounce: Duration(milliseconds: 1),
|
||||
);
|
||||
|
||||
loader.queryOnlines([_peer(id: '123456789')]);
|
||||
await Future.delayed(Duration(milliseconds: 2));
|
||||
|
||||
loader.queryOnlines([_peer(id: '123456789')]);
|
||||
await Future.delayed(Duration(milliseconds: 2));
|
||||
|
||||
expect(queryCount, 2);
|
||||
});
|
||||
|
||||
test('online callback updates currently displayed options', () async {
|
||||
final loader = AllPeersLoader(
|
||||
queryOnlines: (ids) async {},
|
||||
queryOnlineDebounce: Duration(milliseconds: 1),
|
||||
);
|
||||
final displayedOptions = [_peer(id: '123456789')];
|
||||
|
||||
loader.queryOnlines(displayedOptions);
|
||||
loader.updateOnlineStateForTesting({
|
||||
'onlines': '123456789',
|
||||
'offlines': '',
|
||||
});
|
||||
|
||||
expect(displayedOptions.single.online, isTrue);
|
||||
await Future.delayed(Duration(milliseconds: 2));
|
||||
});
|
||||
|
||||
test('cached online callback state is reapplied after peers merge', () {
|
||||
final loader = AllPeersLoader();
|
||||
loader.updateOnlineStateForTesting({
|
||||
'onlines': '123456789',
|
||||
'offlines': '',
|
||||
});
|
||||
|
||||
final mergedPeers = [_peer(id: '123456789')];
|
||||
loader.applyLastOnlineStateForTesting(mergedPeers);
|
||||
|
||||
expect(mergedPeers.single.online, isTrue);
|
||||
});
|
||||
}
|
||||
63
flutter/test/cm_demo.dart
Normal file
63
flutter/test/cm_demo.dart
Normal file
@@ -0,0 +1,63 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/desktop/pages/server_page.dart';
|
||||
import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
|
||||
import 'package:flutter_hbb/main.dart';
|
||||
import 'package:flutter_hbb/models/server_model.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
final testClients = [
|
||||
Client(0, false, false, false, "UserAAAAAA", "123123123", true, false, false),
|
||||
Client(1, false, false, false, "UserBBBBB", "221123123", true, false, false),
|
||||
Client(2, false, false, false, "UserC", "331123123", true, false, false),
|
||||
Client(3, false, false, false, "UserDDDDDDDDDDDd", "441123123", true, false,
|
||||
false)
|
||||
];
|
||||
|
||||
/// flutter run -d {platform} -t test/cm_demo.dart to test cm
|
||||
void main() async {
|
||||
isTest = true;
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await windowManager.ensureInitialized();
|
||||
await windowManager.setSize(const Size(400, 600));
|
||||
await windowManager.setAlignment(Alignment.topRight);
|
||||
await initEnv(kAppTypeMain);
|
||||
for (var client in testClients) {
|
||||
gFFI.serverModel.clients.add(client);
|
||||
gFFI.serverModel.tabController.add(TabInfo(
|
||||
key: client.id.toString(),
|
||||
label: client.name,
|
||||
closable: false,
|
||||
page: buildConnectionCard(client)));
|
||||
}
|
||||
|
||||
runApp(GetMaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: MyTheme.lightTheme,
|
||||
darkTheme: MyTheme.darkTheme,
|
||||
themeMode: MyTheme.currentThemeMode(),
|
||||
localizationsDelegates: const [
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
supportedLocales: supportedLocales,
|
||||
home: const DesktopServerPage()));
|
||||
WindowOptions windowOptions = getHiddenTitleBarWindowOptions(
|
||||
size: kConnectionManagerWindowSizeClosedChat);
|
||||
windowManager.waitUntilReadyToShow(windowOptions, () async {
|
||||
await windowManager.show();
|
||||
// ensure initial window size to be changed
|
||||
await windowManager.setSize(kConnectionManagerWindowSizeClosedChat);
|
||||
await Future.wait([
|
||||
windowManager.setAlignment(Alignment.topRight),
|
||||
windowManager.focus(),
|
||||
windowManager.setOpacity(1)
|
||||
]);
|
||||
// ensure
|
||||
windowManager.setAlignment(Alignment.topRight);
|
||||
});
|
||||
}
|
||||
@@ -1,62 +1,20 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/desktop/pages/server_page.dart';
|
||||
import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
|
||||
import 'package:flutter_hbb/main.dart';
|
||||
import 'package:flutter_hbb/models/server_model.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
final testClients = [
|
||||
Client(0, false, false, false, "UserAAAAAA", "123123123", true, false, false, false),
|
||||
Client(1, false, false, false, "UserBBBBB", "221123123", true, false, false, false),
|
||||
Client(2, false, false, false, "UserC", "331123123", true, false, false, false),
|
||||
Client(3, false, false, false, "UserDDDDDDDDDDDd", "441123123", true, false, false, false)
|
||||
];
|
||||
import 'cm_demo.dart' as cm_demo;
|
||||
|
||||
/// flutter run -d {platform} -t test/cm_test.dart to test cm
|
||||
void main(List<String> args) async {
|
||||
isTest = true;
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await windowManager.ensureInitialized();
|
||||
await windowManager.setSize(const Size(400, 600));
|
||||
await windowManager.setAlignment(Alignment.topRight);
|
||||
await initEnv(kAppTypeMain);
|
||||
for (var client in testClients) {
|
||||
gFFI.serverModel.clients.add(client);
|
||||
gFFI.serverModel.tabController.add(TabInfo(
|
||||
key: client.id.toString(),
|
||||
label: client.name,
|
||||
closable: false,
|
||||
page: buildConnectionCard(client)));
|
||||
}
|
||||
|
||||
runApp(GetMaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: MyTheme.lightTheme,
|
||||
darkTheme: MyTheme.darkTheme,
|
||||
themeMode: MyTheme.currentThemeMode(),
|
||||
localizationsDelegates: const [
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
supportedLocales: supportedLocales,
|
||||
home: const DesktopServerPage()));
|
||||
WindowOptions windowOptions = getHiddenTitleBarWindowOptions(
|
||||
size: kConnectionManagerWindowSizeClosedChat);
|
||||
windowManager.waitUntilReadyToShow(windowOptions, () async {
|
||||
await windowManager.show();
|
||||
// ensure initial window size to be changed
|
||||
await windowManager.setSize(kConnectionManagerWindowSizeClosedChat);
|
||||
await Future.wait([
|
||||
windowManager.setAlignment(Alignment.topRight),
|
||||
windowManager.focus(),
|
||||
windowManager.setOpacity(1)
|
||||
void main() {
|
||||
test('connection manager demo clients match the current Client API', () {
|
||||
expect(cm_demo.testClients, hasLength(4));
|
||||
expect(cm_demo.testClients.map((client) => client.name), [
|
||||
'UserAAAAAA',
|
||||
'UserBBBBB',
|
||||
'UserC',
|
||||
'UserDDDDDDDDDDDd',
|
||||
]);
|
||||
// ensure
|
||||
windowManager.setAlignment(Alignment.topRight);
|
||||
expect(
|
||||
cm_demo.testClients.every(
|
||||
(client) => client.keyboard && !client.clipboard && !client.audio),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
38
flutter/test/server_settings_dialog_test.dart
Normal file
38
flutter/test/server_settings_dialog_test.dart
Normal file
@@ -0,0 +1,38 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter_hbb/mobile/widgets/dialog.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('server settings text fields preserve literal input',
|
||||
(tester) async {
|
||||
final controller = TextEditingController(text: 'AbCdR1c1E=');
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: Scaffold(
|
||||
body: serverSettingsTextFormField(
|
||||
label: 'Key',
|
||||
controller: controller,
|
||||
errorMsg: '',
|
||||
autofocus: true,
|
||||
),
|
||||
),
|
||||
));
|
||||
|
||||
final textField = tester.widget<TextField>(find.byType(TextField));
|
||||
|
||||
expect(textField.controller, controller);
|
||||
expect(textField.autofocus, isTrue);
|
||||
expect(textField.keyboardType, TextInputType.visiblePassword);
|
||||
expect(textField.textCapitalization, TextCapitalization.none);
|
||||
expect(textField.autocorrect, isFalse);
|
||||
expect(textField.enableSuggestions, isFalse);
|
||||
expect(textField.smartDashesType, SmartDashesType.disabled);
|
||||
expect(textField.smartQuotesType, SmartQuotesType.disabled);
|
||||
expect(textField.enableIMEPersonalizedLearning, isFalse);
|
||||
expect(
|
||||
textField.spellCheckConfiguration,
|
||||
const SpellCheckConfiguration.disabled(),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -89,11 +89,11 @@ BEGIN
|
||||
BEGIN
|
||||
BLOCK "040904e4"
|
||||
BEGIN
|
||||
VALUE "CompanyName", "Purslane Ltd" "\0"
|
||||
VALUE "CompanyName", "Purslane Tech Pte. Ltd." "\0"
|
||||
VALUE "FileDescription", "RustDesk Remote Desktop" "\0"
|
||||
VALUE "FileVersion", VERSION_AS_STRING "\0"
|
||||
VALUE "InternalName", "rustdesk" "\0"
|
||||
VALUE "LegalCopyright", "Copyright © 2025 Purslane Ltd. All rights reserved." "\0"
|
||||
VALUE "LegalCopyright", "Copyright © 2026 Purslane Tech Pte. Ltd. All rights reserved." "\0"
|
||||
VALUE "OriginalFilename", "rustdesk.exe" "\0"
|
||||
VALUE "ProductName", "RustDesk" "\0"
|
||||
VALUE "ProductVersion", VERSION_AS_STRING "\0"
|
||||
|
||||
@@ -533,7 +533,7 @@ impl FuseServer {
|
||||
offset: i64,
|
||||
size: u32,
|
||||
) -> Result<Vec<u8>, std::io::Error> {
|
||||
// todo: async and concurrent read, generate stream_id per request
|
||||
let request_stream_id = rand::random();
|
||||
let cb_requested = unsafe {
|
||||
// convert `size` from u32 to i32
|
||||
// yet with same bit representation
|
||||
@@ -543,7 +543,7 @@ impl FuseServer {
|
||||
let (n_position_high, n_position_low) =
|
||||
((offset >> 32) as i32, (offset & (u32::MAX as i64)) as i32);
|
||||
let request = ClipboardFile::FileContentsRequest {
|
||||
stream_id: node.stream_id,
|
||||
stream_id: request_stream_id,
|
||||
list_index: node.index as i32,
|
||||
dw_flags: 2,
|
||||
n_position_low,
|
||||
@@ -573,7 +573,7 @@ impl FuseServer {
|
||||
stream_id,
|
||||
requested_data,
|
||||
} => {
|
||||
if stream_id != node.stream_id {
|
||||
if stream_id != request_stream_id {
|
||||
log::debug!("stream id mismatch, ignore");
|
||||
continue;
|
||||
}
|
||||
@@ -611,11 +611,6 @@ struct FuseNode {
|
||||
/// connection id
|
||||
pub conn_id: i32,
|
||||
|
||||
// todo: use stream_id to identify a FileContents request-reply
|
||||
// instead of a whole file
|
||||
/// stream id
|
||||
pub stream_id: i32,
|
||||
|
||||
/// file index in peer's file list
|
||||
/// NOTE:
|
||||
/// it is NOT the same as inode, this is the index in the file list
|
||||
@@ -639,7 +634,6 @@ impl FuseNode {
|
||||
pub fn from_description(inode: Inode, desc: FileDescription) -> Self {
|
||||
Self {
|
||||
conn_id: desc.conn_id,
|
||||
stream_id: rand::random(),
|
||||
index: inode as usize - 2,
|
||||
name: desc
|
||||
.name
|
||||
@@ -656,7 +650,6 @@ impl FuseNode {
|
||||
pub fn new_root() -> Self {
|
||||
Self {
|
||||
conn_id: 0,
|
||||
stream_id: rand::random(),
|
||||
index: 0,
|
||||
name: String::from("/"),
|
||||
parent: None,
|
||||
|
||||
@@ -4,23 +4,24 @@ use super::filetype::FileDescription;
|
||||
use crate::{ClipboardFile, CliprdrError};
|
||||
use cs::FuseServer;
|
||||
use fuser::MountOption;
|
||||
use hbb_common::{config::APP_NAME, log};
|
||||
use hbb_common::{config::Config, log};
|
||||
use parking_lot::Mutex;
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
sync::{mpsc::Sender, Arc},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref FUSE_MOUNT_POINT_CLIENT: Arc<String> = {
|
||||
let mnt_path = format!("/tmp/{}/{}", APP_NAME.read().unwrap(), "cliprdr-client");
|
||||
let mnt_path = fuse_mount_point("cliprdr-client");
|
||||
// No need to run `canonicalize()` here.
|
||||
Arc::new(mnt_path)
|
||||
};
|
||||
|
||||
static ref FUSE_MOUNT_POINT_SERVER: Arc<String> = {
|
||||
let mnt_path = format!("/tmp/{}/{}", APP_NAME.read().unwrap(), "cliprdr-server");
|
||||
let mnt_path = fuse_mount_point("cliprdr-server");
|
||||
// No need to run `canonicalize()` here.
|
||||
Arc::new(mnt_path)
|
||||
};
|
||||
@@ -31,6 +32,21 @@ lazy_static::lazy_static! {
|
||||
|
||||
static FUSE_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum MountPointState {
|
||||
HealthyMount,
|
||||
NotMounted,
|
||||
StaleMount,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
fn fuse_mount_point(name: &str) -> String {
|
||||
let mut path = PathBuf::from(Config::ipc_path(""));
|
||||
path.pop();
|
||||
path.push(name);
|
||||
path.to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
pub fn get_exclude_paths(is_client: bool) -> Arc<String> {
|
||||
if is_client {
|
||||
FUSE_MOUNT_POINT_CLIENT.clone()
|
||||
@@ -53,8 +69,27 @@ pub fn init_fuse_context(is_client: bool) -> Result<(), CliprdrError> {
|
||||
} else {
|
||||
FUSE_CONTEXT_SERVER.lock()
|
||||
};
|
||||
if fuse_context_lock.is_some() {
|
||||
return Ok(());
|
||||
if let Some(ctx) = fuse_context_lock.as_ref() {
|
||||
match inspect_mount_point_state(&ctx.mount_point) {
|
||||
MountPointState::HealthyMount => return Ok(()),
|
||||
MountPointState::StaleMount | MountPointState::NotMounted => {
|
||||
log::warn!(
|
||||
"clipboard FUSE mount {} is disconnected, remounting",
|
||||
ctx.mount_point.display()
|
||||
);
|
||||
let stale_context = fuse_context_lock.take();
|
||||
drop(fuse_context_lock);
|
||||
drop(stale_context);
|
||||
return init_fuse_context(is_client);
|
||||
}
|
||||
MountPointState::Unknown => {
|
||||
log::warn!(
|
||||
"failed to verify clipboard FUSE mount {}",
|
||||
ctx.mount_point.display()
|
||||
);
|
||||
return Err(CliprdrError::CliprdrInit);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mount_point = if is_client {
|
||||
FUSE_MOUNT_POINT_CLIENT.clone()
|
||||
@@ -63,10 +98,32 @@ pub fn init_fuse_context(is_client: bool) -> Result<(), CliprdrError> {
|
||||
};
|
||||
|
||||
let mount_point = std::path::PathBuf::from(&*mount_point);
|
||||
match inspect_mount_point_state(&mount_point) {
|
||||
MountPointState::HealthyMount => {
|
||||
log::warn!(
|
||||
"clipboard FUSE mount {} is already active in another context",
|
||||
mount_point.display()
|
||||
);
|
||||
return Err(CliprdrError::ClipboardOccupied);
|
||||
}
|
||||
MountPointState::StaleMount => {
|
||||
log::warn!(
|
||||
"clipboard FUSE mount {} is stale, cleaning up before remount",
|
||||
mount_point.display()
|
||||
);
|
||||
unmount_fuse_mount_point(&mount_point);
|
||||
validate_mount_state_after_stale_cleanup(
|
||||
&mount_point,
|
||||
inspect_mount_point_state(&mount_point),
|
||||
)?;
|
||||
}
|
||||
MountPointState::Unknown => return Err(CliprdrError::CliprdrInit),
|
||||
MountPointState::NotMounted => {}
|
||||
}
|
||||
let (server, tx) = FuseServer::new(FUSE_TIMEOUT);
|
||||
let server = Arc::new(Mutex::new(server));
|
||||
|
||||
prepare_fuse_mount_point(&mount_point);
|
||||
prepare_fuse_mount_point(&mount_point)?;
|
||||
let mnt_opts = [
|
||||
MountOption::FSName("rustdesk-cliprdr-fs".to_string()),
|
||||
MountOption::NoAtime,
|
||||
@@ -159,35 +216,246 @@ struct FuseContext {
|
||||
}
|
||||
|
||||
// this function must be called after the main IPC is up
|
||||
fn prepare_fuse_mount_point(mount_point: &PathBuf) {
|
||||
fn prepare_fuse_mount_point(mount_point: &PathBuf) -> Result<(), CliprdrError> {
|
||||
use std::{
|
||||
fs::{self, Permissions},
|
||||
os::unix::prelude::PermissionsExt,
|
||||
};
|
||||
|
||||
fs::create_dir(mount_point).ok();
|
||||
fs::set_permissions(mount_point, Permissions::from_mode(0o777)).ok();
|
||||
if let Some(parent) = mount_point.parent() {
|
||||
reject_symlink_path(parent)?;
|
||||
if let Err(e) = fs::create_dir_all(parent) {
|
||||
log::warn!("failed to create FUSE mount parent {:?}: {:?}", parent, e);
|
||||
return Err(CliprdrError::CliprdrInit);
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = std::process::Command::new("umount")
|
||||
.arg(mount_point)
|
||||
.status()
|
||||
{
|
||||
log::warn!("umount {:?} may fail: {:?}", mount_point, e);
|
||||
reject_symlink_path(mount_point)?;
|
||||
|
||||
let recovered_stale_mount = if let Err(e) = fs::create_dir_all(mount_point) {
|
||||
log::warn!(
|
||||
"failed to create clipboard FUSE mount point {}, trying stale mount cleanup: {:?}",
|
||||
mount_point.display(),
|
||||
e
|
||||
);
|
||||
unmount_fuse_mount_point(mount_point);
|
||||
fs::create_dir_all(mount_point).map_err(|e| {
|
||||
log::error!(
|
||||
"failed to create clipboard FUSE mount point {} after cleanup: {:?}",
|
||||
mount_point.display(),
|
||||
e
|
||||
);
|
||||
CliprdrError::CliprdrInit
|
||||
})?;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if let Err(e) = fs::set_permissions(mount_point, Permissions::from_mode(0o777)) {
|
||||
log::warn!(
|
||||
"failed to set clipboard FUSE mount point permissions {}: {:?}",
|
||||
mount_point.display(),
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
if !recovered_stale_mount {
|
||||
unmount_fuse_mount_point(mount_point);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn inspect_mount_point_state(mount_point: &Path) -> MountPointState {
|
||||
if ensure_mount_point_path_is_safe(mount_point).is_err() {
|
||||
return MountPointState::Unknown;
|
||||
}
|
||||
inspect_mount_point_state_with(
|
||||
mount_point,
|
||||
std::fs::metadata(mount_point),
|
||||
std::fs::read_to_string("/proc/self/mountinfo"),
|
||||
)
|
||||
}
|
||||
|
||||
fn validate_mount_state_after_stale_cleanup(
|
||||
mount_point: &Path,
|
||||
mount_state: MountPointState,
|
||||
) -> Result<(), CliprdrError> {
|
||||
match mount_state {
|
||||
MountPointState::NotMounted => Ok(()),
|
||||
MountPointState::HealthyMount => {
|
||||
log::warn!(
|
||||
"clipboard FUSE mount {} is still active after stale cleanup",
|
||||
mount_point.display()
|
||||
);
|
||||
Err(CliprdrError::ClipboardOccupied)
|
||||
}
|
||||
MountPointState::StaleMount => {
|
||||
log::warn!(
|
||||
"clipboard FUSE mount {} is still stale after cleanup",
|
||||
mount_point.display()
|
||||
);
|
||||
Err(CliprdrError::CliprdrInit)
|
||||
}
|
||||
MountPointState::Unknown => {
|
||||
log::warn!(
|
||||
"failed to verify clipboard FUSE mount {} after cleanup",
|
||||
mount_point.display()
|
||||
);
|
||||
Err(CliprdrError::CliprdrInit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn uninit_fuse_context_(is_client: bool) {
|
||||
if is_client {
|
||||
let _ = FUSE_CONTEXT_CLIENT.lock().take();
|
||||
} else {
|
||||
let _ = FUSE_CONTEXT_SERVER.lock().take();
|
||||
fn inspect_mount_point_state_with<T>(
|
||||
mount_point: &Path,
|
||||
metadata_result: io::Result<T>,
|
||||
mountinfo_result: io::Result<String>,
|
||||
) -> MountPointState {
|
||||
match metadata_result {
|
||||
Ok(_) => match mountinfo_result {
|
||||
Ok(mountinfo) => {
|
||||
if is_mount_point_listed_in_mountinfo(mount_point, &mountinfo) {
|
||||
MountPointState::HealthyMount
|
||||
} else {
|
||||
MountPointState::NotMounted
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("failed to read mountinfo for {:?}: {:?}", mount_point, e);
|
||||
MountPointState::Unknown
|
||||
}
|
||||
},
|
||||
Err(e) if e.raw_os_error() == Some(libc::ENOTCONN) => MountPointState::StaleMount,
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => MountPointState::NotMounted,
|
||||
Err(e) => {
|
||||
log::warn!("failed to inspect FUSE mount {:?}: {:?}", mount_point, e);
|
||||
MountPointState::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_mount_point_listed_in_mountinfo(mount_point: &Path, mountinfo: &str) -> bool {
|
||||
let mount_point = mount_point.to_string_lossy();
|
||||
mountinfo.lines().any(|line| {
|
||||
let mut fields = line.split_whitespace();
|
||||
let _mount_id = fields.next();
|
||||
let _parent_id = fields.next();
|
||||
let _major_minor = fields.next();
|
||||
let _root = fields.next();
|
||||
let mount_path = fields.next();
|
||||
mount_path == Some(mount_point.as_ref())
|
||||
})
|
||||
}
|
||||
|
||||
fn reject_symlink_metadata_result(
|
||||
path: &Path,
|
||||
metadata_result: io::Result<std::fs::Metadata>,
|
||||
allow_disconnected_mount: bool,
|
||||
) -> Result<(), CliprdrError> {
|
||||
match metadata_result {
|
||||
Ok(metadata) if metadata.file_type().is_symlink() => {
|
||||
log::warn!("refusing to use symlinked FUSE path {:?}", path);
|
||||
Err(CliprdrError::CliprdrInit)
|
||||
}
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) if allow_disconnected_mount && e.raw_os_error() == Some(libc::ENOTCONN) => Ok(()),
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => {
|
||||
log::warn!("failed to inspect FUSE path {:?}: {:?}", path, e);
|
||||
Err(CliprdrError::CliprdrInit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reject_symlink_path(path: &Path) -> Result<(), CliprdrError> {
|
||||
reject_symlink_metadata_result(path, std::fs::symlink_metadata(path), false)
|
||||
}
|
||||
|
||||
fn ensure_mount_point_path_is_safe(mount_point: &Path) -> Result<(), CliprdrError> {
|
||||
if let Some(parent) = mount_point.parent() {
|
||||
reject_symlink_path(parent)?;
|
||||
}
|
||||
reject_symlink_metadata_result(mount_point, std::fs::symlink_metadata(mount_point), true)
|
||||
}
|
||||
|
||||
fn unmount_fuse_mount_point(mount_point: &Path) {
|
||||
if ensure_mount_point_path_is_safe(mount_point).is_err() {
|
||||
log::warn!(
|
||||
"refusing to unmount unsafe clipboard FUSE mount point {:?}",
|
||||
mount_point
|
||||
);
|
||||
return;
|
||||
}
|
||||
if inspect_mount_point_state_with(
|
||||
mount_point,
|
||||
std::fs::metadata(mount_point),
|
||||
std::fs::read_to_string("/proc/self/mountinfo"),
|
||||
) == MountPointState::NotMounted
|
||||
{
|
||||
return;
|
||||
}
|
||||
for (program, args) in unmount_command_candidates() {
|
||||
if run_unmount_command(program, args, mount_point) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
log::warn!(
|
||||
"failed to unmount clipboard FUSE mount point {:?}",
|
||||
mount_point
|
||||
);
|
||||
}
|
||||
|
||||
fn unmount_command_candidates() -> [(&'static str, &'static [&'static str]); 3] {
|
||||
[
|
||||
("fusermount3", &["-uz"]),
|
||||
("fusermount", &["-uz"]),
|
||||
("umount", &["-l"]),
|
||||
]
|
||||
}
|
||||
|
||||
fn run_unmount_command(program: &str, args: &[&str], mount_point: &Path) -> bool {
|
||||
match std::process::Command::new(program)
|
||||
.args(args)
|
||||
.arg(mount_point)
|
||||
.status()
|
||||
{
|
||||
Ok(status) if status.success() => {}
|
||||
Ok(status) => {
|
||||
log::debug!(
|
||||
"{} {:?} exited with status {:?}",
|
||||
program,
|
||||
mount_point,
|
||||
status.code()
|
||||
);
|
||||
return false;
|
||||
}
|
||||
Err(e) => {
|
||||
log::debug!("failed to run {} for {:?}: {:?}", program, mount_point, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn uninit_fuse_context_(is_client: bool) {
|
||||
let ctx = {
|
||||
let mut fuse_context_lock = if is_client {
|
||||
FUSE_CONTEXT_CLIENT.lock()
|
||||
} else {
|
||||
FUSE_CONTEXT_SERVER.lock()
|
||||
};
|
||||
fuse_context_lock.take()
|
||||
};
|
||||
drop(ctx);
|
||||
}
|
||||
|
||||
impl Drop for FuseContext {
|
||||
fn drop(&mut self) {
|
||||
self.session.lock().take().map(|s| s.join());
|
||||
log::info!("unmounting clipboard FUSE from {}", self.mount_point.display());
|
||||
log::info!(
|
||||
"unmounting clipboard FUSE from {}",
|
||||
self.mount_point.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,3 +491,66 @@ impl FuseContext {
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::{fs, io};
|
||||
|
||||
#[cfg(target_family = "unix")]
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
#[test]
|
||||
fn classifies_mount_point_state_from_metadata_and_mountinfo() {
|
||||
let mount_point = std::env::temp_dir().join(format!(
|
||||
"rustdesk-fuse-mount-state-test-{}-{}",
|
||||
std::process::id(),
|
||||
line!()
|
||||
));
|
||||
let mountinfo = format!(
|
||||
"123 1 0:45 / {} rw,nosuid,nodev - fuse.rustdesk rustdesk rw\n",
|
||||
mount_point.display()
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
inspect_mount_point_state_with(&mount_point, Ok(()), Ok(mountinfo)),
|
||||
MountPointState::HealthyMount
|
||||
);
|
||||
assert_eq!(
|
||||
inspect_mount_point_state_with(&mount_point, Ok(()), Ok(String::new())),
|
||||
MountPointState::NotMounted
|
||||
);
|
||||
|
||||
let disconnected_metadata: io::Result<()> =
|
||||
Err(io::Error::from_raw_os_error(libc::ENOTCONN));
|
||||
assert_eq!(
|
||||
inspect_mount_point_state_with(&mount_point, disconnected_metadata, Ok(String::new())),
|
||||
MountPointState::StaleMount
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(target_family = "unix")]
|
||||
fn rejects_symlink_mount_point() {
|
||||
let base = std::env::temp_dir().join(format!(
|
||||
"rustdesk-fuse-symlink-test-{}-{}",
|
||||
std::process::id(),
|
||||
line!()
|
||||
));
|
||||
let mount_parent = base.join("parent");
|
||||
let mount_point = mount_parent.join("cliprdr-client");
|
||||
let symlink_target = base.join("symlink-target");
|
||||
let _ = fs::remove_dir_all(&base);
|
||||
fs::create_dir_all(&base).unwrap();
|
||||
fs::create_dir_all(&mount_parent).unwrap();
|
||||
fs::create_dir_all(&symlink_target).unwrap();
|
||||
symlink(&symlink_target, &mount_point).unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
prepare_fuse_mount_point(&mount_point),
|
||||
Err(CliprdrError::CliprdrInit)
|
||||
));
|
||||
|
||||
let _ = fs::remove_dir_all(&base);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,20 +192,16 @@ impl LocalFile {
|
||||
});
|
||||
};
|
||||
|
||||
if offset != self.offset.load(Ordering::Relaxed) {
|
||||
let read_result = if offset != self.offset.load(Ordering::Relaxed) {
|
||||
handle
|
||||
.seek(std::io::SeekFrom::Start(offset))
|
||||
.map_err(|e| CliprdrError::FileError {
|
||||
path: self.path.to_string_lossy().to_string(),
|
||||
err: e,
|
||||
})?;
|
||||
.and_then(|_| handle.read_exact(buf))
|
||||
} else {
|
||||
handle.read_exact(buf)
|
||||
};
|
||||
if let Err(e) = read_result {
|
||||
return Err(self.invalidate_handle(e));
|
||||
}
|
||||
handle
|
||||
.read_exact(buf)
|
||||
.map_err(|e| CliprdrError::FileError {
|
||||
path: self.path.to_string_lossy().to_string(),
|
||||
err: e,
|
||||
})?;
|
||||
let new_offset = offset + (buf.len() as u64);
|
||||
self.offset.store(new_offset, Ordering::Relaxed);
|
||||
|
||||
@@ -217,6 +213,15 @@ impl LocalFile {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn invalidate_handle(&mut self, err: std::io::Error) -> CliprdrError {
|
||||
self.offset.store(0, Ordering::Relaxed);
|
||||
self.handle = None;
|
||||
CliprdrError::FileError {
|
||||
path: self.path.to_string_lossy().to_string(),
|
||||
err,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn construct_file_list(paths: &[PathBuf]) -> Result<Vec<LocalFile>, CliprdrError> {
|
||||
@@ -278,7 +283,10 @@ pub(super) fn construct_file_list(paths: &[PathBuf]) -> Result<Vec<LocalFile>, C
|
||||
|
||||
#[cfg(test)]
|
||||
mod file_list_test {
|
||||
use std::{path::PathBuf, sync::atomic::AtomicU64};
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
|
||||
use hbb_common::bytes::{BufMut, BytesMut};
|
||||
|
||||
@@ -384,4 +392,30 @@ mod file_list_test {
|
||||
as_bin_parse_test("/test")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_exact_at_reopens_after_read_failure() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let file_path = std::env::temp_dir().join(format!(
|
||||
"rustdesk-clipboard-local-file-{}",
|
||||
std::process::id()
|
||||
));
|
||||
std::fs::write(&file_path, b"")?;
|
||||
|
||||
let mut file = LocalFile::try_open(&std::env::temp_dir(), &file_path)?;
|
||||
file.size = 1;
|
||||
|
||||
let mut buf = [0u8; 1];
|
||||
assert!(file.read_exact_at(&mut buf, 0).is_err());
|
||||
assert!(file.handle.is_none());
|
||||
assert_eq!(file.offset.load(Ordering::Relaxed), 0);
|
||||
|
||||
std::fs::write(&file_path, [42u8])?;
|
||||
|
||||
file.read_exact_at(&mut buf, 0)?;
|
||||
assert_eq!(buf, [42u8]);
|
||||
assert!(file.handle.is_none());
|
||||
|
||||
std::fs::remove_file(file_path)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use hbb_common::{
|
||||
log,
|
||||
};
|
||||
use parking_lot::Mutex;
|
||||
use std::{path::PathBuf, sync::Arc, usize};
|
||||
use std::{path::PathBuf, sync::Arc, time::SystemTime, usize};
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
// local files are cached, this value should not be changed when copying files
|
||||
@@ -30,9 +30,35 @@ enum FileContentsRequest {
|
||||
},
|
||||
}
|
||||
|
||||
// Cheap fingerprint of one top-level selected entry. A change in size/mtime --
|
||||
// or a directory in the selection -- forces sync_files() to rebuild (see below).
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
struct FileSig {
|
||||
size: u64,
|
||||
mtime: Option<SystemTime>,
|
||||
is_dir: bool,
|
||||
}
|
||||
|
||||
// Stat the top-level selected paths only (no recursion), same order as `files`.
|
||||
fn fingerprint(files: &[String]) -> Vec<FileSig> {
|
||||
files
|
||||
.iter()
|
||||
.map(|s| match std::fs::metadata(s) {
|
||||
Ok(mt) => FileSig {
|
||||
size: mt.len(),
|
||||
mtime: mt.modified().ok(),
|
||||
is_dir: mt.is_dir(),
|
||||
},
|
||||
Err(_) => FileSig::default(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ClipFiles {
|
||||
files: Vec<String>,
|
||||
// Fingerprint of `files` (same len/order); detects in-place edits on re-copy.
|
||||
sigs: Vec<FileSig>,
|
||||
file_list: Vec<LocalFile>,
|
||||
first_file_index: usize,
|
||||
files_pdu: Vec<u8>,
|
||||
@@ -41,12 +67,17 @@ struct ClipFiles {
|
||||
impl ClipFiles {
|
||||
fn clear(&mut self) {
|
||||
self.files.clear();
|
||||
self.sigs.clear();
|
||||
self.file_list.clear();
|
||||
self.first_file_index = usize::MAX;
|
||||
self.files_pdu.clear();
|
||||
}
|
||||
|
||||
fn sync_files(&mut self, clipboard_files: &[String]) -> Result<(), CliprdrError> {
|
||||
fn sync_files(
|
||||
&mut self,
|
||||
clipboard_files: &[String],
|
||||
sigs: Vec<FileSig>,
|
||||
) -> Result<(), CliprdrError> {
|
||||
let clipboard_paths = clipboard_files
|
||||
.iter()
|
||||
.map(|s| PathBuf::from(s))
|
||||
@@ -58,6 +89,7 @@ impl ClipFiles {
|
||||
.position(|f| !f.path.is_dir())
|
||||
.unwrap_or(usize::MAX);
|
||||
self.files = clipboard_files.to_vec();
|
||||
self.sigs = sigs;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -258,14 +290,128 @@ pub fn read_file_contents(
|
||||
}
|
||||
|
||||
pub fn sync_files(files: &[String]) -> Result<(), CliprdrError> {
|
||||
// Dedup: skip the rebuild only when paths + sizes + mtimes match and no dir is
|
||||
// selected (a dir's own mtime doesn't change when a file inside it is edited).
|
||||
let current = fingerprint(files);
|
||||
let mut files_lock = CLIP_FILES.lock();
|
||||
if files_lock.files == files {
|
||||
if files_lock.files == files
|
||||
&& files_lock.sigs == current
|
||||
&& !current.iter().any(|sig| sig.is_dir)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
files_lock.sync_files(files)?;
|
||||
files_lock.sync_files(files, current)?;
|
||||
Ok(files_lock.build_file_list_pdu())
|
||||
}
|
||||
|
||||
pub fn get_file_list_pdu() -> Vec<u8> {
|
||||
CLIP_FILES.lock().files_pdu.clone()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod sig_test {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
// Unique temp dir under the system temp dir; removed on drop (no dev-dep).
|
||||
struct TmpDir(PathBuf);
|
||||
impl TmpDir {
|
||||
fn new(tag: &str) -> Self {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
let mut dir = std::env::temp_dir();
|
||||
dir.push(format!("rustdesk_sig_test_{}_{}", tag, nanos));
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
TmpDir(dir)
|
||||
}
|
||||
fn join(&self, name: &str) -> PathBuf {
|
||||
self.0.join(name)
|
||||
}
|
||||
}
|
||||
impl Drop for TmpDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn path_str(p: &PathBuf) -> String {
|
||||
p.to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_missing_path_is_default() {
|
||||
let tmp = TmpDir::new("missing");
|
||||
let missing = path_str(&tmp.join("does_not_exist.bin"));
|
||||
let sigs = fingerprint(&[missing]);
|
||||
assert_eq!(sigs.len(), 1);
|
||||
// A path that can't be stat'd -> default sig, which forces a rebuild.
|
||||
assert_eq!(sigs[0], FileSig::default());
|
||||
assert_eq!(sigs[0].mtime, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_detects_inplace_edit() {
|
||||
let tmp = TmpDir::new("edit");
|
||||
let file = tmp.join("a.bin");
|
||||
fs::write(&file, b"small").unwrap();
|
||||
let p = path_str(&file);
|
||||
|
||||
let before = fingerprint(&[p.clone()]);
|
||||
// Same content, same path: fingerprint must be stable.
|
||||
let again = fingerprint(&[p.clone()]);
|
||||
assert_eq!(before, again);
|
||||
assert_eq!(before[0].size, 5);
|
||||
assert!(!before[0].is_dir);
|
||||
|
||||
// Edit in place so the file grows.
|
||||
fs::write(&file, b"much larger contents than before").unwrap();
|
||||
let after = fingerprint(&[p]);
|
||||
assert_ne!(before, after);
|
||||
assert!(after[0].size > before[0].size);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_flags_directory() {
|
||||
let tmp = TmpDir::new("dir");
|
||||
let sub = tmp.join("subdir");
|
||||
fs::create_dir_all(&sub).unwrap();
|
||||
let sigs = fingerprint(&[path_str(&sub)]);
|
||||
assert_eq!(sigs.len(), 1);
|
||||
assert!(sigs[0].is_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recopy_after_edit_refreshes_cached_size() {
|
||||
let tmp = TmpDir::new("recopy");
|
||||
let file = tmp.join("doc.bin");
|
||||
fs::write(&file, b"v1").unwrap(); // 2 bytes
|
||||
let files = vec![path_str(&file)];
|
||||
|
||||
// Drive the public, guarded `sync_files` over the global CLIP_FILES;
|
||||
// reset first (this is the only test that touches the global).
|
||||
clear_files();
|
||||
|
||||
sync_files(&files).unwrap();
|
||||
{
|
||||
let cache = CLIP_FILES.lock();
|
||||
let idx = cache.first_file_index;
|
||||
assert_eq!(cache.file_list[idx].size, 2);
|
||||
}
|
||||
|
||||
// In-place edit grows the file; the re-copy must rebuild. Pre-fix the
|
||||
// path-only guard early-returned and left the cached size stale at 2.
|
||||
fs::write(&file, b"v2 is bigger").unwrap(); // 12 bytes
|
||||
sync_files(&files).unwrap();
|
||||
{
|
||||
let cache = CLIP_FILES.lock();
|
||||
let idx = cache.first_file_index;
|
||||
assert_eq!(cache.file_list[idx].size, 12);
|
||||
}
|
||||
|
||||
clear_files(); // leave the global clean for other tests
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,14 @@
|
||||
|
||||
/* Maximum number of clipboard streams accepted from a remote peer (integer overflow / DoS guard) */
|
||||
#define WF_CLIPRDR_MAX_STREAMS 16384
|
||||
/* Registered clipboard formats use IDs 0xC000 through 0xFFFF.
|
||||
* https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-registerclipboardformatw */
|
||||
#define WF_CLIPRDR_MAX_FORMATS 0x4000u
|
||||
/* Registered format names are string atoms; cap the converted WCHAR name.
|
||||
* https://learn.microsoft.com/en-us/windows/win32/dataxchg/about-atom-tables */
|
||||
#define WF_CLIPRDR_MAX_FORMAT_NAME_WCHARS 255u
|
||||
/* Bound the peer-provided UTF-8 scan separately from the converted Windows name. */
|
||||
#define WF_CLIPRDR_MAX_FORMAT_NAME_UTF8_BYTES (WF_CLIPRDR_MAX_FORMAT_NAME_WCHARS * 4u)
|
||||
|
||||
/* Validates the remote descriptor array size after cItems has been read safely. */
|
||||
static BOOL wf_cliprdr_file_group_descriptor_size_valid(SIZE_T size, UINT count)
|
||||
@@ -61,6 +69,25 @@ static BOOL wf_cliprdr_file_group_descriptor_size_valid(SIZE_T size, UINT count)
|
||||
return size >= descriptors_size;
|
||||
}
|
||||
|
||||
static BOOL wf_cliprdr_bounded_strlen(const char *value, size_t max_len, size_t *len)
|
||||
{
|
||||
size_t i;
|
||||
|
||||
if (!value || !len)
|
||||
return FALSE;
|
||||
|
||||
for (i = 0; i <= max_len; i++)
|
||||
{
|
||||
if (value[i] == '\0')
|
||||
{
|
||||
*len = i;
|
||||
return TRUE;
|
||||
}
|
||||
}
|
||||
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clipboard Formats
|
||||
*/
|
||||
@@ -205,6 +232,7 @@ struct _CliprdrStream
|
||||
FILEDESCRIPTORW m_Dsc;
|
||||
void *m_pData;
|
||||
UINT32 m_connID;
|
||||
UINT32 m_streamId; // unique CLIPRDR streamId; avoids leaking a heap pointer
|
||||
};
|
||||
typedef struct _CliprdrStream CliprdrStream;
|
||||
|
||||
@@ -258,6 +286,9 @@ struct wf_clipboard
|
||||
char *req_fdata;
|
||||
HANDLE req_fevent;
|
||||
BOOL req_f_received;
|
||||
UINT32 req_f_conn_id_expected; // connID of the outstanding request
|
||||
UINT32 req_f_stream_id_expected; // streamId of the outstanding request; responses for another are dropped
|
||||
LONG req_f_stream_id_seq; // source of unique per-stream ids
|
||||
|
||||
size_t nFiles;
|
||||
size_t file_array_size;
|
||||
@@ -288,7 +319,7 @@ static UINT32 get_remote_format_id(wfClipboard *clipboard, UINT32 local_format);
|
||||
static UINT cliprdr_send_data_request(UINT32 connID, wfClipboard *clipboard, UINT32 format);
|
||||
static UINT cliprdr_send_lock(wfClipboard *clipboard);
|
||||
static UINT cliprdr_send_unlock(wfClipboard *clipboard);
|
||||
static UINT cliprdr_send_request_filecontents(wfClipboard *clipboard, UINT32 connID, const void *streamid,
|
||||
static UINT cliprdr_send_request_filecontents(wfClipboard *clipboard, UINT32 connID, UINT32 streamId,
|
||||
ULONG index, UINT32 flag, DWORD positionhigh,
|
||||
DWORD positionlow, ULONG request);
|
||||
|
||||
@@ -371,7 +402,7 @@ static ULONG STDMETHODCALLTYPE CliprdrStream_Release(IStream *This)
|
||||
static HRESULT STDMETHODCALLTYPE CliprdrStream_Read(IStream *This, void *pv, ULONG cb,
|
||||
ULONG *pcbRead)
|
||||
{
|
||||
int ret;
|
||||
UINT ret;
|
||||
CliprdrStream *instance = (CliprdrStream *)This;
|
||||
wfClipboard *clipboard;
|
||||
|
||||
@@ -384,12 +415,23 @@ static HRESULT STDMETHODCALLTYPE CliprdrStream_Read(IStream *This, void *pv, ULO
|
||||
if (instance->m_lOffset.QuadPart >= instance->m_lSize.QuadPart)
|
||||
return S_FALSE;
|
||||
|
||||
ret = cliprdr_send_request_filecontents(clipboard, instance->m_connID, (void *)This, instance->m_lIndex,
|
||||
ret = cliprdr_send_request_filecontents(clipboard, instance->m_connID, instance->m_streamId, instance->m_lIndex,
|
||||
FILECONTENTS_RANGE, instance->m_lOffset.HighPart,
|
||||
instance->m_lOffset.LowPart, cb);
|
||||
|
||||
if (ret < 0)
|
||||
if (ret != CHANNEL_RC_OK)
|
||||
{
|
||||
free(clipboard->req_fdata);
|
||||
clipboard->req_fdata = NULL;
|
||||
return E_FAIL;
|
||||
}
|
||||
|
||||
if (clipboard->req_fsize > cb)
|
||||
{
|
||||
free(clipboard->req_fdata);
|
||||
clipboard->req_fdata = NULL;
|
||||
return STG_E_READFAULT;
|
||||
}
|
||||
|
||||
if (clipboard->req_fdata)
|
||||
{
|
||||
@@ -601,6 +643,7 @@ static CliprdrStream *CliprdrStream_New(UINT32 connID, ULONG index, void *pData,
|
||||
instance->m_pData = pData;
|
||||
instance->m_lOffset.QuadPart = 0;
|
||||
instance->m_connID = connID;
|
||||
instance->m_streamId = (UINT32)InterlockedIncrement(&clipboard->req_f_stream_id_seq);
|
||||
|
||||
if (instance->m_Dsc.dwFlags & FD_ATTRIBUTES)
|
||||
{
|
||||
@@ -611,16 +654,28 @@ static CliprdrStream *CliprdrStream_New(UINT32 connID, ULONG index, void *pData,
|
||||
if (((instance->m_Dsc.dwFlags & FD_FILESIZE) == 0) && !isDir)
|
||||
{
|
||||
/* get content size of this stream */
|
||||
if (cliprdr_send_request_filecontents(clipboard, instance->m_connID, (void *)instance,
|
||||
if (cliprdr_send_request_filecontents(clipboard, instance->m_connID, instance->m_streamId,
|
||||
instance->m_lIndex, FILECONTENTS_SIZE, 0, 0,
|
||||
8) == CHANNEL_RC_OK)
|
||||
{
|
||||
success = TRUE;
|
||||
}
|
||||
|
||||
if (clipboard->req_fdata != NULL)
|
||||
if (clipboard->req_fdata != NULL && clipboard->req_fsize >= sizeof(LONGLONG))
|
||||
{
|
||||
LONGLONG sz = 0;
|
||||
CopyMemory(&sz, clipboard->req_fdata, sizeof(sz));
|
||||
if (sz < 0)
|
||||
success = FALSE;
|
||||
else
|
||||
instance->m_lSize.QuadPart = sz;
|
||||
}
|
||||
else
|
||||
{
|
||||
success = FALSE;
|
||||
}
|
||||
if (clipboard->req_fdata)
|
||||
{
|
||||
instance->m_lSize.QuadPart = *((LONGLONG *)clipboard->req_fdata);
|
||||
free(clipboard->req_fdata);
|
||||
clipboard->req_fdata = NULL;
|
||||
}
|
||||
@@ -1406,25 +1461,35 @@ static UINT32 get_remote_format_id(wfClipboard *clipboard, UINT32 local_format)
|
||||
return local_format;
|
||||
}
|
||||
|
||||
static void map_ensure_capacity(wfClipboard *clipboard)
|
||||
static BOOL map_ensure_capacity(wfClipboard *clipboard, size_t capacity)
|
||||
{
|
||||
size_t old_size;
|
||||
formatMapping *new_map;
|
||||
|
||||
if (!clipboard)
|
||||
return;
|
||||
return FALSE;
|
||||
|
||||
if (clipboard->map_size >= clipboard->map_capacity)
|
||||
{
|
||||
size_t new_size;
|
||||
formatMapping *new_map;
|
||||
new_size = clipboard->map_capacity * 2;
|
||||
new_map =
|
||||
(formatMapping *)realloc(clipboard->format_mappings, sizeof(formatMapping) * new_size);
|
||||
if (!clipboard->format_mappings)
|
||||
return FALSE;
|
||||
|
||||
if (!new_map)
|
||||
return;
|
||||
if (capacity <= clipboard->map_capacity)
|
||||
return TRUE;
|
||||
|
||||
clipboard->format_mappings = new_map;
|
||||
clipboard->map_capacity = new_size;
|
||||
}
|
||||
if (capacity > WF_CLIPRDR_MAX_FORMATS ||
|
||||
capacity > ((size_t)-1) / sizeof(formatMapping))
|
||||
return FALSE;
|
||||
|
||||
old_size = clipboard->map_capacity;
|
||||
new_map =
|
||||
(formatMapping *)realloc(clipboard->format_mappings, sizeof(formatMapping) * capacity);
|
||||
|
||||
if (!new_map)
|
||||
return FALSE;
|
||||
|
||||
memset(new_map + old_size, 0, sizeof(formatMapping) * (capacity - old_size));
|
||||
clipboard->format_mappings = new_map;
|
||||
clipboard->map_capacity = capacity;
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
static BOOL clear_format_map(wfClipboard *clipboard)
|
||||
@@ -1451,6 +1516,13 @@ static BOOL clear_format_map(wfClipboard *clipboard)
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
static UINT wf_cliprdr_server_format_list_fail(wfClipboard *clipboard)
|
||||
{
|
||||
clear_format_map(clipboard);
|
||||
clipboard->copied = FALSE;
|
||||
return ERROR_INTERNAL_ERROR;
|
||||
}
|
||||
|
||||
static UINT cliprdr_send_tempdir(wfClipboard *clipboard)
|
||||
{
|
||||
CLIPRDR_TEMP_DIRECTORY tempDirectory;
|
||||
@@ -1729,12 +1801,12 @@ static UINT cliprdr_send_data_request(UINT32 connID, wfClipboard *clipboard, UIN
|
||||
return wait_response_event(connID, clipboard, clipboard->formatDataRespEvent, &clipboard->formatDataRespReceived, &clipboard->hmem);
|
||||
}
|
||||
|
||||
UINT cliprdr_send_request_filecontents(wfClipboard *clipboard, UINT32 connID, const void *streamid, ULONG index,
|
||||
static UINT cliprdr_send_request_filecontents(wfClipboard *clipboard, UINT32 connID, UINT32 streamId, ULONG index,
|
||||
UINT32 flag, DWORD positionhigh, DWORD positionlow,
|
||||
ULONG nreq)
|
||||
{
|
||||
UINT rc;
|
||||
CLIPRDR_FILE_CONTENTS_REQUEST fileContentsRequest;
|
||||
CLIPRDR_FILE_CONTENTS_REQUEST fileContentsRequest = { 0 };
|
||||
|
||||
if (!clipboard || !clipboard->context || !clipboard->context->ClientFileContentsRequest)
|
||||
return ERROR_INTERNAL_ERROR;
|
||||
@@ -1745,12 +1817,11 @@ UINT cliprdr_send_request_filecontents(wfClipboard *clipboard, UINT32 connID, co
|
||||
return rc;
|
||||
}
|
||||
clipboard->req_f_received = FALSE;
|
||||
clipboard->req_f_conn_id_expected = connID;
|
||||
clipboard->req_f_stream_id_expected = streamId;
|
||||
|
||||
fileContentsRequest.connID = connID;
|
||||
// streamId is `IStream*` pointer, though it is not very good on a 64-bit system.
|
||||
// But it is OK, because it is only used to check if the stream is the same in
|
||||
// `wf_cliprdr_server_file_contents_request()` function.
|
||||
fileContentsRequest.streamId = (UINT32)(ULONG_PTR)streamid;
|
||||
fileContentsRequest.streamId = streamId;
|
||||
fileContentsRequest.listIndex = index;
|
||||
fileContentsRequest.dwFlags = flag;
|
||||
fileContentsRequest.nPositionLow = positionlow;
|
||||
@@ -2443,6 +2514,16 @@ static UINT wf_cliprdr_server_format_list(CliprdrClientContext *context,
|
||||
|
||||
if (!clear_format_map(clipboard))
|
||||
return ERROR_INTERNAL_ERROR;
|
||||
clipboard->copied = FALSE;
|
||||
|
||||
if (formatList->numFormats > WF_CLIPRDR_MAX_FORMATS)
|
||||
return ERROR_INTERNAL_ERROR;
|
||||
|
||||
if (formatList->numFormats > 0 && !formatList->formats)
|
||||
return ERROR_INTERNAL_ERROR;
|
||||
|
||||
if (!map_ensure_capacity(clipboard, formatList->numFormats))
|
||||
return ERROR_INTERNAL_ERROR;
|
||||
|
||||
clipboard->copied = TRUE;
|
||||
|
||||
@@ -2450,19 +2531,58 @@ static UINT wf_cliprdr_server_format_list(CliprdrClientContext *context,
|
||||
{
|
||||
format = &(formatList->formats[i]);
|
||||
mapping = &(clipboard->format_mappings[i]);
|
||||
/* Do not validate the peer-provided formatId as a Windows registered format.
|
||||
* It is only a remote protocol ID used when requesting data from the peer.
|
||||
* For named formats, RegisterClipboardFormatW creates the local Windows
|
||||
* clipboard ID below, and that local ID is checked before publishing. */
|
||||
mapping->remote_format_id = format->formatId;
|
||||
|
||||
if (format->formatName)
|
||||
{
|
||||
int size = MultiByteToWideChar(CP_UTF8, 0, format->formatName,
|
||||
strlen(format->formatName), NULL, 0);
|
||||
mapping->name = calloc(size + 1, sizeof(WCHAR));
|
||||
size_t name_len;
|
||||
int size;
|
||||
|
||||
if (mapping->name)
|
||||
if (!wf_cliprdr_bounded_strlen(format->formatName,
|
||||
WF_CLIPRDR_MAX_FORMAT_NAME_UTF8_BYTES, &name_len))
|
||||
{
|
||||
MultiByteToWideChar(CP_UTF8, 0, format->formatName, strlen(format->formatName),
|
||||
mapping->name, size);
|
||||
mapping->local_format_id = RegisterClipboardFormatW((LPWSTR)mapping->name);
|
||||
return wf_cliprdr_server_format_list_fail(clipboard);
|
||||
}
|
||||
|
||||
if (name_len == 0)
|
||||
{
|
||||
return wf_cliprdr_server_format_list_fail(clipboard);
|
||||
}
|
||||
|
||||
size = MultiByteToWideChar(CP_UTF8, 0, format->formatName, (int)name_len,
|
||||
NULL, 0);
|
||||
if (size <= 0)
|
||||
{
|
||||
return wf_cliprdr_server_format_list_fail(clipboard);
|
||||
}
|
||||
|
||||
if ((UINT)size > WF_CLIPRDR_MAX_FORMAT_NAME_WCHARS)
|
||||
{
|
||||
return wf_cliprdr_server_format_list_fail(clipboard);
|
||||
}
|
||||
|
||||
mapping->name = calloc((size_t)size + 1, sizeof(WCHAR));
|
||||
if (!mapping->name)
|
||||
{
|
||||
return wf_cliprdr_server_format_list_fail(clipboard);
|
||||
}
|
||||
|
||||
if (MultiByteToWideChar(CP_UTF8, 0, format->formatName, (int)name_len,
|
||||
mapping->name, size) != size)
|
||||
{
|
||||
free(mapping->name);
|
||||
mapping->name = NULL;
|
||||
return wf_cliprdr_server_format_list_fail(clipboard);
|
||||
}
|
||||
|
||||
mapping->local_format_id = RegisterClipboardFormatW((LPWSTR)mapping->name);
|
||||
if (mapping->local_format_id == 0)
|
||||
{
|
||||
return wf_cliprdr_server_format_list_fail(clipboard);
|
||||
}
|
||||
}
|
||||
else
|
||||
@@ -2472,7 +2592,6 @@ static UINT wf_cliprdr_server_format_list(CliprdrClientContext *context,
|
||||
}
|
||||
|
||||
clipboard->map_size++;
|
||||
map_ensure_capacity(clipboard);
|
||||
}
|
||||
|
||||
if (file_transferring(clipboard))
|
||||
@@ -2923,6 +3042,7 @@ wf_cliprdr_server_file_contents_request(CliprdrClientContext *context,
|
||||
BOOL bIsStreamFile = TRUE;
|
||||
static LPSTREAM pStreamStc = NULL;
|
||||
static UINT32 uStreamIdStc = 0;
|
||||
static UINT32 uConnIdStc = 0;
|
||||
wfClipboard *clipboard;
|
||||
UINT rc = ERROR_INTERNAL_ERROR;
|
||||
UINT sRc;
|
||||
@@ -2996,7 +3116,8 @@ wf_cliprdr_server_file_contents_request(CliprdrClientContext *context,
|
||||
vFormatEtc.lindex = fileContentsRequest->listIndex;
|
||||
vFormatEtc.ptd = NULL;
|
||||
|
||||
if ((uStreamIdStc != fileContentsRequest->streamId) || !pStreamStc)
|
||||
if ((uStreamIdStc != fileContentsRequest->streamId) ||
|
||||
(uConnIdStc != fileContentsRequest->connID) || !pStreamStc)
|
||||
{
|
||||
LPENUMFORMATETC pEnumFormatEtc;
|
||||
ULONG CeltFetched;
|
||||
@@ -3027,6 +3148,7 @@ wf_cliprdr_server_file_contents_request(CliprdrClientContext *context,
|
||||
{
|
||||
pStreamStc = vStgMedium.pstm;
|
||||
uStreamIdStc = fileContentsRequest->streamId;
|
||||
uConnIdStc = fileContentsRequest->connID;
|
||||
bIsStreamFile = TRUE;
|
||||
}
|
||||
|
||||
@@ -3190,6 +3312,9 @@ wf_cliprdr_server_file_contents_response(CliprdrClientContext *context,
|
||||
rc = ERROR_INTERNAL_ERROR;
|
||||
break;
|
||||
}
|
||||
if (fileContentsResponse->connID != clipboard->req_f_conn_id_expected ||
|
||||
fileContentsResponse->streamId != clipboard->req_f_stream_id_expected)
|
||||
return CHANNEL_RC_OK;
|
||||
clipboard->req_fsize = 0;
|
||||
clipboard->req_fdata = NULL;
|
||||
|
||||
@@ -3200,6 +3325,13 @@ wf_cliprdr_server_file_contents_response(CliprdrClientContext *context,
|
||||
}
|
||||
|
||||
clipboard->req_fsize = fileContentsResponse->cbRequested;
|
||||
/*
|
||||
* Keep the zero-size allocation: supported Windows builds use the Microsoft
|
||||
* CRT, where malloc(0) returns a valid pointer. wait_response_event() also
|
||||
* uses a non-NULL req_fdata to recognize a successful zero-byte response.
|
||||
* The Rust FFI derives requestedData and cbRequested from the same Vec, so a
|
||||
* nonzero length cannot have a NULL data pointer on the normal call path.
|
||||
*/
|
||||
clipboard->req_fdata = (char *)malloc(fileContentsResponse->cbRequested);
|
||||
if (!clipboard->req_fdata)
|
||||
{
|
||||
|
||||
@@ -113,11 +113,11 @@ pub enum MouseButton {
|
||||
|
||||
/// Scroll up button
|
||||
ScrollUp,
|
||||
/// Left right button
|
||||
/// Scroll down button
|
||||
ScrollDown,
|
||||
/// Left right button
|
||||
/// Scroll left button
|
||||
ScrollLeft,
|
||||
/// Left right button
|
||||
/// Scroll right button
|
||||
ScrollRight,
|
||||
}
|
||||
|
||||
|
||||
@@ -223,7 +223,7 @@ impl KeyboardControllable for Enigo {
|
||||
// Windows uses uft-16 encoding. We need to check
|
||||
// for variable length characters. As such some
|
||||
// characters can be 32 bit long and those are
|
||||
// encoded in such called hight and low surrogates
|
||||
// encoded in so-called high and low surrogates
|
||||
// each 16 bit wide that needs to be send after
|
||||
// another to the SendInput function without
|
||||
// being interrupted by "keyup"
|
||||
|
||||
Submodule libs/hbb_common updated: 387603f47c...a920d00945
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rustdesk-portable-packer"
|
||||
version = "1.4.7"
|
||||
version = "1.4.8"
|
||||
edition = "2021"
|
||||
description = "RustDesk Remote Desktop"
|
||||
|
||||
@@ -26,7 +26,7 @@ windows = { version = "0.61", features = [
|
||||
native-windows-gui = {version = "1.0", default-features = false, features = ["animation-timer", "image-decoder"]}
|
||||
|
||||
[package.metadata.winres]
|
||||
LegalCopyright = "Copyright © 2025 Purslane Ltd. All rights reserved."
|
||||
LegalCopyright = "Copyright © 2026 Purslane Tech Pte. Ltd. All rights reserved."
|
||||
ProductName = "RustDesk"
|
||||
OriginalFilename = "rustdesk.exe"
|
||||
FileDescription = "RustDesk Remote Desktop"
|
||||
|
||||
@@ -47,7 +47,7 @@ fn link_vcpkg(mut path: PathBuf, name: &str) -> PathBuf {
|
||||
format!("{}-{}", target_arch, target_os)
|
||||
}
|
||||
} else if target_os == "windows" {
|
||||
"x64-windows-static".to_owned()
|
||||
format!("{}-windows-static", target_arch)
|
||||
} else {
|
||||
format!("{}-{}", target_arch, target_os)
|
||||
};
|
||||
|
||||
@@ -79,6 +79,10 @@ mod webrtc {
|
||||
}
|
||||
}
|
||||
|
||||
fn tile_log2(threads: u32) -> std::os::raw::c_uint {
|
||||
(threads as f64).log2().ceil() as _
|
||||
}
|
||||
|
||||
fn get_super_block_size(width: u32, height: u32, threads: u32) -> aom_superblock_size_t {
|
||||
use aom_superblock_size::*;
|
||||
let resolution = width * height;
|
||||
@@ -160,8 +164,7 @@ mod webrtc {
|
||||
} else {
|
||||
AV1E_SET_TILE_COLUMNS
|
||||
};
|
||||
// Failed on android
|
||||
call_ctl!(ctx, tile_set, (cfg.g_threads as f64 * 1.0f64).log2().ceil());
|
||||
call_ctl!(ctx, tile_set, tile_log2(cfg.g_threads));
|
||||
call_ctl!(ctx, AV1E_SET_ROW_MT, 1);
|
||||
call_ctl!(ctx, AV1E_SET_ENABLE_OBMC, 0);
|
||||
call_ctl!(ctx, AV1E_SET_NOISE_SENSITIVITY, 0);
|
||||
@@ -197,6 +200,23 @@ mod webrtc {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::os::raw::c_uint;
|
||||
|
||||
#[test]
|
||||
fn tile_log2_uses_c_uint_and_rounds_up() {
|
||||
let one_thread: c_uint = tile_log2(1);
|
||||
let three_threads: c_uint = tile_log2(3);
|
||||
let max_threads: c_uint = tile_log2(64);
|
||||
|
||||
assert_eq!(one_thread, 0);
|
||||
assert_eq!(three_threads, 2);
|
||||
assert_eq!(max_threads, 6);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EncoderApi for AomEncoder {
|
||||
|
||||
@@ -52,6 +52,33 @@ lazy_static::lazy_static! {
|
||||
static ref MAG_BUFFER: Mutex<(bool, Vec<u8>)> = Default::default();
|
||||
}
|
||||
|
||||
fn find_windows(cls: &str, name: &str) -> Result<Vec<HWND>> {
|
||||
let name_c = CString::new(name)?;
|
||||
let cls_c = if cls.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(CString::new(cls)?)
|
||||
};
|
||||
let mut hwnds = Vec::new();
|
||||
unsafe {
|
||||
let mut after = NULL as _;
|
||||
loop {
|
||||
let hwnd = FindWindowExA(
|
||||
NULL as _,
|
||||
after,
|
||||
cls_c.as_ref().map_or(NULL as _, |c| c.as_ptr()),
|
||||
name_c.as_ptr(),
|
||||
);
|
||||
if hwnd.is_null() {
|
||||
break;
|
||||
}
|
||||
hwnds.push(hwnd);
|
||||
after = hwnd;
|
||||
}
|
||||
}
|
||||
Ok(hwnds)
|
||||
}
|
||||
|
||||
pub type REFWICPixelFormatGUID = *const GUID;
|
||||
pub type WICPixelFormatGUID = GUID;
|
||||
|
||||
@@ -247,6 +274,8 @@ pub struct CapturerMag {
|
||||
rect: RECT,
|
||||
width: usize,
|
||||
height: usize,
|
||||
excluded_window_target: Option<(String, String)>,
|
||||
excluded_windows: Vec<HWND>,
|
||||
}
|
||||
|
||||
impl Drop for CapturerMag {
|
||||
@@ -261,6 +290,10 @@ impl CapturerMag {
|
||||
MagInterface::new().is_ok()
|
||||
}
|
||||
|
||||
// This captures through the legacy Windows Magnification API. Do not infer
|
||||
// multi-monitor capture support from privacy overlay coverage: WebRTC also
|
||||
// disables its magnifier capturer when SM_CMONITORS != 1.
|
||||
// https://webrtc.googlesource.com/src/+/1845922d5a1bf9c27deeffb4a8c8daea124434c1/modules/desktop_capture/win/screen_capturer_win_magnifier.cc
|
||||
pub(crate) fn new(origin: (i32, i32), width: usize, height: usize) -> Result<Self> {
|
||||
unsafe {
|
||||
let x = GetSystemMetrics(SM_XVIRTUALSCREEN);
|
||||
@@ -305,6 +338,8 @@ impl CapturerMag {
|
||||
},
|
||||
width,
|
||||
height,
|
||||
excluded_window_target: None,
|
||||
excluded_windows: Vec::new(),
|
||||
};
|
||||
|
||||
unsafe {
|
||||
@@ -436,19 +471,41 @@ impl CapturerMag {
|
||||
}
|
||||
|
||||
pub(crate) fn exclude(&mut self, cls: &str, name: &str) -> Result<bool> {
|
||||
let name_c = CString::new(name)?;
|
||||
let mut hwnds = find_windows(cls, name)?;
|
||||
hwnds.sort_unstable_by_key(|hwnd| *hwnd as usize);
|
||||
self.excluded_window_target = Some((cls.to_owned(), name.to_owned()));
|
||||
if hwnds.is_empty() {
|
||||
self.excluded_windows.clear();
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
self.exclude_windows(&mut hwnds)?;
|
||||
self.excluded_windows = hwnds;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn refresh_excluded_windows(&mut self) -> Result<()> {
|
||||
let Some((cls, name)) = self.excluded_window_target.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
let mut hwnds = find_windows(cls, name)?;
|
||||
hwnds.sort_unstable_by_key(|hwnd| *hwnd as usize);
|
||||
// This runs from frame() because refreshed privacy overlays get new
|
||||
// HWNDs. It is only used on the legacy magnifier backend while privacy
|
||||
// mode is active; if it shows up as hot-path cost, throttle this check.
|
||||
// Keep the previous filter list while privacy windows are being recreated.
|
||||
if hwnds.is_empty() || hwnds == self.excluded_windows {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.exclude_windows(&mut hwnds)?;
|
||||
self.excluded_windows = hwnds;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn exclude_windows(&mut self, hwnds: &mut [HWND]) -> Result<bool> {
|
||||
let count = hwnds.len() as _;
|
||||
unsafe {
|
||||
let mut hwnd = if cls.len() == 0 {
|
||||
FindWindowExA(NULL as _, NULL as _, NULL as _, name_c.as_ptr())
|
||||
} else {
|
||||
let cls_c = CString::new(cls).unwrap();
|
||||
FindWindowExA(NULL as _, NULL as _, cls_c.as_ptr(), name_c.as_ptr())
|
||||
};
|
||||
|
||||
if hwnd.is_null() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
if let Some(set_window_filter_list_func) =
|
||||
self.mag_interface.set_window_filter_list_func
|
||||
{
|
||||
@@ -456,16 +513,15 @@ impl CapturerMag {
|
||||
== set_window_filter_list_func(
|
||||
self.magnifier_window,
|
||||
MW_FILTERMODE_EXCLUDE,
|
||||
1,
|
||||
&mut hwnd,
|
||||
count,
|
||||
hwnds.as_mut_ptr(),
|
||||
)
|
||||
{
|
||||
return Err(Error::new(
|
||||
ErrorKind::Other,
|
||||
format!(
|
||||
"Failed MagSetWindowFilterList for cls {} name {}, error {}",
|
||||
cls,
|
||||
name,
|
||||
"Failed MagSetWindowFilterList for {} windows, error {}",
|
||||
count,
|
||||
Error::last_os_error()
|
||||
),
|
||||
));
|
||||
@@ -496,6 +552,7 @@ impl CapturerMag {
|
||||
}
|
||||
|
||||
pub(crate) fn frame(&mut self, data: &mut Vec<u8>) -> Result<()> {
|
||||
self.refresh_excluded_windows()?;
|
||||
Self::clear_data();
|
||||
|
||||
unsafe {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
pkgname=rustdesk
|
||||
pkgver=1.4.7
|
||||
pkgver=1.4.8
|
||||
pkgrel=0
|
||||
epoch=
|
||||
pkgdesc=""
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
#! /usr/bin/env bash
|
||||
sed -i "s/$1/$2/g" res/*spec res/PKGBUILD flutter/pubspec.yaml Cargo.toml .github/workflows/*yml flatpak/*json appimage/*yml libs/portable/Cargo.toml
|
||||
sed -i "s/\b$1\b/$2/g" res/*spec res/PKGBUILD flutter/pubspec.yaml Cargo.toml .github/workflows/*yml flatpak/*json appimage/*yml libs/portable/Cargo.toml
|
||||
cargo run # to bump version in cargo lock
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
<Configuration>Release</Configuration>
|
||||
<Platform>x64</Platform>
|
||||
</ProjectConfiguration>
|
||||
<ProjectConfiguration Include="Release|ARM64">
|
||||
<Configuration>Release</Configuration>
|
||||
<Platform>ARM64</Platform>
|
||||
</ProjectConfiguration>
|
||||
</ItemGroup>
|
||||
<PropertyGroup Label="Globals">
|
||||
<Keyword>Win32Proj</Keyword>
|
||||
@@ -22,6 +26,12 @@
|
||||
<WholeProgramOptimization>true</WholeProgramOptimization>
|
||||
<CharacterSet>Unicode</CharacterSet>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|ARM64'" Label="Configuration">
|
||||
<ConfigurationType>DynamicLibrary</ConfigurationType>
|
||||
<UseDebugLibraries>false</UseDebugLibraries>
|
||||
<WholeProgramOptimization>true</WholeProgramOptimization>
|
||||
<CharacterSet>Unicode</CharacterSet>
|
||||
</PropertyGroup>
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
|
||||
<ImportGroup Label="ExtensionSettings">
|
||||
</ImportGroup>
|
||||
@@ -30,6 +40,9 @@
|
||||
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
|
||||
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
|
||||
</ImportGroup>
|
||||
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Release|ARM64'">
|
||||
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
|
||||
</ImportGroup>
|
||||
<PropertyGroup Label="UserMacros" />
|
||||
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
|
||||
<ClCompile>
|
||||
@@ -53,6 +66,28 @@
|
||||
<ModuleDefinitionFile>CustomActions.def</ModuleDefinitionFile>
|
||||
</Link>
|
||||
</ItemDefinitionGroup>
|
||||
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|ARM64'">
|
||||
<ClCompile>
|
||||
<WarningLevel>Level3</WarningLevel>
|
||||
<FunctionLevelLinking>true</FunctionLevelLinking>
|
||||
<IntrinsicFunctions>true</IntrinsicFunctions>
|
||||
<SDLCheck>true</SDLCheck>
|
||||
<PreprocessorDefinitions>NDEBUG;EXAMPLECADLL_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions>
|
||||
<ConformanceMode>true</ConformanceMode>
|
||||
<PrecompiledHeader>Use</PrecompiledHeader>
|
||||
<PrecompiledHeaderFile>pch.h</PrecompiledHeaderFile>
|
||||
<RuntimeLibrary>MultiThreaded</RuntimeLibrary>
|
||||
</ClCompile>
|
||||
<Link>
|
||||
<AdditionalDependencies>msi.lib;version.lib;%(AdditionalDependencies)</AdditionalDependencies>
|
||||
<SubSystem>Windows</SubSystem>
|
||||
<EnableCOMDATFolding>true</EnableCOMDATFolding>
|
||||
<OptimizeReferences>true</OptimizeReferences>
|
||||
<GenerateDebugInformation>true</GenerateDebugInformation>
|
||||
<EnableUAC>false</EnableUAC>
|
||||
<ModuleDefinitionFile>CustomActions.def</ModuleDefinitionFile>
|
||||
</Link>
|
||||
</ItemDefinitionGroup>
|
||||
<ItemGroup>
|
||||
<ClInclude Include="Common.h" />
|
||||
<ClInclude Include="framework.h" />
|
||||
@@ -65,6 +100,7 @@
|
||||
<ClCompile Include="FirewallRules.cpp" />
|
||||
<ClCompile Include="pch.cpp">
|
||||
<PrecompiledHeader Condition="'$(Configuration)|$(Platform)'=='Release|x64'">Create</PrecompiledHeader>
|
||||
<PrecompiledHeader Condition="'$(Configuration)|$(Platform)'=='Release|ARM64'">Create</PrecompiledHeader>
|
||||
</ClCompile>
|
||||
<ClCompile Include="ReadConfig.cpp" />
|
||||
<ClCompile Include="RemotePrinter.cpp" />
|
||||
|
||||
@@ -79,7 +79,7 @@ heading 1;}{\s2\ql \li0\ri0\nowidctlpar\wrapdefault\faauto\outlinelevel1\rin0\li
|
||||
\ab\af1 \ltrch\fcs0 \b\ul\cf2\lang1033\langfe2052\langnp1033\insrsid1917520
|
||||
\par }\pard \ltrpar\s2\qj \li0\ri0\sb100\sa100\nowidctlpar\wrapdefault\faauto\outlinelevel1\rin0\lin0\itap0\pararsid8979511 {\rtlch\fcs1 \af1\afs18 \ltrch\fcs0 \fs18\lang1033\langfe2052\langnp1033\insrsid8979511\charrsid8979511 \hich\af1\dbch\af31505\loch\f1
|
||||
\hich\f1 This Privacy Policy (hereinafter the \'93}{\rtlch\fcs1 \ab\af1\afs21 \ltrch\fcs0 \b\fs21\lang1033\langfe2052\langnp1033\insrsid8979511\charrsid1523795 \hich\af1\dbch\af31505\loch\f1 Policy}{\rtlch\fcs1 \af1\afs18 \ltrch\fcs0
|
||||
\fs18\lang1033\langfe2052\langnp1033\insrsid8979511\charrsid8979511 \loch\af1\dbch\af31505\hich\f1 \'94\loch\f1 \hich\f1 ) governs the terms and conditions under which Purslane Ltd. (hereinafter \'93}{\rtlch\fcs1 \ab\af1\afs21 \ltrch\fcs0
|
||||
\fs18\lang1033\langfe2052\langnp1033\insrsid8979511\charrsid8979511 \loch\af1\dbch\af31505\hich\f1 \'94\loch\f1 \hich\f1 ) governs the terms and conditions under which Purslane Tech Pte. Ltd. (hereinafter \'93}{\rtlch\fcs1 \ab\af1\afs21 \ltrch\fcs0
|
||||
\b\fs21\lang1033\langfe2052\langnp1033\insrsid8979511\charrsid1523795 \hich\af1\dbch\af31505\loch\f1 us}{\rtlch\fcs1 \af1\afs18 \ltrch\fcs0 \fs18\lang1033\langfe2052\langnp1033\insrsid8979511\charrsid8979511 \loch\af1\dbch\af31505\hich\f1 \'94\loch\f1
|
||||
\hich\f1 or \'93}{\rtlch\fcs1 \ab\af1\afs21 \ltrch\fcs0 \b\fs21\lang1033\langfe2052\langnp1033\insrsid8979511\charrsid1523795 \hich\af1\dbch\af31505\loch\f1 we}{\rtlch\fcs1 \af1\afs18 \ltrch\fcs0
|
||||
\fs18\lang1033\langfe2052\langnp1033\insrsid8979511\charrsid8979511 \loch\af1\dbch\af31505\hich\f1 \'94\loch\f1 \hich\f1
|
||||
@@ -300,4 +300,4 @@ b060828e6f37ed1567914b284d262452282e3198720e274a939cd08a54f980ae38a38f56e422a3a6
|
||||
\lsdpriority49 \lsdlocked0 List Table 4 Accent 5;\lsdpriority50 \lsdlocked0 List Table 5 Dark Accent 5;\lsdpriority51 \lsdlocked0 List Table 6 Colorful Accent 5;\lsdpriority52 \lsdlocked0 List Table 7 Colorful Accent 5;
|
||||
\lsdpriority46 \lsdlocked0 List Table 1 Light Accent 6;\lsdpriority47 \lsdlocked0 List Table 2 Accent 6;\lsdpriority48 \lsdlocked0 List Table 3 Accent 6;\lsdpriority49 \lsdlocked0 List Table 4 Accent 6;
|
||||
\lsdpriority50 \lsdlocked0 List Table 5 Dark Accent 6;\lsdpriority51 \lsdlocked0 List Table 6 Colorful Accent 6;\lsdpriority52 \lsdlocked0 List Table 7 Colorful Accent 6;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Mention;
|
||||
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Smart Hyperlink;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Hashtag;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Unresolved Mention;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Smart Link;}}}
|
||||
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Smart Hyperlink;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Hashtag;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Unresolved Mention;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Smart Link;}}}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<IncludeSearchPaths>
|
||||
</IncludeSearchPaths>
|
||||
<Configurations>Release</Configurations>
|
||||
<Platforms>x64</Platforms>
|
||||
<Platforms>x64;ARM64</Platforms>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Content Include="Includes.wxi" />
|
||||
|
||||
@@ -10,12 +10,17 @@ EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Release|x64 = Release|x64
|
||||
Release|ARM64 = Release|ARM64
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{F403A403-CEFF-4399-B51C-CC646C8E98CF}.Release|x64.ActiveCfg = Release|x64
|
||||
{F403A403-CEFF-4399-B51C-CC646C8E98CF}.Release|x64.Build.0 = Release|x64
|
||||
{F403A403-CEFF-4399-B51C-CC646C8E98CF}.Release|ARM64.ActiveCfg = Release|ARM64
|
||||
{F403A403-CEFF-4399-B51C-CC646C8E98CF}.Release|ARM64.Build.0 = Release|ARM64
|
||||
{6B3647E0-B4A3-46AE-8757-A22EE51C1DAC}.Release|x64.ActiveCfg = Release|x64
|
||||
{6B3647E0-B4A3-46AE-8757-A22EE51C1DAC}.Release|x64.Build.0 = Release|x64
|
||||
{6B3647E0-B4A3-46AE-8757-A22EE51C1DAC}.Release|ARM64.ActiveCfg = Release|ARM64
|
||||
{6B3647E0-B4A3-46AE-8757-A22EE51C1DAC}.Release|ARM64.Build.0 = Release|ARM64
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
||||
@@ -85,7 +85,7 @@ def make_parser():
|
||||
"-m",
|
||||
"--manufacturer",
|
||||
type=str,
|
||||
default="PURSLANE",
|
||||
default="Purslane Tech Pte. Ltd.",
|
||||
help="The app manufacturer.",
|
||||
)
|
||||
return parser
|
||||
@@ -499,7 +499,7 @@ def update_license_file(app_name):
|
||||
license_content = f.read()
|
||||
license_content = license_content.replace("website rustdesk.com and other ", "")
|
||||
license_content = license_content.replace("RustDesk", app_name)
|
||||
license_content = re.sub("Purslane Ltd", app_name, license_content, flags=re.IGNORECASE)
|
||||
license_content = re.sub(r"Purslane(?: Tech Pte\.)? Ltd", app_name, license_content, flags=re.IGNORECASE)
|
||||
with open(license_file, "w", encoding="utf-8") as f:
|
||||
f.write(license_content)
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
Name: rustdesk
|
||||
Version: 1.4.7
|
||||
Version: 1.4.8
|
||||
Release: 0
|
||||
Summary: RPM package
|
||||
License: GPL-3.0
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
Name: rustdesk
|
||||
Version: 1.4.7
|
||||
Version: 1.4.8
|
||||
Release: 0
|
||||
Summary: RPM package
|
||||
License: GPL-3.0
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
Name: rustdesk
|
||||
Version: 1.4.7
|
||||
Version: 1.4.8
|
||||
Release: 0
|
||||
Summary: RPM package
|
||||
License: GPL-3.0
|
||||
|
||||
@@ -45,7 +45,7 @@ pre_start()
|
||||
return 0
|
||||
}
|
||||
|
||||
# When loging out from the interactive shell, the execution sequence is:
|
||||
# When logging out from the interactive shell, the execution sequence is:
|
||||
#
|
||||
# IF ~/.bash_logout exists THEN
|
||||
# execute ~/.bash_logout
|
||||
|
||||
@@ -130,14 +130,18 @@ elseif(VCPKG_TARGET_IS_WINDOWS)
|
||||
--cc=cl \
|
||||
--enable-gpl \
|
||||
--enable-d3d11va \
|
||||
--enable-cuda \
|
||||
--enable-ffnvcodec \
|
||||
--enable-hwaccel=h264_nvdec \
|
||||
--enable-hwaccel=hevc_nvdec \
|
||||
--enable-hwaccel=h264_d3d11va \
|
||||
--enable-hwaccel=hevc_d3d11va \
|
||||
--enable-hwaccel=h264_d3d11va2 \
|
||||
--enable-hwaccel=hevc_d3d11va2 \
|
||||
")
|
||||
|
||||
if(VCPKG_TARGET_ARCHITECTURE STREQUAL "x86" OR VCPKG_TARGET_ARCHITECTURE STREQUAL "x64")
|
||||
string(APPEND OPTIONS "\
|
||||
--enable-cuda \
|
||||
--enable-ffnvcodec \
|
||||
--enable-hwaccel=h264_nvdec \
|
||||
--enable-hwaccel=hevc_nvdec \
|
||||
--enable-amf \
|
||||
--enable-encoder=h264_amf \
|
||||
--enable-encoder=hevc_amf \
|
||||
@@ -147,6 +151,7 @@ elseif(VCPKG_TARGET_IS_WINDOWS)
|
||||
--enable-encoder=h264_qsv \
|
||||
--enable-encoder=hevc_qsv \
|
||||
")
|
||||
endif()
|
||||
|
||||
if(VCPKG_TARGET_ARCHITECTURE STREQUAL "x86")
|
||||
set(LIB_MACHINE_ARG /machine:x86)
|
||||
@@ -154,6 +159,9 @@ elseif(VCPKG_TARGET_IS_WINDOWS)
|
||||
elseif(VCPKG_TARGET_ARCHITECTURE STREQUAL "x64")
|
||||
set(LIB_MACHINE_ARG /machine:x64)
|
||||
string(APPEND OPTIONS " --arch=x86_64")
|
||||
elseif(VCPKG_TARGET_ARCHITECTURE STREQUAL "arm64")
|
||||
set(LIB_MACHINE_ARG /machine:arm64)
|
||||
string(APPEND OPTIONS " --arch=aarch64 --enable-cross-compile")
|
||||
else()
|
||||
message(FATAL_ERROR "Unsupported target architecture")
|
||||
endif()
|
||||
|
||||
@@ -96,6 +96,8 @@ pub mod screenshot;
|
||||
|
||||
pub const MILLI1: Duration = Duration::from_millis(1);
|
||||
pub const SEC30: Duration = Duration::from_secs(30);
|
||||
// Empirical restart reconnect grace window.
|
||||
const RESTART_REMOTE_DEVICE_GRACE: Duration = Duration::from_secs(5 * 60);
|
||||
pub const VIDEO_QUEUE_SIZE: usize = 120;
|
||||
const MAX_DECODE_FAIL_COUNTER: usize = 3;
|
||||
|
||||
@@ -939,21 +941,23 @@ impl Client {
|
||||
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
fn try_stop_clipboard() {
|
||||
// There's a bug here.
|
||||
// If session is closed by the peer, `has_sessions_running()` will always return true.
|
||||
// It's better to check if the active session number.
|
||||
// But it's not a problem, because the clipboard thread does not consume CPU.
|
||||
//
|
||||
// If we want to fix it, we can add a flag to indicate if session is active.
|
||||
// But I think it's not necessary to introduce complexity at this point.
|
||||
// Disconnected Flutter sessions may keep UI handlers alive, so only connected sessions
|
||||
// should block clipboard cleanup.
|
||||
#[cfg(feature = "flutter")]
|
||||
if crate::flutter::sessions::has_sessions_running(ConnType::DEFAULT_CONN) {
|
||||
if crate::flutter::sessions::has_connected_sessions_running(ConnType::DEFAULT_CONN) {
|
||||
return;
|
||||
}
|
||||
#[cfg(not(target_os = "android"))]
|
||||
clipboard_listener::unsubscribe(Self::CLIENT_CLIPBOARD_NAME);
|
||||
CLIPBOARD_STATE.lock().unwrap().running = false;
|
||||
#[cfg(all(feature = "unix-file-copy-paste", target_os = "linux"))]
|
||||
if let Err(e) = crate::clipboard::try_empty_clipboard_files_sync(
|
||||
crate::clipboard::ClipboardSide::Client,
|
||||
0,
|
||||
) {
|
||||
log::error!("Failed to empty client clipboard files: {}", e);
|
||||
}
|
||||
#[cfg(all(feature = "unix-file-copy-paste", target_os = "linux"))]
|
||||
clipboard::platform::unix::fuse::uninit_fuse_context(true);
|
||||
}
|
||||
|
||||
@@ -1740,7 +1744,10 @@ pub struct LoginConfigHandler {
|
||||
features: Option<Features>,
|
||||
pub session_id: u64, // used for local <-> server communication
|
||||
pub supported_encoding: SupportedEncoding,
|
||||
pub restarting_remote_device: bool,
|
||||
restarting_remote_device: bool,
|
||||
// Start time of the restart grace window. On Windows the peer may briefly
|
||||
// reconnect before the real reboot disconnect.
|
||||
restart_remote_device_at: Option<Instant>,
|
||||
pub force_relay: bool,
|
||||
pub direct: Option<bool>,
|
||||
pub received: bool,
|
||||
@@ -1849,7 +1856,7 @@ impl LoginConfigHandler {
|
||||
}
|
||||
self.session_id = sid;
|
||||
self.supported_encoding = Default::default();
|
||||
self.restarting_remote_device = false;
|
||||
self.clear_restarting_remote_device();
|
||||
self.force_relay =
|
||||
config::option2bool("force-always-relay", &self.get_option("force-always-relay"))
|
||||
|| force_relay
|
||||
@@ -2779,6 +2786,30 @@ impl LoginConfigHandler {
|
||||
msg_out
|
||||
}
|
||||
|
||||
pub fn mark_restarting_remote_device(&mut self) {
|
||||
self.restarting_remote_device = true;
|
||||
self.restart_remote_device_at = Some(Instant::now());
|
||||
}
|
||||
|
||||
pub fn clear_restarting_remote_device(&mut self) {
|
||||
self.restarting_remote_device = false;
|
||||
self.restart_remote_device_at = None;
|
||||
}
|
||||
|
||||
pub fn is_restarting_remote_device(&self) -> bool {
|
||||
if !self.restarting_remote_device {
|
||||
return false;
|
||||
}
|
||||
// Keep this flag alive for a short grace window instead of clearing it on
|
||||
// connection_ready or the first peer bytes. During OS restart the peer can
|
||||
// briefly reconnect before the real reboot disconnect, and clearing it too
|
||||
// early would let the next disconnect escape the restart flow and fall back
|
||||
// to the normal error dialog / manual reconnect path.
|
||||
self.restart_remote_device_at
|
||||
.map(|started_at| started_at.elapsed() < RESTART_REMOTE_DEVICE_GRACE)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn get_conn_token(&self) -> Option<String> {
|
||||
if self.password.is_empty() {
|
||||
return None;
|
||||
@@ -3718,9 +3749,18 @@ pub trait Interface: Send + Clone + 'static + Sized {
|
||||
fn on_establish_connection_error(&self, err: String) {
|
||||
let title = "Connection Error";
|
||||
let text = err.to_string();
|
||||
let lc = self.get_lch();
|
||||
let direct = lc.read().unwrap().direct;
|
||||
let received = lc.read().unwrap().received;
|
||||
let lch = self.get_lch();
|
||||
let (is_restarting, direct, received) = {
|
||||
let lc = lch.read().unwrap();
|
||||
(lc.is_restarting_remote_device(), lc.direct, lc.received)
|
||||
};
|
||||
if is_restarting {
|
||||
log::info!("Restart remote device, suppress connection error: {err}");
|
||||
// Flutter treats this as a reconnect control event. The text is kept
|
||||
// for legacy UI and existing translation reuse.
|
||||
self.msgbox("restarting", "Restarting remote device", "Connection in progress. Please wait.", "");
|
||||
return;
|
||||
}
|
||||
|
||||
let mut relay_hint = false;
|
||||
let mut relay_hint_type = "relay-hint";
|
||||
|
||||
@@ -10,6 +10,10 @@ use crate::{
|
||||
common::get_default_sound_input,
|
||||
ui_session_interface::{InvokeUiSession, Session},
|
||||
};
|
||||
|
||||
// Empirical no-data window before exposing the restart reconnect state to the UI.
|
||||
// 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);
|
||||
#[cfg(feature = "unix-file-copy-paste")]
|
||||
use crate::{clipboard::try_empty_clipboard_files, clipboard_file::unix_file_clip};
|
||||
#[cfg(any(
|
||||
@@ -153,7 +157,6 @@ impl<T: InvokeUiSession> Remote<T> {
|
||||
}
|
||||
};
|
||||
|
||||
let mut last_recv_time = Instant::now();
|
||||
let mut received = false;
|
||||
let conn_type = if self.handler.is_file_transfer() {
|
||||
ConnType::FILE_TRANSFER
|
||||
@@ -219,6 +222,7 @@ impl<T: InvokeUiSession> Remote<T> {
|
||||
let mut fps_instant = Instant::now();
|
||||
|
||||
let _keep_it = client::hc_connection(feedback, rendezvous_server, token).await;
|
||||
let mut last_recv_time = Instant::now();
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
@@ -244,7 +248,7 @@ impl<T: InvokeUiSession> Remote<T> {
|
||||
} else {
|
||||
if self.handler.is_restarting_remote_device() {
|
||||
log::info!("Restart remote device");
|
||||
self.handler.msgbox("restarting", "Restarting remote device", "remote_restarting_tip", "");
|
||||
self.handler.msgbox("restarting", "Restarting remote device", "Connection in progress. Please wait.", "");
|
||||
} else {
|
||||
log::info!("Reset by the peer");
|
||||
self.handler.msgbox("error", "Connection Error", "Reset by the peer", "");
|
||||
@@ -279,6 +283,12 @@ impl<T: InvokeUiSession> Remote<T> {
|
||||
}
|
||||
}
|
||||
_ = status_timer.tick() => {
|
||||
if self.handler.is_restarting_remote_device()
|
||||
&& last_recv_time.elapsed() >= RESTART_REMOTE_DEVICE_NO_DATA_TIMEOUT
|
||||
{
|
||||
self.handler.msgbox("restarting-show", "Restarting remote device", "Connection in progress. Please wait.", "");
|
||||
break;
|
||||
}
|
||||
let elapsed = fps_instant.elapsed().as_millis();
|
||||
if elapsed < 1000 {
|
||||
continue;
|
||||
@@ -350,6 +360,8 @@ impl<T: InvokeUiSession> Remote<T> {
|
||||
|
||||
#[cfg(any(target_os = "windows", feature = "unix-file-copy-paste"))]
|
||||
if self.handler.is_default() && _set_disconnected_ok {
|
||||
// Linux client cleanup runs synchronously in try_stop_clipboard() before FUSE is
|
||||
// unmounted. Keep this async path for other file-clipboard platforms.
|
||||
crate::clipboard::try_empty_clipboard_files(ClipboardSide::Client, self.client_conn_id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,41 +151,50 @@ pub fn update_clipboard_files(files: Vec<String>, side: ClipboardSide) {
|
||||
#[cfg(feature = "unix-file-copy-paste")]
|
||||
pub fn try_empty_clipboard_files(_side: ClipboardSide, _conn_id: i32) {
|
||||
std::thread::spawn(move || {
|
||||
let mut ctx = CLIPBOARD_CTX.lock().unwrap();
|
||||
if ctx.is_none() {
|
||||
match ClipboardContext::new() {
|
||||
Ok(x) => {
|
||||
*ctx = Some(x);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to create clipboard context: {}", e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
#[allow(unused_mut)]
|
||||
if let Some(mut ctx) = ctx.as_mut() {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use clipboard::platform::unix;
|
||||
if unix::fuse::empty_local_files(_side == ClipboardSide::Client, _conn_id) {
|
||||
ctx.try_empty_clipboard_files(_side);
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
ctx.try_empty_clipboard_files(_side);
|
||||
// No need to make sure the context is enabled.
|
||||
clipboard::ContextSend::proc(|context| -> ResultType<()> {
|
||||
context.empty_clipboard(_conn_id).ok();
|
||||
Ok(())
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
if let Err(e) = try_empty_clipboard_files_sync(_side, _conn_id) {
|
||||
log::error!("Failed to empty clipboard files: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(feature = "unix-file-copy-paste")]
|
||||
pub fn try_empty_clipboard_files_sync(_side: ClipboardSide, _conn_id: i32) -> ResultType<()> {
|
||||
let mut ctx = CLIPBOARD_CTX.lock().unwrap();
|
||||
if ctx.is_none() {
|
||||
match ClipboardContext::new() {
|
||||
Ok(x) => {
|
||||
*ctx = Some(x);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to create clipboard context: {}", e);
|
||||
bail!("Failed to create clipboard context: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[allow(unused_mut)]
|
||||
if let Some(mut ctx) = ctx.as_mut() {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use clipboard::platform::unix;
|
||||
if unix::fuse::empty_local_files(_side == ClipboardSide::Client, _conn_id) {
|
||||
ctx.try_empty_clipboard_files(_side);
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
ctx.try_empty_clipboard_files(_side);
|
||||
// No need to make sure the context is enabled.
|
||||
clipboard::ContextSend::proc(|context| -> ResultType<()> {
|
||||
if !context.empty_clipboard(_conn_id)? {
|
||||
bail!("Failed to empty clipboard files for conn_id {}", _conn_id);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn try_empty_clipboard_files(side: ClipboardSide, conn_id: i32) {
|
||||
log::debug!("try to empty {} cliprdr for conn_id {}", side, conn_id);
|
||||
@@ -868,6 +877,7 @@ pub mod clipboard_listener {
|
||||
.unwrap()
|
||||
.insert(name.clone(), tx);
|
||||
|
||||
cleanup_stale_listener(&mut listener_lock);
|
||||
if listener_lock.handle.is_none() {
|
||||
log::info!("Start clipboard listener thread");
|
||||
let handler = Handler {
|
||||
@@ -893,6 +903,24 @@ pub mod clipboard_listener {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cleanup_stale_listener(listener: &mut ClipboardListener) {
|
||||
if !listener
|
||||
.handle
|
||||
.as_ref()
|
||||
.map(|(_, h)| h.is_finished())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let Some((shutdown, h)) = listener.handle.take() {
|
||||
log::warn!("Cleaning up stale clipboard listener handle");
|
||||
if let Err(e) = h.join() {
|
||||
log::error!("Clipboard listener thread panicked during stale cleanup: {:?}", e);
|
||||
}
|
||||
drop(shutdown);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unsubscribe(name: &str) {
|
||||
log::info!("Unsubscribe clipboard listener: {}", name);
|
||||
let mut listener_lock = CLIPBOARD_LISTENER.lock().unwrap();
|
||||
|
||||
@@ -332,12 +332,16 @@ pub mod unix_file_clip {
|
||||
log::debug!("format data response: msg_flags: {}", msg_flags);
|
||||
|
||||
if msg_flags != 0x1 {
|
||||
// return failure message?
|
||||
log::error!(
|
||||
"peer reported clipboard format data failure: {}",
|
||||
msg_flags
|
||||
);
|
||||
return vec![];
|
||||
}
|
||||
|
||||
log::debug!("parsing file descriptors");
|
||||
if fuse::init_fuse_context(true).is_ok() {
|
||||
match fuse::format_data_response_to_urls(
|
||||
match fuse::init_fuse_context(side == ClipboardSide::Client) {
|
||||
Ok(()) => match fuse::format_data_response_to_urls(
|
||||
side == ClipboardSide::Client,
|
||||
format_data,
|
||||
conn_id,
|
||||
@@ -348,9 +352,10 @@ pub mod unix_file_clip {
|
||||
Err(e) => {
|
||||
log::error!("failed to parse file descriptors: {:?}", e);
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
log::error!("failed to initialize clipboard FUSE context: {:?}", e);
|
||||
}
|
||||
} else {
|
||||
// send error message to server
|
||||
}
|
||||
}
|
||||
ClipboardFile::FileContentsRequest {
|
||||
@@ -386,6 +391,7 @@ pub mod unix_file_clip {
|
||||
ClipboardFile::FileContentsResponse {
|
||||
msg_flags,
|
||||
stream_id,
|
||||
requested_data,
|
||||
..
|
||||
} => {
|
||||
log::debug!(
|
||||
@@ -393,13 +399,15 @@ pub mod unix_file_clip {
|
||||
msg_flags,
|
||||
stream_id,
|
||||
);
|
||||
if fuse::init_fuse_context(true).is_ok() {
|
||||
hbb_common::allow_err!(fuse::handle_file_content_response(
|
||||
side == ClipboardSide::Client,
|
||||
clip
|
||||
));
|
||||
} else {
|
||||
// send error message to server
|
||||
let response = ClipboardFile::FileContentsResponse {
|
||||
msg_flags,
|
||||
stream_id,
|
||||
requested_data,
|
||||
};
|
||||
if let Err(e) =
|
||||
fuse::handle_file_content_response(side == ClipboardSide::Client, response)
|
||||
{
|
||||
log::error!("failed to handle file contents response: {:?}", e);
|
||||
}
|
||||
}
|
||||
ClipboardFile::NotifyCallback {
|
||||
|
||||
@@ -2297,6 +2297,16 @@ pub mod sessions {
|
||||
*r#type == conn_type && s.session_handlers.read().unwrap().len() != 0
|
||||
})
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
pub fn has_connected_sessions_running(conn_type: ConnType) -> bool {
|
||||
SESSIONS.read().unwrap().iter().any(|((_, r#type), s)| {
|
||||
*r#type == conn_type
|
||||
&& s.session_handlers.read().unwrap().len() != 0
|
||||
&& s.connection_round_state.lock().unwrap().is_connected()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) mod async_tasks {
|
||||
|
||||
@@ -2852,8 +2852,16 @@ pub fn main_get_common(key: String) -> String {
|
||||
crate::platform::windows::is_msi_installed(),
|
||||
crate::common::is_custom_client(),
|
||||
) {
|
||||
(Ok(true), false) => format!("rustdesk-{_version}-x86_64.msi"),
|
||||
(Ok(true), true) | (Ok(false), _) => format!("rustdesk-{_version}-x86_64.exe"),
|
||||
(Ok(true), false) => match crate::platform::windows::release_arch_suffix() {
|
||||
Some(arch) => format!("rustdesk-{_version}-{arch}.msi"),
|
||||
None => "error:unsupported".to_owned(),
|
||||
},
|
||||
(Ok(true), true) | (Ok(false), _) => {
|
||||
match crate::platform::windows::release_arch_suffix() {
|
||||
Some(arch) => format!("rustdesk-{_version}-{arch}.exe"),
|
||||
None => "error:unsupported".to_owned(),
|
||||
}
|
||||
}
|
||||
(Err(e), _) => {
|
||||
log::error!("Failed to check if is msi: {}", e);
|
||||
format!("error:update-failed-check-msi-tip")
|
||||
|
||||
@@ -1245,11 +1245,49 @@ pub fn legacy_keyboard_mode(event: &Event, mut key_event: KeyEvent) -> Vec<KeyEv
|
||||
|
||||
#[inline]
|
||||
pub fn map_keyboard_mode(_peer: &str, event: &Event, key_event: KeyEvent) -> Vec<KeyEvent> {
|
||||
if let Some(evt) = windows_peer_special_key(_peer, event) {
|
||||
return vec![evt];
|
||||
}
|
||||
|
||||
_map_keyboard_mode(_peer, event, key_event)
|
||||
.map(|e| vec![e])
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn windows_peer_special_key(peer: &str, event: &Event) -> Option<KeyEvent> {
|
||||
if peer != OS_LOWER_WINDOWS {
|
||||
return None;
|
||||
}
|
||||
|
||||
let (key, down) = match event.event_type {
|
||||
EventType::KeyPress(key) => (key, true),
|
||||
EventType::KeyRelease(key) => (key, false),
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
// Handle only `Pause` for Windows peers for now.
|
||||
// Windows has no normal scan code for `Pause`, so send it as a legacy control key.
|
||||
#[cfg(target_os = "windows")]
|
||||
let is_pause = {
|
||||
// The Windows scan code can look like `NumLock`; VK_PAUSE distinguishes it.
|
||||
let pause_vk_code = rdev::win_code_from_key(Key::Pause);
|
||||
key == Key::Pause || pause_vk_code == Some(event.platform_code as _)
|
||||
};
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let is_pause = key == Key::Pause;
|
||||
if !is_pause {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut key_event = KeyEvent::new();
|
||||
key_event.mode = KeyboardMode::Legacy.into();
|
||||
key_event.down = down;
|
||||
key_event.set_control_key(ControlKey::Pause);
|
||||
let (alt, ctrl, shift, command) = client::get_modifiers_state(false, false, false, false);
|
||||
client::legacy_modifiers(&mut key_event, alt, ctrl, shift, command);
|
||||
Some(key_event)
|
||||
}
|
||||
|
||||
fn _map_keyboard_mode(_peer: &str, event: &Event, mut key_event: KeyEvent) -> Option<KeyEvent> {
|
||||
match event.event_type {
|
||||
EventType::KeyPress(..) => {
|
||||
@@ -1421,6 +1459,11 @@ fn is_press(event: &Event) -> bool {
|
||||
pub fn translate_keyboard_mode(peer: &str, event: &Event, key_event: KeyEvent) -> Vec<KeyEvent> {
|
||||
let mut events: Vec<KeyEvent> = Vec::new();
|
||||
|
||||
if let Some(evt) = windows_peer_special_key(peer, event) {
|
||||
events.push(evt);
|
||||
return events;
|
||||
}
|
||||
|
||||
if let Some(unicode_info) = &event.unicode {
|
||||
if unicode_info.is_dead {
|
||||
#[cfg(target_os = "macos")]
|
||||
|
||||
74
src/lang.rs
74
src/lang.rs
@@ -103,15 +103,29 @@ pub const LANGS: &[(&str, &str)] = &[
|
||||
("gu", "ગુજરાતી"),
|
||||
];
|
||||
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
pub fn translate(name: String) -> String {
|
||||
let locale = sys_locale::get_locale().unwrap_or_default();
|
||||
translate_locale(name, &locale)
|
||||
pub(crate) fn cjk_ui_unavailable() -> bool {
|
||||
cfg!(all(
|
||||
target_os = "linux",
|
||||
target_arch = "aarch64",
|
||||
feature = "flutter"
|
||||
))
|
||||
}
|
||||
|
||||
pub fn translate_locale(name: String, locale: &str) -> String {
|
||||
pub(crate) fn is_cjk_lang(lang_or_locale: &str) -> bool {
|
||||
let lang = lang_or_locale
|
||||
.split(|c| c == '-' || c == '_')
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.to_lowercase();
|
||||
matches!(lang.as_str(), "zh" | "ja" | "ko")
|
||||
}
|
||||
|
||||
fn resolve_lang(saved_lang: &str, locale: &str, cjk_fallback: bool) -> String {
|
||||
let locale = locale.to_lowercase();
|
||||
let mut lang = hbb_common::config::LocalConfig::get_option("lang").to_lowercase();
|
||||
let mut lang = saved_lang.to_lowercase();
|
||||
if cjk_fallback && is_cjk_lang(&lang) {
|
||||
return "en".to_owned();
|
||||
}
|
||||
if lang.is_empty() {
|
||||
// zh_CN on Linux, zh-Hans-CN on mac, zh_CN_#Hans on Android
|
||||
if locale.starts_with("zh") {
|
||||
@@ -131,7 +145,25 @@ pub fn translate_locale(name: String, locale: &str) -> String {
|
||||
.unwrap_or_default()
|
||||
.to_owned();
|
||||
}
|
||||
let lang = lang.to_lowercase();
|
||||
if cjk_fallback && is_cjk_lang(&lang) {
|
||||
"en".to_owned()
|
||||
} else {
|
||||
lang
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
pub fn translate(name: String) -> String {
|
||||
let locale = sys_locale::get_locale().unwrap_or_default();
|
||||
translate_locale(name, &locale)
|
||||
}
|
||||
|
||||
pub fn translate_locale(name: String, locale: &str) -> String {
|
||||
let lang = resolve_lang(
|
||||
&hbb_common::config::LocalConfig::get_option("lang"),
|
||||
locale,
|
||||
cjk_ui_unavailable(),
|
||||
);
|
||||
let m = match lang.as_str() {
|
||||
"fr" => fr::T.deref(),
|
||||
"zh-cn" => cn::T.deref(),
|
||||
@@ -275,4 +307,32 @@ mod test {
|
||||
("{} times {4} makes {8}".to_string(), Some("2".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_lang_forces_english_for_saved_cjk_when_target_disables_cjk() {
|
||||
use super::resolve_lang as f;
|
||||
|
||||
assert_eq!(f("zh-cn", "en-US", true), "en");
|
||||
assert_eq!(f("zh-tw", "en-US", true), "en");
|
||||
assert_eq!(f("ja", "en-US", true), "en");
|
||||
assert_eq!(f("ko", "en-US", true), "en");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_lang_forces_english_for_cjk_locale_when_target_disables_cjk() {
|
||||
use super::resolve_lang as f;
|
||||
|
||||
assert_eq!(f("", "zh_CN", true), "en");
|
||||
assert_eq!(f("", "ja-JP", true), "en");
|
||||
assert_eq!(f("", "ko_KR", true), "en");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_lang_preserves_cjk_when_target_allows_cjk() {
|
||||
use super::resolve_lang as f;
|
||||
|
||||
assert_eq!(f("zh-cn", "en-US", false), "zh-cn");
|
||||
assert_eq!(f("", "zh_TW", false), "zh-tw");
|
||||
assert_eq!(f("", "ja-JP", false), "ja");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -758,5 +758,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("wayland-keyboard-input-reset-choice-tip", "إعادة تعيين اختيار إدخال لوحة المفاتيح"),
|
||||
("remember-wayland-keyboard-choice-tip", "لا تسأل مرة أخرى لهذا الكمبيوتر البعيد"),
|
||||
("Why this happens", "سبب حدوث ذلك"),
|
||||
("Switch display", "تبديل الشاشة"),
|
||||
("Show monitor switch button on the main toolbar", "إظهار زر تبديل الشاشة على شريط الأدوات الرئيسي"),
|
||||
("Show on the minimized toolbar", "الإظهار على شريط الأدوات المُصغّر"),
|
||||
("All monitors", "جميع الشاشات"),
|
||||
("#{} monitor", "الشاشة رقم {}"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -758,5 +758,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("wayland-keyboard-input-reset-choice-tip", "Скінуць выбар уводу з клавіятуры"),
|
||||
("remember-wayland-keyboard-choice-tip", "Не пытацца зноў для гэтага аддаленага кампутара"),
|
||||
("Why this happens", "Чаму гэта адбываецца"),
|
||||
("Switch display", "Пераключыць дысплэй"),
|
||||
("Show monitor switch button on the main toolbar", "Паказваць кнопку пераключэння манітора на галоўнай панэлі інструментаў"),
|
||||
("Show on the minimized toolbar", "Паказваць на згорнутай панэлі інструментаў"),
|
||||
("All monitors", "Усе манітори"),
|
||||
("#{} monitor", "Манітор {}"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -758,5 +758,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("wayland-keyboard-input-reset-choice-tip", "Нулиране на избора за въвеждане от клавиатура"),
|
||||
("remember-wayland-keyboard-choice-tip", "Не питай отново за този отдалечен компютър"),
|
||||
("Why this happens", "Защо се случва това"),
|
||||
("Switch display", "Превключване на дисплея"),
|
||||
("Show monitor switch button on the main toolbar", "Показване на бутона за превключване на монитора в главната лента с инструменти"),
|
||||
("Show on the minimized toolbar", "Показване в минимизираната лента с инструменти"),
|
||||
("All monitors", "Всички монитори"),
|
||||
("#{} monitor", "Монитор {}"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -758,5 +758,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("wayland-keyboard-input-reset-choice-tip", "Restableix l'opció d'entrada de teclat"),
|
||||
("remember-wayland-keyboard-choice-tip", "No tornis a preguntar-ho per a aquest equip remot"),
|
||||
("Why this happens", "Per què passa això"),
|
||||
("Switch display", "Canvia de pantalla"),
|
||||
("Show monitor switch button on the main toolbar", "Mostra el botó de canvi de monitor a la barra d’eines principal"),
|
||||
("Show on the minimized toolbar", "Mostra a la barra d’eines minimitzada"),
|
||||
("All monitors", "Tots els monitors"),
|
||||
("#{} monitor", "Monitor {}"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -758,5 +758,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("wayland-keyboard-input-reset-choice-tip", "重置键盘输入选择"),
|
||||
("remember-wayland-keyboard-choice-tip", "以后对这台远程电脑不再询问"),
|
||||
("Why this happens", "了解原因"),
|
||||
("Switch display", "切换显示器"),
|
||||
("Show monitor switch button on the main toolbar", "在主工具栏上显示显示器切换按钮"),
|
||||
("Show on the minimized toolbar", "在最小化工具栏上显示"),
|
||||
("All monitors", "所有显示器"),
|
||||
("#{} monitor", "{}号显示器"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -758,5 +758,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("wayland-keyboard-input-reset-choice-tip", "Resetovat volbu vstupu z klávesnice"),
|
||||
("remember-wayland-keyboard-choice-tip", "Pro tento vzdálený počítač se již neptat"),
|
||||
("Why this happens", "Proč k tomu dochází"),
|
||||
("Switch display", "Přepnout obrazovku"),
|
||||
("Show monitor switch button on the main toolbar", "Zobrazit tlačítko přepnutí monitoru na hlavním panelu nástrojů"),
|
||||
("Show on the minimized toolbar", "Zobrazit na minimalizovaném panelu nástrojů"),
|
||||
("All monitors", "Všechny monitory"),
|
||||
("#{} monitor", "Monitor č. {}"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -758,5 +758,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("wayland-keyboard-input-reset-choice-tip", "Nulstil valg for tastaturinput"),
|
||||
("remember-wayland-keyboard-choice-tip", "Spørg ikke igen for denne fjerncomputer"),
|
||||
("Why this happens", "Hvorfor dette sker"),
|
||||
("Switch display", "Skift skærm"),
|
||||
("Show monitor switch button on the main toolbar", "Vis knap til skærmskift på hovedværktøjslinjen"),
|
||||
("Show on the minimized toolbar", "Vis på den minimerede værktøjslinje"),
|
||||
("All monitors", "Alle skærme"),
|
||||
("#{} monitor", "Skærm {}"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -758,5 +758,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("wayland-keyboard-input-reset-choice-tip", "Auswahl der Tastatureingabe zurücksetzen"),
|
||||
("remember-wayland-keyboard-choice-tip", "Für diesen entfernten Computer nicht erneut fragen"),
|
||||
("Why this happens", "Warum dies passiert"),
|
||||
("Switch display", "Anzeige wechseln"),
|
||||
("Show monitor switch button on the main toolbar", "Schaltfläche zum Monitorwechsel in der Haupt-Symbolleiste anzeigen"),
|
||||
("Show on the minimized toolbar", "In der minimierten Symbolleiste anzeigen"),
|
||||
("All monitors", "Alle Bildschirme"),
|
||||
("#{} monitor", "Bildschirm {}"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -758,5 +758,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("wayland-keyboard-input-reset-choice-tip", "Επαναφορά επιλογής εισαγωγής από πληκτρολόγιο"),
|
||||
("remember-wayland-keyboard-choice-tip", "Να μην ερωτηθώ ξανά για αυτόν τον απομακρυσμένο υπολογιστή"),
|
||||
("Why this happens", "Γιατί συμβαίνει αυτό"),
|
||||
("Switch display", "Εναλλαγή οθόνης"),
|
||||
("Show monitor switch button on the main toolbar", "Εμφάνιση κουμπιού εναλλαγής οθόνης στην κύρια γραμμή εργαλείων"),
|
||||
("Show on the minimized toolbar", "Εμφάνιση στην ελαχιστοποιημένη γραμμή εργαλείων"),
|
||||
("All monitors", "Όλες οι οθόνες"),
|
||||
("#{} monitor", "Οθόνη {}"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -758,5 +758,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("wayland-keyboard-input-reset-choice-tip", "Restarigi la elekton de klavara enigo"),
|
||||
("remember-wayland-keyboard-choice-tip", "Ne demandi denove por ĉi tiu fora komputilo"),
|
||||
("Why this happens", "Kial ĉi tio okazas"),
|
||||
("Switch display", "Ŝalti ekranon"),
|
||||
("Show monitor switch button on the main toolbar", "Montri ekran-ŝaltan butonon en la ĉefa ilobreto"),
|
||||
("Show on the minimized toolbar", "Montri en la minimumigita ilobreto"),
|
||||
("All monitors", "Ĉiuj monitoroj"),
|
||||
("#{} monitor", "Monitoro {}"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -758,5 +758,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("wayland-keyboard-input-reset-choice-tip", "Restablecer la opción de entrada del teclado"),
|
||||
("remember-wayland-keyboard-choice-tip", "No volver a preguntar para este equipo remoto"),
|
||||
("Why this happens", "Por qué ocurre esto"),
|
||||
("Switch display", "Cambiar de pantalla"),
|
||||
("Show monitor switch button on the main toolbar", "Mostrar el botón de cambio de monitor en la barra de herramientas principal"),
|
||||
("Show on the minimized toolbar", "Mostrar en la barra de herramientas minimizada"),
|
||||
("All monitors", "Todos los monitores"),
|
||||
("#{} monitor", "Monitor {}"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -758,5 +758,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("wayland-keyboard-input-reset-choice-tip", "Lähtesta klaviatuurisisestuse valik"),
|
||||
("remember-wayland-keyboard-choice-tip", "Ära küsi selle kaugarvuti puhul uuesti"),
|
||||
("Why this happens", "Miks see juhtub"),
|
||||
("Switch display", "Vaheta kuva"),
|
||||
("Show monitor switch button on the main toolbar", "Näita monitori vahetamise nuppu peamisel tööriistaribal"),
|
||||
("Show on the minimized toolbar", "Näita minimeeritud tööriistaribal"),
|
||||
("All monitors", "Kõik kuvarid"),
|
||||
("#{} monitor", "Kuvar {}"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -758,5 +758,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("wayland-keyboard-input-reset-choice-tip", "Berrezarri teklatuko sarreraren aukera"),
|
||||
("remember-wayland-keyboard-choice-tip", "Ez galdetu berriro urruneko ordenagailu honetarako"),
|
||||
("Why this happens", "Zergatik gertatzen den hau"),
|
||||
("Switch display", "Aldatu pantaila"),
|
||||
("Show monitor switch button on the main toolbar", "Erakutsi monitorea aldatzeko botoia tresna-barra nagusian"),
|
||||
("Show on the minimized toolbar", "Erakutsi minimizatutako tresna-barran"),
|
||||
("All monitors", "Monitore guztiak"),
|
||||
("#{} monitor", "{}. monitorea"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -758,5 +758,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("wayland-keyboard-input-reset-choice-tip", "بازنشانی انتخاب ورودی صفحه کلید"),
|
||||
("remember-wayland-keyboard-choice-tip", "برای این رایانه از راه دور دوباره نپرس"),
|
||||
("Why this happens", "چرا این اتفاق میافتد"),
|
||||
("Switch display", "تعویض نمایشگر"),
|
||||
("Show monitor switch button on the main toolbar", "نمایش دکمه تعویض نمایشگر در نوار ابزار اصلی"),
|
||||
("Show on the minimized toolbar", "نمایش در نوار ابزار کوچکشده"),
|
||||
("All monitors", "همه نمایشگرها"),
|
||||
("#{} monitor", "نمایشگر {}"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -758,5 +758,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("wayland-keyboard-input-reset-choice-tip", "Nollaa näppäimistösyötteen valinta"),
|
||||
("remember-wayland-keyboard-choice-tip", "Älä kysy uudelleen tältä etätietokoneelta"),
|
||||
("Why this happens", "Miksi näin tapahtuu"),
|
||||
("Switch display", "Vaihda näyttöä"),
|
||||
("Show monitor switch button on the main toolbar", "Näytä näytön vaihtopainike päätyökalurivillä"),
|
||||
("Show on the minimized toolbar", "Näytä pienennetyssä työkalurivissä"),
|
||||
("All monitors", "Kaikki näytöt"),
|
||||
("#{} monitor", "Näyttö {}"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -758,5 +758,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("wayland-keyboard-input-reset-choice-tip", "Réinitialiser le choix de la saisie au clavier"),
|
||||
("remember-wayland-keyboard-choice-tip", "Ne plus demander pour cet appareil distant"),
|
||||
("Why this happens", "Pourquoi cela se produit"),
|
||||
("Switch display", "Changer d’écran"),
|
||||
("Show monitor switch button on the main toolbar", "Afficher le bouton de changement d’écran dans la barre d’outils principale"),
|
||||
("Show on the minimized toolbar", "Afficher dans la barre d’outils réduite"),
|
||||
("All monitors", "Tous les moniteurs"),
|
||||
("#{} monitor", "Moniteur {}"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -758,5 +758,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("wayland-keyboard-input-reset-choice-tip", "კლავიატურის შეყვანის არჩევანის ჩამოყრა"),
|
||||
("remember-wayland-keyboard-choice-tip", "აღარ მკითხო ამ დისტანციური კომპიუტერისთვის"),
|
||||
("Why this happens", "რატომ ხდება ეს"),
|
||||
("Switch display", "ეკრანის გადართვა"),
|
||||
("Show monitor switch button on the main toolbar", "მონიტორის გადართვის ღილაკის ჩვენება მთავარ ხელსაწყოთა ზოლზე"),
|
||||
("Show on the minimized toolbar", "ჩვენება ჩაკეცილ ხელსაწყოთა ზოლზე"),
|
||||
("All monitors", "ყველა მონიტორი"),
|
||||
("#{} monitor", "მონიტორი {}"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -758,5 +758,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("wayland-keyboard-input-reset-choice-tip", "કીબોર્ડ ઇનપુટ પસંદગી રિસેટ કરો"),
|
||||
("remember-wayland-keyboard-choice-tip", "આ રિમોટ કમ્પ્યુટર માટે ફરીથી પૂછશો નહીં"),
|
||||
("Why this happens", "આવું શા માટે થાય છે"),
|
||||
("Switch display", "ડિસ્પ્લે બદલો"),
|
||||
("Show monitor switch button on the main toolbar", "મુખ્ય ટૂલબાર પર મોનિટર સ્વિચ બટન બતાવો"),
|
||||
("Show on the minimized toolbar", "ન્યૂનતમ કરેલા ટૂલબાર પર બતાવો"),
|
||||
("All monitors", "બધા મોનિટર"),
|
||||
("#{} monitor", "મોનિટર {}"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -758,5 +758,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("wayland-keyboard-input-reset-choice-tip", "אפס את בחירת קלט המקלדת"),
|
||||
("remember-wayland-keyboard-choice-tip", "אל תשאל שוב עבור מחשב מרוחק זה"),
|
||||
("Why this happens", "מדוע זה קורה"),
|
||||
("Switch display", "החלפת צג"),
|
||||
("Show monitor switch button on the main toolbar", "הצגת לחצן החלפת צג בסרגל הכלים הראשי"),
|
||||
("Show on the minimized toolbar", "הצגה בסרגל הכלים הממוזער"),
|
||||
("All monitors", "כל המסכים"),
|
||||
("#{} monitor", "מסך {}"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -758,5 +758,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("wayland-keyboard-input-reset-choice-tip", "कीबोर्ड इनपुट चयन रीसेट करें"),
|
||||
("remember-wayland-keyboard-choice-tip", "इस रिमोट कंप्यूटर के लिए दोबारा न पूछें"),
|
||||
("Why this happens", "ऐसा क्यों होता है"),
|
||||
("Switch display", "डिस्प्ले बदलें"),
|
||||
("Show monitor switch button on the main toolbar", "मुख्य टूलबार पर मॉनिटर स्विच बटन दिखाएं"),
|
||||
("Show on the minimized toolbar", "न्यूनतम किए गए टूलबार पर दिखाएं"),
|
||||
("All monitors", "सभी मॉनिटर"),
|
||||
("#{} monitor", "मॉनिटर {}"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -758,5 +758,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("wayland-keyboard-input-reset-choice-tip", "Poništi izbor unosa tipkovnicom"),
|
||||
("remember-wayland-keyboard-choice-tip", "Ne pitaj ponovno za ovo udaljeno računalo"),
|
||||
("Why this happens", "Zašto se ovo događa"),
|
||||
("Switch display", "Promijeni zaslon"),
|
||||
("Show monitor switch button on the main toolbar", "Prikaži gumb za prebacivanje monitora na glavnoj alatnoj traci"),
|
||||
("Show on the minimized toolbar", "Prikaži na minimiziranoj alatnoj traci"),
|
||||
("All monitors", "Svi monitori"),
|
||||
("#{} monitor", "Monitor {}"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -758,5 +758,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("wayland-keyboard-input-reset-choice-tip", "Billentyűzetbevitel választásának visszaállítása"),
|
||||
("remember-wayland-keyboard-choice-tip", "Ne kérdezze meg újra ennél a távoli számítógépnél"),
|
||||
("Why this happens", "Miért történik ez"),
|
||||
("Switch display", "Kijelző váltása"),
|
||||
("Show monitor switch button on the main toolbar", "Monitorváltó gomb megjelenítése a fő eszköztáron"),
|
||||
("Show on the minimized toolbar", "Megjelenítés a kis méretű eszköztáron"),
|
||||
("All monitors", "Minden monitor"),
|
||||
("#{} monitor", "{}. monitor"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -758,5 +758,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("wayland-keyboard-input-reset-choice-tip", "Setel ulang pilihan masukan keyboard"),
|
||||
("remember-wayland-keyboard-choice-tip", "Jangan tanya lagi untuk komputer jarak jauh ini"),
|
||||
("Why this happens", "Mengapa ini terjadi"),
|
||||
("Switch display", "Ganti tampilan"),
|
||||
("Show monitor switch button on the main toolbar", "Tampilkan tombol pengalih monitor di bilah alat utama"),
|
||||
("Show on the minimized toolbar", "Tampilkan di bilah alat yang diperkecil"),
|
||||
("All monitors", "Semua monitor"),
|
||||
("#{} monitor", "Monitor {}"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -758,5 +758,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("wayland-keyboard-input-reset-choice-tip", "Ripristina scelta input da tastiera"),
|
||||
("remember-wayland-keyboard-choice-tip", "Non chiedere più per questo computer remoto"),
|
||||
("Why this happens", "Perché accade questo"),
|
||||
("Switch display", "Cambia schermo"),
|
||||
("Show monitor switch button on the main toolbar", "Visualizza nella barra strumenti principale il pulsante per il cambio schermo"),
|
||||
("Show on the minimized toolbar", "Visualizza nella barra strumenti ridotta a icona"),
|
||||
("All monitors", "Tutti gli schermi"),
|
||||
("#{} monitor", "Schermo {}"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -197,9 +197,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Please enter the folder name", "フォルダー名を入力してください"),
|
||||
("Fix it", "修復する"),
|
||||
("Warning", "警告"),
|
||||
("Login screen using Wayland is not supported", "Wayland を使用したログインスクリーンはサポートされていません"),
|
||||
("Login screen using Wayland is not supported", "Wayland を使用したログイン画面は対応していません"),
|
||||
("Reboot required", "再起動が必要です"),
|
||||
("Unsupported display server", "サポートされていないディスプレイサーバー"),
|
||||
("Unsupported display server", "非対応のディスプレイサーバー"),
|
||||
("x11 expected", "X11 が必要です"),
|
||||
("Port", "ポート"),
|
||||
("Settings", "設定"),
|
||||
@@ -268,11 +268,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Share screen", "画面を共有"),
|
||||
("Chat", "チャット"),
|
||||
("Total", "合計"),
|
||||
("items", "個のアイテム"),
|
||||
("items", "個の項目"),
|
||||
("Selected", "選択済み"),
|
||||
("Screen Capture", "画面キャプチャ"),
|
||||
("Screen Capture", "画面をキャプチャ"),
|
||||
("Input Control", "入力操作"),
|
||||
("Audio Capture", "音声キャプチャ"),
|
||||
("Audio Capture", "オーディオをキャプチャ"),
|
||||
("Do you accept?", "許可しますか?"),
|
||||
("Open System Setting", "システム設定を開く"),
|
||||
("How to get Android input permission?", "Android の入力権限を取得するには?"),
|
||||
@@ -281,7 +281,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("android_new_connection_tip", "新しい操作リクエストが届きました。この端末を操作しようとしています。"),
|
||||
("android_service_will_start_tip", "「画面キャプチャ」を有効にするとサービスが自動的に開始され、他の端末がこの端末への接続をリクエストできるようになります。"),
|
||||
("android_stop_service_tip", "サービスを停止すると、自動的に現在のセッションがすべて閉じられます。"),
|
||||
("android_version_audio_tip", "現在の Android バージョンでは音声キャプチャはサポートされていません。Android 10 以降に更新してください。"),
|
||||
("android_version_audio_tip", "使用している Android はオーディオキャプチャに対応していません。Android 10 以降に更新してください。"),
|
||||
("android_start_service_tip", "「サービスを開始」をタップするか、「画面キャプチャ」の許可を有効にすると、画面共有サービスが開始されます。"),
|
||||
("android_permission_may_not_change_tip", "権限の変更は現在のセッションには適用されません。再接続後に適用されます。"),
|
||||
("Account", "アカウント"),
|
||||
@@ -292,7 +292,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Failed", "失敗"),
|
||||
("Succeeded", "成功"),
|
||||
("Someone turns on privacy mode, exit", "プライバシーモードがオンになりました。終了します。"),
|
||||
("Unsupported", "サポートされていません"),
|
||||
("Unsupported", "対応していません"),
|
||||
("Peer denied", "リモートホストに拒否されました"),
|
||||
("Please install plugins", "プラグインをインストールしてください"),
|
||||
("Peer exit", "リモートホストが退出しました"),
|
||||
@@ -376,10 +376,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Confirm before closing multiple tabs", "複数のタブを閉じる前に確認する"),
|
||||
("Keyboard Settings", "キーボードの設定"),
|
||||
("Full Access", "フルアクセス"),
|
||||
("Screen Share", "画面共有"),
|
||||
("Screen Share", "画面を共有"),
|
||||
("ubuntu-21-04-required", "Wayland を使用するには、Ubuntu 21.04 以降のバージョンが必要です。"),
|
||||
("wayland-requires-higher-linux-version", "Wayland を使用するには、より新しい Linux ディストリビューションが必要です。 X11 デスクトップを試すか、OS を変更してください。"),
|
||||
("xdp-portal-unavailable", "Wayland の画面キャプチャに失敗しました。XDG Desktop Portal がクラッシュしたか、利用できない可能性があります。`systemctl --user restart xdg-desktop-portal` で再起動してみてください。"),
|
||||
("xdp-portal-unavailable", "Wayland の画面キャプチャに失敗しました。XDG デスクトップポータルがクラッシュしたか、利用できない可能性があります。`systemctl --user restart xdg-desktop-portal` で再起動してみてください。"),
|
||||
("JumpLink", "表示"),
|
||||
("Please Select the screen to be shared(Operate on the peer side).", "共有する画面を選択してください(リモートコンピューターが操作します)"),
|
||||
("Show RustDesk", "RustDesk を表示"),
|
||||
@@ -397,7 +397,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Request access to your device", "デバイスへのアクセス要求"),
|
||||
("Hide connection management window", "接続管理画面を隠す"),
|
||||
("hide_cm_tip", "パスワードによるセッションを許可し、固定パスワードを使用する場合にのみ、管理画面の非表示を許可する。"),
|
||||
("wayland_experiment_tip", "Wayland のサポートは試験的なものです。無人アクセスを使用する場合はX11デスクトップをご利用ください。"),
|
||||
("wayland_experiment_tip", "Wayland の対応は試験的なものです。無人アクセスを使用する場合はX11デスクトップをご利用ください。"),
|
||||
("Right click to select tabs", "右クリックでタブを選択"),
|
||||
("Skipped", "スキップ"),
|
||||
("Add to address book", "アドレス帳に追加"),
|
||||
@@ -568,7 +568,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("input_source_1_tip", "入力ソース 1"),
|
||||
("input_source_2_tip", "入力ソース 2"),
|
||||
("Swap control-command key", "ctrl と command キーを入れ替える"),
|
||||
("swap-left-right-mouse", "マウスのクリックを入れ替える"),
|
||||
("swap-left-right-mouse", "マウスクリックを入れ替える"),
|
||||
("2FA code", "二要素認証コード"),
|
||||
("More", "詳細"),
|
||||
("enable-2fa-title", "二要素認証を有効化する"),
|
||||
@@ -601,10 +601,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("default_proxy_tip", "既定のプロトコルとポートは Socks5 と 1080 です。"),
|
||||
("no_audio_input_device_tip", "オーディオ入力デバイスが見つかりません。"),
|
||||
("Incoming", "受信"),
|
||||
("Outgoing", "発信"),
|
||||
("Clear Wayland screen selection", "Wayland の画面選択をクリア"),
|
||||
("clear_Wayland_screen_selection_tip", "画面選択をクリア後、共有画面を再び選択できます。"),
|
||||
("confirm_clear_Wayland_screen_selection_tip", "本当に Wayland の画面選択をクリアしますか?"),
|
||||
("Outgoing", "送信"),
|
||||
("Clear Wayland screen selection", "Wayland の画面選択を消去"),
|
||||
("clear_Wayland_screen_selection_tip", "画面選択を消去後、共有画面を再び選択できます。"),
|
||||
("confirm_clear_Wayland_screen_selection_tip", "本当に Wayland の画面選択を消去しますか?"),
|
||||
("android_new_voice_call_tip", "新しい音声通話リクエストを受信しました。承認すると音声通話に切り替わります。"),
|
||||
("texture_render_tip", "テクスチャレンダリングを使用し、画像をより滑らかに描画します。レンダリングの問題が発生した場合は無効にしてみてください。"),
|
||||
("Use texture rendering", "テクスチャレンダリングを使用する"),
|
||||
@@ -643,7 +643,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("one-way-file-transfer-tip", "コントロールをされる側では一方向のファイル転送が有効になります。"),
|
||||
("Authentication Required", "認証が必要です"),
|
||||
("Authenticate", "認証"),
|
||||
("web_id_input_tip", "同じサーバー内の ID を入力できます。Web クライアントでは直接 IP アドレスによるアクセスはサポートされていません。\n別のサーバー上のデバイスにアクセスする場合は、サーバーアドレス (<id>@<server_address>?key=<key_value>) を入力してください。\n 例: 9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=\nパブリックサーバー上のデバイスにアクセスする場合は、「<id>@public」と入力してください。パブリックサーバーはキーは不要です。"),
|
||||
("web_id_input_tip", "同じサーバー内の ID を入力できます。Web クライアントでは IP アドレスによる直接アクセスに対応していません。\n別のサーバー上のデバイスにアクセスする場合は、サーバーアドレス (<id>@<server_address>?key=<key_value>) を入力してください。\n 例: 9123456234@192.168.16.1:21117?key=5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=\nパブリックサーバー上のデバイスにアクセスする場合は、「<id>@public」と入力してください。パブリックサーバーはキーは不要です。"),
|
||||
("Download", "ダウンロード"),
|
||||
("Upload folder", "フォルダーをアップロード"),
|
||||
("Upload files", "ファイルをアップロード"),
|
||||
@@ -674,7 +674,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("dont-show-again-tip", "今後は表示しない"),
|
||||
("Take screenshot", "スクリーンショットを撮影"),
|
||||
("Taking screenshot", "スクリーンショットを撮影中"),
|
||||
("screenshot-merged-screen-not-supported-tip", "複数のディスプレイのスクリーンショットの結合は、現在サポートされていません。単一のディスプレイに切り替えてもう一度お試しください。"),
|
||||
("screenshot-merged-screen-not-supported-tip", "複数のディスプレイのスクリーンショットの結合は、現在非対応です。単一のディスプレイに切り替えてもう一度お試しください。"),
|
||||
("screenshot-action-tip", "スクリーンショットを続行する方法を選択してください。"),
|
||||
("Save as", "保存先"),
|
||||
("Copy to clipboard", "クリップボードにコピー"),
|
||||
@@ -685,7 +685,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("download-new-version-failed-tip", "ダウンロードに失敗しました。もう一度お試しいただくか、「ダウンロード」ボタンをクリックしてリリースページからダウンロードし、手動でアップグレードしてください。"),
|
||||
("Auto update", "ソフトウェアの自動更新を行う"),
|
||||
("update-failed-check-msi-tip", "インストール方法の確認に失敗しました。「ダウンロード」ボタンをクリックしてリリースページからダウンロードし、手動でアップグレードしてください。"),
|
||||
("websocket_tip", "WebSocket を使用する場合、リレー接続のみがサポートされます。"),
|
||||
("websocket_tip", "WebSocket を使用する場合、リレー接続のみ対応しています。"),
|
||||
("Use WebSocket", "WebSocket を使用する"),
|
||||
("Trackpad speed", "トラックパッドの速度"),
|
||||
("Default trackpad speed", "既定のトラックパッドの速度"),
|
||||
@@ -695,7 +695,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("View camera", "カメラを表示"),
|
||||
("Enable camera", "カメラを有効化する"),
|
||||
("No cameras", "カメラなし"),
|
||||
("view_camera_unsupported_tip", "リモートデバイスはカメラの表示をサポートしていません。"),
|
||||
("view_camera_unsupported_tip", "リモートデバイスはカメラの表示に対応していません"),
|
||||
("Terminal", "ターミナル"),
|
||||
("Enable terminal", "ターミナルを有効化する"),
|
||||
("New tab", "新しいタブ"),
|
||||
@@ -706,7 +706,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Incorrect username or password.", "ユーザー名またはパスワードが正しくありません。"),
|
||||
("The user is not an administrator.", "このユーザーは管理者ではありません。"),
|
||||
("Failed to check if the user is an administrator.", "ユーザーが管理者であるかどうかを確認できませんでした。"),
|
||||
("Supported only in the installed version.", "インストールされたバージョンでのみサポートされます。"),
|
||||
("Supported only in the installed version.", "インストールされたバージョンでのみ対応しています。"),
|
||||
("elevation_username_tip", "ユーザー名またはドメインのユーザー名を入力してください。"),
|
||||
("Preparing for installation ...", "インストールの準備中です..."),
|
||||
("Show my cursor", "自分のカーソルを表示する"),
|
||||
@@ -758,5 +758,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("wayland-keyboard-input-reset-choice-tip", "キーボード入力の選択をリセット"),
|
||||
("remember-wayland-keyboard-choice-tip", "このリモートコンピューターでは今後確認しない"),
|
||||
("Why this happens", "この問題が起こる理由"),
|
||||
("Switch display", "ディスプレイを切り替え"),
|
||||
("Show monitor switch button on the main toolbar", "メインツールバーにモニター切り替えボタンを表示"),
|
||||
("Show on the minimized toolbar", "最小化したツールバーに表示"),
|
||||
("All monitors", "すべてのディスプレイ"),
|
||||
("#{} monitor", "ディスプレイ {}"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("id_change_tip", "a-z, A-Z, 0-9, -(대시) 및 _(밑줄) 문자만 허용됩니다. 첫 글자는 a-z, A-Z여야 합니다. 길이는 6에서 16 사이여야 합니다."),
|
||||
("Website", "웹사이트"),
|
||||
("About", "정보"),
|
||||
("Slogan_tip", "이 혼란스러운 세상에서 마음을 담아 만들었습니다! - 한국어 번역: 비너스걸"),
|
||||
("Slogan_tip", "이 혼란스러운 세상에서 마음을 담아 만들었습니다!"),
|
||||
("Privacy Statement", "개인정보 보호정책"),
|
||||
("Mute", "음소거"),
|
||||
("Build Date", "빌드 날짜"),
|
||||
@@ -379,7 +379,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Screen Share", "화면 공유"),
|
||||
("ubuntu-21-04-required", "Wayland는 Ubuntu 21.04 이상 버전이 필요합니다."),
|
||||
("wayland-requires-higher-linux-version", "Wayland는 상위 버전의 Linux 배포판이 필요합니다. X11 데스크탑을 사용하거나 OS를 변경하세요."),
|
||||
("xdp-portal-unavailable", "Wayland 화면 캡처에 실패했습니다. XDG Desktop Portal이 충돌했거나 사용할 수 없는 상태일 수 있습니다. `systemctl --user restart xdg-desktop-portal` 명령으로 다시 시작해 보세요."),
|
||||
("xdp-portal-unavailable", ""),
|
||||
("JumpLink", "점프 링크"),
|
||||
("Please Select the screen to be shared(Operate on the peer side).", "공유할 화면을 선택하세요 (피어 측에서 작동)"),
|
||||
("Show RustDesk", "RustDesk 표시"),
|
||||
@@ -749,14 +749,19 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Deploy", "배포"),
|
||||
("Custom ID (optional)", "사용자 지정 ID (선택 사항)"),
|
||||
("server_requires_deployment_tip", "서버에서 이 장치를 명시적으로 배포하도록 요구합니다. 지금 배포하시겠습니까?"),
|
||||
("The server does not require explicit deployment.", "서버에서 명시적인 배포를 요구하지 않습니다."),
|
||||
("The server does not require explicit deployment.", "서버에서 명시적 배포를 요구하지 않습니다."),
|
||||
("Unknown response.", "알 수 없는 응답입니다."),
|
||||
("wayland-keyboard-input-disabled-tip", "키보드 입력을 허용하시겠습니까?"),
|
||||
("wayland-keyboard-input-consent-tip", "이 원격 컴퓨터에서 입력하는 내용(비밀번호 포함)은 해당 컴퓨터의 다른 앱이 읽을 수 있습니다."),
|
||||
("wayland-keyboard-input-consent-tip", "이 원격 컴퓨터에서 입력하는 내용 (비밀번호 포함)은 해당 컴퓨터의 다른 앱에서 읽을 수 있습니다."),
|
||||
("wayland-keyboard-input-applies-to-tip", "이 선택이 적용되는 대상:"),
|
||||
("wayland-soft-keyboard-input-label", "소프트 키보드 입력"),
|
||||
("wayland-keyboard-input-reset-choice-tip", "키보드 입력 선택 초기화"),
|
||||
("remember-wayland-keyboard-choice-tip", "이 원격 컴퓨터에 대해 다시 묻지 않기"),
|
||||
("Why this happens", "이런 현상이 발생하는 이유"),
|
||||
("wayland-keyboard-input-reset-choice-tip", "키보드 입력 선택 재설정"),
|
||||
("remember-wayland-keyboard-choice-tip", "이 원격 컴퓨터에 대해 다시 묻지 않음"),
|
||||
("Why this happens", "이 현상이 발생하는 이유"),
|
||||
("Switch display", "디스플레이 전환"),
|
||||
("Show monitor switch button on the main toolbar", "기본 도구 모음에 모니터 전환 버튼 표시"),
|
||||
("Show on the minimized toolbar", "최소화된 도구 모음에 표시"),
|
||||
("All monitors", "모든 모니터"),
|
||||
("#{} monitor", "#{} 모니터"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -758,5 +758,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("wayland-keyboard-input-reset-choice-tip", "Пернетақта еңгізу таңдауын қалпына келтіру"),
|
||||
("remember-wayland-keyboard-choice-tip", "Осы қашықтағы компьютер үшін қайта сұрамау"),
|
||||
("Why this happens", "Бұл неге болады"),
|
||||
("Switch display", "Дисплейді ауыстыру"),
|
||||
("Show monitor switch button on the main toolbar", "Негізгі құралдар тақтасында мониторды ауыстыру түймесін көрсету"),
|
||||
("Show on the minimized toolbar", "Кішірейтілген құралдар тақтасында көрсету"),
|
||||
("All monitors", "Барлық мониторлар"),
|
||||
("#{} monitor", "Монитор {}"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -758,5 +758,10 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("wayland-keyboard-input-reset-choice-tip", "Atstatyti klaviatūros įvesties pasirinkimą"),
|
||||
("remember-wayland-keyboard-choice-tip", "Daugiau neklausti dėl šio nuotolinio kompiuterio"),
|
||||
("Why this happens", "Kodėl taip nutinka"),
|
||||
("Switch display", "Perjungti ekraną"),
|
||||
("Show monitor switch button on the main toolbar", "Rodyti monitoriaus perjungimo mygtuką pagrindinėje įrankių juostoje"),
|
||||
("Show on the minimized toolbar", "Rodyti sumažintoje įrankių juostoje"),
|
||||
("All monitors", "Visi monitoriai"),
|
||||
("#{} monitor", "Monitorius {}"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user