mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-02-17 22:11:30 +08:00
Compare commits
215 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6aefcfa30 | ||
|
|
4c5ec42100 | ||
|
|
f61728e24c | ||
|
|
2d7d1d0545 | ||
|
|
968a9deee5 | ||
|
|
e79f254e50 | ||
|
|
8f712a51a3 | ||
|
|
7d20e0f26f | ||
|
|
c1b46b6b9d | ||
|
|
dd0e6c31ba | ||
|
|
54cf1c8225 | ||
|
|
a73be6fc94 | ||
|
|
2c976eb1e2 | ||
|
|
9dbb6217f7 | ||
|
|
1a8e3005cd | ||
|
|
e9b4e4d170 | ||
|
|
fb1661c897 | ||
|
|
ca7b4872d9 | ||
|
|
9475743b4e | ||
|
|
86bbdf7a5d | ||
|
|
4f6818477f | ||
|
|
d46862e47d | ||
|
|
61cdb60362 | ||
|
|
419bb3f0b0 | ||
|
|
0869ceb5da | ||
|
|
36e52e41ad | ||
|
|
bd85e9c322 | ||
|
|
6ffbcd1375 | ||
|
|
a7d0f3b149 | ||
|
|
5e60a47408 | ||
|
|
aa30f68c05 | ||
|
|
eee5b5f64c | ||
|
|
5298a5f83b | ||
|
|
d56df22838 | ||
|
|
ca00706a38 | ||
|
|
62276b4f4f | ||
|
|
e55722308e | ||
|
|
7c8d2daaf6 | ||
|
|
04e2792f5f | ||
|
|
7196dbed6e | ||
|
|
ec1de6413a | ||
|
|
d30ead1d96 | ||
|
|
20fcddffbd | ||
|
|
83aae23ba6 | ||
|
|
df847e9a60 | ||
|
|
2ad1c907b8 | ||
|
|
c626c2414d | ||
|
|
2864e1984a | ||
|
|
f0c5580f57 | ||
|
|
abde556695 | ||
|
|
c9d5e15ac0 | ||
|
|
16e9e716b6 | ||
|
|
f438bf582b | ||
|
|
c0789a5fc0 | ||
|
|
198967ea35 | ||
|
|
279fb72a4f | ||
|
|
5c2538e7af | ||
|
|
296aa7f8a0 | ||
|
|
2cb096178a | ||
|
|
57ee031827 | ||
|
|
52b6541dd0 | ||
|
|
86d9e62780 | ||
|
|
a8c822ee5d | ||
|
|
bc1f629c17 | ||
|
|
66a9882e30 | ||
|
|
978ead4c42 | ||
|
|
56010344b7 | ||
|
|
c853dd4279 | ||
|
|
f1f504f9f1 | ||
|
|
f34f962b73 | ||
|
|
ded19ce5b9 | ||
|
|
3f9ba53dca | ||
|
|
3e82b99f8e | ||
|
|
98e9e2a0e8 | ||
|
|
375cede605 | ||
|
|
838decccc4 | ||
|
|
b7742ff806 | ||
|
|
36815e9a02 | ||
|
|
581313341b | ||
|
|
d9109560a7 | ||
|
|
d7dc49f1f7 | ||
|
|
f9af3e3a0c | ||
|
|
3b73ee3a23 | ||
|
|
d8eb23a571 | ||
|
|
cc0761446f | ||
|
|
d972c0eda1 | ||
|
|
2d403913b5 | ||
|
|
62a83ad319 | ||
|
|
a7aacc7855 | ||
|
|
9ddeab9be2 | ||
|
|
d808bb2947 | ||
|
|
c19f33a137 | ||
|
|
11d3ea5f24 | ||
|
|
ca1b35440b | ||
|
|
3dbe27ea57 | ||
|
|
a2725df7cd | ||
|
|
f32988b454 | ||
|
|
adf83a1b25 | ||
|
|
ea74ed12b8 | ||
|
|
5f3b980373 | ||
|
|
23e70c0fd1 | ||
|
|
4b14f86134 | ||
|
|
ee2478168c | ||
|
|
f4bbf82363 | ||
|
|
1cb53c1f7a | ||
|
|
eea9e0fa43 | ||
|
|
ac630c2ca6 | ||
|
|
9831f93430 | ||
|
|
c074a1d6af | ||
|
|
47c93f8544 | ||
|
|
c06ac9341a | ||
|
|
8d231b4605 | ||
|
|
745ba1673d | ||
|
|
2ef1dd99de | ||
|
|
960d9a042f | ||
|
|
10457dfe45 | ||
|
|
971d4e6976 | ||
|
|
2dbff45588 | ||
|
|
2bdb621417 | ||
|
|
47b00054d2 | ||
|
|
d1c8b331c5 | ||
|
|
1403c939db | ||
|
|
f1d2073d43 | ||
|
|
f7c930e153 | ||
|
|
22005bac75 | ||
|
|
8f7bb5a032 | ||
|
|
e0fd698101 | ||
|
|
b2cc9eac23 | ||
|
|
5f521c80a7 | ||
|
|
f0f999dc27 | ||
|
|
df4a101316 | ||
|
|
bdc53f0190 | ||
|
|
cef4175961 | ||
|
|
4ff75412c3 | ||
|
|
f1329ca69e | ||
|
|
c95aaf563e | ||
|
|
11fed81c4d | ||
|
|
561bc18f49 | ||
|
|
6946b863f7 | ||
|
|
8f68861920 | ||
|
|
171d178b09 | ||
|
|
7305b6bd1c | ||
|
|
6600c8c648 | ||
|
|
32b77f8968 | ||
|
|
0bda90f8fb | ||
|
|
2b68c46fdc | ||
|
|
bfbf00f18c | ||
|
|
e17ab74040 | ||
|
|
41cd375e3c | ||
|
|
0d919157c9 | ||
|
|
00293a9902 | ||
|
|
bc3a58f6f4 | ||
|
|
d8496aba0b | ||
|
|
280c12942f | ||
|
|
3a5b30a5e7 | ||
|
|
c46023bbde | ||
|
|
93feedc212 | ||
|
|
6f1a769741 | ||
|
|
e191d11f74 | ||
|
|
fc396d2166 | ||
|
|
2575e14811 | ||
|
|
0b9a6a280e | ||
|
|
343f12b380 | ||
|
|
ce1e4863cb | ||
|
|
f631c1c28d | ||
|
|
8b9a7a3506 | ||
|
|
055b351164 | ||
|
|
2e89a33210 | ||
|
|
965cc6af26 | ||
|
|
16e191f913 | ||
|
|
1d1e79c802 | ||
|
|
9ffe516f54 | ||
|
|
ac20d2fb56 | ||
|
|
2217152216 | ||
|
|
ee288280b3 | ||
|
|
cccdb2f289 | ||
|
|
1ddab27c0e | ||
|
|
451b6dc651 | ||
|
|
86b327ee41 | ||
|
|
6e305d4865 | ||
|
|
77af6c4ce1 | ||
|
|
fa49c72835 | ||
|
|
c150143d86 | ||
|
|
023d46b48c | ||
|
|
356adbcd8c | ||
|
|
33b47dd6e3 | ||
|
|
a548e9c94d | ||
|
|
cefda0dec1 | ||
|
|
8f545491a2 | ||
|
|
263bbfc66f | ||
|
|
a039741e5a | ||
|
|
2a0e8c109b | ||
|
|
9614bf266a | ||
|
|
aa63ebc7e5 | ||
|
|
fbba8f0b34 | ||
|
|
a27fa43081 | ||
|
|
7a5941de98 | ||
|
|
db3ca6a373 | ||
|
|
ce5f0d513f | ||
|
|
05b0f95b79 | ||
|
|
8b24b195a2 | ||
|
|
5fc8e8c428 | ||
|
|
25f917a7b4 | ||
|
|
55005f8129 | ||
|
|
f08cb0412d | ||
|
|
fc2e27bcf0 | ||
|
|
7aa4592669 | ||
|
|
d656ae2956 | ||
|
|
e4f00361f6 | ||
|
|
1b49d49df2 | ||
|
|
80f759c1ed | ||
|
|
da80f3352a | ||
|
|
ec3ba5be8e | ||
|
|
d04756ad70 | ||
|
|
0eba939cd6 |
11
.github/dependabot.yml
vendored
Normal file
11
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "gitsubmodule"
|
||||
directory: "/"
|
||||
target-branch: "master"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
commit-message:
|
||||
prefix: "Git submodule"
|
||||
labels:
|
||||
- "dependencies"
|
||||
8
.github/workflows/bridge.yml
vendored
8
.github/workflows/bridge.yml
vendored
@@ -6,6 +6,7 @@ on:
|
||||
workflow_call:
|
||||
|
||||
env:
|
||||
CARGO_EXPAND_VERSION: "1.0.95"
|
||||
FLUTTER_VERSION: "3.22.3"
|
||||
FLUTTER_RUST_BRIDGE_VERSION: "1.80.1"
|
||||
RUST_VERSION: "1.75" # https://github.com/rustdesk/rustdesk/discussions/7503
|
||||
@@ -19,7 +20,7 @@ jobs:
|
||||
job:
|
||||
- {
|
||||
target: x86_64-unknown-linux-gnu,
|
||||
os: ubuntu-20.04,
|
||||
os: ubuntu-22.04,
|
||||
extra-build-args: "",
|
||||
}
|
||||
steps:
|
||||
@@ -39,9 +40,9 @@ jobs:
|
||||
gcc \
|
||||
git \
|
||||
g++ \
|
||||
libclang-10-dev \
|
||||
libclang-11-dev \
|
||||
libgtk-3-dev \
|
||||
llvm-10-dev \
|
||||
llvm-11-dev \
|
||||
nasm \
|
||||
ninja-build \
|
||||
pkg-config \
|
||||
@@ -75,6 +76,7 @@ jobs:
|
||||
- name: Install flutter rust bridge deps
|
||||
shell: bash
|
||||
run: |
|
||||
cargo install cargo-expand --version ${{ env.CARGO_EXPAND_VERSION }} --locked
|
||||
cargo install flutter_rust_bridge_codegen --version ${{ env.FLUTTER_RUST_BRIDGE_VERSION }} --features "uuid" --locked
|
||||
pushd flutter && sed -i -e 's/extended_text: 14.0.0/extended_text: 13.0.0/g' pubspec.yaml && flutter pub get && popd
|
||||
|
||||
|
||||
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
@@ -4,9 +4,8 @@ env:
|
||||
# MIN_SUPPORTED_RUST_VERSION: "1.46.0"
|
||||
# CICD_INTERMEDIATES_DIR: "_cicd-intermediates"
|
||||
VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite"
|
||||
# vcpkg version: 2024.11.16
|
||||
# for multiarch gcc compatibility
|
||||
VCPKG_COMMIT_ID: "b2cb0da531c2f1f740045bfe7c4dac59f0b2b69c"
|
||||
VCPKG_COMMIT_ID: "6f29f12e82a8293156836ad81cc9bf5af41fe836"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -82,7 +81,7 @@ jobs:
|
||||
# - { target: x86_64-apple-darwin , os: macos-10.15 }
|
||||
# - { target: x86_64-pc-windows-gnu , os: windows-2022 }
|
||||
# - { target: x86_64-pc-windows-msvc , os: windows-2022 }
|
||||
- { target: x86_64-unknown-linux-gnu , os: ubuntu-20.04 }
|
||||
- { target: x86_64-unknown-linux-gnu , os: ubuntu-22.04 }
|
||||
# - { target: x86_64-unknown-linux-musl , os: ubuntu-20.04, use-cross: true }
|
||||
steps:
|
||||
- name: Export GitHub Actions cache environment variables
|
||||
@@ -112,6 +111,7 @@ jobs:
|
||||
g++ \
|
||||
libpam0g-dev \
|
||||
libasound2-dev \
|
||||
libunwind-dev \
|
||||
libgstreamer1.0-dev \
|
||||
libgstreamer-plugins-base1.0-dev \
|
||||
libgtk-3-dev \
|
||||
|
||||
125
.github/workflows/flutter-build.yml
vendored
125
.github/workflows/flutter-build.yml
vendored
@@ -31,17 +31,18 @@ env:
|
||||
FLUTTER_ELINUX_VERSION: "3.16.9"
|
||||
TAG_NAME: "${{ inputs.upload-tag }}"
|
||||
VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite"
|
||||
# vcpkg version: 2024.11.16
|
||||
VCPKG_COMMIT_ID: "b2cb0da531c2f1f740045bfe7c4dac59f0b2b69c"
|
||||
VERSION: "1.3.7"
|
||||
# vcpkg version: 2025.01.13
|
||||
# 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: "6f29f12e82a8293156836ad81cc9bf5af41fe836"
|
||||
VERSION: "1.4.0"
|
||||
NDK_VERSION: "r27c"
|
||||
#signing keys env variable checks
|
||||
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"
|
||||
MACOS_P12_BASE64: "${{ secrets.MACOS_P12_BASE64 }}"
|
||||
# To make a custom build with your own servers set the below secret values
|
||||
RS_PUB_KEY: "${{ secrets.RS_PUB_KEY }}"
|
||||
RENDEZVOUS_SERVER: "${{ secrets.RENDEZVOUS_SERVER }}"
|
||||
API_SERVER: "${{ secrets.API_SERVER }}"
|
||||
UPLOAD_ARTIFACT: "${{ inputs.upload-artifact }}"
|
||||
SIGN_BASE_URL: "${{ secrets.SIGN_BASE_URL }}"
|
||||
|
||||
@@ -163,14 +164,44 @@ jobs:
|
||||
|
||||
- name: Build rustdesk
|
||||
run: |
|
||||
# Windows: build RustDesk
|
||||
python3 .\build.py --portable --hwcodec --flutter --vram --skip-portable-pack
|
||||
mv ./flutter/build/windows/x64/runner/Release ./rustdesk
|
||||
|
||||
# Download usbmmidd_v2.zip and extract it to ./rustdesk
|
||||
Invoke-WebRequest -Uri https://github.com/rustdesk-org/rdev/releases/download/usbmmidd_v2/usbmmidd_v2.zip -OutFile usbmmidd_v2.zip
|
||||
Expand-Archive usbmmidd_v2.zip -DestinationPath .
|
||||
python3 .\build.py --portable --hwcodec --flutter --vram --skip-portable-pack
|
||||
Remove-Item -Path usbmmidd_v2\Win32 -Recurse
|
||||
Remove-Item -Path "usbmmidd_v2\deviceinstaller64.exe", "usbmmidd_v2\deviceinstaller.exe", "usbmmidd_v2\usbmmidd.bat"
|
||||
mv ./flutter/build/windows/x64/runner/Release ./rustdesk
|
||||
mv -Force .\usbmmidd_v2 ./rustdesk
|
||||
|
||||
# Download printer driver files and extract them to ./rustdesk
|
||||
try {
|
||||
Invoke-WebRequest -Uri https://github.com/rustdesk/hbb_common/releases/download/driver/rustdesk_printer_driver_v4.zip -OutFile rustdesk_printer_driver_v4.zip
|
||||
Invoke-WebRequest -Uri https://github.com/rustdesk/hbb_common/releases/download/driver/printer_driver_adapter.zip -OutFile printer_driver_adapter.zip
|
||||
Invoke-WebRequest -Uri https://github.com/rustdesk/hbb_common/releases/download/driver/sha256sums -OutFile sha256sums
|
||||
|
||||
# Check and move the files
|
||||
$checksum_driver = (Select-String -Path .\sha256sums -Pattern '^([a-fA-F0-9]{64}) \*rustdesk_printer_driver_v4\.zip$').Matches.Groups[1].Value
|
||||
$downloadsum_driver = Get-FileHash -Path rustdesk_printer_driver_v4.zip -Algorithm SHA256
|
||||
$checksum_dll = (Select-String -Path .\sha256sums -Pattern '^([a-fA-F0-9]{64}) \*printer_driver_adapter\.zip$').Matches.Groups[1].Value
|
||||
$downloadsum_dll = Get-FileHash -Path printer_driver_adapter.zip -Algorithm SHA256
|
||||
if ($checksum_driver -eq $downloadsum_driver.Hash -and $checksum_dll -eq $downloadsum_dll.Hash) {
|
||||
Write-Output "rustdesk_printer_driver_v4, checksums match, extract the file."
|
||||
Expand-Archive rustdesk_printer_driver_v4.zip -DestinationPath .
|
||||
mkdir ./rustdesk/drivers
|
||||
mv -Force .\rustdesk_printer_driver_v4 ./rustdesk/drivers/RustDeskPrinterDriver
|
||||
Expand-Archive printer_driver_adapter.zip -DestinationPath .
|
||||
mv -Force .\printer_driver_adapter.dll ./rustdesk
|
||||
} elseif ($checksum_driver -ne $downloadsum_driver.Hash) {
|
||||
Write-Output "rustdesk_printer_driver_v4, checksums do not match, ignore the file."
|
||||
} else {
|
||||
Write-Output "printer_driver_adapter.dll, checksums do not match, ignore the file."
|
||||
}
|
||||
} catch {
|
||||
Write-Host "Ingore the printer driver error."
|
||||
}
|
||||
|
||||
- name: find Runner.res
|
||||
# Windows: find Runner.res (compiled from ./flutter/windows/runner/Runner.rc), copy to ./Runner.res
|
||||
# Runner.rc does not contain actual version, but Runner.res does
|
||||
@@ -419,7 +450,7 @@ jobs:
|
||||
|
||||
- name: Build rustdesk
|
||||
run: |
|
||||
./build.py --flutter --hwcodec
|
||||
./build.py --flutter --hwcodec --unix-file-copy-paste
|
||||
|
||||
- name: create unsigned dmg
|
||||
if: env.UPLOAD_ARTIFACT == 'true'
|
||||
@@ -800,7 +831,7 @@ jobs:
|
||||
sed -i -e "s/osx_minimum_system_version = \"[0-9]*.[0-9]*\"/osx_minimum_system_version = \"${MIN_MACOS_VERSION}\"/" Cargo.toml
|
||||
sed -i -e "s/MACOSX_DEPLOYMENT_TARGET = [0-9]*.[0-9]*;/MACOSX_DEPLOYMENT_TARGET = ${MIN_MACOS_VERSION};/" flutter/macos/Runner.xcodeproj/project.pbxproj
|
||||
fi
|
||||
./build.py --flutter --hwcodec ${{ matrix.job.extra-build-args }}
|
||||
./build.py --flutter --hwcodec --unix-file-copy-paste ${{ matrix.job.extra-build-args }}
|
||||
|
||||
- name: create unsigned dmg
|
||||
if: env.UPLOAD_ARTIFACT == 'true'
|
||||
@@ -898,21 +929,21 @@ jobs:
|
||||
- {
|
||||
arch: aarch64,
|
||||
target: aarch64-linux-android,
|
||||
os: ubuntu-20.04,
|
||||
os: ubuntu-22.04,
|
||||
reltype: release,
|
||||
suffix: "",
|
||||
}
|
||||
- {
|
||||
arch: armv7,
|
||||
target: armv7-linux-androideabi,
|
||||
os: ubuntu-20.04,
|
||||
os: ubuntu-22.04,
|
||||
reltype: release,
|
||||
suffix: "",
|
||||
}
|
||||
- {
|
||||
arch: x86_64,
|
||||
target: x86_64-linux-android,
|
||||
os: ubuntu-20.04,
|
||||
os: ubuntu-22.04,
|
||||
reltype: release,
|
||||
suffix: "",
|
||||
}
|
||||
@@ -949,7 +980,8 @@ jobs:
|
||||
libayatana-appindicator3-dev \
|
||||
libasound2-dev \
|
||||
libc6-dev \
|
||||
libclang-10-dev \
|
||||
libclang-11-dev \
|
||||
libunwind-dev \
|
||||
libgstreamer1.0-dev \
|
||||
libgstreamer-plugins-base1.0-dev \
|
||||
libgtk-3-dev \
|
||||
@@ -961,7 +993,7 @@ jobs:
|
||||
libxcb-xfixes0-dev \
|
||||
libxdo-dev \
|
||||
libxfixes-dev \
|
||||
llvm-10-dev \
|
||||
llvm-11-dev \
|
||||
nasm \
|
||||
ninja-build \
|
||||
openjdk-17-jdk-headless \
|
||||
@@ -1043,16 +1075,6 @@ jobs:
|
||||
prefix-key: rustdesk-lib-cache-android # TODO: drop '-android' part after caches are invalidated
|
||||
key: ${{ matrix.job.target }}
|
||||
|
||||
- name: fix android for flutter 3.13
|
||||
if: ${{ env.ANDROID_FLUTTER_VERSION == '3.13.9' }}
|
||||
run: |
|
||||
cd flutter
|
||||
sed -i 's/uni_links_desktop/#uni_links_desktop/g' pubspec.yaml
|
||||
sed -i 's/extended_text: .*/extended_text: 11.1.0/' pubspec.yaml
|
||||
flutter pub get
|
||||
cd lib
|
||||
find . | grep dart | xargs sed -i 's/textScaler: TextScaler.linear(\(.*\)),/textScaleFactor: \1,/g'
|
||||
|
||||
- name: Build rustdesk lib
|
||||
env:
|
||||
ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }}
|
||||
@@ -1190,7 +1212,7 @@ jobs:
|
||||
needs: [build-rustdesk-android]
|
||||
name: build rustdesk android universal apk
|
||||
if: ${{ inputs.upload-artifact }}
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-22.04
|
||||
env:
|
||||
reltype: release
|
||||
x86_target: "" # can be ",android-x86"
|
||||
@@ -1228,7 +1250,8 @@ jobs:
|
||||
libayatana-appindicator3-dev \
|
||||
libasound2-dev \
|
||||
libc6-dev \
|
||||
libclang-10-dev \
|
||||
libclang-11-dev \
|
||||
libunwind-dev \
|
||||
libgstreamer1.0-dev \
|
||||
libgstreamer-plugins-base1.0-dev \
|
||||
libgtk-3-dev \
|
||||
@@ -1240,7 +1263,7 @@ jobs:
|
||||
libxcb-xfixes0-dev \
|
||||
libxdo-dev \
|
||||
libxfixes-dev \
|
||||
llvm-10-dev \
|
||||
llvm-11-dev \
|
||||
nasm \
|
||||
ninja-build \
|
||||
openjdk-17-jdk-headless \
|
||||
@@ -1295,16 +1318,6 @@ jobs:
|
||||
name: librustdesk.so.i686-linux-android
|
||||
path: ./flutter/android/app/src/main/jniLibs/x86
|
||||
|
||||
- name: fix android for flutter 3.13
|
||||
if: ${{ env.ANDROID_FLUTTER_VERSION == '3.13.9' }}
|
||||
run: |
|
||||
cd flutter
|
||||
sed -i 's/uni_links_desktop/#uni_links_desktop/g' pubspec.yaml
|
||||
sed -i 's/extended_text: .*/extended_text: 11.1.0/' pubspec.yaml
|
||||
flutter pub get
|
||||
cd lib
|
||||
find . | grep dart | xargs sed -i 's/textScaler: TextScaler.linear(\(.*\)),/textScaleFactor: \1,/g'
|
||||
|
||||
- name: Build rustdesk
|
||||
shell: bash
|
||||
env:
|
||||
@@ -1390,7 +1403,7 @@ jobs:
|
||||
arch: x86_64,
|
||||
target: x86_64-unknown-linux-gnu,
|
||||
distro: ubuntu18.04,
|
||||
on: ubuntu-20.04,
|
||||
on: ubuntu-22.04,
|
||||
deb_arch: amd64,
|
||||
vcpkg-triplet: x64-linux,
|
||||
}
|
||||
@@ -1398,7 +1411,7 @@ jobs:
|
||||
arch: aarch64,
|
||||
target: aarch64-unknown-linux-gnu,
|
||||
distro: ubuntu18.04,
|
||||
on: [self-hosted, Linux, ARM64],
|
||||
on: ubuntu-22.04-arm,
|
||||
deb_arch: arm64,
|
||||
vcpkg-triplet: arm64-linux,
|
||||
}
|
||||
@@ -1411,13 +1424,15 @@ jobs:
|
||||
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
|
||||
|
||||
- name: Maximize build space
|
||||
if: ${{ matrix.job.arch == 'x86_64' }}
|
||||
run: |
|
||||
sudo rm -rf /opt/ghc
|
||||
sudo rm -rf /usr/local/lib/android
|
||||
sudo rm -rf /usr/share/dotnet
|
||||
sudo apt-get update -y
|
||||
sudo apt-get install -y nasm qemu-user-static
|
||||
sudo apt-get install -y nasm
|
||||
if [[ "${{ matrix.job.arch }}" == "x86_64" ]]; then
|
||||
sudo apt-get install -y qemu-user-static
|
||||
fi
|
||||
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
@@ -1576,7 +1591,7 @@ jobs:
|
||||
export JOBS=""
|
||||
fi
|
||||
echo $JOBS
|
||||
cargo build --lib $JOBS --features hwcodec,flutter --release
|
||||
cargo build --lib $JOBS --features hwcodec,flutter,unix-file-copy-paste --release
|
||||
rm -rf target/release/deps target/release/build
|
||||
rm -rf ~/.cargo
|
||||
|
||||
@@ -1713,7 +1728,6 @@ jobs:
|
||||
|
||||
build-rustdesk-linux-sciter:
|
||||
if: ${{ inputs.upload-artifact }}
|
||||
needs: build-rustdesk-linux # not for dep, just make it run later for parallelism
|
||||
runs-on: ${{ matrix.job.on }}
|
||||
name: build-rustdesk-linux-sciter ${{ matrix.job.target }}
|
||||
strategy:
|
||||
@@ -1724,22 +1738,22 @@ jobs:
|
||||
- {
|
||||
arch: x86_64,
|
||||
target: x86_64-unknown-linux-gnu,
|
||||
on: ubuntu-20.04,
|
||||
on: ubuntu-22.04,
|
||||
distro: ubuntu18.04,
|
||||
deb_arch: amd64,
|
||||
sciter_arch: x64,
|
||||
vcpkg-triplet: x64-linux,
|
||||
extra_features: ",hwcodec",
|
||||
extra_features: ",hwcodec,unix-file-copy-paste",
|
||||
}
|
||||
- {
|
||||
arch: armv7,
|
||||
target: armv7-unknown-linux-gnueabihf,
|
||||
on: [self-hosted, Linux, ARM64],
|
||||
on: ubuntu-22.04-arm,
|
||||
distro: ubuntu18.04-rustdesk,
|
||||
deb_arch: armhf,
|
||||
sciter_arch: arm32,
|
||||
vcpkg-triplet: arm-linux,
|
||||
extra_features: "",
|
||||
extra_features: ",unix-file-copy-paste",
|
||||
}
|
||||
steps:
|
||||
- name: Export GitHub Actions cache environment variables
|
||||
@@ -1932,7 +1946,7 @@ jobs:
|
||||
build-appimage:
|
||||
name: Build appimage ${{ matrix.job.target }}
|
||||
needs: [build-rustdesk-linux]
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-22.04
|
||||
if: ${{ inputs.upload-artifact }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -1961,7 +1975,8 @@ jobs:
|
||||
run: |
|
||||
# install libarchive-tools for bsdtar command used in AppImageBuilder.yml
|
||||
sudo apt-get update -y
|
||||
sudo apt-get install -y libarchive-tools
|
||||
# https://github.com/AppImage/AppImageKit/wiki/FUSE
|
||||
sudo apt-get install -y libarchive-tools libfuse2
|
||||
# set-up appimage-builder
|
||||
pushd /tmp
|
||||
wget -O appimage-builder-x86_64.AppImage https://github.com/AppImageCrafters/appimage-builder/releases/download/v1.1.0/appimage-builder-1.1.0-x86_64.AppImage
|
||||
@@ -1995,14 +2010,14 @@ jobs:
|
||||
- {
|
||||
target: x86_64-unknown-linux-gnu,
|
||||
distro: ubuntu18.04,
|
||||
on: ubuntu-20.04,
|
||||
on: ubuntu-22.04,
|
||||
arch: x86_64,
|
||||
suffix: "",
|
||||
}
|
||||
- {
|
||||
target: x86_64-unknown-linux-gnu,
|
||||
distro: ubuntu18.04,
|
||||
on: ubuntu-20.04,
|
||||
on: ubuntu-22.04,
|
||||
arch: x86_64,
|
||||
suffix: "-sciter",
|
||||
}
|
||||
@@ -2010,7 +2025,7 @@ jobs:
|
||||
target: aarch64-unknown-linux-gnu,
|
||||
# try out newer flatpak since error of "error: Nothing matches org.freedesktop.Platform in remote flathub"
|
||||
distro: ubuntu22.04,
|
||||
on: [self-hosted, Linux, ARM64],
|
||||
on: ubuntu-22.04-arm,
|
||||
arch: aarch64,
|
||||
suffix: "",
|
||||
}
|
||||
@@ -2068,7 +2083,7 @@ jobs:
|
||||
build-rustdesk-web:
|
||||
if: False
|
||||
name: build-rustdesk-web
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-22.04
|
||||
strategy:
|
||||
fail-fast: false
|
||||
env:
|
||||
|
||||
12
.github/workflows/playground.yml
vendored
12
.github/workflows/playground.yml
vendored
@@ -16,9 +16,8 @@ env:
|
||||
FLUTTER_ELINUX_VERSION: "3.16.9"
|
||||
TAG_NAME: "nightly"
|
||||
VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite"
|
||||
# vcpkg version: 2024.11.16
|
||||
VCPKG_COMMIT_ID: "b2cb0da531c2f1f740045bfe7c4dac59f0b2b69c"
|
||||
VERSION: "1.3.7"
|
||||
VCPKG_COMMIT_ID: "6f29f12e82a8293156836ad81cc9bf5af41fe836"
|
||||
VERSION: "1.4.0"
|
||||
NDK_VERSION: "r26d"
|
||||
#signing keys env variable checks
|
||||
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"
|
||||
@@ -242,7 +241,7 @@ jobs:
|
||||
- {
|
||||
arch: aarch64,
|
||||
target: aarch64-linux-android,
|
||||
os: ubuntu-20.04,
|
||||
os: ubuntu-22.04,
|
||||
openssl-arch: android-arm64,
|
||||
ref: master, # latest
|
||||
}
|
||||
@@ -267,7 +266,8 @@ jobs:
|
||||
libayatana-appindicator3-dev\
|
||||
libasound2-dev \
|
||||
libc6-dev \
|
||||
libclang-10-dev \
|
||||
libclang-11-dev \
|
||||
libunwind-dev \
|
||||
libgstreamer1.0-dev \
|
||||
libgstreamer-plugins-base1.0-dev \
|
||||
libgtk-3-dev \
|
||||
@@ -280,7 +280,7 @@ jobs:
|
||||
libxcb-xfixes0-dev \
|
||||
libxdo-dev \
|
||||
libxfixes-dev \
|
||||
llvm-10-dev \
|
||||
llvm-11-dev \
|
||||
nasm \
|
||||
yasm \
|
||||
ninja-build \
|
||||
|
||||
1166
Cargo.lock
generated
1166
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
25
Cargo.toml
25
Cargo.toml
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rustdesk"
|
||||
version = "1.3.7"
|
||||
version = "1.4.0"
|
||||
authors = ["rustdesk <info@rustdesk.com>"]
|
||||
edition = "2021"
|
||||
build= "build.rs"
|
||||
@@ -46,7 +46,6 @@ screencapturekit = ["cpal/screencapturekit"]
|
||||
|
||||
[dependencies]
|
||||
async-trait = "0.1"
|
||||
whoami = "1.5.0"
|
||||
scrap = { path = "libs/scrap", features = ["wayland"] }
|
||||
hbb_common = { path = "libs/hbb_common" }
|
||||
serde_derive = "1.0"
|
||||
@@ -95,7 +94,7 @@ sys-locale = "0.3"
|
||||
enigo = { path = "libs/enigo", features = [ "with_serde" ] }
|
||||
clipboard = { path = "libs/clipboard" }
|
||||
ctrlc = "3.2"
|
||||
# arboard = { version = "3.4.0", features = ["wayland-data-control"] }
|
||||
# arboard = { version = "3.4", features = ["wayland-data-control"] }
|
||||
arboard = { git = "https://github.com/rustdesk-org/arboard", features = ["wayland-data-control"] }
|
||||
clipboard-master = { git = "https://github.com/rustdesk-org/clipboard-master" }
|
||||
|
||||
@@ -116,13 +115,22 @@ winapi = { version = "0.3", features = [
|
||||
"cguid",
|
||||
"cfgmgr32",
|
||||
"ioapiset",
|
||||
"winspool",
|
||||
] }
|
||||
windows = { version = "0.61", features = [
|
||||
"Win32",
|
||||
"Win32_System",
|
||||
"Win32_System_Diagnostics",
|
||||
"Win32_System_Threading",
|
||||
"Win32_System_Diagnostics_ToolHelp",
|
||||
] }
|
||||
winreg = "0.11"
|
||||
windows-service = "0.6"
|
||||
virtual_display = { path = "libs/virtual_display" }
|
||||
remote_printer = { path = "libs/remote_printer" }
|
||||
impersonate_system = { git = "https://github.com/rustdesk-org/impersonate-system" }
|
||||
shared_memory = "0.12"
|
||||
tauri-winrt-notification = "0.1.2"
|
||||
tauri-winrt-notification = "0.1"
|
||||
runas = "1.2"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
@@ -177,11 +185,11 @@ jni = "0.21"
|
||||
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"]
|
||||
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"]
|
||||
|
||||
[package.metadata.winres]
|
||||
LegalCopyright = "Copyright © 2024 Purslane Ltd. All rights reserved."
|
||||
LegalCopyright = "Copyright © 2025 Purslane Ltd. All rights reserved."
|
||||
ProductName = "RustDesk"
|
||||
FileDescription = "RustDesk Remote Desktop"
|
||||
OriginalFilename = "rustdesk.exe"
|
||||
@@ -197,6 +205,7 @@ os-version = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
hound = "3.5"
|
||||
docopt = "1.1"
|
||||
|
||||
[package.metadata.bundle]
|
||||
name = "RustDesk"
|
||||
@@ -212,7 +221,3 @@ panic = 'abort'
|
||||
strip = true
|
||||
#opt-level = 'z' # only have smaller size after strip
|
||||
rpath = true
|
||||
|
||||
[profile.dev]
|
||||
split-debuginfo = '...' # Platform-specific.
|
||||
#strip = "debuginfo"
|
||||
|
||||
13
README.md
13
README.md
@@ -1,14 +1,18 @@
|
||||
<p align="center">
|
||||
<img src="res/logo-header.svg" alt="RustDesk - Your remote desktop"><br>
|
||||
<a href="#public-servers">Servers</a> •
|
||||
<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="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>]<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>]<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>
|
||||
|
||||
> [!Caution]
|
||||
> **Misuse Disclaimer:** <br>
|
||||
> The developers of RustDesk do not condone or support any unethical or illegal use of this software. Misuse, such as unauthorized access, control or invasion of privacy, is strictly against our guidelines. The authors are not responsible for any misuse of the application.
|
||||
|
||||
|
||||
Chat with us: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk)
|
||||
|
||||
[](https://ko-fi.com/I2I04VU09)
|
||||
@@ -113,7 +117,7 @@ cd
|
||||
```sh
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
source $HOME/.cargo/env
|
||||
git clone https://github.com/rustdesk/rustdesk
|
||||
git clone --recurse-submodules https://github.com/rustdesk/rustdesk
|
||||
cd rustdesk
|
||||
mkdir -p target/debug
|
||||
wget https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so
|
||||
@@ -128,6 +132,7 @@ Begin by cloning the repository and building the Docker container:
|
||||
```sh
|
||||
git clone https://github.com/rustdesk/rustdesk
|
||||
cd rustdesk
|
||||
git submodule update --init --recursive
|
||||
docker build -t "rustdesk-builder" .
|
||||
```
|
||||
|
||||
@@ -163,7 +168,7 @@ Please ensure that you are running these commands from the root of the 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 desktop and mobile
|
||||
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: JavaScript for Flutter web client
|
||||
- **[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.3.7
|
||||
version: 1.4.0
|
||||
exec: usr/share/rustdesk/rustdesk
|
||||
exec_args: $@
|
||||
apt:
|
||||
|
||||
@@ -18,7 +18,7 @@ AppDir:
|
||||
id: rustdesk
|
||||
name: rustdesk
|
||||
icon: rustdesk
|
||||
version: 1.3.7
|
||||
version: 1.4.0
|
||||
exec: usr/share/rustdesk/rustdesk
|
||||
exec_args: $@
|
||||
apt:
|
||||
|
||||
125
docs/CODE_OF_CONDUCT-NO.md
Normal file
125
docs/CODE_OF_CONDUCT-NO.md
Normal file
@@ -0,0 +1,125 @@
|
||||
|
||||
# Atferdskodeks for bidragsyterpaktern
|
||||
|
||||
## Hva Vi Står For
|
||||
|
||||
Vi som medlemer, bidragere, og ledere står for å skape ett hat-fritt felleskap,
|
||||
uansett alder, kroppstørrelse, synlig eller usynlige funksjonsnedsettninger,
|
||||
etnesitet, kjønns karaktertrekk, kjønnsidentitet, kunnskapsnivå, utdanning,
|
||||
sosial-økonomisk status, nasjonalitet, utsende, rase, religion, eller seksual
|
||||
identitet og orientasjon.
|
||||
|
||||
Vi står for åpen, velkommende, mangfold, inklusiv og sunn oppførsel i vårt felleskap.
|
||||
|
||||
## Våre Standarer
|
||||
|
||||
Eksempler på oppførsel som hjelper ett positivt felleskap inkluderer:
|
||||
|
||||
* Vise empati og vennlighet mot andre mennesker
|
||||
* Være respektfull ovenfor ulike meninger, synspunkter og erfaringer
|
||||
* Gi og ta konstruktiv kritikk i beste mening
|
||||
* Akseptere ansvar og unskylde seg for de som er utsatt av våre feil,
|
||||
og lære av disse
|
||||
* Fokusere på det som er best ikke bare for individer, men for felleskapet
|
||||
|
||||
Eksempler på uakseptabel oppførsel inkluderer:
|
||||
|
||||
* Bruk av seksualisert språk eller bilder, og seksual oppmerksomhet.
|
||||
* Troll-ene, fornermende og nedsettende kommentarer, og personlig eller politiske angrep
|
||||
* Offentlig eller privat trakassering
|
||||
* Publisering av andres private informasjon, sånn som bosteds- og epost-addresser,
|
||||
uten deres godskjenning.
|
||||
* Andre rettningslinjer som kan bli sett på som upassende i en profesjonell setting.
|
||||
|
||||
## Håndhevingsansvar
|
||||
|
||||
Felleskapets ledere har ansvar for å klarifisere og håndheve våre standarer av
|
||||
akseptert oppførsel og vill ta rimelige og rettferdige handliger som respons på
|
||||
oppførsel de anser som upassende, truende, fornermende eller skadelig.
|
||||
|
||||
Felleskapets ledere har retten og ansvaret til å fjerne, redigere, eller avslå
|
||||
kommentarer, commits, kode, wiki endringer, issues, og andre birag som ikke
|
||||
samsvarer med disse etiske rettningslinjene, og vill kommunisere grunner for
|
||||
moderatorenes valg når passende.
|
||||
|
||||
## Omfang
|
||||
|
||||
Disse etiske rettningslinjene gjelder innenfor alle platformene til felleskapet, og
|
||||
de gjelder også når ett individ representerer felleskapet på offentlige medier.
|
||||
Eksempler på representasjon av vårt felleskap inkluderer bruke av offisielle e-mail
|
||||
addresser, publisering gjennom en offisiell sosial media bruker, eller oppførsel som en
|
||||
utpekt representant på digitale og fysiske arrangsjemanger.
|
||||
|
||||
## Håndheving
|
||||
|
||||
Hendelser av misbruk, trakasserende eller på noen måte uakseptert oppførsel kann
|
||||
bli raportert til felleskapets ledere med ansvar for håndheving på
|
||||
[info@rustdesk.com](mailto:info@rustdesk.com).
|
||||
All tilbakemelding vill bli sett gjennom og investigert rettferdig så fort som mulig.
|
||||
|
||||
Alle felleskapets ledere er obligert til å respektere privatlivet og sikkerhetet ovenfor
|
||||
den som raporterer en hendelse.
|
||||
|
||||
## Håndhevings Guide
|
||||
|
||||
Felleskapets ledere vill følge disse Rettningslinjene for sammfunspåvirkning med
|
||||
tanke på konsekvenser for en handling de anser i brudd med disse etiske rettningslinjene:
|
||||
|
||||
### 1. Korreksjon
|
||||
|
||||
**Sammfunspåvirkning**: Bruk av upassende språk eller annen oppførsel ansett som
|
||||
uprofesjonelt eller uvelkommen i dette felleskapet.
|
||||
|
||||
**Konsekvens**: En privat, skrevet advarsel fra en leder av felleskapet, som
|
||||
klarifiserer grunnlaget til hvorfor denne oppførselen var upassende. En offentlig
|
||||
unskyldning kan bli forespurt.
|
||||
|
||||
### 2. Advarsel
|
||||
|
||||
**Sammfunspåvirkning**: Ett brudd på en singulær hendelse eller en serie handlinger.
|
||||
|
||||
**Konsekvens**: En advarsel med konsekvenser for kontinuerende oppførsel. Ingen
|
||||
interaksjon med individene involvert, inkluderer uoppfordret interaksjoner med
|
||||
de som håndhever disse etiske rettningslinjene, er tillat for en spesifisert tidsperiode.
|
||||
Dette inkluderer å unngå interaksjoner i felleskapets platformer, samt eksterne
|
||||
kanaler, som f.eks sosial media. Brudd av disse vilkårene kan føre til midlertidig
|
||||
eller permanent bannlysning.
|
||||
|
||||
### 3. Midlertidig Bannlysning
|
||||
|
||||
**Sammfunspåvirkning**: Ett særiøst brudd på felleskapets standarer, inkludert
|
||||
vedvarende upassende oppførsel.
|
||||
|
||||
**Konsekvens**: En midlertidig bannlysning fra noen som helst interaksjon eller
|
||||
offentlig kommunikasjon med felleskapet for en spesifisert tidsperiode. Ingen
|
||||
interaksjon med individene involvert, inkluderer uoppfordret interaksjoner med
|
||||
de som håndhever disse etiske rettningslinjene, er tillat for denne perioden.
|
||||
Brudd på disse vilkårene kan føre til permanent bannlysning.
|
||||
|
||||
### 4. Permanent Bannlysning
|
||||
|
||||
**Sammfunspåvirkning**: Demonstasjon av mønster i brudd på felleskapets standarer,
|
||||
inklusivt vedvarende upassende oppførsel, trakassering av ett individ, eller
|
||||
aggresjon mot eller nedsettelse av grupper individer.
|
||||
|
||||
**Konsekvens**: En permanent bannlysning fra alle offentlige interaksjoner i
|
||||
felleskapet
|
||||
|
||||
## Attribusjon
|
||||
|
||||
Disse etiske rettningslinjene er adaptert fra [Contributor Covenant][homepage],
|
||||
versjon 2.0, tilgjengelig ved
|
||||
[https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0].
|
||||
|
||||
Sammfunspåvirknings guid inspirert av
|
||||
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
||||
|
||||
For svar til vanlige spørsmål angående disse etiske rettningslinjene, se FAQ på
|
||||
[https://www.contributor-covenant.org/faq][FAQ]. Oversettelse tilgjengelig
|
||||
ved [https://www.contributor-covenant.org/translations][translations].
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
[v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html
|
||||
[Mozilla CoC]: https://github.com/mozilla/diversity
|
||||
[FAQ]: https://www.contributor-covenant.org/faq
|
||||
[translations]: https://www.contributor-covenant.org/translations
|
||||
@@ -1,42 +1,42 @@
|
||||
# Beitr<EFBFBD>ge zu RustDesk
|
||||
# Beiträge zu RustDesk
|
||||
|
||||
RustDesk begr<EFBFBD><EFBFBD>t Beitr<EFBFBD>ge von jedem. Hier sind die Richtlinien, wenn Sie uns
|
||||
helfen m<EFBFBD>chten:
|
||||
RustDesk begrüßt Beiträge von jedem. Hier sind die Richtlinien, wenn Sie uns
|
||||
helfen möchten:
|
||||
|
||||
## Beitr<EFBFBD>ge
|
||||
## Beiträge
|
||||
|
||||
Beitr<EFBFBD>ge zu RustDesk oder seinen Abh<EFBFBD>ngigkeiten sollten in Form von Pull
|
||||
Beiträge zu RustDesk oder seinen Abhängigkeiten sollten in Form von Pull
|
||||
Requests auf GitHub erfolgen. Jeder Pull Request wird von einem Hauptakteur
|
||||
(jemand mit der Erlaubnis, Korrekturen einzubringen) gepr<EFBFBD>ft und entweder in den
|
||||
Hauptbaum eingef<EFBFBD>gt oder Feedback f<EFBFBD>r notwendige <EFBFBD>nderungen gegeben. Alle
|
||||
Beitr<EFBFBD>ge sollten diesem Format folgen, auch die von Hauptakteuren.
|
||||
(jemand mit der Erlaubnis, Korrekturen einzubringen) geprüft und entweder in den
|
||||
Hauptbaum eingefügt oder Feedback für notwendige Änderungen gegeben. Alle
|
||||
Beiträge sollten diesem Format folgen, auch die von Hauptakteuren.
|
||||
|
||||
Wenn Sie an einem Problem arbeiten m<EFBFBD>chten, melden Sie es bitte zuerst an, indem
|
||||
Sie auf GitHub erkl<EFBFBD>ren, dass Sie daran arbeiten m<EFBFBD>chten. Damit soll verhindert
|
||||
werden, dass Beitr<EFBFBD>ge zum gleichen Thema doppelt bearbeitet werden.
|
||||
Wenn Sie an einem Problem arbeiten möchten, melden Sie es bitte zuerst an, indem
|
||||
Sie auf GitHub erklären, dass Sie daran arbeiten möchten. Damit soll verhindert
|
||||
werden, dass Beiträge zum gleichen Thema doppelt bearbeitet werden.
|
||||
|
||||
## Checkliste f<EFBFBD>r Pull Requests
|
||||
## Checkliste für Pull Requests
|
||||
|
||||
- Verzweigen Sie sich vom Master-Branch und, falls n<EFBFBD>tig, wechseln Sie zum
|
||||
- Verzweigen Sie sich vom Master-Branch und, falls nötig, wechseln Sie zum
|
||||
aktuellen Master-Branch, bevor Sie Ihren Pull Request einreichen. Wenn das
|
||||
Zusammenf<EFBFBD>hren mit dem Master nicht reibungslos funktioniert, werden Sie
|
||||
m<EFBFBD>glicherweise aufgefordert, Ihre <EFBFBD>nderungen zu <EFBFBD>berarbeiten.
|
||||
Zusammenführen mit dem Master nicht reibungslos funktioniert, werden Sie
|
||||
möglicherweise aufgefordert, Ihre Änderungen zu überarbeiten.
|
||||
|
||||
- Commits sollten so klein wie m<EFBFBD>glich sein und gleichzeitig sicherstellen, dass
|
||||
jeder Commit unabh<EFBFBD>ngig voneinander korrekt ist (d. h., jeder Commit sollte
|
||||
sich <EFBFBD>bersetzen lassen und Tests bestehen).
|
||||
- Commits sollten so klein wie möglich sein und gleichzeitig sicherstellen, dass
|
||||
jeder Commit unabhängig voneinander korrekt ist (d. h., jeder Commit sollte
|
||||
sich übersetzen lassen und Tests bestehen).
|
||||
|
||||
- Commits sollten von einem "Herkunftszertifikat f<EFBFBD>r Entwickler"
|
||||
- Commits sollten von einem "Herkunftszertifikat für Entwickler"
|
||||
(https://developercertificate.org) begleitet werden, das besagt, dass Sie (und
|
||||
ggf. Ihr Arbeitgeber) mit den Bedingungen der [Projektlizenz](../LICENCE)
|
||||
einverstanden sind. In Git ist dies die Option `-s` f<EFBFBD>r `git commit`.
|
||||
einverstanden sind. In Git ist dies die Option `-s` für `git commit`.
|
||||
|
||||
- Wenn Ihr Patch nicht begutachtet wird oder Sie eine bestimmte Person zur
|
||||
Begutachtung ben<EFBFBD>tigen, k<EFBFBD>nnen Sie einem Gutachter mit @ antworten und um eine
|
||||
Begutachtung des Pull Requests oder einen Kommentar bitten. Sie k<EFBFBD>nnen auch
|
||||
Begutachtung benötigen, können Sie einem Gutachter mit @ antworten und um eine
|
||||
Begutachtung des Pull Requests oder einen Kommentar bitten. Sie können auch
|
||||
per [E-Mail](mailto:info@rustdesk.com) um eine Begutachtung bitten.
|
||||
|
||||
- F<EFBFBD>gen Sie Tests hinzu, die sich auf den behobenen Fehler oder die neue
|
||||
- Fügen Sie Tests hinzu, die sich auf den behobenen Fehler oder die neue
|
||||
Funktion beziehen.
|
||||
|
||||
Spezifische Git-Anweisungen finden Sie im [GitHub-Workflow](https://github.com/servo/servo/wiki/GitHub-workflow).
|
||||
@@ -47,4 +47,4 @@ https://github.com/rustdesk/rustdesk/blob/master/docs/CODE_OF_CONDUCT.md
|
||||
|
||||
## Kommunikation
|
||||
|
||||
RustDesk-Mitarbeiter arbeiten h<EFBFBD>ufig im [Discord](https://discord.gg/nDceKgxnkV).
|
||||
RustDesk-Mitarbeiter arbeiten häufig im [Discord](https://discord.gg/nDceKgxnkV).
|
||||
|
||||
40
docs/CONTRIBUTING-KR.md
Normal file
40
docs/CONTRIBUTING-KR.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# RustDesk 기여 가이드라인
|
||||
|
||||
RustDesk는 모든 사람의 기여를 환영합니다. 만약 RustDesk에 기여하고 싶다면 아래 가이드를 참고해주세요:
|
||||
|
||||
## 기여 방식
|
||||
|
||||
RustDesk 또는 종속성에 대한 기여는 GitHub Pull Request 형태로 이루어져야 합니다.
|
||||
모든 Pull Request는 코어 기여자가 검토하며, 메인 저장소에 반영되거나 필요한 수정 사항에 대한 피드백을 받습니다.
|
||||
모든 기여는 이 형식을 따라야 합니다.
|
||||
|
||||
특정 이슈에 작업하고 싶다면, 먼저 GitHub 이슈에 댓글을 달아 작업하겠다고 알려주세요.
|
||||
이는 동일한 작업에 대해 중복 기여가 발생하는 것을 방지하기 위함입니다.
|
||||
|
||||
## Pull Request Checklist
|
||||
|
||||
- master 브랜치에서 새 브랜치를 생성하고 작업하세요.<br/>
|
||||
필요한 경우 PR 제출 전에 최신 master 브랜치에 리베이스(rebase)하세요.<br/>
|
||||
충돌이 발생하면 기여자가 직접 해결해야 합니다.
|
||||
|
||||
- 커밋은 가능한 한 작고 독립적인 단위로 작성하세요.<br/>
|
||||
각 커밋은 독립적으로 빌드와 테스트를 통과해야 합니다.
|
||||
|
||||
- 커밋에는 반드시 Developer Certificate of Origin (http://developercertificate.org) 서명이 포함되어야 합니다.<br/>
|
||||
이는 기여자(및 소속된 고용주가 있을 경우) 가 [프로젝트 라이선스](../LICENCE) 에 동의함을 나타냅니다.<br/>
|
||||
Git에서는 `git commit` 명령어에 `-s` 옵션을 사용해 서명을 추가할 수 있습니다.
|
||||
|
||||
- PR이 검토되지 않거나 특정 리뷰어가 필요하면,
|
||||
<br/> PR이나 댓글에서 리뷰어를 태그하거나 [이메일](mailto:info@rustdesk.com)로 리뷰를 요청할 수 있습니다.
|
||||
|
||||
- 수정된 버그나 추가된 기능과 관련된 테스트 코드를 포함해주세요.
|
||||
|
||||
Git 사용에 대한 자세한 내용은 [GitHub workflow 101](https://github.com/servo/servo/wiki/GitHub-workflow)을 참조하세요.
|
||||
|
||||
## 행동 강령
|
||||
|
||||
https://github.com/rustdesk/rustdesk/blob/master/docs/CODE_OF_CONDUCT.md
|
||||
|
||||
## 커뮤니케이션
|
||||
|
||||
RustDesk 기여자들은 [Discord](https://discord.gg/nDceKgxnkV)에서 활동하고 있습니다.
|
||||
46
docs/CONTRIBUTING-NO.md
Normal file
46
docs/CONTRIBUTING-NO.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Bidrag til RustDesk
|
||||
|
||||
RustDesk er åpene for bidrag fra alle. Her er reglene for de som har lyst til å
|
||||
hjelpe oss:
|
||||
|
||||
## Bidrag
|
||||
|
||||
Bidrag til RustDesk eller deres avhengigheter burde være i form av GitHub pull requests.
|
||||
Hver pull request vill bli sett igjennom av en kjerne bidrager (noen med autoritet til
|
||||
å godkjenne endringene) og enten bli sendt til main treet eller respondert med
|
||||
tilbakemelding på endringer som er nødvendig. Alle bidrag burde følge dette formate
|
||||
også de fra kjerne bidragere.
|
||||
|
||||
Om du ønsker å jobbe på en issue må du huske å gjøre krav på den først. Dette
|
||||
kann gjøres ved å kommentere på den GitHub issue-en du ønsker å jobbe på.
|
||||
Dette er for å hindre duplikat innsats på samme problem.
|
||||
|
||||
## Pull Request Sjekkliste
|
||||
|
||||
- Lag en gren fra master grenen og, hvis det er nødvendig, rebase den til den nåværende
|
||||
master grenen før du sender inn din pull request. Hvis ikke dette gjøres på rent
|
||||
vis vill du bli spurt om å rebase dine endringer.
|
||||
|
||||
- Commits burde være så små som mulig, samtidig som de må være korrekt uavhenging av hverandre
|
||||
(hver commit burde kompilere og bestå tester).
|
||||
|
||||
- Commits burde være akkopaniert med en Developer Certificate of Origin
|
||||
(http://developercertificate.org), som indikerer att du (og din arbeidsgiver
|
||||
i det tilfellet) godkjenner å bli knyttet til vilkårene av [prosjekt lisensen](../LICENCE).
|
||||
Ved bruk av git er dette `-s` opsjonen til `git commit`.
|
||||
|
||||
- Hvis dine endringer ikke blir sett eller hvis du trenger en spesefik person til
|
||||
å se på dem kan du @-svare en med autoritet til å godkjenne dine endringer.
|
||||
Dette kann gjøres i en pull request, en kommentar eller via epost på [email](mailto:info@rustdesk.com).
|
||||
|
||||
- Legg til tester relevant til en fikset bug eller en ny tilgjengelighet.
|
||||
|
||||
For spesefike git instruksjoner, se [GitHub workflow 101](https://github.com/servo/servo/wiki/GitHub-workflow).
|
||||
|
||||
## Oppførsel
|
||||
|
||||
https://github.com/rustdesk/rustdesk/blob/master/docs/CODE_OF_CONDUCT.md
|
||||
|
||||
## Kommunikasjon
|
||||
|
||||
RustDesk bidragere burker [Discord](https://discord.gg/nDceKgxnkV).
|
||||
14
docs/DEVCONTAINER-NO.md
Normal file
14
docs/DEVCONTAINER-NO.md
Normal file
@@ -0,0 +1,14 @@
|
||||
|
||||
Etter start av devcontainer i docker konteineren, blir en linux binærfil i debug modus laget.
|
||||
|
||||
Nå tilbyr devcontainer linux og android builds i både debug og release modus.
|
||||
|
||||
Under er tabellen over kommandoer som kan kjøres fra rot-direktive for kreasjon av spesefike builds.
|
||||
|
||||
Kommando|Build Type|Modus
|
||||
-|-|-|
|
||||
`.devcontainer/build.sh --debug linux`|Linux|debug
|
||||
`.devcontainer/build.sh --release linux`|Linux|release
|
||||
`.devcontainer/build.sh --debug android`|android-arm64|debug
|
||||
`.devcontainer/build.sh --release android`|android-arm64|release
|
||||
|
||||
@@ -9,6 +9,11 @@
|
||||
<b>Wir brauchen Ihre Hilfe, um dieses README, die <a href="https://github.com/rustdesk/rustdesk/tree/master/src/lang">RustDesk-Benutzeroberfläche</a> und die <a href="https://github.com/rustdesk/doc.rustdesk.com">Dokumentation</a> in Ihre Muttersprache zu übersetzen.</b>
|
||||
</p>
|
||||
|
||||
> [!Vorsicht]
|
||||
> **Haftungsausschluss bei Missbrauch::** <br>
|
||||
> Die Entwickler von RustDesk billigen oder unterstützen keine unethische oder illegale Nutzung dieser Software. Missbrauch, wie unbefugter Zugriff, unbefugte Kontrolle oder Verletzung der Privatsphäre, verstößt strikt gegen unsere Richtlinien. Die Autoren sind nicht verantwortlich für jeglichen Missbrauch der Anwendung.
|
||||
|
||||
|
||||
Reden Sie mit uns auf: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk)
|
||||
|
||||
[](https://ko-fi.com/I2I04VU09)
|
||||
|
||||
@@ -9,12 +9,18 @@
|
||||
<b>Necesitamos tu ayuda para traducir este README a tu idioma</b>
|
||||
</p>
|
||||
|
||||
> [!Caution]
|
||||
> **Descargo de responsabilidad por mal uso:** <br>
|
||||
> Los desarrolladores de RustDesk no aprueban ni apoyan ningún uso no ético o ilegal de este software. El mal uso, como el acceso no autorizado, el control o la invasión de la privacidad, va estrictamente en contra de nuestras directrices. Los autores no se hacen responsables de ningún uso indebido de la aplicación.
|
||||
|
||||
Chatea con nosotros: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk)
|
||||
|
||||
[](https://ko-fi.com/I2I04VU09)
|
||||
|
||||
Otro software de escritorio remoto, escrito en Rust. Funciona de forma inmediata, sin necesidad de configuración. Tienes el control total de tus datos, sin preocupaciones sobre la seguridad. Puedes utilizar nuestro servidor de rendezvous/relay, [instalar el tuyo](https://rustdesk.com/server), o [escribir tu propio servidor rendezvous/relay](https://github.com/rustdesk/rustdesk-server-demo).
|
||||
|
||||

|
||||
|
||||
RustDesk agradece la contribución de todo el mundo. Lee [`docs/CONTRIBUTING.md`](CONTRIBUTING.md) para ayuda para empezar.
|
||||
|
||||
[**¿Cómo funciona rustdesk?**](https://github.com/rustdesk/rustdesk/wiki/How-does-RustDesk-work%3F)
|
||||
@@ -24,12 +30,15 @@ RustDesk agradece la contribución de todo el mundo. Lee [`docs/CONTRIBUTING.md`
|
||||
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png"
|
||||
alt="Get it on F-Droid"
|
||||
height="80">](https://f-droid.org/en/packages/com.carriez.flutter_hbb)
|
||||
[<img src="https://flathub.org/api/badge?svg&locale=en"
|
||||
alt="Get it on Flathub"
|
||||
height="80">](https://flathub.org/apps/com.rustdesk.RustDesk)
|
||||
|
||||
## Dependencias
|
||||
|
||||
La versión Desktop usa [Sciter](https://sciter.com/) o Flutter para el GUI, este tutorial es solo para Sciter.
|
||||
Las versiones de escritorio utilizan Flutter o Sciter (obsoleto) para GUI, este tutorial es sólo para Sciter, ya que es más fácil y más amigable para empezar. Echa un vistazo a nuestro [CI](https://github.com/rustdesk/rustdesk/blob/master/.github/workflows/flutter-build.yml) para la construcción de la versión Flutter.
|
||||
|
||||
Por favor descarga la librería dinámica de Sciter tu mismo.
|
||||
Por favor descarga la librería dinámica de Sciter tú mismo.
|
||||
|
||||
[Windows](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.win/x64/sciter.dll) |
|
||||
[Linux](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so) |
|
||||
@@ -51,13 +60,21 @@ Por favor descarga la librería dinámica de Sciter tu mismo.
|
||||
### Ubuntu 18 (Debian 10)
|
||||
|
||||
```sh
|
||||
sudo apt install -y 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
|
||||
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
|
||||
```
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
### 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
|
||||
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
|
||||
```
|
||||
|
||||
### Arch (Manjaro)
|
||||
@@ -96,12 +113,12 @@ cd
|
||||
```sh
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
source $HOME/.cargo/env
|
||||
git clone https://github.com/rustdesk/rustdesk
|
||||
git clone --recurse-submodules https://github.com/rustdesk/rustdesk
|
||||
cd rustdesk
|
||||
mkdir -p target/debug
|
||||
wget https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so
|
||||
mv libsciter-gtk.so target/debug
|
||||
cargo run
|
||||
VCPKG_ROOT=$HOME/vcpkg cargo run
|
||||
```
|
||||
|
||||
## Como compilar con Docker
|
||||
@@ -111,10 +128,11 @@ Empieza clonando el repositorio y compilando el contenedor de docker:
|
||||
```sh
|
||||
git clone https://github.com/rustdesk/rustdesk
|
||||
cd rustdesk
|
||||
git submodule update --init --recursive
|
||||
docker build -t "rustdesk-builder" .
|
||||
```
|
||||
|
||||
Entonces, cada vez que necesites compilar una modificación, ejecuta el siguiente comando:
|
||||
Entonces, cada vez que necesites compilar la aplicación, ejecuta el siguiente comando:
|
||||
|
||||
```sh
|
||||
docker run --rm -it -v $PWD:/home/user/rustdesk -v rustdesk-git-cache:/home/user/.cargo/git -v rustdesk-registry-cache:/home/user/.cargo/registry -e PUID="$(id -u)" -e PGID="$(id -g)" rustdesk-builder
|
||||
@@ -147,12 +165,16 @@ Por favor, asegurate de que estás ejecutando estos comandos desde la raíz del
|
||||
- **[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>
|
||||
> Los desarrolladores de RustDesk no aprueban ni apoyan ningún uso no ético o ilegal de este software. El uso indebido, como el acceso no autorizado, el control o la invasión de la privacidad, está estrictamente en contra de nuestras directrices. Los autores no son responsables de ningún uso indebido de la aplicación.
|
||||
|
||||
## Capturas de pantalla
|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
@@ -137,6 +137,10 @@ Veuillez vous assurer que vous exécutez ces commandes à partir de la racine du
|
||||
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)** : Communiquer avec [rustdesk-server](https://github.com/rustdesk/rustdesk-server), attendre une connexion distante directe (TCP hole punching) ou relayée.
|
||||
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)** : code spécifique à la plateforme
|
||||
|
||||
> [!Attention]
|
||||
> **Avertissement contre l'utilisation abusive:** <br>
|
||||
> Les développeurs de RustDesk ne cautionnent ni ne soutiennent aucune utilisation non éthique ou illégale de ce logiciel. Toute utilisation abusive, telle que l'accès non autorisé, le contrôle ou l'invasion de la vie privée, est strictement contraire à nos directives. Les auteurs ne sont pas responsables de toute utilisation abusive de l'application.
|
||||
|
||||
## Images
|
||||
|
||||

|
||||
|
||||
@@ -164,6 +164,10 @@ Assicurati di eseguire questi comandi dalla radice del repository RustDesk, altr
|
||||
- **[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>
|
||||
> Gli sviluppatori di RustDesk non approvano né supportano alcun uso non etico o illegale di questo software. L'uso improprio, come l'accesso non autorizzato, il controllo o l'invasione della privacy, è strettamente contro le nostre linee guida. Gli autori non sono responsabili per qualsiasi uso improprio dell'applicazione.
|
||||
|
||||
## Schermate
|
||||
|
||||

|
||||
|
||||
@@ -168,6 +168,10 @@ target/release/rustdesk
|
||||
- **[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>
|
||||
> RustDeskの開発者は、このソフトウェアの非倫理的または違法な使用を容認または支持しません。不正アクセス、不正な制御、またはプライバシーの侵害などの不正使用は、当社のガイドラインに厳密に違反します。開発者は、アプリケーションの不正使用に対して一切の責任を負いません。
|
||||
|
||||
## スクリーンショット
|
||||
|
||||

|
||||
|
||||
@@ -148,6 +148,10 @@ target/release/rustdesk
|
||||
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: 모바일용 Flutter 코드
|
||||
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Flutter 웹 클라이언트용 자바스크립트
|
||||
|
||||
> [!주의]
|
||||
> **오용에 대한 면책 조항:** <br>
|
||||
> RustDesk의 개발자들은 이 소프트웨어의 비윤리적이거나 불법적인 사용을 용인하거나 지원하지 않습니다. 무단 접근, 제어 또는 개인정보 침해와 같은 오용은 우리의 지침을 엄격히 위반하는 것입니다. 개발자들은 애플리케이션의 오용에 대해 책임을 지지 않습니다.
|
||||
|
||||
## 스냅샷
|
||||
|
||||

|
||||
|
||||
177
docs/README-NO.md
Normal file
177
docs/README-NO.md
Normal file
@@ -0,0 +1,177 @@
|
||||
<p align="center">
|
||||
<img src="res/logo-header.svg" alt="RustDesk - Your remote desktop"><br>
|
||||
<a href="#public-servers">Servere</a> •
|
||||
<a href="#raw-steps-to-build">Build</a> •
|
||||
<a href="#how-to-build-with-docker">Docker</a> •
|
||||
<a href="#file-structure">Struktur</a> •
|
||||
<a href="#snapshot">Snapshot</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><br>
|
||||
<b>Vi trenger din hjelp til å oversette denne README-en, <a href="https://github.com/rustdesk/rustdesk/tree/master/src/lang">RustDesk UI</a> og <a href="https://github.com/rustdesk/doc.rustdesk.com">RustDesk Doc</a> tid ditt morsmål</b>
|
||||
</p>
|
||||
|
||||
Snakk med oss: [Discord](https://discord.gg/nDceKgxnkV) | [Twitter](https://twitter.com/rustdesk) | [Reddit](https://www.reddit.com/r/rustdesk)
|
||||
|
||||
[](https://ko-fi.com/I2I04VU09)
|
||||
|
||||
Enda en annen fjernstyrt desktop programvare, skrevet i Rust. Virker rett ut av pakken, ingen konfigurasjon nødvendig. Du har full kontroll over din data, uten beskymring for sikkerhet. Du kan bruke vår rendezvous_mediator/relay server, [sett opp din egen](https://rustdesk.com/server), eller [skriv din egen rendezvous_mediator/relay server](https://github.com/rustdesk/rustdesk-server-demo).
|
||||
|
||||

|
||||
|
||||
RustDesk er velkommen for bidrag fra alle. Se [CONTRIBUTING.md](docs/CONTRIBUTING-NO.md) for hjelp med oppstart.
|
||||
|
||||
[**FAQ**](https://github.com/rustdesk/rustdesk/wiki/FAQ)
|
||||
|
||||
[**BINARY NEDLASTING**](https://github.com/rustdesk/rustdesk/releases)
|
||||
|
||||
[**NIGHTLY BUILD**](https://github.com/rustdesk/rustdesk/releases/tag/nightly)
|
||||
|
||||
[<img src="https://f-droid.org/badge/get-it-on.png"
|
||||
alt="Få det på F-Droid"
|
||||
height="80">](https://f-droid.org/en/packages/com.carriez.flutter_hbb)
|
||||
[<img src="https://flathub.org/api/badge?svg&locale=en"
|
||||
alt="Få det på Flathub"
|
||||
height="80">](https://flathub.org/apps/com.rustdesk.RustDesk)
|
||||
|
||||
## Avhengigheter
|
||||
|
||||
Desktop versjoner bruker Flutter eller Sciter (avviklet) for GUI, denne veiledningen er bare for Sciter, grunnet att det er letter og en mer venlig start. Skjekk ut vår [CI](https://github.com/rustdesk/rustdesk/blob/master/.github/workflows/flutter-build.yml) for bygging av Flutter versjonen.
|
||||
|
||||
Venligst last ned Sciters dynamiske bibliotek selv.
|
||||
|
||||
[Windows](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.win/x64/sciter.dll) |
|
||||
[Linux](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so) |
|
||||
[macOS](https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.osx/libsciter.dylib)
|
||||
|
||||
## Rå steg for bygging
|
||||
|
||||
- Klargjør ditt Rust development env og C++ build env
|
||||
|
||||
- Installer [vcpkg](https://github.com/microsoft/vcpkg), og koriger `VCPKG_ROOT` env vaiabelen
|
||||
|
||||
- Windows: vcpkg install libvpx:x64-windows-static libyuv:x64-windows-static opus:x64-windows-static aom:x64-windows-static
|
||||
- Linux/macOS: vcpkg install libvpx libyuv opus aom
|
||||
|
||||
- Kjør `cargo run`
|
||||
|
||||
## [Bygg](https://rustdesk.com/docs/en/dev/build/)
|
||||
|
||||
## Hvordan Bygge til Linux
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
### Arch (Manjaro)
|
||||
|
||||
```sh
|
||||
sudo pacman -Syu --needed unzip git cmake gcc curl wget yasm nasm zip make pkg-config clang gtk3 xdotool libxcb libxfixes alsa-lib pipewire
|
||||
```
|
||||
|
||||
### Installer vcpkg
|
||||
|
||||
```sh
|
||||
git clone https://github.com/microsoft/vcpkg
|
||||
cd vcpkg
|
||||
git checkout 2023.04.15
|
||||
cd ..
|
||||
vcpkg/bootstrap-vcpkg.sh
|
||||
export VCPKG_ROOT=$HOME/vcpkg
|
||||
vcpkg/vcpkg install libvpx libyuv opus aom
|
||||
```
|
||||
|
||||
### Fiks libvpx (For Fedora)
|
||||
|
||||
```sh
|
||||
cd vcpkg/buildtrees/libvpx/src
|
||||
cd *
|
||||
./configure
|
||||
sed -i 's/CFLAGS+=-I/CFLAGS+=-fPIC -I/g' Makefile
|
||||
sed -i 's/CXXFLAGS+=-I/CXXFLAGS+=-fPIC -I/g' Makefile
|
||||
make
|
||||
cp libvpx.a $HOME/vcpkg/installed/x64-linux/lib/
|
||||
cd
|
||||
```
|
||||
|
||||
### Bygg
|
||||
|
||||
```sh
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
source $HOME/.cargo/env
|
||||
git clone https://github.com/rustdesk/rustdesk
|
||||
cd rustdesk
|
||||
mkdir -p target/debug
|
||||
wget https://raw.githubusercontent.com/c-smile/sciter-sdk/master/bin.lnx/x64/libsciter-gtk.so
|
||||
mv libsciter-gtk.so target/debug
|
||||
VCPKG_ROOT=$HOME/vcpkg cargo run
|
||||
```
|
||||
|
||||
## Hvordan bygge med Docker
|
||||
|
||||
Start med å klone repositoret og bygg Docker konteineren:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/rustdesk/rustdesk
|
||||
cd rustdesk
|
||||
docker build -t "rustdesk-builder" .
|
||||
```
|
||||
|
||||
Deretter, hver gang du trenger å bygge applikasjonen, kjør følgene kommando:
|
||||
|
||||
```sh
|
||||
docker run --rm -it -v $PWD:/home/user/rustdesk -v rustdesk-git-cache:/home/user/.cargo/git -v rustdesk-registry-cache:/home/user/.cargo/registry -e PUID="$(id -u)" -e PGID="$(id -g)" rustdesk-builder
|
||||
```
|
||||
|
||||
Det kan ta lengere tid før avhengighetene blir bufret første gang du bygger, senere bygg er raskere. Hvis du trenger å spesifisere forkjellige argumenter til bygge kommandoen, kan du gjøre det på slutten av kommandoen ved `<OPTIONAL-ARGS>` feltet. For eksempel, hvis du ville bygge en optimalisert release versjon, ville du kjørt kommandoen over fulgt `--release`. Den kjørbare filen vill være tilgjengelig i mål direktive på ditt system, og kan bli kjørt med:
|
||||
|
||||
```sh
|
||||
target/debug/rustdesk
|
||||
```
|
||||
|
||||
Eller, hvis du kjører ett release program:
|
||||
|
||||
```sh
|
||||
target/release/rustdesk
|
||||
```
|
||||
|
||||
Venligst pass på att du kjører disse kommandoene fra roten av RustDesk repositoret, eller kan det hende att applikasjon ikke finner de riktige ressursene. Pass også på att andre cargo subkommandoer som for eksempel `install` eller `run` ikke støttes med denne metoden da de vill installere eller kjøre programmet i konteineren istedet for verten.
|
||||
|
||||
## Fil Struktur
|
||||
|
||||
- **[libs/hbb_common](https://github.com/rustdesk/rustdesk/tree/master/libs/hbb_common)**: video kodek, configurasjon, tcp/udp innpakning, protobuf, fs funksjon for fil overføring, og noen andre verktøy funksjoner
|
||||
- **[libs/scrap](https://github.com/rustdesk/rustdesk/tree/master/libs/scrap)**: skjermfangst
|
||||
- **[libs/enigo](https://github.com/rustdesk/rustdesk/tree/master/libs/enigo)**: platform spesefik keyboard/mus kontroll
|
||||
- **[libs/clipboard](https://github.com/rustdesk/rustdesk/tree/master/libs/clipboard)**: fil kopi og innliming implementasjon for Windows, Linux, macOS.
|
||||
- **[src/ui](https://github.com/rustdesk/rustdesk/tree/master/src/ui)**: foreldret Sciter UI (avviklet)
|
||||
- **[src/server](https://github.com/rustdesk/rustdesk/tree/master/src/server)**: lyd/utklippstavle/input/video tjenester, og internett tilkobling
|
||||
- **[src/client.rs](https://github.com/rustdesk/rustdesk/tree/master/src/client.rs)**: start en peer tilkobling
|
||||
- **[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
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
@@ -137,6 +137,10 @@ Por favor verifique que está executando estes comandos da raiz do repositório
|
||||
- **[src/rendezvous_mediator.rs](https://github.com/rustdesk/rustdesk/tree/master/src/rendezvous_mediator.rs)**: Comunicação com [rustdesk-server](https://github.com/rustdesk/rustdesk-server), aguardar pela conexão remota direta (TCP hole punching) ou conexão indireta (relayed)
|
||||
- **[src/platform](https://github.com/rustdesk/rustdesk/tree/master/src/platform)**: código específico a cada plataforma
|
||||
|
||||
> [!Cuidadob]
|
||||
> **Aviso de uso indevido:** <br>
|
||||
> Os desenvolvedores do RustDesk não aprovam nem apoiam qualquer uso antiético ou ilegal deste software. O uso indevido, como acesso não autorizado, controle ou invasão de privacidade, é estritamente contra nossas diretrizes. Os autores não são responsáveis por qualquer uso indevido da aplicação.
|
||||
|
||||
## Screenshots
|
||||
|
||||

|
||||
|
||||
@@ -148,6 +148,10 @@ 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)**: специфичный для платформы код
|
||||
|
||||
> [!Осторожно]
|
||||
> **Отказ от ответственности за неправомерное использование:** <br>
|
||||
> Разработчики RustDesk не одобряют и не поддерживают какое-либо неэтичное или незаконное использование данного программного обеспечения. Неправомерное использование, такое как несанкционированный доступ, контроль или вторжение в частную жизнь, строго противоречит нашим правилам. Авторы не несут ответственности за любое неправомерное использование приложения.
|
||||
|
||||
## Скриншоты
|
||||
|
||||

|
||||
|
||||
@@ -166,6 +166,10 @@ Lütfen bu komutları RustDesk deposunun kökünden çalıştırdığınızdan e
|
||||
- **[flutter](https://github.com/rustdesk/rustdesk/tree/master/flutter)**: mobil için Flutter kodu
|
||||
- **[flutter/web/js](https://github.com/rustdesk/rustdesk/tree/master/flutter/web/js)**: Flutter web istemcisi için JavaScript
|
||||
|
||||
> [!Dikkat]
|
||||
> **Yanlış Kullanım Uyarısı:** <br>
|
||||
> RustDesk geliştiricileri, bu yazılımın etik olmayan veya yasa dışı kullanımını onaylamaz veya desteklemez. Yetkisiz erişim, kontrol veya gizlilik ihlali gibi kötüye kullanımlar kesinlikle yönergelerimize aykırıdır. Yazarlar, uygulamanın herhangi bir yanlış kullanımından sorumlu değildir.
|
||||
|
||||
## Ekran Görüntüleri
|
||||
|
||||

|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
[<a href="../README.md">English</a>] | [<a href="README-UA.md">Українська</a>] | [<a href="README-CS.md">česky</a>] | [<a href="README-HU.md">Magyar</a>] | [<a href="README-ES.md">Español</a>] | [<a href="README-FA.md">فارسی</a>] | [<a href="README-FR.md">Français</a>] | [<a href="README-DE.md">Deutsch</a>] | [<a href="README-PL.md">Polski</a>] | [<a href="README-ID.md">Indonesian</a>] | [<a href="README-FI.md">Suomi</a>] | [<a href="README-ML.md">മലയാളം</a>] | [<a href="README-JP.md">日本語</a>] | [<a href="README-NL.md">Nederlands</a>] | [<a href="README-IT.md">Italiano</a>] | [<a href="README-RU.md">Русский</a>] | [<a href="README-PTBR.md">Português (Brasil)</a>] | [<a href="README-EO.md">Esperanto</a>] | [<a href="README-KR.md">한국어</a>] | [<a href="README-AR.md">العربي</a>] | [<a href="README-VN.md">Tiếng Việt</a>] | [<a href="README-GR.md">Ελληνικά</a>]<br>
|
||||
</p>
|
||||
|
||||
> [!警告]
|
||||
> **免责声明:** <br>
|
||||
> RustDesk 的开发人员不纵容或支持任何不道德或非法的软件使用行为。滥用行为,例如未经授权的访问、控制或侵犯隐私,严格违反我们的准则。作者对应用程序的任何滥用行为概不负责。
|
||||
|
||||
与我们交流: [知乎](https://www.zhihu.com/people/rustdesk) | [Discord](https://discord.gg/nDceKgxnkV) | [Reddit](https://www.reddit.com/r/rustdesk)
|
||||
|
||||
[](https://ko-fi.com/I2I04VU09)
|
||||
|
||||
6
docs/SECURITY-KR.md
Normal file
6
docs/SECURITY-KR.md
Normal file
@@ -0,0 +1,6 @@
|
||||
보안 정책
|
||||
취약점 보고
|
||||
|
||||
저희는 프로젝트의 보안을 매우 중요하게 생각합니다. 모든 사용자가 발견한 취약점을 저희에게 보고할 것을 권장합니다. RustDesk 프로젝트에서 보안 취약점이 발견되면 info@rustdesk.com 로 이메일을 보내 책임감 있게 보고해 주시기 바랍니다.
|
||||
|
||||
현재로서는 버그 현상금 프로그램이 없습니다. 저희는 큰 문제를 해결하기 위해 노력하는 소규모 팀입니다. 전체 커뮤니티를 위한 안전한 애플리케이션을 계속 구축할 수 있도록 취약점을 책임감 있게 신고해 주시기 바랍니다.
|
||||
9
docs/SECURITY-NO.md
Normal file
9
docs/SECURITY-NO.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Sikkerhets Rettningslinjer
|
||||
|
||||
## Reportering av en Sårbarhet
|
||||
|
||||
Vi verdsetter pris på sikkerhet for prosjektet høyt. Og oppmunterer alle brukere til å rapportere sårbarheter de oppdager til oss.
|
||||
Om du finner en sikkerhets sårbarhet i RustDesk prosjektet, venligst raportere det ansvarsfult ved å sende oss en email til info@rustdesk.com.
|
||||
|
||||
På dette tidspunktet har vi ingen bug dusør program. Vi er ett lite team som prøver å løse ett stort problem. Vi trenger att du raporterer alle sårbarhetene
|
||||
annsvarfult så vi kan fortsettte å bygge ett en sikker applikasjon for hele felleskapet.
|
||||
@@ -1,28 +0,0 @@
|
||||
[package]
|
||||
name = "custom_plugin"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
name = "custom_plugin"
|
||||
path = "src/lib.rs"
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
|
||||
[features]
|
||||
default = ["flutter"]
|
||||
flutter = []
|
||||
|
||||
[dependencies]
|
||||
lazy_static = "1.4.0"
|
||||
rustdesk = { path = "../../", version = "1.2.0", features = ["flutter"]}
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
panic = 'abort'
|
||||
strip = true
|
||||
#opt-level = 'z' # only have smaller size after strip
|
||||
rpath = true
|
||||
@@ -1,30 +0,0 @@
|
||||
use librustdesk::api::RustDeskApiTable;
|
||||
/// This file demonstrates how to write a custom plugin for RustDesk.
|
||||
use std::ffi::{c_char, c_int, CString};
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
pub static ref PLUGIN_NAME: CString = CString::new("A Template Rust Plugin").unwrap();
|
||||
pub static ref PLUGIN_ID: CString = CString::new("TemplatePlugin").unwrap();
|
||||
// Do your own logic based on the API provided by RustDesk.
|
||||
pub static ref API: RustDeskApiTable = RustDeskApiTable::default();
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
fn plugin_name() -> *const c_char {
|
||||
return PLUGIN_NAME.as_ptr();
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
fn plugin_id() -> *const c_char {
|
||||
return PLUGIN_ID.as_ptr();
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
fn plugin_init() -> c_int {
|
||||
return 0 as _;
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
fn plugin_dispose() -> c_int {
|
||||
return 0 as _;
|
||||
}
|
||||
90
examples/ipc.rs
Normal file
90
examples/ipc.rs
Normal file
@@ -0,0 +1,90 @@
|
||||
use docopt::Docopt;
|
||||
use hbb_common::{
|
||||
env_logger::{init_from_env, Env, DEFAULT_FILTER_ENV},
|
||||
log, tokio,
|
||||
};
|
||||
use librustdesk::{ipc::Data, *};
|
||||
|
||||
const USAGE: &'static str = "
|
||||
IPC test program.
|
||||
|
||||
Usage:
|
||||
ipc (-s | --server | -c | --client) [-p <str> | --postfix=<str>]
|
||||
ipc (-h | --help)
|
||||
|
||||
Options:
|
||||
-h --help Show this screen.
|
||||
-s --server Run as IPC server.
|
||||
-c --client Run as IPC client.
|
||||
-p --postfix=<str> IPC path postfix [default: ].
|
||||
";
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct Args {
|
||||
flag_server: bool,
|
||||
flag_client: bool,
|
||||
flag_postfix: String,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
init_from_env(Env::default().filter_or(DEFAULT_FILTER_ENV, "info"));
|
||||
|
||||
let args: Args = Docopt::new(USAGE)
|
||||
.and_then(|d| d.deserialize())
|
||||
.unwrap_or_else(|e| e.exit());
|
||||
|
||||
if args.flag_server {
|
||||
if args.flag_postfix.is_empty() {
|
||||
log::info!("Starting IPC server...");
|
||||
} else {
|
||||
log::info!(
|
||||
"Starting IPC server with postfix: '{}'...",
|
||||
args.flag_postfix
|
||||
);
|
||||
}
|
||||
ipc_server(&args.flag_postfix).await;
|
||||
} else if args.flag_client {
|
||||
if args.flag_postfix.is_empty() {
|
||||
log::info!("Starting IPC client...");
|
||||
} else {
|
||||
log::info!(
|
||||
"Starting IPC client with postfix: '{}'...",
|
||||
args.flag_postfix
|
||||
);
|
||||
}
|
||||
ipc_client(&args.flag_postfix).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn ipc_server(postfix: &str) {
|
||||
let postfix = postfix.to_string();
|
||||
let postfix2 = postfix.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Err(err) = crate::ipc::start(&postfix) {
|
||||
log::error!("Failed to start ipc: {}", err);
|
||||
std::process::exit(-1);
|
||||
}
|
||||
});
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
ipc_client(&postfix2).await;
|
||||
}
|
||||
|
||||
async fn ipc_client(postfix: &str) {
|
||||
loop {
|
||||
match crate::ipc::connect(1000, postfix).await {
|
||||
Ok(mut conn) => match conn.send(&Data::Empty).await {
|
||||
Ok(_) => {
|
||||
log::info!("send message to ipc server success");
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to send message to ipc server: {}", e);
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
log::error!("Failed to connect to ipc server: {}", e);
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(6)).await;
|
||||
}
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
An open-source remote desktop application, the open source TeamViewer alternative.
|
||||
An open-source remote desktop application, the TeamViewer alternative
|
||||
|
||||
@@ -19,6 +19,7 @@ import android.view.accessibility.AccessibilityEvent
|
||||
import android.view.ViewGroup.LayoutParams
|
||||
import android.view.accessibility.AccessibilityNodeInfo
|
||||
import android.view.KeyEvent as KeyEventAndroid
|
||||
import android.view.ViewConfiguration
|
||||
import android.graphics.Rect
|
||||
import android.media.AudioManager
|
||||
import android.accessibilityservice.AccessibilityServiceInfo
|
||||
@@ -34,10 +35,15 @@ import hbb.MessageOuterClass.KeyEvent
|
||||
import hbb.MessageOuterClass.KeyboardMode
|
||||
import hbb.KeyEventConverter
|
||||
|
||||
const val LIFT_DOWN = 9
|
||||
const val LIFT_MOVE = 8
|
||||
const val LIFT_UP = 10
|
||||
// const val BUTTON_UP = 2
|
||||
// const val BUTTON_BACK = 0x08
|
||||
|
||||
const val LEFT_DOWN = 9
|
||||
const val LEFT_MOVE = 8
|
||||
const val LEFT_UP = 10
|
||||
const val RIGHT_UP = 18
|
||||
// (BUTTON_BACK << 3) | BUTTON_UP
|
||||
const val BACK_UP = 66
|
||||
const val WHEEL_BUTTON_DOWN = 33
|
||||
const val WHEEL_BUTTON_UP = 34
|
||||
const val WHEEL_DOWN = 523331
|
||||
@@ -64,12 +70,15 @@ class InputService : AccessibilityService() {
|
||||
|
||||
private val logTag = "input service"
|
||||
private var leftIsDown = false
|
||||
private var touchPath = Path()
|
||||
private val touchPath = Path()
|
||||
private var stroke: GestureDescription.StrokeDescription? = null
|
||||
private var lastTouchGestureStartTime = 0L
|
||||
private var mouseX = 0
|
||||
private var mouseY = 0
|
||||
private var timer = Timer()
|
||||
private var recentActionTask: TimerTask? = null
|
||||
// 100(tap timeout) + 400(long press timeout)
|
||||
private val longPressDuration = ViewConfiguration.getTapTimeout().toLong() + ViewConfiguration.getLongPressTimeout().toLong()
|
||||
|
||||
private val wheelActionsQueue = LinkedList<GestureDescription>()
|
||||
private var isWheelActionsPolling = false
|
||||
@@ -77,6 +86,9 @@ class InputService : AccessibilityService() {
|
||||
|
||||
private var fakeEditTextForTextStateCalculation: EditText? = null
|
||||
|
||||
private var lastX = 0
|
||||
private var lastY = 0
|
||||
|
||||
private val volumeController: VolumeController by lazy { VolumeController(applicationContext.getSystemService(AUDIO_SERVICE) as AudioManager) }
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.N)
|
||||
@@ -84,7 +96,7 @@ class InputService : AccessibilityService() {
|
||||
val x = max(0, _x)
|
||||
val y = max(0, _y)
|
||||
|
||||
if (mask == 0 || mask == LIFT_MOVE) {
|
||||
if (mask == 0 || mask == LEFT_MOVE) {
|
||||
val oldX = mouseX
|
||||
val oldY = mouseY
|
||||
mouseX = x * SCREEN_INFO.scale
|
||||
@@ -98,31 +110,30 @@ class InputService : AccessibilityService() {
|
||||
}
|
||||
}
|
||||
|
||||
// left button down ,was up
|
||||
if (mask == LIFT_DOWN) {
|
||||
// left button down, was up
|
||||
if (mask == LEFT_DOWN) {
|
||||
isWaitingLongPress = true
|
||||
timer.schedule(object : TimerTask() {
|
||||
override fun run() {
|
||||
if (isWaitingLongPress) {
|
||||
isWaitingLongPress = false
|
||||
leftIsDown = false
|
||||
endGesture(mouseX, mouseY)
|
||||
continueGesture(mouseX, mouseY)
|
||||
}
|
||||
}
|
||||
}, LONG_TAP_DELAY * 4)
|
||||
}, longPressDuration)
|
||||
|
||||
leftIsDown = true
|
||||
startGesture(mouseX, mouseY)
|
||||
return
|
||||
}
|
||||
|
||||
// left down ,was down
|
||||
// left down, was down
|
||||
if (leftIsDown) {
|
||||
continueGesture(mouseX, mouseY)
|
||||
}
|
||||
|
||||
// left up ,was down
|
||||
if (mask == LIFT_UP) {
|
||||
// left up, was down
|
||||
if (mask == LEFT_UP) {
|
||||
if (leftIsDown) {
|
||||
leftIsDown = false
|
||||
isWaitingLongPress = false
|
||||
@@ -132,6 +143,11 @@ class InputService : AccessibilityService() {
|
||||
}
|
||||
|
||||
if (mask == RIGHT_UP) {
|
||||
longPress(mouseX, mouseY)
|
||||
return
|
||||
}
|
||||
|
||||
if (mask == BACK_UP) {
|
||||
performGlobalAction(GLOBAL_ACTION_BACK)
|
||||
return
|
||||
}
|
||||
@@ -241,36 +257,78 @@ class InputService : AccessibilityService() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun startGesture(x: Int, y: Int) {
|
||||
touchPath = Path()
|
||||
touchPath.moveTo(x.toFloat(), y.toFloat())
|
||||
lastTouchGestureStartTime = System.currentTimeMillis()
|
||||
@RequiresApi(Build.VERSION_CODES.N)
|
||||
private fun performClick(x: Int, y: Int, duration: Long) {
|
||||
val path = Path()
|
||||
path.moveTo(x.toFloat(), y.toFloat())
|
||||
try {
|
||||
val longPressStroke = GestureDescription.StrokeDescription(path, 0, duration)
|
||||
val builder = GestureDescription.Builder()
|
||||
builder.addStroke(longPressStroke)
|
||||
Log.d(logTag, "performClick x:$x y:$y time:$duration")
|
||||
dispatchGesture(builder.build(), null, null)
|
||||
} catch (e: Exception) {
|
||||
Log.e(logTag, "performClick, error:$e")
|
||||
}
|
||||
}
|
||||
|
||||
private fun continueGesture(x: Int, y: Int) {
|
||||
@RequiresApi(Build.VERSION_CODES.N)
|
||||
private fun longPress(x: Int, y: Int) {
|
||||
performClick(x, y, longPressDuration)
|
||||
}
|
||||
|
||||
private fun startGesture(x: Int, y: Int) {
|
||||
touchPath.reset()
|
||||
touchPath.moveTo(x.toFloat(), y.toFloat())
|
||||
lastTouchGestureStartTime = System.currentTimeMillis()
|
||||
lastX = x
|
||||
lastY = y
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.N)
|
||||
private fun doDispatchGesture(x: Int, y: Int, willContinue: Boolean) {
|
||||
touchPath.lineTo(x.toFloat(), y.toFloat())
|
||||
var duration = System.currentTimeMillis() - lastTouchGestureStartTime
|
||||
if (duration <= 0) {
|
||||
duration = 1
|
||||
}
|
||||
try {
|
||||
if (stroke == null) {
|
||||
stroke = GestureDescription.StrokeDescription(
|
||||
touchPath,
|
||||
0,
|
||||
duration,
|
||||
willContinue
|
||||
)
|
||||
} else {
|
||||
stroke = stroke?.continueStroke(touchPath, 0, duration, willContinue)
|
||||
}
|
||||
stroke?.let {
|
||||
val builder = GestureDescription.Builder()
|
||||
builder.addStroke(it)
|
||||
Log.d(logTag, "doDispatchGesture x:$x y:$y time:$duration")
|
||||
dispatchGesture(builder.build(), null, null)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(logTag, "doDispatchGesture, willContinue:$willContinue, error:$e")
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.N)
|
||||
private fun continueGesture(x: Int, y: Int) {
|
||||
doDispatchGesture(x, y, true)
|
||||
touchPath.reset()
|
||||
touchPath.moveTo(x.toFloat(), y.toFloat())
|
||||
lastTouchGestureStartTime = System.currentTimeMillis()
|
||||
lastX = x
|
||||
lastY = y
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.N)
|
||||
private fun endGesture(x: Int, y: Int) {
|
||||
try {
|
||||
touchPath.lineTo(x.toFloat(), y.toFloat())
|
||||
var duration = System.currentTimeMillis() - lastTouchGestureStartTime
|
||||
if (duration <= 0) {
|
||||
duration = 1
|
||||
}
|
||||
val stroke = GestureDescription.StrokeDescription(
|
||||
touchPath,
|
||||
0,
|
||||
duration
|
||||
)
|
||||
val builder = GestureDescription.Builder()
|
||||
builder.addStroke(stroke)
|
||||
Log.d(logTag, "end gesture x:$x y:$y time:$duration")
|
||||
dispatchGesture(builder.build(), null, null)
|
||||
} catch (e: Exception) {
|
||||
Log.e(logTag, "endGesture error:$e")
|
||||
}
|
||||
doDispatchGesture(x, y, false)
|
||||
touchPath.reset()
|
||||
stroke = null
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.N)
|
||||
|
||||
@@ -65,8 +65,8 @@ class MainService : Service() {
|
||||
@Keep
|
||||
@RequiresApi(Build.VERSION_CODES.N)
|
||||
fun rustPointerInput(kind: Int, mask: Int, x: Int, y: Int) {
|
||||
// turn on screen with LIFT_DOWN when screen off
|
||||
if (!powerManager.isInteractive && (kind == 0 || mask == LIFT_DOWN)) {
|
||||
// turn on screen with LEFT_DOWN when screen off
|
||||
if (!powerManager.isInteractive && (kind == 0 || mask == LEFT_DOWN)) {
|
||||
if (wakeLock.isHeld) {
|
||||
Log.d(logTag, "Turn on Screen, WakeLock release")
|
||||
wakeLock.release()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
org.gradle.jvmargs=-Xmx1536M
|
||||
org.gradle.jvmargs=-Xmx1024M
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
org.gradle.daemon=false
|
||||
|
||||
BIN
flutter/assets/device_group.ttf
Normal file
BIN
flutter/assets/device_group.ttf
Normal file
Binary file not shown.
BIN
flutter/assets/more.ttf
Normal file
BIN
flutter/assets/more.ttf
Normal file
Binary file not shown.
@@ -150,6 +150,10 @@ prebuild)
|
||||
|
||||
# Flutter used to compile Flutter<->Rust bridge files
|
||||
|
||||
CARGO_EXPAND_VERSION="$(yq -r \
|
||||
.env.CARGO_EXPAND_VERSION \
|
||||
.github/workflows/bridge.yml)"
|
||||
|
||||
FLUTTER_BRIDGE_VERSION="$(yq -r \
|
||||
.env.FLUTTER_VERSION \
|
||||
.github/workflows/bridge.yml)"
|
||||
@@ -239,6 +243,7 @@ prebuild)
|
||||
|
||||
cargo install \
|
||||
cargo-expand \
|
||||
--version "${CARGO_EXPAND_VERSION}" \
|
||||
--locked
|
||||
cargo install flutter_rust_bridge_codegen \
|
||||
--version "${FLUTTER_RUST_BRIDGE_VERSION}" \
|
||||
|
||||
@@ -4,4 +4,5 @@
|
||||
# no obfuscate, because no easy to check errors
|
||||
cd $(dirname $(dirname $(which flutter)))
|
||||
git apply ~/rustdesk/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff
|
||||
cd -
|
||||
flutter build ipa --release
|
||||
|
||||
@@ -1,4 +1,2 @@
|
||||
#!/usr/bin/env bash
|
||||
cd $(dirname $(dirname $(which flutter)))
|
||||
git apply ~/rustdesk/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff
|
||||
cargo build --features flutter,hwcodec --release --target aarch64-apple-ios --lib
|
||||
|
||||
@@ -29,8 +29,10 @@ import '../consts.dart';
|
||||
import 'common/widgets/overlay.dart';
|
||||
import 'mobile/pages/file_manager_page.dart';
|
||||
import 'mobile/pages/remote_page.dart';
|
||||
import 'mobile/pages/view_camera_page.dart';
|
||||
import 'desktop/pages/remote_page.dart' as desktop_remote;
|
||||
import 'desktop/pages/file_manager_page.dart' as desktop_file_manager;
|
||||
import 'desktop/pages/view_camera_page.dart' as desktop_view_camera;
|
||||
import 'package:flutter_hbb/desktop/widgets/remote_toolbar.dart';
|
||||
import 'models/model.dart';
|
||||
import 'models/platform_model.dart';
|
||||
@@ -96,6 +98,7 @@ enum DesktopType {
|
||||
main,
|
||||
remote,
|
||||
fileTransfer,
|
||||
viewCamera,
|
||||
cm,
|
||||
portForward,
|
||||
}
|
||||
@@ -103,6 +106,10 @@ enum DesktopType {
|
||||
class IconFont {
|
||||
static const _family1 = 'Tabbar';
|
||||
static const _family2 = 'PeerSearchbar';
|
||||
static const _family3 = 'AddressBook';
|
||||
static const _family4 = 'DeviceGroup';
|
||||
static const _family5 = 'More';
|
||||
|
||||
IconFont._();
|
||||
|
||||
static const IconData max = IconData(0xe606, fontFamily: _family1);
|
||||
@@ -113,8 +120,12 @@ class IconFont {
|
||||
static const IconData menu = IconData(0xe628, fontFamily: _family1);
|
||||
static const IconData search = IconData(0xe6a4, fontFamily: _family2);
|
||||
static const IconData roundClose = IconData(0xe6ed, fontFamily: _family2);
|
||||
static const IconData addressBook =
|
||||
IconData(0xe602, fontFamily: "AddressBook");
|
||||
static const IconData addressBook = IconData(0xe602, fontFamily: _family3);
|
||||
static const IconData deviceGroupOutline =
|
||||
IconData(0xe623, fontFamily: _family4);
|
||||
static const IconData deviceGroupFill =
|
||||
IconData(0xe748, fontFamily: _family4);
|
||||
static const IconData more = IconData(0xe609, fontFamily: _family5);
|
||||
}
|
||||
|
||||
class ColorThemeExtension extends ThemeExtension<ColorThemeExtension> {
|
||||
@@ -817,7 +828,11 @@ class OverlayDialogManager {
|
||||
|
||||
close([res]) {
|
||||
_dialogs.remove(dialogTag);
|
||||
dialog.complete(res);
|
||||
try {
|
||||
dialog.complete(res);
|
||||
} catch (e) {
|
||||
debugPrint("Dialog complete catch error: $e");
|
||||
}
|
||||
BackButtonInterceptor.removeByName(dialogTag);
|
||||
}
|
||||
|
||||
@@ -1137,15 +1152,23 @@ Widget createDialogContent(String text) {
|
||||
|
||||
void msgBox(SessionID sessionId, String type, String title, String text,
|
||||
String link, OverlayDialogManager dialogManager,
|
||||
{bool? hasCancel, ReconnectHandle? reconnect, int? reconnectTimeout}) {
|
||||
{bool? hasCancel,
|
||||
ReconnectHandle? reconnect,
|
||||
int? reconnectTimeout,
|
||||
VoidCallback? onSubmit,
|
||||
int? submitTimeout}) {
|
||||
dialogManager.dismissAll();
|
||||
List<Widget> buttons = [];
|
||||
bool hasOk = false;
|
||||
submit() {
|
||||
dialogManager.dismissAll();
|
||||
// https://github.com/rustdesk/rustdesk/blob/5e9a31340b899822090a3731769ae79c6bf5f3e5/src/ui/common.tis#L263
|
||||
if (!type.contains("custom") && desktopType != DesktopType.portForward) {
|
||||
closeConnection();
|
||||
if (onSubmit != null) {
|
||||
onSubmit.call();
|
||||
} else {
|
||||
// https://github.com/rustdesk/rustdesk/blob/5e9a31340b899822090a3731769ae79c6bf5f3e5/src/ui/common.tis#L263
|
||||
if (!type.contains("custom") && desktopType != DesktopType.portForward) {
|
||||
closeConnection();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1161,7 +1184,18 @@ void msgBox(SessionID sessionId, String type, String title, String text,
|
||||
|
||||
if (type != "connecting" && type != "success" && !type.contains("nook")) {
|
||||
hasOk = true;
|
||||
buttons.insert(0, dialogButton('OK', onPressed: submit));
|
||||
late final Widget btn;
|
||||
if (submitTimeout != null) {
|
||||
btn = _CountDownButton(
|
||||
text: 'OK',
|
||||
second: submitTimeout,
|
||||
onPressed: submit,
|
||||
submitOnTimeout: true,
|
||||
);
|
||||
} else {
|
||||
btn = dialogButton('OK', onPressed: submit);
|
||||
}
|
||||
buttons.insert(0, btn);
|
||||
}
|
||||
hasCancel ??= !type.contains("error") &&
|
||||
!type.contains("nocancel") &&
|
||||
@@ -1182,7 +1216,8 @@ void msgBox(SessionID sessionId, String type, String title, String text,
|
||||
reconnectTimeout != null) {
|
||||
// `enabled` is used to disable the dialog button once the button is clicked.
|
||||
final enabled = true.obs;
|
||||
final button = Obx(() => _ReconnectCountDownButton(
|
||||
final button = Obx(() => _CountDownButton(
|
||||
text: 'Reconnect',
|
||||
second: reconnectTimeout,
|
||||
onPressed: enabled.isTrue
|
||||
? () {
|
||||
@@ -1741,7 +1776,8 @@ Future<void> saveWindowPosition(WindowType type, {int? windowId}) async {
|
||||
await bind.setLocalFlutterOption(
|
||||
k: windowFramePrefix + type.name, v: pos.toString());
|
||||
|
||||
if (type == WindowType.RemoteDesktop && windowId != null) {
|
||||
if ((type == WindowType.RemoteDesktop || type == WindowType.ViewCamera) &&
|
||||
windowId != null) {
|
||||
await _saveSessionWindowPosition(
|
||||
type, windowId, isMaximized, isFullscreen, pos);
|
||||
}
|
||||
@@ -1892,7 +1928,9 @@ Future<bool> restoreWindowPosition(WindowType type,
|
||||
String? pos;
|
||||
// No need to check mainGetLocalBoolOptionSync(kOptionOpenNewConnInTabs)
|
||||
// Though "open in tabs" is true and the new window restore peer position, it's ok.
|
||||
if (type == WindowType.RemoteDesktop && windowId != null && peerId != null) {
|
||||
if ((type == WindowType.RemoteDesktop || type == WindowType.ViewCamera) &&
|
||||
windowId != null &&
|
||||
peerId != null) {
|
||||
final peerPos = bind.mainGetPeerFlutterOptionSync(
|
||||
id: peerId, k: windowFramePrefix + type.name);
|
||||
if (peerPos.isNotEmpty) {
|
||||
@@ -1907,7 +1945,7 @@ Future<bool> restoreWindowPosition(WindowType type,
|
||||
debugPrint("no window position saved, ignoring position restoration");
|
||||
return false;
|
||||
}
|
||||
if (type == WindowType.RemoteDesktop) {
|
||||
if (type == WindowType.RemoteDesktop || type == WindowType.ViewCamera) {
|
||||
if (!isRemotePeerPos && windowId != null) {
|
||||
if (lpos.offsetWidth != null) {
|
||||
lpos.offsetWidth = lpos.offsetWidth! + windowId * kNewWindowOffset;
|
||||
@@ -2076,6 +2114,7 @@ StreamSubscription? listenUniLinks({handleByFlutter = true}) {
|
||||
enum UriLinkType {
|
||||
remoteDesktop,
|
||||
fileTransfer,
|
||||
viewCamera,
|
||||
portForward,
|
||||
rdp,
|
||||
}
|
||||
@@ -2127,6 +2166,11 @@ bool handleUriLink({List<String>? cmdArgs, Uri? uri, String? uriString}) {
|
||||
id = args[i + 1];
|
||||
i++;
|
||||
break;
|
||||
case '--view-camera':
|
||||
type = UriLinkType.viewCamera;
|
||||
id = args[i + 1];
|
||||
i++;
|
||||
break;
|
||||
case '--port-forward':
|
||||
type = UriLinkType.portForward;
|
||||
id = args[i + 1];
|
||||
@@ -2168,6 +2212,12 @@ bool handleUriLink({List<String>? cmdArgs, Uri? uri, String? uriString}) {
|
||||
password: password, forceRelay: forceRelay);
|
||||
});
|
||||
break;
|
||||
case UriLinkType.viewCamera:
|
||||
Future.delayed(Duration.zero, () {
|
||||
rustDeskWinManager.newViewCamera(id!,
|
||||
password: password, forceRelay: forceRelay);
|
||||
});
|
||||
break;
|
||||
case UriLinkType.portForward:
|
||||
Future.delayed(Duration.zero, () {
|
||||
rustDeskWinManager.newPortForward(id!, false,
|
||||
@@ -2191,7 +2241,14 @@ bool handleUriLink({List<String>? cmdArgs, Uri? uri, String? uriString}) {
|
||||
List<String>? urlLinkToCmdArgs(Uri uri) {
|
||||
String? command;
|
||||
String? id;
|
||||
final options = ["connect", "play", "file-transfer", "port-forward", "rdp"];
|
||||
final options = [
|
||||
"connect",
|
||||
"play",
|
||||
"file-transfer",
|
||||
"view-camera",
|
||||
"port-forward",
|
||||
"rdp"
|
||||
];
|
||||
if (uri.authority.isEmpty &&
|
||||
uri.path.split('').every((char) => char == '/')) {
|
||||
return [];
|
||||
@@ -2229,6 +2286,8 @@ List<String>? urlLinkToCmdArgs(Uri uri) {
|
||||
connect(Get.context!, id);
|
||||
} else if (optionIndex == 2) {
|
||||
connect(Get.context!, id, isFileTransfer: true);
|
||||
} else if (optionIndex == 3) {
|
||||
connect(Get.context!, id, isViewCamera: true);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -2281,6 +2340,7 @@ List<String>? urlLinkToCmdArgs(Uri uri) {
|
||||
|
||||
connectMainDesktop(String id,
|
||||
{required bool isFileTransfer,
|
||||
required bool isViewCamera,
|
||||
required bool isTcpTunneling,
|
||||
required bool isRDP,
|
||||
bool? forceRelay,
|
||||
@@ -2293,6 +2353,12 @@ connectMainDesktop(String id,
|
||||
isSharedPassword: isSharedPassword,
|
||||
connToken: connToken,
|
||||
forceRelay: forceRelay);
|
||||
} else if (isViewCamera) {
|
||||
await rustDeskWinManager.newViewCamera(id,
|
||||
password: password,
|
||||
isSharedPassword: isSharedPassword,
|
||||
connToken: connToken,
|
||||
forceRelay: forceRelay);
|
||||
} else if (isTcpTunneling || isRDP) {
|
||||
await rustDeskWinManager.newPortForward(id, isRDP,
|
||||
password: password,
|
||||
@@ -2309,10 +2375,12 @@ connectMainDesktop(String id,
|
||||
|
||||
/// Connect to a peer with [id].
|
||||
/// If [isFileTransfer], starts a session only for file transfer.
|
||||
/// If [isViewCamera], starts a session only for view camera.
|
||||
/// If [isTcpTunneling], starts a session only for tcp tunneling.
|
||||
/// If [isRDP], starts a session only for rdp.
|
||||
connect(BuildContext context, String id,
|
||||
{bool isFileTransfer = false,
|
||||
bool isViewCamera = false,
|
||||
bool isTcpTunneling = false,
|
||||
bool isRDP = false,
|
||||
bool forceRelay = false,
|
||||
@@ -2344,6 +2412,7 @@ connect(BuildContext context, String id,
|
||||
await connectMainDesktop(
|
||||
id,
|
||||
isFileTransfer: isFileTransfer,
|
||||
isViewCamera: isViewCamera,
|
||||
isTcpTunneling: isTcpTunneling,
|
||||
isRDP: isRDP,
|
||||
password: password,
|
||||
@@ -2354,6 +2423,7 @@ connect(BuildContext context, String id,
|
||||
await rustDeskWinManager.call(WindowType.Main, kWindowConnect, {
|
||||
'id': id,
|
||||
'isFileTransfer': isFileTransfer,
|
||||
'isViewCamera': isViewCamera,
|
||||
'isTcpTunneling': isTcpTunneling,
|
||||
'isRDP': isRDP,
|
||||
'password': password,
|
||||
@@ -2391,6 +2461,31 @@ connect(BuildContext context, String id,
|
||||
),
|
||||
);
|
||||
}
|
||||
} else if (isViewCamera) {
|
||||
if (isWeb) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (BuildContext context) =>
|
||||
desktop_view_camera.ViewCameraPage(
|
||||
key: ValueKey(id),
|
||||
id: id,
|
||||
toolbarState: ToolbarState(),
|
||||
password: password,
|
||||
forceRelay: forceRelay,
|
||||
isSharedPassword: isSharedPassword,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (BuildContext context) => ViewCameraPage(
|
||||
id: id, password: password, isSharedPassword: isSharedPassword),
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (isWeb) {
|
||||
Navigator.push(
|
||||
@@ -2566,6 +2661,8 @@ bool get kUseCompatibleUiMode =>
|
||||
isWindows &&
|
||||
const [WindowsTarget.w7].contains(windowsBuildNumber.windowsVersion);
|
||||
|
||||
bool get isWin10 => windowsBuildNumber.windowsVersion == WindowsTarget.w10;
|
||||
|
||||
class ServerConfig {
|
||||
late String idServer;
|
||||
late String relayServer;
|
||||
@@ -2675,6 +2772,8 @@ String getWindowName({WindowType? overrideType}) {
|
||||
return name;
|
||||
case WindowType.FileTransfer:
|
||||
return "File Transfer - $name";
|
||||
case WindowType.ViewCamera:
|
||||
return "View Camera - $name";
|
||||
case WindowType.PortForward:
|
||||
return "Port Forward - $name";
|
||||
case WindowType.RemoteDesktop:
|
||||
@@ -3040,6 +3139,7 @@ openMonitorInNewTabOrWindow(int i, String peerId, PeerInfo pi,
|
||||
'peer_id': peerId,
|
||||
'display': i,
|
||||
'display_count': pi.displays.length,
|
||||
'window_type': (kWindowType ?? WindowType.RemoteDesktop).index,
|
||||
};
|
||||
if (screenRect != null) {
|
||||
args['screen_rect'] = {
|
||||
@@ -3054,12 +3154,12 @@ openMonitorInNewTabOrWindow(int i, String peerId, PeerInfo pi,
|
||||
}
|
||||
|
||||
setNewConnectWindowFrame(int windowId, String peerId, int preSessionCount,
|
||||
int? display, Rect? screenRect) async {
|
||||
WindowType windowType, int? display, Rect? screenRect) async {
|
||||
if (screenRect == null) {
|
||||
// Do not restore window position to new connection if there's a pre-session.
|
||||
// https://github.com/rustdesk/rustdesk/discussions/8825
|
||||
if (preSessionCount == 0) {
|
||||
await restoreWindowPosition(WindowType.RemoteDesktop,
|
||||
await restoreWindowPosition(windowType,
|
||||
windowId: windowId, display: display, peerId: peerId);
|
||||
}
|
||||
} else {
|
||||
@@ -3103,21 +3203,24 @@ parseParamScreenRect(Map<String, dynamic> params) {
|
||||
|
||||
get isInputSourceFlutter => stateGlobal.getInputSource() == "Input source 2";
|
||||
|
||||
class _ReconnectCountDownButton extends StatefulWidget {
|
||||
_ReconnectCountDownButton({
|
||||
class _CountDownButton extends StatefulWidget {
|
||||
_CountDownButton({
|
||||
Key? key,
|
||||
required this.text,
|
||||
required this.second,
|
||||
required this.onPressed,
|
||||
this.submitOnTimeout = false,
|
||||
}) : super(key: key);
|
||||
final String text;
|
||||
final VoidCallback? onPressed;
|
||||
final int second;
|
||||
final bool submitOnTimeout;
|
||||
|
||||
@override
|
||||
State<_ReconnectCountDownButton> createState() =>
|
||||
_ReconnectCountDownButtonState();
|
||||
State<_CountDownButton> createState() => _CountDownButtonState();
|
||||
}
|
||||
|
||||
class _ReconnectCountDownButtonState extends State<_ReconnectCountDownButton> {
|
||||
class _CountDownButtonState extends State<_CountDownButton> {
|
||||
late int _countdownSeconds = widget.second;
|
||||
|
||||
Timer? _timer;
|
||||
@@ -3138,6 +3241,9 @@ class _ReconnectCountDownButtonState extends State<_ReconnectCountDownButton> {
|
||||
_timer = Timer.periodic(Duration(seconds: 1), (timer) {
|
||||
if (_countdownSeconds <= 0) {
|
||||
timer.cancel();
|
||||
if (widget.submitOnTimeout) {
|
||||
widget.onPressed?.call();
|
||||
}
|
||||
} else {
|
||||
setState(() {
|
||||
_countdownSeconds--;
|
||||
@@ -3149,7 +3255,7 @@ class _ReconnectCountDownButtonState extends State<_ReconnectCountDownButton> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return dialogButton(
|
||||
'${translate('Reconnect')} (${_countdownSeconds}s)',
|
||||
'${translate(widget.text)} (${_countdownSeconds}s)',
|
||||
onPressed: widget.onPressed,
|
||||
isOutline: true,
|
||||
);
|
||||
@@ -3638,3 +3744,100 @@ extension WorkaroundFreezeLinuxMint on Widget {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Don't use `extension` here, the border looks weird if using `extension` in my test.
|
||||
Widget workaroundWindowBorder(BuildContext context, Widget child) {
|
||||
if (!isWin10) {
|
||||
return child;
|
||||
}
|
||||
|
||||
final isLight = Theme.of(context).brightness == Brightness.light;
|
||||
final borderColor = isLight ? Colors.black87 : Colors.grey;
|
||||
final width = isLight ? 0.5 : 0.1;
|
||||
|
||||
getBorderWidget(Widget child) {
|
||||
return Obx(() =>
|
||||
(stateGlobal.isMaximized.isTrue || stateGlobal.fullscreen.isTrue)
|
||||
? Offstage()
|
||||
: child);
|
||||
}
|
||||
|
||||
final List<Widget> borders = [
|
||||
getBorderWidget(Container(
|
||||
color: borderColor,
|
||||
height: width + 0.1,
|
||||
))
|
||||
];
|
||||
if (kWindowType == WindowType.Main && !isLight) {
|
||||
borders.addAll([
|
||||
getBorderWidget(Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: Container(
|
||||
color: borderColor,
|
||||
width: width,
|
||||
),
|
||||
)),
|
||||
getBorderWidget(Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: Container(
|
||||
color: borderColor,
|
||||
width: width,
|
||||
),
|
||||
)),
|
||||
getBorderWidget(Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Container(
|
||||
color: borderColor,
|
||||
height: width,
|
||||
),
|
||||
)),
|
||||
]);
|
||||
}
|
||||
return Stack(
|
||||
children: [
|
||||
child,
|
||||
...borders,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void updateTextAndPreserveSelection(
|
||||
TextEditingController controller, String text) {
|
||||
// Only care about select all for now.
|
||||
final isSelected = controller.selection.isValid &&
|
||||
controller.selection.end > controller.selection.start;
|
||||
|
||||
// Set text will make the selection invalid.
|
||||
controller.text = text;
|
||||
|
||||
if (isSelected) {
|
||||
controller.selection = TextSelection(
|
||||
baseOffset: 0, extentOffset: controller.value.text.length);
|
||||
}
|
||||
}
|
||||
|
||||
List<String> getPrinterNames() {
|
||||
final printerNamesJson = bind.mainGetPrinterNames();
|
||||
if (printerNamesJson.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
final List<dynamic> printerNamesList = jsonDecode(printerNamesJson);
|
||||
final appPrinterName = '$appName Printer';
|
||||
return printerNamesList
|
||||
.map((e) => e.toString())
|
||||
.where((name) => name != appPrinterName)
|
||||
.toList();
|
||||
} catch (e) {
|
||||
debugPrint('failed to parse printer names, err: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
String _appName = '';
|
||||
String get appName {
|
||||
if (_appName.isEmpty) {
|
||||
_appName = bind.mainGetAppNameSync();
|
||||
}
|
||||
return _appName;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ class UserPayload {
|
||||
String name = '';
|
||||
String email = '';
|
||||
String note = '';
|
||||
String? verifier;
|
||||
UserStatus status;
|
||||
bool isAdmin = false;
|
||||
|
||||
@@ -34,6 +35,7 @@ class UserPayload {
|
||||
: name = json['name'] ?? '',
|
||||
email = json['email'] ?? '',
|
||||
note = json['note'] ?? '',
|
||||
verifier = json['verifier'],
|
||||
status = json['status'] == 0
|
||||
? UserStatus.kDisabled
|
||||
: json['status'] == -1
|
||||
@@ -67,6 +69,7 @@ class PeerPayload {
|
||||
int? status;
|
||||
String user = '';
|
||||
String user_name = '';
|
||||
String? device_group_name;
|
||||
String note = '';
|
||||
|
||||
PeerPayload.fromJson(Map<String, dynamic> json)
|
||||
@@ -75,6 +78,7 @@ class PeerPayload {
|
||||
status = json['status'],
|
||||
user = json['user'] ?? '',
|
||||
user_name = json['user_name'] ?? '',
|
||||
device_group_name = json['device_group_name'] ?? '',
|
||||
note = json['note'] ?? '';
|
||||
|
||||
static Peer toPeer(PeerPayload p) {
|
||||
@@ -84,6 +88,7 @@ class PeerPayload {
|
||||
"username": p.info['username'] ?? '',
|
||||
"platform": _platform(p.info['os']),
|
||||
"hostname": p.info['device_name'],
|
||||
"device_group_name": p.device_group_name,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -265,3 +270,19 @@ class AbTag {
|
||||
: name = json['name'] ?? '',
|
||||
color = json['color'] ?? '';
|
||||
}
|
||||
|
||||
class DeviceGroupPayload {
|
||||
String name;
|
||||
|
||||
DeviceGroupPayload(this.name);
|
||||
|
||||
DeviceGroupPayload.fromJson(Map<String, dynamic> json)
|
||||
: name = json['name'] ?? '';
|
||||
|
||||
Map<String, dynamic> toGroupCacheJson() {
|
||||
final Map<String, dynamic> map = {
|
||||
'name': name,
|
||||
};
|
||||
return map;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -509,13 +509,13 @@ class _AddressBookState extends State<AddressBook> {
|
||||
|
||||
double marginBottom = 4;
|
||||
|
||||
row({required Widget lable, required Widget input}) {
|
||||
row({required Widget label, required Widget input}) {
|
||||
makeChild(bool isPortrait) => Row(
|
||||
children: [
|
||||
!isPortrait
|
||||
? ConstrainedBox(
|
||||
constraints: const BoxConstraints(minWidth: 100),
|
||||
child: lable.marginOnly(right: 10))
|
||||
child: label.marginOnly(right: 10))
|
||||
: SizedBox.shrink(),
|
||||
Expanded(
|
||||
child: ConstrainedBox(
|
||||
@@ -535,7 +535,7 @@ class _AddressBookState extends State<AddressBook> {
|
||||
Column(
|
||||
children: [
|
||||
row(
|
||||
lable: Row(
|
||||
label: Row(
|
||||
children: [
|
||||
Text(
|
||||
'*',
|
||||
@@ -558,7 +558,7 @@ class _AddressBookState extends State<AddressBook> {
|
||||
errorMaxLines: 5),
|
||||
).workaroundFreezeLinuxMint())),
|
||||
row(
|
||||
lable: Text(
|
||||
label: Text(
|
||||
translate('Alias'),
|
||||
style: style,
|
||||
),
|
||||
@@ -573,7 +573,7 @@ class _AddressBookState extends State<AddressBook> {
|
||||
),
|
||||
if (isCurrentAbShared)
|
||||
row(
|
||||
lable: Text(
|
||||
label: Text(
|
||||
translate('Password'),
|
||||
style: style,
|
||||
),
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hbb/common/formatter/id_formatter.dart';
|
||||
import '../../../models/platform_model.dart';
|
||||
@@ -6,56 +5,104 @@ import 'package:flutter_hbb/models/peer_model.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/common/widgets/peer_card.dart';
|
||||
|
||||
Future<List<Peer>> getAllPeers() async {
|
||||
Map<String, dynamic> recentPeers = jsonDecode(bind.mainLoadRecentPeersSync());
|
||||
Map<String, dynamic> lanPeers = jsonDecode(bind.mainLoadLanPeersSync());
|
||||
Map<String, dynamic> combinedPeers = {};
|
||||
class AllPeersLoader {
|
||||
List<Peer> peers = [];
|
||||
|
||||
void mergePeers(Map<String, dynamic> peers) {
|
||||
if (peers.containsKey("peers")) {
|
||||
dynamic peerData = peers["peers"];
|
||||
bool _isPeersLoading = false;
|
||||
bool _isPeersLoaded = false;
|
||||
|
||||
if (peerData is String) {
|
||||
try {
|
||||
peerData = jsonDecode(peerData);
|
||||
} catch (e) {
|
||||
print("Error decoding peers: $e");
|
||||
return;
|
||||
}
|
||||
}
|
||||
final String _listenerKey = 'AllPeersLoader';
|
||||
|
||||
if (peerData is List) {
|
||||
for (var peer in peerData) {
|
||||
if (peer is Map && peer.containsKey("id")) {
|
||||
String id = peer["id"];
|
||||
if (!combinedPeers.containsKey(id)) {
|
||||
combinedPeers[id] = peer;
|
||||
}
|
||||
}
|
||||
}
|
||||
late void Function(VoidCallback) setState;
|
||||
|
||||
bool get needLoad => !_isPeersLoaded && !_isPeersLoading;
|
||||
bool get isPeersLoaded => _isPeersLoaded;
|
||||
|
||||
AllPeersLoader();
|
||||
|
||||
void init(void Function(VoidCallback) setState) {
|
||||
this.setState = setState;
|
||||
gFFI.recentPeersModel.addListener(_mergeAllPeers);
|
||||
gFFI.lanPeersModel.addListener(_mergeAllPeers);
|
||||
gFFI.abModel.addPeerUpdateListener(_listenerKey, _mergeAllPeers);
|
||||
gFFI.groupModel.addPeerUpdateListener(_listenerKey, _mergeAllPeers);
|
||||
}
|
||||
|
||||
void clear() {
|
||||
gFFI.recentPeersModel.removeListener(_mergeAllPeers);
|
||||
gFFI.lanPeersModel.removeListener(_mergeAllPeers);
|
||||
gFFI.abModel.removePeerUpdateListener(_listenerKey);
|
||||
gFFI.groupModel.removePeerUpdateListener(_listenerKey);
|
||||
}
|
||||
|
||||
Future<void> getAllPeers() async {
|
||||
if (!needLoad) {
|
||||
return;
|
||||
}
|
||||
_isPeersLoading = true;
|
||||
|
||||
if (gFFI.recentPeersModel.peers.isEmpty) {
|
||||
bind.mainLoadRecentPeers();
|
||||
}
|
||||
if (gFFI.lanPeersModel.peers.isEmpty) {
|
||||
bind.mainLoadLanPeers();
|
||||
}
|
||||
// No need to care about peers from abModel, and group model.
|
||||
// Because they will pull data in `refreshCurrentUser()` on startup.
|
||||
|
||||
final startTime = DateTime.now();
|
||||
_mergeAllPeers();
|
||||
final diffTime = DateTime.now().difference(startTime).inMilliseconds;
|
||||
if (diffTime < 100) {
|
||||
await Future.delayed(Duration(milliseconds: diffTime));
|
||||
}
|
||||
}
|
||||
|
||||
void _mergeAllPeers() {
|
||||
Map<String, dynamic> combinedPeers = {};
|
||||
for (var p in gFFI.abModel.allPeers()) {
|
||||
if (!combinedPeers.containsKey(p.id)) {
|
||||
combinedPeers[p.id] = p.toJson();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mergePeers(recentPeers);
|
||||
mergePeers(lanPeers);
|
||||
for (var p in gFFI.abModel.allPeers()) {
|
||||
if (!combinedPeers.containsKey(p.id)) {
|
||||
combinedPeers[p.id] = p.toJson();
|
||||
for (var p in gFFI.groupModel.peers.map((e) => Peer.copy(e)).toList()) {
|
||||
if (!combinedPeers.containsKey(p.id)) {
|
||||
combinedPeers[p.id] = p.toJson();
|
||||
}
|
||||
}
|
||||
}
|
||||
for (var p in gFFI.groupModel.peers.map((e) => Peer.copy(e)).toList()) {
|
||||
if (!combinedPeers.containsKey(p.id)) {
|
||||
combinedPeers[p.id] = p.toJson();
|
||||
|
||||
List<Peer> parsedPeers = [];
|
||||
for (var peer in combinedPeers.values) {
|
||||
parsedPeers.add(Peer.fromJson(peer));
|
||||
}
|
||||
}
|
||||
|
||||
List<Peer> parsedPeers = [];
|
||||
Set<String> peerIds = combinedPeers.keys.toSet();
|
||||
for (final peer in gFFI.lanPeersModel.peers) {
|
||||
if (!peerIds.contains(peer.id)) {
|
||||
parsedPeers.add(peer);
|
||||
peerIds.add(peer.id);
|
||||
}
|
||||
}
|
||||
|
||||
for (var peer in combinedPeers.values) {
|
||||
parsedPeers.add(Peer.fromJson(peer));
|
||||
for (final peer in gFFI.recentPeersModel.peers) {
|
||||
if (!peerIds.contains(peer.id)) {
|
||||
parsedPeers.add(peer);
|
||||
peerIds.add(peer.id);
|
||||
}
|
||||
}
|
||||
for (final id in gFFI.recentPeersModel.restPeerIds) {
|
||||
if (!peerIds.contains(id)) {
|
||||
parsedPeers.add(Peer.fromJson({'id': id}));
|
||||
peerIds.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
peers = parsedPeers;
|
||||
setState(() {
|
||||
_isPeersLoading = false;
|
||||
_isPeersLoaded = true;
|
||||
});
|
||||
}
|
||||
return parsedPeers;
|
||||
}
|
||||
|
||||
class AutocompletePeerTile extends StatefulWidget {
|
||||
|
||||
@@ -4,7 +4,6 @@ import 'dart:convert';
|
||||
import 'package:bot_toast/bot_toast.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_hbb/common/shared_state.dart';
|
||||
import 'package:flutter_hbb/common/widgets/setting_widgets.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
@@ -71,7 +70,7 @@ void changeIdDialog() {
|
||||
final rules = [
|
||||
RegexValidationRule('starts with a letter', RegExp(r'^[a-zA-Z]')),
|
||||
LengthRangeValidationRule(6, 16),
|
||||
RegexValidationRule('allowed characters', RegExp(r'^\w*$'))
|
||||
RegexValidationRule('allowed characters', RegExp(r'^[\w-]*$'))
|
||||
];
|
||||
|
||||
gFFI.dialogManager.show((setState, close, context) {
|
||||
@@ -412,24 +411,38 @@ class DialogTextField extends StatelessWidget {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
decoration: InputDecoration(
|
||||
labelText: title,
|
||||
hintText: hintText,
|
||||
prefixIcon: prefixIcon,
|
||||
suffixIcon: suffixIcon,
|
||||
helperText: helperText,
|
||||
helperMaxLines: 8,
|
||||
errorText: errorText,
|
||||
errorMaxLines: 8,
|
||||
),
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
autofocus: true,
|
||||
obscureText: obscureText,
|
||||
keyboardType: keyboardType,
|
||||
inputFormatters: inputFormatters,
|
||||
maxLength: maxLength,
|
||||
child: Column(
|
||||
children: [
|
||||
TextField(
|
||||
decoration: InputDecoration(
|
||||
labelText: title,
|
||||
hintText: hintText,
|
||||
prefixIcon: prefixIcon,
|
||||
suffixIcon: suffixIcon,
|
||||
helperText: helperText,
|
||||
helperMaxLines: 8,
|
||||
),
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
autofocus: true,
|
||||
obscureText: obscureText,
|
||||
keyboardType: keyboardType,
|
||||
inputFormatters: inputFormatters,
|
||||
maxLength: maxLength,
|
||||
),
|
||||
if (errorText != null)
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: SelectableText(
|
||||
errorText!,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
fontSize: 12,
|
||||
),
|
||||
textAlign: TextAlign.left,
|
||||
).paddingOnly(top: 8, left: 12),
|
||||
),
|
||||
],
|
||||
).workaroundFreezeLinuxMint(),
|
||||
),
|
||||
],
|
||||
@@ -1610,6 +1623,28 @@ 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;
|
||||
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();
|
||||
}
|
||||
ffi.dialogManager.dismissAll();
|
||||
});
|
||||
msgBoxCommon(
|
||||
ffi.dialogManager,
|
||||
'Trackpad speed',
|
||||
TrackpadSpeedWidget(
|
||||
value: curSpeed,
|
||||
),
|
||||
[btnClose]);
|
||||
}
|
||||
|
||||
void deleteConfirmDialog(Function onSubmit, String title) async {
|
||||
gFFI.dialogManager.show(
|
||||
(setState, close, context) {
|
||||
|
||||
@@ -20,8 +20,11 @@ class MyGroup extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _MyGroupState extends State<MyGroup> {
|
||||
RxString get selectedUser => gFFI.groupModel.selectedUser;
|
||||
RxString get searchUserText => gFFI.groupModel.searchUserText;
|
||||
RxBool get isSelectedDeviceGroup => gFFI.groupModel.isSelectedDeviceGroup;
|
||||
RxString get selectedAccessibleItemName =>
|
||||
gFFI.groupModel.selectedAccessibleItemName;
|
||||
RxString get searchAccessibleItemNameText =>
|
||||
gFFI.groupModel.searchAccessibleItemNameText;
|
||||
static TextEditingController searchUserController = TextEditingController();
|
||||
|
||||
@override
|
||||
@@ -72,7 +75,7 @@ class _MyGroupState extends State<MyGroup> {
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
child: _buildUserContacts(),
|
||||
child: _buildLeftList(),
|
||||
),
|
||||
)
|
||||
],
|
||||
@@ -105,7 +108,7 @@ class _MyGroupState extends State<MyGroup> {
|
||||
_buildLeftHeader(),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
child: _buildUserContacts(),
|
||||
child: _buildLeftList(),
|
||||
)
|
||||
],
|
||||
),
|
||||
@@ -130,7 +133,8 @@ class _MyGroupState extends State<MyGroup> {
|
||||
child: TextField(
|
||||
controller: searchUserController,
|
||||
onChanged: (value) {
|
||||
searchUserText.value = value;
|
||||
searchAccessibleItemNameText.value = value;
|
||||
selectedAccessibleItemName.value = '';
|
||||
},
|
||||
textAlignVertical: TextAlignVertical.center,
|
||||
style: TextStyle(fontSize: fontSize),
|
||||
@@ -150,20 +154,30 @@ class _MyGroupState extends State<MyGroup> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUserContacts() {
|
||||
Widget _buildLeftList() {
|
||||
return Obx(() {
|
||||
final items = gFFI.groupModel.users.where((p0) {
|
||||
if (searchUserText.isNotEmpty) {
|
||||
final userItems = gFFI.groupModel.users.where((p0) {
|
||||
if (searchAccessibleItemNameText.isNotEmpty) {
|
||||
return p0.name
|
||||
.toLowerCase()
|
||||
.contains(searchUserText.value.toLowerCase());
|
||||
.contains(searchAccessibleItemNameText.value.toLowerCase());
|
||||
}
|
||||
return true;
|
||||
}).toList();
|
||||
final deviceGroupItems = gFFI.groupModel.deviceGroups.where((p0) {
|
||||
if (searchAccessibleItemNameText.isNotEmpty) {
|
||||
return p0.name
|
||||
.toLowerCase()
|
||||
.contains(searchAccessibleItemNameText.value.toLowerCase());
|
||||
}
|
||||
return true;
|
||||
}).toList();
|
||||
listView(bool isPortrait) => ListView.builder(
|
||||
shrinkWrap: isPortrait,
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) => _buildUserItem(items[index]));
|
||||
itemCount: deviceGroupItems.length + userItems.length,
|
||||
itemBuilder: (context, index) => index < deviceGroupItems.length
|
||||
? _buildDeviceGroupItem(deviceGroupItems[index])
|
||||
: _buildUserItem(userItems[index - deviceGroupItems.length]));
|
||||
var maxHeight = max(MediaQuery.of(context).size.height / 6, 100.0);
|
||||
return Obx(() => stateGlobal.isPortrait.isFalse
|
||||
? listView(false)
|
||||
@@ -174,14 +188,16 @@ class _MyGroupState extends State<MyGroup> {
|
||||
Widget _buildUserItem(UserPayload user) {
|
||||
final username = user.name;
|
||||
return InkWell(onTap: () {
|
||||
if (selectedUser.value != username) {
|
||||
selectedUser.value = username;
|
||||
isSelectedDeviceGroup.value = false;
|
||||
if (selectedAccessibleItemName.value != username) {
|
||||
selectedAccessibleItemName.value = username;
|
||||
} else {
|
||||
selectedUser.value = '';
|
||||
selectedAccessibleItemName.value = '';
|
||||
}
|
||||
}, child: Obx(
|
||||
() {
|
||||
bool selected = selectedUser.value == username;
|
||||
bool selected = !isSelectedDeviceGroup.value &&
|
||||
selectedAccessibleItemName.value == username;
|
||||
final isMe = username == gFFI.userModel.userName.value;
|
||||
final colorMe = MyTheme.color(context).me!;
|
||||
return Container(
|
||||
@@ -238,4 +254,43 @@ class _MyGroupState extends State<MyGroup> {
|
||||
},
|
||||
)).marginSymmetric(horizontal: 12).marginOnly(bottom: 6);
|
||||
}
|
||||
|
||||
Widget _buildDeviceGroupItem(DeviceGroupPayload deviceGroup) {
|
||||
final name = deviceGroup.name;
|
||||
return InkWell(onTap: () {
|
||||
isSelectedDeviceGroup.value = true;
|
||||
if (selectedAccessibleItemName.value != name) {
|
||||
selectedAccessibleItemName.value = name;
|
||||
} else {
|
||||
selectedAccessibleItemName.value = '';
|
||||
}
|
||||
}, child: Obx(
|
||||
() {
|
||||
bool selected = isSelectedDeviceGroup.value &&
|
||||
selectedAccessibleItemName.value == name;
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? MyTheme.color(context).highlight : null,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
width: 0.7,
|
||||
color: Theme.of(context).dividerColor.withOpacity(0.1))),
|
||||
),
|
||||
child: Container(
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: Icon(IconFont.deviceGroupOutline,
|
||||
color: MyTheme.accent, size: 19),
|
||||
).marginOnly(right: 4),
|
||||
Expanded(child: Text(name)),
|
||||
],
|
||||
).paddingSymmetric(vertical: 4),
|
||||
),
|
||||
);
|
||||
},
|
||||
)).marginSymmetric(horizontal: 12).marginOnly(bottom: 6);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -488,6 +488,7 @@ abstract class BasePeerCard extends StatelessWidget {
|
||||
BuildContext context,
|
||||
String title, {
|
||||
bool isFileTransfer = false,
|
||||
bool isViewCamera = false,
|
||||
bool isTcpTunneling = false,
|
||||
bool isRDP = false,
|
||||
}) {
|
||||
@@ -502,6 +503,7 @@ abstract class BasePeerCard extends StatelessWidget {
|
||||
peer,
|
||||
tab,
|
||||
isFileTransfer: isFileTransfer,
|
||||
isViewCamera: isViewCamera,
|
||||
isTcpTunneling: isTcpTunneling,
|
||||
isRDP: isRDP,
|
||||
);
|
||||
@@ -530,6 +532,15 @@ abstract class BasePeerCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
@protected
|
||||
MenuEntryBase<String> _viewCameraAction(BuildContext context) {
|
||||
return _connectCommonAction(
|
||||
context,
|
||||
translate('View camera'),
|
||||
isViewCamera: true,
|
||||
);
|
||||
}
|
||||
|
||||
@protected
|
||||
MenuEntryBase<String> _tcpTunnelingAction(BuildContext context) {
|
||||
return _connectCommonAction(
|
||||
@@ -716,18 +727,18 @@ abstract class BasePeerCard extends StatelessWidget {
|
||||
switch (tab) {
|
||||
case PeerTabIndex.recent:
|
||||
await bind.mainRemovePeer(id: id);
|
||||
await bind.mainLoadRecentPeers();
|
||||
bind.mainLoadRecentPeers();
|
||||
break;
|
||||
case PeerTabIndex.fav:
|
||||
final favs = (await bind.mainGetFav()).toList();
|
||||
if (favs.remove(id)) {
|
||||
await bind.mainStoreFav(favs: favs);
|
||||
await bind.mainLoadFavPeers();
|
||||
bind.mainLoadFavPeers();
|
||||
}
|
||||
break;
|
||||
case PeerTabIndex.lan:
|
||||
await bind.mainRemoveDiscovered(id: id);
|
||||
await bind.mainLoadLanPeers();
|
||||
bind.mainLoadLanPeers();
|
||||
break;
|
||||
case PeerTabIndex.ab:
|
||||
await gFFI.abModel.deletePeers([id]);
|
||||
@@ -880,6 +891,7 @@ class RecentPeerCard extends BasePeerCard {
|
||||
final List<MenuEntryBase<String>> menuItems = [
|
||||
_connectAction(context),
|
||||
_transferFileAction(context),
|
||||
_viewCameraAction(context),
|
||||
];
|
||||
|
||||
final List favs = (await bind.mainGetFav()).toList();
|
||||
@@ -939,6 +951,7 @@ class FavoritePeerCard extends BasePeerCard {
|
||||
final List<MenuEntryBase<String>> menuItems = [
|
||||
_connectAction(context),
|
||||
_transferFileAction(context),
|
||||
_viewCameraAction(context),
|
||||
];
|
||||
if (isDesktop && peer.platform != kPeerPlatformAndroid) {
|
||||
menuItems.add(_tcpTunnelingAction(context));
|
||||
@@ -992,6 +1005,7 @@ class DiscoveredPeerCard extends BasePeerCard {
|
||||
final List<MenuEntryBase<String>> menuItems = [
|
||||
_connectAction(context),
|
||||
_transferFileAction(context),
|
||||
_viewCameraAction(context),
|
||||
];
|
||||
|
||||
final List favs = (await bind.mainGetFav()).toList();
|
||||
@@ -1045,6 +1059,7 @@ class AddressBookPeerCard extends BasePeerCard {
|
||||
final List<MenuEntryBase<String>> menuItems = [
|
||||
_connectAction(context),
|
||||
_transferFileAction(context),
|
||||
_viewCameraAction(context),
|
||||
];
|
||||
if (isDesktop && peer.platform != kPeerPlatformAndroid) {
|
||||
menuItems.add(_tcpTunnelingAction(context));
|
||||
@@ -1177,6 +1192,7 @@ class MyGroupPeerCard extends BasePeerCard {
|
||||
final List<MenuEntryBase<String>> menuItems = [
|
||||
_connectAction(context),
|
||||
_transferFileAction(context),
|
||||
_viewCameraAction(context),
|
||||
];
|
||||
if (isDesktop && peer.platform != kPeerPlatformAndroid) {
|
||||
menuItems.add(_tcpTunnelingAction(context));
|
||||
@@ -1398,6 +1414,7 @@ class TagPainter extends CustomPainter {
|
||||
|
||||
void connectInPeerTab(BuildContext context, Peer peer, PeerTabIndex tab,
|
||||
{bool isFileTransfer = false,
|
||||
bool isViewCamera = false,
|
||||
bool isTcpTunneling = false,
|
||||
bool isRDP = false}) async {
|
||||
var password = '';
|
||||
@@ -1423,6 +1440,7 @@ void connectInPeerTab(BuildContext context, Peer peer, PeerTabIndex tab,
|
||||
password: password,
|
||||
isSharedPassword: isSharedPassword,
|
||||
isFileTransfer: isFileTransfer,
|
||||
isViewCamera: isViewCamera,
|
||||
isTcpTunneling: isTcpTunneling,
|
||||
isRDP: isRDP);
|
||||
}
|
||||
|
||||
@@ -33,8 +33,8 @@ class PeerTabPage extends StatefulWidget {
|
||||
|
||||
class _TabEntry {
|
||||
final Widget widget;
|
||||
final Function({dynamic hint}) load;
|
||||
_TabEntry(this.widget, this.load);
|
||||
final Function({dynamic hint})? load;
|
||||
_TabEntry(this.widget, [this.load]);
|
||||
}
|
||||
|
||||
EdgeInsets? _menuPadding() {
|
||||
@@ -44,21 +44,15 @@ EdgeInsets? _menuPadding() {
|
||||
class _PeerTabPageState extends State<PeerTabPage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
final List<_TabEntry> entries = [
|
||||
_TabEntry(
|
||||
RecentPeersView(
|
||||
menuPadding: _menuPadding(),
|
||||
),
|
||||
bind.mainLoadRecentPeers),
|
||||
_TabEntry(
|
||||
FavoritePeersView(
|
||||
menuPadding: _menuPadding(),
|
||||
),
|
||||
bind.mainLoadFavPeers),
|
||||
_TabEntry(
|
||||
DiscoveredPeersView(
|
||||
menuPadding: _menuPadding(),
|
||||
),
|
||||
bind.mainDiscover),
|
||||
_TabEntry(RecentPeersView(
|
||||
menuPadding: _menuPadding(),
|
||||
)),
|
||||
_TabEntry(FavoritePeersView(
|
||||
menuPadding: _menuPadding(),
|
||||
)),
|
||||
_TabEntry(DiscoveredPeersView(
|
||||
menuPadding: _menuPadding(),
|
||||
)),
|
||||
_TabEntry(
|
||||
AddressBook(
|
||||
menuPadding: _menuPadding(),
|
||||
@@ -100,7 +94,7 @@ class _PeerTabPageState extends State<PeerTabPage>
|
||||
gFFI.peerTabModel.setCurrentTabCachedPeers([]);
|
||||
}
|
||||
gFFI.peerTabModel.setCurrentTab(tabIndex);
|
||||
entries[tabIndex].load(hint: false);
|
||||
entries[tabIndex].load?.call(hint: false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,7 +219,7 @@ class _PeerTabPageState extends State<PeerTabPage>
|
||||
child: RefreshWidget(
|
||||
onPressed: () {
|
||||
if (gFFI.peerTabModel.currentTab < entries.length) {
|
||||
entries[gFFI.peerTabModel.currentTab].load();
|
||||
entries[gFFI.peerTabModel.currentTab].load?.call();
|
||||
}
|
||||
},
|
||||
spinning: loading,
|
||||
@@ -404,7 +398,7 @@ class _PeerTabPageState extends State<PeerTabPage>
|
||||
for (var p in peers) {
|
||||
await bind.mainRemovePeer(id: p.id);
|
||||
}
|
||||
await bind.mainLoadRecentPeers();
|
||||
bind.mainLoadRecentPeers();
|
||||
break;
|
||||
case 1:
|
||||
final favs = (await bind.mainGetFav()).toList();
|
||||
@@ -412,13 +406,13 @@ class _PeerTabPageState extends State<PeerTabPage>
|
||||
favs.remove(p.id);
|
||||
}).toList();
|
||||
await bind.mainStoreFav(favs: favs);
|
||||
await bind.mainLoadFavPeers();
|
||||
bind.mainLoadFavPeers();
|
||||
break;
|
||||
case 2:
|
||||
for (var p in peers) {
|
||||
await bind.mainRemoveDiscovered(id: p.id);
|
||||
}
|
||||
await bind.mainLoadLanPeers();
|
||||
bind.mainLoadLanPeers();
|
||||
break;
|
||||
case 3:
|
||||
await gFFI.abModel.deletePeers(peers.map((p) => p.id).toList());
|
||||
|
||||
@@ -25,13 +25,13 @@ class PeerSortType {
|
||||
static const String remoteId = 'Remote ID';
|
||||
static const String remoteHost = 'Remote Host';
|
||||
static const String username = 'Username';
|
||||
// static const String status = 'Status';
|
||||
static const String status = 'Status';
|
||||
|
||||
static List<String> values = [
|
||||
PeerSortType.remoteId,
|
||||
PeerSortType.remoteHost,
|
||||
PeerSortType.username,
|
||||
// PeerSortType.status
|
||||
PeerSortType.status
|
||||
];
|
||||
}
|
||||
|
||||
@@ -384,9 +384,9 @@ class _PeersViewState extends State<_PeersView>
|
||||
peers.sort((p1, p2) =>
|
||||
p1.username.toLowerCase().compareTo(p2.username.toLowerCase()));
|
||||
break;
|
||||
// case PeerSortType.status:
|
||||
// peers.sort((p1, p2) => p1.online ? -1 : 1);
|
||||
// break;
|
||||
case PeerSortType.status:
|
||||
peers.sort((p1, p2) => p1.online ? -1 : 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -501,6 +501,7 @@ class DiscoveredPeersView extends BasePeersView {
|
||||
Widget build(BuildContext context) {
|
||||
final widget = super.build(context);
|
||||
bind.mainLoadLanPeers();
|
||||
bind.mainDiscover();
|
||||
return widget;
|
||||
}
|
||||
}
|
||||
@@ -562,14 +563,26 @@ class MyGroupPeerView extends BasePeersView {
|
||||
);
|
||||
|
||||
static bool filter(Peer peer) {
|
||||
if (gFFI.groupModel.searchUserText.isNotEmpty) {
|
||||
if (!peer.loginName.contains(gFFI.groupModel.searchUserText)) {
|
||||
final model = gFFI.groupModel;
|
||||
if (model.searchAccessibleItemNameText.isNotEmpty) {
|
||||
final text = model.searchAccessibleItemNameText.value;
|
||||
final searchPeersOfUser = peer.loginName.contains(text) &&
|
||||
model.users.any((user) => user.name == peer.loginName);
|
||||
final searchPeersOfDeviceGroup = peer.device_group_name.contains(text) &&
|
||||
model.deviceGroups.any((g) => g.name == peer.device_group_name);
|
||||
if (!searchPeersOfUser && !searchPeersOfDeviceGroup) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (gFFI.groupModel.selectedUser.isNotEmpty) {
|
||||
if (gFFI.groupModel.selectedUser.value != peer.loginName) {
|
||||
return false;
|
||||
if (model.selectedAccessibleItemName.isNotEmpty) {
|
||||
if (model.isSelectedDeviceGroup.value) {
|
||||
if (model.selectedAccessibleItemName.value != peer.device_group_name) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
if (model.selectedAccessibleItemName.value != peer.loginName) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -53,13 +54,14 @@ class RawKeyFocusScope extends StatelessWidget {
|
||||
class RawTouchGestureDetectorRegion extends StatefulWidget {
|
||||
final Widget child;
|
||||
final FFI ffi;
|
||||
|
||||
final bool isCamera;
|
||||
late final InputModel inputModel = ffi.inputModel;
|
||||
late final FfiModel ffiModel = ffi.ffiModel;
|
||||
|
||||
RawTouchGestureDetectorRegion({
|
||||
required this.child,
|
||||
required this.ffi,
|
||||
this.isCamera = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -187,6 +189,11 @@ class _RawTouchGestureDetectorRegionState
|
||||
return;
|
||||
}
|
||||
_cacheLongPressPositionTs = DateTime.now().millisecondsSinceEpoch;
|
||||
if (ffiModel.isPeerMobile) {
|
||||
await ffi.cursorModel
|
||||
.move(_cacheLongPressPosition.dx, _cacheLongPressPosition.dy);
|
||||
await inputModel.tapDown(MouseButtons.left);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,15 +211,31 @@ class _RawTouchGestureDetectorRegionState
|
||||
if (lastDeviceKind != PointerDeviceKind.touch) {
|
||||
return;
|
||||
}
|
||||
if (!ffi.ffiModel.isPeerMobile) {
|
||||
if (handleTouch) {
|
||||
final isMoved = await ffi.cursorModel
|
||||
.move(_cacheLongPressPosition.dx, _cacheLongPressPosition.dy);
|
||||
if (!isMoved) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await inputModel.tap(MouseButtons.right);
|
||||
} else {
|
||||
// It's better to send a message to tell the controlled device that the long press event is triggered.
|
||||
// We're now using a `TimerTask` in `InputService.kt` to decide whether to trigger the long press event.
|
||||
// It's not accurate and it's better to use the same detection logic in the controlling side.
|
||||
}
|
||||
}
|
||||
|
||||
onLongPressMoveUpdate(LongPressMoveUpdateDetails d) async {
|
||||
if (!ffiModel.isPeerMobile || lastDeviceKind != PointerDeviceKind.touch) {
|
||||
return;
|
||||
}
|
||||
if (handleTouch) {
|
||||
final isMoved = await ffi.cursorModel
|
||||
.move(_cacheLongPressPosition.dx, _cacheLongPressPosition.dy);
|
||||
if (!isMoved) {
|
||||
if (!ffi.cursorModel.isInRemoteRect(d.localPosition)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!ffi.ffiModel.isPeerMobile) {
|
||||
await inputModel.tap(MouseButtons.right);
|
||||
await ffi.cursorModel.move(d.localPosition.dx, d.localPosition.dy);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,7 +363,7 @@ class _RawTouchGestureDetectorRegionState
|
||||
ffi.cursorModel.clearRemoteWindowCoords();
|
||||
}
|
||||
if (handleTouch) {
|
||||
await inputModel.sendMouse('up', MouseButtons.left);
|
||||
await inputModel.sendMouse('up', MouseButtons.left);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,6 +384,7 @@ class _RawTouchGestureDetectorRegionState
|
||||
_scale = d.scale;
|
||||
|
||||
if (scale != 0) {
|
||||
if (widget.isCamera) return;
|
||||
await bind.sessionSendPointer(
|
||||
sessionId: sessionId,
|
||||
msg: json.encode(
|
||||
@@ -381,6 +405,7 @@ class _RawTouchGestureDetectorRegionState
|
||||
return;
|
||||
}
|
||||
if ((isDesktop || isWebDesktop)) {
|
||||
if (widget.isCamera) return;
|
||||
await bind.sessionSendPointer(
|
||||
sessionId: sessionId,
|
||||
msg: json.encode(
|
||||
@@ -432,7 +457,8 @@ class _RawTouchGestureDetectorRegionState
|
||||
instance
|
||||
..onLongPressDown = onLongPressDown
|
||||
..onLongPressUp = onLongPressUp
|
||||
..onLongPress = onLongPress;
|
||||
..onLongPress = onLongPress
|
||||
..onLongPressMoveUpdate = onLongPressMoveUpdate;
|
||||
}),
|
||||
// Customized
|
||||
HoldTapMoveGestureRecognizer:
|
||||
@@ -514,3 +540,46 @@ class RawPointerMouseRegion extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CameraRawPointerMouseRegion extends StatelessWidget {
|
||||
final InputModel inputModel;
|
||||
final Widget child;
|
||||
final PointerEnterEventListener? onEnter;
|
||||
final PointerExitEventListener? onExit;
|
||||
final PointerDownEventListener? onPointerDown;
|
||||
final PointerUpEventListener? onPointerUp;
|
||||
|
||||
CameraRawPointerMouseRegion({
|
||||
this.onEnter,
|
||||
this.onExit,
|
||||
this.onPointerDown,
|
||||
this.onPointerUp,
|
||||
required this.inputModel,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Listener(
|
||||
onPointerHover: (evt) {
|
||||
final offset = evt.position;
|
||||
double x = offset.dx;
|
||||
double y = max(0.0, offset.dy);
|
||||
inputModel.handlePointerDevicePos(
|
||||
kPointerEventKindMouse, x, y, true, kMouseEventTypeDefault);
|
||||
},
|
||||
onPointerDown: (evt) {
|
||||
onPointerDown?.call(evt);
|
||||
},
|
||||
onPointerUp: (evt) {
|
||||
onPointerUp?.call(evt);
|
||||
},
|
||||
child: MouseRegion(
|
||||
cursor: MouseCursor.defer,
|
||||
onEnter: onEnter,
|
||||
onExit: onExit,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,3 +248,93 @@ List<(String, String)> otherDefaultSettings() {
|
||||
|
||||
return v;
|
||||
}
|
||||
|
||||
class TrackpadSpeedWidget extends StatefulWidget {
|
||||
final SimpleWrapper<int> value;
|
||||
// If null, no debouncer will be applied.
|
||||
final Function(int)? onDebouncer;
|
||||
|
||||
TrackpadSpeedWidget({Key? key, required this.value, this.onDebouncer});
|
||||
|
||||
@override
|
||||
TrackpadSpeedWidgetState createState() => TrackpadSpeedWidgetState();
|
||||
}
|
||||
|
||||
class TrackpadSpeedWidgetState extends State<TrackpadSpeedWidget> {
|
||||
final TextEditingController _controller = TextEditingController();
|
||||
late final Debouncer<int> debouncerSpeed;
|
||||
|
||||
set value(int v) => widget.value.value = v;
|
||||
int get value => widget.value.value;
|
||||
|
||||
void updateValue(int newValue) {
|
||||
setState(() {
|
||||
value = newValue.clamp(kMinTrackpadSpeed, kMaxTrackpadSpeed);
|
||||
// Scale the trackpad speed value to a percentage for display purposes.
|
||||
_controller.text = value.toString();
|
||||
if (widget.onDebouncer != null) {
|
||||
debouncerSpeed.setValue(value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
debouncerSpeed = Debouncer<int>(
|
||||
Duration(milliseconds: 1000),
|
||||
onChanged: widget.onDebouncer,
|
||||
initialValue: widget.value.value,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_controller.text.isEmpty) {
|
||||
_controller.text = value.toString();
|
||||
}
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Slider(
|
||||
value: value.toDouble(),
|
||||
min: kMinTrackpadSpeed.toDouble(),
|
||||
max: kMaxTrackpadSpeed.toDouble(),
|
||||
divisions: ((kMaxTrackpadSpeed - kMinTrackpadSpeed) / 10).round(),
|
||||
onChanged: (double v) => updateValue(v.round()),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 56,
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
keyboardType: TextInputType.number,
|
||||
textAlign: TextAlign.center,
|
||||
onSubmitted: (text) {
|
||||
int? v = int.tryParse(text);
|
||||
if (v != null) {
|
||||
updateValue(v);
|
||||
}
|
||||
},
|
||||
style: const TextStyle(fontSize: 13),
|
||||
decoration: InputDecoration(
|
||||
contentPadding:
|
||||
EdgeInsets.symmetric(vertical: 8.0, horizontal: 12.0),
|
||||
),
|
||||
),
|
||||
).marginOnly(right: 8.0),
|
||||
Text(
|
||||
'%',
|
||||
style: const TextStyle(fontSize: 15),
|
||||
)
|
||||
],
|
||||
)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -15,7 +16,7 @@ bool isEditOsPassword = false;
|
||||
|
||||
class TTextMenu {
|
||||
final Widget child;
|
||||
final VoidCallback onPressed;
|
||||
final VoidCallback? onPressed;
|
||||
Widget? trailingIcon;
|
||||
bool divider;
|
||||
TTextMenu(
|
||||
@@ -89,10 +90,13 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
||||
final pi = ffiModel.pi;
|
||||
final perms = ffiModel.permissions;
|
||||
final sessionId = ffi.sessionId;
|
||||
final isDefaultConn = ffi.connType == ConnType.defaultConn;
|
||||
|
||||
List<TTextMenu> v = [];
|
||||
// elevation
|
||||
if (perms['keyboard'] != false && ffi.elevationModel.showRequestMenu) {
|
||||
if (isDefaultConn &&
|
||||
perms['keyboard'] != false &&
|
||||
ffi.elevationModel.showRequestMenu) {
|
||||
v.add(
|
||||
TTextMenu(
|
||||
child: Text(translate('Request Elevation')),
|
||||
@@ -101,7 +105,7 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
||||
);
|
||||
}
|
||||
// osAccount / osPassword
|
||||
if (perms['keyboard'] != false) {
|
||||
if (isDefaultConn && perms['keyboard'] != false) {
|
||||
v.add(
|
||||
TTextMenu(
|
||||
child: Row(children: [
|
||||
@@ -130,7 +134,9 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
||||
);
|
||||
}
|
||||
// paste
|
||||
if (pi.platform != kPeerPlatformAndroid && perms['keyboard'] != false) {
|
||||
if (isDefaultConn &&
|
||||
pi.platform != kPeerPlatformAndroid &&
|
||||
perms['keyboard'] != false) {
|
||||
v.add(TTextMenu(
|
||||
child: Text(translate('Send clipboard keystrokes')),
|
||||
onPressed: () async {
|
||||
@@ -142,43 +148,53 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
||||
}));
|
||||
}
|
||||
// reset canvas
|
||||
if (isMobile) {
|
||||
if (isDefaultConn && isMobile) {
|
||||
v.add(TTextMenu(
|
||||
child: Text(translate('Reset canvas')),
|
||||
onPressed: () => ffi.cursorModel.reset()));
|
||||
}
|
||||
|
||||
connectWithToken(
|
||||
{required bool isFileTransfer, required bool isTcpTunneling}) {
|
||||
{bool isFileTransfer = false,
|
||||
bool isViewCamera = false,
|
||||
bool isTcpTunneling = false}) {
|
||||
final connToken = bind.sessionGetConnToken(sessionId: ffi.sessionId);
|
||||
connect(context, id,
|
||||
isFileTransfer: isFileTransfer,
|
||||
isViewCamera: isViewCamera,
|
||||
isTcpTunneling: isTcpTunneling,
|
||||
connToken: connToken);
|
||||
}
|
||||
|
||||
// transferFile
|
||||
if (isDesktop) {
|
||||
if (isDefaultConn && isDesktop) {
|
||||
v.add(
|
||||
TTextMenu(
|
||||
child: Text(translate('Transfer file')),
|
||||
onPressed: () =>
|
||||
connectWithToken(isFileTransfer: true, isTcpTunneling: false)),
|
||||
onPressed: () => connectWithToken(isFileTransfer: true)),
|
||||
);
|
||||
}
|
||||
// viewCamera
|
||||
if (isDefaultConn && isDesktop) {
|
||||
v.add(
|
||||
TTextMenu(
|
||||
child: Text(translate('View camera')),
|
||||
onPressed: () => connectWithToken(isViewCamera: true)),
|
||||
);
|
||||
}
|
||||
// tcpTunneling
|
||||
if (isDesktop) {
|
||||
if (isDefaultConn && isDesktop) {
|
||||
v.add(
|
||||
TTextMenu(
|
||||
child: Text(translate('TCP tunneling')),
|
||||
onPressed: () =>
|
||||
connectWithToken(isFileTransfer: false, isTcpTunneling: true)),
|
||||
onPressed: () => connectWithToken(isTcpTunneling: true)),
|
||||
);
|
||||
}
|
||||
// note
|
||||
if (bind
|
||||
.sessionGetAuditServerSync(sessionId: sessionId, typ: "conn")
|
||||
.isNotEmpty) {
|
||||
if (isDefaultConn &&
|
||||
bind
|
||||
.sessionGetAuditServerSync(sessionId: sessionId, typ: "conn")
|
||||
.isNotEmpty) {
|
||||
v.add(
|
||||
TTextMenu(
|
||||
child: Text(translate('Note')),
|
||||
@@ -186,11 +202,12 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
||||
);
|
||||
}
|
||||
// divider
|
||||
if (isDesktop || isWebDesktop) {
|
||||
if (isDefaultConn && (isDesktop || isWebDesktop)) {
|
||||
v.add(TTextMenu(child: Offstage(), onPressed: () {}, divider: true));
|
||||
}
|
||||
// ctrlAltDel
|
||||
if (!ffiModel.viewOnly &&
|
||||
if (isDefaultConn &&
|
||||
!ffiModel.viewOnly &&
|
||||
ffiModel.keyboard &&
|
||||
(pi.platform == kPeerPlatformLinux || pi.sasEnabled)) {
|
||||
v.add(
|
||||
@@ -200,7 +217,8 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
||||
);
|
||||
}
|
||||
// restart
|
||||
if (perms['restart'] != false &&
|
||||
if (isDefaultConn &&
|
||||
perms['restart'] != false &&
|
||||
(pi.platform == kPeerPlatformLinux ||
|
||||
pi.platform == kPeerPlatformWindows ||
|
||||
pi.platform == kPeerPlatformMacOS)) {
|
||||
@@ -212,7 +230,7 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
||||
);
|
||||
}
|
||||
// insertLock
|
||||
if (!ffiModel.viewOnly && ffi.ffiModel.keyboard) {
|
||||
if (isDefaultConn && !ffiModel.viewOnly && ffi.ffiModel.keyboard) {
|
||||
v.add(
|
||||
TTextMenu(
|
||||
child: Text(translate('Insert Lock')),
|
||||
@@ -220,7 +238,8 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
||||
);
|
||||
}
|
||||
// blockUserInput
|
||||
if (ffi.ffiModel.keyboard &&
|
||||
if (isDefaultConn &&
|
||||
ffi.ffiModel.keyboard &&
|
||||
ffi.ffiModel.permissions['block_input'] != false &&
|
||||
pi.platform == kPeerPlatformWindows) // privacy-mode != true ??
|
||||
{
|
||||
@@ -236,12 +255,13 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
||||
}));
|
||||
}
|
||||
// switchSides
|
||||
if (isDesktop &&
|
||||
if (isDefaultConn &&
|
||||
isDesktop &&
|
||||
ffiModel.keyboard &&
|
||||
pi.platform != kPeerPlatformAndroid &&
|
||||
pi.platform != kPeerPlatformMacOS &&
|
||||
versionCmp(pi.version, '1.2.0') >= 0 &&
|
||||
bind.peerGetDefaultSessionsCount(id: id) == 1) {
|
||||
bind.peerGetSessionsCount(id: id, connType: ffi.connType.index) == 1) {
|
||||
v.add(TTextMenu(
|
||||
child: Text(translate('Switch Sides')),
|
||||
onPressed: () =>
|
||||
@@ -275,6 +295,41 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
||||
),
|
||||
onPressed: () => ffi.recordingModel.toggle()));
|
||||
}
|
||||
|
||||
// to-do:
|
||||
// 1. Web desktop
|
||||
// 2. Mobile, copy the image to the clipboard
|
||||
if (isDesktop) {
|
||||
final isScreenshotSupported = bind.sessionGetCommonSync(
|
||||
sessionId: sessionId, key: 'is_screenshot_supported', param: '');
|
||||
if ('true' == isScreenshotSupported) {
|
||||
v.add(TTextMenu(
|
||||
child: Text(ffi.ffiModel.timerScreenshot != null
|
||||
? '${translate('Taking screenshot')} ...'
|
||||
: translate('Take screenshot')),
|
||||
onPressed: ffi.ffiModel.timerScreenshot != null
|
||||
? null
|
||||
: () {
|
||||
if (pi.currentDisplay == kAllDisplayValue) {
|
||||
msgBox(
|
||||
sessionId,
|
||||
'custom-nook-nocancel-hasclose-info',
|
||||
'Take screenshot',
|
||||
'screenshot-merged-screen-not-supported-tip',
|
||||
'',
|
||||
ffi.dialogManager);
|
||||
} else {
|
||||
bind.sessionTakeScreenshot(
|
||||
sessionId: sessionId, display: pi.currentDisplay);
|
||||
ffi.ffiModel.timerScreenshot =
|
||||
Timer(Duration(seconds: 30), () {
|
||||
ffi.ffiModel.timerScreenshot = null;
|
||||
});
|
||||
}
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
// fingerprint
|
||||
if (!(isDesktop || isWebDesktop)) {
|
||||
v.add(TTextMenu(
|
||||
@@ -523,6 +578,7 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
|
||||
final pi = ffiModel.pi;
|
||||
final perms = ffiModel.permissions;
|
||||
final sessionId = ffi.sessionId;
|
||||
final isDefaultConn = ffi.connType == ConnType.defaultConn;
|
||||
|
||||
// show quality monitor
|
||||
final option = 'show-quality-monitor';
|
||||
@@ -535,7 +591,7 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
|
||||
},
|
||||
child: Text(translate('Show quality monitor'))));
|
||||
// mute
|
||||
if (perms['audio'] != false) {
|
||||
if (isDefaultConn && perms['audio'] != false) {
|
||||
final option = 'disable-audio';
|
||||
final value =
|
||||
bind.sessionGetToggleOptionSync(sessionId: sessionId, arg: option);
|
||||
@@ -556,7 +612,8 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
|
||||
final isSupportIfPeer_1_2_4 = versionCmp(pi.version, '1.2.4') >= 0 &&
|
||||
bind.mainHasFileClipboard() &&
|
||||
pi.platformAdditions.containsKey(kPlatformAdditionsHasFileClipboard);
|
||||
if (ffiModel.keyboard &&
|
||||
if (isDefaultConn &&
|
||||
ffiModel.keyboard &&
|
||||
perms['file'] != false &&
|
||||
(isSupportIfPeer_1_2_3 || isSupportIfPeer_1_2_4)) {
|
||||
final enabled = !ffiModel.viewOnly;
|
||||
@@ -574,7 +631,7 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
|
||||
child: Text(translate('Enable file copy and paste'))));
|
||||
}
|
||||
// disable clipboard
|
||||
if (ffiModel.keyboard && perms['clipboard'] != false) {
|
||||
if (isDefaultConn && ffiModel.keyboard && perms['clipboard'] != false) {
|
||||
final enabled = !ffiModel.viewOnly;
|
||||
final option = 'disable-clipboard';
|
||||
var value =
|
||||
@@ -591,7 +648,7 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
|
||||
child: Text(translate('Disable clipboard'))));
|
||||
}
|
||||
// lock after session end
|
||||
if (ffiModel.keyboard && !ffiModel.isPeerAndroid) {
|
||||
if (isDefaultConn && ffiModel.keyboard && !ffiModel.isPeerAndroid) {
|
||||
final enabled = !ffiModel.viewOnly;
|
||||
final option = 'lock-after-session-end';
|
||||
final value =
|
||||
@@ -656,12 +713,12 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
|
||||
child: Text(translate('True color (4:4:4)'))));
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
if (isDefaultConn && isMobile) {
|
||||
v.addAll(toolbarKeyboardToggles(ffi));
|
||||
}
|
||||
|
||||
// view mode (mobile only, desktop is in keyboard menu)
|
||||
if (isMobile && versionCmp(pi.version, '1.2.0') >= 0) {
|
||||
if (isDefaultConn && isMobile && versionCmp(pi.version, '1.2.0') >= 0) {
|
||||
v.add(TToggleMenu(
|
||||
value: ffiModel.viewOnly,
|
||||
onChanged: (value) async {
|
||||
|
||||
@@ -27,6 +27,7 @@ const String kPlatformAdditionsAmyuniVirtualDisplays =
|
||||
const String kPlatformAdditionsHasFileClipboard = "has_file_clipboard";
|
||||
const String kPlatformAdditionsSupportedPrivacyModeImpl =
|
||||
"supported_privacy_mode_impl";
|
||||
const String kPlatformAdditionsSupportViewCamera = "support_view_camera";
|
||||
|
||||
const String kPeerPlatformWindows = "Windows";
|
||||
const String kPeerPlatformLinux = "Linux";
|
||||
@@ -44,6 +45,7 @@ const String kAppTypeConnectionManager = "cm";
|
||||
|
||||
const String kAppTypeDesktopRemote = "remote";
|
||||
const String kAppTypeDesktopFileTransfer = "file transfer";
|
||||
const String kAppTypeDesktopViewCamera = "view camera";
|
||||
const String kAppTypeDesktopPortForward = "port forward";
|
||||
|
||||
const String kWindowMainWindowOnTop = "main_window_on_top";
|
||||
@@ -58,6 +60,7 @@ const String kWindowConnect = "connect";
|
||||
|
||||
const String kWindowEventNewRemoteDesktop = "new_remote_desktop";
|
||||
const String kWindowEventNewFileTransfer = "new_file_transfer";
|
||||
const String kWindowEventNewViewCamera = "new_view_camera";
|
||||
const String kWindowEventNewPortForward = "new_port_forward";
|
||||
const String kWindowEventActiveSession = "active_session";
|
||||
const String kWindowEventActiveDisplaySession = "active_display_session";
|
||||
@@ -75,6 +78,7 @@ const String kOptionScrollStyle = "scroll_style";
|
||||
const String kOptionImageQuality = "image_quality";
|
||||
const String kOptionOpenNewConnInTabs = "enable-open-new-connections-in-tabs";
|
||||
const String kOptionTextureRender = "use-texture-render";
|
||||
const String kOptionD3DRender = "allow-d3d-render";
|
||||
const String kOptionOpenInTabs = "allow-open-in-tabs";
|
||||
const String kOptionOpenInWindows = "allow-open-in-windows";
|
||||
const String kOptionForceAlwaysRelay = "force-always-relay";
|
||||
@@ -94,9 +98,11 @@ const String kOptionVideoSaveDirectory = "video-save-directory";
|
||||
const String kOptionAccessMode = "access-mode";
|
||||
const String kOptionEnableKeyboard = "enable-keyboard";
|
||||
// "Settings -> Security -> Permissions"
|
||||
const String kOptionEnableRemotePrinter = "enable-remote-printer";
|
||||
const String kOptionEnableClipboard = "enable-clipboard";
|
||||
const String kOptionEnableFileTransfer = "enable-file-transfer";
|
||||
const String kOptionEnableAudio = "enable-audio";
|
||||
const String kOptionEnableCamera = "enable-camera";
|
||||
const String kOptionEnableTunnel = "enable-tunnel";
|
||||
const String kOptionEnableRemoteRestart = "enable-remote-restart";
|
||||
const String kOptionEnableBlockInput = "enable-block-input";
|
||||
@@ -133,6 +139,7 @@ const String kOptionCurrentAbName = "current-ab-name";
|
||||
const String kOptionEnableConfirmClosingTabs = "enable-confirm-closing-tabs";
|
||||
const String kOptionAllowAlwaysSoftwareRender = "allow-always-software-render";
|
||||
const String kOptionEnableCheckUpdate = "enable-check-update";
|
||||
const String kOptionAllowAutoUpdate = "allow-auto-update";
|
||||
const String kOptionAllowLinuxHeadless = "allow-linux-headless";
|
||||
const String kOptionAllowRemoveWallpaper = "allow-remove-wallpaper";
|
||||
const String kOptionStopService = "stop-service";
|
||||
@@ -140,9 +147,14 @@ const String kOptionDirectxCapture = "enable-directx-capture";
|
||||
const String kOptionAllowRemoteCmModification = "allow-remote-cm-modification";
|
||||
const String kOptionEnableTrustedDevices = "enable-trusted-devices";
|
||||
|
||||
// network options
|
||||
const String kOptionAllowWebSocket = "allow-websocket";
|
||||
|
||||
// buildin opitons
|
||||
const String kOptionHideServerSetting = "hide-server-settings";
|
||||
const String kOptionHideProxySetting = "hide-proxy-settings";
|
||||
const String kOptionHideWebSocketSetting = "hide-websocket-settings";
|
||||
const String kOptionHideRemotePrinterSetting = "hide-remote-printer-settings";
|
||||
const String kOptionHideSecuritySetting = "hide-security-settings";
|
||||
const String kOptionHideNetworkSetting = "hide-network-settings";
|
||||
const String kOptionRemovePresetPasswordWarning =
|
||||
@@ -214,6 +226,21 @@ const double kDefaultQuality = 50;
|
||||
const double kMaxQuality = 100;
|
||||
const double kMaxMoreQuality = 2000;
|
||||
|
||||
// trackpad speed
|
||||
const String kKeyTrackpadSpeed = 'trackpad-speed';
|
||||
const int kMinTrackpadSpeed = 10;
|
||||
const int kDefaultTrackpadSpeed = 100;
|
||||
const int kMaxTrackpadSpeed = 1000;
|
||||
|
||||
// incomming (should be incoming) is kept, because change it will break the previous setting.
|
||||
const String kKeyPrinterIncomingJobAction = 'printer-incomming-job-action';
|
||||
const String kValuePrinterIncomingJobDismiss = 'dismiss';
|
||||
const String kValuePrinterIncomingJobDefault = '';
|
||||
const String kValuePrinterIncomingJobSelected = 'selected';
|
||||
const String kKeyPrinterSelected = 'printer-selected-name';
|
||||
const String kKeyPrinterSave = 'allow-printer-dialog-save';
|
||||
const String kKeyPrinterAllowAutoPrint = 'allow-printer-auto-print';
|
||||
|
||||
double kNewWindowOffset = isWindows
|
||||
? 56.0
|
||||
: isLinux
|
||||
@@ -248,7 +275,7 @@ const kFullScreenEdgeSize = 0.0;
|
||||
const kMaximizeEdgeSize = 0.0;
|
||||
// Do not use kWindowResizeEdgeSize directly. Use `windowResizeEdgeSize` in `common.dart` instead.
|
||||
const kWindowResizeEdgeSize = 5.0;
|
||||
const kWindowBorderWidth = 1.0;
|
||||
final kWindowBorderWidth = isWindows ? 0.0 : 1.0;
|
||||
const kDesktopMenuPadding = EdgeInsets.only(left: 12.0, right: 3.0);
|
||||
const kFrameBorderRadius = 12.0;
|
||||
const kFrameClipRRectBorderRadius = 12.0;
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hbb/common/widgets/connection_page_title.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/desktop/widgets/popup_menu.dart';
|
||||
import 'package:flutter_hbb/models/state_model.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
@@ -17,7 +19,7 @@ import '../../common/formatter/id_formatter.dart';
|
||||
import '../../common/widgets/peer_tab_page.dart';
|
||||
import '../../common/widgets/autocomplete.dart';
|
||||
import '../../models/platform_model.dart';
|
||||
import '../widgets/button.dart';
|
||||
import '../../desktop/widgets/material_mod_popup_menu.dart' as mod_menu;
|
||||
|
||||
class OnlineStatusWidget extends StatefulWidget {
|
||||
const OnlineStatusWidget({Key? key, this.onSvcStatusChanged})
|
||||
@@ -200,18 +202,25 @@ class _ConnectionPageState extends State<ConnectionPage>
|
||||
final _idController = IDTextEditingController();
|
||||
|
||||
final RxBool _idInputFocused = false.obs;
|
||||
final FocusNode _idFocusNode = FocusNode();
|
||||
final TextEditingController _idEditingController = TextEditingController();
|
||||
|
||||
String selectedConnectionType = 'Connect';
|
||||
|
||||
bool isWindowMinimized = false;
|
||||
List<Peer> peers = [];
|
||||
|
||||
bool isPeersLoading = false;
|
||||
bool isPeersLoaded = false;
|
||||
final AllPeersLoader _allPeersLoader = AllPeersLoader();
|
||||
|
||||
// https://github.com/flutter/flutter/issues/157244
|
||||
Iterable<Peer> _autocompleteOpts = [];
|
||||
|
||||
final _menuOpen = false.obs;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_allPeersLoader.init(setState);
|
||||
_idFocusNode.addListener(onFocusChanged);
|
||||
if (_idController.text.isEmpty) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
final lastRemoteId = await bind.mainGetLastRemoteId();
|
||||
@@ -222,6 +231,7 @@ class _ConnectionPageState extends State<ConnectionPage>
|
||||
}
|
||||
});
|
||||
}
|
||||
Get.put<TextEditingController>(_idEditingController);
|
||||
Get.put<IDTextEditingController>(_idController);
|
||||
windowManager.addListener(this);
|
||||
}
|
||||
@@ -230,6 +240,10 @@ class _ConnectionPageState extends State<ConnectionPage>
|
||||
void dispose() {
|
||||
_idController.dispose();
|
||||
windowManager.removeListener(this);
|
||||
_allPeersLoader.clear();
|
||||
_idFocusNode.removeListener(onFocusChanged);
|
||||
_idFocusNode.dispose();
|
||||
_idEditingController.dispose();
|
||||
if (Get.isRegistered<IDTextEditingController>()) {
|
||||
Get.delete<IDTextEditingController>();
|
||||
}
|
||||
@@ -273,6 +287,20 @@ class _ConnectionPageState extends State<ConnectionPage>
|
||||
bind.mainOnMainWindowClose();
|
||||
}
|
||||
|
||||
void onFocusChanged() {
|
||||
_idInputFocused.value = _idFocusNode.hasFocus;
|
||||
if (_idFocusNode.hasFocus) {
|
||||
if (_allPeersLoader.needLoad) {
|
||||
_allPeersLoader.getAllPeers();
|
||||
}
|
||||
|
||||
final textLength = _idEditingController.value.text.length;
|
||||
// Select all to facilitate removing text, just following the behavior of address input of chrome.
|
||||
_idEditingController.selection =
|
||||
TextSelection(baseOffset: 0, extentOffset: textLength);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isOutgoingOnly = bind.isOutgoingOnly();
|
||||
@@ -299,21 +327,10 @@ class _ConnectionPageState extends State<ConnectionPage>
|
||||
|
||||
/// Callback for the connect button.
|
||||
/// Connects to the selected peer.
|
||||
void onConnect({bool isFileTransfer = false}) {
|
||||
void onConnect({bool isFileTransfer = false, bool isViewCamera = false}) {
|
||||
var id = _idController.id;
|
||||
connect(context, id, isFileTransfer: isFileTransfer);
|
||||
}
|
||||
|
||||
Future<void> _fetchPeers() async {
|
||||
setState(() {
|
||||
isPeersLoading = true;
|
||||
});
|
||||
await Future.delayed(Duration(milliseconds: 100));
|
||||
peers = await getAllPeers();
|
||||
setState(() {
|
||||
isPeersLoading = false;
|
||||
isPeersLoaded = true;
|
||||
});
|
||||
connect(context, id,
|
||||
isFileTransfer: isFileTransfer, isViewCamera: isViewCamera);
|
||||
}
|
||||
|
||||
/// UI for the remote ID TextField.
|
||||
@@ -332,11 +349,12 @@ class _ConnectionPageState extends State<ConnectionPage>
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Autocomplete<Peer>(
|
||||
child: RawAutocomplete<Peer>(
|
||||
optionsBuilder: (TextEditingValue textEditingValue) {
|
||||
if (textEditingValue.text == '') {
|
||||
_autocompleteOpts = const Iterable<Peer>.empty();
|
||||
} else if (peers.isEmpty && !isPeersLoaded) {
|
||||
} else if (_allPeersLoader.peers.isEmpty &&
|
||||
!_allPeersLoader.isPeersLoaded) {
|
||||
Peer emptyPeer = Peer(
|
||||
id: '',
|
||||
username: '',
|
||||
@@ -350,6 +368,7 @@ class _ConnectionPageState extends State<ConnectionPage>
|
||||
rdpPort: '',
|
||||
rdpUsername: '',
|
||||
loginName: '',
|
||||
device_group_name: '',
|
||||
);
|
||||
_autocompleteOpts = [emptyPeer];
|
||||
} else {
|
||||
@@ -362,7 +381,7 @@ class _ConnectionPageState extends State<ConnectionPage>
|
||||
);
|
||||
}
|
||||
String textToFind = textEditingValue.text.toLowerCase();
|
||||
_autocompleteOpts = peers
|
||||
_autocompleteOpts = _allPeersLoader.peers
|
||||
.where((peer) =>
|
||||
peer.id.toLowerCase().contains(textToFind) ||
|
||||
peer.username
|
||||
@@ -376,25 +395,16 @@ class _ConnectionPageState extends State<ConnectionPage>
|
||||
}
|
||||
return _autocompleteOpts;
|
||||
},
|
||||
focusNode: _idFocusNode,
|
||||
textEditingController: _idEditingController,
|
||||
fieldViewBuilder: (
|
||||
BuildContext context,
|
||||
TextEditingController fieldTextEditingController,
|
||||
FocusNode fieldFocusNode,
|
||||
VoidCallback onFieldSubmitted,
|
||||
) {
|
||||
fieldTextEditingController.text = _idController.text;
|
||||
Get.put<TextEditingController>(fieldTextEditingController);
|
||||
fieldFocusNode.addListener(() async {
|
||||
_idInputFocused.value = fieldFocusNode.hasFocus;
|
||||
if (fieldFocusNode.hasFocus && !isPeersLoading) {
|
||||
_fetchPeers();
|
||||
}
|
||||
});
|
||||
final textLength =
|
||||
fieldTextEditingController.value.text.length;
|
||||
// select all to facilitate removing text, just following the behavior of address input of chrome
|
||||
fieldTextEditingController.selection =
|
||||
TextSelection(baseOffset: 0, extentOffset: textLength);
|
||||
updateTextAndPreserveSelection(
|
||||
fieldTextEditingController, _idController.text);
|
||||
return Obx(() => TextField(
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
@@ -467,7 +477,8 @@ class _ConnectionPageState extends State<ConnectionPage>
|
||||
maxHeight: maxHeight,
|
||||
maxWidth: 319,
|
||||
),
|
||||
child: peers.isEmpty && isPeersLoading
|
||||
child: _allPeersLoader.peers.isEmpty &&
|
||||
!_allPeersLoader.isPeersLoaded
|
||||
? Container(
|
||||
height: 80,
|
||||
child: Center(
|
||||
@@ -497,21 +508,87 @@ class _ConnectionPageState extends State<ConnectionPage>
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 13.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Button(
|
||||
isOutline: true,
|
||||
onTap: () => onConnect(isFileTransfer: true),
|
||||
text: "Transfer file",
|
||||
child: Row(mainAxisAlignment: MainAxisAlignment.end, children: [
|
||||
SizedBox(
|
||||
height: 28.0,
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
onConnect();
|
||||
},
|
||||
child: Text(translate("Connect")),
|
||||
),
|
||||
const SizedBox(
|
||||
width: 17,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
height: 28.0,
|
||||
width: 28.0,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Theme.of(context).dividerColor),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
Button(onTap: onConnect, text: "Connect"),
|
||||
],
|
||||
),
|
||||
)
|
||||
child: Center(
|
||||
child: Obx(() {
|
||||
var offset = Offset(0, 0);
|
||||
return InkWell(
|
||||
child: _menuOpen.value
|
||||
? Transform.rotate(
|
||||
angle: pi,
|
||||
child: Icon(IconFont.more, size: 14),
|
||||
)
|
||||
: Icon(IconFont.more, size: 14),
|
||||
onTapDown: (e) {
|
||||
offset = e.globalPosition;
|
||||
},
|
||||
onTap: () async {
|
||||
_menuOpen.value = true;
|
||||
final x = offset.dx;
|
||||
final y = offset.dy;
|
||||
await mod_menu
|
||||
.showMenu(
|
||||
context: context,
|
||||
position: RelativeRect.fromLTRB(x, y, x, y),
|
||||
items: [
|
||||
(
|
||||
'Transfer file',
|
||||
() => onConnect(isFileTransfer: true)
|
||||
),
|
||||
(
|
||||
'View camera',
|
||||
() => onConnect(isViewCamera: true)
|
||||
),
|
||||
]
|
||||
.map((e) => MenuEntryButton<String>(
|
||||
childBuilder: (TextStyle? style) => Text(
|
||||
translate(e.$1),
|
||||
style: style,
|
||||
),
|
||||
proc: () => e.$2(),
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: kDesktopMenuPadding.left),
|
||||
dismissOnClicked: true,
|
||||
))
|
||||
.map((e) => e.build(
|
||||
context,
|
||||
const MenuConfig(
|
||||
commonColor:
|
||||
CustomPopupMenuTheme.commonColor,
|
||||
height: CustomPopupMenuTheme.height,
|
||||
dividerHeight: CustomPopupMenuTheme
|
||||
.dividerHeight)))
|
||||
.expand((i) => i)
|
||||
.toList(),
|
||||
elevation: 8,
|
||||
)
|
||||
.then((_) {
|
||||
_menuOpen.value = false;
|
||||
});
|
||||
},
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -12,6 +12,7 @@ import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/desktop/pages/connection_page.dart';
|
||||
import 'package:flutter_hbb/desktop/pages/desktop_setting_page.dart';
|
||||
import 'package:flutter_hbb/desktop/pages/desktop_tab_page.dart';
|
||||
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';
|
||||
@@ -22,7 +23,6 @@ import 'package:provider/provider.dart';
|
||||
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';
|
||||
|
||||
class DesktopHomePage extends StatefulWidget {
|
||||
@@ -134,12 +134,17 @@ class _DesktopHomePageState extends State<DesktopHomePage>
|
||||
color: Theme.of(context).colorScheme.background,
|
||||
child: Stack(
|
||||
children: [
|
||||
SingleChildScrollView(
|
||||
controller: _leftPaneScrollController,
|
||||
child: Column(
|
||||
key: _childKey,
|
||||
children: children,
|
||||
),
|
||||
Column(
|
||||
children: [
|
||||
SingleChildScrollView(
|
||||
controller: _leftPaneScrollController,
|
||||
child: Column(
|
||||
key: _childKey,
|
||||
children: children,
|
||||
),
|
||||
),
|
||||
Expanded(child: Container())
|
||||
],
|
||||
),
|
||||
if (isOutgoingOnly)
|
||||
Positioned(
|
||||
@@ -428,13 +433,23 @@ class _DesktopHomePageState extends State<DesktopHomePage>
|
||||
updateUrl.isNotEmpty &&
|
||||
!isCardClosed &&
|
||||
bind.mainUriPrefixSync().contains('rustdesk')) {
|
||||
final isToUpdate = (isWindows || isMacOS) && bind.mainIsInstalled();
|
||||
String btnText = isToUpdate ? 'Click to update' : 'Click to download';
|
||||
GestureTapCallback onPressed = () async {
|
||||
final Uri url = Uri.parse('https://rustdesk.com/download');
|
||||
await launchUrl(url);
|
||||
};
|
||||
if (isToUpdate) {
|
||||
onPressed = () {
|
||||
handleUpdate(updateUrl);
|
||||
};
|
||||
}
|
||||
return buildInstallCard(
|
||||
"Status",
|
||||
"${translate("new-version-of-{${bind.mainGetAppNameSync()}}-tip")} (${bind.mainGetNewVersion()}).",
|
||||
"Click to download", () async {
|
||||
final Uri url = Uri.parse('https://rustdesk.com/download');
|
||||
await launchUrl(url);
|
||||
}, closeButton: true);
|
||||
btnText,
|
||||
onPressed,
|
||||
closeButton: true);
|
||||
}
|
||||
if (systemError.isNotEmpty) {
|
||||
return buildInstallCard("", systemError, "", () {});
|
||||
@@ -770,6 +785,7 @@ class _DesktopHomePageState extends State<DesktopHomePage>
|
||||
await connectMainDesktop(
|
||||
call.arguments['id'],
|
||||
isFileTransfer: call.arguments['isFileTransfer'],
|
||||
isViewCamera: call.arguments['isViewCamera'],
|
||||
isTcpTunneling: call.arguments['isTcpTunneling'],
|
||||
isRDP: call.arguments['isRDP'],
|
||||
password: call.arguments['password'],
|
||||
@@ -784,9 +800,15 @@ class _DesktopHomePageState extends State<DesktopHomePage>
|
||||
} catch (e) {
|
||||
debugPrint("Failed to parse window id '${call.arguments}': $e");
|
||||
}
|
||||
if (windowId != null) {
|
||||
WindowType? windowType;
|
||||
try {
|
||||
windowType = WindowType.values.byName(args[3]);
|
||||
} catch (e) {
|
||||
debugPrint("Failed to parse window type '${call.arguments}': $e");
|
||||
}
|
||||
if (windowId != null && windowType != null) {
|
||||
await rustDeskWinManager.moveTabToNewWindow(
|
||||
windowId, args[1], args[2]);
|
||||
windowId, args[1], args[2], windowType);
|
||||
}
|
||||
} else if (call.method == kWindowEventOpenMonitorSession) {
|
||||
final args = jsonDecode(call.arguments);
|
||||
@@ -794,9 +816,10 @@ class _DesktopHomePageState extends State<DesktopHomePage>
|
||||
final peerId = args['peer_id'] as String;
|
||||
final display = args['display'] as int;
|
||||
final displayCount = args['display_count'] as int;
|
||||
final windowType = args['window_type'] as int;
|
||||
final screenRect = parseParamScreenRect(args);
|
||||
await rustDeskWinManager.openMonitorSession(
|
||||
windowId, peerId, display, displayCount, screenRect);
|
||||
windowId, peerId, display, displayCount, screenRect, windowType);
|
||||
} else if (call.method == kWindowEventRemoteWindowCoords) {
|
||||
final windowId = int.tryParse(call.arguments);
|
||||
if (windowId != null) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import 'package:flutter_hbb/desktop/pages/desktop_home_page.dart';
|
||||
import 'package:flutter_hbb/desktop/pages/desktop_tab_page.dart';
|
||||
import 'package:flutter_hbb/mobile/widgets/dialog.dart';
|
||||
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';
|
||||
@@ -55,6 +56,7 @@ enum SettingsTabKey {
|
||||
display,
|
||||
plugin,
|
||||
account,
|
||||
printer,
|
||||
about,
|
||||
}
|
||||
|
||||
@@ -74,6 +76,9 @@ class DesktopSettingPage extends StatefulWidget {
|
||||
if (!isWeb && !bind.isIncomingOnly() && bind.pluginFeatureIsEnabled())
|
||||
SettingsTabKey.plugin,
|
||||
if (!bind.isDisableAccount()) SettingsTabKey.account,
|
||||
if (isWindows &&
|
||||
bind.mainGetBuildinOption(key: kOptionHideRemotePrinterSetting) != 'Y')
|
||||
SettingsTabKey.printer,
|
||||
SettingsTabKey.about,
|
||||
];
|
||||
|
||||
@@ -198,6 +203,10 @@ class _DesktopSettingPageState extends State<DesktopSettingPage>
|
||||
settingTabs.add(
|
||||
_TabInfo(tab, 'Account', Icons.person_outline, Icons.person));
|
||||
break;
|
||||
case SettingsTabKey.printer:
|
||||
settingTabs
|
||||
.add(_TabInfo(tab, 'Printer', Icons.print_outlined, Icons.print));
|
||||
break;
|
||||
case SettingsTabKey.about:
|
||||
settingTabs
|
||||
.add(_TabInfo(tab, 'About', Icons.info_outline, Icons.info));
|
||||
@@ -229,6 +238,9 @@ class _DesktopSettingPageState extends State<DesktopSettingPage>
|
||||
case SettingsTabKey.account:
|
||||
children.add(const _Account());
|
||||
break;
|
||||
case SettingsTabKey.printer:
|
||||
children.add(const _Printer());
|
||||
break;
|
||||
case SettingsTabKey.about:
|
||||
children.add(const _About());
|
||||
break;
|
||||
@@ -460,6 +472,8 @@ class _GeneralState extends State<_General> {
|
||||
}
|
||||
|
||||
Widget other() {
|
||||
final showAutoUpdate =
|
||||
isWindows && bind.mainIsInstalled() && !bind.isCustomClient();
|
||||
final children = <Widget>[
|
||||
if (!isWeb && !bind.isIncomingOnly())
|
||||
_OptionCheckBox(context, 'Confirm before closing multiple tabs',
|
||||
@@ -496,6 +510,16 @@ class _GeneralState extends State<_General> {
|
||||
await bind.mainSetLocalOption(key: k, value: v ? 'Y' : 'N'),
|
||||
),
|
||||
),
|
||||
if (isWindows)
|
||||
Tooltip(
|
||||
message: translate('d3d_render_tip'),
|
||||
child: _OptionCheckBox(
|
||||
context,
|
||||
"Use D3D rendering",
|
||||
kOptionD3DRender,
|
||||
isServer: false,
|
||||
),
|
||||
),
|
||||
if (!isWeb && !bind.isCustomClient())
|
||||
_OptionCheckBox(
|
||||
context,
|
||||
@@ -503,12 +527,19 @@ class _GeneralState extends State<_General> {
|
||||
kOptionEnableCheckUpdate,
|
||||
isServer: false,
|
||||
),
|
||||
if (showAutoUpdate)
|
||||
_OptionCheckBox(
|
||||
context,
|
||||
'Auto update',
|
||||
kOptionAllowAutoUpdate,
|
||||
isServer: true,
|
||||
),
|
||||
if (isWindows && !bind.isOutgoingOnly())
|
||||
_OptionCheckBox(
|
||||
context,
|
||||
'Capture screen using DirectX',
|
||||
kOptionDirectxCapture,
|
||||
)
|
||||
),
|
||||
],
|
||||
];
|
||||
if (!isWeb && bind.mainShowOption(key: kOptionAllowLinuxHeadless)) {
|
||||
@@ -953,6 +984,10 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
|
||||
_OptionCheckBox(
|
||||
context, 'Enable keyboard/mouse', kOptionEnableKeyboard,
|
||||
enabled: enabled, fakeValue: fakeValue),
|
||||
if (isWindows)
|
||||
_OptionCheckBox(
|
||||
context, 'Enable remote printer', kOptionEnableRemotePrinter,
|
||||
enabled: enabled, fakeValue: fakeValue),
|
||||
_OptionCheckBox(context, 'Enable clipboard', kOptionEnableClipboard,
|
||||
enabled: enabled, fakeValue: fakeValue),
|
||||
_OptionCheckBox(
|
||||
@@ -960,6 +995,8 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
|
||||
enabled: enabled, fakeValue: fakeValue),
|
||||
_OptionCheckBox(context, 'Enable audio', kOptionEnableAudio,
|
||||
enabled: enabled, fakeValue: fakeValue),
|
||||
_OptionCheckBox(context, 'Enable camera', kOptionEnableCamera,
|
||||
enabled: enabled, fakeValue: fakeValue),
|
||||
_OptionCheckBox(
|
||||
context, 'Enable TCP tunneling', kOptionEnableTunnel,
|
||||
enabled: enabled, fakeValue: fakeValue),
|
||||
@@ -1440,11 +1477,70 @@ class _NetworkState extends State<_Network> with AutomaticKeepAliveClientMixin {
|
||||
bind.mainGetBuildinOption(key: kOptionHideServerSetting) == 'Y';
|
||||
final hideProxy =
|
||||
isWeb || bind.mainGetBuildinOption(key: kOptionHideProxySetting) == 'Y';
|
||||
// final hideWebSocket = isWeb ||
|
||||
// bind.mainGetBuildinOption(key: kOptionHideWebSocketSetting) == 'Y';
|
||||
final hideWebSocket = true;
|
||||
|
||||
if (hideServer && hideProxy) {
|
||||
if (hideServer && hideProxy && hideWebSocket) {
|
||||
return Offstage();
|
||||
}
|
||||
|
||||
// Helper function to create network setting ListTiles
|
||||
Widget listTile({
|
||||
required IconData icon,
|
||||
required String title,
|
||||
VoidCallback? onTap,
|
||||
Widget? trailing,
|
||||
bool showTooltip = false,
|
||||
String tooltipMessage = '',
|
||||
}) {
|
||||
final titleWidget = showTooltip
|
||||
? Row(
|
||||
children: [
|
||||
Tooltip(
|
||||
waitDuration: Duration(milliseconds: 1000),
|
||||
message: translate(tooltipMessage),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
translate(title),
|
||||
style: TextStyle(fontSize: _kContentFontSize),
|
||||
),
|
||||
SizedBox(width: 5),
|
||||
Icon(
|
||||
Icons.help_outline,
|
||||
size: 14,
|
||||
color: Theme.of(context)
|
||||
.textTheme
|
||||
.titleLarge
|
||||
?.color
|
||||
?.withOpacity(0.7),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Text(
|
||||
translate(title),
|
||||
style: TextStyle(fontSize: _kContentFontSize),
|
||||
);
|
||||
|
||||
return ListTile(
|
||||
leading: Icon(icon, color: _accentColor),
|
||||
title: titleWidget,
|
||||
enabled: !locked,
|
||||
onTap: onTap,
|
||||
trailing: trailing,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 16),
|
||||
minLeadingWidth: 0,
|
||||
horizontalTitleGap: 10,
|
||||
);
|
||||
}
|
||||
|
||||
return _Card(
|
||||
title: 'Network',
|
||||
children: [
|
||||
@@ -1453,39 +1549,36 @@ class _NetworkState extends State<_Network> with AutomaticKeepAliveClientMixin {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!hideServer)
|
||||
ListTile(
|
||||
leading: Icon(Icons.dns_outlined, color: _accentColor),
|
||||
title: Text(
|
||||
translate('ID/Relay Server'),
|
||||
style: TextStyle(fontSize: _kContentFontSize),
|
||||
),
|
||||
enabled: !locked,
|
||||
listTile(
|
||||
icon: Icons.dns_outlined,
|
||||
title: 'ID/Relay Server',
|
||||
onTap: () => showServerSettings(gFFI.dialogManager),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 16),
|
||||
minLeadingWidth: 0,
|
||||
horizontalTitleGap: 10,
|
||||
),
|
||||
if (!hideServer && !hideProxy)
|
||||
if (!hideServer && (!hideProxy || !hideWebSocket))
|
||||
Divider(height: 1, indent: 16, endIndent: 16),
|
||||
if (!hideProxy)
|
||||
ListTile(
|
||||
leading:
|
||||
Icon(Icons.network_ping_outlined, color: _accentColor),
|
||||
title: Text(
|
||||
translate('Socks5/Http(s) Proxy'),
|
||||
style: TextStyle(fontSize: _kContentFontSize),
|
||||
),
|
||||
enabled: !locked,
|
||||
listTile(
|
||||
icon: Icons.network_ping_outlined,
|
||||
title: 'Socks5/Http(s) Proxy',
|
||||
onTap: changeSocks5Proxy,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
if (!hideProxy && !hideWebSocket)
|
||||
Divider(height: 1, indent: 16, endIndent: 16),
|
||||
if (!hideWebSocket)
|
||||
listTile(
|
||||
icon: Icons.web_asset_outlined,
|
||||
title: 'Use WebSocket',
|
||||
showTooltip: true,
|
||||
tooltipMessage: 'websocket_tip',
|
||||
trailing: Switch(
|
||||
value: mainGetBoolOptionSync(kOptionAllowWebSocket),
|
||||
onChanged: locked
|
||||
? null
|
||||
: (value) {
|
||||
mainSetBoolOption(kOptionAllowWebSocket, value);
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 16),
|
||||
minLeadingWidth: 0,
|
||||
horizontalTitleGap: 10,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -1511,6 +1604,7 @@ class _DisplayState extends State<_Display> {
|
||||
scrollStyle(context),
|
||||
imageQuality(context),
|
||||
codec(context),
|
||||
if (isDesktop) trackpadSpeed(context),
|
||||
if (!isWeb) privacyModeImpl(context),
|
||||
other(context),
|
||||
]).marginOnly(bottom: _kListViewBottomMargin);
|
||||
@@ -1598,6 +1692,26 @@ class _DisplayState extends State<_Display> {
|
||||
]);
|
||||
}
|
||||
|
||||
Widget trackpadSpeed(BuildContext context) {
|
||||
final initSpeed = (int.tryParse(
|
||||
bind.mainGetUserDefaultOption(key: kKeyTrackpadSpeed)) ??
|
||||
kDefaultTrackpadSpeed);
|
||||
final curSpeed = SimpleWrapper(initSpeed);
|
||||
void onDebouncer(int v) {
|
||||
bind.mainSetUserDefaultOption(
|
||||
key: kKeyTrackpadSpeed, value: v.toString());
|
||||
// It's better to notify all sessions that the default speed is changed.
|
||||
// But it may also be ok to take effect in the next connection.
|
||||
}
|
||||
|
||||
return _Card(title: 'Default trackpad speed', children: [
|
||||
TrackpadSpeedWidget(
|
||||
value: curSpeed,
|
||||
onDebouncer: onDebouncer,
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
Widget codec(BuildContext context) {
|
||||
onChanged(String value) async {
|
||||
await bind.mainSetUserDefaultOption(
|
||||
@@ -1869,6 +1983,153 @@ class _PluginState extends State<_Plugin> {
|
||||
}
|
||||
}
|
||||
|
||||
class _Printer extends StatefulWidget {
|
||||
const _Printer({super.key});
|
||||
|
||||
@override
|
||||
State<_Printer> createState() => __PrinterState();
|
||||
}
|
||||
|
||||
class __PrinterState extends State<_Printer> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scrollController = ScrollController();
|
||||
return ListView(controller: scrollController, children: [
|
||||
outgoing(context),
|
||||
incoming(context),
|
||||
]).marginOnly(bottom: _kListViewBottomMargin);
|
||||
}
|
||||
|
||||
Widget outgoing(BuildContext context) {
|
||||
final isSupportPrinterDriver =
|
||||
bind.mainGetCommonSync(key: 'is-support-printer-driver') == 'true';
|
||||
|
||||
Widget tipOsNotSupported() {
|
||||
return Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: Text(translate('printer-os-requirement-tip')),
|
||||
).marginOnly(left: _kCardLeftMargin);
|
||||
}
|
||||
|
||||
Widget tipClientNotInstalled() {
|
||||
return Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child:
|
||||
Text(translate('printer-requires-installed-{$appName}-client-tip')),
|
||||
).marginOnly(left: _kCardLeftMargin);
|
||||
}
|
||||
|
||||
Widget tipPrinterNotInstalled() {
|
||||
final failedMsg = ''.obs;
|
||||
platformFFI.registerEventHandler(
|
||||
'install-printer-res', 'install-printer-res', (evt) async {
|
||||
if (evt['success'] as bool) {
|
||||
setState(() {});
|
||||
} else {
|
||||
failedMsg.value = evt['msg'] as String;
|
||||
}
|
||||
}, replace: true);
|
||||
return Column(children: [
|
||||
Obx(
|
||||
() => failedMsg.value.isNotEmpty
|
||||
? Offstage()
|
||||
: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: Text(translate('printer-{$appName}-not-installed-tip'))
|
||||
.marginOnly(bottom: 10.0),
|
||||
),
|
||||
),
|
||||
Obx(
|
||||
() => failedMsg.value.isEmpty
|
||||
? Offstage()
|
||||
: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: Text(failedMsg.value,
|
||||
style: DefaultTextStyle.of(context)
|
||||
.style
|
||||
.copyWith(color: Colors.red))
|
||||
.marginOnly(bottom: 10.0)),
|
||||
),
|
||||
_Button('Install {$appName} Printer', () {
|
||||
failedMsg.value = '';
|
||||
bind.mainSetCommon(key: 'install-printer', value: '');
|
||||
})
|
||||
]).marginOnly(left: _kCardLeftMargin, bottom: 2.0);
|
||||
}
|
||||
|
||||
Widget tipReady() {
|
||||
return Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: Text(translate('printer-{$appName}-ready-tip')),
|
||||
).marginOnly(left: _kCardLeftMargin);
|
||||
}
|
||||
|
||||
final installed = bind.mainIsInstalled();
|
||||
// `is-printer-installed` may fail, but it's rare case.
|
||||
// Add additional error message here if it's really needed.
|
||||
final isPrinterInstalled =
|
||||
bind.mainGetCommonSync(key: 'is-printer-installed') == 'true';
|
||||
|
||||
final List<Widget> children = [];
|
||||
if (!isSupportPrinterDriver) {
|
||||
children.add(tipOsNotSupported());
|
||||
} else {
|
||||
children.addAll([
|
||||
if (!installed) tipClientNotInstalled(),
|
||||
if (installed && !isPrinterInstalled) tipPrinterNotInstalled(),
|
||||
if (installed && isPrinterInstalled) tipReady()
|
||||
]);
|
||||
}
|
||||
return _Card(title: 'Outgoing Print Jobs', children: children);
|
||||
}
|
||||
|
||||
Widget incoming(BuildContext context) {
|
||||
onRadioChanged(String value) async {
|
||||
await bind.mainSetLocalOption(
|
||||
key: kKeyPrinterIncomingJobAction, value: value);
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
PrinterOptions printerOptions = PrinterOptions.load();
|
||||
return _Card(title: 'Incoming Print Jobs', children: [
|
||||
_Radio(context,
|
||||
value: kValuePrinterIncomingJobDismiss,
|
||||
groupValue: printerOptions.action,
|
||||
label: 'Dismiss',
|
||||
onChanged: onRadioChanged),
|
||||
_Radio(context,
|
||||
value: kValuePrinterIncomingJobDefault,
|
||||
groupValue: printerOptions.action,
|
||||
label: 'use-the-default-printer-tip',
|
||||
onChanged: onRadioChanged),
|
||||
_Radio(context,
|
||||
value: kValuePrinterIncomingJobSelected,
|
||||
groupValue: printerOptions.action,
|
||||
label: 'use-the-selected-printer-tip',
|
||||
onChanged: onRadioChanged),
|
||||
if (printerOptions.printerNames.isNotEmpty)
|
||||
ComboBox(
|
||||
initialKey: printerOptions.printerName,
|
||||
keys: printerOptions.printerNames,
|
||||
values: printerOptions.printerNames,
|
||||
enabled: printerOptions.action == kValuePrinterIncomingJobSelected,
|
||||
onChanged: (value) async {
|
||||
await bind.mainSetLocalOption(
|
||||
key: kKeyPrinterSelected, value: value);
|
||||
setState(() {});
|
||||
},
|
||||
).marginOnly(left: 10),
|
||||
_OptionCheckBox(
|
||||
context,
|
||||
'auto-print-tip',
|
||||
kKeyPrinterAllowAutoPrint,
|
||||
isServer: false,
|
||||
enabled: printerOptions.action != kValuePrinterIncomingJobDismiss,
|
||||
)
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
class _About extends StatefulWidget {
|
||||
const _About({Key? key}) : super(key: key);
|
||||
|
||||
|
||||
@@ -103,11 +103,13 @@ class _FileManagerTabPageState extends State<FileManagerTabPage> {
|
||||
));
|
||||
final tabWidget = isLinux
|
||||
? buildVirtualWindowFrame(context, child)
|
||||
: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: MyTheme.color(context).border!)),
|
||||
child: child,
|
||||
);
|
||||
: workaroundWindowBorder(
|
||||
context,
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: MyTheme.color(context).border!)),
|
||||
child: child,
|
||||
));
|
||||
return isMacOS || kUseCompatibleUiMode
|
||||
? tabWidget
|
||||
: SubWindowDragToResizeArea(
|
||||
|
||||
@@ -65,6 +65,7 @@ class _InstallPageBodyState extends State<_InstallPageBody>
|
||||
late final TextEditingController controller;
|
||||
final RxBool startmenu = true.obs;
|
||||
final RxBool desktopicon = true.obs;
|
||||
final RxBool printer = true.obs;
|
||||
final RxBool showProgress = false.obs;
|
||||
final RxBool btnEnabled = true.obs;
|
||||
|
||||
@@ -79,6 +80,7 @@ class _InstallPageBodyState extends State<_InstallPageBody>
|
||||
final installOptions = jsonDecode(bind.installInstallOptions());
|
||||
startmenu.value = installOptions['STARTMENUSHORTCUTS'] != '0';
|
||||
desktopicon.value = installOptions['DESKTOPSHORTCUTS'] != '0';
|
||||
printer.value = installOptions['PRINTER'] != '0';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -161,7 +163,9 @@ class _InstallPageBodyState extends State<_InstallPageBody>
|
||||
).marginSymmetric(vertical: 2 * em),
|
||||
Option(startmenu, label: 'Create start menu shortcuts')
|
||||
.marginOnly(bottom: 7),
|
||||
Option(desktopicon, label: 'Create desktop icon'),
|
||||
Option(desktopicon, label: 'Create desktop icon')
|
||||
.marginOnly(bottom: 7),
|
||||
Option(printer, label: 'Install {$appName} Printer'),
|
||||
Container(
|
||||
padding: EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
@@ -253,6 +257,7 @@ class _InstallPageBodyState extends State<_InstallPageBody>
|
||||
String args = '';
|
||||
if (startmenu.value) args += ' startmenu';
|
||||
if (desktopicon.value) args += ' desktopicon';
|
||||
if (printer.value) args += ' printer';
|
||||
bind.installInstallMe(options: args, path: controller.text);
|
||||
}
|
||||
|
||||
|
||||
@@ -118,11 +118,13 @@ class _PortForwardTabPageState extends State<PortForwardTabPage> {
|
||||
backgroundColor: Theme.of(context).colorScheme.background,
|
||||
body: child),
|
||||
)
|
||||
: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: MyTheme.color(context).border!)),
|
||||
child: child,
|
||||
);
|
||||
: workaroundWindowBorder(
|
||||
context,
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: MyTheme.color(context).border!)),
|
||||
child: child,
|
||||
));
|
||||
return isMacOS || kUseCompatibleUiMode
|
||||
? tabWidget
|
||||
: Obx(
|
||||
|
||||
@@ -212,14 +212,16 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
|
||||
);
|
||||
final tabWidget = isLinux
|
||||
? buildVirtualWindowFrame(context, child)
|
||||
: Obx(() => Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: MyTheme.color(context).border!,
|
||||
width: stateGlobal.windowBorderWidth.value),
|
||||
),
|
||||
child: child,
|
||||
));
|
||||
: workaroundWindowBorder(
|
||||
context,
|
||||
Obx(() => Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: MyTheme.color(context).border!,
|
||||
width: stateGlobal.windowBorderWidth.value),
|
||||
),
|
||||
child: child,
|
||||
)));
|
||||
return isMacOS || kUseCompatibleUiMode
|
||||
? tabWidget
|
||||
: Obx(() => SubWindowDragToResizeArea(
|
||||
@@ -267,8 +269,10 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
|
||||
style: style,
|
||||
),
|
||||
proc: () async {
|
||||
await DesktopMultiWindow.invokeMethod(kMainWindowId,
|
||||
kWindowEventMoveTabToNewWindow, '${windowId()},$key,$sessionId');
|
||||
await DesktopMultiWindow.invokeMethod(
|
||||
kMainWindowId,
|
||||
kWindowEventMoveTabToNewWindow,
|
||||
'${windowId()},$key,$sessionId,RemoteDesktop');
|
||||
cancelFunc();
|
||||
},
|
||||
padding: padding,
|
||||
@@ -415,8 +419,8 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
|
||||
await WindowController.fromWindowId(windowId()).setFullscreen(false);
|
||||
stateGlobal.setFullscreen(false, procWnd: false);
|
||||
}
|
||||
await setNewConnectWindowFrame(
|
||||
windowId(), id!, prePeerCount, display, screenRect);
|
||||
await setNewConnectWindowFrame(windowId(), id!, prePeerCount,
|
||||
WindowType.RemoteDesktop, display, screenRect);
|
||||
Future.delayed(Duration(milliseconds: isWindows ? 100 : 0), () async {
|
||||
await windowOnTop(windowId());
|
||||
});
|
||||
|
||||
@@ -88,12 +88,14 @@ class _DesktopServerPageState extends State<DesktopServerPage>
|
||||
);
|
||||
return isLinux
|
||||
? buildVirtualWindowFrame(context, body)
|
||||
: Container(
|
||||
decoration: BoxDecoration(
|
||||
border:
|
||||
Border.all(color: MyTheme.color(context).border!)),
|
||||
child: body,
|
||||
);
|
||||
: workaroundWindowBorder(
|
||||
context,
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
border:
|
||||
Border.all(color: MyTheme.color(context).border!)),
|
||||
child: body,
|
||||
));
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -351,7 +353,9 @@ Widget buildConnectionCard(Client client) {
|
||||
key: ValueKey(client.id),
|
||||
children: [
|
||||
_CmHeader(client: client),
|
||||
client.type_() != ClientType.remote || client.disconnected
|
||||
client.type_() == ClientType.file ||
|
||||
client.type_() == ClientType.portForward ||
|
||||
client.disconnected
|
||||
? Offstage()
|
||||
: _PrivilegeBoard(client: client),
|
||||
Expanded(
|
||||
@@ -524,7 +528,8 @@ class _CmHeaderState extends State<_CmHeader>
|
||||
Offstage(
|
||||
offstage: !client.authorized ||
|
||||
(client.type_() != ClientType.remote &&
|
||||
client.type_() != ClientType.file),
|
||||
client.type_() != ClientType.file &&
|
||||
client.type_() != ClientType.camera),
|
||||
child: IconButton(
|
||||
onPressed: () => checkClickTime(client.id, () {
|
||||
if (client.type_() == ClientType.file) {
|
||||
@@ -625,96 +630,139 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
||||
padding: EdgeInsets.symmetric(horizontal: spacing),
|
||||
mainAxisSpacing: spacing,
|
||||
crossAxisSpacing: spacing,
|
||||
children: [
|
||||
buildPermissionIcon(
|
||||
client.keyboard,
|
||||
Icons.keyboard,
|
||||
(enabled) {
|
||||
bind.cmSwitchPermission(
|
||||
connId: client.id, name: "keyboard", enabled: enabled);
|
||||
setState(() {
|
||||
client.keyboard = enabled;
|
||||
});
|
||||
},
|
||||
translate('Enable keyboard/mouse'),
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.clipboard,
|
||||
Icons.assignment_rounded,
|
||||
(enabled) {
|
||||
bind.cmSwitchPermission(
|
||||
connId: client.id, name: "clipboard", enabled: enabled);
|
||||
setState(() {
|
||||
client.clipboard = enabled;
|
||||
});
|
||||
},
|
||||
translate('Enable clipboard'),
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.audio,
|
||||
Icons.volume_up_rounded,
|
||||
(enabled) {
|
||||
bind.cmSwitchPermission(
|
||||
connId: client.id, name: "audio", enabled: enabled);
|
||||
setState(() {
|
||||
client.audio = enabled;
|
||||
});
|
||||
},
|
||||
translate('Enable audio'),
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.file,
|
||||
Icons.upload_file_rounded,
|
||||
(enabled) {
|
||||
bind.cmSwitchPermission(
|
||||
connId: client.id, name: "file", enabled: enabled);
|
||||
setState(() {
|
||||
client.file = enabled;
|
||||
});
|
||||
},
|
||||
translate('Enable file copy and paste'),
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.restart,
|
||||
Icons.restart_alt_rounded,
|
||||
(enabled) {
|
||||
bind.cmSwitchPermission(
|
||||
connId: client.id, name: "restart", enabled: enabled);
|
||||
setState(() {
|
||||
client.restart = enabled;
|
||||
});
|
||||
},
|
||||
translate('Enable remote restart'),
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.recording,
|
||||
Icons.videocam_rounded,
|
||||
(enabled) {
|
||||
bind.cmSwitchPermission(
|
||||
connId: client.id, name: "recording", enabled: enabled);
|
||||
setState(() {
|
||||
client.recording = enabled;
|
||||
});
|
||||
},
|
||||
translate('Enable recording session'),
|
||||
),
|
||||
// only windows support block input
|
||||
if (isWindows)
|
||||
buildPermissionIcon(
|
||||
client.blockInput,
|
||||
Icons.block,
|
||||
(enabled) {
|
||||
bind.cmSwitchPermission(
|
||||
connId: client.id,
|
||||
name: "block_input",
|
||||
enabled: enabled);
|
||||
setState(() {
|
||||
client.blockInput = enabled;
|
||||
});
|
||||
},
|
||||
translate('Enable blocking user input'),
|
||||
)
|
||||
],
|
||||
children: client.type_() == ClientType.camera
|
||||
? [
|
||||
buildPermissionIcon(
|
||||
client.audio,
|
||||
Icons.volume_up_rounded,
|
||||
(enabled) {
|
||||
bind.cmSwitchPermission(
|
||||
connId: client.id,
|
||||
name: "audio",
|
||||
enabled: enabled);
|
||||
setState(() {
|
||||
client.audio = enabled;
|
||||
});
|
||||
},
|
||||
translate('Enable audio'),
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.recording,
|
||||
Icons.videocam_rounded,
|
||||
(enabled) {
|
||||
bind.cmSwitchPermission(
|
||||
connId: client.id,
|
||||
name: "recording",
|
||||
enabled: enabled);
|
||||
setState(() {
|
||||
client.recording = enabled;
|
||||
});
|
||||
},
|
||||
translate('Enable recording session'),
|
||||
),
|
||||
]
|
||||
: [
|
||||
buildPermissionIcon(
|
||||
client.keyboard,
|
||||
Icons.keyboard,
|
||||
(enabled) {
|
||||
bind.cmSwitchPermission(
|
||||
connId: client.id,
|
||||
name: "keyboard",
|
||||
enabled: enabled);
|
||||
setState(() {
|
||||
client.keyboard = enabled;
|
||||
});
|
||||
},
|
||||
translate('Enable keyboard/mouse'),
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.clipboard,
|
||||
Icons.assignment_rounded,
|
||||
(enabled) {
|
||||
bind.cmSwitchPermission(
|
||||
connId: client.id,
|
||||
name: "clipboard",
|
||||
enabled: enabled);
|
||||
setState(() {
|
||||
client.clipboard = enabled;
|
||||
});
|
||||
},
|
||||
translate('Enable clipboard'),
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.audio,
|
||||
Icons.volume_up_rounded,
|
||||
(enabled) {
|
||||
bind.cmSwitchPermission(
|
||||
connId: client.id,
|
||||
name: "audio",
|
||||
enabled: enabled);
|
||||
setState(() {
|
||||
client.audio = enabled;
|
||||
});
|
||||
},
|
||||
translate('Enable audio'),
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.file,
|
||||
Icons.upload_file_rounded,
|
||||
(enabled) {
|
||||
bind.cmSwitchPermission(
|
||||
connId: client.id,
|
||||
name: "file",
|
||||
enabled: enabled);
|
||||
setState(() {
|
||||
client.file = enabled;
|
||||
});
|
||||
},
|
||||
translate('Enable file copy and paste'),
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.restart,
|
||||
Icons.restart_alt_rounded,
|
||||
(enabled) {
|
||||
bind.cmSwitchPermission(
|
||||
connId: client.id,
|
||||
name: "restart",
|
||||
enabled: enabled);
|
||||
setState(() {
|
||||
client.restart = enabled;
|
||||
});
|
||||
},
|
||||
translate('Enable remote restart'),
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.recording,
|
||||
Icons.videocam_rounded,
|
||||
(enabled) {
|
||||
bind.cmSwitchPermission(
|
||||
connId: client.id,
|
||||
name: "recording",
|
||||
enabled: enabled);
|
||||
setState(() {
|
||||
client.recording = enabled;
|
||||
});
|
||||
},
|
||||
translate('Enable recording session'),
|
||||
),
|
||||
// only windows support block input
|
||||
if (isWindows)
|
||||
buildPermissionIcon(
|
||||
client.blockInput,
|
||||
Icons.block,
|
||||
(enabled) {
|
||||
bind.cmSwitchPermission(
|
||||
connId: client.id,
|
||||
name: "block_input",
|
||||
enabled: enabled);
|
||||
setState(() {
|
||||
client.blockInput = enabled;
|
||||
});
|
||||
},
|
||||
translate('Enable blocking user input'),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
730
flutter/lib/desktop/pages/view_camera_page.dart
Normal file
730
flutter/lib/desktop/pages/view_camera_page.dart
Normal file
@@ -0,0 +1,730 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:desktop_multi_window/desktop_multi_window.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hbb/common/widgets/remote_input.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
import 'package:flutter_hbb/models/state_model.dart';
|
||||
|
||||
import '../../consts.dart';
|
||||
import '../../common/widgets/overlay.dart';
|
||||
import '../../common.dart';
|
||||
import '../../common/widgets/dialog.dart';
|
||||
import '../../common/widgets/toolbar.dart';
|
||||
import '../../models/model.dart';
|
||||
import '../../models/platform_model.dart';
|
||||
import '../../common/shared_state.dart';
|
||||
import '../../utils/image.dart';
|
||||
import '../widgets/remote_toolbar.dart';
|
||||
import '../widgets/kb_layout_type_chooser.dart';
|
||||
import '../widgets/tabbar_widget.dart';
|
||||
|
||||
import 'package:flutter_hbb/native/custom_cursor.dart'
|
||||
if (dart.library.html) 'package:flutter_hbb/web/custom_cursor.dart';
|
||||
|
||||
final SimpleWrapper<bool> _firstEnterImage = SimpleWrapper(false);
|
||||
|
||||
// Used to skip session close if "move to new window" is clicked.
|
||||
final Map<String, bool> closeSessionOnDispose = {};
|
||||
|
||||
class ViewCameraPage extends StatefulWidget {
|
||||
ViewCameraPage({
|
||||
Key? key,
|
||||
required this.id,
|
||||
required this.toolbarState,
|
||||
this.sessionId,
|
||||
this.tabWindowId,
|
||||
this.password,
|
||||
this.display,
|
||||
this.displays,
|
||||
this.tabController,
|
||||
this.connToken,
|
||||
this.forceRelay,
|
||||
this.isSharedPassword,
|
||||
}) : super(key: key) {
|
||||
initSharedStates(id);
|
||||
}
|
||||
|
||||
final String id;
|
||||
final SessionID? sessionId;
|
||||
final int? tabWindowId;
|
||||
final int? display;
|
||||
final List<int>? displays;
|
||||
final String? password;
|
||||
final ToolbarState toolbarState;
|
||||
final bool? forceRelay;
|
||||
final bool? isSharedPassword;
|
||||
final String? connToken;
|
||||
final SimpleWrapper<State<ViewCameraPage>?> _lastState = SimpleWrapper(null);
|
||||
final DesktopTabController? tabController;
|
||||
|
||||
FFI get ffi => (_lastState.value! as _ViewCameraPageState)._ffi;
|
||||
|
||||
@override
|
||||
State<ViewCameraPage> createState() {
|
||||
final state = _ViewCameraPageState(id);
|
||||
_lastState.value = state;
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
class _ViewCameraPageState extends State<ViewCameraPage>
|
||||
with AutomaticKeepAliveClientMixin, MultiWindowListener {
|
||||
Timer? _timer;
|
||||
String keyboardMode = "legacy";
|
||||
bool _isWindowBlur = false;
|
||||
final _cursorOverImage = false.obs;
|
||||
|
||||
var _blockableOverlayState = BlockableOverlayState();
|
||||
|
||||
final FocusNode _rawKeyFocusNode = FocusNode(debugLabel: "rawkeyFocusNode");
|
||||
|
||||
// We need `_instanceIdOnEnterOrLeaveImage4Toolbar` together with `_onEnterOrLeaveImage4Toolbar`
|
||||
// to identify the toolbar instance and its callback function.
|
||||
int? _instanceIdOnEnterOrLeaveImage4Toolbar;
|
||||
Function(bool)? _onEnterOrLeaveImage4Toolbar;
|
||||
|
||||
late FFI _ffi;
|
||||
|
||||
SessionID get sessionId => _ffi.sessionId;
|
||||
|
||||
_ViewCameraPageState(String id) {
|
||||
_initStates(id);
|
||||
}
|
||||
|
||||
void _initStates(String id) {}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ffi = FFI(widget.sessionId);
|
||||
Get.put<FFI>(_ffi, tag: widget.id);
|
||||
_ffi.imageModel.addCallbackOnFirstImage((String peerId) {
|
||||
showKBLayoutTypeChooserIfNeeded(
|
||||
_ffi.ffiModel.pi.platform, _ffi.dialogManager);
|
||||
_ffi.recordingModel
|
||||
.updateStatus(bind.sessionGetIsRecording(sessionId: _ffi.sessionId));
|
||||
});
|
||||
_ffi.start(
|
||||
widget.id,
|
||||
isViewCamera: true,
|
||||
password: widget.password,
|
||||
isSharedPassword: widget.isSharedPassword,
|
||||
forceRelay: widget.forceRelay,
|
||||
tabWindowId: widget.tabWindowId,
|
||||
display: widget.display,
|
||||
displays: widget.displays,
|
||||
connToken: widget.connToken,
|
||||
);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: []);
|
||||
_ffi.dialogManager
|
||||
.showLoading(translate('Connecting...'), onCancel: closeConnection);
|
||||
});
|
||||
if (!isLinux) {
|
||||
WakelockPlus.enable();
|
||||
}
|
||||
|
||||
_ffi.ffiModel.updateEventListener(sessionId, widget.id);
|
||||
if (!isWeb) bind.pluginSyncUi(syncTo: kAppTypeDesktopRemote);
|
||||
_ffi.qualityMonitorModel.checkShowQualityMonitor(sessionId);
|
||||
_ffi.dialogManager.loadMobileActionsOverlayVisible();
|
||||
DesktopMultiWindow.addListener(this);
|
||||
// if (!_isCustomCursorInited) {
|
||||
// customCursorController.registerNeedUpdateCursorCallback(
|
||||
// (String? lastKey, String? currentKey) async {
|
||||
// if (_firstEnterImage.value) {
|
||||
// _firstEnterImage.value = false;
|
||||
// return true;
|
||||
// }
|
||||
// return lastKey == null || lastKey != currentKey;
|
||||
// });
|
||||
// _isCustomCursorInited = true;
|
||||
// }
|
||||
|
||||
_blockableOverlayState.applyFfi(_ffi);
|
||||
// Call onSelected in post frame callback, since we cannot guarantee that the callback will not call setState.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
widget.tabController?.onSelected?.call(widget.id);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowBlur() {
|
||||
super.onWindowBlur();
|
||||
// On windows, we use `focus` way to handle keyboard better.
|
||||
// Now on Linux, there's some rdev issues which will break the input.
|
||||
// We disable the `focus` way for non-Windows temporarily.
|
||||
if (isWindows) {
|
||||
_isWindowBlur = true;
|
||||
// unfocus the primary-focus when the whole window is lost focus,
|
||||
// and let OS to handle events instead.
|
||||
_rawKeyFocusNode.unfocus();
|
||||
}
|
||||
stateGlobal.isFocused.value = false;
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowFocus() {
|
||||
super.onWindowFocus();
|
||||
// See [onWindowBlur].
|
||||
if (isWindows) {
|
||||
_isWindowBlur = false;
|
||||
}
|
||||
stateGlobal.isFocused.value = true;
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowRestore() {
|
||||
super.onWindowRestore();
|
||||
// On windows, we use `onWindowRestore` way to handle window restore from
|
||||
// a minimized state.
|
||||
if (isWindows) {
|
||||
_isWindowBlur = false;
|
||||
}
|
||||
if (!isLinux) {
|
||||
WakelockPlus.enable();
|
||||
}
|
||||
}
|
||||
|
||||
// When the window is unminimized, onWindowMaximize or onWindowRestore can be called when the old state was maximized or not.
|
||||
@override
|
||||
void onWindowMaximize() {
|
||||
super.onWindowMaximize();
|
||||
if (!isLinux) {
|
||||
WakelockPlus.enable();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowMinimize() {
|
||||
super.onWindowMinimize();
|
||||
if (!isLinux) {
|
||||
WakelockPlus.disable();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowEnterFullScreen() {
|
||||
super.onWindowEnterFullScreen();
|
||||
if (isMacOS) {
|
||||
stateGlobal.setFullscreen(true);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowLeaveFullScreen() {
|
||||
super.onWindowLeaveFullScreen();
|
||||
if (isMacOS) {
|
||||
stateGlobal.setFullscreen(false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
final closeSession = closeSessionOnDispose.remove(widget.id) ?? true;
|
||||
|
||||
// https://github.com/flutter/flutter/issues/64935
|
||||
super.dispose();
|
||||
debugPrint("VIEW CAMERA PAGE dispose session $sessionId ${widget.id}");
|
||||
_ffi.textureModel.onViewCameraPageDispose(closeSession);
|
||||
if (closeSession) {
|
||||
// ensure we leave this session, this is a double check
|
||||
_ffi.inputModel.enterOrLeave(false);
|
||||
}
|
||||
DesktopMultiWindow.removeListener(this);
|
||||
_ffi.dialogManager.hideMobileActionsOverlay();
|
||||
_ffi.imageModel.disposeImage();
|
||||
_ffi.cursorModel.disposeImages();
|
||||
_rawKeyFocusNode.dispose();
|
||||
await _ffi.close(closeSession: closeSession);
|
||||
_timer?.cancel();
|
||||
_ffi.dialogManager.dismissAll();
|
||||
if (closeSession) {
|
||||
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
|
||||
overlays: SystemUiOverlay.values);
|
||||
}
|
||||
if (!isLinux) {
|
||||
await WakelockPlus.disable();
|
||||
}
|
||||
await Get.delete<FFI>(tag: widget.id);
|
||||
removeSharedStates(widget.id);
|
||||
}
|
||||
|
||||
Widget emptyOverlay() => BlockableOverlay(
|
||||
/// the Overlay key will be set with _blockableOverlayState in BlockableOverlay
|
||||
/// see override build() in [BlockableOverlay]
|
||||
state: _blockableOverlayState,
|
||||
underlying: Container(
|
||||
color: Colors.transparent,
|
||||
),
|
||||
);
|
||||
|
||||
Widget buildBody(BuildContext context) {
|
||||
remoteToolbar(BuildContext context) => RemoteToolbar(
|
||||
id: widget.id,
|
||||
ffi: _ffi,
|
||||
state: widget.toolbarState,
|
||||
onEnterOrLeaveImageSetter: (id, func) {
|
||||
_instanceIdOnEnterOrLeaveImage4Toolbar = id;
|
||||
_onEnterOrLeaveImage4Toolbar = func;
|
||||
},
|
||||
onEnterOrLeaveImageCleaner: (id) {
|
||||
// If _instanceIdOnEnterOrLeaveImage4Toolbar != id
|
||||
// it means `_onEnterOrLeaveImage4Toolbar` is not set or it has been changed to another toolbar.
|
||||
if (_instanceIdOnEnterOrLeaveImage4Toolbar == id) {
|
||||
_instanceIdOnEnterOrLeaveImage4Toolbar = null;
|
||||
_onEnterOrLeaveImage4Toolbar = null;
|
||||
}
|
||||
},
|
||||
setRemoteState: setState,
|
||||
);
|
||||
|
||||
bodyWidget() {
|
||||
return Stack(
|
||||
children: [
|
||||
Container(
|
||||
color: kColorCanvas,
|
||||
child: getBodyForDesktop(context),
|
||||
),
|
||||
Stack(
|
||||
children: [
|
||||
_ffi.ffiModel.pi.isSet.isTrue &&
|
||||
_ffi.ffiModel.waitForFirstImage.isTrue
|
||||
? emptyOverlay()
|
||||
: () {
|
||||
if (!_ffi.ffiModel.isPeerAndroid) {
|
||||
return Offstage();
|
||||
} else {
|
||||
return Obx(() => Offstage(
|
||||
offstage: _ffi.dialogManager
|
||||
.mobileActionsOverlayVisible.isFalse,
|
||||
child: Overlay(initialEntries: [
|
||||
makeMobileActionsOverlayEntry(
|
||||
() => _ffi.dialogManager
|
||||
.setMobileActionsOverlayVisible(false),
|
||||
ffi: _ffi,
|
||||
)
|
||||
]),
|
||||
));
|
||||
}
|
||||
}(),
|
||||
// Use Overlay to enable rebuild every time on menu button click.
|
||||
_ffi.ffiModel.pi.isSet.isTrue
|
||||
? Overlay(
|
||||
initialEntries: [OverlayEntry(builder: remoteToolbar)])
|
||||
: remoteToolbar(context),
|
||||
_ffi.ffiModel.pi.isSet.isFalse ? emptyOverlay() : Offstage(),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Theme.of(context).colorScheme.background,
|
||||
body: Obx(() {
|
||||
final imageReady = _ffi.ffiModel.pi.isSet.isTrue &&
|
||||
_ffi.ffiModel.waitForFirstImage.isFalse;
|
||||
if (imageReady) {
|
||||
// If the privacy mode(disable physical displays) is switched,
|
||||
// we should not dismiss the dialog immediately.
|
||||
if (DateTime.now().difference(togglePrivacyModeTime) >
|
||||
const Duration(milliseconds: 3000)) {
|
||||
// `dismissAll()` is to ensure that the state is clean.
|
||||
// It's ok to call dismissAll() here.
|
||||
_ffi.dialogManager.dismissAll();
|
||||
// Recreate the block state to refresh the state.
|
||||
_blockableOverlayState = BlockableOverlayState();
|
||||
_blockableOverlayState.applyFfi(_ffi);
|
||||
}
|
||||
// Block the whole `bodyWidget()` when dialog shows.
|
||||
return BlockableOverlay(
|
||||
underlying: bodyWidget(),
|
||||
state: _blockableOverlayState,
|
||||
);
|
||||
} else {
|
||||
// `_blockableOverlayState` is not recreated here.
|
||||
// The toolbar's block state won't work properly when reconnecting, but that's okay.
|
||||
return bodyWidget();
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
return WillPopScope(
|
||||
onWillPop: () async {
|
||||
clientClose(sessionId, _ffi.dialogManager);
|
||||
return false;
|
||||
},
|
||||
child: MultiProvider(providers: [
|
||||
ChangeNotifierProvider.value(value: _ffi.ffiModel),
|
||||
ChangeNotifierProvider.value(value: _ffi.imageModel),
|
||||
ChangeNotifierProvider.value(value: _ffi.cursorModel),
|
||||
ChangeNotifierProvider.value(value: _ffi.canvasModel),
|
||||
ChangeNotifierProvider.value(value: _ffi.recordingModel),
|
||||
], child: buildBody(context)));
|
||||
}
|
||||
|
||||
void enterView(PointerEnterEvent evt) {
|
||||
_cursorOverImage.value = true;
|
||||
_firstEnterImage.value = true;
|
||||
if (_onEnterOrLeaveImage4Toolbar != null) {
|
||||
try {
|
||||
_onEnterOrLeaveImage4Toolbar!(true);
|
||||
} catch (e) {
|
||||
//
|
||||
}
|
||||
}
|
||||
// See [onWindowBlur].
|
||||
if (!isWindows) {
|
||||
if (!_rawKeyFocusNode.hasFocus) {
|
||||
_rawKeyFocusNode.requestFocus();
|
||||
}
|
||||
_ffi.inputModel.enterOrLeave(true);
|
||||
}
|
||||
}
|
||||
|
||||
void leaveView(PointerExitEvent evt) {
|
||||
if (_ffi.ffiModel.keyboard) {
|
||||
_ffi.inputModel.tryMoveEdgeOnExit(evt.position);
|
||||
}
|
||||
|
||||
_cursorOverImage.value = false;
|
||||
_firstEnterImage.value = false;
|
||||
if (_onEnterOrLeaveImage4Toolbar != null) {
|
||||
try {
|
||||
_onEnterOrLeaveImage4Toolbar!(false);
|
||||
} catch (e) {
|
||||
//
|
||||
}
|
||||
}
|
||||
// See [onWindowBlur].
|
||||
if (!isWindows) {
|
||||
_ffi.inputModel.enterOrLeave(false);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildRawTouchAndPointerRegion(
|
||||
Widget child,
|
||||
PointerEnterEventListener? onEnter,
|
||||
PointerExitEventListener? onExit,
|
||||
) {
|
||||
return RawTouchGestureDetectorRegion(
|
||||
child: _buildRawPointerMouseRegion(child, onEnter, onExit),
|
||||
ffi: _ffi,
|
||||
isCamera: true,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRawPointerMouseRegion(
|
||||
Widget child,
|
||||
PointerEnterEventListener? onEnter,
|
||||
PointerExitEventListener? onExit,
|
||||
) {
|
||||
return CameraRawPointerMouseRegion(
|
||||
onEnter: onEnter,
|
||||
onExit: onExit,
|
||||
onPointerDown: (event) {
|
||||
// A double check for blur status.
|
||||
// Note: If there's an `onPointerDown` event is triggered, `_isWindowBlur` is expected being false.
|
||||
// Sometimes the system does not send the necessary focus event to flutter. We should manually
|
||||
// handle this inconsistent status by setting `_isWindowBlur` to false. So we can
|
||||
// ensure the grab-key thread is running when our users are clicking the remote canvas.
|
||||
if (_isWindowBlur) {
|
||||
debugPrint(
|
||||
"Unexpected status: onPointerDown is triggered while the remote window is in blur status");
|
||||
_isWindowBlur = false;
|
||||
}
|
||||
if (!_rawKeyFocusNode.hasFocus) {
|
||||
_rawKeyFocusNode.requestFocus();
|
||||
}
|
||||
},
|
||||
inputModel: _ffi.inputModel,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
Widget getBodyForDesktop(BuildContext context) {
|
||||
var paints = <Widget>[
|
||||
MouseRegion(onEnter: (evt) {
|
||||
if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: false);
|
||||
}, onExit: (evt) {
|
||||
if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: true);
|
||||
}, child: LayoutBuilder(builder: (context, constraints) {
|
||||
final c = Provider.of<CanvasModel>(context, listen: false);
|
||||
Future.delayed(Duration.zero, () => c.updateViewStyle());
|
||||
final peerDisplay = CurrentDisplayState.find(widget.id);
|
||||
return Obx(
|
||||
() => _ffi.ffiModel.pi.isSet.isFalse
|
||||
? Container(color: Colors.transparent)
|
||||
: Obx(() {
|
||||
widget.toolbarState.initShow(sessionId);
|
||||
_ffi.textureModel.updateCurrentDisplay(peerDisplay.value);
|
||||
return ImagePaint(
|
||||
id: widget.id,
|
||||
cursorOverImage: _cursorOverImage,
|
||||
listenerBuilder: (child) => _buildRawTouchAndPointerRegion(
|
||||
child, enterView, leaveView),
|
||||
ffi: _ffi,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}))
|
||||
];
|
||||
|
||||
paints.add(
|
||||
Positioned(
|
||||
top: 10,
|
||||
right: 10,
|
||||
child: _buildRawTouchAndPointerRegion(
|
||||
QualityMonitor(_ffi.qualityMonitorModel), null, null),
|
||||
),
|
||||
);
|
||||
return Stack(
|
||||
children: paints,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
}
|
||||
|
||||
class ImagePaint extends StatefulWidget {
|
||||
final FFI ffi;
|
||||
final String id;
|
||||
final RxBool cursorOverImage;
|
||||
final Widget Function(Widget)? listenerBuilder;
|
||||
|
||||
ImagePaint(
|
||||
{Key? key,
|
||||
required this.ffi,
|
||||
required this.id,
|
||||
required this.cursorOverImage,
|
||||
this.listenerBuilder})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _ImagePaintState();
|
||||
}
|
||||
|
||||
class _ImagePaintState extends State<ImagePaint> {
|
||||
bool _lastRemoteCursorMoved = false;
|
||||
|
||||
String get id => widget.id;
|
||||
RxBool get cursorOverImage => widget.cursorOverImage;
|
||||
Widget Function(Widget)? get listenerBuilder => widget.listenerBuilder;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final m = Provider.of<ImageModel>(context);
|
||||
var c = Provider.of<CanvasModel>(context);
|
||||
final s = c.scale;
|
||||
|
||||
bool isViewOriginal() => c.viewStyle.style == kRemoteViewStyleOriginal;
|
||||
|
||||
if (c.imageOverflow.isTrue && c.scrollStyle == ScrollStyle.scrollbar) {
|
||||
final paintWidth = c.getDisplayWidth() * s;
|
||||
final paintHeight = c.getDisplayHeight() * s;
|
||||
final paintSize = Size(paintWidth, paintHeight);
|
||||
final paintWidget =
|
||||
m.useTextureRender || widget.ffi.ffiModel.pi.forceTextureRender
|
||||
? _BuildPaintTextureRender(
|
||||
c, s, Offset.zero, paintSize, isViewOriginal())
|
||||
: _buildScrollbarNonTextureRender(m, paintSize, s);
|
||||
return NotificationListener<ScrollNotification>(
|
||||
onNotification: (notification) {
|
||||
c.updateScrollPercent();
|
||||
return false;
|
||||
},
|
||||
child: Container(
|
||||
child: _buildCrossScrollbarFromLayout(
|
||||
context,
|
||||
_buildListener(paintWidget),
|
||||
c.size,
|
||||
paintSize,
|
||||
c.scrollHorizontal,
|
||||
c.scrollVertical,
|
||||
)),
|
||||
);
|
||||
} else {
|
||||
if (c.size.width > 0 && c.size.height > 0) {
|
||||
final paintWidget =
|
||||
m.useTextureRender || widget.ffi.ffiModel.pi.forceTextureRender
|
||||
? _BuildPaintTextureRender(
|
||||
c,
|
||||
s,
|
||||
Offset(
|
||||
isLinux ? c.x.toInt().toDouble() : c.x,
|
||||
isLinux ? c.y.toInt().toDouble() : c.y,
|
||||
),
|
||||
c.size,
|
||||
isViewOriginal())
|
||||
: _buildScrollAutoNonTextureRender(m, c, s);
|
||||
return Container(child: _buildListener(paintWidget));
|
||||
} else {
|
||||
return Container();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildScrollbarNonTextureRender(
|
||||
ImageModel m, Size imageSize, double s) {
|
||||
return CustomPaint(
|
||||
size: imageSize,
|
||||
painter: ImagePainter(image: m.image, x: 0, y: 0, scale: s),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildScrollAutoNonTextureRender(
|
||||
ImageModel m, CanvasModel c, double s) {
|
||||
return CustomPaint(
|
||||
size: Size(c.size.width, c.size.height),
|
||||
painter: ImagePainter(image: m.image, x: c.x / s, y: c.y / s, scale: s),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _BuildPaintTextureRender(
|
||||
CanvasModel c, double s, Offset offset, Size size, bool isViewOriginal) {
|
||||
final ffiModel = c.parent.target!.ffiModel;
|
||||
final displays = ffiModel.pi.getCurDisplays();
|
||||
final children = <Widget>[];
|
||||
final rect = ffiModel.rect;
|
||||
if (rect == null) {
|
||||
return Container();
|
||||
}
|
||||
final curDisplay = ffiModel.pi.currentDisplay;
|
||||
for (var i = 0; i < displays.length; i++) {
|
||||
final textureId = widget.ffi.textureModel
|
||||
.getTextureId(curDisplay == kAllDisplayValue ? i : curDisplay);
|
||||
if (true) {
|
||||
// both "textureId.value != -1" and "true" seems ok
|
||||
children.add(Positioned(
|
||||
left: (displays[i].x - rect.left) * s + offset.dx,
|
||||
top: (displays[i].y - rect.top) * s + offset.dy,
|
||||
width: displays[i].width * s,
|
||||
height: displays[i].height * s,
|
||||
child: Obx(() => Texture(
|
||||
textureId: textureId.value,
|
||||
filterQuality:
|
||||
isViewOriginal ? FilterQuality.none : FilterQuality.low,
|
||||
)),
|
||||
));
|
||||
}
|
||||
}
|
||||
return SizedBox(
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
child: Stack(children: children),
|
||||
);
|
||||
}
|
||||
|
||||
MouseCursor _buildCustomCursor(BuildContext context, double scale) {
|
||||
final cursor = Provider.of<CursorModel>(context);
|
||||
final cache = cursor.cache ?? preDefaultCursor.cache;
|
||||
return buildCursorOfCache(cursor, scale, cache);
|
||||
}
|
||||
|
||||
MouseCursor _buildDisabledCursor(BuildContext context, double scale) {
|
||||
final cursor = Provider.of<CursorModel>(context);
|
||||
final cache = preForbiddenCursor.cache;
|
||||
return buildCursorOfCache(cursor, scale, cache);
|
||||
}
|
||||
|
||||
Widget _buildCrossScrollbarFromLayout(
|
||||
BuildContext context,
|
||||
Widget child,
|
||||
Size layoutSize,
|
||||
Size size,
|
||||
ScrollController horizontal,
|
||||
ScrollController vertical,
|
||||
) {
|
||||
var widget = child;
|
||||
if (layoutSize.width < size.width) {
|
||||
widget = ScrollConfiguration(
|
||||
behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false),
|
||||
child: SingleChildScrollView(
|
||||
controller: horizontal,
|
||||
scrollDirection: Axis.horizontal,
|
||||
physics: cursorOverImage.isTrue
|
||||
? const NeverScrollableScrollPhysics()
|
||||
: null,
|
||||
child: widget,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
widget = Row(
|
||||
children: [
|
||||
Container(
|
||||
width: ((layoutSize.width - size.width) ~/ 2).toDouble(),
|
||||
),
|
||||
widget,
|
||||
],
|
||||
);
|
||||
}
|
||||
if (layoutSize.height < size.height) {
|
||||
widget = ScrollConfiguration(
|
||||
behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false),
|
||||
child: SingleChildScrollView(
|
||||
controller: vertical,
|
||||
physics: cursorOverImage.isTrue
|
||||
? const NeverScrollableScrollPhysics()
|
||||
: null,
|
||||
child: widget,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
widget = Column(
|
||||
children: [
|
||||
Container(
|
||||
height: ((layoutSize.height - size.height) ~/ 2).toDouble(),
|
||||
),
|
||||
widget,
|
||||
],
|
||||
);
|
||||
}
|
||||
if (layoutSize.width < size.width) {
|
||||
widget = RawScrollbar(
|
||||
thickness: kScrollbarThickness,
|
||||
thumbColor: Colors.grey,
|
||||
controller: horizontal,
|
||||
thumbVisibility: false,
|
||||
trackVisibility: false,
|
||||
notificationPredicate: layoutSize.height < size.height
|
||||
? (notification) => notification.depth == 1
|
||||
: defaultScrollNotificationPredicate,
|
||||
child: widget,
|
||||
);
|
||||
}
|
||||
if (layoutSize.height < size.height) {
|
||||
widget = RawScrollbar(
|
||||
thickness: kScrollbarThickness,
|
||||
thumbColor: Colors.grey,
|
||||
controller: vertical,
|
||||
thumbVisibility: false,
|
||||
trackVisibility: false,
|
||||
child: widget,
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
child: widget,
|
||||
width: layoutSize.width,
|
||||
height: layoutSize.height,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildListener(Widget child) {
|
||||
if (listenerBuilder != null) {
|
||||
return listenerBuilder!(child);
|
||||
} else {
|
||||
return child;
|
||||
}
|
||||
}
|
||||
}
|
||||
499
flutter/lib/desktop/pages/view_camera_tab_page.dart
Normal file
499
flutter/lib/desktop/pages/view_camera_tab_page.dart
Normal file
@@ -0,0 +1,499 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:async';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:desktop_multi_window/desktop_multi_window.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/common/shared_state.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/models/input_model.dart';
|
||||
import 'package:flutter_hbb/models/state_model.dart';
|
||||
import 'package:flutter_hbb/desktop/pages/view_camera_page.dart';
|
||||
import 'package:flutter_hbb/desktop/widgets/remote_toolbar.dart';
|
||||
import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
|
||||
import 'package:flutter_hbb/desktop/widgets/material_mod_popup_menu.dart'
|
||||
as mod_menu;
|
||||
import 'package:flutter_hbb/desktop/widgets/popup_menu.dart';
|
||||
import 'package:flutter_hbb/utils/multi_window_manager.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:bot_toast/bot_toast.dart';
|
||||
|
||||
import '../../models/platform_model.dart';
|
||||
|
||||
class _MenuTheme {
|
||||
static const Color blueColor = MyTheme.button;
|
||||
// kMinInteractiveDimension
|
||||
static const double height = 20.0;
|
||||
static const double dividerHeight = 12.0;
|
||||
}
|
||||
|
||||
class ViewCameraTabPage extends StatefulWidget {
|
||||
final Map<String, dynamic> params;
|
||||
|
||||
const ViewCameraTabPage({Key? key, required this.params}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<ViewCameraTabPage> createState() => _ViewCameraTabPageState(params);
|
||||
}
|
||||
|
||||
class _ViewCameraTabPageState extends State<ViewCameraTabPage> {
|
||||
final tabController =
|
||||
Get.put(DesktopTabController(tabType: DesktopTabType.viewCamera));
|
||||
final contentKey = UniqueKey();
|
||||
static const IconData selectedIcon = Icons.desktop_windows_sharp;
|
||||
static const IconData unselectedIcon = Icons.desktop_windows_outlined;
|
||||
|
||||
String? peerId;
|
||||
bool _isScreenRectSet = false;
|
||||
int? _display;
|
||||
|
||||
var connectionMap = RxList<Widget>.empty(growable: true);
|
||||
|
||||
_ViewCameraTabPageState(Map<String, dynamic> params) {
|
||||
RemoteCountState.init();
|
||||
peerId = params['id'];
|
||||
final sessionId = params['session_id'];
|
||||
final tabWindowId = params['tab_window_id'];
|
||||
final display = params['display'];
|
||||
final displays = params['displays'];
|
||||
final screenRect = parseParamScreenRect(params);
|
||||
_isScreenRectSet = screenRect != null;
|
||||
_display = display as int?;
|
||||
tryMoveToScreenAndSetFullscreen(screenRect);
|
||||
if (peerId != null) {
|
||||
ConnectionTypeState.init(peerId!);
|
||||
tabController.onSelected = (id) {
|
||||
final viewCameraPage = tabController.widget(id);
|
||||
if (viewCameraPage is ViewCameraPage) {
|
||||
final ffi = viewCameraPage.ffi;
|
||||
bind.setCurSessionId(sessionId: ffi.sessionId);
|
||||
}
|
||||
WindowController.fromWindowId(params['windowId'])
|
||||
.setTitle(getWindowNameWithId(id));
|
||||
UnreadChatCountState.find(id).value = 0;
|
||||
};
|
||||
tabController.add(TabInfo(
|
||||
key: peerId!,
|
||||
label: peerId!,
|
||||
selectedIcon: selectedIcon,
|
||||
unselectedIcon: unselectedIcon,
|
||||
onTabCloseButton: () => tabController.closeBy(peerId),
|
||||
page: ViewCameraPage(
|
||||
key: ValueKey(peerId),
|
||||
id: peerId!,
|
||||
sessionId: sessionId == null ? null : SessionID(sessionId),
|
||||
tabWindowId: tabWindowId,
|
||||
display: display,
|
||||
displays: displays?.cast<int>(),
|
||||
password: params['password'],
|
||||
toolbarState: ToolbarState(),
|
||||
tabController: tabController,
|
||||
connToken: params['connToken'],
|
||||
forceRelay: params['forceRelay'],
|
||||
isSharedPassword: params['isSharedPassword'],
|
||||
),
|
||||
));
|
||||
_update_remote_count();
|
||||
}
|
||||
tabController.onRemoved = (_, id) => onRemoveId(id);
|
||||
rustDeskWinManager.setMethodHandler(_remoteMethodHandler);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
if (!_isScreenRectSet) {
|
||||
Future.delayed(Duration.zero, () {
|
||||
restoreWindowPosition(
|
||||
WindowType.ViewCamera,
|
||||
windowId: windowId(),
|
||||
peerId: tabController.state.value.tabs.isEmpty
|
||||
? null
|
||||
: tabController.state.value.tabs[0].key,
|
||||
display: _display,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final child = Scaffold(
|
||||
backgroundColor: Theme.of(context).colorScheme.background,
|
||||
body: DesktopTab(
|
||||
controller: tabController,
|
||||
onWindowCloseButton: handleWindowCloseButton,
|
||||
tail: const AddButton(),
|
||||
selectedBorderColor: MyTheme.accent,
|
||||
pageViewBuilder: (pageView) => pageView,
|
||||
labelGetter: DesktopTab.tablabelGetter,
|
||||
tabBuilder: (key, icon, label, themeConf) => Obx(() {
|
||||
final connectionType = ConnectionTypeState.find(key);
|
||||
if (!connectionType.isValid()) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
icon,
|
||||
label,
|
||||
],
|
||||
);
|
||||
} else {
|
||||
bool secure =
|
||||
connectionType.secure.value == ConnectionType.strSecure;
|
||||
bool direct =
|
||||
connectionType.direct.value == ConnectionType.strDirect;
|
||||
String msgConn;
|
||||
if (secure && direct) {
|
||||
msgConn = translate("Direct and encrypted connection");
|
||||
} else if (secure && !direct) {
|
||||
msgConn = translate("Relayed and encrypted connection");
|
||||
} else if (!secure && direct) {
|
||||
msgConn = translate("Direct and unencrypted connection");
|
||||
} else {
|
||||
msgConn = translate("Relayed and unencrypted connection");
|
||||
}
|
||||
var msgFingerprint = '${translate('Fingerprint')}:\n';
|
||||
var fingerprint = FingerprintState.find(key).value;
|
||||
if (fingerprint.isEmpty) {
|
||||
fingerprint = 'N/A';
|
||||
}
|
||||
if (fingerprint.length > 5 * 8) {
|
||||
var first = fingerprint.substring(0, 39);
|
||||
var second = fingerprint.substring(40);
|
||||
msgFingerprint += '$first\n$second';
|
||||
} else {
|
||||
msgFingerprint += fingerprint;
|
||||
}
|
||||
|
||||
final tab = Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
icon,
|
||||
Tooltip(
|
||||
message: '$msgConn\n$msgFingerprint',
|
||||
child: SvgPicture.asset(
|
||||
'assets/${connectionType.secure.value}${connectionType.direct.value}.svg',
|
||||
width: themeConf.iconSize,
|
||||
height: themeConf.iconSize,
|
||||
).paddingOnly(right: 5),
|
||||
),
|
||||
label,
|
||||
unreadMessageCountBuilder(UnreadChatCountState.find(key))
|
||||
.marginOnly(left: 4),
|
||||
],
|
||||
);
|
||||
|
||||
return Listener(
|
||||
onPointerDown: (e) {
|
||||
if (e.kind != ui.PointerDeviceKind.mouse) {
|
||||
return;
|
||||
}
|
||||
final viewCameraPage = tabController.state.value.tabs
|
||||
.firstWhere((tab) => tab.key == key)
|
||||
.page as ViewCameraPage;
|
||||
if (viewCameraPage.ffi.ffiModel.pi.isSet.isTrue &&
|
||||
e.buttons == 2) {
|
||||
showRightMenu(
|
||||
(CancelFunc cancelFunc) {
|
||||
return _tabMenuBuilder(key, cancelFunc);
|
||||
},
|
||||
target: e.position,
|
||||
);
|
||||
}
|
||||
},
|
||||
child: tab,
|
||||
);
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
final tabWidget = isLinux
|
||||
? buildVirtualWindowFrame(context, child)
|
||||
: workaroundWindowBorder(
|
||||
context,
|
||||
Obx(() => Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: MyTheme.color(context).border!,
|
||||
width: stateGlobal.windowBorderWidth.value),
|
||||
),
|
||||
child: child,
|
||||
)));
|
||||
return isMacOS || kUseCompatibleUiMode
|
||||
? tabWidget
|
||||
: Obx(() => SubWindowDragToResizeArea(
|
||||
key: contentKey,
|
||||
child: tabWidget,
|
||||
// Specially configured for a better resize area and remote control.
|
||||
childPadding: kDragToResizeAreaPadding,
|
||||
resizeEdgeSize: stateGlobal.resizeEdgeSize.value,
|
||||
enableResizeEdges: subWindowManagerEnableResizeEdges,
|
||||
windowId: stateGlobal.windowId,
|
||||
));
|
||||
}
|
||||
|
||||
// Note: Some dup code to ../widgets/remote_toolbar
|
||||
Widget _tabMenuBuilder(String key, CancelFunc cancelFunc) {
|
||||
final List<MenuEntryBase<String>> menu = [];
|
||||
const EdgeInsets padding = EdgeInsets.only(left: 8.0, right: 5.0);
|
||||
final viewCameraPage = tabController.state.value.tabs
|
||||
.firstWhere((tab) => tab.key == key)
|
||||
.page as ViewCameraPage;
|
||||
final ffi = viewCameraPage.ffi;
|
||||
final sessionId = ffi.sessionId;
|
||||
final toolbarState = viewCameraPage.toolbarState;
|
||||
menu.addAll([
|
||||
MenuEntryButton<String>(
|
||||
childBuilder: (TextStyle? style) => Obx(() => Text(
|
||||
translate(
|
||||
toolbarState.show.isTrue ? 'Hide Toolbar' : 'Show Toolbar'),
|
||||
style: style,
|
||||
)),
|
||||
proc: () {
|
||||
toolbarState.switchShow(sessionId);
|
||||
cancelFunc();
|
||||
},
|
||||
padding: padding,
|
||||
),
|
||||
]);
|
||||
|
||||
if (tabController.state.value.tabs.length > 1) {
|
||||
final splitAction = MenuEntryButton<String>(
|
||||
childBuilder: (TextStyle? style) => Text(
|
||||
translate('Move tab to new window'),
|
||||
style: style,
|
||||
),
|
||||
proc: () async {
|
||||
await DesktopMultiWindow.invokeMethod(
|
||||
kMainWindowId,
|
||||
kWindowEventMoveTabToNewWindow,
|
||||
'${windowId()},$key,$sessionId,ViewCamera');
|
||||
cancelFunc();
|
||||
},
|
||||
padding: padding,
|
||||
);
|
||||
menu.insert(1, splitAction);
|
||||
}
|
||||
|
||||
menu.addAll([
|
||||
MenuEntryDivider<String>(),
|
||||
MenuEntryButton<String>(
|
||||
childBuilder: (TextStyle? style) => Text(
|
||||
translate('Copy Fingerprint'),
|
||||
style: style,
|
||||
),
|
||||
proc: () => onCopyFingerprint(FingerprintState.find(key).value),
|
||||
padding: padding,
|
||||
dismissOnClicked: true,
|
||||
dismissCallback: cancelFunc,
|
||||
),
|
||||
MenuEntryButton<String>(
|
||||
childBuilder: (TextStyle? style) => Text(
|
||||
translate('Close'),
|
||||
style: style,
|
||||
),
|
||||
proc: () {
|
||||
tabController.closeBy(key);
|
||||
cancelFunc();
|
||||
},
|
||||
padding: padding,
|
||||
)
|
||||
]);
|
||||
|
||||
return mod_menu.PopupMenu<String>(
|
||||
items: menu
|
||||
.map((entry) => entry.build(
|
||||
context,
|
||||
const MenuConfig(
|
||||
commonColor: _MenuTheme.blueColor,
|
||||
height: _MenuTheme.height,
|
||||
dividerHeight: _MenuTheme.dividerHeight,
|
||||
)))
|
||||
.expand((i) => i)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
void onRemoveId(String id) async {
|
||||
if (tabController.state.value.tabs.isEmpty) {
|
||||
// Keep calling until the window status is hidden.
|
||||
//
|
||||
// Workaround for Windows:
|
||||
// If you click other buttons and close in msgbox within a very short period of time, the close may fail.
|
||||
// `await WindowController.fromWindowId(windowId()).close();`.
|
||||
Future<void> loopCloseWindow() async {
|
||||
int c = 0;
|
||||
final windowController = WindowController.fromWindowId(windowId());
|
||||
while (c < 20 &&
|
||||
tabController.state.value.tabs.isEmpty &&
|
||||
(!await windowController.isHidden())) {
|
||||
await windowController.close();
|
||||
await Future.delayed(Duration(milliseconds: 100));
|
||||
c++;
|
||||
}
|
||||
}
|
||||
|
||||
loopCloseWindow();
|
||||
}
|
||||
ConnectionTypeState.delete(id);
|
||||
_update_remote_count();
|
||||
}
|
||||
|
||||
int windowId() {
|
||||
return widget.params["windowId"];
|
||||
}
|
||||
|
||||
Future<bool> handleWindowCloseButton() async {
|
||||
final connLength = tabController.length;
|
||||
if (connLength <= 1) {
|
||||
tabController.clear();
|
||||
return true;
|
||||
} else {
|
||||
final bool res;
|
||||
if (!option2bool(kOptionEnableConfirmClosingTabs,
|
||||
bind.mainGetLocalOption(key: kOptionEnableConfirmClosingTabs))) {
|
||||
res = true;
|
||||
} else {
|
||||
res = await closeConfirmDialog();
|
||||
}
|
||||
if (res) {
|
||||
tabController.clear();
|
||||
}
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
_update_remote_count() =>
|
||||
RemoteCountState.find().value = tabController.length;
|
||||
|
||||
Future<dynamic> _remoteMethodHandler(call, fromWindowId) async {
|
||||
debugPrint(
|
||||
"[View Camera Page] call ${call.method} with args ${call.arguments} from window $fromWindowId");
|
||||
|
||||
dynamic returnValue;
|
||||
// for simplify, just replace connectionId
|
||||
if (call.method == kWindowEventNewViewCamera) {
|
||||
final args = jsonDecode(call.arguments);
|
||||
final id = args['id'];
|
||||
final sessionId = args['session_id'];
|
||||
final tabWindowId = args['tab_window_id'];
|
||||
final display = args['display'];
|
||||
final displays = args['displays'];
|
||||
final screenRect = parseParamScreenRect(args);
|
||||
final prePeerCount = tabController.length;
|
||||
Future.delayed(Duration.zero, () async {
|
||||
if (stateGlobal.fullscreen.isTrue) {
|
||||
await WindowController.fromWindowId(windowId()).setFullscreen(false);
|
||||
stateGlobal.setFullscreen(false, procWnd: false);
|
||||
}
|
||||
await setNewConnectWindowFrame(windowId(), id!, prePeerCount,
|
||||
WindowType.ViewCamera, display, screenRect);
|
||||
Future.delayed(Duration(milliseconds: isWindows ? 100 : 0), () async {
|
||||
await windowOnTop(windowId());
|
||||
});
|
||||
});
|
||||
ConnectionTypeState.init(id);
|
||||
tabController.add(TabInfo(
|
||||
key: id,
|
||||
label: id,
|
||||
selectedIcon: selectedIcon,
|
||||
unselectedIcon: unselectedIcon,
|
||||
onTabCloseButton: () => tabController.closeBy(id),
|
||||
page: ViewCameraPage(
|
||||
key: ValueKey(id),
|
||||
id: id,
|
||||
sessionId: sessionId == null ? null : SessionID(sessionId),
|
||||
tabWindowId: tabWindowId,
|
||||
display: display,
|
||||
displays: displays?.cast<int>(),
|
||||
password: args['password'],
|
||||
toolbarState: ToolbarState(),
|
||||
tabController: tabController,
|
||||
connToken: args['connToken'],
|
||||
forceRelay: args['forceRelay'],
|
||||
isSharedPassword: args['isSharedPassword'],
|
||||
),
|
||||
));
|
||||
} else if (call.method == kWindowDisableGrabKeyboard) {
|
||||
// ???
|
||||
} else if (call.method == "onDestroy") {
|
||||
tabController.clear();
|
||||
} else if (call.method == kWindowActionRebuild) {
|
||||
reloadCurrentWindow();
|
||||
} else if (call.method == kWindowEventActiveSession) {
|
||||
final jumpOk = tabController.jumpToByKey(call.arguments);
|
||||
if (jumpOk) {
|
||||
windowOnTop(windowId());
|
||||
}
|
||||
return jumpOk;
|
||||
} else if (call.method == kWindowEventActiveDisplaySession) {
|
||||
final args = jsonDecode(call.arguments);
|
||||
final id = args['id'];
|
||||
final display = args['display'];
|
||||
final jumpOk =
|
||||
tabController.jumpToByKeyAndDisplay(id, display, isCamera: true);
|
||||
if (jumpOk) {
|
||||
windowOnTop(windowId());
|
||||
}
|
||||
return jumpOk;
|
||||
} else if (call.method == kWindowEventGetRemoteList) {
|
||||
return tabController.state.value.tabs
|
||||
.map((e) => e.key)
|
||||
.toList()
|
||||
.join(',');
|
||||
} else if (call.method == kWindowEventGetSessionIdList) {
|
||||
return tabController.state.value.tabs
|
||||
.map((e) => '${e.key},${(e.page as ViewCameraPage).ffi.sessionId}')
|
||||
.toList()
|
||||
.join(';');
|
||||
} else if (call.method == kWindowEventGetCachedSessionData) {
|
||||
// Ready to show new window and close old tab.
|
||||
final args = jsonDecode(call.arguments);
|
||||
final id = args['id'];
|
||||
final close = args['close'];
|
||||
try {
|
||||
final viewCameraPage = tabController.state.value.tabs
|
||||
.firstWhere((tab) => tab.key == id)
|
||||
.page as ViewCameraPage;
|
||||
returnValue = viewCameraPage.ffi.ffiModel.cachedPeerData.toString();
|
||||
} catch (e) {
|
||||
debugPrint('Failed to get cached session data: $e');
|
||||
}
|
||||
if (close && returnValue != null) {
|
||||
closeSessionOnDispose[id] = false;
|
||||
tabController.closeBy(id);
|
||||
}
|
||||
} else if (call.method == kWindowEventRemoteWindowCoords) {
|
||||
final viewCameraPage =
|
||||
tabController.state.value.selectedTabInfo.page as ViewCameraPage;
|
||||
final ffi = viewCameraPage.ffi;
|
||||
final displayRect = ffi.ffiModel.displaysRect();
|
||||
if (displayRect != null) {
|
||||
final wc = WindowController.fromWindowId(windowId());
|
||||
Rect? frame;
|
||||
try {
|
||||
frame = await wc.getFrame();
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
"Failed to get frame of window $windowId, it may be hidden");
|
||||
}
|
||||
if (frame != null) {
|
||||
ffi.cursorModel.moveLocal(0, 0);
|
||||
final coords = RemoteWindowCoords(
|
||||
frame,
|
||||
CanvasCoords.fromCanvasModel(ffi.canvasModel),
|
||||
CursorCoords.fromCursorModel(ffi.cursorModel),
|
||||
displayRect);
|
||||
returnValue = jsonEncode(coords.toJson());
|
||||
}
|
||||
}
|
||||
} else if (call.method == kWindowEventSetFullscreen) {
|
||||
stateGlobal.setFullscreen(call.arguments == 'true');
|
||||
}
|
||||
_update_remote_count();
|
||||
return returnValue;
|
||||
}
|
||||
}
|
||||
35
flutter/lib/desktop/screen/desktop_view_camera_screen.dart
Normal file
35
flutter/lib/desktop/screen/desktop_view_camera_screen.dart
Normal file
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/desktop/pages/view_camera_tab_page.dart';
|
||||
import 'package:flutter_hbb/models/platform_model.dart';
|
||||
import 'package:flutter_hbb/models/state_model.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
/// multi-tab desktop remote screen
|
||||
class DesktopViewCameraScreen extends StatelessWidget {
|
||||
final Map<String, dynamic> params;
|
||||
|
||||
DesktopViewCameraScreen({Key? key, required this.params}) : super(key: key) {
|
||||
bind.mainInitInputSource();
|
||||
stateGlobal.getInputSource(force: true);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider.value(value: gFFI.ffiModel),
|
||||
ChangeNotifierProvider.value(value: gFFI.imageModel),
|
||||
ChangeNotifierProvider.value(value: gFFI.cursorModel),
|
||||
ChangeNotifierProvider.value(value: gFFI.canvasModel),
|
||||
],
|
||||
child: Scaffold(
|
||||
// Set transparent background for padding the resize area out of the flutter view.
|
||||
// This allows the wallpaper goes through our resize area. (Linux only now).
|
||||
backgroundColor: isLinux ? Colors.transparent : null,
|
||||
body: ViewCameraTabPage(
|
||||
params: params,
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hbb/common/widgets/audio_input.dart';
|
||||
import 'package:flutter_hbb/common/widgets/dialog.dart';
|
||||
import 'package:flutter_hbb/common/widgets/toolbar.dart';
|
||||
import 'package:flutter_hbb/models/chat_model.dart';
|
||||
import 'package:flutter_hbb/models/state_model.dart';
|
||||
@@ -478,7 +479,10 @@ class _RemoteToolbarState extends State<RemoteToolbar> {
|
||||
state: widget.state,
|
||||
setFullscreen: _setFullscreen,
|
||||
));
|
||||
toolbarItems.add(_KeyboardMenu(id: widget.id, ffi: widget.ffi));
|
||||
// Do not show keyboard for camera connection type.
|
||||
if (widget.ffi.connType == ConnType.defaultConn) {
|
||||
toolbarItems.add(_KeyboardMenu(id: widget.id, ffi: widget.ffi));
|
||||
}
|
||||
toolbarItems.add(_ChatMenu(id: widget.id, ffi: widget.ffi));
|
||||
if (!isWeb) {
|
||||
toolbarItems.add(_VoiceCallMenu(id: widget.id, ffi: widget.ffi));
|
||||
@@ -1043,23 +1047,26 @@ class _DisplayMenuState extends State<_DisplayMenu> {
|
||||
scrollStyle(),
|
||||
imageQuality(),
|
||||
codec(),
|
||||
_ResolutionsMenu(
|
||||
id: widget.id,
|
||||
ffi: widget.ffi,
|
||||
screenAdjustor: _screenAdjustor,
|
||||
),
|
||||
if (showVirtualDisplayMenu(ffi))
|
||||
if (ffi.connType == ConnType.defaultConn)
|
||||
_ResolutionsMenu(
|
||||
id: widget.id,
|
||||
ffi: widget.ffi,
|
||||
screenAdjustor: _screenAdjustor,
|
||||
),
|
||||
if (showVirtualDisplayMenu(ffi) && ffi.connType == ConnType.defaultConn)
|
||||
_SubmenuButton(
|
||||
ffi: widget.ffi,
|
||||
menuChildren: getVirtualDisplayMenuChildren(ffi, id, null),
|
||||
child: Text(translate("Virtual display")),
|
||||
),
|
||||
cursorToggles(),
|
||||
if (ffi.connType == ConnType.defaultConn) cursorToggles(),
|
||||
Divider(),
|
||||
toggles(),
|
||||
];
|
||||
// privacy mode
|
||||
if (ffiModel.keyboard && pi.features.privacyMode) {
|
||||
if (ffi.connType == ConnType.defaultConn &&
|
||||
ffiModel.keyboard &&
|
||||
pi.features.privacyMode) {
|
||||
final privacyModeState = PrivacyModeState.find(id);
|
||||
final privacyModeList =
|
||||
toolbarPrivacyMode(privacyModeState, context, id, ffi);
|
||||
@@ -1085,7 +1092,9 @@ class _DisplayMenuState extends State<_DisplayMenu> {
|
||||
]);
|
||||
}
|
||||
}
|
||||
menuChildren.add(widget.pluginItem);
|
||||
if (ffi.connType == ConnType.defaultConn) {
|
||||
menuChildren.add(widget.pluginItem);
|
||||
}
|
||||
return menuChildren;
|
||||
}
|
||||
|
||||
@@ -1586,10 +1595,28 @@ class _KeyboardMenu extends StatelessWidget {
|
||||
viewMode(),
|
||||
Divider(),
|
||||
...toolbarToggles(),
|
||||
...mouseSpeed(),
|
||||
...mobileActions(),
|
||||
]);
|
||||
}
|
||||
|
||||
mouseSpeed() {
|
||||
final speedWidgets = [];
|
||||
final sessionId = ffi.sessionId;
|
||||
if (isDesktop) {
|
||||
if (ffi.ffiModel.keyboard) {
|
||||
final enabled = !ffi.ffiModel.viewOnly;
|
||||
final trackpad = MenuButton(
|
||||
child: Text(translate('Trackpad speed')).paddingOnly(left: 26.0),
|
||||
onPressed: enabled ? () => trackpadSpeedDialog(sessionId, ffi) : null,
|
||||
ffi: ffi,
|
||||
);
|
||||
speedWidgets.add(trackpad);
|
||||
}
|
||||
}
|
||||
return speedWidgets;
|
||||
}
|
||||
|
||||
keyboardMode() {
|
||||
return futureBuilder(future: () async {
|
||||
return await bind.sessionGetKeyboardMode(sessionId: ffi.sessionId) ??
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:flutter/material.dart' hide TabBarTheme;
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
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/platform_model.dart';
|
||||
import 'package:flutter_hbb/models/state_model.dart';
|
||||
@@ -51,6 +52,7 @@ enum DesktopTabType {
|
||||
cm,
|
||||
remoteScreen,
|
||||
fileTransfer,
|
||||
viewCamera,
|
||||
portForward,
|
||||
install,
|
||||
}
|
||||
@@ -179,11 +181,13 @@ class DesktopTabController {
|
||||
jumpTo(state.value.tabs.indexWhere((tab) => tab.key == key),
|
||||
callOnSelected: callOnSelected);
|
||||
|
||||
bool jumpToByKeyAndDisplay(String key, int display) {
|
||||
bool jumpToByKeyAndDisplay(String key, int display, {bool isCamera = false}) {
|
||||
for (int i = 0; i < state.value.tabs.length; i++) {
|
||||
final tab = state.value.tabs[i];
|
||||
if (tab.key == key) {
|
||||
final ffi = (tab.page as RemotePage).ffi;
|
||||
final ffi = isCamera
|
||||
? (tab.page as ViewCameraPage).ffi
|
||||
: (tab.page as RemotePage).ffi;
|
||||
if (ffi.ffiModel.pi.currentDisplay == display) {
|
||||
return jumpTo(i, callOnSelected: true);
|
||||
}
|
||||
@@ -647,7 +651,9 @@ class _DesktopTabState extends State<DesktopTab>
|
||||
controller.state.value.scrollController;
|
||||
if (!sc.canScroll) return;
|
||||
_scrollDebounce.call(() {
|
||||
sc.animateTo(sc.offset + e.scrollDelta.dy,
|
||||
double adjust = 2.5;
|
||||
sc.animateTo(
|
||||
sc.offset + e.scrollDelta.dy * adjust,
|
||||
duration: Duration(milliseconds: 200),
|
||||
curve: Curves.ease);
|
||||
});
|
||||
@@ -725,6 +731,7 @@ class WindowActionPanelState extends State<WindowActionPanel> {
|
||||
return widget.tabController.state.value.tabs.length > 1 &&
|
||||
(widget.tabController.tabType == DesktopTabType.remoteScreen ||
|
||||
widget.tabController.tabType == DesktopTabType.fileTransfer ||
|
||||
widget.tabController.tabType == DesktopTabType.viewCamera ||
|
||||
widget.tabController.tabType == DesktopTabType.portForward ||
|
||||
widget.tabController.tabType == DesktopTabType.cm);
|
||||
}
|
||||
|
||||
234
flutter/lib/desktop/widgets/update_progress.dart
Normal file
234
flutter/lib/desktop/widgets/update_progress.dart
Normal file
@@ -0,0 +1,234 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/models/platform_model.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
void handleUpdate(String releasePageUrl) {
|
||||
String downloadUrl = releasePageUrl.replaceAll('tag', 'download');
|
||||
String version = downloadUrl.substring(downloadUrl.lastIndexOf('/') + 1);
|
||||
final String downloadFile =
|
||||
bind.mainGetCommonSync(key: 'download-file-$version');
|
||||
if (downloadFile.startsWith('error:')) {
|
||||
final error = downloadFile.replaceFirst('error:', '');
|
||||
msgBox(gFFI.sessionId, 'custom-nocancel-nook-hasclose', 'Error', error,
|
||||
releasePageUrl, gFFI.dialogManager);
|
||||
return;
|
||||
}
|
||||
downloadUrl = '$downloadUrl/$downloadFile';
|
||||
|
||||
SimpleWrapper downloadId = SimpleWrapper('');
|
||||
SimpleWrapper<VoidCallback> onCanceled = SimpleWrapper(() {});
|
||||
gFFI.dialogManager.dismissAll();
|
||||
gFFI.dialogManager.show((setState, close, context) {
|
||||
return CustomAlertDialog(
|
||||
title: Text(translate('Downloading {$appName}')),
|
||||
content:
|
||||
UpdateProgress(releasePageUrl, downloadUrl, downloadId, onCanceled)
|
||||
.marginSymmetric(horizontal: 8)
|
||||
.paddingOnly(top: 12),
|
||||
actions: [
|
||||
dialogButton(translate('Cancel'), onPressed: () async {
|
||||
onCanceled.value();
|
||||
await bind.mainSetCommon(
|
||||
key: 'cancel-downloader', value: downloadId.value);
|
||||
// Wait for the downloader to be removed.
|
||||
for (int i = 0; i < 10; i++) {
|
||||
await Future.delayed(const Duration(milliseconds: 300));
|
||||
final isCanceled = 'error:Downloader not found' ==
|
||||
await bind.mainGetCommon(
|
||||
key: 'download-data-${downloadId.value}');
|
||||
if (isCanceled) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
close();
|
||||
}, isOutline: true),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
class UpdateProgress extends StatefulWidget {
|
||||
final String releasePageUrl;
|
||||
final String downloadUrl;
|
||||
final SimpleWrapper downloadId;
|
||||
final SimpleWrapper onCanceled;
|
||||
UpdateProgress(
|
||||
this.releasePageUrl, this.downloadUrl, this.downloadId, this.onCanceled,
|
||||
{Key? key})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
State<UpdateProgress> createState() => UpdateProgressState();
|
||||
}
|
||||
|
||||
class UpdateProgressState extends State<UpdateProgress> {
|
||||
Timer? _timer;
|
||||
int? _totalSize;
|
||||
int _downloadedSize = 0;
|
||||
int _getDataFailedCount = 0;
|
||||
final String _eventKeyDownloadNewVersion = 'download-new-version';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.onCanceled.value = () {
|
||||
cancelQueryTimer();
|
||||
};
|
||||
platformFFI.registerEventHandler(_eventKeyDownloadNewVersion,
|
||||
_eventKeyDownloadNewVersion, handleDownloadNewVersion,
|
||||
replace: true);
|
||||
bind.mainSetCommon(key: 'download-new-version', value: widget.downloadUrl);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
cancelQueryTimer();
|
||||
platformFFI.unregisterEventHandler(
|
||||
_eventKeyDownloadNewVersion, _eventKeyDownloadNewVersion);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void cancelQueryTimer() {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
}
|
||||
|
||||
Future<void> handleDownloadNewVersion(Map<String, dynamic> evt) async {
|
||||
if (evt.containsKey('id')) {
|
||||
widget.downloadId.value = evt['id'] as String;
|
||||
_timer = Timer.periodic(const Duration(milliseconds: 300), (timer) {
|
||||
_updateDownloadData();
|
||||
});
|
||||
} else {
|
||||
if (evt.containsKey('error')) {
|
||||
_onError(evt['error'] as String);
|
||||
} else {
|
||||
// unreachable
|
||||
_onError('$evt');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _onError(String error) {
|
||||
cancelQueryTimer();
|
||||
|
||||
debugPrint('Download new version error: $error');
|
||||
final msgBoxType = 'custom-nocancel-nook-hasclose';
|
||||
final msgBoxTitle = 'Error';
|
||||
final msgBoxText = 'download-new-version-failed-tip';
|
||||
final dialogManager = gFFI.dialogManager;
|
||||
|
||||
close() {
|
||||
dialogManager.dismissAll();
|
||||
}
|
||||
|
||||
jumplink() {
|
||||
launchUrl(Uri.parse(widget.releasePageUrl));
|
||||
dialogManager.dismissAll();
|
||||
}
|
||||
|
||||
retry() {
|
||||
dialogManager.dismissAll();
|
||||
handleUpdate(widget.releasePageUrl);
|
||||
}
|
||||
|
||||
final List<Widget> buttons = [
|
||||
dialogButton('Download', onPressed: jumplink),
|
||||
dialogButton('Retry', onPressed: retry),
|
||||
dialogButton('Close', onPressed: close),
|
||||
];
|
||||
dialogManager.dismissAll();
|
||||
dialogManager.show(
|
||||
(setState, close, context) => CustomAlertDialog(
|
||||
title: null,
|
||||
content: SelectionArea(
|
||||
child: msgboxContent(msgBoxType, msgBoxTitle, msgBoxText)),
|
||||
actions: buttons,
|
||||
),
|
||||
tag: '$msgBoxType-$msgBoxTitle-$msgBoxTitle',
|
||||
);
|
||||
}
|
||||
|
||||
void _updateDownloadData() {
|
||||
String err = '';
|
||||
String downloadData =
|
||||
bind.mainGetCommonSync(key: 'download-data-${widget.downloadId.value}');
|
||||
if (downloadData.startsWith('error:')) {
|
||||
err = downloadData.substring('error:'.length);
|
||||
} else {
|
||||
try {
|
||||
jsonDecode(downloadData).forEach((key, value) {
|
||||
if (key == 'total_size') {
|
||||
if (value != null && value is int) {
|
||||
_totalSize = value;
|
||||
}
|
||||
} else if (key == 'downloaded_size') {
|
||||
_downloadedSize = value as int;
|
||||
} else if (key == 'error') {
|
||||
if (value != null) {
|
||||
err = value.toString();
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
_getDataFailedCount += 1;
|
||||
debugPrint(
|
||||
'Failed to get download data ${widget.downloadUrl}, error $e');
|
||||
if (_getDataFailedCount > 3) {
|
||||
err = e.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (err != '') {
|
||||
_onError(err);
|
||||
} else {
|
||||
if (_totalSize != null && _downloadedSize >= _totalSize!) {
|
||||
cancelQueryTimer();
|
||||
bind.mainSetCommon(
|
||||
key: 'remove-downloader', value: widget.downloadId.value);
|
||||
if (_totalSize == 0) {
|
||||
_onError('The download file size is 0.');
|
||||
} else {
|
||||
setState(() {});
|
||||
msgBox(
|
||||
gFFI.sessionId,
|
||||
'custom-nocancel',
|
||||
'{$appName} Update',
|
||||
'{$appName}-to-update-tip',
|
||||
'',
|
||||
gFFI.dialogManager,
|
||||
onSubmit: () {
|
||||
debugPrint('Downloaded, update to new version now');
|
||||
bind.mainSetCommon(key: 'update-me', value: widget.downloadUrl);
|
||||
},
|
||||
submitTimeout: 5,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return onDownloading(context);
|
||||
}
|
||||
|
||||
Widget onDownloading(BuildContext context) {
|
||||
final value = _totalSize == null
|
||||
? 0.0
|
||||
: (_totalSize == 0 ? 1.0 : _downloadedSize / _totalSize!);
|
||||
return LinearProgressIndicator(
|
||||
value: value,
|
||||
minHeight: 20,
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
backgroundColor: Colors.grey[300],
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(Colors.blue),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import 'package:flutter_hbb/desktop/pages/desktop_tab_page.dart';
|
||||
import 'package:flutter_hbb/desktop/pages/install_page.dart';
|
||||
import 'package:flutter_hbb/desktop/pages/server_page.dart';
|
||||
import 'package:flutter_hbb/desktop/screen/desktop_file_transfer_screen.dart';
|
||||
import 'package:flutter_hbb/desktop/screen/desktop_view_camera_screen.dart';
|
||||
import 'package:flutter_hbb/desktop/screen/desktop_port_forward_screen.dart';
|
||||
import 'package:flutter_hbb/desktop/screen/desktop_remote_screen.dart';
|
||||
import 'package:flutter_hbb/desktop/widgets/refresh_wrapper.dart';
|
||||
@@ -76,6 +77,13 @@ Future<void> main(List<String> args) async {
|
||||
kAppTypeDesktopFileTransfer,
|
||||
);
|
||||
break;
|
||||
case WindowType.ViewCamera:
|
||||
desktopType = DesktopType.viewCamera;
|
||||
runMultiWindow(
|
||||
argument,
|
||||
kAppTypeDesktopViewCamera,
|
||||
);
|
||||
break;
|
||||
case WindowType.PortForward:
|
||||
desktopType = DesktopType.portForward;
|
||||
runMultiWindow(
|
||||
@@ -133,7 +141,8 @@ void runMainApp(bool startService) async {
|
||||
runApp(App());
|
||||
|
||||
// Set window option.
|
||||
WindowOptions windowOptions = getHiddenTitleBarWindowOptions();
|
||||
WindowOptions windowOptions =
|
||||
getHiddenTitleBarWindowOptions(isMainWindow: true);
|
||||
windowManager.waitUntilReadyToShow(windowOptions, () async {
|
||||
// Restore the location of the main window before window hide or show.
|
||||
await restoreWindowPosition(WindowType.Main);
|
||||
@@ -191,6 +200,12 @@ void runMultiWindow(
|
||||
params: argument,
|
||||
);
|
||||
break;
|
||||
case kAppTypeDesktopViewCamera:
|
||||
draggablePositions.load();
|
||||
widget = DesktopViewCameraScreen(
|
||||
params: argument,
|
||||
);
|
||||
break;
|
||||
case kAppTypeDesktopPortForward:
|
||||
widget = DesktopPortForwardScreen(
|
||||
params: argument,
|
||||
@@ -226,6 +241,19 @@ void runMultiWindow(
|
||||
await restoreWindowPosition(WindowType.FileTransfer,
|
||||
windowId: kWindowId!);
|
||||
break;
|
||||
case kAppTypeDesktopViewCamera:
|
||||
// If screen rect is set, the window will be moved to the target screen and then set fullscreen.
|
||||
if (argument['screen_rect'] == null) {
|
||||
// display can be used to control the offset of the window.
|
||||
await restoreWindowPosition(
|
||||
WindowType.ViewCamera,
|
||||
windowId: kWindowId!,
|
||||
peerId: argument['id'] as String?,
|
||||
// FIXME: fix display index.
|
||||
display: argument['display'] as int?,
|
||||
);
|
||||
}
|
||||
break;
|
||||
case kAppTypeDesktopPortForward:
|
||||
await restoreWindowPosition(WindowType.PortForward, windowId: kWindowId!);
|
||||
break;
|
||||
@@ -354,7 +382,10 @@ void runInstallPage() async {
|
||||
}
|
||||
|
||||
WindowOptions getHiddenTitleBarWindowOptions(
|
||||
{Size? size, bool center = false, bool? alwaysOnTop}) {
|
||||
{bool isMainWindow = false,
|
||||
Size? size,
|
||||
bool center = false,
|
||||
bool? alwaysOnTop}) {
|
||||
var defaultTitleBarStyle = TitleBarStyle.hidden;
|
||||
// we do not hide titlebar on win7 because of the frame overflow.
|
||||
if (kUseCompatibleUiMode) {
|
||||
@@ -363,7 +394,7 @@ WindowOptions getHiddenTitleBarWindowOptions(
|
||||
return WindowOptions(
|
||||
size: size,
|
||||
center: center,
|
||||
backgroundColor: Colors.transparent,
|
||||
backgroundColor: (isMacOS && isMainWindow) ? null : Colors.transparent,
|
||||
skipTaskbar: false,
|
||||
titleBarStyle: defaultTitleBarStyle,
|
||||
alwaysOnTop: alwaysOnTop,
|
||||
@@ -485,9 +516,10 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
||||
child = keyListenerBuilder(context, child);
|
||||
}
|
||||
if (isLinux) {
|
||||
child = buildVirtualWindowFrame(context, child);
|
||||
return buildVirtualWindowFrame(context, child);
|
||||
} else {
|
||||
return workaroundWindowBorder(context, child);
|
||||
}
|
||||
return child;
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -41,10 +41,11 @@ class _ConnectionPageState extends State<ConnectionPage> {
|
||||
final _idController = IDTextEditingController();
|
||||
final RxBool _idEmpty = true.obs;
|
||||
|
||||
List<Peer> peers = [];
|
||||
final FocusNode _idFocusNode = FocusNode();
|
||||
final TextEditingController _idEditingController = TextEditingController();
|
||||
|
||||
final AllPeersLoader _allPeersLoader = AllPeersLoader();
|
||||
|
||||
bool isPeersLoading = false;
|
||||
bool isPeersLoaded = false;
|
||||
StreamSubscription? _uniLinksSubscription;
|
||||
|
||||
// https://github.com/flutter/flutter/issues/157244
|
||||
@@ -61,6 +62,8 @@ class _ConnectionPageState extends State<ConnectionPage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_allPeersLoader.init(setState);
|
||||
_idFocusNode.addListener(onFocusChanged);
|
||||
if (_idController.text.isEmpty) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
final lastRemoteId = await bind.mainGetLastRemoteId();
|
||||
@@ -71,6 +74,7 @@ class _ConnectionPageState extends State<ConnectionPage> {
|
||||
}
|
||||
});
|
||||
}
|
||||
Get.put<TextEditingController>(_idEditingController);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -99,6 +103,20 @@ class _ConnectionPageState extends State<ConnectionPage> {
|
||||
connect(context, id);
|
||||
}
|
||||
|
||||
void onFocusChanged() {
|
||||
_idEmpty.value = _idEditingController.text.isEmpty;
|
||||
if (_idFocusNode.hasFocus) {
|
||||
if (_allPeersLoader.needLoad) {
|
||||
_allPeersLoader.getAllPeers();
|
||||
}
|
||||
|
||||
final textLength = _idEditingController.value.text.length;
|
||||
// Select all to facilitate removing text, just following the behavior of address input of chrome.
|
||||
_idEditingController.selection =
|
||||
TextSelection(baseOffset: 0, extentOffset: textLength);
|
||||
}
|
||||
}
|
||||
|
||||
/// UI for software update.
|
||||
/// If _updateUrl] is not empty, shows a button to update the software.
|
||||
Widget _buildUpdateUI(String updateUrl) {
|
||||
@@ -127,18 +145,6 @@ class _ConnectionPageState extends State<ConnectionPage> {
|
||||
color: Colors.white, fontWeight: FontWeight.bold))));
|
||||
}
|
||||
|
||||
Future<void> _fetchPeers() async {
|
||||
setState(() {
|
||||
isPeersLoading = true;
|
||||
});
|
||||
await Future.delayed(Duration(milliseconds: 100));
|
||||
peers = await getAllPeers();
|
||||
setState(() {
|
||||
isPeersLoading = false;
|
||||
isPeersLoaded = true;
|
||||
});
|
||||
}
|
||||
|
||||
/// UI for the remote ID TextField.
|
||||
/// Search for a peer and connect to it if the id exists.
|
||||
Widget _buildRemoteIDTextField() {
|
||||
@@ -156,11 +162,12 @@ class _ConnectionPageState extends State<ConnectionPage> {
|
||||
Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.only(left: 16, right: 16),
|
||||
child: Autocomplete<Peer>(
|
||||
child: RawAutocomplete<Peer>(
|
||||
optionsBuilder: (TextEditingValue textEditingValue) {
|
||||
if (textEditingValue.text == '') {
|
||||
_autocompleteOpts = const Iterable<Peer>.empty();
|
||||
} else if (peers.isEmpty && !isPeersLoaded) {
|
||||
} else if (_allPeersLoader.peers.isEmpty &&
|
||||
!_allPeersLoader.isPeersLoaded) {
|
||||
Peer emptyPeer = Peer(
|
||||
id: '',
|
||||
username: '',
|
||||
@@ -174,6 +181,7 @@ class _ConnectionPageState extends State<ConnectionPage> {
|
||||
rdpPort: '',
|
||||
rdpUsername: '',
|
||||
loginName: '',
|
||||
device_group_name: '',
|
||||
);
|
||||
_autocompleteOpts = [emptyPeer];
|
||||
} else {
|
||||
@@ -187,7 +195,7 @@ class _ConnectionPageState extends State<ConnectionPage> {
|
||||
}
|
||||
String textToFind = textEditingValue.text.toLowerCase();
|
||||
|
||||
_autocompleteOpts = peers
|
||||
_autocompleteOpts = _allPeersLoader.peers
|
||||
.where((peer) =>
|
||||
peer.id.toLowerCase().contains(textToFind) ||
|
||||
peer.username
|
||||
@@ -201,25 +209,14 @@ class _ConnectionPageState extends State<ConnectionPage> {
|
||||
}
|
||||
return _autocompleteOpts;
|
||||
},
|
||||
focusNode: _idFocusNode,
|
||||
textEditingController: _idEditingController,
|
||||
fieldViewBuilder: (BuildContext context,
|
||||
TextEditingController fieldTextEditingController,
|
||||
FocusNode fieldFocusNode,
|
||||
VoidCallback onFieldSubmitted) {
|
||||
fieldTextEditingController.text = _idController.text;
|
||||
Get.put<TextEditingController>(
|
||||
fieldTextEditingController);
|
||||
fieldFocusNode.addListener(() async {
|
||||
_idEmpty.value =
|
||||
fieldTextEditingController.text.isEmpty;
|
||||
if (fieldFocusNode.hasFocus && !isPeersLoading) {
|
||||
_fetchPeers();
|
||||
}
|
||||
});
|
||||
final textLength =
|
||||
fieldTextEditingController.value.text.length;
|
||||
// select all to facilitate removing text, just following the behavior of address input of chrome
|
||||
fieldTextEditingController.selection = TextSelection(
|
||||
baseOffset: 0, extentOffset: textLength);
|
||||
updateTextAndPreserveSelection(
|
||||
fieldTextEditingController, _idController.text);
|
||||
return AutoSizeTextField(
|
||||
controller: fieldTextEditingController,
|
||||
focusNode: fieldFocusNode,
|
||||
@@ -299,7 +296,9 @@ class _ConnectionPageState extends State<ConnectionPage> {
|
||||
maxHeight: maxHeight,
|
||||
maxWidth: 320,
|
||||
),
|
||||
child: peers.isEmpty && isPeersLoading
|
||||
child: _allPeersLoader
|
||||
.peers.isEmpty &&
|
||||
!_allPeersLoader.isPeersLoaded
|
||||
? Container(
|
||||
height: 80,
|
||||
child: Center(
|
||||
@@ -362,6 +361,10 @@ class _ConnectionPageState extends State<ConnectionPage> {
|
||||
void dispose() {
|
||||
_uniLinksSubscription?.cancel();
|
||||
_idController.dispose();
|
||||
_idFocusNode.removeListener(onFocusChanged);
|
||||
_allPeersLoader.clear();
|
||||
_idFocusNode.dispose();
|
||||
_idEditingController.dispose();
|
||||
if (Get.isRegistered<IDTextEditingController>()) {
|
||||
Get.delete<IDTextEditingController>();
|
||||
}
|
||||
|
||||
@@ -204,6 +204,7 @@ class WebHomePage extends StatelessWidget {
|
||||
return;
|
||||
}
|
||||
bool isFileTransfer = false;
|
||||
bool isViewCamera = false;
|
||||
String? id;
|
||||
String? password;
|
||||
for (int i = 0; i < args.length; i++) {
|
||||
@@ -219,6 +220,11 @@ class WebHomePage extends StatelessWidget {
|
||||
id = args[i + 1];
|
||||
i++;
|
||||
break;
|
||||
case '--view-camera':
|
||||
isViewCamera = true;
|
||||
id = args[i + 1];
|
||||
i++;
|
||||
break;
|
||||
case '--password':
|
||||
password = args[i + 1];
|
||||
i++;
|
||||
@@ -228,7 +234,7 @@ class WebHomePage extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
if (id != null) {
|
||||
connect(context, id, isFileTransfer: isFileTransfer, password: password);
|
||||
connect(context, id, isFileTransfer: isFileTransfer, isViewCamera: isViewCamera, password: password);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -695,9 +695,9 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
||||
);
|
||||
if (index != null) {
|
||||
if (index < mobileActionMenus.length) {
|
||||
mobileActionMenus[index].onPressed.call();
|
||||
mobileActionMenus[index].onPressed?.call();
|
||||
} else if (index < mobileActionMenus.length + more.length) {
|
||||
menus[index - mobileActionMenus.length].onPressed.call();
|
||||
menus[index - mobileActionMenus.length].onPressed?.call();
|
||||
}
|
||||
}
|
||||
}();
|
||||
@@ -770,7 +770,7 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
||||
elevation: 8,
|
||||
);
|
||||
if (index != null && index < menus.length) {
|
||||
menus[index].onPressed.call();
|
||||
menus[index].onPressed?.call();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1267,7 +1267,7 @@ void showOptions(
|
||||
title: resolution.child,
|
||||
onTap: () {
|
||||
close();
|
||||
resolution.onPressed();
|
||||
resolution.onPressed?.call();
|
||||
},
|
||||
));
|
||||
}
|
||||
@@ -1279,7 +1279,7 @@ void showOptions(
|
||||
title: virtualDisplayMenu.child,
|
||||
onTap: () {
|
||||
close();
|
||||
virtualDisplayMenu.onPressed();
|
||||
virtualDisplayMenu.onPressed?.call();
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
@@ -80,6 +80,7 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
||||
var _enableDirectIPAccess = false;
|
||||
var _enableRecordSession = false;
|
||||
var _enableHardwareCodec = false;
|
||||
var _allowWebSocket = false;
|
||||
var _autoRecordIncomingSession = false;
|
||||
var _autoRecordOutgoingSession = false;
|
||||
var _allowAutoDisconnect = false;
|
||||
@@ -91,6 +92,7 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
||||
var _hideServer = false;
|
||||
var _hideProxy = false;
|
||||
var _hideNetwork = false;
|
||||
var _hideWebSocket = false;
|
||||
var _enableTrustedDevices = false;
|
||||
|
||||
_SettingsState() {
|
||||
@@ -105,6 +107,7 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
||||
bind.mainGetOptionSync(key: kOptionEnableRecordSession));
|
||||
_enableHardwareCodec = option2bool(kOptionEnableHwcodec,
|
||||
bind.mainGetOptionSync(key: kOptionEnableHwcodec));
|
||||
_allowWebSocket = mainGetBoolOptionSync(kOptionAllowWebSocket);
|
||||
_autoRecordIncomingSession = option2bool(kOptionAllowAutoRecordIncoming,
|
||||
bind.mainGetOptionSync(key: kOptionAllowAutoRecordIncoming));
|
||||
_autoRecordOutgoingSession = option2bool(kOptionAllowAutoRecordOutgoing,
|
||||
@@ -120,6 +123,8 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
||||
_hideProxy = bind.mainGetBuildinOption(key: kOptionHideProxySetting) == 'Y';
|
||||
_hideNetwork =
|
||||
bind.mainGetBuildinOption(key: kOptionHideNetworkSetting) == 'Y';
|
||||
_hideWebSocket =
|
||||
true; //bind.mainGetBuildinOption(key: kOptionHideWebSocketSetting) == 'Y';
|
||||
_enableTrustedDevices = mainGetBoolOptionSync(kOptionEnableTrustedDevices);
|
||||
}
|
||||
|
||||
@@ -243,7 +248,7 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
||||
Widget build(BuildContext context) {
|
||||
Provider.of<FfiModel>(context);
|
||||
final outgoingOnly = bind.isOutgoingOnly();
|
||||
final incommingOnly = bind.isIncomingOnly();
|
||||
final incomingOnly = bind.isIncomingOnly();
|
||||
final customClientSection = CustomSettingsSection(
|
||||
child: Column(
|
||||
children: [
|
||||
@@ -667,6 +672,21 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
||||
onPressed: (context) {
|
||||
changeSocks5Proxy();
|
||||
}),
|
||||
if (!disabledSettings && !_hideNetwork && !_hideWebSocket)
|
||||
SettingsTile.switchTile(
|
||||
title: Text(translate('Use WebSocket')),
|
||||
initialValue: _allowWebSocket,
|
||||
onToggle: isOptionFixed(kOptionAllowWebSocket)
|
||||
? null
|
||||
: (v) async {
|
||||
await mainSetBoolOption(kOptionAllowWebSocket, v);
|
||||
final newValue =
|
||||
await mainGetBoolOption(kOptionAllowWebSocket);
|
||||
setState(() {
|
||||
_allowWebSocket = newValue;
|
||||
});
|
||||
},
|
||||
),
|
||||
SettingsTile(
|
||||
title: Text(translate('Language')),
|
||||
leading: Icon(Icons.translate),
|
||||
@@ -728,7 +748,7 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
|
||||
});
|
||||
},
|
||||
),
|
||||
if (!incommingOnly)
|
||||
if (!incomingOnly)
|
||||
SettingsTile.switchTile(
|
||||
title:
|
||||
Text(translate('Automatically record outgoing sessions')),
|
||||
|
||||
721
flutter/lib/mobile/pages/view_camera_page.dart
Normal file
721
flutter/lib/mobile/pages/view_camera_page.dart
Normal file
@@ -0,0 +1,721 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hbb/common/shared_state.dart';
|
||||
import 'package:flutter_hbb/common/widgets/toolbar.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/models/chat_model.dart';
|
||||
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
|
||||
import 'package:flutter_svg/svg.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
import '../../common.dart';
|
||||
import '../../common/widgets/overlay.dart';
|
||||
import '../../common/widgets/dialog.dart';
|
||||
import '../../common/widgets/remote_input.dart';
|
||||
import '../../models/input_model.dart';
|
||||
import '../../models/model.dart';
|
||||
import '../../models/platform_model.dart';
|
||||
import '../../utils/image.dart';
|
||||
|
||||
final initText = '1' * 1024;
|
||||
|
||||
// Workaround for Android (default input method, Microsoft SwiftKey keyboard) when using physical keyboard.
|
||||
// When connecting a physical keyboard, `KeyEvent.physicalKey.usbHidUsage` are wrong is using Microsoft SwiftKey keyboard.
|
||||
// https://github.com/flutter/flutter/issues/159384
|
||||
// https://github.com/flutter/flutter/issues/159383
|
||||
void _disableAndroidSoftKeyboard({bool? isKeyboardVisible}) {
|
||||
if (isAndroid) {
|
||||
if (isKeyboardVisible != true) {
|
||||
// `enable_soft_keyboard` will be set to `true` when clicking the keyboard icon, in `openKeyboard()`.
|
||||
gFFI.invokeMethod("enable_soft_keyboard", false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ViewCameraPage extends StatefulWidget {
|
||||
ViewCameraPage(
|
||||
{Key? key, required this.id, this.password, this.isSharedPassword})
|
||||
: super(key: key);
|
||||
|
||||
final String id;
|
||||
final String? password;
|
||||
final bool? isSharedPassword;
|
||||
|
||||
@override
|
||||
State<ViewCameraPage> createState() => _ViewCameraPageState(id);
|
||||
}
|
||||
|
||||
class _ViewCameraPageState extends State<ViewCameraPage>
|
||||
with WidgetsBindingObserver {
|
||||
Timer? _timer;
|
||||
bool _showBar = !isWebDesktop;
|
||||
bool _showGestureHelp = false;
|
||||
Orientation? _currentOrientation;
|
||||
double _viewInsetsBottom = 0;
|
||||
|
||||
Timer? _timerDidChangeMetrics;
|
||||
|
||||
final _blockableOverlayState = BlockableOverlayState();
|
||||
|
||||
final keyboardVisibilityController = KeyboardVisibilityController();
|
||||
final FocusNode _mobileFocusNode = FocusNode();
|
||||
final FocusNode _physicalFocusNode = FocusNode();
|
||||
var _showEdit = false; // use soft keyboard
|
||||
|
||||
InputModel get inputModel => gFFI.inputModel;
|
||||
SessionID get sessionId => gFFI.sessionId;
|
||||
|
||||
final TextEditingController _textController =
|
||||
TextEditingController(text: initText);
|
||||
|
||||
_ViewCameraPageState(String id) {
|
||||
initSharedStates(id);
|
||||
gFFI.chatModel.voiceCallStatus.value = VoiceCallStatus.notStarted;
|
||||
gFFI.dialogManager.loadMobileActionsOverlayVisible();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
gFFI.ffiModel.updateEventListener(sessionId, widget.id);
|
||||
gFFI.start(
|
||||
widget.id,
|
||||
isViewCamera: true,
|
||||
password: widget.password,
|
||||
isSharedPassword: widget.isSharedPassword,
|
||||
);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: []);
|
||||
gFFI.dialogManager
|
||||
.showLoading(translate('Connecting...'), onCancel: closeConnection);
|
||||
});
|
||||
if (!isWeb) {
|
||||
WakelockPlus.enable();
|
||||
}
|
||||
_physicalFocusNode.requestFocus();
|
||||
gFFI.inputModel.listenToMouse(true);
|
||||
gFFI.qualityMonitorModel.checkShowQualityMonitor(sessionId);
|
||||
gFFI.chatModel
|
||||
.changeCurrentKey(MessageKey(widget.id, ChatModel.clientModeID));
|
||||
_blockableOverlayState.applyFfi(gFFI);
|
||||
gFFI.imageModel.addCallbackOnFirstImage((String peerId) {
|
||||
gFFI.recordingModel
|
||||
.updateStatus(bind.sessionGetIsRecording(sessionId: gFFI.sessionId));
|
||||
if (gFFI.recordingModel.start) {
|
||||
showToast(translate('Automatically record outgoing sessions'));
|
||||
}
|
||||
_disableAndroidSoftKeyboard(
|
||||
isKeyboardVisible: keyboardVisibilityController.isVisible);
|
||||
});
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
// https://github.com/flutter/flutter/issues/64935
|
||||
super.dispose();
|
||||
gFFI.dialogManager.hideMobileActionsOverlay(store: false);
|
||||
gFFI.inputModel.listenToMouse(false);
|
||||
gFFI.imageModel.disposeImage();
|
||||
gFFI.cursorModel.disposeImages();
|
||||
await gFFI.invokeMethod("enable_soft_keyboard", true);
|
||||
_mobileFocusNode.dispose();
|
||||
_physicalFocusNode.dispose();
|
||||
await gFFI.close();
|
||||
_timer?.cancel();
|
||||
_timerDidChangeMetrics?.cancel();
|
||||
gFFI.dialogManager.dismissAll();
|
||||
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
|
||||
overlays: SystemUiOverlay.values);
|
||||
if (!isWeb) {
|
||||
await WakelockPlus.disable();
|
||||
}
|
||||
removeSharedStates(widget.id);
|
||||
// `on_voice_call_closed` should be called when the connection is ended.
|
||||
// The inner logic of `on_voice_call_closed` will check if the voice call is active.
|
||||
// Only one client is considered here for now.
|
||||
gFFI.chatModel.onVoiceCallClosed("End connetion");
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {}
|
||||
|
||||
@override
|
||||
void didChangeMetrics() {
|
||||
// If the soft keyboard is visible and the canvas has been changed(panned or scaled)
|
||||
// Don't try reset the view style and focus the cursor.
|
||||
if (gFFI.cursorModel.lastKeyboardIsVisible &&
|
||||
gFFI.canvasModel.isMobileCanvasChanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
final newBottom = MediaQueryData.fromView(ui.window).viewInsets.bottom;
|
||||
_timerDidChangeMetrics?.cancel();
|
||||
_timerDidChangeMetrics = Timer(Duration(milliseconds: 100), () async {
|
||||
// We need this comparation because poping up the floating action will also trigger `didChangeMetrics()`.
|
||||
if (newBottom != _viewInsetsBottom) {
|
||||
gFFI.canvasModel.mobileFocusCanvasCursor();
|
||||
_viewInsetsBottom = newBottom;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// to-do: It should be better to use transparent color instead of the bgColor.
|
||||
// But for now, the transparent color will cause the canvas to be white.
|
||||
// I'm sure that the white color is caused by the Overlay widget in BlockableOverlay.
|
||||
// But I don't know why and how to fix it.
|
||||
Widget emptyOverlay(Color bgColor) => BlockableOverlay(
|
||||
/// the Overlay key will be set with _blockableOverlayState in BlockableOverlay
|
||||
/// see override build() in [BlockableOverlay]
|
||||
state: _blockableOverlayState,
|
||||
underlying: Container(
|
||||
color: bgColor,
|
||||
),
|
||||
);
|
||||
|
||||
Widget _bottomWidget() => (_showBar && gFFI.ffiModel.pi.displays.isNotEmpty
|
||||
? getBottomAppBar()
|
||||
: Offstage());
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final keyboardIsVisible =
|
||||
keyboardVisibilityController.isVisible && _showEdit;
|
||||
final showActionButton = !_showBar || keyboardIsVisible || _showGestureHelp;
|
||||
|
||||
return WillPopScope(
|
||||
onWillPop: () async {
|
||||
clientClose(sessionId, gFFI.dialogManager);
|
||||
return false;
|
||||
},
|
||||
child: Scaffold(
|
||||
// workaround for https://github.com/rustdesk/rustdesk/issues/3131
|
||||
floatingActionButtonLocation: keyboardIsVisible
|
||||
? FABLocation(FloatingActionButtonLocation.endFloat, 0, -35)
|
||||
: null,
|
||||
floatingActionButton: !showActionButton
|
||||
? null
|
||||
: FloatingActionButton(
|
||||
mini: !keyboardIsVisible,
|
||||
child: Icon(
|
||||
(keyboardIsVisible || _showGestureHelp)
|
||||
? Icons.expand_more
|
||||
: Icons.expand_less,
|
||||
color: Colors.white,
|
||||
),
|
||||
backgroundColor: MyTheme.accent,
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
if (keyboardIsVisible) {
|
||||
_showEdit = false;
|
||||
gFFI.invokeMethod("enable_soft_keyboard", false);
|
||||
_mobileFocusNode.unfocus();
|
||||
_physicalFocusNode.requestFocus();
|
||||
} else if (_showGestureHelp) {
|
||||
_showGestureHelp = false;
|
||||
} else {
|
||||
_showBar = !_showBar;
|
||||
}
|
||||
});
|
||||
}),
|
||||
bottomNavigationBar: Obx(() => Stack(
|
||||
alignment: Alignment.bottomCenter,
|
||||
children: [
|
||||
gFFI.ffiModel.pi.isSet.isTrue &&
|
||||
gFFI.ffiModel.waitForFirstImage.isTrue
|
||||
? emptyOverlay(MyTheme.canvasColor)
|
||||
: () {
|
||||
gFFI.ffiModel.tryShowAndroidActionsOverlay();
|
||||
return Offstage();
|
||||
}(),
|
||||
_bottomWidget(),
|
||||
gFFI.ffiModel.pi.isSet.isFalse
|
||||
? emptyOverlay(MyTheme.canvasColor)
|
||||
: Offstage(),
|
||||
],
|
||||
)),
|
||||
body: Obx(
|
||||
() => getRawPointerAndKeyBody(Overlay(
|
||||
initialEntries: [
|
||||
OverlayEntry(builder: (context) {
|
||||
return Container(
|
||||
color: kColorCanvas,
|
||||
child: SafeArea(
|
||||
child: OrientationBuilder(builder: (ctx, orientation) {
|
||||
if (_currentOrientation != orientation) {
|
||||
Timer(const Duration(milliseconds: 200), () {
|
||||
gFFI.dialogManager
|
||||
.resetMobileActionsOverlay(ffi: gFFI);
|
||||
_currentOrientation = orientation;
|
||||
gFFI.canvasModel.updateViewStyle();
|
||||
});
|
||||
}
|
||||
return Container(
|
||||
color: MyTheme.canvasColor,
|
||||
child: inputModel.isPhysicalMouse.value
|
||||
? getBodyForMobile()
|
||||
: RawTouchGestureDetectorRegion(
|
||||
child: getBodyForMobile(),
|
||||
ffi: gFFI,
|
||||
isCamera: true,
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
})
|
||||
],
|
||||
)),
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
Widget getRawPointerAndKeyBody(Widget child) {
|
||||
return CameraRawPointerMouseRegion(
|
||||
inputModel: inputModel,
|
||||
// Disable RawKeyFocusScope before the connecting is established.
|
||||
// The "Delete" key on the soft keyboard may be grabbed when inputting the password dialog.
|
||||
child: gFFI.ffiModel.pi.isSet.isTrue
|
||||
? RawKeyFocusScope(
|
||||
focusNode: _physicalFocusNode,
|
||||
inputModel: inputModel,
|
||||
child: child)
|
||||
: child,
|
||||
);
|
||||
}
|
||||
|
||||
Widget getBottomAppBar() {
|
||||
return BottomAppBar(
|
||||
elevation: 10,
|
||||
color: MyTheme.accent,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
children: <Widget>[
|
||||
IconButton(
|
||||
color: Colors.white,
|
||||
icon: Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
clientClose(sessionId, gFFI.dialogManager);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
color: Colors.white,
|
||||
icon: Icon(Icons.tv),
|
||||
onPressed: () {
|
||||
setState(() => _showEdit = false);
|
||||
showOptions(context, widget.id, gFFI.dialogManager);
|
||||
},
|
||||
)
|
||||
] +
|
||||
(isWeb
|
||||
? []
|
||||
: <Widget>[
|
||||
futureBuilder(
|
||||
future: gFFI.invokeMethod(
|
||||
"get_value", "KEY_IS_SUPPORT_VOICE_CALL"),
|
||||
hasData: (isSupportVoiceCall) => IconButton(
|
||||
color: Colors.white,
|
||||
icon: isAndroid && isSupportVoiceCall
|
||||
? SvgPicture.asset('assets/chat.svg',
|
||||
colorFilter: ColorFilter.mode(
|
||||
Colors.white, BlendMode.srcIn))
|
||||
: Icon(Icons.message),
|
||||
onPressed: () =>
|
||||
isAndroid && isSupportVoiceCall
|
||||
? showChatOptions(widget.id)
|
||||
: onPressedTextChat(widget.id),
|
||||
))
|
||||
]) +
|
||||
[
|
||||
IconButton(
|
||||
color: Colors.white,
|
||||
icon: Icon(Icons.more_vert),
|
||||
onPressed: () {
|
||||
setState(() => _showEdit = false);
|
||||
showActions(widget.id);
|
||||
},
|
||||
),
|
||||
]),
|
||||
Obx(() => IconButton(
|
||||
color: Colors.white,
|
||||
icon: Icon(Icons.expand_more),
|
||||
onPressed: gFFI.ffiModel.waitForFirstImage.isTrue
|
||||
? null
|
||||
: () {
|
||||
setState(() => _showBar = !_showBar);
|
||||
},
|
||||
)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget getBodyForMobile() {
|
||||
return Container(
|
||||
color: MyTheme.canvasColor,
|
||||
child: Stack(children: () {
|
||||
final paints = [
|
||||
ImagePaint(),
|
||||
Positioned(
|
||||
top: 10,
|
||||
right: 10,
|
||||
child: QualityMonitor(gFFI.qualityMonitorModel),
|
||||
),
|
||||
SizedBox(
|
||||
width: 0,
|
||||
height: 0,
|
||||
child: !_showEdit
|
||||
? Container()
|
||||
: TextFormField(
|
||||
textInputAction: TextInputAction.newline,
|
||||
autocorrect: false,
|
||||
// Flutter 3.16.9 Android.
|
||||
// `enableSuggestions` causes secure keyboard to be shown.
|
||||
// https://github.com/flutter/flutter/issues/139143
|
||||
// https://github.com/flutter/flutter/issues/146540
|
||||
// enableSuggestions: false,
|
||||
autofocus: true,
|
||||
focusNode: _mobileFocusNode,
|
||||
maxLines: null,
|
||||
controller: _textController,
|
||||
// trick way to make backspace work always
|
||||
keyboardType: TextInputType.multiline,
|
||||
// `onChanged` may be called depending on the input method if this widget is wrapped in
|
||||
// `Focus(onKeyEvent: ..., child: ...)`
|
||||
// For `Backspace` button in the soft keyboard:
|
||||
// en/fr input method:
|
||||
// 1. The button will not trigger `onKeyEvent` if the text field is not empty.
|
||||
// 2. The button will trigger `onKeyEvent` if the text field is empty.
|
||||
// ko/zh/ja input method: the button will trigger `onKeyEvent`
|
||||
// and the event will not popup if `KeyEventResult.handled` is returned.
|
||||
onChanged: null,
|
||||
).workaroundFreezeLinuxMint(),
|
||||
),
|
||||
];
|
||||
return paints;
|
||||
}()));
|
||||
}
|
||||
|
||||
Widget getBodyForDesktopWithListener() {
|
||||
var paints = <Widget>[ImagePaint()];
|
||||
return Container(
|
||||
color: MyTheme.canvasColor, child: Stack(children: paints));
|
||||
}
|
||||
|
||||
List<TTextMenu> _getMobileActionMenus() {
|
||||
if (gFFI.ffiModel.pi.platform != kPeerPlatformAndroid ||
|
||||
!gFFI.ffiModel.keyboard) {
|
||||
return [];
|
||||
}
|
||||
final enabled = versionCmp(gFFI.ffiModel.pi.version, '1.2.7') >= 0;
|
||||
if (!enabled) return [];
|
||||
return [
|
||||
TTextMenu(
|
||||
child: Text(translate('Back')),
|
||||
onPressed: () => gFFI.inputModel.onMobileBack(),
|
||||
),
|
||||
TTextMenu(
|
||||
child: Text(translate('Home')),
|
||||
onPressed: () => gFFI.inputModel.onMobileHome(),
|
||||
),
|
||||
TTextMenu(
|
||||
child: Text(translate('Apps')),
|
||||
onPressed: () => gFFI.inputModel.onMobileApps(),
|
||||
),
|
||||
TTextMenu(
|
||||
child: Text(translate('Volume up')),
|
||||
onPressed: () => gFFI.inputModel.onMobileVolumeUp(),
|
||||
),
|
||||
TTextMenu(
|
||||
child: Text(translate('Volume down')),
|
||||
onPressed: () => gFFI.inputModel.onMobileVolumeDown(),
|
||||
),
|
||||
TTextMenu(
|
||||
child: Text(translate('Power')),
|
||||
onPressed: () => gFFI.inputModel.onMobilePower(),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
void showActions(String id) async {
|
||||
final size = MediaQuery.of(context).size;
|
||||
final x = 120.0;
|
||||
final y = size.height;
|
||||
final mobileActionMenus = _getMobileActionMenus();
|
||||
final menus = toolbarControls(context, id, gFFI);
|
||||
|
||||
final List<PopupMenuEntry<int>> more = [
|
||||
...mobileActionMenus
|
||||
.asMap()
|
||||
.entries
|
||||
.map((e) =>
|
||||
PopupMenuItem<int>(child: e.value.getChild(), value: e.key))
|
||||
.toList(),
|
||||
if (mobileActionMenus.isNotEmpty) PopupMenuDivider(),
|
||||
...menus
|
||||
.asMap()
|
||||
.entries
|
||||
.map((e) => PopupMenuItem<int>(
|
||||
child: e.value.getChild(),
|
||||
value: e.key + mobileActionMenus.length))
|
||||
.toList(),
|
||||
];
|
||||
() async {
|
||||
var index = await showMenu(
|
||||
context: context,
|
||||
position: RelativeRect.fromLTRB(x, y, x, y),
|
||||
items: more,
|
||||
elevation: 8,
|
||||
);
|
||||
if (index != null) {
|
||||
if (index < mobileActionMenus.length) {
|
||||
mobileActionMenus[index].onPressed?.call();
|
||||
} else if (index < mobileActionMenus.length + more.length) {
|
||||
menus[index - mobileActionMenus.length].onPressed?.call();
|
||||
}
|
||||
}
|
||||
}();
|
||||
}
|
||||
|
||||
onPressedTextChat(String id) {
|
||||
gFFI.chatModel.changeCurrentKey(MessageKey(id, ChatModel.clientModeID));
|
||||
gFFI.chatModel.toggleChatOverlay();
|
||||
}
|
||||
|
||||
showChatOptions(String id) async {
|
||||
onPressVoiceCall() => bind.sessionRequestVoiceCall(sessionId: sessionId);
|
||||
onPressEndVoiceCall() => bind.sessionCloseVoiceCall(sessionId: sessionId);
|
||||
|
||||
makeTextMenu(String label, Widget icon, VoidCallback onPressed,
|
||||
{TextStyle? labelStyle}) =>
|
||||
TTextMenu(
|
||||
child: Text(translate(label), style: labelStyle),
|
||||
trailingIcon: Transform.scale(
|
||||
scale: (isDesktop || isWebDesktop) ? 0.8 : 1,
|
||||
child: IgnorePointer(
|
||||
child: IconButton(
|
||||
onPressed: null,
|
||||
icon: icon,
|
||||
),
|
||||
),
|
||||
),
|
||||
onPressed: onPressed,
|
||||
);
|
||||
|
||||
final isInVoice = [
|
||||
VoiceCallStatus.waitingForResponse,
|
||||
VoiceCallStatus.connected
|
||||
].contains(gFFI.chatModel.voiceCallStatus.value);
|
||||
final menus = [
|
||||
makeTextMenu('Text chat', Icon(Icons.message, color: MyTheme.accent),
|
||||
() => onPressedTextChat(widget.id)),
|
||||
isInVoice
|
||||
? makeTextMenu(
|
||||
'End voice call',
|
||||
SvgPicture.asset(
|
||||
'assets/call_wait.svg',
|
||||
colorFilter:
|
||||
ColorFilter.mode(Colors.redAccent, BlendMode.srcIn),
|
||||
),
|
||||
onPressEndVoiceCall,
|
||||
labelStyle: TextStyle(color: Colors.redAccent))
|
||||
: makeTextMenu(
|
||||
'Voice call',
|
||||
SvgPicture.asset(
|
||||
'assets/call_wait.svg',
|
||||
colorFilter: ColorFilter.mode(MyTheme.accent, BlendMode.srcIn),
|
||||
),
|
||||
onPressVoiceCall),
|
||||
];
|
||||
|
||||
final menuItems = menus
|
||||
.asMap()
|
||||
.entries
|
||||
.map((e) => PopupMenuItem<int>(child: e.value.getChild(), value: e.key))
|
||||
.toList();
|
||||
Future.delayed(Duration.zero, () async {
|
||||
final size = MediaQuery.of(context).size;
|
||||
final x = 120.0;
|
||||
final y = size.height;
|
||||
var index = await showMenu(
|
||||
context: context,
|
||||
position: RelativeRect.fromLTRB(x, y, x, y),
|
||||
items: menuItems,
|
||||
elevation: 8,
|
||||
);
|
||||
if (index != null && index < menus.length) {
|
||||
menus[index].onPressed?.call();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class ImagePaint extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final m = Provider.of<ImageModel>(context);
|
||||
final c = Provider.of<CanvasModel>(context);
|
||||
var s = c.scale;
|
||||
final adjust = c.getAdjustY();
|
||||
return CustomPaint(
|
||||
painter: ImagePainter(
|
||||
image: m.image, x: c.x / s, y: (c.y + adjust) / s, scale: s),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void showOptions(
|
||||
BuildContext context, String id, OverlayDialogManager dialogManager) async {
|
||||
var displays = <Widget>[];
|
||||
final pi = gFFI.ffiModel.pi;
|
||||
final image = gFFI.ffiModel.getConnectionImage();
|
||||
if (image != null) {
|
||||
displays.add(Padding(padding: const EdgeInsets.only(top: 8), child: image));
|
||||
}
|
||||
if (pi.displays.length > 1 && pi.currentDisplay != kAllDisplayValue) {
|
||||
final cur = pi.currentDisplay;
|
||||
final children = <Widget>[];
|
||||
for (var i = 0; i < pi.displays.length; ++i) {
|
||||
children.add(InkWell(
|
||||
onTap: () {
|
||||
if (i == cur) return;
|
||||
openMonitorInTheSameTab(i, gFFI, pi);
|
||||
gFFI.dialogManager.dismissAll();
|
||||
},
|
||||
child: Ink(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Theme.of(context).hintColor),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
color: i == cur
|
||||
? Theme.of(context).primaryColor.withOpacity(0.6)
|
||||
: null),
|
||||
child: Center(
|
||||
child: Text((i + 1).toString(),
|
||||
style: TextStyle(
|
||||
color: i == cur ? Colors.white : Colors.black87,
|
||||
fontWeight: FontWeight.bold))))));
|
||||
}
|
||||
displays.add(Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
spacing: 8,
|
||||
children: children,
|
||||
)));
|
||||
}
|
||||
if (displays.isNotEmpty) {
|
||||
displays.add(const Divider(color: MyTheme.border));
|
||||
}
|
||||
|
||||
List<TRadioMenu<String>> viewStyleRadios =
|
||||
await toolbarViewStyle(context, id, gFFI);
|
||||
List<TRadioMenu<String>> imageQualityRadios =
|
||||
await toolbarImageQuality(context, id, gFFI);
|
||||
List<TRadioMenu<String>> codecRadios = await toolbarCodec(context, id, gFFI);
|
||||
List<TToggleMenu> displayToggles =
|
||||
await toolbarDisplayToggle(context, id, gFFI);
|
||||
|
||||
dialogManager.show((setState, close, context) {
|
||||
var viewStyle =
|
||||
(viewStyleRadios.isNotEmpty ? viewStyleRadios[0].groupValue : '').obs;
|
||||
var imageQuality =
|
||||
(imageQualityRadios.isNotEmpty ? imageQualityRadios[0].groupValue : '')
|
||||
.obs;
|
||||
var codec = (codecRadios.isNotEmpty ? codecRadios[0].groupValue : '').obs;
|
||||
final radios = [
|
||||
for (var e in viewStyleRadios)
|
||||
Obx(() => getRadio<String>(
|
||||
e.child,
|
||||
e.value,
|
||||
viewStyle.value,
|
||||
e.onChanged != null
|
||||
? (v) {
|
||||
e.onChanged?.call(v);
|
||||
if (v != null) viewStyle.value = v;
|
||||
}
|
||||
: null)),
|
||||
const Divider(color: MyTheme.border),
|
||||
for (var e in imageQualityRadios)
|
||||
Obx(() => getRadio<String>(
|
||||
e.child,
|
||||
e.value,
|
||||
imageQuality.value,
|
||||
e.onChanged != null
|
||||
? (v) {
|
||||
e.onChanged?.call(v);
|
||||
if (v != null) imageQuality.value = v;
|
||||
}
|
||||
: null)),
|
||||
const Divider(color: MyTheme.border),
|
||||
for (var e in codecRadios)
|
||||
Obx(() => getRadio<String>(
|
||||
e.child,
|
||||
e.value,
|
||||
codec.value,
|
||||
e.onChanged != null
|
||||
? (v) {
|
||||
e.onChanged?.call(v);
|
||||
if (v != null) codec.value = v;
|
||||
}
|
||||
: null)),
|
||||
if (codecRadios.isNotEmpty) const Divider(color: MyTheme.border),
|
||||
];
|
||||
|
||||
final rxToggleValues = displayToggles.map((e) => e.value.obs).toList();
|
||||
final displayTogglesList = displayToggles
|
||||
.asMap()
|
||||
.entries
|
||||
.map((e) => Obx(() => CheckboxListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
visualDensity: VisualDensity.compact,
|
||||
value: rxToggleValues[e.key].value,
|
||||
onChanged: e.value.onChanged != null
|
||||
? (v) {
|
||||
e.value.onChanged?.call(v);
|
||||
if (v != null) rxToggleValues[e.key].value = v;
|
||||
}
|
||||
: null,
|
||||
title: e.value.child)))
|
||||
.toList();
|
||||
final toggles = [
|
||||
...displayTogglesList,
|
||||
];
|
||||
|
||||
var popupDialogMenus = List<Widget>.empty(growable: true);
|
||||
if (popupDialogMenus.isNotEmpty) {
|
||||
popupDialogMenus.add(const Divider(color: MyTheme.border));
|
||||
}
|
||||
|
||||
return CustomAlertDialog(
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: displays + radios + popupDialogMenus + toggles),
|
||||
);
|
||||
}, clickMaskDismiss: true, backDismiss: true).then((value) {
|
||||
_disableAndroidSoftKeyboard();
|
||||
});
|
||||
}
|
||||
|
||||
class FABLocation extends FloatingActionButtonLocation {
|
||||
FloatingActionButtonLocation location;
|
||||
double offsetX;
|
||||
double offsetY;
|
||||
FABLocation(this.location, this.offsetX, this.offsetY);
|
||||
|
||||
@override
|
||||
Offset getOffset(ScaffoldPrelayoutGeometry scaffoldGeometry) {
|
||||
final offset = location.getOffset(scaffoldGeometry);
|
||||
return Offset(offset.dx + offsetX, offset.dy + offsetY);
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,9 @@ class AbModel {
|
||||
String? _personalAbGuid;
|
||||
RxBool legacyMode = false.obs;
|
||||
|
||||
// Only handles peers add/remove
|
||||
final Map<String, VoidCallback> _peerIdUpdateListeners = {};
|
||||
|
||||
final sortTags = shouldSortTags().obs;
|
||||
final filterByIntersection = filterAbTagByIntersection().obs;
|
||||
|
||||
@@ -188,6 +191,7 @@ class AbModel {
|
||||
debugPrint("pull current Ab error: $e");
|
||||
}
|
||||
}
|
||||
_callbackPeerUpdate();
|
||||
if (listInitialized && current.initialized) {
|
||||
_saveCache();
|
||||
}
|
||||
@@ -419,6 +423,7 @@ class AbModel {
|
||||
}
|
||||
});
|
||||
}
|
||||
_callbackPeerUpdate();
|
||||
return ret;
|
||||
}
|
||||
|
||||
@@ -620,6 +625,9 @@ class AbModel {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (abEntries.isNotEmpty) {
|
||||
_callbackPeerUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -742,6 +750,20 @@ class AbModel {
|
||||
}
|
||||
}
|
||||
|
||||
void _callbackPeerUpdate() {
|
||||
for (var listener in _peerIdUpdateListeners.values) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
void addPeerUpdateListener(String key, VoidCallback listener) {
|
||||
_peerIdUpdateListeners[key] = listener;
|
||||
}
|
||||
|
||||
void removePeerUpdateListener(String key) {
|
||||
_peerIdUpdateListeners.remove(key);
|
||||
}
|
||||
|
||||
// #endregion
|
||||
}
|
||||
|
||||
@@ -753,7 +775,10 @@ abstract class BaseAb {
|
||||
|
||||
final pullError = "".obs;
|
||||
final pushError = "".obs;
|
||||
final abLoading = false.obs;
|
||||
final abLoading = false
|
||||
.obs; // Indicates whether the UI should show a loading state for the address book.
|
||||
var abPulling =
|
||||
false; // Tracks whether a pull operation is currently in progress to prevent concurrent pulls. Unlike abLoading, this is not tied to UI updates.
|
||||
bool initialized = false;
|
||||
|
||||
String name();
|
||||
@@ -768,17 +793,22 @@ abstract class BaseAb {
|
||||
}
|
||||
|
||||
Future<void> pullAb({quiet = false}) async {
|
||||
debugPrint("pull ab \"${name()}\"");
|
||||
if (abLoading.value) return;
|
||||
if (abPulling) return;
|
||||
abPulling = true;
|
||||
if (!quiet) {
|
||||
abLoading.value = true;
|
||||
pullError.value = "";
|
||||
}
|
||||
initialized = false;
|
||||
debugPrint("pull ab \"${name()}\"");
|
||||
try {
|
||||
initialized = await pullAbImpl(quiet: quiet);
|
||||
} catch (_) {}
|
||||
abLoading.value = false;
|
||||
} catch (e) {
|
||||
debugPrint("Error occurred while pulling address book: $e");
|
||||
} finally {
|
||||
abLoading.value = false;
|
||||
abPulling = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> pullAbImpl({quiet = false});
|
||||
|
||||
@@ -235,6 +235,17 @@ class TextureModel {
|
||||
}
|
||||
}
|
||||
|
||||
onViewCameraPageDispose(bool closeSession) async {
|
||||
final ffi = parent.target;
|
||||
if (ffi == null) return;
|
||||
for (final texture in _pixelbufferRenderTextures.values) {
|
||||
await texture.destroy(closeSession, ffi);
|
||||
}
|
||||
for (final texture in _gpuRenderTextures.values) {
|
||||
await texture.destroy(closeSession, ffi);
|
||||
}
|
||||
}
|
||||
|
||||
ensureControl(int display) {
|
||||
var ctl = _control[display];
|
||||
if (ctl == null) {
|
||||
|
||||
@@ -30,8 +30,15 @@ enum SortBy {
|
||||
class JobID {
|
||||
int _count = 0;
|
||||
int next() {
|
||||
_count++;
|
||||
return _count;
|
||||
String v = bind.mainGetCommonSync(key: 'transfer-job-id');
|
||||
try {
|
||||
return int.parse(v);
|
||||
} catch (e) {
|
||||
// unreachable. But we still handle it to make it safe.
|
||||
// If we return -1, we have to check it in the caller.
|
||||
_count++;
|
||||
return _count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,16 +12,20 @@ import '../utils/http_service.dart' as http;
|
||||
class GroupModel {
|
||||
final RxBool groupLoading = false.obs;
|
||||
final RxString groupLoadError = "".obs;
|
||||
final RxList<DeviceGroupPayload> deviceGroups = RxList.empty(growable: true);
|
||||
final RxList<UserPayload> users = RxList.empty(growable: true);
|
||||
final RxList<Peer> peers = RxList.empty(growable: true);
|
||||
final RxString selectedUser = ''.obs;
|
||||
final RxString searchUserText = ''.obs;
|
||||
final RxBool isSelectedDeviceGroup = false.obs;
|
||||
final RxString selectedAccessibleItemName = ''.obs;
|
||||
final RxString searchAccessibleItemNameText = ''.obs;
|
||||
WeakReference<FFI> parent;
|
||||
var initialized = false;
|
||||
var _cacheLoadOnceFlag = false;
|
||||
var _statusCode = 200;
|
||||
|
||||
bool get emtpy => users.isEmpty && peers.isEmpty;
|
||||
final Map<String, VoidCallback> _peerIdUpdateListeners = {};
|
||||
|
||||
bool get emtpy => deviceGroups.isEmpty && users.isEmpty && peers.isEmpty;
|
||||
|
||||
late final Peers peersModel;
|
||||
|
||||
@@ -55,6 +59,12 @@ class GroupModel {
|
||||
}
|
||||
|
||||
Future<void> _pull() async {
|
||||
List<DeviceGroupPayload> tmpDeviceGroups = List.empty(growable: true);
|
||||
if (!await _getDeviceGroups(tmpDeviceGroups)) {
|
||||
// old hbbs doesn't support this api
|
||||
// return;
|
||||
}
|
||||
tmpDeviceGroups.sort((a, b) => a.name.compareTo(b.name));
|
||||
List<UserPayload> tmpUsers = List.empty(growable: true);
|
||||
if (!await _getUsers(tmpUsers)) {
|
||||
return;
|
||||
@@ -63,6 +73,7 @@ class GroupModel {
|
||||
if (!await _getPeers(tmpPeers)) {
|
||||
return;
|
||||
}
|
||||
deviceGroups.value = tmpDeviceGroups;
|
||||
// me first
|
||||
var index = tmpUsers
|
||||
.indexWhere((user) => user.name == gFFI.userModel.userName.value);
|
||||
@@ -71,8 +82,9 @@ class GroupModel {
|
||||
tmpUsers.insert(0, user);
|
||||
}
|
||||
users.value = tmpUsers;
|
||||
if (!users.any((u) => u.name == selectedUser.value)) {
|
||||
selectedUser.value = '';
|
||||
if (!users.any((u) => u.name == selectedAccessibleItemName.value) &&
|
||||
!deviceGroups.any((d) => d.name == selectedAccessibleItemName.value)) {
|
||||
selectedAccessibleItemName.value = '';
|
||||
}
|
||||
// recover online
|
||||
final oldOnlineIDs = peers.where((e) => e.online).map((e) => e.id).toList();
|
||||
@@ -82,6 +94,64 @@ class GroupModel {
|
||||
.map((e) => e.online = true)
|
||||
.toList();
|
||||
groupLoadError.value = '';
|
||||
_callbackPeerUpdate();
|
||||
}
|
||||
|
||||
Future<bool> _getDeviceGroups(
|
||||
List<DeviceGroupPayload> tmpDeviceGroups) async {
|
||||
final api = "${await bind.mainGetApiServer()}/api/device-group/accessible";
|
||||
try {
|
||||
var uri0 = Uri.parse(api);
|
||||
final pageSize = 100;
|
||||
var total = 0;
|
||||
int current = 0;
|
||||
do {
|
||||
current += 1;
|
||||
var uri = Uri(
|
||||
scheme: uri0.scheme,
|
||||
host: uri0.host,
|
||||
path: uri0.path,
|
||||
port: uri0.port,
|
||||
queryParameters: {
|
||||
'current': current.toString(),
|
||||
'pageSize': pageSize.toString(),
|
||||
});
|
||||
final resp = await http.get(uri, headers: getHttpHeaders());
|
||||
_statusCode = resp.statusCode;
|
||||
Map<String, dynamic> json =
|
||||
_jsonDecodeResp(utf8.decode(resp.bodyBytes), resp.statusCode);
|
||||
if (json.containsKey('error')) {
|
||||
throw json['error'];
|
||||
}
|
||||
if (resp.statusCode != 200) {
|
||||
throw 'HTTP ${resp.statusCode}';
|
||||
}
|
||||
if (json.containsKey('total')) {
|
||||
if (total == 0) total = json['total'];
|
||||
if (json.containsKey('data')) {
|
||||
final data = json['data'];
|
||||
if (data is List) {
|
||||
for (final user in data) {
|
||||
final u = DeviceGroupPayload.fromJson(user);
|
||||
int index = tmpDeviceGroups.indexWhere((e) => e.name == u.name);
|
||||
if (index < 0) {
|
||||
tmpDeviceGroups.add(u);
|
||||
} else {
|
||||
tmpDeviceGroups[index] = u;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} while (current * pageSize < total);
|
||||
return true;
|
||||
} catch (err) {
|
||||
debugPrint('get accessible device groups: $err');
|
||||
// old hbbs doesn't support this api
|
||||
// groupLoadError.value =
|
||||
// '${translate('pull_group_failed_tip')}: ${translate(err.toString())}';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> _getUsers(List<UserPayload> tmpUsers) async {
|
||||
@@ -225,6 +295,7 @@ class GroupModel {
|
||||
try {
|
||||
final map = (<String, dynamic>{
|
||||
"access_token": bind.mainGetLocalOption(key: 'access_token'),
|
||||
"device_groups": deviceGroups.map((e) => e.toGroupCacheJson()).toList(),
|
||||
"users": users.map((e) => e.toGroupCacheJson()).toList(),
|
||||
'peers': peers.map((e) => e.toGroupCacheJson()).toList()
|
||||
});
|
||||
@@ -244,8 +315,14 @@ class GroupModel {
|
||||
if (groupLoading.value) return;
|
||||
final data = jsonDecode(cache);
|
||||
if (data == null || data['access_token'] != access_token) return;
|
||||
deviceGroups.clear();
|
||||
users.clear();
|
||||
peers.clear();
|
||||
if (data['device_groups'] is List) {
|
||||
for (var u in data['device_groups']) {
|
||||
deviceGroups.add(DeviceGroupPayload.fromJson(u));
|
||||
}
|
||||
}
|
||||
if (data['users'] is List) {
|
||||
for (var u in data['users']) {
|
||||
users.add(UserPayload.fromJson(u));
|
||||
@@ -255,6 +332,7 @@ class GroupModel {
|
||||
for (final peer in data['peers']) {
|
||||
peers.add(Peer.fromJson(peer));
|
||||
}
|
||||
_callbackPeerUpdate();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("load group cache: $e");
|
||||
@@ -263,9 +341,24 @@ class GroupModel {
|
||||
|
||||
reset() async {
|
||||
groupLoadError.value = '';
|
||||
deviceGroups.clear();
|
||||
users.clear();
|
||||
peers.clear();
|
||||
selectedUser.value = '';
|
||||
selectedAccessibleItemName.value = '';
|
||||
await bind.mainClearGroup();
|
||||
}
|
||||
|
||||
void _callbackPeerUpdate() {
|
||||
for (var listener in _peerIdUpdateListeners.values) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
void addPeerUpdateListener(String key, VoidCallback listener) {
|
||||
_peerIdUpdateListeners[key] = listener;
|
||||
}
|
||||
|
||||
void removePeerUpdateListener(String key) {
|
||||
_peerIdUpdateListeners.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import '../common.dart';
|
||||
import '../consts.dart';
|
||||
|
||||
/// Mouse button enum.
|
||||
enum MouseButtons { left, right, wheel }
|
||||
enum MouseButtons { left, right, wheel, back }
|
||||
|
||||
const _kMouseEventDown = 'mousedown';
|
||||
const _kMouseEventUp = 'mouseup';
|
||||
@@ -155,6 +155,8 @@ extension ToString on MouseButtons {
|
||||
return 'right';
|
||||
case MouseButtons.wheel:
|
||||
return 'wheel';
|
||||
case MouseButtons.back:
|
||||
return 'back';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -343,8 +345,11 @@ class InputModel {
|
||||
var _fling = false;
|
||||
Timer? _flingTimer;
|
||||
final _flingBaseDelay = 30;
|
||||
// trackpad, peer linux
|
||||
final _trackpadSpeed = 0.06;
|
||||
final _trackpadAdjustPeerLinux = 0.06;
|
||||
// This is an experience value.
|
||||
final _trackpadAdjustMacToWin = 2.50;
|
||||
int _trackpadSpeed = kDefaultTrackpadSpeed;
|
||||
double _trackpadSpeedInner = kDefaultTrackpadSpeed / 100.0;
|
||||
var _trackpadScrollUnsent = Offset.zero;
|
||||
|
||||
var _lastScale = 1.0;
|
||||
@@ -367,6 +372,8 @@ class InputModel {
|
||||
String? get peerPlatform => parent.target?.ffiModel.pi.platform;
|
||||
bool get isViewOnly => parent.target!.ffiModel.viewOnly;
|
||||
double get devicePixelRatio => parent.target!.canvasModel.devicePixelRatio;
|
||||
bool get isViewCamera => parent.target!.connType == ConnType.viewCamera;
|
||||
int get trackpadSpeed => _trackpadSpeed;
|
||||
|
||||
InputModel(this.parent) {
|
||||
sessionId = parent.target!.sessionId;
|
||||
@@ -382,6 +389,28 @@ class InputModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the trackpad speed based on the session value.
|
||||
///
|
||||
/// The expected format of the retrieved value is a string that can be parsed into a double.
|
||||
/// If parsing fails or the value is out of bounds (less than `kMinTrackpadSpeed` or greater
|
||||
/// than `kMaxTrackpadSpeed`), the trackpad speed is reset to the default
|
||||
/// value (`kDefaultTrackpadSpeed`).
|
||||
///
|
||||
/// Bounds:
|
||||
/// - Minimum: `kMinTrackpadSpeed`
|
||||
/// - Maximum: `kMaxTrackpadSpeed`
|
||||
/// - Default: `kDefaultTrackpadSpeed`
|
||||
Future<void> updateTrackpadSpeed() async {
|
||||
_trackpadSpeed =
|
||||
(await bind.sessionGetTrackpadSpeed(sessionId: sessionId) ??
|
||||
kDefaultTrackpadSpeed);
|
||||
if (_trackpadSpeed < kMinTrackpadSpeed ||
|
||||
_trackpadSpeed > kMaxTrackpadSpeed) {
|
||||
_trackpadSpeed = kDefaultTrackpadSpeed;
|
||||
}
|
||||
_trackpadSpeedInner = _trackpadSpeed / 100.0;
|
||||
}
|
||||
|
||||
void handleKeyDownEventModifiers(KeyEvent e) {
|
||||
KeyUpEvent upEvent(e) => KeyUpEvent(
|
||||
physicalKey: e.physicalKey,
|
||||
@@ -469,6 +498,7 @@ class InputModel {
|
||||
|
||||
KeyEventResult handleRawKeyEvent(RawKeyEvent e) {
|
||||
if (isViewOnly) return KeyEventResult.handled;
|
||||
if (isViewCamera) return KeyEventResult.handled;
|
||||
if (!isInputSourceFlutter) {
|
||||
if (isDesktop) {
|
||||
return KeyEventResult.handled;
|
||||
@@ -523,6 +553,7 @@ class InputModel {
|
||||
|
||||
KeyEventResult handleKeyEvent(KeyEvent e) {
|
||||
if (isViewOnly) return KeyEventResult.handled;
|
||||
if (isViewCamera) return KeyEventResult.handled;
|
||||
if (!isInputSourceFlutter) {
|
||||
if (isDesktop) {
|
||||
return KeyEventResult.handled;
|
||||
@@ -722,6 +753,7 @@ class InputModel {
|
||||
/// [press] indicates a click event(down and up).
|
||||
void inputKey(String name, {bool? down, bool? press}) {
|
||||
if (!keyboardPerm) return;
|
||||
if (isViewCamera) return;
|
||||
bind.sessionInputKey(
|
||||
sessionId: sessionId,
|
||||
name: name,
|
||||
@@ -783,6 +815,7 @@ class InputModel {
|
||||
|
||||
/// Send scroll event with scroll distance [y].
|
||||
Future<void> scroll(int y) async {
|
||||
if (isViewCamera) return;
|
||||
await bind.sessionSendMouse(
|
||||
sessionId: sessionId,
|
||||
msg: json
|
||||
@@ -806,6 +839,7 @@ class InputModel {
|
||||
/// Send mouse press event.
|
||||
Future<void> sendMouse(String type, MouseButtons button) async {
|
||||
if (!keyboardPerm) return;
|
||||
if (isViewCamera) return;
|
||||
await bind.sessionSendMouse(
|
||||
sessionId: sessionId,
|
||||
msg: json.encode(modify({'type': type, 'buttons': button.value})));
|
||||
@@ -832,6 +866,7 @@ class InputModel {
|
||||
/// Send mouse movement event with distance in [x] and [y].
|
||||
Future<void> moveMouse(double x, double y) async {
|
||||
if (!keyboardPerm) return;
|
||||
if (isViewCamera) return;
|
||||
var x2 = x.toInt();
|
||||
var y2 = y.toInt();
|
||||
await bind.sessionSendMouse(
|
||||
@@ -855,6 +890,7 @@ class InputModel {
|
||||
_lastScale = 1.0;
|
||||
_stopFling = true;
|
||||
if (isViewOnly) return;
|
||||
if (isViewCamera) return;
|
||||
if (peerPlatform == kPeerPlatformAndroid) {
|
||||
handlePointerEvent('touch', kMouseEventTypePanStart, e.position);
|
||||
}
|
||||
@@ -863,6 +899,7 @@ class InputModel {
|
||||
// https://docs.flutter.dev/release/breaking-changes/trackpad-gestures
|
||||
void onPointerPanZoomUpdate(PointerPanZoomUpdateEvent e) {
|
||||
if (isViewOnly) return;
|
||||
if (isViewCamera) return;
|
||||
if (peerPlatform != kPeerPlatformAndroid) {
|
||||
final scale = ((e.scale - _lastScale) * 1000).toInt();
|
||||
_lastScale = e.scale;
|
||||
@@ -877,13 +914,16 @@ class InputModel {
|
||||
}
|
||||
}
|
||||
|
||||
final delta = e.panDelta;
|
||||
var delta = e.panDelta * _trackpadSpeedInner;
|
||||
if (isMacOS && peerPlatform == kPeerPlatformWindows) {
|
||||
delta *= _trackpadAdjustMacToWin;
|
||||
}
|
||||
_trackpadLastDelta = delta;
|
||||
|
||||
var x = delta.dx.toInt();
|
||||
var y = delta.dy.toInt();
|
||||
if (peerPlatform == kPeerPlatformLinux) {
|
||||
_trackpadScrollUnsent += (delta * _trackpadSpeed);
|
||||
_trackpadScrollUnsent += (delta * _trackpadAdjustPeerLinux);
|
||||
x = _trackpadScrollUnsent.dx.truncate();
|
||||
y = _trackpadScrollUnsent.dy.truncate();
|
||||
_trackpadScrollUnsent -= Offset(x.toDouble(), y.toDouble());
|
||||
@@ -902,6 +942,7 @@ class InputModel {
|
||||
handlePointerEvent('touch', kMouseEventTypePanUpdate,
|
||||
Offset(x.toDouble(), y.toDouble()));
|
||||
} else {
|
||||
if (isViewCamera) return;
|
||||
bind.sessionSendMouse(
|
||||
sessionId: sessionId,
|
||||
msg: '{"type": "trackpad", "x": "$x", "y": "$y"}');
|
||||
@@ -910,6 +951,7 @@ class InputModel {
|
||||
}
|
||||
|
||||
void _scheduleFling(double x, double y, int delay) {
|
||||
if (isViewCamera) return;
|
||||
if ((x == 0 && y == 0) || _stopFling) {
|
||||
_fling = false;
|
||||
return;
|
||||
@@ -929,8 +971,8 @@ class InputModel {
|
||||
var dx = x.toInt();
|
||||
var dy = y.toInt();
|
||||
if (parent.target?.ffiModel.pi.platform == kPeerPlatformLinux) {
|
||||
dx = (x * _trackpadSpeed).toInt();
|
||||
dy = (y * _trackpadSpeed).toInt();
|
||||
dx = (x * _trackpadAdjustPeerLinux).toInt();
|
||||
dy = (y * _trackpadAdjustPeerLinux).toInt();
|
||||
}
|
||||
|
||||
var delay = _flingBaseDelay;
|
||||
@@ -961,6 +1003,7 @@ class InputModel {
|
||||
}
|
||||
|
||||
void onPointerPanZoomEnd(PointerPanZoomEndEvent e) {
|
||||
if (isViewCamera) return;
|
||||
if (peerPlatform == kPeerPlatformAndroid) {
|
||||
handlePointerEvent('touch', kMouseEventTypePanEnd, e.position);
|
||||
return;
|
||||
@@ -975,7 +1018,10 @@ class InputModel {
|
||||
_stopFling = false;
|
||||
|
||||
// 2.0 is an experience value
|
||||
double minFlingValue = 2.0;
|
||||
double minFlingValue = 2.0 * _trackpadSpeedInner;
|
||||
if (isMacOS && peerPlatform == kPeerPlatformWindows) {
|
||||
minFlingValue *= _trackpadAdjustMacToWin;
|
||||
}
|
||||
if (_trackpadLastDelta.dx.abs() > minFlingValue ||
|
||||
_trackpadLastDelta.dy.abs() > minFlingValue) {
|
||||
_fling = true;
|
||||
@@ -992,6 +1038,7 @@ class InputModel {
|
||||
_remoteWindowCoords = [];
|
||||
_windowRect = null;
|
||||
if (isViewOnly) return;
|
||||
if (isViewCamera) return;
|
||||
if (e.kind != ui.PointerDeviceKind.mouse) {
|
||||
if (isPhysicalMouse.value) {
|
||||
isPhysicalMouse.value = false;
|
||||
@@ -1005,6 +1052,7 @@ class InputModel {
|
||||
void onPointUpImage(PointerUpEvent e) {
|
||||
if (isDesktop) _queryOtherWindowCoords = false;
|
||||
if (isViewOnly) return;
|
||||
if (isViewCamera) return;
|
||||
if (e.kind != ui.PointerDeviceKind.mouse) return;
|
||||
if (isPhysicalMouse.value) {
|
||||
handleMouse(_getMouseEvent(e, _kMouseEventUp), e.position);
|
||||
@@ -1013,6 +1061,7 @@ class InputModel {
|
||||
|
||||
void onPointMoveImage(PointerMoveEvent e) {
|
||||
if (isViewOnly) return;
|
||||
if (isViewCamera) return;
|
||||
if (e.kind != ui.PointerDeviceKind.mouse) return;
|
||||
if (_queryOtherWindowCoords) {
|
||||
Future.delayed(Duration.zero, () async {
|
||||
@@ -1047,6 +1096,7 @@ class InputModel {
|
||||
|
||||
void onPointerSignalImage(PointerSignalEvent e) {
|
||||
if (isViewOnly) return;
|
||||
if (isViewCamera) return;
|
||||
if (e is PointerScrollEvent) {
|
||||
var dx = e.scrollDelta.dx.toInt();
|
||||
var dy = e.scrollDelta.dy.toInt();
|
||||
@@ -1144,6 +1194,7 @@ class InputModel {
|
||||
}
|
||||
|
||||
final evt = PointerEventToRust(kind, type, evtValue).toJson();
|
||||
if (isViewCamera) return;
|
||||
bind.sessionSendPointer(
|
||||
sessionId: sessionId, msg: json.encode(modify(evt)));
|
||||
}
|
||||
@@ -1175,6 +1226,7 @@ class InputModel {
|
||||
Offset offset, {
|
||||
bool onExit = false,
|
||||
}) {
|
||||
if (isViewCamera) return;
|
||||
double x = offset.dx;
|
||||
double y = max(0.0, offset.dy);
|
||||
if (_checkPeerControlProtected(x, y)) {
|
||||
@@ -1426,7 +1478,18 @@ class InputModel {
|
||||
}
|
||||
}
|
||||
|
||||
void onMobileBack() => tap(MouseButtons.right);
|
||||
void onMobileBack() {
|
||||
final minBackButtonVersion = "1.3.8";
|
||||
final peerVersion =
|
||||
parent.target?.ffiModel.pi.version ?? minBackButtonVersion;
|
||||
var btn = MouseButtons.back;
|
||||
// For compatibility with old versions
|
||||
if (versionCmp(peerVersion, minBackButtonVersion) < 0) {
|
||||
btn = MouseButtons.right;
|
||||
}
|
||||
tap(btn);
|
||||
}
|
||||
|
||||
void onMobileHome() => tap(MouseButtons.wheel);
|
||||
Future<void> onMobileApps() async {
|
||||
sendMouse('down', MouseButtons.wheel);
|
||||
|
||||
@@ -9,7 +9,6 @@ import 'package:desktop_multi_window/desktop_multi_window.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_hbb/common/widgets/peers_view.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/models/ab_model.dart';
|
||||
@@ -19,6 +18,7 @@ import 'package:flutter_hbb/models/file_model.dart';
|
||||
import 'package:flutter_hbb/models/group_model.dart';
|
||||
import 'package:flutter_hbb/models/peer_model.dart';
|
||||
import 'package:flutter_hbb/models/peer_tab_model.dart';
|
||||
import 'package:flutter_hbb/models/printer_model.dart';
|
||||
import 'package:flutter_hbb/models/server_model.dart';
|
||||
import 'package:flutter_hbb/models/user_model.dart';
|
||||
import 'package:flutter_hbb/models/state_model.dart';
|
||||
@@ -34,6 +34,7 @@ import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
|
||||
import '../common.dart';
|
||||
import '../utils/image.dart' as img;
|
||||
@@ -119,6 +120,8 @@ class FfiModel with ChangeNotifier {
|
||||
RxBool waitForFirstImage = true.obs;
|
||||
bool isRefreshing = false;
|
||||
|
||||
Timer? timerScreenshot;
|
||||
|
||||
Rect? get rect => _rect;
|
||||
bool get isOriginalResolutionSet =>
|
||||
_pi.tryGetDisplayIfNotAllDisplay()?.isOriginalResolutionSet ?? false;
|
||||
@@ -216,6 +219,7 @@ class FfiModel with ChangeNotifier {
|
||||
_timer = null;
|
||||
clearPermissions();
|
||||
waitForImageTimer?.cancel();
|
||||
timerScreenshot?.cancel();
|
||||
}
|
||||
|
||||
setConnectionType(String peerId, bool secure, bool direct) {
|
||||
@@ -407,15 +411,261 @@ class FfiModel with ChangeNotifier {
|
||||
parent.target?.fileModel.sendEmptyDirs(evt);
|
||||
}
|
||||
} else if (name == "record_status") {
|
||||
if (desktopType == DesktopType.remote || isMobile) {
|
||||
if (desktopType == DesktopType.remote ||
|
||||
desktopType == DesktopType.viewCamera ||
|
||||
isMobile) {
|
||||
parent.target?.recordingModel.updateStatus(evt['start'] == 'true');
|
||||
}
|
||||
} else if (name == "printer_request") {
|
||||
_handlePrinterRequest(evt, sessionId, peerId);
|
||||
} else if (name == 'screenshot') {
|
||||
_handleScreenshot(evt, sessionId, peerId);
|
||||
} else {
|
||||
debugPrint('Event is not handled in the fixed branch: $name');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
_handleScreenshot(
|
||||
Map<String, dynamic> evt, SessionID sessionId, String peerId) {
|
||||
timerScreenshot?.cancel();
|
||||
timerScreenshot = null;
|
||||
final msg = evt['msg'] ?? '';
|
||||
final msgBoxType = 'custom-nook-nocancel-hasclose';
|
||||
final msgBoxTitle = 'Take screenshot';
|
||||
final dialogManager = parent.target!.dialogManager;
|
||||
if (msg.isNotEmpty) {
|
||||
msgBox(sessionId, msgBoxType, msgBoxTitle, msg, '', dialogManager);
|
||||
} else {
|
||||
final msgBoxText = 'screenshot-action-tip';
|
||||
|
||||
close() {
|
||||
dialogManager.dismissAll();
|
||||
}
|
||||
|
||||
saveAs() {
|
||||
close();
|
||||
Future.delayed(Duration.zero, () async {
|
||||
final ts = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
String? outputFile = await FilePicker.platform.saveFile(
|
||||
dialogTitle: '${translate('Save as')}...',
|
||||
fileName: 'screenshot_$ts.png',
|
||||
allowedExtensions: ['png'],
|
||||
type: FileType.custom,
|
||||
);
|
||||
if (outputFile == null) {
|
||||
bind.sessionHandleScreenshot(sessionId: sessionId, action: '2');
|
||||
} else {
|
||||
final res = await bind.sessionHandleScreenshot(
|
||||
sessionId: sessionId, action: '0:$outputFile');
|
||||
if (res.isNotEmpty) {
|
||||
msgBox(sessionId, 'custom-nook-nocancel-hasclose-error',
|
||||
'Take screenshot', res, '', dialogManager);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
copyToClipboard() {
|
||||
bind.sessionHandleScreenshot(sessionId: sessionId, action: '1');
|
||||
close();
|
||||
}
|
||||
|
||||
cancel() {
|
||||
bind.sessionHandleScreenshot(sessionId: sessionId, action: '2');
|
||||
close();
|
||||
}
|
||||
|
||||
final List<Widget> buttons = [
|
||||
dialogButton('${translate('Save as')}...', onPressed: saveAs),
|
||||
dialogButton('Copy to clipboard', onPressed: copyToClipboard),
|
||||
dialogButton('Cancel', onPressed: cancel),
|
||||
];
|
||||
dialogManager.dismissAll();
|
||||
dialogManager.show(
|
||||
(setState, close, context) => CustomAlertDialog(
|
||||
title: null,
|
||||
content: SelectionArea(
|
||||
child: msgboxContent(msgBoxType, msgBoxTitle, msgBoxText)),
|
||||
actions: buttons,
|
||||
),
|
||||
tag: '$msgBoxType-$msgBoxTitle-$msgBoxTitle',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_handlePrinterRequest(
|
||||
Map<String, dynamic> evt, SessionID sessionId, String peerId) {
|
||||
final id = evt['id'];
|
||||
final path = evt['path'];
|
||||
final dialogManager = parent.target!.dialogManager;
|
||||
dialogManager.show((setState, close, context) {
|
||||
PrinterOptions printerOptions = PrinterOptions.load();
|
||||
final saveSettings = mainGetLocalBoolOptionSync(kKeyPrinterSave).obs;
|
||||
final dontShowAgain = false.obs;
|
||||
final Rx<String> selectedPrinterName = printerOptions.printerName.obs;
|
||||
final printerNames = printerOptions.printerNames;
|
||||
final defaultOrSelectedGroupValue =
|
||||
(printerOptions.action == kValuePrinterIncomingJobDismiss
|
||||
? kValuePrinterIncomingJobDefault
|
||||
: printerOptions.action)
|
||||
.obs;
|
||||
|
||||
onRatioChanged(String? value) {
|
||||
defaultOrSelectedGroupValue.value =
|
||||
value ?? kValuePrinterIncomingJobDefault;
|
||||
}
|
||||
|
||||
onSubmit() {
|
||||
final printerName = defaultOrSelectedGroupValue.isEmpty
|
||||
? ''
|
||||
: selectedPrinterName.value;
|
||||
bind.sessionPrinterResponse(
|
||||
sessionId: sessionId, id: id, path: path, printerName: printerName);
|
||||
if (saveSettings.value || dontShowAgain.value) {
|
||||
bind.mainSetLocalOption(key: kKeyPrinterSelected, value: printerName);
|
||||
bind.mainSetLocalOption(
|
||||
key: kKeyPrinterIncomingJobAction,
|
||||
value: defaultOrSelectedGroupValue.value);
|
||||
}
|
||||
if (dontShowAgain.value) {
|
||||
mainSetLocalBoolOption(kKeyPrinterAllowAutoPrint, true);
|
||||
}
|
||||
close();
|
||||
}
|
||||
|
||||
onCancel() {
|
||||
if (dontShowAgain.value) {
|
||||
bind.mainSetLocalOption(
|
||||
key: kKeyPrinterIncomingJobAction,
|
||||
value: kValuePrinterIncomingJobDismiss);
|
||||
}
|
||||
close();
|
||||
}
|
||||
|
||||
final printerItemHeight = 30.0;
|
||||
final selectionAreaHeight =
|
||||
printerItemHeight * min(8.0, max(printerNames.length, 3.0));
|
||||
final content = Column(
|
||||
children: [
|
||||
Text(translate('print-incoming-job-confirm-tip')),
|
||||
Row(
|
||||
children: [
|
||||
Obx(() => Radio<String>(
|
||||
value: kValuePrinterIncomingJobDefault,
|
||||
groupValue: defaultOrSelectedGroupValue.value,
|
||||
onChanged: onRatioChanged)),
|
||||
GestureDetector(
|
||||
child: Text(translate('use-the-default-printer-tip')),
|
||||
onTap: () => onRatioChanged(kValuePrinterIncomingJobDefault)),
|
||||
],
|
||||
),
|
||||
Column(
|
||||
children: [
|
||||
Row(children: [
|
||||
Obx(() => Radio<String>(
|
||||
value: kValuePrinterIncomingJobSelected,
|
||||
groupValue: defaultOrSelectedGroupValue.value,
|
||||
onChanged: onRatioChanged)),
|
||||
GestureDetector(
|
||||
child: Text(translate('use-the-selected-printer-tip')),
|
||||
onTap: () =>
|
||||
onRatioChanged(kValuePrinterIncomingJobSelected)),
|
||||
]),
|
||||
SizedBox(
|
||||
height: selectionAreaHeight,
|
||||
width: 500,
|
||||
child: ListView.builder(
|
||||
itemBuilder: (context, index) {
|
||||
return Obx(() => GestureDetector(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: selectedPrinterName.value ==
|
||||
printerNames[index]
|
||||
? (defaultOrSelectedGroupValue.value ==
|
||||
kValuePrinterIncomingJobSelected
|
||||
? MyTheme.button
|
||||
: MyTheme.button.withOpacity(0.5))
|
||||
: Theme.of(context).cardColor,
|
||||
borderRadius: BorderRadius.all(
|
||||
Radius.circular(5.0),
|
||||
),
|
||||
),
|
||||
key: ValueKey(printerNames[index]),
|
||||
height: printerItemHeight,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 10.0),
|
||||
child: Text(
|
||||
printerNames[index],
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
onTap: defaultOrSelectedGroupValue.value ==
|
||||
kValuePrinterIncomingJobSelected
|
||||
? () {
|
||||
selectedPrinterName.value =
|
||||
printerNames[index];
|
||||
}
|
||||
: null,
|
||||
));
|
||||
},
|
||||
itemCount: printerNames.length),
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Obx(() => Checkbox(
|
||||
value: saveSettings.value,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
saveSettings.value = value;
|
||||
mainSetLocalBoolOption(kKeyPrinterSave, value);
|
||||
}
|
||||
})),
|
||||
GestureDetector(
|
||||
child: Text(translate('save-settings-tip')),
|
||||
onTap: () {
|
||||
saveSettings.value = !saveSettings.value;
|
||||
mainSetLocalBoolOption(kKeyPrinterSave, saveSettings.value);
|
||||
}),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Obx(() => Checkbox(
|
||||
value: dontShowAgain.value,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
dontShowAgain.value = value;
|
||||
}
|
||||
})),
|
||||
GestureDetector(
|
||||
child: Text(translate('dont-show-again-tip')),
|
||||
onTap: () {
|
||||
dontShowAgain.value = !dontShowAgain.value;
|
||||
}),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
return CustomAlertDialog(
|
||||
title: Text(translate('Incoming Print Job')),
|
||||
content: content,
|
||||
actions: [
|
||||
dialogButton('OK', onPressed: onSubmit),
|
||||
dialogButton('Cancel', onPressed: onCancel),
|
||||
],
|
||||
onSubmit: onSubmit,
|
||||
onCancel: onCancel,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
_handleUseTextureRender(
|
||||
Map<String, dynamic> evt, SessionID sessionId, String peerId) {
|
||||
parent.target?.imageModel.setUseTextureRender(evt['v'] == 'Y');
|
||||
@@ -501,7 +751,9 @@ class FfiModel with ChangeNotifier {
|
||||
final display = int.parse(evt['display']);
|
||||
|
||||
if (_pi.currentDisplay != kAllDisplayValue) {
|
||||
if (bind.peerGetDefaultSessionsCount(id: peerId) > 1) {
|
||||
if (bind.peerGetSessionsCount(
|
||||
id: peerId, connType: parent.target!.connType.index) >
|
||||
1) {
|
||||
if (display != _pi.currentDisplay) {
|
||||
return;
|
||||
}
|
||||
@@ -809,7 +1061,9 @@ class FfiModel with ChangeNotifier {
|
||||
_pi.primaryDisplay = currentDisplay;
|
||||
}
|
||||
|
||||
if (bind.peerGetDefaultSessionsCount(id: peerId) <= 1) {
|
||||
if (bind.peerGetSessionsCount(
|
||||
id: peerId, connType: parent.target!.connType.index) <=
|
||||
1) {
|
||||
_pi.currentDisplay = currentDisplay;
|
||||
}
|
||||
|
||||
@@ -827,9 +1081,11 @@ class FfiModel with ChangeNotifier {
|
||||
sessionId: sessionId, arg: kOptionTouchMode) !=
|
||||
'';
|
||||
}
|
||||
// FIXME: handle ViewCamera ConnType independently.
|
||||
if (connType == ConnType.fileTransfer) {
|
||||
parent.target?.fileModel.onReady();
|
||||
} else if (connType == ConnType.defaultConn) {
|
||||
} else if (connType == ConnType.defaultConn ||
|
||||
connType == ConnType.viewCamera) {
|
||||
List<Display> newDisplays = [];
|
||||
List<dynamic> displays = json.decode(evt['displays']);
|
||||
for (int i = 0; i < displays.length; ++i) {
|
||||
@@ -859,7 +1115,7 @@ class FfiModel with ChangeNotifier {
|
||||
bind.sessionGetToggleOptionSync(
|
||||
sessionId: sessionId, arg: kOptionToggleViewOnly));
|
||||
}
|
||||
if (connType == ConnType.defaultConn) {
|
||||
if (connType == ConnType.defaultConn || connType == ConnType.viewCamera) {
|
||||
final platformAdditions = evt['platform_additions'];
|
||||
if (platformAdditions != null && platformAdditions != '') {
|
||||
try {
|
||||
@@ -2430,6 +2686,8 @@ class CursorModel with ChangeNotifier {
|
||||
_x = -10000;
|
||||
_x = -10000;
|
||||
_image = null;
|
||||
_firstUpdateMouseTime = null;
|
||||
gotMouseControl = true;
|
||||
disposeImages();
|
||||
|
||||
_clearCache();
|
||||
@@ -2574,7 +2832,8 @@ class ElevationModel with ChangeNotifier {
|
||||
onPortableServiceRunning(bool running) => _running = running;
|
||||
}
|
||||
|
||||
enum ConnType { defaultConn, fileTransfer, portForward, rdp }
|
||||
// The index values of `ConnType` are same as rust protobuf.
|
||||
enum ConnType { defaultConn, fileTransfer, portForward, rdp, viewCamera }
|
||||
|
||||
/// Flutter state manager and data communication with the Rust core.
|
||||
class FFI {
|
||||
@@ -2649,10 +2908,11 @@ class FFI {
|
||||
ffiModel.waitForImageTimer = null;
|
||||
}
|
||||
|
||||
/// Start with the given [id]. Only transfer file if [isFileTransfer], only port forward if [isPortForward].
|
||||
/// Start with the given [id]. Only transfer file if [isFileTransfer], only view camera if [isViewCamera], only port forward if [isPortForward].
|
||||
void start(
|
||||
String id, {
|
||||
bool isFileTransfer = false,
|
||||
bool isViewCamera = false,
|
||||
bool isPortForward = false,
|
||||
bool isRdp = false,
|
||||
String? switchUuid,
|
||||
@@ -2667,9 +2927,15 @@ class FFI {
|
||||
closed = false;
|
||||
auditNote = '';
|
||||
if (isMobile) mobileReset();
|
||||
assert(!(isFileTransfer && isPortForward), 'more than one connect type');
|
||||
assert(
|
||||
(!(isPortForward && isViewCamera)) &&
|
||||
(!(isViewCamera && isPortForward)) &&
|
||||
(!(isPortForward && isFileTransfer)),
|
||||
'more than one connect type');
|
||||
if (isFileTransfer) {
|
||||
connType = ConnType.fileTransfer;
|
||||
} else if (isViewCamera) {
|
||||
connType = ConnType.viewCamera;
|
||||
} else if (isPortForward) {
|
||||
connType = ConnType.portForward;
|
||||
} else {
|
||||
@@ -2689,6 +2955,7 @@ class FFI {
|
||||
sessionId: sessionId,
|
||||
id: id,
|
||||
isFileTransfer: isFileTransfer,
|
||||
isViewCamera: isViewCamera,
|
||||
isPortForward: isPortForward,
|
||||
isRdp: isRdp,
|
||||
switchUuid: switchUuid ?? '',
|
||||
@@ -2704,7 +2971,10 @@ class FFI {
|
||||
return;
|
||||
}
|
||||
final addRes = bind.sessionAddExistedSync(
|
||||
id: id, sessionId: sessionId, displays: Int32List.fromList(displays));
|
||||
id: id,
|
||||
sessionId: sessionId,
|
||||
displays: Int32List.fromList(displays),
|
||||
isViewCamera: isViewCamera);
|
||||
if (addRes != '') {
|
||||
debugPrint(
|
||||
'Unreachable, failed to add existed session to $id, $addRes');
|
||||
@@ -2715,6 +2985,15 @@ class FFI {
|
||||
if (isDesktop && connType == ConnType.defaultConn) {
|
||||
textureModel.updateCurrentDisplay(display ?? 0);
|
||||
}
|
||||
// FIXME: separate cameras displays or shift all indices.
|
||||
if (isDesktop && connType == ConnType.viewCamera) {
|
||||
// FIXME: currently the default 0 is not used.
|
||||
textureModel.updateCurrentDisplay(display ?? 0);
|
||||
}
|
||||
|
||||
if (isDesktop) {
|
||||
inputModel.updateTrackpadSpeed();
|
||||
}
|
||||
|
||||
// CAUTION: `sessionStart()` and `sessionStartWithDisplays()` are an async functions.
|
||||
// Though the stream is returned immediately, the stream may not be ready.
|
||||
@@ -2991,6 +3270,9 @@ class PeerInfo with ChangeNotifier {
|
||||
bool get isAmyuniIdd =>
|
||||
platformAdditions[kPlatformAdditionsIddImpl] == 'amyuni_idd';
|
||||
|
||||
bool get isSupportViewCamera =>
|
||||
platformAdditions[kPlatformAdditionsSupportViewCamera] == true;
|
||||
|
||||
Display? tryGetDisplay({int? display}) {
|
||||
if (displays.isEmpty) {
|
||||
return null;
|
||||
|
||||
@@ -60,14 +60,14 @@ class PlatformFFI {
|
||||
}
|
||||
|
||||
bool registerEventHandler(
|
||||
String eventName, String handlerName, HandleEvent handler) {
|
||||
String eventName, String handlerName, HandleEvent handler, {bool replace = false}) {
|
||||
debugPrint('registerEventHandler $eventName $handlerName');
|
||||
var handlers = _eventHandlers[eventName];
|
||||
if (handlers == null) {
|
||||
_eventHandlers[eventName] = {handlerName: handler};
|
||||
return true;
|
||||
} else {
|
||||
if (handlers.containsKey(handlerName)) {
|
||||
if (!replace && handlers.containsKey(handlerName)) {
|
||||
return false;
|
||||
} else {
|
||||
handlers[handlerName] = handler;
|
||||
|
||||
@@ -19,6 +19,7 @@ class Peer {
|
||||
String rdpUsername;
|
||||
bool online = false;
|
||||
String loginName; //login username
|
||||
String device_group_name;
|
||||
bool? sameServer;
|
||||
|
||||
String getId() {
|
||||
@@ -41,6 +42,7 @@ class Peer {
|
||||
rdpPort = json['rdpPort'] ?? '',
|
||||
rdpUsername = json['rdpUsername'] ?? '',
|
||||
loginName = json['loginName'] ?? '',
|
||||
device_group_name = json['device_group_name'] ?? '',
|
||||
sameServer = json['same_server'];
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
@@ -57,6 +59,7 @@ class Peer {
|
||||
"rdpPort": rdpPort,
|
||||
"rdpUsername": rdpUsername,
|
||||
'loginName': loginName,
|
||||
'device_group_name': device_group_name,
|
||||
'same_server': sameServer,
|
||||
};
|
||||
}
|
||||
@@ -83,6 +86,7 @@ class Peer {
|
||||
"hostname": hostname,
|
||||
"platform": platform,
|
||||
"login_name": loginName,
|
||||
"device_group_name": device_group_name,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -99,6 +103,7 @@ class Peer {
|
||||
required this.rdpPort,
|
||||
required this.rdpUsername,
|
||||
required this.loginName,
|
||||
required this.device_group_name,
|
||||
this.sameServer,
|
||||
});
|
||||
|
||||
@@ -116,6 +121,7 @@ class Peer {
|
||||
rdpPort: '',
|
||||
rdpUsername: '',
|
||||
loginName: '',
|
||||
device_group_name: '',
|
||||
);
|
||||
bool equal(Peer other) {
|
||||
return id == other.id &&
|
||||
@@ -129,6 +135,7 @@ class Peer {
|
||||
forceAlwaysRelay == other.forceAlwaysRelay &&
|
||||
rdpPort == other.rdpPort &&
|
||||
rdpUsername == other.rdpUsername &&
|
||||
device_group_name == other.device_group_name &&
|
||||
loginName == other.loginName;
|
||||
}
|
||||
|
||||
@@ -146,6 +153,7 @@ class Peer {
|
||||
rdpPort: other.rdpPort,
|
||||
rdpUsername: other.rdpUsername,
|
||||
loginName: other.loginName,
|
||||
device_group_name: other.device_group_name,
|
||||
sameServer: other.sameServer);
|
||||
}
|
||||
|
||||
@@ -157,6 +165,11 @@ class Peers extends ChangeNotifier {
|
||||
final String name;
|
||||
final String loadEvent;
|
||||
List<Peer> peers = List.empty(growable: true);
|
||||
// Part of the peers that are not in the rest peers list.
|
||||
// When there're too many peers, we may want to load the front 100 peers first,
|
||||
// so we can see peers in UI quickly. `restPeerIds` is the rest peers' ids.
|
||||
// And then load all peers later.
|
||||
List<String> restPeerIds = List.empty(growable: true);
|
||||
final GetInitPeers? getInitPeers;
|
||||
UpdateEvent event = UpdateEvent.load;
|
||||
static const _cbQueryOnlines = 'callback_query_onlines';
|
||||
@@ -230,6 +243,12 @@ class Peers extends ChangeNotifier {
|
||||
} else {
|
||||
peers = _decodePeers(evt['peers']);
|
||||
}
|
||||
|
||||
restPeerIds = [];
|
||||
if (evt['ids'] != null) {
|
||||
restPeerIds = (evt['ids'] as String).split(',');
|
||||
}
|
||||
|
||||
for (var peer in peers) {
|
||||
final state = onlineStates[peer.id];
|
||||
peer.online = state != null && state != false;
|
||||
|
||||
@@ -28,14 +28,14 @@ class PeerTabModel with ChangeNotifier {
|
||||
'Favorites',
|
||||
'Discovered',
|
||||
'Address book',
|
||||
'Group',
|
||||
'Accessible devices',
|
||||
];
|
||||
static const List<IconData> icons = [
|
||||
Icons.access_time_filled,
|
||||
Icons.star,
|
||||
Icons.explore,
|
||||
IconFont.addressBook,
|
||||
Icons.group,
|
||||
IconFont.deviceGroupFill,
|
||||
];
|
||||
List<bool> isEnabled = List.from([
|
||||
true,
|
||||
|
||||
48
flutter/lib/models/printer_model.dart
Normal file
48
flutter/lib/models/printer_model.dart
Normal file
@@ -0,0 +1,48 @@
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/models/platform_model.dart';
|
||||
|
||||
class PrinterOptions {
|
||||
String action;
|
||||
List<String> printerNames;
|
||||
String printerName;
|
||||
|
||||
PrinterOptions(
|
||||
{required this.action,
|
||||
required this.printerNames,
|
||||
required this.printerName});
|
||||
|
||||
static PrinterOptions load() {
|
||||
var action = bind.mainGetLocalOption(key: kKeyPrinterIncomingJobAction);
|
||||
if (![
|
||||
kValuePrinterIncomingJobDismiss,
|
||||
kValuePrinterIncomingJobDefault,
|
||||
kValuePrinterIncomingJobSelected
|
||||
].contains(action)) {
|
||||
action = kValuePrinterIncomingJobDefault;
|
||||
}
|
||||
|
||||
final printerNames = getPrinterNames();
|
||||
var selectedPrinterName = bind.mainGetLocalOption(key: kKeyPrinterSelected);
|
||||
if (!printerNames.contains(selectedPrinterName)) {
|
||||
if (action == kValuePrinterIncomingJobSelected) {
|
||||
action = kValuePrinterIncomingJobDefault;
|
||||
bind.mainSetLocalOption(
|
||||
key: kKeyPrinterIncomingJobAction,
|
||||
value: kValuePrinterIncomingJobDefault);
|
||||
if (printerNames.isEmpty) {
|
||||
selectedPrinterName = '';
|
||||
} else {
|
||||
selectedPrinterName = printerNames.first;
|
||||
}
|
||||
bind.mainSetLocalOption(
|
||||
key: kKeyPrinterSelected, value: selectedPrinterName);
|
||||
}
|
||||
}
|
||||
|
||||
return PrinterOptions(
|
||||
action: action,
|
||||
printerNames: printerNames,
|
||||
printerName: selectedPrinterName);
|
||||
}
|
||||
}
|
||||
@@ -791,6 +791,7 @@ class ServerModel with ChangeNotifier {
|
||||
enum ClientType {
|
||||
remote,
|
||||
file,
|
||||
camera,
|
||||
portForward,
|
||||
}
|
||||
|
||||
@@ -798,6 +799,7 @@ class Client {
|
||||
int id = 0; // client connections inner count id
|
||||
bool authorized = false;
|
||||
bool isFileTransfer = false;
|
||||
bool isViewCamera = false;
|
||||
String portForward = "";
|
||||
String name = "";
|
||||
String peerId = ""; // peer user's id,show at app
|
||||
@@ -815,13 +817,15 @@ class Client {
|
||||
|
||||
RxInt unreadChatMessageCount = 0.obs;
|
||||
|
||||
Client(this.id, this.authorized, this.isFileTransfer, this.name, this.peerId,
|
||||
Client(this.id, this.authorized, this.isFileTransfer, this.isViewCamera, this.name, this.peerId,
|
||||
this.keyboard, this.clipboard, this.audio);
|
||||
|
||||
Client.fromJson(Map<String, dynamic> json) {
|
||||
id = json['id'];
|
||||
authorized = json['authorized'];
|
||||
isFileTransfer = json['is_file_transfer'];
|
||||
// TODO: no entry then default.
|
||||
isViewCamera = json['is_view_camera'];
|
||||
portForward = json['port_forward'];
|
||||
name = json['name'];
|
||||
peerId = json['peer_id'];
|
||||
@@ -843,6 +847,7 @@ class Client {
|
||||
data['id'] = id;
|
||||
data['authorized'] = authorized;
|
||||
data['is_file_transfer'] = isFileTransfer;
|
||||
data['is_view_camera'] = isViewCamera;
|
||||
data['port_forward'] = portForward;
|
||||
data['name'] = name;
|
||||
data['peer_id'] = peerId;
|
||||
@@ -863,6 +868,8 @@ class Client {
|
||||
ClientType type_() {
|
||||
if (isFileTransfer) {
|
||||
return ClientType.file;
|
||||
} else if (isViewCamera) {
|
||||
return ClientType.camera;
|
||||
} else if (portForward.isNotEmpty) {
|
||||
return ClientType.portForward;
|
||||
} else {
|
||||
|
||||
@@ -116,6 +116,10 @@ class UserModel {
|
||||
userName.value = user.name;
|
||||
isAdmin.value = user.isAdmin;
|
||||
bind.mainSetLocalOption(key: 'user_info', value: jsonEncode(user));
|
||||
if (isWeb) {
|
||||
// ugly here, tmp solution
|
||||
bind.mainSetLocalOption(key: 'verifier', value: user.verifier ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
// update ab and group status
|
||||
@@ -184,7 +188,9 @@ class UserModel {
|
||||
rethrow;
|
||||
}
|
||||
|
||||
if (loginResponse.user != null) {
|
||||
final isLogInDone = loginResponse.type == HttpType.kAuthResTypeToken &&
|
||||
loginResponse.access_token != null;
|
||||
if (isLogInDone && loginResponse.user != null) {
|
||||
_parseAndUpdateUser(loginResponse.user!);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,10 +8,12 @@ import 'dart:html';
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_hbb/common/widgets/login.dart';
|
||||
import 'package:flutter_hbb/models/state_model.dart';
|
||||
|
||||
import 'package:flutter_hbb/web/bridge.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
final List<StreamSubscription<MouseEvent>> mouseListeners = [];
|
||||
final List<StreamSubscription<KeyboardEvent>> keyListeners = [];
|
||||
@@ -49,14 +51,15 @@ class PlatformFFI {
|
||||
}
|
||||
|
||||
bool registerEventHandler(
|
||||
String eventName, String handlerName, HandleEvent handler) {
|
||||
String eventName, String handlerName, HandleEvent handler,
|
||||
{bool replace = false}) {
|
||||
debugPrint('registerEventHandler $eventName $handlerName');
|
||||
var handlers = _eventHandlers[eventName];
|
||||
if (handlers == null) {
|
||||
_eventHandlers[eventName] = {handlerName: handler};
|
||||
return true;
|
||||
} else {
|
||||
if (handlers.containsKey(handlerName)) {
|
||||
if (!replace && handlers.containsKey(handlerName)) {
|
||||
return false;
|
||||
} else {
|
||||
handlers[handlerName] = handler;
|
||||
@@ -112,6 +115,17 @@ class PlatformFFI {
|
||||
context["onInitFinished"] = () {
|
||||
completer.complete();
|
||||
};
|
||||
context['dialog'] = (type, title, text) {
|
||||
final uuid = Uuid();
|
||||
msgBox(SessionID(uuid.v4()), type, title, text, '', gFFI.dialogManager);
|
||||
};
|
||||
context['loginDialog'] = () {
|
||||
loginDialog();
|
||||
};
|
||||
context['closeConnection'] = () {
|
||||
gFFI.dialogManager.dismissAll();
|
||||
closeConnection();
|
||||
};
|
||||
context.callMethod('init');
|
||||
version = getByName('version');
|
||||
window.onContextMenu.listen((event) {
|
||||
|
||||
@@ -2,16 +2,18 @@ import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/models/platform_model.dart';
|
||||
|
||||
void showPeerSelectionDialog(
|
||||
{bool singleSelection = false,
|
||||
required Function(List<String>) onPeersCallback}) {
|
||||
final peers = bind.mainLoadRecentPeersSync();
|
||||
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 sync failed.");
|
||||
// debugPrint("load recent peers failed.");
|
||||
return;
|
||||
}
|
||||
|
||||
Map<String, dynamic> map = jsonDecode(peers);
|
||||
List<dynamic> peersList = map['peers'] ?? [];
|
||||
final selected = List<String>.empty(growable: true);
|
||||
|
||||
@@ -11,7 +11,14 @@ import 'package:flutter_hbb/models/input_model.dart';
|
||||
|
||||
/// must keep the order
|
||||
// ignore: constant_identifier_names
|
||||
enum WindowType { Main, RemoteDesktop, FileTransfer, PortForward, Unknown }
|
||||
enum WindowType {
|
||||
Main,
|
||||
RemoteDesktop,
|
||||
FileTransfer,
|
||||
ViewCamera,
|
||||
PortForward,
|
||||
Unknown
|
||||
}
|
||||
|
||||
extension Index on int {
|
||||
WindowType get windowType {
|
||||
@@ -23,6 +30,8 @@ extension Index on int {
|
||||
case 2:
|
||||
return WindowType.FileTransfer;
|
||||
case 3:
|
||||
return WindowType.ViewCamera;
|
||||
case 4:
|
||||
return WindowType.PortForward;
|
||||
default:
|
||||
return WindowType.Unknown;
|
||||
@@ -50,31 +59,46 @@ class RustDeskMultiWindowManager {
|
||||
final List<AsyncCallback> _windowActiveCallbacks = List.empty(growable: true);
|
||||
final List<int> _remoteDesktopWindows = List.empty(growable: true);
|
||||
final List<int> _fileTransferWindows = List.empty(growable: true);
|
||||
final List<int> _viewCameraWindows = List.empty(growable: true);
|
||||
final List<int> _portForwardWindows = List.empty(growable: true);
|
||||
|
||||
moveTabToNewWindow(int windowId, String peerId, String sessionId) async {
|
||||
moveTabToNewWindow(int windowId, String peerId, String sessionId,
|
||||
WindowType windowType) async {
|
||||
var params = {
|
||||
'type': WindowType.RemoteDesktop.index,
|
||||
'type': windowType.index,
|
||||
'id': peerId,
|
||||
'tab_window_id': windowId,
|
||||
'session_id': sessionId,
|
||||
};
|
||||
await _newSession(
|
||||
false,
|
||||
WindowType.RemoteDesktop,
|
||||
kWindowEventNewRemoteDesktop,
|
||||
peerId,
|
||||
_remoteDesktopWindows,
|
||||
jsonEncode(params),
|
||||
);
|
||||
if (windowType == WindowType.RemoteDesktop) {
|
||||
await _newSession(
|
||||
false,
|
||||
WindowType.RemoteDesktop,
|
||||
kWindowEventNewRemoteDesktop,
|
||||
peerId,
|
||||
_remoteDesktopWindows,
|
||||
jsonEncode(params),
|
||||
);
|
||||
} else if (windowType == WindowType.ViewCamera) {
|
||||
await _newSession(
|
||||
false,
|
||||
WindowType.ViewCamera,
|
||||
kWindowEventNewViewCamera,
|
||||
peerId,
|
||||
_viewCameraWindows,
|
||||
jsonEncode(params),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// This function must be called in the main window thread.
|
||||
// Because the _remoteDesktopWindows is managed in that thread.
|
||||
openMonitorSession(int windowId, String peerId, int display, int displayCount,
|
||||
Rect? screenRect) async {
|
||||
if (_remoteDesktopWindows.length > 1) {
|
||||
for (final windowId in _remoteDesktopWindows) {
|
||||
Rect? screenRect, int windowType) async {
|
||||
final isCamera = windowType == WindowType.ViewCamera.index;
|
||||
final windowIDs = isCamera ? _viewCameraWindows : _remoteDesktopWindows;
|
||||
if (windowIDs.length > 1) {
|
||||
for (final windowId in windowIDs) {
|
||||
if (await DesktopMultiWindow.invokeMethod(
|
||||
windowId,
|
||||
kWindowEventActiveDisplaySession,
|
||||
@@ -91,7 +115,7 @@ class RustDeskMultiWindowManager {
|
||||
? List.generate(displayCount, (index) => index)
|
||||
: [display];
|
||||
var params = {
|
||||
'type': WindowType.RemoteDesktop.index,
|
||||
'type': windowType,
|
||||
'id': peerId,
|
||||
'tab_window_id': windowId,
|
||||
'display': display,
|
||||
@@ -107,10 +131,10 @@ class RustDeskMultiWindowManager {
|
||||
}
|
||||
await _newSession(
|
||||
false,
|
||||
WindowType.RemoteDesktop,
|
||||
kWindowEventNewRemoteDesktop,
|
||||
windowType.windowType,
|
||||
isCamera ? kWindowEventNewViewCamera : kWindowEventNewRemoteDesktop,
|
||||
peerId,
|
||||
_remoteDesktopWindows,
|
||||
windowIDs,
|
||||
jsonEncode(params),
|
||||
screenRect: screenRect,
|
||||
);
|
||||
@@ -277,6 +301,27 @@ class RustDeskMultiWindowManager {
|
||||
);
|
||||
}
|
||||
|
||||
Future<MultiWindowCallResult> newViewCamera(
|
||||
String remoteId, {
|
||||
String? password,
|
||||
bool? isSharedPassword,
|
||||
String? switchUuid,
|
||||
bool? forceRelay,
|
||||
String? connToken,
|
||||
}) async {
|
||||
return await newSession(
|
||||
WindowType.ViewCamera,
|
||||
kWindowEventNewViewCamera,
|
||||
remoteId,
|
||||
_viewCameraWindows,
|
||||
password: password,
|
||||
forceRelay: forceRelay,
|
||||
switchUuid: switchUuid,
|
||||
isSharedPassword: isSharedPassword,
|
||||
connToken: connToken,
|
||||
);
|
||||
}
|
||||
|
||||
Future<MultiWindowCallResult> newPortForward(
|
||||
String remoteId,
|
||||
bool isRDP, {
|
||||
@@ -324,6 +369,8 @@ class RustDeskMultiWindowManager {
|
||||
return _remoteDesktopWindows;
|
||||
case WindowType.FileTransfer:
|
||||
return _fileTransferWindows;
|
||||
case WindowType.ViewCamera:
|
||||
return _viewCameraWindows;
|
||||
case WindowType.PortForward:
|
||||
return _portForwardWindows;
|
||||
case WindowType.Unknown:
|
||||
@@ -342,6 +389,9 @@ class RustDeskMultiWindowManager {
|
||||
case WindowType.FileTransfer:
|
||||
_fileTransferWindows.clear();
|
||||
break;
|
||||
case WindowType.ViewCamera:
|
||||
_viewCameraWindows.clear();
|
||||
break;
|
||||
case WindowType.PortForward:
|
||||
_portForwardWindows.clear();
|
||||
break;
|
||||
|
||||
@@ -60,7 +60,8 @@ class RustdeskImpl {
|
||||
throw UnimplementedError("hostStopSystemKeyPropagate");
|
||||
}
|
||||
|
||||
int peerGetDefaultSessionsCount({required String id, dynamic hint}) {
|
||||
int peerGetSessionsCount(
|
||||
{required String id, required int connType, dynamic hint}) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -68,6 +69,7 @@ class RustdeskImpl {
|
||||
{required String id,
|
||||
required UuidValue sessionId,
|
||||
required Int32List displays,
|
||||
required bool isViewCamera,
|
||||
dynamic hint}) {
|
||||
return '';
|
||||
}
|
||||
@@ -76,6 +78,7 @@ class RustdeskImpl {
|
||||
{required UuidValue sessionId,
|
||||
required String id,
|
||||
required bool isFileTransfer,
|
||||
required bool isViewCamera,
|
||||
required bool isPortForward,
|
||||
required bool isRdp,
|
||||
required String switchUuid,
|
||||
@@ -90,7 +93,8 @@ class RustdeskImpl {
|
||||
'id': id,
|
||||
'password': password,
|
||||
'is_shared_password': isSharedPassword,
|
||||
'isFileTransfer': isFileTransfer
|
||||
'isFileTransfer': isFileTransfer,
|
||||
'isViewCamera': isViewCamera
|
||||
})
|
||||
]);
|
||||
}
|
||||
@@ -263,6 +267,16 @@ class RustdeskImpl {
|
||||
]));
|
||||
}
|
||||
|
||||
Future<int?> sessionGetTrackpadSpeed(
|
||||
{required UuidValue sessionId, dynamic hint}) {
|
||||
throw UnimplementedError("sessionGetTrackpadSpeed");
|
||||
}
|
||||
|
||||
Future<void> sessionSetTrackpadSpeed(
|
||||
{required UuidValue sessionId, required int value, dynamic hint}) {
|
||||
throw UnimplementedError("sessionSetTrackpadSpeed");
|
||||
}
|
||||
|
||||
Future<String?> sessionGetScrollStyle(
|
||||
{required UuidValue sessionId, dynamic hint}) {
|
||||
return Future(() =>
|
||||
@@ -1848,5 +1862,49 @@ class RustdeskImpl {
|
||||
throw UnimplementedError("sessionGetConnToken");
|
||||
}
|
||||
|
||||
String mainGetPrinterNames({dynamic hint}) {
|
||||
return '';
|
||||
}
|
||||
|
||||
Future<void> sessionPrinterResponse(
|
||||
{required UuidValue sessionId,
|
||||
required int id,
|
||||
required String path,
|
||||
required String printerName,
|
||||
dynamic hint}) {
|
||||
throw UnimplementedError("sessionPrinterResponse");
|
||||
}
|
||||
|
||||
Future<String> mainGetCommon({required String key, dynamic hint}) {
|
||||
throw UnimplementedError("mainGetCommon");
|
||||
}
|
||||
|
||||
String mainGetCommonSync({required String key, dynamic hint}) {
|
||||
throw UnimplementedError("mainGetCommonSync");
|
||||
}
|
||||
|
||||
Future<void> mainSetCommon(
|
||||
{required String key, required String value, dynamic hint}) {
|
||||
throw UnimplementedError("mainSetCommon");
|
||||
}
|
||||
|
||||
Future<String> sessionHandleScreenshot(
|
||||
{required UuidValue sessionId, required String action, dynamic hint}) {
|
||||
throw UnimplementedError("sessionHandleScreenshot");
|
||||
}
|
||||
|
||||
String? sessionGetCommonSync(
|
||||
{required UuidValue sessionId,
|
||||
required String key,
|
||||
required String param,
|
||||
dynamic hint}) {
|
||||
throw UnimplementedError("sessionGetCommonSync");
|
||||
}
|
||||
|
||||
Future<void> sessionTakeScreenshot(
|
||||
{required UuidValue sessionId, required int display, dynamic hint}) {
|
||||
throw UnimplementedError("sessionTakeScreenshot");
|
||||
}
|
||||
|
||||
void dispose() {}
|
||||
}
|
||||
|
||||
@@ -11,4 +11,4 @@ PRODUCT_NAME = RustDesk
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.carriez.flutterHbb
|
||||
|
||||
// The copyright displayed in application information
|
||||
PRODUCT_COPYRIGHT = Copyright © 2024 Purslane Ltd. All rights reserved.
|
||||
PRODUCT_COPYRIGHT = Copyright © 2025 Purslane Ltd. All rights reserved.
|
||||
|
||||
@@ -525,8 +525,8 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: "2ded7f146437a761ffe6981e2f742038f85ca68d"
|
||||
resolved-ref: "2ded7f146437a761ffe6981e2f742038f85ca68d"
|
||||
ref: "08a471bb8ceccdd50483c81cdfa8b81b07b14b87"
|
||||
resolved-ref: "08a471bb8ceccdd50483c81cdfa8b81b07b14b87"
|
||||
url: "https://github.com/rustdesk-org/flutter_gpu_texture_renderer"
|
||||
source: git
|
||||
version: "0.0.1"
|
||||
|
||||
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
|
||||
# Read more about iOS versioning at
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# 1.1.9-1 works for android, but for ios it becomes 1.1.91, need to set it to 1.1.9-a.1 for iOS, will get 1.1.9.1, but iOS store not allow 4 numbers
|
||||
version: 1.3.7+56
|
||||
version: 1.4.0+58
|
||||
|
||||
environment:
|
||||
sdk: '^3.1.0'
|
||||
@@ -94,7 +94,7 @@ dependencies:
|
||||
flutter_gpu_texture_renderer:
|
||||
git:
|
||||
url: https://github.com/rustdesk-org/flutter_gpu_texture_renderer
|
||||
ref: 2ded7f146437a761ffe6981e2f742038f85ca68d
|
||||
ref: 08a471bb8ceccdd50483c81cdfa8b81b07b14b87
|
||||
uuid: ^3.0.7
|
||||
auto_size_text_field: ^2.2.1
|
||||
flex_color_picker: ^3.3.0
|
||||
@@ -161,6 +161,12 @@ flutter:
|
||||
- family: AddressBook
|
||||
fonts:
|
||||
- asset: assets/address_book.ttf
|
||||
- family: DeviceGroup
|
||||
fonts:
|
||||
- asset: assets/device_group.ttf
|
||||
- family: More
|
||||
fonts:
|
||||
- asset: assets/more.ttf
|
||||
|
||||
# An image asset can refer to one or more resolution-specific "variants", see
|
||||
# https://flutter.dev/assets-and-images/#resolution-aware.
|
||||
|
||||
@@ -10,10 +10,10 @@ import 'package:get/get.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
final testClients = [
|
||||
Client(0, false, false, "UserAAAAAA", "123123123", true, false, false),
|
||||
Client(1, false, false, "UserBBBBB", "221123123", true, false, false),
|
||||
Client(2, false, false, "UserC", "331123123", true, false, false),
|
||||
Client(3, false, false, "UserDDDDDDDDDDDd", "441123123", true, false, false)
|
||||
Client(0, false, false, false, "UserAAAAAA", "123123123", true, false, false, false),
|
||||
Client(1, false, false, false, "UserBBBBB", "221123123", true, false, false, false),
|
||||
Client(2, false, false, false, "UserC", "331123123", true, false, false, false),
|
||||
Client(3, false, false, false, "UserDDDDDDDDDDDd", "441123123", true, false, false, false)
|
||||
];
|
||||
|
||||
/// flutter run -d {platform} -t test/cm_test.dart to test cm
|
||||
|
||||
@@ -93,7 +93,7 @@ BEGIN
|
||||
VALUE "FileDescription", "RustDesk Remote Desktop" "\0"
|
||||
VALUE "FileVersion", VERSION_AS_STRING "\0"
|
||||
VALUE "InternalName", "rustdesk" "\0"
|
||||
VALUE "LegalCopyright", "Copyright © 2024 Purslane Ltd. All rights reserved." "\0"
|
||||
VALUE "LegalCopyright", "Copyright © 2025 Purslane Ltd. All rights reserved." "\0"
|
||||
VALUE "OriginalFilename", "rustdesk.exe" "\0"
|
||||
VALUE "ProductName", "RustDesk" "\0"
|
||||
VALUE "ProductVersion", VERSION_AS_STRING "\0"
|
||||
|
||||
@@ -34,7 +34,6 @@ parking_lot = {version = "0.12"}
|
||||
|
||||
[target.'cfg(any(target_os = "linux", target_os = "macos"))'.dependencies]
|
||||
rand = {version = "0.8", optional = true}
|
||||
fuser = {version = "0.13", optional = true}
|
||||
libc = {version = "0.2", optional = true}
|
||||
dashmap = {version ="5.5", optional = true}
|
||||
utf16string = {version = "0.2", optional = true}
|
||||
@@ -44,6 +43,15 @@ once_cell = {version = "1.18", optional = true}
|
||||
percent-encoding = {version ="2.3", optional = true}
|
||||
x11-clipboard = {git="https://github.com/clslaid/x11-clipboard", branch = "feat/store-batch", optional = true}
|
||||
x11rb = {version = "0.12", features = ["all-extensions"], optional = true}
|
||||
fuser = {version = "0.15", default-features = false, optional = true}
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
cacao = {git="https://github.com/clslaid/cacao", branch = "feat/set-file-urls", optional = true}
|
||||
# Use `relax-void-encoding`, as that allows us to pass `c_void` instead of implementing `Encode` correctly for `&CGImageRef`
|
||||
objc2 = { version = "0.5.1", features = ["relax-void-encoding"] }
|
||||
objc2-foundation = { version = "0.2.0", features = ["NSArray", "NSString", "NSEnumerator", "NSGeometry", "NSProgress"] }
|
||||
objc2-app-kit = { version = "0.2.0", features = ["NSPasteboard", "NSPasteboardItem", "NSImage", "NSFilePromiseProvider"] }
|
||||
uuid = { version = "1.3", features = ["v4"] }
|
||||
fsevent = "2.1.2"
|
||||
dirs = "5.0"
|
||||
xattr = "1.4.0"
|
||||
|
||||
@@ -1,22 +1,29 @@
|
||||
use hbb_common::{log, ResultType};
|
||||
use std::sync::Mutex;
|
||||
use std::{ops::Deref, sync::Mutex};
|
||||
|
||||
use crate::CliprdrServiceContext;
|
||||
|
||||
const CLIPBOARD_RESPONSE_WAIT_TIMEOUT_SECS: u32 = 30;
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref CONTEXT_SEND: ContextSend = ContextSend{addr: Mutex::new(None)};
|
||||
static ref CONTEXT_SEND: ContextSend = ContextSend::default();
|
||||
}
|
||||
|
||||
pub struct ContextSend {
|
||||
addr: Mutex<Option<Box<dyn CliprdrServiceContext>>>,
|
||||
#[derive(Default)]
|
||||
pub struct ContextSend(Mutex<Option<Box<dyn CliprdrServiceContext>>>);
|
||||
|
||||
impl Deref for ContextSend {
|
||||
type Target = Mutex<Option<Box<dyn CliprdrServiceContext>>>;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl ContextSend {
|
||||
#[inline]
|
||||
pub fn is_enabled() -> bool {
|
||||
CONTEXT_SEND.addr.lock().unwrap().is_some()
|
||||
CONTEXT_SEND.lock().unwrap().is_some()
|
||||
}
|
||||
|
||||
pub fn set_is_stopped() {
|
||||
@@ -24,7 +31,7 @@ impl ContextSend {
|
||||
}
|
||||
|
||||
pub fn enable(enabled: bool) {
|
||||
let mut lock = CONTEXT_SEND.addr.lock().unwrap();
|
||||
let mut lock = CONTEXT_SEND.lock().unwrap();
|
||||
if enabled {
|
||||
if lock.is_some() {
|
||||
return;
|
||||
@@ -49,7 +56,7 @@ impl ContextSend {
|
||||
|
||||
/// make sure the clipboard context is enabled.
|
||||
pub fn make_sure_enabled() -> ResultType<()> {
|
||||
let mut lock = CONTEXT_SEND.addr.lock().unwrap();
|
||||
let mut lock = CONTEXT_SEND.lock().unwrap();
|
||||
if lock.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -63,7 +70,7 @@ impl ContextSend {
|
||||
pub fn proc<F: FnOnce(&mut Box<dyn CliprdrServiceContext>) -> ResultType<()>>(
|
||||
f: F,
|
||||
) -> ResultType<()> {
|
||||
let mut lock = CONTEXT_SEND.addr.lock().unwrap();
|
||||
let mut lock = CONTEXT_SEND.lock().unwrap();
|
||||
match lock.as_mut() {
|
||||
Some(context) => f(context),
|
||||
None => Ok(()),
|
||||
|
||||
@@ -1,24 +1,32 @@
|
||||
#[allow(dead_code)]
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::{Arc, Mutex, RwLock},
|
||||
};
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
|
||||
#[cfg(any(target_os = "windows", feature = "unix-file-copy-paste",))]
|
||||
use hbb_common::{allow_err, bail};
|
||||
#[cfg(any(
|
||||
target_os = "windows",
|
||||
all(target_os = "macos", feature = "unix-file-copy-paste")
|
||||
))]
|
||||
use hbb_common::ResultType;
|
||||
#[cfg(any(target_os = "windows", feature = "unix-file-copy-paste"))]
|
||||
use hbb_common::{allow_err, log};
|
||||
use hbb_common::{
|
||||
lazy_static,
|
||||
tokio::sync::{
|
||||
mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender},
|
||||
Mutex as TokioMutex,
|
||||
},
|
||||
ResultType,
|
||||
};
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
#[cfg(any(
|
||||
target_os = "windows",
|
||||
all(target_os = "macos", feature = "unix-file-copy-paste")
|
||||
))]
|
||||
pub mod context_send;
|
||||
pub mod platform;
|
||||
#[cfg(any(
|
||||
target_os = "windows",
|
||||
all(target_os = "macos", feature = "unix-file-copy-paste")
|
||||
))]
|
||||
pub use context_send::*;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -28,8 +36,19 @@ const ERR_CODE_INVALID_PARAMETER: u32 = 0x00000002;
|
||||
#[cfg(target_os = "windows")]
|
||||
const ERR_CODE_SEND_MSG: u32 = 0x00000003;
|
||||
|
||||
#[cfg(any(
|
||||
target_os = "windows",
|
||||
all(target_os = "macos", feature = "unix-file-copy-paste")
|
||||
))]
|
||||
pub(crate) use platform::create_cliprdr_context;
|
||||
|
||||
pub struct ProgressPercent {
|
||||
pub percent: f64,
|
||||
pub is_canceled: bool,
|
||||
pub is_failed: bool,
|
||||
}
|
||||
|
||||
// to-do: This trait may be removed, because unix file copy paste does not need it.
|
||||
/// Ability to handle Clipboard File from remote rustdesk client
|
||||
///
|
||||
/// # Note
|
||||
@@ -41,9 +60,12 @@ pub trait CliprdrServiceContext: Send + Sync {
|
||||
fn set_is_stopped(&mut self) -> Result<(), CliprdrError>;
|
||||
/// clear the content on clipboard
|
||||
fn empty_clipboard(&mut self, conn_id: i32) -> Result<bool, CliprdrError>;
|
||||
|
||||
/// run as a server for clipboard RPC
|
||||
fn server_clip_file(&mut self, conn_id: i32, msg: ClipboardFile) -> Result<(), CliprdrError>;
|
||||
/// get the progress of the paste task.
|
||||
fn get_progress_percent(&self) -> Option<ProgressPercent>;
|
||||
/// cancel the paste task.
|
||||
fn cancel(&mut self);
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
@@ -62,10 +84,12 @@ pub enum CliprdrError {
|
||||
ConversionFailure,
|
||||
#[error("failure to read clipboard")]
|
||||
OpenClipboard,
|
||||
#[error("failure to read file metadata or content")]
|
||||
FileError { path: PathBuf, err: std::io::Error },
|
||||
#[error("invalid request")]
|
||||
#[error("failure to read file metadata or content, path: {path}, err: {err}")]
|
||||
FileError { path: String, err: std::io::Error },
|
||||
#[error("invalid request: {description}")]
|
||||
InvalidRequest { description: String },
|
||||
#[error("common request: {description}")]
|
||||
CommonError { description: String },
|
||||
#[error("unknown cliprdr error")]
|
||||
Unknown(u32),
|
||||
}
|
||||
@@ -107,6 +131,7 @@ pub enum ClipboardFile {
|
||||
stream_id: i32,
|
||||
requested_data: Vec<u8>,
|
||||
},
|
||||
TryEmpty,
|
||||
}
|
||||
|
||||
struct MsgChannel {
|
||||
@@ -198,42 +223,67 @@ pub fn get_rx_cliprdr_server(conn_id: i32) -> Arc<TokioMutex<UnboundedReceiver<C
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", feature = "unix-file-copy-paste",))]
|
||||
pub fn remove_channel_by_conn_id(conn_id: i32) {
|
||||
let mut lock = VEC_MSG_CHANNEL.write().unwrap();
|
||||
if let Some(index) = lock.iter().position(|x| x.conn_id == conn_id) {
|
||||
lock.remove(index);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", feature = "unix-file-copy-paste"))]
|
||||
#[inline]
|
||||
fn send_data(conn_id: i32, data: ClipboardFile) -> ResultType<()> {
|
||||
pub fn send_data(conn_id: i32, data: ClipboardFile) -> Result<(), CliprdrError> {
|
||||
#[cfg(target_os = "windows")]
|
||||
return send_data_to_channel(conn_id, data);
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
if conn_id == 0 {
|
||||
send_data_to_all(data);
|
||||
let _ = send_data_to_all(data);
|
||||
Ok(())
|
||||
} else {
|
||||
send_data_to_channel(conn_id, data);
|
||||
send_data_to_channel(conn_id, data)
|
||||
}
|
||||
}
|
||||
#[cfg(any(target_os = "windows", feature = "unix-file-copy-paste",))]
|
||||
|
||||
#[inline]
|
||||
fn send_data_to_channel(conn_id: i32, data: ClipboardFile) -> ResultType<()> {
|
||||
#[cfg(any(target_os = "windows", feature = "unix-file-copy-paste"))]
|
||||
fn send_data_to_channel(conn_id: i32, data: ClipboardFile) -> Result<(), CliprdrError> {
|
||||
if let Some(msg_channel) = VEC_MSG_CHANNEL
|
||||
.read()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|x| x.conn_id == conn_id)
|
||||
{
|
||||
msg_channel.sender.send(data)?;
|
||||
Ok(())
|
||||
msg_channel
|
||||
.sender
|
||||
.send(data)
|
||||
.map_err(|e| CliprdrError::CommonError {
|
||||
description: e.to_string(),
|
||||
})
|
||||
} else {
|
||||
bail!("conn_id not found");
|
||||
Err(CliprdrError::InvalidRequest {
|
||||
description: "conn_id not found".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "unix-file-copy-paste")]
|
||||
#[inline]
|
||||
fn send_data_to_all(data: ClipboardFile) -> ResultType<()> {
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn send_data_exclude(conn_id: i32, data: ClipboardFile) {
|
||||
// Need more tests to see if it's necessary to handle the error.
|
||||
for msg_channel in VEC_MSG_CHANNEL.read().unwrap().iter() {
|
||||
if msg_channel.conn_id != conn_id {
|
||||
allow_err!(msg_channel.sender.send(data.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[cfg(feature = "unix-file-copy-paste")]
|
||||
fn send_data_to_all(data: ClipboardFile) {
|
||||
// Need more tests to see if it's necessary to handle the error.
|
||||
for msg_channel in VEC_MSG_CHANNEL.read().unwrap().iter() {
|
||||
allow_err!(msg_channel.sender.send(data.clone()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
use crate::{CliprdrError, CliprdrServiceContext};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub mod windows;
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -16,76 +13,14 @@ pub fn create_cliprdr_context(
|
||||
}
|
||||
|
||||
#[cfg(feature = "unix-file-copy-paste")]
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
/// use FUSE for file pasting on these platforms
|
||||
pub mod fuse;
|
||||
#[cfg(feature = "unix-file-copy-paste")]
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
pub mod unix;
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
|
||||
#[cfg(all(feature = "unix-file-copy-paste", target_os = "macos"))]
|
||||
pub fn create_cliprdr_context(
|
||||
_enable_files: bool,
|
||||
_enable_others: bool,
|
||||
_response_wait_timeout_secs: u32,
|
||||
) -> crate::ResultType<Box<dyn crate::CliprdrServiceContext>> {
|
||||
#[cfg(feature = "unix-file-copy-paste")]
|
||||
{
|
||||
use std::{fs::Permissions, os::unix::prelude::PermissionsExt};
|
||||
|
||||
use hbb_common::{config::APP_NAME, log};
|
||||
|
||||
if !_enable_files {
|
||||
return Ok(Box::new(DummyCliprdrContext {}) as Box<_>);
|
||||
}
|
||||
|
||||
let timeout = std::time::Duration::from_secs(_response_wait_timeout_secs as u64);
|
||||
|
||||
let app_name = APP_NAME.read().unwrap().clone();
|
||||
|
||||
let mnt_path = format!("/tmp/{}/{}", app_name, "cliprdr");
|
||||
|
||||
// this function must be called after the main IPC is up
|
||||
std::fs::create_dir(&mnt_path).ok();
|
||||
std::fs::set_permissions(&mnt_path, Permissions::from_mode(0o777)).ok();
|
||||
|
||||
log::info!("clear previously mounted cliprdr FUSE");
|
||||
if let Err(e) = std::process::Command::new("umount").arg(&mnt_path).status() {
|
||||
log::warn!("umount {:?} may fail: {:?}", mnt_path, e);
|
||||
}
|
||||
|
||||
let unix_ctx = unix::ClipboardContext::new(timeout, mnt_path.parse()?)?;
|
||||
log::debug!("start cliprdr FUSE");
|
||||
unix_ctx.run()?;
|
||||
|
||||
Ok(Box::new(unix_ctx) as Box<_>)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "unix-file-copy-paste"))]
|
||||
return Ok(Box::new(DummyCliprdrContext {}) as Box<_>);
|
||||
let boxed = unix::macos::pasteboard_context::create_pasteboard_context()? as Box<_>;
|
||||
Ok(boxed)
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
struct DummyCliprdrContext {}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
impl CliprdrServiceContext for DummyCliprdrContext {
|
||||
fn set_is_stopped(&mut self) -> Result<(), CliprdrError> {
|
||||
Ok(())
|
||||
}
|
||||
fn empty_clipboard(&mut self, _conn_id: i32) -> Result<bool, CliprdrError> {
|
||||
Ok(true)
|
||||
}
|
||||
fn server_clip_file(
|
||||
&mut self,
|
||||
_conn_id: i32,
|
||||
_msg: crate::ClipboardFile,
|
||||
) -> Result<(), crate::CliprdrError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "unix-file-copy-paste")]
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
// begin of epoch used by microsoft
|
||||
// 1601-01-01 00:00:00 + LDAP_EPOCH_DELTA*(100 ns) = 1970-01-01 00:00:00
|
||||
const LDAP_EPOCH_DELTA: u64 = 116444772610000000;
|
||||
|
||||
188
libs/clipboard/src/platform/unix/filetype.rs
Normal file
188
libs/clipboard/src/platform/unix/filetype.rs
Normal file
@@ -0,0 +1,188 @@
|
||||
use super::{FLAGS_FD_ATTRIBUTES, FLAGS_FD_LAST_WRITE, FLAGS_FD_UNIX_MODE, LDAP_EPOCH_DELTA};
|
||||
use crate::CliprdrError;
|
||||
use hbb_common::{
|
||||
bytes::{Buf, Bytes},
|
||||
log,
|
||||
};
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
time::{Duration, SystemTime},
|
||||
};
|
||||
use utf16string::WStr;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub type Inode = u64;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum FileType {
|
||||
File,
|
||||
Directory,
|
||||
// todo: support symlink
|
||||
Symlink,
|
||||
}
|
||||
|
||||
/// read only permission
|
||||
pub const PERM_READ: u16 = 0o444;
|
||||
/// read and write permission
|
||||
pub const PERM_RW: u16 = 0o644;
|
||||
/// only self can read and readonly
|
||||
pub const PERM_SELF_RO: u16 = 0o400;
|
||||
/// rwx
|
||||
pub const PERM_RWX: u16 = 0o755;
|
||||
#[allow(dead_code)]
|
||||
/// max length of file name
|
||||
pub const MAX_NAME_LEN: usize = 255;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct FileDescription {
|
||||
pub conn_id: i32,
|
||||
pub name: PathBuf,
|
||||
pub kind: FileType,
|
||||
pub atime: SystemTime,
|
||||
pub last_modified: SystemTime,
|
||||
pub last_metadata_changed: SystemTime,
|
||||
pub creation_time: SystemTime,
|
||||
pub size: u64,
|
||||
pub perm: u16,
|
||||
}
|
||||
|
||||
impl FileDescription {
|
||||
fn parse_file_descriptor(
|
||||
bytes: &mut Bytes,
|
||||
conn_id: i32,
|
||||
) -> Result<FileDescription, CliprdrError> {
|
||||
let flags = bytes.get_u32_le();
|
||||
// skip reserved 32 bytes
|
||||
bytes.advance(32);
|
||||
let attributes = bytes.get_u32_le();
|
||||
|
||||
// in original specification, this is 16 bytes reserved
|
||||
// we use the last 4 bytes to store the file mode
|
||||
// skip reserved 12 bytes
|
||||
bytes.advance(12);
|
||||
let perm = bytes.get_u32_le() as u16;
|
||||
|
||||
// last write time from 1601-01-01 00:00:00, in 100ns
|
||||
let last_write_time = bytes.get_u64_le();
|
||||
// file size
|
||||
let file_size_high = bytes.get_u32_le();
|
||||
let file_size_low = bytes.get_u32_le();
|
||||
// utf16 file name, double \0 terminated, in 520 bytes block
|
||||
// read with another pointer, and advance the main pointer
|
||||
let block = bytes.clone();
|
||||
bytes.advance(520);
|
||||
|
||||
let block = &block[..520];
|
||||
let wstr = WStr::from_utf16le(block).map_err(|e| {
|
||||
log::error!("cannot convert file descriptor path: {:?}", e);
|
||||
CliprdrError::ConversionFailure
|
||||
})?;
|
||||
|
||||
let from_unix = flags & FLAGS_FD_UNIX_MODE != 0;
|
||||
|
||||
let valid_attributes = flags & FLAGS_FD_ATTRIBUTES != 0;
|
||||
if !valid_attributes {
|
||||
return Err(CliprdrError::InvalidRequest {
|
||||
description: "file description must have valid attributes".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// todo: check normal, hidden, system, readonly, archive...
|
||||
let directory = attributes & 0x10 != 0;
|
||||
let normal = attributes == 0x80;
|
||||
let hidden = attributes & 0x02 != 0;
|
||||
let readonly = attributes & 0x01 != 0;
|
||||
|
||||
let perm = if from_unix {
|
||||
// as is
|
||||
perm
|
||||
// cannot set as is...
|
||||
} else if normal {
|
||||
PERM_RWX
|
||||
} else if readonly {
|
||||
PERM_READ
|
||||
} else if hidden {
|
||||
PERM_SELF_RO
|
||||
} else if directory {
|
||||
PERM_RWX
|
||||
} else {
|
||||
PERM_RW
|
||||
};
|
||||
|
||||
let kind = if directory {
|
||||
FileType::Directory
|
||||
} else {
|
||||
FileType::File
|
||||
};
|
||||
|
||||
// to-do: use `let valid_size = flags & FLAGS_FD_SIZE != 0;`
|
||||
// We use `true` to for compatibility with Windows.
|
||||
// let valid_size = flags & FLAGS_FD_SIZE != 0;
|
||||
let valid_size = true;
|
||||
let size = if valid_size {
|
||||
((file_size_high as u64) << 32) + file_size_low as u64
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let valid_write_time = flags & FLAGS_FD_LAST_WRITE != 0;
|
||||
let last_modified = if valid_write_time && last_write_time >= LDAP_EPOCH_DELTA {
|
||||
let last_write_time = (last_write_time - LDAP_EPOCH_DELTA) * 100;
|
||||
let last_write_time = Duration::from_nanos(last_write_time);
|
||||
SystemTime::UNIX_EPOCH + last_write_time
|
||||
} else {
|
||||
SystemTime::UNIX_EPOCH
|
||||
};
|
||||
|
||||
let name = wstr.to_utf8().replace('\\', "/");
|
||||
let name = PathBuf::from(name.trim_end_matches('\0'));
|
||||
|
||||
let desc = FileDescription {
|
||||
conn_id,
|
||||
name,
|
||||
kind,
|
||||
atime: last_modified,
|
||||
last_modified,
|
||||
last_metadata_changed: last_modified,
|
||||
creation_time: last_modified,
|
||||
size,
|
||||
perm,
|
||||
};
|
||||
|
||||
Ok(desc)
|
||||
}
|
||||
|
||||
/// parse file descriptions from a format data response PDU
|
||||
/// which containing a CSPTR_FILEDESCRIPTORW indicated format data
|
||||
pub fn parse_file_descriptors(
|
||||
file_descriptor_pdu: Vec<u8>,
|
||||
conn_id: i32,
|
||||
) -> Result<Vec<Self>, CliprdrError> {
|
||||
let mut data = Bytes::from(file_descriptor_pdu);
|
||||
if data.remaining() < 4 {
|
||||
return Err(CliprdrError::InvalidRequest {
|
||||
description: "file descriptor request with infficient length".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let count = data.get_u32_le() as usize;
|
||||
if data.remaining() == 0 && count == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
if data.remaining() != 592 * count {
|
||||
return Err(CliprdrError::InvalidRequest {
|
||||
description: "file descriptor request with invalid length".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut files = Vec::with_capacity(count);
|
||||
for _ in 0..count {
|
||||
let desc = Self::parse_file_descriptor(&mut data, conn_id)?;
|
||||
files.push(desc);
|
||||
}
|
||||
|
||||
Ok(files)
|
||||
}
|
||||
}
|
||||
@@ -31,33 +31,29 @@ use std::{
|
||||
};
|
||||
|
||||
use fuser::{ReplyDirectory, FUSE_ROOT_ID};
|
||||
use hbb_common::{
|
||||
bytes::{Buf, Bytes},
|
||||
log,
|
||||
};
|
||||
use hbb_common::log;
|
||||
use parking_lot::{Condvar, Mutex};
|
||||
use utf16string::WStr;
|
||||
|
||||
use crate::{send_data, ClipboardFile, CliprdrError};
|
||||
|
||||
use super::LDAP_EPOCH_DELTA;
|
||||
use crate::{
|
||||
platform::unix::{
|
||||
filetype::{FileDescription, FileType, Inode, MAX_NAME_LEN, PERM_RWX},
|
||||
BLOCK_SIZE,
|
||||
},
|
||||
send_data, ClipboardFile, CliprdrError,
|
||||
};
|
||||
|
||||
/// fuse server ready retry max times
|
||||
const READ_RETRY: i32 = 3;
|
||||
|
||||
/// block size for fuse, align to our asynchronic request size over FileContentsRequest.
|
||||
pub const BLOCK_SIZE: u32 = 4 * 1024 * 1024;
|
||||
|
||||
/// read only permission
|
||||
const PERM_READ: u16 = 0o444;
|
||||
/// read and write permission
|
||||
const PERM_RW: u16 = 0o644;
|
||||
/// only self can read and readonly
|
||||
const PERM_SELF_RO: u16 = 0o400;
|
||||
/// rwx
|
||||
const PERM_RWX: u16 = 0o755;
|
||||
/// max length of file name
|
||||
const MAX_NAME_LEN: usize = 255;
|
||||
impl From<FileType> for fuser::FileType {
|
||||
fn from(value: FileType) -> Self {
|
||||
match value {
|
||||
FileType::File => Self::RegularFile,
|
||||
FileType::Directory => Self::Directory,
|
||||
FileType::Symlink => Self::Symlink,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// fuse client
|
||||
/// this is a proxy to the fuse server
|
||||
@@ -150,9 +146,15 @@ impl fuser::Filesystem for FuseClient {
|
||||
server.release(req, ino, fh, _flags, _lock_owner, _flush, reply)
|
||||
}
|
||||
|
||||
fn getattr(&mut self, req: &fuser::Request<'_>, ino: u64, reply: fuser::ReplyAttr) {
|
||||
fn getattr(
|
||||
&mut self,
|
||||
req: &fuser::Request<'_>,
|
||||
ino: u64,
|
||||
fh: Option<u64>,
|
||||
reply: fuser::ReplyAttr,
|
||||
) {
|
||||
let mut server = self.server.lock();
|
||||
server.getattr(req, ino, reply)
|
||||
server.getattr(req, ino, fh, reply)
|
||||
}
|
||||
|
||||
fn statfs(&mut self, req: &fuser::Request<'_>, ino: u64, reply: fuser::ReplyStatfs) {
|
||||
@@ -247,7 +249,6 @@ impl fuser::Filesystem for FuseServer {
|
||||
|
||||
if parent_entry.attributes.kind != FileType::Directory {
|
||||
log::error!("fuse: parent is not a directory");
|
||||
|
||||
reply.error(libc::ENOTDIR);
|
||||
return;
|
||||
}
|
||||
@@ -480,7 +481,13 @@ impl fuser::Filesystem for FuseServer {
|
||||
reply.ok();
|
||||
}
|
||||
|
||||
fn getattr(&mut self, _req: &fuser::Request<'_>, ino: u64, reply: fuser::ReplyAttr) {
|
||||
fn getattr(
|
||||
&mut self,
|
||||
_req: &fuser::Request<'_>,
|
||||
ino: u64,
|
||||
_fh: Option<u64>,
|
||||
reply: fuser::ReplyAttr,
|
||||
) {
|
||||
let files = &self.files;
|
||||
let Some(entry) = files.get(ino as usize - 1) else {
|
||||
reply.error(libc::ENOENT);
|
||||
@@ -527,14 +534,6 @@ impl FuseServer {
|
||||
size: u32,
|
||||
) -> Result<Vec<u8>, std::io::Error> {
|
||||
// todo: async and concurrent read, generate stream_id per request
|
||||
log::debug!(
|
||||
"reading {:?} offset {} size {} on stream: {}",
|
||||
node.name,
|
||||
offset,
|
||||
size,
|
||||
node.stream_id
|
||||
);
|
||||
|
||||
let cb_requested = unsafe {
|
||||
// convert `size` from u32 to i32
|
||||
// yet with same bit representation
|
||||
@@ -554,16 +553,14 @@ impl FuseServer {
|
||||
clip_data_id: 0,
|
||||
};
|
||||
|
||||
send_data(node.conn_id, request.clone());
|
||||
|
||||
log::debug!(
|
||||
"waiting for read reply for {:?} on stream: {}",
|
||||
node.name,
|
||||
node.stream_id
|
||||
);
|
||||
send_data(node.conn_id, request.clone()).map_err(|e| {
|
||||
log::error!("failed to send file list to channel: {:?}", e);
|
||||
std::io::Error::new(std::io::ErrorKind::Other, e)
|
||||
})?;
|
||||
|
||||
let mut retry_times = 0;
|
||||
|
||||
// to-do: more tests needed
|
||||
loop {
|
||||
let reply = self.rx.recv_timeout(self.timeout).map_err(|e| {
|
||||
log::error!("failed to receive file list from channel: {:?}", e);
|
||||
@@ -590,7 +587,10 @@ impl FuseServer {
|
||||
));
|
||||
}
|
||||
|
||||
send_data(node.conn_id, request.clone());
|
||||
send_data(node.conn_id, request.clone()).map_err(|e| {
|
||||
log::error!("failed to send file list to channel: {:?}", e);
|
||||
std::io::Error::new(std::io::ErrorKind::Other, e)
|
||||
})?;
|
||||
continue;
|
||||
}
|
||||
return Ok(requested_data);
|
||||
@@ -605,160 +605,6 @@ impl FuseServer {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FileDescription {
|
||||
pub conn_id: i32,
|
||||
pub name: PathBuf,
|
||||
pub kind: FileType,
|
||||
pub atime: SystemTime,
|
||||
pub last_modified: SystemTime,
|
||||
pub last_metadata_changed: SystemTime,
|
||||
pub creation_time: SystemTime,
|
||||
|
||||
pub size: u64,
|
||||
|
||||
pub perm: u16,
|
||||
}
|
||||
|
||||
impl FileDescription {
|
||||
fn parse_file_descriptor(
|
||||
bytes: &mut Bytes,
|
||||
conn_id: i32,
|
||||
) -> Result<FileDescription, CliprdrError> {
|
||||
let flags = bytes.get_u32_le();
|
||||
// skip reserved 32 bytes
|
||||
bytes.advance(32);
|
||||
let attributes = bytes.get_u32_le();
|
||||
|
||||
// in original specification, this is 16 bytes reserved
|
||||
// we use the last 4 bytes to store the file mode
|
||||
// skip reserved 12 bytes
|
||||
bytes.advance(12);
|
||||
let perm = bytes.get_u32_le() as u16;
|
||||
|
||||
// last write time from 1601-01-01 00:00:00, in 100ns
|
||||
let last_write_time = bytes.get_u64_le();
|
||||
// file size
|
||||
let file_size_high = bytes.get_u32_le();
|
||||
let file_size_low = bytes.get_u32_le();
|
||||
// utf16 file name, double \0 terminated, in 520 bytes block
|
||||
// read with another pointer, and advance the main pointer
|
||||
let block = bytes.clone();
|
||||
bytes.advance(520);
|
||||
|
||||
let block = &block[..520];
|
||||
let wstr = WStr::from_utf16le(block).map_err(|e| {
|
||||
log::error!("cannot convert file descriptor path: {:?}", e);
|
||||
CliprdrError::ConversionFailure
|
||||
})?;
|
||||
|
||||
let from_unix = flags & 0x08 != 0;
|
||||
|
||||
let valid_attributes = flags & 0x04 != 0;
|
||||
if !valid_attributes {
|
||||
return Err(CliprdrError::InvalidRequest {
|
||||
description: "file description must have valid attributes".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// todo: check normal, hidden, system, readonly, archive...
|
||||
let directory = attributes & 0x10 != 0;
|
||||
let normal = attributes == 0x80;
|
||||
let hidden = attributes & 0x02 != 0;
|
||||
let readonly = attributes & 0x01 != 0;
|
||||
|
||||
let perm = if from_unix {
|
||||
// as is
|
||||
perm
|
||||
// cannot set as is...
|
||||
} else if normal {
|
||||
PERM_RWX
|
||||
} else if readonly {
|
||||
PERM_READ
|
||||
} else if hidden {
|
||||
PERM_SELF_RO
|
||||
} else if directory {
|
||||
PERM_RWX
|
||||
} else {
|
||||
PERM_RW
|
||||
};
|
||||
|
||||
let kind = if directory {
|
||||
FileType::Directory
|
||||
} else {
|
||||
FileType::File
|
||||
};
|
||||
|
||||
let valid_size = flags & 0x40 != 0;
|
||||
let size = if valid_size {
|
||||
((file_size_high as u64) << 32) + file_size_low as u64
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let valid_write_time = flags & 0x20 != 0;
|
||||
let last_modified = if valid_write_time && last_write_time >= LDAP_EPOCH_DELTA {
|
||||
let last_write_time = (last_write_time - LDAP_EPOCH_DELTA) * 100;
|
||||
let last_write_time = Duration::from_nanos(last_write_time);
|
||||
SystemTime::UNIX_EPOCH + last_write_time
|
||||
} else {
|
||||
SystemTime::UNIX_EPOCH
|
||||
};
|
||||
|
||||
let name = wstr.to_utf8().replace('\\', "/");
|
||||
let name = PathBuf::from(name.trim_end_matches('\0'));
|
||||
|
||||
let desc = FileDescription {
|
||||
conn_id,
|
||||
name,
|
||||
kind,
|
||||
atime: last_modified,
|
||||
last_modified,
|
||||
last_metadata_changed: last_modified,
|
||||
|
||||
creation_time: last_modified,
|
||||
size,
|
||||
perm,
|
||||
};
|
||||
|
||||
Ok(desc)
|
||||
}
|
||||
|
||||
/// parse file descriptions from a format data response PDU
|
||||
/// which containing a CSPTR_FILEDESCRIPTORW indicated format data
|
||||
pub fn parse_file_descriptors(
|
||||
file_descriptor_pdu: Vec<u8>,
|
||||
conn_id: i32,
|
||||
) -> Result<Vec<Self>, CliprdrError> {
|
||||
let mut data = Bytes::from(file_descriptor_pdu);
|
||||
if data.remaining() < 4 {
|
||||
return Err(CliprdrError::InvalidRequest {
|
||||
description: "file descriptor request with infficient length".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let count = data.get_u32_le() as usize;
|
||||
if data.remaining() == 0 && count == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
if data.remaining() != 592 * count {
|
||||
return Err(CliprdrError::InvalidRequest {
|
||||
description: "file descriptor request with invalid length".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut files = Vec::with_capacity(count);
|
||||
for _ in 0..count {
|
||||
let desc = Self::parse_file_descriptor(&mut data, conn_id)?;
|
||||
files.push(desc);
|
||||
}
|
||||
|
||||
Ok(files)
|
||||
}
|
||||
}
|
||||
|
||||
/// a node in the FUSE file tree
|
||||
#[derive(Debug)]
|
||||
struct FuseNode {
|
||||
@@ -881,7 +727,7 @@ impl FuseNode {
|
||||
format!("invalid file name {}", file.name.display()),
|
||||
);
|
||||
CliprdrError::FileError {
|
||||
path: file.name.clone(),
|
||||
path: file.name.to_string_lossy().to_string(),
|
||||
err,
|
||||
}
|
||||
})?;
|
||||
@@ -902,26 +748,6 @@ impl FuseNode {
|
||||
}
|
||||
}
|
||||
|
||||
pub type Inode = u64;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FileType {
|
||||
File,
|
||||
Directory,
|
||||
// todo: support symlink
|
||||
Symlink,
|
||||
}
|
||||
|
||||
impl From<FileType> for fuser::FileType {
|
||||
fn from(value: FileType) -> Self {
|
||||
match value {
|
||||
FileType::File => Self::RegularFile,
|
||||
FileType::Directory => Self::Directory,
|
||||
FileType::Symlink => Self::Symlink,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InodeAttributes {
|
||||
inode: Inode,
|
||||
@@ -1064,8 +890,6 @@ impl FileHandles {
|
||||
|
||||
#[cfg(test)]
|
||||
mod fuse_test {
|
||||
use std::str::FromStr;
|
||||
|
||||
use super::*;
|
||||
|
||||
// todo: more tests needed!
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user