mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-09-10 06:21:02 +03:00
Compare commits
111 Commits
fix-textur
...
worktree-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2927046f8 | ||
|
|
c794bee3c5 | ||
|
|
163cdb91b2 | ||
|
|
de052b2aaf | ||
|
|
89b3f88286 | ||
|
|
7587cf514a | ||
|
|
1bb4db9484 | ||
|
|
21b4e04e52 | ||
|
|
c5dd8f1e23 | ||
|
|
2a1b1c8cba | ||
|
|
1a8c47f541 | ||
|
|
46a35faf04 | ||
|
|
39a388cf50 | ||
|
|
eb404d01f9 | ||
|
|
d52a6647b8 | ||
|
|
8376431a19 | ||
|
|
054d863a6a | ||
|
|
0fcba29eb5 | ||
|
|
b610b9250c | ||
|
|
b0f26aabc3 | ||
|
|
c109145c54 | ||
|
|
babac46999 | ||
|
|
5693435f7a | ||
|
|
9c8e5dbb98 | ||
|
|
18e07563e2 | ||
|
|
fee3efcd8f | ||
|
|
de46cac9a7 | ||
|
|
7b901de8ff | ||
|
|
52baf741fb | ||
|
|
66d66bf1a6 | ||
|
|
85805c41b1 | ||
|
|
216b1d99e6 | ||
|
|
85b1986df4 | ||
|
|
0978786c7e | ||
|
|
0bf7b0cd26 | ||
|
|
83933e0242 | ||
|
|
4b708edba6 | ||
|
|
cc9343e3d3 | ||
|
|
8101bd2303 | ||
|
|
247acc1c97 | ||
|
|
798c8b22ae | ||
|
|
350ad893f8 | ||
|
|
618bf37deb | ||
|
|
c1a587cfa4 | ||
|
|
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:
|
||||
|
||||
148
Cargo.lock
generated
148
Cargo.lock
generated
@@ -986,27 +986,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"
|
||||
@@ -1477,8 +1456,8 @@ dependencies = [
|
||||
"compression-core",
|
||||
"flate2",
|
||||
"memchr",
|
||||
"zstd 0.13.1",
|
||||
"zstd-safe 7.1.0",
|
||||
"zstd",
|
||||
"zstd-safe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1549,12 +1528,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"
|
||||
@@ -3818,14 +3791,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]]
|
||||
@@ -5959,37 +5932,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 +5999,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"
|
||||
@@ -7258,7 +7177,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustdesk"
|
||||
version = "1.4.9"
|
||||
version = "1.5.0"
|
||||
dependencies = [
|
||||
"android-wakelock",
|
||||
"android_logger",
|
||||
@@ -7316,7 +7235,6 @@ dependencies = [
|
||||
"once_cell",
|
||||
"openssl",
|
||||
"os-version",
|
||||
"pam",
|
||||
"parity-tokio-ipc",
|
||||
"percent-encoding",
|
||||
"piet",
|
||||
@@ -7365,12 +7283,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",
|
||||
@@ -8907,7 +8824,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",
|
||||
@@ -9352,16 +9269,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"
|
||||
@@ -11157,52 +11064,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]]
|
||||
|
||||
@@ -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",
|
||||
@@ -81,7 +80,6 @@ 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"
|
||||
@@ -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,7 +209,7 @@ 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)
|
||||
|
||||
@@ -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/')
|
||||
|
||||
1
build.rs
1
build.rs
@@ -72,7 +72,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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -2636,13 +2627,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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -244,17 +244,54 @@ List<(String, String)> otherDefaultSettings() {
|
||||
kKeyUseAllMyDisplaysForTheRemoteSession
|
||||
),
|
||||
('Keep terminal sessions on disconnect', kOptionTerminalPersistent),
|
||||
(
|
||||
'Allow terminal apps to copy to clipboard',
|
||||
kOptionAllowTerminalClipboardWrite
|
||||
),
|
||||
];
|
||||
|
||||
return v;
|
||||
}
|
||||
|
||||
String getOtherDefaultSettingOption(String key) {
|
||||
if (key == kOptionAllowTerminalClipboardWrite) {
|
||||
return bind.mainGetLocalOption(key: key);
|
||||
}
|
||||
return bind.mainGetUserDefaultOption(key: key);
|
||||
}
|
||||
|
||||
Future<void> setOtherDefaultSettingOption(String key, String value) {
|
||||
if (key == kOptionAllowTerminalClipboardWrite) {
|
||||
return bind.mainSetLocalOption(
|
||||
key: key,
|
||||
value: value == kTerminalClipboardWriteAllowed
|
||||
? kTerminalClipboardWriteAllowed
|
||||
: kTerminalClipboardWriteDenied,
|
||||
);
|
||||
}
|
||||
return bind.mainSetUserDefaultOption(key: key, value: value);
|
||||
}
|
||||
|
||||
bool isOtherDefaultSettingReadOnly(String key) =>
|
||||
isOptionFixed(key) ||
|
||||
(key == kOptionAllowTerminalClipboardWrite && bind.isDisableSettings());
|
||||
|
||||
class TrackpadSpeedWidget extends StatefulWidget {
|
||||
final SimpleWrapper<int> value;
|
||||
// If null, no debouncer will be applied.
|
||||
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 +313,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 +380,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";
|
||||
@@ -88,7 +86,6 @@ const String kOptionEdgeScrollEdgeThickness = "edge-scroll-edge-thickness";
|
||||
const String kOptionImageQuality = "image_quality";
|
||||
const String kOptionOpenNewConnInTabs = "enable-open-new-connections-in-tabs";
|
||||
const String kOptionTextureRender = "use-texture-render";
|
||||
const String kOptionTextureRenderHealth = "texture-render-health";
|
||||
const String kOptionD3DRender = "allow-d3d-render";
|
||||
const String kOptionOpenInTabs = "allow-open-in-tabs";
|
||||
const String kOptionOpenInWindows = "allow-open-in-windows";
|
||||
@@ -118,6 +115,11 @@ const String kOptionEnableAudio = "enable-audio";
|
||||
const String kOptionEnableCamera = "enable-camera";
|
||||
const String kOptionEnableTerminal = "enable-terminal";
|
||||
const String kOptionTerminalPersistent = "terminal-persistent";
|
||||
const String kOptionAllowTerminalClipboardWrite =
|
||||
"allow-terminal-clipboard-write";
|
||||
const String kTerminalClipboardWriteUnconfigured = "";
|
||||
const String kTerminalClipboardWriteAllowed = "Y";
|
||||
const String kTerminalClipboardWriteDenied = "N";
|
||||
const String kOptionEnableTunnel = "enable-tunnel";
|
||||
const String kOptionEnableRemoteRestart = "enable-remote-restart";
|
||||
const String kOptionEnableBlockInput = "enable-block-input";
|
||||
@@ -162,16 +164,18 @@ const String kOptionPeerTabVisible = "peer-tab-visible";
|
||||
const String kOptionPeerCardUiType = "peer-card-ui-type";
|
||||
const String kOptionCurrentAbName = "current-ab-name";
|
||||
const String kOptionEnableConfirmClosingTabs = "enable-confirm-closing-tabs";
|
||||
const String kOptionEnablePortForwardMux = "enable-port-forward-mux";
|
||||
const String kOptionAllowAlwaysSoftwareRender = "allow-always-software-render";
|
||||
const String kOptionEnableCheckUpdate = "enable-check-update";
|
||||
const String kOptionAllowAutoUpdate = "allow-auto-update";
|
||||
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 kOptionEnableUdpPunch = "enable-udp-punch";
|
||||
const String kOptionEnableIpv6Punch = "enable-ipv6-punch";
|
||||
const String kOptionAllowSyncClipboardBetweenSessions =
|
||||
"allow-sync-clipboard-between-sessions";
|
||||
const String kOptionEnableTrustedDevices = "enable-trusted-devices";
|
||||
const String kOptionShowVirtualMouse = "show-virtual-mouse";
|
||||
const String kOptionVirtualMouseScale = "virtual-mouse-scale";
|
||||
@@ -194,6 +198,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 =
|
||||
@@ -326,10 +331,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;
|
||||
@@ -441,7 +447,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";
|
||||
@@ -453,6 +458,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
|
||||
|
||||
@@ -330,12 +330,14 @@ class _ConnectionPageState extends State<ConnectionPage>
|
||||
void onConnect(
|
||||
{bool isFileTransfer = false,
|
||||
bool isViewCamera = false,
|
||||
bool isTerminal = false}) {
|
||||
bool isTerminal = false,
|
||||
bool isTcpTunneling = false}) {
|
||||
var id = _idController.id;
|
||||
connect(context, id,
|
||||
isFileTransfer: isFileTransfer,
|
||||
isViewCamera: isViewCamera,
|
||||
isTerminal: isTerminal);
|
||||
isTerminal: isTerminal,
|
||||
isTcpTunneling: isTcpTunneling);
|
||||
}
|
||||
|
||||
/// UI for the remote ID TextField.
|
||||
@@ -568,6 +570,14 @@ class _ConnectionPageState extends State<ConnectionPage>
|
||||
'${translate('Terminal')} (beta)',
|
||||
() => onConnect(isTerminal: true)
|
||||
),
|
||||
// `connect` routes this through the
|
||||
// desktop path only; the peer card gates
|
||||
// it the same way.
|
||||
if (isDesktop)
|
||||
(
|
||||
'TCP tunneling',
|
||||
() => onConnect(isTcpTunneling: true)
|
||||
),
|
||||
]
|
||||
.map((e) => MenuEntryButton<String>(
|
||||
childBuilder: (TextStyle? style) =>
|
||||
|
||||
@@ -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';
|
||||
@@ -25,7 +24,6 @@ import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
import 'package:window_size/window_size.dart' as window_size;
|
||||
import '../widgets/button.dart';
|
||||
import '../widgets/texture_render_probe.dart';
|
||||
|
||||
class DesktopHomePage extends StatefulWidget {
|
||||
const DesktopHomePage({Key? key}) : super(key: key);
|
||||
@@ -61,20 +59,15 @@ class _DesktopHomePageState extends State<DesktopHomePage>
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
final isIncomingOnly = bind.isIncomingOnly();
|
||||
return Stack(
|
||||
return _buildBlock(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildBlock(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
buildLeftPane(context),
|
||||
if (!isIncomingOnly) const VerticalDivider(width: 1),
|
||||
if (!isIncomingOnly) Expanded(child: buildRightPane(context)),
|
||||
],
|
||||
)),
|
||||
const Positioned(left: 0, top: 0, child: TextureRenderProbe()),
|
||||
buildLeftPane(context),
|
||||
if (!isIncomingOnly) const VerticalDivider(width: 1),
|
||||
if (!isIncomingOnly) Expanded(child: buildRightPane(context)),
|
||||
],
|
||||
);
|
||||
));
|
||||
}
|
||||
|
||||
Widget _buildBlock({required Widget child}) {
|
||||
@@ -117,7 +110,6 @@ class _DesktopHomePageState extends State<DesktopHomePage>
|
||||
}
|
||||
},
|
||||
),
|
||||
buildPluginEntry(),
|
||||
];
|
||||
if (isIncomingOnly) {
|
||||
children.addAll([
|
||||
@@ -788,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());
|
||||
@@ -896,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;
|
||||
@@ -512,6 +509,15 @@ class _GeneralState extends State<_General> {
|
||||
kOptionOpenNewConnInTabs,
|
||||
isServer: false,
|
||||
),
|
||||
Tooltip(
|
||||
message: translate('port-forward-mux-tip'),
|
||||
child: _OptionCheckBox(
|
||||
context,
|
||||
'Reuse one connection for port forwarding',
|
||||
kOptionEnablePortForwardMux,
|
||||
isServer: false,
|
||||
),
|
||||
),
|
||||
// though this is related to GUI, but opengl problem affects all users, so put in config rather than local
|
||||
if (isLinux)
|
||||
Tooltip(
|
||||
@@ -578,6 +584,15 @@ class _GeneralState extends State<_General> {
|
||||
kOptionEnableIpv6Punch,
|
||||
isServer: false,
|
||||
),
|
||||
Tooltip(
|
||||
message: translate('sync-clipboard-between-sessions-tip'),
|
||||
child: _OptionCheckBox(
|
||||
context,
|
||||
'Sync clipboard between sessions',
|
||||
kOptionAllowSyncClipboardBetweenSessions,
|
||||
isServer: false,
|
||||
),
|
||||
),
|
||||
],
|
||||
];
|
||||
|
||||
@@ -591,10 +606,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,
|
||||
@@ -2078,14 +2089,13 @@ class _DisplayState extends State<_Display> {
|
||||
}
|
||||
|
||||
Widget otherRow(String label, String key) {
|
||||
final value = bind.mainGetUserDefaultOption(key: key) == 'Y';
|
||||
final isOptFixed = isOptionFixed(key);
|
||||
final value = getOtherDefaultSettingOption(key) == 'Y';
|
||||
final isOptFixed = isOtherDefaultSettingReadOnly(key);
|
||||
onChanged(bool b) async {
|
||||
await bind.mainSetUserDefaultOption(
|
||||
key: key,
|
||||
value: b
|
||||
? 'Y'
|
||||
: (key == kOptionEnableFileCopyPaste ? 'N' : defaultOptionNo));
|
||||
await setOtherDefaultSettingOption(
|
||||
key,
|
||||
b ? 'Y' : (key == kOptionEnableFileCopyPaste ? 'N' : defaultOptionNo),
|
||||
);
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@@ -2255,51 +2265,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,
|
||||
),
|
||||
|
||||
@@ -21,7 +21,6 @@ import '../../common/shared_state.dart';
|
||||
import '../../utils/image.dart';
|
||||
import '../widgets/remote_toolbar.dart';
|
||||
import '../widgets/kb_layout_type_chooser.dart';
|
||||
import '../widgets/raster_stall_monitor.dart';
|
||||
import '../widgets/tabbar_widget.dart';
|
||||
import 'macos_full_screen_focus_recovery.dart';
|
||||
|
||||
@@ -157,7 +156,6 @@ class _RemotePageState extends State<RemotePage>
|
||||
widget.tabController?.state.listen(_onMacOSTabStateChanged);
|
||||
}
|
||||
Get.put<FFI>(_ffi, tag: widget.id);
|
||||
RasterStallMonitor.start();
|
||||
_ffi.imageModel.addCallbackOnFirstImage((String peerId) {
|
||||
_ffi.canvasModel.activateLocalCursor();
|
||||
showKBLayoutTypeChooserIfNeeded(
|
||||
@@ -184,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((_) {
|
||||
@@ -276,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;
|
||||
}
|
||||
@@ -516,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) {
|
||||
@@ -526,7 +537,7 @@ class _RemotePageState extends State<RemotePage>
|
||||
_cursorOverImage.value = true;
|
||||
_macOSLocalFocusLost = false;
|
||||
}
|
||||
} else {
|
||||
} else if (!isWindows || _windowsCanFocusRemoteInput) {
|
||||
_rawKeyFocusNode.requestFocus();
|
||||
}
|
||||
_ffi.inputModel.onWindowFocus();
|
||||
@@ -649,7 +660,7 @@ class _RemotePageState extends State<RemotePage>
|
||||
// Clear callback reference to prevent memory leaks and stale references
|
||||
_ffi.inputModel.onRelativeMouseModeDisabled = null;
|
||||
// Relative mouse mode cleanup is centralized in FFI.close(closeSession: ...).
|
||||
_ffi.textureModel.onRemotePageDispose();
|
||||
_ffi.textureModel.onRemotePageDispose(closeSession);
|
||||
if (closeSession && !isMacOS) {
|
||||
// ensure we leave this session, this is a double check
|
||||
// enterOrLeave() is already called previously in _releaseMacOSRemoteInput() for macOS.
|
||||
@@ -838,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();
|
||||
}
|
||||
@@ -1404,4 +1424,3 @@ class CursorPaint extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
@@ -19,6 +19,8 @@ class TerminalPage extends StatefulWidget {
|
||||
required this.tabKey,
|
||||
this.forceRelay,
|
||||
this.connToken,
|
||||
this.onClipboardWriteBlocked,
|
||||
this.onClipboardWriteSucceeded,
|
||||
}) : super(key: key);
|
||||
final String id;
|
||||
final String? password;
|
||||
@@ -26,6 +28,8 @@ class TerminalPage extends StatefulWidget {
|
||||
final bool? forceRelay;
|
||||
final bool? isSharedPassword;
|
||||
final String? connToken;
|
||||
final ValueChanged<String>? onClipboardWriteBlocked;
|
||||
final ValueChanged<String>? onClipboardWriteSucceeded;
|
||||
final int terminalId;
|
||||
|
||||
/// Tab key for focus management, passed from parent to avoid duplicate construction
|
||||
@@ -71,6 +75,8 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
|
||||
// Create terminal model with specific terminal ID
|
||||
_terminalModel = TerminalModel(_ffi, widget.terminalId);
|
||||
_terminalModel.onClipboardWriteBlocked = widget.onClipboardWriteBlocked;
|
||||
_terminalModel.onClipboardWriteSucceeded = widget.onClipboardWriteSucceeded;
|
||||
debugPrint(
|
||||
'[TerminalPage] Terminal model created for terminal ${widget.terminalId}');
|
||||
|
||||
@@ -197,7 +203,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,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:desktop_multi_window/desktop_multi_window.dart';
|
||||
@@ -10,6 +11,8 @@ import 'package:flutter_hbb/models/state_model.dart';
|
||||
import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
|
||||
import 'package:flutter_hbb/utils/multi_window_manager.dart';
|
||||
import 'package:flutter_hbb/models/model.dart';
|
||||
import 'package:flutter_hbb/models/terminal_copy_shortcut.dart';
|
||||
import 'package:flutter_hbb/models/terminal_model.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import '../../models/platform_model.dart';
|
||||
@@ -19,6 +22,12 @@ import '../widgets/material_mod_popup_menu.dart' as mod_menu;
|
||||
import '../widgets/popup_menu.dart';
|
||||
import 'package:bot_toast/bot_toast.dart';
|
||||
|
||||
typedef _TerminalClipboardSource = ({
|
||||
String peerId,
|
||||
int terminalId,
|
||||
String tabKey,
|
||||
});
|
||||
|
||||
class TerminalTabPage extends StatefulWidget {
|
||||
final Map<String, dynamic> params;
|
||||
|
||||
@@ -30,6 +39,18 @@ class TerminalTabPage extends StatefulWidget {
|
||||
|
||||
class _TerminalTabPageState extends State<TerminalTabPage> {
|
||||
DesktopTabController get tabController => Get.find<DesktopTabController>();
|
||||
bool get _canConfigureTerminalClipboardPermission =>
|
||||
canConfigureTerminalClipboardPermission(
|
||||
settingsDisabled: bind.isDisableSettings(),
|
||||
optionFixed: isOptionFixed(kOptionAllowTerminalClipboardWrite),
|
||||
);
|
||||
bool get _canHandleTerminalClipboardWriteRequest =>
|
||||
canHandleTerminalClipboardWriteRequest(
|
||||
localOption: bind.mainGetLocalOption(
|
||||
key: kOptionAllowTerminalClipboardWrite,
|
||||
),
|
||||
canConfigurePermission: _canConfigureTerminalClipboardPermission,
|
||||
);
|
||||
|
||||
static const IconData selectedIcon = Icons.terminal;
|
||||
static const IconData unselectedIcon = Icons.terminal_outlined;
|
||||
@@ -38,6 +59,9 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
||||
final Set<String> _closingTabs = {};
|
||||
// When true, all session cleanup should persist (window-level close in progress)
|
||||
bool _windowClosing = false;
|
||||
CancelFunc? _terminalClipboardNoticeCancel;
|
||||
final _terminalClipboardNotice =
|
||||
TerminalClipboardNoticeCoordinator<_TerminalClipboardSource>();
|
||||
|
||||
_TerminalTabPageState(Map<String, dynamic> params) {
|
||||
Get.put(DesktopTabController(tabType: DesktopTabType.terminal));
|
||||
@@ -45,7 +69,10 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
||||
WindowController.fromWindowId(windowId())
|
||||
.setTitle(getWindowNameWithId(id));
|
||||
};
|
||||
tabController.onRemoved = (_, id) => onRemoveId(id);
|
||||
tabController.onRemoved = (_, id) {
|
||||
_closeTerminalClipboardNoticeForTab(id);
|
||||
onRemoveId(id);
|
||||
};
|
||||
tabController.onCloseWindow = _closeWindowFromConnection;
|
||||
final terminalId = params['terminalId'] ?? _nextTerminalId++;
|
||||
tabController.add(_createTerminalTab(
|
||||
@@ -70,6 +97,11 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
||||
final alias = bind.mainGetPeerOptionSync(id: peerId, key: 'alias');
|
||||
final tabLabel =
|
||||
alias.isNotEmpty ? '$alias #$terminalId' : '$peerId #$terminalId';
|
||||
final clipboardSource = (
|
||||
peerId: peerId,
|
||||
terminalId: terminalId,
|
||||
tabKey: tabKey,
|
||||
);
|
||||
return TabInfo(
|
||||
key: tabKey,
|
||||
label: tabLabel,
|
||||
@@ -86,10 +118,169 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
||||
tabController: tabController,
|
||||
forceRelay: forceRelay,
|
||||
connToken: connToken,
|
||||
onClipboardWriteBlocked: _canHandleTerminalClipboardWriteRequest
|
||||
? (text) => _handleTerminalClipboardWriteBlocked(
|
||||
clipboardSource,
|
||||
text,
|
||||
)
|
||||
: null,
|
||||
onClipboardWriteSucceeded: (_) {
|
||||
_handleTerminalClipboardWriteSucceeded(clipboardSource);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleTerminalClipboardWriteBlocked(
|
||||
_TerminalClipboardSource source,
|
||||
String clipboardText,
|
||||
) {
|
||||
if (!mounted) return;
|
||||
final option = bind.mainGetLocalOption(
|
||||
key: kOptionAllowTerminalClipboardWrite,
|
||||
);
|
||||
final request = _terminalClipboardNotice.recordBlocked(
|
||||
source: source,
|
||||
text: clipboardText,
|
||||
option: option,
|
||||
canWrite: _canWriteTerminalClipboard,
|
||||
);
|
||||
if (request != null) _showTerminalClipboardNotice(request);
|
||||
}
|
||||
|
||||
void _showTerminalClipboardNotice(
|
||||
TerminalClipboardNoticeRequest<_TerminalClipboardSource> request,
|
||||
) {
|
||||
_terminalClipboardNoticeCancel = BotToast.showCustomNotification(
|
||||
duration: null,
|
||||
enableSlideOff: false,
|
||||
onlyOne: true,
|
||||
onClose: _handleTerminalClipboardNoticeClosed,
|
||||
toastBuilder: (_) => AnimatedBuilder(
|
||||
animation: _terminalClipboardNotice,
|
||||
builder: (_, __) => MaterialBanner(
|
||||
leading: const Icon(Icons.content_copy_outlined),
|
||||
content: Text(translate(kTerminalClipboardNoticeMessageKey)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _terminalClipboardNotice.canClaimAction
|
||||
? _handleTerminalClipboardNegativeAction
|
||||
: null,
|
||||
child: Text(translate(request.negativeActionKey)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _terminalClipboardNotice.canClaimAction
|
||||
? _handleTerminalClipboardPositiveAction
|
||||
: null,
|
||||
child: Text(translate(request.actionKey)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleTerminalClipboardNegativeAction() {
|
||||
final request = _terminalClipboardNotice.claimCurrentAction();
|
||||
if (request == null) return;
|
||||
if (request.persistAllowed) {
|
||||
unawaited(_declineTerminalClipboardWrite());
|
||||
} else {
|
||||
_closeTerminalClipboardNotice();
|
||||
}
|
||||
}
|
||||
|
||||
void _handleTerminalClipboardPositiveAction() {
|
||||
final request = _terminalClipboardNotice.claimCurrentAction();
|
||||
if (request == null) return;
|
||||
unawaited(_completeTerminalClipboardWrite(request));
|
||||
}
|
||||
|
||||
void _handleTerminalClipboardNoticeClosed() {
|
||||
_terminalClipboardNoticeCancel = null;
|
||||
_terminalClipboardNotice.noticeClosed();
|
||||
}
|
||||
|
||||
bool _canWriteTerminalClipboard(
|
||||
_TerminalClipboardSource source,
|
||||
) {
|
||||
if (!_canHandleTerminalClipboardWriteRequest) return false;
|
||||
final ffi = TerminalConnectionManager.getExistingConnection(source.peerId);
|
||||
return ffi != null &&
|
||||
!ffi.closed &&
|
||||
ffi.ffiModel.permissions['clipboard'] != false &&
|
||||
tabController.state.value.tabs.any((tab) => tab.key == source.tabKey) &&
|
||||
ffi.terminalModels.containsKey(source.terminalId);
|
||||
}
|
||||
|
||||
void _handleTerminalClipboardWriteSucceeded(
|
||||
_TerminalClipboardSource source,
|
||||
) {
|
||||
final request = _terminalClipboardNotice.currentForSource(source);
|
||||
if (request == null) return;
|
||||
_closeTerminalClipboardNotice();
|
||||
}
|
||||
|
||||
Future<void> _declineTerminalClipboardWrite() async {
|
||||
try {
|
||||
await bind.mainSetLocalOption(
|
||||
key: kOptionAllowTerminalClipboardWrite,
|
||||
value: kTerminalClipboardWriteDenied,
|
||||
);
|
||||
} catch (error) {
|
||||
debugPrint(
|
||||
'[TerminalTabPage] Failed to save terminal clipboard permission: $error');
|
||||
return;
|
||||
} finally {
|
||||
_terminalClipboardNotice.releaseAction();
|
||||
}
|
||||
_closeTerminalClipboardNotice();
|
||||
}
|
||||
|
||||
Future<void> _completeTerminalClipboardWrite(
|
||||
TerminalClipboardNoticeRequest<_TerminalClipboardSource> request,
|
||||
) async {
|
||||
final source = request.source;
|
||||
var completed = false;
|
||||
try {
|
||||
completed = await completeTerminalClipboardWrite(
|
||||
clipboardText: request.text,
|
||||
canWrite: () => _canWriteTerminalClipboard(source),
|
||||
writeClipboard: writeTerminalClipboard,
|
||||
persistAllowed: request.persistAllowed
|
||||
? () => bind.mainSetLocalOption(
|
||||
key: kOptionAllowTerminalClipboardWrite,
|
||||
value: kTerminalClipboardWriteAllowed,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
} catch (error) {
|
||||
debugPrint(
|
||||
'[TerminalTabPage] Failed to complete terminal clipboard write: $error');
|
||||
} finally {
|
||||
_terminalClipboardNotice.releaseAction();
|
||||
}
|
||||
if (!completed) return;
|
||||
_closeTerminalClipboardNotice();
|
||||
}
|
||||
|
||||
void _closeTerminalClipboardNoticeForTab(String tabKey) {
|
||||
final current = _terminalClipboardNotice.current;
|
||||
if (current?.source.tabKey != tabKey) return;
|
||||
_closeTerminalClipboardNotice();
|
||||
}
|
||||
|
||||
void _closeTerminalClipboardNotice() {
|
||||
if (!_terminalClipboardNotice.beginClose()) return;
|
||||
final cancel = _terminalClipboardNoticeCancel;
|
||||
if (cancel == null) {
|
||||
debugPrint('[TerminalTabPage] Clipboard notice controller is missing');
|
||||
_terminalClipboardNotice.noticeClosed();
|
||||
return;
|
||||
}
|
||||
cancel();
|
||||
}
|
||||
|
||||
/// Unified tab close handler for all close paths (button, shortcut, programmatic).
|
||||
/// Shows audit dialog, cleans up session if not persistent, then removes the UI tab.
|
||||
Future<void> _closeTab(String tabKey) async {
|
||||
@@ -147,6 +338,8 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
||||
// Remove all UI tabs immediately (same instant behavior as the old tabController.clear())
|
||||
// Keep the cleanup target lookup below synchronous before its first await:
|
||||
// it relies on the current frame still retaining each TerminalPage's FFI/model.
|
||||
_terminalClipboardNotice.clear();
|
||||
_terminalClipboardNoticeCancel?.call();
|
||||
tabController.clear();
|
||||
// Run session cleanup in parallel with bounded timeout (closeTerminal() has internal 3s timeout).
|
||||
// Skip tabs already being closed by a concurrent _closeTab() to avoid duplicate FFI calls.
|
||||
@@ -357,6 +550,8 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
||||
@override
|
||||
void dispose() {
|
||||
HardwareKeyboard.instance.removeHandler(_handleKeyEvent);
|
||||
_terminalClipboardNotice.clear();
|
||||
_terminalClipboardNoticeCancel?.call();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ import '../../common/shared_state.dart';
|
||||
import '../../utils/image.dart';
|
||||
import '../widgets/remote_toolbar.dart';
|
||||
import '../widgets/kb_layout_type_chooser.dart';
|
||||
import '../widgets/raster_stall_monitor.dart';
|
||||
import '../widgets/tabbar_widget.dart';
|
||||
|
||||
import 'package:flutter_hbb/native/custom_cursor.dart'
|
||||
@@ -103,7 +102,6 @@ class _ViewCameraPageState extends State<ViewCameraPage>
|
||||
super.initState();
|
||||
_ffi = FFI(widget.sessionId);
|
||||
Get.put<FFI>(_ffi, tag: widget.id);
|
||||
RasterStallMonitor.start();
|
||||
_ffi.imageModel.addCallbackOnFirstImage((String peerId) {
|
||||
showKBLayoutTypeChooserIfNeeded(
|
||||
_ffi.ffiModel.pi.platform, _ffi.dialogManager);
|
||||
@@ -129,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);
|
||||
@@ -224,7 +221,7 @@ class _ViewCameraPageState extends State<ViewCameraPage>
|
||||
// https://github.com/flutter/flutter/issues/64935
|
||||
super.dispose();
|
||||
debugPrint("VIEW CAMERA PAGE dispose session $sessionId ${widget.id}");
|
||||
_ffi.textureModel.onViewCameraPageDispose();
|
||||
_ffi.textureModel.onViewCameraPageDispose(closeSession);
|
||||
if (closeSession) {
|
||||
// ensure we leave this session, this is a double check
|
||||
_ffi.inputModel.enterOrLeave(false);
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
|
||||
import '../../common.dart';
|
||||
import '../../consts.dart';
|
||||
import '../../models/platform_model.dart';
|
||||
import '../../models/state_model.dart';
|
||||
|
||||
/// Records a hung raster thread (frames continuously scheduled but no frame
|
||||
/// timings delivered for 30s) in `texture-render-health`; a hang cannot be
|
||||
/// rescued in-process, so the next launch defaults texture rendering off.
|
||||
class RasterStallMonitor {
|
||||
static bool _started = false;
|
||||
static bool _reported = false;
|
||||
static DateTime? _lastTimings;
|
||||
static DateTime _lastQuiet = DateTime.now();
|
||||
|
||||
static void start() {
|
||||
if (_started || isWeb) return;
|
||||
_started = true;
|
||||
SchedulerBinding.instance.addTimingsCallback((_) {
|
||||
_lastTimings = DateTime.now();
|
||||
});
|
||||
Timer.periodic(const Duration(seconds: 2), (_) {
|
||||
if (_reported) return;
|
||||
final now = DateTime.now();
|
||||
final lifecycle = SchedulerBinding.instance.lifecycleState;
|
||||
// Minimized/inactive (incl. screen lock) or idle (nothing scheduled):
|
||||
// no timings is legitimate, keep moving the quiet anchor forward.
|
||||
if (stateGlobal.isMinimized ||
|
||||
(lifecycle != null && lifecycle != AppLifecycleState.resumed) ||
|
||||
!SchedulerBinding.instance.hasScheduledFrame) {
|
||||
_lastQuiet = now;
|
||||
return;
|
||||
}
|
||||
var ref = _lastQuiet;
|
||||
final lastTimings = _lastTimings;
|
||||
if (lastTimings != null && lastTimings.isAfter(ref)) {
|
||||
ref = lastTimings;
|
||||
}
|
||||
if (now.difference(ref) > const Duration(seconds: 30)) {
|
||||
_reported = true;
|
||||
bind.mainSetLocalOption(
|
||||
key: kOptionTextureRenderHealth, value: 'failed-raster-stall');
|
||||
debugPrint(
|
||||
'raster thread stall detected, texture rendering disabled for next launch');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -11,7 +11,6 @@ import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/desktop/pages/remote_page.dart';
|
||||
import 'package:flutter_hbb/desktop/pages/view_camera_page.dart';
|
||||
import 'package:flutter_hbb/main.dart';
|
||||
import 'package:flutter_hbb/models/model.dart';
|
||||
import 'package:flutter_hbb/models/platform_model.dart';
|
||||
import 'package:flutter_hbb/models/state_model.dart';
|
||||
import 'package:get/get.dart';
|
||||
@@ -389,7 +388,6 @@ class _DesktopTabState extends State<DesktopTab>
|
||||
void onWindowMinimize() {
|
||||
stateGlobal.setMinimized(true);
|
||||
stateGlobal.setMaximized(false);
|
||||
_updateSessionsRenderVisible(false);
|
||||
super.onWindowMinimize();
|
||||
}
|
||||
|
||||
@@ -397,7 +395,6 @@ class _DesktopTabState extends State<DesktopTab>
|
||||
void onWindowMaximize() {
|
||||
stateGlobal.setMinimized(false);
|
||||
_setMaximized(true);
|
||||
_updateSessionsRenderVisible(true);
|
||||
super.onWindowMaximize();
|
||||
}
|
||||
|
||||
@@ -405,34 +402,9 @@ class _DesktopTabState extends State<DesktopTab>
|
||||
void onWindowUnmaximize() {
|
||||
stateGlobal.setMinimized(false);
|
||||
_setMaximized(false);
|
||||
_updateSessionsRenderVisible(true);
|
||||
super.onWindowUnmaximize();
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowRestore() {
|
||||
// A plain restore (no maximize involved) must clear the minimized flag.
|
||||
stateGlobal.setMinimized(false);
|
||||
_updateSessionsRenderVisible(true);
|
||||
super.onWindowRestore();
|
||||
}
|
||||
|
||||
// A hidden window composites nothing; pause the Rust-side texture watchdog
|
||||
// for its sessions so it cannot record a false failure.
|
||||
void _updateSessionsRenderVisible(bool visible) {
|
||||
if (tabType != DesktopTabType.remoteScreen &&
|
||||
tabType != DesktopTabType.viewCamera) {
|
||||
return;
|
||||
}
|
||||
for (final tab in controller.state.value.tabs) {
|
||||
try {
|
||||
final ffi = Get.find<FFI>(tag: tab.key);
|
||||
bind.sessionSetRenderVisible(
|
||||
sessionId: ffi.sessionId, visible: visible);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
_saveFrame({bool? flush}) async {
|
||||
try {
|
||||
if (tabType == DesktopTabType.main) {
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
|
||||
import '../../common.dart';
|
||||
import '../../consts.dart';
|
||||
import '../../models/platform_model.dart';
|
||||
import '../../models/state_model.dart';
|
||||
|
||||
import 'package:texture_rgba_renderer/texture_rgba_renderer.dart'
|
||||
if (dart.library.html) 'package:flutter_hbb/web/texture_rgba_renderer.dart';
|
||||
|
||||
/// Startup probe: renders one frame through a 1x1 external texture and
|
||||
/// records in `texture-render-health` whether the engine consumed it, so a
|
||||
/// broken environment is detected before the first session goes black.
|
||||
class TextureRenderProbe extends StatefulWidget {
|
||||
const TextureRenderProbe({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<TextureRenderProbe> createState() => _TextureRenderProbeState();
|
||||
}
|
||||
|
||||
class _TextureRenderProbeState extends State<TextureRenderProbe> {
|
||||
static bool _ranThisLaunch = false;
|
||||
final _renderer = TextureRgbaRenderer();
|
||||
int _textureId = -1;
|
||||
int _textureKey = -1;
|
||||
int _ptr = 0;
|
||||
Timer? _timer;
|
||||
int _ticks = 0;
|
||||
bool _sawTimings = false;
|
||||
bool _wasEffectiveOn = false;
|
||||
DateTime? _lastTimings;
|
||||
DateTime? _firstPush;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (_ranThisLaunch || isWeb || !isDesktop) return;
|
||||
_ranThisLaunch = true;
|
||||
if (bind.isIncomingOnly()) return;
|
||||
// An old plugin without the consumed counter cannot be judged, and a
|
||||
// recorded raster-stall means compositing a texture may hang this window.
|
||||
if (!bind.mainTextureRenderProbeSupported()) return;
|
||||
if (bind
|
||||
.mainGetLocalOption(key: kOptionTextureRenderHealth)
|
||||
.startsWith('failed-raster-stall')) {
|
||||
return;
|
||||
}
|
||||
_wasEffectiveOn = bind.mainGetUseTextureRender();
|
||||
// Only probe after the window has really rendered a frame: a hidden
|
||||
// window (silent/tray start) must not record a false failure.
|
||||
SchedulerBinding.instance.addTimingsCallback(_onTimings);
|
||||
Future.delayed(const Duration(seconds: 5), () {
|
||||
if (!_sawTimings) {
|
||||
SchedulerBinding.instance.removeTimingsCallback(_onTimings);
|
||||
_finish(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _onTimings(List<FrameTiming> timings) {
|
||||
_lastTimings = DateTime.now();
|
||||
if (_sawTimings) return;
|
||||
_sawTimings = true;
|
||||
_start();
|
||||
}
|
||||
|
||||
void _start() async {
|
||||
if (!mounted) return;
|
||||
_textureKey = bind.getNextTextureKey();
|
||||
final id = await _renderer.createTexture(_textureKey);
|
||||
if (!mounted || id == -1) {
|
||||
_finish(!mounted ? null : false);
|
||||
return;
|
||||
}
|
||||
_ptr = await _renderer.getTexturePtr(_textureKey);
|
||||
if (!mounted || _ptr <= 0) {
|
||||
_finish(!mounted ? null : false);
|
||||
return;
|
||||
}
|
||||
setState(() => _textureId = id);
|
||||
_timer = Timer.periodic(const Duration(milliseconds: 100), (_) {
|
||||
_ticks += 1;
|
||||
_firstPush ??= DateTime.now();
|
||||
bind.mainPushTextureProbeFrame(ptr: _ptr);
|
||||
final consumed = bind.mainGetTextureProbeConsumed(ptr: _ptr) > 0;
|
||||
// "Consumed" advances inside the plugin callback, before the GL/Metal
|
||||
// upload; only a frame timing after the push proves a completed frame.
|
||||
final frameCompleted = consumed &&
|
||||
_lastTimings != null &&
|
||||
_lastTimings!.isAfter(_firstPush!);
|
||||
if (frameCompleted) {
|
||||
_finish(true);
|
||||
} else if (_ticks >= 10) {
|
||||
if (consumed) {
|
||||
_finish(null);
|
||||
return;
|
||||
}
|
||||
// Only a window that is visibly compositing can prove a failure.
|
||||
final lifecycle = SchedulerBinding.instance.lifecycleState;
|
||||
final active =
|
||||
lifecycle == null || lifecycle == AppLifecycleState.resumed;
|
||||
final timingsFresh = _lastTimings != null &&
|
||||
DateTime.now().difference(_lastTimings!) <
|
||||
const Duration(milliseconds: 1500);
|
||||
_finish(!stateGlobal.isMinimized && active && timingsFresh
|
||||
? false
|
||||
: null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _finish(bool? ok) {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
SchedulerBinding.instance.removeTimingsCallback(_onTimings);
|
||||
if (ok != null) {
|
||||
final old = bind.mainGetLocalOption(key: kOptionTextureRenderHealth);
|
||||
if (ok) {
|
||||
// This rgba probe disproves only the rgba black-texture class: gpu
|
||||
// failures and raster stalls clear via the option toggle alone.
|
||||
final clearable = old.isEmpty ||
|
||||
old.startsWith('failed-probe') ||
|
||||
old.startsWith('failed-watchdog-rgba');
|
||||
if (clearable) {
|
||||
bind.mainSetLocalOption(key: kOptionTextureRenderHealth, value: 'ok');
|
||||
}
|
||||
} else if (!old.startsWith('failed')) {
|
||||
debugPrint('texture render probe failed, disabling texture rendering');
|
||||
bind.mainSetLocalOption(
|
||||
key: kOptionTextureRenderHealth, value: 'failed-probe');
|
||||
if (_wasEffectiveOn) {
|
||||
showToast(translate('texture-render-fallback-tip'));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (_textureKey != -1) {
|
||||
_renderer.closeTexture(_textureKey);
|
||||
_textureKey = -1;
|
||||
}
|
||||
_ptr = 0;
|
||||
if (mounted && _textureId != -1) {
|
||||
setState(() => _textureId = -1);
|
||||
} else {
|
||||
_textureId = -1;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
SchedulerBinding.instance.removeTimingsCallback(_onTimings);
|
||||
if (_textureKey != -1) {
|
||||
_renderer.closeTexture(_textureKey);
|
||||
_textureKey = -1;
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_textureId == -1) return const SizedBox.shrink();
|
||||
// Must actually composite for the engine to sample the texture; the
|
||||
// pushed pixel is fully transparent.
|
||||
return IgnorePointer(
|
||||
child: SizedBox(
|
||||
width: 1, height: 1, child: Texture(textureId: _textureId)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -1269,16 +1269,18 @@ class __DisplayPageState extends State<_DisplayPage> {
|
||||
}
|
||||
|
||||
SettingsTile otherRow(String label, String key) {
|
||||
final value = bind.mainGetUserDefaultOption(key: key) == 'Y';
|
||||
final isOptFixed = isOptionFixed(key);
|
||||
final value = getOtherDefaultSettingOption(key) == 'Y';
|
||||
final isOptFixed = isOtherDefaultSettingReadOnly(key);
|
||||
return SettingsTile.switchTile(
|
||||
initialValue: value,
|
||||
title: Text(translate(label)),
|
||||
onToggle: isOptFixed
|
||||
? null
|
||||
: (b) async {
|
||||
await bind.mainSetUserDefaultOption(
|
||||
key: key, value: b ? 'Y' : defaultOptionNo);
|
||||
await setOtherDefaultSettingOption(
|
||||
key,
|
||||
b ? 'Y' : defaultOptionNo,
|
||||
);
|
||||
setState(() {});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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,7 +9,9 @@ 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/models/terminal_mouse_handler.dart';
|
||||
import 'package:flutter_hbb/mobile/terminal_keyboard_utils.dart';
|
||||
import 'package:flutter_hbb/web/dummy.dart'
|
||||
if (dart.library.html) 'package:flutter_hbb/web/terminal_font.dart';
|
||||
@@ -17,6 +20,49 @@ import 'package:xterm/xterm.dart';
|
||||
import '../../desktop/pages/terminal_connection_manager.dart';
|
||||
import '../../consts.dart';
|
||||
|
||||
const _terminalBackgroundOpacity = 0.7;
|
||||
|
||||
Widget _buildTerminalViewForPlatform({
|
||||
required bool reportMouseInput,
|
||||
required bool reportTouchInput,
|
||||
required Terminal terminal,
|
||||
required TerminalController controller,
|
||||
required TerminalStyle textStyle,
|
||||
required EdgeInsets padding,
|
||||
required bool deleteDetection,
|
||||
required Map<ShortcutActivator, Intent>? shortcuts,
|
||||
required FocusOnKeyEventCallback onKeyEvent,
|
||||
required void Function(TapDownDetails, CellOffset) onSecondaryTapDown,
|
||||
}) {
|
||||
if (reportMouseInput || reportTouchInput) {
|
||||
return TerminalMouseInteraction(
|
||||
terminal,
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
textStyle: textStyle,
|
||||
deleteDetection: deleteDetection,
|
||||
reportTouchInput: reportTouchInput,
|
||||
shortcuts: shortcuts,
|
||||
onKeyEvent: onKeyEvent,
|
||||
backgroundOpacity: _terminalBackgroundOpacity,
|
||||
padding: padding,
|
||||
onSecondaryTapDown: onSecondaryTapDown,
|
||||
);
|
||||
}
|
||||
return TerminalView(
|
||||
terminal,
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
textStyle: textStyle,
|
||||
deleteDetection: deleteDetection,
|
||||
shortcuts: shortcuts,
|
||||
onKeyEvent: onKeyEvent,
|
||||
backgroundOpacity: _terminalBackgroundOpacity,
|
||||
padding: padding,
|
||||
onSecondaryTapDown: onSecondaryTapDown,
|
||||
);
|
||||
}
|
||||
|
||||
class TerminalPage extends StatefulWidget {
|
||||
const TerminalPage({
|
||||
Key? key,
|
||||
@@ -39,6 +85,19 @@ class TerminalPage extends StatefulWidget {
|
||||
|
||||
class _TerminalPageState extends State<TerminalPage>
|
||||
with AutomaticKeepAliveClientMixin, WidgetsBindingObserver {
|
||||
bool get _canConfigureTerminalClipboardPermission =>
|
||||
canConfigureTerminalClipboardPermission(
|
||||
settingsDisabled: bind.isDisableSettings(),
|
||||
optionFixed: isOptionFixed(kOptionAllowTerminalClipboardWrite),
|
||||
);
|
||||
bool get _canHandleTerminalClipboardWriteRequest =>
|
||||
canHandleTerminalClipboardWriteRequest(
|
||||
localOption: bind.mainGetLocalOption(
|
||||
key: kOptionAllowTerminalClipboardWrite,
|
||||
),
|
||||
canConfigurePermission: _canConfigureTerminalClipboardPermission,
|
||||
);
|
||||
|
||||
late FFI _ffi;
|
||||
late TerminalModel _terminalModel;
|
||||
double? _cellHeight;
|
||||
@@ -55,6 +114,9 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
// For iOS edge swipe gesture
|
||||
double _swipeStartX = 0;
|
||||
double _swipeCurrentX = 0;
|
||||
ScaffoldFeatureController<MaterialBanner, MaterialBannerClosedReason>?
|
||||
_terminalClipboardNoticeController;
|
||||
final _terminalClipboardNotice = TerminalClipboardNoticeCoordinator<int>();
|
||||
|
||||
// For web only.
|
||||
// 'monospace' does not work on web, use Google Fonts, `??` is only for null safety.
|
||||
@@ -87,6 +149,12 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
|
||||
// Create terminal model with specific terminal ID
|
||||
_terminalModel = TerminalModel(_ffi, widget.terminalId);
|
||||
if (_canHandleTerminalClipboardWriteRequest) {
|
||||
_terminalModel.onClipboardWriteBlocked =
|
||||
_handleTerminalClipboardWriteBlocked;
|
||||
_terminalModel.onClipboardWriteSucceeded =
|
||||
_handleTerminalClipboardWriteSucceeded;
|
||||
}
|
||||
debugPrint(
|
||||
'[TerminalPage] Terminal model created for terminal ${widget.terminalId}');
|
||||
|
||||
@@ -132,12 +200,144 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
_ffi.ffiModel.updateEventListener(_ffi.sessionId, widget.id);
|
||||
}
|
||||
|
||||
void _handleTerminalClipboardWriteBlocked(String clipboardText) {
|
||||
if (!mounted) return;
|
||||
final option = bind.mainGetLocalOption(
|
||||
key: kOptionAllowTerminalClipboardWrite,
|
||||
);
|
||||
final request = _terminalClipboardNotice.recordBlocked(
|
||||
source: widget.terminalId,
|
||||
text: clipboardText,
|
||||
option: option,
|
||||
canWrite: (_) => _canWriteTerminalClipboard,
|
||||
);
|
||||
if (request != null) _showTerminalClipboardNotice(request);
|
||||
}
|
||||
|
||||
void _showTerminalClipboardNotice(
|
||||
TerminalClipboardNoticeRequest<int> request,
|
||||
) {
|
||||
final controller = ScaffoldMessenger.of(context).showMaterialBanner(
|
||||
MaterialBanner(
|
||||
leading: const Icon(Icons.content_copy_outlined),
|
||||
content: Text(translate(kTerminalClipboardNoticeMessageKey)),
|
||||
actions: [
|
||||
AnimatedBuilder(
|
||||
animation: _terminalClipboardNotice,
|
||||
builder: (_, __) => TextButton(
|
||||
onPressed: _terminalClipboardNotice.canClaimAction
|
||||
? _handleTerminalClipboardNegativeAction
|
||||
: null,
|
||||
child: Text(translate(request.negativeActionKey)),
|
||||
),
|
||||
),
|
||||
AnimatedBuilder(
|
||||
animation: _terminalClipboardNotice,
|
||||
builder: (_, __) => TextButton(
|
||||
onPressed: _terminalClipboardNotice.canClaimAction
|
||||
? _handleTerminalClipboardPositiveAction
|
||||
: null,
|
||||
child: Text(translate(request.actionKey)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
_terminalClipboardNoticeController = controller;
|
||||
unawaited(controller.closed.then<void>((_) {
|
||||
if (identical(_terminalClipboardNoticeController, controller)) {
|
||||
_terminalClipboardNoticeController = null;
|
||||
_terminalClipboardNotice.noticeClosed();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
void _handleTerminalClipboardNegativeAction() {
|
||||
final request = _terminalClipboardNotice.claimCurrentAction();
|
||||
if (request == null) return;
|
||||
if (request.persistAllowed) {
|
||||
unawaited(_declineTerminalClipboardWrite());
|
||||
} else {
|
||||
_closeTerminalClipboardNotice();
|
||||
}
|
||||
}
|
||||
|
||||
void _handleTerminalClipboardPositiveAction() {
|
||||
final request = _terminalClipboardNotice.claimCurrentAction();
|
||||
if (request == null) return;
|
||||
unawaited(_completeTerminalClipboardWrite(request));
|
||||
}
|
||||
|
||||
bool get _canWriteTerminalClipboard =>
|
||||
_canHandleTerminalClipboardWriteRequest &&
|
||||
!_ffi.closed &&
|
||||
_ffi.ffiModel.permissions['clipboard'] != false;
|
||||
|
||||
void _handleTerminalClipboardWriteSucceeded(String _) {
|
||||
_closeTerminalClipboardNotice();
|
||||
}
|
||||
|
||||
Future<void> _declineTerminalClipboardWrite() async {
|
||||
try {
|
||||
await bind.mainSetLocalOption(
|
||||
key: kOptionAllowTerminalClipboardWrite,
|
||||
value: kTerminalClipboardWriteDenied,
|
||||
);
|
||||
} catch (error) {
|
||||
debugPrint(
|
||||
'[TerminalPage] Failed to save terminal clipboard permission: $error');
|
||||
return;
|
||||
} finally {
|
||||
_terminalClipboardNotice.releaseAction();
|
||||
}
|
||||
_closeTerminalClipboardNotice();
|
||||
}
|
||||
|
||||
Future<void> _completeTerminalClipboardWrite(
|
||||
TerminalClipboardNoticeRequest<int> request,
|
||||
) async {
|
||||
var completed = false;
|
||||
try {
|
||||
completed = await completeTerminalClipboardWrite(
|
||||
clipboardText: request.text,
|
||||
canWrite: () => _canWriteTerminalClipboard,
|
||||
writeClipboard: writeTerminalClipboard,
|
||||
persistAllowed: request.persistAllowed
|
||||
? () => bind.mainSetLocalOption(
|
||||
key: kOptionAllowTerminalClipboardWrite,
|
||||
value: kTerminalClipboardWriteAllowed,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
} catch (error) {
|
||||
debugPrint(
|
||||
'[TerminalPage] Failed to complete terminal clipboard write: $error');
|
||||
} finally {
|
||||
_terminalClipboardNotice.releaseAction();
|
||||
}
|
||||
if (!completed) return;
|
||||
_closeTerminalClipboardNotice();
|
||||
}
|
||||
|
||||
void _closeTerminalClipboardNotice() {
|
||||
if (!_terminalClipboardNotice.beginClose()) return;
|
||||
final controller = _terminalClipboardNoticeController;
|
||||
if (controller == null) {
|
||||
debugPrint('[TerminalPage] Clipboard notice controller is missing');
|
||||
_terminalClipboardNotice.noticeClosed();
|
||||
return;
|
||||
}
|
||||
controller.close();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// Unregister terminal model from FFI
|
||||
_ffi.unregisterTerminalModel(widget.terminalId);
|
||||
_terminalModel.dispose();
|
||||
_keyboardDebounce?.cancel();
|
||||
_terminalClipboardNotice.clear();
|
||||
_terminalClipboardNoticeController?.close();
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
TerminalConnectionManager.releaseConnection(widget.id);
|
||||
@@ -190,6 +390,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,
|
||||
@@ -231,12 +432,12 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final heightPx = constraints.maxHeight;
|
||||
return TerminalView(
|
||||
_terminalModel.terminal,
|
||||
return _buildTerminalViewForPlatform(
|
||||
reportMouseInput: isWebDesktop || isAndroid,
|
||||
reportTouchInput: isIOS,
|
||||
terminal: _terminalModel.terminal,
|
||||
controller: _terminalModel.terminalController,
|
||||
autofocus: true,
|
||||
textStyle: _getTerminalStyle(),
|
||||
backgroundOpacity: 0.7,
|
||||
// The following comment is from xterm.dart source code:
|
||||
// Workaround to detect delete key for platforms and IMEs that do not
|
||||
// emit a hardware delete event. Preferred on mobile platforms. [false] by
|
||||
@@ -244,7 +445,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;
|
||||
|
||||
@@ -16,8 +16,6 @@ class _PixelbufferTexture {
|
||||
int _display = 0;
|
||||
SessionID? _sessionId;
|
||||
bool _destroying = false;
|
||||
bool _closed = false;
|
||||
int _ptr = 0;
|
||||
int? _id;
|
||||
|
||||
final textureRenderer = TextureRgbaRenderer();
|
||||
@@ -29,22 +27,11 @@ class _PixelbufferTexture {
|
||||
_textureKey = bind.getNextTextureKey();
|
||||
_sessionId = sessionId;
|
||||
|
||||
final textureKey = _textureKey;
|
||||
textureRenderer.createTexture(textureKey).then((id) async {
|
||||
textureRenderer.createTexture(_textureKey).then((id) async {
|
||||
_id = id;
|
||||
if (id != -1) {
|
||||
if (_closed) {
|
||||
// Destroyed while creation was still in flight (rapid
|
||||
// connect/disconnect); nobody else will close this texture.
|
||||
await textureRenderer.closeTexture(textureKey);
|
||||
return;
|
||||
}
|
||||
ffi.textureModel.setRgbaTextureId(display: d, id: id);
|
||||
final ptr = await textureRenderer.getTexturePtr(textureKey);
|
||||
if (_closed) {
|
||||
return;
|
||||
}
|
||||
_ptr = ptr;
|
||||
final ptr = await textureRenderer.getTexturePtr(_textureKey);
|
||||
platformFFI.registerPixelbufferTexture(sessionId, display, ptr);
|
||||
debugPrint(
|
||||
"create pixelbuffer texture: peerId: ${ffi.id} display:$_display, textureId:$id, texturePtr:$ptr");
|
||||
@@ -52,16 +39,13 @@ class _PixelbufferTexture {
|
||||
});
|
||||
}
|
||||
|
||||
destroy(FFI ffi) async {
|
||||
_closed = true;
|
||||
destroy(bool unregisterTexture, FFI ffi) async {
|
||||
if (!_destroying && _textureKey != -1 && _sessionId != null) {
|
||||
_destroying = true;
|
||||
if (_ptr != 0) {
|
||||
// Compare-and-clear: only clears if Rust still holds this pointer
|
||||
// (#8016-safe); returning from this synchronous call also means no
|
||||
// push through the old pointer is still in flight.
|
||||
platformFFI.unregisterPixelbufferTexture(_sessionId!, display, _ptr);
|
||||
_ptr = 0;
|
||||
if (unregisterTexture) {
|
||||
platformFFI.registerPixelbufferTexture(_sessionId!, display, 0);
|
||||
// sleep for a while to avoid the texture is used after it's unregistered.
|
||||
await Future.delayed(Duration(milliseconds: 100));
|
||||
}
|
||||
await textureRenderer.closeTexture(_textureKey);
|
||||
_textureKey = -1;
|
||||
@@ -77,7 +61,6 @@ class _GpuTexture {
|
||||
SessionID? _sessionId;
|
||||
final support = bind.mainHasGpuTextureRender();
|
||||
bool _destroying = false;
|
||||
bool _closed = false;
|
||||
int _display = 0;
|
||||
int? _id;
|
||||
int? _output;
|
||||
@@ -96,18 +79,9 @@ class _GpuTexture {
|
||||
gpuTextureRenderer.registerTexture().then((id) async {
|
||||
_id = id;
|
||||
if (id != null) {
|
||||
if (_closed) {
|
||||
// Destroyed while creation was still in flight (rapid
|
||||
// connect/disconnect); nobody else will unregister this texture.
|
||||
await gpuTextureRenderer.unregisterTexture(id);
|
||||
return;
|
||||
}
|
||||
_textureId = id;
|
||||
ffi.textureModel.setGpuTextureId(display: d, id: id);
|
||||
final output = await gpuTextureRenderer.output(id);
|
||||
if (_closed) {
|
||||
return;
|
||||
}
|
||||
_output = output;
|
||||
if (output != null) {
|
||||
platformFFI.registerGpuTexture(sessionId, d, output);
|
||||
@@ -121,22 +95,20 @@ class _GpuTexture {
|
||||
}
|
||||
}
|
||||
|
||||
destroy(FFI ffi) async {
|
||||
destroy(bool unregisterTexture, FFI ffi) async {
|
||||
// must stop texture render, render unregistered texture cause crash
|
||||
_closed = true;
|
||||
if (!_destroying && support && _sessionId != null && _textureId != -1) {
|
||||
_destroying = true;
|
||||
final output = _output;
|
||||
if (output != null) {
|
||||
// Compare-and-clear, see _PixelbufferTexture.destroy.
|
||||
platformFFI.unregisterGpuTexture(_sessionId!, _display, output);
|
||||
_output = null;
|
||||
if (unregisterTexture) {
|
||||
platformFFI.registerGpuTexture(_sessionId!, _display, 0);
|
||||
// sleep for a while to avoid the texture is used after it's unregistered.
|
||||
await Future.delayed(Duration(milliseconds: 100));
|
||||
}
|
||||
await gpuTextureRenderer.unregisterTexture(_textureId);
|
||||
_textureId = -1;
|
||||
_destroying = false;
|
||||
debugPrint(
|
||||
"destroy gpu texture: peerId: ${ffi.id} display:$_display, textureId:$_id, output:$output");
|
||||
"destroy gpu texture: peerId: ${ffi.id} display:$_display, textureId:$_id, output:$_output");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -228,11 +200,11 @@ class TextureModel {
|
||||
tryRemoveTexture(int idx) {
|
||||
_control.remove(idx);
|
||||
if (_pixelbufferRenderTextures.containsKey(idx)) {
|
||||
_pixelbufferRenderTextures[idx]!.destroy(ffi);
|
||||
_pixelbufferRenderTextures[idx]!.destroy(true, ffi);
|
||||
_pixelbufferRenderTextures.remove(idx);
|
||||
}
|
||||
if (_gpuRenderTextures.containsKey(idx)) {
|
||||
_gpuRenderTextures[idx]!.destroy(ffi);
|
||||
_gpuRenderTextures[idx]!.destroy(true, ffi);
|
||||
_gpuRenderTextures.remove(idx);
|
||||
}
|
||||
}
|
||||
@@ -252,25 +224,25 @@ class TextureModel {
|
||||
}
|
||||
}
|
||||
|
||||
onRemotePageDispose() async {
|
||||
onRemotePageDispose(bool closeSession) async {
|
||||
final ffi = parent.target;
|
||||
if (ffi == null) return;
|
||||
for (final texture in _pixelbufferRenderTextures.values) {
|
||||
await texture.destroy(ffi);
|
||||
await texture.destroy(closeSession, ffi);
|
||||
}
|
||||
for (final texture in _gpuRenderTextures.values) {
|
||||
await texture.destroy(ffi);
|
||||
await texture.destroy(closeSession, ffi);
|
||||
}
|
||||
}
|
||||
|
||||
onViewCameraPageDispose() async {
|
||||
onViewCameraPageDispose(bool closeSession) async {
|
||||
final ffi = parent.target;
|
||||
if (ffi == null) return;
|
||||
for (final texture in _pixelbufferRenderTextures.values) {
|
||||
await texture.destroy(ffi);
|
||||
await texture.destroy(closeSession, ffi);
|
||||
}
|
||||
for (final texture in _gpuRenderTextures.values) {
|
||||
await texture.destroy(ffi);
|
||||
await texture.destroy(closeSession, ffi);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'];
|
||||
@@ -735,11 +727,6 @@ class FfiModel with ChangeNotifier {
|
||||
_handleUseTextureRender(
|
||||
Map<String, dynamic> evt, SessionID sessionId, String peerId) {
|
||||
parent.target?.imageModel.setUseTextureRender(evt['v'] == 'Y');
|
||||
if (evt['reason'] == 'fallback') {
|
||||
// The Rust watchdog detected that pushed frames were never rendered
|
||||
// and switched this session to software rendering.
|
||||
showToast(translate('texture-render-fallback-tip'));
|
||||
}
|
||||
waitForFirstImage.value = true;
|
||||
isRefreshing = true;
|
||||
showConnectedWaitingForImage(parent.target!.dialogManager, sessionId,
|
||||
@@ -909,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.
|
||||
@@ -925,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
|
||||
@@ -990,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(
|
||||
@@ -2230,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
|
||||
@@ -2278,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) {
|
||||
@@ -2512,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.
|
||||
@@ -2744,6 +2759,7 @@ class CanvasModel with ChangeNotifier {
|
||||
_x = 0;
|
||||
_y = 0;
|
||||
_scale = 1.0;
|
||||
_locked = false;
|
||||
_lastViewStyle = ViewStyle.defaultViewStyle();
|
||||
_timerMobileFocusCanvasCursor?.cancel();
|
||||
_timerMobileRestoreCanvasOffset?.cancel();
|
||||
@@ -2855,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();
|
||||
|
||||
@@ -4072,6 +4088,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');
|
||||
@@ -4176,7 +4197,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';
|
||||
@@ -131,12 +130,6 @@ class PlatformFFI {
|
||||
void registerGpuTexture(SessionID sessionId, int display, int ptr) =>
|
||||
_ffiBind.sessionRegisterGpuTexture(
|
||||
sessionId: sessionId, display: display, ptr: ptr);
|
||||
void unregisterPixelbufferTexture(SessionID sessionId, int display, int ptr) =>
|
||||
_ffiBind.sessionUnregisterPixelbufferTexture(
|
||||
sessionId: sessionId, display: display, ptr: ptr);
|
||||
void unregisterGpuTexture(SessionID sessionId, int display, int ptr) =>
|
||||
_ffiBind.sessionUnregisterGpuTexture(
|
||||
sessionId: sessionId, display: display, ptr: ptr);
|
||||
|
||||
/// Init the FFI class, loads the native Rust core library.
|
||||
Future<void> init(String appType) async {
|
||||
@@ -177,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.
|
||||
@@ -312,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);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,108 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:xterm/xterm.dart';
|
||||
|
||||
enum TerminalClipboardWritePermission { denied, unconfigured, allowed }
|
||||
|
||||
class RustDeskTerminal extends Terminal {
|
||||
RustDeskTerminal({super.maxLines});
|
||||
RustDeskTerminal({
|
||||
super.maxLines,
|
||||
required TerminalClipboardWritePermission Function()
|
||||
clipboardWritePermission,
|
||||
required Future<bool> Function(String) onClipboardWrite,
|
||||
ValueChanged<String>? onClipboardWriteBlocked,
|
||||
ValueChanged<String>? onClipboardWriteSucceeded,
|
||||
}) : _clipboardWritePermission = clipboardWritePermission,
|
||||
_onClipboardWrite = onClipboardWrite,
|
||||
_onClipboardWriteBlocked = onClipboardWriteBlocked,
|
||||
_onClipboardWriteSucceeded = onClipboardWriteSucceeded {
|
||||
onPrivateOSC = _handlePrivateOsc;
|
||||
}
|
||||
|
||||
static const _clipboardOscCode = '52';
|
||||
static const _systemClipboardSelection = 'c';
|
||||
// Match the terminal helper's existing payload safety ceiling.
|
||||
static const _maxClipboardWriteBytes = 16 * 1024 * 1024;
|
||||
static const _base64InputBytesPerBlock = 3;
|
||||
static const _base64EncodedCharsPerBlock = 4;
|
||||
static final _osc52Selection = RegExp(r'^[cpqs0-7]*$');
|
||||
final TerminalClipboardWritePermission Function() _clipboardWritePermission;
|
||||
final Future<bool> Function(String) _onClipboardWrite;
|
||||
final ValueChanged<String>? _onClipboardWriteBlocked;
|
||||
final ValueChanged<String>? _onClipboardWriteSucceeded;
|
||||
|
||||
bool get isClipboardWriteAllowed =>
|
||||
_clipboardWritePermission() == TerminalClipboardWritePermission.allowed;
|
||||
|
||||
void _handlePrivateOsc(String code, List<String> args) {
|
||||
if (code != _clipboardOscCode) return;
|
||||
if (args.length != 2 || !_osc52Selection.hasMatch(args.first)) {
|
||||
debugPrint('[RustDeskTerminal] Rejected malformed OSC 52 command');
|
||||
return;
|
||||
}
|
||||
if (args.last == '?') {
|
||||
debugPrint('[RustDeskTerminal] Rejected OSC 52 clipboard query');
|
||||
return;
|
||||
}
|
||||
final permission = _clipboardWritePermission();
|
||||
if (permission == TerminalClipboardWritePermission.denied) {
|
||||
debugPrint('[RustDeskTerminal] Rejected unauthorized OSC 52 write');
|
||||
return;
|
||||
}
|
||||
final selection = args.first;
|
||||
if (selection.isNotEmpty &&
|
||||
!selection.contains(_systemClipboardSelection)) {
|
||||
debugPrint('[RustDeskTerminal] Ignored unsupported OSC 52 selection');
|
||||
return;
|
||||
}
|
||||
if (selection.replaceAll(_systemClipboardSelection, '').isNotEmpty) {
|
||||
debugPrint('[RustDeskTerminal] Ignored unsupported OSC 52 selections');
|
||||
}
|
||||
final text = _decodeClipboardPayload(args.last);
|
||||
if (text == null) return;
|
||||
if (permission == TerminalClipboardWritePermission.unconfigured) {
|
||||
debugPrint('[RustDeskTerminal] Blocked OSC 52 write pending consent');
|
||||
_onClipboardWriteBlocked?.call(text);
|
||||
return;
|
||||
}
|
||||
unawaited(_writeClipboard(text));
|
||||
}
|
||||
|
||||
Future<void> _writeClipboard(String text) async {
|
||||
final succeeded = await _onClipboardWrite(text);
|
||||
if (succeeded) {
|
||||
_onClipboardWriteSucceeded?.call(text);
|
||||
return;
|
||||
}
|
||||
debugPrint(
|
||||
'[RustDeskTerminal] OSC 52 clipboard write requires interaction');
|
||||
_onClipboardWriteBlocked?.call(text);
|
||||
}
|
||||
|
||||
String? _decodeClipboardPayload(String payload) {
|
||||
if (payload.length > _maxBase64EncodedLength(_maxClipboardWriteBytes)) {
|
||||
debugPrint('[RustDeskTerminal] Rejected oversized OSC 52 payload');
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final bytes = base64.decode(payload);
|
||||
if (bytes.length > _maxClipboardWriteBytes) {
|
||||
debugPrint('[RustDeskTerminal] Rejected oversized OSC 52 payload');
|
||||
return null;
|
||||
}
|
||||
return utf8.decode(bytes);
|
||||
} on FormatException {
|
||||
debugPrint('[RustDeskTerminal] Rejected malformed OSC 52 payload');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static int _maxBase64EncodedLength(int maxBytes) =>
|
||||
((maxBytes + _base64InputBytesPerBlock - 1) ~/
|
||||
_base64InputBytesPerBlock) *
|
||||
_base64EncodedCharsPerBlock;
|
||||
|
||||
@override
|
||||
void eraseScrollbackOnly() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
15
flutter/lib/models/terminal_clipboard_writer.dart
Normal file
15
flutter/lib/models/terminal_clipboard_writer.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
Future<bool> writeTerminalClipboardPlatform(
|
||||
String text, {
|
||||
bool userInitiated = false,
|
||||
}) async {
|
||||
try {
|
||||
await Clipboard.setData(ClipboardData(text: text));
|
||||
return true;
|
||||
} catch (error) {
|
||||
debugPrint('[Terminal] Failed to write clipboard: $error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
29
flutter/lib/models/terminal_clipboard_writer_web.dart
Normal file
29
flutter/lib/models/terminal_clipboard_writer_web.dart
Normal file
@@ -0,0 +1,29 @@
|
||||
import 'dart:js_interop';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
const _writeTerminalClipboardCommand = 'write_terminal_clipboard';
|
||||
|
||||
@JS('setByName')
|
||||
external JSPromise<JSBoolean> _setByName(
|
||||
JSString name,
|
||||
JSString value,
|
||||
JSBoolean userInitiated,
|
||||
);
|
||||
|
||||
Future<bool> writeTerminalClipboardPlatform(
|
||||
String text, {
|
||||
bool userInitiated = false,
|
||||
}) async {
|
||||
try {
|
||||
final result = await _setByName(
|
||||
_writeTerminalClipboardCommand.toJS,
|
||||
text.toJS,
|
||||
userInitiated.toJS,
|
||||
).toDart;
|
||||
return result.toDart;
|
||||
} catch (error) {
|
||||
debugPrint('[Terminal] Failed to write Web clipboard: $error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
201
flutter/lib/models/terminal_copy_shortcut.dart
Normal file
201
flutter/lib/models/terminal_copy_shortcut.dart
Normal file
@@ -0,0 +1,201 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:xterm/xterm.dart';
|
||||
|
||||
import 'terminal_clipboard_writer.dart'
|
||||
if (dart.library.html) 'terminal_clipboard_writer_web.dart';
|
||||
|
||||
const _controlShiftVPasteShortcut = SingleActivator(
|
||||
LogicalKeyboardKey.keyV,
|
||||
control: true,
|
||||
shift: true,
|
||||
);
|
||||
|
||||
typedef TerminalClipboardWriter = Future<bool> Function(
|
||||
String text, {
|
||||
required bool userInitiated,
|
||||
});
|
||||
|
||||
class TerminalClipboardNoticeRequest<T> {
|
||||
const TerminalClipboardNoticeRequest({
|
||||
required this.source,
|
||||
required this.text,
|
||||
required this.persistAllowed,
|
||||
});
|
||||
|
||||
final T source;
|
||||
final String text;
|
||||
final bool persistAllowed;
|
||||
|
||||
String get actionKey => persistAllowed ? 'Enable' : 'Copy to clipboard';
|
||||
|
||||
String get negativeActionKey => persistAllowed ? 'Decline' : 'Dismiss';
|
||||
}
|
||||
|
||||
const kTerminalClipboardNoticeMessageKey = 'terminal-clipboard-write-tip';
|
||||
|
||||
class TerminalClipboardNoticeCoordinator<T> extends ChangeNotifier {
|
||||
TerminalClipboardNoticeRequest<T>? _current;
|
||||
bool _noticeVisible = false;
|
||||
bool _actionInProgress = false;
|
||||
|
||||
TerminalClipboardNoticeRequest<T>? get current => _current;
|
||||
bool get canClaimAction =>
|
||||
_noticeVisible && !_actionInProgress && _current != null;
|
||||
|
||||
TerminalClipboardNoticeRequest<T>? currentForSource(T source) {
|
||||
final current = _current;
|
||||
if (current == null || current.source != source) return null;
|
||||
return current;
|
||||
}
|
||||
|
||||
TerminalClipboardNoticeRequest<T>? recordBlocked({
|
||||
required T source,
|
||||
required String text,
|
||||
required String option,
|
||||
required bool Function(T source) canWrite,
|
||||
}) {
|
||||
if (!canWrite(source)) return null;
|
||||
final requestAllowsPersistence =
|
||||
option == kTerminalClipboardWriteUnconfigured;
|
||||
if (option != kTerminalClipboardWriteAllowed && !requestAllowsPersistence) {
|
||||
return null;
|
||||
}
|
||||
if (_noticeVisible && _actionInProgress) return null;
|
||||
final wasVisible = _noticeVisible;
|
||||
final persistAllowed =
|
||||
wasVisible ? _current?.persistAllowed : requestAllowsPersistence;
|
||||
final request = TerminalClipboardNoticeRequest(
|
||||
source: source,
|
||||
text: text,
|
||||
persistAllowed: persistAllowed ?? requestAllowsPersistence,
|
||||
);
|
||||
_current = request;
|
||||
if (wasVisible) return null;
|
||||
_noticeVisible = true;
|
||||
return request;
|
||||
}
|
||||
|
||||
TerminalClipboardNoticeRequest<T>? claimCurrentAction() {
|
||||
if (!canClaimAction) return null;
|
||||
final current = _current;
|
||||
if (current == null) return null;
|
||||
_actionInProgress = true;
|
||||
notifyListeners();
|
||||
return current;
|
||||
}
|
||||
|
||||
void releaseAction() {
|
||||
if (!_actionInProgress) return;
|
||||
_actionInProgress = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
bool beginClose() {
|
||||
if (!_noticeVisible) return false;
|
||||
_actionInProgress = true;
|
||||
notifyListeners();
|
||||
return true;
|
||||
}
|
||||
|
||||
void noticeClosed() => clear();
|
||||
|
||||
void clear() {
|
||||
_current = null;
|
||||
_noticeVisible = false;
|
||||
_actionInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> writeTerminalClipboard(
|
||||
String text, {
|
||||
bool userInitiated = false,
|
||||
}) =>
|
||||
writeTerminalClipboardPlatform(text, userInitiated: userInitiated);
|
||||
|
||||
Future<bool> completeTerminalClipboardWrite({
|
||||
required String clipboardText,
|
||||
required bool Function() canWrite,
|
||||
required TerminalClipboardWriter writeClipboard,
|
||||
Future<void> Function()? persistAllowed,
|
||||
}) async {
|
||||
if (!canWrite()) return false;
|
||||
if (!await writeClipboard(clipboardText, userInitiated: true)) return false;
|
||||
await persistAllowed?.call();
|
||||
return true;
|
||||
}
|
||||
|
||||
Map<ShortcutActivator, Intent>? platformTerminalShortcuts() {
|
||||
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, userInitiated: true));
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -11,8 +11,38 @@ import 'input_modifier_utils.dart';
|
||||
import 'model.dart';
|
||||
import 'platform_model.dart';
|
||||
import 'rustdesk_terminal.dart';
|
||||
import 'terminal_copy_shortcut.dart';
|
||||
import 'terminal_mouse_handler.dart';
|
||||
|
||||
bool canConfigureTerminalClipboardPermission({
|
||||
required bool settingsDisabled,
|
||||
required bool optionFixed,
|
||||
}) =>
|
||||
!settingsDisabled && !optionFixed;
|
||||
|
||||
bool canHandleTerminalClipboardWriteRequest({
|
||||
required String localOption,
|
||||
required bool canConfigurePermission,
|
||||
}) =>
|
||||
canConfigurePermission || localOption == kTerminalClipboardWriteAllowed;
|
||||
|
||||
TerminalClipboardWritePermission terminalClipboardWritePermission(
|
||||
String localOption, {
|
||||
required bool remoteClipboardEnabled,
|
||||
bool canRequestConsent = true,
|
||||
}) {
|
||||
if (!remoteClipboardEnabled) {
|
||||
return TerminalClipboardWritePermission.denied;
|
||||
}
|
||||
if (localOption == kTerminalClipboardWriteAllowed) {
|
||||
return TerminalClipboardWritePermission.allowed;
|
||||
}
|
||||
if (localOption == kTerminalClipboardWriteUnconfigured && canRequestConsent) {
|
||||
return TerminalClipboardWritePermission.unconfigured;
|
||||
}
|
||||
return TerminalClipboardWritePermission.denied;
|
||||
}
|
||||
|
||||
class TerminalModel with ChangeNotifier {
|
||||
final String id; // peer id
|
||||
final FFI parent;
|
||||
@@ -62,6 +92,9 @@ class TerminalModel with ChangeNotifier {
|
||||
/// The listener (typically TerminalPage) can use this to auto-close the tab/page.
|
||||
VoidCallback? onClosed;
|
||||
|
||||
ValueChanged<String>? onClipboardWriteBlocked;
|
||||
ValueChanged<String>? onClipboardWriteSucceeded;
|
||||
|
||||
Future<void> _handleInput(String data) async {
|
||||
// xterm can complete asynchronous input after the Flutter page has gone
|
||||
// away. Stop before reading or clearing widget-owned modifier state.
|
||||
@@ -130,7 +163,19 @@ class TerminalModel with ChangeNotifier {
|
||||
}
|
||||
|
||||
TerminalModel(this.parent, [this.terminalId = 0]) : id = parent.id {
|
||||
terminal = RustDeskTerminal(maxLines: 10000);
|
||||
terminal = RustDeskTerminal(
|
||||
maxLines: 10000,
|
||||
onClipboardWrite: writeTerminalClipboard,
|
||||
clipboardWritePermission: () => terminalClipboardWritePermission(
|
||||
bind.mainGetLocalOption(key: kOptionAllowTerminalClipboardWrite),
|
||||
remoteClipboardEnabled:
|
||||
parent.ffiModel.permissions['clipboard'] != false,
|
||||
canRequestConsent: onClipboardWriteBlocked != null,
|
||||
),
|
||||
onClipboardWriteBlocked: (text) => onClipboardWriteBlocked?.call(text),
|
||||
onClipboardWriteSucceeded: (text) =>
|
||||
onClipboardWriteSucceeded?.call(text),
|
||||
);
|
||||
terminal.mouseHandler = const WheelButtonFixMouseHandler();
|
||||
terminalController = TerminalController();
|
||||
|
||||
@@ -593,6 +638,8 @@ class TerminalModel with ChangeNotifier {
|
||||
clearAltLock = null;
|
||||
onResizeExternal = null;
|
||||
onClosed = null;
|
||||
onClipboardWriteBlocked = null;
|
||||
onClipboardWriteSucceeded = null;
|
||||
// Clear buffers to free memory
|
||||
_inputBuffer.clear();
|
||||
_pendingOutputChunks.clear();
|
||||
|
||||
297
flutter/lib/models/terminal_mouse_drag_reporter.dart
Normal file
297
flutter/lib/models/terminal_mouse_drag_reporter.dart
Normal file
@@ -0,0 +1,297 @@
|
||||
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;
|
||||
var _dragged = false;
|
||||
|
||||
bool handleDown(
|
||||
PointerDownEvent event,
|
||||
Terminal terminal,
|
||||
TerminalViewState? terminalView, {
|
||||
bool reportTouchInput = false,
|
||||
bool deferReport = false,
|
||||
}) {
|
||||
if (!_isPrimaryPointer(event, reportTouchInput) ||
|
||||
!_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 = !deferReport;
|
||||
_reporting = !deferReport;
|
||||
_dragged = false;
|
||||
controller.setSuspendPointerInput(true);
|
||||
_clearSelection(controller);
|
||||
final position = _cellAt(event, terminalView);
|
||||
_lastReportedPosition = position;
|
||||
if (!deferReport) {
|
||||
terminal.textInput(
|
||||
_report(terminal.mouseReportMode, position),
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool activateDeferredDown(Terminal terminal) {
|
||||
if (_pointerId == null ||
|
||||
_controller == null ||
|
||||
_releasePending ||
|
||||
!_reportsDrag(terminal.mouseMode)) {
|
||||
return false;
|
||||
}
|
||||
_releasePending = true;
|
||||
_reporting = true;
|
||||
_clearSelection(_controller);
|
||||
terminal.textInput(
|
||||
_report(terminal.mouseReportMode, _lastReportedPosition),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool handleMove(
|
||||
PointerMoveEvent event,
|
||||
Terminal terminal,
|
||||
TerminalViewState? terminalView, {
|
||||
void Function(bool dragged)? beforeRelease,
|
||||
void Function()? onCancel,
|
||||
}) {
|
||||
if (event.pointer != _pointerId) return false;
|
||||
if (terminalView == null) {
|
||||
onCancel?.call();
|
||||
cancel();
|
||||
return true;
|
||||
}
|
||||
final reportsDrag = _reportsDrag(terminal.mouseMode);
|
||||
if (!_hasPrimaryButton(event)) {
|
||||
if (_releasePending && reportsDrag) {
|
||||
_finishRelease(
|
||||
event,
|
||||
terminal,
|
||||
terminalView,
|
||||
beforeRelease: beforeRelease,
|
||||
);
|
||||
} else {
|
||||
onCancel?.call();
|
||||
}
|
||||
cancel();
|
||||
return true;
|
||||
}
|
||||
if (!_reporting || !reportsDrag) {
|
||||
if (!reportsDrag && _releasePending) {
|
||||
_releasePending = false;
|
||||
onCancel?.call();
|
||||
}
|
||||
_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);
|
||||
_recordPosition(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, {
|
||||
void Function(bool dragged)? beforeRelease,
|
||||
void Function()? onCancel,
|
||||
}) {
|
||||
if (event.pointer != _pointerId) return false;
|
||||
if (terminalView != null &&
|
||||
_releasePending &&
|
||||
_reportsDrag(terminal.mouseMode)) {
|
||||
_finishRelease(
|
||||
event,
|
||||
terminal,
|
||||
terminalView,
|
||||
beforeRelease: beforeRelease,
|
||||
);
|
||||
} else {
|
||||
onCancel?.call();
|
||||
}
|
||||
_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;
|
||||
_dragged = 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _finishRelease(
|
||||
PointerEvent event,
|
||||
Terminal terminal,
|
||||
TerminalViewState terminalView, {
|
||||
void Function(bool dragged)? beforeRelease,
|
||||
}) {
|
||||
final position =
|
||||
_reporting ? _cellAt(event, terminalView) : _lastReportedPosition;
|
||||
if (_reporting) _recordPosition(position);
|
||||
beforeRelease?.call(_dragged);
|
||||
_reportRelease(terminal, position);
|
||||
}
|
||||
|
||||
void _recordPosition(CellOffset position) {
|
||||
_dragged = _dragged || position != _lastReportedPosition;
|
||||
_lastReportedPosition = position;
|
||||
}
|
||||
|
||||
CellOffset _cellAt(PointerEvent event, TerminalViewState terminalView) {
|
||||
final renderTerminal = terminalView.renderTerminal;
|
||||
return renderTerminal.getCellOffset(
|
||||
renderTerminal.globalToLocal(event.position),
|
||||
);
|
||||
}
|
||||
|
||||
bool _isPrimaryPointer(PointerEvent event, bool reportTouchInput) =>
|
||||
(event.kind == PointerDeviceKind.mouse ||
|
||||
reportTouchInput && event.kind == PointerDeviceKind.touch) &&
|
||||
_hasPrimaryButton(event);
|
||||
|
||||
bool _hasPrimaryButton(PointerEvent event) =>
|
||||
(event.buttons & kPrimaryButton) == kPrimaryButton;
|
||||
|
||||
bool _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,42 +1,294 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:xterm/xterm.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();
|
||||
import 'platform_model.dart';
|
||||
import 'rustdesk_terminal.dart';
|
||||
import 'terminal_copy_shortcut.dart';
|
||||
import 'terminal_mouse_drag_reporter.dart';
|
||||
|
||||
part 'terminal_mouse_handler_input.dart';
|
||||
part 'terminal_web_clipboard_gesture.dart';
|
||||
|
||||
class TerminalMouseInteraction extends StatefulWidget {
|
||||
const TerminalMouseInteraction(
|
||||
this.terminal, {
|
||||
super.key,
|
||||
required this.controller,
|
||||
this.focusNode,
|
||||
this.autofocus = false,
|
||||
this.textStyle = const TerminalStyle(),
|
||||
this.deleteDetection = false,
|
||||
this.reportTouchInput = false,
|
||||
this.shortcuts,
|
||||
this.onKeyEvent,
|
||||
this.backgroundOpacity = 1,
|
||||
this.padding,
|
||||
this.onSecondaryTapDown,
|
||||
});
|
||||
|
||||
final Terminal terminal;
|
||||
final TerminalController controller;
|
||||
final FocusNode? focusNode;
|
||||
final bool autofocus;
|
||||
final TerminalStyle textStyle;
|
||||
final bool deleteDetection;
|
||||
final bool reportTouchInput;
|
||||
final Map<ShortcutActivator, Intent>? shortcuts;
|
||||
final FocusOnKeyEventCallback? onKeyEvent;
|
||||
final double backgroundOpacity;
|
||||
final EdgeInsets? padding;
|
||||
final void Function(TapDownDetails, CellOffset)? onSecondaryTapDown;
|
||||
|
||||
@override
|
||||
String? call(TerminalMouseEvent event) {
|
||||
if (!event.button.isWheel) {
|
||||
return defaultMouseHandler(event);
|
||||
}
|
||||
// Same gate as UpDownMouseHandler: only the scroll modes report a wheel,
|
||||
// and a wheel release is never reported, so the report is always a press.
|
||||
if (!event.state.mouseMode.reportScroll ||
|
||||
event.buttonState == TerminalMouseButtonState.up) {
|
||||
return null;
|
||||
}
|
||||
return _reportWheel(event);
|
||||
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;
|
||||
Timer? _pendingTouchMouseTimer;
|
||||
PointerDownEvent? _pendingTouchMouseDown;
|
||||
var _selectionHasScrolled = false;
|
||||
var _scrollDirection = _noScroll;
|
||||
// xterm can finish its tap callbacks after the raw drag was reported.
|
||||
var _suppressXtermLeftButton = false;
|
||||
var _terminalClipboardGesturePrepared = false;
|
||||
TerminalViewState? get _terminalView => _terminalViewKey.currentState;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_mouseHandler = WheelButtonFixMouseHandler(
|
||||
positionProvider: _cellAtPointer,
|
||||
suppressLeftButton: kIsWeb ? _consumeXtermLeftButtonSuppression : null,
|
||||
);
|
||||
_installMouseHandler(widget.terminal);
|
||||
}
|
||||
|
||||
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';
|
||||
@override
|
||||
void didUpdateWidget(TerminalMouseInteraction oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
final terminalChanged = !identical(oldWidget.terminal, widget.terminal);
|
||||
final controllerChanged =
|
||||
!identical(oldWidget.controller, widget.controller);
|
||||
final touchInputChanged =
|
||||
oldWidget.reportTouchInput != widget.reportTouchInput;
|
||||
if (!terminalChanged && !controllerChanged && !touchInputChanged) return;
|
||||
_cancelPendingTouchMouseDrag();
|
||||
if (!terminalChanged && !controllerChanged) return;
|
||||
if (controllerChanged && !terminalChanged) {
|
||||
_mouseDrag.updateController(widget.controller);
|
||||
} else {
|
||||
_discardPendingTerminalClipboardWrites();
|
||||
_mouseDrag.cancel();
|
||||
}
|
||||
_clearSelectionDrag();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
void _handlePointerMove(PointerMoveEvent event) {
|
||||
_updatePointerPosition(event);
|
||||
if (_handlePendingTouchMove(event)) return;
|
||||
if (_mouseDrag.handleMove(
|
||||
event,
|
||||
widget.terminal,
|
||||
_terminalView,
|
||||
beforeRelease: _finishTerminalClipboardWrite,
|
||||
onCancel: _cancelTerminalClipboardWrite,
|
||||
)) {
|
||||
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);
|
||||
final pendingTouch = _pendingTouchMouseDown;
|
||||
if (pendingTouch != null && pendingTouch.pointer == event.pointer) {
|
||||
final movedBeyondSlop =
|
||||
(event.position - pendingTouch.position).distance > kTouchSlop;
|
||||
if (event is PointerUpEvent && !movedBeyondSlop) {
|
||||
_activatePendingTouchMouseDrag(cancelOnFailure: false);
|
||||
} else {
|
||||
_takePendingTouchMouseDrag(pointer: event.pointer);
|
||||
}
|
||||
}
|
||||
final handledByMouseDrag = _mouseDrag.handleEnd(
|
||||
event,
|
||||
widget.terminal,
|
||||
_terminalView,
|
||||
beforeRelease: event is PointerUpEvent
|
||||
? _finishTerminalClipboardWrite
|
||||
: (_) => _cancelTerminalClipboardWrite(),
|
||||
onCancel: _cancelTerminalClipboardWrite,
|
||||
);
|
||||
if (!handledByMouseDrag && event.pointer != _selectionPointerId) {
|
||||
return;
|
||||
}
|
||||
if (_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() {
|
||||
_discardPendingTerminalClipboardWrites();
|
||||
_cancelPendingTouchMouseDrag();
|
||||
_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,
|
||||
autofocus: widget.autofocus,
|
||||
textStyle: widget.textStyle,
|
||||
deleteDetection: widget.deleteDetection,
|
||||
backgroundOpacity: widget.backgroundOpacity,
|
||||
padding: widget.padding,
|
||||
shortcuts: widget.shortcuts ?? platformTerminalShortcuts(),
|
||||
onKeyEvent: widget.onKeyEvent ??
|
||||
terminalCopyHandler(widget.terminal, widget.controller),
|
||||
onSecondaryTapDown: widget.onSecondaryTapDown,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
162
flutter/lib/models/terminal_mouse_handler_input.dart
Normal file
162
flutter/lib/models/terminal_mouse_handler_input.dart
Normal file
@@ -0,0 +1,162 @@
|
||||
part of 'terminal_mouse_handler.dart';
|
||||
|
||||
/// xterm 4.0.0 encodes wheel buttons as 68..71; the extra bit reads as a Shift
|
||||
/// modifier, so strict full-screen apps ignore the report and never scroll.
|
||||
/// Upstream fix: TerminalStudio/xterm.dart#238.
|
||||
class WheelButtonFixMouseHandler implements TerminalMouseHandler {
|
||||
const WheelButtonFixMouseHandler({
|
||||
this.positionProvider,
|
||||
this.suppressLeftButton,
|
||||
});
|
||||
|
||||
final CellOffset? Function()? positionProvider;
|
||||
final bool Function(TerminalMouseButtonState)? suppressLeftButton;
|
||||
|
||||
@override
|
||||
String? call(TerminalMouseEvent event) {
|
||||
if (!event.button.isWheel) {
|
||||
if (event.button == TerminalMouseButton.left &&
|
||||
suppressLeftButton?.call(event.buttonState) == true) {
|
||||
return null;
|
||||
}
|
||||
return defaultMouseHandler(event);
|
||||
}
|
||||
// Same gate as UpDownMouseHandler: only the scroll modes report a wheel,
|
||||
// and a wheel release is never reported, so the report is always a press.
|
||||
if (!event.state.mouseMode.reportScroll ||
|
||||
event.buttonState == TerminalMouseButtonState.up) {
|
||||
return null;
|
||||
}
|
||||
return _reportWheel(event);
|
||||
}
|
||||
|
||||
String _reportWheel(TerminalMouseEvent event) {
|
||||
// Wheel buttons 4..7 go on the wire as 64..67, but `id` is 64 + 4..7.
|
||||
final button = event.button.id - 4;
|
||||
final position = positionProvider?.call() ?? event.position;
|
||||
return encodeTerminalMouseReport(
|
||||
event.state.mouseReportMode,
|
||||
button,
|
||||
position,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension _TerminalMouseInput on _TerminalMouseInteractionState {
|
||||
CellOffset? _cellAtPointer() {
|
||||
final terminalView = _terminalView;
|
||||
final pointerPosition = _pointerPosition;
|
||||
if (terminalView == null || pointerPosition == null) return null;
|
||||
final renderTerminal = terminalView.renderTerminal;
|
||||
return renderTerminal.getCellOffset(
|
||||
renderTerminal.globalToLocal(pointerPosition),
|
||||
);
|
||||
}
|
||||
|
||||
void _updatePointerPosition(PointerEvent event) =>
|
||||
_pointerPosition = event.position;
|
||||
|
||||
void _handlePointerDown(PointerDownEvent event) {
|
||||
_updatePointerPosition(event);
|
||||
_suppressXtermLeftButton = false;
|
||||
if (_startPendingTouchMouseDrag(event)) return;
|
||||
if (_mouseDrag.handleDown(event, widget.terminal, _terminalView)) {
|
||||
_prepareTerminalClipboardWrite();
|
||||
if (kIsWeb) _suppressXtermLeftButton = true;
|
||||
_clearSelectionDrag();
|
||||
return;
|
||||
}
|
||||
if (event.kind != PointerDeviceKind.mouse ||
|
||||
(event.buttons & kPrimaryMouseButton) != kPrimaryMouseButton) {
|
||||
return;
|
||||
}
|
||||
_clearSelectionDrag();
|
||||
final terminalView = _terminalView;
|
||||
if (terminalView == null) return;
|
||||
final renderTerminal = terminalView.renderTerminal;
|
||||
final localPosition = renderTerminal.globalToLocal(event.position);
|
||||
final selectionBuffer = widget.terminal.buffer;
|
||||
_selectionPointerId = event.pointer;
|
||||
_selectionBase = selectionBuffer.createAnchorFromOffset(
|
||||
renderTerminal.getCellOffset(localPosition),
|
||||
);
|
||||
_selectionBuffer = selectionBuffer;
|
||||
_selectionPointer = localPosition;
|
||||
}
|
||||
|
||||
bool _startPendingTouchMouseDrag(PointerDownEvent event) {
|
||||
if (!widget.reportTouchInput ||
|
||||
event.kind != PointerDeviceKind.touch ||
|
||||
!_mouseDrag.handleDown(
|
||||
event,
|
||||
widget.terminal,
|
||||
_terminalView,
|
||||
reportTouchInput: true,
|
||||
deferReport: true,
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
_pendingTouchMouseDown = event;
|
||||
_pendingTouchMouseTimer = Timer(
|
||||
kLongPressTimeout,
|
||||
_activatePendingTouchMouseDrag,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool _activatePendingTouchMouseDrag({
|
||||
bool cancelOnFailure = true,
|
||||
}) {
|
||||
if (_takePendingTouchMouseDrag() == null) return false;
|
||||
if (_mouseDrag.activateDeferredDown(widget.terminal)) {
|
||||
_prepareTerminalClipboardWrite();
|
||||
_clearSelectionDrag();
|
||||
return true;
|
||||
}
|
||||
if (cancelOnFailure) _mouseDrag.cancel();
|
||||
return false;
|
||||
}
|
||||
|
||||
PointerDownEvent? _takePendingTouchMouseDrag({int? pointer}) {
|
||||
final pending = _pendingTouchMouseDown;
|
||||
if (pending == null || pointer != null && pointer != pending.pointer) {
|
||||
return null;
|
||||
}
|
||||
_pendingTouchMouseTimer?.cancel();
|
||||
_pendingTouchMouseTimer = null;
|
||||
_pendingTouchMouseDown = null;
|
||||
return pending;
|
||||
}
|
||||
|
||||
void _cancelPendingTouchMouseDrag({
|
||||
int? pointer,
|
||||
bool deferCancel = false,
|
||||
}) {
|
||||
if (_takePendingTouchMouseDrag(pointer: pointer) == null) return;
|
||||
if (deferCancel) {
|
||||
scheduleMicrotask(_mouseDrag.cancel);
|
||||
} else {
|
||||
_mouseDrag.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
bool _handlePendingTouchMove(PointerMoveEvent event) {
|
||||
final pending = _pendingTouchMouseDown;
|
||||
if (pending == null || pending.pointer != event.pointer) return false;
|
||||
if ((event.position - pending.position).distance > kTouchSlop) {
|
||||
_cancelPendingTouchMouseDrag(
|
||||
pointer: event.pointer,
|
||||
deferCancel: true,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool _consumeXtermLeftButtonSuppression(TerminalMouseButtonState state) {
|
||||
final suppress = _suppressXtermLeftButton;
|
||||
if (state == TerminalMouseButtonState.up) {
|
||||
_suppressXtermLeftButton = false;
|
||||
}
|
||||
return suppress;
|
||||
}
|
||||
}
|
||||
56
flutter/lib/models/terminal_web_clipboard_gesture.dart
Normal file
56
flutter/lib/models/terminal_web_clipboard_gesture.dart
Normal file
@@ -0,0 +1,56 @@
|
||||
part of 'terminal_mouse_handler.dart';
|
||||
|
||||
const _prepareTerminalClipboardCommand = 'prepare_terminal_clipboard';
|
||||
const _finishTerminalClipboardCommand = 'finish_terminal_clipboard';
|
||||
const _cancelTerminalClipboardCommand = 'cancel_terminal_clipboard';
|
||||
|
||||
extension _TerminalWebClipboardGesture on _TerminalMouseInteractionState {
|
||||
void _prepareTerminalClipboardWrite() {
|
||||
if (!kIsWeb) return;
|
||||
_cancelTerminalClipboardWrite();
|
||||
final terminal = widget.terminal;
|
||||
if (terminal is! RustDeskTerminal || !terminal.isClipboardWriteAllowed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
ffiSetByName(_prepareTerminalClipboardCommand);
|
||||
_terminalClipboardGesturePrepared = true;
|
||||
} catch (error) {
|
||||
debugPrint('[Terminal] Failed to prepare Web clipboard write: $error');
|
||||
}
|
||||
}
|
||||
|
||||
void _finishTerminalClipboardWrite(bool responseExpected) {
|
||||
if (!_terminalClipboardGesturePrepared) return;
|
||||
_terminalClipboardGesturePrepared = false;
|
||||
if (!kIsWeb) return;
|
||||
try {
|
||||
ffiSetByName(
|
||||
_finishTerminalClipboardCommand,
|
||||
responseExpected ? 'true' : 'false',
|
||||
);
|
||||
} catch (error) {
|
||||
debugPrint('[Terminal] Failed to finish Web clipboard write: $error');
|
||||
}
|
||||
}
|
||||
|
||||
void _cancelTerminalClipboardWrite() {
|
||||
if (!_terminalClipboardGesturePrepared) return;
|
||||
_terminalClipboardGesturePrepared = false;
|
||||
_sendTerminalClipboardCancel();
|
||||
}
|
||||
|
||||
void _discardPendingTerminalClipboardWrites() {
|
||||
_cancelTerminalClipboardWrite();
|
||||
_sendTerminalClipboardCancel();
|
||||
}
|
||||
|
||||
void _sendTerminalClipboardCancel() {
|
||||
if (!kIsWeb) return;
|
||||
try {
|
||||
ffiSetByName(_cancelTerminalClipboardCommand);
|
||||
} catch (error) {
|
||||
debugPrint('[Terminal] Failed to cancel Web clipboard write: $error');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -136,12 +136,6 @@ class PlatformFFI {
|
||||
void registerGpuTexture(SessionID sessionId, int display, int ptr) =>
|
||||
_ffiBind.sessionRegisterGpuTexture(
|
||||
sessionId: sessionId, display: display, ptr: ptr);
|
||||
void unregisterPixelbufferTexture(SessionID sessionId, int display, int ptr) =>
|
||||
_ffiBind.sessionUnregisterPixelbufferTexture(
|
||||
sessionId: sessionId, display: display, ptr: ptr);
|
||||
void unregisterGpuTexture(SessionID sessionId, int display, int ptr) =>
|
||||
_ffiBind.sessionUnregisterGpuTexture(
|
||||
sessionId: sessionId, display: display, ptr: ptr);
|
||||
|
||||
Future<void> init(String appType) async {
|
||||
Completer completer = Completer();
|
||||
@@ -257,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']);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user