mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-09-10 22:41:05 +03:00
Compare commits
127 Commits
fix-textur
...
c4df2e6da2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4df2e6da2 | ||
|
|
b473977aa8 | ||
|
|
81b55dff24 | ||
|
|
9eae353ee5 | ||
|
|
a02063ece8 | ||
|
|
e677fd510a | ||
|
|
7280238993 | ||
|
|
019dd20b30 | ||
|
|
98c0401134 | ||
|
|
3432b8bc30 | ||
|
|
fe1300584c | ||
|
|
6ffb506961 | ||
|
|
8f81561918 | ||
|
|
093790875b | ||
|
|
c99ccdcb0d | ||
|
|
e3ef5317c5 | ||
|
|
59b7987b1d | ||
|
|
00005f6af5 | ||
|
|
6926ad290f | ||
|
|
ef642ae329 | ||
|
|
fa48b3f88d | ||
|
|
d6a670e764 | ||
|
|
39a266cfd5 | ||
|
|
1e00d09f84 | ||
|
|
7a0705bdad | ||
|
|
bfd3a9d7d9 | ||
|
|
2786d12a28 | ||
|
|
cf9c94a677 | ||
|
|
db3830a781 | ||
|
|
2befaa5b6a | ||
|
|
b199024082 | ||
|
|
f578fa189f | ||
|
|
ed09f3cc76 | ||
|
|
2657143712 | ||
|
|
51bbaa3f17 | ||
|
|
09e27370a3 | ||
|
|
0412726a20 | ||
|
|
b319cef200 | ||
|
|
c81c85fb78 | ||
|
|
553d66035e | ||
|
|
4ed2a60e35 | ||
|
|
305b3e5e48 | ||
|
|
5a790443a8 | ||
|
|
47b2c05e1d | ||
|
|
120fc4f4c5 | ||
|
|
55cbd1a218 | ||
|
|
0e61153a94 | ||
|
|
65a819f9e2 | ||
|
|
3d5406aa1c | ||
|
|
c40cb2a93e | ||
|
|
c631cdcf22 | ||
|
|
2f8c8704bb | ||
|
|
b591eeaa76 | ||
|
|
21cdf4d0a1 | ||
|
|
5ce320d1cd | ||
|
|
32ee0c19e3 | ||
|
|
0a135e0fae | ||
|
|
d3d5690bbf | ||
|
|
7e80399689 | ||
|
|
777d6347e4 | ||
|
|
9a1c8da143 | ||
|
|
978c901f49 | ||
|
|
d453a19601 | ||
|
|
50c4e435de | ||
|
|
d5c6d0f6b7 | ||
|
|
b6ff62c74b | ||
|
|
ba6de7990f | ||
|
|
a59ad333fc | ||
|
|
82aa28f129 | ||
|
|
3f93005be2 | ||
|
|
23a147b0dc | ||
|
|
e4539fc304 | ||
|
|
6dbd810454 | ||
|
|
0fd1a0eecb | ||
|
|
dfb5804dd0 | ||
|
|
957dfe8c96 | ||
|
|
c312385ffd | ||
|
|
f28ac38ccf | ||
|
|
28cf1836e6 | ||
|
|
1ec1b9e7e3 | ||
|
|
2c84c8fb13 | ||
|
|
66ab0b87f6 | ||
|
|
169f74f8d9 | ||
|
|
d4b06a6c5c | ||
|
|
03a7fc5992 | ||
|
|
1fe451c2e8 | ||
|
|
0b08a83d4b | ||
|
|
e9b81e3475 | ||
|
|
7220f00410 | ||
|
|
fd471fcf02 | ||
|
|
7c6e661fcc | ||
|
|
3f207e91f6 | ||
|
|
cec4085238 | ||
|
|
0d917c6fa1 | ||
|
|
893dc27798 | ||
|
|
7cc82c1575 | ||
|
|
f07b6e2338 | ||
|
|
a3bab27a2a | ||
|
|
7423dced37 | ||
|
|
a7deef02a2 | ||
|
|
d5a7f67999 | ||
|
|
e266380ee9 | ||
|
|
cbf9440281 | ||
|
|
6eaac17ac5 | ||
|
|
92eb137178 | ||
|
|
61ddade049 | ||
|
|
c78bdefc44 | ||
|
|
c45f7d2dd2 | ||
|
|
798b73beb1 | ||
|
|
f1a06f6765 | ||
|
|
0a4b431ea2 | ||
|
|
5679670506 | ||
|
|
630b531108 | ||
|
|
1984678785 | ||
|
|
b0008edcb5 | ||
|
|
0c00d576dd | ||
|
|
9b1b810d3a | ||
|
|
6a27910f34 | ||
|
|
14a4b197ad | ||
|
|
8ffe3117a5 | ||
|
|
5a78be03e3 | ||
|
|
edd0e5fbd4 | ||
|
|
3871c47855 | ||
|
|
8d52d48b24 | ||
|
|
7aa98d43cf | ||
|
|
d1da05c4db | ||
|
|
d829d1410a |
11
.github/dependabot.yml
vendored
11
.github/dependabot.yml
vendored
@@ -1,11 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "gitsubmodule"
|
||||
directory: "/"
|
||||
target-branch: "master"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
commit-message:
|
||||
prefix: "Git submodule"
|
||||
labels:
|
||||
- "dependencies"
|
||||
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
@@ -5,7 +5,7 @@ env:
|
||||
# CICD_INTERMEDIATES_DIR: "_cicd-intermediates"
|
||||
VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite"
|
||||
# for multiarch gcc compatibility
|
||||
VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b"
|
||||
VCPKG_COMMIT_ID: "9e593bb18ea69cc5095e012465dcd675a822ed0d"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -124,7 +124,6 @@ jobs:
|
||||
gcc \
|
||||
git \
|
||||
g++ \
|
||||
libpam0g-dev \
|
||||
libasound2-dev \
|
||||
libunwind-dev \
|
||||
libgstreamer1.0-dev \
|
||||
|
||||
100
.github/workflows/flutter-build.yml
vendored
100
.github/workflows/flutter-build.yml
vendored
@@ -31,20 +31,21 @@ env:
|
||||
# engine is 3.44. Every other platform stays on FLUTTER_VERSION (3.24.5) until Windows 7
|
||||
# support is restored after the upstream-wide Flutter bump. The arm64 job patches the few
|
||||
# 3.44-only source/pubspec changes on the fly (see "Patch RustDesk sources for Flutter 3.44").
|
||||
FLUTTER_WINDOWS_ARM_VERSION: "3.44.8"
|
||||
FLUTTER_WINDOWS_ARM_VERSION: "3.44.9"
|
||||
# for arm64 linux because official Dart SDK does not work
|
||||
FLUTTER_ELINUX_VERSION: "3.16.9"
|
||||
TAG_NAME: "${{ inputs.upload-tag }}"
|
||||
VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite"
|
||||
# vcpkg version: 2025.08.27
|
||||
# vcpkg version: 2026.07.29
|
||||
# If we change the `VCPKG COMMIT_ID`, please remember:
|
||||
# 1. Call `$VCPKG_ROOT/vcpkg x-update-baseline` to update the baseline in `vcpkg.json`.
|
||||
# Or we may face build issue like
|
||||
# https://github.com/rustdesk/rustdesk/actions/runs/14414119794/job/40427970174
|
||||
# 2. Update the `VCPKG_COMMIT_ID` in `ci.yml` and `playground.yml`.
|
||||
VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b"
|
||||
VCPKG_COMMIT_ID: "9e593bb18ea69cc5095e012465dcd675a822ed0d"
|
||||
VCPKG_CMAKE_VERSION: "4.3.0"
|
||||
ARMV7_VCPKG_COMMIT_ID: "6f29f12e82a8293156836ad81cc9bf5af41fe836" # 2025.01.13, got "/opt/artifacts/vcpkg/vcpkg: No such file or directory" with latest version
|
||||
VERSION: "1.4.9"
|
||||
VERSION: "1.5.0"
|
||||
NDK_VERSION: "r28c"
|
||||
#signing keys env variable checks
|
||||
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"
|
||||
@@ -389,6 +390,54 @@ jobs:
|
||||
mv $msi.FullName ../../SignOutput/rustdesk-${{ env.VERSION }}-${{ matrix.job.arch }}.msi
|
||||
sha256sum ../../SignOutput/rustdesk-*.msi
|
||||
|
||||
- name: Build pre-built MSI template
|
||||
# Two things this works around: preprocess.py rewrites res/msi in place, so the
|
||||
# tree is reset around this second variant; and it locates the app as
|
||||
# <app-name>.exe inside the dist, so the dist copy is renamed to match.
|
||||
#
|
||||
# The placeholder is chosen to keep this template as close to the shipped msi as
|
||||
# possible: eight characters like "RustDesk", and a valid 8.3 name, so WiX
|
||||
# derives no short name for it. A longer placeholder would get one, and a patch
|
||||
# cannot rewrite a truncated placeholder, leaving short names pointing at it.
|
||||
#
|
||||
# It still has to be unique, which is why "RustDesk" itself cannot be used:
|
||||
# it also names payload that must never be renamed, such as librustdesk.dll
|
||||
# and drivers\RustDeskPrinterDriver.
|
||||
#
|
||||
#
|
||||
# Building the arm64 template on the native arm64 runner makes the ARM
|
||||
# package available: the build agents are x64 and cannot run
|
||||
# preprocess.py against an ARM exe.
|
||||
if: env.UPLOAD_ARTIFACT == 'true'
|
||||
run: |
|
||||
git checkout -- res/msi
|
||||
cp -r ./rustdesk ./rustdesk-msi-template
|
||||
mv ./rustdesk-msi-template/rustdesk.exe ./rustdesk-msi-template/RDAPPNAM.exe
|
||||
Set-Content -Path ./rustdesk-msi-template/custom.txt -Value 'placeholder' -NoNewline
|
||||
$assets = './rustdesk-msi-template/data/flutter_assets/assets'
|
||||
New-Item -ItemType Directory -Force -Path $assets | Out-Null
|
||||
foreach ($a in 'icon.ico','icon.png','logo.png','logo_light.png','logo_dark.png') {
|
||||
Set-Content -Path "$assets/$a" -Value 'placeholder' -NoNewline
|
||||
}
|
||||
pushd ./res/msi
|
||||
python preprocess.py --arp --template --revision-version 0 -d ../../rustdesk-msi-template --app-name RDAPPNAM
|
||||
$msiPlatform = if ('${{ matrix.job.arch }}' -eq 'aarch64') { 'ARM64' } else { 'x64' }
|
||||
msbuild msi.sln -t:clean -p:Configuration=Release -p:Platform=$msiPlatform
|
||||
msbuild msi.sln -p:Configuration=Release -p:Platform=$msiPlatform /p:TargetVersion=Windows10
|
||||
$msi = Get-ChildItem ./Package/bin/*/Release/en-us/Package.msi | Select-Object -First 1
|
||||
popd
|
||||
mkdir ./msi-template
|
||||
mv $msi.FullName ./msi-template/rustdesk-template-${{ matrix.job.arch }}.msi
|
||||
git checkout -- res/msi
|
||||
rm -r -fo ./rustdesk-msi-template
|
||||
|
||||
- name: Upload unsigned msi template
|
||||
if: env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: rustdesk-unsigned-msi-template-${{ matrix.job.arch }}
|
||||
path: ./msi-template
|
||||
|
||||
- name: Sign rustdesk self-extracted file
|
||||
if: env.UPLOAD_ARTIFACT == 'true' && env.SIGN_BASE_URL != '-2'
|
||||
shell: bash
|
||||
@@ -925,15 +974,33 @@ jobs:
|
||||
name: rustdesk-unsigned-windows-x86_64
|
||||
path: ./windows-x86_64/
|
||||
|
||||
- name: Download Artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: rustdesk-unsigned-windows-aarch64
|
||||
path: ./windows-aarch64/
|
||||
|
||||
- name: Download Artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: rustdesk-unsigned-windows-x86
|
||||
path: ./windows-x86/
|
||||
|
||||
- name: Download Artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: rustdesk-unsigned-msi-template-x86_64
|
||||
path: ./msi-template/
|
||||
|
||||
- name: Download Artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: rustdesk-unsigned-msi-template-aarch64
|
||||
path: ./msi-template/
|
||||
|
||||
- name: Combine unsigned app
|
||||
run: |
|
||||
tar czf rustdesk-${{ env.VERSION }}-unsigned.tar.gz *.dmg windows-x86_64 windows-x86
|
||||
tar czf rustdesk-${{ env.VERSION }}-unsigned.tar.gz *.dmg windows-x86_64 windows-aarch64 windows-x86 msi-template
|
||||
|
||||
- name: Publish unsigned app
|
||||
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
@@ -1009,7 +1076,6 @@ jobs:
|
||||
libgstreamer1.0-dev \
|
||||
libgstreamer-plugins-base1.0-dev \
|
||||
libgtk-3-dev \
|
||||
libpam0g-dev \
|
||||
libpulse-dev \
|
||||
libva-dev \
|
||||
libxcb-randr0-dev \
|
||||
@@ -1283,7 +1349,6 @@ jobs:
|
||||
libgstreamer1.0-dev \
|
||||
libgstreamer-plugins-base1.0-dev \
|
||||
libgtk-3-dev \
|
||||
libpam0g-dev \
|
||||
libpulse-dev \
|
||||
libva-dev \
|
||||
libxcb-randr0-dev \
|
||||
@@ -1472,7 +1537,6 @@ jobs:
|
||||
submodules: recursive
|
||||
|
||||
- name: Set Swap Space
|
||||
if: ${{ matrix.job.arch == 'x86_64' }}
|
||||
uses: pierotofy/set-swap-space@49819abfb41bd9b44fb781159c033dba90353a7c # v1.0
|
||||
with:
|
||||
swap-size-gb: 12
|
||||
@@ -1507,6 +1571,15 @@ jobs:
|
||||
name: bridge-artifact
|
||||
path: ./
|
||||
|
||||
# vcpkg 2026.07.29's SPDX scripts require CMake 4.3+, but this ARM64 runner selects CMake 3.31.
|
||||
- name: Install CMake for vcpkg on Linux ARM64
|
||||
if: matrix.job.arch == 'aarch64' && env.UPLOAD_ARTIFACT == 'true'
|
||||
run: |
|
||||
python3 -m pip install --user "cmake==${VCPKG_CMAKE_VERSION}"
|
||||
user_base="$(python3 -m site --user-base)"
|
||||
"${user_base}/bin/cmake" --version
|
||||
echo "${user_base}/bin" >> "${GITHUB_PATH}"
|
||||
|
||||
- name: Setup vcpkg with Github Actions binary cache
|
||||
if: matrix.job.arch == 'x86_64' || env.UPLOAD_ARTIFACT == 'true'
|
||||
uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11
|
||||
@@ -1574,7 +1647,6 @@ jobs:
|
||||
libgstreamer1.0-dev \
|
||||
libgstreamer-plugins-base1.0-dev \
|
||||
libgtk-3-dev \
|
||||
libpam0g-dev \
|
||||
libpulse-dev \
|
||||
libva-dev \
|
||||
libxcb-randr0-dev \
|
||||
@@ -1896,7 +1968,6 @@ jobs:
|
||||
libgstreamer1.0-dev \
|
||||
libgstreamer-plugins-base1.0-dev \
|
||||
libgtk-3-dev \
|
||||
libpam0g-dev \
|
||||
libpulse-dev \
|
||||
libva-dev \
|
||||
libxcb-randr0-dev \
|
||||
@@ -2079,6 +2150,12 @@ jobs:
|
||||
echo "Modified vcpkg.json for armv7 build:"
|
||||
grep -A 2 -B 2 '"baseline"' vcpkg.json
|
||||
|
||||
- name: Set Swap Space
|
||||
if: matrix.job.arch == 'armv7'
|
||||
uses: pierotofy/set-swap-space@49819abfb41bd9b44fb781159c033dba90353a7c # v1.0
|
||||
with:
|
||||
swap-size-gb: 12
|
||||
|
||||
- name: Free Space
|
||||
run: |
|
||||
df -h
|
||||
@@ -2126,7 +2203,6 @@ jobs:
|
||||
libgstreamer-plugins-base1.0-dev \
|
||||
libgtk-3-dev \
|
||||
liblzma-dev \
|
||||
libpam0g-dev \
|
||||
libpulse-dev \
|
||||
libva-dev \
|
||||
libxcb-randr0-dev \
|
||||
@@ -2203,7 +2279,7 @@ jobs:
|
||||
mkdir -p ~/.cargo/
|
||||
echo """
|
||||
[source.crates-io]
|
||||
registry = 'https://github.com/rust-lang/crates.io-index'
|
||||
registry = 'sparse+https://index.crates.io/'
|
||||
""" > ~/.cargo/config
|
||||
cat ~/.cargo/config
|
||||
# install dependencies from vcpkg
|
||||
|
||||
11
.github/workflows/playground.yml
vendored
11
.github/workflows/playground.yml
vendored
@@ -16,8 +16,8 @@ env:
|
||||
FLUTTER_ELINUX_VERSION: "3.16.9"
|
||||
TAG_NAME: "nightly"
|
||||
VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite"
|
||||
VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b"
|
||||
VERSION: "1.4.9"
|
||||
VCPKG_COMMIT_ID: "9e593bb18ea69cc5095e012465dcd675a822ed0d"
|
||||
VERSION: "1.5.0"
|
||||
NDK_VERSION: "r26d"
|
||||
#signing keys env variable checks
|
||||
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"
|
||||
@@ -271,7 +271,6 @@ jobs:
|
||||
libgstreamer1.0-dev \
|
||||
libgstreamer-plugins-base1.0-dev \
|
||||
libgtk-3-dev \
|
||||
libpam0g-dev \
|
||||
libpulse-dev \
|
||||
libva-dev \
|
||||
libvdpau-dev \
|
||||
@@ -284,7 +283,7 @@ jobs:
|
||||
nasm \
|
||||
yasm \
|
||||
ninja-build \
|
||||
openjdk-11-jdk-headless \
|
||||
openjdk-17-jdk-headless \
|
||||
pkg-config \
|
||||
tree \
|
||||
wget
|
||||
@@ -366,9 +365,9 @@ jobs:
|
||||
- name: Build rustdesk
|
||||
shell: bash
|
||||
env:
|
||||
JAVA_HOME: /usr/lib/jvm/java-11-openjdk-amd64
|
||||
JAVA_HOME: /usr/lib/jvm/java-17-openjdk-amd64
|
||||
run: |
|
||||
export PATH=/usr/lib/jvm/java-11-openjdk-amd64/bin:$PATH
|
||||
export PATH=/usr/lib/jvm/java-17-openjdk-amd64/bin:$PATH
|
||||
# temporary use debug sign config
|
||||
sed -i "s/signingConfigs.release/signingConfigs.debug/g" ./flutter/android/app/build.gradle
|
||||
case ${{ matrix.job.target }} in
|
||||
|
||||
4
.github/workflows/update-webpki-roots.yml
vendored
4
.github/workflows/update-webpki-roots.yml
vendored
@@ -33,6 +33,10 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
# The root workspace lists libs/hbb_common as a member; without the
|
||||
# submodule its manifest is missing and cargo cannot load the workspace.
|
||||
submodules: recursive
|
||||
|
||||
- name: Update webpki-roots in all lockfiles
|
||||
id: update
|
||||
|
||||
34
AGENTS.md
34
AGENTS.md
@@ -63,17 +63,43 @@
|
||||
|
||||
### Comments
|
||||
|
||||
* Keep them short: one line by default, three at most.
|
||||
* Say **why**, never what. If the code already says it, delete the comment.
|
||||
* A comment must never be longer than the code it describes.
|
||||
* Applies to YAML, shell and Python too, not just Rust.
|
||||
* Avoid comments unless they explain a non-obvious reason, constraint, or workaround.
|
||||
* Never restate what the code does; prefer clearer code instead.
|
||||
* If the code is self-explanatory, add no comment.
|
||||
|
||||
### Be minimally invasive
|
||||
|
||||
* Prefer purely additive changes: layer new (`#[cfg]`-gated) blocks or new functions around existing code instead of restructuring it. The ideal diff for a fix adds lines and modifies/deletes none.
|
||||
* Do not extract or reshape existing code just to enable your new code; look for a mechanism that leaves existing lines untouched (e.g. hide/show an existing object instead of refactoring its construction into a helper for rebuilding).
|
||||
* Accept a little duplication over a restructure. A new function that repeats a few lines of an existing one is a better diff than reshaping the original so both can share it.
|
||||
* Put new logic in self-contained functions in the module it belongs to (platform-specific logic in `src/platform/`, with `use` inside the function body to avoid churning shared import blocks). Call sites in shared files (`src/tray.rs`, `src/core_main.rs`, `src/server/connection.rs`, …) should be thin one-line hooks.
|
||||
|
||||
### Scope check before touching shared code
|
||||
|
||||
* Before changing a shared trait, a shared struct, or the signature of a widely used function, check whether the bug or feature is specific to one path. If it is, keep the change inside that path unless that is impossible, and say in the PR why it was.
|
||||
* If an unrelated caller needs `Default::default()`, `None`, or another placeholder solely to satisfy a signature you changed, the diff is too broad: stop and redesign.
|
||||
* The expected shape of a fix is a new function in the feature's own module, plus at most a new field or a thin hook in the shared code it needs. Feature-specific state belongs beside the feature's existing state, not in a new abstraction every caller has to learn.
|
||||
|
||||
### Mandatory regression-surface check
|
||||
|
||||
Before considering any implementation complete, perform a minimization pass over the final diff.
|
||||
|
||||
* Inspect every modified existing file and every modified existing code path. Each must be strictly necessary for the requested change. Revert changes that are merely cleanup, refactoring, consistency improvements, or fixes for pre-existing issues.
|
||||
* For new features, preserve the existing implementation path when the feature is disabled or unsupported whenever practical. `feature off` should run the old code, not a rewritten equivalent.
|
||||
* Do not route existing behavior through a new abstraction merely to share code with the new feature. Prefer a parallel new function or a small amount of duplication over changing a proven existing path.
|
||||
* Keep new implementation logic in new or feature-specific modules. Changes to shared/core files should normally be thin hooks, capability checks, or protocol plumbing.
|
||||
* Do not fix unrelated pre-existing bugs in the same PR. Put them in a separate change unless they directly block correctness or security of the requested work.
|
||||
* For submodule bumps, inspect the exact commit range and ensure unrelated changes are not being pulled into the parent PR.
|
||||
* Before finalizing, explicitly report the regression surface: list the existing files and existing runtime paths whose behavior changed, and explain why each change is unavoidable.
|
||||
* During review, treat an unnecessarily modified legacy path as a review finding even if tests pass and the rewritten behavior appears equivalent.
|
||||
|
||||
## Reviewing a PR
|
||||
|
||||
* Review only what the diff introduces. Verify ownership with `gh pr diff` before reporting a finding — if the offending lines are untouched context, it is a pre-existing problem, not this PR's.
|
||||
* List pre-existing problems in a separate section at the end, or leave out the ones that are not fatal. Never mix them into the findings the author has to fix.
|
||||
* Before re-reviewing, read the author's reply comments. Do not re-raise items they declined on scope grounds.
|
||||
* State a finding's consequence exactly: distinguish "the value is lost" from "the shortcut is inert but the value still saves".
|
||||
|
||||
## Localization (`src/lang/*.rs`)
|
||||
|
||||
Each file is a `HashMap<key, translation>`. Layout:
|
||||
|
||||
450
Cargo.lock
generated
450
Cargo.lock
generated
@@ -753,24 +753,6 @@ dependencies = [
|
||||
"syn 2.0.98",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bindgen"
|
||||
version = "0.71.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"cexpr",
|
||||
"clang-sys",
|
||||
"itertools 0.12.1",
|
||||
"proc-macro2 1.0.93",
|
||||
"quote 1.0.36",
|
||||
"regex",
|
||||
"rustc-hash 2.1.1",
|
||||
"shlex",
|
||||
"syn 2.0.98",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bindgen"
|
||||
version = "0.72.1"
|
||||
@@ -986,27 +968,6 @@ dependencies = [
|
||||
"serde 1.0.228",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bzip2"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8"
|
||||
dependencies = [
|
||||
"bzip2-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bzip2-sys"
|
||||
version = "0.1.11+1.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "736a955f3fa7875102d57c82b8cac37ec45224a07fd32d58f9f7a186b6cd4cdc"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cacao"
|
||||
version = "0.4.0-beta2"
|
||||
@@ -1182,30 +1143,6 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
|
||||
dependencies = [
|
||||
"cfg-if 1.0.0",
|
||||
"cipher",
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chacha20poly1305"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"chacha20",
|
||||
"cipher",
|
||||
"poly1305",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.41"
|
||||
@@ -1255,7 +1192,6 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"inout",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1477,8 +1413,8 @@ dependencies = [
|
||||
"compression-core",
|
||||
"flate2",
|
||||
"memchr",
|
||||
"zstd 0.13.1",
|
||||
"zstd-safe 7.1.0",
|
||||
"zstd",
|
||||
"zstd-safe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1549,12 +1485,6 @@ dependencies = [
|
||||
"unicode-xid 0.2.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "constant_time_eq"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc"
|
||||
|
||||
[[package]]
|
||||
name = "constant_time_eq"
|
||||
version = "0.2.6"
|
||||
@@ -2351,7 +2281,7 @@ version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412"
|
||||
dependencies = [
|
||||
"libloading 0.8.4",
|
||||
"libloading 0.7.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2469,42 +2399,6 @@ dependencies = [
|
||||
"linux-raw-sys 0.6.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dtls"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f531dd7c181beaf3cebab3716afa4d0d41ab888be85232583f56bbaf07ca208a"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
"async-trait",
|
||||
"bincode",
|
||||
"byteorder",
|
||||
"cbc",
|
||||
"ccm",
|
||||
"chacha20poly1305",
|
||||
"der-parser",
|
||||
"hmac",
|
||||
"log",
|
||||
"p256",
|
||||
"p384",
|
||||
"portable-atomic",
|
||||
"rand 0.9.2",
|
||||
"rand_core 0.6.4",
|
||||
"rcgen",
|
||||
"ring",
|
||||
"rustls",
|
||||
"sec1",
|
||||
"serde 1.0.228",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"thiserror 1.0.61",
|
||||
"tokio",
|
||||
"webrtc-util",
|
||||
"x25519-dalek",
|
||||
"x509-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dtoa"
|
||||
version = "0.4.8"
|
||||
@@ -2890,7 +2784,7 @@ dependencies = [
|
||||
"is-terminal",
|
||||
"lazy_static",
|
||||
"log",
|
||||
"nu-ansi-term 0.49.0",
|
||||
"nu-ansi-term",
|
||||
"regex",
|
||||
"thiserror 1.0.61",
|
||||
]
|
||||
@@ -3793,6 +3687,7 @@ dependencies = [
|
||||
"mac_address",
|
||||
"machine-uid",
|
||||
"osascript",
|
||||
"percent-encoding",
|
||||
"protobuf",
|
||||
"protobuf-codegen",
|
||||
"rand 0.8.5",
|
||||
@@ -3818,14 +3713,14 @@ dependencies = [
|
||||
"toml 0.7.8",
|
||||
"tungstenite",
|
||||
"url",
|
||||
"users 0.11.0",
|
||||
"users",
|
||||
"uuid",
|
||||
"webpki-roots 1.0.9",
|
||||
"webrtc",
|
||||
"whoami",
|
||||
"winapi 0.3.9",
|
||||
"x11 2.21.0",
|
||||
"zstd 0.13.1",
|
||||
"zstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4204,16 +4099,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "interceptor"
|
||||
version = "0.15.0"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea51375727680dc15f06e8ad90fa31df75d79dd030100e8ad60eef1c27fe2c98"
|
||||
checksum = "1ac0781c825d602095113772e389ef0607afcb869ae0e68a590d8e0799cdcef8"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"futures",
|
||||
"log",
|
||||
"portable-atomic",
|
||||
"rand 0.9.2",
|
||||
"rand 0.8.5",
|
||||
"rtcp",
|
||||
"rtp",
|
||||
"thiserror 1.0.61",
|
||||
@@ -4365,11 +4259,11 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "kcp-sys"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/rustdesk-org/kcp-sys#32a6c09fc6223f54aea83981a6aa8995931d29be"
|
||||
source = "git+https://github.com/rustdesk-org/kcp-sys?branch=rustdesk-patches#023a0065398968989f2ddfcf5cc72bb886d02675"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"auto_impl",
|
||||
"bindgen 0.71.1",
|
||||
"bindgen 0.72.1",
|
||||
"bitflags 2.9.1",
|
||||
"bytes",
|
||||
"cc",
|
||||
@@ -4380,8 +4274,6 @@ dependencies = [
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"zerocopy 0.7.34",
|
||||
]
|
||||
|
||||
@@ -4515,7 +4407,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e310b3a6b5907f99202fcdb4960ff45b93735d7c7d96b760fcff8db2dc0e103d"
|
||||
dependencies = [
|
||||
"cfg-if 1.0.0",
|
||||
"windows-targets 0.52.6",
|
||||
"windows-targets 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5237,16 +5129,6 @@ dependencies = [
|
||||
"winapi 0.3.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.46.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84"
|
||||
dependencies = [
|
||||
"overload",
|
||||
"winapi 0.3.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.49.0"
|
||||
@@ -5910,12 +5792,6 @@ dependencies = [
|
||||
"serde_json 1.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "overload"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
|
||||
|
||||
[[package]]
|
||||
name = "owned_ttf_parser"
|
||||
version = "0.25.1"
|
||||
@@ -5959,37 +5835,6 @@ dependencies = [
|
||||
"winapi 0.3.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pam"
|
||||
version = "0.7.0"
|
||||
source = "git+https://github.com/rustdesk-org/pam#7bfd25510202cd269292cbdd7c71f3977a6fd762"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"pam-macros",
|
||||
"pam-sys",
|
||||
"users 0.10.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pam-macros"
|
||||
version = "0.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c94f3b9b97df3c6d4e51a14916639b24e02c7d15d1dba686ce9b1118277cb811"
|
||||
dependencies = [
|
||||
"proc-macro2 1.0.93",
|
||||
"quote 1.0.36",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pam-sys"
|
||||
version = "1.0.0-alpha4"
|
||||
source = "git+https://github.com/rustdesk-org/pam-sys?branch=fix/v1.0.0-alpha4_gnuc_va_list#3337c9bb9a9c68d7497ec8c93cad2368c26091b7"
|
||||
dependencies = [
|
||||
"bindgen 0.59.2",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pango"
|
||||
version = "0.18.3"
|
||||
@@ -6057,35 +5902,12 @@ dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "password-hash"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "paste"
|
||||
version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "pbkdf2"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"hmac",
|
||||
"password-hash",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "peeking_take_while"
|
||||
version = "0.1.2"
|
||||
@@ -6358,17 +6180,6 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "poly1305"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
|
||||
dependencies = [
|
||||
"cpufeatures",
|
||||
"opaque-debug",
|
||||
"universal-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polyval"
|
||||
version = "0.6.2"
|
||||
@@ -7175,9 +6986,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rtcp"
|
||||
version = "0.14.0"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81d30d1c4091644431c22acf9f8be6191b56805e0e977f15ca7104b4a6d6eaec"
|
||||
checksum = "e9689528bf3a9eb311fd938d05516dd546412f9ce4fffc8acfc1db27cc3dbf72"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"thiserror 1.0.61",
|
||||
@@ -7186,14 +6997,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rtp"
|
||||
version = "0.14.0"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f126f38ea84c02480e32e547c1459a939052f74fb92117ac3eef23fdac6b023"
|
||||
checksum = "c54733451a67d76caf9caa07a7a2cec6871ea9dda92a7847f98063d459200f4b"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"memchr",
|
||||
"portable-atomic",
|
||||
"rand 0.9.2",
|
||||
"rand 0.8.5",
|
||||
"serde 1.0.228",
|
||||
"thiserror 1.0.61",
|
||||
"webrtc-util",
|
||||
@@ -7258,7 +7069,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustdesk"
|
||||
version = "1.4.9"
|
||||
version = "1.5.0"
|
||||
dependencies = [
|
||||
"android-wakelock",
|
||||
"android_logger",
|
||||
@@ -7316,7 +7127,6 @@ dependencies = [
|
||||
"once_cell",
|
||||
"openssl",
|
||||
"os-version",
|
||||
"pam",
|
||||
"parity-tokio-ipc",
|
||||
"percent-encoding",
|
||||
"piet",
|
||||
@@ -7349,6 +7159,7 @@ dependencies = [
|
||||
"terminfo",
|
||||
"termios 0.3.3",
|
||||
"tiny-skia",
|
||||
"tokio",
|
||||
"totp-rs",
|
||||
"tray-icon",
|
||||
"ttf-parser",
|
||||
@@ -7365,12 +7176,11 @@ dependencies = [
|
||||
"wol-rs",
|
||||
"x11-clipboard 0.8.1",
|
||||
"x11rb 0.12.0",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustdesk-portable-packer"
|
||||
version = "1.4.9"
|
||||
version = "1.5.0"
|
||||
dependencies = [
|
||||
"brotli",
|
||||
"dirs 5.0.1",
|
||||
@@ -7630,11 +7440,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sdp"
|
||||
version = "0.10.0"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32c374dceda16965d541c8800ce9cc4e1c14acfd661ddf7952feeedc3411e5c6"
|
||||
checksum = "4cd277015eada44a0bb810a4b84d3bf6e810573fa62fb442f457edf6a1087a69"
|
||||
dependencies = [
|
||||
"rand 0.9.2",
|
||||
"rand 0.8.5",
|
||||
"substring",
|
||||
"thiserror 1.0.61",
|
||||
"url",
|
||||
@@ -7864,15 +7674,6 @@ dependencies = [
|
||||
"tzdb 0.5.10",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shared_library"
|
||||
version = "0.1.9"
|
||||
@@ -8212,15 +8013,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "stun"
|
||||
version = "0.9.0"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a512c5d501e3e3b5a4bb3e8e31462d56d54a66b95a28b8596e14422bf21c32b"
|
||||
checksum = "7dbc2bab375524093c143dc362a03fb6a1fb79e938391cdb21665688f88a088a"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"crc",
|
||||
"lazy_static",
|
||||
"md-5",
|
||||
"rand 0.9.2",
|
||||
"rand 0.8.5",
|
||||
"ring",
|
||||
"subtle",
|
||||
"thiserror 1.0.61",
|
||||
@@ -8602,16 +8403,6 @@ dependencies = [
|
||||
"syn 2.0.98",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thread_local"
|
||||
version = "1.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c"
|
||||
dependencies = [
|
||||
"cfg-if 1.0.0",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "threadpool"
|
||||
version = "1.8.1"
|
||||
@@ -8907,7 +8698,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c4ae9724c5888c0417d2396037ed3b60665925624766416e3e342b6ba5dbd3f"
|
||||
dependencies = [
|
||||
"base32",
|
||||
"constant_time_eq 0.2.6",
|
||||
"constant_time_eq",
|
||||
"hmac",
|
||||
"rand 0.8.5",
|
||||
"sha1",
|
||||
@@ -8991,32 +8782,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"valuable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-log"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-subscriber"
|
||||
version = "0.3.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008"
|
||||
dependencies = [
|
||||
"nu-ansi-term 0.46.0",
|
||||
"sharded-slab",
|
||||
"smallvec",
|
||||
"thread_local",
|
||||
"tracing-core",
|
||||
"tracing-log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9131,9 +8896,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "turn"
|
||||
version = "0.11.0"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ed995882f66ab94238de77c62e5e778389698ab700afa4696f4754da8f457cb"
|
||||
checksum = "3f5aea1116456e1da71c45586b87c72e3b43164fbf435eb93ff6aa475416a9a4"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -9141,7 +8906,7 @@ dependencies = [
|
||||
"log",
|
||||
"md-5",
|
||||
"portable-atomic",
|
||||
"rand 0.9.2",
|
||||
"rand 0.8.5",
|
||||
"ring",
|
||||
"stun",
|
||||
"thiserror 1.0.61",
|
||||
@@ -9267,12 +9032,6 @@ dependencies = [
|
||||
"unic-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-bidi"
|
||||
version = "0.3.15"
|
||||
@@ -9352,16 +9111,6 @@ version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||
|
||||
[[package]]
|
||||
name = "users"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa4227e95324a443c9fcb06e03d4d85e91aabe9a5a02aa818688b6918b6af486"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "users"
|
||||
version = "0.11.0"
|
||||
@@ -9428,12 +9177,6 @@ dependencies = [
|
||||
"bindgen 0.65.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
@@ -9817,25 +9560,26 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "webrtc"
|
||||
version = "0.14.0"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08fd686c0920ac08f3a57eacc48e31f0e4ca1ffefba4478784606f78c14e83ad"
|
||||
checksum = "24bab7195998d605c862772f90a452ba655b90a2f463c850ac032038890e367a"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"dtls",
|
||||
"cfg-if 1.0.0",
|
||||
"hex",
|
||||
"interceptor",
|
||||
"lazy_static",
|
||||
"log",
|
||||
"portable-atomic",
|
||||
"rand 0.9.2",
|
||||
"rand 0.8.5",
|
||||
"rcgen",
|
||||
"regex",
|
||||
"ring",
|
||||
"rtcp",
|
||||
"rtp",
|
||||
"rustls",
|
||||
"sdp",
|
||||
"serde 1.0.228",
|
||||
"serde_json 1.0.118",
|
||||
@@ -9843,12 +9587,13 @@ dependencies = [
|
||||
"smol_str",
|
||||
"stun",
|
||||
"thiserror 1.0.61",
|
||||
"time 0.3.36",
|
||||
"tokio",
|
||||
"turn",
|
||||
"unicase",
|
||||
"url",
|
||||
"waitgroup",
|
||||
"webrtc-data",
|
||||
"webrtc-dtls",
|
||||
"webrtc-ice",
|
||||
"webrtc-mdns",
|
||||
"webrtc-media",
|
||||
@@ -9859,9 +9604,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "webrtc-data"
|
||||
version = "0.12.0"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "062a5438d63bb0756a221693d76cc0dd6119affee1dfdfe57abe3a2a8c8b3eea"
|
||||
checksum = "4e97b932854da633a767eff0cc805425a2222fc6481e96f463e57b015d949d1d"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"log",
|
||||
@@ -9873,17 +9618,54 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webrtc-ice"
|
||||
version = "0.14.0"
|
||||
name = "webrtc-dtls"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69cb13fd1a373e68addc4bba0c8ca058627518e54342583d024bdcbb8ae5d97d"
|
||||
checksum = "5ccbe4d9049390ab52695c3646c1395c877e16c15fb05d3bda8eee0c7351711c"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
"async-trait",
|
||||
"bincode",
|
||||
"byteorder",
|
||||
"cbc",
|
||||
"ccm",
|
||||
"der-parser",
|
||||
"hkdf",
|
||||
"hmac",
|
||||
"log",
|
||||
"p256",
|
||||
"p384",
|
||||
"portable-atomic",
|
||||
"rand 0.8.5",
|
||||
"rand_core 0.6.4",
|
||||
"rcgen",
|
||||
"ring",
|
||||
"rustls",
|
||||
"sec1",
|
||||
"serde 1.0.228",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"subtle",
|
||||
"thiserror 1.0.61",
|
||||
"tokio",
|
||||
"webrtc-util",
|
||||
"x25519-dalek",
|
||||
"x509-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webrtc-ice"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb51bde0d790f109a15bfe4d04f1b56fb51d567da231643cb3f21bb74d678997"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"async-trait",
|
||||
"crc",
|
||||
"log",
|
||||
"portable-atomic",
|
||||
"rand 0.9.2",
|
||||
"rand 0.8.5",
|
||||
"serde 1.0.228",
|
||||
"serde_json 1.0.118",
|
||||
"stun",
|
||||
@@ -9899,9 +9681,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "webrtc-mdns"
|
||||
version = "0.10.0"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a17279a067e75df72ce923fdeb7f04cd808f6f5aa4910dc6bcb4fbe66b396ace"
|
||||
checksum = "979cc85259c53b7b620803509d10d35e2546fa505d228850cbe3f08765ea6ea8"
|
||||
dependencies = [
|
||||
"log",
|
||||
"socket2 0.5.10",
|
||||
@@ -9912,22 +9694,21 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "webrtc-media"
|
||||
version = "0.11.0"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94a84c910fec0848fd5a0d8a5651e0ddbdedaf25a7d3ae3f0b15f71ac73a1773"
|
||||
checksum = "80041211deccda758a3e19aa93d6b10bc1d37c9183b519054b40a83691d13810"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"rand 0.9.2",
|
||||
"rand 0.8.5",
|
||||
"rtp",
|
||||
"thiserror 1.0.61",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webrtc-sctp"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f985465467d8910c1f8ac4382cd64f83b1f6a1a75021a82b221546f6fb3b856f"
|
||||
version = "0.12.0"
|
||||
source = "git+https://github.com/rustdesk-org/webrtc?rev=825a0a4862818f74406d8e1cc25be72259228235#825a0a4862818f74406d8e1cc25be72259228235"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"async-trait",
|
||||
@@ -9935,7 +9716,7 @@ dependencies = [
|
||||
"crc",
|
||||
"log",
|
||||
"portable-atomic",
|
||||
"rand 0.9.2",
|
||||
"rand 0.8.5",
|
||||
"thiserror 1.0.61",
|
||||
"tokio",
|
||||
"webrtc-util",
|
||||
@@ -9943,9 +9724,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "webrtc-srtp"
|
||||
version = "0.16.0"
|
||||
version = "0.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66d8cdc33413f1d0192670a80ce93d17cb78d57fe3a2414be30d6f6dff121123"
|
||||
checksum = "01e773f79b09b057ffbda6b03fe7b43403b012a240cf8d05d630674c3723b5bb"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"aes",
|
||||
@@ -9966,19 +9747,19 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "webrtc-util"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1c0c7e0c8f280f2bbfae442701465777ac07adaf46ce0c5863cd58e13fe472a"
|
||||
version = "0.11.0"
|
||||
source = "git+https://github.com/rustdesk-org/webrtc?rev=825a0a4862818f74406d8e1cc25be72259228235#825a0a4862818f74406d8e1cc25be72259228235"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bitflags 1.3.2",
|
||||
"bytes",
|
||||
"ipnet",
|
||||
"lazy_static",
|
||||
"libc",
|
||||
"log",
|
||||
"nix 0.26.4",
|
||||
"portable-atomic",
|
||||
"rand 0.9.2",
|
||||
"rand 0.8.5",
|
||||
"thiserror 1.0.61",
|
||||
"tokio",
|
||||
"winapi 0.3.9",
|
||||
@@ -11157,52 +10938,13 @@ dependencies = [
|
||||
"syn 2.0.98",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "0.6.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"byteorder",
|
||||
"bzip2",
|
||||
"constant_time_eq 0.1.5",
|
||||
"crc32fast",
|
||||
"crossbeam-utils",
|
||||
"flate2",
|
||||
"hmac",
|
||||
"pbkdf2",
|
||||
"sha1",
|
||||
"time 0.3.36",
|
||||
"zstd 0.11.2+zstd.1.5.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd"
|
||||
version = "0.11.2+zstd.1.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4"
|
||||
dependencies = [
|
||||
"zstd-safe 5.0.2+zstd.1.5.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d789b1514203a1120ad2429eae43a7bd32b90976a7bb8a05f7ec02fa88cc23a"
|
||||
dependencies = [
|
||||
"zstd-safe 7.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd-safe"
|
||||
version = "5.0.2+zstd.1.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d2a5585e04f9eea4b2a3d1eca508c4dee9592a89ef6f450c11719da0726f4db"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"zstd-sys",
|
||||
"zstd-safe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
22
Cargo.toml
22
Cargo.toml
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rustdesk"
|
||||
version = "1.4.9"
|
||||
version = "1.5.0"
|
||||
authors = ["rustdesk <info@rustdesk.com>"]
|
||||
edition = "2021"
|
||||
build= "build.rs"
|
||||
@@ -37,7 +37,6 @@ drm = ["scrap/drm"]
|
||||
# kind of operation and deserves a switch that can remove it from the binary entirely, without
|
||||
# giving up DRM capture: `--features drm` builds the capture path with no wake code compiled in.
|
||||
drm-wake = ["drm"]
|
||||
plugin_framework = []
|
||||
linux-pkg-config = ["magnum-opus/linux-pkg-config", "scrap/linux-pkg-config"]
|
||||
unix-file-copy-paste = [
|
||||
"dep:x11-clipboard",
|
||||
@@ -53,7 +52,7 @@ screencapturekit = ["cpal/screencapturekit"]
|
||||
[dependencies]
|
||||
async-trait = "0.1"
|
||||
scrap = { path = "libs/scrap", features = ["wayland"] }
|
||||
hbb_common = { path = "libs/hbb_common" }
|
||||
hbb_common = { path = "libs/hbb_common", features = ["webrtc"] }
|
||||
serde_derive = "1.0"
|
||||
serde = "1.0"
|
||||
serde_json = "1.0"
|
||||
@@ -81,11 +80,10 @@ hex = "0.4"
|
||||
chrono = "0.4"
|
||||
cidr-utils = "0.5"
|
||||
fon = "0.6"
|
||||
zip = "0.6"
|
||||
shutdown_hooks = "0.1"
|
||||
totp-rs = { version = "5.4", default-features = false, features = ["gen_secret", "otpauth"] }
|
||||
stunclient = "0.4"
|
||||
kcp-sys= { git = "https://github.com/rustdesk-org/kcp-sys"}
|
||||
kcp-sys= { git = "https://github.com/rustdesk-org/kcp-sys", branch = "rustdesk-patches" }
|
||||
reqwest = { version = "0.12", features = ["blocking", "socks", "json", "native-tls", "rustls-tls", "rustls-tls-native-roots", "gzip", "zstd"], default-features=false }
|
||||
|
||||
[target.'cfg(not(target_os = "linux"))'.dependencies]
|
||||
@@ -191,7 +189,6 @@ async-process = "1.7"
|
||||
evdev = { git="https://github.com/rustdesk-org/evdev" }
|
||||
dbus = "0.9"
|
||||
dbus-crossroads = "0.5"
|
||||
pam = { git="https://github.com/rustdesk-org/pam" }
|
||||
x11-clipboard = {git="https://github.com/clslaid/x11-clipboard", branch = "feat/store-batch", optional = true}
|
||||
x11rb = {version = "0.12", features = ["all-extensions"], optional = true}
|
||||
percent-encoding = {version = "2.3", optional = true}
|
||||
@@ -212,12 +209,22 @@ android-wakelock = { git = "https://github.com/rustdesk-org/android-wakelock" }
|
||||
|
||||
[workspace]
|
||||
members = ["libs/scrap", "libs/hbb_common", "libs/enigo", "libs/clipboard", "libs/virtual_display", "libs/virtual_display/dylib", "libs/portable", "libs/remote_printer"]
|
||||
exclude = ["vdi/host", "examples/custom_plugin"]
|
||||
exclude = ["vdi/host"]
|
||||
|
||||
# Patch libxdo-sys to use a stub implementation that doesn't require libxdo
|
||||
# This allows building and running on systems without libxdo installed (e.g., Wayland-only)
|
||||
[patch.crates-io]
|
||||
libxdo-sys = { path = "libs/libxdo-sys-stub" }
|
||||
# One branch off upstream v0.13.0, the tag whose crate versions match this stack.
|
||||
# webrtc-util: reads the Windows adapter list's IPv6 addresses as host-order u16 groups, so every
|
||||
# one comes out byte-swapped, fails to bind, and ICE gathers no IPv6 host candidate on Windows.
|
||||
# webrtc-sctp: RFC 4960's 1s RTO floor makes a single loss cost 1-3s on a link whose RTT is 24-64ms,
|
||||
# and fast retransmit cannot cover a request/response exchange; INITIAL_MTU 1228 also fragments on
|
||||
# IPv6; and its AIMD pins a lossy long-haul link to MSS/(RTT*sqrt(p)), so a switch sends without
|
||||
# a congestion window, as KCP does - on by default, `allow-webrtc-congestion-control` opts back in.
|
||||
# Pinned by rev, not branch: a fork branch can be rewritten out from under the lockfile.
|
||||
webrtc-util = { git = "https://github.com/rustdesk-org/webrtc", rev = "825a0a4862818f74406d8e1cc25be72259228235" }
|
||||
webrtc-sctp = { git = "https://github.com/rustdesk-org/webrtc", rev = "825a0a4862818f74406d8e1cc25be72259228235" }
|
||||
|
||||
[package.metadata.winres]
|
||||
LegalCopyright = "Copyright © 2026 Purslane Tech Pte. Ltd. All rights reserved."
|
||||
@@ -237,6 +244,7 @@ os-version = "0.2"
|
||||
[dev-dependencies]
|
||||
hound = "3.5"
|
||||
docopt = "1.1"
|
||||
tokio = { version = "1.44", features = ["test-util"] }
|
||||
|
||||
[package.metadata.bundle]
|
||||
name = "RustDesk"
|
||||
|
||||
@@ -19,7 +19,6 @@ RUN apt update -y && \
|
||||
libxcb-shape0-dev \
|
||||
libxcb-xfixes0-dev \
|
||||
libasound2-dev \
|
||||
libpam0g-dev \
|
||||
libpulse-dev \
|
||||
make \
|
||||
wget \
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<a href="#raw-steps-to-build">Build</a> •
|
||||
<a href="#how-to-build-with-docker">Docker</a> •
|
||||
<a href="#file-structure">Structure</a> •
|
||||
<a href="#snapshot">Snapshot</a><br>
|
||||
<a href="#screenshots">Screenshots</a><br>
|
||||
[<a href="docs/README-UA.md">Українська</a>] | [<a href="docs/README-CS.md">česky</a>] | [<a href="docs/README-ZH.md">中文</a>] | [<a href="docs/README-HU.md">Magyar</a>] | [<a href="docs/README-ES.md">Español</a>] | [<a href="docs/README-FA.md">فارسی</a>] | [<a href="docs/README-FR.md">Français</a>] | [<a href="docs/README-DE.md">Deutsch</a>] | [<a href="docs/README-PL.md">Polski</a>] | [<a href="docs/README-ID.md">Indonesian</a>] | [<a href="docs/README-FI.md">Suomi</a>] | [<a href="docs/README-ML.md">മലയാളം</a>] | [<a href="docs/README-JP.md">日本語</a>] | [<a href="docs/README-NL.md">Nederlands</a>] | [<a href="docs/README-IT.md">Italiano</a>] | [<a href="docs/README-RU.md">Русский</a>] | [<a href="docs/README-PTBR.md">Português (Brasil)</a>] | [<a href="docs/README-EO.md">Esperanto</a>] | [<a href="docs/README-KR.md">한국어</a>] | [<a href="docs/README-AR.md">العربي</a>] | [<a href="docs/README-VN.md">Tiếng Việt</a>] | [<a href="docs/README-DA.md">Dansk</a>] | [<a href="docs/README-GR.md">Ελληνικά</a>] | [<a href="docs/README-TR.md">Türkçe</a>] | [<a href="docs/README-NO.md">Norsk</a>] | [<a href="docs/README-RO.md">Română</a>]<br>
|
||||
<b>We need your help to translate this README, <a href="https://github.com/rustdesk/rustdesk/tree/master/src/lang">RustDesk UI</a> and <a href="https://github.com/rustdesk/doc.rustdesk.com">RustDesk Doc</a> to your native language</b>
|
||||
</p>
|
||||
@@ -66,19 +66,19 @@ Please download Sciter dynamic library yourself.
|
||||
```sh
|
||||
sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \
|
||||
libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \
|
||||
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev
|
||||
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
|
||||
```
|
||||
|
||||
### openSUSE Tumbleweed
|
||||
|
||||
```sh
|
||||
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel
|
||||
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel
|
||||
```
|
||||
|
||||
### Fedora 28 (CentOS 8)
|
||||
|
||||
```sh
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel
|
||||
```
|
||||
|
||||
### Arch (Manjaro)
|
||||
@@ -168,7 +168,6 @@ Please ensure that you run these commands from the root of the RustDesk reposito
|
||||
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Communicate with [rustdesk-server](https://github.com/rustdesk/rustdesk-server), wait for remote direct (TCP hole punching) or relayed connection
|
||||
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: platform specific code
|
||||
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Flutter code for desktop and mobile
|
||||
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/v1/js)**: JavaScript for Flutter web client
|
||||
|
||||
## Screenshots
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ AppDir:
|
||||
id: rustdesk
|
||||
name: rustdesk
|
||||
icon: rustdesk
|
||||
version: 1.4.9
|
||||
version: 1.5.0
|
||||
exec: usr/share/rustdesk/rustdesk
|
||||
exec_args: $@
|
||||
apt:
|
||||
@@ -58,7 +58,6 @@ AppDir:
|
||||
- libpulse0
|
||||
- packagekit-gtk3-module
|
||||
- libcanberra-gtk3-module
|
||||
- libpam0g
|
||||
- libdrm2
|
||||
exclude:
|
||||
- humanity-icon-theme
|
||||
@@ -77,6 +76,13 @@ AppDir:
|
||||
env:
|
||||
GIO_MODULE_DIR: /lib64/gio/modules:/usr/lib/aarch64-linux-gnu/gio/modules:$APPDIR/usr/lib/aarch64-linux-gnu/gio/modules
|
||||
GDK_BACKEND: x11
|
||||
# AppRun sets these to "$APPDIR/...:$XDG_DATA_DIRS", and setting them at all suppresses the XDG
|
||||
# defaults, so a host that leaves them unset loses /usr/share and /etc/xdg. gdk-pixbuf 2.43+
|
||||
# (Arch, Fedora) then finds no glycin loaders and every PNG decode fails, aborting on the first
|
||||
# remote cursor. The host value goes last: unset it expands to an empty element, which GLib
|
||||
# resolves against the CWD, and that must not outrank the defaults below.
|
||||
XDG_DATA_DIRS: $APPDIR/usr/local/share:$APPDIR/usr/share:/usr/local/share:/usr/share:$XDG_DATA_DIRS
|
||||
XDG_CONFIG_DIRS: $APPDIR/etc/xdg:/etc/xdg:$XDG_CONFIG_DIRS
|
||||
APPDIR_LIBRARY_PATH: /lib64:/usr/lib/aarch64-linux-gnu:$APPDIR/lib/aarch64-linux-gnu:$APPDIR/lib/aarch64-linux-gnu/security:$APPDIR/lib/systemd:$APPDIR/usr/lib/aarch64-linux-gnu:$APPDIR/usr/lib/aarch64-linux-gnu/gdk-pixbuf-2.0/2.10.0/loaders:$APPDIR/usr/lib/aarch64-linux-gnu/gstreamer-1.0:$APPDIR/usr/lib/aarch64-linux-gnu/gtk-3.0/3.0.0/immodules:$APPDIR/usr/lib/aarch64-linux-gnu/gtk-3.0/3.0.0/printbackends:$APPDIR/usr/lib/aarch64-linux-gnu/krb5/plugins/preauth:$APPDIR/usr/lib/aarch64-linux-gnu/libcanberra-0.30:$APPDIR/usr/lib/aarch64-linux-gnu/pulseaudio:$APPDIR/usr/lib/aarch64-linux-gnu/sasl2:$APPDIR/usr/lib/aarch64-linux-gnu/vdpau:$APPDIR/usr/share/rustdesk/lib:$APPDIR/lib/aarch64
|
||||
GST_PLUGIN_PATH: /lib64/gstreamer-1.0:/usr/lib/aarch64-linux-gnu/gstreamer-1.0:$APPDIR/usr/lib/aarch64-linux-gnu/gstreamer-1.0
|
||||
GST_PLUGIN_SYSTEM_PATH: /lib64/gstreamer-1.0:/usr/lib/aarch64-linux-gnu/gstreamer-1.0:$APPDIR/usr/lib/aarch64-linux-gnu/gstreamer-1.0
|
||||
|
||||
@@ -18,7 +18,7 @@ AppDir:
|
||||
id: rustdesk
|
||||
name: rustdesk
|
||||
icon: rustdesk
|
||||
version: 1.4.9
|
||||
version: 1.5.0
|
||||
exec: usr/share/rustdesk/rustdesk
|
||||
exec_args: $@
|
||||
apt:
|
||||
@@ -61,7 +61,6 @@ AppDir:
|
||||
- libpulse0
|
||||
- packagekit-gtk3-module
|
||||
- libcanberra-gtk3-module
|
||||
- libpam0g
|
||||
- libdrm2
|
||||
exclude:
|
||||
- humanity-icon-theme
|
||||
@@ -80,6 +79,13 @@ AppDir:
|
||||
env:
|
||||
GIO_MODULE_DIR: /lib64/gio/modules:/usr/lib/x86_64-linux-gnu/gio/modules:$APPDIR/usr/lib/x86_64-linux-gnu/gio/modules
|
||||
GDK_BACKEND: x11
|
||||
# AppRun sets these to "$APPDIR/...:$XDG_DATA_DIRS", and setting them at all suppresses the XDG
|
||||
# defaults, so a host that leaves them unset loses /usr/share and /etc/xdg. gdk-pixbuf 2.43+
|
||||
# (Arch, Fedora) then finds no glycin loaders and every PNG decode fails, aborting on the first
|
||||
# remote cursor. The host value goes last: unset it expands to an empty element, which GLib
|
||||
# resolves against the CWD, and that must not outrank the defaults below.
|
||||
XDG_DATA_DIRS: $APPDIR/usr/local/share:$APPDIR/usr/share:/usr/local/share:/usr/share:$XDG_DATA_DIRS
|
||||
XDG_CONFIG_DIRS: $APPDIR/etc/xdg:/etc/xdg:$XDG_CONFIG_DIRS
|
||||
APPDIR_LIBRARY_PATH: /lib64:/usr/lib/x86_64-linux-gnu:$APPDIR/lib/x86_64-linux-gnu:$APPDIR/lib/x86_64-linux-gnu/security:$APPDIR/lib/systemd:$APPDIR/usr/lib/x86_64-linux-gnu:$APPDIR/usr/lib/x86_64-linux-gnu/gdk-pixbuf-2.0/2.10.0/loaders:$APPDIR/usr/lib/x86_64-linux-gnu/gstreamer-1.0:$APPDIR/usr/lib/x86_64-linux-gnu/gtk-3.0/3.0.0/immodules:$APPDIR/usr/lib/x86_64-linux-gnu/gtk-3.0/3.0.0/printbackends:$APPDIR/usr/lib/x86_64-linux-gnu/krb5/plugins/preauth:$APPDIR/usr/lib/x86_64-linux-gnu/libcanberra-0.30:$APPDIR/usr/lib/x86_64-linux-gnu/pulseaudio:$APPDIR/usr/lib/x86_64-linux-gnu/sasl2:$APPDIR/usr/lib/x86_64-linux-gnu/vdpau:$APPDIR/usr/share/rustdesk/lib:$APPDIR/lib/x86_64
|
||||
GST_PLUGIN_PATH: /lib64/gstreamer-1.0:/usr/lib/x86_64-linux-gnu/gstreamer-1.0:$APPDIR/usr/lib/x86_64-linux-gnu/gstreamer-1.0
|
||||
GST_PLUGIN_SYSTEM_PATH: /lib64/gstreamer-1.0:/usr/lib/x86_64-linux-gnu/gstreamer-1.0:$APPDIR/usr/lib/x86_64-linux-gnu/gstreamer-1.0
|
||||
|
||||
20
build.py
20
build.py
@@ -364,7 +364,7 @@ Version: %s
|
||||
Architecture: %s
|
||||
Maintainer: rustdesk <info@rustdesk.com>
|
||||
Homepage: https://rustdesk.com
|
||||
Depends: libgtk-3-0t64 | libgtk-3-0, libxcb-randr0, libxdo3 | libxdo4, libxfixes3, libxcb-shape0, libxcb-xfixes0, libasound2t64 | libasound2, libsystemd0, curl, libva2, libva-drm2, libva-x11-2, libgstreamer-plugins-base1.0-0, libpam0g, gstreamer1.0-pipewire%s
|
||||
Depends: libgtk-3-0t64 | libgtk-3-0, libxcb-randr0, libxdo3 | libxdo4, libxfixes3, libxcb-shape0, libxcb-xfixes0, libasound2t64 | libasound2, libsystemd0, curl, libva2, libva-drm2, libva-x11-2, libgstreamer-plugins-base1.0-0, gstreamer1.0-pipewire%s
|
||||
Recommends: libayatana-appindicator3-1
|
||||
Description: A remote control software.
|
||||
|
||||
@@ -390,9 +390,9 @@ def ffi_bindgen_function_refactor():
|
||||
# The commit is fetched directly by sha, so no branch or tag name takes part in the build: see
|
||||
# build_libdrmtap_so(). This is the SINGLE source of truth for the pin, deliberately not duplicated in
|
||||
# any workflow, so a bump is one edit here (plus the informational version comment in
|
||||
# libs/scrap/Cargo.toml). This commit is libdrmtap v0.5.2.
|
||||
# libs/scrap/Cargo.toml). This commit is libdrmtap v0.5.4.
|
||||
LIBDRMTAP_REPO_PINNED = 'https://github.com/rustdesk-org/libdrmtap'
|
||||
LIBDRMTAP_SHA_PINNED = '653de8c774bc245eaf960611ca7a136f7a544d21'
|
||||
LIBDRMTAP_SHA_PINNED = '5da68a3a368db569716d0d0f11cefacbb11b2290'
|
||||
LIBDRMTAP_REPO = os.environ.get('DRMTAP_REPO', LIBDRMTAP_REPO_PINNED)
|
||||
LIBDRMTAP_SHA = os.environ.get('DRMTAP_SHA', LIBDRMTAP_SHA_PINNED)
|
||||
# Every way of getting a different .so than the pin needs the same explicit opt-in. Otherwise the
|
||||
@@ -704,8 +704,6 @@ def build_flutter_deb(version, features):
|
||||
system2('flutter build linux --release')
|
||||
system2('mkdir -p tmpdeb/usr/bin/')
|
||||
system2('mkdir -p tmpdeb/usr/share/rustdesk')
|
||||
system2('mkdir -p tmpdeb/etc/rustdesk/')
|
||||
system2('mkdir -p tmpdeb/etc/pam.d/')
|
||||
system2('mkdir -p tmpdeb/usr/share/rustdesk/files/systemd/')
|
||||
system2('mkdir -p tmpdeb/usr/share/icons/hicolor/256x256/apps/')
|
||||
system2('mkdir -p tmpdeb/usr/share/icons/hicolor/scalable/apps/')
|
||||
@@ -724,12 +722,6 @@ def build_flutter_deb(version, features):
|
||||
'cp ../res/rustdesk.desktop tmpdeb/usr/share/applications/rustdesk.desktop')
|
||||
system2(
|
||||
'cp ../res/rustdesk-link.desktop tmpdeb/usr/share/applications/rustdesk-link.desktop')
|
||||
system2(
|
||||
'cp ../res/startwm.sh tmpdeb/etc/rustdesk/')
|
||||
system2(
|
||||
'cp ../res/xorg.conf tmpdeb/etc/rustdesk/')
|
||||
system2(
|
||||
'cp ../res/pam.d/rustdesk.debian tmpdeb/etc/pam.d/rustdesk')
|
||||
system2(
|
||||
"echo \"#!/bin/sh\" >> tmpdeb/usr/share/rustdesk/files/polkit && chmod a+x tmpdeb/usr/share/rustdesk/files/polkit")
|
||||
# Bundle libdrmtap.so only when this build actually enabled the `drm` feature, so stock packages
|
||||
@@ -1132,13 +1124,7 @@ def main():
|
||||
'cp res/rustdesk.desktop tmpdeb/usr/share/applications/rustdesk.desktop')
|
||||
system2(
|
||||
'cp res/rustdesk-link.desktop tmpdeb/usr/share/applications/rustdesk-link.desktop')
|
||||
os.system('mkdir -p tmpdeb/etc/rustdesk/')
|
||||
os.system('cp -a res/startwm.sh tmpdeb/etc/rustdesk/')
|
||||
os.system('mkdir -p tmpdeb/etc/X11/rustdesk/')
|
||||
os.system('cp res/xorg.conf tmpdeb/etc/X11/rustdesk/')
|
||||
os.system('cp -a DEBIAN/* tmpdeb/DEBIAN/')
|
||||
os.system('mkdir -p tmpdeb/etc/pam.d/')
|
||||
os.system('cp pam.d/rustdesk.debian tmpdeb/etc/pam.d/rustdesk')
|
||||
system2('strip tmpdeb/usr/bin/rustdesk')
|
||||
system2('mkdir -p tmpdeb/usr/share/rustdesk')
|
||||
system2('mv tmpdeb/usr/bin/rustdesk tmpdeb/usr/share/rustdesk/')
|
||||
|
||||
13
build.rs
13
build.rs
@@ -43,6 +43,15 @@ fn build_manifest() {
|
||||
}
|
||||
}
|
||||
|
||||
// bionic only exports getifaddrs()/freeifaddrs() from API 24, while the jniLibs
|
||||
// are built against the API 21 sysroot (flutter/ndk_*.sh). webrtc-util calls
|
||||
// them, so without this the android link fails on undefined symbols.
|
||||
fn build_android_ifaddrs() {
|
||||
let file = "src/platform/android_ifaddrs.c";
|
||||
cc::Build::new().file(file).compile("android_ifaddrs");
|
||||
println!("cargo:rerun-if-changed={}", file);
|
||||
}
|
||||
|
||||
fn install_android_deps() {
|
||||
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap();
|
||||
if target_os != "android" {
|
||||
@@ -72,7 +81,6 @@ fn install_android_deps() {
|
||||
path.join("lib").to_str().unwrap()
|
||||
);
|
||||
println!("cargo:rustc-link-lib=ndk_compat");
|
||||
println!("cargo:rustc-link-lib=oboe");
|
||||
println!("cargo:rustc-link-lib=c++");
|
||||
println!("cargo:rustc-link-lib=OpenSLES");
|
||||
}
|
||||
@@ -90,5 +98,8 @@ fn main() {
|
||||
build_mac();
|
||||
println!("cargo:rustc-link-lib=framework=ApplicationServices");
|
||||
}
|
||||
if target_os == "android" {
|
||||
build_android_ifaddrs();
|
||||
}
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ Untuk instruksi Git yang lebih lanjut, cek disini [GitHub workflow 101](https://
|
||||
|
||||
## Tindakan
|
||||
|
||||
<https://github.com/rustdesk/rustdesk/blob/master/docs/CODE_OF_CONDUCT-ID.md>
|
||||
<https://github.com/rustdesk/rustdesk/blob/master/docs/CODE_OF_CONDUCT.md>
|
||||
|
||||
## Komunikasi
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ Per istruzioni specifiche su git, vedi [Workflow GitHub - 101](https://github.co
|
||||
|
||||
## Condotta
|
||||
|
||||
https://github.com/rustdesk/rustdesk/blob/master/docs/CODE_OF_CONDUCT-IT.md
|
||||
https://github.com/rustdesk/rustdesk/blob/master/docs/CODE_OF_CONDUCT.md
|
||||
|
||||
## Comunicazioni
|
||||
|
||||
|
||||
@@ -160,7 +160,6 @@ RustDesk يرجى التأكد من أنك تنفذ هذه الأوامر من
|
||||
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: أو المنقول عن بُعد (TCP hole punching) انتظر الاتصال المباشر [rustdesk-server](https://github.com/rustdesk/rustdesk-server) الإتصال ب
|
||||
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: رمز خاص بكل منصة
|
||||
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: رمز الهاتف المحمول
|
||||
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**:Flutter لعميل الويب الخاص ب Javascript
|
||||
|
||||
## لقطات
|
||||
|
||||
|
||||
@@ -144,7 +144,6 @@ Ujistěte se, že tyto příkazy spouštíte z kořenového adresáře RustDesk,
|
||||
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: komunikace s [rustdesk-server](https://github.com/rustdesk/rustdesk-server), očekávání vzdálených příméhých („proděrováváním“ TCP) nebo předávaných (relay) spojení
|
||||
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: zdrojové kódy, specifické pro jednotlivé platformy
|
||||
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: zdrojové kódy pro použití s aplikačním rámcem (framework) Flutter pro mobilní platformy
|
||||
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Javascript pro Flutter webový klient
|
||||
|
||||
## Ukázky
|
||||
|
||||
|
||||
@@ -66,19 +66,19 @@ Bitte laden Sie die dynamische Bibliothek Sciter selbst herunter.
|
||||
```sh
|
||||
sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \
|
||||
libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \
|
||||
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev
|
||||
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
|
||||
```
|
||||
|
||||
### openSUSE Tumbleweed
|
||||
|
||||
```sh
|
||||
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel
|
||||
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel
|
||||
```
|
||||
|
||||
### Fedora 28 (CentOS 8)
|
||||
|
||||
```sh
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel
|
||||
```
|
||||
|
||||
### Arch (Manjaro)
|
||||
@@ -168,7 +168,6 @@ Bitte stellen Sie sicher, dass Sie diese Befehle im Stammverzeichnis des RustDes
|
||||
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Mit [rustdesk-server](https://github.com/rustdesk/rustdesk-server) kommunizieren, warten auf direkte (TCP hole punching) oder weitergeleitete Verbindung
|
||||
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: Plattformspezifischer Code
|
||||
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Flutter-Code für Handys
|
||||
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: JavaScript für Flutter-Webclient
|
||||
|
||||
## Screenshots
|
||||
|
||||
|
||||
@@ -62,19 +62,19 @@ Por favor descarga la librería dinámica de Sciter tú mismo.
|
||||
```sh
|
||||
sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \
|
||||
libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \
|
||||
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev
|
||||
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
|
||||
```
|
||||
|
||||
### openSUSE Tumbleweed
|
||||
|
||||
```sh
|
||||
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel
|
||||
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel
|
||||
```
|
||||
|
||||
### Fedora 28 (CentOS 8)
|
||||
|
||||
```sh
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel
|
||||
```
|
||||
|
||||
### Arch (Manjaro)
|
||||
@@ -163,7 +163,6 @@ Por favor, asegurate de que estás ejecutando estos comandos desde la raíz del
|
||||
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Comunicación con [rustdesk-server](https://github.com/rustdesk/rustdesk-server), esperar la conexión remota directa ("TCP hole punching") o conexión indirecta ("relayed")
|
||||
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: código específico de cada plataforma
|
||||
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Flutter, código para moviles
|
||||
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Javascript para el cliente web Flutter
|
||||
|
||||
> [!Precaución]
|
||||
> **Descargo de responsabilidad por uso indebido:** <br>
|
||||
|
||||
@@ -146,7 +146,6 @@ target/release/rustdesk
|
||||
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Communicate with [rustdesk-server](https://github.com/rustdesk/rustdesk-server), wait for remote direct (TCP hole punching) or relayed connection
|
||||
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: platform specific code
|
||||
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Flutter code for mobile
|
||||
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Javascript for Flutter web client
|
||||
|
||||
## تصاویر محیط نرمافزار
|
||||
|
||||
|
||||
@@ -158,7 +158,6 @@ target/release/rustdesk
|
||||
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Communicate with [rustdesk-server](https://github.com/rustdesk/rustdesk-server), wait for remote direct (TCP hole punching) or relayed connection
|
||||
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: platform specific code
|
||||
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Flutter code for mobile
|
||||
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: JavaScript for Flutter web client
|
||||
|
||||
## Στιγμιότυπα
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ A telefonos verziók Flutter-t hasznának. Később lehetséges hogy Sciterről
|
||||
|
||||
- Futtasd a `cargo run` parancsot
|
||||
|
||||
## [Építés](https://rustdesk.com/docs/hu/dev/build/)
|
||||
## [Építés](https://rustdesk.com/docs/en/dev/build/)
|
||||
|
||||
## Hogyan építs Linuxon
|
||||
|
||||
@@ -150,7 +150,6 @@ Kérlek mindenképpen nézd meg hogy ezeket a parancsokat a root RustDesk mappá
|
||||
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Communicate with [rustdesk-server](https://github.com/rustdesk/rustdesk-server), wait for remote direct (TCP hole punching) or relayed connection
|
||||
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: platform specific code
|
||||
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Flutter code for mobile
|
||||
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Javascript for Flutter web client
|
||||
|
||||
## Képernyőképek
|
||||
|
||||
|
||||
@@ -162,7 +162,6 @@ Assicurati di eseguire questi comandi dalla radice del repository RustDesk, altr
|
||||
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: comunica con [rustdesk-server](https://github.com/rustdesk/rustdesk-server), attende la connessione remota diretta (TCP hole punching) oppure indiretta (relayed)
|
||||
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: codice specifico della piattaforma
|
||||
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: codice Flutter per desktop e mobile
|
||||
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: JavaScript per client web Flutter
|
||||
|
||||
> [!Attenzione]
|
||||
> **Dichiarazione di non responsabilità per uso improprio:** <br>
|
||||
|
||||
@@ -166,7 +166,6 @@ target/release/rustdesk
|
||||
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: [rustdesk-server](https://github.com/rustdesk/rustdesk-server)と通信し、リモートの直接接続(TCPホールパンチング)や中継接続を担う。
|
||||
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: プラットフォーム固有のコード
|
||||
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: デスクトップとモバイル向けのFlutterコード
|
||||
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Flutterウェブクライアント向けのJavaScript
|
||||
|
||||
> [!注意]
|
||||
> **:不正使用に関する免責事項** <br>
|
||||
|
||||
@@ -66,19 +66,19 @@ Sciter 동적 라이브러리를 직접 다운로드하세요.
|
||||
```sh
|
||||
sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \
|
||||
libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \
|
||||
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev
|
||||
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
|
||||
```
|
||||
|
||||
### openSUSE Tumbleweed
|
||||
|
||||
```sh
|
||||
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel
|
||||
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel
|
||||
```
|
||||
|
||||
### Fedora 28 (CentOS 8)
|
||||
|
||||
```sh
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel
|
||||
```
|
||||
|
||||
### Arch (Manjaro)
|
||||
@@ -168,7 +168,6 @@ RustDesk 리포지토리의 루트에서 이러한 명령을 실행하고 있는
|
||||
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: [rustdesk-server](https://github.com/rustdesk/rustdesk-server)와 통신, 원격 다이렉트 (TCP 홀 펀칭) 또는 릴레이 연결 대기
|
||||
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: 플랫폼별 코드
|
||||
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: 데스크톱 및 모바일용 Flutter 코드
|
||||
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/v1/js)**: Flutter 웹 클라이언트용 JavaScript
|
||||
|
||||
## 스크린샷
|
||||
|
||||
|
||||
@@ -62,19 +62,19 @@ Venligst last ned Sciters dynamiske bibliotek selv.
|
||||
```sh
|
||||
sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \
|
||||
libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \
|
||||
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev
|
||||
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
|
||||
```
|
||||
|
||||
### openSUSE Tumbleweed
|
||||
|
||||
```sh
|
||||
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel
|
||||
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel
|
||||
```
|
||||
|
||||
### Fedora 28 (CentOS 8)
|
||||
|
||||
```sh
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel
|
||||
```
|
||||
|
||||
### Arch (Manjaro)
|
||||
@@ -163,7 +163,6 @@ Venligst pass på att du kjører disse kommandoene fra roten av RustDesk reposit
|
||||
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Kommunikasjon med [rustdesk-server](https://github.com/rustdesk/rustdesk-server), vent på direkte fjernstyring (TCP hulling) eller vidresendt tilkobling
|
||||
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: platform spesefik kode
|
||||
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Flutter kode for desktop og mobil
|
||||
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: JavaScript for Flutter nettsted klient
|
||||
|
||||
## Skjermbilder
|
||||
|
||||
|
||||
@@ -155,7 +155,6 @@ Upewnij się, że uruchamiasz te polecenia z katalogu głównego repozytorium Ru
|
||||
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Komunikacja z [rustdesk-server](https://github.com/rustdesk/rustdesk-server), czekanie na bezpośrednie (odpytywanie TCP) lub przekazywane połączenie
|
||||
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: kod specyficzny dla danej platformy
|
||||
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: kod Flutter dla urządzeń mobilnych
|
||||
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: JavaScript dla Flutter - klient web
|
||||
|
||||
## Zrzuty ekranu
|
||||
|
||||
|
||||
@@ -64,19 +64,19 @@ Por favor, faça o download da biblioteca dinâmica do Sciter por conta própria
|
||||
### Ubuntu 18 (Debian 10)
|
||||
|
||||
```sh
|
||||
sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev
|
||||
sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
|
||||
```
|
||||
|
||||
### openSUSE Tumbleweed
|
||||
|
||||
```sh
|
||||
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel
|
||||
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel
|
||||
```
|
||||
|
||||
### Fedora 28 (CentOS 8)
|
||||
|
||||
```sh
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel
|
||||
```
|
||||
|
||||
### Arch (Manjaro)
|
||||
@@ -166,7 +166,6 @@ Certifique-se de executar esses comandos a partir da raiz do repositório do Rus
|
||||
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Comunica-se com o [rustdesk-server](https://github.com/rustdesk/rustdesk-server), aguarda por conexão remota direta (perfuração de túnel TCP / hole punching) ou retransmitida.
|
||||
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: código específico de cada plataforma.
|
||||
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: código Flutter para desktop e dispositivos móveis.
|
||||
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/v1/js)**: JavaScript para o cliente web do Flutter.
|
||||
|
||||
## Capturas de Tela
|
||||
|
||||
|
||||
@@ -66,19 +66,19 @@ Te rugăm să descarci singur librăria dinamică Sciter.
|
||||
```sh
|
||||
sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \
|
||||
libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \
|
||||
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev
|
||||
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
|
||||
```
|
||||
|
||||
### openSUSE Tumbleweed
|
||||
|
||||
```sh
|
||||
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel
|
||||
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel
|
||||
```
|
||||
|
||||
### Fedora 28 (CentOS 8)
|
||||
|
||||
```sh
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel
|
||||
```
|
||||
|
||||
### Arch (Manjaro)
|
||||
@@ -168,7 +168,6 @@ Asigură-te că rulezi aceste comenzi din rădăcina repository-ului RustDesk, a
|
||||
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: comunică cu [rustdesk-server](https://github.com/rustdesk/rustdesk-server), așteaptă conexiune directă remote (TCP hole punching) sau prin relay
|
||||
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: cod specific platformei
|
||||
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: cod Flutter pentru desktop și mobil
|
||||
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/v1/js)**: JavaScript pentru clientul Flutter web
|
||||
|
||||
## Capturi de ecran
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ RustDesk приветствует вклад каждого. Ознакомьт
|
||||
|
||||
- Выполните команду `cargo run`
|
||||
|
||||
## [Сборка](https://rustdesk.com/docs/ru/dev/build/)
|
||||
## [Сборка](https://rustdesk.com/docs/en/dev/build/)
|
||||
|
||||
## Как собрать на Linux
|
||||
|
||||
@@ -68,19 +68,19 @@ RustDesk приветствует вклад каждого. Ознакомьт
|
||||
```sh
|
||||
sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \
|
||||
libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \
|
||||
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev
|
||||
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
|
||||
```
|
||||
|
||||
### openSUSE Tumbleweed
|
||||
|
||||
```sh
|
||||
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel
|
||||
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel
|
||||
```
|
||||
|
||||
### Fedora 28 (CentOS 8)
|
||||
|
||||
```sh
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel
|
||||
```
|
||||
|
||||
### Arch (Manjaro)
|
||||
@@ -170,7 +170,6 @@ target/release/rustdesk
|
||||
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: связь с [сервером RustDesk](https://github.com/rustdesk/rustdesk-server), ожидает удаленного прямого (через TCP hole punching) или ретранслируемого соединения
|
||||
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: специфичный для платформы код
|
||||
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: код Flutter для ПК-версии и мобильных устройств
|
||||
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/v1/js)**: JavaScript для Web-клиента Flutter
|
||||
|
||||
## Скриншоты
|
||||
|
||||
@@ -180,4 +179,4 @@ target/release/rustdesk
|
||||
|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
@@ -166,7 +166,6 @@ Lütfen bu komutları RustDesk reposunun root klasöründe çalıştırdığın
|
||||
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: [rustdesk-server](https://github.com/rustdesk/rustdesk-server) ile iletişime gir, remote direct(TCP delik açma) yada relay bağlantısı için bekle
|
||||
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: platforma özgü kod
|
||||
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Masaüstü ve mobil için Flutter kodu
|
||||
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/v1/js)**: Flutter web istemcisi için JavaScript
|
||||
|
||||
|
||||
## Ekran Görüntüleri
|
||||
|
||||
@@ -59,19 +59,19 @@ RustDesk вітає внесок кожного. Ознайомтеся з [CONT
|
||||
```sh
|
||||
sudo apt install -y zip g++ gcc git curl wget nasm yasm libgtk-3-dev clang libxcb-randr0-dev libxdo-dev \
|
||||
libxfixes-dev libxcb-shape0-dev libxcb-xfixes0-dev libasound2-dev libpulse-dev cmake make \
|
||||
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libpam0g-dev
|
||||
libclang-dev ninja-build libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
|
||||
```
|
||||
|
||||
### openSUSE Tumbleweed
|
||||
|
||||
```sh
|
||||
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel pam-devel
|
||||
sudo zypper install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libXfixes-devel cmake alsa-lib-devel gstreamer-devel gstreamer-plugins-base-devel xdotool-devel
|
||||
```
|
||||
|
||||
### Fedora 28 (CentOS 8)
|
||||
|
||||
```sh
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel pam-devel
|
||||
sudo yum -y install gcc-c++ git curl wget nasm yasm gcc gtk3-devel clang libxcb-devel libxdo-devel libXfixes-devel pulseaudio-libs-devel cmake alsa-lib-devel gstreamer1-devel gstreamer1-plugins-base-devel
|
||||
```
|
||||
|
||||
### Arch (Manjaro)
|
||||
@@ -160,7 +160,6 @@ target/release/rustdesk
|
||||
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: комунікація з [rustdesk-server](https://github.com/rustdesk/rustdesk-server), очікування віддаленого прямого (обхід TCP NAT) або ретрансльованого зʼєднання
|
||||
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: специфічний для платформи код
|
||||
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: код Flutter для мобільних пристроїв
|
||||
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: JavaScript для веб клієнта на Flutter
|
||||
|
||||
## Знімки екрана
|
||||
|
||||
|
||||
@@ -148,7 +148,6 @@ Hãy đảm bảo rằng bạn đang chạy các lệnh này từ gốc của th
|
||||
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: giao tiếp với [rustdesk-server](https://github.com/rustdesk/rustdesk-server), đợi kết nối trực tiếp (TCP hole punching) hoặc kết nối được chuyển tiếp.
|
||||
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: mã nguồn riêng cho mỗi nền tảng
|
||||
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: Mã Flutter dành máy tính và điện thoại
|
||||
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Mã JavaScript dành cho giao diện trên web bằng Flutter
|
||||
|
||||
## Snapshot
|
||||
|
||||
|
||||
@@ -220,7 +220,6 @@ target/release/rustdesk
|
||||
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: 与[rustdesk-server](https://github.com/rustdesk/rustdesk-server)保持UDP通讯, 等待远程连接(通过打洞直连或者中继)
|
||||
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: 平台服务相关代码
|
||||
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: 适用于桌面和移动设备的 Flutter 代码
|
||||
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Flutter Web版本中的Javascript代码
|
||||
|
||||
## 截图
|
||||
|
||||
|
||||
@@ -21,18 +21,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "pam",
|
||||
"buildsystem": "autotools",
|
||||
"config-opts": ["--disable-selinux"],
|
||||
"sources": [
|
||||
{
|
||||
"type": "archive",
|
||||
"url": "https://github.com/linux-pam/linux-pam/releases/download/v1.3.1/Linux-PAM-1.3.1.tar.xz",
|
||||
"sha256": "eff47a4ecd833fbf18de9686632a70ee8d0794b79aecb217ebd0ce11db4cd0db"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "rustdesk",
|
||||
"buildsystem": "simple",
|
||||
@@ -63,4 +51,4 @@
|
||||
"--socket=pulseaudio",
|
||||
"--talk-name=org.freedesktop.Flatpak"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +82,8 @@ protobuf {
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdkVersion 34
|
||||
namespace "com.carriez.flutter_hbb"
|
||||
compileSdkVersion 36
|
||||
sourceSets {
|
||||
main.java.srcDirs += 'src/main/kotlin'
|
||||
|
||||
@@ -91,6 +92,7 @@ android {
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
coreLibraryDesugaringEnabled true
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
@@ -99,7 +101,7 @@ android {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId "com.carriez.flutter_hbb"
|
||||
minSdkVersion 22
|
||||
targetSdkVersion 33
|
||||
targetSdkVersion 36
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
}
|
||||
@@ -128,6 +130,7 @@ flutter {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4'
|
||||
implementation 'com.google.protobuf:protobuf-javalite:3.20.1'
|
||||
implementation "androidx.media:media:1.6.0"
|
||||
implementation 'com.github.getActivity:XXPermissions:18.5'
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="com.carriez.flutter_hbb">
|
||||
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:node="remove" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" tools:node="remove" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" tools:node="remove" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
@@ -26,7 +30,6 @@
|
||||
android:name=".MainApplication"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="RustDesk"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:roundIcon="@mipmap/ic_launcher"
|
||||
android:supportsRtl="true">
|
||||
|
||||
@@ -88,7 +91,12 @@
|
||||
<service
|
||||
android:name=".MainService"
|
||||
android:enabled="true"
|
||||
android:foregroundServiceType="mediaProjection" />
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse|mediaProjection|microphone">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="@string/foreground_service_special_use_subtype" />
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".FloatingWindowService"
|
||||
|
||||
@@ -18,7 +18,33 @@ const val AUDIO_SAMPLE_RATE = 48000
|
||||
const val AUDIO_CHANNEL_MASK = AudioFormat.CHANNEL_IN_STEREO
|
||||
|
||||
class AudioRecordHandle(private var context: Context, private var isVideoStart: ()->Boolean, private var isAudioStart: ()->Boolean) {
|
||||
private val logTag = "LOG_AUDIO_RECORD_HANDLE"
|
||||
companion object {
|
||||
private const val LOG_TAG = "LOG_AUDIO_RECORD_HANDLE"
|
||||
private const val NO_ACTIVE_PUBLISHERS = 0
|
||||
private var activeAudioFramePublishers = NO_ACTIVE_PUBLISHERS
|
||||
|
||||
@Synchronized
|
||||
private fun acquireAudioFramePublisher() {
|
||||
if (activeAudioFramePublishers == NO_ACTIVE_PUBLISHERS) {
|
||||
FFI.setFrameRawEnable("audio", true)
|
||||
}
|
||||
activeAudioFramePublishers++
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun releaseAudioFramePublisher() {
|
||||
if (activeAudioFramePublishers == NO_ACTIVE_PUBLISHERS) {
|
||||
Log.e(LOG_TAG, "No active audio frame publisher to release")
|
||||
return
|
||||
}
|
||||
activeAudioFramePublishers--
|
||||
if (activeAudioFramePublishers == NO_ACTIVE_PUBLISHERS) {
|
||||
FFI.setFrameRawEnable("audio", false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val logTag = LOG_TAG
|
||||
|
||||
private var audioRecorder: AudioRecord? = null
|
||||
private var audioReader: AudioReader? = null
|
||||
@@ -79,48 +105,94 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart:
|
||||
return
|
||||
}
|
||||
// read f32 to byte , length * 4
|
||||
minBufferSize = 2 * 4 * AudioRecord.getMinBufferSize(
|
||||
val bufferSize = 2 * 4 * AudioRecord.getMinBufferSize(
|
||||
AUDIO_SAMPLE_RATE,
|
||||
AUDIO_CHANNEL_MASK,
|
||||
AUDIO_ENCODING
|
||||
)
|
||||
if (minBufferSize == 0) {
|
||||
if (bufferSize <= 0) {
|
||||
Log.d(logTag, "get min buffer size fail!")
|
||||
return
|
||||
}
|
||||
audioReader = AudioReader(minBufferSize, 4)
|
||||
audioReader = AudioReader(bufferSize, 4)
|
||||
minBufferSize = bufferSize
|
||||
Log.d(logTag, "init audioData len:$minBufferSize")
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
fun startAudioRecorder() {
|
||||
checkAudioReader()
|
||||
if (audioReader != null && audioRecorder != null && minBufferSize != 0) {
|
||||
try {
|
||||
FFI.setFrameRawEnable("audio", true)
|
||||
audioRecorder!!.startRecording()
|
||||
audioRecordStat = true
|
||||
audioThread = thread {
|
||||
while (audioRecordStat) {
|
||||
audioReader!!.readSync(audioRecorder!!)?.let {
|
||||
FFI.onAudioFrameUpdate(it)
|
||||
}
|
||||
}
|
||||
// let's release here rather than onDestroy to avoid threading issue
|
||||
audioRecorder?.release()
|
||||
audioRecorder = null
|
||||
minBufferSize = 0
|
||||
FFI.setFrameRawEnable("audio", false)
|
||||
Log.d(logTag, "Exit audio thread")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d(logTag, "startAudioRecorder fail:$e")
|
||||
private fun releaseRecorder(recorder: AudioRecord) {
|
||||
try {
|
||||
recorder.release()
|
||||
} finally {
|
||||
if (audioRecorder === recorder) {
|
||||
audioRecorder = null
|
||||
}
|
||||
} else {
|
||||
Log.d(logTag, "startAudioRecorder fail")
|
||||
}
|
||||
}
|
||||
|
||||
private fun captureAudio(reader: AudioReader, recorder: AudioRecord) {
|
||||
try {
|
||||
while (audioRecordStat) {
|
||||
reader.readSync(recorder)?.let {
|
||||
FFI.onAudioFrameUpdate(it)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
minBufferSize = 0
|
||||
try {
|
||||
releaseRecorder(recorder)
|
||||
} finally {
|
||||
releaseAudioFramePublisher()
|
||||
Log.d(logTag, "Exit audio thread")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
fun startAudioRecorder(): Boolean {
|
||||
val recorder = audioRecorder
|
||||
if (recorder == null) {
|
||||
Log.d(logTag, "startAudioRecorder fail")
|
||||
return false
|
||||
}
|
||||
var audioFramePublisherAcquired = false
|
||||
return try {
|
||||
checkAudioReader()
|
||||
val reader = audioReader
|
||||
if (reader == null || minBufferSize == 0) {
|
||||
releaseRecorder(recorder)
|
||||
Log.d(logTag, "startAudioRecorder fail")
|
||||
return false
|
||||
}
|
||||
recorder.startRecording()
|
||||
if (recorder.recordingState != AudioRecord.RECORDSTATE_RECORDING) {
|
||||
throw IllegalStateException("AudioRecord failed to enter recording state")
|
||||
}
|
||||
audioRecordStat = true
|
||||
val captureThread = thread(start = false) { captureAudio(reader, recorder) }
|
||||
acquireAudioFramePublisher()
|
||||
audioFramePublisherAcquired = true
|
||||
audioThread = captureThread
|
||||
captureThread.start()
|
||||
true
|
||||
} catch (error: Exception) {
|
||||
audioRecordStat = false
|
||||
audioThread = null
|
||||
Log.e(logTag, "startAudioRecorder fail", error)
|
||||
try {
|
||||
releaseRecorder(recorder)
|
||||
} finally {
|
||||
if (audioFramePublisherAcquired) {
|
||||
releaseAudioFramePublisher()
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun isVoiceCallActive(): Boolean {
|
||||
return audioRecorder?.audioSource == MediaRecorder.AudioSource.VOICE_COMMUNICATION
|
||||
}
|
||||
|
||||
fun onVoiceCallStarted(mediaProjection: MediaProjection?): Boolean {
|
||||
if (!isSupportVoiceCall()) {
|
||||
return false
|
||||
@@ -137,11 +209,9 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart:
|
||||
if (!isSupportVoiceCall()) {
|
||||
return true
|
||||
}
|
||||
if (isVideoStart()) {
|
||||
switchOutVoiceCall(mediaProjection)
|
||||
}
|
||||
val switched = !isVideoStart() || switchOutVoiceCall(mediaProjection)
|
||||
tryReleaseAudio()
|
||||
return true
|
||||
return switched
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
@@ -159,8 +229,7 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart:
|
||||
Log.e(logTag, "createAudioRecorder fail")
|
||||
return false
|
||||
}
|
||||
startAudioRecorder()
|
||||
return true
|
||||
return startAudioRecorder()
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
@@ -177,8 +246,7 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart:
|
||||
Log.e(logTag, "createAudioRecorder fail")
|
||||
return false
|
||||
}
|
||||
startAudioRecorder()
|
||||
return true
|
||||
return startAudioRecorder()
|
||||
}
|
||||
|
||||
fun tryReleaseAudio() {
|
||||
|
||||
@@ -9,6 +9,7 @@ package com.carriez.flutter_hbb
|
||||
|
||||
import ffi.FFI
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
@@ -24,6 +25,10 @@ import android.media.MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface
|
||||
import android.media.MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar
|
||||
import android.media.MediaCodecList
|
||||
import android.media.MediaFormat
|
||||
import android.net.Uri
|
||||
import android.provider.DocumentsContract
|
||||
import android.provider.OpenableColumns
|
||||
import android.webkit.MimeTypeMap
|
||||
import android.util.DisplayMetrics
|
||||
import androidx.annotation.RequiresApi
|
||||
import org.json.JSONArray
|
||||
@@ -33,6 +38,9 @@ import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import kotlin.concurrent.thread
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
|
||||
|
||||
class MainActivity : FlutterActivity() {
|
||||
@@ -46,6 +54,23 @@ class MainActivity : FlutterActivity() {
|
||||
private val channelTag = "mChannel"
|
||||
private val logTag = "mMainActivity"
|
||||
private var mainService: MainService? = null
|
||||
private sealed class PendingPicker {
|
||||
data class ImportFiles(val result: MethodChannel.Result) : PendingPicker()
|
||||
data class ExportFile(val source: File, val result: MethodChannel.Result) : PendingPicker()
|
||||
data class ImportDirectory(val result: MethodChannel.Result) : PendingPicker()
|
||||
data class ExportFiles(
|
||||
val sources: List<File>,
|
||||
val rejected: Int,
|
||||
val result: MethodChannel.Result
|
||||
) : PendingPicker()
|
||||
}
|
||||
|
||||
private data class ExportSource(
|
||||
val file: File,
|
||||
val children: List<ExportSource>?
|
||||
)
|
||||
|
||||
private var pendingPicker: PendingPicker? = null
|
||||
|
||||
private var isAudioStart = false
|
||||
private val audioRecordHandle = AudioRecordHandle(this, { false }, { isAudioStart })
|
||||
@@ -91,6 +116,108 @@ class MainActivity : FlutterActivity() {
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
if (requestCode == REQ_IMPORT_FILES) {
|
||||
val pending = pendingPicker as? PendingPicker.ImportFiles ?: return
|
||||
pendingPicker = null
|
||||
if (resultCode != Activity.RESULT_OK || data == null) {
|
||||
pending.result.success(emptyList<Map<String, String>>())
|
||||
return
|
||||
}
|
||||
|
||||
val uris = linkedSetOf<Uri>()
|
||||
data.data?.let { uris.add(it) }
|
||||
data.clipData?.let { clipData ->
|
||||
for (index in 0 until clipData.itemCount) {
|
||||
uris.add(clipData.getItemAt(index).uri)
|
||||
}
|
||||
}
|
||||
thread {
|
||||
val files = uris.map { uri ->
|
||||
mapOf(
|
||||
"uri" to uri.toString(),
|
||||
"name" to (displayName(uri) ?: uri.lastPathSegment.orEmpty())
|
||||
)
|
||||
}
|
||||
runOnUiThread { pending.result.success(files) }
|
||||
}
|
||||
return
|
||||
}
|
||||
if (requestCode == REQ_EXPORT_FILE) {
|
||||
val pending = pendingPicker as? PendingPicker.ExportFile ?: return
|
||||
pendingPicker = null
|
||||
val destination = data?.data
|
||||
|
||||
if (resultCode != Activity.RESULT_OK || destination == null) {
|
||||
pending.result.success(false)
|
||||
return
|
||||
}
|
||||
|
||||
thread {
|
||||
try {
|
||||
FileInputStream(pending.source).use { input ->
|
||||
contentResolver.openOutputStream(destination, "wt")?.use { output ->
|
||||
input.copyTo(output)
|
||||
} ?: throw IllegalStateException("Unable to open the selected destination")
|
||||
}
|
||||
runOnUiThread { pending.result.success(true) }
|
||||
} catch (e: Exception) {
|
||||
Log.e(logTag, "Failed to export file", e)
|
||||
runOnUiThread {
|
||||
pending.result.error("export_failed", e.message, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (requestCode == REQ_IMPORT_DIRECTORY) {
|
||||
val pending = pendingPicker as? PendingPicker.ImportDirectory ?: return
|
||||
pendingPicker = null
|
||||
val treeUri = data?.data
|
||||
if (resultCode != Activity.RESULT_OK || treeUri == null) {
|
||||
pending.result.success(null)
|
||||
return
|
||||
}
|
||||
thread {
|
||||
val selected = mapOf(
|
||||
"uri" to treeUri.toString(),
|
||||
"name" to (treeDisplayName(treeUri) ?: "Imported")
|
||||
)
|
||||
runOnUiThread { pending.result.success(selected) }
|
||||
}
|
||||
return
|
||||
}
|
||||
if (requestCode == REQ_EXPORT_FILES) {
|
||||
val pending = pendingPicker as? PendingPicker.ExportFiles ?: return
|
||||
pendingPicker = null
|
||||
val treeUri = data?.data
|
||||
if (resultCode != Activity.RESULT_OK || treeUri == null) {
|
||||
pending.result.success(null)
|
||||
return
|
||||
}
|
||||
thread {
|
||||
var exported = 0
|
||||
var failed = pending.rejected
|
||||
var processed = 0
|
||||
try {
|
||||
val sources = pending.sources.map { snapshotExportSource(it) }
|
||||
val rootDocId = DocumentsContract.getTreeDocumentId(treeUri)
|
||||
sources.forEach { source ->
|
||||
val ok = source?.let {
|
||||
copyExportSourceToTree(treeUri, rootDocId, it)
|
||||
} ?: false
|
||||
if (ok) exported++ else failed++
|
||||
processed++
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(logTag, "Failed to export selected files", e)
|
||||
failed += pending.sources.size - processed
|
||||
}
|
||||
runOnUiThread {
|
||||
pending.result.success(mapOf("exported" to exported, "failed" to failed))
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (requestCode == REQ_INVOKE_PERMISSION_ACTIVITY_MEDIA_PROJECTION && resultCode == RES_FAILED) {
|
||||
flutterMethodChannel?.invokeMethod("on_media_projection_canceled", null)
|
||||
}
|
||||
@@ -267,6 +394,242 @@ class MainActivity : FlutterActivity() {
|
||||
result.success(false)
|
||||
}
|
||||
}
|
||||
PICK_IMPORT_FILES -> {
|
||||
if (pendingPicker != null) {
|
||||
result.error("picker_in_progress", "Another document picker is already open", null)
|
||||
} else {
|
||||
pendingPicker = PendingPicker.ImportFiles(result)
|
||||
try {
|
||||
startActivityForResult(
|
||||
Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
type = "*/*"
|
||||
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
|
||||
},
|
||||
REQ_IMPORT_FILES
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
pendingPicker = null
|
||||
result.error("picker_unavailable", e.message, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
IMPORT_FILE -> {
|
||||
val arguments = call.arguments as? Map<*, *>
|
||||
val uri = (arguments?.get("uri") as? String)?.let {
|
||||
runCatching { Uri.parse(it) }.getOrNull()
|
||||
}
|
||||
val path = arguments?.get("path") as? String
|
||||
val overwrite = arguments?.get("overwrite") as? Boolean ?: false
|
||||
val destination = path?.let { canonicalAppScopedFile(it) }
|
||||
|
||||
if (uri?.scheme != "content") {
|
||||
result.error("invalid_uri", "The selected document URI is invalid", null)
|
||||
} else if (destination == null ||
|
||||
destination.isDirectory ||
|
||||
destination.parentFile?.isDirectory != true) {
|
||||
result.error("invalid_destination", "The destination is outside app-scoped storage", null)
|
||||
} else {
|
||||
thread {
|
||||
var temporary: File? = null
|
||||
var reservedDestination = false
|
||||
var errorCode = "import_failed"
|
||||
try {
|
||||
val temporaryFile = File.createTempFile(
|
||||
".rustdesk-import-",
|
||||
".tmp",
|
||||
destination.parentFile
|
||||
)
|
||||
temporary = temporaryFile
|
||||
contentResolver.openInputStream(uri)?.use { input ->
|
||||
FileOutputStream(temporaryFile).use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
} ?: throw IllegalStateException("Unable to open the selected document")
|
||||
if (!overwrite) {
|
||||
reservedDestination = destination.createNewFile()
|
||||
if (!reservedDestination) {
|
||||
throw IllegalStateException("The destination already exists")
|
||||
}
|
||||
}
|
||||
if (!temporaryFile.renameTo(destination)) {
|
||||
if (reservedDestination) {
|
||||
destination.delete()
|
||||
}
|
||||
errorCode = "rename_failed"
|
||||
throw IllegalStateException("Unable to replace the destination")
|
||||
}
|
||||
runOnUiThread { result.success(true) }
|
||||
} catch (e: Exception) {
|
||||
Log.e(logTag, "Failed to import file", e)
|
||||
runOnUiThread {
|
||||
result.error(errorCode, e.message, null)
|
||||
}
|
||||
} finally {
|
||||
temporary?.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
EXPORT_FILE -> {
|
||||
val path = (call.arguments as? Map<*, *>)?.get("path") as? String
|
||||
val source = path?.let { canonicalExportSource(it) }
|
||||
|
||||
if (source?.isFile != true) {
|
||||
result.error("invalid_source", "The file is outside app-scoped storage", null)
|
||||
} else if (pendingPicker != null) {
|
||||
result.error("picker_in_progress", "Another document picker is already open", null)
|
||||
} else {
|
||||
val mimeType = MimeTypeMap.getSingleton()
|
||||
.getMimeTypeFromExtension(source.extension.lowercase())
|
||||
?: "application/octet-stream"
|
||||
pendingPicker = PendingPicker.ExportFile(source, result)
|
||||
try {
|
||||
startActivityForResult(
|
||||
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
type = mimeType
|
||||
putExtra(Intent.EXTRA_TITLE, source.name)
|
||||
},
|
||||
REQ_EXPORT_FILE
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
pendingPicker = null
|
||||
result.error("picker_unavailable", e.message, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
PICK_IMPORT_DIRECTORY -> {
|
||||
if (pendingPicker != null) {
|
||||
result.error("picker_in_progress", "Another document picker is already open", null)
|
||||
} else {
|
||||
pendingPicker = PendingPicker.ImportDirectory(result)
|
||||
try {
|
||||
startActivityForResult(
|
||||
Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
|
||||
putExtra(Intent.EXTRA_TITLE, "Select the folder to import")
|
||||
},
|
||||
REQ_IMPORT_DIRECTORY
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
pendingPicker = null
|
||||
result.error("picker_unavailable", e.message, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
IMPORT_DIRECTORY -> {
|
||||
val arguments = call.arguments as? Map<*, *>
|
||||
val uri = (arguments?.get("uri") as? String)?.let {
|
||||
runCatching { Uri.parse(it) }.getOrNull()
|
||||
}
|
||||
val path = arguments?.get("path") as? String
|
||||
val overwrite = arguments?.get("overwrite") as? Boolean ?: false
|
||||
val destination = path?.let { canonicalAppScopedFile(it) }
|
||||
|
||||
if (uri?.scheme != "content") {
|
||||
result.error("invalid_uri", "The selected document URI is invalid", null)
|
||||
} else if (destination == null ||
|
||||
destination.parentFile?.isDirectory != true ||
|
||||
(destination.exists() && !destination.isDirectory)) {
|
||||
result.error("invalid_destination", "The destination is outside app-scoped storage", null)
|
||||
} else {
|
||||
thread {
|
||||
var temporary: File? = null
|
||||
var backup: File? = null
|
||||
val ok = try {
|
||||
val parent = destination.parentFile
|
||||
?: throw IllegalStateException("The destination has no parent")
|
||||
temporary = File.createTempFile(
|
||||
".rustdesk-import-dir-",
|
||||
".tmp",
|
||||
parent
|
||||
).also {
|
||||
if (!it.delete() || !it.mkdir()) {
|
||||
throw IllegalStateException("Unable to create a temporary folder")
|
||||
}
|
||||
}
|
||||
if (!copyDocumentTreeToFile(uri, temporary!!)) {
|
||||
throw IllegalStateException("Unable to read all folder contents")
|
||||
}
|
||||
if (destination.exists()) {
|
||||
if (!overwrite) {
|
||||
throw IllegalStateException("The destination already exists")
|
||||
}
|
||||
val backupFile = File.createTempFile(
|
||||
".rustdesk-import-backup-",
|
||||
".tmp",
|
||||
parent
|
||||
)
|
||||
if (!backupFile.delete()) {
|
||||
throw IllegalStateException("Unable to prepare the destination backup")
|
||||
}
|
||||
backup = backupFile
|
||||
if (!destination.renameTo(backupFile)) {
|
||||
throw IllegalStateException("Unable to replace the destination")
|
||||
}
|
||||
}
|
||||
if (!temporary!!.renameTo(destination)) {
|
||||
val destinationBackup = backup
|
||||
if (destinationBackup != null &&
|
||||
!destinationBackup.renameTo(destination)
|
||||
) {
|
||||
throw IllegalStateException(
|
||||
"Unable to move the imported folder and restore " +
|
||||
"the destination from $destinationBackup"
|
||||
)
|
||||
}
|
||||
throw IllegalStateException("Unable to move the imported folder")
|
||||
}
|
||||
temporary = null
|
||||
val destinationBackup = backup
|
||||
if (destinationBackup != null &&
|
||||
!destinationBackup.deleteRecursively()
|
||||
) {
|
||||
throw IllegalStateException(
|
||||
"Unable to remove the destination backup: $destinationBackup"
|
||||
)
|
||||
}
|
||||
backup = null
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(logTag, "Failed to import directory", e)
|
||||
false
|
||||
} finally {
|
||||
temporary?.deleteRecursively()
|
||||
}
|
||||
runOnUiThread { result.success(ok) }
|
||||
}
|
||||
}
|
||||
}
|
||||
EXPORT_FILES -> {
|
||||
val paths = (call.arguments as? Map<*, *>)?.get("paths") as? List<*>
|
||||
if (paths.isNullOrEmpty()) {
|
||||
result.error("invalid_source", "The selected files are outside app-scoped storage", null)
|
||||
} else {
|
||||
val sources = paths.mapNotNull {
|
||||
(it as? String)?.let(::canonicalExportSource)
|
||||
}
|
||||
val rejected = paths.size - sources.size
|
||||
if (sources.isEmpty()) {
|
||||
result.success(mapOf("exported" to 0, "failed" to rejected))
|
||||
} else if (pendingPicker != null) {
|
||||
result.error("picker_in_progress", "Another document picker is already open", null)
|
||||
} else {
|
||||
pendingPicker = PendingPicker.ExportFiles(sources, rejected, result)
|
||||
try {
|
||||
startActivityForResult(
|
||||
Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
|
||||
putExtra(Intent.EXTRA_TITLE, "Select the destination folder")
|
||||
},
|
||||
REQ_EXPORT_FILES
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
pendingPicker = null
|
||||
result.error("picker_unavailable", e.message, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
GET_VALUE -> {
|
||||
if (call.arguments is String) {
|
||||
if (call.arguments == KEY_IS_SUPPORT_VOICE_CALL) {
|
||||
@@ -291,6 +654,228 @@ class MainActivity : FlutterActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun canonicalAppScopedFile(path: String): File? {
|
||||
val file = runCatching { File(path).canonicalFile }.getOrNull() ?: return null
|
||||
val allowedRoots = listOfNotNull(filesDir, getExternalFilesDir(null)).mapNotNull {
|
||||
runCatching { it.canonicalFile }.getOrNull()
|
||||
}
|
||||
return file.takeIf { candidate ->
|
||||
allowedRoots.any { root ->
|
||||
candidate == root || candidate.path.startsWith(root.path + File.separator)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun canonicalExportSource(path: String): File? {
|
||||
val original = File(path).absoluteFile
|
||||
val canonical = canonicalAppScopedFile(path) ?: return null
|
||||
return canonical.takeIf {
|
||||
original.path == canonical.path && (canonical.isFile || canonical.isDirectory)
|
||||
}
|
||||
}
|
||||
|
||||
private fun snapshotExportSource(source: File): ExportSource? {
|
||||
val safeSource = canonicalExportSource(source.path) ?: return null
|
||||
if (safeSource.isFile) return ExportSource(safeSource, null)
|
||||
val sourceChildren = safeSource.listFiles() ?: return null
|
||||
val children = ArrayList<ExportSource>(sourceChildren.size)
|
||||
for (child in sourceChildren) {
|
||||
val snapshot = snapshotExportSource(child) ?: return null
|
||||
children.add(snapshot)
|
||||
}
|
||||
return ExportSource(safeSource, children)
|
||||
}
|
||||
|
||||
private fun copyExportSourceToTree(
|
||||
treeUri: Uri,
|
||||
parentDocId: String,
|
||||
source: ExportSource
|
||||
): Boolean {
|
||||
val children = source.children
|
||||
return if (children == null) {
|
||||
copyFileToTree(treeUri, parentDocId, source.file)
|
||||
} else {
|
||||
copyDirToTree(treeUri, parentDocId, source)
|
||||
}
|
||||
}
|
||||
|
||||
private fun treeDisplayName(treeUri: Uri): String? {
|
||||
return try {
|
||||
val rootDocId = DocumentsContract.getTreeDocumentId(treeUri)
|
||||
val docUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, rootDocId)
|
||||
contentResolver.query(
|
||||
docUri,
|
||||
arrayOf(DocumentsContract.Document.COLUMN_DISPLAY_NAME),
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)?.use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null }
|
||||
} catch (e: Exception) {
|
||||
Log.w(logTag, "Failed to read selected folder name", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyDocumentTreeToFile(treeUri: Uri, destinationDir: File): Boolean {
|
||||
val rootDocId = DocumentsContract.getTreeDocumentId(treeUri)
|
||||
return copyChildrenToFile(treeUri, rootDocId, destinationDir)
|
||||
}
|
||||
|
||||
private fun copyChildrenToFile(
|
||||
treeUri: Uri,
|
||||
parentDocId: String,
|
||||
destinationDir: File
|
||||
): Boolean {
|
||||
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, parentDocId)
|
||||
var ok = true
|
||||
val destinationNames = HashSet<String>()
|
||||
val cursor = contentResolver.query(childrenUri, childColumns, null, null, null)
|
||||
?: return false
|
||||
cursor.use {
|
||||
while (cursor.moveToNext()) {
|
||||
val docId = cursor.getString(0)
|
||||
val name = cursor.getString(1)
|
||||
val mime = cursor.getString(2)
|
||||
val docUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, docId)
|
||||
if (name != null && !destinationNames.add(name)) {
|
||||
ok = false
|
||||
continue
|
||||
}
|
||||
val destination = safeDestinationChild(destinationDir, name)
|
||||
if (destination == null || destination.exists()) {
|
||||
ok = false
|
||||
continue
|
||||
}
|
||||
if (mime == DocumentsContract.Document.MIME_TYPE_DIR) {
|
||||
if (!destination.mkdirs() && !destination.isDirectory) {
|
||||
ok = false
|
||||
continue
|
||||
}
|
||||
if (!copyChildrenToFile(treeUri, docId, destination)) {
|
||||
ok = false
|
||||
}
|
||||
} else if (!copyDocumentToFile(docUri, destination)) {
|
||||
ok = false
|
||||
}
|
||||
}
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
private fun safeDestinationChild(destinationDir: File, name: String?): File? {
|
||||
if (name.isNullOrEmpty() || name == "." || name == ".." ||
|
||||
name.indexOf('\u0000') >= 0 || name.contains('/') || name.contains('\\')) {
|
||||
return null
|
||||
}
|
||||
val parent = runCatching { destinationDir.canonicalFile }.getOrNull() ?: return null
|
||||
val child = runCatching { File(parent, name).canonicalFile }.getOrNull() ?: return null
|
||||
return child.takeIf { it.path.startsWith(parent.path + File.separator) }
|
||||
}
|
||||
|
||||
private fun copyDocumentToFile(uri: Uri, destination: File): Boolean {
|
||||
return try {
|
||||
destination.parentFile?.mkdirs()
|
||||
if (destination.exists() && !destination.delete()) {
|
||||
return false
|
||||
}
|
||||
contentResolver.openInputStream(uri)?.use { input ->
|
||||
FileOutputStream(destination).use { output -> input.copyTo(output) }
|
||||
} != null
|
||||
} catch (e: Exception) {
|
||||
Log.e(logTag, "Failed to copy document to $destination", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyFileToTree(treeUri: Uri, parentDocId: String, source: File): Boolean {
|
||||
val safeSource = canonicalExportSource(source.path)?.takeIf { it.isFile } ?: return false
|
||||
return try {
|
||||
val mime = MimeTypeMap.getSingleton()
|
||||
.getMimeTypeFromExtension(safeSource.extension.lowercase())
|
||||
?: "application/octet-stream"
|
||||
val parentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, parentDocId)
|
||||
val docUri = DocumentsContract.createDocument(
|
||||
contentResolver,
|
||||
parentUri,
|
||||
mime,
|
||||
safeSource.name
|
||||
) ?: return false
|
||||
contentResolver.openOutputStream(docUri, "wt")?.use { output ->
|
||||
FileInputStream(safeSource).use { input -> input.copyTo(output) }
|
||||
} ?: return false
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(logTag, "Failed to export file $safeSource", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyDirToTree(
|
||||
treeUri: Uri,
|
||||
parentDocId: String,
|
||||
source: ExportSource
|
||||
): Boolean {
|
||||
val children = source.children ?: return false
|
||||
val safeSource = canonicalExportSource(source.file.path)?.takeIf { it.isDirectory }
|
||||
?: return false
|
||||
val parentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, parentDocId)
|
||||
var dirDocId = findChildDocId(treeUri, parentDocId, safeSource.name)
|
||||
if (dirDocId == null) {
|
||||
dirDocId = try {
|
||||
DocumentsContract.createDocument(
|
||||
contentResolver,
|
||||
parentUri,
|
||||
DocumentsContract.Document.MIME_TYPE_DIR,
|
||||
safeSource.name
|
||||
)?.let { DocumentsContract.getDocumentId(it) }
|
||||
} catch (e: Exception) {
|
||||
Log.e(logTag, "Failed to create folder ${safeSource.name}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
if (dirDocId == null) return false
|
||||
|
||||
var ok = true
|
||||
children.forEach { child ->
|
||||
val childOk = copyExportSourceToTree(treeUri, dirDocId, child)
|
||||
if (!childOk) ok = false
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
private fun findChildDocId(treeUri: Uri, parentDocId: String, name: String): String? {
|
||||
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, parentDocId)
|
||||
val cursor = contentResolver.query(childrenUri, childColumns, null, null, null)
|
||||
?: throw IllegalStateException("Unable to query destination folder")
|
||||
cursor.use {
|
||||
while (cursor.moveToNext()) {
|
||||
if (cursor.getString(1) == name &&
|
||||
cursor.getString(2) == DocumentsContract.Document.MIME_TYPE_DIR
|
||||
) {
|
||||
return cursor.getString(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private val childColumns = arrayOf(
|
||||
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
|
||||
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
|
||||
DocumentsContract.Document.COLUMN_MIME_TYPE
|
||||
)
|
||||
|
||||
private fun displayName(uri: Uri): String? {
|
||||
return try {
|
||||
contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) cursor.getString(0) else null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(logTag, "Failed to read selected document name", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun setCodecInfo() {
|
||||
val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS)
|
||||
val codecs = codecList.codecInfos
|
||||
|
||||
@@ -17,6 +17,7 @@ import android.app.PendingIntent.FLAG_UPDATE_CURRENT
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.content.res.Configuration
|
||||
import android.content.res.Configuration.ORIENTATION_LANDSCAPE
|
||||
import android.graphics.Color
|
||||
@@ -150,7 +151,7 @@ class MainService : Service() {
|
||||
if (incomingVoiceCall) {
|
||||
voiceCallRequestNotification(id, "Voice Call Request", username, peerId)
|
||||
} else {
|
||||
if (!audioRecordHandle.switchOutVoiceCall(mediaProjection)) {
|
||||
if (!switchOutVoiceCall()) {
|
||||
Log.e(logTag, "switchOutVoiceCall fail")
|
||||
MainActivity.flutterMethodChannel?.invokeMethod("msgbox", mapOf(
|
||||
"type" to "custom-nook-nocancel-hasclose-error",
|
||||
@@ -159,7 +160,7 @@ class MainService : Service() {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (!audioRecordHandle.switchToVoiceCall(mediaProjection)) {
|
||||
if (!switchToVoiceCall()) {
|
||||
Log.e(logTag, "switchToVoiceCall fail")
|
||||
MainActivity.flutterMethodChannel?.invokeMethod("msgbox", mapOf(
|
||||
"type" to "custom-nook-nocancel-hasclose-error",
|
||||
@@ -214,6 +215,19 @@ class MainService : Service() {
|
||||
|
||||
// video
|
||||
private var mediaProjection: MediaProjection? = null
|
||||
private var mediaProjectionCallback: MediaProjection.Callback? = null
|
||||
private var captureRestartPending = false
|
||||
private var captureRestartInVoiceCall = false
|
||||
private val mediaProjectionResultReceiver =
|
||||
object : ResultReceiver(Handler(Looper.getMainLooper())) {
|
||||
override fun onReceiveResult(resultCode: Int, resultData: Bundle?) {
|
||||
if (resultCode == RES_FAILED) {
|
||||
cancelMediaProjectionRecovery()
|
||||
}
|
||||
}
|
||||
}
|
||||
private var mediaProjectionForegroundService = false
|
||||
private var microphoneForegroundService = false
|
||||
private var surface: Surface? = null
|
||||
private val sendVP9Thread = Executors.newSingleThreadExecutor()
|
||||
private var videoEncoder: MediaCodec? = null
|
||||
@@ -243,7 +257,9 @@ class MainService : Service() {
|
||||
// keep the config dir same with flutter
|
||||
val prefs = applicationContext.getSharedPreferences(KEY_SHARED_PREFERENCES, FlutterActivity.MODE_PRIVATE)
|
||||
val configPath = prefs.getString(KEY_APP_DIR_CONFIG_PATH, "") ?: ""
|
||||
FFI.startServer(configPath, "")
|
||||
val homePath = applicationContext.getExternalFilesDir(null)?.absolutePath
|
||||
?: applicationContext.filesDir.absolutePath
|
||||
FFI.startServer(configPath, homePath, "")
|
||||
|
||||
createForegroundNotification()
|
||||
}
|
||||
@@ -337,8 +353,6 @@ class MainService : Service() {
|
||||
Log.d("whichService", "this service: ${Thread.currentThread()}")
|
||||
super.onStartCommand(intent, flags, startId)
|
||||
if (intent?.action == ACT_INIT_MEDIA_PROJECTION_AND_SERVICE) {
|
||||
createForegroundNotification()
|
||||
|
||||
if (intent.getBooleanExtra(EXT_INIT_FROM_BOOT, false)) {
|
||||
FFI.startService()
|
||||
}
|
||||
@@ -347,10 +361,7 @@ class MainService : Service() {
|
||||
getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
|
||||
|
||||
intent.getParcelableExtra<Intent>(EXT_MEDIA_PROJECTION_RES_INTENT)?.let {
|
||||
mediaProjection =
|
||||
mediaProjectionManager.getMediaProjection(Activity.RESULT_OK, it)
|
||||
checkMediaPermission()
|
||||
_isReady = true
|
||||
replaceMediaProjection(mediaProjectionManager, it)
|
||||
} ?: let {
|
||||
Log.d(logTag, "getParcelableExtra intent null, invoke requestMediaProjection")
|
||||
requestMediaProjection()
|
||||
@@ -364,14 +375,23 @@ class MainService : Service() {
|
||||
updateScreenInfo(newConfig.orientation)
|
||||
}
|
||||
|
||||
private fun requestMediaProjection() {
|
||||
private fun requestMediaProjection(recovery: Boolean = false) {
|
||||
val intent = Intent(this, PermissionRequestTransparentActivity::class.java).apply {
|
||||
action = ACT_REQUEST_MEDIA_PROJECTION
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
if (recovery) {
|
||||
putExtra(EXT_MEDIA_PROJECTION_RESULT_RECEIVER, mediaProjectionResultReceiver)
|
||||
}
|
||||
}
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun cancelMediaProjectionRecovery() {
|
||||
captureRestartPending = false
|
||||
captureRestartInVoiceCall = false
|
||||
}
|
||||
|
||||
@SuppressLint("WrongConstant")
|
||||
private fun createSurface(): Surface? {
|
||||
return if (useVP9) {
|
||||
@@ -405,15 +425,149 @@ class MainService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
fun onVoiceCallStarted(): Boolean {
|
||||
return audioRecordHandle.onVoiceCallStarted(mediaProjection)
|
||||
private fun releaseMediaProjection() {
|
||||
val projection = mediaProjection
|
||||
val callback = mediaProjectionCallback
|
||||
mediaProjection = null
|
||||
mediaProjectionCallback = null
|
||||
if (projection != null && callback != null) {
|
||||
projection.unregisterCallback(callback)
|
||||
}
|
||||
projection?.stop()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun handleMediaProjectionStopped(stoppedProjection: MediaProjection) {
|
||||
if (mediaProjection !== stoppedProjection) {
|
||||
return
|
||||
}
|
||||
Log.d(logTag, "MediaProjection stopped")
|
||||
setMediaProjectionForegroundService(false)
|
||||
stopCapture()
|
||||
virtualDisplay?.release()
|
||||
virtualDisplay = null
|
||||
mediaProjection = null
|
||||
mediaProjectionCallback = null
|
||||
_isReady = false
|
||||
checkMediaPermission()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun replaceMediaProjection(
|
||||
mediaProjectionManager: MediaProjectionManager,
|
||||
resultIntent: Intent,
|
||||
) {
|
||||
val wasCapturing = isStart
|
||||
val restartCapture = wasCapturing || captureRestartPending
|
||||
val restartInVoiceCall = if (wasCapturing) {
|
||||
audioRecordHandle.isVoiceCallActive()
|
||||
} else {
|
||||
captureRestartInVoiceCall
|
||||
}
|
||||
val hadProjection = mediaProjection != null
|
||||
if (!setMediaProjectionForegroundService(true)) {
|
||||
if (!hadProjection) {
|
||||
cancelMediaProjectionRecovery()
|
||||
_isReady = false
|
||||
checkMediaPermission()
|
||||
}
|
||||
return
|
||||
}
|
||||
val projection =
|
||||
mediaProjectionManager.getMediaProjection(Activity.RESULT_OK, resultIntent)
|
||||
if (projection == null) {
|
||||
if (!hadProjection) {
|
||||
cancelMediaProjectionRecovery()
|
||||
_isReady = false
|
||||
setMediaProjectionForegroundService(false)
|
||||
checkMediaPermission()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (wasCapturing) {
|
||||
stopCapture()
|
||||
}
|
||||
captureRestartPending = restartCapture
|
||||
virtualDisplay?.release()
|
||||
virtualDisplay = null
|
||||
releaseMediaProjection()
|
||||
val callback = object : MediaProjection.Callback() {
|
||||
override fun onStop() {
|
||||
handleMediaProjectionStopped(projection)
|
||||
}
|
||||
}
|
||||
projection.registerCallback(callback, Handler(Looper.getMainLooper()))
|
||||
mediaProjection = projection
|
||||
mediaProjectionCallback = callback
|
||||
_isReady = true
|
||||
checkMediaPermission()
|
||||
if (restartCapture) {
|
||||
captureRestartPending = false
|
||||
startCapture(restartInVoiceCall)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun startMicrophoneCapture(startAudio: () -> Boolean): Boolean {
|
||||
if (!setMicrophoneForegroundService(true)) {
|
||||
return false
|
||||
}
|
||||
if (startAudio()) {
|
||||
return true
|
||||
}
|
||||
setMicrophoneForegroundService(false)
|
||||
return false
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun stopMicrophoneCapture(stopAudio: () -> Boolean): Boolean {
|
||||
val stopped = stopAudio()
|
||||
val foregroundServiceUpdated = setMicrophoneForegroundService(false)
|
||||
return stopped && foregroundServiceUpdated
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun switchToVoiceCall(): Boolean {
|
||||
if (captureRestartPending) {
|
||||
captureRestartInVoiceCall = true
|
||||
}
|
||||
return startMicrophoneCapture {
|
||||
audioRecordHandle.switchToVoiceCall(mediaProjection)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun switchOutVoiceCall(): Boolean {
|
||||
captureRestartInVoiceCall = false
|
||||
val switched = audioRecordHandle.switchOutVoiceCall(mediaProjection)
|
||||
val foregroundServiceUpdated = setMicrophoneForegroundService(false)
|
||||
return switched && foregroundServiceUpdated
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun onVoiceCallStarted(): Boolean {
|
||||
if (captureRestartPending) {
|
||||
captureRestartInVoiceCall = true
|
||||
}
|
||||
return startMicrophoneCapture {
|
||||
audioRecordHandle.onVoiceCallStarted(mediaProjection)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun onVoiceCallClosed(): Boolean {
|
||||
return audioRecordHandle.onVoiceCallClosed(mediaProjection)
|
||||
captureRestartInVoiceCall = false
|
||||
return stopMicrophoneCapture {
|
||||
audioRecordHandle.onVoiceCallClosed(mediaProjection)
|
||||
}
|
||||
}
|
||||
|
||||
fun startCapture(): Boolean {
|
||||
return startCapture(false)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun startCapture(inVoiceCall: Boolean): Boolean {
|
||||
if (isStart) {
|
||||
return true
|
||||
}
|
||||
@@ -421,25 +575,35 @@ class MainService : Service() {
|
||||
Log.w(logTag, "startCapture fail,mediaProjection is null")
|
||||
return false
|
||||
}
|
||||
captureRestartInVoiceCall = inVoiceCall
|
||||
|
||||
updateScreenInfo(resources.configuration.orientation)
|
||||
Log.d(logTag, "Start Capture")
|
||||
surface = createSurface()
|
||||
|
||||
if (useVP9) {
|
||||
val videoStarted = if (useVP9) {
|
||||
startVP9VideoRecorder(mediaProjection!!)
|
||||
} else {
|
||||
startRawVideoRecorder(mediaProjection!!)
|
||||
}
|
||||
if (!videoStarted) {
|
||||
if (!captureRestartPending) {
|
||||
captureRestartInVoiceCall = false
|
||||
}
|
||||
releaseFailedVideoCapture()
|
||||
return false
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
if (!audioRecordHandle.createAudioRecorder(false, mediaProjection)) {
|
||||
Log.d(logTag, "createAudioRecorder fail")
|
||||
val audioStarted = if (inVoiceCall) {
|
||||
switchToVoiceCall()
|
||||
} else {
|
||||
Log.d(logTag, "audio recorder start")
|
||||
audioRecordHandle.startAudioRecorder()
|
||||
audioRecordHandle.createAudioRecorder(false, mediaProjection) &&
|
||||
audioRecordHandle.startAudioRecorder()
|
||||
}
|
||||
Log.d(logTag, if (audioStarted) "audio recorder start" else "audio recorder start failed")
|
||||
}
|
||||
captureRestartInVoiceCall = false
|
||||
checkMediaPermission()
|
||||
_isStart = true
|
||||
FFI.setFrameRawEnable("video",true)
|
||||
@@ -447,9 +611,24 @@ class MainService : Service() {
|
||||
return true
|
||||
}
|
||||
|
||||
private fun releaseFailedVideoCapture() {
|
||||
imageReader?.close()
|
||||
imageReader = null
|
||||
videoEncoder?.let {
|
||||
it.signalEndOfInputStream()
|
||||
it.stop()
|
||||
it.release()
|
||||
}
|
||||
videoEncoder = null
|
||||
surface?.release()
|
||||
surface = null
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun stopCapture() {
|
||||
Log.d(logTag, "Stop Capture")
|
||||
captureRestartPending = false
|
||||
captureRestartInVoiceCall = false
|
||||
FFI.setFrameRawEnable("video",false)
|
||||
_isStart = false
|
||||
MainActivity.rdClipboardManager?.setCaptureStarted(_isStart)
|
||||
@@ -480,8 +659,11 @@ class MainService : Service() {
|
||||
surface?.release()
|
||||
|
||||
// release audio
|
||||
_isAudioStart = false
|
||||
audioRecordHandle.tryReleaseAudio()
|
||||
stopMicrophoneCapture {
|
||||
_isAudioStart = false
|
||||
audioRecordHandle.tryReleaseAudio()
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
@@ -496,7 +678,9 @@ class MainService : Service() {
|
||||
virtualDisplay = null
|
||||
}
|
||||
|
||||
mediaProjection = null
|
||||
releaseMediaProjection()
|
||||
mediaProjectionForegroundService = false
|
||||
microphoneForegroundService = false
|
||||
checkMediaPermission()
|
||||
stopForeground(true)
|
||||
stopService(Intent(this, FloatingWindowService::class.java))
|
||||
@@ -519,49 +703,70 @@ class MainService : Service() {
|
||||
return isReady
|
||||
}
|
||||
|
||||
private fun startRawVideoRecorder(mp: MediaProjection) {
|
||||
private fun startRawVideoRecorder(mp: MediaProjection): Boolean {
|
||||
Log.d(logTag, "startRawVideoRecorder,screen info:$SCREEN_INFO")
|
||||
if (surface == null) {
|
||||
val captureSurface = surface
|
||||
if (captureSurface == null) {
|
||||
Log.d(logTag, "startRawVideoRecorder failed,surface is null")
|
||||
return
|
||||
return false
|
||||
}
|
||||
createOrSetVirtualDisplay(mp, surface!!)
|
||||
return createOrSetVirtualDisplay(mp, captureSurface)
|
||||
}
|
||||
|
||||
private fun startVP9VideoRecorder(mp: MediaProjection) {
|
||||
private fun startVP9VideoRecorder(mp: MediaProjection): Boolean {
|
||||
createMediaCodec()
|
||||
videoEncoder?.let {
|
||||
surface = it.createInputSurface()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
surface!!.setFrameRate(1F, FRAME_RATE_COMPATIBILITY_DEFAULT)
|
||||
}
|
||||
it.setCallback(cb)
|
||||
it.start()
|
||||
createOrSetVirtualDisplay(mp, surface!!)
|
||||
val encoder = videoEncoder ?: return false
|
||||
val inputSurface = encoder.createInputSurface()
|
||||
surface = inputSurface
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
inputSurface.setFrameRate(1F, FRAME_RATE_COMPATIBILITY_DEFAULT)
|
||||
}
|
||||
encoder.setCallback(cb)
|
||||
encoder.start()
|
||||
return createOrSetVirtualDisplay(mp, inputSurface)
|
||||
}
|
||||
|
||||
// https://github.com/bk138/droidVNC-NG/blob/b79af62db5a1c08ed94e6a91464859ffed6f4e97/app/src/main/java/net/christianbeier/droidvnc_ng/MediaProjectionService.java#L250
|
||||
// Reuse virtualDisplay if it exists, to avoid media projection confirmation dialog every connection.
|
||||
private fun createOrSetVirtualDisplay(mp: MediaProjection, s: Surface) {
|
||||
try {
|
||||
virtualDisplay?.let {
|
||||
it.resize(SCREEN_INFO.width, SCREEN_INFO.height, SCREEN_INFO.dpi)
|
||||
it.setSurface(s)
|
||||
} ?: let {
|
||||
virtualDisplay = mp.createVirtualDisplay(
|
||||
private fun createOrSetVirtualDisplay(mp: MediaProjection, s: Surface): Boolean {
|
||||
return try {
|
||||
val existingDisplay = virtualDisplay
|
||||
if (existingDisplay != null) {
|
||||
existingDisplay.resize(SCREEN_INFO.width, SCREEN_INFO.height, SCREEN_INFO.dpi)
|
||||
existingDisplay.setSurface(s)
|
||||
true
|
||||
} else {
|
||||
val display = mp.createVirtualDisplay(
|
||||
"RustDeskVD",
|
||||
SCREEN_INFO.width, SCREEN_INFO.height, SCREEN_INFO.dpi, VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
|
||||
s, null, null
|
||||
)
|
||||
if (display == null) {
|
||||
Log.e(logTag, "createOrSetVirtualDisplay failed")
|
||||
handleVirtualDisplayFailure()
|
||||
} else {
|
||||
virtualDisplay = display
|
||||
true
|
||||
}
|
||||
}
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(logTag, "createOrSetVirtualDisplay: got SecurityException, re-requesting confirmation");
|
||||
// This initiates a prompt dialog for the user to confirm screen projection.
|
||||
requestMediaProjection()
|
||||
Log.w(logTag, "createOrSetVirtualDisplay: got SecurityException", e)
|
||||
handleVirtualDisplayFailure()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleVirtualDisplayFailure(): Boolean {
|
||||
captureRestartPending = true
|
||||
virtualDisplay?.release()
|
||||
virtualDisplay = null
|
||||
releaseMediaProjection()
|
||||
setMediaProjectionForegroundService(false)
|
||||
_isReady = false
|
||||
checkMediaPermission()
|
||||
requestMediaProjection(true)
|
||||
return false
|
||||
}
|
||||
|
||||
private val cb: MediaCodec.Callback = object : MediaCodec.Callback() {
|
||||
override fun onInputBufferAvailable(codec: MediaCodec, index: Int) {}
|
||||
override fun onOutputFormatChanged(codec: MediaCodec, format: MediaFormat) {}
|
||||
@@ -652,7 +857,63 @@ class MainService : Service() {
|
||||
.setColor(ContextCompat.getColor(this, R.color.primary))
|
||||
.setWhen(System.currentTimeMillis())
|
||||
.build()
|
||||
startForeground(DEFAULT_NOTIFY_ID, notification)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(DEFAULT_NOTIFY_ID, notification, foregroundServiceType())
|
||||
} else {
|
||||
startForeground(DEFAULT_NOTIFY_ID, notification)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
private fun foregroundServiceType(): Int {
|
||||
var serviceType = ServiceInfo.FOREGROUND_SERVICE_TYPE_NONE
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
// Keep a valid FGS type while the unattended host is idle and no capture type is active.
|
||||
serviceType = serviceType or ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
|
||||
}
|
||||
if (mediaProjectionForegroundService) {
|
||||
serviceType = serviceType or ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && microphoneForegroundService) {
|
||||
serviceType = serviceType or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
|
||||
}
|
||||
return serviceType
|
||||
}
|
||||
|
||||
private fun setMediaProjectionForegroundService(enabled: Boolean): Boolean {
|
||||
return updateForegroundServiceTypes(enabled, microphoneForegroundService)
|
||||
}
|
||||
|
||||
private fun setMicrophoneForegroundService(enabled: Boolean): Boolean {
|
||||
return updateForegroundServiceTypes(mediaProjectionForegroundService, enabled)
|
||||
}
|
||||
|
||||
private fun updateForegroundServiceTypes(
|
||||
mediaProjectionEnabled: Boolean,
|
||||
microphoneEnabled: Boolean,
|
||||
): Boolean {
|
||||
if (mediaProjectionForegroundService == mediaProjectionEnabled &&
|
||||
microphoneForegroundService == microphoneEnabled) {
|
||||
return true
|
||||
}
|
||||
val previousMediaProjection = mediaProjectionForegroundService
|
||||
val previousMicrophone = microphoneForegroundService
|
||||
mediaProjectionForegroundService = mediaProjectionEnabled
|
||||
microphoneForegroundService = microphoneEnabled
|
||||
return try {
|
||||
createForegroundNotification()
|
||||
true
|
||||
} catch (error: SecurityException) {
|
||||
mediaProjectionForegroundService = previousMediaProjection
|
||||
microphoneForegroundService = previousMicrophone
|
||||
Log.e(logTag, "Failed to update foreground service types", error)
|
||||
false
|
||||
} catch (error: IllegalStateException) {
|
||||
mediaProjectionForegroundService = previousMediaProjection
|
||||
microphoneForegroundService = previousMicrophone
|
||||
Log.e(logTag, "Failed to update foreground service types", error)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun loginRequestNotification(
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.content.Intent
|
||||
import android.media.projection.MediaProjectionManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.ResultReceiver
|
||||
import android.util.Log
|
||||
|
||||
class PermissionRequestTransparentActivity: Activity() {
|
||||
@@ -31,7 +32,13 @@ class PermissionRequestTransparentActivity: Activity() {
|
||||
if (resultCode == RESULT_OK && data != null) {
|
||||
launchService(data)
|
||||
} else {
|
||||
setResult(RES_FAILED)
|
||||
val resultReceiver =
|
||||
intent.getParcelableExtra<ResultReceiver>(EXT_MEDIA_PROJECTION_RESULT_RECEIVER)
|
||||
if (resultReceiver != null) {
|
||||
resultReceiver.send(RES_FAILED, null)
|
||||
} else {
|
||||
setResult(RES_FAILED)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,4 +58,4 @@ class PermissionRequestTransparentActivity: Activity() {
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,11 +33,16 @@ const val ACT_INIT_MEDIA_PROJECTION_AND_SERVICE = "INIT_MEDIA_PROJECTION_AND_SER
|
||||
const val ACT_LOGIN_REQ_NOTIFY = "LOGIN_REQ_NOTIFY"
|
||||
const val EXT_INIT_FROM_BOOT = "EXT_INIT_FROM_BOOT"
|
||||
const val EXT_MEDIA_PROJECTION_RES_INTENT = "MEDIA_PROJECTION_RES_INTENT"
|
||||
const val EXT_MEDIA_PROJECTION_RESULT_RECEIVER = "MEDIA_PROJECTION_RESULT_RECEIVER"
|
||||
const val EXT_LOGIN_REQ_NOTIFY = "LOGIN_REQ_NOTIFY"
|
||||
|
||||
// Activity requestCode
|
||||
const val REQ_INVOKE_PERMISSION_ACTIVITY_MEDIA_PROJECTION = 101
|
||||
const val REQ_REQUEST_MEDIA_PROJECTION = 201
|
||||
const val REQ_EXPORT_FILE = 301
|
||||
const val REQ_IMPORT_FILES = 302
|
||||
const val REQ_IMPORT_DIRECTORY = 303
|
||||
const val REQ_EXPORT_FILES = 304
|
||||
|
||||
// Activity responseCode
|
||||
const val RES_FAILED = -100
|
||||
@@ -47,6 +52,12 @@ const val START_ACTION = "start_action"
|
||||
const val GET_START_ON_BOOT_OPT = "get_start_on_boot_opt"
|
||||
const val SET_START_ON_BOOT_OPT = "set_start_on_boot_opt"
|
||||
const val SYNC_APP_DIR_CONFIG_PATH = "sync_app_dir"
|
||||
const val PICK_IMPORT_FILES = "pick_import_files"
|
||||
const val IMPORT_FILE = "import_file"
|
||||
const val EXPORT_FILE = "export_file"
|
||||
const val PICK_IMPORT_DIRECTORY = "pick_import_directory"
|
||||
const val IMPORT_DIRECTORY = "import_directory"
|
||||
const val EXPORT_FILES = "export_files"
|
||||
const val GET_VALUE = "get_value"
|
||||
|
||||
const val KEY_IS_SUPPORT_VOICE_CALL = "KEY_IS_SUPPORT_VOICE_CALL"
|
||||
@@ -154,4 +165,4 @@ fun getScreenSize(windowManager: WindowManager) : Pair<Int, Int>{
|
||||
fun translate(input: String): String {
|
||||
Log.d("common", "translate:$LOCAL_NAME")
|
||||
return FFI.translateLocale(LOCAL_NAME, input)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ object FFI {
|
||||
external fun init(ctx: Context)
|
||||
external fun onAppStart(ctx: Context)
|
||||
external fun setClipboardManager(clipboardManager: RdClipboardManager)
|
||||
external fun startServer(app_dir: String, custom_client_config: String)
|
||||
external fun startServer(app_dir: String, home_dir: String, custom_client_config: String)
|
||||
external fun startService()
|
||||
external fun onVideoFrameUpdate(buf: ByteBuffer)
|
||||
external fun onAudioFrameUpdate(buf: ByteBuffer)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<resources>
|
||||
<string name="app_name">RustDesk</string>
|
||||
<string name="accessibility_service_description">Allow other devices to control your phone using virtual touch, when RustDesk screen sharing is established</string>
|
||||
<string name="foreground_service_special_use_subtype">Keeps the RustDesk remote desktop host available for authorized unattended connections and foreground notifications without starting screen capture before user approval.</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,3 +1,29 @@
|
||||
def legacyPluginNamespaces = [
|
||||
external_path: 'com.pinciat.external_path',
|
||||
flutter_keyboard_visibility: 'com.jrai.flutter_keyboard_visibility',
|
||||
qr_code_scanner: 'net.touchcapture.qr.flutterqr',
|
||||
sqflite: 'com.tekartik.sqflite',
|
||||
uni_links: 'name.avioli.unilinks',
|
||||
]
|
||||
|
||||
def java8JvmTarget = JavaVersion.VERSION_1_8.toString()
|
||||
def java8KotlinJvmTargets = [
|
||||
app: java8JvmTarget,
|
||||
external_path: java8JvmTarget,
|
||||
qr_code_scanner: java8JvmTarget,
|
||||
]
|
||||
|
||||
def configureKotlinJvmTarget = { Project project, String kotlinJvmTarget ->
|
||||
project.plugins.withId('kotlin-android') {
|
||||
project.tasks.configureEach { task ->
|
||||
if (!task.hasProperty('kotlinOptions')) {
|
||||
return
|
||||
}
|
||||
task.kotlinOptions.jvmTarget = kotlinJvmTarget
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
@@ -9,6 +35,16 @@ allprojects {
|
||||
rootProject.buildDir = '../build'
|
||||
subprojects {
|
||||
project.buildDir = "${rootProject.buildDir}/${project.name}"
|
||||
def legacyNamespace = legacyPluginNamespaces[project.name]
|
||||
if (legacyNamespace != null) {
|
||||
project.plugins.withId('com.android.library') {
|
||||
project.android.namespace = legacyNamespace
|
||||
}
|
||||
}
|
||||
def kotlinJvmTarget = java8KotlinJvmTargets[project.name]
|
||||
if (kotlinJvmTarget != null) {
|
||||
configureKotlinJvmTarget(project, kotlinJvmTarget)
|
||||
}
|
||||
}
|
||||
subprojects {
|
||||
project.evaluationDependsOn(':app')
|
||||
|
||||
@@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.4-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
|
||||
|
||||
@@ -18,7 +18,7 @@ pluginManagement {
|
||||
|
||||
plugins {
|
||||
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
||||
id "com.android.application" version "7.3.1" apply false
|
||||
id "com.android.application" version "8.10.1" apply false
|
||||
id "org.jetbrains.kotlin.android" version "2.1.21" apply false
|
||||
}
|
||||
|
||||
|
||||
@@ -84,8 +84,6 @@ const double _kPositionEpsilon = 1e-6;
|
||||
bool get isMainDesktopWindow =>
|
||||
desktopType == DesktopType.main || desktopType == DesktopType.cm;
|
||||
|
||||
String get screenInfo => screenInfo_;
|
||||
|
||||
/// Check if the app is running with single view mode.
|
||||
bool isSingleViewApp() {
|
||||
return desktopType == DesktopType.cm;
|
||||
@@ -1521,13 +1519,6 @@ class AndroidPermissionManager {
|
||||
static Timer? _timer;
|
||||
static var _current = "";
|
||||
|
||||
static bool isWaitingFile() {
|
||||
if (_completer != null) {
|
||||
return !_completer!.isCompleted && _current == kManageExternalStorage;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static Future<bool> check(String type) {
|
||||
if (isDesktop || isWeb) {
|
||||
return Future.value(true);
|
||||
@@ -1642,7 +1633,8 @@ String bool2option(String option, bool b) {
|
||||
String res;
|
||||
if (option.startsWith('enable-') &&
|
||||
option != kOptionEnableUdpPunch &&
|
||||
option != kOptionEnableIpv6Punch) {
|
||||
option != kOptionEnableIpv6Punch &&
|
||||
option != kOptionEnableWebrtc) {
|
||||
res = b ? defaultOptionYes : 'N';
|
||||
} else if (option.startsWith('allow-') ||
|
||||
option == kOptionStopService ||
|
||||
@@ -2636,13 +2628,6 @@ connect(BuildContext context, String id,
|
||||
}
|
||||
} else {
|
||||
if (isFileTransfer) {
|
||||
if (isAndroid) {
|
||||
if (!await AndroidPermissionManager.check(kManageExternalStorage)) {
|
||||
if (!await AndroidPermissionManager.request(kManageExternalStorage)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isWeb) {
|
||||
Navigator.push(
|
||||
context,
|
||||
|
||||
@@ -936,26 +936,19 @@ void enterPasswordDialog(
|
||||
);
|
||||
}
|
||||
|
||||
void enterUserLoginDialog(
|
||||
SessionID sessionId,
|
||||
OverlayDialogManager dialogManager,
|
||||
String osAccountDescTip,
|
||||
bool canRememberAccount) async {
|
||||
void enterUserLoginDialog(SessionID sessionId,
|
||||
OverlayDialogManager dialogManager, String osAccountDescTip) async {
|
||||
await _connectDialog(
|
||||
sessionId,
|
||||
dialogManager,
|
||||
osUsernameController: TextEditingController(),
|
||||
osPasswordController: TextEditingController(),
|
||||
osAccountDescTip: osAccountDescTip,
|
||||
canRememberAccount: canRememberAccount,
|
||||
);
|
||||
}
|
||||
|
||||
void enterUserLoginAndPasswordDialog(
|
||||
SessionID sessionId,
|
||||
OverlayDialogManager dialogManager,
|
||||
String osAccountDescTip,
|
||||
bool canRememberAccount) async {
|
||||
void enterUserLoginAndPasswordDialog(SessionID sessionId,
|
||||
OverlayDialogManager dialogManager, String osAccountDescTip) async {
|
||||
await _connectDialog(
|
||||
sessionId,
|
||||
dialogManager,
|
||||
@@ -963,7 +956,6 @@ void enterUserLoginAndPasswordDialog(
|
||||
osPasswordController: TextEditingController(),
|
||||
passwordController: TextEditingController(),
|
||||
osAccountDescTip: osAccountDescTip,
|
||||
canRememberAccount: canRememberAccount,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -974,7 +966,6 @@ _connectDialog(
|
||||
TextEditingController? osPasswordController,
|
||||
TextEditingController? passwordController,
|
||||
String? osAccountDescTip,
|
||||
bool canRememberAccount = true,
|
||||
}) async {
|
||||
final errUsername = ''.obs;
|
||||
var rememberPassword = false;
|
||||
@@ -982,11 +973,6 @@ _connectDialog(
|
||||
rememberPassword =
|
||||
await bind.sessionGetRemember(sessionId: sessionId) ?? false;
|
||||
}
|
||||
var rememberAccount = false;
|
||||
if (canRememberAccount && osUsernameController != null) {
|
||||
rememberAccount =
|
||||
await bind.sessionGetRemember(sessionId: sessionId) ?? false;
|
||||
}
|
||||
if (osUsernameController != null) {
|
||||
osUsernameController.addListener(() {
|
||||
if (errUsername.value.isNotEmpty) {
|
||||
@@ -1014,12 +1000,6 @@ _connectDialog(
|
||||
final osPassword = osPasswordController?.text.trim() ?? '';
|
||||
final password = passwordController?.text.trim() ?? '';
|
||||
if (passwordController != null && password.isEmpty) return;
|
||||
if (rememberAccount) {
|
||||
bind.sessionPeerOption(
|
||||
sessionId: sessionId, name: 'os-username', value: osUsername);
|
||||
bind.sessionPeerOption(
|
||||
sessionId: sessionId, name: 'os-password', value: osPassword);
|
||||
}
|
||||
gFFI.login(
|
||||
osUsername,
|
||||
osPassword,
|
||||
@@ -1096,16 +1076,6 @@ _connectDialog(
|
||||
controller: osPasswordController,
|
||||
autoFocus: false,
|
||||
),
|
||||
if (canRememberAccount)
|
||||
rememberWidget(
|
||||
translate('remember_account_tip'),
|
||||
rememberAccount,
|
||||
(v) {
|
||||
if (v != null) {
|
||||
setState(() => rememberAccount = v);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -1542,91 +1512,6 @@ showSetOSPassword(
|
||||
});
|
||||
}
|
||||
|
||||
showSetOSAccount(
|
||||
SessionID sessionId,
|
||||
OverlayDialogManager dialogManager,
|
||||
) async {
|
||||
final usernameController = TextEditingController();
|
||||
final passwdController = TextEditingController();
|
||||
var username =
|
||||
await bind.sessionGetOption(sessionId: sessionId, arg: 'os-username') ??
|
||||
'';
|
||||
var password =
|
||||
await bind.sessionGetOption(sessionId: sessionId, arg: 'os-password') ??
|
||||
'';
|
||||
usernameController.text = username;
|
||||
passwdController.text = password;
|
||||
dialogManager.show((setState, close, context) {
|
||||
submit() {
|
||||
final username = usernameController.text.trim();
|
||||
final password = usernameController.text.trim();
|
||||
bind.sessionPeerOption(
|
||||
sessionId: sessionId, name: 'os-username', value: username);
|
||||
bind.sessionPeerOption(
|
||||
sessionId: sessionId, name: 'os-password', value: password);
|
||||
close();
|
||||
}
|
||||
|
||||
descWidget(String text) {
|
||||
return Column(
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
text,
|
||||
maxLines: 3,
|
||||
softWrap: true,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
height: 8,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return CustomAlertDialog(
|
||||
title: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.password_rounded, color: MyTheme.accent),
|
||||
Text(translate('OS Account')).paddingOnly(left: 10),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
descWidget(translate("os_account_desk_tip")),
|
||||
DialogTextField(
|
||||
title: translate(DialogTextField.kUsernameTitle),
|
||||
controller: usernameController,
|
||||
prefixIcon: DialogTextField.kUsernameIcon,
|
||||
errorText: null,
|
||||
),
|
||||
PasswordWidget(controller: passwdController),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
dialogButton(
|
||||
"Cancel",
|
||||
icon: Icon(Icons.close_rounded),
|
||||
onPressed: close,
|
||||
isOutline: true,
|
||||
),
|
||||
dialogButton(
|
||||
"OK",
|
||||
icon: Icon(Icons.done_rounded),
|
||||
onPressed: submit,
|
||||
),
|
||||
],
|
||||
onSubmit: submit,
|
||||
onCancel: close,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Widget buildNoteTextField({
|
||||
required TextEditingController controller,
|
||||
required VoidCallback onEscape,
|
||||
@@ -2014,26 +1899,110 @@ customImageQualityDialog(SessionID sessionId, String id, FFI ffi) async {
|
||||
msgBoxCommon(ffi.dialogManager, 'Custom Image Quality', content, [btnClose]);
|
||||
}
|
||||
|
||||
trackpadSpeedDialog(SessionID sessionId, FFI ffi) async {
|
||||
int initSpeed = ffi.inputModel.trackpadSpeed;
|
||||
int? _validateTrackpadSpeed(String text) {
|
||||
final speed = int.tryParse(text);
|
||||
if (speed == null || speed < kMinTrackpadSpeed || speed > kMaxTrackpadSpeed) {
|
||||
BotToast.showText(
|
||||
text:
|
||||
'${translate('Invalid format')}: $kMinTrackpadSpeed-$kMaxTrackpadSpeed',
|
||||
contentColor: Colors.red,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return speed;
|
||||
}
|
||||
|
||||
Future<void> _saveTrackpadSpeed({
|
||||
required SessionID sessionId,
|
||||
required FFI ffi,
|
||||
required int initSpeed,
|
||||
required int speed,
|
||||
}) async {
|
||||
if (speed == initSpeed) {
|
||||
return;
|
||||
}
|
||||
await bind.sessionSetTrackpadSpeed(sessionId: sessionId, value: speed);
|
||||
await ffi.inputModel.updateTrackpadSpeed();
|
||||
}
|
||||
|
||||
void _showTrackpadSpeedSaveError(Object error, StackTrace stackTrace) {
|
||||
debugPrint('Failed to save trackpad speed: $error');
|
||||
debugPrintStack(stackTrace: stackTrace);
|
||||
BotToast.showText(
|
||||
text: translate('Failed'),
|
||||
contentColor: Colors.red,
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _trackpadSpeedDialogActions({
|
||||
required bool isSubmitting,
|
||||
required VoidCallback close,
|
||||
required VoidCallback submit,
|
||||
}) {
|
||||
return [
|
||||
dialogButton(
|
||||
'Cancel',
|
||||
icon: Icon(Icons.close_rounded),
|
||||
onPressed: isSubmitting ? null : close,
|
||||
isOutline: true,
|
||||
),
|
||||
dialogButton(
|
||||
'OK',
|
||||
icon: Icon(Icons.done_rounded),
|
||||
onPressed: isSubmitting ? null : submit,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
void trackpadSpeedDialog(SessionID sessionId, FFI ffi) {
|
||||
final initSpeed = ffi.inputModel.trackpadSpeed;
|
||||
final curSpeed = SimpleWrapper(initSpeed);
|
||||
final btnClose = dialogButton('Close', onPressed: () async {
|
||||
if (curSpeed.value <= kMaxTrackpadSpeed &&
|
||||
curSpeed.value >= kMinTrackpadSpeed &&
|
||||
curSpeed.value != initSpeed) {
|
||||
await bind.sessionSetTrackpadSpeed(
|
||||
sessionId: sessionId, value: curSpeed.value);
|
||||
await ffi.inputModel.updateTrackpadSpeed();
|
||||
var speedText = initSpeed.toString();
|
||||
var isSubmitting = false;
|
||||
ffi.dialogManager.show((setState, close, context) {
|
||||
Future<void> submit([String? submittedText]) async {
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
}
|
||||
speedText = submittedText ?? speedText;
|
||||
final speed = _validateTrackpadSpeed(speedText);
|
||||
if (speed == null) {
|
||||
return;
|
||||
}
|
||||
setState(() => isSubmitting = true);
|
||||
try {
|
||||
await _saveTrackpadSpeed(
|
||||
sessionId: sessionId,
|
||||
ffi: ffi,
|
||||
initSpeed: initSpeed,
|
||||
speed: speed,
|
||||
);
|
||||
close();
|
||||
} catch (error, stackTrace) {
|
||||
_showTrackpadSpeedSaveError(error, stackTrace);
|
||||
setState(() => isSubmitting = false);
|
||||
}
|
||||
}
|
||||
ffi.dialogManager.dismissAll();
|
||||
});
|
||||
msgBoxCommon(
|
||||
ffi.dialogManager,
|
||||
'Trackpad speed',
|
||||
TrackpadSpeedWidget(
|
||||
value: curSpeed,
|
||||
|
||||
return CustomAlertDialog(
|
||||
title: Text(
|
||||
translate('Trackpad speed'),
|
||||
style: TextStyle(fontSize: 21),
|
||||
),
|
||||
[btnClose]);
|
||||
content: TrackpadSpeedWidget(
|
||||
value: curSpeed,
|
||||
onTextChanged: (text) => speedText = text,
|
||||
onTextSubmitted: submit,
|
||||
),
|
||||
actions: _trackpadSpeedDialogActions(
|
||||
isSubmitting: isSubmitting,
|
||||
close: close,
|
||||
submit: submit,
|
||||
),
|
||||
onSubmit: isSubmitting ? null : submit,
|
||||
onCancel: isSubmitting ? null : close,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void deleteConfirmDialog(Function onSubmit, String title) async {
|
||||
|
||||
@@ -606,6 +606,9 @@ class QualityMonitor extends StatelessWidget {
|
||||
_row(
|
||||
"Codec", qualityMonitorModel.data.codecFormat ?? '-'),
|
||||
_row("Chroma", qualityMonitorModel.data.chroma ?? '-'),
|
||||
if (qualityMonitorModel.webrtcTransport != null)
|
||||
_row("Transport",
|
||||
qualityMonitorModel.webrtcTransport!),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -115,6 +115,7 @@ class _RawTouchGestureDetectorRegionState
|
||||
InputModel get inputModel => widget.inputModel;
|
||||
bool get handleTouch => (isDesktop || isWebDesktop) || ffiModel.touchMode;
|
||||
SessionID get sessionId => ffi.sessionId;
|
||||
bool get canvasLocked => isMobile && ffi.canvasModel.locked;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -471,6 +472,8 @@ class _RawTouchGestureDetectorRegionState
|
||||
return;
|
||||
}
|
||||
|
||||
if (canvasLocked) return;
|
||||
|
||||
if ((isDesktop || isWebDesktop)) {
|
||||
final scale = ((d.scale - _scale) * 1000).toInt();
|
||||
_scale = d.scale;
|
||||
|
||||
@@ -253,8 +253,18 @@ class TrackpadSpeedWidget extends StatefulWidget {
|
||||
final SimpleWrapper<int> value;
|
||||
// If null, no debouncer will be applied.
|
||||
final Function(int)? onDebouncer;
|
||||
final ValueChanged<String>? onTextChanged;
|
||||
// IME actions call TextField.onSubmitted without reaching the dialog's
|
||||
// raw Enter handler, so the dialog needs a separate submission callback.
|
||||
final ValueChanged<String>? onTextSubmitted;
|
||||
|
||||
TrackpadSpeedWidget({Key? key, required this.value, this.onDebouncer});
|
||||
TrackpadSpeedWidget({
|
||||
Key? key,
|
||||
required this.value,
|
||||
this.onDebouncer,
|
||||
this.onTextChanged,
|
||||
this.onTextSubmitted,
|
||||
});
|
||||
|
||||
@override
|
||||
TrackpadSpeedWidgetState createState() => TrackpadSpeedWidgetState();
|
||||
@@ -276,6 +286,34 @@ class TrackpadSpeedWidgetState extends State<TrackpadSpeedWidget> {
|
||||
debouncerSpeed.setValue(value);
|
||||
}
|
||||
});
|
||||
widget.onTextChanged?.call(_controller.text);
|
||||
}
|
||||
|
||||
void updateTextValue(String text) {
|
||||
widget.onTextChanged?.call(text);
|
||||
final newValue = int.tryParse(text);
|
||||
if (newValue == null ||
|
||||
newValue < kMinTrackpadSpeed ||
|
||||
newValue > kMaxTrackpadSpeed) {
|
||||
return;
|
||||
}
|
||||
setState(() => value = newValue);
|
||||
}
|
||||
|
||||
void submitTextValue(String text) {
|
||||
final onTextSubmitted = widget.onTextSubmitted;
|
||||
if (onTextSubmitted != null) {
|
||||
onTextSubmitted(text);
|
||||
return;
|
||||
}
|
||||
if (widget.onTextChanged != null) {
|
||||
return;
|
||||
}
|
||||
final newValue = int.tryParse(text);
|
||||
if (newValue == null) {
|
||||
return;
|
||||
}
|
||||
updateValue(newValue);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -315,12 +353,8 @@ class TrackpadSpeedWidgetState extends State<TrackpadSpeedWidget> {
|
||||
controller: _controller,
|
||||
keyboardType: TextInputType.number,
|
||||
textAlign: TextAlign.center,
|
||||
onSubmitted: (text) {
|
||||
int? v = int.tryParse(text);
|
||||
if (v != null) {
|
||||
updateValue(v);
|
||||
}
|
||||
},
|
||||
onChanged: updateTextValue,
|
||||
onSubmitted: submitTextValue,
|
||||
style: const TextStyle(fontSize: 13),
|
||||
decoration: InputDecoration(
|
||||
contentPadding:
|
||||
|
||||
@@ -349,12 +349,12 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
||||
showRequestElevationDialog(sessionId, ffi.dialogManager)),
|
||||
);
|
||||
}
|
||||
// osAccount / osPassword
|
||||
// osPassword
|
||||
if (isDefaultConn && perms['keyboard'] != false) {
|
||||
v.add(
|
||||
TTextMenu(
|
||||
child: Row(children: [
|
||||
Text(translate(pi.isHeadless ? 'OS Account' : 'OS Password')),
|
||||
Text(translate('OS Password')),
|
||||
]),
|
||||
trailingIcon: Transform.scale(
|
||||
scale: (isDesktop || isWebDesktop) ? 0.8 : 1,
|
||||
@@ -363,18 +363,12 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
||||
if (isMobile && Navigator.canPop(context)) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
if (pi.isHeadless) {
|
||||
showSetOSAccount(sessionId, ffi.dialogManager);
|
||||
} else {
|
||||
handleOsPasswordEditIcon(sessionId, ffi.dialogManager);
|
||||
}
|
||||
handleOsPasswordEditIcon(sessionId, ffi.dialogManager);
|
||||
},
|
||||
icon: Icon(Icons.edit, color: isMobile ? MyTheme.accent : null),
|
||||
),
|
||||
),
|
||||
onPressed: () => pi.isHeadless
|
||||
? showSetOSAccount(sessionId, ffi.dialogManager)
|
||||
: handleOsPasswordAction(sessionId, ffi.dialogManager),
|
||||
onPressed: () => handleOsPasswordAction(sessionId, ffi.dialogManager),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ const kKeyMapMode = 'map';
|
||||
const kKeyTranslateMode = 'translate';
|
||||
|
||||
const String kPlatformAdditionsIsWayland = "is_wayland";
|
||||
const String kPlatformAdditionsHeadless = "headless";
|
||||
const String kPlatformAdditionsIsInstalled = "is_installed";
|
||||
const String kPlatformAdditionsIddImpl = "idd_impl";
|
||||
const String kPlatformAdditionsRustDeskVirtualDisplays =
|
||||
@@ -55,7 +54,6 @@ const String kAppTypeDesktopTerminal = "terminal";
|
||||
|
||||
const String kWindowMainWindowOnTop = "main_window_on_top";
|
||||
const String kWindowRefreshCurrentUser = "refresh_current_user";
|
||||
const String kWindowGetWindowInfo = "get_window_info";
|
||||
const String kWindowGetScreenList = "get_screen_list";
|
||||
// This method is not used, maybe it can be removed.
|
||||
const String kWindowDisableGrabKeyboard = "disable_grab_keyboard";
|
||||
@@ -164,13 +162,16 @@ const String kOptionEnableConfirmClosingTabs = "enable-confirm-closing-tabs";
|
||||
const String kOptionAllowAlwaysSoftwareRender = "allow-always-software-render";
|
||||
const String kOptionEnableCheckUpdate = "enable-check-update";
|
||||
const String kOptionAllowAutoUpdate = "allow-auto-update";
|
||||
const String kOptionAllowLinuxHeadless = "allow-linux-headless";
|
||||
const String kOptionAllowRemoveWallpaper = "allow-remove-wallpaper";
|
||||
const String kOptionStopService = "stop-service";
|
||||
const String kOptionDirectxCapture = "enable-directx-capture";
|
||||
const String kOptionAllowRemoteCmModification = "allow-remote-cm-modification";
|
||||
const String kOptionEnableTcpPunch = "enable-tcp-punch";
|
||||
const String kOptionEnableUdpPunch = "enable-udp-punch";
|
||||
const String kOptionEnableIpv6Punch = "enable-ipv6-punch";
|
||||
const String kOptionAllowSyncClipboardBetweenSessions =
|
||||
"allow-sync-clipboard-between-sessions";
|
||||
const String kOptionEnableWebrtc = "enable-webrtc";
|
||||
const String kOptionEnableTrustedDevices = "enable-trusted-devices";
|
||||
const String kOptionShowVirtualMouse = "show-virtual-mouse";
|
||||
const String kOptionVirtualMouseScale = "virtual-mouse-scale";
|
||||
@@ -193,6 +194,7 @@ const String kOptionHideProxySetting = "hide-proxy-settings";
|
||||
const String kOptionHideWebSocketSetting = "hide-websocket-settings";
|
||||
const String kOptionHideStopService = "hide-stop-service";
|
||||
const String kOptionHideRemotePrinterSetting = "hide-remote-printer-settings";
|
||||
const String kOptionHideGeneralSetting = "hide-general-settings";
|
||||
const String kOptionHideSecuritySetting = "hide-security-settings";
|
||||
const String kOptionHideNetworkSetting = "hide-network-settings";
|
||||
const String kOptionRemovePresetPasswordWarning =
|
||||
@@ -325,10 +327,11 @@ double kNewWindowOffset = isWindows
|
||||
? 30.0
|
||||
: 50.0;
|
||||
|
||||
const kDragToResizeAreaPaddingSize = 5.0;
|
||||
EdgeInsets get kDragToResizeAreaPadding => !kUseCompatibleUiMode && isLinux
|
||||
? stateGlobal.fullscreen.isTrue || stateGlobal.isMaximized.value
|
||||
? EdgeInsets.zero
|
||||
: EdgeInsets.all(5.0)
|
||||
: EdgeInsets.all(kDragToResizeAreaPaddingSize)
|
||||
: EdgeInsets.zero;
|
||||
// https://en.wikipedia.org/wiki/Non-breaking_space
|
||||
const int $nbsp = 0x00A0;
|
||||
@@ -440,7 +443,6 @@ const kActionApplicationDetailsSettings =
|
||||
const kActionAccessibilitySettings = "android.settings.ACCESSIBILITY_SETTINGS";
|
||||
|
||||
const kRecordAudio = "android.permission.RECORD_AUDIO";
|
||||
const kManageExternalStorage = "android.permission.MANAGE_EXTERNAL_STORAGE";
|
||||
const kRequestIgnoreBatteryOptimizations =
|
||||
"android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS";
|
||||
const kSystemAlertWindow = "android.permission.SYSTEM_ALERT_WINDOW";
|
||||
@@ -452,6 +454,12 @@ class AndroidChannel {
|
||||
static final kGetStartOnBootOpt = "get_start_on_boot_opt";
|
||||
static final kSetStartOnBootOpt = "set_start_on_boot_opt";
|
||||
static final kSyncAppDirConfigPath = "sync_app_dir";
|
||||
static final kPickImportFiles = "pick_import_files";
|
||||
static final kImportFile = "import_file";
|
||||
static final kExportFile = "export_file";
|
||||
static final kPickImportDirectory = "pick_import_directory";
|
||||
static final kImportDirectory = "import_directory";
|
||||
static final kExportFiles = "export_files";
|
||||
}
|
||||
|
||||
/// flutter/packages/flutter/lib/src/services/keyboard_key.dart -> _keyLabels
|
||||
|
||||
@@ -16,7 +16,6 @@ import 'package:flutter_hbb/desktop/widgets/update_progress.dart';
|
||||
import 'package:flutter_hbb/models/platform_model.dart';
|
||||
import 'package:flutter_hbb/models/server_model.dart';
|
||||
import 'package:flutter_hbb/models/state_model.dart';
|
||||
import 'package:flutter_hbb/plugin/ui_manager.dart';
|
||||
import 'package:flutter_hbb/utils/multi_window_manager.dart';
|
||||
import 'package:flutter_hbb/utils/platform_channel.dart';
|
||||
import 'package:get/get.dart';
|
||||
@@ -111,7 +110,6 @@ class _DesktopHomePageState extends State<DesktopHomePage>
|
||||
}
|
||||
},
|
||||
),
|
||||
buildPluginEntry(),
|
||||
];
|
||||
if (isIncomingOnly) {
|
||||
children.addAll([
|
||||
@@ -782,13 +780,6 @@ class _DesktopHomePageState extends State<DesktopHomePage>
|
||||
windowOnTop(null);
|
||||
} else if (call.method == kWindowRefreshCurrentUser) {
|
||||
gFFI.userModel.refreshCurrentUser();
|
||||
} else if (call.method == kWindowGetWindowInfo) {
|
||||
final screen = (await window_size.getWindowInfo()).screen;
|
||||
if (screen == null) {
|
||||
return '';
|
||||
} else {
|
||||
return jsonEncode(screenToMap(screen));
|
||||
}
|
||||
} else if (call.method == kWindowGetScreenList) {
|
||||
return jsonEncode(
|
||||
(await window_size.getScreenList()).map(screenToMap).toList());
|
||||
@@ -890,21 +881,6 @@ class _DesktopHomePageState extends State<DesktopHomePage>
|
||||
shouldBeBlocked(_block, canBeBlocked);
|
||||
}
|
||||
}
|
||||
|
||||
Widget buildPluginEntry() {
|
||||
final entries = PluginUiManager.instance.entries.entries;
|
||||
return Offstage(
|
||||
offstage: entries.isEmpty,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
...entries.map((entry) {
|
||||
return entry.value;
|
||||
})
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void setPasswordDialog({VoidCallback? notEmptyCallback}) async {
|
||||
|
||||
@@ -17,8 +17,6 @@ import 'package:flutter_hbb/models/platform_model.dart';
|
||||
import 'package:flutter_hbb/models/printer_model.dart';
|
||||
import 'package:flutter_hbb/models/server_model.dart';
|
||||
import 'package:flutter_hbb/models/state_model.dart';
|
||||
import 'package:flutter_hbb/plugin/manager.dart';
|
||||
import 'package:flutter_hbb/plugin/widgets/desktop_settings.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
@@ -55,7 +53,6 @@ enum SettingsTabKey {
|
||||
safety,
|
||||
network,
|
||||
display,
|
||||
plugin,
|
||||
account,
|
||||
printer,
|
||||
about,
|
||||
@@ -64,7 +61,8 @@ enum SettingsTabKey {
|
||||
class DesktopSettingPage extends StatefulWidget {
|
||||
final SettingsTabKey initialTabkey;
|
||||
static final List<SettingsTabKey> tabKeys = [
|
||||
SettingsTabKey.general,
|
||||
if (bind.mainGetBuildinOption(key: kOptionHideGeneralSetting) != 'Y')
|
||||
SettingsTabKey.general,
|
||||
if (!isWeb &&
|
||||
!bind.isOutgoingOnly() &&
|
||||
!bind.isDisableSettings() &&
|
||||
@@ -74,10 +72,9 @@ class DesktopSettingPage extends StatefulWidget {
|
||||
bind.mainGetBuildinOption(key: kOptionHideNetworkSetting) != 'Y')
|
||||
SettingsTabKey.network,
|
||||
if (!bind.isIncomingOnly()) SettingsTabKey.display,
|
||||
if (!isWeb && !bind.isIncomingOnly() && bind.pluginFeatureIsEnabled())
|
||||
SettingsTabKey.plugin,
|
||||
if (!bind.isDisableAccount()) SettingsTabKey.account,
|
||||
if (isWindows &&
|
||||
!bind.isDisableSettings() &&
|
||||
bind.mainGetBuildinOption(key: kOptionHideRemotePrinterSetting) != 'Y')
|
||||
SettingsTabKey.printer,
|
||||
SettingsTabKey.about,
|
||||
@@ -95,7 +92,8 @@ class DesktopSettingPage extends StatefulWidget {
|
||||
if (index == -1) {
|
||||
return;
|
||||
}
|
||||
if (Get.isRegistered<PageController>(tag: _kSettingPageControllerTag)) {
|
||||
if (Get.isRegistered<PageController>(tag: _kSettingPageControllerTag) &&
|
||||
Get.isRegistered<Rx<SettingsTabKey>>(tag: _kSettingPageTabKeyTag)) {
|
||||
DesktopTabPage.onAddSetting(initialPage: page);
|
||||
PageController controller =
|
||||
Get.find<PageController>(tag: _kSettingPageControllerTag);
|
||||
@@ -163,17 +161,23 @@ class _DesktopSettingPageState extends State<DesktopSettingPage>
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
_canBeBlocked.value = await canBeBlocked();
|
||||
final blocked = await canBeBlocked();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
_canBeBlocked.value = blocked;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
Get.delete<PageController>(tag: _kSettingPageControllerTag);
|
||||
Get.delete<RxInt>(tag: _kSettingPageTabKeyTag);
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_videoConnTimer?.cancel();
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
Get.delete<PageController>(tag: _kSettingPageControllerTag);
|
||||
Get.delete<Rx<SettingsTabKey>>(tag: _kSettingPageTabKeyTag);
|
||||
// Get.delete does not dispose a plain ChangeNotifier.
|
||||
controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
List<_TabInfo> _settingTabs() {
|
||||
@@ -196,10 +200,6 @@ class _DesktopSettingPageState extends State<DesktopSettingPage>
|
||||
settingTabs.add(_TabInfo(tab, 'Display',
|
||||
Icons.desktop_windows_outlined, Icons.desktop_windows));
|
||||
break;
|
||||
case SettingsTabKey.plugin:
|
||||
settingTabs.add(_TabInfo(
|
||||
tab, 'Plugin', Icons.extension_outlined, Icons.extension));
|
||||
break;
|
||||
case SettingsTabKey.account:
|
||||
settingTabs.add(
|
||||
_TabInfo(tab, 'Account', Icons.person_outline, Icons.person));
|
||||
@@ -233,9 +233,6 @@ class _DesktopSettingPageState extends State<DesktopSettingPage>
|
||||
case SettingsTabKey.display:
|
||||
children.add(const _Display());
|
||||
break;
|
||||
case SettingsTabKey.plugin:
|
||||
children.add(const _Plugin());
|
||||
break;
|
||||
case SettingsTabKey.account:
|
||||
children.add(const _Account());
|
||||
break;
|
||||
@@ -566,6 +563,12 @@ class _GeneralState extends State<_General> {
|
||||
kOptionDirectxCapture,
|
||||
),
|
||||
if (!isWeb && !incomingOnly) ...[
|
||||
_OptionCheckBox(
|
||||
context,
|
||||
'Enable TCP hole punching',
|
||||
kOptionEnableTcpPunch,
|
||||
isServer: false,
|
||||
),
|
||||
_OptionCheckBox(
|
||||
context,
|
||||
'Enable UDP hole punching',
|
||||
@@ -579,6 +582,23 @@ class _GeneralState extends State<_General> {
|
||||
isServer: false,
|
||||
),
|
||||
],
|
||||
if (!incomingOnly)
|
||||
_OptionCheckBox(
|
||||
context,
|
||||
'Enable WebRTC P2P connection',
|
||||
kOptionEnableWebrtc,
|
||||
isServer: false,
|
||||
),
|
||||
if (!isWeb && !incomingOnly)
|
||||
Tooltip(
|
||||
message: translate('sync-clipboard-between-sessions-tip'),
|
||||
child: _OptionCheckBox(
|
||||
context,
|
||||
'Sync clipboard between sessions',
|
||||
kOptionAllowSyncClipboardBetweenSessions,
|
||||
isServer: false,
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
// Add client-side wakelock option for desktop platforms
|
||||
@@ -591,10 +611,6 @@ class _GeneralState extends State<_General> {
|
||||
));
|
||||
}
|
||||
|
||||
if (!isWeb && bind.mainShowOption(key: kOptionAllowLinuxHeadless)) {
|
||||
children.add(_OptionCheckBox(
|
||||
context, 'Allow linux headless', kOptionAllowLinuxHeadless));
|
||||
}
|
||||
if (!bind.isDisableAccount()) {
|
||||
children.add(_OptionCheckBox(
|
||||
context,
|
||||
@@ -2255,51 +2271,6 @@ class _CheckboxState extends State<_Checkbox> {
|
||||
}
|
||||
}
|
||||
|
||||
class _Plugin extends StatefulWidget {
|
||||
const _Plugin({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<_Plugin> createState() => _PluginState();
|
||||
}
|
||||
|
||||
class _PluginState extends State<_Plugin> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
bind.pluginListReload();
|
||||
final scrollController = ScrollController();
|
||||
return ChangeNotifierProvider.value(
|
||||
value: pluginManager,
|
||||
child: Consumer<PluginManager>(builder: (context, model, child) {
|
||||
return ListView(
|
||||
controller: scrollController,
|
||||
children: model.plugins.map((entry) => pluginCard(entry)).toList(),
|
||||
).marginOnly(bottom: _kListViewBottomMargin);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Widget pluginCard(PluginInfo plugin) {
|
||||
return ChangeNotifierProvider.value(
|
||||
value: plugin,
|
||||
child: Consumer<PluginInfo>(
|
||||
builder: (context, model, child) => DesktopSettingsCard(plugin: model),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget accountAction() {
|
||||
return Obx(() => _Button(
|
||||
gFFI.userModel.userName.value.isEmpty
|
||||
? 'Login'
|
||||
: '${translate('Logout')} (${gFFI.userModel.accountLabelWithHandle})',
|
||||
() => {
|
||||
gFFI.userModel.userName.value.isEmpty
|
||||
? loginDialog()
|
||||
: logOutConfirmDialog()
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
class _Printer extends StatefulWidget {
|
||||
const _Printer({super.key});
|
||||
|
||||
|
||||
@@ -278,7 +278,39 @@ class _FileManagerPageState extends State<FileManagerPage>
|
||||
item.state != JobState.inProgress,
|
||||
child: LinearPercentIndicator(
|
||||
animateFromLastPercent: true,
|
||||
center: Text(item.percentText),
|
||||
center: SizedBox.expand(
|
||||
child: ShaderMask(
|
||||
blendMode: BlendMode.srcATop,
|
||||
shaderCallback: (bounds) =>
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Colors.white,
|
||||
Colors.transparent,
|
||||
],
|
||||
stops: [item.percent, item.percent],
|
||||
).createShader(bounds),
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
text: item.percentText,
|
||||
children: [
|
||||
if (item.recvJobRes)
|
||||
TextSpan(
|
||||
text:
|
||||
' ${readableFileSize(item.speed)}/s',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w300,
|
||||
color: MyTheme.darkGray,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
barRadius: Radius.circular(15),
|
||||
percent: item.percent,
|
||||
progressColor: MyTheme.accent,
|
||||
@@ -1094,6 +1126,7 @@ class _FileManagerViewState extends State<FileManagerView> {
|
||||
return element.name.contains(_searchText.value);
|
||||
}).toList(growable: false)
|
||||
: entries;
|
||||
// Keep rows lazy so large directories only build visible list items.
|
||||
final rows = filteredEntries.map((entry) {
|
||||
final sizeStr =
|
||||
entry.isFile ? readableFileSize(entry.size.toDouble()) : "";
|
||||
@@ -1276,7 +1309,7 @@ class _FileManagerViewState extends State<FileManagerView> {
|
||||
],
|
||||
))),
|
||||
);
|
||||
}).toList(growable: false);
|
||||
});
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
@@ -1292,7 +1325,7 @@ class _FileManagerViewState extends State<FileManagerView> {
|
||||
controller: scrollController,
|
||||
itemExtent: kDesktopFileTransferRowHeight,
|
||||
itemBuilder: (context, index) {
|
||||
return rows[index];
|
||||
return rows.elementAt(index);
|
||||
},
|
||||
itemCount: rows.length,
|
||||
),
|
||||
|
||||
@@ -182,7 +182,6 @@ class _RemotePageState extends State<RemotePage>
|
||||
WakelockManager.enable(_uniqueKey);
|
||||
|
||||
_ffi.ffiModel.updateEventListener(sessionId, widget.id);
|
||||
if (!isWeb) bind.pluginSyncUi(syncTo: kAppTypeDesktopRemote);
|
||||
_ffi.qualityMonitorModel.checkShowQualityMonitor(sessionId);
|
||||
_ffi.dialogManager.loadMobileActionsOverlayVisible();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
@@ -274,6 +273,11 @@ class _RemotePageState extends State<RemotePage>
|
||||
tabState.tabs[selected].key == widget.id;
|
||||
}
|
||||
|
||||
// Every Windows requestFocus() must pass this, or a blocking dialog or an
|
||||
// inactive tab could hand remote input to this page.
|
||||
bool get _windowsCanFocusRemoteInput =>
|
||||
_isSelectedTab && _blockableOverlayState.middleBlocked.isFalse;
|
||||
|
||||
bool get _isMacOSKeyboardContextActive {
|
||||
return stateGlobal.isFocused.value && !_isWindowBlur && _isSelectedTab;
|
||||
}
|
||||
@@ -514,6 +518,15 @@ class _RemotePageState extends State<RemotePage>
|
||||
_queueMacOSKeyboardAfterFullScreen(allowHiddenLifecycle: true);
|
||||
}
|
||||
|
||||
// Refocus without PointerEnter: the cursor already hovers the image when
|
||||
// focus returns (Alt+Tab, taskbar), so enterView() never fires again.
|
||||
if (isWindows &&
|
||||
_cursorOverImage.value &&
|
||||
_windowsCanFocusRemoteInput &&
|
||||
!_rawKeyFocusNode.hasFocus) {
|
||||
_rawKeyFocusNode.requestFocus();
|
||||
}
|
||||
|
||||
// Restore relative mouse mode constraints when window regains focus.
|
||||
if (_ffi.inputModel.relativeMouseMode.value) {
|
||||
if (isMacOS) {
|
||||
@@ -524,7 +537,7 @@ class _RemotePageState extends State<RemotePage>
|
||||
_cursorOverImage.value = true;
|
||||
_macOSLocalFocusLost = false;
|
||||
}
|
||||
} else {
|
||||
} else if (!isWindows || _windowsCanFocusRemoteInput) {
|
||||
_rawKeyFocusNode.requestFocus();
|
||||
}
|
||||
_ffi.inputModel.onWindowFocus();
|
||||
@@ -836,7 +849,16 @@ class _RemotePageState extends State<RemotePage>
|
||||
_macOSLocalFocusLost = false;
|
||||
stateGlobal.getInputSource(force: true);
|
||||
_syncMacOSKeyboardGrab(reassert: true, allowInactiveLifecycle: true);
|
||||
} else if (!isWindows) {
|
||||
} else if (isWindows) {
|
||||
// Blur unfocuses this node and nothing restores it, so the keyboard stayed
|
||||
// dead until a click. Focus only while the window is really active, or a
|
||||
// background window would grab system keys. onFocusChange does enterOrLeave.
|
||||
if (!_isWindowBlur &&
|
||||
_windowsCanFocusRemoteInput &&
|
||||
!_rawKeyFocusNode.hasFocus) {
|
||||
_rawKeyFocusNode.requestFocus();
|
||||
}
|
||||
} else {
|
||||
if (!_rawKeyFocusNode.hasFocus) {
|
||||
_rawKeyFocusNode.requestFocus();
|
||||
}
|
||||
|
||||
@@ -22,6 +22,14 @@ import '../../models/file_model.dart';
|
||||
import '../../models/platform_model.dart';
|
||||
import '../../models/server_model.dart';
|
||||
|
||||
/// Set only by this window's own close control, and only once the user has confirmed. Any other
|
||||
/// way the window can go - a session logout closing every window, the window manager, a native
|
||||
/// title-bar button this app does not draw - leaves it false, which is the honest answer:
|
||||
/// nothing in that close says who asked for it. It lives at file scope because the control that
|
||||
/// sets it (`ConnectionManagerState`) and the handler that reads it (`_DesktopServerPageState`)
|
||||
/// are different widgets.
|
||||
bool _cmClosedByOperator = false;
|
||||
|
||||
class DesktopServerPage extends StatefulWidget {
|
||||
const DesktopServerPage({Key? key}) : super(key: key);
|
||||
|
||||
@@ -55,7 +63,10 @@ class _DesktopServerPageState extends State<DesktopServerPage>
|
||||
|
||||
@override
|
||||
void onWindowClose() {
|
||||
Future.wait([gFFI.serverModel.closeAll(), gFFI.close()]).then((_) {
|
||||
// Other platforms keep the old behaviour exactly: the ambiguity this guards against is a
|
||||
// Linux session logout, which closes every window in the session.
|
||||
final byOperator = _cmClosedByOperator || !isLinux;
|
||||
Future.wait([gFFI.serverModel.closeAll(byOperator: byOperator), gFFI.close()]).then((_) {
|
||||
if (isMacOS) {
|
||||
RdPlatformChannel.instance.terminate();
|
||||
} else {
|
||||
@@ -327,6 +338,7 @@ class ConnectionManagerState extends State<ConnectionManager>
|
||||
var tabController = gFFI.serverModel.tabController;
|
||||
final connLength = tabController.length;
|
||||
if (connLength <= 1) {
|
||||
_cmClosedByOperator = true;
|
||||
windowManager.close();
|
||||
return true;
|
||||
} else {
|
||||
@@ -338,6 +350,9 @@ class ConnectionManagerState extends State<ConnectionManager>
|
||||
res = await closeConfirmDialog();
|
||||
}
|
||||
if (res) {
|
||||
// After the dialog, never before it: an external close while it is open must not
|
||||
// inherit an intent the user had not expressed yet.
|
||||
_cmClosedByOperator = true;
|
||||
windowManager.close();
|
||||
}
|
||||
return res;
|
||||
|
||||
@@ -5,7 +5,7 @@ import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
|
||||
import 'package:flutter_hbb/models/model.dart';
|
||||
import 'package:flutter_hbb/models/terminal_model.dart';
|
||||
import 'package:xterm/xterm.dart';
|
||||
import 'package:flutter_hbb/models/terminal_mouse_handler.dart';
|
||||
import 'terminal_connection_manager.dart';
|
||||
|
||||
class TerminalPage extends StatefulWidget {
|
||||
@@ -197,7 +197,7 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final heightPx = constraints.maxHeight;
|
||||
return TerminalView(
|
||||
return TerminalMouseInteraction(
|
||||
_terminalModel.terminal,
|
||||
controller: _terminalModel.terminalController,
|
||||
focusNode: _terminalFocusNode,
|
||||
|
||||
@@ -127,7 +127,6 @@ class _ViewCameraPageState extends State<ViewCameraPage>
|
||||
WakelockManager.enable(_uniqueKey);
|
||||
|
||||
_ffi.ffiModel.updateEventListener(sessionId, widget.id);
|
||||
if (!isWeb) bind.pluginSyncUi(syncTo: kAppTypeDesktopRemote);
|
||||
_ffi.qualityMonitorModel.checkShowQualityMonitor(sessionId);
|
||||
_ffi.dialogManager.loadMobileActionsOverlayVisible();
|
||||
DesktopMultiWindow.addListener(this);
|
||||
|
||||
@@ -9,9 +9,6 @@ import 'package:flutter_hbb/common/widgets/toolbar.dart';
|
||||
import 'package:flutter_hbb/models/chat_model.dart';
|
||||
import 'package:flutter_hbb/models/state_model.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/utils/multi_window_manager.dart';
|
||||
import 'package:flutter_hbb/plugin/widgets/desc_ui.dart';
|
||||
import 'package:flutter_hbb/plugin/common.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
@@ -1336,6 +1333,12 @@ class ScreenAdjustor {
|
||||
final FFI ffi;
|
||||
final VoidCallback cbExitFullscreen;
|
||||
window_size.Screen? _screen;
|
||||
Size? _waylandMaximizedWorkAreaSize;
|
||||
Rect? _waylandWorkAreaScreenFrame;
|
||||
double? _waylandWorkAreaScaleFactor;
|
||||
Rect? _x11WorkArea;
|
||||
Rect? _x11WorkAreaScreenFrame;
|
||||
double? _x11WorkAreaScaleFactor;
|
||||
|
||||
ScreenAdjustor({
|
||||
required this.id,
|
||||
@@ -1346,9 +1349,18 @@ class ScreenAdjustor {
|
||||
bool get isFullscreen => stateGlobal.fullscreen.isTrue;
|
||||
int get windowId => stateGlobal.windowId;
|
||||
|
||||
Future<bool?> isWindowMaximized() async {
|
||||
try {
|
||||
return await WindowController.fromWindowId(windowId).isMaximized();
|
||||
} catch (_) {
|
||||
// The delayed resolution callback may run after the window is disposed.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
adjustWindow(BuildContext context) {
|
||||
return futureBuilder(
|
||||
future: isWindowCanBeAdjusted(),
|
||||
future: isWindowCanBeAdjusted(context),
|
||||
hasData: (data) {
|
||||
final visible = data as bool;
|
||||
if (!visible) return Offstage();
|
||||
@@ -1364,36 +1376,201 @@ class ScreenAdjustor {
|
||||
});
|
||||
}
|
||||
|
||||
doAdjustWindow(BuildContext context) async {
|
||||
await updateScreen();
|
||||
if (_screen != null) {
|
||||
cbExitFullscreen();
|
||||
double scale = _screen!.scaleFactor;
|
||||
final wndRect = await WindowController.fromWindowId(windowId).getFrame();
|
||||
final mediaSize = MediaQueryData.fromView(View.of(context)).size;
|
||||
// On windows, wndRect is equal to GetWindowRect and mediaSize is equal to GetClientRect.
|
||||
// Linux screen and work-area coordinates can use different units or become
|
||||
// unreliable across Wayland/X11 state changes, so normalize reported frames
|
||||
// and cache usable work-area measurements before sizing the window.
|
||||
|
||||
Future<void> _updateLinuxWorkAreaCache({
|
||||
required window_size.Screen screen,
|
||||
required Rect wndRect,
|
||||
required bool isWayland,
|
||||
required bool isX11,
|
||||
required bool forMenu,
|
||||
}) async {
|
||||
if (isWayland &&
|
||||
(_waylandWorkAreaScreenFrame != screen.frame ||
|
||||
_waylandWorkAreaScaleFactor != screen.scaleFactor)) {
|
||||
_waylandMaximizedWorkAreaSize = null;
|
||||
_waylandWorkAreaScreenFrame = screen.frame;
|
||||
_waylandWorkAreaScaleFactor = screen.scaleFactor;
|
||||
}
|
||||
if (isWayland &&
|
||||
forMenu &&
|
||||
!isFullscreen &&
|
||||
await isWindowMaximized() == true) {
|
||||
_waylandMaximizedWorkAreaSize = wndRect.size;
|
||||
}
|
||||
if (isX11 &&
|
||||
(_x11WorkAreaScreenFrame != screen.frame ||
|
||||
_x11WorkAreaScaleFactor != screen.scaleFactor)) {
|
||||
_x11WorkArea = null;
|
||||
_x11WorkAreaScreenFrame = screen.frame;
|
||||
_x11WorkAreaScaleFactor = screen.scaleFactor;
|
||||
}
|
||||
if (isX11 && forMenu && !isFullscreen) {
|
||||
_x11WorkArea = screen.visibleFrame;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Rect?> _getEffectiveScreenFrame({
|
||||
required window_size.Screen screen,
|
||||
required bool isWayland,
|
||||
required bool isX11,
|
||||
required bool forMenu,
|
||||
}) async {
|
||||
Rect frameRect = screen.visibleFrame;
|
||||
if (isMacOS && forMenu && isFullscreen) {
|
||||
List<double>? workArea;
|
||||
try {
|
||||
workArea = await kMacOSPermChannel
|
||||
.invokeListMethod<double>('getMacOSWorkAreaSize');
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
if (workArea == null || workArea.length != 2) {
|
||||
return null;
|
||||
}
|
||||
frameRect = Rect.fromLTWH(
|
||||
frameRect.left,
|
||||
frameRect.top,
|
||||
workArea[0] < frameRect.width ? workArea[0] : frameRect.width,
|
||||
workArea[1] < frameRect.height ? workArea[1] : frameRect.height,
|
||||
);
|
||||
}
|
||||
final x11WorkArea = _x11WorkArea;
|
||||
if (isX11 &&
|
||||
forMenu &&
|
||||
isFullscreen &&
|
||||
x11WorkArea != null &&
|
||||
(x11WorkArea.width < frameRect.width ||
|
||||
x11WorkArea.height < frameRect.height)) {
|
||||
frameRect = x11WorkArea;
|
||||
}
|
||||
final screenScale = screen.scaleFactor;
|
||||
if (isWayland && screenScale > 1.01) {
|
||||
String monitorLayoutMode;
|
||||
try {
|
||||
monitorLayoutMode =
|
||||
await bind.mainGetCommon(key: 'gnome-monitor-layout-mode');
|
||||
} catch (_) {
|
||||
monitorLayoutMode = '';
|
||||
}
|
||||
if (monitorLayoutMode == 'physical') {
|
||||
frameRect = Rect.fromLTRB(
|
||||
frameRect.left / screenScale,
|
||||
frameRect.top / screenScale,
|
||||
frameRect.right / screenScale,
|
||||
frameRect.bottom / screenScale,
|
||||
);
|
||||
}
|
||||
}
|
||||
return frameRect;
|
||||
}
|
||||
|
||||
Future<Rect?> _getAdjustedWindowFrame(Size mediaSize,
|
||||
{bool forMenu = false}) async {
|
||||
final screen = _screen;
|
||||
if (screen != null) {
|
||||
// Windows window frames use physical pixels while Flutter view sizes are
|
||||
// logical. macOS and Linux window frames use the same units as Flutter.
|
||||
double scale = isWindows ? screen.scaleFactor : 1.0;
|
||||
final Rect wndRect;
|
||||
try {
|
||||
wndRect = await WindowController.fromWindowId(windowId).getFrame();
|
||||
} catch (e) {
|
||||
debugPrint("Failed to get frame of window $windowId, it may be hidden");
|
||||
return null;
|
||||
}
|
||||
// On Windows, wndRect is GetWindowRect while mediaSize is GetClientRect.
|
||||
// https://stackoverflow.com/a/7561083
|
||||
double magicWidth =
|
||||
wndRect.right - wndRect.left - mediaSize.width * scale;
|
||||
double magicHeight =
|
||||
wndRect.bottom - wndRect.top - mediaSize.height * scale;
|
||||
final canvasModel = ffi.canvasModel;
|
||||
// canvasModel.scale is the rendered scale and already applies kIgnoreDpi.
|
||||
// Use it instead of the remote source resolution.
|
||||
final isWayland = isLinux && bind.mainCurrentIsWayland();
|
||||
final isX11 = isLinux && !isWayland;
|
||||
await _updateLinuxWorkAreaCache(
|
||||
screen: screen,
|
||||
wndRect: wndRect,
|
||||
isWayland: isWayland,
|
||||
isX11: isX11,
|
||||
forMenu: forMenu,
|
||||
);
|
||||
if (isWindows && forMenu && isFullscreen) {
|
||||
// desktop_multi_window's hidden title bar keeps 8 physical pixels on
|
||||
// each horizontal edge and at the bottom, plus up to 1px at the top.
|
||||
// Fullscreen removes these in WM_NCCALCSIZE, so predict the restored
|
||||
// frame's worst-case padding when deciding whether to show the menu.
|
||||
magicWidth = 16.0;
|
||||
magicHeight = 9.0;
|
||||
}
|
||||
double horizontalEdges;
|
||||
double verticalEdges;
|
||||
if (forMenu && (isLinux || ((isMacOS || isWindows) && isFullscreen))) {
|
||||
// Linux Adjust Window unmaximizes; macOS and Windows exit fullscreen
|
||||
// before resizing. Predict the restored normal-window edges when
|
||||
// deciding whether to show the menu item.
|
||||
final resizePadding = isLinux && !kUseCompatibleUiMode
|
||||
? kDragToResizeAreaPaddingSize
|
||||
: 0.0;
|
||||
final windowEdge = kWindowBorderWidth + resizePadding;
|
||||
horizontalEdges = windowEdge * 2;
|
||||
verticalEdges = kDesktopRemoteTabBarHeight + windowEdge * 2;
|
||||
} else {
|
||||
horizontalEdges = CanvasModel.leftToEdge + CanvasModel.rightToEdge;
|
||||
verticalEdges = CanvasModel.topToEdge + CanvasModel.bottomToEdge;
|
||||
}
|
||||
final width = (canvasModel.getDisplayWidth() * canvasModel.scale +
|
||||
CanvasModel.leftToEdge +
|
||||
CanvasModel.rightToEdge) *
|
||||
horizontalEdges) *
|
||||
scale +
|
||||
magicWidth;
|
||||
final height = (canvasModel.getDisplayHeight() * canvasModel.scale +
|
||||
CanvasModel.topToEdge +
|
||||
CanvasModel.bottomToEdge) *
|
||||
scale +
|
||||
magicHeight;
|
||||
final height =
|
||||
(canvasModel.getDisplayHeight() * canvasModel.scale + verticalEdges) *
|
||||
scale +
|
||||
magicHeight;
|
||||
double left = wndRect.left + (wndRect.width - width) / 2;
|
||||
double top = wndRect.top + (wndRect.height - height) / 2;
|
||||
|
||||
Rect frameRect = _screen!.frame;
|
||||
if (!isFullscreen) {
|
||||
frameRect = _screen!.visibleFrame;
|
||||
final frameRect = await _getEffectiveScreenFrame(
|
||||
screen: screen,
|
||||
isWayland: isWayland,
|
||||
isX11: isX11,
|
||||
forMenu: forMenu,
|
||||
);
|
||||
if (frameRect == null) {
|
||||
return null;
|
||||
}
|
||||
var availableSize = frameRect.size;
|
||||
if (isWayland && forMenu && _waylandMaximizedWorkAreaSize != null) {
|
||||
final cachedSize = _waylandMaximizedWorkAreaSize!;
|
||||
availableSize = Size(
|
||||
cachedSize.width < availableSize.width
|
||||
? cachedSize.width
|
||||
: availableSize.width,
|
||||
cachedSize.height < availableSize.height
|
||||
? cachedSize.height
|
||||
: availableSize.height,
|
||||
);
|
||||
}
|
||||
// A window frame cannot be smaller than its client area. Tolerate small
|
||||
// floating-point differences; larger negative values mean the native
|
||||
// frame and Flutter view metrics are not synchronized.
|
||||
if (magicWidth < -0.1 || magicHeight < -0.1) {
|
||||
return null;
|
||||
}
|
||||
// Reject implausibly small targets to avoid hiding the window.
|
||||
if (width < 300 || height < 300) {
|
||||
return null;
|
||||
}
|
||||
// The remote size may change after the menu is built. Reject targets
|
||||
// that exceed the available area.
|
||||
final exceedsScreen =
|
||||
width > availableSize.width || height > availableSize.height;
|
||||
if (exceedsScreen) {
|
||||
return null;
|
||||
}
|
||||
if (left < frameRect.left) {
|
||||
left = frameRect.left;
|
||||
@@ -1407,69 +1584,101 @@ class ScreenAdjustor {
|
||||
if ((top + height) > frameRect.bottom) {
|
||||
top = frameRect.bottom - height;
|
||||
}
|
||||
await WindowController.fromWindowId(windowId)
|
||||
.setFrame(Rect.fromLTWH(left, top, width, height));
|
||||
return Rect.fromLTWH(left, top, width, height);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
doAdjustWindow([BuildContext? context]) async {
|
||||
// A resolution change is adjusted after a delay, when the menu context may
|
||||
// already be disposed. Each desktop_multi_window window has its own engine,
|
||||
// so that engine's first view is the current window.
|
||||
final views = WidgetsBinding.instance.platformDispatcher.views;
|
||||
if (context == null && views.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final view = context != null ? View.of(context) : views.first;
|
||||
await updateScreen();
|
||||
if (_screen != null) {
|
||||
final wc = WindowController.fromWindowId(windowId);
|
||||
final wasFullscreen = isFullscreen;
|
||||
cbExitFullscreen();
|
||||
if (wasFullscreen) {
|
||||
// Wait for the native fullscreen exit to update the window frame.
|
||||
await Future.delayed(Duration(milliseconds: 700));
|
||||
await updateScreen();
|
||||
}
|
||||
if (isLinux) {
|
||||
final isMaximized = await isWindowMaximized();
|
||||
if (isMaximized == null) {
|
||||
return;
|
||||
}
|
||||
if (isMaximized == true) {
|
||||
// setFrame may be ignored while the native window is maximized.
|
||||
try {
|
||||
await wc.unmaximize();
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
stateGlobal.setMaximized(false);
|
||||
// Wait for the window manager and Flutter view metrics to reflect
|
||||
// the restored window before calculating and setting its frame.
|
||||
await Future.delayed(Duration(milliseconds: 300));
|
||||
await updateScreen();
|
||||
}
|
||||
}
|
||||
final mediaSize = MediaQueryData.fromView(view).size;
|
||||
final frame = await _getAdjustedWindowFrame(mediaSize);
|
||||
if (frame == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await wc.setFrame(frame);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
stateGlobal.setMaximized(false);
|
||||
}
|
||||
}
|
||||
|
||||
updateScreen() async {
|
||||
final String info =
|
||||
isWeb ? screenInfo : await _getScreenInfoDesktop() ?? '';
|
||||
if (info.isEmpty) {
|
||||
_screen = null;
|
||||
} else {
|
||||
final screenMap = jsonDecode(info);
|
||||
_screen = window_size.Screen(
|
||||
Rect.fromLTRB(screenMap['frame']['l'], screenMap['frame']['t'],
|
||||
screenMap['frame']['r'], screenMap['frame']['b']),
|
||||
Rect.fromLTRB(
|
||||
screenMap['visibleFrame']['l'],
|
||||
screenMap['visibleFrame']['t'],
|
||||
screenMap['visibleFrame']['r'],
|
||||
screenMap['visibleFrame']['b']),
|
||||
screenMap['scaleFactor']);
|
||||
_screen = await _getCurrentScreen();
|
||||
}
|
||||
|
||||
Future<window_size.Screen?> _getCurrentScreen() async {
|
||||
try {
|
||||
return (await window_size.getWindowInfo()).screen;
|
||||
} catch (e) {
|
||||
debugPrint('Failed to get current window screen: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
_getScreenInfoDesktop() async {
|
||||
final v = await rustDeskWinManager.call(
|
||||
WindowType.Main, kWindowGetWindowInfo, '');
|
||||
return v.result;
|
||||
}
|
||||
|
||||
Future<bool> isWindowCanBeAdjusted() async {
|
||||
Future<bool> isWindowCanBeAdjusted([BuildContext? context]) async {
|
||||
if (isWeb) {
|
||||
return false;
|
||||
}
|
||||
// Capture the view before awaiting because the menu context may be disposed.
|
||||
final views = WidgetsBinding.instance.platformDispatcher.views;
|
||||
if (context == null && views.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
final view = context != null ? View.of(context) : views.first;
|
||||
final mediaSize = MediaQueryData.fromView(view).size;
|
||||
final viewStyle =
|
||||
await bind.sessionGetViewStyle(sessionId: ffi.sessionId) ?? '';
|
||||
if (viewStyle != kRemoteViewStyleOriginal) {
|
||||
return false;
|
||||
}
|
||||
if (!isWeb) {
|
||||
final remoteCount = RemoteCountState.find().value;
|
||||
if (remoteCount != 1) {
|
||||
return false;
|
||||
}
|
||||
final remoteCount = RemoteCountState.find().value;
|
||||
if (remoteCount != 1) {
|
||||
return false;
|
||||
}
|
||||
await updateScreen();
|
||||
if (_screen == null) {
|
||||
return false;
|
||||
}
|
||||
final scale = kIgnoreDpi ? 1.0 : _screen!.scaleFactor;
|
||||
double selfWidth = _screen!.visibleFrame.width;
|
||||
double selfHeight = _screen!.visibleFrame.height;
|
||||
if (isFullscreen) {
|
||||
selfWidth = _screen!.frame.width;
|
||||
selfHeight = _screen!.frame.height;
|
||||
}
|
||||
|
||||
final canvasModel = ffi.canvasModel;
|
||||
final displayWidth = canvasModel.getDisplayWidth();
|
||||
final displayHeight = canvasModel.getDisplayHeight();
|
||||
final requiredWidth =
|
||||
CanvasModel.leftToEdge + displayWidth + CanvasModel.rightToEdge;
|
||||
final requiredHeight =
|
||||
CanvasModel.topToEdge + displayHeight + CanvasModel.bottomToEdge;
|
||||
return selfWidth > (requiredWidth * scale) &&
|
||||
selfHeight > (requiredHeight * scale);
|
||||
return await _getAdjustedWindowFrame(mediaSize, forMenu: true) != null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1478,20 +1687,11 @@ class _DisplayMenu extends StatefulWidget {
|
||||
final FFI ffi;
|
||||
final ToolbarState state;
|
||||
final Function(bool) setFullscreen;
|
||||
final Widget pluginItem;
|
||||
_DisplayMenu(
|
||||
{Key? key,
|
||||
required this.id,
|
||||
const _DisplayMenu(
|
||||
{required this.id,
|
||||
required this.ffi,
|
||||
required this.state,
|
||||
required this.setFullscreen})
|
||||
: pluginItem = LocationItem.createLocationItem(
|
||||
id,
|
||||
ffi,
|
||||
kLocationClientRemoteToolbarDisplay,
|
||||
true,
|
||||
),
|
||||
super(key: key);
|
||||
required this.setFullscreen});
|
||||
|
||||
@override
|
||||
State<_DisplayMenu> createState() => _DisplayMenuState();
|
||||
@@ -1529,7 +1729,6 @@ class _DisplayMenuState extends State<_DisplayMenu> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
_screenAdjustor.updateScreen();
|
||||
menuChildrenGetter(_IconSubmenuButtonState state) {
|
||||
final menuChildren = <Widget>[
|
||||
_screenAdjustor.adjustWindow(context),
|
||||
@@ -1582,9 +1781,6 @@ class _DisplayMenuState extends State<_DisplayMenu> {
|
||||
]);
|
||||
}
|
||||
}
|
||||
if (ffi.connType == ConnType.defaultConn) {
|
||||
menuChildren.add(widget.pluginItem);
|
||||
}
|
||||
return menuChildren;
|
||||
}
|
||||
|
||||
@@ -2096,15 +2292,19 @@ class _ResolutionsMenuState extends State<_ResolutionsMenu> {
|
||||
|
||||
Future<void> _getLocalResolutionWayland() async {
|
||||
if (!isWayland) return _getLocalResolution();
|
||||
final window = await window_size.getWindowInfo();
|
||||
final screen = window.screen;
|
||||
if (screen != null) {
|
||||
setState(() {
|
||||
_localResolution = Resolution(
|
||||
screen.frame.width.toInt(),
|
||||
screen.frame.height.toInt(),
|
||||
);
|
||||
});
|
||||
try {
|
||||
final window = await window_size.getWindowInfo();
|
||||
final screen = window.screen;
|
||||
if (screen != null) {
|
||||
setState(() {
|
||||
_localResolution = Resolution(
|
||||
screen.frame.width.toInt(),
|
||||
screen.frame.height.toInt(),
|
||||
);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Failed to get local resolution on Wayland: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2176,8 +2376,16 @@ class _ResolutionsMenuState extends State<_ResolutionsMenu> {
|
||||
return;
|
||||
}
|
||||
if (w == rect.width.toInt() && h == rect.height.toInt()) {
|
||||
if (await widget.screenAdjustor.isWindowCanBeAdjusted()) {
|
||||
widget.screenAdjustor.doAdjustWindow(context);
|
||||
if (!await widget.screenAdjustor.isWindowCanBeAdjusted()) {
|
||||
return;
|
||||
}
|
||||
if (widget.screenAdjustor.isFullscreen) {
|
||||
return;
|
||||
}
|
||||
if ((await widget.screenAdjustor.isWindowMaximized()) == false) {
|
||||
// This delayed callback can outlive the menu State, so its context
|
||||
// is unsafe.
|
||||
widget.screenAdjustor.doAdjustWindow();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -30,9 +30,6 @@ import 'mobile/pages/server_page.dart';
|
||||
import 'mobile/widgets/deploy_dialog.dart';
|
||||
import 'models/platform_model.dart';
|
||||
|
||||
import 'package:flutter_hbb/plugin/handlers.dart'
|
||||
if (dart.library.html) 'package:flutter_hbb/web/plugin/handlers.dart';
|
||||
|
||||
/// Basic window and launch properties.
|
||||
int? kWindowId;
|
||||
WindowType? kWindowType;
|
||||
@@ -141,8 +138,6 @@ void runMainApp(bool startService) async {
|
||||
await bind.mainCheckConnectStatus();
|
||||
if (startService) {
|
||||
gFFI.serverModel.startService();
|
||||
bind.pluginSyncUi(syncTo: kAppTypeMain);
|
||||
bind.pluginListReload();
|
||||
}
|
||||
await Future.wait([gFFI.abModel.loadCache(), gFFI.groupModel.loadCache()]);
|
||||
gFFI.userModel.refreshCurrentUser();
|
||||
@@ -570,12 +565,6 @@ _registerEventHandler() {
|
||||
reloadAllWindows();
|
||||
});
|
||||
}
|
||||
// Register native handlers.
|
||||
if (isDesktop) {
|
||||
platformFFI.registerEventHandler('native_ui', 'native_ui', (evt) async {
|
||||
NativeUiHandler.instance.onEvent(evt);
|
||||
});
|
||||
}
|
||||
if (isAndroid) {
|
||||
platformFFI.registerEventHandler(
|
||||
'android_needs_deploy', 'android_needs_deploy', (_) async {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_breadcrumb/flutter_breadcrumb.dart';
|
||||
@@ -8,6 +9,7 @@ import 'package:toggle_switch/toggle_switch.dart';
|
||||
|
||||
import '../../common.dart';
|
||||
import '../../common/widgets/dialog.dart';
|
||||
import '../../consts.dart';
|
||||
|
||||
class FileManagerPage extends StatefulWidget {
|
||||
FileManagerPage(
|
||||
@@ -73,6 +75,173 @@ class _FileManagerPageState extends State<FileManagerPage> {
|
||||
DirectoryOptions get currentOptions => currentFileController.options.value;
|
||||
final _uniqueKey = UniqueKey();
|
||||
|
||||
Future<T> _runAndroidDocumentPicker<T>(Future<T> Function() action) async {
|
||||
gFFI.ffiModel.beginAndroidDocumentPicker();
|
||||
try {
|
||||
return await action();
|
||||
} finally {
|
||||
gFFI.ffiModel.endAndroidDocumentPicker();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _importFiles() async {
|
||||
var imported = 0;
|
||||
var failed = false;
|
||||
final importController = currentFileController;
|
||||
final importDirectory = currentDir.path;
|
||||
final importIsWindows = currentOptions.isWindows;
|
||||
try {
|
||||
final selectedFiles = await _runAndroidDocumentPicker(() =>
|
||||
gFFI.invokeMethodWithResult<List<dynamic>>(
|
||||
AndroidChannel.kPickImportFiles));
|
||||
if (selectedFiles == null || selectedFiles.isEmpty) return;
|
||||
|
||||
for (final selected in selectedFiles) {
|
||||
final uri = (selected as Map<dynamic, dynamic>)['uri'] as String?;
|
||||
final selectedName = selected['name'] as String?;
|
||||
final name = selectedName?.replaceAll('\\', '/').split('/').last;
|
||||
if (uri == null ||
|
||||
name == null ||
|
||||
!PathUtil.validName(name, importIsWindows)) {
|
||||
failed = true;
|
||||
continue;
|
||||
}
|
||||
final destination =
|
||||
PathUtil.join(importDirectory, name, importIsWindows);
|
||||
var overwrite = false;
|
||||
if (await File(destination).exists()) {
|
||||
final overwriteResult = await model.showFileConfirmDialog(
|
||||
translate('Overwrite'), destination, false, false);
|
||||
if (overwriteResult == false) break;
|
||||
if (overwriteResult != true) continue;
|
||||
overwrite = true;
|
||||
}
|
||||
try {
|
||||
final success = await gFFI.invokeMethod(
|
||||
AndroidChannel.kImportFile,
|
||||
{'uri': uri, 'path': destination, 'overwrite': overwrite});
|
||||
if (success == true) {
|
||||
imported++;
|
||||
} else {
|
||||
failed = true;
|
||||
}
|
||||
} catch (e) {
|
||||
failed = true;
|
||||
debugPrint('Failed to import $name: $e');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
failed = true;
|
||||
debugPrint('Failed to select files for import: $e');
|
||||
}
|
||||
await importController.refresh();
|
||||
if (failed) {
|
||||
showToast(translate('Failed'));
|
||||
} else if (imported > 0) {
|
||||
showToast(translate('Successful'));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _exportFile(Entry entry) async {
|
||||
try {
|
||||
final exported = await _runAndroidDocumentPicker(() => gFFI
|
||||
.invokeMethod(AndroidChannel.kExportFile, {'path': entry.path}));
|
||||
if (exported == true) {
|
||||
showToast(translate('Successful'));
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Failed to export ${entry.name}: $e');
|
||||
showToast(translate('Failed'));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _importFolder() async {
|
||||
final importController = currentFileController;
|
||||
final importDirectory = currentDir.path;
|
||||
final importIsWindows = currentOptions.isWindows;
|
||||
try {
|
||||
final picked = await _runAndroidDocumentPicker(() =>
|
||||
gFFI.invokeMethodWithResult<Map<dynamic, dynamic>>(
|
||||
AndroidChannel.kPickImportDirectory));
|
||||
if (picked == null || picked.isEmpty) return;
|
||||
final uri = picked['uri'] as String?;
|
||||
final name =
|
||||
(picked['name'] as String?)?.replaceAll('\\', '/').split('/').last;
|
||||
if (uri == null ||
|
||||
name == null ||
|
||||
name == '.' ||
|
||||
name == '..' ||
|
||||
!PathUtil.validName(name, importIsWindows)) {
|
||||
showToast(translate('Failed'));
|
||||
return;
|
||||
}
|
||||
final destination = PathUtil.join(importDirectory, name, importIsWindows);
|
||||
final destinationType = await FileSystemEntity.type(destination);
|
||||
var overwrite = false;
|
||||
if (destinationType == FileSystemEntityType.directory) {
|
||||
final overwriteResult = await model.showFileConfirmDialog(
|
||||
translate('Overwrite'), destination, false, false);
|
||||
if (overwriteResult != true) return;
|
||||
overwrite = true;
|
||||
} else if (destinationType != FileSystemEntityType.notFound) {
|
||||
showToast(translate('Failed'));
|
||||
return;
|
||||
}
|
||||
final success = await gFFI.invokeMethod(AndroidChannel.kImportDirectory,
|
||||
{'uri': uri, 'path': destination, 'overwrite': overwrite});
|
||||
if (success == true) {
|
||||
showToast(translate('Successful'));
|
||||
} else {
|
||||
showToast(translate('Failed'));
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Failed to import folder: $e');
|
||||
showToast(translate('Failed'));
|
||||
}
|
||||
await importController.refresh();
|
||||
}
|
||||
|
||||
Future<void> _exportItems(SelectedItems items) async {
|
||||
await _exportPaths(items.items.map((e) => e.path));
|
||||
}
|
||||
|
||||
Future<void> _exportLogs() async {
|
||||
final home = currentFileController.homePath;
|
||||
if (home.isEmpty) {
|
||||
showToast(translate('Failed'));
|
||||
return;
|
||||
}
|
||||
final appDir = PathUtil.join(home, appName, false);
|
||||
final paths = [
|
||||
PathUtil.join(appDir, 'Logs', false),
|
||||
PathUtil.join(appDir, 'ScreenRecord', false),
|
||||
].where((p) => File(p).existsSync() || Directory(p).existsSync()).toList();
|
||||
if (paths.isEmpty) {
|
||||
showToast(translate('Failed'));
|
||||
return;
|
||||
}
|
||||
await _exportPaths(paths);
|
||||
}
|
||||
|
||||
Future<void> _exportPaths(Iterable<String> paths) async {
|
||||
try {
|
||||
final result = await _runAndroidDocumentPicker(() =>
|
||||
gFFI.invokeMethodWithResult<Map<dynamic, dynamic>>(
|
||||
AndroidChannel.kExportFiles, {'paths': paths.toList()}));
|
||||
if (result == null) return;
|
||||
final exported = result['exported'] as int? ?? 0;
|
||||
final failed = result['failed'] as int? ?? 0;
|
||||
if (failed > 0) {
|
||||
showToast(translate('Failed'));
|
||||
} else if (exported > 0) {
|
||||
showToast(translate('Successful'));
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Failed to export paths: $e');
|
||||
showToast(translate('Failed'));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -159,6 +328,45 @@ class _FileManagerPageState extends State<FileManagerPage> {
|
||||
),
|
||||
value: "refresh",
|
||||
),
|
||||
if (isAndroid)
|
||||
PopupMenuItem(
|
||||
enabled: showLocal && currentDir.path.isNotEmpty,
|
||||
value: "import",
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.add_to_drive,
|
||||
color: Theme.of(context).iconTheme.color),
|
||||
SizedBox(width: 5),
|
||||
Text(translate("Add"))
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isAndroid)
|
||||
PopupMenuItem(
|
||||
enabled: showLocal && currentDir.path.isNotEmpty,
|
||||
value: "import_folder",
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.create_new_folder_outlined,
|
||||
color: Theme.of(context).iconTheme.color),
|
||||
SizedBox(width: 5),
|
||||
Text(translate("Import Folder"))
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isAndroid)
|
||||
PopupMenuItem(
|
||||
enabled: showLocal && currentDir.path.isNotEmpty,
|
||||
value: "export_logs",
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.article_outlined,
|
||||
color: Theme.of(context).iconTheme.color),
|
||||
SizedBox(width: 5),
|
||||
Text(translate("Export Logs"))
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
enabled: currentDir.path != "/",
|
||||
child: Row(
|
||||
@@ -203,6 +411,12 @@ class _FileManagerPageState extends State<FileManagerPage> {
|
||||
onSelected: (v) {
|
||||
if (v == "refresh") {
|
||||
currentFileController.refresh();
|
||||
} else if (v == "import") {
|
||||
_importFiles();
|
||||
} else if (v == "import_folder") {
|
||||
_importFolder();
|
||||
} else if (v == "export_logs") {
|
||||
_exportLogs();
|
||||
} else if (v == "select") {
|
||||
model.localController.selectedItems.clear();
|
||||
model.remoteController.selectedItems.clear();
|
||||
@@ -300,6 +514,24 @@ class _FileManagerPageState extends State<FileManagerPage> {
|
||||
setState(() {});
|
||||
},
|
||||
actions: [
|
||||
if (isAndroid &&
|
||||
selectedItems?.isLocal == true &&
|
||||
selectedItems?.items.isNotEmpty == true) ...[
|
||||
if (selectedItems!.items.length == 1 &&
|
||||
selectedItems!.items.single.isFile)
|
||||
IconButton(
|
||||
tooltip: translate("Save as"),
|
||||
icon: Icon(Icons.save_alt),
|
||||
onPressed: () =>
|
||||
_exportFile(selectedItems!.items.single),
|
||||
)
|
||||
else
|
||||
IconButton(
|
||||
tooltip: translate("Export"),
|
||||
icon: Icon(Icons.drive_folder_upload),
|
||||
onPressed: () => _exportItems(selectedItems!),
|
||||
),
|
||||
],
|
||||
IconButton(
|
||||
icon: Icon(Icons.compare_arrows),
|
||||
onPressed: () => setState(() => showLocal = !showLocal),
|
||||
@@ -366,8 +598,7 @@ class _FileManagerPageState extends State<FileManagerPage> {
|
||||
return BottomSheetBody(
|
||||
leading: CircularProgressIndicator(),
|
||||
title: translate("Waiting"),
|
||||
text:
|
||||
"${translate("Speed")}: ${readableFileSize(activeJob.speed)}/s",
|
||||
text: "${readableFileSize(activeJob.speed)}/s",
|
||||
onCanceled: () {
|
||||
model.jobController.cancelJob(activeJob.id);
|
||||
jobTable.clear();
|
||||
|
||||
@@ -1276,6 +1276,14 @@ void showOptions(
|
||||
List<TToggleMenu> cursorToggles = await toolbarCursor(context, id, gFFI);
|
||||
List<TToggleMenu> displayToggles =
|
||||
await toolbarDisplayToggle(context, id, gFFI);
|
||||
if (isMobile) {
|
||||
displayToggles.insert(
|
||||
0,
|
||||
TToggleMenu(
|
||||
child: Text(translate('Lock canvas')),
|
||||
value: gFFI.canvasModel.locked,
|
||||
onChanged: (value) => gFFI.canvasModel.setLocked(value == true)));
|
||||
}
|
||||
|
||||
List<TToggleMenu> privacyModeList = [];
|
||||
if ((gFFI.ffiModel.pi.features.privacyMode && gFFI.ffiModel.keyboard) ||
|
||||
|
||||
@@ -225,12 +225,6 @@ class _ServerPageState extends State<ServerPage> {
|
||||
|
||||
void checkService() async {
|
||||
gFFI.invokeMethod("check_service");
|
||||
// for Android 10/11, request MANAGE_EXTERNAL_STORAGE permission from system setting page
|
||||
if (AndroidPermissionManager.isWaitingFile() && !gFFI.serverModel.fileOk) {
|
||||
AndroidPermissionManager.complete(kManageExternalStorage,
|
||||
await AndroidPermissionManager.check(kManageExternalStorage));
|
||||
debugPrint("file permission finished");
|
||||
}
|
||||
}
|
||||
|
||||
class ServiceNotRunningNotification extends StatelessWidget {
|
||||
|
||||
@@ -97,10 +97,12 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
||||
var _hideNetwork = false;
|
||||
var _hideWebSocket = false;
|
||||
var _enableTrustedDevices = false;
|
||||
var _enableTcpPunch = false;
|
||||
var _enableUdpPunch = false;
|
||||
var _allowInsecureTlsFallback = false;
|
||||
var _disableUdp = false;
|
||||
var _enableIpv6Punch = false;
|
||||
var _enableWebrtc = false;
|
||||
var _isUsingPublicServer = false;
|
||||
var _allowAskForNoteAtEndOfConnection = false;
|
||||
var _preventSleepWhileConnected = true;
|
||||
@@ -141,8 +143,10 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
||||
bind.mainGetBuildinOption(key: kOptionHideWebSocketSetting) == 'Y' ||
|
||||
isWeb;
|
||||
_enableTrustedDevices = mainGetBoolOptionSync(kOptionEnableTrustedDevices);
|
||||
_enableTcpPunch = mainGetLocalBoolOptionSync(kOptionEnableTcpPunch);
|
||||
_enableUdpPunch = mainGetLocalBoolOptionSync(kOptionEnableUdpPunch);
|
||||
_enableIpv6Punch = mainGetLocalBoolOptionSync(kOptionEnableIpv6Punch);
|
||||
_enableWebrtc = mainGetLocalBoolOptionSync(kOptionEnableWebrtc);
|
||||
_allowAskForNoteAtEndOfConnection =
|
||||
mainGetLocalBoolOptionSync(kOptionAllowAskForNoteAtEndOfConnection);
|
||||
_preventSleepWhileConnected =
|
||||
@@ -815,31 +819,65 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
||||
});
|
||||
},
|
||||
),
|
||||
if (!incomingOnly)
|
||||
SettingsTile.switchTile(
|
||||
title: Text(translate('Enable TCP hole punching')),
|
||||
initialValue: _enableTcpPunch,
|
||||
onToggle: isOptionFixed(kOptionEnableTcpPunch)
|
||||
? null
|
||||
: (v) async {
|
||||
await mainSetLocalBoolOption(kOptionEnableTcpPunch, v);
|
||||
final newValue =
|
||||
mainGetLocalBoolOptionSync(kOptionEnableTcpPunch);
|
||||
setState(() {
|
||||
_enableTcpPunch = newValue;
|
||||
});
|
||||
},
|
||||
),
|
||||
if (!incomingOnly)
|
||||
SettingsTile.switchTile(
|
||||
title: Text(translate('Enable UDP hole punching')),
|
||||
initialValue: _enableUdpPunch,
|
||||
onToggle: (v) async {
|
||||
await mainSetLocalBoolOption(kOptionEnableUdpPunch, v);
|
||||
final newValue =
|
||||
mainGetLocalBoolOptionSync(kOptionEnableUdpPunch);
|
||||
setState(() {
|
||||
_enableUdpPunch = newValue;
|
||||
});
|
||||
},
|
||||
onToggle: isOptionFixed(kOptionEnableUdpPunch)
|
||||
? null
|
||||
: (v) async {
|
||||
await mainSetLocalBoolOption(kOptionEnableUdpPunch, v);
|
||||
final newValue =
|
||||
mainGetLocalBoolOptionSync(kOptionEnableUdpPunch);
|
||||
setState(() {
|
||||
_enableUdpPunch = newValue;
|
||||
});
|
||||
},
|
||||
),
|
||||
if (!incomingOnly)
|
||||
SettingsTile.switchTile(
|
||||
title: Text(translate('Enable IPv6 P2P connection')),
|
||||
initialValue: _enableIpv6Punch,
|
||||
onToggle: (v) async {
|
||||
await mainSetLocalBoolOption(kOptionEnableIpv6Punch, v);
|
||||
final newValue =
|
||||
mainGetLocalBoolOptionSync(kOptionEnableIpv6Punch);
|
||||
setState(() {
|
||||
_enableIpv6Punch = newValue;
|
||||
});
|
||||
},
|
||||
onToggle: isOptionFixed(kOptionEnableIpv6Punch)
|
||||
? null
|
||||
: (v) async {
|
||||
await mainSetLocalBoolOption(kOptionEnableIpv6Punch, v);
|
||||
final newValue =
|
||||
mainGetLocalBoolOptionSync(kOptionEnableIpv6Punch);
|
||||
setState(() {
|
||||
_enableIpv6Punch = newValue;
|
||||
});
|
||||
},
|
||||
),
|
||||
if (!incomingOnly)
|
||||
SettingsTile.switchTile(
|
||||
title: Text(translate('Enable WebRTC P2P connection')),
|
||||
initialValue: _enableWebrtc,
|
||||
onToggle: isOptionFixed(kOptionEnableWebrtc)
|
||||
? null
|
||||
: (v) async {
|
||||
await mainSetLocalBoolOption(kOptionEnableWebrtc, v);
|
||||
final newValue =
|
||||
mainGetLocalBoolOptionSync(kOptionEnableWebrtc);
|
||||
setState(() {
|
||||
_enableWebrtc = newValue;
|
||||
});
|
||||
},
|
||||
),
|
||||
SettingsTile(
|
||||
title: Text(translate('Language')),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -8,6 +9,7 @@ import 'package:flutter_hbb/common/widgets/dialog.dart';
|
||||
import 'package:flutter_hbb/models/input_modifier_utils.dart';
|
||||
import 'package:flutter_hbb/models/model.dart';
|
||||
import 'package:flutter_hbb/models/platform_model.dart';
|
||||
import 'package:flutter_hbb/models/terminal_copy_shortcut.dart';
|
||||
import 'package:flutter_hbb/models/terminal_model.dart';
|
||||
import 'package:flutter_hbb/mobile/terminal_keyboard_utils.dart';
|
||||
import 'package:flutter_hbb/web/dummy.dart'
|
||||
@@ -190,6 +192,7 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
KeyEventResult _handleTerminalKeyEvent(FocusNode _, KeyEvent event) {
|
||||
final hardwareKeyboard = HardwareKeyboard.instance;
|
||||
final shouldPaste = shouldHandleTerminalPasteShortcut(
|
||||
platform: defaultTargetPlatform,
|
||||
logicalKey: event.logicalKey,
|
||||
isKeyDown: event is KeyDownEvent,
|
||||
isKeyRepeat: event is KeyRepeatEvent,
|
||||
@@ -244,7 +247,12 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
//
|
||||
// Android works fine without this workaround.
|
||||
deleteDetection: isIOS,
|
||||
onKeyEvent: _handleTerminalKeyEvent,
|
||||
shortcuts: platformTerminalShortcuts(),
|
||||
onKeyEvent: terminalCopyHandler(
|
||||
_terminalModel.terminal,
|
||||
_terminalModel.terminalController,
|
||||
fallback: _handleTerminalKeyEvent,
|
||||
),
|
||||
padding: _calculatePadding(heightPx),
|
||||
onSecondaryTapDown: (details, offset) async {
|
||||
final selection = _terminalModel.terminalController.selection;
|
||||
|
||||
@@ -46,6 +46,12 @@ class JobID {
|
||||
|
||||
typedef GetSessionID = SessionID Function();
|
||||
typedef GetDialogManager = OverlayDialogManager? Function();
|
||||
typedef ReadRemoteDirectory = Future<void> Function(
|
||||
SessionID sessionId, String path, bool includeHidden);
|
||||
|
||||
const _kRemoteReadDirTimeout = Duration(seconds: 30);
|
||||
const _kRemoteSessionChangedError =
|
||||
'Remote directory read cancelled because the session changed';
|
||||
|
||||
class FileModel {
|
||||
final WeakReference<FFI> parent;
|
||||
@@ -84,6 +90,7 @@ class FileModel {
|
||||
}
|
||||
|
||||
Future<void> onReady() async {
|
||||
fileFetcher.beginRemoteSession();
|
||||
await evtLoop.onReady();
|
||||
if (!isWeb) await localController.onReady();
|
||||
await remoteController.onReady();
|
||||
@@ -133,7 +140,11 @@ class FileModel {
|
||||
final id = int.tryParse(evt['id']?.toString() ?? '');
|
||||
if (id != null) {
|
||||
final err = evt['err']?.toString() ?? 'Unknown error';
|
||||
fileFetcher.tryCompleteRecursiveTaskWithError(id, err);
|
||||
if (id == 0) {
|
||||
fileFetcher.tryCompleteRemoteTaskWithError(err);
|
||||
} else {
|
||||
fileFetcher.tryCompleteRecursiveTaskWithError(id, err);
|
||||
}
|
||||
}
|
||||
// Always call jobController.jobError(evt) to ensure all error events are processed,
|
||||
// even if the event does not have a valid job ID. This allows for generic error handling
|
||||
@@ -350,6 +361,8 @@ class FileController {
|
||||
final history = RxList<String>.empty(growable: true);
|
||||
final sortBy = SortBy.name.obs;
|
||||
var sortAscending = true;
|
||||
// Incremented for each navigation; only the latest generation applies results.
|
||||
int _directoryRequestGeneration = 0;
|
||||
final JobController jobController;
|
||||
final WeakReference<FFI> rootState;
|
||||
|
||||
@@ -368,6 +381,14 @@ class FileController {
|
||||
void set homePath(String path) => options.value.home = path;
|
||||
OverlayDialogManager? get dialogManager => rootState.target?.dialogManager;
|
||||
|
||||
bool _isPathAllowed(String candidate) {
|
||||
if (!isAndroid || !isLocal) return true;
|
||||
if (homePath.isEmpty || candidate.isEmpty) return false;
|
||||
final home = PathUtil.posixContext.normalize(homePath);
|
||||
final target = PathUtil.posixContext.normalize(candidate);
|
||||
return target == home || PathUtil.posixContext.isWithin(home, target);
|
||||
}
|
||||
|
||||
String get shortPath {
|
||||
final dirPath = directory.value.path;
|
||||
if (dirPath.startsWith(homePath)) {
|
||||
@@ -401,8 +422,13 @@ class FileController {
|
||||
|
||||
await Future.delayed(Duration(milliseconds: 100));
|
||||
|
||||
final savedDir = (await bind.sessionGetPeerOption(
|
||||
var savedDir = (await bind.sessionGetPeerOption(
|
||||
sessionId: sessionId, name: isLocal ? "local_dir" : "remote_dir"));
|
||||
if (savedDir.isNotEmpty && !_isPathAllowed(savedDir)) {
|
||||
savedDir = options.value.home;
|
||||
await bind.sessionPeerOption(
|
||||
sessionId: sessionId, name: "local_dir", value: savedDir);
|
||||
}
|
||||
Future<bool> tryOpenReadyDirs() async {
|
||||
final dirs = <String>{
|
||||
if (directory.value.path.isNotEmpty) directory.value.path,
|
||||
@@ -472,6 +498,9 @@ class FileController {
|
||||
}
|
||||
|
||||
Future<bool> _openDirectoryPath(String path, {bool isBack = false}) async {
|
||||
if (!_isPathAllowed(path)) {
|
||||
return false;
|
||||
}
|
||||
if (!isBack) {
|
||||
pushHistory();
|
||||
}
|
||||
@@ -484,12 +513,20 @@ class FileController {
|
||||
path = "$path\\";
|
||||
}
|
||||
}
|
||||
final requestGeneration = ++_directoryRequestGeneration;
|
||||
try {
|
||||
final fd = await fileFetcher.fetchDirectory(path, isLocal, showHidden);
|
||||
if (requestGeneration != _directoryRequestGeneration) {
|
||||
return true;
|
||||
}
|
||||
fd.format(isWindows, sort: sortBy.value);
|
||||
selectedItems.reconcile(fd.entries);
|
||||
directory.value = fd;
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (requestGeneration != _directoryRequestGeneration) {
|
||||
return true;
|
||||
}
|
||||
debugPrint("Failed to openDirectory $path: $e");
|
||||
return false;
|
||||
}
|
||||
@@ -530,6 +567,9 @@ class FileController {
|
||||
final isWindows = options.value.isWindows;
|
||||
final dirPath = directory.value.path;
|
||||
var parent = PathUtil.dirname(dirPath, isWindows);
|
||||
if (!_isPathAllowed(parent)) {
|
||||
return true;
|
||||
}
|
||||
// specially for C:\, D:\, goto '/'
|
||||
if (parent == dirPath && isWindows) {
|
||||
return await _openDirectoryPath('/', isBack: isBack);
|
||||
@@ -541,6 +581,7 @@ class FileController {
|
||||
void initDirAndHome(Map<String, dynamic> evt) {
|
||||
try {
|
||||
final fd = FileDirectory.fromJson(jsonDecode(evt['value']));
|
||||
final isHomeResponse = fileFetcher.isLikelyRemoteHomeResponse(fd.path);
|
||||
fd.format(options.value.isWindows, sort: sortBy.value);
|
||||
if (fd.id > 0) {
|
||||
final jobIndex = jobController.getJob(fd.id);
|
||||
@@ -556,10 +597,12 @@ class FileController {
|
||||
debugPrint("update receive details: ${fd.path}");
|
||||
jobController.jobTable.refresh();
|
||||
}
|
||||
} else if (options.value.home.isEmpty) {
|
||||
} else if (options.value.home.isEmpty && isHomeResponse) {
|
||||
options.value.home = fd.path;
|
||||
debugPrint("init remote home: ${fd.path}");
|
||||
directory.value = fd;
|
||||
if (_directoryRequestGeneration == 0) {
|
||||
directory.value = fd;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("initDirAndHome err=$e");
|
||||
@@ -1362,16 +1405,78 @@ class JobResultListener<T> {
|
||||
}
|
||||
}
|
||||
|
||||
class _RemoteReadTask {
|
||||
final bool includeHidden;
|
||||
final Completer<FileDirectory> completer = Completer<FileDirectory>();
|
||||
final Completer<void> released = Completer<void>();
|
||||
late final Timer timer;
|
||||
|
||||
_RemoteReadTask(this.includeHidden);
|
||||
}
|
||||
|
||||
class FileFetcher {
|
||||
// Map<String,Completer<FileDirectory>> localTasks = {}; // now we only use read local dir sync
|
||||
Map<String, Completer<FileDirectory>> remoteTasks = {};
|
||||
final Map<String, _RemoteReadTask> _remoteReadTasks = {};
|
||||
Map<String, Completer<List<FileDirectory>>> remoteEmptyDirsTasks = {};
|
||||
Map<int, Completer<FileDirectory>> readRecursiveTasks = {};
|
||||
int _remoteSessionGeneration = 0;
|
||||
|
||||
final GetSessionID getSessionID;
|
||||
final ReadRemoteDirectory _readRemoteDirectory;
|
||||
SessionID get sessionId => getSessionID();
|
||||
|
||||
FileFetcher(this.getSessionID);
|
||||
FileFetcher(this.getSessionID, {ReadRemoteDirectory? readRemoteDirectory})
|
||||
: _readRemoteDirectory = readRemoteDirectory ??
|
||||
((sessionId, path, includeHidden) => bind.sessionReadRemoteDir(
|
||||
sessionId: sessionId,
|
||||
path: path,
|
||||
includeHidden: includeHidden));
|
||||
|
||||
bool hasPendingRemoteRead(String path) => _remoteReadTasks.containsKey(path);
|
||||
|
||||
bool isLikelyRemoteHomeResponse(String path) =>
|
||||
_remoteReadTasks.isEmpty ||
|
||||
(_remoteReadTasks.length == 1 &&
|
||||
hasPendingRemoteRead("") &&
|
||||
!hasPendingRemoteRead(path));
|
||||
|
||||
void beginRemoteSession() {
|
||||
_remoteSessionGeneration++;
|
||||
final pendingTasks = _remoteReadTasks.entries.toList(growable: false);
|
||||
for (final entry in pendingTasks) {
|
||||
final task = entry.value;
|
||||
if (!_removeRemoteReadTask(entry.key, task)) continue;
|
||||
task.completer.completeError(StateError(_kRemoteSessionChangedError));
|
||||
}
|
||||
}
|
||||
|
||||
_RemoteReadTask _registerRemoteReadTask(String path, bool includeHidden) {
|
||||
if (hasPendingRemoteRead(path)) {
|
||||
throw "Failed to registerReadTask, already have same read job";
|
||||
}
|
||||
final task = _RemoteReadTask(includeHidden);
|
||||
_remoteReadTasks[path] = task;
|
||||
task.timer = Timer(_kRemoteReadDirTimeout, () {
|
||||
if (!_removeRemoteReadTask(path, task)) return;
|
||||
task.completer.completeError("Failed to read dir, timeout");
|
||||
});
|
||||
return task;
|
||||
}
|
||||
|
||||
bool _removeRemoteReadTask(String path, _RemoteReadTask task) {
|
||||
if (!identical(_remoteReadTasks[path], task)) return false;
|
||||
_remoteReadTasks.remove(path);
|
||||
task.timer.cancel();
|
||||
task.released.complete();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool _completeRemoteReadTask(String path, FileDirectory directory) {
|
||||
final task = _remoteReadTasks[path];
|
||||
if (task == null || !_removeRemoteReadTask(path, task)) return false;
|
||||
task.completer.complete(directory);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<List<FileDirectory>> registerReadEmptyDirsTask(
|
||||
bool isLocal, String path) {
|
||||
@@ -1391,23 +1496,6 @@ class FileFetcher {
|
||||
return c.future;
|
||||
}
|
||||
|
||||
Future<FileDirectory> registerReadTask(bool isLocal, String path) {
|
||||
// final jobs = isLocal?localJobs:remoteJobs; // maybe we will use read local dir async later
|
||||
final tasks = remoteTasks; // bypass now
|
||||
if (tasks.containsKey(path)) {
|
||||
throw "Failed to registerReadTask, already have same read job";
|
||||
}
|
||||
final c = Completer<FileDirectory>();
|
||||
tasks[path] = c;
|
||||
|
||||
Timer(Duration(seconds: 2), () {
|
||||
tasks.remove(path);
|
||||
if (c.isCompleted) return;
|
||||
c.completeError("Failed to read dir, timeout");
|
||||
});
|
||||
return c.future;
|
||||
}
|
||||
|
||||
Future<FileDirectory> registerReadRecursiveTask(int actID) {
|
||||
final tasks = readRecursiveTasks;
|
||||
if (tasks.containsKey(actID)) {
|
||||
@@ -1445,27 +1533,37 @@ class FileFetcher {
|
||||
|
||||
tryCompleteTask(String? msg, String? isLocalStr) {
|
||||
if (msg == null || isLocalStr == null) return;
|
||||
late final Map<Object, Completer<FileDirectory>> tasks;
|
||||
try {
|
||||
final fd = FileDirectory.fromJson(jsonDecode(msg));
|
||||
if (fd.id > 0) {
|
||||
// fd.id > 0 is result for read recursive
|
||||
// to-do later,will be better if every fetch use ID,so that there will only one task map for read and recursive read
|
||||
tasks = readRecursiveTasks;
|
||||
final completer = tasks.remove(fd.id);
|
||||
completer?.complete(fd);
|
||||
} else if (fd.path.isNotEmpty) {
|
||||
// result for normal read dir
|
||||
// final jobs = isLocal?localJobs:remoteJobs; // maybe we will use read local dir async later
|
||||
tasks = remoteTasks; // bypass now
|
||||
final completer = tasks.remove(fd.path);
|
||||
final completer = readRecursiveTasks.remove(fd.id);
|
||||
completer?.complete(fd);
|
||||
return;
|
||||
}
|
||||
if (isLocalStr == "false" && fd.path.isNotEmpty) {
|
||||
if (_completeRemoteReadTask(fd.path, fd)) {
|
||||
return;
|
||||
}
|
||||
// A Home request uses an empty path but returns its resolved path.
|
||||
if (isLikelyRemoteHomeResponse(fd.path)) {
|
||||
_completeRemoteReadTask("", fd);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("tryCompleteJob err: $e");
|
||||
}
|
||||
}
|
||||
|
||||
bool tryCompleteRemoteTaskWithError(String error) {
|
||||
if (_remoteReadTasks.length != 1) return false;
|
||||
final entry = _remoteReadTasks.entries.single;
|
||||
final task = entry.value;
|
||||
if (!_removeRemoteReadTask(entry.key, task)) return false;
|
||||
task.completer.completeError(error);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Complete a pending recursive read task with an error.
|
||||
// See FileModel.handleJobError() for why this is necessary.
|
||||
void tryCompleteRecursiveTaskWithError(int id, String error) {
|
||||
@@ -1506,9 +1604,26 @@ class FileFetcher {
|
||||
final fd = FileDirectory.fromJson(jsonDecode(res));
|
||||
return fd;
|
||||
} else {
|
||||
await bind.sessionReadRemoteDir(
|
||||
sessionId: sessionId, path: path, includeHidden: showHidden);
|
||||
return registerReadTask(isLocal, path);
|
||||
final remoteSessionGeneration = _remoteSessionGeneration;
|
||||
final pendingTask = _remoteReadTasks[path];
|
||||
if (pendingTask != null) {
|
||||
if (pendingTask.includeHidden == showHidden) {
|
||||
return pendingTask.completer.future;
|
||||
}
|
||||
await pendingTask.released.future;
|
||||
if (remoteSessionGeneration != _remoteSessionGeneration) {
|
||||
throw StateError(_kRemoteSessionChangedError);
|
||||
}
|
||||
return fetchDirectory(path, isLocal, showHidden);
|
||||
}
|
||||
final task = _registerRemoteReadTask(path, showHidden);
|
||||
unawaited(Future<void>.sync(
|
||||
() => _readRemoteDirectory(sessionId, path, showHidden))
|
||||
.catchError((Object error, StackTrace stackTrace) {
|
||||
if (!_removeRemoteReadTask(path, task)) return;
|
||||
task.completer.completeError(error, stackTrace);
|
||||
}));
|
||||
return task.completer.future;
|
||||
}
|
||||
} catch (e) {
|
||||
return Future.error(e);
|
||||
@@ -1790,7 +1905,7 @@ class PathUtil {
|
||||
}
|
||||
|
||||
static bool validName(String name, bool isWindows) {
|
||||
final unixFileNamePattern = RegExp(r'^[^/\0]+$');
|
||||
final unixFileNamePattern = RegExp(r'^[^/\x00]+$');
|
||||
final windowsFileNamePattern = RegExp(r'^[^<>:"/\\|?*]+$');
|
||||
final reg = isWindows ? windowsFileNamePattern : unixFileNamePattern;
|
||||
return reg.hasMatch(name);
|
||||
@@ -1833,6 +1948,21 @@ class SelectedItems {
|
||||
items.clear();
|
||||
}
|
||||
|
||||
void reconcile(List<Entry> entries) {
|
||||
if (items.isEmpty) return;
|
||||
final currentByPath = {for (final entry in entries) entry.path: entry};
|
||||
final reconciled = <Entry>[];
|
||||
for (final item in items) {
|
||||
final current = currentByPath[item.path];
|
||||
if (current != null && current.entryType == item.entryType) {
|
||||
reconciled.add(current);
|
||||
}
|
||||
}
|
||||
items
|
||||
..clear()
|
||||
..addAll(reconciled);
|
||||
}
|
||||
|
||||
void selectAll(List<Entry> entries) {
|
||||
items.clear();
|
||||
items.addAll(entries);
|
||||
|
||||
@@ -1787,6 +1787,11 @@ class InputModel {
|
||||
}
|
||||
|
||||
bool _checkPeerControlProtected(double x, double y) {
|
||||
if (isViewOnly && showMyCursor) {
|
||||
lastMousePos = ui.Offset(x, y);
|
||||
return false;
|
||||
}
|
||||
|
||||
final cursorModel = parent.target!.cursorModel;
|
||||
if (cursorModel.isPeerControlProtected) {
|
||||
lastMousePos = ui.Offset(x, y);
|
||||
|
||||
@@ -117,10 +117,11 @@ String prepareTerminalInputPayload(
|
||||
|
||||
/// Returns true when a hardware paste shortcut must bypass keyboard modifiers.
|
||||
///
|
||||
/// xterm already handles hardware Ctrl/Cmd+V correctly in the common case. Only
|
||||
/// intercept while a virtual Ctrl/Alt lock is active, because xterm can emit a
|
||||
/// one-character paste as normal text when bracketed paste mode is disabled.
|
||||
/// xterm already handles each platform's paste shortcut in the common case.
|
||||
/// Only intercept while a virtual Ctrl/Alt lock is active, because xterm can
|
||||
/// emit a one-character paste as normal text when bracketed paste mode is off.
|
||||
bool shouldHandleTerminalPasteShortcut({
|
||||
required TargetPlatform platform,
|
||||
required LogicalKeyboardKey logicalKey,
|
||||
required bool isKeyDown,
|
||||
required bool isKeyRepeat,
|
||||
@@ -133,8 +134,18 @@ bool shouldHandleTerminalPasteShortcut({
|
||||
if (!modifierLockActive) return false;
|
||||
if (!isKeyDown && !isKeyRepeat) return false;
|
||||
if (logicalKey != LogicalKeyboardKey.keyV) return false;
|
||||
if (altPressed || shiftPressed) return false;
|
||||
return controlPressed != metaPressed;
|
||||
if (altPressed) return false;
|
||||
switch (platform) {
|
||||
case TargetPlatform.linux:
|
||||
return controlPressed && !metaPressed && shiftPressed;
|
||||
case TargetPlatform.iOS:
|
||||
case TargetPlatform.macOS:
|
||||
return !controlPressed && metaPressed && !shiftPressed;
|
||||
case TargetPlatform.android:
|
||||
case TargetPlatform.fuchsia:
|
||||
case TargetPlatform.windows:
|
||||
return controlPressed && !metaPressed && !shiftPressed;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true when collapsing Row3 should also clear hidden modifier state.
|
||||
|
||||
@@ -25,9 +25,6 @@ import 'package:flutter_hbb/models/user_model.dart';
|
||||
import 'package:flutter_hbb/models/state_model.dart';
|
||||
import 'package:flutter_hbb/models/desktop_render_texture.dart';
|
||||
import 'package:flutter_hbb/models/terminal_model.dart';
|
||||
import 'package:flutter_hbb/plugin/event.dart';
|
||||
import 'package:flutter_hbb/plugin/manager.dart';
|
||||
import 'package:flutter_hbb/plugin/widgets/desc_ui.dart';
|
||||
import 'package:flutter_hbb/common/shared_state.dart';
|
||||
import 'package:flutter_hbb/utils/multi_window_manager.dart';
|
||||
import 'package:flutter_hbb/utils/http_service.dart' as http;
|
||||
@@ -127,6 +124,8 @@ class FfiModel with ChangeNotifier {
|
||||
Timer? _restartReconnectDelayTimer;
|
||||
var _reconnects = 1;
|
||||
DateTime? _offlineReconnectStartTime;
|
||||
bool _androidDocumentPickerActive = false;
|
||||
bool _androidDocumentPickerInterruptedConnection = false;
|
||||
bool _viewOnly = false;
|
||||
bool _showMyCursor = false;
|
||||
WeakReference<FFI> parent;
|
||||
@@ -258,6 +257,8 @@ class FfiModel with ChangeNotifier {
|
||||
_inputBlocked = false;
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
_androidDocumentPickerActive = false;
|
||||
_androidDocumentPickerInterruptedConnection = false;
|
||||
resetRestartReconnectState();
|
||||
clearPermissions();
|
||||
waitForImageTimer?.cancel();
|
||||
@@ -437,15 +438,6 @@ class FfiModel with ChangeNotifier {
|
||||
parent.target?.serverModel.updateVoiceCallState(evt);
|
||||
} else if (name == 'fingerprint') {
|
||||
FingerprintState.find(peerId).value = evt['fingerprint'] ?? '';
|
||||
} else if (name == 'plugin_manager') {
|
||||
pluginManager.handleEvent(evt);
|
||||
} else if (name == 'plugin_event') {
|
||||
handlePluginEvent(evt,
|
||||
(Map<String, dynamic> e) => handleMsgBox(e, sessionId, peerId));
|
||||
} else if (name == 'plugin_reload') {
|
||||
handleReloading(evt);
|
||||
} else if (name == 'plugin_option') {
|
||||
handleOption(evt);
|
||||
} else if (name == "sync_peer_hash_password_to_personal_ab") {
|
||||
if (desktopType == DesktopType.main || isWeb || isMobile) {
|
||||
final id = evt['id'];
|
||||
@@ -904,6 +896,13 @@ class FfiModel with ChangeNotifier {
|
||||
final text = evt['text'];
|
||||
final link = evt['link'];
|
||||
|
||||
if (isAndroid &&
|
||||
_androidDocumentPickerActive &&
|
||||
title == 'Connection Error') {
|
||||
_androidDocumentPickerInterruptedConnection = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable relative mouse mode on any error-type message to ensure cursor is released.
|
||||
// This includes connection errors, session-ending messages, elevation errors, etc.
|
||||
// Safety: releasing pointer lock on errors prevents the user from being stuck.
|
||||
@@ -920,17 +919,12 @@ class FfiModel with ChangeNotifier {
|
||||
enter2FaDialog(sessionId, dialogManager);
|
||||
} else if (type == 'input-password') {
|
||||
enterPasswordDialog(sessionId, dialogManager);
|
||||
} else if (type == 'session-login' || type == 'session-re-login') {
|
||||
enterUserLoginDialog(sessionId, dialogManager, 'login_linux_tip', true);
|
||||
} else if (type == 'session-login-password') {
|
||||
enterUserLoginAndPasswordDialog(
|
||||
sessionId, dialogManager, 'login_linux_tip', true);
|
||||
} else if (type == 'terminal-admin-login') {
|
||||
enterUserLoginDialog(
|
||||
sessionId, dialogManager, 'terminal-admin-login-tip', false);
|
||||
sessionId, dialogManager, 'terminal-admin-login-tip');
|
||||
} else if (type == 'terminal-admin-login-password') {
|
||||
enterUserLoginAndPasswordDialog(
|
||||
sessionId, dialogManager, 'terminal-admin-login-tip', false);
|
||||
sessionId, dialogManager, 'terminal-admin-login-tip');
|
||||
} else if (type == 'restarting') {
|
||||
// Treat restart messages as reconnect control events. Rust still sends
|
||||
// title/text for legacy UI and translation reuse; Flutter keeps the last
|
||||
@@ -985,6 +979,23 @@ class FfiModel with ChangeNotifier {
|
||||
_restartReconnectDelayTimer = null;
|
||||
}
|
||||
|
||||
void beginAndroidDocumentPicker() {
|
||||
if (!isAndroid) return;
|
||||
_androidDocumentPickerActive = true;
|
||||
_androidDocumentPickerInterruptedConnection = false;
|
||||
}
|
||||
|
||||
void endAndroidDocumentPicker() {
|
||||
if (!isAndroid) return;
|
||||
_androidDocumentPickerActive = false;
|
||||
if (!_androidDocumentPickerInterruptedConnection ||
|
||||
parent.target?.closed == true) {
|
||||
return;
|
||||
}
|
||||
_androidDocumentPickerInterruptedConnection = false;
|
||||
reconnect(parent.target!.dialogManager, sessionId, false);
|
||||
}
|
||||
|
||||
/// Auto-retry check for "Remote desktop is offline" error.
|
||||
/// returns true to auto-retry, false otherwise.
|
||||
bool shouldAutoRetryOnOffline(
|
||||
@@ -2225,6 +2236,7 @@ class CanvasModel with ChangeNotifier {
|
||||
double _y = 0;
|
||||
// image scale
|
||||
double _scale = 1.0;
|
||||
bool _locked = false;
|
||||
double _devicePixelRatio = 1.0;
|
||||
Size _size = Size.zero;
|
||||
// the tabbar over the image
|
||||
@@ -2273,12 +2285,19 @@ class CanvasModel with ChangeNotifier {
|
||||
double get x => _x;
|
||||
double get y => _y;
|
||||
double get scale => _scale;
|
||||
bool get locked => _locked;
|
||||
double get devicePixelRatio => _devicePixelRatio;
|
||||
Size get size => _size;
|
||||
ScrollStyle get scrollStyle => _scrollStyle;
|
||||
ViewStyle get viewStyle => _lastViewStyle;
|
||||
RxBool get imageOverflow => _imageOverflow;
|
||||
|
||||
void setLocked(bool value) {
|
||||
if (_locked == value) return;
|
||||
_locked = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
_resetScroll() => setScrollPercent(0.0, 0.0);
|
||||
|
||||
void setScrollPercent(double x, double y) {
|
||||
@@ -2507,6 +2526,7 @@ class CanvasModel with ChangeNotifier {
|
||||
}
|
||||
|
||||
void updateLocalCursor(double x, double y) {
|
||||
if (parent.target?.ffiModel.viewOnly == true) return;
|
||||
// If keyboard is not permitted, do not move cursor when mouse is moving.
|
||||
if (parent.target != null && parent.target!.ffiModel.keyboard) {
|
||||
// Draw cursor if is not desktop.
|
||||
@@ -2739,6 +2759,7 @@ class CanvasModel with ChangeNotifier {
|
||||
_x = 0;
|
||||
_y = 0;
|
||||
_scale = 1.0;
|
||||
_locked = false;
|
||||
_lastViewStyle = ViewStyle.defaultViewStyle();
|
||||
_timerMobileFocusCanvasCursor?.cancel();
|
||||
_timerMobileRestoreCanvasOffset?.cancel();
|
||||
@@ -2850,7 +2871,7 @@ class CursorData {
|
||||
required this.width,
|
||||
required this.height,
|
||||
}) : hotx = hotxOrigin * scale,
|
||||
hoty = hotxOrigin * scale;
|
||||
hoty = hotyOrigin * scale;
|
||||
|
||||
int _doubleToInt(double v) => (v * 10e6).round().toInt();
|
||||
|
||||
@@ -3576,6 +3597,16 @@ class QualityMonitorModel with ChangeNotifier {
|
||||
bool get show => _show;
|
||||
QualityMonitorData get data => _data;
|
||||
|
||||
// Only a WebRTC session names its transport here: web has no session tab
|
||||
// to show it on, and WebRTC is the one path that can be direct or TURN.
|
||||
String? get webrtcTransport {
|
||||
final ffiModel = parent.target?.ffiModel;
|
||||
if (ffiModel == null) return null;
|
||||
final streamType = ffiModel.cachedPeerData.streamType;
|
||||
if (!streamType.startsWith('WebRTC')) return null;
|
||||
return ffiModel.direct == false ? '$streamType (TURN)' : streamType;
|
||||
}
|
||||
|
||||
checkShowQualityMonitor(SessionID sessionId) async {
|
||||
final show = await bind.sessionGetToggleOption(
|
||||
sessionId: sessionId, arg: 'show-quality-monitor') ==
|
||||
@@ -4067,6 +4098,11 @@ class FFI {
|
||||
return await platformFFI.invokeMethod(method, arguments);
|
||||
}
|
||||
|
||||
Future<T?> invokeMethodWithResult<T>(String method,
|
||||
[dynamic arguments]) async {
|
||||
return await platformFFI.invokeMethodWithResult<T>(method, arguments);
|
||||
}
|
||||
|
||||
// Terminal model management
|
||||
void registerTerminalModel(int terminalId, TerminalModel model) {
|
||||
debugPrint('[FFI] Registering terminal model for terminal $terminalId');
|
||||
@@ -4171,7 +4207,6 @@ class PeerInfo with ChangeNotifier {
|
||||
RxBool isSet = false.obs;
|
||||
|
||||
bool get isWayland => platformAdditions[kPlatformAdditionsIsWayland] == true;
|
||||
bool get isHeadless => platformAdditions[kPlatformAdditionsHeadless] == true;
|
||||
bool get isInstalled =>
|
||||
platform != kPeerPlatformWindows ||
|
||||
platformAdditions[kPlatformAdditionsIsInstalled] == true;
|
||||
|
||||
@@ -4,7 +4,6 @@ import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:external_path/external_path.dart';
|
||||
import 'package:ffi/ffi.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -171,8 +170,10 @@ class PlatformFFI {
|
||||
_startListenEvent(_ffiBind); // global event
|
||||
try {
|
||||
if (isAndroid) {
|
||||
// only support for android
|
||||
_homeDir = (await ExternalPath.getExternalStorageDirectories())[0];
|
||||
// Android file transfer uses app-specific storage. User-selected
|
||||
// files enter and leave this workspace through the system picker.
|
||||
_homeDir = (await getExternalStorageDirectory())?.path ??
|
||||
(await getApplicationSupportDirectory()).path;
|
||||
} else if (isIOS) {
|
||||
// The previous code was `_homeDir = (await getDownloadsDirectory())?.path ?? '';`,
|
||||
// which provided the `downloads` path in the sandbox.
|
||||
@@ -306,6 +307,12 @@ class PlatformFFI {
|
||||
return await _toAndroidChannel.invokeMethod(method, arguments);
|
||||
}
|
||||
|
||||
Future<T?> invokeMethodWithResult<T>(String method,
|
||||
[dynamic arguments]) async {
|
||||
if (!isAndroid) return null;
|
||||
return await _toAndroidChannel.invokeMethod<T>(method, arguments);
|
||||
}
|
||||
|
||||
void syncAndroidServiceAppDirConfigPath() {
|
||||
invokeMethod(AndroidChannel.kSyncAppDirConfigPath, _dir);
|
||||
}
|
||||
|
||||
@@ -210,15 +210,10 @@ class ServerModel with ChangeNotifier {
|
||||
_audioOk = audioOption != 'N';
|
||||
}
|
||||
|
||||
// file
|
||||
if (!await AndroidPermissionManager.check(kManageExternalStorage)) {
|
||||
_fileOk = false;
|
||||
bind.mainSetOption(key: kOptionEnableFileTransfer, value: "N");
|
||||
} else {
|
||||
final fileOption =
|
||||
await bind.mainGetOption(key: kOptionEnableFileTransfer);
|
||||
_fileOk = fileOption != 'N';
|
||||
}
|
||||
// Android file transfer is confined to app-specific storage. Files enter
|
||||
// and leave the workspace through Android's system document picker.
|
||||
final fileOption = await bind.mainGetOption(key: kOptionEnableFileTransfer);
|
||||
_fileOk = fileOption != 'N';
|
||||
|
||||
// clipboard
|
||||
final clipOption = await bind.mainGetOption(key: kOptionEnableClipboard);
|
||||
@@ -319,16 +314,6 @@ class ServerModel with ChangeNotifier {
|
||||
if (clients.any((c) => !c.disconnected)) {
|
||||
await showClientsMayNotBeChangedAlert(parent.target);
|
||||
}
|
||||
if (!_fileOk &&
|
||||
!await AndroidPermissionManager.check(kManageExternalStorage)) {
|
||||
final res =
|
||||
await AndroidPermissionManager.request(kManageExternalStorage);
|
||||
if (!res) {
|
||||
showToast(translate('Failed'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_fileOk = !_fileOk;
|
||||
bind.mainSetOption(
|
||||
key: kOptionEnableFileTransfer,
|
||||
@@ -418,9 +403,6 @@ class ServerModel with ChangeNotifier {
|
||||
if (bind.mainGetLocalOption(key: kOptionDisableFloatingWindow) != 'Y') {
|
||||
await checkFloatingWindowPermission();
|
||||
}
|
||||
if (!await AndroidPermissionManager.check(kManageExternalStorage)) {
|
||||
await AndroidPermissionManager.request(kManageExternalStorage);
|
||||
}
|
||||
final res = await parent.target?.dialogManager
|
||||
.show<bool>((setState, close, context) {
|
||||
submit() => close(true);
|
||||
@@ -738,9 +720,13 @@ class ServerModel with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> closeAll() async {
|
||||
await Future.wait(
|
||||
_clients.map((client) => bind.cmCloseConnection(connId: client.id)));
|
||||
/// `byOperator` false means the CM's window went away rather than a person asking for the
|
||||
/// peers to go. The sessions end either way; only the close reason differs, and with it
|
||||
/// whether the peer is allowed to reconnect. See `ipc::Data::CmWindowClosed`.
|
||||
Future<void> closeAll({bool byOperator = true}) async {
|
||||
await Future.wait(_clients.map((client) => byOperator
|
||||
? bind.cmCloseConnection(connId: client.id)
|
||||
: bind.cmCloseConnectionWindow(connId: client.id)));
|
||||
_clients.clear();
|
||||
tabController.state.value.tabs.clear();
|
||||
if (isAndroid) androidUpdatekeepScreenOn();
|
||||
|
||||
91
flutter/lib/models/terminal_copy_shortcut.dart
Normal file
91
flutter/lib/models/terminal_copy_shortcut.dart
Normal file
@@ -0,0 +1,91 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:xterm/xterm.dart';
|
||||
|
||||
const _controlShiftVPasteShortcut = SingleActivator(
|
||||
LogicalKeyboardKey.keyV,
|
||||
control: true,
|
||||
shift: true,
|
||||
);
|
||||
|
||||
Future<void> writeTerminalClipboard(String text) async {
|
||||
try {
|
||||
await Clipboard.setData(ClipboardData(text: text));
|
||||
} catch (error) {
|
||||
debugPrint('[Terminal] Failed to write clipboard: $error');
|
||||
}
|
||||
}
|
||||
|
||||
Map<ShortcutActivator, Intent>? platformTerminalShortcuts() {
|
||||
final platform = defaultTargetPlatform;
|
||||
if (platform == TargetPlatform.linux) {
|
||||
return {
|
||||
for (final entry in defaultTerminalShortcuts.entries)
|
||||
if (!_isControlShortcut(entry.key, LogicalKeyboardKey.keyV))
|
||||
entry.key: entry.value,
|
||||
_controlShiftVPasteShortcut:
|
||||
const PasteTextIntent(SelectionChangedCause.keyboard),
|
||||
};
|
||||
}
|
||||
if (platform != TargetPlatform.windows &&
|
||||
platform != TargetPlatform.android) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
for (final entry in defaultTerminalShortcuts.entries)
|
||||
if (!_isControlShortcut(
|
||||
entry.key,
|
||||
LogicalKeyboardKey.keyC,
|
||||
shift: true,
|
||||
))
|
||||
entry.key: entry.value,
|
||||
};
|
||||
}
|
||||
|
||||
bool _isControlShortcut(
|
||||
ShortcutActivator shortcut,
|
||||
LogicalKeyboardKey key, {
|
||||
bool shift = false,
|
||||
}) =>
|
||||
shortcut is SingleActivator &&
|
||||
shortcut.trigger == key &&
|
||||
shortcut.control &&
|
||||
shortcut.shift == shift &&
|
||||
!shortcut.alt &&
|
||||
!shortcut.meta;
|
||||
|
||||
FocusOnKeyEventCallback terminalCopyHandler(
|
||||
Terminal terminal,
|
||||
TerminalController controller, {
|
||||
FocusOnKeyEventCallback? fallback,
|
||||
}) =>
|
||||
(focusNode, event) {
|
||||
if (_isSelectionCopyShortcut(event)) {
|
||||
final selection = controller.selection;
|
||||
if (selection != null && !selection.isCollapsed) {
|
||||
if (event is KeyDownEvent) {
|
||||
final text = terminal.buffer.getText(selection);
|
||||
unawaited(writeTerminalClipboard(text));
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
}
|
||||
return fallback?.call(focusNode, event) ?? KeyEventResult.ignored;
|
||||
};
|
||||
|
||||
bool _isSelectionCopyShortcut(KeyEvent event) {
|
||||
final keyboard = HardwareKeyboard.instance;
|
||||
final platform = defaultTargetPlatform;
|
||||
final usesControlCopy =
|
||||
platform == TargetPlatform.windows || platform == TargetPlatform.android;
|
||||
return usesControlCopy &&
|
||||
(event is KeyDownEvent || event is KeyRepeatEvent) &&
|
||||
event.logicalKey == LogicalKeyboardKey.keyC &&
|
||||
keyboard.isControlPressed &&
|
||||
!keyboard.isShiftPressed &&
|
||||
!keyboard.isAltPressed &&
|
||||
!keyboard.isMetaPressed;
|
||||
}
|
||||
235
flutter/lib/models/terminal_mouse_drag_reporter.dart
Normal file
235
flutter/lib/models/terminal_mouse_drag_reporter.dart
Normal file
@@ -0,0 +1,235 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:xterm/xterm.dart';
|
||||
|
||||
const _cellIndexOffset = 1;
|
||||
const _legacyCodeOffset = 32;
|
||||
const _leftButtonCode = 0;
|
||||
const _motionButtonCode = 32;
|
||||
const _releaseButtonCode = 3;
|
||||
const _shiftModifierCode = 4;
|
||||
const _metaModifierCode = 8;
|
||||
const _controlModifierCode = 16;
|
||||
const _modifierCodeMask =
|
||||
_shiftModifierCode | _metaModifierCode | _controlModifierCode;
|
||||
const _normalCoordinateLimit = 223;
|
||||
const _utfCoordinateLimit = 2015;
|
||||
|
||||
String encodeTerminalMouseReport(
|
||||
MouseReportMode mode,
|
||||
int button,
|
||||
CellOffset position, {
|
||||
bool release = false,
|
||||
}) {
|
||||
final x = position.x + _cellIndexOffset;
|
||||
final y = position.y + _cellIndexOffset;
|
||||
final reportedButton =
|
||||
release ? _releaseButtonCode | (button & _modifierCodeMask) : button;
|
||||
switch (mode) {
|
||||
case MouseReportMode.normal:
|
||||
case MouseReportMode.utf:
|
||||
final limit = mode == MouseReportMode.normal
|
||||
? _normalCoordinateLimit
|
||||
: _utfCoordinateLimit;
|
||||
final encodedButton =
|
||||
String.fromCharCode(_legacyCodeOffset + reportedButton);
|
||||
return '\x1b[M$encodedButton${_legacyCoordinate(x, limit)}'
|
||||
'${_legacyCoordinate(y, limit)}';
|
||||
case MouseReportMode.sgr:
|
||||
final suffix = release ? 'm' : 'M';
|
||||
return '\x1b[<$button;$x;$y$suffix';
|
||||
case MouseReportMode.urxvt:
|
||||
return '\x1b[${_legacyCodeOffset + reportedButton};$x;${y}M';
|
||||
}
|
||||
}
|
||||
|
||||
String _legacyCoordinate(int value, int limit) =>
|
||||
value > limit ? '\x00' : String.fromCharCode(_legacyCodeOffset + value);
|
||||
|
||||
int _activeModifierCode() {
|
||||
final keyboard = HardwareKeyboard.instance;
|
||||
return (keyboard.isShiftPressed ? _shiftModifierCode : 0) |
|
||||
(keyboard.isAltPressed ? _metaModifierCode : 0) |
|
||||
(keyboard.isControlPressed ? _controlModifierCode : 0);
|
||||
}
|
||||
|
||||
class TerminalMouseDragReporter {
|
||||
int? _pointerId;
|
||||
TerminalController? _controller;
|
||||
late CellOffset _lastReportedPosition;
|
||||
var _ownsControllerSuspension = false;
|
||||
var _releasePending = false;
|
||||
var _reporting = false;
|
||||
|
||||
bool handleDown(
|
||||
PointerDownEvent event,
|
||||
Terminal terminal,
|
||||
TerminalViewState? terminalView,
|
||||
) {
|
||||
if (!_isPrimaryMouse(event) || !_reportsDrag(terminal.mouseMode)) {
|
||||
return false;
|
||||
}
|
||||
if (terminalView == null || terminalView.widget.readOnly) return false;
|
||||
final controller = terminalView.widget.controller;
|
||||
if (controller == null ||
|
||||
controller.suspendedPointerInputs ||
|
||||
!controller.pointerInput.inputs.contains(PointerInput.tap)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
cancel();
|
||||
_pointerId = event.pointer;
|
||||
_controller = controller;
|
||||
_ownsControllerSuspension = true;
|
||||
_releasePending = true;
|
||||
_reporting = true;
|
||||
controller.setSuspendPointerInput(true);
|
||||
_clearSelection(controller);
|
||||
final position = _cellAt(event, terminalView);
|
||||
_lastReportedPosition = position;
|
||||
terminal.textInput(
|
||||
_report(terminal.mouseReportMode, position),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool handleMove(
|
||||
PointerMoveEvent event,
|
||||
Terminal terminal,
|
||||
TerminalViewState? terminalView,
|
||||
) {
|
||||
if (event.pointer != _pointerId) return false;
|
||||
if (terminalView == null) {
|
||||
cancel();
|
||||
return true;
|
||||
}
|
||||
final reportsDrag = _reportsDrag(terminal.mouseMode);
|
||||
if (!_isPrimaryMouse(event)) {
|
||||
if (_releasePending && reportsDrag) {
|
||||
_reportRelease(
|
||||
terminal,
|
||||
_reporting ? _cellAt(event, terminalView) : _lastReportedPosition,
|
||||
);
|
||||
}
|
||||
cancel();
|
||||
return true;
|
||||
}
|
||||
if (!_reporting || !reportsDrag) {
|
||||
if (!reportsDrag) _releasePending = false;
|
||||
_reporting = false;
|
||||
// Keep ownership until the matching end event to suppress local selection.
|
||||
final controller = _controller;
|
||||
scheduleMicrotask(() => _clearSelection(controller));
|
||||
return true;
|
||||
}
|
||||
|
||||
final position = _cellAt(event, terminalView);
|
||||
_lastReportedPosition = position;
|
||||
terminal.textInput(
|
||||
_report(terminal.mouseReportMode, position, motion: true),
|
||||
);
|
||||
final controller = _controller;
|
||||
scheduleMicrotask(() => _clearSelection(controller));
|
||||
return true;
|
||||
}
|
||||
|
||||
bool handleEnd(
|
||||
PointerEvent event,
|
||||
Terminal terminal,
|
||||
TerminalViewState? terminalView,
|
||||
) {
|
||||
if (event.pointer != _pointerId) return false;
|
||||
if (terminalView != null &&
|
||||
_releasePending &&
|
||||
_reportsDrag(terminal.mouseMode)) {
|
||||
_reportRelease(
|
||||
terminal,
|
||||
_reporting ? _cellAt(event, terminalView) : _lastReportedPosition,
|
||||
);
|
||||
}
|
||||
_clearSelection(_controller);
|
||||
final controller = _controller;
|
||||
_pointerId = null;
|
||||
// Keep xterm's tap recognizer suspended for this pointer event.
|
||||
scheduleMicrotask(() {
|
||||
if (_pointerId == null && identical(_controller, controller)) {
|
||||
_clearSelection(controller);
|
||||
cancel();
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
void cancel() {
|
||||
final controller = _controller;
|
||||
if (_ownsControllerSuspension) {
|
||||
controller?.setSuspendPointerInput(false);
|
||||
}
|
||||
_pointerId = null;
|
||||
_controller = null;
|
||||
_ownsControllerSuspension = false;
|
||||
_releasePending = false;
|
||||
_reporting = false;
|
||||
}
|
||||
|
||||
void updateController(TerminalController controller) {
|
||||
final oldController = _controller;
|
||||
if (_pointerId == null || oldController == null) {
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
if (identical(oldController, controller)) return;
|
||||
if (_ownsControllerSuspension) {
|
||||
oldController.setSuspendPointerInput(false);
|
||||
}
|
||||
final acceptsPointerInput = !controller.suspendedPointerInputs &&
|
||||
controller.pointerInput.inputs.contains(PointerInput.tap);
|
||||
_controller = controller;
|
||||
_ownsControllerSuspension = acceptsPointerInput;
|
||||
_reporting = _reporting && acceptsPointerInput;
|
||||
if (_ownsControllerSuspension) controller.setSuspendPointerInput(true);
|
||||
_clearSelection(controller);
|
||||
}
|
||||
|
||||
void _reportRelease(Terminal terminal, CellOffset position) {
|
||||
terminal.textInput(
|
||||
_report(
|
||||
terminal.mouseReportMode,
|
||||
position,
|
||||
release: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
CellOffset _cellAt(PointerEvent event, TerminalViewState terminalView) {
|
||||
final renderTerminal = terminalView.renderTerminal;
|
||||
return renderTerminal.getCellOffset(
|
||||
renderTerminal.globalToLocal(event.position),
|
||||
);
|
||||
}
|
||||
|
||||
bool _isPrimaryMouse(PointerEvent event) =>
|
||||
event.kind == PointerDeviceKind.mouse &&
|
||||
(event.buttons & kPrimaryMouseButton) == kPrimaryMouseButton;
|
||||
|
||||
bool _reportsDrag(MouseMode mode) =>
|
||||
mode == MouseMode.upDownScrollDrag || mode == MouseMode.upDownScrollMove;
|
||||
|
||||
void _clearSelection(TerminalController? controller) {
|
||||
if (controller == null || controller.selection == null) return;
|
||||
controller.clearSelection();
|
||||
}
|
||||
|
||||
String _report(
|
||||
MouseReportMode mode,
|
||||
CellOffset position, {
|
||||
bool release = false,
|
||||
bool motion = false,
|
||||
}) {
|
||||
final baseButton = motion ? _motionButtonCode : _leftButtonCode;
|
||||
final button = baseButton | _activeModifierCode();
|
||||
return encodeTerminalMouseReport(mode, button, position, release: release);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,19 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:xterm/xterm.dart';
|
||||
|
||||
import 'terminal_copy_shortcut.dart';
|
||||
import 'terminal_mouse_drag_reporter.dart';
|
||||
|
||||
/// xterm 4.0.0 encodes wheel buttons as 68..71; the extra bit reads as a Shift
|
||||
/// modifier, so strict full-screen apps ignore the report and never scroll.
|
||||
/// Upstream fix: TerminalStudio/xterm.dart#238.
|
||||
class WheelButtonFixMouseHandler implements TerminalMouseHandler {
|
||||
const WheelButtonFixMouseHandler();
|
||||
const WheelButtonFixMouseHandler({this.positionProvider});
|
||||
|
||||
final CellOffset? Function()? positionProvider;
|
||||
|
||||
@override
|
||||
String? call(TerminalMouseEvent event) {
|
||||
@@ -23,20 +32,270 @@ class WheelButtonFixMouseHandler implements TerminalMouseHandler {
|
||||
String _reportWheel(TerminalMouseEvent event) {
|
||||
// Wheel buttons 4..7 go on the wire as 64..67, but `id` is 64 + 4..7.
|
||||
final button = event.button.id - 4;
|
||||
final x = event.position.x + 1;
|
||||
final y = event.position.y + 1;
|
||||
switch (event.state.mouseReportMode) {
|
||||
case MouseReportMode.normal:
|
||||
case MouseReportMode.utf:
|
||||
final limit =
|
||||
event.state.mouseReportMode == MouseReportMode.normal ? 223 : 2015;
|
||||
final col = x > limit ? '\x00' : String.fromCharCode(32 + x);
|
||||
final row = y > limit ? '\x00' : String.fromCharCode(32 + y);
|
||||
return '\x1b[M${String.fromCharCode(32 + button)}$col$row';
|
||||
case MouseReportMode.sgr:
|
||||
return '\x1b[<$button;$x;${y}M';
|
||||
case MouseReportMode.urxvt:
|
||||
return '\x1b[${32 + button};$x;${y}M';
|
||||
}
|
||||
final position = positionProvider?.call() ?? event.position;
|
||||
return encodeTerminalMouseReport(
|
||||
event.state.mouseReportMode,
|
||||
button,
|
||||
position,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class TerminalMouseInteraction extends StatefulWidget {
|
||||
const TerminalMouseInteraction(
|
||||
this.terminal, {
|
||||
super.key,
|
||||
required this.controller,
|
||||
this.focusNode,
|
||||
this.backgroundOpacity = 1,
|
||||
this.padding,
|
||||
this.onSecondaryTapDown,
|
||||
});
|
||||
|
||||
final Terminal terminal;
|
||||
final TerminalController controller;
|
||||
final FocusNode? focusNode;
|
||||
final double backgroundOpacity;
|
||||
final EdgeInsets? padding;
|
||||
final void Function(TapDownDetails, CellOffset)? onSecondaryTapDown;
|
||||
|
||||
@override
|
||||
State<TerminalMouseInteraction> createState() =>
|
||||
_TerminalMouseInteractionState();
|
||||
}
|
||||
|
||||
class _TerminalMouseInteractionState extends State<TerminalMouseInteraction> {
|
||||
static const _selectionScrollInterval = Duration(milliseconds: 50);
|
||||
static const _noScroll = 0;
|
||||
static const _scrollUp = -1;
|
||||
static const _scrollDown = 1;
|
||||
|
||||
final _terminalViewKey = GlobalKey<TerminalViewState>();
|
||||
final _scrollController = ScrollController();
|
||||
final _mouseDrag = TerminalMouseDragReporter();
|
||||
late final WheelButtonFixMouseHandler _mouseHandler;
|
||||
TerminalMouseHandler? _previousMouseHandler;
|
||||
Offset? _pointerPosition;
|
||||
Offset? _selectionPointer;
|
||||
CellAnchor? _selectionBase;
|
||||
Buffer? _selectionBuffer;
|
||||
int? _selectionPointerId;
|
||||
Timer? _selectionScrollTimer;
|
||||
var _selectionHasScrolled = false;
|
||||
var _scrollDirection = _noScroll;
|
||||
TerminalViewState? get _terminalView => _terminalViewKey.currentState;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_mouseHandler = WheelButtonFixMouseHandler(
|
||||
positionProvider: _cellAtPointer,
|
||||
);
|
||||
_installMouseHandler(widget.terminal);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(TerminalMouseInteraction oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
final terminalChanged = !identical(oldWidget.terminal, widget.terminal);
|
||||
final controllerChanged =
|
||||
!identical(oldWidget.controller, widget.controller);
|
||||
if (!terminalChanged && !controllerChanged) return;
|
||||
if (controllerChanged && !terminalChanged) {
|
||||
_mouseDrag.updateController(widget.controller);
|
||||
} else {
|
||||
_mouseDrag.cancel();
|
||||
}
|
||||
_clearSelectionDrag();
|
||||
if (!terminalChanged) return;
|
||||
_restoreMouseHandler(oldWidget.terminal);
|
||||
_installMouseHandler(widget.terminal);
|
||||
}
|
||||
|
||||
void _installMouseHandler(Terminal terminal) {
|
||||
_previousMouseHandler = terminal.mouseHandler;
|
||||
terminal.mouseHandler = _mouseHandler;
|
||||
}
|
||||
|
||||
void _restoreMouseHandler(Terminal terminal) {
|
||||
if (identical(terminal.mouseHandler, _mouseHandler)) {
|
||||
terminal.mouseHandler = _previousMouseHandler;
|
||||
}
|
||||
}
|
||||
|
||||
CellOffset? _cellAtPointer() {
|
||||
final terminalView = _terminalView;
|
||||
final pointerPosition = _pointerPosition;
|
||||
if (terminalView == null || pointerPosition == null) return null;
|
||||
final renderTerminal = terminalView.renderTerminal;
|
||||
return renderTerminal.getCellOffset(
|
||||
renderTerminal.globalToLocal(pointerPosition),
|
||||
);
|
||||
}
|
||||
|
||||
void _updatePointerPosition(PointerEvent event) =>
|
||||
_pointerPosition = event.position;
|
||||
|
||||
void _handlePointerDown(PointerDownEvent event) {
|
||||
_updatePointerPosition(event);
|
||||
if (_mouseDrag.handleDown(event, widget.terminal, _terminalView)) {
|
||||
_clearSelectionDrag();
|
||||
return;
|
||||
}
|
||||
if (event.kind != PointerDeviceKind.mouse ||
|
||||
(event.buttons & kPrimaryMouseButton) != kPrimaryMouseButton) {
|
||||
return;
|
||||
}
|
||||
_clearSelectionDrag();
|
||||
final terminalView = _terminalView;
|
||||
if (terminalView == null) return;
|
||||
final renderTerminal = terminalView.renderTerminal;
|
||||
final localPosition = renderTerminal.globalToLocal(event.position);
|
||||
final selectionBuffer = widget.terminal.buffer;
|
||||
_selectionPointerId = event.pointer;
|
||||
_selectionBase = selectionBuffer.createAnchorFromOffset(
|
||||
renderTerminal.getCellOffset(localPosition),
|
||||
);
|
||||
_selectionBuffer = selectionBuffer;
|
||||
_selectionPointer = localPosition;
|
||||
}
|
||||
|
||||
void _handlePointerMove(PointerMoveEvent event) {
|
||||
_updatePointerPosition(event);
|
||||
if (_mouseDrag.handleMove(event, widget.terminal, _terminalView)) return;
|
||||
if (event.pointer != _selectionPointerId) return;
|
||||
if (event.kind != PointerDeviceKind.mouse ||
|
||||
(event.buttons & kPrimaryMouseButton) != kPrimaryMouseButton) {
|
||||
_clearSelectionDrag();
|
||||
return;
|
||||
}
|
||||
final terminalView = _terminalView;
|
||||
if (terminalView == null || _selectionBase == null) return;
|
||||
final renderTerminal = terminalView.renderTerminal;
|
||||
final localPosition = renderTerminal.globalToLocal(event.position);
|
||||
_selectionPointer = localPosition;
|
||||
_setScrollDirection(
|
||||
_directionFor(localPosition, renderTerminal.paintBounds),
|
||||
);
|
||||
if (_selectionHasScrolled) {
|
||||
scheduleMicrotask(() => _scrollSelection(scroll: false));
|
||||
}
|
||||
}
|
||||
|
||||
int _directionFor(Offset position, Rect bounds) {
|
||||
if (position.dy < bounds.top) return _scrollUp;
|
||||
if (position.dy >= bounds.bottom) return _scrollDown;
|
||||
return _noScroll;
|
||||
}
|
||||
|
||||
void _setScrollDirection(int direction) {
|
||||
if (_scrollDirection == direction) return;
|
||||
_stopAutoScroll();
|
||||
_scrollDirection = direction;
|
||||
if (direction == _noScroll) return;
|
||||
_scrollSelection();
|
||||
if (_scrollDirection != _noScroll) {
|
||||
_selectionScrollTimer = Timer.periodic(
|
||||
_selectionScrollInterval,
|
||||
(_) => _scrollSelection(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _scrollSelection({bool scroll = true}) {
|
||||
final terminalView = _terminalView;
|
||||
final selectionBase = _selectionBase;
|
||||
final selectionBuffer = _selectionBuffer;
|
||||
final selectionPointer = _selectionPointer;
|
||||
if (terminalView == null ||
|
||||
selectionBase == null ||
|
||||
selectionBuffer == null ||
|
||||
selectionPointer == null ||
|
||||
!_scrollController.hasClients) {
|
||||
return;
|
||||
}
|
||||
if (!identical(selectionBuffer, widget.terminal.buffer) ||
|
||||
!selectionBase.attached) {
|
||||
_clearSelectionDrag();
|
||||
return;
|
||||
}
|
||||
final renderTerminal = terminalView.renderTerminal;
|
||||
if (scroll) {
|
||||
final position = _scrollController.position;
|
||||
final target =
|
||||
(position.pixels + renderTerminal.lineHeight * _scrollDirection)
|
||||
.clamp(position.minScrollExtent, position.maxScrollExtent)
|
||||
.toDouble();
|
||||
if (target == position.pixels) {
|
||||
_stopAutoScroll();
|
||||
} else {
|
||||
position.jumpTo(target);
|
||||
_selectionHasScrolled = true;
|
||||
}
|
||||
}
|
||||
renderTerminal.selectCharacters(
|
||||
renderTerminal.getOffset(selectionBase.offset),
|
||||
selectionPointer,
|
||||
);
|
||||
}
|
||||
|
||||
void _handlePointerEnd(PointerEvent event) {
|
||||
_updatePointerPosition(event);
|
||||
if (!_mouseDrag.handleEnd(event, widget.terminal, _terminalView) &&
|
||||
event.pointer != _selectionPointerId) return;
|
||||
if (_selectionHasScrolled) _scrollSelection(scroll: false);
|
||||
_clearSelectionDrag();
|
||||
}
|
||||
|
||||
void _clearSelectionDrag() {
|
||||
_selectionPointerId = null;
|
||||
_selectionBase?.dispose();
|
||||
_selectionBase = null;
|
||||
_selectionBuffer = null;
|
||||
_selectionPointer = null;
|
||||
_selectionHasScrolled = false;
|
||||
_stopAutoScroll();
|
||||
}
|
||||
|
||||
void _stopAutoScroll() {
|
||||
_selectionScrollTimer?.cancel();
|
||||
_selectionScrollTimer = null;
|
||||
_scrollDirection = _noScroll;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_mouseDrag.cancel();
|
||||
_clearSelectionDrag();
|
||||
_restoreMouseHandler(widget.terminal);
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Listener(
|
||||
onPointerDown: _handlePointerDown,
|
||||
onPointerMove: _handlePointerMove,
|
||||
onPointerUp: _handlePointerEnd,
|
||||
onPointerHover: _updatePointerPosition,
|
||||
onPointerCancel: _handlePointerEnd,
|
||||
onPointerSignal: _updatePointerPosition,
|
||||
onPointerPanZoomStart: _updatePointerPosition,
|
||||
onPointerPanZoomUpdate: _updatePointerPosition,
|
||||
onPointerPanZoomEnd: _updatePointerPosition,
|
||||
child: TerminalView(
|
||||
widget.terminal,
|
||||
key: _terminalViewKey,
|
||||
controller: widget.controller,
|
||||
scrollController: _scrollController,
|
||||
focusNode: widget.focusNode,
|
||||
backgroundOpacity: widget.backgroundOpacity,
|
||||
padding: widget.padding,
|
||||
shortcuts: platformTerminalShortcuts(),
|
||||
onKeyEvent: terminalCopyHandler(widget.terminal, widget.controller),
|
||||
onSecondaryTapDown: widget.onSecondaryTapDown,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,6 +251,11 @@ class PlatformFFI {
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<T?> invokeMethodWithResult<T>(String method,
|
||||
[dynamic arguments]) async {
|
||||
return null;
|
||||
}
|
||||
|
||||
// just for compilation
|
||||
void syncAndroidServiceAppDirConfigPath() {}
|
||||
|
||||
|
||||
@@ -10,8 +10,6 @@ final isWebDesktop_ = false;
|
||||
|
||||
final isDesktop_ = Platform.isWindows || Platform.isMacOS || Platform.isLinux;
|
||||
|
||||
String get screenInfo_ => '';
|
||||
|
||||
final isWebOnWindows_ = false;
|
||||
final isWebOnLinux_ = false;
|
||||
final isWebOnMacOS_ = false;
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import 'dart:convert';
|
||||
|
||||
typedef PluginId = String;
|
||||
|
||||
// ui location
|
||||
const String kLocationHostMainPlugin = 'host|main|settings|plugin';
|
||||
const String kLocationClientRemoteToolbarDisplay =
|
||||
'client|remote|toolbar|display';
|
||||
|
||||
class MsgFromUi {
|
||||
String id;
|
||||
String name;
|
||||
String location;
|
||||
String key;
|
||||
String value;
|
||||
String action;
|
||||
|
||||
MsgFromUi({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.location,
|
||||
required this.key,
|
||||
required this.value,
|
||||
required this.action,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
'id': id,
|
||||
'name': name,
|
||||
'location': location,
|
||||
'key': key,
|
||||
'value': value,
|
||||
'action': action,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return jsonEncode(toJson());
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
void handlePluginEvent(
|
||||
Map<String, dynamic> evt,
|
||||
Function(Map<String, dynamic> e) handleMsgBox,
|
||||
) {
|
||||
Map<String, dynamic>? content;
|
||||
try {
|
||||
content = json.decode(evt['content']);
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'Json decode plugin event content failed: $e, ${evt['content']}');
|
||||
}
|
||||
if (content?['t'] == 'MsgBox') {
|
||||
handleMsgBox(content?['c']);
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:ffi';
|
||||
|
||||
import 'package:ffi/ffi.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hbb/plugin/ui_manager.dart';
|
||||
import 'package:flutter_hbb/plugin/utils/dialogs.dart';
|
||||
|
||||
abstract class NativeHandler {
|
||||
bool onEvent(Map<String, dynamic> evt);
|
||||
}
|
||||
|
||||
typedef OnSelectPeersCallback = Bool Function(Int returnCode,
|
||||
Pointer<Void> data, Uint64 dataLength, Pointer<Void> userData);
|
||||
typedef OnSelectPeersCallbackDart = bool Function(
|
||||
int returnCode, Pointer<Void> data, int dataLength, Pointer<Void> userData);
|
||||
|
||||
class NativeUiHandler extends NativeHandler {
|
||||
NativeUiHandler._();
|
||||
|
||||
static NativeUiHandler instance = NativeUiHandler._();
|
||||
|
||||
@override
|
||||
bool onEvent(Map<String, dynamic> evt) {
|
||||
final name = evt['name'];
|
||||
final action = evt['action'];
|
||||
if (name != "native_ui") {
|
||||
return false;
|
||||
}
|
||||
switch (action) {
|
||||
case "select_peers":
|
||||
int cb = evt['cb'];
|
||||
int userData = evt['user_data'] ?? 0;
|
||||
final cbFuncNative = Pointer.fromAddress(cb)
|
||||
.cast<NativeFunction<OnSelectPeersCallback>>();
|
||||
final cbFuncDart = cbFuncNative.asFunction<OnSelectPeersCallbackDart>();
|
||||
onSelectPeers(cbFuncDart, userData);
|
||||
break;
|
||||
case "register_ui_entry":
|
||||
int cb = evt['on_tap_cb'];
|
||||
int userData = evt['user_data'] ?? 0;
|
||||
String title = evt['title'] ?? "";
|
||||
final cbFuncNative = Pointer.fromAddress(cb)
|
||||
.cast<NativeFunction<OnSelectPeersCallback>>();
|
||||
final cbFuncDart = cbFuncNative.asFunction<OnSelectPeersCallbackDart>();
|
||||
onRegisterUiEntry(title, cbFuncDart, userData);
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void onSelectPeers(OnSelectPeersCallbackDart cb, int userData) async {
|
||||
showPeerSelectionDialog(onPeersCallback: (peers) {
|
||||
String json = jsonEncode(<String, dynamic> {
|
||||
"peers": peers
|
||||
});
|
||||
final native = json.toNativeUtf8();
|
||||
cb(0, native.cast(), native.length, Pointer.fromAddress(userData));
|
||||
malloc.free(native);
|
||||
});
|
||||
}
|
||||
|
||||
void onRegisterUiEntry(String title, OnSelectPeersCallbackDart cbFuncDart, int userData) {
|
||||
Widget widget = InkWell(
|
||||
child: Container(
|
||||
height: 25.0,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: Text(title)),
|
||||
Icon(Icons.chevron_right_rounded, size: 12.0,)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
PluginUiManager.instance.registerEntry(title, widget);
|
||||
}
|
||||
}
|
||||
@@ -1,319 +0,0 @@
|
||||
// The plugin manager is a singleton class that manages the plugins.
|
||||
// 1. It merge metadata and the desc of plugins.
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:collection';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
const String kValueTrue = '1';
|
||||
const String kValueFalse = '0';
|
||||
|
||||
class ConfigItem {
|
||||
String key;
|
||||
String description;
|
||||
String defaultValue;
|
||||
|
||||
ConfigItem(this.key, this.defaultValue, this.description);
|
||||
ConfigItem.fromJson(Map<String, dynamic> json)
|
||||
: key = json['key'] ?? '',
|
||||
description = json['description'] ?? '',
|
||||
defaultValue = json['default'] ?? '';
|
||||
|
||||
static String get trueValue => kValueTrue;
|
||||
static String get falseValue => kValueFalse;
|
||||
static bool isTrue(String value) => value == kValueTrue;
|
||||
static bool isFalse(String value) => value == kValueFalse;
|
||||
}
|
||||
|
||||
class UiType {
|
||||
String key;
|
||||
String text;
|
||||
String tooltip;
|
||||
String action;
|
||||
|
||||
UiType(this.key, this.text, this.tooltip, this.action);
|
||||
|
||||
UiType.fromJson(Map<String, dynamic> json)
|
||||
: key = json['key'] ?? '',
|
||||
text = json['text'] ?? '',
|
||||
tooltip = json['tooltip'] ?? '',
|
||||
action = json['action'] ?? '';
|
||||
|
||||
static UiType? create(Map<String, dynamic> json) {
|
||||
if (json['t'] == 'Button') {
|
||||
return UiButton.fromJson(json['c']);
|
||||
} else if (json['t'] == 'Checkbox') {
|
||||
return UiCheckbox.fromJson(json['c']);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class UiButton extends UiType {
|
||||
String icon;
|
||||
|
||||
UiButton(
|
||||
{required String key,
|
||||
required String text,
|
||||
required this.icon,
|
||||
required String tooltip,
|
||||
required String action})
|
||||
: super(key, text, tooltip, action);
|
||||
|
||||
UiButton.fromJson(Map<String, dynamic> json)
|
||||
: icon = json['icon'] ?? '',
|
||||
super.fromJson(json);
|
||||
}
|
||||
|
||||
class UiCheckbox extends UiType {
|
||||
UiCheckbox(
|
||||
{required String key,
|
||||
required String text,
|
||||
required String tooltip,
|
||||
required String action})
|
||||
: super(key, text, tooltip, action);
|
||||
|
||||
UiCheckbox.fromJson(Map<String, dynamic> json) : super.fromJson(json);
|
||||
}
|
||||
|
||||
class Location {
|
||||
// location key:
|
||||
// host|main|settings|plugin
|
||||
// client|remote|toolbar|display
|
||||
HashMap<String, UiType> ui;
|
||||
|
||||
Location(this.ui);
|
||||
Location.fromJson(Map<String, dynamic> json) : ui = HashMap() {
|
||||
(json['ui'] as Map<String, dynamic>).forEach((key, value) {
|
||||
var ui = UiType.create(value);
|
||||
if (ui != null) {
|
||||
this.ui[ui.key] = ui;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class PublishInfo {
|
||||
PublishInfo({
|
||||
required this.lastReleased,
|
||||
required this.published,
|
||||
});
|
||||
|
||||
final DateTime lastReleased;
|
||||
final DateTime published;
|
||||
}
|
||||
|
||||
class Meta {
|
||||
Meta({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.version,
|
||||
required this.description,
|
||||
required this.author,
|
||||
required this.home,
|
||||
required this.license,
|
||||
required this.publishInfo,
|
||||
required this.source,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final String version;
|
||||
final String description;
|
||||
final String author;
|
||||
final String home;
|
||||
final String license;
|
||||
final PublishInfo publishInfo;
|
||||
final String source;
|
||||
}
|
||||
|
||||
class SourceInfo {
|
||||
String name; // 1. RustDesk github 2. Local
|
||||
String url;
|
||||
String description;
|
||||
|
||||
SourceInfo({
|
||||
required this.name,
|
||||
required this.url,
|
||||
required this.description,
|
||||
});
|
||||
}
|
||||
|
||||
class PluginInfo with ChangeNotifier {
|
||||
SourceInfo sourceInfo;
|
||||
Meta meta;
|
||||
String installedVersion; // It is empty if not installed.
|
||||
String failedMsg;
|
||||
String invalidReason; // It is empty if valid.
|
||||
|
||||
PluginInfo({
|
||||
required this.sourceInfo,
|
||||
required this.meta,
|
||||
required this.installedVersion,
|
||||
required this.invalidReason,
|
||||
this.failedMsg = '',
|
||||
});
|
||||
|
||||
bool get installed => installedVersion.isNotEmpty;
|
||||
bool get needUpdate => installed && installedVersion != meta.version;
|
||||
|
||||
void setInstall(String msg) {
|
||||
if (msg == "finished") {
|
||||
msg = '';
|
||||
}
|
||||
failedMsg = msg;
|
||||
if (msg.isEmpty) {
|
||||
installedVersion = meta.version;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setUninstall(String msg) {
|
||||
failedMsg = msg;
|
||||
if (msg.isEmpty) {
|
||||
installedVersion = '';
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
class PluginManager with ChangeNotifier {
|
||||
String failedReason = ''; // The reason of failed to load plugins.
|
||||
final List<PluginInfo> _plugins = [];
|
||||
|
||||
PluginManager._();
|
||||
static final PluginManager _instance = PluginManager._();
|
||||
static PluginManager get instance => _instance;
|
||||
|
||||
List<PluginInfo> get plugins => _plugins;
|
||||
|
||||
PluginInfo? getPlugin(String id) {
|
||||
for (var p in _plugins) {
|
||||
if (p.meta.id == id) {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void handleEvent(Map<String, dynamic> evt) {
|
||||
if (evt['plugin_list'] != null) {
|
||||
_handlePluginList(evt['plugin_list']);
|
||||
} else if (evt['plugin_install'] != null && evt['id'] != null) {
|
||||
_handlePluginInstall(evt['id'], evt['plugin_install']);
|
||||
} else if (evt['plugin_uninstall'] != null && evt['id'] != null) {
|
||||
_handlePluginUninstall(evt['id'], evt['plugin_uninstall']);
|
||||
} else {
|
||||
debugPrint('Failed to handle manager event: $evt');
|
||||
}
|
||||
}
|
||||
|
||||
void _sortPlugins() {
|
||||
plugins.sort((a, b) {
|
||||
if (a.installed) {
|
||||
return -1;
|
||||
} else if (b.installed) {
|
||||
return 1;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _handlePluginList(String pluginList) {
|
||||
_plugins.clear();
|
||||
try {
|
||||
for (var p in json.decode(pluginList) as List<dynamic>) {
|
||||
final plugin = _getPluginFromEvent(p);
|
||||
if (plugin == null) {
|
||||
continue;
|
||||
}
|
||||
_plugins.add(plugin);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Failed to decode $e, plugin list \'$pluginList\'');
|
||||
}
|
||||
_sortPlugins();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _handlePluginInstall(String id, String msg) {
|
||||
debugPrint('Plugin \'$id\' install msg $msg');
|
||||
for (var i = 0; i < _plugins.length; i++) {
|
||||
if (_plugins[i].meta.id == id) {
|
||||
_plugins[i].setInstall(msg);
|
||||
_sortPlugins();
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _handlePluginUninstall(String id, String msg) {
|
||||
debugPrint('Plugin \'$id\' uninstall msg $msg');
|
||||
for (var i = 0; i < _plugins.length; i++) {
|
||||
if (_plugins[i].meta.id == id) {
|
||||
_plugins[i].setUninstall(msg);
|
||||
_sortPlugins();
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PluginInfo? _getPluginFromEvent(Map<String, dynamic> evt) {
|
||||
final s = evt['source'];
|
||||
assert(s != null, 'Source is null');
|
||||
if (s == null) {
|
||||
return null;
|
||||
}
|
||||
final source = SourceInfo(
|
||||
name: s['name'],
|
||||
url: s['url'] ?? '',
|
||||
description: s['description'] ?? '',
|
||||
);
|
||||
|
||||
final m = evt['meta'];
|
||||
assert(m != null, 'Meta is null');
|
||||
if (m == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
late DateTime lastReleased;
|
||||
late DateTime published;
|
||||
try {
|
||||
lastReleased = DateTime.parse(
|
||||
m['publish_info']?['last_released'] ?? '1970-01-01T00+00:00');
|
||||
} catch (e) {
|
||||
lastReleased = DateTime.utc(1970);
|
||||
}
|
||||
try {
|
||||
published = DateTime.parse(
|
||||
m['publish_info']?['published'] ?? '1970-01-01T00+00:00');
|
||||
} catch (e) {
|
||||
published = DateTime.utc(1970);
|
||||
}
|
||||
|
||||
final meta = Meta(
|
||||
id: m['id'],
|
||||
name: m['name'],
|
||||
version: m['version'],
|
||||
description: m['description'] ?? '',
|
||||
author: m['author'],
|
||||
home: m['home'] ?? '',
|
||||
license: m['license'] ?? '',
|
||||
source: m['source'] ?? '',
|
||||
publishInfo:
|
||||
PublishInfo(lastReleased: lastReleased, published: published),
|
||||
);
|
||||
return PluginInfo(
|
||||
sourceInfo: source,
|
||||
meta: meta,
|
||||
installedVersion: evt['installed_version'],
|
||||
invalidReason: evt['invalid_reason'] ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
PluginManager get pluginManager => PluginManager.instance;
|
||||
@@ -1,110 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import './common.dart';
|
||||
import './manager.dart';
|
||||
|
||||
final Map<String, LocationModel> _locationModels = {};
|
||||
final Map<String, OptionModel> _optionModels = {};
|
||||
|
||||
class OptionModel with ChangeNotifier {
|
||||
String? v;
|
||||
|
||||
String? get value => v;
|
||||
set value(String? v) {
|
||||
this.v = v;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
static String key(String location, PluginId id, String peer, String k) =>
|
||||
'$location|$id|$peer|$k';
|
||||
}
|
||||
|
||||
class PluginModel with ChangeNotifier {
|
||||
final List<UiType> uiList = [];
|
||||
final Map<String, String> opts = {};
|
||||
|
||||
void add(List<UiType> uiList) {
|
||||
bool found = false;
|
||||
for (var ui in uiList) {
|
||||
for (int i = 0; i < this.uiList.length; i++) {
|
||||
if (this.uiList[i].key == ui.key) {
|
||||
this.uiList[i] = ui;
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
this.uiList.add(ui);
|
||||
}
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
String? getOpt(String key) => opts.remove(key);
|
||||
|
||||
bool get isEmpty => uiList.isEmpty;
|
||||
}
|
||||
|
||||
class LocationModel with ChangeNotifier {
|
||||
final Map<PluginId, PluginModel> pluginModels = {};
|
||||
|
||||
void add(PluginId id, List<UiType> uiList) {
|
||||
if (pluginModels[id] != null) {
|
||||
pluginModels[id]!.add(uiList);
|
||||
} else {
|
||||
var model = PluginModel();
|
||||
model.add(uiList);
|
||||
pluginModels[id] = model;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void clear() {
|
||||
pluginModels.clear();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void remove(PluginId id) {
|
||||
pluginModels.remove(id);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
bool get isEmpty => pluginModels.isEmpty;
|
||||
}
|
||||
|
||||
void addLocationUi(String location, PluginId id, List<UiType> uiList) {
|
||||
if (_locationModels[location] == null) {
|
||||
_locationModels[location] = LocationModel();
|
||||
}
|
||||
_locationModels[location]?.add(id, uiList);
|
||||
}
|
||||
|
||||
LocationModel? getLocationModel(String location) => _locationModels[location];
|
||||
|
||||
PluginModel? getPluginModel(String location, PluginId id) =>
|
||||
_locationModels[location]?.pluginModels[id];
|
||||
|
||||
void clearPlugin(PluginId pluginId) {
|
||||
for (var element in _locationModels.values) {
|
||||
element.remove(pluginId);
|
||||
}
|
||||
}
|
||||
|
||||
void clearLocations() {
|
||||
for (var element in _locationModels.values) {
|
||||
element.clear();
|
||||
}
|
||||
}
|
||||
|
||||
OptionModel getOptionModel(
|
||||
String location, PluginId pluginId, String peer, String key) {
|
||||
final k = OptionModel.key(location, pluginId, peer, key);
|
||||
if (_optionModels[k] == null) {
|
||||
_optionModels[k] = OptionModel();
|
||||
}
|
||||
return _optionModels[k]!;
|
||||
}
|
||||
|
||||
void updateOption(
|
||||
String location, PluginId id, String peer, String key, String value) {
|
||||
final k = OptionModel.key(location, id, peer, key);
|
||||
_optionModels[k]?.value = value;
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class PluginUiManager {
|
||||
PluginUiManager._();
|
||||
|
||||
static PluginUiManager instance = PluginUiManager._();
|
||||
|
||||
Map<String, Widget> entries = <String, Widget>{};
|
||||
|
||||
void registerEntry(String key, Widget widget) {
|
||||
entries[key] = widget;
|
||||
}
|
||||
|
||||
void unregisterEntry(String key) {
|
||||
entries.remove(key);
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
|
||||
void showPeerSelectionDialog(
|
||||
{bool singleSelection = false,
|
||||
required Function(List<String>) onPeersCallback}) async {
|
||||
// load recent peers, we can directly use the peers in `gFFI.recentPeersModel`.
|
||||
// The plugin is not used for now, so just left it empty here.
|
||||
final peers = '';
|
||||
if (peers.isEmpty) {
|
||||
// debugPrint("load recent peers failed.");
|
||||
return;
|
||||
}
|
||||
|
||||
Map<String, dynamic> map = jsonDecode(peers);
|
||||
List<dynamic> peersList = map['peers'] ?? [];
|
||||
final selected = List<String>.empty(growable: true);
|
||||
|
||||
submit() async {
|
||||
onPeersCallback.call(selected);
|
||||
}
|
||||
|
||||
gFFI.dialogManager.show((setState, close, context) {
|
||||
return CustomAlertDialog(
|
||||
title:
|
||||
Text(translate(singleSelection ? "Select peers" : "Select a peer")),
|
||||
content: SizedBox(
|
||||
height: 300.0,
|
||||
child: ListView.builder(
|
||||
itemBuilder: (context, index) {
|
||||
final Map<String, dynamic> peer = peersList[index];
|
||||
final String platform = peer['platform'] ?? "";
|
||||
final String id = peer['id'] ?? "";
|
||||
final String alias = peer['alias'] ?? "";
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
if (selected.contains(id)) {
|
||||
selected.remove(id);
|
||||
} else {
|
||||
selected.add(id);
|
||||
}
|
||||
});
|
||||
},
|
||||
child: Container(
|
||||
key: ValueKey(index),
|
||||
height: 50.0,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).highlightColor,
|
||||
borderRadius: BorderRadius.circular(12.0)),
|
||||
padding: EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0),
|
||||
margin: EdgeInsets.symmetric(vertical: 4.0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
// platform
|
||||
SizedBox(
|
||||
width: 8.0,
|
||||
),
|
||||
Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
getPlatformImage(platform, size: 34.0),
|
||||
],
|
||||
),
|
||||
SizedBox(
|
||||
width: 8.0,
|
||||
),
|
||||
// id/alias
|
||||
Expanded(child: Text(alias.isEmpty ? id : alias)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
itemCount: peersList.length,
|
||||
itemExtent: 50.0,
|
||||
),
|
||||
),
|
||||
onSubmit: submit,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,301 +0,0 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/models/model.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:get/get.dart';
|
||||
// to-do: do not depend on desktop
|
||||
import 'package:flutter_hbb/desktop/widgets/remote_toolbar.dart';
|
||||
import 'package:flutter_hbb/models/platform_model.dart';
|
||||
|
||||
import '../manager.dart';
|
||||
import '../model.dart';
|
||||
import '../common.dart';
|
||||
|
||||
// dup to flutter\lib\desktop\pages\desktop_setting_page.dart
|
||||
const double _kCheckBoxLeftMargin = 10;
|
||||
|
||||
class LocationItem extends StatelessWidget {
|
||||
final String peerId;
|
||||
final FFI ffi;
|
||||
final String location;
|
||||
final LocationModel locationModel;
|
||||
final bool isMenu;
|
||||
|
||||
LocationItem({
|
||||
Key? key,
|
||||
required this.peerId,
|
||||
required this.ffi,
|
||||
required this.location,
|
||||
required this.locationModel,
|
||||
required this.isMenu,
|
||||
}) : super(key: key);
|
||||
|
||||
bool get isEmpty => locationModel.isEmpty;
|
||||
|
||||
static Widget createLocationItem(
|
||||
String peerId, FFI ffi, String location, bool isMenu) {
|
||||
final model = getLocationModel(location);
|
||||
return model == null
|
||||
? Container()
|
||||
: LocationItem(
|
||||
peerId: peerId,
|
||||
ffi: ffi,
|
||||
location: location,
|
||||
locationModel: model,
|
||||
isMenu: isMenu,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ChangeNotifierProvider.value(
|
||||
value: locationModel,
|
||||
child: Consumer<LocationModel>(builder: (context, model, child) {
|
||||
return Column(
|
||||
children: model.pluginModels.entries
|
||||
.map((entry) => _buildPluginItem(entry.key, entry.value))
|
||||
.toList(),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPluginItem(PluginId id, PluginModel model) => PluginItem(
|
||||
pluginId: id,
|
||||
peerId: peerId,
|
||||
ffi: ffi,
|
||||
location: location,
|
||||
pluginModel: model,
|
||||
isMenu: isMenu,
|
||||
);
|
||||
}
|
||||
|
||||
class PluginItem extends StatelessWidget {
|
||||
final PluginId pluginId;
|
||||
final String peerId;
|
||||
final FFI? ffi;
|
||||
final String location;
|
||||
final PluginModel pluginModel;
|
||||
final bool isMenu;
|
||||
|
||||
PluginItem({
|
||||
Key? key,
|
||||
required this.pluginId,
|
||||
required this.peerId,
|
||||
this.ffi,
|
||||
required this.location,
|
||||
required this.pluginModel,
|
||||
required this.isMenu,
|
||||
}) : super(key: key);
|
||||
|
||||
bool get isEmpty => pluginModel.isEmpty;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ChangeNotifierProvider.value(
|
||||
value: pluginModel,
|
||||
child: Consumer<PluginModel>(
|
||||
builder: (context, pluginModel, child) {
|
||||
return Column(
|
||||
children: pluginModel.uiList.map((ui) => _buildItem(ui)).toList(),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildItem(UiType ui) {
|
||||
Widget? child;
|
||||
switch (ui.runtimeType) {
|
||||
case UiButton:
|
||||
if (isMenu) {
|
||||
if (ffi != null) {
|
||||
child = _buildMenuButton(ui as UiButton, ffi!);
|
||||
}
|
||||
} else {
|
||||
child = _buildButton(ui as UiButton);
|
||||
}
|
||||
break;
|
||||
case UiCheckbox:
|
||||
if (isMenu) {
|
||||
if (ffi != null) {
|
||||
child = _buildCheckboxMenuButton(ui as UiCheckbox, ffi!);
|
||||
}
|
||||
} else {
|
||||
child = _buildCheckbox(ui as UiCheckbox);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
// to-do: add plugin icon and tooltip
|
||||
return child ?? Container();
|
||||
}
|
||||
|
||||
Widget _buildButton(UiButton ui) {
|
||||
return TextButton(
|
||||
onPressed: () => bind.pluginEvent(
|
||||
id: pluginId,
|
||||
peer: peerId,
|
||||
event: _makeEvent(ui.key),
|
||||
),
|
||||
child: Text(ui.text),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCheckbox(UiCheckbox ui) {
|
||||
getChild(OptionModel model) {
|
||||
final v = _getOption(model, ui.key);
|
||||
if (v == null) {
|
||||
// session or plugin not found
|
||||
return Container();
|
||||
}
|
||||
|
||||
onChanged(bool value) {
|
||||
bind.pluginEvent(
|
||||
id: pluginId,
|
||||
peer: peerId,
|
||||
event: _makeEvent(ui.key, v: value),
|
||||
);
|
||||
}
|
||||
|
||||
final value = ConfigItem.isTrue(v);
|
||||
return GestureDetector(
|
||||
child: Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: value,
|
||||
onChanged: (_) => onChanged(!value),
|
||||
).marginOnly(right: 5),
|
||||
Expanded(
|
||||
child: Text(translate(ui.text)),
|
||||
)
|
||||
],
|
||||
).marginOnly(left: _kCheckBoxLeftMargin),
|
||||
onTap: () => onChanged(!value),
|
||||
);
|
||||
}
|
||||
|
||||
return ChangeNotifierProvider.value(
|
||||
value: getOptionModel(location, pluginId, peerId, ui.key),
|
||||
child: Consumer<OptionModel>(
|
||||
builder: (context, model, child) => getChild(model),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCheckboxMenuButton(UiCheckbox ui, FFI ffi) {
|
||||
getChild(OptionModel model) {
|
||||
final v = _getOption(model, ui.key);
|
||||
if (v == null) {
|
||||
// session or plugin not found
|
||||
return Container();
|
||||
}
|
||||
return CkbMenuButton(
|
||||
value: ConfigItem.isTrue(v),
|
||||
onChanged: (v) {
|
||||
if (v != null) {
|
||||
bind.pluginEvent(
|
||||
id: pluginId,
|
||||
peer: peerId,
|
||||
event: _makeEvent(ui.key, v: v),
|
||||
);
|
||||
}
|
||||
},
|
||||
// to-do: RustDesk translate or plugin translate ?
|
||||
child: Text(ui.text),
|
||||
ffi: ffi,
|
||||
);
|
||||
}
|
||||
|
||||
return ChangeNotifierProvider.value(
|
||||
value: getOptionModel(location, pluginId, peerId, ui.key),
|
||||
child: Consumer<OptionModel>(
|
||||
builder: (context, model, child) => getChild(model),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMenuButton(UiButton ui, FFI ffi) {
|
||||
return MenuButton(
|
||||
onPressed: () => bind.pluginEvent(
|
||||
id: pluginId,
|
||||
peer: peerId,
|
||||
event: _makeEvent(ui.key),
|
||||
),
|
||||
// to-do: support trailing icon, but it will cause tree shake error.
|
||||
// ```
|
||||
// This application cannot tree shake icons fonts. It has non-constant instances of IconData at the following locations:
|
||||
// Target release_macos_bundle_flutter_assets failed: Exception: Avoid non-constant invocations of IconData or try to build again with --no-tree-shake-icons.
|
||||
// ```
|
||||
//
|
||||
// trailingIcon: Icon(
|
||||
// IconData(int.parse(ui.icon, radix: 16), fontFamily: 'MaterialIcons')),
|
||||
//
|
||||
// to-do: RustDesk translate or plugin translate ?
|
||||
child: Text(ui.text),
|
||||
ffi: ffi,
|
||||
);
|
||||
}
|
||||
|
||||
Uint8List _makeEvent(
|
||||
String key, {
|
||||
bool? v,
|
||||
}) {
|
||||
final event = MsgFromUi(
|
||||
id: pluginId,
|
||||
name: pluginManager.getPlugin(pluginId)?.meta.name ?? '',
|
||||
location: location,
|
||||
key: key,
|
||||
value:
|
||||
v != null ? (v ? ConfigItem.trueValue : ConfigItem.falseValue) : '',
|
||||
action: '',
|
||||
);
|
||||
return Uint8List.fromList(event.toString().codeUnits);
|
||||
}
|
||||
|
||||
String? _getOption(OptionModel model, String key) {
|
||||
var v = model.value;
|
||||
if (v == null) {
|
||||
try {
|
||||
if (peerId.isEmpty) {
|
||||
v = bind.pluginGetSharedOption(id: pluginId, key: key);
|
||||
} else {
|
||||
v = bind.pluginGetSessionOption(id: pluginId, peer: peerId, key: key);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Failed to get option "$key", $e');
|
||||
v = null;
|
||||
}
|
||||
}
|
||||
return v;
|
||||
}
|
||||
}
|
||||
|
||||
void handleReloading(Map<String, dynamic> evt) {
|
||||
if (evt['id'] == null || evt['location'] == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final uiList = <UiType>[];
|
||||
for (var e in json.decode(evt['ui'] as String)) {
|
||||
final ui = UiType.create(e);
|
||||
if (ui != null) {
|
||||
uiList.add(ui);
|
||||
}
|
||||
}
|
||||
if (uiList.isNotEmpty) {
|
||||
addLocationUi(evt['location']!, evt['id']!, uiList);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Failed handleReloading, json decode of ui, $e ');
|
||||
}
|
||||
}
|
||||
|
||||
void handleOption(Map<String, dynamic> evt) {
|
||||
updateOption(
|
||||
evt['location'], evt['id'], evt['peer'] ?? '', evt['key'], evt['value']);
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/models/platform_model.dart';
|
||||
import 'package:flutter_hbb/plugin/model.dart';
|
||||
import 'package:flutter_hbb/plugin/common.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import '../manager.dart';
|
||||
import './desc_ui.dart';
|
||||
|
||||
// to-do: use settings from desktop_setting_page.dart
|
||||
const double _kCardFixedWidth = 540;
|
||||
const double _kCardLeftMargin = 15;
|
||||
const double _kContentHMargin = 15;
|
||||
const double _kTitleFontSize = 20;
|
||||
const double _kVersionFontSize = 12;
|
||||
|
||||
class DesktopSettingsCard extends StatefulWidget {
|
||||
final PluginInfo plugin;
|
||||
DesktopSettingsCard({
|
||||
Key? key,
|
||||
required this.plugin,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<DesktopSettingsCard> createState() => _DesktopSettingsCardState();
|
||||
}
|
||||
|
||||
class _DesktopSettingsCardState extends State<DesktopSettingsCard> {
|
||||
PluginInfo get plugin => widget.plugin;
|
||||
bool get installed => plugin.installed;
|
||||
|
||||
bool isEnabled = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
isEnabled = bind.pluginIsEnabled(id: plugin.meta.id);
|
||||
return Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: SizedBox(
|
||||
width: _kCardFixedWidth,
|
||||
child: Card(
|
||||
child: Column(
|
||||
children: [
|
||||
header(),
|
||||
body(),
|
||||
],
|
||||
).marginOnly(bottom: 10),
|
||||
).marginOnly(left: _kCardLeftMargin, top: 15),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget header() {
|
||||
return Row(
|
||||
children: [
|
||||
headerNameVersion(),
|
||||
headerInstallEnable(),
|
||||
],
|
||||
).marginOnly(
|
||||
left: _kContentHMargin,
|
||||
top: 10,
|
||||
bottom: 10,
|
||||
right: _kContentHMargin,
|
||||
);
|
||||
}
|
||||
|
||||
Widget headerNameVersion() {
|
||||
return Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
widget.plugin.meta.name,
|
||||
textAlign: TextAlign.start,
|
||||
style: const TextStyle(
|
||||
fontSize: _kTitleFontSize,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 5,
|
||||
),
|
||||
Text(
|
||||
plugin.meta.version,
|
||||
textAlign: TextAlign.start,
|
||||
style: const TextStyle(
|
||||
fontSize: _kVersionFontSize,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget headerButton(String label, VoidCallback onPressed) {
|
||||
return Container(
|
||||
child: ElevatedButton(
|
||||
onPressed: onPressed,
|
||||
child: Text(translate(label)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget headerInstallEnable() {
|
||||
final installButton = headerButton(
|
||||
installed ? 'Uninstall' : 'Install',
|
||||
() {
|
||||
bind.pluginInstall(
|
||||
id: plugin.meta.id,
|
||||
b: !installed,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (installed) {
|
||||
final updateButton = plugin.needUpdate
|
||||
? headerButton('Update', () {
|
||||
bind.pluginInstall(
|
||||
id: plugin.meta.id,
|
||||
b: !installed,
|
||||
);
|
||||
})
|
||||
: Container();
|
||||
|
||||
final enableButton = !installed
|
||||
? Container()
|
||||
: headerButton(isEnabled ? 'Disable' : 'Enable', () {
|
||||
if (isEnabled) {
|
||||
clearPlugin(plugin.meta.id);
|
||||
}
|
||||
bind.pluginEnable(id: plugin.meta.id, v: !isEnabled);
|
||||
setState(() {});
|
||||
});
|
||||
return Row(
|
||||
children: [
|
||||
updateButton,
|
||||
SizedBox(
|
||||
width: 10,
|
||||
),
|
||||
installButton,
|
||||
SizedBox(
|
||||
width: 10,
|
||||
),
|
||||
enableButton,
|
||||
],
|
||||
);
|
||||
} else {
|
||||
return installButton;
|
||||
}
|
||||
}
|
||||
|
||||
Widget body() {
|
||||
return Column(children: [
|
||||
author(),
|
||||
description(),
|
||||
more(),
|
||||
]).marginOnly(
|
||||
left: _kCardLeftMargin,
|
||||
top: 4,
|
||||
right: _kContentHMargin,
|
||||
);
|
||||
}
|
||||
|
||||
Widget author() {
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(plugin.meta.author),
|
||||
);
|
||||
}
|
||||
|
||||
Widget description() {
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(plugin.meta.description),
|
||||
);
|
||||
}
|
||||
|
||||
Widget more() {
|
||||
if (!(installed && isEnabled)) {
|
||||
return Container();
|
||||
}
|
||||
|
||||
final List<Widget> children = [];
|
||||
final model = getPluginModel(kLocationHostMainPlugin, plugin.meta.id);
|
||||
if (model != null) {
|
||||
children.add(PluginItem(
|
||||
pluginId: plugin.meta.id,
|
||||
peerId: '',
|
||||
location: kLocationHostMainPlugin,
|
||||
pluginModel: model,
|
||||
isMenu: false,
|
||||
));
|
||||
}
|
||||
return ExpansionTile(
|
||||
title: Text('Options'),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
children: children,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -762,10 +762,6 @@ class RustdeskImpl {
|
||||
throw UnimplementedError("mainGetError");
|
||||
}
|
||||
|
||||
bool mainShowOption({required String key, dynamic hint}) {
|
||||
throw UnimplementedError("mainShowOption");
|
||||
}
|
||||
|
||||
Future<void> mainSetOption(
|
||||
{required String key, required String value, dynamic hint}) {
|
||||
js.context.callMethod('setByName', [
|
||||
@@ -1377,6 +1373,10 @@ class RustdeskImpl {
|
||||
throw UnimplementedError("cmLoginRes");
|
||||
}
|
||||
|
||||
Future<void> cmCloseConnectionWindow({required int connId, dynamic hint}) {
|
||||
throw UnimplementedError("cmCloseConnectionWindow");
|
||||
}
|
||||
|
||||
Future<void> cmCloseConnection({required int connId, dynamic hint}) {
|
||||
throw UnimplementedError("cmCloseConnection");
|
||||
}
|
||||
@@ -1644,78 +1644,6 @@ class RustdeskImpl {
|
||||
throw UnimplementedError("sendUrlScheme");
|
||||
}
|
||||
|
||||
Future<void> pluginEvent(
|
||||
{required String id,
|
||||
required String peer,
|
||||
required Uint8List event,
|
||||
dynamic hint}) {
|
||||
throw UnimplementedError("pluginEvent");
|
||||
}
|
||||
|
||||
Stream<EventToUI> pluginRegisterEventStream(
|
||||
{required String id, dynamic hint}) {
|
||||
throw UnimplementedError("pluginRegisterEventStream");
|
||||
}
|
||||
|
||||
String? pluginGetSessionOption(
|
||||
{required String id,
|
||||
required String peer,
|
||||
required String key,
|
||||
dynamic hint}) {
|
||||
throw UnimplementedError("pluginGetSessionOption");
|
||||
}
|
||||
|
||||
Future<void> pluginSetSessionOption(
|
||||
{required String id,
|
||||
required String peer,
|
||||
required String key,
|
||||
required String value,
|
||||
dynamic hint}) {
|
||||
throw UnimplementedError("pluginSetSessionOption");
|
||||
}
|
||||
|
||||
String? pluginGetSharedOption(
|
||||
{required String id, required String key, dynamic hint}) {
|
||||
throw UnimplementedError("pluginGetSharedOption");
|
||||
}
|
||||
|
||||
Future<void> pluginSetSharedOption(
|
||||
{required String id,
|
||||
required String key,
|
||||
required String value,
|
||||
dynamic hint}) {
|
||||
throw UnimplementedError("pluginSetSharedOption");
|
||||
}
|
||||
|
||||
Future<void> pluginReload({required String id, dynamic hint}) {
|
||||
throw UnimplementedError("pluginReload");
|
||||
}
|
||||
|
||||
void pluginEnable({required String id, required bool v, dynamic hint}) {
|
||||
throw UnimplementedError("pluginEnable");
|
||||
}
|
||||
|
||||
bool pluginIsEnabled({required String id, dynamic hint}) {
|
||||
throw UnimplementedError("pluginIsEnabled");
|
||||
}
|
||||
|
||||
bool pluginFeatureIsEnabled({dynamic hint}) {
|
||||
throw UnimplementedError("pluginFeatureIsEnabled");
|
||||
}
|
||||
|
||||
Future<void> pluginSyncUi({required String syncTo, dynamic hint}) {
|
||||
throw UnimplementedError("pluginSyncUi");
|
||||
}
|
||||
|
||||
Future<void> pluginListReload({dynamic hint}) {
|
||||
throw UnimplementedError("pluginListReload");
|
||||
}
|
||||
|
||||
Future<void> pluginInstall(
|
||||
{required String id, required bool b, dynamic hint}) {
|
||||
throw UnimplementedError("pluginInstall");
|
||||
}
|
||||
|
||||
bool isSupportMultiUiSession({required String version, dynamic hint}) {
|
||||
return versionToNumber(v: version) > versionToNumber(v: '1.2.4');
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'dart:js' as js;
|
||||
import 'dart:html' as html;
|
||||
// cycle imports, maybe we can improve this
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
|
||||
@@ -13,8 +12,6 @@ final isWebDesktop_ = !js.context.callMethod('isMobile');
|
||||
|
||||
final isDesktop_ = false;
|
||||
|
||||
String get screenInfo_ => js.context.callMethod('getByName', ['screen_info']);
|
||||
|
||||
final _localOs = js.context.callMethod('getByName', ['local_os', '']);
|
||||
final isWebOnWindows_ = _localOs == kPeerPlatformWindows;
|
||||
final isWebOnLinux_ = _localOs == kPeerPlatformLinux;
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
abstract class NativeHandler {
|
||||
bool onEvent(Map<String, dynamic> evt);
|
||||
}
|
||||
|
||||
class NativeUiHandler extends NativeHandler {
|
||||
NativeUiHandler._();
|
||||
|
||||
static NativeUiHandler instance = NativeUiHandler._();
|
||||
|
||||
@override
|
||||
bool onEvent(Map<String, dynamic> evt) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
}
|
||||
@@ -36,8 +36,28 @@ class RelativeMouseState {
|
||||
}
|
||||
|
||||
class MainFlutterWindow: NSWindow {
|
||||
private static let fullscreenWorkAreaSizes = NSMapTable<NSWindow, NSValue>(
|
||||
keyOptions: [.weakMemory, .objectPointerPersonality],
|
||||
valueOptions: .strongMemory
|
||||
)
|
||||
private static let fullscreenObserver = NotificationCenter.default.addObserver(
|
||||
forName: NSWindow.willEnterFullScreenNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { notification in
|
||||
guard let window = notification.object as? NSWindow,
|
||||
let screen = window.screen else {
|
||||
return
|
||||
}
|
||||
fullscreenWorkAreaSizes.setObject(
|
||||
NSValue(size: screen.visibleFrame.size),
|
||||
forKey: window
|
||||
)
|
||||
}
|
||||
|
||||
override func awakeFromNib() {
|
||||
rustdesk_core_main();
|
||||
_ = MainFlutterWindow.fullscreenObserver
|
||||
let flutterViewController = FlutterViewController.init()
|
||||
let windowFrame = self.frame
|
||||
self.contentViewController = flutterViewController
|
||||
@@ -278,6 +298,16 @@ class MainFlutterWindow: NSWindow {
|
||||
self.disableNativeRelativeMouseMode()
|
||||
result(true)
|
||||
|
||||
case "getMacOSWorkAreaSize":
|
||||
guard Thread.isMainThread,
|
||||
let window = registrar.view?.window,
|
||||
let size = MainFlutterWindow.fullscreenWorkAreaSizes
|
||||
.object(forKey: window)?.sizeValue else {
|
||||
result(nil)
|
||||
break
|
||||
}
|
||||
result([Double(size.width), Double(size.height)])
|
||||
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
|
||||
@@ -340,7 +340,7 @@ packages:
|
||||
description:
|
||||
path: "."
|
||||
ref: HEAD
|
||||
resolved-ref: 533883bcb0ffe91a9afdb13b8bac9b14b3e054ba
|
||||
resolved-ref: 8b774a66671cbb9bcb2631af6ac28f9bdd469ce3
|
||||
url: "https://github.com/rustdesk-org/rustdesk_desktop_multi_window"
|
||||
source: git
|
||||
version: "0.1.0"
|
||||
@@ -409,14 +409,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "12.0.1"
|
||||
external_path:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: external_path
|
||||
sha256: "2095c626fbbefe70d5a4afc9b1137172a68ee2c276e51c3c1283394485bea8f4"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.3"
|
||||
ffi:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -529,11 +521,12 @@ packages:
|
||||
flutter_custom_cursor:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_custom_cursor
|
||||
sha256: "3850a32ac6de351ccc5e4286b6d94ff70c10abecd44479ea6c5aaea17264285d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.0.4"
|
||||
path: "."
|
||||
ref: db63b785c38153603e9fb84b50d3ec46f0d7e05e
|
||||
resolved-ref: db63b785c38153603e9fb84b50d3ec46f0d7e05e
|
||||
url: "https://github.com/rustdesk-org/flutter_custom_cursor"
|
||||
source: git
|
||||
version: "0.0.3"
|
||||
flutter_gpu_texture_renderer:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1597,9 +1590,9 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "plugins/window_size"
|
||||
ref: eb3964990cf19629c89ff8cb4a37640c7b3d5601
|
||||
resolved-ref: eb3964990cf19629c89ff8cb4a37640c7b3d5601
|
||||
url: "https://github.com/google/flutter-desktop-embedding.git"
|
||||
ref: "51e67ce047c72b26810b99e8473ddb44612fe356"
|
||||
resolved-ref: "51e67ce047c72b26810b99e8473ddb44612fe356"
|
||||
url: "https://github.com/21pages/flutter-desktop-embedding.git"
|
||||
source: git
|
||||
version: "0.1.0"
|
||||
xdg_directories:
|
||||
|
||||
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
|
||||
# Read more about iOS versioning at
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# 1.1.9-1 works for android, but for ios it becomes 1.1.91, need to set it to 1.1.9-a.1 for iOS, will get 1.1.9.1, but iOS store not allow 4 numbers
|
||||
version: 1.4.9+67
|
||||
version: 1.5.0+68
|
||||
|
||||
environment:
|
||||
sdk: '^3.1.0'
|
||||
@@ -29,7 +29,6 @@ dependencies:
|
||||
|
||||
ffi: ^2.1.0
|
||||
path_provider: ^2.1.1
|
||||
external_path: ^1.0.3
|
||||
provider: ^6.0.5
|
||||
tuple: ^2.0.0
|
||||
wakelock_plus: ^1.1.3
|
||||
@@ -58,12 +57,15 @@ dependencies:
|
||||
git:
|
||||
url: https://github.com/rustdesk-org/rustdesk_desktop_multi_window
|
||||
freezed_annotation: ^2.0.3
|
||||
flutter_custom_cursor: ^0.0.4
|
||||
flutter_custom_cursor:
|
||||
git:
|
||||
url: https://github.com/rustdesk-org/flutter_custom_cursor
|
||||
ref: db63b785c38153603e9fb84b50d3ec46f0d7e05e
|
||||
window_size:
|
||||
git:
|
||||
url: https://github.com/google/flutter-desktop-embedding.git
|
||||
url: https://github.com/21pages/flutter-desktop-embedding.git
|
||||
path: plugins/window_size
|
||||
ref: eb3964990cf19629c89ff8cb4a37640c7b3d5601
|
||||
ref: 51e67ce047c72b26810b99e8473ddb44612fe356
|
||||
get: ^4.6.5
|
||||
visibility_detector: ^0.4.0+2
|
||||
contextmenu: ^3.0.0
|
||||
|
||||
281
flutter/test/file_model_test.dart
Normal file
281
flutter/test/file_model_test.dart
Normal file
@@ -0,0 +1,281 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_hbb/models/file_model.dart';
|
||||
import 'package:flutter_hbb/models/model.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
final _sessionId = UuidValue('00000000-0000-0000-0000-000000000000');
|
||||
|
||||
class _FakeFFI implements FFI {
|
||||
@override
|
||||
String id = 'test-peer';
|
||||
@override
|
||||
UuidValue get sessionId => _sessionId;
|
||||
@override
|
||||
late final FfiModel ffiModel = FfiModel(WeakReference(this));
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
FileController _createController(FileFetcher fileFetcher) {
|
||||
final ffi = _FakeFFI();
|
||||
return FileController(
|
||||
isLocal: false,
|
||||
getSessionID: () => _sessionId,
|
||||
rootState: WeakReference(ffi),
|
||||
jobController: JobController(() => _sessionId, () => null),
|
||||
fileFetcher: fileFetcher,
|
||||
getOtherSideDirectoryData: () =>
|
||||
DirectoryData(FileDirectory(), DirectoryOptions()),
|
||||
);
|
||||
}
|
||||
|
||||
FileDirectory _directory(String path) => FileDirectory()..path = path;
|
||||
|
||||
String _directoryJson(String path) => jsonEncode({
|
||||
'id': 0,
|
||||
'path': path,
|
||||
'entries': <Object>[],
|
||||
});
|
||||
|
||||
class _SentRead {
|
||||
final String path;
|
||||
final bool includeHidden;
|
||||
|
||||
const _SentRead(this.path, this.includeHidden);
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('a fast remote response is matched after registration', () async {
|
||||
late final FileFetcher fileFetcher;
|
||||
fileFetcher = FileFetcher(
|
||||
() => _sessionId,
|
||||
readRemoteDirectory: (_, path, __) {
|
||||
fileFetcher.tryCompleteTask(_directoryJson(path), 'false');
|
||||
return Future<void>.value();
|
||||
},
|
||||
);
|
||||
final directory = await fileFetcher.fetchDirectory('/fast', false, false);
|
||||
expect(directory.path, '/fast');
|
||||
});
|
||||
|
||||
test('a send failure fails and removes its registered task', () async {
|
||||
final failure = StateError('send failed');
|
||||
final fileFetcher = FileFetcher(
|
||||
() => _sessionId,
|
||||
readRemoteDirectory: (_, __, ___) => Future<void>.error(failure),
|
||||
);
|
||||
await expectLater(
|
||||
fileFetcher.fetchDirectory('/failed', false, false),
|
||||
throwsA(same(failure)),
|
||||
);
|
||||
expect(fileFetcher.hasPendingRemoteRead('/failed'), isFalse);
|
||||
});
|
||||
|
||||
test('a resolved Home path completes the sole empty-path request', () async {
|
||||
final sent = <_SentRead>[];
|
||||
final fileFetcher = FileFetcher(
|
||||
() => _sessionId,
|
||||
readRemoteDirectory: (_, path, includeHidden) async {
|
||||
sent.add(_SentRead(path, includeHidden));
|
||||
},
|
||||
);
|
||||
final controller = _createController(fileFetcher);
|
||||
controller.directory.value = _directory('/initial');
|
||||
|
||||
final home = controller.openDirectory('');
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
final response = _directoryJson('/home/user');
|
||||
controller.initDirAndHome({'value': response});
|
||||
expect(controller.homePath, '/home/user');
|
||||
expect(controller.directory.value.path, '/initial');
|
||||
fileFetcher.tryCompleteTask(response, 'false');
|
||||
expect(await home, isTrue);
|
||||
expect(controller.directory.value.path, '/home/user');
|
||||
expect(sent.single.path, isEmpty);
|
||||
});
|
||||
|
||||
test('an automatic response initializes Home without a pending request', () {
|
||||
final controller = _createController(FileFetcher(() => _sessionId));
|
||||
|
||||
controller.initDirAndHome({'value': _directoryJson('/home/user')});
|
||||
|
||||
expect(controller.homePath, '/home/user');
|
||||
expect(controller.directory.value.path, '/home/user');
|
||||
});
|
||||
|
||||
test('an exact path response is not taken by a pending Home request',
|
||||
() async {
|
||||
final sent = <_SentRead>[];
|
||||
final fileFetcher = FileFetcher(
|
||||
() => _sessionId,
|
||||
readRemoteDirectory: (_, path, includeHidden) async {
|
||||
sent.add(_SentRead(path, includeHidden));
|
||||
},
|
||||
);
|
||||
final home = fileFetcher.fetchDirectory('', false, false);
|
||||
final regular = fileFetcher.fetchDirectory('/regular', false, false);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
var homeCompleted = false;
|
||||
home.then<void>((_) => homeCompleted = true);
|
||||
|
||||
fileFetcher.tryCompleteTask(_directoryJson('/unmatched'), 'false');
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(homeCompleted, isFalse);
|
||||
|
||||
fileFetcher.tryCompleteTask(_directoryJson('/regular'), 'false');
|
||||
expect((await regular).path, '/regular');
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(homeCompleted, isFalse);
|
||||
|
||||
fileFetcher.tryCompleteTask(_directoryJson('/home/user'), 'false');
|
||||
expect((await home).path, '/home/user');
|
||||
expect(sent.map((request) => request.path), ['', '/regular']);
|
||||
});
|
||||
|
||||
test('a read error completes the sole pending request', () async {
|
||||
final fileFetcher = FileFetcher(
|
||||
() => _sessionId,
|
||||
readRemoteDirectory: (_, __, ___) async {},
|
||||
);
|
||||
final request = fileFetcher.fetchDirectory('/denied', false, false);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
final expectation = expectLater(request, throwsA('permission denied'));
|
||||
|
||||
fileFetcher.tryCompleteRemoteTaskWithError('permission denied');
|
||||
|
||||
await expectation;
|
||||
expect(fileFetcher.hasPendingRemoteRead('/denied'), isFalse);
|
||||
});
|
||||
|
||||
test('same-path requests share the pending read', () async {
|
||||
final sent = <_SentRead>[];
|
||||
late final FileFetcher fileFetcher;
|
||||
fileFetcher = FileFetcher(
|
||||
() => _sessionId,
|
||||
readRemoteDirectory: (_, path, includeHidden) async {
|
||||
sent.add(_SentRead(path, includeHidden));
|
||||
},
|
||||
);
|
||||
final controller = _createController(fileFetcher);
|
||||
controller.directory.value = _directory('/initial');
|
||||
|
||||
final first = controller.openDirectory('/same');
|
||||
final waiting = controller.openDirectory('/same');
|
||||
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(sent.map((request) => request.path), ['/same']);
|
||||
fileFetcher.tryCompleteTask(_directoryJson('/same'), 'false');
|
||||
expect(await first, isTrue);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(sent.map((request) => request.path), ['/same']);
|
||||
expect(await waiting, isTrue);
|
||||
expect(controller.directory.value.path, '/same');
|
||||
});
|
||||
|
||||
test('same-path requests with different hidden options are serialized',
|
||||
() async {
|
||||
final sent = <_SentRead>[];
|
||||
final fileFetcher = FileFetcher(
|
||||
() => _sessionId,
|
||||
readRemoteDirectory: (_, path, includeHidden) async {
|
||||
sent.add(_SentRead(path, includeHidden));
|
||||
},
|
||||
);
|
||||
|
||||
final first = fileFetcher.fetchDirectory('/same', false, false);
|
||||
final second = fileFetcher.fetchDirectory('/same', false, true);
|
||||
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(sent.map((request) => request.includeHidden), [false]);
|
||||
|
||||
fileFetcher.tryCompleteTask(_directoryJson('/same'), 'false');
|
||||
expect((await first).path, '/same');
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(sent.map((request) => request.includeHidden), [false, true]);
|
||||
|
||||
fileFetcher.tryCompleteTask(_directoryJson('/same'), 'false');
|
||||
expect((await second).path, '/same');
|
||||
});
|
||||
|
||||
test('session invalidation cancels active and waiting reads', () async {
|
||||
final sent = <_SentRead>[];
|
||||
final fileFetcher = FileFetcher(
|
||||
() => _sessionId,
|
||||
readRemoteDirectory: (_, path, includeHidden) async {
|
||||
sent.add(_SentRead(path, includeHidden));
|
||||
},
|
||||
);
|
||||
final first = fileFetcher.fetchDirectory('/same', false, false);
|
||||
final waiting = fileFetcher.fetchDirectory('/same', false, true);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
final firstError = expectLater(first, throwsA(isA<StateError>()));
|
||||
final waitingError = expectLater(waiting, throwsA(isA<StateError>()));
|
||||
|
||||
fileFetcher.beginRemoteSession();
|
||||
|
||||
await firstError;
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(sent.map((request) => request.includeHidden), [false]);
|
||||
await waitingError;
|
||||
expect(fileFetcher.hasPendingRemoteRead('/same'), isFalse);
|
||||
final replacement = fileFetcher.fetchDirectory('/same', false, true);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(sent.map((request) => request.includeHidden), [false, true]);
|
||||
fileFetcher.tryCompleteTask(_directoryJson('/same'), 'false');
|
||||
expect((await replacement).path, '/same');
|
||||
});
|
||||
|
||||
test('a late dispatch failure cannot remove a replacement task', () async {
|
||||
final dispatches = <Completer<void>>[];
|
||||
final fileFetcher = FileFetcher(
|
||||
() => _sessionId,
|
||||
readRemoteDirectory: (_, __, ___) {
|
||||
final dispatch = Completer<void>();
|
||||
dispatches.add(dispatch);
|
||||
return dispatch.future;
|
||||
},
|
||||
);
|
||||
final first = fileFetcher.fetchDirectory('/same', false, false);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
final firstError = expectLater(first, throwsA(isA<StateError>()));
|
||||
fileFetcher.beginRemoteSession();
|
||||
await firstError;
|
||||
|
||||
final replacement = fileFetcher.fetchDirectory('/same', false, false);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(dispatches, hasLength(2));
|
||||
dispatches.first.completeError(StateError('late dispatch failure'));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(fileFetcher.hasPendingRemoteRead('/same'), isTrue);
|
||||
fileFetcher.tryCompleteTask(_directoryJson('/same'), 'false');
|
||||
expect((await replacement).path, '/same');
|
||||
dispatches.last.complete();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
});
|
||||
|
||||
test('navigation ignores stale directory responses', () async {
|
||||
final fileFetcher = FileFetcher(
|
||||
() => _sessionId,
|
||||
readRemoteDirectory: (_, __, ___) async {},
|
||||
);
|
||||
final controller = _createController(fileFetcher);
|
||||
controller.directory.value = _directory('/initial');
|
||||
|
||||
final stale = controller.openDirectory('/stale');
|
||||
final latest = controller.openDirectory('/latest');
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
fileFetcher.tryCompleteTask(_directoryJson('/latest'), 'false');
|
||||
expect(await latest, isTrue);
|
||||
fileFetcher.tryCompleteTask(_directoryJson('/stale'), 'false');
|
||||
expect(await stale, isTrue);
|
||||
|
||||
expect(controller.directory.value.path, '/latest');
|
||||
});
|
||||
}
|
||||
@@ -342,11 +342,43 @@ void main() {
|
||||
});
|
||||
|
||||
group('shouldHandleTerminalPasteShortcut', () {
|
||||
test('handles only Ctrl+Shift+V on Linux with a virtual lock', () {
|
||||
expect(
|
||||
shouldHandleTerminalPasteShortcut(
|
||||
platform: TargetPlatform.linux,
|
||||
logicalKey: LogicalKeyboardKey.keyV,
|
||||
isKeyDown: true,
|
||||
isKeyRepeat: false,
|
||||
controlPressed: true,
|
||||
metaPressed: false,
|
||||
altPressed: false,
|
||||
shiftPressed: true,
|
||||
modifierLockActive: true,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
shouldHandleTerminalPasteShortcut(
|
||||
platform: TargetPlatform.linux,
|
||||
logicalKey: LogicalKeyboardKey.keyV,
|
||||
isKeyDown: true,
|
||||
isKeyRepeat: false,
|
||||
controlPressed: true,
|
||||
metaPressed: false,
|
||||
altPressed: false,
|
||||
shiftPressed: false,
|
||||
modifierLockActive: true,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'keeps default xterm paste behavior when virtual modifiers are inactive',
|
||||
() {
|
||||
expect(
|
||||
shouldHandleTerminalPasteShortcut(
|
||||
platform: TargetPlatform.windows,
|
||||
logicalKey: LogicalKeyboardKey.keyV,
|
||||
isKeyDown: true,
|
||||
isKeyRepeat: false,
|
||||
@@ -364,6 +396,7 @@ void main() {
|
||||
() {
|
||||
expect(
|
||||
shouldHandleTerminalPasteShortcut(
|
||||
platform: TargetPlatform.windows,
|
||||
logicalKey: LogicalKeyboardKey.keyV,
|
||||
isKeyDown: true,
|
||||
isKeyRepeat: false,
|
||||
@@ -377,6 +410,7 @@ void main() {
|
||||
);
|
||||
expect(
|
||||
shouldHandleTerminalPasteShortcut(
|
||||
platform: TargetPlatform.macOS,
|
||||
logicalKey: LogicalKeyboardKey.keyV,
|
||||
isKeyDown: true,
|
||||
isKeyRepeat: false,
|
||||
@@ -393,6 +427,7 @@ void main() {
|
||||
test('handles paste shortcut repeats while a virtual lock is active', () {
|
||||
expect(
|
||||
shouldHandleTerminalPasteShortcut(
|
||||
platform: TargetPlatform.windows,
|
||||
logicalKey: LogicalKeyboardKey.keyV,
|
||||
isKeyDown: false,
|
||||
isKeyRepeat: true,
|
||||
@@ -409,6 +444,7 @@ void main() {
|
||||
test('ignores key-up and unmodified V events', () {
|
||||
expect(
|
||||
shouldHandleTerminalPasteShortcut(
|
||||
platform: TargetPlatform.windows,
|
||||
logicalKey: LogicalKeyboardKey.keyV,
|
||||
isKeyDown: false,
|
||||
isKeyRepeat: false,
|
||||
@@ -422,6 +458,7 @@ void main() {
|
||||
);
|
||||
expect(
|
||||
shouldHandleTerminalPasteShortcut(
|
||||
platform: TargetPlatform.windows,
|
||||
logicalKey: LogicalKeyboardKey.keyV,
|
||||
isKeyDown: true,
|
||||
isKeyRepeat: false,
|
||||
@@ -444,6 +481,7 @@ void main() {
|
||||
]) {
|
||||
expect(
|
||||
shouldHandleTerminalPasteShortcut(
|
||||
platform: TargetPlatform.windows,
|
||||
logicalKey: LogicalKeyboardKey.keyV,
|
||||
isKeyDown: true,
|
||||
isKeyRepeat: false,
|
||||
@@ -461,6 +499,7 @@ void main() {
|
||||
test('ignores non-V key events', () {
|
||||
expect(
|
||||
shouldHandleTerminalPasteShortcut(
|
||||
platform: TargetPlatform.windows,
|
||||
logicalKey: LogicalKeyboardKey.keyC,
|
||||
isKeyDown: true,
|
||||
isKeyRepeat: false,
|
||||
|
||||
@@ -1,7 +1,35 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_hbb/models/terminal_mouse_handler.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:xterm/xterm.dart';
|
||||
|
||||
const _terminalSize = Size(400, 120);
|
||||
|
||||
Widget _terminalHarness(
|
||||
Terminal terminal,
|
||||
TerminalController controller,
|
||||
) =>
|
||||
MaterialApp(
|
||||
home: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: SizedBox(
|
||||
width: _terminalSize.width,
|
||||
height: _terminalSize.height,
|
||||
child: TerminalMouseInteraction(
|
||||
terminal,
|
||||
controller: controller,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
void _writeLines(Terminal terminal, int count) => terminal.write(
|
||||
List.generate(count, (index) => 'line $index\r\n').join(),
|
||||
);
|
||||
|
||||
void main() {
|
||||
late Terminal terminal;
|
||||
late List<String> output;
|
||||
@@ -12,6 +40,34 @@ void main() {
|
||||
..onOutput = output.add;
|
||||
});
|
||||
|
||||
testWidgets('Linux Ctrl+V is not paste', (tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.linux;
|
||||
try {
|
||||
final messenger = tester.binding.defaultBinaryMessenger;
|
||||
messenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform,
|
||||
(_) async => {'text': 'clipboard'},
|
||||
);
|
||||
final controller = TerminalController();
|
||||
addTearDown(controller.dispose);
|
||||
await tester.pumpWidget(_terminalHarness(terminal, controller));
|
||||
await tester.tap(find.byType(TerminalView));
|
||||
await tester.pump(kDoubleTapTimeout);
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyV);
|
||||
final controlVOutput = List.of(output);
|
||||
output.clear();
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyV);
|
||||
await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft);
|
||||
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
|
||||
expect(controlVOutput, ['\x16']);
|
||||
expect(output, ['clipboard']);
|
||||
} finally {
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
}
|
||||
});
|
||||
|
||||
String? report(
|
||||
TerminalMouseButton button, [
|
||||
TerminalMouseButtonState state = TerminalMouseButtonState.down,
|
||||
@@ -111,4 +167,133 @@ void main() {
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('dragging below scrolls and extends selection', (tester) async {
|
||||
final controller = TerminalController();
|
||||
_writeLines(terminal, 80);
|
||||
await tester.pumpWidget(_terminalHarness(terminal, controller));
|
||||
final terminalView =
|
||||
tester.state<TerminalViewState>(find.byType(TerminalView));
|
||||
final scrollController = terminalView.widget.scrollController!;
|
||||
scrollController.jumpTo(0);
|
||||
await tester.pump();
|
||||
final renderTerminal = terminalView.renderTerminal;
|
||||
const localStart = Offset(20, 20);
|
||||
final startCell = renderTerminal.getCellOffset(localStart);
|
||||
final mouse = TestPointer(1, PointerDeviceKind.mouse);
|
||||
final outside = Offset(20, renderTerminal.size.height);
|
||||
await tester.handlePointerEventRecord([
|
||||
PointerEventRecord(Duration.zero, [
|
||||
mouse.down(renderTerminal.localToGlobal(localStart)),
|
||||
mouse.move(renderTerminal.localToGlobal(outside)),
|
||||
]),
|
||||
PointerEventRecord(const Duration(milliseconds: 150), [
|
||||
mouse.move(
|
||||
renderTerminal.localToGlobal(outside + const Offset(1, 1)),
|
||||
),
|
||||
mouse.up(),
|
||||
]),
|
||||
]);
|
||||
|
||||
expect(scrollController.offset, greaterThan(0));
|
||||
expect(controller.selection!.begin, startCell);
|
||||
expect(controller.selection!.end.y, greaterThan(startCell.y));
|
||||
final releasedOffset = scrollController.offset;
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
expect(scrollController.offset, releasedOffset);
|
||||
});
|
||||
|
||||
testWidgets('tmux mouse input is reported without local selection',
|
||||
(tester) async {
|
||||
final controller = TerminalController();
|
||||
terminal.write('\x1b[?1049h\x1b[?1002h\x1b[?1006hword');
|
||||
await tester.pumpWidget(_terminalHarness(terminal, controller));
|
||||
final renderTerminal = tester
|
||||
.state<TerminalViewState>(find.byType(TerminalView))
|
||||
.renderTerminal;
|
||||
const wheel = Offset(120, 40);
|
||||
await tester.sendEventToBinding(
|
||||
PointerScrollEvent(
|
||||
position: renderTerminal.localToGlobal(wheel),
|
||||
scrollDelta: const Offset(0, 40),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
final wheelCell = renderTerminal.getCellOffset(wheel);
|
||||
expect(output.first, '\x1b[<65;${wheelCell.x + 1};${wheelCell.y + 1}M');
|
||||
output.clear();
|
||||
final clickPosition =
|
||||
renderTerminal.getOffset(const CellOffset(0, 0)) + const Offset(1, 1);
|
||||
final clickCell = renderTerminal.getCellOffset(clickPosition);
|
||||
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
|
||||
await mouse.down(renderTerminal.localToGlobal(clickPosition));
|
||||
await mouse.up();
|
||||
await tester.pump();
|
||||
await mouse.down(renderTerminal.localToGlobal(clickPosition));
|
||||
await mouse.up();
|
||||
await tester.pump(kDoubleTapTimeout);
|
||||
expect(output, [
|
||||
'\x1b[<0;${clickCell.x + 1};${clickCell.y + 1}M',
|
||||
'\x1b[<0;${clickCell.x + 1};${clickCell.y + 1}m',
|
||||
'\x1b[<0;${clickCell.x + 1};${clickCell.y + 1}M',
|
||||
'\x1b[<0;${clickCell.x + 1};${clickCell.y + 1}m',
|
||||
]);
|
||||
expect(controller.selection, isNull);
|
||||
expect(controller.suspendedPointerInputs, isFalse);
|
||||
output.clear();
|
||||
const start = Offset(40, 40);
|
||||
const end = Offset(240, 80);
|
||||
final startCell = renderTerminal.getCellOffset(start);
|
||||
final endCell = renderTerminal.getCellOffset(end);
|
||||
await mouse.down(renderTerminal.localToGlobal(start));
|
||||
await mouse.moveTo(renderTerminal.localToGlobal(end));
|
||||
await tester.pump();
|
||||
|
||||
expect(output, [
|
||||
'\x1b[<0;${startCell.x + 1};${startCell.y + 1}M',
|
||||
'\x1b[<32;${endCell.x + 1};${endCell.y + 1}M',
|
||||
]);
|
||||
expect(controller.selection, isNull);
|
||||
await mouse.up();
|
||||
expect(output.last, '\x1b[<0;${endCell.x + 1};${endCell.y + 1}m');
|
||||
expect(controller.suspendedPointerInputs, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('tmux drag stays suppressed after mouse mode is disabled',
|
||||
(tester) async {
|
||||
final controller = TerminalController();
|
||||
terminal.write('\x1b[?1049h\x1b[?1002h\x1b[?1006hword');
|
||||
await tester.pumpWidget(_terminalHarness(terminal, controller));
|
||||
final renderTerminal = tester
|
||||
.state<TerminalViewState>(find.byType(TerminalView))
|
||||
.renderTerminal;
|
||||
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
|
||||
final start = renderTerminal.localToGlobal(const Offset(40, 40));
|
||||
final end = renderTerminal.localToGlobal(const Offset(240, 80));
|
||||
await mouse.down(start);
|
||||
output.clear();
|
||||
terminal.write('\x1b[?1002l');
|
||||
await mouse.moveTo(end);
|
||||
|
||||
expect(output, isEmpty);
|
||||
expect(controller.selection, isNull);
|
||||
expect(controller.suspendedPointerInputs, isTrue);
|
||||
terminal.write('\x1b[?1002h');
|
||||
await mouse.moveTo(start);
|
||||
expect(output, isEmpty);
|
||||
expect(controller.selection, isNull);
|
||||
await mouse.up();
|
||||
expect(output, isEmpty);
|
||||
expect(controller.suspendedPointerInputs, isFalse);
|
||||
|
||||
await mouse.down(start);
|
||||
output.clear();
|
||||
terminal.write('\x1b[?1002l');
|
||||
await mouse.up();
|
||||
await tester.pump(kDoubleTapTimeout);
|
||||
|
||||
expect(output, isEmpty);
|
||||
expect(controller.selection, isNull);
|
||||
expect(controller.suspendedPointerInputs, isFalse);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -42,6 +42,13 @@ impl Enigo {
|
||||
&mut self.custom_mouse
|
||||
}
|
||||
|
||||
/// Override the display server guessed in `Default::default`: on "x11" every method here
|
||||
/// routes to `xdo`, and a null xdo context makes all of them silent no-ops. A caller
|
||||
/// installing custom devices knows better than the guess.
|
||||
pub fn set_is_x11(&mut self, is_x11: bool) {
|
||||
self.is_x11 = is_x11;
|
||||
}
|
||||
|
||||
/// Clear remapped keycodes
|
||||
pub fn tfc_clear_remapped(&mut self) {
|
||||
if let Some(tfc) = &mut self.tfc {
|
||||
@@ -390,3 +397,52 @@ fn test_key_seq() {
|
||||
let mut en = Enigo::new();
|
||||
en.key_sequence("^^");
|
||||
}
|
||||
|
||||
/// Both directions: the failure is silent, so a one-directional test passes against the bug.
|
||||
#[test]
|
||||
fn test_custom_mouse_dispatch_follows_is_x11() {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
struct CountingMouse(Arc<AtomicUsize>);
|
||||
impl MouseControllable for CountingMouse {
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
fn as_mut_any(&mut self) -> &mut dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
fn mouse_move_to(&mut self, _x: i32, _y: i32) {
|
||||
self.0.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
fn mouse_move_relative(&mut self, _x: i32, _y: i32) {}
|
||||
fn mouse_down(&mut self, _button: MouseButton) -> crate::ResultType {
|
||||
Ok(())
|
||||
}
|
||||
fn mouse_up(&mut self, _button: MouseButton) {}
|
||||
fn mouse_click(&mut self, _button: MouseButton) {}
|
||||
fn mouse_scroll_x(&mut self, _length: i32) {}
|
||||
fn mouse_scroll_y(&mut self, _length: i32) {}
|
||||
}
|
||||
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let mut en = Enigo::new();
|
||||
en.set_custom_mouse(Box::new(CountingMouse(calls.clone())));
|
||||
|
||||
en.set_is_x11(false);
|
||||
en.mouse_move_to(10, 20);
|
||||
assert_eq!(
|
||||
calls.load(Ordering::Relaxed),
|
||||
1,
|
||||
"custom mouse was not reached on the non-x11 branch"
|
||||
);
|
||||
|
||||
// Negative control: on the x11 branch the custom device must be bypassed entirely.
|
||||
en.set_is_x11(true);
|
||||
en.mouse_move_to(30, 40);
|
||||
assert_eq!(
|
||||
calls.load(Ordering::Relaxed),
|
||||
1,
|
||||
"custom mouse was reached on the x11 branch"
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user